在 Go 语言开发中,我们经常需要控制某些操作的执行频率,避免重复执行带来的性能开销。本文将深入探讨三种常见的并发控制机制:singleflight、sync.Once 和 init(),分析它们的特点、适用场景以及实现原理。
sync.Once:终生一次的初始化
sync.Once 是 Go 标准库提供的同步原语,用于确保某个函数在整个程序生命周期内只执行一次。
核心特性
- 执行频率:终生一次(Once per lifetime)
- 主要用途:懒加载、单例初始化、替代
init()函数 - 生命周期:执行完后,状态永久标记为
Done,无法重置 - 区分任务:不区分任务,绑定在
struct实例上 - 返回值:无返回值(
func())
实现原理
sync.Once 内部使用原子计数器(uint32)来标记执行状态。一旦计数器变为 1,永远不会变回 0,因此没有"重置"或"删除"的逻辑。这使得它非常适合用于只需要初始化一次的场景。
使用示例
package main
import (
"fmt"
"sync"
)
var once sync.Once
var config map[string]string
func initConfig() {
fmt.Println("初始化配置…")
config = make(map[string]string)
config["key"] = "value"
}
func GetConfig() map[string]string {
once.Do(initConfig)
return config
}
singleflight:并发期间一次的执行
singleflight 是 golang.org/x/sync 包提供的并发控制工具,用于在并发场景下确保相同的操作只执行一次,并将结果共享给所有并发请求。
核心特性
- 执行频率:并发期间一次(Once per concurrent batch)
- 主要用途:防止缓存击穿(Cache Stampede)、高并发请求聚合
- 生命周期:执行完后,Key 从 Map 中删除,下次可再次触发
- 区分任务:通过字符串 Key(
Do(key, fn))区分不同任务 - 返回值:返回结果(
val, err, shared)
实现原理
singleflight 内部维护一个 map[string]*call 结构。当一个函数执行完毕(无论成功失败),它会从内部的 map 中把自己删掉(delete)。这意味着,下一波请求进来时,map 里没有这个 key,它会重新创建一个新的 call 并再次执行函数。
使用示例
package main
import (
"fmt"
"time"
"golang.org/x/sync/singleflight"
)
// 定义一个全局的 Group
var g singleflight.Group
// 模拟数据库查询
func queryDB(id string) (string, error) {
fmt.Printf(">>> 正在查询数据库 id=%s …\n", id)
time.Sleep(100 * time.Millisecond) // 模拟耗时
return "文章内容:" + id, nil
}
func GetArticle(id string) (string, error) {
key := "article_" + id
// 使用 Do 方法
// 第一个参数是 key,同一时刻相同的 key 只会执行一次 fn
v, err, shared := g.Do(key, func() (interface{}, error) {
// 这里放你的 数据库/Redis 查询逻辑
return queryDB(id)
})
if err != nil {
return "", err
}
// shared 为 true 表示这次结果被共享给了其他并发请求
if shared {
fmt.Println("命中 singleflight,共享结果")
}
// 类型断言 (Do 返回的是 interface{})
return v.(string), nil
}
func main() {
// 模拟 10 个并发请求
for i := 0; i < 10; i++ {
go func() {
val, _ := GetArticle("1")
fmt.Println("获取到:", val)
}()
}
time.Sleep(1 * time.Second)
}
init():包级别的初始化
init() 是 Go 语言提供的特殊函数,用于在包被导入时自动执行初始化操作。
核心特性
- 执行频率:包导入时执行一次
- 主要用途:包级别的初始化、全局变量初始化、注册操作
- 生命周期:程序启动时按依赖顺序执行,执行后无法再次调用
- 区分任务:每个包可以有多个
init()函数 - 返回值:无返回值,无参数
使用场景
init() 函数适合用于:
- 初始化包级别的全局变量
- 注册驱动、插件等
- 执行一次性的设置操作
注意事项
init()函数在main()函数之前执行- 多个
init()函数按照导入顺序和声明顺序执行 - 无法手动调用或控制
init()的执行时机 - 错误处理受限,通常使用
panic或log.Fatal
三者对比总结
| 特性 | sync.Once | singleflight | init() |
|---|---|---|---|
| 执行频率 | 终生一次 | 并发期间一次 | 包导入时一次 |
| 主要用途 | 懒加载、单例初始化 | 防止缓存击穿、高并发聚合 | 包级别初始化 |
| 生命周期 | 永久标记为 Done | Key 删除后可再次触发 | 程序启动时执行 |
| 区分任务 | 不区分(绑定实例) | 通过 Key 区分 | 不区分(包级别) |
| 返回值 | 无返回值 | 返回结果和共享标志 | 无返回值 |
| 控制时机 | 运行时控制 | 运行时控制 | 编译时确定 |
缓存击穿解决方案
在高并发场景下,缓存击穿是一个常见问题。当热点数据过期时,大量并发请求会同时穿透缓存,直接访问数据库,造成数据库压力过大。以下是几种常见的解决方案:
[!NOTE] 缓存击穿
方案一:使用 singleflight 防止缓存击穿
这是本文重点介绍的方法。通过
singleflight,当多个并发请求同时访问同一个已过期的缓存 key 时,只有一个请求会真正执行数据库查询,其他请求会等待并共享查询结果。这样可以有效减少数据库压力。方案二:使用(分布式)锁防止缓存击穿
在查询数据库前,先去 Redis 抢一个锁(比如使用
SETNX)。抢到锁的请求去查数据库并回写缓存,没抢到锁的请求 sleep 一会儿后重试去查缓存。这种方式适合分布式环境,但需要注意锁的超时时间设置,避免死锁。方案三:逻辑过期方案
在缓存中记录一个逻辑过期时间,先查询缓存,判断是否过期。如果过期,则返回空数据或旧数据,并异步更新缓存。这种方式可以保证服务的高可用性,但可能会返回过期数据,需要根据业务场景权衡。
方案四:缓存预热
在系统启动时,提前将热点数据加载到缓存中,避免系统启动时缓存中没有数据导致缓存击穿。这种方式适合可以预测热点数据的场景,但需要额外的预热逻辑和资源消耗。
总结
选择合适的并发控制机制需要根据具体场景来判断:
- sync.Once:适合只需要初始化一次的全局资源,如配置加载、单例模式
- singleflight:适合高并发场景下的重复操作聚合,如缓存查询、API 调用
- init():适合包级别的初始化,如驱动注册、全局变量设置
在实际开发中,singleflight 在防止缓存击穿方面表现尤为出色,它能够有效减少重复的数据库查询,提升系统性能和稳定性。而 sync.Once 则更适合用于确保某些初始化操作只执行一次的场景。