星期三下午三点,我盯着监控上那条近乎垂直的数据库负载曲线。按理说,平时就千把来次查询,不应该让服务器风扇狂转。根源查下来,是一个热key在Redis里刚好过期——就那一毫秒,5000个调用一起扑空。
每个请求都觉得自己有责任重建缓存。它们绕开失效的Redis,直接撞向同一张表,执行同一条昂贵查询。数据库从无聊变成冒烟,查询变得更慢,更多请求堵在后面,缓存根本没机会重新填满。这不是流量真有多高,而是同一份活儿重复干了5000遍。Go标准库扩展里恰好有一个小包专门处理这类场景:golang.org/x/sync/singleflight。
singleflight.Group 做的事情很直接:对同一个键,同一时刻只会运行一次函数。其他带着相同键进来的调用者,全都会等这次执行结束,然后大家一起拿到同一个结果。我把读取用户信息的逻辑包了一下,一行 group.Do(id, func() {...}),整个缓存回填路径上那些密密麻麻的并发请求就都安静下来了。
为了看清效果,我起了100个goroutine,让它们在一个50毫秒的窗口里用同一个键去调。底层函数用原子计数器记录真实执行次数。跑完一数,计数器只跳了1。换句话说,100个并发调用只触发了一次实际执行。这差不多就是这个库的全部卖点。
不过得说清楚,singleflight不是缓存。每次 Do 返回后,它立刻把键从内部映射里删掉。下次同一个键再来,还是会重新跑函数。它只管合并时间上重叠的调用,替代不了缓存。所以正确的用法是先查缓存,没命中再走 group.Do 去加载,最后把结果写回缓存。
Do 的第三个返回值 shared 是个很实用的监测点。它告诉你这份结果是不是被多个调用者共同拿走了。如果 shared 频繁为真,就说明合并确实在发力,省下不少重复计算。不过这个“共享”也埋着一个尖锐的提醒:所有等待者拿到的是完全一样的值。后面如果对这个值动了手脚,就可能惹出意料之外的并发问题,这一点值得在指标之外多留一个心眼。
热门跟贴