Merge pull request #1493 from CyC2018/update-backend-notes

Update Spring、分布式和消息队列
This commit is contained in:
CyC2018
2026-10-28 13:39:10 +08:00
committed by GitHub
4 changed files with 485 additions and 17 deletions
+211 -2
View File
@@ -23,6 +23,9 @@
* [缓存](#缓存)
* [七、Spring 中的设计模式](#七spring-中的设计模式)
* [附录:实测](#附录实测)
* [A1 单例 Bean 的成员变量](#a1-单例-bean-的成员变量)
* [A2 锁与事务的先后](#a2-锁与事务的先后)
* [A3 Bean 创建的调用栈](#a3-bean-创建的调用栈)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -49,6 +52,8 @@ public class OrderService {
- 对象只依赖接口,替换实现、在测试中注入模拟对象都不需要修改代码;
- 对象的创建、作用域和生命周期由容器统一管理,事务、缓存这类功能也是容器在创建对象时织入的。
容器通过注解知道要创建哪些对象:类上标注 @Component 或者它的派生注解 @Service、@Repository、@Controller,启动时由组件扫描发现;第三方库中的类无法修改源码,就在 @Configuration 类中写一个返回该对象的方法,并标注 @Bean。
依赖注入有构造器注入、Setter 注入和字段注入(把 @Autowired 加在字段上)三种方式。Spring 官方推荐构造器注入:依赖可以声明为 final,对象创建出来就是完整可用的,缺少依赖时启动就会失败。
@Autowired 和 @Resource 都能注入依赖,区别在于查找的顺序:
@@ -68,6 +73,23 @@ public class OrderService {
### Bean 的生命周期
容器启动时,AbstractApplicationContext 的 refresh() 分两步创建 Bean。第一步解析配置:扫描 @Component,解析 @Bean 方法和自动配置类,把每个 Bean 的类名、作用域、依赖等信息保存为 BeanDefinition,这时还没有创建任何对象。这一步由 ConfigurationClassPostProcessor 完成,它是一个 BeanFactoryPostProcessor,这类扩展点在创建对象之前读取或修改 BeanDefinition。
第二步在 finishBeanFactoryInitialization() 中,按 BeanDefinition 依次创建所有非懒加载的单例,创建过程依靠反射。在一个 Bean 的构造函数和带 @Autowired 的 Setter 方法中打印调用栈,按调用顺序整理如下(Spring Boot 3.3 实测,见[附录 A3](#a3-bean-创建的调用栈),最后一行 initializeBean 来自源码):
```text
AbstractApplicationContext.refresh()
└─ finishBeanFactoryInitialization() → preInstantiateSingletons() 遍历 BeanDefinition
└─ getBean() → doGetBean() 先查单例缓存,没有再创建
└─ createBean() → doCreateBean()
├─ createBeanInstance() → Constructor.newInstance() 实例化:反射调用构造函数
├─ populateBean() → AutowiredAnnotationBeanPostProcessor
│ .postProcessProperties() → Method.invoke() 属性填充:反射调用 Setter
└─ initializeBean() Aware 回调、初始化、AOP 代理
```
@Autowired 加在字段上时,同样由 postProcessProperties() 处理,区别是用 Field.set() 反射赋值。
一个单例 Bean 从创建到销毁会经过以下步骤:
1. 实例化:调用构造函数创建对象;
@@ -113,7 +135,7 @@ public class CounterController {
}
```
例如用 64 个线程并发请求 1 万次,多次运行中每轮丢失的更新从 0 次到 67 次不等(Spring Boot 3.3 实测,见[附录:实测](#附录实测))。是否丢失、丢失多少取决于线程执行的时序,这类问题在测试中很难稳定复现。解决方法是不在单例中保存状态,把数据放在方法的局部变量中;确实需要共享时使用 AtomicInteger 等线程安全的类,每个线程各自的数据放在 ThreadLocal 中。
例如用 64 个线程并发请求 1 万次,多次运行中每轮丢失的更新从 0 次到 67 次不等(Spring Boot 3.3 实测,见[附录 A1](#a1-单例-bean-的成员变量))。是否丢失、丢失多少取决于线程执行的时序,这类问题在测试中很难稳定复现。解决方法是不在单例中保存状态,把数据放在方法的局部变量中;确实需要共享时使用 AtomicInteger 等线程安全的类,每个线程各自的数据放在 ThreadLocal 中。
需要注意的是,把 prototype 的 Bean 注入到单例中,只会在创建单例时注入一次,之后每次使用的都是同一个对象。需要每次得到新对象时,注入 ObjectProvider 并在使用时调用 getObject()。
@@ -177,6 +199,56 @@ proxy.create();
提交事务
```
生成的代理类 jdk.proxy1.$Proxy0 的父类是 java.lang.reflect.Proxy。Java 不支持多继承,代理类已经继承了 Proxy,就只能通过实现接口与目标类关联,这就是 JDK 动态代理只能代理接口的原因,没有实现接口的类要用 CGLIB 生成子类(JDK 21 实测)。
动态代理与反射是两个概念:反射指的是在运行时获取类的信息、调用方法和读写字段,例如 method.invoke(target, args);动态代理指的是在运行时生成新的类,例如上面的 Proxy.newProxyInstance() 生成了一个实现 OrderService 接口的类。JDK 动态代理依赖反射:代理类把每次调用都交给 InvocationHandler,传入表示被调用方法的 Method 对象,再由 method.invoke() 调用目标对象。
实际开发中不需要自己创建代理,用 @Aspect 声明切面,Spring 在创建 Bean 时生成代理。常见的做法是自定义一个注解,让切面拦截带有这个注解的方法。例如给下单方法加分布式锁:
```java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String prefix(); // 锁的 key 为 prefix 加第一个参数
}
@Aspect
@Component
@Order(1) // 比事务切面先执行,锁包在事务外面
public class DistributedLockAspect {
private final StringRedisTemplate redis; // 构造器注入,省略构造函数
@Around("@annotation(lock)") // 拦截带有 @DistributedLock 的方法
public Object around(ProceedingJoinPoint pjp, DistributedLock lock) throws Throwable {
String key = lock.prefix() + pjp.getArgs()[0];
String value = UUID.randomUUID().toString();
while (!Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(key, value, Duration.ofSeconds(30)))) {
Thread.onSpinWait(); // 简化:自旋重试
}
try {
return pjp.proceed(); // 调用原方法
} finally {
redis.execute(UNLOCK, List.of(key), value); // 是自己的锁才删除
}
}
}
@Service
public class OrderService {
@Transactional
@DistributedLock(prefix = "lock:order:user:")
public boolean createOrder(int userId, int skuId) {
// 查询这个用户是否已经买过这个商品,没有就插入订单,完整代码见附录 A2
}
}
```
@Around 中的 @annotation(lock) 表示拦截所有带 @DistributedLock 的方法,并把注解对象作为参数 lock 传入;pjp.proceed() 调用原方法。UNLOCK 是先比较再删除的 Lua 脚本,原理见 [Redis 的 SET NX 指令](分布式.md#redis-的-set-nx-指令)。记录日志、限流、幂等校验也都是这种写法。
需要注意的是,createOrder() 上同时有事务和锁两个切面,它们的先后决定了锁是否包住事务。锁必须在外层:如果事务在外层,锁释放之后事务才提交,这期间另一个请求拿到锁,查不到还没有提交的订单,就会再创建一个。20 个请求同时为同一个用户下单,锁在事务外时 10 轮都只创建了 1 个订单;锁在事务内时 10 轮中有 8 轮创建了 2 个(Spring Boot 3.3、MySQL 8.0 实测,见[附录 A2](#a2-锁与事务的先后))。
两个切面都没有指定顺序时,执行的先后是不确定的。@Order 的值越小越先执行、越靠外层,事务切面的默认值是 Ordered.LOWEST_PRECEDENCE,所以上面给锁的切面加上了 @Order(1)。
### 自调用
代理只能拦截从外部经过代理的调用。在目标对象内部调用自己的另一个方法时,等价于 this.inner(),调用的是原始对象,不经过代理,切面不会生效:
@@ -206,6 +278,21 @@ public class OrderService {
需要注意的是,默认只有 RuntimeException 和 Error 会触发回滚,受检异常(例如 IOException)不会回滚,需要通过 rollbackFor 指定。
除了声明式事务,还可以用 TransactionTemplate 编程式地使用事务,事务的范围就是传入的回调:
```java
transactionTemplate.execute(status -> {
jdbc.update("UPDATE goods SET stock = stock - 1 WHERE id = ?", 1001);
jdbc.update("INSERT INTO orders (user_id, goods_id) VALUES (?, ?)", 1024, 1001);
if (riskCheckFailed) {
status.setRollbackOnly(); // 不抛异常也能回滚
}
return null;
});
```
回调正常返回时提交,抛出异常或者调用了 setRollbackOnly() 时回滚。它不依赖代理,不会因为自调用而失效,事务的范围也可以只是方法中的一段,适合只有一小段操作需要事务、或者要按条件决定回滚的场景,其余情况用 @Transactional 更简洁。
### 传播行为
传播行为指的是一个事务方法调用另一个事务方法时,事务如何传递。常用的有以下三种:
@@ -236,14 +323,37 @@ Spring Boot 并不是新的框架,容器、AOP、事务仍然是 Spring 提供
自动配置的过程:
1. 启动类上的 @SpringBootApplication 包含了 @EnableAutoConfiguration;
1. 启动类上的 @SpringBootApplication 组合了三个注解:@SpringBootConfiguration(相当于 @Configuration,启动类中也可以定义 @Bean)、@ComponentScan(扫描启动类所在的包及其子包)和 @EnableAutoConfiguration(开启自动配置);
2. 启动时读取所有 jar 包中的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件,得到自动配置类的列表,Spring Boot 2.7 之前写在 META-INF/spring.factories 中;
3. 每个自动配置类都带有条件注解,条件满足时才生效。例如 @ConditionalOnClass 要求类路径上存在某个类,@ConditionalOnMissingBean 要求用户没有定义同类型的 Bean,@ConditionalOnProperty 根据配置项决定。
例如引入 spring-boot-starter-jdbc 和数据库驱动后,数据源的自动配置类就会生效,按配置文件中的地址创建一个 DataSource;如果用户自己定义了 DataSource,由于 @ConditionalOnMissingBean,自动配置不再创建。因此用户的配置总是优先于自动配置。
自定义 starter 也是同样的做法。例如把公司内部的短信客户端做成 starter,自动配置类如下:
```java
@AutoConfiguration
@EnableConfigurationProperties(SmsProperties.class)
public class SmsAutoConfiguration {
@Bean
@ConditionalOnMissingBean // 用户自己定义了 SmsClient 时不再创建
public SmsClient smsClient(SmsProperties props) {
return new SmsClient(props.sign());
}
}
@ConfigurationProperties("sms") // 绑定配置文件中 sms.* 的配置
public record SmsProperties(String sign) {}
```
再在 jar 包的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件中写上 SmsAutoConfiguration 的全名。其它项目引入这个 jar、配置 sms.sign 之后,就能直接注入 SmsClient;自己定义了 SmsClient 时,注入的是自己定义的那个。
需要注意的是,自动配置类要放在不会被组件扫描到的包中,只通过 imports 文件加载。自动配置类在用户定义的 Bean 都注册之后才处理,@ConditionalOnMissingBean 才能看到用户的 Bean;如果它被组件扫描到,就会和普通的配置类一起提前处理,条件可能判断错误。第三方 starter 按约定命名为 xxx-spring-boot-starter,spring-boot-starter-xxx 留给 Spring Boot 官方使用。
## 五、Spring MVC
Spring MVC 是基于 Servlet 的 Web 框架,按模型-视图-控制器(Model-View-Controller, MVC)的方式组织代码:Controller 接收请求、调用业务逻辑,Model 是要展示的数据,View 把数据渲染成页面。前后端分离后,Controller 通常直接把数据写成 JSON 返回,没有 View。
### 请求的处理过程
所有请求都先交给一个 DispatcherServlet,由它的 doDispatch 方法完成分发:
@@ -266,6 +376,8 @@ Spring Boot 并不是新的框架,容器、AOP、事务仍然是 Spring 提供
拦截器与过滤器(Filter)的区别:过滤器是 Servlet 规范定义的,在请求进入 DispatcherServlet 之前执行,拿不到要调用哪个 Controller 方法;拦截器由 Spring MVC 调用,可以拿到 Handler,并且能使用 Spring 容器中的 Bean。
拦截器与 Spring AOP 的区别:拦截器按 URL 匹配,只作用于进入 DispatcherServlet 的请求;AOP 按切点匹配任意 Spring Bean 的方法,Service 之间的调用也能拦截。因此登录校验、访问日志这类与请求有关的处理用拦截器,事务、缓存、分布式锁这类与业务方法有关的处理用 AOP。
## 六、MyBatis
### #{} 和 ${} 的区别
@@ -296,6 +408,8 @@ List<Order> byUserUnsafe(@Param("userId") String userId);
MyBatis 在启动时为每个 Mapper 接口生成 JDK 动态代理,例如上面的 OrderMapper 注入的对象类型是 jdk.proxy2.$Proxy63,调用处理器是 MapperProxy。调用接口方法时,MapperProxy 按“接口全名 + 方法名”找到对应的 SQL,绑定参数后交给 SqlSession 执行,再把结果映射成返回类型。
这个代理是通过 FactoryBean 放进容器的。FactoryBean 是一种特殊的 Bean,容器按名称获取它时,得到的是它的 getObject() 返回的对象,名称前加 & 才能取到 FactoryBean 本身。MyBatis-Spring 为每个 Mapper 接口注册一个 MapperFactoryBean:getBean("app.OrderMapper") 得到的是上面的代理对象,getBean("&app.OrderMapper") 得到的是 MapperFactoryBean(Spring Boot 3.3、mybatis-spring 3.0 实测)。BeanFactory 则是容器本身的根接口,负责创建和管理所有 Bean,两者只是名称相近。
### 动态 SQL
XML 中可以用 if、where、foreach 等标签按参数拼接 SQL:
@@ -312,6 +426,29 @@ XML 中可以用 if、where、foreach 等标签按参数拼接 SQL:
只传 status 时生成 WHERE status = ?,两个都传时生成 WHERE user_id = ? AND status = ?,where 标签会去掉开头多余的 AND。
foreach 用于拼接 IN 列表。方法有多个参数时用 @Param 给参数命名,XML 中按名称引用:
```java
List<Order> byStatuses(@Param("userId") Integer userId, @Param("statuses") List<Integer> statuses);
```
```xml
<select id="byStatuses" resultType="demo.App$Order">
SELECT id, user_id AS userId, status FROM orders
WHERE user_id = #{userId} AND status IN
<foreach collection="statuses" item="s" open="(" separator="," close=")">#{s}</foreach>
</select>
```
传入 1024 和 [0, 3] 时,生成的 SQL 和参数为:
```text
==> Preparing: SELECT id, user_id AS userId, status FROM orders WHERE user_id = ? AND status IN ( ? , ? )
==> Parameters: 1024(Integer), 0(Integer), 3(Integer)
```
列表中的每个元素仍然是一个占位符。需要注意的是,列表为空时 foreach 不输出任何内容,括号也没有,SQL 变成 status IN 后面直接结束,执行时报语法错误,所以调用前要先判断列表是否为空。
### 缓存
- 一级缓存:默认开启,作用范围是一个 SqlSession,同一个会话中相同的查询只执行一次,执行增删改或提交后清空。与 Spring 集成时,不在事务中的每次调用都会使用新的 SqlSession,一级缓存基本不起作用;
@@ -353,6 +490,8 @@ public void on(OrderPaidEvent e) { ... } // 积分服务、短信
## 附录:实测
### A1 单例 Bean 的成员变量
Spring Boot 3.3.5、JDK 21,Apple M1 Pro。CounterController 与正文相同,用 Python 的线程池以 64 个线程请求 /count,每轮 1 万次,用请求前后 /count 的返回值计算增加了多少,应用启动后连续运行 3 轮:
```text
@@ -374,6 +513,71 @@ Spring Boot 3.3.5、JDK 21,Apple M1 Pro。CounterController 与正文相同,
第 3 轮:64 个线程并发请求 10000 次,计数增加 9998,丢失 2 次更新
```
### A2 锁与事务的先后
Spring Boot 3.3.5(spring-boot-starter-aop、spring-boot-starter-data-redis)、JDK 21,MySQL 8.0.44(默认的可重复读),Redis 7.4.1,Apple M1 Pro。切面和 OrderService 与正文相同,createOrder() 的方法体为:
```java
Integer n = jdbc.queryForObject(
"SELECT COUNT(*) FROM t3_order WHERE user_id = ? AND sku_id = ?", Integer.class, userId, skuId);
if (n > 0) {
return false; // 一人一单
}
jdbc.update("INSERT INTO t3_order (user_id, sku_id) VALUES (?, ?)", userId, skuId);
return true;
```
每轮先清空 t3_order,再用 20 个线程同时调用 createOrder(1024, 10086),统计返回 true 的次数和表中的行数。对照组加一个配置类 @EnableTransactionManagement(order = 0),让事务切面比锁的切面(@Order(1))更靠外:
```text
锁在事务外(锁 @Order(1),事务切面为默认的 LOWEST_PRECEDENCE)
第 1~10 轮:20 个并发请求,返回成功 1 次,订单表中 1 行
锁在事务内(事务切面 order = 0)
第 1 轮:20 个并发请求,返回成功 2 次,订单表中 2 行
第 2 轮:20 个并发请求,返回成功 2 次,订单表中 2 行
第 3 轮:20 个并发请求,返回成功 1 次,订单表中 1 行
第 4 轮:20 个并发请求,返回成功 2 次,订单表中 2 行
第 5 轮:20 个并发请求,返回成功 1 次,订单表中 1 行
第 6 轮:20 个并发请求,返回成功 2 次,订单表中 2 行
第 7 轮:20 个并发请求,返回成功 2 次,订单表中 2 行
第 8 轮:20 个并发请求,返回成功 2 次,订单表中 2 行
第 9 轮:20 个并发请求,返回成功 2 次,订单表中 2 行
第 10 轮:20 个并发请求,返回成功 2 次,订单表中 2 行
```
### A3 Bean 创建的调用栈
环境同 A2。在一个 @Component 的构造函数和带 @Autowired 的 Setter 方法中 new Throwable().getStackTrace(),只保留 org.springframework.beans、org.springframework.context 和反射的帧(从栈顶到栈底):
```text
== 构造函数
DirectConstructorHandleAccessor.newInstance
Constructor.newInstanceWithCaller
Constructor.newInstance
BeanUtils.instantiateClass
SimpleInstantiationStrategy.instantiate
AbstractAutowireCapableBeanFactory.instantiateBean
AbstractAutowireCapableBeanFactory.createBeanInstance
AbstractAutowireCapableBeanFactory.doCreateBean
AbstractAutowireCapableBeanFactory.createBean
AbstractBeanFactory.lambda$doGetBean$0
DefaultSingletonBeanRegistry.getSingleton
AbstractBeanFactory.doGetBean
AbstractBeanFactory.getBean
DefaultListableBeanFactory.preInstantiateSingletons
AbstractApplicationContext.finishBeanFactoryInitialization
AbstractApplicationContext.refresh
== Setter 注入
DirectMethodHandleAccessor.invoke
Method.invoke
AutowiredAnnotationBeanPostProcessor$AutowiredMethodElement.inject
InjectionMetadata.inject
AutowiredAnnotationBeanPostProcessor.postProcessProperties
AbstractAutowireCapableBeanFactory.populateBean
AbstractAutowireCapableBeanFactory.doCreateBean
(以下与构造函数相同)
```
## 参考资料
- [Spring Framework: Dependency Injection](https://docs.spring.io/spring-framework/reference/core/beans/dependencies/factory-collaborators.html)
@@ -384,7 +588,12 @@ Spring Boot 3.3.5、JDK 21,Apple M1 Pro。CounterController 与正文相同,
- [Spring Framework: Transaction Propagation](https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/tx-propagation.html)
- [Spring Framework 源码:DefaultSingletonBeanRegistry](https://github.com/spring-projects/spring-framework/blob/main/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultSingletonBeanRegistry.java)
- [Spring Boot: Creating Your Own Auto-configuration](https://docs.spring.io/spring-boot/reference/features/developing-auto-configuration.html)
- [Spring Boot: Using the @SpringBootApplication Annotation](https://docs.spring.io/spring-boot/reference/using/using-the-springbootapplication-annotation.html)
- [Spring Framework: Programmatic Transaction Management](https://docs.spring.io/spring-framework/reference/data-access/transaction/programmatic.html)
- [Spring Framework: Declaring Advice(Advice Ordering)](https://docs.spring.io/spring-framework/reference/core/aop/ataspectj/advice.html)
- [Spring Framework 源码:AbstractApplicationContext](https://github.com/spring-projects/spring-framework/blob/6.1.x/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java)
- [Spring Boot 2.6 Release Notes](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.6-Release-Notes)
- [Spring Boot 2.7 Release Notes](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.7-Release-Notes)
- [MyBatis 3 文档](https://mybatis.org/mybatis-3/zh_CN/index.html)
- [MyBatis 3: Dynamic SQL](https://mybatis.org/mybatis-3/dynamic-sql.html)
- [DispatcherServlet | Spring Framework 文档](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-servlet.html)
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

+121 -7
View File
@@ -37,6 +37,10 @@
* [雪花算法](#雪花算法)
* [九、幂等](#九幂等)
* [附录:实测](#附录实测)
* [A1 自增主键与 UUID 主键](#a1-自增主键与-uuid-主键)
* [A2 雪花算法](#a2-雪花算法)
* [A3 Redisson 的可重入与看门狗](#a3-redisson-的可重入与看门狗)
* [A4 重复提交与唯一索引](#a4-重复提交与唯一索引)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -75,7 +79,7 @@ SET lock_key unique_value NX PX 30000
还需要注意以下几点:
- 释放锁:value 设置为客户端的唯一标识,释放锁时先判断 value 是否为自己设置的再删除,避免误删其它客户端的锁,判断和删除需要使用 Lua 脚本保证原子性;
- 锁续期:如果业务执行时间超过了过期时间,锁会被提前释放,可以使用后台线程定时延长过期时间,例如 Redisson 的看门狗机制默认每 10 秒把过期时间重置为 30 秒;
- 锁续期:如果业务执行时间超过了过期时间,锁会被提前释放,可以使用后台线程定时延长过期时间,例如下文 Redisson 的看门狗;
- 主从切换:Redis 的主从复制是异步的,如果主节点加锁后还未同步到从节点就宕机,从节点晋升为主节点之后锁就丢失了。
```lua
@@ -88,6 +92,18 @@ end
Redis 8.4 新增了 `DELEX lock_key IFEQ unique_value` 指令,可以直接原子地完成比较并删除的操作,不再需要 Lua 脚本。
实际项目中通常使用 Redisson 的 RLock,它在 SET NX 的基础上解决了可重入、等待和续期三个问题。以锁 lock:order:1024 为例(Redisson 4.8、Redis 7.4 实测,见[附录 A3](#a3-redisson-的可重入与看门狗)):
- 可重入:锁是一个哈希,字段是“客户端 UUID:线程 ID”,值是加锁次数。加锁的 Lua 脚本在键不存在、或者字段就是自己时,把次数加 1 并设置过期时间,否则返回锁的剩余过期时间。同一个线程加锁两次后 HGETALL 返回 [uuid:1, 2],解锁一次变为 1,再解锁一次才删除这个键;
- 等待:加锁失败的线程订阅这把锁对应的频道,持有者释放锁时向频道发布一条消息,等待的线程收到后再去加锁,而不是不停地轮询;
- 看门狗:调用 lock() 时不指定过期时间,Redisson 先把过期时间设为 lockWatchdogTimeout(默认 30 秒),再启动一个定时任务,每 10 秒(30 秒的 1/3)检查一次,锁仍是自己的就把过期时间重置为 30 秒,解锁时取消这个任务。
<div align="center"> <img src="pics/redisson-watchdog.png" width="720px"/> </div><br>
看门狗运行在持有锁的进程中,只在这个进程存活时续期。进程崩溃后没有人续期,锁最多 30 秒后自动过期,其它客户端就能拿到锁。反过来,如果持有锁的线程卡住(例如等待一个没有设置超时的远程调用)而进程还活着,看门狗会一直续期,其它客户端一直拿不到锁,所以锁内的操作都要设置超时。
调用 lock(10, TimeUnit.SECONDS) 指定了过期时间时,Redisson 不启动看门狗,10 秒后锁被自动删除,即使业务还没有执行完。需要注意的是,Redisson 的锁仍然保存在一个 Redis 主节点上,主从切换时丢锁的问题依然存在。
### Redis 的 RedLock 算法
使用了多个 Redis 实例来实现分布式锁,这是为了保证在发生单点故障时仍然可用。
@@ -157,6 +173,12 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节
实际使用中一般直接采用 Redisson、Curator 等成熟的客户端实现。
锁的粒度应该和要保护的资源一致:限制一人一单就锁 lock:order:user:1024,扣减某个商品的库存就锁 lock:stock:10086,不要让整个下单接口共用一把锁,否则不相关的请求也要排队。
所有请求都争抢同一个资源时,例如秒杀同一件商品,分布式锁会让请求排队串行执行,每个请求还要多两次访问 Redis,吞吐量受限,锁的键也成了热点。判断库存并扣减这类可以在一次原子操作中完成的逻辑,更适合直接用 Redis 的 Lua 脚本或者数据库的条件更新,见 [秒杀系统](系统设计基础.md#六秒杀系统)。
锁与本地事务一起使用时,锁要包住事务,在事务提交之后再释放,否则后来的请求拿到锁时还看不到前一个请求的修改,见 [Spring 中用自定义注解加锁](Spring.md#动态代理)。
## 二、分布式事务
指事务的操作位于不同的节点上,需要保证事务的 ACID 特性。
@@ -168,6 +190,12 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节
- 锁问题的关键在于进程操作的互斥关系,例如多个进程同时修改账户的余额,如果没有互斥关系则会导致该账户的余额不正确;
- 而事务问题的关键则在于事务涉及的一系列操作需要满足 ACID 特性,例如要满足原子性操作则需要这些操作要么都执行,要么都不执行。
下面介绍的几种做法,常用的分布式事务框架 Seata 都提供了实现。Seata 中有三个角色:事务协调者(Transaction Coordinator, TC)是单独部署的服务,记录全局事务和各个分支事务的状态,驱动它们提交或回滚;事务管理器(Transaction Manager, TM)在发起全局事务的服务中,负责开始、提交或回滚全局事务;资源管理器(Resource Manager, RM)在每个参与的服务中,负责注册分支事务、报告状态并执行提交或回滚。它提供四种模式:
- XA:即下文的 2PC,由数据库执行 XA PREPARE,锁一直持有到第二阶段;
- AT:第一阶段直接提交本地事务,同时记录修改前后的数据,失败时按记录生成反向的 SQL 回滚,对业务代码没有侵入,过程见 [微服务中的 Seata](微服务.md#七服务保护与分布式事务);
- TCC、Saga:即下文的两种做法,需要业务自己实现预留、确认、取消或补偿的接口。
### 2PC
两阶段提交(Two-phase Commit,2PC),通过引入协调者(Coordinator)来协调参与者的行为,并最终决定这些参与者是否要真正执行事务。
@@ -210,6 +238,8 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节
三阶段提交(3PC)在 2PC 的基础上增加了一个询问阶段(CanCommit),并且在参与者端也引入了超时机制:参与者收到预提交(PreCommit)之后,如果等待最终通知超时,就默认提交,从而减少了阻塞。但是它假设网络延迟和节点的响应时间都有上界,而现实系统中存在无界的网络延迟和进程暂停,超时并不能说明对方已经失效,因此仍然可能出现数据不一致。实践中更常见的做法是用共识算法把协调者的决定复制到多个节点上,避免单点。
两阶段提交也用在单机的数据库内部:MySQL 用它保证 redo log 和 binlog 一致,见 [MySQL 的两阶段提交](MySQL.md#两阶段提交)。
### TCC
TCC(Try-Confirm-Cancel)是在业务层面实现的两阶段提交,每个操作都需要实现三个接口:
@@ -397,7 +427,7 @@ Raft 也是分布式共识算法,相比于 Paxos 更容易理解和实现。
有三种节点:Follower、Candidate 和 Leader。Raft 把时间划分为多个任期(Term),任期号单调递增,每个任期从一次选举开始,并且每个任期内最多只有一个 Leader。任期号的作用和 Paxos 中的提议序号相同,发生冲突时任期号大的一方说了算。
Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了一个随机的竞选超时时间,一般为 150ms\~300ms,如果在这个时间内没有收到 Leader 的心跳包,就会变成 Candidate,将任期号加 1 并给自己投一票,进入竞选阶段。
Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了一个随机的竞选超时时间,论文中的例子是 150ms\~300ms,如果在这个时间内没有收到 Leader 的心跳包,就会变成 Candidate,将任期号加 1 并给自己投一票,进入竞选阶段。
- 系统刚启动时只有 Follower 没有 Leader。Node A 的随机竞选超时最短,到期时没收到 Leader 发来的心跳包,因此进入竞选阶段,发送投票请求给其它所有节点。
@@ -432,6 +462,13 @@ Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了
读请求则要另外处理:旧 Leader 如果直接用本地数据回答读请求,可能返回旧数据,所以 Raft 要求 Leader 在处理只读请求前,先与大多数节点交换一次心跳,确认自己仍是 Leader。另外,新 Leader 刚上任时还不知道哪些日志已经提交,要先提交一条当前任期的空日志,之后才能处理只读请求。
Raft 与 Paxos 达成的结果是等价的,区别在于对 Leader 的依赖程度:
- Paxos 中任何一个 Proposer 都可以发起提议,选出一个主 Proposer 只是性能上的优化(也用来避免上文的活锁)。Multi-Paxos 中日志的每个位置各自独立地确定,新的主节点还要先找回自己缺少的日志;
- Raft 中选主是协议的一部分,只有 Leader 能追加日志,日志只从 Leader 流向 Follower,投票规则又保证只有日志最新的节点能当选,新 Leader 不需要从其它节点补日志,因此更容易理解和实现。代价是所有写入都要经过 Leader,选举期间不能写入。
ZooKeeper 使用的 ZAB 协议同样以 Leader 为中心,纪元编号(epoch)的作用相当于 Raft 的任期号。
## 七、RPC
远程过程调用(Remote Procedure Call, RPC)指的是像调用本地方法一样调用另一台机器上的服务。例如订单服务调用库存服务扣减库存:
@@ -497,6 +534,8 @@ inventoryService.deduct(10086, 2); // 实际执行的是服务方的 Inventory
这段代码每次调用都新建连接,使用 Java 自带的序列化,也没有超时处理。Dubbo、gRPC 等框架在此基础上实现了连接复用、高效的序列化、服务发现、负载均衡、超时重试和熔断,设计一个 RPC 框架时也是从这几个模块考虑。
复用连接之后,要在 TCP 的字节流中分出一个个请求。TCP 不保留消息的边界,所以协议都在消息前写明长度。例如 Dubbo 协议的首部固定为 16 字节:2 字节魔数 0xdabb、1 字节标志(请求还是响应、是否需要返回值、序列化方式)、1 字节响应状态、8 字节请求 ID、4 字节正文长度。接收方先读 16 字节的首部,再按长度读出正文,用请求 ID 找到对应的调用。
### 服务发现
服务的实例会扩容、缩容或者宕机,地址不能写死在调用方。注册中心(例如 Nacos、ZooKeeper)保存每个服务当前可用的实例:
@@ -507,6 +546,8 @@ inventoryService.deduct(10086, 2); // 实际执行的是服务方的 Inventory
因此注册中心全部宕机时,调用方仍然可以按缓存的地址继续调用,只是新上线的实例无法被发现。某个实例宕机后、被注册中心删除之前,发往它的请求会失败,需要调用方重试其它实例。
Nacos、Eureka、ZooKeeper 作为注册中心的区别,以及注册中心与客户端负载均衡在 Spring Cloud 中如何配合,见 [微服务](微服务.md#三注册中心)。
### RPC 与 HTTP
HTTP 是一种协议,RPC 是一种调用方式,两者不是同一层面的概念:RPC 框架可以在 TCP 上使用自定义的协议,例如 Dubbo 协议;也可以基于 HTTP,例如 gRPC 基于 HTTP/2,Spring Cloud 的 OpenFeign 用动态代理把接口调用转换成 HTTP 请求。
@@ -520,6 +561,8 @@ HTTP 是一种协议,RPC 是一种调用方式,两者不是同一层面的
HTTP + JSON 的优势在于通用:浏览器和各种语言都能直接调用,报文可读,便于调试,适合对外开放的接口。
gRPC 选择 HTTP/2 是因为它的 [多路复用](HTTP.md#二进制分帧层):每次调用是一个流(Stream),请求路径为 /包名.服务名/方法名,例如 /inventory.InventoryService/Deduct;多个调用的帧在同一个连接上交错发送,用流 ID 区分,不需要自己设计请求 ID。参数编码成 Protobuf 后,前面加 1 字节的压缩标志和 4 字节的长度,放在 DATA 帧中。上面的参数在 DATA 帧中就是 00 00 00 00 05 08 E6 4E 10 02,前 5 个字节表示不压缩、长度为 5。
### 超时与重试
- 每次调用都要设置超时时间,否则下游变慢时,调用方的线程会一直等待,最终被耗尽;
@@ -545,7 +588,7 @@ HTTP + JSON 的优势在于通用:浏览器和各种语言都能直接调用
| Redis INCR | 用 INCR 得到递增的整数 | 依赖 Redis 的可用性和持久化,宕机后可能重复 |
| 雪花算法 | 本地用时间戳、机器 ID 和序号拼成 64 位整数 | 依赖时钟,需要分配机器 ID |
例如分别以自增整数和 UUID() 为主键插入 100 万行,自增主键用时 3.4 秒、数据 133 MB,UUID 主键用时 5.1 秒、数据 164 MB(MySQL 8.0 实测,见[附录:实测](#附录实测))。这是因为 UUID 的值是无序的,每次都插入到 B+ 树中间的随机位置,频繁的页分裂让页的填充率变低,二级索引中保存的主键也更长。
例如分别以自增整数和 UUID() 为主键插入 100 万行,自增主键用时 3.4 秒、数据 133 MB,UUID 主键用时 5.1 秒、数据 164 MB(MySQL 8.0 实测,见[附录 A1](#a1-自增主键与-uuid-主键))。这是因为 UUID 的值是无序的,每次都插入到 B+ 树中间的随机位置,频繁的页分裂让页的填充率变低,二级索引中保存的主键也更长。
### 雪花算法
@@ -572,7 +615,7 @@ public synchronized long nextId() {
}
```
用 8 个线程共生成 400 万个 ID,耗时 1.2 秒,全部不重复,每个线程得到的 ID 都是递增的;去掉“序号用完就等到下一毫秒”这一步,400 万个 ID 中只有约 240 万个不重复(JDK 21 实测,见[附录:实测](#附录实测))。
用 8 个线程共生成 400 万个 ID,耗时 1.2 秒,全部不重复,每个线程得到的 ID 都是递增的;去掉“序号用完就等到下一毫秒”这一步,400 万个 ID 中只有约 240 万个不重复(JDK 21 实测,见[附录 A2](#a2-雪花算法))。
需要注意的是,同一个实例生成的 ID 是递增的,但不同实例的时钟之间存在偏差,两个实例生成的 ID 只能大致反映生成的先后顺序。
@@ -583,7 +626,7 @@ public synchronized long nextId() {
## 九、幂等
幂等指的是同一个操作执行一次和执行多次的效果相同。分布式系统中重复的请求很常见:调用超时后重试、用户重复点击、消息队列重复投递,都会让同一个操作执行多次。查询和删除天然幂等,新增和扣减不是,例如支付回调被调用两次,订单就可能被处理两次。
幂等指的是同一个操作执行一次和执行多次的效果相同。分布式系统中重复的请求很常见:调用超时后重试、用户重复点击、消息队列重复投递,都会让同一个操作执行多次。查询和删除天然幂等,新增和扣减不是,例如支付回调被调用两次,订单就可能被处理两次。HTTP 规范中各个方法是否幂等,见 [HTTP 的幂等性](HTTP.md#幂等性)。
实现方法:
@@ -594,11 +637,25 @@ public synchronized long nextId() {
需要注意的是,“先查询是否处理过、再处理”分成两步时,两个并发的重复请求可能同时查到“未处理”。判断和处理必须是原子的,要么依靠数据库的唯一索引或条件更新,要么把判断和写入放在同一个 Lua 脚本中。
实现幂等首先要定义什么算同一个请求,也就是幂等键怎么来。以用户狂点“提交订单”为例:
- 客户端在打开确认页时生成一个 request_id,之后的每次点击和重试都带着它,下单成功或者用户修改了订单内容之后才换一个新的。如果每次点击都生成一个新的 ID,服务端就无法识别重复;
- 服务端在订单表的 request_id 上建唯一索引,插入订单时重复的请求报错 1062,返回第一次的结果。同一个 request_id 并发提交 10 次,先查后插的写法 5 轮中每轮都创建了 2~3 个订单,唯一索引每轮只创建 1 个(MySQL 8.0 实测,见[附录 A4](#a4-重复提交与唯一索引));
- 前端把按钮置灰、网关限流可以减少重复请求,但请求仍可能来自重试、脚本或者网络重发,只能作为辅助,最终要靠数据库的约束兜底。
没有客户端参与的场景,幂等键用业务上的唯一标识。例如支付回调用支付平台的交易号,再配合上面的状态机:重复的回调影响 0 行时,同样要返回成功,否则支付平台会继续重试。
分布式锁不能代替幂等。锁只保证同一时刻只有一个请求在处理,第一个请求完成并释放锁之后,重试的请求拿到锁仍会再执行一次。锁的作用是挡住并发的重复请求,减轻数据库的冲突,是否已经处理过还是要靠唯一索引或者状态机判断。
用 Token 实现时,Token 的过期时间按用户在页面上停留的时长设置,例如 30 分钟。DEL 返回 1 之后处理失败时,这个 Token 已经删除,客户端要重新获取 Token 再提交。
## 附录:实测
环境为 Apple M1 Pro、macOS,MySQL 8.0.44,JDK 21。
自增主键与 UUID 主键,每张表插入 100 万行,v 列为 100 个字符:
### A1 自增主键与 UUID 主键
每张表插入 100 万行,v 列为 100 个字符:
```sql
create table t_auto (id bigint primary key auto_increment, v char(100)) engine=innodb;
@@ -615,13 +672,64 @@ t_auto 133 7
t_uuid 164 7
```
雪花算法:正文中的 nextId() 补齐字段后,8 个线程各生成 50 万个 ID,检查是否重复、每个线程内是否递增;第二行是去掉“序号用完就等到下一毫秒”之后的结果:
### A2 雪花算法
正文中的 nextId() 补齐字段后,8 个线程各生成 50 万个 ID,检查是否重复、每个线程内是否递增;第二行是去掉“序号用完就等到下一毫秒”之后的结果:
```text
generated 4000000 unique=4000000 increasingPerThread=true ms=1166
generated 4000000 unique=2404324 increasingPerThread=false ms=712
```
### A3 Redisson 的可重入与看门狗
Redisson 4.8.0,Redis 7.4.1。三个程序分别加锁 lock:order:1024,用 redis-cli 查看哈希的内容和 PTTL:
```java
RLock lock = redisson.getLock("lock:order:1024");
// 程序一:lock() 两次,持有 30 秒,再 unlock() 两次
// 程序二:lock(10, TimeUnit.SECONDS),之后不解锁
// 程序三:lock() 之后立即 Runtime.getRuntime().halt(1),模拟进程崩溃
```
```text
== 程序一
0.0s lock() HGETALL=[0fb9b3a2-f60d-4d48-b081-ccbdacb0b4b6:1 1] PTTL=29979
0.0s 再次 lock() HGETALL=[0fb9b3a2-f60d-4d48-b081-ccbdacb0b4b6:1 2] PTTL=29991
6.1s 持有中 HGETALL=[0fb9b3a2-f60d-4d48-b081-ccbdacb0b4b6:1 2] PTTL=23973
12.1s 持有中 HGETALL=[0fb9b3a2-f60d-4d48-b081-ccbdacb0b4b6:1 2] PTTL=27977
18.1s 持有中 HGETALL=[0fb9b3a2-f60d-4d48-b081-ccbdacb0b4b6:1 2] PTTL=21961
24.1s 持有中 HGETALL=[0fb9b3a2-f60d-4d48-b081-ccbdacb0b4b6:1 2] PTTL=25955
30.1s 持有中 HGETALL=[0fb9b3a2-f60d-4d48-b081-ccbdacb0b4b6:1 2] PTTL=29966
30.1s unlock() 一次 HGETALL=[0fb9b3a2-f60d-4d48-b081-ccbdacb0b4b6:1 1] PTTL=29987
30.2s unlock() 两次 HGETALL=[] PTTL=-2
== 程序二
0.0s lock(10s) HGETALL=[f6be60e4-44c3-4086-bc03-2cd44ddd0459:1 1] PTTL=9988
4.0s 持有中 HGETALL=[f6be60e4-44c3-4086-bc03-2cd44ddd0459:1 1] PTTL=5971
8.1s 持有中 HGETALL=[f6be60e4-44c3-4086-bc03-2cd44ddd0459:1 1] PTTL=1950
12.1s 持有中 HGETALL=[] PTTL=-2
== 程序三
0.0s lock(),随后 halt PTTL=29985
崩溃后 25s PTTL=4480
崩溃后 30s PTTL=-2
(另一个客户端 tryLock(0, SECONDS) 返回 true)
```
配图中的曲线是另外运行一次、每 0.5 秒采样一次 PTTL 得到的。
### A4 重复提交与唯一索引
MySQL 8.0.44,Python 3 + pymysql。两张订单表结构相同,只是 t3_orders_uk 在 request_id 上有唯一索引。每轮用 10 个线程同时提交同一个 request_id,先查后插的写法先 SELECT,没有记录再 INSERT;唯一索引的写法直接 INSERT,捕获 1062 错误:
```text
先查后插 第 1 轮:同一个 request_id 并发提交 10 次,创建订单 2 个
先查后插 第 2 轮:同一个 request_id 并发提交 10 次,创建订单 2 个
先查后插 第 3 轮:同一个 request_id 并发提交 10 次,创建订单 3 个
先查后插 第 4 轮:同一个 request_id 并发提交 10 次,创建订单 2 个
先查后插 第 5 轮:同一个 request_id 并发提交 10 次,创建订单 2 个
唯一索引 第 1~5 轮:同一个 request_id 并发提交 10 次,创建订单 1 个,其余 9 次报错 1062
```
## 参考资料
- [Apache Dubbo: Architecture](https://dubbo.apache.org/docs/v2.7/user/preface/architecture/)
@@ -636,6 +744,12 @@ generated 4000000 unique=2404324 increasingPerThread=false ms=712
- [etcd: KV API guarantees](https://etcd.io/docs/v3.5/learning/api_guarantees/)
- [Nacos 的 Distro 一致性协议](https://nacos.io/en/news/article-nacos-distro-mechanism/)
- [Locks and synchronizers - Redisson](https://redisson.pro/docs/data-and-services/locks-and-synchronizers/)
- [Redisson 源码:RedissonLock](https://github.com/redisson/redisson/blob/master/redisson/src/main/java/org/redisson/RedissonLock.java)
- [Seata 是什么](https://seata.apache.org/zh-cn/docs/overview/what-is-seata/)
- [Seata 术语](https://seata.apache.org/zh-cn/docs/overview/terminology/)
- [Seata XA 模式](https://seata.apache.org/zh-cn/docs/user/mode/xa/)
- [gRPC over HTTP2](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md)
- [Dubbo 协议详解](https://cn.dubbo.apache.org/zh-cn/blog/2018/10/05/dubbo-%E5%8D%8F%E8%AE%AE%E8%AF%A6%E8%A7%A3/)
- [深度剖析 Seata TCC 模式](https://seata.apache.org/zh-cn/blog/seata-tcc/)
- [Seata TCC 模式的空回滚、幂等与悬挂(tcc-fence)](https://seata.apache.org/zh-cn/blog/seata-tcc-fence/)
- [Seata Saga 模式](https://seata.apache.org/zh-cn/docs/user/mode/saga/)
+153 -8
View File
@@ -23,6 +23,10 @@
* [为什么吞吐量高](#为什么吞吐量高)
* [副本与可靠性](#副本与可靠性)
* [附录:实测](#附录实测)
* [A1 批量对吞吐量的影响](#a1-批量对吞吐量的影响)
* [A2 重复消费](#a2-重复消费)
* [A3 消费积压](#a3-消费积压)
* [A4 Redis Stream 的确认](#a4-redis-stream-的确认)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -78,7 +82,17 @@ Kafka、RocketMQ 等通过消费者组把两种模型结合起来:一条消息
## 三、可靠性
一条消息要经过发送、存储、消费三个环节,每个环节都可能丢失消息。
一条消息要经过发送、存储、消费三个环节,每个环节都可能丢失消息。三种常用的消息队列在这三个环节上的做法:
| 环节 | Kafka | RabbitMQ | RocketMQ(4.x) |
| --- | --- | --- | --- |
| 发送 | acks=all,失败自动重试 | 开启发布确认(Publisher Confirm),收到 ack 才算成功,并设置 mandatory | 同步发送,返回 SEND_OK 才算成功 |
| 存储 | 多副本,配合 min.insync.replicas,见 [副本与可靠性](#副本与可靠性) | 持久化的队列加持久化的消息,或者使用多副本的仲裁队列(Quorum Queue) | 同步刷盘(SYNC_FLUSH)加同步复制(SYNC_MASTER),默认都是异步的 |
| 消费 | 处理完再提交偏移量 | 关闭自动确认,处理完再发送 ack | 处理完再返回 CONSUME_SUCCESS,失败返回 RECONSUME_LATER 稍后重试 |
需要注意的是,RabbitMQ 对无法路由到任何队列的消息同样返回 ack,只开启确认而不设置 mandatory 时,发错路由键的消息会被直接丢弃;设置了 mandatory,这类消息会先退回给生产者。
即使这些配置都打开,消息仍可能因为程序缺陷、误操作等原因丢失或者处理失败,关键业务还要定期核对上下游的数据(对账),发现缺失时补发。
### 发送端的可靠性
@@ -101,17 +115,53 @@ Kafka、RocketMQ 等通过消费者组把两种模型结合起来:一条消息
- 保证接收端处理消息的业务逻辑具有幂等性:只要具有幂等性,那么消费多少次消息,最后处理的结果都是一样的;
- 保证消息具有唯一编号,并使用一张日志表来记录已经消费的消息编号。
例如积分服务消费“订单 1024 支付成功”的消息,在同一个本地事务中先写去重表、再加积分。重复的消息插入去重表时违反主键约束,回滚后直接确认这条消息:
```sql
BEGIN;
INSERT INTO consumed_msg (msg_key) VALUES ('order:1024:paid'); -- 重复时报错 1062
UPDATE points SET balance = balance + 100 WHERE user_id = 1024;
COMMIT;
```
去重的键要用业务上的唯一标识,例如订单号加事件类型,而不是消息队列生成的消息 ID。RocketMQ 文档指出,同一条消息被生产者重发后可能有两个不同的消息 ID。
去重记录也可以放在 Redis 中,但如果用 SETNX 标记之后、处理完成之前消费者崩溃,重新投递的消息会被当作已经处理而跳过,这条消息就丢了。因此 Redis 中要区分“处理中”和“已完成”两种状态,“处理中”设置过期时间,过期后允许重新处理。通用的幂等方法见 [分布式中的幂等](分布式.md#九幂等)。
## 四、顺序性
消息队列通常只保证单个队列(Kafka 中的分区)内的消息有序,多个队列之间没有顺序保证。并且在点对点模式下,一个消费者处理失败的消息会被重新投递给另一个消费者,顺序也会被打乱。
要保证同一业务的消息有序,需要让发送端把这些消息按业务 Key(例如订单号)路由到同一个队列,并且接收端对同一个队列只用一个线程顺序消费。
RabbitMQ 中一个队列内的消息是先进先出的,但一个队列有多个消费者时,消息被拒绝后重新入队会打乱顺序。需要顺序时按业务 Key 把消息路由到多个队列,每个队列只有一个消费者;或者开启单活跃消费者(Single Active Consumer),同一时刻只有一个消费者接收消息,它断开后才由另一个消费者接替。Kafka 中这两件事由分区和消费者组完成:同一个 key 进入同一个分区,一个分区只分配给组内的一个消费者。
## 五、消息堆积
消费速度长期低于生产速度时,消息会在队列中堆积。
消费速度长期低于生产速度时,消息会在队列中堆积。堆积的程度用积压量(Lag)衡量,Kafka 中每个分区的积压量等于日志末尾的偏移量减去消费者组已提交的偏移量。例如积分服务只消费了 3000 条就停止,用 kafka-consumer-groups.sh 查看(Kafka 4.4 实测,省略了后三列,见[附录 A3](#a3-消费积压)):
解决方法是增加消费者数量来提高消费能力,但是消费者数量超过队列(分区)数量之后就不再有效,需要同时增加队列数量。
```text
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
points points-events 0 0 9237 9237
points points-events 1 0 9577 9577
points points-events 2 3000 11186 8186
```
排查时先看积压是生产突增造成的,还是消费变慢造成的:
- 生产突增:例如大促、批量任务,生产速率的监控会明显上升,峰值过后通常能自然消化;
- 消费变慢:最常见的原因是消费逻辑依赖的数据库或外部接口变慢,其次是某些消息反复处理失败、不断重试,或者处理超时导致消费者被踢出、频繁重平衡;
- 只有个别分区积压:通常是 key 分布不均,大量消息集中到了同一个分区。
处理方法:
- 先解决消费变慢的原因,例如修复下游、把反复失败的消息转入死信队列;
- 增加消费者数量,但消费者数量超过分区(队列)数量之后不再有效。上面的主题有 3 个分区,组内启动 4 个消费者后,其中一个分到 0 个分区;
- 提高单个消费者的处理能力:批量写库,或者在消费者内部用线程池并行处理,同一个 key 的消息交给同一个线程以保持顺序;
- 积压量很大、分区数又不够时,临时新建一个分区更多的主题,原来的消费者只把消息原样转发过去,再用更多的消费者消费新主题;
- 不重要的消息可以降级,例如先跳过,之后再补偿。
需要注意的是,Kafka 按保留时间删除旧数据,不管消息有没有被消费,积压超过 retention.ms(默认 7 天)的消息会被删除;RocketMQ 默认保留 72 小时。积压的时间接近保留时间时,要先把消息转存出来。
## 六、延迟消息与死信队列
@@ -152,6 +202,26 @@ JDK 自带的 DelayQueue 也能实现延迟,但数据只在单个进程的内
## 七、Kafka
Kafka 把消息保存为按分区追加的日志,消息被消费后不会删除,这是它与 RabbitMQ 最大的区别。三种常用的消息队列对比如下:
| | Kafka | RabbitMQ | RocketMQ |
| --- | --- | --- | --- |
| 存储 | 分区日志,按保留时间删除,与是否被消费无关 | 队列,消息被确认后删除 | 所有主题的消息顺序写入同一个 CommitLog,每个队列另有消费索引,按保留时间删除 |
| 消费 | 消费者拉取,提交偏移量,可以把偏移量调回去重新消费 | Broker 主动推送给消费者,用 prefetch 限制未确认的消息数 | 消费者按队列拉取(Push 模式用长轮询实现),可以按时间重置消费位置 |
| 路由 | 生产者按 key 选择分区 | 交换机按路由键把消息投递到一个或多个队列,规则灵活 | 主题加 Tag 过滤 |
| 业务功能 | 本身不支持延迟消息和死信 | 死信交换机、TTL、优先级队列,延迟消息需要插件 | 延迟消息、事务消息、消费重试和死信队列 |
| 适用场景 | 日志、埋点、数据同步、流处理等吞吐量大的场景 | 消息量不大、路由规则复杂的业务消息 | 订单、支付等需要事务消息、延迟消息和重试的业务消息 |
RabbitMQ 也提供了只追加的 Stream 类型,消息确认后不删除,可以重复读取。
Redis 也常被用作轻量的消息队列,但可靠性不如专门的消息队列:
- Pub/Sub 不保存消息,发布时没有订阅者或者订阅者断线,消息就丢了,实测没有订阅者时 PUBLISH 返回 0;
- List 用 LPUSH 和 BRPOP 实现队列,消息取出后就从 List 中删除,消费者处理时崩溃就丢了,也不支持多个消费者组;
- Stream(Redis 5.0 起)支持消费者组和确认:XREADGROUP 读出的消息进入待确认列表,XACK 之后才移除,消费者崩溃时其它消费者可以用 XAUTOCLAIM 接管它没有确认的消息(见[附录 A4](#a4-redis-stream-的确认))。
即使使用 Stream,数据也保存在内存中,持久化和主从复制都是异步的,宕机时可能丢失最近写入的消息,能保存的消息量也受内存限制。消息量不大、能接受少量丢失时可以用 Redis,否则用专门的消息队列。
### 分区与消费者组
Kafka 中的消息按主题(Topic)分类,每个主题分成多个分区(Partition)。分区是一个只能在末尾追加的日志,每条消息在分区内有一个递增的偏移量(Offset),消费者记录自己读到了哪个偏移量。
@@ -193,12 +263,16 @@ Kafka 中的消息按主题(Topic)分类,每个主题分成多个分区(
顺序写和页缓存:消息只追加到日志的末尾,是顺序写,比随机写快得多。写入时只写到操作系统的页缓存,由操作系统决定何时刷盘,消费者读取刚写入不久的数据时也通常直接命中页缓存。可靠性不依靠每条消息都刷盘,而是依靠多个副本。
批量:生产者把发往同一个分区的消息攒成批次再发送,批次在网络传输、写入日志和副本复制时都作为一个整体处理,压缩也以批次为单位。例如在 3 个节点的集群上发送 2 万条 100 字节的消息(acks=all),默认配置每秒约 5.7 万~6.8 万条,平均延迟 21~54 ms;设置 batch.size=0(linger.ms=0)关闭批量后,每秒只有约 6500~6800 条,发送的请求大量排队,平均延迟约 1.4 秒(Kafka 4.4 实测,见[附录:实测](#附录实测))。
批量:生产者把发往同一个分区的消息攒成批次再发送,批次在网络传输、写入日志和副本复制时都作为一个整体处理,压缩也以批次为单位。例如在 3 个节点的集群上发送 2 万条 100 字节的消息(acks=all),默认配置每秒约 5.7 万~6.8 万条,平均延迟 21~54 ms;设置 batch.size=0(linger.ms=0)关闭批量后,每秒只有约 6500~6800 条,发送的请求大量排队,平均延迟约 1.4 秒(Kafka 4.4 实测,见[附录 A1](#a1-批量对吞吐量的影响))。
零拷贝:broker 把日志发给消费者时使用 sendfile 系统调用,数据不需要先读到用户空间再写回内核(启用 SSL 时数据要在用户空间加密,不能使用 sendfile):
零拷贝:零拷贝(Zero-copy)指的是传输数据时,CPU 不在内核空间和用户空间之间来回拷贝数据。用 read() 和 write() 把文件发到网络时,数据从页缓存拷贝到应用的缓冲区,再拷贝到 Socket 缓冲区,共 4 次拷贝、2 次系统调用。broker 把日志发给消费者时使用 sendfile 系统调用,数据只在内核中传递(启用 SSL 时数据要在用户空间加密,不能使用 sendfile):
<div align="center"> <img src="pics/zero-copy.png" width="760px"/> </div><br>
另一种减少拷贝的方法是内存映射(mmap):把文件映射到进程的地址空间,进程读写这段内存就是直接读写页缓存,不需要 read() 和 write() 的拷贝。第一次访问某一页时触发缺页异常,由内核把这一页从磁盘读入页缓存,再建立映射。前文 Kafka 的索引文件、RocketMQ 的 CommitLog 都是用 mmap 读写的。
两者的适用场景不同:sendfile 把文件原样发给 Socket,程序接触不到数据;mmap 适合程序本身要频繁读写文件内容的场景。Linux 还提供了 splice,在两个文件描述符之间移动数据而不经过用户空间,要求其中一个是管道。
分区并行:一个主题的多个分区分布在不同的 broker 上,读写由多台机器共同承担。
### 副本与可靠性
@@ -230,13 +304,15 @@ acks=all 要配合 min.insync.replicas 使用,否则 ISR 只剩 Leader 一个
生产者没有收到确认就会重试,这可能让同一条消息写入两次。Kafka 3.0 起默认开启幂等生产者:broker 给每个生产者分配一个 ID,生产者给发往每个分区的消息编上序号,broker 发现序号重复就丢弃。前面查看 .log 文件时,每个批次都带有 producerId: 1001 和 baseSequence 字段。幂等只保证单个分区内不重复;要让写入多个分区的消息同时成功或失败,需要使用事务,消费者设置 isolation.level=read_committed 后只读取已提交事务中的消息。
消费者处理完消息后提交偏移量,下次从提交的位置继续读取。默认每 5 秒自动提交一次,已经处理但还没提交时崩溃,重启后就会重复消费。例如消费者读到 3 条扣款消息后被 kill -9,这时组的偏移量仍然为空;它在 session.timeout.ms(默认 45 秒)之后被移出消费者组,重新启动的消费者又收到了这 3 条消息(见[附录:实测](#附录实测))。因此接收端必须做到幂等,方法见 [接收端的可靠性](#接收端的可靠性)。反过来,如果先提交偏移量再处理,处理失败的消息就丢失了。
消费者处理完消息后提交偏移量,下次从提交的位置继续读取。默认每 5 秒自动提交一次,已经处理但还没提交时崩溃,重启后就会重复消费。例如消费者读到 3 条扣款消息后被 kill -9,这时组的偏移量仍然为空;它在 session.timeout.ms(默认 45 秒)之后被移出消费者组,重新启动的消费者又收到了这 3 条消息(见[附录 A2](#a2-重复消费))。因此接收端必须做到幂等,方法见 [接收端的可靠性](#接收端的可靠性)。反过来,如果先提交偏移量再处理,处理失败的消息就丢失了。
## 附录:实测
环境为 Apple M1 Pro、macOS,Kafka 4.4.0 的 3 个节点的 KRaft 集群运行在同一台机器上(端口 9091~9093,命令中的 $B 为这三个地址)。
批量对吞吐量的影响:主题有 3 个分区、3 个副本,每种配置运行 3 次:
### A1 批量对吞吐量的影响
主题有 3 个分区、3 个副本,每种配置运行 3 次:
```text
$ kafka-topics.sh --bootstrap-server $B --create --topic perf --partitions 3 --replication-factor 3
@@ -252,7 +328,9 @@ $ ...(同上) acks=all batch.size=0 linger.ms=0
20000 records sent, 6795.786612 records/sec (0.65 MB/sec), 1410.16 ms avg latency, 2648.00 ms max latency
```
重复消费:消费者组 billing 使用默认的自动提交,读到 3 条消息后立即 kill -9,45 秒后再启动一个同组的消费者:
### A2 重复消费
消费者组 billing 使用默认的自动提交,读到 3 条消息后立即 kill -9,45 秒后再启动一个同组的消费者:
```text
第一个消费者:
@@ -267,12 +345,79 @@ $ ...(同上) acks=all batch.size=0 linger.ms=0
Processed a total of 3 messages
```
### A3 消费积压
主题 points-events 有 3 个分区、3 个副本。用 kafka-console-producer.sh 写入 3 万条带 key 的消息(key 为 997 个用户 ID),消费者组 points 用 kafka-console-consumer.sh --max-messages 3000 消费 3000 条后退出,再查看积压;之后同时启动 4 个同组的消费者,查看每个消费者分到的分区数:
```text
$ kafka-consumer-groups.sh --bootstrap-server $B --describe --group points
Consumer group 'points' has no active members.
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
points points-events 0 0 9237 9237
points points-events 1 0 9577 9577
points points-events 2 3000 11186 8186
$ kafka-consumer-groups.sh --bootstrap-server $B --describe --group points --members
GROUP CONSUMER-ID #PARTITIONS
points console-consumer-473682e9-43d9-4ade-98fb-045cbad44ef5 1
points console-consumer-45a48e35-9625-4cb2-8495-73c2f2da0830 1
points console-consumer-dcea488d-0498-4116-8265-3aed53a8667d 0
points console-consumer-610451bd-692d-45f0-8b8b-d0af64cda4b1 1
```
### A4 Redis Stream 的确认
Redis 7.4.1,依次执行以下命令(redis-cli 非交互模式的原始输出,每个元素一行,空行表示空结果):
```text
$ redis-cli xadd s3:orders '*' orderId 1024
1793162053370-0
$ redis-cli xgroup create s3:orders points 0
OK
$ redis-cli xreadgroup group points c1 count 10 streams s3:orders '>'
s3:orders
1793162053370-0
orderId
1024
$ redis-cli xpending s3:orders points # c1 读出但还没有确认
1
1793162053370-0
1793162053370-0
c1
1
$ redis-cli xreadgroup group points c2 count 10 streams s3:orders '>'
# 新消息已经被 c1 领走,c2 读不到
$ redis-cli xautoclaim s3:orders points c2 0 0 # c2 接管 c1 没有确认的消息
0-0
1793162053370-0
orderId
1024
$ redis-cli xack s3:orders points 1793162053370-0
1
$ redis-cli xpending s3:orders points
0
$ redis-cli publish ch:orders 1024 # 没有订阅者
0
```
## 参考资料
- [Designing Data-Intensive Applications 中文版](https://github.com/vonng/ddia)
- [消息队列中点对点与发布订阅区别](https://web.archive.org/web/20250522024511/https://blog.csdn.net/lizhitao/article/details/47723105)
- [定时/延时消息 | RocketMQ](https://rocketmq.apache.org/zh/docs/featureBehavior/02delaymessage/)
- [Dead Letter Exchanges | RabbitMQ](https://www.rabbitmq.com/docs/dlx)
- [Consumer Acknowledgements and Publisher Confirms | RabbitMQ](https://www.rabbitmq.com/docs/confirms)
- [Queues: Message Ordering | RabbitMQ](https://www.rabbitmq.com/docs/queues)
- [最佳实践 | RocketMQ 4.x](https://rocketmq.apache.org/zh/docs/4.x/bestPractice/01bestpractice/)
- [RocketMQ 设计](https://github.com/apache/rocketmq/blob/develop/docs/cn/design.md)
- [Redis Streams](https://redis.io/docs/latest/develop/data-types/streams/)
- [Redis Pub/Sub](https://redis.io/docs/latest/develop/pubsub/)
- [sendfile(2)](https://man7.org/linux/man-pages/man2/sendfile.2.html)、[splice(2)](https://man7.org/linux/man-pages/man2/splice.2.html)、[mmap(2)](https://man7.org/linux/man-pages/man2/mmap.2.html)
- [Apache Kafka Documentation: Design](https://kafka.apache.org/43/design/design/)
- [Apache Kafka Documentation: Configuration](https://kafka.apache.org/43/configuration/)
- [Apache Kafka Documentation: Upgrading](https://kafka.apache.org/43/getting-started/upgrade/)