同一個排程在三台機器上各跑了一次,那批獎金就這樣發了兩次半
Leo Wu,主力 Go 的後端工程師,做過金流與高併發系統,信奉「擴容前先問自己:哪些假設會被複製成多份?」
財務同事那句「這批獎金怎麼發了兩次?」
那天下午財務同事拿著一張報表走到我旁邊,語氣還算客氣,但眼神已經在問罪:「這個月的推薦獎金,怎麼有一批人拿了兩次?金額對不上,差了快六位數。」
我第一反應是:不可能。那個發放邏輯我自己寫的,每筆都有 user_id 跟金額,SQL 我看過八百遍。我還理直氣壯地回她:「應該是你們報表撈的時間區間重疊了吧?」
結果我打開資料庫一查,臉就綠了。reward_records 裡面,同一批 batch_id、同一群 user_id,確確實實有兩筆一模一樣的發放紀錄,連建立時間都只差幾百毫秒。不是報表的問題,是我的服務真的發了兩次。
我一開始查錯方向
我最先懷疑的是重試。那陣子剛好加了 HTTP 重試機制,我直覺認定是某個 timeout 觸發了重跑。我花了大概兩個小時翻重試的 log,想找那個「多打一次」的請求。
但怎麼看都不對。重試是有的,可是那批發放根本不是外部請求進來的,它是一個排程任務——每個月 1 號凌晨,cron 到點自己跑,把上個月符合條件的人撈出來、算獎金、寫入、發通知。沒有人「請求」它,它是自己醒來的。
轉念的關鍵是我去看發放紀錄的建立時間,發現那幾百毫秒的落差很奇怪。如果是同一個程序跑兩次,中間不該這麼近;但如果是兩個不同的程序、幾乎同時醒來各做一遍,這個時間差就合理了。
我打開部署設定,心涼了半截。這個服務三個月前為了高可用,從單一實例改成了三個 Pod。而排程,是寫在應用程式裡面的——用的是進程內的 cron 函式庫,跟業務程式碼綁在同一個服務。
為什麼單機時代沒事,水平擴展後就爆了
這就是整件事最誠實、也最讓我難堪的地方。
把 cron 寫在應用程式裡,在單機時代是完全沒問題的。整個服務就一份,排程到點觸發一次,發放跑一次,天下太平。我當初這樣寫,還覺得很乾淨:排程跟業務放一起,不用額外維護一台跑 crontab 的機器。
但水平擴展的本質,是把「同一份程式」複製成好幾份同時跑。我為了高可用開了三個 Pod,等於把那個進程內的排程也複製了三份。每個 Pod 到了凌晨 1 號都盡忠職守地醒過來,各自撈資料、各自發放。它們之間沒有任何人負責協調「這件事這次只該有一個人做」。
我那批獎金為什麼是「兩次半」?因為三個 Pod 幾乎同時跑,但發放中間有幾筆撞到唯一索引擋掉了,有些人拿兩次、有些拿三次、有些因為 race 只成功一次,湊起來大概是兩倍多一點。這種不乾不脆的錯,反而最難對帳。
單機時代那個「排程只有一份」的假設,在我按下擴容按鈕的那一刻就悄悄破掉了,而我完全沒意識到。
修法:先讓它別搶,再讓它搶了也不會錯
事故當下我先做了止血:把 Pod 縮回一個,手動把重複的紀錄依 batch_id + user_id 去重、對財務出一份差異清單、把多發的部分走回收流程。這部分很痛,但不是重點。重點是怎麼讓它以後不再發生。
我最後做了兩層,而且我認為兩層都要做:
第一層,讓排程互斥,同一時間只有一個實例在跑。 最直接的是分散式鎖。排程觸發時,先去 Redis 用 SET key value NX PX 30000 搶一把鎖,搶到的才往下執行,搶不到的直接放棄這一輪。這裡有兩個坑我踩過:
- 鎖一定要有過期時間(
PX)。如果拿到鎖的那個 Pod 中途 crash 了、鎖沒釋放,又沒設過期,下個月就變成誰都拿不到鎖、排程再也不跑——把重複執行換成了永遠不執行,更慘。 - 過期時間要估得比任務執行時間長,不然任務還在跑鎖就過期了,第二個實例又進來,等於白鎖。任務真的長,就要用能續租的方案,別自己硬幹。
如果不想自己管鎖,更省心的做法是換成集中式排程器,由一個中心負責觸發、保證單一實例執行;或是做 leader election,只讓選出來的 leader 跑排程,其他人待命。原理都一樣:把「誰該做」這件事收攏到一個地方協調。
第二層,也是我認為更根本的:讓被排程執行的動作本身冪等。 因為鎖總有失效的一天——網路分區、時鐘飄移、Redis 抖一下,你永遠可能在某個縫隙裡跑第二次。所以我不再賭鎖百分之百可靠,而是讓那個發放動作跑幾次結果都一樣。
做法是給每一批發放一個唯一鍵,例如 reward:202607:{user_id},發放前先佔位再執行:先往一張去重表 insert 這個唯一鍵,靠資料庫唯一約束擋住第二次;insert 成功的才真正發、才寫通知,insert 撞鍵的直接跳過。這樣就算三個 Pod 真的同時衝進來,也只有一個能佔到位,其餘的都會被唯一約束彈回去。鎖是減少衝突的機率,冪等才是最後那道不會破的防線。
順手補的那個「排程重疊」坑
修的過程我還發現一個潛在的雷:排程重疊。如果某一輪任務跑得特別久(比如那個月人特別多),還沒跑完,下一輪的觸發時間就到了,第二輪就疊上來。這在單實例也會發生,跟多機無關。
我的處理是給每個排程一個執行中的狀態標記(其實就是前面那把鎖的延伸):這一輪還握著鎖,下一輪來搶搶不到,就自然跳過。順帶也讓我養成一個習慣——排程任務要嘛設計成能被安全跳過一輪,要嘛任務本身要能斷點續跑,別假設它一定每輪都準時、乾淨地跑完。
小結
這次事故給我最實在的一課是:水平擴展會讓很多「本來只有一份」的隱性假設,在無聲無息中破掉。排程只有一份、快取只有一份、記憶體計數器只有一份——這些在單機時代成立的前提,擴容那一刻起就不再成立,而且沒有人會跳出來提醒你。
對排程這件事,我現在的原則很簡單:要嘛集中協調,要嘛動作冪等,最好兩者都做。 用分散式鎖或 leader election 讓同一時間只有一個人跑,把重複的機率壓到極低;再用唯一鍵、先佔位再執行,讓萬一真的跑了兩次也不會產生第二次副作用。鎖負責日常,冪等負責兜底。
那批被發兩次的獎金,我們花了三天才對乾淨。而真正要修的,從來不是那幾筆數字,是我腦子裡那個「排程只會有一份」的過期假設。
留言討論
有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。