Update 分布式.md

补充 ZooKeeper 栅栏令牌使用 czxid、Dubbo 默认的重试次数;拆分较长的段落,调整雪花算法配图的说明。
This commit is contained in:
CyC2018
2026-10-31 14:03:50 +08:00
parent 1b915cc1c0
commit 4e9608a9aa
2 changed files with 14 additions and 10 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 129 KiB

+14 -10
View File
@@ -85,7 +85,11 @@ SELECT * FROM distributed_lock WHERE name = 'order:1024' FOR UPDATE;
COMMIT;
```
其它客户端执行同一条语句时阻塞,超过 innodb_lock_wait_timeout(默认 50 秒)返回错误;加上 NOWAIT 时拿不到锁立即返回错误,相当于非阻塞锁。持有锁的进程崩溃后连接断开,事务回滚,锁立即释放:kill -9 持有锁的进程后,等待中的客户端马上拿到了锁(MySQL 8.0 实测,见[附录 A7](#a7-mysql-行锁实现的分布式锁))。需要注意的是,持有锁的机器宕机或网络中断时,MySQL 收不到断开的通知,要等这个空闲连接超过 wait_timeout(默认 8 小时)才回滚。可以为持有锁的连接调小 wait_timeout,但要大于持锁期间连接空闲的最长时间(例如调用远程服务),否则业务还没执行完锁就被释放了。代价是持有锁期间一直占用一个数据库连接和一个未提交的事务,并发高时会耗尽连接池。
其它客户端执行同一条语句时阻塞,超过 innodb_lock_wait_timeout(默认 50 秒)返回错误;加上 NOWAIT 时拿不到锁立即返回错误,相当于非阻塞锁。持有锁的进程崩溃后连接断开,事务回滚,锁立即释放:kill -9 持有锁的进程后,等待中的客户端马上拿到了锁(MySQL 8.0 实测,见[附录 A7](#a7-mysql-行锁实现的分布式锁))。
需要注意的是,持有锁的机器宕机或网络中断时,MySQL 收不到断开的通知,要等这个空闲连接超过 wait_timeout(默认 8 小时)才回滚。可以为持有锁的连接调小 wait_timeout,但要大于持锁期间连接空闲的最长时间(例如调用远程服务),否则业务还没执行完锁就被释放了。
代价是持有锁期间一直占用一个数据库连接和一个未提交的事务,并发高时会耗尽连接池。
### Redis 的 SET NX 指令
@@ -157,7 +161,7 @@ RedLock 不要求各节点的时钟同步,但假设各节点的时钟走速大
UPDATE inventory SET stock = 97, fence = 8 WHERE sku_id = 10086 AND fence <= 8;
```
RedLock 使用的随机值不是递增的,算法本身无法提供栅栏令牌;ZooKeeper 则可以使用加锁时创建的有序节点的序号或者事务 ID(zxid),它们都是单调递增的。
RedLock 使用的随机值不是递增的,算法本身无法提供栅栏令牌;ZooKeeper 则可以使用加锁时创建的节点的事务 ID(czxid),它在整个集群中单调递增。有序节点的序号不适合作令牌:序号的计数器属于父节点,Curator 把锁的父节点建成容器节点,锁空闲后父节点会被删除,序号又从 0 开始。
Redisson 已把 RedLock 标为弃用,改为推荐 RLock 加上主从同步检查;需要令牌时用 RFencedLock,它在加锁时对一个计数键执行 INCR,返回值作为令牌。这个计数键和锁在同一个主节点上,主从复制是异步的,故障转移后计数可能回退,令牌不再严格递增,所以它同样不能完全排除旧持有者的写入。
@@ -636,7 +640,7 @@ gRPC 选择 HTTP/2 是因为它的 [多路复用](HTTP.md#二进制分帧层):
- 每次调用都要设置超时时间,否则下游变慢时,调用方的线程会一直等待,最终被耗尽;
- 调用方超时不代表服务方没有执行:请求可能在网络中延迟,或者在服务方的线程池中排队、遇到 GC 停顿,这些时间都计入了调用方的耗时,却不在服务方记录的处理时间里,于是调用方超时了,服务方却执行成功了;
- 因此只有幂等的接口才能自动重试,例如查询;扣库存这类写操作要带上请求 ID,由服务方去重(见 [幂等](#九幂等)),否则重试会扣减两次;
- 因此只有幂等的接口才能自动重试,例如查询;扣库存这类写操作要带上请求 ID,由服务方去重(见 [幂等](#九幂等)),否则重试会扣减两次。需要注意的是,有的框架默认就会重试,例如 Dubbo 默认的集群容错方式是 failover,失败或超时后换一台服务器重试 2 次,一次调用最多执行 3 次,写操作要改为 failfast 或者带上请求 ID;
- 调用链较长时,下游的超时时间应该比上游短。gRPC 会把截止时间(deadline)随请求传给服务方,超时后调用方收到 DEADLINE_EXCEEDED,服务方的框架也会取消这次调用,但正在执行的业务代码不会被中断,耗时的处理要自己检查是否已被取消;服务方调用下游时沿用这次请求的上下文,Java 和 Go 的 gRPC 会自动把剩余的时间传下去。
<div align="center"> <img src="pics/rpc-timeout-retry.png" width="760px"/> </div><br>
@@ -932,7 +936,7 @@ RLock lock = redisson.getLock("lock:order:1024");
### A4 重复提交与唯一索引
MySQL 8.0.44,Python 3 + pymysql。两张订单表结构相同,只是 t3_orders_uk 在 request_id 上有唯一索引。每轮用 10 个线程同时提交同一个 request_id,先查后插的写法先 SELECT,没有记录再 INSERT;唯一索引的写法直接 INSERT,捕获 1062 错误:
MySQL 8.0.44,Python 3 + pymysql。两张订单表结构相同,只是 orders_uk 在 request_id 上有唯一索引。每轮用 10 个线程同时提交同一个 request_id,先查后插的写法先 SELECT,没有记录再 INSERT;唯一索引的写法直接 INSERT,捕获 1062 错误:
```text
先查后插 第 1 轮:同一个 request_id 并发提交 10 次,创建订单 2 个
@@ -952,15 +956,15 @@ MySQL 8.0.44,Python 3 + pymysql。两张订单表结构相同,只是 t3_orde
### 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 插入一条短信记录:
Redis 7.4.1,MySQL 8.0.44,Python 3 + redis-py、pymysql。users 中有 100 个用户,两个进程 A、B 模拟账单服务的两个实例,在同一时刻触发(B 的时钟慢 300 ms 时,B 晚 0.3 秒触发)。任务查询全部用户,每个用户向 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) ...
for uid in select_ids(): # SELECT id FROM users
insert_sms(uid, '2026-11', name) # INSERT INTO bill_sms (user_id, month, instance) ...
if mode == 'release' and r.get('lock:job:bill') == name.encode():
r.delete('lock:job:bill')
```
@@ -988,11 +992,11 @@ if mode == 'release' and r.get('lock:job:bill') == name.encode():
### A6 分片与按分片重试
环境同 A5。h3_users 中有 1 万个用户,每发送一条短信先等待 1 ms,模拟调用短信服务。发送前先向 h3_bill 插入 (user_id, month),它是主键,插入失败说明已经发送过,跳过这个用户:
环境同 A5。users 中有 1 万个用户,每发送一条短信先等待 1 ms,模拟调用短信服务。发送前先向 bill_sent 插入 (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:
for uid in select_ids(total, index): # SELECT id FROM users WHERE id % total = index
if execute('INSERT IGNORE INTO bill_sent (user_id, month) VALUES (%s, %s)', (uid, '2026-11')) == 0:
continue
time.sleep(0.001)
insert_sms(uid, '2026-11', name)