分类: 架构设计

  • Etcd 集群频繁 Leader 切换雪崩:WAL fsync 阻塞引发的 Raft 心跳饿死与选主风暴排查实战

    近期排查了一个非常经典的分布式共识层故障。K8s 集群的 API Server 频繁报 context deadline exceeded,核心控制器全线 CrashLoopBackOff。底层定位到 Etcd 集群处于极度不稳定的状态,Raft Leader 疯狂切换(Flapping)。最终查明,这是一起由于共主节点磁盘 I/O 被同机其他定时任务打满,导致 Etcd WAL (Write-Ahead Log) fsync 严重超时,进而“饿死” Raft 心跳触发的选主风暴惨案。

    在分布式共识(Raft/Paxos)的工程实践中,存储 I/O 抖动是干掉集群可用性的头号杀手。遇到这种问题,调整网络参数是缘木求鱼,必须深入底层的日志复制和状态机流转机制去开刀。

    故障现场:API Server 雪崩与疯狂的 Term 暴增

    排查期间,首先接到 Prometheus 告警,K8s API Server 的 P99 延迟直接从平时的 30ms 飙升到了 8000ms 以上。查看 Etcd 集群状态,发现 etcd_server_leader_changes_seen_total 指标呈阶梯状暴增。

    直接拉取 Etcd 的运行日志,满屏的红色 Error,核心报错就两行:

    # Leader 节点疯狂抱怨心跳发送超时
    {"level":"warn","ts":"...","caller":"etcdserver/server.go:2038","msg":"failed to send out heartbeat on time (exceeded the 100ms timeout for 2.3s)","server_id":"8211f1d0f64f3269"}
    
    # 紧接着 Leader 发现自己任期落后,被迫下台
    {"level":"info","ts":"...","caller":"raft/raft.go:825","msg":"8211f1d0f64f3269 [term: 1205] received a MsgVote with higher term from 7192f1d0f64f11a2 [term: 1206]"}
    {"level":"info","ts":"...","caller":"raft/raft.go:842","msg":"8211f1d0f64f3269 became follower at term 1206"}
    

    从日志可以看出一个典型的 Raft 状态扭转过程:

    1. 当前 Leader 因为某种原因,长达 2.3 秒没有发包。

    2. Follower 节点的 election-timeout(默认 1000ms)耗尽,认为 Leader 已死。

    3. Follower 状态转为 Candidate,将当前任期(Term)+1,并向集群广播 MsgVote

    4. 原 Leader 收到高 Term 的投票请求,瞬间认怂,StepDown 退化为 Follower。

    如此反复,集群陷入了永无止境的选主(Election Storm),导致没有任何一个节点能稳定处理外部 Client 提交的写请求(Propose)。

    原理剖析:为什么磁盘卡顿会饿死网络心跳?

    很多新人会有个疑问:磁盘 I/O 慢,大不了客户端的写请求(Put)慢一点,为什么连 Raft 节点之间的网络心跳都会发不出去?

    这就得扒一下 Etcd 底层 Raft 状态机的工程实现逻辑。在 etcd/raft 模块中,为了保证强一致性,Raft Node 处理状态机输出(Ready 结构体)的典型流程是一个同步的串行大循环:

    // Etcd Raft 核心循环的伪代码逻辑映射
    for {
        select {
        case rd := <-node.Ready():
            // 1. 将 HardState 和 Entries 写入底层 WAL 文件并强制落盘
            saveToStorage(rd.HardState, rd.Entries)
            // 注意这里的 fsync 是阻塞调用!
            wal.Fsync() 
    
            // 2. 将消息(包含 AppendEntries/心跳)发送给其他 Peer
            send(rd.Messages)
    
            // 3. 将已提交的日志应用到内存状态机(KV 存储)
            applyToStore(rd.CommittedEntries)
    
            node.Advance()
        }
    }
    

    发现致命问题了吗?WAL 落盘(wal.Fsync())和发送网络消息(send)是在同一个处理流程中的。 Raft 协议要求:日志必须先持久化到本地(保证 Crash-Safe),然后才能广播给其他节点。 如果底层磁盘 I/O 突然飙升,fsync 系统调用被内核挂起 2 秒,那么紧跟在后面的 send(rd.Messages) 就会被硬生生延迟 2 秒!

    Leader 发不出带着空 Entry 的 AppendEntries RPC(即心跳),Follower 就会准时发起叛变。

    现场缉凶:I/O 被谁吃干抹净了?

    顺着这个逻辑,直接去 Leader 宿主机上查 I/O 现场。 使用 iostat -dx 1 监控,发现系统盘(/dev/vda)的 %util 长期顶死在 100%,await 指标高达 2500ms+。

    进一步通过 iotop -ops 溯源,抓到了真凶: 宿主机上被人偷偷配了一个 Ansible 统一下发的 Cronjob,跑的是一个极度暴力的 tar -czf 日志归档脚本,且没有任何资源限制(cgroups/ionice)。这个任务瞬间榨干了云盘的 IOPS(突发型 EBS 的 Burst Balance 直接被扣光),导致同在一块盘上的 Etcd WAL 写入被内核底层 I/O 调度队列无情阻塞。

    架构避坑与防御性配置

    把这种重型 I/O 任务与对延迟极其敏感的分布式共识组件混跑,在运维界属于经典的低级失误。为了防止这类 I/O 抖动导致系统雪崩,必须做好以下防御性架构调优:

    1. 物理隔离:分离 WAL 目录

    千万不要把 Etcd 的数据和系统的 /var/log 甚至其他业务跑在同一块盘上。 Etcd 启动时强烈建议利用 --wal-dir 参数,将 WAL 单独挂载到一块独立的高性能 SSD / NVMe 盘上。 WAL 是 Append-only 的顺序写,对 IOPS 要求极高且对延迟敏感;而 DB 文件 (--data-dir) 存在随机读写和压缩。分离两者能最大程度保护心跳逻辑。

    2. 调优 Raft 超时参数 (适用于云环境)

    Etcd 默认的 heartbeat-interval=100mselection-timeout=1000ms 是为局域网低延迟裸金属服务器设计的。在存在网络虚拟化和存储网络化(EBS/Ceph)的云环境中,稍微的 I/O 抖动就会打破这个 1 秒的底线。 实战建议: 针对跨可用区(Multi-AZ)或云盘环境,适当放宽超时容忍度。

    # 启动参数调整
    --heartbeat-interval=250
    --election-timeout=2500
    

    注:election-timeout 推荐设置为 heartbeat-interval 的 10 倍,以规避网络偶发丢包。

    3. 确保 Pre-Vote 机制开启

    如果是自行维护的旧版本 Etcd 或其他 Raft 实现,务必确保 Pre-Vote 机制是开启的(Etcd 3.4+ 默认开启)。 当网络发生非对称分区(Asymmetric Partition)或节点局部 I/O 夯死时,节点会被隔离并空转 Term。一旦它恢复并重新接入集群,它的高 Term 会立刻把正常 Leader 打下台。开启 Pre-Vote 后,Candidate 在增加本地 Term 前,必须先发起一轮预投票(PreVote),如果无法获得多数派响应,则不允许增加 Term,从根本上阻断了此类选主风暴。

    排查清单:同类问题速查

    如果你的 K8s/Etcd/Consul 集群出现频繁选主或超时断连,请直接按以下清单排查:

    1. 查磁盘 fsync 延迟:查看 Prometheus 指标 etcd_disk_wal_fsync_duration_seconds,若 P99 超过 election-timeout(默认 1s),必发选主风暴。

    2. 查系统级 I/O 争抢:使用 iostat 检查 IO util 和 await,排查同节点是否有定时快照(Snapshot)、日志备份、Prometheus 压盘等耗 IO 进程。

    3. 查网络 RTT 与丢包率:排查跨 AZ 部署时的网络抖动,指标 etcd_network_peer_round_trip_time_seconds,若网络 RTT 超过心跳间隔(100ms),会导致 Follower 频繁超时。

    4. 查大 Key 写阻塞:排查业务端是否有超大体积的 KV 写入(如巨型 ConfigMap)。Raft 复制大单体 Entry 会占用整个网络与 I/O 周期,变相阻塞后续的心跳包发送。

  • 深入剖析分布式事务的工程取舍:从 2PC 锁争用泥潭到 TCC 防悬挂实战

    核心结论:高并发核心链路严禁直接使用 XA/2PC 协议,其同步阻塞与全局锁定机制必然导致数据库连接池雪崩。Seata AT 模式虽通过一阶段提交缓解了长事务,但在热点行更新时,全依赖 TC 全局锁,极易造成 P99 延迟飙升。落地高并发分布式事务,最稳妥的解法是 TCC 或 Saga,并必须在底层辅以本地事务防悬挂控制表,实现极致的防御性编程。

    排查与重构高并发交易系统时,分布式事务永远是绕不开的雷区。很多人在架构选型时迷信各种中间件包装好的透明事务,却忽视了 CAP 定理下分布式事务的本质:通过牺牲可用性(锁阻塞)或牺牲一致性(最终一致补偿)来换取系统的流转

    本文以 MySQL 8.0.32 和 Seata 1.6.1 为例,撕开分布式事务底层的工程细节,只谈实际落地时的痛点与防御。

    XA/2PC 的原罪:网络 RTT 与底层锁的致命耦合

    传统 XA 规范(2PC)的逻辑看似无懈可击:Prepare 阶段锁定资源,Commit/Rollback 阶段统一决断。但在实际微服务场景下,这是灾难的设计。

    当业务发起一次 XA 事务,MySQL 底层会执行 XA START -> SQL -> XA PREPARE。此时,InnoDB 引擎已经对涉及的数据行加上了排他锁(X Lock),并且这个锁的释放完全依赖于网络另一端 TM(Transaction Manager)的指令。

    你可以通过以下 SQL 在 MySQL 8.0+ 中观察到 XA 事务持有的锁阻塞情况:

    SELECT 
        p.trx_id, 
        p.trx_state, 
        p.trx_started, 
        l.lock_type, 
        l.lock_mode, 
        l.lock_data
    FROM performance_schema.data_locks l
    JOIN information_schema.innodb_trx p ON l.engine_transaction_id = p.trx_id
    WHERE p.trx_state = 'PREPARED';
    

    雪崩路径:

    1. 阶段一完成后,RM(数据库)持有行锁。

    2. TM 在阶段二由于网络抖动、GC 停顿或节点宕机,迟迟不发送 XA COMMIT

    3. 其他并发请求试图访问该行数据,全部堆积在 innodb_lock_waits 中。

    4. 数据库连接池(如 HikariCP)迅速被占满,拖垮整个服务。

    这就是为什么在 C 端高并发核心链路(如库存扣减、资金转账)中,XA 协议属于绝对的禁区。

    为什么 Seata AT 模式在热点数据下会演变成性能灾难?

    为了解决 2PC 的长时间锁资源问题,Seata AT 模式应运而生。它的核心思想是:一阶段直接提交本地事务释放数据库锁,二阶段通过 undo_log 回滚。这听起来很完美,但它真的能抗住高并发吗?

    在某次大促压测中,我们发现扣减热点 SKU 库存时,TPS 始终卡在 300 左右,且 API 的 P99 延迟高达 3000ms+。抓取 Seata TC Server 的日志发现大量获取全局锁超时:

    [timeoutChecker_1] ERROR io.seata.core.lock.LockManager - Global lock wait timeout, xid: 192.168.1.10:8091:859392134, table: inventory, pk: 1001
    

    底层原理解析: Seata AT 为了防止脏写(Dirty Write),在本地事务提交前,必须向 TC(Transaction Coordinator)申请全局锁(Global Lock)。 如果两个并发请求同时修改同一行数据(例如热点 SKU id=1001):

    1. 事务 A 获取本地锁,修改数据。

    2. 事务 A 申请全局锁 inventory:1001,成功。A 提交本地事务,释放本地锁。

    3. 事务 B 获取本地锁,修改数据。

    4. 事务 B 申请全局锁 inventory:1001失败,事务 A 尚未完成二阶段

    5. 事务 B 必须等待,若超时则抛出 LockWaitTimeoutException,随后回滚本地事务。

    结论: Seata AT 只是把数据库的行锁争用,转移到了 Seata TC Server 的全局锁争用上。在热点行更新场景下,网络 RTT 被放大,性能瓶颈依然存在。AT 模式适合低并发的后台管理系统,绝不适合高并发交易链路。

    TCC 架构的防御性编程:空回滚、幂等与防悬挂实战

    既然底层锁不可靠,我们就必须走向应用层补偿事务,即 TCC(Try-Confirm-Cancel)或 Saga。 TCC 的 Try 阶段预留资源,Confirm 提交,Cancel 释放预留。但 TCC 落地的核心难点根本不是业务逻辑,而是分布式网络三大暗礁:网络重试导致的非幂等、空回滚、悬挂(Suspension)

    • 空回滚:Try 请求因网络丢包未到达,TM 直接发起 Cancel。此时 Cancel 必须能够识别并成功返回。

    • 悬挂:Try 请求超时,TM 发起 Cancel 并执行成功。随后那个被网络延迟的 Try 请求终于到达了参与者。如果 Try 成功执行,预留的资源将永远无法被 Confirm 或 Cancel,造成数据死锁。

    最佳实践:基于本地控制表的 TCC 防御机制

    我们必须在业务数据库中建立一张 TCC 事务控制表,利用本地事务的 ACID 特性来抵抗分布式网络的混乱。

    CREATE TABLE `tcc_branch_control` (
        `xid` VARCHAR(128) NOT NULL COMMENT '全局事务ID',
        `branch_id` VARCHAR(128) NOT NULL COMMENT '分支事务ID',
        `status` TINYINT NOT NULL COMMENT '状态: 0-Try, 1-Confirm, 2-Cancel',
        `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
        `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        PRIMARY KEY (`xid`, `branch_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    

    Try 阶段的防御代码逻辑:

    在 Try 方法中,我们将业务 SQL 与插入控制表包裹在同一个本地事务中。

    @Transactional(rollbackFor = Exception.class)
    public boolean tryDeduct(String xid, String branchId, String sku, int count) {
        // 1. 防悬挂与幂等检查:尝试插入 Try 记录
        // 如果插入失败(主键冲突),说明 Try 已执行(需处理幂等),或者 Cancel 已经执行(发生悬挂)
        int insertCount = tccControlMapper.insertIgnore(xid, branchId, 0);
        if (insertCount == 0) {
            TccControl record = tccControlMapper.select(xid, branchId);
            if (record.getStatus() == 2) {
                log.warn("防悬挂拦截: Cancel已执行, 丢弃迟到的Try请求, xid: {}", xid);
                return false; 
            }
            log.info("Try 幂等放行, xid: {}", xid);
            return true;
        }
    
        // 2. 正常执行 Try 业务逻辑 (如: 冻结库存)
        inventoryMapper.freeze(sku, count);
        return true;
    }
    

    Cancel 阶段的防御代码逻辑:

    @Transactional(rollbackFor = Exception.class)
    public boolean cancelDeduct(String xid, String branchId, String sku, int count) {
        // 1. 尝试插入 Cancel 记录 (防御空回滚)
        // 如果之前没有 Try 过,这里会插入成功,状态为 2 (Cancel)。
        // 这同时阻断了后续迟到的 Try (防悬挂)。
        int insertCount = tccControlMapper.insertIgnore(xid, branchId, 2);
        if (insertCount > 0) {
            log.info("空回滚执行: 记录Cancel状态, 拦截后续Try, xid: {}", xid);
            return true;
        }
    
        // 2. 检查当前状态
        TccControl record = tccControlMapper.select(xid, branchId);
        if (record.getStatus() == 2) {
            log.info("Cancel 幂等放行, xid: {}", xid);
            return true;
        }
    
        // 3. 执行资源释放,并更新状态为 Cancel
        inventoryMapper.unfreeze(sku, count);
        tccControlMapper.updateStatus(xid, branchId, 2);
        return true;
    }
    

    通过这一张表和一个 INSERT IGNORE 指令,我们在数据库引擎层面完美防范了所有由于网络乱序引发的事务状态异常。

    Saga 模式的取舍:隔离性的彻底放弃

    当你的分布式事务跨越了第三方系统(如调用外部银行接口),你无法要求第三方提供 Try 接口预留资源,此时 TCC 不适用,只能退化为 Saga 模式。

    Saga 也是两阶段:一阶段直接执行正向业务(如直接入账),二阶段执行补偿业务(如扣减入账)。 它的最大缺陷是缺乏隔离性。在正向业务执行完,补偿业务尚未执行的这段时间窗口内,其他事务可能会读取甚至修改这部分数据(脏读、脏写)。

    Saga 防治脏写的底线: 如果采用 Saga,必须引入乐观锁(版本号机制)或状态机。一旦补偿阶段发现数据的版本号被其他事务推进过,绝对不能强行执行回滚逻辑,必须立即阻断补偿链路,抛出异常,转入人工对账异常队列表。自动化的尽头是人工,这是容灾兜底的最后防线。

    常见问题 (FAQ)

    Q1:在 TCC 模式下,如果 Confirm 或 Cancel 阶段执行失败(比如数据库临时宕机),应该怎么处理? A: TCC 的设计前提是 Confirm 和 Cancel 必须最终成功。如果阶段二失败,TM(Transaction Manager)会不断重试。工程实现上,必须保证阶段二的绝对幂等性。如果重试超过一定阈值(如重试 5 次依然报错),通常意味着出现了底层硬故障(如坏块或长期的依赖宕机)。此时 TM 会记录异常日志,触发告警,转由人工介入。绝对不要在阶段二返回业务层面的错误。

    Q2:Saga 模式执行补偿逻辑时,发现数据已经被用户修改过了(脏写),如何进行补偿? A: 这是 Saga 的经典痛点。在设计 Saga 时,必须对被操作的数据加上状态锁或语义锁。例如订单状态变更为“发货中”,此时如果触发补偿,发现状态已经是“已收货”,就不应该直接执行逆向逻辑。一旦检测到脏写(通过乐观锁版本号或状态机流转规则拦截),系统应该停止自动补偿,触发风控或异常对账流程,由运营人员判断是否需要人工冲正。

    Q3:Seata Server (TC) 如果发生 OOM 或者宕机,对正在运行的业务有什么影响? A: 以 Seata 1.6.1 为例,TC 本身无状态,其事务数据存储在 MySQL 或 Redis 中。如果 TC 宕机,客户端的发起的全局事务将无法注册或提交,业务接口会大量抛出 TransactionException,导致新事务完全中断(可用性受损)。对于已经进入二阶段的事务,待 TC 恢复后,会从数据库读取处于 COMMITTINGROLLBACKING 状态的会话,继续下发二阶段指令。监控上会观察到活跃事务数(Active Transactions)剧增。