Caching: cache-aside, write-through và TTL

1. Tổng quan

Cache giúp giảm tải cho database và rút ngắn thời gian phản hồi. Bù lại, bạn phải chấp nhận dữ liệu có thể cũ. Bài này trình bày hai chiến lược đọc/ghi cache phổ biến là cache-aside và write-through. Bài cũng giải thích cách dùng TTL (time-to-live) để giới hạn độ cũ của dữ liệu.

Đọc xong, bạn sẽ tự cài được cả hai chiến lược bằng Java thuần, chuyển sang dùng annotation của Spring Cache, cấu hình TTL cho Caffeine và Redis, và tránh được vài lỗi hay gặp.

2. Các khái niệm cơ bản

Trước khi vào code, ta thống nhất vài thuật ngữ:

  • Cache hit: dữ liệu có sẵn trong cache, trả về ngay.
  • Cache miss: cache không có dữ liệu, phải đọc từ nguồn gốc (database, service khác).
  • Stale data: dữ liệu trong cache đã khác với database.
  • TTL: thời gian sống của một entry. Hết TTL thì entry bị coi là không còn hợp lệ.

Mọi chiến lược cache đều xoay quanh một câu hỏi: khi dữ liệu thay đổi, ai chịu trách nhiệm cập nhật cache, và cập nhật vào lúc nào?

Ta dùng chung một domain đơn giản cho cả bài:

public record Product(long id, String name, BigDecimal price) {}

public interface ProductRepository {
    Optional<Product> findById(long id);
    Product save(Product product);
}

3. Một cache có TTL viết tay

Để thấy rõ cơ chế, ta tự viết một cache nhỏ có TTL. Thời gian được lấy qua Supplier<Instant>, nhờ đó test có thể “tua” đồng hồ.

public class TtlCache<K, V> {

    private record Entry<V>(V value, Instant expiresAt) {}

    private final Map<K, Entry<V>> store = new ConcurrentHashMap<>();
    private final Duration ttl;
    private final Supplier<Instant> now;

    public TtlCache(Duration ttl, Supplier<Instant> now) {
        this.ttl = ttl;
        this.now = now;
    }

    public Optional<V> get(K key) {
        Entry<V> entry = store.get(key);
        if (entry == null) {
            return Optional.empty();
        }
        if (now.get().isAfter(entry.expiresAt())) {
            store.remove(key, entry);
            return Optional.empty();
        }
        return Optional.of(entry.value());
    }

    public void put(K key, V value) {
        store.put(key, new Entry<>(value, now.get().plus(ttl)));
    }

    public void evict(K key) {
        store.remove(key);
    }
}

Cách lớp này hoạt động:

  • put lưu giá trị kèm thời điểm hết hạn expiresAt = now + ttl.
  • get kiểm tra hạn. Nếu entry đã quá hạn, nó bị xoá và hàm trả về Optional.empty(), tức là một cache miss.
  • store.remove(key, entry) chỉ xoá đúng entry vừa đọc. Nhờ vậy, nếu một thread khác vừa put giá trị mới thì giá trị đó không bị xoá nhầm.

Lớp này đủ cho mục đích minh hoạ. Trong production, bạn nên dùng Caffeine hoặc Redis (mục 6).

4. Cache-aside

4.1. Ý tưởng

Với cache-aside (còn gọi là lazy loading), ứng dụng tự quản lý cache:

  1. Đọc: tìm trong cache trước. Nếu miss thì đọc database, ghi kết quả vào cache rồi trả về.
  2. Ghi: cập nhật database trước, sau đó xoá entry trong cache. Lần đọc kế tiếp sẽ nạp lại dữ liệu mới.
public class ProductService {

    private final ProductRepository repository;
    private final TtlCache<Long, Product> cache;

    public ProductService(ProductRepository repository, TtlCache<Long, Product> cache) {
        this.repository = repository;
        this.cache = cache;
    }

    public Optional<Product> findById(long id) {
        Optional<Product> cached = cache.get(id);
        if (cached.isPresent()) {
            return cached;                       // cache hit
        }
        Optional<Product> fromDb = repository.findById(id);
        fromDb.ifPresent(p -> cache.put(id, p)); // nạp vào cache
        return fromDb;
    }

    public Product updatePrice(long id, BigDecimal newPrice) {
        Product current = repository.findById(id).orElseThrow();
        Product saved = repository.save(new Product(id, current.name(), newPrice));
        cache.evict(id);                         // xoá sau khi DB đã ghi xong
        return saved;
    }
}

Giải thích:

  • findById chỉ gọi repository khi cache miss. Nếu sản phẩm không tồn tại, ta không cache gì cả.
  • updatePrice ghi database trước rồi mới evict. Thứ tự này quan trọng, mục 8 sẽ giải thích lý do.
  • Ta chọn xoá entry thay vì ghi đè giá trị mới. Cách này đơn giản hơn, vì cache chỉ được nạp từ một chỗ duy nhất là đường đọc.

4.2. Khi nào nên dùng

Cache-aside phù hợp khi:

  • Tỷ lệ đọc cao hơn ghi rất nhiều, ví dụ danh mục sản phẩm hoặc hồ sơ người dùng.
  • Bạn chỉ muốn cache dữ liệu thực sự được truy cập.
  • Hệ thống vẫn phải chạy được khi cache sập. Lúc đó mọi request đi thẳng vào database: chậm hơn nhưng không lỗi.

Nhược điểm: request đầu tiên sau mỗi lần miss luôn chậm. Ngoài ra, nếu có tiến trình khác ghi thẳng vào database mà không xoá cache, dữ liệu sẽ cũ cho tới khi hết TTL.

5. Write-through

5.1. Ý tưởng

Với write-through, mọi thao tác ghi đi qua tầng cache. Tầng này ghi đồng bộ vào database, rồi cập nhật cache với giá trị mới. Khi lệnh ghi trả về, cache và database đã khớp nhau.

public class WriteThroughProductStore {

    private final ProductRepository repository;
    private final TtlCache<Long, Product> cache;

    public WriteThroughProductStore(ProductRepository repository, TtlCache<Long, Product> cache) {
        this.repository = repository;
        this.cache = cache;
    }

    public Product save(Product product) {
        Product saved = repository.save(product); // 1. ghi DB (đồng bộ)
        cache.put(saved.id(), saved);             // 2. cập nhật cache
        return saved;
    }

    public Optional<Product> findById(long id) {
        return cache.get(id).or(() -> {
            Optional<Product> fromDb = repository.findById(id);
            fromDb.ifPresent(p -> cache.put(id, p));
            return fromDb;
        });
    }
}

Điểm khác với cache-aside:

  • save ghi đè cache bằng giá trị vừa lưu thay vì xoá. Lần đọc ngay sau đó là cache hit.
  • Ta chỉ ghi cache khi repository.save thành công. Nếu database ném exception thì cache không bị đụng tới, nên không có chuyện cache chứa dữ liệu chưa từng được lưu.
  • findById vẫn cần nhánh fallback, vì entry có thể đã hết TTL hoặc chưa từng được ghi qua save.

5.2. Khi nào nên dùng

Write-through hợp với dữ liệu vừa ghi xong là được đọc lại ngay, ví dụ giỏ hàng hoặc cài đặt người dùng. Cái giá phải trả:

  • Mỗi lần ghi chậm hơn một chút vì phải ghi hai nơi.
  • Cache có thể chứa nhiều dữ liệu không ai đọc. Bạn nên kết hợp với TTL và giới hạn kích thước.

Lưu ý: Đừng nhầm write-through với write-behind (write-back). Write-behind ghi cache trước rồi mới ghi database bất đồng bộ. Ghi nhanh hơn, nhưng nếu cache node chết trước khi kịp flush thì bạn mất dữ liệu. Chỉ dùng write-behind khi chấp nhận được rủi ro đó.

6. Dùng Spring Cache

Viết tay giúp hiểu cơ chế, còn trong dự án thực tế bạn nên dùng Spring Cache với Caffeine (cache local) hoặc Redis (cache phân tán). Thêm dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

Bật cache bằng @EnableCaching:

@SpringBootApplication
@EnableCaching
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

6.1. Cache-aside với @Cacheable và @CacheEvict

@Service
public class CatalogService {

    private final ProductRepository repository;

    public CatalogService(ProductRepository repository) {
        this.repository = repository;
    }

    @Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
    public Optional<Product> findById(long id) {
        return repository.findById(id);
    }

    @CacheEvict(cacheNames = "products", key = "#product.id")
    public Product update(Product product) {
        return repository.save(product);
    }
}
  • @Cacheable là cache-aside ở dạng khai báo. Spring kiểm tra cache trước, miss thì gọi method rồi lưu kết quả.
  • Với kiểu trả về Optional, Spring cache giá trị bên trong. unless = "#result == null" ngăn việc cache trường hợp không tìm thấy.
  • @CacheEvict mặc định chạy sau khi method trả về thành công. Đây đúng là thứ tự “ghi DB rồi xoá cache”.

6.2. Write-through với @CachePut

@CachePut(cacheNames = "products", key = "#result.id")
public Product save(Product product) {
    return repository.save(product);
}

@CachePut luôn chạy method, rồi ghi giá trị trả về vào cache. Cách này tương đương WriteThroughProductStore.save ở mục 5.

Lưu ý: Annotation cache hoạt động qua proxy. Nếu bạn gọi this.findById(...) từ một method khác trong cùng class thì cache bị bỏ qua hoàn toàn. Hãy gọi qua một bean khác.

7. Cấu hình TTL

7.1. Caffeine

spring:
  cache:
    type: caffeine
    cache-names: products
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m
  • expireAfterWrite=10m: entry hết hạn 10 phút sau lần ghi gần nhất.
  • maximumSize=10000: khi vượt giới hạn, Caffeine loại bớt entry ít dùng. Luôn đặt giới hạn này để tránh cache ăn hết heap.

7.2. Redis

Thêm spring-boot-starter-data-redis, rồi cấu hình:

spring.cache.type=redis
spring.cache.redis.time-to-live=10m
spring.cache.redis.key-prefix=shop::

Redis tự xoá key khi hết TTL. Cache này dùng chung giữa nhiều instance của ứng dụng.

7.3. Chọn TTL bao nhiêu?

Không có con số chung cho mọi trường hợp. Hãy tự hỏi: dữ liệu được phép cũ tối đa bao lâu?

Tỷ giá, tồn kho        : vài giây – 1 phút
Giá sản phẩm, danh mục : 5 – 30 phút
Cấu hình, danh sách tỉnh/thành : vài giờ – 1 ngày

TTL ngắn cho dữ liệu tươi hơn nhưng tỷ lệ hit thấp hơn. TTL dài thì ngược lại. Kể cả khi đã evict chủ động, bạn vẫn nên đặt TTL làm lưới an toàn cho những lần evict bị sót.

7.4. Thêm jitter để tránh hết hạn đồng loạt

Giả sử bạn nạp 10.000 sản phẩm cùng lúc với TTL 10 phút. Chúng sẽ hết hạn cùng lúc, và database hứng một đợt request dồn dập. Hiện tượng này gọi là cache stampede. Cách khắc phục là cộng thêm một khoảng ngẫu nhiên vào TTL:

public void put(K key, V value) {
    long jitterSeconds = ThreadLocalRandom.current().nextLong(0, 60);
    Instant expiresAt = now.get().plus(ttl).plusSeconds(jitterSeconds);
    store.put(key, new Entry<>(value, expiresAt));
}

Với Spring + Caffeine, bạn có thể thêm @Cacheable(..., sync = true). Khi nhiều thread cùng miss một key, chỉ một thread được gọi xuống database, các thread còn lại chờ kết quả.

8. Viết test

Ta dùng một repository giả để đếm số lần truy cập database, và một đồng hồ có thể điều khiển:

class ProductServiceTest {

    static class CountingRepository implements ProductRepository {
        final Map<Long, Product> data = new HashMap<>();
        int findCalls = 0;

        public Optional<Product> findById(long id) {
            findCalls++;
            return Optional.ofNullable(data.get(id));
        }

        public Product save(Product p) {
            data.put(p.id(), p);
            return p;
        }
    }

    AtomicReference<Instant> clock = new AtomicReference<>(Instant.parse("2026-01-01T00:00:00Z"));
    CountingRepository repository = new CountingRepository();
    ProductService service = new ProductService(
            repository, new TtlCache<>(Duration.ofMinutes(10), clock::get));

    @BeforeEach
    void setUp() {
        repository.save(new Product(1L, "Nón lá", new BigDecimal("50000")));
    }

    @Test
    void secondReadIsServedFromCache() {
        service.findById(1L);
        service.findById(1L);

        assertThat(repository.findCalls).isEqualTo(1);
    }

    @Test
    void entryExpiresAfterTtl() {
        service.findById(1L);
        clock.set(clock.get().plus(Duration.ofMinutes(11)));
        service.findById(1L);

        assertThat(repository.findCalls).isEqualTo(2);
    }

    @Test
    void updateEvictsStaleValue() {
        service.findById(1L);
        service.updatePrice(1L, new BigDecimal("45000"));

        assertThat(service.findById(1L))
                .get()
                .extracting(Product::price)
                .isEqualTo(new BigDecimal("45000"));
    }
}
  • secondReadIsServedFromCache: lần đọc thứ hai không chạm database.
  • entryExpiresAfterTtl: sau khi tua đồng hồ quá 10 phút, cache miss và database bị gọi lại.
  • updateEvictsStaleValue: sau khi cập nhật, người đọc thấy giá mới chứ không phải giá cũ trong cache.

9. Lỗi hay gặp

Xoá cache trước khi ghi database. Xét kịch bản sau: thread A xoá cache. Thread B đọc, gặp miss, lấy giá cũ từ database rồi ghi vào cache. Sau đó thread A mới ghi giá mới vào database. Kết quả là cache giữ giá trị cũ cho tới hết TTL. Hãy ghi database trước, xoá cache sau.

Cache bên trong transaction chưa commit. Nếu bạn @CachePut trong một method @Transactional rồi transaction bị rollback, cache sẽ chứa dữ liệu không tồn tại trong database. Hãy đặt thao tác cache ở tầng ngoài transaction, hoặc dùng TransactionAwareCacheManagerProxy.

Cache local trên nhiều instance. Caffeine nằm trong JVM của từng instance. @CacheEvict trên instance A không xoá được cache của instance B. Khi chạy nhiều instance mà cần nhất quán, hãy dùng Redis hoặc chấp nhận độ cũ bằng TTL ngắn.

Cache object có thể thay đổi. Caffeine trả về chính tham chiếu đang nằm trong cache. Nếu code sửa object đó, bạn đã sửa luôn cache. Dùng record hoặc object immutable như ví dụ trong bài.

Lưu ý: Không nên cache khi dữ liệu thay đổi liên tục và yêu cầu nhất quán tuyệt đối, ví dụ số dư tài khoản hay số ghế còn trống tại thời điểm thanh toán. Ở những chỗ này, đọc thẳng database với khoá phù hợp an toàn hơn.

10. Kết luận

Bài đã so sánh hai chiến lược cache chính:

  • Cache-aside: ứng dụng tự nạp cache khi miss và xoá cache sau khi ghi. Đây là cách đơn giản và chịu lỗi tốt, phù hợp đa số trường hợp đọc nhiều.
  • Write-through: mọi thao tác ghi cập nhật đồng bộ cả database lẫn cache, đổi lấy dữ liệu tươi ngay sau khi ghi.

Dù chọn chiến lược nào, bạn vẫn cần TTL làm lưới an toàn, nên giới hạn kích thước cache, và nên thêm jitter để tránh stampede. Hãy bắt đầu với @Cacheable + Caffeine. Khi ứng dụng scale ra nhiều instance thì chuyển sang Redis, và luôn có test chứng minh cache thực sự giảm số lần truy cập database.

system-designcachingspring-bootcaffeineredis