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 篇,含桥接篇)

相关推荐
llwszx2 分钟前
【Java/Go后端手撸原生Agent(第九篇):Plan-and-Execute规划模式——从“走一步看一步“到“先谋后动“】
java·python·golang·agent开发·plan模式·规划执行模式
ZJH__GO12 分钟前
网络编程v2--多客户端互通
java·运维·服务器·开发语言·计算机网络
霸道流氓气质24 分钟前
SpringBoot中基于 AES-GCM + KMS 密钥管理的数据加解密 Starter 实践
java·数据库·spring boot
Gofarlic_OMS36 分钟前
NX浮动许可调度黑名单机制,对比两款谁更合理
java·大数据·运维·开源·制造
三言老师1 小时前
CentOS7.9:Redis服务器部署结构化实战教程
linux·运维·服务器·数据库
乐观的Terry1 小时前
8、发布系统-完整流水线的核心
java·spring boot·spring·spring cloud
Alexalbb1 小时前
从角色过滤到可审计授权:单体系统数据权限设计复盘与演进
java
番茄炒鸡蛋加糖1 小时前
MySQL 实战调优& 分表基础
数据库·mysql
万亿少女的梦1681 小时前
基于Spring Boot的游戏交易管理系统设计与实现
java·spring boot·mysql·系统设计·交易管理
Database_Cool_1 小时前
云数据库如何保证高可用、故障了怎么办:阿里云 RDS MySQL 高可用架构详解
数据库·mysql·阿里云