凌晨三点,你的数据库 CPU 飙升至 100%。流量看起来很正常,请求数也没有异常升高。然而,你的主存储系统却在崩溃,因为每一个请求都在同一时刻请求完全相同的内容。
这就是“惊群效应”(thundering herd problem)。
想象一下演唱会结束后的体育场。在长达一小时的时间里,同一个出口可以从容地让所有人离开。但如果整个人群决定在短短十秒钟内通过这扇唯一的门离开,门并不会坏掉,它只是无法处理如此高的并发量。拥挤才是 Bug,而不是门本身。
惊群效应是如何产生的
只要许多进程在同一个事件上同步,这种模式就会出现。你不需要恶意流量,普通的系统也会自找麻烦。
缓存过期。 一个热点缓存键失效了。它可能是一个产品目录、功能开关(feature flag)或用户的权限列表。成千上万个应用服务器同时察觉到了这个空缺。每个服务器出于好意,都打开了自己的数据库连接,并运行相同的重型查询来重建该值。数据库从未被配置为能够并行回答两千次相同的昂贵问题。一个过期的键就能让整个集群瘫痪。
连接池唤醒。 在某些架构中,许多工作进程会阻塞在单个条件上,等待任务到达。当该条件触发时,操作系统会唤醒每一个处于睡眠状态的工作进程。但实际上只有一个进程能获取到连接或任务。其余的进程醒来后发现自己输掉了竞争,只能再次进入睡眠。这种循环会因为上下文切换而消耗大量 CPU,并可能导致实际的生产性工作因资源匮乏而无法进行。
重试风暴。 下游服务出现抖动。每个客户端捕获到超时后,都会等待一个固定的时间间隔(例如恰好一秒)然后再重试。当服务恢复并重新接受流量时,所有客户端会在同一瞬间涌入。由于服务仍处于冷启动或恢复阶段,它会再次宕机。如此循环往复。
定时任务冲突。 如果你安排在所有实例上于 UTC 时间 00:00 准时运行维护、报告生成或缓存预热,你就在以极其精确的方式制造流量峰值。这种负载是可预测的,但其集中程度是致命的。
请求合并 (Request Coalescing)
解决缓存击穿(cache stampedes)最有效的方法是停止重复回答同一个问题。当发生缓存未命中时,你不希望 2,500 个线程各自运行数据库查询。你希望只有一个线程运行查询,而其他 2,499 个线程等待该结果。
这种模式通常被称为请求合并(request coalescing)。在 Go 语言中,golang.org/x/sync 包中的 singleflight 提供了标准的实现方式。第一个为给定键调用 Do 的 goroutine 会开始执行任务。后续使用相同键的调用者会阻塞在同一个 Do 调用上。当任务完成时,所有等待者会同时收到结果。你只执行了一次查询,却服务了 2,500 个请求。
你也可以通过使用 Promise、Future 或 Channel 的内存并发 Map 来构建类似的行为。诀窍在于原子性地检查并注册正在进行的(in-flight)请求。如果请求失败,所有人都会收到错误,这通常是正确的处理方式。如果请求成功,所有人都会得到缓存值,随后的请求将直接命中缓存。数据库负载会从垂直飙升的尖峰变为平稳的直线。
概率性提前过期 (Probabilistic Early Expiration)
有时你希望完全避免这种“踩踏”现象,而不是去管理它。这时就需要引入“概率性提前过期”(probabilistic early expiration),它可以通过 XFetch 等算法来实现。
与其设置一个硬性的过期时间,不如让每个缓存值都带有一个“软过期窗口”。当请求到达时,应用程序会生成一个随机数并进行简单的概率检查。如果随机数的结果为“是”,那么该请求会提前刷新缓存。如果不是,该请求则返回稍微陈旧(stale)的值。
由于每个请求都会进行自己的随机判定,刷新操作会分散在整个软过期窗口内。一个客户端可能在硬过期前 40 秒进行刷新,另一个在 12 秒前刷新,而大多数请求则完全不会触发刷新。工作负载从单一的同步尖峰转变为平缓的
