Bài 10 kết luận rằng cách tránh lock mạnh nhất là làm cho việc lặp lại trở nên vô hại. Bài này là cách làm điều đó — và nó cũng là câu trả lời cho một bài toán mà mọi hệ thống phân tán phải đối mặt: khi client nhận timeout, nó không thể biết request đã được xử lý hay chưa.
Lab của bài này bắn 78.774 request đồng thời với cùng một idempotency key vào một
endpoint trừ tiền. Kết quả: đúng một bản ghi trong database, đúng một mã
201 Created, và số dư bị trừ đúng một lần. Tắt idempotency đi và chạy lại cùng tải: 14.236
bản ghi, số dư bị trừ 1.423.600 — tài khoản âm 423.600.
Tầng truy cập database là
app/minipg.js — client wire-protocol tự viết, không dependency
(giới thiệu ở Bài 7). Nó không hỗ trợ tham số hoá, nên endpoint tự
làm sạch đầu vào; điều này có ghi rõ trong mã và là giới hạn của lab, không phải cách làm nên theo.
11.1 Vì sao "exactly-once" là ảo tưởng
Client gửi một request trừ tiền. Nó chờ, và nhận timeout. Câu hỏi: server đã trừ tiền chưa? Client không có cách nào biết. Ba tình huống sau đây tạo ra đúng cùng một trải nghiệm ở phía client:
| Điều thật sự xảy ra | Client thấy gì | Nếu client thử lại thì sao |
|---|---|---|
| Request không tới được server | Timeout | Đúng — cần thử lại |
| Server xử lý xong, response bị mất trên đường về | Timeout | SAI — trừ tiền lần thứ hai |
| Server đang xử lý, chưa xong | Timeout | Sai — có thể xử lý song song hai lần |
Đây là bài toán hai vị tướng: không có giao thức nào cho phép hai bên đạt được sự chắc chắn tuyệt đối về việc thông điệp đã tới, khi kênh truyền có thể mất tin. Hệ quả: exactly-once delivery là bất khả thi.
Nhưng có một tổ hợp làm được điều mà người ta thật sự muốn: at-least-once delivery + xử lý idempotent. Gửi lại cho tới khi chắc đã tới (at-least-once), và thiết kế sao cho nhận nhiều lần cũng chỉ có hiệu ứng một lần. Kết quả nhìn từ ngoài giống exactly-once, dù không có bước nào trong đó là exactly-once.
Phạm vi đó không bao gồm side-effect ra thế giới bên ngoài. Nếu consumer của bạn gọi API thanh toán, gửi email, hay đẩy thông báo, thì không broker nào đảm bảo được việc đó chỉ xảy ra một lần — vì chính lời gọi đó cũng chịu bài toán hai vị tướng. Với những side-effect ấy, bạn vẫn phải tự làm idempotent.
11.2 Idempotency key và bảng dedup
Cơ chế rất gọn: client sinh một key duy nhất cho ý định của mình — không phải cho từng lần thử. Server lưu ánh xạ key → kết quả vào một bảng dedup (dedup = deduplication, chống trùng: một bảng chỉ để nhớ "key này đã xử lý rồi, và đây là kết quả"). Gặp lại key đã có, server trả về kết quả cũ thay vì xử lý lại.
| Quyết định | Làm đúng | Làm sai và hậu quả |
|---|---|---|
| Ai sinh key | Client, một key cho một ý định | Server sinh key ⇒ mỗi lần thử là một key mới ⇒ không dedup được gì. Client sinh key mới cho mỗi lần thử cũng cùng hậu quả |
| Lưu gì | Toàn bộ kết quả để trả lại |
Chỉ lưu cờ "đã xử lý" ⇒ request retry nhận 200 rỗng, client
mất mã giao dịch và không biết phải làm gì tiếp
|
| TTL của key | Dài hơn thời gian retry tối đa của client | Ngắn hơn ⇒ key hết hạn giữa hai lần retry, và lần thứ hai được xử lý như một ý định mới |
| Mã trả về khi trùng | Cùng mã và cùng body như lần đầu |
Trả 409 Conflict ⇒ client tưởng thất bại và có thể thử lại bằng key khác — đúng cái ta
muốn tránh
|
Hệ quả thực hành: key phải được sinh ở nơi ý định phát sinh (khi người dùng bấm nút, hoặc khi job được tạo) và giữ nguyên qua mọi lần thử. Nếu bạn sinh key ngay trước lời gọi HTTP thì mỗi lần thử lại sẽ có key khác, và bạn đã cài đặt xong toàn bộ cơ chế mà không nhận được lợi ích nào.
11.3 Ghi dedup và xử lý nghiệp vụ phải nằm trong CÙNG một transaction
Đây là mục quan trọng nhất của bài. Cách cài đặt trực giác nhất — và sai — trông như thế này:
// ===== WRONG: check-then-act — the classic race condition =====
const seen = await db.query(`SELECT * FROM charges WHERE idem_key = $1`, [key]);
if (seen.rows.length) return seen.rows[0].response; // (1) check
await db.query(`UPDATE balances SET balance = balance - $1 WHERE id = 1`, [amount]);
await db.query(`INSERT INTO charges (idem_key, response) VALUES ($1, $2)`,
[key, resp]); // (2) write
// The window between (1) and (2): TWO concurrent requests both see "not there" at (1),
// so BOTH proceed and BOTH charge. Exactly what we set out to prevent.
Lời giải không phải là thêm lock (xem Bài 10) mà là để
database tự thực thi tính duy nhất, bằng một UNIQUE constraint, và làm cả
hai việc trong một câu lệnh:
-- CTE: `ins` tries to write the dedup row. ON CONFLICT DO NOTHING means that if the
-- key already exists nothing is written and `ins` is EMPTY. `upd` only debits the
-- balance WHEN `ins` produced a row — so only the FIRST time has a business effect.
-- The final SELECT returns either the new result (created=true) or the STORED one.
--
-- It is ONE statement => Postgres runs it in an implicit transaction => there is NO
-- window for two concurrent requests to slip through.
WITH ins AS (
INSERT INTO charges (idem_key, amount, response)
VALUES ('order-999', 100, 'charge-ok-100')
ON CONFLICT (idem_key) DO NOTHING
RETURNING id, amount, response
), upd AS (
UPDATE balances SET balance = balance - 100
WHERE id = 1 AND EXISTS (SELECT 1 FROM ins)
RETURNING balance
)
SELECT id, amount, response, 'true' AS created FROM ins
UNION ALL
SELECT c.id, c.amount, c.response, 'false' FROM charges c
WHERE c.idem_key = 'order-999' AND NOT EXISTS (SELECT 1 FROM ins);
Bảng charges có idem_key TEXT NOT NULL UNIQUE. Ràng buộc đó là thứ làm việc
thật: nó biến việc chống trùng từ một đoạn code có thể sai thành một bất biến mà
database không cho phép vi phạm.
Đo thật: 78.774 request đồng thời với cùng một key
64 kết nối, 10 giây, cùng một idem_key, so với cùng tải nhưng không có idempotency key:
| Số liệu | CÓ idempotency key | KHÔNG có |
|---|---|---|
| Request đã xử lý | 78.774 | 14.172 |
| Mã trả về | 201 × 1 · 200 × 78.773 | 201 × 14.172 |
| Bản ghi trong database | 1 | 14.236 |
| Số dư bị trừ | 100 | 1.423.600 (số dư còn −423.600) |
| Throughput | 6.596 rps | 1.183 rps |
| p50 / p99 | 3,41 / 72,47 ms | 39,62 / 189,92 ms |
Dòng "mã trả về" là bằng chứng gọn nhất: trong 78.774 request đồng thời cùng nhắm một
key, đúng một request nhận 201 Created. Toàn bộ phần còn lại nhận
200 kèm chính kết quả đã lưu — cùng chargeId, cùng
response. Không có request nào thất bại (0 lỗi).
INSERT và một
UPDATE balances ở mọi request, và tất cả đều cập nhật
cùng một dòng số dư. Postgres khoá dòng đó ở mức row-level, nên 64 kết nối phải xếp hàng đi qua
một dòng duy nhất.Bản có idempotency chỉ ghi ở request đầu tiên; 78.773 request sau chỉ
SELECT, không cần
khoá ghi gì cả. Nên chênh lệch throughput ở đây là
hệ quả của việc bỏ được tranh chấp khoá, chứ không phải một tính chất tổng quát của
idempotency. Với tải mà các request nhắm những key khác nhau, sẽ không có khoảng cách này.Nhưng có một điều tổng quát: bản không idempotency tạo ra chính sự tranh chấp mà nó phải chịu. Việc xử lý trùng không chỉ làm dữ liệu sai — nó còn tự sinh thêm tải cho hệ thống đúng lúc hệ thống đang gặp vấn đề mạng, tức là đúng lúc client đang thử lại nhiều nhất.
UNIQUE constraint, nếu bạn ghi dedup ở transaction A rồi xử lý nghiệp vụ ở
transaction B thì vẫn còn hai cửa sổ hỏng.Ghi dedup trước: A thành công, tiến trình chết trước khi B chạy. Key đã tồn tại nên mọi lần thử lại đều nhận "đã xử lý" — nhưng tiền chưa bao giờ bị trừ. Yêu cầu im lặng biến mất.
Xử lý nghiệp vụ trước: B thành công, tiến trình chết trước khi A chạy. Tiền đã trừ nhưng không có bản ghi dedup, nên lần thử lại trừ thêm lần nữa.
Không có thứ tự nào an toàn. Chỉ có một cách: cùng một transaction — và cách rẻ nhất để đảm bảo điều đó là viết cả hai việc trong một câu lệnh như SQL ở trên.
UNIQUE constraint đáng giá hơn mọi cơ chế dedup viết bằng tay: nó là bất biến
do database thực thi, không phải một đoạn code có thể bị đua.
11.4 Ngữ nghĩa HTTP, và hai tính chất hay bị gộp
| Method | Idempotent theo đặc tả? | Ghi chú thực hành |
|---|---|---|
GET |
Có (và an toàn — không đổi trạng thái) |
Nếu GET của bạn đổi trạng thái thì bạn đã vi phạm đặc tả và mọi tầng cache sẽ hại bạn
|
PUT |
Có |
Với ID do client sinh, PUT biến việc tạo mới thành idempotent tự nhiên
— không cần bảng dedup nào
|
DELETE |
Có | Xoá lần thứ hai không đổi gì. Nhưng trả 404 ở lần hai có thể làm client hiểu sai |
POST |
Không | Đây là lý do idempotency key tồn tại. Mọi endpoint tạo bằng POST đều cần nó |
Hai tính chất rất hay bị gộp làm một, và chúng độc lập với nhau:
| Idempotent | Giao hoán (commutative) | |
|---|---|---|
| Nghĩa | Lặp lại không đổi kết quả | Đổi thứ tự không đổi kết quả |
| Ví dụ có | SET x = 5 |
x = x + 1 và x = x + 2 |
| Ví dụ không | x = x + 1 (lặp lại thì cộng hai lần) |
SET x = 5 và SET x = 9 (thứ tự quyết định kết quả) |
Chú ý hai hàng cuối đối nghịch nhau: SET thì idempotent nhưng không giao hoán;
+= 1 thì giao hoán nhưng không idempotent. Trong hệ phân tán bạn thường cần cả hai,
và cách đạt được là dùng phép cộng có kèm dedup — += 1 cho tính giao hoán, bảng dedup cho
tính idempotent. Đây cũng là lời giải thích cho gợi ý ở Bài 9 mục
9.5: balance = balance + 50 trộn được còn SET balance = 150 thì không.
POST /orders không kèm idempotency keyHai cách sửa, cả hai đều tốt: thêm idempotency key (header
Idempotency-Key là quy ước phổ biến), hoặc đổi sang
PUT /orders/{id} với id do client sinh — lúc đó tính idempotent là hệ quả
của thiết kế URL, không cần thêm hạ tầng nào.
11.5 Idempotency ở tầng dữ liệu và cho consumer
Không phải lúc nào cũng cần một bảng dedup riêng. Nhiều khi tính idempotent có thể nằm sẵn trong cách bạn thiết kế dữ liệu:
| Kỹ thuật | Cách làm | Khi nào dùng |
|---|---|---|
INSERT ... ON CONFLICT |
Upsert theo khoá tự nhiên | Dữ liệu có khoá tự nhiên (email, mã đơn từ hệ thống nguồn) — rẻ nhất và chắc nhất |
| Khoá tự nhiên thay vì auto-increment | Khoá chính là order_no do hệ thống nguồn cấp |
Khi có thể. Auto-increment nghĩa là mỗi lần ghi lại tạo một ID mới, nên không có gì để dedup theo |
Dedup theo event_id |
Consumer lưu event_id đã xử lý, bỏ qua nếu gặp lại |
Điều kiện tiên quyết cho at-least-once của message queue (Bài 12) |
| Cột version (optimistic) | UPDATE ... WHERE version = :expected |
Khi cần cả chống trùng và chống ghi đè lẫn nhau (Bài 10 mục 10.5) |
Con số phải so là: TTL bảng dedup > (số lần retry tối đa × khoảng nghỉ lớn nhất) + thời gian job có thể nằm trong dead letter queue trước khi được đưa lại. Với những hệ có DLQ và người vận hành đưa job trở lại sau vài ngày, TTL vài giờ là chắc chắn không đủ — và lỗi này chỉ xuất hiện sau khi có sự cố, tức là đúng lúc tệ nhất.
Tự làm lại các phép đo
cd blog/sysdesign/sysdesign-lab
docker compose --profile replica up -d # PostgreSQL is needed
psql_() { docker compose exec -T postgres psql -U lab -d lab -tAc "$1"; }
reset() { psql_ "TRUNCATE charges; UPDATE balances SET balance = 1000000 WHERE id = 1;"; }
# --- WITH an idempotency key: fire THE SAME key every time ---
reset
docker compose run --rm loadgen loadgen.js \
--url "http://lb:8080/charge?key=order-999&amount=100&idem=1" -c 64 -d 10 -w 2 --json
psql_ "SELECT count(*) AS rows FROM charges;" # 1
psql_ "SELECT balance FROM balances WHERE id=1;" # 999900 (debited exactly 100)
# --- WITHOUT an idempotency key: same load, same duration ---
reset
docker compose run --rm loadgen loadgen.js \
--url "http://lb:8080/charge?amount=100&idem=0" -c 64 -d 10 -w 2 --json
psql_ "SELECT count(*) AS rows FROM charges;" # 14236
psql_ "SELECT balance FROM balances WHERE id=1;" # -423600 (!)
# Look closely at the statusCodes field of the idempotent run: {"200":78773,"201":1}
# Exactly ONE request received 201 Created out of 78,774 concurrent requests.
Tóm lại
Exactly-once delivery là bất khả thi — bài toán hai vị tướng nói rằng client nhận timeout không thể phân biệt được ba tình huống khác nhau. Thứ khả thi và cũng là thứ người ta thật sự muốn là at-least-once + xử lý idempotent, và nhìn từ ngoài tổ hợp đó giống exactly-once.
Idempotency key phải là key cho ý định, do client sinh và giữ nguyên qua mọi lần thử. Server phải lưu toàn bộ kết quả chứ không chỉ một cờ "đã xử lý", và trả lại chính kết quả đó khi gặp key trùng.
Phần quan trọng nhất là mục 11.3: ghi dedup và xử lý nghiệp vụ phải nằm trong
cùng một transaction. Không có thứ tự nào an toàn nếu tách làm hai — ghi dedup trước thì
yêu cầu im lặng biến mất, xử lý trước thì lần thử lại trừ tiền thêm lần nữa. Và đừng dùng mẫu "kiểm tra
rồi ghi": hai request song song đều thấy "chưa có". Cách rẻ nhất và chắc nhất là để
UNIQUE constraint thực thi tính duy nhất, trong một câu lệnh duy nhất.
Đo thật: 78.774 request đồng thời cùng một key → đúng 1 bản ghi, đúng 1 mã 201, số dư trừ đúng 100, không một lỗi nào. Tắt idempotency: 14.236 bản ghi và số dư âm 423.600.
Bài 12 là nơi tất cả những điều trên trở thành điều kiện bắt buộc chứ không phải lựa chọn: message queue với ngữ nghĩa at-least-once. Khi một job có thể được giao lại bất cứ lúc nào, consumer phải idempotent — nếu không, mỗi lần redeliver là một lần side-effect nữa.
📖 Tài liệu tham khảo
-
Stripe — Idempotent requests: quy ước header
Idempotency-Keymà mục 11.2 dựa vào, kèm chính sách TTL của họ (24 giờ) - IETF RFC 9110 §9.2.2 — định nghĩa chính thức "idempotent method", nguồn của bảng ở mục 11.4
- Wikipedia — bài toán hai vị tướng, cơ sở lý thuyết cho khẳng định "exactly-once là bất khả thi" ở mục 11.1
-
PostgreSQL —
INSERT ... ON CONFLICT, cơ chế làm nên câu lệnh ở mục 11.3 -
PostgreSQL — CTE có lệnh ghi (
WITH ... INSERT/UPDATE): vì sao cả CTE chạy trong một ảnh chụp dữ liệu, điều làm cho cách viết ở 11.3 an toàn - Confluent — Kafka làm "exactly-once" thế nào: đọc để thấy chính xác phạm vi mà nó áp dụng, đúng điểm cạm bẫy ở mục 11.1
- Brandur Leach — Implementing Stripe-like Idempotency Keys in Postgres: bản mở rộng của mục 11.3 cho trường hợp nghiệp vụ không gói được vào một câu lệnh
Tải mã nguồn thực hành
Schema của lab, trong đó có bảng charges với ràng buộc
idem_key TEXT NOT NULL UNIQUE — chính ràng buộc đó, chứ không phải đoạn code nào, là thứ
thực thi tính duy nhất trong toàn bộ phép đo ở mục 11.3:
Bình luận