缓存雪崩或击穿所造成的一个关键风险,是并发未命中对同一依赖发起大量重复工作。同时到期只是触发条件之一:容量淘汰、刚部署的空缓存、缓存服务不可用,都可能造成类似突发。即使只有一把热门键,也足以压垮后端。判断风险时应数清楚:一份结果尚未计算完的这段时间内,到底有多少次重建真正抵达数据源。

在真实边界内合并工作

Go 的 singleflight Group 对同一键,在这个 Group 内最多保持一次在途函数执行;重复调用者共享结果,也共享错误。不同 Group 之间不会协调,即使它们位于同一个进程;不同服务实例通常也各有自己的 Group。因此它既不是分布式锁,也不是保存已完成结果的缓存。把 Group 放在每个请求内部重新创建,无法实现跨请求合并。

实际使用时,应让请求共享同一个 Group,并使键准确表示等价工作。不同租户、权限、币种或响应表示如果错误地使用同一个键,可能拿到不属于自己的结果。对于缓存回填,在共享函数内部应再次检查缓存,确实仍缺失才访问数据源,并在函数返回前完成成功结果的缓存写入。这些存储操作都不是 singleflight 自动提供的能力。

错开时间不能改变新鲜度承诺

随机抖动可以打散不同条目或独立副本的到期时间,却不能阻止访问同一过期热键的人一起未命中。如果最大 TTL 是业务规定的新鲜度上限,在有界范围内随机缩短寿命,不会偷偷越过这个上限;任意增加正向抖动则改变了原有承诺。不能脱离请求量、计算成本和允许陈旧程度,宣称几个百分点或几秒钟就普遍足够。

提前刷新和允许使用旧响应,有机会避免用户等待,但必须有明确业务语义。暂时展示上一版公开目录可能可接受,已经撤销的权限或当前余额则未必可以。并发合并只减少重复计算,不负责判断陈旧内容能否使用;如果缓存完全是空的,系统仍需要取得一份数据源结果,不能把后台刷新当作无条件的可用性保证。

坏掉的样子

共享加载函数一旦卡住,可能同时拖住很多等待者。要为数据源调用设置执行期限,也让每个调用者能限制自己的等待时间。DoChan 返回的是结果通道,不是取消机制:某个等待者离开,不会自动停止底层执行。Forget 允许同一键重新启动一次执行,但并不取消原有执行,用它解除等待可能重新制造两次并行加载。

错误只在本次执行仍处于在途状态时共享。执行结束后立刻重试,会向故障依赖发起一轮又一轮新请求。应根据依赖的实际约束设置重试与准入策略,对可以缓存的“不存在”和瞬时失败分别处理。另一个常见误判是把进程内的数字推广到全局:一百个各有 Group 的实例,仍可能同时产生一百次重建,本地只执行一次不等于源站只收到一次。

快速验证

需要 Go 1.25 或更新版本;本次使用 Go 1.27.0 和 x/sync v0.22.0。在下面创建的独立模块目录中,将 Go 代码放入 main.go,再运行 go run -race main.go。只有依赖安装需要外网。程序从“已经发生冷缓存未命中”的边界开始,用有计数器的加载函数观察行为,没有连接真实数据库、缓存服务,也没有实现 TTL 定时器。

stampede_lab=$(mktemp -d)
cd "$stampede_lab"
go mod init example.invalid/stampede-lab
go get golang.org/x/sync@v0.22.0
package main

import (
	"errors"
	"fmt"
	"sync"
	"sync/atomic"

	"golang.org/x/sync/singleflight"
)

const callers = 12

func require(ok bool) {
	if !ok {
		panic("unexpected result")
	}
}

func wave(groups []*singleflight.Group, failure error) int32 {
	var calls atomic.Int32
	gate := make(chan struct{})
	results := make([]<-chan singleflight.Result, callers)
	load := func() (any, error) {
		calls.Add(1)
		<-gate
		if failure != nil {
			return nil, failure
		}
		return "catalog-v1", nil
	}
	for i := range results {
		results[i] = groups[i%len(groups)].DoChan("catalog", load)
	}
	close(gate)
	for _, result := range results {
		r := <-result
		require(r.Shared && r.Err == failure)
		if failure == nil {
			require(r.Val == "catalog-v1")
		} else {
			require(r.Val == nil)
		}
	}
	return calls.Load()
}

func main() {
	var plain atomic.Int32
	var workers sync.WaitGroup
	gate := make(chan struct{})
	for range callers {
		workers.Add(1)
		go func() {
			defer workers.Done()
			plain.Add(1)
			<-gate
		}()
	}
	close(gate)
	workers.Wait()
	require(plain.Load() == callers)
	fmt.Println("without coalescing: 12 loader calls")

	group := new(singleflight.Group)
	require(wave([]*singleflight.Group{group}, nil) == 1)
	require(wave([]*singleflight.Group{group}, nil) == 1)
	fmt.Println("one Group: 1 call per wave; completed results are not cached")
	other := new(singleflight.Group)
	require(wave([]*singleflight.Group{group, other}, nil) == 2)
	fmt.Println("two independent Groups: 2 loader calls")

	failure := errors.New("origin unavailable")
	require(wave([]*singleflight.Group{group}, failure) == 1)
	require(wave([]*singleflight.Group{group}, nil) == 1)
	fmt.Println("one error reaches 12 callers; next wave starts a new load")
}

未合并的基线执行十二次加载。所有调用加入同一个 Group 时,每一轮只执行一次;在同一个 Group 上再运行第二轮,仍会新增一次执行,说明完成结果并未被保存。把十二名调用者分给两个独立 Group,就得到两次加载。故障轮次让十二名调用者收到同一个错误,随后成功的轮次仍会重新加载,而不是读取已缓存的错误结果。

实验用通道闸门让共享加载保持在途,直到所有 DoChan 调用完成注册才放行,因此无需靠睡眠长度或碰巧的调度顺序制造重叠。这里同一轮的所有结果都会标记 Shared 为真,包括最初发起者收到的结果。每个结果通道只接收一次即可;DoChan 不会关闭这些通道,如果对它执行 range 并等待结束,程序就会一直等待。

验收真实服务时,应在隔离环境安排冷缓存测试,同时记录加载次数、等待人数、请求延迟和失败数。验证范围需要包括多个实例和数据源超时,不能只看正常运行时漂亮的缓存命中率。这次 race 检查证明的是此程序在观察到的执行中没有报告数据竞争,并不覆盖生产缓存所有交错顺序,更不代表全局容量、超时及恢复策略已经验收。

延伸阅读

来源