diff --git a/notes/pics/cap-partition.png b/notes/pics/cap-partition.png index 590f9777..d3d15e90 100644 Binary files a/notes/pics/cap-partition.png and b/notes/pics/cap-partition.png differ diff --git a/notes/pics/idempotent-request-id.png b/notes/pics/idempotent-request-id.png index 975e060b..2e28b242 100644 Binary files a/notes/pics/idempotent-request-id.png and b/notes/pics/idempotent-request-id.png differ diff --git a/notes/pics/job-lock-clock-skew.png b/notes/pics/job-lock-clock-skew.png new file mode 100644 index 00000000..ce449830 Binary files /dev/null and b/notes/pics/job-lock-clock-skew.png differ diff --git a/notes/pics/local-message-table.png b/notes/pics/local-message-table.png index a4e67c36..372dc262 100644 Binary files a/notes/pics/local-message-table.png and b/notes/pics/local-message-table.png differ diff --git a/notes/pics/paxos-example.png b/notes/pics/paxos-example.png index e61a3ffe..0384fc4a 100644 Binary files a/notes/pics/paxos-example.png and b/notes/pics/paxos-example.png differ diff --git a/notes/pics/raft-election.png b/notes/pics/raft-election.png index 76c94792..b84ab0c3 100644 Binary files a/notes/pics/raft-election.png and b/notes/pics/raft-election.png differ diff --git a/notes/pics/raft-log.png b/notes/pics/raft-log.png index a4f6dc53..2a3c36c5 100644 Binary files a/notes/pics/raft-log.png and b/notes/pics/raft-log.png differ diff --git a/notes/pics/raft-split.png b/notes/pics/raft-split.png index b5c2bdd0..2d111938 100644 Binary files a/notes/pics/raft-split.png and b/notes/pics/raft-split.png differ diff --git a/notes/pics/redis-lock-failover.png b/notes/pics/redis-lock-failover.png new file mode 100644 index 00000000..cf0ed0eb Binary files /dev/null and b/notes/pics/redis-lock-failover.png differ diff --git a/notes/pics/redis-lock-release.png b/notes/pics/redis-lock-release.png new file mode 100644 index 00000000..eda8f47c Binary files /dev/null and b/notes/pics/redis-lock-release.png differ diff --git a/notes/pics/rocketmq-transaction.png b/notes/pics/rocketmq-transaction.png index 721352d1..9aee9907 100644 Binary files a/notes/pics/rocketmq-transaction.png and b/notes/pics/rocketmq-transaction.png differ diff --git a/notes/pics/rpc-call.png b/notes/pics/rpc-call.png index 1c134c8c..d64d040d 100644 Binary files a/notes/pics/rpc-call.png and b/notes/pics/rpc-call.png differ diff --git a/notes/pics/rpc-timeout-retry.png b/notes/pics/rpc-timeout-retry.png new file mode 100644 index 00000000..43a199e9 Binary files /dev/null and b/notes/pics/rpc-timeout-retry.png differ diff --git a/notes/pics/saga.png b/notes/pics/saga.png index 84563f5b..fd15cb4e 100644 Binary files a/notes/pics/saga.png and b/notes/pics/saga.png differ diff --git a/notes/pics/tcc-fence.png b/notes/pics/tcc-fence.png new file mode 100644 index 00000000..f0f19271 Binary files /dev/null and b/notes/pics/tcc-fence.png differ diff --git a/notes/pics/zk-namespace.png b/notes/pics/zk-namespace.png index 252413e9..f4d43483 100644 Binary files a/notes/pics/zk-namespace.png and b/notes/pics/zk-namespace.png differ diff --git a/notes/分布式.md b/notes/分布式.md index 54c89d20..996d5fea 100644 --- a/notes/分布式.md +++ b/notes/分布式.md @@ -5,7 +5,7 @@ * [数据库的唯一索引](#数据库的唯一索引) * [Redis 的 SET NX 指令](#redis-的-set-nx-指令) * [Redis 的 RedLock 算法](#redis-的-redlock-算法) - * [Zookeeper 的有序节点](#zookeeper-的有序节点) + * [ZooKeeper 的有序节点](#zookeeper-的有序节点) * [三种实现的对比](#三种实现的对比) * [二、分布式事务](#二分布式事务) 🔥 * [2PC](#2pc) @@ -59,12 +59,12 @@ 在单机场景下,可以使用语言的内置锁来实现进程同步。但是在分布式场景下,需要同步的进程可能位于不同的节点上,那么就需要使用分布式锁。 -阻塞锁通常使用互斥量来实现: +分布式锁把“锁是否被占用”保存在所有节点都能访问的地方,例如数据库中的一行、Redis 中的一个键、ZooKeeper 中的一个节点。下面的几种实现都要解决同样的几个问题: -- 互斥量为 0 表示有其它进程在使用锁,此时处于锁定状态; -- 互斥量为 1 表示未锁定状态。 - -1 和 0 可以用一个整型值表示,也可以用某个数据是否存在表示。 +- 互斥:同一时刻只有一个客户端能拿到锁; +- 不会死锁:持有锁的客户端崩溃后,锁最终能被释放; +- 不误删:只有持有锁的客户端才能释放锁; +- 按需要支持阻塞等待和可重入。 ### 数据库的唯一索引 @@ -85,13 +85,13 @@ 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-行锁实现的分布式锁))。代价是持有锁期间一直占用一个数据库连接和一个未提交的事务,并发高时会耗尽连接池。 +其它客户端执行同一条语句时阻塞,超过 innodb_lock_wait_timeout(默认 50 秒)返回错误;加上 NOWAIT 时拿不到锁立即返回错误,相当于非阻塞锁。持有锁的进程崩溃后连接断开,事务回滚,锁立即释放:kill -9 持有锁的进程后,等待中的客户端马上拿到了锁(MySQL 8.0 实测,见[附录 A7](#a7-mysql-行锁实现的分布式锁))。需要注意的是,持有锁的机器宕机或网络中断时,MySQL 收不到断开的通知,要等这个空闲连接超过 wait_timeout(默认 8 小时)才回滚。可以为持有锁的连接调小 wait_timeout,但要大于持锁期间连接空闲的最长时间(例如调用远程服务),否则业务还没执行完锁就被释放了。代价是持有锁期间一直占用一个数据库连接和一个未提交的事务,并发高时会耗尽连接池。 ### Redis 的 SET NX 指令 -SETNX(set if not exist)指令插入一个键值对,如果 Key 已经存在,那么插入失败并返回 0,否则插入成功并返回 1。它和数据库的唯一索引类似,保证了只存在一个 Key 的键值对,那么可以用一个 Key 的键值对是否存在来判断是否处于锁定状态。 +SETNX(SET if Not eXists)命令在键不存在时写入并返回 1,键已经存在时什么也不做并返回 0。它和数据库的唯一索引类似,可以用这个键是否存在来判断是否处于锁定状态。 -EXPIRE 指令可以为一个键值对设置一个过期时间,从而避免了释放锁失败的问题。但是 SETNX 和 EXPIRE 是两条指令,不具有原子性,如果执行完 SETNX 之后进程崩溃,那么这个锁就永远不会过期。因此应该使用 SET 指令的 NX 和 PX 选项一次性完成加锁和设置过期时间: +EXPIRE 命令可以为键设置过期时间,从而避免了释放锁失败的问题。但是 SETNX 和 EXPIRE 是两条命令,不具有原子性,如果执行完 SETNX 之后进程崩溃,那么这个锁就永远不会过期。因此应该使用 SET 命令的 NX 和 PX 选项一次性完成加锁和设置过期时间: ```text SET lock_key unique_value NX PX 30000 @@ -99,9 +99,13 @@ SET lock_key unique_value NX PX 30000 还需要注意以下几点: -- 释放锁:value 设置为客户端的唯一标识,释放锁时先判断 value 是否为自己设置的再删除,避免误删其它客户端的锁,判断和删除需要使用 Lua 脚本保证原子性; +- 释放锁:value 设置为客户端的唯一标识,释放锁时先判断 value 是否为自己设置的再删除,避免误删其它客户端的锁。判断和删除要在一个 [Lua 脚本](Redis.md#lua-脚本)中完成:分成 GET 和 DEL 两条命令时,GET 确认是自己的锁之后、DEL 执行之前,锁可能恰好过期并被其它客户端拿到,DEL 删掉的就是别人的锁; - 锁续期:如果业务执行时间超过了过期时间,锁会被提前释放;过期时间太长,持有者崩溃后其它客户端又要等很久。过期时间通常取业务正常耗时的几倍,耗时难以估计时使用后台线程定时延长过期时间,例如下文 Redisson 的看门狗; -- 主从切换:Redis 的主从复制是异步的,如果主节点加锁后还未同步到从节点就宕机,从节点晋升为主节点之后锁就丢失了。 +- 主从切换:Redis 的主从复制是异步的,如果主节点加锁后还未同步到从节点就宕机,从节点经[故障转移](Redis.md#十二sentinel)晋升为主节点之后锁就丢失了。 + +

+ +释放锁的 Lua 脚本: ```lua if redis.call("get", KEYS[1]) == ARGV[1] then @@ -111,23 +115,27 @@ else end ``` -Redis 8.4 新增了 `DELEX lock_key IFEQ unique_value` 指令,可以直接原子地完成比较并删除的操作,不再需要 Lua 脚本。 +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() 一直等到拿到锁为止,tryLock(3, TimeUnit.SECONDS) 最多等待 3 秒,拿不到就返回 false; - 看门狗:调用 lock() 时不指定过期时间,Redisson 先把过期时间设为 lockWatchdogTimeout(默认 30 秒),再启动一个定时任务,每 10 秒(30 秒的 1/3)检查一次,锁仍是自己的就把过期时间重置为 30 秒,解锁时取消这个任务。

看门狗运行在持有锁的进程中,只在这个进程存活时续期。进程崩溃后没有人续期,锁最多 30 秒后自动过期,其它客户端就能拿到锁。反过来,如果持有锁的线程卡住(例如等待一个没有设置超时的远程调用)而进程还活着,看门狗会一直续期,其它客户端一直拿不到锁,所以锁内的操作都要设置超时。 -调用 lock(10, TimeUnit.SECONDS) 指定了过期时间时,Redisson 不启动看门狗,10 秒后锁被自动删除,即使业务还没有执行完。需要注意的是,Redisson 的锁仍然保存在一个 Redis 主节点上,主从切换时丢锁的问题依然存在。 +续期也可能失败,例如持有者与 Redis 之间的网络中断超过 30 秒:看门狗只记录日志并重试,不通知业务线程,锁过期后被其它客户端拿到,原持有者却还在执行,只能靠栅栏令牌或数据库约束兜底。之后原持有者解锁时,脚本发现字段“客户端 UUID:线程 ID”不存在,不会删掉别人的锁,unlock() 抛出 IllegalMonitorStateException。 + +调用 lock(10, TimeUnit.SECONDS) 指定了过期时间时,Redisson 不启动看门狗,10 秒后锁被自动删除,即使业务还没有执行完。需要注意的是,Redisson 的锁仍然保存在一个 Redis 主节点上。有从节点时,它加锁后默认执行 WAIT 等从节点确认,能缩小丢锁的窗口;但故障转移不一定选中收到这把锁的从节点,仍不能保证不丢。对正确性要求高的场景,资源端用下文 [RedLock](#redis-的-redlock-算法) 一节的栅栏令牌或 ZooKeeper,并以数据库的唯一约束、条件更新兜底。 + +

### Redis 的 RedLock 算法 -使用了多个 Redis 实例来实现分布式锁,这是为了保证在发生单点故障时仍然可用。 +RedLock 使用多个互相独立的 Redis 实例(通常是 5 个)实现分布式锁,避免单个主节点宕机或主从切换时丢锁。 - 用同一个键和随机值,并行地向 N 个互相独立的 Redis 实例加锁,每次请求的超时时间远小于锁的过期时间; - 计算获取锁消耗的时间,只有时间小于锁的过期时间,并且从大多数(N / 2 + 1)实例上获取了锁,才认为获取锁成功; @@ -151,11 +159,11 @@ UPDATE inventory SET stock = 97, fence = 8 WHERE sku_id = 10086 AND fence <= 8; RedLock 使用的随机值不是递增的,算法本身无法提供栅栏令牌;ZooKeeper 则可以使用加锁时创建的有序节点的序号或者事务 ID(zxid),它们都是单调递增的。 -### Zookeeper 的有序节点 +### ZooKeeper 的有序节点 -#### 1. Zookeeper 抽象模型 +#### 1. ZooKeeper 抽象模型 -Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节点为 /app1。 +ZooKeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节点为 /app1。

@@ -176,9 +184,11 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节 - 客户端获取 /lock 下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁;否则监听自己的前一个子节点,获得子节点的变更通知后重复此步骤直至获得锁; - 执行业务代码,完成后,删除对应的子节点。 +例如上图中客户端 A、B、C 依次创建了 node-0000000000、node-0000000001、node-0000000002,A 的序号最小,获得锁;B 监听 node-0000000000,C 监听 node-0000000001。A 执行完删除自己的节点后,只有 B 收到通知,B 重新获取子节点列表,发现自己的序号最小,获得锁。 + #### 5. 会话超时 -如果一个已经获得锁的会话超时了,因为创建的是临时节点,所以该会话对应的临时节点会被删除,其它会话就可以获得锁了。可以看到,这种实现方式不会出现数据库的唯一索引实现方式释放锁失败的问题。 +如果一个已经获得锁的会话超时了,因为创建的是临时节点,所以该会话对应的临时节点会被删除,其它会话就可以获得锁了。这种实现方式不会出现数据库的唯一索引实现方式释放锁失败的问题。 但是如果客户端因为 GC 等原因长时间没有发送心跳,也会导致会话超时,因此同样可能出现两个客户端都认为自己持有锁的情况,需要配合上文的栅栏令牌。 @@ -190,7 +200,7 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节 - 数据库:实现简单,但是性能低; - Redis:性能最高,适合高并发场景,但是在主从切换、进程暂停等极端情况下可能出现两个客户端同时持有锁; -- Zookeeper:写操作需要同步到大多数节点,性能较低,但是可靠性更高,还能提供栅栏令牌,适合一致性要求高、并发量不大的场景。 +- ZooKeeper:写操作需要同步到大多数节点,性能较低,但是可靠性更高,还能提供栅栏令牌,适合一致性要求高、并发量不大的场景。 实际使用中一般直接采用 Redisson、Curator 等成熟的客户端实现。 @@ -210,8 +220,8 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节 分布式锁和分布式事务区别: -- 锁问题的关键在于进程操作的互斥关系,例如多个进程同时修改账户的余额,如果没有互斥关系则会导致该账户的余额不正确; -- 而事务问题的关键则在于事务涉及的一系列操作需要满足 ACID 特性,例如要满足原子性操作则需要这些操作要么都执行,要么都不执行。 +- 锁解决的是互斥,例如多个进程同时修改同一个账户的余额; +- 事务解决的是一组操作要么都执行、要么都不执行。 下面介绍的几种做法,常用的分布式事务框架 Seata 都提供了实现。Seata 中有三个角色:事务协调者(Transaction Coordinator, TC)是单独部署的服务,记录全局事务和各个分支事务的状态,驱动它们提交或回滚;事务管理器(Transaction Manager, TM)在发起全局事务的服务中,负责开始、提交或回滚全局事务;资源管理器(Resource Manager, RM)在每个参与的服务中,负责注册分支事务、报告状态并执行提交或回滚。它提供四种模式: @@ -247,6 +257,8 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节 所有事务参与者在等待其它参与者响应的时候都处于同步阻塞等待状态,并且一直持有锁,无法进行其它操作。 +锁要经过两轮网络往返才释放,并发高时吞吐量下降明显;而且用数据库的 XA 实现时,每个参与者都要支持 XA,Redis、Kafka、RocketMQ 和一般的 HTTP 接口都无法直接参与。这是互联网业务很少直接使用 2PC 的主要原因。 + ##### 2.2 单点问题 协调者在 2PC 中起到非常大的作用,发生故障将会造成很大影响。特别是在提交阶段发生故障,已经回复同意的参与者既不能提交也不能回滚(称为存疑事务),只能一直阻塞等待协调者恢复。 @@ -257,7 +269,7 @@ Zookeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节 ##### 2.4 太过保守 -任意一个节点失败就会导致整个事务失败,没有完善的容错机制。 +准备阶段任意一个参与者失败、回复中止或者超时,整个事务都要回滚,没有完善的容错机制。 三阶段提交(3PC)在 2PC 的基础上增加了一个询问阶段(CanCommit),并且在参与者端也引入了超时机制:参与者收到预提交(PreCommit)之后,如果等待最终通知超时,就默认提交,从而减少了阻塞。但是它假设网络延迟和节点的响应时间都有上界,而现实系统中存在无界的网络延迟和进程暂停,超时并不能说明对方已经失效,因此仍然可能出现数据不一致。实践中更常见的做法是用共识算法把协调者的决定复制到多个节点上,避免单点。 @@ -271,7 +283,9 @@ TCC(Try-Confirm-Cancel)是在业务层面实现的两阶段提交,每个 - Confirm:使用预留的资源真正执行业务,例如扣减冻结的库存; - Cancel:释放预留的资源,例如解冻库存。 -如果所有参与者的 Try 都执行成功,就执行 Confirm,否则执行 Cancel。Confirm 和 Cancel 失败后都需要重试,因此必须实现幂等。 +如果所有参与者的 Try 都执行成功,就执行 Confirm,否则执行 Cancel。 + +Try 成功后资源已经预留,Confirm 失败只会是超时、宕机这类临时故障;其它参与者可能已经 Confirm,所以不能改为 Cancel,只能不断重试,多次失败后告警人工处理。Cancel 失败同样重试,因此两者都必须实现 [幂等](#九幂等)。 相比于 2PC,TCC 不需要长时间持有数据库的锁,性能更好,但是对业务的侵入性较强。它适合扣库存、扣余额这类一致性要求高、能提供预留和取消接口的资源操作。 @@ -280,7 +294,9 @@ TCC(Try-Confirm-Cancel)是在业务层面实现的两阶段提交,每个 - 空回滚:Try 没有执行,协调者超时后就调用了 Cancel,Cancel 需要识别出这种情况并直接返回成功; - 悬挂:Cancel 执行之后,被延迟的 Try 才到达并预留了资源,这些资源再也不会被释放,因此 Try 执行前需要检查该事务是否已经被 Cancel。 -通常用一张事务控制表记录每个事务的执行状态来解决这两个问题,幂等也可以通过它实现。 +通常用一张事务控制表记录每个分支的执行状态来解决这两个问题,幂等也可以通过它实现。例如 Seata 的 tcc_fence_log 以(全局事务 ID,分支 ID)为主键,和业务操作在同一个本地事务中写入:Cancel 查不到 Try 的记录时,只插入一条状态为 suspended 的记录并返回成功,这就是空回滚;之后延迟的 Try 再插入同一个主键时冲突,不再预留资源。 + +

### Saga @@ -292,7 +308,9 @@ Saga 没有预留资源的 Try 阶段,中间状态对其它事务可见,因 ### 本地消息表 -本地消息表与业务数据表放在同一个数据库中,用本地事务保证业务数据和消息一起写入,再通过消息队列把消息送到另一个服务。以下单后给用户加积分为例: +本地消息表与业务数据表放在同一个数据库中,用本地事务保证业务数据和消息一起写入,再通过消息队列把消息送到另一个服务。 + +不在本地事务提交后直接发送消息,是因为提交之后、发送之前进程崩溃,订单有了,消息却丢了;反过来先发送再提交,事务回滚时消息已经发出,积分服务会给不存在的订单加积分。把消息作为一行数据和订单一起提交,“写订单”和“要发消息”就成了一个原子操作。以下单后给用户加积分为例: 1. 订单服务在同一个本地事务中插入订单 1024 和一条待发送的消息,两行要么都写入,要么都不写入; 2. 定时任务扫描待发送的消息,转发到消息队列,发送成功后把消息标记为已发送(或者删除),失败就在下次扫描时重新转发; @@ -300,7 +318,9 @@ Saga 没有预留资源的 Try 阶段,中间状态对其它事务可见,因

-由于重新转发会导致消息被重复投递,积分服务要按订单号去重,实现幂等。订单服务的事务已经提交,无法回滚,积分服务执行失败时只能不断重试或者人工介入处理。所以它适合发券、加积分这类能接受最终一致、失败后可以重试的后续操作。 +实践中常在事务提交后立即异步发送一次,定时任务只转发超过一定时间仍未发送的消息,作为兜底;已发送的消息定期删除或归档。 + +由于重新转发会导致消息被重复投递,积分服务要按订单号加事件类型去重,实现幂等,做法见 [接收端的可靠性](消息队列.md#接收端的可靠性)。订单服务的事务已经提交,无法回滚,积分服务执行失败时只能不断重试或者人工介入处理。所以它适合发券、加积分这类能接受最终一致、失败后可以重试的后续操作。 RocketMQ 的事务消息把本地消息表的工作交给了消息队列:生产者先发送一条对消费者不可见的半消息,再执行本地事务,然后根据执行结果通知 Broker 提交或者回滚这条消息。如果 Broker 长时间没有收到通知,会主动回查生产者的本地事务状态。 @@ -352,14 +372,14 @@ CAP 中的一致性指的是线性一致性(Linearizability),也称为强 例如两个节点之间发生分区后,一个节点完成了写入,另一个节点收到读请求时,要么返回旧数据(放弃一致性),要么拒绝响应(放弃可用性),不存在第三种选择。 -- 为了保证一致性(CP),无法和其它节点同步的节点需要拒绝请求,也就失去了部分可用性,例如 Zookeeper、etcd; +- 为了保证一致性(CP),无法和其它节点同步的节点需要拒绝请求,也就失去了部分可用性,例如 ZooKeeper、etcd; - 为了保证可用性(AP),所有节点都继续提供服务,但是读取到的数据可能不一致,例如 Eureka、按默认一致性级别使用的 Cassandra。 -需要注意的是,ZooKeeper 只保证写入是线性一致的,读请求由客户端连接的节点直接返回,可能读到旧值,需要读到最新值时要先调用 sync();etcd 从 v3 起默认提供线性一致的读。 +需要注意的是,ZooKeeper 只保证写入是线性一致的,读请求由客户端连接的节点直接返回,可能读到旧值,需要读到最新值时通常先调用 sync() 再读,但 sync() 不经过多数节点确认,严格来说也不能保证读到最新值;etcd 从 v3 起默认提供线性一致的读。 -Nacos 则两种都有:临时实例使用自研的 Distro 协议,优先保证可用性;持久实例使用基于 Raft 的 JRaft,优先保证一致性。 +Nacos 则两种都有:临时实例使用自研的 Distro 协议,优先保证可用性;持久化实例使用基于 Raft 的 JRaft,优先保证一致性。 -CAP 只讨论了线性一致性这一种一致性模型和网络分区这一种故障,没有涉及网络延迟、节点宕机等更常见的问题。很多系统既不提供线性一致性,也不满足 CAP 中严格定义的可用性,因此既不是 CP 也不是 AP。与其给系统贴上 CP 或 AP 的标签,不如直接说明它在分区时的行为,例如“少数派一侧拒绝写入,读请求可能返回旧值”。 +CAP 只讨论了线性一致性这一种一致性模型和网络分区这一种故障,没有涉及网络延迟、节点宕机等更常见的问题。很多系统既不提供线性一致性,也不满足 CAP 中严格定义的可用性,因此既不是 CP 也不是 AP。例如 [Redis Cluster](Redis.md#redis-cluster) 的主从之间异步复制,故障转移时可能丢失已经确认的写入,不是线性一致的;分区超过 cluster-node-timeout 后,少数派一侧的主节点拒绝写入,也不满足 CAP 的可用性。与其给系统贴上 CP 或 AP 的标签,不如直接说明它在分区时的行为。 PACELC 是对 CAP 的扩展:发生分区(P)时在可用性(A)和一致性(C)之间权衡,没有分区(E)时则在延迟(L)和一致性(C)之间权衡,因为保证强一致性需要等待节点之间同步数据,会增加响应延迟。 @@ -464,8 +484,6 @@ Paxos 并不能严格保证可终止性:如果两个 Proposer 交替地使用 Raft 也是分布式共识算法,相比于 Paxos 更容易理解和实现。它将共识问题分解为 Leader 选举、日志复制和安全性三个子问题,etcd、Consul 等系统都使用了 Raft。 -- [Raft: Understandable Distributed Consensus](http://thesecretlivesofdata.com/raft) - ### 单个 Candidate 的竞选 有三种节点:Follower、Candidate 和 Leader。Raft 把时间划分为多个任期(Term),任期号单调递增,每个任期从一次选举开始,并且每个任期内最多只有一个 Leader。任期号的作用和 Paxos 中的提议序号相同,发生冲突时任期号大的一方说了算。 @@ -474,7 +492,7 @@ Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了 - 系统刚启动时只有 Follower 没有 Leader。Node A 的随机竞选超时最短,到期时没收到 Leader 发来的心跳包,因此进入竞选阶段,发送投票请求给其它所有节点。 -- 每个节点在一个任期内最多只能投一票,先到先得,并且只会投给日志至少和自己一样新的 Candidate,这保证了新的 Leader 一定包含所有已提交的日志。如果 Candidate 获得超过一半节点的选票,就会变成 Leader。 +- 每个节点在一个任期内最多只能投一票,先到先得,并且只会投给日志至少和自己一样新的 Candidate(比较双方最后一条日志:任期大的更新,任期相同时日志更长的更新),这保证了新的 Leader 一定包含所有已提交的日志。如果 Candidate 获得超过一半节点的选票,就会变成 Leader。 - 之后 Leader 会周期性地发送心跳包给 Follower,Follower 接收到心跳包,会重新开始计时。 @@ -505,12 +523,12 @@ Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了 读请求则要另外处理:旧 Leader 如果直接用本地数据回答读请求,可能返回旧数据,所以 Raft 要求 Leader 在处理只读请求前,先与大多数节点交换一次心跳,确认自己仍是 Leader。另外,新 Leader 刚上任时还不知道哪些日志已经提交,要先提交一条当前任期的空日志,之后才能处理只读请求。 -需要注意的是,Leader 只对当前任期的日志按“大多数节点已写入”判断是否提交。之前任期留下的日志即使已经复制到大多数节点,也可能被之后的 Leader 覆盖,要等当前任期的一条日志提交后才随之提交(论文第 5.4.2 节和图 8)。这也是上文新 Leader 要先提交一条空日志的原因。 +需要注意的是,Leader 只对当前任期的日志按“大多数节点已写入”判断是否提交。之前任期留下的日志即使已经复制到大多数节点,也可能被之后的 Leader 覆盖,要等当前任期的一条日志提交后才随之提交。这也是上文新 Leader 要先提交一条空日志的原因。 Raft 与 Paxos 达成的结果是等价的,区别在于对 Leader 的依赖程度: - Paxos 中任何一个 Proposer 都可以发起提议,选出一个主 Proposer 只是性能上的优化(也用来避免上文的活锁)。Multi-Paxos 中日志的每个位置各自独立地确定,新的主节点还要先找回自己缺少的日志; -- Raft 中选主是协议的一部分,只有 Leader 能追加日志,日志只从 Leader 流向 Follower,投票规则又保证只有日志最新的节点能当选,新 Leader 不需要从其它节点补日志,因此更容易理解和实现。代价是所有写入都要经过 Leader,选举期间不能写入。 +- Raft 中选主是协议的一部分,只有 Leader 能追加日志,日志只从 Leader 流向 Follower,投票规则又保证当选的节点包含所有已提交的日志,新 Leader 不需要从其它节点补日志,因此更容易理解和实现。代价是所有写入都要经过 Leader,选举期间不能写入。 ZooKeeper 使用的 ZAB 协议同样以 Leader 为中心,纪元编号(epoch)的作用相当于 Raft 的任期号。 @@ -585,24 +603,26 @@ inventoryService.deduct(10086, 2); // 实际执行的是服务方的 Inventory 服务的实例会扩容、缩容或者宕机,地址不能写死在调用方。注册中心(例如 Nacos、ZooKeeper)保存每个服务当前可用的实例: -- 服务方启动时把自己的地址注册到注册中心,之后定期发送心跳,长时间没有心跳的实例会被删除; -- 调用方订阅需要的服务,把地址列表缓存在本地,实例变化时由注册中心推送通知; -- 每次调用都从本地缓存的列表中选择实例,注册中心不转发请求。 +- 服务方启动时把自己的地址注册到注册中心,之后靠定期的心跳(Eureka)或者长连接(Nacos 2.x 的临时实例)保持在线,心跳超时或者连接断开的实例会被删除(Eureka 进入自我保护时暂停删除); +- 调用方订阅需要的服务,把地址列表缓存在本地,实例变化时由注册中心推送通知,或者由调用方定期拉取(Eureka 每 30 秒一次); +- 每次调用都从本地缓存的列表中选择实例,注册中心不转发请求。Dubbo 默认按权重随机选择:例如两个实例的权重为 3 和 1,在 [0, 4) 中取一个随机数,小于 3 选第一个,否则选第二个;其它算法见 [负载均衡算法](集群.md#负载均衡算法)。 -因此注册中心全部宕机时,调用方仍然可以按缓存的地址继续调用,只是新上线的实例无法被发现。某个实例宕机后、被注册中心删除之前,发往它的请求会失败,需要调用方重试其它实例。 +因此注册中心全部宕机时,调用方仍然可以按缓存的地址继续调用,只是新上线的实例无法被发现,新启动且本地没有缓存地址的调用方也无法调用。某个实例宕机后、调用方本地的列表更新之前,发往它的请求会失败,需要调用方重试其它实例。 -Nacos、Eureka、ZooKeeper 作为注册中心的区别,以及注册中心与客户端负载均衡在 Spring Cloud 中如何配合,见 [微服务](微服务.md#三注册中心)。 +用 ZooKeeper 作注册中心时,每个实例在 /services/inventory-service 下创建一个保存地址的 [临时节点](#2-节点类型),会话超时后节点自动删除,调用方 [监听](#3-监听器) 这个目录的子节点变化;etcd 的做法类似,用绑定租约(lease)的键代替临时节点。 + +Nacos、Eureka、ZooKeeper 的区别,与客户端负载均衡的配合,以及 [实例下线后多久能感知](微服务.md#实例下线后多久能感知)、[注册中心宕机](微服务.md#注册中心宕机) 时的表现,见 [微服务](微服务.md#三注册中心)。 ### RPC 与 HTTP -HTTP 是一种协议,RPC 是一种调用方式,两者不是同一层面的概念:RPC 框架可以在 TCP 上使用自定义的协议,例如 Dubbo 协议;也可以基于 HTTP,例如 gRPC 基于 HTTP/2,Spring Cloud 的 OpenFeign 用动态代理把接口调用转换成 HTTP 请求。 +HTTP 是一种协议,RPC 是一种调用方式,两者不是同一层面的概念:RPC 框架和它使用的协议都属于应用层,可以在 TCP 上使用自定义的协议,例如 Dubbo 协议;也可以基于 HTTP,例如 gRPC 基于 HTTP/2,Spring Cloud 的 OpenFeign 用动态代理把接口调用转换成 HTTP 请求。 面试中通常问的是:为什么对外接口常用 HTTP + JSON,内部服务之间却常用 RPC 框架。这是因为内部调用量大、对延迟敏感,RPC 框架在这些方面做得更好: - 序列化更紧凑:例如参数 {"skuId":10086,"count":2},JSON 需要 25 字节;Protobuf 不传字段名,只传字段编号和值,编码为 08 E6 4E 10 02,共 5 字节,其中 08 表示字段 1,E6 4E 是 10086 的变长编码,10 02 表示字段 2 的值为 2; -- 连接复用:在少量长连接上同时发送多个请求,用请求 ID 区分响应,省去频繁建立连接的开销; -- 服务治理:内置服务发现、负载均衡、超时重试和熔断; -- 接口约定:用 Java 接口或者 IDL 文件定义服务,自动生成调用方的代码,参数类型在编译期就能检查。 +- 连接复用:在少量长连接上同时发送多个请求,用请求 ID 区分响应。HTTP/1.1 也能复用连接,但同一个连接上的响应必须按请求的顺序返回,前面一个慢请求会挡住后面的请求; +- 服务治理:内置服务发现、负载均衡、超时重试和熔断,使用 HTTP 时则要由 Spring Cloud 这类框架另外提供; +- 接口约定:用 Java 接口或者 IDL 文件定义服务,自动生成调用方的代码,参数类型在编译期就能检查。跨语言时双方共用一份 IDL,例如 Go 程序用同一个 .proto 文件生成客户端,就能调用 Java 实现的 gRPC 服务。 HTTP + JSON 的优势在于通用:浏览器和各种语言都能直接调用,报文可读,便于调试,适合对外开放的接口。 @@ -612,12 +632,14 @@ gRPC 选择 HTTP/2 是因为它的 [多路复用](HTTP.md#二进制分帧层): - 每次调用都要设置超时时间,否则下游变慢时,调用方的线程会一直等待,最终被耗尽; - 调用方超时不代表服务方没有执行:请求可能在网络中延迟,或者在服务方的线程池中排队、遇到 GC 停顿,这些时间都计入了调用方的耗时,却不在服务方记录的处理时间里,于是调用方超时了,服务方却执行成功了; -- 因此只有幂等的接口才能自动重试,例如查询;扣库存这类写操作要带上请求 ID,由服务方去重,否则重试会扣减两次; -- 调用链较长时,下游的超时时间应该比上游短。gRPC 会把截止时间(deadline)随请求传给服务方,超时后调用方收到 DEADLINE_EXCEEDED,服务方也可以检查调用是否已经超时,从而停止处理。 +- 因此只有幂等的接口才能自动重试,例如查询;扣库存这类写操作要带上请求 ID,由服务方去重(见 [幂等](#九幂等)),否则重试会扣减两次; +- 调用链较长时,下游的超时时间应该比上游短。gRPC 会把截止时间(deadline)随请求传给服务方,超时后调用方收到 DEADLINE_EXCEEDED,服务方的框架也会取消这次调用,但正在执行的业务代码不会被中断,耗时的处理要自己检查是否已被取消;服务方调用下游时沿用这次请求的上下文,Java 和 Go 的 gRPC 会自动把剩余的时间传下去。 + +

## 八、分布式 ID 🔥 -单库时用数据库的自增主键就能生成 ID,分库分表之后,各个库的自增序列会重复,需要一个全局唯一的 ID。一般有以下要求: +单库时用数据库的自增主键就能生成 ID,分库分表之后,各个库默认的自增序列会重复,需要一个全局唯一的 ID。一般有以下要求: - 全局唯一; - 大体按时间递增,作为 InnoDB 的主键时能顺序插入; @@ -628,16 +650,19 @@ gRPC 选择 HTTP/2 是因为它的 [多路复用](HTTP.md#二进制分帧层): | 方案 | 做法 | 缺点 | | --- | --- | --- | -| UUID | 本地生成 128 位随机数 | 无序,作主键时随机插入、页分裂多;36 个字符太长 | -| 数据库号段 | 用一张表记录当前最大 ID,每次取一段(例如 1000 个)放在内存中慢慢用 | 依赖数据库,服务重启会浪费没用完的号段 | -| Redis INCR | 用 INCR 得到递增的整数 | 依赖 Redis 的可用性和持久化,宕机后可能重复 | +| 随机 UUID(v4) | 本地生成 128 位随机数,例如 Java 的 UUID.randomUUID() | 无序,作主键时随机插入、页分裂多;36 个字符太长 | +| 数据库步长 | 每个库设置相同的步长和不同的起始值,例如两个库分别生成奇数和偶数 | 扩容时要重新规划步长,ID 只在单库内递增 | +| 数据库号段 | 用一张表记录当前最大 ID,每次用 UPDATE 把它加 1000,取走这一段放在内存中慢慢用,美团 Leaf 在当前号段用掉 10% 时就异步取下一段 | 依赖数据库,服务重启会浪费没用完的号段 | +| Redis INCR | 用 INCR 得到递增的整数 | 依赖 Redis 的可用性和[持久化](Redis.md#八持久化),宕机后可能重复 | | 雪花算法 | 本地用时间戳、机器 ID 和序号拼成 64 位整数 | 依赖时钟,需要分配机器 ID | -例如分别以自增整数和 UUID() 为主键插入 100 万行,自增主键用时 3.4 秒、数据 133 MB,UUID 主键用时 5.1 秒、数据 164 MB(MySQL 8.0 实测,见[附录 A1](#a1-自增主键与-uuid-主键))。这是因为 UUID 的值是无序的,每次都插入到 B+ 树中间的随机位置,频繁的页分裂让页的填充率变低,二级索引中保存的主键也更长。 +例如分别以自增整数和随机 UUID 为主键插入 100 万行,自增主键用时 3.2 秒、数据 133 MB,随机 UUID 主键用时 10.1 秒、数据 264 MB(MySQL 8.0 实测,见[附录 A1](#a1-自增主键与-uuid-主键))。这是因为随机的值每次都插入到 B+ 树中间的随机位置,频繁的页分裂让页的填充率变低。另外,二级索引中保存的是主键,主键越长,二级索引也越大。 + +需要注意的是,MySQL 的 UUID() 生成的是基于时间的 v1,时间戳的低位在最前面,短时间内连续生成的值几乎是递增的,接近顺序插入:同样插入 100 万行用时 5.1 秒、数据 164 MB,多出的部分主要来自 36 个字符的长度。 ### 雪花算法 -雪花算法(Snowflake)把 64 位整数分成四部分: +雪花算法(Snowflake)把 64 位整数分成四部分,最高位固定为 0,使 ID 总是正数:

@@ -664,28 +689,43 @@ public synchronized long nextId() { 需要注意的是,同一个实例生成的 ID 是递增的,但不同实例的时钟之间存在偏差,两个实例生成的 ID 只能大致反映生成的先后顺序。 -实际使用时还要解决两个问题: +雪花 ID 不重复依赖三个条件:各实例的机器 ID 不同,同一毫秒内的序号不同,时钟不倒退。第二个由加锁的 nextId() 保证;机器 ID 可以在启动时从 ZooKeeper、数据库或配置中心领取。 -- 机器 ID:每个实例的机器 ID 不能相同,可以在启动时从 ZooKeeper、数据库或配置中心领取; -- 时钟回拨:服务器校时可能让时钟倒退,回拨较小时可以等待时钟追上,较大时报错,或者像美团的 Leaf 那样在启动时检查本机时间与其它节点是否一致。 +时钟回拨指的是校时等原因让系统时间倒退。回拨后已经用过的毫秒会再出现一次,序号又从 0 开始,生成的 ID 与之前的相同(模拟见[附录 A2](#a2-雪花算法))。常见的处理: + +- 回拨几毫秒时等时钟追上,例如美团的 Leaf 在 5 ms 以内时等待两倍的差值后重试; +- 回拨较大时报错,摘除这个实例并报警; +- lastTime 只保存在内存中,重启后发现不了回拨,要把时间持久化,例如 Leaf 每 3 秒把本机时间写入 ZooKeeper,启动时本机时间比记录的还早就不启动;上报有间隔,最后一次上报之后用过的时间仍可能被重复使用。 + +雪花 ID 还有两个容易忽略的问题: + +- 它远大于 JavaScript 的安全整数上限 253 − 1,前端把它当作数字解析 JSON 时会丢失精度,例如 372990889737342977 解析后变成 372990889737343000,接口中要把 ID 作为字符串返回; +- 并发低时每毫秒只生成一个 ID,序号总是 0,按 ID 对 16 取模分表会全部落到第 0 张表;Leaf 把每个新毫秒的序号初值设为 0~99 的随机数,同样的测试中 16 张表各分到 45~76 个(JDK 21 实测,见[附录 A2](#a2-雪花算法))。 ## 九、幂等 🔥🔥 -幂等指的是同一个操作执行一次和执行多次的效果相同。分布式系统中重复的请求很常见:调用超时后重试、用户重复点击、消息队列重复投递,都会让同一个操作执行多次。查询和删除天然幂等,新增和扣减不是,例如支付回调被调用两次,订单就可能被处理两次。HTTP 规范中各个方法是否幂等,见 [HTTP 的幂等性](HTTP.md#幂等性)。 +幂等指的是同一个操作执行一次和执行多次的效果相同。分布式系统中重复的请求很常见:调用超时后重试、用户重复点击、[消息队列重复投递](消息队列.md#接收端的可靠性),都会让同一个操作执行多次。查询和删除天然幂等,新增和扣减不是,例如支付回调被调用两次,订单就可能被处理两次。HTTP 规范中各个方法是否幂等,见 [HTTP 的幂等性](HTTP.md#幂等性)。 实现方法: -- 唯一索引:为每个请求生成唯一的请求 ID,在去重表的主键或唯一索引上插入,重复的请求插入失败(ERROR 1062 Duplicate entry)就直接返回之前的结果,去重表和业务数据在同一个本地事务中写入; +- 唯一索引:为每个请求生成唯一的请求 ID,在去重表的主键或唯一索引上插入,重复的请求插入失败(ERROR 1062 Duplicate entry)就直接返回之前的结果; - 状态机:更新时带上前置状态,例如 UPDATE orders SET status = 'PAID' WHERE id = 1024 AND status = 'UNPAID',第一次影响 1 行,重复执行影响 0 行; -- 版本号:更新时带上读到的版本号,版本号不匹配就不更新,即乐观锁; +- 版本号:更新时带上读到的版本号,版本号不匹配就不更新,即乐观锁。重试时要沿用第一次读到的版本号,重新查询后再更新就会再执行一次; - Token:提交前先从服务端获取一个 Token 存入 Redis,提交时用 DEL 删除,返回 1 才处理,重复提交时返回 0。 -需要注意的是,“先查询是否处理过、再处理”分成两步时,两个并发的重复请求可能同时查到“未处理”。判断和处理必须是原子的,要么依靠数据库的唯一索引或条件更新,要么把判断和写入放在同一个 Lua 脚本中。 +需要注意的是,“先查询是否处理过、再处理”分成两步时,两个并发的重复请求可能同时查到“未处理”。判断和处理必须是原子的,要么依靠数据库的唯一索引或条件更新,要么把判断和写入放在同一个 [Lua 脚本](Redis.md#lua-脚本)中。 + +去重记录要和业务数据在同一个本地事务中写入,处理失败时一起回滚,重试还能正常执行。如果单独写入去重记录(例如用 Redis 的 SET NX 记下请求 ID),业务失败后要删掉它,否则重试会被挡掉;进程崩溃时来不及删除,所以记录要分两种状态: + +- 处理中:期间到达的重复请求返回“处理中”(例如 HTTP 409),让客户端稍后再查;过期时间要长于处理的最长耗时,崩溃后等它过期就能重试; +- 已完成:保存第一次的结果,保留到上游不再重试之后,例如微信支付的回调失败后会在约 24 小时内反复重发。 + +业务已经提交、记录还没改为“已完成”时崩溃,重试仍会再执行一次,所以业务写入本身仍要靠唯一约束或状态条件兜底,调用外部服务时让下游按同一个业务键去重。 实现幂等首先要定义什么算同一个请求,也就是幂等键怎么来。以用户狂点“提交订单”为例: -- 客户端在打开确认页时生成一个 request_id,之后的每次点击和重试都带着它,下单成功或者用户修改了订单内容之后才换一个新的。如果每次点击都生成一个新的 ID,服务端就无法识别重复; -- 服务端在订单表的 request_id 上建唯一索引,插入订单时重复的请求报错 1062,返回第一次的结果。同一个 request_id 并发提交 10 次,先查后插的写法 5 轮中每轮都创建了 2~3 个订单,唯一索引每轮只创建 1 个(MySQL 8.0 实测,见[附录 A4](#a4-重复提交与唯一索引)); +- 客户端在打开确认页时生成一个 request_id,用 UUID 这类随机串,各个客户端各自生成也不会重复。之后的每次点击和重试都带着它,下单成功或者用户修改了订单内容之后才换一个新的。如果每次点击都生成一个新的 ID,服务端就无法识别重复; +- 服务端在订单表的 (user_id, request_id) 上建唯一索引,插入订单时重复的请求报错 1062,返回第一次的结果。同一个 request_id 并发提交 10 次,先查后插的写法 5 轮中每轮都创建了 2~3 个订单,唯一索引每轮只创建 1 个(MySQL 8.0 实测,见[附录 A4](#a4-重复提交与唯一索引))。索引中不带 user_id 时,另一个用户带着同一个 ID 提交会被当成重复请求,拿到别人的订单; - 前端把按钮置灰、网关限流可以减少重复请求,但请求仍可能来自重试、脚本或者网络重发,只能作为辅助,最终要靠数据库的约束兜底。

@@ -720,6 +760,8 @@ 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 的实例跳过,原理与 [数据库的唯一索引](#数据库的唯一索引) 相同,还顺便留下了执行记录。 抢锁的做法不需要额外部署,一个实例宕机时其它实例照常触发。缺点是每次只有一个实例在工作;拿到锁的实例中途崩溃时,锁还没有过期,其它实例也不会接手;执行记录、失败告警和手动重跑都要自己实现。 @@ -766,7 +808,7 @@ total 取的是触发时的在线实例数,扩容后下一次触发自动分 单机的 @Scheduled 没有这些机制,触发时间只保存在进程的内存中,实例停机期间错过的触发不会补执行。 -重试的前提是任务可以重复执行。账单任务每发送一个用户就记录下来,例如在账单表的 (user_id, month) 上建唯一索引,重试时跳过已经发送过的用户,做法见 [幂等](#九幂等)。实例 A 发送 2000 条后崩溃,按分片参数 0/2 重试,只补发了剩下的 3000 条,1 万个用户每人一条(见[附录 A6](#a6-分片与按分片重试))。 +重试的前提是任务可以重复执行。账单任务每发送一个用户就记录下来,例如在账单表的 (user_id, month) 上建唯一索引,重试时跳过已经发送过的用户,做法见 [幂等](#九幂等)。实例 A 发送 2000 条后崩溃,按分片参数 0/2 重试,只补发了剩下的 3000 条,1 万个用户每人一条(见[附录 A6](#a6-分片与按分片重试))。需要注意的是,写记录和发短信不是一个原子操作,两步之间崩溃会漏发或重发一个用户。要做到不漏不重,记录先写成“待发送”,发送成功后再标记完成,做法与 [本地消息表](#本地消息表) 相同,短信服务再按 (user_id, month) 去重。 处理条件也要写成“所有待处理的数据”,例如关闭所有超时未支付的订单,而不是关闭上一分钟创建的订单,这样某一次执行失败,下一次触发也能补上。 @@ -790,16 +832,26 @@ total 取的是触发时的在线实例数,扩容后下一次触发自动分 ```sql create table t_auto (id bigint primary key auto_increment, v char(100)) engine=innodb; create table t_uuid (id char(36) primary key, v char(100)) engine=innodb; +create table t_rand (id char(36) primary key, v char(100)) engine=innodb; insert into t_auto (v) with recursive n(i) as (select 1 union all select i + 1 from n where i < 1000000) select repeat('x', 100) from n; insert into t_uuid with recursive n(i) as (select 1 union all select i + 1 from n where i < 1000000) select uuid(), repeat('x', 100) from n; +insert into t_rand with recursive n(i) as (select 1 union all select i + 1 from n where i < 1000000) select bin_to_uuid(random_bytes(16)), repeat('x', 100) from n; +select uuid() union all select uuid() union all select uuid(); ``` +t_uuid 用 MySQL 的 UUID(),t_rand 用 16 个随机字节格式化成同样 36 个字符的 UUID。两轮的用时相差不到 5%,下面是第一轮: + ```text -auto_sec 3.411053 -uuid_sec 5.064563 -TABLE_NAME data_mb free_mb -t_auto 133 7 -t_uuid 164 7 +auto_sec 3.189367 +uuid_sec 5.067726 +rand_sec 10.106084 +TABLE_NAME data_mb +t_auto 133 +t_rand 264 +t_uuid 164 +4deea432-d3d0-11f1-a990-57c9e93345c1 +4deea43c-d3d0-11f1-a990-57c9e93345c1 +4deea43d-d3d0-11f1-a990-57c9e93345c1 ``` ### A2 雪花算法 @@ -811,6 +863,26 @@ generated 4000000 unique=4000000 increasingPerThread=true ms=1166 generated 4000000 unique=2404324 increasingPerThread=false ms=712 ``` +时钟回拨:去掉回拨检查,时钟读数为 1000 ms 时生成 3 个 ID,然后拨回 5 ms,每毫秒生成一个,走回 1000 ms 时与第一个 ID 重复;保留检查时直接抛出异常: + +```text +t=1000 id=4194324480 worker=5 seq=0 +t=1000 id=4194324481 worker=5 seq=1 +t=1000 id=4194324482 worker=5 seq=2 +t=995 id=4173352960 worker=5 seq=0 +... +t=999 id=4190130176 worker=5 seq=0 +t=1000 id=4194324480 worker=5 seq=0 DUPLICATE +with check: clock moved backwards +``` + +低并发:每 2 ms 生成一个 ID,共 1000 个,按 ID 对 16 取模统计各表的数量;第二行是每个新毫秒的序号从 0~99 的随机数开始: + +```text +note [1000, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] +leaf-random [76, 65, 72, 65, 49, 58, 58, 56, 62, 64, 63, 74, 57, 73, 63, 45] +``` + ### A3 Redisson 的可重入与看门狗 Redisson 4.8.0,Redis 7.4.1。三个程序分别加锁 lock:order:1024,用 redis-cli 查看哈希的内容和 PTTL: @@ -847,6 +919,13 @@ RLock lock = redisson.getLock("lock:order:1024"); 配图中的曲线是另外运行一次、每 0.5 秒采样一次 PTTL 得到的。 +在 1 主 1 从上 lock(10, TimeUnit.SECONDS) 时,MONITOR 中加锁脚本之后紧跟着 WAIT;把从节点用 SIGSTOP 冻结后再加锁,约 17.6 秒后失败,根异常为 NoSyncedSlavesException: + +```text +"EVALSHA" "149c5a8bf18676fd99e9e6e24923bd64d9b62f94" "1" "lock:order:1024" "10000" "b516124e-8fa9-4da6-8fd7-48ca5058ac86:1" +"WAIT" "1" "1000" +``` + ### A4 重复提交与唯一索引 MySQL 8.0.44,Python 3 + pymysql。两张订单表结构相同,只是 t3_orders_uk 在 request_id 上有唯一索引。每轮用 10 个线程同时提交同一个 request_id,先查后插的写法先 SELECT,没有记录再 INSERT;唯一索引的写法直接 INSERT,捕获 1062 错误: @@ -860,6 +939,13 @@ MySQL 8.0.44,Python 3 + pymysql。两张订单表结构相同,只是 t3_orde 唯一索引 第 1~5 轮:同一个 request_id 并发提交 10 次,创建订单 1 个,其余 9 次报错 1062 ``` +把唯一索引改为 (user_id, request_id) 后,用户 1001 并发提交 10 次的结果相同。再让用户 1002 带着用户 1001 用过的 request_id 下单,重复时按幂等键查出第一次的订单返回: + +```text +唯一索引 (request_id): 1002 的插入报错 1062,查出的是 1001 的订单 (1, 1001, 10086) +唯一索引 (user_id, request_id):1002 的订单正常创建 +``` + ### 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 插入一条短信记录: @@ -943,6 +1029,14 @@ NOWAIT:0.1 ms 返回 (3572, 'Statement aborted because lock(s) could not be ac 第二个客户端等待 1.01 s 后拿到锁: (('order:1024', None),) ``` +再模拟持有锁的机器宕机:持有锁的客户端经一个 TCP 转发进程连接 MySQL,加锁后把两个进程一起 SIGSTOP,服务端收不到断开连接的通知,只看到一个空闲连接(第二组中等待方的 innodb_lock_wait_timeout 设为 10 秒): + +```text +@@global.wait_timeout = 28800 +持有锁的会话 wait_timeout = 5:等待方 3.59 s 后拿到锁 +持有锁的会话 wait_timeout = 28800:等待方 10.12 s 后返回 (1205, 'Lock wait timeout exceeded; try restarting transaction') +``` + ## 参考资料 - [Apache Dubbo: Architecture](https://dubbo.apache.org/docs/v2.7/user/preface/architecture/) @@ -959,6 +1053,9 @@ NOWAIT:0.1 ms 返回 (3572, 'Statement aborted because lock(s) could not be ac - [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) +- [Redis: WAIT](https://redis.io/docs/latest/commands/wait/) +- [MySQL 8.0: Server System Variables(wait_timeout)](https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_wait_timeout) +- [MySQL 8.0: Replication Source Options(auto_increment_increment)](https://dev.mysql.com/doc/refman/8.0/en/replication-options-source.html#sysvar_auto_increment_increment) - [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/) @@ -980,7 +1077,10 @@ NOWAIT:0.1 ms 返回 (3572, 'Statement aborted because lock(s) could not be ac - [Paxos Made Simple](https://lamport.azurewebsites.net/pubs/paxos-simple.pdf) - [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) +- [Raft: Understandable Distributed Consensus](http://thesecretlivesofdata.com/raft) - [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) +- [The Idempotency-Key HTTP Header Field](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) +- [微信支付:支付成功回调通知](https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_1_5.shtml)