使用 time.ticker 的 goroutine 并非无阻塞,需拆解节奏、锁粒度与时间判断;缓存读写应优先用 sync.rwmutex,清理函数须基于统一时间快照、分批扫描、避免 map 迭代删除,并提供可控的 start/stop 接口。

直接起一个带 time.Ticker 的 goroutine 并不等于“无阻塞执行”——它只是把阻塞挪到了后台;真正要避免卡住读请求、防止 GC 尖刺、控制资源抖动,得拆开节奏、锁粒度和时间判断三件事。
用 sync.RWMutex 而不是 sync.Mutex 保护缓存读写
缓存场景几乎全是读多写少,清理只是周期性写操作。如果用 sync.Mutex,每次清理都会让所有并发 Get 等待锁释放,吞吐直接掉一倍以上。
-
Get和Exists必须只调RUnlock(),不能在RLock()持有期间做任何可能阻塞的操作(比如 HTTP 请求、DB 查询) -
Set、Delete、清理函数必须用Lock()/Unlock() - 别在
RLock()里调time.Now()做过期判断——看似快,但若系统时间跳变或纳秒级误差,同一轮清理中部分条目会漏删或误删
清理函数必须基于统一时间快照,且每次只扫固定条目
错误写法是 for k, v := range cacheMap 配合 delete() —— Go map 迭代时删元素会触发迭代器失效,行为未定义;更糟的是,一次性全扫百万条目会让 GC 瞬间吃紧、STW 明显。
- 清理前先调
now := time.Now(),后续所有item.ExpiredAt判断都基于这个快照:if now.After(item.ExpiredAt) - 每次只扫描固定数量(如 100 条),用索引手动步进:
keys := make([]string, 0, len(c.data)); for k := range c.data { keys = append(keys, k) },再遍历keys[0:min(n, len(keys))] - 若
len(c.data) == 0,直接continue,空跑毫无意义 - 存储过期时间必须用绝对时间
time.Time,别存相对time.Duration——避免反复调time.Now().Add(ttl)引入精度偏差
启动和停止必须可控,不能写死在 init() 或 main()
全局启动清理 goroutine 是测试噩梦:无法 mock、无法等它退出、服务未就绪就开扫会导致空 map 竞争或 panic。
- 封装成结构体,带
Start()和Stop()方法,内部用done chan struct{}控制退出 - 启动时机必须晚于服务就绪:比如 HTTP server 成功调用
ListenAndServe后,或 gRPC server 完成启动回调 - 停止时机必须早于服务关闭:收到
SIGTERM后,先调cleaner.Stop(),再等 goroutine 退出,最后调server.GracefulStop() - 别用
for range time.Tick(),要用time.NewTicker+select+case 显式退出
最容易被忽略的点是:清理逻辑本身不能成为性能瓶颈——它不该抢锁太久、不该反复调 time.Now()、不该一次扫太多。节奏(Ticker 间隔)、粒度(每次扫多少)、快照(统一 now)这三者缺一不可,否则“定时清理”就只是个看起来在跑的幻觉。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











