Command Palette

Search for a command to run...

[Advanced Spring Boot] Locking và concurrency trong Spring Boot: optimistic @Version, pessimistic lock và race condition

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 transaction cùng lao vào một row product, trên row có một cái lock

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.

Bash
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.zip
Bash
docker run -d --name sba-a7-pg -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=demo -p 5507:5432 postgres:18
src/main/resources/application.properties
spring.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=debug
src/main/resources/db/migration/V1__create_products.sql
create 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);
src/main/java/com/example/demo/product/Product.java
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.

src/main/java/com/example/demo/product/ProductService.java
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;
    }
}
src/main/java/com/example/demo/product/ProductController.java
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, descriptionprice với các constraint quen thuộc, còn ProductResponse trả về mọi cột. InsufficientStockExceptionProductNotFoundException kế thừa ConflictExceptionNotFoundException 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.

src/test/java/com/example/demo/Race.java
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:

build.gradle
tasks.named('test') {
	testLogging {
		showStandardStreams = true
	}
}
Bash
./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:

Bash
curl -s http://localhost:8207/api/products/1
Text
{"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:

Bash
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
Text
{"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:

Bash
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
Text
{"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:

Text
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 đó:

src/test/java/com/example/demo/product/LostUpdateTest.java
@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();
        }
    }
}
Text
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 0

Mườ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:

src/main/resources/db/migration/V2__add_product_version.sql
alter table products add column version bigint not null default 0;
src/main/java/com/example/demo/product/Product.java
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:

Text
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:

Text
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 10

Mỗ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:

Text
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 ms

UPDATE 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:

Text
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 flushCode của bạn bắt được gìCause của nó
commit của method @Transactional hoặc TransactionTemplateObjectOptimisticLockingFailureExceptionorg.hibernate.StaleObjectStateException
productRepository.flush() hoặc saveAndFlushObjectOptimisticLockingFailureExceptionorg.hibernate.StaleObjectStateException
EntityManager.flush() được inject, gọi bên ngoài repositoryjakarta.persistence.OptimisticLockExceptionorg.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:

src/test/java/com/example/demo/product/VersionTest.java
    @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
    }
Text
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:

src/main/java/com/example/demo/product/ProductRepository.java
    @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:

Text
>>> 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:

Text
>>> 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. ETagIf-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.

src/main/java/com/example/demo/product/ProductResponse.java
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());
    }
}
src/main/java/com/example/demo/product/UpdateProductRequest.java
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) {
}
src/main/java/com/example/demo/product/StaleProductException.java
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);
    }
}
src/main/java/com/example/demo/product/ProductService.java
    @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:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
    @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ó:

Bash
curl -s http://localhost:8207/api/products/1
Text
{"id":1,"sku":"KB-01","name":"Mechanical keyboard","description":"Tenkeyless, brown switches","price":89.90,"stock":10,"version":3}

Alice lưu trước:

Bash
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/1
Http
HTTP/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:

Bash
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/1
Http
HTTP/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
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:

Bash
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
Text
   1 200
   9 409
Bash
cat put/*.json | jq -r '.detail // "200 OK"' | sort | uniq -c
Text
   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.

Diễn biến theo thời gian của hai PUT cùng dựa trên version 3: cả hai đọc version 3 và qua check trong service, câu update where id=1 and version=3 của A khớp một row và giữ lock, câu update giống hệt của B chờ A commit, A commit và trả 200 với version 4, câu update của B khớp 0 row và thành StaleObjectStateException, ObjectOptimisticLockingFailureException rồi 409 ProblemDetail

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:

src/main/java/com/example/demo/product/StockService.java
@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:

src/main/java/com/example/demo/product/StockReservations.java
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:

src/test/java/com/example/demo/product/RetryTest.java
@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òngBán đượcVẫn thất bạiSố retry trong log
không retry2, 3, 318, 17, 17không có
retry, bản đầu chưa có backOff (load 3.0)10, 11, 1210, 9, 859, 55, 51
retry với backOff ngẫu nhiên ở trên (load 4.1)18, 20, 202, 0, 041, 35, 45
@Transactional trên method làm retry2, 2, 318, 18, 17khô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.

src/main/java/com/example/demo/product/StockReservations.java
    @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í:

Text
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:

Text
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-only

Lầ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:

src/main/java/com/example/demo/product/ProductRepository.java
    @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 = -1SKIP_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:

src/test/java/com/example/demo/product/LockModesTest.java
    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 xinMệnh đề Hibernate thêm vàoTransaction thứ hai nhận được
PESSIMISTIC_WRITEPESSIMISTIC_WRITEfor no key update of p1_0row sau 1413 ms
PESSIMISTIC_WRITEPESSIMISTIC_READfor share of p1_0row sau 1399 ms
PESSIMISTIC_WRITEfindById thườngkhông córow đã commit gần nhất sau 7 ms
PESSIMISTIC_READPESSIMISTIC_READfor share of p1_0row sau 3 ms
PESSIMISTIC_READPESSIMISTIC_WRITEfor no key update of p1_0row sau 1405 ms
PESSIMISTIC_WRITEghi, timeout 0for no key update of p1_0 nowaitCannotAcquireLockException sau 8 ms
PESSIMISTIC_WRITE, giữ 3 sghi, timeout 2000for no key update of p1_0, sau set local lock_timeout = 2000CannotAcquireLockException sau 2015 ms
PESSIMISTIC_WRITEghi, timeout -2for no key update of p1_0 skip lockedOptional.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ì.

Bốn thẻ so sánh các mode pessimistic lock trên PostgreSQL 18: PESSIMISTIC_WRITE gửi for no key update và khiến WRITE hay READ thứ hai chờ khoảng 1.4 s trong khi lần đọc thường trả về sau 7 ms; PESSIMISTIC_READ gửi for share, cho READ thứ hai vào sau 3 ms, bắt WRITE chờ 1405 ms và deadlock khi cả hai bên đọc cùng update; timeout 0 gửi nowait và thất bại sau 8 ms, timeout 2000 gửi set local lock_timeout và thất bại sau 2015 ms; timeout -2 gửi skip locked và không trả row nào sau 12 ms

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:

src/test/java/com/example/demo/product/DeadlockTest.java
            tx.executeWithoutResult(s -> {
                Product product = products.findForShareById(3L).orElseThrow();
                await(bothShared);
                product.decreaseStock(1);
                products.flush();
            });
Text
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 10

Mỗ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:

Text
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:

Text
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: ROLLBACK

Lỗ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:

Text
select current_setting('lock_timeout', true)
set local lock_timeout = 2000
set local lock_timeout = 0
lock_timeout after the hinted query: 0

Tứ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:

src/main/java/com/example/demo/product/StockService.java
    @Transactional
    public void reserveWithLock(Long id, int quantity) {
        Product product = products.findForUpdateWithTimeoutById(id).orElseThrow(() -> new ProductNotFoundException(id));
        product.decreaseStock(quantity);
    }
src/main/java/com/example/demo/product/ProductController.java
    @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ý:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
    @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:

Bash
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"
Bash
curl -i -s -H 'Content-Type: application/json' -d '{"quantity":1}' -w 'time %{time_total}s\n' http://localhost:8207/api/products/3/reservations
Http
HTTP/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.037096s

Cù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:

src/main/resources/db/migration/V3__create_jobs.sql
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ó JobStatusNEW 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:

src/main/java/com/example/demo/job/JobRepository.java
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);
}
Text
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 locked

Limit 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:

src/main/java/com/example/demo/job/JobProcessor.java
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:

src/test/java/com/example/demo/job/JobQueueTest.java
                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 jobTổng thời gianSố row job_runs cho 1000 jobJob bị xử lý hơn một lầnSố job mỗi workerLần gọi lâu nhất
không lock3091, 2956, 3199 ms4000, 4000, 39803000, 3000, 2980990 tới 1000 mỗi worker35 tới 105 ms
for no key update2689, 2487, 2547 ms10000110 tới 400280 tới 1300 ms
for no key update … skip locked720, 905, 883 ms10000250 mỗi worker40 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.

src/test/java/com/example/demo/product/DeadlockTest.java
    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:

Text
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 10
Text
HHH000247: 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:

Text
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 10

Transaction 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:

src/main/java/com/example/demo/product/ProductRepository.java
    @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);
src/main/java/com/example/demo/product/StockService.java
    @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:

Text
update products p1_0 set stock=(p1_0.stock-?) where p1_0.id=? and p1_0.stock>=?
Text
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 requested

Cá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:

Text
>>> 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:

Text
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:

src/main/java/com/example/demo/product/Product.java
@Entity
@DynamicUpdate
@Table(name = "products")
public class Product {
Text
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ượcChỉ đổi stock+5 ms sau khi đổi stock+5 ms trước khi đổi stock
PESSIMISTIC_WRITE, timeout 2 s57 ms512 ms90 ms
atomic update29 ms448 ms82 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 đó:

src/main/resources/db/migration/V4__add_stock_check.sql
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:

Text
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}
Text
  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. withWorkBeforewithWorkAfter 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:

src/test/java/com/example/demo/product/StockBurstTest.java
    @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ượcKết quả mỗi vòngStock còn lạiTrung vị, chỉ đổi stockTrung vị, +5 ms sau đó
read-modify-write100 lượt "bán"37 tới 4246 ms75 ms
@Version, không retrybán 11 tới 13, 87 tới 89 conflict37 tới 3934 ms77 ms
@Version + retry của StockReservationsbán 50, 1 tới 26 vẫn conflict sau 5 lần thử0136 ms349 ms, bán 41 hoặc 42, còn 8 hoặc 9
PESSIMISTIC_WRITE, timeout 2 sbán 50, 50 hết hàng057 ms512 ms
PESSIMISTIC_WRITE, không hintbán 50, 50 hết hàng034 mskhông chạy
atomic updatebán 50, 50 hết hàng029 ms448 ms

Cùng một burst 100 lượt giữ hàng cho 50 cái hub được xử lý theo năm cách: read-modify-write bán 100 và để lại 37 tới 42 trong kho; riêng @Version bán 12 và từ chối 88 dù còn hàng; @Version có retry bán 50 và còn vài conflict; PESSIMISTIC_WRITE và atomic update bán đúng 50; thời gian trung vị của burst là 46, 34, 136, 57 và 29 ms, và 75, 77, 349, 512 và 448 ms khi có 5 ms việc khác sau lúc đổi stock, 90 và 82 ms khi phần việc đó nằm trước

Chiến lượcNgăn được gìCái giá khi tranh chấp (đo ở trên)Kiểu thất bạiDùng khi nào
@Version, trả conflict cho clientlost update qua các request và trong serverkhông lock khi đọc; 100 thread trên một row thì 87 tới 89 trên 100 bị từ chốiObjectOptimisticLockingFailureException, 409form sửa dữ liệu, mọi thứ cần con người gộp
@Version + retry quanh transactionlost update, không làm phiền người dùngtransaction bị bỏ phí: 35 tới 59 retry cho 20 lượt giữ hàng; ở 100 thread vẫn có thất bạiexception của lần thử cuốithay đổi tương đối trên row ít khi va chạm
PESSIMISTIC_WRITE có timeoutlost update và check-then-act trên các row bị locktuần tự hoá mọi transaction trên row, và giữ nó tới lúc commitchờ; CannotAcquireLockException khi timeout hoặc deadlockđọc, quyết định, ghi mà cần entity; nhiều row theo thứ tự id
SKIP LOCKEDhai worker lấy cùng một jobcá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 msbatch rỗng trong khi vẫn còn job bị lockjob queue, outbox relay
atomic update có điều kiệnlost update và check-then-act trên một bộ đếmmột câu lệnh; row lock kéo dài tới lúc commit, nên để nó ở cuối0 row thay đổi, chuyển thành 409stock, quota, bộ đếm
CHECK constraintgiá trị không hợp lệ từ bất kỳ đoạn code nàokhông cóDataIntegrityViolationException, 409, một WARN cho mỗi lần từ chốilớ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)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.

Bài viết liên quan

[Advanced Spring Boot] OAuth2 và OpenID Connect trong Spring Boot: OAuth2 Login và resource server với JWT

OAuth2 và OpenID Connect với Spring Security trên Spring Boot 4.1.1 và Keycloak: realm import từ file JSON, discovery qua issuer-uri và lỗi khởi động khi Keycloak tắt, authorization code flow với PKCE (S256, bật sẵn cho confidential client) từng bước một, authorization code bị đánh cắp bị từ chối vì thiếu verifier, ID token và access token giải mã cạnh nhau, OidcUser với các authority OIDC_USER và SCOPE_, user-name-attribute, realm role của Keycloak map thành ROLE_ bằng GrantedAuthoritiesMapper, RP-initiated logout với OidcClientInitiatedLogoutSuccessHandler, Google và GitHub qua CommonOAuth2Provider, resource server JWT với issuer-uri và key được tải lười, header WWW-Authenticate cho token từ realm khác, sai issuer, bị sửa chữ ký, sai audience và hết hạn, realm_access.roles map bằng authorities-claim-expressions, token relay với OAuth2ClientHttpRequestInterceptor và token client credentials dùng lại qua nhiều lần gọi.

[Advanced Spring Boot] Nhiều datasource trong Spring Boot: hai database và định tuyến read/write replica

Nhiều datasource trong Spring Boot 4.1.1 với PostgreSQL: Boot lùi lại những gì khi có hai bean DataSource, DataSourceProperties và bẫy prefix của HikariCP, hai EntityManagerFactory dựng bằng EntityManagerFactoryBuilder, @EnableJpaRepositories và @Transactional(transactionManager), repository chạy dưới transaction manager sai, Flyway cho database thứ hai, vì sao ghi vào hai database không atomic, streaming replica trong Docker, AbstractRoutingDataSource và bug cờ read-only, LazyConnectionDataSourceProxy cùng read-only DataSource có sẵn, SQLSTATE 25006, replication lag và các cách giữ read-your-writes, mỗi đích một connection pool HikariCP.

[Advanced Spring Boot] Caching trong Spring Boot: cache abstraction, Caffeine, Redis và chiến lược invalidation

Caching trong Spring Boot 4.1.1 trên một lookup sản phẩm với PostgreSQL và Redis: vì sao vẫn cần @EnableCaching, @Cacheable, @CachePut, @CacheEvict với allEntries và @Caching, key SimpleKey và SpEL, condition, unless và null từ Optional được cache, LazyInitializationException do cache entity, eviction của Caffeine và metric cache.gets, vì sao Boot chọn Redis thay vì Caffeine, JDK serialization so với GenericJacksonJsonRedisSerializer cho Jackson 3 và type validator từ chối BigDecimal, hai cách cấu hình serializer âm thầm làm mất setting, latency khi không cache, với Caffeine và với Redis, cache stampede mà sync = true không chặn được trên Redis, race evict-trước-commit và transactionAware(), đọc dữ liệu cũ giữa hai instance, và request ra sao khi Redis dừng hoặc treo, có và không có CacheErrorHandler.

[Advanced Spring Boot] Truy vấn động với Spring Data JPA: Specification, Criteria API và Querydsl

Dynamic query cho một search sản phẩm có filter tùy chọn trên Spring Boot 4.1.1 và PostgreSQL: vì sao derived query và mẹo @Query (:x is null or …) không đủ, kèm lỗi lower(bytea) và generic plan đọc 200.403 buffer, Specification kết hợp được với API của Spring Data 4 (allOf, unrestricted, PredicateSpecification, UpdateSpecification, where(null) giờ throw), escape LIKE, join to-many làm count phình ra và page co lại, distinct so với subquery exists, Criteria API trong repository fragment tự viết với metamodel của hibernate-processor và đếm theo category, Querydsl với jakarta classifier, QuerydslPredicateExecutor và JPAQueryFactory, code generation của jOOQ, whitelist cho sort, 400 ProblemDetail khi minPrice lớn hơn maxPrice, và Query by Example.