Skip to content

Thời gian & thứ tự ​

Mỗi máy có đồng hồ riêng, và không hai đồng hồ nào chạy giống hệt nhau. Trang này giải thích đồng hồ máy tính sai thế nào, vì sao không dùng giờ máy để sắp thứ tự sự kiện giữa nhiều máy, và các cách thay thế: logical clock, hybrid logical clock (HLC), TrueTime của Spanner. Lamport clock và version vector đã có ở trang Replication, nên trang này chỉ nhắc lại phần cần dùng. Số liệu đồng hồ trong trang là số đo thật trên máy của tác giả bằng lab/thoi-gian/.

Hai loại đồng hồ ​

Máy tính có hai loại đồng hồ dùng cho hai việc khác nhau:

Wall clock (giờ thực)Monotonic clock
Trả vềNgày giờ hiện tại, ví dụ số ms từ 1/1/1970Một số chỉ tăng, tính từ một mốc tuỳ ý (thường là lúc máy khởi động)
Có thể đi lùiCó: NTP chỉnh giờ, người chỉnh tay, máy ảo vừa được khôi phụcKhông
So được giữa hai máyCó, nhưng sai bằng độ lệch giữa hai đồng hồKhông, mỗi máy một mốc
Node.jsDate.now(), new Date()performance.now(), process.hrtime.bigint()
DatabaseNOW(), CURRENT_TIMESTAMP(không có)
Dùng đểGhi ngày giờ cho con người đọc, hẹn lịchĐo khoảng thời gian, timeout

Quy tắc là: đo khoảng thời gian bằng monotonic clock. Đo thời gian chạy một request bằng Date.now() thì một lần NTP chỉnh giờ ngay giữa chừng có thể cho ra số âm, hay một timeout bị kéo dài thêm vài giây. Đo thật trong Node.js 24 (hai lượt): sau 5 giây, Date.now() và performance.now() lệch nhau chưa tới 0,1 ms; trong 1 ms, Date.now() chỉ có 2 giá trị khác nhau (độ phân giải 1 ms), còn performance.now() có hơn 3.000; một triệu lần gọi performance.now() không có lần nào nhỏ hơn lần trước.

Đồng hồ các máy lệch nhau bao nhiêu ​

Đồng hồ máy tính chạy bằng một tinh thể thạch anh, và tinh thể nào cũng nhanh hoặc chậm một chút tuỳ nhiệt độ. Bài báo Spanner kể rằng Google giả định đồng hồ trôi tới 200 micro giây mỗi giây giữa hai lần đồng bộ, tức 6 ms sau 30 giây. Vì vậy các máy đều đồng bộ định kỳ với máy chủ thời gian qua NTP.

NTP không biết chính xác độ trễ một chiều của mạng, nên nó đo offset bằng bốn mốc thời gian của một lần hỏi đáp: T1 lúc client gửi, T2 lúc server nhận, T3 lúc server trả lời, T4 lúc client nhận. RFC 5905 tính:

offset = ((T2 − T1) + (T3 − T4)) / 2
delay  = (T4 − T1) − (T3 − T2)

Công thức giả định đường đi và đường về mất bằng nhau. Nếu không bằng thì offset sai, và sai tối đa bằng một nửa round trip. Vì vậy NTP ưu tiên những lần hỏi có delay nhỏ nhất. Khi lệch nhỏ, NTP chỉnh dần tốc độ đồng hồ để không làm thời gian nhảy; khi lệch quá ngưỡng 125 ms (theo RFC 5905), nó nhảy thẳng tới giờ đúng, và đó là lúc wall clock có thể đi lùi.

Đo thật trên một MacBook (hai lượt ngày 2026-10-21):

ĐoKết quả
Đồng hồ máy so với máy chủ NTP của Google, Apple, Cloudflare (sntp)Máy chậm hơn khoảng 57–68 ms ở cả ba máy chủ. Sai số sntp báo là ± 34–38 ms với Google và khoảng ± 100 ms với hai máy chủ còn lại, nên chắc chắn máy lệch ít nhất khoảng 20 ms
Đồng hồ trong container Docker so với máy host, đo bằng đúng công thức NTP ở trênLần hỏi có delay nhỏ nhất (44–50 ms) cho offset 14,6–15,9 ms, sai số tối đa ± 22–25 ms. Các lần có delay lớn cho offset tới 78 ms

Hàng thứ hai cho thấy vì sao NTP phải chọn mẫu có delay nhỏ: cùng hai đồng hồ, cùng một phút, nhưng các mẫu có delay lớn cho offset sai lệch tới vài chục ms. Trên macOS, Docker Desktop chạy container trong một máy ảo có đồng hồ riêng, nên container và máy host không chung một đồng hồ.

Vài chục ms nghe nhỏ, nhưng một request trong cùng data center chỉ mất dưới 1 ms. Hai sự kiện cách nhau 5 ms trên hai máy lệch nhau 60 ms sẽ bị xếp ngược thứ tự.

Vì sao không sắp thứ tự bằng giờ máy ​

Giả sử node A chạy nhanh 60 ms. A ghi x = 1, rồi gửi giá trị đó cho B; B đọc được x = 1 rồi mới ghi x = 2. Theo nhân quả, x = 2 phải đứng sau x = 1. Nhưng timestamp của A lớn hơn vì đồng hồ A chạy nhanh, nên last write wins (LWW) giữ x = 1 và lặng lẽ bỏ x = 2, dù cả hai lệnh ghi đều đã báo thành công. Đây là cách Cassandra và nhiều hệ leaderless giải quyết write conflict theo mặc định (Replication).

Kéo độ lệch đồng hồ và độ trễ mạng để xem ba cách đóng timestamp xếp các lệnh ghi ra sao:

Độ lệch đồng hồ so với giờ thật

Giờ thật đo bằng một chiếc đồng hồ tưởng tượng mà không node nào có. Mặc định A chạy nhanh 60 ms, như khi một máy vừa bị NTP chỉnh hay đồng hồ trôi nhanh.

Sự kiệnGiờ thậtĐồng hồ nodeLamportHLC (l, c)
A ghi x = 11001601(160, 0)
A gửi tin cho B1101702(170, 0)
C ghi x = 3 (độc lập)125951(95, 0)
B nhận tin của A1301303(170, 1)
B ghi x = 2 (sau khi thấy x = 1)1401404(170, 2)
Theo đồng hồ vật lý, B nhận tin trước cả lúc A gửi. Không thể dùng timestamp này để biết việc nào xảy ra trước.
physical: thứ tự x = 3 < x = 2 < x = 1; last write wins giữ x = 1. x = 2 bị xếp trước x = 1 dù B ghi sau khi đã thấy x = 1: lệnh ghi x = 2 bị mất.
Lamport: thứ tự x = 1 < x = 3 < x = 2; last write wins giữ x = 2. x = 2 đứng sau x = 1, đúng nhân quả.
HLC: thứ tự x = 3 < x = 1 < x = 2; last write wins giữ x = 2. x = 2 đứng sau x = 1, đúng nhân quả.

Theo giờ thật, lệnh ghi cuối cùng là x = 2. HLC luôn giữ đúng thứ tự nhân quả, và l chỉ vượt đồng hồ vật lý của node tối đa 40 ms, tức là bằng độ lệch đồng hồ lớn nhất giữa các node. x = 3 của C không liên quan nhân quả với hai lệnh kia, nên mọi cách xếp đều hợp lệ về mặt nhân quả, dù có thể khác thứ tự theo giờ thật.

Logical clock ​

Logical clock không đo thời gian mà đếm sự kiện, để giữ đúng quan hệ xảy ra trước (happens-before): A xảy ra trước B nếu cùng một node làm A rồi làm B, hoặc A là lúc gửi một tin nhắn còn B là lúc nhận tin đó, hoặc có một chuỗi như vậy nối A tới B. Hai sự kiện không có quan hệ này thì là concurrent.

  • Lamport clock là một bộ đếm: mỗi sự kiện cộng 1, nhận tin nhắn thì lấy max của mình và của tin nhắn rồi cộng 1. Nếu A xảy ra trước B thì timestamp của A nhỏ hơn của B. Chiều ngược lại không đúng: timestamp nhỏ hơn không có nghĩa là xảy ra trước, có thể chỉ là concurrent. Ví dụ từng bước có ở Replication.
  • Vector clock (version vector khi dùng cho dữ liệu) giữ một bộ đếm cho mỗi node, nên phân biệt được hai sự kiện có quan hệ trước sau hay là concurrent (Version vector). Đổi lại, kích thước tăng theo số node.

Nhược điểm chung: logical clock không gắn gì với giờ thật. Không trả lời được câu “có những gì trước 15:00 hôm qua”, và hai sự kiện không trao đổi tin nhắn có thể được xếp bất kỳ, dù cách nhau cả giờ.

Hybrid logical clock ​

HLC (Kulkarni và cộng sự, 2014) gộp hai cách trên. Timestamp là một cặp (l, c):

  • l là giờ vật lý lớn nhất mà node từng thấy, của chính nó hay nhận qua tin nhắn.
  • c là bộ đếm phụ, dùng khi l không tăng, để vẫn phân biệt được các sự kiện.

Thuật toán trong bài báo khá ngắn. Với sự kiện cục bộ hay lúc gửi: l = max(l_cũ, giờ vật lý); nếu l không đổi thì c tăng 1, còn không thì c về 0. Lúc nhận tin nhắn mang (l_m, c_m): l = max(l_cũ, l_m, giờ vật lý), rồi đặt c theo việc l mới bằng giá trị nào trong ba giá trị đó.

Kết quả là HLC giữ đúng quan hệ xảy ra trước như Lamport clock, mà vẫn gần giờ thật: l chỉ vượt đồng hồ vật lý của node một khoảng không lớn hơn độ lệch giữa các đồng hồ. Theo bài báo, HLC còn vừa với định dạng timestamp 64 bit của NTP. Nhờ vậy dùng được HLC làm timestamp cho dữ liệu, và vẫn hỏi được “dữ liệu lúc 15:00” với sai số bằng độ lệch đồng hồ. CockroachDB và MongoDB dùng HLC cho mục đích này.

TrueTime và commit wait ​

Spanner chọn cách khác: không giấu độ lệch đồng hồ, mà đo và công bố nó. API TrueTime trả về một khoảng [earliest, latest] chắc chắn chứa giờ thật, thay vì một thời điểm. Theo bài báo Spanner (2012), mỗi data center có máy chủ thời gian dùng GPS và đồng hồ nguyên tử; độ bất định ε thường dao động từ 1 tới 7 ms giữa các lần đồng bộ (30 giây một lần), trung bình khoảng 4 ms.

Để timestamp của transaction khớp với thứ tự thời gian thật, Spanner dùng commit wait: sau khi chọn timestamp s cho một transaction, coordinator chờ cho tới khi TrueTime chắc chắn rằng s đã qua, rồi mới cho client thấy dữ liệu. Transaction nào bắt đầu sau đó chắc chắn có timestamp lớn hơn. Cái giá là mỗi lần commit phải chờ cỡ vài ms; bài báo đo commit wait khoảng 5 ms. Độ bất định càng nhỏ thì chờ càng ít, nên Google đầu tư vào đồng hồ chính xác.

Những chỗ hay gặp trong ứng dụng ​

  • ID sinh theo thời gian. Snowflake và UUIDv7 có phần timestamp ở đầu. Đồng hồ đi lùi thì có thể sinh trùng ID, nên phải chờ hoặc từ chối (Sinh ID).
  • TTL và lease. TTL chỉ cần đo khoảng thời gian, nên lẽ ra dùng monotonic clock. Redis tính TTL theo wall clock, nên chỉnh giờ có thể làm lock hết hạn sớm (Distributed lock).
  • Token có hạn (JWT, link đặt lại mật khẩu). Server phát và server kiểm tra có đồng hồ lệch nhau, nên khi kiểm tra exp, nbf thường cho phép lệch vài chục giây, ví dụ tuỳ chọn clockTolerance của thư viện jsonwebtoken.
  • Sắp thứ tự sự kiện trong một database. Dùng ID tự tăng hay sequence của chính database, hoặc thời gian lấy từ một đồng hồ (NOW() của primary), không lấy giờ của từng app server.
  • Log từ nhiều máy. Log gộp từ nhiều service có thể có thứ tự sai lệch vài chục ms. Muốn biết thứ tự thật của một request qua nhiều service thì dùng trace ID và quan hệ cha con của span, không dựa vào timestamp.

Câu hỏi phỏng vấn hay gặp ​

Vì sao không dùng timestamp để quyết định lệnh ghi nào mới hơn? Đồng hồ các máy lệch nhau hàng chục ms, nhiều hơn khoảng cách giữa hai lệnh ghi. Last write wins theo timestamp có thể giữ lệnh ghi cũ và bỏ lệnh ghi mới, mà không báo lỗi gì.

Lamport clock khác vector clock thế nào? Lamport clock là một số, giữ được chiều “xảy ra trước thì nhỏ hơn”, nhưng không biết hai sự kiện có concurrent hay không. Vector clock giữ một bộ đếm cho mỗi node, nên phát hiện được concurrent, đổi lại kích thước tăng theo số node.

Đo thời gian chạy của một đoạn code bằng gì? Monotonic clock (performance.now(), process.hrtime.bigint()), không dùng Date.now().

Ghi nhớ

  1. Wall clock có thể đi lùi và không so chính xác được giữa các máy; monotonic clock chỉ tăng nhưng chỉ có nghĩa trong một máy. Đo khoảng thời gian bằng monotonic clock.
  2. Đồng hồ các máy lệch nhau hàng chục ms. Đo thật: máy của tác giả lệch khoảng 60 ms so với NTP, container Docker lệch khoảng 15 ms so với máy host.
  3. NTP tính offset từ bốn mốc thời gian và giả định đường đi bằng đường về; sai số tới một nửa round trip. Lệch quá 125 ms thì nhảy giờ.
  4. Không sắp thứ tự sự kiện giữa nhiều máy bằng giờ máy: last write wins có thể lặng lẽ mất lệnh ghi đến sau.
  5. Lamport clock giữ quan hệ xảy ra trước; vector clock phát hiện concurrent; HLC giữ quan hệ xảy ra trước mà vẫn gần giờ thật.
  6. TrueTime công bố độ bất định của đồng hồ, và Spanner chờ hết độ bất định đó lúc commit để timestamp khớp với thứ tự thật.

Nguồn tham khảo ​

Đã đối chiếu (đọc nguồn ngày 2026-10-21):

NguồnDùng cho phần
Sandeep Kulkarni và cộng sự, Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases (2014)Thuật toán HLC (hình 5), l gần giờ vật lý, vừa định dạng 64 bit của NTP
James Corbett và cộng sự, Spanner: Google’s Globally-Distributed Database (OSDI 2012)TrueTime, ε từ 1 tới 7 ms (trung bình 4 ms), đồng bộ mỗi 30 giây, drift 200 micro giây mỗi giây, commit wait, đo commit wait khoảng 5 ms
RFC 5905, NTPv4Công thức offset và delay từ bốn mốc thời gian, ngưỡng nhảy giờ STEPT 125 ms

Từ kiến thức chung, chưa đối chiếu lần này:

  • Leslie Lamport, Time, Clocks, and the Ordering of Events in a Distributed System (1978): quan hệ xảy ra trước và Lamport clock.
  • CockroachDB và MongoDB dùng HLC.
  • Redis tính TTL theo wall clock (đã đối chiếu ở trang Distributed lock: Redis không dùng monotonic clock cho TTL).
  • Tuỳ chọn clockTolerance của thư viện jsonwebtoken.
  • NTP chỉnh dần tốc độ đồng hồ khi lệch nhỏ (slew).
  • Cassandra dùng last write wins theo timestamp cho từng ô dữ liệu.

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- thoi-gian (cần Docker và image mysql:8.0 của lab, không cần lab:up; khoảng 40 giây; phần NTP dùng lệnh sntp nên chỉ chạy trên macOS). Số trong trang lấy từ hai lượt ngày 2026-10-21 trên MacBook M2.

lab/thoi-gian/clocks.mjs: NTP, đồng hồ container, wall clock và monotonic clock
js
// Trang distributed/thoi-gian: đồng hồ của các máy lệch nhau bao nhiêu. Chỉ cần Docker (image mysql:8.0 của lab).
// 1. Đồng hồ máy này so với máy chủ NTP công cộng (lệnh sntp có sẵn trên macOS).
// 2. Đồng hồ trong container Docker so với máy host, đo bằng đúng công thức của NTP (RFC 5905):
//      offset = ((T2 − T1) + (T3 − T4)) / 2,  delay = (T4 − T1) − (T3 − T2)
//    T1, T4: giờ host lúc gửi và lúc nhận; T2 = T3: giờ container. Lấy mẫu có delay nhỏ nhất.
//    Trên macOS, Docker Desktop chạy container trong một máy ảo có đồng hồ riêng.
// 3. Trong Node.js: Date.now() (wall clock) và performance.now() (monotonic) có chạy cùng tốc độ không, độ phân giải.
import { execSync, spawnSync } from 'node:child_process'

console.log('\n=== 1. Đồng hồ máy này so với NTP (sntp, offset dương nghĩa là máy này chậm hơn máy chủ) ===')
if (process.platform === 'darwin') {
  for (const round of [1, 2]) {
    for (const s of ['time.apple.com', 'time.google.com', 'time.cloudflare.com']) {
      const r = spawnSync('sntp', [s], { timeout: 15000 })
      const line = r.stdout?.toString().trim().split('\n').pop() || '(không trả lời)'
      const m = line.match(/^([+-][\d.]+) \+\/- ([\d.]+)/)
      console.log(`lượt ${round} ${s.padEnd(20)} ${m ? `offset ${(Number(m[1]) * 1000).toFixed(1)} ms, ± ${(Number(m[2]) * 1000).toFixed(1)} ms` : line}`)
    }
  }
} else console.log('bỏ qua: sntp chỉ có sẵn trên macOS (Linux dùng chronyc tracking hoặc ntpq -p)')

console.log('\n=== 2. Đồng hồ trong container so với máy host (công thức NTP, 30 mẫu) ===')
const NAME = 'lab-clock'
try { execSync(`docker rm -f ${NAME}`, { stdio: 'ignore' }) } catch {}
execSync(`docker run -d --rm --name ${NAME} --entrypoint sleep mysql:8.0 300`, { stdio: 'ignore' })
const nowUs = () => (performance.timeOrigin + performance.now()) * 1000 // micro giây, wall clock của host
const samples = []
for (let i = 0; i < 30; i++) {
  const t1 = nowUs()
  const out = execSync(`docker exec ${NAME} date +%s%N`).toString().trim()
  const t4 = nowUs()
  const t2 = Number(BigInt(out) / 1000n) // ns → µs
  samples.push({ offset: ((t2 - t1) + (t2 - t4)) / 2, delay: t4 - t1 })
}
execSync(`docker rm -f ${NAME}`, { stdio: 'ignore' })
samples.sort((a, b) => a.delay - b.delay)
const best = samples[0]
const offs = samples.map((s) => s.offset).sort((a, b) => a - b)
console.log(`mẫu có delay nhỏ nhất: offset ${(best.offset / 1000).toFixed(2)} ms, delay ${(best.delay / 1000).toFixed(1)} ms (sai số tối đa ± ${(best.delay / 2000).toFixed(1)} ms)`)
console.log(`30 mẫu: offset từ ${(offs[0] / 1000).toFixed(1)} tới ${(offs[29] / 1000).toFixed(1)} ms, delay từ ${(samples[0].delay / 1000).toFixed(1)} tới ${(samples[29].delay / 1000).toFixed(1)} ms`)

console.log('\n=== 3. Date.now() và performance.now() trong Node.js ===')
{
  const d0 = Date.now(), p0 = performance.now()
  const t = performance.now()
  while (performance.now() - t < 5000) {} // chạy 5 giây
  const d1 = Date.now(), p1 = performance.now()
  console.log(`sau 5 giây: Date.now() tăng ${d1 - d0} ms, performance.now() tăng ${(p1 - p0).toFixed(3)} ms, chênh ${((d1 - d0) - (p1 - p0)).toFixed(3)} ms`)
  const ds = new Set(), ps = new Set(), hs = new Set()
  const end = performance.now() + 1
  while (performance.now() < end) { ds.add(Date.now()); ps.add(performance.now()); hs.add(process.hrtime.bigint()) }
  console.log(`trong 1 ms: Date.now() ra ${ds.size} giá trị khác nhau (độ phân giải 1 ms), performance.now() ra ${ps.size}, process.hrtime.bigint() ra ${hs.size}`)
  let back = 0, prev = performance.now()
  for (let i = 0; i < 1e6; i++) { const v = performance.now(); if (v < prev) back++; prev = v }
  console.log(`1 triệu lần gọi performance.now(): ${back} lần giá trị nhỏ hơn lần trước`)
}