从一次下单开始,彻底理解 Spring Cloud 与微服务:调用、容错、一致性、高并发到部署

hello,大家,我是逆境不可逃

微服务真正难的地方,不是把一个项目拆成很多个 Spring Boot 应用,而是拆开以后,怎样处理网络故障、数据一致性、重复请求、流量高峰、监控排障和持续发布。

前言:先理解问题,再记组件

刚接触 Spring Cloud 时,很容易看到一长串名词:Nacos、OpenFeign、Gateway、Sentinel、Redis、RocketMQ、Seata、Docker、Kubernetes……如果只是逐个记配置,学完以后依然不知道这些组件为什么要出现,也不知道实际项目应该把它们放在哪里。

更容易理解的方式,是从一条真实业务链路出发:

客户端 → 网关 → 订单服务 → 商品服务 → 库存服务 → 支付服务 → 消息队列 → 履约和通知服务

这篇文章就围绕“一次下单”展开。每增加一个组件,都先回答四个问题:

  1. 没有它会出现什么问题?
  2. 它解决了什么问题?
  3. 它大致怎样工作?
  4. 使用它又会增加什么成本?

文章不依赖某个具体项目,也不会堆砌完整 Demo,重点是建立一套能够迁移到真实项目中的微服务思维。


一、为什么会从单体走向微服务

1. 单体应用并不落后

假设一个商城项目最初只有一个应用:

mall-application ├── 商品模块 ├── 订单模块 ├── 库存模块 ├── 支付模块 └── 用户模块

所有模块一起编译、一起启动、一起发布,这就是单体应用。

单体的优势非常直接:

  • 本地方法调用快,调试方便;
  • 一个数据库事务就能保证多张表同时成功或失败;
  • 部署、监控和测试都比较简单;
  • 小团队沟通成本低。

因此,业务刚起步、团队人数不多时,边界清晰的模块化单体往往比微服务更合适。

2. 单体什么时候开始出现压力

随着项目和团队变大,问题可能逐渐出现:

  • 修改商品模块,却要重新发布整个商城;
  • 支付模块流量很小,商品模块流量很大,却只能一起扩容;
  • 一个模块发生内存泄漏,整个应用都受影响;
  • 多个团队修改同一个代码库,发布节奏互相阻塞;
  • 项目启动越来越慢,测试范围越来越大。

这时可以按业务能力拆分:

product-service 商品服务 order-service 订单服务 inventory-service 库存服务 payment-service 支付服务

拆分后,每个服务可以独立开发、部署和扩容。但原来的本地调用:

inventoryService.reserve(orderId,skuId,quantity);

会变成网络调用:

order-service → HTTP → inventory-service

网络调用会新增一系列问题:

  • 库存服务部署在哪台机器?
  • 有三个库存实例时应该调用哪一个?
  • 调用超时后,对方到底有没有执行成功?
  • 订单写入成功、库存扣减失败怎么办?
  • 一个服务变慢,会不会拖垮整条链路?
  • 请求经过多个服务后,怎样定位失败位置?

Spring Cloud 的主要价值,就是提供一组解决这些分布式问题的工具。


二、Spring、Spring Boot、Spring Cloud 到底是什么关系

这几个名称经常一起出现,可以把它们理解成不同层次的能力。

1. Spring Framework:基础能力

Spring Framework 提供最核心的能力:

  • IoC 和依赖注入;
  • AOP;
  • 事务管理;
  • Web MVC;
  • 数据访问抽象。

它解决的是“Java 应用应该怎样组织对象和基础代码”。

2. Spring Boot:快速构建一个应用

Spring Boot 在 Spring Framework 之上提供:

  • 自动配置;
  • Starter 依赖;
  • 内嵌 Web 服务器;
  • 外部化配置;
  • 健康检查和运行指标。

它解决的是“怎样快速构建并运行一个独立服务”。

Spring Framework = 基础零件 Spring Boot = 快速组装一台能运行的机器

3. Spring Cloud:管理一群服务

当系统中出现很多 Spring Boot 应用后,需要解决:

  • 服务注册与发现;
  • 远程调用;
  • 负载均衡;
  • 配置管理;
  • API 网关;
  • 熔断、重试和限流;
  • 分布式链路追踪。

Spring Cloud 并不是一个单独的“超级框架”,而是一组分布式系统工具的集合。

4. Spring Cloud Alibaba:一套常见实现

Spring Cloud 定义和整合了很多微服务能力,Spring Cloud Alibaba 提供了一组常见实现,例如:

能力常见组件
注册发现、配置管理Nacos
流量治理Sentinel
消息队列RocketMQ
分布式事务Seata

组件不是越多越好。是否引入某个组件,要看项目是否真的存在对应问题,以及团队能否承担它的部署、监控和维护成本。


三、服务注册、发现、负载均衡与 OpenFeign

1. 服务和实例不是同一个概念

inventory-service表示一个逻辑服务,它可以有多个运行实例:

inventory-service ├── 10.0.0.11:8080 ├── 10.0.0.12:8080 └── 10.0.0.13:8080

服务是业务身份,实例是真正运行的进程。

2. 为什么需要注册中心

如果把库存地址写死:

http://10.0.0.11:8080/inventory/reservations

实例扩容、迁移或重启后,IP 可能变化,调用方必须跟着修改配置。

注册中心相当于动态通讯录:

库存实例启动 → 向注册中心登记地址 订单服务调用库存 → 根据 inventory-service 查询实例列表 → 选择一个健康实例 → 发起 HTTP 请求

Nacos、Eureka、Consul 都能承担类似职责;在 Kubernetes 环境中,Service 和集群 DNS 也能够提供服务发现能力。

3. 负载均衡发生在哪里

订单服务拿到三个库存实例后,还要选择一个:

请求1 → 实例A 请求2 → 实例B 请求3 → 实例C

这就是负载均衡。它只能分散流量,不能保证业务请求一定成功,也不能自动解决慢实例和数据一致性问题。

4. OpenFeign 做了什么

直接手写 HTTP 请求需要拼接地址、序列化参数、处理响应。OpenFeign 可以把远程接口声明成 Java 接口:

@FeignClient(name="inventory-service")publicinterfaceInventoryClient{@PostMapping("/inventory/reservations")ReserveResultreserve(@RequestBodyReserveCommandcommand);}

业务代码看起来像普通方法调用:

ReserveResultresult=inventoryClient.reserve(command);

但必须牢记,它本质上仍然是网络调用:

Java 方法代理 → 服务发现 → 负载均衡 → HTTP 请求 → 序列化与反序列化 → 返回或异常

不能因为代码长得像本地方法,就忽略超时、重试、幂等和失败处理。


四、配置中心和 API 网关

1. 为什么需要配置中心

少量服务时,可以在每个应用中维护配置文件。服务变多后会遇到:

  • 同一个配置散落在多个仓库;
  • 测试、预发布、生产环境容易混淆;
  • 修改配置需要重新打包;
  • 不知道谁在什么时候改过配置。

配置中心用于集中管理不同服务、不同环境的配置:

order-service-dev.yaml order-service-test.yaml order-service-prod.yaml

适合外部化的内容包括:

  • 下游地址和超时时间;
  • 功能开关;
  • 限流阈值;
  • 部分业务规则。

数据库密码、私钥等敏感信息不能当作普通配置随意传播,应使用专门的密钥管理和访问控制。

动态配置也不是任何值都能随时修改。线程池、连接池、序列化格式等配置如果没有安全的刷新机制,运行时修改反而可能造成故障。

2. 网关为什么要放在最前面

没有网关时,客户端需要知道每个服务的地址:

/product → 商品服务 /order → 订单服务 /payment → 支付服务

有网关后:

客户端 → gateway → 具体服务

Spring Cloud Gateway 中最重要的三个概念是:

  • Route:请求要转发到哪里;
  • Predicate:什么请求匹配这条路由;
  • Filter:转发前后执行什么处理。
spring:cloud:gateway:routes:-id:order-serviceuri:lb://order-servicepredicates:-Path=/api/orders/**

网关适合处理:

  • 统一认证;
  • 路由;
  • 跨域;
  • 限流;
  • traceId;
  • 灰度流量标记。

网关不适合堆放订单计算、库存判断等业务逻辑,否则它会变成新的超级单体和性能瓶颈。


五、超时、重试、幂等、熔断、限流和降级

这些词经常一起出现,但解决的问题不同。

1. 超时:不允许无限等待

远程调用通常至少需要关注:

  • 连接超时:多长时间无法建立连接就放弃;
  • 读取超时:连接成功后,多长时间没有收到结果就放弃。

超时必须逐层收紧:

客户端超时 3000ms 网关超时 2500ms 订单服务总预算 2000ms 库存调用超时 500ms 数据库查询超时 200ms

如果网关 2 秒就放弃,订单服务却愿意等待库存 5 秒,客户端已经离开,服务仍在占用线程和连接。

2. 超时不等于失败

这是分布式系统最重要的认识之一:

订单服务发送“预占库存”请求 → 库存服务预占成功 → 返回响应时网络中断 → 订单服务超时

订单服务只知道“没有收到结果”,并不知道库存是否成功。因此结果可能是成功、失败,也可能未知。

未知状态需要通过业务编号查询、异步确认或定时对账处理,不能简单当成失败。

3. 重试为什么危险

如果一个下单请求被重试三次,可能创建三张订单、扣减三次库存。

重试只适用于:

  • 操作已经幂等;
  • 故障可能是暂时的;
  • 有最大次数;
  • 有退避和随机抖动;
  • 没有超过总时间预算。

参数错误、权限不足、库存不足等明确业务错误不应该重试。

4. 幂等:重复执行结果不变

创建订单可以由客户端传递幂等键:

Idempotency-Key: 8b52f1...

数据库增加唯一约束:

UNIQUEKEYuk_order_request(user_id,request_id)

第一次请求创建订单;重复请求返回原订单。数据库唯一约束是非常重要的最终防线。

5. 熔断、限流、隔离和降级的区别

手段要解决的问题
超时不无限等待
重试重新尝试短暂失败
限流不让过量请求进入
隔离一个资源耗尽不拖垮全部资源
熔断下游持续失败时暂停调用
降级暂时牺牲非核心能力

熔断器通常有三个状态:

CLOSED 正常调用 OPEN 错误过多,快速失败 HALF_OPEN 放少量请求试探恢复情况

例如推荐服务故障时,可以降级为空推荐;但支付状态不能随便返回一个伪造成功结果。降级结果必须符合业务语义。


六、微服务的数据边界与分布式事务

1. 为什么不建议共享数据库

理想的数据所有权是:

order-service → order_db inventory-service → inventory_db payment-service → payment_db

如果订单服务直接修改库存表,两个服务就无法独立演进,表结构也会变成隐含接口。

服务之间应该通过 API 或事件协作,而不是跨库随意读写。

2.@Transactional为什么管不了远程调用

下面的代码看起来在一个事务中:

@TransactionalpublicvoidcreateOrder(){orderRepository.save(order);inventoryClient.reserve(command);}

但 Spring 本地事务只能控制当前数据库连接,不能让远程 HTTP 服务自动参与同一个原子事务。

还要避免在数据库事务中长时间等待远程调用,因为它会占用数据库连接和锁。

3. 用状态机表达业务过程

订单状态可以设计为:

STOCK_CONFIRMING ├── 库存成功 → PENDING_PAYMENT └── 库存不足 → CANCELED PENDING_PAYMENT ├── 支付成功 → PAID └── 超时未支付 → CANCELED PAID → FULFILLING → COMPLETED

更新状态时检查旧状态:

UPDATEordersSETstatus='PAID'WHEREid=:orderIdANDstatus='PENDING_PAYMENT';

如果影响行数为 0,说明回调可能重复,或者订单状态已经不允许支付。

4. 最终一致性

跨服务业务常用的组合是:

本地事务 + 状态机 + 可靠消息 + 幂等 + 补偿 + 对账

例如订单超时未支付:

订单服务关闭订单 → 发送 OrderCanceled 事件 → 库存服务释放预占库存

如果释放失败,消息可以重试;多次失败进入异常队列或对账任务。

5. Saga、TCC 和 Seata 应该怎样理解

  • Saga:每个服务执行自己的本地事务,失败后执行相反的补偿动作;
  • TCC:业务显式提供 Try、Confirm、Cancel 三个阶段,控制力强但开发成本高;
  • Seata:提供多种分布式事务模式,降低部分接入成本,但不能消除锁、性能、补偿和运维代价;
  • Outbox:把业务数据与“待发送事件”写入同一本地事务,再由后台可靠投递。

对多数互联网业务,不要一开始追求跨服务强一致,而应先确认业务是否能接受“处理中”和最终一致。


七、消息队列:异步、解耦和削峰

1. 什么时候用同步调用

如果当前步骤必须立即知道结果,例如下单时判断库存是否充足,可以使用同步调用:

订单服务 → 库存服务 → 返回结果

优点是流程直观,缺点是调用方需要等待,双方运行状态耦合。

2. 什么时候用消息

支付成功后的通知、积分、履约等操作可以异步处理:

支付服务 → PaymentSucceeded 事件 ├── 订单服务 ├── 库存服务 ├── 履约服务 └── 通知服务

支付服务不需要等待短信发送完成,也不需要知道未来会增加多少消费者。

3. 消息为什么会重复

生产者发送消息后,Broker 已经保存成功,但确认响应丢失,生产者会再次发送;消费者处理成功后,在确认消费前宕机,消息也会再次投递。

因此工程上经常采用“至少一次投递”,并让业务消费幂等:

@KafkaListener(topics="payment-succeeded")publicvoidconsume(PaymentSucceededEventevent){if(processedEventRepository.exists(event.eventId())){return;}orderService.markPaid(event.orderId());processedEventRepository.save(event.eventId());}

业务更新与已消费记录应放在同一个本地事务中。

4. Outbox 解决什么问题

直接“更新数据库再发消息”存在宕机窗口:

数据库提交成功 → 应用宕机 → 消息没有发送

Outbox 做法:

同一个本地事务: 1. 支付记录更新成功 2. 插入待发送事件 后台任务: 3. 读取事件 4. 发送到 MQ 5. 标记已发送

它保证业务数据和待发送事件一起保存。发送仍可能重复,因此消费者幂等依然不能省略。

5. MQ 不是无限仓库

如果生产速度持续大于消费速度:

生产 5000 条/秒 消费 3000 条/秒 每秒积压 2000 条

MQ 只能把峰值暂时变成积压,不能凭空增加数据库处理能力。必须监控积压量、最老消息年龄、消费失败和清空积压所需时间。


八、Redis 缓存、高并发和分布式锁

1. 高并发为什么会突然雪崩

可以用一个简单关系理解:

并发请求数 ≈ QPS × 平均响应时间

1000 QPS、平均 200ms,大约有 200 个请求同时处理中;如果数据库变慢到 2 秒,同时处理的请求会增加到约 2000 个。

数据库变慢 → 连接池耗尽 → 应用线程阻塞 → 网关请求堆积 → 客户端超时重试 → 流量进一步放大

高并发设计首先要保证系统过载时能够有控制地拒绝请求,而不是让所有请求一起超时。

2. 缓存旁路模式

Productproduct=redis.get(productId);if(product==null){product=productRepository.findById(productId);redis.set(productId,product,randomTtl());}returnproduct;

更新时常见策略是先更新数据库,再删除缓存,并对删除失败进行重试或消息补偿。数据库通常仍然是事实来源。

3. 穿透、击穿和雪崩

缓存穿透:不断查询不存在的数据,每次都访问数据库。可以缓存空结果、校验参数或使用布隆过滤器。

缓存击穿:一个热点 Key 过期,大量请求同时查询数据库。可以使用主动刷新、单航班或短时间互斥重建。

缓存雪崩:大量 Key 同时过期或 Redis 整体故障。可以给 TTL 增加随机值、分批预热,并准备限流和降级方案。

4. 线程池不是越大越好

假设 Web 线程池有 500 个线程,数据库连接池只有 30 个连接:

30 个请求执行 SQL 470 个请求等待连接

增大线程池没有提高数据库能力,只是增加了等待、内存占用和超时数量。扩容应用时还要计算所有实例的总连接数。

5. Redis 分布式锁

多个应用实例中的synchronized互相不可见,需要共享的锁状态:

SET lock:order:10001 owner-id NX PX 30000
  • NX:Key 不存在时才成功;
  • PX:设置过期时间,避免实例宕机后永久死锁;
  • owner-id:标识锁的真实持有者。

释放时不能直接DEL,否则可能删掉别人后来获得的锁:

ifredis.call('GET',KEYS[1])==ARGV[1]thenreturnredis.call('DEL',KEYS[1])elsereturn0end

分布式锁只保证尽量互斥,不等于幂等。防止重复下单优先使用唯一约束,防止库存超卖优先使用条件更新:

UPDATEsku_stockSETavailable=available-:quantityWHEREsku_id=:skuIdANDavailable>=:quantity;

只有无法通过唯一约束、状态机、乐观锁或原子更新解决时,才考虑分布式锁。

6. MQ 削峰

数据库每秒只能创建 3000 个订单,活动瞬间收到 20000 个请求时,可以先进行轻量校验并写入队列:

请求 → 网关限流 → Redis资格校验 → MQ → 消费者匀速创建订单

接口此时应该返回“排队中”,而不是直接宣称订单已经创建成功。


九、安全与可观测性

1. 认证和授权不是一回事

认证 Authentication:你是谁? 授权 Authorization:你能做什么?

JWT 常用于携带用户身份和声明:

Header.Payload.Signature

网关可以验证 Token、删除客户端伪造的身份头,再注入可信用户信息。但资金、退款等重要操作仍应在业务服务内部进行授权检查。

RBAC 可以表示:

用户 → 角色 → 权限

除了用户访问,还要考虑服务之间的身份认证、最小权限、密钥轮换和敏感数据脱敏。

2. 日志、指标和 Trace 的区别

类型回答的问题
日志这一次请求具体发生了什么
指标系统整体是否异常
Trace请求经过哪些服务,慢在哪里

一次下单应贯穿以下标识:

traceId orderId paymentId eventId userId

日志中不要输出密码、Token、身份证号等敏感数据。

3. 应该监控什么

技术指标:

  • QPS;
  • P95、P99 延迟;
  • 错误率;
  • CPU、内存和 GC;
  • 线程池和连接池;
  • Redis 命中率;
  • MQ 积压。

业务指标:

  • 下单成功率;
  • 库存预占失败率;
  • 待支付订单量;
  • 支付回调延迟;
  • 自动退款数量。

Actuator、Micrometer、OpenTelemetry、Prometheus、Grafana 等工具分别帮助采集、传递、存储和展示这些信息,但工具本身不能替代合理的指标设计。


十、Docker、Kubernetes 与发布

1. Docker 解决交付一致性

传统部署常见问题是“本地能运行,服务器不能运行”,原因可能是 Java 版本、目录、参数和依赖不同。

Docker 把应用和运行环境制作成镜像:

FROM eclipse-temurin:21-jre WORKDIR /app COPY target/order-service.jar app.jar USER 10001 ENTRYPOINT ["java", "-jar", "app.jar"]

镜像是不可变模板,容器是镜像的运行实例。生产环境应避免把密钥写进镜像,不长期使用latest标签,并尽量以非 root 用户运行。

2. Kubernetes 解决实例管理

Kubernetes 的核心思想是期望状态:

spec:replicas:3

表示无论发生什么,都希望系统维持三个实例。

常见对象:

对象作用
Pod运行容器
Deployment管理副本、版本和滚动升级
Service为变化的 Pod 提供稳定入口
ConfigMap普通配置
Secret敏感配置
Ingress/Gateway外部流量入口
HPA根据指标扩缩容

3. 三种健康检查

startupProbe 应用是否启动完成 readinessProbe 当前是否适合接收流量 livenessProbe 进程是否卡死,需要重启

不要把数据库是否正常直接作为存活检查。如果数据库短暂故障导致所有 Pod 同时重启,问题会进一步扩大。

4. 滚动升级与优雅停机

滚动升级大致是:

旧 旧 旧 新 旧 旧 新 新 旧 新 新 新

新实例通过就绪检查后才接收流量,旧实例停止前还要:

停止接收新请求 → 等待正在处理的请求完成 → 停止消费者 → 关闭连接池 → 退出进程

数据库变更要采用“扩展—迁移—收缩”:先增加兼容结构,再发布新代码和迁移数据,最后删除旧结构。

5. Kubernetes 和 Nacos 是否重复

Kubernetes Service 和 DNS 已经能提供服务发现,因此纯 Kubernetes 环境不一定还需要 Nacos 注册发现。Nacos 仍可用于配置中心、混合部署或非 Kubernetes 服务。

同一种职责最好只有一个权威来源,避免两套服务发现中的实例状态不一致。


十一、用一次完整下单把所有知识串起来

第一步:网关接收请求

POST /api/orders Authorization: Bearer ... Idempotency-Key: 8b52...

网关完成认证、限流、路由和 traceId 传递。

第二步:订单服务保存商品快照

订单不能永远查询商品当前价格,否则商品涨价后历史订单也会变化。订单项需要保存下单时的名称、单价和数量快照。

第三步:创建确认库存中的订单

本地事务创建 STOCK_CONFIRMING 订单 → 提交事务 → 调用库存服务预占

不要在一个长数据库事务中等待远程服务。

第四步:库存原子预占

UPDATEsku_stockSETavailable=available-:quantity,reserved=reserved+:quantityWHEREsku_id=:skuIdANDavailable>=:quantity;

影响一行表示成功,影响零行表示库存不足。预占记录还应通过订单号唯一约束实现幂等。

第五步:处理未知结果

库存明确成功,订单进入PENDING_PAYMENT;明确不足,订单进入CANCELED;网络超时则保持STOCK_CONFIRMING,由查询和对账任务确认真实结果。

第六步:支付平台回调

支付服务需要验证签名、金额和商户订单号,再通过条件更新保证重复回调不会重复处理。

第七步:支付事件异步扩散

支付结果与 Outbox 事件在同一本地事务中保存,后台发送PaymentSucceeded

订单服务 → 改为 PAID 库存服务 → RESERVED 改为 DEDUCTED 履约服务 → 创建发货任务 通知服务 → 发送支付通知

通知失败只重试通知,不回滚已经成功的支付。

第八步:超时关闭与补偿

未支付订单超时后,通过条件更新变成CANCELED,再发送事件释放预占库存。如果订单取消和支付成功同时发生,状态机决定谁先成功,冲突进入自动退款或人工对账。

整条链路最终依赖:

本地事务 + 状态机 + 幂等 + 超时 + 消息 + 补偿 + 对账 + 可观测性

十二、DDD、服务拆分与 API 设计

1. 服务按业务能力拆分

DDD 强调先理解业务边界,再决定代码和服务边界:

商品上下文:商品、分类、价格 订单上下文:订单、订单项、状态 库存上下文:可用库存、预占记录 支付上下文:支付单、退款单

不要按 Controller、Service、DAO 拆服务,也不要一个数据库表对应一个服务。

2. 不要把数据库实体当作 API

接口应该返回稳定 DTO:

recordOrderResponse(LongorderId,Stringstatus,BigDecimalamount){}

这样数据库字段调整不会直接破坏消费者,也能避免内部字段泄露。

3. API 兼容性

通常比较安全的修改是增加可选字段;删除字段、修改类型、改变原语义属于破坏性修改,应通过新版本或迁移期处理。

服务拆分合理的标志是:能够独立开发、独立发布、拥有自己的数据,并通过清晰契约协作。如果所有服务必须一起发布,它们只是一个更难维护的分布式单体。


十三、数据库扩展、读写分离和分库分表

1. 先优化 SQL 和索引

SELECTid,total_amountFROMordersWHEREuser_id=?ANDstatus=?ORDERBYcreated_atDESCLIMIT20;

可以考虑联合索引:

CREATEINDEXidx_user_status_createdONorders(user_id,status,created_at);

索引要结合执行计划、扫描行数和真实参数分析。索引越多,写入维护成本也越高。

深分页可以在允许时改成游标分页:

SELECT*FROMordersWHEREid<:lastIdORDERBYidDESCLIMIT20;

2. 读写分离存在复制延迟

用户刚在主库创建订单,立即查询从库时,数据可能还没有同步。关键的写后读可以暂时走主库,或者接受短暂的“处理中”。

读写分离提高读能力,不能解决主库写入瓶颈。

3. 分库分表的代价

按照用户、订单或时间分片后,会出现:

  • 跨库查询和分页;
  • 全局唯一约束;
  • 数据迁移和扩容;
  • 热点分片;
  • 跨分片事务。

因此推荐顺序是:

优化 SQL 和索引 → 缓存与归档 → 缩短事务 → 读写分离 → 垂直拆库 → 最后水平分片

分布式 ID 可以使用 UUID、Snowflake 思想或号段模式,各自需要权衡索引性能、时钟、机器编号和可用性。


十四、测试、CI/CD 和灰度发布

1. 微服务测试分层

大量单元测试 较多集成测试 必要的契约测试 少量关键端到端测试
  • 单元测试验证状态机、计算和业务规则;
  • 集成测试验证真实数据库、Redis、MQ 和 HTTP 配置;
  • 契约测试保证服务升级后仍满足消费者需要的接口格式;
  • 端到端测试只覆盖下单、支付等关键主链路。

只使用 Mock 无法发现 SQL 方言、事务、序列化和真实网络配置问题;只使用端到端测试又会导致测试慢、定位困难。

2. 一条完整流水线

提交代码 → 编译 → 单元测试 → 静态检查 → 集成测试 → 构建镜像 → 安全扫描 → 部署测试环境 → 冒烟测试 → 灰度发布 → 全量发布

不同环境使用同一个镜像,只注入不同外部配置,才能保证生产运行的是已经测试过的制品。

3. 滚动、蓝绿和金丝雀发布

  • 滚动发布:逐步替换实例,资源成本较低;
  • 蓝绿发布:新旧环境同时存在,切换和回滚快,但资源成本高;
  • 金丝雀发布:先让少量流量进入新版本,根据指标逐步扩大。

灰度期间不能只看 CPU,还要看错误率、P99 延迟和下单成功率等业务指标。


十五、线上故障应该怎样排查

先确认四件事:

什么时候开始? 影响哪些接口和用户? 最近发布或修改了什么? 是错误增加,还是延迟升高?

常见现象与方向:

现象优先检查
CPU 很高热点线程、死循环、序列化、频繁 GC
CPU 不高但接口慢数据库、网络、锁、连接池等待
数据库连接池耗尽慢 SQL、长事务、连接泄漏
Web 线程池耗尽下游慢、超时过长、请求堆积
内存持续增长无界缓存、对象泄漏、大查询
MQ 积压消费变慢、失败重试、分区热点

正确顺序是:

1. 止血:回滚、限流、熔断、降级 2. 保留日志、线程、指标和 Trace 证据 3. 缩小故障范围 4. 提出假设并验证 5. 修复根因 6. 确认业务恢复 7. 复盘并改进机制

不要看到连接池耗尽就盲目扩大连接池。数据库只能承受 100 个稳定连接时,把应用连接池扩大到 1000 只会让数据库更快失去响应。


十六、什么时候不应该使用微服务

以下情况通常更适合模块化单体:

  • 团队人数少;
  • 产品还在快速试错;
  • 业务边界经常变化;
  • 所有模块总是一起发布;
  • 没有成熟的自动化测试、监控和发布能力;
  • 拆分后仍然需要共享大量数据库表。

微服务适合:

  • 多个团队需要独立发布;
  • 业务边界已经比较稳定;
  • 不同模块的流量和扩容需求差异明显;
  • 需要独立的故障和安全边界;
  • 团队具备自动化交付和可观测能力。

服务数量多不等于架构先进。能够用简单方案稳定解决业务问题,才是更成熟的架构判断。


十七、最终知识地图

服务通信

同步 HTTP:必须立即获得结果 异步消息:允许延迟,需要解耦或削峰

高可用

超时:不无限等待 重试:处理短暂失败 限流:控制入口 隔离:限制影响范围 熔断:下游故障时停止调用 降级:保护核心业务

数据一致性

单服务:本地事务 跨服务:状态机、事件、补偿和对账 最终防线:唯一约束和幂等

高并发

缓存:减少工作 扩容:增加容量 限流:控制流量 MQ:平滑峰值 降级:保护核心

可观测性

日志:发生了什么 指标:整体是否异常 Trace:哪一跳出现问题

部署交付

Docker:统一交付环境 Kubernetes:调度、自愈和扩容 CI/CD:自动验证和发布 灰度:控制新版本风险

总结:真正需要掌握的不是组件名称

学完一套微服务技术,最终应该形成以下认识:

  1. 远程调用一定可能失败;
  2. 超时不代表对方没有成功;
  3. 消息可能重复、延迟和乱序;
  4. 重试前必须先保证幂等;
  5. 数据库唯一约束是重要的最终防线;
  6. 每个服务应该拥有清晰的数据边界;
  7. 系统必须在过载时有控制地拒绝请求;
  8. 任何组件都有收益、限制和运维成本;
  9. Spring Cloud 是治理微服务的工具集合,不等于微服务本身;
  10. 微服务的收益必须大于它引入的复杂度。

如果能沿着一次下单链路,解释每一条远程调用怎样发现服务、怎样超时、怎样保证幂等、失败后怎样补偿、消息怎样可靠消费、系统怎样监控和发布,就已经建立了比单纯背配置更可靠的微服务知识体系。