直接用sync.mutex+map实现函数缓存易出错,因未命中时若将函数调用置于锁外,会导致多goroutine重复计算、竞态写入及panic;正确做法是将“查key→调函数→写map”全过程原子化加锁,或采用double-checked locking减少锁持有时间。

sync.Mutex + map 组合能实现并发安全的函数缓存,但必须把“函数调用结果”和“缓存更新逻辑”全部包进锁里,否则极易出现重复计算、竞态写入或 panic。
为什么直接包裹函数调用还不够?
常见错误是只在 Get 和 Set 时加锁,却把函数执行本身放在锁外:
- 多个 goroutine 同时查不到 key → 全部触发函数调用 → 重复执行(比如 DB 查询、HTTP 请求)
- 函数返回后,各自往
map写结果 → 最后一个写入覆盖前面的,但中间可能已有 goroutine 拿到部分旧值 - 若函数返回结构体指针或 map/slice,还可能因共享底层数据引发隐性竞态
正确做法:查不到时,**先占住锁、再调用函数、再写入 map、最后解锁** —— 整个过程原子化。
如何避免重复计算?用 double-checked locking 模式
高频场景下,光靠一把大锁会成为瓶颈。可结合 sync.Once 或「先释放锁再计算、再加锁写入」的 double-check 逻辑:
- 第一次查
map,没命中 → 释放读锁(如果用了RWMutex) - 单独执行函数(无锁)
- 重新加写锁,再次检查 key 是否已被其他 goroutine 写入 → 若已存在,丢弃本次结果;否则写入
注意:sync.Once 只适合「全局唯一初始化」,不适用于每个 key 独立缓存,别误用。
sync.RWMutex 在函数缓存里真能提速吗?
不能简单替换。函数缓存的 Get 看似只读,但实际包含「未命中时触发计算+写入」路径,该路径必须用写锁。所以:
- 读多写少且命中率 >95% 时,
RWMutex才有收益 - 一旦命中率下降或函数耗时变长,写锁等待时间会拖垮整体吞吐
- 更稳妥的做法:用
Mutex,但把函数调用移出锁外(配合 double-check),减少锁持有时间
压测显示,当函数平均耗时超过 1ms,锁内执行直接导致 QPS 下降 40% 以上。
缓存值带 TTL 时,锁怎么设计?
TTL 不是加个 time.Time 字段就完事。关键点在于:过期判断和写入必须在同一个锁保护下完成,否则会出现「刚判未过期、写入前已过期」的窗口:
- 每次
Get都要检查value.expiredAt.Before(time.Now()),并在同一锁内决定是否丢弃 -
Set时,必须把expiredAt和value一起写入map,不可分两步 - 不要在锁外调用
time.Now()后传入,避免时间漂移误差累积
更轻量的做法:惰性过期(get 时检查),不做后台清理 —— 多数业务场景够用,且避免定时 goroutine 与主逻辑争锁。
真正容易被忽略的是:函数缓存的本质不是「存数据」,而是「协调多个 goroutine 对同一输入的协作执行」。锁的边界必须覆盖「判断 → 计算 → 存储」全链路,任何拆分都会引入竞态。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











