[{"content":"官方源码阅读 main/mrsequential.go 先看它的loadPlugin函数：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func loadPlugin(filename string) (func(string, string) []mr.KeyValue, func(string, []string) string) { p, err := plugin.Open(filename) if err != nil { log.Fatalf(\u0026#34;cannot load plugin %v\u0026#34;, filename) } xmapf, err := p.Lookup(\u0026#34;Map\u0026#34;) if err != nil { log.Fatalf(\u0026#34;cannot find Map in %v\u0026#34;, filename) } mapf := xmapf.(func(string, string) []mr.KeyValue) xreducef, err := p.Lookup(\u0026#34;Reduce\u0026#34;) if err != nil { log.Fatalf(\u0026#34;cannot find Reduce in %v\u0026#34;, filename) } reducef := xreducef.(func(string, []string) string) return mapf, reducef } 这个函数传入了一个.so常见文件的路径，返回了两个函数\n一个是mafp：两个字符串（文件名、文件内容）进一组kv出\n一个是reducef：一个key和一组值进入，一个结果字符出\n这里的if err != nil是用来记录错误日志的，我们先跳过\n那么也就是说这个loadPlugin函数总共做了四步：\np, err := plugin.Open(filename)把文件动态加载到当前进程 xmapf, err := p.Lookup(\u0026quot;Map\u0026quot;)按照名字\u0026quot;Map\u0026quot;查找符号，拿到interface mapf := xmapf.(func(string, string) []mr.KeyValue)断言，如果不是这个签名的函数就panic xreducef, err := p.Lookup(\u0026quot;Reduce\u0026quot;)同上两步 在这个文件里只有这么一个重要函数，那么我们现在去看main函数：\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 func main() { if len(os.Args) \u0026lt; 3 { fmt.Fprintf(os.Stderr, \u0026#34;Usage: mrsequential xxx.so inputfiles...\\n\u0026#34;) os.Exit(1) } mapf, reducef := loadPlugin(os.Args[1]) // // read each input file, // pass it to Map, // accumulate the intermediate Map output. // intermediate := []mr.KeyValue{} for _, filename := range os.Args[2:] { file, err := os.Open(filename) if err != nil { log.Fatalf(\u0026#34;cannot open %v\u0026#34;, filename) } content, err := ioutil.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...) } // // a big difference from real MapReduce is that all the // intermediate data is in one place, intermediate[], // rather than being partitioned into NxM buckets. // sort.Sort(ByKey(intermediate)) oname := \u0026#34;mr-out-0\u0026#34; ofile, _ := os.Create(oname) // // call Reduce on each distinct key in intermediate[], // and print the result to mr-out-0. // i := 0 for i \u0026lt; len(intermediate) { j := i + 1 for j \u0026lt; len(intermediate) \u0026amp;\u0026amp; intermediate[j].Key == intermediate[i].Key { j++ } values := []string{} for k := i; k \u0026lt; j; k++ { values = append(values, intermediate[k].Value) } output := reducef(intermediate[i].Key, values) // this is the correct format for each line of Reduce output. fmt.Fprintf(ofile, \u0026#34;%v %v\\n\u0026#34;, intermediate[i].Key, output) i = j } ofile.Close() } 这个就比较好理解了，首先要校验参数的数量，因为这个程序需要大于等于两个参数查新，然后就是利用之前的loadPlugin函数来加载后面会提到的mc.so文件得到两个函数mapf和reducef 然后是map阶段，我们会创建一组kv叫intermediate，然后用一个for循环把剩下的参数也就是文件用我们之前得到的mapf函数解析成kv对然后丢进kv池里面\n然后用一个sort函数根据key的值进行排序\n最后就是reduce阶段，用前面的reducef函数和双指针分组调用的方法得到结果\n后续要做的呢就是把这两个for循环里的东西都分配给woker然后交给coordinator来管理\nmain/mrcoordinator.go 这个文件就是到时候写完我们自己的MapReduce和RPC系统以后测试用的脚本，看看他是那些部分组成的\n1 2 3 4 5 6 7 8 9 10 11 12 13 func main() { if len(os.Args) \u0026lt; 2 { fmt.Fprintf(os.Stderr, \u0026#34;Usage: mrcoordinator inputfiles...\\n\u0026#34;) os.Exit(1) } m := mr.MakeCoordinator(os.Args[1:], 10) for m.Done() == false { time.Sleep(time.Second) } time.Sleep(time.Second) } 只有一个简单的main函数，开头依然是参数校验，后面的m := mr.MakeCoordinator(os.Args[1:], 10)就是调用后面我们会写的coordinator代码来注册我们的coordinator，然后就是轮询代码，最后等待一秒钟退出\nmain/mrworker.go 这个则更简单，一笔带过了\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 func main() { if len(os.Args) != 2 { fmt.Fprintf(os.Stderr, \u0026#34;Usage: mrworker xxx.so\\n\u0026#34;) os.Exit(1) } mapf, reducef := loadPlugin(os.Args[1]) mr.Worker(mapf, reducef) } // load the application Map and Reduce functions // from a plugin file, e.g. ../mrapps/wc.so func loadPlugin(filename string) (func(string, string) []mr.KeyValue, func(string, []string) string) { p, err := plugin.Open(filename) if err != nil { log.Fatalf(\u0026#34;cannot load plugin %v\u0026#34;, filename) } xmapf, err := p.Lookup(\u0026#34;Map\u0026#34;) if err != nil { log.Fatalf(\u0026#34;cannot find Map in %v\u0026#34;, filename) } mapf := xmapf.(func(string, string) []mr.KeyValue) xreducef, err := p.Lookup(\u0026#34;Reduce\u0026#34;) if err != nil { log.Fatalf(\u0026#34;cannot find Reduce in %v\u0026#34;, filename) } reducef := xreducef.(func(string, []string) string) return mapf, reducef } 开头一个参数校验，然后就是和main/mrsequential一样的注册mapf和reducef函数，结束\nmr/coordinator.go、mr/worker.go、mr/rpc.go空壳 这三个文件就是空壳，需要我们来写入的，我们可以参考论文原文来进行设计\nmr/rpc.go 照例先来看一下源码，首先就是两个结构体，里面各自包了一个int变量：\n1 2 3 4 5 6 7 type ExampleArgs struct { X int } type ExampleReply struct { Y int } 这个是官方的样例，不用管他，然后看后面的coordinatorSock函数：\n1 2 3 4 5 func coordinatorSock() string { s := \u0026#34;/var/tmp/5840-mr-\u0026#34; s += strconv.Itoa(os.Getuid()) return s } 它是 server()（监听端）和 call()（拨号端）约定socket 路径的“接头暗号” 我们在这里只需要写入四个结构体，来方便worker和coordinator两个独立进程的沟通,具体的流程如下：\nsequenceDiagram participant W as Worker 进程 participant C as Coordinator 进程 W-\u0026gt;\u0026gt;W: ① 客户端打包 args\u0026lt;br/\u0026gt;(gob 序列化成字节流) W-\u0026gt;\u0026gt;C: ② 字节流经 Unix socket 发出\u0026lt;br/\u0026gt;(附带上方法名 \u0026#34;Coordinator.AssignTask\u0026#34;) C-\u0026gt;\u0026gt;C: ③ 服务端按方法名查表\u0026lt;br/\u0026gt;找到 c 对象的 AssignTask 方法 C-\u0026gt;\u0026gt;C: ④ 反序列化字节流 → 填充 *TaskRequest\u0026lt;br/\u0026gt;分配一个零值 TaskReply C-\u0026gt;\u0026gt;C: ⑤ 执行你的 AssignTask 函数体\u0026lt;br/\u0026gt;(读任务表, 填 reply, 返回 nil) C-\u0026gt;\u0026gt;W: ⑥ 把 reply 序列化发回 W-\u0026gt;\u0026gt;W: ⑦ 反序列化 → 填进你的 \u0026amp;reply\u0026lt;br/\u0026gt;call() 返回 true 所以我们考虑两组结构体，第一组是worker向coordinator要任务：\n1 2 3 4 5 6 7 8 9 10 11 12 13 type TaskRequest struct { // 什么都不用带，代表着像coordinator要任务 // 以后如果做优化(比如 worker 声明\u0026#34;我刚做完map任务X,它的中间文件我本地还有\u0026#34;), // 这里才有东西可加 } type TaskReply struct { Type string // \u0026#34;map\u0026#34; / \u0026#34;reduce\u0026#34; / \u0026#34;wait\u0026#34; / \u0026#34;exit\u0026#34; 四种指令 TaskID int // 任务编号: map任务的X(对应mr-X-*), reduce任务的Y InputFile string // 仅map任务用: 要处理的文件名 NMap int // reduce任务需要: 知道要去读 mr-0-Y ~ mr-(NMap-1)-Y NReduce int // map任务需要: 分桶数, ihash(key)%NReduce } 第二组是worker向coordinator汇报任务完成/失败：\n1 2 3 4 5 6 type ReportArgs struct { Type string // 哪类任务完成了 TaskID int // 哪一个 } type ReportReply struct{} // 惯例留空: 汇报不需要回执内容, 但Reply结构体必须存在 coordinator.go 先写服务端，方便理解，首先依然是需要想清楚作为一个服务端他需要哪些信息：\n1 2 3 4 5 6 7 8 9 type Coordinator struct { mu sync.Mutex files []string // M 个 map 任务的任务表 nReduce int mapDone []bool // 各 map 任务状态(false=可派发) nextMap int // 下一个待派的 map 任务下标 reduceDone []bool // 各 reduce 任务状态 nextReduce int // 待派的 reduce 任务游标 } 接下来可以看看项目自带的一个server函数\n1 2 3 4 5 6 7 8 9 10 11 12 func (c *Coordinator) server() { rpc.Register(c) rpc.HandleHTTP() //l, e := net.Listen(\u0026#34;tcp\u0026#34;, \u0026#34;:1234\u0026#34;) sockname := coordinatorSock() os.Remove(sockname) l, e := net.Listen(\u0026#34;unix\u0026#34;, sockname) if e != nil { log.Fatal(\u0026#34;listen error:\u0026#34;, e) } go http.Serve(l, nil) } 第一行就是一个注册函数，把Coordinator类里面的所有方法都登记到一张方法表里面，写的所有方法都可以被远程调用\n第二行就是注册http处理器，net/tpc的报文是跑在http协议上的，也就是说每个RPC请求都是一个HTTP POST\n第三行被注释掉了，他注册了一个跨机器的tcp链接，如果想要模拟多机器的话要用\n第四行这是我们之前看到coordinatorSock函数拿到本机的socket路径\n第五行调用了一个库函数os.Remove是用来删除可能残留的旧socket文件\n第六行则是创建ocket端点并开始监听，从这里开始所有的worker都可以进入了\n最后就是做一个后台的服务循环，阻塞式的接受链接，每来一个链接就处理上面的RPC请求，也就是第一行中登记的方法，注意这个东西永不返回，出发socket报错了\n接下来就是考虑创建Coordinator服务了，写一个简单的构造函数：\n1 2 3 4 5 6 7 8 9 10 func MakeCoordinator(files []string, nReduce int) *Coordinator { c := Coordinator{ files: files, nReduce: nReduce, mapDone: make([]bool, len(files)), reduceDone: make([]bool, nReduce), } c.server() return \u0026amp;c } 这里有个编译器抓不到的坑：如果把结构体初始化写成空的c := Coordinator{}，len(c.files)永远是0，所有worker只能收到wait；更糟的是第一个ReportDone进来时c.mapDone[0] = true会直接panic——对nil slice赋值。所以四个字段必须在这里全部初始化好，尤其两个bool切片要用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 func (c *Coordinator) AssignTask(args *TaskRequest, reply *TaskReply) error { c.mu.Lock() defer c.mu.Unlock() if c.nextMap \u0026lt; len(c.files) { // 还有 map 任务没派完 reply.Type = \u0026#34;map\u0026#34; reply.TaskID = c.nextMap reply.InputFile = c.files[c.nextMap] reply.NReduce = c.nReduce c.nextMap++ return nil } // 阶段2: map 全部完成才开始派 reduce(桶文件此时才齐) if allTrue(c.mapDone) { if c.nextReduce \u0026lt; c.nReduce { reply.Type = \u0026#34;reduce\u0026#34; reply.TaskID = c.nextReduce reply.NMap = len(c.files) // reduce 收货时需要知道有几个来源 c.nextReduce++ return nil } if allTrue(c.reduceDone) { // 阶段3: 全部完成, 通知 worker 收工 reply.Type = \u0026#34;exit\u0026#34; return nil } } reply.Type = \u0026#34;wait\u0026#34; // 本阶段已派完但没全跑完, worker 先等等 return nil } 这里是先加了一个锁，然后defer了锁，让他完成任务后自动解锁\n后面就是判断活是否派完了，这里的reply指的是RPC的返回载体，具体流程如下：\nflowchart LR A[\u0026#34;worker侧: 声明零值的reply\u0026#34;] --\u0026gt; B[\u0026#34;worker发起call, 请求飞出\u0026#34;] B --\u0026gt; C[\u0026#34;coordinator框架收到请求\u0026lt;br/\u0026gt;分配一个零值TaskReply\u0026#34;] C --\u0026gt; D[\u0026#34;你的handler往reply里填字段\u0026lt;br/\u0026gt;然后return nil\u0026#34;] D --\u0026gt; E[\u0026#34;框架把reply序列化发回worker\u0026#34;] E --\u0026gt; F[\u0026#34;worker侧的reply被填充\u0026lt;br/\u0026gt;call返回true\u0026#34;] 正如之前所说，net/rpc规定的handler无法返回业务数据，唯一的办法就是reply这个指针参数，吧task相关新的信息给到worker，其实也就是相当于return\n如果派完了任务，但是仍然跑了这个函数，那么就会标记为wait\n这里要注意两个细节：第一，判断能不能派reduce用的是allTrue(c.mapDone)而不是nextMap \u0026gt;= len(files)——因为派完不等于跑完，桶文件要等所有map真正写完才齐全，reduce提前开跑会收到os.Open失败。第二，派reduce时必须填reply.NMap = len(c.files)，因为doReduce要靠它决定去收几家的货，不填就一个桶文件都收不到。\n另外如果只有map和wait两个分支，map全跑完之后没有人切换阶段，所有worker会无限wait下去，所以阶段2和阶段3是整个状态机真正转起来的关键：map全完成→派reduce→reduce全完成→发exit，Done()才有机会变成true让mrcoordinator退出。\n接着就是一个回复的函数：\n1 2 3 4 5 6 7 8 9 10 func (c *Coordinator) ReportDone(args *ReportArgs, reply *ReportReply) error { c.mu.Lock() defer c.mu.Unlock() if args.Type == \u0026#34;map\u0026#34; { c.mapDone[args.TaskID] = true } else if args.Type == \u0026#34;reduce\u0026#34; { c.reduceDone[args.TaskID] = true } return nil } 这个函数相当于worker完成任务以后寄回给coordinator的完工信\n第一行同样的是一个加锁然后第二行defer这是因为两个worker可能同时完工，而这个后面的mapDone是共享账本，并发写入就会有问题\n注意else if那个reduce分支不能少：如果只处理map，reduce的完工汇报会被静默丢弃，reduceDone永远全是false，Done()永远不成立，mrcoordinator就永远不会退出\n最后是一个表示完成的Done函数\n1 2 3 4 5 6 7 func (c *Coordinator) Done() bool { c.mu.Lock() defer c.mu.Unlock() return c.nextMap \u0026gt;= len(c.files) \u0026amp;\u0026amp; // map 全派完 allTrue(c.mapDone) \u0026amp;\u0026amp; // map 全完成 allTrue(c.reduceDone) // reduce 全完成 } 这个是到时候运行main/mrcoordinator.go的时候那个轮询，来知道是否完成所有任务\nDone()里用到的allTrue是一个小工具函数，见到一个false就整体false，全true才返回true：\n1 2 3 4 5 6 7 8 func allTrue(flags []bool) bool { for _, b := range flags { if !b { return false } } return true } worker.go 我们先来看看worer里面带了哪些预设的可用的东西\n首先就是一个kv结构体和一个哈希函数，就不多赘述了：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 type KeyValue struct { Key string Value string } // // use ihash(key) % NReduce to choose the reduce // task number for each KeyValue emitted by Map. // func ihash(key string) int { h := fnv.New32a() h.Write([]byte(key)) return int(h.Sum32() \u0026amp; 0x7fffffff) } 下面就是一个call函数：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 func call(rpcname string, args interface{}, reply interface{}) bool { // c, err := rpc.DialHTTP(\u0026#34;tcp\u0026#34;, \u0026#34;127.0.0.1\u0026#34;+\u0026#34;:1234\u0026#34;) sockname := coordinatorSock() c, err := rpc.DialHTTP(\u0026#34;unix\u0026#34;, sockname) if err != nil { log.Fatal(\u0026#34;dialing:\u0026#34;, err) } defer c.Close() err = c.Call(rpcname, args, reply) if err == nil { return true } fmt.Println(err) return false } 首先定义一个socket,然后我们用Dial.HTTP来做三件事情：打开 socket 文件 → 和 coordinator 完成 HTTP 握手 → 协商出 RPC 通道。最后返回一条连接*rpc.Client\n接着defer一个关闭这连接的语句 重点就是后一句，调用连接中的Call函数，他是同步阻塞的，主要作用就是你传的 args 序列化后经 socket 发给 coordinator，等它执行完对应方法、把填好的 reply 序列化传回来填进你的 reply 指针。\n接着就是一个call函数的样例，我们后续就根据这个样例来写我们的函数：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 func CallExample() { // declare an argument structure. args := ExampleArgs{} // fill in the argument(s). args.X = 99 // declare a reply structure. reply := ExampleReply{} // send the RPC request, wait for the reply. // the \u0026#34;Coordinator.Example\u0026#34; tells the // receiving server that we\u0026#39;d like to call // the Example() method of struct Coordinator. ok := call(\u0026#34;Coordinator.Example\u0026#34;, \u0026amp;args, \u0026amp;reply) if ok { // reply.Y should be 100. fmt.Printf(\u0026#34;reply.Y %v\\n\u0026#34;, reply.Y) } else { fmt.Printf(\u0026#34;call failed!\\n\u0026#34;) } } 我们需要的是一个一个客户端循环，所以我们可以考虑这么设计：\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 func Worker(mapf func(string, string) []KeyValue, reducef func(string, []string) string) { for { args := TaskRequest{} reply := TaskReply{} ok := call(\u0026#34;Coordinator.AssignTask\u0026#34;, \u0026amp;args, \u0026amp;reply) if !ok { return // coordinator 已退出 } switch reply.Type { case \u0026#34;map\u0026#34;: if err := doMap(mapf, reply); err != nil { log.Printf(\u0026#34;map task %d failed: %v\u0026#34;, reply.TaskID, err) continue // 失败不汇报, 靠超时重派兜底 } call(\u0026#34;Coordinator.ReportDone\u0026#34;, \u0026amp;ReportArgs{Type: \u0026#34;map\u0026#34;, TaskID: reply.TaskID}, \u0026amp;ReportReply{}) case \u0026#34;reduce\u0026#34;: if err := doReduce(reducef, reply); err != nil { log.Printf(\u0026#34;reduce task %d failed: %v\u0026#34;, reply.TaskID, err) continue } call(\u0026#34;Coordinator.ReportDone\u0026#34;, \u0026amp;ReportArgs{Type: \u0026#34;reduce\u0026#34;, TaskID: reply.TaskID}, \u0026amp;ReportReply{}) case \u0026#34;wait\u0026#34;: time.Sleep(500 * time.Millisecond) case \u0026#34;exit\u0026#34;: return } } } 他是无限循环的，直到最后到了触发条件的时候才退出 首先会创建一个空的请求和空的回复，args和reply\n然后使用一个call方法，找到coordinator中的AssignTask方法，同时载入args和reply\n如果这call返回false那么说明coordinator已经退出，这时候就直接return 退出循环\n这时候我们会用switch case语句来判断worker类型，如果是map或者reduce那么就干活，然后同样使用call方法汇报完工情况，如果是wait那就等，如果是exit那就返回\n下面写一下具体干活的doMap和doReduce是具体怎么完成这个工作的： 首先是doMap\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 func doMap(mapf func(string, string) []KeyValue, reply TaskReply) error { content, err := os.ReadFile(reply.InputFile) // 读文件(原来在main循环里) if err != nil { return err } kva := mapf(reply.InputFile, string(content)) // 调插件把文件解析为kv对 // 分桶写文件: 先全部写临时文件 encs := make([]*json.Encoder, reply.NReduce) tmps := make([]*os.File, reply.NReduce) for i := 0; i \u0026lt; reply.NReduce; i++ { f, err := os.CreateTemp(\u0026#34;.\u0026#34;, \u0026#34;tmp-*\u0026#34;) // 临时文件 if err != nil { return err } tmps[i] = f encs[i] = json.NewEncoder(f) } for _, kv := range kva { //取哈希值 b := ihash(kv.Key) % reply.NReduce encs[b].Encode(\u0026amp;kv) } // 原子提交: 全部写完才逐个改名(半成品永远不可见) for i, f := range tmps { f.Close() os.Rename(f.Name(), fmt.Sprintf(\u0026#34;mr-%d-%d\u0026#34;, reply.TaskID, i)) } return nil } 这一这里的分桶，其实他就是在磁盘上的文件，然后以mr-x-y的形式存储，x就是哪个map任务产生的，y就是桶号，里面就是一堆json格式的kv对\n然后这个函数先for循环创建了n个文件然后存储在tmps内，然后通过encs吧编码器绑定在文件上\n然后在kv主循环上求出哈希值，再再编码器上把kv对解析成json格式存储\n然后就是落盘，形成n个桶文件\n然后就是doReduce：\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 func doReduce(reducef func(string, []string) string, reply TaskReply) error { // 1. 收集: 把 NMap 个 map 任务产出的第 TaskID 桶全部读进来 kva := []KeyValue{} for x := 0; x \u0026lt; reply.NMap; x++ { f, err := os.Open(fmt.Sprintf(\u0026#34;mr-%d-%d\u0026#34;, x, reply.TaskID)) if err != nil { return err } dec := json.NewDecoder(f) var kv KeyValue for dec.More() { dec.Decode(\u0026amp;kv) kva = append(kva, kv) } f.Close() } // 2. 排序 —— 从这里开始和 mrsequential 逐字相同 sort.Sort(ByKey(kva)) // 3. 双指针分组 + reducef + 标准格式输出 tmp, _ := os.CreateTemp(\u0026#34;.\u0026#34;, \u0026#34;tmp-*\u0026#34;) 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) } fmt.Fprintf(tmp, \u0026#34;%v %v\\n\u0026#34;, kva[i].Key, reducef(kva[i].Key, values)) i = j } tmp.Close() os.Rename(tmp.Name(), fmt.Sprintf(\u0026#34;mr-out-%d\u0026#34;, reply.TaskID)) // 原子提交 return nil } 首先就是一个for循环来收集我们之前doMap产生的文件，然后就是根据key来排序 然后就是经典的双指针分组，然后append到valud里面，最后就是落盘写到mr-our-x文件里，x指的是reduce的任务编号\n还有一个容易踩的坑：doReduce里用了sort.Sort(ByKey(kva))，但ByKey定义在mrsequential.go里，那是package main的代码，mr包看不见它——同一个仓库不同包，各自独立编译，所以要自己在worker.go里补一份：\n1 2 3 4 5 6 // for sorting by key. type ByKey []KeyValue 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 } 这是sort.Sort的接口约定：要排序的类型必须实现sort.Interface，也就是Len、Swap、Less这三个方法\nmrapps/wc.go 参考文献 https://pdos.csail.mit.edu/6.824/papers/mapreduce.pdf\n","permalink":"https://bg5dux.top/posts/6.824lab1/","summary":"\u003ch1 id=\"官方源码阅读\"\u003e官方源码阅读\u003c/h1\u003e\n\u003ch2 id=\"mainmrsequentialgo\"\u003emain/mrsequential.go\u003c/h2\u003e\n\u003cp\u003e先看它的loadPlugin函数：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cdiv style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\n\u003ctable style=\"border-spacing:0;padding:0;margin:0;border:0;\"\u003e\u003ctr\u003e\u003ctd style=\"vertical-align:top;padding:0;margin:0;border:0;\"\u003e\n\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 1\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 2\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 3\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 4\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 5\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 6\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 7\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 8\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e 9\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e10\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e11\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e12\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e13\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e14\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e15\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e16\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e17\n\u003c/span\u003e\u003cspan style=\"white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f\"\u003e18\n\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/td\u003e\n\u003ctd style=\"vertical-align:top;padding:0;margin:0;border:0;;width:100%\"\u003e\n\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-go\" data-lang=\"go\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eloadPlugin\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003efilename\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e) (\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e(\u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e, \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e) []\u003cspan style=\"color:#a6e22e\"\u003emr\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eKeyValue\u003c/span\u003e, \u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e(\u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e, []\u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e) \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\u003cspan style=\"color:#a6e22e\"\u003ep\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eplugin\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eOpen\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003efilename\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e!=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enil\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\u003cspan style=\"color:#a6e22e\"\u003elog\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eFatalf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;cannot load plugin %v\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003efilename\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\u003cspan style=\"color:#a6e22e\"\u003exmapf\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ep\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eLookup\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Map\u0026#34;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e!=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enil\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\u003cspan style=\"color:#a6e22e\"\u003elog\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eFatalf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;cannot find Map in %v\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003efilename\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\u003cspan style=\"color:#a6e22e\"\u003emapf\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003exmapf\u003c/span\u003e.(\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e(\u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e, \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e) []\u003cspan style=\"color:#a6e22e\"\u003emr\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eKeyValue\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\u003cspan style=\"color:#a6e22e\"\u003exreducef\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ep\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eLookup\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Reduce\u0026#34;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e!=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enil\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\t\u003cspan style=\"color:#a6e22e\"\u003elog\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eFatalf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;cannot find Reduce in %v\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003efilename\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\u003cspan style=\"color:#a6e22e\"\u003ereducef\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003exreducef\u003c/span\u003e.(\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e(\u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e, []\u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e) \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\t\u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emapf\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ereducef\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/table\u003e\n\u003c/div\u003e\n\u003c/div\u003e\u003cp\u003e这个函数传入了一个.so常见文件的路径，返回了两个函数\u003c/p\u003e","title":"6.824Lab1"},{"content":"一、Redis三大件 1.1 缓存穿透 在用户访问数据的时候，这个数据既不在缓存也不再数据库，就会导致这个请求哪里都没打中，直接穿透了这两层数据库，当有大量这样的数据到达的时候就会导致数据库压力增大。\n布隆过滤器：利用一个位数组和多个哈希函数，快速判断一个元素是绝对不存在还是可能存在于数据库中。 接口层参数校验：最基本的防线，在网关或API入口处，对请求参数进行合法性检查，例如检查ID的格式、范围、类型是否符合预期。 黑名单：对于已知的恶意用户ID、IP或异常key，可以将其维护在一个Redis Set或独立的黑名单列表中。 缓存空值：当数据库查询结果为空时，程序不直接返回，而是向Redis中写入一个特殊标记的值（如空字符串\u0026quot;\u0026quot;或\u0026quot;NULL\u0026quot;），并为其设置一个较短的过期时间（TTL），例如30秒到5分钟。 互斥锁：当缓存未命中时，不是所有请求都一窝蜂去查数据库，而是通过分布式锁（基于Redis SETNX实现）来控制，只允许一个请求去数据库查询并回填缓存，其他请求则短暂等待后重试读取缓存。必须设置锁超时：防止拿到锁的请求崩溃，导致锁永不释放，形成死锁。等待策略：未抢到锁的请求重试次数或等待时间需要设上限，避免线程长时间阻塞。 限流与熔断降级：在系统层面配置限流规则（如基于 QPS），当针对某些key的请求速率超过阈值时，直接快速失败或返回降级内容，避免数据库承受超出其承载能力的流量。对于确认无法处理的异常来源，可以触发熔断，暂时切断对特定接口或IP的访问。 1.2 缓存击穿 在用户访问数据的时候，这个数据的hot key过期了，就会有大量请求大道数据库，击穿了redis这一层缓存，导致数据库压力增大\n互斥锁：同上，只允许一个请求去数据库重建缓存，其他请求等待片刻后重新读取缓存 逻辑过期：就是让hot key在物理上永不过期，用户可能会短暂读到旧数据，因此该方案要求业务场景能容忍最终一致性，例如商品详情页、首页 Banner、排行榜等。 大数据预热：对于可预知的热点（如大促商品），通过定时任务在缓存逻辑过期或物理过期前主动刷新，使其“永不失效”。这通常与逻辑过期策略配合使用，形成“异步刷新 + 定时保底”的双重保障。 熔断降级和互斥锁结合：当系统检测到数据库压力过大（如响应超时）时，触发熔断，直接返回兜底数据（如默认值或旧数据），而不是让请求继续冲击数据库。在互斥锁方案中，也可以优化为：未抢到锁的请求不等待，直接返回降级数据，以此将“保护数据库”作为最高优先级。 二级缓存：在 Redis 之前再加一层本地缓存（如 Caffeine、Guava Cache）。热点数据在本地缓存中命中，几乎不产生网络开销，能极大地分担 Redis 和数据库的压力，作为整体的性能与稳定性兜底。 1.3 缓存雪崩 大量缓存数据同时过期或者Redis故障宕机的时候，大量的数据直接打到了数据库里，导致数据库压力增加。\n过期时间加上随机值：在基础 TTL 上叠加一个随机偏移量，例如 TTL = 30分钟 + random(0~5分钟)，让 Key 的失效时间分散开，避免同一时刻批量过期。这是最常用的基础方案。 分批过期 / 错峰预热：对于可预知会批量写入的缓存，不要一次性写入相同 TTL，而是按业务维度分批设置不同过期时间；系统启动或大促前预热时也分批加载，避免启动瞬间形成流量尖峰 热点数据永不过期 + 后台更新：对核心热点数据不设置物理 TTL，而是采用“逻辑过期 + 异步刷新”或定时任务主动刷新，从源头消除集中失效。这通常与击穿方案中的逻辑过期策略配合使用。 Redis高可用架构：使用哨兵模式实现主从自动故障转移，或使用 Redis Cluster 实现分片与高可用。避免单实例故障导致整个缓存层不可用。 多级缓存：在 Redis 之前增加本地缓存（如 Caffeine、Guava Cache），形成“本地缓存 → Redis → 数据库”的多级结构。即使 Redis 整体故障，本地缓存仍能承接一部分热点流量，为恢复争取时间。本地缓存适合存放极热、变化不频繁的数据。 持久化与快速恢复：合理配置 RDB/AOF，确保 Redis 重启后能较快恢复数据，缩短缓存层不可用窗口。同时要有容量规划和故障演练，避免恢复过程中再次被流量打垮。 熔断+限流+互斥锁+数据库层保护 二、Redis快照与恢复机制 2.1 AOF日志 Redis在执行完一条写命令后，就会把该命令追加写入一个文件中，在Redis重启时，会读取这些指令，然后逐一执行来进行数据恢复。\n过程：\n执行写命令后，把命令追加到自己的aof_buf缓冲区 调用write()把aof_buf的数据拷贝到内核的page cache中 内核在某个时机把page cache刷盘，持久化落库 三种刷盘策略：\nalways：每次写操作命令后同步将AOF日志落盘 Everysec：每次写操作命令后，现将命令写入AOF文件的内核缓冲区，然后每隔一秒落盘 No：同上，但是落盘时机由操作系统决定 2.2 AOF重写 AOF本质是一个日志文件，随着执行的写操作命令越来越多，文件的大小会越来越大。所以，Redis 为了避免 AOF 文件越写越大，提供了 AOF 重写机制，当 AOF 文件的大小超过所设定的阈值后，Redis 就会启用 AOF 重写机制，来压缩 AOF 文件。\nAOF 重写机制是：在重写时，读取当前 Redis 中的所有键值对，然后将每一个键值对用一条命令记录到「新的 AOF 文件」，等到全部记录完后，就将新的 AOF 文件替换掉现有的 AOF 文件。\nRedis 的重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的，这么做可以达到两个好处\n子进程进行 AOF 重写期间，主进程可以继续处理命令请求，从而避免阻塞主进程； 子进程带有主进程的数据副本，这里使用子进程而不是线程，因为如果是使用线程，多线程之间会共享内存，那么在修改共享内存数据的时候，需要通过加锁来保证数据的安全，而这样就会降低性能。而使用子进程，创建子进程时，父子进程是共享内存数据的，不过这个共享的内存只能以只读的方式，而当父子进程任意一方修改了该共享内存，就会发生「写时复制」，于是父子进程就有了独立的数据副本，就不用加锁来保证数据安全。 2.3 RDB快照 本质是一个全量快快照，是某一瞬间的内存数据，一般有两种方式来保存，save和bgsave，前者直接在主线程创建RDB文件，所以可能会导致阻塞问题，后者是调用子进程，所以不会有阻塞问题，生产下基本不会用save，除非是迁移等特殊场景\n而bgsave在执行过程中，如果有内存被更新了，当主进程需要修改一个共享页中的数据时，会触发一个保护机制。操作系统会为这个即将被修改的物理页创建一个副本，并将主进程的写操作定向到这个副本上。此后，主进程读写这个副本，而子进程依然读取原始的、未被修改的共享页，从而保证了子进程看到的是 fork 瞬间那个稳定、一致的数据快照。当子进程结束后，子进程读取的那一块内存释放，复制的那一份转正。\n2.4 AOF与RDB性能对比 写入性能上，RDB 优于 AOF。RDB 在两次快照之间不消耗 IO，AOF 则要持续写盘，尤其是 always 模式下每条命令都 fsync，在高并发写场景下会成为瓶颈；everysec 是性能和安全的折中。 **恢复速度上，RDB 明显更快。**它直接把二进制数据加载回内存即可，而 AOF 要逐条重放命令，数据量大时启动会很慢。 数据安全性上，AOF 胜出。RDB 在两次快照之间宕机会丢数据，可能是几分钟；AOF 在 everysec 下最多丢 1 秒，always 下几乎不丢。 文件体积上，RDB 小很多，因为它是压缩过的二进制；AOF 是命令文本累积，即便有重写机制，体积通常还是更大。 2.5 混合持久化 redis4.0后可以使用混合持久化来做快照与恢复，在 AOF 重写时，由子进程把当前全量数据以 RDB 二进制格式写入新 AOF 文件头部，主进程同时将重写期间的增量写命令缓冲下来，待子进程完成后以 AOF 文本格式追加到文件尾部，最后原子替换旧 AOF；恢复时先批量加载 RDB 头部，再回放少量 AOF 尾部命令。其特性是必须开启 AOF，并由 aof-use-rdb-preamble 控制（4.0 默认关闭，5.0 起默认开启），文件呈“RDB 头 + AOF 尾”结构。优点是启动恢复快、数据安全性接近纯 AOF（最多约丢 1 秒）、文件体积更小；缺点是 AOF 头部不再可读、难以手动查看或修改，且混合格式 AOF 不被 4.0 之前版本识别，版本回退需注意兼容性。\n三、Redis删除策略 Redis主要采用惰性删除加上定时删除策略\n惰性删除就是不主动删除key，在用到的时候看是否过期了，过期了就删\n定期删除的流程如下：\n随机抽样：从过期字典中随机抽取 20 个键。 检查与删除：删除其中所有已过期的键。 判断是否继续：如果这 20 个键中，过期键的比例超过 25%，说明系统中可能积压了大量过期数据，Redis 会立即再次抽取 20 个键，重复步骤 1-2。 终止条件：当抽样中过期键的比例低于 25%，或本轮定期删除的**累计执行时间达到上限（默认 25 毫秒）**时，循环终止，等待下一轮定时任务触发。 3.1 Redis持久化中过期的key如何处理 对于RDB来说：\n生成RDB文件的时候：Redis会检查每一个键，已经过期的键不会被写入RDB文件。因此，RDB文件本身是“干净”的，不包含过期数据。\n载入RDB文件的时候：\n主服务器：会过滤掉RDB文件中所有已过期的键，只载入未过期的数据。 从服务器：会载入RDB文件中的所有键，无论是否过期。这是因为从服务器的过期键删除权完全由主服务器控制，载入后等待主服务器的同步指令即可，无需自己判断 对于AOF来说：\n日常写入：当一个键逻辑上已过期，但尚未被惰性删除或定期删除机制物理清除时，AOF文件不会对此产生任何影响，它依然可能存在于AOF记录中。只有当该键实际被删除（通过惰性删除或定期删除）时，Redis才会向AOF文件追加一条DEL命令，显式地记录这次删除操作。 AOF重写：在执行BGREWRITEAOF进行AOF重写时，Redis会过滤掉所有已过期的键，确保重写后的新AOF文件不包含过期数据，从而精简文件体积。 四、缓存与数据库一致性 4.1 基础方案：旁路缓存模式（Cache Aside Pattern） 最主流最通用的方案，由业务代码直接控制缓存与数据库的操作，核心就是：读时回填，写时删除。也就是读的时候先查缓存，命中则返回；未命中则查数据库，将结果写回缓存后再返回。写的时候先更新数据库，成功后再删除缓存（而不是更新缓存）。后续的读请求未命中缓存时，会自然从数据库加载最新值并重建缓存。\n直接删除而不是更新的原因是：因为更新缓存存在并发写乱序的风险\n优点是它产生不一致的窗口期极短——仅在“数据库更新提交”到“缓存删除完成”这一瞬间，并且还需要恰好有并发读请求将旧值回填，才会产生实际的脏数据。\n4.2 针对残留漏洞的强化方案 延迟双删：主要是针对并发回填的问题，一个读请求在缓存失效后，从数据库读到了旧值，但在它准备将旧值写回缓存前被阻塞；此时一个写请求完成了“更新数据库+删除缓存”；随后，那个被阻塞的读请求苏醒，将过期的旧值写入了缓存，导致缓存中留下脏数据。 ","permalink":"https://bg5dux.top/posts/redis/","summary":"\u003ch1 id=\"一redis三大件\"\u003e一、Redis三大件\u003c/h1\u003e\n\u003ch2 id=\"11-缓存穿透\"\u003e1.1 缓存穿透\u003c/h2\u003e\n\u003cp\u003e在用户访问数据的时候，这个数据\u003cstrong\u003e既不在缓存也不再数据库\u003c/strong\u003e，就会导致这个请求哪里都没打中，直接穿透了这两层数据库，当有大量这样的数据到达的时候就会导致数据库压力增大。\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e布隆过滤器\u003c/strong\u003e：利用一个位数组和多个哈希函数，快速判断一个元素是绝对不存在还是可能存在于数据库中。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e接口层参数校验\u003c/strong\u003e：最基本的防线，在网关或API入口处，对请求参数进行合法性检查，例如检查ID的格式、范围、类型是否符合预期。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e黑名单\u003c/strong\u003e：对于已知的恶意用户ID、IP或异常key，可以将其维护在一个Redis Set或独立的黑名单列表中。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e缓存空值\u003c/strong\u003e：当数据库查询结果为空时，程序不直接返回，而是向Redis中写入一个特殊标记的值（如空字符串\u003ccode\u003e\u0026quot;\u0026quot;\u003c/code\u003e或\u003ccode\u003e\u0026quot;NULL\u0026quot;\u003c/code\u003e），并为其设置一个\u003cstrong\u003e较短的过期时间（TTL）\u003c/strong\u003e，例如30秒到5分钟。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e互斥锁\u003c/strong\u003e：当缓存未命中时，不是所有请求都一窝蜂去查数据库，而是通过分布式锁（基于Redis \u003ccode\u003eSETNX\u003c/code\u003e实现）来控制，\u003cstrong\u003e只允许一个请求\u003c/strong\u003e去数据库查询并回填缓存，其他请求则短暂等待后重试读取缓存。\u003cstrong\u003e必须设置锁超时\u003c/strong\u003e：防止拿到锁的请求崩溃，导致锁永不释放，形成死锁。\u003cstrong\u003e等待策略\u003c/strong\u003e：未抢到锁的请求重试次数或等待时间需要设上限，避免线程长时间阻塞。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e限流与熔断降级\u003c/strong\u003e：在系统层面配置限流规则（如基于 QPS），当针对某些key的请求速率超过阈值时，直接快速失败或返回降级内容，避免数据库承受超出其承载能力的流量。对于确认无法处理的异常来源，可以触发熔断，暂时切断对特定接口或IP的访问。\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"12-缓存击穿\"\u003e1.2 缓存击穿\u003c/h2\u003e\n\u003cp\u003e在用户访问数据的时候，这个数据的hot key过期了，就会有大量请求大道数据库，击穿了redis这一层缓存，导致数据库压力增大\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e互斥锁\u003c/strong\u003e：同上，只允许一个请求去数据库重建缓存，其他请求等待片刻后重新读取缓存\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e逻辑过期\u003c/strong\u003e：就是让hot key在物理上永不过期，用户\u003cstrong\u003e可能会短暂读到旧数据\u003c/strong\u003e，因此该方案要求业务场景\u003cstrong\u003e能容忍最终一致性\u003c/strong\u003e，例如商品详情页、首页 Banner、排行榜等。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e大数据预热\u003c/strong\u003e：对于可预知的热点（如大促商品），通过\u003cstrong\u003e定时任务在缓存逻辑过期或物理过期前主动刷新\u003c/strong\u003e，使其“永不失效”。这通常与逻辑过期策略配合使用，形成“异步刷新 + 定时保底”的双重保障。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e熔断降级和互斥锁结合\u003c/strong\u003e：当系统检测到数据库压力过大（如响应超时）时，触发熔断，\u003cstrong\u003e直接返回兜底数据（如默认值或旧数据）\u003c/strong\u003e，而不是让请求继续冲击数据库。在互斥锁方案中，也可以优化为：\u003cstrong\u003e未抢到锁的请求不等待，直接返回降级数据\u003c/strong\u003e，以此将“保护数据库”作为最高优先级。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e二级缓存\u003c/strong\u003e：在 Redis 之前再加一层本地缓存（如 Caffeine、Guava Cache）。热点数据在本地缓存中命中，几乎不产生网络开销，能极大地分担 Redis 和数据库的压力，作为整体的性能与稳定性兜底。\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"13-缓存雪崩\"\u003e1.3 缓存雪崩\u003c/h2\u003e\n\u003cp\u003e大量缓存数据同时过期或者Redis故障宕机的时候，大量的数据直接打到了数据库里，导致数据库压力增加。\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e过期时间加上随机值\u003c/strong\u003e：在基础 TTL 上叠加一个随机偏移量，例如 \u003ccode\u003eTTL = 30分钟 + random(0~5分钟)\u003c/code\u003e，让 Key 的失效时间分散开，避免同一时刻批量过期。这是最常用的基础方案。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e分批过期 / 错峰预热\u003c/strong\u003e：对于可预知会批量写入的缓存，不要一次性写入相同 TTL，而是按业务维度分批设置不同过期时间；系统启动或大促前预热时也分批加载，避免启动瞬间形成流量尖峰\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e热点数据永不过期 + 后台更新\u003c/strong\u003e：对核心热点数据不设置物理 TTL，而是采用“逻辑过期 + 异步刷新”或定时任务主动刷新，从源头消除集中失效。这通常与击穿方案中的逻辑过期策略配合使用。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eRedis高可用架构\u003c/strong\u003e：使用\u003cstrong\u003e哨兵模式\u003c/strong\u003e实现主从自动故障转移，或使用 \u003cstrong\u003eRedis Cluster\u003c/strong\u003e 实现分片与高可用。避免单实例故障导致整个缓存层不可用。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e多级缓存\u003c/strong\u003e：在 Redis 之前增加本地缓存（如 Caffeine、Guava Cache），形成“本地缓存 → Redis → 数据库”的多级结构。即使 Redis 整体故障，本地缓存仍能承接一部分热点流量，为恢复争取时间。本地缓存适合存放极热、变化不频繁的数据。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e持久化与快速恢复\u003c/strong\u003e：合理配置 RDB/AOF，确保 Redis 重启后能较快恢复数据，缩短缓存层不可用窗口。同时要有容量规划和故障演练，避免恢复过程中再次被流量打垮。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e熔断+限流+互斥锁+数据库层保护\u003c/strong\u003e\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch1 id=\"二redis快照与恢复机制\"\u003e二、Redis快照与恢复机制\u003c/h1\u003e\n\u003ch2 id=\"21-aof日志\"\u003e2.1 AOF日志\u003c/h2\u003e\n\u003cp\u003eRedis在执行完一条\u003cstrong\u003e写命令\u003c/strong\u003e后，就会把该命令\u003cstrong\u003e追加写入\u003c/strong\u003e一个文件中，在Redis重启时，会读取这些指令，然后逐一执行来进行数据恢复。\u003c/p\u003e","title":"Redis"},{"content":"一、整体框架图 1.1、Agent Loop：外层与内层的双循环 PI 的核心是 runLoop() 里的双层 while(true)：内层负责单个回合（turn）内的工具调用循环，外层负责回合之间的准备与 followUp 接续。\nflowchart TB start([用户提交消息]) subgraph OUTER[\u0026#34;外层循环 runLoop · while(true)\u0026#34;] direction TB prep[\u0026#34;① 回合准备 prepareNextTurn\u0026lt;br/\u0026gt;上下文压缩 / 模型切换 / 收集 followUp\u0026#34;] followup{\u0026#34;⑥ 取 followUp 队列\u0026lt;br/\u0026gt;getFollowUpMessages\u0026#34;} subgraph INNER[\u0026#34;内层循环 turn 循环 · while 还有工具调用 或 有插话\u0026#34;] direction TB s1[\u0026#34;② 注入 steering 插话\u0026lt;br/\u0026gt;\u0026#43; 组装上下文（context 事件）\u0026#34;] s2[\u0026#34;③ 流式采样\u0026lt;br/\u0026gt;streamAssistantResponse\u0026#34;] s3{\u0026#34;④ stopReason 判定\u0026lt;br/\u0026gt;有无工具调用?\u0026#34;} s4[\u0026#34;⑤a 校验 prepareToolCall\u0026lt;br/\u0026gt;beforeToolCall 钩子可拦截 / 改参\u0026#34;] s5[\u0026#34;⑤b 执行工具 executeToolCalls\u0026lt;br/\u0026gt;串行或并发\u0026#34;] s6[\u0026#34;⑤c 结果回填上下文\u0026lt;br/\u0026gt;afterToolCall → turn_end\u0026#34;] s1 --\u0026gt; s2 --\u0026gt; s3 s3 --\u0026gt;|toolUse 有工具| s4 --\u0026gt; s5 --\u0026gt; s6 --\u0026gt; s1 end prep --\u0026gt; s1 s3 --\u0026gt;|\u0026#34;stop / length 无工具\u0026#34;| followup followup --\u0026gt;|有 followUp| prep end start --\u0026gt; prep followup --\u0026gt;|无| fin([agent_end\u0026lt;br/\u0026gt;本次运行结束]) s3 --\u0026gt;|error / aborted| abort([硬停\u0026lt;br/\u0026gt;跳过工具执行\u0026lt;br/\u0026gt;排队消息退回编辑器]) style INNER stroke:#4a90d9,stroke-width:2px,stroke-dasharray:6 4,fill:#f0f6fc style OUTER stroke:#3aa76d,stroke-width:2.5px,fill:#f7fbf8 两个循环的分工：\n内层（小循环）：一个回合的完整生命周期。流式采样拿到模型响应后，若 stopReason 是 toolUse 就进入\u0026quot;校验 → 执行 → 回填\u0026quot;流程，回填完成后回到采样；用户中途插话（steering）也会让内层续命——它在每个 turn 开始前被注入 外层（大循环）：内层自然结束（模型不再要工具）后，先做回合准备（上下文压缩、换模型），再取 followUp 队列；有 followUp 就开启新回合，没有则整个运行结束 插话 vs followUp：回车发送的是 steering（插队，当前工具执行完立即生效）；Alt+Enter 发送的是 followUp（等本轮工作全部做完后作为新回合的开始） 为什么这么设计？\n双层循环主要是做一个语义分割，插话便于客户在流式输出中“转向”，排活则便于自动继续下一个任务\n这是一种节省成本同时兼顾正确性与交互性的设计，用户如果发现agent出现问题，手动输入后在这一轮工具结束以后就会转变思考方向，如果是单循环的话就会变成停止任务，从头再来，哪怕有历史记录也会有重新生成的部分。\n二、回合准备 主要做四个事情\n2.1上下文压缩 通过一系列方式进行上下文估算，防止上下文溢出导致模型报错\n阈值触发（threshold）：回合结束后检查不等式：当前上下文 token 数 \u0026gt; 模型窗口大小 − 16384，也就是说在快满的时候就压缩，会有16384的余量 溢出恢复（overflow）：如果非常不幸，运行过程中上下文溢出了，比如读了一个超大的工具集，那么把失败那条消息从上下文里摘掉（历史还在，只是不再喂给模型）→ 立即压缩 → 自动重试一次。第二次还失败就不再折腾，提示你换更大窗口的模型。还有个细节：从小窗口模型切到大窗口后，旧模型的溢出错误不会触发压缩 手动触发（manual）：/compact 2.2 模型切换 聊到一半的时候使用/model等指令切换模型，不会马上切换，记录在回合准备这里，下一次走到这一步的时候进行切换\n2.3 思考等级置换 同上\n2.4 收集插话 中途发送消息的话不会打断，而是先排队，回合结束的时候检查队列，如果有插话的话就会放入peddingMessages（待注入消息队列），下回合开始的时候模型就能看到了 这里要注意，插话为steering，属于内层循环，而另一个概念followUp属于外层循环，后面会详细说明\n三、流式采样 3.1 流式采样状态机（partial即消息） flowchart TD A[\u0026#34;streamFunction()\u0026lt;br/\u0026gt;供应商差异止步于此\u0026#34;] --\u0026gt; B[\u0026#34;start\u0026lt;br/\u0026gt;partial push 进历史\u0026#34;] B --\u0026gt; C[\u0026#34;text_start / delta / end\u0026#34;] B --\u0026gt; D[\u0026#34;thinking_start / delta / end\u0026#34;] B --\u0026gt; E[\u0026#34;toolcall_start / delta / end\u0026lt;br/\u0026gt;参数 JSON 流式生长\u0026#34;] C --\u0026gt; F[\u0026#34;done / error\u0026lt;br/\u0026gt;定稿替换 partial，同一条出口\u0026#34;] D --\u0026gt; F E --\u0026gt; F F --\u0026gt; G{\u0026#34;stopReason?\u0026#34;} G --\u0026gt;|normal| H[\u0026#34;进工具阶段\u0026#34;] G --\u0026gt;|length| I[\u0026#34;半截工具调用全判废\u0026lt;br/\u0026gt;模型重新发起\u0026#34;] G --\u0026gt;|aborted| J[\u0026#34;也是正常 turn\u0026lt;br/\u0026gt;finishTurn 照常收尾\u0026#34;] I -.-\u0026gt;|重新采样| B style A fill:#EEEDFE,stroke:#534AB7 style B fill:#CECBF6,stroke:#534AB7 style F fill:#E6F1FB,stroke:#185FA5 style G fill:#E6F1FB,stroke:#185FA5 一次LLM采样的产物是一条消息，但是在物理上这个消息是在几秒或者几分钟内逐token到达的，所以会出现几个矛盾点：\n3.2 历史是“消息的列表”，可是这条消息在到达过程中并不存在，存入历史的时机是什么？ 直觉上我们考虑全部到完再放，但是这会存在一个问题，采样中途ESC那条消息就消失了，不留痕迹了，如果插话（steering）想要知道模型看到哪里了就断档，压缩和调试都是看到的残缺边界，所以PI考虑使用一边到一边放，放入历史的不是一个已完成消息，而是正在生成的消息的活表示：partial。也就是说历史列表的语义从“已完成消息的集合”悄悄变成了“包含生成中消息的世界状态”，所以如果中断了也不会消失，只是变成了aborted标记的字段\n3.3 partial从何而来，如何组织成整体？ delta是增量碎片，如何组织成一个整体呢，如果用循环凭借：buffer+=delta，那么每一个消费者（UI、steering、调试器）都需要拼接一次，逻辑扩散到各处，拼出来的东西不一致，如果用ai拼接的话，就只能在距离供应商最近的位置拼接\n所以PI这里考虑的是事件词汇表：12 种事件里，9 种带 partial 字段——每种 delta 事件都自带“到目前为止的完整消息” 这里注意，不同于cc的模式，cc是先打标记最后拼接，做了一个透传，如何凭借看调用方的想法，\n而PI是发生在事件的生产端，供应商每吐一个增量，协议适配层就会积到partial上，然后才发出这个事件，所以agent循环拿到的时候已经是一个完整消息了，只是未达到历史消息的标准，此时称之为活对象。\n3.4 活对象有多个观察者，那么每个人应该看到什么 活对象在出口的时候做一个浅拷贝：观察者拿到的副本是发出那一刻的真相，历史持有的是持续生长的本体。一个对象，两种视角，各取所需。\n为什么这么设计？\n流式采样可以和之前的插话联动，流式采样可以让用户感知到目前的agent思考进度，观察思考大致方向是否有问题，这是一种强而有力的交互方式，而partial这是一种防止上下文边界缺失的措施，如果没有partial，上下文中未完成的内容不会存在历史里，下一次启动任务的时候可能丢失关键信息\n四、并发调用 4.1 并发调用流程图 flowchart TD A[\u0026#34;一条回复带 N 个工具调用\u0026#34;] --\u0026gt; B{\u0026#34;有工具声明 sequential？\u0026lt;br/\u0026gt;或宿主全局配了 sequential？\u0026#34;} B --\u0026gt;|是| C[\u0026#34;整批串行：for 循环逐个跑\u0026#34;] B --\u0026gt;|否| D[\u0026#34;阶段② 串行准备（按调用序）\u0026lt;br/\u0026gt;TypeBox 校验 → beforeToolCall 钩子\u0026#34;] D --\u0026gt;|拒绝 / 找不到工具| E[\u0026#34;immediate：错误结果当场定型\u0026#34;] D --\u0026gt;|放行| F[\u0026#34;阶段③ 打包成闭包\u0026lt;br/\u0026gt;循环不 await，继续准备下一个\u0026#34;] F --\u0026gt; G[\u0026#34;阶段④ Promise.all 同时点火\u0026lt;br/\u0026gt;真并发只在这一行\u0026#34;] G --\u0026gt; H[\u0026#34;阶段⑤ 保序回填\u0026lt;br/\u0026gt;结果严格按调用序进历史\u0026#34;] H --\u0026gt; I{\u0026#34;terminate 全票通过？\u0026#34;} I --\u0026gt;|每一个都是 true| J[\u0026#34;循环终止\u0026#34;] I --\u0026gt;|有否决| K[\u0026#34;continue 内层 while\u0026#34;] F -.-\u0026gt;|signal.aborted| L[\u0026#34;Operation aborted\u0026lt;br/\u0026gt;不执行，不留半截副作用\u0026#34;] style G fill:#EAF3DE,stroke:#3B6D11 style D fill:#CECBF6,stroke:#534AB7 style F fill:#CECBF6,stroke:#534AB7 4.2 N个调用之间是什么关系 首先要解决的问题就是：调用之间，谁是独立的，谁必须排队\n通用层：默认人人独立，读写不同文件的调用时互相不阻塞的，全部并发，因为95%的时间在等I/O，所以会考虑单线程的并发模型，类似epoll的就绪通知 + 事件驱动，等待不需要算力。\n例外层：具有一票否决的特性，任何一个工具声明了executionMode:\u0026quot;sequential\u0026quot;这一整批调用全部退化为串行，因为乱序执行结果不稳定\n4.3 五个阶段 分流：宿主全局配置是否 sequential、本批是否有工具声明 sequential 串行准备：TypeBox校验参数，beforeToolCall 钩子作准备 打包闭包：每个调用都打包成async塞进数组，循环在这时候不会await它，准备循环继续跑下一个调用的校验和钩子，而已放行的调用还没有开始执行，只是“蓄势”。每个闭包开头有一道 signal.aborted 检查——中断的调用直接返回「Operation aborted」错误结果，不真跑。 并发点火：准备循环全部跑完，所有闭包同时开跑 保序回填：结果数组严格按输入顺序排列——谁先完成无所谓。循环按这个顺序创建 toolResultMessage、广播、push 进历史。然后 shouldTerminateToolBatch 做“全票检查”：这批结果每一个都 terminate: true 循环才终止——一票即否决，防止单个工具的单方面退出吞掉兄弟调用的结果。 注意**“看到的顺序”和“回填的顺序”是两回事。** 执行过程中的事件（tool_execution_start、流式 partial 更新）按完成序实时广播；而结果消息按调用序回填。广播是给 UI 看的（越快越好），回填是给历史用的（toolCall/toolResult 配对必须确定）。\n为什么这么设计？\n因为agent并发任务多为I/O阻塞的，比如大模型API，外部工具，MCP等，这种情况下采用epoll的方式会好很多\n五、结果回填 5.1 回填解决了什么问题 并发结束的时候会收到N个执行纪录，而模型的协议中有一条铁律：下一条采样请求里，assistant 的每个 toolCall 块必须紧跟一个 toolResult 块。缺了错了，供应商直接 400。\n所以回填解决的问题就是如何吧这个执行记录配对完整并合理的落入历史上下文中\n5.2 四步机制 包装：利用配对键进行配对，同时也要做规范化，防止脏数据返回，比如JS扩张的Null等\n1 2 3 4 5 6 7 { role: \u0026#34;toolResult\u0026#34;, toolCallId: finalized.toolCall.id, // 配对键 toolName: finalized.toolCall.name, content: finalized.result.content ?? [], // 规范化：null 不许进历史 isError, durationMs?, timestamp: Date.now() } 广播：只有message_start + message_end，没有 update 阶段，这里可以和前面的流式采样那种start，delta，end做对比，这里没有中间态，消息需要完整性。\n双账本落笔：有两个账本，一个是世界状态（供下一轮采样），另一个是本次run的增量（供宿主/回放）\n打包交换，触发下一站：打包完成后立刻轮询steering队列，检查是否有插话，有插话就进入插话的逻辑调整agent大致方向\n为什么这么设计？\n这么设计我理解是为了保证顺序性和完整性，首先工具是并发的，所以需要利用配对键找到合适的位置，进行包装，也需要保证每个调用都有结果而不是返回很奇怪的东西影响结果\n六、工具系统详解 Pi框架的工具系统也考虑了如何轻量化，下面是他的简单流程图\nflowchart TD A[\u0026#34;ToolDefinition\u0026lt;br/\u0026gt;扩展面向：renderers \u0026#43; 上下文\u0026#34;] --\u0026gt;|wrapToolDefinition| B[\u0026#34;AgentTool\u0026lt;br/\u0026gt;运行时面向\u0026#34;] B --\u0026gt; C[\u0026#34;串行准备\u0026lt;br/\u0026gt;TypeBox 校验 \u0026#43; prepareArguments 兼容垫片\u0026#34;] C --\u0026gt; D[\u0026#34;execute 四参数\u0026lt;br/\u0026gt;toolCallId · params · signal · onUpdate\u0026#34;] D --\u0026gt; E{\u0026#34;工具内部\u0026#34;} E --\u0026gt;|edit / write| F[\u0026#34;withFileMutationQueue\u0026lt;br/\u0026gt;同文件串行 · 异文件并行\u0026#34;] E --\u0026gt;|bash / read| G[\u0026#34;直接并发\u0026#34;] F --\u0026gt; H[\u0026#34;AgentToolResult 三通道\u0026lt;br/\u0026gt;content→模型 · details→UI\u0026lt;br/\u0026gt;structuredContent→程序\u0026#34;] G --\u0026gt; H H --\u0026gt; I[\u0026#34;回填：toolCallId 配对\u0026lt;br/\u0026gt;isError 也是合法结果\u0026#34;] style B fill:#CECBF6,stroke:#534AB7 style H fill:#EAF3DE,stroke:#3B6D11 style F fill:#FAEEDA,stroke:#BA7517 6.1 一个工具=2个接口+一条包装边界 接口一：ToolDefinition —— 定义/扩展面向\n1 2 3 4 5 6 7 8 ToolDefinition = { name, label, description, parameters, // 基本身份 + schema 说明书 outputSchema, executionMode, prepareArguments, renderers: …, // ← UI 渲染器（决定结果在终端里怎么画） constrainedSampling: …, execute: (toolCallId, params, signal, onUpdate, ctx: ExtensionToolContext) // ← 最后多一个\u0026#34;环境\u0026#34;参数 } 是环境性质的，是写工具的人面对的接口\n接口二：AgentTool —— 内核运行时面向\n这个是内核认识的最小执行单元，只有执行必需的七八个字段，没有 renderers，execute 也没有 ctx 参数。内核对工具的全部认知到此为止。\n包装边界：wrapToolDefinition，两个接口之间的翻译层\n1 2 3 4 5 6 7 8 9 10 11 12 // 正向：Definition → AgentTool（扩展装进内核时） wrapToolDefinition(definition, ctxFactory) { return { ...字段原样拷贝…, execute: (toolCallId, params, signal, onUpdate) =\u0026gt; definition.execute(toolCallId, params, signal, onUpdate, ctxFactory(toolCallId, signal)) // ← 执行瞬间才注入环境 }; } // 反向：AgentTool → 最小 Definition（有人直接塞裸 AgentTool 时） synthesizeMinimalDefinition(agentTool) // 保证注册表永远 definition-first 正向包装的重点在于 ctxFactory(toolCallId, signal)，也就是说环境不是包装时注入的，是每次执行时现场创建的——同一次 run 里多次调用同一工具，每次拿到的 ctx 都是针对本次调用的（带正确的 abort signal）。反向合成则保证“有人塞了个裸 AgentTool 进来”注册表也不崩。\n6.2 三个通道结果：一个结果，三种受众 AgentToolResult的一次返回会做三路分发\n1 2 3 4 5 6 7 8 { content: …, // 通道① 给模型看的（进对话历史） details: …, // 通道② 给 UI 的（日志、渲染器用，模型看不见） structuredContent: …, // 通道③ 给程序的（符合 outputSchema 的机器可读数据，明确注释\u0026#34;不发给模型\u0026#34;） isError?: true, // 报错不用 throw——模型看到错误内容，UI 保留完整细节 terminate?: true, // 全票制终止的一票 usage?: … // 工具自身的用量（不计入主上下文账） } 这样子给模型看的和给人看的就会强制分离，三着就不会相互污染，大多数自研工具系统把这三样糊在一个字符串里，模型看到 UI 专属的转义垃圾，就是这个通道没分的后果。\n6.4 文件突变队列 解决了一个并发中一个重要的问题:两个并发的 edit/write 打同一个文件怎么办？\nPi的解决方案是每个文件一把锁（按真实路径识别，符号链接指向同一文件也算同一个）。edit 和 write 工具内部自动把自己的写操作挂进这个队列——同一个文件的操作排队一个个来，不同文件照常并发。框架内置，写工具自动获得，模型和扩展都不用操心。\n参考文献 https://pi-doc.com/ How Pi Works — Pi 官方文档 pi-mono Deep Dive 4: Agent Loop — Double-Loop \u0026amp; Event Flow ","permalink":"https://bg5dux.top/posts/pi%E6%A1%86%E6%9E%B6/","summary":"\u003ch1 id=\"一整体框架图\"\u003e一、整体框架图\u003c/h1\u003e\n\u003ch2 id=\"11agent-loop外层与内层的双循环\"\u003e1.1、Agent Loop：外层与内层的双循环\u003c/h2\u003e\n\u003cp\u003ePI 的核心是 \u003ccode\u003erunLoop()\u003c/code\u003e 里的双层 \u003ccode\u003ewhile(true)\u003c/code\u003e：\u003cstrong\u003e内层\u003c/strong\u003e负责单个回合（turn）内的工具调用循环，\u003cstrong\u003e外层\u003c/strong\u003e负责回合之间的准备与 followUp 接续。\u003c/p\u003e","title":"PI框架"}]