CacheSQL(二):主从复制——OpLog 环形缓冲区与故障自动恢复

CacheSQL(二):主从复制------OpLog 环形缓冲区与故障自动恢复


B+ 树内核在手写数据库那版就有了。CacheSQL 真正从原型变成产品,关键就在复制模块。

主从复制不是新东西------Redis、MySQL 都有。但自己从头设计一遍,每个取舍都要掂量,每个边界都要想清楚。


一、设计原则:核心层零修改

这是整个复制模块最重要的决策。

Table、BPTree、Node------核心层的三个核心类------完全不知道复制的存在。它们的 API 一致没变:table.insert()table.update()table.delete()。复制逻辑全部封装在 ReplicationManager 里。

结果:核心层可以脱离复制独立使用(standalone 模式),复制层只在需要时挂上去。这种"透明加层"的设计让三种模式(standalone / master / slave)共用同一套核心代码。

java 复制代码
// ReplicationManager 的写操作入口
public static void insert(Table table, String indexColumn, Object keyValue,
        HashMap<String, Object> newData) throws Exception {
    if (ROLE_SLAVE.equals(ROLE)) {
        forwardOrBuffer("insert", ...);  // Slave:转发到 Master
        return;
    }
    table.insert(indexColumn, keyValue, newData);   // 本地执行
    if (ROLE_MASTER.equals(ROLE) && syncClient != null) {
        long seq = opLog.append("insert", ...);      // 记录 OpLog
        syncClient.broadcast("insert", ...);         // 广播给所有 Slave
    }
}

三种角色一条调用链:Slave 转发 → Master 执行 + 记录 + 广播 → Slave 的 SyncServer 接收后回放。核心层始终只看到一个 table.insert()


二、OpLog:为什么用定长环形缓冲区

操作日志的实现,第一个念头是 ArrayList。追加,满了扩容------很直觉。

ArrayList 有两个问题不适合 OpLog:

  1. 无限增长。 只要不清理,内存会持续膨胀。如果不设上限,时间长了一定 OOM。
  2. 清理逻辑复杂。 要按时间或序列号删除旧条目,每次清理都有内存拷贝。

换成了定长环形缓冲区:

java 复制代码
public class OpLog {
    private final OpEntry[] buffer;  // 固定容量
    private final int capacity;
    private long nextSeq = 1;         // 下一个序列号
    private long headSeq = 0;         // 当前可访问的最早序列号

    public synchronized long append(...) {
        int pos = (int) ((nextSeq - 1) % capacity);
        buffer[pos] = new OpEntry(nextSeq, ...);
        headSeq = Math.max(1, nextSeq - capacity + 1);
        return nextSeq++;   // 序列号递增,永不重置
    }
}

为什么序列号递增而不重置? 这是为了增量同步。假设 Slave 离线时 lastSeq = 500,Master 此时已写到 seq = 10500(环形缓冲覆盖了 10001 到 10500)。Slave 重连时上报 500,Master 的 getSince(500) 发现 headSeq=10001 > 500,说明中间的操作已被覆盖------Slave 需要全量重载。如果 nextSeq 在 buffer 写入时会重置,这个"需要全量重载"的判断就不准确了。

定长环形缓冲的核心优势不是省内存------而是 O(1) 无动态分配。追加就是一把数组插,不触发 GC。固定容量意味着内存占用可预测------配 10000 个条目,就占这么多,不会突然膨胀。

代价是覆盖风险。Slave 离线时间超过缓冲区容量,缺失的操作就丢了------此时只能全量刷新。但这个代价在业务受控的范围内:把容量配大一点(10000 条够 Slave 离线两个小时),覆盖概率就极低。


三、Slave 的故障缓冲:pendingQueue

Slave 收到写操作后先尝试直接转发到 Master。如果 Master 不可达怎么办?

不是返回 500------这样用户体验差,每笔写入在 Master 宕机期间都会报错。加入了一个内存缓冲层:

java 复制代码
private static void forwardOrBuffer(...) {
    // 先尝试直接转发
    if (masterReachable && pendingQueue.isEmpty()) {
        try {
            doForward(op, ...);
            return;
        } catch (Exception e) {
            masterReachable = false;
        }
    }
    // 转发失败:缓冲到本地队列
    synchronized (pendingQueue) {
        if (pendingQueue.size() >= pendingCapacity) {
            pendingQueue.pollFirst();  // FIFO 溢出:丢弃最旧的
        }
        pendingQueue.addLast(new PendingOp(...));
    }
}

几个关键点:

  1. 先发一次试试。 不先用队列缓冲------如果 Master 正常,直连最快。只有失败才缓冲。
  2. 标记 masterReachable 一旦失败就设 false,后续直接走缓冲------避免每次都等 3 秒超时。
  3. 队列满时 FIFO 溢出。 队列满了丢最旧的------如果必须丢,丢最早的操作损失最小。
  4. 后台线程定期重放。 每 2 秒尝试清空 pendingQueue,成功就逐个弹出。失败就停------保持顺序不跳操作

背景线程的重放逻辑:

java 复制代码
private static void flushPending() {
    while (true) {
        PendingOp op;
        synchronized (pendingQueue) {
            op = pendingQueue.peekFirst();  // 只看不弹
        }
        if (op == null) break;
        try {
            doForward(op.op, ...);
            synchronized (pendingQueue) {
                pendingQueue.pollFirst();   // 成功了才弹
            }
        } catch (Exception e) {
            masterReachable = false;
            return;  // 失败了就停,等下一轮
        }
    }
    masterReachable = true;  // 全部成功:Master 恢复了
}

为什么要逐个吞而非批量发? 批量更高效,但失败了你不知道哪个没发成功,重发顺序会乱。One by one 虽慢,但保证了完全有序重放。对配置表场景(写操作低频),吞吐量不是瓶颈。


四、insert 的幂等性:upsert 语义

主从复制最大的坑不是网络断,是重复操作。

Slave 在 Master 不可达时把写操作缓冲到 pendingQueue。Master 恢复后,pendingQueue 被清空。但如果 Master 在宕机前已经处理了部分转发请求------Slave 不知道,重放时会重复发。

这就要求写操作必须是幂等的:同一个操作无论执行多少次,结果一样。

insert 采用 upsert 语义:

复制代码
主键存在且行已删除 → 复用槽位
主键存在且行活跃 → 覆盖更新
主键不存在 → 新增

同一个 key 重复 insert 不会产生重复行------第二条覆盖第一条。这保证了 pendingQueue 的重放是安全的:即使 Master 已经处理了,Slave"重放"一下也不会产生脏数据。

这是从 MySQL InnoDB 的 REPLACE 语法学来的设计。不是新东西,但映射到自己的场景里------操作日志 + 幂等语义------构成了主从复制的基础可靠性保证。


五、增量同步:getSince

Slave 离线后重连,Master 要补发缺失的操作。OpLog 提供了一个轻量级的增量同步机制:

java 复制代码
public synchronized List<OpEntry> getSince(long sinceSeq) {
    List<OpEntry> result = new ArrayList<>();
    long start = Math.max(sinceSeq + 1, headSeq);
    for (long seq = start; seq < nextSeq; seq++) {
        int pos = (int) ((seq - 1) % capacity);
        if (buffer[pos] != null && buffer[pos].seq == seq) {
            result.add(buffer[pos]);
        }
    }
    return result;
}

注意:seq == buffer[pos].seq。环形缓冲区在 capacity 次循环后会覆盖同一个数组位置。如果 nextSeq - capacity > sinceSeq,说明缓冲区已覆盖------此时返回 headSeq 起的所有操作(全量)。这个校验保证了"不会把不同序列号但同一数组位置的新操作当成旧操作返回"。


六、总结

主从复制模块的三个设计原则:

  1. 核心层零修改:Table/BPTree 不知道复制的存在
  2. 定长环形缓冲区:O(1) 无动态分配,内存可预测
  3. 写操作幂等:upsert 语义保证重放安全

这些不是高级的分布式系统理论,是单机领域里"把一件事做到可靠"的工程设计。复制不是让你支撑千万并发------是让你在 Master 宕机时,系统不停。


下一篇:[CacheSQL(三):双 HTTP 引擎与 SQL 查询------接口抽象的价值]


系列:CacheSQL 工程化交付实录(共 5 篇,含桥接篇)

相关推荐
2401_832365522 小时前
JavaScript中rest参数(...args)取代arguments的优势
jvm·数据库·python
Bat U2 小时前
JavaEE|多线程初阶(七)
java·开发语言
2301_779622413 小时前
Go语言怎么用信号量控制并发_Go语言semaphore信号量教程【入门】
jvm·数据库·python
2301_766283443 小时前
c++如何将控制台输出保存到文件_cout重定向到txt【详解】
jvm·数据库·python
北极的冰箱3 小时前
MySQL Ver 8.0.41 for macos14.7密码遗忘
数据库·mysql
XDH_CS4 小时前
MySQL 8.0 安装与 MySQL Workbench 使用全流程(超详细教程)
开发语言·数据库·mysql
treacle田4 小时前
达梦数据库-统计信息收集-记录
数据库·达梦数据库统计信息收集
审判长烧鸡5 小时前
PostgreSQL之索引/函数/触发器
数据库·postgresql·触发器·函数·索引
Data_Journal5 小时前
如何使用cURL更改User Agent
大数据·服务器·前端·javascript·数据库