缓存穿透怎么办?五种防护方案从入门到实战

缓存穿透怎么办?五种防护方案从入门到实战 缓存穿透这个概念很多后端开发者第一次接触时都会觉得“不就是在缓存没命中的时候去查数据库吗”但真正处理过线上事故的人都知道最头疼的恰恰是那些在数据库里根本不存在的 key。因为缓存永远不可能命中每次请求都会穿透到数据库层一旦密集请求上来数据库 QPS 被瞬间拉满连接池耗尽正常业务跟着全部超时。给数据库“请保安”本质就是在缓存和数据库之间多设几道闸门把无效请求挡在半路而不是让它们全部冲到数据库门口排队。这篇文章适合正在做 Web 后端、微服务接口、网关层或者在线查询业务的开发者。看完之后你可以按照自己的业务规模选择参数校验、限流、空值缓存、布隆过滤器、互斥锁这几种手段里的两到三层组合使用并且能判断出每种方案到底能挡住什么、挡不住什么。1. 缓存穿透不是缓存雪崩它更像“敲门党”1.1 一个真实场景商品详情页、订单查询、用户信息查询这类接口标准流程都是先查缓存缓存没命中再查数据库。正常情况下热点数据会被缓存挡住数据库只需要处理小部分回源请求。但有一种场景会让缓存彻底失效客户端不断请求一个不存在的商品 ID比如999999999。第一次请求过来缓存没有数据库查不到返回空结果。第二次请求过来因为数据库里根本没有这条数据Redis 里也没有被写入任何东西所以依然查不到。第三次、第四次、第 N 次都是同样的结果。如果这些请求来自正常用户顶多是多几次慢查询但它们一旦是脚本或异常流量比如每秒几千个随机 ID数据库的连接池很快就会被占满那些本来能正常处理的读请求也跟着排队超时。这类问题最坑的地方在于它不是并发量真的有多大而是大量请求在做无效查询把资源浪费在不存在的数据上。数据库 QPS 可能从几百瞬间抬到几千甚至几万但业务上完全没有任何新增的有效查询。1.2 缓存为什么挡不住不存在的 key缓存的优点是“把读多写少的热数据放在更快的位置”。它适合保存已经存在、会被反复读取的数据。但一个数据如果在数据库里根本不存在它就不会成为热数据缓存里也永远不会出现这个 key。很多人容易把缓存穿透、缓存击穿、缓存雪崩搞混。击穿是某个热点 key 刚好过期同一时间大量请求同时回源雪崩是大批 key 在同一时间段失效导致数据库压力同步上升。这两个问题都有一个前提数据本身是存在的只是缓存暂时没有。而穿透是“查什么都不存在”缓存无论如何都不可能命中。正因为这样穿透问题的防护思路和其他两个完全不同它需要拦截的是“无效查询”而不是单纯加速“有效回源”。1.3 怎么判断已经发生了穿透不要靠感觉判断看指标更可靠。如果出现以下情况大概率是发生了缓存穿透缓存命中率突然下降但业务流量没有明显增长。数据库查询次数远高于缓存未命中次数二者比例异常。日志里出现大量“数据不存在”或者“空结果”记录。数据库 CPU、连接数、慢查询数在某个时段同时飙升持续时间较长。这里有一个容易忽略的点只看缓存命中率其实是滞后的因为命中率下降只能说明缓存效率变低了不能说明是不是穿透。更好的监控指标是“缓存未命中且数据库结果为空”的次数把这个数单独统计出来。如果这个数字异常高那么穿透请求一定不少。2. 第一道保安请求入口的参数校验与限流2.1 先拦截明显不合法的请求很多穿透流量其实用的是明显不合理的参数。比如一个商品 ID 是负数、0、超长字符串或者一个订单号包含了太多特殊字符。这些请求根本不需要进入业务层在网关或 Controller 入口直接校验参数就能挡掉一大半。以商品查询为例一个很简单的判断是商品 ID 必须大于 0且格式必须为纯数字。如果不符合直接返回参数错误不查缓存也不查数据库。public Product getProduct(Long productId) { // 非法参数直接拦掉不进入缓存和数据库 if (productId null || productId 0 || productId MAX_VALID_ID) { throw new IllegalArgumentException(invalid product id); } // ... 后续缓存、数据库逻辑 }这种校验成本极低但效果很直接。凡是格式明显不对的请求连 Redis 都不需要访问。2.2 限流和降级要放在哪一层只做参数校验还不够因为恶意请求可以伪装成合法参数。比如一个不存在的 8 位数字 ID格式上完全合法但数据库里就是没有。这时候需要限流。限流可以放在网关层也可以放在业务代码里。常见维度有单 IP 每秒请求数、单个用户每秒请求数、接口总 QPS。超过阈值后直接返回“操作频繁”或者降级为默认提示不进入数据库查询。对数据库来说限流并不能真正阻止穿透但可以防止穿透发生后数据库被打爆。如果接口 QPS 是 20000但数据库只能承受 5000那么限流到 3000 或 4000起码能保证数据库不宕机。限流参数要结合实际压测结果去调不要拍脑袋定。一般来说限流阈值可以定为数据库能够承受的峰值 QPS 的 80% 左右。2.3 为什么不能只靠输入校验参数校验只能挡住“非法参数”挡不住“参数合法但数据不存在”的情况。比如用户查询一个已下架或已删除的商品。查询一个从未创建过的订单号。查询一个已经清空的历史数据。这些 key 在格式上完全合法数据库里却查不到。参数校验根本没有办法区分它们。所以输入校验只能作为第一道门不能作为核心防护方案。如果你发现穿透问题还在不要只在入口校验上纠结而是要往下看空值缓存和布隆过滤器。3. 第二道保安缓存空值3.1 空值缓存思路空值缓存的思路很简单当数据库查询结果为空时把“这个 key 不存在”也写进缓存并设置一个较短的过期时间。后续相同 key 的请求在 TTL 内直接命中这个空值不再进入数据库。EMPTY_FLAG __EMPTY__ def get_product_detail(product_id): # 先查缓存 cached redis.get(fproduct:{product_id}) if cached is not None: # 缓存命中的是空标记说明数据库查不到 if cached EMPTY_FLAG: return None return cached # 缓存未命中查数据库 product db.query_product(product_id) if product is None: # 把空结果写入缓存TTL 设置为 30 秒 redis.set(fproduct:{product_id}, EMPTY_FLAG, ex30) return None redis.set(fproduct:{product_id}, product, ex3600) return product这里的关键判断标准是缓存里有值即使是空标记就直接返回不再进入数据库。这样同一个不存在的 ID 在 30 秒内的后续请求都打在 Redis 上数据库压力会明显下降。3.2 TTL 怎么设置空值缓存的 TTL 是一个非常关键的参数。设太短恶意请求每隔一段时间就能穿透一次数据库还是会频繁被访问设太长数据库后来真的新增了这条数据但缓存里还是空标记用户在一段时间内查不到新数据。我的经验是TTL 按业务对数据一致性的容忍度来定商品、订单这类“新增后很少变”的数据可以设 3 到 5 分钟。库存、价格、活动状态这类变化快的数据设 30 秒到 60 秒。如果业务对“新增数据立即可见”要求很高不要只依赖自然过期应该在写数据库时主动删除对应的空值缓存。还可以在写入空值缓存时附带一个最后写入时间后台任务定期清理过期时间过长的空 key避免内存里堆积太多无效数据。3.3 空值缓存的问题空值缓存有两个最明显的问题。第一个是内存浪费。如果恶意请求用大量随机 ID 刷Redis 里会堆积很多空 key。虽然每个 key 占用的内存不大但数量上来之后内存会明显增长。解决办法是控制空值缓存的 TTL并且定期清理。如果内存非常紧张可以设置一个空值缓存的条数上限超过上限后优先淘汰最久未访问的空 key。第二个是数据一致性问题。数据库新增一条记录后如果对应的空值缓存还没过期用户会短暂看不到这条新数据。对一致性要求高的场景应该在写入数据库时主动删除对应的空值缓存而不是等它自然过期。比如商品创建成功后执行一次redis.delete(product: productId)。注意空值缓存适合中低频率的无效 key 查询但如果攻击方使用“每次随机生成不同 key”的方式空值缓存就无法形成有效命中因为每个 key 都只被访问一次。这时候必须靠布隆过滤器。4. 第三道保安布隆过滤器4.1 布隆过滤器在干什么布隆过滤器是一个概率型数据结构用来判断“一个元素是否可能存在”。它的特点是只用很少的内存就能在极短时间内告诉你这个 key 肯定不存在还是可能存在。这里的关键词是“可能存在”。布隆过滤器会误判但它只会把“不存在的元素”误判成“可能存在”不会把“存在的元素”误判成“不存在”。所以在缓存穿透场景里它的作用就是拦截那些“肯定不存在”的请求。举个例子如果系统里一共有 100 万个合法商品 ID把这 100 万个 ID 都加入布隆过滤器。当一个请求传入 ID888888时过滤器如果判断这个 ID 不在集合里那就可以直接返回“商品不存在”连缓存都不查。如果过滤器判断存在那才继续走缓存和数据库。4.2 参数设计布隆过滤器有三个核心参数预期元素数量n、误判率p、位数组大小m和哈希函数数量k。前两个是输入后两个是需要计算的。常用公式m - (n * ln p) / (ln 2)^2 k (m / n) * ln 2举个例子。假设业务里预期会有 100 万个合法 ID误判率允许 1%那么位数组大小约 958 万 bit约 1.2MB。哈希函数数量约 7 个。如果误判率要求降到 0.1%那么位数组大小约 1438 万 bit约 1.8MB哈希函数数量约 10 个。这个内存占用在绝大多数业务场景下都可以接受。我的建议是预期元素数量按未来半年业务量的峰值来估算不要只按当前数据量。误判率按业务场景取 1% 到 3% 就够没有必要追求极低误判率因为布隆过滤器只是第一层筛选后面还有缓存和数据库兜底。4.3 布隆过滤器的初始化与更新布隆过滤器必须提前初始化不能等到请求来了再往里加。常见做法有几种系统启动时从数据库读取所有合法 ID批量加入布隆过滤器。每天定时全量刷新一次用脚本把当天所有有效 ID 重新构建。增量场景下通过消息队列在新增记录时更新。这里最容易踩坑的是增量更新。比如商品 ID 是每天新增的如果布隆过滤器只在启动时初始化之后新增加的商品 ID 没有同步进去这些合法 ID 就会被过滤器误判成“不存在”导致正常用户查询商品也返回不存在。这种事故比穿透更严重。更麻烦的是删除操作。布隆过滤器本身不支持删除单个元素因为多个元素可能映射到同一个 bit 位直接清零会影响其他元素。如果业务频繁删除 ID就需要引入计数布隆过滤器或者在业务层面定期全量重建布隆过滤器。4.4 布隆过滤器的局限布隆过滤器能挡住“大部分不存在的 ID”但挡不住“存在但已删除”的数据。一个商品 ID 在布隆过滤器里存在但商品已经被删除数据库查不到请求依然会穿透到数据库。所以布隆过滤器通常需要和空值缓存一起用布隆过滤器挡掉绝大多数随机不存在的 key。穿过布隆过滤器的那一小撮请求落到数据库以后用空值缓存暂时挡住避免短时间内重复回源。另外布隆过滤器不能作为唯一的拦截手段。它只是说“可能存在”那 1% 到 3% 的误判流量仍然需要系统能够承受。不要把保护全押在过滤器身上。5. 第四道保安互斥锁与回源控制5.1 穿透场景下的“回源风暴”即使做了空值缓存和布隆过滤器还可能出现一种情况缓存还没建立大量相同请求同时到达数据库。常见于一个冷门数据第一次被查询或者缓存刚好过期同一时间来了很多相同请求。每个请求都发现缓存没有然后一起去数据库查询数据库瞬时压力暴增。这和穿透不同因为数据本身可能是存在的问题出在“并发回源”这个动作。如果把数据库响应时间从 10ms 变成 100ms这批请求就会越积越多可能把数据库连接池打满。所以在做穿透防护时也要顺带考虑回源并发控制。5.2 互斥锁思路互斥锁的思路是当多个请求发现缓存没有时只允许一个请求去查数据库并写回缓存其他请求等待锁释放后直接读缓存。def get_product_detail_with_lock(product_id): cached redis.get(fproduct:{product_id}) if cached is not None: return cached lock_key flock:product:{product_id} # 尝试获取锁拿到锁的请求才允许查数据库 if redis.set(lock_key, 1, nxTrue, ex3): try: product db.query_product(product_id) redis.set(fproduct:{product_id}, product, ex3600) return product finally: redis.delete(lock_key) else: # 拿不到锁短暂等待后重试 time.sleep(0.05) return redis.get(fproduct:{product_id})这里有几个参数需要理解nxTrue表示只有当 key 不存在时才能设置成功这是实现锁的关键相当于“只有一个请求能抢到锁”。ex3是锁的自动过期时间防止持有锁的线程崩溃后锁永远不释放。拿不到锁的请求不要无限重试最多重试几次或者设置一个超时时间否则请求堆积会拖垮业务线程。5.3 锁的粒度与超时锁的粒度越细越好最好精确到单个 key。如果对整个查询接口加锁等于把所有商品查询串行化即使缓存失效了吞吐量也上不去。粒度细到lock:product:12345这种级别不同商品的查询互不影响。锁的超时时间要结合数据库的慢查询时间设置。如果数据库查询平均 10ms但极端情况下可能 500ms锁超时设置 1 秒基本够用。如果设置太短数据库还没查完锁就提前释放其他请求又会重复查数据库设置太长一旦持有锁的线程卡住其他请求会长时间拿不到锁发生大量等待。这类互斥锁主要解决的是“热点 key 失效”或“冷门 key 首次查询”时的回源风暴严格来说属于缓存击穿防护。但它在穿透防护的组合拳里也很有价值因为空值缓存和布隆过滤器都不能保证“同一时刻只有一个人去查数据库”。生产环境通常把四层方案一起启用而不是只选一个。6. 组合拳落地从最小方案到生产配置6.1 三层方案怎么选不同业务规模适合不同方案组合不要一上来就全部实现。业务规模推荐方案原因学习项目、低流量小站参数校验 空值缓存实现简单覆盖大部分场景中型业务有真实用户参数校验 布隆过滤器 空值缓存能挡随机 key也能挡重复穿透高并发、核心交易链路参数校验 布隆过滤器 空值缓存 互斥锁兼顾穿透拦截和回源并发控制如果你之前已经实现了空值缓存再引入布隆过滤器时不要急着把空值缓存去掉。两者职责不同布隆过滤器挡“大概率不存在的随机 key”空值缓存挡“布隆过滤器误判的和已删除但过滤器未清理的 key”。6.2 监控指标怎么配线上环境不能只靠代码必须有监控指标。建议在 Redis 层和数据库层分别埋点缓存命中率总命中次数 / 总查询次数。空值缓存命中数标记为空结果的缓存被命中的次数。数据库空结果查询数数据库返回空集合的次数。布隆过滤器拦截数在过滤器位置被直接拦截的请求数。数据库慢查询数和连接池占用率。其中“数据库空结果查询数”是最直接的穿透信号。可以为它单独设置一个阈值比如每分钟超过 200 次就告警。如果这个指标持续走高说明有异常流量在查询不存在的 key需要立即检查布隆过滤器是否需要更新、空值缓存 TTL 是否合理。6.3 常见问题和排查顺序如果接口已经出现延迟飙升按这个顺序排查不要乱动参数先看数据库慢查询和连接数。如果数据库确实被打满了先把限流阈值降下来保证数据库不宕机。再看 Redis 命中率。命中率低且请求量高穿透可能性大。打开请求日志统计访问次数最多的 key 模式。如果都是不存在的 ID基本可以确认是穿透。检查布隆过滤器是否需要更新。新业务数据有没有同步到过滤器里。最后检查空值缓存 TTL 和互斥锁超时时间。如果这些参数之前没做过压测验证不要随意调整。这里最容易犯的错误是看到 Redis 命中率低就认为扩大缓存时间能解决问题。但缓存时间再长也不能让“不存在的 key”变成存在。缓存穿透的根因是无效数据请求不是缓存时长不足。先把请求的有效性过滤清楚再谈缓存优化。另外一个容易忽略的点是日志和输出一致性。批量任务或者定时任务如果触发了大量不存在的 key 查询比如每天跑一次商品同步脚本这个脚本会定时刷一批已经下架的 ID同样会造成周期性穿透。这种情况需要在脚本入口做一次布隆过滤器预校验或者把这批 ID 手动从库里排除。最后留几个我自己的判断。空值缓存是性价比最高的第一道防线布隆过滤器适合拦截大规模随机穿透互斥锁用来防回源风暴参数校验和限流则是上游兜底。真正落地时不要追求把所有层都做得很重而是先确认当前业务最大的风险点是随机不存在的 key还是合法 key 被高频反复查数据库然后针对性地选两层做扎实。预防缓存穿透没有“装一次保安就完事”的终点这更像持续调整保安配置的过程。你要经常观察空值命中率、布隆过滤器拦截数、数据库空结果查询数这些指标发现哪一层没发挥作用就去更新数据、调整参数或者补齐增量逻辑。把无效查询控制在数据库之前吞吐量和稳定性自然会好转。