Compound action: từng method an toàn, ghép lại vẫn hỏng
Tiếp đi. Mình đã dùng ConcurrentHashMap, toàn class thread-safe cả. Sao vẫn có lúc tạo ra 2 payment session cho cùng một đơn?
Bài cuối của phần Concurrency, và là lỗi rất hay gặp ở Order Service: mọi thứ "thread-safe" mà logic vẫn sai.
1. Bài toán
Người dùng bấm "Thanh toán" 2 lần liên tiếp (hoặc client retry). Hai Tomcat thread cùng xử lý đơn 42:
@Service
class PaymentSessionService {
private final ConcurrentHashMap<Long, PaymentSession> sessions = new ConcurrentHashMap<>();
PaymentSession start(Long orderId) {
if (!sessions.containsKey(orderId)) { // check
sessions.put(orderId, createSession(orderId)); // act
}
return sessions.get(orderId);
}
}
2. Vấn đề
Mỗi method containsKey, put đều thread-safe. Nhưng giữa hai lời gọi có một khe hở:
T1: containsKey(42) → false
T2: containsKey(42) → false
T1: put(42, session1)
T2: put(42, session2) ← đè, tạo 2 session ở payment gateway
Đây gọi là compound action (thao tác ghép). Thread-safe ở mức method không có nghĩa thread-safe ở mức logic.
| Dạng | Ví dụ |
|---|---|
| Check-then-act | if (!map.containsKey(k)) map.put(k, v), lazy init singleton |
| Read-modify-write | count++, map.put(k, map.get(k) + 1) |
| Đọc nhiều bước | size() rồi get(i), duyệt rồi xử lý |
| Ghi nhiều biến liên quan | Cập nhật min và max phải luôn min ≤ max |
3. Giải pháp
Theo thứ tự ưu tiên:
- Thao tác gộp atomic có sẵn:
computeIfAbsent,putIfAbsent,merge,compute,AtomicXxx.compareAndSet. - Bọc cả chuỗi trong cùng một khóa khi không có method gộp sẵn.
- Tránh hẳn state chia sẻ: immutability, thread confinement.
- Dữ liệu ở DB hoặc nhiều instance: giải ở DB (unique constraint, atomic UPDATE, lock) như Bài 3.5.
4. Code từng bước
Bước 1: Check-then-act → computeIfAbsent
PaymentSession start(Long orderId) {
return sessions.computeIfAbsent(orderId, this::createSession); // chỉ tạo 1 lần
}
Bước 2: Read-modify-write → merge/compute/Atomic
counts.merge(status, 1, Integer::sum);
paidAmount.compute(userId, (k, v) -> v == null ? amount : v.add(amount));
AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();
stateRef.compareAndSet(State.PENDING, State.PAID);
Bước 3: Không có method gộp → cùng một khóa
Vector<Task> tasks = ...;
synchronized (tasks) { // Vector dùng chính nó làm lock
if (!tasks.isEmpty()) {
last = tasks.get(tasks.size() - 1);
}
}
Mọi chỗ truy cập dữ liệu đó phải dùng cùng một khóa. Khóa một chỗ, quên một chỗ là vô ích.
Bước 4: Nhiều instance → DB
-- unique constraint: chỉ 1 session ACTIVE mỗi đơn
CREATE UNIQUE INDEX ux_session_active ON payment_session(order_id) WHERE status = 'ACTIVE';
-- atomic UPDATE thay cho đọc rồi ghi
UPDATE orders SET status = 'PAID' WHERE id = 42 AND status = 'PENDING'; -- 0 dòng = đã xử lý
Test: bắn 2 request cùng lúc.
curl -s -X POST localhost:8080/orders/42/pay & curl -s -X POST localhost:8080/orders/42/pay & wait
# trước: 2 sessionId khác nhau
# sau: cùng 1 sessionId
5. Cách hoạt động
containsKey + put (2 bước, có khe hở)
T1: [lock b][check][unlock] ......... [lock b][put][unlock]
T2: ........ [lock b][check][unlock] ........ [lock b][put][unlock]
↑ khe hở: T2 chen vào
computeIfAbsent (1 bước, khóa bao trọn)
T1: [lock bucket(42)][check → null → create → put][unlock]
T2: ⏳ chờ ............................ [lock][check → có rồi → trả session1][unlock]
6. So sánh
| Cách | Ưu | Nhược |
|---|---|---|
| Method gộp atomic | Gọn, nhanh, khóa theo key | Chỉ có cho thao tác collection hỗ trợ |
synchronized/Lock bao chuỗi | Dùng cho mọi logic | Dễ quên một chỗ, dễ khóa quá to |
| Immutable / confinement | Không cần khóa | Phải thiết kế từ đầu |
| DB constraint / atomic UPDATE | Đúng cả khi nhiều instance | Phụ thuộc DB, cần xử lý lỗi trùng |
putIfAbsent(k, v) | computeIfAbsent(k, fn) | |
|---|---|---|
| Tạo value | Luôn tạo trước, kể cả khi không dùng | Chỉ gọi fn khi key chưa có |
| Hợp với | Value rẻ, có sẵn | Value đắt (gọi API, tạo session) |
7. Khi nào nên dùng / không nên dùng
- Nên: mỗi khi thấy "if rồi làm", "đọc rồi ghi" trên dữ liệu chung, dừng lại hỏi: có thread khác chen vào giữa được không?
- Không nên: làm IO lâu bên trong
computeIfAbsenthay khối khóa; tin rằng "dùng class thread-safe là đủ"; khóa JVM khi có nhiều instance.
Vector có synchronized mọi method mà cũng bị à?Vector có synchronized mọi method mà cũng bị à?
Bị, vì khóa chỉ bao từng method. Ví dụ lấy phần tử cuối:
Vector<Task> tasks = ...;
Task last = tasks.get(tasks.size() - 1);
A: size() → 10
B: remove(0) → size 9
A: get(9) ← ArrayIndexOutOfBoundsException
Đó là lý do Vector/Hashtable bị xem là "thread-safe giả": an toàn cấu trúc bên trong, nhưng không bảo vệ logic của bạn. Sửa bằng cách bọc cả chuỗi trong synchronized (tasks) như Bước 3.
Có cách nào tránh hẳn việc phải khóa không?
Có. Race condition chỉ xảy ra khi có dữ liệu chia sẻ và có thể thay đổi. Bỏ một trong hai là hết.
Immutability: không cho đổi
public record Money(BigDecimal amount, String currency) {
public Money add(Money other) {
return new Money(amount.add(other.amount), currency); // tạo object mới
}
}
List<String> roles = List.of("ADMIN", "USER"); // immutable
private final AtomicReference<PaymentConfig> config = new AtomicReference<>(load());
void reload() { config.set(load()); } // reader thấy config cũ hoặc mới, không bao giờ nửa vời
Thread confinement: không chia sẻ
- Biến local trong method: mỗi thread có stack riêng, luôn an toàn.
- Spring bean singleton nên stateless: không giữ state của request trong field.
- ThreadLocal (Bài 3.8): mỗi thread một bản. Nhưng pool tái sử dụng worker, quên
remove()thì request B đọc được context của request A. Luônsettrongtry,remove()trongfinally. ThreadLocal cũng không tự đi theo sangsupplyAsync.
@Service
class ReportService {
// SAI: field dùng chung giữa mọi request
// private List<Row> rows = new ArrayList<>();
Report build(Long id) {
List<Row> rows = new ArrayList<>(); // ĐÚNG: biến local
...
}
}
Khóa cả method hay chỉ khóa một đoạn nhỏ thì tốt hơn?
Không có câu trả lời "càng nhỏ càng tốt". Đây là bài toán lock granularity:
| Khóa to (coarse) | Khóa nhỏ (fine) | |
|---|---|---|
| Ví dụ | synchronized cả method | Khóa theo từng key, từng bucket |
| Ưu | Đơn giản, khó sai | Song song tốt hơn |
| Nhược | Thread xếp hàng nhiều | Phức tạp, dễ deadlock khi lấy nhiều khóa |
- Khóa phải bao trọn cả compound action, không được nhỏ hơn.
- Nhưng không làm IO, gọi API, query DB lâu trong khi giữ khóa. Chuẩn bị bên ngoài, chỉ khóa đoạn đọc–ghi state.
- Khóa theo key nếu các key độc lập (mỗi orderId một khóa), như
ConcurrentHashMap.computeđã làm sẵn. - Cần nhiều khóa thì luôn lấy theo thứ tự cố định để tránh deadlock (Bài 3.6).
Phỏng vấn hỏi "dùng toàn class thread-safe thì code có thread-safe không?" thì trả lời sao?
Q1. Dùng toàn class thread-safe thì code có thread-safe không?
🇻🇳 Tiếng Việt:
Không chắc. Mỗi method thread-safe không có nghĩa chuỗi method thread-safe. Ví dụ
if (!map.containsKey(k)) map.put(k, v)trên ConcurrentHashMap, hayvector.get(vector.size() - 1), đều có khe hở giữa hai lời gọi. Đó là compound action: check-then-act, read-modify-write. Em xử lý bằng thao tác atomic có sẵn nhưcomputeIfAbsent,merge,AtomicInteger; nếu không có thì bọc cả chuỗi trong cùng một khóa.
🇬🇧 English:
Not necessarily. Thread-safe methods do not make a sequence of calls thread-safe. For example, checking containsKey and then calling put on a ConcurrentHashMap, or calling get with size minus one on a Vector, leaves a gap between the two calls. These are compound actions such as check-then-act and read-modify-write. I use built-in atomic operations like computeIfAbsent, merge or AtomicInteger, and if none exists I guard the whole sequence with the same lock.
Q2. Làm sao tránh phải đồng bộ?
🇻🇳 Tiếng Việt:
Bỏ chia sẻ hoặc bỏ thay đổi. Dùng object immutable như record,
List.of; giữ state trong biến local; Spring bean singleton phải stateless. Khi buộc phải khóa thì khóa bao trọn compound action, giữ khóa ngắn, không gọi IO trong khóa, và lấy nhiều khóa theo thứ tự cố định. Khi có nhiều instance thì dùng DB constraint hoặc atomic UPDATE.
🇬🇧 English:
Remove either sharing or mutability. Use immutable objects such as records or List.of, keep state in local variables, and keep singleton Spring beans stateless. When locking is unavoidable, the lock must cover the whole compound action, be held briefly, never wrap I/O, and multiple locks must be taken in a fixed order. With several instances we rely on database constraints or atomic updates.
Cách nhớ ngắn gọn
- An toàn từng method ≠ an toàn cả chuỗi.
- Check-then-act →
computeIfAbsent; read-modify-write →merge/Atomic. - Không chia sẻ (local, stateless) hoặc không thay đổi (immutable) thì khỏi khóa.
- Khóa bao trọn, ngắn, không IO, thứ tự cố định; nhiều instance → DB.
Tổng kết phần Concurrency
| Bài | Chủ đề | Ý chính trên Order Service |
|---|---|---|
| 3.1 | Process và Thread | Một JVM là một process; mỗi request chạy trên một Tomcat thread, chung heap |
| 3.2 | Runnable, Callable, Future | Runnable không trả kết quả; Callable + Future lấy kết quả và exception |
| 3.3 | Thread pool | Tái sử dụng thread; core → queue → max → reject; 10 payment / 2 worker |
| 3.4 | synchronized, volatile, Atomic | Mutual exclusion vs visibility vs CAS cho biến đơn |
| 3.5 | Race condition | Hai request cùng cộng tiền vào ví (lost update); atomic UPDATE, FOR UPDATE, @Version, Redis lock |
| 3.6 | Deadlock | Chuyển tiền A→B và B→A khóa chéo nhau; lấy khóa theo thứ tự id, tryLock, deadlock ở DB |
| 3.7 | Lock | ReentrantLock: tryLock, timeout, fair, Condition; ReadWriteLock |
| 3.8 | ThreadLocal | Context theo thread; luôn remove() trong finally vì pool tái dùng thread |
| 3.9 | CompletableFuture | Checkout gọi user/order/payment song song 3s → 1s; executor riêng; @Async |
| 3.10 | Concurrent collections | ConcurrentHashMap khóa bucket; CopyOnWrite; BlockingQueue |
| 3.11 | Virtual threads | Tomcat 200 thread chờ IO; virtual thread unmount; pinning; Semaphore |
| 3.12 | Compound action | Từng method an toàn, ghép lại vẫn hỏng; computeIfAbsent, immutability |
Khi gặp một bài toán đa luồng, hỏi theo thứ tự: có cần chia sẻ không → có cần thay đổi không → có thao tác atomic sẵn không → khóa bao trọn, ngắn, thứ tự cố định → nhiều instance thì khóa ở DB/distributed. Và nhớ: thread pool, @Async, CompletableFuture chỉ quyết định chạy ở thread nào, không làm dữ liệu chung an toàn hơn.
Tự kiểm tra
Compound action là gì? Cho 2 ví dụ.
Chuỗi nhiều thao tác cần chạy như một khối. Ví dụ check-then-act (containsKey rồi put) và read-modify-write (count++).
putIfAbsent và computeIfAbsent khác gì?
putIfAbsent nhận value đã tạo sẵn (luôn tạo dù không dùng). computeIfAbsent nhận hàm, chỉ gọi khi key chưa có.
Vì sao object immutable an toàn khi chia sẻ?
Không ai sửa được nên không có race; mọi thread thấy cùng một trạng thái (field final được publish an toàn).
Sai lầm thường gặp khi chọn phạm vi khóa?
Khóa nhỏ hơn compound action (vẫn có khe hở), hoặc giữ khóa trong lúc gọi IO/DB làm thread xếp hàng lâu.
Vì sao Spring bean nên stateless?
Bean singleton được mọi request thread dùng chung; field chứa state request sẽ bị các thread đè lên nhau.