ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用Go从0到1实现分布式数据库:架构、Raft与故障恢复实践

用Go从0到1实现分布式数据库:架构、Raft与故障恢复实践 去年我啃完《Designing Data-Intensive Applications》之后冒出一个有点疯的想法用Go从零实现一个分布式数据库。不是想复刻TiDB或CockroachDB而是想亲手把“分片、复制、共识、故障恢复”这条链路完整走一遍。这个项目后来成了我的主力技术练手项目代码不多但该踩的坑一个没少。如果你正在学Go同时对分布式架构处于“看了很多原理、但没亲手拼过”的状态这篇文章应该能帮你看清一个分布式数据库到底是怎么从0到1粘起来的。其中会涉及Go语言工程实践、分布式数据库的架构取舍以及关键模块的代码实现偏实验性但绝对能落地。我把项目定位成一个分布式KV数据库支持简单的Range Scan、多副本复制、基于Raft的选主与日志复制、分片路由以及一个极简单的跨分片事务。不做SQL解析不搞复杂优化器所有精力都花在“数据在分布式环境下如何保持一致”这个核心问题上。如果你也想做类似的事我建议先按照这个范围划边界否则很容易陷入无尽的功能泥潭。1. 为什么我会用Go重写一套分布式数据库1.1 从单机到分布式的痛点不只在性能单机数据库的瓶颈很好理解CPU打满、磁盘写满、主从切换时总有那么一段时间不可用。但真正的分布式难点不是把数据拆开而是拆开之后还要让人感觉它像一个整体。你要面对的不只是“查询变慢”而是网络延迟、节点宕机、日志乱序、时钟不同步这些平时根本不会碰到的概念。我最早是用一个单机存储引擎做原型跑通简单接口后才发现后续工作量和单机版本完全不是一个量级。分布式系统里的每个操作都多了一个“另一台机器也在做同样的事”的维度。你要考虑客户端请求落在哪个分片、哪个副本是主、日志是否已经提交、故障后谁来接管。这些问题如果不在架构层面统一解决代码就会越写越乱最后变成一堆if else。所以我的第一个建议是不要急着写代码先把“数据是怎么流动的”画明白。你可以用白板画一张包含Proxy、Meta节点、数据节点、Raft组的拓扑图然后标出读写路径。哪个节点收到请求怎么定位分片如何复制日志日志提交后怎么应用这套流程一旦清晰代码实现顺序也就自然出来了。1.2 Go语言给我的取舍并发模型与部署便利选Go不是因为它比Java/C强而是它非常契合“网络服务型分布式组件”这个场景。goroutine与channel分布式系统里到处是“同时处理很多连接”的需求。每个客户端连接、每个Raft节点之间的消息通道都可以用goroutine很自然地承载。相比Java的线程池和回调Go写起来更直白不容易绕进复杂的异步状态机。静态编译交叉编译一个二进制直接扔到服务器上跑不用装JRE不用管依赖这对快速部署和故障复现非常友好。标准库与生态net、crypto/tls、encoding/json这些库足够我搭一套实验性项目。当然生产环境我不会用JSON做RPC协议但原型阶段完全够用。GC代价可接受很多人担心Go的GC会让数据库出现长尾延迟。实际测下来在实验规模下GC造成的抖动远小于网络分区和磁盘fsync带来的时间波动。分布式系统的主要矛盾是“协调成本”不是那几十微秒GC停顿。我也考虑过用C实现好处是可控性极强但坏处是你得从内存分配、锁、异常安全一路自己打理。对一个想验证分布式架构的人来说C会分散一半精力。Go的工程质量下限比较高适合快速验证“架构是否成立”。1.3 项目边界不重造TiDB只验证核心闭环这个项目最容易犯的错误是“什么都想要”。我想要SQL、想要复杂事务、想要跨地域容灾那短期内什么都会烂尾。我给自己划了几条硬边界功能实验版本生产系统差距数据模型KV Range Scan需要列、索引、统计信息事务单分片原子性 简单2PCPercolator/Spanner级别事务存储内存Map WALLSM Tree / BTree共识自研Mini Raftetcd/Raft、Multi-Raft工具链手动测试脚本完备的监控、运维、迁移工具边界清晰之后我反而更容易专注。事实证明光把一个“内存版Mini Raft”跑对就已经比想象中困难了。分布式系统最大的幻觉就是“看起来只剩最后一步”实际上最后一步永无止境。2. 架构设计先把数据流动想清楚2.1 集群拓扑与角色定位整个集群分三层节点角色分别是Proxy接入层、Meta元数据层、DataNode数据层。Proxy层接收客户端读写请求根据Key路由到对应的分片再把请求发给对应Raft组的Leader。Proxy本身无状态可以水平扩展。Meta层维护“分片ID到节点组”的映射关系以及分片Range表。我的实验版本里Meta用了一个简化版Raft做主备实际生产系统还需要考虑头节点性能。DataNode层每个DataNode内部可以承载多个Raft组每个Raft组负责若干分片。每个分片默认3副本防止单点故障。我在画架构图时特别注意了“谁拥有决策权”这个问题。以分片迁移为例如果我让Meta直接控制数据搬迁那么Meta故障会影响所有迁移操作如果完全下放给数据节点又会出现两节点同时认为自己是分片Owner的情况。最终我采用“Meta负责元数据与指令下发DataNode负责执行数据拷贝和状态回报”的方式责任清晰调试也容易。2.2 分片策略Range分片和一致性哈希如何选分片策略直接决定后续的路由、扩容、Scan效率。主流方案是Range分片和一致性哈希我做了个对比维度Range分片一致性哈希数据分布按Key字典序按哈希值环状分布Scan支持友好连续Range天然适合难支持需要全表扫描热点问题顺序写入容易集中尾部能分散但可能出现哈希倾斜分裂迁移按Range分裂迁移局部环上迁移涉及多个节点实现复杂度中等较低但平衡复杂我在实验项目里选择的是Range分片 动态分裂。当时认为如果一个数据库丢掉Range Scan很多场景就失去了意义。顺序热点的处理可以靠“Key加盐”或“写入端做缓冲”来缓解但Range Scan丢了就得靠重写存储层才能救回来代价太高。每个分片在Meta里记录一个[start, end)区间区间不重叠并且全局有序。当某个分片数据量超过阈值比如64MBMeta会触发分裂生成两个子Range并把其中一个迁移到负载较低的节点。这个过程和TiKV的Region分裂思路一致只是我做的非常简略。2.3 一致性协议Raft落地的关键决策分片有了副本就一定存在“主副本写成功但从副本没收到”的情况。为了解决这个经典问题我给每个分片Raft组实现了Mini Raft协议。Raft协议本身相比Paxos更工程化核心是三个子问题Leader选举、日志复制、安全性。但落到代码里最麻烦的不是算法原理而是各种边界条件比如节点启动时日志不一致Leader如何处理Follower日志冲突选举超时范围不合理多个节点同时拉票造成活锁网络分区后旧Leader还在处理请求如何避免它提交过期日志我实现时严格遵守Raft论文里的规则只有Leader能追加日志日志只要被多数节点持久化就认为已提交已提交日志最终会被应用到状态机。然后通过Term和Index两个递增数列来保证日志顺序。遇到冲突时Leader会让Follower删掉冲突位置之后的日志再从自己的日志中补齐。我还做了一个关键决定读请求也走Leader并且使用ReadIndex机制。具体做法是Leader在返回读结果前确认自己仍是当前Term的Leader并且确认当前commitIndex已经追上了自己最新日志。如果不做这一步读请求可能会读到旧数据出现线性一致性问题。很多人在实现时都只关注写路径忽略了读一致性结果就会出现“数据明明写着成功了却查不到”的诡异问题。2.4 读写路径设计从Request到Apply我在代码里把一次写请求拆成了六个阶段Proxy根据Key定位分片。Proxy从Meta获取该分片Leader所在的DataNode地址。Proxy把请求发给Leader。Leader把写操作追加到本地Raft日志并并行发送给Follower。多数节点返回成功后Leader标记日志已提交。Leader将日志应用到状态机写入存储引擎并返回成功给客户端。这个流程中最重要的细节是“日志提交”和“日志应用”是两回事。Raft通过“多数节点持久化日志”保证安全而状态机应用只是时间问题。即使应用失败重启后也可以继续应用因此状态机本身必须设计成幂等的。读路径类似但只需要在Leader上等待ReadIndex确认然后读存储引擎并不需要把读操作写入日志。这个区别帮我在保证一致性的同时避免了读操作把写日志撑爆。3. 关键模块代码实现3.1 存储引擎先用内存Map把正确性跑通第一版存储引擎我直接用了内存Map加WAL原因是写一套LSM Tree代价太大会拖慢分布式部分的进度。等分布式逻辑验证完了再把存储层换成可插拔的LSM实现。存储接口我定义得特别简单type Engine interface { Put(key, value []byte) error Get(key []byte) ([]byte, error) Delete(key []byte) error Sync() error }底层实现就是一把大锁保护内存Map写操作先追加到WAL再更新内存。WAL文件做成了追加写文本行的格式type Op struct { Type string json:type Key []byte json:key Value []byte json:value,omitempty } type WAL struct { mu sync.Mutex f *os.File } func (w *WAL) Append(op Op) error { data, err : json.Marshal(op) if err ! nil { return err } w.mu.Lock() defer w.mu.Unlock() if _, err : w.f.Write(append(data, \n)); err ! nil { return err } return nil } func (w *WAL) Sync() error { return w.f.Sync() }注意这里有一个很容易踩的坑写入文件不代表数据已经持久化。操作系统通常把数据放在页缓存里掉电会丢。必须调用fsyncGo里的File.Sync才真正落盘。但每次写都Sync会非常慢后面我会讲如何做Group Commit。WAL恢复的逻辑是在启动时逐行读取日志重新应用一遍。因为操作本身是幂等的直接Put和Delete即可。生产级存储还要考虑CRC校验、日志压缩、分段回放这里我从简。3.2 分片路由一个可排序的Key Range表路由模块负责把一个Key映射到对应的分片ID。这个结构我认为是全项目里最好理解、也最容易出边界bug的地方。我的实现用了一个有序数组保存每个分片的Range上界type RangeTable struct { ranges [][]byte // 每个分片的上界升序排列 shardIDs []uint64 // 与ranges一一对应的分片ID } func (t *RangeTable) Route(key []byte) uint64 { // 找到第一个大于key的上界 idx : sort.Search(len(t.ranges), func(i int) bool { return bytes.Compare(key, t.ranges[i]) 0 }) // idx 0 表示key小于第一个上界属于第一个分片 // idx len(t.ranges) 表示key大于所有上界理论上不应该出现 if idx len(t.ranges) { panic(key out of range) } return t.shardIDs[idx] }看起来简单但边界条件非常多。尤其当Key刚好等于某个分片的上界时它应该归属下一个分片。sort.Search要求“第一个大于Key的上界”所以等于上界时仍归属前一个分片这个逻辑得反复核对。我在测试时写了一个生成随机Key的模糊测试才把这个边界查干净。Meta层除了保存Range表还要定期心跳检查每个分片副本的健康状态。一旦发现Leader宕机Raft组内会触发新一轮选举选举完成后Meta需要把最新的Leader地址同步给Proxy节点。这里我遇到过的最严重问题就是“新Leader选出来了但Proxy还在往旧Leader发请求”后面在排查实录里细说。3.3 Raft最小实现选举、心跳、日志复制我们不能把raft-word全部写出来但最核心的Raft节点状态机是这样的type RaftNode struct { mu sync.Mutex id int peers []string state int // 0follower, 1candidate, 2leader term uint64 votedFor int log []LogEntry commitIndex uint64 lastApplied uint64 electionDeadline time.Time } type LogEntry struct { Term uint64 Index uint64 Op Op }每一轮tick函数要做的事分别是Leader向所有Follower发送心跳心跳实际是携带空日志的AppendEntries请求。Follower检查选举超时如果超时了Term递增转成Candidate然后向其他节点发送RequestVote。在实现选举时有一个关键参数选举超时必须随机化。如果所有Follower都用相同固定超时它们会同时变成Candidate同时投票结果谁都拿不到多数票一直重复选举。我的实验做法是让选举超时落在400到700毫秒之间并加入少量随机抖动实测下来选主速度稳定在几百毫秒内。日志复制是最爽的部分也是最容易出错的部分。Leader收到客户端命令后追加一条日志然后并行发送AppendEntries给所有Follower并等待多数节点确认。Follower收到日志后要先对比PrevLogTerm和PrevLogIndex确保日志在冲突位置之前是一致的不一致就拒绝。Leader收到拒绝后把NextIndex回退继续重试。这个逻辑本质上是一个“向前对齐”的过程看起来没什么花花肠子但高并发下不锁好就会数据竞争。我几乎每一步访问共享字段时都用了n.mu.Lock()但还是在一个地方漏了运行时用go test -race才抓出来。3.4 极简事务协调器能跑但不是花架子跨分片事务我用了最朴素的**两阶段提交2PC**思路。事务协调器收到客户端的批量操作后先给所有涉及分片发送Prepare请求每个分片把写操作暂存到本地临时缓冲区不提交当所有分片都返回Prepare成功协调器再发送Commit请求分片才正式应用。状态用常量表示const ( TxnStatusPrepare iota TxnStatusCommit TxnStatusAbort )这个实现最大的坑在于Prepare成功后如果协调器崩溃事务会卡在中间状态。所以需要有一个事务表记录状态并在协调器恢复后主动询问所有参与者或者依赖超时机制强制回滚。实验版本里我用了“超时未Commit则自动Abort”的兜底策略牺牲了一部分严格性但对验证流程来说够了。如果真要支持高并发事务我建议后续迁移到Percolator模型通过时间戳和MVCC避免2PC的阻塞问题。但那是另一个大工程了。4. 实操过程中踩过的坑和排查实录4.1 没有请求幂等重复提交把数据写了两遍这是分布式系统最容易出现的bug。客户端发送写请求Leader已经写入了日志但响应超时客户端重试导致同一条操作被应用两次。解决方式很简单每个客户端请求带上全局唯一的请求ID状态机里记录最近处理过的请求ID。重复请求直接返回上一次的结果不再应用。这个幂等设计一定要放在“应用日志”那一步而不是放在“接收请求”那一步因为日志复制恢复后也需要幂等。4.2 Raft选主时锁没拿好直接数据竞争一开始我是通过几个独立goroutine各自读取term和votedFor结果用go test -race一跑满屏的race warning。因为一个goroutine在election里改term另一个goroutine在接受AppendEntries时读term数据竞争导致选主结果完全随机。修复方法就是所有对Raft共享状态的访问全走同一把sync.Mutex。简单粗暴但有效。后来我把tick、消息处理、日志追加都收敛到同一个控制循环里状态机反而清晰很多。这也是为什么很多Raft实现采用“单线程事件循环”模型因为它从根上消灭了并发访问问题。4.3 fsync太勤写入吞吐被打回原形最初我实现是一写就立即Sync()正确性完全没问题但单分片写入延迟被拖到几十毫秒吞吐量根本起不来。后来我做了Group Commit短暂收集一批并发写请求把日志一起写入WAL然后只调用一次Sync()之后所有请求统一回复成功。这个改动让批量场景下的吞吐提升了将近一个数量级而且在重启恢复时没有任何问题因为WAL本身就是一批日志连续追加的。4.4 慢Follower把Leader拖垮当某个Follower磁盘或者网络变慢时Leader的重试机制会不断往它发送积压的日志最终 Leader的发送队列内存膨胀所有写请求都被拖住。我的处理办法是给每条复制流水线加了一个最大并发窗口如果Follower确认进度落后超过窗口上限Leader就停止给它发送更多日志转而等待它追到窗口内。同时Leader不阻塞主流程只要多数节点确认就可以提交。很多人一上来不考虑慢节点测试环境一切正常线上偶尔一个抖动的Follower就能让整个集群出现雪崩这个教训非常深刻。4.5 问题排查速查表现象可能原因排查建议选主迟迟不结束选举超时范围固定节点同时超时加入随机抖动检查网络分区写入超时Follower进度落后太多检查发送窗口看磁盘占用读到了旧数据读请求走了非Leader节点且无ReadIndex统一读Leader或实现Lease出现重复写入缺少请求幂等表状态机记录已应用RequestID数据竞争Raft状态未加锁多goroutine共享字段跑go test -race统一锁重启后数据丢失WAL未调用fsync确认每次提交都Sync或Group Commit后Sync5. 后续还能怎么扩展5.1 替换存储引擎从Map到LSM Tree内存Map做原型很快但数据量一上来就完全不可用。真正要长期跑我会把存储层替换成LSM Tree或者直接嵌入Badger/BoltDB。Raft状态机和存储引擎之间保持接口隔离替换时只需要重写Engine接口的实现日志复制部分不用动。这个接口隔离的收益在第一次替换存储时就能体会到。5.2 引入Multi-Raft与更细致的流控这里实现的Raft是每个分片一个实例但同一台机器上如果有几十个分片每个分片单独跑一个Raft实例就会有很多线程和网络连接。生产级系统通常会做Multi-Raft用更少的长连接承载多条Raft流并做带宽公平调度。这个方向需要比较大的重构我目前还在设计阶段。5.3 备份恢复与监控分布式数据库的备份不能只备份某一个节点的数据必须保证备份的是某个一致性的快照。最简单的做法是让Raft状态机支持Snapshot定期把当前状态写一份快照同时清掉旧的WAL日志。恢复的时候先加载快照再重放快照之后的增量日志这样启动速度会快很多。监控方面至少要暴露三个核心指标Raft Term变化次数、日志提交延迟、Leader切换次数这三项能一次性反映集群健康的程度。我在实际做这个项目时有一个很深的体会不要低估网络和故障处理的复杂度。单机版本的所有bug都可以依赖堆栈和日志快速定位分布式版本里最难的bug往往是重现不出来的。你只能靠协议约束、幂等设计、以及严格的日志记录来兜底。Go语言在这个场景下给了我足够快的迭代速度和干净的并发模型它让我能把精力放在“系统如何决策”而不是“内存如何管理”上。如果你也想做类似的练习我的建议是先跑一个最小闭环Master、两个数据节点、一个客户端写一个Key然后宕机一台节点看数据能不能继续读到。这个过程走通了分布式数据库的骨架你已经搭好了。剩下的都是在这个骨架上填肉。
返回列表