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.
- 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?
- 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.
- 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?
- 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?
Module tiếp theo: Module 7 — EXPLAIN & query optimization
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.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên