自研基于Raft的分布式KV存储:架构设计与核心机制解析
我在去年年初启动了一个确实有点自找麻烦的项目从零实现一个基于 Raft 协议的高性能分布式 KV 存储系统。当时团队里有人劝我直接用 etcd也有人建议在 TiKV 上做二次开发但最后我坚持走自研这条路到现在这套系统已经在我们两套独立环境里稳定跑了小半年解决了不少现成组件没法替代的痛点。这篇文章是系列的第一篇我会先把全局讲清楚为什么放着 etcd 不用非要自己写、整套系统的架构设计、Raft 里最核心的选主与日志复制机制以及第一个可运行版本的代码骨架长什么样。适合两类读者一类是想把 Raft 彻底搞懂、看论文又总觉得隔着一层纸的人另一类是正纠结要不要自研分布式存储组件、想看看别人工程决策过程的人。1. 为什么放着 etcd 不用偏要自研一套 Raft KV每次跟人聊起这个项目第一个问题永远是etcd 不香吗。香真香但香不代表适合所有场景。我先把动机摆出来大家再对照自己的处境判断。1.1 自研的真实动机不是秀肌肉而是被需求逼的我们业务里有一个分布式协调层需要保存一些元数据比如任务调度状态、配置版本、分片路由表。这些数据的特征很明确量不大几十 GB 以内、写入有一定频率、但绝对不能丢、不能读到旧值。最早用的是 MySQL 主从加半同步复制但机房断网演练时主备切换丢数据的问题一直没根治后来才意识到这种强一致场景必须上共识协议。但直接用 etcd 有几个绕不过去的坑。etcd 默认内置了它自己的存储引擎 bolt底层是 B 树这个引擎在小数据量下没问题但我们对存储层有定制需求——希望把热数据放在内存里批量刷盘并且能对不同的 key 前缀走不同的存储策略。etcd 的存储层虽然也能改但改完基本就是 fork 整个项目了上游更新全废掉。另外我们部署环境有些是低配边缘节点etcd 的默认配置在这些机器上跑起来很吃力最小化裁减又怕改出隐藏问题。所以当时定了一个原则如果是 CRUD 业务打死不用自研但如果是存储引擎要定制 部署环境受限 需要深度掌控一致性边界这种组合自研 Raft 反而是一条走得通的路。1.2 什么场景才值得自研什么场景应该直接上现成方案我见过太多人一听到自研分布式 KV就热血上涌结果三个月后项目烂尾。说实话Raft 本身并不难理解难的是把它做成一个能抗故障、能保证数据不丢、性能还能看的系统。我列一个对照表方便大家决策场景建议方案理由需要强一致的配置中心etcd / Consul成熟、API 完善、运维生态好大规模分布式数据库底座TiKV / CockroachDB分布式事务、多副本、自动均衡已经做好轻量级分布式缓存Redis Cluster 哨兵最终一致性够用吞吐高存储引擎需要深度定制自研 Raft 自选引擎etcd/TiKV 的引擎层替换成本极高边缘/内网隔离环境部署自研或裁剪现成组件体积和依赖不适合想彻底搞懂共识机制自研学习版跑通一个简化 Raft 是最好课程关键判断点是你需要的到底是一个分布式 KV还是一个你能改得动的分布式 KV。前者买现成的后者才考虑自研。我们属于后者——存储引擎定制、协议裁剪、部署形态都和标准场景有差异。1.3 自研之前必须想清楚的三件事第一一致性模型。Raft 提供的是线性一致性但 KV 系统为了性能通常会把读路径拆成强一致读和本地读这两种读的语义完全不同接口设计上必须一开始就区分清楚。许多自研项目死在读接口到底要不要走 Raft 日志这种灵魂拷问上。第二性能目标。Raft 的写入瓶颈几乎是固定的Leader 收到请求后必须把日志刷盘然后同步到多数派节点再异步通知提交。这个路径决定了单组 Raft 的 QPS 天花板通常就几千到几万取决于 fsync 频率和网络 RTT。你定的性能目标直接决定了要不要做批量、pipeline、异步 apply 这些优化架构上会差非常多。第三运维复杂度。自研系统意味着监控、告警、恢复工具全部自己来。我们光是做节点级健康检查、慢日志告警、日志滞后检测就花了两周。如果团队没有运维人力劝退。2. 整体架构定稿一条写请求从客户端到落盘的完整路径架构设计我前后推翻过三版最后定下来的是一个很清晰的分层模型。这一节把核心路径讲透后续文章里实现的每行代码都能在这张图上找到位置。2.1 系统整体模块视图整个系统分为五层从外到内分别是客户端接入层负责协议解析、请求路由、错误码映射。客户端连接任何一个节点写请求如果落到非 Leader 节点会被转发或拒绝我们第一版选择转发性能上略亏但客户端逻辑简单。Raft 共识层这是核心管理角色状态、任期、日志、选主、复制、提交。这一层不感知业务含义只负责把顺序确定下来。状态机应用层把 Raft commit 的日志按顺序 apply 到底层存储处理 KV 语义Put/Delete/Scan生成响应返回给客户端。存储引擎层封装 RocksDB、元数据管理、快照。状态机数据、Raft 元数据、快照元数据分开列族存储。网络与传输层节点间 RPC 复用、心跳管理、故障检测。分层原则只有一个Raft 层绝对不能知道KV是什么。它处理的只是Command []byte具体命令是 Put 还是 Delete 是状态机层的事。这个边界守住了未来再加分布式事务、多 Raft group 都会容易很多。2.2 写请求的主路径拆解一次写请求走完的路径如下客户端从任意节点发起写请求如果目标节点不是 Leader它把请求转发给当前已知 Leader。Leader 首先把命令封装成 LogEntry带 index 和 term追加到自己的日志内存区同时调用存储层把这条日志持久化fsync 到磁盘。Leader 批量或逐条向所有 Follower 发送 AppendEntries RPCFollower 收到后做一致性检查并追加日志同样持久化然后回 ACK。Leader 收到多数派含自己的 ACK 后更新 commitIndex标记该日志为已提交。Leader 把已提交的日志交给状态机 apply写入 RocksDB执行真正的 KV 修改。apply 完成后Leader 把结果返回给客户端同时在下一次心跳或后续 AppendEntries 时把 commitIndex 带给 Follower。Follower 发现 commitIndex 推进后也逐条 apply 日志保证状态机一致。这里有一个大家初学时容易踩的误区不要在收到 ACK 那一刻就立刻返回客户端写入成功。严格来说提交和 apply 是两个阶段返回客户端成功应该在 apply 之后否则客户端刚拿到成功、Leader 就崩溃数据可能在另一个节点上还没 apply客户端去读其他节点会读到旧值。我们第一版为了性能做过提交就返回后来被一致性测试狠狠打脸。2.3 读请求的两条路径强一致读与性能读读请求我拆成两种接口上用ReadMode区分StrongRead强一致读请求直接发给 LeaderLeader 先确认自己仍是当前任期 Leader通过心跳维持的 lease然后返回本地状态机的最新值。严格线性一致读要求做了 read index 校验即 Leader 需要和多数派确认自己的日志至少追平了提交点再执行读防止网络分区后的孤儿 Leader 返回脏数据。LocalRead本地读任何节点都可以直接读自己的状态机性能最高但可能读到略微滞后的数据。适合缓存、非关键状态等场景。这个设计微妙的地方在于Raft 的日志机制管的是写入顺序读一致性必须靠 read index 或 lease 机制补齐。第一版为了赶进度把 LocalRead 当默认读结果业务方反馈读到已删除后复活的配置赶紧补上了 StrongRead。2.4 高性能在 Raft 系统里到底意味着什么很多人自研 Raft 时一上来就搞各种花活其实 Raft 系统的性能瓶颈非常固定日志持久化的 fsync 和网络往返。我们做了三个核心优化批量写入把多个客户端请求合并成一个 LogEntry 数组追加到日志里一次 fsync 刷多条命令吞吐直接涨了一个数量级。Pipeline 复制Leader 不必等每条 AppendEntries 的 ACK 再发下一条可以连续发送、批量处理 ACK配合滑动窗口控制积压。异步 applycommit 后先给客户端返回已成功然后异步批量 apply 到 RocksDB。注意这个优化需要谨慎我们只在可容忍微弱窗口的场景开启默认还是同步 apply。性能数据方面三节点同机房RTT 约 0.3ms、普通 SSD、批量 128 条我们的写入 QPS 能到 2.8w 左右P99 延迟约 8ms单条逐写模式 QPS 掉到 4000P99 约 12ms。这个数字不算顶尖但对我们的业务场景完全够用。3. Raft 协议核心机制拆解选主、日志复制与安全机制一个都不能少写代码之前我花了整整两周把 Raft 论文刷了三遍又把 etcd 的 raft 库源码拉下来反复读。结论是Raft 它聪明在把复杂问题拆成了几个可以独立验证的小机制但真正正确跑起来必须吃透机制与机制之间的咬合关系。3.1 三个角色、一个任期与两类 RPCRaft 的节点只有三种状态Leader、Follower、Candidate。状态之间通过任期Term作为逻辑时钟来划分代际。任期单调递增每个任期最多只有一次成功的选主如果选举分裂则这个任期不会有 Leader。两类 RPC 是全部通信的载体RequestVote选举时 Candidate 发起包含 candidateId、term、lastLogIndex、lastLogTerm。AppendEntriesLeader 发给 Follower 的日志复制请求也用作心跳。包含 term、leaderId、prevLogIndex、prevLogTerm、entries、leaderCommit。理解 Raft 的一个关键技巧是所有 RPC 里都带着 term接收方如果发现 RPC 里的 term 比自己当前 term 小直接拒绝如果比自己大立即更新自己的 term 并切换成 Follower。这个规则让谁更先进永远能被正确识别。3.2 选主过程随机超时、Quorum 与 PreVote选主流程是这样走的Follower 超过选举超时时间没收到 Leader 的心跳转换为 Candidate。Candidate 把当前 term 加一给自己投票然后向所有对端节点发送 RequestVote。每个节点在同一任期只能投一票投票前要检查对方的日志是否比自己更新选举限制后面详讲。如果 Candidate 收到超过一半节点的投票含自己成为新 Leader开始发心跳。如果选举超时到期还没有获得多数票进入下一轮 term重新发起选举。这里有个著名的坑不可能把选举超时设成固定值否则所有 Follower 同时超时、同时发起选举互相争票又都拿不到多数形成活锁。论文建议是 150ms~300ms 之间随机化我们实际用的是 200ms 基准加 0~150ms 随机抖动。PreVote 机制是工程上的重要补丁。它解决的是网络分区后重新合并导致的问题一个落后的节点如果重新加入集群直接加 term 发起选举会干扰当前正常工作的 Leader。PreVote 的思路是 Candidate 先不带真实 term 增长地向大家问一声如果我现在竞选你们会投我吗得到多数认可后才真正进入选举。这个机制虽然不违反论文语义但对生产环境稳定性帮助极大etcd 和 TiKV 都默认开启。3.3 日志复制AppendEntries 如何保证不丢不乱Leader 维护每个 Follower 的nextIndex和matchIndex。日志复制的核心是状态对齐AppendEntries 请求里携带prevLogIndex和prevLogTerm它表示你要检查你这个位置上的日志是否跟我一致一致才允许追加我带来的后面这些日志。Follower 如果在prevLogIndex处找不到对应 term 的日志直接返回失败Leader 就把nextIndex回退一格继续重试直到找到共同前缀。找到共同前缀后Follower 会把后续冲突的日志全部删除改写为 Leader 发来的日志。这就是 Raft 的强 leader 原则以 Leader 的日志为准Follower 允许被覆盖。这一块的代码 bug 最容易出现在回退 批量的组合上。如果你一次发送从nextIndex开始的 100 条日志而 Follower 在prevLogIndex处就不匹配回退一格就要重新组装请求。我们第一版优化可以一次回退多个术语利用每段日志 term 单调的特点做指数回退后来发现实现复杂度飙升回退带来的性能收益远不如批量来得实在就保持了最简单的逐条回退实测没问题。3.4 安全机制的三道锁选举限制、日志匹配、当前任期提交Raft 的正确性靠三个安全机制兜底任何一条被突破整个系统就废了第一道是选举限制Candidate 必须包含全部已提交日志才能当选。requestVote 里携带lastLogIndex和lastLogTerm投票时比较谁的日志更新。更新的规则简洁最后一条日志的 term 不同则 term 大的赢term 相同则 index 大的赢。这个规则保证已经提交的日志永远不会被新的 Leader 覆盖。第二道是日志匹配两点要求。一是不同节点日志中某条相同 indexterm 的日志内容一定相同因为 Leader 只会下发一种命令二是前面所有日志也相同通过共同前缀保证。这是日志一致性的理论基础。第三道是当前任期提交规则这个特别容易忽略。Leader 不能单纯因为一条旧 term 的日志复制到了多数派就提交它因为此时可能有一个日志更新的 Candidate 冒出来推翻它。解决方法是Leader 只有通过提交自己当前 term 的日志才能顺带确认前面所有日志都已经被安全提交。这也是提交一条空命令no-op快速恢复提交能力这个常见 hack 的原理。3.5 持久化设计你只需要稳定保存三样东西Raft 的正确性依赖持久化但好消息是只需要保存三样currentTerm当前任期、votedFor当前任期投给谁、log entries全部日志。commitIndex 和 lastApplied 反而不需要持久化因为重启后可以从日志重新推导commitIndex 归零lastApplied 归零通过日志副本和心跳重新同步。在工程实现上我把这三样东西分别存到 RocksDB 的元数据列族和日志列族里。每次追加日志时用 WriteBatch 把日志和元数据一起刷盘保证原子性。这里有个细节currentTerm 和 votedFor 的更新频率极低但每次都必须 fsync日志可以批量写但一次 fsync 内如果既有日志又有 term 变更必须保证它们要么都落盘要么都不落盘否则重启后可能投了票但没记下来破坏选举安全性。4. 存储层选型与数据模型为什么第一版就锁定 RocksDBRaft 层只负责日志的顺序和复制真正存 KV 数据的是底部存储引擎。这一层的选择直接决定系统的性能上限和运维复杂度。4.1 纯内存 KV 的问题以及常见存储引擎对比不少自研教学项目把数据全放内存这在我们场景里行不通数据量几十 GB机器故障重启后如果要从 Raft 日志全量回放恢复时间可能长达几分钟而且内存泄漏/膨胀在长时间运行时几乎无法避免。RocksDB 的优势是成熟、可靠、性能好社区里大部分分布式数据库底层都在用LevelDB 虽然更轻但 bug 修复停滞性能也已经落后纯自研 LSM 树听起来很酷但那是另一个量级的坑。方案优点缺点适用场景纯内存 日志回放实现简单、读写极快启动恢复慢、内存上限教学 DemoLevelDB轻量、稳定维护停滞、只支持单列族中小型项目RocksDB功能全、社区活跃、列族/事务/快照依赖较重、调参复杂生产级存储底座自研 LSM / B 树可控性最强工作量巨大有时间有资源再上我最终选了 RocksDB其实不是因为它性能最极致而是因为它把最麻烦的磁盘数据结构维护做好了我们只需要关心 KV 语义和列族布局。gorocksdb 这个绑定库在 Go 生态里算够用配合 cgo 调用性能损耗在可接受范围内。4.2 KV 数据如何映射到 RocksDB我用了四个 ColumnFamilydefault列族真正的用户数据key 是用户原始 key 的字节串value 是同样的字节串不做编码转换。meta列族Raft 元数据和系统状态例如currentTerm、votedFor、commitIndex、lastApplied。log列族Raft 日志key 编码为uint64的日志 indexvalue 是序列化后的 LogEntry。snapshot列族快照的元数据记录快照包含到的日志 index 和 term以及生成时间。ColumnFamily 分离的好处是日志列族可以单独配置 compaction 策略快照列族可以设白名单避免日常读放大影响数据列族。这个设计其实是在写第二版时才加的第一版用单一列族导致日志和数据混在一起compaction 时互相拖累性能后来很痛地拆开了。4.3 存储引擎接口抽象把日志和状态机彻底分开存储层暴露给上层的是一个很薄的接口Put(key, value)/Delete(key)/Get(key)/Scan(start, end)AppendLog(index, entry)/ReadLog(index)/DeleteLogRange(start, end)Snapshot()/ApplySnapshot(snap)为什么要刻意分开AppendLog和Put因为 Raft 层只调日志接口状态机层只调 KV 接口二者在物理上处在不同列族逻辑上属于不同模块。如果混用后面做日志压缩、快照时就会扯不清到底这个 key 是状态机数据还是日志数据顺序 commit 和应用的关系也会变乱。我在代码评审时反复要求团队成员不许跨模块调用哪怕临时方便也不行。存储引擎是唯一允许直接访问磁盘的地方Raft 和状态机都必须通过接口操作它这个纪律保持下来后后面引入快照和日志裁剪时只改了存储引擎内部没有动上层一行 Raft 逻辑。5. 第一个可运行版本选主与日志复制的 Go 代码骨架第一版里程碑目标定得很小三节点能选出 LeaderLeader 能接受写入并把日志复制到所有节点杀掉 Leader 后能选出新 Leader 且已提交的数据不丢。这个目标里刻意先不管读一致性、快照、成员变更先把共识的核心转起来。5.1 技术栈与依赖清单语言Go 1.22。选它的理由是 goroutine 天然适合 Raft 这类强并发模型标准库net/http 也能快速起服务加上我们团队 Go 功底最好。节点间通信gRPC Protocol Buffers。第一版用 gRPC 是为了开发快后面如果要极致性能再考虑换 QUIC 或自研协议。存储gorocksdb 绑定 RocksDB 7.x。配置与日志YAML zap日志统一进标准 error 流便于容器采集。测试go test 自研的 chaos 脚本随机 kill、随机分区。5.2 必要的核心数据结构定义我先把核心的 Raft 状态用 Go 结构体定义清楚。注意这里没有用复杂的并发库所有状态访问都通过一个raftgoroutine 串行化这是 Raft 实现中最容易犯的错误——到处加锁最后死锁和 data race 满天飞。type LogEntry struct { Index uint64 Term uint64 Command []byte } type RaftPersistentState struct { CurrentTerm uint64 VotedFor uint64 Log []LogEntry } type RaftVolatileState struct { CommitIndex uint64 LastApplied uint64 LeaderID uint64 } type FollowerProgress struct { NextIndex uint64 MatchIndex uint64 }关键设计决策Log []LogEntry在内存里维护追加式切片并定期压缩。Raft 日志的读写模式几乎永远是顺序追加和按 index 随机读用切片比 map 的 cache 友好得多。压缩时截断前半段配合快照把已提交并应用的部分定期固化。5.3 选举超时与心跳的调度模型调度模型我踩了很多坑最终采用了两个 cycle 的 tick 驱动func (r *Raft) tickLoop() { ticker : time.NewTicker(100 * time.Millisecond) for { select { case -ticker.C: r.tick() } } }每次 tick 后如果是 Follower/Candidate 就检查选举超时随机在 200~350ms 之间超时了发起选举如果是 Leader 就检查是否到了心跳间隔100ms。特别提示选举超时必须是从上一次收到心跳算起而不是固定周期否则广播风暴会一波接一波。我们第一版用 time.Timer 每次超时重置后来改成 tick 计数原因是 Timer 的绝对时间戳在系统时钟跳变时会出问题。发送心跳和日志复制的循环则独立跑func (r *Raft) sendAppendLoop() { if r.State ! StateLeader { return } for peer : range r.Peers { go r.replicateToPeer(peer) } }每个 peer 单独一个 goroutine 发送日志和心跳通过 channel 控制退出Leader 的日志追加永远在raftgoroutine 里进行避免 data race。5.4 日志复制与提交的代码骨架日志附加与提交的核心伪代码如下注意它并不涉及“KV”只是把 Command 当作不透明字节流func (r *Raft) appendAndReplicate(cmd []byte) uint64 { r.mu.RLock() lastIndex : r.store.LastLogIndex() r.mu.RUnlock() entry : LogEntry{ Index: lastIndex 1, Term: r.persistentState.CurrentTerm, Command: cmd, } r.store.AppendLog(entry) for peer : range r.Peers { r.sendAppendEntries(peer, false) // 非心跳模式带entries } return entry.Index }AppendEntries handler 最关键的就是一致性检查func (r *Raft) handleAppendEntries(req *pb.AppendEntriesRequest) { if req.Term r.persistentState.CurrentTerm { return // 过期请求拒绝 } if req.Term r.persistentState.CurrentTerm { r.persistentState.CurrentTerm req.Term r.persistentState.VotedFor 0 r.State StateFollower } last : r.store.LastLogIndex() if req.PrevLogIndex last || r.store.TermAt(req.PrevLogIndex) ! req.PrevLogTerm { r.sendAppendEntriesResponse(false) // 日志不匹配拒绝 return } // 删除冲突日志追加新日志 r.store.TruncateFrom(req.PrevLogIndex 1) for _, e : range req.Entries { r.store.AppendLog(e) } // 更新 commitIndex if req.LeaderCommit r.volatileState.CommitIndex { r.volatileState.CommitIndex min(req.LeaderCommit, r.store.LastLogIndex()) } r.sendAppendEntriesResponse(true) }这里有个非常隐蔽的坑TruncateFrom必须发生在追加之前而且这两步必须对上层表现为原子操作同一 WriteBatch 或加锁串行否则一个并发读可能读到一半冲突日志一半新日志。5.5 第一个里程碑实测结果我用 docker-compose 起了三个节点node1、node2、node3节点间网络模拟 0.3ms RTT单组 Raft、单分片。测试流程启动后发现 node1 在约 250ms 内被选为 Leader符合随机超时区间。用客户端工具连续写入 1000 个 key/value确认节点间日志 index 完全一致。kill 掉 node1Leader约 600ms 后 node2 成为新 Leader确认 1000 条已提交日志全部存在。将 node1 重新拉起它找新 Leader 补全日志最终三节点日志再次一致。数字虽然朴素但这是整个项目最踏实的一刻。从这一步开始后面所有快照、读优化、成员变更都有了可以依赖的基座。6. 首版踩坑记录与下一篇文章的规划系列第一篇收尾我把第一版开发中印象最深的几个坑拿出来说都是常规文档里不会写的东西。6.1 首版测试暴露的三个工程问题第一个问题是选举风暴。我最初把心跳间隔设为 500ms、选举超时基数为 800ms本以为能省资源结果只要 Leader 有一次 GC 卡顿所有 Follower 同时超时进入选举整组节点持续近一分钟无法恢复稳定状态。后来把心跳降到 100ms、选举超时降到 200msrandom配合 PreVote问题彻底消失。这个调整也证明了一个道理Raft 参数不是越大越稳而是在及时失败和避免噪声抖动之间找平衡。第二个问题是 fsync 导致的吞吐灾难。刚开始每次追加日志都立刻 fsync三节点下写入 QPS 只有几百。加了批量写128 条一组 batch和 Pipeline 复制后QPS 提升到 2.8w同时做了一个小验证批量写一旦失败整批重放不会出现半条日志落盘的中间状态。说白了fsync 这种重型操作一定要凑够量再打出去碎片化 fsync 是所有 Raft 性能问题的根源。第三个问题是网络分区后孤儿 Leader 还在服务写请求。第一版只在写路径上靠半数复制成功来判断但分区后的旧 Leader 发给少数派的请求也会得到成功响应此时客户端拿到成功但数据根本没有复制到多数派。后来补上了 read index 逻辑每个写请求在 append 前先做一次我是当前 Leader 吗的 quorum 确认RequestVote 滥用版本其实不可取正确做法是起一个独立的 read index probe。这个改动让写路径多了一次 RTT 开销但保证了一致性语义。6.2 系列后续线性一致读、快照与成员变更后面的文章我计划按这个路线展开第二篇线性一致读的实现方案read index、Lease 读以及它们对性能的影响。第三篇快照与日志压缩。日志无限增长是自研 Raft 躲不过的坎installSnapshot 如何设计、如何与日志复制衔接。第四篇成员变更。单节点加/减的简单场景先跑通再决定要不要做 joint consensus。第五篇多 Raft group 与 key 分片。这是我们真正上生产前的最后一块拼图涉及数据迁移和路由。这几块内容每块都不轻松但前面的地基已经打好后面就是按部就班推进的问题了。6.3 自研 Raft 半年的经验总结最后说一点个人体会。自研 Raft 系统最容易被低估的不是算法本身而是验证正确性的工程成本。Raft 的日志复制、选主这些机制看似清晰真正 bug 往往出现在边界场景网络超时和重试同时发生、日志回退与批量叠加、崩溃恢复时元数据不一致。我们的经验是从第一行代码开始就要搭好 fault injection 的测试框架把分区、丢包、进程崩溃、磁盘满这些坏情况当成一等场景去设计而不是等写完再补。如果你也在走这条自研之路我的建议是先追求可验证的正确再追求性能最后才追求功能丰富。把一致性测试套件写出来比任何性能优化都值得。下一步我会把第二篇里线性一致读的完整实现思路整理出来到时继续聊。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →