缓存该删还是该改,LLM 服务里的双写问题
给答案加缓存之后,命中率上去了,账单下来了。然后开始出现一类很难查的问题,同一个问题,五分钟后问一次得到新答案,再问一次又回到旧答案。用户以为系统在抽风。
这类问题的根源在双写。数据变了,数据库和缓存都要动,两个地方不是原子的,中间那段窗口就会出不一致。而缓存这种不一致一旦形成,可能一直存在,直到过期。
为什么推荐删而不是改
两条路都能走。数据变了,把新值写进缓存,或者把缓存删掉等下次读时重建。
推荐删的理由有三个,每一个都跟并发有关。
第一个,改缓存有顺序问题。两个请求同时更新同一条数据,A 先拿到旧值改成新值一,B 后拿到旧值改成新值二,如果 B 的缓存写入先到、A 的后到,缓存里留下的是新值一,数据库里是新值二。这个不一致会一直留着,直到过期。删缓存没这个问题,因为删除是幂等的,谁先删谁后删结果一样。
第二个,改缓存需要读本来不该读的数据。很多更新操作只知道哪几个字段变了,为了写缓存要先把整条读完,多一次读,多一个失败点。
第三个,删缓存的成本低。删一个键是常数开销,写一个可能很大的对象不是。
但删也有窗口
删缓存的经典问题是这个顺序,先删缓存、再写库。如果中间有一个读请求进来,它读到旧库值,把它加载进缓存,然后写库完成。缓存里留下了旧值,而且没有过期之前一直错。
所以常见做法是先写库、再删缓存。这样窗口小很多,因为写库之后立刻删,读请求撞上窗口的概率低。但不是零,如果读请求在写库之前读到旧值,在删除之后才写进缓存,还是错的。
降低这个概率的手段是延迟双删。写完库之后删一次,过一小段时间再删一次,把那个抢跑进来的读请求写下的脏值清掉。延迟多久不好定,通常给几百毫秒。
版本号是最稳的兜底
上面所有手段都是降低概率,没有一个能给保证。想要保证,得让缓存自己知道它是不是过期数据。
做法是给数据带版本。缓存里除了值,还要存这个值的版本号或者更新时间。读的时候,用缓存里的版本和数据库里的当前版本比一下,不一致就丢弃缓存重新加载。
这样即使脏值被写进缓存,下一次读也能识别出来。代价是每次读要多查一次版本,通常查的是一个很小的字段,或者单独一个版本表。
在 LLM 服务里,这个方案特别适用,因为要缓存的东西往往本来就有版本概念。提示词有模板版本,知识库有文档版本,答案缓存可以带上游数据的最大更新时间。
三种不一致要分开看
第一种是写后读。用户刚改完,立刻刷新,看到的还是旧值。这是最容易被投诉的,因为用户能感知。
第二种是并发写。就是前面说的顺序颠倒。这种不一致持续时间最长,也最难查,因为它是随机的,重试一次可能就对了。
第三种是主从延迟。数据库有从库,写完主库立刻读从库读到旧值。这一种和缓存无关,但现象一样,排查时容易混淆。
排查时先确定是哪一种,看时间戳和请求日志的先后。混在一起猜,一定猜错。
缓存空值也有坑
查不到的数据要不要缓存空值。缓存的好处是防止穿透,坏处是数据后来补上了,缓存里还是空。
解法是空值给一个很短的过期时间,比如三十秒,长数据给正常过期时间。这样既挡住了穿透,又不会长期错。
还有一类特殊情况,负缓存和正缓存用同一个键空间。数据从无到有的时候,要显式删掉那个空值标记,否则用户会在一段时间内看到查无此数据。
LLM 服务里哪些缓存不用删
不是所有缓存都要走这套逻辑。
按内容哈希缓存的嵌入向量,天然不需要删。同样的文本永远得到同样的向量,除非换模型。换模型时整批失效,按模型版本换前缀就行。
提示词模板的渲染结果也可以按内容哈希缓存,因为输入相同输出必然相同。
只有那些依赖外部状态变化的缓存,才需要这套删除加版本的处理。答案缓存、业务数据摘要、工具调用结果,都属于这一类。
分清这两类很重要,不然你会为了不需要一致性的东西写一堆逻辑。
更新频率低的数据别折腾
有些数据一天改一次,缓存时间本来就短。这种场景上复杂的双写方案,收益很低,风险反而高,因为逻辑复杂了自己会出 bug。
判断标准很简单,如果脏数据的最大持续时间在你的容忍范围内,就别做。
容忍范围怎么定,问业务。价格错五分钟能接受吗,库存错五分钟能接受吗,个人资料错五分钟呢。答案不一样,方案也不一样。
结合系列
Redis 那篇讲的是这套缓存的物理形态,键怎么设计、过期怎么定、大键怎么拆。缓存那篇讲的是命中率和成本,这一篇补上一致性这一维。配额那篇的计数缓存也需要一致性,但它的容忍度高得多,因为计数本来就是近似值。数据脱敏篇的缓存要额外注意,删缓存时要确保受限数据不会残留在别的键里。可观测性篇的 trace 要记录缓存命中与否,否则你分不清是没命中还是命中了脏数据。