微服务系统中如何开发

18396 字
92 分钟
微服务系统中如何开发


从开发角度来说,微服务项目和单体项目的日常开发区别并没有想象中那么大。因为说到底,微服务架构下的每一个服务,本质上也都是一个独立的 Spring Boot 项目,我们平时写 Controller、Service、Mapper、SQL、接口开发等工作,和在单体项目中并没有太大区别。

真正的区别在于:原本在一个项目内部完成的事情,被拆分到了多个服务之间协同完成。因此在开发过程中,会额外涉及一些单体项目中不太会遇到的问题,例如服务的注册与发现、远程配置管理、服务间远程调用、API 网关、分布式锁、分布式事务等。

所以,对于刚接触微服务的新人来说,不必把它想得过于高深。先把每个服务当成一个普通的 Spring Boot 项目去理解,再逐步掌握服务之间的协作方式,会更容易上手。

微服务架构
微服务架构

一、远程配置与注册中心#

在微服务架构下,有两个基础组件几乎是必须的:配置中心注册中心

配置中心解决的是”配置怎么统一管理”的问题,注册中心解决的是”服务之间怎么找到对方”的问题。

这两个东西看起来是独立的功能,但在实际项目中,它们经常由同一个中间件来承担,比如 Nacos 就同时支持配置中心和注册中心。

1、远程配置#

1.1 为什么需要远程配置?#

在单体项目里,配置文件一般就直接放在项目里,比如 application.yml,改了配置就重启服务就行。

但在微服务架构下,通常有这些问题:

  • 公共配置分散,改一处要动 N 个项目:微服务下有十几个甚至几十个服务,很多配置是多个服务共用的(比如 Redis 地址、数据库连接、第三方接口密钥)。虽然 Spring Boot 自带的 Profile 机制(application-dev.ymlapplication-prod.yml)也能做多环境切换,但当服务数量多了之后,每个服务都维护自己的一套配置文件,公共配置一旦变更就要逐个修改 N 个项目,很容易漏改或改错。配置中心可以把公共配置集中管理,改一次所有服务都生效
  • 敏感配置不适合放在代码仓库中:数据库密码、第三方接口密钥等敏感信息,如果直接写在项目的配置文件里,会被提交到 Git 仓库,存在泄露风险。配置中心可以集中管理这些敏感配置,代码仓库里只保留非敏感的基本配置
  • 配置修改需要重启才能生效:有些配置改了之后,服务必须重启才能生效,这在生产环境是很不方便的

所以,微服务架构下,通常会引入配置中心来统一管理配置。

简单理解:配置中心就是把你之前放在 application.yml 里的配置,搬到远程服务器上统一管理。你可以随时改,改完之后服务可以做到动态生效,不需要重启。

1.2 常见的配置中心#

配置中心说明
Nacos阿里开源,国内使用最广泛,同时支持注册中心和配置中心
Apollo携程开源,功能丰富,配置管理能力很强
Spring Cloud ConfigSpring Cloud 官方提供,需要依赖 Git 等存储

其中,Nacos 是国内企业用得最多的,很多公司的微服务架构都是基于 Spring Cloud Alibaba + Nacos 搭建的。

1.3 远程配置一般怎么用?#

以 Nacos 为例,一般的用法是这样的:

1
1

1)在 Nacos 控制台上创建配置

你登录 Nacos 控制台,在里面创建一个配置,比如:

  • Data ID:order-service-dev.yml
  • Group:DEFAULT_GROUP
  • 配置内容:就是你的 yml 配置,比如数据库连接、Redis 地址等

1
1

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 参数就行,不用动配置文件。

7
7

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
@RefreshScope
public 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.1localhost,因为 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 注册中心的工作流程#

3
3

2.4 常见的注册中心#

注册中心说明
Nacos阿里开源,国内使用最广泛,同时支持注册中心和配置中心
EurekaNetflix 开源,Spring Cloud 早期默认组件,目前已停止维护
ZooKeeperApache 基金会项目,Dubbo 早期默认使用的注册中心
ConsulHashiCorp 开源,功能丰富,支持多数据中心

其中,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 控制台的”服务列表”页面,就能看到所有已注册的服务及其实例信息。

4
4

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
@SpringBootApplication
public 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)在业务代码中直接注入使用

@Service
public 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 实现该接口
@RestController
public 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 接口
@Autowired
private UserClient userClient;

这样做的好处:

  • 消费方不用自己写 Feign 接口,直接引用提供方的 jar 包,减少重复代码
  • 接口和实现天然一致,因为 Controller 实现了 Feign 接口,路径、参数、返回值都是一样的,不会出现”调用方写的接口和提供方不一致”的问题
  • DTO 共享,请求和响应的对象也放在 api 模块里,双方用的是同一个类

简单理解:就像你调用别人的 SDK,人家把接口定义好打成 jar 包给你用,你不用关心人家怎么实现的,直接调就行。

入职后看到项目结构里有 xxx-api 模块,多半就是这种模式。

3.2 contextId 解决同服务名多个 FeignClient(了解)#

有些项目会把一个大服务的接口拆成多个 FeignClient(比如用户查询一组、用户管理一组),这时会出现两个 @FeignClientname 相同的情况,启动会报错。加上 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 的区别#

对比项OpenFeignDubbo
通信协议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: 20880

3)定义服务接口

Dubbo 的做法是:把接口定义抽成一个独立的模块(API 模块),服务提供方和消费方都依赖这个模块。

// user-api 模块(公共接口)
public interface UserService {
UserDTO getUserById(Long id);
}

4)服务提供方实现接口

在用户服务中实现这个接口,并暴露为 Dubbo 服务:

// user-service 模块(服务提供方)
@DubboService
public 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 模块(服务消费方)
@Service
public 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
@SpringBootApplication
public 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 服务列表,消费者默认不会注册。 消费者只是订阅它需要调用的服务,不会把自己作为”服务”注册上去。

5
5

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

6
6

4.4 怎么快速判断项目用的是哪种?#

入职后,快速判断项目远程调用方式的方法:

特征OpenFeignDubbo
看依赖有 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 即可:

@Configuration
public class FeignConfig {
@Bean
public Retryer retryer() {
// 参数:初始间隔(ms)、最大间隔(ms)、最大尝试次数(含首次调用)
return new Retryer.Default(100, 1000, 3);
}
}

注意:这是全局配置,对所有 FeignClient 生效。如果项目中既有查询接口(可重试)又有写入接口(不可重试),就不要全局开启,可以在 @FeignClientconfiguration 属性上针对单个客户端指定配置。

如果用的是 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);
}
// 降级类
@Component
public 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=truefallback 属性会静默失效,不报错也不降级。另外,降级类一定要加 @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 熔断(了解)#

熔断和降级通常会配合使用。当某个服务调用失败率过高、响应时间过长,或者异常次数达到阈值后,熔断器会暂时阻止继续调用下游服务,避免故障扩散。此时系统通常会进入降级逻辑,返回默认值、缓存数据或友好提示。就像家里的保险丝,电流过大时自动断开,保护整个电路不烧毁。

常见的熔断框架有 SentinelResilience4j,国内企业用 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 路由多配了两个东西:CacheRequestFilterValidateCodeFilter。这两个就是过滤器(Filter)——请求在转发到服务之前,先经过过滤器处理。比如 ValidateCodeFilter 会在登录请求到达认证服务之前,先校验验证码是否正确,不对的话直接拦截,不用等到服务层才报错。

3)启动类

@SpringBootApplication
public 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/**,对应的 urilb://ruoyi-system,所以这个接口是 ruoyi-system 处理的。

3)确认转发后的实际路径

由于配了 StripPrefix=1,转发前会去掉第一段 /system,所以 ruoyi-system 收到的请求路径是 /user/list,对应 Controller 里的 @GetMapping("/user/list")

举几个例子:

前端请求路径匹配的路由转发到哪个服务服务收到的路径
/auth/loginPath=/auth/**ruoyi-auth/login
/system/user/listPath=/system/**ruoyi-system/user/list
/code/gen/tablesPath=/code/**ruoyi-gen/gen/tables
/file/uploadPath=/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 自带的锁(如 synchronizedReentrantLock)来解决:

// 单体项目:用 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 自带的锁在多实例部署的情况下,起不到互斥作用

这时候就需要分布式锁

8
8

2、什么是分布式锁?#

分布式锁,就是让多个服务实例在操作同一份数据时,也能做到互斥

不管是哪台机器上的线程,谁先抢到锁,谁就能执行;其他线程必须等锁释放后才能继续。

实例 A → 抢锁 → 成功 → 执行业务 → 释放锁
实例 B → 抢锁 → 失败,等待
实例 C → 抢锁 → 失败,等待

3、常见的分布式锁实现方式#

方式说明
Redis 分布式锁最常用,基于 Redis 的 SETNX 命令实现
Redisson基于 Redis 的分布式锁框架,封装得更完善
ZooKeeper 分布式锁基于 ZooKeeper 的临时顺序节点实现
数据库锁通过数据库行锁或乐观锁实现,性能较低

其中,Redis + Redisson 是企业里用得最多的方案

4、Redis 分布式锁的基本原理#

最简单的实现方式是借助 Redis 的 SETNX 命令,核心思路就三步:

  1. 抢锁:往 Redis 写一个 key,谁先写成功谁就拿到锁(SETNX = set if not exists,key 不存在才写入成功)
  2. 设过期时间:防止拿到锁的服务突然宕机,导致锁永远不释放
  3. 释放锁:业务执行完,把这个 key 删掉

下面是一个最简实现(基于 StringRedisTemplate),只有两个方法:tryLock 抢锁、unlock 释放锁:

@Component
public 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 参数版本,从根上避免这个坑。

工作中的一个使用场景是定时任务防重复执行(多台机器都部署了同一个定时任务,但只希望其中一台真正执行):

@Autowired
private 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() 方法:

@Autowired
private 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
@Component
public 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 注解就能搞定:

// 单体项目:一个事务搞定
@Transactional
public void createOrder(Order order) {
orderMapper.insert(order); // 写订单表
orderItemMapper.batchInsert(items); // 写订单明细表
productMapper.deductStock(productId); // 扣库存
}

但在微服务架构下,这些操作可能分布在不同服务中:

创建订单 → 订单服务(写订单表、订单明细表)
扣库存 → 商品服务(扣商品库存)

这时候,@Transactional 只能保证订单服务自己数据库的事务,商品服务那边出了问题,订单服务是管不了的:

这就是分布式事务问题。

9
9

2、解决分布式事务的常见思路#

分布式事务没有完美解决方案,只有在一致性和性能之间做取舍

常见的思路有:

方案说明一致性
2PC(两阶段提交)准备阶段 + 提交阶段,所有参与者都成功才提交强一致
TCCTry-Confirm-Cancel,手动编写三个阶段的逻辑(Seata 支持此模式,详见 4.5)最终一致
消息事务通过消息队列保证最终一致性最终一致
Seata阿里开源的分布式事务框架,支持多种模式看选用的模式

3、先搞清楚:大多数业务不需要强一致性#

这是新人特别容易纠结的一个点。

很多新人一听到”分布式事务”,就觉得必须保证所有服务的数据实时一致。但实际上,大部分业务场景只需要最终一致性就够了。而且很多互联网业务项目并不会引入 Seata,而是采用本地事务 + MQ + 补偿机制实现最终一致性;只有一致性要求较高或业务复杂的场景,才可能采用 Seata、TCC 等方案

什么叫最终一致性?

不要求所有服务的数据在同一时刻完全一致,但经过一段时间后,数据最终会变成一致的。

比如下单场景:

  • 用户下单后,订单先创建成功
  • 过一会儿,库存扣减成功
  • 如果库存扣减失败,再把订单取消掉

用户看到的可能有一小段时间的”不一致”,但最终数据是对的。

哪些场景需要强一致性?

  • 转账、支付等金融场景(钱必须实时一致,不能有中间态)

哪些场景只需要最终一致性?

  • 大部分订单、库存、物流类业务
  • 通知、消息类业务
  • 积分、优惠券等

关于库存的一个常见疑问:库存不是怕超卖吗,为什么也算最终一致性?因为防止超卖靠的是「分布式锁 + 原子扣减」(上一章讲过的 lock:stock:{skuId}),而不是靠分布式事务——锁保证同一时刻只有一个请求在扣库存,扣减成功后通过 MQ 通知其他服务即可。

这两个概念新人特别容易混,一定要分清分布式事务解决的是「跨服务数据一致性」问题(A 服务成功了,B 服务也要成功);分布式锁解决的是「并发竞争」问题(同一时刻只有一个线程能操作)。两者关注点不同,不要混为一谈。

4、实际开发中最常见的处理方式#

说实话,很多中小型公司的微服务项目,并不一定会引入完整的分布式事务框架,甚至有些公司压根不会对分布式事务做系统性处理——出了数据不一致就人工修。原因很简单:发生事务不一致的概率本身就很低(网络正常、服务不宕机的绝大部分时间里,数据都是一致的),如果业务本身能容忍这个极低概率的不一致,那加一堆补偿逻辑、消息表、分布式事务框架反而增加了系统复杂度,得不偿失。能简单解决的问题就不要用复杂方案,这是 KISS 原则

4.1 本地事务 + 普通消息(最常用,但有隐患)#

这是企业里用得最多的一种方式。注意这里用的是 MQ 的「普通消息」,不是「事务消息」,所以会有一个先天的隐患,下面会专门讲。

实现思路

12
12

代码示例(以 RocketMQ 为例):

1)订单服务(生产者)——创建订单后发送扣库存消息

@Service
public 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
@NoArgsConstructor
public 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 消息,而是先在本地数据库里写一条消息记录,和业务数据在同一个事务中,然后由定时任务扫描并发送。

流程

10
10

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)订单服务——在本地事务中同时写入业务数据和消息记录

@Service
public 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)定时任务——扫描消息表并发送

@Component
public 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 的隐患。

原理

11
11

和本地消息表(4.2)的对比

本地消息表(4.2)事务消息(4.3)
可靠性✅ 可靠✅ 可靠
需要额外建表✅ 需要❌ 不需要
需要定时任务✅ 需要❌ 不需要
实现复杂度中等较低(RocketMQ 原生支持)
依赖无特殊依赖依赖 RocketMQ

代码示例

@Service
public 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 秒),高并发下连接池会被打爆,整个服务都不可用。所以这里先提交本地事务、释放连接,再调远程服务,失败了手动补偿。

思路

  1. 先执行主业务逻辑
  2. 再调用其他服务
  3. 如果调用失败,手动回滚主业务的数据
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 模式用得最多,因为它基本不需要改业务代码,只需要加一个注解:

@GlobalTransactional
public 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 解决不了的,你已经对问题有了基本概念,带着这个基础再去问同事,沟通效率会高很多,同事也不需要从零给你解释,同时也会觉得你是有思考过的。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
微服务系统中如何开发
https://study-docs-158.pages.dev/posts/微服务系统中如何开发/
作者
我的学习小铺
发布于
2026-08-01
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
我的学习小铺
学而时习之
分类
标签
最新动态
站点统计
文章
44
分类
8
标签
9
总字数
126,514
运行时长
0
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.15.3
文章许可
CC BY-NC-SA 4.0
1
一、远程配置与注册中心
1、远程配置
1.1 为什么需要远程配置?
1.2 常见的配置中心
1.3 远程配置一般怎么用?
1.4 哪些配置适合放远程,哪些不适合?
1.5 配置修改后,服务怎么感知?
1.6 远程配置相关的常见问题
2、注册中心(服务的注册与发现)
2.1 为什么需要注册中心?
2.2 注册中心是做什么的?
2.3 注册中心的工作流程
2.4 常见的注册中心
2.5 服务注册与发现在代码中是怎么体现的?
2.6 关于服务注册与发现的几个关键概念
2.7 注册中心相关的常见问题
3、一些注意事项
2
二、远程调用
1、为什么需要远程调用?
2、常见的远程调用方式
3、OpenFeign 的基本用法
3.1 Feign 接口抽取到独立 API 模块(最常见的做法)
3.2 contextId 解决同服务名多个 FeignClient(了解)
4、Dubbo 的基本用法
4.1 Dubbo 和 OpenFeign 的区别
4.2 Dubbo 的基本用法
4.3 Dubbo 使用时的注意事项
4.4 怎么快速判断项目用的是哪种?
5、远程调用失败怎么办?
5.1 超时配置
5.2 重试
5.3 降级(Fallback)
5.4 熔断(了解)
6、远程调用时需要注意的问题
3
三、API 网关
1、为什么需要 API 网关?
2、Spring Cloud Gateway 快速上手
3、常见问题
3.1 放开接口时,网关层和服务层都要放
3.2 通过接口路径定位到哪个服务处理的
3.3 跨域处理(了解)
4
四、锁与并发问题
1、为什么微服务下并发问题更复杂?
2、什么是分布式锁?
3、常见的分布式锁实现方式
4、Redis 分布式锁的基本原理
5、Redisson 分布式锁的基本用法
Redisson 加锁 API 速览
5.1 Redisson 原生写法
5.2 实际项目中的封装方式
6、分布式锁使用时的注意事项
5
五、事务如何解决
1、微服务下的事务问题
2、解决分布式事务的常见思路
3、先搞清楚:大多数业务不需要强一致性
4、实际开发中最常见的处理方式
4.1 本地事务 + 普通消息(最常用,但有隐患)
4.2 本地消息表 + MQ(推荐)
4.3 RocketMQ 事务消息(推荐)
4.4 本地事务 + 补偿机制
4.5 使用 Seata 框架
6
六、善用 AI 辅助开发