微服务系统中如何开发
从开发角度来说,微服务项目和单体项目的日常开发区别并没有想象中那么大。因为说到底,微服务架构下的每一个服务,本质上也都是一个独立的 Spring Boot 项目,我们平时写 Controller、Service、Mapper、SQL、接口开发等工作,和在单体项目中并没有太大区别。
真正的区别在于:原本在一个项目内部完成的事情,被拆分到了多个服务之间协同完成。因此在开发过程中,会额外涉及一些单体项目中不太会遇到的问题,例如服务的注册与发现、远程配置管理、服务间远程调用、API 网关、分布式锁、分布式事务等。
所以,对于刚接触微服务的新人来说,不必把它想得过于高深。先把每个服务当成一个普通的 Spring Boot 项目去理解,再逐步掌握服务之间的协作方式,会更容易上手。

一、远程配置与注册中心
在微服务架构下,有两个基础组件几乎是必须的:配置中心和注册中心。
配置中心解决的是”配置怎么统一管理”的问题,注册中心解决的是”服务之间怎么找到对方”的问题。
这两个东西看起来是独立的功能,但在实际项目中,它们经常由同一个中间件来承担,比如 Nacos 就同时支持配置中心和注册中心。
1、远程配置
1.1 为什么需要远程配置?
在单体项目里,配置文件一般就直接放在项目里,比如 application.yml,改了配置就重启服务就行。
但在微服务架构下,通常有这些问题:
- 公共配置分散,改一处要动 N 个项目:微服务下有十几个甚至几十个服务,很多配置是多个服务共用的(比如 Redis 地址、数据库连接、第三方接口密钥)。虽然 Spring Boot 自带的 Profile 机制(
application-dev.yml、application-prod.yml)也能做多环境切换,但当服务数量多了之后,每个服务都维护自己的一套配置文件,公共配置一旦变更就要逐个修改 N 个项目,很容易漏改或改错。配置中心可以把公共配置集中管理,改一次所有服务都生效 - 敏感配置不适合放在代码仓库中:数据库密码、第三方接口密钥等敏感信息,如果直接写在项目的配置文件里,会被提交到 Git 仓库,存在泄露风险。配置中心可以集中管理这些敏感配置,代码仓库里只保留非敏感的基本配置
- 配置修改需要重启才能生效:有些配置改了之后,服务必须重启才能生效,这在生产环境是很不方便的
所以,微服务架构下,通常会引入配置中心来统一管理配置。
简单理解:配置中心就是把你之前放在
application.yml里的配置,搬到远程服务器上统一管理。你可以随时改,改完之后服务可以做到动态生效,不需要重启。
1.2 常见的配置中心
| 配置中心 | 说明 |
|---|---|
| Nacos | 阿里开源,国内使用最广泛,同时支持注册中心和配置中心 |
| Apollo | 携程开源,功能丰富,配置管理能力很强 |
| Spring Cloud Config | Spring Cloud 官方提供,需要依赖 Git 等存储 |
其中,Nacos 是国内企业用得最多的,很多公司的微服务架构都是基于 Spring Cloud Alibaba + Nacos 搭建的。
1.3 远程配置一般怎么用?
以 Nacos 为例,一般的用法是这样的:

1)在 Nacos 控制台上创建配置
你登录 Nacos 控制台,在里面创建一个配置,比如:
- Data ID:
order-service-dev.yml - Group:
DEFAULT_GROUP - 配置内容:就是你的 yml 配置,比如数据库连接、Redis 地址等

2)在项目的 bootstrap.yml 中配置 Nacos 地址
spring: application: name: order-service cloud: nacos: config: server-addr: 127.0.0.1:8848 file-extension: yml group: DEFAULT_GROUP #分组不设置,默认就是DEFAULT_GROUP namespace: 7c5c0c0d-c0c5-4c0c-b0c5-0c5c0c5c0c5c #命名空间不设置,默认是public username: nacos password: nacos shared-configs: # 引入公共配置 - data-id: application-dev.yml # 公共配置的 Data ID refresh: true # 是否支持动态刷新注意:Nacos 配置相关的内容一般放在
bootstrap.yml(或bootstrap.properties)里,而不是application.yml。因为bootstrap.yml的加载优先级比application.yml更高,这样可以保证在应用启动时先连上 Nacos 拉取配置,再进行后续初始化。
除了在 bootstrap.yml 里写死 Nacos 地址,也可以在 IDEA 的启动配置里通过 JVM 参数指定,本地开发时用这种方式更灵活,不用改代码:
在 IDEA 的 Run Configuration 里,找到 VM options,添加:
-Dspring.cloud.nacos.config.server-addr=http://127.0.0.1:8848-Dspring.cloud.nacos.config.file-extension=yml-Dspring.cloud.nacos.config.namespace=0662a2cc-f7fd-42e0-b33b-aa07603950bd-Dspring.cloud.nacos.config.shared-configs[0].data-id=application-dev.yml-Dspring.cloud.nacos.config.shared-configs.refresh=true-Dspring.profiles.active=dev-Dspring.cloud.nacos.config.group=DEFAULT_GROUP-Dspring.cloud.nacos.discovery.server-addr=http://127.0.0.1:8848-Dspring.cloud.nacos.discovery.group=zy-Dspring.cloud.nacos.discovery.namespace=0662a2cc-f7fd-42e0-b33b-aa07603950bd这样本地开发连自己电脑的 Nacos,测试环境连测试环境的 Nacos,只需要改 VM 参数就行,不用动配置文件。

3)启动服务时自动拉取远程配置
服务启动后,会自动去 Nacos 拉取对应的配置,和本地配置合并后生效。
启动过程中,各个配置被读入的先后顺序是这样的:
1、bootstrap.yml(本地) ↓2、拉取公共配置(shared-configs) ↓3、拉取当前服务自己的配置(Data ID = 服务名.yml) ↓4、application.yml(本地) ↓最终合并生效特别注意:这里讲的”加载顺序”和下面讲的”优先级”不是一回事,别搞混了。 上面说的是各个配置在启动过程中”什么时候被读到”,而真正决定同名属性最终用哪个值的,是优先级。Nacos 拉取到的远程配置会被插入到比本地配置更高的优先级位置上,所以它虽然比
application.yml先被加载,最终却能覆盖application.yml。
如果同一个属性在多处都配了,最终生效遵循以下优先级(高优先级覆盖低优先级):
Nacos 服务专属配置(Data ID = 服务名.yml) ← 最高 ↓ 覆盖Nacos 公共配置(shared-configs) ↓ 覆盖application.yml(本地) ↓ 覆盖bootstrap.yml(本地) ← 最低简单记三条就够:
- 远程 > 本地:Nacos 上的配置会覆盖本地配置
- 专属 > 共享:服务自己的配置会覆盖公共配置
- application > bootstrap:本地文件中,
application.yml覆盖bootstrap.yml
优先级能改吗? 能,但一般不需要。Spring Cloud Nacos 提供了一些配置项(如
spring.cloud.config.override-none=true可以让远程配置不覆盖本地),但实际项目中几乎不会去改默认优先级,了解有这么回事就行。
这也是为什么很多项目本地只保留最基本的配置(服务名、Nacos 地址、端口号等),其他配置全部放到 Nacos 上管理——避免本地和远程配置打架。
当然,如果某个属性只在某一处配了,其他地方没配,那自然就是那一处的值生效。
其中,公共配置是多个服务共用的配置,比如数据库连接、Redis 地址、通用日志配置等。这些配置不需要每个服务都重复配一遍,只需要在 Nacos 上创建一份公共配置文件(如 common.yml),然后在每个服务的 bootstrap.yml 中引入即可。修改公共配置时,所有引用它的服务都会生效,不用一个一个改。
1.4 哪些配置适合放远程,哪些不适合?
| 放远程配置中心 | 放本地配置文件 |
|---|---|
| 数据库连接地址 | 服务端口号 |
| Redis 地址 | 应用名称 |
| 第三方接口地址和密钥 | 日志级别(本地调试用的) |
| 业务开关配置 | 框架本身的配置 |
| 需要经常修改的配置 | 基本不会变的配置 |
原则:多环境有差异的、需要经常修改的、敏感信息类的配置,优先放配置中心。
1.5 配置修改后,服务怎么感知?
有两种常见方式:
方式一:自动刷新
通过 @RefreshScope 注解,配合 Nacos 的配置监听机制,配置修改后,对应的 Bean 会自动重新创建,不需要重启服务。
实际开发中,最常见的场景是业务开关——比如某个功能上线后发现问题,需要紧急关闭,如果配置写在代码里就只能重新发版;但放在 Nacos 上,改个配置就能即时生效,不用重启服务。
1)在 Nacos 上添加开关配置
# 比如配置在 order-service-dev.yml 中order: flash-sale-enabled: false # 秒杀功能开关,false 表示关闭 max-order-count: 5 # 单用户最大下单数2)在代码中通过 @Value 读取,配合 @RefreshScope 实现动态刷新
@RestController@RefreshScopepublic class OrderController {
@Value("${order.flash-sale-enabled:false}") private Boolean flashSaleEnabled;
@Value("${order.max-order-count:10}") private Integer maxOrderCount;
@GetMapping("/flashSale") public String flashSale() { if (!flashSaleEnabled) { return "秒杀活动暂未开放"; } return "秒杀活动进行中,每人限购" + maxOrderCount + "件"; }}启动服务后,浏览器访问 /flashSale 会返回”秒杀活动暂未开放”。然后在 Nacos 上把 order.flash-sale-enabled 改成 true,不用重启服务,再刷新页面就能看到返回”秒杀活动进行中,每人限购5件”。
方式二:手动重启
有些配置改了之后,确实需要重启服务才能生效(比如数据库连接池的配置)。这种情况就直接改配置、重启服务。
1.6 远程配置相关的常见问题
问题一:本地配置不生效
这是新人特别容易遇到的问题。排查思路:
- 检查
bootstrap.yml或 Idea 启动脚本里配置的 Nacos 地址是否正确 - 检查 Data ID、Group 是否和 Nacos 上的一致
- 检查环境(dev / test / prod)是否正确
- 检查远程配置是否覆盖了本地配置(优先级问题)
问题二:配置改了但没生效
- 确认你改的是不是正确的环境配置(可能改的是测试环境的,但本地连的是开发环境)
- 确认是否需要
@RefreshScope才能动态刷新 - 确认修改后是否需要重启服务
问题三:远程配置里数据库、Redis 这些中间件的 IP,应该写哪个?
很多新人会困惑:Nacos 是配置中心,那配置里的数据库 IP,到底是要写 Nacos 能访问的,还是服务自己能访问的?
答案:写”服务自己能访问的”IP。
原因很简单:Nacos 只是配置的”仓库”,它负责存储和分发配置,本身根本不去连接数据库、Redis 这些中间件。真正拿着这些连接信息去建立连接、发 SQL 的,是消费配置的业务服务(order-service、user-service 等)。所以 IP 必须是业务服务所在的网络环境里能访问到的地址。
举个例子:
- Nacos、MySQL、Redis 都部署在 A 服务器
- 你的业务服务部署在 B 服务器
那么远程配置里数据库、Redis 的 IP 不能写 127.0.0.1 或 localhost,因为 127.0.0.1 在 B 服务器的视角下指的是 B 自己,B 上根本没有装 MySQL/Redis,肯定连不上。应该写 A 服务器对外的 IP(也就是从 B 服务器上能 ping 通的那个 IP)。
2、注册中心(服务的注册与发现)
2.1 为什么需要注册中心?
在单体项目里,所有代码都在一起,调用另一个模块的方法直接调就行了,不需要关心对方在哪台机器上。
但在微服务架构下,每个服务都是独立部署的,可能分布在不同机器上,而且服务的实例数量还可能随时变化(比如某个服务扩容了,从 2 个实例变成 5 个;某个实例挂了,只剩 4 个了)。
订单服务想调用用户服务,问题是: - 用户服务部署在哪台机器上? - IP 地址是多少?端口是多少? - 用户服务有几个实例?调哪个? - 如果某个实例挂了怎么办?如果这些信息全靠人工维护,比如在配置文件里写死每个服务的 IP 和端口,那根本管不过来:
- 某个服务换了机器,所有调用方都要改配置
- 某个服务扩容了,调用方不知道新的实例在哪
- 某个服务挂了,调用方还在傻傻地请求已经不存在的地址
注册中心就是用来自动解决这些问题的。
2.2 注册中心是做什么的?
用一句话概括:
注册中心就是一个”服务通讯录”,每个服务启动时把自己的地址登记上去,需要调用别人时从通讯录里查。
2.3 注册中心的工作流程

2.4 常见的注册中心
| 注册中心 | 说明 |
|---|---|
| Nacos | 阿里开源,国内使用最广泛,同时支持注册中心和配置中心 |
| Eureka | Netflix 开源,Spring Cloud 早期默认组件,目前已停止维护 |
| ZooKeeper | Apache 基金会项目,Dubbo 早期默认使用的注册中心 |
| Consul | HashiCorp 开源,功能丰富,支持多数据中心 |
其中,Nacos 是目前国内企业使用最多的注册中心,特别是基于 Spring Cloud Alibaba 的微服务项目,基本上都是用 Nacos。前面说过,Nacos 同时支持配置中心和注册中心,所以很多公司只需要部署一个 Nacos 就够了。
2.5 服务注册与发现在代码中是怎么体现的?
很多新人会觉得:“注册中心这么重要,我是不是要写很多注册相关的代码?”
实际上,注册中心的使用基本上是自动的,你不需要手动写注册和发现的代码。
以 Spring Cloud Alibaba + Nacos 为例,我们的项目只需要:
1)添加依赖
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>2)在配置文件中指定 Nacos 地址
spring: application: name: order-service # 服务名,注册到 Nacos 上的名称 cloud: nacos: discovery: server-addr: 127.0.0.1:8848 # Nacos 地址 group: DEFAULT_GROUP #分组不设置,默认就是DEFAULT_GROUP namespace: 7c5c0c0d-c0c5-4c0c-b0c5-0c5c0c5c0c5c #命名空间不设置,默认是public username: nacos password: nacos也是可以在IDEA 的启动配置里通过 JVM 参数指定。
3)启动服务
服务启动后,Spring Cloud 会自动帮你完成以下操作:
- 把当前服务(
order-service)注册到 Nacos - 定时向 Nacos 发送心跳,告诉它”我还活着”
- 当你用 Feign 或 Dubbo 调用其他服务时,自动从 Nacos 查询目标服务的地址
你在 Nacos 控制台的”服务列表”页面,就能看到所有已注册的服务及其实例信息。

2.6 关于服务注册与发现的几个关键概念
1)服务注册
服务启动时,将自己的信息(服务名、IP、端口等)注册到注册中心。
2)服务发现
服务消费方从注册中心获取目标服务的实例列表,选择其中一个进行调用。
3)心跳检测
注册中心会定时检查每个服务是否还活着。如果某个服务长时间没有发送心跳,注册中心会认为它已经挂了,把它从服务列表中剔除。
4)负载均衡
当一个服务有多个实例时,调用方需要决定请求哪个实例。常见的负载均衡策略有:
| 策略 | 说明 |
|---|---|
| 轮询 | 按顺序依次请求每个实例 |
| 随机 | 随机选一个实例 |
| 权重 | 按权重比例分配请求(性能好的机器权重高,分配更多请求) |
在 Spring Cloud 中,负载均衡默认使用的是 Spring Cloud LoadBalancer(早期版本用的是 Ribbon,从 Spring Cloud 2020.0 开始 Ribbon 已被移除,统一换成 LoadBalancer)。一般不需要你手动配置。
2.7 注册中心相关的常见问题
问题一:服务启动了,但注册中心上看不到
排查思路:
- 检查 Nacos 地址是否配置正确
- 检查服务名是否正确
- 检查网络是否能连上 Nacos(telnet 一下 Nacos 的端口)
- 检查依赖是否引入了
spring-cloud-starter-alibaba-nacos-discovery - 看启动日志里有没有注册失败的报错
问题二:远程调用报错”找不到服务”
这种情况通常是调用方在注册中心上找不到目标服务。排查思路:
- 确认目标服务是否已启动并注册成功
- 确认调用方配置的目标服务名和注册中心上的服务名是否一致(大小写、横杠、下划线等)
- 确认调用方和目标服务连的是同一个 Nacos、同一个命名空间(namespace)、同一个分组
3、一些注意事项
1)先搞清楚你们公司用的是哪个注册中心和配置中心
如果有使用注册中心:确认注册中心和配置中心用的是 Nacos、Apollo + Eureka 还是别的?管理后台地址是什么?可以直接问AI或者看pom文件引入的依赖是什么。
2)配置改完,记得确认是否生效
不要改完 Nacos 上的配置就觉得万事大吉了。有时候你可能改的是测试环境的配置,但本地连的是开发环境;或者配置改了但没刷新成功。改完之后,最好实际验证一下。
3)不要随便改生产环境的配置
生产环境的配置修改一般有流程和审批机制,不要直接上去改。如果确实需要改,先和同事确认。
4)服务启动后,先去注册中心确认注册成功
不要启动完就不管了。登录 Nacos 控制台,看服务列表里有没有你的服务,实例信息(IP、端口)是否正确。如果注册都没成功,后续的远程调用肯定会出问题。
5)本地开发时,注意环境隔离
本地调试代码时,经常会遇到一种情况:你明明改了代码,但调用时发现改的没生效。这时候很可能是请求没有走到你本地启动的服务实例,而是走到了线上或测试环境的服务实例上。
排查方法:
- 在本地服务打断点:如果请求走的是你本地的服务,断点一定会被命中。断点没命中,说明请求走的是远程实例
- 通过命名空间或分组隔离:本地开发时,把服务注册到一个独立的命名空间或分组(比如
dev-local),这样本地实例和线上实例互不干扰 - Dubbo 可以改版本号:本地服务配置一个不同的版本号(比如
version = "1.0.0-local"),调用方指定这个版本号,就能确保请求只走你本地的实例
二、远程调用
1、为什么需要远程调用?
在单体项目里,所有代码都在一个项目里,模块之间直接调用方法就行了:
// 单体项目:直接调用orderService.createOrder(order);但在微服务架构下,系统被拆成了多个独立的服务,比如:
- 用户服务
- 订单服务
- 商品服务
- 支付服务
订单服务要查用户信息,不可能直接调用用户服务的方法,因为它们是两个独立的 Java 进程,跑在不同的机器上。
这时候就需要通过远程调用来让服务之间互相通信。
订单服务 ↓ (远程调用)用户服务简单理解:远程调用,就是服务 A 通过网络请求去调用服务 B 的接口,看起来像调用本地方法一样,但底层实际上是通过 HTTP 或 RPC 发了一次网络请求。
2、常见的远程调用方式
| 方式 | 说明 | 典型框架 |
|---|---|---|
| HTTP 调用 | 通过 HTTP 协议调用其他服务的接口 | RestTemplate、OpenFeign |
| RPC 调用 | 通过自定义协议直接调用,性能更好 | Dubbo |
目前企业里用得比较多的是 OpenFeign(HTTP 方式)和 Dubbo(RPC 方式)。
3、OpenFeign 的基本用法
OpenFeign 是 Spring Cloud 提供的一个声明式 HTTP 客户端,用起来非常简单。
1)添加依赖
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId></dependency>2)在启动类上开启 Feign
@EnableFeignClients@SpringBootApplicationpublic class OrderApplication { public static void main(String[] args) { SpringApplication.run(OrderApplication.class, args); }}3)定义 Feign 接口
@FeignClient(name = "user-service")public interface UserClient {
@GetMapping("/user/{id}") UserDTO getUserById(@PathVariable("id") Long id);}这段代码的意思是:
name = "user-service":要调用的目标服务名(注册中心上的服务名)@GetMapping("/user/{id}"):目标服务的接口路径UserDTO:返回值类型
重要:Feign 接口的入参必须加注解。
普通 Controller 里,简单类型的参数不加注解默认当
@RequestParam处理,但 Feign 接口不行,不加注解会报错或参数传不过去。必须通过注解明确告诉 Feign 参数放在哪:// 路径参数 → @PathVariable@GetMapping("/user/{id}")UserDTO getUserById(@PathVariable("id") Long id);// 查询参数 → @RequestParam@GetMapping("/user/list")List<UserDTO> listUsers(@RequestParam("name") String name,@RequestParam("page") Integer page);// 请求体 → @RequestBody(POST/PUT 传对象)@PostMapping("/user")void createUser(@RequestBody UserDTO user);// 请求头 → @RequestHeader@GetMapping("/user/info")UserDTO getInfo(@RequestHeader("Authorization") String token);// 文件上传 → @RequestPart@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)void upload(@RequestPart("file") MultipartFile file);// consumes 指定请求格式,文件上传用 multipart/form-data,不加一般也能自动识别
4)在业务代码中直接注入使用
@Servicepublic class OrderServiceImpl implements OrderService {
@Autowired private UserClient userClient;
public OrderDTO getOrderDetail(Long orderId) { Order order = orderMapper.selectById(orderId); // 远程调用用户服务,查询下单用户信息 UserDTO user = userClient.getUserById(order.getUserId()); // 组装返回 OrderDTO dto = new OrderDTO(); dto.setOrder(order); dto.setUser(user); return dto; }}可以看到,使用起来和调用本地方法几乎没有区别,这就是 OpenFeign 的好处。
3.1 Feign 接口抽取到独立 API 模块(最常见的做法)
上面演示的是在消费方(order-service)里自己写 Feign 接口。但工作中更常见的做法是:服务提供方把 Feign 接口和 DTO 放到一个独立的模块里,打成 jar 包,消费方直接引用这个 jar 包就行。
openfeign-test/ ← 父工程├── user-api ← 独立模块,只放接口和 DTO│ └── com.example.user.api│ ├── UserClient.java ← Feign 接口│ └── UserDTO.java ← 数据传输对象├── user-service ← 服务提供方(用户服务)│ └── com.example.user│ ├── UserServiceApplication.java│ └── controller/│ └── UserController.java ← Controller 实现 UserClient 接口└── order-service ← 服务消费方(订单服务) └── com.example.order └── OrderServiceApplication.java服务提供方的 Controller 直接实现这个 Feign 接口:
// user-api 模块中的 Feign 接口@FeignClient(name = "user-service")public interface UserClient {
@GetMapping("/user/{id}") UserDTO getUserById(@PathVariable("id") Long id);
@PostMapping("/user") void createUser(@RequestBody UserDTO user);}
// user-service-impl 模块中的 Controller 实现该接口@RestControllerpublic class UserController implements UserClient {
@Autowired private UserService userService;
@Override public UserDTO getUserById(Long id) { return userService.getUserById(id); }
@Override public void createUser(UserDTO user) { userService.createUser(user); }}消费方只需要在 pom.xml 里引用 user-api 的依赖,就能直接注入使用:
<!-- order-service 的 pom.xml --><dependency> <groupId>com.example</groupId> <artifactId>user-api</artifactId> <version>1.0.0</version></dependency>// order-service 中直接注入,不用自己写 Feign 接口@Autowiredprivate UserClient userClient;这样做的好处:
- 消费方不用自己写 Feign 接口,直接引用提供方的 jar 包,减少重复代码
- 接口和实现天然一致,因为 Controller 实现了 Feign 接口,路径、参数、返回值都是一样的,不会出现”调用方写的接口和提供方不一致”的问题
- DTO 共享,请求和响应的对象也放在 api 模块里,双方用的是同一个类
简单理解:就像你调用别人的 SDK,人家把接口定义好打成 jar 包给你用,你不用关心人家怎么实现的,直接调就行。
入职后看到项目结构里有
xxx-api模块,多半就是这种模式。
3.2 contextId 解决同服务名多个 FeignClient(了解)
有些项目会把一个大服务的接口拆成多个 FeignClient(比如用户查询一组、用户管理一组),这时会出现两个 @FeignClient 的 name 相同的情况,启动会报错。加上 contextId 就能解决:
// 两个接口都指向 user-service,用 contextId 区分@FeignClient(name = "user-service", contextId = "userQueryClient")public interface UserQueryClient {
@GetMapping("/user/{id}") UserDTO getUserById(@PathVariable("id") Long id);}
@FeignClient(name = "user-service", contextId = "userManageClient")public interface UserManageClient {
@PostMapping("/user") void createUser(@RequestBody UserDTO user);
@PutMapping("/user") void updateUser(@RequestBody UserDTO user);}简单理解:Spring 容器里不能有两个同名的 Bean。
name相同的 FeignClient 会冲突,contextId就是给它们起不同的 Bean 名字。
4、Dubbo 的基本用法
Dubbo 是阿里开源的 RPC 框架,在国内企业中使用也非常广泛。和 OpenFeign 基于 HTTP 不同,Dubbo 使用自定义的二进制协议进行通信,性能更高。
4.1 Dubbo 和 OpenFeign 的区别
| 对比项 | OpenFeign | Dubbo |
|---|---|---|
| 通信协议 | HTTP | 自定义 RPC 协议(默认 Dubbo 协议) |
| 性能 | 一般(HTTP 协议开销较大) | 更高(二进制协议,序列化效率更高) |
| 使用方式 | 声明式接口 + Spring MVC 注解 | 声明式接口 + Dubbo 注解 |
| 服务注册 | 依赖 Spring Cloud 注册中心 | 自带注册中心集成(Nacos、ZooKeeper 等) |
| 生态 | Spring Cloud 生态 | Dubbo 生态(Spring Boot 也可以集成) |
| 适用场景 | 中小型项目、对外接口 | 高并发内部服务调用 |
简单理解:OpenFeign 就像你用浏览器访问网页,走的是通用的 HTTP 协议;Dubbo 就像两个人打专线电话,用专门的协议直接通话,速度更快但需要两边都用同一套协议。
4.2 Dubbo 的基本用法
Dubbo 的使用方式有两种主流写法:一种是老版本的 XML / 注解方式,另一种是新版本推荐的 Spring Boot 方式。现在新项目基本都用 Spring Boot 方式,这里也以此为例。
1)添加依赖
服务提供方和服务消费方都需要引入 Dubbo 的 starter:
<dependency> <groupId>org.apache.dubbo</groupId> <artifactId>dubbo-spring-boot-starter</artifactId> <version>3.2.0</version></dependency><!-- 注册中心以 Nacos 为例 --><dependency> <groupId>org.apache.dubbo</groupId> <artifactId>dubbo-registry-nacos</artifactId> <version>3.2.0</version></dependency>2)配置 Dubbo
dubbo: application: name: user-service registry: address: nacos://127.0.0.1:8848?namespace=7c5c0c0d-c0c5-4c0c-b0c5-0c5c0c5c0c5c&group=zy username: nacos password: nacos protocol: name: dubbo port: 208803)定义服务接口
Dubbo 的做法是:把接口定义抽成一个独立的模块(API 模块),服务提供方和消费方都依赖这个模块。
// user-api 模块(公共接口)public interface UserService { UserDTO getUserById(Long id);}4)服务提供方实现接口
在用户服务中实现这个接口,并暴露为 Dubbo 服务:
// user-service 模块(服务提供方)@DubboServicepublic class UserServiceImpl implements UserService {
@Autowired private UserMapper userMapper;
@Override public UserDTO getUserById(Long id) { User user = userMapper.selectById(id); UserDTO dto = new UserDTO(); dto.setId(user.getId()); dto.setName(user.getName()); return dto; }}@DubboService 注解表示:这个类是一个 Dubbo 服务,会自动注册到注册中心,其他服务可以通过远程调用来使用它。
5)服务消费方调用远程服务
在订单服务中,通过 @DubboReference 注入远程服务:
// order-service 模块(服务消费方)@Servicepublic class OrderServiceImpl implements OrderService {
@DubboReference private UserService userService;
public OrderDTO getOrderDetail(Long orderId) { Order order = orderMapper.selectById(orderId); // 远程调用用户服务 UserDTO user = userService.getUserById(order.getUserId()); OrderDTO dto = new OrderDTO(); dto.setOrder(order); dto.setUser(user); return dto; } }可以看到,使用方式和 OpenFeign 类似,注入后直接调用方法就行,底层 Dubbo 会自动完成远程调用。
6)启动类上加注解
@EnableDubbo@SpringBootApplicationpublic class OrderApplication { public static void main(String[] args) { SpringApplication.run(OrderApplication.class, args); }}说明:Dubbo 3.x 配合
dubbo-spring-boot-starter时已经支持自动装配,理论上可以不写@EnableDubbo。但项目里如果用到了包路径扫描(比如服务接口分散在多个包下),写上@EnableDubbo并配合@DubboService/@DubboReference会更稳妥,所以很多老项目和教程里都保留了这个注解。新人照着现有代码来就行。
4.3 Dubbo 使用时的注意事项
1)接口模块要独立
Dubbo 的服务接口(如 UserService)通常放在一个独立的 api 模块中,服务提供方和消费方都依赖这个模块。这样消费方不需要依赖服务提供方的整个项目,只需要知道接口定义就行。
2)序列化问题
Dubbo 远程调用时,传输的对象需要能被序列化。所以 DTO 类一定要实现 Serializable 接口:
public class UserDTO implements Serializable { private static final long serialVersionUID = 1L; private Long id; private String name; // ...}3)版本号
Dubbo 支持给同一个接口配置不同的版本号,在做接口升级时非常有用:
// 服务提供方@DubboService(version = "2.0")public class UserServiceImplV2 implements UserService { ... }
// 服务消费方@DubboReference(version = "2.0")private UserService userService;4)超时和重试配置
Dubbo 的超时和重试配置在后面”远程调用失败怎么办”章节会详细讲,这里先知道有这个概念就行。
5)Dubbo 和 OpenFeign 在 Nacos 服务列表中的区别
使用 OpenFeign 的项目,所有服务都会注册到 Nacos 服务列表中,不管是调用方还是被调用方。
但 Dubbo 不一样:只有服务提供者会注册到 Nacos 服务列表,消费者默认不会注册。 消费者只是订阅它需要调用的服务,不会把自己作为”服务”注册上去。

所以如果你用的是 Dubbo,在 Nacos 服务列表里只看到提供者、看不到消费者,这是正常的,不是配置问题。想确认消费者是否正常工作,可以在 Nacos 控制台点进某个服务,查看它的”订阅者列表”(注意:Dubbo 3.x 中订阅者的应用名可能显示为 “unknown”,这也是正常现象,不影响实际调用)。

4.4 怎么快速判断项目用的是哪种?
入职后,快速判断项目远程调用方式的方法:
| 特征 | OpenFeign | Dubbo |
|---|---|---|
| 看依赖 | 有 spring-cloud-starter-openfeign | 有 dubbo-spring-boot-starter |
| 看注解 | 有 @FeignClient | 有 @DubboService、@DubboReference(3.x)或 @Service、@Reference(2.x) |
| 看配置 | 有 feign 相关配置 | 有 dubbo 相关配置 |
| 看项目结构 | 不一定有独立 api 模块 | 通常有独立的 api/接口模块 |
注意:有些项目可能同时用了 OpenFeign 和 Dubbo。比如内部服务间调用用 Dubbo(性能更好),调用外部系统用 OpenFeign(走 HTTP 更通用)。碰到这种情况不要奇怪。
5、远程调用失败怎么办?
5.1 超时配置
不管是 OpenFeign 还是 Dubbo,都需要合理配置超时时间。
OpenFeign 超时配置:
feign: client: config: default: connectTimeout: 5000 # 连接超时 5 秒 readTimeout: 10000 # 读取超时 10 秒Dubbo 超时配置:
dubbo: consumer: timeout: 5000 # 全局超时 5 秒也可以在注解上针对单个服务设置超时:
@DubboReference(timeout = 10000)private UserService userService;注意:正常情况下不需要单独设置超时时间,公司一般通过 Nacos 远程配置统一管理,或者直接使用框架默认值。除非你新写的接口调用比较耗时,而且已经优化到极限、业务方也可以接受,这时候再去修改超时配置。
5.2 重试
有些临时性的网络抖动,重试一次可能就好了。但重试要谨慎,因为很多时候,请求超时并不代表业务一定失败。比如下游服务可能已经成功执行了,只是响应返回过程中超时了,如果此时直接重试,可能会导致重复下单、重复扣款等问题。
正因为如此,大部分项目不会在远程调用层面对所有接口统一开启自动重试。通常只会对查询类、幂等接口做有限重试,而新增、扣减、支付类接口一般会禁用自动重试。
处理方式有两种思路:
- 直接禁用重试:简单粗暴,非幂等接口设置
retries = 0,大部分项目用这种 - 把接口做成幂等:比如下单接口用唯一订单号做去重,扣款接口用唯一交易流水号做去重,同样的请求只处理一次。这样即使重试也不会重复操作,但需要额外开发去重逻辑
说明:OpenFeign 默认不重试,调用失败直接抛异常,所以一般不需要额外配置。Dubbo 默认会重试 2 次(总共调用 3 次),非幂等接口一定要手动设置
retries = 0,否则可能会重复操作。
如果确实需要 OpenFeign 开启重试(比如查询类接口),配置一个 Retryer Bean 即可:
@Configurationpublic class FeignConfig {
@Bean public Retryer retryer() { // 参数:初始间隔(ms)、最大间隔(ms)、最大尝试次数(含首次调用) return new Retryer.Default(100, 1000, 3); }}注意:这是全局配置,对所有 FeignClient 生效。如果项目中既有查询接口(可重试)又有写入接口(不可重试),就不要全局开启,可以在
@FeignClient的configuration属性上针对单个客户端指定配置。
如果用的是 Dubbo,可以通过配置或注解设置重试次数:
dubbo: consumer: retries: 2 # 全局重试 2 次(总共调用 3 次)也可以在注解上针对单个服务设置:
@DubboReference(retries = 0) // 不重试(非幂等接口建议关闭)private OrderService orderService;
@DubboReference(retries = 2) // 重试 2 次(查询类接口可以开)private UserService userService;5.3 降级(Fallback)
降级就是调用失败时,不直接报错,而是返回一个兜底结果:
@FeignClient(name = "user-service", fallback = UserClientFallback.class)public interface UserClient {
@GetMapping("/user/{id}") UserDTO getUserById(@PathVariable("id") Long id);}
// 降级类@Componentpublic class UserClientFallback implements UserClient {
@Override public UserDTO getUserById(Long id) { UserDTO fallback = new UserDTO(); fallback.setId(id); fallback.setName("未知用户"); return fallback; }}踩坑提醒:Spring Cloud 2020+ 移除了 Hystrix,如果项目中没有引入替代的断路器依赖(如
spring-cloud-starter-circuitbreaker-resilience4j),或者没有在配置文件中开启spring.cloud.openfeign.circuitbreaker.enabled=true,fallback属性会静默失效,不报错也不降级。另外,降级类一定要加@Component注解,否则 Spring 不认识它,fallback 同样不会生效。
Dubbo 没有像 OpenFeign 那样内置的 fallback 参数,但可以通过 mock 属性声明降级逻辑:
@DubboReference(version = "1.0.0", mock = "return null")private UserService userService;mock 的几种写法:
| 写法 | 说明 |
|---|---|
| mock = “return null” | 调用失败直接返回 null |
| mock = “return 默认值” | 比如 return new UserDTO() |
| mock = “force: return null” | 不管调用是否失败,直接返回 null(用于手动关闭某个调用) |
| mock = “com.example.MockUserService” | 指定一个 Mock 类,实现完整的降级逻辑 |
说明:
mock方式虽然简单,但大部分项目中用得不多,很多项目直接不配降级,远程调用失败就抛异常,由全局异常处理器统一处理。入职后先看项目里有没有现成的写法,照着来就行。
不过,降级不是所有项目都会用。有些公司要求每个远程调用都配置降级;也有很多项目不配降级,远程调用失败就直接抛异常,由全局异常处理器统一处理。两种方式都可以,取决于公司的技术规范。入职后先看项目里有没有降级相关的代码,照着现有写法来就行。
注意:配置了 fallback 的话,降级类里返回的值要合理。比如查询用户信息失败,返回”未知用户”是合理的;但如果下单接口失败返回”下单成功”,那就会出大问题。
5.4 熔断(了解)
熔断和降级通常会配合使用。当某个服务调用失败率过高、响应时间过长,或者异常次数达到阈值后,熔断器会暂时阻止继续调用下游服务,避免故障扩散。此时系统通常会进入降级逻辑,返回默认值、缓存数据或友好提示。就像家里的保险丝,电流过大时自动断开,保护整个电路不烧毁。
常见的熔断框架有 Sentinel 和 Resilience4j,国内企业用 Sentinel 比较多。早期的 Hystrix 目前已经停止维护,新项目基本不再使用。
如果项目用了 Sentinel,通常会通过 @SentinelResource 注解标记需要保护的远程调用方法,然后在 Sentinel 控制台上配置熔断规则。
不过,熔断在很多公司的项目中并没有使用。因为熔断、限流等治理功能会增加系统复杂度,需要额外维护规则、监控和告警。对于访问量不大、服务链路不复杂的项目,很多公司不会专门引入完整的熔断体系,只用 Nacos + OpenFeign/Dubbo + Gateway 就够了。所以入职后发现项目里没有熔断相关的代码,不要觉得奇怪。先了解公司有没有用,如果有就学一下 Sentinel 的用法,如果没有就不用管。
6、远程调用时需要注意的问题
1)不要循环调用远程接口
// 错误示范:循环调用远程接口,性能极差for (Order order : orders) { UserDTO user = userClient.getUserById(order.getUserId()); order.setUserName(user.getName());}如果列表有 100 条数据,就要发 100 次远程调用,性能会非常差。
正确做法是提供一个批量查询接口:
// 正确做法:批量查询List<Long> userIds = orders.stream().map(Order::getUserId).collect(Collectors.toList());List<UserDTO> users = userClient.listUsersByIds(userIds);2)远程调用报”找不到服务”或”No provider available”
这是远程调用最常见的报错之一,但问题根源通常不在调用代码本身,而是注册中心层面的问题(服务没注册上、命名空间/分组不一致等)。排查思路见前面「注册中心」章节的常见问题。
3)本地调试时注意环境隔离,避免请求打到远程实例
本地开发时,你的服务和线上/测试环境的服务都注册在同一个 Nacos 上。这时候远程调用可能会被负载均衡到别人的实例上,而不是你本地启动的实例。表现就是:你明明改了代码,但调用时发现没生效,断点也进不去。
解决方法在前面「注册中心」章节的注意事项里有详细说明(独立命名空间、Dubbo 改版本号等),这里不再重复。
三、API 网关
1、为什么需要 API 网关?
在微服务架构下,系统被拆分成了多个独立服务,每个服务都有自己的地址。如果让前端直接调各个服务,会有一堆问题:前端要维护多个服务地址、每个服务各自做鉴权逻辑重复、跨域问题无法统一处理、没有统一入口做限流。
所以,微服务架构下通常会引入 API 网关,作为整个系统的统一入口。网关的核心功能:
| 功能 | 说明 |
|---|---|
| 路由转发 | 根据请求路径,把请求转发到对应的微服务 |
| 统一鉴权 | 在网关层统一校验 Token 和权限,不需要每个服务各自处理 |
| 限流 | 控制某个接口或服务的访问频率,防止流量过大拖垮系统 |
| 日志记录 | 统一记录请求日志,方便排查问题 |
| 跨域处理 | 在网关层统一解决跨域问题,不需要每个服务各自配置 |
简单理解:网关就像一栋大楼的前台。你要找不同部门的人,不需要自己记住每个人在哪个房间,只需要到前台,前台帮你转接到对应的房间。同时,前台还会检查你的身份(鉴权)、控制来访人数(限流)。
前端 / 客户端 ↓ API 网关(统一入口)—— 鉴权、限流、日志、路由转发 ↓ ┌────┴────┬────────┐ ↓ ↓ ↓用户服务 订单服务 商品服务常见的网关有 Spring Cloud Gateway(Spring 官方,目前国内使用最广泛)、Zuul(Netflix 开源,已停止维护)、Kong(基于 Nginx + Lua)。目前新项目基本都用 Spring Cloud Gateway。
2、Spring Cloud Gateway 快速上手
1)添加依赖
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId></dependency>注意:Gateway 基于 Netty + WebFlux,因此网关模块中一般不要再引入
spring-boot-starter-web(Spring MVC),否则容易出现 Web 容器冲突导致启动失败。只是网关模块不能用 Spring MVC,其他业务服务正常使用 Spring MVC 没有问题。
2)配置路由规则
以若依微服务版的网关配置为例(国内使用非常广泛的开源框架):
spring: cloud: gateway: routes: # 认证中心 - id: ruoyi-auth uri: lb://ruoyi-auth predicates: - Path=/auth/** filters: - CacheRequestFilter # 自定义过滤器:缓存请求体 - ValidateCodeFilter # 自定义过滤器:校验验证码 - StripPrefix=1 # 系统模块 - id: ruoyi-system uri: lb://ruoyi-system predicates: - Path=/system/** filters: - StripPrefix=1 # 代码生成 - id: ruoyi-gen uri: lb://ruoyi-gen predicates: - Path=/code/** filters: - StripPrefix=1 # 定时任务 - id: ruoyi-job uri: lb://ruoyi-job predicates: - Path=/schedule/** filters: - StripPrefix=1 # 文件服务 - id: ruoyi-file uri: lb://ruoyi-file predicates: - Path=/file/** filters: - StripPrefix=1每条路由的核心就三个配置:
| 配置项 | 说明 |
|---|---|
| uri: lb://服务名 | 转发到哪个服务。lb:// 表示从注册中心获取地址并负载均衡 |
| predicates: Path=/** | 什么样的请求路径匹配这条路由 |
| filters: StripPrefix=1 | 转发前去掉第一段路径 |
StripPrefix 为什么要去掉? 因为前端请求的路径带前缀(如
/auth/login),但服务端 Controller 的路径不带(如@PostMapping("/login"))。/auth只是网关用来区分”转发给哪个服务”的标识,转发前必须去掉,否则服务匹配不到路径会返回 404。StripPrefix 配几段取决于 Controller 的实际路径,入职后看项目里怎么配的就行。
另外注意 ruoyi-auth 路由多配了两个东西:CacheRequestFilter 和 ValidateCodeFilter。这两个就是过滤器(Filter)——请求在转发到服务之前,先经过过滤器处理。比如 ValidateCodeFilter 会在登录请求到达认证服务之前,先校验验证码是否正确,不对的话直接拦截,不用等到服务层才报错。
3)启动类
@SpringBootApplicationpublic class GatewayApplication { public static void main(String[] args) { SpringApplication.run(GatewayApplication.class, args); }}只要配置了 Nacos 地址,Gateway 会自动从注册中心发现服务,不需要额外写代码。
3、常见问题
3.1 放开接口时,网关层和服务层都要放
这是一个新人特别容易踩的坑。
很多项目中,网关层会做统一鉴权(Token 校验、权限检查等),不在白名单里的请求会被网关直接拦截,根本到不了后端服务。所以当你需要放开某个接口(比如登录、注册、公共查询等无需登录就能访问的接口)时,不能只在服务层放开,网关层也要同步放开。
举个例子:你写了一个 /system/register 注册接口,不需要登录就能访问。你在系统服务里把 Security 配置放开了,但前端调用时还是返回 401。原因是:请求先到了网关,网关的鉴权过滤器发现没有 Token,直接返回 401 了,请求根本没到你的系统服务。
前端请求 /system/register(没带 Token) ↓网关鉴权过滤器 → 没有 Token → 直接返回 401 ❌ ↓ (请求根本没到系统服务)系统服务(你配的放开规则没机会生效)正确做法:在网关的鉴权过滤器白名单中,也把这个接口加上。 比如前面示例中的 AuthGlobalFilter:
// 白名单路径(不需要鉴权的接口)if (path.startsWith("/auth/login") || path.startsWith("/system/register") // ← 加上需要放开的接口 || path.startsWith("/file/public")) { return chain.filter(exchange);}简单记:微服务架构下,一个请求要经过网关 → 服务两道关。放开接口时两道关都要放,少放一个就会被拦住。排查 401、403 问题时,先确认是网关拦的还是服务拦的,不要只盯着服务层看。
3.2 通过接口路径定位到哪个服务处理的
入职后经常会遇到这种情况:前端跟你说某个接口报错了,你拿到接口路径(比如 /system/user/list),但不知道这个请求最终是由哪个后端服务处理的。这时候就可以通过网关的路由规则来定位。
定位步骤:
1)找到网关的路由配置
路由配置一般在网关模块的 application.yml(或 bootstrap.yml)里,也可能在 Nacos 的网关配置中。
2)用请求路径匹配路由规则
比如接口路径是 /system/user/list,对照路由规则:匹配 Path=/system/**,对应的 uri 是 lb://ruoyi-system,所以这个接口是 ruoyi-system 处理的。
3)确认转发后的实际路径
由于配了 StripPrefix=1,转发前会去掉第一段 /system,所以 ruoyi-system 收到的请求路径是 /user/list,对应 Controller 里的 @GetMapping("/user/list")。
举几个例子:
| 前端请求路径 | 匹配的路由 | 转发到哪个服务 | 服务收到的路径 |
|---|---|---|---|
| /auth/login | Path=/auth/** | ruoyi-auth | /login |
| /system/user/list | Path=/system/** | ruoyi-system | /user/list |
| /code/gen/tables | Path=/code/** | ruoyi-gen | /gen/tables |
| /file/upload | Path=/file/** | ruoyi-file | /upload |
注意:如果请求路径没有匹配到任何路由规则,网关会直接返回 404。如果匹配到了但目标服务不可用,网关会返回 502。
常见问题排查:
- 接口返回 404:先确认路由规则是否匹配你的接口路径,再确认
StripPrefix配置是否正确(比如接口实际路径是/user/list,但StripPrefix多去了一段,转发后变成/list,服务里根本匹配不到) - 接口返回 502:通常表示网关调用下游服务失败,不一定是服务宕机,也可能是服务未注册、连接超时、网络异常等原因。先确认服务是否注册成功,再确认服务本身是否正常(直接调服务 IP 看看能不能通)
- 本地调试时可以先绕过网关:如果网关出了问题不好排查,先直接请求你自己服务的本地端口,确认接口本身没问题。服务没问题之后,再去看网关层面的问题
- 在浏览器控制台(F12 → Network)看到接口请求,对照路由规则就能快速定位到对应的服务
3.3 跨域处理(了解)
前端页面直接请求多个不同地址的服务会碰到跨域问题。在微服务架构下,跨域最理想的做法是在网关层统一处理,比如在网关模块里写一个跨域过滤器(实现 WebFilter),统一设置跨域响应头,这样所有经过网关的请求都不用再单独处理。
但真实项目中,不一定都在网关层做。也有可能通过以下方式处理:
- 在业务服务的公共模块里统一配置:所有服务都依赖一个公共模块(如 framework、common),在公共模块里通过
CorsFilter统一处理 - 在 Nginx 层处理:有些项目在 Nginx 反向代理时直接加跨域头,请求还没到网关就解决了
- 按需单独处理:只在需要的接口上单独配,比如通过
WebMvcConfigurer.addCorsMappings()或在 Controller 上加@CrossOrigin
所以入职后,遇到跨域问题先看项目里是怎么处理的,不要自己加配置,可能和已有的配置冲突。
四、锁与并发问题
1、为什么微服务下并发问题更复杂?
在单体项目里,如果多个线程同时操作同一份数据,可以用 Java 自带的锁(如 synchronized、ReentrantLock)来解决:
// 单体项目:用 synchronized 就行synchronized (this) { int stock = getStock(productId); if (stock > 0) { deductStock(productId); }}但在微服务架构下,同一个服务可能会部署多个实例(多台机器),每个实例都是独立的 JVM。这时候,Java 自带的锁就只能锁住自己这台机器上的线程,其他机器上的线程完全不受影响。
实例 A(JVM 1): synchronized → 只能锁住实例 A 内部的线程实例 B(JVM 2): synchronized → 只能锁住实例 B 内部的线程实例 C(JVM 3): synchronized → 只能锁住实例 C 内部的线程也就是说,Java 自带的锁在多实例部署的情况下,起不到互斥作用。
这时候就需要分布式锁。

2、什么是分布式锁?
分布式锁,就是让多个服务实例在操作同一份数据时,也能做到互斥。
不管是哪台机器上的线程,谁先抢到锁,谁就能执行;其他线程必须等锁释放后才能继续。
实例 A → 抢锁 → 成功 → 执行业务 → 释放锁实例 B → 抢锁 → 失败,等待实例 C → 抢锁 → 失败,等待3、常见的分布式锁实现方式
| 方式 | 说明 |
|---|---|
| Redis 分布式锁 | 最常用,基于 Redis 的 SETNX 命令实现 |
| Redisson | 基于 Redis 的分布式锁框架,封装得更完善 |
| ZooKeeper 分布式锁 | 基于 ZooKeeper 的临时顺序节点实现 |
| 数据库锁 | 通过数据库行锁或乐观锁实现,性能较低 |
其中,Redis + Redisson 是企业里用得最多的方案。
4、Redis 分布式锁的基本原理
最简单的实现方式是借助 Redis 的 SETNX 命令,核心思路就三步:
- 抢锁:往 Redis 写一个 key,谁先写成功谁就拿到锁(
SETNX= set if not exists,key 不存在才写入成功) - 设过期时间:防止拿到锁的服务突然宕机,导致锁永远不释放
- 释放锁:业务执行完,把这个 key 删掉
下面是一个最简实现(基于 StringRedisTemplate),只有两个方法:tryLock 抢锁、unlock 释放锁:
@Componentpublic class SimpleRedisLock {
@Autowired private StringRedisTemplate redisTemplate;
/** * 尝试获取锁(不阻塞,拿不到立即返回 false) * * @param key 锁的 key,例如 "lock:job:order_report_daily" * @param expire 锁的过期时间(防止宕机后锁不释放) * @param unit 时间单位 */ public boolean tryLock(String key, long expire, TimeUnit unit) { // setIfAbsent 就是 SETNX:key 不存在才写入 // 这里用 4 参数版本,把"设值 + 设过期时间"合成一步,保证原子性 return Boolean.TRUE.equals( redisTemplate.opsForValue().setIfAbsent(key, "1", expire, unit) ); }
/** 释放锁:把 key 删掉 */ public void unlock(String key) { redisTemplate.delete(key); }}小贴士:早期写法是先
SETNX设值、再单独expire设过期时间,两步之间如果服务宕机,锁就永远不释放了。现在推荐用上面这种「设值 + 过期时间一条命令完成」的 4 参数版本,从根上避免这个坑。
工作中的一个使用场景是定时任务防重复执行(多台机器都部署了同一个定时任务,但只希望其中一台真正执行):
@Autowiredprivate SimpleRedisLock redisLock;
@Scheduled(cron = "30 30 0 * * ?") // 每天 00:30:30 执行public void generateDailyReport() { // 1. 抢锁,过期时间设 6 小时(只要够任务跑完就行) boolean locked = redisLock.tryLock( "lock:job:order_report_daily", 6, TimeUnit.HOURS);
// 2. 没抢到锁,说明其他机器正在执行,本次直接跳过 if (!locked) { log.info("其他实例正在执行,本次跳过"); return; }
try { // 3. 抢到了,执行业务逻辑 this.reportService.generateDailyReport(); } finally { // 4. 跑完后释放锁(放 finally 里,即使抛异常也能释放) redisLock.unlock("lock:job:order_report_daily"); }}注意:这种基于
SETNX的实现比较简单,适合定时任务这种”获取失败就跳过”的场景。但对于库存扣减、订单处理这种需要等待锁释放的业务场景,一般用 Redisson。真实项目里通常不会让你从零写这套工具类,公司大概率已经有现成的封装(比如下面 Redisson 那节会提到的自定义注解)。先看项目里已有的,有的话直接用。
5、Redisson 分布式锁的基本用法
第 4 节那套 SETNX 锁要自己处理过期时间、释放时机等一堆边界问题。而 Redisson 作为成熟的分布式锁框架,已经把这些都封装好了(比如锁续期、可重入、公平锁等),实际项目里基本都用它。
Redisson 加锁 API 速览
Redisson 加锁常用的方法就下面几个,先有个整体印象,看后面的代码才不懵:
| 方法 | 说明 |
|---|---|
| lock.lock() | 阻塞等待,一直等到拿到锁为止 |
| lock.lock(10, TimeUnit.SECONDS) | 阻塞等待,但显式指定过期时间(这里是 10 秒),到点强制释放 |
| lock.tryLock(5, 30, TimeUnit.SECONDS) | 带超时返回:最多等 5 秒,拿不到就返回 false;拿到后锁 30 秒强制释放 |
| lock.unlock() | 释放锁 |
简单记:
lock是”抢不到就一直等”,tryLock是”等一会儿就算了”。至于”不写过期时间会不会出问题”,下面会专门讲。
5.1 Redisson 原生写法
下面是一个电商项目中库存锁定的真实代码,使用 Redisson 的 lock() 方法:
@Autowiredprivate RedissonClient redissonClient;
@Transactional(rollbackFor = Exception.class)public void lockStock(List<StockLockParam> stockLockParams) { List<RLock> locks = new ArrayList<>(); try { for (StockLockParam param : stockLockParams) { // 1. 按商品 SKU ID 获取锁(每个商品一把锁,粒度细) RLock lock = redissonClient.getLock("lock:stock:" + param.getSkuId()); // 2. 加锁(阻塞等待,直到获取成功) lock.lock(); locks.add(lock);
// 3. 执行业务逻辑:校验库存 → 扣减库存 int stock = stockService.getStock(param.getSkuId()); if (stock < param.getCount()) { throw new BusinessException("库存不足"); } stockService.deductStock(param.getSkuId(), param.getCount()); }
// 4. 保存库存锁定记录 stockLockMapper.saveBatch(stockLockParams);
// 5. 发送延时消息,1小时后自动解锁库存(订单超时未支付场景) mqTemplate.syncSend("stock-unlock-topic", orderIds);
} finally { // 6. 释放所有锁(只释放当前线程持有的,避免释放别人的锁) for (RLock lock : locks) { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }}说明:这里用
lock.lock()阻塞等待,保证一定拿到锁才继续执行。获取锁后,通过isHeldByCurrentThread()判断再释放,防止释放别人的锁。锁的粒度是 SKU 级别(lock:stock:{skuId}),不是整个订单,并发性能更好。
疑问:上面
lock.lock()没有写过期时间,会不会出问题?不会。这正是 Redisson 和手写 SETNX 锁(第 4 节那种)最大的区别——无参的
lock.lock()会自动带过期时间,并启动”看门狗”机制自动续期:
- 拿到锁时,Redisson 在底层设了一个默认过期时间(默认 30 秒)
- 同时启动一个后台线程(看门狗),每隔 10 秒检查一次:“这个锁还被当前线程持有吗?是的话,把过期时间重新刷回 30 秒”
这样就化解了一个矛盾:业务没跑完时一直续期,不会出现”锁提前过期、别人插队”的超卖问题;而服务一旦宕机,看门狗也跟着死了,锁最多 30 秒后自动释放,不会永远卡死。
⚠️ 反直觉的坑:一旦你显式指定过期时间(比如
lock.lock(10, SECONDS)或tryLock(5, 30, SECONDS)),看门狗就不会启动了,锁到点强制释放,哪怕业务还没跑完。所以要么用无参lock.lock()(靠看门狗),要么自己估算一个足够长的过期时间,别设太短。下面是一个
tryLock的例子,抢锁失败时还能给用户一个友好提示:RLock lock = redissonClient.getLock("lock:product:" + productId);try {if (lock.tryLock(5, 30, TimeUnit.SECONDS)) { // 最多等 5 秒;拿到后锁 30 秒强制释放try {doDeductStock(productId);} finally {lock.unlock();}} else {throw new BusinessException("系统繁忙,请稍后重试"); // 抢锁失败,给用户友好提示}} catch (InterruptedException e) {Thread.currentThread().interrupt();}
5.2 实际项目中的封装方式
在实际开发中,公司一般会把 Redisson 封装成自定义注解,你不会直接写上面的代码。最常见的封装方式是「自定义注解 + AOP」:通过注解声明锁,AOP 切面自动处理加锁和解锁,业务代码完全不感知锁的存在。以下是某项目的真实封装:
// 注解定义@Target({ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)public @interface RedissonLock { /** 锁的 key,支持 SpEL 表达式,例如:keys = {"#orderId"} */ String[] keys(); /** key 前缀,默认用「类名:方法名」 */ String prefix() default ""; /** 锁超时时间(毫秒),默认 30000ms */ long lockTimeout() default 0; /** 等待加锁超时时间(毫秒),0 表示不等待 */ long waitTimeout() default 0;}// AOP 切面实现(核心逻辑)@Aspect@Componentpublic class RedissonLockAspect {
private final RedissonClient redissonClient;
@Around("@annotation(RedissonLock)") public Object around(ProceedingJoinPoint joinPoint) { Method targetMethod = ((MethodSignature) joinPoint.getSignature()).getMethod(); RedissonLock annotation = targetMethod.getAnnotation(RedissonLock.class);
// 1. 通过 SpEL 解析锁的 key(例如从参数中取 orderId) String lockKey = getLockKey(annotation.keys(), targetMethod, joinPoint.getArgs()); String keyPrefix = targetMethod.getDeclaringClass().getName() + ":" + targetMethod.getName();
// 2. 获取锁 RLock lock = redissonClient.getLock("lock:" + keyPrefix + ":" + lockKey); boolean lockFlag = false; Object proceed = null; try { // 3. 尝试加锁(带超时) lockFlag = lock.tryLock(annotation.waitTimeout(), annotation.lockTimeout(), TimeUnit.MILLISECONDS); if (lockFlag) { // 4. 获取成功,执行业务方法 proceed = joinPoint.proceed(); } } finally { if (lockFlag && lock.isHeldByCurrentThread()) { lock.unlock(); } } // 5. 获取失败,抛异常(防重复提交) if (proceed == null) { throw new BusinessException("操作过于频繁,请稍后重试"); } return proceed; }}使用时只需加一个注解:
@RedissonLock(keys = {"#orderId"}, waitTimeout = 5000, lockTimeout = 30000)public void processOrder(String orderId) { // 业务逻辑,自动被分布式锁保护}6、分布式锁使用时的注意事项
1)锁的粒度要合理
锁的范围要尽量小,只锁住真正需要互斥的操作。比如:
- 不要锁整个方法,只锁住操作共享资源的代码块
- 锁的 key 要精确到具体的业务对象,比如
lock:order:123而不是lock:order
2)一定要设置过期时间
防止服务宕机后锁一直不释放,导致其他服务永远获取不到锁。
3)释放锁一定要放在 finally 里
保证即使业务代码抛异常,锁也能正常释放。
4)获取锁失败时要处理
获取锁失败,说明有其他线程在操作,不能直接忽略。要么返回提示信息让用户重试,要么走其他逻辑。
五、事务如何解决
1、微服务下的事务问题
在单体项目里,事务很好处理,一个 @Transactional 注解就能搞定:
// 单体项目:一个事务搞定@Transactionalpublic void createOrder(Order order) { orderMapper.insert(order); // 写订单表 orderItemMapper.batchInsert(items); // 写订单明细表 productMapper.deductStock(productId); // 扣库存}但在微服务架构下,这些操作可能分布在不同服务中:
创建订单 → 订单服务(写订单表、订单明细表)扣库存 → 商品服务(扣商品库存)这时候,@Transactional 只能保证订单服务自己数据库的事务,商品服务那边出了问题,订单服务是管不了的:
这就是分布式事务问题。

2、解决分布式事务的常见思路
分布式事务没有完美解决方案,只有在一致性和性能之间做取舍。
常见的思路有:
| 方案 | 说明 | 一致性 |
|---|---|---|
| 2PC(两阶段提交) | 准备阶段 + 提交阶段,所有参与者都成功才提交 | 强一致 |
| TCC | Try-Confirm-Cancel,手动编写三个阶段的逻辑(Seata 支持此模式,详见 4.5) | 最终一致 |
| 消息事务 | 通过消息队列保证最终一致性 | 最终一致 |
| Seata | 阿里开源的分布式事务框架,支持多种模式 | 看选用的模式 |
3、先搞清楚:大多数业务不需要强一致性
这是新人特别容易纠结的一个点。
很多新人一听到”分布式事务”,就觉得必须保证所有服务的数据实时一致。但实际上,大部分业务场景只需要最终一致性就够了。而且很多互联网业务项目并不会引入 Seata,而是采用本地事务 + MQ + 补偿机制实现最终一致性;只有一致性要求较高或业务复杂的场景,才可能采用 Seata、TCC 等方案。
什么叫最终一致性?
不要求所有服务的数据在同一时刻完全一致,但经过一段时间后,数据最终会变成一致的。
比如下单场景:
- 用户下单后,订单先创建成功
- 过一会儿,库存扣减成功
- 如果库存扣减失败,再把订单取消掉
用户看到的可能有一小段时间的”不一致”,但最终数据是对的。
哪些场景需要强一致性?
- 转账、支付等金融场景(钱必须实时一致,不能有中间态)
哪些场景只需要最终一致性?
- 大部分订单、库存、物流类业务
- 通知、消息类业务
- 积分、优惠券等
关于库存的一个常见疑问:库存不是怕超卖吗,为什么也算最终一致性?因为防止超卖靠的是「分布式锁 + 原子扣减」(上一章讲过的
lock:stock:{skuId}),而不是靠分布式事务——锁保证同一时刻只有一个请求在扣库存,扣减成功后通过 MQ 通知其他服务即可。这两个概念新人特别容易混,一定要分清:分布式事务解决的是「跨服务数据一致性」问题(A 服务成功了,B 服务也要成功);分布式锁解决的是「并发竞争」问题(同一时刻只有一个线程能操作)。两者关注点不同,不要混为一谈。
4、实际开发中最常见的处理方式
说实话,很多中小型公司的微服务项目,并不一定会引入完整的分布式事务框架,甚至有些公司压根不会对分布式事务做系统性处理——出了数据不一致就人工修。原因很简单:发生事务不一致的概率本身就很低(网络正常、服务不宕机的绝大部分时间里,数据都是一致的),如果业务本身能容忍这个极低概率的不一致,那加一堆补偿逻辑、消息表、分布式事务框架反而增加了系统复杂度,得不偿失。能简单解决的问题就不要用复杂方案,这是 KISS 原则。
4.1 本地事务 + 普通消息(最常用,但有隐患)
这是企业里用得最多的一种方式。注意这里用的是 MQ 的「普通消息」,不是「事务消息」,所以会有一个先天的隐患,下面会专门讲。
实现思路:

代码示例(以 RocketMQ 为例):
1)订单服务(生产者)——创建订单后发送扣库存消息
@Servicepublic class OrderServiceImpl implements OrderService {
@Autowired private OrderMapper orderMapper; @Autowired private RocketMQTemplate rocketMQTemplate;
@Transactional public void createOrder(Order order) { // 1. 写订单表(本地事务) orderMapper.insert(order);
// 2. 发送消息到 MQ,通知商品服务扣库存 StockMessage msg = new StockMessage(); msg.setOrderId(order.getId()); // 带上订单 ID,消费端用于幂等校验和取消订单 msg.setProductId(order.getProductId()); msg.setQuantity(order.getQuantity()); rocketMQTemplate.convertAndSend("topic-stock-deduct", msg);
//其他业务逻辑 }}2)商品服务(消费者)——收到消息后扣库存
@Component@RocketMQMessageListener( topic = "topic-stock-deduct", // 监听哪个 topic(和生产者发送时的 topic 一致) consumerGroup = "product-service-group" // 消费者组名:同一组内只有一台机器消费这条消息,避免重复消费)public class StockConsumer implements RocketMQListener<StockMessage> {
@Autowired private ProductMapper productMapper; @Autowired private OrderClient orderClient; // 回调订单服务,用于取消订单 @Autowired private StringRedisTemplate redisTemplate;
@Override public void onMessage(StockMessage msg) { // 【幂等校验】防止 MQ 重复投递导致库存被多扣 // MQ 保证「至少一次」投递,不保证「恰好一次」,所以消费端必须自己做幂等 String lockKey = "mq:stock:deducted:" + msg.getOrderId(); Boolean alreadyDeducted = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 24, TimeUnit.HOURS); if (!Boolean.TRUE.equals(alreadyDeducted)) { log.info("订单{}已扣过库存,跳过重复消息", msg.getOrderId()); return; // 已处理过,直接返回 }
try { // 扣库存(SQL 里带 stock > 0 条件,原子操作防超卖) int rows = productMapper.deductStock(msg.getProductId(), msg.getQuantity()); if (rows == 0) { // 库存不足是「业务异常」,不能靠 MQ 重试解决(重试再多库存还是不够) // 正确做法:走业务补偿 —— 通知订单服务取消这张订单 redisTemplate.delete(lockKey); // 清除标记,允许后续如果用户重新下单时能正常处理 orderClient.cancelOrder(msg.getOrderId(), "库存不足"); return; // 正常返回,消息消费成功,不再重试 } } catch (Exception e) { // 扣库存过程中发生异常(如数据库超时),清除幂等标记 // 让 MQ 重试时能重新进入,否则 key 会把重试全部挡住 redisTemplate.delete(lockKey); throw e; // 抛出异常,触发 MQ 重试 } }}3)消息体定义
@Data@AllArgsConstructor@NoArgsConstructorpublic class StockMessage implements Serializable { private Long orderId; // 订单 ID(用于幂等校验 + 取消订单) private Long productId; private Integer quantity;}⚠️ 必须知道的隐患:上面这段代码有一个经典坑——
@Transactional方法里直接调用rocketMQTemplate.convertAndSend()发消息。如果消息发出之后,本地事务因为后续代码抛异常而回滚了,会出现:本地数据没写入,但 MQ 消息已经发出去并被下游消费了,导致数据不一致。关于消费端的幂等:MQ 默认会重试,所以消费端要做幂等处理(比如通过消息 ID 或业务 ID 去重),防止重复消费导致库存多扣。多次重试仍失败的消息会进入死信队列,需要人工介入。
优点:实现简单,服务间解耦 缺点:有上述”事务回滚但消息已发”的隐患;属于最终一致性,有少量延迟
4.2 本地消息表 + MQ(推荐)
上面的方案有一个隐患:本地事务和消息发送不在同一个原子操作中。本地消息表就是用来解决这个问题的。
思路:不直接发 MQ 消息,而是先在本地数据库里写一条消息记录,和业务数据在同一个事务中,然后由定时任务扫描并发送。
流程:

1)消息表结构(参考)
CREATE TABLE local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, topic VARCHAR(100) COMMENT 'MQ topic', content TEXT COMMENT '消息内容(JSON)', status TINYINT COMMENT '0-待发送 1-已发送 2-发送失败', retry_count INT DEFAULT 0 COMMENT '重试次数', create_time DATETIME COMMENT '创建时间', INDEX idx_status (status));2)订单服务——在本地事务中同时写入业务数据和消息记录
@Servicepublic class OrderServiceImpl implements OrderService {
@Autowired private OrderMapper orderMapper; @Autowired private MessageMapper messageMapper;
@Transactional public void createOrder(Order order) { // 1. 写订单表(本地事务) orderMapper.insert(order);
// 2. 写消息表(和业务数据在同一个事务中,保证原子性) LocalMessage message = new LocalMessage(); message.setTopic("topic-stock-deduct"); message.setContent(JSON.toJSONString( new StockMessage(order.getId(), order.getProductId(), order.getQuantity()))); message.setStatus(0); // 0-待发送 messageMapper.insert(message); }}3)定时任务——扫描消息表并发送
@Componentpublic class MessageScheduler {
@Autowired private MessageMapper messageMapper; @Autowired private RocketMQTemplate rocketMQTemplate;
@Scheduled(fixedRate = 5000) // 每 5 秒扫描一次 public void sendPendingMessages() { // 查询条件:status != 1(未成功的)且 retry_count <= 5(没超过重试上限) // 这样 status=0(待发送)和 status=2(之前发送失败)的消息都会被查出来重试 List<LocalMessage> messages = messageMapper.selectPendingAndFailed(5); for (LocalMessage msg : messages) { try { rocketMQTemplate.convertAndSend(msg.getTopic(), msg.getContent()); msg.setStatus(1); // 1-已发送 messageMapper.updateById(msg); } catch (Exception e) { msg.setRetryCount(msg.getRetryCount() + 1); msg.setStatus(2); // 标记为失败 // 查询条件是 status != 1 AND retry_count <= 5 // retryCount <= 5 的消息下次扫描依然会被查出来重试 // retryCount > 5 的消息不会再被查出,留给人工处理 messageMapper.updateById(msg); log.warn("消息发送失败,id={},重试次数={}", msg.getId(), msg.getRetryCount()); } } }}一句话总结:把”发消息”这个不可靠动作,变成”写本地数据库”这个可靠动作,和业务操作绑在同一个事务里,再由定时任务异步发送、失败重试。这是实际项目中最稳妥的最终一致性方案。
优点:解决了 4.1 的隐患,本地数据与消息严格原子一致 缺点:要额外建一张消息表、配一个定时任务,有少量延迟
4.3 RocketMQ 事务消息(推荐)
本地消息表虽然可靠,但要额外建表 + 定时任务,有点重。RocketMQ 本身提供了事务消息机制,可以不建表就解决 4.1 的隐患。
原理:

和本地消息表(4.2)的对比:
| 本地消息表(4.2) | 事务消息(4.3) | |
|---|---|---|
| 可靠性 | ✅ 可靠 | ✅ 可靠 |
| 需要额外建表 | ✅ 需要 | ❌ 不需要 |
| 需要定时任务 | ✅ 需要 | ❌ 不需要 |
| 实现复杂度 | 中等 | 较低(RocketMQ 原生支持) |
| 依赖 | 无特殊依赖 | 依赖 RocketMQ |
代码示例:
@Servicepublic class OrderServiceImpl implements OrderService {
@Autowired private RocketMQTemplate rocketMQTemplate;
public void createOrder(Order order) { // 1. 发送半消息(此时消费者还收不到) // 发完后 RocketMQ 会自动回调 OrderTransactionListener.executeLocalTransaction() // 写订单表的操作在那个回调方法里执行,不在这里 rocketMQTemplate.sendMessageInTransaction( "tx-order-group", // 事务消息生产者组 "topic-stock-deduct", // 目标 topic MessageBuilder.withPayload( new StockMessage(order.getId(), order.getProductId(), order.getQuantity()) ).build(), order // 传递给本地事务的参数 ); }}// 2. 事务监听器:执行本地事务 + 处理回查@RocketMQTransactionListener(txProducerGroup = "tx-order-group")public class OrderTransactionListener implements RocketMQLocalTransactionListener {
@Autowired private OrderMapper orderMapper;
@Override public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) { try { Order order = (Order) arg; orderMapper.insert(order); // 执行本地事务:写订单表 return RocketMQLocalTransactionState.COMMIT; // 成功 → 通知 Broker 投递消息 } catch (Exception e) { return RocketMQLocalTransactionState.ROLLBACK; // 失败 → 通知 Broker 丢弃消息 } }
@Override public RocketMQLocalTransactionState checkLocalTransaction(Message msg) { // 回查逻辑:Broker 主动来问"本地事务到底成功没有" // 从消息体中取出 orderId,查数据库确认订单是否已写入 StockMessage stockMsg = JSON.parseObject(new String((byte[]) msg.getPayload()), StockMessage.class); Order order = orderMapper.selectById(stockMsg.getOrderId()); if (order != null) { return RocketMQLocalTransactionState.COMMIT; // 订单存在 → 提交 } return RocketMQLocalTransactionState.ROLLBACK; // 订单不存在 → 回滚 }}一句话总结:先发半消息 → 执行本地事务 → 根据结果 commit/rollback → 万一没响应 Broker 会回查。不需要建消息表和定时任务,是4.1 隐患的最优解。
注意:事务消息需要 RocketMQ 4.3+ 版本支持。消费端的幂等处理和 4.1 一样,不能省。
4.4 本地事务 + 补偿机制
如果不想用 MQ,也可以在业务代码里手动处理补偿。
为什么不用 @Transactional 直接回滚? 因为 productClient.deductStock() 是远程调用,如果放在 @Transactional 里,数据库连接会在整个远程调用期间被占用(可能 3~5 秒),高并发下连接池会被打爆,整个服务都不可用。所以这里先提交本地事务、释放连接,再调远程服务,失败了手动补偿。
思路:
- 先执行主业务逻辑
- 再调用其他服务
- 如果调用失败,手动回滚主业务的数据
public void createOrder(Order order) { // 1. 先创建订单(本地事务) orderMapper.insert(order);
// 2. 调用商品服务扣库存 try { productClient.deductStock(order.getProductId(), order.getQuantity()); } catch (Exception e) { // 扣库存失败,回滚本地订单 orderMapper.deleteById(order.getId()); throw new BusinessException("创建订单失败"); }}优点:简单直接
缺点:补偿逻辑需要自己写,如果涉及多个服务的调用,补偿代码会比较复杂
⚠️ 补偿机制的真实难度:上面的例子只涉及”调一个服务、回滚一步”,看起来很简单。但实际业务中,一个方法可能调三四个远程服务(扣库存 + 扣积分 + 发优惠券),失败时要逆序补偿每一步,代码很容易写漏。而且补偿本身也可能失败(比如
deleteById网络超时),这时候数据照样不一致。所以需要可靠补偿的场景,优先用上面 4.2 的消息表方案,而不是手写补偿逻辑。
4.5 使用 Seata 框架
如果你们公司已经引入了 Seata,那就可以直接用。Seata 支持多种模式:
| 模式 | 说明 |
|---|---|
| AT 模式 | 最常用,自动管理全局事务和回滚,对业务代码侵入最小 |
| TCC 模式 | 需要手动编写 Try / Confirm / Cancel 三个阶段的逻辑 |
| Saga 模式 | 长事务解决方案,通过正向操作和补偿操作来管理事务 |
| XA 模式 | 基于数据库 XA 协议的强一致方案,性能较低,适合对一致性要求极高的场景 |
其中,AT 模式用得最多,因为它基本不需要改业务代码,只需要加一个注解:
@GlobalTransactionalpublic void createOrder(Order order) { orderMapper.insert(order); // 本地事务:写订单表 productClient.deductStock(order.getProductId()); // 远程调用 1:扣库存 pointClient.deductPoints(order.getUserId(), 100); // 远程调用 2:扣积分 // 任何一步失败,以上操作全部自动回滚}加了 @GlobalTransactional 注解后,Seata 会自动管理整个分布式事务,任何一步失败都会自动回滚——包括本地事务和所有远程调用。
注意:Seata 用起来方便,但要额外部署 Server 端、增加运维成本,AT 模式对数据库性能也有一定影响。如前所述,很多互联网业务项目更倾向用本地事务 + MQ + 补偿的方案来实现最终一致性。只有当业务对一致性要求较高、或涉及多步复杂远程调用时,才值得引入 Seata。
六、善用 AI 辅助开发
前面五章讲了微服务开发的各个核心知识点——从配置中心、注册中心,到远程调用、网关、分布式锁、分布式事务。内容不少,对于刚接触微服务的新人来说,不可能一次性全部记住,也不需要全部记住。
实际开发中,更高效的方式是:先把整体概念理清楚,知道每个技术点是干什么的、大概怎么用。具体细节和配置写法,用到的时候再查。 遇到不确定的问题,可以直接问 AI。
像 Claude Code 这样的 AI 编程工具,可以直接打开你的项目目录,自动读取所有代码和配置文件,然后用自然语言向它提问。不需要你手动复制粘贴代码,也不需要自己一行一行去翻。
微服务开发过程中,遇到不确定的问题可以直接问:
- “这个项目的注册中心和配置中心是怎么配的?”
- “这个项目的远程调用方式是什么?OpenFeign 还是 Dubbo?”
- “这个项目的网关路由规则是怎么配的?”
- “我要新增一个远程调用接口,参考哪个现有代码来写?”
- “这个分布式锁的用法对不对?有没有什么边界问题?”
- “服务注册不上 Nacos,可能是什么原因?”
- “远程调用报找不到服务,帮我排查一下”
- “配置改了但没生效,可能是什么原因?”
核心思路:遇到不确定的问题,先问 AI。AI 能解决的就不用打扰同事了;AI 解决不了的,你已经对问题有了基本概念,带着这个基础再去问同事,沟通效率会高很多,同事也不需要从零给你解释,同时也会觉得你是有思考过的。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!














