上一篇 下一篇 分享链接 返回 返回顶部

基于香港服务器构建电商秒杀系统,如何应对库存缓存双写一致性问题?

发布人:Minchunlin 发布时间:2025-07-28 19:59 阅读量:687


大约半年前,我参与了一个港澳跨境电商项目,目标明确:为香港本地用户提供极致秒杀体验。项目上线初期的一次618促销活动中,我们遭遇了库存“穿透”和“超卖”的严重问题。虽然系统表面上用了缓存和数据库双写,但在高并发冲击下,数据不一致频频出现,最终引发了用户投诉和财务审计。

这次经历让我彻底重构了我们的库存架构,尤其在“缓存一致性”这一块做了大量实战优化。今天我就从实操角度出发,结合香港服务器部署环境,讲述我们是如何逐步解决缓存和数据库之间的一致性问题的,避免“秒杀变事故”。

一、架构背景与部署环境

1. 系统架构概览

  • 应用层:基于Spring Boot + Gateway + Redis + MySQL。
  • 缓存层:Redis Cluster 部署于香港阿里云。
  • 数据库层:RDS for MySQL,同样部署于香港,可用区内主从复制。
  • 中间件:消息队列采用 RocketMQ,限流用 Sentinel。
  • 部署环境:Kubernetes 部署于香港Region,延迟控制在5ms内。

2. 秒杀业务特性

  • 高并发读写:10万QPS以上请求同时涌入。
  • 库存敏感:库存是强一致性资源,不能有误差。
  • 时效性强:系统延迟直接影响用户体验。

二、双写一致性问题分析

1. 缓存双写是什么?

所谓缓存双写,是指业务逻辑在更新数据库的同时,也需要更新缓存,以保持数据一致性。

常见场景:

// 伪代码
updateDB();  
updateCache();

但在秒杀这种高并发场景下,这种写法会存在以下问题:

问题一:并发写覆盖

多个线程并发执行,导致后写入的缓存覆盖了前一个刚刚刷新的数据。

问题二:缓存脏读

当缓存更新早于数据库提交,读取到的是已经修改但尚未提交的缓存值,存在“幻觉”。

问题三:删除失败或延迟写回

若先删缓存后更新数据库,突然宕机,缓存未命中,用户将读到老数据。

三、常见解决方案优劣分析

方案 描述 优点 缺点
先更新数据库,再更新缓存 update DB -> update cache 数据逻辑清晰 并发写覆盖严重
先删除缓存,再更新数据库 del cache -> update DB 缓存雪崩控制较好 脏读风险高
延迟双删策略 update DB -> del cache -> 延时del cache 弹性容错较好 延迟时序依赖
订阅Binlog异步更新 基于Canal监听更新 高一致性 实时性略差,复杂度高

我们最终采用的是延迟双删 + 异步消息通知机制 + 结合Redis原子操作锁,确保强一致性与可用性之间的平衡。

四、实操方案详解:延迟双删 + 异步补偿机制

Step1:核心库存更新逻辑

@Transactional
public void deductStock(String productId) {
    // 获取分布式锁
    RLock lock = redissonClient.getLock("lock:stock:" + productId);
    lock.lock();
    try {
        // 检查库存
        int stock = stockMapper.getStock(productId);
        if (stock <= 0) {
            throw new RuntimeException("库存不足");
        }

        // 更新数据库库存
        stockMapper.deduct(productId);

        // 删除缓存
        redisTemplate.delete("stock:" + productId);

        // 延迟再次删除缓存(异步线程)
        executorService.schedule(() -> {
            redisTemplate.delete("stock:" + productId);
        }, 500, TimeUnit.MILLISECONDS);

        // 发送库存变化消息
        mqTemplate.send("stock-update", productId);
    } finally {
        lock.unlock();
    }
}

Step2:MQ消费端同步缓存补偿

@RocketMQMessageListener(topic = "stock-update", ...)
public class StockCacheRebuildConsumer implements RocketMQListener<String> {
    @Override
    public void onMessage(String productId) {
        Integer latestStock = stockMapper.getStock(productId);
        redisTemplate.opsForValue().set("stock:" + productId, latestStock);
    }
}

Step3:防止缓存击穿策略

  • 使用热点预加载:活动开始前预热库存数据到缓存。
  • 设置合理的缓存TTL+随机扰动:防止同一时间大量缓存失效。
  • 使用本地热点库存布隆过滤器避免查询空库存造成的缓存穿透。

五、测试压测与线上表现

上线前我们使用 JMeter 模拟 10万并发,压测结果如下:

指标
QPS 105,000
平均延迟 37ms
库存错误率 <0.001%
缓存命中率 98.6%
Redis延迟 <3ms

上线618期间系统稳定运行,库存准确率高达 99.999%,并未出现“超卖”或“库存错乱”的问题,业务团队表示极其满意。

六、总结与建议

经验总结:

  • 香港服务器部署延迟虽低,但依旧要关注跨可用区链路故障带来的不可用问题。
  • 缓存一致性必须在业务层设计上做兜底,技术上配合“延迟双删 + MQ 异步补偿”才靠谱。
  • 高并发下,分布式锁与预热缓存策略同等重要。
  • 建议结合 Redis Stream 或 Kafka 做更精细的变更监听和回滚策略。

技术推荐栈:

  • 缓存:Redis Cluster + Redisson 分布式锁
  • 数据库:MySQL InnoDB(开启行级锁)
  • 消息队列:RocketMQ(或Kafka)
  • 延迟任务调度:ScheduledExecutorService 或使用延迟队列中间件(如DelayQueue、RabbitMQ延迟插件)

这个项目让我深刻意识到,秒杀系统的难点不仅仅在于高并发的抗压能力,而在于“精确的库存控制”和“缓存与数据库的数据一致性”。如果你也正面临类似问题,希望这篇文章能为你带来一点启发。

目录结构
全文