Chuyển đến nội dung chính (Skip to content)
Kiến trúc hệ thống

Idempotency key với PostgreSQL: chống tạo đơn trùng khi retry và chạy nhiều API

Idempotency key với PostgreSQL: chống tạo đơn trùng khi retry và chạy nhiều API

Dùng unique constraint và một transaction để giữ order cùng response. Chạy 12 lời gọi đồng thời, thử payload xung đột và kiểm tra cách ly tenant.

Client gửi yêu cầu tạo đơn, server commit thành công nhưng response bị mất. Người dùng bấm lại hoặc SDK tự retry. Nếu API tạo thêm một dòng order, lỗi mạng đã biến thành lỗi dữ liệu nghiệp vụ. Vô hiệu hóa nút bấm trên giao diện không giải quyết request đến từ ứng dụng di động, worker hoặc một replica khác.

Lab này triển khai idempotency key bằng unique constraint và một transaction PostgreSQL. Bạn sẽ chạy 12 lời gọi đồng thời, kiểm tra cùng một order ID được trả lại, rồi thử tái sử dụng key với payload khác. Ví dụ chỉ tạo đơn trong database; nó không gọi cổng thanh toán hay thực hiện giao dịch tài chính.

1. Chốt phạm vi của key và kết quả cần giữ

Key thuộc bộ ba tenant, operation và giá trị do client tạo. Cùng key ở hai tenant không được trả response của nhau. Operation có phiên bản để một thao tác tạo đơn không vô tình dùng chung namespace với thao tác hoàn hàng hoặc một hợp đồng request đã thay đổi.

Một key, một giao dịch tạo đơn: API xác thực tenant; Unique index; Cùng transaction; COMMIT
Một key, một giao dịch tạo đơn. Chỉ bảo vệ side effect trong cùng database; không tự chống thu tiền trùng ở dịch vụ bên ngoài. Có thể mở ảnh gốc để phóng to.

Idempotency key không phải thông tin xác thực. Tenant phải lấy từ phiên đăng nhập hoặc credential đã kiểm tra, không tin trường tenant trong JSON client gửi. Khi nối hàm mẫu vào HTTP handler, còn phải giới hạn body, kiểm quyền trên SKU, ánh xạ lỗi và chặn nội dung không đúng schema trước khi chạy transaction.

Tài liệu idempotency của Stripe là một ví dụ thực tế về việc gắn key với tham số và trả lại kết quả, nhưng chính sách giữ key và lưu lỗi phụ thuộc từng API. Trong bài, chỉ kết quả tạo đơn đã commit được lưu. Lỗi validation không giữ key; lỗi transaction rollback cho phép retry cùng key.

2. Chuẩn bị database lab và các bảng

Cần Docker Compose, Node.js 24 và npm. Tạo thư mục mới, lưu file Compose bên dưới thành compose.yaml. Tài khoản lab là dữ liệu mẫu công khai, không dùng cho production. Nếu cổng 15432 đang do lab khác chiếm, dừng lab đó trước; không đổi cấu hình database thật để chạy bài.

Image được ghim bằng digest từ lần chạy lab để tránh tag thay đổi làm lệch kết quả. Chỉ chạy cấu hình này trong môi trường thử nghiệm riêng.

File: idempotency.compose.yaml

name: thanhnh-architecture-20261020
services:
  postgres:
    image: postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24
    environment:
      POSTGRES_USER: lab
      POSTGRES_PASSWORD: lab-only-password
      POSTGRES_DB: arch_lab
    ports: ["127.0.0.1:15432:5432"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U lab -d arch_lab"]
      interval: 2s
      timeout: 2s
      retries: 30

File: idempotency-setup.sh

node --version  # dùng Node.js 24
npm init -y
npm install pg@8.23.1
docker compose up -d --wait

Lưu DDL vào idempotency.sql, rồi dùng lệnh nạp SQL ở cuối khối setup. Bảng request có khóa chính ghép; bảng order chứa side effect cần chống lặp. Cột response là JSON đã chuẩn hóa theo hợp đồng trả về, không chứa secret hay toàn bộ bản ghi người dùng.

File: idempotency.sql

CREATE TABLE IF NOT EXISTS idem_requests (
  tenant_id text NOT NULL,
  operation text NOT NULL,
  idem_key text NOT NULL,
  request_hash text NOT NULL,
  response jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, operation, idem_key)
);
CREATE TABLE IF NOT EXISTS idem_orders (
  id uuid PRIMARY KEY,
  tenant_id text NOT NULL,
  sku text NOT NULL,
  quantity integer NOT NULL CHECK (quantity > 0)
);

File: idempotency-load.sh

docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U lab -d arch_lab < idempotency.sql

Không chọn mô hình “SELECT kiểm tra rồi INSERT nếu chưa có” mà thiếu unique constraint. Hai transaction đều có thể đọc trạng thái chưa tồn tại trước khi một bên insert. Ràng buộc tại database mới là điểm phân xử chung cho mọi process API.

3. Viết transaction tạo đơn và lưu response

Tạo db.mjs. Kết nối được cố định vào loopback và database arch_lab để ví dụ không vô tình đọc production .env. Khi tích hợp ứng dụng thật, thay bằng cấu hình được quản lý an toàn; đồng thời tách credential dùng test và production.

File: db.mjs

import pg from 'pg';
// Fixed local lab target; this file never reads production .env.
export const pool = new pg.Pool({
  connectionString: 'postgres://lab:lab-only-password@127.0.0.1:15432/arch_lab',
  max: 10, connectionTimeoutMillis: 2000, statement_timeout: 3000,
});

Tiếp theo lưu idempotency.mjs. Toàn bộ BEGIN, INSERT, UPDATE và COMMIT phải dùng cùng một client lấy từ pool, đúng yêu cầu trong tài liệu transaction của node-postgres. Dùng pool.query cho từng câu trong một transaction có thể đưa câu lệnh sang các connection khác nhau.

File: idempotency.mjs

import { createHash, randomUUID } from 'node:crypto';
import { pool } from './db.mjs';
export async function createOrder(tenant, key, input) {
  if (typeof tenant !== 'string' || !tenant || typeof key !== 'string' || !/^[A-Za-z0-9_-]{8,80}$/.test(key)) throw new Error('invalid idempotency key');
  if (!input || Object.keys(input).sort().join(',') !== 'quantity,sku' ||
      (typeof input.sku !== 'string' || !/^[A-Z0-9_-]{1,40}$/.test(input.sku)) ||
      !Number.isInteger(input.quantity) || input.quantity < 1 || input.quantity > 100) {
    throw new Error('invalid order');
  }
  const canonical = JSON.stringify({ sku: input.sku, quantity: input.quantity });
  const hash = createHash('sha256').update(canonical).digest('hex');
  const client = await pool.connect();
  try {
    await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
    await client.query("SET LOCAL lock_timeout = '1000ms'");
    const inserted = await client.query(`
      INSERT INTO idem_requests(tenant_id, operation, idem_key, request_hash)
      VALUES ($1, 'create-order-v1', $2, $3)
      ON CONFLICT DO NOTHING RETURNING idem_key`, [tenant, key, hash]);
    if (!inserted.rowCount) {
      const { rows: [saved] } = await client.query(`
        SELECT request_hash, response FROM idem_requests
        WHERE tenant_id=$1 AND operation='create-order-v1' AND idem_key=$2`, [tenant, key]);
      if (saved.request_hash !== hash) {
        await client.query('ROLLBACK');
        return { status: 409, body: { error: 'key reused with different payload' } };
      }
      await client.query('COMMIT');
      return { status: 201, body: saved.response, replayed: true };
    }
    const body = { id: randomUUID(), sku: input.sku, quantity: input.quantity };
    await client.query('INSERT INTO idem_orders VALUES ($1,$2,$3,$4)',
      [body.id, tenant, body.sku, body.quantity]);
    await client.query(`UPDATE idem_requests SET response=$3
      WHERE tenant_id=$1 AND operation='create-order-v1' AND idem_key=$2`, [tenant, key, body]);
    await client.query('COMMIT');
    return { status: 201, body, replayed: false };
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally { client.release(); }
}

Hash cái gì để retry không bị hiểu nhầm?

Mẫu chỉ chấp nhận sku và quantity, rồi dựng lại một object với thứ tự trường cố định. Hai JSON khác thứ tự key nhưng cùng ý nghĩa cho ra cùng chuỗi chuẩn hóa. Trường dư bị từ chối thay vì âm thầm bỏ qua; quantity phải là số nguyên trong giới hạn, không tự đổi chuỗi “2” thành số.

Không dùng JSON.stringify trực tiếp trên body tùy ý rồi coi đó là canonicalization cho mọi schema. Mảng, số thập phân, thời gian và trường mặc định cần quy tắc riêng. Với API có giá tiền, nên định nghĩa đơn vị tiền nhỏ nhất và cách xử lý làm tròn ngay từ hợp đồng. SHA-256 ở đây để phát hiện khác payload, không dùng thay xác thực hoặc mã hóa dữ liệu.

4. Đọc đúng diễn biến khi hai request đến cùng lúc

Request A giành được khóa unique và tiếp tục tạo đơn. Request B insert cùng bộ khóa sẽ chờ transaction đang tranh chấp kết thúc. Nếu A commit, INSERT của B không thêm dòng; câu SELECT sau đó đọc response đã commit. Nếu A rollback, B có thể giành key và tự thực hiện tạo đơn.

Hai request đến đồng thời: Request A; Request B; A COMMIT; B tiếp tục
Hai request đến đồng thời. Luồng minh họa dùng Read Committed; lock timeout phải trả lỗi có thể retry, không tạo key mới. Có thể mở ảnh gốc để phóng to.

Hành vi trên được viết cho Read Committed. Theo tài liệu isolation PostgreSQL 17, các statement kế tiếp có thể nhìn snapshot mới. Không chuyển nguyên thuật toán sang Repeatable Read hoặc Serializable rồi giả định không có serialization failure; các mức đó cần chiến lược retry transaction phù hợp.

ON CONFLICT của PostgreSQL xử lý tranh chấp unique, nhưng không phải hàng đợi không giới hạn. Lab đặt lock_timeout một giây. Nếu hết thời gian, HTTP adapter nên trả lỗi tạm thời phù hợp và yêu cầu client retry cùng key, không tạo một key khác để vượt tranh chấp.

5. Chạy thử đồng thời, xung đột payload và cách ly tenant

Lưu script bên dưới vào idempotency-demo.mjs, sau đó chạy bằng Node 24. Mỗi lần chạy sinh một key mới để có thể lặp thí nghiệm mà không xóa bảng. Script kiểm tra điều kiện bằng assert, không dựa vào việc nhìn log rồi đoán.

File: idempotency-demo.mjs

import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { createOrder } from './idempotency.mjs';
import { pool } from './db.mjs';
const key = randomUUID();
try {
  const results = await Promise.all(Array.from({ length: 12 }, () =>
    createOrder('tenant-a', key, { sku: 'BOOK', quantity: 2 })));
  assert.equal(new Set(results.map((r) => r.body.id)).size, 1);
  assert.equal(results.filter((r) => !r.replayed).length, 1);
  assert.equal((await createOrder('tenant-a', key, { sku: 'BOOK', quantity: 3 })).status, 409);
  assert.equal((await createOrder('tenant-b', key, { sku: 'BOOK', quantity: 2 })).replayed, false);
  console.log('PASS: one order per tenant/key; payload mismatch returns 409');
} finally { await pool.end(); }

File: idempotency-run.sh

node idempotency-demo.mjs

Trong kiểm thử cục bộ của bài, 12 lời gọi cùng key trả cùng một ID, chỉ một kết quả là lần thực hiện mới. Tái sử dụng key với quantity khác trả 409; đổi tenant tạo một phạm vi độc lập. Đây là bằng chứng cho đường code trong lab, không chứng minh mọi side effect của một API tích hợp sau này đều được bảo vệ.

Quyết định khi key đã tồn tại: Chuẩn hóa payload; Hash giống nhau; Hash khác nhau; Hết thời gian giữ key
Quyết định khi key đã tồn tại. Việc xóa key quá sớm có thể cho phép retry tạo đơn mới. Retention là quyết định nghiệp vụ. Có thể mở ảnh gốc để phóng to.

6. Những điểm dễ hỏng khi đưa vào hệ thống thật

Đừng gọi dịch vụ ngoài trong transaction rồi kỳ vọng rollback đủ

Nếu gửi email hoặc thu tiền trước khi COMMIT, rollback database không thu hồi tác động ở bên ngoài. Nếu gọi sau COMMIT rồi process chết trước khi gọi, tác động có thể bị bỏ sót. Với việc cần chuyển sang worker, ghi outbox cùng order và xử lý event trùng ở consumer; đó là bài transactional outbox trong cùng danh mục.

Retention không chỉ là một câu DELETE

Xóa request key sau một ngày có thể khiến retry muộn tạo đơn mới. Chọn khoảng giữ theo cửa sổ retry thực tế, quy định nghiệp vụ và chi phí lưu response. Nếu mã đơn hàng hoặc external reference phải duy nhất dài hạn, đặt thêm ràng buộc nghiệp vụ trên order. Key kỹ thuật ngắn hạn không thay thế quy tắc đó.

Mẫu không có job xóa key nhằm giữ thí nghiệm dễ đọc. Khi bổ sung cleanup, không xóa dòng đang bị transaction giữ, không xóa trước khi client hết quyền retry, và ghi rõ hành vi sau retention trong tài liệu API. Dữ liệu response lưu lâu cũng cần chính sách truy cập, mã hóa và xóa theo loại dữ liệu thực tế.

Giữ hợp đồng response ổn định

Lab luôn trả status 201 cho cả lần đầu và replay, kèm cờ replayed trong kết quả hàm để HTTP adapter quan sát. Cần chọn và công bố chính sách nhất quán. Nếu response có thêm trường động như thời gian hiện tại hoặc URL có chữ ký, phải phân biệt phần cần lưu bất biến với phần cần tái tạo có kiểm quyền.

Ở nhiều khu vực địa lý hoặc nhiều database writer độc lập, unique constraint tại một database không còn là điểm phân xử toàn cục. Cần định tuyến key về cùng authority, dùng kho dữ liệu có bảo đảm phù hợp hoặc thay đổi hợp đồng. Không thể kéo dài bảo đảm local bằng cách chỉ thêm một lớp cache Redis trước nó.

7. Theo dõi và triển khai từng bước

  1. Thêm schema và thử tranh chấp trên staging trước. Đo thời gian chờ khóa và số connection bị giữ, không chỉ đo request thành công.
  2. Ghi operation, request ID, kết quả new/replay/conflict và mã lỗi DB. Không log raw key hoặc response riêng tư nếu không cần thiết.
  3. Kiểm tra mất response sau commit bằng client thử lại cùng key; xác nhận ID không đổi và side effect không tăng.
  4. Chuẩn bị xử lý lỗi lock timeout và mất connection. Rollback không được biến thành lệnh retry không giới hạn trong handler.
  5. Khi đổi phiên bản API, giữ đường đọc response cũ đủ lâu cho các retry đang tồn tại. Rollback code không đồng nghĩa được xóa bảng request.

Khi thấy nhiều replay, hãy đối chiếu độ trễ và lỗi mạng trước khi coi đó là hành vi xấu của người gọi. Khi thấy nhiều conflict, kiểm tra client có tái dùng key cho thao tác mới hay không. Hai chỉ số này kể hai câu chuyện vận hành khác nhau.