Update 分布式.md

补充分布式定时任务(新增附录 A5、A6)和 Raft 只按多数派提交当前任期日志的规则,增加配图;Paxos、RocketMQ 事务消息、Saga 的配图改由脚本生成。
This commit is contained in:
CyC2018
2026-10-28 22:51:28 +08:00
parent d6810671af
commit eea08577ba
4 changed files with 161 additions and 0 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

+161
View File
@@ -36,11 +36,19 @@
* [八、分布式 ID](#八分布式-id)
* [雪花算法](#雪花算法)
* [九、幂等](#九幂等)
* [十、分布式定时任务](#十分布式定时任务)
* [只执行一次](#只执行一次)
* [调度中心](#调度中心)
* [分片](#分片)
* [失败与宕机](#失败与宕机)
* [定时扫描与延迟消息](#定时扫描与延迟消息)
* [附录:实测](#附录实测)
* [A1 自增主键与 UUID 主键](#a1-自增主键与-uuid-主键)
* [A2 雪花算法](#a2-雪花算法)
* [A3 Redisson 的可重入与看门狗](#a3-redisson-的可重入与看门狗)
* [A4 重复提交与唯一索引](#a4-重复提交与唯一索引)
* [A5 定时任务的重复执行与加锁](#a5-定时任务的重复执行与加锁)
* [A6 分片与按分片重试](#a6-分片与按分片重试)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -462,6 +470,8 @@ Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了
读请求则要另外处理:旧 Leader 如果直接用本地数据回答读请求,可能返回旧数据,所以 Raft 要求 Leader 在处理只读请求前,先与大多数节点交换一次心跳,确认自己仍是 Leader。另外,新 Leader 刚上任时还不知道哪些日志已经提交,要先提交一条当前任期的空日志,之后才能处理只读请求。
需要注意的是,Leader 只对当前任期的日志按“大多数节点已写入”判断是否提交。之前任期留下的日志即使已经复制到大多数节点,也可能被之后的 Leader 覆盖,要等当前任期的一条日志提交后才随之提交(论文第 5.4.2 节和图 8)。这也是上文新 Leader 要先提交一条空日志的原因。
Raft 与 Paxos 达成的结果是等价的,区别在于对 Leader 的依赖程度:
- Paxos 中任何一个 Proposer 都可以发起提议,选出一个主 Proposer 只是性能上的优化(也用来避免上文的活锁)。Multi-Paxos 中日志的每个位置各自独立地确定,新的主节点还要先找回自己缺少的日志;
@@ -649,6 +659,89 @@ public synchronized long nextId() {
用 Token 实现时,Token 的过期时间按用户在页面上停留的时长设置,例如 30 分钟。DEL 返回 1 之后处理失败时,这个 Token 已经删除,客户端要重新获取 Token 再提交。
## 十、分布式定时任务
定时任务指的是按时间规则自动执行的任务,例如每月 1 日凌晨 2 点给所有用户发送上月的账单。只有一个实例时,用 Spring 的 `@Scheduled(cron = "0 0 2 1 * ?")` 就能实现。
问题在于服务部署多个实例之后,每个实例都有自己的调度器,到点时各自执行一次。账单服务的两个实例 A、B 同时触发,100 个用户每人收到了两条账单短信(MySQL 8.0 实测,见[附录 A5](#a5-定时任务的重复执行与加锁))。多个实例定时扫描 [本地消息表](#本地消息表) 时也一样,同一条消息会被转发多次。
分布式定时任务要解决三个问题:
- 同一次触发只执行一次;
- 数据量大时由多个实例分摊;
- 执行失败或者实例宕机时能够补上。
### 只执行一次
最简单的做法是到点时所有实例都去抢一把 [分布式锁](#一分布式锁),抢到的执行,抢不到的直接跳过,不等待。
锁的键要对应“这一次触发”,并且执行完也不删除,等它自然过期:
```text
SET lock:job:bill:2026-11 10.0.0.11 NX EX 3600
```
这是因为各实例的时钟存在偏差。如果用固定的键 lock:job:bill 并在执行完立即删除,任务只要几十毫秒,A 执行完删除了锁,时钟慢 300 ms 的 B 才触发,照样能拿到锁再发一遍;键中带上账单月份并且不删除,B 就拿不到锁(Redis 7.4 实测,各 3 轮,见[附录 A5](#a5-定时任务的重复执行与加锁))。给 @Scheduled 加锁的 ShedLock 库提供了 lockAtLeastFor(锁至少持有的时长),解决的也是这个问题。
用数据库时,可以在执行记录表的 (job_name, fire_time) 上建唯一索引,插入成功的实例执行,报错 1062 的实例跳过,原理与 [数据库的唯一索引](#数据库的唯一索引) 相同,还顺便留下了执行记录。
抢锁的做法不需要额外部署,一个实例宕机时其它实例照常触发。缺点是每次只有一个实例在工作;拿到锁的实例中途崩溃时,锁还没有过期,其它实例也不会接手;执行记录、失败告警和手动重跑都要自己实现。
### 调度中心
XXL-JOB 等框架把“什么时候触发”和“执行什么”拆开(以下以 XXL-JOB 3.5 的文档和源码为准):
- 调度中心:独立部署,保存任务配置,按 Cron 表达式计算下次触发时间,到点时发出调度请求,本身不执行业务代码;
- 执行器:嵌入在业务服务中,启动时按应用名(AppName)把自己的地址注册到调度中心,之后每 30 秒发一次心跳,超过 90 秒没有心跳就被移出注册表;收到调度请求后运行任务代码,结束后把结果回调给调度中心。
调度请求、心跳和回调都是 HTTP 请求。
调度中心本身也部署多个节点,它们共用一个 MySQL。每轮扫描前先用 SELECT ... FOR UPDATE 锁住 xxl_job_lock 表中的一行,拿到锁的节点才查询 5 秒内到期的任务,并在同一个事务中更新它们的下次触发时间。其它节点等事务提交后才能拿到锁,查到的已经是更新后的时间,所以同一次触发只会被发出一次。
执行器有多个实例时,调度中心按任务的路由策略选择实例:第一个、轮询、一致性哈希、故障转移等策略每次只发给一个实例,分片广播则发给全部实例。需要注意的是,官方文档也提到集群中仍有小概率重复触发,任务本身还是要做到 [幂等](#九幂等)。
### 分片
只有一个实例执行时,100 万个用户的账单可能要发几个小时。分片指的是把数据按规则分成几份,每个实例只处理其中一份。
XXL-JOB 的分片广播把一次触发发给执行器的每个在线实例,并带上分片序号 index(从 0 开始)和分片总数 total(在线实例数)。任务代码用 XxlJobHelper.getShardIndex() 和 getShardTotal() 取得这两个参数,按 ID 取模只查询自己的那一份:
```sql
SELECT id FROM users WHERE id % 2 = 0; -- 执行器 A,分片 0/2
SELECT id FROM users WHERE id % 2 = 1; -- 执行器 B,分片 1/2
```
<div align="center"> <img src="pics/xxl-job-sharding.png" width="760px"/> </div><br>
1 万个用户、每条短信模拟 1 ms 的外部调用时,单个实例用时 16.9 秒;两个实例各处理 5000 个,用时 8.8 秒,没有遗漏和重复(MySQL 8.0 实测,见[附录 A6](#a6-分片与按分片重试))。
total 取的是触发时的在线实例数,扩容后下一次触发自动分成更多份。取模时每个分片都要扫描全部 ID,数据量很大时可以改为按 ID 范围分片,每个分片只扫描自己的区间。
不用框架时,也可以由一个实例扫描出待处理的用户 ID,发送到 [消息队列](消息队列.md),由所有实例一起消费,适合每条数据处理较慢、需要削峰的场景。
### 失败与宕机
一次任务分为调度和执行两个阶段,失败的处理方式不同:
- 调度时实例宕机:故障转移策略按顺序向各个实例发送心跳检测,把请求发给第一个响应的实例;
- 执行失败:任务可以配置失败重试次数,调度中心发现执行失败后再触发一次。分片任务只重试失败的那一片,沿用原来的分片参数,例如 0/2;
- 执行中宕机:执行器不会再回调结果,调度中心发现一条调度记录处于“运行中”超过 10 分钟、对应的执行器也已经不在线,就把它标记为失败,再按执行失败处理。
单机的 @Scheduled 没有这些机制,触发时间只保存在进程的内存中,实例停机期间错过的触发不会补执行。
重试的前提是任务可以重复执行。账单任务每发送一个用户就记录下来,例如在账单表的 (user_id, month) 上建唯一索引,重试时跳过已经发送过的用户,做法见 [幂等](#九幂等)。实例 A 发送 2000 条后崩溃,按分片参数 0/2 重试,只补发了剩下的 3000 条,1 万个用户每人一条(见[附录 A6](#a6-分片与按分片重试))。
处理条件也要写成“所有待处理的数据”,例如关闭所有超时未支付的订单,而不是关闭上一分钟创建的订单,这样某一次执行失败,下一次触发也能补上。
### 定时扫描与延迟消息
定时任务适合按周期批量处理的场景,例如月度账单、日终对账、清理过期数据和补偿扫描。每条数据各有到期时间的场景,例如下单 30 分钟未支付自动关闭,定时扫描有两个问题:
- 精度取决于扫描间隔:每分钟扫描一次,订单最晚要到第 31 分钟才关闭;
- 扫描间隔越短,查询越频繁,而每次只有少量订单到期。
这类场景更适合 [延迟消息](消息队列.md#延迟消息):下单时发送一条 30 分钟后投递的消息,到期只处理这一个订单。实际使用时常把两者结合,延迟消息负责按时关闭,低频的定时扫描兜底,处理消息丢失的订单。
## 附录:实测
环境为 Apple M1 Pro、macOS,MySQL 8.0.44,JDK 21。
@@ -730,6 +823,71 @@ MySQL 8.0.44,Python 3 + pymysql。两张订单表结构相同,只是 t3_orde
唯一索引 第 1~5 轮:同一个 request_id 并发提交 10 次,创建订单 1 个,其余 9 次报错 1062
```
### A5 定时任务的重复执行与加锁
Redis 7.4.1,MySQL 8.0.44,Python 3 + redis-py、pymysql。h3_users 中有 100 个用户,两个进程 A、B 模拟账单服务的两个实例,在同一时刻触发(B 的时钟慢 300 ms 时,B 晚 0.3 秒触发)。任务查询全部用户,每个用户向 h3_bill_sms 插入一条短信记录:
```python
if mode == 'release': # 固定的键,执行完删除
if not r.set('lock:job:bill', name, nx=True, px=60000): skip()
elif mode == 'period': # 键中带账单月份,执行完不删除
if not r.set('lock:job:bill:2026-11', name, nx=True, ex=3600): skip()
for uid in select_ids(): # SELECT id FROM h3_users
insert_sms(uid, '2026-11', name) # INSERT INTO h3_bill_sms (user_id, month, instance) ...
if mode == 'release' and r.get('lock:job:bill') == name.encode():
r.delete('lock:job:bill')
```
每种情况运行 3 轮,结果相同(固定键、时钟一致时,拿到锁的实例各轮不同),下面是第 1 轮:
```text
== 不加锁,两个实例时钟一致
A 触发 +0.001s 查到 100 人,发送 100 条,用时 0.02s
B 触发 +0.001s 查到 100 人,发送 100 条,用时 0.02s
短信 200 条,覆盖用户 100 个,收到两条及以上的用户 100 个
== 固定键加锁、执行完删除,时钟一致
A 触发 +0.001s 查到 100 人,发送 100 条,用时 0.02s
B 触发 +0.001s 未拿到锁,跳过
短信 100 条,覆盖用户 100 个,收到两条及以上的用户 0 个
== 固定键加锁、执行完删除,B 的时钟慢 300ms
A 触发 +0.000s 查到 100 人,发送 100 条,用时 0.02s
B 触发 +0.300s 查到 100 人,发送 100 条,用时 0.02s
短信 200 条,覆盖用户 100 个,收到两条及以上的用户 100 个
== 键中带账单月份、执行完不删除,B 的时钟慢 300ms
A 触发 +0.001s 查到 100 人,发送 100 条,用时 0.02s
B 触发 +0.300s 未拿到锁,跳过
短信 100 条,覆盖用户 100 个,收到两条及以上的用户 0 个
```
### A6 分片与按分片重试
环境同 A5。h3_users 中有 1 万个用户,每发送一条短信先等待 1 ms,模拟调用短信服务。发送前先向 h3_bill 插入 (user_id, month),它是主键,插入失败说明已经发送过,跳过这个用户:
```python
for uid in select_ids(total, index): # SELECT id FROM h3_users WHERE id % total = index
if execute('INSERT IGNORE INTO h3_bill (user_id, month) VALUES (%s, %s)', (uid, '2026-11')) == 0:
continue
time.sleep(0.001)
insert_sms(uid, '2026-11', name)
```
这个写法在插入之后、发送之前崩溃时会漏发一个用户,实验中的崩溃点在发送之后。
```text
== 单个实例处理全部用户
A 分片 0/1 查到 10000 人,发送 10000 条,用时 16.94s
== 两个实例按 id % 2 分片
A 分片 0/2 查到 5000 人,发送 5000 条,用时 8.75s
B 分片 1/2 查到 5000 人,发送 5000 条,用时 8.76s
短信 10000 条,覆盖用户 10000 个,收到两条及以上的用户 0 个
== 分片 0 发送 2000 条后崩溃(os._exit),之后按原分片参数 0/2 重试
A 分片 0/2 发送 2000 条后崩溃
B 分片 1/2 查到 5000 人,发送 5000 条,用时 8.72s
短信 7000 条,覆盖用户 7000 个,收到两条及以上的用户 0 个
A2 分片 0/2 查到 5000 人,发送 3000 条,用时 4.92s
重试后 短信 10000 条,覆盖用户 10000 个,收到两条及以上的用户 0 个
```
## 参考资料
- [Apache Dubbo: Architecture](https://dubbo.apache.org/docs/v2.7/user/preface/architecture/)
@@ -765,3 +923,6 @@ MySQL 8.0.44,Python 3 + pymysql。两张订单表结构相同,只是 t3_orde
- [NEAT ALGORITHMS - PAXOS](https://web.archive.org/web/20260307090655/http://harry.me/blog/2014/12/27/neat-algorithms-paxos/)
- [In Search of an Understandable Consensus Algorithm](https://raft.github.io/raft.pdf)
- [Leaf——美团点评分布式 ID 生成系统](https://tech.meituan.com/2017/04/21/mt-leaf.html)
- [分布式任务调度平台 XXL-JOB](https://www.xuxueli.com/xxl-job/)
- [XXL-JOB 源码:JobScheduleHelper](https://github.com/xuxueli/xxl-job/blob/3.5.0/xxl-job-admin/src/main/java/com/xxl/job/admin/business/scheduler/thread/JobScheduleHelper.java)
- [ShedLock](https://github.com/lukas-krecan/ShedLock)