保持Redis缓存与数据库一致性

1. 概述

在数据库前放置Redis有助于降低数据库负载并加速读取。代价是你在两个地方保存了同一份数据,它们可能不一致。用户改变了商品价格,但详情页在10分钟内仍然显示旧价格。这是典型的一致性错误。

本文使用cache-aside模式:读取时先从缓存获取,写入时更新数据库然后在transaction commit后删除缓存。我们将分析仍然存在的竞态条件以及如何减少它们。文末附有一个使用Testcontainers进行真实Redis集成测试的例子。

2. 缓存与数据库为什么会不一致

数据库和Redis不共享同一个transaction。没有办法以原子方式同时写入两者。操作的顺序和时机决定了数据是否会不一致。

三个最常见的错误:

  • 更新缓存而不是删除。 两个并发写入请求可能按A→B的顺序写入数据库,但按B→A的顺序写入缓存。缓存将永久保持A的值,或直到TTL过期。
  • 在commit前删除缓存。 一个读取请求夹在删除缓存和commit之间。该请求从数据库读取旧值,然后重新加载到缓存中。
  • 缓存删除失败但无人知晓。 Redis超时、异常被吞掉,缓存保留旧数据。

本文接下来依次处理这些错误。

3. 项目设置

添加Redis、JPA及测试数据库的依赖:

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

配置Redis连接:

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

短超时是有意设置的。如果Redis很慢,你希望请求快速降级到数据库,而不是挂起几秒钟。

实体和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> {}

我们不直接缓存entity,而是缓存一个不可变的record:

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

这种方式避免了Hibernate代理序列化的问题。它还使缓存中的格式独立于表结构。

4. 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); }
    }
}

处理流程如下:

  • redis.opsForValue().get(...) 尝试从缓存获取。如果存在则立即返回。这称为cache hit。
  • 如果不存在(cache miss),service从数据库读取并以10分钟TTL写入Redis。
  • TTL是安全网。 后续的所有机制都可能失败。TTL保证错误数据不会存在超过10分钟。

首次调用id = 1时,Redis将包含:

127.0.0.1:6379> GET product:1
"{\"id\":1,\"name\":\"机械键盘\",\"price\":1200000}"
127.0.0.1:6379> TTL product:1
(integer) 598

5. 写入:更新数据库,然后在commit后删除缓存

5.1. 删除而不是更新

当价格更改时,我们删除key而不是写入新价格到缓存。下次读取将从数据库重新加载数据,数据库是真实来源。删除是幂等操作:删除两次仍产生相同结果。删除命令之间的顺序也不重要。因此第2部分的“写入顺序错位”错误消失了。

5.2. 等待transaction commit

如果在@Transactional方法内调用redis.delete(...),删除命令在commit之前执行。这是可能发生的场景:

T1 (写入)                         T2 (读取)
UPDATE price = 900 (还未提交)
DEL product:1
                                 GET product:1 -> 未命中
                                 SELECT -> price = 1200 (旧值)
                                 SET product:1 = 1200
COMMIT
=> 缓存保持1200直到TTL过期

解决方案是发布一个事件并在AFTER_COMMIT阶段处理它:

public record ProductChangedEvent(Long productId) {}

向ProductService添加写入方法:

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

Listener负责删除缓存:

@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("无法删除缓存{},将依赖TTL", key, e);
        }
    }
}

需要注意几点:

  • @TransactionalEventListener(phase = AFTER_COMMIT) 仅在transaction成功commit时运行。如果transaction回滚,缓存不会被删除。这是正确的,因为数据没有改变。
  • 删除缓存时的错误不会破坏写入请求。数据库已经commit,此时抛出异常只会让客户端认为操作失败。
  • 警告日志帮助你跟踪错误频率。同时,TTL限制旧数据存在的时间。

注意: 如果publishEvent在没有transaction的地方调用,@TransactionalEventListener默认会忽略事件,缓存永远不会被删除。确保写入方法有@Transactional。如果需要,设置fallbackExecution = true。

6. 仍然存在的竞态条件与延迟双删

即使在commit后删除,仍然存在一个罕见的场景:

T2 (读取)                          T1 (写入)
GET product:1 -> 未命中
SELECT -> price = 1200
                                  UPDATE price = 900, COMMIT
                                  DEL product:1
SET product:1 = 1200   <- 迟到的写入

T2在T1 commit之前读取旧版本,但在T1删除后写入缓存。这个场景需要T2在恰当的时刻被延迟,例如由于GC暂停或网络抖动。它罕见但真实存在。

一个简单的缓解方法是延迟双删:在短时间后再删除一次。

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

当你在配置类上启用@EnableScheduling时,Spring Boot会自动配置TaskScheduler。500ms后的第二次删除将清除T2迟到写入的旧值。选择延迟大于你典型数据库读取时间的值。

注意: 延迟双删只是缩小了错误窗口,并不能完全消除。调度在内存中,应用重启时会丢失。不要把它当作绝对保证。

7. 当缓存删除必须绝对成功时

对于余额或库存等数据,“依赖TTL”可能是不可接受的。此时有两种常见方法:

  • Transactional outbox: 在同一transaction中,将cache_invalidation行写入数据库以及业务变更。一个定期任务读取该表,删除Redis中的key,仅在删除成功后标记行为完成。由于outbox行与数据一起commit,缓存删除事件永远不会丢失。
  • Change Data Capture (CDC): 一个进程读取数据库的binlog/WAL并删除相应的缓存。这种方法将缓存逻辑从业务代码中完全分离,但代价是基础设施更复杂。

两者都是最终一致性。它们保证缓存最终会一致,但不保证立即一致。

8. 使用真实Redis进行测试

Mock StringRedisTemplate无法检测到与commit时机相关的错误。通过Testcontainers使用真实Redis进行测试:

<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("机械键盘", new BigDecimal("1200000"))).getId();

        productService.findById(id); // 加载缓存
        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");
    }
}

这个测试检查三件事:

  • @ServiceConnection(name = "redis") 自动将spring.data.redis.*指向容器,无需额外配置。
  • 在updatePrice之后,key不再在Redis中。这证明AFTER_COMMIT listener已运行。
  • 下次读取从数据库返回新价格。

注意: 不要将测试方法标记为@Transactional。Spring会在测试结束时回滚transaction,因此AFTER_COMMIT永远不会被触发,断言会失败。

运行结果:

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

9. 何时不应该使用这种方法

  • 需要强一致性的数据,如用于决定是否允许交易的账户余额。不要从缓存读取它们。应该直接从数据库读取,使用适当的锁。
  • 写入多于读取的数据。 缓存不断被删除,所以hit rate很低。你付出了复杂性的代价但获益甚微。
  • 由多个实体组成的key,例如“top 10产品”列表。当一个产品改变时,很难知道应该删除哪些key。对于这种key,短TTL通常比精确失效更简单、更安全。
  • 多个服务写入同一表。 你的应用中的事件看不到来自其他服务的变更。此时CDC更合适。

10. 结论

保持Redis和数据库一致不是让它们始终完全同步。目标是使不一致的窗口小且有界。基本公式包括四步:

  1. 使用cache-aside读取,总是设置TTL。
  2. 写入数据库,然后删除key,不要更新key。
  3. 仅在commit后删除,使用@TransactionalEventListener(phase = AFTER_COMMIT)。
  4. 根据数据的敏感性添加延迟双删、outbox或CDC。

最后,使用真实Redis进行测试,不要让测试上的@Transactional隐藏commit后的行为。

rediscachespring-bootconsistencyjava