一、一场让我凌晨爬起来处理的磁盘故障

那是某个周四的晚上,我刚准备睡觉,手机上的告警就开始“连环call”。打开监控面板一看,Pulsar 集群的消息写入失败率已经变成了红色,业务方反馈消息发不进去。我第一反应是“某个存储节点挂了?”因为这种场景太常见了,可我心里不慌:Pulsar 的存储层是 BookKeeper,它写数据时会分多个副本,一个节点挂了,理论上不应该阻挡写入。可十秒钟后,我就意识到事情没那么简单——虽然确实有一台存储节点的磁盘彻底报错,但集群里依然有 4 台健康节点,它们应该能接替写入才对。

我登录到生产环境,看到 BookKeeper 日志里出现大量“Missing ledger fragments”和“Long wait”的报错。很明显,自动恢复机制已经启动了,但它似乎正在修复某个老旧的账本,而真正需要快速补副本的“新账本”,却被排在后面。整个过程持续了 30 多分钟,写入才慢慢恢复。后面我仔细复盘,发现问题不在“是否触发恢复”,而在于“恢复策略不够聪明”。这就是我们这次要聊的主题:一个存储节点磁盘烧毁后,为什么写入会长时间不可用?这背后其实藏着 BookKeeper 在容错设计上的一个短板。

二、先弄明白 Pulsar 和 BookKeeper 是怎么配合的

在深入分析之前,我们先得知道 Pulsar 和 BookKeeper 的关系。简单来说,Pulsar 负责消息的 API 接入和管理,而 BookKeeper 负责数据落盘和副本管理。你可以把 BookKeeper 想象成一个特别强调“多写几份”的分布式存储系统。

2.1 账本和存储节点的比喻

BookKeeper 里有两个核心概念:一个叫“账本”,英文是 Ledger;一个叫“存储节点”,英文是 Bookie。一个账本可以理解为一个不断追加写入的数据文件,它会按一定规则把数据复制到多个存储节点上。比如我们设定“每个账本包含 2 个副本,并且 2 个副本都写成功才算成功”,那么任何一台存储节点损坏,另一台上依然有完整的数据。这就像写两份文件,分别放在两个保险柜里,其中一个保险柜被偷了,另一个还能证明曾经发生过什么。

但问题是,账本并不是永远只写在一台节点上。它会根据配置,将不同的数据块(Entry)分布在一组存储节点中,这个组叫 Ensemble。当某一个节点坏掉后,存在于这个节点上的数据块就成了“缺失副本”。为了保证数据不丢,BookKeeper 就必须从其他节点上拷贝缺失的数据块到新的节点,这个动作就是“自动恢复”。

2.2 用 Java 演示一次正常写入

为了让你更直观地理解写入过程,我们用 Java 技术栈写一个小例子。这个例子连接一个本地模拟的 BookKeeper 集群,创建一个账本,然后写入一条消息:

// Java技术栈示例:创建账本并写入消息
import org.apache.bookkeeper.client.BookKeeper;
import org.apache.bookkeeper.client.LedgerHandle;

public class WriteExample {
    public static void main(String[] args) throws Exception {
        // 连接元数据存储(通常是ZooKeeper),地址根据自己的环境修改
        BookKeeper bk = new BookKeeper("127.0.0.1:2181");

        // createLedger参数说明:
        // 第1个参数(3):Ensemble大小,即数据会分布在3个存储节点上
        // 第2个参数(2):Write Quorum,即每条消息需要写入2个节点
        // 第3个参数(2):Ack Quorum,即至少2个节点确认写入才认为成功
        // 所以这里即使有一个节点挂了,理论上也能写入成功
        LedgerHandle lh = bk.createLedger(3, 2, 2,
                BookKeeper.DigestType.CRC32, "secret".getBytes());

        // 写入一条消息,返回这条消息在账本中的编号
        long entryId = lh.addEntry("这是一条测试消息".getBytes("UTF-8"));

        // 关闭账本句柄,确保数据落盘
        lh.close();
        bk.close();

        System.out.println("写入成功,entryId=" + entryId);
    }
}

你看,只要活着节点足够,移除一个节点不应该影响写入。但刚刚的故障告诉我们,实际不是这么简单。因为书写入时,可能还在等那个坏节点的确认,或者由于修复机制的影响,导致整个写入被“卡住”。

三、BookKeeper 的自动修复机制,到底怎么工作

在讲短板之前,我们得先知道 BookKeeper 是怎么设计这个自动恢复的。

3.1 自动修复的三个关键步骤

第一步,检测。BookKeeper 集群里会有一个叫 Auditor 的角色(一般由某个 Bookie 充当),它定期查看其他 Bookie 的心跳。一旦发现某个 Bookie 的心跳超时,就认为这个节点“死了”。

第二步,扫描。接着,Auditor 会扫描所有账本的元数据,找出哪些账本包含“死 Bookie”上的副本。这一步非常耗时,因为账本可能成千上万。

第三步,修复。对找到的每个账本,系统会从存活的副本中挑一个作为源,把缺失的数据复制到另一个存活节点上。复制完成后,更新元数据,这样就重新满足了副本数要求。

听起来很有逻辑对吗?但正是这三个步骤中的某些细节,成了事故的温床。

3.2 用 Java 代码模拟修复任务生成

我们用一个 Java 示例来模拟“扫描后生成任务”的过程。这里故意让任务按创建时间排序,表示系统默认的修复策略:

// Java技术栈示例:模拟审计员生成修复任务并按创建时间排序
import java.util.*;

public class RepairTaskGenerator {

    // 一个简单的任务类,保存待修复的账本信息和创建时间
    static class RepairTask {
        String ledgerId;
        long createTime;   // 账本创建时间戳
        boolean urgent;     // 是否为“正在被大量写入”的紧急账本

        RepairTask(String id, long createTime, boolean urgent) {
            this.ledgerId = id;
            this.createTime = createTime;
            this.urgent = urgent;
        }
    }

    public static void main(String[] args) {
        List<RepairTask> tasks = new ArrayList<>();

        // 假设有三个账本需要修复
        tasks.add(new RepairTask("ledger-100", 300000L, false)); // 3小时前创建,早就不写了
        tasks.add(new RepairTask("ledger-200", 60000L,  false)); // 1小时前创建,低频率写
        tasks.add(new RepairTask("ledger-300", 2000L,   true));  // 刚刚创建,正在被狂写

        // 默认排序:按createTime升序,即谁先创建谁先修复
        tasks.sort((a, b) -> Long.compare(a.createTime, b.createTime));

        System.out.println("修复任务执行顺序(按创建时间排序):");
        for (RepairTask task : tasks) {
            System.out.println("  " + task.ledgerId + ", 创建时间=" + task.createTime);
        }
        // 输出结果中,ledger-300 排在最后,尽管它是最紧急的。
    }
}

这个示例告诉我们,默认策略就是“先来后到”,并不关心实时写入量。接下来,我们就来聊聊这背后到底有哪些坑。

四、磁盘故障后写入长时间不可用的五个原因

4.1 检测故障的“慢动作”

Auditor 发现故障依赖心跳超时。心跳超时时间配置默认可能是 30 秒,甚至更长。在这 30 秒内,客户端已经向那个坏节点发出了很多写请求,这些请求会一直等待响应,直到超时。超时后客户端会重试,又可能再次失败。更麻烦的是,如果 Auditor 本身因为垃圾回收停顿或者网络抖动,检测还会更晚。这就像一个保安,三十秒之后才意识到小偷已经进门了。

4.2 修复顺序:先来后到,还是急事急办?

如上面代码演示,修复任务按创建时间排序。这带来一个非常糟糕的效应:修复旧账本会占用大量资源和时间,而新账本只能眼巴巴地等着。可是,业务写入主要发生在最新的账本上。新账本的缺失副本迟迟不能补充,导致新账本一直处于“副本数不足”的状态。一旦写入侧又要求每个写入必须确认 2 个副本,那么只要坏节点在账本中承担了 2 个副本里的 1 个,剩下的那一个健康节点虽然能写,但因为无法凑齐 2 个确认,写入就会一直失败。于是,整体写入就像被按下了暂停键。

4.3 全量复制惹的祸

现代账本可能非常大,几十 GB 甚至几百 GB。当修复一个老账本时,它会把账本里的所有数据从源节点复制到目标节点,“全量”两个字听起来就让人腿软。复制期间,磁盘 I/O 和网络带宽被大量占用,正常写入的延迟会明显上升。更气人的是,由于复制任务很多,系统常常同时启动多个复制,导致集群的网卡直接成为瓶颈。

4.4 笨拙的重试

修复过程一旦因为某种原因失败(比如源节点也抖动,或者目标节点磁盘坏了),任务会被重新放回队列,从头开始复制。如果你运气不好,这个任务可能陷入无限循环:复制一半失败了,重新来,又失败。每一次重试都会继续消耗集群资源,让原本就紧张的写入环境雪上加霜。

4.5 后台修复和写入修复的左手打右手

BookKeeper 在写入路径上也有“写修复”机制:当客户端发现副本数不足时,它会在写入过程中偷偷把缺失的副本补上。这种机制是为小概率故障设计的,但一旦遇上大范围故障,它和后台自动修复就会同时行动。两者可能操作同一个账本,产生重复复制甚至锁竞争,导致系统整体吞吐量不升反降。说白了,双管齐下本来是好事,但没协调好就变成“两个大夫同时开刀”,病人自然更难受。

五、一个 Java 模拟示例,重现“修复顺序拖垮写入”

为了不纸上谈兵,我们写一个更完整的 Java 模拟程序。这个程序不依赖真实集群,用虚拟时间模拟三个账本的修复过程,让大家直观看到为什么新账本写入会长时间卡住:

// Java技术栈示例:模拟自动恢复顺序对写入可用性的影响
import java.util.*;

public class DiskFailureSimulator {

    static class Ledger {
        String name;
        long createTime;
        boolean hot; // 是否是热写账本
        Ledger(String name, long createTime, boolean hot) {
            this.name = name;
            this.createTime = createTime;
            this.hot = hot;
        }
    }

    public static void main(String[] args) {
        // 模拟一个坏节点上的三个账本
        List<Ledger> ledgers = new ArrayList<>();
        ledgers.add(new Ledger("老账本(昨天创建,几乎不写入)", 100000L, false));
        ledgers.add(new Ledger("中账本(1小时前创建)", 3600L, false));
        ledgers.add(new Ledger("热账本(刚刚创建,产生大量写入)", 10L, true));

        // 按创建时间排序(先创建的先修复)
        ledgers.sort((a, b) -> Long.compare(a.createTime, b.createTime));

        int time = 0;
        System.out.println("=== 自动恢复执行顺序 ===");
        for (Ledger ledger : ledgers) {
            // 假设每个账本修复需要 30 秒
            time += 30;
            System.out.println("时间点 " + time + ":开始修复 [" + ledger.name + "]");
            if (ledger.hot) {
                System.out.println("  注意:此时热账本的副本仍然不足,写入请求全部超时!");
            }
        }
        System.out.println("最终在时间点 " + time + " 修复完成,写入才恢复。");
        System.out.println("也就是说,热账本被白白晾在一边了 " + (time - 30) + " 秒。");
    }
}

输出大概是:先修复老账本,30秒;再修复中账本,30秒;最后修复热账本。热账本在中间60秒里一直处于“缺副本”的状态,所有需要它确认的写入都会失败。虽然真实集群中会有一些并发和重试机制,但整体趋势是一样的。

六、怎么避坑?几个实用的建议

既然知道了短板,我们就能针对性地做点什么。下面这些建议来自我自己的实践,不一定适用所有场景,但值得参考。

6.1 合理设置心跳超时和检测周期

如果你的集群对故障容忍度要求高,可以把心跳超时时间调短一点,让 Auditor 更快发现问题。比如将 heartbeatTimeout 从默认的 30 秒调整到 10 秒。但也要注意,太短会导致节点因瞬时抖动而频繁被“判死”,反而引发不必要的自动恢复。可以根据压力测试找到一个平衡点。

6.2 给修复任务设置带宽和并发限制

很多版本支持限制自动修复使用的带宽或并发数量。比如可以配置 repairWorkerThreadsreplicationNibble 等参数,让修复任务慢一点,但至少不能把正常写入堵死。这里有一个重要思想:恢复速度不是越快越好,而是要“不打扰主要业务”。如果有必要,可以在业务低峰期手动触发修复。

6.3 让修复任务给实时写入“让路”

一些高版本已经支持按账本的“最后写入时间”或者“写入热度”来排序修复任务。如果版本不支持,我们可以通过监控工具及时发现坏节点,然后手动将热账本的修复任务优先级调高,或者直接使用运维命令强制把热账本迁移到健康节点。

6.4 监控和告警必须到位

事后我才意识到,如果我能及时看到“磁盘故障后,修复任务卡在哪个账本上”,就可以手动干预。所以建议监控 BookKeeper 的 LedgerUnderreplicated 指标,并记录每个账本的创建时间、末次写入时间。当出现“冗长的恢复队列”时,第一时间定位热点账本。

6.5 热升级和版本选择

BookKeeper 社区也在持续改进这个短板,比如将恢复任务改成按优先级调度,或支持断点续传。如果条件允许,应该升级到含有修复优化的版本。但升级前一定要在测试环境模拟一次磁盘故障,确保新版本的恢复行为不再“坑”。

七、总结

这次故障让我重新理解了“容错”两个字。BookKeeper 的自动恢复机制,初衷是要做到无人值守的故障自愈,但它在“先来后到”的修复顺序、全量复制、笨拙重试等方面的设计,确实存在不小的短板。在真实生产环境中,一块磁盘的损坏可能并不可怕,真正可怕的是自愈机制自身变成了新的故障放大器。通过合理配置、主动监控和人工介入,我们可以在很大程度上规避这些问题。我也希望这个案例能提醒大家:花钱买了高可用架构,不等于万事大吉,还得了解它内部机制中的那些“隐性陷阱”。