Go中单例本质是全局变量加初始化控制,需满足懒加载、线程安全、可测试,标准做法是sync.Once配合指针变量;应避免init()、手动加锁、返回值拷贝及硬编码依赖,推荐依赖注入;sync.Once不支持失败重试,HTTP中勿将request级对象误作单例。

Go里没有“new Singleton()”这种单例写法
Go语言本身不支持类和构造函数,所谓“单例”本质是**全局变量 + 初始化控制**。直接声明一个包级变量 var instance *MyService 不等于单例——它可能未初始化、被并发读写、或在测试中无法重置。
真正可靠的单例必须满足三点:懒加载、线程安全、可测试。标准做法是用 sync.Once 配合指针变量:
var (
instance *MyService
once sync.Once
)
func GetInstance() *MyService {
once.Do(func() {
instance = &MyService{...}
})
return instance
}
- 不用
init():它在包加载时执行,无法按需初始化,且无法捕获初始化失败的错误 - 避免在
GetInstance()里加锁:sync.Once已保证只执行一次,比手写互斥锁更轻量 - 不要返回值拷贝(如
func GetInstance() MyService):会导致每次调用都复制结构体,失去单例语义
依赖注入场景下硬编码单例会破坏可测试性
很多项目在 handler 或 service 层直接调 database.GetInstance() 或 cache.NewRedisClient(),看似方便,实则让单元测试难以 mock 依赖。
正确做法是把单例作为依赖传入,而非内部获取:
type UserService struct {
db *sql.DB // 而不是自己调 database.GetInstance()
}
func NewUserService(db *sql.DB) *UserService {
return &UserService{db: db}
}
- 测试时可传入
sqlmock或内存数据库实例,无需启动真实 DB - 多个环境(dev/staging/prod)可注入不同配置的实例,而不是靠包变量切换
- 如果真需要“全局可用”,可在 main 包统一创建并注入,而非每个子包自行获取
sync.Once 不是万能的——初始化失败时无法重试
sync.Once 的设计是“最多执行一次”,一旦 Do() 内部 panic 或返回错误,后续调用永远拿不到有效实例,且无提示。
常见错误是把带 error 返回的初始化逻辑塞进 once.Do():
once.Do(func() {
instance, err = NewExpensiveClient() // 如果 err != nil,instance 是 nil,但调用方完全不知道
})
- 应提前检查错误并显式处理,例如 panic(开发期暴露问题)或记录日志后 os.Exit(启动期失败)
- 若需容错重试(如网络服务临时不可用),
sync.Once不适用,得换用带状态机的初始化器,或结合retry库+原子指针更新 - 注意:
once.Do()内 panic 会导致整个 goroutine 崩溃,别在里面做不可控操作
HTTP Server 中的“单例”常被误用为 request-scoped 对象
新手容易把 http.HandlerFunc 里需要的上下文对象(如 *User、requestID)也做成全局单例,结果所有请求共享同一份数据,引发严重竞态。
区分清楚生命周期:
- 应用级单例:DB 连接池、配置对象、全局 logger —— 整个进程生命周期存在
- Request 级对象:用户身份、trace ID、校验结果 —— 必须从
*http.Request或 context 里提取,通过参数传递 - 错误示例:
var currentUser *User在 handler 里赋值,然后被其他 goroutine 读取 —— 典型 data race
真正该用单例的地方很少,多数时候你只是需要一个 well-structured dependency graph 和清晰的 ownership 边界。










