我的老天額系列 #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` 時間戳給後端做判斷——千萬別這樣做,過期時間應該由後端自己從資料庫撈、用自己的系統時間比對。
點擊複製文章連結