[{"content":" lab3B实验中主要实现的功能是raft的日志复制功能，建议多看几遍关于raft日志复制功能的过程。\n原文pdf:http://nil.csail.mit.edu/6.5840/2024/papers/raft-extended.pdf\n源码地址：https://github.com/liusir521/mit6.5840\n实验要求 实现领导者和追随者代码以追加新的日志条目，以便go test -run 3B测试通过。\n实验中的要求看似就一句话并不多，但是我们需要考虑很多细节上的东西，以及针对日志不同步的情况的处理等等。个人感觉3B难就难在细节太多，不是一下子就可以想全面的，所以这里才说需要多看几遍raft的日志复制功能的讲解。\n我之前的关于分布式共识算法中也有关于raft日志复制相关的描述，这里再粘贴一下：\nRaft 算法中，领导者通过广播消息（AppendEntries RPC）将日志条目复制到所有跟随者。AppendEntries RPC 的示例如下：\n1 2 3 4 5 6 7 8 9 10 { \u0026#34;term\u0026#34;: 5, // 领导者的任期号 \u0026#34;leaderId\u0026#34;: \u0026#34;leader-123\u0026#34;, \u0026#34;prevLogIndex\u0026#34;: 8, // 前一日志条目的索引 \u0026#34;prevLogTerm\u0026#34;: 4, // 前一日志条目的任期 \u0026#34;entries\u0026#34;: [ { \u0026#34;index\u0026#34;: 9, \u0026#34;term\u0026#34;: 5, \u0026#34;command\u0026#34;: \u0026#34;set x=4\u0026#34; }, // 要复制的日志条目 ], \u0026#34;leaderCommit\u0026#34;: 7// Leader 的“已提交”状态的日志条目索引号 } 当 Raft 集群收到客户端请求（例如 set x=4）时，日志复制的过程如下：\n若当前节点非领导者，将请求转发至领导者；\n领导者接收请求后：\n将请求转化为日志条目，写入本地存储系统，初始状态为“未提交”（uncommitted）；\n生成日志复制消息（AppendEntries RPC），并广播至所有跟随者；\n跟随者收到日志复制消息后，验证任期（确保本地任期不大于领导者任期）、日志一致性（通过 prevLogIndex 检查日志是否匹配）。若验证通过，跟随者将日志条目追加至本地存储系统，并发送确认响应；\n领导者确认日志条目已成功复制至多数节点后，将其状态标记为“已提交”（committed），并向客户端返回结果。已提交的日志条目不可回滚，指令永久生效，且可安全地“应用”（apply）至状态机。\n领导者向客户端返回结果，并不意味着日志复制过程已完全结束，跟随者尚不清楚日志条目是否已被大多数节点确认。Raft 的设计通过心跳或后续日志复制请求中携带更新的提交索引（leaderCommit），通知跟随者提交日志。此机制将“达成共识的过程”优化为一个阶段，减少了客户端约一半的等待时间。\n我们来看日志复制的另一种情况。在上述例子中，只有 follower-1 成功追加日志，follower-2 因为日志不连续，追加失败。日志的连续性至关重要，如果日志条目没有按正确顺序应用到状态机，各个 follower 节点的状态肯定不一致。\n日志不连续的问题是这样解决的：follower-2 收到日志复制请求后，它会通过 prevLogIndex 和 prevLogTerm 检查本地日志的连续性。如果日志缺失或存在冲突，follower-2 返回失败响应，指明与领导者日志不一致的部分。\n1 2 3 4 5 6 { \u0026#34;success\u0026#34;: false, \u0026#34;term\u0026#34;: 4, \u0026#34;conflictIndex\u0026#34;: 4, // 表示发生缺失的日志索引，Follower 的日志中最大索引为 3，所以缺失的索引是 4。 \u0026#34;conflictTerm\u0026#34;: 3//缺失日志的“上一个有效日志条目”的任期号 } 当领导者收到失败响应，根据 conflictIndex 和 conflictTerm 找到与跟随者日志的最大匹配索引（例如，6）。随后，领导者从该索引开始重新向跟随者（如 follower-2）发送日志条目，逐步修复日志的不一致性，直至同步完成。\nOK，再看一遍raft的日志复制流程，才发现需要实现的功能确实不少。感觉3B确实可以算hard。\n代码设计 好的，接下来讲讲我的代码设计。\n首先，我本来准备延用3A中的设计思路，但是思考之下发现了一些问题，比如心跳机制，我在3A中设计的是当节点成为领导者之后再启动这个协程，但是在3B中，心跳函数中还需要实现一些额外的功能，如推进日志的应用等，再把心跳放到选举之后反而会增加代码的复杂度。\n所以，我在Make函数初始化时便启动了这个心跳函数，如果当前节点是领导者时，会进行日志检测、同步日志的功能。\n初始化 在3B中，也需要在raft结构体中添加一些额外的字段，来满足日志同步的需求，对领导者和跟随者来说，二者都需要已提交的日志的索引字段和已应用的日志的索引字段，对领导者来说，还需要额外定义一个切片来记录对每个跟随者下一个要发送的日志的索引，同时还需要另外一个切片来实现记录每个跟随者已经匹配的最大日志的索引。\n初始化时便启动计时器和心跳函数。选举流程基本和3A没什么变动。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 type Raft struct { mu sync.Mutex // Lock to protect shared access to this peer\u0026#39;s state peers []*labrpc.ClientEnd // RPC end points of all peers persister *Persister // Object to hold this peer\u0026#39;s persisted state me int // this peer\u0026#39;s index into peers[] dead int32 // set by Kill() currentTerm int // 当前任期 votedFor int // 投票给的节点 log []LogEntry // 日志 state int // 当前节点状态：跟随者、候选者、领导者 applyCh chan ApplyMsg // 应用通道 lastAppliedID int // 已应用的 ID commitIndex int // 已提交的索引 lastheartbeattime time.Time // 最后一次收到心跳的时间 nextIndex []int // 对每个 follower，下一个要发送的 log index matchIndex []int // 对每个 follower，已经匹配的最大 index } type LogEntry struct { Command any // 命令 Term int // 日志的任期 } const ( FOLLOWER = iota // 跟随者 CANDIDATE // 候选者 LEADER // 领导者 ) const ( UnCommitted = iota // 未提交 Committed // 已提交 Applied // 已应用 ) func Make(peers []*labrpc.ClientEnd, me int, persister *Persister, applyCh chan ApplyMsg) *Raft { rf := \u0026amp;Raft{} rf.peers = peers rf.persister = persister rf.me = me // Your initialization code here (3A, 3B, 3C). rf.currentTerm = 0 rf.votedFor = -1 rf.state = FOLLOWER // 初始状态都为跟随者 rf.lastheartbeattime = time.Now() rf.applyCh = applyCh rf.lastAppliedID = 0 // 已应用的索引，从0开始（表示还没有应用任何日志） rf.commitIndex = 0 // 已提交的索引，从0开始（表示还没有提交任何日志） rf.log = make([]LogEntry, 1) rf.log[0] = LogEntry{Term: 0} // 初始化领导者状态 rf.nextIndex = make([]int, len(peers)) rf.matchIndex = make([]int, len(peers)) for i := range rf.nextIndex { rf.nextIndex[i] = len(rf.log) // 初始下一个要发送的索引是当前日志长度 rf.matchIndex[i] = 0 // 初始已匹配的索引是0 } // initialize from state persisted before a crash rf.readPersist(persister.ReadRaftState()) // 计时器守护进程，检测到超时后，开始选举，投自己 go rf.ticker() go rf.heartbeatLoop() return rf } // 心跳循环 func (rf *Raft) heartbeatLoop() { for !rf.killed() { time.Sleep(100 * time.Millisecond) rf.mu.Lock() if rf.state == LEADER { term := rf.currentTerm // 持续推进 commitIndex rf.updateCommitIndex() rf.mu.Unlock() for i := range rf.peers { if i != rf.me { go rf.sendAppendEntriesToPeer(i, term) } } } else { rf.mu.Unlock() } } } 日志同步 设置日志的入口函数其实是Start函数，在Start函数中，如果当前节点是领导者，就将新日志写入到日志存储字段中，然后触发广播复制日志的函数，将复制日志的消息广播给跟随者，根据返回消息的情况进行处理。\n不过触发日志复制的时机在心跳函数中也存在，所以其实是否在Start函数中推进commitIndex都可以，当时在这里推进是想到可能刚好写完日志之后遇到心跳检测将这个日志进行了同步，然后这里直接推进并应用能更省事，其实这里是否推进并没有太大的关系，因为这个刚好写完就遇到心跳检测毕竟是小概率，两种情况测试都能通过的。\n在日志同步的过程中，会有许多细节上的问题，首先，需要进行二次状态检测，防止信息发送未执行时发生了选举，其次在构建entry切片时，如果当前没有新的日志，就发送空切片，代表心跳。然后在发送append rpc请求前是需要释放锁的，防止rpc阻塞导致的死锁。然后根据响应查看是否存在缺失日志，存在则修改下标，在下次发送时进行同步。\n关键代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 // 领导者向其他节点发送 AppendEntries 请求，server代表跟随者节点 func (rf *Raft) sendAppendEntriesToPeer(server int, term int) { rf.mu.Lock() // 二次检查状态，不是领导者直接退出 if rf.state != LEADER || term != rf.currentTerm { rf.mu.Unlock() return } // 确保 nextIndex 不越界 if rf.nextIndex[server] \u0026gt; len(rf.log) { rf.nextIndex[server] = len(rf.log) } nextIdx := rf.nextIndex[server] prevLogIndex := nextIdx - 1 // 获取 prevLogTerm prevLogTerm := 0 if prevLogIndex \u0026gt;= 0 \u0026amp;\u0026amp; prevLogIndex \u0026lt; len(rf.log) { prevLogTerm = rf.log[prevLogIndex].Term } // 构建 entries 切片 var entries []LogEntry if nextIdx \u0026lt; len(rf.log) { entries = append([]LogEntry{}, rf.log[nextIdx:]...) } else { entries = []LogEntry{} } args := AppendEntriesArgs{ Term: rf.currentTerm, LeaderId: rf.me, PrevLogIndex: prevLogIndex, PrevLogTerm: prevLogTerm, Entries: entries, LeaderCommit: rf.commitIndex, } rf.mu.Unlock() // 在发送rpc请求时，需要先解锁，避免死锁 reply := AppendEntriesReply{} ok := rf.sendAppendEntries(server, \u0026amp;args, \u0026amp;reply) if !ok { return } rf.mu.Lock() defer rf.mu.Unlock() // 再次检查状态 if rf.state != LEADER || term != rf.currentTerm { return } // term 过期 if reply.Term \u0026gt; rf.currentTerm { rf.currentTerm = reply.Term rf.state = FOLLOWER rf.votedFor = -1 rf.persist() return } if reply.Success { rf.matchIndex[server] = args.PrevLogIndex + len(args.Entries) rf.nextIndex[server] = rf.matchIndex[server] + 1 } else { if reply.ConflictIndex != -1 { rf.nextIndex[server] = reply.ConflictIndex } else { rf.nextIndex[server]-- } if rf.nextIndex[server] \u0026lt; 1 { rf.nextIndex[server] = 1 } } // 更新 commitIndex rf.updateCommitIndex() } // 更新 commitIndex func (rf *Raft) updateCommitIndex() { for N := len(rf.log) - 1; N \u0026gt; rf.commitIndex; N-- { count := 1 // 统计日志索引 N 是否被大多数节点提交 for i := range rf.peers { if i != rf.me \u0026amp;\u0026amp; rf.matchIndex[i] \u0026gt;= N { count++ } } // 如果大多数节点都已提交到 N 索引位置，且 N 索引位置的日志任期与当前任期相同，则推进 commitIndex if count \u0026gt; len(rf.peers)/2 \u0026amp;\u0026amp; rf.log[N].Term == rf.currentTerm { rf.commitIndex = N go rf.applyLogs() break } } } // 日志进行应用 func (rf *Raft) applyLogs() { for { rf.mu.Lock() // 如果 lastAppliedID 已经大于等于 commitIndex，则没有日志需要应用 if rf.lastAppliedID \u0026gt;= rf.commitIndex { rf.mu.Unlock() return } rf.lastAppliedID++ msg := ApplyMsg{ CommandValid: true, Command: rf.log[rf.lastAppliedID].Command, CommandIndex: rf.lastAppliedID, } rf.mu.Unlock() rf.applyCh \u0026lt;- msg } } // 领导者发送日志请求结构体 // AppendEntriesreq type AppendEntriesArgs struct { Term int LeaderId int PrevLogIndex int PrevLogTerm int Entries []LogEntry // 日志条目 LeaderCommit int // Leader 的“已提交”状态的日志条目索引号 } // AppendEntriesReply type AppendEntriesReply struct { Term int Success bool ConflictIndex int // 表示发生缺失的日志索引 ConflictTerm int // 缺失日志的“上一个有效日志条目”的任期号 } // 领导者发送日志请求 func (rf *Raft) sendAppendEntries(server int, args *AppendEntriesArgs, reply *AppendEntriesReply) bool { ok := rf.peers[server].Call(\u0026#34;Raft.AppendEntries\u0026#34;, args, reply) return ok } // 在3A中只需要实现心跳功能即可，3B中需要实现日志复制 func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) { rf.mu.Lock() defer rf.mu.Unlock() reply.Term = rf.currentTerm reply.Success = false // 初始化 reply.ConflictIndex = -1 reply.ConflictTerm = -1 // term 检查 if args.Term \u0026lt; rf.currentTerm { return } // 更新为 follower if args.Term \u0026gt; rf.currentTerm { rf.currentTerm = args.Term rf.votedFor = -1 rf.state = FOLLOWER } rf.lastheartbeattime = time.Now() // prevLogIndex 不存在，返回缺失的日志索引 if args.PrevLogIndex \u0026gt;= len(rf.log) { reply.ConflictIndex = len(rf.log) return } // term 不匹配，寻找错误日志的索引和任期 if args.PrevLogIndex \u0026gt;= 0 \u0026amp;\u0026amp; rf.log[args.PrevLogIndex].Term != args.PrevLogTerm { reply.ConflictTerm = rf.log[args.PrevLogIndex].Term i := args.PrevLogIndex for i \u0026gt; 0 \u0026amp;\u0026amp; rf.log[i-1].Term == reply.ConflictTerm { i-- } reply.ConflictIndex = i return } // append entries index := args.PrevLogIndex + 1 // 跟随者开始对齐的位置 i := 0 // 领导者发送的日志条目下标 // 寻找冲突日志的索引位置 for ; i \u0026lt; len(args.Entries); i++ { if index+i \u0026lt; len(rf.log) { if rf.log[index+i].Term != args.Entries[i].Term { // 如果存在任期不匹配的日志，进行截断 rf.log = rf.log[:index+i] break } } else { break } } // 将剩余的日志条目添加到跟随者日志中 if i \u0026lt; len(args.Entries) { rf.log = append(rf.log, args.Entries[i:]...) } // 更新 commitIndex 和 lastAppliedID 索引并应用日志 if args.LeaderCommit \u0026gt; rf.commitIndex { rf.commitIndex = min(args.LeaderCommit, len(rf.log)-1) go rf.applyLogs() } reply.Success = true rf.persist() } func (rf *Raft) Start(command interface{}) (int, int, bool) { rf.mu.Lock() defer rf.mu.Unlock() if rf.state != LEADER { return -1, rf.currentTerm, false } index := len(rf.log) term := rf.currentTerm rf.log = append(rf.log, LogEntry{ Command: command, Term: term, }) // leader 自己的 matchIndex 必须更新 rf.matchIndex[rf.me] = len(rf.log) - 1 rf.persist() // 立即尝试 commit // rf.updateCommitIndex() // 触发复制 go rf.broadcastAppendEntries() return index, term, true } // 触发复制 func (rf *Raft) broadcastAppendEntries() { rf.mu.Lock() term := rf.currentTerm rf.mu.Unlock() for i := range rf.peers { if i == rf.me { continue } go rf.sendAppendEntriesToPeer(i, term) } } 其中需要对某个节点发送的日志的下标是在rf.nextIndex[server]中，包括如果说某个server的日志不匹配，修改的也是rf.nextIndex[server]的值，在发送时根据此下标进行获取相关日志。\n运行结果 测试结果如下：\n","date":"2026-04-01T00:00:00Z","image":"https://liusir521.github.io/p/mit-lab3b%E4%B8%AA%E4%BA%BA%E7%AC%94%E8%AE%B0/mit_hu_7d5f5b09f91c89db.jpg","permalink":"https://liusir521.github.io/p/mit-lab3b%E4%B8%AA%E4%BA%BA%E7%AC%94%E8%AE%B0/","title":"Mit lab3B个人笔记"},{"content":" 对raft算法不熟悉的可以参考我之前记录过的分布式共识算法文章\nhttps://liusir521.github.io/p/%E5%88%86%E5%B8%83%E5%BC%8F%E5%85%B1%E8%AF%86%E7%AE%97%E6%B3%95/\n源码地址：https://github.com/liusir521/mit6.5840\n实验要求 Lab3实验中要我们复刻raft算法，并由易到难的分为了4个部分，对应着raft算法中的三个关键点，领导者选举、日志复制以及安全性，当前是3A部分，对应的是领导者选举的部分。\n3A任务要求：实现 Raft 领导者选举和心跳机制（使用 AppendEntries RPC，不记录日志）。第 3A 部分的目标是：选举出一个领导者，在没有故障的情况下保持领导者地位，并在旧领导者发生故障或与旧领导者之间的数据包丢失时，由新的领导者接管。运行 go test -run 3A来测试你的第 3A 部分代码。\n关键点：\n要实现心跳机制，请定义一个 AppendEntries RPC 结构体（尽管您可能暂时不需要所有参数），并让领导者定期发送这些参数。编写一个 AppendEntries RPC 处理方法。\n测试人员要求领导者每秒发送心跳 RPC 的次数不得超过 10 次。\n测试人员要求你的 Raft 在旧领导者失效后的 5 秒钟内选出新的领导者（如果大多数同伴仍然可以沟通）。\n代码设计 在写代码之前建议先结合实验要求过一下代码，加深一下对代码中定义的理解。在我第一眼看到代码的时候，我有一个很大的疑惑，就是Raft结构体是给每个节点使用的，还是说相当于是一个中间调配者的角色，在我结合着代码阅读了几遍实验要求之后，才意识到其实每个节点都是一个Raft结构体实例。（在config.go文件中也可以看出cfg.rafts = make([]*Raft, cfg.n)，用于测试文件中的初始化）\n在3A中，我们需要实现的功能并不复杂，主要是领导者选举。这意味着我们只需要先定义好相关的结构体，然后在Make函数中进行初始化之后(初始化默认节点是跟随者)，等待领导者发送心跳包即可，如果在规定时间内未收到，就进入选举过程，当某个节点完成选举成为主节点后，再继续向其他节点发送心跳包确认即可。(其中心跳包暂时使用AppendEntries函数实现)\n初始化 需要我们在Raft结构体中完善相关的定义，关键字段包括当前任期、当前节点的身份以及最后一次收到心跳的时间等等。然后在Make函数中进行初始化。\n关键代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 type Raft struct { mu sync.Mutex // Lock to protect shared access to this peer\u0026#39;s state peers []*labrpc.ClientEnd // RPC end points of all peers persister *Persister // Object to hold this peer\u0026#39;s persisted state me int // this peer\u0026#39;s index into peers[] dead int32 // set by Kill() // Your data here (3A, 3B, 3C). // Look at the paper\u0026#39;s Figure 2 for a description of what // state a Raft server must maintain. currentTerm int // 当前任期 votedFor int // 投票给的节点 log []LogEntry // 日志 state int // 当前节点状态：跟随者、候选者、领导者 lastheartbeattime time.Time // 最后一次收到心跳的时间 } type LogEntry struct { Command any // 日志内容 Term int // 日志的任期 } const ( FOLLOWER = iota // 跟随者 CANDIDATE // 候选者 LEADER // 领导者 ) func (rf *Raft) GetState() (int, bool) { rf.mu.Lock() defer rf.mu.Unlock() return rf.currentTerm, rf.state == LEADER } func Make(peers []*labrpc.ClientEnd, me int, persister *Persister, applyCh chan ApplyMsg) *Raft { rf := \u0026amp;Raft{} rf.peers = peers rf.persister = persister rf.me = me // Your initialization code here (3A, 3B, 3C). rf.currentTerm = 0 rf.votedFor = -1 rf.log = make([]LogEntry, 0) rf.state = FOLLOWER // 初始状态都为跟随者 rf.lastheartbeattime = time.Now() // initialize from state persisted before a crash rf.readPersist(persister.ReadRaftState()) // 计时器守护进程，检测到超时后，开始选举，投自己 go rf.ticker() return rf } 选举 在初始化时，起初应该都是跟随者，当某个跟随者等待心跳包超时之后就会发起选举，进入选举流程，然后通过rpc请求向除了自己之外的在peers数组中的其他节点发送选举请求，如果获得了多数派的同意，就成为领导者，成为领导者之后向其他跟随者发送心跳包，如果收到了任期比自己大的则变为跟随者。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 // 发送心跳的sendHeartbeat函数忘记粘贴了，建议自己实现一下。 func (rf *Raft) ticker() { for rf.killed() == false { // pause for a random amount of time between 50 and 350 // milliseconds. ms := 50 + (rand.Int63() % 300) time.Sleep(time.Duration(ms) * time.Millisecond) rf.mu.Lock() // Your code here (3A) // Check if a leader election should be started. // 只有 follower 和 candidate 才需要检查选举超时 if rf.state == FOLLOWER || rf.state == CANDIDATE { electionTimeout := 150 + rand.Int63n(150) // 150-300ms 随机超时 if time.Since(rf.lastheartbeattime) \u0026gt; time.Duration(electionTimeout)*time.Millisecond { // 超时，开始选举 rf.startElection() // startElection 会重置 electionStart，所以这里不需要重复设置 } } rf.mu.Unlock() } } func (rf *Raft) startElection() { // 任期+1，状态为候选者 rf.currentTerm++ rf.state = CANDIDATE rf.votedFor = rf.me // 投给自己 rf.lastheartbeattime = time.Now() // 重置选举超时时间 rf.persist() // 获取最后一条日志的索引和任期 lastLogIndex := len(rf.log) - 1 lastLogTerm := 0 if lastLogIndex \u0026gt;= 0 { lastLogTerm = rf.log[lastLogIndex].Term } args := \u0026amp;RequestVoteArgs{ Term: rf.currentTerm, CandidateId: rf.me, LastLogIndex: lastLogIndex, LastLogTerm: lastLogTerm, } votesReceived := 1 // 记录收到的投票数 var mu sync.Mutex // 向除了自己之外的所有节点发送投票请求 for i := 0; i \u0026lt; len(rf.peers); i++ { if i == rf.me { continue } go func(server int) { reply := \u0026amp;RequestVoteReply{} ok := rf.sendRequestVote(server, args, reply) mu.Lock() defer mu.Unlock() if !ok { return } // 如果成功且收到投票，则记录 if reply.VoteGranted { rf.mu.Lock() if args.Term == rf.currentTerm \u0026amp;\u0026amp; rf.state == CANDIDATE { votesReceived++ // 获得大多数的投票，转换成领导者 if votesReceived \u0026gt; len(rf.peers)/2 { rf.state = LEADER rf.lastheartbeattime = time.Now() go rf.sendHeartbeat() } } rf.mu.Unlock() } // 如果收到新的任期大于当前任期，则转换成跟随者 if reply.Term \u0026gt; rf.currentTerm { rf.mu.Lock() if reply.Term \u0026gt; rf.currentTerm { rf.currentTerm = reply.Term rf.state = FOLLOWER rf.votedFor = -1 rf.lastheartbeattime = time.Now() rf.persist() } rf.mu.Unlock() return } }(i) } } // 共用发送投票请求 func (rf *Raft) sendRequestVote(server int, args *RequestVoteArgs, reply *RequestVoteReply) bool { ok := rf.peers[server].Call(\u0026#34;Raft.RequestVote\u0026#34;, args, reply) return ok } // 选举rpc函数 func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) { rf.mu.Lock() defer rf.mu.Unlock() reply.Term = rf.currentTerm reply.VoteGranted = false rf.lastheartbeattime = time.Now() // 如果请求中的任期小于当前任期，拒绝投票 if args.Term \u0026lt; rf.currentTerm { return } // 如果当前任期小于请求的任期，更新任期并转为跟随者 if args.Term \u0026gt; rf.currentTerm { rf.currentTerm = args.Term rf.votedFor = -1 rf.state = FOLLOWER } // 如果尚未投票，或者是已经投给CandidateId，则校验日志进行投票 if args.Term == rf.currentTerm \u0026amp;\u0026amp; (rf.votedFor == -1 || rf.votedFor == args.CandidateId) { // 检查日志 lastLogIndex := len(rf.log) - 1 lastLogTerm := 0 if lastLogIndex \u0026gt;= 0 { lastLogTerm = rf.log[lastLogIndex].Term } candidateLogUpToDate := (args.LastLogTerm \u0026gt; lastLogTerm) || (args.LastLogTerm == lastLogTerm \u0026amp;\u0026amp; args.LastLogIndex \u0026gt;= lastLogIndex) if candidateLogUpToDate { reply.VoteGranted = true rf.votedFor = args.CandidateId } } rf.persist() } 测试结果 测试如图所示\n","date":"2026-03-30T00:00:00Z","image":"https://liusir521.github.io/p/mit-lab3a%E4%B8%AA%E4%BA%BA%E7%AC%94%E8%AE%B0/mit_hu_7d5f5b09f91c89db.jpg","permalink":"https://liusir521.github.io/p/mit-lab3a%E4%B8%AA%E4%BA%BA%E7%AC%94%E8%AE%B0/","title":"Mit lab3A个人笔记"},{"content":" 原文地址：https://www.thebyte.com.cn/consensus/consensus.html\n我是买的纸质书学习的，记录笔记时发现web端的内容其实挺全面的，下方内容大部分摘录自web端页面。\n什么是共识 “共识”和“一致”意思相似，但在计算机领域，它们具有截然不同的含义。\n共识（Consensus）：指所有节点就某项操作（如选主、原子事务提交、日志复制、分布式锁管理等）达成一致的实现过程。\n一致性（Consistency）：描述多个节点的数据是否保持一致，关注数据最终达到稳定状态的结果。\n在分布式系统中，节点故障是不可避免的，但部分节点故障不应该影响系统整体状态。通过增加节点数量，依据“少数服从多数”原则，只要多数节点（至少N/2+1）达成一致，其状态即可代表整个系统。这种依赖多数节点实现容错的机制称为 Quorum 机制。\n基于 Quorum 的机制，通过“少数服从多数”协商机制达成一致的决策，从而对外表现为一致的运行结果。这一过程被称为节点间的“协商共识”。一旦解决共识问题，便可提供一套屏蔽内部复杂性的抽象机制，为应用层提供一致性保证，满足多种需求。\n主节点选举：在主从复制数据库中，所有节点需要就“谁来当主节点”达成一致。如果由于网络问题导致节点间无法通信，很容易引发争议。若争议未解决，可能会出现多个节点同时认为自己是主节点的情况，这就是分布式系统中最棘手的问题之一 —— “脑裂”。\n原子事务提交：对于支持跨节点或跨分区事务的数据库，可能会发生部分节点事务成功、部分节点事务失败的情况。为维护事务的原子性（即 ACID 特性），所有节点必须就事务的最终结果达成一致。\n分布式锁管理：当多个请求尝试访问共享资源时，共识机制可确保所有节点一致认定“谁成功获取了锁”。即使发生网络故障或节点异常，也能避免锁争议，从而防止并发冲突或数据不一致。\n日志复制：日志复制指将主节点的操作日志同步到从节点。在这一过程中，所有节点必须确保日志条目的顺序一致，即日志条目必须以相同顺序写入。\n日志与复制状态机 日志是有序且持久化的记录序列。新记录会从末尾追加，而读取时则按“从左到右”的顺序进行扫描。结构如下：\n有序的日志记录了“何时发生了什么”，这一点可以通过以下两种数据复制模型来理解。\n主备模型（Primary-backup）：又称 状态转移 模型，主节点（Master）负责执行如“+1”、“-2”的操作，将 操作结果 （如“1”、“3”、“6”）记录到日志中，备节点（Slave）根据日志直接同步结果。\n复制状态机模型（State-Machine Replication）：又称 操作转移 模型，日志记录的不是最终结果，而是具体的 操作指令 ，如“+1”、“-2”。指令按照顺序被依次复制到各个节点（Peer）。如果每个节点按顺序执行这些指令，各个节点最终将达到一致的状态。\n无论哪一种模型，它们都揭示了：“顺序是节点之间保持一致性的关键因素”。如果打乱了操作的顺序，就会得到不同的运算结果。\n复制状态机的基本原理\n两个“相同的” (identical)、“确定的” (deterministic) 进程：\n相同的：进程的代码、逻辑、以及配置完全一致，它们在设计和实现上完全相同； 确定的：进程的行为是完全可预测的，不能有任何非确定性的逻辑，比如随机数生成或不受控制的时间依赖。 如果它们以相同的状态启动，按相同的顺序获取相同的输入。那么，它们一定会达到相同的状态。\n节点内的进程按 顺序 执行日志序列，操作具有全局顺序。因此，所有节点 最终将达到一致 的状态。多个这样的进程结合有序日志，就构成了 Apache Kafka、Zookeeper、etcd、CockroachDB 等分布式系统中的关键组件。\nPaxos算法 算法背景 在 Paxos 算法中，节点分为三种角色。\n提议者（Proposer）：提议者是启动共识过程的节点，它提出一个值，请求其他节点对这个值进行投票，提出值的行为称为发起“提案\u0026quot;（Proposal），提案包含提案编号 (Proposal ID) 和提议的值 (Value)。注意的是，Paxos 算法是一个典型的为 操作转移 模型设计的算法，为简化表述，本文把提案类比成 变量赋值 操作，但你应该理解它是 操作日志 相似的概念，后面介绍的 Raft 算法中，直接就把“提案”称做“日志”了。\n决策者（Acceptor）：接受或拒绝提议者发起的提案，如果一个提案被超过半数的决策者接受，意味着提案被“批准”（accepted）。提案一旦被批准，意味着在所有节点中达到共识，便不可改变、永久生效。\n记录者（Learner）：记录者不发起提案，也不参与决策提案，它们学习、记录被批准的提案。\n在 Paxos 算法中，所有节点都是平等的，能够承担一种或多种角色。例如，提议者既可以发起提案，也可以对其他提案进行表决。但为了更明确地计算 Quorum，通常建议表决提案的节点数为奇数。\nPaxos 算法描述 简而言之，Paxos 算法本质是一个支持多次重复的 二阶段提交 协议。\nPaxos 算法的第一个阶段称 准备阶段（Prepare）。提议者选择一个提案编号 N（通常是单调递增的数字，相当于乐观锁中的 version，更高的编号意味着更高的优先级），向所有的决策者广播许可申请（称为 Prepare(N) 请求），如果决策者：\n尚未承诺 ≥N 编号的提案：则“承诺”（promise）不再接受任何编号小于 N 的提案，返回一个响应，其中包含承诺的提案编号以及对应的提案值（如果有）；\n已承诺 ≥N 编号的提案：拒绝 Prepare 请求，不返回任何响应。\n提议者从多数决策者获得了“承诺”（Promise），则“准备阶段”达成。接着，决策者选择提案值：如果决策者的响应中返回了提案值，从中选择 编号最高 的提案值；如果没有提案值返回，则使用决策者 初始提案值。\n完成以上操作后，进入下一个阶段。Paxos 算法的第二个阶段称 批准阶段 （Accept）。提议者向所有决策者广播批准申请（称为 accept(N,V) 请求），请求批准：“提案编号 N 提案值 V”。如果决策者发现提案编号 N 不小于它已承诺的最大编号，则“批准”（accepted）该提案；否则拒绝该提案。当多数的决策者批准提案时，提议者认为本轮提案成功、共识达成。一旦提案成功，提议者会将最终的决议广播给所有记录者节点，供它们学习、记录最终结果。\n证明 Paxos 算法的正确性比重新实现 Paxos 算法还难。我们没必须推导 Paxos 的正确性，通过以下几个例子来验证 Paxos 算法。\n下面的示例中，X、Y 代表客户端，S1 ~ S5 是服务端，它们既是提议者又是决策者，图中的 P 代表 “准备阶段”，A 代表“批准阶段”。为了便于理解，提案编号 N 由自增序号和 Server ID 组成。例如，S1 的提案编号为 1.1、2.1、3.1\u0026hellip;。\n现在，我们来分析 S1 、S5 同时发起提案，会出现什么情况。\n情况一：提案已批准。如图所示，S1 收到客户端的请求，于是 S1 作为提议者，向 S1\u0026hellip;S3 广播 Prepare(3.1) 消息，决策者 S1\u0026hellip;S3 没有接受过任何提案，所以接受该提案。接着，S1 广播 Accept(3.1, X) 消息，提案 X 成功被批准。\n在提案 X 被批准后，S5 收到客户端的提案 Y，S5 作为提议者向 S3\u0026hellip;S5 广播 Prepare(4.5) 消息。对 S3 来说，4.5 比 3.1 大，且已经接受了 X，它回复提案 (3.1, X)。S5 收到 S3\u0026hellip;S5 的回复后，使用 X 替换自己的 Y，接着进入批准阶段，广播 Accept(4.5, X) 消息。S3\u0026hellip;S5 批准提案，所有决策者就 X 达成一致。\n情况二：事实上，对于情况一，也就是“取值为 X”并不是一定需要多数派批准，S5 发起提案时，准备阶段的应答中是否包含了批准过 X 的决策者也影响决策。如图所示，S3 接受了提案 (3.1, X)，但 S1、S2 还没有收到 Accept(3.1, X) 消息。此时 S3、S4、S5 收到 Prepare(4.5) 消息，S3 回复已经接受的提案 (3.1, X)，S5 将提案值 Y 替换成 X，广播 Accept(4.5, X) 消息给 S3、S4、S5，对 S3 来说，编号 4.5 大于 3.1，所以批准提案 X，最终共识的结果仍然是 X。\n情况三：另外一种可能的情况是 S5 发起提案时，准备阶段的应答中未包含批准过 X 的决策节点。S1 接受了提案 (3.1, X)，S3 先收到 Prepare(4.5) 消息，后收到 Accept(3.1, X) 消息，由于 3.1 小于 4.5，会直接拒绝这个提案。提案 X 没有收到多数的回复，X 提案就被阻止了。提案 Y 顺利通过，整个系统最终对“取值为 Y”达成一致。\n情况四：从情况三可以推导出另一种极端的情况，多个提议者同时发起提案，在准备阶段互相抢占，反复刷新决策者上的提案编号，导致任何一方都无法达到多数派决议，这个过程理论上可以无限持续下去，形成 活锁 （livelock）。\n解决这个问题并不复杂，将 重试时间随机化 ，就能减少这种巧合发生。\n以上，就是整个 Paxos 算法的工作原理。\nPaxos 算法只能处理单个提案，达成共识至少需要两次网络往返，高并发情况下还可能导致活锁。因此，Paxos 算法主要用于理论研究，很少直接用于工程实践。后来，Lamport 在论文《Paxos Made Simple》中提出了 Paxos 的变体 —— Multi Paxos。Multi Paxos 引入了“选主”机制，通过多轮运行 Paxos 算法来处理多个提案。\nRaft算法 前言 不可否认，Paxos 是一个划时代的共识算法。\nRaft 算法出现之前，绝大多数共识系统都是基于 Paxos 算法或者受其影响。同时，Paxos 算法也成为教学领域里讲解共识问题时的范例。不幸的是，Paxos 算法理解起来非常晦涩。此外，论文虽然提到了 Multi Paxos，但缺少实现细节。因此，无论是学术界还是工业界普遍对 Paxos 算法感到十分头疼。\n那段时期，虽然所有的共识系统都是从 Paxos 算法开始的，但工程师们实现过程中有很多难以逾越的难题，往往不得已开发出与 Paxos 完全不一样的算法，这导致 Lamport 的证明并没有太大价值。所以，很长的一段时间内，实际上并没有一个被大众广泛认同的 Paxos 算法。\n2013 年，斯坦福大学的学者 Diego Ongaro 和 John Ousterhout 发表了论文 《In Search of an Understandable Consensus Algorithm》[1]，提出了 Raft 算法。Raft 论文开篇描述了 Raft 的证明和 Paxos 等价，详细阐述了算法如何实现。也就是说，Raft 天生就是 Paxos 算法的工程化。\n此后，Raft 算法成为分布式系统领域的首选共识算法。\n领导者选举 Paxos 算法中“节点众生平等”，每个节点都可以发起提案。多个提议者并行发起提案，是活锁、以及其他异常问题的源头。那如何不破坏 Paxos 的“节点众生平等”基本原则，又能在提案节点中实现主次之分，约束提案权利？\n理解上面的问题，是先搞清楚 Raft 算法中节点的分类。Raft 提出了领导者角色，通过选举机制“分享”提案权利。\n领导者（Leader）：负责处理所有客户端请求，将请求转换为 日志 复制到其他节点，不断地向所有节点广播心跳消息：“你们的领导还在，不要发起新的选举”。\n跟随者（Follower）：接收、处理领导者的消息，并向领导者反馈日志的写入情况。当领导者心跳超时时，他会主动站起来，推荐自己成为候选人。\n候选人（Candidate）：候选人属于过渡角色，他向所有的节点广播投票消息，如果他赢得多数选票，那么他将晋升为领导者。\n联想到现实世界中的领导人都有一段不等的任期。自然，Raft 算法中也对应的概念 —— “任期”（term）。Raft 中的任期是一个递增的数字，贯穿于 Raft 的选举、日志复制和一致性维护过程中。\n选举过程：任期确保了领导者的唯一性。在一次任期内，只有获得多数选票的节点才能成为领导者。\n日志一致性：任期号会附加到每条日志条目中，帮助集群判断日志的最新程度。\n冲突检测：通过比较任期号，节点可以快速判断自己是否落后，并切换到跟随者状态。\nRaft 集群 Leader 选举过程：\n初始状态下，所有的节点处于跟随者状态。如果跟随者在某个时限（通常是 150-300 毫秒的随机超时时间）未收到领导者心跳，则触发触发选举。节点的角色转为候选者，任期号递增，然后向其他节点广播“投票给我”的消息（RequestVote RPC）。\nRequestVote RPC 消息示例如下：\n1 2 3 4 5 6 { \u0026#34;term\u0026#34;: 5, // 候选者的当前任期号，用于通知接收方当前选举属于哪个任期。 \u0026#34;candidateId\u0026#34;: 3, // 候选者的节点 ID，标识请求投票的节点。 \u0026#34;lastLogIndex\u0026#34;: 12, // 候选者日志的最后一条日志的索引，用于比较日志的完整性。 \u0026#34;lastLogTerm\u0026#34;: 4//候选者日志的最后一条日志的任期号，用于进一步比较日志的新旧程度。 } 其他节点收到投票消息后，根据下面的条件判断是否投票：\n候选者的日志至少与投票者的日志一样新（根据最后一条日志的任期号和索引号判断）。\n当前节点尚未在本任期投票。\nRequestVote 响应的示例如下：\n1 2 3 4 { \u0026#34;term\u0026#34;: 5, //接收方的当前任期号，用于告知候选者最新的任期号。如果候选者发现该值比自己大，会转为跟随者。 \u0026#34;voteGranted\u0026#34;: true//是否投票给候选者，true 表示同意，false 表示拒绝。 } 如果候选者获得多数（超过半数）投票，即成为领导者。之后，领导者向其他节点广播心跳消息，维持领导者地位。如果没有获得多数票，进入下一轮选举，任期号递增，重新发起投票。如果选举过程中收到任期号更高的心跳或投票请求，则转为跟随者。\n日志复制 一旦选出一个公认的领导者，那领导者顺理成章地承担起“处理系统发生的所有变更，并将变更复制到所有跟随者节点”的职责。\n在 Raft 算法中，日志承载着系统所有变更。下图展示了 Raft 集群的日志模型，每个“日志条目”（log entry）包含索引、任期、指令等关键信息：\n指令: 表示客户端请求的具体操作内容，也就是待“状态机”（State Machine）执行的操作。\n索引值：日志条目在仓库中的索引值，是单调递增的数字。\n任期编号：日志条目是在哪个任期中创建的，用于解决“脑裂”或日志不一致问题。\nRaft 算法中，领导者通过广播消息（AppendEntries RPC）将日志条目复制到所有跟随者。AppendEntries RPC 的示例如下：\n1 2 3 4 5 6 7 8 9 10 { \u0026#34;term\u0026#34;: 5, // 领导者的任期号 \u0026#34;leaderId\u0026#34;: \u0026#34;leader-123\u0026#34;, \u0026#34;prevLogIndex\u0026#34;: 8, // 前一日志条目的索引 \u0026#34;prevLogTerm\u0026#34;: 4, // 前一日志条目的任期 \u0026#34;entries\u0026#34;: [ { \u0026#34;index\u0026#34;: 9, \u0026#34;term\u0026#34;: 5, \u0026#34;command\u0026#34;: \u0026#34;set x=4\u0026#34; }, // 要复制的日志条目 ], \u0026#34;leaderCommit\u0026#34;: 7// Leader 的“已提交”状态的日志条目索引号 } 当 Raft 集群收到客户端请求（例如 set x=4）时，日志复制的过程如下：\n若当前节点非领导者，将请求转发至领导者；\n领导者接收请求后：\n将请求转化为日志条目，写入本地存储系统，初始状态为“未提交”（uncommitted）；\n生成日志复制消息（AppendEntries RPC），并广播至所有跟随者；\n跟随者收到日志复制消息后，验证任期（确保本地任期不大于领导者任期）、日志一致性（通过 prevLogIndex 检查日志是否匹配）。若验证通过，跟随者将日志条目追加至本地存储系统，并发送确认响应；\n领导者确认日志条目已成功复制至多数节点后，将其状态标记为“已提交”（committed），并向客户端返回结果。已提交的日志条目不可回滚，指令永久生效，且可安全地“应用”（apply）至状态机。\n领导者向客户端返回结果，并不意味着日志复制过程已完全结束，跟随者尚不清楚日志条目是否已被大多数节点确认。Raft 的设计通过心跳或后续日志复制请求中携带更新的提交索引（leaderCommit），通知跟随者提交日志。此机制将“达成共识的过程”优化为一个阶段，减少了客户端约一半的等待时间。\n我们来看日志复制的另一种情况。在上述例子中，只有 follower-1 成功追加日志，follower-2 因为日志不连续，追加失败。日志的连续性至关重要，如果日志条目没有按正确顺序应用到状态机，各个 follower 节点的状态肯定不一致。\n日志不连续的问题是这样解决的：follower-2 收到日志复制请求后，它会通过 prevLogIndex 和 prevLogTerm 检查本地日志的连续性。如果日志缺失或存在冲突，follower-2 返回失败响应，指明与领导者日志不一致的部分。\n1 2 3 4 5 6 { \u0026#34;success\u0026#34;: false, \u0026#34;term\u0026#34;: 4, \u0026#34;conflictIndex\u0026#34;: 4, // 表示发生缺失的日志索引，Follower 的日志中最大索引为 3，所以缺失的索引是 4。 \u0026#34;conflictTerm\u0026#34;: 3//缺失日志的“上一个有效日志条目”的任期号 } 当领导者收到失败响应，根据 conflictIndex 和 conflictTerm 找到与跟随者日志的最大匹配索引（例如，6）。随后，领导者从该索引开始重新向跟随者（如 follower-2）发送日志条目，逐步修复日志的不一致性，直至同步完成。\n成员变更 在前面的内容中，我们假设集群节点数固定，即集群的 Quorum 也保持不变。然而，在生产环境中，集群通常需要进行节点变更，例如因故障移除节点或扩容增加节点等。对于旨在实现容错能力的算法来说，显然不能通过“关闭集群、更新配置并重启系统”的方式来实现。\n在讨论如何实现成员动态变更之前，我们需要先搞明白 Raft 集群中“配置”（configuration）的概念。\n配置\n配置说明集群由哪些节点组成。例如，一个集群有三个节点（Server 1、Server 2、Server 3），该集群的配置就是 [Server1、Server2、Server3]。\n如果把“配置”当成 Raft 中的“特殊日志”。这样一来，成员动态变更需求就可以转化为“配置日志”的一致性问题。但需要注意的是，各个节点中的日志“应用”（apply）到状态机是异步的，不可能同时操作。这种情况下，apply “配置日志”很容易导致“脑裂”问题。\n举个具体例子，假设有一个由三个节点 [Server1、Server2 和 Server3] 组成的 Raft 集群，当前的配置为 Cold。现在，我们计划增加两个节点 [Server1、Server2、Server3、Server4、Server5]，新的配置为 Cnew。\n由于日志提交是异步处理的，假设 Server1 和 Server2 比较迟钝，仍在使用老配置 Cold，而 Server3、Server4、Server5 的状态机已经应用了新配置 Cnew：\n假设 Server5 触发选举并赢得 Server3、Server4、Server5 的投票（满足 Cnew 配置下的 Quorum 3 要求），成为领导者；\n同时，假设 Server1 也触发选举并赢得 Server1、Server2 的投票（满足 Cold配置下的 Quorum 2 要求），成为领导者。\n一个集群存在两个领导者也就是“脑裂”，同一个日志索引可能会对应不同的日志条目，最终导致集群数据不一致。\n上述问题的根本原因在于，成员变更过程中形成了两个没有交集的 Quorum，即 [Server1, Server2] 和 [Server3, Server4, Server5] 各自为营。\nRaft 的论文中，对此提出过一种基于两阶段的“联合共识”（Joint Consensus）成员变更方案，但这种方案实现较为复杂，Diego Ongaro 后来又提出一种更为简化的方案 — 单成员变更（Single Server Changes）。该方案思想的核心是，既然同时提交多个成员变更可能引发问题，那么每次只提交一个成员变更，需要添加多个成员，就执行多次单成员变更操作。这样不就没有问题了么！\n单成员变更方案很容易穷举所有情况，如下图所示，穷举奇/偶数集群下节点添加/删除情况。如果每次只操作一个节点，Cold 的 Quorum 和 Cnew 的 Quorum 一定存在交集。交集节点只会进行一次投票，要么投票给 Cold，要么投票给 Cnew。因此，不可能出现两个符合条件的 Quorum，也就不会出现两个领导者。\n以下图第二种情况为例，Cold 为 [Server1、Server2、Server3]，该配置的 Quorum 为 2，Cnew 为 [Server1、Server2、Server3、Server4]，该配置的 Quorum 为 3。假设 Server1、Server2 比较迟钝，还在用 Cold ，其他节点的状态机已经应用 Cnew：\n假设 Server1 触发选举，赢得 Server1，Server2 的投票，满足 Cold Quorum 要求，当选领导者；\n假设 Server3 也触发选举，赢得 Server3，Server4 的投票，但不满足 Cnew 的 Quorum 要求，选举失效。\n目前，绝大多数 Raft 算法的实现和系统，如 HashiCorp Raft 和 etcd，均采用单节点变更方案。\n","date":"2026-03-27T00:00:00Z","image":"https://liusir521.github.io/p/%E5%88%86%E5%B8%83%E5%BC%8F%E5%85%B1%E8%AF%86%E7%AE%97%E6%B3%95/book_hu_7d5f5b09f91c89db.jpg","permalink":"https://liusir521.github.io/p/%E5%88%86%E5%B8%83%E5%BC%8F%E5%85%B1%E8%AF%86%E7%AE%97%E6%B3%95/","title":"分布式共识算法"},{"content":" 源码地址：https://github.com/liusir521/mit6.5840\n前言 久闻mit6.824大名，一直没找到合适的时机进行实操，最近在工作之余对此课程进行了学习。这篇文章总结一下个人学习lab1时的一些心得体会。\nlab1简介 在本实验中，你将构建一个 MapReduce 系统。你将实现一个工作进程，该进程调用应用程序的 Map 和 Reduce 函数并处理文件的读写操作；以及一个协调器进程，该进程将任务分配给工作进程并处理故障的工作进程。\n实验中给出了很多细节上的方案与提示，建议先多看几遍实验任务。\n流程介绍：我们需要通过多个Map任务去读取系统给出的文件中的单词，并根据单词的key的hash值将其归入指定的中间文件中(mr-X-Y)，当所有的Map任务均处理完毕之后，启动Reduce任务，每个Reduce任务会找到自己对应的中间文件并对内部的单词进行统计，最后输出到对应的文件中(mr-out-Y)。\n流程图如下：\n其中X代表的是map任务的编号，Y代表的是reduce任务的编号\n总体设计 总体需要我们来实现的其实并不是关于KeyValue键值对的处理，这部分可以参考示例中给出的代码，无需我们再次实现，只需在原本单机场景之下改写为多任务模式即可。主要需要我们实现的是关于任务的分配处理代码。主要包括三部分：\nrpc：定义实验中所需要的关于rpc通讯用到的相关结构体\nWorker：定义工作进程相关的代码\nCoordinator：起到协调作用，定义关于任务分配等相关的具体实现\n具体实现 Coordinator Coordinator中需要我们定义整体任务分配相关的结构体，从原有的函数中可以看出还需要我们实现rpc被调用方的相关函数，包括获取任务、完成任务以及Coordinator初始化。同时官方给出需要进行超时校验(10s)，我们还需要在获取任务时进行超时校验。\n关于Coordinator结构体我们需要定义的关键内容有：\nfiles：文件数组，用于分配给各个map任务执行，每个map对应一个文件，所以map的总任务数其实就是文件总数\nnReduce：系统指定的reduce任务的总数\nmapTasks：map的任务列表\nreduceTasks：reduce的任务列表\nisMapFinished：map任务是否全部完成的标志\nisReduceFinished：reduce任务是否全部完成的标志，只有map任务全部完成之后才会启动reduce任务，reduce任务全部完成之后也就标志的整体流程结束\nsync.Mutex：同理需要一个锁对象来控制流程（也可以对map和reduce分别设置，这里简便实现只设置一个）\n关于Task任务结构体我们需要定义的关键内容有：\nTaskType：任务类型，map or reduce\nTaskStatus：任务状态，等待中、运行中、已完成\nNReduce：系统NReduce变量，用于map任务中对应的key流向哪个文件（hash之后对NReduce取余）\nMapTaskIndex：如果是map任务，当前map任务的编号\nReduceTaskIndex：如果是reduce任务，当前reduce任务的编号\nFileName：如果是map任务，当前map任务要处理的文件名\n我这里只是一个粗略的设计，有很多地方可以进行优化，比如可以添加map、reduce还未完成的数量，通过原子类操作数量的方式会比我这里使用遍历的方式更加的合理。锁也可以设计为两个，在超时校验时或许可以再节省一些时间。\n获取任务 在获取任务时，需要首先判断是否存在超时文件，然后接着首先判断是否需要分配map任务，先把map任务分配完毕，然后才是reduce任务，当reduce任务也执行完毕，就返回退出标志。（当map任务都在执行，reduce任务尚未开启，此时返回等待标志。reduce任务都在执行时也是）\n关键代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 type Coordinator struct { // Your definitions here. mu sync.Mutex // 共用锁 files []string nReduce int mapTasks []Task // map任务列表 reduceTasks []Task // reduce任务列表 isMapFinished bool // map任务是否完成 isReduceFinished bool // reduce任务是否完成 nextTaskId int } // 任务结构体 type Task struct { TaskID int TaskType int TaskStatus int // 当前任务状态 NReduce int // map任务需要知道分配到多少个reduce任务 MapTaskIndex int ReduceTaskIndex int FileName string StartTime time.Time // 用于判断超时 } // Your code here -- RPC handlers for the worker to call. // 获取任务 func (c *Coordinator) GetTask(args *GetTaskReq, reply *GetTaskReply) error { c.mu.Lock() defer c.mu.Unlock() // 判断是否所有任务均完成 if c.isMapFinished \u0026amp;\u0026amp; c.isReduceFinished { reply.TaskType = ExitTask return nil } // 判断是否有超时任务 c.checkTimeoutTask() // 判断map任务是否都完成 if !c.isMapFinished { for i, task := range c.mapTasks { // 如果存在map waiting任务，则将其改为running，并返回给worker执行 if c.mapTasks[i].TaskStatus == Waiting { reply.TaskID = task.TaskID reply.TaskType = task.TaskType reply.FileName = task.FileName reply.NReduce = task.NReduce reply.ReduceID = task.ReduceTaskIndex c.mapTasks[i].TaskStatus = Running c.mapTasks[i].StartTime = time.Now() return nil } } reply.TaskType = WaitingTask return nil } // 到这里说明map任务已经完成，判断reduce任务是否都完成 if !c.isReduceFinished { for i, task := range c.reduceTasks { // 如果存在reduce waiting任务，则将其改为running，并返回给worker执行 if c.reduceTasks[i].TaskStatus == Waiting { reply.TaskID = task.TaskID reply.TaskType = task.TaskType reply.AllMapNum = len(c.mapTasks) reply.ReduceID = task.ReduceTaskIndex c.reduceTasks[i].TaskStatus = Running c.reduceTasks[i].StartTime = time.Now() return nil } } } reply.TaskType = WaitingTask return nil } // 查看是否存在任务超时 func (c *Coordinator) checkTimeoutTask() { // 超时时间 10s timeout := 10 * time.Second // 如果 map 任务已经完成，则返回 // if c.isMapFinished { // return // } // 这里不可以直接return，会导致下面的reduce任务无法判断 now := time.Now() if !c.isMapFinished { for i := 0; i \u0026lt; len(c.mapTasks); i++ { task := c.mapTasks[i] // 判断正在执行的任务是否存在超时的 if task.TaskStatus == Running \u0026amp;\u0026amp; now.Sub(task.StartTime) \u0026gt; timeout { // 如果存在，将状态改为 waiting，等待其他 worker 来执行 c.mapTasks[i].TaskStatus = Waiting // 这里不需要修改任务的time，后面检测到waiting时，会重新标记时间 // return 不 return，继续判断下一个任务，否则每次只标记到了一个任务就返回了 } } } // 如果 reduce 任务已经完成，则返回 // 这里可以直接return，代表全部任务执行完毕 if c.isReduceFinished { return } for i := 0; i \u0026lt; len(c.reduceTasks); i++ { task := c.reduceTasks[i] // 存在超时任务 if task.TaskStatus == Running \u0026amp;\u0026amp; now.Sub(task.StartTime) \u0026gt; timeout { // 将状态改为 waiting，等待其他 worker 来执行 c.reduceTasks[i].TaskStatus = Waiting // 这里不需要修改任务的time，后面检测到waiting时，会重新标记时间 // return } } } func MakeCoordinator(files []string, nReduce int) *Coordinator { c := Coordinator{ files: files, nReduce: nReduce, mapTasks: make([]Task, len(files)), reduceTasks: make([]Task, nReduce), nextTaskId: 0, isMapFinished: false, isReduceFinished: false, } // map任务初始化 for i, file := range files { c.mapTasks[i] = Task{ TaskID: i, TaskType: MapTask, TaskStatus: Waiting, NReduce: nReduce, MapTaskIndex: i, FileName: file, StartTime: time.Now(), } c.nextTaskId++ } // reduce任务初始化 for i := 0; i \u0026lt; nReduce; i++ { c.reduceTasks[i] = Task{ TaskID: i, TaskType: ReduceTask, TaskStatus: Waiting, ReduceTaskIndex: i, } c.nextTaskId++ } c.server() return \u0026amp;c } 刚开始时，我在校验超时时间中犯了个逻辑错误，我先校验了map任务是否都已经完成了，完成之后我直接return了，这个操作就导致了后面超时函数无法校验到reduce任务的状态(是否存在超时任务)，就导致最后一个测验一直无法通过。\n完成任务 在完成任务中，我们需要判断当前完成的任务的类型，然后判断是否当前所有类型的任务都完成了。关键代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 // 完成任务 func (c *Coordinator) CompleteTask(args *CompleteTaskReq, reply *CompleteTaskReply) error { c.mu.Lock() defer c.mu.Unlock() // 完成map任务 switch args.TaskType { case MapTask: // map任务完成 // 判断所有map任务是否完成 allmapfinished := true for i, task := range c.mapTasks { if args.TaskID == task.TaskID { c.mapTasks[i].TaskStatus = Finished } else if task.TaskStatus != Finished { allmapfinished = false } } // 所有map任务完成 c.isMapFinished = allmapfinished case ReduceTask: // 完成reduce任务 // 检测reduce任务是否完成 if c.isReduceFinished { return nil } allreducefinished := true for i, task := range c.reduceTasks { if args.TaskID == task.TaskID { c.reduceTasks[i].TaskStatus = Finished } else if task.TaskStatus != Finished { allreducefinished = false } } c.isReduceFinished = allreducefinished } return nil } rpc rpc.go文件中主要是对实验中需要用到的rpc相关的请求和响应的相关结构体，关键代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 // 任务类型枚举定义 const ( MapTask = iota ReduceTask WaitingTask // 任务正在执行，此时需要task等待 ExitTask // map、reduce任务已经执行完毕，退出信号 ) // task任务状态枚举定义 const ( Waiting = iota Running Finished ) // 获取任务请求 type GetTaskReq struct { WorkerID int } // 获取任务响应 type GetTaskReply struct { TaskID int TaskType int // 当收到ExitTask时，结束标志 FileName string AllMapNum int // 一共有多少map任务，用于reduce过程，其实相当于map处理文件的个数，一个map任务对应一个文件 ReduceID int // 当前reduce任务负责的分区编号 NReduce int TaskStatus int } // 完成任务请求 type CompleteTaskReq struct { TaskType int TaskID int } // 完成任务响应 type CompleteTaskReply struct { } 其中，GetTaskReply就是获取task任务的响应结构体，根据tasktype字段判断当前任务是map任务还是reduce任务，然后再从里面获取需要使用到的字段。\nWorker 在worker中，需要我们通过rpc向调度器获取任务并执行具体的任务，当任务执行完毕时通过rpc向调度器汇报。其中具体的执行任务流程可以参考示例中给出的代码。关于文件的操作，可以使用课程中提示的中间文件的形式。\n关键代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 // 请求任务并执行 func Worker(mapf func(string, string) []KeyValue, reducef func(string, []string) string) { // Your worker implementation here. for { workerid := os.Getpid() reply := getWorkerTask(workerid) switch reply.TaskType { case MapTask: startMapTask(workerid, reply, mapf) case ReduceTask: startReduceTask(workerid, reply, reducef) case WaitingTask: // 休眠0.5秒 time.Sleep(500 * time.Millisecond) continue case ExitTask: return } } // uncomment to send the Example RPC to the coordinator. // CallExample() } // for sorting by key. type ByKey []KeyValue // for sorting by key. func (a ByKey) Len() int { return len(a) } func (a ByKey) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByKey) Less(i, j int) bool { return a[i].Key \u0026lt; a[j].Key } func startReduceTask(workerid int, reply GetTaskReply, reducef func(string, []string) string) { // 根据传入的reduceNum确定所负责的任务index // 然后去所有map产出的文件中找到对应index的文件进行reduce kva := []KeyValue{} for i := 0; i \u0026lt; reply.AllMapNum; i++ { curfilename := fmt.Sprintf(\u0026#34;mr-%d-%d\u0026#34;, i, reply.ReduceID) file, err := os.Open(curfilename) if err != nil { log.Fatalf(\u0026#34;reduce cannot open %v\u0026#34;, file) } dec := json.NewDecoder(file) for { var kv KeyValue if err := dec.Decode(\u0026amp;kv); err != nil { break } kva = append(kva, kv) } file.Close() } // 对结果进行排序 sort.Sort(ByKey(kva)) tempfile, err := os.CreateTemp(\u0026#34;\u0026#34;, \u0026#34;mr-out-tmp-*\u0026#34;) if err != nil { log.Fatal(\u0026#34;create tempfile err\u0026#34;, err) } // 参考示例代码处理键值对 i := 0 for i \u0026lt; len(kva) { j := i + 1 for j \u0026lt; len(kva) \u0026amp;\u0026amp; kva[j].Key == kva[i].Key { j++ } values := []string{} for k := i; k \u0026lt; j; k++ { values = append(values, kva[k].Value) } output := reducef(kva[i].Key, values) // this is the correct format for each line of Reduce output. fmt.Fprintf(tempfile, \u0026#34;%v %v\\n\u0026#34;, kva[i].Key, output) i = j } tempfile.Close() err = os.Rename(tempfile.Name(), fmt.Sprintf(\u0026#34;mr-out-%d\u0026#34;, reply.ReduceID)) if err != nil { log.Fatal(\u0026#34;rename err\u0026#34;, err) } completeTask(reply.TaskID, reply.TaskType) } // Map任务 func startMapTask(workerid int, reply GetTaskReply, mapf func(string, string) []KeyValue) { // 首先读取文件kv，并根据kv的hash分配到不同的切片中 // 然后使用临时文件的方式，将这些kv存入对应的临时文件，最后写入对应的reduce文件中 // 读取文件内容参考示例文件 filename := reply.FileName file, err := os.Open(filename) if err != nil { log.Fatalf(\u0026#34;map cannot open %v %v\u0026#34;, filename, err) } content, err := io.ReadAll(file) if err != nil { log.Fatalf(\u0026#34;cannot read %v\u0026#34;, filename) } file.Close() kva := mapf(filename, string(content)) // intermediate = append(intermediate, kva...) // 示例文件给出的是单机版本，这里需要实现并发版本 intermediate := make([][]KeyValue, reply.NReduce) for _, kv := range kva { // 计算hash并分配 index := ihash(kv.Key) % reply.NReduce intermediate[index] = append(intermediate[index], kv) } // 临时文件方式存储 for i := 0; i \u0026lt; reply.NReduce; i++ { tempfile, err := os.CreateTemp(\u0026#34;\u0026#34;, \u0026#34;mr-tmp-*\u0026#34;) if err != nil { log.Fatal(\u0026#34;create tempfile err\u0026#34;, err) } // 使用推荐的json包处理kv encoder := json.NewEncoder(tempfile) for _, kv := range intermediate[i] { err := encoder.Encode(\u0026amp;kv) if err != nil { log.Fatal(\u0026#34;json write tempfile err\u0026#34;, err) } } tempfile.Close() reducename := fmt.Sprintf(\u0026#34;mr-%d-%d\u0026#34;, reply.TaskID, i) os.Rename(tempfile.Name(), reducename) } // 汇报任务执行完成 completeTask(reply.TaskID, MapTask) } // 通过rpc获取任务 func getWorkerTask(workerID int) GetTaskReply { // rpc获取任务 req := GetTaskReq{ WorkerID: workerID, } reply := GetTaskReply{} call(\u0026#34;Coordinator.GetTask\u0026#34;, \u0026amp;req, \u0026amp;reply) return reply } // 汇报任务完成 func completeTask(taskid, tasktype int) { req := CompleteTaskReq{ TaskID: taskid, TaskType: tasktype, } reply := CompleteTaskReply{} call(\u0026#34;Coordinator.CompleteTask\u0026#34;, \u0026amp;req, \u0026amp;reply) } 总结 个人感觉，实验主要还是考验的思维逻辑能力，而不是一些细节实现，毕竟一些关键实现实验中已经提供了。\n代码执行结果如下：\n","date":"2026-03-25T00:00:00Z","image":"https://liusir521.github.io/p/mit-lab1%E4%B8%AA%E4%BA%BA%E7%AC%94%E8%AE%B0/mit_hu_7d5f5b09f91c89db.jpg","permalink":"https://liusir521.github.io/p/mit-lab1%E4%B8%AA%E4%BA%BA%E7%AC%94%E8%AE%B0/","title":"Mit lab1个人笔记"},{"content":" 源码地址：https://github.com/liusir521/mit6.5840\n简介 实验二要求我们实现一个单机版的键值服务器，并实现Get\\Put\\Append方法。需要考虑的重点是多个客户端并发访问的问题以及由于网络原因导致的客户端请求重试问题（幂等性）。\n实验二其实并不难，针对Get操作我们不需要做过多的处理，主要是Put和Append操作。我们只需要在服务端定义好存储客户端id与该客户端请求id的对应关系，以及客户端id和上次缓存的结果即可(用于Append请求)。\n代码实现 客户端 客户端的代码主要在client.go文件中，该文件中需要我们完善Clerk客户端结构体，其中我们需要添加clientID以及requestID即可，文件中已经提供了相关的函数，我们只需要在每次Put请求和Append请求成功时增加requestID即可。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 type Clerk struct { server *labrpc.ClientEnd clientID int64 requestID int64 mu sync.Mutex } func nrand() int64 { max := big.NewInt(int64(1) \u0026lt;\u0026lt; 62) bigx, _ := rand.Int(rand.Reader, max) x := bigx.Int64() return x } func MakeClerk(server *labrpc.ClientEnd) *Clerk { ck := \u0026amp;Clerk{ clientID: nrand(), requestID: 0, } ck.server = server return ck } func (ck *Clerk) Get(key string) string { args := GetArgs{Key: key} var reply GetReply for { ok := ck.server.Call(\u0026#34;KVServer.Get\u0026#34;, \u0026amp;args, \u0026amp;reply) if ok { return reply.Value } else { time.Sleep(100 * time.Millisecond) } } } func (ck *Clerk) PutAppend(key string, value string, op string) string { args := PutAppendArgs{ ClientId: ck.clientID, Key: key, Value: value, RequestId: ck.requestID, } var reply PutAppendReply for { ok := ck.server.Call(\u0026#34;KVServer.\u0026#34;+op, \u0026amp;args, \u0026amp;reply) if ok { ck.mu.Lock() ck.requestID++ ck.mu.Unlock() return reply.Value } else { time.Sleep(100 * time.Millisecond) } } } func (ck *Clerk) Put(key string, value string) { ck.PutAppend(key, value, \u0026#34;Put\u0026#34;) } func (ck *Clerk) Append(key string, value string) string { return ck.PutAppend(key, value, \u0026#34;Append\u0026#34;) } rpc定义 关于rpc的定义位于common.go文件中，代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // Put or Append type PutAppendArgs struct { Key string Value string // You\u0026#39;ll have to add definitions here. // Field names must start with capital letters, // otherwise RPC will break. ClientId int64 RequestId int64 } type PutAppendReply struct { Value string } type GetArgs struct { Key string // You\u0026#39;ll have to add definitions here. } type GetReply struct { Value string } 服务端 服务端需要我们修改KVServer结构体来满足Append请求，具体代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 type KVServer struct { mu sync.Mutex // Your definitions here. data map[string]string lastclientrequestid map[int64]int64 lastclientresponse map[int64]string // 记录每个客户端上次请求的返回值，只用于Append方法 } func (kv *KVServer) Get(args *GetArgs, reply *GetReply) { // Your code here. kv.mu.Lock() defer kv.mu.Unlock() reply.Value = kv.data[args.Key] } func (kv *KVServer) Put(args *PutAppendArgs, reply *PutAppendReply) { // Your code here. kv.mu.Lock() defer kv.mu.Unlock() lastReqId, exists := kv.lastclientrequestid[args.ClientId] if !exists || args.RequestId \u0026gt; lastReqId { kv.lastclientrequestid[args.ClientId] = args.RequestId kv.data[args.Key] = args.Value } } func (kv *KVServer) Append(args *PutAppendArgs, reply *PutAppendReply) { // Your code here. kv.mu.Lock() defer kv.mu.Unlock() lastReqId, exists := kv.lastclientrequestid[args.ClientId] if !exists || args.RequestId \u0026gt; lastReqId { // 新请求，执行追加 oldvalue := kv.data[args.Key] kv.lastclientrequestid[args.ClientId] = args.RequestId kv.data[args.Key] += args.Value reply.Value = oldvalue // 更新记录值 kv.lastclientresponse[args.ClientId] = oldvalue } else { // 重复请求，返回上次记录的响应值 reply.Value = kv.lastclientresponse[args.ClientId] } } func StartKVServer() *KVServer { kv := \u0026amp;KVServer{ data: make(map[string]string), lastclientrequestid: make(map[int64]int64), lastclientresponse: make(map[int64]string), } return kv } 测试结果 结果如下\n","date":"2026-03-25T00:00:00Z","image":"https://liusir521.github.io/p/mit-lab2%E4%B8%AA%E4%BA%BA%E7%AC%94%E8%AE%B0/mit_hu_7d5f5b09f91c89db.jpg","permalink":"https://liusir521.github.io/p/mit-lab2%E4%B8%AA%E4%BA%BA%E7%AC%94%E8%AE%B0/","title":"Mit lab2个人笔记"},{"content":"存储引擎的数据结构 存储引擎要做的事情是将磁盘的数据读取到内存并返回给应用，或者将由应用更改的数据从内存写入磁盘。目前大多数流行的存储引擎都是基于B-Tree或LSM(Log Structured Merge)-Tree这两种数据结构设计的。\nOracle、SQL Server、MySql(InnoDB)和PostgreSQL等传统型关系型数据库依赖的底层存储引擎都是基于B-Tree开发的；而ElasticSearch(Lucene)、Apache HBase、LevelDB和RocksDB等NoSQL数据库存储引擎都是基于LSM-Tree开发的。当然有些数据库采用了插件式的存储引擎架构，实现了Server层和存储引擎层的解耦，可以支持多种存储引擎，如MySQL既可以支持B-Tree数据结构的InnoDB存储引擎，又可以支持LSM-Tree数据结构的RocksDB存储引擎。\n对于MongoDB来说，也采用了插件式存储引擎架构，底层的WiredTiger存储引擎可以支持B-Tree和LSM-Tree两种数据结构组织数据，但MongoDB在使用WiredTiger作为存储引擎时，目前默认的配置是使用B-Tree数据结构。\n磁盘中的基础数据结构 对于WiredTiger存储引擎来说，集合所在的数据文件和相应的索引文件都是按B-Tree数据结构来组织的，不同之处在于 数据文件 对应的B-Tree叶子节点上除了存储键名(key)外，还会存储真正的集合数据(value)，所以数据文件的存储结构也可以被认为是一种B+Tree结构。结构如下图所示。\n从图中我们可以看出，B+Tree结构中的leaf page包含一个页头、块头和真正的数据。其中，页头定义了页的类型、页中实际存储数据的大小、页中记录条数等信息；块头定义了此页的checknum、块在磁盘上的寻址位置等信息。\n内存中的基础数据结构 WiredTiger会按需求将磁盘中的数据以page为单位加载到内存，同时在内存中会构造相应的B-Tree结构来存储这些数据。为了更高效的支撑CRUD等操作以及将内存中的数据持久化到磁盘，WiredTiger也会在内存中维护其他的数据结构。\n内存中的B-Tree结构包含3种类型的page，即root page、internal page和leaf page。前两者包含指向其子页的page index指针，不包含真正的数据，leaf page包含集合中的真正数据和指向父页的指针。\n内存中的leaf page会维护一个WT_ROW的数组变量，将保存从磁盘leaf page读取的key/value值，每一条记录都有一个cell_offset的变量，表示这条记录在page上的偏移量。\n内存中的leaf page会维护一个WT_UPDATE结构的数组变量，每条被修改的记录都会有一个数组元素与之对应，如果某条记录被多次修改，则会将修改值以链表形式保存。\n内存中的leaf page会维护一个WT_INSERT_HEAD结构的数组变量，具体插入的data会保存在WT_INSERT_HEAD结构的WT_UPDATE属性上，且通过key属性的offset和size可以计算出此条记录要插入的位置；同时，为了提高寻找插入位置的效率，每个WT_INSERT_HEAD结构的数组变量以跳转链表的形式构成。\npage的其他数据结构 WT_PAGE_MODIFY：用于保存page上事务、脏数据字节大小等与page修改相关的信息。\nWT_PAGE_LOOKASIDE：当对一个page进行reconclie（写入磁盘）时，如果系统中还有之前的读操作正在访问此page中修改的数据，则会将这些数据保存到lookaside table中。当再次读page时，可以利用lookaside table中的数据重新构建内存page。\nWT_ADDR：当page被成功reconclied后，对应的磁盘上块的地址，会按照这个地址将page写入磁盘，块是磁盘上文件的最小分配单元，一个page可能有多个块。\nchecksum：page的校验和，如果page从磁盘读到内存上之后没有任何修改，比较checksum可以得到相同的结果，后续reconclie时此page不必再写入磁盘。\npage eviction——页面淘汰 当内存中的脏页达到一定比例或内存使用量达到一定比列时，就会触发相应的evict page线程将page按一定的算法淘汰（LRU队列），以便有足够的空间，从而保障后续的插入和修改操作。\n当内存使用量达到eviction_target设定值时（默认配置为80%），会触发后台线程执行page eviction；如果内存使用量继续增长，达到eviction_trigger设定值时（默认90%），则应用线程支撑的读写操作等请求被阻塞，应用线程也参与到页面淘汰中，以加速淘汰内存中的page。\n当内存中的脏数据达到eviction_dirty_target设定值时（默认5%），会触发后台线程执行page eviction；如果脏数据继续增长，达到eviction_dirty_trigger设定值（默认20%）时，则会同时触发应用线程来执行page eviction。\n特殊情况：当在page上不断进行插入或更新操作时，如果page中的内容占用内存的空间大于系统设定的最大值，则会强制触发page eviction。首先将大page拆分成多个小page，再通过reconcile将这些小的page保存到磁盘上，一旦reconcile写入磁盘的操作完成，这些page就能从内存中淘汰出去，从而为后面的操作保留足够的空间。\npage reconcile——数据写入磁盘 WiredTiger实现了一个reconcile模块来完成将内存中的修改的数据生成相应的磁盘映像（与磁盘中的page格式匹配），再将这些磁盘映像写入磁盘的操作。\n将内存leaf page中的新插入和修改的数据写入磁盘流程如下：\n首先，内存中的leaf page中修改的插入的数据分别会保存在WT_UPDATE和WT_INSERT_HEAD数组中。\n然后，创建一个buffer(缓存)，为其分配一个磁盘page大小的内存，遍历leaf page中所有插入数组和修改数组上的key/value，将这些数据依次复制到buffer中并进行排序。\n如果数据所占内存不超过一个磁盘page的大小，则会直接将这些数据写入一页磁盘映像中，再写入磁盘。\n如果数据超过一个磁盘page，则会将数据分为多个磁盘映像，然后将所有的磁盘映像写入磁盘。\nCache的分配规则 WiredTiger启动时会向操作系统申请一部分内存以供自己使用，这部分内存被称为Internal Cache。如果在主机上只运行MongoDB相关的服务进程，则剩余的内存可以作为文件系统的缓存（File System Cache）并由操作系统负责管理。\n当MongoDB启动时，首先从整个主机内存中切出一大块来分给WiredTiger的Internal Cache，以用于构建B-Tree中的各种page，以执行基于这些page的增加、删除、修改、查询等操作。\n然后，从主机内存中再额外划分出一部分内存容量以供MongoDB创建索引专用，默认最大值500MB。\n最后，将主机的剩余内存容量作为文件系统缓存，供MongoDB使用，这样，MongoDB可以将压缩的文件也缓存到内存中，从而减少磁盘IO次数。\n为了节省磁盘空间，集合和索引在磁盘中的数据是被压缩的，在默认情况下，集合采取的是块压缩算法，索引采取的是前缀压缩算法。\n事务 MongoDB3.0版本引入WiredTiger存储引擎之后开始支持事务，3.6之前的版本只能支持单文档事务，从4.0开始支持复制集部署模式下的事务，从4.2开始支持分片集群中的事务。\nMongoDB的所有事务都在一个sesion中，且一个session可以包含多个事务。\n事务的基本原理 与关系型数据库一样，MongoDB事务同样具有ACID特性。\n原子性（Automicity）：一个事务要么完全执行成功，要么不做任何改变。\n一致性（Consistency）：当多个事务并行时，元素的属性在每个事务中保持一致。\n隔离性（Isolation）：当多个事务同时执行时，互不影响。WiredTiger提供了三种隔离级别，读未提交、读已提交和快照，MongoDB默认选择的是快照隔离级别。\n持久性（Durability）：一旦事务提交，数据的更改就不会丢失。\n在不同的隔离级别下，一个事务的生命周期内，允许出现的数据范围不一样，可能会出现脏读、不可重复读、幻读等现象。\n脏读 事务A读取到了事务B还未提交的数据。\n不可重复读 事务A前后两次读取同一记录的值不一样。\n幻读 事务A前后两次读取的数据集不一样（条数不一样）。\n每种隔离级别现象分析：\n读未提交（read-uncommitted）：A事务运行过程中能看到B事务修改但未提交的数据，因此可能出现脏读、不可重复读、幻读。\n读已提交（read-committed）：A事务运行过程中能看到B事务修改且提交过的数据，可以避免脏读，但不能避免不可重复读和幻读。\n快照隔离（snapshot）：A事务运行过程中能看到A事务开始之前且已经提交的其他事务的数据和A事务开始时其他未提交的事务的修改的数据，A事务开始之后其他事务再提交的修改数据是看不到的。快照隔离不会出现脏读和不可重复读，但可能会出现幻读。\n事务的snapshot隔离 MongoDB启动时默认选择的是snapshot隔离级别。事务开始时，会创建一个快照，从已提交的事务中获取行版本数据，如果行版本数据标识的事务尚未提交，则从更早的事务中获取已提交的行版本数据作为其事务开始时的值。\n通过事务可以看到其他还未提交的事务修改的行版本数据，但不会看到事务id大于snap_max的事务修改的数据。\nMVCC并发控制机制 要实现事务之间的并发操作，可以使用锁机制和MVCC控制等。对于WiredTiger来说，使用MVCC控制来实现并发操作，相较于锁机制的并发，MVCC是一种乐观并发机制，因此它比较轻量级。\nMVCC是在内存中维护一个多版本的行数据的，也就是说它会将多个写操作，针对同一行记录的修改以不同行版本号的形式保存下来，从而实现事务的并发操作。\n示例如下：\nA事务首先从表中读取要修改的行数据，读取的库存值为100，行记录的版本号是1。\nB事务也从中读取要修改的相同行数据，读取的库存值是100，行记录的版本号是1。\nA事务修改库存值后提交，同时记录行版本号加1，变为2，大于A事务一开始读取的版本号，A事务可以提交。\n但B事务提交时发现此时行记录版本号为2，产生冲突，B事务提交失败。\nB事务尝试重新提交，此时再次读取的版本号为2，再加1后为3，不会产生冲突，B事务正常提交。\n事务日志（Journal） Journal是一种WAL（Write Ahead Log）事务日志，目的是实现事务提交层面的数据持久化。\nJournal持久化的对象不是修改的数据，而是修改的动作，以日志的形式先保存到事务日志缓存中，再根据相应的配置按照一定的周期，将缓存中的日志数据写入日志文件中。\n完整的写操作流程 在一个session中开启一个事务，同时构造一个snapshot的结构，作为本次事务执行过程中能够看到的快照数据。\n将写操作相关的事务日志写入日志缓存中，再提交事务，如果发生错误则回滚事务；事务日志按照设定的规则持续从内存刷新到磁盘。\n写操作修改的数据在缓存中以特定的数据结构被保存起来。\n当缓存中的内存使用量或脏数据达到一定条件时，会触发页面淘汰动作，从淘汰队列中按优先级选取包含修改数据的内存page写入相应的磁盘page中。同时，在这个过程中，会先通过reconcile线程将修改的数据构造成磁盘映像格式，再写入磁盘，然后，删除内存脏页以释放占用的内存。\n当真正的数据page从内存写入磁盘上时，会调用WiredTiger的block management模块提供的接口完成，同时压缩数据。\n","date":"2026-03-05T00:00:00Z","image":"https://liusir521.github.io/p/mongodb%E4%B9%8Bwiredtiger%E5%AD%98%E5%82%A8%E5%BC%95%E6%93%8E/mongo_hu_6292a4067b82900.png","permalink":"https://liusir521.github.io/p/mongodb%E4%B9%8Bwiredtiger%E5%AD%98%E5%82%A8%E5%BC%95%E6%93%8E/","title":"MongoDB之WiredTiger存储引擎"},{"content":"底层结构 源码：src/internal/runtime/maps\n使用Directory管理多个table，Directory是 Table的数组 ，大小为2^globalDepth。如果globalDepth=2，那Directory最多有4个表，分为0x00、0x01、0x10、0x11。Go通过key的hash值的前 globalDepth 个bit来选择table。这是一种“extendible hashing”，这是一种动态哈希技术，其核心特点是通过动态调整使用的哈希位数(比如上面提到的globalDepth)来实现渐进式扩容。比如：初始可能只用1位哈希值来区分，需要时可以扩展到用2位，再需要时可以扩展到用3位，以此类推。\n关键源码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 type Map struct { // 已填充槽位的数量（即所有表中的元素总数）。 // 不包含已删除的槽位。 // 必须放在结构体的第一个字段 //（编译器已知，用于 len() 内建函数）。 used uint64 // 哈希种子 seed uintptr // 通常dirPtr指向一个tables数组 // directory [] *Table // 该数组的长度（dirLen）为 `1 \u0026lt;\u0026lt; globalDepth`。 // 多个目录项可能指向同一个表。 // 小 map 优化：如果 map 中的元素数量始终不超过abi.SwissMapGroupSlots=8，则可以完全放入一个单独的 group 中。 // 在这种情况下，dirPtr 直接指向一个 group。 // 此时，dirLen 为 0。used 表示该 group 中已使用的槽位数量。 // 注意：小 map 永远不会有已删除的槽位 //（因为不存在需要维护的探测序列）。 dirPtr unsafe.Pointer dirLen int // 用于表目录（table directory）查找的比特数。 globalDepth uint8 // 在进行目录（directory）查找时，需要从哈希值中右移（丢弃）的比特数。 // 在 64 位系统上，该值为 64 - globalDepth。 globalShift uint8 // writing 是一个标志位，在 map 被写入时通过异或 1（XOR 1）来翻转。 // 通常在写入期间它被置为 1； // 但如果存在多个并发写入者，持续翻转该标志 // 可以提高双方检测到竞争条件（data race）的概率。 writing uint8 // clearSeq 是对 Clear 调用次数的序列计数器。 // 用于在迭代过程中检测 map 是否被清空（clear）。 clearSeq uint64 } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 type table struct { // 已填充槽位的数量（即表中元素的数量）。 used uint16 // 槽位的总数量（始终为 2 的 N 次幂）。 // 等于 `(groups.lengthMask + 1) * abi.SwissMapGroupSlots`。 capacity uint16 // 在无需 rehash 的前提下仍可使用的槽位数。 // 当 used + tombstones \u0026gt; loadFactor * capacity 时触发 rehash， // 计入 tombstones，以防止哈希表被墓碑项过度填充。 // 该字段表示距离下一次 rehash 还剩余的空槽位数量。 growthLeft uint16 // 用于定位到该表的目录查找所需的比特位数。 // 注意：当目录已经增长但该表还未被分裂时， // 该值可能小于 globalDepth。 localDepth uint8 // 该表在 Map 目录中的索引。这里指的是目录中指向该表的 // 第一个索引位置；该表可能对应目录中的多个连续索引。 // 若该表已过期（不再存在于目录中），则 index 取值为 -1。 index int // groups 为槽位组数组。每个组包含 abi.SwissMapGroupSlots = 8 个 // 键/值槽位及其控制字节。表的 groups 数组大小固定， // 当需要扩容时，会在 rehash 时用新表替换。 // TODO(prattmic)：键和值交错存储可提高缓存局部性， // 但对某些类型会浪费空间（如 uint8 键 + uint64 值）。 // 可考虑在这些情况下将键集中存放以节省空间。 groups groupsReference } 1 2 3 4 type groupsReference struct { data unsafe.Pointer //group数组 lengthMask uint64 //len(group)-1，方便快速求模 } 1 2 3 4 5 6 7 8 type group struct { ctrl ctrlGroup // 8 字节 slots [8]slot } // 控制字 type ctrlGroup uint64 // 每个control byte 包含状态(1byte---空、删除、占用)+h2(哈希低7位) 特性 多个 directory index 可以指向同一个 table\ndirectory 里存的只是 指针\n初始化流程 关键源码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 func NewMap(mt *abi.SwissMapType, hint uintptr, m *Map, maxAlloc uintptr) *Map { if m == nil { m = new(Map) } m.seed = uintptr(rand()) if hint \u0026lt;= abi.SwissMapGroupSlots { // A small map can fill all 8 slots, so no need to increase // target capacity. // // In fact, since an 8 slot group is what the first assignment // to an empty map would allocate anyway, it doesn\u0026#39;t matter if // we allocate here or on the first assignment. // // Thus we just return without allocating. (We\u0026#39;ll save the // allocation completely if no assignment comes.) // Note that the compiler may have initialized m.dirPtr with a // pointer to a stack-allocated group, in which case we already // have a group. The control word is already initialized. return m } // Full size map. // Set initial capacity to hold hint entries without growing in the // average case. targetCapacity := (hint * abi.SwissMapGroupSlots) / maxAvgGroupLoad if targetCapacity \u0026lt; hint { // overflow return m // return an empty map. } dirSize := (uint64(targetCapacity) + maxTableCapacity - 1) / maxTableCapacity dirSize, overflow := alignUpPow2(dirSize) if overflow || dirSize \u0026gt; uint64(math.MaxUintptr) { return m // return an empty map. } // Reject hints that are obviously too large. groups, overflow := math.MulUintptr(uintptr(dirSize), maxTableCapacity) if overflow { return m // return an empty map. } else { mem, overflow := math.MulUintptr(groups, mt.GroupSize) if overflow || mem \u0026gt; maxAlloc { return m // return an empty map. } } m.globalDepth = uint8(sys.TrailingZeros64(dirSize)) m.globalShift = depthToShift(m.globalDepth) directory := make([]*table, dirSize) for i := range directory { // TODO: Think more about initial table capacity. directory[i] = newTable(mt, uint64(targetCapacity)/dirSize, i, m.globalDepth) } m.dirPtr = unsafe.Pointer(\u0026amp;directory[0]) m.dirLen = len(directory) return m } 源码解读：初始化时会根据 hint 的值进入不同的初始化逻辑：\nhint\u0026lt;=8，小map\n对于小map，指定容量hint\u0026lt;=8，做了优化，不再分配一个table数组，dirPtr直接引用分配 一个group\nhint\u0026gt;8\na\u0026gt; 首先计算目标容量 targetCapacity，算出需要多少slot，才能在“平均负载”下容纳 hint 个元素\nb\u0026gt; 计算 directory 大小dirSize（table 数）（ 需要多少个 table，才能容纳 targetCapacity ）\nc\u0026gt; 计算总的 group 数量和总内存。\nd\u0026gt; 设置 globalDepth / globalShift\ne\u0026gt; 分配directory并 创建table（初始化时table内部的localdepth=globalDepth）\nf\u0026gt; directory 写入Map（unsafe） ，最后返回Map。\n添加流程 关键源码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 func (m *Map) PutSlot(typ *abi.SwissMapType, key unsafe.Pointer) unsafe.Pointer { if m.writing != 0 { fatal(\u0026#34;concurrent map writes\u0026#34;) } hash := typ.Hasher(key, m.seed) // Set writing after calling Hasher, since Hasher may panic, in which // case we have not actually done a write. m.writing ^= 1 // toggle, see comment on writing if m.dirPtr == nil { m.growToSmall(typ) } if m.dirLen == 0 { if m.used \u0026lt; abi.SwissMapGroupSlots { elem := m.putSlotSmall(typ, hash, key) if m.writing == 0 { fatal(\u0026#34;concurrent map writes\u0026#34;) } m.writing ^= 1 return elem } // Can\u0026#39;t fit another entry, grow to full size map. // // TODO(prattmic): If this is an update to an existing key then // we actually don\u0026#39;t need to grow. m.growToTable(typ) } for { idx := m.directoryIndex(hash) elem, ok := m.directoryAt(idx).PutSlot(typ, m, hash, key) if !ok { continue } if m.writing == 0 { fatal(\u0026#34;concurrent map writes\u0026#34;) } m.writing ^= 1 return elem } } 源码解读：\n首先进行并发写检测，保障线程安全。\n然后计算key的hash，并标记写入状态，防止并发写。\n判断当前map是否已经分配了，如果没有分配，会先分配一个group（小map）。\na. 如果当前map是小map\n如果是小表且未满，直接插入，返回元素槽指针。\n如果小表已满，升级为大表（分配table并迁移数据）\nb. 如果当前map是大map\n根据H1计算目录索引，定位table（与查找相同）\n调用大map的插入函数，若table扩容则重试\n插入成功返回与元素槽指针\n再次检测并发写，防止并发写破坏数据。\n小map写入细节 首先获取group的引用，并结合H2对所有控制字进行比对。\n若存在相同的，比对是否存在相同key，如果存在则返回对应value槽指针。\n不存在相同的，查找空槽，准备写入新key，如果没有空槽，说明并发写入或者逻辑错误。\n写入key和value。\n更新控制字和used计数。\n返回value槽指针。\n大map写入细节 首先根据hash的高位H1和group的数量定位探查序列。\n定义变量，记录第一个已删除槽，以便后续使用。\n探查循环，遍历group，查找与H2匹配的槽。\n若有匹配的槽，对比key，若key需要更新，则拷贝新key\n无匹配槽，查找空槽或已删除槽（tombstone）。若有空槽且growthleft\u0026gt;0，优先复用空槽，设置控制字，更新计数。若growthleft耗尽（表已满），触发rehash（扩容或分裂），返回nil，false，提示上层重试。\n搜索流程 关键源码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 // Get performs a lookup of the key that key points to. It returns a pointer to // the element, or false if the key doesn\u0026#39;t exist. func (m *Map) Get(typ *abi.SwissMapType, key unsafe.Pointer) (unsafe.Pointer, bool) { return m.getWithoutKey(typ, key) } func (m *Map) getWithoutKey(typ *abi.SwissMapType, key unsafe.Pointer) (unsafe.Pointer, bool) { if m.Used() == 0 { return nil, false } if m.writing != 0 { fatal(\u0026#34;concurrent map read and map write\u0026#34;) } hash := typ.Hasher(key, m.seed) if m.dirLen == 0 { _, elem, ok := m.getWithKeySmall(typ, hash, key) return elem, ok } idx := m.directoryIndex(hash) return m.directoryAt(idx).getWithoutKey(typ, hash, key) } func (m *Map) directoryIndex(hash uintptr) uintptr { if m.dirLen == 1 { return 0 } return hash \u0026gt;\u0026gt; (m.globalShift \u0026amp; 63) } func (t *table) getWithoutKey(typ *abi.SwissMapType, hash uintptr, key unsafe.Pointer) (unsafe.Pointer, bool) { seq := makeProbeSeq(h1(hash), t.groups.lengthMask) for ; ; seq = seq.next() { g := t.groups.group(typ, seq.offset) match := g.ctrls().matchH2(h2(hash)) for match != 0 { i := match.first() slotKey := g.key(typ, i) if typ.IndirectKey() { slotKey = *((*unsafe.Pointer)(slotKey)) } if typ.Key.Equal(key, slotKey) { slotElem := g.elem(typ, i) if typ.IndirectElem() { slotElem = *((*unsafe.Pointer)(slotElem)) } return slotElem, true } match = match.removeFirst() } match = g.ctrls().matchEmpty() if match != 0 { // Finding an empty slot means we\u0026#39;ve reached the end of // the probe sequence. return nil, false } } } 源码解读：\n根据map的 used 字段快速判断，如果是空map，直接返回nil。\n其次对map的 并发读写 进行检测，存在并发读写直接报错。\n计算key的hash（H1-高57位，用于定位group。H2-低7位，用于对比控制字）值。\n根据dirLen判断是否是小map。\na. 如果是小map\n进入小map的搜索流程，直接从初始化中的单个group中进行查找\n获取当前hash的H2，然后遍历group，逐个对比控制字中的H2部分\n不匹配就继续下一个，匹配之后，再继续对key校验（只用7位进行校验，存在hash冲突的可能性，虽然很低，1/128）\n若key相同，找到val直接返回，若遍历结束也没有匹配项，返回nil结束。\nb. 如果不是小map\n先根据key的hash的高位（globalDepth）计算出当前 map 的“目录数组”(directory)中的索引，从而定位到应该查找或操作的 table（分表）。（如果目录长度为 1，说明 map 只有一个 table（未分表），所有 key 都在这一个 table 里。否则说明已经分表，用hash的高位来选择table。例如globalDepth = 3，则目录长度为8，globalShift = 61，目录索引为hash\u0026raquo;61，即hash的最高3位）\n根据hash的 H1 部分计算出table内部遍历的 group的起始位置。（起始位置offset：uint64(H1) \u0026amp; mask，用 h1(hash) 的低若干位（由 mask 决定）作为 group 的初始索引。其中mask = group 数量 - 1。例如group数量为8，mask=0b111，则只取H1的最低3位。）\n然后开始查找group内的元素，通过H2与group的8个控制字做并行匹配（复制H2为8份），如果存在匹配的，则继续对key进行比较，如果遇到空槽，说明key不存在，返回nil,false，如果没有找到且没有空槽，继续查找下一个group。\n关键点 q：为什么遇到空槽代表不存在key？\na：在插入新key时会沿着探查序列查找第一个空槽或者已删除的空槽，查找时同样沿着探查序列查找，如果key曾经被插入过，一定会在遇到空槽之前被找到（插入时遇到空槽就插入了）\n删除过程 关键源码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 func (m *Map) Delete(typ *abi.SwissMapType, key unsafe.Pointer) { if m == nil || m.Used() == 0 { if err := mapKeyError(typ, key); err != nil { panic(err) // see issue 23734 } return } if m.writing != 0 { fatal(\u0026#34;concurrent map writes\u0026#34;) } hash := typ.Hasher(key, m.seed) // Set writing after calling Hasher, since Hasher may panic, in which // case we have not actually done a write. m.writing ^= 1 // toggle, see comment on writing if m.dirLen == 0 { m.deleteSmall(typ, hash, key) } else { idx := m.directoryIndex(hash) m.directoryAt(idx).Delete(typ, m, hash, key) } if m.used == 0 { // Reset the hash seed to make it more difficult for attackers // to repeatedly trigger hash collisions. See // https://go.dev/issue/25237. m.seed = uintptr(rand()) } if m.writing == 0 { fatal(\u0026#34;concurrent map writes\u0026#34;) } m.writing ^= 1 } 源码解读：\n判断当前map是否为空，如果为空，则删除操作无效。同时检查key的类型是否合法，如果不合法直接 panic。\n检测并发写状态，保证线程安全。\n计算key的hash，如果key类型不匹配则panic。\n设置并发写标志。\n判断map的大小。\na. 如果是小map\n获取group引用，匹配H2。\n若有匹配的，再对比key。\n找到之后， 计数-1，然后清理key和value。\n标记slot为空，小map没有探查序列，不需要tombstone直接将控制字设为empty。\n如果没有匹配的key，则什么都不做。\nb. 如果是大map\n通过H1和表容量构造探查序列。\n结合H2遍历group，若存在H2匹配的，再对比key。\n清理key和value。\n设置控制字，如果当前group还有空槽，直接将ctrl（控制字）设置为empty，并回收growthleft（可插入槽数）。否则，设置为deleted（墓碑），墓碑会在rehash时被清理。\n如果group没找到目标key，遇到空槽则终止。如果group有空槽，说明key不存在，直接返回。\n扩容过程： 小map扩容 扩容节点：小map只能存放8个元素，当插入第9个元素时会触发扩容。调用growToTable\n关键源码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 func (m *Map) growToTable(typ *abi.SwissMapType) { tab := newTable(typ, 2*abi.SwissMapGroupSlots, 0, 0) g := groupReference{ data: m.dirPtr, } for i := uintptr(0); i \u0026lt; abi.SwissMapGroupSlots; i++ { if (g.ctrls().get(i) \u0026amp; ctrlEmpty) == ctrlEmpty { // Empty continue } key := g.key(typ, i) if typ.IndirectKey() { key = *((*unsafe.Pointer)(key)) } elem := g.elem(typ, i) if typ.IndirectElem() { elem = *((*unsafe.Pointer)(elem)) } hash := typ.Hasher(key, m.seed) tab.uncheckedPutSlot(typ, hash, key, elem) } directory := make([]*table, 1) directory[0] = tab m.dirPtr = unsafe.Pointer(\u0026amp;directory[0]) m.dirLen = len(directory) m.globalDepth = 0 m.globalShift = depthToShift(m.globalDepth) } 源码解读：\n分配新表，新表容量为16，原来2倍，localdepth\\globaldepth都为0，index为0。\n遍历原来的8个slot，如果非空，重新计算hash，插入到新表。\n构建目录指向新表，长度为1。\nmap进入表模式，后续插入查找都走table逻辑。\n大map扩容 扩容节点：在插入流程中，如果当前为大表，且在插入时发现没有空槽，就会出发rehash。\n关键源码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 func (t *table) rehash(typ *abi.SwissMapType, m *Map) { newCapacity := 2 * t.capacity if newCapacity \u0026lt;= maxTableCapacity { t.grow(typ, m, newCapacity) return } t.split(typ, m) } func (t *table) grow(typ *abi.SwissMapType, m *Map, newCapacity uint16) { newTable := newTable(typ, uint64(newCapacity), t.index, t.localDepth) if t.capacity \u0026gt; 0 { for i := uint64(0); i \u0026lt;= t.groups.lengthMask; i++ { g := t.groups.group(typ, i) for j := uintptr(0); j \u0026lt; abi.SwissMapGroupSlots; j++ { if (g.ctrls().get(j) \u0026amp; ctrlEmpty) == ctrlEmpty { // Empty or deleted continue } key := g.key(typ, j) if typ.IndirectKey() { key = *((*unsafe.Pointer)(key)) } elem := g.elem(typ, j) if typ.IndirectElem() { elem = *((*unsafe.Pointer)(elem)) } hash := typ.Hasher(key, m.seed) newTable.uncheckedPutSlot(typ, hash, key, elem) } } } newTable.checkInvariants(typ, m) m.replaceTable(newTable) t.index = -1 } func (t *table) split(typ *abi.SwissMapType, m *Map) { localDepth := t.localDepth localDepth++ // TODO: is this the best capacity? left := newTable(typ, maxTableCapacity, -1, localDepth) right := newTable(typ, maxTableCapacity, -1, localDepth) // Split in half at the localDepth bit from the top. mask := localDepthMask(localDepth) for i := uint64(0); i \u0026lt;= t.groups.lengthMask; i++ { g := t.groups.group(typ, i) for j := uintptr(0); j \u0026lt; abi.SwissMapGroupSlots; j++ { if (g.ctrls().get(j) \u0026amp; ctrlEmpty) == ctrlEmpty { // Empty or deleted continue } key := g.key(typ, j) if typ.IndirectKey() { key = *((*unsafe.Pointer)(key)) } elem := g.elem(typ, j) if typ.IndirectElem() { elem = *((*unsafe.Pointer)(elem)) } hash := typ.Hasher(key, m.seed) var newTable *table if hash\u0026amp;mask == 0 { newTable = left } else { newTable = right } newTable.uncheckedPutSlot(typ, hash, key, elem) } } m.installTableSplit(t, left, right) t.index = -1 } func (m *Map) installTableSplit(old, left, right *table) { if old.localDepth == m.globalDepth { // No room for another level in the directory. Grow the // directory. newDir := make([]*table, m.dirLen*2) for i := range m.dirLen { t := m.directoryAt(uintptr(i)) newDir[2*i] = t newDir[2*i+1] = t // t may already exist in multiple indicies. We should // only update t.index once. Since the index must // increase, seeing the original index means this must // be the first time we\u0026#39;ve encountered this table. if t.index == i { t.index = 2 * i } } m.globalDepth++ m.globalShift-- //m.directory = newDir m.dirPtr = unsafe.Pointer(\u0026amp;newDir[0]) m.dirLen = len(newDir) } // N.B. left and right may still consume multiple indicies if the // directory has grown multiple times since old was last split. left.index = old.index m.replaceTable(left) entries := 1 \u0026lt;\u0026lt; (m.globalDepth - left.localDepth) right.index = left.index + entries m.replaceTable(right) } 源码解读：\n首先判断预期的新容量（旧容量的两倍）是否达到了单表的最大group容量（maxTableCapacity=1024）\n如果没超过，则进入扩容流程（grow）\n如果超过了，则进入分裂流程（split）\n扩容流程详解（grow） 首先创建新table（但还未替换）。\n判断旧table是否为空，迁移所有元素（新建table时capacity=0，判断避免空表扫描）。\n遍历所有group，遍历每个group内的8个slot，遇到delete或者empty的直接跳过，获取存活的key\\elem，重新计算hash。\n插入新table（unchecked）（新table是一定有足够空间的）。\n完整性校验（checkInvariants）（used数量、ctrl状态、 growthLeft 、 map.used 一致性 ）。\n替换旧table（replaceTable）。\n标记旧table失效（t.index = -1）。\n分裂流程详解（split）（ Extendible Hashing ） 当一个table已经到达最大容量（ maxTableCapacity ），但还是要继续插入时会进入split。\ntable的localdepth加1（localdepth表示当前table负责的hash范围，区别globaldepth表示当前directory使用的hash的范围）。\n创建左右两个新table。\n计算分流掩码mask（用于后续决定元素流向哪个table）。\n扫描旧table，逐个slot进行分流。跳过delete\\empty，取key\\elem并重新hash。\n根据mask，用新增的那一位hash来决定该slot流入哪个table（见上方源码）。\n插入新table，不需要检查，保证成功。\n将split的结果加入到directory中（如果directory的globalDepth位数不够用会扩容）。\n废弃当前table。\n分裂过程中第8步详解 判断当前directory的globalDepth位数是否够用。（split会使得localdepth+1，如果超过的globalDepth位数则需要扩容directory）\n如果需要翻倍，创建新的dir数组，原来的2倍容量。\na. 复制并维护index。\nb. 更新 globalDepth / globalShift 。\nc. 原子替换 directory（将原来的dir指针指向新的数组）。\nleft覆盖原来old table负责的起始位置。\n计算right table的index偏移量并设置right index。\n","date":"2025-12-12T00:00:00Z","image":"https://liusir521.github.io/p/go-map-1.24%E6%96%B0%E5%AE%9E%E7%8E%B0/go_hu_26c879cfae96044.jpg","permalink":"https://liusir521.github.io/p/go-map-1.24%E6%96%B0%E5%AE%9E%E7%8E%B0/","title":"Go Map 1.24新实现"},{"content":"Mutex Mutex由 state 和 sema 两个字段组成，state表示当前互斥锁的状态，sema是用于控制锁状态的信号量。\n1 2 3 4 type Mutex struct { state int32\t// 状态 sema uint32 // 信号量 } 互斥锁的状态的 低三位 分别表示互斥锁 是否锁定（mutexLocked）（1是锁定）、 是否有唤醒的goroutine(mutexWoken)（1是有）、当前线程 是否是饥饿状态(mutexStarving)（1是饥饿）。前面的就表示当前互斥锁上等待的goroutine的个数。\nMutex的状态 Mutex有两种模式，正常模式和饥饿模式。\n在正常模式下，锁的等待者会按照 先进先出 的顺序去获取锁，但是刚被唤醒的goroutine与新创建的goroutine竞争时，大概率获取不到锁。为了减少这种情况的发生，一旦goroutine超过1ms没有获取到锁，他就会将当前互斥锁切换到饥饿模式，防止部分goroutine被饿死。\n在饥饿模式下，互斥锁会直接交给 等待队列最前面 的goroutine。新的goroutine在该状态下不能获取锁，也不会进入自旋状态，他只会在 队列末尾 等待。如果一个goroutine获得了互斥锁并且他在队列末尾或者他等待的时间少于 1ms ，那么当前互斥锁就会切换为 正常状态。\n正常状态能提供更好的性能，饥饿模式能够避免goroutine由于陷入等待无法获取锁造成的高延时。\n加锁和解锁 加锁：在lock方法中，他会先通过CAS（CompareAndSwapInt32方法）去判断能否获取到锁，获取到的话就会将mutexLocked设置为1。如果获取时他的状态不是0，也就会获取不到锁，他会调用lockslow方法来尝试通过 自旋 来等待锁的释放。当前线程进入自旋的话会一直 保持CPU的占用，持续检查某些条件是否为真。处理完自旋相关的逻辑之后，互斥锁会根据上下文计算当前互斥锁的最新状态。然后使用CAS（CompareAndSwapInt32方法）更新状态，如果获取不到会通过 信号量 来保证资源不被两个goroutine获取。\n解锁：解锁会先使用atomic.addint32()方法来快速解锁，如果成功就释放，不成功的话会进行慢速解锁（unlockslow方法），他会先 校验锁的状态，如果已经解锁了，会报错。然后正常情况下分为正常模式和饥饿模式处理，正常模式下如果有等待者，会唤醒等待者并移交锁的所有权，没有等待者的话直接返回。在饥饿模式下，他会将当前锁交给下一个正在尝试获取锁的等待者，然后依然是饥饿状态。\nRWMutex RWMutex结构体中共有5个字段。\n1 2 3 4 5 6 7 8 9 10 type RWMutex struct { w Mutex // 控制 writer 在 队列 排队 writerSem uint32 // 写信号量，用于等待前面的 reader 完成读操作 readerSem uint32 // 读信号量，用于等待前面的 writer 完成写操作 readerCount int32 // reader 的总数量，同时也指示是否有 writer 在队列中等待 readerWait int32 // writer 前面 reader 的数量 } // 允许最大的 reader 数量 const rwmutexMaxReaders = 1 \u0026lt;\u0026lt; 30 w：主要提供写锁相关的功能\nreaderCount：存储了正在执行的读操作的数量\nreaderWait：表示当前写操作被阻塞时前方等待的读操作的数量\n写锁 加锁Lock()：\n首先会先调用w.Lock() 阻塞后续的写操作 。因为锁已经被获取，当其他的goroutine获取写锁时会进入 自旋或者休眠。\n然后通过 原子操作 (调用atomic.AddInt32方法)将readerCount设置为负数， 阻塞后续的读操作。\n然后判断如果有其他的goroutine持有读锁的话，当前goroutine会进入 休眠状态，等待所有读锁读完之后释放writerSem信号量唤醒当前goroutine。\n先阻塞写锁后阻塞读锁避免读操作因为连续的写操作饿死。\n释放UnLock():\n先通过 原子操作 将(调用atomic.AddInt32方法)readerCount设置为正数，释放读锁。\n然后通过for循环释放所有因为获取 读锁 而陷入等待的goroutine。\n然后调用w.UnLock()释放写锁。\n读锁 加锁RLock()：\n该方法会通过 原子操作 将(调用atomic.AddInt32方法)readerCount加一。\n然后如果返回负数，说明其他goroutine获得了写锁，当前goroutine就会陷入 休眠 等待锁的释放。\n如果返回非负数，说明没有goroutine获得写锁，该方法会返回成功。\n释放RUnLock():\n该方法会通过原子操作将(调用atomic.AddInt32方法)readerCount减一。\n如果返回值大于零，直接解锁成功。\n如果小于零，说明有一个写操作正在执行。这时会调用rUnlockSlow()方法，他会减少写操作前面等待的读操作的数量readerWait，并在 所有读操作完成之后 触发写操作的信号量writerSem唤醒写线程。\n","date":"2025-12-12T00:00:00Z","image":"https://liusir521.github.io/p/go%E5%86%85%E9%83%A8%E9%94%81/go_hu_26c879cfae96044.jpg","permalink":"https://liusir521.github.io/p/go%E5%86%85%E9%83%A8%E9%94%81/","title":"Go内部锁"},{"content":"简介 G 是调度器中待执行的任务，在运行时用 runtime.g 结构体表示（内有_defer字段表示defer链表）。他可以分为三种状态（等待中、可运行、运行中（状态非常多））。结构体中也有m指针字段，跟m进行动态绑定。\nM 是操作系统线程，调度器最多可以创建10000个线程，但是最多会有GOMAXPROC（默认当前机器的核数）个线程能够正常运行（可以手动改变）。在go中使用runtime.m表示。他有一个 g0 字段和一个 curg 字段，g0是持有调度栈的goroutine，curg是在当前线程上运行的用户的goroutine。g0他会深度的参与到运行时的调度过程（goroutine的创建、大内存分配）。还有关于处理器 P 的字段\nP 是调度器中的处理器，是线程和goroutine的中间层，他可以提供线程需要的上下文，也会负责调度线程上的等待队列。还有处理器的数量是跟线程数相等的。然后在运行时go中使用runtime.p结构体来表示。他存储了处理器持有的运行队列、等待执行的goroutine列表、下个需要执行的goroutine等信息。P中最多存储 256 个g。\ng的状态 刚开始创建时为_Gidle状态，初始化完成之后为_Gdead状态。当环境都准备就绪后会进入_Grunnable就绪态，当被调度器调度到时进入_Grunning运行态，当运行时代码中越过用户态进入内核态发生一些系统调用时会进入_Gsyscall状态，当执行完成之后会重新进入_Grunnable就绪态，等待被重新调度。当在用户态视角下发生一些阻塞时（加锁时的阻塞，channel的阻塞）会进入_Gwaiting状态，当某些条件达成之后会被切换成_Grunnable就绪态。当正常的调度完成之后会进入_Gdead被销毁回收。\ng0 g0是与m绑定的特殊的goroutine，用于其他的g之间的调度管理。当g0找到要执行的g之后会调用gogo函数将执行权交给当前g，当 g 需要主动让渡或被动调度时会调用m_call函数来把执行权重新交给g0\n四种调度类型 主动调度：由 用户 主动发起，通过 runtime.Gosched 方法来实现主动让出当前P的执行权。当前g会由_Grunning状态切换成_Grunnable状态，然后被投递到 全局队列 中。然后执行权回归到g0，g0会继续寻找下一个可执行的g\n被动调度：由于一些客观的因素导致当前的g不得不 陷入阻塞 的状态（加锁，channel）。当前g通过 gopark 方法由_Grunning状态切换成_Gwaiting状态，这个G会由 网络轮询器 接手，同时将执行权交给g0。goready方法会将当前g从阻塞状态中恢复，重新进入等待执行的状态。然后这个g会被优先加入到 唤醒这个g 的P的本地队列当中同时优先被调度。\n正常调度：执行完成当前g之后会将这个g置为_Gdead状态，并发起下一轮的调度。\n抢占调度：当某一个g发生 系统调用 时并超过了一定的时长就会被感知到，然后这个g会和P进行解绑留下g和m绑定（hand off），然后P去寻找其他的空闲m，若没有空闲的就会创建一个新的M。抢占这个动作不再由g0完成，而是有一个全局监控者（monitor g）完成的。因为发起系统调用时需要打破用户态的边界进入内核态，此时 m 也会因系统调用而陷入僵直，无法主动完成抢占调度的行为。\nfindRunnable函数（寻找可执行的g） 首先P如果执行到了第61次，会从全局队列中获取一个g来执行，并将一个全局队列中的g填充到P的本地队列中。如果本地队列已经满了，会将本地的一半g放回到全局队列中缓解本地压力。\n然后从本地队列中尝试寻找可执行的g，如果有，会尝试加锁获取。由于窃取动作发生的频率不是很高，所以一般都能拿到锁，所以说P的本地队列是接近无锁化的。\n如果本地队列没有可执行的g，会尝试加锁从全局队列中获取。\n如果本地和全局都没有，会获取准备就绪的网络协程。\n最后才会尝试去窃取其他P的一半的g（work-stealing机制），会进行4次尝试，其中某次成功获取到之后就会直接返回\n","date":"2025-12-12T00:00:00Z","image":"https://liusir521.github.io/p/go%E8%AF%AD%E8%A8%80gmp%E6%A8%A1%E5%9E%8B/go_hu_26c879cfae96044.jpg","permalink":"https://liusir521.github.io/p/go%E8%AF%AD%E8%A8%80gmp%E6%A8%A1%E5%9E%8B/","title":"Go语言GMP模型"},{"content":" 源码版本1.22\n数据结构 关键源码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 type Map struct { _ noCopy // 互斥锁 mu Mutex // 只读map read atomic.Pointer[readOnly] // 有锁的读写map dirty map[any]*entry // 只读map的miss次数 misses int } type readOnly struct { // 无锁化的只读map m map[any]*entry // 标记只读map是否缺失数据 amended bool } 通过read map（只读）和dirty map + 互斥锁两个map来实现读写分离（空间换取时间） entry的三种状态：\n存活态：正常指向元素 软删除态：指向nil（向软删除的数据插入或者更新时会直接通过read map的一个CAS操作进行更新） 硬删除态：指向固定的全局变量expunged（在dirty map中已经物理上删除了） 读流程 首先读取read map，如果amended状态为false，表示read map中已经是全量数据，这时就看能不能再read map中找到数据，然后如果找到的数据他的entry对应为nil或者expunged也表示没找到。\n如果read map中没找到并且amended为true时，表示read map中没找到并且read map中不是全量数据时。这时会去dirty map中查找（会加锁），在查找之前会进行一个对read map的 二次检查 （防止在此期间其他goroutine的操作让read map的数据覆盖了），如果read中的amended为false时，就会去read中查找并判断返回。如果read map不可用，就读取dirty中的数据判断返回，并把misses++（到达阈值（跟dirty长度相等）时将read中数据进行覆盖）。\n覆盖流程 关键源码misslocked：\n1 2 3 4 5 6 7 8 9 func (m *Map) missLocked() { m.misses++ if m.misses \u0026lt; len(m.dirty) { return } m.read.Store(readOnly{m: m.dirty}) m.dirty = nil m.misses = 0 } 如果misses大于等于dirty中数据的数量时，会使用dirty覆盖read并将amended变为false。此时dirty为nil，misses计数器清零。\n写流程（插入或更新） 如果read中存在key并且entry不是expunged，就可以通过CAS的操作来进行一个更新（直接将entry指向新插入的数据）\n如果read中不存在或者是硬删除态，就会进入dirty中，同样会先进行一个read的二次检查，如果read中没有，就看dirty中是否存在，存在就进行更新操作（修改dirty），不存在就进行插入操作。插入时要判断amended，如果为false，就会执行dirtyLocked流程，并将amended置为true。\ndirtyLocked流程 关键源码dirtyLocked：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 func (m *Map) dirtyLocked() { if m.dirty != nil { return } read, _ := m.read.Load().(readOnly) m.dirty = make(map[any]*entry, len(read.m)) for k, e := range read.m { if !e.tryExpungeLocked() { m.dirty[k] = e } } } 当将dirty覆盖给read之后，dirty应该为nil，如果执行时为nil说明已经执行过了，可以直接返回。如果不是nil，就通过for循环线性的将read中的非删除态数据拷贝回dirty中，并将其中的软删除态的数据更新为硬删除态。\ndirtyLocked的拷贝是线性的，性能不好。\n诱发dirtyLocked的原因：写多读少 ，大量的新数据的插入，同时很多读操作在read中未命中。造成misslocked，然后又一次写操作越过了read和dirty，就会进入dirtyLocked流程。\n删除流程 如果read中存在key，通过CAS的操作将对应的value置为nil（软删除态）\n如果read中不存在，去dirty中查找，会先进行二次检查。如果read中不存在并且amended为true，则去dirty中查找删除（物理删除）并返回旧值（方法要返回），然后将misses++。如果read中存在或者amended为false，就会去read中判断删除。\n遍历流程 首先获取read，然后判断amended，如果amended为true，就会将dirty中的数据覆盖到read中，并将dirty置为nil并将misses归零。\n然后遍历read并执行传入的函数，通过返回值判断何时终止。\n","date":"2025-12-12T00:00:00Z","image":"https://liusir521.github.io/p/go%E8%AF%AD%E8%A8%80sync.map/go_hu_26c879cfae96044.jpg","permalink":"https://liusir521.github.io/p/go%E8%AF%AD%E8%A8%80sync.map/","title":"Go语言sync.Map"},{"content":"相关概念 整体结构图： page 一个page大小为8kb，page是go语言内存管理和虚拟内存交互时的最小单元。\nmspan 表示一组连续的page（整数倍的page）\nsize class相关 object size: 指协程应用逻辑一次向go语言内存申请的对象object大小。\nobject: go语言内存管理模块针对内存管理更加细化的内存管理单元。\nspan: span在初始化时会被分为多个object。\nsize class: 表示一块内存所属的规格或者刻度，例如object size在1-8B的object属于size class1级别，8-16B大小的为size class2级别。（go语言划分了66个size）\nspan class: 针对span进行划分，是span大小的级别。一个size class会对应两个span class，其中一个span存放需要gc扫描的对象（包含指针的对象），另一个span存放不需要gc扫描的对象（不含指针的）\nDemo object size大小为8B大小的object，所属的span的大小为8kb，那么这个span就会被平均分为1024个object。\n相关图解： MCache 简介 MCache与go语言的GMP模型中的 P进行绑定 ，因为真正可运行的线程M的数量与P一致，所以与P绑定更能 节省内存空间，可以保证每个 G 使用MCache时 不需要加锁 就可以获取内存。（因为一个P只有一个M在其上运行，不可能出现竞争，所以没有锁限制，进而加快了内存的分配）\n内部构造 如图所示： MCache中每个 span class 会对应一个 Mspan ，不同span class的mspan的总体长度不同（参考runtime/sizeclass.go）。当其中某个span class对应的mspan已经没有可以提供的object时，MCache会向MCentral申请一个对应的mspan。\n特殊size class 对于span class为0和1，也就是 size class为0 的规格刻度内存，MCache实际上没有分配任何内存。Go语言内存管理对内存为0的数据申请做了特殊处理，如果申请的数据大小为0，则将直接返回一个 固定内存地址 ，不会进入Go语言内存管理的正常逻辑。 这也是为什么在做channel同步时，会发送一个struct{}数据，因为不会申请任何内存，能够适当节省一部分内存空间 。\nMCentral 简介 当MCache中的某个size class对应的span被一次次的object上层取走后，如果出现当前size class的span空缺的情况，MCache会向MCentral申请对应的span。申请时需要 加锁 。\n内部构造 MCentral针对每个span class 级别有两个 span链表 。\nMCentral是一个抽象的概念，实际上每个span class对应的内存数据结构是一个MCentral， 即在MCentral这层数据管理中，实际上有span class个MCentral小内存管理单元。\n内部字段：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 type mcentral struct { _ sys.NotInHeap spanclass spanClass //当前mcantral对应的spanclass等级 partial [2]spanSet // list of spans with a free object //表示还有可用空间的集合，集合中的所有span都至少有一个空闲的object空间。 //如果MCentral上游的MCache退还span，则会将退还的span加入集合 full [2]spanSet // list of spans with no free objects //表示没有可用空间的span链表，该链表上的span都不确定是否还有空闲的object空间。 //如果MCentral将一个span提供给上游MCache，则被提供的span就会加入集合 } partial 和 full 都是一个spanSet数组类型，都各有两个spanSet，这是为了给 GC 使用的，其中一个是已扫描的，另一个是未扫描的。\nMHeap 简介 MHeap是内存块的管理对象，通过page对内存单元进行管理。用来详细管理每一系列page的结构称为一个 HeapArena 。一个HeapArena占用内存 64MB ，其中里面的内存是一个个的mspan，最小单元依然是pages。所有的HeapArena组成的集合是一个 Arenas ，即MHeap针对堆内存的管理。MHeap上游是MCentral，当MCentral中的span不够时向MHeap申请。MHeap的下游是操作系统，当MHeap内存不足时会向操作系统的 虚拟内存 空间申请，访问MHeap获取内存时依然需要 加锁。\nMHeap中HeapArena占用了绝大部分的空间，其中每个HeapArena包含一个 bitmap ，其作用是标记当前这个HeapArena的内存使用情况。其主要服务于 GC 垃圾回收模块，bitmap共有 两种标记，一种是标记对应地址中 是否存在对象，另一种是标记此对象 是否被GC模块标记过，所以当前HeapArena中的所有page均会被bitmap标记。\n内部构造 如图所示： 对象分配流程 go语言内存管理中的对象划分： 对象级别 大小范围 微对象（Tiny对象） [1，16B） 小对象 [16B，32KB] 大对象 (32KB，无限大） Tiny对象分配流程 针对Tiny对象，go语言做了特殊处理，MCache中不仅保存着各个span class级别的内存块空间，还有一个比较特殊的 Tiny存储空间。\nTiny空间是从size class=2（对应span class=4\\5）中获取的一个16B的object，作为Tiny对象的分配空间。\n主要是因为类似bool或者1字节的byte，也都会独享8B的内存空间，进而导致一定的空间浪费。所以将申请的object小于16B的申请同意归为Tiny对象申请。\n分配过程：\nP 向 MCache 申请微小对象，如一个bool变量。如果申请的object在Tiny对象的大小范围，则进入Tiny对象的申请流程，否则进入小对象或者大对象的申请流程。\n判断申请的tiny对象是否包含 指针 ，如果包含指针，则进入 小对象 的申请流程（不会放在tiny缓冲区，因为需要 GC 进入扫描流程）。\n如果tiny空间的16B没有多余的存储容量，则从size class=2的span中获取一个16B的object放入tiny缓冲区中。\n将1B的bool类型放置在16B的tiny空间中，以 字节对齐 的方式放置。（tiny对象的申请也达不到内存利用率100%）（此处不做详细内存对齐讲解）\n小对象 分配小对象的流程是按照span class的规格匹配的。\n分配过程：\n协程逻辑层 P 向go语言内存管理申请一个对象所需的内存空间。\nMCache 收到请求后，根据对象所需的内存空间计算出需要的size。\n判断size是否小于16B，小于进入tiny对象的申请流程，否则进入小对象的申请流程。\n根据size匹配对应的 size class 内存规格，在根据size class和该对象是否包含 指针，来定位是从noscan span class还是从scan span class获取空间，如果没有指针，则锁定 noscan。\n在定位的span class中的span取出一个object返回给协程逻辑层P。流程结束\n如果定位的span class中的span所有的内存块object都被占用，则 MCache 会向 MCentral 申请一个span。\nMCentral收到内存申请后，优先从对应的span class中的 Partial Set 里取出span，如果Partial Set里没有，则从 Full Set 中取，返给MCache。\nMCache得到span后，补充 到对应的span class中，之后再次执行第5步。\n如果Full Set中没有符合的span，则MCentral会向 MHeap 申请内存。\nMHeap收到请求后从其中一个 HeapArena 中取出一部分pages返给MCentral，当MHeap没有足够空间时向 操作系统 申请内存，将申请的内存也保存到HeapArena中的mspan中。MCentral将从MHeap获取的由pages组成的span添加到对应的span class 集合中作为补充 ，之后继续执行第7步。\n最后协程业务逻辑层得到对象申请到的内存，流程结束。\n大对象 小对象从MCache中分配，而大对象直接从MHeap中分配。对于不满足MCache分配范围的对象均按照大对象处理。\n分配过程：\n协程逻辑层申请大对象所需的内存空间，如果超过32KB，则直接 绕过MCache 和 MCentral 向 MHeap 申请。\nMHeap根据对象所需的空间计算得到需要多少个pages。\nMHeap向 Arenas 中的HeapArena申请对应的pages。\n如果Arenas中没有HeapArena可提供合适的pages内存，则向操作系统的虚拟内存申请，并且填充到Arenas中。\nMHeap返回大对象的内存空间，协程逻辑层得到内存，流程结束\n","date":"2025-12-04T00:00:00Z","image":"https://liusir521.github.io/p/go%E8%AF%AD%E8%A8%80%E5%86%85%E5%AD%98%E5%88%86%E9%85%8D%E6%A8%A1%E5%9E%8B/go_hu_26c879cfae96044.jpg","permalink":"https://liusir521.github.io/p/go%E8%AF%AD%E8%A8%80%E5%86%85%E5%AD%98%E5%88%86%E9%85%8D%E6%A8%A1%E5%9E%8B/","title":"Go语言内存分配模型"},{"content":" 源码解读版本1.22\n简介 go语言提供的并发模型是通信顺序进程（csp）。goroutine和channel分别对应csp中的实体和传递信息的媒介，goroutine之间通过channel传递数据。\n目前channel的收发操作都遵循 先进先出 的设计。channel的内部表示的hchan结构体，结构体中包含了用于保护成员变量的 互斥锁。所以说，在某种程度上，channel是一个 有锁队列。\n关键源码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 type hchan struct { qcount uint dataqsiz uint buf unsafe.Pointer elemsize uint16 closed uint32 elemtype *_type sendx uint recvx uint recvq waitq sendq waitq lock mutex } 相关字段含义：\nqcount表示channel中的元素个数；\ndataqsiz表示channel中循环队列的长度；\nbuf表示channel的缓冲区的数据指针；\nsendx和recvx分别表示发送和接收操作处理到的位置；\nsendq和recvq分别表示由于缓冲区空间不足而阻塞的goroutine列表；\ngoroutine使用waitq结构体表示，他是一个双向链表。\n1 2 3 4 type waitq struct { first *sudog last *sudog } 链表中每个元素都是sudog结构，他用来表示等待列表中的一个goroutine。\nchannel的创建 channel的创建都会使用 make 关键字，他会对缓冲区大小参数进行检查，如果没有，他会默认为0。然后根据传入的缓冲区的大小调用makechan或makechan64函数，64的话是用来处理缓冲区大小大于232的情况，这种情况的话很少见。\n在makechan中会分三种情况:\n如果channel中不存在缓冲区，那就为hchan分配一块内存空间。\n如果channel中存储的不是指针类型，会为当前channel和底层数组分配一块连续的内存空间。\n默认情况下会为hchan和缓冲区单独分配空间。\n无缓冲的channel 一个基于无缓存Channels的发送操作将导致发送者goroutine阻塞，直到另一个goroutine在相同的Channel上执行接收操作，当发送的值通过Channel成功传输之后，两个goroutine可以继续执行后面的语句。反之，如果接收操作先发生，那么接收者goroutine也将阻塞，直到有另一个goroutine在相同的Channel上执行发送操作。\n有缓冲的channel 向缓存Channel的发送操作就是向内部缓存队列的尾部插入元素，接收操作则是从队列的头部删除元素。如果内部缓存队列是满的，那么发送操作将阻塞直到因另一个goroutine执行接收操作而释放了新的队列空间。相反，如果channel是空的，接收操作将阻塞直到有另一个goroutine执行发送操作而向队列插入元素。\n关闭的channel 对于已经关闭的channel，如果通道内已经没有数据了，可以不限次数的读取，但是读到的是该数据类型的零值 如果通道内还有数据，那么仍然可以读到之前存储的数据，ok会返回true，表示通道内有数据，当通道内的数据读完时，也会返回零值。\n但是如果向已经关闭的channel写入数据会panic。\n","date":"2025-11-27T00:00:00Z","image":"https://liusir521.github.io/p/go%E8%AF%AD%E8%A8%80channel%E8%AF%A6%E8%A7%A3/go_hu_26c879cfae96044.jpg","permalink":"https://liusir521.github.io/p/go%E8%AF%AD%E8%A8%80channel%E8%AF%A6%E8%A7%A3/","title":"Go语言Channel详解"},{"content":" redis 版本7.x\n有序性与唯一性 Sorted sets与Sets类似，是一种集合类型，这种集合中不会出现重复的member(数据)。Sorted seats中的元素由两部分组成，分别是member和score(分数)。\nmember会关联一个double类型的score，Sorted sets默认会根据这个score对member从小到大排序。如果member关联的score相同，则按照字符串的字典顺序排列。\n常见使用场景：\n排行榜：游戏中根据分数排名top10。\n速率限流器：滑动窗口速率限制器。\n延迟队列：使用score存储过期时间，从小到大排序，最靠前的就是最先到期的数据。\nskiplist + dict 和 listpack Sorted sets底层通过两种方式存储数据：\nlistpack（7.0版本之前是ziplist）：使用条件是集合元素小于或等于128，且member占用字节数小于64。将member和score紧凑排列作为listpack的一个元素存储。\nskiplist + dict：当不满足上述条件时，将数据分别存储在skiplist（跳表）和dict中，是一种空间换时间的思想。散列表key存储的是member，value存储的是关联的score。\nlistpack适用于元素个数不多且占用空间不大的场景，使用listpack就是为了节省内存。Sorted Sets能支持高效的范围查询，正是因为采用了skiplist。\n而使用dict的目的是以O(1)的时间复杂度查询单个元素。总之，Sorted Sets在插入或者更新时，会同时向skiplist和dict中插入或更新对应的数据，以保证两者数据的一致性。\nskiplist + dict skiplist本质是一种可以进行二分查找的有序链表。skiplist在原有的基础上增加了多级索引来实现快速查找。\nskiplist节点查找 通常数据查找是从顶层开始，如果节点保存的值比待查数据的值小，skipllist就继续访问该层的下一个节点。\n如果比待查数据的值大，就跳到当前节点的下一层的链表继续查找。如下图所示查找节点17：\n从level1开始，17大于6，继续与下一个节点比较\n17\u0026lt;26，回到原节点，跳到当前节点的level0层链表，与下一个节点比较，找到目标17\nskiplist也是受到这种多层链表的启发设计出来的。根据上面的生成链表，上层节点个数是下层节点个数的一半，查找过程类似二分查找，时间复杂度是O(n)。\n但是，这种设计方式有个问题，就是每次新增一个节点，就会打乱相邻的两层链表节点个数2：1的关系，就需要调整链表结构。\n为了避免这个问题，skiplist不要求上下相邻两层链表之间节点个数有严格的比例关系，而是为每个节点随机出一个层数，这样插入节点时只需要修改前后指针。\nSorted Sets相关数据结构源码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 typedef struct zskiplist { // 头尾指针便于双向遍历 struct zskiplistNode *header, *tail; // 当前skiplist包含的元素个数 unsigned long length; // 表内节点的最大层级 int level; size_t alloc_size; } zskiplist; typedef struct zset { dict *dict; zskiplist *zsl; } zset; skiplist中的每个节点，由zskiplistNode结构体表示：\n1 2 3 4 5 6 7 8 9 typedef struct zskiplistNode { sds ele; double score; struct zskiplistNode *backward; struct zskiplistLevel { struct zskiplistNode *forward; unsigned long span; } level[]; } zskiplistNode; ele和score属性：使用sds类型的ele存储实际数据，score存储分数。\n*backward：后退指针，指向该节点的上一个节点，便于倒序查找。每个节点只有一个后退指针，只有level0层的节点是双向链表。\nlevel[]：\n*forward：前进指针。\nspan：跨度，记录该层的forward指针指向的下一个节点之间的跨越了level0层的节点数\nlistpack listpack的优势的节省内存，但只能按顺序查找元素，时间复杂度是O(n)。正因如此，才能在少量数据的情况下，节省内存同时又不影响性能。\n","date":"2025-10-11T00:00:00Z","image":"https://liusir521.github.io/p/redis%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B9%8Bsorted-sets/redis_hu_b82f72a20a63ec03.jpg","permalink":"https://liusir521.github.io/p/redis%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B9%8Bsorted-sets/","title":"Redis数据结构之Sorted Sets"},{"content":" redis中的list和java中的linkedlist类似，是一种线性有序结构，按照元素被推入列表中的顺序存储元素，满足先进先出的需求。可以把它当作队列、栈来使用。\n内部结构演进 linkedlist 在redis3.2之前List底层数据结构由linkedlist或者ziplist实现，优先使用ziplist存储。当List对象满足以下两个条件时，将使用ziplist存储，否则使用linkedlist。\n链表中的每个元素占用的字节数小于64\n链表中的元素数量小于512个\n关键源码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 /* 链表节点 */ typedef struct listNode { // prev与next字段形成双端链表 struct listNode *prev; struct listNode *next; void *value; } listNode; /* 链表结构体 */ typedef struct list { listNode *head; listNode *tail; void *(*dup)(void *ptr); void (*free)(void *ptr); int (*match)(void *ptr, void *key); unsigned long len; } list; redis链表的特性 双端：链表节点带有prev和next指针，获取某个节点的前置和后置节点的时间复杂度都是O(1)\n无环：链表头节点的prev和尾节点的next指针指向的都是null。对链表的访问以null结尾。\n带表头指针和表尾指针：list结构体有head和tail指针，获取链表头尾节点的时间复杂度为O(1)\nlen属性：list结构体有len属性，获取节点数量的时间复杂度为O(1)\nziplist linkedlist中存在prev、next两个指针，在数据很小的情况下，指针占用的空间会超过数据占用的空间。\nlinkedlist是链表结构，在内存中不是连续的，遍历效率低下。\n为了解决上述的两个问题，创建了ziplist，ziplist是一种内存紧凑的数据结构，占用一块连续的内存空间，能够提高内存利用率。\n当一个Lists只有少量数据，并且每个列表项要么是小整数型，要么是比较短的字符串时，就会使用ziplist来作为List的底层数据结构存储数据。\nziplist是一块连续的内存，结构如下：\nzlbytes：占用4字节，记录整个ziplist占用的总字节数\nzltail：占用4字节，只想最后一个entry偏移量，用于快速定位最后一个entry\nzllen：占用2字节，记录entry总数\nentry：Lists的元素\nzlend：ziplist结束的标志，占用1字节，值等于255\n因为ziplist头尾元数据大小是固定的，并且zllen记录了ziplist头部最后一个元素的位置，所以可以用O(1)的时间复杂度找到ziplist中第一个或最后一个元素。而在查找中间元素时，只能从Lists头部或尾部开始遍历，时间复杂度O(n)。\n存储数据的entry结构如图所示：\nprevlen：记录前一个entry占用的字节数，逆序遍历就是通过这个字段确定的向前移动多少字节拿到上一个entry的 首地址 的。这部分会根据上一个entry的长度进行变长编码。变长方式如下：\n前一个entry的字节数小于254（255用于zlend），prevlen的长度为1字节，值等于上一个entry的长度。\n前一个entry的字节数大于等于254，prevlen占用5字节，第一字节配置为254作为一个标识，后面4字节组成一个32位int值，用于存放上一个entry的字节长度。\nencoding：表示当前entry的类型和长度，前两位用于表示类型，前两位为11时表示存储的是int类型，其他情况表示存储的字符串。\nentry-data：实际存放数据的地方，但是当entry存储的是int类型时，encoding和entry-data会合并到encoding中，并没有entry-data字段。\nlinkedlist与ziplist对比 为什么说ziplist节省内存？\n与linkedlist相比，少了prev和next指针。\n通过encoding字段针对编码进行细化存储，尽可能做到按需分配。\nziplist的不足:\n不能保存过多的元素，否则查询性能下降，导致O(n)时间复杂度。\nziplist存储空间是连续的，当插入新的entry时，内存空间不足就需要重新分配一块连续的内存空间，引发连锁更新。\n连锁更新问题:\n每个entry都用prevlen记录上一个entry的长度，在当前entry B 前面插入一个新的entry A 时，会导致 B 的prevlen发生变化，也会导致 B 的大小发生改变。同理后面的entry C 也会发生改变。以此类推，就可能导致连锁更新问题。\n连锁更新会导致多次重新分配ziplist的存储空间，直接影响ziplist的查询性能。所以在redis3.2引入了quicklist。\nquicklist quicklist结合了linkedlist与ziplist的优势，本质还是一个双向链表，只不过链表的每一个节点都是一个ziplist。\n结构体源码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 typedef struct quicklistNode { struct quicklistNode *prev; struct quicklistNode *next; unsigned char *zl; unsigned int sz; unsigned int count : 16; unsigned int encoding : 2; unsigned int container : 2; unsigned int recompress : 1; unsigned int attempted_compress : 1; unsigned int extra : 10; } quicklistNode; typedef struct quicklist { quicklistNode *head; quicklistNode *tail; unsigned long count; /* total entries */ unsigned long len; /* number of nodes */ int fill : 16; /* ziplist size limit */ unsigned int compress : 16; /* compression depth */ } quicklist; quicklist是ziplist的升级版，优化的关键点在于控制好每个ziplist的大小或者元素个数。\n但是存在两个极端情况：\nquicklist node的ziplist越小，可能造成越多的内存碎片。极端情况是每个ziplist只有一个entry，退化成了linkedlist。\nquicklist node的ziplist过大，极端情况下会造成一个quicklist只有一个ziplist, 退化成了ziplist, 连锁更新的问题就会暴露出来。\n所以在5.0版本的时候设计了另一个数据结构listpack, 并在7.0版本中替换掉了ziplist。\nlistpack listpack的结构如图所示：\ntot-bytes：即total bytes，占用4字节，记录listpack占用的总字节数\nnum-elements：占用2字节，记录listpack elements的个数\nelements：listpack元素，保存数据的部分\nlistpack-end-byte：结束标志，占用1字节，固定值为255\nelement结构如图所示：\nencoding-type：存储实际数据的编码类型和长度，是一个变长字段。\nelement-data：存放实际数据。\nelement-tot-len：前两个字段的总长度，不包括自身长度。\n每个element只记录自身长度，修改或新增元素时，不会影响后续element的长度，解决了连锁更新的问题。\n消息队列实战 消息队列介绍 消息队列是一种异步的服务间通信方式，适合用于分布式和微服务架构。消息在未被处理和删除之前一直在队列上。\n消息队列基于先进先出(FIFO)的设计原则，允许发送者(生产者)向队列中发送消息，而接收者(消费者)则可以从队列中获取消息进行处理。通常消息队列被用于解耦应用程序的各个组件，实现异步通信、削峰填谷、解耦合、流量控制等。\n消息队列特性：\n消息有序性：消息是异步处理的，但消费者需要按生产者发送消息的顺序来消费，避免出现后发送的消息被先处理的情况。\n重复消息处理：当网络问题出现消息重传时，消费者可能收到多条重复消息，可能造成同一业务逻辑被多次执行。在这种情况下，应用系统需要确保幂等性。\n可靠性：保证一次性传递消息。如果发送消息时接收者不可用，消息队列会保留消息，直到成功传递它，消费者重启后可以继续读取消息进行处理，防止消息遗漏。\nredis实现 实时消费问题 生产者可以使用LPUSH key element[element\u0026hellip;] 的形式将消息插入队列头部，如果key不存在则会创建一个空的队列再插入消息。\n消费者可以通过RPOP key 的形式依次读取队列的消息，以此实现先进先出的消息队列。\n但是LPUSH、RPOP存在性能风险，生产者向队列插入消息时，Lists并不会主动通知消费者及时消费。程序需要不断轮询并判断是否为空再执行消费逻辑，这就会导致即使没有新的信息写入，消费者也在不停的调用RPOP命令占用CPU资源。\nredis提供了BLPOP、BRPOP的阻塞读取的命令，消费者在读取队列没有数据时会自动阻塞，直到有新的消息写入队列，才继续读取新消息执行业务逻辑。\n重复消费解决方案 消息队列自动为每一条消息生成一个全局ID\n生产者为每条消息创建一个全局ID，消费者把处理过的消息ID记录下来，判断是否重复\n其实这就是密等，对于同一条消息，消费者收到后处理一次的结果和处理多次的结果是一样的。\n消息可靠性解决方案 场景：消费者读取消息处理过程中宕机了，就会导致消息没有处理完成，可是数据已经不在队列中了。\n这种现象的本质是消费者处理消息时崩溃了，无法再读取消息，缺乏一个消息确认的可靠机制。\nredis提供了BRPOPLPUSH source destination timeout 命令，含义是以阻塞的方式从source队列读取消息，同时把这个消息复制到另一个destination队列中(备份)，并且是原子操作。\n不过这个命令在redis6.2版本被BLMOVE取代。\nBLMOVE op1 op2 RIGHT LEFT 0\n消费者在消费时在while循环中使用BLMOVE，以阻塞的方式从队列 op1 队尾消费消息，同时把消息复制到队列 op2 队头(备份队列)，该操作是原子性的，最后一个参数 timeout=0 表示持续等待。\n如果上述命令消费成功，就使用 LREM 命令把队列 op2 中的对应消息删除，从而实现 ACK 确认机制。如果消费异常，使用 BRPOP op2 从备份队列中再次读取消息即可。\n","date":"2025-09-11T00:00:00Z","image":"https://liusir521.github.io/p/redis%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B9%8Blists/redis_hu_b82f72a20a63ec03.jpg","permalink":"https://liusir521.github.io/p/redis%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B9%8Blists/","title":"Redis数据结构之Lists"},{"content":"无序和唯一 Sets是字符串类型的无序集合，集合中的元素是唯一的，不会出现重复的数据。Sets底层是用散列表实现的，散列表的key存储的是Sets元素中的value，散列表的value指向null。\n使用场景 当你需要存储多个元素，且不允许出现重复数据，无需考虑元素有序性时，可以使用Sets。Sets还支持在集合之间做交集、并集、差集操作\n共同关注：通过交集实现\n每日新增关注数：对近两天的总注册用户量集合取差集\n打标签：为自己收藏的文章打标签，例如微信收藏功能，这样可以快速找到被添加了某个标签的所有文章。\nintset 当元素内容是64位以内的10进制整数，且元素个数不超过512时，Sets会使用更加省内存的intset来存储。相关结构源码如下：\n1 2 3 4 5 typedef struct intset { uint32_t encoding; uint32_t length; int8_t contents[]; } intset; length：记录整数集合存储的元素个数，其实就是contents数组的长度\ncontents：真正存储整数集合的数组，是一块连续的内存区域。数组中的元素会按照值的大小从小到大存储，并且不会有重复元素\nencoding：编码格式，决定数组类型，一共有三种不同的值。\nINTSET_ENC_INT16：表示contents数组的存储元素是int16_t类型的，每2字节表示一个整数元素\nINTSET_ENC_INT32：表示contents数组的存储元素是int32_t类型的，每4字节表示一个整数元素\nINTSET_ENC_INT64：表示contents数组的存储元素是int64_t类型的，每8字节表示一个整数元素\nintset升级 当往一个int16_t类型的intset中插入一个int64_t类型的值时会触发升级。也就是Sets的所有数据类型会转换成int64_t类型。步骤如下：\n根据新元素的类型和Sets元素数量，计算包括新添加的元素在内的新空间大小，对底层数组空间扩容，重新分配空间。\n将intset中原有的元素转换成新元素类型，按从大到小的顺序放到正确位置，需要保障intset元素的有序性。\n将encoding的值修改为length+1\n因此，每次向intset添加新元素可能引起升级，升级又会对原始数据进行类型转换，时间复杂度是O(n)。\nintset不支持降级操作\n散列表原理 Redis的散列表的底层数据结构通常是dict，由数组和链表构成，数组元素占用的槽位叫做哈希桶。当出现散列冲突时，会在桶下挂一个链表，用 拉链法 解决散列冲突的问题。\n存储结构 散列表的底层存储数据结构实际上有两种:\ndict数据结构\nlistpack(7.0之前使用ziplist)数据结构\n通常使用dict数据结构存储数据，每个field-value pairs构成一个dictEntry节点。只有 同时满足 以下两个条件，才会使用listpack数据结构替代dict。按照field在前，value在后紧密相连的方式，依次把每个field-value pairs放到列表的表尾。\n每个field-value pairs中的field和value的字符串的字节数都小于64\nfield-value pairs数量小于512\n每次向散列表写数据时，都会调用相关函数来判断是否需要转换底层数据结构。\n当插入和修改的数据不满足以上两个条件时，就把散列表底层存储的数据结构转换为dict。虽然使用了listpack无法实现O(1)时间复杂度操作数据，但能大大减少内存占用，由于数据量比较小，性能不会有太大差异。\n需要注意的是，不能由dict退化成listpack。\ndict结构体主要源码如下：\n1 2 3 4 5 6 7 8 struct dict { dictType *type; dictEntry **ht_table[2]; unsigned long ht_used[2]; long rehashidx; unsigned pauserehash; signed char ht_size_exp[2]; }; dictType *type：存放函数的结构体，定义了一些函数指针。可以通过配置自定义函数，实现在dict的key和value中存放任何类型的数据。\ndictEntry **ht_table[2]：存放大小为2的散列表指针数组，每个指针指向一个dictEntry类型的散列表。\nht_used[2]：记录每个散列表使用了多少槽位。\nrehashidx：标记是否正在执行rehash操作，-1表示没有，如果正在执行rehash操作，那么其实表示当前执行rehash操作的ht_table[0]的dictEntry数组的索引。\npauserehash：表示rehash的状态，大于0表示rehash暂停，等于0时表示继续执行，小于0时表示出错。\n数组中每个元素都是dictEntry类型的，就是它存放了field-value pairs。其结构如下：\n1 2 3 4 5 6 7 8 9 10 struct dictEntry { struct dictEntry *next; /* Must be first */ void *key; /* Must be second */ union { void *val; uint64_t u64; int64_t s64; double d; } v; }; *key指针指向field-value pairs中的field，实际上指向一个SDS实例\nv 是一个union联合体，表示field-value pairs中的value，同一时刻只有一个字段有value，用联合体的目的是节省内存。\n*val：value是非数字类型时使用该指针存储\nuint64_t u64：value是无符号整数时使用该字段\nint64_t s64：value是有符号整数时使用该字段\ndouble d：value是浮点数时使用该字段\n*next指针指向下一个节点。当发生哈希冲突时，使用此链表（链表法）。\n扩容和缩容 扩容和缩容的步骤如下：\n为了提高性能，减少哈希冲突，会创建一个大小等于ht_used[0] * 2的散列表ht_used[1]，也就是每次扩容时根据散列表ht_table [0]已使用空间扩大一倍创建一个新散列表ht_table [1]。反之，如果是缩容操作，就根据ht_table [0]已使用空间缩小一半创建一个新的散列表。\n重新计算field-value pairs的哈希值，得到这个field-value pairs在新散列表ht_table [1]中的桶位置，将field-value pairs迁移到新的散列表上。\n所有field-value pairs迁移完成后，修改指针，释放空间。把ht_table [0]指针指向扩容后的散列表，回收原来的小的散列表空间，把ht_table [1]指针指向null，为下次扩容\\缩容准备。\n扩容\\缩容时机 当前没有执行bgsave或者BGREWRITEAOF命令，同时负载因子大于等于1。也就是当前没有RDB子进程和AOF重写子进程在工作，这两个操作容易对性能造成影响。\n正在执行bgsave或者BGREWRITEAOF命令，负载因子大于或等于5。这时哈希冲突比较严重，再不扩容，查询效率就太低了。\n负载因子 = 散列表存储的dictEntry节点数量 / 哈希桶个数。理想情况下每个哈希桶存储一个dictEntry节点，这时负载因子 = 1。\n扩容过程 为了防止阻塞主线程造成性能问题，不是一次性把全部key迁移，而是分多次将迁移操作分散到每次请求中，避免集中式rehash造成长时间阻塞。\n在渐进式rehash期间，dict会同时使用ht_table[0]和ht_table[1]两个散列表，具体步骤如下：\n将rehashidx配置为0，表示rehash开始执行。\n在rehash期间，服务端每次处理客户端对dict散列表的增删改查操作时，除了执行指定操作外，还会检查当前dict是否处于rehash状态，如果是，就把散列表ht_table[0]上索引位置为rehashidx的哈希桶的链表的所有field-value pairs rehash到散列表ht_table[1]上，并将rehashidx加1。\n当所有field-value pairs迁移完成后将rehashidx配置为-1，表示操作已完成。\n删除、修改和查找可能会在两个散列表上进行，第一个没找到就去第二个，但是增加操作只会在新的散列表上进行。\n","date":"2025-09-11T00:00:00Z","image":"https://liusir521.github.io/p/redis%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B9%8Bset/redis_hu_b82f72a20a63ec03.jpg","permalink":"https://liusir521.github.io/p/redis%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B9%8Bset/","title":"Redis数据结构之Set"},{"content":"前言 原生map设计的缺陷 不支持并发，需要加锁操作，原生库提供的有sync.map，底层存储所使用的容器还是map。\n不会自动缩容。内置的map有自动扩容的功能，但是当删除大量数据之后不会自动缩容，删除之后桶本身占用的内存并不会被回收。(可以通过定时新建map拷贝来实现，但是比较麻烦)\n如果存放的数据的类型是指针时，GC会扫描map的所有元素，效率不高。\n三方库主流解决方案 三方库通过 预分配内存（创建时指定map大小，当插入时如果没有空位就通过淘汰算法清除老数据）+ 分片hash（将一个大map拆分成多个小map，分片减少了key进入同一个hash shard的概率，同时，当一个分片加锁时不会影响别的分片）+ 缓存淘汰算法（lfu、lru、fifo等）通过控制数量来减少map不缩容带来的影响。\n也有三方库底层通过map+slice的形式来减少不缩容的影响，其中slice用来真正的存储value，map[key][index]用来查找key在slice中对应的下标。\n三方库对比 bigcache 存储结构 分片hash+[]byte ，每一个分片称为一个shard（map[int64]int32，避免了存储指针），在get或者set时会先对key进行hash，根据hash值判断操作哪一个shard，之后的操作都是在shard上进行的。（shard之间互不影响，每个都有读写锁）真正的item（set的value）是存储在[]byte（bytesqueue，gc对于切片当作一个变量扫描，无需遍历整个数组）中的，分片（shard）中的value其实就是该item对应的下标。\n添加操作 添加操作分为三种情况：\n要添加的key已经存在（也可能是hash碰撞）：由于使用的是FIFO淘汰策略，所以即使有旧值的情况下，新值也不会复用其内存，而是push新的value到队列中。之前的旧值并未从内存中移除，仅将其偏移量从hashmap中移除，使得外部读不到。\n旧值何时淘汰：\n设置了CleanWindow，且旧值刚好过时，会被清理器自动淘汰。\n设置了MaxEntrySize或者HardMaxCacheSize，当内存满时，也会出发旧数据的淘汰。\n1 2 3 4 5 6 7 if previousIndex := s.hashmap[hashedKey]; previousIndex != 0 { if previousEntry, err := s.entries.Get(in(previousIndex)); err == nil { resetHashFromEntry(previousEntry) //remove hashkey delete(s.hashmap, hashedKey) } } bytesqueue已满：\na. 如果bytesqueue未达到设定的HardMaxCacheSize上限，或者HardMaxCacheSize没有设置，则直接扩容直到切片上限。\nb. 如果已达上限，则会删除最旧的数据（无论是否过期），知道可以将当前数据添加进去。\n正常情况下，加当前shard的写锁，直接添加并更新索引。\n获取操作 添加操作的逆过程。需要注意的是，如果获取时数据到达了过期时间，但还没有被清理掉，这时也是可以成功获取到的。是符合大多数需求场景的。\n删除操作 和添加时清除旧值类似。\n淘汰策略 采用FIFO淘汰策略。新增数据，以及对老数据进行修改，都是直接append到[]byte（bytesqueue）中的。基本不对内存进行修改删除。同时，每个数据项不能单独设置缓存时长，必须全部保持一致。这样对数据淘汰比较友好。\nfreecache 存储结构 freecache和bigcache类似，也是分片的思想。freecache内部包含256个分段（segment），每个分段都有一把锁。每个分段内部包含两个指针rb（ringbuffer，底层是切片，存储真实数据）和slotsData（也是切片，存储数据在rb中的下标，查找时二分法遍历）。指针固定512（rb和slotData）个，所以号称0GC。但是freecache的key和value都是[]byte类型的。需要进行序列化操作。\n添加操作 先对key进行hash，低8位对应segment数组，低8-15位选取slot下标， 同时取高 16 位做 hash16 用于 slot 内快速比较。 然后通过二分法寻找小于等于当前hash16，如果找到等于的还要继续对比key的信息。如果找到相同的key，则是更新操作，没找到就是插入。更新时会比较新value和老value的大小，如果比老的小就可以直接覆盖写入，否则就在rb的末尾寻找空间写入。插入时如果rb空间不足就会触发淘汰机制。\n查找操作 与添加操作的查找类似，先通过key定位segment，然后继续定位slot，然后根据hash16进行二分查找。如果找到了继续对存储的key进行对比。完全命中才算。命中时读取其设置的过期时间，如果已经到了，返回未命中，并将此项标记为删除。没过期就更新相关的命中统计。\n删除操作 value的定位和上面的一样，找到之后会将索引从slot中移除， 并把该 entry 的 header 标记 deleted = true（逻辑删除标识，写在 header 里）。这意味着索引已经不再指向该条目，外部 Get/Set 不会再找到它。\n淘汰机制 当 RingBuf 空间不足时，FreeCache 会从缓冲区的头部开始主动淘汰数据以释放空间。其淘汰策略是一个精巧的、近似 LRU 的策略，综合考虑了访问时间和过期时间。 freecache 不是精确维护一个 LRU 链表，而是用分段 + 环形缓冲 + “平均访问时间”比较的方法：当某个段要腾空间时，优先删除过期或“比段平均访问时间更旧（更冷）”的条目；如果条目相对较新（热），则把它搬到环形缓冲的尾部以保留。这个策略因此被称为 Nearly LRU（近似 LRU）。\nristretto 存储结构 与前面的框架类似，也是分片hash的设计思想（256个分片+读写锁）（最底层使用的map存储的value），但是最底层存储的key是经过计算后的hash值（该框架对key计算了两个hash值\u0026lt;两个uint64\u0026gt;，keyhash和 conflict ，后者用于判断冲突，使用的是xxhash算法）。该框架在初始化实例时会自动启动一个协程processItems来进行增删改的操作，并周期性的进行GC回收。\n添加操作 首先会检查设置的过期时间，如果小于0直接返回false代表失败，为0表示永不过期。然后根据key计算出两个hash值。然后会先尝试更新操作，如果返回更新成功，会直接修改存储的value，并将当前item的flag修改为更新操作。然后尝试将这个item放入到setbuff的缓冲队列中（channel），如果队列未满或者满了但是当前操作是更新会返回true，视为写入成功，否则返回false并记录指标。然后processItems协程会从setbuff中获取数据（select）。\n获取到数据之后，首先会判断当前item是否是特殊item（内置一个空结构体类型的channel字段wait）， 由于是协程监听进行的增删改操作，不保证强一致性，Ristretto提供了一个Wait函数来等待之前设置的item全部设置成功，该函数会进行阻塞（读取wait），直到setbuff执行到这个特殊的item，然后关闭这个特殊item的wait，结束Wait函数，表示数据已经完全录入完毕。\n如果是普通的item，会先计算这个item的消耗cost，如果初始化时提供了Coster函数，并且当前flag不是删除，且item的cost为0，就会调用提供的函数进行计算真实的cost，然后还会加上内部存储占用 itemSize，除非在 Config 中设置 IgnoreInternalCost = true。\n然后判断当前item的flag是否是添加操作（这里默认是），然后调用函数进行决策，通过TinyLFU算法来判断当前item是否值得被接受，以及如果接受之后超出内存上限需要通过 Sampled LFU 算法决定要淘汰哪些数据。如果值得添加，就调用函数将item添加到底层map中并记录指标，然后记录当前key的时间戳，方便后续计算寿命。如果不值得添加，就调用 onReject 回调函数，直接丢弃。然后对需要淘汰的数据进行删除。\n更新操作 同理从setbuff中获取，未进入setbuff的添加的item参考添加操作。外部暴露的函数与添加操作一致，都是Set。进入setbuff的flag为更新操作的item仅更新相关指标即可。\n删除操作 同理从setbuff中获取（有序队列，避免了先执行删除后又被恢复的场景），执行相关的指标更新，然后从底层删除，并调用相关的回调函数。\n获取操作 先获取key的两个hash值，在从map寻找数据之前，会先执行一个Push操作，将访问计数的采集推入缓冲再进行读取，缓冲是自定义的一个ring-buffer，内部有sync.pool字段存储获取的key的切片，目的是合成批量操作降低开销。然后才会在真正的分片hash中查找对应的数据， 如果传入了 conflictHash 且不匹配就认为不存在，检查 item 是否过期（TTL），如果过期也当作不存在。\n淘汰策略 采用的是 Sampled LFU（随机采样找出最不常用的候选）淘汰算法，当添加时发现空间不足时会先用 TinyLFU 估算新key的频率，然后从已有的item中随机抽取5个（源码写死，研究表明，5 个样本的概率足以近似全局最低频项）估算key的频率，如果新key最低，就不值得进入缓存，不是最低就将要淘汰的key添加到相关队列中，重复上述步骤，直到能把新key放进去。\n总结 对比 数据类型限制 淘汰策略 一致性 过期设置 GC 内存利用率 适用场景 bigcache value 必须是 []byte FIFO 强一致性 仅支持 全局过期时间，不支持单个 key 设置过期；获取时若过期按成功处理 0 GC 支持动态扩展，利用率较高，但没有淘汰策略，过期数据可能占用内存较久 数据生命周期短，不太在意命中率；适合 短时缓存、临时存储 freecache key 和 value 都必须是 []byte Nearly LRU（近似 LRU） 强一致性 支持 单个 key 设置过期时间；获取时若过期按失败处理 0 GC 内存固定分配，利用率高；但 key 小 value 大 时可能产生空间碎片 命中率要求不高；适合 日志、监控指标等大量 KV 缓存 ristretto 无限制 Sampled LFU（随机采样选出最不常用候选） 最终一致性 支持 单个 key 设置过期时间；获取时若过期按失败处理 Go GC 有容量上限且控制精细，结合淘汰策略，整体 内存利用率最高 需要 高命中率；适合 业务缓存（用户会话、推荐系统、热点数据） ","date":"2025-09-10T00:00:00Z","image":"https://liusir521.github.io/p/go%E4%B8%89%E6%96%B9%E7%BC%93%E5%AD%98%E5%BA%93%E5%AF%B9%E6%AF%94/go_hu_ddebc93416516e7f.png","permalink":"https://liusir521.github.io/p/go%E4%B8%89%E6%96%B9%E7%BC%93%E5%AD%98%E5%BA%93%E5%AF%B9%E6%AF%94/","title":"Go三方缓存库对比"},{"content":" redis版本7.x\n原生C语言字符串的问题 C语言使用char * 字符数组来实现字符串，在创建时就需要手动检查和分配空间。由于没有 length属性 记录长度，想要获取字符串的长度就需要从头遍历，直到遇到\\0。\n无法做到“安全的二进制格式数据存储”，图片等二进制格式数据无法保存。无法存储\\0这种特殊字符，\\0在C语言中代表字符串结尾。\n字符串的扩容和缩容。char数组的长度在创建字符串的时候就确定下来，如果要追加数据，则要重新申请一块空间，把追加后的字符串内容拷贝进去，再释放旧的空间，十分消耗资源。\nRedis中SDS设计 SDS也遵循C语言的以空字符串 \\0 结尾的惯例，但是空字符串不计入SDS内部的len字段中。\nSDS主要字段如下：\nlen：数组已使用长度\nalloc：数组总长度\nflags：SDS类型\nchar buf[]：存储的实际内容\nO(1)时间复杂度获取字符串长度 SDS中的len字段保存了字符串的长度，实现了O(1)时间复杂度获取字符串长度。\nSDS结构有一个flags字段，表示的是SDS类型。实际上SDS一共设计了5种类型，分别是sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64，区别在于数组的len长度和分配空间长度alloc不同。\n节省内存 之所以这么设计，是因为SDS使用不同的类型保存不同大小的字符串可以节省内存。\nredis内部限制最大的字符串长度为512MB\n编码格式 SDS内部采用了三种编码格式来存储，分别是int、embstr和raw。\nint编码：8字节的长整型，值是数字类型且数字的长度小于20。\nembstr编码：长度小于或等于44字节的字符串。\nraw编码：长度大于44字节的字符串。\n作用：代替字节对齐的方式来节省内存。\n二进制格式的数据安全 因为SDS并不是通过 \\0 来判断字符串结束的，而是采用len标志结束，所以可以直接存储二进制格式数据。\n空间预分配 在需要对SDS的空间进行扩容时，不仅仅分配所需的空间，还会分配额外的未使用空间，通过预分配策略，减少了执行字符串增长所需的内存重新分配次数。\n惰性空间释放 当对SDS进行缩短操作时，程序并不会回收多余的内存空间，如果后面需要append追加操作，则直接使用buf数组alloc-len中未使用的空间。通过惰性空间释放策略，避免了减小字符串所需的内存重新分配操作。\n","date":"2025-09-10T00:00:00Z","image":"https://liusir521.github.io/p/redis%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B9%8Bstringsds/redis_hu_b82f72a20a63ec03.jpg","permalink":"https://liusir521.github.io/p/redis%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B9%8Bstringsds/","title":"Redis数据结构之String(SDS)"}]