ARTICLE DETAIL

资讯详情

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

分布式缓存一致性实战:Redis缓存更新与延迟双删踩坑总结

分布式缓存一致性实战:Redis缓存更新与延迟双删踩坑总结 做分布式系统这些年缓存一致性是绕不过去的坎。几乎每个项目组到最后都会面对同一个灵魂拷问数据库里的数据明明改了Redis里怎么还是旧的用户看到的页面为什么还是好几秒之前的状态这时候有人会甩出一句“缓存延迟嘛正常”有人忙不迭地去刷新缓存、重启服务还有人一拍大腿说“干脆把缓存去掉全走数据库”。这三种处理方式我全都见过也都踩过坑。这篇博文不打算给你灌那种“看完就会”的鸡汤我直接把我这几年在分布式缓存一致性上积累的方案、参数、代码和踩坑记录整理出来你能复现多少就复现多少能避开的坑就尽量别踩。“分布式缓存一致性”这个问题本质上是数据在多个存储节点之间流转时时序和副本之间的偏差造成的。只要你的架构里同时存在数据库、缓存、多副本、异步消息这些组件一致性就是设计出来的而不是天然存在的。适合正在做微服务架构改造、或者被线上缓存数据错乱问题折磨的同学参考。内容以Redis为主但思路可以平移到任何缓存中间件上。1. 一致性问题的根源为什么缓存会“说谎”1.1 数据通路上的三个不一致来源缓存和数据库不一致通常出现在三个环节写入环节、读取环节、同步环节。写入环节最容易出问题。经典操作是先更新数据库再更新缓存。这看起来没问题但并发场景下完全不是这么回事。两个线程同时更新同一个用户余额线程A把余额从100改成200线程B把余额从200改成150。如果A先写库后写缓存B后写库但先写缓存那么数据库最终是150缓存却可能是200。这个矛盾正是“读到了新缓存、旧库”或者“旧缓存、新库”这类错乱的源头。读取环节的问题则源于回填。缓存Miss之后请求打到数据库拿数据再把结果写回缓存。这个“写回”操作如果在并发下执行没有一个统一的顺序保证回填的可能是旧版本数据。更麻烦的是回填数据还带着TTLTTL一过又触发下一次回填又可能带着更旧的数据被写回去。同步环节的坑主要在主从复制和异步链路里。数据库从库读取可能延迟缓存删除消息发送可能失败消息重试又可能乱序。这些都属于典型的“数据链路上没有全局时钟”导致的问题。听起来学术但落到线上就是订单支付成功缓存里订单状态却还是待支付怎么刷新都没用。1.2 一致性层次从最终一致到强一致在讨论方案之前必须先统一术语。缓存一致性不是一个“有/无”的开关而是一个连续的光谱。最终一致性是大多数业务系统默认接受的标准。意思是在一段不确定但有限的时间内缓存数据和数据库数据可能不一致但经过TTL过期、定时对账、后续更新等机制最终会回归一致。这种方案简单、可用性高、性能损失小适合读多写少、对实时性要求不高的大多数业务。强一致性则是只要数据发生变更任何读取操作都能立刻读到最新值。这在分布式环境里代价极高通常得引入分布式事务、全局锁、甚至串行化访问。我见过有团队为了“缓存一致性”搞出两阶段提交结果核心链路RT翻了三倍最后灰溜溜撤掉。还有一种是因果一致性介于两者之间同一用户相关的操作按顺序生效不同用户或不同维度的数据可以暂时不一致。这在社交、电商详情页等超大规模读多写少场景下很有用。选择哪种层次取决于业务能不能容忍短暂的数据偏差而不是架构师觉得哪种方案更高级。1.3 CAP约束下的现实选择聊到一致性就绕不开CAP。分区容错P在分布式架构里是必须的剩下的一致性和可用性只能二选一优先。绝大多数缓存场景业务选的是AP——先用缓存保证读取可用用最终一致来消化数据库和缓存之间的差距。这不是妥协而是成本收益的权衡。缓存本身就是性能优化手段如果为了强一致把性能拖垮那缓存也就没有存在意义了。所以我在设计一致性方案时第一件事不是选最强方案而是评估业务容忍度这个数据变了用户晚看到5秒能不能接受能接受就走延迟双删或者异步清理。不能接受就考虑强制走数据库直读或者直接上分布式锁串行化更新别把一致性方案当成所有问题的万能药。2. 主流更新策略拆解把一致性做到什么程度2.1 Cache Aside大多数项目的事实标准你要是在任何团队里问缓存怎么更新十有八九都会听到Cache Aside模式。它的规则只有两条读的时候先读缓存读不到再读数据库然后把数据写进缓存写的时候先更新数据库再删除缓存。关键就在“更新数据库后删除缓存”而不是“更新数据库后更新缓存”。为什么是删除因为缓存里的值可能是经过复杂计算的视图模型直接更新成本高而且并发更新缓存时版本控制极其恶心。删除则是一种惰性策略下次读取时按需重建天然避开了写入顺序问题。但Cache Aside并不是万无一失。删除缓存这个动作本身可能失败比如网络抖动、Redis超时。一旦删除失败后面所有读请求都会命中旧缓存直到TTL到期。所以它的完整形态至少要配一个兜底缓存删除失败时记录日志或者投递到MQ异步重试。实践里我还见过一种进阶写法数据库更新成功后把删除缓存的任务发到延迟队列延迟百毫秒再执行进一步缩小不一致窗口。2.2 Write Through与Write Back谁在用、什么时候用如果业务无法容忍缓存Miss时的回源压力可以考虑Write Through。它的逻辑是写入时同步更新数据库和缓存保证两个存储始终一致。代价是写入链路的延迟被拉高因为每次写都要等两个存储都成功。这个方案适合写频率低、但对一致性要求高的场景比如配置中心、权限数据。Write Back则完全反过来写入操作只更新缓存由后台线程异步批量刷回数据库。性能最高但崩溃丢失数据的风险也最大一旦机器宕机还没落库的缓存数据就永久丢了。这个方案在真正业务系统里基本见不到主要是CPU、存储引擎这类底层组件在用。中间件层可以用业务层我建议你别碰。有一个判断标准可以分享如果业务写入量极大、可以接受分钟级的数据丢失Write Back可以试一试如果丢一条订单记录就要追责那就老老实实回源写库。2.3 多级缓存与本地缓存引入后的新问题很多高并发项目还会做多级缓存浏览器/CDN一层、Nginx一层、本地进程内缓存一层比如Caffeine、Redis一层、数据库最底层。每多一层缓存一致性问题的复杂度就指数级上升。本地缓存最麻烦。它离应用最近、性能最好但更新策略完全依赖进程间的消息同步。比如用户信息改了你需要广播一个“失效本地缓存”的事件每个节点收到事件后执行本地清理。这个广播机制在Kubernetes环境里还相对简单但遇到跨机房、跨可用区部署事件通知的延迟和丢失问题会让本地缓存出现长时间脏读。我的经验是能不用本地缓存就不用真要用就把本地缓存的TTL缩短到分钟级同时加版本号校验。每次回源时对比版本号不一致就强制刷新。虽然不能做到实时一致至少能把“脏读”的时间控制在秒级甚至毫秒级。3. Redis场景下的一致性实操删除、延迟双删与兜底设计3.1 为什么删除缓存比更新缓存更安全前面提过Cache Aside推荐删除缓存这里展开说。假设缓存里存的是用户详情包含昵称、头像、等级。业务A更新了昵称业务B更新了头像。如果采用“更新缓存”两个业务各自把自己查询出来的整份数据写回去谁后写谁覆盖最终缓存里可能只有昵称新、头像旧或者头像新、昵称旧。这些都是错乱的脏数据。删除缓存就不存在这种问题因为删完之后任何读取都会从数据库重新拉取最新快照天然规避了并发写覆盖。这里有个很容易被忽略的细节删除的key和实际读取的key必须一一对应。Redis集群环境下如果key的设计带上了后缀比如某个维度、某个租户标识删除的时候也要一样否则就白删了。我见过不少事故都是因为删除key拼错了参数导致缓存一直命中旧数据。3.2 延迟双删的具体参数怎么定延迟双删是Cache Aside的增强版先更新数据库延迟一段时间后再次删除缓存。为什么要延迟因为在高并发场景下第一次删除后可能马上有一个读请求刚好Miss回源数据库读到了旧数据比如数据库主从延迟、或者事务还没完全提交可见然后把这个旧数据写回缓存。如果不执行第二次删除旧数据就会继续存活到TTL耗尽。延迟时间的设置是门技术活。时间太短回填操作还没完成二次删除跑在回填前面等于没删时间太长不一致窗口被拉大。我的经验值是先确认你的数据库主从延迟或者事务提交时长大致在什么量级通常几十毫秒到几百毫秒然后取一个比它稍大的值比如500毫秒到1秒。不要为了稳妥设成几分钟那会让延迟双删失去意义。延迟双删的实现也不复杂但要注意别用sleep项目里应该用延迟队列或者异步定时任务。Spring里可以直接用ThreadPoolTaskScheduler投递一个延迟任务执行第二次删除。Service public class CacheRefreshService { private final ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); Autowired private StringRedisTemplate redisTemplate; public void updateData(String key, String newValue) { // 1. 更新数据库 updateDb(newValue); // 2. 第一次删除缓存 redisTemplate.delete(key); // 3. 延迟 500ms 后第二次删除缓存 scheduler.schedule(() - redisTemplate.delete(key), new Date(System.currentTimeMillis() 500)); } }这段代码只能算基础版。延迟双删最大的隐患在于第二次删除也可能失败。所以我建议在生产环境把第二次删除的任务投递到MQ由消费者执行并提供重试机制。虽然做不到绝对可靠但至少能把成功率拉到可用水准。3.3 订阅binlog异步清理一个能直接落地的方案如果你不想在业务代码里手动写删除缓存的逻辑或者业务代码改造成本高可以走数据库binlog订阅的路线。原理是伪装成一个MySQL从库监听binlog变更事件拿到变更的key之后去删除对应的缓存。这个方案的最大优势是和业务代码完全解耦数据更新了就是更新了binlog一定会记录不用关心业务代码里是不是忘了删缓存。而且在数据库各种变更入口很多的情况下比如管理后台、数据订正脚本、定时任务都能被binlog无缝覆盖到。需要注意的点也很明确监听binlog需要额外部署一套中间件比如Canal这本身就增加了运维成本。其次binlog事件是流式的你必须维护好消费位点稍有不慎就会重复消费或者跳过事件。另外binlog里只有主键或者变更前镜像如果你想精确拼出缓存的key可能还得做一层字段映射。对于多租户、多维度key的场景这个映射逻辑会比较痛苦但这属于实现细节模式本身非常成熟。3.4 更新缓存时的分布式锁与并发控制在缓存的写回阶段其实还有一个分布式锁的经典应用场景缓存预热和缓存重建。比如一个热点商品详情缓存过期的一瞬间几千个请求同时打到数据库数据库压力瞬间飙升。这时就需要保证只有一个请求真正回源查库其他请求短暂等待或者返回旧值这个控制就是靠分布式锁实现的。Redis里实现分布式锁的经典姿势是SETNX加上过期时间。核心思想是如果锁不存在就用set key value nx ex设置一个带过期时间的锁拿到锁的线程才有资格回源数据库并重建缓存没拿到锁的线程可以选择轮询等待也可以直接返回旧值或者走降级逻辑。String lockKey lock:product: productId; String lockValue UUID.randomUUID().toString(); // 尝试获取锁过期时间 200ms Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, Duration.ofMillis(200)); if (Boolean.TRUE.equals(locked)) { try { // 回源数据库并重建缓存 Product product dbMapper.selectByPrimaryKey(productId); redisTemplate.opsForValue().set(product: productId, product, Duration.ofMinutes(10)); } finally { // 释放锁时校验 value防止误删别人的锁 if (lockValue.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } } else { // 没拿到锁短暂自旋重试或返回缓存旧值 }注意锁必须设置过期时间否则持有锁的线程崩了锁永远不会释放会形成死锁。释放锁的时候必须比较value避免线程A超时释放了线程B刚拿到的锁。这两个点面试题里经常考线上也会真实踩到。还有一个容易忽略的点200毫秒的锁过期时间如果回源数据库超过200毫秒锁已经失效此时其他线程也能进来重建缓存依然会造成并发穿透。所以锁的过期时间要根据实际回源耗时动态评估宁可长一点。4. 一致性之外的三座大山穿透、击穿、雪崩排查实录4.1 缓存穿透空值缓存与布隆过滤器的取舍缓存穿透很好理解请求查询一个根本不存在的数据缓存里没有数据库里也没有于是一个查询就白白打到了数据库。量一大数据库直接被打崩。典型的恶意攻击场景就是不断访问不存在的商品ID。最简单的解法是空值缓存把null也缓存起来设置一个较短的TTL比如几十秒。这能挡住大部分穿透流量但有一个副作用可能出现短暂的空值和真实数据并存。比如用户先查了一个不存在的ID空值被缓存几分钟后这个ID的商品真的上架了但用户依然会读到空值。所以空值缓存TTL不能太长这是一个典型的用时间差换取性能的做法。更工程化的方案是布隆过滤器。把所有可能存在的ID提前加载到布隆过滤器里查询前先判断如果布隆过滤器说不存在就直接返回压根不到缓存和数据库。但布隆过滤器存在误判率也支持不了删除操作数据量膨胀之后需要重建。我个人观点是业务量不大时空值缓存已经够用布隆过滤器更适合超大流量场景别一上来就上重型过滤器。4.2 缓存击穿单点热点key的互斥重建缓存击穿和穿透不一样穿透是查不存在的数据击穿是热点key过期瞬间所有请求都打到数据库。这时候业务数据库通常扛不住因为热点key本身的流量极大。解决击穿的核心思路就是互斥重建这一点我在前面分布式锁部分已经演示过。你还可以考虑逻辑过期时间缓存里不设置物理TTL而是给数据加一个逻辑过期字段。读取时发现逻辑过期了就立马返回旧值同时异步触发重建。这种方案的好处是请求永远不会阻塞坏处是数据一致性变得更差用户可能会看到一小段时间的旧值。适合对一致性要求不那么苛刻的场景。还有一个从业务侧化解的办法热点key不要全部同一时刻过期可以加随机时间戳让过期时间散开。这个方案简单粗暴但效果稳定尤其在批量导入缓存时特别有用。4.3 缓存雪崩过期时间打散与熔断降级雪崩比击穿更可怕因为雪崩是大面积key同时过期或者Redis实例宕机导致请求流量全部涌入数据库。数据库在几秒钟内就会被压垮恢复时间很长。应对雪崩最直接的手段是给缓存TTL加随机值。比如基础过期时间10分钟再叠加一个0到5分钟的随机值避免大量key在同一时刻过期。另一个手段是Redis高可用主从切换、哨兵、集群模式保证实例本身不挂。如果Redis真挂了业务侧要准备多级降级本地缓存顶上、限流、熔断、直接返回默认值保证数据库不被打死。有个细节值得强调降级方案必须提前写在代码里而且要经过演练。线上Redis宕机的十几分钟里你根本没有时间现场写降级逻辑。我在不少项目里发现大家嘴上说着有降级实际上代码路径根本没有真出事的时候靠人肉介入那场面极度混乱。4.4 线上排查的套路从一个告警到定位根因聊几个真实排查经验。某次告警显示商品详情接口P99延迟飙到3秒数据库CPU飙升。初步判断缓存可能失效但Redis的命中率看着又正常。后来一查发现是某款商品刚好秒杀活动缓存key设置了统一的5分钟过期活动开始时所有参与的key同时过期数据库瞬间被打满。这就是一个典型的击穿雪崩混合事故解决方式就是把过期时间加随机偏移同时给热点key做互斥重建。还有一种场景是Redis服务正常、缓存也有值但数据就是不对。先从业务链路查更新数据库和删除缓存是不是在同一个事务里事务提交失败有没有补偿删除缓存是同步还是异步如果都是对的再查是不是有另外一个定时任务或脚本在直接改库没有走正常的更新入口导致缓存一直没被删。这类“绕过了删除逻辑”的问题靠查代码往往很难最终都是通过binlog或者审计日志发现真相。5. 一致性治理监控、对账与沉淀规范5.1 缓存与数据库的定期对账方案再完善也挡不住代码里出现幺蛾子。所以我一直建议团队做一个缓存和数据库的定期对账任务。对于关键数据比如订单状态、库存、账户余额每天晚上跑一次全量或抽样比对发现不一致就自动修复或者告警。对账任务的核心逻辑并不复杂扫描一张业务表把每行数据对应的缓存值拉取出来做对比不一致时以数据库为准删除缓存或者覆盖缓存。要注意的是对账本身也会产生大量读取所以必须避开业务高峰期而且不能全表扫描要有分批、限流、游标等手段否则对账系统本身就成了新的性能炸弹。对账最麻烦的是抽样比例设定。全量对账最安全但成本最高抽样对账成本低但可能漏掉问题。我的经验是关键数据全量对账非关键数据随机抽样5%。全量对账的量级可以依赖分库分表也可以按业务ID取模分片跑。5.2 手动失效与异步补偿线上经常会有“数据订正”的需求比如价格调整、库存修正。这种场景下绝对不能只改数据库缓存失效操作必须绑定进去。我给团队定的规范是任何写库操作除了框架自动执行缓存删除之外业务代码里还必须显式调用一个失效接口不让“隐性依赖”存在于代码里。另一个实用建议是做一个“运维后门”一个管理后台页面能对任意缓存key执行删除和查看操作。这个后门在排查问题的时候极其有用不需要连Redis命令行也不需要写临时脚本。但有权限控制不能让所有研发都能执行最好还能审计操作记录。5.3 一套可落地的告警指标一致性不是一个可以直接监控的指标但可以通过间接指标发现问题。我最常用的三个核心指标是缓存命中率、回源QPS、Redis慢查询数量。命中率下降通常意味着缓存大量失效可能是TTL设置不合理也可能是热点key被批量删除。回源QPS升高则直接反映数据库压力如果同时伴随接口RT上升多半是缓存击穿或穿透。Redis慢查询能暴露大key操作和复杂度偏高的命令比如keys、hgetall这类命令在大key场景下会让单线程的Redis卡顿进一步加剧缓存更新超时。告警阈值根据业务情况设置。命中率低于90%就要留意低于80%基本要排查慢查询超过每秒10条就要考虑优化。我习惯把这些指标接入Prometheus和Grafana和业务告警打通一旦异常会直接拉群不至于等用户反馈才发现。6. 一些碎碎念的实操心得最后分享一点个人体会。分布式缓存一致性没有银弹核心原则是“让代码路径越短越好让兜底越厚越好”。不要迷信某个复杂方案也不要因为某个方案在某些极端场景下不完美就直接否定。大部分业务系统Cache Aside加TTL加定期对账已经完全够用。真正复杂的一致性需求往往不是靠技术解决的而是靠产品设计绕过去的。我最后再分享一个小技巧在写缓存更新代码时把“删除失败”“回填失败”这类异常都打上日志并带上业务主键和操作时间。这样即使一致性真出了问题你也能从日志里快速还原出事发时间点的数据流排查效率会提升一个量级。缓存一致性排查里最痛苦的不是方案不够而是没有痕迹有了痕迹一切都能追回去。
返回列表