sync.Once是Go中确保初始化只执行一次且并发安全的唯一标准原语,适用于单例、懒加载等场景;Go 1.21+推荐用语义更清晰的sync.Lazy替代手写Once。

sync.Once 是缓存初始化最稳的选择
缓存初始化必须满足「只执行一次」和「并发安全」两个硬性条件,sync.Once 是 Go 标准库中唯一为此设计的原语。用全局变量 + if 判断、或自定义互斥锁都容易在高并发下漏掉初始化,或者引发 panic。
常见错误现象包括:多个 goroutine 同时触发初始化逻辑、DB 连接被重复创建、缓存预热数据被覆盖、日志里反复打印“init cache”。
- 不要在
init()函数里调用once.Do—— init 本就是单次同步执行,加sync.Once不仅冗余,还会误导后续维护者以为“这里可能被多次调用” - 避免在
once.Do回调里做阻塞操作,比如http.Get、db.Query或长耗时计算;否则首请求会卡住,且后续请求会被阻塞排队 - 如果初始化可能失败(如连接数据库超时),务必把错误结果也缓存下来,否则下次调用仍会重试——参考带
initErr的双返回值模式
Go 1.21+ 推荐用 sync.Lazy 替代手写 Once
sync.Lazy 是 Go 1.21 引入的简化方案,它把「懒加载 + 值封装」合为一体,语义更清晰、代码更少。它内部仍基于 sync.Once,但自动管理指针解引用和零值判断。
使用场景集中在:单例缓存实例、配置解析结果、本地内存缓存(如 sync.Map 封装体)、LRU 缓存结构体等轻量级对象。
-
sync.Lazy是值类型,初始化函数必须返回非接口的具体类型,不能是interface{}或未导出 struct - 它不支持错误传播——若初始化可能失败,仍得退回
sync.Once+ 全局 error 变量组合 - 性能差异可忽略,但可读性提升明显;升级到 Go 1.21+ 后,新项目优先用
sync.Lazy
缓存初始化时机错峰比“越懒越好”更重要
把所有缓存都拖到第一个 HTTP 请求才初始化,看似启动快,实则把延迟转嫁给了用户。P95 首请求延迟飙升 800ms,远比启动慢 200ms 更伤体验。
真正合理的策略是分层错峰:核心缓存(如配置、权限规则)在 main 启动后立即初始化;低频缓存(如 admin 接口元数据、debug endpoint 状态)用 sync.Once 拖到首次访问;冷数据缓存(如历史报表模板)甚至可以完全按需构造,不设全局实例。
- 别给每个 map/slice 都套一层
sync.Once——空map[string]int在 Go 1.21+ 已无底层数组分配,比“懒加载”还轻量 - 用
make(map[K]V, 0)初始化 map,比预设 cap=64 更适合低频路径;扩容由第一次put触发,真实按需 - 对带 TTL 的本地缓存(如服务发现实例列表),初始化只需建空
sync.Map,填充动作本身应异步或由 Watch 事件驱动,而非塞进once.Do
嵌入式缓存(如 LRU)要自己加锁,不能只靠 Once
sync.Once 或 sync.Lazy 只保证“缓存结构体本身创建一次”,不负责其内部数据读写安全。像 lru.Cache 这类非并发安全结构,即使用 sync.Once 初始化了,多 goroutine 并发 Get/Put 仍会 data race。
典型结构是外层结构体持有一个 sync.RWMutex 和一个 *lru.Cache 字段,所有对外方法都先加锁再操作内部 cache。
- 读多写少场景优先用
sync.RWMutex,不是所有读操作都要升级为写锁 - 不要在
Get方法里调once.Do——那意味着每次读都检查是否初始化,违背 Once 本意 - 如果 LRU 缓存本身需要按 key 粒度控制并发(如防止同一个 key 多次回源),就得额外加 key-level 锁或使用
singleflight.Group
实际中最容易被忽略的是:缓存初始化失败后没有降级路径,导致整个服务不可用;以及把「延迟」误解为「推迟到绝对最后时刻」,结果让首请求承担全部初始化成本。这两点比选 Once 还是 Lazy 更关键。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











