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)从数据库读取并创建key product: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、适当的驱逐策略和明确的重试机制。

rediscachespring-bootjava性能