🎯 什麼情境該想到我
當你「某個東西全程只該有一份」的時候——設定、連線池、快取、log 寫入器。
★ 但先讀完「什麼時候不適用」:這是 GoF 二十三個模式裡爭議最大、被濫用最嚴重的一個。
⚙️ 怎麼用
意圖與結構
保證一個類別只有一個實例,並提供一個全域存取點。
經典結構(Java/C++):私有建構子 + 靜態欄位 + getInstance() + 雙重檢查鎖定(DCL)。
★ Go 的長相:sync.Once,不要自己寫 DCL
type Config struct{ DSN string }
var (
once sync.Once
instance *Config
)
func GetConfig() *Config {
once.Do(func() { // ★ 保證「恰好執行一次」,且並行安全
instance = &Config{DSN: os.Getenv("DSN")}
})
return instance
}Go 1.21 起更簡潔(2026-09-20 查證 pkg.go.dev/sync):
func OnceValue[T any](f func() T) func() T
func OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2)var GetConfig = sync.OnceValue(func() *Config {
return &Config{DSN: os.Getenv("DSN")}
})
// 需要回傳 error 的初始化
var GetDB = sync.OnceValues(func() (*sql.DB, error) {
return sql.Open("postgres", os.Getenv("DSN"))
})★ 絕對不要自己寫雙重檢查鎖定。
Go 的記憶體模型下,沒有sync.Once或 atomic 的 DCL 是資料競爭——
它可能在你的機器上跑對、在別的架構上錯,而且go test -race才抓得到(見 工具-用race偵測器抓資料競爭)。
更簡單的兩種:
// ① 不需要延遲初始化時,直接用 package-level 變數
var Config = loadConfig() // 套件初始化時執行,天然只有一次且安全
// ② init() —— 但要注意跨套件的初始化順序(見 [[拓撲排序]])TypeScript 的長相
// ★ ES module 本身就是單例 —— 模組只會被求值一次
// config.ts
export const config = { dsn: process.env.DSN! };
// 需要延遲初始化
let _db: Db | undefined;
export function getDb(): Db {
return (_db ??= createDb(config.dsn)); // ★ Node 是單執行緒,不需要鎖
}★ Node 沒有多執行緒競爭的問題(worker_threads 除外),所以 TS 的單例遠比 Go 單純。
而 ES module 的求值語義本身就是單例——大多數情況根本不需要寫任何模式。
生態中的真實案例
| 案例 | 說明 |
|---|---|
http.DefaultClient、http.DefaultServeMux | ★ Go 標準庫的全域單例——也是被批評最多的設計 |
slog.Default() | 全域 logger |
| 資料庫連線池 | *sql.DB 本身就是池,整個程式共用一個是正確的 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
★ 為什麼它被稱為反模式(這一段才是重點)
| 問題 | 說明 |
|---|---|
| ★ 測試地獄 | 全域狀態無法在測試之間隔離。測試 A 改了單例,測試 B 就受影響;而且測試無法平行跑(t.Parallel() 直接出事) |
| ★ 隱藏的相依 | 函式簽章看不出它用了什麼。func Process(x int) 內部偷偷呼叫 GetConfig()——呼叫者完全不知道,也無法替換 |
| 初始化順序 | 多個單例互相依賴時,順序難以控制且難以除錯 |
| 生命週期無法控制 | 無法關閉、無法重建、無法有多個獨立實例(多租戶就死了) |
| 並行的全域可變狀態 | 單例若可變,就是全域共享可變狀態——最容易出競態的地方 |
★ 正解:依賴注入
// ❌ 單例:相依隱藏、無法測試
func Process(x int) error {
db := GetDB() // 藏起來的相依
...
}
// ✅ 注入:相依顯式、可替換、可平行測試
type Service struct { db *sql.DB }
func (s *Service) Process(x int) error {
...s.db...
}「整個程式只需要一份」和「用全域變數存取它」是兩件事。
前者是事實(連線池確實只該一份),後者是你選擇的取得方式。
在main()裡建一份,往下傳 —— 這樣它既只有一份,又是可注入、可測試的。
其他注意
- ★ 可以接受單例的少數情況:真正無狀態的(純函式集合)、程序級的不可變設定、標準庫已經這樣設計的東西。
- 單例 ≠ 「只 new 一次」。重點是「全域存取點」——那才是問題所在。
sync.Once裡的 panic 不會重試:Do認為已經執行過了,後續呼叫直接返回。初始化可能失敗時用OnceValues回傳 error,並在呼叫端處理。- 不要為了「省一次配置」用單例。現代 GC 下這不是瓶頸。
🔗 相關工具
- 工廠方法模式 —— 控制物件建立的另一種方式(不強制唯一)
- 原型模式 —— 另一種創建型模式
- 工具-打破依賴以便測試 —— 已經被單例綁死時怎麼拆
- 工具-用race偵測器抓資料競爭 —— 為什麼不要自己寫 DCL
- 工具-介面設計原則 —— 依賴注入的前置條件
- 設計模式