24.SpringCloud消息队列与分布式协作

Spring Cloud:消息队列、服务网关与分布式协作
微服务拆开以后,一个请求可能需要多个服务配合。订单创建后要发通知,前端需要统一的接口入口,配置变更希望及时生效,调用变慢时需要找到问题,而跨服务扣款失败时,还要把已经修改的数据恢复。
这些需求分别对应消息队列、服务网关、配置中心、链路追踪和分布式事务。下面沿着这五个问题展开,每部分先解释为什么需要它,再用一个独立的小例子把配置、调用和结果接起来。
示例采用 Java 17、Spring Boot 3.5.16、Spring Cloud 2025.0.2、Spring Cloud Alibaba 2025.0.0.0。RocketMQ 服务端为 5.3.2,RocketMQ Spring starter 为 2.3.6,BOM 管理的传统客户端为 5.3.1;Gateway 为 4.3.4;Seata 客户端和服务端均明确使用 2.2.0。
这是用于本文示例的一套独立版本组合。已有 Boot 4 项目不能只替换父工程版本就混用全部 starter;尤其要核对自动配置、Jackson 和 Servlet 相关适配。本篇的多个服务分别建立项目,不把网关、消息消费者和事务示例全部装进一个应用。
各 Spring Boot 项目共用下面的 Maven 基础配置,artifactId 按实际服务修改;每个部分再加入它需要的依赖。Java 类放在对应启动类所在包或其子包中。
xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>order</artifactId>
<version>1.0</version>
<properties>
<java.version>17</java.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2025.0.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2025.0.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- 在这里加入当前服务需要的依赖。 -->
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
1. RocketMQ:让服务通过消息异步协作
1.1 先看订单为什么需要消息队列
订单创建成功后,需要给用户发短信。如果订单接口直接调用短信接口,就得等短信服务返回;短信服务一旦变慢,用户下单也可能被拖慢。
可以把流程改成:订单服务把“订单已创建”的消息交给 RocketMQ,短信服务再读取消息发送通知。订单服务不用等待短信业务完成,短信服务临时不可用时,也可以在恢复后继续处理仍保留的消息。
RocketMQ 是分布式消息中间件。它负责接收、保存和投递消息,让生产消息的一方与处理消息的一方不必同时完成工作。
text
订单服务(生产者)→ RocketMQ → 短信服务(消费者)
↓
消息先保存,再被消费
这种做法适合下单通知、支付成功后的积分发放、操作事件记录等场景:
- 异步解耦:下单流程不直接依赖短信服务的响应速度。
- 削峰填谷:短时间涌入的消息先排队,消费者按自身处理能力逐步消化。
- 事件驱动:围绕“订单已创建”等事件,让不同服务各自完成职责。
不过,消息队列不是无限容量的缓冲区,也不能仅凭“发送成功”就认定业务一定成功。积压要监控,失败要重试,重复投递要处理,数据保留时间也要合理设置。
1.2 NameServer、Broker、Topic 分别做什么
发送消息前,先把几个名称对应起来:
| 名称 | 在订单通知例子中的作用 |
|---|---|
| Producer | 订单服务,产生并发送消息 |
| Consumer | 短信服务,读取并处理消息 |
| NameServer | 保存 Broker 的路由信息,帮助客户端找到消息应发到哪里 |
| Broker | 接收、保存和投递消息的服务进程 |
| Topic | 消息的主题,例如 study24-order |
| MessageQueue | Topic 下的队列,消息分布在不同队列中 |
| ConsumerGroup | 一组执行同类消费任务的消费者,例如短信消费者组 |
| Offset | 队列位置;消费进度用来记录已经处理到哪里 |
| Tag | Topic 内的进一步分类,例如 created |
| Keys | 消息的业务检索键,例如订单号 |
NameServer 不保存业务消息,也不代替 Broker 转发消息。生产者先获取路由,再直接连接 Broker;所以 NameServer 能访问,并不代表消息一定能发送成功。
同一 Topic 可以被多个消费者组订阅。短信组和积分组可以分别处理同一条“订单已创建”消息;同一个组内的多个实例,在默认集群消费模式下分担消息。传统 PushConsumer 的分配单位主要是队列,实例数超过可分配队列数时,部分实例可能空闲。
1.3 在 Docker 中启动 RocketMQ
本地学习先使用一个 NameServer、一个 Broker 和一个 Dashboard。单 Broker 便于理解流程,但不具备生产集群的高可用能力。
下面使用单独的 Compose 项目。先准备 broker.conf,内容为:
properties
brokerClusterName = StudyCluster
brokerName = broker-a
brokerId = 0
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
autoCreateTopicEnable = false
brokerIP1 = host.docker.internal
listenPort = 11911
brokerIP1 是 Broker 告诉客户端“请连接我这个地址”的配置。本例中 Java 应用运行在 Windows,Broker 运行在 Docker Desktop,host.docker.internal 必须能在两边解析到宿主机,并通过映射端口到达 Broker。先用 PowerShell 的 Resolve-DnsName host.docker.internal 检查;如果环境不支持这个名称,应换成 Java 应用与控制台都能访问的宿主机 IP。
这里 RocketMQ 的 Broker 地址允许使用可解析的主机名,不能照搬 Sentinel 控制台要求数值 IP 的限制。Linux Docker 环境还要按实际网络配置宿主机地址;若应用也在同一个 Docker 网络,通常应使用该网络中可达的 Broker 地址。
11911 是本例主动选择的 Broker 端口,避免与已有的 10911 冲突。brokerRole 和 flushDiskType 是当前单节点示例的设置;异步刷盘并不意味着绝对不会丢消息,生产环境需要结合刷盘、复制、故障恢复和业务补偿设计。
再在同一目录准备 Compose 配置:
yaml
services:
volume-init:
image: apache/rocketmq:5.3.2
user: "0:0"
command: ["sh", "-c", "chown -R 3000:3000 /init/namesrv-logs /init/broker-logs /init/broker-store"]
volumes:
- namesrv-logs:/init/namesrv-logs
- broker-logs:/init/broker-logs
- broker-store:/init/broker-store
namesrv:
image: apache/rocketmq:5.3.2
command: ["sh", "mqnamesrv"]
environment:
JAVA_OPT_EXT: "-Xms128m -Xmx128m -Xmn64m"
ports:
- "127.0.0.1:11976:9876"
depends_on:
volume-init:
condition: service_completed_successfully
volumes:
- namesrv-logs:/home/rocketmq/logs/rocketmqlogs
broker:
image: apache/rocketmq:5.3.2
command: ["sh", "mqbroker", "-n", "namesrv:9876", "-c", "/home/rocketmq/rocketmq-5.3.2/conf/broker.conf"]
environment:
JAVA_OPT_EXT: "-Xms256m -Xmx256m -Xmn128m"
ports:
- "11911:11911"
- "11909:11909"
- "11912:11912"
depends_on:
namesrv:
condition: service_started
volume-init:
condition: service_completed_successfully
volumes:
- ./broker.conf:/home/rocketmq/rocketmq-5.3.2/conf/broker.conf:ro
- broker-logs:/home/rocketmq/logs/rocketmqlogs
- broker-store:/home/rocketmq/store
dashboard:
image: apacherocketmq/rocketmq-dashboard:1.0.0
environment:
JAVA_OPTS: "-Drocketmq.config.namesrvAddr=namesrv:9876 -Xms128m -Xmx128m"
ports:
- "127.0.0.1:18093:8080"
depends_on:
- namesrv
volumes:
namesrv-logs:
broker-logs:
broker-store:
几个容易混淆的端口需要对应到用途:
| 宿主机端口 | 用途 |
|---|---|
| 11976 | 映射 NameServer 的 9876,供 Windows 客户端获取路由 |
| 11911 | Broker 的业务通信端口,发送和消费消息都需要它 |
| 11909 | Broker VIP 通道端口,为监听端口减 2 |
| 11912 | Broker HA 通道端口,为监听端口加 1;单主例子不演示主从复制 |
| 18093 | 映射 Dashboard 的 8080,用浏览器访问 |
端口冲突时不能只改 Compose 左边的端口:客户端会使用 Broker 注册的地址和端口。如果映射端口与 Broker 对外公布的端口不对应,就会出现“能查路由,却连不上 Broker”的情况。
在配置目录执行:
powershell
docker compose -p study24-mq -f compose-mq.yaml config --quiet
docker compose -p study24-mq -f compose-mq.yaml up -d
docker compose -p study24-mq -f compose-mq.yaml ps -a
volume-init 以 root 身份给新命名卷设置目录权限,完成后正常退出,状态应为 Exited (0)。它只做初始化,不承担消息收发,所以不用一直运行。NameServer、Broker、Dashboard 才是持续运行的服务。
原图中的 rocketmq-volume-init、rocketmq-namesrv、rocketmq-broker、rocketmq-dashboard 对应的就是这四项。Docker Desktop 把它们归到同一个 Compose 项目下,不代表存在一个额外的“RocketMQ 总进程”。
depends_on 保证启动次序,不等于 Broker 已经可用。继续检查日志和路由:
powershell
docker compose -p study24-mq -f compose-mq.yaml logs --tail 30 namesrv broker
docker compose -p study24-mq -f compose-mq.yaml exec broker sh mqadmin clusterList -n namesrv:9876
能看到 StudyCluster、broker-a 和 host.docker.internal:11911,说明 Broker 已向 NameServer 注册。浏览器再打开 http://localhost:18093,检查控制台能看到对应集群。
1.4 控制台有哪些页面,先看什么
Dashboard 是管理页面,关闭它并不等于关闭消息服务。开始时重点看下面几项:
- Cluster:查看 Broker 是否在线、属于哪个集群。
- Topic:查看主题及队列配置,本例关注
study24-basic、study24-order。 - Consumer:查看消费者组是否连接、订阅关系和消费积压。
- Message:按主题、时间或业务 Key 等条件查询消息,帮助排查发送与消费问题。
- Producer:查看生产者连接等信息;只有当前有连接的生产者才适合在这里观察。
不同版本的页面名称和布局会有差异。判断是否跑通时,先确认集群和 Topic,再看消费者和消息,不要只凭首页能打开就认定收发正常。
本例关闭自动创建 Topic,先明确创建两个学习主题:
powershell
docker compose -p study24-mq -f compose-mq.yaml exec broker sh mqadmin updateTopic -n namesrv:9876 -c StudyCluster -t study24-basic -r 1 -w 1
docker compose -p study24-mq -f compose-mq.yaml exec broker sh mqadmin updateTopic -n namesrv:9876 -c StudyCluster -t study24-order -r 1 -w 1
两个 Topic 都先用一个读队列、一个写队列,方便观察消息位置。队列数影响并行能力,不能因此把单队列当作所有业务的通用配置。
1.5 先单独测试发送消息
先建立一个普通 Java 测试项目,用 Maven 管理依赖:
xml
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.3.6</version>
</dependency>
这个 starter 包含下面原生客户端需要的类型。普通 Java 示例不依赖 Spring 容器;仅练习原生 API 时,也可以单独使用匹配版本的 rocketmq-client。这里先沿用同一个 starter,便于与整合示例衔接。
下面两个 main 直接在 IDE 中分别运行,不需要 @SpringBootApplication。普通 Java 测试项目也不需要 Spring Boot 打包插件;如果复制了开头的基础 POM,这个项目去掉对应的打包插件即可,避免它在两个入口之间无法确定应该打包哪一个。
发送类延续创建 Producer、设置 NameServer、启动、发送、关闭的过程:
java
package com.example.rawmq;
import java.nio.charset.StandardCharsets;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;
public class SendMessage {
public static void main(String[] args) throws Exception {
String namesrvAddr = args.length > 0 ? args[0]
: System.getenv().getOrDefault("ROCKETMQ_NAMESRV_ADDR", "localhost:11976");
DefaultMQProducer producer = new DefaultMQProducer("study24-producer");
producer.setNamesrvAddr(namesrvAddr);
producer.start();
try {
Message message = new Message("study24-basic", "myTag",
"Test MQ".getBytes(StandardCharsets.UTF_8));
// Keys 是业务检索键,和 Tag 的订阅过滤作用不同。
message.setKeys("test-001");
SendResult result = producer.send(message, 5000L);
System.out.println(result);
} finally {
producer.shutdown();
}
}
}
在 IDE 中运行 SendMessage.main()。不传参数时读取环境变量,未设置时使用 localhost:11976;也可以传入自己的 NameServer 地址作为第一个参数。发送结束后 finally 关闭生产者,避免留下网络线程。
一次结果类似:
text
SendResult [sendStatus=SEND_OK,
msgId=C0A81501DAC06BC7C05478A895500000,
offsetMsgId=C0A841FE00002E8700000000000002A8,
messageQueue=MessageQueue [topic=study24-basic, brokerName=broker-a, queueId=0],
queueOffset=0]
这些字段分别表示:
sendStatus=SEND_OK:按当前 Broker 刷盘、复制配置,这次发送得到成功确认;它不表示短信等下游业务已经执行。msgId:客户端生成的消息标识,便于追踪和查询,不能拿来代替订单业务上的幂等键。offsetMsgId:Broker 存储位置相关的消息标识;这里不是消费者组的消费进度。topic:消息属于哪个主题。brokerName:本次写入哪个 Broker。queueId:写入 Topic 下的哪条队列。queueOffset:该消息在这条队列中的逻辑位置,从 0 开始计数。
消息 ID 和位置会随运行改变。多队列时,不同队列各有自己的 Offset,不能用某一条队列的 Offset 当整个 Topic 的全局顺序。
发送超时也不能直接理解成“Broker 一定没收到”。可能消息已经写入,但确认没有及时返回;此时重发可能产生重复,业务上仍需要唯一事件编号和幂等处理。
1.6 再单独测试读取消息
现在把读取端单独运行,让它只负责接收刚才的 Topic:
java
package com.example.rawmq;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
public class ReceiveMessage {
public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("study24-basic-reader");
consumer.setNamesrvAddr(args.length > 0 ? args[0] : "localhost:11976");
// 仅对没有已有消费进度的新组,从当前仍保留的最早消息开始读。
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
consumer.subscribe("study24-basic", "myTag");
consumer.registerMessageListener((MessageListenerConcurrently) (messages, context) -> {
for (var message : messages) {
System.out.println("收到:" + new String(message.getBody(), StandardCharsets.UTF_8));
}
// 完成业务处理后才确认成功;失败时应返回 RECONSUME_LATER。
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});
consumer.start();
Runtime.getRuntime().addShutdownHook(new Thread(consumer::shutdown));
new CountDownLatch(1).await(); // 保持进程运行,等待消息。
}
}
运行 ReceiveMessage.main() 后,首次使用这个消费者组时,可以读到仍保留的历史消息:
text
收到:Test MQ
保持消费者运行,再运行一次发送端,读取端会继续打印新消息。发送与读取是两个独立进程,停止一个不等于停止另一个。
这里有两个容易误会的地方。首先,CONSUME_FROM_FIRST_OFFSET 只影响没有已有消费进度的新组;老组重启通常会接着已保存的进度消费,不会每次从头来。其次,成功返回应放在业务处理成功之后;如果业务失败,可以返回 RECONSUME_LATER 请求稍后重投,不能捕获异常后无条件确认成功。
重试可能带来重复消息,因此“投递一次”不能直接等同于“业务执行一次”。消费端通常根据订单号、事件编号等业务标识去重;在数据库中用唯一约束配合事务,比只用一个进程内 Set 更可靠。
1.7 Spring Boot 整合:订单服务发送消息
有了原生 API 的收发过程,再看 Spring Boot 如何减少初始化代码。RocketMQTemplate 是 starter 提供的发送入口,配置好生产者后,Spring 会创建它,业务类直接注入即可。
建立订单服务,依赖为:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.3.6</version>
</dependency>
先定义启动类:
java
package com.example.order;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
配置订单服务端口和消息生产者:
yaml
server:
port: 18084
spring:
application:
name: order-service
rocketmq:
name-server: 127.0.0.1:11976
producer:
group: study24-order-producer
send-message-timeout: 5000
再提供一个发送订单通知的接口:
java
package com.example.order;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class OrderController {
private final RocketMQTemplate template;
public OrderController(RocketMQTemplate template) {
this.template = template;
}
@PostMapping("/orders/notify")
public String notifySms(@RequestParam String orderNo) {
// Topic:Tag。本例只模拟下单后发通知,尚未写订单数据库。
var result = template.syncSend("study24-order:created", orderNo);
return "消息已发送:" + result.getSendStatus() + ",订单号:" + orderNo;
}
}
syncSend 表示发送者等待 Broker 的确认,不表示订单接口等短信处理完才返回。消息体是订单号,study24-order:created 把 Topic 和 Tag 一起写出。
这个接口只演示“订单已创建后,怎么发消息”,没有模拟完整下单和数据库持久化。真实订单写入与消息发送的原子性,需要事务消息或本地消息表等方案,不能因为二者写在同一个 Java 方法里就认定它们是一个事务。
1.8 短信服务接收订单消息
再建立一个独立的短信服务,只需要 RocketMQ starter。它没有 HTTP 接口,可配置为非 Web 应用:
yaml
spring:
application:
name: sms-service
main:
web-application-type: none
rocketmq:
name-server: 127.0.0.1:11976
启动类与订单服务结构相同,但包名改为 com.example.sms。监听类如下:
java
package com.example.sms;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
@Component
@RocketMQMessageListener(
topic = "study24-order",
selectorExpression = "created",
consumerGroup = "study24-sms-consumer")
public class SmsListener implements RocketMQListener<String> {
@Override
public void onMessage(String orderNo) {
// 控制台输出代替真实短信平台调用,不会发送真实短信。
System.out.println("短信通知:订单 " + orderNo + " 已创建");
}
}
@RocketMQMessageListener 说明监听哪个 Topic、Tag,以及属于哪个消费者组;RocketMQListener<String> 说明把消息体转换为字符串后交给 onMessage。
先启动短信服务,再启动订单服务,发送请求:
powershell
curl.exe -X POST "http://localhost:18084/orders/notify?orderNo=ORDER-1001"
订单接口返回类似:
text
消息已发送:SEND_OK,订单号:ORDER-1001
短信服务的控制台随后打印:
text
短信通知:订单 ORDER-1001 已创建
这条输出说明消息经过 Broker 到达了另一个服务,并不代表调用了真实短信平台。本例不会发送真实短信。
要观察异步解耦,可以先运行短信服务,让这个消费者组正常订阅一次,再停掉短信服务。订单端继续发送一条新的通知,恢复短信服务后,它会按保存的进度处理积压。Broker 必须保留这段时间内的消息;积压超过保存期限后不能保证还能找回。
1.9 发送成功之外,还要知道哪些能力
普通消息跑通后,再把常见能力与适用条件对应起来:
顺序消息。 例如同一订单的“创建、支付、取消”需要按顺序处理。发送端要按订单号把相关消息放入同一队列,消费端也要采用对应的顺序消费方式。不同队列之间没有天然全局顺序;只给消息打上同一个 Tag 不能保证顺序。
延迟消息。 例如下单一段时间后检查是否未支付,再决定关闭订单。传统客户端可使用延迟级别,5.x 新协议还支持不同的定时消息能力;要按客户端与 Broker 版本选择。到了触发时间仍应查询订单状态,不能看到延迟消息就直接关闭已支付订单。
事务消息。 用于协调“本地业务事务”和“发送消息”,例如本地订单提交后,订单事件才应对下游可见。它通过半消息、本地事务执行、提交/回滚和事务状态回查工作,不是把所有消费者的数据库事务合成一个大事务。
重试与死信。 消费异常时按策略重投;多次仍失败的消息可能进入死信队列,方便人工排查或补偿。不能让异常被吞掉后误报成功,也不能把无限重试当作修复错误数据的办法。
消息回溯。 在消息仍被保存的前提下,通过重置消费进度等方式重新消费历史数据。回溯会重复触发业务处理,因此要先确认幂等与副作用。
Proxy、gRPC 和 LiteTopic。 5.x 的 Proxy 是面向新客户端接入的服务端组件之一,Java 5.x SDK 可以通过 gRPC Endpoint 接入;本文使用传统 DefaultMQProducer 与 RocketMQTemplate,直接走 NameServer/Broker,不需要为了收发例子额外启动 Proxy。LiteTopic 是较新版本面向大量轻量主题、会话等场景的能力,AI Agent 会话是其中一种应用方向;不能理解成所有 5.x 版本都具备它。本文固定的 5.3.2 环境不演示 LiteTopic,也不能给普通 Topic 直接套上 LiteTopic 的接口。
这些能力解决的问题不同。先判断是需要异步通知、顺序处理还是数据一致性,再选择对应机制,不能统一简化成“用了 MQ 就不会堵、不会丢”。
1.10 停止与再次启动
实验结束后,先在 IDE 中停止发送端、读取端和 Spring Boot 消费服务,再停止这套 Compose 环境:
powershell
docker compose -p study24-mq -f compose-mq.yaml down
这个命令停止并移除当前项目的容器与默认网络,命名卷会保留。下次在同一目录、使用相同项目名执行 up -d,Broker 可以继续使用卷里的消息和消费进度;这也解释了为什么再次启动老消费者组不会自动从头读取。卷、磁盘或保留期限造成的数据丢失,不会被重新启动自动修复。
2. Spring Cloud Gateway:把服务放到统一入口后面
2.1 为什么需要服务网关
现在订单、用户、账户已经是不同服务。如果让前端分别记住每个服务的主机和端口,服务一扩容、地址一变,前端就得跟着调整。登录检查、跨域处理、限流等逻辑也容易重复写。
网关把它们放到统一入口后面。前端访问 /api/users/1,网关按规则找到用户服务,再把响应交回前端。
text
浏览器 → Gateway → 用户服务
→ 订单服务
→ 其他服务
Gateway 是 Spring Cloud 提供的网关项目。早期 Spring Cloud 常用 Netflix Zuul,Gateway 是另一套实现;不能脱离具体架构和负载就笼统认定任何场景下都更快。Spring Cloud Alibaba 可以与它集成,Nacos 负责提供实例信息,Gateway 负责处理入口请求。
本文选择 Gateway Server WebFlux,基于 WebFlux 和 Reactor Netty。这个应用里不要再加入 spring-boot-starter-web 来启动 Spring MVC,也不按传统 Servlet WAR 部署,而是运行独立 JAR。
这项限制针对本文选择的 WebFlux 版本。Gateway 也有 Server Web MVC 分支,不能把“所有 Gateway 都不支持 Servlet”当作统一结论。
2.2 先认识路由、断言和过滤器
Gateway 配置里最常见的三个名称是:
- Route,路由:一条转发规则,说明请求最终发向哪个地址。
- Predicate,断言:匹配条件,决定某个请求是否属于这条路由,例如路径以
/api/users/开头。 - Filter,过滤器:匹配成功后执行的处理,例如去掉路径前缀、添加请求头或限流。
用本例说明:Path=/api/users/** 决定“哪些请求走这里”,uri 决定“发给谁”,StripPrefix=1 决定“转发前怎样改路径”。
2.3 先准备一个最简单的用户服务
建立用户服务,先只加 Web 依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
启动类与前面的 Application 结构相同,包名改为 com.example.provider。先把 server.port 配为 18085,提供一个固定用户,暂时不接数据库。
UserVO 是返回给调用方的展示对象:
java
package com.example.provider;
public record UserVO(Long id, String name) {
}
Controller 接收用户编号,再把展示对象作为 JSON 返回:
java
package com.example.provider;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserController {
@GetMapping("/users/{id}")
public UserVO find(@PathVariable Long id) {
return new UserVO(id, "张三");
}
}
yaml
server:
port: 18085
spring:
application:
name: study24-user-service
启动后先直连检查:
powershell
curl.exe http://localhost:18085/users/1
json
{"id":1,"name":"张三"}
先确认服务本身能返回数据,再接网关,出错时更容易判断是业务服务还是路由配置的问题。
2.4 用固定地址跑通第一条路由
建立独立的网关项目,加入:
xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
</dependency>
网关启动类仍使用 @SpringBootApplication,包名为 com.example.gateway。它不需要自己编写用户 Controller,按配置转发请求即可:
yaml
server:
port: 18086
spring:
application:
name: study24-gateway
cloud:
gateway:
server:
webflux:
routes:
- id: user-route
uri: http://127.0.0.1:18085
predicates:
- Path=/api/users/**
filters:
- StripPrefix=1
当前 Gateway 4.3.4 使用 spring.cloud.gateway.server.webflux 前缀。旧教程常见 spring.cloud.gateway.routes,不要把两代属性路径随意混用。
启动网关,再访问:
powershell
curl.exe http://localhost:18086/api/users/1
仍然得到 {"id":1,"name":"张三"}。请求在网关中这样变化:
text
收到:GET /api/users/1
匹配:Path=/api/users/**
处理:StripPrefix=1,去掉第一个路径段 api
转发:GET http://127.0.0.1:18085/users/1
返回:用户服务的 JSON
StripPrefix 的数字按路径段计算,不是删除几个字符。如果忘了去掉 /api,用户服务收到 /api/users/1,因为没有这个映射,就可能返回 404。
2.5 与 Nacos 联动:用服务名代替固定地址
固定地址已经能转发了。如果用户服务增加实例,网关不应再维护一串 IP,可以让 Nacos 提供实例清单,再由 LoadBalancer 选择一个实例。
用户服务增加 Nacos Discovery 依赖;网关则增加 Discovery 与 LoadBalancer 两个依赖:
xml
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
用户服务的配置改为:
yaml
server:
port: 18085
spring:
application:
name: study24-user-service
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
# 本例的两个 Java 应用都运行在 Windows,仅用于本机调用。
ip: 127.0.0.1
网关保留原路由结构,在 spring.cloud 下加入同样的 Nacos Discovery 地址,并把 uri 替换为:
yaml
uri: lb://study24-user-service
lb:// 表示按服务名查找并选择实例,不是一个能直接在浏览器里打开的 HTTP 地址。调用过程变成:
text
Gateway → Nacos 中的 study24-user-service 实例列表
→ LoadBalancer 选择一个实例
→ HTTP 转发 /users/1
先启动 Nacos,再启动用户服务和网关。在 Nacos 控制台确认用户服务已注册,然后继续访问网关的 /api/users/1,返回结果应与固定地址时相同。
注册中心地址、namespace 和 group 要对应一致。ip: 127.0.0.1 只适合本机 Java 进程之间访问;如果网关在容器中,就必须注册它能访问的用户服务地址。找不到实例时常见返回是 503,路径不匹配则通常是 404,二者排查方向不同。
这里采用显式路由,URL 与服务名之间的关系一眼可见。无需为了 Nacos 联动就开启“所有服务自动生成路由”,也不应无意间把内部管理服务暴露给前端。
2.6 用 Redis 令牌桶限制入口流量
现在用户接口能统一访问了。如果一个来源短时间请求过多,可以在网关先限制流量,减少压力传到下游。
本例使用 Gateway 内置的 RequestRateLimiter 与 Redis 令牌桶。桶里有令牌时请求通过,用完后返回 HTTP 429;Redis 共享状态,让多个网关实例可以针对同一个限流键协作计数。
先单独启动一个学习用 Redis,避免修改已有 Redis 的密码和数据:
powershell
docker run -d --name study24-redis -p 127.0.0.1:16379:6379 redis:7.4
网关增加响应式 Redis 依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
还要告诉网关“按谁来限流”。KeyResolver 负责为当前请求计算限流键,例如来源 IP 或已经认证的用户编号。本地直连演示先按 IP:
java
package com.example.gateway;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Mono;
@Configuration
public class RateLimitConfig {
@Bean
public KeyResolver ipKeyResolver() {
// 本机直连测试,按连接来源 IP 共用一个令牌桶。
return exchange -> Mono.justOrEmpty(exchange.getRequest().getRemoteAddress())
.map(address -> address.getAddress().getHostAddress());
}
}
这个方法返回的不是最终 HTTP 响应,而是 Mono<String> 形式的限流键。Gateway 在响应式流程中取到它,再用它访问 Redis 中的令牌桶。
网关完整配置如下,替换前面的简化版本:
yaml
server:
port: 18086
spring:
application:
name: study24-gateway
data:
redis:
host: 127.0.0.1
port: 16379
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
ip: 127.0.0.1
gateway:
server:
webflux:
routes:
- id: user-route
uri: lb://study24-user-service
predicates:
- Path=/api/users/**
filters:
- StripPrefix=1
- name: RequestRateLimiter
args:
key-resolver: "#{@ipKeyResolver}"
redis-rate-limiter.replenishRate: 1
redis-rate-limiter.burstCapacity: 2
redis-rate-limiter.requestedTokens: 1
关键参数可以按“每秒补多少、最多存多少、每次花多少”来理解:
| 参数 | 本例数值 | 含义 |
|---|---|---|
replenishRate |
1 | 每秒补充 1 个令牌 |
burstCapacity |
2 | 桶最多保存 2 个令牌,允许短时突发 |
requestedTokens |
1 | 每个请求消耗 1 个令牌 |
key-resolver |
ipKeyResolver |
使用前面声明的 Bean 计算限流键 |
桶满时连续发出多个请求,前几个可能成功,后续会被拒绝;等待补充令牌后又能继续访问。由于请求耗时和 Redis 计时边界会影响结果,不要断言任何机器上都严格只有前两次成功。
快速检查:
powershell
1..8 | ForEach-Object {
curl.exe -s -o NUL -w "%{http_code}
" http://localhost:18086/api/users/1
}
应观察到成功的 200 和拒绝的 429;停一会儿再请求,又会恢复 200。本次运行确实观察到了这个过程。
如果网关前面还有 Nginx,getRemoteAddress() 可能只能看到代理服务器的 IP。实际项目常按认证后的用户编号限流,或在可信代理边界内解析来源;不能直接相信任意客户端提交的 X-Forwarded-For、userId。所有用户共用一个键,会变成大家抢同一个桶;空键默认可能被拒绝,也应与 429 区分。
网关的入口限流不能替代服务内部保护。调用方可能绕过网关,慢 SQL、线程池和下游故障也仍需在服务内处理。是否引入 Sentinel 网关适配,是另一种方案选择,不是把普通 MVC 的拦截器配置直接复制到 WebFlux 网关。
2.7 网关里适合放什么,不适合放什么
网关适合统一路由、认证入口检查、限流和跨域策略。用户信息的业务查询、订单金额计算等逻辑仍放在对应服务,不把网关逐渐写成所有业务的集合。
WebFlux 网关中不要直接执行 JDBC 查询、Thread.sleep 等阻塞操作;这些操作会占用处理请求的事件线程。如果需要网关调用外部系统,应选择与响应式执行模型匹配的客户端与处理方式。
前端通过网关访问后,跨域设置通常集中在网关统一处理,避免下游与网关重复添加冲突的 CORS 响应头。内部服务也仍要有合适的访问边界,不能仅凭“前面有网关”就忽略内部权限检查。
3. Nacos 配置中心:修改配置,不再逐台改文件
3.1 服务注册与配置管理不是一回事
前面用 Nacos 找到用户服务地址,这属于服务注册与发现。现在换一个需求:页面显示的店铺公告变更了,希望更新配置后服务能读到新公告,而不必逐台修改 application.yaml 并重新部署。
Nacos 配置中心把这些配置集中保存,应用按约定加载。配置变更时,支持刷新机制的属性和 Bean 可以接收到新值。
text
Nacos 配置 → 应用加载 → 业务读取属性
修改后 → 变更通知 → 刷新支持动态更新的对象
Discovery starter 不会自动变成 Config starter;“服务已注册成功”也不代表应用已经导入了远程配置。
3.2 先在控制台创建一份配置
使用已有的 Nacos 3.1.1 学习环境。浏览器访问 http://127.0.0.1:8080/index.html;这里是控制台端口,客户端配置仍使用 127.0.0.1:8848,3.x 不能把两者混淆。客户端还需能访问相应 gRPC 端口,默认是 9848。
页面要求登录时,使用初始化该环境时设置的管理员账号;不能假设所有 Nacos 3.x 环境都自带同一组默认密码。
进入“配置管理 → 配置列表”,在 public 命名空间创建:
| 项目 | 值 |
|---|---|
| Data ID | study24-shop.yaml |
| Group | STUDY24 |
| 配置格式 | YAML |
配置内容:
yaml
shop:
notice: 今日下单免运费
这三个定位信息需要一起理解。namespace 用来隔离环境,group 用来分组,Data ID 用来标识一份配置。应用必须导入同一位置,才能拿到这份内容。
使用自定义命名空间时,在客户端填写的是 namespace ID,不只是控制台展示名称。开发、测试、生产配置应隔离,不把同名 Data ID 当作已经隔离的依据。
3.3 应用怎样加载这份配置
建立独立的配置演示服务,加入 Web 和 Nacos Config:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
启动类放在 com.example.config 包中。配置如下:
yaml
server:
port: 18087
spring:
application:
name: study24-config
config:
import:
- "nacos:study24-shop.yaml?group=STUDY24&refreshEnabled=true"
cloud:
nacos:
config:
server-addr: 127.0.0.1:8848
spring.config.import 显式说明要导入哪份远程配置。group=STUDY24 对应控制台分组,refreshEnabled=true 允许监听变更。
这里使用当前版本的 Config Data 导入方式,不需要为了加载这一份配置另建旧式 bootstrap.yaml。本例未加 optional:,因为缺少核心配置时,希望启动能明确失败。若业务确实允许远程配置缺失,可使用 optional:nacos:... 并提供合理默认值,但不能把它当成掩盖连接错误的办法。
接下来用接口读取公告:
java
package com.example.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RefreshScope
public class NoticeController {
@Value("${shop.notice:暂无公告}")
private String notice;
@GetMapping("/notice")
public String notice() {
return notice;
}
}
@Value 把配置值注入字段,冒号后的内容是默认值。@RefreshScope 让这个 Bean 支持在配置变更后重新创建目标对象,后续请求就能取得新值。
启动后访问:
powershell
curl.exe http://localhost:18087/notice
text
今日下单免运费
3.4 在控制台修改,然后观察动态刷新
现在回到同一份配置,把内容改成:
yaml
shop:
notice: 周末满100减10
点击“发布”,在内容比较窗口核对当前值和原始值,再点击“确认发布”。成功后等变更通知到达,再请求 /notice:
text
周末满100减10
这个过程没有重启应用。Nacos 保存新配置后,客户端监听到变化,Spring 更新环境并触发支持刷新的 Bean 更新。
刷新需要传播时间,不应要求发布按钮按下的同一瞬间所有实例都已经更新。可以先看应用日志里是否出现收到 Data ID 变更、刷新 shop.notice 等记录,再检查接口结果。
这里演示的是一个适合动态更新的业务文案,不代表所有配置都能安全热更新。端口、连接池、线程池、消息订阅等通常涉及对象重建和资源生命周期,要按对应组件的支持情况处理。配置写在 static 字段、启动时拷贝到其他缓存里,或 Bean 不支持刷新时,也不能期待只改 Nacos 就自动生效。
3.5 环境、权限与配置优先级
实际项目通常按 namespace 或明确的 Data ID 约定区分环境,再通过 profile 选择导入。application-dev.yaml 只有在激活 dev profile 时才会生效;远程配置与本地配置的覆盖关系,也要按导入位置、profile、环境变量和命令行参数共同判断。
不要把应用连接配置中心所需的关键地址全部只放在那份尚未加载的远程配置里,否则应用连“去哪里取配置”都不知道。生产环境还要配置身份认证和访问权限,密码与令牌通过受控的配置或密钥机制管理。
出了问题,按这个顺序查:配置中心地址能否连接、namespace/group/Data ID 是否一致、YAML 格式是否正确、是否确实导入、属性名是否对应、读取对象是否支持刷新。配置中心能打开,只能证明页面可访问。
4. 服务追踪:看清一次请求经过了哪些服务
4.1 接口慢了,只看一台服务的日志够不够
用户点击查询,入口服务又去调用用户服务。最终接口花了 2 秒,可能是入口处理慢,也可能是在等待下游。如果只看到最终响应时间,很难知道该改哪一段。
链路追踪把一次跨服务请求连接成一条调用链,记录每一段操作的服务、起止时间、父子关系和状态。它适合排查慢接口、远程调用失败和服务之间的依赖关系。
先认识两个名称:
- Trace:这一次请求的整条链,共用一个 Trace ID。
- Span:链上的一段操作,有自己的 Span ID,例如接收 HTTP 请求或发起一次远程调用。
text
同一条 Trace
入口服务接收请求(SERVER Span)
└─ 入口服务发送 HTTP(CLIENT Span)
└─ 用户服务接收 HTTP(SERVER Span)
Trace ID 负责把整条链联系起来,Parent ID 表示父子关系。它们不是用户 ID,也不能代替权限校验。
4.2 使用 Micrometer Tracing 与 Zipkin
旧教程常用 Spring Cloud Sleuth。本文基于 Boot 3,使用 Micrometer Tracing 接入追踪,由 Brave 实现追踪桥接,再把数据上报到 Zipkin。不要给 Boot 3 项目直接加入只适配旧版本的 Sleuth 依赖。
Micrometer Tracing 负责统一的追踪接口,Brave 负责创建和传播追踪上下文,Zipkin 用来接收、查询和展示。仅启动 Zipkin,不会自动让所有 Java 服务都有链路数据。
先运行 Zipkin:
powershell
docker run -d --name study24-zipkin -p 127.0.0.1:19411:9411 openzipkin/zipkin:3.5.1
浏览器打开 http://localhost:19411。这个学习部署使用内存存储,重启后不能认为历史数据仍然保留。
下面只用两个服务演示。继续使用第 2 节的用户服务,再新建一个调用入口;暂时不让网关参与这条链,避免一次引入过多组件。
4.3 两个服务都加入追踪依赖
用户服务和新建的调用入口服务,都加入:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
<groupId>io.zipkin.reporter2</groupId>
<artifactId>zipkin-reporter-brave</artifactId>
</dependency>
Actuator 在这里不仅用于查看管理接口,还提供与应用观测有关的自动配置。加入依赖后,不需要为演示而公开所有 Actuator 管理端点。
用户服务保留原配置,在顶层加入:
yaml
management:
tracing:
sampling:
probability: 1.0
zipkin:
tracing:
endpoint: http://127.0.0.1:19411/api/v2/spans
probability: 1.0 表示采样全部请求,方便学习时每次都能找到链路。生产环境应结合流量、成本和诊断需求设置采样率,不能直接把全部采样当作固定要求。
调用入口服务加入 Web 依赖,启动类包名为 com.example.caller,完整配置为:
yaml
server:
port: 18088
spring:
application:
name: study24-caller
management:
tracing:
sampling:
probability: 1.0
zipkin:
tracing:
endpoint: http://127.0.0.1:19411/api/v2/spans
两个服务需要不同的 spring.application.name,否则查询时容易把它们当成同一个服务。
4.4 通过受框架管理的 HTTP 客户端传播上下文
入口服务先定义 RestClient。它是 Spring 提供的同步 HTTP 客户端,适合这里的简单调用:
java
package com.example.caller;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
public class ClientConfig {
@Bean
public RestClient userClient(RestClient.Builder builder) {
// 用 Boot 注入的 Builder,才能保留观测和追踪拦截配置。
return builder.baseUrl("http://127.0.0.1:18085").build();
}
}
关键不是只写一个 RestClient 类型,而是使用 Boot 注入的 RestClient.Builder。Builder 上已经带有观测配置,发请求时才能创建客户端 Span、注入追踪请求头。直接 RestClient.create(),或者手动 new 一个没有观测配置的客户端,可能导致下游另起一条 Trace。
再提供调用接口:
java
package com.example.caller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
@RestController
public class TraceController {
private final RestClient userClient;
public TraceController(RestClient userClient) {
this.userClient = userClient;
}
@GetMapping("/trace-demo")
public String demo() {
return userClient.get().uri("/users/1").retrieve().body(String.class);
}
}
先启动 Zipkin、用户服务和调用入口,再访问:
powershell
curl.exe http://localhost:18088/trace-demo
响应仍然是用户 JSON。追踪配置没有改业务接口的数据格式,但在请求收发过程中记录了 Span,并通过追踪请求头把上下文传给下游。
4.5 在 Zipkin 中怎样看结果
打开 Zipkin,点击 RUN QUERY,找到 study24-caller 的 /trace-demo 请求;也可以按服务名缩小范围。点击 SHOW 查看详情。
本例能看到同一个 Trace ID 下的三个 Span,分别属于两个服务:
text
study24-caller: http get /trace-demo
└─ study24-caller: http get
└─ study24-user-service: http get /users/{id}
Services: 2
Total Spans: 3
从时间条可以区分入口请求总耗时、客户端等待时间、用户服务处理时间。它们存在包含关系,不能把每个 Span 的耗时直接相加当作总耗时。
如果入口很慢,但下游 SERVER Span 很短,就继续看连接等待、网络耗时或入口其他逻辑;如果某个下游 Span 明显拉长,再去检查那个服务的日志、SQL 和依赖。
本例实际查询到了两个服务、三个 Span 的同一条链。上报是异步的,刚请求完暂时看不到时,可以等待短暂批量上报,再刷新查询;同时检查服务时间、查询时间范围、采样率和上报地址。
4.6 Trace 与日志、线程、消息有什么关系
Trace 帮助定位哪一段有问题,日志解释那一段具体发生了什么。Boot 的追踪集成可以把当前 Trace ID、Span ID 放进日志上下文;前提是在携带该上下文的处理流程中记录日志。进入一个没有传播上下文的新线程后,不能期待 ID 自己跟过去。
HTTP 客户端需要注入追踪请求头,下游也要启用对应的观测能力。线程池、异步任务和消息队列则有各自的上下文传播方式。本节验证的是同步 HTTP 链,不把它自动扩展为 RocketMQ 消息链路已经打通。
实际业务也不要把密码、完整身份证号或大量用户数据写进 Span 标签。Trace 采样和存储保留同样需要管理;追踪系统用于诊断,不替代业务数据库和审计记录。
5. Seata:让跨服务的数据修改一起提交或回滚
5.1 本地事务解决不了什么问题
现在模拟一次购买:订单服务写入一条 10 元订单,再调用账户服务扣 10 元。两个服务使用不同数据库连接,各自执行本地事务。
问题在于:账户扣款成功并提交后,订单服务又抛出异常。订单库回滚了,但账户库并不知道上游失败,钱已经扣掉。
text
订单库:新增订单 → 调账户服务 → 后续出错 → 本地回滚
账户库: 扣 10 元 → 本地提交
@Transactional 管的是当前服务的数据源事务,不会沿 HTTP 自动控制另一个进程。给两个方法都加上它,也不等于它们已经属于同一个全局事务。
分布式事务要解决的是:多个参与者的修改,怎样按同一次业务的结果协调提交与回滚。Seata 是一种实现;本节使用 AT 模式,先把上面的不一致真实做出来,再接入全局事务观察区别。
5.2 TC、TM、RM 和 XID 分别是什么
| 名称 | 在本例中的角色 |
|---|---|
| TC,事务协调器 | Seata Server,记录全局状态,协调分支提交或回滚 |
| TM,事务管理器 | 订单服务中的事务入口,开启并结束全局事务 |
| RM,资源管理器 | 两个服务的数据源代理,管理各自数据库分支 |
| XID | 同一次全局事务的编号,跨服务调用时必须传播 |
AT 模式的一阶段执行本地 SQL,同时保存回滚所需的信息,并向 TC 注册相应分支。业务成功时,TC 协调提交;业务失败时,已提交的分支可以按回滚日志恢复。
订单号、Trace ID 和 XID 分别服务于业务识别、链路诊断和事务协调,不能互相替代。调用账户服务时不传 XID,账户分支就不会因为“上游也用了 Seata”而自动加入全局事务。
5.3 在 Docker 中启动 Seata Server
使用与客户端一致的 2.2.0:
powershell
docker run -d --name study24-seata `
-p 127.0.0.1:8091:8091 `
-p 127.0.0.1:17091:7091 `
-e STORE_MODE=file `
-e SEATA_IP=127.0.0.1 `
-e SEATA_PORT=8091 `
apache/seata-server:2.2.0
docker logs --tail 30 study24-seata
8091 是事务客户端连接的端口,17091 映射控制台的 7091,二者不能混用。日志中出现服务监听 8091、Seata Server 启动成功后,再启动业务应用。
本例的 Java 服务都运行在同一台 Windows 上,通过 127.0.0.1:8091 直连。SEATA_IP=127.0.0.1 不是适用于所有部署的固定值,跨机器或容器部署应使用参与者能到达的协调器地址。
这里使用 file 存储和单节点直连,目的是观察事务流程。生产环境还要考虑协调器状态持久化、可靠存储与高可用,不能直接把容器中的临时文件当作完整部署方案。
5.4 准备两个演示数据库
使用 MySQL 的 3307 端口、学习账号 root/root。两个服务分别连接不同的库,即使它们位于同一个 MySQL 实例,也仍使用各自独立的数据源连接和本地事务。
先在 MySQL 客户端执行:
sql
CREATE DATABASE study24_account;
CREATE DATABASE study24_order;
CREATE TABLE study24_account.account (
id BIGINT PRIMARY KEY,
balance INT NOT NULL
) ENGINE = InnoDB;
INSERT INTO study24_account.account VALUES (1, 100);
CREATE TABLE study24_order.purchase_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
amount INT NOT NULL
) ENGINE = InnoDB;
这里只用整数金额,表示本例的简化计价单位;真实金额可以用分为单位的整数或合理精度的 DECIMAL,不要用浮点数随意计算扣款。
AT 需要在每个参与的业务库中有 undo_log,用来保存对应分支的回滚信息。学习示例的结构如下;实际接入时应以所用 Seata 版本提供的官方 SQL 为准:
sql
CREATE TABLE study24_account.undo_log (
branch_id BIGINT NOT NULL,
xid VARCHAR(128) NOT NULL,
context VARCHAR(128) NOT NULL,
rollback_info LONGBLOB NOT NULL,
log_status INT NOT NULL,
log_created DATETIME(6) NOT NULL,
log_modified DATETIME(6) NOT NULL,
UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE = InnoDB;
CREATE TABLE study24_order.undo_log LIKE study24_account.undo_log;
undo_log 放在业务库里,不是只在 Seata Server 那边建一张表就够了。业务表应具有主键,使用支持事务的引擎;SQL 是否受当前 AT 版本支持,也要在接入前核对。
5.5 两个服务加入依赖和数据源配置
建立账户服务 account 和订单事务演示服务 business。它们与第 1 节发通知的订单示例独立,先只关注数据库和 HTTP,避免把 MQ 事务与 AT 混在一个例子里。
两个项目都加入:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.apache.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
这里直接使用 Apache Seata starter,并显式固定 2.2.0;没有让 Alibaba BOM 默认的 Seata 版本替换它。Java 包名也使用 org.apache.seata,不要混入旧教程的 io.seata import。
账户服务的完整配置:
yaml
server:
port: 18089
spring:
application:
name: study24-account
datasource:
url: jdbc:mysql://127.0.0.1:3307/study24_account?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
username: root
password: root
seata:
application-id: study24-account
tx-service-group: study24_tx_group
enable-auto-data-source-proxy: true
data-source-proxy-mode: AT
registry:
type: file
config:
type: file
service:
vgroup-mapping:
study24_tx_group: default
grouplist:
default: 127.0.0.1:8091
订单服务使用相同的 Seata 配置,但修改以下三处:
server.port改为 18090。spring.application.name与seata.application-id改为study24-business。- 数据源 URL 中的库名改为
study24_order。
启动类包名分别为 com.example.account 和 com.example.business,其余结构与前面相同。
enable-auto-data-source-proxy 让 starter 自动代理数据源,AT 才能拦截 SQL 并记录回滚信息;不要又手动包一层同样的数据源代理。
tx-service-group 是事务分组名,vgroup-mapping 把这个分组映射到协调器集群名 default,grouplist.default 再给出它的地址。三个名称必须接得上。本例使用 file 注册/配置类型进行静态直连,不需要为了 Seata 示例额外在 Nacos 中创建配置。
5.6 账户服务:只负责扣款
先把账户本地事务写清楚。JdbcTemplate 负责执行 SQL,@Transactional 让扣款方法成为一个本地事务:
java
package com.example.account;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AccountService {
private final JdbcTemplate jdbc;
public AccountService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public void deduct() {
int rows = jdbc.update("UPDATE account SET balance = balance - 10 WHERE id = 1 AND balance >= 10");
if (rows != 1) {
throw new IllegalStateException("余额不足");
}
}
}
balance >= 10 和扣款放在同一条 UPDATE 中,避免先查余额再扣款的竞争窗口;受影响行数不为 1 时就抛异常,表示本次扣款没有成功。
Controller 接收上游传来的 XID,并在调用本地业务前绑定到当前线程:
java
package com.example.account;
import org.apache.seata.core.context.RootContext;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AccountController {
private final AccountService accountService;
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
@PostMapping("/accounts/deduct")
public String deduct(@RequestHeader(value = "TX_XID", required = false) String xid) {
// 将上游全局事务编号绑定到当前处理线程,后面的本地事务才能加入分支。
if (xid != null && !xid.isBlank()) {
RootContext.bind(xid);
}
try {
accountService.deduct();
return "扣款成功";
} finally {
if (xid != null && !xid.isBlank()) {
RootContext.unbind(); // 线程会复用,必须清除本次请求的事务上下文。
}
}
}
}
普通 HTTP 请求不会天然携带 Seata 上下文,因此本例显式接收 TX_XID 请求头。绑定后,数据源代理才能识别这次扣款所属的全局事务;请求完成后在 finally 里清理,避免工作线程被下个请求复用时带着旧值。
这是本地教学服务的显式传播方式,外部客户端不应有权随意指定内部事务 XID。真实项目通常由受控的服务调用拦截器完成传播,并在服务边界落实认证和访问控制。如果项目已有 Seata 传播集成,不要再重复绑定。
5.7 订单服务:先观察只有本地事务的结果
订单服务需要一个调用账户接口的 HTTP 客户端:
java
package com.example.business;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
public class OrderClientConfig {
@Bean
public RestClient accountClient(RestClient.Builder builder) {
return builder.baseUrl("http://127.0.0.1:18089").build();
}
}
接下来定义购买服务。为了便于对比,同一个类提供本地事务入口和全局事务入口,Controller 分别调用;第一次先看 localOnly():
java
package com.example.business;
import org.apache.seata.core.context.RootContext;
import org.apache.seata.spring.annotation.GlobalTransactional;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestClient;
@Service
public class PurchaseService {
private final JdbcTemplate jdbc;
private final RestClient accountClient;
public PurchaseService(JdbcTemplate jdbc, RestClient accountClient) {
this.jdbc = jdbc;
this.accountClient = accountClient;
}
@Transactional
public void localOnly(boolean fail) {
execute(fail);
}
@GlobalTransactional(name = "study24-purchase", rollbackFor = Exception.class)
@Transactional
public void global(boolean fail) {
execute(fail);
}
private void execute(boolean fail) {
jdbc.update("INSERT INTO purchase_order(amount) VALUES (10)");
var request = accountClient.post().uri("/accounts/deduct");
String xid = RootContext.getXID();
if (xid != null) {
request.header("TX_XID", xid); // 普通 HTTP 客户端不会替我们自动传递 XID。
}
request.retrieve().toBodilessEntity();
if (fail) {
throw new IllegalStateException("模拟订单后续处理失败");
}
}
}
先顺着公共业务过程看:插入订单、调用账户扣款、按参数决定是否模拟后续异常。两种入口执行完全相同的 SQL 和 HTTP 请求,区别在于是否创建并传播全局事务。
localOnly() 只有本地 @Transactional,没有 XID,账户服务会独立提交。global() 则在下一节使用。两个入口都由 Controller 从 Spring 代理外部调用,没有用 this.global() 这种自调用方式绕过代理。
最后定义两个测试接口:
java
package com.example.business;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PurchaseController {
private final PurchaseService service;
public PurchaseController(PurchaseService service) {
this.service = service;
}
@PostMapping("/purchase/local")
public String local(@RequestParam(defaultValue = "false") boolean fail) {
service.localOnly(fail);
return "购买成功";
}
@PostMapping("/purchase/global")
public String global(@RequestParam(defaultValue = "false") boolean fail) {
service.global(fail);
return "购买成功";
}
}
先确认初始数据是订单 0 条、余额 100,再发起本地事务失败请求:
powershell
curl.exe -i -X POST "http://localhost:18090/purchase/local?fail=true"
本例故意抛出异常,HTTP 500 是模拟失败的预期结果。然后查询两个库:
sql
SELECT COUNT(*) AS order_count FROM study24_order.purchase_order;
SELECT balance FROM study24_account.account WHERE id = 1;
text
order_count = 0
balance = 90
订单的本地事务回滚,账户服务的本地事务却早已提交。这就把“跨服务不一致”真实表现出来了,不能仅凭订单接口返回失败就认定钱也退回。
5.8 再用全局事务处理同样的失败
先只重置这两个演示库的数据,让前后实验从同一个起点开始:
sql
DELETE FROM study24_order.purchase_order;
UPDATE study24_account.account SET balance = 100 WHERE id = 1;
这次改调 global() 对应的入口:
powershell
curl.exe -i -X POST "http://localhost:18090/purchase/global?fail=true"
代码中的 @GlobalTransactional 开启全局事务;RootContext.getXID() 取到编号,HTTP 请求把它传给账户服务。两个库各自的本地事务因此被纳入同一次协调。
后续异常仍然抛出,接口仍返回 500。不过等协调回滚完成后,再查询会得到:
text
order_count = 0
balance = 100
可以从日志确认具体发生了什么:订单服务创建了全局事务;账户服务注册了 AT 分支;订单失败后,全局事务进入回滚;账户分支收到回滚请求并恢复扣款。
恢复是一个协调过程,查询可能需要等待短暂时间。不能只检查最终余额,还要结合分支日志,避免把“下游根本没执行”误认为“回滚成功”。
这里同时保留本地 @Transactional,用于本服务 SQL 的事务边界;@GlobalTransactional 负责跨服务协调,两者职责不同。异常要能传播到事务入口,捕获后返回普通成功值会改变事务判断。
5.9 正常请求应该一起提交
继续保持上一步回滚后的 0 条订单、100 余额,取消模拟异常:
powershell
curl.exe -i -X POST "http://localhost:18090/purchase/global?fail=false"
text
HTTP 200
购买成功
order_count = 1
balance = 90
现在对比三个结果,就能看出全局事务解决了哪一部分问题:
| 调用方式 | 订单后续是否抛异常 | 最终订单数 | 最终余额 |
|---|---|---|---|
| 只有本地事务 | 是 | 0 | 90 |
| Seata 全局事务 | 是 | 0 | 100 |
| Seata 全局事务 | 否 | 1 | 90 |
每行都以订单 0 条、余额 100 为起点;重复运行前要重置演示数据。不能把连续多次扣款后的余额,直接与这一组初始状态比较。
5.10 AT 的边界,以及与消息事务的区别
AT 通过数据源代理、回滚日志和协调器管理支持的数据库操作,不会把“已经发出去的短信”撤回来,也不会自动回滚任意第三方 HTTP 副作用。
本例扣款接口最终修改的是受 AT 管理的 MySQL 数据,这与调用真实支付平台是两回事。对于不能自动回滚的资源,需要明确的补偿、幂等和业务状态设计;Seata 还提供 TCC、Saga、XA 等模式,应按资源特征选择。
AT 的一阶段可能已经提交本地事务,全局结果尚未确定;它不等于把所有数据库变成一个进程内的本地事务。全局锁、隔离级别、脏写检查、长事务和超时都需要考虑,事务中也不应长时间等待人工操作。
这里还要区分两种一致性需求:
- “订单库和账户库必须随同一次购买提交或回滚”,是本节的跨服务事务协调问题。
- “订单提交成功后,通知事件要可靠发出,下游允许稍后完成”,常用事务消息或本地消息表解决,并由消费者保证幂等。
并不是所有微服务调用都需要分布式事务。若业务允许通过状态机与补偿达到最终一致,就可以选择更松耦合的流程;关键是说清业务承诺和失败后的恢复方法。
5.11 按症状排查 Seata
服务启动成功但没有分支。 先看数据源是否被自动代理、调用是否经过 Spring 代理、SQL 是否在正确的事务边界内。再检查下游是否收到 XID,不能只看入口注解。
连接不到协调器。 检查 file 配置中的分组映射和 8091 端口,确认没有错连控制台的 7091。跨容器时,localhost 指向的是当前容器,不是 Windows。
提示缺少 undo_log 或不支持 SQL。 在实际连接的每个业务库中检查表结构、主键和数据库引擎,再对照所用版本的 SQL 支持范围。
接口报错却没有回滚。 确认异常是否被捕获后转换成成功、是否满足回滚规则、下游是否确实加入了同一个全局事务。长时间未完成的回滚要检查 TC、RM 和数据库日志,不能靠直接删除 undo_log 来“修复”。
6. 官方资料
- Apache RocketMQ 官方文档,了解消息模型、部署和不同版本的能力。
- RocketMQ Spring 官方仓库,核对 starter、客户端及示例。
- Spring Cloud Gateway Server WebFlux,核对路由、过滤器与版本说明。
- Gateway Redis RequestRateLimiter,核对令牌桶参数和 KeyResolver。
- Spring Cloud Alibaba 官方仓库,核对版本组合和 Nacos Config 接入方式。
- Nacos 配置管理文档,了解配置管理和环境隔离。
- Spring Boot 链路追踪,核对 Micrometer Tracing 和 HTTP 客户端观测。
- Zipkin 官方站点,了解链路模型与查询。
- Apache Seata 快速开始,了解 AT 事务的接入与执行流程。
- Apache Seata 2.2.0 业务库 undo_log,实际建表时按所用版本核对。

评论