分布式系统基础
分布式系统不是“把项目拆成多个服务”这么简单。真正的难点是:原来一次进程内方法调用,现在变成跨网络调用;原来一个数据库事务,现在变成多个服务各自提交;原来一个日志文件,现在分散到多台机器。
零基础可以先记住一句话:
分布式系统的核心问题不是框架,而是网络不可靠、状态分散、失败不可避免。
本页是基础导读。要深入理解微服务为什么拆、怎样确定数据边界,以及一次远程调用在框架内部怎样执行,请按顺序继续:
单机调用和远程调用的区别
单机方法调用:
inventoryService.deduct(productId, count);如果方法抛异常,调用方基本能确认失败;如果正常返回,基本能确认成功。
远程调用:
POST http://inventory-service/api/inventory/deduct可能出现:
- 请求没发出去。
- 请求发到了,下游没处理。
- 下游处理成功,但响应丢了。
- 下游处理很慢,调用方超时。
- 调用方重试,导致下游收到两次。
flowchart TD
A["订单服务发送扣减库存请求"] --> B["库存服务执行本地事务"]
B --> C["库存扣减已经提交"]
C --> D["响应在网络中丢失或超过调用方超时"]
D --> E["订单服务只知道没有按时收到结果"]
E --> F["使用业务流水查询事实并保证重试幂等"]这就是为什么分布式系统一定要有查询、重试和幂等。
分布式系统的失败类型
| 失败类型 | 例子 | 如果不处理会怎样 |
|---|---|---|
| 网络超时 | 调库存服务 3 秒无响应 | 调用线程堆积,订单接口变慢 |
| 部分失败 | 订单成功,库存失败 | 数据不一致 |
| 重复请求 | 支付回调来了两次 | 重复改状态、重复发货 |
| 乱序消息 | 先收到取消,再收到创建 | 状态倒退 |
| 下游雪崩 | 库存服务慢,订单服务线程被拖死 | 整个系统不可用 |
| 节点故障 | 某个实例宕机 | 请求失败或任务中断 |
必须设置超时
远程调用不能无限等待。
flowchart TD
A["订单服务请求库存服务"] --> B{"是否设置超时"}
B -- "没有" --> C["线程一直等待"]
C --> D["线程池耗尽"]
D --> E["订单服务也不可用"]
B -- "有" --> F["超时后快速失败"]
F --> G["重试、降级或进入补偿"]Java 11+/17+ 标准 HTTP Client 示例;JDK 8 项目请使用调用链专题中的 HttpURLConnection Demo,或沿用项目已有成熟客户端:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://inventory-service/api/deduct"))
.timeout(Duration.ofSeconds(3))
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();超时时间不是越长越好。下游已经慢了,上游还长时间等待,只会让线程堆积,把故障扩大。
重试必须配合幂等
重试可以解决临时失败,也可能制造重复写入。
错误思路:
调用支付服务超时 -> 立刻重试 -> 支付服务收到两次扣款请求正确思路:
- 每次业务操作都有幂等号。
- 下游按幂等号去重。
- 重试只会得到同一个业务结果。
支付幂等表:
create table t_payment_request (
id bigint primary key,
idempotent_key varchar(128) not null,
order_no varchar(64) not null,
status varchar(32) not null,
created_at datetime not null,
unique key uk_idempotent_key (idempotent_key)
);支付接口伪代码:
@Transactional
public PaymentResult createPayment(CreatePaymentCommand command) {
PaymentRequest existed = paymentRequestRepository.findByKey(command.idempotentKey());
if (existed != null) {
return PaymentResult.from(existed);
}
PaymentRequest request = PaymentRequest.create(command.idempotentKey(), command.orderNo());
paymentRequestRepository.save(request);
return PaymentResult.from(request);
}状态机比布尔字段更可靠
分布式业务不要只用一个 true/false 表示状态。订单、支付、退款、发券都应该设计状态机。
订单状态示例:
flowchart TD
A["WAIT_PAY 待支付"] --> B["PAID 已支付"]
A --> C["CLOSED 已关闭"]
B --> D["SHIPPED 已发货"]
D --> E["FINISHED 已完成"]
B --> F["REFUNDING 退款中"]
F --> G["REFUNDED 已退款"]状态机的好处:
- 明确哪些状态可以流转。
- 防止重复消息导致状态倒退。
- 便于排查业务卡在哪一步。
- 更新时可以带状态条件,天然支持幂等。
状态更新 SQL:
update t_order
set status = 'PAID',
paid_at = now()
where order_no = ?
and status = 'WAIT_PAY';如果更新行数是 0,说明订单可能已经支付、关闭或不存在,不能盲目继续处理。
服务调用链路
flowchart TD
A["用户请求"] --> B["网关"]
B --> C["订单服务"]
C --> D["库存服务"]
C --> E["优惠券服务"]
C --> F["支付服务"]
C --> G["发送订单事件到 MQ"]
G --> H["积分服务"]
G --> I["通知服务"]这条链路越长,失败概率越高。所以要尽量缩短同步链路,把非核心动作异步化。
下单主链路通常只保留必须立即完成的动作:
- 校验商品和价格。
- 创建订单。
- 锁定库存。
- 创建支付单或返回支付参数。
积分、短信、推荐、报表、ES 同步通常可以通过 MQ 异步处理。
同步调用和异步消息怎么选
| 对比 | 同步调用 | 异步消息 |
|---|---|---|
| 调用方是否等待结果 | 等待 | 不等待或只等待投递成功 |
| 适合 | 下单必须校验库存 | 发短信、加积分、同步 ES |
| 优点 | 结果直接,流程清晰 | 解耦、削峰、隔离故障 |
| 风险 | 下游慢会拖慢上游 | 有延迟,需要幂等和补偿 |
选择原则:
- 用户当前操作必须知道结果的,用同步。
- 不影响当前响应的副作用,用异步。
- 异步不是不管结果,必须有重试、死信、补偿和告警。
可观测性
分布式系统必须能回答四个问题:
- 请求经过了哪些服务。
- 哪个服务慢。
- 哪个服务失败。
- 影响了哪些业务数据。
最基础做法是全链路传递 traceId。
String traceId = request.getHeader("X-Trace-Id");
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString();
}
MDC.put("traceId", traceId);日志示例:
[traceId=7f3a] create order start, orderNo=O1001
[traceId=7f3a] deduct inventory success, productId=9001
[traceId=7f3a] send order created event success, topic=order.created没有 traceId,线上排查只能靠时间猜日志,效率很低。
常见设计原则
| 原则 | 解释 |
|---|---|
| 超时 | 所有远程调用必须有超时 |
| 限流 | 防止突发流量压垮服务 |
| 熔断 | 下游持续失败时快速失败 |
| 降级 | 非核心能力失败时不影响主流程 |
| 幂等 | 重复请求结果一致 |
| 补偿 | 失败后可以重新修复 |
| 对账 | 定期发现数据不一致 |
| 可观测 | 日志、指标、链路追踪齐全 |
本章小结
分布式系统不是靠某个框架变可靠的,而是靠一组工程习惯:超时、重试、幂等、状态机、异步解耦、补偿、对账、可观测。
这些基础学明白后,再学 CAP、BASE、分布式事务、Seata、TCC、Saga,才不会只停留在概念表面。
