SwapU 项目核心模块性能优化指南
SwapU 项目核心模块性能优化指南
本指南系统性地记录了项目中关于"热门商品"与"推荐系统"两大核心模块的性能优化方案,旨在解决高并发场景下普遍存在的数据库写入压力过大、查询响应缓慢以及推荐内容缺乏多样性与时效性等典型问题。以下内容将分别阐述各模块所面临的核心挑战、设计思路以及具体的落地实现。
一、 热门商品浏览缓存策略
1. 核心挑战分析
在高并发访问的电商场景下,热门商品模块面临两个主要技术挑战:
-
频繁写入造成的数据库压力: 每当用户浏览一个商品详情页,系统都需要对该商品的
viewCount(浏览量)字段进行加一操作。在流量高峰期,这种实时更新操作会转化为大量的数据库行锁竞争与日志写入,极易形成写入瓶颈,甚至导致数据库连接池耗尽,影响整体服务的稳定性。 -
高频查询导致的慢查询风险: 首页或商品列表页通常需要展示"浏览量最高"的 Top N 热门商品。如果每次请求都直接执行带有
ORDER BY view_count DESC LIMIT N的 SQL 查询,即使建立了索引,在高并发下仍会因重复的排序计算和磁盘 I/O 而产生较高的响应延迟,严重影响用户体验。
2. 解决方案设计思路
针对上述两个挑战,我们采用了"读写分离 + 异步批量同步"与"缓存预热"相结合的策略:
- 写入侧: 利用 Redis 极高的读写性能,将每次浏览量的增加操作在内存中完成,再通过定时任务将累积的变化量批量同步到数据库,从而实现"削峰填谷"。
- 查询侧: 彻底避免实时查询数据库,改为通过定时任务预先计算热门商品列表并存储到 Redis 的有序集合或列表中,所有查询请求均直接读取缓存数据。
3. 解决方案具体实现
A. 浏览量异步处理(削峰填谷)
该方案的核心是将 Redis 作为浏览量的临时存储层,用户每次浏览商品时,仅对 Redis 中的计数器执行自增操作,完全绕过数据库。随后,通过 Spring 的 @Scheduled 注解声明一个定时任务,每隔固定时间(例如 5 分钟)将 Redis 中累计的所有商品浏览量批量更新到 MySQL 数据库中。
代码实现要点:
@Scheduled(cron = "0 0/5 * * * ?")
public void syncViewCountToDB() {
log.info("开始同步浏览量到数据库");
Set<String> keys = stringRedisTemplate.keys("product:view:count:*");
if (keys == null || keys.isEmpty()) return;
List<HashMap<String, Long>> updateList = new ArrayList<>();
for (String key : keys) {
try {
Long productId = Long.parseLong(key.replace("product:view:count:", ""));
String countStr = stringRedisTemplate.opsForValue().get(key);
if (countStr == null) continue;
long count = Long.parseLong(countStr);
if (count <= 0) continue;
HashMap<String, Long> map = new HashMap<>();
map.put("productId", productId);
map.put("count", count);
updateList.add(map);
} catch (Exception e) {
log.error("解析浏览量key出错: {}", key, e);
}
}
if (!updateList.isEmpty()) {
productMapper.batchUpdateViewCount(updateList);
stringRedisTemplate.delete(keys);
log.info("同步完成:{} 条", updateList.size());
}
}
执行流程说明:
- 定时任务启动后,通过
keys命令获取所有以product:view:count:为前缀的 Redis 键。 - 遍历每一个键,从中解析出商品 ID 和当前累计的浏览量数值。
- 将有效的
(productId, count)键值对封装为 Map 对象,存入待更新列表。 - 调用 MyBatis 的批量更新方法
batchUpdateViewCount,一次性将多条记录的浏览量累加到数据库对应字段上。 - 批量更新成功后,删除 Redis 中已同步的键,避免重复处理。
优化收益: 原本每秒数百次的数据库写入操作,被压缩为每 5 分钟一次的批量更新,数据库写入压力下降了 99% 以上。
B. 热门商品缓存预热与刷新
为了彻底规避实时查询数据库带来的性能开销,我们设计了缓存预热机制,通过定时任务将计算好的 Top N 热门商品直接推送到 Redis 中。
第一步:数据库索引优化
在执行任何查询之前,首先确保数据库表具备高效的索引支持,这是保证定时任务本身能够快速执行的基础。
CREATE INDEX idx_view_count ON product(view_count DESC);
该索引能够使 ORDER BY view_count DESC LIMIT N 类型的查询通过索引顺序扫描直接获取所需数据,避免额外的文件排序操作。
第二步:定时刷新缓存
每隔 10 分钟,系统自动执行一次热门商品的刷新任务。任务内部首先调用 Mapper 方法从数据库查询当前浏览量最高的前 20 件商品,然后将旧缓存删除,再以列表形式将新的热门商品列表存入 Redis。
@Scheduled(cron = "0 0/10 * * * ?")
public void refreshHotProducts() {
log.info("刷新热门商品到 Redis...");
List<Product> hotProducts = productMapper.selectHotProducts(20);
redisTemplate.delete("hot:products");
redisTemplate.opsForList().leftPushAll("hot:products", hotProducts);
log.info("热门商品刷新完成");
}
查询路径变更: 前端或服务层需要获取热门商品时,不再调用数据库查询,而是直接从 Redis 的 hot:products 键中读取。这一变更使得热门商品接口的响应时间从原来的平均 80ms ~ 120ms 降低至 5ms 以内。
二、 热门商品库存预扣减方案
1. 核心挑战分析
在限时抢购、秒杀等高并发交易场景下,热门商品的库存扣减面临严峻的技术挑战:
- 超卖风险: 多个用户几乎同时提交订单,若按照传统方案在数据库中执行
UPDATE product SET stock = stock - 1 WHERE id = #{id} AND stock > 0操作,在高并发下虽然行锁能够保证数据一致性,但大量线程串行等待锁释放会导致严重的性能瓶颈,TPS(每秒事务数)急剧下降。 - 数据库连接资源耗尽: 每一个订单请求都直接占用一个数据库连接进行库存扣减操作,当瞬时请求量超过数据库连接池上限时,大量请求会阻塞或超时,进而引发级联故障。
- 事务长尾问题: 库存扣减通常与订单生成、支付回调等逻辑耦合在一起,长事务会导致数据库锁持有时间过长,进一步加剧性能恶化。
2. 解决方案设计思路
我们采用 “Redis 预扣减 + 异步同步 + 兜底对账” 的三层防护体系:
- 第一层(Redis 预扣减): 所有库存扣减请求首先在 Redis 中执行原子性扣减操作,利用 Redis 单线程模型天然保证原子性,避免超卖的同时提供极高的并发处理能力。
- 第二层(异步同步到数据库): 将扣减成功的操作以消息或日志形式异步写入数据库,实现最终一致性,将高频随机写入转化为低频批量同步。
- 第三层(定时对账机制): 定期对比 Redis 与数据库的库存数据,修正因网络抖动、系统异常等原因导致的数据不一致问题。
3. 解决方案具体实现
A. Redis 原子库存预扣减
在 Redis 中为每个热门商品维护一个库存计数器,所有扣减操作通过 Lua 脚本保证原子性执行:
-- 库存扣减 Lua 脚本
-- KEYS[1]: 商品库存 key
-- ARGV[1]: 扣减数量
-- ARGV[2]: 该商品允许的最大超卖阈值(可选)
local stock = tonumber(redis.call('get', KEYS[1]))
if stock == nil then
return -1 -- 库存 key 不存在
end
if stock < tonumber(ARGV[1]) then
return 0 -- 库存不足,扣减失败
end
redis.call('decrby', KEYS[1], ARGV[1])
return 1 -- 扣减成功
Java 层调用封装:
@Component
public class StockRedisService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String STOCK_PREFIX = "product:stock:";
/**
* 预扣减库存
* @param productId 商品ID
* @param quantity 扣减数量
* @return 1-成功, 0-库存不足, -1-商品不存在
*/
public int preDeductStock(Long productId, Integer quantity) {
String luaScript = loadLuaScript("stock_deduct.lua");
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class);
String key = STOCK_PREFIX + productId;
Long result = redisTemplate.execute(
redisScript,
Collections.singletonList(key),
String.valueOf(quantity)
);
return result != null ? result.intValue() : -1;
}
/**
* 回滚库存(订单取消或支付超时场景)
*/
public void rollbackStock(Long productId, Integer quantity) {
String key = STOCK_PREFIX + productId;
redisTemplate.opsForValue().increment(key, quantity);
}
}
B. 库存异步同步到数据库
预扣减成功后,系统只生成一条扣减日志放入消息队列,由消费者异步更新数据库,避免阻塞主流程:
@Component
public class StockSyncConsumer {
@Autowired
private ProductMapper productMapper;
@KafkaListener(topics = "stock-deduct-topic", concurrency = "3")
public void consumeStockDeduct(StockDeductMessage message) {
try {
// 数据库原子扣减,条件更新防止最终超卖
int affected = productMapper.atomicDeductStock(
message.getProductId(),
message.getQuantity()
);
if (affected <= 0) {
// 理论上应该成功,如果失败需要触发告警
log.error("库存同步失败,商品ID: {}, 扣减量: {}",
message.getProductId(), message.getQuantity());
// 触发补偿或告警机制
alertService.sendStockAlert(message);
}
} catch (Exception e) {
log.error("库存同步异常", e);
// 重试或落盘到死信队列
}
}
}
数据库 Mapper 方法:
<update id="atomicDeductStock">
UPDATE product
SET stock = stock - #{quantity},
sold_count = sold_count + #{quantity},
version = version + 1
WHERE id = #{productId}
AND stock >= #{quantity}
AND deleted = 0
</update>
C. 定时对账机制
为了防止 Redis 与数据库库存数据出现不一致,系统每隔 30 分钟进行一次全量对账:
@Scheduled(cron = "0 0/30 * * * ?")
public void stockReconciliation() {
log.info("开始执行库存对账任务");
// 获取所有需要监控的热门商品 ID
List<Long> hotProductIds = productMapper.selectHotProductIds();
for (Long productId : hotProductIds) {
try {
// 查询数据库实际库存
Product product = productMapper.selectById(productId);
Integer dbStock = product.getStock();
// 查询 Redis 缓存库存
String key = STOCK_PREFIX + productId;
String redisStockStr = redisTemplate.opsForValue().get(key);
if (redisStockStr == null) {
// Redis 未命中,重新初始化
redisTemplate.opsForValue().set(key, String.valueOf(dbStock));
continue;
}
Integer redisStock = Integer.parseInt(redisStockStr);
int diff = redisStock - dbStock;
if (Math.abs(diff) > 10) {
// 差异较大,需要人工介入
log.warn("库存偏差过大,商品ID: {}, Redis库存: {}, DB库存: {}, 偏差: {}",
productId, redisStock, dbStock, diff);
alertService.sendStockReconciliationAlert(productId, redisStock, dbStock);
} else if (diff != 0) {
// 微小偏差,自动修正 Redis 以数据库为准
log.info("自动修正库存,商品ID: {}, Redis: {} -> DB: {}",
productId, redisStock, dbStock);
redisTemplate.opsForValue().set(key, String.valueOf(dbStock));
}
} catch (Exception e) {
log.error("对账异常,商品ID: {}", productId, e);
}
}
}
D. 缓存预热与库存初始化
系统启动或商品上架时,需要将库存数据预热到 Redis 中:
@PostConstruct
public void initStockCache() {
log.info("开始初始化库存缓存...");
List<Product> allProducts = productMapper.selectAllActiveProducts();
for (Product product : allProducts) {
String key = STOCK_PREFIX + product.getId();
redisTemplate.opsForValue().set(key, String.valueOf(product.getStock()));
}
log.info("库存缓存初始化完成,共初始化 {} 个商品", allProducts.size());
}
优化收益:
- 并发能力大幅提升: 库存扣减接口的 TPS 从原来的 500 左右提升至 8000+,性能提升了 16 倍。
- 数据库连接压力缓解: 同步扣减转化为异步批量同步,数据库连接占用从高峰期的 80% 下降到 10% 以下。
- 超卖率降为零: 通过 Lua 脚本的原子性保证,配合条件更新的二次校验,实现了零超卖。
三、 首页智能推荐查询优化
1. 痛点分析
传统的首页推荐逻辑如果仅简单地按照商品浏览量进行降序排列,会导致以下几个严重问题:
- 内容固化,缺乏新鲜感: 高浏览量商品长期霸占首页前几位,用户每次访问看到几乎相同的内容,容易产生审美疲劳,降低浏览意愿。
- 新商品曝光机会被压制: 新上架的商品天生缺乏历史浏览量数据,在纯排序逻辑下几乎永远无法进入首页推荐区域,形成"强者恒强,弱者恒弱"的马太效应。
- 用户体验单一化: 不同用户的兴趣偏好无法被体现,所有用户看到的推荐结果完全一致,缺乏个性化与探索性。
2. 解决方案设计思路
为了解决上述痛点,我们设计了一种"分区 + 随机打乱"的加权排序算法,核心思想如下:
- 将全部待推荐商品按照浏览量从高到低排序。
- 取前三分之一(或最多 20 个)的商品作为"热门区",这些商品代表了当前最受欢迎的内容,需要给予一定的曝光权重。
- 剩余的商品归入"常规区",其中可能包含大量浏览量较低但质量不错的新品或潜力商品。
- 分别在"热门区"和"常规区"内部进行随机打乱(Shuffle),然后在最终结果中先将打乱后的热门区放在前面,再将打乱后的常规区拼接在后面。
这种处理方式既保证了高热度商品仍然能够获得前排展示的机会,又通过随机性让同一分区内的商品顺序发生变化,使得每次刷新页面都可能看到不同的排列组合,有效提升了新商品的曝光概率和首页的新鲜感。
3. 核心算法实现
以下方法完整实现了上述加权随机排序逻辑,输入为原始商品列表,输出为经过"分区随机打乱"处理后的新列表。
public static List<Product> weightedRandomSort(List<Product> products) {
if (products == null || products.size() <= 10) {
return products;
}
List<Product> sorted = new ArrayList<>(products);
sorted.sort((p1, p2) -> {
int view1 = p1.getViewCount() != null ? p1.getViewCount() : 0;
int view2 = p2.getViewCount() != null ? p2.getViewCount() : 0;
return Integer.compare(view2, view1);
});
int topSize = Math.min(sorted.size() / 3, 20);
List<Product> topProducts = new ArrayList<>(sorted.subList(0, topSize));
List<Product> restProducts = new ArrayList<>(sorted.subList(topSize, sorted.size()));
// 分区内打乱,保证新鲜感
Collections.shuffle(topProducts, new Random());
Collections.shuffle(restProducts, new Random());
List<Product> result = new ArrayList<>(topProducts);
result.addAll(restProducts);
return result;
}
算法关键点说明:
- 边界处理: 当商品总数不超过 10 个时,直接返回原列表(或简单排序后的列表),避免不必要的随机化操作。
- 浏览量空值保护: 排序时对可能为
null的viewCount字段进行判空处理,默认赋值为 0,防止空指针异常。 - 分区大小限制: 热门区的大小取"总商品数除以 3"和"20"两者中的较小值,既保证了热门商品有合理的曝光比例,又防止热门区过大导致常规区被过度压缩。
- 随机种子: 使用
Random()无参构造器,默认以系统时间为种子,确保每次调用产生的随机顺序各不相同。
实际效果: 应用该算法后,首页推荐位中新商品的点击率(CTR)提升了约 35%,用户人均浏览商品数也有了明显增长,验证了多样性和随机性对用户体验的正向影响。
四、 方案总结与对比
| 优化方案 | 核心解决的问题 | 关键技术点 | 性能提升指标 |
|---|---|---|---|
| 浏览量异步处理 | 数据库写入压力过大 | Redis计数器 + 定时批量同步 | 数据库写入减少 99% |
| 热门商品缓存预热 | 热门商品查询慢 | 定时计算 + Redis缓存 | 响应时间从 80~120ms 降至 5ms 以内 |
| 库存预扣减方案 | 高并发下的库存超卖与性能瓶颈 | Lua原子操作 + 消息队列异步同步 + 定时对账 | TPS 从 500 提升至 8000+ |
| 推荐加权随机排序 | 推荐内容固化、新商品曝光不足 | 分区 + 随机打乱算法 | 新商品 CTR 提升 35% |
更多推荐




所有评论(0)