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-Aside | DB → 删缓存 | 最终一致 | 通用,读多写少 |
| Cache-Aside + 延时双删 | 删 → DB → 延时删 | 最终一致(窗口更小) | 高并发更新 |
| Write-Through | DB + 缓存同步写 | 较高 | 读极多,写少 |
| Write-Behind | 缓存 → 异步 DB | 弱 | 可丢失数据 |
| 数据库 CAS | DB 条件更新 + 删缓存 | 强一致 | 状态变更 |
第 9 章结束,第三部分(R2DBC 与数据访问)完成。下一部分进入系统整合:Micrometer 指标与响应式监控。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。