尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Redis延迟双删策略:解决高并发下缓存与数据库数据不一致问题

Redis延迟双删策略:解决高并发下缓存与数据库数据不一致问题 1. 从一次线上数据不一致说起那天下午监控系统突然报警提示某个核心业务页面的用户积分显示和实际账户余额对不上。排查过程很典型前端显示的是缓存里的旧积分而数据库里已经完成了扣减。这种“缓存与数据库数据不一致”的问题对于用过Redis的开发者来说简直是老生常谈但又总在不经意间冒出来咬你一口。我们当时的第一反应就是去检查缓存更新策略果然代码里是简单的“先更新数据库再删除缓存”。在低并发下这没问题但在那个促销活动带来的流量洪峰下问题被放大了。这次事故让我们团队重新审视了缓存更新的可靠性。在众多解决方案中“延迟双删”策略因其相对简单的实现和较好的最终一致性保障成为了我们重点研究和落地的方案。它不是银弹有它的适用场景和坑但理解透了用对了能帮你规避掉很多麻烦。今天我就结合这次踩坑和后续的实践把Redis延迟双删策略里里外外掰扯清楚。2. 缓存不一致的根源为什么简单的“删缓存”会失效在深入双删之前我们必须先搞清楚敌人是谁。缓存不一致的根本原因通常出在“并发”这个场景下。很多人以为“先更新数据库再删除缓存”Cache-Aside Pattern是安全的但在高并发下这个操作序列会暴露出时间窗口问题。假设一个场景线程A需要更新数据。它先成功更新了数据库但在它执行删除缓存的操作之前由于网络波动、GC停顿或单纯的速度慢这个删除指令还没到达Redis。此时线程B来读取这个数据。B发现缓存是旧的或者为空触发回源于是它去数据库查询此时它读到的是线程A刚刚更新好的新数据。然后线程B很自然地把这个“新数据”写回了缓存。最后线程A姗姗来迟的“删除缓存”命令终于执行了。看起来缓存被清了对吗但关键在于如果在线程A删除之后另一个请求线程C又来读数据此时缓存为空C会去数据库查到新数据并再次写入缓存。这个流程最终是一致的。然而更隐蔽的问题出现在另一种时序下也就是我们常说的“先删缓存再更新数据库”策略的弊端。线程A先删除缓存然后去更新数据库。在A更新数据库完成之前线程B来读数据发现缓存miss于是去读数据库此时读到的是旧数据然后B把这个旧数据写回了缓存。接着A更新完了数据库。结果就是数据库里是新数据缓存里是线程B写回的旧数据不一致就此产生并且这个旧缓存会一直存在直到下一次更新或过期。延迟双删策略主要就是为了解决这第二种时序导致的不一致问题。它的核心思路是在数据库更新操作前后各执行一次缓存删除并且第二次删除是延迟执行的。目的是清除掉在“更新数据库”这个耗时操作期间可能被其他线程读请求写入的脏缓存。3. 延迟双删策略的完整操作流程与原理拆解理解了问题我们来看解决方案的具体步骤。一个完整的延迟双删操作不是两个DEL命令那么简单它是一套组合拳。3.1 标准操作步骤第一次删除在业务逻辑开始更新数据库之前首先删除Redis中的对应缓存。这一步的目的是为了确保在接下来的数据库更新期间任何新的读请求都无法从缓存中命中必然走向数据库。这为后续操作创造一个“干净”的起点。执行数据库更新执行核心的业务逻辑更新数据库如MySQL中的数据。这一步是耗时操作也是产生并发读写交织的时间窗口。提交数据库事务确保数据库更新已经持久化完成。这是关键节点在此之后数据库中的新数据才对其他事务可见。第二次删除延迟删除在数据库更新提交后并不立即执行第二次缓存删除而是提交一个延迟任务。这个延迟任务会在一个特定的时间间隔比如500毫秒或1秒后再次尝试删除同一个缓存键。3.2 每一步背后的设计意图第一次删除前置删除它的作用类似于一个“信号量”或“锁标记”。它告诉系统“这个数据正在被更新缓存已不可信”。所有后续的读请求在缓存缺失后都会去查询数据库。如果数据库事务尚未提交它们可能读到旧值在可重复读隔离级别下但这没关系因为最终它们写入缓存的也是旧值会被后续的延迟删除清理掉。延迟第二次删除这是整个策略的灵魂。为什么要延迟因为我们需要让时间“飞一会儿”。数据库更新提交后可能存在一些“正在进行中”的读请求。这些请求可能发生在步骤2和步骤3之间它们查到了旧数据并且正在执行“将旧数据写入缓存”的操作。这个“写缓存”的操作可能因为网络或Redis队列在数据库提交后才真正执行。如果我们立即进行第二次删除可能会发生以下情况立即删除操作很快执行完毕。那个携带旧数据的“写缓存”操作稍晚一点到达Redis成功将旧数据又塞了回去。结果第二次删除“删了个寂寞”旧缓存依然存在。 延迟一段时间例如500ms就是为了确保所有在数据库更新期间发起的、可能携带旧数据的“读请求写缓存”操作都已完成。这个延迟时间需要根据系统的平均耗时来评估通常要大于“一次读数据库 一次写Redis”的网络耗时和操作耗时。3.3 与“先更新数据库再删缓存”的对比很多人会混淆觉得延迟双删的第一步和“先更库再删缓存”一样。其实逻辑完全不同。先更库再删缓存它的不一致风险在于“删缓存”失败或过慢导致后续读请求在删除前将新数据写入缓存然后缓存又被删除。这个策略不处理“在更新期间有读请求写入旧缓存”的问题。延迟双删的第一步它是一个主动的、预防性的清除目的是为了在更新期间强制所有读请求穿透到数据库从而控制脏数据的来源。它和后续的延迟删除配合共同解决的是“更新期间写入旧缓存”这个核心难题。4. 延迟双删的关键技术实现与避坑指南理论很美好但落地到代码里处处是细节。实现一个健壮的延迟双删需要考虑以下几个核心环节。4.1 延迟任务的实现方案选型这是实现双删的“发动机”有几种常见选择各有优劣方案一消息队列如RocketMQ, Kafka这是最可靠、解耦程度最高的方案。实现在数据库事务提交后向消息队列发送一条延迟消息内容为缓存Key。消费者在延迟时间后收到消息执行删除操作。优点可靠性高支持重试消息不丢失。即使消费者服务重启消息仍在队列中。完全解耦更新服务无需关心删除任务。缺点系统复杂度增加需要引入和维护消息中间件。对于简单的应用有点“杀鸡用牛刀”。适用场景大型分布式系统对一致性要求高已有消息队列基础设施。方案二异步线程池 睡眠在应用内利用线程池提交一个延迟任务。// 伪代码示例 Transactional public void updateData(Data data) { // 1. 第一次删除 redisTemplate.delete(data.getCacheKey()); // 2. 更新数据库 dataMapper.updateById(data); // 3. 提交事务后提交延迟任务 CompletableFuture.runAsync(() - { try { // 延迟500毫秒 Thread.sleep(500); // 第二次删除 redisTemplate.delete(data.getCacheKey()); log.info(延迟双删执行成功key: {}, data.getCacheKey()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error(延迟双删任务被中断, e); } catch (Exception e) { log.error(延迟双删执行异常, e); // 此处可以考虑重试逻辑 } }, taskExecutor); // 使用专用的线程池 }优点实现简单无需引入外部组件。缺点可靠性差。如果应用重启或Pod调度内存中的延迟任务会丢失。线程睡眠Thread.sleep会占用线程资源在高并发下对线程池压力大。异常处理和重试机制需要自己完善。适用场景单机或小型应用对一致性要求不是极端严格可以接受极低概率的失败。方案三Redis自身过期键或Sorted Set利用Redis的EXPIRE设置一个很短的过期时间如1秒来代替第二次删除或者将删除任务作为一个元素放入ZSet用时间戳作为Score另起一个进程轮询ZSet获取到期的任务执行删除。优点利用了现有Redis无额外依赖。缺点用EXPIRE不够精确是“惰性删除”不能保证及时性。用ZSet方案需要自建轮询服务复杂度不低且可靠性取决于轮询服务。适用场景作为备选或简化方案适用于可以接受秒级不一致性的场景。实操心得对于大多数互联网业务我推荐方案一消息队列。它带来的可靠性提升远大于复杂度成本。如果业务量确实很小可以用方案二但务必做好线程池隔离和异常日志监控并心里清楚它的风险边界。4.2 延迟时间的评估与设置延迟时间delayTime是双删策略的精髓设短了可能删不干净设长了会延长缓存失效时间影响读性能。如何评估你需要统计在业务峰值下一个读请求“从发起、到查询数据库、再到将结果写入Redis”这个链路的P99或P999耗时。这个时间包括了网络往返、数据库查询、序列化等。假设这个P99时间是200ms。如何设置delayTime应略大于上述评估值。通常可以设置为评估值的2-3倍例如500ms。这提供了一个安全缓冲以应对偶发的网络抖动或数据库慢查询。动态调整这个值不是一成不变的。你可以将它设计为可配置的例如放在配置中心。通过监控“第二次删除时缓存是否仍为旧值”的比率这需要额外的日志或监控来动态调整这个延迟时间。4.3 保证删除操作的成功率与幂等性“删不掉”和“重复删”是两个需要处理的问题。失败重试无论是第一次删除还是延迟的第二次删除都必须有失败重试机制。对于消息队列方案中间件通常支持。对于线程池方案需要在catch块中实现简单的重试逻辑如最多3次每次间隔递增。操作幂等性删除缓存操作DEL key本身是幂等的。无论执行多少次结果都是key不存在。这是一个很好的特性意味着即使延迟任务因为某种原因被重复执行如消息重复消费也不会造成副作用。这是选择“删除”而非“设置新值”作为第二次操作的一个重要原因。4.4 在分布式事务与复杂更新中的考量如果你的数据库更新是一个分布式事务如涉及多个数据库或者更新操作非常复杂涉及多个步骤情况会变得更棘手。分布式事务延迟双删的第二次删除必须在所有数据库分片都提交成功后再发起。你需要有一个可靠的机制如事务协调器发送的最终提交事件来触发延迟任务而不是在单个服务的事务提交后触发。多步骤更新如果一次业务操作需要更新多张关联表那么对应的缓存Key可能不止一个。你需要清晰地识别出所有需要失效的缓存Key在第一次删除和延迟任务中对它们进行批量操作。这里容易遗漏建议通过设计规范将数据与缓存Key的映射关系固化下来。5. 延迟双删的局限性、适用场景与替代方案没有任何策略是完美的延迟双删有其明显的优缺点和适用边界。5.1 主要局限性弱一致性非强一致双删策略保证的是最终一致性。在延迟时间窗口内比如那500ms读请求仍然可能读到旧数据因为缓存被第一次删除后读请求会穿透到数据库如果此时数据库事务未提交或刚提交读到的可能是旧值。它不能提供“读写强一致”的体验。延迟时间难以精确设定设置短了可能无效设置长了增加系统不一致的时间窗口并影响性能。这个时间需要根据线上实际监控动态调整。系统复杂度提升引入了延迟任务机制无论是用消息队列还是线程池都增加了系统的运维和理解成本。对“极度热点数据”效果有限如果一个Key在毫秒级内被超高并发读写即使用了双删也可能在极短的时间窗口内发生多次“读旧数据-写脏缓存”的序列交织导致一致性窗口被放大。这时可能需要更严格的并发控制如分布式锁。5.2 最佳适用场景写后并非立即需要强一致读的场景例如后台管理员更新商品信息前台用户晚一两秒看到旧描述通常可以接受。读多写少的业务数据缓存收益高且写操作频率不高延迟双删带来的额外开销相对可接受。无法或不愿引入更重一致性方案的系统作为在“简单删除”和“复杂一致性协议”之间的一个折中方案。5.3 常见替代方案对比当延迟双删不能满足要求时可以考虑这些方案方案核心思想一致性强度性能影响复杂度延迟双删前置删除 延迟后置删除清理时间窗口内的脏缓存。最终一致性有毫秒级不一致窗口。写操作有额外延迟开销读操作在延迟期间穿透DB。中订阅数据库Binlog通过Canal、Debezium等工具监听数据库变更日志异步删除或更新缓存。最终一致性延迟取决于Binlog同步速度通常很低。对业务代码零侵入写性能无影响。高需维护中间件串行化队列将对同一数据的读写请求路由到同一个内存队列串行化处理。强一致性在队列内。严重降低并发度性能差。高分布式锁更新数据时使用分布式锁如Redis Redlock锁住Key让读写互斥。强一致性在锁持有期间。锁竞争带来性能开销可能死锁。中先更新缓存再异步更新DB写请求直接更新Redis然后异步任务同步到数据库。风险极高。弱可能丢数据。写性能极好。高数据可靠性风险经验之谈在我们的实践中对于用户核心资产如余额、库存我们采用了“分布式锁 延迟双删”的组合拳。在更新时用锁保证强一致锁释放后结合延迟双删来清理可能的残余脏数据作为双重保险。对于商品信息、配置类数据则直接使用基于Binlog订阅的缓存更新简单有效。延迟双删更像是一个在业务代码层、轻量级、可控的补救和增强措施。6. 实战中的典型问题排查与优化记录理论落地到生产环境总会遇到一些意想不到的情况。分享两个我们遇到的典型问题。6.1 问题一延迟删除后缓存依然命中旧数据现象监控发现在实施了延迟双删后偶尔仍有用户反馈看到旧数据。通过日志追踪发现第二次删除命令确实执行成功了但后续请求依然读到了旧值。排查过程检查Redis删除命令的返回结果确认是删除成功返回1而非Key不存在返回0。怀疑是删除成功但立刻又被写入了。于是在删除命令前后增加了对该Key的get操作日志。发现一个模式在删除命令执行后几毫秒内紧接着就有一个set操作值正是旧数据。分析这个set操作的调用链。发现来源是一个“读多写少”的定时任务这个任务会全量读取一批数据并刷新到缓存目的是做缓存预热。这个定时任务没有感知到双删逻辑它读取数据库的时刻正好在双删的延迟窗口内读到了旧数据然后无情地覆盖了刚刚被删除的缓存。解决方案短期调整缓存预热定时任务的执行时间避开业务高峰期和可能的双删窗口。长期改造缓存预热逻辑使其具备“版本感知”或“更新通知”能力。例如为数据增加一个版本号或更新时间戳预热程序只更新比缓存中版本更旧的数据。或者让双删模块在删除后发送一个事件预热程序监听该事件并跳过对该Key的更新。这个坑告诉我们延迟双删必须作为一个全局的缓存失效公约。任何旁路系统如定时任务、数据同步工具如果会写缓存都必须尊重和融入这个失效策略否则就会破窗。6.2 问题二线程池方案下的任务堆积与丢失现象在采用异步线程池方案初期业务高峰期发现数据不一致率有所上升。同时应用服务器的线程监控显示用于双删的线程池队列持续积压活跃线程数打满。排查过程线程池队列积压说明生产延迟任务的速度超过了消费速度。大量任务在队列中等待导致实际的删除延迟远大于我们设定的500ms。当延迟实际达到数秒甚至更久时在这段时间内新的读请求可能已经用正确的新数据重建了缓存。而那个姗姗来迟的延迟删除任务此时执行的DEL操作删除的已经是正确的新缓存了。这导致了“误删”紧接着的下一次读请求又得穿透数据库性能受损且可能引发短暂不一致。更严重的是如果此时应用重启队列中所有未执行的任务都会丢失意味着大量的第二次删除根本没有执行脏缓存会一直存在直到过期。优化措施线程池参数调优根据业务写QPS重新评估并设置了核心线程数、最大线程数和队列容量。原则是宁可适当增加线程数也不要让队列无限制积压。可以设置一个合理的队列上限并在队列满时采用合适的拒绝策略如记录日志并告警而不是默默丢弃。引入降级开关在监控到线程池负载过高时可以自动或手动降级双删策略比如只执行第一次删除跳过延迟删除并记录日志用于后续数据订正。这属于“有损服务”的范畴需要业务评估是否可接受。最终迁移至消息队列正是这个问题的出现促使我们将核心业务的延迟双删实现从线程池迁移到了RocketMQ。消息队列的持久化、削峰填谷和可靠投递特性从根本上解决了任务堆积和丢失的问题。7. 总结将延迟双删纳入你的缓存设计工具箱回顾整个延迟双删策略它本质上是一种用时间换空间一致性空间的妥协方案。它通过引入一个可控的延迟来换取对高并发下缓存不一致这一经典问题的、相对简单且有效的解法。我的体会是不要把它当成一个可以随处粘贴的“标准答案”。在引入前先问自己几个问题你的业务对一致性的要求到底是多高是秒级、毫秒级还是必须强一致你的写并发到底有多高现有的“先更库再删缓存”问题出现的频率和影响面有多大如果评估下来延迟双删是合适的那么在实现时请务必关注延迟任务的可靠性优先消息队列、延迟时间的合理设置基于监控数据、以及与之配套的监控告警如双删失败率、延迟任务积压情况。同时要把它作为一条缓存更新纪律在团队内贯彻确保所有会操作缓存的代码路径都知晓并遵守这一策略。缓存的世界没有银弹延迟双删是我们工具箱里一件趁手的、用于处理特定问题的工具。理解它的原理、边界和实现细节才能在合适的场景下把它用好真正为系统的稳定性和数据一致性保驾护航。
返回列表