Command Palette

Search for a command to run...

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

Trang chi tiết sản phẩm đọc cùng một sản phẩm hàng nghìn lần giữa hai lần đổi giá, và lần đọc nào cũng chạy lại cùng một phép join và aggregate trên database. Cache giữ kết quả của lookup đó trong bộ nhớ, hoặc ngay trên heap của ứng dụng, hoặc trên một server dùng chung, rồi trả lời request tiếp theo có cùng key mà không đụng đến database. Cái giá là cache giờ giữ một bản sao thứ hai của dữ liệu, và mỗi lần ghi đều phải quyết định bản sao đó sẽ ra sao.

Bài này thêm caching cho một lookup catalogue chậm bằng cache abstraction của Spring, trước tiên với Caffeine nằm trong JVM, sau đó với Redis dùng chung giữa các instance. Bài đo những gì từng cách làm được: số câu SQL mỗi request, số cache hit, latency, một cơn stampede của các cache miss đồng thời, dữ liệu cũ sau một lần ghi, và request khi Redis ngừng hoạt động. Các ví dụ dùng Spring Boot 4.1.1 và Java 21, cùng PostgreSQL 18 và Redis 8.

Một lookup database chậm được trả lời từ cache

Phần đầu dựng lookup; ba phần tiếp theo nói về abstraction, Caffeine và Redis; phần còn lại đo latency, stampede, invalidation và sự cố, rồi kết thúc bằng cách chọn.

Lookup catalogue và cách tạo ra các output

Project được sinh từ Spring Initializr với starter cho cache và Redis, bên cạnh các starter quen thuộc cho web, JPA và Flyway:

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,cache,data-redis,actuator" -o demo.zip

Initializr không có mục Caffeine, nên dòng được đánh dấu bên dưới là thêm bằng tay. Version 3.2.4 của nó đến từ dependency management của Boot. Để ý starter -test mà Boot 4 sinh ra cho mỗi starter chính:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-actuator'
	implementation 'org.springframework.boot:spring-boot-starter-cache'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
	implementation 'org.springframework.boot:spring-boot-starter-flyway'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	implementation 'com.github.ben-manes.caffeine:caffeine'
	implementation 'org.flywaydb:flyway-database-postgresql'
	runtimeOnly 'org.postgresql:postgresql'
	testImplementation 'org.springframework.boot:spring-boot-starter-actuator-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-cache-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-data-redis-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-flyway-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Catalogue có bốn category, 200 product và mỗi product 40 review, tổng cộng 8.000, do Flyway tạo và Hibernate kiểm tra bằng ddl-auto=validate:

src/main/resources/db/migration/V1__create_catalog.sql
create table categories (
    id   bigint generated by default as identity primary key,
    name varchar(60) not null unique
);
 
create table products (
    id          bigint generated by default as identity primary key,
    sku         varchar(40)    not null unique,
    name        varchar(120)   not null,
    price       numeric(10, 2) not null,
    category_id bigint         not null references categories (id)
);
 
create index products_category_id_idx on products (category_id);
 
create table reviews (
    id         bigint generated by default as identity primary key,
    product_id bigint   not null references products (id),
    rating     smallint not null check (rating between 1 and 5),
    body       varchar(500)
);
 
create index reviews_product_id_idx on reviews (product_id);

Thứ được cache là một read model chứ không phải entity: một record chứa product, tên category và thống kê review của nó.

src/main/java/com/example/demo/catalog/ProductView.java
public record ProductView(long id, String sku, String name, BigDecimal price, String category,
        long reviewCount, BigDecimal averageRating) {
}

Lookup join ba bảng và aggregate các review. Với 8.000 review và có index, PostgreSQL chạy xong trong 0,174 ms (explain analyze), quá nhanh để cache có ý nghĩa. Vì vậy query mang theo một thứ đóng vai query chậm: cross join (select pg_sleep(0.02)) cộng thêm 20 ms vào mỗi lần chạy. Mọi con số "không cache" bên dưới là 20 ms đó cộng với overhead của chính ứng dụng:

src/main/java/com/example/demo/catalog/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    /** One product with its category and review statistics; pg_sleep stands in for a heavy query. */
    @NativeQuery("""
            select p.id, p.sku, p.name, p.price, c.name as category,
                   count(r.id) as review_count, coalesce(round(avg(r.rating), 2), 0) as average_rating
            from products p
            join categories c on c.id = p.category_id
            left join reviews r on r.product_id = p.id
            cross join (select pg_sleep(0.02)) as slow
            where p.id = :id
            group by p.id, c.name
            """)
    Optional<ProductView> findView(long id);
 
    @NativeQuery("""
            select p.id, p.sku, p.name, p.price, c.name as category,
                   count(r.id) as review_count, coalesce(round(avg(r.rating), 2), 0) as average_rating
            from products p
            join categories c on c.id = p.category_id
            left join reviews r on r.product_id = p.id
            cross join (select pg_sleep(0.02)) as slow
            where lower(c.name) = lower(:category)
            group by p.id, c.name
            order by average_rating desc, p.id
            limit :limit
            """)
    List<ProductView> findTopRated(String category, int limit);
}

Cấu hình theo đúng quy ước của series: tắt open-in-view, Flyway quản lý schema, lỗi trả về dạng ProblemDetail. Port 8209 là của lab này; instance thứ hai ở phần sau chạy trên 9209:

src/main/resources/application.properties
server.port=8209
 
spring.datasource.url=jdbc:postgresql://localhost:5509/demo
spring.datasource.username=demo
spring.datasource.password=demo
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=validate
 
spring.data.redis.port=6309
 
spring.cache.type=caffeine
spring.cache.cache-names=products,topRated,productEntities
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m,recordStats
 
spring.mvc.problemdetails.enabled=true
management.endpoints.web.exposure.include=health,caches,metrics
 
logging.level.org.hibernate.SQL=debug

Câu hỏi "cái này tốn bao nhiêu câu SQL" được trả lời bằng một bộ đếm chứ không phải bằng cách đọc log. Hibernate đưa mọi statement nó prepare, kể cả native query, cho một StatementInspector, và HibernatePropertiesCustomizer của Boot cho phép một Spring bean làm inspector đó:

src/main/java/com/example/demo/support/SqlStatementCounter.java
@Component
public class SqlStatementCounter implements StatementInspector, HibernatePropertiesCustomizer {
 
    private final AtomicLong count = new AtomicLong();
 
    @Override
    public String inspect(String sql) {
        count.incrementAndGet();
        return sql;
    }
 
    @Override
    public void customize(Map<String, Object> hibernateProperties) {
        hibernateProperties.put(AvailableSettings.STATEMENT_INSPECTOR, this);
    }
 
    public long count() {
        return count.get();
    }
}

Một endpoint chỉ dành cho lab, GET /lab/stats, trả về con số đó cùng các meter cache.gets của Micrometer, theo từng cache và từng kết quả. Ứng dụng chạy dưới dạng jar với -Xmx512m. Mọi con số thời gian bên dưới đều đi kèm load average 1, 5 và 15 phút lấy từ uptime ngay trước khi đo.

Cache abstraction: @EnableCaching, @Cacheable và proxy

Cache abstraction của Spring là một bộ annotation đặt trên method của bạn, cộng với một CacheManager đứng phía sau. Một interceptor trong proxy của bean đọc các annotation, tính key từ các argument, rồi hỏi Cache của cache manager. Provider (Caffeine, Redis hay bất kỳ thứ gì khác) chỉ cần implement Cache, nên annotation giữ nguyên khi đổi provider.

Spring Boot 4 có còn cần @EnableCaching không?

Có. Trong Boot 4.1.1, CacheAutoConfiguration mang @ConditionalOnBean(CacheAspectSupport.class), và CacheAspectSupport chính là interceptor mà @EnableCaching đăng ký. Không có annotation thì không có interceptor, nên Boot hoàn toàn không tạo CacheManager:

src/main/java/com/example/demo/cache/CacheConfig.java
@Configuration(proxyBeanMethods = false)
@EnableCaching
public class CacheConfig {
}

Khi bỏ annotation đi, cùng hai request cho product 7 đều xuống database, và endpoint của Actuator không liệt kê cache manager nào:

Bash
curl -s localhost:8209/api/products/7
curl -s localhost:8209/api/products/7
curl -s localhost:8209/lab/stats
curl -s localhost:8209/actuator/caches
Text
{"sqlStatements":2,"cacheGets":{},"cacheManager":[]}
{"cacheManagers":{}}

Log lúc khởi động không nhắc gì đến caching: không warning, không lỗi. @Cacheable trên một method chỉ là metadata cho đến khi có thứ gì đó đọc nó.

Một cache miss và một cache hit

Method được cache chỉ cần một annotation trên service, và controller chuyển Optional rỗng thành 404:

src/main/java/com/example/demo/catalog/ProductService.java
@Service
public class ProductService {
 
    private final ProductRepository products;
 
    ProductService(ProductRepository products) {
        this.products = products;
    }
 
    @Cacheable("products")
    public Optional<ProductView> find(long id) {
        return products.findView(id);
    }
}
src/main/java/com/example/demo/catalog/ProductController.java
    @GetMapping("/{id}")
    public ProductView get(@PathVariable long id) {
        return service.find(id).orElseThrow(() -> new ProductNotFoundException(id));
    }

Hai request cho product 7 trên một instance vừa khởi động trả về cùng một body và chạy một câu SQL:

Text
{"id":7,"sku":"P-007","name":"Monitor 7","price":278.00,"category":"Monitors","reviewCount":40,"averageRating":2.00}
{"id":7,"sku":"P-007","name":"Monitor 7","price":278.00,"category":"Monitors","reviewCount":40,"averageRating":2.00}
{"sqlStatements":1,"cacheGets":{"productEntities.hit":0.0,"productEntities.miss":0.0,"products.hit":1.0,"products.miss":1.0,"topRated.hit":0.0,"topRated.miss":0.0},"cacheManager":["org.springframework.cache.caffeine.CaffeineCacheManager"]}

Với logging.level.org.springframework.cache=trace, CacheInterceptor ghi log từng bước. Các dòng ở đây được rút gọn còn thời gian, thread, logger và message, phần mô tả operation bị cắt bớt:

Text
14:43:55.032 [nio-8209-exec-1] o.s.cache.interceptor.CacheInterceptor : Computed cache key '7' for operation Builder[public java.util.Optional com.example.demo.catalog.ProductService.find(long)] caches=[products] | key='' | …
14:43:55.033 [nio-8209-exec-1] o.s.cache.interceptor.CacheInterceptor : No cache entry for key '7' in cache(s) [products]
14:43:55.082 [nio-8209-exec-1] org.hibernate.SQL : select p.id, p.sku, p.name, p.price, c.name as category,
14:43:55.119 [nio-8209-exec-1] o.s.cache.interceptor.CacheInterceptor : Creating cache entry for key '7' in cache(s) [products]
14:43:55.140 [nio-8209-exec-2] o.s.cache.interceptor.CacheInterceptor : Computed cache key '7' for operation Builder[public java.util.Optional com.example.demo.catalog.ProductService.find(long)] caches=[products] | key='' | …
14:43:55.141 [nio-8209-exec-2] o.s.cache.interceptor.CacheInterceptor : Cache entry for key '7' found in cache(s) [products]

Ở lần miss, interceptor tính key, không tìm thấy gì, để method chạy query, rồi lưu kết quả. Ở lần hit, nó tìm thấy entry và trả về luôn: thân method không chạy, và query cũng không.

Đường đi của cache interceptor khi miss và khi hit với key 7, kèm số câu SQL và cache.gets của mỗi trường hợp

Key: SimpleKey mặc định, SpEL khi cần

Với một parameter, key chính là argument đó, ở đây là Long 7. Với nhiều parameter, SimpleKeyGenerator mặc định gói tất cả vào một SimpleKey. Danh sách top-rated nhận một category và một limit:

src/main/java/com/example/demo/catalog/ProductService.java
    @Cacheable("topRated")
    public List<ProductView> topRated(String category, int limit) {
        return products.findTopRated(category, limit);
    }

Request category=Keyboards&limit=3 rồi category=keyboards&limit=3 chạy hai câu SQL cho cùng ba sản phẩm, vì câu SQL so sánh bằng lower() còn key thì không. Một endpoint của lab liệt kê key của một cache Caffeine cho thấy hai entry:

Text
{"SimpleKey SimpleKey [keyboards, 3]":"java.util.ArrayList","SimpleKey SimpleKey [Keyboards, 3]":"java.util.ArrayList"}

key nhận một biểu thức SpEL trên các parameter. condition quyết định trước lời gọi xem có dùng cache hay không, còn unless quyết định sau lời gọi xem có lưu kết quả hay không:

src/main/java/com/example/demo/catalog/ProductService.java
    @Cacheable("topRated") 
    @Cacheable(cacheNames = "topRated", key = "#category.toLowerCase() + ':' + #limit", 
            condition = "#limit <= 20", unless = "#result.isEmpty()") 
    public List<ProductView> topRated(String category, int limit) {
        return products.findTopRated(category, limit);
    }

Cùng các request đó trên một instance mới, tiếp theo là limit=50 hai lần và một category không tồn tại hai lần:

RequestSố câu SQLtopRated hit / missChuyện gì xảy ra
Keyboards, rồi keyboards, limit 311 / 1Một key, keyboards:3
Mice, limit 50, hai lần2không đổicondition là false: cache không được hỏi đến
Nope, limit 3, hai lần2+0 / +2Có tra cache, nhưng unless giữ list rỗng ở ngoài

Key duy nhất còn lại là {"String keyboards:3":"java.util.ArrayList"}. Tên parameter như #category resolve được vì Gradle plugin của Boot compile với -parameters; #root.args[0] vẫn chạy khi không có nó.

Cache Optional và null

find trả về Optional<ProductView>, nhưng cache lưu thứ nằm bên trong: record khi sản phẩm tồn tại, và null khi rỗng. Caffeine giữ null đó dưới dạng marker NullValue của Spring. Hai request cho một sản phẩm không tồn tại:

Http
HTTP/1.1 404
Content-Type: application/problem+json
 
{"detail":"No product with id 9999","instance":"/api/products/9999","status":404,"title":"Product not found"}

Lần 404 thứ hai đến từ cache: một câu SQL cho hai request, và các key của products là:

Text
{"Long 7":"com.example.demo.catalog.ProductView","Long 9999":"org.springframework.cache.support.NullValue"}

Cache cả lần không tìm thấy giúp database khỏi phải tra lặp lại những id không tồn tại. Nó cũng có nghĩa là một dòng được insert với key đó sẽ vô hình cho đến khi entry hết hạn hoặc bị evict. Muốn tắt, unless = "#result == null" vẫn dùng được trên method trả về Optional, vì #result là giá trị đã được mở ra: hai request cho id 9998 qua biến thể đó chạy hai câu SQL.

@CachePut, @CacheEvict và @Caching

Một lần đổi giá phải cập nhật product trong cache và invalidate mọi danh sách top-rated đang được cache, vì danh sách nào cũng có thể chứa nó. @CachePut luôn chạy method và lưu kết quả. @CacheEvict xoá một key, hoặc với allEntries = true là xoá cả cache. @Caching gom nhiều operation lên một method:

src/main/java/com/example/demo/catalog/ProductService.java
    @Transactional
    @Caching(
            put = @CachePut(cacheNames = "products", key = "#id"),
            evict = @CacheEvict(cacheNames = "topRated", allEntries = true))
    public Optional<ProductView> changePrice(long id, BigDecimal price) {
        Product product = products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
        product.setPrice(price);
        products.flush();
        return products.findView(id);
    }

Key được ghi rõ vì mặc định sẽ là SimpleKey [13, 24.50] từ cả hai parameter, không phải key mà find(13) đọc. Khi product 13 và hai danh sách top-rated đang nằm trong cache, lần đổi giá chạy ba câu SQL: câu select, câu update và query view tạo ra giá trị cho @CachePut.

Bash
curl -s -X PUT localhost:8209/api/products/13/price -H 'Content-Type: application/json' -d '{"price": 24.50}'
Text
{"id":13,"sku":"P-013","name":"Keyboard 13","price":24.50,"category":"Keyboards","reviewCount":40,"averageRating":4.68}

Sau đó topRated rỗng ({}), và GET /api/products/13 trả về 24.50 dưới dạng hit, không có SQL. Giá trị của @CachePut phải có cùng hình dạng với thứ mà method đọc lưu vào: ở đây cả hai đều là nội dung của một Optional<ProductView>. Giá 0 bị @DecimalMin("0.01") từ chối với 400 ProblemDetail trước khi bất kỳ phần nào ở trên chạy.

Cache record, đừng cache entity

Interceptor lưu chính object được trả về. Với Caffeine, vốn giữ value trên heap, đó là cùng một instance cho mọi caller: một endpoint của lab gọi find(7) hai lần rồi so sánh kết quả.

Text
{"first":1473438077,"sameInstance":true,"second":1473438077}

Một record có các thành phần immutable thì không caller nào sửa được sau lưng caller khác. Một JPA entity thì sửa được, và nó còn mang theo vấn đề thứ hai: các association lazy của nó thuộc về persistence context đã load nó. Lab này cố tình cache entity Product:

src/main/java/com/example/demo/lab/LabLookups.java
    /** The mistake: caching a managed entity. */
    @Cacheable("productEntities")
    public Product loadEntity(long id) {
        return products.findById(id).orElseThrow();
    }
src/main/java/com/example/demo/lab/ProductDescriber.java
    @Transactional(readOnly = true)
    public String describe(long id) {
        Product product = lookups.loadEntity(id);
        return product.getName() + " in " + product.getCategory().getName();
    }

Gọi describe(5) hai lần đều chạy tốt: lần đầu load product trong transaction của nó, chạm vào category lazy, và cache một entity mà proxy của category đã được khởi tạo. Sau đó, trên một instance mới, một endpoint khác chỉ đọc giá đã cache product 5 trước, rồi mới tới describe(5):

Bash
curl -s localhost:8209/lab/price-of/5
curl -s localhost:8209/lab/describe/5

Request đầu in ra 204.00. Request thứ hai trả về 500, và log ghi:

Text
org.hibernate.LazyInitializationException: Could not initialize proxy [com.example.demo.catalog.Category#1] - no session

Product trong cache đến từ một session đã đóng. category của nó vẫn là một proxy chưa khởi tạo, và session của transaction mới không sở hữu nó. Kết quả tuỳ vào caller nào điền vào cache trước, đúng loại bug khó tái hiện nhất. Hãy map sang record hoặc DTO trước khi cache, và fetch mọi thứ record cần ngay bên trong method được cache.

Self-invocation bỏ qua cache

Bài 3 đã giải thích vì sao một lời gọi trên this không bao giờ đi qua proxy. Với caching nó trông như sau: một lookup theo lô gọi find từ bên trong.

src/main/java/com/example/demo/catalog/ProductService.java
    /** Looks up several products by calling find() on this, which skips the proxy. */
    public List<ProductView> findAll(List<Long> ids) {
        return ids.stream().map(this::find).flatMap(Optional::stream).toList();
    }

GET /api/products?ids=1,2,3 hai lần chạy sáu câu SQL, và cache.gets của products không nhúc nhích: interceptor chưa từng được hỏi. Cách sửa là những cách ở bài 3: chuyển method được cache sang bean khác, hoặc gọi nó qua proxy được inject.

Caffeine làm cache cục bộ

Caffeine giữ entry trên heap của ứng dụng, trong một concurrent map có chính sách eviction. spring.cache.caffeine.spec là một chuỗi spec của Caffeine áp dụng cho mọi cache mà manager tạo ra. Nó đã có sẵn trong cấu hình ở trên:

src/main/resources/application.properties
spring.cache.type=caffeine
spring.cache.cache-names=products,topRated,productEntities
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m,recordStats

maximumSize giới hạn số entry, expireAfterWrite là TTL tính từ lần ghi cuối, và recordStats bật các bộ đếm đứng sau metric. spring.cache.cache-names làm hai việc với Caffeine: tạo các cache đó lúc khởi động, và biến manager thành tĩnh, nên một tên cache không có trong danh sách sẽ lỗi ở lần gọi đầu tiên:

Text
java.lang.IllegalArgumentException: Cannot find cache named 'topRated' for Builder[public java.util.List com.example.demo.catalog.ProductService.topRated(java.lang.String,int)] caches=[topRated] | key='' | …

Eviction theo kích thước, quan sát thực tế

Với maximumSize=100, request 150 sản phẩm khác nhau, mỗi sản phẩm một lần, rồi lặp lại đúng 150 sản phẩm đó:

Text
after 150 distinct ids:
{"sqlStatements":150,"cacheGets":{…,"products.hit":0.0,"products.miss":150.0,…}}
cache.size [{'statistic': 'VALUE', 'value': 100.0}]
cache.evictions [{'statistic': 'COUNT', 'value': 50.0}]
cache.puts [{'statistic': 'COUNT', 'value': 0.0}]
after the same 150 again:
{"sqlStatements":201,"cacheGets":{…,"products.hit":99.0,"products.miss":201.0,…}}
cache.size [{'statistic': 'VALUE', 'value': 100.0}]
cache.evictions [{'statistic': 'COUNT', 'value': 101.0}]

Cache giữ 100 entry và đã evict 50. Lượt thứ hai được 99 hit và 51 miss. Giữ lại 100 entry nào là quyết định của Caffeine (nó cân cả tần suất lẫn độ mới), không đơn giản là 100 entry gần nhất. cache.puts vẫn là 0 dù đã lưu 150 value: trong thống kê của Caffeine, bộ đếm đó theo dõi số lần load qua một loading cache, còn Spring lưu value bằng put, nên đừng đọc nó là "số entry đã ghi".

Actuator: /actuator/caches và metric cache.gets

/actuator/caches liệt kê mọi cache manager và các cache của nó, kèm class native đứng sau mỗi cache:

JSON
{"cacheManagers":{"cacheManager":{"caches":{"topRated":{"target":"com.github.benmanes.caffeine.cache.BoundedLocalCache$BoundedLocalManualCache"},"productEntities":{"target":"com.github.benmanes.caffeine.cache.BoundedLocalCache$BoundedLocalManualCache"},"products":{"target":"com.github.benmanes.caffeine.cache.BoundedLocalCache$BoundedLocalManualCache"}}}}}

Các meter của Caffeine là cache.eviction.weight, cache.evictions, cache.gets, cache.putscache.size. cache.gets mang các tag result (hit hoặc miss), cache, namecache.manager, nên số hit của một cache là:

Bash
curl -s 'localhost:8209/actuator/metrics/cache.gets?tag=cache:products&tag=result:hit'
JSON
{"availableTags":[{"tag":"cache.manager","values":["cacheManager"]},{"tag":"name","values":["products"]}],"description":"The number of times cache lookup methods have returned a cached (hit) or uncached (newly loaded or null) value (miss).","measurements":[{"statistic":"COUNT","value":4.0}],"name":"cache.gets"}

Hai điều kiện phải đúng thì các meter đó mới tồn tại:

Cấu hìnhChuyện gì xảy ra
Spec thiếu recordStatsMỗi cache một dòng WARN lúc khởi động: "The cache 'products' is not recording statistics. No meters except 'cache.size' will be registered."
spring.cache.cache-names để trốngCache được tạo ở lần dùng đầu tiên và cache đúng (1 câu SQL cho 2 request), nhưng /actuator/metrics/cache.gets trả về 404

Trường hợp thứ hai xảy ra vì Boot gắn metric cho cache lúc khởi động, cho những cache mà manager biết vào lúc đó. Một cache được tạo muộn hơn bởi lời gọi @Cacheable đầu tiên thì không bao giờ được gắn metric.

Caffeine và Redis cùng nằm trên classpath

Project này có cả Caffeine lẫn Redis starter. Khi không đặt spring.cache.type, Boot 4.1.1 chọn Redis:

Text
{"sqlStatements":0,"cacheGets":{…},"cacheManager":["org.springframework.data.redis.cache.RedisCacheManager"]}

Boot thử các provider theo thứ tự của enum CacheType: GENERIC, JCACHE, HAZELCAST, COUCHBASE, INFINISPAN, REDIS, CACHE2K, CAFFEINE, SIMPLE, NONE. Provider đầu tiên thoả điều kiện sẽ tạo CacheManager, các provider còn lại lùi ra. Bean RedisConnectionFactory luôn tồn tại khi có Redis starter, nên Redis đứng trước Caffeine. Hãy đặt spring.cache.type một cách tường minh mỗi khi classpath có nhiều hơn một provider.

Redis làm cache phân tán

Cache Redis nằm ngoài JVM. Mọi instance đọc và ghi cùng một bộ entry, restart ứng dụng không mất gì, và mỗi lần tra là một round trip qua mạng cộng với deserialize. Redis như một data store tổng quát, với các cấu trúc dữ liệu riêng, RedisTemplate@RedisHash, là chủ đề của bài 11; ở đây nó chỉ là backend cho @Cacheable.

RedisCacheManager từ các property spring.cache.redis

Các setting cho Redis được đặt trong một profile, để một file jar chạy được với cả hai provider:

src/main/resources/application-redis.properties
spring.cache.type=redis
spring.cache.redis.time-to-live=10m
spring.cache.redis.key-prefix=catalog:
spring.cache.redis.enable-statistics=true

Boot dựng RedisCacheManager từ các property này: time-to-live thành TTL của entry, key-prefix đứng trước mọi key, cache-null-values (mặc định true) cho phép marker null, và enable-statistics cấp số liệu cho các meter cache.gets. Thiếu nó thì các meter của Redis vẫn tồn tại nhưng đứng yên ở 0. Các meter của Redis là cache.gets với result = hit, miss hoặc pending, cache.puts, cache.removalscache.lock.duration.

Serializer mặc định cho value là JDK serialization

Boot cấu hình JdkSerializationRedisSerializer cho value. Request đầu tiên cho product 7 chạy query rồi thất bại khi lưu kết quả:

Text
java.lang.IllegalStateException: Cannot serialize value of type com.example.demo.catalog.ProductView without a serializer
	at org.springframework.data.redis.serializer.DefaultRedisElementWriter.write(DefaultRedisElementWriter.java:55)
	at org.springframework.data.redis.serializer.RedisSerializationContext$SerializationPair.write(RedisSerializationContext.java:291)
	at org.springframework.data.redis.cache.RedisCache.serializeCacheValue(RedisCache.java:358)
	at org.springframework.data.redis.cache.RedisCache.put(RedisCache.java:206)

JDK serializer chỉ nhận các type Serializable, và record này không phải. Lỗi lộ ra ở lần put đầu tiên chứ không phải lúc khởi động, và nó biến một query đã thành công thành một lỗi 500. Giá trị null cho product 9999 thì lưu được, vì NullValueSerializable. Đây là những gì redis-cli cho thấy:

Bash
docker exec sba-a9-redis redis-cli KEYS '*'
docker exec sba-a9-redis redis-cli TTL 'catalog:products::9999'
docker exec sba-a9-redis redis-cli --no-raw GET 'catalog:products::9999'
Text
catalog:products::9999
592
"\xac\xed\x00\x05sr\x00+org.springframework.cache.support.NullValue\x00\x00\x00\x00\x00\x00\x00\x01\x02\x00\x00xp"

Key có dạng prefix + cacheName + "::" + key, TTL đếm ngược từ 600 giây, và value là một stream Java serialization (\xac\xed là magic number của nó). Cho record implement Serializable thì chạy được, nhưng khi đó các byte chỉ đọc được bởi một JVM có đúng class đó, và ngoài Java ra không gì xem được chúng.

Serializer JSON cho Jackson 3

Spring Data Redis 4 có hai serializer JSON tổng quát: GenericJackson2JsonRedisSerializer cho Jackson 2, và GenericJacksonJsonRedisSerializer cho Jackson 3 (các package tools.jackson mà Boot 4 dùng). RedisSerializer.json() trả về bản Jackson 3, được dựng với enableUnsafeDefaultTyping(). Javadoc của nó cảnh báo rằng khi không có type validator, việc deserialize "is vulnerable to arbitrary code execution when reading from untrusted sources". Phiên bản bên dưới thay vào đó giới hạn các type, và gắn serializer qua RedisCacheManagerBuilderCustomizer của Boot:

src/main/java/com/example/demo/cache/RedisCacheConfig.java
@Configuration(proxyBeanMethods = false)
public class RedisCacheConfig {
 
    static RedisSerializer<Object> jsonSerializer() {
        PolymorphicTypeValidator types = BasicPolymorphicTypeValidator.builder()
                .allowIfSubType("com.example.demo.")
                .allowIfSubType("java.util.")
                .allowIfSubType("java.math.")
                .build();
        return GenericJacksonJsonRedisSerializer.builder()
                .enableSpringCacheNullValueSupport()
                .enableDefaultTyping(types)
                .build();
    }
 
    @Bean
    RedisCacheManagerBuilderCustomizer jsonCacheValues() {
        RedisSerializer<Object> json = jsonSerializer();
        return builder -> {
            RedisCacheConfiguration defaults = builder.cacheDefaults()
                    .serializeValuesWith(SerializationPair.fromSerializer(json));
            builder.cacheDefaults(defaults);
            builder.getConfiguredCaches().forEach(name -> builder.withCacheConfiguration(name, defaults));
            builder.withCacheConfiguration("topRated", defaults.entryTtl(Duration.ofMinutes(1)));
        };
    }
}

Phiên bản đầu tiên của validator chỉ cho phép com.example.demo.java.util.. Ghi thì chạy, còn request thứ hai, lần hit đầu tiên, thất bại khi đọc:

Text
org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Could not resolve type id 'java.math.BigDecimal' as a subtype of `java.math.BigDecimal`: Configured `PolymorphicTypeValidator` (of type `tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator`) denied resolution

Serializer ghi type id cho mọi value, trừ primitive, wrapper của chúng, enum và các class final trong package java như String. BigDecimal không phải final, nên mỗi giá và mỗi điểm đánh giá đều mang tên class, và validator phải cho phép nó. Sau khi thêm java.math., một lần miss, một lần hit, lỗi 404 và một danh sách top-rated đều chạy. redis-cli cho thấy thông tin type trông như thế nào:

Bash
docker exec sba-a9-redis redis-cli GET 'catalog:products::7'
docker exec sba-a9-redis redis-cli GET 'catalog:topRated::mice:2'
Text
{"@class":"com.example.demo.catalog.ProductView","id":7,"sku":"P-007","name":"Monitor 7","price":["java.math.BigDecimal",278.00],"category":"Monitors","reviewCount":40,"averageRating":["java.math.BigDecimal",2.00]}
["java.util.ArrayList",[{"@class":"com.example.demo.catalog.ProductView","id":18,"sku":"P-018","name":"Mouse 18","price":["java.math.BigDecimal",205.00],"category":"Mice","reviewCount":40,"averageRating":["java.math.BigDecimal",4.68]},{"@class":"com.example.demo.catalog.ProductView","id":38,"sku":"P-038","name":"Mouse 38","price":["java.math.BigDecimal",465.00],"category":"Mice","reviewCount":40,"averageRating":["java.math.BigDecimal",4.68]}]]

Record là final, nhưng vẫn có property @class, vì serializer chỉ bỏ type id cho các type final trong package java. Nhờ vậy lần hit deserialize ra ProductView chứ không phải LinkedHashMap. List được ghi thành một cặp [type, value]. MONITOR cho thấy các command đứng sau một lần miss, một lần hit và lần đổi giá:

Text
"GET" "catalog:products::7"
"SET" "catalog:products::7" "{\"@class\":\"com.example.demo.catalog.ProductView\",…}" "PX" "600000"
"GET" "catalog:products::7"
"SET" "catalog:products::7" "{\"@class\":\"com.example.demo.catalog.ProductView\",…}" "PX" "600000"
"KEYS" "catalog:topRated::*"

@CachePut là một command SET bình thường với TTL tính bằng mili giây. allEntries = true trở thành KEYS catalog:topRated::*, sau đó writer DEL những key khớp (ở đây không có key nào). KEYS duyệt toàn bộ keyspace và chặn Redis trong lúc duyệt, nên trên một Redis lớn dùng chung, evict cả cache không hề miễn phí. RedisCacheWriter nhận BatchStrategies.scan(…) thay cho keys() mặc định.

Vì mỗi lần hit deserialize ra một object mới, phép kiểm tra identity trả về cùng một instance trên Caffeine thì trên Redis trả về hai: {"first":2069918412,"second":1727265053,"sameInstance":false}.

Hai cách cấu hình serializer âm thầm làm mất setting

Customizer ở trên dài hơn mức tưởng như cần. Cả hai phiên bản ngắn hơn đều đã chạy thử, và cả hai đều làm mất thứ gì đó:

Cấu hìnhRedis chứa gì sau GET /api/products/7
Customizer chỉ gọi builder.cacheDefaults(… json …)Không gì cả: request thất bại với cùng lỗi Cannot serialize value of type … ProductView without a serializer
Một bean RedisCacheConfiguration với serializer JSONJSON dưới key products::7, TTL -1

Cách đầu thất bại vì Boot gọi initialCacheNames(...) với spring.cache.cache-names trước khi chạy các customizer, và lời gọi đó chép cấu hình mặc định của thời điểm đó, tức JDK serialization, vào từng cache có tên. Đổi default sau đó chỉ ảnh hưởng tới các cache được tạo về sau. Vì thế mới cần getConfiguredCaches().forEach(...). Cách thứ hai thất bại vì Boot dùng bean RedisCacheConfiguration thay cho các property: prefix catalog: và TTL 10 phút biến mất, và entry không bao giờ hết hạn. Hoặc đặt mọi thứ trong bean, hoặc dùng customizer bắt đầu từ builder.cacheDefaults(), vốn đã áp dụng sẵn các property.

TTL riêng cho từng cache và giá trị null

Customizer cho topRated một TTL riêng. Sau mỗi thứ một request, redis-cli TTL trả về 600 cho catalog:products::7 và 60 cho catalog:topRated::mice:2.

Một giá trị null trong cache không đi qua serializer JSON. RedisCache lưu NullValue dưới dạng một mảng byte JDK serialization cố định, bất kể value serializer là gì: khi đã cấu hình JSON, catalog:products::9999 vẫn chứa stream \xac\xed…NullValue 64 byte ở trên. Với spring.cache.redis.cache-null-values=false, Optional rỗng của 9999 không được lưu, và request thất bại:

Text
java.lang.IllegalArgumentException: Cache 'products' does not allow 'null' values; Avoid storing null via '@Cacheable(unless="#result == null")' or configure RedisCache to allow 'null' via RedisCacheConfiguration

Vậy tắt null value thì phải có unless = "#result == null" trên mọi method có thể trả về null hoặc Optional rỗng.

Đo latency: không cache, Caffeine và Redis

Một client Java nhỏ gửi tuần tự các request GET /api/products/{id} trên một kết nối HTTP keep-alive, xoay vòng 100 id: 300 request khởi động, sau đó 2.000 request được đo. Mỗi cấu hình chạy ba lần trên một instance vừa khởi động. Bảng ghi lần chạy có median tốt nhất; cột SQL là mức thay đổi của bộ đếm trong 2.000 request được đo:

Cấu hìnhMedianp99MaxSố câu SQLLoad average (1, 5, 15 phút)
Không cache (spring.cache.type=none)24,08 ms31,12 ms47,46 ms2.0005.23 5.30 4.38
Caffeine0,197 ms1,348 ms5,006 ms04.16 4.87 4.38
Redis, value JSON0,556 ms2,093 ms7,188 ms04.92 4.99 4.43

Qua ba lần chạy, median dao động từ 24,08 đến 25,00 ms khi không cache, 0,197 đến 0,512 ms với Caffeine, và 0,556 đến 1,471 ms với Redis. Các con số chỉ mang tính tham khảo, với Redis chạy trong một container Docker trên cùng máy với ứng dụng. 24 ms là 20 ms giả lập cộng với HTTP, JDBC và mapping. Một lần hit Caffeine là một phép tra hash trên heap. Một lần hit Redis cộng thêm round trip sang process khác và một lần parse JSON, ở đây khoảng 2,8 lần median của Caffeine, và vẫn chỉ khoảng 1/40 so với query. Trên mạng thật, round trip tới Redis tăng lên; round trip tới database mà nó thay thế cũng tăng theo.

Median và p99 latency của cùng một lookup khi không cache, với Caffeine và với Redis, kèm số câu SQL của mỗi cấu hình

Cache stampede: N cache miss đồng thời và sync = true

Khi một entry được truy cập nhiều bị hết hạn, mọi request đến trước khi nó quay lại cache đều miss cùng lúc, và request nào cũng chạy query. Một endpoint của lab tái hiện chuyện đó: nó evict product 42, khởi động N thread chờ trên một CountDownLatch, thả chúng cùng lúc, rồi đếm số câu SQL:

src/main/java/com/example/demo/lab/LabController.java
        CountDownLatch start = new CountDownLatch(1);
        List<Future<Optional<ProductView>>> results = new ArrayList<>();
        try (ExecutorService pool = Executors.newFixedThreadPool(threads)) {
            for (int i = 0; i < threads; i++) {
                long key = distinct ? id + i : id;
                results.add(pool.submit(() -> {
                    start.await();
                    return sync ? lookups.findSync(key) : service.find(key);
                }));
            }
            start.countDown();
            for (Future<Optional<ProductView>> f : results) {
                f.get();
            }
        }

sync = true bảo interceptor gọi Cache.get(key, valueLoader) thay vì get rồi put, và để cache tự đảm bảo chỉ một caller load một key nhất định:

src/main/java/com/example/demo/catalog/ProductService.java
    @Cacheable("products") 
    @Cacheable(cacheNames = "products", sync = true) 
    public Optional<ProductView> find(long id) {
        return products.findView(id);
    }

Hai mươi thread, một key, mỗi cấu hình hai lượt. Pool mặc định 10 connection của HikariCP được chia cho những thread thực sự xuống database:

Provider và writersyncSố câu SQLThời gianLoad average
Caffeinefalse20, 20148 ms, 50 ms8.10 5.70 4.70
Caffeinetrue1, 128 ms, 54 ms8.10 5.70 4.70
Redis, writer mặc địnhfalse20, 20134 ms, 51 ms7.77 5.71 4.71
Redis, writer mặc địnhtrue20, 2056 ms, 51 ms7.77 5.71 4.71
Redis, locking writerfalse20, 15149 ms, 118 ms7.47 5.69 4.71
Redis, locking writertrue1, 1742 ms, 387 ms7.47 5.69 4.71

Cache provider nào tôn trọng sync = true?

Caffeine có: CaffeineCache.get(key, valueLoader) của Spring giao cho phép get nguyên tử theo từng key của Caffeine, nên 19 thread chờ thread đang load. ConcurrentMapCache cũng vậy, bằng computeIfAbsent.

Redis với cấu hình mặc định của Boot thì không. RedisCache.get(key, valueLoader) chuyển loader cho cache writer. Boot dựng manager bằng RedisCacheManager.builder(connectionFactory), với writer loại không lock, và phiên bản của writer đó chỉ là "GET, nếu miss thì load rồi SET", không có lock nào. Hai mươi thread, hai mươi query. Locking writer giữ một lock trong Redis quanh lần load, nên nó giữ được stampede ở mức một query:

src/main/java/com/example/demo/cache/RedisCacheConfig.java
    @Bean
    RedisCacheManagerBuilderCustomizer lockingCacheWriter(RedisConnectionFactory connectionFactory) {
        return builder -> builder.cacheWriter(RedisCacheWriter.lockingRedisCacheWriter(connectionFactory));
    }

Lock đó có hai cái giá, cả hai đều thấy được trong các lần chạy. Nó là một lock cho cả cache, key products~lock, không phải mỗi key một lock, và các thread chờ poll nó kèm sleep. Method get thông thường của writer cũng kiểm tra cùng lock đó, nên trong lúc một key đang load, các lần đọc mọi key khác của cache cũng phải chờ. Stampede trên một key mất 387 đến 742 ms thay vì khoảng 50. Hai mươi thread, mỗi thread load một key khác nhau với sync = true, mất 1.156 và 1.059 ms với locking writer, so với 160 và 55 ms với writer mặc định và 111 và 49 ms với Caffeine (load 6.31, 6.19 và 6.18). Trường hợp nào cũng chạy đủ 20 query, và với locking writer chúng chạy lần lượt từng cái một.

Sự khác nhau giữa lock cục bộ và lock phân tán lộ ra khi có hai instance, mỗi instance 10 thread, thả cùng lúc vào cùng một key:

Thiết lập, sync = trueSQL trên 8209SQL trên 9209Tổng, hai lượtLoad average
Caffeine trên từng instance112 và 25.34 5.41 4.69
Redis, locking writer011 và 15.26 5.39 4.67
Redis, writer mặc định101020 và 205.48 5.43 4.69

Lock cục bộ chặn stampede trong từng JVM, nên N instance chạy query N lần, thường là chấp nhận được. Locking writer là thiết lập duy nhất ở đây chỉ load một lần trên mọi instance, và cái giá là một lock trên cả cache mà mọi lần đọc và ghi đều phải kiểm tra.

sync = true cũng có giới hạn riêng: CacheAspectSupport từ chối nó khi đi cùng các cache operation khác trên method, khi có nhiều hơn một cache, và khi có unless. Việc kiểm tra xảy ra ở lần gọi đầu tiên chứ không phải lúc khởi động:

Text
java.lang.IllegalStateException: A sync=true operation does not support the unless attribute on 'Builder[public java.util.Optional com.example.demo.lab.LabLookups.tempSyncUnless(long)] caches=[products] | key='' | … | unless='#result == null' | sync='true''

Các chiến lược invalidation

Một entry trong cache đúng cho tới khi dòng dữ liệu tạo ra nó thay đổi. Mọi thứ xảy ra sau đó là invalidation: hoặc entry tự hết hạn, hoặc lần ghi xoá hay thay thế nó.

Chỉ dùng TTL và khoảng thời gian dữ liệu cũ

Khi có TTL mà không evict, một entry là dữ liệu cũ từ lúc dòng thay đổi cho đến lúc nó hết hạn. Một script cache product 31 trên một instance Caffeine với expireAfterWrite=30s, đổi giá bằng psql 5,2 giây sau (bỏ qua ứng dụng, như một service khác hay một migration sẽ làm), rồi poll mỗi 200 ms:

Text
t=+0.0s  reader 8209 caches price 206.0
t=+5.2s  price set to 111.00 by psql UPDATE (bypassing the application)
t=+30.3s  reader 8209 returns 111.0: 121 stale reads, stale for 25.1s after the write

Khoảng dữ liệu cũ bằng TTL trừ đi tuổi của entry lúc ghi, và trường hợp xấu nhất là trọn TTL (load 5.13 5.37 4.70). Chỉ dùng TTL là công cụ đúng cho dữ liệu mà hệ thống khác thay đổi và độ trễ có giới hạn là chấp nhận được. Nó là loại invalidation duy nhất hoạt động khi ứng dụng không nhìn thấy lần ghi.

Evict chạy trước hay sau commit

changePrice mang @Transactional và các cache annotation trên cùng một method. Khi bật log của transaction và cache, các cache operation chạy sau commit:

Text
o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [com.example.demo.catalog.ProductService.changePrice]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
org.hibernate.SQL : select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=?
org.hibernate.SQL : update products set category_id=?,name=?,price=?,sku=? where id=?
org.hibernate.SQL : select p.id, p.sku, p.name, p.price, c.name as category,
o.s.orm.jpa.JpaTransactionManager : Initiating transaction commit
o.s.orm.jpa.JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(1757222655<open>)]
o.s.cache.interceptor.CacheInterceptor : Creating cache entry for key '13' in cache(s) [products]
o.s.cache.interceptor.CacheInterceptor : Invalidating entire cache for operation Builder[public java.util.Optional com.example.demo.catalog.ProductService.changePrice(long,java.math.BigDecimal)] caches=[topRated] | …

Trong ứng dụng này, cache interceptor bọc bên ngoài transaction interceptor. @EnableCaching@EnableTransactionManagement đều mặc định Ordered.LOWEST_PRECEDENCE, nên thứ tự lồng nhau đó không phải điều mà annotation nào hứa hẹn. Trường hợp quan trọng trong thực tế lại là một trường hợp khác: method ghi có cache thường được gọi từ một transaction lớn hơn nó, và khi đó evict của nó chạy bên trong transaction đó, trước commit.

Evict khi ghi bên trong một transaction lớn hơn: race condition

Một đợt cập nhật giá được áp dụng trong một transaction, để hoặc mọi dòng đều đổi hoặc không dòng nào đổi. Mỗi dòng đi qua một method evict key của product:

src/main/java/com/example/demo/catalog/ProductService.java
    @Transactional
    @CacheEvict(cacheNames = "products", key = "#id")
    public void changePriceEvicting(long id, BigDecimal price) {
        Product product = products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
        product.setPrice(price);
    }
src/main/java/com/example/demo/catalog/PriceImportService.java
    /** A price feed applied in one transaction: every row commits, or none does. */
    @Transactional
    public void importPrices(Map<Long, BigDecimal> prices) {
        prices.forEach(products::changePriceEvicting);
        log.info("all prices applied, committing");
        pause.pauseIfArmed();
    }

pause.pauseIfArmed() là một hook của lab: hai latch giữ writer lại sau các lần evict và trước commit, trong lúc một reader chạy. Trên Redis, với product 21 đang ở 316.00 và đợt cập nhật đặt thành 99.00:

Text
14:35:03.368662 reader primes the cache: price=316.00 (1 SQL, miss)
14:35:03.395632 writer: price changed, changePriceEvicting returned, transaction still open
14:35:03.430812 reader in the gap: price=316.00 (1 SQL, miss)
14:35:03.441432 writer committed
14:35:03.450664 reader after the commit: price=316.00 (0 SQL, hit)
14:35:03.454719 database: price=99.00

Một đoạn log cho thấy thứ tự trên cả hai thread:

Text
14:35:03.393 [writer] o.s.cache.interceptor.CacheInterceptor : Invalidating cache key [21] for operation Builder[public void com.example.demo.catalog.ProductService.changePriceEvicting(long,java.math.
14:35:03.395 [writer] c.e.demo.catalog.PriceImportService : all prices applied, committing
14:35:03.396 [nio-8209-exec-1] o.s.cache.interceptor.CacheInterceptor : No cache entry for key '21' in cache(s) [products]
14:35:03.397 [nio-8209-exec-1] org.hibernate.SQL : select p.id, p.sku, p.name, p.price, c.name as category,
14:35:03.430 [nio-8209-exec-1] o.s.cache.interceptor.CacheInterceptor : Creating cache entry for key '21' in cache(s) [products]
14:35:03.430 [writer] o.s.orm.jpa.JpaTransactionManager : Initiating transaction commit
14:35:03.437 [writer] org.hibernate.SQL : update products set category_id=?,name=?,price=?,sku=? where id=?
14:35:03.450 [nio-8209-exec-1] o.s.cache.interceptor.CacheInterceptor : Cache entry for key '21' found in cache(s) [products]

Evict làm trống key trong lúc giá mới vẫn chỉ nằm trong persistence context của writer. Hibernate thậm chí chưa gửi UPDATE cho tới lần flush lúc commit. Reader miss, đọc giá đã commit 316.00, và đặt nó trở lại cache. Sau commit, Redis giữ 316.00 với TTL 600, nên mọi instance trả giá cũ trong tối đa mười phút. Cú pause chỉ làm thời điểm trở nên tất định; trong production, khoảng hở dài bằng thời gian phần còn lại của transaction.

Cache nhận biết transaction: evict và put sau commit

TransactionAwareCacheDecorator của Spring hoãn put, evictclear tới một callback afterCommit khi có transaction đang hoạt động, và bỏ chúng khi rollback. RedisCacheManager kế thừa AbstractTransactionSupportingCacheManager, và builder của nó có công tắc cho việc này. Boot không có property cho nó, nên nó được đặt trong customizer:

src/main/java/com/example/demo/cache/RedisCacheConfig.java
        return builder -> {
            RedisCacheConfiguration defaults = builder.cacheDefaults()
                    .serializeValuesWith(SerializationPair.fromSerializer(json));
            builder.cacheDefaults(defaults);
            builder.getConfiguredCaches().forEach(name -> builder.withCacheConfiguration(name, defaults));
            builder.withCacheConfiguration("topRated", defaults.entryTtl(Duration.ofMinutes(1)));
            builder.transactionAware(); 
        };

Cùng cách xen kẽ cưỡng bức đó, lại bắt đầu từ 316.00:

Text
14:34:46.778112 reader primes the cache: price=316.00 (1 SQL, miss)
14:34:46.804579 writer: price changed, changePriceEvicting returned, transaction still open
14:34:46.814594 reader in the gap: price=316.00 (0 SQL, hit)
14:34:46.831221 writer committed
14:34:46.856745 reader after the commit: price=99.00 (1 SQL, miss)
14:34:46.861394 database: price=99.00

Reader trong khoảng hở nhận giá cũ dưới dạng hit, và điều đó đúng: 99.00 chưa được commit. Evict chạy sau commit, nên lần đọc tiếp theo miss và cache 99.00. CaffeineCacheManager không hỗ trợ transaction và Boot không cung cấp gì cho nó; cách tương đương là tự định nghĩa bean CacheManager bọc trong TransactionAwareCacheManagerProxy, đồng nghĩa với việc tự dựng Caffeine manager.

Cùng công tắc đó sửa luôn vấn đề thứ hai của @CachePut trong một transaction lớn hơn. Một đợt import hai dòng qua changePrice, trong đó dòng thứ hai không tồn tại:

TrướcSau rollbackDatabase
RedisCacheManager thường353.00 (miss)50.00 (hit)353.00
transactionAware()353.00 (miss)353.00 (hit)353.00

Khi không có nó, @CachePut của dòng đầu ghi 50.00 vào Redis trước khi dòng thứ hai throw ProductNotFoundException. Transaction rollback, còn cache tiếp tục trả về một mức giá chưa từng được commit. Hoãn tới sau commit vẫn để lại một khoảng hở hẹp hơn. Một reader đã load dòng trước commit nhưng ghi kết quả sau evict vẫn đặt giá trị cũ trở lại. TTL là chốt chặn cuối cùng cho trường hợp đó.

Hai instance: Caffeine cục bộ trả dữ liệu cũ, Redis dùng chung thì không

Hai bản của ứng dụng chạy trên 8209 và 9209 với cùng một database. Với Caffeine (expireAfterWrite=30s), 9209 cache product 32, và 5,5 giây sau giá được đổi qua 8209. @CachePut của 8209 chỉ cập nhật cache của 8209:

Text
t=+0.0s  reader 9209 caches price 243.0
t=+5.5s  price set to 122.00 by PUT through 8209
t=+30.6s  reader 9209 returns 122.0: 121 stale reads, stale for 25.1s after the write

Cùng bài kiểm tra đó khi cả hai instance dùng Redis:

Text
t=+0.0s  reader 9209 caches price 280.0
t=+5.8s  price set to 133.00 by PUT through 8209
t=+5.8s  reader 9209 returns 133.0: 0 stale reads, stale for 0.0s after the write

Load average lần lượt là 4.09 và 6.54. Với cache cục bộ, một lần evict hay put chỉ đến được instance đã thực hiện lần ghi, và mọi instance khác trả dữ liệu cũ trong tối đa một TTL. Với cache dùng chung chỉ có một entry, và lần đọc đầu tiên của 9209 sau khi ghi là một hit trên chính giá trị mà 8209 đã put.

Hai instance với cache cục bộ trả giá cũ sau khi ghi qua một trong hai, cache Redis dùng chung trả giá mới, và race evict-trước-commit đặt lại dòng cũ vào cache

Cache hai tầng

Có thể kết hợp cả hai: một cache Caffeine nhỏ, TTL ngắn đặt trước Redis trong mỗi instance, để những key nóng nhất chỉ tốn một lần tra heap và phần còn lại tốn một round trip tới Redis. Cái giá là dữ liệu cũ của tầng cục bộ như bài kiểm tra ở trên, giới hạn bởi TTL của chính nó, trừ khi mỗi lần ghi cũng phát một thông báo invalidation tới mọi instance, chẳng hạn qua Redis pub/sub. Spring không có sẵn CacheManager hai tầng; bạn tự ghép một cái từ hai implementation Cache, hoặc dùng một thư viện làm việc đó. Nó đáng giá khi latency hoặc tải của Redis là nút thắt đã được đo, và chưa đáng trước lúc đó.

Khi Redis ngừng trả lời

Một script gửi mỗi giây một request và đổi trạng thái của Redis ở giữa chừng. Product 1 đến 3 đã được cache trước khi đổi. Với mặc định của Boot và Redis bị dừng (docker stop):

Text
t=+  2.0s  GET /api/products/1 -> 200 in    30.6 ms
t=+  3.3s  docker stop sba-a9-redis
t=+  3.3s  GET /api/products/2 -> 500 in    16.8 ms
t=+  4.3s  GET /api/products/3 -> 500 in     3.0 ms
t=+  5.3s  GET /api/products/1 -> 500 in     2.8 ms
Text
org.springframework.data.redis.RedisSystemException: Redis exception
java.net.SocketException: Connection reset

Mọi request đều thất bại, kể cả những request mà câu trả lời chỉ cách một query 20 ms (load 5.67 5.41 4.83). Lỗi đến nhanh vì cơ chế port forwarding của Docker Desktop reset mọi lần thử kết nối. Một Redis ngừng trả lời mà không đóng kết nối thì hành xử khác. docker pause đóng băng process:

Text
t=+  2.1s  docker pause sba-a9-redis
t=+  2.1s  GET /api/products/1 -> 500 in 60018.5 ms
t=+ 62.1s  GET /api/products/2 -> 500 in 60030.4 ms
t=+122.1s  GET /api/products/3 -> 500 in 59996.2 ms
t=+182.2s  docker unpause sba-a9-redis
t=+182.2s  GET /api/products/1 -> 200 in    11.0 ms
Text
org.springframework.dao.QueryTimeoutException: Redis command timed out
io.lettuce.core.RedisCommandTimeoutException: Command timed out after 1 minute(s)

Load average của lần chạy đó là 5.59 5.36 4.83. Boot để trống spring.data.redis.timeout, nên timeout mặc định một phút của chính Lettuce được áp dụng, và mỗi request giữ một thread của Tomcat trong 60 giây. Với 200 worker thread, mặc định của Tomcat, chỉ cần hơn khoảng ba request mỗi giây là hết thread trước khi timeout đầu tiên xảy ra. Đặt timeout là cách sửa đầu tiên:

src/main/resources/application-redis.properties
spring.data.redis.timeout=250ms

Khi đó các request thất bại sau 255 đến 268 ms với Command timed out after 250 millisecond(s) (load 4.64 4.81 4.68). Nhưng chúng vẫn thất bại. Một CacheErrorHandler quyết định một exception của cache sẽ dẫn tới đâu. Spring có sẵn LoggingCacheErrorHandler, ghi log rồi nuốt exception, nên một lần get thất bại được coi như miss và method được chạy. Nó được đăng ký qua CachingConfigurer:

src/main/java/com/example/demo/cache/CacheErrorHandlingConfig.java
/** Treats a failing cache as a miss: log it, then run the method. */
@Configuration(proxyBeanMethods = false)
public class CacheErrorHandlingConfig implements CachingConfigurer {
 
    @Override
    public CacheErrorHandler errorHandler() {
        return new LoggingCacheErrorHandler(false);
    }
}

Với timeout 250 ms và handler, cùng lần pause và cùng lần stop đó:

Text
t=+  3.1s  docker pause sba-a9-redis
t=+  3.1s  GET /api/products/2 -> 200 in   286.3 ms
t=+  4.1s  GET /api/products/3 -> 200 in   290.2 ms
t=+  5.1s  GET /api/products/1 -> 200 in   291.8 ms
t=+  6.2s  docker unpause sba-a9-redis
t=+  6.2s  GET /api/products/2 -> 200 in    17.9 ms
Text
t=+  3.3s  docker stop sba-a9-redis
t=+  3.3s  GET /api/products/2 -> 200 in    27.1 ms
t=+  4.4s  GET /api/products/3 -> 200 in    35.8 ms
t=+  5.4s  GET /api/products/1 -> 200 in    39.9 ms
t=+  6.5s  docker start sba-a9-redis
t=+  6.5s  GET /api/products/2 -> 200 in    30.8 ms
Text
WARN o.s.c.i.LoggingCacheErrorHandler : Cache 'products' failed to get entry with key '2'

Mọi request đều thành công nhờ database: 250 ms chờ timeout của get cộng với query khi Redis treo, và chỉ có query khi Redis từ chối kết nối (load 5.02 và 4.40). Handler chỉ ghi log các lần get thất bại, không bao giờ ghi các lần put thất bại, và put không cộng thêm 250 ms. Lý do là Spring Data Redis 4 mặc định ghi cache entry bất đồng bộ: put, evictclear được gửi đi mà không chờ Redis (RedisCacheWriterConfigurer.immediateWrites() tắt chế độ đó). Một lần đổi giá gửi trong lúc Redis bị pause trả về 200 sau 30 ms, không có warning nào. Nhanh, nhưng lỗi ở phía ghi không bao giờ tới được error handler: một lần evict không tới nơi sẽ trôi qua mà không ai hay, và chỉ TTL mới dọn được entry mà nó định xoá.

Còn hai giới hạn. Mỗi request trong lúc Redis treo vẫn phải trả trọn timeout; một circuit breaker đặt trước cache sẽ bỏ qua Redis sau vài lần thất bại đầu tiên, việc mà bài này không bàn tới. Và database giờ gánh toàn bộ tải mà cache đang hấp thụ, ổn với catalogue này nhưng không ổn với một hệ thống được tính toán dựa trên giả định tỉ lệ hit 99%.

Chọn giữa không cache, Caffeine và Redis

Không cacheCaffeine (cục bộ)Redis (phân tán)
Latency đo được, median / p9924,08 / 31,12 ms, 1 câu SQL mỗi request0,197 / 1,348 ms, 0 SQL0,556 / 2,093 ms, 0 SQL
Nhất quán giữa các instanceLuôn mới nhấtMỗi instance một bản: instance kia cũ 25,1 s với TTL 30 sMột bản dùng chung: 0 lần đọc cũ sau khi ghi qua bất kỳ instance nào
Chi phí invalidationKhông cóEvict chỉ tới được một heap; các instance khác chờ TTL hoặc cần broadcastMột command qua mạng cho mỗi key; allEntries chạy KEYS trên keyspace
Stampede, 20 thread trên một key20 query1 query với sync = true, trên mỗi instance20 query dù có sync = true; 1 với locking writer và lock trên cả cache của nó
ValueKhông lưuChính object đó, dùng chung theo referenceMột bản sao được serialize cho mỗi lần hit; cần serializer và type validator
Khi cache hỏngKhông có gì để hỏngChỉ hỏng cùng JVM; bộ nhớ bị giới hạn bởi maximumSizeMặc định: HTTP 500 cho mọi request có cache, hoặc mỗi request treo 60 s nếu Redis ngừng trả lời; với timeout và LoggingCacheErrorHandler: phục vụ từ database

Hãy bắt đầu từ không cache và một vấn đề đã được đo. Chọn Caffeine khi dữ liệu chấp nhận được việc cũ tới một TTL giữa các instance, hoặc khi chỉ có một instance. Chọn Redis khi các instance phải thống nhất ngay sau một lần ghi, hoặc khi tập dữ liệu cache quá lớn cho heap của từng instance. Có hai chủ đề lân cận mà bài này không bàn tới. Second-level cache của Hibernate cache entity và collection ở tầng dưới repository, theo id, và hợp với các lần đọc xoay quanh entity hơn là read model như ở đây. HTTP caching (Cache-Control, ETag) giữ response ở browser và proxy, nơi không có dòng code server nào chạy.

FAQ

Spring Boot 4 có cần @EnableCaching không?

Có. CacheAutoConfiguration của Boot chỉ chạy khi tồn tại bean CacheAspectSupport@EnableCaching đăng ký. Không có annotation, Boot 4.1.1 không tạo CacheManager nào, @Cacheable bị bỏ qua trong im lặng (hai request, hai câu SQL), /actuator/caches trả về {"cacheManagers":{}}, và log không nói gì.

Vì sao Spring Boot dùng Redis thay vì Caffeine cho cache của tôi?

Khi không đặt spring.cache.type, Boot thử các provider theo thứ tự của enum CacheType, và REDIS đứng trước CAFFEINE. Mọi ứng dụng có Redis starter đều có RedisConnectionFactory, nên nó nhận RedisCacheManager dù Caffeine có trên classpath. Hãy đặt spring.cache.type=caffeine (hoặc redis) một cách tường minh.

Vì sao @Cacheable không hoạt động khi gọi method từ cùng class?

Lời gọi đi tới this, không đi qua proxy chứa cache interceptor, nên không có lần tra cache nào: một method theo lô gọi find từ bên trong đã chạy sáu câu SQL cho hai request ba id, và cache.gets không đổi. Hãy đặt method được cache trên một bean khác, hoặc gọi nó qua proxy được inject.

Làm sao lưu value của cache dạng JSON trong Redis với Spring Boot 4 và Jackson 3?

Dùng GenericJacksonJsonRedisSerializer của Spring Data Redis 4, bật default typing với một BasicPolymorphicTypeValidator cho phép các package của bạn cùng java.util.java.math., rồi đặt nó trong một RedisCacheManagerBuilderCustomizer trên builder.cacheDefaults() trên từng tên trong builder.getConfiguredCaches(). Chỉ đặt cho default thì các cache liệt kê trong spring.cache.cache-names vẫn dùng JDK serialization. Một bean RedisCacheConfiguration cũng chạy, nhưng bỏ qua mọi property spring.cache.redis.*.

@Cacheable(sync = true) có chặn được cache stampede với Redis không?

Với cấu hình mặc định của Boot thì không. RedisCacheWriter mặc định loại không lock thì không giữ lock nào, và 20 cache miss đồng thời chạy 20 query dù có sync = true. RedisCacheWriter.lockingRedisCacheWriter(...) giảm xuống còn 1, kể cả trên hai instance, nhưng lock của nó phủ cả cache: 20 lần miss trên 20 key khác nhau mất hơn một giây, so với 55 đến 160 ms với writer mặc định. Caffeine tôn trọng sync = true theo từng key.

@CacheEvict nên chạy trước hay sau khi transaction commit?

Sau. Khi evict chạy bên trong transaction, một reader đồng thời có thể miss, đọc dòng cũ đã commit rồi đặt nó trở lại trước commit; lab đã tái hiện chuyện đó và giữ một mức giá cũ trong Redis với TTL 600 giây. RedisCacheManager.builder(...).transactionAware() (qua một RedisCacheManagerBuilderCustomizer) hoãn evict và put tới sau commit và bỏ chúng khi rollback.

Request ra sao khi Redis ngừng hoạt động?

Với mặc định của Boot, mọi request có cache đều thất bại. Redis bị dừng cho ra RedisSystemException ngay lập tức; Redis bị treo cho ra 60 giây chờ và QueryTimeoutException, từ timeout mặc định một phút của Lettuce. Hãy đặt spring.data.redis.timeout và đăng ký LoggingCacheErrorHandler qua CachingConfigurer: khi đó request quay về database trong khoảng 290 ms lúc Redis treo.

Kết luận

Cache abstraction không lớn: @EnableCaching (vẫn bắt buộc trong Boot 4.1.1, và im lặng khi thiếu), @Cacheable, @CachePut, @CacheEvict@Caching, với key từ SimpleKey hoặc SpEL, cùng conditionunless để quyết định tra gì và lưu gì. Thứ được lưu là object trả về, và nó nên là một record: một entity trong cache có throw LazyInitializationException hay không tuỳ vào caller nào điền cache trước. Caffeine biến một lookup 24 ms thành median 0,197 ms với không câu SQL nào, cho thấy eviction và số hit qua Actuator khi đã đặt recordStatscache-names, và giữ stampede ở một query trên mỗi instance. Redis, thứ mà Boot chọn thay Caffeine khi có cả hai, tốn 0,556 ms và cho mọi instance cùng một entry. Nó cũng cần chăm chút trước khi chạy được: JDK serialization từ chối record, GenericJacksonJsonRedisSerializer cần một type validator cho phép BigDecimal, và hai cách đặt serializer nghe rất hợp lý thì mỗi cách lại làm mất một phần cấu hình.

Invalidation mang phần lớn rủi ro. TTL giới hạn thời gian dữ liệu cũ, và là thứ duy nhất bảo vệ trước những lần ghi mà ứng dụng không bao giờ thấy. Một lần evict bên trong transaction lớn hơn cho phép reader đặt giá cũ trở lại trong mười phút, và một @CachePut trong transaction bị rollback đã cache một mức giá chưa từng tồn tại; transactionAware() sửa được cả hai. Cache cục bộ trên hai instance lệch nhau 25 giây sau một lần ghi, còn cache dùng chung thì không lệch chút nào. sync = true không có tác dụng gì với writer mặc định của Redis. Và một Redis bị treo giữ mọi request trong một phút, cho tới khi timeout và một CacheErrorHandler biến sự cố thành những lần đọc chậm hơn từ database.

Bài tiếp theo nói về nhiều datasource: cấu hình multi-datasource trong Spring Boot, và định tuyến đọc/ghi giữa database chính và read replica.

Bài viết liên quan

[Advanced Spring Boot] Spring AOP: proxy JDK và CGLIB, aspect và lỗi self-invocation

Spring AOP trên Spring Boot 4.1.1: spring-boot-starter-aop đã biến mất khỏi BOM và spring-boot-starter-aspectj thay thế nó, JDK dynamic proxy so với CGLIB subclass kèm tên class thật, ClassCastException mà JDK proxy gây ra, class final ném AopConfigException, method final âm thầm NPE vì Objenesis bỏ qua constructor, các pointcut designator đáng dùng, thứ tự đo được của cả năm loại advice trên hai nhánh, @Order giữa các aspect, lỗi self-invocation nằm dưới @Transactional và @Async cùng ba cách sửa được so sánh, chi phí nanosecond của một lời gọi qua proxy, và Advised#getAdvisors để debug.

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

Locking và concurrency trong Spring Boot 4.1.1 trên PostgreSQL: lost update khi hai người cùng sửa một product, @Version và câu update … where version=? nó gửi đi, chuỗi exception tới được code của bạn, saveAll, dirty checking và bulk update @Modifying bỏ qua version, version qua HTTP trả về 409 ProblemDetail, retry một conflict bao quanh cả transaction và cách đặt retry khiến nó không bao giờ retry, PESSIMISTIC_WRITE so với PESSIMISTIC_READ, NOWAIT và jakarta.persistence.lock.timeout thành set local lock_timeout, SKIP LOCKED cho work queue, một deadlock thật (40P01) và cách sửa, atomic update có điều kiện, CHECK constraint, và một bảng để chọn giữa chúng.

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.

[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.