Giao diện
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
awaitlà một chỗ để request khác chen vào. Request AawaitcâuSELECTvà chờ database trả lời. Trong lúc đó event loop chạy tiếp request B, và B cũngSELECTđú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-mutexchẳ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
Bước tiếp theo:
SELECT stock FROM products WHERE id = 1
Request B
Bước tiếp theo:
SELECT stock FROM products WHERE id = 1
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ắc | Ràng buộc |
|---|---|
| Mỗi email một tài khoản | UNIQUE (email) |
| Mỗi user dùng một voucher một lần | UNIQUE (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ần | UNIQUE (showtime_id, seat_id) |
| Mỗi ngày một báo cáo | UNIQUE (report_date) |
| Tồn kho không âm | CHECK (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
UNIQUEhoặ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 UPDATEtrê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 IGNOREcủ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 NOTHINGchỉ bỏ qua đúng lỗi trùng. - Với PostgreSQL,
ON CONFLICT DO NOTHING RETURNING idkhông trả dòng nào khi bị trùng. Muốn “lấy hoặc tạo” thì sau đóSELECTlạ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ặcSKIP 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 UPDATElấ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ênINSERTlạ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ì INSERTHai 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ách | Làm thế nào | Hợp khi |
|---|---|---|
| Lock dòng cha | SELECT 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ày | Cách chung nhất, chạy được trên cả MySQL lẫn PostgreSQL |
| Chia thành ô thời gian cố định | Bả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_gist | Dù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 retry | Nhiề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án | Cách chính | Thê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ùng | UPDATE … WHERE còn đủ và kiểm tra affected rows | CHECK (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 nhau | Cột version | Báo người dùng “dữ liệu đã thay đổi” |
| Logic phức tạp trên vài dòng có sẵn | SELECT … FOR UPDATE, lock theo thứ tự id | NOWAIT 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ần | Idempotency key | |
| Tài nguyên ngoài database | Advisory lock, distributed lock |
Ghi nhớ
- Node.js một thread vẫn có race condition, vì mỗi
awaitlà chỗ để request khác chen vào và app thường chạy nhiều process. - 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).
- Quy tắc nào viết được thành
UNIQUEhayCHECKthì để database giữ, và app bắt lỗi trùng khoá. - Gộp điều kiện vào
UPDATE … WHERErồi kiểm tra affected rows; đây là cách rẻ nhất cho tồn kho, số dư, trạng thái. FOR UPDATEkhô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.- Request gửi lại hai lần là bài toán idempotency: lưu idempotency key có
UNIQUEtrong cùng transaction.
Nguồn tham khảo
Đã đối chiếu (đọc lại nguồn ngày 2026-10-15):
| Nguồn | Dùng cho phần |
|---|---|
| MySQL 8.4 Reference Manual, INSERT … ON DUPLICATE KEY UPDATE | Affected 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 Locking | Gap lock của nhiều transaction cùng tồn tại được; lệnh INSERT chờ gap lock |
| PostgreSQL docs, INSERT | ON 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 Types | Exclusion constraint chặn lịch đặt chồng giờ, cần btree_gist |
| PostgreSQL docs, Error Codes | 23505 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_timeoutmặc định 50 giây (chương 7 đã nhắc);INSERT IGNOREbiến một số lỗi thành warning; SERIALIZABLE biếnSELECTthường thànhFOR SHARE(chương 6 đã nhắc). - MySQL ở READ COMMITTED không dùng gap lock cho
SELECT … FOR UPDATEtì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
mysql2trảerr.code = 'ER_DUP_ENTRY'vàaffectedRows; thư việnpgtrảerr.code = '23505'vàrowCount. - Stripe dùng header
Idempotency-Key.