Mini-challenge M06 — Race condition khi 2 user cùng assign task
TaskFlow: 2 user click 'Assign me to task #42' cùng lúc. Reproduce race với naive code, fix 3 cách (SELECT FOR UPDATE / atomic UPDATE WHERE / Serializable + retry), 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 có error log. Không có exception. Chỉ có wrong data — silent failure điển hình của race condition.
Bài này không dạy concept mới. Bài này reproduce race, đếm tần suất, rồi fix 3 cách khác nhau để bạn hiểu tradeoff thực chiến. Cuối bài: 4 script runnable + bảng so sánh + 5 câu tự kiểm tra.
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:
-- 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ó:
# Cai node-postgres neu chua co
npm install pg
Mỗi script đọc DATABASE_URL từ environment:
export DATABASE_URL="postgresql://user:password@localhost:5432/taskflow"
Step 1 — Reproduce race với naive code
Naive code nhìn đúng về logic, sai về concurrency:
// Naive attempt -- has race condition
const { rows } = await client.query('SELECT assignee_id FROM tasks WHERE id = 42');
if (rows[0].assignee_id === null) {
await client.query('UPDATE tasks SET assignee_id = $1 WHERE id = 42', [meId]);
}
Vấn đề: giữa SELECT và UPDATE là 2 statement độc lập, không có transaction bảo vệ. 2 user cùng chạy: cả 2 SELECT thấy NULL → cả 2 UPDATE thành công → user nào UPDATE sau ghi đè → user UPDATE trước nghĩ mình đã assign nhưng thực tế mất ownership. Không có error, không có warning.
Script race-repro.js — chạy 100 lần, đếm race:
// race-repro.js
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
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) {
// Cuong tinh delay nho de race xay ra ro hon (mo phong network/app latency)
await new Promise(r => setTimeout(r, 5));
await pool.query(
'UPDATE tasks SET assignee_id = $1 WHERE id = $2',
[userId, taskId]
);
return true; // ta nghi da assign thanh cong
}
return false;
}
async function runOnce(taskId) {
// Reset task ve unassigned truoc moi round
await pool.query('UPDATE tasks SET assignee_id = NULL WHERE id = $1', [taskId]);
// 2 user race dong thoi
const [aClaimed, bClaimed] = await Promise.all([
naiveAssign(1, taskId),
naiveAssign(2, taskId),
]);
// Check thuc te ai duoc assign sau khi ca 2 chay xong
const { rows } = await pool.query(
'SELECT assignee_id FROM tasks WHERE id = $1',
[taskId]
);
const actualAssignee = rows[0].assignee_id;
// Race: ca 2 tra ve true (nghi minh assign thanh cong)
// nhung chi 1 nguoi thuc su giu task (last write wins)
const isRace = aClaimed && bClaimed;
return { isRace, actualAssignee, aClaimed, bClaimed };
}
let raceCount = 0;
for (let i = 0; i < 100; i++) {
const { isRace } = await runOnce(42);
if (isRace) raceCount++;
}
console.log(`Race rate: ${raceCount}/100`);
console.log(raceCount > 50
? 'HIGH race rate -- naive code is unsafe for concurrent assign'
: 'Low race rate -- try increasing delay or iteration count'
);
await pool.end();
Cách chạy:
node race-repro.js
Expected output:
Race rate: 80-95/100
HIGH race rate -- naive code is unsafe for concurrent assign
Race rate cao (80–95%) vì delay 5ms cố ý làm window overlap rõ ràng. Trong production không có delay này nhưng race vẫn xảy ra — chỉ ít thấy hơn, khó debug hơn. Silent failure trong production còn nguy hiểm hơn vì không ai biết nó đang xảy ra.
Default isolation level của PostgreSQL là Read Committed. Ở RC, mỗi statement thấy snapshot mới nhất tại thời điểm nó chạy. SELECT và UPDATE là 2 statement tách biệt — không có gì đảm bảo state không thay đổi giữa hai câu. Transaction bắt đầu ở RC chỉ giúp rollback khi có lỗi — không ngăn race giữa statement.
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. Sau khi tx 1 commit, tx 2 SELECT lại và thấy assignee_id đã được gán — điều kiện IS NULL không còn đúng → return false.
Script fix1-for-update.js:
// fix1-for-update.js
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
async function safeAssign(userId, taskId) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// SELECT FOR UPDATE: lay row lock ngay lap tuc
// Tx thu 2 se block o day cho den khi tx 1 COMMIT hoac ROLLBACK
const { rows } = await client.query(
'SELECT assignee_id FROM tasks WHERE id = $1 FOR UPDATE',
[taskId]
);
if (rows[0].assignee_id === null) {
// Chi tx nay dang chay -- tx thu 2 van dang bi block
await new Promise(r => setTimeout(r, 5)); // simulate work
await client.query(
'UPDATE tasks SET assignee_id = $1 WHERE id = $2',
[userId, taskId]
);
await client.query('COMMIT');
return true; // da assign thanh cong
}
// Task da co nguoi assign (thay sau khi duoc unblock)
await client.query('COMMIT');
return false;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async function runOnce(taskId) {
await pool.query('UPDATE tasks SET assignee_id = NULL WHERE id = $1', [taskId]);
const [aClaimed, bClaimed] = await Promise.all([
safeAssign(1, taskId),
safeAssign(2, taskId),
]);
const isRace = aClaimed && bClaimed;
return { isRace, aClaimed, bClaimed };
}
let raceCount = 0;
for (let i = 0; i < 100; i++) {
const { isRace } = await runOnce(42);
if (isRace) raceCount++;
}
console.log(`Race rate: ${raceCount}/100`);
// Expected: Race rate: 0/100
await pool.end();
Cách chạy:
node fix1-for-update.js
Expected output:
Race rate: 0/100
Tại sao 0 race? FOR UPDATE serializes access vào row. Timeline thực tế:
Tx A: BEGIN → SELECT FOR UPDATE (acquire lock) → check NULL → UPDATE → COMMIT (release lock)
|
Tx B: BEGIN → SELECT FOR UPDATE (BLOCK... wait for Tx A) ────────────────── unblock
→ SELECT sees assignee_id = user A
→ condition false → COMMIT → return false
Tx B không bao giờ thấy NULL sau khi Tx A commit → không có double-assign.
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 click "Assign me" sẽ bị block chờ — UX chậm. Nếu app bị crash giữa chừng mà không ROLLBACK, lock giữ cho đến khi PostgreSQL timeout. 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. PostgreSQL đảm bảo một UPDATE statement là atomic — không có khoảng trống giữa check và write. Tx thứ 2 execute sau khi Tx 1 commit sẽ thấy assignee_id IS NOT NULL → WHERE clause không match → rowCount = 0 → return false.
Script fix2-conditional-update.js:
// fix2-conditional-update.js
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
async function atomicAssign(userId, taskId) {
// 1 statement duy nhat, atomic -- khong can BEGIN/COMMIT
// WHERE assignee_id IS NULL la dieu kien tranh race
// RETURNING id xac nhan row duoc cap nhat (rowCount = 1 neu thanh cong)
const { rowCount } = await pool.query(
`UPDATE tasks
SET assignee_id = $1
WHERE id = $2 AND assignee_id IS NULL
RETURNING id`,
[userId, taskId]
);
return rowCount === 1; // true neu ta thang race, false neu da co nguoi khac assign
}
async function runOnce(taskId) {
await pool.query('UPDATE tasks SET assignee_id = NULL WHERE id = $1', [taskId]);
const [aClaimed, bClaimed] = await Promise.all([
atomicAssign(1, taskId),
atomicAssign(2, taskId),
]);
const isRace = aClaimed && bClaimed;
return { isRace, aClaimed, bClaimed };
}
let raceCount = 0;
for (let i = 0; i < 100; i++) {
const { isRace } = await runOnce(42);
if (isRace) raceCount++;
}
console.log(`Race rate: ${raceCount}/100`);
// Expected: Race rate: 0/100
await pool.end();
Cách chạy:
node fix2-conditional-update.js
Expected output:
Race rate: 0/100
So sánh với Fix 1: Đơn giản hơn đáng kể — không cần BEGIN/COMMIT, không cần explicit lock, không cần checkout client từ pool riêng. PostgreSQL xử lý atomic single-statement ở engine level: row-level write lock được acquire và release trong cùng một statement, không có window nào cho transaction khác chen vào. Tx thứ 2 "thấy" kết quả của Tx 1 sau khi Tx 1 commit — khi đó assignee_id IS NULL không còn đúng nữa.
RETURNING id không bắt buộc về correctness nhưng hữu ích: cho phép phân biệt "assign thành công" (rowCount=1) vs "đã có người assign" (rowCount=0) mà không cần query thêm. Pattern này cũng hoạt động đúng với autocommit (default trong node-postgres pool).
Step 4 — Fix 3: Serializable isolation + retry on 40001
Serializable isolation (SSI) đảm bảo kết quả của các transaction concurrent tương đương với việc chúng chạy tuần tự. PostgreSQL dùng SIREAD locks để detect serialization conflicts — khi conflict được phát hiện tại COMMIT, một transaction nhận error 40001 (serialization_failure) và phải retry.
Script fix3-serializable-retry.js:
// fix3-serializable-retry.js
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// Retry helper voi exponential backoff + jitter
// 40001 = serialization_failure (SSI conflict detected at COMMIT)
async function retryOnSerializationFail(fn, maxAttempts = 5) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
if (err.code !== '40001') throw err; // chi retry tren SSI conflict
if (attempt === maxAttempts - 1) throw new Error('Max retry exceeded');
// Exponential backoff voi jitter de tranh thundering herd
const delay = Math.min(50 * 2 ** attempt, 500) + Math.random() * 50;
await new Promise(r => setTimeout(r, delay));
}
}
}
async function ssiAssign(userId, taskId) {
return await retryOnSerializationFail(async () => {
const client = await pool.connect();
try {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
const { rows } = await client.query(
'SELECT assignee_id FROM tasks WHERE id = $1',
[taskId]
);
if (rows[0].assignee_id === null) {
await client.query(
'UPDATE tasks SET assignee_id = $1 WHERE id = $2',
[userId, taskId]
);
}
// COMMIT co the throw 40001 neu PG detect serialization conflict
await client.query('COMMIT');
return rows[0].assignee_id === null; // true neu ta nghi minh assign duoc
} catch (err) {
// ROLLBACK truoc khi re-throw de retry handler bat
await client.query('ROLLBACK').catch(() => {});
throw err;
} finally {
client.release();
}
});
}
async function runOnce(taskId) {
await pool.query('UPDATE tasks SET assignee_id = NULL WHERE id = $1', [taskId]);
const [aClaimed, bClaimed] = await Promise.all([
ssiAssign(1, taskId),
ssiAssign(2, taskId),
]);
const isRace = aClaimed && bClaimed;
return { isRace, aClaimed, bClaimed };
}
let raceCount = 0;
for (let i = 0; i < 100; i++) {
const { isRace } = await runOnce(42);
if (isRace) raceCount++;
}
console.log(`Race rate: ${raceCount}/100`);
// Expected: Race rate: 0/100
// (1 tx nhan 40001, retry, lan sau SELECT thay assignee_id da set, return false)
await pool.end();
Cách chạy:
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? Fix 1 và Fix 2 dùng explicit lock (FOR UPDATE) hoặc atomic conditional — khi conflict, transaction thứ 2 chờ (block) rồi tiếp tục, không có error. SSI không block — nó để cả 2 transaction chạy song song, phát hiện conflict tại COMMIT, abort 1 transaction với error 40001. Caller phải bắt error đó và retry từ đầu. Đây là tradeoff của optimistic concurrency: throughput cao hơn khi conflict ít, cần thêm code retry khi conflict xảy ra.
So sánh 3 cách — tradeoff
| Cách | Cú pháp | Lock | Retry cần? | Complexity | Recommend khi |
|---|---|---|---|---|---|
| Fix 1 — FOR UPDATE | BEGIN + SELECT FOR UPDATE + UPDATE + COMMIT | Row-level explicit (pessimistic) | Không | Trung bình | Transaction có nhiều bước phụ thuộc state đã đọc |
| Fix 2 — Atomic UPDATE WHERE | 1 statement duy nhất | Implicit row write lock | Không | Đơn giản nhất | Decision logic có thể encode vào WHERE clause |
| Fix 3 — Serializable + retry | BEGIN ISOLATION LEVEL SERIALIZABLE + retry helper | SIREAD predicate locks (optimistic) | Có (40001) | Cao nhất | Workflow phức tạp đa table, không thể atomic 1 statement |
Recommendation cho scenario assign task:
Ưu tiên Fix 2 — atomic UPDATE WHERE vì business rule đơn giản ("chỉ assign nếu chưa có ai") hoàn toàn encode được vào WHERE assignee_id IS NULL. Không cần BEGIN, không cần explicit lock, không cần retry. Ít code = ít bug.
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 đa table, đặc biệt khi read-write conflict cross nhiều bảng và không thể gom vào atomic single statement. Chấp nhận retry overhead đổi lấy correctness guarantee mạnh nhất.
- PostgreSQL Documentation — SELECT FOR UPDATE / FOR SHARE: full lock mode reference, NOWAIT và SKIP LOCKED options.
- PostgreSQL Documentation — Serializable Isolation Level: cơ chế SSI, SIREAD locks, serialization_failure và retry pattern.
- PostgreSQL Documentation — Explicit Locking: row-level vs table-level locks, deadlock detection, advisory locks.
Tự kiểm tra
Q1Vì sao naive SELECT-then-UPDATE bị race ở Read Committed (default isolation)? Giải thích cơ chế snapshot.▸
Ở Read Committed, mỗi statement thấy một snapshot mới nhất tại thời điểm statement đó bắt đầu — không phải snapshot tại thời điểm transaction bắt đầu. SELECT và UPDATE là 2 statement riêng biệt, mỗi cái có snapshot độc lập.
Timeline race: Tx A SELECT (snapshot: assignee_id = NULL) → Tx B SELECT (snapshot: assignee_id = NULL) → Tx A UPDATE → Tx B UPDATE (ghi đè). Cả 2 SELECT đều thấy NULL vì chúng chạy trước khi bất kỳ UPDATE nào commit. Không có gì bảo vệ khoảng trống giữa SELECT và UPDATE của cùng một caller.
Đây không phải bug của Read Committed — đây là expected behavior. Race condition là do code không dùng concurrency primitive phù hợp (lock hoặc atomic operation) để bảo vệ read-modify-write cycle.
Q2Phân biệt SELECT FOR UPDATE và atomic UPDATE WHERE — cơ chế lock khác nhau thế nào? Khi nào nên dùng cái nào?▸
SELECT FOR UPDATE acquire row-level exclusive lock tại thời điểm SELECT, giữ đến COMMIT. Transaction khác cố SELECT FOR UPDATE cùng row sẽ bị block (wait). Đây là pessimistic locking — giả định conflict thường xảy ra, block trước.
Atomic UPDATE WHERE không có explicit lock trước. PostgreSQL chạy UPDATE như một atomic operation — acquire write lock chỉ trong thời gian write, release ngay sau khi statement xong. Transaction thứ 2 chạy UPDATE ngay sau: nếu Tx 1 đã commit, WHERE clause không match → rowCount=0 → return false. Không có blocking window.
Dùng FOR UPDATE khi transaction cần đọc state, dùng state đó trong nhiều bước logic (vd: check assignee → tính toán → insert log → update), và cần đảm bảo state không thay đổi giữa các bước. Các bước đó phải nằm trong cùng DB transaction.
Dùng atomic UPDATE WHERE khi toàn bộ decision logic có thể encode vào WHERE clause của một statement. Đơn giản hơn, không cần transaction wrapper, không block caller khác.
Q3Vì sao Fix 3 (Serializable) cần retry mà Fix 1 và Fix 2 không cần? Giải thích cơ chế conflict resolution khác nhau.▸
Fix 1 — FOR UPDATE: pessimistic locking. Tx B block ngay tại SELECT FOR UPDATE, chờ Tx A commit. Sau đó Tx B tiếp tục, đọc state mới, tự quyết định không assign. Không có error — chỉ có wait.
Fix 2 — Atomic UPDATE: database engine handle conflict internally trong một statement. Tx B UPDATE sau Tx A commit, WHERE không match, rowCount=0. Không có error, không có wait đáng kể.
Fix 3 — Serializable: optimistic concurrency. PostgreSQL để cả 2 transaction chạy song song (không block). Tại COMMIT, PG dùng SIREAD locks để detect: "nếu 2 transaction này chạy tuần tự thì kết quả phải khác kết quả hiện tại". Nếu phát hiện conflict, PG abort 1 transaction với error code 40001 (serialization_failure). Caller bắt error đó và retry từ đầu. Retry lần sau sẽ SELECT thấy assignee_id đã được set → không assign nữa.
Rule: bất kỳ code nào dùng Serializable isolation phải có retry loop xử lý 40001. Thiếu retry, user sẽ thấy error thay vì correct behavior.
Q4Trong scenario assign task có 1 dòng UPDATE đơn giản, atomic UPDATE WHERE thắng FOR UPDATE ở điểm nào? Có tradeoff nào không?▸
Atomic UPDATE WHERE thắng về:
- Simplicity: 1 statement, không cần BEGIN/COMMIT, không cần pool.connect() riêng. Ít code = ít surface area cho bug.
- Concurrency: không giữ explicit lock — các caller khác không bị block. Throughput cao hơn khi có nhiều request concurrent trên nhiều task khác nhau.
- Connection pool: không cần checkout dedicated client, dùng được pool query trực tiếp — quan trọng khi connection limit thấp.
- Crash safety: không có half-committed state. Statement tự-commit (autocommit). Không cần lo ROLLBACK khi app crash giữa chừng.
Tradeoff — khi FOR UPDATE tốt hơn: nếu sau khi assign task cần insert audit log trong cùng transaction (INSERT INTO task_history ...), hoặc check thêm điều kiện (vd: "chỉ assign nếu user có quyền trong project này"), phải đọc thêm row khác trước khi UPDATE. Lúc đó business logic không thể encode gọn trong 1 WHERE clause → cần transaction multi-step → FOR UPDATE phù hợp hơn.
Q5Nếu workflow là 'assign task + insert audit log + send notification request', cách nào trong 3 phù hợp nhất? Vì sao?▸
Fix 1 — SELECT FOR UPDATE phù hợp nhất, với một số lưu ý quan trọng.
Workflow gồm 3 bước phụ thuộc nhau:
- Check task chưa có người assign (read)
- Assign task + insert audit log (write, phải atomic cùng nhau)
- Send notification request (I/O bên ngoài)
Bước 1 và 2 phải nằm trong cùng transaction với FOR UPDATE để đảm bảo state không thay đổi giữa check và write. Bước 2 không thể encode vào 1 statement (có 2 write khác nhau). Fix 2 (atomic UPDATE) chỉ bảo vệ 1 statement, không cover INSERT audit log trong cùng atomic operation.
Lưu ý quan trọng với bước 3: không gọi send notification trong transaction. Notification call là I/O bên ngoài (HTTP request, message queue), không rollback được nếu transaction fail sau đó. Pattern đúng: COMMIT transaction trước, rồi gọi notification sau. Nếu notification fail, xử lý riêng (retry queue) — không ảnh hưởng tính toàn vẹn của data.
Fix 3 (Serializable) cũng đúng về correctness nhưng thêm retry complexity không cần thiết — FOR UPDATE đơn giản hơn và đủ cho workflow này.
Module tiếp theo: Module 7 — EXPLAIN & query optimization
Bài này có giúp bạn hiểu bản chất không?