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:
- 无限增长。 只要不清理,内存会持续膨胀。如果不设上限,时间长了一定 OOM。
- 清理逻辑复杂。 要按时间或序列号删除旧条目,每次清理都有内存拷贝。
换成了定长环形缓冲区:
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(...));
}
}
几个关键点:
- 先发一次试试。 不先用队列缓冲------如果 Master 正常,直连最快。只有失败才缓冲。
- 标记
masterReachable。 一旦失败就设 false,后续直接走缓冲------避免每次都等 3 秒超时。 - 队列满时 FIFO 溢出。 队列满了丢最旧的------如果必须丢,丢最早的操作损失最小。
- 后台线程定期重放。 每 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 起的所有操作(全量)。这个校验保证了"不会把不同序列号但同一数组位置的新操作当成旧操作返回"。
六、总结
主从复制模块的三个设计原则:
- 核心层零修改:Table/BPTree 不知道复制的存在
- 定长环形缓冲区:O(1) 无动态分配,内存可预测
- 写操作幂等:upsert 语义保证重放安全
这些不是高级的分布式系统理论,是单机领域里"把一件事做到可靠"的工程设计。复制不是让你支撑千万并发------是让你在 Master 宕机时,系统不停。
系列:CacheSQL 工程化交付实录(共 5 篇,含桥接篇)