防击穿是将同一key的并发请求合并为一次后端调用并广播结果,sync.once无法按key区分、不支持等待与结果分发;需用sync.map+channel实现key粒度的请求合并。

什么是防击穿,为什么不能直接用 sync.Once?
防击穿(cache stampede protection)本质是避免大量并发请求同时穿透缓存去查数据库——不是单纯“只执行一次”,而是把同一时间对相同 key 的多个请求合并成一次后端调用,再把结果广播给所有等待者。sync.Once 只能保证函数全局只执行一次,无法按 key 区分,也不能等待、聚合、分发结果。
典型错误是写一个 sync.Once + 全局变量,结果所有 key 共享同一个 once,或者误以为加个 map 就能解决并发安全问题,却忘了 map 本身非并发安全、漏掉等待逻辑、没处理 panic 传播。
用 sync.Map + channel 实现 key 粒度的请求合并
核心思路:每个 key 对应一个正在运行的请求(用 channel 作为等待队列),新请求到来时,若发现已有进行中的请求,就加入该 channel 等待;否则启动新 goroutine 执行实际逻辑,并把结果通过 channel 广播出去。
-
sync.Map存储key → chan interface{},注意 value 类型必须统一(建议用chan result,其中result是带 error 的结构体) - 启动 goroutine 前先
LoadOrStore,返回的 ok 为 false 表示当前 goroutine 是首个发起者 - goroutine 执行完必须清理
sync.Map中对应 key(否则内存泄漏),推荐用defer delete(map, key) - channel 用无缓冲或带缓冲 1 即可,不要设大 buffer,否则可能堆积未消费结果
示例关键片段:
在 Golang 中使用 samber/hot 进行内存缓存,支持 LRU、LFU、TinyLFU、W‑TinyLFU、S3FIFO、ARC、TwoQueue、SIEVE、FIFO 等淘汰算法,提供 TTL、缓存加载器及分片功能。
type result struct {
data interface{}
err error
}
func (c *Merger) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
ch, loaded := c.m.LoadOrStore(key, make(chan result, 1))
if loaded {
r := <h3>panic 和超时怎么处理?</h3><p>真实场景中,fn 可能 panic 或阻塞过久。不处理会导致 goroutine 泄漏、channel 永久阻塞、后续同 key 请求全部卡死。</p>
- 用
recover()捕获 panic,并发送含 error 的 result,避免 goroutine 异常退出却不通知等待者 - 超时必须由调用方控制(比如传入
context.Context),不能在 merger 内部硬编码time.Sleep—— 否则等待者无法主动取消 - channel 接收必须配合 context select,例如:
select { case r := - fn 执行时也应接收 context,确保数据库查询等操作可中断
什么时候不该用这个模式?
合并请求有隐性成本:内存占用(每个活跃 key 占一个 channel)、调度开销(goroutine 创建/销毁)、以及语义变更(原本并行的请求变成串行等待)。它适合读多写少、key 空间有限、且后端响应慢的场景。
如果 key 是用户 ID 且量级达百万,sync.Map 会退化,此时应考虑 LRU cache 配合 TTL 清理;如果后端响应稳定在 5ms 内,合并收益几乎为零,反而增加延迟;如果请求携带不同参数(如带 version 或 locale),必须把参数哈希进 key,否则合并会导致结果错乱。
最易被忽略的是 error 处理一致性:所有等待者收到的 error 必须完全相同(包括 stack trace),不能一个拿到 sql.ErrNoRows,另一个拿到 context.Canceled —— 这要求 fn 的 error 必须在 goroutine 内统一生成并广播,不能让各等待者各自重试或 fallback。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










