ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

缓存与数据库一致性实战:从Cache Aside到延迟双删与最终一致

缓存与数据库一致性实战:从Cache Aside到延迟双删与最终一致 干后端这些年几乎每个团队都会撞上同一类诡异故障用户明明支付成功订单列表却还挂着“待支付”后台编辑了商品信息前端怎么刷新都是旧数据用户改了登录密码第二天居然还能用旧密码登进系统。排查到最后大部分问题的根因都指向同一处——缓存和数据库的更新顺序出了问题。缓存、数据库、更新顺序、不一致这四个词凑到一起就是一套经典的“线上事故组合拳”。这篇内容我会把这个坑拆开揉碎讲清楚先分析为什么会不一致再给出一套从Cache Aside到延迟双删再到消息队列最终一致性的完整解法最后补上线上排查的经验。适合正在做后端开发、系统架构或者被缓存一致性折磨过的同学参考看完可以直接拿去用。1. 问题根源缓存和数据库为什么会“打架”1.1 两类存储的定位差异临时笔记本与最终账本要理解缓存和数据库不一致得先认清这两类组件的本质差异。以最常见的Redis和MySQL为例Redis是内存型数据库读写速度快到毫秒级甚至微秒级但数据存在内存里掉电就可能丢MySQL这类关系型数据库数据落在磁盘上有完整的事务保障和持久化机制但读写速度比Redis慢一个数量级以上。所以大家通常把Redis当成“临时笔记本”把MySQL当成“最终账本”。业务查询先翻临时笔记本翻不到再去账本里查查完顺手抄一份到笔记本上。这个模型天然就有一个问题笔记本上的内容和账本上的内容本质上是两份数据只要存在副本就一定存在同步延迟和同步失败的窗口。很多人一开始写缓存代码时想法很简单数据库更新了顺手把缓存也更新一下不就行了这个“顺手”恰恰就是一连串事故的开端。因为更新操作一旦涉及两个存储系统就不再是“一次写操作”而是“两次写操作”只要两次写之间有并发请求穿插进来时序就会被搅乱。1.2 两条更新路线两种踩坑方式先更新数据库再更新缓存。这是新手最容易采用的方式也是写写冲突的重灾区。假设有两个线程同时修改同一条数据线程A想把库存从100改成80线程B想把库存从100改成70。如果A先更新数据库到80B再更新数据库到70最后数据库里的值是70。但缓存这一侧由于线程调度和网络延迟的不确定性完全可能出现B先把缓存更新成70A再把缓存更新成80的结果。最终数据库是70缓存却是80两边就对不上了。先更新缓存再更新数据库。这个方案更危险。缓存更新成功后数据库更新却因为事务回滚、网络故障等原因失败了那缓存里的“新值”就彻底成了无源之水每次读都会被这个假数据误导。这种方案基本属于自掘坟墓正经业务里不建议使用。你可能会说那我让两个线程串行不就行了理论上可以但为了实现“串行”付出的代价——分布式锁、串行化队列——往往会成为新的性能瓶颈。这也是缓存一致性问题的核心矛盾强一致性和高性能天生就是冲突的。1.3 并发时机才是真正的元凶实际上比起“写写冲突”线上更常见的脏数据场景是由“读写交错”造成的。我画个经典的时间线你就明白了时间点1读请求到达发现缓存中没有数据也就是缓存Miss。时间点2读请求去数据库查查到一条旧值比如库存100。时间点3写请求到达把数据库更新到了80。时间点4读请求拿着查到的旧值100回写到了缓存。从结果看数据库是80缓存里躺着的却是100。后面所有读请求都会命中这个旧值直到缓存过期或再次被删除。这个场景之所以隐蔽是因为整个过程里没有任何一步写缓存是错乱的每一步看起来都在按顺序执行但偏偏就产出了不一致的结果。关键点在于读请求的“读数据库”和“写缓存”这两个动作之间被写请求的“更新数据库”插了一脚。这就是为什么单纯调整“更新顺序”并不能彻底解决问题因为在并发环境下读者和写者之间永远存在这种可乘之机。2. 缓存更新的黄金方案Cache Aside模式2.1 Cache Aside 的核心思想数据库为主缓存为辅在大量实践之后业界沉淀出一套相对靠谱的缓存读写模式叫作Cache Aside Pattern翻译过来就是“旁路缓存”。它的核心思想是数据库才是唯一的事实来源缓存只是旁边的一个加速层一切以数据库为准。读操作流程先读缓存。缓存命中直接返回。缓存未命中读数据库。把查询结果写入缓存。返回结果。写操作流程更新数据库。删除缓存。注意最后一步是删除缓存不是更新缓存。这是整个模式里最关键的决定我后面会单独解释为什么。这套模式之所以叫“黄金方案”是因为它在绝大多数业务场景下能以最小的代价把不一致的概率压到最低。实现简单、理解成本低、不依赖额外组件新项目直接照抄都不会出大乱子。2.2 写操作的正确姿势先更新库再删缓存写操作先更新数据库再删除缓存这一点很多人第一次听到会觉得反直觉为什么不更新缓存呢我们来分析一下这个顺序的合理性。先更新数据库的好处是数据库作为唯一数据源无论如何它的值都是最新的。缓存被删除后下一次读请求会发现缓存Miss于是去数据库读最新值再回填缓存一切恢复正常。用代码来表示最基本的写法是这样的public void updateOrderStatus(Long orderId, Integer status) { // 1. 更新数据库这一步失败则整个操作失败不会动缓存 orderDao.updateStatus(orderId, status); // 2. 删除缓存让后续读请求回填最新值 String cacheKey order:detail: orderId; redisTemplate.delete(cacheKey); }这里有个容易被忽略的细节更新数据库和删除缓存这两个动作之间理论上仍然有极短的时间窗口期间读请求可能读到旧缓存。但相比“更新缓存”的方案这个窗口已经被压缩得非常小而且缓存值仍然是“旧值”不会出现“新值被旧值覆盖”这种更棘手的场景。另外一个细节是如果删除缓存这一步失败怎么办我的建议是在业务代码里把Redis操作包一层捕获异常但不要因为缓存删除失败就让数据库事务回滚。数据库是主缓存是辅不能因为次要组件让核心数据更新失败。要解决删除失败的问题应该靠重试机制这个我在后面会展开讲。2.3 为什么是“删缓存”而不是“更新缓存”很多人不理解删掉再重新查一次多费一遍事啊直接把新值更新到缓存里不是更高效吗这个问题我踩过坑所以想认真说明白。更新缓存这个操作存在两个问题。第一写缓存需要额外的数据转换或计算。如果缓存里存的不是数据库原始字段而是聚合后的JSON、列表、统计值那每次更新数据库都需要重新计算整个缓存结构成本会成倍上升。第二更新缓存无法区分“这次写入是不是最新版本”。并发环境下两个线程同时更新缓存后写的线程不一定对应数据库的最新值写缓存完全没有顺序保障。而删除缓存是一个非常轻量级的操作删错了大不了下次读请求再回填一次天然免疫了“新旧覆盖”的问题。我在实际项目里还遇到过一种情况团队里有人把“更新缓存”和“删除缓存”混在一起用同一个数据有的接口更新、有的接口删除结果缓存里的数据一会儿是结构A一会儿是结构B下游解析直接报错。所以我的建议是一个数据项统一只采用一种写缓存策略要么全部用删除要么全部用更新千万别混着来。3. 进阶补全延迟双删与最终一致性3.1 延迟双删的原理和适用边界Cache Aside已经能解决90%的问题但开头描述的那个“读请求旧值回写缓存”的极端场景还在。延迟双删就是专门针对这个场景设计的补丁。思路很朴素第一次删除缓存后等一小段时间确保所有可能回写旧值的读请求都完成了再删一次缓存把可能重新出现的旧值清掉。完整的延迟双删流程更新数据库。删除缓存第一次删除。等待一段时间比如500毫秒。再次删除缓存第二次删除。对应到代码public void updateOrderStatusWithDelayDelete(Long orderId, Integer status) { orderDao.updateStatus(orderId, status); String cacheKey order:detail: orderId; // 第一次删除 redisTemplate.delete(cacheKey); // 延迟第二次删除 scheduledExecutorService.schedule(() - { redisTemplate.delete(cacheKey); }, 500, TimeUnit.MILLISECONDS); }需要注意的是延迟双删并不是一个“银弹”它有明显的适用边界。它解决的是“读请求在缓存删除之前miss、之后把旧值写回”的竞态问题。如果并发量极高、写请求极频繁双删之间的窗口内可能又产生了新的脏数据那就需要继续权衡。从实践看绝大多数中低并发业务用延迟双删已经足够了。另外提醒一句第二次删除的间隔不能太短否则起不到效果也不能太长否则在窗口期内缓存一直处于“被删除状态”流量可能全部打到数据库造成缓存穿透。这个时间的设置我下面单独讲。3.2 延迟时间到底怎么定延迟双删里最让人纠结的就是sleep的时间。设短了读请求还没把旧值写完第二次删除就执行了白删设长了缓存长期空窗数据库压力陡增。我的经验是延迟时间必须大于一次读请求“从缓存Miss到回写缓存”所经历的最长时间。这个时间可以这样估算测量线上读接口的TP99耗时也就是99%的请求都落在多少毫秒内再预留50%到100%的余量。假设读接口TP99是200毫秒那延迟时间取400到500毫秒比较稳妥。如果不想用sleep这么粗暴的方式可以用延迟消息队列、定时任务或者Redis的过期回调来替代把“定时删一次”的逻辑和业务线程解耦。但说实话绝大多数业务场景下一个全局的延迟调度线程池就够用了不需要为了双删专门引入一套新中间件。还有一点要强调延迟双删只能“尽可能减少”脏数据的存活时间不能做到绝对没有脏数据。所以给所有缓存设置一个合理的过期时间是最后一道保命符绝不能省略。3.3 更可靠的最终一致性消息队列与重试机制延迟双删解决不了另一个问题第一步删除就失败了怎么办如果缓存删除操作一直失败缓存里就会长期保留旧值。我的做法是引入消息队列把“删除缓存”这个动作变成一个可重试的异步任务。流程大致是业务接口里先更新数据库更新成功后发送一条“删除指定缓存Key”的消息到MQ。消费者收到消息执行缓存删除。如果删除失败消费者返回重试状态MQ会按照预设策略重复投递。超过最大重试次数后转入死信队列由人工或定时任务兜底处理。伪代码示例// 发送端业务更新后发送消息 public void updateOrderStatusWithMq(Long orderId, Integer status) { orderDao.updateStatus(orderId, status); mqTemplate.send(cache-del-topic, new CacheDeleteEvent(order:detail: orderId)); } // 消费端执行缓存删除失败则重试 RabbitListener(queues cache-del-queue) public void onCacheDelete(CacheDeleteEvent event) { try { redisTemplate.delete(event.getCacheKey()); } catch (Exception e) { // 抛出异常让MQ重试 throw new RuntimeException(delete cache failed, e); } }这套方案的核心优势有两个一是业务接口的响应时间不再被Redis操作拖累二是删除操作拥有了重试能力即使Redis瞬间抖动消息还可以在队列里等待等Redis恢复后再删。在实际生产环境中我还见过更进一步的方案——订阅数据库的Binlog变更比如用Canal监听MySQL的binlog一旦订单表发生更新自动触发对应缓存的删除。这属于“CDCChange Data Capture”思路对业务代码侵入为零但运维成本也更高。一般业务规模没到那个程度用MQ方案就足够了。4. 实操案例一个订单状态同步的全过程4.1 需求与方案选型用一个我最近在做的电商订单场景来完整演示一遍方案选型。需求是这样用户在商城下单支付后订单状态从“待支付”变成“已支付”订单列表页和订单详情页需要实时展示最新状态。首页和列表页的请求量非常大一旦支付成功用户会立即刷新页面如果没有缓存数据库会被读流量打爆但缓存更新得太激进又可能出现没支付的旧状态被读到。这个场景的约束条件有三点一是读多写少订单状态变化频率不高但查询频率很高二是对一致性要求偏高用户支付成功后不能看到“待支付”状态太长时间三是系统流量集中在整点秒杀等场景瞬时并发大。综合评估后我选择了最朴素的方案组合Cache Aside模式作为主链路加一次延迟删除兜底再加上缓存过期时间兜底。没有直接上MQ原因是订单状态写入频率不高用MQ会引入额外的组件维护成本属于过度设计。4.2 核心代码落地订单状态更新的核心代码我拆成三个部分来看。第一部分是订单服务里的更新逻辑采用Cache Aside加延迟双删Service public class OrderService { private static final String ORDER_CACHE_PREFIX order:detail:; Autowired private OrderDao orderDao; Autowired private RedisTemplateString, Object redisTemplate; Autowired private ScheduledExecutorService cacheExecutor; Transactional public void markOrderPaid(Long orderId, Integer newStatus) { // 1. 更新数据库 orderDao.updateStatus(orderId, newStatus); // 2. 第一次删除缓存 String cacheKey ORDER_CACHE_PREFIX orderId; redisTemplate.delete(cacheKey); // 3. 延迟第二次删除防止读请求旧值回写 cacheExecutor.schedule(() - redisTemplate.delete(cacheKey), 500, TimeUnit.MILLISECONDS); } }第二部分是查询逻辑走典型的Cache Aside读路径public OrderVO getOrderDetail(Long orderId) { String cacheKey ORDER_CACHE_PREFIX orderId; // 1. 查缓存 Object cached redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { return (OrderVO) cached; } // 2. 缓存未命中查数据库 OrderVO orderVO orderDao.selectDetail(orderId); if (orderVO null) { return null; } // 3. 回填缓存并设置过期时间兜底 redisTemplate.opsForValue().set(cacheKey, orderVO, 300, TimeUnit.SECONDS); return orderVO; }第三个细节是缓存过期时间的设置。我这里给了5分钟主要考虑是支付状态这种数据5分钟的极端延迟在业务上可以接受同时如果真发生缓存和数据库不一致5分钟后也会自动恢复。过期时间不要太长以前我见过有人为了“提高命中率”把过期时间设成24小时结果线上出了脏数据用户硬生生看了一整天的旧状态非常影响口碑。4.3 验证方式与压测结果代码写完怎么证明它有效我一般是三层验证。第一层单元测试。模拟一个读线程在读到一个旧值后模拟写线程更新数据库并执行双删再模拟读线程回写旧值最后断言缓存里的值是否已经被清理。这个测试能用很低的成本暴露出双删逻辑是否生效。第二层本地并发模拟。用线程池起大约50个读线程和10个写线程同时操作同一条订单数据跑一段时间后定期扫描缓存值和数据库值并做比对。多跑几轮观察是否有不一致的记录出现。第三层压测环境验证。用压测工具把QPS打到日常峰值的1.5倍观察接口耗时和错误率。注意延迟双删里那500毫秒的删除动作如果同步执行会占用业务线程所以一定要放到独立的调度线程池里不要阻塞主链路。我自己实操下来这套组合在订单场景里的表现是常规压测下接口写耗时没有明显上升读缓存命中率维持在95%以上长时间对账没有发现缓存与数据库不一致的记录。5. 常见问题与排查技巧实录5.1 线上排查三板斧即使做了各种预防线上偶尔还是会有不一致问题这是常态。遇到问题别慌我建议按照三个步骤排查。第一板斧看缓存有没有过期时间。很多缓存不一致是“缓存永不过期”造成的解决办法就是给所有缓存设置合理的过期时间这是成本最低的保底手段。第二板斧看写接口的代码顺序。进代码里确认写操作是不是“先更新库、再删缓存”。经常有人改了业务逻辑后在事务提交前删缓存导致本次删除只删了旧缓存事务一旦回滚新缓存没生成旧值反而被重新回填空窗越删越乱。第三板斧拉日志看时间线。把读请求的“缓存Miss日志”和写请求的“更新数据库日志”放在一起看定位是否存在“读请求读库在旧值时刻、写回缓存在新值时刻之后”的交错窗口。只要找到这种窗口就能确认是不是经典的读写交错问题。5.2 高频踩坑清单我把这几年的踩坑经历整理成一张速查表方便你直接对照排查。问题场景典型原因参考解法改完数据页面永远不变缓存没设过期时间旧值长期存活统一为缓存设置过期时间偶发脏数据过一会儿自愈读请求旧值回写缓存延迟双删或MQ异步兜底删除缓存后数据库被压垮双删间隔过长或缓存穿透设置合理的双删间隔加布隆过滤器缓存删除失败后无感知Redis网络抖动或故障引入消息队列重试或失败日志告警同一数据既有更新又有删除多个接口策略不一致统一缓存写策略禁止混用事务回滚了但缓存被删了缓存删除在事务提交前执行删除操作移到事务提交后的回调里5.3 日志打点与对账任务排查问题除了靠应急三板斧更可靠的办法是建立事前的监控和主动发现机制。我现在的习惯是给缓存写入和删除的关键动作打点记录操作时间、缓存Key、操作类型日志格式尽量统一方便后续检索。光有日志还不够最好再写一个定时对账任务定期扫描一批核心数据的缓存值和数据库值比对不一致的条目并上报告警。这个对账任务不用跑得特别频繁每五分钟扫描一次即可扫描的Key范围也只需要覆盖订单、库存、用户余额这类高价值数据。对账扫描的核心代码大概是这样Component public class CacheConsistencyCheckTask { Autowired private RedisTemplateString, Object redisTemplate; Autowired private OrderDao orderDao; // 每5分钟执行一次 Scheduled(fixedDelay 300_000) public void checkOrderCache() { ListLong activeOrderIds orderDao.findActiveOrderIds(); for (Long orderId : activeOrderIds) { String cacheKey order:detail: orderId; Object cachedOrder redisTemplate.opsForValue().get(cacheKey); if (cachedOrder null) { continue; } OrderVO dbOrder orderDao.selectDetail(orderId); if (!cachedOrder.equals(dbOrder)) { log.warn(cache inconsistency detected, key{}, cacheKey); // 触发删除并发送告警 redisTemplate.delete(cacheKey); } } } }这个思路本质上就是“兜底兜到最后一层”即使前面的双删、MQ重试都失效了定时对账也能把脏数据揪出来清掉。数据一致性从来不是靠某一个方案单独保证的而是由“缓存过期兜底 双删 重试 对账”这一整套防线共同支撑起来的。最后再分享一个我个人的经验讨论缓存和数据库一致性不要一开始就追求百分百强一致而是先给业务划分一个“可接受的不一致窗口”。有的数据容忍5秒有的数据容忍5分钟这个时间直接决定了你该用双删、MQ还是对账任务。把需求定清楚了技术选型自然就清晰了。很多时候团队之所以在这个问题上反复出事故不是因为缺少方案而是因为所有人都默认自己处理的是一道“非黑即白”的题忽略了业务本身才是第一约束。
返回列表