Deadlock: chuyển tiền A→B và B→A cùng lúc
Deadlock là gì? Cho ví dụ.
Deadlock là tình trạng 2 hoặc nhiều thread bị kẹt vĩnh viễn vì đang chờ lock của nhau. Ví dụ kinh điển với 2 thread và 2 lock:
static final Object lockA = new Object();
static final Object lockB = new Object();
Thread t1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread 1 giữ lockA");
synchronized (lockB) { System.out.println("Thread 1 giữ lockB"); }
}
});
Thread t2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("Thread 2 giữ lockB");
synchronized (lockA) { System.out.println("Thread 2 giữ lockA"); }
}
});
t1.start(); t2.start();
Thread 1 giữ A muốn B, Thread 2 giữ B muốn A. Không ai nhả vì chưa chạy xong block synchronized. Giờ mang nó về project wallet-service của bài trước.
1. Bài toán
Thêm API chuyển tiền POST /transfers. Để không bị lost update ở cả hai ví, mình khóa cả hai trước khi trừ/cộng:
void transfer(Wallet from, Wallet to, BigDecimal amount) {
synchronized (from) {
synchronized (to) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
Chạy test đơn lẻ thì ổn. Lên production, thỉnh thoảng app treo cứng: không exception, không log.
2. Vấn đề
Hai request chạy cùng lúc, một cái A→B, một cái B→A:
Thread 1 (A→B): khóa A ... chờ B
Thread 2 (B→A): khóa B ... chờ A ← cả hai chờ nhau, không ai nhả
Hai thread đứng đó mãi, request treo tới khi client timeout. Nhiều request dính vào thì Tomcat thread pool cạn và cả app ngừng phản hồi.
Deadlock chỉ xảy ra khi đủ cả 4 điều kiện Coffman:
| Điều kiện | Nghĩa là | Trong ví dụ |
|---|---|---|
| Mutual exclusion | Tài nguyên chỉ một thread giữ | synchronized độc quyền |
| Hold and wait | Giữ cái này, chờ cái kia | Giữ A, chờ B |
| No preemption | Không ai giật khóa được | JVM không tự lấy lại monitor |
| Circular wait | Chờ nhau thành vòng | T1 chờ T2, T2 chờ T1 |
3. Giải pháp
Phá được một điều kiện là hết deadlock. Thực tế hay phá hai cái:
- Phá circular wait bằng lock ordering: luôn khóa theo thứ tự cố định, ví dụ wallet id nhỏ trước. Đơn giản, không tốn gì, nên nghĩ tới đầu tiên.
- Phá no preemption bằng
tryLockcó timeout: không chờ vô hạn; không lấy được thì nhả hết và thử lại. Dùng khi không xác định được thứ tự (khóa lấy động, nhiều tầng gọi).
4. Code từng bước
Bước 1: Lock ordering
void transfer(Wallet from, Wallet to, BigDecimal amount) {
Wallet first = from.getId() < to.getId() ? from : to;
Wallet second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
Bước 2: tryLock + backoff (mỗi wallet có một ReentrantLock, bài sau nói kỹ):
boolean transfer(Wallet from, Wallet to, BigDecimal amount) throws InterruptedException {
for (int i = 0; i < 3; i++) {
if (from.lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (to.lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
from.withdraw(amount);
to.deposit(amount);
return true;
} finally { to.lock.unlock(); }
}
} finally { from.lock.unlock(); }
}
Thread.sleep(ThreadLocalRandom.current().nextInt(10, 50)); // backoff ngẫu nhiên
}
return false; // controller trả 409/503 cho client thử lại
}
Sleep ngẫu nhiên để hai thread không cùng nhả rồi cùng lấy lại y hệt nhau mãi (gọi là livelock).
Bước 3: Test, bắn chuyển tiền hai chiều song song:
for i in $(seq 1 200); do
curl -s -X POST localhost:8080/transfers -H 'Content-Type: application/json' \
-d '{"from":1,"to":2,"amount":1}' &
curl -s -X POST localhost:8080/transfers -H 'Content-Type: application/json' \
-d '{"from":2,"to":1,"amount":1}' &
done; wait
# code cũ: vài request không bao giờ trả về
# lock ordering: tất cả trả về, tổng tiền 2 ví không đổi
5. Cách hoạt động
KHÔNG sắp thứ tự CÓ sắp thứ tự (id nhỏ trước)
Thread 1 Thread 2 Thread 1 (A→B) Thread 2 (B→A)
lock(A) lock(B) lock(A) lock(A) ← chờ
↓ ↓ lock(B) .
chờ B ←─ vòng ─→ chờ A làm việc .
DEADLOCK unlock B, A ───→ lock(A) OK
lock(B), làm việc
Thread 2 vẫn phải chờ, nhưng đó là blocking bình thường: Thread 1 xong sẽ nhả, Thread 2 chạy tiếp. Không có vòng thì không có deadlock.
6. So sánh
| Race condition | Deadlock | Livelock | |
|---|---|---|---|
| Thread làm gì | Cùng chạy, ghi đè nhau | Đứng chờ nhau mãi | Chạy liên tục nhưng không tiến |
| Triệu chứng | Dữ liệu sai | App treo, CPU thấp | CPU cao, không request nào xong |
| Cách sửa | Lock / atomic update | Lock ordering, tryLock | Backoff ngẫu nhiên |
| Cách phá | Ưu | Nhược |
|---|---|---|
| Lock ordering | Đơn giản, không tốn thêm | Phải biết trước mọi lock cần lấy |
| tryLock + timeout | Không bao giờ treo vô hạn | Code dài, phải xử lý thất bại, coi chừng livelock |
7. Khi nào dùng
- Cần lấy nhiều lock cùng lúc: luôn có quy tắc thứ tự (theo id).
- Không sắp được thứ tự:
tryLockcó timeout + backoff. - Tốt nhất là không giữ nhiều lock: giảm phạm vi khóa, không gọi API ngoài khi đang giữ khóa.
- Nhớ: lock trong JVM chỉ đúng khi một instance. Với wallet-service chạy 2 instance, khóa thật nằm ở DB, và DB cũng có deadlock (câu tiếp).
SELECT FOR UPDATE. Vậy DB có deadlock không?Có vẻ database cũng có deadlock?
Đúng. Database cũng có deadlock, và bản chất gần như y hệt Java. Hai transaction chuyển tiền theo thứ tự ngược nhau:
-- Transaction 1 (A→B) -- Transaction 2 (B→A)
BEGIN; BEGIN;
UPDATE wallet SET balance = balance - 100 UPDATE wallet SET balance = balance - 50
WHERE id = 'A'; -- lock A WHERE id = 'B'; -- lock B
UPDATE wallet SET balance = balance + 100 UPDATE wallet SET balance = balance + 50
WHERE id = 'B'; -- muốn B WHERE id = 'A'; -- muốn A ← vòng
| Java | Database |
|---|---|
| Thread 1, Thread 2 | Transaction 1, Transaction 2 |
synchronized(lockA) | Lock row A |
| Thread chờ lock | Transaction chờ lock |
| Chờ nhau mãi | DB phát hiện, chọn một victim để rollback |
Điểm khác quan trọng: MySQL InnoDB, PostgreSQL tự phát hiện vòng chờ và rollback một transaction. App nhận lỗi kiểu Deadlock found when trying to get lock, Spring bọc thành CannotAcquireLockException / DeadlockLoserDataAccessException.
Cũng vì vậy mà @Lock(PESSIMISTIC_WRITE) ở bài trước không phải cứ thêm là xong: khóa sai thứ tự còn tạo ra deadlock. Cách xử lý giống Java:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select w from Wallet w where w.id in :ids order by w.id")
List<Wallet> lockAllOrdered(List<Long> ids); // khóa theo thứ tự id
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
lockAllOrdered(List.of(fromId, toId)); // A trước B, bất kể chiều chuyển
...
}
- Khóa theo thứ tự:
WHERE id IN (...) ORDER BY id FOR UPDATE. - Transaction ngắn, không gọi API ngoài khi đang giữ khóa.
- Retry transaction bị chọn làm victim (ví dụ Spring Retry
@Retryable). - Xem nguyên nhân: MySQL
SHOW ENGINE INNODB STATUS, PostgreSQL xem log.
Làm sao phát hiện deadlock khi app đang chạy?
Đúng hướng: deadlock trong JVM không tự báo lỗi, phải tự đi tìm. Lấy thread dump:
jps # tìm pid
jstack <pid> > dump.txt # hoặc: jcmd <pid> Thread.print
JVM tự phát hiện deadlock với monitor và ReentrantLock, in ra cuối dump:
Found one Java-level deadlock:
"http-nio-8080-exec-2":
waiting to lock monitor 0x... (object 0x..., a Wallet),
which is held by "http-nio-8080-exec-5"
"http-nio-8080-exec-5":
waiting to lock monitor 0x... (object 0x..., a Wallet),
which is held by "http-nio-8080-exec-2"
Kèm stack trace chỉ đúng dòng code. Thread chờ synchronized có trạng thái BLOCKED, chờ ReentrantLock là WAITING. Trong code có thể dùng ThreadMXBean.findDeadlockedThreads() để tự kiểm tra định kỳ và bắn alert.
Phỏng vấn hỏi deadlock thì trả lời sao?
Q1. Deadlock là gì, tránh thế nào?
🇻🇳 Tiếng Việt:
Deadlock xảy ra khi nhiều thread giữ các resource khác nhau và đồng thời chờ resource mà thread khác đang giữ, tạo thành vòng chờ nên không thread nào chạy tiếp được. Nó cần đủ 4 điều kiện Coffman: mutual exclusion, hold and wait, no preemption, circular wait. Em thường phá circular wait bằng lock ordering, ví dụ luôn khóa account id nhỏ trước; hoặc phá no preemption bằng
tryLockcó timeout kèm retry và backoff.
🇬🇧 English:
A deadlock happens when threads hold some resources while waiting for resources held by each other, forming a cycle, so none of them can proceed. It requires all four Coffman conditions: mutual exclusion, hold and wait, no preemption and circular wait. I usually break circular wait with lock ordering, for example always locking the smaller account id first, or break no preemption with
tryLockwith a timeout plus retry and backoff.
Q2. Deadlock trong database khác trong Java thế nào?
🇻🇳 Tiếng Việt:
Bản chất giống nhau: hai transaction khóa row theo thứ tự ngược nhau. Khác ở chỗ DB tự phát hiện vòng chờ và rollback một transaction làm victim, còn JVM để các thread treo mãi. Với
SELECT FOR UPDATEem khóa theo thứ tự id, giữ transaction ngắn và retry transaction bị rollback.
🇬🇧 English:
The root cause is the same: two transactions lock rows in opposite order. The difference is that the database detects the cycle and rolls back one transaction as the victim, while the JVM leaves the threads stuck forever. With
SELECT FOR UPDATEI lock rows in id order, keep transactions short, and retry the rolled-back transaction.
Q3. App đang treo, làm sao xác định có deadlock?
🇻🇳 Tiếng Việt:
Em lấy thread dump bằng
jstackhoặcjcmd Thread.print. JVM sẽ in "Found one Java-level deadlock" kèm tên thread, lock đang giữ, lock đang chờ và stack trace đến đúng dòng code.
🇬🇧 English:
I take a thread dump with
jstackorjcmd Thread.print. The JVM prints "Found one Java-level deadlock" with the thread names, the locks they hold and wait for, and stack traces pointing to the exact lines of code.
Cách nhớ ngắn gọn
- Giữ A chờ B, giữ B chờ A = vòng = deadlock.
- 4 điều kiện Coffman, phá 1 là đủ: ordering (circular wait), tryLock (no preemption).
- Race condition = dữ liệu sai; deadlock = treo; livelock = bận mà không tiến.
- DB tự rollback victim, JVM thì không: dùng
jstack.
Tự kiểm tra
Deadlock khác race condition thế nào?
Race condition: các thread cùng chạy và ghi đè nhau, dữ liệu sai. Deadlock: các thread chờ lock của nhau, không ai chạy được.
Lock ordering phá điều kiện Coffman nào?
Circular wait. Mọi thread khóa cùng thứ tự nên không thể chờ nhau thành vòng.
Vì sao dùng tryLock phải có backoff ngẫu nhiên?
Để tránh livelock: các thread cùng nhả rồi cùng thử lại đúng nhịp, mãi không ai thành công.
Deadlock trong DB khác trong JVM ở điểm nào?
DB tự phát hiện và rollback một transaction (Spring ném CannotAcquireLockException), còn JVM để các thread treo mãi.
Lệnh nào để xem deadlock của app Java đang chạy?
jstack <pid> hoặc jcmd <pid> Thread.print, tìm đoạn "Found one Java-level deadlock".