Phần trước đo mạng. Phần này đo thứ nghe còn vô lý hơn khi nói là "không tin được": đồng hồ. Mỗi máy có một đồng hồ, NTP giữ chúng gần nhau — nhưng gần là bao nhiêu, và bao nhiêu thì đủ để "sự kiện A xảy ra trước B" trở thành câu sai? Bài này dựng ba tiến trình thật, cho chúng gửi thông điệp qua mạng, gắn ba loại đồng hồ vào từng sự kiện, rồi đếm xem đồng hồ nào nói dối và khi nào.

Ba đồng hồ cho một sự kiện

Ảnh chụp đoạn mã nền tối ba đồng hồ cho một sự kiện tường Lamport vector với 3 tiến trình Go node 0 1 2 trong go-lab gửi 900 thông điệp qua HTTP lệch đồng hồ tường giả lập bằng cờ lech, mỗi sự kiện ghi cả ba cùng lúc gồm struct suKien có Wall int64 là time.Now cộng lech tức đồng hồ tường có thể lệch giữa các máy, Lamport int64 là bộ đếm logic một số, Vector mảng 3 int64 là một bộ đếm cho mỗi node, gửi thì tăng của mình nhận thì lấy max rồi tăng của mình gồm khi gửi lamport tăng vector tại id tăng rồi đóng gói thong với TuNode TuSeq Lamport Vector, khi nhận nếu m.Lamport lớn hơn lamport thì gán rồi lamport tăng tức Lamport bằng max của mình và nhận cộng 1, với vector thì từng phần lấy max rồi vector tại id tăng tức max từng phần rồi tăng phần của mình, a xảy ra trước b theo vector nghĩa là mọi phần nhỏ hơn hoặc bằng và ít nhất một phần nhỏ hơn gồm func truocVector a b trả bool với nho bằng false, duyệt từng phần nếu a lớn hơn b thì return false vì có phần a lớn hơn thì a không thể trước b, nếu a nhỏ hơn b thì nho bằng true, cuối cùng return nho, ghi chú không truocVector a b và không truocVector b a thì a và b đồng thời không bên nào biết về bên kia

Hình 1: Mỗi sự kiện mang cả ba đồng hồ. Lamport là một số; vector là một số cho mỗi node; cả hai chỉ tăng theo quy tắc gửi/nhận, không nhìn đồng hồ máy.

  • Đồng hồ tường là time.Now(), cộng một độ lệch giả lập theo cờ -lech để mô phỏng máy có đồng hồ chạy nhanh hoặc chậm. Ba tiến trình ở đây chạy trên cùng một kernel nên không có lệch thật; độ lệch là thứ duy nhất được giả lập trong bài, và nó được ghi rõ trong mọi bảng.
  • Lamport clock (1978): một bộ đếm. Mỗi sự kiện tăng nó lên; khi nhận thông điệp, lấy max của mình và của người gửi rồi tăng. Nếu a xảy ra trước b thì Lamport(a) < Lamport(b) — chiều ngược lại thì không.
  • Vector clock: một bộ đếm cho mỗi node. Khi nhận, lấy max từng phần rồi tăng phần của mình. "a trước b" khi mọi phần của a ≤ b và ít nhất một phần nhỏ hơn thật; không bên nào trước bên nào thì a và b đồng thời — nghĩa là không bên nào biết về bên kia lúc xảy ra.

Đo thật: 1 ms là đủ

Ba tiến trình, mỗi tiến trình gửi 300 thông điệp tới một node ngẫu nhiên khác, tổng 900. Mỗi cặp gửi → nhận là quan hệ nhân quả chắc chắn: không thể nhận trước khi gửi. Đếm xem mỗi đồng hồ xếp ngược bao nhiêu cặp:

Ảnh chụp output thật nền tối lệch 1 ms đủ để tường xếp ngược 300 trên 900 cặp với Go 1.23 ba tiến trình trong go-lab RTT khoảng 0,3 ms 900 thông điệp mỗi lần chạy, mục 1 cặp gửi nhận là nhân quả chắc chắn đồng hồ nào xếp ngược cho thấy lệch 0 0 0 ms 900 cặp tường xếp ngược 0 Lamport ngược 0 vector ngược 0, lệch 0 cộng 1 0 ms 900 cặp tường ngược 300 tức 33,3 phần trăm Lamport 0 vector 0 với ghi chú mọi thông điệp node1 gửi, lệch 0 cộng 5 0 ms 300 tức 33,3 phần trăm, lệch 0 cộng 50 0 ms 300, lệch 0 trừ 50 0 ms 297 tức 33,0 phần trăm với ghi chú mọi thông điệp node1 nhận, lệch trừ 30 0 cộng 30 ms 431 tức 47,9 phần trăm Lamport 0 vector 0, mục 2 hai sự kiện gửi ở hai node không liên quan nhau cho thấy cặp sự kiện gửi ở hai node khác nhau 270000, vector nói đồng thời không bên nào trước 1269 tức 0,5 phần trăm, trong số đó Lamport vẫn gán trước sau 1109 với ghi chú thứ tự đó không có nghĩa nhân quả, mục 3 LWW theo đồng hồ tường node1 ghi trước node0 ghi sau 5 ms thật bản nào được giữ cho thấy lệch node1 0 ms node0 sau đúng 200 trên 200 node1 trước cũ 0 trên 200, lệch cộng 1 ms node0 200 trên 200 node1 0, lệch cộng 20 ms node0 0 trên 200 node1 200 trên 200 với ghi chú bản cũ đè bản mới, lệch cộng 200 ms node0 0 node1 200 trên 200, nói thẳng trong mạng nội bộ 0,3 ms lệch đồng hồ 1 ms đã đủ để nhận đứng trước gửi NTP tốt cũng chỉ hứa vài ms Lamport không bao giờ sai nhân quả nhưng không phân biệt được đồng thời vector phân biệt được với giá một bộ đếm mỗi node

Hình 2: Kết quả thật. Mục 1 là nhân quả chắc chắn, mục 2 là đồng thời, mục 3 là hậu quả thực tế của việc tin đồng hồ tường.

lệch (node0, node1, node2) tường xếp ngược Lamport ngược vector ngược
0, 0, 0 ms 0 0 0
0, +1, 0 ms 300 (33,3%) 0 0
0, +50, 0 ms 300 (33,3%) 0 0
0, −50, 0 ms 297 (33,0%) 0 0
−30, 0, +30 ms 431 (47,9%) 0 0

Lệch 1 ms là đủ, vì mạng nhanh hơn 1 ms. RTT giữa ba tiến trình là 0,3 ms. Khi đồng hồ node 1 chạy nhanh 1 ms, mọi thông điệp node 1 gửi được đóng dấu "muộn" hơn thời điểm người nhận đóng dấu "đã nhận" — vì 1 ms lệch lớn hơn 0,3 ms đường truyền. Đúng 300 cặp, tức 100% thông điệp node 1 gửi đi, bị xếp ngược. Lệch 5 hay 50 ms cho cùng con số, vì ngưỡng đã bị vượt từ 1 ms. Lệch −50 ms đảo chiều: 297 cặp ngược là mọi thông điệp node 1 nhận. Ba node lệch nhau theo ba hướng: gần một nửa số cặp sai.

Đặt con số đó cạnh NTP: trong điều kiện tốt, NTP giữ lệch ở mức vài mili-giây; qua Internet công cộng là hàng chục; một máy ảo bị tạm dừng rồi tiếp tục có thể lệch hàng giây cho tới lần đồng bộ sau. Tất cả đều lớn hơn 0,3 ms. Trong mạng nội bộ, đồng hồ tường không đủ tốt để xếp thứ tự hai sự kiện ở hai máy khác nhau — không phải vì NTP kém, mà vì mạng nhanh hơn độ chính xác của bất kỳ đồng hồ nào.

Lamport và vector sai 0 lần ở mọi độ lệch, vì chúng không nhìn đồng hồ máy. Thứ tự của chúng đến từ chính thông điệp: nhận bao giờ cũng lấy max rồi tăng, nên nhận luôn lớn hơn gửi, bất kể máy nào nghĩ mấy giờ.

Mục 2: thứ Lamport không nói được

Lamport đảm bảo "a trước b ⇒ L(a) < L(b)" nhưng chiều ngược lại thì không: L(a) < L(b) không có nghĩa a xảy ra trước b. Trong 270.000 cặp sự kiện gửi ở hai node khác nhau, vector clock nhận ra 1.269 cặp đồng thời — hai sự kiện mà lúc xảy ra, không bên nào đã nghe gì về bên kia. Lamport gán số khác nhau cho 1.109 trong số đó, tức là xếp một cái trước một cái sau, và thứ tự đó không mang nghĩa nhân quả — nó chỉ là kết quả của việc ép mọi thứ lên một trục số.

Chuyện này quan trọng khi nào? Khi hai sự kiện đồng thời là hai lần ghi vào cùng một khoá. Lamport sẽ chọn một bên thắng mà không ai hay biết có xung đột; vector clock nói "đây là xung đột, cần hợp nhất" — và đó chính là cơ chế đằng sau sibling trong Dynamo/Riak và CRDT ở phần 9. Cái giá: vector lớn theo số node (ba số ở đây, hàng trăm số với hàng trăm node), còn Lamport luôn là một số.

Mục 3: Last-Writer-Wins với đồng hồ chạy nhanh

Nhiều hệ thống giải quyết xung đột bằng last-writer-wins: bản có timestamp lớn hơn thắng. Mục 3 đo đúng chuyện đó với hai node ghi cùng một khoá: node 1 ghi trước, node 0 ghi sau 5 ms theo thời gian thật.

  • Lệch 0 hoặc +1 ms: node 0 thắng 200/200 — đúng, vì nó ghi sau.
  • Lệch node 1 +20 ms: node 1 thắng 200/200 — sai, vì nó ghi trước; đồng hồ nhanh 20 ms khiến bản cũ có timestamp lớn hơn bản mới 5 ms.

Hệ quả thực tế: trong một cụm dùng LWW theo đồng hồ tường, máy có đồng hồ chạy nhanh nhất luôn thắng, bất kể nó ghi trước hay sau. Và nó thắng một cách im lặng — không log, không lỗi, chỉ có cập nhật của máy khác biến mất. Cassandra có ghi rõ điều này trong tài liệu; nhiều người đọc nó lần đầu sau khi mất dữ liệu.

Đánh đổi cần cân nhắc

Lệch đồng hồ ở đây là giả lập, và đó là điểm cần nói rõ. Ba tiến trình chạy chung một kernel, không thể lệch thật. Cờ -lech cộng một hằng vào time.Now() — nó mô phỏng offset chứ không mô phỏng drift (đồng hồ chạy nhanh dần) hay bước nhảy (NTP chỉnh lùi giờ). Hai thứ sau còn tệ hơn offset: bước nhảy lùi làm một máy tự xếp ngược sự kiện của chính nó. Go dùng đồng hồ đơn điệu cho time.Since đúng vì vậy — nhưng timestamp gửi qua mạng thì vẫn là đồng hồ tường.

Đồng hồ tường vẫn có chỗ: khi cần so với thế giới thật. "Đơn này tạo lúc 10:03" cho người đọc, "token hết hạn sau 15 phút", "xoá log cũ hơn 30 ngày" — những thứ này không phải nhân quả giữa hai máy, và Lamport vô dụng ở đó. Vấn đề chỉ xuất hiện khi lấy timestamp tường để quyết định cái nào xảy ra trước giữa hai máy.

Hybrid Logical Clock là cách lai thực dụng. HLC ghép đồng hồ tường với bộ đếm logic: gần với giờ thật để người đọc được, nhưng tuân thủ nhân quả như Lamport. CockroachDB và nhiều hệ mới dùng nó. Cái giá là phải giới hạn lệch tối đa và từ chối node vượt ngưỡng.

Spanner làm ngược lại: biến độ lệch thành khoảng. TrueTime trả về [sớm nhất, muộn nhất] thay vì một con số, và commit chờ cho tới khi khoảng đó trôi qua. Nó cần đồng hồ nguyên tử và GPS trong từng trung tâm dữ liệu — tức là trả tiền để thu hẹp cái khoảng mà bài này đo bằng mili-giây xuống micro-giây, chứ không xoá nó.

Số đo của ba node, 900 thông điệp. Tỉ lệ 33% là của mô hình "một node lệch, gửi đều cho hai node kia"; tỉ lệ thật phụ thuộc đồ thị trao đổi. Kết luận không phụ thuộc: ngưỡng để đồng hồ tường sai bằng độ trễ mạng, và độ trễ mạng nội bộ nhỏ hơn mọi độ lệch đồng hồ thực tế.

Ba ý mang về

  1. Đồng hồ tường sai nhân quả ngay khi lệch vượt độ trễ mạng — 1 ms trong mạng 0,3 ms là đủ để 100% thông điệp của node lệch bị xếp ngược (300/900); ba node lệch ba hướng là gần một nửa. NTP không hứa được dưới mức đó.
  2. Lamport và vector clock không bao giờ sai nhân quả (0/900 ở mọi độ lệch) vì thứ tự đến từ thông điệp, không từ đồng hồ. Lamport là một số nhưng không phân biệt được đồng thời (gán trước/sau cho 1.109 cặp vốn không liên quan); vector phân biệt được, với giá một bộ đếm mỗi node.
  3. LWW theo đồng hồ tường cho máy có đồng hồ nhanh thắng vô điều kiện: lệch 20 ms là bản cũ đè bản mới 200/200, không log, không lỗi. Dùng đồng hồ tường để hiển thị và hết hạn; dùng đồng hồ logic để xếp thứ tự giữa các máy.

Nguồn

Phần sau dùng đúng thứ vừa đo để đập vỡ một công cụ ai cũng từng viết: khoá phân tán bằng SET NX kèm TTL. Tiến trình giữ khoá bị treo quá TTL, khoá hết hạn, tiến trình khác lấy khoá — rồi tiến trình đầu tỉnh dậy và ghi tiếp. Đo thật số lần ghi sai, và fencing token chặn nó ra sao.