Race Condition trong Backend API
Có một loại bug backend khá nguy hiểm:
Code nhìn đúng, API trả
200 OK, nhưng production lại tạo ra dữ liệu sai.
Ví dụ:
- Một registration có 2 invoices.
- User double-click và tạo 2 records.
- Admin và cron job cùng approve một registration.
- Hai người cùng update score và ghi đè dữ liệu của nhau.
Đây thường là Race Condition.
Race Condition xảy ra khi hai request/process cùng xử lý một dữ liệu gần như cùng lúc.
Ví dụ endpoint approve:
1. Get registration 2. Check status = REGISTERED 3. Create invoice 4. Update status = APPROVEDNếu Admin và Cron cùng chạy:
Admin Cron Read REGISTERED Read REGISTERED Create Invoice A Create Invoice B Update APPROVED Update APPROVEDKết quả:
Registration = APPROVED Invoice A = CREATED Invoice B = CREATED 😱Không có exception. Database không crash.
Nhưng dữ liệu đã sai.
Vì sao if không đủ?
Code này nhìn hoàn toàn hợp lý:
if (registration.status === 'APPROVED')
{ return;
}
await createInvoice();
registration.status = 'APPROVED';
await save(registration);Vấn đề là hai request có thể cùng đọc:
Request A → REGISTERED Request B → REGISTEREDtrước khi request nào kịp update database.
Pattern nguy hiểm là:
READ → CHECK → WRITEkhi có nhiều request chạy đồng thời.
1. Transaction
Transaction giúp nhiều database operations:
hoặc cùng thành công hoặc cùng rollbackVí dụ:
BEGIN;
UPDATE registrations SET status = 'APPROVED';
INSERT INTO invoices (...);
COMMIT;Nếu insert invoice lỗi → rollback toàn bộ.
Nhưng cần nhớ:
Transaction không tự động ngăn hai request chạy cùng lúc.
Nếu chỉ một process được phép xử lý registration tại một thời điểm, có thể dùng:
SELECT * FROM registrations WHERE id = 100 FOR UPDATE;Flow:
Request A → lock registration → create invoice → APPROVED → commit Request B → wait → đọc lại dữ liệu → thấy APPROVED → stopTrong TypeORM thường là:
.setLock('pessimistic_write')Lock phù hợp với các nghiệp vụ như:
Invoice Payment Registration Approval Booking RefundRace Condition không chỉ đến từ hai user.
Ví dụ:
POST /payment → server xử lý thành công → response bị timeout → frontend retryBackend có thể nhận cùng operation hai lần.
Giải pháp phổ biến là Idempotency Key:
Idempotency-Key: abc-123Request đầu:
abc-123 → create invoice INV001Request retry:
abc-123 → đã xử lý → trả lại INV001Không tạo invoice mới.
Nói đơn giản:
Cùng một operation gửi nhiều lần nhưng chỉ được xử lý một lần.
4. Database Constraint
Nếu business rule nói:
Một registration chỉ có một invoice.
Hãy để database bảo vệ rule đó:
UNIQUE (registration_id)Nếu hai request cùng insert:
Request A → success Request B → duplicate key errorĐây là lớp bảo vệ rất quan trọng.
Code:
if (!invoice) { createInvoice(); }chỉ là check.
Còn:
UNIQUE(registration_id)mới là guarantee.
Nên nhớ gì?
Có thể nhớ đơn giản:
Một API quan trọng thường cần kết hợp nhiều lớp:
Request ↓ Idempotency ↓ Transaction ↓ Database Lock ↓ Business Logic ↓ Unique ConstraintRace Condition không chỉ đến từ User
Trong production, cùng một record có thể được xử lý bởi:
Admin Cron Job Worker Webhook Mobile App API Retry Another ServerVí dụ hệ thống registration:
Admin → approve Cron → auto approve Webhook → update payment Worker → sync invoiceTất cả có thể chạy gần như cùng lúc.
Cách test
Đừng test kiểu:
click wait clickHãy gửi request đồng thời:
await Promise.all([ approveRegistration(100), approveRegistration(100), ]);Sau đó kiểm tra:
expect(invoiceCount).toBe(1);Kết luận
Race Condition thường không phải vì code sai cú pháp.
Nó xảy ra vì developer nghĩ hệ thống chạy:
one request at a timetrong khi production thực tế là:
many requests at the same timeKhi review một API quan trọng, hãy luôn hỏi:
What happens if this API is called twice at exactly the same time?
Nếu chưa trả lời được câu hỏi này, rất có thể endpoint đó vẫn còn một Race Condition đang chờ xuất hiện trong production.