做MIT 6.824的Lab4A之前,我劝你先想清楚一个问题:ShardCtrler到底在解决什么。
很多人一上来就翻Raft论文、抄Lab3的代码,结果卡在测试过不去,回头问我“为啥我的配置服务老是被打爆”。其实Lab4A和前面的Lab2、Lab3有本质区别——它不是一个通用KV存储,而是一个配置管理服务。你不需要存用户的业务数据,你只需要维护一张“数据应该放在哪个分片”的表。想通这一点,整个Lab4A就轻松了。
这篇博客我把自己的实现思路、踩过的坑、还有排查问题的方法全部写出来。不管你是刚做完Lab3准备冲刺Lab4,还是卡在某个测试用例过不去,这篇都值得你花10分钟看完。
1. 整体设计:先搞懂ShardCtrler在分布式系统里的定位
1.1 它不是存储,是“总调度”
ShardCtrler的全称是Shard Controller,直译过来就是“分片控制器”。它的职责非常单纯:管理分片(Shard)与组(Group)的映射关系。
在6.824的Lab4里,整个系统拆成两层:
- 上层是ShardCtrler,维护一张全局配置表(Configuration),记录每个Shard(总共10个)分别属于哪个复制组(Replica Group)。
- 下层是若干ShardKV服务,每个服务负责一部分Shard的存储和读写。
ShardCtrler本身不存业务数据,它只回答一个问题:“key为xxx的数据,现在应该在哪个组里查?”
这样说可能有点抽象,我打个比方。你开了一家连锁超市,有10个品类(Shard)的商品,分布在5个仓库(Group)里。ShardCtrler就是总部的调度中心,它只负责告诉客人“洗发水在3号仓库,零食在5号仓库”。至于仓库里具体怎么摆货、怎么盘点,那是仓库自己的事。
理解了这层关系,你就能明白为什么Lab4A的接口这么简单——一共就4个RPC:Join、Leave、Move、Query。每个操作都是在修改或读取那张“品类-仓库”映射表。
1.2 为什么需要一致性和容错
既然ShardCtrler是“总调度”,那它绝对不能出错。如果两个客户端同时Join两个不同的组,最后配置表乱套了,整个集群的数据就全乱套了——客户端不知道该去哪个组读写数据,系统直接瘫痪。
所以ShardCtrler必须是一个强一致性的容错服务。这就是为什么Lab4A要求你复用Lab2/3的Raft库,而不是自己写一个简单的KV存储。
具体来说,ShardCtrler需要满足:
- 线性一致性:所有客户端看到的配置变更顺序完全一致,不存在“你先看到新配置、我先看到旧配置”的情况。
- 容错性:少数节点宕机不影响服务可用性,配置请求依然能正确处理。
- 持久性:配置信息不能丢,节点重启后能恢复。
这三点,恰好是Raft协议能提供的。所以Lab4A本质上不是让你从零写一个新服务,而是让你把Raft应用到一个新的业务场景中。
1.3 ShardCtrler与Lab4B的关系
还有一个容易迷糊的点:Lab4A的ShardCtrler和Lab4B的ShardKV是分开的,但它们在同一个大实验里配合使用。
- Lab4A:先实现配置管理服务,提供Join/Leave/Move/Query四个接口。
- Lab4B:实现真正的分片KV存储,每个ShardKV节点需要向ShardCtrler查询配置,并根据配置变化迁移分片数据。
所以Lab4A的质量直接影响Lab4B的难度。如果配置服务写得有bug,Lab4B做分片迁移时会非常痛苦——你根本分不清是配置错了还是迁移逻辑错了。我建议你在Lab4A阶段就把配置服务的正确性做扎实,后面能省很多事。
2. 四个核心RPC:Join/Leave/Move/Query的状态机
2.1 Join:往系统里加一组机器
Join的作用是往系统里加入新的复制组。请求参数是map[int][]string,key是组ID(GID),value是该组所有节点的地址列表。比如:
args := &shardctrler.JoinArgs{ Servers: map[int][]string{ 1: {"node1:1234", "node2:1234", "node3:1234"}, 2: {"node4:1234", "node5:1234", "node6:1234"}, }, }含义就是:组1有三台机器,组2有三台机器,把它们加入集群。
Join的处理逻辑分三步:
- 保留原有配置,把新组加入组列表。
- 把所有的Shard重新分配到各组,让负载尽量均衡。
- 配置版本号+1,生成新的配置。
这里的关键问题是:如何重新分配Shard才能做到负载均衡?
从6.824的任务书来看,它没有强制要求最优分配,只要“基本均衡”即可。但测试里有一个陷阱:新加入的组一开始可能没有分到任何Shard,而老组却忙得一塌糊涂。如果不做重新分配,测试大概率会失败。
我的策略比较简单粗暴:先把所有Shard抽出,按组从小到大排序,然后依次轮询分配。这种做法在Shard数量远大于组数量时效果很好,基本能做到每个组分到的Shard数量差不超过1。
2.2 Leave:把一组机器踢出去
Leave是Join的逆操作,请求参数是[]int,表示要移除的组ID列表。
处理逻辑:
- 把指定组从组列表中删除。
- 被删组的Shard全部“释放”出来。
- 把这些Shard重新分配给剩余组,保持负载均衡。
- 生成新配置。
这里有个容易漏的细节:如果请求删除的组ID本来就不存在,应该正常返回,不能报错。类似地,Join中如果加入的组ID已经存在,也应该幂等处理——多次执行相同操作,结果一致。
我踩过的坑是:Leave之后,剩余组数量可能是0。此时Shard没有地方可去,配置表应该是0个Shard映射到空组。这种情况在测试里会出现吗?会的。比如你只有一组,然后Leave了它,系统就空转了。逻辑上要保证这个边界情况不panic。
2.3 Move:手动指定某个Shard归属
Move的参数是Shard(分片ID)和GID(目标组ID),作用是强制把某个Shard分配给指定组。这个接口主要用于人工干预,比如你发现某个组负载太高了,手动挪走一个分片。
Move的处理逻辑非常简单:
- 检查GID是否存在于当前配置中。
- 如果存在,就把指定Shard分配给该GID。
- 如果GID不存在,维持现状(或者忽略该操作)。
- 生成新配置。
这里需要注意:Move不保证全局负载均衡,它就是个“手动指令”,优先级最高。所以它不需要重新计算其他Shard的分配,只改目标Shard即可。
2.4 Query:查询某个版本的配置
Query的参数是Num(配置版本号),返回值是那个版本的配置。
- 如果
Num为0或者负数,返回最新配置。 - 如果
Num指定了具体版本号,返回该版本的配置(如果存在)。
这个接口是Lab4B的ShardKV用来做分片迁移的关键。比如某个ShardKV节点发现自己缓存的是配置5,但ShardCtrler已经更新到配置8了,它就需要用Query拉取配置6、7、8,逐步比对哪些Shard被挪走了。
值得注意的一个细节:Query有可能请求一个还没生成的配置版本。比如客户端请求Num=10,但当前最新配置才到5。这种情况下应该怎么处理?
6.824任务书里没有明确规定,但常见做法是:如果能等到就等,不能等就返回错误或当前最新配置。实际上测试不会故意卡这种场景,你只要做到“如果配置存在就返回,不存在就返回最新版”就够了。
3. 代码架构:怎么把Raft包进ShardCtrler
3.1 核心数据结构定义
我的ShardCtrler结构体定义如下:
type ShardCtrler struct { mu sync.Mutex me int rf *raft.Raft applyCh chan raft.ApplyMsg configs []Config // 配置历史,configs[0]是初始配置 // 用于关联请求与响应 pending map[int]chan OpResult lastApplied int }这里的configs是整个服务的核心状态——每成功应用一次配置变更,就往configs尾部追加一个新配置。
pending是一个请求追踪表,用于处理“客户端提交命令给Raft,但Raft还没返回结果”的情况。每个客户端请求都会生成一个唯一的请求ID,存到pending里,等Raft应用这条命令后再通过channel唤醒等待的协程。
3.2 初始配置怎么定义
按照6.824的约定,configs[0]是初始配置,此时没有任何组,10个Shard也没有分配:
func makeInitialConfig() Config { return Config{ Num: 0, Shards: [NShards]int{}, Groups: map[int][]string{}, } }Num=0表示这是初始版本,Shards全0(但实际上因为有Groups为空,这个0没有实际意义),Groups为空。
初始化之后,每次配置变更都会基于上一个配置生成新配置,Num递增。
3.3 Join/Leave/Move的统一处理
我在实现时发现,与其为四个RPC写四套代码逻辑,不如抽象成一个通用接口。因为所有RPC的本质都是一样的:把参数封装成Op,提交给Raft,等Raft返回结果,再返回给客户端。
type Op struct { OpType string // "Join", "Leave", "Move", "Query" Servers map[int][]string GIDs []int Shard int GID int Num int ClientId int64 RequestId int64 }核心逻辑伪代码如下:
func (sc *ShardCtrler) handleCommand(op Op) Err { index, _, isLeader := sc.rf.Start(op) if !isLeader { return ErrWrongLeader } ch := make(chan OpResult, 1) sc.mu.Lock() sc.pending[index] = ch sc.mu.Unlock() select { case result := <-ch: return result.Err case <-time.After(500 * time.Millisecond): return ErrTimeout } }这里有个重要的点:pending以Raft的日志索引为key,而不是以请求ID为key。因为同一个索引位置只能有一个日志条目,用索引做key天然能区分不同的请求。
但是这里有一个并发问题:Raft的Start调用可能返回相同的索引(比如前一个leader的日志没有commit,新leader又提交了新命令到相同索引)。解决方案是:在应用goroutine里记录日志索引与请求ID的映射,只有两者都匹配才唤醒对应的channel。
3.4 应用goroutine怎么写
func (sc *ShardCtrler) applyLoop() { for msg := range sc.applyCh { if msg.CommandValid { op := msg.Command.(Op) var result OpResult switch op.OpType { case "Join": result = sc.applyJoin(op) case "Leave": result = sc.applyLeave(op) case "Move": result = sc.applyMove(op) case "Query": result = sc.applyQuery(op) } sc.mu.Lock() if ch, ok := sc.pending[msg.CommandIndex]; ok { ch <- result delete(sc.pending, msg.CommandIndex) } sc.mu.Unlock() } } }关于applyLoop,有三个细节值得注意:
第一,所有状态变更必须在applyLoop里完成,不能在RPC handler里直接改configs。因为只有经过Raft达成共识的日志条目才能修改状态,RPC handler直接改会导致节点间状态不一致。
第二,applyJoin/applyLeave/applyMove/applyQuery要保证幂等。Raft可能会重复apply同一条日志(这在极端情况下会发生),所以如果当前applied的索引已经大于等于这条日志的索引,就直接跳过操作,只返回对应配置。
第三,Query不需要修改configs,只需要读。但如果直接读取configs数组,会有并发安全问题——其他协程可能正在append新的Config。所以Query也要走Raft,保证读到是“当前共识状态”的配置。
3.5 幂等性与重复请求处理
客户端的RPC可能因为超时重发,如果服务端已经处理了第一次请求,第二次再来怎么办?
我给每个客户端分配的ClientId和递增的RequestId就能解决这个问题:
type Op struct { ... ClientId int64 RequestId int64 }在applyJoin/applyLeave/applyMove/applyQuery中,先检查ClientId和RequestId是否已经记录。如果已经处理过,直接返回上次的结果,不重复修改状态。
if op.RequestId <= sc.lastRequestId[op.ClientId] { return sc.pendingResult[op.ClientId] } sc.lastRequestId[op.ClientId] = op.RequestId这个优化非常实用。尤其是在Lab4A中,客户端可能会重试Join/Leave操作,如果没有幂等性保护,同一个请求被应用两次,配置版本就会多加一次,导致配置历史出现异常。
4. 负载均衡算法:再也不用手动分配Shard了
4.1 我用的Greedy算法
Lab4A对负载均衡的要求是:每个组分配的Shard数大致相同,最好能做到max - min <= 1。
我用的算法非常简单,效果也稳定:
- 统计当前配置
configs[last]中每个GID拥有的Shard数量。 - 如果是Join,把新组的Shard数初始化为0;如果是Leave,把被移除组的Shard数设为-1(表示不可分配)。
- 遍历所有Shard(0~9),如果某个Shard所属的组已经被移除,就把这个Shard放入“待分配”列表。
- 把组按Shard数从小到大排序,依次从“待分配”列表里拿Shard分配给它,同时更新该组的Shard计数。
- 如果“待分配”列表已经空了,但还有组明显偏少(比如新加入的组一个Shard都没有),则从Shard数最多的组里拿走一个Shard给最少的组。
这种算法的好处是代码量少、逻辑清晰、不会出现极端不平衡的情况。坏处是它不是最优的(比如在特定场景下可能会多迁移几个Shard)。但6.824测试不会检查迁移代价,只要最终配置均衡即可。
4.2 我从测试用例里学到的:Join后新组必须分到Shard
这个坑特别典型。假设当前配置有3个组(组1、组2、组3),每个组分3个Shard,还剩1个Shard轮空。你Join了一个新组(组4),如果按照“只重分配轮空的Shard”这种保守思路,组4大概率只分到1个Shard,而组1还有4个Shard。测试会认为这个配置不均衡,然后报错。
正确做法是:Join之后必须全量重分配,而不是只把新组塞进现有配置。因为“均衡”是全局属性,不是局部属性。
4.3 Leave后空组处理
另一个边界场景是:Leave之后,剩余组数量不足以分配全部10个Shard时,必然有一些Shard是未分配的。
我的做法是:如果Groups为空,所有Shard的映射全部置为0(表示无归属);如果Groups非空,尽量保证每个组数量均衡,多余的Shard分配给最后一个(或者最小的)组。
但这里有一个权衡:如果一组都没有,此时配置的Groups是空map还是nil map?建议统一初始化为map[int][]string{},避免Lab4B在做range遍历时nil map导致的panic。
5. 快照与日志压缩:为什么Lab4A必须处理它
5.1 不压缩日志会怎么样
ShardCtrler服务会持续接收Join/Leave/Move请求,每处理一个请求就往Raft日志里追加一条记录。如果不做日志压缩,Raft日志会无限增长,占用大量内存和磁盘空间,最终导致节点响应变慢、甚至OOM。
6.824的Lab3让你实现了Snapshot,Lab4A需要把快照机制集成进来。
5.2 快照应该保存哪些状态
对于ShardCtrler来说,需要持久化的状态包括:
configs数组(配置历史,至少保留到快照点)lastRequestId(客户端请求幂等表)pendingResult(客户端请求结果缓存)
快照的逻辑很简单:每隔一定日志条数(比如100条),就把这些状态序列化,交给Raft的Snapshot方法。
func (sc *ShardCtrler) takeSnapshot() { sc.mu.Lock() defer sc.mu.Unlock() if sc.lastApplied - sc.lastSnapshotIndex >= 100 { snapshot := sc.makeSnapshot() sc.rf.Snapshot(sc.lastApplied, snapshot) sc.lastSnapshotIndex = sc.lastApplied } }5.3 从快照恢复
节点重启后,需要从Raft的Snapshot中恢复状态。Raft在启动时会传递InstallSnapshot消息,你的代码需要在applyLoop中处理msg.SnapshotValid的情况。
if msg.SnapshotValid { sc.mu.Lock() sc.configs = msg.Snapshot.(*ShardCtrlerSnapshot).Configs sc.lastRequestId = msg.Snapshot.(*ShardCtrlerSnapshot).LastRequestId sc.lastApplied = msg.SnapshotIndex sc.mu.Unlock() }这里要注意:从快照恢复时,configs数组的历史版本会被截断。比如快照时已经到配置10了,恢复后只剩配置10和之后的配置。如果你的代码假设configs[0]永远是初始配置,这里就需要做调整——快照里的configs下标依然从0开始,但configs[0].Num已经是10了。
我在这里踩过一个大坑:在Lab4B里用Query(i)去查历史配置,结果发现ShardCtrler返回的Num和预期的对不上。后来才明白,快照之后历史配置被压缩了,不能再假设配置版本号和数组下标一一对应。
6. 多进程调试技巧:4个节点同时跑还不出错
6.1 为什么要手动启动多个进程
6.824的测试用的是testing框架,但如果你只是在IDE里跑一遍go test,很难观察到并发问题。我强烈建议你手动启动多个ShardCtrler进程,模拟真实的多节点环境。
具体操作是:
- 写一个简单的main函数,读取命令行参数(节点ID、端口等)。
- 启动3~5个进程,分别监听不同端口。
- 用一个客户端脚本轮流发Join/Leave/Move/Query请求。
- 观察每个节点的日志,确认它们的
configs是否一致。
6.2 一个小技巧:给日志加颜色
当多个节点同时在终端输出日志时,很难分辨哪个log来自哪个节点。我习惯给每个节点加一个颜色前缀,这样一眼就能看出问题出在哪个节点上。
const ( colorReset = "\033[0m" colorRed = "\033[31m" colorGreen = "\033[32m" colorYellow = "\033[33m" ) logger := log.New(os.Stdout, fmt.Sprintf("%s[节点%d]%s ", colorGreen, id, colorReset), log.LstdFlags)用颜色区分节点之后,排查Raft选举、Leader切换、日志复制的问题能快很多。
6.3 用raft-log的调试输出定位异常
Lab4A的绝大多数问题都出在Raft层,比如:
- 选举失败(一直只有一个节点在term递增)
- 日志提交卡住(客户端请求一直超时)
- Leader切换导致请求返回ErrWrongLeader
建议你在Raft代码里加详细的日志输出,包括:term变化、votedFor是谁、收到了谁的心跳、日志是否匹配等。不要怕日志刷屏,调试分布式系统就得靠这些细节。
6.4 定时任务、分布式锁这些热词怎么在这个实验里体现
网上搜Lab4A,经常会看到“分布式锁”、“定时任务重复执行”、“Redis分布式锁”这些热词。很多人疑惑,这些跟ShardCtrler有什么关系?
其实没什么直接关系。这些热词反映的是网友们在做类似分布式项目时遇到的通用痛点:
- 怎么保证多个服务的配置一致→ 用Raft共识,等价于分布式锁的“互斥访问”思想。
- 怎么防止定时任务重复执行导致配置重复增加→ 幂等性设计,等价于Redis锁里“value为当前日期”的去重逻辑。
- 怎么多开进程测试→ 与“Idea多开进程服务”是同一类操作需求。
所以如果你在网上搜索时被这些热词带偏,记住一点:Lab4A最核心的还是Raft共识 + 状态机应用,其他的都是次要的。
7. 测试实战:从零到全过的心路历程
7.1 测试用例逐项解读
6.824的Lab4A测试通常有4个测试函数:
TestBasic:最基本的Join/Leave/Query流程。TestMove:Move操作的正确性。TestConcurrent:并发环境下多个客户端同时发送请求。TestUnreliable:模拟网络故障、消息丢失的环境。
前两个相对简单,主要检查功能正确性。后两个是重灾区,很多人在并发和网络故障场景下暴露Raft层的bug。
7.2 我在TestConcurrent上卡了两天
TestConcurrent的典型做法是:启动多个客户端goroutine,同时向ShardCtrler发送Join/Leave请求,最后验证所有配置版本一致且负载均衡。
我的问题出在:多个客户端同时提交命令,Raft返回的日志索引相同但请求不同,导致pending映射错乱,一个客户端收到了另一个客户端的响应。
解决方案很简单:在唤醒channel之前,校验日志索引对应的请求ID是否与当前等待的请求ID相等。如果不相等,说明这条日志不是你的命令,继续等待。
// applyLoop中 sc.mu.Lock() if opCh, ok := sc.pending[msg.CommandIndex]; ok { opResult := OpResult{RequestId: op.RequestId, Err: result.Err} opCh <- opResult // 这里要保证opCh对应的请求ID与当前op的RequestId一致 pendingOp := sc.pendingOps[msg.CommandIndex] if pendingOp != nil && pendingOp.RequestId == op.RequestId { opCh <- result delete(sc.pending, msg.CommandIndex) delete(sc.pendingOps, msg.CommandIndex) } } sc.mu.Unlock()7.3 TestUnreliable:网络分区导致Raft Leader切换
TestUnreliable会随机丢包、延迟消息,模拟一个不稳定的网络环境。这最容易暴露Raft实现的问题。
如果你的Lab3是通过了所有测试的,Lab4A的TestUnreliable大概率能通过。但有一种情况会卡住:Leader切换后,旧Leader的pending请求没有处理。
举例来说,客户端把命令提交给了节点1(旧Leader),但节点1还没等Raft提交结果,就与客户端断开了。客户端超时重试,把请求发给了节点2(新Leader)。节点2也提交了相同的命令,最终两条日志都被应用——如果命令是Join,相当于同一个组被加入了两次。
幂等性设计加上RequestId就能解决这个问题。节点2在处理时会发现RequestId已经存在(节点2自己之前也可能处理过相同请求),直接返回缓存结果,不会重复改配置。
8. 需要注意的5个关键点(血泪总结)
8.1 配置版本号和数组下标别混淆
configs数组的索引从0开始,但配置的Num从0开始递增。当快照压缩后,数组索引和Num不再一一对应。
我建议:所有外部接口(Query)都基于Num来查找,不要直接用数组下标。比如:
func (sc *ShardCtrler) getConfigByNum(num int) Config { if num <= 0 || num >= len(sc.configs) { return sc.configs[len(sc.configs)-1] } return sc.configs[num] }8.2 channel缓冲不能为0
在handleCommand里,channel最好设成带缓冲的,比如make(chan OpResult, 1)。因为applyLoop发送结果和RPC handler接收结果不是同步的——中间可能有调度延迟。如果channel无缓冲,applyLoop会被卡住,影响后续日志应用。
8.3 小心死锁
ShardCtrler.mu和Raft.mu的加锁顺序必须一致。如果不一致,两个节点互相持有锁等待对方释放,就会死锁。
我的一般原则是:只在ShardCtrler.mu锁内部操作ShardCtrler的状态,不调用任何Raft方法。如果需要调用Raft方法(如Start、Snapshot),先把ShardCtrler的状态拷贝出来,解锁后再调用Raft。
8.4 不要用time.Sleep来处理问题
有些同学遇到测试超时,喜欢加time.Sleep(1 * time.Second)“等一等”。这是饮鸩止渴——它能让你某个场景通过,但会拖慢所有场景的速度,而且不能解决根本问题。
正确做法是找出为什么需要等待。比如“等待leader选举完成”,应该用time.After配合channel,而不是time.Sleep。Raft代码里一般会有一个leaderCh或者“已提交索引增长”的信号,用它来触发后续操作。
8.5 保持代码干净,别急着写Lab4B
很多同学做Lab4A时,因为后面还有Lab4B,就想着把ShardKV的代码也先写一部分。我的建议是:先专注Lab4A,把测试跑通、跑稳。Lab4B的分片迁移逻辑比Lab4A复杂得多,如果在Lab4A阶段就混入分片KV的代码,出了问题很难定位。
9. 一份可以直接抄的参考checklist
以下是我在做Lab4A时使用的自查清单,每一步都验证通过再往下走:
- 初始化:
configs[0]为空配置,Groups是空map,Shards是全0。 - Join:新组加入后,
configs版本+1,Shard分配均衡(各组分到的数量差≤1)。 - Leave:移除指定组,其Shard被重新分配,剩余组均衡。
- Move:指定Shard被移动到指定GID,其他Shard不动。
- Query(0)或Query负数:返回最新配置。
- Query(指定版本):返回对应版本配置;如果版本不存在,返回最新配置。
- 并发请求:多个客户端同时Join/Leave,配置最终一致,不出现重复应用。
- 网络故障:丢包/延迟下,客户端能通过重试得到正确结果,配置不出现异常。
- 快照与恢复:节点重启后能加载快照,继续正确服务。
每一行看起来都很简单,但背后涉及Raft的选举、日志复制、持久化、快照等一大堆机制。如果某一步出了问题,优先去检查Raft层的实现,而不是怀疑ShardCtrler逻辑。
10. 写在最后:一点经验之谈
Lab4A在整个6.824课程里定位很特殊。它代码量不算大,但涉及的知识点非常多:Raft共识、状态机复制、幂等性设计、负载均衡、快照与压缩。可以说,Lab4A就是一次把前面所有知识融会贯通的实战演练。
我在做这个实验时最大的体会是:一定要想清楚“数据从哪里来、到哪里去”。ShardCtrler本身不产生数据,也不存储业务数据,它只是一个配置管理中枢。所有的一致性、容错、幂等设计,都是为了一个目标——让所有节点对“配置长什么样”达成一致。
如果你现在卡在某个测试上,不要焦虑。我当初也是从TestUnreliable反复失败开始,一点点看日志、看Raft状态、模拟网络异常才跑通。分布式系统的调试就是这样,没有捷径,但每一次失败都会让你对Raft的理解更深一层。
最后分享一个小技巧:如果你实在找不到Bug在哪,试着把你对这道题目的理解写下来,一步一步推演,从客户端发起请求到Raft提交日志到applyLoop应用日志,理顺了再回去看代码。很多时候Bug不在代码里,而在你对系统的理解里。祝大家都能顺利通关Lab4A,后面Lab4B的ShardKV更刺激,也能从这个扎实的配置服务上受益。