WeHelp
我的老天額系列 #1 — 原來不是 bug,是後端機器時間慢了 10 秒
2026-03-26 08:37:22
## 背景說明 最近遇到一個有趣的 bug,分享一下。 功能是這樣的:前端倒數計時結束後,會 POST 一支 API 確認訂單最新狀態,由後端重新計算是否真的過期,再回傳結果給前端。 然後前端反映,時間到期的瞬間打了這支 API,狀態卻沒有改變。 查了一下 Response 裡的 Timestamp(後端機器的系統時間)⋯⋯對,後端機器時間慢了整整 10 秒。所以對後端來說,「現在」根本還沒到期,完全合理地回傳了「未過期」。 第一次遇到這種問題,哈哈笑了一下,挺有趣的。 --- ## 先搞清楚兩個名詞 在分散式系統裡,這類現象有兩個容易搞混的術語: - **時鐘偏差(Clock Skew)**:指不同機器之間,某個時間點上的時間差異。我遇到的問題就是這個,前後端機器在同一瞬間讀到的「現在」差了 10 秒。 - **時鐘漂移(Clock Drift)**:指不同機器上的時鐘「走速」不同(例如 A 機器的一秒實際比 B 機器的一秒略長),長期累積下來就會造成越來越大的 Clock Skew。 也就是說:**Clock Drift 是原因,Clock Skew 是結果**。 ### 那 Clock Skew 到底發生在「誰」跟「誰」之間? 這裡要特別釐清一下:前端 JavaScript 的 `Date.now()` 讀的是**使用者瀏覽器所在裝置**的系統時間,不是前端 Server(Nginx、Node)的時間。 所以這裡的 Clock Skew 嚴格來說是「**使用者的裝置時鐘**」vs「**後端 Server 時鐘**」。就算你的前後端服務跑在同一台機器上,只要使用者的手機或電腦時間不準,一樣會遇到這個問題。 這也是為什麼「應用層校正」是必要的——你能修好自家 Server 的時鐘,但你管不了每一個使用者的裝置時間。 ### 根治方向:檢查 NTP 服務 如果後端慢了 10 秒,代表伺服器本身的時間同步服務(`ntpd` 或 `chronyd`)可能掛掉或設定異常了,讓機器時間慢慢飄移。正確的根治方式是修好這個服務,讓伺服器定期向上游時間伺服器校時。 **如果後端跑在 Docker 裡呢?** Docker container 預設共用 host 的 kernel clock,容器本身沒有獨立的時鐘。所以 NTP 要校的對象是 **host 主機**,不是容器。你在容器裡跑 `date` 看到的時間就是 host 的時間。 這也代表:如果多個容器跑在同一台 host 上,它們的系統時間一定一樣,不會有 Clock Skew。會出現「後端慢 10 秒」通常是不同 host 之間的 NTP 出了問題。 不過就算 Server 端的 NTP 校好了,使用者端的裝置時間仍然不可控,所以還是需要在應用層做一層保護。 --- ## 應用層解法:讓前端對時間進行校正 讓前端拿到後端的系統時間,計算出誤差值(offset),之後所有「現在時間」的取用都加上這個 offset,讓前端的時鐘盡量貼近後端。 實務上有三種做法,複雜度與精準度遞增: --- ### 方法一:單次 Offset(夠用首選) 最簡單。打一次 API 拿後端時間,算差值。 ```typescript // 後端 API app.get('/api/server-time', (req, res) => { res.json({ serverTime: Date.now() }) }) ``` ```typescript // 前端 let clockOffset = 0 async function syncTime() { const res = await fetch('/api/server-time') const { serverTime } = await res.json() clockOffset = serverTime - Date.now() } // 全專案統一用這個取「現在時間」 export function now(): number { return Date.now() + clockOffset } ``` App 初始化時呼叫一次 `syncTime()`,之後所有地方都改用 `now()` 取代裸呼叫的 `Date.now()`。 > 注意:這個算法假設網路延遲為零。如果一趟 request 花了 200ms,算出來的 offset 就會有 ±200ms 的誤差。對大多數場景來說這個程度的誤差可以忽略,但如果你的場景對精準度要求很高,可以直接跳到方法三。 **小技巧:用 HTTP `Date` Header 免開新 API** 其實不用特地開一支 `/api/server-time`,大多數 Web Server(Nginx、Apache)的 Response Header 本來就有 `Date` 欄位,直接抓任意一支 API 的 Header 就能做 offset 校正: ```typescript async function syncTimeFromHeader() { const res = await fetch('/api/any-existing-endpoint') const serverDateStr = res.headers.get('date') if (serverDateStr) { clockOffset = new Date(serverDateStr).getTime() - Date.now() } } ``` `Date` Header 的精準度只到「秒」,但以訂單過期、倒數計時這類場景來說,秒級精度已經綽綽有餘。 --- ### 方法二:多次取樣平均 Offset 打多次 API 取樣,平均掉網路抖動造成的誤差,結果比方法一穩定。 ```typescript async function syncTimeAverage(samples = 5) { const offsets: number[] = [] for (let i = 0; i < samples; i++) { const res = await fetch('/api/server-time') const { serverTime } = await res.json() offsets.push(serverTime - Date.now()) } clockOffset = offsets.reduce((a, b) => a + b, 0) / offsets.length } ``` 這個方法適合**後端不方便改 API 回傳額外時間欄位**(沒辦法用方法三),但又想比方法一更穩定的情境。缺點是要多打幾次請求,在 Mobile 網路環境下可能造成不必要的延遲與流量消耗,且跟方法一一樣沒有扣掉傳輸延遲本身。 --- ### 方法三:NTP 演算法(最精準) 這是 NTP(網路時間協定)的核心概念,進一步把「封包在網路上飛的時間」也考慮進去。 概念如下(假設上行與下行的網路延遲大致對稱,這個公式就能把傳輸時間互相抵銷,只留下純粹的時鐘差異): ``` t1 → 前端送出請求的時間 t2 → 後端收到請求的時間 t3 → 後端準備回應的時間 t4 → 前端收到回應的時間 offset = ((t2 - t1) + (t3 - t4)) / 2 ``` ```typescript // 後端需要回傳 t2、t3 兩個時間點 // t2 在 handler 進入點取、t3 在回傳前取 // 如果處理邏輯很簡單,兩者幾乎相同也沒關係 app.get('/api/server-time', (req, res) => { const t2 = Date.now() // 收到請求的時間 // ... 其他處理邏輯 ... const t3 = Date.now() // 準備回應的時間 res.json({ t2, t3 }) }) ``` ```typescript // 前端 async function syncTimeNTP() { const t1 = Date.now() const res = await fetch('/api/server-time') const { t2, t3 } = await res.json() const t4 = Date.now() clockOffset = ((t2 - t1) + (t3 - t4)) / 2 } ``` 三種方法算出 `clockOffset` 之後,`now()` 的用法完全一樣,切換方案只需要換掉 `syncTime` 的實作。 --- ## 選哪種? | | 實作難度 | 精準度 | 額外請求數 | |---|---|---|---| | 方法一 | 低 | 夠用 | 1 次(或 0 次,用 Header) | | 方法二 | 低 | 較穩定 | N 次 | | 方法三 | 中 | 最高 | 1 次 | 一般訂單過期、倒數計時這類場景,**方法一就夠了**。如果你的場景是多人競搶、金融交易等對時間極度敏感的功能,再考慮方法三。 --- ## ⚠️ 重要提醒:前端校正不等於安全 前端校正的目的是讓**畫面顯示**更準確、使用者體驗更好。但如果你的倒數計時涉及搶購、領取獎勵等有利益的操作,**過期與否的最終判斷務必以後端資料庫時間為準**,不能依賴前端傳來的時間。 前端的時間永遠可以被竄改,校正只是讓正常使用者的體驗變好,不是安全機制。一個常見的反模式是讓前端帶 `expiredAt` 時間戳給後端做判斷——千萬別這樣做,過期時間應該由後端自己從資料庫撈、用自己的系統時間比對。