Java + Redis 缓存一致性实战:旁路缓存、延迟双删与高并发避坑

缓存问题往往不在 Redis 命令本身,而在写库、删缓存、回填缓存三步之间留下了并发窗口。

结论先给:大多数 Java 后端业务先用 Cache Aside 就够了,但真正决定稳定性的,从来不是你有没有把 Redis 接上,而是你是否想清楚了“写库、删缓存、回填缓存”三步之间的并发窗口。缓存一致性问题,本质上是时序设计问题。

很多团队在项目介绍里都会写“用了 Redis 做缓存”,可一旦继续追问“为什么是先更新数据库再删缓存”“延迟双删解决了什么、又没解决什么”“热点 Key 回源风暴怎么控”,答案往往就开始模糊。

缓存真正难的地方,不是 API 会不会写,而是你是否知道:哪些业务可以接受最终一致,哪些业务根本不该上缓存,哪些场景需要删除缓存,哪些场景应该做延迟补偿,哪些高并发问题压根不是 Redis 命令层面能兜住的。

一、先别急着谈方案,先判断你要的到底是哪种一致性

缓存并不是越一致越好,而是要看业务能接受什么程度的不一致。先把这件事判断清楚,方案就不会跑偏。

业务场景 能接受的结果 更适合的思路
商品详情、文章详情、店铺信息 秒级以内短暂旧数据可接受 Cache Aside + 过期时间 + 热点保护
订单状态、支付结果、账户余额 读到旧值风险高 优先读主库或短链路查询,不要盲目依赖缓存
排行榜、浏览量、点赞数 允许短时偏差 缓存聚合 + 异步落库
库存、优惠券余量 不能超卖,但允许读侧延迟展示 强约束放数据库或原子扣减层,缓存做读优化

这一点非常关键。很多缓存事故不是方案太弱,而是把“最终一致”的工具硬用在“强一致”场景里。缓存从来不是银弹,它只是拿空间换时间、拿复杂度换吞吐的手段。

二、为什么大多数项目最后都回到 Cache Aside

Cache Aside 也叫旁路缓存,本质是:应用自己同时管理数据库和缓存。

读流程:先查缓存,命中直接返回;未命中就查数据库,再把结果写回缓存。
写流程:先更新数据库,再删除缓存。

它会成为事实标准,不是因为它最“高级”,而是因为它把复杂度留在应用侧,最容易结合业务语义做定制:你可以选择缓存多久、空值怎么处理、热点怎么保护、删除失败怎么补偿、哪类数据干脆不缓存。

三、先更新数据库再删除缓存,不是经验主义,而是并发下更稳

缓存一致性里最常见的第一问就是:为什么不是先删缓存,再更新数据库?原因很简单,顺序一反,在高并发下更容易把旧值重新写回缓存。

可以把错误时序想象成这样:

  1. 写请求 A 先删掉缓存。
  2. 读请求 B 发现缓存没了,于是去数据库读取旧值。
  3. 写请求 A 再把新值写入数据库。
  4. 读请求 B 把刚刚读到的旧值回填进缓存。

结果就是:数据库是新值,缓存却被旧值污染了。相比之下,先更新数据库,再删除缓存 虽然也不是绝对强一致,但它在大多数业务里风险更低,且更容易靠补偿手段继续收敛。

注意:“先更新 DB,后删缓存”不是完美方案,而是成本和稳定性之间最均衡的默认解。别把它理解成“这样就一定不会脏数据”。

四、为什么删除缓存仍然会有并发窗口

很多人以为只要顺序改成“先写库后删缓存”,问题就结束了。其实不是。典型的残余风险是:

  1. 读请求 A 缓存未命中,开始查数据库,拿到旧值。
  2. 写请求 B 很快完成了数据库更新,并删除了缓存。
  3. 读请求 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 技巧题”,而是后端系统设计题。你愿意在缓存层承担多少复杂度,本质上取决于业务能接受多少时延、多少旧值窗口,以及你是否真的有能力维护这条补偿链路。很多时候,最稳的方案并不是最花哨的方案,而是那个边界清楚、失败可观测、补偿能跑通的方案。

本文总结

  • 大多数业务先用 Cache Aside 就够了,关键不是模式名字,而是明确写库、删缓存和回填缓存的先后顺序。
  • 删除缓存通常比直接更新缓存更稳,但它并不天然强一致,热点数据仍要配合延迟补偿、互斥重建或 binlog 驱动失效。
  • 缓存穿透、击穿、雪崩和重建风暴属于同一类问题:系统没有给缓存失效后的回源路径设置上限和保护。
GYSTACK 文章文末广告 硅云云服务器活动 适合个人项目、轻量建站和出海业务部署。
后浪云移动端信息流广告 后浪云主机服务 适合长期部署、独立站和海外机房需求。