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

资讯详情

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

缓存一致性实战:从并发读写到最终一致性的解决方案

缓存一致性实战:从并发读写到最终一致性的解决方案 1. 项目概述从“数据打架”到“缓存一致性”在任何一个处理高并发请求的系统里引入缓存几乎是性能优化的必经之路。但只要你把数据存了两份——一份在数据库一份在缓存里“数据打架”的问题就随之而来。用户刚更新了个人头像刷新页面一看怎么还是旧的订单状态明明已支付前台展示却还是“待付款”。这些看似微小的bug背后都是缓存一致性这个“幽灵”在作祟。它不像系统宕机那样惊天动地却如慢性毒药般侵蚀着用户体验和数据可靠性。所谓缓存一致性核心目标就一个确保缓存中的数据与底层数据源通常是数据库的数据在任何时间点对任何观察者而言在逻辑上都是一致的。这听起来像一句正确的废话但实操起来你会发现它涉及读、写、更新、失效、并发等一系列操作的精细编排任何一个环节的疏漏都可能导致短暂甚至持久的数据不一致。今天我们就抛开那些高大上的理论从一个一线工程师的视角深入聊聊如何在实际项目中系统地“解决缓存一致性问题”。我会结合常见的业务场景拆解几种主流方案的实现细节、适用边界以及我踩过的那些坑。2. 缓存一致性问题的根源与常见场景拆解在动手解决问题之前我们必须先搞清楚问题是怎么来的。缓存不一致并非凭空产生它根植于我们为了提升性能而引入的“额外副本”这一架构决策中。2.1 不一致的典型“案发现场”不一致的发生几乎总是伴随着数据的更新操作。以下是几个高频出现的场景先更新数据库后删除/更新缓存失败这是最经典的“坑点”。假设我们采用“先更新数据库再使缓存失效”的策略。当数据库更新成功但后续删除缓存的操作因为网络抖动、缓存服务短暂不可用或程序异常而失败时缓存里就留下了一份陈旧的旧数据。后续的所有读请求都会命中这份脏数据直到缓存因过期或被其他Key挤掉才会恢复。更棘手的是如果缓存没有设置过期时间这份脏数据将一直存在。并发读写下的数据覆盖在高并发场景下即使每个单独的操作序列如先更新库再删缓存看起来正确并发执行时也会产生竞态条件。例如场景A线程A需要更新数据它先成功更新了数据库。在线程A删除缓存之前线程B来读取数据它发现缓存缺失于是去数据库查询到线程A更新后的新数据并将这份新数据写入缓存。随后线程A执行了删除缓存的操作。此时缓存是空的看起来没问题。但如果在B写缓存之后、A删缓存之前的极短瞬间有另一个线程C来读它会读到B刚写入的新数据。这个不一致窗口期极短但确实存在。更糟糕的变种是如果线程A不是删除缓存而是更新缓存。那么并发时序可能变成A更新库 - B读库旧值- B写缓存旧值- A更新缓存新值。最终缓存是新值看似一致。但如果A更新缓存失败缓存里就是B写入的旧值。主从数据库延迟带来的不一致在采用读写分离架构时写操作走主库读操作可能走从库。当我们采用“先更新主库后删缓存”的策略时删除缓存后一个读请求可能被路由到尚未同步完成的从库从而读到旧数据并用这个旧数据回种了缓存。这就导致了缓存被旧数据污染。这种不一致会持续到缓存过期或从库同步完成后的下一次缓存更新。2.2 为什么“先删缓存再更新数据库”也不是银弹很多初学者会想到另一种顺序“先删缓存再更新数据库”。这个策略能避免缓存删除失败导致永久不一致的问题吗很遗憾不能而且它引入了另一个经典的并发问题。考虑如下并发序列线程A写先删除缓存。线程B读发现缓存缺失去数据库读取此时数据库还是旧数据。线程B将读到的旧数据写入缓存。线程A完成数据库的更新。结果数据库是新数据缓存是旧数据不一致发生了。而且只要后续没有写操作来删除缓存这个旧缓存会一直存在直到过期。这个问题在写后立即有读的场景下概率不低。注意这里的关键洞察是在分布式和高并发环境下任何涉及多个非原子操作的序列无论顺序如何都可能因为操作的交叉执行而导致中间状态被观察到。单纯调整“更新库”和“操作缓存”的顺序无法根治一致性问题。3. 主流缓存一致性方案深度解析与选型理解了问题根源我们来看看业界有哪些“武器”可供选择。没有一种方案是完美的关键在于根据你的业务场景对一致性的要求、并发压力、技术复杂度承受能力做出权衡。3.1 Cache-Aside Pattern旁路缓存与双删策略这是最常用、最直观的策略。应用代码直接管理缓存读时从缓存取取不到则查库并回填写时更新数据库然后使缓存失效。标准操作流程读操作读取缓存命中则返回。未命中则读取数据库。将数据库结果写入缓存。写操作更新数据库。删除缓存。如何应对不一致引入“延迟双删”针对“先更新数据库后删缓存”在并发下可能产生的不一致窗口可以采用“延迟双删”来缓解。更新数据库。删除缓存。等待一个短暂的时间例如几百毫秒到1秒。这个时间的目的是让可能发生的“读请求回填旧数据”这个操作完成。再次删除缓存。第二次删除的目的是清除掉在第一次删除后、数据库主从延迟期间可能被读请求用旧数据回填的缓存。这个等待时间需要根据你数据库的主从同步延迟来估算通常是一个经验值。实操心得与坑点等待时间难以精确设定设短了可能还有旧数据没来得及回填设长了增加写操作的延迟。通常可以通过监控主从同步延迟将其设定为平均同步延迟 几百毫秒缓冲。第二次删除也可能失败所以第二次删除最好放入一个异步重试队列如消息队列中确保最终能执行。这引入了“最终一致性”的思维。适用场景延迟双删适用于对一致性要求不是强一致但希望不一致窗口尽可能短的场景如用户信息更新、商品库存非秒杀级等。3.2 Write-Through/Write-Behind Pattern写穿透与写回这两种模式通常由缓存组件本身或中间件来提供对应用层透明。Write-Through写穿透当应用更新数据时它同时更新缓存和数据库。缓存层保证这两个更新操作在一个原子事务内完成或通过强依赖保证先后成功。这保证了缓存和数据库的强一致性。但缺点是每次写操作都有更新缓存的开销即使这个数据可能很快又被覆盖或很少被读取。通常用于写少读多、且一致性要求极高的场景。Write-Behind写回应用更新数据时只更新缓存并将数据库更新操作异步化、批量化的执行。这能提供极高的写性能因为写操作直接落在内存缓存上。但风险极大在缓存数据刷回数据库之前如果缓存宕机数据会永久丢失。这种模式对数据可靠性要求极高的业务如订单、支付基本不适用更适合用于计数、点赞等可丢失或可重建的数据。选型建议除非你使用像AWS DynamoDB Accelerator (DAX) 或某些特定商业中间件在自建系统中完整实现一个可靠的Write-Through/Write-Behind缓存层复杂度很高。大多数互联网公司更倾向于在应用层用Cache-Aside模式进行更灵活的控制。3.3 基于消息队列的最终一致性方案这是处理高并发写、降低不一致窗口的进阶方案。核心思想是将缓存失效或缓存更新的操作异步化、串行化。基本架构应用执行写操作只更新数据库。数据库更新成功后向一个消息队列如RocketMQ, Kafka发送一条“缓存失效”事件消息消息体包含需要失效的缓存Key。一个独立的缓存失效消费者服务从队列中顺序消费消息执行删除缓存的操作。如果删除失败消费者可以自动重试基于消息队列的重试机制直到成功。优势解耦与削峰写应用无需关心缓存失效是否成功也避免了缓存服务抖动对主写流程的影响。消息队列可以缓冲流量。保证最终成功利用消息队列的持久化和重试机制可以确保缓存失效操作最终一定会被执行解决了“删除缓存失败”的顽疾。易于扩展可以部署多个消费者实例来提高失效处理能力。挑战与细节顺序问题对于同一个Key的多次更新必须保证其失效消息被顺序消费否则可能发生“后发生的更新先失效缓存”的错乱。这要求消息队列支持分区内顺序消息如Kafka的PartitionRocketMQ的MessageQueue并将同一个Key的消息路由到同一个分区。消费延迟从数据库更新到缓存最终失效存在一个消息传递和消费的延迟这段时间内读请求可能读到旧数据。这属于“最终一致性”范畴。方案变体-更新缓存你也可以发送“缓存更新”消息消费者去数据库拉取最新数据并更新缓存。这比直接删除缓存一致性更强减少了缓存缺失后的回填时间但消费者逻辑更复杂且需要处理“读-改-写”的并发问题。3.4 强一致性方案探索分布式锁与串行化如果业务要求强一致性如金融账户余额、秒杀库存那么就需要更重型的武器。其核心思路是让对同一个数据项的“读数据库并回填缓存”和“更新数据库并失效缓存”这两个操作串行化。一种实现方式无论是读请求缓存未命中时还是写请求在操作目标数据前都先去获取一个针对该数据Key的分布式锁。获得锁之后如果是写请求执行“更新数据库 - 删除缓存”的流程。如果是读请求且缓存未命中执行“查询数据库 - 回填缓存”的流程。操作完成后释放锁。代价与权衡性能骤降分布式锁的引入使得针对热点数据的并发操作退化为串行完全牺牲了缓存带来的读性能优势。它只适用于那些一致性要求绝对优先且写并发极低的场景。复杂性高你需要引入和维护一个高可用的分布式锁服务如Redis Redlock, ZooKeeper并妥善处理锁超时、锁续期、避免死锁等问题。实用建议在实际业务中真正需要强一致性的数据少之又少。很多时候我们通过业务设计如将秒杀库存扣减放在Redis原子操作中异步同步回数据库来规避对数据库的强一致读写而不是在缓存一致性这个层面硬扛。4. 多级缓存与数据库主从架构下的特殊问题处理在实际生产环境中缓存可能不止一层如本地缓存分布式缓存数据库也可能有主从分离。这给一致性带来了新的维度挑战。4.1 本地缓存与分布式缓存的一致性本地缓存如Guava Cache, Caffeine速度极快但如何失效是个难题。当分布式缓存如Redis中的数据失效或更新后如何通知到所有应用实例上的本地缓存常见方案设置较短的本地缓存过期时间例如5-10秒。牺牲一部分本地缓存命中率来换取数据在一定时间窗口内自动更新。适用于数据变化不非常频繁的场景。发布订阅机制当数据在Redis中失效或更新时发布一个消息到特定频道。所有应用实例订阅该频道收到消息后主动失效自己本地缓存中的对应数据。这需要一套消息通信机制。基于版本号或时间戳本地缓存的数据附带一个版本号。读数据时不仅从本地取也去Redis获取一次该数据的版本号。如果版本号落后则失效本地缓存从Redis拉取新数据。这增加了一次网络IO但比方案1更及时。实操心得对于“用户会话信息”、“全局配置”这类变化少但一致性要求高的数据采用“发布订阅本地缓存”是很好的组合。对于“商品详情”这类海量数据采用“短过期时间”策略更简单可靠。永远要评估复杂性带来的收益。4.2 读写分离与主从延迟的应对之策在“先更新数据库主库后删缓存”的模式下主从延迟是导致不一致的一个重要原因。解决方案除了前面提到的“延迟双删”还有强制读主库对于写操作后立即需要读一致性的场景可以在写操作后的一个时间窗口内根据主从延迟确定将特定用户或特定数据的读请求强制路由到主库。这可以通过在缓存中设置一个标记如key:force_master并设置短过期时间或者通过中间件根据业务逻辑判断来实现。基于Binlog日志的缓存失效这是目前许多大厂采用的更优雅的方案。其原理是写操作更新主库。数据库的Binlog变更日志被实时捕获如通过Canal, Debezium等工具。一个独立的Binlog消费服务解析Binlog识别出数据变更然后向消息队列发送缓存失效事件或者直接调用缓存服务进行失效。缓存失效消费者处理消息。这个方案的优势在于解耦彻底业务代码完全不用关心缓存失效只需要写数据库。保证最终一致只要Binlog不丢缓存最终一定会被失效。解决主从延迟缓存失效的触发是基于主库的Binlog与从库同步状态无关。只要消费速度跟上不一致窗口就是Binlog捕获、解析、消费的延迟这个延迟通常可以控制在毫秒级。实施复杂度你需要搭建和维护一套Binlog捕获、解析和分发的数据管道技术门槛较高但一劳永逸是构建数据一致性基础设施的优良选择。5. 实战中的经典问题排查与经验沉淀理论方案很多但真到了线上问题总是以意想不到的方式出现。下面分享几个我亲身经历或高频处理过的问题场景。5.1 “缓存穿透”与“缓存雪崩”对一致性的影响这两个经典问题本身是关于可用性的但它们会恶化一致性问题的表现。缓存穿透大量请求查询一个不存在的数据如不存在的商品ID导致请求直接打到数据库。如果这个“不存在”是暂时的比如新商品刚创建缓存还没来得及预热那么写操作更新数据库后由于请求永远穿透缓存读请求永远看不到新数据直到这个Key被正常写入缓存。解决方案对查询不到的数据也缓存一个空值或特殊标记并设置一个较短的过期时间。缓存雪崩大量缓存Key在同一时间失效导致所有请求涌向数据库。在“先更新库再删缓存”的策略下如果大量写操作同时发生并触发缓存删除就可能人为制造一次“雪崩”。雪崩期间数据库压力巨大响应变慢这又可能导致“更新数据库”和“删除缓存”两个操作的间隔被拉长增大不一致窗口期的概率。解决方案对缓存失效时间加随机值避免同时失效对写操作进行限流或队列化平滑压力。5.2 热点Key更新下的惊群效应当一个热点Key如顶流明星的微博、热门秒杀商品的缓存失效时会有大量并发请求同时发现缓存缺失然后同时去查询数据库并回填缓存。这会导致数据库瞬间压力巨大并且多个请求可能回填缓存多次虽然结果一样但浪费资源。解决方案分布式锁或互斥锁Mutex Lock在回填缓存时只让一个请求去数据库查询其他请求等待。以Redis为例可以使用SETNX命令实现一个简单的互斥锁# 伪代码逻辑 def get_data(key): data cache.get(key) if data is not None: return data # 尝试获取锁锁的key可以是 “lock:” original_key lock_key lock: key if redis.setnx(lock_key, 1, expire5): # 设置5秒超时防止死锁 try: # 获取锁成功查询数据库 data db.query(key) cache.set(key, data, expire300) finally: redis.delete(lock_key) # 释放锁 return data else: # 获取锁失败等待一小段时间后重试或直接返回旧数据/错误 time.sleep(0.05) return get_data(key) # 递归重试或循环重试这个方案能有效防止数据库被击穿但也增加了读延迟等待锁的请求。需要根据热点程度权衡。5.3 监控与度量如何发现不一致一致性问题是业务逻辑错误不像性能问题有明确的QPS、延迟指标。如何发现它关键指标监控缓存与数据库值对比定期如每分钟对核心业务数据如商品价格、库存进行采样同时查询缓存和数据库对比值是否一致。不一致则告警。这个成本较高只能覆盖核心数据。写后读不一致率在写操作链路中埋点记录写操作完成的时间点和数据标识。在后续的读请求链路中如果读到了旧数据可通过版本号或时间戳判断则记录一次不一致事件。可以计算一个比例如不一致读次数 / 写后读总次数来度量。缓存删除失败率监控缓存删除操作的失败次数和失败率。这是导致不一致的直接原因之一。业务日志与用户反馈很多时候不一致是由用户反馈发现的。建立快速的问题反馈和排查通道至关重要。在关键业务操作的日志中记录数据的版本信息便于事后追溯。解决缓存一致性问题没有一劳永逸的“银弹”它是一个在性能、一致性、复杂度之间不断权衡和迭代的过程。从简单的Cache-Aside配合重试到引入消息队列解耦再到基于Binlog的终极异步失效方案技术栈在升级但核心思想始终不变识别并控制好“更新数据源”和“失效/更新缓存”这两个操作之间的时序和状态依赖关系。我个人在实际项目中的体会是优先采用简单可靠的方案如Cache-Aside 异步重试队列处理删除失败并配合适当的监控。只有当业务确实提出强一致需求且简单方案无法满足时才考虑引入分布式锁或串行化这类重型方案。大多数时候通过业务逻辑的巧妙设计例如将秒杀库存的扣减放在Redis原子操作中完成事后再异步同步扣减记录到数据库做持久化我们能够避免在缓存层面对最棘手的强一致性问题。记住架构是演进而来的合适的才是最好的。
返回列表