结论先给:大多数 Java 后端业务先用 Cache Aside 就够了,但真正决定稳定性的,从来不是你有没有把 Redis 接上,而是你是否想清楚了“写库、删缓存、回填缓存”三步之间的并发窗口。缓存一致性问题,本质上是时序设计问题。
很多团队在项目介绍里都会写“用了 Redis 做缓存”,可一旦继续追问“为什么是先更新数据库再删缓存”“延迟双删解决了什么、又没解决什么”“热点 Key 回源风暴怎么控”,答案往往就开始模糊。
缓存真正难的地方,不是 API 会不会写,而是你是否知道:哪些业务可以接受最终一致,哪些业务根本不该上缓存,哪些场景需要删除缓存,哪些场景应该做延迟补偿,哪些高并发问题压根不是 Redis 命令层面能兜住的。
一、先别急着谈方案,先判断你要的到底是哪种一致性
缓存并不是越一致越好,而是要看业务能接受什么程度的不一致。先把这件事判断清楚,方案就不会跑偏。
| 业务场景 | 能接受的结果 | 更适合的思路 |
|---|---|---|
| 商品详情、文章详情、店铺信息 | 秒级以内短暂旧数据可接受 | Cache Aside + 过期时间 + 热点保护 |
| 订单状态、支付结果、账户余额 | 读到旧值风险高 | 优先读主库或短链路查询,不要盲目依赖缓存 |
| 排行榜、浏览量、点赞数 | 允许短时偏差 | 缓存聚合 + 异步落库 |
| 库存、优惠券余量 | 不能超卖,但允许读侧延迟展示 | 强约束放数据库或原子扣减层,缓存做读优化 |
这一点非常关键。很多缓存事故不是方案太弱,而是把“最终一致”的工具硬用在“强一致”场景里。缓存从来不是银弹,它只是拿空间换时间、拿复杂度换吞吐的手段。
二、为什么大多数项目最后都回到 Cache Aside
Cache Aside 也叫旁路缓存,本质是:应用自己同时管理数据库和缓存。
读流程:先查缓存,命中直接返回;未命中就查数据库,再把结果写回缓存。
写流程:先更新数据库,再删除缓存。
它会成为事实标准,不是因为它最“高级”,而是因为它把复杂度留在应用侧,最容易结合业务语义做定制:你可以选择缓存多久、空值怎么处理、热点怎么保护、删除失败怎么补偿、哪类数据干脆不缓存。
三、先更新数据库再删除缓存,不是经验主义,而是并发下更稳
缓存一致性里最常见的第一问就是:为什么不是先删缓存,再更新数据库?原因很简单,顺序一反,在高并发下更容易把旧值重新写回缓存。
可以把错误时序想象成这样:
- 写请求 A 先删掉缓存。
- 读请求 B 发现缓存没了,于是去数据库读取旧值。
- 写请求 A 再把新值写入数据库。
- 读请求 B 把刚刚读到的旧值回填进缓存。
结果就是:数据库是新值,缓存却被旧值污染了。相比之下,先更新数据库,再删除缓存 虽然也不是绝对强一致,但它在大多数业务里风险更低,且更容易靠补偿手段继续收敛。
注意:“先更新 DB,后删缓存”不是完美方案,而是成本和稳定性之间最均衡的默认解。别把它理解成“这样就一定不会脏数据”。
四、为什么删除缓存仍然会有并发窗口
很多人以为只要顺序改成“先写库后删缓存”,问题就结束了。其实不是。典型的残余风险是:
- 读请求 A 缓存未命中,开始查数据库,拿到旧值。
- 写请求 B 很快完成了数据库更新,并删除了缓存。
- 读请求 A 把自己刚刚拿到的旧值写回了缓存。
这个窗口通常比较小,但在热点数据、高并发、慢查询或数据库抖动时,概率会显著放大。所以真正有经验的做法从来不是“我用了 Cache Aside,所以万事大吉”,而是继续看业务是否需要额外补偿。
五、删除缓存为什么通常比直接更新缓存更稳
不少开发者第一直觉是:“既然删除后还要回填,那我直接把新值写缓存不就行了?”这在单机场景看起来顺理成章,但在真实业务里往往更脆:
| 方式 | 优点 | 风险 |
|---|---|---|
| 更新数据库后删除缓存 | 动作轻、实现简单、回到读时懒加载 | 存在极小并发窗口,需要补偿机制收口 |
| 更新数据库后同步更新缓存 | 命中率看起来更稳定 | 写扩散、字段拼装复杂、并发更新顺序更容易错乱 |
删除的核心优势在于:缓存是可重建的,真正需要时再回填即可。而“更新缓存”往往意味着你要在写路径上立刻拿到完整对象、保证字段一致、处理并发覆盖顺序,这在复杂对象或聚合视图场景里成本更高。
六、真正实战时,常见补偿手段应该怎么选
当业务对一致性要求更高,或者热点数据确实频繁被读写时,常见的补偿手段大致有下面几类:
| 方案 | 适合场景 | 优点 | 局限 |
|---|---|---|---|
| 短 TTL + 随机过期 | 对旧数据容忍度较高的读多写少场景 | 实现简单,能自然收敛脏数据 | 高频回源时对数据库仍有压力 |
| 延迟双删 | 热点 Key 写后立即被频繁读取 | 能进一步压缩旧值回填窗口 | 删除间隔难精确,不能当成强一致方案 |
| MQ / binlog 驱动失效 | 多节点服务、缓存键复杂、需要统一失效治理 | 失效路径更可追踪,可做重试补偿 | 链路更长,实现复杂度更高 |
| 逻辑过期 + 异步重建 | 热点读多、极少数请求可接受旧值 | 避免大量请求同时回源 | 读侧需要接受短时旧数据 |
这些方案没有一个是“通杀”。你要选的不是理论上最强的方案,而是业务愿意支付多少复杂度来换多少一致性收益。
七、延迟双删能用,但别把它神化
所谓延迟双删,常见做法是:更新数据库后先删一次缓存,等待一个短延迟,再删一次缓存。它试图解决的,是“旧值在窗口期被回填进缓存”这个问题。
它确实有帮助,但要明确三点:
- 它只能降低概率,不能提供严格强一致。
- 删除延迟时间很难精确。 你不知道数据库慢查询、网络抖动、线程调度会把窗口拉多长。
- 第二次删除同样可能失败。 所以最好配合重试、消息队列或告警,而不是“发个延迟任务就算完”。
因此,延迟双删更像是一个成本较低的工程折中,而不是缓存一致性的终局方案。
八、高并发下更危险的,往往不是脏数据,而是回源风暴
很多系统不是先死于数据不一致,而是先死于缓存一失效,所有请求同时打数据库。缓存层常见的四类问题可以一起看:
- 缓存穿透: 请求的数据本来就不存在,导致缓存永远 miss,数据库被持续打穿。
- 缓存击穿: 某个热点 Key 恰好过期,大量并发同时回源。
- 缓存雪崩: 大量 Key 在同一时间段一起失效,数据库瞬间承压。
- 重建风暴: 回填逻辑复杂、查询慢、序列化重,导致重建线程把系统自己拖慢。
这些问题如果不和一致性一起设计,最后经常会变成两难:为了防脏数据频繁删缓存,结果把数据库打爆;为了保护数据库把 TTL 拉长,结果旧数据停留过久。
九、一个更稳的 Java + Redis 读路径模板
下面这个模板体现了几个比较实用的原则:空值缓存、防击穿互斥、随机过期时间,以及把“缓存是否可重建”作为前提。
public ProductDTO getProduct(Long productId) {
String cacheKey = "product:" + productId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if ("NULL".equals(cached)) {
return null;
}
if (cached != null) {
return Jsons.parse(cached, ProductDTO.class);
}
String lockKey = "lock:product:" + productId;
boolean locked = Boolean.TRUE.equals(
redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5))
);
if (!locked) {
return redisRetryReader.readLater(productId);
}
try {
ProductDTO product = productRepository.findProduct(productId);
if (product == null) {
redisTemplate.opsForValue().set(cacheKey, "NULL", Duration.ofMinutes(2));
return null;
}
long ttlSeconds = 300 + ThreadLocalRandom.current().nextLong(60);
redisTemplate.opsForValue().set(cacheKey, Jsons.toJson(product), Duration.ofSeconds(ttlSeconds));
return product;
} finally {
redisTemplate.delete(lockKey);
}
}
这段代码不是万能模板,但它说明了一件重要的事:缓存读取路径本身也需要限流和保护,不能把回源逻辑当成“查一下数据库而已”。
十、写路径更关键:数据库提交后再删缓存,最好带补偿
写路径里最推荐的基本动作仍然是:更新数据库成功后删除缓存。如果你在 Spring Boot 里已经有事务,那么删除缓存最好放在提交成功之后执行,这样可以减少“数据库最终回滚,但缓存已经删掉”的无效抖动。
@Transactional(rollbackFor = Exception.class)
public void updateProduct(UpdateProductCommand command) {
productRepository.update(command);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
redisTemplate.delete("product:" + command.productId());
cacheInvalidationPublisher.publish(command.productId());
}
});
}
这里多做了一步 publish,是因为真正线上稳定的方案很少只依赖“一次删缓存成功”。一旦 Redis 抖动、网络闪断或删除失败,你至少还要有第二条补偿链路能继续清理。
十一、哪些数据最好不要强行缓存
如果业务要求“写完立刻读必须是新值,而且绝不能错”,你首先应该问的不是“怎么把缓存做强一致”,而是“这类读到底该不该走缓存”。
下面这些场景就应该非常谨慎:
- 支付结果页、扣款结果、账户余额、优惠券可用状态。
- 库存扣减后的实时校验。
- 后台运营刚改完配置,前台立刻必须看到新值的强管控场景。
如果业务本身不允许短暂旧值,那缓存层再怎么补丁,也很难让设计真正简单。很多时候,正确答案就是主链路直接查数据库或走专门的一致性读模型。
十二、落地时可以直接记住这份缓存一致性清单
- 默认从 Cache Aside 开始,不要一上来就做重型方案。
- 写路径优先“先更新数据库,再删除缓存”,而不是反过来。
- 对热点 Key 补上互斥重建、随机 TTL、空值缓存和限流。
- 延迟双删可以用,但只能当降低概率的补偿手段。
- 删除缓存最好有重试、消息补偿或 binlog 驱动的第二保险。
- 强一致业务不要硬靠缓存兜,先回到业务边界本身判断。
缓存一致性从来不是“Redis 技巧题”,而是后端系统设计题。你愿意在缓存层承担多少复杂度,本质上取决于业务能接受多少时延、多少旧值窗口,以及你是否真的有能力维护这条补偿链路。很多时候,最稳的方案并不是最花哨的方案,而是那个边界清楚、失败可观测、补偿能跑通的方案。