Test transaction và concurrency: rollback giấu bug, optimistic lock, race condition
Tiếp đi: test transaction và concurrency. Test của mình toàn xanh mà lên production vẫn lỗi trừ tiền ví sai.
Đây là loại bug test "xanh giả" kinh điển. Nguyên nhân thường nằm ở hai chỗ: test chạy trong một transaction rồi rollback, và test chỉ có một thread trong khi production có hàng trăm request song song.
1. Bài toán: nạp tiền ví trong shop-app
WalletService.deposit() cộng tiền vào ví. Test dưới đây xanh:
@SpringBootTest
@Transactional // "cho sạch DB sau mỗi test"
class WalletServiceTest {
@Autowired WalletService walletService;
@Autowired WalletRepository walletRepository;
@Test
void depositAddsBalance() {
Wallet w = walletRepository.save(new Wallet(1L, new BigDecimal("0")));
walletService.deposit(w.getId(), new BigDecimal("100"));
assertThat(walletRepository.findById(w.getId()).get().getBalance())
.isEqualByComparingTo("100");
}
}
2. Vấn đề
- Không flush: cả test là một transaction, rollback ở cuối. Hibernate có thể không bao giờ gửi
UPDATExuống DB. Constraint (CHECK balance >= 0, unique, not null) không bao giờ bị kiểm tra. - Cùng Persistence Context:
findById()trả về chính object trong first-level cache, không đọc DB. Bạn đang assert trên bộ nhớ. - Lazy loading giả: trong test, session còn mở nên
order.getItems()chạy được. Production gọi ngoài transaction →LazyInitializationException. - Propagation sai: code dùng
REQUIRES_NEW,@TransactionalEventListener(AFTER_COMMIT)sẽ hành xử khác vì transaction của test không bao giờ commit, listener AFTER_COMMIT không chạy. - Một thread: deposit đọc balance, cộng, ghi. Hai request song song → lost update (xem race condition). Test một thread không thể thấy.
3. Giải pháp
- Test service/integration: không đặt
@Transactionaltrên class test. Dọn dữ liệu bằng@Sql/truncate hoặc dùng dữ liệu riêng mỗi test. - Nếu giữ
@Transactional(ví dụ@DataJpaTest): gọiflush()+clear()trước khi assert. - Optimistic locking: test hai transaction thật cùng sửa một version.
- Race condition: dùng
ExecutorService+CountDownLatchđể bắn N thread cùng lúc, assert bất biến (tổng tiền). - Code async (event, @Async): dùng Awaitility thay cho
Thread.sleep.
4. Code từng bước
Bước 1: Dependency
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
<!-- spring-boot-starter-test + Testcontainers PostgreSQL như bài 5.5 -->
Bước 2: Config. Dùng PostgreSQL thật qua Testcontainers (bài 5.5), không dùng H2: locking và isolation của H2 khác.
spring:
jpa:
open-in-view: false
datasource:
hikari:
maximum-pool-size: 20 # đủ cho test song song
Bước 3: Code Java
3a. Flush + clear để lộ bug ở DB
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class WalletRepositoryTest {
@Autowired WalletRepository repo;
@Autowired TestEntityManager em;
@Test
void negativeBalanceViolatesConstraint() {
Wallet w = repo.save(new Wallet(1L, new BigDecimal("-5")));
assertThatThrownBy(() -> em.flush()) // không flush: test xanh giả
.isInstanceOf(ConstraintViolationException.class);
}
@Test
void readsFromDatabaseNotCache() {
Long id = repo.save(new Wallet(1L, BigDecimal.TEN)).getId();
em.flush();
em.clear(); // bỏ first-level cache
assertThat(repo.findById(id).get().getBalance()).isEqualByComparingTo("10");
}
}
3b. Test lazy loading như production
@SpringBootTest
class OrderQueryServiceTest { // không @Transactional
@Autowired OrderQueryService service;
@Test
void orderDetailDoesNotNeedOpenSession() {
OrderDetail dto = service.getDetail(seedOrderId);
assertThat(dto.items()).hasSize(2); // nếu service trả Entity lazy → nổ ở đây
}
}
3c. Optimistic locking
@Entity
class Wallet {
@Id @GeneratedValue Long id;
Long userId;
BigDecimal balance;
@Version Long version;
}
@SpringBootTest
class WalletOptimisticLockTest {
@Autowired WalletRepository repo;
@Autowired TransactionTemplate tx;
@Test
void secondStaleWriteFails() {
Long id = repo.save(new Wallet(1L, BigDecimal.ZERO)).getId();
Wallet a = repo.findById(id).orElseThrow(); // version 0
Wallet b = repo.findById(id).orElseThrow(); // version 0 (bản khác)
a.setBalance(new BigDecimal("100"));
repo.save(a); // version -> 1
b.setBalance(new BigDecimal("50"));
assertThatThrownBy(() -> repo.save(b))
.isInstanceOf(ObjectOptimisticLockingFailureException.class);
}
}
Không có @Transactional trên test nên mỗi save là một transaction riêng, giống hai request thật. Chi tiết @Version ở bài JPA locking.
3d. Tái hiện race condition với CountDownLatch
@SpringBootTest
class WalletConcurrencyTest {
@Autowired WalletService walletService;
@Autowired WalletRepository repo;
@Test
void concurrentDepositsDoNotLoseMoney() throws Exception {
Long id = repo.save(new Wallet(1L, BigDecimal.ZERO)).getId();
int threads = 50;
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch ready = new CountDownLatch(threads);
CountDownLatch start = new CountDownLatch(1);
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < threads; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
start.await(); // tất cả chờ ở vạch xuất phát
walletService.deposit(id, BigDecimal.TEN);
return null;
}));
}
ready.await();
start.countDown(); // bắn cùng lúc
for (Future<?> f : futures) f.get(10, TimeUnit.SECONDS); // lộ exception
pool.shutdown();
assertThat(repo.findById(id).get().getBalance()).isEqualByComparingTo("500");
}
}
Với code "đọc - cộng - ghi" không lock, test này đỏ (ví dụ balance = 130). Sửa bằng UPDATE wallet SET balance = balance + :amount WHERE id = :id, pessimistic lock, hoặc optimistic lock + retry. Khi có optimistic lock mà không retry, test sẽ đỏ với OptimisticLockingFailureException: đúng hành vi, test giúp bạn quyết định có cần retry không.
3e. Awaitility cho code async
@Test
void depositPublishesNotificationAfterCommit() {
walletService.deposit(walletId, BigDecimal.TEN); // listener AFTER_COMMIT + @Async
await().atMost(Duration.ofSeconds(5))
.pollInterval(Duration.ofMillis(100))
.untilAsserted(() ->
assertThat(notificationRepo.findByWalletId(walletId)).hasSize(1));
}
Bước 4: Chạy
./mvnw test -Dtest=WalletConcurrencyTest
# Trước khi sửa: expected 500 but was 130
# Sau khi sửa: Tests run: 1, Failures: 0
# Chạy lặp để chắc: -Dsurefire.rerunFailingTestsCount=0 + @RepeatedTest(20)
5. Cách hoạt động
@Transactional trên test
BEGIN ──► save() ──► deposit() ──► findById() (cache) ──► assert ──► ROLLBACK
(chưa flush) (UPDATE không bao giờ chạy)
Không @Transactional
save() [tx1 COMMIT] ──► deposit() [tx2 COMMIT] ──► findById() [tx3, đọc DB] ──► assert
Race test
T1..T50: ready.countDown() ──► start.await() ─┐
main: ready.await() ──► start.countDown() ─┴─► 50 thread cùng chạy deposit()
T1 đọc 0 T2 đọc 0 T1 ghi 10 T2 ghi 10 ──► lost update
6. So sánh
| Cách | Ưu | Nhược | Dùng khi |
|---|---|---|---|
| @Transactional + rollback | Nhanh, tự dọn | Giấu lỗi flush, lazy, AFTER_COMMIT, concurrency | @DataJpaTest query đơn giản, nhớ flush/clear |
| Commit thật + dọn bằng truncate | Giống production | Phải tự dọn, chậm hơn chút | Test service, transaction, lock |
| Thread.sleep | Dễ viết | Chậm hoặc flaky | Không dùng |
| Awaitility | Chờ đúng điều kiện, có timeout | Thêm dependency | Mọi assert async |
| CountDownLatch + pool | Ép các thread chạy cùng lúc | Không đảm bảo 100% tái hiện | Kiểm bất biến dưới tải song song |
7. Khi nào nên dùng / không nên dùng
- Logic tiền, tồn kho, đếm: bắt buộc có concurrency test assert bất biến.
- Không đặt
@Transactionalmặc định trên mọi integration test chỉ để dọn dữ liệu. - Concurrency test không chứng minh code đúng, chỉ tăng xác suất lộ bug. Thiết kế đúng (atomic update, lock) vẫn là chính.
- Không dùng H2 cho test lock/isolation.
Vậy @DataJpaTest mặc định cũng có @Transactional, mình có nên tắt luôn không?
Không cần tắt hết. Bạn hiểu đúng là nó có @Transactional, chỉ cần chỉnh một điểm: với test repository (query, mapping) rollback là ổn, miễn là bạn flush() + clear() trước khi assert để SQL thật sự chạy và đọc lại từ DB.
Chỉ tắt (@Transactional(propagation = Propagation.NOT_SUPPORTED)) khi test cần commit thật: optimistic lock, nhiều thread, listener AFTER_COMMIT.
Test race condition chạy 10 lần thì 9 lần đỏ, 1 lần xanh. Như vậy có phải test flaky không?
Đúng là kết quả không ổn định, nhưng ở đây test không sai: code sai. Một lần xanh nghĩa là lần đó các thread không chen nhau. Sau khi sửa code, test phải xanh 100% kể cả khi lặp (@RepeatedTest(50)). Nếu sau khi sửa vẫn lúc xanh lúc đỏ thì mới nghi test có vấn đề (pool connection quá nhỏ, timeout quá ngắn). Flaky test là chủ đề bài 5.9.
Phỏng vấn hỏi test transaction và concurrency thì trả lời sao?
Q1. Đặt @Transactional trên test có vấn đề gì?
🇻🇳 Tiếng Việt:
Test chạy trong một transaction rồi rollback nên Hibernate có thể không flush, constraint DB không được kiểm tra, dữ liệu đọc từ first-level cache, lazy loading chạy được dù production sẽ lỗi, và listener AFTER_COMMIT không bao giờ chạy. Em chỉ dùng cho test repository và luôn flush, clear trước khi assert. Test service thì commit thật và dọn dữ liệu riêng.
🇬🇧 English:
The test runs inside one transaction that is rolled back, so Hibernate may never flush, database constraints are not checked, reads come from the first-level cache, lazy loading works even though it would fail in production, and AFTER_COMMIT listeners never run. I only use it for repository tests and always flush and clear before asserting. Service tests commit for real and clean up separately.
Q2. Làm sao test race condition?
🇻🇳 Tiếng Việt:
Dùng ExecutorService với N thread, CountDownLatch để tất cả chờ ở vạch xuất phát rồi thả cùng lúc, gọi hàm nghiệp vụ trên DB thật, sau đó assert bất biến, ví dụ 50 lần nạp 10 thì số dư phải là 500. Gọi Future.get để exception trong thread không bị nuốt. Test này tăng xác suất lộ bug chứ không chứng minh code đúng.
🇬🇧 English:
I use an ExecutorService with N threads and a CountDownLatch so they all wait at a starting line and are released together, call the business method against a real database, and assert an invariant, for example 50 deposits of 10 must give a balance of 500. I call Future.get so exceptions in worker threads are not swallowed. It increases the chance of exposing the bug but does not prove correctness.
Q3. Test code async thế nào?
🇻🇳 Tiếng Việt:
Không dùng Thread.sleep. Dùng Awaitility để poll điều kiện với timeout: xanh ngay khi điều kiện đúng, đỏ rõ ràng khi hết thời gian.
🇬🇧 English:
Not with Thread.sleep. I use Awaitility to poll a condition with a timeout: the test passes as soon as the condition holds and fails clearly when the timeout expires.
Cách nhớ ngắn gọn
- Rollback test = không flush, không commit, cache, lazy giả.
- Repository test: flush + clear. Service test: commit thật.
- Race: pool + 2 latch (ready, start) + assert bất biến.
- Async: Awaitility, không sleep.
Tự kiểm tra
Vì sao test có @Transactional không bắt được vi phạm CHECK constraint?
Không flush nên câu SQL không xuống DB, cuối test rollback.
em.clear() dùng để làm gì trước khi assert?
Xóa first-level cache để findById đọc lại từ DB.
Hai latch ready và start có vai trò gì?
ready: main chờ mọi thread sẵn sàng. start: thả tất cả cùng lúc để tăng va chạm.
Vì sao phải gọi future.get()?
Exception trong thread pool bị giữ trong Future; không get thì test không thấy lỗi.
Listener AFTER_COMMIT có chạy trong test @Transactional không?
Không, vì transaction rollback, không bao giờ commit.