请求合并(singleflight)是什么
同一时刻对同一个键的回源请求合并成一次:第一个请求去查数据库并回填缓存,其余并发请求等待同一个结果。进程内实现通常用 singleflight 或异步合并队列;跨进程则需要分布式锁加双检,并用短超时兜底。它是缓存击穿治理与通用减负手段——热点键失效瞬间,若不做合并,成百上千个请求会同时打到数据库。
基本信息
| 适用场景 | 热点键失效瞬间的并发回源(缓存击穿) |
|---|---|
| 进程内方案 | singleflight(Go)、异步合并队列、锁 + 双检 |
| 跨进程方案 | 分布式锁(Redis SET NX)+ 双检 + 短超时 |
| 合并窗口 | 毫秒级,窗口过长会放大单次失败的传播范围 |
| 兜底策略 | 超时降级返回旧值或默认值,避免全部请求排队 |
| 配套手段 | 逻辑过期(后台异步刷新)避免集中失效 |
典型使用场景
- 热点商品详情:缓存过期瞬间的并发回源合并
- 配置与字典数据:读多写少且被高频读取
- 启动预热:大量实例同时启动时避免集体打库
- 缓存穿透防护的补充:空值缓存之外再加合并
- 慢查询保护:回源本身很慢时,合并能显著降低数据库压力
Go singleflight 示例
Go singleflight 示例
import "golang.org/x/sync/singleflight"
var g singleflight.Group
func GetUser(uid string) (string, error) {
v, err, _ := g.Do("user:"+uid, func() (interface{}, error) {
if v := redis.Get("user:" + uid); v != nil {
return v, nil // 命中缓存直接返回
}
row, err := db.Query(uid) // 并发只执行一次回源
if err == nil {
redis.Set("user:"+uid, row, 300)
}
return row, err
})
return v.(string), err
}方案对照
| 方案 | 适用范围 | 代价 |
|---|---|---|
| singleflight | 单进程内的并发合并 | 无法跨进程合并,实例多时效果有限 |
| 分布式锁 + 双检 | 跨进程合并 | 锁本身的可用性成为关键路径 |
| 逻辑过期 | 热点键不集中失效 | 需要后台刷新任务与数据一致性权衡 |
| 空值缓存 | 防穿透(查询不存在的数据) | 需控制空值 TTL,避免掩盖数据新增 |
合并窗口要短(毫秒级):窗口太长会把单次回源的失败传播给所有等待者,也会让整体延迟被最慢的那次回源拖住;配合超时与降级(返回旧值或默认值)才是完整方案。
合并键必须包含全部查询参数:只按用户 ID 合并而忽略其他筛选条件,会把不同请求的数据相互串用,这类错误在压测中很难发现,往往上线后由用户投诉才暴露。