每天 20 點發文,卻有兩個「今天」:流水線時區邊界怎麼咬出重複一天

這條內容流水線每天 20:00(SGT)發一篇文章,slug 前綴是 article-YYYYMMDD-,發之前要查一遍 CMS:今天這個前綴已經有行沒有,有就跳過,沒有才寫新的。聽起來是個兩行的邏輯。

專屬插圖
每天 20 點發文,卻有兩個「今天」:流水線時區邊界怎麼咬出重複一天

每天 20 點發文,卻有兩個「今天」:流水線時區邊界怎麼咬出重複一天

這條內容流水線每天 20:00(SGT)發一篇文章,slug 前綴是 `article-YYYYMMDD-`,發之前要查一遍 CMS:今天這個前綴已經有行沒有,有就跳過,沒有才寫新的。聽起來是個兩行的邏輯。

上個週期它出了問題:同一天入了兩筆記錄。沒有報錯,沒有告警,兩次執行各自都覺得自己是當天第一篇。

根子不在程式碼寫錯,而在「今天」這個詞有兩個答案。SGT 的 20 點到 24 點,對應 UTC 是中午 12 點到 16 點,同一天;但 UTC 0 點到 8 點這一段,SGT 已經是新一天了。我們排程器按 SGT 算 `YYYYMMDD`,資料庫裡存的 `published_at` 是 UTC,前端列表按 UTC 日摺疊分組。排程器說「今天是 2026-08-27」,資料庫說「這筆屬於 2026-08-26」——兩邊各自沒錯,拼起來就錯。

具體咬法有兩種。第一種:SGT 23:50 的一次補發,UTC 還在前一天,入庫存的是前綴舊日期,但因為執行時按 SGT 查「今天」,查重查的是新日期前綴,查不到,於是新日期前綴又發了一篇,補發那篇留在了舊日期下面。第二種更隱蔽:SGT 零點後的查詢視窗裡,「今天」的前綴缺口還沒被 20:00 的正式任務填上,任何一個手動觸發的補跑都可能搶著把這個前綴佔了。

復盤之後的改法,四條,都不涉及資料庫遷移:

**一、日期只在一個地方算。** 排程器、腳本、入庫前統一呼叫同一個 `day_id(utc_offset)` 函式,寫死 offset=+8。任何程式碼裡不允許出現裸的 `date.today()` 或 `now().date()`——這兩個函式用的是機器本地時區,容器和 Mac 上答案不一樣。CI 裡加了條 grep 規則,命中裸呼叫直接掛。

**二、查重和入庫用同一個鍵。** 修復前「查重用的前綴」和「入庫用的前綴」是兩次獨立取時間,中間隔了寫稿幾分鐘。改成先取一次時間戳,算出 `day_id`,查重、生成 slug、寫入 `published_at` 全部引用這一個值,用變數傳下去,不重新取。

**三、唯一約束兜底。** 光靠「先查後插」永遠有視窗期。表上補了 `(slug, locale)` 唯一索引,重複插入直接 500,排程器看到就判 HOLD 跳過,不再靜默放行。唯一約束是最便宜的業務規則執行器。

**四、告警看「日聚合」,不看單行。** 監控改成按 `day_id` 聚合:同一天同 category 文章數大於 1,直接告警。單行看每一行都合法,聚合看才露餡——這類邊界的特徵是「局部全對、全局多一」。

還有一點是踩完才明白的:時段視窗越大,邊界問題越容易被平均掉掩蓋。我們 20:00 發一次,一次 200ms 的取時間抖動根本激不活視窗;真正把它激活的是斷網後的手動補跑——補跑把任務從「準點」挪到了「任意時刻」執行,邊界附近的行為就全變了。所以規則加了一條:補跑必須顯式傳 `--day-id`,不允許補跑自己猜今天。

這類問題分享出去經常被問「上分散式鎖或者用 UTC 統一不就行了」。我們最後還是留在 SGT 算日子,因為讀者、營運、告警值班過的是本地時間的日子,換成 UTC 只是把縫從午夜挪到凌晨 8 點,縫本身還在。把日期收成一個函式、讓唯一約束替你吵架、讓聚合告警替你看全局,這三件事做完,時區邊界就從「隨機翻車的分散式事故」退化成「一年有兩天需要留意的固定現象」。

留言區

歡迎分享你的想法!

發表留言

0/500

載入留言中…