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

大约半年前,我参与了一个港澳跨境电商项目,目标明确:为香港本地用户提供极致秒杀体验。项目上线初期的一次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延迟插件)
这个项目让我深刻意识到,秒杀系统的难点不仅仅在于高并发的抗压能力,而在于“精确的库存控制”和“缓存与数据库的数据一致性”。如果你也正面临类似问题,希望这篇文章能为你带来一点启发。