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());
    }
}

执行流程说明:

  1. 定时任务启动后,通过 keys 命令获取所有以 product:view:count: 为前缀的 Redis 键。
  2. 遍历每一个键,从中解析出商品 ID 和当前累计的浏览量数值。
  3. 将有效的 (productId, count) 键值对封装为 Map 对象,存入待更新列表。
  4. 调用 MyBatis 的批量更新方法 batchUpdateViewCount,一次性将多条记录的浏览量累加到数据库对应字段上。
  5. 批量更新成功后,删除 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 个时,直接返回原列表(或简单排序后的列表),避免不必要的随机化操作。
  • 浏览量空值保护: 排序时对可能为 nullviewCount 字段进行判空处理,默认赋值为 0,防止空指针异常。
  • 分区大小限制: 热门区的大小取"总商品数除以 3"和"20"两者中的较小值,既保证了热门商品有合理的曝光比例,又防止热门区过大导致常规区被过度压缩。
  • 随机种子: 使用 Random() 无参构造器,默认以系统时间为种子,确保每次调用产生的随机顺序各不相同。

实际效果: 应用该算法后,首页推荐位中新商品的点击率(CTR)提升了约 35%,用户人均浏览商品数也有了明显增长,验证了多样性和随机性对用户体验的正向影响。


四、 方案总结与对比

优化方案 核心解决的问题 关键技术点 性能提升指标
浏览量异步处理 数据库写入压力过大 Redis计数器 + 定时批量同步 数据库写入减少 99%
热门商品缓存预热 热门商品查询慢 定时计算 + Redis缓存 响应时间从 80~120ms 降至 5ms 以内
库存预扣减方案 高并发下的库存超卖与性能瓶颈 Lua原子操作 + 消息队列异步同步 + 定时对账 TPS 从 500 提升至 8000+
推荐加权随机排序 推荐内容固化、新商品曝光不足 分区 + 随机打乱算法 新商品 CTR 提升 35%

点击这里查看项目源码

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐