9.3 缓存一致性

缓存与数据库一致性策略:cache-aside 竞态窗口与延时双删方案、write-through 同步写与 write-behind 异步刷的适用场景、Redis Pub/Sub 分布式失效广播、最终一致性与数据库 CAS 强一致性选型、响应式链事务性缓存更新。

Cache-Aside 的竞态窗口

最常见的缓存模式有固有的不一致窗口:

// 线程 A:读取
Order order = cache.get(id);      // null(未命中)
order = db.query(id);             // 查到 v1
cache.set(id, v1);               // 写入缓存

// 线程 B:同时更新
db.update(id, v2);                // 更新数据库为 v2
cache.delete(id);                 // 删除缓存(或更新)

如果执行顺序是 A 查 DB → B 更新 DB → B 删缓存 → A 写缓存,缓存中的 v1 覆盖了数据库的 v2,出现不一致。虽然概率低,但高并发场景下必然发生。

解决方案:延时双删

public Mono<Void> updateOrder(Order order) {
    String key = "order:" + order.id();
    return redisCache.delete(key)           // 1. 先删缓存
        .then(repo.save(order))             // 2. 更新数据库
        .delayElement(Duration.ofMillis(500))  // 3. 等待 500ms(主从同步)
        .flatMap(saved -> redisCache.delete(key))  // 4. 再删一次
        .then();
}

两次删除之间的窗口是为了覆盖「旧值已被并发读回缓存」和主从复制延迟。它只能缩小不一致窗口,不能消除:延迟时长是经验值,主从延迟超过 500ms 时依然会漏;delayElement 还会把写请求的响应时间拉长 500ms。实践中更常见的是写后删缓存 + 短 TTL 兜底,或者对强一致读强制走主库,而不是依赖固定延时。

Write-Through 的一致性

Write-Through 同时更新缓存和数据库:

public Mono<Order> updateOrderWriteThrough(Order order) {
    String key = "order:" + order.id();
    return repo.save(order)
        .flatMap(saved -> {
            // 同步写缓存
            return redisCache.set(key, serialize(saved), Duration.ofMinutes(5))
                .doOnSuccess(v -> localCache.synchronous().invalidate(saved.id()))
                .thenReturn(saved);
        });
}

优势是读路径几乎总能命中;劣势是写操作要等 Redis 响应,写延迟上升,而且 Redis 故障时即便数据库已写成功,缓存仍然缺失。适合读多写少、且能接受写延迟的场景。

事件驱动优化:把 Redis 写入交给消息队列异步处理,不阻塞主流程。

Write-Behind 的一致性风险

Write-Behind(只写缓存,异步刷数据库)提供最低延迟,但一致性最弱:

写入 → 更新缓存 → 返回成功(异步刷 DB)
                          ↓
                    缓存写入队列
                          ↓
                    批量写入数据库

风险:

  • 缓存故障导致数据丢失
  • 数据库是 stale 的(落后于缓存)
  • 故障恢复时需要从缓存反写数据库

应用场景:

  • 实时计数器(如点赞数,丢失几条可接受)
  • 日志写入(可丢失,可重试)
  • 非关键状态更新
    eventd v2 的限流计数器使用 Write-Behind:Redis 原子 INCR,定时刷到数据库,丢几条不影响限流精度。

分布式失效传播

多节点部署时,一个节点更新数据,需要通知其他节点清除本地缓存:

@Service
public class CacheInvalidationService {
    private final ReactiveRedisMessageListenerContainer container;
    private final AsyncCache<Long, User> localCache;

    @EventListener(ApplicationReadyEvent.class)
    public void listenInvalidation() {
        container.receive(new PatternTopic("cache:invalidate:*"))
            .map(msg -> msg.getMessage())
            .flatMap(key -> {
                Long id = Long.parseLong(key.replace("cache:invalidate:user:", ""));
                localCache.synchronous().invalidate(id);
                return Mono.empty();
            })
            .subscribe();
    }

    public Mono<Void> invalidateUserCache(Long id) {
        // 广播失效消息
        return redis.convertAndSend("cache:invalidate:user:" + id, "del")
            .then();
    }
}

使用 Redis Pub/Sub 广播失效消息,所有节点订阅并清除本地 Caffeine。适合更新频率低、节点数少的场景:Pub/Sub 是「发后即忘」,不保证送达,订阅者掉线期间的消息会丢失,所以本地缓存必须另有 TTL 兜底。节点数增加后广播成本线性上升,但「超过 10 个节点就不行」没有依据,要按消息频率实测;量级更大时可以缩短本地 TTL 或改用持久化的消息通道。

最终一致性 vs 强一致性

场景推荐策略容忍延迟
用户信息Cache-Aside + 延时双删秒级
订单状态数据库 CAS + 删除缓存无(状态变更需立即可见)
库存数据库 CAS + 缓存仅加速读无(超卖不可接受)
实时计数Write-Behind分钟级
全局配置Write-Through + Redis 监听秒级

订单状态:为什么不能用纯缓存

// 危险:先读缓存判断状态
public Mono<Void> payOrder(Long orderId) {
    return cache.get("order:" + orderId)
        .flatMap(order -> {
            if ("PAID".equals(order.status())) {
                return Mono.error(new IllegalStateException("Already paid"));
            }
            // 缓存状态可能是 stale 的!
            return paymentService.charge(order);
        });
}

正确做法:状态校验走数据库(乐观锁/唯一约束),缓存只做结果加速。

public Mono<Void> payOrder(Long orderId) {
    return db.sql("UPDATE orders SET status = 'PAID' WHERE id = :id AND status = 'PENDING'")
        .bind("id", orderId)
        .fetch().rowsUpdated()
        .flatMap(updated -> updated == 1
            ? paymentService.charge(orderId)
            : Mono.error(new IllegalStateException("Order not eligible")))
        .then(cache.delete("order:" + orderId))
        .then();
}

数据库的 WHERE status = 'PENDING' 是原子化条件更新,天然防止并发竞态。但这段代码把 charge() 放在状态更新之后:如果扣款失败,订单已经是 PAID,却没有实际支付记录。生产实现要么把支付做成幂等的可补偿步骤(失败后把状态改回或标记为异常),要么采用「先支付、后置终态」的流程,不能只靠一条 UPDATE 保证业务正确。

响应式链中的事务性缓存更新

缓存与数据库不是同一个事务资源。如果数据库提交成功但缓存更新失败,就会出现不一致。

正确做法:缓存更新作为数据库事务的 after-commit 回调。响应式事务同步机制不是 ThreadLocal 的,要注册到当前事务的 TransactionSynchronization:

import org.springframework.transaction.reactive.TransactionSynchronization;
import org.springframework.transaction.reactive.TransactionSynchronizationManager;

public Mono<Order> createOrderTransactional(Order order) {
    return txOp.transactional(
        repo.save(order)
            .flatMap(saved ->
                TransactionSynchronizationManager.forCurrentTransaction()
                    .flatMap(tx -> {
                        tx.registerSynchronization(new TransactionSynchronization() {
                            @Override
                            public Mono<Void> afterCommit() {
                                return redisCache
                                    .set("order:" + saved.id(), serialize(saved), ttl)
                                    .doOnSuccess(v -> localCache.synchronous()
                                        .invalidate(saved.id()))
                                    .then();
                            }
                        });
                        return Mono.just(saved);
                    })
            )
    );
}

afterCommit() 在事务提交之后执行,缓存失败不会回滚数据库——这仍然是一致性缺口,只是把「数据库回滚但缓存已更新」换成了「数据库已提交但缓存可能没更新」,后者可以由 TTL 和重试收敛。不要写成 txOp.transactional(...) 的静态调用:transactional 是实例方法,而且嵌套调用只是再开一个事务边界,不是 after-commit。

更简单的做法:接受最终一致性,通过短 TTL 自然收敛。eventd v2 的缓存 TTL 一般 ≤ 5 分钟,不一致最多持续 5 分钟,对多数业务可接受。

eventd v2 一致性策略总结

用户数据:
    Cache-Aside + 延时双删 + 5 分钟 TTL
    允许秒级不一致

订单数据:
    状态变更走数据库 CAS
    缓存仅缓存只读视图,更新后立即删除
    强一致性

库存:
    数据库扣减 + 唯一约束
    缓存预热,但不用于库存判断
    强一致性

实时指标:
    Write-Behind + Redis Stream
    允许分钟级延迟

小结

策略写操作一致性适用
Cache-AsideDB → 删缓存最终一致通用,读多写少
Cache-Aside + 延时双删删 → DB → 延时删最终一致(窗口更小)高并发更新
Write-ThroughDB + 缓存同步写较高读极多,写少
Write-Behind缓存 → 异步 DB弱可丢失数据
数据库 CASDB 条件更新 + 删缓存强一致状态变更

第 9 章结束,第三部分(R2DBC 与数据访问)完成。下一部分进入系统整合:Micrometer 指标与响应式监控。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南