Race condition: hai request cùng cộng tiền vào một ví
Mình viết một API cộng tiền và bắn nhiều request cùng lúc. Nếu không dùng SELECT ... FOR UPDATE thì dữ liệu có thể bị sai. Vấn đề này trong lập trình gọi là gì? Và khi phỏng vấn hỏi "làm sao tránh" thì trả lời thế nào?
Đúng. Keyword lớn nhất là Concurrency / Race Condition, cụ thể hơn là Lost Update. Từ bài này đến hết phần lock, mình dùng chung một project ví dụ:
wallet-service (Spring Boot 3, chạy 2 instance sau load balancer)
├── POST /wallets/{id}/deposit cộng tiền ← bài này
├── POST /transfers chuyển tiền A→B ← bài Deadlock
├── GET /fees cache phí ← bài Lock
└── audit log: userId, traceId ← bài ThreadLocal
1. Bài toán
API cộng tiền viết theo kiểu "tự nhiên" nhất:
@Transactional
public void deposit(Long walletId, BigDecimal amount) {
Wallet w = walletRepository.findById(walletId).orElseThrow(); // đọc
w.setBalance(w.getBalance().add(amount)); // tính
walletRepository.save(w); // ghi
}
Test bằng Postman từng request thì đúng. Bắn 100 request song song thì số dư bị thiếu.
2. Vấn đề: Lost Update
Ví có 100. Request A cộng 50, request B cộng 30, chạy cùng lúc:
A đọc 100 B đọc 100
A tính 150 B tính 130
A ghi 150
B ghi 130 ← đè mất phần của A
Kết quả 130, đúng ra phải 180. Lỗi nằm ở chỗ đọc → tính → ghi là 3 bước rời nhau, request khác chen vào giữa được. @Transactional mặc định (READ COMMITTED) không chặn được chuyện này: mỗi transaction đọc dữ liệu đã commit, rồi ghi đè lên nhau.
3. Giải pháp
Có 3 cách ở tầng database, rất đáng nhớ:
- Atomic update: gộp đọc–tính–ghi thành một câu
UPDATE. DB tự đảm bảo một câu lệnh chạy nguyên vẹn trên dòng đó. - Pessimistic lock: khóa dòng ngay khi đọc (
SELECT ... FOR UPDATE). Ai đến sau phải chờ. - Optimistic lock: không khóa, thêm cột
version. Lúc ghi mới kiểm tra version có bị đổi không.
Vì vậy không phải cứ cộng tiền là bắt buộc phải tự SELECT FOR UPDATE. Phép tính đơn giản thì atomic update là đủ và nhanh nhất.
4. Code từng bước
Bước 1: Entity (có sẵn cột version cho optimistic lock):
@Entity
public class Wallet {
@Id Long id;
BigDecimal balance;
@Version Long version;
// getter/setter
}
Bước 2: Repository với cả 3 cách:
public interface WalletRepository extends JpaRepository<Wallet, Long> {
// Cách 1: atomic update
@Modifying
@Query("update Wallet w set w.balance = w.balance + :amount where w.id = :id")
int addBalance(Long id, BigDecimal amount);
// Trừ tiền: thêm điều kiện để không âm, 0 dòng = không đủ tiền
@Modifying
@Query("update Wallet w set w.balance = w.balance - :amount " +
"where w.id = :id and w.balance >= :amount")
int subtractBalance(Long id, BigDecimal amount);
// Cách 2: pessimistic lock → SELECT ... FOR UPDATE
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select w from Wallet w where w.id = :id")
Optional<Wallet> findForUpdate(Long id);
}
Bước 3: Service
@Service
public class WalletService {
private final WalletRepository repo;
public WalletService(WalletRepository repo) { this.repo = repo; }
// Cách 1: logic đơn giản
@Transactional
public void deposit(Long id, BigDecimal amount) {
if (repo.addBalance(id, amount) == 0) throw new WalletNotFoundException(id);
}
// Cách 2: logic nhiều bước (hạn mức ngày, trạng thái ví...)
@Transactional
public void depositWithLimit(Long id, BigDecimal amount) {
Wallet w = repo.findForUpdate(id).orElseThrow(); // giữ khóa dòng từ đây
checkDailyLimit(w, amount);
w.setBalance(w.getBalance().add(amount));
} // commit → nhả khóa
// Cách 3: optimistic, findById thường + @Version trên entity
@Transactional
public void depositOptimistic(Long id, BigDecimal amount) {
Wallet w = repo.findById(id).orElseThrow();
w.setBalance(w.getBalance().add(amount));
// Hibernate: UPDATE wallet SET balance=?, version=6 WHERE id=? AND version=5
// 0 dòng → ObjectOptimisticLockingFailureException → retry hoặc trả 409
}
}
Bước 4: Test bằng curl, bắn 100 request song song:
seq 1 100 | xargs -P 50 -I{} curl -s -X POST \
"http://localhost:8080/wallets/1/deposit?amount=10" -o /dev/null
curl http://localhost:8080/wallets/1
# code cũ (đọc-tính-ghi): balance thiếu, ví dụ 430 ← mất update
# atomic update: balance = 1000 ← đúng
5. Cách hoạt động
Với SELECT ... FOR UPDATE, request B bị chặn ngay ở bước đọc:
Request A Request B
BEGIN BEGIN
SELECT ... FOR UPDATE (lock row)
balance = 100 SELECT ... FOR UPDATE
↓ chờ, row đang bị A khóa
UPDATE balance = 150
COMMIT (nhả lock) ─────────────→ đọc được: balance = 150
UPDATE balance = 180
COMMIT
Với optimistic lock, hai bên cùng chạy, ai ghi sau thì thua:
A đọc (100, v5) B đọc (100, v5)
A ghi WHERE version=5 → OK, v6
B ghi WHERE version=5 → 0 dòng → exception → retry
6. So sánh
| Atomic update | Pessimistic | Optimistic | |
|---|---|---|---|
| Ví dụ | balance = balance + 50 | SELECT ... FOR UPDATE | WHERE id=? AND version=? |
| Hợp khi | Phép tính đơn giản | Logic nhiều bước, xung đột cao (ví hot) | Xung đột ít (sửa profile, cấu hình) |
| Cái giá | Không đưa logic phức tạp vào được | Request phải chờ, có thể deadlock | Phải retry khi xung đột |
7. Khi nào dùng cái gì
- Nghĩ tới atomic update đầu tiên.
- Logic nhiều bước, nhiều request đụng cùng một ví: pessimistic, transaction ngắn.
- Xung đột hiếm, không muốn giữ khóa: optimistic + retry có giới hạn.
- Không dùng
synchronizedlàm giải pháp chính khi app chạy nhiều instance (câu hỏi tiếp theo).
Còn một vấn đề rất hay bị hỏi chung: client timeout rồi retry cùng một request 3 lần → cộng 3 lần. Đây không phải lost update, mà là duplicate request, giải bằng idempotency key:
Nhiều request KHÁC NHAU chạy đồng thời → Race condition → Lost update
→ Lock / Optimistic / Atomic update
CÙNG MỘT request bị retry nhiều lần → Duplicate processing
→ Idempotency key / Transaction ID
Ngoài tầng database, còn giải pháp nào khác không?
Có. Ra khỏi database thì đây là bài toán concurrency control trong distributed system. Giả sử 100 request cùng lúc vào POST /wallets/123/deposit:
1. Lock trong application (synchronized, ReentrantLock): chỉ đúng khi có một instance.
Load Balancer
/ \
Server A Server B
lock A lock B ← lock của A không biết lock của B
2. Distributed lock (Redis, Redisson): đưa lock ra một chỗ chung mọi instance đều thấy.
SET lock:wallet:123 <token> NX PX 30000
NX: chỉ set nếu key chưa có. Ai set được thì giữ khóa.PX 30000: tự hết hạn sau 30 giây, phòng app chết mà chưa nhả.<token>: giá trị ngẫu nhiên. Nhả khóa phải kiểm tra đúng token của mình, tránh xóa nhầm khóa người khác.
RLock lock = redisson.getLock("lock:wallet:" + walletId);
if (lock.tryLock(0, 30, TimeUnit.SECONDS)) {
try { walletService.deposit(walletId, amount); }
finally { lock.unlock(); }
}
Nhưng distributed lock phức tạp hơn DB lock: lease hết hạn khi việc vẫn đang chạy (GC pause, mạng chậm), process chết giữa chừng, fencing token...
3. Queue / Message broker (Kafka, RabbitMQ): đưa request vào queue, consumer ghi DB. Kafka partition theo key = walletId thì message cùng ví vào cùng partition, xử lý theo thứ tự.
4. Optimistic concurrency ở API: client gửi kèm version, server trả 409 Conflict nếu version đã đổi. HTTP có sẵn pattern ETag / If-Match.
5. Idempotency key: không ngăn hai giao dịch khác nhau chạy song song, nhưng ngăn cùng một giao dịch chạy hai lần.
POST /wallets/123/deposit
Idempotency-Key: txn-ABC123
txn-ABC123 chưa có → +50, lưu txn-ABC123 (unique constraint)
retry txn-ABC123 → đã xử lý, trả lại kết quả cũ, không +50 nữa
Điểm rất đáng nói khi phỏng vấn: với tiền, đừng nghĩ "Redis lock là đủ". DB vẫn là source of truth; Redis/queue giúp điều phối, còn correctness cuối cùng phải được bảo vệ ở nơi dữ liệu được commit (transaction, atomic update, constraint).
synchronized và queue đều "xếp hàng" được. Thật vậy không?Hai câu hỏi:
- Dùng
synchronizedtrên method Java thì có giải quyết được không? - Bạn nói dùng queue, nhưng consumer vẫn nhận nhiều message và xử lý song song mà?
Đúng cả 2 điểm, và câu 2 của bạn bắt đúng chỗ cần làm rõ.
1. synchronized cả method được. Với instance method, nó tương đương synchronized (this):
@Service
public class WalletService {
public synchronized void deposit(Long id, BigDecimal amount) { ... }
}
WalletService là singleton nên mọi request trong cùng một JVM đi qua cùng object → lock hoạt động. Nhưng:
Server A Server B
WalletService A WalletService B
[lock] [lock]
↓ ↓
Database ← vẫn lost update
synchronizedchỉ giải quyết concurrency trong cùng JVM.- Khóa cả method thì ví A đang cộng, ví B cũng phải chờ. Nếu dùng lock trong app, nên khóa theo walletId (bài Lock sẽ làm).
- Thêm một bẫy:
synchronized+@Transactionaltrên cùng method thì lock nhả trước khi proxy commit, request sau vẫn có thể đọc số dư cũ.
2. Consumer hoàn toàn có thể xử lý đồng thời. Câu trước nói "đưa vào queue → tuần tự" là chưa đủ chính xác. Queue không đồng nghĩa với sequential processing.
RabbitMQ queue
├── msg wallet 123 → Consumer thread 1
├── msg wallet 123 → Consumer thread 2 ← chạy song song
└── msg wallet 123 → Consumer thread 3
Với Kafka, dùng walletId làm key: trong một consumer group, mỗi partition chỉ thuộc một consumer tại một thời điểm, nên message cùng ví được xử lý theo thứ tự, còn ví khác nhau vẫn song song:
wallet 123: +50 → +20 → -10 (partition 1, tuần tự)
wallet 456: +100 → -20 (partition 2, tuần tự)
wallet 123 ┐
wallet 456 ┴→ song song với nhau
Nhưng nếu consumer lấy record rồi lại đẩy sang thread pool xử lý song song thì bạn tự phá ordering. RabbitMQ không có partition key kiểu này; muốn tuần tự theo ví phải tự thiết kế, ví dụ shard hash(walletId) % N ra N queue, mỗi queue một consumer.
synchronized → serialize trong 1 JVM
Redis distributed lock → serialize giữa nhiều JVM
Kafka partition by walletId → serialize message cùng ví
DB lock / atomic update → bảo vệ trực tiếp data
Phỏng vấn hỏi race condition thì trả lời sao?
Q1. Race condition là gì, tránh thế nào?
🇻🇳 Tiếng Việt:
Race condition xảy ra khi nhiều request cùng đọc–sửa–ghi một dữ liệu, điển hình là lost update. Em ưu tiên atomic update ở DB nếu logic đơn giản. Logic nhiều bước thì dùng pessimistic lock (
SELECT FOR UPDATE) khi xung đột cao, hoặc optimistic lock (@Version) khi xung đột thấp.synchronizedkhông đủ vì hệ thống chạy nhiều instance. Tài nguyên ngoài DB thì dùng distributed lock như Redis, nhưng vẫn giữ ràng buộc ở DB làm lớp cuối.
🇬🇧 English:
A race condition happens when several requests read, modify and write the same data concurrently; the classic result is a lost update. If the logic is simple I prefer an atomic update in the database. For multi-step logic I use a pessimistic lock (
SELECT FOR UPDATE) when contention is high, or an optimistic lock (@Version) when it is low.synchronizedis not enough because we run multiple instances. For resources outside the database I can use a distributed lock such as Redis, but I still keep database constraints as the last line of defense.
Q2. Lost update và duplicate request khác nhau thế nào?
🇻🇳 Tiếng Việt:
Với API cộng tiền em quan tâm hai vấn đề. Thứ nhất là concurrency: nhiều giao dịch khác nhau cùng update balance gây lost update, giải bằng lock hoặc atomic update. Thứ hai là idempotency: client retry cùng một request nhiều lần, cần idempotency key hoặc transaction ID để một giao dịch chỉ được xử lý một lần.
🇬🇧 English:
For a deposit API I care about two different problems. The first is concurrency: different transactions updating the same balance at the same time can cause lost updates, solved with locking or atomic updates. The second is idempotency: a client may retry the same request, so I need an idempotency key or transaction ID to make sure one operation is applied only once.
Q3. Đưa request vào queue thì có hết race condition không?
🇻🇳 Tiếng Việt:
Không tự động hết, vì nhiều consumer có thể xử lý song song. Phải đảm bảo message của cùng một resource được xử lý tuần tự, ví dụ Kafka dùng
walletIdlàm partition key. Tuy vậy ở DB em vẫn giữ transaction, atomic update hoặc constraint làm lớp đảm bảo cuối cùng, và consumer phải idempotent vì message có thể bị redeliver.
🇬🇧 English:
Not automatically, because multiple consumers can process messages in parallel. We must make sure messages for the same resource are processed in order, for example by using
walletIdas the Kafka partition key. I still keep transactions, atomic updates or constraints in the database as the final guarantee, and the consumer must be idempotent because messages can be redelivered.
Cách nhớ ngắn gọn
- Đọc → tính → ghi rời nhau = lost update.
- Atomic update trước; nhiều bước thì pessimistic (xung đột cao) hoặc optimistic (xung đột thấp).
synchronized= 1 JVM; Redis lock = nhiều JVM; Kafka key = tuần tự theo ví; DB = lớp cuối.- Retry cùng request là idempotency, không phải race condition.
Tự kiểm tra
Vì sao @Transactional mặc định không chặn được lost update?
READ COMMITTED chỉ đảm bảo đọc dữ liệu đã commit; hai transaction vẫn đọc cùng giá trị cũ rồi ghi đè lên nhau.
Vì sao synchronized không đủ trên production?
Mỗi JVM có khóa riêng. Nhiều instance thì nhiều khóa, request ở instance khác vẫn chạy song song.
Optimistic lock báo lỗi thì làm gì?
Bắt ObjectOptimisticLockingFailureException, đọc lại và retry vài lần có giới hạn, hoặc trả 409 Conflict.
Vì sao Redis lock cần token và TTL?
TTL để khóa tự nhả khi app chết. Token để chỉ người giữ khóa mới được xóa, tránh xóa nhầm khóa người khác đã lấy sau khi hết hạn.
Kafka đảm bảo thứ tự ở mức nào?
Trong một partition. Cùng key → cùng partition → tuần tự; nhưng nếu consumer tự đẩy sang thread pool thì mất thứ tự.