diff --git a/notes/分布式.md b/notes/分布式.md index 900bc183..65746f23 100644 --- a/notes/分布式.md +++ b/notes/分布式.md @@ -103,7 +103,7 @@ SET lock_key unique_value NX PX 30000 还需要注意以下几点: -- 释放锁:value 设置为客户端的唯一标识,释放锁时先判断 value 是否为自己设置的再删除,避免误删其它客户端的锁。判断和删除要在一个 [Lua 脚本](Redis.md#lua-脚本)中完成:分成 GET 和 DEL 两条命令时,GET 确认是自己的锁之后、DEL 执行之前,锁可能恰好过期并被其它客户端拿到,DEL 删掉的就是别人的锁; +- 释放锁:value 设置为客户端的唯一标识,释放锁时先判断 value 是否为自己设置的再删除,避免误删其它客户端的锁。判断和删除要原子地完成,通常放在一个 [Lua 脚本](Redis.md#lua-脚本)中(Redis 8.4 起也可以用下文的 DELEX):分成 GET 和 DEL 两条命令时,GET 确认是自己的锁之后、DEL 执行之前,锁可能恰好过期并被其它客户端拿到,DEL 删掉的就是别人的锁; - 锁续期:如果业务执行时间超过了过期时间,锁会被提前释放;过期时间太长,持有者崩溃后其它客户端又要等很久。过期时间通常取业务正常耗时的几倍,耗时难以估计时使用后台线程定时延长过期时间,例如下文 Redisson 的看门狗; - 主从切换:Redis 的主从复制是异步的,如果主节点加锁后还未同步到从节点就宕机,从节点经[故障转移](Redis.md#十二sentinel)晋升为主节点之后锁就丢失了。 @@ -129,7 +129,7 @@ Redis 8.4 新增了 `DELEX lock_key IFEQ unique_value` 命令,可以直接原

-看门狗运行在持有锁的进程中,只在这个进程存活时续期。进程崩溃后没有人续期,锁最多 30 秒后自动过期,其它客户端就能拿到锁。反过来,如果持有锁的线程卡住(例如等待一个没有设置超时的远程调用)而进程还活着,看门狗会一直续期,其它客户端一直拿不到锁,所以锁内的操作都要设置超时。 +看门狗运行在持有锁的进程中,只在这个进程存活时续期。进程崩溃后没有人续期,锁最多 30 秒后自动过期,其它客户端就能拿到锁。进程长时间暂停(例如 Full GC)时看门狗也停了,从最后一次续期算起超过 30 秒锁就会过期,这种情况怎样兜底见[栅栏令牌](#redis-的-redlock-算法)。反过来,如果持有锁的线程卡住(例如等待一个没有设置超时的远程调用)而进程还活着,看门狗会一直续期,其它客户端一直拿不到锁,所以锁内的操作都要设置超时。 续期也可能失败,例如持有者与 Redis 之间的网络中断超过 30 秒:看门狗只记录日志并重试,不通知业务线程,锁过期后被其它客户端拿到,原持有者却还在执行,只能靠栅栏令牌或数据库约束兜底。之后原持有者解锁时,脚本发现字段“客户端 UUID:线程 ID”不存在,不会删掉别人的锁,unlock() 抛出 IllegalMonitorStateException。 @@ -145,6 +145,8 @@ RedLock 使用多个互相独立的 Redis 实例(通常是 5 个)实现分 - 计算获取锁消耗的时间,只有时间小于锁的过期时间,并且从大多数(N / 2 + 1)实例上获取了锁,才认为获取锁成功; - 如果获取锁失败,就到每个实例上释放锁。 +实例崩溃重启也会破坏多数派:例如 5 个实例中客户端 A 在 1、2、3 上加锁,3 号断电重启后丢了这个键,客户端 B 就能在 3、4、5 上再拿到多数。官方文档给出的办法是开启 AOF 并设置 appendfsync always,让每次写入都刷盘,或者让崩溃的实例延迟重启,不可用的时间超过最长的锁过期时间后再加入。 + RedLock 不要求各节点的时钟同步,但假设各节点的时钟走速大致相同,时钟跳变(例如被 NTP 大幅校正)会让锁提前过期,这是它存在争议的原因之一。 更根本的问题在于,所有带过期时间的锁都可能同时被两个客户端持有:持有锁的客户端发生长时间的进程暂停(例如 GC),或者它发出的请求在网络中延迟了很久,锁过期后另一个客户端已经拿到了锁,前一个客户端的写入却仍然会到达资源。 @@ -267,7 +269,7 @@ ZooKeeper 提供了一种树形结构的命名空间,/app1/p_1 节点的父节 ##### 2.2 单点问题 -协调者在 2PC 中起到非常大的作用,发生故障将会造成很大影响。特别是在提交阶段发生故障,已经回复同意的参与者既不能提交也不能回滚(称为存疑事务),只能一直阻塞等待协调者恢复。 +协调者在 2PC 中起到非常大的作用,发生故障将会造成很大影响。特别是在提交阶段发生故障,已经回复同意的参与者既不能提交也不能回滚(称为存疑事务),只能一直阻塞等待协调者恢复。以 MySQL 为例,执行过 XA PREPARE 的分支在连接断开后不会回滚(5.7.7 起),它更新过的行仍然被锁住,其它事务更新这些行会被阻塞,分支一直不结束时等到锁等待超时报错;用 XA RECOVER 可以查到这个分支(MySQL 8.0 实测)。之后要等协调者按事务 ID 提交或回滚,协调者的记录丢失时,可能需要人工核对各参与者的状态再处理。 ##### 2.3 数据不一致 @@ -291,7 +293,7 @@ TCC(Try-Confirm-Cancel)是在业务层面实现的两阶段提交,每个 如果所有参与者的 Try 都执行成功,就执行 Confirm,否则执行 Cancel。 -Try 成功后资源已经预留,Confirm 失败只会是超时、宕机这类临时故障;其它参与者可能已经 Confirm,所以不能改为 Cancel,只能不断重试,多次失败后告警人工处理。Cancel 失败同样重试,因此两者都必须实现 [幂等](#九幂等)。 +Try 成功后资源已经预留,Confirm 失败只会是超时、宕机这类临时故障;其它参与者可能已经 Confirm,所以不能改为 Cancel,只能不断重试,多次失败后告警人工处理。例如 Seata 默认无限重试二阶段提交,可以用 server.maxCommitRetryTimeout 设置上限,超过后全局事务进入 CommitRetryTimeout 状态,等待人工处理(Seata 2.5 文档与源码)。Cancel 失败同样重试,因此两者都必须实现 [幂等](#九幂等)。 相比于 2PC,TCC 不需要长时间持有数据库的锁,性能更好,但是对业务的侵入性较强。它适合扣库存、扣余额这类一致性要求高、能提供预留和取消接口的资源操作。 @@ -324,13 +326,13 @@ Saga 没有预留资源的 Try 阶段,中间状态对其它事务可见,因

-实践中常在事务提交后立即异步发送一次,定时任务只转发超过一定时间仍未发送的消息,作为兜底;已发送的消息定期删除或归档。消息表很大、扫描开销高时,也可以用 Canal 等工具读取 binlog,消息一写入就转发,但同样可能重复发送。 +实践中常在事务提交后立即异步发送一次,定时任务只转发超过一定时间仍未发送的消息,作为兜底。Spring 中提交后的发送通常写在 TransactionSynchronization 的 afterCommit() 或 @TransactionalEventListener(默认在提交后执行)中,这些回调只登记在内存中,进程在提交后崩溃或发送失败时不会重发,所以定时任务的兜底不能省;已发送的消息定期删除或归档。消息表很大、扫描开销高时,也可以用 Canal 等工具读取 binlog,消息一写入就转发,但同样可能重复发送。 由于重新转发会导致消息被重复投递,积分服务要按订单号加事件类型去重,实现幂等,做法见 [接收端的可靠性](消息队列.md#接收端的可靠性)。订单服务的事务已经提交,无法回滚,积分服务执行失败时只能不断重试或者人工介入处理。所以它适合发券、加积分这类能接受最终一致、失败后可以重试的后续操作。 RocketMQ 的事务消息把本地消息表的工作交给了消息队列:生产者先发送一条对消费者不可见的半消息,再执行本地事务,然后根据执行结果通知 Broker 提交或者回滚这条消息。如果 Broker 长时间没有收到通知,会主动回查生产者的本地事务状态。 -先发半消息,是为了先确认 Broker 可用,并让 Broker 一直持有这条消息:之后生产者无论在哪一步崩溃,Broker 都能通过回查得到结果。回查返回未知时会继续回查,默认最多 15 次,超过后半消息被转入系统主题 TRANS_CHECK_MAX_TIME_TOPIC,不再投递,需要人工处理。 +先发半消息,是为了先确认 Broker 可用,并让 Broker 一直持有这条消息:之后生产者无论在哪一步崩溃,Broker 都能通过回查得到结果,前提是生产者组还有实例在线。回查返回未知、或者回查请求没有送达生产者时都会继续回查,次数照样累加,默认最多 15 次(开源 5.5.1 默认每 30 秒一次,发送半消息后生产者组立即全部下线时,约 7~8 分钟就会用完),超过后半消息被转入系统主题 TRANS_CHECK_MAX_TIME_TOPIC,不再投递,需要人工处理。

@@ -538,7 +540,11 @@ Raft 与 Paxos 达成的结果是等价的,区别在于对 Leader 的依赖程 - Paxos 中任何一个 Proposer 都可以发起提议,选出一个主 Proposer 只是性能上的优化(也用来避免上文的活锁)。Multi-Paxos 中日志的每个位置各自独立地确定,新的主节点还要先找回自己缺少的日志; - Raft 中选主是协议的一部分,只有 Leader 能追加日志,日志只从 Leader 流向 Follower,投票规则又保证当选的节点包含所有已提交的日志,新 Leader 不需要从其它节点补日志,因此更容易理解和实现。代价是所有写入都要经过 Leader,选举期间不能写入。 -ZooKeeper 使用的 ZAB 协议同样以 Leader 为中心,纪元编号(epoch)的作用相当于 Raft 的任期号。不同的是恢复方式:ZAB 选出见过最大 zxid 的节点当 Leader,新 Leader 先同步日志,使包括自己在内的大多数节点状态一致,再开始处理新的写请求;Raft 不设单独的同步阶段,靠追加日志时的一致性检查边复制边覆盖。ZAB 还假设节点之间是 TCP 这样的先进先出通道。 +ZooKeeper 使用的 ZAB 协议同样以 Leader 为中心,纪元编号(epoch)的作用相当于 Raft 的任期号,它写在事务 ID(zxid)的高 32 位,低 32 位是这个纪元内的计数。 + +两者的选举和恢复方式不同。选举时节点先投自己,同一轮选举中收到更好的选票就改投,依次比较候选者当前的纪元编号(currentEpoch)、最后一个 zxid 和节点 ID,得到多数节点支持的候选者当选;Raft 的节点在一个任期内最多投一票,投给最先请求并且日志不比自己旧的候选者,投出后不再改投。 + +恢复时,ZAB 的新 Leader 先同步日志,使包括自己在内的大多数节点状态一致,再开始处理新的写请求;Raft 不设单独的同步阶段,靠追加日志时的一致性检查边复制边覆盖。ZAB 还假设节点之间是 TCP 这样的先进先出通道。 ## 七、RPC 🔥 @@ -605,6 +611,10 @@ inventoryService.deduct(10086, 2); // 实际执行的是服务方的 Inventory 这段代码每次调用都新建连接,使用 Java 自带的序列化,也没有超时处理。Dubbo、gRPC 等框架在此基础上实现了连接复用、高效的序列化、服务发现、负载均衡、超时重试和熔断,设计一个 RPC 框架时也是从这几个模块考虑。 +Java 自带的序列化还有安全问题:数据中写着完整的类名,接收方按类名创建对象,并可能调用类自定义的 readObject() 等方法。接收方的类路径中有可利用的类时,伪造的数据就可能触发攻击者构造的调用链,而普通的 ObjectInputStream 默认没有配置反序列化过滤器。 + +Protobuf 的数据中没有类名,解析成哪种消息由接收方调用的生成类决定。Dubbo 3.2 起对 Hessian2、Fastjson2 和泛化调用默认开启严格的类检查,只有允许列表中的类才能被反序列化,允许列表包括框架内置的类、自己配置的类,以及按接口自动扫描得到的类和包。 + 复用连接之后,要在 TCP 的字节流中分出一个个请求。TCP 不保留消息的边界,所以协议都在消息前写明长度。例如 Dubbo 协议的首部固定为 16 字节:2 字节魔数 0xdabb、1 字节标志(请求还是响应、是否需要返回值、序列化方式)、1 字节响应状态、8 字节请求 ID、4 字节正文长度。接收方先读 16 字节的首部,再按长度读出正文,用请求 ID 找到对应的调用。 ### 服务发现 @@ -638,10 +648,10 @@ gRPC 选择 HTTP/2 是因为它的 [多路复用](HTTP.md#二进制分帧层): ### 超时与重试 -- 每次调用都要设置超时时间,否则下游变慢时,调用方的线程会一直等待,最终被耗尽; +- 每次调用都要设置超时时间,否则下游变慢时,调用方的线程会一直等待,最终被耗尽。超时按下游延迟的高分位数设置,例如能接受 0.1% 的请求被误判为超时,就取下游的 P99.9,再加上建立连接和网络的开销,并用压测验证; - 调用方超时不代表服务方没有执行:请求可能在网络中延迟,或者在服务方的线程池中排队、遇到 GC 停顿,这些时间都计入了调用方的耗时,却不在服务方记录的处理时间里,于是调用方超时了,服务方却执行成功了; - 因此只有幂等的接口才能自动重试,例如查询;扣库存这类写操作要带上请求 ID,由服务方去重(见 [幂等](#九幂等)),否则重试会扣减两次。需要注意的是,有的框架默认就会重试,例如 Dubbo 默认的集群容错方式是 failover,失败或超时后换一台服务器重试 2 次,一次调用最多执行 3 次,写操作要改为 failfast 或者带上请求 ID; -- 调用链较长时,下游的超时时间应该比上游短。gRPC 会把截止时间(deadline)随请求传给服务方,超时后调用方收到 DEADLINE_EXCEEDED,服务方的框架也会取消这次调用,但正在执行的业务代码不会被中断,耗时的处理要自己检查是否已被取消;服务方调用下游时沿用这次请求的上下文,Java 和 Go 的 gRPC 会自动把剩余的时间传下去。 +- 调用链较长时,下游的超时时间应该比上游短,上游的超时还要容纳下游的重试,否则上游已经放弃,下游仍在重试。gRPC 会把截止时间(deadline)随请求传给服务方,超时后调用方收到 DEADLINE_EXCEEDED,服务方的框架也会取消这次调用,但正在执行的业务代码不会被中断,耗时的处理要自己检查是否已被取消;服务方调用下游时沿用这次请求的上下文,Java 和 Go 的 gRPC 会自动把剩余的时间传下去。

@@ -668,6 +678,8 @@ gRPC 选择 HTTP/2 是因为它的 [多路复用](HTTP.md#二进制分帧层): 需要注意的是,MySQL 的 UUID() 生成的是基于时间的 v1,时间戳的低位在最前面,短时间内连续生成的值几乎是递增的,接近顺序插入:同样插入 100 万行用时 5.1 秒、数据 164 MB,多出的部分主要来自 36 个字符的长度。 +号段模式要避免取下一段时卡住发号。美团 Leaf 为每个业务准备两个号段缓冲区,当前号段发出 10% 后就异步把下一段取到另一个缓冲区,当前号段用完时直接切换;下一段还没取回时短暂等待,仍然没有就返回错误。号段长度一般按高峰 QPS 的若干分钟设置,美团建议取 600 倍,即 10 分钟的用量,这样数据库宕机时还能继续发号一段时间。 + ### 雪花算法 雪花算法(Snowflake)把 64 位整数分成四部分,最高位固定为 0,使 ID 总是正数: @@ -723,10 +735,15 @@ public synchronized long nextId() { 需要注意的是,“先查询是否处理过、再处理”分成两步时,两个并发的重复请求可能同时查到“未处理”。判断和处理必须是原子的,要么依靠数据库的唯一索引或条件更新,要么把判断和写入放在同一个 [Lua 脚本](Redis.md#lua-脚本)中。 +唯一索引能挡住并发的首次请求:第一个请求的事务还没提交时,同一个键的第二个 INSERT 会等待,第一个提交后它返回 1062,第一个回滚后它插入成功。需要注意的是: + +- 1062 之后要查出第一次的结果时,如果同一个事务在 INSERT 之前已经做过普通的 SELECT,可重复读级别下仍沿用之前的快照,查不到这条记录,要用 SELECT ... FOR SHARE 或者新的事务; +- 两个以上的重复请求在等待同一个键时第一个事务回滚,等待者之间可能死锁,其中一个收到 1213,也要当作可以重试的错误(MySQL 8.0 手册中 InnoDB 加锁的例子)。 + 去重记录要和业务数据在同一个本地事务中写入,处理失败时一起回滚,重试还能正常执行。如果单独写入去重记录(例如用 Redis 的 SET NX 记下请求 ID),业务失败后要删掉它,否则重试会被挡掉;进程崩溃时来不及删除,所以记录要分两种状态: - 处理中:期间到达的重复请求返回“处理中”(例如 HTTP 409),让客户端稍后再查;过期时间要长于处理的最长耗时,崩溃后等它过期就能重试; -- 已完成:保存第一次的结果,保留到上游不再重试之后,例如微信支付的回调失败后会在约 24 小时内反复重发。 +- 已完成:保存第一次的结果,保留到上游不再重试之后,例如微信支付的回调失败后会在约 24 小时内反复重发。记录中还可以保存请求内容的摘要,同一个幂等键带着不同的内容再次提交时直接报错(Idempotency-Key 首部的 IETF 草案建议返回 422),而不是回放第一次的结果。 业务已经提交、记录还没改为“已完成”时崩溃,重试仍会再执行一次,所以业务写入本身仍要靠唯一约束或状态条件兜底,调用外部服务时让下游按同一个业务键去重。 @@ -827,7 +844,7 @@ total 取的是触发时的在线实例数,扩容后下一次触发自动分 - 精度取决于扫描间隔:每分钟扫描一次,订单最晚要到第 31 分钟才关闭; - 扫描间隔越短,查询越频繁,而每次只有少量订单到期。 -这类场景更适合 [延迟消息](消息队列.md#延迟消息):下单时发送一条 30 分钟后投递的消息,到期只处理这一个订单。实际使用时常把两者结合,延迟消息负责按时关闭,低频的定时扫描兜底,处理消息丢失的订单。 +这类场景更适合 [延迟消息](消息队列.md#延迟消息):下单时发送一条 30 分钟后投递的消息,到期只处理这一个订单。实际使用时常把两者结合,延迟消息负责按时关闭,低频的定时扫描兜底,处理消息丢失的订单。超时未支付的订单按 status = 'UNPAID' AND expire_at <= NOW() 扫描,并建 (status, expire_at) 联合索引,每次只读到已经到期的未支付订单,不会扫过大量已完成的历史订单;还要扫描长时间停在关单中的订单,继续关单流程,见[超时关单](系统设计基础.md#超时关单)。 ## 附录:实测 @@ -1057,6 +1074,8 @@ NOWAIT:0.1 ms 返回 (3572, 'Statement aborted because lock(s) could not be ac - [MySQL 8.0: Locking Reads](https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html) - [How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html) - [ZooKeeper Programmer's Guide](https://zookeeper.apache.org/doc/current/zookeeperProgrammers.html) +- [Zab: High-performance broadcast for primary-backup systems](https://marcoserafini.github.io/assets/pdf/zab.pdf) +- [ZooKeeper 源码:FastLeaderElection](https://github.com/apache/zookeeper/blob/release-3.9.3/zookeeper-server/src/main/java/org/apache/zookeeper/server/quorum/FastLeaderElection.java) - [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/) @@ -1091,4 +1110,5 @@ NOWAIT:0.1 ms 返回 (3572, 'Statement aborted because lock(s) could not be ac - [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/) +- [MySQL 8.0 Reference Manual: Locks Set by Different SQL Statements in InnoDB](https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html) - [微信支付:支付成功回调通知](https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_1_5.shtml)