🐹 Effective Go
🏷 命名與介面
🎯 什麼情境該想到我
當你在 Go 裡「想不到這個 package/型別/方法該叫什麼」,或是把別的語言的命名習慣直接搬過來的時候。
⚙️ 怎麼用(步驟 / 公式)
0. 先知道:Go 的命名有語義效果
首字母大寫決定它在 package 外看不看得到。 在多數語言這只是風格,在 Go 是語言規則——所以命名不只是可讀性問題。
1. Package 名:短、精簡、有畫面
- 小寫、單字、不用底線也不用駝峰。
- 寧短勿長——每個使用者都要打這個名字。
- 就是原始目錄的 base name:
src/encoding/base64匯入路徑是encoding/base64,但名字是base64,不是encoding_base64。 - 不用預先擔心撞名:package 名只是匯入時的預設名,撞到時匯入方可以自己改。
2. ⭐ 用套件名幫你省字(最常被忽略的一條)
使用者看到的永遠是 套件.名稱,所以不要在型別名裡重複套件名:
bufio.Reader // ✅ 不是 bufio.BufReader
ring.New() // ✅ 不是 ring.NewRing(Ring 是這個套件唯一匯出的型別)
once.Do(setup) // ✅ 不是 once.DoOrWaitUntilDone(setup)而且 bufio.Reader 跟 io.Reader 不會衝突,因為總是帶著套件名。
3. Getter 不要加 Get
欄位叫 owner(小寫未匯出),getter 就叫 Owner(),不是 GetOwner()。大小寫本身就是區分欄位與方法的鉤子。setter 才叫 SetOwner()。
owner := obj.Owner()
if owner != user {
obj.SetOwner(user)
}4. 單方法介面用 -er
Reader、Writer、Formatter、CloseNotifier——方法名加 -er 構成施事名詞。
反過來的規則同樣重要:
Read/Write/Close/Flush/String有規範化的簽章與意義——除非你的方法簽章與意義完全相同,否則不要用這些名字。- 反之,如果你的方法跟知名型別的方法意義相同,就用同一個名字與簽章:字串轉換方法叫
String(),不叫ToString()。
5. 多字用 MixedCaps/mixedCaps,不用底線
★ 具體對照表(照抄即可)
| 場景 | ❌ 別這樣 | ✅ Go 的慣例 |
|---|---|---|
| 套件名 | utils、common、helpers、base | ★ bytes、http、user —— 單一名詞、小寫、不用底線或駝峰 |
| 避免重複 | chubby.ChubbyFile | ★ chubby.File —— 呼叫端看到的是 套件.型別 |
| 介面 | IUserService、UserServiceInterface | ★ Reader、Writer —— 單方法介面用 -er |
| 實作 | UserServiceImpl | ★ postgresUserStore(用「它是什麼」命名,不是「它實作了什麼」) |
| Getter | GetName() | ★ Name() —— Go 不用 Get 前綴;Setter 才用 SetName() |
| 縮寫 | Url、Id、Http、Api | ★ URL、ID、HTTP、API —— 整個縮寫同大小寫;未匯出時 url、id |
| 錯誤變數 | NotFoundError | ★ ErrNotFound —— sentinel 用 Err 前綴 |
| 錯誤型別 | NotFoundErr | ★ NotFoundError —— 型別用 Error 後綴 |
| 變數長度 | userRepository | ★ 作用域越小名字越短:for i、r *http.Request、u User |
| 接收者 | (this *User)、(self *User) | ★ (u *User) —— 一到兩個字母,且同一型別全部統一 |
| 常數 | MAX_RETRIES | ★ MaxRetries —— Go 沒有全大寫的慣例 |
| 測試 | TestFoo1、TestFoo2 | ★ TestFoo + t.Run("空輸入", ...) 子測試(見 工具-Go表格測試與子測試) |
★ 三條背後的原則
① 名字的長度該和作用域成正比
for i := range s { ... } // ★ 三行內,一個字母就夠
func (c *Client) Do(r *Request) (*Response, error) // 參數,短
var defaultRetryBackoffMultiplier = 1.5 // ★ 套件層級,要完整這和多數語言的「名字越長越清楚」直覺相反。
Go 的理由:短作用域裡上下文已經很清楚,長名字只是噪音。
② 匯出名要能和套件名一起讀
http.Get、bytes.Buffer、io.Reader——呼叫端讀到的是「套件.名字」這一整串。
所以 http.HTTPClient 是重複的(應該是 http.Client)。
③ ★ 不要用 utils / common 這種套件名
它們是「我不知道這東西屬於哪裡」的自白。
結果是所有人都往裡面丟,最後變成一個誰都要 import 的巨大耦合點。
→ 按「領域」而不是按「種類」分套件:user、billing、auth,不是 models、services、utils。
★ 註解的慣例(godoc 會直接用)
// Reader 從底層資料流讀取位元組。 ← ★ 以「名字」開頭
//
// Read 在讀到 0 個位元組時回傳 io.EOF。 ← 說明保證
type Reader interface{ ... }
// ErrNotFound 表示請求的資源不存在。
var ErrNotFound = errors.New("not found")- 匯出的東西一定要有註解,且以該名字開頭(
golint會檢查) - 套件註解放在
doc.go或任一檔案的package前 - ★ 註解寫「為什麼」與「保證什麼」,不要複述程式碼
★ 錯誤訊息的慣例
// ✅ 小寫開頭、不加標點 —— 因為它會被嵌進更長的句子
return fmt.Errorf("open config: %w", err)
// → 上層包裝後:「load settings: open config: permission denied」
// ❌ errors.New("Failed to open config file.")(例外:以專有名詞或縮寫開頭時保留原大小寫,如 HTTP request failed。)
🧪 我實際套用的紀錄
- 2026-08-28:(待填)
⚠️ 注意 / 什麼時候不適用
- ★ 一致性勝過個人偏好。進到既有專案就照它的慣例走,不要為了「更正確」而製造兩套風格。
gofmt管格式,不管命名。命名靠 review 與 linter(revive、staticcheck、golangci-lint)。- ★ 短名字有邊界。函式超過二三十行,
i、v、c就會開始不清楚——
那通常代表函式該拆了,而不是名字該變長。 - 接收者名稱不要用
this/self,也不要在同一型別的不同方法間換名字。 Err前綴只給 sentinel error(可比較的哨兵值)。動態產生的錯誤不需要變數名。- 測試檔案命名:
foo_test.go與被測檔案同套件(白箱)或package foo_test(黑箱,只能用匯出的 API)。 - 不要過度縮寫。
cfg、ctx、req、db是公認的;usrMgrSvc不是。
🔗 相關工具
- 工具-Go可讀性五原則 —— 命名之外的可讀性紀律
- 工具-有意義的命名 —— 跨語言的命名原則
- 工具-Go介面小而隱式 ——
-er命名與介面設計 - 工具-Go現代錯誤處理 ——
ErrXxx與XxxError的分工 - 工具-Go表格測試與子測試 —— 測試命名
- 工具-註解是必要之惡 —— 註解該寫什麼
- Effective Go、Modern Go Guidelines
🎯 什麼情境該想到我
當你在 Go 裡設計介面,帶著「先宣告 implements、介面要完整」的其他語言直覺的時候。
⚙️ 怎麼用(步驟 / 公式)
1. 隱式滿足:型別不宣告自己實作了什麼
Go 沒有 implements。只要方法都有,就滿足了。
多數介面轉換是靜態的、編譯期就檢查——把 *os.File 傳給要 io.Reader 的函式,不滿足就編不過。
2. 介面要小
只有一兩個方法的介面在 Go 裡是常態,不是例外(io.Reader 只有一個方法)。
3. ⭐ 只為實作介面而存在的型別,不要匯出
如果一個型別的存在只是為了實作某個介面,而且不會有介面以外的匯出方法,就沒必要匯出這個型別。
這時建構函式應該回傳介面值,而不是實作型別:
// crc32.NewIEEE 與 adler32.New 都回傳 hash.Hash32好處是換演算法只要改建構呼叫,其餘程式碼完全不動。
只匯出介面也讓「這個值除了介面描述的以外沒有別的有趣行為」變得清楚,還省掉在每個實作上重複寫文件。
4. 用空白識別碼做編譯期介面檢查
有些檢查發生在執行期(例如 encoding/json 用型別斷言看你有沒有實作 json.Marshaler)。這很危險——沒實作到的話 encoder 照樣能跑,只是不會用你的自訂邏輯。
要在編譯期釘死,用這個宣告:
var _ json.Marshaler = (*RawMessage)(nil)介面一改,這個套件就編不過,你會馬上知道要更新。
但不要每個型別都寫。 慣例是只在程式碼裡本來就沒有靜態轉換時才用,而那很罕見。
5. 判斷「有沒有實作」而不需要用它時,用空白識別碼
if _, ok := val.(json.Marshaler); ok { /* ... */ }6. ★ 介面該定義在「消費方」,不是「實作方」
這是 Go 相對 Java 最大的差異,也是隱式滿足真正的價值所在:
// ❌ Java 式:實作方定義大介面
package storage
type Storage interface { Get; Put; List; Delete; Stat } // ★ 消費者只用 Get 卻被迫依賴全部
// ✅ Go 式:消費方定義小介面
package app
type Getter interface { Get(context.Context, string) ([]byte, error) }
func Process(g Getter) { ... } // ★ storage 套件完全不知道 app.Getter 存在“accept interfaces, return structs” —— 函式收介面(讓呼叫端自由替換),回傳具體型別(讓呼叫端拿到完整能力)。
→ 完整討論見 工具-針對介面編程。
7. ★★ 最惡名昭彰的陷阱:非 nil 的 nil 介面
type MyError struct{ Code int }
func (e *MyError) Error() string { return "boom" }
func mayFail() error {
var e *MyError = nil // ★ 指標是 nil
return e // ★ 但包成 error 介面後就「不是 nil」了
}
if err := mayFail(); err != nil {
// ★★ 這裡會進來!
}為什麼:介面值是一組 (型別, 值)。return e 得到的是 (*MyError, nil) ——
型別欄位非空,所以整個介面值不等於 nil。
修法:不要宣告具體型別的錯誤變數再回傳它。
func mayFail() error {
if ok { return nil } // ★ 直接回傳字面的 nil
return &MyError{Code: 1}
}★ 這個 bug 幾乎每個 Go 工程師都踩過一次,而且它不會有任何編譯警告。
go vet有nilness分析可以抓部分情況,但不是全部。
8. 介面 vs 泛型(Go 1.18+)
| 用介面 | 用泛型 |
|---|---|
| 行為不同(各自有各自的實作) | 型別不同但邏輯相同 |
| 需要執行期多型(一個 slice 裝不同實作) | 編譯期就知道型別 |
io.Reader、http.Handler | slices.Sort、maps.Keys |
| 動態分派,無法內聯 | ★ 可特化、可內聯,通常更快 |
★ 判準:問「這些型別的行為一樣嗎?」 一樣(只是型別不同)→ 泛型;不一樣 → 介面。
不要用泛型取代 io.Reader 這種真正的行為抽象。
🧪 我實際套用的紀錄
- 2026-08-28:(待填)
⚠️ 注意 / 什麼時候不適用
- ★★ 非 nil 的 nil 介面(見上)。這是 Go 最常見的執行期 bug 之一。
- ★ 介面污染(interface pollution):為每個 struct 配一個同名介面(
Service+IService)
在 Go 裡沒有任何好處——它不會讓程式更解耦,只是多一層間接和一個要同步維護的檔案。
只有一個實作且測試不需要替換 → 不要抽。 - 介面太大 = 沒有介面。十個方法的介面只有原本那個型別滿足得了,替換性是零。
- 回傳介面通常是錯的。例外:實作型別未匯出、或真的有多種實作(
sql.Driver、hash.Hash)。 - 動態分派有成本:無法內聯、可能造成逃逸到 heap。極熱的迴圈上具體型別更快——但先 profile。
- 介面不描述保證。它只說「有這些方法」,不說複雜度、是否阻塞、是否並行安全。那些要寫在註解裡。
- 空介面
any不是抽象,是放棄型別。Go 1.18 之後多數any的用法應該改成泛型。 - 嵌入介面會繼承它的全部方法,包含你不想要的(見 工具-Go嵌入機制)。
🔗 相關工具
- 工具-針對介面編程 —— ★ 介面該定義在哪一邊(Go 與 Java 相反的結論)
- 工具-Go嵌入機制 —— 介面嵌入與方法集
- 工具-Go錯誤該包還是該轉 —— error 也是一個介面,nil 陷阱最常在這裡爆
- 配接器模式 —— 隱式滿足讓 Adapter 幾乎免費(
http.HandlerFunc) - 策略模式 —— 什麼時候用
func型別就夠、什麼時候需要介面 - 工具-優先組合而非繼承 —— 組合的前置條件
- Modern Go Guidelines、Effective Go
🧩 組合
🎯 什麼情境該想到我
當你想「借用另一個型別的行為」,但 Go 沒有繼承的時候。
⚙️ 怎麼用(步驟 / 公式)
1. 嵌入省掉的是「轉發方法」這種苦工
不嵌入的話你要自己寫一堆轉發:
type ReadWriter struct {
reader *Reader
writer *Writer
}
func (rw *ReadWriter) Read(p []byte) (n int, err error) {
return rw.reader.Read(p) // 每個方法都要來一次
}直接嵌入就沒這回事:
type ReadWriter struct {
*Reader
*Writer
}bufio.ReadWriter 因此同時滿足 io.Reader、io.Writer、io.ReadWriter 三個介面,一行都不用寫。
2. ⭐ 嵌入不是繼承——差別在 receiver
嵌入一個型別時,它的方法會變成外層型別的方法,但呼叫時的 receiver 是內層型別,不是外層。
這一條是所有「以為是繼承」的誤解來源:內層的方法看不到外層,沒有多型覆寫、沒有 virtual dispatch。效果等同你自己手寫的那個轉發方法,不多不少。
3. 嵌入也可以只是圖方便
type Job struct {
Command string
*log.Logger
}
job.Println("starting now...") // 直接有 Logger 的方法4. 要指名嵌入欄位時,型別名就是欄位名
去掉套件前綴即可——job.Logger。這在想「加工」內層方法時很有用:
func (job *Job) Printf(format string, args ...interface{}) {
job.Logger.Printf("%q: %s", job.Command, fmt.Sprintf(format, args...))
}這是 Go 版的「包一層」,但要自己明寫,不會自動發生。
5. 名稱衝突的兩條規則
- 淺的蓋深的:外層的欄位或方法
X,會遮蔽更深層的同名X。 - 同一層同名通常是錯:
Job已經有個叫Logger的欄位,就不能再嵌入log.Logger。
但如果那個重名在型別定義之外從沒被用到,就沒事——這條保護你不被外部型別新增欄位所波及。
6. ★ 介面嵌入:Go 標準庫最漂亮的用法
type Reader interface{ Read(p []byte) (int, error) }
type Writer interface{ Write(p []byte) (int, error) }
// ★ 用嵌入組合出大介面,而不是重寫方法
type ReadWriter interface {
Reader
Writer
}★ 這才是嵌入在 Go 裡最主要的正當用途:把小介面組合成大介面。
io套件的ReadWriter、ReadCloser、ReadWriteSeeker全部這樣來的。
7. ★ 嵌入介面做「部分覆寫」(很好用的技巧)
type loggingStore struct {
Store // ★ 嵌入介面:其餘方法自動轉發
log *slog.Logger
}
func (s loggingStore) Put(ctx context.Context, k string, v []byte) error {
s.log.Info("put", "key", k)
return s.Store.Put(ctx, k, v) // ★ 只覆寫這一個
}好處:Store 介面日後加方法,loggingStore 自動跟著有,不用改。
風險:新方法會「靜默地」不被 log 到——這是可接受的取捨,但要知道。
★ 這正是 裝飾者模式 在 Go 裡的寫法。
8. ★ 嵌入的三個實務陷阱
① 嵌入 sync.Mutex 會把 Lock()/Unlock() 變成公開 API
type Counter struct {
sync.Mutex // ❌ 外部可以 c.Lock() 鎖住你的物件
n int
}
type Counter struct {
mu sync.Mutex // ✅ 具名私有欄位
n int
}★ 除非你真的想讓呼叫端參與鎖定,否則一律用具名私有欄位。
② 嵌入會暴露被嵌入型別的「所有」匯出方法
包含你不想要的。Job 嵌入 *log.Logger → Job 也有 SetOutput、SetFlags——這些多半不該是 Job 的 API。
③ ★ JSON 序列化會「攤平」嵌入的欄位
type Base struct{ ID string `json:"id"` }
type User struct {
Base // ★ 嵌入
Name string `json:"name"`
}
// 序列化結果:{"id":"...","name":"..."} ← ★ 攤平,不是 {"Base":{...}}
type User2 struct {
Base Base `json:"base"` // 具名欄位 → 巢狀
Name string `json:"name"`
}
// {"base":{"id":"..."},"name":"..."}嵌入介面時 JSON 會用動態型別的欄位——這常常不是你要的,而且反序列化會失敗(不知道要建哪個具體型別)。
🧪 我實際套用的紀錄
- 2026-08-28:(待填)
⚠️ 注意 / 什麼時候不適用
- ★★ 沒有虛擬分派。嵌入型別的方法永遠呼叫自己的方法,不會分派到外層:
→ 不能用嵌入模擬 範本方法模式。要可覆寫的步驟,就用函式欄位或介面參數。func (b Base) Greet() string { return "hi, " + b.Name() } // b 永遠是 Base - ★ 嵌入不是「is-a」。它只是方法轉發的語法糖。不要為了表達分類關係而嵌入。
- ★ 方法集的 receiver 規則:嵌入
T只帶來T的方法;嵌入*T帶來T和*T的方法。
介面滿足不成立時,先檢查這裡。 - 名稱衝突:同一層兩個嵌入型別有同名方法 → 呼叫時才編譯錯誤(宣告時不會錯)。
- 多層嵌入會讓「這個方法從哪來」變得難追。兩層以上就該考慮改用具名欄位 + 明確轉發。
- 嵌入
context.Context是反模式。context 應該當參數傳,不該存進 struct。 sync.Mutex/sync.WaitGroup被嵌入後,整個 struct 就不可複製(go vet的 copylocks 會警告)。
🔗 相關工具
- 工具-優先組合而非繼承 —— ★ 嵌入是組合不是繼承,以及為什麼這件事重要
- 工具-Go介面小而隱式 —— 介面嵌入與方法集
- 範本方法模式 —— ★ 為什麼 Go 不能用嵌入模擬它
- 裝飾者模式 —— 嵌入介面做部分覆寫的完整說明
- 工具-資料編碼與演進 —— 嵌入對 JSON 序列化的影響
- Effective Go
🔀 並行
🎯 什麼情境該想到我
當你在 Go 裡處理並行,手很癢想用鎖去保護一個共享變數的時候。
「不要透過共享記憶體來通訊;要透過通訊來共享記憶體。」
⚙️ 怎麼用(步驟 / 公式)
1. 心智模型:值在 channel 上傳遞,不被多方同時持有
Go 鼓勵的做法是把值放上 channel 傳來傳去,而不是讓多個執行緒主動共享它。
任何時刻只有一個 goroutine 能存取那個值 → 資料競爭在設計上就不可能發生。
2. 原文給的直覺(很好用)
想像一個單執行緒程式跑在一顆 CPU 上——它不需要同步。
再跑一個實例——它也不需要同步。
現在讓這兩個互相通訊——如果通訊本身就是同步機制,那就仍然不需要其他同步。
Unix pipeline 完美符合這個模型。Go 的並行源自 Hoare 的 CSP,也可以看成是型別安全版的 Unix pipe。
3. goroutine 的成本模型
就是一個「與其他 goroutine 併行執行的函式」。輕量,成本略高於配置堆疊空間;堆疊從小開始、按需增長。它們被多工到多個 OS 執行緒上,所以其中一個因 I/O 阻塞時,其他照跑。
go list.Sort() // 併行跑,不等它函式字面量在 Go 裡是閉包,實作會確保被引用的變數活得夠久。
4. ⭐ 但這句標語有邊界——原文自己講的
“This approach can be taken too far. Reference counts may be best done by putting a mutex around an integer variable, for instance.”
引用計數這種東西,用 mutex 包一個整數就好。 channel 是高階做法,不是所有同步問題的答案。
判準:要傳遞「所有權」或「資料流」用 channel;只是保護一小塊狀態用 mutex。
5. ★ goroutine 洩漏:四種最常見的模式
「開了 goroutine 卻沒人負責它結束」是 Go 最常見的生產問題。
| 洩漏模式 | 為什麼 | 修法 |
|---|---|---|
| 送到沒人收的 channel | 無緩衝 channel 上的 ch <- v 永遠阻塞 | 加緩衝,或用 select + ctx.Done() |
| 從沒人送的 channel 收 | <-ch 永遠阻塞 | 同上;或確保一定有人 close |
忘記 ctx 取消 | 上游放棄了,下游還在跑 | ★ 每個長命 goroutine 都要 select { case <-ctx.Done(): return; ... } |
for range ch 但沒人 close | range 會等到 channel 關閉 | 由「唯一的送出方」負責 close |
// ★ 可被取消的 goroutine 骨架
func worker(ctx context.Context, in <-chan Job, out chan<- Result) {
for {
select {
case <-ctx.Done(): // ★ 一定要有這條
return
case j, ok := <-in:
if !ok { return } // ★ 上游關閉了
select {
case out <- process(j):
case <-ctx.Done(): // ★ 送出時也可能阻塞,也要能取消
return
}
}
}
}怎麼偵測:
runtime.NumGoroutine()只增不減 → 有洩漏pprof的 goroutine profile(/debug/pprof/goroutine?debug=2)能直接看到卡在哪一行go.uber.org/goleak可以在測試結束時斷言沒有殘留 goroutine
6. ★ Channel 的語義表(必背)
| 操作 | nil channel | 開著且空 | 開著且滿 | 已關閉 |
|---|---|---|---|---|
<-ch 接收 | 永遠阻塞 | 阻塞 | 取到值 | ★ 立刻回零值,ok == false |
ch <- v 送出 | 永遠阻塞 | 送出 | 阻塞 | ★ panic |
close(ch) | panic | ok | ok | ★ panic |
三條由此推出的鐵律:
- ★ 只有送出方能 close,而且只能有一個送出方 close(多個送出方就用
sync.WaitGroup+ 一個協調者) - 不要 close 接收端拿到的 channel
- ★ 「nil channel 永遠阻塞」是特性不是 bug——在
select裡把某個 case 的 channel 設成 nil,就能動態關掉那個分支
7. Worker Pool 與 errgroup
import "golang.org/x/sync/errgroup"
func processAll(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx) // ★ 任一個回錯,ctx 自動取消其他的
g.SetLimit(10) // ★ 併發上限(見 [[AI 工程 Coding 題型]] 的批次處理器)
for _, it := range items {
it := it // ★ Go < 1.22 必須這行;1.22+ 可省
g.Go(func() error { return process(ctx, it) })
}
return g.Wait() // ★ 回傳第一個非 nil 的錯誤
}★ errgroup 是 Go 並行的實務標配:它把「等待、錯誤傳播、取消、限流」四件事一次解決。
自己用 sync.WaitGroup + channel 收錯誤幾乎總是重新發明它。
★ 對照 Java 線程池:Go 的「拒絕策略」長什麼樣
Java 的 ThreadPoolExecutor 有三個關鍵旋鈕:執行緒數、有界佇列、佇列滿時的拒絕策略。常見的踩雷點是 Executors.newFixedThreadPool 背後用的 LinkedBlockingQueue 容量是 Integer.MAX_VALUE,等於無界,量一大就 OOM(阿里巴巴 Java 開發規約因此禁止用 Executors 建執行緒池)。Go 沒有執行緒池,但這三個旋鈕都有對應:
| Java | Go | 滿了會怎樣 |
|---|---|---|
| 核心/最大執行緒數 | g.SetLimit(n) 或固定 n 個 worker goroutine | — |
| 有界佇列 | make(chan Job, n) | 送不進去時阻塞 |
CallerRunsPolicy(呼叫端自己執行,拖慢提交速度) | g.Go(...):阻塞到有空位才回傳 | 生產者自然被拖慢 → 背壓 |
AbortPolicy(預設,丟 RejectedExecutionException 給呼叫端) | g.TryGo(...) 回傳 false;或 select { case ch <- job: default: } | 呼叫端自己決定重試、落地或回錯 |
- errgroup 官方註解:
Go會 “block until the new goroutine can be added without the number of goroutines in the group exceeding the configured limit”;TryGo只在低於上限時才啟動,“The return value reports whether the goroutine was started.”(查證 2026-09-27) - 選哪種看場景:離線批次(從 DB 撈千萬筆推送)要的是「生產者慢下來」,用會阻塞的
g.Go/阻塞送 channel。線上請求不能讓請求執行緒被拖住,用TryGo/select default快速失敗,回 429 或降級。Java 的 CallerRuns 也是同樣的取捨:在 Web 請求執行緒上用它,會把整個服務卡死。 AbortPolicy不是「資料直接丟了」,而是把例外丟給呼叫端;真正靜默丟棄的是DiscardPolicy。Go 的TryGo回false也一樣:丟不丟,取決於呼叫端有沒有處理這個回傳值。- 併發數怎麼估:與其背「IO 密集型設 2N」這類經驗值,不如用 Little’s Law 反推:
所需併發數 = 目標吞吐 × 單次延遲。例如 1,000 萬則簡訊要在 1 小時內發完 → 10,000,000 ÷ 3,600 ≈ 2,778 則/秒;若簡訊閘道每次呼叫約 200ms,就需要約 2,778 × 0.2 ≈ 556 個同時在途的請求。這是推導出來的起點,還要再看閘道的限流上限、壓測調整(見 工具-限流器設計)。 - 在記憶體裡排隊的工作,當機就沒了。千萬級任務要先在 DB/Redis 落一個「發送中」狀態,做完回寫「已完成」,再用補償任務重送卡太久的(跟 工具-延遲任務方案選型 的兜底對帳同一個思路)。
(Java 線程池的部分補充自 https://juejin.cn/post/7626595191144595456,對照 OpenJDK 原始碼與 errgroup 原始碼,查證 2026-09-27)
8. ★ Go 1.22 的迴圈變數變更(2026-09-21 查證)
Go 1.22 release notes:“Previously, the variables declared by a ‘for’ loop were created once and updated by each iteration. In Go 1.22, each iteration of the loop creates new variables, to avoid accidental sharing bugs.”
for _, v := range items {
go func() { use(v) }() // ★ Go 1.22 前:所有 goroutine 看到同一個 v(最後一個值)
} // ★ Go 1.22 起:每輪新的 v,行為正確但要注意:
- 這由
go.mod裡的go指令版本決定,不是編譯器版本 go vet也跟著改了——1.22+ 的檔案不再報告這類引用- 舊專案(
go 1.21以下)仍然需要v := v
🧪 我實際套用的紀錄
- 2026-08-28:(待填)
⚠️ 注意 / 什麼時候不適用
- ★ 這句標語有邊界,原文自己講了:“This approach can be taken too far.”
保護一小塊狀態 →sync.Mutex更簡單也更快;傳遞所有權或資料流 → channel。
用 channel 做引用計數是過度工程。 - ★ channel 不是免費的。每次收送都有排程與鎖的開銷,比 mutex 貴。
高頻的小操作(計數器、快取查找)用sync/atomic或RWMutex。 - ★ 無緩衝 channel 是同步點,會讓雙方互相等待。加緩衝能解耦,但緩衝滿了還是會阻塞——
緩衝只是延後問題,不是背壓的解答(見 工具-MQ消費端防禦三原則)。 - 無界的 goroutine = 無界的記憶體。
for { go handle(req) }在流量尖峰時會爆。一定要有併發上限。 - panic 跨不過 goroutine 邊界。子 goroutine 裡的 panic 會直接終止整個程式,主 goroutine 的
recover攔不到。
→ 每個go出去的函式都該有自己的defer recover()(見 工具-defer-panic-recover)。 select的多個就緒 case 是隨機選的,不是依序。不要依賴順序。sync.WaitGroup的Add必須在go之前呼叫,放在 goroutine 內部是競態。
🔗 相關工具
- 工具-select超時與競速 —— select 的逾時、競速與 nil channel 技巧
- 工具-用race偵測器抓資料競爭 —— 驗證「沒有共享」是真的
- 工具-defer-panic-recover —— ★ 每個 goroutine 都要自己 recover
- 工具-服務容錯設計 —— 超時、重試、斷路器
- 工具-MQ消費端防禦三原則 —— 背壓與有界佇列
- 堆疊與佇列 —— channel 作為有界佇列
- 中介者模式 —— Go 的 hub 模式(單一 goroutine 序列化狀態)
- Effective Go、Go Blog 經典六篇