🎯 什麼情境該想到我

當你「某個東西全程只該有一份」的時候——設定、連線池、快取、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 下這不是瓶頸。

🔗 相關工具