背景#

Spring Boot 3.5 配合 Java 25,已经把“同步写法 + virtual threads”这条路线变成了很现实的工程选择。JDK 在 JEP 444 里把 virtual threads 的目标讲得很清楚:让 thread-per-request 风格更容易扩展;Spring Boot 官方文档也提供了 spring.threads.virtual.enabled=true 这条开关入口。

于是一个常见问题会重新冒出来:当一个微服务会同时访问 HTTP 下游、Redis、Kafka、数据库时,Resilience4j 应该怎么用?

这篇文章不是“所有外部调用统一套一层 Resilience4j 模板”的教程,而是一篇事实约束下的探索综述。文中的强结论优先建立在仍在维护的官方资料和工程文档上,例如 Spring / Resilience4j、AWS Builders LibraryAzure Architecture CenterGoogle Cloud retry strategyRedis 官方文档Spring for Apache Kafka 文档PostgreSQL 文档。少量技术文章和 case study 只用来补充上下文,不会单独支撑“行业定论”。

如果你使用的是 resilience4j-spring-boot3,或者使用 Spring 生态提供的 Spring Cloud CircuitBreaker + Resilience4j,本文的判断原则都成立;区别更多在接入方式,而不在策略本身。

为什么这篇文章只讨论“按 dependency + operation semantics 设计策略”#

我对这个问题最核心的判断是:

Resilience4j 不应该按“所有外部调用统一套模板”来使用,而应该按具体 dependency 与操作语义来设计。

只按“HTTP / Redis / Kafka / DB”四个技术名词分层还不够,因为真正决定策略边界的是下面这些维度:

维度 你真正想判断的是什么
下游依赖 这是一个独立故障域,还是本地一致性链路的一部分
操作语义 这是读、写、幂等写,还是带副作用的写
失败后果 失败是“体验退化”,还是“破坏正确性”
原生机制 这个依赖本身是否已经提供更强的可靠性语义
资源瓶颈 你保护的是线程、连接、队列,还是业务配额

这也是为什么我更推荐把 Resilience4j 实例命名成 dependency + operation semantics,而不是一个笼统的 externalCall

resilience4j:
  circuitbreaker:
    instances:
      searchServiceRead:
        slidingWindowSize: 20
        failureRateThreshold: 50
      paymentCreate:
        slidingWindowSize: 10
        failureRateThreshold: 50
  retry:
    instances:
      searchServiceRead:
        maxAttempts: 3
        waitDuration: 200ms
      # paymentCreate 默认不重试,除非上层已经设计了 idempotency key

这样的命名天然逼着你回答:这个调用为什么能重试?为什么能 fallback?为什么要隔离?

VT 改变了什么,没改变什么#

VT 确实改变了“阻塞 I/O 会不会立刻把平台线程耗光”这件事。按照 JEP 444 的定义,virtual threads 的价值在于降低等待中的线程成本,让同步代码更容易维持高并发。

但 VT 没有改变下面这些基本事实:

  • 下游服务照样会慢、会超时、会返回 5xx
  • 数据库连接池照样有上限
  • Redis 锁失败照样可能破坏互斥语义
  • Kafka producer / consumer 依然有自己的 delivery 语义
  • 幂等性依然是业务语义,不是框架可以替你猜的

换句话说,VT 改变的是线程成本模型,不是容错语义模型。所以到了 Java 25,真正需要更新的不是“要不要做 resilience”,而是“哪些地方的 resilience 仍然必要,哪些地方应该优先依赖原生机制”。

HTTP 调用:最适合 Resilience4j 的主战场#

如果只问一句“哪类外部调用最适合 Resilience4j”,我的答案仍然会是:HTTP(以及语义非常接近的 gRPC / 同步 RPC)

原因很简单:对于同步下游调用,timeout / retry / circuit breaker / bulkhead 这几种机制的职责边界最清晰,和业务 fallback 的对应关系也最容易解释。

Resilience4j 官方文档本身就把 RetryCircuitBreaker 设计成围绕“对外部调用的装饰器”;Spring Cloud CircuitBreaker 也把 Resilience4j 作为 Spring 生态里的主要实现之一。另一方面,AWS Builders LibraryAzure Retry PatternAzure Circuit Breaker PatternGoogle Cloud retry strategy 在最近几年反复强调的,也恰好是 HTTP/remote call 这类场景。

Retry 只给 transient + idempotent 调用#

这一点几乎是各家资料的最大公约数:

  • AWS 强调 retry 适合 partial / transient failure,但要配合 backoff,避免放大下游故障
  • Google Cloud 明确把“response 是否可重试”和“请求是否幂等”分开判断
  • Azure 也强调 retry 是为了 transient fault,而不是为了掩盖 permanent failure

所以对 HTTP 调用,我更推荐这样的默认规则:

调用类型 默认是否重试 说明
GET / 幂等查询 通常可以 但要配合短超时、有限次数、backoff + jitter
PUT / 幂等更新 条件成立时可以 前提是服务端语义真的幂等
POST / 下单 / 扣款 / 发券 默认不可以 除非业务层已经设计了 idempotency key
429 / 503 / timeout 更适合进入 retry 集合 仍然要看操作是否安全
4xx 业务错误 通常不应该 retry 这更像请求错误,不是瞬时故障

如果你想在 Spring Boot 3.5 里直接落地,这一层最不该省的是 retryOnException / ignoreExceptions / retryOnResultPredicate 这些显式白名单配置,而不是一个笼统的 maxAttempts=3

Timeout 先放在 client 层,再决定要不要额外的 TimeLimiter#

在 HTTP 场景里,我通常把超时分成两层:

  1. HTTP client 自身的 connect / read / response timeout
  2. Resilience4j 的 TimeLimiter(当你要表达“整个调用预算”或异步边界时)

这样分层的原因是:client timeout 更贴近 socket / 连接语义,而 TimeLimiter 更像“调用预算”和“快失败”的补充机制。

我不太建议在项目里含糊地依赖默认值。近几年的 Spring / Resilience4j 实践里,一个反复出现的经验就是:超时最好显式写清楚,否则很容易在真实 RT、连接池、代理层、重试层之间互相放大。

Bulkhead 在 VT 时代更像“隔离”和“配额”,不是“省线程”#

到了 Java 25,很多人看到 VT 后的第一反应是:既然线程便宜了,那是不是 bulkhead 就不重要了?

我不认同这个推论。

VT 让“等待中的线程”成本下降了,但 bulkhead 的价值从来不只是省线程。对 HTTP 下游来说,它至少还承担三件事:

  • 把慢下游和快下游隔离开
  • 给高风险依赖设置并发上限
  • 防止某个依赖吃光本地连接、队列或回调资源

所以在 VT 环境里,bulkhead 的重点通常会从“线程池隔离”转成“故障域隔离和并发预算控制”。这也是为什么我更建议按 dependency + semantics 配实例,而不是全局一个 httpClientBreaker

Redis 调用:缓存型调用与协调型调用要分开#

Redis 是最容易被“统一模板”误伤的一类依赖,因为很多系统嘴上说自己“在用 Redis”,实际上做的事情完全不同:

  • 可能只是缓存
  • 也可能是在做分布式锁
  • 也可能是在做限流、幂等键、库存协调、Lua 原子操作

而这几类路径,对失败的容忍度差异极大。

Redis 官方关于连接管理的文档 就已经提醒过:连接池、multiplexing、blocking command 的适用边界不一样;Redis 的 distributed lock 文档 又把 safety / liveness / fault tolerance 讲得很明确。把这些语义硬揉成同一个“Redis 调用策略”,通常会出事。

缓存路径:更接近 availability optimization#

如果 Redis 在这里扮演的是缓存,例如:

  • 商品详情缓存
  • 用户 profile cache
  • 热门榜单 cache
  • 只读配置 cache

那它失败时最常见的结果只是:命中率下降,系统回源,延迟上升

这时比较自然的做法通常是:

  • client timeout 设短
  • 失败时直接 bypass cache
  • 读路径可以考虑 fail fast
  • 某些幂等读可以做有限 retry

这类场景里,Resilience4j 是能帮上忙的:它可以把“缓存读超时 → 快速回源”表达得很清楚。

协调路径:失败往往不能被“优雅降级”#

但如果 Redis 扮演的是协调角色,情况就完全不同了。例如:

  • 分布式锁
  • 限流令牌桶 / 计数器
  • 幂等键
  • 库存或名额协调
  • Lua 脚本保证的一次性原子操作

这时 Redis 失败意味着的不是“少一次缓存命中”,而是“你失去了协调事实”。

Redis 官方的 distributed lock 页面把这件事说得很直白:锁的核心目标是 mutual exclusion,且 failover-based implementation 本身就有边界。如果一个锁获取失败,你再用一个“fallback=继续执行业务”的默认兜底把它吃掉,等于直接放弃了锁存在的意义。

所以我会把 Redis 场景拆成两组完全不同的默认策略:

Redis 角色 更自然的策略
缓存读 / 缓存写回 短超时、有限 retry、允许 bypass / 回源
锁 / 限流 / 幂等键 / 库存协调 明确失败、通常不做静默 fallback、重试也必须非常谨慎

一句话概括就是:

缓存失败通常是 availability 问题;协调失败通常是 correctness 问题。

这两者不应该共用一个 Resilience4j 策略模板。

Kafka 调用:优先依赖 Kafka 自身可靠性机制#

Kafka 是另一个经常被“HTTP 化理解”的地方。

如果你只从“调用远端系统”这个角度看,Kafka producer / consumer 好像也能套上 retry、circuit breaker、timeout。但只要你回到 Kafka 的真正语义,就会发现它的主问题其实是:

  • producer ack 与 durability
  • idempotent producer
  • transaction / exactly-once 边界
  • consumer offset commit
  • non-blocking retry
  • DLT / DLQ
  • partition / ordering / rebalance

这些都不是 generic circuit breaker 真正懂的东西。

Spring Kafka 的 non-blocking retry 文档 说得很明确:@RetryableTopic / DLT 这一套机制,本来就是 topic 级别的;Confluent 的 producer 配置文档 则直接把 retriesdelivery.timeout.msacksenable.idempotence 这些参数定义为 producer 可靠性的主机制;Confluent 那篇 Exactly-Once Semantics 文章 在 2025 年还更新过,强调 exactly-once 依赖的是 Kafka 自己的 idempotence 和 transactions,而不是外围再套一层 generic retry。

Producer:先把 Kafka 自己的语义配对#

对 producer,我更推荐先把下面这些问题回答清楚:

  • acks 要不要用 all
  • delivery.timeout.ms 预算是多少
  • retries 要不要交给 Kafka 自己管理
  • enable.idempotence 是否必须打开
  • 是否需要 transactional.id

在这些问题没想清楚前,先给 KafkaTemplate.send() 外面套一个 @Retry,通常解决不了最关键的事。

Consumer:先想 offset、backoff、DLT,不是先想 circuit breaker#

对 consumer 也是一样。

一个消息消费失败以后,真正要回答的是:

  • 这次失败要不要重放
  • 重放几次
  • 是 blocking retry 还是 non-blocking retry
  • 什么时候发 DLT
  • 如何保证幂等消费
  • offset 在哪里提交

这些问题,Spring Kafka 和 Kafka 本身已经有一套比较成熟的表达方式了。Resilience4j 在这里不是完全不能用,而是通常不该充当主机制

更准确的说法是:

  • Resilience4j 很适合保护消费逻辑里额外发生的同步下游调用
  • 但 Producer / Consumer 的主可靠性语义,通常应该优先依赖 Kafka 自身机制

DB 调用:优先数据库与连接池语义,而不是通用熔断包装#

数据库是这篇文章里最不适合“一把梭”套模板的依赖。

HTTP 调用大体上是“请求 - 响应”模型;而数据库调用则常常会碰到:

  • 事务边界
  • 隔离级别
  • 锁等待
  • 死锁
  • 连接池耗尽
  • SQLState 分类
  • ORM 惰性加载 / N+1

这些问题里,有些是 transient failure,有些是并发冲突,有些是设计问题,还有些根本不该被自动重试。

Retry 应该围绕“完整事务逻辑”,不是单条 SQL 装饰器#

PostgreSQL 官方文档对这点说得非常清楚:40001serialization_failure)这类错误应准备好重试整个事务40P01deadlock_detected)有时也适合重试;但像 unique violation 之类的错误则要更谨慎,因为它可能是持久性错误,而不是瞬时错误。

这段话背后的工程含义是:数据库重试并不是“遇到 SQLException 再试一次”这么简单,而是要和事务边界、冲突类型、业务幂等性绑在一起。

所以我通常不会把数据库调用默认归类成“像 HTTP GET 一样可以装饰 @Retry”。更稳妥的默认值是:

  • 先用数据库 / 驱动 / ORM / 连接池提供的机制表达 timeout 和 error class
  • 只对白名单的 transient error 做 targeted retry
  • 重试时围绕完整 transaction logic,而不是只围绕某一个 repository 方法

VT 解决线程成本,不解决连接池上限#

到了 Java 25,这个问题反而更容易被看见。

因为 VT 让等待中的线程更便宜了,所以瓶颈往往不会再首先表现成“Servlet 线程不够”;它更可能表现成:

  • Hikari 连接池被打满
  • 下游数据库 RT 上升
  • 锁竞争放大
  • 大量 virtual threads 堵在获取连接上

JEP 444 的目标从来不是让数据库 magically 支持无限并发;一些技术 case study,比如 InfoQ 的 virtual threads case study 以及 MariaDB 的 Java 21 virtual threads benchmark,也都在提醒同一个事实:VT 经常把瓶颈从线程池转移到连接池和下游系统本身。

因此,数据库侧更合理的优先级通常是:

  1. SQL / ORM 设计
  2. 连接池与 statement timeout
  3. 事务边界与错误分类
  4. 必要时再考虑 service boundary 上的 fast-fail 保护

Resilience4j 在 DB 场景里不是完全没价值,但它更像外围保护层,而不是数据库可靠性的主语义层。

一张决策表:什么时候用 Resilience4j,什么时候不用#

把上面的讨论压缩成一张表,大概就是这样:

场景 典型操作 Resilience4j 是否适合作为主机制 更稳妥的默认做法
HTTP 下游读 搜索、商品详情聚合、读 profile timeout + circuit breaker + retry(仅幂等)+ backoff/jitter
HTTP 下游写 下单、支付、发券 条件成立时才是 先确认幂等键 / 幂等语义,再决定是否 retry
Redis 缓存 cache get / cache put 可以 短超时、fail fast、允许 bypass / 回源
Redis 协调 锁、限流、幂等键、库存协调 通常不是 优先正确暴露失败,避免静默 fallback
Kafka Producer 发业务事件 通常不是 acksdelivery.timeout.msenable.idempotence、transactions
Kafka Consumer 消费事件 通常不是 offset 语义、backoff、@RetryableTopic、DLT、幂等消费
DB 查询 读模型、聚合查询 通常不是 pool / SQL timeout / 错误分类优先,必要时外围 fast-fail
DB 事务写 下单、扣库存、记账 通常不是 围绕完整事务逻辑做 targeted retry,不做笼统自动重试

如果要把它再压缩成一句工程建议,那就是:

对 HTTP,下游调用本身就是 resilience 的主对象;对 Redis / Kafka / DB,更应该先问它们自己的语义是什么,再决定 Resilience4j 是否只是辅助手段。

小结#

  • Resilience4j 最适合的主战场仍然是 同步 HTTP / RPC 下游调用
  • Java 25 的 virtual threads 改变了线程成本,但没有改变幂等性、超时预算、连接池上限、消息语义和一致性约束。
  • Redis 不能一刀切:缓存路径协调路径要分开设计。
  • Kafka producer / consumer 的主可靠性机制,通常应该优先依赖 Kafka 自己的 delivery semantics,而不是 generic circuit breaker。
  • 数据库重试要围绕 完整事务逻辑和明确的 transient error class,而不是“给 repository 方法统一挂一个 @Retry”。

参考资料#

官方文档#

公司实践 / 工程资料#

技术文章 / case study / conference signal#

相关文章#