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ột201 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.

ℹ️ Cấu hình máy đo
Apple M1 Max, 10 lõi, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Container native arm64 (Node v22.23.2), PostgreSQL 18.3, ba app replica mỗi replica 1 CPU, nginx 1.27-alpine, bộ đo tải 2 CPU, 64 kết nối đồng thời trong 10 giây.

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.

⚠️ Cạm bẫy: tin quảng cáo "exactly-once delivery"
Vài message broker quảng cáo exactly-once. Thứ họ thật sự cung cấp là exactly-once processing trong một phạm vi hẹp: khi cả việc đọc message và việc ghi kết quả đều nằm trong hệ thống của họ, họ có thể làm hai việc đó atomic với nhau.

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.
Ba tình huống, cùng MỘT thứ client nhìn thấy: timeout 1 · Request KHÔNG tới được server C S server không biết gì · số dư KHÔNG đổi thử lại là ĐÚNG 2 · Server XỬ LÝ XONG, response bị mất trên đường về C S ĐÃ TRỪ TIỀN · số dư đã đổi thử lại ⇒ TRỪ LẦN THỨ HAI 3 · Server ĐANG xử lý, chưa xong C S đang chạy · chưa biết kết quả thử lại ⇒ xử lý SONG SONG hai lần Không có giao thức nào phân biệt được ba tình huống này từ phía client Nên đừng cố phân biệt. Hãy làm cho việc thử lại trở nên VÔ HẠI: at-least-once + xử lý idempotent. Nhìn từ ngoài, tổ hợp đó giống exactly-once — dù không bước nào trong đó là exactly-once.
Bài toán hai vị tướng nói rằng sự chắc chắn tuyệt đối là bất khả thi. Idempotency là cách làm cho sự không chắc chắn đó không còn quan trọng.

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
💡 Key là cho Ý ĐỊNH, không phải cho lần thử
Đây là chỗ dễ hiểu sai nhất, và cũng là chỗ quyết định toàn bộ cơ chế có hoạt động hay không. Nếu người dùng bấm "Thanh toán" một lần, đó là một ý định — nên chỉ có một key, dù thư viện HTTP thử lại năm lần.

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.
Cùng một lần thử lại, hai kết cục — khác nhau đúng một header client server số dư KHÔNG có idempotency key 1 · POST /charge amount=100 −100 2 · response MẤT trên đường về 3 · client THỬ LẠI (không biết lần 1 đã xong) −100 nữa Kết quả: TRỪ HAI LẦN. Đo thật ở quy mô tải: 14.236 bản ghi, số dư âm 423.600. CÓ idempotency key 1 · POST + Idempotency-Key: order-999 −100 2 · response mất — nhưng kết quả ĐÃ ĐƯỢC LƯU cùng key 3 · thử lại với CÙNG key order-999 chỉ SELECT · không chạm số dư Kết quả: trả lại CHÍNH kết quả cũ (cùng chargeId). Đo thật: 78.774 request → 1 bản ghi, 1 mã 201. Chú ý bước 3 ở nhánh dưới trả về 200 kèm body cũ, KHÔNG phải 409 — nếu trả 409, client tưởng thất bại và có thể thử lại bằng key khác.
Client không cần biết lần thử nào đã thành công. Đó chính là điểm: idempotency làm cho sự không chắc chắn trở nên không quan trọng.

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:

mẫu check-then-act (SAI — không có trong lab)
// ===== 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:

app/app.js — câu lệnh SQL của handler /charge
-- 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 chargesidem_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).

🔬 Vì sao bản có idempotency lại NHANH HƠN 5,6 lần
Điều này ban đầu trông như một lợi ích bất ngờ của idempotency, nhưng nguyên nhân thật nằm ở chỗ khác và cần nói rõ. Bản không idempotency thực hiện một INSERT và một UPDATE balancesmọ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.
⚠️ Cạm bẫy: tách dedup và nghiệp vụ thành hai transaction
Ngay cả khi dùng 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.
Ranh giới transaction — không có thứ tự nào an toàn nếu tách làm hai SAI · ghi dedup trước, nghiệp vụ sau TX A: ghi dedup ✓ TX B: trừ tiền Key đã tồn tại ⇒ 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 SAI · nghiệp vụ trước, dedup sau TX A: trừ tiền ✓ TX B: ghi dedup Tiền đã trừ nhưng không có bản ghi dedup ⇒ lần thử lại trừ THÊM lần nữa ĐÚNG · một transaction bao trọn cả hai — rẻ nhất là viết trong MỘT câu lệnh INSERT ... ON CONFLICT + UPDATE balances Cả hai thành công, hoặc cả hai không xảy ra. UNIQUE constraint là thứ thực thi tính duy nhất, không phải code. Đ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. Cách "kiểm tra rồi mới ghi" không có trong hình vì nó còn tệ hơn cả hai cách SAI ở trên: hai request song song đều thấy "chưa có". Và đừng thêm lock để vá nó — xem Bài 10 về việc distributed lock không cho đảm bảo đúng đắn.
Đây là lý do 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 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 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 + 1x = x + 2
Ví dụ không x = x + 1 (lặp lại thì cộng hai lần) SET x = 5SET 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.

⚠️ Cạm bẫy: POST /orders không kèm idempotency key
Đây là cạm bẫy phổ biến nhất của cả bài, và nó im lặng gần như hoàn hảo. Endpoint chạy đúng trong mọi lần test, chạy đúng trên mạng ổn định, và chỉ sinh đơn trùng khi mạng chập chờn — tức là với một phần nhỏ người dùng, không tái tạo được theo yêu cầu, và thường chỉ bị phát hiện khi khách hàng phàn nàn hoặc khi bộ phận kế toán đối chiếu.

Hai 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)
⚡ TTL của bảng dedup phải dài hơn chu kỳ retry của queue
Bảng dedup phải được dọn, nếu không nó lớn vô hạn. Nhưng nếu TTL của nó ngắn hơn chu kỳ redeliver của message queue thì sinh ra một lỗi rất khó truy: job bị redeliver sau khi key đã hết hạn, nên nó được xử lý lần thứ hai như một job hoàn toàn mới.

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

reproduce_measurements.sh
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

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:

Tải về 20-schema.sql

Bài viết liên quan trong series

Bài 10: Distributed Lock Bài 12: Message Queue & Xử Lý Bất Đồng Bộ Quay lại Lộ trình Thiết Kế Hệ Thống

Bình luận