Skip to content

Xử lý lỗi & idempotency ​

Trong hệ phân tán, mọi lời gọi qua mạng đều có thể thành công, thất bại, hoặc không cho biết kết quả. Trang này là trang chính về cách xử lý điều đó: timeout và deadline, retry, idempotency, “exactly-once”, circuit breaker, bulkhead, và cách phát hiện một node đã chết. Phần cài đặt idempotency key bằng database đã có ở Chống trùng & race condition, còn hàng đợi và outbox ở Hàng đợi bằng database. Số liệu trong trang là số đo thật bằng các script trong lab/xu-ly-loi/.

Ba kết quả của một lời gọi qua mạng ​

Một lời gọi trong cùng process chỉ có hai kết quả, hoặc trả về, hoặc ném lỗi. Lời gọi qua mạng có thêm kết quả thứ ba là không biết. Client gửi request rồi không nhận được gì trong thời gian chờ, và có ít nhất ba khả năng:

  • Request bị mất trên đường đi, server chưa hề nhận được.
  • Server đã nhận, đang xử lý, hoặc bị treo (GC pause, quá tải).
  • Server đã xử lý xong và đã commit, nhưng response bị mất hoặc trễ trên đường về.

Client không phân biệt được ba trường hợp này. Nếu thao tác là trừ tiền, thì gửi lại có thể trừ hai lần, còn không gửi lại thì có thể mất đơn hàng. Toàn bộ trang này xoay quanh câu hỏi làm sao gửi lại cho an toàn.

Đo thật trên MySQL: server trừ tiền và commit, rồi trả lời chậm 300 ms ở hai lần đầu; client có timeout 150 ms và gửi tối đa 3 lần (ba lượt chạy ngày 2026-10-20, kết quả giống nhau):

Client gửiCác lần gửiSố lần trừ tiền trong bảng payments
Không có idempotency keytimeout → timeout → 2003
Có idempotency keytimeout → 200 (trả lại kết quả đã lưu, 7–20 ms)1

Client thấy cùng một chuỗi lỗi ở cả hai cách, và cuối cùng đều nhận 200. Khác biệt chỉ nằm ở chỗ server có nhận ra request gửi lại hay không.

Kịch bản
Bước 1/6

Client gửi POST /charge 100.000 đ, không kèm idempotency key.

Client
#1 100.000 đ · không key → đang gửi
Transaction trên serverchưa có
Bảng payments
trống
Bảng idempotency_keys
trống

Dòng viền đứt là dòng chưa commit, transaction khác chưa thấy được. Kết quả của các kịch bản khớp với số đo trong lab/xu-ly-loi/idempotency.mjs và design-b.mjs.

Timeout và deadline ​

Mọi lời gọi qua mạng đều cần timeout, vì không có timeout thì một dependency bị treo sẽ giữ request, connection và bộ nhớ của mình mãi mãi. Nhiều thư viện mặc định không có timeout, hoặc có timeout rất dài, nên phải tự đặt.

Chọn timeout theo latency thật của dependency. Hướng dẫn của AWS là chọn trước một tỉ lệ timeout nhầm chấp nhận được, ví dụ 0,1%, rồi đặt timeout bằng p99,9 latency của dependency (Latency). Timeout ngắn quá thì request bình thường cũng bị cắt, còn dài quá thì lúc dependency treo, mỗi request phải chờ lâu (Circuit breaker đo chuyện này). AWS cũng kể một lỗi hay gặp: timeout 20 ms bị vượt sau mỗi lần deploy, vì thời gian mở connection mới cũng bị tính vào timeout. Họ sửa bằng cách mở sẵn connection lúc khởi động.

Deadline là timeout cho cả request của người dùng chứ không cho từng lời gọi. Google SRE khuyên truyền deadline xuống các lời gọi bên dưới: mỗi tầng chỉ được dùng phần thời gian còn lại, không tự đặt timeout mới. Server cũng nên kiểm tra còn thời gian trước khi bắt đầu một việc nặng, vì làm xong sau deadline thì client đã bỏ đi rồi. Trong Node.js dùng AbortSignal:

js
async function getProfile(req, res) {
  const deadline = AbortSignal.timeout(2000) // cả request chỉ có 2 giây
  // Mỗi lời gọi có timeout riêng, nhưng không được vượt deadline chung
  const user = await fetch(USER_API, { signal: AbortSignal.any([deadline, AbortSignal.timeout(500)]) })
  const orders = await fetch(ORDER_API, { signal: deadline }) // dùng phần thời gian còn lại
  res.json({ user: await user.json(), orders: await orders.json() })
}

AbortSignal chỉ có tác dụng trong một process. Muốn truyền deadline sang service khác qua HTTP thì phải gửi phần thời gian còn lại trong một header, và service bên kia đọc header đó để đặt deadline của mình. gRPC làm sẵn việc này bằng header grpc-timeout; giao thức của gRPC quy định khi không có header này thì server coi như không có timeout.

Retry ​

Retry chỉ có ích với lỗi tạm thời: timeout, lỗi mạng, 408 Request Timeout, 429 Too Many Requests, 502, 503 Service Unavailable, 504. Lỗi do chính request (400, 401, 404, 422) thì gửi lại bao nhiêu lần cũng vậy. Server trả 429 hay 503 kèm header Retry-After thì nên chờ đúng khoảng đó. Ngoài ra, retry chỉ an toàn khi thao tác idempotent (mục sau).

Backoff và jitter ​

Khi một server sập rồi hồi lại, mọi client bị lỗi cùng lúc. Nếu tất cả retry theo cùng một lịch, tải sẽ đến thành từng đợt dồn dập ngay lúc server yếu nhất. Có ba cách chờ hay gặp:

  • Exponential backoff: chờ base × 2ⁿ sau lần thất bại thứ n, có giới hạn trên (cap). Khoảng chờ dài dần nên tổng tải giảm, nhưng mọi client vẫn chờ y hệt nhau nên vẫn đến cùng lúc.
  • Full jitter: chờ một khoảng ngẫu nhiên từ 0 tới min(cap, base × 2ⁿ). Các lần retry trải đều theo thời gian.
  • Decorrelated jitter: chờ ngẫu nhiên từ base tới ba lần khoảng chờ trước, có giới hạn cap.

Marc Brooker (AWS) mô phỏng nhiều client cùng tranh một tài nguyên và thấy cả hai cách có jitter đều giảm tổng số lời gọi rất nhiều so với exponential không jitter. Full jitter tốn ít lời gọi nhất, decorrelated jitter xong nhanh hơn một chút. AWS còn thêm jitter cho mọi timer, job định kỳ và việc hẹn giờ, vì client chạy mỗi phút hay mỗi đêm cũng có xu hướng dồn vào cùng vài giây đầu.

Đo thật: 500 client cùng gửi lúc server vừa sập 1 giây, sau đó server chỉ xử lý được 300 request/giây; mỗi client retry tối đa 10 lần (hai lượt):

Chiến lượcTổng requestClient bỏ cuộc50% client xong99% client xongTải đỉnh sau khi hồi (request/100 ms)
Retry ngay5.000500không đạtkhông đạt0 (đã bỏ cuộc hết lúc server còn sập)
Chờ cố định 500 ms3.824–3.878179–1984,1 skhông đạt449–500
Exponential (100 ms × 2ⁿ, tối đa 3,2 s)4.349–4.419255–277không đạtkhông đạt500
Exponential + full jitter3.046–3.07401,7 s4,1–4,2 s70–87

Năng lực của server là 30 request mỗi 100 ms. Không có jitter thì mỗi đợt retry mang tới 500 request, gấp 16 lần năng lực, nên mỗi đợt chỉ khoảng 30 client thành công và số còn lại hẹn nhau vào đợt sau. Full jitter giữ tải sau khi hồi chỉ gấp 2–3 lần năng lực, mọi client đều xong, và tổng số request ít nhất. Retry ngay thì tệ theo kiểu khác: 10 lần thử của mỗi client nằm gọn trong lúc server còn sập.

Chiến lược retry
4.550tổng request
320client bỏ cuộc
không đạt50% client xong
không đạt99% client xong

Mô hình bước 10 ms, mỗi client tối đa 10 lần; exponential bắt đầu 100 ms, nhân đôi, tối đa 3,2 s. Số đo thật với 500 client, sập 1 s, 300 request/giây ở bảng bên dưới.

0263525năng lực server0 s1 s2 s3 s4 s5 s6 srequest mỗi 100 ms
request bị 503request thành cônglúc server sập

Thời gian chờ dài dần, nhưng mọi client vẫn chờ giống hệt nhau, nên tải vẫn đến thành đợt và mỗi đợt vượt năng lực server. Các đợt thưa dần nên tổng request giảm, nhưng xong rất chậm, và có client bỏ cuộc.

Retry nhân lên qua nhiều tầng ​

Mỗi tầng tự retry thì số lần gọi nhân lên theo cấp số nhân. Google SRE lấy ví dụ 4 tầng, mỗi tầng thử 3 lần: một thao tác của người dùng có thể thành 3⁴ = 64 lần gọi vào database. AWS lấy ví dụ 5 tầng mỗi tầng 3 lần, thành 3⁵ = 243 lần, và nhận xét rằng lúc đó database gần như không thể hồi lại được. Có ba cách chặn:

  • Chỉ retry ở một tầng, thường là tầng gần người dùng nhất hoặc tầng gọi trực tiếp dependency, không phải mọi tầng.
  • Giới hạn số lần cho mỗi request, ví dụ 3–4 lần.
  • Ngân sách retry cho cả process. Google SRE gợi ý ví dụ chỉ cho 60 lần retry mỗi phút trong một process, vượt thì trả lỗi luôn. AWS SDK dùng token bucket: còn token thì được retry, hết token thì thôi. Server quá tải nên trả một mã lỗi riêng để client biết là đừng retry.

Một hàm retry đủ dùng trong Node.js. Khi viết hàm này với fetch có sẵn trong Node, cần biết hai điều (đã chạy thử trên Node 24):

  • fetch không ném lỗi khi server trả 503, mà trả về response có res.ok === false. Vì vậy fn phải tự ném lỗi kèm status và header Retry-After.
  • Lỗi mạng của fetch luôn là TypeError: fetch failed, còn mã lỗi thật nằm ở err.cause.code. Đo được ECONNREFUSED khi cổng đóng, ENOTFOUND khi tên miền không tồn tại, và UND_ERR_SOCKET khi server đóng socket giữa chừng. Timeout bằng AbortSignal.timeout thì có err.name === 'TimeoutError'.
js
import { setTimeout as sleep } from 'node:timers/promises'

const RETRYABLE_STATUS = new Set([408, 429, 502, 503, 504])
const RETRYABLE_CODE = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_SOCKET'])

// Ngân sách retry riêng cho từng dependency: mỗi lần thành công nạp 0,1 token, mỗi lần retry tốn 1 token,
// nên retry không vượt quá khoảng 10% số request khi dependency lỗi kéo dài. Dùng chung một ngân sách
// cho cả process thì một dependency hỏng sẽ tiêu hết lượt retry của các dependency khác.
const budgets = new Map()
const budget = (name) => budgets.get(name) ?? budgets.set(name, { tokens: 10 }).get(name)

// fn phải ném lỗi có .status (và .retryAfter, tính bằng giây) khi response không ok
async function withRetry(name, fn, { attempts = 4, base = 100, cap = 2000, signal } = {}) {
  const b = budget(name)
  for (let n = 0; ; n++) {
    try {
      const result = await fn()
      b.tokens = Math.min(10, b.tokens + 0.1)
      return result
    } catch (err) {
      const code = err.cause?.code ?? err.code
      const retryable = err.name === 'TimeoutError' || RETRYABLE_CODE.has(code) || RETRYABLE_STATUS.has(err.status)
      if (!retryable || n + 1 >= attempts || b.tokens < 1) throw err
      b.tokens -= 1
      const backoff = Math.random() * Math.min(cap, base * 2 ** n) // full jitter
      const wait = err.retryAfter ? Math.max(backoff, err.retryAfter * 1000) : backoff // tôn trọng Retry-After
      await sleep(wait, undefined, { signal })
    }
  }
}

// Ví dụ fn bọc fetch
const getUser = (id, signal) => withRetry('user-api', async () => {
  const res = await fetch(`${USER_API}/users/${id}`, { signal: AbortSignal.any([signal, AbortSignal.timeout(500)].filter(Boolean)) })
  if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { status: res.status, retryAfter: Number(res.headers.get('retry-after')) || 0 })
  return res.json()
}, { signal })

Retry-After cũng có thể là một mốc thời gian dạng HTTP date; ví dụ trên chỉ xử lý dạng số giây.

Idempotency ​

RFC 9110 định nghĩa một method là idempotent nếu tác động mong muốn lên server của nhiều request giống nhau bằng với tác động của một request. GET, HEAD, OPTIONS, TRACE, PUT, DELETE là idempotent; POST và PATCH thì không. RFC cũng nói rõ hai điều:

  • Tính idempotent chỉ xét phần người dùng yêu cầu. Server vẫn được ghi log cho từng request, hay giữ lịch sử thay đổi.
  • Client không nên tự động retry một method không idempotent, trừ khi có cách biết request đó thực ra an toàn, hoặc biết chắc request gốc chưa được áp dụng.

Idempotent tự nhiên ​

Nhiều thao tác có thể viết sao cho tự nó idempotent, không cần thêm gì:

Không idempotentViết lại cho idempotent
UPDATE accounts SET balance = balance - 100Ghi một dòng vào sổ cái với mã giao dịch UNIQUE (Sổ cái)
INSERT INTO follows (a, b)INSERT … ON DUPLICATE KEY UPDATE, hoặc UNIQUE (a, b) và bỏ qua lỗi trùng
UPDATE orders SET status = 'shipped' rồi gửi emailGhi có điều kiện WHERE status = 'paid', chỉ gửi email khi affectedRows = 1
POST /orders tạo đơn mớiPUT /orders/{id} với id do client sinh (Sinh ID)
Gửi email xác nhậnLưu email_sent_at, bỏ qua nếu đã gửi

Ghi có điều kiện theo trạng thái hay theo version là cách hay dùng nhất (Chống trùng). Còn những thao tác không viết lại được, như trừ tiền qua cổng thanh toán, thì cần idempotency key.

Idempotency key ​

Client sinh một key ngẫu nhiên (UUID) một lần cho mỗi thao tác, ví dụ lúc người dùng mở màn hình thanh toán, chứ không phải mỗi lần gửi. Mọi lần gửi lại đều mang đúng key đó. Server lưu key cùng kết quả, và lần sau gặp lại key thì trả lại đúng kết quả cũ thay vì làm lại.

Những chi tiết mà Stripe và bản nháp chuẩn IETF (Idempotency-Key, bản 07, đã hết hạn nhưng vẫn là tham khảo tốt) đều có:

  • Lưu cả nội dung request, hoặc mã băm của nó. Cùng key mà khác nội dung thì từ chối với 422, thay vì trả nhầm kết quả của request khác. Trước khi băm phải chuẩn hoá nội dung (sắp xếp key của JSON), vì JSON.stringify giữ nguyên thứ tự key: cùng nội dung mà client gửi các field theo thứ tự khác sẽ bị trả nhầm 422.
  • Request cùng key đang được xử lý thì trả 409. Bản nháp IETF còn dùng 400 khi endpoint bắt buộc có key mà client không gửi.
  • Lưu kết quả cả khi lỗi. Stripe (API v1) lưu status code và body của lần đầu kể cả lỗi 500, sau khi endpoint đã bắt đầu chạy. Request bị loại từ bước kiểm tra tham số, hay đụng một request khác đang chạy, thì không được lưu và có thể gửi lại.
  • Key có hạn. Stripe cho xoá key sau ít nhất 24 giờ; gửi lại key đã bị xoá thì được coi là request mới. Key dài tối đa 255 ký tự, và không nên dùng dữ liệu cá nhân như email làm key.
  • Phạm vi của key nên là từng tài khoản, tức khoá chính là (user_id, idem_key), để key của người này không đụng key của người khác.

Hai cách lưu key ​

Cách A: key và việc chính trong một transactionCách B: ghi key “đang xử lý” trước
Làm thế nàoINSERT key, làm việc, lưu kết quả vào key, rồi commit một lầnINSERT key với trạng thái processing và commit ngay; làm việc; lưu kết quả và commit
Dùng khiMọi việc đều nằm trong cùng databaseViệc chính có gọi ra ngoài (cổng thanh toán, gửi email, service khác)
Request trùng đến khi request đầu chưa xongĐứng chờ lock trên dòng key, rồi nhận kết quả đã lưuNhận 409 ngay
Server crash giữa chừngTransaction rollback, key biến mất, gửi lại chạy từ đầuKey kẹt ở processing, cần cơ chế hết hạn và làm lại

Đo thật với hai request cùng key gửi cách nhau 20 ms, việc chính mất 200 ms (ba lượt):

CáchRequest 1Request 2Số lần trừ tiền
A200, 214–223 mschờ 196–202 ms, rồi 200 với kết quả đã lưu1
B200, 219–230 ms409 sau 3–8 ms1
A và B, cùng key nhưng số tiền khác2004221

Với cách A, request thứ hai bị chặn đúng bằng thời gian request đầu giữ transaction, vì InnoDB bắt mọi lệnh INSERT trùng khoá phải chờ transaction đang giữ dòng đó commit hay rollback. Cách này đơn giản và an toàn, nhưng không dùng được khi việc chính có gọi ra ngoài: không thể giữ transaction mở trong lúc chờ cổng thanh toán (Giữ transaction ngắn). Code của cách A có ở trang Chống trùng.

Cách B cần thêm bốn thứ:

  1. Cách lấy lại key kẹt ở processing khi server crash: một cột hạn locked_until giống lease của hàng đợi bằng database. Request gửi lại sau khi quá hạn thì được lấy lại key bằng một lệnh ghi có điều kiện, nên chỉ một request lấy được.
  2. Truyền key xuống cổng thanh toán, để lần chạy lại không trừ tiền lần hai.
  3. Fencing cho bước cuối. Lấy lại key quá hạn có cùng vấn đề với lock hết hạn: request cũ có thể chỉ bị treo chứ chưa chết, rồi chạy tiếp song song với request mới. Cổng thanh toán chỉ trừ tiền một lần và trả cùng một charge_id cho cả hai, nhưng cả hai request sẽ cùng ghi vào bảng payments của mình. Chặn bằng cột lease tăng lên mỗi lần lấy lại key; bước cuối chỉ ghi khi lease vẫn là của mình. Thêm UNIQUE (charge_id) làm chốt chặn cuối.
  4. Xử lý lỗi từ cổng thanh toán. Lỗi đã có kết quả chắc chắn (thẻ bị từ chối, 402) thì lưu thành done cùng response lỗi, đúng như nguyên tắc lưu kết quả cả khi lỗi. Lỗi tạm thời (timeout, 503) thì nhả key (locked_until = NOW()), để client gửi lại được ngay thay vì nhận 409 suốt 5 phút.
sql
CREATE TABLE idempotency_keys (
  user_id       BIGINT       NOT NULL,
  idem_key      VARCHAR(255) NOT NULL,
  request_hash  CHAR(64)     NOT NULL,
  status        VARCHAR(16)  NOT NULL,   -- processing | done
  lease         BIGINT       NOT NULL DEFAULT 1,  -- tăng mỗi lần lấy lại key, dùng làm fencing token
  response_code INT          NULL,
  response_body JSON         NULL,
  locked_until  TIMESTAMP(3) NOT NULL,   -- processing quá hạn này thì coi như hỏng, cho làm lại
  created_at    TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (user_id, idem_key)
);
CREATE TABLE payments (
  id        BIGINT AUTO_INCREMENT PRIMARY KEY,
  user_id   BIGINT      NOT NULL,
  charge_id VARCHAR(64) NOT NULL,
  amount    BIGINT      NOT NULL,
  UNIQUE KEY uq_charge (charge_id)       -- chốt chặn cuối: một charge chỉ ghi một lần
);
js
// Rút gọn: db.execute trả [result], db.transaction mở transaction trên một connection;
// sha256, canonicalJson (JSON với key đã sắp xếp) và httpError là hàm tự viết.
async function pay(userId, key, body) {
  const hash = sha256(canonicalJson(body))
  let lease = 1
  try {
    await db.execute(
      `INSERT INTO idempotency_keys (user_id, idem_key, request_hash, status, locked_until)
       VALUES (?, ?, ?, 'processing', NOW(3) + INTERVAL 5 MINUTE)`, [userId, key, hash])
  } catch (err) {
    if (err.code !== 'ER_DUP_ENTRY') throw err
    const [[row]] = await db.execute('SELECT * FROM idempotency_keys WHERE user_id = ? AND idem_key = ?', [userId, key])
    if (row.request_hash !== hash) throw httpError(422, 'Key đã dùng cho một request khác')
    if (row.status === 'done') return [row.response_code, row.response_body]
    // Đang processing: chỉ lấy lại được khi đã quá hạn; lease mới lấy bằng LAST_INSERT_ID(lease + 1)
    const [r] = await db.execute(
      `UPDATE idempotency_keys SET locked_until = NOW(3) + INTERVAL 5 MINUTE, lease = LAST_INSERT_ID(lease + 1)
       WHERE user_id = ? AND idem_key = ? AND status = 'processing' AND locked_until < NOW(3)`, [userId, key])
    if (r.affectedRows === 0) throw httpError(409, 'Request với key này đang được xử lý')
    lease = r.insertId
  }

  let charge
  try {
    // Truyền key xuống: chạy lại thì cổng thanh toán trả lần trừ tiền cũ, không trừ lần hai
    charge = await gateway.charge(body, { idempotencyKey: `${userId}:${key}` })
  } catch (err) {
    if (err.final) {
      // Kết quả chắc chắn (thẻ bị từ chối): lưu lại như mọi kết quả khác
      await finish(userId, key, lease, err.status, { error: err.message })
      return [err.status, { error: err.message }]
    }
    // Lỗi tạm thời: nhả key để client gửi lại được ngay
    await db.execute(
      `UPDATE idempotency_keys SET locked_until = NOW(3)
       WHERE user_id = ? AND idem_key = ? AND status = 'processing' AND lease = ?`, [userId, key, lease])
    throw err
  }
  // INSERT IGNORE: webhook của cổng thanh toán có thể đã ghi charge này trước (xem bài toán cuối trang)
  await finish(userId, key, lease, 200, charge, (tx) =>
    tx.execute('INSERT IGNORE INTO payments (user_id, charge_id, amount) VALUES (?, ?, ?)', [userId, charge.id, body.amount]))
  return [200, charge]
}

// Bước cuối có fencing: chỉ hoàn tất khi key vẫn processing và lease vẫn là của mình
async function finish(userId, key, lease, code, response, work) {
  await db.transaction(async (tx) => {
    const [u] = await tx.execute(
      `UPDATE idempotency_keys SET status = 'done', response_code = ?, response_body = ?
       WHERE user_id = ? AND idem_key = ? AND status = 'processing' AND lease = ?`,
      [code, JSON.stringify(response), userId, key, lease])
    if (u.affectedRows === 0) throw httpError(409, 'Key đã được request khác lấy lại') // transaction rollback
    if (work) await work(tx)
  })
}

Đã dựng lại đúng thứ tự xảy ra trên MySQL 8.0. Request A lấy key (hạn 1 giây), gọi cổng thanh toán, rồi bị treo 1,5 giây. Lúc 1,2 giây client gửi lại, request B lấy lại key quá hạn và gọi cổng thanh toán. Mỗi dòng thử với hai thứ tự, hai lượt, kết quả giống nhau:

Cách viết bước cuốiB xong trước khi A tỉnhA tỉnh khi B còn chờ cổng thanh toán
Không có fencing, không UNIQUE (code ban đầu của trang)2 dòng payments2 dòng payments
Thêm UNIQUE (charge_id)1 dòng, A bị lỗi trùng khoá và rollback1 dòng, nhưng A (request cũ) thắng, B rollback
Fencing bằng lease1 dòng, A rollback1 dòng, A rollback

Ở cả sáu lần, cổng thanh toán chỉ trừ tiền một lần nhờ key được truyền xuống. Lỗi nằm hoàn toàn ở database của mình. Riêng UNIQUE thì chặn được dòng trùng nhưng không chọn đúng request thắng, nên dùng fencing bằng lease, còn UNIQUE làm chốt chặn cuối. Phần xử lý lỗi cũng đã chạy thử: thẻ bị từ chối được lưu thành done, gửi lại nhận đúng 402; lỗi 503 mà không nhả key thì gửi lại nhận 409, nhả key thì lấy lại được ngay.

Lấy lại key và job đối soát là hai việc khác nhau. Lấy lại key xảy ra khi client gửi lại sau khi quá hạn. Còn nếu client không bao giờ gửi lại, ví dụ người dùng đóng app, thì key nằm ở processing mãi và đơn hàng không biết đã thanh toán hay chưa. Job đối soát chạy định kỳ, tìm các key quá hạn, lấy lại key giống như một request gửi lại (tăng lease), hỏi cổng thanh toán xem giao dịch mang key đó đã thành công chưa, rồi ghi kết quả bằng finish. Hai việc dùng chung một luật: ai muốn hoàn tất key phải đang giữ lease mới nhất.

Chạy từng bước các trường hợp trên. Dòng viền đứt trong bảng là dòng chưa commit:

Kịch bản
Bước 1/7

Người dùng bấm Thanh toán.

Client
#1 100.000 đ · key k-7f3a → đang gửi
Transaction trên serverchưa có
Bảng payments
trống
Bảng idempotency_keys
trống

Dòng viền đứt là dòng chưa commit, transaction khác chưa thấy được. Kết quả của các kịch bản khớp với số đo trong lab/xu-ly-loi/idempotency.mjs và design-b.mjs.

“Exactly-once” thực chất là gì ​

Có ba mức đảm bảo khi gửi một message hay một request:

MứcCách làmHệ quả
At-most-onceGửi một lần, không retryCó thể mất, không bao giờ trùng
At-least-onceRetry tới khi được xác nhậnKhông mất, nhưng có thể trùng
Exactly-once (chính xác hơn là effectively-once)At-least-once cộng với phía nhận idempotentCó thể nhận nhiều lần, nhưng tác động chỉ một lần

Vì có kết quả “không biết”, bên gửi không thể đảm bảo một message được chuyển tới đúng một lần. Thứ đạt được là message được xử lý với tác động như một lần, nhờ phía nhận bỏ qua bản trùng. Hàng đợi bằng database, outbox relay, Kafka hay RabbitMQ đều chỉ đảm bảo at-least-once khi giao cho consumer, nên consumer phải ghi lại id các message đã xử lý trong cùng transaction với việc xử lý (Hàng đợi bằng database, Chống trùng). “Exactly-once” của Kafka (idempotent producer cộng transaction) chỉ có hiệu lực khi đọc từ Kafka và ghi lại vào Kafka. Ghi ra một database bên ngoài thì vẫn cần consumer idempotent.

Circuit breaker ​

Khi dependency bị treo, mỗi request gọi nó phải chờ tới timeout. Trong lúc chờ, request giữ connection, bộ nhớ và chỗ trong pool, còn retry lại làm dependency càng thêm tải. Lỗi của một dependency lan thành lỗi của cả service, rồi của các service gọi tới nó (cascading failure).

Circuit breaker, theo cách Martin Fowler mô tả (Michael Nygard đưa ra trong sách Release It!), bọc lời gọi và có ba trạng thái:

  • Closed: cho lời gọi đi qua và đếm lỗi. Thành công thì đếm lại từ đầu.
  • Open: lỗi vượt ngưỡng (ví dụ 5 lần liên tiếp, hay 50% trong một khoảng) thì breaker mở, và mọi lời gọi bị từ chối ngay mà không đụng tới dependency.
  • Half-open: sau một khoảng thời gian, breaker cho đúng một lời gọi thử. Thành công thì đóng lại, thất bại thì mở tiếp.

Đo thật: service nhận 50 request/giây, gọi dependency với timeout 1 giây; dependency bị treo từ giây 2 tới giây 6 (hai lượt):

Latency lúc treo: p50p99Request tới dependency (10 s)Request đang chờ cùng lúc (max)Request đầu tiên thành công sau khi hết treo
Gọi thẳng1.001 ms1.003 ms500516,00–6,02 s
Có circuit breaker0 ms1.002 ms300–301517,10–7,14 s

Breaker mở lúc 3,08 s, sang half-open lúc 5,08–5,12 s, mở lại lúc 6,09–6,12 s, rồi đóng lúc 7,11–7,15 s. Bảng cho thấy cả lợi ích lẫn giới hạn của nó:

  • Lỗi trả ngay thay vì sau 1 giây, nên lúc dependency treo, người dùng nhận lỗi nhanh và service không giữ tài nguyên.
  • Dependency bị gọi ít hơn (300 so với 500 request), nên nó có cơ hội hồi lại.
  • Breaker chỉ phản ứng sau timeout đầu tiên. Trong giây đầu dependency treo, 50 request đã gửi đi và đứng chờ, nên số request đang chờ cùng lúc vẫn lên 51. Timeout càng dài thì khoảng mù này càng dài, nên breaker không thay được một timeout hợp lý. Một số thư viện rút ngắn khoảng mù bằng cách đếm cả lời gọi chậm: Resilience4j coi lời gọi lâu hơn slowCallDurationThreshold là chậm, và mở breaker khi tỉ lệ lời gọi chậm đạt slowCallRateThreshold, không cần chờ lời gọi đó hết timeout. Mặc định của Resilience4j là mở khi 50% lời gọi lỗi trong 100 lời gọi gần nhất; còn ngưỡng chậm mặc định là 60 giây và 100%, nên muốn dùng thì phải tự đặt.
  • Phục hồi chậm hơn khoảng 1,1 giây. Request thử lúc half-open được gửi khi dependency còn treo, nên nó phải chờ hết timeout rồi lỗi, và breaker mở thêm một lượt. Trong code của lab, mỗi lỗi của các request gửi từ trước cũng làm thời gian mở tính lại từ đầu, nên lần half-open đầu tiên lùi tới 5,1 s.

Bấm Chạy rồi đổi trạng thái của dependency bất cứ lúc nào:

t = 0,0 s
Dependency
  1. closed (đóng, cho request đi qua)
  2. open (mở, từ chối ngay)
  3. half-open (cho một request thử)
0request đang chờ dependency
0request/giây tới dependency
0 msp50 latency, 2 giây gần nhất
100%thành công, 2 giây gần nhất
dependencybreakerthành cônglỗi ngaylỗi do timeoutbreaker từ chối0 s
closed / thành côngopen / chờ tới timeouthalf-open / lỗi ngaybị từ chối, không gọi dependency

Bấm Chạy, rồi bấm Treo hoặc Lỗi ngay bất cứ lúc nào. 50 request/giây; mỗi vạch đỏ là một request đang chờ tới timeout. Kịch bản như lab dùng cùng thông số với lab/xu-ly-loi/circuit-breaker.mjs.

AWS cũng nêu một nhược điểm: circuit breaker đưa vào hành vi theo chế độ (mode), khó test và có thể làm hệ thống hồi phục chậm hơn. Vì vậy AWS ưu tiên giới hạn retry bằng token bucket ở phía client. Hai cách không loại trừ nhau. Breaker có ích nhất với dependency hay bị treo hẳn, còn token bucket ngăn retry tự nhân lên.

Khi breaker mở, cần có phương án dự phòng (fallback): trả dữ liệu cũ từ cache, ẩn tính năng phụ (gợi ý sản phẩm, đếm lượt xem), hay xếp việc vào hàng đợi để làm sau. Trạng thái của breaker nên được ghi log và đưa lên dashboard. Trong Node.js có thư viện opossum cài sẵn circuit breaker.

Bulkhead và giới hạn tải ​

Bulkhead (vách ngăn chống nước của tàu) là chia tài nguyên theo từng dependency, để một dependency hỏng không ăn hết tài nguyên dùng chung. Thứ bị dùng chung khác nhau tuỳ nền tảng:

  • Server Java kiểu Tomcat: thread xử lý request. Service gợi ý sản phẩm bị treo thì mọi thread đều đứng chờ nó, và request thanh toán không còn thread nào để chạy.
  • Node.js: client HTTP vốn đã có pool connection riêng cho từng origin, nên cổng thanh toán và service gợi ý không tranh connection của nhau. Thứ dùng chung là bộ nhớ, số request đang dở dang, và pool connection database. Nếu handler mượn một connection database (hay mở transaction) rồi mới gọi service gợi ý, thì service gợi ý treo sẽ giữ hết pool database, và mọi tính năng khác cũng chết theo.

Có ba cách chia:

  • Giới hạn số lời gọi đang chạy tới từng dependency, vượt giới hạn thì lỗi ngay. Trong Node.js là một semaphore nhỏ hay thư viện p-limit cho mỗi dependency.
  • Không giữ connection database trong lúc gọi ra ngoài, hoặc tách pool database theo loại việc (pool cho thanh toán, pool cho báo cáo).
  • Giới hạn số request đang chạy theo từng route, để một route bị treo không làm cạn bộ nhớ của cả process.

Khi chính service của mình quá tải, cách chặn là từ chối sớm một phần request (load shedding) và tạm làm ít việc hơn (graceful degradation), như trang Latency đã nói. Google SRE nhắc thêm rằng đường code dự phòng ít khi chạy thì thường hỏng đúng lúc cần, nên phải chạy thử nó định kỳ.

Phát hiện một node đã chết ​

Cách đơn giản là heartbeat với timeout cố định: mỗi node gửi tín hiệu đều đặn, quá X giây không thấy thì coi như chết. Cái khó là chọn X. Ngắn quá thì một lần GC pause hay mạng chậm cũng bị coi là chết, và hệ thống có thể bầu leader mới trong khi leader cũ vẫn sống (xem fencing token). Dài quá thì node chết thật lâu mới bị phát hiện. Qua mạng không có cách nào phân biệt chắc chắn một node đã chết với một node chỉ đang chậm.

Phi accrual failure detector (Hayashibara và cộng sự, 2004) không trả lời có hay không, mà tính một mức nghi ngờ phi từ lịch sử khoảng cách giữa các heartbeat. Akka dùng công thức phi = −log10(1 − F(t)), trong đó F là phân phối chuẩn ước lượng từ các khoảng cách gần đây và t là thời gian kể từ heartbeat cuối. phi = 8 nghĩa là theo mô hình, khả năng heartbeat chỉ đang đến muộn còn 10⁻⁸. Akka gửi heartbeat mỗi giây, ngưỡng mặc định 8, và gợi ý ngưỡng khoảng 12 trên môi trường cloud như EC2. Akka cũng có tham số acceptable-heartbeat-pause để chịu được GC pause và mạng chập chờn. Đọc mã nguồn của Akka thì thấy tham số này được cộng thẳng vào trung bình trước khi tính phi, và độ lệch chuẩn không bao giờ nhỏ hơn min-std-deviation. Với mặc định của Akka cluster (heartbeat 1 s, pause cho phép 3 s, độ lệch chuẩn tối thiểu 100 ms, ngưỡng 8), detector nghi node chết sau khoảng 4,5 giây không có heartbeat. Ưu điểm so với timeout cố định là detector tự thích nghi: mạng dao động nhiều thì nó chờ lâu hơn trước khi nghi.

Mô phỏng dưới tính phi bằng phân phối chuẩn chính xác. Mã nguồn Akka dùng một công thức xấp xỉ logistic của phân phối chuẩn, nên kết quả lệch đôi chút. Bấm nút mặc định của Akka để thấy ảnh hưởng của acceptable-heartbeat-pause:

Thử: phi accrual failure detector
6,5phi hiện tại
Chưa nghi
04812160 s1 s2 s3 s4 s5 s6 sphingưỡngtimeout cố định
phi theo thời gian chờngưỡng nghi chếttimeout cố định

Heartbeat đều đặn mỗi 1 giây, lệch chuẩn 100 ms. Với ngưỡng phi = 8, detector nghi node chết sau khoảng 1,56 giây không có heartbeat, sớm hơn timeout cố định 3 giây. Mạng càng dao động thì đường cong càng thoai thoải và detector tự chờ lâu hơn trước khi nghi; timeout cố định thì không biết điều đó.

Membership: gossip và SWIM ​

Phần trên là một node theo dõi một node. Một cluster vài chục tới vài nghìn node (agent của Consul, node của Cassandra, Redis Cluster, Akka cluster) cần nhiều hơn thế: mỗi node phải biết những node nào đang trong cluster, ai mới vào, ai đã chết. Bài toán này gọi là membership.

Cách thẳng nhất là mỗi node gửi heartbeat cho mọi node khác. Mỗi chu kỳ cả cluster gửi n(n − 1) gói, nên tải trên mỗi node tăng theo số node. Trên lab, với chu kỳ 200 ms, mỗi node gửi khoảng 41 gói mỗi giây khi cluster có 10 node, 131 gói khi có 30 node, và 265 gói khi có 60 node. Dồn việc theo dõi về một node trung tâm thì node đó thành điểm nghẽn và điểm chết duy nhất.

Gossip ​

Gossip là cách lan tin giống lây bệnh: mỗi chu kỳ, mỗi node trao đổi những gì nó biết với một vài node chọn ngẫu nhiên. Node vừa nhận tin lại kể tiếp ở chu kỳ sau, nên số node biết tin tăng gần gấp đôi mỗi chu kỳ, và cả cluster biết sau khoảng log n chu kỳ. Tin vẫn lan dù vài gói bị mất hay vài node chết, vì có nhiều đường khác nhau. Cassandra làm đúng như vậy: theo tài liệu, mỗi giây mỗi node tăng bộ đếm heartbeat của chính mình rồi trao đổi trạng thái với một node ngẫu nhiên. Redis Cluster cũng dùng gossip giữa các node (Redis › Gossip).

SWIM ​

SWIM (Das, Gupta và Motivala, 2002) tách membership làm hai việc, và làm cả hai với tải cố định trên mỗi node:

  1. Phát hiện node chết. Mỗi chu kỳ, mỗi node ping một node khác. Node được chọn lần lượt theo danh sách đã xáo trộn, hết lượt thì xáo lại, nên mọi node đều được kiểm tra trong một số chu kỳ có giới hạn. Không có ack trong thời gian chờ ngắn (ngắn hơn chu kỳ) thì node gửi ping-req nhờ k node khác ping hộ. Hết chu kỳ mà không có ack nào, trực tiếp hay qua người khác, thì node đó bị coi là chết. Bài báo chỉ ra rằng thời gian trung bình để phát hiện, tỉ lệ báo nhầm và số gói mỗi node gửi đều không phụ thuộc số node. Trên lab, mỗi node gửi khoảng 9 gói mỗi giây dù cluster có 10, 30 hay 60 node.
  2. Lan tin. Tin "node X đã chết", "node Y mới vào" không được gửi bằng gói riêng, mà gắn kèm (piggyback) vào chính các gói ping, ping-req, ack đang được gửi. Mỗi tin được gắn kèm vài lần (khoảng λ·log n lần) rồi bỏ, và lan đi theo kiểu gossip.

Vì sao cần ping-req. Ping không có ack có thể vì node kia chết, nhưng cũng có thể chỉ vì một gói bị mất trên đường giữa hai node. Nhờ k node khác ping hộ thì có thêm k đường khác nhau tới node đó, và mất gói trên một đường không đủ để kết tội. Trên lab, 30 node, mất 5% gói: bỏ ping-req thì có khoảng 800 lần báo nhầm, và mọi node còn sống đều bị coi là chết ít nhất một lần. Có ping-req với k = 3 thì 3 trong 4 lượt chạy không báo nhầm lần nào. Lượt còn lại có một node bị báo nhầm, và tin đó lan tới cả 28 node khác: probe gián tiếp làm báo nhầm hiếm đi chứ không loại bỏ được. Heartbeat tới mọi node, với timeout 3 chu kỳ, báo nhầm 2–9 lần trong cùng điều kiện.

Suspect trước khi kết luận. SWIM gốc tuyên bố chết ngay khi một lần probe thất bại. Bản mở rộng trong bài báo thêm một bước: trước hết chỉ đánh dấu node là suspect và lan tin nghi ngờ. Node bị nghi nếu còn sống sẽ nhận được tin đó (qua gói gắn kèm) và tự cải chính bằng tin "alive" kèm incarnation number tăng lên. Chỉ chính node đó được tăng số này, nên tin cải chính luôn thắng tin nghi ngờ cũ hơn. Hết thời gian nghi mà không ai cải chính thì node mới bị tuyên bố chết (confirm), và tin confirm thắng mọi tin khác.

Bước này quan trọng với một node còn sống nhưng tạm im lặng: GC pause dài, CPU bị tranh hết, event loop bị chặn. Trên lab, một node bị treo 700 ms:

  • SWIM gốc: cả 28 node còn lại đều coi nó là chết, trong cả 6 lượt chạy.
  • SWIM + suspect, chờ 1 giây: 5 trong 6 lượt không ai coi nó là chết. Lượt còn lại cả 28 node coi nó là chết, vì thời gian chờ chỉ dài hơn lần treo một chút, và tin cải chính lan không kịp.
  • Chờ 2 giây: không lượt nào báo nhầm.

Cái giá là phát hiện node chết thật chậm hơn. Để mọi node biết một node đã chết, SWIM gốc mất khoảng 0,7–1,3 giây; chờ nghi 1 giây thì mất khoảng 1,7 giây, chờ 2 giây thì khoảng 2,7 giây.

Heartbeat tới mọi node còn có một kiểu báo nhầm ngược. Trên lab, khi hết treo, timer kiểm tra của chính node bị treo chạy trước khi nó kịp đọc các heartbeat đã nhận, nên nó coi cả 28 node kia là chết. Tức là node có vấn đề lại đi tố những node khỏe. HashiCorp gặp đúng hiện tượng này với SWIM khi node xử lý gói chậm, và thêm các mở rộng gọi là Lifeguard vào thư viện memberlist (nền của Serf và Consul) để giảm báo nhầm.

Chạy từng chu kỳ để thấy tin lan đi. Giết node 0 rồi xem các node lần lượt chuyển sang "đã chết"; treo node 1 rồi so SWIM gốc với SWIM + suspect; kéo k về 0 để thấy báo nhầm khi không có ping-req:

Đang theo dõi:
0chu kỳ
0gói trong chu kỳ vừa rồi
0 / 19node coi node 0 là chết
0lần báo nhầm
012345678910111213141516171819Mỗi node tô theo điều nó tinvề node 0 (viền đậm):còn sốngđang nghi (suspect)đã coi là chếtđang bị treoping tới node 0, có ackping không có ackping hộ (ping-req)

Bấm “Giết node 0” rồi chạy từng chu kỳ để thấy tin lan đi, hoặc “Treo node 1” để xem một node còn sống nhưng tạm im lặng bị đối xử ra sao.

Mỗi bước là một chu kỳ. Heartbeat coi là chết sau 3 chu kỳ không thấy; SWIM + suspect chờ 4 chu kỳ trước khi tuyên bố chết. Mỗi tin được gắn kèm tối đa 9 lần (3·ln n). Số ngẫu nhiên có seed, nên “Từ đầu” cho lại đúng kịch bản.

Membership không phải đồng thuận. Gossip và SWIM chỉ cho mỗi node một cái nhìn gần đúng và sẽ khớp dần về cluster: trong một lúc, hai node có thể có danh sách khác nhau, và một node còn sống vẫn có thể bị coi là chết. Như vậy là đủ để chọn node gửi request, chia lại partition, hay báo cảnh báo. Còn những việc cần mọi node đồng ý tuyệt đối, như bầu leader hay giữ lock, thì phải dùng đồng thuận (Đồng thuận) kèm fencing token (Distributed lock).

Bài toán: thanh toán qua cổng bên ngoài ​

Ghép các phần trên lại cho luồng thanh toán đơn hàng, một câu hỏi hay gặp trong phỏng vấn:

  1. Client sinh idempotency key khi mở màn hình thanh toán, gửi kèm POST /payments. Bấm hai lần hay mạng chập chờn thì đều gửi lại cùng key.
  2. Server lưu key theo cách B, vì phải gọi cổng thanh toán. Request trùng nhận 409, client chờ một chút rồi hỏi lại.
  3. Gọi cổng thanh toán với timeout theo p99,9 latency của nó, và truyền key xuống. Chỉ retry khi lỗi tạm thời, với exponential backoff và full jitter, tối đa vài lần, nằm trong deadline của request.
  4. Circuit breaker bọc lời gọi tới cổng thanh toán. Khi breaker mở thì báo người dùng thử lại sau, không xếp hàng thanh toán ngầm, vì người dùng cần biết kết quả ngay.
  5. Job đối soát chạy định kỳ: với mỗi key kẹt ở processing quá hạn, lấy lại key (tăng lease), hỏi cổng thanh toán xem giao dịch mang key đó đã thành công chưa, rồi ghi kết quả có fencing. Không có job này thì kết quả “không biết” của những người dùng không bao giờ gửi lại sẽ phải chờ người xử lý bằng tay.
  6. Cổng thanh toán gọi lại webhook báo kết quả. Có ba điều phải làm:
    • Kiểm tra chữ ký của webhook (cổng thanh toán ký bằng secret chung), không thì ai cũng gọi được endpoint này để báo “đã thanh toán”.
    • Idempotent theo mã sự kiện, vì webhook cũng là at-least-once và có thể đến nhiều lần.
    • Chịu được việc webhook đến trước khi server kịp ghi kết quả của chính mình, tức key còn ở processing. Handler ghi kết quả theo charge_id (có UNIQUE). Bước hoàn tất key của request sau đó dùng INSERT IGNORE, nên gặp dòng webhook đã ghi thì không tạo dòng thứ hai mà vẫn hoàn tất key.

Ghi nhớ

  1. Lời gọi qua mạng có ba kết quả: thành công, thất bại, không biết. Retry sau khi “không biết” chỉ an toàn khi thao tác idempotent.
  2. Mọi lời gọi qua mạng cần timeout chọn theo p99,9 latency của dependency, và deadline của cả request được truyền xuống các tầng dưới.
  3. Retry chỉ lỗi tạm thời, ở một tầng, có giới hạn số lần và ngân sách, với exponential backoff cộng jitter. Không có jitter thì retry dồn thành từng đợt đúng lúc server yếu nhất.
  4. Idempotency key: client sinh một lần cho mỗi thao tác; server lưu key, mã băm của nội dung đã chuẩn hoá và kết quả; khác nội dung trả 422, đang xử lý trả 409. Khi có gọi ra ngoài thì truyền key xuống, cho lấy lại key quá hạn, và dùng lease làm fencing ở bước cuối, không thì request cũ tỉnh dậy sẽ ghi trùng.
  5. “Exactly-once” là at-least-once cộng với phía nhận idempotent.
  6. Circuit breaker giúp lỗi trả nhanh và dependency có cơ hội hồi lại, nhưng chỉ phản ứng sau timeout đầu tiên và có thể làm phục hồi chậm hơn. Bulkhead chia tài nguyên để một dependency hỏng không kéo theo cả service.
  7. Membership của cluster lớn: heartbeat tới mọi node tốn tải tăng theo số node; SWIM ping một node mỗi chu kỳ, nhờ k node ping hộ để không kết tội vì mất gói, gắn tin vào gói sẵn có, và suspect trước khi tuyên bố chết để node chỉ bị treo kịp cải chính. Membership chỉ gần đúng, không thay được đồng thuận.

Nguồn tham khảo ​

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

NguồnDùng cho phần
RFC 9110, §9.2.2 Idempotent MethodsĐịnh nghĩa idempotent, các method idempotent, server vẫn được ghi log, không tự retry method không idempotent
Stripe API docs, Idempotent requestsLưu status code và body kể cả 500 (API v1), key tới 255 ký tự, xoá sau ít nhất 24 giờ, so sánh tham số, không lưu khi lỗi kiểm tra tham số hay đụng request đang chạy, GET/DELETE không cần key
IETF, draft-ietf-httpapi-idempotency-key-header-07 (đã hết hạn)Key duy nhất, không dùng lại cho payload khác, fingerprint, 400 khi thiếu key, 422 khi khác payload, 409 khi đang xử lý, chính sách hết hạn
Marc Brooker, Exponential Backoff And Jitter, AWS Architecture Blog, 2015Exponential backoff, full jitter, equal jitter, decorrelated jitter và kết quả mô phỏng. Công thức trong bài là ảnh; trang này viết lại theo mô tả bằng chữ
AWS Builders’ Library, Timeouts, retries, and backoff with jitterChọn timeout theo tỉ lệ timeout nhầm và p99,9, lỗi timeout 20 ms khi mở connection, retry nhân lên 243 lần, retry ở một tầng, token bucket, idempotency cho API có tác dụng phụ, jitter cho mọi timer, nhược điểm của circuit breaker
Google SRE Book, Addressing Cascading FailuresGiới hạn retry mỗi request, ngân sách 60 retry mỗi phút, ví dụ 4 tầng thành 64 lần, mã lỗi “đừng retry”, deadline và truyền deadline, load shedding, graceful degradation, chạy thử đường dự phòng
Martin Fowler, CircuitBreaker, 2014Ba trạng thái, ngưỡng, request thử, fallback, ghi log và theo dõi
Akka docs, Phi Accrual Failure DetectorCông thức phi, heartbeat mỗi giây, ngưỡng mặc định 8, gợi ý 12 trên EC2, acceptable-heartbeat-pause, bài báo của Hayashibara và cộng sự
Akka, mã nguồn PhiAccrualFailureDetector và cấu hình mặc định (đọc khi kiểm theo review)acceptable-heartbeat-pause cộng vào trung bình, độ lệch chuẩn không nhỏ hơn min-std-deviation, xấp xỉ logistic; mặc định cluster: ngưỡng 8, pause 3 s, độ lệch chuẩn tối thiểu 100 ms, heartbeat 1 s
Resilience4j docs, CircuitBreaker (đọc khi kiểm theo review)slowCallDurationThreshold (mặc định 60 s), slowCallRateThreshold (mặc định 100%), failureRateThreshold 50%, cửa sổ 100 lời gọi
gRPC, PROTOCOL-HTTP2 (đọc khi kiểm theo review)Header grpc-timeout; không có header thì server coi như không có timeout
Node.js docs, Globals (đọc khi kiểm theo review)AbortSignal.any có từ v20.3.0 và v18.17.0, AbortSignal.timeout từ v17.3.0
Abhinandan Das, Indranil Gupta, Ashish Motivala, SWIM: Scalable Weakly-consistent Infection-style Process Group Membership Protocol, DSN 2002 (đọc ngày 2026-10-28)Heartbeat tới mọi node có tải tăng theo bình phương số node; ping, ping-req tới k node, tuyên bố chết khi hết chu kỳ; thời gian phát hiện, tỉ lệ báo nhầm, tải mỗi node không phụ thuộc số node; gắn kèm tin vào ping và ack; chọn đích lần lượt; suspect, alive, confirm, incarnation number chỉ chính node đó tăng
HashiCorp, memberlist README và Lifeguard: SWIM-ing with Situational Awareness (đọc ngày 2026-10-28)memberlist dựa trên SWIM, có mở rộng Lifeguard; xử lý gói chậm làm SWIM coi node khỏe là chết
Cassandra docs, Dynamo: Gossip (đọc ngày 2026-10-28)Mỗi giây mỗi node tăng heartbeat của mình và gossip với một node ngẫu nhiên; mỗi node chạy một biến thể của phi accrual failure detector

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

  • Công thức của decorrelated jitter min(cap, random(base, prev × 3)) (bài của AWS chỉ có dạng ảnh).
  • Giới hạn của exactly-once trong Kafka (idempotent producer và transaction chỉ trong phạm vi Kafka).
  • InnoDB bắt INSERT trùng khoá chờ transaction đang giữ dòng đó; kết quả đo khớp (request thứ hai chờ đúng bằng thời gian việc chính).
  • Thư viện opossum, p-limit.
  • Cách fetch của Node báo lỗi (TypeError: fetch failed, mã lỗi ở err.cause.code, không ném lỗi khi gặp 503) là kết quả chạy thử trên Node 24.14, chưa đối chiếu tài liệu undici.
  • Client HTTP của Node có pool connection riêng cho từng origin.
  • Mặc định timeout của các client HTTP và driver database trong Node.js; trang chỉ nói nhiều thư viện mặc định không có hoặc rất dài, cần kiểm từng thư viện đang dùng.
  • Michael Nygard, Release It! (Pragmatic Bookshelf), nguồn gốc của circuit breaker và bulkhead.
  • Naohiro Hayashibara và cộng sự, The φ Accrual Failure Detector, 2004.
  • Consul dùng Serf (dựng trên memberlist); Akka cluster dùng gossip. Tin gossip lan tới cả cluster sau khoảng log n chu kỳ (bài báo SWIM có công thức, nhưng bản text trích từ PDF bị lỗi ký hiệu nên chưa đối chiếu được con số).

Kiểm bằng lab (phần membership): tải mỗi node của heartbeat tới mọi node và SWIM với 10, 30, 60 node; báo nhầm khi mất 5% gói có và không có ping-req; node bị treo 700 ms với heartbeat, SWIM gốc và SWIM + suspect. Lab chạy mọi node trong một process trên localhost, nên độ trễ mạng gần bằng 0 và các node dùng chung một event loop: con số chỉ để so sánh các cách với nhau.

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

Chạy bằng npm run lab -- xu-ly-loi (khoảng 3 phút, trong đó swim.mjs mất khoảng 2 phút). idempotency.mjs cần MySQL của lab (npm run lab:up), các script còn lại chỉ cần Node.js. Số trong trang lấy từ các lượt chạy ngày 2026-10-20 trên MacBook M2 với MySQL 8.0 trong Docker; phần membership chạy ngày 2026-10-28 trên Node 24.14. Xem Lab.

Kết quả chạy lab/xu-ly-loi/swim.mjs (30 node trừ khi ghi khác, chu kỳ 200 ms, timeout ack 60 ms, k = 3)
Phép thửHeartbeat tới mọi nodeSWIMSWIM + suspect
Gói mỗi node mỗi giây, 10 / 30 / 60 node41 / 131 / 2659 / 9 / 9
Mọi node biết node chết, 10 / 30 / 60 node (hai lượt)0,49–0,54 s / 0,48–0,56 s / 0,48–0,63 s0,64–0,68 s / 0,9–1,05 s / 0,92–1,3 s
Mất 5% gói: số lần báo nhầm (4 lượt)2–90 ở 3 lượt, 28 ở 1 lượt (một node, tin lan tới mọi node); bỏ ping-req: khoảng 800, mọi node bị báo nhầm0
Mất 5% gói: mọi node biết node chết0,58–0,65 s0,5–0,95 s1,5–2,1 s
Node bị treo 700 ms: số node coi nó là chết (6 lượt)2828chờ 1 s: 0 ở 5 lượt, 28 ở 1 lượt; chờ 2 s: 0
Node bị treo, khi hết treo: số node khỏe nó coi là chết2800
Node bị treo: mọi node biết node chết thật0,46–0,62 s0,69–1,25 schờ 1 s: 1,7–1,76 s; chờ 2 s: 2,65–2,76 s
lab/xu-ly-loi/idempotency.mjs: timeout sau commit, cách A và cách B
js
// Trang distributed/xu-ly-loi: kết quả "không biết" và idempotency key. Cần MySQL của lab (npm run up).
// Server HTTP và client chạy trong cùng process. Server commit xong rồi mới trả lời chậm (giả lập response
// bị trễ hay mất trên mạng); client timeout trước khi nhận được, nên không biết tiền đã bị trừ chưa, và gửi lại.
// Hai cách thiết kế idempotency:
//   A  ghi key và làm việc chính trong CÙNG một transaction (chỉ dùng được khi mọi việc nằm trong database)
//   B  ghi key ở trạng thái 'processing' và commit trước, làm việc chính, rồi ghi kết quả vào key
//      (cần khi việc chính có gọi API bên ngoài, không gói được vào một transaction)
import http from 'node:http'
import { createHash, randomUUID } from 'node:crypto'
import mysql from 'mysql2/promise'
import { MYSQL, sleep } from '../common/config.mjs'

const DB = 'xu_ly_loi'
const admin = await mysql.createConnection(MYSQL)
await admin.query(`CREATE DATABASE IF NOT EXISTS ${DB}`)
await admin.end()
const pool = mysql.createPool({ ...MYSQL, database: DB, connectionLimit: 10 })
await pool.query('DROP TABLE IF EXISTS payments, idempotency_keys')
await pool.query(`CREATE TABLE payments (
  id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, amount INT NOT NULL,
  created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))`)
await pool.query(`CREATE TABLE idempotency_keys (
  idem_key VARCHAR(64) PRIMARY KEY,
  request_hash CHAR(64) NOT NULL,      -- băm của body, để phát hiện cùng key mà khác nội dung
  status_code INT NULL,                -- NULL: đang xử lý (cách B)
  response TEXT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`)

// Điều khiển của phép thử: số request đầu tiên trả lời chậm, thời gian làm việc chính, cách thiết kế.
const ctl = { slowReplies: 0, replyDelay: 0, workMs: 0, design: 'A' }

async function charge(conn, body) {
  const [r] = await conn.execute('INSERT INTO payments (order_id, amount) VALUES (?, ?)', [body.orderId, body.amount])
  return { paymentId: r.insertId, orderId: body.orderId, amount: body.amount }
}

async function handle(body, key) {
  const hash = createHash('sha256').update(JSON.stringify(body)).digest('hex')
  const conn = await pool.getConnection()
  try {
    if (!key) {
      // Không có key: mỗi request là một lần trừ tiền mới
      await conn.beginTransaction()
      await sleep(ctl.workMs)
      const result = await charge(conn, body)
      await conn.commit()
      return [200, result, 'new']
    }
    if (ctl.design === 'A') {
      await conn.beginTransaction()
      try {
        await conn.execute('INSERT INTO idempotency_keys (idem_key, request_hash) VALUES (?, ?)', [key, hash])
      } catch (e) {
        if (e.code !== 'ER_DUP_ENTRY') throw e
        await conn.rollback()
        const [[row]] = await conn.execute('SELECT request_hash, status_code, response FROM idempotency_keys WHERE idem_key = ?', [key])
        if (row.request_hash !== hash) return [422, { error: 'Idempotency-Key đã dùng cho một request khác' }, 'mismatch']
        return [row.status_code, JSON.parse(row.response), 'replay']
      }
      await sleep(ctl.workMs)
      const result = await charge(conn, body)
      await conn.execute('UPDATE idempotency_keys SET status_code = 200, response = ? WHERE idem_key = ?', [JSON.stringify(result), key])
      await conn.commit()
      return [200, result, 'new']
    }
    // Cách B: ghi key 'processing' và commit ngay, rồi mới làm việc chính
    try {
      await conn.execute('INSERT INTO idempotency_keys (idem_key, request_hash) VALUES (?, ?)', [key, hash])
    } catch (e) {
      if (e.code !== 'ER_DUP_ENTRY') throw e
      const [[row]] = await conn.execute('SELECT request_hash, status_code, response FROM idempotency_keys WHERE idem_key = ?', [key])
      if (row.request_hash !== hash) return [422, { error: 'Idempotency-Key đã dùng cho một request khác' }, 'mismatch']
      if (row.status_code === null) return [409, { error: 'Request với key này đang được xử lý' }, 'in-progress']
      return [row.status_code, JSON.parse(row.response), 'replay']
    }
    await sleep(ctl.workMs) // ví dụ gọi API của cổng thanh toán, gửi kèm cùng key
    await conn.beginTransaction()
    const result = await charge(conn, body)
    await conn.execute('UPDATE idempotency_keys SET status_code = 200, response = ? WHERE idem_key = ?', [JSON.stringify(result), key])
    await conn.commit()
    return [200, result, 'new']
  } finally {
    conn.release()
  }
}

let served = 0
const server = http.createServer(async (req, res) => {
  let raw = ''
  for await (const c of req) raw += c
  const t0 = performance.now()
  const [status, body, kind] = await handle(JSON.parse(raw), req.headers['idempotency-key'])
  served++
  // Việc đã xong và đã commit. Một số request đầu trả lời chậm, giả lập response bị trễ trên mạng.
  if (kind === 'new' && ctl.slowReplies > 0) { ctl.slowReplies--; await sleep(ctl.replyDelay) }
  res.writeHead(status, { 'content-type': 'application/json', 'x-kind': kind, 'x-server-ms': String(Math.round(performance.now() - t0)) })
  res.end(JSON.stringify(body))
})
await new Promise((r) => server.listen(0, '127.0.0.1', r))
const url = `http://127.0.0.1:${server.address().port}/charge`

async function send(body, key, timeoutMs = 2000) {
  const t0 = performance.now()
  try {
    const r = await fetch(url, {
      method: 'POST', body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs),
      headers: { 'content-type': 'application/json', ...(key ? { 'idempotency-key': key } : {}) }
    })
    return { status: r.status, kind: r.headers.get('x-kind'), ms: Math.round(performance.now() - t0), body: await r.json() }
  } catch (e) {
    return { status: e.name === 'TimeoutError' ? 'timeout' : e.message, ms: Math.round(performance.now() - t0) }
  }
}

// Client: tối đa 3 lần, timeout 150 ms mỗi lần, gửi lại đúng key cũ
async function payWithRetry(body, key) {
  const attempts = []
  for (let i = 0; i < 3; i++) {
    const r = await send(body, key, 150)
    attempts.push(r.status === 200 ? `200 (${r.kind}, ${r.ms} ms)` : `${r.status} (${r.ms} ms)`)
    if (r.status === 200) break
  }
  return attempts
}
const count = async (orderId) => (await pool.query('SELECT COUNT(*) AS n FROM payments WHERE order_id = ?', [orderId]))[0][0].n

console.log('\n=== 1. Timeout sau khi server đã commit, client gửi lại (server trả lời chậm 300 ms ở 2 lần đầu) ===')
for (const [name, withKey, orderId] of [['không có idempotency key', false, 1], ['có idempotency key (cách A)', true, 2]]) {
  Object.assign(ctl, { slowReplies: 2, replyDelay: 300, workMs: 0, design: 'A' })
  const attempts = await payWithRetry({ orderId, amount: 100000 }, withKey ? randomUUID() : null)
  await sleep(400) // chờ các lần trả lời chậm của server xong hẳn
  console.log(`${name.padEnd(30)} các lần gửi: ${attempts.join(' → ')}`)
  console.log(`${''.padEnd(30)} số lần trừ tiền trong bảng payments: ${await count(orderId)}`)
}

console.log('\n=== 2. Bấm hai lần: hai request cùng key gửi gần như cùng lúc, việc chính mất 200 ms ===')
for (const [design, orderId] of [['A', 3], ['B', 4]]) {
  Object.assign(ctl, { slowReplies: 0, replyDelay: 0, workMs: 200, design })
  const key = randomUUID()
  const body = { orderId, amount: 50000 }
  const [r1, r2] = await Promise.all([send(body, key), sleep(20).then(() => send(body, key))])
  const fmt = (r) => `${r.status}${r.kind ? ' ' + r.kind : ''}, ${r.ms} ms`
  console.log(`cách ${design}: request 1 → ${fmt(r1)}; request 2 (gửi sau 20 ms) → ${fmt(r2)}; số lần trừ tiền: ${await count(orderId)}`)
}

console.log('\n=== 3. Dùng lại key cũ cho một request khác (số tiền khác) ===')
for (const design of ['A', 'B']) {
  Object.assign(ctl, { slowReplies: 0, replyDelay: 0, workMs: 0, design })
  const key = randomUUID()
  const a = await send({ orderId: 5, amount: 100000 }, key)
  const b = await send({ orderId: 5, amount: 999000 }, key)
  console.log(`cách ${design}: lần 1 → ${a.status}; lần 2 cùng key, số tiền khác → ${b.status} ${b.body.error ?? ''}`)
}

server.close()
await pool.end()
lab/xu-ly-loi/design-b.mjs: request cũ tỉnh dậy, UNIQUE và lease, lỗi từ cổng thanh toán
js
// Trang distributed/xu-ly-loi: lỗi của cách B (ghi key 'processing' trước) khi request cũ chỉ bị treo chứ chưa chết.
// Cần MySQL của lab. Cổng thanh toán là giả lập trong process: cùng idempotency key thì trả lại cùng charge.
// Thứ tự: request 1 lấy key (hạn 1 giây), gọi cổng thanh toán, rồi bị treo 1,5 giây. Lúc 1,2 giây client gửi lại;
// request 2 thấy key quá hạn nên lấy lại key, gọi cổng thanh toán (nhận lại cùng charge), ghi payments và xong.
// Request 1 tỉnh dậy và ghi tiếp. So sánh ba cách:
//   gốc      code ban đầu trong trang: INSERT payments rồi UPDATE key, không điều kiện
//   unique   như gốc, thêm UNIQUE (charge_id) cho payments
//   lease    key có cột lease tăng mỗi lần lấy lại; bước cuối chỉ ghi khi lease còn là của mình (fencing)
// Cuối script thử hai loại lỗi từ cổng thanh toán: lỗi chắc chắn (thẻ bị từ chối) và lỗi tạm thời.
import mysql from 'mysql2/promise'
import { randomUUID } from 'node:crypto'
import { MYSQL, sleep } from '../common/config.mjs'

const admin = await mysql.createConnection(MYSQL)
await admin.query('CREATE DATABASE IF NOT EXISTS xu_ly_loi')
await admin.end()
const db = mysql.createPool({ ...MYSQL, database: 'xu_ly_loi', connectionLimit: 10 })

// Cổng thanh toán giả lập, idempotent theo key
const charges = new Map()
let gatewayCalls = 0
async function gateway(key, amount, behaviour = 'ok') {
  gatewayCalls++
  await sleep(50)
  if (behaviour === 'declined') { const e = new Error('card_declined'); e.status = 402; e.final = true; throw e }
  if (behaviour === 'flaky') { const e = new Error('gateway 503'); e.status = 503; throw e }
  if (!charges.has(key)) charges.set(key, { id: 'ch_' + (charges.size + 1), amount })
  return charges.get(key)
}

async function setup(unique) {
  await db.query('DROP TABLE IF EXISTS payments, idem_b')
  await db.query(`CREATE TABLE payments (id BIGINT AUTO_INCREMENT PRIMARY KEY, charge_id VARCHAR(32) NOT NULL, amount INT NOT NULL
    ${unique ? ', UNIQUE KEY uq_charge (charge_id)' : ''})`)
  await db.query(`CREATE TABLE idem_b (
    idem_key VARCHAR(64) PRIMARY KEY, status VARCHAR(16) NOT NULL, lease BIGINT NOT NULL DEFAULT 1,
    response_code INT NULL, response_body JSON NULL, locked_until TIMESTAMP(3) NOT NULL)`)
}

// Lấy key: INSERT mới; trùng thì đọc, và chỉ lấy lại được khi đang processing và đã quá hạn (ghi có điều kiện).
// Trả về { kind, lease }. lease mới lấy bằng LAST_INSERT_ID(lease + 1) để biết mình là lần lấy thứ mấy.
async function claim(key) {
  try {
    await db.execute(`INSERT INTO idem_b (idem_key, status, locked_until) VALUES (?, 'processing', NOW(3) + INTERVAL 1 SECOND)`, [key])
    return { kind: 'new', lease: 1 }
  } catch (e) {
    if (e.code !== 'ER_DUP_ENTRY') throw e
  }
  const [[row]] = await db.execute('SELECT status, response_code, response_body FROM idem_b WHERE idem_key = ?', [key])
  if (row.status === 'done') return { kind: 'done', code: row.response_code, body: row.response_body }
  const [r] = await db.execute(`UPDATE idem_b SET locked_until = NOW(3) + INTERVAL 1 SECOND, lease = LAST_INSERT_ID(lease + 1)
    WHERE idem_key = ? AND status = 'processing' AND locked_until < NOW(3)`, [key])
  return r.affectedRows ? { kind: 'reclaimed', lease: r.insertId } : { kind: 'busy' }
}

async function finish(key, lease, charge, mode) {
  const conn = await db.getConnection()
  try {
    await conn.beginTransaction()
    if (mode === 'lease') {
      // Fencing: chỉ hoàn tất khi key vẫn processing và lease vẫn là của mình
      const [u] = await conn.execute(`UPDATE idem_b SET status = 'done', response_code = 200, response_body = ?
        WHERE idem_key = ? AND status = 'processing' AND lease = ?`, [JSON.stringify(charge), key, lease])
      if (u.affectedRows === 0) { await conn.rollback(); return 'lease đã bị lấy lại, rollback' }
      await conn.execute('INSERT INTO payments (charge_id, amount) VALUES (?, ?)', [charge.id, charge.amount])
    } else {
      await conn.execute('INSERT INTO payments (charge_id, amount) VALUES (?, ?)', [charge.id, charge.amount])
      await conn.execute(`UPDATE idem_b SET status = 'done', response_code = 200, response_body = ? WHERE idem_key = ?`, [JSON.stringify(charge), key])
    }
    await conn.commit()
    return 'ghi payments, xong'
  } catch (e) {
    await conn.rollback()
    if (e.code === 'ER_DUP_ENTRY') return 'trùng charge_id, rollback'
    throw e
  } finally {
    conn.release()
  }
}

async function request(name, key, mode, { pauseMs = 0, startAt = 0, gatewayDelay = 0 }, log) {
  await sleep(startAt)
  const c = await claim(key)
  log.push(`${name}: lấy key → ${c.kind}${c.lease ? ' (lease ' + c.lease + ')' : ''}`)
  if (c.kind !== 'new' && c.kind !== 'reclaimed') return
  await sleep(gatewayDelay)
  const charge = await gateway(key, 100000)
  log.push(`${name}: cổng thanh toán trả ${charge.id}`)
  if (pauseMs) { log.push(`${name}: bị treo ${pauseMs} ms`); await sleep(pauseMs) }
  log.push(`${name}: ${await finish(key, c.lease, charge, mode)}`)
}

console.log('\n=== Cách B: request cũ bị treo rồi chạy tiếp song song với request đã lấy lại key ===')
for (const [mode, label] of [['gốc', 'code ban đầu'], ['unique', 'thêm UNIQUE (charge_id)'], ['lease', 'lease token ở bước cuối']]) {
  for (const order of ['B xong trước khi A tỉnh', 'A tỉnh khi B còn chờ cổng thanh toán']) {
    await setup(mode === 'unique')
    charges.clear(); gatewayCalls = 0
    const key = randomUUID(), log = []
    const slowB = order.startsWith('A tỉnh') ? 600 : 0
    await Promise.all([
      request('A', key, mode, { pauseMs: 1500 }, log),
      request('B', key, mode, { startAt: 1200, gatewayDelay: slowB }, log)
    ])
    const [[{ n }]] = await db.query('SELECT COUNT(*) AS n FROM payments')
    console.log(`${label.padEnd(26)} | ${order.padEnd(36)} | dòng payments: ${n} | lần gọi cổng thanh toán: ${gatewayCalls}, số lần trừ tiền thật: ${charges.size}`)
    console.log('   ' + log.join(' → '))
  }
}

console.log('\n=== Lỗi từ cổng thanh toán ===')
await setup(true)
{
  // Lỗi chắc chắn: lưu thành done cùng response lỗi, gửi lại nhận đúng lỗi đó
  const key = randomUUID()
  await claim(key)
  try { await gateway(key, 100000, 'declined') } catch (e) {
    await db.execute(`UPDATE idem_b SET status = 'done', response_code = ?, response_body = ? WHERE idem_key = ?`, [e.status, JSON.stringify({ error: e.message }), key])
  }
  const again = await claim(key)
  console.log(`thẻ bị từ chối (402): lưu done; gửi lại → ${again.kind} ${again.code} ${JSON.stringify(again.body)}`)
}
{
  // Lỗi tạm thời: nếu để nguyên, gửi lại ngay nhận busy (409) tới khi hết hạn; nếu nhả key (locked_until = NOW) thì lấy lại được ngay
  const key = randomUUID()
  await claim(key)
  try { await gateway(key, 100000, 'flaky') } catch {}
  const keep = await claim(key)
  await db.execute('UPDATE idem_b SET locked_until = NOW(3) - INTERVAL 1 MICROSECOND WHERE idem_key = ? AND status = ?', [key, 'processing'])
  const release = await claim(key)
  console.log(`cổng thanh toán 503: để nguyên key, gửi lại ngay → ${keep.kind}; nhả key (locked_until = NOW) rồi gửi lại → ${release.kind} (lease ${release.lease})`)
}
await db.end()
lab/xu-ly-loi/retry-storm.mjs: bốn chiến lược retry
js
// Retry storm. 500 client cùng gửi một request lúc t = 0, đúng lúc server vừa sập (503 trong 1 giây đầu).
// Sau khi hồi lại, server chỉ xử lý được 300 request/giây. Mỗi client retry tới khi thành công, tối đa 10 lần.
// So sánh bốn chiến lược chờ giữa các lần retry. Chỉ cần Node.js.
// "Retry ngay" dùng hết 10 lần trong vài ms, tức là toàn bộ nằm trong lúc server sập: đó chính là điều cần thấy.
import http from 'node:http'
import { fork } from 'node:child_process'

const N = 500, OUTAGE = 1000, CAP = 300, MAX_ATTEMPTS = 10
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const STRATEGIES = {
  'retry ngay': () => 0,
  'chờ cố định 500 ms': () => 500,
  'exponential (100 ms × 2ⁿ, tối đa 3,2 s)': (n) => Math.min(3200, 100 * 2 ** n),
  'exponential + full jitter': (n) => Math.random() * Math.min(3200, 100 * 2 ** n)
}

function call(port, agent) {
  return new Promise((resolve) => {
    const req = http.get({ host: '127.0.0.1', port, agent }, (res) => { res.resume(); res.on('end', () => resolve(res.statusCode)) })
    req.on('error', () => resolve('error'))
  })
}

console.log(`\n=== Retry storm: ${N} client, server sập ${OUTAGE} ms rồi xử lý được ${CAP} request/giây, tối đa ${MAX_ATTEMPTS} lần mỗi client ===`)
console.log('chiến lược                                 tổng request  bỏ cuộc  xong 50%   xong 99%   tải đỉnh sau khi hồi (request/100 ms)')
const shapes = {}
for (const [name, delay] of Object.entries(STRATEGIES)) {
  const child = fork(new URL('./retry-server.mjs', import.meta.url), [String(OUTAGE), String(CAP)])
  const { port } = await new Promise((r) => child.once('message', r))
  const agent = new http.Agent({ keepAlive: true, maxSockets: Infinity })
  for (let i = 0; i < 50; i++) await call(port, agent) // mở sẵn connection, khởi động
  child.send('start')
  await new Promise((r) => child.once('message', r))
  const t0 = performance.now()
  let total = 0
  const doneAt = []
  let gaveUp = 0
  await Promise.all(Array.from({ length: N }, async () => {
    for (let n = 0; n < MAX_ATTEMPTS; n++) {
      total++
      if ((await call(port, agent)) === 200) return doneAt.push(performance.now() - t0)
      await sleep(delay(n))
    }
    gaveUp++
  }))
  child.send('stats')
  const { buckets } = await new Promise((r) => child.once('message', r))
  doneAt.sort((a, b) => a - b)
  // Mốc p tính trên cả N client; client bỏ cuộc thì không bao giờ xong, nên mốc có thể không đạt được.
  const q = (p) => { const i = Math.ceil(p * N) - 1; return i < doneAt.length ? Math.round(doneAt[i]) : NaN }
  const peak = Math.max(0, ...buckets.slice(OUTAGE / 100))
  shapes[name] = buckets.slice(0, 60)
  const f = (v) => (Number.isNaN(v) ? 'không đạt' : v + ' ms')
  console.log([name.padEnd(42), String(total).padStart(12), String(gaveUp).padStart(8), f(q(0.5)).padStart(9), f(q(0.99)).padStart(10), String(peak).padStart(10)].join('  '))
  agent.destroy()
  child.kill()
}
console.log('\nSố request đến server theo từng khung 100 ms (6 giây đầu):')
for (const [name, b] of Object.entries(shapes)) console.log(name.padEnd(42), b.join(' '))
lab/xu-ly-loi/retry-server.mjs
js
// Server cho retry-storm.mjs, chạy trong process con.
// Sập (trả 503 ngay) trong OUTAGE ms đầu tiên kể từ lúc nhận lệnh 'start'. Sau đó chỉ xử lý được CAP request/giây
// (token bucket, sức chứa CAP/10); request vượt năng lực cũng bị trả 503 ngay.
// Ghi số request đến theo từng khung 100 ms để vẽ lại tải.
import http from 'node:http'

const [OUTAGE, CAP] = process.argv.slice(2).map(Number)
let start = null, tokens = CAP / 10, last = 0
const buckets = [], okBuckets = []
const server = http.createServer((req, res) => {
  const now = performance.now()
  const t = start === null ? 0 : now - start
  const b = Math.floor(t / 100)
  buckets[b] = (buckets[b] || 0) + 1
  tokens = Math.min(CAP / 10, tokens + ((now - last) / 1000) * CAP)
  last = now
  if (t < OUTAGE || tokens < 1) {
    res.writeHead(503)
    return res.end()
  }
  tokens -= 1
  okBuckets[b] = (okBuckets[b] || 0) + 1
  res.end('ok')
})
server.keepAliveTimeout = 60_000
server.listen(0, '127.0.0.1', () => process.send({ port: server.address().port }))
process.on('message', (m) => {
  if (m === 'start') { start = performance.now(); last = start; tokens = CAP / 10; buckets.length = 0; okBuckets.length = 0; process.send('started') }
  if (m === 'stats') process.send({ buckets: Array.from(buckets, (x) => x || 0), okBuckets: Array.from(okBuckets, (x) => x || 0) })
})
lab/xu-ly-loi/circuit-breaker.mjs
js
// Circuit breaker. Service gọi một dependency (process con) với timeout 1 giây, 50 request/giây trong 10 giây.
// Từ giây 2 tới giây 6, dependency bị treo: nhận request nhưng không trả lời. So sánh có và không có circuit breaker.
// Breaker: 5 lần lỗi liên tiếp thì mở; mở 1 giây rồi sang half-open, cho đúng một request thử; thử được thì đóng lại.
// Chỉ cần Node.js.
import http from 'node:http'
import { fork } from 'node:child_process'
import { writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

const depFile = join(tmpdir(), 'lab-cb-dependency.mjs')
writeFileSync(depFile, `
  import http from 'node:http'
  let down = false, received = 0
  const s = http.createServer((q, r) => { received++; if (!down) setTimeout(() => r.end('ok'), 5) })
  s.keepAliveTimeout = 60_000
  s.listen(0, '127.0.0.1', () => process.send({ port: s.address().port }))
  process.on('message', (m) => {
    if (m === 'down') down = true
    if (m === 'up') down = false
    if (m === 'count') process.send({ received })
  })
`)

class CircuitBreaker {
  constructor({ threshold = 5, openMs = 1000 } = {}) {
    Object.assign(this, { threshold, openMs, state: 'closed', failures: 0, openedAt: 0, trial: false, log: [] })
  }
  async call(fn) {
    if (this.state === 'open') {
      if (performance.now() - this.openedAt < this.openMs) throw new Error('circuit open')
      this.to('half-open')
    }
    if (this.state === 'half-open') {
      if (this.trial) throw new Error('circuit open') // đã có một request thử đang chạy
      this.trial = true
    }
    try {
      const r = await fn()
      this.failures = 0
      if (this.state === 'half-open') this.to('closed')
      return r
    } catch (e) {
      this.failures++
      if (this.state === 'half-open' || this.failures >= this.threshold) { this.openedAt = performance.now(); this.to('open') }
      throw e
    } finally {
      if (this.state !== 'half-open') this.trial = false
    }
  }
  to(s) { if (this.state !== s) { this.state = s; this.log.push([Math.round(performance.now() - T0), s]) } }
}

let T0 = 0
async function run(useBreaker) {
  const dep = fork(depFile)
  const { port } = await new Promise((r) => dep.once('message', r))
  const agent = new http.Agent({ keepAlive: true, maxSockets: Infinity })
  const callDep = () => new Promise((resolve, reject) => {
    const req = http.get({ host: '127.0.0.1', port, agent, signal: AbortSignal.timeout(1000) }, (res) => { res.resume(); res.on('end', resolve) })
    req.on('error', reject)
  })
  const breaker = new CircuitBreaker()
  const samples = []
  let inFlight = 0, maxInFlight = 0
  T0 = performance.now()
  setTimeout(() => dep.send('down'), 2000)
  setTimeout(() => dep.send('up'), 6000)
  const jobs = []
  for (let i = 0; i < 500; i++) {
    const due = T0 + i * 20
    await new Promise((r) => setTimeout(r, Math.max(0, due - performance.now())))
    jobs.push((async () => {
      const t = performance.now()
      inFlight++; maxInFlight = Math.max(maxInFlight, inFlight)
      let ok = true
      try { await (useBreaker ? breaker.call(callDep) : callDep()) } catch { ok = false }
      inFlight--
      samples.push({ at: t - T0, ms: performance.now() - t, ok })
    })())
  }
  await Promise.all(jobs)
  dep.send('count')
  const { received } = await new Promise((r) => dep.once('message', r))
  agent.destroy(); dep.kill()
  return { samples, received, maxInFlight, log: breaker.log }
}

const pctl = (a, p) => { const s = [...a].sort((x, y) => x - y); return s.length ? s[Math.min(s.length - 1, Math.ceil(p * s.length) - 1)] : NaN }
console.log('\n=== Circuit breaker: 50 request/giây trong 10 s, dependency treo từ giây 2 tới giây 6, timeout 1 s ===')
console.log('cách gọi          lúc treo: p50   p99    thành công   request tới dependency   đang chờ cùng lúc (max)   phục hồi sau khi hết treo')
for (const useBreaker of [false, true]) {
  const r = await run(useBreaker)
  const during = r.samples.filter((s) => s.at >= 2000 && s.at < 6000)
  const firstOkAfter = r.samples.filter((s) => s.at >= 6000 && s.ok).sort((a, b) => a.at - b.at)[0]
  console.log([(useBreaker ? 'có circuit breaker' : 'gọi thẳng').padEnd(17),
    (Math.round(pctl(during.map((s) => s.ms), 0.5)) + ' ms').padStart(13), (Math.round(pctl(during.map((s) => s.ms), 0.99)) + ' ms').padStart(8),
    `${during.filter((s) => s.ok).length}/${during.length}`.padStart(12), String(r.received).padStart(24), String(r.maxInFlight).padStart(26),
    (firstOkAfter ? `request đầu tiên thành công lúc ${(firstOkAfter.at / 1000).toFixed(2)} s` : '—').padStart(40)].join('  '))
  if (useBreaker) console.log('   breaker đổi trạng thái (ms → trạng thái):', r.log.map(([t, s]) => `${t} → ${s}`).join(', '))
}
lab/xu-ly-loi/swim.mjs: heartbeat tới mọi node, SWIM, SWIM + suspect trên UDP thật
js
// Trang distributed/xu-ly-loi, phần membership. Chỉ cần Node.js, khoảng 1 phút.
// N node chạy trong một process, mỗi node một socket UDP thật trên 127.0.0.1. Mất gói được giả lập lúc gửi.
// Ba cách phát hiện node chết:
//   heartbeat  mỗi chu kỳ T, mỗi node gửi heartbeat cho mọi node khác; quá 3T không thấy thì coi là chết
//   swim       mỗi chu kỳ T, mỗi node ping một node (lần lượt theo danh sách đã xáo trộn); không có ack sau 60 ms
//              thì nhờ k = 3 node khác ping hộ (ping-req); hết chu kỳ vẫn không có ack thì tuyên bố chết.
//              Tin "đã chết" lan đi bằng cách gắn kèm (piggyback) vào ping, ping-req, ack, không gửi gói riêng.
//   swim+suspect  như swim, nhưng trước hết chỉ đánh dấu "nghi ngờ" (suspect); hết 5 chu kỳ không ai cải chính
//              mới tuyên bố chết. Node bị nghi tự cải chính bằng tin "alive" với incarnation number tăng lên.
import dgram from 'node:dgram'

const T = 200, ACK_TIMEOUT = 60, K = 3, SUSPECT_PERIODS = 5, HB_TIMEOUT = 3 * T
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

async function run({ proto, n, loss = 0, k = K, pause = 0, pauseAt = 3000, suspectPeriods = SUSPECT_PERIODS, killAt = 2000, duration = 6000 }) {
  const start = performance.now()
  const now = () => performance.now() - start
  const stats = { sent: 0, deadDecl: [] } // deadDecl: { observer, target, t }
  const nodes = []
  const MAXP = Math.ceil(3 * Math.log(n)) // mỗi tin được gắn kèm tối đa λ·ln(n) lần, λ = 3
  for (let i = 0; i < n; i++) {
    const sock = dgram.createSocket('udp4')
    await new Promise((r) => sock.bind(0, '127.0.0.1', r))
    nodes.push({ id: i, sock, port: sock.address().port, alive: true, inc: 0, members: new Map(), timers: [], seq: 0, pending: new Map(), relay: new Map(), updates: [], order: [], pausedUntil: 0, deferred: [], backlog: [] })
  }
  // Node bị treo (GC pause dài, event loop bị chặn): trong lúc treo nó không gửi gì; timer và gói nhận được dồn lại.
  // Hết treo thì timer chạy trước, rồi mới tới gói đã nhận, giống thứ tự phase timers rồi poll của event loop.
  const SLOW = pause ? n - 1 : -1
  const paused = (node) => node.pausedUntil > now()
  const later = (node, fn, ms) => setTimeout(() => (paused(node) ? node.deferred.includes(fn) || node.deferred.push(fn) : fn()), ms)
  if (pause) setTimeout(() => {
    const node = nodes[SLOW]
    node.pausedUntil = now() + pause
    setTimeout(() => {
      const timers = node.deferred.splice(0), msgs = node.backlog.splice(0)
      for (const fn of timers) fn()
      for (const m of msgs) onMessage(node, m)
    }, pause)
  }, pauseAt)
  const KILLED = 0
  for (const a of nodes) for (const b of nodes) if (a !== b) a.members.set(b.id, { state: 'alive', inc: 0, last: 0, timer: null })

  const send = (from, toId, msg) => {
    if (!from.alive || paused(from)) return
    stats.sent++
    if (Math.random() < loss) return
    from.sock.send(Buffer.from(JSON.stringify({ ...msg, from: from.id })), nodes[toId].port, '127.0.0.1')
  }
  const declareDead = (node, id) => {
    const m = node.members.get(id)
    if (m.state === 'dead') return
    m.state = 'dead'
    clearTimeout(m.timer)
    stats.deadDecl.push({ observer: node.id, target: id, t: now() })
  }
  // ----- SWIM: tin membership gắn kèm vào gói -----
  const enqueue = (node, upd) => {
    node.updates = node.updates.filter((u) => u.upd.id !== upd.id)
    node.updates.push({ upd, sent: 0 })
  }
  const piggyback = (node) => {
    node.updates.sort((a, b) => a.sent - b.sent)
    const out = node.updates.slice(0, 6)
    for (const u of out) u.sent++
    node.updates = node.updates.filter((u) => u.sent < MAXP)
    return out.map((u) => u.upd)
  }
  const applyUpdate = (node, u) => {
    if (u.id === node.id) {
      // tin về chính mình: bị nghi thì cải chính bằng incarnation mới
      if (u.kind === 'suspect' && u.inc >= node.inc) { node.inc = u.inc + 1; enqueue(node, { kind: 'alive', id: node.id, inc: node.inc }) }
      return
    }
    const m = node.members.get(u.id)
    if (m.state === 'dead') return
    if (u.kind === 'confirm') { declareDead(node, u.id); enqueue(node, u) }
    else if (u.kind === 'suspect' && u.inc >= m.inc && m.state === 'alive') { suspect(node, u.id, u.inc) }
    else if (u.kind === 'alive' && u.inc > m.inc) { m.inc = u.inc; if (m.state === 'suspect') { m.state = 'alive'; clearTimeout(m.timer) } enqueue(node, u) }
  }
  const suspect = (node, id, inc) => {
    const m = node.members.get(id)
    m.state = 'suspect'; m.inc = inc
    enqueue(node, { kind: 'suspect', id, inc })
    m.timer = later(node, () => { if (m.state === 'suspect') { declareDead(node, id); enqueue(node, { kind: 'confirm', id }) } }, suspectPeriods * T)
  }
  const failed = (node, id) => {
    if (proto === 'swim') { declareDead(node, id); enqueue(node, { kind: 'confirm', id }) }
    else if (node.members.get(id).state === 'alive') suspect(node, id, node.members.get(id).inc)
  }
  const liveIds = (node, except = []) => [...node.members].filter(([id, m]) => m.state !== 'dead' && !except.includes(id)).map(([id]) => id)
  const nextTarget = (node) => {
    for (let tries = 0; tries < 2; tries++) {
      while (node.order.length) { const id = node.order.pop(); if (node.members.get(id).state !== 'dead') return id }
      node.order = liveIds(node).sort(() => Math.random() - 0.5)  // hết lượt thì xáo lại, như mục 4.3 của bài báo
    }
    return null
  }
  const probe = (node) => {
    if (!node.alive) return
    const target = nextTarget(node)
    if (target === null) return
    const seq = ++node.seq
    let acked = false
    node.pending.set(seq, () => (acked = true))
    send(node, target, { t: 'ping', seq, upd: piggyback(node) })
    node.timers.push(later(node, () => {
      if (acked) return
      for (const h of liveIds(node, [target]).sort(() => Math.random() - 0.5).slice(0, k)) send(node, h, { t: 'ping-req', seq, target, upd: piggyback(node) })
    }, ACK_TIMEOUT))
    node.timers.push(later(node, () => { node.pending.delete(seq); if (!acked) failed(node, target) }, T - 5))
  }
  function onMessage(node, msg) {
    if (!node.alive) return
    if (paused(node)) { node.backlog.push(msg); return }
    if (proto === 'heartbeat') { const m = node.members.get(msg.from); m.last = now(); return }
    for (const u of msg.upd || []) applyUpdate(node, u)
    if (msg.t === 'ping') send(node, msg.from, { t: 'ack', seq: msg.seq, upd: piggyback(node) })
    else if (msg.t === 'ping-req') {
      const seq = ++node.seq
      node.relay.set(seq, { origin: msg.from, oseq: msg.seq })
      send(node, msg.target, { t: 'ping', seq, upd: piggyback(node) })
      setTimeout(() => node.relay.delete(seq), T)
    } else if (msg.t === 'ack') {
      const r = node.relay.get(msg.seq)
      if (r) { node.relay.delete(msg.seq); send(node, r.origin, { t: 'ack', seq: r.oseq, upd: piggyback(node) }) }
      else node.pending.get(msg.seq)?.()
    }
  }
  for (const node of nodes) node.sock.on('message', (b) => onMessage(node, JSON.parse(b)))

  const intervals = []
  for (const node of nodes) {
    const phase = Math.random() * T // lệch pha để các node không cùng gửi một lúc
    if (proto === 'heartbeat') {
      for (const m of node.members.values()) m.last = 0
      setTimeout(() => {
        const beat = () => { for (const id of node.members.keys()) send(node, id, { t: 'hb' }) }
        const check = () => { if (!node.alive) return; for (const [id, m] of node.members) if (m.state === 'alive' && now() - m.last > HB_TIMEOUT && now() > HB_TIMEOUT + T) declareDead(node, id) }
        intervals.push(setInterval(() => (paused(node) ? null : beat()), T))
        intervals.push(setInterval(() => (paused(node) ? node.deferred.includes(check) || node.deferred.push(check) : check()), T / 4))
      }, phase)
    } else {
      const tick = () => probe(node)
      setTimeout(() => intervals.push(setInterval(() => (paused(node) ? node.deferred.includes(tick) || node.deferred.push(tick) : tick()), T)), phase)
    }
  }
  let sentAtKill = 0
  setTimeout(() => { nodes[KILLED].alive = false; sentAtKill = stats.sent }, killAt)
  await sleep(duration)
  for (const i of intervals) clearInterval(i)
  for (const node of nodes) { node.alive = false; node.timers.forEach(clearTimeout); for (const m of node.members.values()) clearTimeout(m.timer); node.sock.close() }

  const survivors = nodes.filter((x) => x.id !== KILLED)
  // Coi node 0 là chết trước lúc nó chết thật cũng là báo nhầm
  const ofKilled = stats.deadDecl.filter((d) => d.target === KILLED && d.observer !== KILLED && d.t >= killAt).map((d) => d.t - killAt)
  const preKill = stats.deadDecl.filter((d) => d.target === KILLED && d.observer !== KILLED && d.t < killAt).length
  const falsePairs = stats.deadDecl.filter((d) => d.observer !== KILLED && (d.target !== KILLED || d.t < killAt))
  return {
    msgsPerNodePerSec: sentAtKill / n / (killAt / 1000),
    first: ofKilled.length ? Math.min(...ofKilled) : null,
    all: ofKilled.length === survivors.length ? Math.max(...ofKilled) : null,
    knew: ofKilled.length,
    preKill,
    survivors: survivors.length,
    falseNodes: new Set(falsePairs.map((d) => d.target)).size,
    falsePairs: falsePairs.length,
    slowAccused: pause ? falsePairs.filter((d) => d.target === SLOW).length : 0,   // số node coi node bị treo là chết
    slowAccusing: pause ? falsePairs.filter((d) => d.observer === SLOW).length : 0  // số node còn sống mà node bị treo coi là chết
  }
}

const fmt = (v) => (v === null ? 'chưa xong' : `${Math.round(v).toLocaleString('vi-VN')} ms`)
const LABEL = { heartbeat: 'heartbeat tới mọi node', swim: 'SWIM', 'swim+suspect': 'SWIM + suspect' }

console.log(`\n=== 1. Tải mạng và thời gian phát hiện theo số node (chu kỳ ${T} ms, không mất gói, node 0 chết ở giây thứ 2) ===`)
for (const n of [10, 30, 60]) {
  for (const proto of ['heartbeat', 'swim']) {
    const r = await run({ proto, n })
    console.log(`${String(n).padStart(2)} node, ${LABEL[proto].padEnd(24)} ${r.msgsPerNodePerSec.toFixed(0).padStart(4)} gói/node/giây; node đầu tiên biết sau ${fmt(r.first)}, mọi node biết sau ${fmt(r.all)}`)
  }
}

console.log('\n=== 2. 30 node, mất 5% gói ===')
for (const [proto, k] of [['heartbeat', K], ['swim', 0], ['swim', K], ['swim+suspect', K]]) {
  const r = await run({ proto, n: 30, loss: 0.05, k })
  const label = proto === 'swim' && k === 0 ? 'SWIM, không có ping-req' : LABEL[proto]
  const real = r.preKill ? `${r.preKill}/${r.survivors} node đã coi nó là chết từ trước khi nó chết thật` : `mọi node biết sau ${fmt(r.all)} (${r.knew}/${r.survivors})`
  console.log(`${label.padEnd(26)} báo nhầm ${String(r.falsePairs).padStart(3)} lần (${r.falseNodes} node còn sống bị coi là chết); node chết thật: ${real}`)
}

console.log('\n=== 3. 30 node, một node còn sống nhưng bị treo 700 ms ở giây thứ 3 (giống GC pause dài), không mất gói, 3 lượt mỗi cách ===')
for (const [proto, sp] of [['heartbeat', SUSPECT_PERIODS], ['swim', SUSPECT_PERIODS], ['swim+suspect', SUSPECT_PERIODS], ['swim+suspect', 10]]) {
  const rs = []
  for (let i = 0; i < 3; i++) rs.push(await run({ proto, n: 30, pause: 700, suspectPeriods: sp }))
  const label = proto === 'swim+suspect' ? `SWIM + suspect ${sp * T / 1000} s` : LABEL[proto]
  console.log(`${label.padEnd(26)} node bị treo bị coi là chết bởi ${rs.map((r) => r.slowAccused).join(', ')} /28 node; sau khi hết treo, nó coi ${rs.map((r) => r.slowAccusing).join(', ')} node còn sống là chết; node chết thật: mọi node biết sau ${rs.map((r) => fmt(r.all)).join(', ')}`)
}