Java 基础体系 · 第 30/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 微服务治理:HTTP、gRPC、配置、发现、限流、熔断和幂等

微服务治理解决的不是“如何把服务拆成多个进程”,而是拆分之后如何控制调用关系、配置变化、实例变化、流量峰值和重复执行。

一个典型请求可能经过以下路径:

sequenceDiagram
    participant C as Client
    participant G as Gateway/HTTP Client
    participant R as Registry
    participant O as Order Service
    participant P as Payment Service
    participant DB as Database

    C->>G: POST /orders + Idempotency-Key
    G->>R: 获取 Payment 实例列表
    R-->>G: payment-1, payment-2
    G->>O: 创建订单
    O->>P: gRPC 扣款 + deadline
    P->>DB: 唯一键检查幂等记录
    DB-->>P: 首次执行或已完成结果
    P-->>O: 成功/失败
    O->>DB: 保存订单和业务状态
    O-->>G: HTTP 响应
    G-->>C: 订单结果

这条链路中,HTTP 负责外部资源接口,gRPC 适合内部强类型调用;配置决定超时和限流参数;服务发现决定请求发往哪个实例;限流控制进入系统的速率;熔断控制持续失败的传播;幂等则防止重试把一次业务变成多次扣款。


一、先建立治理模型:一次调用不是一个方法调用

在单体程序中,调用通常可以抽象为:

调用=函数执行\text{调用} = \text{函数执行}

在微服务中,调用至少包含以下步骤:

  1. 根据服务名定位实例;
  2. 建立或复用连接;
  3. 写入请求;
  4. 等待服务端处理;
  5. 读取响应;
  6. 根据结果决定是否重试;
  7. 将失败、超时或重复请求映射为业务结果。

因此,远程调用的正确抽象是:

远程调用=寻址+传输+超时+失败分类+重试策略+业务语义\text{远程调用} = \text{寻址}+\text{传输}+\text{超时}+\text{失败分类}+\text{重试策略}+\text{业务语义}

任何一个环节缺失,都可能形成“看起来正常、故障时失控”的系统。例如:

  • 没有超时:线程、连接和请求上下文会长期占用;
  • 只有重试没有幂等:支付可能执行两次;
  • 只有熔断没有限流:本服务仍可能被大量请求压垮;
  • 只有服务发现没有健康检查:请求会持续发往已失效实例;
  • 只有配置中心没有版本和回滚:配置变更本身变成事故源。

二、HTTP:资源语义、状态码和超时边界

2.1 HTTP 不只是“用 JSON 传数据”

HTTP 请求由方法、目标 URI、头部和请求体组成。REST 风格接口通常把资源状态映射到 URI 和 HTTP 方法:

方法 常见语义 是否应具有幂等语义
GET 获取资源
PUT 用完整表示替换资源 应是
DELETE 删除资源 应是
POST 创建资源或执行动作 协议层不保证
PATCH 局部修改资源 取决于操作定义

这里的“幂等”是协议语义:同一个请求执行一次或多次,最终资源状态相同。它不等于“服务端只执行一次”。例如:

DELETE /users/42

第一次删除用户,第二次可能返回 404,但最终状态都是“用户不存在”,因此可以具有幂等效果。

相反:

POST /payments

每次都可能创建一笔新支付。即使两次请求的 JSON 完全相同,HTTP 也不会自动合并它们。

2.2 状态码必须表达可操作的结果

服务端至少应区分以下结果:

  • 400 Bad Request:请求格式或字段不满足接口约束;
  • 401 Unauthorized:缺少或无法验证身份;
  • 403 Forbidden:身份有效但无权限;
  • 404 Not Found:资源不存在,或出于安全原因不暴露其存在;
  • 409 Conflict:请求与当前资源状态冲突,例如版本冲突或幂等键冲突;
  • 429 Too Many Requests:触发限流;
  • 500 Internal Server Error:服务端未预期错误;
  • 502 Bad Gateway:代理从上游得到无效响应;
  • 503 Service Unavailable:服务暂时不可用;
  • 504 Gateway Timeout:代理等待上游超时。

客户端不能把所有非 2xx 都当作“可以重试”。例如:

  • 400 通常重试无意义;
  • 401 应刷新凭证或重新认证;
  • 409 需要读取业务冲突并处理;
  • 429 应尊重 Retry-After 或退避;
  • 503 只有在请求可安全重放时才考虑重试;
  • 504 可能代表上游已经成功,只是响应没有及时返回。

最后一种情况直接连接到幂等问题:客户端看见超时,不等于服务端没有执行。

2.3 超时必须分层,并且受总预算约束

一次请求的总耗时可以粗略写成:

Ttotal=Tqueue+Tconnect+Twrite+Tserver+TreadT_{\text{total}} = T_{\text{queue}}+ T_{\text{connect}}+ T_{\text{write}}+ T_{\text{server}}+ T_{\text{read}}

其中:

  • TqueueT_{\text{queue}}:客户端等待连接池或线程资源;
  • TconnectT_{\text{connect}}:建立 TCP/TLS 连接;
  • TwriteT_{\text{write}}:发送请求;
  • TserverT_{\text{server}}:服务端处理;
  • TreadT_{\text{read}}:等待并读取响应。

上游还可能有总截止时间 DD。正确约束是:

Tconnect+Tserver+TreadDTqueueT_{\text{connect}} + T_{\text{server}} + T_{\text{read}} \le D - T_{\text{queue}}

如果服务 A 给服务 B 设置 2 秒超时,但 A 自身的调用方只允许 1 秒,那么 B 最多只能拿到剩余预算,而不是固定使用 2 秒。

gRPC 将这个概念称为 deadline;HTTP 客户端也应实现等价的请求级截止时间。只配置连接超时而不配置读取超时,不能防止服务端逻辑卡住。只配置读取超时而不限制连接池等待,也不能防止资源耗尽。


三、gRPC:强类型 RPC、状态码和 deadline

3.1 gRPC 的核心组成

gRPC 通常使用 Protocol Buffers 定义接口和消息,再生成 Java 客户端与服务端代码:

syntax = "proto3";

option java_multiple_files = true;
option java_package = "com.example.payment.proto";

service PaymentService {
  rpc Authorize(AuthorizeRequest) returns (AuthorizeResponse);
}

message AuthorizeRequest {
  string request_id = 1;
  string order_id = 2;
  int64 amount_cent = 3;
}

message AuthorizeResponse {
  string payment_id = 1;
  string status = 2;
}

生成代码后,服务端实现 PaymentServiceGrpc.PaymentServiceImplBase,客户端使用 blocking、future 或 async stub。协议通常运行在 HTTP/2 之上,具备多路复用、二进制消息和生成式类型约束。

但 gRPC 并不自动提供:

  • 服务发现;
  • 业务幂等;
  • 自动安全重试;
  • 分布式事务;
  • 无限期连接的自动正确治理。

这些能力需要在客户端、代理或基础设施层明确配置。

3.2 deadline 是 gRPC 调用的必要条件

Java 客户端调用示意:

PaymentServiceGrpc.PaymentServiceBlockingStub stub =
        PaymentServiceGrpc.newBlockingStub(channel);

AuthorizeResponse response = stub
        .withDeadlineAfter(800, TimeUnit.MILLISECONDS)
        .authorize(AuthorizeRequest.newBuilder()
                .setRequestId(requestId)
                .setOrderId(orderId)
                .setAmountCent(1999)
                .build());

withDeadlineAfter 给本次调用设置截止时间。服务端超过 deadline 后继续计算,通常已经无法为调用方提供有用结果,客户端会得到 DEADLINE_EXCEEDED

服务端应区分 gRPC 状态:

  • INVALID_ARGUMENT:请求参数不合法;
  • NOT_FOUND:目标资源不存在;
  • ALREADY_EXISTS:创建对象时已存在;
  • FAILED_PRECONDITION:业务前置条件不满足;
  • RESOURCE_EXHAUSTED:配额、容量或限流耗尽;
  • UNAVAILABLE:暂时不可用,可能适合受控重试;
  • DEADLINE_EXCEEDED:超过截止时间;
  • INTERNAL:服务内部错误;
  • UNAUTHENTICATEDPERMISSION_DENIED:认证与授权失败。

不能把所有异常包装成 INTERNAL,否则客户端无法区分“参数错误”“暂时不可用”和“已经处理但响应丢失”。

3.3 gRPC 的重试尤其需要幂等条件

假设客户端发送扣款请求:

  1. 客户端写出请求;
  2. 服务端完成扣款;
  3. 网络在响应返回前断开;
  4. 客户端收到 UNAVAILABLE 或超时;
  5. 客户端重试。

客户端无法从网络错误推断第 2 步是否发生。因此,只有满足以下条件之一才能安全重试:

  • 操作本身是幂等的;
  • 请求携带稳定的幂等键,服务端持久化去重;
  • 服务端明确保证失败发生在业务执行前;
  • 重试的是查询而不是改变状态的命令。

gRPC 的连接重试和业务请求重试是两个不同层次。连接重新建立不代表业务请求可以重复发送。


四、配置:从静态绑定到运行时行为

4.1 配置绑定是类型化输入,不是全局变量

Spring Boot 支持把外部配置绑定到 Java 对象。推荐将配置视为不可变的、带校验的输入:

@ConfigurationProperties(prefix = "payment.client")
@Validated
public record PaymentClientProperties(
        @NotBlank String target,
        @DurationMin(millis = 1)
        Duration timeout,
        @Min(0) int maxAttempts
) {
}

注册配置类:

@SpringBootApplication
@ConfigurationPropertiesScan
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

配置文件:

payment:
  client:
    target: dns:///payment.default.svc.cluster.local:8443
    timeout: 800ms
    max-attempts: 1

这里有三个重要边界:

  1. Duration 应使用 800ms2s 等带单位值,避免把 800 的单位误解为毫秒或秒;
  2. 配置绑定失败应在启动期暴露,而不是等第一次请求才失败;
  3. max-attempts 必须和业务幂等策略一起解释,不能单独调大。

@Value 适合少量简单值;具有层级、校验和复用需求时,@ConfigurationProperties 更适合。配置类不是业务状态,不应在业务方法中随意修改。

4.2 配置来源和优先级会改变最终值

Spring Boot 会合并多个配置来源,例如:

  • application.yml
  • profile 配置;
  • 环境变量;
  • JVM 系统属性;
  • 命令行参数;
  • 测试配置;
  • 外部配置文件。

具体优先级应以当前 Spring Boot 版本的配置文档为准。工程上必须能够回答:“最终生效的值来自哪里?”Actuator 的环境端点可以辅助诊断,但生产环境应限制敏感值暴露。

例如:

java -jar order.jar \
  --payment.client.timeout=500ms \
  --server.port=8080

命令行参数通常会覆盖文件中的同名配置。验证时不能只检查 Git 中的 YAML,还要检查部署注入的环境变量和启动参数。

4.3 动态刷新不是普通 Bean 自动安全更新

如果配置中心把超时从 800ms 改成 50ms,存在三种不同语义:

  1. 新请求使用新值,正在执行的请求不受影响;
  2. 已创建的客户端连接和负载均衡器重建;
  3. 所有线程立即看到新值。

这三者并不等价。普通 Spring Bean 的字段绑定不会自动让所有底层客户端安全重建。动态配置需要明确:

  • 变更是否需要重启;
  • 哪些 Bean 可以刷新;
  • 刷新期间正在进行的请求怎么办;
  • 新值是否经过范围校验;
  • 如何审计、回滚和灰度。

例如把超时从 800ms 误改为 800 秒,会造成连接长期占用;把并发上限改成 0,可能让全部流量被拒绝。配置变更本身必须拥有版本、操作者、时间和恢复路径。


五、服务发现:把逻辑服务名映射为可用实例

5.1 服务发现解决的是动态寻址

客户端调用的不是固定地址:

http://10.0.4.17:8080

而是逻辑服务:

payment-service

服务发现系统保存实例信息:

service = payment-service
instance = 10.0.4.17:8080
status = healthy
metadata = zone=az-a, version=v2

发现方式通常有两类:

  • 客户端发现:客户端从注册中心获得实例并自行负载均衡;
  • 服务端发现:客户端访问负载均衡器,由负载均衡器选择实例。

DNS、Kubernetes Service、Consul、Eureka 等方案的健康检查、缓存和故障语义不同,不能只因为都叫“服务发现”就混用假设。

5.2 注册、续约、摘除和缓存形成状态机

一个实例的典型状态可以表示为:

stateDiagram-v2
    [*] --> STARTING
    STARTING --> UP: 启动检查通过
    STARTING --> FAILED: 启动失败
    UP --> SUSPECT: 心跳超时或健康检查失败
    SUSPECT --> UP: 检查恢复
    SUSPECT --> DOWN: 连续失败/主动摘除
    UP --> DRAINING: 发布或优雅停机
    DRAINING --> DOWN: 不再接收新请求
    DOWN --> [*]

发布新版本时,正确顺序通常是:

  1. 启动实例;
  2. 等待依赖连接、数据库迁移和端口准备完成;
  3. 通过 readiness 检查;
  4. 注册为可接收流量;
  5. 停止时先标记 DRAINING
  6. 等待在途请求完成;
  7. 再关闭服务器和连接。

如果进程先关闭再从注册中心摘除,注册信息会短暂指向死实例。若客户端缓存实例列表,则摘除也不会立即阻止所有请求,客户端必须设置合理的缓存刷新和失败剔除策略。

5.3 健康检查不应把所有依赖都放进存活检查

健康检查至少要区分:

  • liveness:进程是否陷入无法恢复的状态;
  • readiness:当前是否应该接收流量;
  • startup:启动过程是否完成。

如果 liveness 直接检查数据库,数据库短时故障可能导致平台反复杀死并重启所有实例,形成重启风暴。数据库故障通常应先影响 readiness,而不是立刻证明 JVM 已经无法恢复。

Spring Boot Actuator 可以暴露健康、指标和应用信息端点。生产环境需要:

  • 只暴露必要端点;
  • 保护管理端口或路径;
  • 不在环境端点中泄露密码、令牌和连接字符串;
  • 让编排系统使用合适的健康组,而不是把所有监控端点都当成流量准入条件。

六、限流:限制进入速率,也限制并发资源

6.1 限流的两个不同问题

速率限流限制单位时间内允许通过的请求数,例如每秒 100 个;并发限流限制同时执行的请求数,例如最多 50 个。

设平均服务时间为 SS,并发数为 CC,根据 Little 定律:

L=λWL = \lambda W

其中 LL 是系统内平均请求数,λ\lambda 是吞吐率,WW 是平均响应时间。若目标吞吐率为 100100 req/s,平均处理时间为 200200 ms,则理想并发量约为:

C100×0.2=20C \approx 100 \times 0.2 = 20

这只是平均估算。长尾延迟、连接池、线程池、数据库连接数和突发流量都会使实际安全并发低于这个值。

6.2 令牌桶的计算过程

令牌桶有容量 BB,补充速率 rr 个令牌/秒。每个请求消耗 cc 个令牌。

设某一时刻桶中有 xx 个令牌,经过 Δt\Delta t 秒后:

x=min(B,x+rΔt)x' = \min(B, x + r\Delta t)

xcx' \ge c,请求通过并令:

xafter=xcx_{\text{after}} = x' - c

否则请求被拒绝或等待。

完整算例:

  • B=5B=5
  • r=2r=2 tokens/s;
  • 初始有 5 个令牌;
  • 连续到达 7 个请求,每个请求消耗 1 个令牌。

前 5 个请求依次消耗令牌,剩余 0;第 6 个和第 7 个请求立即被拒绝。若等待 1 秒,补入 2 个令牌,最多允许 2 个请求通过。

令牌桶允许短时突发,但长期平均速率仍受 rr 约束。漏桶更倾向于以稳定速率出队;固定窗口实现简单,却存在窗口边界突发问题;滑动窗口更准确但需要更多状态。

6.3 分布式限流的一致性边界

单实例内存计数器只能限制单实例流量。若有 10 个实例,每实例允许 100 req/s,集群总量可能达到 1000 req/s。

全局限流需要共享状态,例如 Redis 原子脚本、网关集中限流或专用限流服务。此时要决定:

  • 共享存储不可用时是放行还是拒绝;
  • key 按用户、租户、API、IP 还是组合维度;
  • 时钟漂移和网络延迟如何处理;
  • 限流状态是否允许丢失;
  • 429 是否返回剩余配额和重试时间。

“失败打开”保证可用性但可能保护失效;“失败关闭”保护资源但可能扩大故障范围。两者不是固定答案,而是业务风险选择。

6.4 限流位置决定保护对象

  • 网关限流:保护整个集群和入口带宽;
  • 服务入口限流:保护某个服务;
  • 下游客户端限流:保护数据库、支付供应商等依赖;
  • 并发舱壁:保护线程、连接和队列。

例如支付服务每秒只能处理 20 个供应商请求,即使订单接口可以接收 200 req/s,也不能把 200 个请求全部同步压给支付供应商。应该在支付客户端前设置速率或并发限制,并定义排队超时和拒绝结果。


七、熔断:根据失败历史暂时停止调用

7.1 熔断器的状态和转移

熔断器通常有三种状态:

stateDiagram-v2
    [*] --> CLOSED
    CLOSED --> OPEN: 失败率/慢调用率超过阈值
    OPEN --> HALF_OPEN: 等待窗口结束
    HALF_OPEN --> CLOSED: 探测请求成功
    HALF_OPEN --> OPEN: 探测请求失败
  • CLOSED:正常放行并统计结果;
  • OPEN:快速拒绝,不再调用下游;
  • HALF_OPEN:只允许少量探测请求判断是否恢复。

熔断器统计的是窗口内的调用结果。假设最小样本数为 10,窗口中 10 次调用有 6 次失败:

failureRate=610=60%\text{failureRate}=\frac{6}{10}=60\%

若阈值为 50%,则从 CLOSED 转为 OPEN。但如果只发生 1 次调用且失败率是 100%,在最小样本数为 10 时不应立刻熔断,否则偶然错误会造成误判。

“慢调用率”与失败率不同。一个请求返回 200 但耗时 5 秒,也可能占满资源,因此系统可以把超过慢调用阈值的请求计入慢调用统计。

7.2 熔断不是重试,也不是降级

  • 重试:再次执行调用;
  • 熔断:一段时间内不执行调用;
  • 降级:调用失败后返回替代结果;
  • 超时:限制一次调用最长等待时间;
  • 限流:限制进入或并发执行的请求量。

错误组合会放大故障。假设调用链有 3 层,每层失败时都重试 3 次,理论上最坏请求数可能接近:

33=273^3=27

这还没有计算连接池等待和超时叠加。通常应在靠近业务入口的一层控制重试预算,并让下游调用只对明确可重试的暂时性错误重试。

7.3 熔断恢复不能只看“网络恢复”

下游端口恢复并不代表业务恢复。Half-open 探测应验证真实依赖路径,例如数据库连接、必要的业务表和供应商接口是否可用。探测请求数量过多会在恢复瞬间再次冲垮下游;数量过少则恢复判断太慢。

熔断打开后,降级结果必须符合业务语义:

  • 商品推荐可以返回空列表;
  • 库存查询可以返回“暂不可用”;
  • 扣款不能伪造“支付成功”;
  • 订单创建不能因为支付服务熔断就默认为已支付,除非业务明确采用异步待处理状态。

八、幂等:重复请求的识别、持久化和结果一致性

8.1 幂等不是简单的“查一下再插入”

一个操作满足幂等,需要定义:

f(f(s,r),r)=f(s,r)f(f(s, r), r)=f(s, r)

其中 ss 是系统状态,rr 是同一个请求。执行一次和执行多次,最终状态相同。

但是并发下的如下代码不安全:

if (!repository.existsByKey(key)) {
    repository.save(new Payment(key, amount));
    chargeGateway.charge(amount);
}

两个线程可能同时读到“不存在”,然后都调用支付网关。幂等判断和业务执行之间存在竞态窗口。

8.2 数据库唯一键提供并发下的第一道约束

可以建立幂等记录:

CREATE TABLE idempotency_record (
    idempotency_key VARCHAR(128) PRIMARY KEY,
    request_hash     CHAR(64) NOT NULL,
    status           VARCHAR(16) NOT NULL,
    response_json    TEXT,
    created_at       TIMESTAMP NOT NULL,
    updated_at       TIMESTAMP NOT NULL
);

请求处理逻辑应先尝试插入:

INSERT INTO idempotency_record
    (idempotency_key, request_hash, status, created_at, updated_at)
VALUES
    (?, ?, 'PROCESSING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);

结果分三类:

  1. 插入成功:当前请求获得处理权;
  2. 主键冲突且 request_hash 相同:读取原记录;
  3. 主键冲突但 hash 不同:同一幂等键被用于不同请求,应返回 409 Conflict

然而,PROCESSING 记录还需要处理进程崩溃。若进程在写入 PROCESSING 后宕机,后续请求不能永久等待。可以增加租约时间、恢复任务或将处理状态交给可靠消息系统,但“超时后直接重新扣款”仍然可能重复执行,必须确认下游是否也支持幂等键。

8.3 返回原结果比返回“重复请求”更实用

对于同一幂等键和同一请求内容:

  • 第一次执行成功:后续请求返回相同业务结果;
  • 第一次执行失败:应明确失败是否可重试;
  • 第一次仍在处理:返回 202 Accepted409 或短暂等待,取决于接口协议;
  • 请求内容不同:返回 409 Conflict

如果只保存“已处理”标记而不保存响应,重试请求只能得到模糊的“重复”,客户端无法获得订单号或支付结果。对于重要命令,通常要保存规范化请求 hash、状态和可重放响应。

8.4 本地事务和外部副作用之间仍有裂缝

以下流程不具备原子性:

  1. 数据库保存支付成功;
  2. 调用外部支付网关;
  3. 进程在两步之间崩溃。

或者反过来:

  1. 外部支付成功;
  2. 数据库事务回滚;
  3. 系统不知道支付已经发生。

幂等键只能减少重复执行,不能自动解决跨系统原子提交。常见解决方向是:

  • 外部供应商支持业务幂等号;
  • 本地保存操作状态并通过重试或对账补偿;
  • 对本地数据库和消息发布使用 Outbox;
  • 将同步命令改为“已受理/处理中”,最终通过消息或查询确认。

九、Spring Boot HTTP 端到端示例:配置、幂等和错误映射

以下示例展示一个最小的 HTTP 命令接口。它不是完整支付系统,但关键边界是可执行的。

@RestController
@RequestMapping("/payments")
class PaymentController {

    private final PaymentApplicationService service;

    PaymentController(PaymentApplicationService service) {
        this.service = service;
    }

    @PostMapping
    ResponseEntity<PaymentResponse> create(
            @RequestHeader("Idempotency-Key") String key,
            @Valid @RequestBody CreatePaymentRequest request) {

        PaymentResponse result = service.create(key, request);
        return ResponseEntity.status(HttpStatus.CREATED).body(result);
    }
}

record CreatePaymentRequest(
        @NotBlank String orderId,
        @Positive long amountCent
) {}

record PaymentResponse(String paymentId, String status) {}

服务层必须完成以下顺序:

  1. 校验幂等键格式和长度;
  2. 计算请求规范化后的 hash;
  3. 以数据库唯一键抢占处理权;
  4. 已存在时比较 hash;
  5. 首次请求执行业务;
  6. 在同一可靠状态模型中保存结果;
  7. 后续相同请求返回保存的结果。

异常映射应集中处理:

@RestControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(InvalidIdempotencyKeyException.class)
    ResponseEntity<ApiError> invalidKey(InvalidIdempotencyKeyException ex) {
        return ResponseEntity.badRequest()
                .body(new ApiError("INVALID_IDEMPOTENCY_KEY", ex.getMessage()));
    }

    @ExceptionHandler(IdempotencyConflictException.class)
    ResponseEntity<ApiError> conflict(IdempotencyConflictException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(new ApiError("IDEMPOTENCY_KEY_REUSED", ex.getMessage()));
    }

    @ExceptionHandler(TooManyRequestsException.class)
    ResponseEntity<ApiError> limited(TooManyRequestsException ex) {
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                .header("Retry-After", "1")
                .body(new ApiError("RATE_LIMITED", "请求过于频繁"));
    }

    record ApiError(String code, String message) {}
}

调用示例:

curl -i -X POST http://localhost:8080/payments \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-10001-payment-v1' \
  -d '{"orderId":"order-10001","amountCent":1999}'

第一次应返回 201 Created。使用完全相同的幂等键和请求再次发送时,应返回相同的 paymentId,而不是再创建一笔支付。若把金额改成 2999,应返回 409 Conflict,因为同一业务请求标识不能代表两个不同命令。


十、客户端治理:超时、重试、限流和熔断的组合顺序

一个合理的调用路径通常接近:

入口限流
  -> 并发舱壁
  -> 熔断快速拒绝
  -> 获取实例
  -> 连接池等待
  -> deadline
  -> 受控重试
  -> 业务结果映射

顺序不是绝对固定,但每一层必须说明保护对象:

  • 入口限流防止请求进入过多;
  • 并发舱壁防止调用占满线程或连接;
  • 熔断在已知下游持续失败时快速失败;
  • deadline 防止单次调用无限等待;
  • 重试只处理明确可重放的暂时性错误;
  • 业务映射决定是返回错误、异步受理还是降级。

配置示例可以表达意图:

payment:
  client:
    timeout: 800ms
    max-attempts: 1

resilience:
  payment:
    rate-limit-per-second: 20
    max-concurrent-calls: 10
    circuit-breaker:
      minimum-calls: 20
      failure-rate-threshold: 50
      open-duration: 5s

resilience 不是 Spring Boot 内置统一配置命名,实际项目可能使用 Resilience4j、Spring Cloud 或自研组件。配置名称和动态刷新能力必须以实际依赖版本为准,不能仅凭概念拼接属性名。

如果采用 Resilience4j 等库,通常还需要验证:

  • 统计窗口是计数窗口还是时间窗口;
  • 失败是否包括超时和业务异常;
  • minimumNumberOfCalls 是否生效;
  • 半开状态允许多少探测调用;
  • 限流拒绝是否被熔断器统计;
  • 装饰器顺序如何影响指标和异常。

这些差异会直接改变故障表现。


十一、与 Kafka、RabbitMQ 和 Outbox 的衔接

消息系统中的“至少一次投递”意味着消费者可能重复收到同一消息。确认机制只表示消息是否被 broker 认为已经处理,不等于业务数据库和外部副作用已经原子完成。

典型消费者流程:

收到消息
  -> 根据 messageId 或业务幂等键抢占去重记录
  -> 执行业务事务
  -> 提交数据库事务
  -> ACK 消息

若业务事务失败,应按消息系统语义选择:

  • 重新投递;
  • 延迟重试;
  • 死信队列;
  • 记录人工处理任务。

若先 ACK 再提交数据库,进程崩溃会导致消息丢失;若提交数据库后迟迟不 ACK,消息可能重复投递,所以消费者必须幂等。

Outbox 用于解决“数据库状态变更”和“发布消息”之间的双写问题:

本地事务:
    更新业务表
    插入 outbox_event
事务提交

后台发布器:
    读取未发布 outbox_event
    发布到 Kafka/RabbitMQ
    标记 published

发布器崩溃可能重复发布同一事件,因此消费者仍需幂等。Outbox 解决的是“事件最终可发布”,不是“事件只发布一次”。如果消费者要调用外部支付或库存系统,仍然需要外部幂等键和补偿机制。


十二、可观测性:没有关联标识就无法解释重复和超时

每次跨服务调用至少应关联:

  • trace_id:整条调用链;
  • span_id:单个操作;
  • request_id:一次请求;
  • Idempotency-Key 或业务命令号;
  • 下游目标服务和实例;
  • deadline、重试次数、熔断状态、限流结果。

日志不应只写“调用失败”,而应说明:

trace=abc request=xyz target=payment instance=10.0.4.17
deadline=800ms elapsed=812ms attempt=1 status=DEADLINE_EXCEEDED
idempotency_key=order-10001-payment-v1

指标需要区分:

  • 请求总数和成功率;
  • 429 数量;
  • 超时数量;
  • 重试次数;
  • 熔断打开次数;
  • 当前半开探测结果;
  • 每个下游实例的延迟分位数;
  • 幂等命中、冲突和处理中数量;
  • 消息重复消费和死信数量。

仅观察平均延迟会掩盖长尾。应至少关注 p95、p99,并结合连接池等待、线程池队列和数据库连接占用判断瓶颈位置。


十三、常见错误与诊断路径

错误一:把超时当成失败

客户端出现 504DEADLINE_EXCEEDED 时,服务端可能已经完成写库或扣款。诊断时应按 trace_id、业务号和幂等键查询服务端日志、数据库状态和供应商流水,而不是立即无条件重试。

错误二:每一层都重试

表现为故障期间 QPS 反而升高、线程池耗尽、下游恢复更慢。应统计每一层的 attempt,确定重试发生在哪些层,并把重试预算限制在少数明确可重放的调用上。

错误三:熔断器从未打开

可能原因包括:

  • 最小样本数尚未达到;
  • 超时异常未被计入失败;
  • 统计窗口配置过大;
  • 实际调用绕过了熔断器代理;
  • 只监控 HTTP 500,却没有统计连接失败和 deadline。

应构造可控故障验证状态转移,而不是只看配置文件。

错误四:限流只在单实例生效

当实例数扩容后,集群总吞吐超过预期,说明限流 key 或状态存储是本地的。需要明确限制是“每实例”还是“全局”,并对共享限流组件故障进行演练。

错误五:幂等表只有标记没有请求指纹

同一个 key 被错误复用后,系统可能把金额、用户或订单信息静默混淆。必须保存请求 hash 或关键业务字段,并把冲突作为显式错误。

错误六:健康检查导致重启风暴

数据库短时不可用时,所有实例同时被判定为不存活并重启。应拆分 liveness 与 readiness,并检查平台实际使用的是哪个端点、失败阈值和恢复阈值。


十四、生产验证:用故障实验证明治理有效

治理配置不能只通过单元测试。至少应验证这些路径:

  1. 实例摘除:停止一个实例,确认新请求不再持续命中它,在途请求按优雅停机策略结束;
  2. 下游超时:让下游延迟超过 deadline,确认线程、连接和请求最终释放;
  3. 响应丢失:让服务端完成业务后断开响应,重复发送相同幂等键,确认不会产生第二次副作用;
  4. 限流峰值:短时发送超过桶容量的请求,确认 429 比例和恢复速率符合公式;
  5. 熔断恢复:制造连续失败,确认 CLOSED -> OPEN -> HALF_OPEN,恢复后只允许有限探测;
  6. 配置回滚:将超时改成异常小值,再恢复旧版本,确认变更范围和回滚时间;
  7. 消息重复:同一消息投递两次,确认数据库和外部动作只产生一次有效业务结果;
  8. 进程崩溃:在 PROCESSING 状态和外部调用前后分别终止进程,检查恢复、对账和人工处理路径。

每次实验都要记录输入条件、预期状态、实际日志和恢复动作。否则“有熔断、有幂等”只是静态配置,而不是已经被证明的系统行为。

微服务治理的核心不是组件数量,而是把远程调用中的不确定性显式化:HTTP 和 gRPC 定义通信与错误语义,配置定义可变参数及其边界,服务发现定义实例生命周期,限流定义资源入口,熔断定义持续失败时的隔离,幂等定义重复执行时的状态结果。只有这些机制在超时、重试、崩溃、扩容和消息重复路径上仍然成立,服务拆分才真正具备可运维性。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。