Redis: chọn cache-aside, write-through hay write-behind

1. Tổng quan

Thêm Redis vào trước database là cách nhanh nhất để giảm độ trễ và tải cho hệ thống. Khó ở chỗ khác: bạn phải quyết định ghi dữ liệu thế nào để cache và database không lệch nhau. Có ba chiến lược phổ biến là cache-aside, write-through và write-behind.

Bài này cài đặt cả ba với Spring Boot 3 và Redis. Sau đó bài so sánh chúng theo độ nhất quán, hiệu năng và rủi ro mất dữ liệu. Đọc xong, bạn sẽ biết nên chọn chiến lược nào cho từng loại dữ liệu.

2. Chuẩn bị

2.1. Dependency

Ta dùng Spring Data Redis, Spring Data JPA với H2 làm database mẫu, và Testcontainers để test:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Cấu hình kết nối Redis khi chạy local:

spring.data.redis.host=localhost
spring.data.redis.port=6379

2.2. Entity, repository và RedisTemplate

@Entity
public class Product {

    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal price;

    protected Product() {
    }

    public Product(String name, BigDecimal price) {
        this.name = name;
        this.price = price;
    }

    // getters và setters
}

public interface ProductRepository extends JpaRepository<Product, Long> {
}

Spring Boot tự tạo sẵn StringRedisTemplate. Để lưu Product dưới dạng JSON, ta khai báo thêm một RedisTemplate riêng:

@Configuration
@EnableScheduling
public class RedisConfig {

    @Bean
    RedisTemplate<String, Product> productRedisTemplate(RedisConnectionFactory factory) {
        var template = new RedisTemplate<String, Product>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Product.class));
        return template;
    }
}

Key có dạng product:{id}, value là JSON của Product. Annotation @EnableScheduling được dùng ở phần write-behind.

Cả ba chiến lược đều cần chạy một đoạn code sau khi transaction commit. Ta gom phần này vào một helper:

public final class AfterCommit {

    public static void run(Runnable action) {
        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            action.run();
            return;
        }
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                action.run();
            }
        });
    }
}

Nếu đang ở trong transaction, action chỉ chạy khi commit thành công. Nếu transaction rollback, cache không bị đụng tới.

3. Cache-aside (lazy loading)

Ở cache-aside, ứng dụng tự quản lý cache:

  • Đọc: tìm trong Redis trước. Nếu miss, đọc database rồi ghi kết quả vào Redis kèm TTL.
  • Ghi: cập nhật database, sau đó xóa key trong Redis.
@Service
public class ProductCacheAsideService {

    private static final Duration TTL = Duration.ofMinutes(10);

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;

    public ProductCacheAsideService(ProductRepository repository,
                                    RedisTemplate<String, Product> redis) {
        this.repository = repository;
        this.redis = redis;
    }

    public Optional<Product> findById(Long id) {
        Product cached = redis.opsForValue().get(key(id));
        if (cached != null) {
            return Optional.of(cached);
        }
        Optional<Product> fromDb = repository.findById(id);
        fromDb.ifPresent(p -> redis.opsForValue().set(key(id), p, TTL));
        return fromDb;
    }

    @Transactional
    public Product updatePrice(Long id, BigDecimal newPrice) {
        Product product = repository.findById(id).orElseThrow();
        product.setPrice(newPrice);
        AfterCommit.run(() -> redis.delete(key(id)));
        return product;
    }

    static String key(Long id) {
        return "product:" + id;
    }
}

Hành vi của đoạn code trên:

  • Lần gọi findById(1L) đầu tiên đọc database và tạo key product:1 với TTL 10 phút.
  • Các lần gọi sau trong 10 phút đó trả về từ Redis, không chạm database.
  • updatePrice sửa entity, dirty checking của JPA ghi xuống database khi commit, rồi key bị xóa. Lần đọc kế tiếp sẽ nạp lại giá trị mới.

Kiểm tra bằng redis-cli:

redis-cli GET product:1
redis-cli TTL product:1
"{\"id\":1,\"name\":\"Bàn phím cơ\",\"price\":1200000.00}"
(integer) 587

Lưu ý: Khi ghi, hãy xóa key thay vì set giá trị mới. Giả sử hai request cập nhật cùng lúc. Nếu cả hai cùng set, thứ tự ghi vào Redis có thể ngược với thứ tự commit vào database, và cache giữ giá trị cũ vô thời hạn. Xóa key thì lần đọc sau luôn lấy bản mới nhất. Ngoài ra, phải xóa sau commit. Nếu xóa trước, một request đọc chen vào giữa có thể nạp lại dữ liệu cũ vào cache.

Ngay cả khi làm đúng, cache-aside vẫn còn một cửa sổ race nhỏ: một request đọc dữ liệu cũ từ database rồi ghi vào cache sau khi key vừa bị xóa. TTL là lưới an toàn cuối cùng cho trường hợp này. Đừng bao giờ cache mà không đặt TTL.

Với dữ liệu đơn giản, bạn có thể dùng @Cacheable và @CacheEvict của Spring Cache để đạt cùng hiệu quả với ít code hơn. Bản chất của chúng vẫn là cache-aside.

4. Write-through

Ở write-through, mỗi lần ghi sẽ cập nhật cả database lẫn cache một cách đồng bộ. Request chỉ trả về khi cả hai đã xong. Phần đọc vẫn giống cache-aside, nhưng tỉ lệ hit cao hơn vì dữ liệu vừa ghi đã nằm sẵn trong Redis.

@Service
public class ProductWriteThroughService {

    private static final Duration TTL = Duration.ofMinutes(30);

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;

    public ProductWriteThroughService(ProductRepository repository,
                                      RedisTemplate<String, Product> redis) {
        this.repository = repository;
        this.redis = redis;
    }

    @Transactional
    public Product save(Product product) {
        Product saved = repository.saveAndFlush(product);
        AfterCommit.run(() -> redis.opsForValue()
                .set(ProductCacheAsideService.key(saved.getId()), saved, TTL));
        return saved;
    }
}

Dòng saveAndFlush ghi xuống database ngay để có id và phát hiện lỗi ràng buộc sớm. Sau khi commit, bản ghi được đẩy vào Redis. Khi một sản phẩm vừa tạo được đọc lại, kết quả là cache hit ngay lần đầu.

Ưu điểm của write-through:

  • Cache luôn “ấm” với dữ liệu vừa ghi, không có cache miss đầu tiên.
  • Phù hợp với dữ liệu thường được đọc ngay sau khi ghi, ví dụ hồ sơ Customer hoặc giỏ hàng.

Nhược điểm:

  • Mỗi lần ghi tốn thêm một round-trip tới Redis, nên độ trễ ghi tăng.
  • Cache bị lấp đầy bởi cả những bản ghi không ai đọc. TTL giúp dọn bớt, nhưng vẫn tốn bộ nhớ.
  • Vẫn có race khi nhiều writer cùng set, như đã nói ở phần 3. Với dữ liệu cập nhật đồng thời nhiều, bạn cần thêm version, ví dụ dùng @Version và chỉ ghi đè khi version mới hơn, hoặc quay về cách xóa key.

Lưu ý: Nếu Redis lỗi sau khi database đã commit, cache sẽ thiếu bản mới nhất. Hãy bắt exception và xóa key như phương án dự phòng. Đừng để request thất bại khi database đã ghi thành công.

5. Write-behind (write-back)

Ở write-behind, ứng dụng chỉ ghi vào Redis rồi trả về ngay. Một tiến trình nền gom các thay đổi và ghi xuống database theo lô. Database không còn nằm trên đường đi của request, nên ghi rất nhanh. Đổi lại, Redis tạm thời trở thành nơi giữ dữ liệu gốc.

@Service
public class ProductWriteBehindService {

    private static final String DIRTY_SET = "products:dirty";
    private static final Logger log = LoggerFactory.getLogger(ProductWriteBehindService.class);

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;
    private final StringRedisTemplate stringRedis;

    public ProductWriteBehindService(ProductRepository repository,
                                     RedisTemplate<String, Product> redis,
                                     StringRedisTemplate stringRedis) {
        this.repository = repository;
        this.redis = redis;
        this.stringRedis = stringRedis;
    }

    public void updatePrice(Long id, BigDecimal newPrice) {
        String key = ProductCacheAsideService.key(id);
        Product product = Optional.ofNullable(redis.opsForValue().get(key))
                .or(() -> repository.findById(id))
                .orElseThrow();
        product.setPrice(newPrice);
        redis.opsForValue().set(key, product);              // không TTL
        stringRedis.opsForSet().add(DIRTY_SET, id.toString());
    }

    @Scheduled(fixedDelay = 5000)
    public void flush() {
        String id;
        while ((id = stringRedis.opsForSet().pop(DIRTY_SET)) != null) {
            Product product = redis.opsForValue().get(ProductCacheAsideService.key(Long.valueOf(id)));
            if (product == null) {
                continue;
            }
            try {
                repository.save(product);
            } catch (RuntimeException ex) {
                log.warn("Flush thất bại cho product {}, thử lại sau", id, ex);
                stringRedis.opsForSet().add(DIRTY_SET, id);
                break;
            }
        }
    }
}

Luồng xử lý như sau:

  • updatePrice cập nhật bản ghi trong Redis và thêm id vào set products:dirty. Request kết thúc trong vài mili giây.
  • Cứ 5 giây, flush() lấy từng id ra bằng SPOP và ghi bản ghi tương ứng xuống database.
  • Nếu một sản phẩm bị cập nhật 100 lần trong 5 giây, database chỉ nhận một lần ghi. Đây là lợi ích lớn nhất của write-behind.
  • Khi ghi lỗi, id được trả lại set để thử lại ở lượt sau.

Đây là mô hình rủi ro nhất trong ba chiến lược. Một số điểm bắt buộc phải xử lý:

  • Mất dữ liệu khi Redis sập. Bạn cần bật AOF với appendfsync everysec hoặc always, và chạy Redis có replica. Dù vậy, vẫn có thể mất khoảng 1 giây dữ liệu.
  • Eviction. Key dirty không có TTL. Hãy đặt maxmemory-policy là noeviction hoặc volatile-*, nếu không Redis có thể xóa dữ liệu chưa kịp flush.
  • Nhiều instance. Nhờ SPOP là atomic, hai instance không lấy trùng một id. Tuy vậy, nếu instance chết ngay sau khi SPOP và trước khi save, thay đổi đó bị kẹt trong Redis cho tới lần cập nhật kế tiếp. Muốn chắc chắn hơn, hãy dùng Redis Streams với consumer group và XACK.
  • Query trên database thấy dữ liệu cũ. Báo cáo, job khác hoặc service khác đọc trực tiếp database sẽ chậm vài giây so với thực tế.

Lưu ý: Không dùng write-behind cho số dư tài khoản, đơn hàng Order, thanh toán qua PaymentService, hay bất kỳ dữ liệu nào mà mất một bản ghi là sự cố. Nó hợp với lượt xem, lượt thích, vị trí đọc, last-seen: những dữ liệu ghi rất nhiều và chấp nhận mất một chút.

6. Test hành vi cache-aside

Test dưới đây dùng Redis thật chạy trong Testcontainers. @ServiceConnection tự cấu hình host và port cho Spring Boot:

@SpringBootTest
@Testcontainers
class ProductCacheAsideServiceTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redisContainer =
            new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Autowired
    ProductCacheAsideService service;

    @Autowired
    ProductRepository repository;

    @Autowired
    RedisTemplate<String, Product> redis;

    @Test
    void shouldCacheOnReadAndEvictAfterUpdate() {
        Product saved = repository.save(new Product("Bàn phím cơ", new BigDecimal("1200000")));
        String key = ProductCacheAsideService.key(saved.getId());

        service.findById(saved.getId());
        assertThat(redis.hasKey(key)).isTrue();

        service.updatePrice(saved.getId(), new BigDecimal("990000"));
        assertThat(redis.hasKey(key)).isFalse();

        Product reloaded = service.findById(saved.getId()).orElseThrow();
        assertThat(reloaded.getPrice()).isEqualByComparingTo("990000");
        assertThat(redis.hasKey(key)).isTrue();
    }
}

Test kiểm tra đủ vòng đời của key: được tạo khi đọc, bị xóa sau khi cập nhật, rồi được nạp lại với giá mới. Class test không gắn @Transactional. Nếu gắn, transaction của test sẽ bị rollback, callback afterCommit không bao giờ chạy, và assertion thứ hai sẽ fail.

7. So sánh và cách chọn

Tiêu chí Cache-aside Write-through Write-behind
Độ trễ đọc lần đầu Cao (miss) Thấp Thấp
Độ trễ ghi Thấp Trung bình (ghi 2 nơi) Rất thấp
Nhất quán với DB Tốt (có TTL làm lưới an toàn) Tốt Eventual, trễ vài giây
Rủi ro mất dữ liệu Không Không Có
Độ phức tạp Thấp Trung bình Cao
Giảm tải ghi cho DB Không Không Có (gộp lô)

Một số gợi ý khi chọn:

  • Mặc định hãy dùng cache-aside. Nó đơn giản, chịu lỗi tốt (Redis chết thì vẫn đọc được database), và đủ cho đa số dữ liệu đọc nhiều ghi ít như danh mục sản phẩm hay cấu hình.
  • Chọn write-through khi dữ liệu thường được đọc ngay sau khi ghi và bạn muốn tránh miss đầu tiên. Thực tế, nhiều hệ thống kết hợp: ghi kiểu write-through, đọc kiểu cache-aside.
  • Chỉ chọn write-behind khi database thực sự là nút thắt ghi và dữ liệu chấp nhận mất mát. Nếu không có con số đo đạc chứng minh database bị nghẽn, đừng trả cái giá phức tạp này.
  • Bạn không cần chọn một chiến lược cho cả hệ thống. Mỗi loại dữ liệu có thể dùng một chiến lược khác nhau.

Một số lỗi hay gặp với cả ba chiến lược:

  • Cache stampede. Một key nóng hết hạn, hàng nghìn request cùng miss và đổ dồn vào database. Có thể khắc phục bằng TTL có jitter ngẫu nhiên, hoặc lock bằng SET key value NX PX để chỉ một request nạp lại.
  • Cache penetration. Request liên tục hỏi một id không tồn tại, nên lần nào cũng miss. Hãy cache cả kết quả rỗng với TTL ngắn.
  • Đổi cấu trúc class mà quên dọn cache. JSON cũ không deserialize được sang class mới. Hãy thêm version vào key, ví dụ product:v2:{id}.

8. Kết luận

Bài viết đã cài đặt ba chiến lược cache với Redis và Spring Boot 3. Cache-aside nạp dữ liệu khi cần và xóa key sau khi commit. Write-through đồng bộ cả hai nơi khi ghi. Write-behind đánh đổi độ bền dữ liệu để có tốc độ ghi cao.

Quy tắc ngắn gọn: bắt đầu với cache-aside và luôn đặt TTL. Nâng lên write-through khi cần cache ấm. Chỉ dùng write-behind cho dữ liệu chấp nhận mất mát, kèm AOF, chính sách eviction phù hợp và cơ chế thử lại rõ ràng.

rediscachespring-bootjavahiệu năng