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 适合内部强类型调用;配置决定超时和限流参数;服务发现决定请求发往哪个实例;限流控制进入系统的速率;熔断控制持续失败的传播;幂等则防止重试把一次业务变成多次扣款。
一、先建立治理模型:一次调用不是一个方法调用
在单体程序中,调用通常可以抽象为:
在微服务中,调用至少包含以下步骤:
- 根据服务名定位实例;
- 建立或复用连接;
- 写入请求;
- 等待服务端处理;
- 读取响应;
- 根据结果决定是否重试;
- 将失败、超时或重复请求映射为业务结果。
因此,远程调用的正确抽象是:
任何一个环节缺失,都可能形成“看起来正常、故障时失控”的系统。例如:
- 没有超时:线程、连接和请求上下文会长期占用;
- 只有重试没有幂等:支付可能执行两次;
- 只有熔断没有限流:本服务仍可能被大量请求压垮;
- 只有服务发现没有健康检查:请求会持续发往已失效实例;
- 只有配置中心没有版本和回滚:配置变更本身变成事故源。
二、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 超时必须分层,并且受总预算约束
一次请求的总耗时可以粗略写成:
其中:
- :客户端等待连接池或线程资源;
- :建立 TCP/TLS 连接;
- :发送请求;
- :服务端处理;
- :等待并读取响应。
上游还可能有总截止时间 。正确约束是:
如果服务 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:服务内部错误;UNAUTHENTICATED和PERMISSION_DENIED:认证与授权失败。
不能把所有异常包装成 INTERNAL,否则客户端无法区分“参数错误”“暂时不可用”和“已经处理但响应丢失”。
3.3 gRPC 的重试尤其需要幂等条件
假设客户端发送扣款请求:
- 客户端写出请求;
- 服务端完成扣款;
- 网络在响应返回前断开;
- 客户端收到
UNAVAILABLE或超时; - 客户端重试。
客户端无法从网络错误推断第 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
这里有三个重要边界:
Duration应使用800ms、2s等带单位值,避免把800的单位误解为毫秒或秒;- 配置绑定失败应在启动期暴露,而不是等第一次请求才失败;
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,存在三种不同语义:
- 新请求使用新值,正在执行的请求不受影响;
- 已创建的客户端连接和负载均衡器重建;
- 所有线程立即看到新值。
这三者并不等价。普通 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 --> [*]
发布新版本时,正确顺序通常是:
- 启动实例;
- 等待依赖连接、数据库迁移和端口准备完成;
- 通过 readiness 检查;
- 注册为可接收流量;
- 停止时先标记
DRAINING; - 等待在途请求完成;
- 再关闭服务器和连接。
如果进程先关闭再从注册中心摘除,注册信息会短暂指向死实例。若客户端缓存实例列表,则摘除也不会立即阻止所有请求,客户端必须设置合理的缓存刷新和失败剔除策略。
5.3 健康检查不应把所有依赖都放进存活检查
健康检查至少要区分:
- liveness:进程是否陷入无法恢复的状态;
- readiness:当前是否应该接收流量;
- startup:启动过程是否完成。
如果 liveness 直接检查数据库,数据库短时故障可能导致平台反复杀死并重启所有实例,形成重启风暴。数据库故障通常应先影响 readiness,而不是立刻证明 JVM 已经无法恢复。
Spring Boot Actuator 可以暴露健康、指标和应用信息端点。生产环境需要:
- 只暴露必要端点;
- 保护管理端口或路径;
- 不在环境端点中泄露密码、令牌和连接字符串;
- 让编排系统使用合适的健康组,而不是把所有监控端点都当成流量准入条件。
六、限流:限制进入速率,也限制并发资源
6.1 限流的两个不同问题
速率限流限制单位时间内允许通过的请求数,例如每秒 100 个;并发限流限制同时执行的请求数,例如最多 50 个。
设平均服务时间为 ,并发数为 ,根据 Little 定律:
其中 是系统内平均请求数, 是吞吐率, 是平均响应时间。若目标吞吐率为 req/s,平均处理时间为 ms,则理想并发量约为:
这只是平均估算。长尾延迟、连接池、线程池、数据库连接数和突发流量都会使实际安全并发低于这个值。
6.2 令牌桶的计算过程
令牌桶有容量 ,补充速率 个令牌/秒。每个请求消耗 个令牌。
设某一时刻桶中有 个令牌,经过 秒后:
若 ,请求通过并令:
否则请求被拒绝或等待。
完整算例:
- ;
- tokens/s;
- 初始有 5 个令牌;
- 连续到达 7 个请求,每个请求消耗 1 个令牌。
前 5 个请求依次消耗令牌,剩余 0;第 6 个和第 7 个请求立即被拒绝。若等待 1 秒,补入 2 个令牌,最多允许 2 个请求通过。
令牌桶允许短时突发,但长期平均速率仍受 约束。漏桶更倾向于以稳定速率出队;固定窗口实现简单,却存在窗口边界突发问题;滑动窗口更准确但需要更多状态。
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 次失败:
若阈值为 50%,则从 CLOSED 转为 OPEN。但如果只发生 1 次调用且失败率是 100%,在最小样本数为 10 时不应立刻熔断,否则偶然错误会造成误判。
“慢调用率”与失败率不同。一个请求返回 200 但耗时 5 秒,也可能占满资源,因此系统可以把超过慢调用阈值的请求计入慢调用统计。
7.2 熔断不是重试,也不是降级
- 重试:再次执行调用;
- 熔断:一段时间内不执行调用;
- 降级:调用失败后返回替代结果;
- 超时:限制一次调用最长等待时间;
- 限流:限制进入或并发执行的请求量。
错误组合会放大故障。假设调用链有 3 层,每层失败时都重试 3 次,理论上最坏请求数可能接近:
这还没有计算连接池等待和超时叠加。通常应在靠近业务入口的一层控制重试预算,并让下游调用只对明确可重试的暂时性错误重试。
7.3 熔断恢复不能只看“网络恢复”
下游端口恢复并不代表业务恢复。Half-open 探测应验证真实依赖路径,例如数据库连接、必要的业务表和供应商接口是否可用。探测请求数量过多会在恢复瞬间再次冲垮下游;数量过少则恢复判断太慢。
熔断打开后,降级结果必须符合业务语义:
- 商品推荐可以返回空列表;
- 库存查询可以返回“暂不可用”;
- 扣款不能伪造“支付成功”;
- 订单创建不能因为支付服务熔断就默认为已支付,除非业务明确采用异步待处理状态。
八、幂等:重复请求的识别、持久化和结果一致性
8.1 幂等不是简单的“查一下再插入”
一个操作满足幂等,需要定义:
其中 是系统状态, 是同一个请求。执行一次和执行多次,最终状态相同。
但是并发下的如下代码不安全:
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);
结果分三类:
- 插入成功:当前请求获得处理权;
- 主键冲突且
request_hash相同:读取原记录; - 主键冲突但 hash 不同:同一幂等键被用于不同请求,应返回
409 Conflict。
然而,PROCESSING 记录还需要处理进程崩溃。若进程在写入 PROCESSING 后宕机,后续请求不能永久等待。可以增加租约时间、恢复任务或将处理状态交给可靠消息系统,但“超时后直接重新扣款”仍然可能重复执行,必须确认下游是否也支持幂等键。
8.3 返回原结果比返回“重复请求”更实用
对于同一幂等键和同一请求内容:
- 第一次执行成功:后续请求返回相同业务结果;
- 第一次执行失败:应明确失败是否可重试;
- 第一次仍在处理:返回
202 Accepted、409或短暂等待,取决于接口协议; - 请求内容不同:返回
409 Conflict。
如果只保存“已处理”标记而不保存响应,重试请求只能得到模糊的“重复”,客户端无法获得订单号或支付结果。对于重要命令,通常要保存规范化请求 hash、状态和可重放响应。
8.4 本地事务和外部副作用之间仍有裂缝
以下流程不具备原子性:
- 数据库保存支付成功;
- 调用外部支付网关;
- 进程在两步之间崩溃。
或者反过来:
- 外部支付成功;
- 数据库事务回滚;
- 系统不知道支付已经发生。
幂等键只能减少重复执行,不能自动解决跨系统原子提交。常见解决方向是:
- 外部供应商支持业务幂等号;
- 本地保存操作状态并通过重试或对账补偿;
- 对本地数据库和消息发布使用 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) {}
服务层必须完成以下顺序:
- 校验幂等键格式和长度;
- 计算请求规范化后的 hash;
- 以数据库唯一键抢占处理权;
- 已存在时比较 hash;
- 首次请求执行业务;
- 在同一可靠状态模型中保存结果;
- 后续相同请求返回保存的结果。
异常映射应集中处理:
@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,并结合连接池等待、线程池队列和数据库连接占用判断瓶颈位置。
十三、常见错误与诊断路径
错误一:把超时当成失败
客户端出现 504 或 DEADLINE_EXCEEDED 时,服务端可能已经完成写库或扣款。诊断时应按 trace_id、业务号和幂等键查询服务端日志、数据库状态和供应商流水,而不是立即无条件重试。
错误二:每一层都重试
表现为故障期间 QPS 反而升高、线程池耗尽、下游恢复更慢。应统计每一层的 attempt,确定重试发生在哪些层,并把重试预算限制在少数明确可重放的调用上。
错误三:熔断器从未打开
可能原因包括:
- 最小样本数尚未达到;
- 超时异常未被计入失败;
- 统计窗口配置过大;
- 实际调用绕过了熔断器代理;
- 只监控 HTTP 500,却没有统计连接失败和 deadline。
应构造可控故障验证状态转移,而不是只看配置文件。
错误四:限流只在单实例生效
当实例数扩容后,集群总吞吐超过预期,说明限流 key 或状态存储是本地的。需要明确限制是“每实例”还是“全局”,并对共享限流组件故障进行演练。
错误五:幂等表只有标记没有请求指纹
同一个 key 被错误复用后,系统可能把金额、用户或订单信息静默混淆。必须保存请求 hash 或关键业务字段,并把冲突作为显式错误。
错误六:健康检查导致重启风暴
数据库短时不可用时,所有实例同时被判定为不存活并重启。应拆分 liveness 与 readiness,并检查平台实际使用的是哪个端点、失败阈值和恢复阈值。
十四、生产验证:用故障实验证明治理有效
治理配置不能只通过单元测试。至少应验证这些路径:
- 实例摘除:停止一个实例,确认新请求不再持续命中它,在途请求按优雅停机策略结束;
- 下游超时:让下游延迟超过 deadline,确认线程、连接和请求最终释放;
- 响应丢失:让服务端完成业务后断开响应,重复发送相同幂等键,确认不会产生第二次副作用;
- 限流峰值:短时发送超过桶容量的请求,确认
429比例和恢复速率符合公式; - 熔断恢复:制造连续失败,确认
CLOSED -> OPEN -> HALF_OPEN,恢复后只允许有限探测; - 配置回滚:将超时改成异常小值,再恢复旧版本,确认变更范围和回滚时间;
- 消息重复:同一消息投递两次,确认数据库和外部动作只产生一次有效业务结果;
- 进程崩溃:在
PROCESSING状态和外部调用前后分别终止进程,检查恢复、对账和人工处理路径。
每次实验都要记录输入条件、预期状态、实际日志和恢复动作。否则“有熔断、有幂等”只是静态配置,而不是已经被证明的系统行为。
微服务治理的核心不是组件数量,而是把远程调用中的不确定性显式化:HTTP 和 gRPC 定义通信与错误语义,配置定义可变参数及其边界,服务发现定义实例生命周期,限流定义资源入口,熔断定义持续失败时的隔离,幂等定义重复执行时的状态结果。只有这些机制在超时、重试、崩溃、扩容和消息重复路径上仍然成立,服务拆分才真正具备可运维性。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Spring Boot 测试:Slice、上下文缓存、MockMvc、容器和契约测试
- 下一篇:Java 消息队列工程:Kafka、RabbitMQ、确认、幂等、重试和 Outbox
- 延伸:Spring Boot 工程基础:自动配置、Starter、配置绑定、Actuator 和启动
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论