Skip to content

Chống trùng & race condition ​

Race condition là khi kết quả phụ thuộc vào thứ tự chạy của các request đồng thời. Chạy lần lượt thì đúng, nhưng hai request chen nhau đúng lúc thì sai. Trong backend, lỗi này hay gặp ở hai dạng: hai tài khoản trùng email, và bán quá số hàng trong kho.

Lỗi này khó phát hiện vì khi test bằng tay, mỗi lần chỉ có một request. Nó chỉ lộ ra khi có tải thật, hoặc khi người dùng bấm nút hai lần.

Node.js chạy một thread, vẫn có race condition? ​

Có, vì hai lý do:

  • Mỗi await là một chỗ để request khác chen vào. Request A await câu SELECT và chờ database trả lời. Trong lúc đó event loop chạy tiếp request B, và B cũng SELECT đúng dòng đó. Code của từng request vẫn chạy tuần tự, nhưng các bước của hai request thì xen kẽ nhau.
  • App thường chạy nhiều process. Có PM2 cluster, nhiều container, nhiều server. Mutex trong bộ nhớ (thư viện async-mutex chẳng hạn) chỉ có tác dụng trong một process.

Vì vậy chỗ chặn race condition phải nằm ở nơi mọi request cùng đi qua, mà thường là database.

Hai mẫu lỗi ​

Check-then-act: kiểm tra rồi mới làm ​

js
const [rows] = await db.execute('SELECT id FROM users WHERE email = ?', [email])
if (rows.length > 0) throw new ConflictError('Email đã được dùng')
await db.execute('INSERT INTO users (email, name) VALUES (?, ?)', [email, name])

Hai request đăng ký cùng email chạy song song. Cả hai cùng SELECT, cùng thấy chưa có ai dùng email đó, rồi cùng INSERT. Kết quả là hai tài khoản trùng email. Những bài toán cùng dạng: mỗi user chỉ dùng voucher một lần, một ghế chỉ bán cho một người, mỗi ngày chỉ tạo một báo cáo.

Read-modify-write: đọc, tính trong app, rồi ghi ​

js
const [[p]] = await db.execute('SELECT stock FROM products WHERE id = ?', [id])
if (p.stock <= 0) throw new Error('Hết hàng')
await db.execute('UPDATE products SET stock = ? WHERE id = ?', [p.stock - 1, id])
await db.execute('INSERT INTO orders (product_id, user_id) VALUES (?, ?)', [id, userId])

Kho còn 1 sản phẩm. Hai request cùng đọc được stock = 1, cùng ghi stock = 0, và cùng tạo đơn. Kết quả là bán 2 đơn cho 1 sản phẩm, trong khi số tồn kho vẫn trông hợp lý. Lỗi này gọi là lost update, vì một trong hai lần trừ kho bị ghi đè. Số dư ví, lượt thích, số chỗ còn trống đều dễ gặp lỗi này (MySQL chương 6).

Thử: hai request chạy xen kẽ
Cách viết:
Request A
Request B
products.stock1
orderstrống

Gợi ý: bấm A, B, A, B… để hai request chen nhau. Với cách viết có lỗi, chạy lần lượt thì đúng nhưng chạy xen kẽ thì sai; đó là lý do race condition hiếm khi lộ ra khi test bằng tay. Ở cách FOR UPDATE, B bị chặn ở bước đầu cho tới khi A COMMIT.

Có năm cách chặn, xếp theo thứ tự nên nghĩ tới trước.

1. Ràng buộc của database là chốt chặn cuối ​

Nếu một quy tắc viết được thành ràng buộc thì hãy để database giữ nó. Database kiểm tra ràng buộc ngay lúc ghi và tự xử lý các lệnh ghi đồng thời, nên không có khe hở nào giữa lúc kiểm tra và lúc ghi.

Quy tắcRàng buộc
Mỗi email một tài khoảnUNIQUE (email)
Mỗi user dùng một voucher một lầnUNIQUE (user_id, voucher_id) trên bảng voucher_usages
Một ghế của một suất chiếu chỉ bán một lầnUNIQUE (showtime_id, seat_id)
Mỗi ngày một báo cáoUNIQUE (report_date)
Tồn kho không âmCHECK (stock >= 0)

Khi đã có UNIQUE thì bước SELECT kiểm tra trước chỉ còn để báo lỗi sớm, đẹp hơn. Chỗ chặn thật là INSERT, nên app phải bắt lỗi trùng khoá và trả lỗi dễ hiểu cho người dùng:

js
try {
  await db.execute('INSERT INTO users (email, name) VALUES (?, ?)', [email, name])
} catch (err) {
  // MySQL (mysql2): err.code === 'ER_DUP_ENTRY' (errno 1062)
  // PostgreSQL (pg): err.code === '23505'
  if (err.code === 'ER_DUP_ENTRY') throw new ConflictError('Email đã được dùng')
  throw err
}

Dùng soft delete thì UNIQUE (email) chặn cả email của tài khoản đã xoá; cách xử lý ở Soft delete.

2. Upsert: thêm nếu chưa có, cập nhật nếu đã có ​

Khi muốn “có rồi thì cập nhật, chưa có thì thêm”, đừng tự SELECT rồi chọn INSERT hay UPDATE. Hãy dùng một câu lệnh upsert để database làm cả hai việc một cách nguyên tử.

sql
-- MySQL: dùng alias cho dòng mới (hàm VALUES() đã deprecated)
INSERT INTO daily_stats (stat_date, page_views) VALUES ('2026-10-15', 1) AS new
ON DUPLICATE KEY UPDATE page_views = daily_stats.page_views + new.page_views;

-- PostgreSQL
INSERT INTO daily_stats (stat_date, page_views) VALUES ('2026-10-15', 1)
ON CONFLICT (stat_date) DO UPDATE SET page_views = daily_stats.page_views + EXCLUDED.page_views;

Những điểm cần nhớ:

  • Upsert dựa vào UNIQUE hoặc khoá chính, nên phải có ràng buộc trước.
  • MySQL trả affected rows bằng 1 khi thêm dòng mới, 2 khi cập nhật dòng cũ, 0 khi dòng giữ nguyên giá trị. App dựa vào đó để biết vừa xảy ra việc gì.
  • MySQL khuyên tránh ON DUPLICATE KEY UPDATE trên bảng có nhiều hơn một unique index, vì không rõ dòng nào sẽ bị cập nhật.
  • Tránh INSERT IGNORE của MySQL. Nó bỏ qua lỗi trùng khoá, nhưng cũng biến nhiều lỗi khác (sai kiểu, quá độ dài) thành warning, nên dữ liệu sai lọt vào mà không ai biết. PostgreSQL có ON CONFLICT DO NOTHING chỉ bỏ qua đúng lỗi trùng.
  • Với PostgreSQL, ON CONFLICT DO NOTHING RETURNING id không trả dòng nào khi bị trùng. Muốn “lấy hoặc tạo” thì sau đó SELECT lại.

3. Ghi có điều kiện (compare-and-set) ​

Gộp bước kiểm tra vào chính câu UPDATE, rồi xem có bao nhiêu dòng bị ảnh hưởng. Câu UPDATE khoá dòng trong lúc chạy và luôn đọc bản mới nhất đã commit, nên hai request không thể cùng thoả điều kiện.

sql
-- Trừ kho: 0 dòng bị ảnh hưởng nghĩa là hết hàng
UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0;

-- Chuyển trạng thái: chỉ đơn đang chờ mới được đánh dấu đã thanh toán
UPDATE orders SET status = 'paid' WHERE id = ? AND status = 'pending';

-- Voucher có giới hạn lượt dùng
UPDATE vouchers SET used = used + 1 WHERE code = ? AND used < usage_limit;
js
const [res] = await db.execute(
  "UPDATE orders SET status = 'paid' WHERE id = ? AND status = 'pending'", [orderId])
if (res.affectedRows === 0) {
  // đơn đã thanh toán rồi (webhook gửi lại) hoặc đã bị huỷ: không làm gì thêm
  return
}
// chỉ một request đi được tới đây
await sendReceiptEmail(orderId)

PostgreSQL (pg) trả số dòng trong result.rowCount.

Cách này hợp với hầu hết bài toán tồn kho, số dư, trạng thái đơn. Nó chỉ cần một câu lệnh, không giữ lock lâu, và không cần transaction riêng. Khi dữ liệu phải đọc ra để tính phức tạp trong app thì dùng cột version (optimistic locking): UPDATE … SET …, version = version + 1 WHERE id = ? AND version = ?. Nếu 0 dòng bị ảnh hưởng thì đọc lại và thử lại.

4. Lock dòng: SELECT … FOR UPDATE ​

Khi logic phải đọc vài dòng, tính toán trong app rồi mới ghi, thì lock các dòng đó trước. Request khác muốn lock cùng dòng phải chờ tới khi transaction này commit.

sql
BEGIN;
SELECT stock, price FROM products WHERE id = ? FOR UPDATE;
-- app kiểm tra, tính giảm giá, phí ship…
UPDATE products SET stock = stock - ? WHERE id = ?;
INSERT INTO orders (...) VALUES (...);
COMMIT;

Những điểm cần nhớ:

  • Lock theo thứ tự cố định để tránh deadlock. Ví dụ chuyển tiền từ A sang B, đồng thời có người chuyển từ B sang A. Nếu mỗi bên lock tài khoản gửi trước thì hai bên chờ nhau. Luôn lock tài khoản có id nhỏ hơn trước (Tiền & sổ cái, MySQL chương 7).
  • Không chờ lâu. MySQL mặc định chờ lock tới 50 giây. Với request web, dùng NOWAIT để báo lỗi ngay, hoặc SKIP LOCKED để bỏ qua dòng đang bị khoá, như khi làm hàng đợi job (Hàng đợi bằng database).
  • Giữ transaction ngắn. Không gọi API bên ngoài khi đang giữ lock.

Bẫy: lock một dòng chưa tồn tại ​

sql
BEGIN;
SELECT id FROM users WHERE email = 'an@x.vn' FOR UPDATE;  -- chưa có dòng nào
INSERT INTO users (email) VALUES ('an@x.vn');
COMMIT;

Nhiều người tưởng cách này chặn được việc đăng ký trùng, nhưng không phải vậy:

  • PostgreSQL, và MySQL ở mức READ COMMITTED: không có dòng nào để lock, nên cả hai request đều đi qua và cùng INSERT.
  • MySQL ở REPEATABLE READ (mặc định): FOR UPDATE lấy gap lock trên khoảng trống nơi dòng đó sẽ nằm. Gap lock của hai transaction không chặn nhau, nên cả hai cùng lấy được. Sau đó mỗi bên INSERT lại phải chờ gap lock của bên kia, và MySQL báo deadlock (lỗi 1213) cho một bên. Vẫn không có dòng trùng, nhưng người dùng nhận lỗi deadlock thay vì lỗi email đã dùng, và dưới tải cao sẽ có rất nhiều deadlock.

Trường hợp dòng chưa tồn tại thì dùng UNIQUE. Nếu cần lock thật thì lock một dòng cha luôn tồn tại, ở mục dưới.

5. Khi không có dòng nào để lock: write skew ​

Đặt phòng họp: một lịch đặt hợp lệ khi không trùng giờ với lịch đặt khác của cùng phòng.

sql
SELECT COUNT(*) FROM bookings
WHERE room_id = 3 AND start_at < '10:30' AND end_at > '09:00';
-- 0 thì INSERT

Hai người cùng đặt phòng 3, một người 9:00–10:00, một người 9:30–10:30. Cả hai cùng đếm ra 0, rồi cùng INSERT hai dòng khác nhau, nên UNIQUE không chặn được. FOR UPDATE cũng không chặn được vì lúc kiểm tra chưa có dòng nào để lock. Dạng lỗi này gọi là write skew: mỗi transaction đều đúng nếu chạy một mình, nhưng hai transaction chạy song song thì phá quy tắc chung.

Bốn cách xử lý:

CáchLàm thế nàoHợp khi
Lock dòng chaSELECT id FROM rooms WHERE id = 3 FOR UPDATE trước khi kiểm tra. Mọi lệnh đặt phòng 3 đều phải xếp hàng qua dòng nàyCách chung nhất, chạy được trên cả MySQL lẫn PostgreSQL
Chia thành ô thời gian cố địnhBảng room_slots(room_id, slot_start) có khoá chính hai cột. Đặt 9:00–10:30 thì INSERT ba ô 30 phút; trùng một ô là lỗi trùng khoáThời gian chia được thành ô đều nhau, như phòng họp hay lịch khám
Exclusion constraint (chỉ PostgreSQL)EXCLUDE USING GIST (room_id WITH =, during WITH &&) trên cột kiểu tstzrange; cần extension btree_gistDùng PostgreSQL và giờ đặt tuỳ ý
Isolation SERIALIZABLE (PostgreSQL)PostgreSQL tự phát hiện write skew và huỷ một transaction với lỗi 40001; app phải retryNhiều quy tắc phức tạp, khó lock tay. Ở MySQL, SERIALIZABLE biến mọi SELECT thành locking read và dễ deadlock
sql
-- PostgreSQL: database tự chặn hai lịch đặt chồng giờ của cùng một phòng
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE bookings (
  id      BIGINT PRIMARY KEY,
  room_id BIGINT NOT NULL,
  during  TSTZRANGE NOT NULL,
  EXCLUDE USING GIST (room_id WITH =, during WITH &&)
);

6. Advisory lock và distributed lock ​

Khi thứ cần bảo vệ không phải một dòng (gọi API bên ngoài, sinh file báo cáo), có thể lock theo tên:

sql
-- MySQL: lock theo chuỗi, chờ tối đa 5 giây
SELECT GET_LOCK('export:user:42', 5);
-- ...
SELECT RELEASE_LOCK('export:user:42');

-- PostgreSQL: lock theo số, tự release khi transaction kết thúc
SELECT pg_advisory_xact_lock(42);

Advisory lock gắn với connection, nên với connection pool phải acquire và release trên cùng một connection. Khi lock phải dùng chung giữa nhiều service hoặc không có database chung, xem Distributed lock. Trang đó cũng giải thích vì sao lock có TTL vẫn có thể bị hai process cùng giữ.

7. Request trùng: idempotency ​

Race condition là hai request khác nhau chen nhau. Còn một loại trùng khác: cùng một request được gửi hai lần, do người dùng bấm hai lần, client retry khi timeout, hay message broker gửi lại message. Mỗi lần chạy riêng đều đúng, nhưng chạy hai lần là trừ tiền hai lần.

Cách xử lý là cho mỗi request một idempotency key: client sinh một UUID và gửi kèm request; khi retry thì gửi lại đúng key cũ. Server ghi key vào một bảng có UNIQUE, trong cùng transaction với việc chính:

sql
CREATE TABLE idempotency_keys (
  idem_key    VARCHAR(64) PRIMARY KEY,
  status_code INT NULL,        -- NULL: đang xử lý
  response    TEXT NULL,
  created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
js
await withTransaction(async (tx) => {
  try {
    await tx.execute('INSERT INTO idempotency_keys (idem_key) VALUES (?)', [key])
  } catch (err) {
    if (err.code !== 'ER_DUP_ENTRY') throw err
    const [[row]] = await tx.execute('SELECT status_code, response FROM idempotency_keys WHERE idem_key = ?', [key])
    if (row.status_code === null) throw new ConflictError('Request đang được xử lý, thử lại sau')
    return reply(row.status_code, JSON.parse(row.response))   // trả đúng kết quả lần đầu
  }
  const result = await chargeWallet(tx, userId, amount)
  await tx.execute('UPDATE idempotency_keys SET status_code = 200, response = ? WHERE idem_key = ?',
    [JSON.stringify(result), key])
  return reply(200, result)
})
  • Consumer của message queue làm tương tự với bảng processed_messages(message_id PRIMARY KEY), ghi cùng transaction với việc xử lý message.
  • Dọn các key cũ bằng job định kỳ, ví dụ sau 24 giờ.
  • Stripe dùng đúng mô hình này với header Idempotency-Key. Phần lý thuyết về retry và idempotency nằm ở Xử lý lỗi & idempotency.

Chọn cách nào ​

Bài toánCách chínhThêm
Không cho trùng (email, voucher một lần, ghế)UNIQUE và bắt lỗi trùng khoáSELECT trước chỉ để báo lỗi sớm
Thêm hoặc cập nhật bộ đếm, thống kêUpsert
Trừ kho, trừ số dư, giới hạn lượt dùngUPDATE … WHERE còn đủ và kiểm tra affected rowsCHECK (stock >= 0) làm chốt chặn cuối
Chuyển trạng thái (pending → paid)UPDATE … WHERE status = trạng thái cũWebhook gửi lại thì 0 dòng, bỏ qua
Sửa bản ghi qua form, ít khi đụng nhauCột versionBáo người dùng “dữ liệu đã thay đổi”
Logic phức tạp trên vài dòng có sẵnSELECT … FOR UPDATE, lock theo thứ tự idNOWAIT hoặc timeout ngắn
Quy tắc trên một khoảng (lịch không chồng nhau)Lock dòng cha, hoặc exclusion constraint (PostgreSQL)
Cùng request gửi hai lầnIdempotency key
Tài nguyên ngoài databaseAdvisory lock, distributed lock

Ghi nhớ

  1. Node.js một thread vẫn có race condition, vì mỗi await là chỗ để request khác chen vào và app thường chạy nhiều process.
  2. Hai mẫu lỗi: kiểm tra rồi mới làm (check-then-act), và đọc, tính trong app rồi ghi (read-modify-write).
  3. Quy tắc nào viết được thành UNIQUE hay CHECK thì để database giữ, và app bắt lỗi trùng khoá.
  4. Gộp điều kiện vào UPDATE … WHERE rồi kiểm tra affected rows; đây là cách rẻ nhất cho tồn kho, số dư, trạng thái.
  5. FOR UPDATE không chặn được dòng chưa tồn tại; khi không có dòng nào để lock thì lock dòng cha hoặc dùng ràng buộc.
  6. Request gửi lại hai lần là bài toán idempotency: lưu idempotency key có UNIQUE trong cùng transaction.

Nguồn tham khảo ​

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

NguồnDùng cho phần
MySQL 8.4 Reference Manual, INSERT … ON DUPLICATE KEY UPDATEAffected rows 1/2/0, alias dòng mới, VALUES() deprecated, tránh dùng trên bảng có nhiều unique index
MySQL 8.4 Reference Manual, InnoDB LockingGap lock của nhiều transaction cùng tồn tại được; lệnh INSERT chờ gap lock
PostgreSQL docs, INSERTON CONFLICT DO NOTHING / DO UPDATE, EXCLUDED, upsert nguyên tử khi chạy đồng thời, RETURNING không trả dòng bị bỏ qua
PostgreSQL docs, Range TypesExclusion constraint chặn lịch đặt chồng giờ, cần btree_gist
PostgreSQL docs, Error Codes23505 unique_violation, 40001 serialization_failure

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

  • MySQL: mã lỗi 1062 ER_DUP_ENTRY, 1213 deadlock; innodb_lock_wait_timeout mặc định 50 giây (chương 7 đã nhắc); INSERT IGNORE biến một số lỗi thành warning; SERIALIZABLE biến SELECT thường thành FOR SHARE (chương 6 đã nhắc).
  • MySQL ở READ COMMITTED không dùng gap lock cho SELECT … FOR UPDATE tìm theo index (chương 6 đã nhắc).
  • PostgreSQL SERIALIZABLE (SSI) phát hiện write skew. Ví dụ write skew theo cách trình bày trong Designing Data-Intensive Applications (Kleppmann), chương 7.
  • Thư viện mysql2 trả err.code = 'ER_DUP_ENTRY' và affectedRows; thư viện pg trả err.code = '23505' và rowCount.
  • Stripe dùng header Idempotency-Key.