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

相关推荐
格子软件11 分钟前
2026年分布式GEO代理流量调度:源码级状态机防重挂实战
java·vue.js·人工智能·spring boot·分布式·vue
hj28625113 分钟前
Docker 容器化技术标准化笔记
java·笔记·docker
我是一颗柠檬24 分钟前
【Java项目技术亮点】EXPLAIN深度分析与慢查询治理
android·java·开发语言
xcLeigh26 分钟前
KES运维自动化与脚本体系实战
运维·数据库·自动化·脚本·数据迁移·kes
万亿少女的梦16827 分钟前
基于Spring Boot的社区管理系统设计与实现
java·spring boot·mysql·vue·系统设计
大气的小蜜蜂37 分钟前
领域层的服务
java·前端·数据库
agent89739 分钟前
Spring Boot 接口超时治理:从连接池、线程池到熔断限流的完整排查思路
java·spring boot·后端
Devin~Y1 小时前
抖音级短视频推荐与直播带货平台面试实战:从 Java 微服务到 RAG 智能客服全链路解析
java·spring boot·redis·spring cloud·kafka·agent·rag
翔云1234561 小时前
简单概括主库上 Executed_Gtid_Set 是什么时候更新的
数据库·mysql
帅次1 小时前
Android 高级工程师面试:Java 多线程与并发 近1年高频追问 22 题
android·java·面试