Giữ cache và database nhất quán khi dùng Redis

1. Tổng quan

Đặt Redis trước database giúp giảm tải và tăng tốc đọc. Đổi lại, bạn có hai nơi cùng lưu một dữ liệu, và chúng có thể lệch nhau. Người dùng đổi giá sản phẩm, nhưng trang chi tiết vẫn hiện giá cũ trong 10 phút. Đó là lỗi nhất quán điển hình.

Bài này dùng mẫu cache-aside: đọc thì lấy từ cache trước, ghi thì cập nhật database rồi xóa cache sau khi transaction commit. Ta sẽ xem các race condition còn sót lại và cách thu hẹp chúng. Cuối bài có một test tích hợp với Redis thật chạy bằng Testcontainers.

2. Vì sao cache và database lệch nhau

Database và Redis không chung một transaction. Không có cách nào ghi vào cả hai một cách nguyên tử. Thứ tự thao tác và thời điểm thao tác quyết định dữ liệu có bị lệch hay không.

Ba lỗi hay gặp nhất:

  • Cập nhật cache thay vì xóa. Hai request ghi đồng thời có thể ghi vào database theo thứ tự A→B, nhưng ghi vào cache theo thứ tự B→A. Cache giữ giá trị của A mãi mãi, hoặc tới khi hết TTL.
  • Xóa cache trước khi commit. Một request đọc chen vào giữa lúc xóa cache và lúc commit. Request đó đọc giá trị cũ từ database rồi nạp lại vào cache.
  • Xóa cache thất bại mà không ai biết. Redis timeout, exception bị nuốt, và cache giữ dữ liệu cũ.

Phần còn lại của bài xử lý lần lượt từng lỗi.

3. Thiết lập dự án

Thêm các dependency cho Redis, JPA và một database để chạy thử:

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

Cấu hình kết nối Redis:

spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=500ms

Timeout ngắn là chủ ý. Nếu Redis chậm, bạn muốn request rơi về database nhanh, không muốn nó treo vài giây.

Entity và repository:

@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; }

    public Long getId() { return id; }
    public String getName() { return name; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
}

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

Ta không cache trực tiếp entity mà cache một record bất biến:

public record ProductView(Long id, String name, BigDecimal price) {
    static ProductView from(Product p) {
        return new ProductView(p.getId(), p.getName(), p.getPrice());
    }
}

Cách này tránh lỗi serialize proxy Hibernate. Nó cũng giữ định dạng trong cache độc lập với cấu trúc bảng.

4. Đọc theo cache-aside

@Service
public class ProductService {

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

    private final ProductRepository repository;
    private final StringRedisTemplate redis;
    private final ObjectMapper mapper;
    private final ApplicationEventPublisher events;

    public ProductService(ProductRepository repository, StringRedisTemplate redis,
                          ObjectMapper mapper, ApplicationEventPublisher events) {
        this.repository = repository;
        this.redis = redis;
        this.mapper = mapper;
        this.events = events;
    }

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

    public ProductView findById(Long id) {
        String cached = redis.opsForValue().get(key(id));
        if (cached != null) {
            return read(cached);
        }
        ProductView view = repository.findById(id)
                .map(ProductView::from)
                .orElseThrow(() -> new NoSuchElementException("Product " + id));
        redis.opsForValue().set(key(id), write(view), TTL);
        return view;
    }

    private ProductView read(String json) {
        try { return mapper.readValue(json, ProductView.class); }
        catch (JsonProcessingException e) { throw new IllegalStateException(e); }
    }

    private String write(ProductView view) {
        try { return mapper.writeValueAsString(view); }
        catch (JsonProcessingException e) { throw new IllegalStateException(e); }
    }
}

Luồng xử lý như sau:

  • redis.opsForValue().get(...) thử lấy từ cache. Nếu có thì trả về ngay. Đây là cache hit.
  • Nếu không có (cache miss), service đọc từ database và ghi vào Redis với TTL 10 phút.
  • TTL là lưới an toàn. Mọi cơ chế ở các phần sau đều có thể thất bại. TTL bảo đảm dữ liệu sai không tồn tại quá 10 phút.

Lần gọi đầu tiên với id = 1, Redis sẽ chứa:

127.0.0.1:6379> GET product:1
"{\"id\":1,\"name\":\"Ban phim co\",\"price\":1200000}"
127.0.0.1:6379> TTL product:1
(integer) 598

5. Ghi: cập nhật database, rồi xóa cache sau commit

5.1. Xóa thay vì cập nhật

Khi giá thay đổi, ta xóa key thay vì ghi giá mới vào cache. Lần đọc tiếp theo sẽ nạp lại dữ liệu từ database, và database là nguồn sự thật. Xóa là thao tác idempotent: xóa hai lần vẫn cho cùng một kết quả. Thứ tự giữa các lệnh xóa cũng không quan trọng. Nhờ vậy lỗi “ghi đè lệch thứ tự” ở phần 2 biến mất.

5.2. Chờ transaction commit

Nếu bạn gọi redis.delete(...) bên trong method @Transactional, lệnh xóa chạy trước khi commit. Đây là kịch bản có thể xảy ra:

T1 (ghi)                         T2 (đọc)
UPDATE price = 900 (chưa commit)
DEL product:1
                                 GET product:1 -> miss
                                 SELECT -> price = 1200 (bản cũ)
                                 SET product:1 = 1200
COMMIT
=> cache giữ 1200 tới khi hết TTL

Giải pháp là phát một event và xử lý nó ở pha AFTER_COMMIT:

public record ProductChangedEvent(Long productId) {}

Thêm method ghi vào ProductService:

@Transactional
public void updatePrice(Long id, BigDecimal newPrice) {
    Product product = repository.findById(id).orElseThrow();
    product.setPrice(newPrice);
    events.publishEvent(new ProductChangedEvent(id));
}

Listener chịu trách nhiệm xóa cache:

@Component
public class ProductCacheEvictor {

    private static final Logger log = LoggerFactory.getLogger(ProductCacheEvictor.class);
    private final StringRedisTemplate redis;

    public ProductCacheEvictor(StringRedisTemplate redis) {
        this.redis = redis;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onProductChanged(ProductChangedEvent event) {
        evict(ProductService.key(event.productId()));
    }

    void evict(String key) {
        try {
            redis.delete(key);
        } catch (RuntimeException e) {
            log.warn("Không xóa được cache {}, sẽ dựa vào TTL", key, e);
        }
    }
}

Một vài điểm cần lưu ý:

  • @TransactionalEventListener(phase = AFTER_COMMIT) chỉ chạy khi transaction commit thành công. Nếu transaction rollback, cache không bị xóa. Điều đó đúng, vì dữ liệu không đổi.
  • Lỗi khi xóa cache không làm hỏng request ghi. Database đã commit rồi, ném exception lúc này chỉ khiến client tưởng thao tác thất bại.
  • Log cảnh báo giúp bạn theo dõi tần suất lỗi. Trong lúc đó, TTL giới hạn thời gian dữ liệu cũ tồn tại.

Lưu ý: Nếu publishEvent được gọi ở chỗ không có transaction, @TransactionalEventListener mặc định sẽ bỏ qua event và cache không bao giờ bị xóa. Hãy bảo đảm method ghi có @Transactional. Nếu cần, đặt fallbackExecution = true.

6. Race condition còn sót lại và delayed double delete

Ngay cả khi xóa sau commit, vẫn có một kịch bản hiếm gặp:

T2 (đọc)                          T1 (ghi)
GET product:1 -> miss
SELECT -> price = 1200
                                  UPDATE price = 900, COMMIT
                                  DEL product:1
SET product:1 = 1200   <- ghi muộn

T2 đọc bản cũ trước khi T1 commit, nhưng ghi vào cache sau khi T1 đã xóa. Kịch bản này cần T2 bị chậm đúng lúc, ví dụ do GC pause hoặc mạng chập chờn. Nó hiếm nhưng có thật.

Một cách giảm thiểu đơn giản là delayed double delete: xóa thêm một lần nữa sau một khoảng ngắn.

@Component
public class DelayedCacheEvictor {

    private final ProductCacheEvictor evictor;
    private final TaskScheduler scheduler;

    public DelayedCacheEvictor(ProductCacheEvictor evictor, TaskScheduler scheduler) {
        this.evictor = evictor;
        this.scheduler = scheduler;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void scheduleSecondDelete(ProductChangedEvent event) {
        String key = ProductService.key(event.productId());
        scheduler.schedule(() -> evictor.evict(key),
                Instant.now().plusMillis(500));
    }
}

Spring Boot tự cấu hình một TaskScheduler khi bạn bật @EnableScheduling trên một lớp cấu hình. Lần xóa thứ hai sau 500ms sẽ quét đi giá trị cũ mà T2 ghi muộn. Chọn độ trễ lớn hơn thời gian một lần đọc database thông thường của bạn.

Lưu ý: Delayed double delete chỉ thu hẹp cửa sổ lỗi, nó không loại bỏ hoàn toàn. Lịch hẹn nằm trong bộ nhớ, nên mất đi nếu ứng dụng restart. Đừng coi nó là bảo đảm tuyệt đối.

7. Khi việc xóa cache phải chắc chắn thành công

Với dữ liệu như số dư hay tồn kho, “dựa vào TTL” có thể không chấp nhận được. Khi đó có hai hướng phổ biến:

  • Transactional outbox: ghi một dòng cache_invalidation vào chính database, trong cùng transaction với thay đổi nghiệp vụ. Một job định kỳ đọc bảng này, xóa key trong Redis, và chỉ đánh dấu dòng là xong khi xóa thành công. Vì dòng outbox commit cùng dữ liệu, sự kiện xóa cache không bao giờ bị mất.
  • Change Data Capture (CDC): một tiến trình đọc binlog/WAL của database và xóa cache tương ứng. Cách này tách hẳn logic cache khỏi code nghiệp vụ, đổi lại hạ tầng phức tạp hơn.

Cả hai đều là eventual consistency. Chúng bảo đảm cache cuối cùng sẽ đúng, nhưng không bảo đảm đúng ngay lập tức.

8. Kiểm thử với Redis thật

Mock StringRedisTemplate không phát hiện được lỗi về thời điểm commit. Hãy test với Redis thật qua Testcontainers:

<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>
@SpringBootTest
@Testcontainers
class ProductServiceIT {

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

    @Autowired ProductService productService;
    @Autowired ProductRepository repository;
    @Autowired StringRedisTemplate redis;

    @Test
    void updatePrice_evictsCache_andNextReadReturnsNewPrice() {
        Long id = repository.save(new Product("Ban phim co", new BigDecimal("1200000"))).getId();

        productService.findById(id); // nạp cache
        assertThat(redis.hasKey(ProductService.key(id))).isTrue();

        productService.updatePrice(id, new BigDecimal("900000"));

        assertThat(redis.hasKey(ProductService.key(id))).isFalse();
        assertThat(productService.findById(id).price())
                .isEqualByComparingTo("900000");
    }
}

Test này kiểm tra ba điều:

  • @ServiceConnection(name = "redis") tự trỏ spring.data.redis.* tới container, nên bạn không cần cấu hình thêm.
  • Sau updatePrice, key không còn trong Redis. Điều này chứng minh listener AFTER_COMMIT đã chạy.
  • Lần đọc kế tiếp trả về giá mới từ database.

Lưu ý: Không đánh dấu method test này là @Transactional. Spring sẽ rollback transaction của test ở cuối, nên AFTER_COMMIT không bao giờ được kích hoạt và assertion sẽ thất bại.

Kết quả khi chạy:

[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

9. Khi nào không nên dùng cách này

  • Dữ liệu đòi hỏi nhất quán mạnh, như số dư tài khoản dùng để quyết định cho phép giao dịch. Đừng đọc chúng từ cache. Hãy đọc thẳng từ database, có khóa phù hợp.
  • Dữ liệu ghi nhiều hơn đọc. Cache liên tục bị xóa nên hit rate thấp. Bạn trả chi phí phức tạp mà gần như không được gì.
  • Key được nhiều thực thể ghép thành, ví dụ danh sách “top 10 sản phẩm”. Khó biết phải xóa key nào khi một sản phẩm thay đổi. Với loại key này, TTL ngắn thường đơn giản và an toàn hơn invalidation chính xác.
  • Nhiều service cùng ghi một bảng. Event trong ứng dụng của bạn không thấy được thay đổi từ service khác. Lúc này CDC hợp lý hơn.

10. Kết luận

Giữ Redis và database nhất quán không phải là làm cho chúng luôn đồng bộ tuyệt đối. Mục tiêu là làm cho cửa sổ lệch nhỏ và có giới hạn. Công thức cơ bản gồm bốn bước:

  1. Đọc theo cache-aside, luôn đặt TTL.
  2. Ghi vào database, rồi xóa key, không cập nhật key.
  3. Chỉ xóa sau khi commit, bằng @TransactionalEventListener(phase = AFTER_COMMIT).
  4. Thêm delayed double delete, outbox hoặc CDC tùy theo mức độ nhạy cảm của dữ liệu.

Cuối cùng, hãy test với Redis thật và đừng để @Transactional trên test che mất hành vi sau commit.

rediscachespring-bootconsistencyjava