先把结论放这儿:KV-Engine 里做高可用,主从复制不是唯一的路,但绝对是最容易落地、最容易被团队接受的一条路。如果你正在设计一个自研的 KV 存储,或者想在已有引擎上补上“高可用”这块拼图,这篇就是把你从“能读写”带到“挂了也不怕”的完整过程。
我不打算讲那种纯理论的东西,我会直接按我们实际实现 KV-Engine 主从复制的顺序来写:从为什么选主从、到复制模式怎么选、再到日志设计、同步协议、故障切换,最后附上我们踩过的一堆坑。内容会偏向工程落地,但我会把原理嵌入到每个决策里,看完你至少能独立画出一张主从复制的设计图。
1. 高可用架构的第一步:为什么偏偏是主从复制
1.1 单机 KV 引擎暴露的三个致命问题
我一开始做 KV-Engine 时,心里想的很简单:一个高性能的单机 KV 存储,内存索引加磁盘持久化,读写能力够猛就行。但当我把引擎真正部署到测试环境,模拟一台机器宕机时,才意识到单机架构的脆弱程度远超预期。
单机 KV 引擎的问题可以归纳成三个:
- 可用性归零:进程一挂,所有读写全部失败,业务直接断。哪怕底层磁盘再可靠,操作系统崩溃、机器断电、网络被误关,这类故障都绕不开。
- 数据恢复时间不可控:就算启用了 WAL(Write-Ahead Log,预写日志),重启后也要经历日志回放。如果日志量大,恢复可能需要几十秒甚至分钟级,这对线上业务来说是不可接受的。
- 扩容就是停机:想把单机能力翻倍,只能停服迁移数据。一旦数据量到了 TB 级,迁移时间会让业务方彻底崩溃。
所以做高可用架构的第一步,就是先解决“数据在另一台机器上还有一份”的问题。主从复制其实就是在干这件事:它让一台机器挂了,另一台机器还能顶上。
1.2 主从复制和 Raft、多主复制的本质区别
当年在设计高可用方案时,团队内部其实讨论过三个方向:主从复制(Master-Slave)、多主复制(Multi-Master)和 Raft 共识协议(比如 etcd 那种)。我简单说说最后为什么选了主从。
- 多主复制:所有节点都能写,听起来很灵活,但冲突解决是个无底洞。两个节点同时写同一个 key,到底以谁为准?引入向量时钟、CRDT 还是最后写入者获胜(LWW),都会给客户端带来额外的理解成本。而且 KV 场景下低延迟是关键,跨节点协商必然拖慢单次写入。
- Raft 共识:一致性最强,选主自动,但代价是写入要过多数派确认。在 KV 引擎这种追求极致吞吐的场景里,Raft 的写放大和延迟开销明显偏高。当然你也可以说 etcd 能扛,但 etcd 的定位是协调型 KV,跟数据型 KV 的写放大不是一个量级。
- 主从复制:只有主节点可写,从节点复制数据、分担读流量。它的一致性模型虽然“弱”一点,但对绝大多数业务来说完全够用,而且实现复杂度最低,运维也最直观。
所以说,如果你的 KV 引擎是给缓存、会话数据、配置快照这类场景用的,主从复制就是性价比最高的高可用方案。Raft 是给“绝对不能丢数据”的场景准备的,主从复制是给“绝对不能不可用”的场景准备的。
1.3 MySQL 和 Redis 已经替我们蹚过路了
聊主从复制,绕不开 MySQL 和 Redis。MySQL 的 binlog 主从复制、半同步复制(Semisync Replication)、GTID 自动定位,Redis 的 PSYNC 全量加增量复制,这些都是生产环境验证过十几年的成熟机制。
设计 KV-Engine 的时候,我大量参考了它们的思路,但没全抄。原因是两者的数据模型和存储结构差异很大:
- MySQL 是 B+ Tree 存储,重放 binlog 时要做行级变更,随机 IO 占大头;
- Redis 是纯内存加 RDB/AOF 持久化,全量同步传 RDB 文件就好,增量同步传命令流;
- KV-Engine 用的是 LSM Tree 风格存储加 WAL,全量同步更适合用快照文件加后续增量日志组合。
所以,在后面的实现里,我会刻意结合 KV 引擎自身的特点来做设计,而不是生搬硬套 MySQL 的 binlog 那一套。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 主从复制的架构设计与复制模式选型
2.1 角色模型与整体数据流
主从复制的角色模型很清晰:一个主节点(Master)负责处理所有写请求;一个或多个从节点(Slave)通过复制日志保持与主节点数据一致,并且可以承担读请求。在接入层,客户端会感知到主从的区别,写流量打到主,读流量按需分发到从。
整体数据流我总结成六步:
- 客户端向主节点发送写请求(PUT/DELETE)。
- 主节点先写本地 WAL,确保宕机可恢复。
- 主节点更新内存索引,并生成一条复制日志条目。
- 主节点将复制日志异步或半同步地推送给从节点。
- 从节点收到复制日志后,先落本地 WAL,再应用日志更新内存索引。
- 从节点返回 ACK(按复制模式决定是否阻塞主节点)。
这里最关键的设计点是第 2 步和第 4 步的衔接:WAL 不仅仅是本地数据的恢复保障,它天然就是复制日志的载体。也就是说,主节点不用为复制单独生成一份日志,直接复用 WAL 条目就行。这能省掉一大堆维护两份日志的麻烦。
2.2 复制模式选型:异步、半同步还是同步
在设计复制链路时,第一个要拍板的问题就是:主节点要等到从节点确认,才向客户端返回成功吗?
我对比一下三种模式的实际表现,用一个实例来说明会很清楚。
假设主节点刚写入一条 key,从节点还没来得及同步,此时主节点宕机:
- 异步复制:客户端已经收到“写入成功”的响应,但数据实际没到从节点。主节点一挂,这条数据就永久丢失。好处是主节点写入延迟完全不受从节点影响,吞吐最高。
- 同步复制:主节点必须等所有从节点都 ACK 才返回成功。数据最安全,但任何一个从节点网络抖动,主节点写入就被拖住,可用性反而被拉低。
- 半同步复制:主节点只要等至少一个从节点 ACK 就返回成功。兼顾了数据安全和写入延迟。这是 MySQL 5.7 之后默认推荐的模式,也是我给 KV-Engine 选的折中方案。
具体落地时,可以在配置里加一个参数 repl.ack-count,默认设为 1,表示至少有 1 个从节点确认才算写入完成。如果你对数据安全极度敏感,可以设成 2 或更多,但写入延迟会线性上升。
2.3 一致性与可用性的边界
主从复制在一致性上有个绕不开的边界:它提供的是最终一致性(Eventual Consistency),不是强一致性。原因在于从节点的数据应用存在延迟,这个延迟再小也不是零。
我用一个真实的时序例子来说明这个问题:
- 客户端 A 往主节点写入 key =
stock:1001, value =100,主节点返回成功。 - 客户端 B 在同一毫秒内,把读请求发送到了从节点。
- 从节点还没来得及应用这条最新写入,返回给 B 的还是旧值。
这种情况叫“复制滞后”(Replication Lag)。如果业务无法接受这种短暂的不一致,有两个补救办法:
- 读主节点:对一致性要求高的读请求强制路由到主节点,比如用请求头标记
consistent-read: true。 - 版本号校验:客户端写入时拿到版本号(或时间戳),读从节点时带上期望版本,如果从节点版本不够就重试读主。
我把这个边界理解成“高可用是有代价的”:主从复制换来的是“挂了还有另一台能顶”,代价是“读取可能不是最新的”。在设计架构时,你必须在需求阶段就跟业务方对齐这件事,否则后面出了问题全是运维背锅。
2.4 全量同步与增量同步的划分
节点之间第一次建立复制关系时,从节点内存里通常是空的,这时候不可能直接补增量日志,因为主节点根本不知道从节点缺哪些数据。所以必须做一次全量同步。之后才进入增量同步阶段。
全量同步和增量同步的分界线,我用一个复制偏移量(Replication Offset)来标定。这个偏移量可以简单理解成 WAL 里已经写入的日志条目序号,从 0 开始,每写入一条自增。
全量同步的处理流程是:
- 从节点发送全量同步请求,带上自己的复制偏移量(通常是 0 或上次断连时的值)。
- 主节点收到请求后,立即生成一个当前 WAL 的偏移量快照
snapshot_offset。 - 主节点在后台生成一份完整的内存数据快照文件(类似 Redis 的 RDB),同时继续接收新的写请求,并把这些新写入的日志条目暂存在发送缓冲区。
- 主节点把快照文件传输给从节点。
- 从节点加载快照文件,把偏移量设为
snapshot_offset,然后请求主节点从snapshot_offset + 1开始补发增量日志。
增量同步就简单了,主节点把复制日志条目推给从节点,从节点按顺序应用。我们给从节点发送的协议里带了明确的偏移量,这样即使断线重连,也能从最接近的偏移量续传,不需要重新全量同步。
3. 核心实现细节:从写入到同步的完整链路
3.1 WAL 与复制日志的结构设计
前面说了,KV-Engine 的 WAL 要一鱼两吃:既负责宕机恢复,又充当复制日志。所以 WAL 的条目不光是“值”这么简单,必须带上复制需要的元信息。
我设计的复制日志条目格式大概是这样(用 Go 的结构体表示):
go复制type ReplLogEntry struct {
Seq uint64 // 自增序号,用于偏移量追踪
Op uint8 // 操作类型:PUT / DELETE / CLEAR
Key []byte // 操作的 Key
Value []byte // PUT 时的 Value
Timestamp int64 // 写入时的 Unix 纳秒时间
Checksum uint32 // 整条日志的 CRC32 校验值
}
Seq是复制的生命线,所有节点都需要用这个字段对齐位置;Checksum是为了防止传输过程中数据损坏,从节点应用前必须校验;Timestamp用来辅助解决时钟相关问题,比如业务做时间排序。
写入路径上的操作顺序要严格设计,我建议这么排:
- 加写锁。
- 构造
ReplLogEntry。 - 把条目追加到 WAL,并
fsync(按配置可放宽)。 - 更新内存索引。
- 把日志条目写入复制发送缓冲。
- 释放写锁。
这里有坑:如果先更新内存索引,再写 WAL,一旦写 WAL 失败,内存和磁盘就出现不一致。所以一定要先落日志,再改内存。
3.2 复制协议:握手、流式传输、心跳与断线续传
主从节点之间需要一套自定义的二进制协议。我把它分成四个阶段。
阶段一:握手
从节点连接主节点后,发送一条握手消息:
code复制REPL_HANDSHAKE
repl_id: <从节点自己的ID>
repl_offset: <从节点当前已知的复制偏移量>
主节点收到后,返回自己的 master_repl_id 和 master_repl_offset。如果主节点发现从节点的偏移量已经过期,即不在主节点的复制缓冲里了,就会主动拒绝增量同步并要求从节点做全量同步。
阶段二:全量同步
主节点发送:
code复制REPL_SNAPSHOT
snapshot_offset: <快照对应的复制偏移量>
snapshot_size: <快照文件字节数>
<随后跟快照文件的二进制数据>
从节点接收完快照后,要校验 snapshot_size 并对快照文件内容做一次 Checksum 校验,防止文件传输被截断。
阶段三:增量同步
全量同步结束后,主节点开始推送增量日志:
code复制REPL_STREAM
seq: <日志序号>
op: PUT/DELETE/CLEAR
key_len: <key 长度>
key: <key 数据>
value_len: <value 长度>
value: <value 数据>
checksum: <日志条目的CRC32>
每推送一条,从节点返回一条 ACK:
code复制REPL_ACK
seq: <收到并应用完成的日志序号>
这一步对半同步模式至关重要,主节点要维护一个“待 ACK 日志队列”,只有收到足够 ACK 数,才向客户端返回写入成功。
阶段四:心跳保活
为了防止连接静默断开后主节点不知道,从节点还要周期性地发送心跳:
code复制REPL_PING
repl_offset: <从节点当前的复制偏移量>
主节点如果在 repl.timeout 秒内没收到心跳,就认为该从节点离线,把它从可用从节点列表中摘除。这个 repl.timeout 不能设得太短,否则网络抖动会导致误判,默认建议 10 秒。
3.3 从库应用线程与并行回放
从节点收到复制日志后,需要把日志应用到自己的存储中。最朴素的实现方式是单线程顺序回放,因为复制日志天然是有序的,单线程应用能保证和主节点完全一致。
但单线程的问题也很明显:如果主节点写入吞吐很高,从节点的回放速度一旦跟不上,延迟就会持续累积。我们在压测中发现,单线程回放只能跑到主节点写入吞吐的 60% 左右,这在大流量下是不可接受的。
于是我们做了并行回放,但是有严格前提的。思路是:把不同 Key 的日志条目分发到不同回放线程,但同一个 Key 的日志必须串行执行。
实现方式很简单,对 Key 取哈希后分桶,每个桶一个 FIFO 队列和一个线程:
go复制func hashKey(key []byte) uint32 {
h := fnv.New32a()
h.Write(key)
return h.Sum32()
}
func chooseWorker(key []byte, workerCount int) int {
return int(hashKey(key) % uint32(workerCount))
}
这样设计后,回放吞吐几乎能线性扩展到 CPU 核数。不过要注意:并行回放只适用于互不影响的 KV 操作,如果你的引擎支持事务或范围操作(Range Operation),这些操作必须退化成全局串行,否则会破坏一致性。
3.4 幂等设计与冲突处理
复制日志重放必须支持幂等。因为网络超时可能导致从节点应用成功后 ACK 丢失,主节点会重试推送同一条日志。如果从节点傻乎乎地再执行一次,就会产生错误数据。
解决幂等问题的标准方案是:在从节点内存里缓存最近应用的日志序号(比如一个环形缓冲,保存最近 10 万条 seq)。应用日志前检查该 seq 是否已存在,如果存在就直接跳过。
还有另一个场景:全量同步期间,主节点会产生新的写入,这些写入的日志条目必须暂时缓存,等从节点加载完快照后再按顺序发送。如果缓存区大小设置得不够,就会导致全量同步失败,从节点只能重新发起全量同步。我在配置里设置了 repl.backlog-size,默认 256 MB,基本能扛住全量同步期间的高峰写入。
4. 故障切换:让主从复制真正撑起高可用
4.1 故障检测:主观下线与客观下线
主从复制不是把日志同步过去就完事了,真正让高可用闭环的是故障切换。故障切换的第一步是故障检测。
我先定义一个概念:主观下线是指某个节点自己判断另一个节点不可达,比如连续多次心跳超时;客观下线是指有足够多的节点都认为这个节点不可达,通常是配合外部的协调组件(比如 ZooKeeper、etcd)做决策。
KV-Engine 的故障检测流程可以这么设计:
- 主节点和从节点各自维护一个心跳计时器。
- 如果从节点连续
repl.timeout秒没有收到主节点的心跳,从节点标记主节点“主观下线”。 - 从节点向协调服务(等会用一个简单的
etcd)上报疑似主节点故障。 - 协调服务统计多数意见,如果超过半数的从节点认为主节点故障,就判定主节点“客观下线”。
- 协调服务从可用从节点中选出一个新主节点,并通知其他从节点切换。
这个流程里,我特意避开了“每个从节点自己决定切换”的方案,因为多个从节点同时把自己升为主节点,就会引发脑裂。
4.2 主从切换的完整流程
当协调服务判定主节点客观下线后,切换流程开始。我实际操作时的完整步骤如下:
- 锁定候选:协调服务从存活从节点中选出数据最新的从节点作为新主节点,选法很简单:比较各从节点上报的
repl_offset,偏移量最大的胜出。 - 提升新主:协调服务通知候选节点执行
ROLE CHANGE TO MASTER,候选节点停止应用复制日志,转为可写状态。 - 重定向从节点:其他从节点收到新主的广播后,断开与旧主的连接,向新主发起全量或增量同步。
- 旧主降级:如果旧主实际上没宕机,只是网络分区,那它恢复后必须降级成从节点,向新主请求同步。
- 更新路由:协调服务更新客户端连接配置,把写流量指向新主。
在实际工程中,第 4 步是最容易被忽略的。很多人只设计了新主提升,没考虑旧主回归,结果旧主一恢复就把数据覆盖了。
4.3 脑裂防护与数据补偿
脑裂指的是:旧主和新主同时处于可写状态,导致两边都有写入,数据变得不可合并。这种情况必须从设计上杜绝。
我用两个手段来防脑裂:
-
fencing token(任期号):协调服务在每次切换时生成一个递增的任期号,只有持有最新任期号的节点才能接受写请求。客户端写入时需要携带令牌,旧主令牌已过期,即使恢复也会拒绝写入。
-
租约机制(Lease):主节点必须周期性从协调服务续租,租期比如 5 秒。如果主节点无法续租,就自动降级为只读。这样即使节点认为自己还是主,也无法处理写请求,从而根除脑裂。
对于已经发生的脑裂(比如极端情况下两个主都短暂接受过写入),只能靠数据补偿或者接受丢失。我在设计时明确了一条底线:KV-Engine 的主从复制优先保证可用性,如果业务要求绝对不丢数据,建议改用 Raft 方案,不要指望在主从复制里做到完全无脑裂。
5. 常见问题排查与运维实录
5.1 主从延迟飙升的排查思路
主从延迟是出现频率最高的问题。我们线上曾出现过从节点延迟达到 30 秒的告警,最后查下来是慢查询造成的。
通常延迟飙升有四个原因:
- 从节点回放性能不足:并行回放线程数量太少,或者从节点的 CPU 被其他任务抢占。
- 大 Key 操作阻塞:如果你在 KV 里存了一个 100 MB 的 Value,主节点写一次很快,但从节点应用这个 Value 时要分配内存、写磁盘,耗时会远超其他小 Key。
- 网络带宽瓶颈:大量的全量同步或大 Value 传输会占满网卡,导致增量日志传输变慢。
- 从节点本身承担了太多读流量:读请求抢占了 CPU 和 IO,回放线程饿死。
排查优先级建议:先看从节点的监控,用 top、iostat 看 CPU 和 IO;然后看复制日志积压数;最后看是不是有大 Key。把这几个逐步排除,基本能定位问题。
5.2 复制中断与数据不一致
复制中断最常见的表现是:从节点不断尝试重连,但每次连上后主节点要求全量同步,全量同步又因为网络问题反复失败。
这里的关键点是过期偏移量。前面提过,主节点只会在内存里保留最近一段时间的复制日志(repl.backlog-size 控制)。如果从节点断连时间太长,它的偏移量已经不在主节点的 backlog 范围内了,主节点只能要求全量同步。
我给的缓解策略是:
- 调大
repl.backlog-size,至少能扛住 30 分钟的断连时间; - 在从节点做快照持久化时,顺手把复制偏移量也存下来;
- 如果全量同步频繁失败,建议先排查网络丢包,再考虑做限速策略,别让主节点被全量同步打垮。
数据不一致这个问题我单独提醒一句:不要以为主从复制天然一致,一定要定期做校验。我们做法是每天凌晨跑一次 key 的 CRC 比对任务,抽样对比主从节点的校验和,发现不一致就用主节点数据强制覆盖从节点。
5.3 全量同步失败的排查
全量同步失败的情况,我把它整理成一个速查表,方便你直接对照处理:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 快照传输一半断连 | 网络不稳定或超时 | 调大 repl.snapshot-timeout,启用断点续传 |
| 从节点加载快照后数据不全 | 快照生成期间写入的日志未完全补发 | 检查 snapshot_offset 后的增量日志是否完整 |
| 全量同步反复触发 | 从节点断连时间超过 backlog 窗口 | 调大 repl.backlog-size,缩短断连时间 |
| 快照文件校验失败 | 磁盘故障或传输损坏 | 生成快照时计算 SHA256,加载后校验,失败重传 |
| 从节点启动慢 | 加载快照期间所有请求被阻塞 | 用全量加载加增量日志回放的方式,避免一次性挂载大文件 |
这里我要强调一下断点续传,最优做法是:主节点在生成快照时按 8 MB 分块,每传完一块,从节点就落一块磁盘并回复 ACK。断线后从节点告诉主节点“我已经有第 N 块”,主节点从 N+1 续传,能极大降低全量同步的失败率。
5.4 监控指标与告警阈值
好的监控是主从复制稳定运行的保障。我把自己在用的监控指标清单列出来,这些指标缺一不可:
| 指标名称 | 类型 | 建议告警阈值 | 说明 |
|---|---|---|---|
| master_repl_offset | 递增计数 | 无 | 主节点当前复制偏移量 |
| slave_repl_offset | 递增计数 | 无 | 从节点当前复制偏移量 |
| repl_lag_seconds | 时延 | 超过 5 秒告警 | 主从延迟时间 |
| repl_stream_failures | 计数 | 1 分钟内超过 3 次 | 复制流中断次数 |
| repl_snapshot_in_progress | 布尔 | 持续超过 5 分钟 | 全量同步状态 |
| slave_read_only | 布尔 | 状态变化即告警 | 从节点是否只读 |
| fsync_failed | 计数 | 1 次即告警 | 从节点 Fsync 失败,可能引发数据不一致 |
特别注意 repl_stream_failures 这个指标,它往往先于其他指标暴露问题。我在压测中发现,它一旦在短时间内连续增长,大概率是网络链路或主节点写放大导致的积压,必须立刻处理。
写在最后
做完这套主从复制之后,我的整体感受是:设计本身并不难,难的是在真正的业务场景里把各种边界条件想清楚。比如半同步复制到底要在哪些写路径上生效,脑裂防护是依赖外部协调服务还是引擎内置,全量同步时的背压策略怎么设计,这些细节每一个都能写一篇专题。
我再分享一个我们踩过的最深的一个坑:一开始偷懒,把主从节点直接部署在同一台物理机的两个容器里,觉得这样省机器。直到做故障演练时,一不小心把整台物理机给拔了电,主从一起挂,才明白了高可用架构里“物理隔离”是不可妥协的原则。真正部署时,请务必让主节点和从节点落在不同的机架、不同的交换机下,甚至不同的可用区,否则你的复制机制再完善,也扛不住单点物理故障。
希望这篇对你有用。如果你也在做 KV 引擎或类似的数据组件,欢迎对照这份设计自己动手试一遍,踩坑后的收获会来得更扎实。
