Khi nhiều request cùng đọc → quyết định → ghi trên cùng row, race condition rất dễ xảy ra — trừ stock, charge ví, đặt ghế là 3 ví dụ kinh điển. select_for_update() phát SELECT ... FOR UPDATE → khoá hàng đó bên trong transaction đến khi commit/rollback.
Request khác cố đụng cùng row phải đứng chờ.
from django.db import transaction
def reserve_seat(seat_id, user):
with transaction.atomic():
seat = Seat.objects.select_for_update().get(id=seat_id)
if seat.holder_id is not None:
raise ValueError('Seat already taken')
seat.holder = user
seat.save()Không có lock thì 2 request đọc thấy holder = None cùng lúc → cả 2 cùng set → 1 bị ghi đè. Lỗi này lặng lẽ, không exception, gần như không debug được nếu không có log chi tiết.
Thêm 3 option hay dùng: nowait=True không chờ mà raise DatabaseError ngay; skip_locked=True bỏ qua row đang khoá (hợp với queue kiểu "worker nào grab job trước thì xử lý"); of=('self',) chỉ khoá bảng chính khi có JOIN, đỡ khoá lan sang bảng liên quan.
Điểm dễ vấp: select_for_update() bắt buộc nằm trong transaction.atomic() (ngoài atomic là Django raise lỗi luôn), và SQLite không hỗ trợ. Lock chỉ release khi transaction kết thúc — nếu giữa atomic block bạn gọi API ngoài hay sleep thì lock kéo dài theo, mọi user khác phải xếp hàng. Cách phòng tốt nhất là giữ atomic block thật ngắn, chỉ làm đúng phần cần lock rồi commit.
When many requests read → decide → write the same row, race conditions are easy (decrement stock, debit a wallet, claim a seat). select_for_update() emits SELECT ... FOR UPDATE → locks that row within the transaction until commit/rollback.
Other requests trying the same row have to wait.
from django.db import transaction
def reserve_seat(seat_id, user):
with transaction.atomic():
seat = Seat.objects.select_for_update().get(id=seat_id)
if seat.holder_id is not None:
raise ValueError('Seat already taken')
seat.holder = user
seat.save()Without the lock: 2 requests both see holder = None at the same time → both set → one overwrites the other (silent corruption, very hard to debug).
Useful options:
- nowait=True — do not wait, raise DatabaseError immediately if the lock is held.
- skip_locked=True — skip locked rows; fits a queue pattern ("whichever worker grabs a job first processes it").
- of=('self',) — only lock the main table when joining, not the entire related set.
Pitfall: select_for_update() must run inside transaction.atomic() — outside it is meaningless and Django raises an error. SQLite does not support it. Locks release when the transaction ends — if the transaction is too long (external API call, sleep) → locks held longer → bottleneck for other users. Keep atomic blocks short.