Redis:选择cache-aside、write-through还是write-behind
1. 概述
在数据库前添加Redis是降低系统延迟和负载最快的方法。难点在于其他地方:你需要决定如何写入数据,以保持缓存和数据库的一致性。有三种常见的策略:cache-aside、write-through和write-behind。
本文用Spring Boot 3和Redis实现这三种策略。接着按一致性、性能和数据丢失风险来比较它们。读完后,你就知道针对不同类型的数据应该选择哪种策略。
2. 准备
2.1. 依赖
我们使用Spring Data Redis、Spring Data JPA(H2作为示例数据库)和Testcontainers来进行测试:
<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>
本地运行时配置Redis连接:
spring.data.redis.host=localhost
spring.data.redis.port=6379
2.2. Entity、repository和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和setters
}
public interface ProductRepository extends JpaRepository<Product, Long> {
}
Spring Boot已经内置了StringRedisTemplate。为了将Product以JSON格式存储,我们需要声明一个单独的RedisTemplate:
@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的形式是product:{id},value是Product的JSON。注解@EnableScheduling将在write-behind部分使用。
所有三种策略都需要在事务commit后运行一段代码。我们将这部分提取到一个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();
}
});
}
}
如果在事务中,action只在commit成功时运行。如果事务rollback,缓存不会受影响。
3. Cache-aside(惰性加载)
在cache-aside模式中,应用程序自己管理缓存:
- 读:先在Redis中查找。如果miss,则从数据库读取,然后将结果写入Redis并设置TTL。
- 写:更新数据库,然后删除Redis中的key。
@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;
}
}
上述代码的行为如下:
- 第一次调用
findById(1L)从数据库读取并创建keyproduct:1,TTL为10分钟。 - 在随后的10分钟内,调用会从Redis返回,不会访问数据库。
updatePrice修改entity,JPA的脏检查在commit时将其写入数据库,然后key被删除。下次读取会重新加载新的值。
用redis-cli检查:
redis-cli GET product:1
redis-cli TTL product:1
"{\"id\":1,\"name\":\"机械键盘\",\"price\":1200000.00}"
(integer) 587
注意:写入时应该删除key而不是
set新值。假设两个请求同时更新。如果都执行set,写入Redis的顺序可能与commit到数据库的顺序相反,导致缓存永久保持旧值。删除key的话,下次读取总会获取最新的值。此外,必须在commit后删除。如果提前删除,一个读请求可能在删除后立即从数据库重新加载旧数据到缓存中。
即使操作正确,cache-aside仍有一个小的竞态窗口:一个请求从数据库读取旧数据,然后在key被删除之后将其写入缓存。TTL是这种情况下最后的安全网。永远不要缓存而不设置TTL。
对于简单的数据,你可以使用Spring Cache的@Cacheable和@CacheEvict注解以用更少的代码达到相同效果。本质上它们仍然是cache-aside。
4. Write-through
在write-through模式中,每次写入都会同步更新数据库和缓存。只有当两者都完成时,请求才会返回。读取部分与cache-aside相同,但hit率更高,因为刚写入的数据已经在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;
}
}
saveAndFlush行立即将数据写入数据库以获得id并尽早检测约束错误。commit后,记录被推送到Redis。当刚创建的产品再次被读取时,第一次就是缓存命中。
write-through的优点:
- 缓存始终是“热的”,存储刚写入的数据,没有首次缓存未命中。
- 适合那些经常在写入后立即被读取的数据,例如
Customer档案或购物车。
缺点:
- 每次写入都需要额外的Redis往返,因此写入延迟增加。
- 缓存会被那些没有人读取的记录填满。TTL有助于清理,但仍然消耗内存。
- 当多个写入器同时
set时,仍然会出现竞态,如第3部分所述。对于高并发更新的数据,你需要添加版本控制,例如使用@Version只在版本更新时覆盖,或回到删除key的方法。
注意:如果Redis在数据库commit后出错,缓存将缺少最新版本。请捕获异常并删除key作为备用方案。不要让请求在数据库已成功写入时失败。
5. Write-behind(write-back)
在write-behind模式中,应用程序只写入Redis然后立即返回。后台进程收集变更并以批量方式写入数据库。数据库不再在请求路径上,因此写入非常快。作为交换,Redis暂时成为原始数据的存储库。
@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); // 没有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失败,产品{},稍后重试", id, ex);
stringRedis.opsForSet().add(DIRTY_SET, id);
break;
}
}
}
}
处理流程如下:
updatePrice在Redis中更新记录并将id添加到products:dirty集合。请求在几毫秒内完成。- 每5秒,
flush()使用SPOP弹出每个id并将相应的记录写入数据库。 - 如果一个产品在5秒内被更新100次,数据库只收到一次写入。这是write-behind的最大优势。
- 当写入失败时,
id被放回集合以便下次重试。
这是三种策略中风险最高的模型。需要处理的一些必要点:
- Redis崩溃时数据丢失。你需要启用AOF,设置
appendfsync everysec或always,并运行Redis副本。即便如此,仍可能丢失约1秒的数据。 - 驱逐。Dirty key没有TTL。将
maxmemory-policy设置为noeviction或volatile-*,否则Redis可能会删除还未flush的数据。 - 多个实例。由于
SPOP是原子的,两个实例不会取出相同的id。但是,如果实例在SPOP之后、save之前崩溃,更改将被卡在Redis中,直到下一次更新。为了更加确定,使用Redis Streams和consumer group以及XACK。 - 对数据库的查询看到旧数据。报告、其他job或直接读取数据库的其他服务将比实际情况延迟几秒钟。
注意:不要对账户余额、
Order订单、PaymentService支付或任何丢失一条记录都是事故的数据使用write-behind。它适合于浏览次数、点赞、阅读位置、last-seen等:这些是写入频繁且可以接受丢失一些数据的信息。
6. 测试cache-aside行为
下面的测试使用在Testcontainers中运行的真实Redis。@ServiceConnection会自动为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("机械键盘", 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检查了key的完整生命周期:在读取时创建,在更新后删除,然后用新值重新加载。测试类没有标记@Transactional。如果标记了,测试的事务会回滚,afterCommit回调永远不会运行,第二个断言会失败。
7. 对比和选择
| 标准 | Cache-aside | Write-through | Write-behind |
|---|---|---|---|
| 首次读延迟 | 高(miss) | 低 | 低 |
| 写延迟 | 低 | 中等(写2处) | 非常低 |
| 与DB一致性 | 好(TTL作为安全网) | 好 | 最终一致,延迟数秒 |
| 数据丢失风险 | 无 | 无 | 有 |
| 复杂性 | 低 | 中等 | 高 |
| 减轻DB写入负载 | 否 | 否 | 是(批量合并) |
选择建议:
- 默认使用cache-aside。它很简单,容错性好(Redis崩溃时仍可读数据库),对于大多数读多写少的数据(如产品目录或配置)都足够。
- 选择write-through,当数据经常在写入后立即被读取,且你想避免首次miss。实际上,许多系统结合两者:写入用write-through,读取用cache-aside。
- 仅选择write-behind,当数据库真正是写入瓶颈且数据可以接受丢失时。如果没有指标证明数据库被堵住,不要为这种复杂性买单。
- 你不需要为整个系统选择一种策略。每种数据类型可以使用不同的策略。
三种策略都常见的一些错误:
- 缓存崩溃。一个热key过期,数千个请求同时miss并涌向数据库。可以通过随机jitter的TTL或使用
SET key value NX PX锁定来修复,只允许一个请求重新加载。 - 缓存穿透。请求不断查询一个不存在的
id,每次都会miss。缓存空结果,并设置短TTL。 - 更改class结构但忘记清理缓存。旧JSON无法反序列化为新class。在key中添加版本,例如
product:v2:{id}。
8. 总结
本文用Redis和Spring Boot 3实现了三种缓存策略。Cache-aside在需要时加载数据并在commit后删除key。Write-through在写入时同步两处。Write-behind用数据持久性交换高写入速度。
简单规则:从cache-aside开始并始终设置TTL。当需要热缓存时升级到write-through。仅对可接受丢失的数据使用write-behind,配合AOF、适当的驱逐策略和明确的重试机制。