ARTICLE DETAIL

资讯详情

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

缓存刷新不生效?一文拆解多级缓存与数据一致性的坑

缓存刷新不生效?一文拆解多级缓存与数据一致性的坑 1. 刷新缓存失败的现场比想象中隐蔽先交代一下背景这个标题看着很朴素但它背后其实是一个很磨人的问题。刷新缓存这四个字在研发日常里通常被描述得很轻巧——你把缓存刷一下不就好了吗说这话的人大概率没被坑过。真正干过的人会知道刷新缓存从来不是执行一条命令、调一次接口那么简单它牵涉到键的设计、多级缓存的联动、刷新时机的选取甚至还有数据一致性的边界问题。我第一次被这事缠住是一个很平常的工作日下午。线上有个数据统计类接口数据被缓存了十分钟运营那边改完配置之后希望立刻看到最新值。我当时就是简单粗暴地进了 Redis 执行了一条 DEL然后满怀信心地跟对方说刷好了。结果对方刷新页面之后发现数值还是旧的。我当时的第一反应是是不是键名写错了——这个直觉有一部分是对的但远不是全部。真正的问题比键名写错要复杂得多后面排查了将近三个小时才彻底弄明白其中牵扯到序列化格式、缓存前缀、本地缓存覆盖、延迟异步刷新等等一系列东西。从那次之后我就决定把这部分工作经验系统沉淀下来这也是这篇笔记的来由。如果你也负责接口开发、数据维护或运营配置系统这篇笔记应该能帮你省下不少查日志的时间。我会从最常见的刷新缓存失败场景入手一层层拆解背后的原因然后给出我实测下来比较稳妥的解决和验证方式。不光是告诉你命令怎么写更关键的是告诉你——刷新之后怎么确认它真的刷新到位了。通常来说刷新缓存失败会有这几种表象数据还是旧的但缓存里已经不见 key 了部分用户看到的是新的部分用户看到的还是旧的刷新之后立刻是新的过几分钟又回退成旧的还有一种是接口报错直接拿不到数据。这些表象看着差别很大但它们指向的根因翻来覆去也就是那几类下面逐一拆开说。2. 根因拆解为什么刷新了却没有生效2.1 你删的 key和业务读的 key 可能根本不是同一个这是新手最容易踩的坑也是排查时必须先排除掉的一环。你在 Redis 里删了一个看起来正确的 key但应用代码里实际读的 key 可能经过了一层拼接。举个例子很多团队习惯用统一前缀加业务维度组成 key比如user:123:profile但是写 key 的时候用的是String.format(user:%s:profile, userId)而查 key 的时候用的是CacheKeyUtil.buildUserProfileKey(userId)如果这个工具方法里拼接顺序不一样或者中间的连接符从冒号换成了下划线那么你手动删的 key 和代码在读的 key 就完全对不上——你删了等于没删旧数据还是稳稳躺在原 key 里还会继续被读到。这块的隐蔽性在于Redis 命令本身不会报错DEL 返回的也是 1看起来一切正常但实际什么都没刷掉。所以我在出问题时第一个动作不是去删而是去查。先看看业务代码里到底封装了什么样的 key 生成逻辑再拿真实的入参去模拟生成一次得到确切的 key 字符串这才去执行删除操作。这个习惯看着多花了一分钟实际上能省掉后面一小时甚至更久的折腾。另外还有一个容易被忽略的相似场景键名里带了环境或版本号。比如预发环境和生产环境用同一个 Redis 实例的团队很多人会在 key 里拼 env 标识删的时候只记得删config:data忘了还有config:data:prod和config:data:staging两套结果只刷掉了其中一套线上当然还是旧的。2.2 刷新后重新写入的可能是一份过期数据这个问题比 2.1 要隐蔽得多因为它涉及的已经不是缓存层本身而是缓存和数据源之间的一致性。我当时遇到的那个案例重启之后就出现这个症状删除缓存后应用自动回源重新加载数据到缓存里但加载出来的还是旧值。一开始百思不得其解后来查了代码才发现回源加载的数据来自一个中间表而这个中间表本身是由一个异步 Job 从另一个库里同步过来的同步周期是五分钟。也就是说运营在两分钟前改了源数据但中间表里存的本质上还是五分钟前的快照我刷新缓存只是在刷新一个注定会拿到旧数据的流程。这个场景在实际工作中非常常见尤其是涉及多系统联动、数据仓库同步、消息异步通知更新缓存这一类链路的时候。缓存只是数据链条的最后一公里前面任何一环有延迟光刷缓存都没用。排查思路是别只盯着缓存层问为什么还是旧的要把数据链条往前推一层看看数据在到达缓存之前经过了多少个环节每个环节的时效性分别是什么。可以用一个比较笨但很管用的方法——把当前读到的值和数据库中的值、中间表中的值放在同一时刻做对比看它到底卡在哪个环节里。一般来说会得到三种结果数据库里本身就是旧的那问题不在缓存数据库是新的但中间表是旧的那问题在同步链路中间表是新的但缓存是旧的那才真正轮到缓存刷新出马。2.3 你只刷了一层缓存但上面还有一层本地缓存这是分布式系统里特别经典的一个坑。很多服务为了追求性能会在应用内部用 Caffeine 或 Guava Cache 做一层本地缓存再在 Redis 里做一层分布式缓存。你在 Redis 里删 key 删得再干净应用进程内的本地缓存可能还存着旧值甚至本地缓存的过期时间设得比 Redis 还长这种情况下你刷 Redis 一百遍效果也是零。这种问题的排查难点在于它时好时坏。同一个 key在这台机器上刷新后立即生效因为你恰好连到了没命中本地缓存的那台节点在另一台机器上却还是旧值因为它进程内的本地缓存还活着。如果服务后面接了多实例负载均衡用户请求随机打到不同节点上就会出现那种最让人头疼的表现——一批用户看到新数据另一批用户看到旧数据过了很久才慢慢统一过来。处理办法也很明确如果你确认服务里有本地缓存这一层那就要同时处理两件事——删 Redis key以及触发本地缓存的主动失效。主动失效有很多种方式比较通用的做法是引入一个消息通知机制比如通过 Redis 的 Pub/Sub 广播一个某某 key 已失效的事件所有服务节点收到后清掉自己的本地缓存。注意这里要用广播而不能用点对点队列因为每台机器上的本地缓存都是独立副本必须都通知到。我在实际的系统里是这么处理的提供一个统一的缓存刷新接口这个接口做三件事先删远程缓存再往广播频道发失效消息最后记录一个操作日志方便事后追溯。效果比单纯执行一条 DEL 命令要可靠得多。2.4 并发下删早导致旧值回填这个场景我说它是刷新缓存界的钓鱼执法。你以为刷成功了验证的时候也确实看到缓存被删掉了反复刷新几次页面也都是新的正当你准备收工用户的反馈又来了——又变回旧的了。这里面的根因是并发回填在缓存被删掉的那一瞬间仍然有大量请求正在执行旧数据的查询和回填逻辑它们在你删除操作之前就已经开启了回源流程持有的还是旧数据只不过回填动作晚了一拍导致你删完之后它们又堂而皇之地把旧值写回了缓存。用大白话讲就是你删的速度赶不上别人写的速度。解决这个问题业界比较成熟的方案有几个。一种叫更新式刷新不去删 key而是直接把新值写入缓存这样就不会留下回填的窗口期。但这么做的条件是你必须已经拿到了最新数据适合那种配置修改后主动推送缓存的场景。另一种是双删策略第一次删除让旧请求回填等一小会儿比如一两秒然后再删一次。这样做的目的是在旧请求基本回填完毕后再清理一轮尽量清掉残留的旧值。这么做不是完美无缺但确实能把概率压到很低。第三种更稳妥的做法是给缓存加版本号写入的时候带上版本号读取的时候校验版本号不匹配就重新回源这就彻底绕开了删不干净的问题。从工程实践角度我不建议无脑选择更新式刷新因为它有一个前提条件新值来源必须是可靠的、一次性的。在配置类场景下没问题但在复杂数据聚合类场景下更新的数据本身可能也只是某个中间结果这时候用双删加版本号组合会更稳妥。3. 从表象到根因完整的排查链路3.1 第一步确认缓存没了但数据没变还是数据没变也没了很多人排查的第一步就错了——他们第一时间去翻业务代码试图从逻辑上推断问题。我的习惯是反过来先在缓存层做物理验证。打开 Redis 命令行工具执行一次 TTL 查询和 GET 查询看看目标 key 当前的存活状态和实际值。这一步能帮我们快速判定问题的相位。如果 TTL 返回 -2说明 key 已经不存在了那问题就从缓存没被删掉变成了缓存被删了但新值不对或缓存被删了但读取链路没走缓存。如果 TTL 返回 -1 或一个正数说明 key 还活着那就回到 2.1 里说的键名核对流程——你删的和别人读的到底是不是同一个。这里我会顺便记录几个数据快照当前 Redis 中的旧值、当前数据库中的新值、当前接口返回给用户的值。三个值一对比问题的边界立刻就清晰了一大半。现象可能原因排查方向Redis key 已删接口仍返回旧值本地缓存未清除检查服务内 Caffeine 缓存确认是否存在多级缓存Redis key 已删接口返回报错回源路径异常可能依赖的库表不可用检查数据源健康状态关注 DB 连接池Redis key 在但值不是最新键名不匹配或刷新的是另一个 key比对代码中的 key 生成逻辑刚刷完是新的过一阵变旧并发旧值回填观察时间窗排查是否有异步回填逻辑这三列数据一旦记录下来后面的排查路径就不太会跑偏了。别嫌这一步麻烦缓存问题最怕的就是毫无目的地东翻西看。确定了相位之后再去翻代码和日志就有的放矢了。3.2 第二步沿着数据链路走一遍找到在哪一层变旧了如果确认缓存确实被刷新了但值不对那就进入链路排查阶段。我的做法是把数据从源头到展示给用户按顺序拆成几个环节在每一层的入口和出口打上标记。以我经历的那个案例为例链条是这样的运营配置写进主数据库、一个同步任务把主库数据搬运到查询库、应用查询时先看 Redis 缓存、未命中则读查询库。明确了链条之后我在每个环节做了一次读一下当前值的操作记录时间和值的内容。结果很清楚主库的值已经是新的查询库里的值还是旧的等于问题出在同步任务上。这时候再回头去查同步任务为什么没跑发现是同步任务挂了所以数据根本没流过来。这也就解释了为什么反复刷新 Redis 都没用——所有刷新动作都在给一条不流通的管道注水。排查这条链路时有一个小技巧特别实用不要只在一个时间点做采样建议隔几分钟做两次采样因为有些同步任务是分钟级的你第一次采样时可能恰好赶上同步间隙数据还没过来。间隔采样能排除掉只是时机不凑巧这种假象。3.3 第三步把偶发性问题和不稳定因素区分开最头疼的一类问题是偶发性很强你连续复现五次都正常第六次突然就旧了等你想抓现场它又恢复正常了。这种时候靠人肉点按钮是抓不到的必须借助监控和日志。如果你们的服务有链路追踪系统的话把接口请求的 traceId 和相关日志拉出来看看那次返回旧值请求的具体执行路径它到底是从缓存取的还是回源取的如果是从缓存取的那缓存键是什么如果是回源取的回源后有没有写入新缓存。这些内容都会留在日志里只是平时没人去翻而已。还有一种情况必须注意——本地调试工具和线上环境行为不一致。比如你在本地连的都是同一个 Redis但本地代码里可能开启了一个禁用缓存的开关导致你调试时从来不会命中缓存看起来什么问题都没有但线上是正常的缓存链路当然表现不同。所以我排查时永远先确认一件事当前这个请求到底走没走缓存代码分支有没有被开关或环境变量改变过行为。3.4 排查时随手要记的几条命令和工具这里把我在上述排查链路里最常用的命令和工具整理出来方便直接抄作业redis-cli -n 0 DEL key删除指定库里的键注意 Redis 默认有 16 个库别删错库了。redis-cli --scan --pattern user:*:profile模糊查找键名用于定位键名拼写是否一致。redis-cli TTL key查看剩余过期时间-1 表示永久有效-2 表示键不存在。redis-cli --bigkeys扫描大键当你怀疑缓存对象过大导致回填耗时过长时用。生产环境优先使用具备审计功能的控制台或运维平台避免直接暴露可执行任意命令的权限这个后面会细说。在非生产环境我还会用redis-cli MONITOR命令来实时监听一段时间内的键读写操作。它能捕获当前正在被访问的 key对于确认业务实际在读哪个 key 非常有效。生产环境慎用因为它会影响性能但在预发环境或用低峰期窗口执行是很高效的定位手段。4. 从源头解决设计一套可复现的缓存刷新流程4.1 别靠人肉操作把刷新动作收敛成统一入口排查做了很多次之后我最大的体会是刷新缓存这件事最大的风险来源其实是人。人记错了键名、人删错了库、人漏了本地缓存、人忘了通知下游——所有这些人为失误叠加起来才让一次简单的刷新变得危机四伏。所以后来我在团队里推动的第一件事就是把散落在各处的缓存刷新操作收敛成一个统一入口。具体做法是做一个简单的缓存管理接口接受一个业务键名作为入参然后由代码内部的统一逻辑去执行完整的刷新动作。这个接口的内部实现长这样public void refreshCache(String bizKey) { // 1. 从配置中心或数据库中获取该业务键对应的所有 redisKey 模板 ListString redisKeys cacheKeyMappingService.getTargetKeys(bizKey); // 2. 依次删除远程缓存 redisTemplate.delete(redisKeys); // 3. 广播本地缓存失效消息 cacheInvalidatePublisher.publish(redisKeys); // 4. 写审计日志 auditLogger.log(bizKey, redisKeys, System.currentTimeMillis()); }这个方案看起来不复杂但它解决了几个关键问题一是键名不再依赖人的记忆而是由代码依据配置自动生成杜绝了 2.1 里那个坑二是刷新动作天然覆盖了远程缓存和本地缓存两层不用人去记还要不要清本地缓存三是所有刷新操作都留痕出了问题能追溯。你可能会说这不就是给运维多做了个接口嘛有什么大不了的。其实差别很大。没有统一入口之前每次刷新都是临时的、手工的、不可复现的有统一入口之后刷新成了一个定义明确的、可测试的、可回滚的操作。这两者在生产环境的稳定性上完全不是一个量级。4.2 刷新之前的预校验与刷新之后的确认单在把刷新动作做成接口之后我又在业务流程上加了两道保险。第一道保险是刷之前的预校验。刷新动作执行前系统先自动做一轮检查目标键在 Redis 中是否存在、数据库中的最新值是什么、当前缓存值与数据库值是否一致。如果一致那就根本不值得刷新直接返回无需刷新即可。这能减少很多无效操作也能避免那种运营觉得数据不对其实是自己看错了的乌龙。第二道保险是刷之后的确认单。接口执行完删除和广播动作后会自动触发一个验证任务等待一小段时间后重新读取该键回源后缓存中的新值是什么与数据库比对是否一致然后把这些结果汇总成一段摘要返回给调用方。有了这个确认单操作的人立刻就能知道本轮刷新是否真的把链条串起来了不用再去页面反复刷新验证。这两道保险的代码逻辑都比较直接核心其实就一句话把验证从人肉观察升级为系统自动比对。实践中效果非常明显团队里后来再也没出现过刷完之后告诉别人好了结果过了半小时发现根本没刷掉的尴尬局面。4.3 刷新接口的权限与灰度控制别让所有人都有删除权缓存刷新操作本质上是一个高危动作。一个配置类的 key 被误删还好说回源就能恢复但如果存在防止缓存穿透这类保护性缓存被误删之后又碰上高并发流量数据库被一波打穿也不是没可能。所以在做统一入口的同时权限控制一定要跟上。我的建议是至少做两层权限。第一层是操作人权限只有持有运维角色或指定负责人角色的人才能调用刷新接口普通业务人员只能查看不能删除。第二层是键范围权限把 key 按重要程度分层——核心交易链路相关的缓存、非核心业务缓存、短生命周期缓存——不同层级的操作频率和审批流程可以不同但原则是核心链路必须要有二次确认。另外还有一个非常容易被忽略的点刷新接口在生产环境一定要做好限流。原因是如果这个接口自身被脚本或机器人高频调用等于变相发起了一轮缓存抖动攻击会让回源压力骤增。哪怕是无意的比如有人写了个定时任务把刷新事逻辑反复触发也会造成不必要的性能损耗。加上限流之后高频调用直接被拒掉也算是保护自己。4.4 防穿透保护性缓存要单独设计刷新策略上面提到防穿透缓存这块需要单独说因为它和普通业务缓存的刷新策略完全不同。普通缓存的目标是在不刷新时保持旧值可靠防穿透缓存的目标是在空窗期保护数据库不回源超限。后者最关键的是不能出现空窗一旦缓存被删掉所有流量直接打到数据库上风险比数据短暂偏差大得多。所以对于防穿透缓存我会采用用新值覆盖而不是删除的刷新策略。也就是说请求方拿新数据来我们先把新数据写进缓存然后再更新一个逻辑版本标记读侧看到版本变化就知道数据已更新。整个过程不会出现没有任何缓存数据的空窗期数据库始终被缓存挡着安全系数高很多。这里可以举一个实际例子。我们有个接口回源成本很高单次查询要聚合十几张表平时依赖一个五分钟过期的缓存来扛流量。有一次运营要做全量配置变更我要是按照老思路直接删缓存在配置完全生效之前所有请求都会穿透到聚合计算上极有可能把数据库连接池占满。所以我们用了覆盖式写入先让新配置的计算结果落到缓存再切流量整个过程数据库的压力几乎没有波动。5. 刷新之后还要留几个心眼监控、验证与后续检查5.1 缓存回源时间窗与新值生效时间窗要分开看一个让很多人困惑的问题是刷新缓存之后到底要等多久才能看到新值这个答案不是固定的因为它取决于两个时间窗——回源时间窗和生效时间窗。回源时间窗是指从缓存清空到新值写入缓存之间的间隔这个间隔受回源数据复杂度影响快则几百毫秒慢则几十秒。生效时间窗则是指本地缓存的过期时间如果你服务里有 Caffeine 缓存的过期时间是五分钟那么哪怕 Redis 里已经写入了新值本地的旧值最多还能存活五分钟。所以如果你刷新之后发现数据瞬间就变了那说明没有本地缓存这一层如果发现过了几分钟才变那多半就是被本地缓存拖慢了。理解了这两个时间窗你就不会在刷新完成后的一两秒内反复点页面然后得出刷新没生效的错误结论。正确做法是分两阶段验证Redis 层面的写入是否完成通过直接读 Redis 确认应用层面的对外表现是否更新在本地缓存过期后再次访问接口确认。5.2 刷新后五分钟内的访问日志是安全性的试纸刷新缓存这个动作本身有点像一个将系统短暂暴露在风险中的开关。缓存失效的那一刻系统会有一段回源高发期这时候如果代码里有什么潜在隐患比如慢查询、死锁、超时都很容易被放大暴露出来。所以在刷新后的五分钟内我习惯去看几类指标的波动数据库连接池活跃连接数、接口平均响应时间、错误率、CPU 使用率。如果这些指标整体平稳那说明这次刷新是顺滑的。如果有显著的尖峰哪怕接口最终恢复了正常也说明系统的回源能力存在隐患值得在后续优化一下。尤其是数据库连接池如果刷新后活跃连接数短期爬得很猛可能意味着回源逻辑的重度超出预期能优化聚合逻辑就优化优化不了就要考虑给缓存增加更长的有效期。5.3 缓存中脏值与旧值的区分说到最后还有一个概念值得理清旧值和脏值是两码事。旧值是指数据源本身是最新的但缓存里存的是过期版本刷新就能解决。脏值是指缓存里的值本身就来自一次错误的写入或计算它可能不是任何时间点的有效版本刷新未必能解决——甚至可能又一次把脏值回填回去。我们线上就出现过一次脏值案例。一批数据导入任务因为代码逻辑缺陷把一批错误的映射关系写进了缓存而且有效期长达一天。后来发现问题后我们在刷新缓存之前先跑了一个清理任务把可能受影响的键全部枚举出来批量删除后才触发回源。如果你只是简单调用统一刷新接口去刷其中一个键回源时用的还是那套错误映射关系结果照样是脏的。所以在排查类似刷新之后数据仍然明显异常的问题时别局限于缓存层有没有正常刷新还要多问一句这个数据当前来源环节本身是不是就有问题如果是刷新只会加速错误的传播。5.4 一套简单但有效的验证清单最后分享一份我每次刷新完缓存之后都会过一遍的验证清单算是个人的土办法但胜在简单可靠先在 Redis 里直接读一次目标键确认新值已经写入这一步最直接再通过业务接口访问一次确认对外表现一致注意避开本地缓存生效时间窗检查同一个键在数据库中对应的记录比对值是否相等排除中间链路问题观察刷新后五分钟的监控指标确认没有异常尖峰如果有消息通知机制确认失效广播已被各节点消费本地缓存均被清除。这条清单执行下来通常不超过十分钟但能覆盖掉我在前面几节里提到的绝大多数根因类型。遇到复杂场景时再往上追溯到数据源链路基本就不会再有刷新了个寂寞的情况了。6. 踩过几次坑之后的真心话刷新缓存这事技术门槛确实不高命令简单、原理不深但我现在越来越觉得它恰恰是一个系统设计是否成熟的试金石。一个团队如果连刷新缓存都要靠人肉记忆键名、靠运气确认生效那说明缓存相关的基础设施还不够完善早晚会在某个凌晨被一个问题给教做人。我的建议是不要等到出事了再来优化流程可以在平时就花半天左右把统一刷新入口、预校验、验证反馈这几个基础能力搭起来。成本并不高但收益是长期且持续的——至少以后再有人跟我说你把缓存刷一下的时候我可以用一套稳定的工具去完成而不是打开命令行赌一把。这里还有一个我个人的小经验也分享给你。刷新缓存后如果数据依然表现异常不要反复执行刷新命令越刷越乱。正确做法是停下来回到你说的现场记录那一步——当前值是什么期望值是什么中间穿过哪些数据环节。把这三个问题想清楚大多数问题都能自己浮出水面。如果你也经常被缓存刷新问题困扰希望这篇笔记能给你一些参考少走一点弯路。
返回列表