2023 年我做一个电商的商品详情页——用户访问,展示商品的名称、价格、库存。详情页访问量很大,我自然想到加缓存:在数据库前面挡一层 Redis。第一版方案我做得很顺手:读商品时,先查 Redis,命中就直接返回;没命中,就查数据库,把结果写进 Redis,再返回。写商品时(比如运营改了价格),更新数据库,再顺手把 Redis 里那条也更新成新值。本地一测,读得飞快,改了价格刷新页面也是新的,我心里很笃定:缓存嘛,就是数据库的一个副本,数据库改了顺手把缓存也改一下,两边就一致了,这缓存稳了。可等真实流量上来,一串问题冒了出来。第一种最先把我打懵:运营改了价格,数据库里明明是新的,可用户看到的详情页,价格还是旧的,过了好久才自己变过来。第二种最难缠:我后来把"更新缓存"改成了"删除缓存",大部分时候好了,但偶尔还是有用户看到旧价格,而且死活刷不新。第三种最头疼:两个运营几乎同时改同一个商品,改完之后缓存里的值,既不是 A 改的、也不是 B 改的,是一个更早的旧值。第四种最莫名其妙:有个商品被下架了,数据库里那条已经没了,可缓存里还在,用户照样能点进去下单。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为缓存就是数据库的一个快照——数据库是"主",缓存是"副本",我只要在改数据库的时候,顺手把缓存也改成一样的,两个就永远一致。可这个认知是错的。缓存和数据库,是两个独立的、各自存储数据的系统。"把数据库改了"和"把缓存改了",是两个独立的操作,它们之间无论隔得多近,都不是原子的——中间永远存在一个时间缝隙,在这个缝隙里,两个系统的数据是不一致的。并发请求、操作失败、操作顺序,都会从这个缝隙里钻进来。要把缓存用扎实,根上要明白:你做不到"缓存和数据库永远一致",你能做的,是选一套让"不一致窗口尽量短、且一定会自愈"的策略。本文从头梳理:为什么"顺手更新一下缓存"会出问题,Cache-Aside 模式怎么读写,更新缓存为什么要用"删除"而不是"写入",先删缓存还是先更数据库,并发下缓存被旧值回填怎么办,以及一些把它做扎实要避开的工程坑。
问题背景
先把"缓存一致性"这件事说清楚。缓存的作用,是在数据库前面挡一层,让大量的读请求落在快速的缓存上,不去压数据库。代价是:同一份数据,现在存在了两个地方——数据库里一份,缓存里一份。只要数据会变,这两份就有"对不上"的风险。
错误认知是:缓存是数据库的影子,改数据库时同步改一下缓存,两边就一致。真相是:更新数据库和更新缓存是两个独立操作,不存在让它们同时生效的办法,中间的缝隙是结构性的。把这一点摊开,第一版的几类问题就都能解释了:
- 改了库但页面还是旧的:更新缓存这一步失败了,或者根本没做对,缓存就一直留着旧值,直到它碰巧过期。
- 删了缓存还是旧值:删除之后、新值写回之前的缝隙里,一个读请求把旧值又回填了进去。
- 并发改同一商品留下更旧的值:两个写请求更新缓存的先后顺序,和它们更新数据库的顺序相反了。
- 下架的商品缓存里还在:删除数据记录时,没有同步把缓存里那条清掉。
所以让缓存用对,核心不是"想办法让两边时刻一致"——做不到,而是选一套策略:让不一致的窗口足够短,并且保证缓存最终一定会回到正确。下面六节,就从第一版"顺手更新缓存"的想当然讲起。
一、为什么"顺手更新一下缓存"会出问题
第一版的写操作,逻辑朴素得很:更新数据库,然后顺手把缓存也设成新值。这个写法符合直觉——数据变了,两个存数据的地方都改一下,不就一致了吗?可它在并发和失败面前,处处是缝。
# 反面教材:写商品时,顺手把缓存也更新成新值
def update_product_v1(product_id, new_data):
# 第 1 步:更新数据库
db.update("products", product_id, new_data)
# 第 2 步:顺手把缓存也更新成新值
cache.set(f"product:{product_id}", new_data)
# 单线程下,这段代码看起来天衣无缝:
# 数据库改了,缓存也改了,两边一样。
# 但它至少有三个隐患:
# 1. 两步不是原子的:第 1 步成功、第 2 步失败,
# 缓存就一直是旧值(直到它碰巧过期)
# 2. 并发下两个写请求,两次 cache.set 的先后顺序
# 可能和两次 db.update 相反,缓存留下错误的值
# 3. 写多读少时,每次写都更新缓存,而这个值
# 可能根本没有读请求来用 —— 纯属浪费
把三个隐患摊开看。其一,两步不原子:db.update 和 cache.set 是先后执行的两件事,任何一件失败,两边就分叉了——尤其是数据库成功、缓存失败,缓存会一直顶着旧值。其二,并发顺序错乱:两个写请求 A、B 同时改一个商品,完全可能出现"A 更库、B 更库、B 写缓存、A 写缓存"的交错,最后数据库是 B 的值,缓存却是 A 的值。其三,写入是浪费:写多读少的场景下,你辛辛苦苦算出新值塞进缓存,可能下一个写请求又把它覆盖了,中间根本没有读请求受益。
这一节要建立的认知是:缓存一致性问题的根源,不是"你忘了更新缓存",而是"更新数据库"和"更新缓存"这两件事,本质上无法被捆成一个原子操作。第一版的错,不在某一行代码写错了,而在它默认了一个不成立的前提——"我把两个更新写在一起,它们就会一起生效"。可它们不会。它们是发往两个不同系统的两个独立请求,中间隔着网络、隔着时间,隔着随时可能挤进来的其他请求。只要你承认这一点,你就会明白:追求"两边时刻一致"是徒劳的,你真正要做的,是设计一套策略,让这个必然存在的不一致窗口尽量小、并且让缓存有办法自己回到正确。这套策略的标准骨架,就是下一节的 Cache-Aside。
二、Cache-Aside 模式:读写缓存的标准骨架
缓存读写,有一个被广泛采用的标准模式,叫 Cache-Aside(旁路缓存)。它的规则很简单。读:先查缓存,命中就返回;未命中,查数据库,把结果回填进缓存,再返回。写:更新数据库,然后让缓存失效。先看读这一半。
# Cache-Aside 读:缓存优先,未命中再查库并回填
def get_product(product_id):
key = f"product:{product_id}"
# 第 1 步:先查缓存
cached = cache.get(key)
if cached is not None:
return cached # 命中,直接返回
# 第 2 步:未命中,查数据库
product = db.query_one("SELECT * FROM products WHERE id = %s",
product_id)
# 第 3 步:把查到的结果回填进缓存,并设一个过期时间
if product is not None:
cache.set(key, product, ttl=3600)
return product
读这一半的关键,是那个"回填"动作和那个"过期时间"。回填,意味着缓存是被读请求逐渐填起来的,不是写请求负责填的。过期时间,意味着缓存里的每一条都有寿命,到点自动消失。再看写这一半——注意,它做的不是"更新缓存",而是"删除缓存"。
# Cache-Aside 写:更新数据库,然后让缓存失效(删除)
def update_product(product_id, new_data):
# 第 1 步:更新数据库 —— 权威数据先改对
db.update("products", product_id, new_data)
# 第 2 步:删除缓存 —— 不是写新值,是直接删掉
cache.delete(f"product:{product_id}")
# 缓存被删后,下一个读请求会自然走"未命中 -> 查库 -> 回填",
# 那条读路径会算出正确的新值,我们这里不必操心
这一节的认知是:Cache-Aside 模式背后的核心定位是——缓存只是数据库的一个"临时投影",它可以为空、可以过期、可以随时被丢弃,它从不承担"权威"。第一版的隐含假设,是把缓存当成了和数据库平起平坐的"另一份正式数据",所以才会执着于"两份都得是对的、都得我来维护"。Cache-Aside 换了个定位:数据库是唯一的权威,缓存只是为了快,临时映射了一下权威数据。这个定位的转变,卸下了一个沉重的包袱——你不再需要保证"缓存时刻正确",你只需要保证"缓存错了的时候,能被及时清掉、并由读路径重新填对"。缓存可以错,只要它错得不久、且能自愈。后面几节的所有技巧,都是在这个定位之上展开的。
三、更新缓存:为什么是"删除"而不是"写入"
上一节的写操作里,埋了一个第一版没做对的关键改动:把 cache.set(新值) 换成了 cache.delete()。第二版我做了这个改动,问题确实少了一大半。这一节专门讲清楚:为什么"删掉缓存"比"把缓存改成新值"要好。原因有三层,一层比一层重要。
第一层,删除省事。要"写入新值",你得先算出那个新值——有时新值不是请求里现成的,要重新查库、重新拼装。而删除不用算,把旧的扔掉就行。第二层,删除不浪费。写多读少时,"写入"是在做无用功:你算好新值塞进缓存,可能没人读就被下次写覆盖了;"删除"是惰性的——删掉,等真有读请求来了,再由读路径回填,没人读就一直空着,不占资源。第三层,也是最重要的——并发安全。
# 为什么"写入缓存"在并发下危险:两次写的先后会乱
# 写请求 A 把价格改成 100,写请求 B 改成 200,设想这个时间线:
# 1) A 更新数据库,price 变 100
# 2) B 更新数据库,price 变 200 数据库最终是 200,正确
# 3) B 执行 cache.set,缓存写成 200
# 4) A 执行 cache.set,缓存写成 100 缓存最终是 100,错了
# 数据库和缓存,一个 200 一个 100,两边对不上。
# 换成 cache.delete 就没这个问题:
# A、B 都只是把这个 key 删掉,删两次和删一次效果一样;
# 删完之后,下一次读会从数据库(200)回填,缓存自愈成 200。
这个并发场景是"写入"方案的死穴。两个写请求,它们更新数据库的顺序,和它们写缓存的顺序,是两件独立的事,完全可能相反。一旦相反,缓存就留下了一个和数据库矛盾的值,而且这个错值不会自己消失。换成"删除",问题就消解了:删除是幂等的、不带值的,无论几个请求并发删、按什么顺序删,结果都一样——这个 key 没了。没了之后,下一次读自然会从数据库(权威)回填出正确的值。
这一节的认知是:写操作里用"删除"而不是"写入",本质上是把"算出正确的新值"这件容易错的难事,从写路径推迟到了读路径。写路径上,你面对的是并发的、乱序的多个写请求,在这里去"算新值、写新值",你要对付顺序、对付覆盖,处处是坑。而你把写操作做到最简单——只删除,不带任何值,不需要算任何东西——它就变成了一个幂等的、怎么并发都不会错的动作。那个"正确的新值到底是什么",交给读路径去操心:读路径本来就有一套成熟的"未命中就查库回填"的逻辑,而查库读到的,永远是数据库里那个权威值。把难的事交给有权威数据兜底的读路径,把写路径砍到不可能出错,这就是"删除优于写入"的全部道理。
四、先删缓存,还是先更新数据库:顺序的坑
确定了写操作要"更新数据库 + 删除缓存"这两个动作,马上有一个新问题:这两个动作,谁先谁后?直觉上好像无所谓,反正两个都要做。但并发之下,顺序是有讲究的——选错了,又是一个一致性漏洞。
先看"先删缓存,再更新数据库"这个顺序。它有一个明确的并发漏洞。
# 反面教材:先删缓存,再更新数据库 —— 并发下会回填旧值
def update_product_wrong_order(product_id, new_data):
cache.delete(f"product:{product_id}") # 先删缓存
db.update("products", product_id, new_data) # 再更新数据库
# 危险的时间线:
# t1: 写请求删掉缓存
# t2: 读请求来了,缓存未命中,去查数据库 —— 此时库还没更新,
# 读到的是旧值
# t3: 写请求更新数据库为新值
# t4: 读请求把它在 t2 读到的旧值,回填进了缓存
# 结果:数据库是新值,缓存是旧值,而且这个旧值不会再被删
这个漏洞的要害在 t2 到 t4 这一段:写请求把缓存删了,但还没来得及更新数据库,一个读请求趁虚而入,查到了旧的数据库值,并把它回填进了缓存。等写请求终于更新完数据库,缓存里已经被那个旧值占住了——而且写请求的"删缓存"动作早在 t1 就做完了,它不会再来删第二次。缓存就这样长期顶着旧值。所以正确的顺序是反过来:先更新数据库,再删除缓存。
# 正解:先更新数据库,再删除缓存
def update_product(product_id, new_data):
db.update("products", product_id, new_data) # 先改权威数据
cache.delete(f"product:{product_id}") # 再让缓存失效
# 即便在两步之间有读请求挤进来:
# 缓存这时还没被删,读请求会命中缓存、拿到旧值 —— 短暂不一致,
# 但紧接着的 cache.delete 会把它清掉,下次读就回填成新值。
# 危害是"短暂读到旧值",不是"长期顶着旧值"。
这一节的认知是:在"更新两个存储"这件事上,操作顺序要遵循一个原则——先改"权威"的那个,再让"副本"失效;让任何并发请求挤进缝隙时,最坏结果只是"短暂读到旧值",而不是"缓存被永久写错"。"先删缓存再更库"之所以错,是因为它制造了一个有毒的缝隙:在这个缝隙里被回填的旧值,会长期存活、不再被纠正。"先更库再删缓存"也有缝隙,但这个缝隙是良性的——缝隙里读到的旧值,马上就会被随后的删除动作清掉。两种顺序都不能消除不一致,但一个的不一致会自愈、另一个的不一致会沉淀。设计这类操作时,你要做的不是幻想消除缝隙,而是把缝隙的后果,从"永久错误"压成"短暂、可自愈的错误"。不过,"先更库再删缓存"也不是 100% 无懈可击,它还剩一个小概率的漏洞,就是下一节的延迟双删要补的。
五、并发下缓存被旧值回填:延迟双删
"先更新数据库,再删除缓存"已经相当稳了,但还剩一个小概率的并发漏洞。这个漏洞,正是第一版第二种问题"删了缓存还是旧值,死活刷不新"的真正成因。它出现的条件比较苛刻,但在高并发下,苛刻的条件也会被撞上。
设想这样一个时间线:一个读请求先到,它查缓存——恰好未命中(可能缓存刚过期),于是它去查数据库,读到了旧值。但就在它读完库、还没来得及回填缓存的这一瞬,一个写请求插了进来,它更新了数据库、删除了缓存,整个写操作做完了。然后,那个慢吞吞的读请求才回过神来,把它早先读到的那个旧值,回填进了缓存。结果:数据库是新值,缓存是旧值,而写请求的删除动作已经做完了,不会再删。这就和"先删后更"的结局一样了——缓存沉淀了一个旧值。
# 延迟双删:写完之后,过一小段时间,再删一次缓存
import threading
def update_product_double_delete(product_id, new_data):
key = f"product:{product_id}"
# 第 1 步:更新数据库
db.update("products", product_id, new_data)
# 第 2 步:删一次缓存
cache.delete(key)
# 第 3 步:延迟一小段时间,再删一次
# 这段延迟,要足够覆盖"一次读请求查库 + 回填"的耗时
def delayed_delete():
cache.delete(key)
threading.Timer(1.0, delayed_delete).start() # 1 秒后再删一次
# 第二次删,专门用来清掉那个"可能在第 2 步之后
# 才被慢读请求回填进去的旧值"
延迟双删的思路很直白:写操作删完一次缓存后,不算完,过一小段时间(这段时间要足够让那个"慢读请求"完成它的回填动作),再删一次。这第二次删除,专门负责清理那个可能被回填进来的旧值。当然,延迟双删也不是绝对保险——如果那个慢读请求慢到连第二次删除都错过了,还是会漏。所以,真正的最后一道防线,是每条缓存都设的过期时间:哪怕双删都漏了,过期时间一到,这个错值也会自己消失。
这一节的认知是:延迟双删加上过期时间,本质上是承认了"缓存一致性做不到 100%",转而用一套"多重补刀 + 兜底自愈"的设计,把出错的概率和持续时间,都压到可接受的程度。到这一步,你应该彻底放下"让缓存永远对"的执念了。延迟双删不保证不出错,它只是又堵了一道概率不低的缝;过期时间也不保证实时一致,它只是给所有漏网的错值,设了一个"最长能活多久"的上限。这套组合拳的哲学是:不一致是必然的,那就让每一次不一致,要么很快被某一刀删掉,要么最迟被过期时间清掉——总之,不允许任何错值"永久"存活。一个能在有限时间内自我修复的系统,远比一个幻想自己永不出错的系统可靠。把缓存一致性当成一个"概率和时长"的工程问题来对待,而不是一个"对或错"的逻辑问题,你才算真正理解了它。
把一次写操作完整的缓存处理流程,以及期间读请求的走向,画出来,就是下面这张图:
[mermaid]
flowchart TD
A[收到写请求] --> B[更新数据库 权威数据先改对]
B --> C[删除缓存 第一次]
C --> D[延迟一小段时间]
D --> E[再删除缓存 第二次]
E --> F[写操作结束]
G[期间有读请求到达] --> H{缓存命中吗}
H -->|命中| I[直接返回]
H -->|未命中| J[查库回填 并由过期时间最终兜底]
六、把缓存一致性做扎实,要避开的工程坑
前面五节,讲清了缓存一致性的核心:Cache-Aside、删除优于写入、先更库再删、延迟双删、过期兜底。但要在生产里真正用好缓存,还有三个经典的"缓存异常"得专门讲——它们和一致性无关,但同样会把数据库压垮。第一个,缓存穿透:有人反复查一个数据库里根本不存在的 id,每次缓存都未命中,每次都打到数据库。
# 坑一:缓存穿透 —— 查一个根本不存在的 id,每次都打到数据库
def get_product_safe(product_id):
key = f"product:{product_id}"
cached = cache.get(key)
if cached is not None:
# 命中,但可能命中的是一个"空值占位符"
return None if cached == "__NULL__" else cached
product = db.query_one("SELECT * FROM products WHERE id = %s",
product_id)
if product is None:
# 数据库里也没有:把"空"这件事也缓存起来,
# 用短一点的过期时间,挡住对这个不存在 id 的反复查询
cache.set(key, "__NULL__", ttl=60)
return None
cache.set(key, product, ttl=3600)
return product
第二个,缓存击穿:某个热点 key 过期的那一瞬间,大量并发请求同时未命中,一起涌向数据库去重建它。解法是用一把锁,只放一个请求去查库重建,其余的稍等再读缓存。
# 坑二:缓存击穿 —— 热点 key 过期瞬间,大量请求一起打到数据库
import time
def get_product_with_lock(product_id):
key = f"product:{product_id}"
cached = cache.get(key)
if cached is not None:
return cached
lock_key = f"lock:product:{product_id}"
# set_nx:只有一个请求能成功设置这把锁,它去查库重建
if cache.set_nx(lock_key, "1", ttl=10):
try:
product = db.query_one(
"SELECT * FROM products WHERE id = %s", product_id)
cache.set(key, product, ttl=3600)
return product
finally:
cache.delete(lock_key)
else:
# 没抢到锁,等一下,让拿到锁的那个请求把缓存重建好
time.sleep(0.05)
return get_product_with_lock(product_id)
第三个,缓存雪崩:大量 key 被设置成同一时刻过期(比如系统启动时批量预热的缓存),时间一到,它们集体失效,海量请求同时砸向数据库。解法很简单——给过期时间加一个随机抖动,把集中的过期时刻打散。
# 坑三:缓存雪崩 —— 大量 key 同一时刻集中过期
import random
def cache_set_with_jitter(key, value, base_ttl=3600):
# 给过期时间加一个随机抖动,把集中过期打散开
jitter = random.randint(0, 600) # 0 到 10 分钟的随机量
cache.set(key, value, ttl=base_ttl + jitter)
# 这样原本会在同一秒一起失效的 key,
# 就被摊到了一个时间范围里,不会同时压垮数据库
还有几个坑值得点一下。其一,删除数据记录时,别忘了同步删缓存——第一版第四种问题"下架商品缓存里还在",就是删库记录时漏删了缓存。其二,cache.delete 本身也可能失败(网络抖动),关键场景要对删除失败做重试,或把要删的 key 投进一个队列异步重试。其三,对一致性要求极高的场景,可以不靠应用代码删缓存,而是订阅数据库的 binlog(变更日志),由一个独立服务监听数据变更去清理缓存——这样"删缓存"就和业务代码解耦了,不会因为业务代码漏写而遗漏。下面把这三种缓存异常集中对照一下:
三种缓存异常对照
异常 现象 应对
--------------------------------------------------------------
缓存穿透 查不存在的 key 反复打到库 缓存空值 或 布隆过滤器
缓存击穿 热点 key 过期瞬间被击穿 互斥锁重建 或 逻辑过期
缓存雪崩 大量 key 同时过期压垮库 过期时间加随机抖动
原则:缓存是性能优化,不是数据权威;
系统要在"缓存随时可能不可信"的前提下依然正确。
这一节这几个坑,串起来是同一个意思:缓存是一个"性能优化部件",而不是"数据系统的正式成员"——它可以未命中、可以过期、可以被击穿、可以装着错值,而你的系统,必须在"缓存随时可能不可信"的前提下,依然能给出正确的结果。穿透、击穿、雪崩,这三个异常表面上花样不同,根子上是同一件事:它们都是"缓存这层防护在某个瞬间失效了,请求直接砸到了数据库"。如果你的系统设计成"缓存必须命中、数据库扛不住裸奔",那这三个异常里随便哪个发作,都是一场事故。反过来,如果你始终记得"缓存只是锦上添花,数据库才是地基",你就会自然地去给数据库留足余量、去给这些异常场景做防护。把缓存当成一个"可能随时罢工的加速器"来对待,而不是"系统正常运转的必要零件",你的系统才经得起真实流量的冲击。
关键概念速查
| 概念 | 说明 |
|---|---|
| 缓存 | 挡在数据库前的高速存储,是性能优化而非数据权威 |
| Cache-Aside | 旁路缓存模式:读未命中查库回填,写更库后删缓存 |
| 缓存命中 | 读请求直接在缓存里取到数据,不必查数据库 |
| 回填 | 缓存未命中时,把查库得到的结果写回缓存 |
| 删除而非写入 | 写操作让缓存失效用 delete,把算新值推给读路径 |
| 先更库再删缓存 | Cache-Aside 的标准顺序,先改权威再让副本失效 |
| 延迟双删 | 写完延迟一段时间再删一次缓存,清掉可能回填的旧值 |
| 缓存穿透 | 反复查不存在的 key 打到数据库,用缓存空值挡 |
| 缓存击穿 | 热点 key 过期瞬间被大量请求击穿,用互斥锁重建 |
| 缓存雪崩 | 大量 key 同一时刻过期压垮数据库,用随机过期打散 |
避坑清单
- 不要把缓存当数据库的快照:它们是两个独立存储,更新不是原子的。
- 不要在写操作里更新缓存的值:用删除,把算新值推给读路径。
- 不要先删缓存再更数据库:并发下旧值会被回填,缓存与库反向。
- 不要以为"先更库再删缓存"就万无一失:仍要用延迟双删补漏。
- 不要给缓存设永不过期:过期时间是一致性的最后一道兜底防线。
- 不要忽略 cache.delete 失败:删除失败要重试,或靠过期时间兜底。
- 不要让不存在的 key 反复穿透:把查到的"空"也缓存起来。
- 不要让热点 key 裸过期:用互斥锁重建,避免击穿打垮数据库。
- 不要让大量 key 同时过期:过期时间加随机抖动,打散雪崩。
- 不要让系统依赖缓存正确:缓存随时可能丢、空、错,系统要照样对。
总结
回头看第一版那个"顺手更新一下缓存"的方案,它的错误很典型。它不在某一行代码,而在一个对缓存的根本误解:以为缓存是数据库的快照,改数据库时把缓存一起改,两边就一致了。真相是,数据库和缓存是两个独立的存储,"更新数据库"和"更新缓存"是两个无法被捆成原子的操作,中间的不一致缝隙是结构性的,消除不掉。你能做的,不是消灭不一致,而是设计一套策略,让不一致的窗口尽量短、且一定会自愈。
而把缓存一致性做对,工程量并不小。它不是"改完库顺手设一下缓存"那么简单,而是要理解缓存只是临时投影、数据库才是权威,要用 Cache-Aside 的骨架来读写,要用"删除"而不是"写入"来让缓存失效,要把顺序定成"先更库再删缓存",要用延迟双删补上并发回填的漏洞,要用过期时间做最终兜底,还要防住穿透、击穿、雪崩这三种会压垮数据库的异常。一套真正可靠的缓存方案,是这些环节一个不少地拼起来的。
这件事其实很像公司前台的那块"人员去向留言板"。每个员工此刻在不在工位,真实情况在各自的办公室里(这是数据库);前台挂一块留言板,抄着每个人的去向,访客来了不必跑遍每间办公室,看一眼留言板就知道(这是缓存)。麻烦在于,留言板是抄来的:某人临时出差了,如果没人去更新留言板,访客照着旧留言扑了个空。怎么办最稳?不是"谁状态一变就冲过去改留言板上那一条"——手忙脚乱容易抄错,两个人同时改还会打架(这就是为什么"写入新值"不如"删除")。更稳的做法是:谁的状态变了,就把留言板上他那一条擦掉;下一个访客来问,前台再现去核实一遍、重新写上(这就是删除 + 读时回填)。再加一条保险:留言板上每条留言都标个有效期,过期自动作废——万一某次真忘了擦,错的信息也不会一直挂着误导人(这就是过期兜底)。
这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你自己测,点一下读、点一下写,所有请求都是串行的、一个一个来的,"先更库再删缓存""删了又被回填"这些时序漏洞,一个都不会出现——你会觉得"读走缓存、写更缓存"这套天衣无缝。真正会把缓存的缝隙撑开的,是上线后的真实流量:成千上万的读写请求在同一毫秒里交错,删除操作偶尔因为网络抖动失败,慢读请求的回填恰好赶在写删除之后,大批缓存约好了似的同时过期——那些"两步之间的缝隙",会被真实并发精准地、反复地踩中,旧价格、错库存、下架了还能买,就涌进了工单。所以如果你正在给一个会变的数据加缓存,别等用户看着旧价格来投诉,才回头怀疑缓存。在加上这层缓存的时候就想清楚:我的读写用的是哪种模式、我更新缓存是删还是写、我的顺序对不对、并发回填怎么补、过期时间这道兜底设了没有——把"加缓存让它变快"和"管好缓存不让它出错"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。
—— 别看了 · 2026