CacheSQL(二):主从复制——OpLog 环形缓冲区与故障自动恢复
B+ 树内核在手写数据库那版就有了。CacheSQL 真正从原型变成产品,关键就在复制模块。
主从复制不是新东西——Redis、MySQL 都有。但自己从头设计一遍,每个取舍都要掂量,每个边界都要想清楚。
一、设计原则:核心层零修改
这是整个复制模块最重要的决策。
Table、BPTree、Node——核心层的三个核心类——完全不知道复制的存在。它们的 API 一致没变:table.insert()、table.update()、table.delete()。复制逻辑全部封装在ReplicationManager里。
结果:核心层可以脱离复制独立使用(standalone 模式),复制层只在需要时挂上去。这种"透明加层"的设计让三种模式(standalone / master / slave)共用同一套核心代码。
// ReplicationManager 的写操作入口publicstaticvoidinsert(Tabletable,StringindexColumn,ObjectkeyValue,HashMap<String,Object>newData)throwsException{if(ROLE_SLAVE.equals(ROLE)){forwardOrBuffer("insert",...);// Slave:转发到 Masterreturn;}table.insert(indexColumn,keyValue,newData);// 本地执行if(ROLE_MASTER.equals(ROLE)&&syncClient!=null){longseq=opLog.append("insert",...);// 记录 OpLogsyncClient.broadcast("insert",...);// 广播给所有 Slave}}三种角色一条调用链:Slave 转发 → Master 执行 + 记录 + 广播 → Slave 的 SyncServer 接收后回放。核心层始终只看到一个table.insert()。
二、OpLog:为什么用定长环形缓冲区
操作日志的实现,第一个念头是ArrayList。追加,满了扩容——很直觉。
但ArrayList有两个问题不适合 OpLog:
- 无限增长。只要不清理,内存会持续膨胀。如果不设上限,时间长了一定 OOM。
- 清理逻辑复杂。要按时间或序列号删除旧条目,每次清理都有内存拷贝。
换成了定长环形缓冲区:
publicclassOpLog{privatefinalOpEntry[]buffer;// 固定容量privatefinalintcapacity;privatelongnextSeq=1;// 下一个序列号privatelongheadSeq=0;// 当前可访问的最早序列号publicsynchronizedlongappend(...){intpos=(int)((nextSeq-1)%capacity);buffer[pos]=newOpEntry(nextSeq,...);headSeq=Math.max(1,nextSeq-capacity+1);returnnextSeq++;// 序列号递增,永不重置}}为什么序列号递增而不重置?这是为了增量同步。假设 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 宕机期间都会报错。加入了一个内存缓冲层:
privatestaticvoidforwardOrBuffer(...){// 先尝试直接转发if(masterReachable&&pendingQueue.isEmpty()){try{doForward(op,...);return;}catch(Exceptione){masterReachable=false;}}// 转发失败:缓冲到本地队列synchronized(pendingQueue){if(pendingQueue.size()>=pendingCapacity){pendingQueue.pollFirst();// FIFO 溢出:丢弃最旧的}pendingQueue.addLast(newPendingOp(...));}}几个关键点:
- 先发一次试试。不先用队列缓冲——如果 Master 正常,直连最快。只有失败才缓冲。
- 标记
masterReachable。一旦失败就设 false,后续直接走缓冲——避免每次都等 3 秒超时。 - 队列满时 FIFO 溢出。队列满了丢最旧的——如果必须丢,丢最早的操作损失最小。
- 后台线程定期重放。每 2 秒尝试清空 pendingQueue,成功就逐个弹出。失败就停——保持顺序不跳操作。
背景线程的重放逻辑:
privatestaticvoidflushPending(){while(true){PendingOpop;synchronized(pendingQueue){op=pendingQueue.peekFirst();// 只看不弹}if(op==null)break;try{doForward(op.op,...);synchronized(pendingQueue){pendingQueue.pollFirst();// 成功了才弹}}catch(Exceptione){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 提供了一个轻量级的增量同步机制:
publicsynchronizedList<OpEntry>getSince(longsinceSeq){List<OpEntry>result=newArrayList<>();longstart=Math.max(sinceSeq+1,headSeq);for(longseq=start;seq<nextSeq;seq++){intpos=(int)((seq-1)%capacity);if(buffer[pos]!=null&&buffer[pos].seq==seq){result.add(buffer[pos]);}}returnresult;}注意:seq == buffer[pos].seq。环形缓冲区在capacity次循环后会覆盖同一个数组位置。如果nextSeq - capacity > sinceSeq,说明缓冲区已覆盖——此时返回 headSeq 起的所有操作(全量)。这个校验保证了"不会把不同序列号但同一数组位置的新操作当成旧操作返回"。
六、总结
主从复制模块的三个设计原则:
- 核心层零修改:Table/BPTree 不知道复制的存在
- 定长环形缓冲区:O(1) 无动态分配,内存可预测
- 写操作幂等:upsert 语义保证重放安全
这些不是高级的分布式系统理论,是单机领域里"把一件事做到可靠"的工程设计。复制不是让你支撑千万并发——是让你在 Master 宕机时,系统不停。
下一篇:[CacheSQL(三):双 HTTP 引擎与 SQL 查询——接口抽象的价值]
系列:CacheSQL 工程化交付实录(共 5 篇,含桥接篇)