大家好,我是十三!欢迎来到十三Tech。

最近接手了一个老项目,全局数据库连接用的是一个手写的单例——经典的「双重检查锁」搬到了 Go 里。看起来挺像样,但压测的时候发现偶尔会出现两个连接实例,DB 连接数莫名其妙翻倍。

排查了好一阵才搞明白:Go 的内存模型跟 Java 不一样,原作者直接照搬 Java 的双重检查锁,少了一道内存屏障。这种 bug 平时跑不出来,高并发下偶尔闪现,特别难定位。

这一篇就聊聊 Go 里的单例模式——为什么 sync.Once 是标准答案,以及更重要的,你真的需要单例吗

单例模式的现实类比:全局只有一个实例

一、单例在解决什么——不是「全局只有一个」

教科书定义:单例保证一个类只有一个实例,并提供全局访问点。但这个定义只描述了结果,没说理由

单例真正解决的问题有两个:资源共享(数据库连接池、线程池、缓存)和协调一致(配置中心、日志聚合、ID 生成器)。如果只是想全局访问一个对象,用全局变量就够了——单例的核心价值是保证初始化只发生一次,不是访问便捷。

很多人误把单例当"全局变量高级版"用,结果代码里到处是 GetXXXInstance() 调用,耦合度比直接用全局变量还高,测试的时候想 mock 都没办法。单例是全局状态,全局状态是项目复杂度的加速器

判断口诀:真的需要"有且仅有一个"且初始化有副作用 → 单例;只是想方便访问 → 全局变量或依赖注入

二、Go 里 sync.Once 为什么是标准答案

Java 时代的标准写法是双重检查锁(DCL),搬过来大概长这样:

var instance *Config
var mu sync.Mutex

func GetConfig() *Config {
    if instance == nil {       // 第一次检查
        mu.Lock()
        defer mu.Unlock()
        if instance == nil {   // 第二次检查
            instance = loadConfig()
        }
    }
    return instance
}

这段代码在 Java 里是对的(加上 volatile),在 Go 里也凑巧能跑,但它有三个隐藏成本:双重检查的代码冗余、锁粒度难以优化、读多写少场景下锁是浪费。

单例 UML 结构:私有构造 + 静态访问点

Go 标准库给了更好的答案——sync.Once。看下面这段,这是 Go 工程里唯一推荐的单例写法

var (
    configOnce sync.Once
    config     *Config
)

func GetConfig() *Config {
    configOnce.Do(func() {
        config = loadConfig()  // 保证全进程只跑一次
    })
    return config
}

对比一下:sync.Once 内部用了原子操作 + 双检查,但所有复杂度都被标准库封装了。你写 Do(func) 一行就够,不需要操心内存屏障、不需要手动加锁、不需要担心 panic 中断初始化(Once 会把 panic 当成初始化完成,下次调用直接返回,避免重复 panic)。

sync.Once 还有个隐藏好处:第一次调用阻塞,后续调用无锁。读多写少的场景下,这比 DCL 高效得多。我那个翻车项目改成 sync.Once 后,压测里再没出现重复实例。

单例初始化流程:sync.Once 保证 Do 内的函数只执行一次

sync.Once 内部实现不复杂——一个 uint32 状态位 + 一个 sync.Mutex,先用原子 CAS 抢占执行权,没抢到的 goroutine 等待锁释放后直接返回。这种"快路径无锁,慢路径加锁"的模式在标准库里随处可见,是写高并发代码的基本套路。

三、什么时候别用单例——它的代价比你想的大

单例最大的问题不是写法,而是它让代码难以测试和演化

坑一:全局状态污染。单例一旦被多处引用,修改它的行为要影响所有调用方。比如 GetLogger() 返回的全局 logger,想给某个模块单独切到 structured logger 都做不到——所有调用方共享同一个实例。

坑二:测试不友好。单例的全局状态让单元测试隔离变得困难。两个 test 之间共享状态,互相污染结果,是单元测试 flaky 的常见原因。Go 标准库的 database/sql 提供 SetMaxOpenConns 等方法,但项目里的单例通常没设计 reset 接口。

坑三:隐式依赖func ProcessOrder(o *Order) 看起来只依赖 Order,但函数体里 GetInventory().Deduct() 暗中依赖了库存单例——这种隐式依赖让代码可读性大幅下降,新人 review 代码根本不知道 Order 处理过程中会动哪些全局状态。

单例适用决策:要不要用,看初始化是否有副作用

更现代的做法是依赖注入——在 main 里初始化所有依赖,通过函数参数或结构体字段传下去。Go 的 wirefx 等依赖注入工具就是为这个场景生的。一开始可能觉得啰嗦,但项目大了之后,每个函数的依赖一目了然,测试时只要 mock 参数就行。

那什么时候单例真的合适?初始化有副作用且必须全局唯一的场景——数据库连接池、HTTP client(带连接复用)、全局 logger、配置加载。这些场景下,单例省去了依赖注入的复杂度,且这些对象本身就是全局共享的语义。

写单例前问自己三个问题:这个对象真的全局唯一吗?初始化有副作用吗?会被测试 mock 吗? 前两个"是"、第三个"否",用单例;否则考虑依赖注入。

Go 工程里我个人的偏好是——能用依赖注入就用,单例留给少数真正全局的对象。这个习惯让我省下了大量"全局状态导致"的 debug 时间。

Go 标准库里单例思想其实藏在很多地方——sync.Once 不用说,database/sql 的 driver 注册(sql.Register 内部用 once 保证幂等)、os/signal 的全局 channel、runtime.GOMAXPROCS 的全局调度参数。理解 sync.Once 的设计,比记住单例模式有用得多。

下一篇讲工厂方法,它解决的是"对象创建逻辑应该放哪里"的问题。


关于十三Tech 资深服务端研发,AI 实践者,专注分享真实可落地的技术经验。 相信 AI 是程序员的最佳搭档。

联系方式:569893882@qq.com GitHub:@TriTechAI