PostgreSQL — Schema, Index & Internals
10/17
Bài 10 / 17~20 phútTransactions, ACID & MVCCMiễn phí… lượt xem

Mini-challenge — race condition khi 2 user cùng assign task

Hai user bấm Assign me cùng lúc: tái hiện race bằng script, rồi fix ba cách (SELECT FOR UPDATE, atomic UPDATE WHERE, Serializable + retry) và so sánh tradeoff.

TaskFlow vừa ra feature "Assign me" — user click nút, hệ thống gán task cho họ nếu task chưa có người nhận. PM nói: "Simple thôi, check assignee_id rồi UPDATE."

Bạn viết 10 dòng code, deploy. Mọi thứ trông ổn. Nhưng một tuần sau có user report: "Mình click Assign me, thấy tên mình hiện lên rồi biến mất." Không error log, không exception, chỉ có dữ liệu sai: kiểu silent failure kinh điển của race condition.

Bài này không có concept mới: bạn tái hiện race, đếm tần suất, rồi fix theo ba cách để thấy tradeoff thực chiến; cuối bài là 4 script chạy được trên một khung đo chung và một bảng so sánh. Ba cách fix dùng đúng ba thứ module này đã dạy: hành vi snapshot của READ COMMITTED ở bài MVCC, SELECT FOR UPDATE và retry ở bài locks, và cái giá của một transaction giữ lock quá lâu ở bài VACUUM. Cùng bài toán này ở góc nhìn nhiều instance app: blog Check trùng bằng SELECT trên nhiều instance.

Setup — schema + dependencies

Schema TaskFlow canonical cho bài này. Task #42 phải tồn tại và có assignee_id = NULL trước mỗi lần chạy:

SQL
-- TaskFlow schema (canonical)
CREATE TABLE IF NOT EXISTS users (
  id         BIGSERIAL PRIMARY KEY,
  name       TEXT NOT NULL,
  email      TEXT NOT NULL UNIQUE
);

CREATE TABLE IF NOT EXISTS tasks (
  id           BIGSERIAL PRIMARY KEY,
  project_id   BIGINT NOT NULL,
  assignee_id  BIGINT REFERENCES users(id),  -- nullable: chua assign
  title        TEXT NOT NULL,
  status       TEXT NOT NULL CHECK (status IN ('todo', 'doing', 'done', 'archived')),
  due_at       TIMESTAMPTZ,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Seed users va task de demo
INSERT INTO users (id, name, email) VALUES
  (1, 'Alice', '[email protected]'),
  (2, 'Bob',   '[email protected]')
ON CONFLICT (id) DO NOTHING;

INSERT INTO tasks (id, project_id, assignee_id, title, status)
VALUES (42, 1, NULL, 'Design login page', 'todo')
ON CONFLICT (id) DO UPDATE SET assignee_id = NULL;

-- Reset task ve unassigned truoc moi lan chay
UPDATE tasks SET assignee_id = NULL WHERE id = 42;

Cài node-postgres nếu chưa có:

Bash
# Cai node-postgres neu chua co
npm install pg

Mỗi script đọc DATABASE_URL từ environment và dùng chung một khung đo harness.js: reset task, cho hai user chạy song song, đếm số vòng mà cả hai cùng tin là mình assign được:

Bash
export DATABASE_URL="postgresql://user:password@localhost:5432/taskflow"
JavaScript
// harness.js -- khung chung cho ca 4 script
import pg from 'pg';
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

export async function measure(assignFn, taskId = 42, rounds = 100) {
  let raceCount = 0;
  for (let i = 0; i < rounds; i++) {
    await pool.query('UPDATE tasks SET assignee_id = NULL WHERE id = $1', [taskId]);
    const [a, b] = await Promise.all([assignFn(1, taskId), assignFn(2, taskId)]);
    if (a && b) raceCount++;
  }
  console.log(`Race rate: ${raceCount}/${rounds}`);
  await pool.end();
}

Step 1 — Vì sao naive SELECT-then-UPDATE sinh race?

Naive code nhìn đúng về logic, sai về concurrency: giữa SELECT và UPDATE là hai statement độc lập, không gì bảo vệ khoảng hở ở giữa.

Trước khi chạy: với 2 user và delay 5 ms, bạn đoán trong 100 vòng có bao nhiêu vòng mà cả hai cùng nhận true? Ghi con số ra rồi mới đọc tiếp.

JavaScript
// race-repro.js
import { pool, measure } from './harness.js';

async function naiveAssign(userId, taskId) {
  const { rows } = await pool.query('SELECT assignee_id FROM tasks WHERE id = $1', [taskId]);
  if (rows[0].assignee_id !== null) return false;
  await new Promise(r => setTimeout(r, 5));   // simulate app-side latency
  await pool.query('UPDATE tasks SET assignee_id = $1 WHERE id = $2', [userId, taskId]);
  return true;
}

await measure(naiveAssign);

Cách chạy:

Bash
node race-repro.js

Expected output:

Race rate: 80-95/100

Cả hai SELECT thấy NULL, cả hai UPDATE "thành công", user ghi sau đè lên user ghi trước (last write wins), và user ghi trước tưởng mình giữ task. Race rate cao (80–95%) vì delay 5 ms cố ý làm hai cửa sổ chồng lên nhau. Production không có delay này nhưng race vẫn xảy ra, chỉ hiếm hơn và khó debug hơn, và vì không ai thấy lỗi nên chẳng ai biết nó đang xảy ra.

Pitfall — Read Committed không bảo vệ giữa 2 statement

Default isolation level của PostgreSQL là Read Committed. Ở RC, mỗi statement chụp snapshot mới tại thời điểm nó chạy (bài MVCC, mục 5). SELECT và UPDATE là hai statement tách biệt, không gì đảm bảo state đứng yên giữa hai câu. Bọc chúng trong một transaction ở RC chỉ giúp rollback khi có lỗi, nó không ngăn race giữa các statement.

Tự làm trước

Đề bài: viết lại naiveAssign (giữ nguyên harness.js) để Race rate: 0/100, bằng công cụ bạn đã học ở bài 04–06 để đóng khoảng hở giữa đọc và ghi. Có ít nhất ba cách; hãy tự chọn một, chạy 100 vòng, rồi mới mở lời giải bên dưới để đối chiếu.

Step 2 — Fix 1: SELECT FOR UPDATE + check + UPDATE

SELECT FOR UPDATE lấy row-level lock ngay khi SELECT. Transaction thứ 2 cố SELECT FOR UPDATE cùng row sẽ bị block cho đến khi transaction thứ 1 COMMIT hoặc ROLLBACK. Tx 1 commit xong, tx 2 mới đọc được row, thấy assignee_id đã có người nhận, điều kiện IS NULL sai nên hàm trả false.

Gợi ý: khoá row trước khi kiểm tra, trong cùng một transaction.

Lời giải Fix 1 — fix1-for-update.js

Cách chạy:

Bash
node fix1-for-update.js

Expected output:

Race rate: 0/100

Vì sao ra 0 race? FOR UPDATE xếp hàng mọi truy cập vào cùng một row, nên transaction sau phải đợi. Timeline thực tế:

Sơ đồ tuần tự ba cột Tx A, row task 42, Tx B: A chạy SELECT FOR UPDATE và lấy lock; B chạy SELECT FOR UPDATE và bị chặn; A thấy assignee_id NULL, UPDATE assignee_id = 1 rồi COMMIT nhả lock; B hết chặn và thấy assignee_id = 1 nên điều kiện IS NULL sai, không UPDATE, COMMIT và return false — không có khoảnh khắc nào hai transaction cùng thấy NULL

Tx B không bao giờ thấy NULL sau khi Tx A commit, nên không thể gán trùng.

Pitfall — FOR UPDATE giữ lock trong suốt transaction

FOR UPDATE lock row từ SELECT đến COMMIT. Nếu transaction còn làm nhiều việc khác (gọi API bên ngoài, xử lý file, v.v.) sau SELECT FOR UPDATE, lock giữ trong suốt thời gian đó. User khác bấm "Assign me" thì phải đứng chờ, giao diện đơ theo. App crash giữa chừng mà chưa ROLLBACK thì lock nằm đó cho tới khi TCP keepalive phát hiện connection chết (có thể hàng giờ), trừ khi bạn đã đặt idle_in_transaction_session_timeout / statement_timeout để cắt nó. Dùng FOR UPDATE phù hợp khi transaction ngắn và toàn bộ logic nằm trong DB transaction.

Step 3 — Fix 2: UPDATE ... WHERE ... RETURNING (atomic conditional)

Thay vì SELECT-then-UPDATE, gộp luôn điều kiện kiểm tra vào câu UPDATE. Một câu UPDATE là nguyên tử, giữa lúc kiểm tra và lúc ghi không có khe hở nào. Tx thứ 2 chạy sau khi Tx 1 commit thì thấy assignee_id IS NOT NULL, mệnh đề WHERE không khớp dòng nào, rowCount = 0, hàm trả false.

Gợi ý: gộp kiểm tra và ghi vào một câu lệnh duy nhất.

Lời giải Fix 2 — fix2-conditional-update.js

Cách chạy:

Bash
node fix2-conditional-update.js

Expected output:

Race rate: 0/100

So sánh với Fix 1: đơn giản hơn hẳn: không cần BEGIN/COMMIT, không cần explicit lock, không cần checkout client riêng từ pool. Row lock vẫn có, nhưng PG lấy và nhả nó trong cùng một statement. Tx thứ 2 chờ ở UPDATE, và khi Tx 1 commit, ở READ COMMITTED nó đánh giá lại WHERE trên phiên bản mới: assignee_id IS NULL không còn đúng, 0 row.

RETURNING id không bắt buộc về correctness nhưng hữu ích: cho phép phân biệt "gán thành công" (rowCount = 1) với "đã có người nhận" (rowCount = 0) mà khỏi query thêm. Pattern này cũng hoạt động đúng với autocommit (default trong node-postgres pool).

Thử ngẫmbusiness rule đổi thành "chỉ assign nếu chưa có ai VÀ user đó còn dưới 5 task đang mở". Fix 2 atomic UPDATE WHERE còn viết được gọn như cũ không?

Step 4 — Fix 3: Serializable isolation + retry on 40001

Ở REPEATABLE READ và SERIALIZABLE, transaction giữ một snapshot cố định từ lúc bắt đầu. Khi Tx B cố UPDATE một row mà Tx A đã UPDATE và commit trong lúc B đang chờ, PG không cho B ghi đè lên phiên bản B chưa từng thấy: B nhận 40001 (serialization_failure, "could not serialize access due to concurrent update") ngay tại câu UPDATE, không đợi tới COMMIT. Luật này tên first-updater-wins, có ngay từ REPEATABLE READ.

SERIALIZABLE bổ sung SSI (Serializable Snapshot Isolation): mỗi lần transaction đọc, PG để lại một SIREAD lock, dấu vết "tx này đã đọc row/range này" dùng để phát hiện phụ thuộc chéo giữa các transaction; nhờ đó nó bắt được cả write skew, tình huống hai transaction cùng đọc một điều kiện rồi mỗi bên ghi một row khác nhau khiến điều kiện chung bị vi phạm (hai bác sĩ cùng thấy "còn 2 người trực" rồi cùng xin nghỉ). Loại conflict đó do SSI phát hiện, có thể ngay giữa transaction hoặc lúc COMMIT; với ví dụ một row này, first-updater-wins là đòn quyết định. Bốn anomaly và mức isolation nào chặn được gì: Isolation level và 4 anomaly.

Gợi ý: để PostgreSQL tự phát hiện xung đột ở mức isolation cao nhất, và chuẩn bị chạy lại khi nó báo.

Lời giải Fix 3 — fix3-serializable-retry.js

Cách chạy:

Bash
node fix3-serializable-retry.js

Expected output:

Race rate: 0/100

Tại sao cần retry ở Fix 3 mà Fix 1 và Fix 2 không cần? Ở READ COMMITTED (Fix 1, Fix 2), transaction thứ 2 chờ rồi đánh giá lại trên phiên bản mới và đi tiếp, không có lỗi. Ở SERIALIZABLE, snapshot của Tx B đã cố định từ lúc BEGIN; khi A commit, B không được phép "đọc lại" mà bị abort với 40001, và mọi conflict SSI chéo row cũng kết thúc bằng cùng mã lỗi. Caller phải bắt và chạy lại từ đầu. Đó là tradeoff của optimistic concurrency: ít conflict thì không ai phải chờ ai, có conflict thì trả giá bằng một vòng retry.

So sánh 3 cách — tradeoff

CáchCú phápLockCần retry?Độ phức tạpNên dùng khi
Fix 1 — FOR UPDATEBEGIN + SELECT FOR UPDATE + UPDATE + COMMITRow-level explicit (pessimistic)KhôngTrung bìnhTransaction có nhiều bước phụ thuộc state đã đọc
Fix 2 — Atomic UPDATE WHERE1 statement duy nhấtImplicit row write lockKhôngĐơn giản nhấtDecision logic có thể encode vào WHERE clause
Fix 3 — Serializable + retryBEGIN ISOLATION LEVEL SERIALIZABLE + retry helperSnapshot cố định + SIREAD (optimistic)Có (40001)Cao nhấtWorkflow phức tạp đa table, không thể atomic 1 statement

Với bài toán assign task:

Chọn Fix 2: luật "chỉ assign nếu chưa có ai" nhét gọn vào WHERE assignee_id IS NULL, khỏi BEGIN, khỏi lock tường minh, khỏi retry.

Dùng Fix 1 — FOR UPDATE khi transaction cần làm nhiều bước phụ thuộc: ví dụ "check assignee → insert audit log → send notification → update task" trong cùng một transaction. FOR UPDATE đảm bảo state không thay đổi giữa các bước.

Dùng Fix 3 — Serializable cho workflow phức tạp chạm nhiều bảng, nơi xung đột đọc-ghi chéo bảng và không gom được vào một câu lệnh. Bạn trả bằng chi phí retry, đổi lại được mức đảm bảo chặt nhất.

Thử ngẫmworkflow đổi thành "assign task + insert audit log + gửi notification". Cách nào trong ba cách hợp nhất, và bước gửi notification nên nằm trong hay ngoài transaction?

Deep Dive — đọc thêm

Bài tiếp theo: EXPLAIN — đọc kế hoạch thực thi

Bài này có giúp bạn hiểu bản chất không?

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

EXPLAIN & EXPLAIN ANALYZE — đọc query plan của PostgreSQL