在 Go 语言开发中,我们经常需要控制某些操作的执行频率,避免重复执行带来的性能开销。本文将深入探讨三种常见的并发控制机制:singleflightsync.Onceinit(),分析它们的特点、适用场景以及实现原理。

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:并发期间一次的执行

singleflightgolang.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() 的执行时机
  • 错误处理受限,通常使用 paniclog.Fatal

三者对比总结

特性sync.Oncesingleflightinit()
执行频率终生一次并发期间一次包导入时一次
主要用途懒加载、单例初始化防止缓存击穿、高并发聚合包级别初始化
生命周期永久标记为 DoneKey 删除后可再次触发程序启动时执行
区分任务不区分(绑定实例)通过 Key 区分不区分(包级别)
返回值无返回值返回结果和共享标志无返回值
控制时机运行时控制运行时控制编译时确定

缓存击穿解决方案

在高并发场景下,缓存击穿是一个常见问题。当热点数据过期时,大量并发请求会同时穿透缓存,直接访问数据库,造成数据库压力过大。以下是几种常见的解决方案:

[!NOTE] 缓存击穿

方案一:使用 singleflight 防止缓存击穿

这是本文重点介绍的方法。通过 singleflight,当多个并发请求同时访问同一个已过期的缓存 key 时,只有一个请求会真正执行数据库查询,其他请求会等待并共享查询结果。这样可以有效减少数据库压力。

方案二:使用(分布式)锁防止缓存击穿

在查询数据库前,先去 Redis 抢一个锁(比如使用 SETNX)。抢到锁的请求去查数据库并回写缓存,没抢到锁的请求 sleep 一会儿后重试去查缓存。这种方式适合分布式环境,但需要注意锁的超时时间设置,避免死锁。

方案三:逻辑过期方案

在缓存中记录一个逻辑过期时间,先查询缓存,判断是否过期。如果过期,则返回空数据或旧数据,并异步更新缓存。这种方式可以保证服务的高可用性,但可能会返回过期数据,需要根据业务场景权衡。

方案四:缓存预热

在系统启动时,提前将热点数据加载到缓存中,避免系统启动时缓存中没有数据导致缓存击穿。这种方式适合可以预测热点数据的场景,但需要额外的预热逻辑和资源消耗。

总结

选择合适的并发控制机制需要根据具体场景来判断:

  • sync.Once:适合只需要初始化一次的全局资源,如配置加载、单例模式
  • singleflight:适合高并发场景下的重复操作聚合,如缓存查询、API 调用
  • init():适合包级别的初始化,如驱动注册、全局变量设置

在实际开发中,singleflight 在防止缓存击穿方面表现尤为出色,它能够有效减少重复的数据库查询,提升系统性能和稳定性。而 sync.Once 则更适合用于确保某些初始化操作只执行一次的场景。