Bài Basics 42 đã lock các row product trong lúc một order trừ stock, sau khi đo xem chuyện gì xảy ra khi không lock: trong hai mươi order song song cho năm cái USB-C hub, có lần cả hai mươi order đều được nhận. Việc đó sửa được một use case bằng một công cụ. Cùng race condition ấy quay lại dưới những hình dạng khác: hai người cùng sửa một product, một batch job đổi giá cả catalogue trong lúc có người đang sửa, nhiều worker cùng lấy việc từ một queue, hai transaction lock các row theo thứ tự ngược nhau. PESSIMISTIC_WRITE chỉ là câu trả lời đúng cho một phần trong số đó.
Bài này đi qua phần còn lại của bộ công cụ và đo từng công cụ trên cùng một database: optimistic lock với @Version từ câu SQL tới HTTP status, retry một conflict mà không che giấu nó, các mode pessimistic khác cùng timeout của chúng, SKIP LOCKED cho work queue, một deadlock thật, và atomic update không cần lock với một CHECK constraint đứng sau. Các ví dụ dùng Spring Boot 4.1.1 và Java 21 với PostgreSQL 18, application chạy ở port 8207. Thời gian là trung vị hoặc lần tốt nhất, kèm load average một phút bên cạnh: để tham khảo, không phải benchmark.
![]()
Hai phần đầu dựng project và tái hiện lost update; mỗi phần sau đó thêm một công cụ, và phần cuối so sánh tất cả trên cùng một burst.
Project và test harness
Lại là catalogue sản phẩm, rút gọn còn những gì các race condition cần: một Product có giá, mô tả và stock, một GET và một PUT, sau đó thêm một endpoint giữ hàng. Locking là hành vi của database, nên project chỉ nói chuyện với PostgreSQL, và Flyway quản lý schema với ddl-auto=validate như bài Basics 31.
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,data-jpa,postgresql,flyway,validation" -o demo.zipdocker run -d --name sba-a7-pg -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=demo -p 5507:5432 postgres:18spring.application.name=demo
server.port=8207
spring.datasource.url=jdbc:postgresql://localhost:5507/demo
spring.datasource.username=demo
spring.datasource.password=demo
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=validate
logging.level.org.hibernate.SQL=debugcreate table products (
id bigint generated by default as identity primary key,
sku varchar(40) not null unique,
name varchar(120) not null,
description varchar(1000),
price numeric(10, 2) not null,
stock integer not null
);
insert into products (sku, name, description, price, stock) values
('KB-01', 'Mechanical keyboard', 'Tenkeyless, brown switches', 89.90, 10),
('MS-01', 'Wireless mouse', 'Two-button, USB-C receiver', 24.50, 10),
('HUB-07', 'USB-C hub', 'Seven ports, 100 W pass-through', 39.00, 50);package com.example.demo.product;
import java.math.BigDecimal;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 40, unique = true)
private String sku;
@Column(nullable = false, length = 120)
private String name;
@Column(length = 1000)
private String description;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Column(nullable = false)
private int stock;
protected Product() {
}
public void update(String name, String description, BigDecimal price) {
this.name = name;
this.description = description;
this.price = price;
}
public void decreaseStock(int quantity) {
if (quantity > stock) {
throw new InsufficientStockException(sku, stock, quantity);
}
stock -= quantity;
}
// getters for every field
}Service và controller có đúng hình dạng mà bài Basics 26 và 30 để lại: một service class read-only với một method ghi, và một controller map sang DTO.
package com.example.demo.product;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository products;
public ProductService(ProductRepository products) {
this.products = products;
}
public Product findById(Long id) {
return products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
}
@Transactional
public Product update(Long id, UpdateProductRequest request) {
Product product = findById(id);
product.update(request.name(), request.description(), request.price());
return product;
}
}package com.example.demo.product;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping("/{id}")
public ProductResponse findById(@PathVariable Long id) {
return ProductResponse.from(service.findById(id));
}
@PutMapping("/{id}")
public ProductResponse update(@PathVariable Long id, @Valid @RequestBody UpdateProductRequest request) {
return ProductResponse.from(service.update(id, request));
}
}UpdateProductRequest là một record gồm name, description và price với các constraint quen thuộc, còn ProductResponse trả về mọi cột. InsufficientStockException và ProductNotFoundException kế thừa ConflictException và NotFoundException của series, và GlobalExceptionHandler là class con của ResponseEntityExceptionHandler từ bài Basics 20, ở trạng thái bài Basics 42 để lại, cắt bớt còn những handler API này cần: 404, 409, 409 cho DataIntegrityViolationException, danh sách lỗi field 422 và handler bắt tất cả.
Thả các thread ra cùng một lúc
Một race condition phụ thuộc vào may rủi thì không chứng minh được gì, nên mọi race bên dưới đều bắt đầu cùng một cách: N thread cùng chờ trên một CountDownLatch, test đợi cho cả N thread sẵn sàng rồi mở latch một lần. Mỗi thread ghi lại ok hoặc tên ngắn của exception nó nhận, và test in ra số lượng.
package com.example.demo;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public final class Race {
public record Outcome(Map<String, Integer> counts, long millis) {
}
private Race() {
}
public static Outcome run(int threads, Runnable task) throws Exception {
CountDownLatch ready = new CountDownLatch(threads);
CountDownLatch start = new CountDownLatch(1);
List<Future<String>> results = new ArrayList<>();
try (ExecutorService pool = Executors.newFixedThreadPool(threads)) {
for (int i = 0; i < threads; i++) {
results.add(pool.submit(() -> {
ready.countDown();
start.await();
try {
task.run();
return "ok";
} catch (RuntimeException e) {
return e.getClass().getSimpleName();
}
}));
}
ready.await();
long begin = System.nanoTime();
start.countDown();
Map<String, Integer> counts = new TreeMap<>();
for (Future<String> result : results) {
counts.merge(result.get(), 1, Integer::sum);
}
return new Outcome(counts, (System.nanoTime() - begin) / 1_000_000);
}
}
}Các test là những class @SpringBootTest(webEnvironment = NONE) chạy trên cùng PostgreSQL đó, nên mỗi transaction là một transaction thật trên một connection thật. Pool HikariCP của Spring Boot giữ mặc định 10 connection, và JVM của test giữ 10 session trong pg_stat_activity: trong 100 thread, cùng lúc tối đa 10 thread ở trong database, số còn lại chờ connection. Các test in ra bằng System.out, thứ mà Gradle chỉ hiển thị khi có thêm một block trong build.gradle:
tasks.named('test') {
testLogging {
showStandardStreams = true
}
}./gradlew test --tests '*LostUpdateTest'Lost update: hai người cùng sửa một product
Alice hạ giá bàn phím. Cùng lúc đó, Bob thêm "PBT keycaps" vào mô tả của nó. Cả hai đều mở product trước:
curl -s http://localhost:8207/api/products/1{"id":1,"sku":"KB-01","name":"Mechanical keyboard","description":"Tenkeyless, brown switches","price":89.90,"stock":10}Cả hai nhận cùng body đó. Alice lưu form của mình:
curl -s -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","description":"Tenkeyless, brown switches","price":79.90}' http://localhost:8207/api/products/1{"id":1,"sku":"KB-01","name":"Mechanical keyboard","description":"Tenkeyless, brown switches","price":79.90,"stock":10}Rồi Bob lưu form của anh ấy, vẫn mang cái giá anh đã đọc:
curl -s -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","description":"Tenkeyless, brown switches, PBT keycaps","price":89.90}' http://localhost:8207/api/products/1{"id":1,"sku":"KB-01","name":"Mechanical keyboard","description":"Tenkeyless, brown switches, PBT keycaps","price":89.90,"stock":10}Cả hai đều trả 200, và một GET cuối cùng trả về body của Bob: giá của Alice đã mất, và không ai được báo. SQL của mỗi PUT giải thích lý do:
select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
update products set description=?,name=?,price=?,sku=?,stock=? where id=?Mặc định Hibernate ghi mọi cột của một entity đã thay đổi, và mệnh đề where chỉ có id. Ai ghi sau cùng sẽ quyết định mọi cột, kể cả những cột họ chưa hề đụng tới.
Qua HTTP, hai lần đọc và hai lần ghi cách nhau vài phút. Cùng kiểu xen kẽ đó bên trong server cần hai transaction đều đã đọc row trước khi bên nào ghi, và một CyclicBarrier cho hai thread bảo đảm được điều đó:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class LostUpdateTest {
@Autowired
ProductRepository products;
@Autowired
TransactionTemplate tx;
@Autowired
JdbcClient jdbc;
@Test
void twoEditorsOfOneProduct() throws Exception {
for (int round = 1; round <= 10; round++) {
jdbc.sql("update products set price = 89.90, description = 'Tenkeyless, brown switches' where id = 1").update();
CyclicBarrier bothLoaded = new CyclicBarrier(2);
String[] errors = new String[2];
Thread alice = new Thread(() -> errors[0] = edit(bothLoaded, p -> p.update(p.getName(), p.getDescription(), new BigDecimal("79.90"))));
Thread bob = new Thread(() -> errors[1] = edit(bothLoaded, p -> p.update(p.getName(), "Tenkeyless, brown switches, PBT keycaps", p.getPrice())));
alice.start();
bob.start();
alice.join();
bob.join();
// read price and description back with JdbcClient, count and print the round
}
}
private String edit(CyclicBarrier bothLoaded, Consumer<Product> change) {
try {
tx.executeWithoutResult(status -> {
Product product = products.findById(1L).orElseThrow();
await(bothLoaded); // both transactions have read the row
change.accept(product);
});
return null;
} catch (RuntimeException e) {
return e.getClass().getSimpleName();
}
}
}round 1: price 79.90, description 'Tenkeyless, brown switches', alice committed, bob committed
round 2: price 79.90, description 'Tenkeyless, brown switches', alice committed, bob committed
round 3: price 89.90, description 'Tenkeyless, brown switches, PBT keycaps', alice committed, bob committed
both kept 0, price lost 5, description lost 5, rounds with an exception 0Mười vòng, mười lần mất một thay đổi, không một exception. Thay đổi nào sống sót phụ thuộc vào commit nào tới sau. Đây chính là kiểu read-modify-write đã bán lố hub trong bài Basics 42; ở đó giá trị bị mất là con số stock, ở đây là công sức của một người.
Optimistic lock với @Version
Optimistic lock không lấy lock nào khi đọc. Nó thêm một cột version, và mỗi câu UPDATE nói rõ nó dựa trên version nào; khi một transaction khác tới trước, câu UPDATE không khớp row nào và Hibernate throw. Một migration và một field:
alter table products add column version bigint not null default 0;import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "products")
public class Product {
// ...
@Column(nullable = false)
private int stock;
@Version
private Long version;
// ...
public Long getVersion() {
return version;
}
}Cùng test đó, không sửa gì, giờ gửi câu UPDATE này:
update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?Hibernate bind version mới vào set và version nó đã load vào where, rồi kiểm tra đúng một row bị thay đổi. Mười vòng:
round 1: price 79.90, description 'Tenkeyless, brown switches', alice committed, bob ObjectOptimisticLockingFailureException
round 2: price 79.90, description 'Tenkeyless, brown switches', alice committed, bob ObjectOptimisticLockingFailureException
round 3: price 89.90, description 'Tenkeyless, brown switches, PBT keycaps', alice ObjectOptimisticLockingFailureException, bob committed
both kept 0, price lost 8, description lost 2, rounds with an exception 10Mỗi vòng vẫn có một thay đổi không được giữ, nhưng nó không còn biến mất âm thầm: ở mọi vòng đúng một transaction commit và transaction kia nhận một exception có thể báo lại cho người dùng. Phần đọc không đổi gì; toàn bộ việc kiểm tra version nằm ở phần ghi.
Câu UPDATE thua không thất bại ngay lập tức. Một test flush UPDATE của A, giữ transaction của A mở thêm một giây, và để B flush cùng câu UPDATE đó 200 ms sau, in ra:
A flushed at 27 ms, holding the row for 1000 ms
B flushes at 228 ms
A committed at 1037 ms
B threw ObjectOptimisticLockingFailureException at 1046 msUPDATE của B chờ row lock của A, và khi A commit, PostgreSQL đánh giá lại where id=? and version=? trên row đã commit, thấy version 4 thay vì 3, và không cập nhật gì.
Exception nào tới được code của bạn
Toàn bộ chuỗi cause của bên thua, đúng như test in ra:
org.springframework.orm.ObjectOptimisticLockingFailureException: Unexpected row count (expected row count 1 but was 0) [update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?] for entity [com.example.demo.product.Product with id '1']
org.hibernate.StaleObjectStateException: Unexpected row count (expected row count 1 but was 0) [update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?] for entity [com.example.demo.product.Product with id '1']
org.hibernate.StaleStateException: Unexpected row count (expected row count 1 but was 0) [update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?]Câu UPDATE chạy lúc commit, khi dirty checking flush, nên exception đi ra từ bước commit của transaction manager và Spring đã translate nó rồi. jakarta.persistence.OptimisticLockException, cái tên mà đặc tả JPA dùng, không hề xuất hiện trong chuỗi đó. Nó chỉ xuất hiện khi bạn tự flush qua EntityManager, như một test thứ hai đã thử, với row bị một thread khác sửa giữa lúc đọc và lúc flush:
| Ai flush | Code của bạn bắt được gì | Cause của nó |
|---|---|---|
commit của method @Transactional hoặc TransactionTemplate | ObjectOptimisticLockingFailureException | org.hibernate.StaleObjectStateException |
productRepository.flush() hoặc saveAndFlush | ObjectOptimisticLockingFailureException | org.hibernate.StaleObjectStateException |
EntityManager.flush() được inject, gọi bên ngoài repository | jakarta.persistence.OptimisticLockException | org.hibernate.StaleObjectStateException |
Repository và transaction manager có translate; EntityManager được inject và dùng bên ngoài repository, trong một service hay như ở đây trong test class, thì không. Code bắt OptimisticLockingFailureException của Spring, class cha của ObjectOptimisticLockingFailureException, chỉ phủ được hai dòng đầu, thêm một lý do để việc flush cho repository lo.
@Version cũng bảo vệ saveAll và dirty checking
Test ở trên chưa từng gọi save: dirty checking ghi thay đổi, và version vẫn được kiểm tra. Con đường còn lại là saveAll với các entity load từ một transaction trước đó, kiểu batch job điển hình đọc hết, xử lý, rồi ghi lại:
@Test
void saveAllOfStaleDetachedEntities() {
List<Product> all = products.findAll(); // detached once the repository call returns
productService.update(2L, new UpdateProductRequest("Wireless mouse", "Two-button, USB-C receiver",
new BigDecimal("22.00"), 0L));
all.forEach(p -> p.update(p.getName(), p.getDescription(), p.getPrice().multiply(new BigDecimal("1.10"))));
products.saveAll(all); // wrapped in try/catch and printed in the real test
}after the edit: [{id=1, price=89.90, version=0}, {id=2, price=22.00, version=1}, {id=3, price=39.00, version=0}]
>>> saveAll
select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=?
select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=?
saveAll threw
chain: org.springframework.orm.ObjectOptimisticLockingFailureException: Row was already updated or deleted by another transaction for entity [com.example.demo.product.Product with id '2']
chain: org.hibernate.StaleObjectStateException: Row was already updated or deleted by another transaction for entity [com.example.demo.product.Product with id '2']
after saveAll: [{id=1, price=89.90, version=0}, {id=2, price=22.00, version=1}, {id=3, price=39.00, version=0}]saveAll merge từng entity detached, và một lần merge load row rồi so sánh version trước khi gửi bất kỳ câu UPDATE nào. Version của product 2 đã đi từ 0 lên 1, nên merge throw với một message khác, "Row was already updated or deleted by another transaction", và vì saveAll là một transaction, giá mới của product 1 cũng không được ghi.
Bulk update @Modifying bỏ qua version
Một câu update JPQL chạy trong database mà không load entity nào, nên Hibernate không có version để so sánh. Hai method repository, method thứ hai dùng update versioned của Hibernate:
@Modifying
@Query("update Product p set p.price = p.price * :factor")
int repriceAll(BigDecimal factor);
@Modifying
@Query("update versioned Product p set p.price = p.price * :factor")
int repriceAllVersioned(BigDecimal factor);Test load product 1 trong transaction của một người sửa, chạy tăng giá 10 % cho cả catalogue trong transaction riêng, rồi cho người sửa đổi mô tả và commit:
>>> bulk update, versioned=false
update products p1_0 set price=(p1_0.price*?)
bulk update changed 3 rows
after the bulk update: [{id=1, price=98.89, version=0}, {id=2, price=26.95, version=0}, {id=3, price=42.90, version=0}]
update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?
editor committed
after the editor: [{id=1, price=89.90, version=1}, {id=2, price=26.95, version=0}, {id=3, price=42.90, version=0}]Lần tăng giá để mọi version ở 0, nên where version=0 của người sửa vẫn khớp, và câu UPDATE ghi mọi cột của nó đặt lại giá cũ: phần tăng giá của product 1 biến mất mà không có lỗi nào. Với update versioned:
>>> bulk update, versioned=true
update products p1_0 set price=(p1_0.price*?),version=(p1_0.version+1)
bulk update changed 3 rows
after the bulk update: [{id=1, price=98.89, version=1}, {id=2, price=26.95, version=1}, {id=3, price=42.90, version=1}]
update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?
editor threw
chain: org.springframework.orm.ObjectOptimisticLockingFailureException: Unexpected row count (expected row count 1 but was 0) [update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?] for entity [com.example.demo.product.Product with id '1']update versioned là HQL, không phải JPQL, và Spring Data JPA 4.1.1 chuyển nó qua @Query nguyên vẹn.
Version qua HTTP: 409 với ProblemDetail
Trong một transaction, version được kiểm tra thay bạn. Qua hai request, cái GET hiện form cho Alice và cái PUT lưu nó, server phải được cho biết form dựa trên version nào. Có hai quy ước. Quy ước của chính HTTP đặt version vào header ETag của GET và chờ nó quay lại trong If-Match của PUT, trả về 412 Precondition Failed khi không khớp. Quy ước còn lại đặt version vào JSON và trả về 409 Conflict.
API này dùng body và 409. Series đã trả mọi conflict bằng 409, và có hai cách để thua race này, cả hai nên trông giống nhau với client: một version cũ bị check trong service bắt được, và hai request cùng qua check đó rồi va nhau ở câu UPDATE, tới advice dưới dạng ObjectOptimisticLockingFailureException. Với version trong body, cả hai đều là 409 mà không cần xử lý riêng. ETag và If-Match là lựa chọn tốt hơn khi client HTTP chung chung, cache hoặc một DELETE không có body phải tham gia.
package com.example.demo.product;
import java.math.BigDecimal;
public record ProductResponse(Long id, String sku, String name, String description, BigDecimal price, int stock,
Long version) {
static ProductResponse from(Product product) {
return new ProductResponse(product.getId(), product.getSku(), product.getName(),
product.getDescription(), product.getPrice(), product.getStock(), product.getVersion());
}
}package com.example.demo.product;
import java.math.BigDecimal;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
public record UpdateProductRequest(
@NotBlank @Size(max = 120) String name,
@Size(max = 1000) String description,
@NotNull @Positive @Digits(integer = 8, fraction = 2) BigDecimal price,
@NotNull Long version) {
}package com.example.demo.product;
import com.example.demo.common.ConflictException;
public class StaleProductException extends ConflictException {
public StaleProductException(Long id, Long current, Long expected) {
super("Product " + id + " is at version " + current + ", the update was based on version " + expected);
}
} @Transactional
public Product update(Long id, UpdateProductRequest request) {
Product product = findById(id);
if (!Objects.equals(product.getVersion(), request.version())) {
throw new StaleProductException(id, product.getVersion(), request.version());
}
product.update(request.name(), request.description(), request.price());
return product;
}Check này so version của client với version vừa load; where version=? của câu UPDATE lo phần khoảng trống giữa lần load đó và lúc commit. Service không bao giờ ghi vào field version của entity; việc đó để Hibernate lo. Advice có thêm một handler, cạnh handler ConflictException của series:
@ExceptionHandler(ConflictException.class)
public ProblemDetail conflict(ConflictException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
@ExceptionHandler(OptimisticLockingFailureException.class)
public ProblemDetail concurrentUpdate(OptimisticLockingFailureException ex) {
log.info("Optimistic lock conflict: {}", ex.getMessage());
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"The resource was changed by another request. Reload it and apply your change again.");
} Chạy lại kịch bản của phần đầu, với product 1 đang ở version 3 (đặt thẳng trong table để các con số khớp với sơ đồ bên dưới). Cả hai người dùng đọc nó:
curl -s http://localhost:8207/api/products/1{"id":1,"sku":"KB-01","name":"Mechanical keyboard","description":"Tenkeyless, brown switches","price":89.90,"stock":10,"version":3}Alice lưu trước:
curl -i -s -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","description":"Tenkeyless, brown switches","price":79.90,"version":3}' http://localhost:8207/api/products/1HTTP/1.1 200
Content-Type: application/json
Content-Length: 131
Date: Fri, 18 Sep 2026 05:04:15 GMT
{"id":1,"sku":"KB-01","name":"Mechanical keyboard","description":"Tenkeyless, brown switches","price":79.90,"stock":10,"version":4}Response đã mang version 4: controller map entity sau khi commit, và Hibernate tăng field version của entity đang được quản lý khi nó flush. Bob lưu với version anh đã đọc:
curl -i -s -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","description":"Tenkeyless, brown switches, PBT keycaps","price":89.90,"version":3}' http://localhost:8207/api/products/1HTTP/1.1 409
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Fri, 18 Sep 2026 05:04:15 GMT
{"detail":"Product 1 is at version 4, the update was based on version 3","instance":"/api/products/1","status":409,"title":"Conflict"}Client nào bỏ quên version sẽ nhận 422 của series:
HTTP/1.1 422
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Fri, 18 Sep 2026 05:04:15 GMT
{"detail":"Request has 1 invalid value(s).","instance":"/api/products/1","status":422,"title":"Unprocessable Content","errors":[{"field":"version","message":"must not be null"}]}Client của Bob tải lại, thấy giá của Alice, áp lại mô tả của anh và gửi "version":4: 200, với "price":79.90, mô tả mới và "version":5. Cả hai thay đổi đều sống, vì một con người đã gộp chúng.
Các request tuần tự chỉ gặp check trong service. Check của chính câu UPDATE cần những request cùng qua check trong service, nên mười PUT với cùng version được gửi song song, mỗi cái một mô tả riêng và body của nó lưu trong thư mục put:
seq 1 10 | xargs -P 10 -I{} sh -c "curl -s -o put/{}.json -w '%{http_code}\n' -X PUT -H 'Content-Type: application/json' -d '{\"name\":\"Mechanical keyboard\",\"description\":\"Run 1, edit {}\",\"price\":79.90,\"version\":10}' http://localhost:8207/api/products/1" | sort | uniq -c 1 200
9 409cat put/*.json | jq -r '.detail // "200 OK"' | sort | uniq -c 1 200 OK
6 Product 1 is at version 11, the update was based on version 10
3 The resource was changed by another request. Reload it and apply your change again.Sáu request đọc row sau khi bên thắng commit và trượt check trong service. Ba request đã qua check đó và thua ở câu UPDATE, application log ghi lại là Optimistic lock conflict: Unexpected row count (expected row count 1 but was 0) [update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?] for entity [com.example.demo.product.Product with id '1']. Hai lần chạy nữa chia chín cái 409 thành 6/3 và 7/2, luôn kèm đúng một 200. Các mô tả khác nhau là có chủ ý: ở một lần chạy trước, mọi request gửi cùng một mô tả, request trùng với row đang lưu không thay đổi gì, Hibernate không gửi UPDATE nào và không tăng version, và nó trả 200 cạnh bên thắng thật.

Retry một optimistic conflict
Một cái 409 trả conflict về cho người dùng, và điều đó đúng với việc sửa dữ liệu: chỉ Bob mới quyết định được mô tả của anh kết hợp với giá của Alice thế nào. Có những thao tác không cần người dùng. Giữ một cái hub là thay đổi tương đối: đọc stock, kiểm tra, trừ một. Dù transaction khác đã làm gì ở giữa, áp cùng thao tác đó lên trạng thái mới vẫn đúng, nên server có thể retry. Với @Version trên Product, đoạn read-modify-write này thành optimistic mà không sửa dòng nào:
@Service
public class StockService {
private final ProductRepository products;
public StockService(ProductRepository products) {
this.products = products;
}
@Transactional
public void reserve(Long id, int quantity) {
Product product = products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
product.decreaseStock(quantity);
}
}Retry phải bắt đầu một transaction mới ở mỗi lần thử, nên nó nằm ở một bean khác và gọi reserve qua proxy:
package com.example.demo.product;
import java.util.concurrent.ThreadLocalRandom;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.stereotype.Service;
@Service
public class StockReservations {
private static final Logger log = LoggerFactory.getLogger(StockReservations.class);
private static final int MAX_ATTEMPTS = 5;
private final StockService stock;
public StockReservations(StockService stock) {
this.stock = stock;
}
public void reserve(Long id, int quantity) {
for (int attempt = 1; ; attempt++) {
try {
stock.reserve(id, quantity);
return;
} catch (OptimisticLockingFailureException e) {
if (attempt == MAX_ATTEMPTS) {
throw e;
}
log.debug("Conflict on product {}, attempt {} of {}", id, attempt, MAX_ATTEMPTS);
backOff(attempt);
}
}
}
private static void backOff(int attempt) {
try {
Thread.sleep(ThreadLocalRandom.current().nextLong(1, 10L * attempt));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while retrying", e);
}
}
}Mỗi lần thử là một lời gọi StockService.reserve mới: transaction mới, persistence context mới, một câu select mới đọc stock và version đã commit. Test gửi cùng lúc 20 lượt giữ hàng cho một hub còn 50 cái, ba vòng cho mỗi biến thể, và đếm số lần retry từ dòng DEBUG:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = {"logging.level.org.hibernate.SQL=info", "logging.level.com.example.demo.product.StockReservations=debug"})
class RetryTest {
// StockService stock, StockReservations reservations and JdbcClient jdbc are @Autowired
@Test
void retryPlacement() throws Exception {
run("no retry", () -> stock.reserve(HUB, 1));
run("retry around the transaction", () -> reservations.reserve(HUB, 1));
run("retry inside one transaction", () -> reservations.reserveInOneTransaction(HUB, 1));
}
private void run(String name, Runnable task) throws Exception {
for (int round = 1; round <= 3; round++) {
jdbc.sql("update products set stock = 50 where id = ?").param(HUB).update();
Race.Outcome outcome = Race.run(20, task);
// print outcome.counts() and the stock left
}
}
}| 20 lượt giữ hàng, còn 50, 3 vòng | Bán được | Vẫn thất bại | Số retry trong log |
|---|---|---|---|
| không retry | 2, 3, 3 | 18, 17, 17 | không có |
retry, bản đầu chưa có backOff (load 3.0) | 10, 11, 12 | 10, 9, 8 | 59, 55, 51 |
retry với backOff ngẫu nhiên ở trên (load 4.1) | 18, 20, 20 | 2, 0, 0 | 41, 35, 45 |
@Transactional trên method làm retry | 2, 2, 3 | 18, 18, 17 | không có |
Không có retry, 17 hoặc 18 trên 20 khách bị từ chối trong khi 47 hoặc 48 cái hub vẫn nằm trong kho. Retry ngay lập tức giúp ít hơn mong đợi: những request thua cùng lúc thì retry cùng lúc và lại va nhau, 8 tới 10 request vẫn hết lượt thử. Một khoảng sleep ngẫu nhiên tới 10 ms nhân với số thứ tự lần thử đã giãn chúng ra, và 58 trên 60 lượt giữ hàng thành công.
Retry chỉ đúng vì reserve đọc lại và áp lại. PUT là trường hợp ngược lại. Một test bọc cùng kiểu retry quanh một lần sửa toàn bộ trạng thái, mỗi người gửi cả form với những giá trị họ đã đọc, có một người retry ở mỗi vòng trong 10 vòng, và ở cả 10 vòng thay đổi của người kia biến mất: retry load version mới, áp form cũ lên trên, rồi commit. Đó là lost update của phần đầu, được retry đưa trở lại. Một conflict cần ý định của người dùng để giải quyết thì phải quay về người dùng dưới dạng 409.
Retry phải nằm ở đâu
Dòng cuối của bảng là lỗi tự nhiên nhất: đặt @Transactional lên method làm retry.
@Transactional
public void reserveInOneTransaction(Long id, int quantity) {
for (int attempt = 1; ; attempt++) {
try {
stock.reserve(id, quantity);
return;
} catch (OptimisticLockingFailureException e) {
if (attempt == MAX_ATTEMPTS) {
throw e;
}
log.debug("Conflict on product {}, attempt {} of {}", id, attempt, MAX_ATTEMPTS);
}
}
}Nó không log một lần retry nào. stock.reserve giờ tham gia transaction bên ngoài, không có gì được flush bên trong vòng lặp, và câu UPDATE chạy khi method bên ngoài đã return. Stack trace của một request thua cho thấy vị trí:
org.springframework.orm.ObjectOptimisticLockingFailureException: Unexpected row count (expected row count 1 but was 0) [update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?] for entity [com.example.demo.product.Product with id '3']
at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:794)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:757)
at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:687)
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:408)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:130)
at com.example.demo.product.StockReservations$$SpringCGLIB$$0.reserveInOneTransaction(<generated>)Flush bên trong vòng lặp cũng không cứu được. Một biến thể gọi saveAndFlush ở mỗi lần thử, bên trong một transaction, với một thread khác commit một lượt giữ hàng giữa lúc đọc và lúc flush, in ra:
attempt 1 read stock 50 version 108
attempt 1 caught ObjectOptimisticLockingFailureException
attempt 2 read stock 49 version 108
attempt 2 caught ObjectOptimisticLockingFailureException
attempt 3 read stock 48 version 108
attempt 3 caught ObjectOptimisticLockingFailureException
outer threw org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-onlyLần thử 2 và 3 không gửi câu select nào: findById trả về cùng entity đang được quản lý trong persistence context, vẫn ở version 108 và đã mang phần trừ stock của lần thử trước. Và saveAndFlush là một method repository có transaction, nên proxy của nó đã đánh dấu transaction là rollback-only ngay lần thất bại đầu tiên, đúng cái bẫy của bài Basics 30. Retry chỉ hoạt động khi bọc quanh cả transaction. Spring Framework 7 cũng có sẵn @Retryable dạng khai báo trong org.springframework.resilience.annotation, bật bằng @EnableResilientMethods; bài 19 của khoá này nói về nó và các quy tắc đặt nó.
Pessimistic lock ngoài PESSIMISTIC_WRITE
Bài Basics 42 dùng @Lock(LockModeType.PESSIMISTIC_WRITE), được Hibernate 7.4.5 gửi tới PostgreSQL dưới dạng for no key update, đo được rằng nó chặn việc bán lố, và lock các product theo thứ tự id. JPA còn một mode yếu hơn và một hint timeout, và Hibernate map một giá trị timeout đặc biệt sang SKIP LOCKED của PostgreSQL:
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Product> findForUpdateById(Long id);
@Lock(LockModeType.PESSIMISTIC_READ)
Optional<Product> findForShareById(Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000"))
Optional<Product> findForUpdateWithTimeoutById(Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "0"))
Optional<Product> findForUpdateNoWaitById(Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "-2"))
Optional<Product> findForUpdateSkipLockedById(Long id);Các giá trị đặc biệt của hint là hằng số của org.hibernate.Timeouts trong bản 7.4.5: NO_WAIT_MILLI = 0, WAIT_FOREVER_MILLI = -1 và SKIP_LOCKED_MILLI = -2; mọi giá trị dương là mili giây. Để xem transaction thứ hai gặp gì, một test cho transaction đầu lấy lock và giữ 1.5 s, khởi động transaction thứ hai 100 ms sau, rồi đo thời gian:
void contend(String label, Runnable holderLock, long holdMillis, Supplier<Object> contender) throws Exception {
CountDownLatch locked = new CountDownLatch(1);
Thread holder = new Thread(() -> tx.executeWithoutResult(s -> {
holderLock.run();
locked.countDown();
sleep(holdMillis);
}));
holder.start();
locked.await();
sleep(100);
long begin = System.nanoTime();
// tx.execute(s -> contender.get()), then print the result or the exception chain
// and the milliseconds since begin
}
@Test
void lockModes() throws Exception {
Runnable write = () -> products.findForUpdateById(3L).orElseThrow();
Runnable read = () -> products.findForShareById(3L).orElseThrow();
contend("WRITE held, WRITE requested", write, 1500, () -> products.findForUpdateById(3L).map(Product::getStock));
contend("WRITE held, READ requested", write, 1500, () -> products.findForShareById(3L).map(Product::getStock));
contend("WRITE held, plain read", write, 1500, () -> products.findById(3L).map(Product::getStock));
contend("READ held, READ requested", read, 1500, () -> products.findForShareById(3L).map(Product::getStock));
contend("READ held, WRITE requested", read, 1500, () -> products.findForUpdateById(3L).map(Product::getStock));
contend("WRITE held, NOWAIT", write, 1500, () -> products.findForUpdateNoWaitById(3L).map(Product::getStock));
contend("WRITE held, timeout 2000", write, 3000, () -> products.findForUpdateWithTimeoutById(3L).map(Product::getStock));
contend("WRITE held, SKIP LOCKED", write, 1500, () -> products.findForUpdateSkipLockedById(3L).map(Product::getStock));
}Với load average một phút ở mức 3.9:
| Transaction đầu đang giữ | Transaction thứ hai xin | Mệnh đề Hibernate thêm vào | Transaction thứ hai nhận được |
|---|---|---|---|
PESSIMISTIC_WRITE | PESSIMISTIC_WRITE | for no key update of p1_0 | row sau 1413 ms |
PESSIMISTIC_WRITE | PESSIMISTIC_READ | for share of p1_0 | row sau 1399 ms |
PESSIMISTIC_WRITE | findById thường | không có | row đã commit gần nhất sau 7 ms |
PESSIMISTIC_READ | PESSIMISTIC_READ | for share of p1_0 | row sau 3 ms |
PESSIMISTIC_READ | PESSIMISTIC_WRITE | for no key update of p1_0 | row sau 1405 ms |
PESSIMISTIC_WRITE | ghi, timeout 0 | for no key update of p1_0 nowait | CannotAcquireLockException sau 8 ms |
PESSIMISTIC_WRITE, giữ 3 s | ghi, timeout 2000 | for no key update of p1_0, sau set local lock_timeout = 2000 | CannotAcquireLockException sau 2015 ms |
PESSIMISTIC_WRITE | ghi, timeout -2 | for no key update of p1_0 skip locked | Optional.empty sau 12 ms |
Ba điều đáng chú ý. Lần đọc thường hoàn toàn không chờ row lock, nên một lock chỉ bảo vệ những đoạn code cũng lấy lock. Hai PESSIMISTIC_READ cùng chia sẻ một row, và chính điều đó khiến chúng nguy hiểm cho read-modify-write. Và mọi thất bại đều cùng một kiểu của Spring, CannotAcquireLockException, bất kể PostgreSQL nói gì.

PESSIMISTIC_READ và việc nâng cấp lock
for share trông như lock rẻ hơn cho việc "đọc stock rồi trừ đi". Hai transaction cùng lấy nó, chờ nhau ở một barrier, rồi cùng trừ stock:
tx.executeWithoutResult(s -> {
Product product = products.findForShareById(3L).orElseThrow();
await(bothShared);
product.decreaseStock(1);
products.flush();
});shared round 1: committed after 1025 ms, CannotAcquireLockException after 1023 ms, stock 49
shared round 2: committed after 1011 ms, CannotAcquireLockException after 1011 ms, stock 49
shared round 3: committed after 1004 ms, CannotAcquireLockException after 1004 ms, stock 49
PESSIMISTIC_READ then update: rounds with a failure 10 of 10Mỗi câu UPDATE cần row một cách độc quyền và chờ share lock của transaction kia: deadlock ở mọi vòng, trên đúng một row, báo về dạng ERROR: deadlock detected while updating tuple (15,3) in relation "products" ở câu UPDATE. PESSIMISTIC_READ giữ cho một row không đổi trong lúc bạn chỉ đọc nó; một giá trị bạn sắp ghi thì cần PESSIMISTIC_WRITE ngay từ đầu.
Lock timeout: NOWAIT và jakarta.persistence.lock.timeout
Không có hint, một pessimistic lock chờ chừng nào bên giữ còn giữ. Timeout 0 khiến PostgreSQL từ chối ngay:
select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=? for no key update of p1_0 nowait
HHH000247: ErrorCode: 0, SQLState: 55P03
ERROR: could not obtain lock on row in relation "products"
chain: org.springframework.dao.CannotAcquireLockException: JDBC exception executing SQL [ERROR: could not obtain lock on row in relation "products"] [select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=? for no key update of p1_0 nowait]; SQL [select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=? for no key update of p1_0 nowait]
chain: org.hibernate.exception.LockTimeoutException: JDBC exception executing SQL [ERROR: could not obtain lock on row in relation "products"] [select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=? for no key update of p1_0 nowait]
chain: org.postgresql.util.PSQLException: ERROR: could not obtain lock on row in relation "products"PostgreSQL không có mệnh đề wait N, nên với timeout dương, Hibernate 7.4.5 đổi setting của session quanh câu query. Statement log của chính server (log_statement = 'all') cho hint 2000 ms trong lúc một transaction khác giữ row:
LOG: execute <unnamed>: select current_setting('lock_timeout', true)
LOG: execute <unnamed>: set local lock_timeout = 2000
LOG: execute <unnamed>: select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=$1 for no key update of p1_0
ERROR: canceling statement due to lock timeout
CONTEXT: while locking tuple (15,2) in relation "products"
LOG: execute S_2: ROLLBACKLỗi có cùng SQLSTATE 55P03 và tới Java qua cùng ba class, CannotAcquireLockException với cause là org.hibernate.exception.LockTimeoutException, cause của nó là PSQLException: ERROR: canceling statement due to lock timeout, 2015 ms sau khi query bắt đầu. Khi lock được cấp kịp, Hibernate đặt lại giá trị cũ ngay sau query, nên timeout không rò sang phần còn lại của transaction:
select current_setting('lock_timeout', true)
set local lock_timeout = 2000
set local lock_timeout = 0
lock_timeout after the hinted query: 0Tức là hai câu lệnh trước query và một câu sau, ở mọi lần gọi. Trong burst 100 thread ở phần cuối, reserveWithLock có hint mất trung vị 57 ms so với 34 ms cho cùng lock đó không có hint (load 2.4 tới 3.1): các round trip thêm chạy trong lúc các transaction khác xếp hàng chờ row.
Lock timeout nên có trên mọi đường đi của request, vì một request chờ mãi mãi sẽ giữ một thread và một connection mãi mãi. Endpoint giữ hàng dùng method có hint:
@Transactional
public void reserveWithLock(Long id, int quantity) {
Product product = products.findForUpdateWithTimeoutById(id).orElseThrow(() -> new ProductNotFoundException(id));
product.decreaseStock(quantity);
} @PostMapping("/{id}/reservations")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void reserve(@PathVariable Long id, @Valid @RequestBody ReservationRequest request) {
stock.reserveWithLock(id, request.quantity());
} ReservationRequest là một record có một field @NotNull @Positive Integer quantity. Advice cũng map các thất bại pessimistic sang 409 của series, với detail báo cho client biết thử lại là hợp lý:
@ExceptionHandler(PessimisticLockingFailureException.class)
public ProblemDetail lockNotAcquired(PessimisticLockingFailureException ex) {
log.info("Lock not acquired: {}", ex.getMostSpecificCause().getMessage());
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"The resource is locked by another request. Try again.");
} Để giữ row từ bên ngoài application, psql lấy lock và sleep 5 s trong cùng một transaction:
docker exec sba-a7-pg psql -U demo -d demo -c "begin" -c "select id, stock from products where id = 3 for update" -c "select pg_sleep(5)" -c "commit"curl -i -s -H 'Content-Type: application/json' -d '{"quantity":1}' -w 'time %{time_total}s\n' http://localhost:8207/api/products/3/reservationsHTTP/1.1 409
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Fri, 18 Sep 2026 05:05:04 GMT
{"detail":"The resource is locked by another request. Try again.","instance":"/api/products/3/reservations","status":409,"title":"Conflict"}time 2.037096sCùng request đó khi không có lock của psql trả HTTP/1.1 204 trong 0.045 s.
Work queue với SKIP LOCKED
Pattern mà SKIP LOCKED sinh ra để phục vụ là nhiều worker lấy job từ một table, mỗi job chỉ làm một lần, không worker nào chờ worker nào. Table jobs, và một table job_runs nơi mỗi lần xử lý để lại một row, đại diện cho tác dụng phụ không được xảy ra hai lần, như một email hay một lời gọi thanh toán:
create table jobs (
id bigint generated by default as identity primary key,
payload varchar(200) not null,
status varchar(20) not null,
processed_by varchar(40),
processed_at timestamp with time zone
);
create index jobs_status_id_idx on jobs (status, id);
create table job_runs (
id bigint generated by default as identity primary key,
job_id bigint not null references jobs (id),
worker varchar(40) not null
);Job là một entity có JobStatus là NEW hoặc DONE và một method complete(worker) đặt status, worker và thời điểm. Repository có cùng một query ba lần, chỉ khác nhau ở phần lock:
package com.example.demo.job;
import java.util.List;
import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
import org.springframework.data.domain.Limit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryHints;
public interface JobRepository extends JpaRepository<Job, Long> {
@Query("select j from Job j where j.status = :status order by j.id")
List<Job> findNext(JobStatus status, Limit limit);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select j from Job j where j.status = :status order by j.id")
List<Job> findNextLocked(JobStatus status, Limit limit);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "-2"))
@Query("select j from Job j where j.status = :status order by j.id")
List<Job> findNextSkippingLocked(JobStatus status, Limit limit);
}select j1_0.id,j1_0.payload,j1_0.processed_at,j1_0.processed_by,j1_0.status from jobs j1_0 where j1_0.status=? order by j1_0.id fetch first ? rows only
select j1_0.id,j1_0.payload,j1_0.processed_at,j1_0.processed_by,j1_0.status from jobs j1_0 where j1_0.status=? order by j1_0.id fetch first ? rows only for no key update of j1_0
select j1_0.id,j1_0.payload,j1_0.processed_at,j1_0.processed_by,j1_0.status from jobs j1_0 where j1_0.status=? order by j1_0.id fetch first ? rows only for no key update of j1_0 skip lockedLimit của Spring Data thành fetch first ? rows only, và mệnh đề lock đứng ngay sau nó, không cần follow-on locking. Một worker nhận tối đa mười job, xử lý từng job trong cùng transaction để các row vẫn bị lock cho tới khi được đánh dấu xong, rồi commit:
package com.example.demo.job;
import java.util.List;
import org.springframework.data.domain.Limit;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class JobProcessor {
private static final Limit BATCH = Limit.of(10);
private final JobRepository jobs;
private final JdbcClient jdbc;
public JobProcessor(JobRepository jobs, JdbcClient jdbc) {
this.jobs = jobs;
this.jdbc = jdbc;
}
@Transactional
public int processNextBatch(String worker, ClaimStrategy strategy) {
List<Job> batch = switch (strategy) {
case NO_LOCK -> jobs.findNext(JobStatus.NEW, BATCH);
case LOCK -> jobs.findNextLocked(JobStatus.NEW, BATCH);
case SKIP_LOCKED -> jobs.findNextSkippingLocked(JobStatus.NEW, BATCH);
};
for (Job job : batch) {
process(job, worker);
job.complete(worker);
}
return batch.size();
}
private void process(Job job, String worker) {
// the side effect that must happen once per job: an email, a payment call...
jdbc.sql("insert into job_runs (job_id, worker) values (?, ?)").params(job.getId(), worker).update();
try {
Thread.sleep(2);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}ClaimStrategy chỉ tồn tại để một test so sánh được ba query; một processor thật chỉ gọi findNextSkippingLocked. Test khởi động bốn worker trên 1000 job NEW, thả ra cùng lúc bằng một latch, mỗi worker gọi processNextBatch cho tới khi nó trả về 0, rồi đếm job_runs:
batches.add(pool.submit(() -> {
start.await();
int n = 0;
long longest = 0;
while (true) {
long t0 = System.nanoTime();
int size = processor.processNextBatch(worker, strategy);
longest = Math.max(longest, (System.nanoTime() - t0) / 1_000_000);
if (size == 0) {
break;
}
n++;
}
// print the worker's batches, longest call and the jobs still NEW
return n;
}));Ba vòng cho mỗi query, load average 4.0 tới 4.4:
| Query nhận job | Tổng thời gian | Số row job_runs cho 1000 job | Job bị xử lý hơn một lần | Số job mỗi worker | Lần gọi lâu nhất |
|---|---|---|---|---|---|
| không lock | 3091, 2956, 3199 ms | 4000, 4000, 3980 | 3000, 3000, 2980 | 990 tới 1000 mỗi worker | 35 tới 105 ms |
for no key update | 2689, 2487, 2547 ms | 1000 | 0 | 110 tới 400 | 280 tới 1300 ms |
for no key update … skip locked | 720, 905, 883 ms | 1000 | 0 | 250 mỗi worker | 40 tới 53 ms |
Không có lock, bốn worker đọc cùng mười row NEW, xử lý hết, và đi cùng nhịp suốt lần chạy: gần như mọi job chạy bốn lần. Với lock thường, không job nào chạy hai lần, nhưng các worker xếp hàng sau nhau. Một worker đang chờ, khi bên giữ commit, thấy các row của nó đã DONE, đi tiếp sang các row sau, và thường thấy chúng bị bên thắng kế tiếp lock; một lần gọi chờ 1.3 s, phần chia giữa các worker từ 110 tới 400 job, và ở một lần chạy trước của cùng test, một trong bốn worker không xử lý job nào trong cả ba vòng. Với SKIP LOCKED, mỗi worker lấy ngay mười row chưa bị lock tiếp theo: không trùng, mỗi worker 250 job, nhanh hơn khoảng ba lần.
Có một chi tiết quan trọng với worker thật. Ở một vòng SKIP LOCKED, một worker dừng khi vẫn còn 30 job NEW: tất cả đang bị ba worker kia lock, nên query của nó không trả về gì. Một batch rỗng nghĩa là "hiện chưa có gì để lấy", không phải "queue đã trống"; một poller chạy theo lịch và quay lại sau sẽ tự xử lý được chuyện đó.
Một deadlock thật, và cách sửa
Hai transaction lock cùng hai row theo thứ tự ngược nhau: A lock product 1 rồi product 2, B lock product 2 rồi product 1, và một barrier bảo đảm cả hai đều giữ row đầu tiên trước khi bên nào xin row thứ hai.
private String lockTwo(CyclicBarrier firstLocks, long first, long second) {
return run(() -> tx.executeWithoutResult(s -> {
products.findForUpdateById(first).orElseThrow();
await(firstLocks); // both hold their first row before asking for the second
products.findForUpdateById(second).orElseThrow();
}));
}Với load average ở mức 4.8:
opposite order round 1: A CannotAcquireLockException after 1064 ms, B committed after 1058 ms
opposite order round 2: A committed after 1010 ms, B CannotAcquireLockException after 1010 ms
opposite order round 3: A CannotAcquireLockException after 1009 ms, B committed after 1011 ms
opposite order: rounds with a failure 10 of 10HHH000247: ErrorCode: 0, SQLState: 40P01
ERROR: deadlock detected
Detail: Process 290 waits for ShareLock on transaction 22060; blocked by process 291.
Process 291 waits for ShareLock on transaction 22061; blocked by process 290.
Hint: See server log for query details.
Where: while locking tuple (0,34) in relation "products"Mười vòng, mười deadlock, mỗi cái được giải quyết sau khoảng một giây, đúng bằng deadlock_timeout của PostgreSQL (show deadlock_timeout trả về 1s): server chờ chừng đó rồi mới đi tìm vòng lặp, sau đó huỷ một transaction và cho transaction kia chạy tiếp. Chuỗi exception của bên bị huỷ là CannotAcquireLockException với cause org.hibernate.exception.LockAcquisitionException, cause của nó là PSQLException: ERROR: deadlock detected, cùng kiểu Spring với lock timeout, nên advice ở trên trả nó bằng cùng cái 409.
Cách sửa là cách bài Basics 42 đã áp cho OrderService.place: mọi transaction lock các row theo cùng một thứ tự. Cùng test đó với cả hai transaction lock product 1 trước product 2, khởi động cùng lúc và giữ lock đầu tiên 100 ms:
same order round 1: A committed after 160 ms, B committed after 272 ms
same order round 2: A committed after 228 ms, B committed after 115 ms
same order: rounds with a failure 0 of 10Transaction thứ hai chờ ở lock đầu tiên thay vì lấy lock thứ hai, nên không thể hình thành vòng lặp. Khi không thể giữ một thứ tự nhất quán, transaction bị deadlock huỷ có thể được retry nguyên vẹn giống lượt giữ hàng optimistic, bằng cách thêm PessimisticLockingFailureException vào catch của retry, miễn là thao tác đó an toàn khi lặp lại.
Không cần lock: atomic update có điều kiện
Với một bộ đếm, database có thể làm trọn read-modify-write trong một câu lệnh. Điều kiện nằm trong where, và số row nó thay đổi cho biết nó có được áp dụng hay không:
@Modifying
@Query("update Product p set p.stock = p.stock - :quantity where p.id = :id and p.stock >= :quantity")
int decreaseStockIfAvailable(Long id, int quantity); @Transactional
public void reserveAtomically(Long id, int quantity) {
if (products.decreaseStockIfAvailable(id, quantity) == 0) {
Product product = products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
throw new InsufficientStockException(product.getSku(), product.getStock(), quantity);
}
}Một lượt giữ hàng vừa đủ gửi một câu lệnh; một lượt không đủ gửi cùng câu lệnh đó, nhận về 0, rồi đọc row một lần để phân biệt product không tồn tại với kệ hàng trống:
update products p1_0 set stock=(p1_0.stock-?) where p1_0.id=? and p1_0.stock>=?update products p1_0 set stock=(p1_0.stock-?) where p1_0.id=? and p1_0.stock>=?
select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=?
threw com.example.demo.product.InsufficientStockException: Only 0 of HUB-07 in stock, 1 requestedCác câu UPDATE đồng thời trên một row vẫn xếp hàng ở row lock của nó, nhưng mỗi câu đánh giá lại stock >= ? trên giá trị đã commit khi tới lượt, nên phần kiểm tra và phần ghi không thể bị tách rời. Trong burst 100 thread, mọi vòng đều bán đúng 50 và từ chối 50, stock về 0, với trung vị 29 ms: chiến lược đúng nhanh nhất đo được ở đây.
Atomic update và @Version cùng lúc
Bulk update bỏ qua version, như lần tăng giá đã cho thấy, và điều đó cũng quan trọng ở đây. Một người sửa load cái hub, mười lượt giữ hàng commit qua reserveAtomically, rồi người sửa lưu một giá mới:
>>> plain atomic update: before the sales {stock=50, price=39.00, version=2803}
after 10 sales {stock=40, price=39.00, version=2803}
update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?
editor committed
after the editor {stock=50, price=35.00, version=2804}Mười cái hub đã bán quay lại kho. Các lượt bán không hề đổi version, nên where version=2803 của người sửa khớp, và câu UPDATE ghi mọi cột của nó đã ghi lại stock mà nó từng load. Có hai cách sửa, cả hai đều đã chạy. Chuyển atomic update thành update versioned sẽ tăng version sau mỗi lượt bán, và người sửa đi vào nhánh 409:
update products p1_0 set stock=(p1_0.stock-?),version=(p1_0.version+1) where p1_0.id=? and p1_0.stock>=?
editor threw ObjectOptimisticLockingFailureException
after the editor {stock=40, price=39.00, version=2814}Cách này đúng, nhưng với một product bán ra mỗi giây, form sửa gần như không bao giờ lưu được. Cách còn lại chặn người sửa ghi vào những cột họ không đổi, bằng @DynamicUpdate của Hibernate:
@Entity
@DynamicUpdate
@Table(name = "products")
public class Product {after 10 sales {stock=40, price=39.00, version=2815}
update products set price=?,version=? where id=? and version=?
editor committed
after the editor {stock=40, price=35.00, version=2816}Giờ câu UPDATE của người sửa chỉ đặt giá và version, vẫn được kiểm tra theo version, và stock giữ nguyên ở 40. Cách này đúng chừng nào stock chỉ được đổi bằng atomic update: một thay đổi stock đi qua entity sẽ lại ghi một giá trị mà atomic update của transaction khác đã làm cho cũ đi, và không có version nào bắt được.
Để câu lệnh bị tranh chấp ở cuối
Row lock do một câu UPDATE lấy, cũng như lock do for no key update lấy, được giữ tới khi transaction kết thúc. Trong một order thật, sau khi đổi stock còn phải insert order và các line của nó, nên burst được chạy lại với 5 ms việc khác trong cùng transaction, một lần sau khi đổi stock và một lần trước đó (load 2.4 tới 3.1):
| Chiến lược | Chỉ đổi stock | +5 ms sau khi đổi stock | +5 ms trước khi đổi stock |
|---|---|---|---|
PESSIMISTIC_WRITE, timeout 2 s | 57 ms | 512 ms | 90 ms |
| atomic update | 29 ms | 448 ms | 82 ms |
Khi việc khác nằm sau lock, 50 transaction lần lượt giữ row trong 5 ms của mình, và atomic update không nhanh hơn lock tường minh. Chuyển đúng phần việc đó lên trước lúc đổi stock đã kéo cả hai xuống dưới 100 ms. Optimistic lock không phải chọn gì ở đây: câu UPDATE của nó là lần flush lúc commit, luôn ở cuối.
CHECK constraint là tuyến phòng thủ cuối
Dù code làm gì, stock dưới 0 không bao giờ hợp lệ, và database có thể nói điều đó:
alter table products add constraint products_stock_check check (stock >= 0);Để thấy nó chặn, một method bỏ điều kiện stock >= :quantity, update Product p set p.stock = p.stock - :quantity where p.id = :id, nhận cùng burst ba lần:
unconditional round 1: {DataIntegrityViolationException=50, ok=50}, {stock=0, price=39.00, version=2814}
unconditional round 2: {DataIntegrityViolationException=50, ok=50}, {stock=0, price=39.00, version=2814}
unconditional round 3: {DataIntegrityViolationException=50, ok=50}, {stock=0, price=39.00, version=2814} chain: org.springframework.dao.DataIntegrityViolationException: JDBC exception executing SQL [ERROR: new row for relation "products" violates check constraint "products_stock_check"
Detail: Failing row contains (3, HUB-07, USB-C hub, Seven ports, 100 W pass-through, 39.00, -1, 2814).] [update products p1_0 set stock=(p1_0.stock-?) where p1_0.id=?]; SQL [update products p1_0 set stock=(p1_0.stock-?) where p1_0.id=?]; constraint [products_stock_check]
chain: org.hibernate.exception.ConstraintViolationException: JDBC exception executing SQL [ERROR: new row for relation "products" violates check constraint "products_stock_check"
chain: org.postgresql.util.PSQLException: ERROR: new row for relation "products" violates check constraint "products_stock_check"Đúng 50 lượt bán mỗi vòng, SQLSTATE 23514, và một DataIntegrityViolationException mà handler của series đã trả bằng 409. Nó là lớp chặn cuối, không phải một chiến lược: lần chạy cũng log 151 cặp WARN HHH000247 từ Hibernate, mỗi câu lệnh bị từ chối một cặp (150 trong các vòng, một trong lần gọi riêng in ra chuỗi exception ở trên), và constraint không nhìn thấy đúng loại bug người ta thường mong nó bắt. Khi bỏ @Version và giữ constraint, burst read-modify-write "bán" 100 trên 100 ở mọi vòng và để lại 37 tới 42 trong kho. Một lost update ghi một con số cũ, không bao giờ là số âm, nên constraint không có gì để từ chối.
Chọn chiến lược nào
Cùng một burst cho mọi chiến lược: 100 thread, mỗi thread giữ một hub, còn 50 trong kho, 10 connection, một vòng khởi động rồi năm vòng đo, load average 2.4 tới 3.1. withWorkBefore là withWorkAfter với hai dòng đổi chỗ, còn retrying là vòng lặp của StockReservations.reserve bọc quanh transaction có chứa phần việc khác:
@Test
void hundredReservationsForFiftyHubs() throws Exception {
measure("optimistic", () -> stock.reserve(HUB, 1));
measure("optimistic + retry", () -> reservations.reserve(HUB, 1));
measure("pessimistic", () -> stock.reserveWithLock(HUB, 1));
measure("pessimistic, no hint", () -> tx.executeWithoutResult(status ->
products.findForUpdateById(HUB).orElseThrow().decreaseStock(1)));
measure("atomic", () -> stock.reserveAtomically(HUB, 1));
measure("optimistic +5ms", () -> withWorkAfter(() -> stock.reserve(HUB, 1)));
measure("optimistic + retry +5ms", () -> retrying(() -> withWorkAfter(() -> stock.reserve(HUB, 1))));
measure("pessimistic +5ms", () -> withWorkAfter(() -> stock.reserveWithLock(HUB, 1)));
measure("atomic +5ms", () -> withWorkAfter(() -> stock.reserveAtomically(HUB, 1)));
measure("pessimistic 5ms+", () -> withWorkBefore(() -> stock.reserveWithLock(HUB, 1)));
measure("atomic 5ms+", () -> withWorkBefore(() -> stock.reserveAtomically(HUB, 1)));
}
// the rest of the order, inside the same transaction, after taking the stock
private void withWorkAfter(Runnable reserve) {
tx.executeWithoutResult(status -> {
reserve.run();
sleep(5);
});
}
private void measure(String name, Runnable task) throws Exception {
for (int round = 0; round <= 5; round++) { // round 0 warms up
jdbc.sql("update products set stock = 50 where id = ?").param(HUB).update();
Race.Outcome outcome = Race.run(100, task);
// print outcome.counts(), the stock left and outcome.millis(); then best and median of rounds 1-5
}
}Các dòng read-modify-write lấy từ chính test đó, chạy với @Version được comment lại.
| Chiến lược | Kết quả mỗi vòng | Stock còn lại | Trung vị, chỉ đổi stock | Trung vị, +5 ms sau đó |
|---|---|---|---|---|
| read-modify-write | 100 lượt "bán" | 37 tới 42 | 46 ms | 75 ms |
@Version, không retry | bán 11 tới 13, 87 tới 89 conflict | 37 tới 39 | 34 ms | 77 ms |
@Version + retry của StockReservations | bán 50, 1 tới 26 vẫn conflict sau 5 lần thử | 0 | 136 ms | 349 ms, bán 41 hoặc 42, còn 8 hoặc 9 |
PESSIMISTIC_WRITE, timeout 2 s | bán 50, 50 hết hàng | 0 | 57 ms | 512 ms |
PESSIMISTIC_WRITE, không hint | bán 50, 50 hết hàng | 0 | 34 ms | không chạy |
| atomic update | bán 50, 50 hết hàng | 0 | 29 ms | 448 ms |

| Chiến lược | Ngăn được gì | Cái giá khi tranh chấp (đo ở trên) | Kiểu thất bại | Dùng khi nào |
|---|---|---|---|---|
@Version, trả conflict cho client | lost update qua các request và trong server | không lock khi đọc; 100 thread trên một row thì 87 tới 89 trên 100 bị từ chối | ObjectOptimisticLockingFailureException, 409 | form sửa dữ liệu, mọi thứ cần con người gộp |
@Version + retry quanh transaction | lost update, không làm phiền người dùng | transaction bị bỏ phí: 35 tới 59 retry cho 20 lượt giữ hàng; ở 100 thread vẫn có thất bại | exception của lần thử cuối | thay đổi tương đối trên row ít khi va chạm |
PESSIMISTIC_WRITE có timeout | lost update và check-then-act trên các row bị lock | tuần tự hoá mọi transaction trên row, và giữ nó tới lúc commit | chờ; CannotAcquireLockException khi timeout hoặc deadlock | đọc, quyết định, ghi mà cần entity; nhiều row theo thứ tự id |
SKIP LOCKED | hai worker lấy cùng một job | các worker không bao giờ chờ nhau: 1000 job trong 0.7 tới 0.9 s với 4 worker, lần gọi lâu nhất 53 ms | batch rỗng trong khi vẫn còn job bị lock | job queue, outbox relay |
| atomic update có điều kiện | lost update và check-then-act trên một bộ đếm | một câu lệnh; row lock kéo dài tới lúc commit, nên để nó ở cuối | 0 row thay đổi, chuyển thành 409 | stock, quota, bộ đếm |
CHECK constraint | giá trị không hợp lệ từ bất kỳ đoạn code nào | không có | DataIntegrityViolationException, 409, một WARN cho mỗi lần từ chối | lớp chặn cuối dưới mọi chiến lược khác |
Isolation level là đòn bẩy còn lại: liệu REPEATABLE READ hay SERIALIZABLE tự nó có chặn được các race condition này không, và một serialization failure trông ra sao, là chủ đề của bài 6. Mọi thứ ở trên đều lock row bên trong một database. Phối hợp giữa các instance application riêng rẽ, để một scheduled job chỉ chạy trên một instance, là việc ShedLock làm ở bài 20; lock giữ trong Redis nằm ngoài phạm vi khoá này.
FAQ
Optimistic lock và pessimistic lock trong Spring Boot khác nhau thế nào?
Optimistic lock không lấy lock khi đọc: @Version thêm and version=? vào mọi câu UPDATE, và transaction nào thấy version đã đổi sẽ nhận ObjectOptimisticLockingFailureException. Pessimistic lock lấy lock trên row ngay khi đọc, @Lock(PESSIMISTIC_WRITE) gửi for no key update trên PostgreSQL, và bắt các bên lock khác phải chờ. Với 100 lượt giữ hàng đồng thời trên một row, riêng @Version cho qua 11 tới 13 và từ chối số còn lại; PESSIMISTIC_WRITE bán đúng 50 cái có trong kho.
Tại sao tôi gặp ObjectOptimisticLockingFailureException?
Vì một câu UPDATE với where id=? and version=? không khớp row nào: một transaction khác đã commit thay đổi trên row sau khi transaction của bạn đọc nó. Message là Unexpected row count (expected row count 1 but was 0) khi lần flush phát hiện ra, và Row was already updated or deleted by another transaction khi save hoặc saveAll merge một entity detached. Cause là org.hibernate.StaleObjectStateException; jakarta.persistence.OptimisticLockException chỉ xuất hiện khi bạn tự gọi EntityManager.flush().
Version nên nằm trong ETag hay trong request body?
Cách nào cũng được; mỗi API chọn một. ETag cộng If-Match là HTTP chuẩn và trả 412, hợp với client chung chung, cache và DELETE không có body. Field version trong body hợp với một JSON API vốn đã trả conflict bằng 409, và cho check version cũ trong service lẫn conflict ở câu UPDATE cùng một status; API trong bài này trả 200, 409 và 422 (thiếu version) theo cách đó.
@Modifying @Query có tăng cột @Version không?
Không. update Product p set p.price = p.price * :factor để mọi version ở 0, và một người sửa đang giữ entity cũ sau đó ghi đè giá mới mà không có lỗi. Hãy viết update versioned Product p … (HQL của Hibernate, được Spring Data JPA 4.1.1 chấp nhận trong @Query), câu lệnh sẽ có thêm version=(p1_0.version+1), và người sửa đang giữ entity cũ nhận ObjectOptimisticLockingFailureException thay vào đó.
Đặt lock timeout với Spring Data JPA trên PostgreSQL thế nào?
Thêm @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) cạnh @Lock. Hibernate 7.4.5 chạy select current_setting('lock_timeout', true) và set local lock_timeout = 2000 trước query rồi đặt lại giá trị cũ sau đó; một lock không được cấp kịp sẽ thất bại với canceling statement due to lock timeout (SQLSTATE 55P03) dưới dạng CannotAcquireLockException. Giá trị 0 gửi nowait, -2 gửi skip locked.
Làm sao để nhiều worker xử lý job queue mà không bị trùng?
Nhận job bằng @Lock(PESSIMISTIC_WRITE) cộng hint lock timeout -2, được Hibernate gửi thành for no key update … skip locked, và xử lý chúng trong cùng transaction. Bốn worker trên 1000 job xử lý mỗi job đúng một lần, mỗi worker 250 job, trong 0.7 tới 0.9 s; không có lock thì gần như mọi job chạy bốn lần, còn với lock thường các worker xếp hàng sau nhau mất 2.5 tới 2.7 s.
Tại sao retry cho optimistic lock failure của tôi không bao giờ retry?
Vì nó chạy bên trong chính transaction mà nó muốn lặp lại. Với @Transactional trên method làm retry, câu UPDATE được flush khi proxy của method đó commit, sau khi vòng lặp đã return, và test log đúng không lần retry nào. Retry phải gọi một method @Transactional của bean khác, để mỗi lần thử là một transaction mới với một lần đọc mới.
Kết luận
Mọi race condition trong bài đều cùng một hình dạng, đọc rồi ghi với một khoảng trống ở giữa, và mỗi công cụ lấp khoảng trống đó theo một cách. @Version phát hiện nó lúc ghi và biến nó thành một cái 409 người dùng có thể giải quyết, hoặc thành một lần retry khi thay đổi có thể áp lại, miễn là retry bọc quanh cả transaction. PESSIMISTIC_WRITE lấp nó bằng cách bắt người khác chờ, nên cần timeout, thứ tự nhất quán và thời gian giữ ngắn, còn SKIP LOCKED biến cùng cái lock đó thành một queue nơi không ai phải chờ. Atomic update có điều kiện xoá khoảng trống cho một bộ đếm, và CHECK constraint bắt những gì lọt qua, nhưng chỉ những giá trị thật sự không hợp lệ.
Những phép đo đáng nhớ là các cái bẫy: bulk update @Modifying và atomic update stock đều để nguyên version, nên người sửa đang giữ entity lặng lẽ ghi giá trị cũ trở lại; PESSIMISTIC_READ deadlock ngay khi hai bên đọc cùng update; một row lock bị giữ trong lúc việc khác chạy tốn đúng bằng phần việc đó, bất kể chiến lược nào lấy lock; và một retry đặt bên trong transaction không bao giờ retry.
Bài tiếp theo vẫn ở Chương 2 và chuyển từ cách row được ghi sang cách query được dựng: truy vấn động với Specification và Criteria API, Querydsl hoặc jOOQ, cho những màn hình tìm kiếm mà bộ lọc chỉ biết được lúc runtime.