AI 帮我加了缓存命中率很好看,可数据改了页面还是旧的
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
缓存出问题的时候,绝大多数人把它归成”性能问题”,然后去调 TTL、加内存、换更快的缓存组件——但你真正踩到的几乎都是一致性问题:写入之后没有人负责把旧值弄掉。 命中率是个让人安心的指标,它只告诉你读得快,不告诉你读得对。AI 生成缓存代码时这个偏差被放大了一次:你让它”给这个查询加缓存”,它会给你一个结构完整、异常处理齐全、还带上注释的读路径包装;写路径它不会碰,因为你没提,而它也没有你的业务全貌可推断谁会改这份数据。
先说清本篇和站内两篇的分工。如果你的现象是改了代码但构建产物还是旧的、CI 拉到了脏产物,那是构建缓存,去看 构建缓存不生效;如果你的现象是 Agent 重跑一次就重复发一遍外部请求、重复写一遍库,那属于调用层的缓存与幂等,去看 Agent 缓存与幂等。这篇只管一件事:业务读路径上的数据缓存,策略怎么定、失效怎么落、坏了怎么查。
一、先把现象分成六类,别急着清缓存
清缓存是最没有信息量的操作。它能让现场消失,同时把你唯一的证据也一起删掉。真出故障时,第一步是保留现场并做一次分类判别。
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 改完数据页面仍是旧值,过一阵自己好了 | 写路径根本没有失效动作,全靠过期兜底 | 改一次数据,立刻分别读缓存里的值和数据源里的值做对比 | 在写事务提交成功之后补一次显式删除(顺序是先写库、后删缓存) |
| 只有一部分用户看到旧值,刷新几次时好时坏 | 多个实例各持一份进程内缓存,互不知道对方改了 | 把请求固定打到单个实例复现,逐实例对比 | 进程内缓存改成共享存储,或加失效广播 |
| 切换语言、切换账号后看到了别人的内容 | 缓存键少了区分维度(租户、角色、语言、灰度分组) | 把实际命中的键打出来,看键里有没有这些维度 | 键补维度,补之前先临时旁路这段缓存 |
| 全站突然变慢,数据源压力出现尖峰 | 一批键在同一时刻集体作废:要么键的版本前缀整体换了,要么它们本来就是同时写入、同一个过期时间 | 先看尖峰时刻和发布时间点对不对得上:对得上就打印发布前后实际读到的键,比对前缀是否换了;对不上就查这批键的写入时间是否集中、过期时间是否同一个常量 | 前缀变更走分批推进或新旧键并存一段时间,并在发布前预热热点键;同时到期用随机抖动打散。两种成因都要在回源侧加单飞(singleflight),否则尖峰会被放大 |
| 偶发返回空结果或半截数据,重试又正常 | 异常分支或未完成对象也被写进了缓存 | 人为让下游超时,看这一次的结果有没有落缓存 | 只缓存成功且完整的结果,空结果用独立短标记 |
| 本地怎么试都对,线上就是不生效 | 链路上还有一层你没算进去的缓存 | 带旁路参数逐层请求,再直连源站对比响应头 | 把每一层的 Cache-Control 与 Vary 明确写死 |
这张表的用法是从上往下走,而不是挑一个看着像的。前两类占绝大多数,且互相会掩盖:你补了失效,进程内那份还是旧的,看上去像”失效没生效”,于是你又去改失效逻辑,越改越乱。
二、AI 在这个环节能帮到哪一步
它擅长的是有确定答案的机械部分,且你能立刻验证对错的那部分。
能交给它的:把裸查询包装成读缓存的模板代码;把散落各处的键拼接收敛成一个构造函数;根据你给出的键规则批量改写调用点;把回源逻辑加上单飞保护;写出打点代码把命中、回源、失效各计一个数;根据你描述的失效规则生成对应的测试用例。
它默认不会做的:找出所有会修改这份数据的写入口。这句话是本篇的核心。AI 看到的是你喂给它的那几个文件,一个后台批处理脚本、一条数据订正 SQL、一个另一个服务发过来的消息消费者,都会改同一份数据,也都不在它的视野里。它生成的代码在你给的上下文里自洽,上线以后失效覆盖率是残缺的。这个残缺不会报错,只会表现为”某些路径改完不刷新”,而那些路径往往是低频的、上线两周后才被人发现的。
关于改动范围失控这件事,AI 改动范围失控 里讲过怎么框边界,缓存这块尤其需要框:别让它顺手”优化”你的键结构。
三、人必须先定的四件事
把这四个决定写下来,再让 AI 去落地。顺序不能反——先有规约后有代码,代码才可审。
第一,这份数据能容忍多旧。 这是业务决定,不是技术决定。账户余额、库存、权限判定这类,要么不缓存要么写完立刻失效;配置类、榜单类、统计类,可以容忍到分钟级;纯静态词典可以长期缓存靠版本号更新。你不说,AI 会给所有查询一视同仁的处理。
第二,键里有哪些维度。 逐条列出来:主体标识、租户、语言、权限视角、数据模式版本。漏一个就是越权读或串数据。键的构造必须收在一个函数里,别让字符串拼接散落在十几个文件。
第三,谁负责失效。 把这份数据的所有写入口列成清单,一个都不能少,包括脚本和消息消费者。清单没列全,后面写多少代码都是白写。
第四,失效失败了怎么办。 删缓存这一步会失败(网络抖动、缓存服务重启)。你要提前决定:是让写操作跟着失败,还是记一条补偿任务异步重删,还是靠过期时间兜底。默认什么都不做等于选了第三条,而你可能并不知道自己选了。
四、落地动作与验收
代码写完之后,验收动作要能证明”失效真的发生了”,光看命中率涨了不算。
键的构造统一收口,稳定序列化,带上模式版本前缀:
import hashlib
import json
SCHEMA_VERSION = "v3" # 数据结构变更时手动进位,旧值自然作废
def cache_key(namespace: str, params: dict) -> str:
payload = json.dumps(params, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
return f"{namespace}:{SCHEMA_VERSION}:{digest}"
sort_keys=True 不能省,字典顺序不稳定会让同一份参数产生两个键,表现为命中率莫名偏低。
这里有个代价要提前认下来:摘要截断之后键变短了,但也变得不可读——线上看到一个键,你没法反推它对应哪次查询、哪个租户。所以打点和日志里除了键本身,还要把 namespace 和参数里的关键维度(主体标识、租户、语言)单独作为字段记一遍,否则第四节要求的”这次读命中了哪个键、这个键上次是被谁失效的”根本还原不出来。截断长度也不是随便定的,取得太短会出现两个不同参数算出同一个键,读到别人的数据,而这种错读不报错、只会偶发;不确定就别截断,或者只在键长确实成为瓶颈时再截。
HTTP 这一层,直接看响应头比看代码可靠:
curl -sSI https://example.com/api/profile | grep -iE '^(cache-control|etag|age|vary):'
^ 和结尾的冒号别省,只匹配头字段名,否则 age 会顺带命中 Content-Language 这类含相同字母的行,读起来像是有中间层缓存其实没有。另外有些接口不接受 HEAD 请求,-I 拿到的状态和头可能与真实 GET 不一致,遇到这种情况改用 curl -sS -o /dev/null -D - <url> 发一次真实 GET 再看头。
Age 有值说明中间有人替你缓存了;Vary 缺了 Accept-Language 或认证相关维度,就是跨用户串数据的隐患。带上认证信息的接口如果没有 Cache-Control: private,任何一层共享缓存都可能把 A 的响应发给 B;如果这份响应压根不该被任何一方留存,写 no-store 而不是 private——private 只是禁止共享缓存,浏览器本地仍然可以留。
验收要跑三个动作,缺一个都不算数:
- 改—读验证:改一次数据,不等待,立刻读接口,必须是新值。等待之后才对,说明是过期在兜底,不是失效在起作用。
- 写入口遍历:第三步列的清单逐个走一遍,包括后台脚本那条。每走一个记一次结果。
- 失效失败演练:把缓存服务临时断开,执行一次写入,观察系统行为是否符合你第四条的决定。
第三个动作大部分团队不做,于是第一次缓存服务抖动就变成了长期脏数据,而且没人知道脏了哪些键。
命中率、回源量、失效调用次数这三个数要一起看,只看命中率会误判。日志里还要能还原出一件事:这次读命中了哪个键,这个键上一次是被谁失效的。没有这两条信息,出问题时你只能靠猜。
五、什么情况下别再折腾
排查缓存问题最大的成本不是找不到原因,是在一个错误的方向上持续修补。下面几条出现任意一条,停手。
已经改了两轮失效逻辑,脏数据仍然复现。 这说明写入口清单不全,继续在代码里加删除调用是在猜。止损动作是直接把这段缓存旁路掉(配置开关,不是删代码),让服务退回直查数据源,先把线上恢复,再离线把写入口列清楚。旁路开关必须在加缓存的第一天就留好,事后加要发版,来不及。
回滚点在哪: 键结构变更、序列化格式变更、缓存组件版本升级,这三类改动要单独发布,不要和业务改动混在一个版本里。混在一起你就没法二分定位,只能整包回滚,而整包回滚又会带来另一批状态不一致,具体见 回滚后状态不一致。
这份数据根本不该缓存。 判断依据很直白:如果一次读的成本远小于维护失效正确性的成本,就别缓存。强一致要求高、写频率接近读频率、单条查询本身就很快的数据,加缓存是纯亏。有个反直觉的现象:很多缓存是为了掩盖一条没走索引的查询,把索引补上以后缓存就没必要了,先去看执行计划再决定要不要缓存。
换条路的信号: 如果你发现自己在给缓存写补偿任务、给补偿任务写监控、给监控写告警,这条链已经比原始查询复杂一个量级了。此时更划算的方向通常是换成”写时更新视图”或者干脆让下游订阅变更,而不是继续加固失效。
六、避坑清单
先删缓存再写库。 为什么会踩:这个顺序读起来更”安全”,很多示例代码也这么写。但删完之后、库写完之前,任何一次读都会把旧值重新加载回来,然后长期驻留。 怎么避:写库成功后再删;对一致性要求高的,写完延迟一小段时间再删第二次。
用键前缀模糊删除。 为什么会踩:AI 生成的失效代码经常是扫描一批键然后批量删,写起来最省事。但全量扫描在数据量大时会拖住整个缓存服务,个别命令还会阻塞。 怎么避:维护反向索引(记住某个实体关联了哪些键),或者用版本号推进让旧键自然作废,别做运行时扫描。
把异常也缓存了。
为什么会踩:包装函数统一 try 之后返回默认值,默认值顺手写进了缓存,看起来是”防止击穿”。
怎么避:成功路径和失败路径分开处理,失败只允许写一个极短的空标记,且标记要和真实空结果可区分。
所有键用同一个过期时间。 为什么会踩:配置里就一个常量,AI 也乐意复用它。结果是一批键同时生成、同时到期。 怎么避:过期时间加随机抖动,热点键提前异步续期。
认证接口的响应进了共享缓存。
为什么会踩:加缓存时只想着自己那层,忘了反向代理和浏览器也会按响应头决定缓不缓。
怎么避:带用户身份的响应显式标 private,Vary 把认证相关维度写全,上线后用上面那条 curl 复核一遍。
本地缓存和共享缓存混用却没有失效广播。 为什么会踩:为了再快一点,在共享缓存前面加了一层进程内的,这层通常是后来补的,补的时候没人管失效。 怎么避:进程内那层要么只放不可变数据,要么接一条失效广播通道;做不到就别加。
测试环境只跑单实例。 为什么会踩:单实例下多副本不同步的问题永远不出现,测试全绿,上线就炸。这类”测试通过但线上有问题”的模式在 测试绿线上爆 里是同一个根因。 怎么避:一致性相关的用例强制在多副本环境跑,至少两个实例。
收束
缓存这件事上,AI 的产出质量取决于你给它的规约有多完整,而不是取决于你怎么描述需求。你不列写入口清单,它就不会替你想失效;你不说容忍多旧,它就给所有数据同一套参数。把决定权留在自己手里,把重复劳动交出去,这个分工比在生成的代码里逐行挑毛病有用得多。
上线前过一遍这份自检:
- 这份数据能容忍多旧,写下来了吗
- 所有写入口列全了吗,包括脚本和消息消费者
- 键里的维度齐吗(主体、租户、语言、权限视角、模式版本)
- 删缓存失败时的行为,是主动选的还是默认的
- 改—读验证验过了吗,是立刻对还是等一会才对
- 旁路开关留了吗,能不发版关掉吗
- 过期时间有抖动吗
- 多副本环境下跑过一致性用例吗
八条里有两条答不上来,就先别上生产。