缓存一致性完全指南:从一次"运营改了价格用户还看到旧的"看懂为什么顺手更新缓存会出大问题

2023 年我做一个电商的商品详情页用户访问展示商品的名称价格库存详情页访问量很大我自然想到加缓存在数据库前面挡一层 Redis 第一版方案我做得很顺手读商品时先查 Redis 命中就直接返回没命中就查数据库把结果写进 Redis 再返回写商品时更新数据库再顺手把 Redis 里那条也更新成新值本地一测读得飞快改了价格刷新页面也是新的我心里很笃定缓存就是数据库的一个副本数据库改了顺手把缓存也改一下两边就一致了可等真实流量上来一串问题冒了出来第一种最先把我打懵运营改了价格数据库里明明是新的可用户看到的详情页价格还是旧的第二种最难缠我后来把更新缓存改成了删除缓存大部分时候好了但偶尔还是有用户看到旧价格而且死活刷不新第三种最头疼两个运营几乎同时改同一个商品改完之后缓存里的值既不是 A 改的也不是 B 改的是一个更早的旧值第四种最莫名其妙有个商品被下架了数据库里那条已经没了可缓存里还在我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为缓存就是数据库的一个快照可这个认知是错的本文从头梳理为什么顺手更新缓存会出问题 Cache-Aside 模式怎么读写更新缓存为什么要用删除而不是写入先删缓存还是先更数据库并发下缓存被旧值回填怎么办以及一些把它做扎实要避开的工程坑

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.updatecache.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 同一时刻过期压垮数据库,用随机过期打散

避坑清单

  1. 不要把缓存当数据库的快照:它们是两个独立存储,更新不是原子的。
  2. 不要在写操作里更新缓存的值:用删除,把算新值推给读路径。
  3. 不要先删缓存再更数据库:并发下旧值会被回填,缓存与库反向。
  4. 不要以为"先更库再删缓存"就万无一失:仍要用延迟双删补漏。
  5. 不要给缓存设永不过期:过期时间是一致性的最后一道兜底防线。
  6. 不要忽略 cache.delete 失败:删除失败要重试,或靠过期时间兜底。
  7. 不要让不存在的 key 反复穿透:把查到的"空"也缓存起来。
  8. 不要让热点 key 裸过期:用互斥锁重建,避免击穿打垮数据库。
  9. 不要让大量 key 同时过期:过期时间加随机抖动,打散雪崩。
  10. 不要让系统依赖缓存正确:缓存随时可能丢、空、错,系统要照样对。

总结

回头看第一版那个"顺手更新一下缓存"的方案,它的错误很典型。它不在某一行代码,而在一个对缓存的根本误解:以为缓存是数据库的快照,改数据库时把缓存一起改,两边就一致了。真相是,数据库和缓存是两个独立的存储,"更新数据库"和"更新缓存"是两个无法被捆成原子的操作,中间的不一致缝隙是结构性的,消除不掉。你能做的,不是消灭不一致,而是设计一套策略,让不一致的窗口尽量短、且一定会自愈。

而把缓存一致性做对,工程量并不小。它不是"改完库顺手设一下缓存"那么简单,而是要理解缓存只是临时投影、数据库才是权威,要用 Cache-Aside 的骨架来读写,要用"删除"而不是"写入"来让缓存失效,要把顺序定成"先更库再删缓存",要用延迟双删补上并发回填的漏洞,要用过期时间做最终兜底,还要防住穿透、击穿、雪崩这三种会压垮数据库的异常。一套真正可靠的缓存方案,是这些环节一个不少地拼起来的。

这件事其实很像公司前台的那块"人员去向留言板"。每个员工此刻在不在工位,真实情况在各自的办公室里(这是数据库);前台挂一块留言板,抄着每个人的去向,访客来了不必跑遍每间办公室,看一眼留言板就知道(这是缓存)。麻烦在于,留言板是抄来的:某人临时出差了,如果没人去更新留言板,访客照着旧留言扑了个空。怎么办最稳?不是"谁状态一变就冲过去改留言板上那一条"——手忙脚乱容易抄错,两个人同时改还会打架(这就是为什么"写入新值"不如"删除")。更稳的做法是:谁的状态变了,就把留言板上他那一条擦掉;下一个访客来问,前台再现去核实一遍、重新写上(这就是删除 + 读时回填)。再加一条保险:留言板上每条留言都标个有效期,过期自动作废——万一某次真忘了擦,错的信息也不会一直挂着误导人(这就是过期兜底)。

这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你自己测,点一下读、点一下写,所有请求都是串行的、一个一个来的,"先更库再删缓存""删了又被回填"这些时序漏洞,一个都不会出现——你会觉得"读走缓存、写更缓存"这套天衣无缝。真正会把缓存的缝隙撑开的,是上线后的真实流量:成千上万的读写请求在同一毫秒里交错,删除操作偶尔因为网络抖动失败,慢读请求的回填恰好赶在写删除之后,大批缓存约好了似的同时过期——那些"两步之间的缝隙",会被真实并发精准地、反复地踩中,旧价格、错库存、下架了还能买,就涌进了工单。所以如果你正在给一个会变的数据加缓存,别等用户看着旧价格来投诉,才回头怀疑缓存。在加上这层缓存的时候就想清楚:我的读写用的是哪种模式、我更新缓存是删还是写、我的顺序对不对、并发回填怎么补、过期时间这道兜底设了没有——把"加缓存让它变快"和"管好缓存不让它出错"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

LLM 应用评估完全指南:从一次"改了提示词修好一个 case 结果碰坏一片"看懂为什么肉眼看例子不算测试

2026-5-22 21:12:09

技术教程

Few-shot 提示工程完全指南:从一次"加了几个例子分类反而更偏了"看懂示例为什么是双刃剑

2026-5-22 21:26:52

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索