外观
从0.3%不一致率到0:商品缓存双写一致性兜底实战
问题背景:大促热点商品的隐形数据不一致痛点
电商大促场景下,爆款商品详情页的QPS经常突破10万,为了扛住流量,我们通常会把商品价格、库存、标题这类热点数据缓存到Redis中,缓存过期时间设置为30分钟避免被打穿。此前团队一直采用行业通用的「先更新数据库,再删除缓存」的双写策略,但去年618大促期间我们收到了上百条用户客诉:部分用户付款后看到的商品库存、价格和实际不一致,排查后统计的不一致率约为0.3%。 深入溯源后发现,这类不一致都是极端并发时序导致的:线程A发起商品更新请求,更新完数据库之后准备执行缓存删除,此时线程B刚好发起商品查询请求,发现缓存不存在就从数据库读取了旧版本数据写回缓存,后续线程A的删除操作执行时,删除的已经是刚被写入的旧缓存,相当于脏数据被永久驻留在缓存中,直到30分钟过期才会失效。而热点商品的缓存每秒被上万次请求访问,旧数据一旦被写回就会被反复读取,进一步放大不一致的影响范围。 普通的延时双删方案只能把不一致率降到0.05%,依然无法满足大促场景下0客诉的要求,我们最终落地了一套双重兜底的缓存双写最终一致性方案,上线后完全解决了热点商品的数据不一致问题。
方案对比:4种主流缓存一致性策略优劣评估
我们先后对比了行业内常用的4种落地方案,结合商品详情页的场景特性做了筛选:
- 先删缓存再更新数据库:该方案的并发脏写概率反而远高于先更库后删缓存,只要读请求在写请求更新数据库的间隙把旧数据写回缓存,就会产生脏数据,一致性保障能力最差,直接排除。
- 延时双删策略:更新数据库后异步延迟1~2秒再执行一次缓存删除,该方案的问题是大促场景下流量抖动严重,固定延时无法覆盖所有极端时序,异步任务堆积丢数的概率很高,依然存在小概率不一致。
- 分布式读写锁方案:读写请求都加分布式锁串行执行,能从根源上避免并发时序问题,但热点接口的性能损耗超过50%,QPS直接砍半,完全无法支撑大促场景的十万级流量,性能不达标。
- 双删兜底架构:写请求侧异步发MQ触发缓存删除,同时部署Canal订阅商品表Binlog做二次删除兜底,两套机制并行工作,性能几乎无损,兜底冗余度极高,是唯一满足我们场景要求的方案。
实操步骤与Java核心代码实现
我们最终落地的方案核心逻辑非常清晰:所有商品数据变更请求执行完数据库更新之后,立刻向RocketMQ发送一条缓存删除消息,消费端执行缓存删除操作;同时部署Canal监听商品表的所有数据变更事件,只要监听到写入动作就自动触发对应缓存的删除操作,哪怕写请求侧的MQ消息丢失,Binlog的兜底删除也能把脏缓存清掉,双重保障彻底避免不一致。 核心Java代码实现如下:
java
/**
* 商品更新服务核心实现
*/
@Service
public class ProductServiceImpl implements ProductService {
@Resource
private ProductMapper productMapper;
@Resource
private RocketMQTemplate rocketMQTemplate;
// 商品缓存key前缀
private static final String PRODUCT_CACHE_KEY_PREFIX =
<!-- 内容间隙推广位:正文之后内联渲染,不遮挡阅读;变现开关关闭时零渲染零占位 -->
<MonetizeSlot position="inline" domain="backend" />
<!-- 免责声明:文字与 data/site.config.yaml 的 disclaimer 保持完全一致,不得删改 -->
::: warning 免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。
:::