Compound action: từng method an toàn, ghép lại vẫn hỏng

5 câu hỏi~22 phútTừ khóa: check-then-act, read-modify-write, putIfAbsent, computeIfAbsent, merge, compute, immutability, thread confinement, lock granularity

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ạngVí dụ
Check-then-actif (!map.containsKey(k)) map.put(k, v), lazy init singleton
Read-modify-writecount++, map.put(k, map.get(k) + 1)
Đọc nhiều bướcsize() rồi get(i), duyệt rồi xử lý
Ghi nhiều biến liên quanCập nhật min và max phải luôn min ≤ max

3. Giải pháp

Theo thứ tự ưu tiên:

  1. Thao tác gộp atomic có sẵn: computeIfAbsent, putIfAbsent, merge, compute, AtomicXxx.compareAndSet.
  2. Bọc cả chuỗi trong cùng một khóa khi không có method gộp sẵn.
  3. Tránh hẳn state chia sẻ: immutability, thread confinement.
  4. 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ƯuNhược
Method gộp atomicGọn, nhanh, khóa theo keyChỉ có cho thao tác collection hỗ trợ
synchronized/Lock bao chuỗiDùng cho mọi logicDễ quên một chỗ, dễ khóa quá to
Immutable / confinementKhông cần khóaPhải thiết kế từ đầu
DB constraint / atomic UPDATEĐúng cả khi nhiều instancePhụ thuộc DB, cần xử lý lỗi trùng
putIfAbsent(k, v)computeIfAbsent(k, fn)
Tạo valueLuôn tạo trước, kể cả khi không dùngChỉ gọi fn khi key chưa có
Hợp vớiValue rẻ, có sẵnValue đắt (gọi API, tạo session)

7. Khi nào nên dùng / không nên dùng

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ẻ

@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ả methodKhóa theo từng key, từng bucket
ƯuĐơn giản, khó saiSong song tốt hơn
NhượcThread xếp hàng nhiềuPhức tạp, dễ deadlock khi lấy nhiều khóa

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, hay vector.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

Tổng kết phần Concurrency

BàiChủ đềÝ chính trên Order Service
3.1Process và ThreadMột JVM là một process; mỗi request chạy trên một Tomcat thread, chung heap
3.2Runnable, Callable, FutureRunnable không trả kết quả; Callable + Future lấy kết quả và exception
3.3Thread poolTái sử dụng thread; core → queue → max → reject; 10 payment / 2 worker
3.4synchronized, volatile, AtomicMutual exclusion vs visibility vs CAS cho biến đơn
3.5Race conditionHai request cùng cộng tiền vào ví (lost update); atomic UPDATE, FOR UPDATE, @Version, Redis lock
3.6DeadlockChuyể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.7LockReentrantLock: tryLock, timeout, fair, Condition; ReadWriteLock
3.8ThreadLocalContext theo thread; luôn remove() trong finally vì pool tái dùng thread
3.9CompletableFutureCheckout gọi user/order/payment song song 3s → 1s; executor riêng; @Async
3.10Concurrent collectionsConcurrentHashMap khóa bucket; CopyOnWrite; BlockingQueue
3.11Virtual threadsTomcat 200 thread chờ IO; virtual thread unmount; pinning; Semaphore
3.12Compound actionTừ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.