保持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_COMMITlistener已运行。 - 下次读取从数据库返回新价格。
注意: 不要将测试方法标记为
@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和数据库一致不是让它们始终完全同步。目标是使不一致的窗口小且有界。基本公式包括四步:
- 使用cache-aside读取,总是设置TTL。
- 写入数据库,然后删除key,不要更新key。
- 仅在commit后删除,使用
@TransactionalEventListener(phase = AFTER_COMMIT)。 - 根据数据的敏感性添加延迟双删、outbox或CDC。
最后,使用真实Redis进行测试,不要让测试上的@Transactional隐藏commit后的行为。