[{"content":"91kvstore 是一条持续演进的系统编程主线。它从统一接入 Reactor、Proactor 与协程开始，随后扩展数组、红黑树、Hash、SkipList 等存储引擎，再向 AOF、Snapshot、主从复制和更可靠的命令协议推进。\n语言：C 关键词：epoll、io_uring、协程、存储引擎、持久化、复制 源码：JasperStonnne/91kvstore 阅读路线 # 网络层：统一接入 Reactor、Proactor 与协程 协议层与数组存储 客户端测试、压力测试与多引擎扩展 Hash 接入、多语言客户端与 SkipList 扩展 KV 进化（一）：接入跳表 KV 进化（二）：Snapshot + AOF KV 进化（三）：内存管理与内存池 KV 进化（四）：Batch Commands KV 进化（五）：Primary / Replica 主从同步 KV 进化（六）：命令协议改造 ","date":"2026年8月22日","externalUrl":null,"permalink":"/MyBlog/projects/91kvstore/","section":"项目","summary":"","title":"91kvstore","type":"projects"},{"content":"这组文章不是把框架 API 逐项抄一遍，而是从一个已有 Go 项目倒推设计：先建立整体地图，再沿请求生命周期进入各层，最后回到异步架构、Feed 流和工程化。\n语言：Go 关键词：Gin、GORM、Redis、RabbitMQ、JWT、SSE 阅读路线 # 从 Docker 到开源贡献：先建立项目全景 请求生命周期 认证体系 数据库与 GORM Redis 缓存设计 消息队列与异步架构 Feed 流设计 SSE 实时推送与分片上传 工程化 ","date":"2026年7月21日","externalUrl":null,"permalink":"/MyBlog/projects/go-feed/","section":"项目","summary":"","title":"Go Feed 流系统","type":"projects"},{"content":"","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/categories/ai%E4%B8%8E%E5%B7%A5%E5%85%B7/","section":"Categories","summary":"","title":"AI与工具","type":"categories"},{"content":"","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":" BUILD · TRACE · WRITE\n从真实项目出发，把复杂系统拆开看清楚。 这里主要记录系统与网络编程、C/C++、Go，以及实现项目时踩过的坑。文章更接近可复查的工程笔记，而不是零散知识点。\nSELECTED PROJECTS\n项目主线 查看全部 → 01 91kvstore C 语言实现的多引擎 KV 存储：网络模型、持久化、复制与命令协议。\nC · epoll · io_uring · AOF 02 Go Feed 流系统 从请求生命周期反推认证、缓存、消息队列、Feed 设计和工程化。\nGo · Gin · Redis · RabbitMQ ","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/","section":"JasperSao的博客","summary":"","title":"JasperSao的博客","type":"page"},{"content":"","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/categories/%E8%AF%BB%E4%B9%A6%E4%B8%8E%E8%B5%84%E6%96%99/","section":"Categories","summary":"","title":"读书与资料","type":"categories"},{"content":"","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/posts/%E8%AF%BB%E4%B9%A6%E4%B8%8E%E8%B5%84%E6%96%99/","section":"Posts","summary":"","title":"读书与资料","type":"posts"},{"content":"","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/categories/%E8%AF%BB%E4%B9%A6%E6%9C%AD%E8%AE%B0/","section":"Categories","summary":"","title":"读书札记","type":"categories"},{"content":"","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/categories/%E4%B8%AA%E4%BA%BA%E9%9A%8F%E7%AC%94/","section":"Categories","summary":"","title":"个人随笔","type":"categories"},{"content":"","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/posts/%E9%9A%8F%E7%AC%94/","section":"Posts","summary":"","title":"个人随笔","type":"posts"},{"content":"平庸的生活方式是麻醉药。他只会束缚你，让你没有作为，甚至没有出息的度过大学的四年\n\u0026ndash;交大生存手册\n你好，我是 Jasper，山东大学 2029 届本科生。\n最近常常在和 Linux、C/C++、Go 打交道，目前主要关注系统与网络编程。\n正在向 CUHK CS 和 Quant Developer 的方向前进。这里记录我的学习、项目和思考，也见证自己一点点接近想去的地方。\nGitHub # 联系方式 # GitHub: JasperStonnne Email: shijingyuan061010@gmail.com Linux.do: jaspersao ","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/about/","section":"JasperSao的博客","summary":"","title":"关于","type":"page"},{"content":"","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/archives/","section":"JasperSao的博客","summary":"","title":"归档","type":"page"},{"content":"","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/categories/%E6%8A%80%E6%9C%AF%E7%AC%94%E8%AE%B0/","section":"Categories","summary":"","title":"技术笔记","type":"categories"},{"content":"新版站点的搜索已经放进页头。点击右上角的放大镜，或按下 /，即可搜索标题与正文。\n","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/search/","section":"JasperSao的博客","summary":"","title":"搜索","type":"page"},{"content":"这里不是简单的作品陈列。每个项目都连接着一组按实现顺序整理的文章，方便从架构全景一路读到具体问题。\n","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/projects/","section":"项目","summary":"","title":"项目","type":"projects"},{"content":"","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/categories/%E9%A1%B9%E7%9B%AE%E8%AE%B0%E5%BD%95/","section":"Categories","summary":"","title":"项目记录","type":"categories"},{"content":"","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/posts/%E9%A1%B9%E7%9B%AE%E8%AE%B0%E5%BD%95/","section":"Posts","summary":"","title":"项目记录","type":"posts"},{"content":"","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/categories/%E5%AD%A6%E4%B9%A0%E6%96%B9%E6%B3%95/","section":"Categories","summary":"","title":"学习方法","type":"categories"},{"content":"一些常看的站点、朋友的角落，以及能把人带回真实工程现场的入口。\n朋友与社区 # boweny\u0026rsquo;s Blog — 工程、嵌入式、Agent 与个人成长记录。 Linux.do — 技术、AI、开发环境与工程实践社区。 Linux.do / jaspersao — 我更日常的技术讨论和社区动态。 开发与本站 # GitHub / JasperStonnne — 代码、实验项目与博客源码。 Hugo — 本站使用的静态站点生成器。 Blowfish — 本站当前使用的 Hugo 主题。 Giscus — 基于 GitHub Discussions 的评论系统。 交换友链 # 如果你也有自己的博客，可以通过 GitHub 或社交链接联系我，并附上站点名称、链接、一句话介绍，以及可选的头像或图标链接。\n","date":"2026年10月9日","externalUrl":null,"permalink":"/MyBlog/links/","section":"JasperSao的博客","summary":"","title":"友链","type":"page"},{"content":"","date":"2026年10月6日","externalUrl":null,"permalink":"/MyBlog/tags/aof/","section":"Tags","summary":"","title":"AOF","type":"tags"},{"content":"","date":"2026年10月6日","externalUrl":null,"permalink":"/MyBlog/tags/c-%E8%AF%AD%E8%A8%80/","section":"Tags","summary":"","title":"C 语言","type":"tags"},{"content":"","date":"2026年10月6日","externalUrl":null,"permalink":"/MyBlog/tags/kv-%E5%AD%98%E5%82%A8/","section":"Tags","summary":"","title":"KV 存储","type":"tags"},{"content":"实现命令协议改造\n行文至此，已经接近燃尽了。\n上一篇完成 Primary / Replica 主从同步后，91kvstore 已经有了客户端命令、AOF、Snapshot、全量同步和在线增量复制。回头看，原来的 SET key value 不只是客户端输入格式，AOF、Snapshot 和复制增量也都依赖这种按空格拆字段、按行拆命令的表示。\n这一轮的需求是让 key 和 value 支持原本用来分隔字段的特殊字符，并且不再受 1024 字节固定网络缓冲区限制。表面上是“改协议”，实际上需要逐层找出对空格、换行和固定容量的假设。\n客户端输入 ↓ 协议编解码 ↓ 命令执行 ↓ 内存引擎 + AOF ↓ Snapshot / 主从复制 原协议的问题 # 旧命令是一行文本：\nSET name Jasper\\r\\n GET name\\r\\n 网络层寻找 \\r\\n，执行层调用 strtok(msg, \u0026quot; \u0026quot;)。只要 key 是 user name，或者 value 是 hello world，空格既可能是数据，又可能是分隔符，接收端无法恢复用户本来的意思。\n换行也一样。如果 value 本身包含 \\r\\n，逐行拆命令可能在 value 中途结束。即使改好了解析器，网络层固定的 BUFFER_LENGTH = 1024 仍会让较长命令在接收时耗尽空间。\n所以要解决的是三个边界：\n字段边界：不能靠空格 命令边界：不能靠数据中也可能出现的换行 缓冲区边界：不能固定在 1024 字节 先把旧链路画完整 # 改协议前，先确认一条命令是怎样走完生命周期的。否则只改网络入口，下一次重启就可能把数据还原错。\n客户端发送 \u0026#34;HSET user name hello world\\r\\n\u0026#34; ↓ 网络后端调用 recv / nty_recv / io_uring recv ↓ 输入缓冲区累计字节 ↓ kvs_line_batch_protocol 找到行尾 ↓ kvs_client_protocol ↓ kvs_execute_command 去掉行尾 ↓ kvs_split_token 用 strtok 按空格切分 ↓ kvs_filter_protocol 选择 HSET、HGET 等命令 ↓ 内存引擎写入；成功写命令追加到 AOF 旧 kvs_split_token 的关键部分大致是：\nchar *token = strtok(msg, \u0026#34; \u0026#34;); while (token != NULL) { tokens[idx++] = token; token = strtok(NULL, \u0026#34; \u0026#34;); } strtok 会改写 msg：它把找到的空格位置替换为 \\0，让 tokens[i] 指向同一块报文中的不同位置。它并不知道哪个空格来自用户的 key/value，哪个空格是协议分隔符。\n以 HSET user name hello world 为例，旧解析结果会趋向于：\ntokens[0] = HSET tokens[1] = user tokens[2] = name tokens[3] = hello tokens[4] = world 即使执行器只使用前三个 token，也已经把用户希望保存的 key/value 拆坏。更严重的是，命令执行层此时拿不到原始字段边界，无法靠后续逻辑“猜回来”。\n这个问题不只发生在客户端请求。原来的 AOF 使用 fprintf(aof_fp, \u0026quot;%s %s %s\\n\u0026quot;, ...)，Snapshot 的每条键值记录也写成 SET key value\\n。即使客户端入口暂时通过引号或转义规则把空格保住，落盘后再按空格拆分仍会丢失含义。\n因此需求不能只表述成“网络层支持特殊字符”。更完整的要求是：\n同一组命令字段，经过网络输入、执行、AOF、Snapshot、复制、恢复后， 字段数量、每个字段的字节长度和内容都不能变化。 为什么不继续给空格加转义 # 另一种思路是让客户端把空格写成 \\ ，把换行写成 \\n。这看起来比改整条协议轻，但会把复杂度转移到更多位置：\n反斜杠自己如何转义？ 转义后的长度怎么算？ AOF 保存转义前还是转义后？ Snapshot 和主从是否用完全相同的规则？ 收到半个转义序列时应等待还是报错？ 如果用户数据原本就是 \\n 两个普通字符，还要区分它与一个真正的换行字节。每增加一种特殊字符，编码与解码规则都会增长。\n长度格式的核心规则更稳定：先知道后续内容有多少字节，然后不解释内容中的分隔符。字段内的空格、CRLF、反斜杠都只是内容。协议层只检查长度之外的结构标记。\n一条命令与一个 TCP 包不是一回事 # 旧协议按行时已经碰到过 TCP 半包与粘包，新协议同样需要面对，只是判断完整性的方式改变。\n假设一条 SET 帧共 40 字节。第一次 recv 可能只得到 7 字节，第二次得到 33 字节；也可能一次得到 40 字节和下一条 GET 的前 12 字节。\n正确处理方法是：\n7 字节：解析器返回 0，缓冲区保留 7 字节 再到 33 字节：累计到 40，解析器返回 1，执行并消费 40 字节 若还有 GET 前半段：只保留 GET 的 12 字节，不提前执行 这里的“包”只是观察网络收发的方便说法。应用不能依赖一次 recv 的返回值代表一条命令，必须维护自己的输入缓冲区，并按协议决定何时消费。\n本轮范围 # 选择 RESP 风格的长度格式：命令先声明字段数量，每个字段再声明自己的字节长度。这样可以保留文本 key/value 中的空格和换行，并让编解码、持久化和复制使用同一套格式。\n“长度不受限制”在这里是指不再被原来的固定网络缓冲区截住，不是数学意义上的无限大。实际数据仍受可用内存、size_t、当前网络接口使用的 int 长度以及存储引擎限制。\n本轮没有把内嵌 \\0 的任意二进制数据、完整 RESP2 响应或 redis-cli 兼容放进完成条件。这些分别涉及 C 字符串引擎和更完整的客户端协议语义。\n这轮实际按什么顺序推进 # 如果一上来就同时修改三个网络后端、命令执行、AOF、Snapshot 和复制，编译错误会混在一起，很难知道是哪一层的契约断了。这轮实际推进可以拆成下面几段；每一段都先明确“输入是什么、输出是什么”，再继续往下接。\n阶段 主要修改 当时要确认的事情 1 protocol.h、codec.c 单独给定字节数组，能区分完整、未完成、非法帧；能算出编码大小 2 kvstore.c 的命令入口 长度字段能转换为旧执行器认识的 token，含空格的 SET/GET 能在内存中往返 3 输入缓冲区与三个网络后端 半帧留下、完整帧消费、下一帧继续处理，超过 1024 字节也能接收 4 输出缓冲区与 GET 已存在的长 value 能被完整编码和发送；部分发送不覆盖未发完的数据 5 AOF 写入、重放 含空格的命令落盘后重启，恢复出的字段不变 6 Snapshot 保存、加载 offset 头部保留，正文改用长度帧；全量恢复可读取长 value 7 复制增量接入 FULL_SYNC 仍传文件原始字节，CATCH_UP/ONLINE 识别长度格式 AOF 命令 8 单元与集成测试 编译、协议边界、重启恢复、全量与在线复制分别通过 这些阶段并不是八个彼此孤立的功能。比如阶段 3 的“输入可扩容”只保证长 SET 能进入服务；如果阶段 4 没跟上，长 GET 仍可能失败。阶段 5 的 AOF 格式改变又会立即影响阶段 7，因为 Primary 发给 Replica 的增量数据来自 AOF。前一步的输出，常常就是后一步的输入。\n可以把最低可用闭环写成：\n客户端编码 → 网络累计 → 解析字段 → 命令执行 → 响应编码 ├→ AOF 追加 → 重放 / 复制增量 └→ 内存引擎 → Snapshot 保存 → 加载 / 全量复制 所以“SET 在当前进程返回 OK”只验证了图的前半截，不能作为整个改造完成的证据。\n在独立分支开发 # 本轮使用 feat/length-prefixed-protocol 分支。初期提交 b406726 WIP：接入长度前缀协议及动态缓冲区 记录了基础接入；持久化、复制和测试收尾形成 ef42eb6 完善长度前缀协议的持久化与主从复制支持。两个提交已经推送到 GitHub 和 GitLab。\n工作区曾有独立的 third_party/NtyCo 子模块变化，没有纳入这次协议提交。\n新命令格式 # 以 key 为 user name、value 为 hello world 的 SET 为例，用可读的转义形式表示：\n*3\\r\\n $3\\r\\nSET\\r\\n $9\\r\\nuser name\\r\\n $11\\r\\nhello world\\r\\n *3 表示三个字段。$9 表示后面恰好读取九个字节，所以 user name 中间的空格属于 key；$11 后面的空格属于 value。这里的数字是字节长度，既不是“读到下个空格”，也不是 C 字符串终止符的位置。\n例如 value 为 a\\r\\nb 时，内容长度是四字节：a、\\r、\\n、b。解析器按已声明的四字节读取内容，内容中的 CRLF 不能提前结束字段。\nTCP 依然只是字节流。一次 recv 可以得到半条命令、一条命令，或者多条命令粘在一起。长度格式让应用有办法判断边界，不会改变 TCP 自身的行为。\n先用同一条命令做新旧对照 # 把 key 设为 user name、value 设为 hello world。旧格式若直接写成：\nSET user name hello world\\r\\n 执行器看见的是一串靠空格分隔的 token，无法知道 user name 原本是一个 key。即使临时修改客户端解析，只要 AOF 继续写 SET user name hello world\\n，重启时又会被拆坏。\n新格式在各个环节的职责不同：\n环节 接到什么 做了什么 交给下一层什么 客户端 三个独立字段 对每个字段写长度和原始内容 一条长度帧 网络后端 任意分段的 TCP 字节 累计到输入缓冲区 当前缓冲区地址和实际字节数 协议解析器 缓冲区中的字节 校验 *、$、十进制长度、CRLF 三个 slice 与 parsed_bytes 命令适配层 三个 slice 复制字段并补 \\0，仅在旧执行器边界转换 SET、user name、hello world 三个 token 引擎 key、value 两个 C 字符串 写入内存 执行结果 AOF 成功写命令的三个 token 重新按长度帧编码并追加 以后可无歧义重放的字节 Snapshot 引擎遍历得到的 key/value 写 offset 头部，再写长度帧正文 可作为全量复制的文件 Replica Snapshot 文件块或 AOF 增量 由复制状态决定安装文件还是解析命令 同一组 key/value 这里有一个容易忽略的细节：协议解析器没有直接执行 SET。它只回答“字节流里是否已经有一条完整命令”“字段分别在哪里”“应消费多少字节”。kvstore.c 再决定这条命令的业务含义。这样，AOF 重放和 Replica 收到增量命令时，可以复用同一个字节解析器，却以 RECOVERY 或 REPLICATION 来源执行。\n更直观地说，同一条数据在四个位置的形式并不完全相同：\n网络/AOF/Snapshot 正文：*3\\r\\n$3\\r\\nSET\\r\\n$9\\r\\nuser name\\r\\n$11\\r\\nhello world\\r\\n 解析器输出： (\u0026#34;SET\u0026#34;,3), (\u0026#34;user name\u0026#34;,9), (\u0026#34;hello world\u0026#34;,11) 旧执行器输入： \u0026#34;SET\\0\u0026#34;, \u0026#34;user name\\0\u0026#34;, \u0026#34;hello world\\0\u0026#34; 内存引擎内容： key=\u0026#34;user name\u0026#34;, value=\u0026#34;hello world\u0026#34; 同样是 \\r\\n，在字段声明之后、字段内容之外，它是协议结构；出现在字段内容内部时，它是用户数据。解析器依赖长度而不是靠肉眼或 strtok 区分两者。\n公共协议模块 # 新增 include/protocol.h 和 src/protocol/codec.c。协议模块不关心 NtyCo、epoll、io_uring 或具体存储引擎；它只关心输入字节、字段长度和帧边界。\ntypedef struct { const char *data; size_t length; } kvs_slice_t; kvs_slice_t 只引用原始输入中的一段字节，不分配、不释放，也不保证 data 后面有 \\0。因此在字段仍被使用时，输入缓冲区不能被移动或释放。网络层处理完命令、按已消费字节数整理缓冲区后，旧 slice 就不能继续保存使用。\n三个网络后端复用同一个协议入口：\nNtyCo / Reactor / Proactor ↓ 收到字节 输入缓冲区 ↓ kvs_parse_command() ↓ 字段数组 ↓ 命令执行 协议解析器只写一份；三个后端各自处理收发、等待、扩容和连接生命周期。\nparse_number、parse_field 和 parse_command # 解析过程按格式层次拆开：\nparse_number 读取十进制数字以及随后的 \\r\\n parse_field 读取 $长度\\r\\n内容\\r\\n kvs_parse_command 读取 *字段数量\\r\\n，再读取指定数量的字段 parse_number 和 parse_field 是 codec.c 内部的 static 函数，不需要在公共头文件暴露。读取数字时要检查 value * 10 + digit 是否会超过 SIZE_MAX；字段解析时先检查剩余字节，再计算内容与结尾位置，避免超大长度造成偏移溢出。\n最外层 kvs_parse_command 从输入开头尝试解析第一条命令：\n返回 1：第一条命令完整 返回 0：还缺字节，等待更多输入 返回 -1：格式错误 调用方准备 fields 数组并给出容量。成功后，field_count 是字段数量，parsed_bytes 是这条完整命令从输入开头占用的字节数。\n假如一次接收到 [完整 SET][半条 GET]，解析器只报告 SET 的字段和 parsed_bytes。网络层消费这段前缀，保留后面的半条 GET，等下一次接收。这里不能用 strlen(input)：TCP 收到的是“地址 + 当前字节数”，输入未必以 \\0 结尾。\n解析函数的参数逐个看 # 接口最重要的不是函数名，而是它和调用者之间的契约。可以把 kvs_parse_command 读成：\nint kvs_parse_command( const char *input, size_t input_length, kvs_slice_t *fields, size_t field_capacity, size_t *field_count, size_t *parsed_bytes ); 各参数的责任：\n参数 谁提供 含义 input 调用方 当前缓冲区中尚未处理的数据起点 input_length 调用方 当前实际收到的字节数，不是缓冲区容量 fields 调用方 用于接收解析结果的 slice 数组 field_capacity 调用方 fields 最多能放几个元素，防止越界写入 field_count 解析器输出 第一条完整命令实际有几个字段 parsed_bytes 解析器输出 第一条完整命令占用了多少输入字节 input_length 和 field_capacity 容易混淆。前者描述网络已经给了多少原始字节，后者描述输出字段数组有多少个位置。两个容量都必须检查，但检查的是不同对象。\nparsed_bytes 也不是“解析了几个字段”。例如两条完整命令粘在同一个缓冲区：\ninput = [命令 A 的 46 字节][命令 B 的 25 字节] input_length = 71 第一次调用只返回命令 A 的字段，parsed_bytes = 46。调用方把前 46 字节消费掉，再用剩余 25 字节进行下一次处理。如果把整个 input_length = 71 都消费掉，命令 B 会静默丢失。\nparse_number 为什么先用局部 pos # 数字函数只识别类似 123\\r\\n 的片段，不读取字段正文。它从调用方给的 *offset 出发，但开始时先复制到局部变量：\nsize_t pos = *offset; size_t value = 0; 然后逐个处理十进制字符：\nsize_t digit = (size_t)(input[pos] - \u0026#39;0\u0026#39;); if (value \u0026gt; (SIZE_MAX - digit) / 10) { return -1; } value = value * 10 + digit; pos++; 检查式来自 value * 10 + digit \u0026lt;= SIZE_MAX。移项后，只要 value \u0026gt; (SIZE_MAX - digit) / 10，下一步就一定溢出。不能先计算、再看结果有没有变小，因为无符号溢出后的值已经不是用户声明的长度。\n数字读完还要确认后面是 \\r\\n：\n数字后没有任何字节：返回 0，等待 只有 \\r，没有 \\n：返回 0，等待 数字后不是 \\r：返回 -1，格式错 \\r 后不是 \\n：返回 -1，格式错 只有 数字\\r\\n 全部到齐时，才把结果写进 *number，把 *offset 更新为后一个位置，返回 1。半包时不推进调用方的 offset，这让上层可以安全地从同一位置重新尝试。\nparse_field 如何区分“还没到齐”和“格式错误” # 字段形式是：\n$\u0026lt;length\u0026gt;\\r\\n\u0026lt;length 个正文原始字节\u0026gt;\\r\\n 关键逻辑可以按顺序读：\nif (pos \u0026gt;= input_length) return 0; if (input[pos] != \u0026#39;$\u0026#39;) return -1; pos++; size_t length = 0; int result = parse_number(input, input_length, \u0026amp;pos, \u0026amp;length); if (result \u0026lt;= 0) return result; size_t remaining = input_length - pos; if (length \u0026gt; remaining || remaining - length \u0026lt; 2) return 0; if (input[pos + length] != \u0026#39;\\r\u0026#39; || input[pos + length + 1] != \u0026#39;\\n\u0026#39;) { return -1; } remaining 是当前位置之后实际收到多少字节。如果声明正文 2048 字节，而现在只有 300 字节，应该返回 0，不能把它判成坏命令。即使正文已经到齐，还需要两个字节作为字段结尾；所以 remaining - length \u0026lt; 2 时同样等待。\n这段代码先确认 length \u0026lt;= remaining，再做 remaining - length 和 pos + length。顺序很重要：若反过来先把一个极大的 length 加到 pos 上，可能造成整数溢出或越界访问。\n在字段完整时才写：\nfield-\u0026gt;data = input + pos; field-\u0026gt;length = length; *offset = pos + length + 2; data 指向正文第一个字节，而不是 $。length 不包括前缀和尾部 CRLF。offset 更新到下一字段或下一条命令的起点。\nparse_command 为什么只解析第一条 # 最外层先检查输入第一个字节是 *，再用 parse_number 得到字段个数。如果字段个数是 0，或者超过调用方 fields 的容量，就拒绝。接着循环调用 parse_field，每次成功才推进到下一字段。\n只有全部字段完整后，才写出最终的 field_count 和 parsed_bytes。如果中途返回 0，调用方应继续保留输入并等待网络数据；不应把 fields 中可能已经写过的前几个元素当作一条可执行命令。\n这个函数不执行 SET/GET，不识别具体引擎，也不分配 key/value 的长期存储。它只把字节流解释成第一条完整命令的字段视图。\n空字段、零字节与内嵌 NUL # 协议格式本身能够表达 $0\\r\\n\\r\\n，即长度为零的字段；是否允许空 key 或空 value 是命令层的决定，不能只由协议解析器替业务决定。\n协议视图同样能够指向值为 0x00 的字节，但后续 C 字符串适配层会拒绝内嵌 NUL。这个差别体现出两个不同层次：\n协议可以描述哪些字节 业务引擎当前能保存哪些数据 把协议做成 slice(data, length)，是为将来支持二进制数据保留接口空间；并不意味着这一轮已经完成四种引擎的二进制改造。\n编码器为什么也要共用 # 解析器只负责读。客户端请求、AOF、Snapshot 和复制增量还需要以同样的规则写出命令。如果它们各自手拼 *、$、长度数字和 CRLF，格式很容易分叉。\nkvs_encoded_command_size() 先计算总字节数，kvs_encode_command() 再写入调用方提供的输出空间。长度数字本身也占空间，例如 9 是一位，2048 是四位，因此不能把字段内容长度直接当作编码后总长度。\nGET 类长度响应也采用先计算、确认容量、再编码的方式。例如长度格式 GET 的结果可以是：\n$11\\r\\nhello world\\r\\n 当前 SET 成功仍返回旧式 OK\\r\\n，不是 RESP2 的 +OK\\r\\n。所以“请求使用 RESP 风格数组和 bulk string”并不等于“服务端响应已经全面兼容 Redis”。\n一条 SET 帧到底占多少字节 # 以 SET、user name、hello world 三个字段为例，命令总长度不是 3 + 9 + 11 = 23。还要算字段数、长度数字、*、$ 与各处 CRLF：\n部分 字节数 计算 *3\\r\\n 4 * 1 + 数字 1 + CRLF 2 $3\\r\\nSET\\r\\n 9 $ 1 + 数字 1 + CRLF 2 + 内容 3 + CRLF 2 $9\\r\\nuser name\\r\\n 15 1 + 1 + 2 + 9 + 2 $11\\r\\nhello world\\r\\n 18 1 + 2 + 2 + 11 + 2 合计 46 4 + 9 + 15 + 18 这解释了手工 xxd 示例中第一条 SET 写入后，Snapshot 的 AOF_OFFSET 是 46：offset 记录的是 AOF 的字节位置，不是写了几条命令。\n大小计算函数的思路可以写成：\nsize_t total = 1 + decimal_digits(field_count) + 2; for (size_t i = 0; i \u0026lt; field_count; i++) { size_t overhead = 1 + decimal_digits(fields[i].length) + 4; /* 先检查 total + overhead + 字段正文是否会溢出。 */ total += overhead + fields[i].length; } 上面的 4 是每个字段前后两组 CRLF，共四字节。实际实现还需要检查每次相加是否超过 SIZE_MAX，并确认字段指针有效。decimal_digits(0) 也应返回 1，因为长度为零时仍要把字符 0 写出来。\n把编码大小算出来之后再分配：\n计算 required ↓ 分配恰好需要的 required 字节 ↓ kvs_encode_command 写入 output ↓ 拿到 encoded_bytes，按这个长度发送或 fwrite 编码器生成的是协议字节，不要求末尾附加 C 字符串的 \\0。对二进制安全的网络发送和文件写入来说，真正重要的是 encoded_bytes，不是 strlen(output)。\nGET 的大小为什么也要先计算 # 旧 GET 习惯写 sprintf(response, \u0026quot;%s\\r\\n\u0026quot;, value)。sprintf 不知道输出缓冲区容量。如果 value 有 2048 字节，而输出只有 1024 字节，问题已经不是“显示不完整”，而可能是越界写入。\n长度响应的大小可以直接由 value 长度算出。例如 2048 字节 value：\n$2048\\r\\n\u0026lt;2048 字节 value\u0026gt;\\r\\n 总大小是 1 + 4 + 2 + 2048 + 2 = 2057 字节。输出缓冲区先保证至少有 2057 字节，再调用编码函数写入。kvs_encoded_value_response_size 和 kvs_encode_value_response 正是为了把“算大小”和“写字节”分别交给协议模块。\n响应中的 $2048 属于协议内容，不是 value 的一部分。使用 wc -c 看网络输出时，统计到的是整个响应帧，不是纯 value 长度，所以数值比 value 的字节数大是正常的。\n现有命令执行器如何接入 # 解析结果是 data + length，但现有 Array、RBTree、Hash、SkipList 引擎使用 char *，并在键比较、复制数据等位置依赖 strlen、strcmp。本轮用适配层把每个 slice 复制成 length + 1 字节的临时 C 字符串，在末尾补 \\0，然后交给原有命令执行器。执行后释放这些临时 token。\n长度格式字段 ↓ kvs_fields_to_tokens 临时的、以 \\0 结尾的 token ↓ kvs_execute_tokens 原有命令执行逻辑 旧行格式和新长度格式最终共用 kvs_execute_tokens。这避免为同一个 SET、GET、DEL、MOD 维护两份业务规则，也让 Replica 只读拦截与 AOF 追加规则继续集中在原来的执行路径。\n这里的代价是：字段内容一旦含有内嵌 \\0，C 字符串接口就不能完整表达它。当前转换层会拒绝这种字段。若以后需要真正的二进制 key/value，不能只改解析器；执行器、四种引擎、AOF、Snapshot 和响应都需要持续携带长度。\n为什么先做适配，而不是立刻重写四个引擎 # 理想中的二进制 KV 引擎会始终保存 key_data + key_length 和 value_data + value_length，比较 key 时用长度与 memcmp，而不是用 strcmp。但现有四个引擎都建立在 C 字符串语义上；一次把它们、AOF、Snapshot 和复制改成完全二进制接口，会让当前需求的改动范围进一步扩大。\n本轮的要求是包含空格与换行的文本 key/value。对不含内嵌 NUL 的文本来说，把 slice 复制为独立的 C 字符串仍可保留字段内容。于是用一个明确的边界适配现有引擎：\n/* 示意：fields[i] 本身不是以 \\0 结尾的字符串。 */ tokens[i] = malloc(fields[i].length + 1); memcpy(tokens[i], fields[i].data, fields[i].length); tokens[i][fields[i].length] = \u0026#39;\\0\u0026#39;; 调用前需要检查 fields[i].length + 1 不溢出，并拒绝正文中的内嵌 \\0。所有 token 必须在命令执行后释放；若第 N 个 token 分配失败，前面已经分配的也要清理。\n这样做不等于 protocol 模块负责引擎内存。协议只返回 slice；边界适配负责临时 token；引擎在 SET 成功时仍按自己的规则复制需要长期保存的 key/value。\nSAVE、普通命令和命令来源 # 旧行格式和新长度格式最后共用 kvs_execute_tokens，因此 SAVE 这种特殊命令也能从同一个入口执行。对于普通命令，继续进入 kvs_filter_protocol，由命令名决定 Array、RBTree、Hash、SkipList 中的哪一路处理。\n执行来源仍然要保留：\nCLIENT 普通客户端命令；Replica 上的写请求应被拒绝 RECOVERY 从 AOF / Snapshot 恢复；不能再次追加同一条 AOF REPLICATION Primary 的增量命令；Replica 可以执行并追加本地 AOF 如果只看命令文本，Replica 收到 HSET 时无法判断是普通客户端想越权写入，还是 Primary 正在同步数据。改协议时不能丢掉原来主从模块建立的命令来源信息。\n为什么把四种 GET 的查找合到一起 # 项目有 GET、RGET、HGET、SGET，分别对应不同引擎。为新长度响应增加扩容时，如果在四个 case 中复制“算大小、扩容、编码”四次，以后修改响应格式就需要改四处。\n这次先把“按命令类型查找 value”集中为 kvs_get_value(cmd, key)，再让新协议的 GET 路径共用 kvs_encode_value_to_output。职责变成：\nkvs_get_value 从对应引擎取出原始 C 字符串 kvs_encode_value_to_output 按长度格式生成响应，必要时扩输出缓冲区 查找函数不知道网络缓冲区，编码函数不知道 Array / Hash 的内部数据结构；这比把四种引擎和协议格式混在每个分支里更容易维护。\n目前 value_slice.length 仍由 strlen(value) 得到。这是另一处内嵌 NUL 不受支持的明确信号，并不是编码器自身只能处理字符串。\n动态输入缓冲区 # 旧连接缓冲区类似：\nchar data[BUFFER_LENGTH]; int length; 即使解析器识别出“帧尚未收完整”，1024 字节的数组满了之后也没有地方接收剩余内容。改造后缓冲区同时保存 data、length 和 capacity：\ndata 动态分配的内存地址 length 当前已接收且尚未消费的字节数 capacity 当前分配容量 接收前调用 kvs_input_buffer_ensure_space 保证至少有可写空间。不够时按需扩容。处理完命令后，kvs_input_buffer_consume 移走已执行的前缀，保留尚未构成完整命令的尾部。\n收到：[完整帧 A][半帧 B] 执行：A 消费：A 的 parsed_bytes 保留：[半帧 B] 下次接收：把 B 的剩余字节接在后面 输出也有相似接口：kvs_output_buffer_ensure_space 和 kvs_output_buffer_free。长 GET 响应要先算出 $长度\\r\\n内容\\r\\n 的字节数，再确保输出有足够容量。只改输入而不改输出，会出现“长 SET 能写入，长 GET 却不能完整返回”的不对称情况。\n两个方向共享底层扩容逻辑，但调用时机不同。扩容使用 realloc，它可能原地扩张，也可能分配新块并移动数据。因此只能在 realloc 成功后更新地址和容量；失败时旧地址仍需保持有效。关闭连接时要释放输入和输出的动态内存。\nensure_space 的不变量 # 扩容函数看起来只是 realloc，但运行前先要确认结构体状态可信：\n0 \u0026lt;= length \u0026lt;= capacity 未分配：data == NULL 且 capacity == 0 已分配：data != NULL 且 capacity \u0026gt; 0 若 capacity \u0026gt; 0 却 data == NULL，后面计算 data + length 会出错；若 length \u0026gt; capacity，所谓“剩余空间”已经没有意义。函数先拒绝这些不一致状态。\n设调用方希望再写入 minimum_free 字节：\n已有空间足够：capacity - length \u0026gt;= minimum_free，直接返回 已有空间不足：required = length + minimum_free 在求和前要检查 length \u0026gt; INT_MAX - minimum_free，因为当前缓冲区的 length 和 capacity 使用 int。如果需要的容量超出 int 可表示范围，本轮实现返回错误，而不是发生溢出后分配过小的内存。\n新容量一般从原容量或 BUFFER_LENGTH 开始倍增：\n1024 → 2048 → 4096 → 8192 ... 倍增是为了避免每到一个新字节就分配一次。接近 INT_MAX 时不能盲目再乘 2，必须限制到刚好需要的 required，避免整数溢出。\nrealloc 返回新地址后才更新 buffer-\u0026gt;data 与 buffer-\u0026gt;capacity。若它失败，原地址仍有效，调用方可以按错误路径清理连接。关闭连接统一调用 kvs_input_buffer_free 或 kvs_output_buffer_free，同时把地址、长度、容量等状态清零，防止下一次误用。\n输入消费比扩容更容易漏 # 空间足够并不代表 TCP 拆包处理正确。每次业务 handler 需要明确告诉网络层：“这次成功处理了输入开头的多少字节”。网络层再调用 kvs_input_buffer_consume：\nint remaining_length = buffer-\u0026gt;length - consumed_length; if (consumed_length \u0026gt; 0 \u0026amp;\u0026amp; remaining_length \u0026gt; 0) { memmove(buffer-\u0026gt;data, buffer-\u0026gt;data + consumed_length, remaining_length); } buffer-\u0026gt;length = remaining_length; memmove 而不是 memcpy，因为源区域和目标区域位于同一个缓冲区，可能重叠。\n三个典型情形：\n收到半条命令：consumed_length = 0，所有字节保留 收到一条完整命令：消费这一条的 parsed_bytes 收到两条命令：先消费第一条，再处理缓冲区中剩下的第二条 不能在返回“等更多输入”时把缓冲区清空；也不能因为第一次执行产生了响应，就覆盖尚未发送的输出，或者把下一条输入也误算成已消费。这与上一篇主从同步处理 TCP 合包的经验直接相连。\n输出缓冲区不只是容量，还包含发送进度 # 输出侧除了 data、capacity、length，还需要 offset。length 是本次响应总共有多少字节，offset 是已经成功发送了多少字节。\n假设长度响应总共 2057 字节，第一次 send 只发出去 800 字节：\noutput.length = 2057 output.offset = 800 下一次从 output.data + 800 继续发送 1257 字节 在 offset == length 前，不能把输出数据覆盖为下一条命令的响应。否则客户端可能收到前半条旧响应和后半条新响应。长 GET 测试正好覆盖了固定输出容量之外的情况，但不等于已经枚举了所有部分发送时机。\n三个网络后端各自要注意什么 # NtyCo、Reactor 和 Proactor 使用相同的缓冲区接口和协议解析入口，但等待 I/O 的方式不同。\nNtyCo 协程每次 nty_recv 前确认剩余空间；Reactor 在 recv_cb 接收前扩容；Proactor 提交新一轮 io_uring 接收前确认地址和容量。Proactor 额外需要注意：上一轮接收请求完成之前不能对它正在使用的缓冲区 realloc。如果内存地址移动，已提交的接收操作可能还在引用旧地址。\n所以不是把一个 realloc 调用机械复制三遍，而是让各后端在自己安全的时机使用公共缓冲区管理函数。收到完整命令后的业务逻辑仍然集中在上层。\n动态缓冲区这轮没有强行接入原有内存池。内存池是否支持连续增长、如何处理大块内存与归还时机，是另一个需要单独设计的问题。\nNtyCo：协程循环中的先后顺序 # NtyCo 的 server_reader 为每条连接保留自己的输入和输出缓冲区。每轮接收之前确认 input.capacity - input.length 还有空间；收到字节后增加 input.length，调用业务 handler，按 consumed_length 消费前缀，再发送已生成的输出。\n协程可以反复在同一连接上处理尚存的输入；但需要在“没有任何字节被消费”时等待下一次网络数据，不能在循环中一直解析同一个半帧。\n协程结束时要释放输入和输出动态内存。Replica 主动连接 Primary 时还会先生成一个初始 PING/PSYNC 消息，发送完成后再进入 server_reader。这块初始输出也需要先分配有效空间，并在成功和失败路径释放，不能在其他函数的作用域里误释放一个不存在的 output 变量。\nReactor：epoll 告诉我们“现在可读/可写” # Reactor 的 recv_cb 在调用 recv 前确认至少有一个可写字节，随后按 input.capacity - input.length 计算本次可接收上限。业务处理若消费了第一条帧，而缓冲区还有第二条，就继续尝试处理；若没有消费任何字节，说明暂时不完整，恢复监听可读事件。\n响应可能需要多次 send_cb 才能发完。关闭连接时从 epoll 移除 fd，关闭 socket，释放动态输入和输出缓冲区，再通知可选的复制关闭回调。把释放逻辑集中到统一关闭路径，可以覆盖正常断开、协议错误和发送失败。\nProactor：完成事件之后才可以重新决定地址 # Proactor 提交 set_event_recv 时，会把当前 input.data + input.length 的地址交给 io_uring。提交之后、对应完成事件到来之前，不能调用可能移动这块内存的 realloc。\n因此顺序是：\n接收完成事件到来 ↓ 把完成的字节数计入 input.length ↓ 执行并消费当前已完整的命令 ↓ 确认下一次接收需要的空间，必要时 realloc ↓ 使用此时的新 data 地址提交下一次 recv 这一阶段曾把“确保空间并提交 recv”的代码误粘了两遍，导致同一 fd 可能提交两次接收。删除重复块后，才保证每次只为下一轮接收注册一次操作。这比简单的重复代码更危险，因为 io_uring 可能在同一缓冲区上形成意料外的并发写入。\nmsg_handler 的接口为什么要调整 # 旧业务回调只拿到 char *response 和一个固定的 response_capacity。如果业务层要返回 2048 字节 GET，就算网络连接的输出结构已支持动态内存，业务回调也没有办法安全地让它扩容。\n因此网络层与业务层之间改为传 kvs_output_buffer_t *。客户端协议处理器可以先扩容，再编码 value。复制协议当前仍有按行控制消息，因此把 response-\u0026gt;data 和 response-\u0026gt;capacity 传给旧的行批处理接口。修改函数指针类型时，server.h 的 typedef、kvstore.c 的客户端 handler、replication.c 的上下游 handler、三个网络后端调用点都必须一致。\nAOF：从文本行变成长度帧 # 旧 AOF 使用 fprintf 写入，例如：\nHSET Dad Jasper\\n HMOD Dad Sao\\n 一旦 key/value 包含空格，重放时再按空格拆分就会还原出不同的字段。网络写入成功不代表重启后还能读回相同数据。\n新 kvs_aof_append 把命令字段交给公共编码器：先计算所需大小、分配临时空间、编码，再用 fwrite 按字节写入。AOF 文件里相邻命令不再依赖额外换行分隔。\n对应地，kvs_aof_replay 不能继续用 getline 把每一行当作一条命令。它读取字节、累计当前帧，调用 kvs_parse_command，完整后通过字段回调执行。恢复命令使用 RECOVERY 来源，不会在执行时又写一遍 AOF。\n一次手工验证中，带空格的 user name 和 hello world 被写成 *3\\r\\n$3\\r\\nSET...。xxd -g1 能看到真实的 0d 0a 字节。服务重启后再次 GET，仍读到 hello world，说明写入、落盘、重放、查询这条路径闭合。\n原有按行 AOF 是旧测试数据，本轮在确认无需保留后将其清空，重新从长度格式开始。新重放器没有为旧格式文件做自动迁移，这一点不能忽略。\nAOF 写入步骤 # kvs_aof_append(char **tokens, int count) 暂时仍从命令执行器接收 C 字符串，因为引擎这一轮没有改成二进制接口。它为每个 token 构造 kvs_slice_t：\nfields[i].data = tokens[i]; fields[i].length = strlen(tokens[i]); 随后执行：\n1. kvs_encoded_command_size 计算 required 2. malloc(required) 获取临时编码缓冲区 3. kvs_encode_command 得到 encoded_length 4. fwrite(encoded, 1, encoded_length, aof_fp) 5. 释放 encoded 6. fflush + fsync，沿用原来的落盘策略 这里必须对比 written 与 encoded_length。fwrite 可能短写，只检查它是否大于零是不够的。fsync 之后，Primary 才能认为对应的 AOF 字节进入了自己的持久化进度。\nkvs_encoded_command_size 的复用价值并不局限于客户端发命令。它让 AOF 和 Snapshot 的字节格式由同一个 codec 决定；未来若要改变字段头的写法，只改协议模块即可。\nAOF 重放为什么需要新的回调签名 # 旧恢复回调接收 (char *msg, int length, char *response)。这默认一条记录可以被表示为一段独立的、接近 C 字符串的文本。新 AOF 每条记录已经是字段数组：\ntypedef int (*aof_replay_handler)( const kvs_slice_t *fields, size_t field_count, char *response, int response_capacity ); 重放器只负责“从文件里找到第一条完整命令并取出字段”；回调负责“执行这些字段”。它不需要重新把命令拼成 HSET key value，否则含空格字段又会丢失边界。\nsrc/kvstore.c 最后使用 kvs_recovery_fields_protocol 作为 AOF 与 Snapshot 的共同恢复回调。这个名字强调参数已经是字段，不是原始命令文本。RECOVERY 来源保证恢复期间不会再次追加到 AOF。\n逐字节读取会不会让复杂度爆炸 # 当前重放逻辑为了容易说明边界，逐字节累计输入并尝试 kvs_parse_command。这会比一次读一大块产生更多函数调用，但 FILE * 本身有缓冲，不等于每个字节发起一个磁盘系统调用。\n还要区分“每字节调用一次解析函数”和“每次重新复制、扫描整个已有 value”。对已经读到字段长度但正文仍未到齐的输入，解析器能够通过长度与剩余字节数判断继续等待；它不需要每次遍历整个长 value。大文件性能仍值得单独测量与优化，但不能只看到 fgetc 就断言每字节一次物理磁盘读取。\nSnapshot：保留 offset 头部，正文改格式 # Snapshot 第一行仍然是：\nAOF_OFFSET \u0026lt;offset\u0026gt;\\n 这个数字记录 Snapshot 所对应的 AOF 字节位置，主从全量同步还需要它。变化的是后面的键值记录。原来一条记录是 SET key value\\n，现在 snapshot_write_item 调用公共编码器，把命令名、key 和 value 写成长度格式帧。\n加载时先用 getline 读取并解析头部这一行；正文则按字节累计，交给 kvs_parse_command。一条完整命令执行后，复用缓冲区读取下一条；文件结束时如果还留有半条命令，就视为损坏。\nSnapshot 文件 ├─ AOF_OFFSET 46\\n ├─ *3\\r\\n...SET...\\r\\n └─ *3\\r\\n...HSET...\\r\\n 当前加载端以 rb 打开文件，使用 fgetc 逐字节读取，每加入一个字节就尝试解析。FILE * 有内部缓冲，所以这不是每字节一次系统调用；不过函数调用次数较多。Snapshot 很大时可以再考虑分块读取。本轮优先保证帧边界正确。\n不要把“Snapshot 文件里如何表示命令”和“FULL_SYNC 如何传送文件”混为一谈。主从传输仍把 Snapshot 当作原始字节流分块发送；kvs_snapshot_reader_open/read/close 负责读文件块。Replica 收完整个文件并安装时，kvs_snapshot_load 才解析其中的长度命令。\n这三个 reader 函数曾在编辑过程中意外丢失，链接阶段出现 undefined reference。从原实现恢复后构建通过。这个插曲提醒我：修改 Snapshot 正文格式时，原始文件传输接口仍然是同一模块的重要部分，不能顺手删掉。\n手工执行 SAVE 后，xxd 显示首行 AOF_OFFSET 46\\n，后面紧跟 *3 开头的长度帧。再次重启并查询含空格的数据，验证了 Snapshot 保存和加载路径。\nSnapshot 为什么仍保留第一行文本 # Snapshot 首行的 AOF_OFFSET 是文件元数据，不是一条 KV 命令。它描述“这个快照已经包含 AOF 的哪个字节位置之前的效果”。Replica 加载快照后，从这个位置继续追赶增量日志。\n因此文件结构没有必要一刀切：\n文件头：AOF_OFFSET N\\n 命令正文：一条接一条的长度格式命令 加载端读一次 getline 只用于头部。之后便不能再用 getline 读取命令正文。两种解析方式处在明确的边界上，不会因为“文件中还出现换行”就发生冲突。\nsnapshot_write_item 的 context # 四种引擎的遍历函数把每个 key/value 交给同一个回调 snapshot_write_item(const char *key, const char *value, void *context)。context 不是神秘的全局状态，而是调用遍历时传进去的一块小结构体：\ntypedef struct { FILE *fp; const char *command; } snapshot_write_context_t; 遍历 Array 时 command 可以是 SET，遍历 Hash 时改成 HSET。同一个写入回调根据当前引擎选择命令名，再把命令名、key、value 组成三个 slice，交给公共编码器。\nArray 遍历 → context.command = SET → *3 ... SET ... Hash 遍历 → context.command = HSET → *3 ... HSET ... 回调只负责写一条记录；Snapshot 保存函数负责文件头、遍历顺序、fflush、fsync、临时文件替换和最终文件大小。这样没有把整个 Snapshot 生命周期塞进单个回调。\nSnapshot 正文如何加载 # 头部读完后，加载器为当前命令维护：\ninput 当前命令已读字节的地址 input_length 已读入多少字节 input_capacity 已分配多少空间 每读到一个字节，先确保 input 有容量，再追加，然后调用解析器。解析器返回 0 时继续读；返回 -1 时中止加载；返回 1 时执行刚得到的字段。\n成功执行一条记录后，只把 input_length 置零，已分配内存可以继续复用。这样多条 Snapshot 记录不会每条都重新 malloc 一个新缓冲区。\n文件尾有两种不同情况：\ninput_length == 0：刚好在记录边界结束，正常 input_length \u0026gt; 0：还留着半条记录，Snapshot 不完整或损坏 处理完要释放 input 并关闭 FILE *。若解析错误、恢复回调失败、读文件出错或关闭失败，kvs_snapshot_load 返回错误，启动或安装流程不能把部分恢复当作完整快照。\n恢复回调的响应缓冲区仍是固定的 1024 字节，因为 Snapshot 正文里只有写入命令，预期成功响应是很短的 OK\\r\\n。这个局部固定容量与“客户端 GET 可以返回长 value”不是同一个输出场景；调用时仍应把容量传给回调并检查返回长度。\n为什么 Snapshot offset 必须按新的 AOF 字节计算 # 旧文本行和新长度帧对同一条逻辑命令使用的字节数不同。例如新帧的字段数、每个字段的长度头和 CRLF 都计入 AOF。Snapshot 记录的 offset 必须来自实际新 AOF 文件位置，不能沿用“写了几行”或“命令内容加空格”的计算。\nPrimary 发出 Snapshot 后，Replica 从这个 offset 继续接收 AOF。如果两个节点对同一条增量命令编码后的字节数不一致，后面收到 ONLINE \u0026lt;offset\u0026gt; 时就会发现本地与 Primary 的进度不相等。\n复制链路里不是所有字节都属于 KV 命令 # 现有复制状态机同时承载控制行、原始文件块和 AOF 命令：\nHANDSHAKE PING / PONG / PSYNC 等控制消息 FULL_SYNC Snapshot 文件的原始字节 CATCH_UP 长度格式的 AOF 增量命令 ONLINE 长度格式增量命令，以及 ONLINE \u0026lt;offset\u0026gt; 控制行 因此不能把整条复制连接一律交给长度解析器。FULL_SYNC 中的某个原始文件块即便恰好以 * 开头，也仍然是 Snapshot 文件的一部分；必须先按协商的文件大小接收、提交并安装它。\n在 CATCH_UP / ONLINE 阶段，输入以 * 开头时，才调用 kvs_parse_command。一条完整命令的字段由复制回调交给 kvstore 执行，并追加到 Replica 本地 AOF，然后以本地 AOF 末尾更新复制 offset。帧还不完整时消费 0 字节，等待网络补充。\nONLINE \u0026lt;offset\u0026gt; 仍按行解析。Replica 收到后检查自己的 AOF offset 是否与 Primary 声明的一致。其他文本行不应被悄悄当作合法控制消息消费；收尾检查时把这条路径改为返回协议错误。\n复制模块从原来传 (msg, length, response) 改为传 (fields, count, response, capacity)。KV 模块接到字段后进入共用命令执行逻辑，因此复制不再把新格式重新拼回旧空格文本。\n复制状态机里怎样选择解析方式 # 上一轮已经建立复制状态机。这一轮没有再造一套“RESP 专用复制器”，而是在 Replica 的上游消息入口中，根据当前状态选择原有处理方式或新命令解析器。\n收到 Primary 字节 ↓ HANDSHAKE？ 按控制行处理 PONG / FULLRESYNC 等消息 FULL_SYNC？ 按 Snapshot 剩余文件字节数写入临时文件 CATCH_UP？ 若是 * 开头，解析长度命令；否则检查 ONLINE 控制行 ONLINE？ 若是 * 开头，解析长度命令；否则检查 ONLINE 控制行 状态判断必须先于字节内容判断。FULL_SYNC 中收到的第一个字节也可能是 *：Snapshot 文件的正文恰好开始了一条长度命令，但这一阶段要把它当作文件内容写入，等完整 Snapshot 安装后再由 kvs_snapshot_load 统一解析。\n一个 recv 甚至可能包含 [Snapshot 最后一块][ONLINE 控制行]。网络层用 consumed_length 先消费文件最后的字节，再让剩余字节按新的复制状态重新进入处理函数。这一机制在上一篇主从同步中已建立，协议改造不能破坏它。\nCATCH_UP 增量命令的完整路径 # Primary 的 AOF 已经改写成长度帧。Primary 的 stream handler 按原有逻辑从 AOF reader 读取原始字节，网络层按块发送；它不需要理解每条命令内容。\nReplica 接收到这些字节后：\ninput[0] == \u0026#39;*\u0026#39; ↓ kvs_parse_command(input, 当前已收长度, fields, 3, ...) ├─ 0：半条命令，consumed_length = 0，等待后续字节 ├─ -1：格式错，终止本次复制连接处理 └─ 1：执行字段，追加 Replica 本地 AOF ↓ 更新 replication_offset ↓ consumed_length = parsed_bytes 字段数组容量 3 对应当前写命令的字段个数，不能当作“协议永远只支持三个字段”的通用结论。客户端协议的字段数组有自己的最大容量。\n复制命令的执行回调使用 REPLICATION 来源。它应该像本地成功写命令一样修改内存并追加 Replica 本地 AOF，但不向 Primary 返回普通客户端响应。上游处理函数返回的 0 表示“这次没有需要发给 Primary 的响应字节”，不表示命令没有执行。\nAOF offset 是字节位置 # 假设快照生成时 Primary 的 AOF 末尾是 offset 1000：\nSnapshot 已含 [0, 1000) 的执行效果 后续命令 A 占 46 字节，范围 [1000, 1046) 后续命令 B 占 58 字节，范围 [1046, 1104) Replica 安装 Snapshot 后应从 1000 开始追赶。执行并追加命令 A 后，它的本地 AOF 末尾应变为 1046；执行 B 后是 1104。若 Primary 宣布 ONLINE 1104，Replica 就能用本地 kvs_aof_get_offset() 检查自己是否真正追到这个位置。\n这里比较的是字节数，不是“两条增量命令”这个数量。新旧编码若字节数不同，不能只比较执行了几次 SET。\nONLINE 为什么仍是一条文本控制行 # ONLINE \u0026lt;Primary当前AOF offset\u0026gt; 的作用是告知 Replica：这一轮 AOF 追赶的边界已经到达，可以进入持续在线同步。它不是 Array 或 Hash 的 KV 命令，不需要写入 AOF。\n因此复制模块保留一个专门的文本行回调，只处理这条控制消息。它要解析出完整的 offset，检查行中没有额外尾随内容，并确认 Replica 本地 AOF 末尾与 Primary 声明的 offset 相等。\n修改过程中，旧的文本回调被改名为 kvs_replication_online_control_frame_protocol，原本执行普通 KV 文本命令的分支删除。代码审查发现末尾还留了一个 return 0，使意外文本行也被当作成功消费。改为 return -1 后，控制消息与异常输入才有明确边界。\n这个细节不是测试脚本通常会主动制造的错误输入，却是状态机很重要的防误判规则：识别出的 ONLINE 返回成功；不认识的文本不能静默跳过。\n为什么改动量比最初想象的大 # 刚开始容易把任务理解成“加一个解析函数，把 strtok 换掉”。实际依赖是：\n新请求格式 ↓ 解析出带长度的字段 ↓ 原有引擎只接受 C 字符串，需要适配 ↓ 长请求需要动态输入缓冲区 ↓ 长 GET 需要动态输出缓冲区 ↓ AOF / Snapshot 不能继续按行保存和重放 ↓ 复制增量使用 AOF 字节和 offset，也要接入新格式 这些地方不是要重复写协议逻辑，而是它们原本都假设“命令是一行、字段按空格分”。公共编解码器只写一份；网络、持久化、复制各自在自己的职责内调用它。\n编译和联调中遇到的错误 # 这次是边写边验证，不是一口气改完。典型问题包括：\nkvs_slice_t unknown type 使用了新类型，但没有包含 protocol.h SIZE_MAX undeclared / memcpy implicit declaration 缺少对应标准头文件 undefined reference to 编码大小计算函数 公共头文件已声明，但实现尚未接入或遗漏 redefinition of ensure_buffer_space 修改输入、输出缓冲区时重复粘贴了共用函数 undefined reference to kvs_output_buffer_ensure_space/free 多个后端已调用，buffer.c 还没有对应定义 invalid storage class for function ... Proactor 某个函数结尾漏了大括号，后续函数被当作嵌套定义 incompatible function pointer type 修改 msg_handler / 复制回调签名后，声明和调用没有同步 这些错误说明在 C 项目里改公共接口，必须顺着“头文件声明 → 实现 → 每个调用点 → 构建规则”检查。函数指针签名不一致尤其不能因为最终能够链接就忽略。\n更稳妥的排查顺序是：先看第一条编译或链接错误，判断是缺声明、缺定义、作用域错误还是调用方仍用旧接口；修好这一处再构建，不把多个不相干的问题混在一起。\n手工验证：含空格的命令能否重启恢复 # 用 Packet Sender 或 nc 发送：\n*3\\r\\n$3\\r\\nSET\\r\\n$9\\r\\nuser name\\r\\n$11\\r\\nhello world\\r\\n 服务器返回 OK。用长度格式 GET 读取 user name 时，得到 $11\\r\\nhello world\\r\\n。再检查 AOF：xxd -g1 显示 2a 33 0d 0a 24 33 ...，对应 *3\\r\\n$3...，不是旧的空格分隔行。\n重启后 GET 仍返回 hello world，说明 AOF 重放成功。执行 SAVE 后，Snapshot 首行出现 AOF_OFFSET 46，正文是长度帧；再次重启后查询也成功。\n十六进制检查非常重要。Packet Sender 按行显示报文时，容易把真实的 \\r\\n 字节和界面换行混在一起；xxd 则能确认文件中的实际格式。\n协议单元测试 # 新增 tests/test_protocol.c，并在 Makefile 里加入 make test-protocol。它直接链接 src/protocol/codec.c，不启动服务，专门验证编解码器。\n测试覆盖：\nvalue 内部含 CRLF，仍按字段声明长度保留 输入连续包含两条命令，第一次只解析第一条 最后少一个字节，返回“还需要更多输入” 字段长度写成非十进制字符，返回格式错误 2048 字节字段编码后再解析，长度与每个字节都一致 运行结果：\n./bin/test_protocol protocol parser tests passed 这里的 2048 字节往返只证明编解码器正确，不能单独证明实际网络连接、Snapshot 和 Replica 也能够处理这个长度。\n为什么把协议测试从网络测试中拆出来 # 若直接启动完整服务器，只看到“客户端没收到响应”，很难立刻判断是解析器、连接缓冲区、命令执行、AOF 还是复制出了错。tests/test_protocol.c 只链接 codec.c，对同一段明确的输入字节直接检查返回值和字段内容，能把协议规则单独验证。\n手工编译曾使用：\ngcc -Iinclude -std=gnu11 -Wall -Wextra -Werror \\ tests/test_protocol.c src/protocol/codec.c \\ -o /tmp/test_protocol /tmp/test_protocol 之后把相同构建规则接入 Makefile，日常只需：\nmake test-protocol -Werror 的含义是让编译警告变成错误。新增 test_large_value 函数后，曾忘记在 main 调用它，于是编译器提示 defined but not used。修正方式是在 main 中调用它，而不是去掉 -Werror。测试代码写在文件里却未执行，本身就是测试覆盖的缺口。\n含 CRLF 的字段测试 # 测试命令正文可以写成：\nstatic const char input[] = \u0026#34;*3\\r\\n\u0026#34; \u0026#34;$3\\r\\nSET\\r\\n\u0026#34; \u0026#34;$3\\r\\nkey\\r\\n\u0026#34; \u0026#34;$4\\r\\na\\r\\nb\\r\\n\u0026#34;; 这里 $4 之后的四个内容字节是 a\\r\\nb，最后的 \\r\\n 才是字段结尾。测试不仅检查返回 1，还要检查 fields[2].length == 4，并用 memcmp(fields[2].data, \u0026quot;a\\r\\nb\u0026quot;, 4) 验证内部换行确实保留。\n若只检查“解析成功”，即使解析器不小心截成 a，测试也可能漏掉语义错误。\n连续两条命令测试 # 测试把完整 SET 和完整 GET 字节直接拼在一个数组里，调用一次 kvs_parse_command。期望是：\n返回值 == 1 field_count == SET 的字段数 parsed_bytes == 第一条 SET 的总字节数 不要求解析器一次返回两条命令。外层网络循环负责根据 parsed_bytes 再处理剩余部分。这正是协议与网络缓冲区的分工。\n少一个字节与无效数字 # 对一条完整命令，故意只提供 sizeof(command) - 2 字节：减去 C 字符串自带的末尾 \\0 后，再少给最后一个协议字节。解析器应返回 0。这验证“不完整”不能被当作“错误”，否则网络稍微拆包，正常客户端就会被错误断开。\n另一个输入是 *1\\r\\n$X\\r\\n。$ 后应该是十进制长度，X 不是数字，因此应返回 -1。这与“数字尚未收完”不同：多等几个字节也不会让 X 变成合法长度。\n2048 字节往返 # 测试先分配一个 2048 字节数组，填入已知字符，再用 kvs_encoded_command_size 计算输出容量并调用 kvs_encode_command，最后把编码结果送回 kvs_parse_command。\n关键断言：\n编码函数成功 解析函数返回 1 解析出预期字段数量 parsed_bytes == encoded_length value 字段长度 == 2048 memcmp(解析结果, 原始 value, 2048) == 0 逐字节比较比“只检查长度”更强：长度对了仍可能在扩容、复制或结尾处理时把正文改坏。这个测试覆盖公共编解码器；实际 TCP 输入和输出要靠下一层的集成测试。\n主从集成测试 # 扩展 tests/test_replication.sh，让它启动相互隔离目录下的 Primary 和 Replica，再发送长度格式的 HSET。测试顺序是：\n启动 Primary 写入含空格的初始 value 写入 2048 字节的初始 value 启动 Replica，等待它进入 ONLINE 从 Replica 查询两条初始数据 ONLINE 后向 Primary 再写入含空格的 value 等待 Replica 查询到这条增量数据 这里踩过两个很有代表性的坑。\n第一个是人工计算字段长度：after online value 实际是 18 字节，误写成 $16 或 $17 时，解析器会等待剩余字节或把分隔符当作正文，客户端收不到预期响应。后来测试脚本为长字段提供了按长度构造帧的辅助函数。当前 shell 用例是 ASCII 文本；如果以后加入多字节字符，不能直接假设 Bash 的 ${#value} 一定等于 UTF-8 字节数。\n第二个是测试步骤放错位置。起初把 2048 字节写入放在 Replica 已经进入 ONLINE 之后，却拿它验证 Snapshot。Primary 日志的 snapshot_size=72 清楚显示那份 Snapshot 生成时只有先前的小数据。把长 value 写入移到“启动 Replica”之前，测试才真正覆盖全量同步。测试的时间顺序本身就是断言的一部分。\n修正后，完整回归输出：\nprotocol parser tests passed [PASS] Primary started [PASS] Initial RESP data written to Primary [PASS] 2048-byte value written to Primary [PASS] Replica entered ONLINE [PASS] Snapshot full synchronization with spaces [PASS] Snapshot full synchronization with a 2048-byte value [PASS] ONLINE incremental synchronization with spaces replication integration test passed 最后再次执行 make \u0026amp;\u0026amp; make test-protocol \u0026amp;\u0026amp; bash tests/test_replication.sh，构建和两组测试均通过。集成测试说明当前默认网络后端的接收、Snapshot 全量同步、Replica 长 value 查询响应以及带空格 value 的在线增量同步已经形成闭环；它没有自动覆盖三个后端的完整测试矩阵。\n为什么使用独立临时目录 # 集成测试不是在平时工作的目录里直接运行 Primary 和 Replica。脚本用 mktemp -d 创建专用测试目录，再分别创建 primary/、replica/ 子目录。\n/tmp/91kv-replication-test.XXXXXX/ primary/ replica/ primary.log replica.log 两个进程在各自的工作目录里生成 AOF 和 Snapshot，避免与开发目录旧数据混用。脚本保存自己启动的 PID，退出时停止两个进程并删除自己的临时目录。测试失败时先输出两份日志，帮助确定是 Primary 写入、全量同步还是在线增量出了问题。\n等待 replication: enter ONLINE 使用带重试的日志检查，而不是固定睡一秒。网络和文件操作速度可能变化，固定时间既可能太短导致误报，也可能太长拖慢测试。\nsend_resp_frame 为什么只适合已编码的测试数据 # 脚本中 send_resp_frame 通过 printf '%b' \u0026quot;$frame\u0026quot; 把字符串中的 \\r\\n 转成实际 CRLF 字节，然后交给 nc -N 发送。这个辅助函数不会替我们计算 $长度。手工写 $16 但实际正文有 18 字节时，它照样会把错误帧发出去。\n之后的 send_resp_hset 根据传入的 key/value 构造 $\u0026lt;length\u0026gt;。这里曾把 printf 格式串写成只有三个占位符，却传入四个参数。Bash 的 printf 会复用格式串处理多余参数，把 2048 个 v 当成整数，报出 invalid number。正确关系应该是：\n%d ← key 的长度 %s ← key 内容 %d ← value 的长度 %s ← value 内容 用的格式串需要完整包含最后一个 %s：\nprintf \u0026#39;*3\\r\\n$4\\r\\nHSET\\r\\n$%d\\r\\n%s\\r\\n$%d\\r\\n%s\\r\\n\u0026#39; \\ \u0026#34;${#key}\u0026#34; \u0026#34;$key\u0026#34; \u0026#34;${#value}\u0026#34; \u0026#34;$value\u0026#34; 这里 HSET 固定四字节；测试中 key/value 都是 ASCII，因此 ${#key}、${#value} 与字节数一致。若改成中文或其他多字节 UTF-8 字符，Bash 在当前 locale 下的字符计数未必等于网络协议要求的字节计数，应改用字节计数或明确设置合适的 locale。\n如何从日志看出测试步骤放错 # 最初失败输出里是：\n[PASS] Replica entered ONLINE [PASS] 2048-byte value written to Primary replication test failed: Snapshot replication did not preserve the 2048-byte value 从顺序就能看出：Replica 已经拿到 Snapshot 并进入 ONLINE，长 value 才写入 Primary。Primary 同时报告 offset=58 snapshot_size=72，与只有前一条小记录的 Snapshot 相符。\n这时不能仓促断言“Snapshot 解析长帧有 bug”。先确认测试是不是在正确的时间点创建数据。把整段长 value 的写入移到 # 启动 Replica。 之前，运行输出变为：\n[PASS] 2048-byte value written to Primary [PASS] Replica entered ONLINE [PASS] Snapshot full synchronization with a 2048-byte value 这个例子说明：集成测试的日志和步骤顺序也是证据。[PASS] 只证明某一步单独成功，不自动证明它发生在所需的同步阶段。\n这组测试能证明什么，不能证明什么 # 它证明当前默认网络后端下，长度大于旧 1024 字节容量的请求能够进入 Primary，Snapshot 能保存并传到 Replica，Replica 的长度格式查询能返回完整 2048 字节。含空格 value 还分别经过 Snapshot 全量与 ONLINE 增量路径。\n它没有专门通过脚本人为控制 TCP 把某个 $长度 分成两次发送，也没有对三种网络后端逐一运行自动化矩阵。协议单元测试中的“不完整帧”覆盖解析器返回值，但网络层真正的拆包时序仍有继续补充端到端测试的价值。\n为什么还不能直接用 redis-cli # redis-cli 是客户端，会把用户输入的命令自动编码成 RESP，请求端不需要再手写 *3 和 $11。但要让它直接操作本项目，服务端响应也必须满足它能解析的 RESP 语义。当前 GET 类响应已有长度形式，SET、错误、空值等响应尚未全部统一为完整 RESP2；项目命令的名称和行为也不必然与 Redis 一致。\n本轮准确的说法是：\n已支持：RESP 风格的长度格式命令请求，以及项目中的部分长度响应 未承诺：完整 RESP2 响应或 redis-cli 的通用兼容 以后如果决定支持 redis-cli -2，需要单独整理成功、错误、整数、空值和 bulk string 等响应，再用真实客户端验证。若只是想省去手算命令长度，也可以为 91kvstore 做自己的小型 CLI，由客户端调用编码器。\n回头回答开发中的几个疑问 # \u0026lt;stddef.h\u0026gt; 为什么出现在 protocol.h # 公共头文件要声明 size_t，所以需要包含定义这个类型的标准头文件 \u0026lt;stddef.h\u0026gt;。size_t 用来表示对象大小和数组长度，适合描述“这段输入有多少字节”和“字段长度是多少”。\nsize_t 与 int 不同：它是无符号类型，宽度与平台相关。网络层既有接口仍使用 int 保存连接缓冲区长度，所以从协议的 size_t parsed_bytes 转回 int consumed_length 时，要确保这个值不会超出 int 范围。本轮解析的是从 int length 传入的输入区间，成功消费量不应大于输入长度；以后若扩大网络长度接口，需要重新审视这个转换。\n为什么称为“帧” # 帧在这里不是以太网帧，只是指一条有明确开头和结尾的协议消息。网络层看到的是连续字节：\n[命令帧 1][命令帧 2][命令帧 3 的前半段] “帧长度”强调一条完整消息占用了多少字节；“命令”强调业务含义。本文在讲语义时说命令，在讲边界与消费量时说帧，不需要把两者当成不同业务对象。\nparse_field 与 parse_command 为什么分开 # 单个字段只需要理解 $长度\\r\\n内容\\r\\n。整条命令要先理解 *字段数量\\r\\n，再重复解析字段。把两个层级写进一个大函数会混淆“不完整数字”“不完整字段”和“字段数超过容量”等不同错误来源。\nparse_field 是内部辅助函数，不出现在头文件里；外部调用者只需要 kvs_parse_command。这也让以后更换内部解析步骤时，不必修改所有使用协议的模块。\nconsumed_length 与 parsed_bytes 是不是一回事 # 两者在新命令入口通常表示同一个数量，但处在不同接口：\nparsed_bytes 协议解析器计算：第一条完整命令的字节数 consumed_length 网络 handler 告诉后端：本次可以从输入缓冲区移除多少字节 协议解析成功且命令执行成功，handler 才把 parsed_bytes 写入 consumed_length。若命令尚未收完整，消费量是 0。若执行失败，连接可能走错误处理，不能随意宣称这段输入已经成功处理。\n复制 FULL_SYNC 状态还有另一个用法：它消费的是 Snapshot 原始文件块，而不是长度格式命令。这个例子也说明 consumed_length 是网络层的通用契约，parsed_bytes 是协议解析器的具体结果。\n为什么不能把 parser 做成每个后端的 feed 状态机 # 每条连接已经保存自己的输入缓冲区。解析器接收 input + input_length，返回“完整/等待/错误”和已消费字节数；半帧保存在连接缓冲区，下一次收到数据后再调用同一个无状态解析器。\n这样协议模块不需要拥有 socket、不需要管理协程或 io_uring，也不需要再维护一份独立的 per-connection parser 状态。若未来有性能需求，可以设计增量状态机，但当前实现用同一解析函数即可满足三个后端的正确性要求。\nrealloc 会不会造成外部碎片 # 它可能移动内存，也可能使堆内存布局出现碎片；这是通用堆分配器需要面对的代价。当前主要目标是处理超出 1024 字节的连接数据，并避免每增加一个字节就分配一次，因此使用倍增容量减少重新分配次数。\n是否要用内存池替代，得先知道池是否支持连续增长、如何处理变长对象、一个连接何时归还整块内存，以及并发连接的峰值使用量。没有这些设计就直接把 realloc 改成池分配，可能只是把碎片问题换成数据复制和容量浪费。\nPacket Sender、nc 与 redis-cli 的分工 # Packet Sender 适合观察、手工构造协议字节，尤其能用十六进制检查 CRLF；nc 适合把 printf 生成的确切字节送到服务端，放进 shell 集成测试。\nredis-cli 会把用户输入的命令编码成 RESP，并期待服务端返回符合 RESP 的响应。它很方便，但它是兼容性目标，不是这次长度帧改造能够自动获得的能力。自己实现的小 CLI 只需遵守本项目已经实现的请求和响应约定，工作量则与 Redis 兼容不同。\n当前完成边界 # 以“文本 key/value 可以包含原分隔符，命令长度不再被固定网络缓冲区截住，并能经过持久化与主从同步”为目标，关键路径已经完成。\n✅ protocol 模块集中编解码长度命令 ✅ 解析器区分完整、未完成和格式错误 ✅ 解析结果提供字段视图与第一条命令消费字节数 ✅ 三个网络后端接入动态输入/输出缓冲区接口 ✅ AOF 追加、重放使用长度帧 ✅ Snapshot 保留 offset 头部，正文使用长度帧 ✅ CATCH_UP / ONLINE 执行长度格式增量命令 ✅ 协议单元测试和默认后端主从集成测试通过 尚未解决或需要另立需求的边界：\n⬜ 内嵌 NUL 的二进制 key/value：四种引擎仍依赖 C 字符串 ⬜ 完整 RESP2 响应与 redis-cli 兼容 ⬜ 旧按行客户端协议的长期保留或移除策略 ⬜ 旧格式 AOF / Snapshot 的自动迁移 ⬜ 超大命令的资源上限与拒绝策略 ⬜ 更多恶意长度、TCP 拆包和粘包的端到端用例 ⬜ NtyCo / Reactor / Proactor 自动化测试矩阵 ⬜ Snapshot 加载阶段逐字节读取的性能优化 目前新客户端入口仍保留旧按行格式的兼容路径；复制握手和 ONLINE 控制行则因职责不同，继续使用各自的控制格式。未来若移除旧客户端兼容，不能把这些复制控制消息一并删除。\n代码地图：遇到问题先看哪里 # 这轮改动横跨多个目录。重新打开项目时，可以按职责找文件，而不是从 main() 一路滚动到末尾。\n文件 主要职责 出问题时先看什么 include/protocol.h slice 与编解码接口 声明、参数、返回值是否一致 src/protocol/codec.c 数字、字段、整条命令的解析与编码 长度、CRLF、溢出、返回 0/-1/1 include/server.h handler 与缓冲区结构 输出是否给了扩容所需结构 src/network/buffer.c 输入消费和输入/输出扩容 length \u0026lt;= capacity、重复定义、释放 src/network/ntyco.c 协程连接读写 recv 前空间、循环消费、关闭释放 src/network/reactor.c epoll 连接读写 EPOLLIN/EPOLLOUT 切换与部分发送 src/network/proactor.c io_uring 完成事件 pending recv 使用的地址是否稳定 src/kvstore.c 协议入口与命令执行适配 slice 转 token、命令来源、GET 响应 src/persistence/aof.c AOF 追加与重放 写入字节数、恢复回调、旧格式遗留 src/persistence/snapshot.c Snapshot 保存与加载 第一行 offset、正文帧、原始文件 reader src/replication/replication.c 复制状态机与增量命令 FULL_SYNC/CATCH_UP/ONLINE 是否走对分支 tests/test_protocol.c 解析器单元测试 边界字节、返回值、字段内容 tests/test_replication.sh 端到端主从测试 写入时机、日志、全量与在线验证 例如“SET 返回 OK，但重启后 GET 不到”，优先看 AOF 是否使用长度格式、恢复是否按字段回调执行；“只在 Proactor 下长命令出错”，优先看 io_uring 接收完成前是否错误扩容；“全量测试失败，但 Primary 日志的 Snapshot 很小”，优先确认测试是否在生成 Snapshot 前完成写入。\n一次从零回归的顺序 # 如果隔一段时间重新接手这个分支，可以按由小到大的顺序验证：\nmake make test-protocol bash tests/test_replication.sh make 说明声明、实现和链接至少在当前配置下吻合。make test-protocol 排查纯协议格式；主从脚本才覆盖两个服务进程、端口、AOF、Snapshot 与在线复制。不要只看到 make 成功就宣称长命令已能端到端使用。\n手工调试时再补：\nxxd -g1 -l 100 appendonly.aof xxd -g1 -l 100 snapshot.db 这些命令直接显示文件字节。Snapshot 第一行应该是可读的 AOF_OFFSET，正文应从 * 长度帧开始；AOF 则不应回到 HSET key value\\n 的旧行格式。\n如果测试失败，先分清失败位置：\n没有响应：请求帧可能不完整、长度数字可能错、连接可能提前关闭 SET 有响应但 AOF 不对：检查追加路径和命令来源 AOF 正确但重启读不到：检查重放与恢复回调 Snapshot 大小不对：检查生成时机与 foreach 写入 Replica 与 Primary offset 不同：检查增量执行、AOF 编码字节和 ONLINE 校验 这比看到一个空响应就同时怀疑协议、网络、AOF 和 Snapshot 更容易定位。\n与上一篇主从同步的关系 # 上一篇的主从复制建立了状态机、Snapshot offset、AOF 追赶、三种网络后端与输入输出缓冲区契约。这一篇没有推翻那些设计，而是改变了KV 命令本身在网络和持久化中的字节表示。\n最容易误以为“主从同步早已完成，不用改”的是增量阶段。Primary 传送的正是 AOF 字节；AOF 一旦从文本行改为长度帧，Replica 就必须按新帧解析、执行并正确更新 offset。相反，FULL_SYNC 的 Snapshot 文件分块传输仍然是原始字节流，不因为正文格式改变而重新设计底层发送。\n可以把两篇的关系概括为：\n第五篇：字节怎样从 Primary 可靠地到达 Replica，并在正确 offset 衔接 第六篇：这些字节里的 KV 命令怎样无歧义地表示 key/value 去 AI 化复盘：必须自己讲清楚的五件事 # 1. 长度解决了什么 # 空格和换行本来既可能是数据，也被旧协议当作边界。长度格式先声明字段有多少字节，再精确读取这么多字节，于是数据里的旧分隔符不再参与拆字段。\n2. parsed_bytes 为什么重要 # TCP 可能一次送来半条、一条或多条命令。解析器返回第一条完整命令消费的字节数，网络层才能移除已处理的前缀，并留下后面的数据。\n3. slice 与 C 字符串有什么不同 # slice 是地址加长度，不自动以 \\0 结尾，也不拥有内存。现有引擎仍用 C 字符串，所以边界层复制并补终止符。这让含空格文本工作，也说明内嵌 NUL 为什么仍不支持。\n4. 为什么 AOF 和 Snapshot 也必须改 # 持久化保存的是将来要再次执行的命令。如果网络能够接收 user name，AOF 却仍写 SET user name ...\\n，重启后的字段会变样。复制增量又直接使用 AOF 字节，三者必须对命令边界达成一致。\n5. 为什么不能把所有复制字节都交给同一个解析器 # 复制连接同时传输握手控制行、Snapshot 原始文件字节、AOF 增量命令和 ONLINE 控制行。状态机决定当前字节的含义。只有增量阶段的长度命令交给 kvs_parse_command，FULL_SYNC 的文件块不能当作命令解释。\n面试版总结 # 我把 91kvstore 原来按空格拆字段、按行拆命令的文本协议，改造成 RESP 风格的长度字段协议。公共 protocol 模块计算帧大小、编码命令，并从字节流解析第一条完整命令；解析结果用只引用输入的 slice 表示，同时返回已消费字节数，供网络层处理半包和粘包。三个网络后端共用解析入口，输入和输出缓冲区按需扩容。为了让含空格和长 value 在重启及主从同步后仍保持一致，我同步改造 AOF、Snapshot 正文和复制增量命令处理，保留握手与 ONLINE 控制行各自的格式。协议单元测试覆盖 CRLF、半包、连续帧、错误长度和 2048 字节字段；主从集成测试验证带空格和长 value 的全量同步，以及带空格 value 的在线增量同步。当前引擎仍以 C 字符串为核心，因此不支持内嵌 NUL，服务端响应也尚未完整兼容 RESP2。\n这轮命令协议改造的核心需求至此完成。完整二进制值与 redis-cli 兼容，应作为后续独立需求设计和验证。\n","date":"2026年10月6日","externalUrl":null,"permalink":"/MyBlog/p/kv-evolution-length-prefixed-protocol/","section":"Posts","summary":"","title":"KV 进化（六）：命令协议改造","type":"posts"},{"content":"","date":"2026年10月6日","externalUrl":null,"permalink":"/MyBlog/tags/resp/","section":"Tags","summary":"","title":"RESP","type":"tags"},{"content":"","date":"2026年10月6日","externalUrl":null,"permalink":"/MyBlog/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"2026年10月6日","externalUrl":null,"permalink":"/MyBlog/tags/tcp/","section":"Tags","summary":"","title":"TCP","type":"tags"},{"content":"","date":"2026年10月6日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%91%BD%E4%BB%A4%E5%8D%8F%E8%AE%AE/","section":"Tags","summary":"","title":"命令协议","type":"tags"},{"content":"","date":"2026年10月6日","externalUrl":null,"permalink":"/MyBlog/tags/%E4%B8%BB%E4%BB%8E%E5%90%8C%E6%AD%A5/","section":"Tags","summary":"","title":"主从同步","type":"tags"},{"content":"实现主从同步功能\n当前的 91kvstore 是一个单点 KV 服务：\nClient ↓ kvstore ↓ Array / RBTree / Hash / SkipList 所有 SET、GET、DEL、MOD 等操作都发生在同一个进程中。一旦这个进程退出或者所在机器发生故障，虽然可以通过 Snapshot 和 AOF 恢复本机数据，但服务在恢复期间不可用，也没有另一台机器可以继续提供数据副本。\n这一轮准备在现有单机 KV、AOF 和 Snapshot 的基础上增加标准主从复制：\n客户端写入 ↓ Primary ↓ 复制写命令 Replica Primary 负责接受写请求并确定写命令顺序，Replica 负责复制 Primary 的数据并提供只读查询。\nPrimary / Replica 与 Master / Slave # 两组术语表示相同角色：\nMaster = Primary = 主库 Slave = Replica = 从库 这一轮代码统一采用：\nPrimary Replica Primary 表示主要写入节点，Replica 表示复制出来的数据副本节点。旧文章和旧项目仍可能使用 Master / Slave，但在本项目中不再混用两套名称。\n标准主从的基本规则是：\nPrimary：允许客户端读写 Replica：允许客户端读取，拒绝客户端直接写入 例如客户端向 Primary 写入：\nSET name jasper Primary 执行成功后，把相同的写命令发送给 Replica。Replica 在自己的存储引擎中再次执行：\nSET name jasper 最终两台机器都保存：\nname = jasper 如果普通客户端直接向 Replica 发送写命令，后续应返回：\nREADONLY 但来自 Primary 的复制命令必须允许执行。因此后续不能只看命令是不是 SET，还需要判断命令来源。\n为什么选择标准主从而不是双主 # 双主意味着两台机器都可以接受写入：\nClient A → Primary A ⇄ Primary B ← Client B 如果两边同时写入同一个 key：\nA：SET name alice B：SET name bob 系统必须决定最终保存哪个值，还要处理：\n写冲突 命令重复传播 同步环路 节点版本 逻辑时钟 网络分区 冲突合并 这已经不是把 AOF 双向发送那么简单，而是另一套分布式一致性设计。\n当前 91kvstore 已经具备 AOF、Snapshot、TCP 协议和多种存储引擎，与标准主从的匹配程度更高。Primary 可以统一确定写命令顺序，Replica 只需要按相同顺序执行，不需要解决两个写入源之间的冲突。\n因此本轮确定的范围是：\n实现：一主一从、全量同步、增量同步、断线恢复、只读 Replica 不实现：双主、Raft、自动选主、多数据中心冲突合并 这不是只转发一条 SET 的演示功能，也不会直接扩张成完整分布式集群。目标是实现一个范围清楚、流程完整、能够正确处理同步边界的主从复制模块。\nSnapshot 与 AOF 在复制中的职责 # 主从复制不是只发送当前内存，也不是每次都重新执行完整 AOF，而是组合使用 Snapshot 和 AOF。\n两者的职责可以概括为：\nSnapshot：某个时间点的完整数据状态 AOF：这个时间点之后发生的数据变化 例如同一个 key 被修改五次：\nSET name a MOD name b MOD name c MOD name d MOD name jasper AOF 保存数据变化过程，恢复时需要依次重放这些命令。Snapshot 只保存最终状态：\nSET name jasper 因此 Replica 第一次连接 Primary 时，计划采用：\n1. Replica 请求全量同步 2. Primary 自动生成用于复制的临时 Snapshot 3. Primary 记录 Snapshot 对应的 AOF offset 4. Primary 发送 Snapshot 5. Replica 加载 Snapshot 6. Primary 发送该 offset 之后的 AOF 7. Replica 追上 Primary 后进入在线增量复制 这里不依赖用户提前手动执行 SAVE。手动 SAVE 是用户主动保存持久化快照；首次主从同步需要的 Snapshot 将由复制系统自动触发。\n当前 snapshot.db 第一行已经记录：\nAOF_OFFSET \u0026lt;offset\u0026gt; 这为 Snapshot 和后续 AOF 的衔接提供了基础。\nreplication offset # Replication offset 表示 Replica 已经处理到复制流的哪个位置，可以理解为复制进度书签。\n例如：\nPrimary offset = 1200 Replica offset = 1000 说明 Replica 还缺少 200 字节的复制数据。\n断线重连后，Replica 可以告诉 Primary：\n我已经处理到 offset 1000 Primary 只需要从 1000 之后继续发送，而不必每次重新传输所有数据。\n需要区分两个概念：\nAOF offset // 本地持久化日志位置 Replication offset // 主从复制进度 第一阶段可以让复制流直接复用 AOF 的字节位置，但代码设计中仍应保留概念边界，避免以后调整复制日志时把本地持久化和主从进度永久绑定在一起。\n全量同步期间的新写入 # 生成和发送 Snapshot 不是瞬间完成的。在这个过程中 Primary 仍可能收到新写命令。\n锁和状态机解决的是两个不同问题：\n锁：保证 Snapshot 与 offset 对应同一个一致性时刻 状态机：记录 Replica 当前同步到了哪个阶段 计划中的一致性流程是：\n短暂锁定写入 ↓ 确定 offset 并生成一致性 Snapshot ↓ 解除锁定，Primary 继续处理写入 ↓ 通过网络发送 Snapshot ↓ 发送 Snapshot offset 之后新增的 AOF 锁不能覆盖整个网络传输过程。如果发送一个大 Snapshot 时一直持有数据库锁，Primary 会长时间无法写入。\nReplica 的连接状态计划表示为：\nDISCONNECTED ↓ CONNECTING ↓ HANDSHAKE ↓ FULL_SYNC 或 PARTIAL_SYNC ↓ CATCHING_UP ↓ ONLINE 连接断开后进入重试流程：\nONLINE → BACKOFF → CONNECTING 状态机与 Snapshot、增量 AOF、ACK 和重连将在后续阶段逐步实现。\n运行方式设计 # 为了保留原来的单机用法，同时支持主从复制，最终确定三种运行角色：\nSTANDALONE PRIMARY REPLICA 原来的启动方式保持不变：\n./bin/kvstore 2000 Primary 使用业务端口和复制端口：\n./bin/kvstore primary 2000 2100 含义：\n2000：普通客户端连接的业务端口 2100：Replica 连接的复制端口 Replica 使用自己的业务端口，并主动连接 Primary 的复制端口：\n./bin/kvstore replica 2001 127.0.0.1 2100 含义：\n2001：Replica 自己的只读业务端口 127.0.0.1：Primary 地址 2100：Primary 的复制端口 Primary 使用两个端口，是为了让客户端协议与复制协议保持清晰边界：\n客户端命令 → service_port 复制握手、Snapshot、AOF、ACK → replication_port 创建功能分支 # 主从同步在独立功能分支开发：\ngit switch -c feature/primary-replica-replication 这一分支已经分别推送到：\nGitHub：JasperStonnne/91kvstore GitLab：Shijingyuan/91kvstore GitHub 被设置为默认 upstream，GitLab 使用显式远程名称同步同一功能分支。\n定义运行角色 # 新增公共头文件：\ninclude/replication.h 首先定义运行角色：\n#ifndef KVS_REPLICATION_H #define KVS_REPLICATION_H typedef enum { KVS_ROLE_STANDALONE = 0, KVS_ROLE_PRIMARY, KVS_ROLE_REPLICA } kvs_role_t; #endif 枚举实际对应：\nKVS_ROLE_STANDALONE = 0 KVS_ROLE_PRIMARY = 1 KVS_ROLE_REPLICA = 2 使用枚举而不是散落的整数或字符串，可以让后续判断更明确：\nif (role == KVS_ROLE_REPLICA) { // 检查客户端写入 } 集中保存启动配置 # 同一个角色还需要携带端口和地址，因此在 replication.h 中增加：\ntypedef struct { // 保存启动配置 kvs_role_t role; // 当前进程角色 unsigned short service_port; // 客户端连接端口 unsigned short replication_port; // 主从复制连接端口 const char *primary_host; // Replica 要连接的 Primary 地址 } kvs_server_config_t; Primary 的配置示例：\nrole = KVS_ROLE_PRIMARY service_port = 2000 replication_port = 2100 primary_host = NULL Replica 的配置示例：\nrole = KVS_ROLE_REPLICA service_port = 2001 replication_port = 2100 primary_host = \u0026#34;127.0.0.1\u0026#34; primary_host 直接指向 argv 中的字符串。命令行参数在整个 main() 生命周期内都有效，因此当前阶段不需要额外复制这段字符串。\n补充 Makefile 依赖 # src/kvstore.c 引入：\n#include \u0026#34;replication.h\u0026#34; 因此 kvstore.o 的构建依赖也必须包含这个头文件：\n$(BUILD_DIR)/kvstore.o: \\ $(SRC_DIR)/kvstore.c \\ $(INCLUDE_DIR)/kvstore.h \\ $(INCLUDE_DIR)/replication.h | $(BUILD_DIR) $(CC) $(CPPFLAGS) $(CFLAGS) -c $\u0026lt; -o $@ 如果不声明依赖，只修改 replication.h 后执行 make，构建系统可能认为 kvstore.o 不需要重新编译。\n规范解析端口 # 旧代码直接使用：\nint port = atoi(argv[1]); atoi() 无法区分：\n\u0026#34;abc\u0026#34; \u0026#34;0\u0026#34; 它们都可能得到 0，而且不能可靠检查数值溢出。主从模式需要解析多个端口，因此先增加统一端口解析函数：\nstatic int kvs_parse_port(const char *text, unsigned short *port) { if (text == NULL || port == NULL) { return -1; } errno = 0; char *end = NULL; // 字符串解析结束的位置 long value = strtol(text, \u0026amp;end, 10); // 按十进制转换为 long if (errno != 0 || // 转换发生错误 end == text || // 没有解析出任何数字 *end != \u0026#39;\\0\u0026#39; || // 数字后仍有非法字符 value \u0026lt; 1 || // 端口不能小于 1 value \u0026gt; 65535) { // 端口不能超过 65535 return -1; } *port = (unsigned short)value; return 0; } strtol() 通过 end 返回停止解析的位置。\n输入完整数字：\ntext → 2000\\0 ↑ end 此时：\n*end == \u0026#39;\\0\u0026#39; 输入数字后带非法字符：\ntext → 2000abc ↑ end 此时：\n*end != \u0026#39;\\0\u0026#39; 输入完全不是数字：\ntext → abc ↑ text 与 end 此时：\nend == text 也就是一个数字字符都没有成功读取。\n解析三种启动参数 # main() 先创建默认配置：\nkvs_server_config_t config = { .role = KVS_ROLE_STANDALONE, .service_port = 0, .replication_port = 0, .primary_host = NULL }; 单机模式 # if (argc == 2) { if (kvs_parse_port(argv[1], \u0026amp;config.service_port) \u0026lt; 0) { fprintf(stderr, \u0026#34;invalid service port: %s\\n\u0026#34;, argv[1]); return -1; } } 执行：\n./bin/kvstore 2000 得到：\nrole = STANDALONE service_port = 2000 replication_port = 0 primary_host = NULL Primary 模式 # else if (argc == 4 \u0026amp;\u0026amp; strcmp(argv[1], \u0026#34;primary\u0026#34;) == 0) { config.role = KVS_ROLE_PRIMARY; if (kvs_parse_port(argv[2], \u0026amp;config.service_port) \u0026lt; 0) { fprintf(stderr, \u0026#34;invalid service port: %s\\n\u0026#34;, argv[2]); return -1; } if (kvs_parse_port(argv[3], \u0026amp;config.replication_port) \u0026lt; 0) { fprintf(stderr, \u0026#34;invalid replication port: %s\\n\u0026#34;, argv[3]); return -1; } if (config.service_port == config.replication_port) { fprintf(stderr, \u0026#34;service port and replication port must differ\\n\u0026#34;); return -1; } } 执行：\n./bin/kvstore primary 2000 2100 得到：\nrole = PRIMARY service_port = 2000 replication_port = 2100 primary_host = NULL 业务端口和复制端口不能相同，否则同一个进程中的两个监听 socket 无法同时绑定相同地址和端口。\nReplica 模式 # else if (argc == 5 \u0026amp;\u0026amp; strcmp(argv[1], \u0026#34;replica\u0026#34;) == 0) { config.role = KVS_ROLE_REPLICA; if (kvs_parse_port(argv[2], \u0026amp;config.service_port) \u0026lt; 0) { fprintf(stderr, \u0026#34;invalid service port: %s\\n\u0026#34;, argv[2]); return -1; } if (argv[3][0] == \u0026#39;\\0\u0026#39;) { fprintf(stderr, \u0026#34;primary host cannot be empty\\n\u0026#34;); return -1; } config.primary_host = argv[3]; if (kvs_parse_port(argv[4], \u0026amp;config.replication_port) \u0026lt; 0) { fprintf(stderr, \u0026#34;invalid replication port: %s\\n\u0026#34;, argv[4]); return -1; } } 执行：\n./bin/kvstore replica 2001 127.0.0.1 2100 得到：\nrole = REPLICA service_port = 2001 primary_host = \u0026#34;127.0.0.1\u0026#34; replication_port = 2100 非法启动参数 # 不满足三种形式时，程序打印：\nfprintf(stderr, \u0026#34;usage:\\n\u0026#34; \u0026#34; %s \u0026lt;service-port\u0026gt;\\n\u0026#34; \u0026#34; %s primary \u0026lt;service-port\u0026gt; \u0026lt;replication-port\u0026gt;\\n\u0026#34; \u0026#34; %s replica \u0026lt;service-port\u0026gt; \u0026lt;primary-host\u0026gt; \u0026lt;replication-port\u0026gt;\\n\u0026#34;, argv[0], argv[0], argv[0]); C 会自动拼接相邻字符串。三个 %s 分别使用三个 argv[0]，最终显示实际程序名：\nusage: ./bin/kvstore \u0026lt;service-port\u0026gt; ./bin/kvstore primary \u0026lt;service-port\u0026gt; \u0026lt;replication-port\u0026gt; ./bin/kvstore replica \u0026lt;service-port\u0026gt; \u0026lt;primary-host\u0026gt; \u0026lt;replication-port\u0026gt; 业务网络层改用配置端口 # 原来三个网络模型直接使用局部变量 port：\nreactor_start(port, kvs_network_protocol); ntyco_start(port, kvs_network_protocol); proactor_start(port, kvs_network_protocol); 改为统一使用：\nreactor_start(config.service_port, kvs_network_protocol); ntyco_start(config.service_port, kvs_network_protocol); proactor_start(config.service_port, kvs_network_protocol); 当前这三个调用仍然只负责业务端口。Primary 的复制端口监听和 Replica 的主动连接尚未接入。\n启动参数验证 # 无参数启动：\n./bin/kvstore 输出：\nusage: ./bin/kvstore \u0026lt;service-port\u0026gt; ./bin/kvstore primary \u0026lt;service-port\u0026gt; \u0026lt;replication-port\u0026gt; ./bin/kvstore replica \u0026lt;service-port\u0026gt; \u0026lt;primary-host\u0026gt; \u0026lt;replication-port\u0026gt; Primary 使用相同的业务端口和复制端口：\n./bin/kvstore primary 2000 2000 输出：\nservice port and replication port must differ Replica 使用非法业务端口：\n./bin/kvstore replica abc 127.0.0.1 2100 输出：\ninvalid service port: abc 完整 make 构建通过，编译过程没有 error 和 warning。\n当前提交 # 这一阶段使用提交标题：\nfeat(replication): 增加主从角色与启动参数解析 提交内容包括：\n新增 standalone、primary 和 replica 三种运行角色 增加服务端启动配置结构体 支持三种启动参数格式 校验非法端口与 Primary 端口冲突 补充 replication.h 的 Makefile 构建依赖 功能分支已经同步到 GitHub 和 GitLab：\nfeature/primary-replica-replication 命令来源设计 # 下一阶段首先要解决 Replica 的只读限制。\n同样一条写命令：\nSET name jasper 需要根据来源决定是否允许执行：\n普通客户端 → Replica // 拒绝 Snapshot / AOF → Replica // 允许恢复 Primary → Replica // 允许同步 因此准备在 kvstore.h 中定义：\ntypedef enum { KVS_COMMAND_SOURCE_CLIENT = 0, // 普通客户端发送的命令 KVS_COMMAND_SOURCE_RECOVERY, // Snapshot 或 AOF 恢复命令 KVS_COMMAND_SOURCE_REPLICATION // Primary 同步给 Replica 的命令 } kvs_command_source_t; 这里使用 RECOVERY，而不是只叫 AOF，因为启动恢复流程同时包含：\nSnapshot 加载 ↓ AOF 重放 两者都是内部恢复命令。\n截至当前，这个命令来源枚举属于下一阶段设计，还没有接入命令执行流程。后续需要让协议层明确接收 source，不能简单复用现有的 aof_replaying 标志来冒充复制状态。\n截至当前的完成情况 # 已经完成并提交：\n确定标准主从架构 明确 Primary / Replica 角色语义 明确 Snapshot + AOF 的全量同步方向 明确 replication offset 的用途 创建主从复制功能分支 新增 replication.h 定义 STANDALONE / PRIMARY / REPLICA 定义服务端启动配置 规范端口字符串解析 支持三种启动参数 完成非法参数验证 通过完整构建 提交并推送 GitHub、GitLab 功能分支 尚未实现：\nReplica 只读限制 命令来源接入协议执行流程 Primary 复制端口监听 Replica 主动连接 Primary 复制握手协议 Replication ID 自动生成和传输 Snapshot Snapshot 后续 AOF 追赶 在线增量复制 ACK 与心跳 状态机 断线重连与部分同步 数据库锁和复制状态锁 ROLE 状态查询 主从集成测试 当前程序虽然能够识别：\n./bin/kvstore primary 2000 2100 ./bin/kvstore replica 2001 127.0.0.1 2100 但角色尚未改变后续运行路径。Primary 还没有监听 replication_port，Replica 也还没有连接 primary_host:replication_port。\n下一阶段 # 下一阶段从命令来源开始，让角色第一次真正影响程序行为：\n识别客户端命令 识别启动恢复命令 识别主从复制命令 ↓ Replica 拒绝客户端写命令 ↓ Replica 允许恢复与复制写命令 完成只读边界后，再建立独立复制连接和握手协议。只有握手与传输边界确定后，才能安全加入自动 Snapshot 全量同步和后续 AOF 增量追赶。\n第二阶段：让命令携带来源 # 第一阶段完成了角色和启动参数，但当时角色还只是配置：\n程序知道自己是 Primary 或 Replica 但命令执行层还不知道命令从哪里来 Replica 的“只读”不是任何时候都不能修改数据，而是不能被普通客户端直接修改。Replica 至少需要接受下面两类内部写入：\nSnapshot / AOF 恢复写入 Primary 发送的复制写入 所以，同样一条命令：\nHSET name jasper 可能有三种不同语义：\nCLIENT // 普通客户端请求 RECOVERY // Snapshot 或 AOF 恢复 REPLICATION // Primary 同步给 Replica 这也是为什么只判断命令是不是写命令还不够。正确的权限判断至少需要同时知道：\n当前进程是什么角色 这一条命令来自哪里 这一条命令是读还是写 命令来源类型定义在 include/kvstore.h：\ntypedef enum { KVS_COMMAND_SOURCE_CLIENT = 0, KVS_COMMAND_SOURCE_RECOVERY, KVS_COMMAND_SOURCE_REPLICATION } kvs_command_source_t; 这个枚举不是状态机。它只描述一条命令的来源，不保存连接状态，也不负责状态迁移。\n真正的复制状态机将在后面描述类似过程：\nDISCONNECTED ↓ CONNECTING ↓ HANDSHAKE ↓ FULL_SYNC / PARTIAL_SYNC ↓ ONLINE 为什么移除 aof_replaying # 原来的启动恢复依赖一个文件级全局变量：\nstatic int aof_replaying = 0; 启动时先切换它：\naof_replaying = 1 ↓ 加载 Snapshot ↓ 重放 AOF ↓ aof_replaying = 0 命令执行成功后，原来的 AOF 判断是：\nif(!aof_replaying \u0026amp;\u0026amp; ret == 0 \u0026amp;\u0026amp; kvs_is_write_command(cmd)){ kvs_aof_append(tokens,count); } 这个变量能够回答：\n程序现在是不是处在启动恢复阶段？ 但它不能准确回答：\n当前这一条命令究竟来自客户端、恢复流程还是复制连接？ 主从复制加入后，程序可能同时处理不同来源的命令。用一个会切换的全局开关描述命令来源，不仅表达能力不足，也容易在并发执行时产生错误。\n完成命令来源传递后，AOF 判断改为：\nif(ret == 0 \u0026amp;\u0026amp; kvs_is_write_command(cmd) \u0026amp;\u0026amp; source != KVS_COMMAND_SOURCE_RECOVERY){ kvs_aof_append(tokens,count); } 规则变成：\n命令来源 是否执行 是否追加本机 AOF CLIENT 是 是 RECOVERY 是 否 REPLICATION 是 是 恢复命令不能重新追加 AOF，否则会出现：\n从 AOF 读取命令 ↓ 执行命令 ↓ 再次追加回 AOF ↓ AOF 在恢复过程中继续增长 复制命令则不同。Replica 接收 Primary 的写命令后，除了修改内存，通常还需要写入自己的 AOF，这样 Replica 重启后才能恢复已经同步的数据。\n因此只排除 RECOVERY，没有排除 REPLICATION。\n拆分统一执行入口与适配器 # 原来的 kvs_protocol() 同时承担单条命令解析和对外回调入口。加入命令来源后，如果直接给它增加第四个参数，会破坏现有三参数回调：\nSnapshot 加载回调 AOF 重放回调 批处理中的单命令调用 因此把真正的单命令解析与执行入口改为：\nstatic int kvs_execute_command( char *msg, int length, char *response, kvs_command_source_t source ); 然后分别增加两个三参数适配器。\n客户端适配器：\nstatic int kvs_client_protocol(char *msg,int length,char *response){ return kvs_execute_command( msg, length, response, KVS_COMMAND_SOURCE_CLIENT ); } 恢复适配器：\nstatic int kvs_recovery_protocol(char *msg,int length,char *response){ return kvs_execute_command( msg, length, response, KVS_COMMAND_SOURCE_RECOVERY ); } 新的客户端调用链是：\nReactor / NtyCo / Proactor ↓ kvs_network_protocol() ↓ kvs_batch_protocol() ↓ kvs_client_protocol() ↓ source = CLIENT kvs_execute_command() ↓ kvs_filter_protocol() ↓ 具体存储引擎 + AOF 判断 新的恢复调用链是：\nkvs_snapshot_load() └── kvs_recovery_protocol() kvs_aof_replay() └── kvs_recovery_protocol() ↓ source = RECOVERY kvs_execute_command() ↓ kvs_filter_protocol() 这样既保留了原有三参数回调接口，又把命令来源明确传入了统一执行流程。\n迁移完成后，旧的 aof_replaying 定义和启动期间的所有赋值全部删除。Proactor 中已经失效的旧 kvs_protocol 声明也一并清理。\n验证恢复不会重复写 AOF # 只通过编译不能证明恢复逻辑正确，因此使用 SHA-256 比较启动前后的 AOF 内容。\n第一次记录：\nsha256sum appendonly.aof 得到：\nbd7fabf2bbcc594f77497733c6b94e67ae972606df977dfe1c61505c18250497 appendonly.aof 启动一次服务，让程序加载 Snapshot 并重放 AOF：\ntimeout 2s ./bin/kvstore 19000 重新计算 SHA-256，结果保持不变：\nbd7fabf2bbcc594f77497733c6b94e67ae972606df977dfe1c61505c18250497 appendonly.aof 这证明 RECOVERY 命令虽然被执行，但没有重新追加到 AOF。\n随后通过 Packet Sender 发送一条客户端写命令：\nHSET source_test_20260917 client\\r\\n 返回：\nOK\\r\\n AOF 的 SHA-256 变为：\nc8fbe5540cbcc88fd3fae8ae31fb2ce69d3fed6f62a06c76f324d8f0c935531b appendonly.aof 说明 CLIENT 写命令仍会正常追加 AOF。\n再次启动并恢复后，SHA-256 仍然是：\nc8fbe5540cbcc88fd3fae8ae31fb2ce69d3fed6f62a06c76f324d8f0c935531b appendonly.aof 最终完成双向验证：\nRECOVERY → 执行命令，AOF 不变化 CLIENT → 执行命令，AOF 发生变化 让命令执行层知道服务器角色 # 命令来源接入后，执行层还需要知道当前进程角色。\n启动配置 config 是 main() 的局部变量，而 kvs_filter_protocol() 位于另一个函数中，不能直接读取这个局部变量。因此在 src/kvstore.c 内保存当前进程角色：\nstatic kvs_role_t kvs_server_role = KVS_ROLE_STANDALONE; 这里的 STANDALONE 只是默认初始值。参数解析结束后统一赋值：\nkvs_server_role = config.role; 三种启动方式分别得到：\n./bin/kvstore 2000 → kvs_server_role = STANDALONE ./bin/kvstore primary 2000 2100 → kvs_server_role = PRIMARY ./bin/kvstore replica 2001 127.0.0.1 2100 → kvs_server_role = REPLICA 这个变量使用文件级 static，只在 src/kvstore.c 内可见。当前读取角色的 kvs_filter_protocol() 也在同一个文件中，因此不需要把角色变量暴露给其他模块。\n它与被删除的 aof_replaying 本质不同：\nkvs_server_role：进程启动时确定，运行期间保持不变 aof_replaying：执行过程中切换，试图描述临时命令状态 服务器角色确实是进程级属性，而命令来源应该是每条命令自己的属性。\nReplica 客户端只读拦截 # 命令解析出 cmd 后、进入具体存储引擎之前，增加写权限检查：\nif(kvs_server_role == KVS_ROLE_REPLICA \u0026amp;\u0026amp; source == KVS_COMMAND_SOURCE_CLIENT \u0026amp;\u0026amp; kvs_is_write_command(cmd)){ return sprintf( response, \u0026#34;READONLY replica does not accept client writes\\r\\n\u0026#34; ); } 这是一个执行前的守卫条件。只有下面三个条件同时成立才会拦截：\n当前角色是 Replica 命令来源是普通客户端 命令属于写命令 因为这里直接 return，被拒绝的命令不会进入下面的 switch，所以不会：\n修改 Replica 内存 追加 Replica AOF 最终权限规则是：\n当前角色 命令来源 命令类型 结果 Standalone Client 写 允许 Primary Client 写 允许 Replica Client 写 拒绝 Replica Client 读 允许 Replica Recovery 写 允许 Replica Replication 写 允许 使用 Replica 模式启动：\n./bin/kvstore replica 2001 127.0.0.1 2100 Packet Sender 向 Replica 的业务端口 2001 发送：\nHSET replica_write_test blocked\\r\\n 返回：\nREADONLY replica does not accept client writes\\r\\n 向同一端口发送读命令仍能正常返回数据。随后用 Primary 模式启动，并向业务端口写入，写命令返回 OK。这证明只读判断没有误伤 Primary，也没有把 Replica 变成完全不可写的进程。\n当前 Replica 为什么仍然像单机服务 # 虽然现在能够使用下面的命令启动 Replica：\n./bin/kvstore replica 2001 127.0.0.1 2100 但当前真实执行路径仍然是：\n在 2001 启动原有客户端网络 加载本机 snapshot.db 重放本机 appendonly.aof 对客户端写命令增加 READONLY 拦截 启动参数中的：\n127.0.0.1 2100 目前只被保存到：\nconfig.primary_host config.replication_port 还没有被用于地址解析、建立 socket 或调用 connect()。\n同理，Primary 虽然能够使用：\n./bin/kvstore primary 2000 2100 但当前只在 2000 上启动原有业务网络，尚未在 2100 上监听 Replica。\n因此当前测试证明的是：\n角色识别正确 命令来源正确 Replica 客户端只读正确 它不能证明：\nPrimary 和 Replica 已经建立连接 数据已经能够跨进程同步 Packet Sender 测试 Replica 时，目标地址应当是 Ubuntu 虚拟机的可访问地址，目标端口是 Replica 的业务端口 2001。启动参数中的 primary_host:replication_port 是未来 Replica 主动连接 Primary 时使用的另一条网络路径。\n第二阶段提交 # 命令来源重构提交：\n9c2020d refactor(replication): 引入命令来源并区分客户端与恢复命令 内容包括：\n定义 CLIENT、RECOVERY、REPLICATION 拆分统一命令执行入口 增加客户端与恢复适配器 Snapshot 和 AOF 使用恢复来源 删除 aof_replaying 清理 Proactor 中失效的旧声明 Replica 只读提交：\nb51206e feat(replication): 拒绝 Replica 客户端写命令 内容包括：\n保存当前进程角色 执行前检查角色、来源和命令类型 拒绝 Replica 客户端写命令 允许 Replica 客户端读取与内部写入 两个阶段均通过完整 make，没有编译错误和警告。相关功能还分别通过 AOF 哈希、Replica 读写和 Primary 写入测试。\n截至第二阶段的完成情况 # 目前已经完成：\n主从角色与启动参数 严格端口解析 命令来源模型 客户端与恢复执行入口 去除 aof_replaying 全局状态 Replica 客户端只读 恢复不重复追加 AOF 的哈希验证 Primary 与 Replica 权限行为测试 阶段性 Git 提交 如果按照预定的完整主从范围估算，目前整体约完成 20%。完成的是角色、命令来源和权限边界，也就是复制功能的基础层。\n尚未实现的核心复制功能：\nPrimary 监听 replication_port Replica 连接 primary_host:replication_port 复制消息帧与握手协议 Replication ID 与 offset 自动 Snapshot 全量同步 全量同步期间缓存新写命令 Snapshot 后的增量追赶 在线增量复制 ACK 与心跳 复制状态机 断线重连与部分同步 数据库锁和复制状态锁 ROLE 状态查询 主从集成测试 下一阶段：建立复制传输通道 # 下一阶段先只建立网络连接，不立即传输 Snapshot 或写命令。\nPrimary 需要同时维护两类端口：\nPrimary ├── service_port：普通客户端连接 └── replication_port：Replica 复制连接 Replica 需要同时完成两类网络行为：\nReplica ├── 在 service_port 上接收只读客户端 └── 主动连接 primary_host:replication_port 以同一台机器上的两个进程为例：\n./bin/kvstore primary 2000 2100 ./bin/kvstore replica 2001 127.0.0.1 2100 目标连接关系是：\n客户端 ───────\u0026gt; Primary:2000 客户端 ───────\u0026gt; Replica:2001 Replica ──────\u0026gt; Primary:2100 建立连接后，操作系统中应当能够看到：\nPrimary:2100 处于 LISTEN Replica 临时端口到 Primary:2100 处于 ESTABLISHED 这一阶段首先需要检查现有 Reactor、NtyCo 和 Proactor 的启动结构。当前业务网络启动函数会持续运行，不能直接在它后面顺序启动第二个监听器。需要先确定复制通道是复用同一个事件循环，还是由独立线程运行，再为三种网络模型保留一致的上层复制接口。\n连接建立只是通信基础，不等于主从同步已经完成。后续还需要在这条 TCP 连接上依次加入：\n握手 全量同步 增量追赶 在线复制 ACK 与心跳 断线恢复 第三阶段：先改造网络运行时，再承载复制连接 # 上一阶段已经明确了最终需要的三条网络路径：\n客户端 ───────\u0026gt; Primary:service_port 客户端 ───────\u0026gt; Replica:service_port Replica ──────\u0026gt; Primary:replication_port 原来的网络层只服务于第一种场景：\n一个进程 ↓ 启动一个监听端口 ↓ 所有连接使用同一个 KV 命令处理函数 这对单机 KV 已经足够，但无法直接满足主从复制：\nPrimary 需要同时监听两个端口 Replica 除了监听客户端，还要主动连接 Primary 不同端口收到的数据，需要交给不同协议处理器 复制连接断开时，还要通知复制管理器 因此这一阶段没有立刻开始发送 Snapshot，而是先把网络层从“单监听器启动函数”改造成可以组合监听器和连接器的运行时。\n这部分工作量看起来较大，但它解决的是复制功能的承载问题。没有这层基础，后续的握手、全量同步和实时命令传播都会和某一个具体网络框架紧密耦合。\n监听器是什么 # 监听器负责等待别人连接自己。它对应服务器端的典型调用流程：\nsocket() ↓ bind() ↓ listen() ↓ accept() 例如 Primary 使用两个监听器：\n监听器 1 port = 19000 handler = 客户端 KV 协议 监听器 2 port = 19100 handler = 主从复制协议 同样是 TCP 连接，进入不同端口后会走不同的协议处理流程：\nHSET name jasper → 19000 → KV 命令处理器 PING → 19100 → 复制协议处理器 这里不能继续只使用一个全局 kvs_handler，否则两个监听器无法分别绑定不同的处理函数。\nconfig 与 context 的区别 # 网络改造中出现了两类名字：\nconfig context config 表示启动任务的静态说明：\n在哪个端口监听 收到数据后调用哪个处理器 主动连接哪个主机和端口 连接建立或断开时调用哪个回调 context 表示网络框架真正运行时需要保存的数据：\n当前使用的 fd 当前监听器对应的 handler 连接上下文内存池 连接关闭回调 正在连接的目标地址 可以把它们理解为：\nconfig = 任务说明书 context = 执行任务时背着的运行时背包 通用配置不包含 NtyCo 的内部实现细节。进入 NtyCo 后，再转换为 NtyCo 自己的 context：\nkvs_listener_config_t ↓ ntyco_listener_context_t kvs_connector_config_t ↓ ntyco_connector_context_t 这样以后 Reactor 和 Proactor 可以使用同一份上层配置，但各自创建不同的运行时 context。\n统一网络层消息处理函数 # 原来的 msg_handler 在 kvstore.h、Reactor、Proactor 和 NtyCo 中有多份重复定义。复制端口接入后，消息处理函数已经属于网络层与上层协议之间的公共契约，因此统一放入：\ninclude/server.h 最终接口增加了 connection_fd：\ntypedef int (*msg_handler)( int connection_fd, char *msg, int length, char *response, int response_capacity, int *consumed_length ); 普通客户端协议暂时不关心具体连接，可以忽略：\n(void)connection_fd; 复制协议则必须知道是哪一条连接，因为后续需要为每台 Replica 保存：\n连接 fd 同步状态 确认 offset 待发送数据 因此 connection_fd 不是把 socket 逻辑塞进业务层，而是为有连接身份需求的上层协议提供最小必要信息。\nNtyCo 监听器上下文 # 通用监听器配置定义为：\ntypedef struct { unsigned short port; msg_handler handler; } kvs_listener_config_t; NtyCo 将它转换为运行时上下文：\ntypedef struct { unsigned short port; msg_handler handler; memory_pool_t connection_pool; } ntyco_listener_context_t; 三项数据分别表示：\nport 当前监听器绑定的端口 handler 这个端口收到消息后使用的协议处理器 connection_pool 为 accept 出来的连接分配上下文 监听协程 server() 从 context 取出端口并创建监听 socket。每次 accept() 成功后，再为该连接创建独立的连接上下文。\n为什么每条连接还需要 connection context # 连接上下文定义为：\ntypedef struct { int fd; msg_handler handler; kvs_connection_close_handler close_handler; memory_pool_t *pool; } ntyco_connection_context_t; 它保存一条已经建立好的 TCP 连接运行时信息：\nfd 这条连接对应的 socket handler 收到数据后调用谁 close_handler 断开时通知谁 pool 这个 context 应该归还到哪个内存池 最初代码把循环中的局部变量地址直接交给协程：\nint cli_fd = accept(...); nty_coroutine_create(\u0026amp;read_co, server_reader, \u0026amp;cli_fd); 这里存在生命周期问题。cli_fd 是 server() 循环中的局部变量，下一轮 accept() 会继续修改它，而新建协程并不一定立刻读取该地址。多个协程可能最终看到同一个或已经变化的 fd，从而产生段错误或连接串线。\n改造后，每次连接都从固定块内存池申请独立 context：\nntyco_connection_context_t *connection = memory_pool_alloc(\u0026amp;context-\u0026gt;connection_pool); connection-\u0026gt;fd = cli_fd; connection-\u0026gt;handler = context-\u0026gt;handler; connection-\u0026gt;close_handler = NULL; connection-\u0026gt;pool = \u0026amp;context-\u0026gt;connection_pool; 随后把 connection 交给读取协程。读取协程先复制其中需要长期使用的字段，再把 context 归还内存池：\n连接建立 ↓ 从内存池取出 connection context ↓ 创建 server_reader 协程 ↓ server_reader 保存 fd 和回调 ↓ context 归还内存池 这个改造修复了实际测试中出现的 Segmentation fault。通过 Packet Sender 多次建立新连接并执行 HSET、HGET 后，服务不再崩溃。\n为什么连接上下文使用专用内存池 # 连接上下文是固定大小、频繁申请和释放的对象，符合现有固定块内存池的使用条件：\n对象尺寸固定 生命周期短 申请释放频繁 不需要单独扩缩容 每个监听器持有自己的连接上下文池：\nlistener context └── connection_pool ├── connection context ├── connection context └── ... server() 负责从这个池申请，server_reader() 需要知道归还到哪个池，因此 pool 指针也被保存在 connection context 中。\n当前每个 chunk 预分配 64 个连接上下文：\n#define NTYCO_CONNECTION_CONTEXT_BLOCKS_PER_CHUNK 64 这与 Hash、RBTree、SkipList 的节点池用途类似，但属于网络连接生命周期，不与存储引擎节点池混用。\nNtyCo 多监听器启动 # Primary 需要同时启动业务监听器和复制监听器，因此增加：\nint ntyco_start_listeners( const kvs_listener_config_t *listeners, size_t listener_count ); 调用方传入监听器配置数组：\nkvs_listener_config_t listeners[] = { { .port = config.service_port, .handler = kvs_network_protocol }, { .port = config.replication_port, .handler = kvs_replication_network_protocol } }; NtyCo 为每份配置创建独立 listener context，再为每个 context 创建一个 server 协程：\nserver 协程 1 → bind/listen service_port server 协程 2 → bind/listen replication_port ↓ nty_schedule_run() 两个 server() 都包含长期运行的 accept() 循环，但使用 nty_accept() 后，等待连接的协程会主动让出执行权，不会阻塞另一个监听器。\n这一点也解释了为什么 NtyCo 网络文件必须使用：\nnty_socket nty_accept nty_recv nty_send nty_close 如果直接使用系统的阻塞式 accept()，第一个监听协程可能会卡住整个调度线程，第二个端口就无法真正开始工作。\n通用按行批处理器 # 客户端协议之前已经处理过 TCP 半包和粘包，但复制协议最初只处理一条 PING。如果一次 TCP 接收得到：\nPING\\r\\nPING\\r\\n 复制协议也应该返回：\nPONG\\r\\nPONG\\r\\n 如果只收到：\nPI 则不能提前报错，需要把数据保留到下一次 recv()。\n因此将 CRLF 拆帧逻辑从具体 KV 协议中提取为通用网络缓冲能力：\ntypedef int (*kvs_frame_handler)( int connection_fd, char *frame, int frame_length, char *response, int response_capacity ); 通用批处理器负责：\n查找 \\r\\n 识别完整帧 保留不完整半包 连续处理粘在一起的多帧 累加多个响应 计算 consumed_length 单帧处理器只负责：\n这条完整命令是什么意思 应该返回什么响应 于是客户端协议和复制协议都可以复用：\n客户端 TCP 数据 ↓ kvs_line_batch_protocol() ↓ kvs_client_protocol() 复制 TCP 数据 ↓ kvs_line_batch_protocol() ↓ kvs_replication_frame_protocol() 公共批处理器属于网络缓冲层，具体 KV 命令和复制命令仍然属于各自业务模块，因此解耦关系没有被破坏。\n复制管理器 # 复制模块增加了进程级管理器：\nkvs_replication_manager_t 它不是网络连接本身，也不是启动参数，而是复制子系统运行期间的长期状态账本。\n当前保存：\nrole 当前进程角色 replication_offset 当前复制进度 primary_host Replica 的上游地址 primary_port Replica 的上游复制端口 primary_connection_fd 当前上游 socket state 当前复制阶段 initialized 是否已经初始化 三类数据需要明确区分：\nkvs_server_config_t main() 启动时解析出的配置 kvs_replication_manager_t 复制模块在整个进程生命周期中保存的业务状态 ntyco_connector_context_t NtyCo 执行 connect() 时使用的网络运行时数据 Replica 初始化时，复制管理器会复制 primary_host，而不是永久保存 argv 指针：\nconfig.primary_host ↓ memcpy，包含结尾 \\0 replication_manager.primary_host Standalone 和 Primary 没有上游节点，因此对应字段保持为空：\nprimary_host = \u0026#34;\u0026#34; primary_port = 0 primary_connection_fd = -1 复制 offset 初始值暂时来自当前 AOF 文件位置：\nkvs_aof_get_offset() 这为后续使用 Snapshot offset 和增量 AOF 打下基础，但仍然保留了复制 offset 与 AOF offset 的概念边界。\n当前复制状态机 # 复制状态枚举已经定义为：\nDISCONNECTED 尚未建立复制连接 CONNECTING Replica 正在连接 Primary HANDSHAKE 已连接，正在交换同步信息 FULL_SYNC 正在传输并加载 Snapshot CATCH_UP 正在补齐 Snapshot 之后的增量命令 ONLINE 已经追平，持续接收实时写命令 目前真正走通的状态范围是：\nDISCONNECTED ↓ connect() HANDSHAKE ↓ PING / PONG 复制 TCP 连接保持建立 FULL_SYNC、CATCH_UP 和 ONLINE 已经有类型定义，但还没有实现对应动作。状态枚举不是完整状态机本身；只有当事件、状态判断和状态转换逻辑都接入后，才构成真正运行的状态机。\nPrimary 复制端口的最小协议 # 为了先验证两端通信，在复制端口实现最小 PING/PONG 协议：\nReplica → Primary：PING\\r\\n Primary → Replica：PONG\\r\\n Primary 的复制端口使用：\nkvs_replication_network_protocol 它先调用通用按行批处理器，再将每条完整帧交给复制单帧处理器。未知复制命令返回：\nERROR unknown replication command\\r\\n 这个 PING/PONG 不是 TCP 三次握手。TCP 三次握手由内核在 connect() 和 accept() 过程中完成；这里是建立 TCP 连接后的应用层协议探活，作用是确认：\n连接到的确实是复制协议端口 双方能够按照约定收发完整帧 通用批处理能够正确处理复制消息 监听器与连接器 # 监听器和连接器是方向相反的网络任务：\nlistener 等待别人连接自己 bind + listen + accept connector 主动连接别人 socket + connect Primary 的复制端口属于 listener：\nPrimary:19100 等待 Replica Replica 到 Primary 的复制链路属于 connector：\nReplica 主动连接 127.0.0.1:19100 通用连接器配置定义了：\nhost 目标主机 port 目标端口 open_handler 连接建立后做什么 message_handler 收到数据后交给谁 close_handler 连接断开后通知谁 复制管理器负责生成这份业务配置，NtyCo 只负责执行网络动作：\n复制模块 决定连接谁、发送什么、如何解释响应 ↓ connector config NtyCo 执行 socket、connect、send、recv 这样复制模块不需要直接依赖 NtyCo，网络层也不需要理解 PING、PONG 或复制状态。\n为什么需要 open、message 和 close 三类回调 # 连接生命周期包含三个不同事件：\n连接建立 收到消息 连接断开 复制模块分别处理：\nopen_handler 保存 primary_connection_fd 状态进入 HANDSHAKE 生成 PING\\r\\n message_handler 通过通用批处理拆帧 识别 Primary 返回的 PONG close_handler 将 primary_connection_fd 重置为 -1 状态退回 DISCONNECTED 保留 host、port 和 offset，为重连做准备 网络层只在正确时间调用回调，不直接修改复制管理器内部状态。\n统一连接关闭路径 # 原来的 server_reader() 在多个分支中直接关闭 fd：\n输入缓冲区已满 协议处理失败 缓冲区消费失败 发送失败 对端主动断开 recv 发生错误 复制连接断开时还需要触发 close_handler。如果每个分支分别关闭和通知，很容易出现：\n重复 close 某条错误路径漏掉通知 回调调用两次 新增分支时忘记清理 因此读取协程改为统一退出路径：\n各类错误或断开 ↓ break / goto connection_closed ↓ nty_close(fd) ↓ close_handler(fd) 这里使用向下跳转到清理标签的 goto，目的是从“发送循环嵌套在接收循环”的位置一次跳出两层，并集中释放资源。它不是任意跳转业务流程，而是 C 项目中常见的统一错误清理方式。\nNtyCo 统一运行时 # 只有多监听器仍然不够，因为 Replica 同时需要：\n一个 listener：监听自己的客户端端口 一个 connector：主动连接 Primary 因此增加统一运行时入口：\nint ntyco_start_runtime( const kvs_listener_config_t *listeners, size_t listener_count, const kvs_connector_config_t *connectors, size_t connector_count ); 它的执行流程是：\n初始化所有 listener context ↓ 初始化所有 connector context ↓ 为 listener 创建 server 协程 ↓ 为 connector 创建主动连接协程 ↓ nty_schedule_run() 当前限制为：\n#define NTYCO_MAX_LISTENER_COUNT 2 #define NTYCO_MAX_CONNECTOR_COUNT 1 也就是当前一个进程最多使用两个监听器和一个主动连接器。对现阶段的一主一从足够：\nPrimary：2 listeners + 0 connector Replica：1 listener + 1 connector Standalone：1 listener + 0 connector ntyco_start_listeners() 被保留为兼容适配器：\nreturn ntyco_start_runtime( listeners, listener_count, NULL, 0 ); 它没有维护第二套实现，只是让原有只需要监听器的代码不必立刻改成四参数调用。\nReplica 主动连接 Primary # Replica 启动时，从复制管理器构造连接器配置：\nkvs_replication_build_connector_config(\u0026amp;connector); 然后把客户端 listener 和 Primary connector 一起交给 NtyCo：\nntyco_start_runtime( \u0026amp;listener, 1, \u0026amp;connector, 1 ); 连接器协程执行：\n解析 Primary IPv4 地址 ↓ nty_socket() ↓ nty_connect(primary_host, primary_port) ↓ 调用 open_handler 生成 PING ↓ 处理 partial send，完整发送 PING ↓ 复用 server_reader 接收后续数据 连接成功后构造的 connection context 不来自监听器内存池，因此：\npool = NULL server_reader() 已经支持这种情况：只有 pool 非空时才归还内存池。这样监听器接收的连接和连接器主动建立的连接可以复用同一套接收、拆包、协议分发和发送逻辑。\n当前地址转换使用：\ninet_pton(AF_INET, primary_host, ...) 所以当前支持 IPv4 字面地址，例如：\n127.0.0.1 192.168.1.20 暂时不支持域名和 IPv6。后续如果需要支持：\nprimary.example.com ::1 应将地址解析替换为 getaddrinfo()。\n127.0.0.1 在测试中的意义 # 下面的 Replica 启动命令：\n./bin/kvstore replica 19001 127.0.0.1 19100 参数含义是：\n19001 Replica 的客户端服务端口 127.0.0.1 Primary 所在主机 19100 Primary 的复制端口 127.0.0.1 表示当前机器自己。测试时 Primary 和 Replica 都运行在同一台 Ubuntu 虚拟机中，因此使用回环地址。\n如果 Primary 位于另一台机器，例如 192.168.1.20，则 Replica 应使用：\n./bin/kvstore replica 19001 192.168.1.20 19100 Primary 不需要填写自己的主机地址，因为它执行的是：\nbind(INADDR_ANY, replication_port) listen() Replica 是主动发起连接的一方，所以必须同时知道目标主机和目标端口。\n为什么测试时使用两个独立目录 # 当前持久化文件使用相对路径：\nsnapshot.db appendonly.aof 如果在同一个项目目录中同时启动 Primary 和 Replica，两个进程会直接打开同一份文件：\nPrimary ─┐ ├── ~/dev/91kvstore/appendonly.aof Replica ─┘ 这不是主从同步，而是两个进程错误地共享本地文件，还可能造成内容交错和状态污染。\n因此测试时分别创建临时工作目录，同时使用绝对路径运行同一个二进制文件。\nPrimary：\ncd \u0026#34;$(mktemp -d /tmp/91kv-primary.XXXXXX)\u0026#34; /home/jaspersao/dev/91kvstore/bin/kvstore primary 19000 19100 Replica：\ncd \u0026#34;$(mktemp -d /tmp/91kv-replica.XXXXXX)\u0026#34; /home/jaspersao/dev/91kvstore/bin/kvstore replica 19001 127.0.0.1 19100 由此形成：\n/tmp/91kv-primary.xxxxxx/ ├── snapshot.db └── appendonly.aof /tmp/91kv-replica.xxxxxx/ ├── snapshot.db └── appendonly.aof 两个节点的数据是否一致，必须通过复制协议实现，而不能依赖共享文件。\n自动连接测试 # Primary 启动后输出：\nlisten port : 19000 listen port : 19100 Replica 启动后输出：\nlisten port : 19001 connect to primary 127.0.0.1:19100 使用 ss 检查得到三条监听：\nPrimary pid=8282 LISTEN 19000 Primary pid=8282 LISTEN 19100 Replica pid=8371 LISTEN 19001 复制连接同时存在两个方向的 socket 视图：\nReplica 127.0.0.1:35810 → 127.0.0.1:19100 ESTAB Primary 127.0.0.1:19100 → 127.0.0.1:35810 ESTAB 35810 是操作系统临时分配给 Replica 的本地端口，不需要写入启动参数。\n这个结果证明：\nPrimary 两个监听器能够并行工作 Replica listener 与 connector 能够并行工作 Replica 已主动连接 Primary 复制端口 PING/PONG 后连接保持建立 随后使用 Packet Sender 连接 Replica 的业务端口 19001，验证：\n读命令仍能正常处理 客户端写命令仍返回 READONLY 复制连接不会阻塞普通客户端连接 需要再次强调：此时只证明复制传输通道已经建立，并不代表数据已经完成同步。\n第三阶段提交 # 这一阶段不是一次完成，而是按网络基础设施逐步提交。\nNtyCo 监听器与连接上下文：\n020475e refactor(ntyco): 引入监听器与连接上下文 NtyCo 多监听器：\nf88c029 feat(network): 支持 NtyCo 多监听器启动 复制运行时基础设施：\n8dfbdb6 feat(replication): 建立复制运行时基础设施 复制端口与通用批处理：\nea7311e feat(replication): 接入复制端口与通用批处理 Replica 主动连接 Primary：\n46805dd feat(replication): 实现 Replica 主动连接 Primary 这些提交分别解决：\n连接上下文生命周期与段错误 一个 NtyCo 调度器启动多个监听器 复制管理器、状态和 offset 基础 复制端口、半包、粘包与 PING/PONG Replica 主动连接与断开通知 截至第三阶段的架构 # 目前主进程启动后的结构是：\nmain() ├── 解析 kvs_server_config_t ├── 初始化 KV Engine ├── 加载 Snapshot ├── 重放 AOF ├── 打开 AOF ├── 初始化 replication manager └── 启动网络运行时 Primary 的网络结构：\nPrimary ├── listener:19000 │ └── kvs_network_protocol │ └── 客户端 KV 命令 │ └── listener:19100 └── kvs_replication_network_protocol └── PING / PONG 与后续同步协议 Replica 的网络结构：\nReplica ├── listener:19001 │ └── kvs_network_protocol │ └── 客户端读命令 │ └── 客户端写命令返回 READONLY │ └── connector → Primary:19100 ├── open_handler：发送 PING ├── message_handler：处理 PONG └── close_handler：状态退回 DISCONNECTED 网络通用层与业务协议层的边界是：\nNtyCo 负责 socket、协程、收发、连接生命周期 buffer.c 负责 CRLF 拆帧、半包、粘包和批量响应 kvstore.c 负责客户端命令来源、权限和存储引擎执行 replication.c 负责复制角色、状态、握手和后续同步决策 截至当前的完成比例 # 按照完整的一主一从目标估算，目前整体约完成 50%。\n已经完成：\n三种启动角色 严格参数解析 命令来源模型 Replica 客户端只读 恢复命令不重复追加 AOF Primary 客户端端口与复制端口 通用 CRLF 批处理 NtyCo 多监听器 NtyCo 主动连接器 复制管理器基础 Replica 自动连接 Primary PING/PONG 应用层探活 连接断开通知 独立目录双进程测试 从不同角度看：\n网络与运行时基础：约 80% 真正的数据复制：约 10% 完整主从目标：约 50% 尚未实现：\nPSYNC 同步协商 Replication ID Snapshot 全量传输 Replica 接收并加载 Snapshot 全量同步期间缓存新增写命令 Snapshot 后的 AOF 增量追赶 ONLINE 实时命令传播 ACK offset 断线自动重连 复制 backlog 与部分同步 Reactor / Proactor 的完整复制运行时 主从集成测试与异常测试 下一阶段：PSYNC 同步协商 # 现在已经有一条稳定的 TCP 通道：\nReplica ─────────────\u0026gt; Primary:replication_port 下一步不直接发送 Snapshot，而是先让双方协商同步方式。Replica 将发送类似：\nPSYNC ? -1\\r\\n 其中：\n? 表示 Replica 还不知道 Primary 的 replication ID -1 表示 Replica 没有可继续使用的旧复制 offset Primary 收到后，需要根据自己的 replication ID、当前 offset 和可保留的增量范围作出决定：\n无法部分同步 ↓ FULLRESYNC \u0026lt;replication-id\u0026gt; \u0026lt;offset\u0026gt; ↓ 进入 Snapshot 全量同步 可以部分同步 ↓ CONTINUE \u0026lt;offset\u0026gt; ↓ 从指定位置发送增量复制流 第一版会先走通 PSYNC ? -1 到 FULLRESYNC，也就是首次连接的全量同步协商。等 Snapshot 与增量追赶完成后，再补充断线重连时的部分同步。\n下一阶段仍然按小步实现：\n1. 定义 replication ID 2. 定义 PSYNC 请求格式 3. Replica 在 PONG 后发送 PSYNC 4. Primary 解析 PSYNC 5. Primary 返回 FULLRESYNC 6. 状态从 HANDSHAKE 进入 FULL_SYNC 只有当这段协商稳定后，才开始设计 Snapshot 的文件头、长度、传输边界和加载流程。\n第四阶段：首次 PSYNC 全量同步协商 # 上一阶段已经解决了“Replica 怎样主动找到并连接 Primary”的问题，但连接建立以后，双方仍然不知道应该从哪里开始同步。\n因此这一阶段先完成同步协商，而不是立刻传输数据。\n当前完整的应用层握手过程是：\nReplica Primary │ │ │── PING ───────────────────────────\u0026gt;│ │\u0026lt;──────────────────────────── PONG ─│ │ │ │── PSYNC ? -1 ─────────────────────\u0026gt;│ │ │ 生成复制快照 │\u0026lt;── FULLRESYNC \u0026lt;id\u0026gt; \u0026lt;offset\u0026gt; \u0026lt;size\u0026gt;─│ │ │ │ 进入 FULL_SYNC │ 这里的 PING/PONG 只是应用层握手，不是 TCP 三次握手。\nTCP 三次握手由操作系统和 nty_connect() 完成；复制层的 PING/PONG 用来确认：\n连接到的确实是 KVStore 的复制端口 双方能够理解当前复制协议 连接已经可以继续进行 PSYNC 协商 为什么首次同步发送 PSYNC ? -1 # Replica 首次启动时没有保存过 Primary 的复制历史，因此发送：\nPSYNC ? -1 ? ：不知道 Primary 的 replication ID -1 ：没有能够继续同步的旧 offset 这个请求实际上是在告诉 Primary：\n“我没有任何可复用的同步历史，请给我一次完整同步。” Primary 收到该请求后，第一版直接选择 FULLRESYNC。将来实现复制 backlog 后，PSYNC 还可以携带旧 ID 和旧 offset，由 Primary 判断是否能够返回 CONTINUE。\nReplication ID # Primary 初始化复制管理器时，从系统随机源读取 20 个随机字节，再将每个字节转换为两个十六进制字符：\n20 个二进制字节 ↓ 每字节转换为两个十六进制字符 40 个可打印字符 例如：\n8f39d585411b311d66ffa3dae4b43e9a08a4542a 使用 40 个十六进制字符的原因不是哈希计算更方便，而是：\n随机二进制数据可能包含 \\0、换行和不可打印字符 十六进制字符串可以安全地放入文本协议和日志 20 字节仍然提供足够大的随机空间 当前 ID 的生命周期与 Primary 进程一致：\nPrimary 启动 → 生成新的 replication ID Primary 正常运行 → ID 保持不变 Primary 重启 → 生成新的 ID Primary 重启后 ID 改变，旧 Replica 暂时不能把原有 offset 当作同一条复制历史继续使用，因此会退回全量同步。这是当前没有 backlog 和持久化复制元数据时更安全的行为。\n上游协议与下游协议 # 复制连接虽然只有一条，但两个方向收到的消息不同，因此分别使用两个单帧处理函数。\nPrimary 处理 Replica 发来的请求：\nkvs_replication_frame_protocol() ├── PING └── PSYNC ? -1 Replica 处理 Primary 发来的响应：\nkvs_replication_upstream_frame_protocol() ├── PONG └── FULLRESYNC ... 这里的 frame 不是状态，而是通用按行批处理器已经拆出来的一条完整协议消息。例如：\nTCP 输入：PONG\\r\\nFULLRESYNC ...\\r\\n 拆帧后： frame 1 = PONG frame 2 = FULLRESYNC ... 这样复制业务只处理完整消息，不需要重复处理 TCP 半包、粘包和 CRLF 边界。\nFULLRESYNC 的解析与状态变化 # Primary 最初返回：\nFULLRESYNC \u0026lt;replication-id\u0026gt; \u0026lt;offset\u0026gt;\\r\\n Replica 使用 sscanf() 按格式提取字段，并通过 %n 得到实际匹配到的位置：\nFULLRESYNC %40[0-9a-f] %lld%n 除了检查匹配字段数，还需要检查：\n解析是否完整消费了整条 frame replication ID 是否恰好为 40 个十六进制字符 offset 是否非负 当前进程是否为 Replica 当前连接是否就是复制管理器记录的 Primary 连接 当前状态是否仍处于 HANDSHAKE 校验通过后，Replica 保存 Primary ID 和同步起点，并进入：\nKVS_REPLICATION_STATE_FULL_SYNC 这次状态变化只表示：\n全量同步协商已经成功 Replica 已经准备接收 Snapshot 它并不表示 Snapshot 已经加载完成。\n这一阶段对应提交：\n1bc52f3 feat(replication): 实现首次 PSYNC 全量同步协商 第五阶段：为 Snapshot 文件传输准备元数据 # 完成 FULLRESYNC \u0026lt;id\u0026gt; \u0026lt;offset\u0026gt; 后，新的问题出现了：Primary 即使直接发送 Snapshot 文件，Replica 也不知道文件到哪里结束。\nSnapshot 是原始文件负载，文件内部本身可以出现换行，不能继续使用 CRLF 判断文件边界。\n因此协议扩展为：\nFULLRESYNC \u0026lt;replication-id\u0026gt; \u0026lt;aof-offset\u0026gt; \u0026lt;snapshot-size\u0026gt;\\r\\n 例如：\nFULLRESYNC 8f39...542a 580 2048\\r\\n 三个字段分别表示：\n8f39...542a ：Primary 的复制历史 ID 580 ：生成这份 Snapshot 时对应的 AOF 位置 2048 ：接下来 Snapshot 文件负载的总字节数 offset 与 snapshot size 为什么不能混用 # aof_offset 描述 AOF 文件中的逻辑进度：\nSnapshot 加载完以后 → 从 AOF 的哪个位置继续发送增量命令 file_size 描述 Snapshot 文件的物理长度：\nReplica 需要从网络中准确接收多少原始字节 两个值可能完全不同。假设历史中多次修改同一个键：\nHSET name A HMOD name B HMOD name C HMOD name D AOF 会保留这些历史写命令，因此 offset 会持续增长；Snapshot 只需要保存最终状态：\nHSET name D 所以 Snapshot 文件可能很小，但对应的 AOF offset 已经很大。\n全量同步需要同时拥有两个值：\nfile_size → 决定 Snapshot 接收边界 aof_offset → 决定 Snapshot 之后的增量同步起点 扩展 Snapshot 保存接口 # 原来的持久化接口只有：\nint kvs_snapshot_save(const char *path); 它只能告诉调用者成功或失败，无法返回本次生成的 Snapshot 对应 offset 和文件大小。\n现在定义：\ntypedef struct { long long aof_offset; long long file_size; } kvs_snapshot_metadata_t; 并将接口升级为：\nint kvs_snapshot_save( const char *path, kvs_snapshot_metadata_t *metadata ); 返回值仍然只负责表示：\n0 ：保存成功 -1 ：保存失败 可选输出参数 metadata 负责返回额外结果。\n普通客户端执行 SAVE 时：\nkvs_snapshot_save(\u0026#34;snapshot.db\u0026#34;, NULL); 它只关心持久化是否成功，不需要复制元数据。\nPrimary 收到 PSYNC 时：\nkvs_snapshot_metadata_t snapshot_metadata; kvs_snapshot_save( \u0026#34;replication.snapshot\u0026#34;, \u0026amp;snapshot_metadata ); 它需要同时获得：\nsnapshot_metadata.aof_offset snapshot_metadata.file_size 两个场景不是由 kvs_snapshot_save() 自己判断的，而是由调用路径区分：\n业务端口收到 SAVE → 客户端协议 → 保存 snapshot.db 复制端口收到 PSYNC ? -1 → 复制协议 → 保存 replication.snapshot 持久化模块只负责“把当前数据保存到指定路径”，不需要认识 Standalone、Primary、Replica、socket 或 NtyCo。这保持了模块边界。\nSnapshot 文件大小的取得时机 # Snapshot 内容全部写完并执行 fflush()、fsync() 后，通过 ftello() 获取当前文件位置：\n写入 Snapshot 内容 ↓ fflush ↓ fsync ↓ ftello ↓ 得到完整文件字节数 这里先将结果保存在局部变量中，而不是立即填写调用者的 metadata。\n原因是后面仍然可能发生：\nfclose 失败 rename 失败 只有所有步骤都成功以后，才把元数据交给调用者：\n临时文件完整写入 ↓ 关闭成功 ↓ rename 成功 ↓ 提交 metadata 这与事务中的“最后提交”类似：函数返回失败时，调用者不会得到一组看起来有效、实际上却没有成功发布的元数据。\nrename 在项目中的原子替换 # Snapshot 不直接写入正式文件，而是：\n先写 snapshot.db.tmp ↓ 写完、刷新、同步并关闭 ↓ rename(snapshot.db.tmp, snapshot.db) 在写新文件期间：\nsnapshot.db → 上一次完整快照 snapshot.db.tmp → 本次尚未完成的新快照 如果写入过程中进程崩溃，正式文件仍然是上一份完整快照。\n同一文件系统内的 rename() 以原子方式替换目录项，因此其他读取者只能看到：\n旧的完整 snapshot.db 或者：\n新的完整 snapshot.db 不会看到一个“只替换了一半”的正式文件。\n这里的原子性只解决文件发布问题，不代表整个数据库快照天然具备并发一致性，也不代表 Snapshot 已经通过网络发送。更严格的掉电持久性将来还可以在 rename 后同步父目录。\nPrimary 生成复制快照的时机 # Primary 在复制端口收到 PSYNC ? -1 后，先检查：\n复制管理器已经初始化 当前角色必须是 PRIMARY replication ID 已经生成 只有全部成立，才执行：\nkvs_snapshot_save(\u0026#34;replication.snapshot\u0026#34;, \u0026amp;snapshot_metadata) 因此 Replica 不会误生成 Primary 的复制快照。\n生成成功后，Primary 将同一次保存返回的两个值写入复制管理器：\nreplication_offset = snapshot_metadata.aof_offset snapshot_file_size = snapshot_metadata.file_size 随后才构造：\nFULLRESYNC \u0026lt;id\u0026gt; \u0026lt;offset\u0026gt; \u0026lt;snapshot-size\u0026gt;\\r\\n 这个顺序保证 FULLRESYNC 中的 offset 与即将传输的 Snapshot 属于同一次保存，而不是分别在两个时刻独立读取。\nReplica 保存 Snapshot 元数据 # Replica 将 FULLRESYNC 的解析格式升级为三个字段：\nFULLRESYNC %40[0-9a-f] %lld %lld%n 解析并校验成功后保存：\nreplication_id replication_offset snapshot_file_size 随后进入 FULL_SYNC。\n复制管理器中的 snapshot_file_size 初始值为 -1：\n-1 ：当前没有有效的 Snapshot 大小 \u0026gt; 0 ：已经通过 FULLRESYNC 得到本次文件大小 在复制管理器初始化和销毁时都恢复为 -1，避免上一次连接留下的旧值被下一次同步误用。\n元数据协商测试 # Primary 使用独立目录启动：\ncd \u0026#34;$(mktemp -d /tmp/91kv-primary.XXXXXX)\u0026#34; /home/jaspersao/dev/91kvstore/bin/kvstore primary 19000 19100 Replica 使用另一个独立目录启动：\ncd \u0026#34;$(mktemp -d /tmp/91kv-replica.XXXXXX)\u0026#34; /home/jaspersao/dev/91kvstore/bin/kvstore replica 19001 127.0.0.1 19100 Primary 输出：\nreplication: FULLRESYNC response ready id=8f39...542a offset=0 snapshot_size=13 fd=8 Replica 输出：\nreplication: enter FULL_SYNC id=8f39...542a offset=0 snapshot_size=13 fd=7 两边的 ID、offset 和 snapshot size 完全一致，证明第三个字段已经正确生成、传输、解析并保存。\n空库 Snapshot 大约只有：\nAOF_OFFSET 0\\n 因此测试中的 snapshot_size=13 是合理结果。\n这一阶段对应提交：\n49e0e12 feat(replication): 协商全量同步快照元数据 为什么不能直接扩大 msg_handler 缓冲区 # 当前 msg_handler 的语义是：\n网络层收到一次输入 ↓ 协议处理器生成一个小响应 ↓ 网络层发送该响应 这适合：\nPING → PONG PSYNC → FULLRESYNC HGET → value 但不适合大小不确定的 Snapshot 文件。\n如果简单把 BUFFER_LENGTH 从 1024 提高到几十 MB，会产生三个问题。\n第一，每条连接都会持有巨大的输入和输出缓冲区。当前缓冲区还是连接读取协程中的局部对象，过大的局部数组可能直接耗尽协程栈。\n第二，Snapshot 没有固定最大值。它可能从几十字节增长到数 GB，永远无法选择一个保证足够、又不会浪费内存的固定缓冲区。\n第三，msg_handler 只在网络层收到请求时调用。大文件需要在同一个请求之后连续读取并发送许多数据块，不能反复重新执行 PSYNC，否则会重复解析命令、重复生成 Snapshot。\n因此正确模型不是准备一个特别大的桶，而是反复使用同一个小桶：\n读取 Snapshot 1024 字节 ↓ 发送这 1024 字节 ↓ 继续读取下一块 ↓ 直到累计发送 snapshot_file_size 字节 控制消息与文件流需要不同的调用语义：\nmsg_handler → 解析控制命令 → 生成 PONG、FULLRESYNC 等小响应 stream_handler → 连续生产 Snapshot 数据块 → 每次只返回当前缓冲区能够容纳的字节 它们可以复用同一个小型网络输出缓冲区，但不应该混成一个“必须一次生成全部内容”的接口。\n当前进度边界 # 截至提交 49e0e12，全量同步已经做到：\n✅ Replica 主动连接 Primary ✅ PING / PONG ✅ PSYNC ? -1 ✅ Primary 生成 replication.snapshot ✅ Snapshot 保存后返回 offset 和 file size ✅ FULLRESYNC 携带 ID、offset 和 size ✅ Replica 校验并保存三项元数据 ✅ Replica 进入 FULL_SYNC 尚未做到：\n❌ Primary 发送 Snapshot 原始字节 ❌ Replica 按 snapshot_file_size 接收文件 ❌ Replica 使用临时文件原子落盘 ❌ Replica 清空旧数据并加载新 Snapshot ❌ 从 Snapshot offset 开始追赶 AOF 增量 ❌ 追平后进入 ONLINE 所以日志中的：\nenter FULL_SYNC 当前含义是“准备开始全量同步”，而不是“全量同步已经完成”。\n下一步：通用分块输出能力 # 下一步不会在 replication.c 中直接调用 nty_send()，因为那会让复制业务绑死在 NtyCo 后端。\n目标边界仍然是：\nreplication.c → 决定发送哪份 Snapshot → 提供下一块文件数据 network/ntyco.c → 负责 socket 与 partial send → 不理解 Snapshot 内容 persistence/snapshot.c → 负责 Snapshot 文件格式、生成与读取 计划在通用网络接口中加入流式输出回调：\ntypedef int (*kvs_connection_stream_handler)( int connection_fd, char *output, int output_capacity ); 预期返回语义：\n\u0026gt; 0 ：本次生成的字节数，网络层发送后继续取下一块 = 0 ：流已经完成 \u0026lt; 0 ：读取失败，网络层关闭连接 最终发送路径应当是：\nPrimary 发送 FULLRESYNC 控制行 ↓ 网络层发送控制行 ↓ stream handler 分块读取 replication.snapshot ↓ 网络层处理 partial send ↓ 累计发送 snapshot_file_size 字节 ↓ 文件流结束 Replica 端则需要在 FULL_SYNC 状态下切换输入解释方式：\nHANDSHAKE 状态 → 输入按 CRLF 控制帧解析 FULL_SYNC 状态 → 输入按 snapshot_file_size 读取原始文件字节 Snapshot 收满 → 原子发布并加载 → 进入 CATCH_UP 这也是为什么复制状态机不只是几个名字：状态决定同一条 TCP 连接上的后续字节应该按“控制协议”还是“原始文件负载”解释。\n第六阶段：Snapshot 分块传输与安装 # 前一阶段已经完成 Snapshot 元数据协商，但 Replica 只知道“接下来应该收到多少字节”，真正的文件内容还没有经过网络传输。\n这一阶段需要补齐完整数据路径：\nPrimary 的 replication.snapshot ↓ 分块读取 复制连接 ↓ 分块接收 Replica 的临时文件 ↓ 完整性确认与原子替换 Replica 的 snapshot.db ↓ 加载 Replica 内存 Engine 这里没有一次性申请与 Snapshot 等大的内存。Primary 和 Replica 都只复用固定大小的网络缓冲区，因此 Snapshot 即使继续增长，也不会让单条连接占用同等大小的内存。\n为什么为端口绑定处理函数 # Primary 同时对外提供两个端口：\nservice_port → 普通客户端协议 replication_port → 主从复制协议 虽然两个端口最终都会执行 accept()、recv() 和 send()，但收到字节后的解释方式不同。\n普通客户端端口需要处理：\nSET / GET / DEL / MOD / HSET / HGET ... 复制端口需要处理：\nPING / PSYNC / FULLRESYNC / Snapshot / AOF stream 因此监听器配置不仅保存端口，还保存与该端口对应的协议回调：\ntypedef struct { unsigned short port; msg_handler handler; kvs_connection_stream_handler stream_handler; } kvs_listener_config_t; 网络层只负责：\n在哪个端口建立连接 收到多少字节 把字节交给哪个回调 把回调产生的响应发送出去 它不需要写死“19000 是客户端端口”“19100 是复制端口”。这种做法让网络运行时保持通用，也让不同端口可以安装不同业务协议。\nstream handler 的职责 # 普通 msg_handler 是请求驱动的：\n收到一批输入 ↓ 解析命令 ↓ 产生一次响应 Snapshot 传输则需要在一条 PSYNC 请求之后连续输出多块数据。因此新增 stream_handler：\ntypedef int (*kvs_connection_stream_handler)( int connection_fd, char *output, int output_capacity ); 它每次调用只负责生产下一块数据：\n\u0026gt; 0 ：output 中有这么多字节需要发送 = 0 ：当前流已经结束 \u0026lt; 0 ：产生数据失败 NtyCo 网络层拿到正数后调用 send_all()，保证即使一次 send() 只发送了部分数据，也会继续发送剩余内容。\n因此职责边界变成：\nreplication.c → 决定现在应该输出 Snapshot 还是 AOF persistence.c → 从文件读取下一块数据 network/ntyco.c → 保证这一块数据完整写入 socket Snapshot reader # Primary 使用 kvs_snapshot_reader_t 保存一次分块读取任务的状态：\ntypedef struct { int fd; long long remaining; } kvs_snapshot_reader_t; 其中：\nfd → 当前打开的 Snapshot 文件 remaining → 还剩多少协商好的字节没有发送 打开 reader 时不仅打开文件，还通过 fstat() 校验实际文件大小必须等于 FULLRESYNC 中发送的 snapshot_file_size。\n每次读取大小取两者中的较小值：\nmin(output_capacity, remaining) 最后一块可能小于网络缓冲区。例如还剩 100 字节时，即使输出缓冲区能容纳 1024 字节，也只能读取 100 字节。\n每次读取成功后执行：\nremaining -= result 当 remaining == 0 时，本次 Snapshot 的协商字节已经全部读完。\nSnapshot writer # Replica 使用对应的 kvs_snapshot_writer_t：\ntypedef struct { int fd; long long remaining; } kvs_snapshot_writer_t; writer 的 remaining 表示还需要从网络中消费多少 Snapshot 字节。\n这也是 kvs_snapshot_writer_write() 为什么返回“实际写入字节数”。一次 TCP 接收可能同时包含：\nSnapshot 最后 100 字节 + 后续 AOF 数据 如果 writer 还差 100 字节，它只能消费输入缓冲区的前 100 字节：\nwritten = 100 剩余的 AOF 数据必须继续留在网络输入缓冲区，交给下一个复制状态处理，不能被 Snapshot writer 一并吞掉。\n临时文件、commit 与 abort # Replica 不会直接覆盖正式的 snapshot.db，而是先写入：\nsnapshot.db.replication.tmp 打开临时文件时使用：\nO_WRONLY | O_CREAT | O_TRUNC 含义分别是：\nO_WRONLY → 只写打开 O_CREAT → 文件不存在时创建 O_TRUNC → 文件已经存在时先清空 权限 0600 表示只有当前用户可以读写该临时文件。\n只有 remaining == 0，也就是协商好的 Snapshot 已经一字节不少地接收完成，才允许执行 commit：\nfsync(fd) ↓ close(fd) ↓ rename(temp_path, final_path) fsync() 保证文件内容已经交给底层存储；rename() 在同一文件系统内原子替换正式 Snapshot。其他线程或重启恢复过程不会看到只写了一半的新文件。\n如果接收、落盘、关闭或重命名失败，则执行 abort：\n关闭临时文件 unlink(temp_path) 这里的 unlink() 表示删除临时文件的目录项，避免下一次同步误用半成品。\n同一次 recv 跨越两个协议阶段 # TCP 是字节流，不会保证一次 recv() 恰好对应一条命令或一个阶段。\n例如 Replica 可能一次收到：\nSnapshot 最后 100 字节 + AOF 第一条命令 25 字节 Snapshot writer 消费前 100 字节后，输入缓冲区仍然剩下 25 字节。此时状态已经从 FULL_SYNC 切换到 CATCH_UP，剩余字节应当立即按照 AOF 命令解释。\n因此 server_reader() 在一次 recv() 后使用循环反复调用协议处理器：\n调用当前状态的 handler ↓ 根据 consumed_length 移走已消费字节 ↓ 如果仍有输入并且刚才确实消费了数据 ↓ 再次调用 handler 循环条件类似：\nwhile (input.length \u0026gt; 0 \u0026amp;\u0026amp; consumed_length \u0026gt; 0) 这让同一批 TCP 数据可以安全跨越：\nHANDSHAKE → FULL_SYNC FULL_SYNC → CATCH_UP CATCH_UP → ONLINE 同时 consumed_length == 0 时必须退出本轮处理并等待更多网络数据，否则会形成无限循环。\nNtyCo 对普通文件调用的 hook # 第一次进行 Snapshot 传输时出现了一个隐蔽问题：\nPrimary 已经生成 Snapshot FULLRESYNC 元数据也正确 stream_handler 确实被调用 但 Replica 的临时文件始终是 0 字节 继续检查发现 NtyCo 在 nty_socket.c 中实现了同名的：\nread() write() close() 这些函数原本用于让 socket I/O 自动配合协程调度，但链接后也会截获 Snapshot 模块对普通文件描述符的调用。\n结果是 Snapshot 文件读取被误当成协程 socket 读取，文件数据没有按照预期进入复制流。\n解决方式是在持久化模块操作普通文件描述符时绕过 NtyCo 的 socket hook，直接使用系统调用层完成文件 read、write 和 close。\n修复后的日志显示：\nsnapshot_reader_read result=41 remaining=0 replication: Snapshot stream completed Replica 随后能够收到 41 字节 Snapshot、完成原子安装并加载到内存。\n这个问题说明：\n相同的 fd 类型都是 int 但 socket fd 与普通文件 fd 的 I/O 语义并不相同 对 libc 系统调用做全局 hook 时，必须明确它是否能够区分两类描述符。\nSnapshot 安装回调 # 复制模块知道“Snapshot 已经完整接收”，但它不应该直接操作 Array、RBTree、Hash 和 Skiplist。\n因此初始化复制模块时安装一个回调：\ntypedef int (*kvs_snapshot_install_handler)( const char *snapshot_path ); replication.c 只在正确时机调用它：\nSnapshot 接收完成 ↓ 临时文件成功 commit ↓ 调用 snapshot_install_handler(path) 真正的 Engine 操作仍然位于 kvstore.c：\n销毁旧 Engine ↓ 初始化空 Engine ↓ 加载新的 snapshot.db 如果加载中途失败，内存里可能已经恢复了一部分 key。因此失败路径再次销毁并初始化 Engine，确保 Replica 不会对外提供“半套数据”。\n函数指针在这里实现的是控制反转：\nreplication.c 知道何时安装 kvstore.c 知道如何安装 复制模块不需要包含各个 Engine 的实现细节，kvstore 模块也不需要了解 Snapshot 网络接收过程。\n全量同步验证 # Primary 先写入：\nHSET fullsync_test primary 然后启动一个空目录中的 Replica。日志依次出现：\nreplication: enter FULL_SYNC replication: Snapshot installed, enter CATCH_UP 在 Replica 查询：\nHGET fullsync_test 返回：\nprimary 这证明数据经过了完整路径：\nPrimary 内存 → replication.snapshot → TCP 分块发送 → Replica 临时文件 → snapshot.db → Replica 内存 Engine 这一阶段对应提交：\n92c9947 feat(replication): 实现 Snapshot 全量传输与安装 第七阶段：AOF 增量追赶 # Snapshot 安装完成只表示 Replica 恢复到了 Snapshot 对应的历史时刻。\n假设生成 Snapshot 时 Primary AOF 末尾是：\noffset = 27 Snapshot 传输期间 Primary 又接受了新写入，AOF 增长到：\noffset = 80 那么 Replica 仍缺少区间：\n[27, 80) 因此 Snapshot 中保存的 AOF offset 不是“文件传输进度”，而是这个 Snapshot 对应的数据边界。\n为什么 Replica 本地 AOF 需要对齐 offset # Replica 安装的是 Primary 在 offset 27 时的数据状态。如果 Replica 本地 AOF 仍然从 0 开始追加后续命令，它的字节坐标就会与 Primary 不一致：\nPrimary 新命令位置：27 之后 Replica 新命令位置：0 之后 以后 Replica 报告“我处理到 offset 50”时，两边的 50 将不再指向相同位置。\n因此安装 Snapshot 后执行：\nkvs_aof_reset_to_offset(snapshot_offset); 它通过 ftruncate() 把 Replica 本地 AOF 调整到相同长度：\n本地文件较短 → 扩展 本地文件较长 → 截断 然后把 stdio 写入位置移动到文件末尾。后续从 Primary 收到的增量命令，就从相同 offset 继续追加。\nReplica 不需要复制 Primary offset 之前的完整 AOF，因为这部分历史效果已经包含在 Snapshot 中。对齐的是复制坐标，不是重复传输已有历史。\n独立 AOF reader # Primary 仍然使用全局 aof_fp 接受新的客户端写入。增量发送不能通过修改这个 FILE 指针的位置来读取历史，否则会干扰正常追加。\n因此新增独立 reader：\ntypedef struct { int fd; long long offset; long long remaining; } kvs_aof_reader_t; 打开时固定本轮发送区间：\n[start_offset, end_offset) 读取使用 pread()：\n从指定 offset 读取 但不修改文件描述符自身的共享位置 每次读取后：\noffset += result remaining -= result 这样 AOF 可以一边由客户端写入路径继续追加，一边由复制 reader 按固定区间读取。\n为什么 AOF 使用按行协议 # 当前 AOF 内容本身就是可重放命令：\nSET key value\\n HSET key value\\n DEL key\\n 它与复制握手控制行的区别是：\n网络控制协议 → 通常以 \\r\\n 结束 AOF 文件 → 以 \\n 结束 原来的批处理器只识别 CRLF，直接发送 AOF 后，Replica 会一直等待不存在的 \\r。\n因此通用行解析器被扩展为同时识别：\n\\r\\n \\n 解析器除了返回行正文结束位置，还返回分隔符长度：\nCRLF → delimiter_length = 2 LF → delimiter_length = 1 消费输入时执行：\nrequest_offset += frame_length + delimiter_length 这样握手控制消息和 AOF 命令可以复用同一个批量逐行处理框架。\n复制命令执行回调 # Replica 收到增量 AOF 后，需要真正执行 SET、HSET、DEL 等命令。\n但 replication.c 不应该直接依赖所有 KV Engine，因此又安装一个函数指针：\ntypedef int (*kvs_replication_command_handler)( char *msg, int length, char *response ); 调用关系是：\nreplication.c → 从复制流拆出一条 AOF 命令 → 调用 command_handler kvstore.c → 标记来源为 REPLICATION → 执行真正命令 → 更新 Replica 内存 → 追加 Replica 本地 AOF 执行成功应返回：\nOK\\r\\n 复制帧处理器验证命令执行成功后，对 Primary 返回的网络响应长度仍然是 0：\n命令响应 OK\\r\\n → 只供内部判断执行是否成功 返回 0 → 不把 OK 再发送给 Primary 这里的 0 不是“没有执行命令”，而是“没有需要写回网络的响应”。\n从 Snapshot 切换到 AOF # Primary 的流式输出函数首先读取 Snapshot：\nsnapshot_reader.fd \u0026gt;= 0 → 输出下一块 Snapshot 当 Snapshot reader 返回 0 时，说明文件已经发送完成。Primary 此时重新取得最新 AOF 末尾：\ncatch_up_end = kvs_aof_get_offset() 然后打开固定增量区间：\n[snapshot_offset, catch_up_end) 这里不能立刻把 0 返回给网络层。对于 stream_handler 来说，返回 0 原本意味着整个流结束，网络层不会再次调用它。正确做法是在同一次流任务中从 Snapshot 阶段继续进入 AOF 阶段。\n因此整体过程是：\n读取 Snapshot ↓ Snapshot 完成 打开第一轮 AOF reader ↓ 读取并发送 AOF 追赶期间 Primary 继续写入怎么办 # 第一轮 AOF 发送期间，Primary 仍可能继续增长。\n每一轮 reader 只读取打开时确定的固定区间。读完后记录：\nsent_offset 然后再次读取 Primary 最新 AOF 末尾：\nlatest_offset 根据两者关系决定下一步：\nlatest_offset \u0026lt; sent_offset → AOF 被异常截断，同步失败 latest_offset \u0026gt; sent_offset → 又产生了新命令 → 打开 [sent_offset, latest_offset) 继续追赶 latest_offset == sent_offset → 已经追平 → 发送 ONLINE \u0026lt;offset\u0026gt; 这形成一个有限的追赶循环。只要 Replica 的网络发送速度最终能够超过 Primary 的持续写入速度，它就能缩小差距并进入 ONLINE。\n如果 Primary 写入速度始终高于 Replica 的接收与执行速度，Replica 就会一直处于 CATCH_UP。这不是状态机错误，而是系统吞吐能力不足。\nONLINE 状态 # ONLINE 表示：\nSnapshot 已安装 AOF 历史欠账已补齐 Replica 当前与 Primary 对齐 复制连接仍然保持 它不表示复制工作结束。进入 ONLINE 后，Primary 新产生的写命令仍然需要沿同一条连接持续发送。\nReplica 收到：\nONLINE \u0026lt;offset\u0026gt;\\r\\n 后保存新的复制 offset，并把状态从 CATCH_UP 切换为 ONLINE。\n后续 AOF 命令仍复用增量命令处理器。它被允许在两种状态下工作：\nCATCH_UP ONLINE 区别只在于：\nCATCH_UP → 正在偿还历史欠账 ONLINE → 正在接收刚产生的实时写入 流结束与暂时没数据不是一回事 # Snapshot 是有限文件，读完后确实结束；ONLINE 复制则是长期数据流。\n当 Primary 暂时没有新 AOF 时，如果继续返回 0，网络层会理解为：\n整个 stream 已经结束 以后 Primary 即使出现新写入，也不会再次检查 AOF。\n因此流式回调增加“暂时无数据”的状态，例如：\n#define KVS_STREAM_WAIT (-2) 新的返回语义是：\n\u0026gt; 0 → 有数据需要发送 KVS_STREAM_WAIT → 当前没数据，但流仍然存活 0 → 流真正结束 -1 → 发生错误 NtyCo 网络层收到 KVS_STREAM_WAIT 后短暂休眠，再次调用 stream handler 检查是否出现新的 AOF。\n当前选择 50ms 作为简单轮询间隔：\n延迟不会太高 也不会在无数据时持续占满 CPU 这属于定时轮询，不是 TTL，也不是心跳探活。它检查的是“是否出现新的复制数据”，没有用来判断对端是否存活。\nNtyCo 非零 sleep 的调度 bug # 加入 ONLINE 轮询后出现了新的现象：\nPrimary 和 Replica 已经进入 ONLINE 但客户端向 Primary 发送命令后没有任何响应 本地请求最终得到：\ntimeout exit code = 124 问题不在 socket，也不在复制状态机，而在 NtyCo 的：\nnty_coroutine_sleep(uint64_t msecs) 原实现中，msecs == 0 的路径会调用：\nnty_coroutine_yield(co); 但非零路径只执行：\nnty_schedule_sched_sleepdown(co, msecs); nty_schedule_sched_sleepdown() 只把协程插入 sleeping 红黑树并标记为 SLEEPING，函数末尾甚至留有：\n//yield 它没有真正切换回调度器。因此复制协程虽然登记了 50ms 休眠，实际上仍继续执行下一轮循环，形成持续空转，并饿死同一线程中的其他协程。\n修复只需要一行：\n} else { nty_schedule_sched_sleepdown(co, msecs); nty_coroutine_yield(co); } 修复后：\n复制协程进入 sleeping 队列 ↓ 主动让出执行权 ↓ 其他连接协程正常运行 ↓ 50ms 到期后复制协程重新被调度 该修复已经向 NtyCo 上游提交 PR：\nhttps://github.com/wangbojing/NtyCo/pull/18 PR 标题为：\nfix: 非零时长协程休眠后让出执行权 ONLINE 实时同步验证 # Primary 和 Replica 都进入 ONLINE 后，向 Primary 写入：\nHSET live_test online_value Primary 正常返回：\nOK Replica 随后收到并执行同一条命令。再向 Replica 查询：\nHGET live_test 返回：\nonline_value 至此三条关键路径均已跑通：\nPrimary 启动前已有数据 → Snapshot 全量同步 Snapshot 之后产生的数据 → AOF CATCH_UP 追平后产生的新数据 → ONLINE 实时同步 这一阶段对应提交：\n97e0781 feat(replication): 实现 AOF 增量追赶与在线同步 当前复制架构 # 截至目前，NtyCo 后端上的完整路径是：\nReplica 连接 Primary ↓ PING / PONG ↓ PSYNC ? -1 ↓ FULLRESYNC \u0026lt;id\u0026gt; \u0026lt;offset\u0026gt; \u0026lt;snapshot-size\u0026gt; ↓ Snapshot 分块传输与原子安装 ↓ AOF [snapshot-offset, current-offset) 增量追赶 ↓ ONLINE \u0026lt;offset\u0026gt; ↓ 持续发送新追加的 AOF 模块职责保持为：\nkvstore.c → 命令执行、Engine 生命周期、Snapshot 安装 replication.c → 复制协议、状态机、发送阶段选择 persistence/snapshot.c → Snapshot 保存、reader、writer、原子提交 persistence/aof.c → AOF 追加、offset 对齐、固定区间 reader network/buffer.c → LF / CRLF 帧拆分与输入消费 network/ntyco.c → 连接、收发、partial send、流式等待 当前完成边界 # 已经完成：\n✅ 三种服务器角色与启动参数 ✅ Replica 客户端只读限制 ✅ 独立复制端口 ✅ Replica 主动连接 Primary ✅ PING / PONG 与 PSYNC 握手 ✅ replication ID 与 FULLRESYNC ✅ Snapshot 元数据协商 ✅ Snapshot 分块传输 ✅ 临时文件落盘与原子安装 ✅ Snapshot 加载进 Replica Engine ✅ Replica 本地 AOF offset 对齐 ✅ AOF 固定区间增量追赶 ✅ CATCH_UP → ONLINE ✅ ONLINE 后持续复制新写入 ✅ NtyCo 环境下的一主一从端到端验证 尚未完成：\n❌ 自动化主从集成测试 ❌ Replica 断线后的自动重连 ❌ Primary 重启后的恢复策略 ❌ 多 Replica 独立状态管理 ❌ ACK 与已确认 offset ❌ replication backlog ❌ 根据 ID 与 offset 进行部分重同步 ❌ Reactor 网络后端复制适配 ❌ Proactor 网络后端复制适配 当前的复制管理器仍然只保存一组：\nsnapshot_connection_fd snapshot_reader aof_reader 所以它本质上还是一主一从实现。支持多 Replica 时，这些状态需要从全局单例字段下沉到每条 Replica 连接自己的上下文中。\n三个网络后端意味着什么 # 项目目前有：\nReactor Proactor NtyCo 主从复制核心不需要重写三次。以下模块可以直接共享：\n复制状态机 Snapshot reader / writer AOF reader FULLRESYNC 协议 CATCH_UP 与 ONLINE 逻辑 Engine 安装与命令执行回调 另外两个网络后端需要补的是适配能力：\n同时监听客户端端口与复制端口 Replica 主动连接 Primary 安装 open / message / stream / close 回调 处理 partial send 处理 KVS_STREAM_WAIT 处理连接断开与资源清理 因此后续顺序确定为：\n1. 为 NtyCo 主从链路增加自动化集成测试 2. 使用同一套测试固定当前正确行为 3. 将复制网络接口适配到 Reactor 4. 使用同一套测试验证 Reactor 5. 最后适配 Proactor 6. 再实现断线重连、多 Replica 与部分重同步 当前项目已经越过“主从复制能不能工作”的阶段，进入“如何让它稳定、可重复验证，并覆盖全部网络后端”的阶段。\n自动化主从集成测试 # 手工使用多个终端和 Packet Sender 可以帮助观察协议过程，但不适合作为长期回归手段。每次修改网络层后，如果都要手动完成下面的操作：\n启动 Primary ↓ 写入同步前数据 ↓ 启动 Replica ↓ 等待 FULLRESYNC ↓ 检查 Snapshot 数据 ↓ 向 Primary 写入 ONLINE 数据 ↓ 再次查询 Replica ↓ 停止两个进程并清理临时目录 不仅步骤多，而且很容易因为残留进程、端口占用或旧的持久化文件得到错误结论。\n因此新增：\ntests/test_replication.sh 脚本会自动完成：\n创建相互隔离的 Primary / Replica 临时目录 启动两个 kvstore 进程 等待监听端口与复制状态就绪 验证全量 Snapshot 同步 验证 ONLINE 增量同步 检查命令返回结果 退出时停止后台进程 删除测试临时目录 日常回归命令简化为：\nmake ./tests/test_replication.sh 测试已经全部通过并提交。至此，“自动化主从集成测试”从上一节的未完成项中移除。\n为什么 Reactor 仍然需要单独适配 # 复制状态机已经在 NtyCo 下跑通，并不等于 Reactor 能直接使用它。\n共享的是业务能力：\nreplication.c 决定现在应该处理握手、Snapshot 还是 AOF persistence.c 负责生成或读取实际数据 kvstore.c 负责执行命令和安装 Snapshot 不同的是网络驱动方式。\nNtyCo 可以在一个协程中写出接近顺序代码的逻辑：\n生成一块数据 ↓ 发送 ↓ 当前没有新数据就 sleep ↓ 醒来后继续检查 Reactor 只有一个事件循环。它不能让某条连接停在函数中 sleep，否则同一个线程上的所有客户端都会被阻塞。它必须把一次完整复制拆成多个事件：\nEPOLLIN → 收到命令并生成控制响应 EPOLLOUT → 发送当前 output 缓冲区 output 发送完 → 向 stream_handler 索取下一块数据 暂时没有新 AOF → 记录等待连接和重试时间 → 返回 epoll 主循环 因此 Reactor 的适配不是重写主从复制，而是把已经完成的复制逻辑接入 epoll 的状态驱动模型。\nReactor 多监听器改造 # 原来的 Reactor 启动接口是：\nint reactor_start(unsigned short port, msg_handler handler); 内部会从 port 开始创建一组连续端口，并且全部使用同一个全局 handler。这无法表达：\nservice_port → kvs_network_protocol replication_port → kvs_replication_network_protocol 因此引入统一的监听器配置：\ntypedef struct { unsigned short port; msg_handler handler; kvs_connection_stream_handler stream_handler; } kvs_listener_config_t; 新的 Reactor 入口接收配置数组：\nint reactor_start_listeners( const kvs_listener_config_t *listeners, size_t listener_count ); Primary 传入两个监听器：\nkvs_listener_config_t listeners[] = { { .port = config.service_port, .handler = kvs_network_protocol, .stream_handler = NULL }, { .port = config.replication_port, .handler = kvs_replication_network_protocol, .stream_handler = kvs_replication_stream } }; 这样配置表达的是两个明确的端口，而不是“从某个端口开始连续监听若干端口”。\nhandler 为什么要从监听 fd 继承 # epoll 返回的是发生事件的 fd，不会额外告诉程序：\n这个连接来自客户端端口 还是来自复制端口 因此监听 socket 创建时先保存自己的业务标签：\nconn_list[sockfd].handler = listeners[i].handler; conn_list[sockfd].stream_handler = listeners[i].stream_handler; accept() 得到新连接后，再继承监听 fd 的标签：\nconn_list[clientfd].handler = conn_list[fd].handler; conn_list[clientfd].stream_handler = conn_list[fd].stream_handler; 例如：\n监听 fd 5：service_port handler = kvs_network_protocol ↓ accept 客户端 fd 8 继承 kvs_network_protocol 监听 fd 6：replication_port handler = kvs_replication_network_protocol stream_handler = kvs_replication_stream ↓ accept Replica fd 9 继承两个复制回调 随后 kvs_request() 不再调用全局 handler，而是使用当前连接自己的：\nc-\u0026gt;output.length = c-\u0026gt;handler( c-\u0026gt;fd, c-\u0026gt;input.data, c-\u0026gt;input.length, c-\u0026gt;output.data, BUFFER_LENGTH, \u0026amp;consumed_length ); 网络层仍然不知道 SET、PSYNC 或 FULLRESYNC 的含义，它只根据连接上保存的函数指针把数据交给正确的业务协议。\nReactor 接入流式输出 # 原来的 send_cb() 只支持普通请求响应：\n发送 output ↓ output 清空 ↓ 切回 EPOLLIN，等待下一条请求 复制连接不能在发送完 FULLRESYNC 后直接等待输入，因为 Replica 正在等 Primary 主动发送 Snapshot。双方都会等待对方，连接看起来正常，却不再产生任何数据。\n因此在 output 发送完之后加入：\nreactor_prepare_stream_output(\u0026amp;conn_list[fd]); 这个辅助函数只负责向 stream_handler 索取下一块数据：\n返回值 \u0026gt; 0 → 下一块 Snapshot/AOF 已放入 output → 继续关注 EPOLLOUT 返回值 = 0 → 本轮数据流结束 返回值 = -1 → 生成数据失败，关闭连接 返回值 = KVS_STREAM_WAIT → 已进入 ONLINE，但当前暂时没有新 AOF 因此 Primary 的发送过程变成：\nhandler 生成 FULLRESYNC ↓ send_cb 发送 FULLRESYNC ↓ stream_handler 生成一块 Snapshot ↓ send_cb 发送 Snapshot ↓ 继续生成下一块 ↓ Snapshot → CATCH_UP AOF → ONLINE EPOLLOUT 的真实含义 # EPOLLOUT 不是“对端已经收到数据”，也不是“业务层存在数据”。它只表示：\n当前 socket 的内核发送缓冲区还有空间， 可以尝试调用 send()。 执行：\nset_event(fd, EPOLLOUT, 0); 只是告诉 epoll 关注该 fd 的可写状态。真正的数据发送仍然发生在：\nsend_cb(fd); 而且一次 send() 仍可能只发送部分字节，因此 Reactor 继续使用：\noutput.offset += count; 记录尚未发送的区间。\nsocket 在大多数时候都是可写的。如果 ONLINE 状态没有新 AOF，却一直关注 EPOLLOUT，就会形成：\nepoll 报告可写 ↓ stream_handler 发现没有数据 ↓ 再次关注 EPOLLOUT ↓ epoll 立即再次报告可写 这会造成 CPU 空转，所以 KVS_STREAM_WAIT 不能直接继续监听 EPOLLOUT。\nReactor 中的 ONLINE 定时轮询 # NtyCo 可以让复制协程 sleep，Reactor 则使用 epoll_wait() 的 timeout 实现同样的等待效果。\n当前一主一从模型只维护一条复制流，因此记录：\nstatic int stream_waiting_fd = -1; static long long stream_retry_at_ms = -1; 两个值分别表示：\nstream_waiting_fd → 哪一条复制连接正在等待 stream_retry_at_ms → 到什么时间才允许再次检查 例如：\n当前复制 socket 为 fd 8 当前时间为 10000ms stream_waiting_fd = 8 stream_retry_at_ms = 10100 Reactor 主循环原来使用：\nepoll_wait(epfd, events, 1024, -1); -1 表示没有网络事件就永远等待。存在 ONLINE 等待任务时，需要把 timeout 改成距离重试时间剩余的毫秒数：\nlong long remaining_ms = stream_retry_at_ms - now_ms; wait_timeout = remaining_ms \u0026gt; 0 ? (int)remaining_ms : 0; 这里使用绝对截止时间，而不是每一轮固定等待完整的 100ms。原因是普通客户端事件可能提前唤醒 epoll_wait()：\n计划在 1100ms 检查复制数据 ↓ 1050ms 时客户端 fd 先发生事件 ↓ 处理完成后只剩 50ms ↓ 下一次 epoll_wait 应等待50ms，而不是重新等待100ms 到达重试时间后：\nint waiting_fd = stream_waiting_fd; stream_waiting_fd = -1; stream_retry_at_ms = -1; set_event(waiting_fd, EPOLLOUT, 0); 旧闹钟必须先清除。如果再次调用 stream_handler 后仍然没有新 AOF，send_cb() 会设置一个新的 100ms 闹钟。\n最终循环为：\n没有新 AOF ↓ 记录 fd 与100ms后的截止时间 ↓ epoll_wait 等网络事件或等待到期 ↓ 到期后将该 fd 切回 EPOLLOUT ↓ send_cb 再次检查 AOF ├── 有数据：发送数据 └── 没数据：重新设置下一次闹钟 这仍然属于定时轮询，不是心跳，也不是 TTL。当前轮询间隔为 100ms，因此 ONLINE 复制属于近实时同步，最坏会引入约一个轮询周期的额外延迟。\nReactor Primary 端到端验证 # 临时将：\n#define NETWORK_SELECT NETWORK_REACTOR 启动 Primary：\nkvstore primary 19000 19100 两个监听器均成功启动：\nlisten port : 19000 listen port : 19100 先向普通客户端端口写入：\nHSET reactor_test primary_value 返回：\nOK 再使用 Packet Sender 在复制端口发送：\nPSYNC ? -1 同一条持久 TCP 连接依次收到：\nFULLRESYNC cc1a88c488f7647f8cec7a2c5b16f3f171a6324f 32 46 AOF_OFFSET 32 HSET reactor_test primary_value ONLINE 32 这证明 Reactor Primary 已经能够发送：\nFULLRESYNC 控制帧 Snapshot 内容 固定区间 AOF ONLINE 控制帧 保持复制连接不关闭，再向客户端端口写入：\nHSET reactor_live after_online 复制连接在轮询周期内收到：\nHSET reactor_live after_online 因此 Reactor Primary 的 ONLINE 增量输出已经跑通。\nReactor 当时的阶段性边界 # 在完成 Primary 发送侧、尚未改造 Replica 主动连接器时，Reactor 的阶段性进度是：\n✅ 多监听器配置 ✅ 客户端端口与复制端口绑定不同 handler ✅ accept 后继承 handler ✅ accept 后继承 stream_handler ✅ FULLRESYNC 响应发送 ✅ Snapshot 分块发送 ✅ AOF CATCH_UP 发送 ✅ ONLINE 定时轮询 ✅ ONLINE 新增 AOF 实测发送成功 尚未完成的是 Replica 接收侧：\n❌ Reactor Replica 主动 connect Primary ❌ 连接成功后调用 open_handler 生成 PING ❌ 收到上游数据后调用 message_handler ❌ 断开后调用 close_handler ❌ Reactor 下真正的一主一从端到端验证 刚才的验证中，Packet Sender 暂时扮演了 Replica。下一阶段需要让 Reactor Replica 自己完成：\n创建 socket ↓ connect Primary ↓ 调用复制 open_handler ↓ 发送 PING / PSYNC ↓ 通过 message_handler 接收 Snapshot 与 AOF ↓ 断开时通知复制管理器清理状态 换句话说，复制协议本身已经不需要重写。接下来要补的是 Reactor 的主动连接器能力。\nReactor Replica：主动连接 Primary # NtyCo 版本中，Replica 已经能够主动连接 Primary，但 Reactor 最初只有监听器，没有连接器。也就是说，它能够接受别人连接，却不能主动连接远端服务器。\nReplica 同时需要完成两类网络行为：\nReplica ├── 被动监听 service_port │ └── 接受客户端 HGET 等读请求 │ └── 主动连接 Primary replication_port └── 请求并持续接收 Snapshot/AOF 因此为 Reactor 增加了新的运行时入口：\nint reactor_start_runtime( const kvs_listener_config_t *listeners, size_t listener_count, const kvs_connector_config_t *connectors, size_t connector_count ); 这里有两组配置：\nlisteners → 本进程需要监听哪些端口 connectors → 本进程需要主动连接哪些远端地址 对于 Reactor Replica，调用关系为：\n一个 listener → 监听 19001 客户端端口 → handler = kvs_network_protocol 一个 connector → 主动连接 127.0.0.1:19100 → open_handler = 复制连接建立处理器 → message_handler = Primary 消息处理器 → close_handler = 复制连接断开处理器 Standalone 不需要主动连接，因此旧接口仍然可以表示：\nreactor_run( listeners, listener_count, NULL, 0 ); 其中 NULL, 0 表示没有任何主动连接任务。\n为了兼容旧调用者，原来的：\nreactor_start(port, handler); 也被保留下来。它只负责构造一个 listener，然后转调新的多监听器入口，不再维护第二套事件循环。\n主动连接器的职责 # Reactor 的连接器按照以下步骤启动：\n读取 connector 配置 ↓ socket() ↓ inet_pton() 解析 Primary IPv4 地址 ↓ connect() 连接复制端口 ↓ event_register() 注册进 epoll ↓ 保存 message_handler 和 close_handler ↓ 调用 open_handler() 生成 PING ↓ 切换到 EPOLLOUT ↓ 复用 send_cb() 发送 PING 建立 TCP 连接的逻辑单独放进：\nreactor_open_connector_socket() 这个函数只负责：\n创建 socket → 解析地址 → connect → 返回 fd 它不理解复制状态，也不直接发送 PING。连接成功后的业务行为仍然由 connector 中的函数指针决定。\nopen_handler() 生成的 PING 不直接调用 send()，而是写入连接自身的 output 缓冲区：\nopen_handler 生成 PING ↓ conn_list[fd].output 保存 PING ↓ 关注 EPOLLOUT ↓ send_cb() 统一处理部分发送 这样主动连接和被动接受的连接共用相同的发送机制，不需要维护第二套 socket 输出代码。\n每条连接保存自己的生命周期回调 # 为了让 Reactor 能够管理普通客户端、复制下游连接和 Replica 上游连接，struct conn 最终需要保存：\nmsg_handler handler; kvs_connection_stream_handler stream_handler; kvs_connection_close_handler close_handler; 三者分别解决：\nhandler → 收到数据后应该按什么协议解析 stream_handler → 普通响应发送完后，是否还要继续生成 Snapshot/AOF close_handler → 连接关闭后，是否需要通知业务模块清理状态 普通客户端连接没有特殊的断开业务，因此：\nconn_list[clientfd].close_handler = NULL; Replica 到 Primary 的主动连接则保存 replication 模块提供的 close_handler。当上游连接断开时，Reactor 负责释放网络资源，随后通知 replication 模块恢复复制状态。\n所有已注册连接的关闭路径被收敛到：\nreactor_close_connection(fd); 该函数统一执行：\n从 epoll 删除 fd ↓ 关闭 socket ↓ 取消该连接的 ONLINE 等待闹钟 ↓ 清空 conn_list 中的连接状态 ↓ 最后调用保存下来的 close_handler 需要先保存 close_handler，再清空连接对象。否则清空：\nconn_list[fd].close_handler = NULL; 后就无法通知业务层。先清理、后回调也可以避免回调重入时看到一条仍处于半关闭状态的旧连接。\nReactor Replica 联调时发现的 TCP 合包问题 # 第一次真正启动 Reactor Replica 时，日志停在：\nreplication: enter FULL_SYNC ... replication: Snapshot installed, enter CATCH_UP ... 却没有出现：\nreplication: enter ONLINE ... Primary 日志已经明确显示：\nreplication: AOF catch-up completed, enter ONLINE offset=33 fd=7 因此问题不在 Primary，也不是 Snapshot/AOF 算法错误，而是 Replica 没有继续处理已经读入用户态缓冲区的数据。\nTCP 是字节流，不保留应用层消息边界。Primary 连续发送：\n[Snapshot 最后一块][ONLINE 33\\r\\n] Replica 的一次 recv() 完全可能同时得到两部分：\ninput buffer: [47 字节 Snapshot][ONLINE 33\\r\\n] 当时 Replica 处于 FULL_SYNC，所以第一次协议调用只会：\n消费47字节 Snapshot ↓ 安装 Snapshot ↓ 状态切换为 CATCH_UP 此时 input buffer 中仍然保存：\nONLINE 33\\r\\n 旧版 Reactor 的 recv_cb() 每次 EPOLLIN 只调用一次 kvs_request()，随后退出。但 ONLINE 已经从内核 socket 进入程序自己的缓冲区，内核中没有新的未读数据，因此 epoll 不会再次报告 EPOLLIN。\n结果相当于：\n快递员一次送来两个包裹 程序只拆了第一个 第二个已经放在屋里 程序却继续等待快递员再次敲门 修复方法是在一次 recv() 后，只要满足以下条件，就继续处理当前输入缓冲区：\n没有响应等待发送 并且输入缓冲区仍有数据 并且上一轮确实消费了数据 伪代码为：\nwhile (1) { int previous_input_length = input.length; process_current_buffer(); if (output.length \u0026gt; 0) break; if (input.length == 0) break; if (input.length \u0026gt;= previous_input_length) break; } 最后一个条件非常重要。如果剩余输入还不是完整消息，本轮长度不会减少，此时必须退出并等待下一次 recv()，否则会在用户态形成无限循环。\n修复后的同一次 recv() 会经历：\n第一轮：FULL_SYNC 处理 Snapshot ↓ 状态变为 CATCH_UP，缓冲区仍有 ONLINE ↓ 第二轮：CATCH_UP 处理 ONLINE ↓ 状态变为 ONLINE 这再次说明：TCP 只提供连续字节，不提供“一个 send 对应一个 recv”的消息边界。协议层必须显式维护输入缓冲区、消费长度和状态转换。\nReactor 一主一从端到端验证 # Primary 启动后监听：\nlisten port : 19000 listen port : 19100 在 Replica 连接前，先写入：\nHSET reactor_full before_replica Replica 启动后成功输出：\nlisten port : 19001 connect to primary 127.0.0.1:19100 replication: enter FULL_SYNC ... offset=33 snapshot_size=47 replication: Snapshot installed, enter CATCH_UP ... replication: enter ONLINE offset=33 ... 从 Replica 服务端口读取：\nHGET reactor_full 返回：\nbefore_replica 证明连接前已有数据已经通过 Snapshot 完成全量同步。\n随后在 Primary 的 ONLINE 阶段写入：\nHSET reactor_live after_online 再从 Replica 读取：\nHGET reactor_live 返回：\nafter_online 证明 Reactor 下的持续 AOF 增量同步也已经完成。\n自动化复制测试最终全部通过：\n[PASS] Primary started [PASS] Initial data written to Primary [PASS] Replica entered ONLINE [PASS] Snapshot full synchronization [PASS] ONLINE incremental synchronization replication integration test passed 恢复默认的 NtyCo 网络框架并重新编译后，同一套自动化测试仍然全部通过。因此本次 Reactor 改造没有破坏已经完成的 NtyCo 主从复制。\n当前复制属于异步复制 # 当前写入路径是：\n客户端写入 Primary ↓ Primary 修改内存并写本地 AOF ↓ Primary 向客户端返回 OK ↓ Replica 稍后读取并执行新的 AOF 命令 Primary 返回 OK 前不会等待 Replica 确认，因此当前实现属于异步复制，而不是同步复制。\nReactor/NtyCo/Proactor 描述的是网络 I/O 模型；同步复制/异步复制描述的是 Primary 是否等待 Replica 的确认。二者不是同一个概念：\nReactor + 异步复制 NtyCo + 异步复制 都是合理组合。\n异步复制存在一个明确窗口：\nPrimary 已向客户端返回 OK ↓ 数据尚未发送到 Replica ↓ Primary 此时宕机 故障切换后，这条已确认写入可能丢失。\n同步复制则需要新增完整的确认链路：\nPrimary 执行写命令 ↓ 发送给 Replica ↓ Replica 执行并持久化 ↓ Replica 返回 ACK offset ↓ Primary 找到对应客户端请求 ↓ Primary 才返回 OK 这还会引入超时、降级、多 Replica 确认策略、ACK 丢失和写入延迟等问题。因此当前阶段先完成异步复制是合理的工程顺序。\n为什么解耦之后三个网络框架仍然需要大量改造 # 解耦并不意味着网络层不需要修改，而是复制业务逻辑只实现一次，各网络框架分别实现同一套网络能力。\n原网络层主要面向：\n监听一个端口 ↓ 接收客户端命令 ↓ 调用一个全局 handler ↓ 返回一次响应 主从同步要求的网络能力明显更多：\n一个进程监听多个端口 不同端口绑定不同协议 Replica 主动连接 Primary 一条连接持续流式发送 Snapshot/AOF 暂时无数据时等待并再次唤醒 连接断开时通知复制模块 一次 recv 中处理跨协议阶段的数据 这些不是 Snapshot 或 AOF 的业务算法，而是原网络抽象此前没有覆盖的通用能力。\n当前解耦边界为：\nreplication.c ├── 不知道 epoll ├── 不知道协程调度 ├── 不知道 io_uring └── 只提供 open/message/stream/close 回调 网络框架 ├── 不知道 PSYNC 的业务含义 ├── 不知道 Snapshot 文件格式 ├── 不知道 AOF offset 规则 └── 只负责连接、等待、收发字节和调用回调 三个框架仍然必须分别改造，是因为等待和唤醒机制不同：\nNtyCo → 协程 sleep/yield Reactor → epoll_wait timeout + EPOLLOUT Proactor → io_uring 完成事件 无法复用的是“如何等待、如何被唤醒、如何提交和完成 I/O”；已经复用的是全部复制状态机与 Snapshot/AOF 业务规则。\n这次改动较大，本质上是在支付一次网络基础设施升级的成本。多监听器、每连接 handler、主动 connector、流式输出、WAIT、close 回调和输入缓冲连续处理完成后，未来的数据迁移、订阅推送或节点通信都可以复用这些能力。\nReactor 阶段完成 # 最终提交为：\nc3a88ec feat(network): 支持 Reactor 主从复制 当前总体进度：\n✅ Snapshot 全量同步 ✅ AOF 增量追赶 ✅ ONLINE 持续异步复制 ✅ Replica 本地 AOF offset 对齐 ✅ NtyCo 一主一从端到端验证 ✅ Reactor 一主一从端到端验证 ✅ 自动化复制回归测试 ⬜ Proactor 网络框架适配 下一阶段不需要重写复制协议，而是让 Proactor 实现已经确定的通用网络契约：\n多监听器 每连接 handler 主动 connector stream_handler KVS_STREAM_WAIT close_handler 输入缓冲连续消费 第八阶段：Proactor 主从复制适配 # Reactor 完成后，复制业务协议已经稳定。Proactor 阶段的目标不是再实现一遍 PSYNC、Snapshot 或 AOF，而是让 io_uring 网络后端满足同一套通用网络契约。\n改造前的 Proactor 只支持最简单的单端口请求响应模型：\n一个监听 fd ↓ accept 一个 client fd ↓ recv 完成 调用全局 kvs_handler ↓ send 完成 重新提交 recv 这种结构无法直接支持主从同步，原因包括：\nPrimary 需要同时监听 service_port 和 replication_port 两个端口需要绑定不同的 handler Replica 需要主动 connect Primary 复制连接需要连续发送 Snapshot/AOF ONLINE 暂时无数据时不能把复制流判定为结束 连接断开时需要通知 replication.c 一次 recv 得到的数据可能跨越多个复制状态 因此 Proactor 的改造顺序仍然遵循此前确定的原则：\n先让连接拥有自己的协议 ↓ 再支持多个监听器 ↓ 再支持流式输出与定时重试 ↓ 最后支持 Replica 主动连接 Primary io_uring 中的提交与完成 # Reactor 询问的是：\n“哪个 fd 现在可以读或可以写？” Proactor 的思路则是：\n“我要对这个 fd 执行一次 accept/recv/send/timeout， 操作完成后再把结果告诉我。” 初始化代码：\nstruct io_uring_params params; memset(\u0026amp;params,0,sizeof(params)); struct io_uring ring; io_uring_queue_init_params( ENTRIES_LENGTH, \u0026amp;ring, \u0026amp;params ); 可以把 ring 理解为用户态和内核态共同使用的一套异步任务队列：\nSQ（Submission Queue） 程序向内核提交 accept/recv/send/timeout 请求 CQ（Completion Queue） 内核把已经完成的请求结果返回给程序 一次事件循环的核心过程是：\n准备 SQE ↓ io_uring_submit ↓ 内核完成 I/O ↓ 生成 CQE ↓ 程序根据 user_data 判断完成的是哪类操作 因此 EVENT_ACCEPT、EVENT_READ、EVENT_WRITE 并不是业务状态，而是用来标记 CQE 类型的网络调度信息。\n为什么监听 fd 也需要保存 connection # 多个监听端口出现后，全局 kvs_handler 已经不够用了：\n19000 service listener → kvs_network_protocol 19100 replication listener → kvs_replication_network_protocol → kvs_replication_stream accept 的完成事件中，result.fd 是产生这次连接的监听 fd，entries-\u0026gt;res 才是新建立的 client fd：\nresult.fd = listener fd entries-\u0026gt;res = accepted client fd 因此监听 fd 需要保存自己绑定的协议配置：\nlistener_connection-\u0026gt;fd = sockfd; listener_connection-\u0026gt;handler = listener-\u0026gt;handler; listener_connection-\u0026gt;stream_handler = listener-\u0026gt;stream_handler; accept 完成后，新连接从监听连接继承这些处理器：\nconnection-\u0026gt;fd = connfd; connection-\u0026gt;handler = listener-\u0026gt;handler; connection-\u0026gt;stream_handler = listener-\u0026gt;stream_handler; 这里的 listener_connection 并不是新的结构体类型，它仍然是 struct conn。变量名只表示它当前保存的是监听 fd 的运行时状态。\n真正关键的关系是：\n监听 fd 决定“这是什么端口” ↓ accepted fd 继承“这个端口使用什么协议” 这样事件循环只需要根据 fd 找到对应的 struct conn，不需要在每次读事件中判断端口号。\nProactor 多监听器 # 提取 proactor_register_listener() 后，每个监听器的注册过程变成一个独立步骤：\n读取 listener config ↓ 创建并 bind/listen socket ↓ 创建 listener connection ↓ 保存 handler/stream_handler ↓ 提交 ACCEPT SQE proactor_run() 只创建一个 io_uring，然后把所有监听器注册进去：\nfor(size_t i=0;i\u0026lt;listener_count;i++){ proactor_register_listener( \u0026amp;ring, \u0026amp;listeners[i] ); } Primary 因此可以在同一个事件循环中同时处理两个监听端口：\nservice_port → 普通客户端命令 replication_port → PSYNC / Snapshot / AOF 多个监听器不是多个 io_uring，也不是多个线程；它们共享同一个 ring 和同一个完成事件循环。\nstart、start_listeners 与 start_runtime # Proactor 最终保留三层启动接口：\nproactor_start 一个监听器 兼容原来的 standalone 调用 proactor_start_listeners 多个监听器 Primary 使用 proactor_start_runtime 多个监听器 + 多个主动连接器 Replica 使用 这三个接口最后都进入同一个核心：\nproactor_run( listeners, listener_count, connectors, connector_count ) runtime 并不是一种新的网络模型。它表达的是完整运行时启动需求：\n被动监听客户端 + 主动连接上游 Primary + 进入同一个事件循环 Replica 必须在事件循环开始前同时完成这两类注册，因为事件循环进入后会长期等待 CQE，不会再返回启动代码继续创建上游连接。\nconnector 与 connection 的区别 # 这两个名字很接近，但处在不同层次。\nkvs_connector_config_t 是主动连接配置：\nhost port open_handler message_handler close_handler 它描述“准备连接谁，以及连接生命周期各阶段调用谁”。\nstruct conn 是连接建立后的运行时对象：\nfd input buffer output buffer handler stream_handler close_handler 它描述“这条已经存在的 TCP 连接现在处于什么状态”。\n可以概括为：\nconnector = 建立连接的蓝图 connection = 建立完成后的实例 Replica 启动时，replication.c 生成 connector config；Proactor 根据它创建并连接 socket，再把回调保存到 connection 中。\nReplica 主动连接 Primary # Proactor 的主动连接注册过程为：\n创建 socket ↓ connect Primary replication_port ↓ 创建 struct conn ↓ 安装 message_handler 和 close_handler ↓ 调用 open_handler 生成首次 PING ↓ 提交 SEND ↓ 后续通过 READ/WRITE CQE 驱动复制状态机 当前 connect() 在启动阶段仍然是同步调用。它发生在事件循环开始之前，结构清晰，但如果 Primary 不可达，启动过程缺少异步连接、超时和自动重试能力。这属于后续可以继续演进的边界。\n连接成功后，Proactor 不需要理解 PING 或 PSYNC 的含义：\nopen_handler → replication.c 生成握手数据 message_handler → replication.c 消费 Primary 返回的数据 close_handler → replication.c 得知上游连接断开 Proactor 中的流式输出 # 普通请求响应在一次 output 发送完成后就重新提交 RECV；复制连接则不同：\nFULLRESYNC 响应发送完成 ↓ 继续发送 Snapshot ↓ 继续发送 AOF catch-up ↓ ONLINE 后继续等待新 AOF 因此 WRITE 完成后的逻辑变为：\n当前 output 是否全部发送完成？ ├── 否：继续提交剩余 SEND └── 是：清空 output ↓ 调用 stream_handler proactor_schedule_stream_output() 统一处理 stream handler 的四种返回值：\n\u0026gt; 0 output 中已有下一块数据 → 提交 SEND 0 流已经结束 → 重新提交 RECV KVS_STREAM_WAIT (-2) 流没有结束，只是暂时没有新 AOF → 提交 TIMEOUT 其他负数 生成流数据失败 → 关闭连接 这个辅助函数返回的是网络层调度结果：\n1 已经提交 SEND 或 TIMEOUT 0 当前没有流任务，可以回到 RECV -1 调度失败 它把业务层的流语义转换成了 io_uring 可以执行的下一项操作。\nKVS_STREAM_WAIT 与 io_uring timeout # KVS_STREAM_WAIT 本身不属于 io_uring，它只是业务层和网络层之间的约定：\n复制尚未结束 但此刻没有新的 AOF 字节 如果收到 WAIT 后立刻反复调用 stream handler，会造成 CPU 空转。Proactor 因此向 ring 提交一个 timeout SQE：\nstream_handler 返回 WAIT ↓ 提交 100ms timeout ↓ 当前线程继续处理其他 CQE ↓ timeout 到期产生 EVENT_STREAM_RETRY ↓ 重新调用 stream_handler timeout 并没有脱离 io_uring。它与 ACCEPT、READ、WRITE 一样被提交到 SQ，完成后也从 CQ 返回。\n区别只是它等待的不是 socket I/O，而是时间到期。\ntimeout 完成时 entries-\u0026gt;res 通常是 -ETIME。对普通 I/O 来说负数通常表示失败，但对 timeout 来说 -ETIME 表示计时正常到期，因此不能把它当作连接错误。\n如果重试后仍然没有 AOF：\nstream_handler 再次返回 WAIT ↓ 再次提交一个新的 timeout 如果已经产生新 AOF：\nstream_handler 返回正数 ↓ 提交 SEND 这就形成 ONLINE 状态下的低频轮询。\n为什么异步 I/O 仍然可能被 handler 阻塞 # io_uring 使网络 I/O 异步，并不自动让业务处理并行。\n当前每个 kvstore 进程仍然主要由一个事件循环线程处理 CQE：\n取出 CQE ↓ 调用 connection-\u0026gt;handler ↓ handler 返回 ↓ 处理下一个 CQE 因此如果某个 handler 执行时间很长：\n当前 CQE 长时间未处理完 ↓ 其他已经完成的 CQE 只能留在完成队列中等待 所以当前系统同时满足：\n网络 I/O 是异步的 复制协议是异步复制 业务 handler 不是多线程并行执行 “异步”必须明确它描述的是哪一层，不能简单等同于“所有代码同时运行”。\n输入缓冲区为什么必须连续消费 # Proactor Replica 联调时，同样遇到了 TCP 没有消息边界的问题。\n一次 recv 可能同时得到：\nFULLRESYNC ...\\r\\n AOF_OFFSET ...\\r\\n Snapshot binary bytes ONLINE ...\\r\\n handler 只会消费当前状态能够解释的前缀，并通过 consumed_length 告诉网络层本次消费了多少字节。剩余数据仍然保存在用户态 input buffer 中。\n错误做法是：\n调用一次 handler ↓ 消费一部分 input ↓ 直接提交下一次 RECV 如果内核接收缓冲区暂时没有新字节，下一次 READ 不会完成；而真正需要处理的 ONLINE 可能已经留在用户态 input buffer 里，于是状态机看起来像“忘了进入 ONLINE”。\n正确做法是 proactor_process_input()：\n只要 input 中仍有数据 ↓ 调用当前 connection-\u0026gt;handler ↓ 根据 consumed_length 删除已消费前缀 ↓ 如果状态发生变化，下一轮自动使用新的状态解释剩余字节 循环停止条件包括：\nhandler 生成了 output，需要先发送响应 consumed_length \u0026lt;= 0，说明当前数据还不完整 input 已经全部消费完 handler 返回错误 这里本质上涉及两层缓冲区：\n内核 socket receive buffer recv 完成后 用户态 connection-\u0026gt;input buffer READ CQE 只表示“内核已经把一批字节搬进用户缓冲区”，不表示“用户缓冲区中的所有完整协议帧都已经处理完”。\n输出缓冲区与部分发送 # 输出也不能假设一次 SEND 就能发送全部字节。\noutput.length 本块数据总长度 output.offset 已经成功发送的长度 remaining output.length - output.offset WRITE CQE 返回正数后：\noffset += ret 如果 offset \u0026lt; length，只提交剩余部分；只有整块数据发送完成，才能清空 output 并向 stream handler 索取下一块。\n因此输入和输出分别解决两个不同问题：\ninput + consumed_length 解决拆包、合包与跨状态消费 output + offset 解决部分发送与流式续发 Proactor 统一关闭路径 # 原 Proactor 在许多分支中重复执行：\nclose(fd); free(connection); proactor_connections[fd] = NULL; 主从同步要求关闭连接时还必须调用业务层 close_handler。如果继续复制这些清理代码，很容易在某条错误路径中漏掉回调或重复释放。\n因此提取统一的：\nproactor_close_connection(fd) 它负责：\n校验 fd 与 connection 保存 close_handler 清空 fd 映射 关闭 socket 释放 connection 最后通知业务层 先保存回调再释放 connection，是因为回调函数指针本身保存在即将释放的对象里。先清空映射则可以避免同一个 fd 被重复关闭。\n网络层只负责资源生命周期；复制模块通过 close handler 更新复制状态。这个边界与 Reactor、NtyCo 保持一致。\nProactor 一主一从端到端验证 # Primary 使用两个监听端口启动：\nlisten port : 19000 listen port : 19100 在 Replica 启动前，先向 Primary 写入：\nHSET proactor_full before_replica Replica 随后主动连接 Primary：\nlisten port : 19001 connect to primary 127.0.0.1:19100 replication: enter FULL_SYNC ... replication: Snapshot installed, enter CATCH_UP ... replication: enter ONLINE ... Primary 侧对应输出：\nreplication: FULLRESYNC response ready ... replication: Snapshot stream completed ... replication: AOF catch-up completed, enter ONLINE ... 从 Replica 查询 proactor_full 能够得到 before_replica，证明 Snapshot 全量同步成功。\n在双方进入 ONLINE 后继续向 Primary 写入新键，再从 Replica 读取到该值，证明 io_uring timeout 能够重新唤醒复制流并发送新的 AOF 命令。\n最终默认 NtyCo 后端下的自动化回归测试再次通过：\n[PASS] Primary started [PASS] Initial data written to Primary [PASS] Replica entered ONLINE [PASS] Snapshot full synchronization [PASS] ONLINE incremental synchronization replication integration test passed 三种网络调度模型的最终对比 # 三种后端执行的是同一套复制状态机，但把 KVS_STREAM_WAIT 转换成不同的等待机制。\nNtyCo # stream_handler 返回 WAIT ↓ 当前连接协程 sleep/yield ↓ 调度器运行其他协程 ↓ 协程醒来后重新检查 AOF 理解重点是协程的挂起和恢复。\nReactor # stream_handler 返回 WAIT ↓ 记录 waiting fd 与 retry_at ↓ epoll_wait 使用剩余时间作为 timeout ↓ 时间到达后重新关注 EPOLLOUT ↓ send callback 再次检查 AOF 理解重点是就绪通知、事件兴趣集合与定时唤醒。\nProactor # stream_handler 返回 WAIT ↓ 提交 io_uring timeout SQE ↓ timeout 完成后产生 CQE ↓ EVENT_STREAM_RETRY 再次检查 AOF 理解重点是操作提交和完成通知。\n统一部分为：\nhandler stream_handler close_handler KVS_STREAM_WAIT 输入缓冲区消费规则 输出缓冲区续发规则 差异部分为：\n连接如何等待 何时被唤醒 读写如何注册或提交 完成结果如何返回事件循环 这正是业务逻辑和 I/O 调度解耦后的边界。\nProactor 阶段完成 # 最终提交为：\n505f937 feat(network): 支持 Proactor 主从复制 它完成了：\n✅ 多监听器共用一个 io_uring ✅ 每监听器和每连接独立 handler ✅ Primary Snapshot/AOF 流式输出 ✅ ONLINE timeout 定时重试 ✅ Replica 主动连接 Primary ✅ open/message/close 生命周期回调 ✅ 用户态输入缓冲区连续消费 ✅ 统一连接关闭路径 ✅ Proactor 一主一从端到端验证 ✅ 默认 NtyCo 自动化回归测试 主从同步功能最终合并 # 功能分支最终合并到 main：\n4c0bb7a merge: 集成主从同步 合并后执行了干净构建：\nmake clean make 所有源文件在 -Wall -Wextra 下成功编译，没有 warning。\n随后再次执行：\n./tests/test_replication.sh 全量同步与 ONLINE 增量同步均通过，说明功能分支进入 main 后仍然保持正确。\nREADME 也随之更新，不再把主从复制描述为 Roadmap，而是记录实际支持的启动方式、状态机、测试方法和当前边界。\n当前主从同步完成度 # 如果以本阶段定义的目标衡量：\n“让三个网络后端都支持基础的一主一从异步复制， 完成首次全量同步、AOF 追赶和 ONLINE 持续同步。” 该目标已经完成。\n已具备：\n✅ standalone / primary / replica 三种角色 ✅ Primary 客户端端口与复制端口分离 ✅ Replica 主动连接 Primary ✅ PING/PONG 与 PSYNC 握手 ✅ FULLRESYNC 元数据 ✅ Snapshot 分块传输与原子安装 ✅ Snapshot AOF offset 边界 ✅ AOF 增量追赶 ✅ ONLINE 实时异步复制 ✅ Replica 拒绝客户端写入 ✅ NtyCo / Reactor / Proactor 三种网络后端 ✅ 自动化全量与增量集成测试 但它仍然是学习型复制系统，而不是生产级高可用系统。\n尚未具备：\n⬜ Replica 断线自动重连与退避 ⬜ replication backlog 与部分重同步 ⬜ Replica ACK 与已应用 offset 跟踪 ⬜ 心跳和连接存活检测 ⬜ 多 Replica 的独立连接状态管理 ⬜ Primary 故障检测与 Replica 提升 ⬜ 选主、仲裁与脑裂处理 ⬜ Snapshot/AOF 后台生成，避免阻塞事件循环 ⬜ 认证、TLS、协议版本和校验和 ⬜ 三种网络后端的自动化测试矩阵与故障注入 所以更准确的评价是：\n学习项目的主从同步里程碑：完成度较高 生产级复制系统：仍处于早期阶段 去 AI 化复盘：必须能够自己讲清楚的四件事 # 这次功能代码很多，但真正需要掌握的不是每一行语法，而是四个核心模型。\n1. 状态机 # CONNECTING 建立 Replica 到 Primary 的 TCP 连接 HANDSHAKE PING/PONG、PSYNC 协商 FULL_SYNC 接收并安装 Snapshot CATCH_UP 从 Snapshot offset 开始补齐 AOF ONLINE 持续接收新产生的 AOF 命令 状态决定同一批 TCP 字节当前应该由哪个协议逻辑解释。\n2. offset # AOF offset 是字节位置，不是命令数量。\nSnapshot 中记录：\nAOF_OFFSET N 表示 Snapshot 已经包含 AOF 区间 [0, N) 的执行结果。Replica 安装 Snapshot 后只应该继续应用 [N, current_end)，否则会重复执行已经包含在快照中的命令。\n3. 缓冲区 # TCP 没有消息边界 一次 recv 可能得到半条、完整一条或多条消息 输入侧使用：\ninput.length + consumed_length 保留未消费尾部，并在同一次 READ 后持续处理已经到达用户态的数据。\n输出侧使用：\noutput.length + output.offset 处理部分发送，并确保一块完整发送后才生成下一块 Snapshot/AOF。\n4. 三种调度模型 # NtyCo 连接协程主动让出执行权，之后恢复 Reactor epoll 告诉程序 fd 已经就绪 Proactor 程序先提交 I/O 操作，io_uring 告诉程序操作已经完成 三者都调用同一套复制 handler；不同的是等待、唤醒与 I/O 完成方式。\n如果能够不看代码讲清楚这四件事，就不是只会复制 AI 生成的代码，而是已经掌握了这次改造的核心设计。\n面试版总结 # 可以用下面这段话概括整个功能：\n我在一个 C 语言 KVStore 中实现了基于 Snapshot 和 AOF offset 的 Primary-Replica 异步复制。Replica 首次连接后通过 PSYNC 进入 FULL_SYNC，安装带有 AOF byte offset 的 Snapshot，再从该 offset 进行 CATCH_UP，追平后进入 ONLINE 持续接收增量命令。复制状态机集中在 replication 模块中，三种网络后端只实现统一的多监听器、主动连接器、每连接 handler、流式输出、定时唤醒和连接生命周期契约。NtyCo 使用协程 sleep/yield，Reactor 使用 epoll timeout，Proactor 使用 io_uring timeout CQE。为解决 TCP 拆包、合包和跨状态数据，我让输入缓冲区按 consumed_length 连续消费；输出则通过 length/offset 处理部分发送。当前实现已通过三种后端的端到端验证和自动化全量、增量同步测试，但自动重连、部分重同步、ACK、心跳和故障转移仍属于后续工作。\n这段总结覆盖了：\n业务目标 复制状态机 offset 正确性 网络层解耦 三种调度差异 TCP 缓冲区问题 测试证据 当前工程边界 至此，本轮主从同步需求完成并合并到主分支。\n","date":"2026年9月29日","externalUrl":null,"permalink":"/MyBlog/p/kv-evolution-primary-replica-sync/","section":"Posts","summary":"","title":"KV 进化（五）：Primary / Replica 主从同步","type":"posts"},{"content":"","date":"2026年9月29日","externalUrl":null,"permalink":"/MyBlog/tags/primary-replica/","section":"Tags","summary":"","title":"Primary Replica","type":"tags"},{"content":"","date":"2026年9月29日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%A4%8D%E5%88%B6/","section":"Tags","summary":"","title":"复制","type":"tags"},{"content":"","date":"2026年9月14日","externalUrl":null,"permalink":"/MyBlog/tags/batch-commands/","section":"Tags","summary":"","title":"Batch Commands","type":"tags"},{"content":"实现批处理功能\n已知现在服务端发送给客户端的响应数据 已经有\\r\\n 作为TCP字节流的数据分割了 我们在 kvs_protocol 前 添加 对客户端向服务端发送指令 的处理 static int kvs_trim_command_end(char *msg,int length){ if(msg==NULL||length\u0026lt;=0){ return -1; } while(length\u0026gt;0\u0026amp;\u0026amp;(msg[length-1]==\u0026rsquo;\\r\u0026rsquo;||msg[length-1]==\u0026rsquo;\\n\u0026rsquo;)){ msg[length-1]=\u0026rsquo;\\0\u0026rsquo;; length\u0026ndash;; } return length; }\n很容易的值 是将 发送指令末尾的\\r\\n 去除 因为字节流没有区分 何时终止\n这里需要区分两种“拆分”：\nkvs_split_token() 使用空格拆分一条命令中的命令名、key 和 value。 \\r\\n 用于拆分 TCP 字节流中的多条命令。 例如：\nSET name Jasper\\r\\nGET name\\r\\n 第一层先按照 \\r\\n 拆成：\nSET name Jasper GET name 第二层再分别调用 kvs_split_token()：\nSET / name / Jasper GET / name 因此，kvs_trim_command_end() 还不是真正的批量处理。它只是让原来的单指令处理器能够接收以 \\r\\n 结尾的完整命令，避免 \\r\\n 被当作 key 或 value 的一部分。\n在一次 recv 中处理多条完整命令 # 先增加一个函数，在接收到的字节中寻找第一个 \\r\\n：\nstatic int kvs_find_crlf(const char *msg, int length) { if (msg == NULL || length \u0026lt; 2) { return -1; } for (int i = 0; i + 1 \u0026lt; length; i++) { if (msg[i] == \u0026#39;\\r\u0026#39; \u0026amp;\u0026amp; msg[i + 1] == \u0026#39;\\n\u0026#39;) { return i; } } return -1; } 返回值是 \\r 相对于当前命令起点的下标。小于 0 表示当前数据中还没有找到完整的命令结束标记；等于 0 则表示收到的是空命令 \\r\\n。\n批量处理函数维护两个偏移量：\nint request_offset = 0; int response_offset = 0; request_offset 表示请求已经处理到哪里，response_offset 表示响应缓冲区已经写入多少字节。\n每找到一条完整命令，就把 \\r 替换成 \\0，从原始接收缓冲区中临时分离出一个 C 字符串：\nchar *command_start = msg + request_offset; int remaining_length = length - request_offset; int command_end = kvs_find_crlf(command_start, remaining_length); command_start[command_end] = \u0026#39;\\0\u0026#39;; 这里的 command_start 没有创建或复制新数组，只是通过指针运算指向 msg 中当前命令开始的位置。\n随后继续复用原来的单命令函数：\nint response_length = kvs_protocol( command_start, command_end, response + response_offset ); 一条响应本身已经包含 \\r\\n。例如：\nsprintf(response, \u0026#34;OK\\r\\n\u0026#34;); 它会写入 O K 0D 0A 00，返回的有效长度是 4，不包含末尾的 \\0，但已经包含 \\r\\n。因此响应位置只需要增加：\nresponse_offset += response_length; 请求位置则需要跨过命令正文以及两个结束字节：\nrequest_offset += command_end + 2; 最终，批量函数不断寻找完整命令、调用 kvs_protocol()，并把每一条响应依次写入同一个响应缓冲区。\n接入网络入口 # 最初的网络回调只返回响应长度：\ntypedef int (*msg_handler)( char *msg, int length, char *response ); 这对于“一次 recv() 就处理全部数据”的旧模型足够，但引入持久请求缓冲区后，网络层还必须知道：协议层究竟消费了多少请求字节？\n因此回调扩展成五个参数：\ntypedef int (*msg_handler)( char *msg, // 当前累计的请求数据 int length, // 请求缓冲区有效长度 char *response, // 响应写入位置 int response_capacity, // 响应缓冲区容量 int *consumed_length // 返回已经处理的请求长度 ); 这里存在两个完全不同的长度：\n函数返回值 // 生成了多少响应字节 *consumed_length // 消费了多少请求字节 批量入口在开始时把消费长度清零：\n*consumed_length = 0; 每处理一条完整命令，request_offset 都会跨过命令正文和 \\r\\n。批量处理结束后：\n*consumed_length = request_offset; return response_offset; 网络适配函数只负责转发，不自己理解命令内容：\nstatic int kvs_network_protocol( char *msg, int length, char *response, int response_capacity, int *consumed_length ) { return kvs_batch_protocol( msg, length, response, response_capacity, consumed_length ); } 网络模型启动时统一接收这个入口：\nreactor_start(port, kvs_network_protocol); ntyco_start(port, kvs_network_protocol); proactor_start(port, kvs_network_protocol); 完整调用链可以压缩成：\n网络层接收字节 ↓ kvs_network_protocol() // 网络协议入口 ↓ kvs_batch_protocol() // 按 CRLF 取出完整命令 ↓ kvs_protocol() // 处理一条命令 ↓ kvs_split_token() // 按空格拆参数 ↓ kvs_filter_protocol() // 分发 SET、GET、DEL 等命令 ↓ 具体存储引擎 快照加载和 AOF 重放仍然使用 kvs_protocol()。它们读取的是本地持久化文件中的单条命令，不需要经过网络批量入口。\n第一阶段验证：一次 recv 中的多条命令 # 一次发送：\nGET batch_missing_1\\r\\nGET batch_missing_2\\r\\n 一次返回：\nNO EXIST\\r\\nNO EXIST\\r\\n 混合批量测试：\nSET batch_test 123\\r\\nGET batch_test\\r\\n 返回：\nOK\\r\\n123\\r\\n 这说明服务端已经能够处理一次 recv() 中存在多条完整命令的情况，也就是通常所说的“粘包”。客户端不必再严格执行“发送一条、等待一条、再发送下一条”，多条命令可以共用一次应用层网络往返。\n但是，这时只解决了“一个接收缓冲区里有多条命令”，还没有解决“一条命令分多次到达”。\n从粘包继续走向半包 # TCP 是字节流，不保存应用层消息边界。一条命令可能被拆成：\n第一次 recv：SET name Jas 第二次 recv：per\\r\\n 第一次数据中找不到 \\r\\n，协议层能够判断命令不完整，但如果网络层在下一次 recv() 时覆盖旧数据，SET name Jas 仍然会丢失。\n解决请求半包需要建立下面这条规则：\nrecv 新数据 ↓ 追加到该连接的请求缓冲区末尾 ↓ 协议层执行其中所有完整的 CRLF 命令 ↓ 删除已经处理的前缀 ↓ 保留末尾不完整的数据，等待下一次 recv 关键不是简单地“增大 recv 缓冲区”，而是让缓冲区拥有跨多次接收的生命周期，并准确维护有效长度。\n公共请求缓冲区 # 在 server.h 中定义通用输入缓冲区：\ntypedef struct kvs_input_buffer { char data[BUFFER_LENGTH]; // 保存累计请求和末尾半包 int length; // data 中当前有效字节数 } kvs_input_buffer_t; 三个核心概念必须分开：\nrecv 返回值 // 本次新收到多少字节 input.length // 当前累计有多少请求字节 consumed_length // 协议层本次处理了多少请求字节 追加接收的基本写法：\nint available = BUFFER_LENGTH - input.length; int received = recv( fd, input.data + input.length, // 从已有数据末尾继续写 available, // 防止越过数组边界 0 ); input.length += received; 为什么传给协议层的是 input.length，而不是本次 recv() 的返回值？\n第一次收到 \u0026#34;GET na\u0026#34; // received = 6，input.length = 6 第二次收到 \u0026#34;me\\r\\n\u0026#34; // received = 4，input.length = 10 第二次解析时，协议层需要看到累计的 GET name\\r\\n 共 10 字节，而不是只看到本次新收到的 4 字节。\nconsume：删除已处理前缀，保留末尾半包 # 命令执行完成，不等于命令已经从请求缓冲区中消失。kvs_handler() 负责解析和执行，但网络层仍然持有原始 input。\n因此增加公共函数：\nint kvs_input_buffer_consume( kvs_input_buffer_t *buffer, int consumed_length ) { if (buffer == NULL || consumed_length \u0026lt; 0 || consumed_length \u0026gt; buffer-\u0026gt;length) { return -1; } int remaining_length = buffer-\u0026gt;length - consumed_length; if (consumed_length \u0026gt; 0 \u0026amp;\u0026amp; remaining_length \u0026gt; 0) { memmove( buffer-\u0026gt;data, buffer-\u0026gt;data + consumed_length, remaining_length ); } buffer-\u0026gt;length = remaining_length; return 0; } 例如缓冲区内容是：\n[GET a\\r\\n][SET name Jas] 第一条命令完整，第二条命令不完整。协议层返回第一条命令的长度作为 consumed_length，随后 memmove() 把未处理部分移动到开头：\nconsume 前：[GET a\\r\\n][SET name Jas] consume 后：[SET name Jas] 如果当前只有半包：\ninput = \u0026#34;GET na\u0026#34; consumed_length = 0 调用 consume(input, 0) 不会删除任何数据，半包会原样保留。\nmemmove() 而不是 memcpy() 的原因是源区间和目标区间来自同一个数组，可能发生重叠。\n五个重要长度 # 随着功能增加，最容易混淆的是各种长度。可以统一记成：\nreceived / ret // 本次 recv 新收到的字节数 input.length // 请求缓冲区累计有效长度 available // 请求缓冲区剩余容量 consumed_length // 协议层处理掉的请求长度 wlength / slength // 协议层生成的响应长度 关系如下：\navailable = BUFFER_LENGTH - input.length; input.length += received; remaining = input.length - consumed_length; 其中：\nReactor 使用 c-\u0026gt;wlength NtyCo 使用局部变量 slength Proactor 使用 connection-\u0026gt;wlength 名字不同，但三者都表示响应缓冲区中有效的响应字节数。\nReactor 的半包接入 # Reactor 使用 conn_list[fd] 保存每个连接的状态。原来的：\nchar rbuffer[BUFFER_LENGTH]; int rlength; 替换为：\nkvs_input_buffer_t input; 接收时不再清空请求缓冲区，而是追加到末尾：\nint available = BUFFER_LENGTH - conn_list[fd].input.length; int count = recv( fd, conn_list[fd].input.data + conn_list[fd].input.length, available, 0 ); conn_list[fd].input.length += count; kvs_request() 负责连接网络层和协议层：\nint kvs_request(struct conn *c) { int consumed_length = 0; c-\u0026gt;wlength = kvs_handler( c-\u0026gt;input.data, c-\u0026gt;input.length, c-\u0026gt;wbuffer, BUFFER_LENGTH, \u0026amp;consumed_length ); if (c-\u0026gt;wlength \u0026lt; 0) { return -1; } if (kvs_input_buffer_consume( \u0026amp;c-\u0026gt;input, consumed_length) \u0026lt; 0) { c-\u0026gt;wlength = 0; return -1; } return 0; } 事件切换规则：\nwlength \u0026lt; 0 // 协议失败，关闭连接 wlength == 0 // 没有完整命令，继续监听 EPOLLIN wlength \u0026gt; 0 // 已经生成响应，切换到 EPOLLOUT Reactor 的关键是：kvs_request() 的一次调用会结束，但 conn_list[fd].input 与连接一起长期存在。\nNtyCo 的半包接入 # NtyCo 为每个客户端创建一个 server_reader() 协程。它不需要全局连接数组，因为协程函数自身的栈帧就可以保存该连接的状态。\n缓冲区必须放在 while 外面：\nvoid server_reader(void *arg) { int fd = *(int *)arg; int ret = 0; kvs_input_buffer_t input = {0}; // 整个连接期间一直存在 while (1) { // 反复接收同一客户端的数据 } } 如果把 input 定义在 while 内，每轮都会重新初始化，上一轮保存的半包就会消失。\nNtyCo 的一轮处理：\n计算 available ↓ recv 到 input 尾部 ↓ input.length += ret ↓ kvs_handler 得到 slength 和 consumed_length ↓ consume 删除完整请求 ↓ slength == 0：continue，等待下一次 recv slength \u0026gt; 0：send 响应 这里的 consumed_length 放在循环内是合理的，因为它只描述本轮协议调用的结果；而 input 必须放在循环外，因为请求数据需要跨多轮保留。\nProactor / io_uring 的半包接入 # Proactor 比前两种模型更复杂，因为 recv 和 send 是先提交、后完成的异步操作。\n原实现只有一组公共缓冲区：\nchar buffer[BUFFER_LENGTH]; char response[BUFFER_LENGTH]; 所有客户端共用它们会产生两个问题：\n客户端之间可能互相覆盖数据 异步操作尚未完成时，缓冲区可能已经被下一项操作改写 改造后使用 struct conn 保存每个客户端自己的：\nfd input wbuffer wlength 并维护 fd 到连接状态的映射：\nstatic struct conn *proactor_connections[CONNECTION_SIZE]; 接受新连接后动态创建状态：\nstruct conn *connection = calloc(1, sizeof(*connection)); connection-\u0026gt;fd = connfd; proactor_connections[connfd] = connection; 使用 calloc() 是因为它在申请 struct conn 内存后会将内容清零，使 input.length 和 wlength 从 0 开始。相比之下，malloc() 申请的内存内容是不确定的，还需要额外 memset()。\nSQE、CQE 与 user_data # 提交读取操作时，conn_info 被复制到 SQE 的 user_data：\nstruct conn_info info = { .fd = sockfd, .event = EVENT_READ, }; memcpy(\u0026amp;sqe-\u0026gt;user_data, \u0026amp;info, sizeof(info)); 操作完成后，CQE 会把相同的 user_data 带回来：\nresult.fd // 哪个客户端完成了操作 result.event // ACCEPT、READ 或 WRITE entries-\u0026gt;res // 操作结果，例如 recv 收到多少字节 user_data 可以理解为提交异步任务时贴上的标签，任务完成后内核把标签还给程序。\n收到 READ 完成事件后，通过 fd 找回连接：\nstruct conn *connection = proactor_connections[result.fd]; 这行代码没有新建 struct conn。真正创建对象的是 calloc()；这里仅仅取回之前保存的地址。\nEVENT_READ # EVENT_READ 不是在现场同步调用 recv()，而是处理“之前提交的 recv 已经完成”这一结果：\n读取 entries-\u0026gt;res ↓ 通过 result.fd 找到 connection ↓ connection-\u0026gt;input.length += ret ↓ 调用 kvs_handler ↓ consume 已处理请求 ↓ 有响应则提交 send 只有半包则重新提交 recv 半包时不能只写 continue。因为上一项异步 recv 已经完成，必须显式调用 set_event_recv() 提交下一项读取任务：\nset_event_recv( \u0026amp;ring, result.fd, connection-\u0026gt;input.data + connection-\u0026gt;input.length, available, 0 ); EVENT_WRITE # EVENT_WRITE 也不是现场调用 send()，而是处理“之前提交的异步 send 已经完成”。\n发送完成后必须重新提交下一次 recv。而且接收位置不能退回旧的公共 buffer，必须继续使用当前连接自己的缓冲区：\nstruct conn *connection = proactor_connections[result.fd]; int available = BUFFER_LENGTH - connection-\u0026gt;input.length; set_event_recv( \u0026amp;ring, result.fd, connection-\u0026gt;input.data + connection-\u0026gt;input.length, available, 0 ); 如果不修改 EVENT_WRITE，第一次接收会写入 connection-\u0026gt;input，但响应发送完成后的第二次接收又会写回旧公共 buffer，整个半包链路会在第二轮断开。\nProactor 的闭环是：\n提交 recv ↓ EVENT_READ ↓ 解析请求并提交 send ↓ EVENT_WRITE ↓ 再次提交 recv 三种网络模型的共同点与差异 # 共同规则完全一致：\n追加接收 只处理完整命令 消费已处理前缀 保留末尾半包 区别只在于连接状态保存在哪里、何时继续接收：\n网络模型 每连接状态保存位置 半包后如何继续接收 Reactor conn_list[fd] 继续监听 EPOLLIN NtyCo server_reader() 协程栈 continue 后再次 recv() Proactor proactor_connections[fd] 再提交一次异步 recv 因此，协议层不需要知道 epoll、协程或 io_uring。协议层只提供统一契约：\n给我累计请求和响应空间 我返回响应长度和请求消费长度 网络层也不需要知道 GET、SET 的语义。它只负责：\n保存未消费数据 发送协议层生成的响应 请求半包验证 # 最小半包测试：\n第一次发送：\nGET half_ 此时没有 \\r\\n，服务端不响应。第二次在同一 Persistent TCP 连接发送：\nkey\\r\\n 服务端累计得到：\nGET half_key\\r\\n 返回：\nNO EXIST\\r\\n 进一步验证“完整命令 + 末尾半包”：\n第一次发送：\nGET one\\r\\nGET two_ 只返回第一条命令的响应：\nNO EXIST\\r\\n 随后发送：\nkey\\r\\n 服务端将保留的 GET two_ 与新数据拼成：\nGET two_key\\r\\n 再返回一次：\nNO EXIST\\r\\n Reactor、NtyCo 和 Proactor 均通过了上述半包测试。\n半包、批量命令与 AOF # 请求缓冲区和 consume() 本身不会写入 AOF。只有完整并实际执行的写命令才沿用原有持久化流程：\nrecv 半包 ↓ input 保存 // 不写 AOF ↓ 发现完整 CRLF 命令 ↓ kvs_protocol 执行 SET / DEL ↓ 写入 AOF 例如分两次发送：\n第一次：SET user Jas 第二次：per\\r\\n 第一次没有完整命令，不执行，也不写 AOF。第二次拼成 SET user Jasper\\r\\n 后，只执行一次，并且 AOF 只记录一次完整写命令。\nGET // 不写 AOF SET、DEL 等写命令 // 写 AOF 批量写命令 // 按执行顺序分别写入 AOF 不完整半包 // 不执行，不写 AOF 快照机制也不需要直接理解网络半包；它看到的仍然是已经执行后的存储状态。\n当前阶段完成情况 # 当前已经完成：\nCRLF 请求边界定义 一次 recv 中的批量命令解析 响应按 CRLF 分隔 协议层返回 consumed_length 公共请求缓冲区及 consume Reactor 请求半包 NtyCo 请求半包 Proactor / io_uring 请求半包 三种网络模型的半包实测 对应提交：\n1ccfcc1 feat(protocol): support CRLF-delimited command batches 32301d3 feat(network): 支持三种网络模型的请求半包缓冲 下一阶段：partial send # 当前已经解决的是请求方向：\n一条请求可能需要多次 recv 才能收完整 还没有解决的是响应方向：\n一次 send 可能只接受响应的一部分 例如响应长度为 10：\nint sent = send(fd, response, 10, 0); sent 可能只等于 4。此时前 4 字节已经发送，剩余 6 字节必须从正确偏移继续发送：\nsend(fd, response + 4, 6, 0); 下一阶段需要维护：\nwbuffer // 完整响应 wlength // 响应总长度 woffset // 已经发送的长度 remaining // wlength - woffset 三种网络模型的方向相同，但继续发送的方式不同：\n网络模型 partial send 后的动作 Reactor 保持 EPOLLOUT，下次从 woffset 继续发送 NtyCo 在协程中循环 send()，直到全部完成 Proactor 根据 WRITE CQE 累加 woffset，重新提交剩余 send 只有当：\nwoffset == wlength 才能认为这次响应真正发送完毕。\n需要特别区分：即使服务端一次 send() 全部成功，客户端也不一定一次 recv() 就收到完整响应。TCP 仍然是字节流，客户端同样需要根据响应协议边界进行累计和拆分。\n响应 partial send 改造正式开始 # 前面的设计仍然沿用了旧名字：\nwbuffer wlength woffset 真正落地时，为了让请求方向和响应方向的状态更加对称，最终将它们整理成了统一的输出缓冲区：\ntypedef struct kvs_output_buffer { char data[BUFFER_LENGTH]; // 保存完整响应内容 int length; // 完整响应总长度 int offset; // 已经成功发送的长度 } kvs_output_buffer_t; 随后将原来 struct conn 中零散的：\nchar wbuffer[BUFFER_LENGTH]; int wlength; 替换为：\nkvs_output_buffer_t output; // 当前连接的响应发送状态 至此，一条连接拥有两类明确的状态：\nstruct conn { int fd; // 客户端连接 fd kvs_input_buffer_t input; // 请求方向：累计尚未处理的数据 kvs_output_buffer_t output; // 响应方向：保存尚未发完的数据 }; 它们的职责并不完全相同：\n缓冲区 保存什么 推进方式 input 尚未处理完的请求字节 consume() 后通过 memmove() 将剩余数据移到开头 output 尚未发送完的响应字节 响应内容保持不动，只增加 offset 输入缓冲区没有 offset，是因为每次消费后都会整理数据：\n已处理请求 + 未处理请求 ↓ consume 未处理请求被移动到 data[0] 输出缓冲区不能这样做。异步发送过程中，内核可能仍在使用原来的响应内存，所以响应数据应保持稳定，只记录发送位置：\ndata[0 ... offset - 1] // 已发送 data[offset ... length - 1] // 尚未发送 剩余长度统一按下面的方式计算：\nint remaining = output.length - output.offset; // 尚未发送的响应长度 下一次发送地址则是：\noutput.data + output.offset // 跳过已经发送成功的字节 这不是 POSIX API 的缺陷 # send() 不保证一次发送完，并不是 POSIX 忘记处理“半包”，而是职责划分的结果。\nTCP 提供的是可靠、有序的字节流：\n应用层 output ↓ send 本机内核 TCP 发送缓冲区 ↓ 网络 对端内核 TCP 接收缓冲区 ↓ recv 对端应用程序 send() 成功返回的数字表示：\n本次有多少字节被本机内核接收 它并不表示：\n对端应用程序已经一次性读到了多少字节 如果应用层希望发送 1000 字节，而内核发送缓冲区暂时只能容纳 300 字节，那么 send() 可以返回 300。剩余的 700 字节必须由应用程序保存并再次发送。\n操作系统不能替应用无限保存剩余数据，因为不同应用可能选择不同策略：\n继续缓存并稍后发送 丢弃低优先级响应 限制慢客户端占用的内存 连接超时后主动关闭 合并、压缩或重新编码响应 因此 POSIX 提供机制，应用层负责策略：\nPOSIX：返回本次实际完成的字节数 应用：维护 length、offset 和错误处理策略 Reactor 的响应续发 # Reactor 使用 epoll 驱动状态转换。\n需要先理解两个事件：\nEPOLLIN // 当前连接可以读取，请调用 recv EPOLLOUT // 当前连接可以写入，请调用 send 正常流程是：\n监听 EPOLLIN // 等待客户端请求 ↓ recv_cb // 请求追加到 input ↓ kvs_request // 解析请求并生成 output ↓ 监听 EPOLLOUT // 等待可以发送响应 ↓ send_cb // 从 output.offset 开始发送 ↓ 未发送完 → 继续监听 EPOLLOUT // 下次进入 send_cb 续发 发送完成 → 清空 output，监听 EPOLLIN // 等待下一批请求 epoll 的基本组成 # 创建 epoll 实例后，可以把它理解为维护了两类列表：\n关注列表 // 应用希望监听哪些 fd、哪些事件 就绪列表 // 内核已经发现哪些 fd 可以读写 事件描述结构体主要包含：\nstruct epoll_event { uint32_t events; // EPOLLIN、EPOLLOUT 等事件类型 epoll_data_t data; // 与事件绑定的用户数据 }; 本项目使用 fd 识别连接：\nstruct epoll_event ev; ev.events = event; // 设置希望监听的事件 ev.data.fd = fd; // 事件发生后，通过 fd 找回连接 set_event() 最终整理成：\nint set_event(int fd, int event, int flag) { struct epoll_event ev; ev.events = event; // 设置读或写事件 ev.data.fd = fd; // 绑定客户端 fd if (flag) { return epoll_ctl(epfd, EPOLL_CTL_ADD, fd, \u0026amp;ev); // 第一次注册 } return epoll_ctl(epfd, EPOLL_CTL_MOD, fd, \u0026amp;ev); // 修改已有监听 } 这里 flag 的含义是：\nflag != 0 // 使用 EPOLL_CTL_ADD，将 fd 首次加入 epoll flag == 0 // 使用 EPOLL_CTL_MOD，切换已有 fd 的事件 Reactor 生成响应 # kvs_request() 不再写入旧的 wbuffer，而是写入 output：\nint kvs_request(struct conn *c) { int consumed_length = 0; // 协议层实际消费的请求长度 c-\u0026gt;output.offset = 0; // 新响应尚未发送 c-\u0026gt;output.length = kvs_handler( c-\u0026gt;input.data, // 已累计的请求数据 c-\u0026gt;input.length, // 请求数据总长度 c-\u0026gt;output.data, // 响应写入输出缓冲区 BUFFER_LENGTH, // 输出缓冲区容量 \u0026amp;consumed_length // 返回已经处理的请求字节数 ); if (c-\u0026gt;output.length \u0026lt; 0) { return -1; // 协议处理失败 } if (kvs_input_buffer_consume(\u0026amp;c-\u0026gt;input, consumed_length) \u0026lt; 0) { c-\u0026gt;output.length = 0; // 输入缓冲区状态异常 return -1; } return 0; } Reactor 从偏移位置继续发送 # send_cb() 的核心逻辑变为：\nint remaining = conn_list[fd].output.length - conn_list[fd].output.offset; // 计算剩余响应长度 if (remaining \u0026gt; 0) { count = send( fd, conn_list[fd].output.data + conn_list[fd].output.offset, // 从上次停止位置继续 remaining, // 只发送剩余响应 0 ); } 成功发送部分字节后推进进度：\nif (count \u0026gt; 0) { conn_list[fd].output.offset += count; // 累加本次实际发送长度 if (conn_list[fd].output.offset \u0026lt; conn_list[fd].output.length) { set_event(fd, EPOLLOUT, 0); // 没发完，继续等待可写 return count; // 结束本次回调 } conn_list[fd].output.length = 0; // 完整响应发送完成 conn_list[fd].output.offset = 0; // 重置发送进度 } set_event(fd, EPOLLIN, 0); // 重新等待客户端请求 Reactor 的临时错误 # 非阻塞 send() 可能暂时无法发送：\nif (count \u0026lt; 0) { if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { set_event(fd, EPOLLOUT, 0); // 响应仍在 output 中，稍后继续 return 0; // offset 保持不变 } epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); // 先移除监听 close(fd); // 再关闭 fd conn_list[fd].output.length = 0; // 清理响应状态 conn_list[fd].output.offset = 0; return -1; } 这里最容易犯的错误是：send() 返回 EAGAIN 后直接切换成 EPOLLIN。\n错误路径会变成：\n响应尚未发完 ↓ send 返回 EAGAIN ↓ 错误切换到 EPOLLIN ↓ 剩余响应仍在 output，却没有下一次发送机会 正确做法是保留 offset，继续监听 EPOLLOUT。\nNtyCo 的响应续发 # NtyCo 的网络接口看起来仍然像同步的 recv() 和 send()，但等待 I/O 时会让出当前协程。因此它不需要像 Reactor 一样手动将事件切换成 EPOLLOUT，可以在当前协程中循环发送。\n每个 server_reader() 对应一个客户端连接：\nvoid server_reader(void *arg) { int fd = *(int *)arg; // 当前客户端 fd int ret = 0; // recv/send 的实际结果 kvs_input_buffer_t input = {0}; // 跨循环保存请求数据 kvs_output_buffer_t output = {0}; // 跨循环保存响应状态 接收时仍然追加到请求尾部：\nint available = BUFFER_LENGTH - input.length; // 计算剩余请求空间 ret = recv( fd, input.data + input.length, // 从已有请求尾部继续写 available, 0 ); if (ret \u0026gt; 0) { input.length += ret; // 不能写成 input.length = ret } 协议层生成响应：\noutput.offset = 0; // 新响应从开头发送 output.length = kvs_handler( input.data, // 已累计请求 input.length, // 请求总长度 output.data, // 响应输出位置 BUFFER_LENGTH, // 响应容量 \u0026amp;consumed_length // 已处理的请求长度 ); 如果 output.length == 0，说明尚未发现一条完整的 CRLF 命令：\nif (output.length == 0) { continue; // input 保持不变，继续 recv 后续半包 } 发现完整命令后，先消费已经处理的请求：\nif (kvs_input_buffer_consume(\u0026amp;input, consumed_length) \u0026lt; 0) { close(fd); // 输入缓冲区状态异常 break; } 再循环发送完整响应：\nwhile (output.offset \u0026lt; output.length) { int remaining = output.length - output.offset; // 计算剩余长度 ret = send( fd, output.data + output.offset, // 从发送断点继续 remaining, 0 ); if (ret \u0026gt; 0) { output.offset += ret; // 保存发送进度 continue; // 仍未结束则继续循环 } if (ret \u0026lt; 0 \u0026amp;\u0026amp; errno == EINTR) { continue; // 被信号打断，原位置重试 } close(fd); // 其他错误关闭客户端 return; // 结束整个 server_reader } output.length = 0; // 响应全部发送完成 output.offset = 0; // 为下一次响应重置状态 这里使用 return 而不是 break 很重要。代码此时处在发送内层循环中：\nbreak // 只退出发送循环，可能继续在已关闭 fd 上 recv return // 结束整个客户端协程 Proactor / io_uring 的响应续发 # Proactor 与前两种模型不同。它提交异步操作，然后通过 CQE 得知实际完成结果。\n准备 SQE ↓ 提交异步 send ↓ 程序继续处理其他事件 ↓ 内核产生 WRITE CQE ↓ entries-\u0026gt;res 返回实际发送结果 为什么 Proactor 必须保存每个连接的状态 # 提交操作和收到完成通知发生在不同时间，因此使用 fd 找回对应连接：\nstatic struct conn *proactor_connections[CONNECTION_SIZE]; 注意：数组中的每个元素不是 struct conn 对象，而是 struct conn * 指针。\n建立连接时真正分配对象：\nstruct conn *connection = calloc(1, sizeof(*connection)); // 创建连接状态 if (connection == NULL) { close(connfd); // 分配失败 continue; } connection-\u0026gt;fd = connfd; // 保存客户端 fd proactor_connections[connfd] = connection; // 建立 fd 到状态的映射 收到 READ 或 WRITE CQE 后，通过 fd 找回来：\nstruct conn *connection = proactor_connections[result.fd]; // 取回已有对象，不是重新创建 连接关闭时才释放：\nclose(result.fd); // 关闭 fd free(connection); // 释放堆上的连接对象 proactor_connections[result.fd] = NULL; // 清除映射，避免悬空指针 READ CQE 生成响应 # 在 EVENT_READ 中：\nint ret = entries-\u0026gt;res; // 本次异步 recv 实际接收的字节数 connection-\u0026gt;input.length += ret; // 新数据已经写在旧数据尾部 int consumed_length = 0; connection-\u0026gt;output.offset = 0; // 新响应尚未发送 connection-\u0026gt;output.length = kvs_handler( connection-\u0026gt;input.data, // 当前请求数据 connection-\u0026gt;input.length, // 当前请求总长度 connection-\u0026gt;output.data, // 响应写入这里 BUFFER_LENGTH, // 响应容量 \u0026amp;consumed_length // 返回已消费请求长度 ); 如果生成了响应，提交第一次发送：\nif (set_event_send( \u0026amp;ring, result.fd, connection-\u0026gt;output.data, // 第一次从 data[0] 发送 connection-\u0026gt;output.length, // 提交完整响应长度 0 ) \u0026lt; 0) { close(result.fd); // 无法取得 SQE free(connection); proactor_connections[result.fd] = NULL; continue; } WRITE CQE 推进响应进度 # 在 EVENT_WRITE 中：\nint ret = entries-\u0026gt;res; // 本次异步 send 实际完成的字节数 这里的 ret 与其他几个长度含义不同：\nret // 一次异步 recv/send 实际完成多少字节 output.length // 完整响应总长度 output.offset // 已经发送成功的总长度 consumed_length // 协议层已经处理的请求长度 发送成功后累加：\nconnection-\u0026gt;output.offset += ret; // 记录本次 WRITE CQE 的发送量 如果仍未发送完，就再次准备 SQE：\nif (connection-\u0026gt;output.offset \u0026lt; connection-\u0026gt;output.length) { int remaining = connection-\u0026gt;output.length - connection-\u0026gt;output.offset; // 计算剩余响应长度 if (set_event_send( \u0026amp;ring, result.fd, connection-\u0026gt;output.data + connection-\u0026gt;output.offset, // 从发送断点继续 remaining, 0 ) \u0026lt; 0) { close(result.fd); // 无法准备新的发送 SQE free(connection); proactor_connections[result.fd] = NULL; } continue; // 等待下一次 WRITE CQE，不提交 recv } 响应全部完成后才重新接收请求：\nconnection-\u0026gt;output.length = 0; // 清理完整响应长度 connection-\u0026gt;output.offset = 0; // 清理发送进度 int available = BUFFER_LENGTH - connection-\u0026gt;input.length; // 请求缓冲区剩余空间 set_event_recv( \u0026amp;ring, result.fd, connection-\u0026gt;input.data + connection-\u0026gt;input.length, available, 0 ); io_uring 的错误表示方式 # 普通 POSIX send() 失败时通常表现为：\n返回 -1 errno = EAGAIN io_uring 的 CQE 不通过当前线程的 errno 返回错误，而是直接把负错误码放进：\nentries-\u0026gt;res 因此 Proactor 判断的是：\nif (ret == -EAGAIN || ret == -EINTR) { // offset 没有增加，从原来的位置重新提交 send } 两种写法不能混用：\nReactor：errno == EAGAIN Proactor：ret == -EAGAIN SQE 辅助函数补全返回值 # 严格编译还暴露了三个旧问题：\nset_event_send set_event_recv set_event_accept 它们声明返回 int，但原来没有返回值，也没有检查 io_uring_get_sqe() 是否失败。\n以发送为例，整理为：\nint set_event_send( struct io_uring *ring, int sockfd, void *buf, size_t len, int flags ) { struct io_uring_sqe *sqe = io_uring_get_sqe(ring); if (sqe == NULL) { return -1; // 提交队列暂时没有空闲 SQE } struct conn_info event_info = { .fd = sockfd, .event = EVENT_WRITE, }; io_uring_prep_send(sqe, sockfd, buf, len, flags); // 只准备操作 memcpy(\u0026amp;sqe-\u0026gt;user_data, \u0026amp;event_info, sizeof(event_info)); return 0; // 只表示 SQE 准备成功，不表示响应已经发送 } 这里必须区分两个阶段：\nset_event_send 返回 0 // SQE 已经准备好 WRITE CQE 的 ret // send 最终实际完成的字节数 三种网络模型的共同状态与不同驱动方式 # 三种实现现在共享同一套应用层状态：\noutput.data // 完整响应 output.length // 响应总长度 output.offset // 已发送长度 但推进发送的方式不同：\n网络模型 谁通知可以继续 如何继续 Reactor epoll 返回 EPOLLOUT 再次进入 send_cb() NtyCo 协程 I/O 封装恢复当前协程 在 while 中再次 send() Proactor io_uring 返回 WRITE CQE 根据 entries-\u0026gt;res 重新提交 SQE 可以统一概括为：\n生成响应 // length 已知，offset = 0 ↓ 尝试发送 // 每次可能只完成一部分 ↓ offset += 实际发送长度 // 保存进度 ↓ offset \u0026lt; length // 未完成 └── 按网络模型的方式安排下一次发送 ↓ offset == length // 完成 ↓ 重置 output // 回到请求接收状态 为什么客户端可能仍然只显示一条响应 # 即使服务端执行了四次 send()：\n3 字节 + 3 字节 + 3 字节 + 1 字节 Packet Sender 也可能一次显示完整的：\nNO EXIST\\r\\n 这是正常的。TCP 可以在传输和接收过程中重新组合字节：\n服务端多次 send ↓ TCP 字节流传输 ↓ 客户端一次或多次 recv 因此不能只看客户端显示了几个 packet 来判断服务端是否真的发生 partial send。需要观察服务端记录的每次实际发送长度和 offset。\n强制构造 partial send 测试 # 项目当前响应缓冲区只有 1024 字节，本机 TCP 发送缓冲区通常足以一次接收整段响应。为了确定续发状态机真的工作，测试时临时将每次发送限制为 3 字节。\n以长度为 10 的响应为例：\nNO EXIST\\r\\n 测试期间临时使用：\nint send_length = remaining \u0026gt; 3 ? 3 : remaining; // 每次最多发送 3 字节 并打印每次发送进度。三种网络模型均得到：\n第一次：ret = 3，offset = 3，length = 10 第二次：ret = 3，offset = 6，length = 10 第三次：ret = 3，offset = 9，length = 10 第四次：ret = 1，offset = 10，length = 10 NtyCo 强制分段结果 # partial send: ret=3, offset=3, length=10 partial send: ret=3, offset=6, length=10 partial send: ret=3, offset=9, length=10 partial send: ret=1, offset=10, length=10 Reactor 强制分段结果 # reactor partial send: count=3, offset=3, length=10 reactor partial send: count=3, offset=6, length=10 reactor partial send: count=3, offset=9, length=10 reactor partial send: count=1, offset=10, length=10 每一行都对应一次独立的 EPOLLOUT → send_cb()。\nProactor 强制分段结果 # proactor partial send: ret=3, offset=3, length=10 proactor partial send: ret=3, offset=6, length=10 proactor partial send: ret=3, offset=9, length=10 proactor partial send: ret=1, offset=10, length=10 每一行都对应一次独立的 send SQE → WRITE CQE。\n三种模型的客户端最终都收到了完整响应：\nNO EXIST\\r\\n 这证明 offset 不是形式上的字段，而是真正参与了响应断点续发。\n测试完成后，删除了所有临时限制和日志：\n删除 send_length = min(remaining, 3) 恢复真实 remaining 长度 删除 partial send 调试输出 恢复默认 NETWORK_SELECT = NETWORK_NTYCO 测试代码没有进入正式提交。\n严格编译与完整构建 # 三个网络模型分别通过：\nmake -B \\ build/reactor.o \\ build/ntyco.o \\ build/proactor.o \\ CFLAGS=\u0026#34;-std=gnu11 -Wall -Wextra -Werror -g\u0026#34; 随后执行完整构建：\nmake -B 最终成功生成：\nbin/kvstore 提交前还使用 CRLF-aware 方式检查行尾：\ngit -c core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol \\ diff --check 本轮提交与合并 # 响应续发提交：\n4fa3d83 feat(network): 支持三种网络模型的响应分段续发 提交说明覆盖：\n引入统一输出缓冲区 Reactor 保持 EPOLLOUT 并处理临时错误 NtyCo 在协程中循环发送 Proactor 根据 WRITE CQE 更新偏移并重新提交 SQE 避免 send 未完整写入时丢失响应尾部 功能分支最终通过 merge commit 合并进 main：\nbb49955 merge: 集成批量命令与网络分包处理 GitHub 和 GitLab 的 main 均已推进到：\nbb49955 需要再次强调：merge 只会合并提交历史，不会自动删除功能分支。feature/batch-commands 仍然存在是正常现象，可以在确认主分支稳定后再清理。\n本章最终完成情况 # 本章已经不只是“批量解析两条命令”，而是完成了 TCP 应用层协议所需的一条完整链路：\nCRLF 定义命令边界 // 协议帧边界 ↓ 批量解析一段缓冲区中的多条完整命令 // 解决粘包 ↓ input 跨 recv 保存不完整命令 // 解决请求拆包 ↓ consumed_length 精确消费已处理请求 // 保留末尾半包 ↓ output 保存完整响应和发送进度 // 解决响应 partial send ↓ Reactor / NtyCo / Proactor 分别续发 // 适配三种网络模型 最终不再依赖下面两个错误假设：\n一次 send 等于对方的一次 recv 一次 recv 等于一条完整命令 而是建立了真正符合 TCP 字节流语义的处理方式：\n请求按照协议边界累计和拆分 响应按照实际发送长度记录和续发 后续仍值得改进的方向 # 本轮目标已经完成，但继续演进时仍有几个清晰方向：\n连接关闭逻辑统一封装 // 减少 close、free、置 NULL 的重复代码 所有 SQE 提交点检查失败返回 // 不让连接进入“无事件等待”的悬空状态 补全 recv 负错误码处理 // 区分临时错误与不可恢复错误 为输出增加队列或动态扩容 // 支持超过固定缓冲区容量的大批量响应 客户端实现响应缓冲与 CRLF 拆分 // 客户端同样不能假设一次 recv 是一条响应 增加自动化协议与网络分段测试 // 替代手工修改每次发送 3 字节 增加批处理性能基准 // 对比单命令往返和 pipeline 吞吐量 其中，下一轮最值得优先考虑的是自动化测试和批处理性能基准。只有测出：\n每秒完成命令数 平均延迟与尾延迟 不同 batch size 的吞吐变化 单连接与多连接的差异 才能真正回答最初的问题：批量命令究竟减少了多少网络往返开销，又将 CPU 利用率和系统吞吐提升了多少。\n","date":"2026年9月14日","externalUrl":null,"permalink":"/MyBlog/p/kv-evolution-batch-commands/","section":"Posts","summary":"","title":"KV 进化（四）：Batch Commands 批量命令处理","type":"posts"},{"content":"","date":"2026年9月14日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%8D%8F%E8%AE%AE%E8%A7%A3%E6%9E%90/","section":"Tags","summary":"","title":"协议解析","type":"tags"},{"content":"","date":"2026年9月8日","externalUrl":null,"permalink":"/MyBlog/tags/hash/","section":"Tags","summary":"","title":"Hash","type":"tags"},{"content":" 91kvstore 内存池学习与实现笔记 # 1. 从 Hash 节点的内存说起 # 我们先拿 Hash 数据结构举例。Hash 由节点组成，节点中保存 key、value 和指向下一个节点的 next。\n在开启指针形式的 key/value 后，一个节点通常会这样创建：\nhashnode_t *node = malloc(sizeof(hashnode_t)); node-\u0026gt;key = malloc(4); node-\u0026gt;value = malloc(7); malloc 的作用是申请一块动态内存。上面三次 malloc 会得到三块彼此独立的空间：\nnode ───→ [hashnode_t] │ ├── key ───→ [key 数据] │ └── value ─→ [value 数据] node-\u0026gt;key 和 node-\u0026gt;value 保存的不是字符串本身，而是字符串所在内存的地址。\n因此，释放顺序应当是：\nfree(node-\u0026gt;key); free(node-\u0026gt;value); free(node); 如果先执行：\nfree(node); 之后就不能再安全地通过 node-\u0026gt;key 和 node-\u0026gt;value 找到对应内存。没有保存其他地址时，那两块内存将无法释放，从而造成内存泄漏。\n2. 指针变量、动态内存与悬空指针 # 执行 free 后，指针不会自动变成 NULL。\n释放前： node-\u0026gt;key ───→ [\u0026#34;Dad\\0\u0026#34;] 释放后： node-\u0026gt;key ───→ [已经释放的空间] free 只是告诉内存分配器：这块动态内存可以重新使用了。保存地址的指针变量不会被自动修改。\n指针变量 p 动态内存 ┌────────────┐ ┌──────────┐ │ 地址 0x100 │ ─────────→ │ 10 字节 │ └────────────┘ └──────────┘ 执行：\nfree(p); 只是释放右边的动态内存，左边的 p 仍然保存旧地址。此时 p 被称为悬空指针，不能再解引用或再次释放。\n如果希望当前指针变量不再保存旧地址，需要手动赋值：\np = NULL; 需要注意，p = NULL 只改变 p 这个变量。如果还有另一个指针也保存了同一地址，它仍然可能是悬空指针。\n对同一块内存执行两次 free 称为重复释放，可能导致程序崩溃或破坏分配器的内部状态。\n3. 什么是内存碎片 # 假设一段内存被切成四块：\n[使用 16B][空闲 16B][使用 16B][空闲 16B] 空闲内存总量是：\n16B + 16B = 32B 但是如果程序申请一块连续的 24B 内存，两块空闲区域彼此分开，任何一块都放不下 24B。\n这种“空闲总量足够，但是被分散成许多小块”的现象叫作外部碎片。\n频繁申请和释放不同大小的对象，可能让内存布局越来越零散。引入内存池的目的之一，就是让相同或相近大小的内存有规律地重复使用。\n4. 内存池为什么可能更快 # 假设节点固定为 24 字节，普通方式反复执行：\nnode = malloc(24); free(node); node = malloc(24); free(node); 通用分配器需要处理许多情况：\n申请大小可能是任意值。 可能存在多个线程。 需要维护内存块的元数据。 需要查找合适的空闲块。 可能需要拆分或合并内存块。 专用的固定大小内存池提前知道对象大小，可以先申请一大块内存：\n一个大块内存 ┌──────┬──────┬──────┬──────┐ │ 24B │ 24B │ 24B │ 24B │ └──────┴──────┴──────┴──────┘ 然后维护一条空闲链表：\n空闲块1 → 空闲块2 → 空闲块3 → NULL 申请时取下链表头，释放时再放回链表头。主要操作只是修改几个指针。\n内存池只能复用已经被业务释放的对象，不能擅自回收仍然在使用的对象。\n空闲块耗尽后，可以采用不同策略：\n扩容：再申请一大块内存。 降级：这一笔申请临时使用普通 malloc。 失败：返回 NULL。 我们的第一版采用自动扩容。\n5. Chunk、Block 与 Free List # Chunk：内存池一次向 malloc 申请的大块内存。 Block：从 Chunk 中划分出来、交给业务使用的小块。 Free List：把所有空闲 Block 串起来的链表。 假设每个 Block 为 32 字节，每个 Chunk 包含 4 个 Block：\n一个 Chunk ┌────────┬────────┬────────┬────────┐ │ Block1 │ Block2 │ Block3 │ Block4 │ │ 32B │ 32B │ 32B │ 32B │ └────────┴────────┴────────┴────────┘ 刚扩容时，所有 Block 都进入空闲链表。连续申请 4 次后，空闲链表变成 NULL；第 5 次申请触发新 Chunk 的创建。\n6. 用空闲 Block 自身保存 next # 空闲 Block 中没有有效业务数据，因此可以借用它的前几个字节保存 next：\ntypedef struct free_node { struct free_node *next; } free_node_t; 这不是把结构体完整地嵌套进自己。next 只是一个地址大小固定的指针。\n下面这种写法才是无法成立的完整嵌套：\nstruct free_node { struct free_node next; }; 多个 free_node_t 通过指针连接起来，就形成了 C 语言中的单向链表：\nfree_list │ ▼ ┌────────┐ ┌────────┐ ┌────────┐ │ next ──────→│ next ──────→│ NULL │ └────────┘ └────────┘ └────────┘ Block1 Block2 Block3 申请链表头：\nfree_node_t *block = free_list; free_list = free_list-\u0026gt;next; return block; 归还到链表头：\nblock-\u0026gt;next = free_list; free_list = block; 这种链表信息直接保存在被管理对象内部的方式，叫作侵入式链表。\n它要求 Block 至少能放下一个指针：\nif (block_size \u0026lt; sizeof(free_node_t)) { block_size = sizeof(free_node_t); } 7. 为什么还需要 Chunk List # free_list 只记录当前哪些 Block 可以再次分配，却不能完整反映系统一共申请过哪些 Chunk。\n因此还需要一条 chunk_list：\ntypedef struct memory_chunk { void *memory; struct memory_chunk *next; } memory_chunk_t; memory 指向真正存放多个 Block 的大块内存。 next 指向下一个 Chunk 管理节点。 销毁内存池时，遍历 chunk_list，逐个释放 Chunk。\n8. 内存池的数据结构与接口 # 头文件使用宏避免重复包含：\n#ifndef KVS_MEMORY_POOL_H #define KVS_MEMORY_POOL_H #include \u0026lt;stddef.h\u0026gt; typedef struct free_node { struct free_node *next; } free_node_t; typedef struct memory_chunk { void *memory; struct memory_chunk *next; } memory_chunk_t; typedef struct memory_pool { size_t block_size; size_t blocks_per_chunk; free_node_t *free_list; memory_chunk_t *chunk_list; } memory_pool_t; int memory_pool_init(memory_pool_t *pool, size_t block_size, size_t blocks_per_chunk); void *memory_pool_alloc(memory_pool_t *pool); void memory_pool_free(memory_pool_t *pool, void *ptr); void memory_pool_destroy(memory_pool_t *pool); #endif memory_pool_alloc 返回 void *，因为内存池只负责返回一段通用内存，并不知道调用者最终会把它当成 hashnode_t、链表节点还是其他对象。\n9. 创建独立的 Git 分支 # git switch -c feature/memory-pool 它完成两件事：\n创建 feature/memory-pool 分支。 将当前工作分支切换到新分支。 刚创建时，两个分支指向同一个提交：\nmain → C feature/memory-pool → C 提交内存池代码后：\nmain → C feature/memory-pool → D 这样内存池开发不会直接改变 main 所指向的稳定版本。\n10. 初始化内存池 # int memory_pool_init(memory_pool_t *pool, size_t block_size, size_t blocks_per_chunk) { if (pool == NULL || block_size == 0 || blocks_per_chunk == 0) { return -1; } if (block_size \u0026lt; sizeof(free_node_t)) { block_size = sizeof(free_node_t); } size_t alignment = sizeof(void *); size_t remainder = block_size % alignment; if (remainder != 0) { block_size += alignment - remainder; } pool-\u0026gt;block_size = block_size; pool-\u0026gt;blocks_per_chunk = blocks_per_chunk; pool-\u0026gt;free_list = NULL; pool-\u0026gt;chunk_list = NULL; return 0; } 初始化函数完成四件事：\n检查参数是否合法。 保证 Block 至少能保存一个 free_node_t。 将 Block 大小向上对齐。 保存配置并清空两条链表。 初始化阶段没有申请 Chunk。我们采用懒加载：第一次申请 Block 时再扩容。\n11. 为什么要对齐 Block # 每个空闲 Block 都会被转换为 free_node_t *，并在其中保存指针，因此每个 Block 的起始地址需要满足指针的基本对齐要求。\n假设指针大小是 8 字节，用户要求的 Block 大小是 20 字节：\n未对齐的起始偏移：0、20、40、60…… 将 20 向上对齐到 24：\n20 % 8 = 4 20 + (8 - 4) = 24 对齐后的起始偏移为：\n0、24、48、72…… 它们都是 8 的整数倍。\n12. 扩容前的整数溢出检查 # 一个 Chunk 的大小为：\nchunk_size = blocks_per_chunk * block_size; 两个 size_t 相乘可能超过 SIZE_MAX，因此乘法前检查：\nif (pool-\u0026gt;blocks_per_chunk \u0026gt; SIZE_MAX / pool-\u0026gt;block_size) { return -1; } 这里不是说 Block 数量太多时应该继续扩容，而是说这次乘法已经无法得到可信结果，申请参数不合法，必须失败。\nSIZE_MAX 由标准整数类型相关头文件提供：\n#include \u0026lt;stdint.h\u0026gt; 13. 实现 Chunk 扩容 # static int memory_pool_grow(memory_pool_t *pool) { if (pool-\u0026gt;blocks_per_chunk \u0026gt; SIZE_MAX / pool-\u0026gt;block_size) { return -1; } size_t chunk_size = pool-\u0026gt;blocks_per_chunk * pool-\u0026gt;block_size; memory_chunk_t *chunk = malloc(sizeof(*chunk)); if (chunk == NULL) { return -1; } chunk-\u0026gt;memory = malloc(chunk_size); if (chunk-\u0026gt;memory == NULL) { free(chunk); return -1; } chunk-\u0026gt;next = pool-\u0026gt;chunk_list; pool-\u0026gt;chunk_list = chunk; unsigned char *start = (unsigned char *)chunk-\u0026gt;memory; for (size_t i = 0; i \u0026lt; pool-\u0026gt;blocks_per_chunk; i++) { free_node_t *node = (free_node_t *)(start + i * pool-\u0026gt;block_size); node-\u0026gt;next = pool-\u0026gt;free_list; pool-\u0026gt;free_list = node; } return 0; } 这里有两次 malloc：\nmemory_chunk_t *chunk = malloc(sizeof(*chunk)); chunk-\u0026gt;memory = malloc(chunk_size); 第一次申请 Chunk 管理结构，第二次申请真正存放 Block 的大块内存。\n如果第二次失败，要释放第一次已经成功申请的 chunk，否则会发生内存泄漏。\n新 Chunk 使用头插法加入 chunk_list：\nchunk-\u0026gt;next = pool-\u0026gt;chunk_list; pool-\u0026gt;chunk_list = chunk; 14. 划分 Block 的循环究竟做了什么 # unsigned char *start = (unsigned char *)chunk-\u0026gt;memory; for (size_t i = 0; i \u0026lt; pool-\u0026gt;blocks_per_chunk; i++) { free_node_t *node = (free_node_t *)(start + i * pool-\u0026gt;block_size); node-\u0026gt;next = pool-\u0026gt;free_list; pool-\u0026gt;free_list = node; } 这个循环没有继续分配内存。真正的内存申请只有：\nchunk-\u0026gt;memory = malloc(chunk_size); 循环只是计算各个 Block 的起始地址，并把它们登记到空闲链表。\nunsigned char 的大小是一个字节，把地址转换成 unsigned char * 后，便可以按照字节偏移计算地址。\n假设：\nstart = 1000 block_size = 32 那么：\ni = 0：1000 + 0 × 32 = 1000 i = 1：1000 + 1 × 32 = 1032 i = 2：1000 + 2 × 32 = 1064 i = 3：1000 + 3 × 32 = 1096 因此：\n不同 Chunk 之间不保证连续，但是同一个 Chunk 内部的 Block 是连续的。\n15. 再次理解头插法 # 假设当前链表为：\nfree_list → B → C → NULL 现在需要插入 A：\nA-\u0026gt;next = free_list; free_list = A; 第一句让 A 指向原来的表头 B：\nA → B → C → NULL 第二句让表头改为 A：\nfree_list → A → B → C → NULL 扩容循环不断进行相同操作，因此地址按从低到高计算出来，但最终链表顺序会被反转。\n16. 从内存池申请 Block # void *memory_pool_alloc(memory_pool_t *pool) { if (pool == NULL) { return NULL; } if (pool-\u0026gt;free_list == NULL) { if (memory_pool_grow(pool) != 0) { return NULL; } } free_node_t *node = pool-\u0026gt;free_list; pool-\u0026gt;free_list = node-\u0026gt;next; return (void *)node; } 自动扩容来自：\nif (pool-\u0026gt;free_list == NULL) { memory_pool_grow(pool); } 扩容成功后不能直接 return NULL，因为此时 free_list 已经重新拥有空闲 Block，应该继续向下取出一个。\n假设：\nfree_list → A → B → C → NULL 执行：\nfree_node_t *node = pool-\u0026gt;free_list; pool-\u0026gt;free_list = node-\u0026gt;next; 结果为：\n返回 node → A free_list → B → C → NULL 17. 将 Block 放回内存池 # void memory_pool_free(memory_pool_t *pool, void *ptr) { if (pool == NULL || ptr == NULL) { return; } free_node_t *node = (free_node_t *)ptr; node-\u0026gt;next = pool-\u0026gt;free_list; pool-\u0026gt;free_list = node; } 这里没有调用系统 free()，只是把 Block 重新插入空闲链表头部。\n释放前：free_list → B → C → NULL 释放 A：free_list → A → B → C → NULL 下一次申请会直接取出 A，这就是自动复用的来源。\n18. 销毁整个内存池 # void memory_pool_destroy(memory_pool_t *pool) { if (pool == NULL) { return; } memory_chunk_t *chunk = pool-\u0026gt;chunk_list; while (chunk != NULL) { memory_chunk_t *next = chunk-\u0026gt;next; free(chunk-\u0026gt;memory); free(chunk); chunk = next; } pool-\u0026gt;block_size = 0; pool-\u0026gt;blocks_per_chunk = 0; pool-\u0026gt;free_list = NULL; pool-\u0026gt;chunk_list = NULL; } 必须在释放当前 Chunk 前保存：\nmemory_chunk_t *next = chunk-\u0026gt;next; 如果先 free(chunk)，之后再访问 chunk-\u0026gt;next，就是访问已经释放的内存。\n不需要逐个释放 Block，因为 Block 不是分别调用 malloc 得到的。释放整个 chunk-\u0026gt;memory 后，其中所有 Block 会一起失效。\n19. 使用 ASan 测试 # 测试中设置：\nmemory_pool_init(\u0026amp;pool, 32, 2); blocks_per_chunk = 2 表示每次扩容创建两个 Block。\nMakefile 中的测试目标开启 AddressSanitizer：\ntest-memory-pool: $(BIN_DIR)/test_memory_pool ./$(BIN_DIR)/test_memory_pool $(BIN_DIR)/test_memory_pool: \\ $(TEST_DIR)/test_memory_pool.c \\ $(MEMORY_DIR)/memory_pool.c \\ $(INCLUDE_DIR)/memory_pool.h | $(BIN_DIR) $(CC) $(CPPFLAGS) $(CFLAGS) -Werror \\ -fsanitize=address -fno-omit-frame-pointer \\ $(TEST_DIR)/test_memory_pool.c \\ $(MEMORY_DIR)/memory_pool.c \\ -o $@ 运行：\nmake test-memory-pool 测试输出：\na = 0x506000000040 b = 0x506000000020 c = 0x506000000040 (same as a) d = 0x5060000000a0 (new chunk) memory pool test passed 20. 分析测试结果 # a 与 b 的地址相差：\n0x40 - 0x20 = 0x20 = 32 这与 block_size = 32 一致，说明它们是同一个 Chunk 中相邻的 Block。\n释放 A 后再次申请 C：\nc == a 说明被释放的 A 已经放回空闲链表，并被下一次申请成功复用。\nA、B 对应的两个 Block 都被占用后，继续申请 D。此时 free_list == NULL，触发 memory_pool_grow()：\nd = 0x5060000000a0 D 位于新的地址区域，说明第二个 Chunk 创建成功。\n21. ASan 是什么，后续是否关闭 # AddressSanitizer 简称 ASan，可以帮助发现：\n内存越界。 使用已经释放的内存。 重复释放。 释放非法地址。 内存泄漏。 我们的 ASan 只在 test-memory-pool 目标中开启。正常的 KV Store 构建没有自动开启。\n未来测试 QPS、虚拟内存和物理内存时必须关闭 ASan，因为它会加入额外检查和内存区域，影响性能与内存数据。\n22. 当前已经完成的内容 # 固定大小内存池 ├── 参数初始化 ├── Block 大小对齐 ├── Chunk 自动扩容 ├── Block 连续划分 ├── Free List 分配 ├── Block 回收与复用 ├── Chunk List 统一销毁 ├── ASan 单元测试 └── Makefile 测试目标 代码已经提交到独立分支：\nfeature/memory-pool 提交为：\nfeat(memory): add fixed-size memory pool 23. 为什么现在不能直接替换所有 kvs_malloc # 当前实现的是固定大小内存池。一个池只能返回同样大小的 Block。\n但是通用接口可能收到各种尺寸：\nkvs_malloc(16); kvs_malloc(100); kvs_malloc(4096); 单个固定大小池无法合理处理这些不同大小的申请，所以不能直接在 kvs_malloc() 内无条件调用当前的 memory_pool_alloc()。\n可变大小的内存管理当然存在，例如：\n多个固定大小池组成的大小类别。 Arena 或区域分配器。 Buddy allocator。 Slab allocator。 jemalloc 这样的通用分配器。 jemalloc 内部会管理多个大小类别，因此能够提供类似 malloc/free 的通用接口。\n24. 下一步为什么先集成 Hash 节点 # Hash 中主要涉及三类动态内存：\nhashnode_t：大小固定 key：长度不固定 value：长度不固定 当前固定大小内存池最适合管理 hashnode_t。\n第一版的分工应该是：\nhashnode_t → memory_pool_alloc / memory_pool_free key/value → 继续使用 kvs_malloc / kvs_free 未来的总体结构可以是：\n业务代码 │ ├── hashnode_t │ │ │ └── 固定大小内存池 │ └── key/value 等可变大小数据 │ └── kvs_malloc/kvs_free │ ┌──────┴──────┐ │ │ glibc malloc jemalloc 这样可以进行有意义的对比：\n全部使用系统 malloc/free。 Hash 节点使用自研固定大小内存池。 通用分配切换为 jemalloc。 之后分别测量：\nQPS。 虚拟内存。 物理内存。 分配与释放的耗时。 当前进度正好停在这里：\n内存池已经实现、测试并提交。下一步开始将它接入 Hash 节点的创建、删除和 Hash 销毁流程。\n现在开始接入hash的具体流程\n首当其冲的 是 将memorypool的头文件 加入的kv.h里 此外 在对应hash.c 的文件开头 定义 一次性 扩建的 blocks数量 #define HASH_NODE_BLOCKS_PER_CHUNK 1024\n在hash数据结构定义的地方 就是那个结构体 多加一个 memorypool 类型的 成员变量 可以直接写 而不是加*避免还要 申请内存的繁琐\n在hash_create 函数 参数验证之后 初始化我们的内存池 顺带参数验证``` if (memory_pool_init(\u0026amp;hash-\u0026gt;node_pool, sizeof(hashnode_t), HASH_NODE_BLOCKS_PER_CHUNK) != 0) { return -1; }\n25. 将内存池嵌入 Hash 结构 # 在 Hash 的管理结构中加入节点内存池：\ntypedef struct hashtable_s { hashnode_t **nodes; int max_slots; int count; memory_pool_t node_pool; } kvs_hash_t; 这里的：\nmemory_pool_t node_pool; 没有 *，表示 node_pool 管理结构直接嵌入 kvs_hash_t。\nkvs_hash_t ├── nodes ├── max_slots ├── count └── node_pool ├── block_size ├── blocks_per_chunk ├── free_list └── chunk_list 创建 kvs_hash_t 时，node_pool 也已经拥有存储空间，不需要再执行：\nmalloc(sizeof(memory_pool_t)); 但是“拥有结构体空间”不等于“内存池已经初始化”，所以仍然需要调用：\nmemory_pool_init(\u0026amp;hash-\u0026gt;node_pool, sizeof(hashnode_t), HASH_NODE_BLOCKS_PER_CHUNK); 这里必须使用 \u0026amp;：\n\u0026amp;hash-\u0026gt;node_pool 因为 memory_pool_init() 需要的是：\nmemory_pool_t * 而 hash-\u0026gt;node_pool 本身是一个 memory_pool_t 对象，使用 \u0026amp; 才能取得它的地址。\n26. 初始化 Hash 节点内存池 # 每个 Chunk 暂定包含 1024 个 Hash 节点 Block：\n#define HASH_NODE_BLOCKS_PER_CHUNK 1024 在 kvs_hash_create() 中初始化：\nif (memory_pool_init(\u0026amp;hash-\u0026gt;node_pool, sizeof(hashnode_t), HASH_NODE_BLOCKS_PER_CHUNK) != 0) { return -1; } 参数含义：\n\u0026amp;hash-\u0026gt;node_pool 要初始化哪个内存池 sizeof(hashnode_t) 每个 Block 能够存放一个 hashnode_t HASH_NODE_BLOCKS_PER_CHUNK 每次扩容产生 1024 个 Block 此时仍然没有真正申请 Chunk，因为当前内存池采用懒加载。第一次创建 Hash 节点时才会触发扩容。\n27. 为 Hash 节点封装存储策略 # 为了能够在“内存池”和“普通 malloc”之间切换，加入编译开关：\n#ifndef KVS_HASH_USE_MEMORY_POOL #define KVS_HASH_USE_MEMORY_POOL 1 #endif 含义：\nKVS_HASH_USE_MEMORY_POOL = 1 Hash 节点来自内存池 KVS_HASH_USE_MEMORY_POOL = 0 Hash 节点来自 kvs_malloc 节点申请被封装为一个独立函数：\nstatic hashnode_t *hash_node_alloc(kvs_hash_t *hash) { if (hash == NULL) { return NULL; } #if KVS_HASH_USE_MEMORY_POOL return (hashnode_t *) memory_pool_alloc(\u0026amp;hash-\u0026gt;node_pool); #else return (hashnode_t *) kvs_malloc(sizeof(hashnode_t)); #endif } 节点存储空间的释放也统一封装：\nstatic void hash_node_storage_free(kvs_hash_t *hash, hashnode_t *node) { if (hash == NULL || node == NULL) { return; } #if KVS_HASH_USE_MEMORY_POOL memory_pool_free(\u0026amp;hash-\u0026gt;node_pool, node); #else kvs_free(node); #endif } 这样业务代码不用到处分散编写条件编译：\n#if KVS_HASH_USE_MEMORY_POOL ... #else ... #endif _create_node() 只负责调用：\nhashnode_t *node = hash_node_alloc(hash); 其他地方只负责调用：\nhash_node_storage_free(hash, node); 这相当于在 Hash 业务逻辑与底层内存策略之间加了一层隔离。\n28. Hash 节点和 key/value 使用不同的管理方式 # 当前只把固定大小的 hashnode_t 接入内存池：\nhashnode_t → memory_pool_alloc → memory_pool_free key/value → kvs_malloc → kvs_free 因此释放完整节点时，顺序仍然是：\nstatic void _hash_node_free(kvs_hash_t *hash, hashnode_t *node) { if (hash == NULL || node == NULL) { return; } #if ENABLE_KEY_POINTER kvs_free(node-\u0026gt;key); kvs_free(node-\u0026gt;value); #endif hash_node_storage_free(hash, node); } 不能先把 node 放回内存池，再访问：\nnode-\u0026gt;key node-\u0026gt;value 因为 memory_pool_free() 会把这个 Block 的前几个字节用作空闲链表的 next，原来的节点内容不再属于业务代码。\n29. 删除节点时必须先断开链表 # 删除 Hash 冲突链中的头节点时，曾经出现过一个错误：\n_hash_node_free(hash, head); 只释放节点，却没有修改桶数组中的表头指针。\n正确顺序是：\nhash-\u0026gt;nodes[idx] = head-\u0026gt;next; _hash_node_free(hash, head); 执行过程：\n释放前： hash-\u0026gt;nodes[idx] → head → next → NULL 先更新表头：\nhash-\u0026gt;nodes[idx] ───────→ next → NULL ↑ head 即将被释放 再释放 head：\n_hash_node_free(hash, head); 删除中间节点时同样要先保存目标节点并断开连接：\nhashnode_t *tmp = cur-\u0026gt;next; cur-\u0026gt;next = tmp-\u0026gt;next; _hash_node_free(hash, tmp); 这个原则可以概括为：\n先保证数据结构可以延续，再清除旧节点。\n如果先释放节点，但链表或桶数组仍然指向它，就会留下悬空指针。\n之前的 ASan 错误正是由这个问题导致的：\nattempting free on address which was not malloc()-ed 原因是已经归还内存池的 Block 后来被复用，但 Hash 桶仍然保存旧地址，销毁时又把它当成有效业务节点处理。\n30. 销毁 Hash 与销毁内存池 # 销毁 Hash 时，需要依次处理：\n节点中的 key/value → Hash 桶数组 → 内存池的全部 Chunk → 清空 Hash 状态 核心流程：\nvoid kvs_hash_destory(kvs_hash_t *hash) { if (hash == NULL) { return; } for (int i = 0; i \u0026lt; hash-\u0026gt;max_slots; i++) { hashnode_t *node = hash-\u0026gt;nodes[i]; while (node != NULL) { hashnode_t *next = node-\u0026gt;next; _hash_node_free(hash, node); node = next; } hash-\u0026gt;nodes[i] = NULL; } kvs_free(hash-\u0026gt;nodes); hash-\u0026gt;nodes = NULL; #if KVS_HASH_USE_MEMORY_POOL memory_pool_destory(\u0026amp;hash-\u0026gt;node_pool); #endif hash-\u0026gt;max_slots = 0; hash-\u0026gt;count = 0; } 这里仍然要在释放当前节点前保存：\nhashnode_t *next = node-\u0026gt;next; 否则释放后再访问 node-\u0026gt;next 就属于使用已经释放的内存。\n需要注意，当前项目中的函数名实际拼写为：\ndestory 标准英文拼写应当是：\ndestroy 以后可以统一重命名，但必须同时修改声明、定义和所有调用位置。\n31. Hash 与内存池的 ASan 集成测试 # 集成测试验证了以下内容：\nHash 能够成功创建。 第一次插入节点会触发 Chunk 懒加载。 HSET/HGET/HDEL 行为正确。 删除节点后，Block 会归还空闲链表。 再次插入时能够复用刚释放的 Block。 销毁 Hash 后，桶数组和内存池状态被清空。 ASan 没有发现非法释放、越界或内存泄漏。 内存池模式输出：\nfirst node = 0x52b0000061e8 second node = 0x52b0000061e8 memory pool reused the released node block hash memory pool integration test passed 两个地址相同，说明第二次创建节点时复用了第一次删除后归还的 Block。\n普通 malloc 模式输出：\nfirst node = 0x503000000040 second node = 0x503000000070 malloc mode does not require address reuse hash memory pool integration test passed 两个地址不同也不是错误，因为 malloc 不保证下一次申请一定返回刚释放的地址。\n因此测试中的地址断言只能在内存池模式启用：\n#if KVS_HASH_USE_MEMORY_POOL assert(second_node == first_node); #endif 32. 同一份测试验证两种内存策略 # Hash 的通用功能在两种模式下都必须成立：\nassert(kvs_hash_set(\u0026amp;hash, \u0026#34;name\u0026#34;, \u0026#34;Jasper\u0026#34;) == 0); assert(kvs_hash_get(\u0026amp;hash, \u0026#34;name\u0026#34;) != NULL); assert(kvs_hash_del(\u0026amp;hash, \u0026#34;name\u0026#34;) == 0); 内存池内部状态只在内存池模式检查：\n#if KVS_HASH_USE_MEMORY_POOL assert(hash.node_pool.chunk_list != NULL); assert(hash.node_pool.free_list != NULL); #endif 测试逻辑因此被分为两部分：\nHash 通用行为 两种模式都检查 内存池专属行为 只在内存池模式检查 只有先证明两个版本的业务功能一致，后面的性能对比才有意义。\n33. 使用 Makefile 切换两种服务器版本 # Makefile 中加入：\nHASH_USE_MEMORY_POOL ?= 1 CPPFLAGS += -DKVS_HASH_USE_MEMORY_POOL=$(HASH_USE_MEMORY_POOL) 其中：\nHASH_USE_MEMORY_POOL ?= 1 表示外部没有指定时，默认值为 1。\n编译内存池版本：\nmake clean make HASH_USE_MEMORY_POOL=1 all testcase 编译普通 malloc 版本：\nmake clean make HASH_USE_MEMORY_POOL=0 all testcase 切换模式时必须执行 make clean。\n原因是 Make 通常根据源文件和目标文件的修改时间判断是否需要重新编译，它不知道编译参数中的宏已经发生变化。\n另外：\nmake HASH_USE_MEMORY_POOL=0 只会影响当前这一次 make，不会永久修改 Makefile 变量。\n34. 压测前移除调试输出 # 客户端原来每个成功请求都会打印：\nprintf(\u0026#34;==\u0026gt;PASS-\u0026gt; %s\\n\u0026#34;, casename); 大量终端输出会严重干扰 QPS，因此压测时将其注释：\n// printf(\u0026#34;==\u0026gt;PASS-\u0026gt; %s\\n\u0026#34;, casename); 失败输出仍然保留，这样请求结果错误时测试会立即终止。\n服务器中还存在协议解析调试输出：\nprintf(\u0026#34;idx:%d,%s\\n\u0026#34;, idx, token); 它位于 src/kvstore.c，同样被注释：\n// printf(\u0026#34;idx:%d,%s\\n\u0026#34;, idx, token); 性能测试时，客户端和服务器都不应该为每条请求打印日志。\n35. 将 Hash 压测集中到申请和释放 # 原来的 Hash 测试每轮包含 9 个命令：\nHSET HGET HMOD HGET HEXIST HDEL HGET HMOD HEXIST 其中只有：\nHSET → 创建节点 HDEL → 删除节点 直接涉及节点内存的申请和释放。\n其他 7 个请求会稀释内存分配策略对 QPS 的影响。因此第一版定向压测改为：\nfor (int i = 0; i \u0026lt; count; i++) { testcase(connfd, \u0026#34;HSET Dad Jasper\u0026#34;, \u0026#34;OK\\r\\n\u0026#34;, \u0026#34;HSET-Dad\u0026#34;); testcase(connfd, \u0026#34;HDEL Dad\u0026#34;, \u0026#34;OK\\r\\n\u0026#34;, \u0026#34;HDEL-Dad\u0026#34;); } 当前设置：\nint count = 10000; 因此实际请求总数为：\n10000 × 2 = 20000 QPS 不能继续使用原来的 90000，而应该根据实际请求数计算：\nint total_requests = count * 2; int qps = total_requests * 1000 / time_used; 这里故意重复使用同一个 key，是为了让业务状态始终在以下两种状态之间切换：\n不存在节点 ↓ HSET 存在一个节点 ↓ HDEL 不存在节点 这样可以集中观察节点反复申请和释放的开销。\n36. 隔离快照与 AOF 对压测的影响 # 服务器启动时会读取：\nkvs_snapshot_load(\u0026#34;snapshot.db\u0026#34;, kvs_protocol); kvs_aof_open(\u0026#34;appendonly.aof\u0026#34;); 这两个都是相对路径。\n如果直接从项目目录启动服务器，旧的快照和 AOF 可能影响：\n初始节点数量。 Hash 冲突链长度。 RSS 和 VIRT。 测试 key 是否已经存在。 两次实验的公平性。 因此使用独立的临时工作目录启动服务器：\nmkdir /tmp/kvbench-pool-churn1 cd /tmp/kvbench-pool-churn1 /home/jaspersao/dev/91kvstore/bin/kvstore 普通 malloc 版本使用另一个目录：\nmkdir /tmp/kvbench-malloc-churn1 cd /tmp/kvbench-malloc-churn1 /home/jaspersao/dev/91kvstore/bin/kvstore 虽然可执行文件位于项目目录，但服务器的当前工作目录位于 /tmp，所以相对路径文件会创建在对应的临时目录中：\n/tmp/kvbench-*/snapshot.db /tmp/kvbench-*/appendonly.aof 这不会移动或覆盖项目目录中的原始持久化数据。\n37. 第一轮定向 QPS 对比 # 测试条件：\n每种模式都从空数据启动 使用相同客户端 使用相同协议 执行 10000 轮 每轮一次 HSET 和一次 HDEL 总请求数为 20000 关闭逐请求成功日志 关闭服务器 idx 调试日志 未开启 ASan 第一轮结果：\n内存策略 请求数 耗时 QPS 普通 malloc/free 20000 3658 ms 5467 自研固定大小内存池 20000 3688 ms 5422 内存池版本在这一轮中大约慢：\n(5467 - 5422) / 5467 ≈ 0.8% 0.8% 的差距很小，暂时应当认为两者基本持平，不能据此断言 malloc 一定更快。\n当前实验没有证明内存池能够提升整个服务器的 QPS。\n38. 为什么当前 QPS 没有明显提升 # 可能原因包括：\n测试包含 TCP 网络收发。 每条命令还需要协议解析。 HSET/HDEL 可能写入 AOF。 内存池只管理 hashnode_t。 key 和 value 仍然使用 malloc/free。 glibc 的小对象分配本身已经经过优化。 虚拟机调度会带来一定波动。 当前只进行了一轮对比。 当前构建使用 -g，还没有使用统一的发布优化参数。 因此当前测试测量的是：\n客户端 → TCP → 网络模块 → 协议解析 → Hash → 内存管理 → AOF → 返回响应 内存池只是整条链路中的一个环节，它的差异可能被其他开销淹没。\n39. 服务器整体性能与分配器性能需要分开测 # 后续应该保留两类实验。\n第一类是端到端测试：\n客户端 → 网络 → KV Server → Hash → 内存管理 它回答：\n接入内存池后，真实服务器整体 QPS 是否提高？\n第二类是进程内测试：\n直接调用 Hash 创建和删除 不经过网络 不写 AOF 它回答：\n单独观察节点申请和释放时，内存池本身是否比 malloc 更快？\n如果进程内测试更快，但服务器整体 QPS 没变化，说明瓶颈不在节点分配。\n如果进程内测试也没有变快，则需要继续检查：\nChunk 大小是否合适。 函数调用开销。 Block 布局。 是否需要批量分配。 是否需要为 key/value 引入其他管理策略。 40. 当前项目进度 # 目前已经完成：\n固定大小内存池 ├── Chunk 扩容 ├── Block 对齐 ├── Free List ├── Chunk List ├── Block 回收复用 ├── 统一销毁 └── ASan 单元测试 Hash 接入 ├── hashtable 内嵌 node_pool ├── 创建时初始化内存池 ├── 节点申请策略封装 ├── 节点释放策略封装 ├── key/value 与节点分开释放 ├── 删除节点前断开链表 ├── Hash 销毁时销毁内存池 └── ASan 集成测试 对比能力 ├── 编译时切换 malloc/内存池 ├── Makefile 传递切换宏 ├── 关闭客户端逐请求日志 ├── 关闭服务器调试日志 ├── 使用独立目录隔离快照和 AOF └── 完成第一轮定向 QPS 对比 当前得到的阶段性结论：\n自研固定大小内存池已经正确接入 Hash 节点，并能够复用释放的节点 Block；但在当前端到端测试中，它与普通 malloc/free 的 QPS 基本持平，尚未表现出明显优势。\n尚未完成：\n1. 重复多轮 QPS 测试并统计平均值。 2. 使用统一的 -O2 优化参数重新测试。 3. 编写不经过网络和 AOF的进程内分配性能测试。 4. 构造大量节点同时存活的负载。 5. 测量 VIRT 和 RSS。 6. 接入 jemalloc 形成第三组对比。 7. 根据结果决定是否调整 Chunk 大小。 8. 决定是否把内存池接入红黑树和跳表节点。 41. 进程内 Hash 分配器压测 # 之前的端到端压测需要经过：\n客户端 → TCP → 协议解析 → Hash → 内存分配 → AOF → TCP 返回 这条链路包含很多与内存池无关的开销，所以即使节点分配变快，最终的服务器 QPS 也可能没有明显变化。\n为了单独观察分配器，我们新增了：\ntests/benchmark_hash_allocator.c 它直接在进程内反复执行：\nHSET 对应的 kvs_hash_set() ↓ HDEL 对应的 kvs_hash_del() 不经过网络、不解析协议，也不写 AOF。\n测试始终使用同一个 key，意味着任意时刻最多只有一个 Hash 节点存活。这样做不是为了模拟大量数据，而是为了集中制造节点的反复申请与释放，尽量隔离分配器本身的开销。\n本次采用：\nrounds: 100000000 operations: 200000000 每轮包含一次 SET 和一次 DEL，所以总操作数等于 rounds * 2。\n测试使用 clock_gettime(CLOCK_MONOTONIC, ...) 计时。CLOCK_MONOTONIC 是单调递增时钟，不会因为系统时间被校准而突然向前或向后跳动，适合测量耗时。\n42. 进程内吞吐量测试结果 # 内存池版本：\nallocator: memory_pool rounds: 100000000 operations: 200000000 elapsed: 3.180859 seconds ops/sec: 62876099 malloc 版本：\nallocator: malloc rounds: 100000000 operations: 200000000 elapsed: 3.577090 seconds ops/sec: 55911367 按照这一次实验的数据计算：\n吞吐量提升 ≈ (62876099 - 55911367) / 55911367 ≈ 12.5% 阶段性结论：\n在反复申请和释放固定大小 hashnode_t 的进程内场景中，自研内存池比普通 malloc 具有明显的吞吐量优势。\n这个结果与端到端压测并不矛盾：\n进程内压测说明内存池这个局部组件确实更快。 端到端压测基本持平，说明节点分配不是当前服务器整条请求链路的主要耗时。 因此不能简单地说“内存池没有用”，也不能说“服务器已经快了 12.5%”。准确说法是：\n内存池优化了 Hash 节点分配，但这部分优势目前被网络、协议和持久化等开销稀释了。\n43. 修复字符串复制与修改失败的问题 # 在使用 -Werror 编译基准测试时，编译器指出原代码存在以下写法：\nstrncpy(kcopy, key, strlen(key)); 虽然前面使用 memset 清零可以让字符串在当前情况下以 \\0 结尾，但这种写法把“正确性”分散在两条语句中，也会触发字符串截断警告。\n最终改为：\nmemcpy(kcopy, key, strlen(key) + 1); + 1 会把字符串末尾的 \\0 一起复制过去。\n在修改 value 时，还调整了操作顺序：\n先申请新 value → 确认申请成功 → 复制新内容 → 再释放旧 value → 替换指针 如果先释放旧 value，再申请新 value，一旦申请失败，节点就会留下一个已经失效的悬空指针。这里再次使用了“可延续清除”的思想：先准备好后继状态，再破坏旧状态。\n44. 为什么内存占用测试要同时创建大量节点 # 分配速度测试只保留一个节点，是为了反复刺激 alloc/free。\n内存占用测试的目标不同。它需要让大量节点同时存活，才能观察：\n每个节点的分配开销。 Chunk 的总体占用。 malloc 的元数据和对齐开销。 删除和销毁前后的内存变化。 因此新增：\ntests/benchmark_hash_memory.c 测试插入 20 万个不同的 key：\nfor (int i = 0; i \u0026lt; entries; i++) { snprintf(key, sizeof(key), \u0026#34;key-%d\u0026#34;, i); snprintf(value, sizeof(value), \u0026#34;value-%d\u0026#34;, i); kvs_hash_set(\u0026amp;hash, key, value); } snprintf 的作用是按照格式生成字符串，同时限制最多写入多少字节，避免写出数组边界。例如当 i == 12 时：\nkey = \u0026#34;key-12\u0026#34; value = \u0026#34;value-12\u0026#34; 虽然循环一直复用栈上的 key 和 value 数组，但 kvs_hash_set 会复制字符串内容，因此已经插入 Hash 的节点不会依赖这两个临时数组。\n45. 用阶段暂停观察内存 # 测试中定义了：\nstatic void wait_for_measurement(const char *phase) { long pid = (long)getpid(); printf(\u0026#34;Phase: %s\\n\u0026#34;, phase); printf(\u0026#34;PID: %ld\\n\u0026#34;, pid); printf(\u0026#34;press Enter to continue...\\n\u0026#34;); fflush(stdout); getchar(); } 其中：\nphase 表示当前测量阶段。 getpid() 取得当前测试进程的 PID。 fflush(stdout) 立即刷新标准输出缓冲区，确保提示信息已经显示到终端。 getchar() 等待一个字符，为我们人为制造暂停点；按 Enter 后程序继续。 这里不使用 fsync，因为两者解决的问题不同：\nfflush 刷新 C 标准库的用户态输出缓冲区。 fsync 要求把文件描述符对应的数据同步到底层存储设备，主要用于文件持久化。 终端提示只需要 fflush(stdout)。\n测试设置了三个暂停阶段：\nafter insert → 20 万个节点仍然存活 after delete → 节点全部删除 after destroy → Hash 和内存池已经销毁 46. 用 ps 查看 VSZ 和 RSS # 测量命令为：\nps -o pid,vsz,rss,comm -p PID 各字段含义：\nps：查看进程状态的快照。 -o：指定需要显示的字段。 pid：进程编号。 vsz：Virtual Size，进程虚拟地址空间大小。 rss：Resident Set Size，当前驻留在物理内存中的部分。 comm：程序的命令名。 -p PID：只查看指定 PID。 Linux 中 VSZ 和 RSS 默认以 KiB 为单位。\n需要特别记住：\nVSZ / VIRT：虚拟内存，不等于实际占用的物理内存 RSS / RES：当前驻留在物理内存中的内存 47. 内存池与 malloc 的内存测量结果 # 测试条件：20 万个不同 Hash 节点同时存活。\n分配方式 阶段 VSZ RSS memory pool after insert 19416 KiB 18688 KiB memory pool after delete 19416 KiB 18688 KiB memory pool after destroy 19416 KiB 18688 KiB malloc after insert 21000 KiB 20232 KiB malloc after delete 21000 KiB 20232 KiB malloc after destroy 21000 KiB 20232 KiB 插入完成时，malloc 相比内存池多出：\nVSZ: 21000 - 19416 = 1584 KiB RSS: 20232 - 18688 = 1544 KiB RSS 大约多出 1.51 MiB。平均到 20 万个节点：\n1544 * 1024 / 200000 ≈ 7.9 字节/节点 这与 malloc 的单次分配元数据、大小分类及地址对齐开销相符。内存池提前知道所有 Block 大小相同，因此不需要为每个正在使用的节点单独记录完整的大小信息；空闲 Block 还可以直接把自身空间当作 free_node_t 来保存 next。\n但这只是当前机器、当前结构大小和当前负载下的实验结果，不能直接推广到所有程序。\n48. 为什么 delete 和 destroy 后 RSS 没有下降 # 内存池模式执行删除时：\nHash 节点从桶链表断开 → Block 放回 free_list → Chunk 仍然存在 因此 after delete 内存不下降完全符合设计。保留的 Block 可以在后续插入时立即复用。\n内存池执行 destroy 时会调用：\nfree(chunk-\u0026gt;memory); free(chunk); 但是 free 通常只表示把空间归还给当前进程内部的分配器。分配器可能为了后续申请而保留这些内存，不一定立即归还操作系统，所以 ps 看到的 VSZ/RSS 可能暂时不变。\nmalloc 模式也出现了同样现象：每个节点已经执行 free，但 glibc 仍可能保留释放后的堆内存。\n因此：\nRSS 没有立即下降，不能单独作为内存泄漏的证据。\n内存是否被错误遗失，应该结合以下手段判断：\nASan/LeakSanitizer 是否报告泄漏。 分配与释放的所有权是否匹配。 内存池的 Chunk 是否都能从 chunk_list 找到并销毁。 进程长期运行时占用是否无上限增长。 当测试进程最终退出后，再次执行 ps -p PID 只剩表头，没有进程记录。这说明操作系统已经回收该进程的全部资源。\n49. glibc 是不是“内存缓存页” # 不是。\nglibc 是 GNU C Library，即 GNU C 标准库。它向 C 程序提供大量常用能力，例如：\nprintf / fopen / memcpy malloc / free 线程和系统调用封装 其中 malloc/free 只是 glibc 的一个组成部分。glibc 内部的内存分配器会向操作系统申请较大的内存区域，再把它们切分给程序；程序调用 free 后，分配器也可能暂时缓存这些空闲区域，以便后续复用。\n“内存页”则是操作系统进行虚拟内存映射和物理内存管理的基本单位，在常见系统中一页通常是 4 KiB，但实际页大小需要以具体系统为准。\n可以把层级暂时理解为：\n我们的 Hash ↓ 申请 hashnode_t 自研内存池 或 glibc malloc ↓ 需要更大的内存区域 Linux 虚拟内存系统 ↓ 按页建立映射 物理内存 所以准确说法是：\nglibc 不是内存缓存页；glibc 的 malloc 分配器会管理和复用进程的堆内存，而操作系统在更底层以页为单位管理虚拟内存与物理内存。\n50. 当前阶段结论与下一步 # 目前已经完成原计划中的两项核心对比：\n进程内吞吐量：内存池约 6288 万 ops/s malloc 约 5591 万 ops/s 20 万节点 RSS：内存池约 18688 KiB malloc 约 20232 KiB 当前结论：\n固定大小内存池适合管理 hashnode_t。 在节点高频申请/释放的局部测试中，内存池速度更快。 在大量节点同时存活时，内存池的 RSS 和 VSZ 更低。 在完整服务器链路中，内存池带来的局部优势暂时被其他开销掩盖。 free 完成与 RSS 立即下降不是同一个概念。 接下来的计划：\n1. 把 Hash 内存占用测试加入 Makefile。 2. 适当重复内存实验，确认数据稳定性。 3. 接入 jemalloc，形成 malloc / memory pool / jemalloc 三组对比。 4. 比较三者的进程内吞吐量、VSZ 和 RSS。 5. 根据结果评估 Chunk 大小是否需要调整。 6. 再决定是否把内存池接入红黑树和跳表。 51. 先整理当前测试体系 # 随着测试文件增加，一度很难分清每个文件究竟在测什么。现在把它们按“正确性、速度、内存、完整链路”重新整理：\n文件 类型 回答的问题 tests/test_memory_pool.c 内存池单元测试 内存池自身能否申请、归还、复用、扩容和销毁？ tests/test_hash_memory_pool.c Hash 集成测试 Hash 接入内存池后，CRUD 和节点复用是否正常？ tests/benchmark_hash_allocator.c 进程内速度测试 不经过网络时，不同分配器处理 Hash SET/DEL 的速度如何？ tests/benchmark_hash_memory.c 进程内内存测试 20 万节点时，不同分配器的 VSZ/RSS 如何变化？ tests/testcase.c 端到端测试 经过 TCP、协议、Hash 和 AOF 的完整服务器表现如何？ 最简记忆：\ntest → 判断对不对 benchmark → 测量快不快、占多少 测试顺序也形成了清晰层次：\n先证明内存池自身正确 → 再证明 Hash 接入正确 → 再测局部分配速度 → 再测进程内存 → 最后观察完整服务器 52. 简化 Makefile，重新取得控制权 # 原先为了同时保存 pool/malloc 两套 benchmark 二进制，Makefile 中复制了多份几乎相同的规则。虽然能够工作，但重复内容较多，已经不容易掌控。\n现在改为通过一个变量控制分配策略：\nHASH_USE_MEMORY_POOL ?= 1 两个 benchmark 目标分别只保留一份规则：\nbenchmark-hash → 编译速度测试 benchmark-hash-memory → 编译内存测试 使用方法：\n# 编译自研内存池版本 make HASH_USE_MEMORY_POOL=1 benchmark-hash # 编译 glibc malloc 版本 make HASH_USE_MEMORY_POOL=0 benchmark-hash 内存测试同理：\nmake HASH_USE_MEMORY_POOL=1 benchmark-hash-memory make HASH_USE_MEMORY_POOL=0 benchmark-hash-memory Makefile 现在只负责编译，不再自动运行 benchmark。这样“编译哪一种分配器”和“什么时候运行”都由开发者明确控制。\n检查 Makefile 时使用了：\nmake -n HASH_USE_MEMORY_POOL=1 benchmark-hash-memory make -n 只打印 Make 准备执行的命令，不真正执行，也叫 dry run。它适合检查最终宏值、编译参数以及是否存在意外运行步骤。\n53. malloc 是接口，glibc/jemalloc 是实现 # 之前容易把 malloc 理解为一个固定的内存分配器。更准确的理解是：\nvoid *malloc(size_t size); void free(void *ptr); malloc/free 是程序调用的统一接口，具体如何管理内存由背后的实现决定。\nUbuntu 默认情况：\n业务代码 → kvs_malloc → malloc → glibc 提供的分配器实现 链接 jemalloc 后：\n业务代码 → kvs_malloc → malloc → jemalloc 提供的分配器实现 jemalloc 提供了与标准 malloc/free 兼容的符号，所以业务代码不需要把 malloc 改成其他函数名。链接时增加：\n-ljemalloc 就可以让程序链接 libjemalloc.so。\n最简记忆：\n代码决定“调用 malloc”，链接决定“谁实现 malloc”。\n只有需要使用 jemalloc 独有的 mallctl、mallocx 等接口时，才需要显式包含：\n#include \u0026lt;jemalloc/jemalloc.h\u0026gt; 本轮只是公平替换标准分配器，没有使用专用接口。\n54. 安装并验证 jemalloc # Ubuntu 24.04 ARM64 软件源提供：\nlibjemalloc-dev 5.3.0-2build1 安装命令：\nsudo apt install libjemalloc-dev 简单区分：\nlibjemalloc2 → 让已链接 jemalloc 的程序运行 libjemalloc-dev → 让自己的程序能够编译并链接 jemalloc 安装 -dev 包时，apt 自动安装了运行时包 libjemalloc2。\n使用以下命令检查系统动态库：\nldconfig -p | grep jemalloc 结果：\nlibjemalloc.so.2 =\u0026gt; /lib/aarch64-linux-gnu/libjemalloc.so.2 libjemalloc.so =\u0026gt; /lib/aarch64-linux-gnu/libjemalloc.so 编译 jemalloc 版 benchmark 时同时使用：\n-DKVS_HASH_USE_MEMORY_POOL=0 -ljemalloc 第一项关闭自研节点池，第二项真正链接 jemalloc。\n使用 ldd 验证：\nldd /tmp/benchmark_hash_jemalloc | grep jemalloc 结果出现 libjemalloc.so.2，证明程序运行时确实加载了 jemalloc。\n即使 jemalloc 版同时显示 libc.so.6 也不矛盾，因为 printf、字符串处理等其他 C 标准库能力仍然来自 glibc；被替换的是内存分配接口的实现。\n55. 自研内存池能否和 jemalloc 组合 # 可以，因为二者工作在不同层级：\nHash 节点 → 自研内存池管理固定大小 Block → 内存池需要扩容 Chunk → Chunk 通过底层 malloc 申请 → malloc 可以由 jemalloc 实现 此外，当前 key/value 没有进入固定大小节点池：\nhashnode_t → memory_pool_alloc key/value → kvs_malloc → malloc 因此链接 jemalloc 后可以形成：\nhashnode_t → 自研内存池 └── Chunk 由 jemalloc 提供 key/value → jemalloc 理论上可以形成四种组合：\n模式 Hash 节点 key/value glibc glibc glibc jemalloc jemalloc jemalloc memory pool + glibc 自研内存池 glibc memory pool + jemalloc 自研内存池 jemalloc 本轮先比较前三种主要模式，混合模式留到后续作为扩展实验。\n56. Arena 的简单理解 # Arena 可以理解为分配器内部的一座独立仓库：\nBlock = 仓库中的一个货位 Chunk = 一批一起申请的货架 Arena = 管理多批货架和货位的仓库 Arena 内部会管理空闲内存、大小类别和必要的同步结构。多个线程使用不同 Arena，可以减少同时申请内存时的锁竞争：\n线程 A → Arena A 线程 B → Arena B 线程 C → Arena C 但 Arena 不是越多越好。各 Arena 独立管理内存，会增加固定管理开销，也可能导致某个 Arena 有空闲空间、另一个 Arena 却不能直接利用，从而增加碎片或虚拟内存。\n一句话：\n多个 Arena 用更多独立仓库换取更少排队，代价是更多管理开销和潜在空闲空间。\n57. 修正 benchmark 的分配器标签 # 最初 jemalloc 版仍打印：\nallocator: malloc 原因是原程序只区分“自研内存池开启/关闭”，并不知道标准 malloc 背后来自 glibc 还是 jemalloc。\n标签判断改为：\n#if KVS_HASH_USE_MEMORY_POOL const char *allocator = \u0026#34;memory_pool\u0026#34;; #elif defined(KVS_BENCHMARK_JEMALLOC) const char *allocator = \u0026#34;jemalloc\u0026#34;; #else const char *allocator = \u0026#34;glibc_malloc\u0026#34;; #endif 编译 jemalloc 版本时增加：\n-DKVS_BENCHMARK_JEMALLOC=1 需要明确：\nKVS_BENCHMARK_JEMALLOC → 只负责显示正确标签 -ljemalloc → 真正改变 malloc/free 的实现 58. 三方进程内吞吐量对比 # 为了减少单次运行波动，在同一时段依次运行 glibc、自研内存池和 jemalloc，共进行三轮。每个程序执行：\n100000000 轮 SET + DEL 200000000 次 Hash 操作 原始结果：\n轮次 分配器 elapsed ops/sec 1 glibc malloc 3.863011 秒 51,773,090 1 memory pool 3.413726 秒 58,587,006 1 jemalloc 3.196506 秒 62,568,315 2 glibc malloc 3.847538 秒 51,981,298 2 memory pool 3.466388 秒 57,696,942 2 jemalloc 3.253883 秒 61,465,034 3 glibc malloc 3.812338 秒 52,461,241 3 memory pool 3.426403 秒 58,370,256 3 jemalloc 3.224374 秒 62,027,550 三轮平均值：\n分配器 平均 elapsed 平均 ops/sec glibc malloc 3.840962 秒 52,071,876 memory pool 3.435506 秒 58,218,068 jemalloc 3.224921 秒 62,020,300 按平均吞吐量计算：\nmemory pool 比 glibc malloc 高约 11.8% jemalloc 比 glibc malloc 高约 19.1% jemalloc 比 memory pool 高约 6.5% 三轮排名一致，并且每种分配器自身波动不大，因此比此前单轮结果更有参考意义。\n但实验仍有局限：三轮始终按照 glibc → pool → jemalloc 的固定顺序运行，后续还可以轮换或随机化顺序，减少 CPU 温度、调度等顺序因素。\n59. 为什么这组数据不能证明 jemalloc 的节点分配一定比内存池快 # 每次 kvs_hash_set 实际可能包含三次分配：\n1 个 hashnode_t 1 段 key 1 段 value 三种模式的实际路径为：\nglibc： node、key、value 全部由 glibc 管理 memory pool： node 由自研内存池管理 key、value 仍由 glibc 管理 jemalloc： node、key、value 全部由 jemalloc 管理 因此当前 benchmark 测到的是完整 Hash SET/DEL 分配路径，不是只测 hashnode_t 的裸分配器性能。\n当前可以准确地说：\n在这次完整 Hash SET/DEL 分配路径中，jemalloc 最快，自研内存池其次，glibc malloc 最慢。\n但不能直接说：\njemalloc 的单次固定大小节点分配一定比自研内存池快。\n如果将来要回答后一个问题，需要编写更纯粹的分配器微基准，只申请和释放 hashnode_t 大小的内存，不进行 key/value 分配和 Hash 查找。\n60. jemalloc 的 VSZ/RSS 测量 # jemalloc 内存测试仍使用 20 万个不同节点和三个暂停点：\nafter insert after delete after destroy jemalloc 结果：\n阶段 VSZ RSS after insert 24120 KiB 16572 KiB after delete 24120 KiB 12476 KiB after destroy 24120 KiB 12476 KiB 删除节点后：\nVSZ 变化：0 KiB RSS 变化：-4096 KiB 这说明虚拟地址空间仍然被保留，但约 4 MiB 页面在本次测量时已经不再驻留于物理内存。\n61. 三方 VSZ/RSS 对比 # 完整对比数据：\n分配器 阶段 VSZ RSS memory pool after insert 19416 KiB 18688 KiB memory pool after delete 19416 KiB 18688 KiB memory pool after destroy 19416 KiB 18688 KiB glibc malloc after insert 21000 KiB 20232 KiB glibc malloc after delete 21000 KiB 20232 KiB glibc malloc after destroy 21000 KiB 20232 KiB jemalloc after insert 24120 KiB 16572 KiB jemalloc after delete 24120 KiB 12476 KiB jemalloc after destroy 24120 KiB 12476 KiB 只看插入 20 万节点后的状态：\n分配器 VSZ 排名 RSS 排名 memory pool 最低 第二 glibc malloc 第二 最高 jemalloc 最高 最低 jemalloc 相比自研内存池：\nVSZ 多 4704 KiB RSS 少 2116 KiB，约少 11.3% jemalloc 相比 glibc malloc：\nVSZ 多 3120 KiB RSS 少 3660 KiB，约少 18.1% 这说明“内存占用”不能只用一个数字描述：\n看虚拟地址空间 VSZ → 自研内存池最低 看实际驻留物理内存 RSS → jemalloc 最低 jemalloc 可以保留较大的虚拟地址范围，同时让较少页面实际驻留。删除后 RSS 下降、VSZ 不变，也体现了“保留地址空间”和“物理页仍然驻留”是两回事。\n需要注意，ps 是某个时刻的进程快照，结果会受分配器配置、系统负载和页面回收策略影响。当前数据是实验事实，但仍应通过多轮运行确认稳定性。\n62. 当前阶段结论 # 目前已经完成：\nglibc malloc vs 自研 memory pool vs jemalloc ├── 进程内 Hash 吞吐量对比 └── 20 万节点 VSZ/RSS 对比 在当前 Ubuntu ARM64 虚拟机和当前 Hash 负载下：\njemalloc 的平均吞吐量最高。 自研节点池的平均吞吐量高于 glibc malloc。 自研节点池的 VSZ 最低。 jemalloc 的 RSS 最低，并且删除节点后 RSS 下降约 4 MiB。 glibc 在本轮吞吐量和 RSS 数据中均处于第三位。 这不代表 jemalloc 在所有场景全方位超过 glibc。 自研内存池只管理固定大小节点，jemalloc 则是能够处理不同尺寸的通用分配器，二者职责不同且可以组合。 下一步计划：\n1. 用一个清楚的 Makefile 变量加入 jemalloc 构建模式，避免复制规则。 2. 重复 jemalloc 内存测试，确认 VSZ/RSS 的稳定性。 3. 轮换三方 benchmark 的运行顺序。 4. 根据目标选择是否编写纯 hashnode_t 分配微基准。 5. 可选：测试 memory pool + jemalloc 混合模式。 6. 最后再决定是否将节点池接入红黑树和跳表。 63. 红黑树节点接入内存池 # Hash 阶段完成后，将同一个固定大小内存池继续接入红黑树。\n红黑树节点结构中包含颜色、左右孩子、父节点以及 key/value 指针。虽然 key 和 value 指向的字符串长度不同，但指针本身大小固定，因此：\nrbtree_node 节点外壳 → 大小固定，可以使用节点池 key/value 字符串 → 长度不固定，继续使用 kvs_malloc/kvs_free 在红黑树管理结构中直接嵌入：\nmemory_pool_t node_pool; 这里没有使用 memory_pool_t *。这表示内存池管理结构本身就是红黑树实例的一部分，不需要再单独为管理器执行一次 malloc。\nnil 哨兵节点为什么不进入节点池 # 红黑树使用一个 nil 哨兵代表不存在的孩子。它是一个真实节点，并且从树创建一直存活到树销毁，不会像业务节点一样频繁创建和删除。\n因此采用：\nnil 哨兵 → kvs_malloc/kvs_free 普通 rbtree_node → node_pool 这样保留了节点池的懒扩容：创建空树时只初始化 node pool，还没有 Chunk；第一次插入普通节点时才申请第一个 Chunk。\nnil 初始化为黑色，并使它的 left、right、parent 都指向自身：\nnil-\u0026gt;color = BLACK nil-\u0026gt;left = nil nil-\u0026gt;right = nil nil-\u0026gt;parent = nil 节点释放入口 # 普通节点不能再直接 kvs_free(node)，因为它可能来自节点池。于是增加统一的节点释放函数：\n先释放 node-\u0026gt;key 再释放 node-\u0026gt;value 最后把 node 外壳归还 node_pool 删除时必须释放 rbtree_delete() 返回的节点。该函数可能真正摘除目标节点，也可能摘除它的后继节点并交换 key/value 指针；返回值才是最终脱离树结构、可以释放的节点。\n销毁时循环选择当前树中的最小节点：\nrbtree_mini(inst, inst-\u0026gt;root) → rbtree_delete() → 释放 key/value → 归还节点外壳 全部普通节点处理完成后，再释放 nil，最后销毁 node pool 的所有 Chunk。\n64. 红黑树重复 key 与 Engine 层防护 # 协议层已经会检查重复的 RSET，但如果测试或其他模块绕过协议层，直接调用 kvs_rbtree_set()，Engine 层仍可能收到重复 key。\n原始底层插入函数遇到相同 key 会直接返回。如果在发现重复之前已经申请 node、key、value，这三块内存将无法进入树，也无法再被找到，形成泄漏。\n因此在申请任何新内存前增加：\nif (rbtree_search(inst, key) != inst-\u0026gt;nil) { return 1; } 最终顺序为：\n检查 key 是否重复 → 不重复才申请 node → 再申请 key/value → 插入红黑树 这形成两层防线：协议层维护命令语义，Engine 层保证自身接口直接使用时也不会泄漏。\n65. 红黑树分配器开关与测试 # 为了公平对比，在红黑树中增加编译开关：\nKVS_RBTREE_USE_MEMORY_POOL=1 // 普通节点使用自研节点池 KVS_RBTREE_USE_MEMORY_POOL=0 // 普通节点使用 kvs_malloc/kvs_free 节点申请和节点外壳释放分别通过统一入口完成。即使关闭内存池，上层创建逻辑也仍调用统一入口，只是入口内部会选择 kvs_malloc。\n集成测试覆盖：\ncreate 与 nil 哨兵初始化 SET/GET 重复 key 被拒绝且原 value 不变 DEL 节点 Block 地址复用 多节点红黑树销毁 pool/malloc 两种编译模式 ASan 内存安全检查 pool 模式的地址结果：\nfirst node = 0x52f00000c3d0 second node = 0x52f00000c3d0 (reused) rbtree memory pool integration test passed malloc 模式中不要求地址相同，测试只验证 CRUD、重复 key 和销毁路径。\n66. 红黑树进程内吞吐量对比 # 红黑树 benchmark 不经过网络和 AOF。每个进程执行：\n100000000 轮 SET + DEL 200000000 次红黑树操作 三轮原始结果：\n轮次 分配器 elapsed ops/sec 1 glibc malloc 3.131742 秒 63,862,213 1 memory pool 2.678489 秒 74,668,968 1 jemalloc 2.507363 秒 79,765,072 2 glibc malloc 3.178343 秒 62,925,868 2 memory pool 2.684461 秒 74,502,851 2 jemalloc 2.579492 秒 77,534,646 3 glibc malloc 3.170954 秒 63,072,494 3 memory pool 2.672549 秒 74,834,912 3 jemalloc 2.472445 秒 80,891,603 三轮平均：\n分配器 平均 elapsed 平均 ops/sec glibc malloc 3.160346 秒 63,286,858 memory pool 2.678500 秒 74,668,910 jemalloc 2.519767 秒 79,397,107 按平均吞吐量计算：\nmemory pool 比 glibc malloc 快约 18.0% jemalloc 比 glibc malloc 快约 25.5% jemalloc 比 memory pool 快约 6.3% 每轮树中最多只有一个普通节点，所以查找、旋转和平衡成本很低，分配器开销在结果中占比较高。\n同样需要注意：自研池只管理 rbtree_node 外壳；key/value 仍由 glibc 管理。jemalloc 模式则同时接管 node、key 和 value。因此这是完整 SET/DEL 路径的比较，不是裸节点分配速度比较。\n67. 红黑树 VSZ/RSS 对比 # 内存测试插入 20 万个不同节点，并在插入、删除、销毁三个阶段暂停测量。\n分配器 阶段 VSZ RSS memory pool after insert 24104 KiB 23388 KiB memory pool after delete 24104 KiB 23388 KiB memory pool after destroy 24104 KiB 23388 KiB glibc malloc after insert 27208 KiB 26472 KiB glibc malloc after delete 27208 KiB 26472 KiB glibc malloc after destroy 27208 KiB 26472 KiB jemalloc after insert 29244 KiB 19636 KiB jemalloc after delete 29244 KiB 19636 KiB jemalloc after destroy 29244 KiB 19636 KiB 插入 20 万节点时：\n自研池比 glibc 少约 3104 KiB VSZ 自研池比 glibc 少约 3084 KiB RSS jemalloc 的 VSZ 最高，但 RSS 最低 这次 jemalloc 删除后 RSS 没有下降。与 Hash 实验不同，说明释放后是否立刻回收物理页不是固定规律，会受对象尺寸、分配布局、回收策略和测量时机影响。\n68. 跳表节点为什么只能部分接入固定大小内存池 # 跳表节点为：\ntypedef struct kvs_skiplist_node { char *key; char *value; struct kvs_skiplist_node **forward; } kvs_skiplist_node_t; 在当前 64 位环境中，三个字段都是 8 字节指针，所以节点外壳通常约为：\n8 + 8 + 8 = 24 字节 forward 字段本身只是固定大小的地址，但它指向的数组长度由节点 level 决定：\nlevel 0 → 1 个 forward 指针 level 3 → 4 个 forward 指针 level 6 → 7 个 forward 指针 因此最终所有权划分为：\nkvs_skiplist_node_t 外壳 → 固定大小 node_pool key 字符串 → kvs_malloc value 字符串 → kvs_malloc forward 指针数组 → kvs_malloc 可以把 forward 字段理解为一张写着仓库地址的纸条：纸条大小固定，但纸条指向的仓库大小可以变化。\n69. 跳表 header 与懒扩容 # 跳表的 header 与红黑树 nil 类似，创建后长期存在，不会随业务命令频繁创建和删除。因此采用：\nheader 节点外壳 → kvs_malloc 普通业务节点 → node_pool 这使 node pool 保持懒扩容：\nkvs_skiplist_create() → 初始化 node_pool → header 使用 kvs_malloc → node_pool 还没有 Chunk 第一次 SSET → 普通节点调用 memory_pool_alloc → 创建第一个 Chunk skiplist_create_node() 同时用于 header 和普通节点，因此额外接收 bool use_node_pool：\nfalse → 创建 header，走 kvs_malloc true → 创建普通节点，根据编译宏选择 pool/malloc 统一的 skiplist_node_storage_alloc/free 不是强制使用内存池，而是分配方式选择器。\n70. 跳表失败路径与状态提交顺序 # 创建一个普通跳表节点依次申请：\nnode 外壳 → key → value → forward 数组 失败时按照已经成功取得的资源逐项回收：\nkey 失败 → 归还 node 外壳 value 失败 → 释放 key，再归还 node 外壳 forward 失败 → 释放 key/value，再归还 node 外壳 key/value/forward 均由 kvs_malloc 取得，因此使用 kvs_free；节点外壳来源可变，因此通过 skiplist_node_storage_free 判断应归还 node pool 还是 malloc。\nforward 数组申请成功后，将每个位置初始化为 NULL，避免节点暂时携带未初始化的垃圾地址。\n另一个失败路径发生在随机 level 高于当前跳表 level 时。不能在节点申请成功前就提高 skipList-\u0026gt;level，否则申请失败后会留下“层数已经提高、节点却没有插入”的不一致状态。\n正确顺序为：\n生成随机 level → 准备 update 数组 → 创建节点 → 节点创建成功后更新 skipList-\u0026gt;level → 接入各层 forward 原则是：只有资源申请成功以后，才能提交对外可见的数据结构状态。\n71. 跳表正确性与多 Chunk 测试 # 跳表使用编译开关：\nKVS_SKIPLIST_USE_MEMORY_POOL=1 KVS_SKIPLIST_USE_MEMORY_POOL=0 集成测试覆盖：\nheader 创建与 node pool 懒扩容 普通节点 SET/GET/DEL 节点外壳归还 Free List 删除后节点地址复用 不同 level 的 forward 数组 2001 个同时存活的普通节点 1024 Blocks/Chunk 下触发第二个 Chunk 多个 Chunk 的销毁 pool/malloc 两种模式 ASan 内存安全检查 pool 模式结果：\nfirst node = 0x52b0000061e8 second node = 0x52b0000061e8 (reused) skiplist create/destroy test passed 测试中曾经把“第二个 Chunk 已存在”的断言放在 2000 次插入循环内部，并在第一次插入前立即执行，导致断言失败。移动到整个循环完成之后才正确验证扩容。\n这个问题说明：断言不仅要写对条件，还必须放在被验证行为已经完成之后。\n72. 跳表进程内吞吐量对比 # 每个程序执行：\n100000000 轮 SET + DEL 200000000 次跳表操作 三轮原始结果：\n轮次 分配器 elapsed ops/sec 1 glibc malloc 5.615434 秒 35,616,122 1 memory pool 5.522943 秒 36,212,577 1 jemalloc 4.974261 秒 40,206,980 2 glibc malloc 5.878963 秒 34,019,604 2 memory pool 5.468806 秒 36,571,056 2 jemalloc 5.041026 秒 39,674,463 3 glibc malloc 5.775833 秒 34,627,039 3 memory pool 5.410201 秒 36,967,203 3 jemalloc 4.903869 秒 40,784,125 三轮平均：\n分配器 平均 elapsed 平均 ops/sec glibc malloc 5.756743 秒 34,754,255 memory pool 5.467317 秒 36,583,612 jemalloc 4.973052 秒 40,221,856 按平均吞吐量计算：\nmemory pool 比 glibc malloc 快约 5.3% jemalloc 比 glibc malloc 快约 15.7% jemalloc 比 memory pool 快约 9.9% 跳表每次 SET 可能包含四类动态分配：node、key、value 和 forward。自研池只优化 node 外壳，jemalloc 则接管全部四类，因此自研池相对 glibc 的提升小于红黑树场景。\n跳表 benchmark 没有调用 srand()。三个独立进程通常从相同的默认随机序列开始，因此三种分配器面对的 level 序列基本一致，有利于当前对比。\n73. 跳表 VSZ/RSS 对比 # 20 万存活节点的完整数据：\n分配器 阶段 VSZ RSS memory pool after insert 26196 KiB 25420 KiB memory pool after delete 26196 KiB 25420 KiB memory pool after destroy 26196 KiB 25420 KiB glibc malloc after insert 27736 KiB 26944 KiB glibc malloc after delete 27736 KiB 26944 KiB glibc malloc after destroy 27736 KiB 26944 KiB jemalloc after insert 29244 KiB 20000 KiB jemalloc after delete 29244 KiB 18784 KiB jemalloc after destroy 29244 KiB 18784 KiB 插入后，自研池相对 glibc：\nVSZ 少 1540 KiB，约 5.6% RSS 少 1524 KiB，约 5.7% jemalloc：\nVSZ 最高 RSS 最低 删除后 RSS 再下降 1216 KiB 跳表中 forward 数组长度可变，并且仍由通用分配器管理。因此节点池降低了固定 node 外壳的开销，但无法消除 key/value/forward 的分配器元数据和碎片。\n74. 当前阶段总进度与下一步 # 目前已完成：\n固定大小内存池实现 ├── Free List 复用 ├── Chunk 自动扩容 ├── Chunk List 统一销毁 ├── 对齐与溢出检查 └── ASan 单元测试 Hash 普通节点 ├── 内存池接入 ├── pool/malloc 开关 ├── 吞吐量 benchmark └── VSZ/RSS benchmark 红黑树普通节点 ├── nil 哨兵独立管理 ├── 内存池接入 ├── 重复 key 防泄漏 ├── pool/malloc 开关 ├── 吞吐量 benchmark └── VSZ/RSS benchmark 跳表普通节点 ├── header 独立管理 ├── node 外壳进入内存池 ├── 可变 forward 数组继续走通用分配器 ├── 失败路径和状态提交顺序修复 ├── 多 Chunk ASan 测试 ├── 吞吐量 benchmark └── VSZ/RSS benchmark 实验反复得到同一个核心认识：\n固定大小节点池擅长管理生命周期频繁、尺寸相同的节点外壳；它不负责替代所有动态内存分配。jemalloc 是通用分配器，可以与业务专用节点池组合使用。\n下一步分析 Array 的真实内存模型。数组通常整体申请连续空间，并通过下标管理元素，可能不存在能够逐个回收到 Free List 的独立节点。因此不能为了“四种结构都接入”而机械套用节点池，需要先确认它的实际申请与释放方式，再决定应优化元素、key/value，还是根本不使用当前固定 Block 内存池。\n75. 为什么 Array 不接入当前的节点内存池 # Array 的核心结构为：\ntypedef struct kvs_array_item_s { char *key; char *value; } kvs_array_item_t; typedef struct kvs_array_s { kvs_array_item_t *table; int idx; int total; } kvs_array_t; kvs_array_create 会一次性申请整张连续数组：\ninst-\u0026gt;table = kvs_malloc( KVS_ARRAY_SIZE * sizeof(kvs_array_item_t) ); 因此 kvs_array_item_t 与 Hash、红黑树、跳表的普通节点不同：\nHash/红黑树/跳表：普通节点被逐个申请和释放 Array：所有 item 槽位在 create 时一次性申请 Array 删除元素后，会把最后一个有效元素移动到被删除的位置，使有效元素继续紧密排列在：\ntable[0] ... table[total - 1] 下一个空闲槽位自然是 table[total]。这已经具有固定容量、连续存储和槽位复用的特点。若再让每个 item 从节点池逐个取得，反而会破坏连续数组的缓存局部性和下标访问方式。\n真正单独动态申请的是 key 和 value。但字符串长度不固定，而当前内存池每个 Block 大小固定，因此也不适合直接管理它们。\n结论是：\nArray 保留连续数组设计，不接入当前固定大小节点池；只继续研究 key/value 使用 glibc malloc 或 jemalloc 时的差异。\n76. Array 中发现并修复的内存问题 # 虽然 Array 不需要节点池，但检查内存所有权时发现了三类问题。\n76.1 destroy 必须先释放存活的 key/value # 原实现只释放 table，会丢失其中仍然存活的 key/value 地址，造成内存泄漏。\n正确顺序为：\n遍历有效 item → 释放每个 key → 释放每个 value → 释放整个 table → 清空 table、total 和 idx 76.2 SET 的部分失败需要回滚 # SET 先申请 key，再申请 value。如果 value 申请失败，已经申请成功的 key 必须释放：\nkey 成功，value 失败 → free(key) → 返回错误 否则一次失败就会泄漏一份 key。\n76.3 MOD 必须先申请新 value，再释放旧 value # 错误写法曾经类似：\nkvs_free(inst-\u0026gt;table[i].value = NULL); 赋值表达式先把成员改为 NULL，再把 NULL 传给 kvs_free。旧地址因此丢失，旧 value 没有被释放。\n修复后的顺序是：\n申请新 value → 申请失败时保留旧 value → 复制新内容 → 释放旧 value → 保存新地址 这是一种“先准备新状态，成功后再提交”的写法。它不仅避免泄漏，也保证 malloc 失败时原数据仍然有效。\n字符串复制同时改为：\nsize_t size = strlen(value) + 1; char *copy = kvs_malloc(size); memcpy(copy, value, size); strlen 不包含字符串末尾的 \\0，因此申请和复制都需要 +1。这样不再需要 memset + strncpy(strlen(...)) 的绕行组合，也消除了 -Wstringop-truncation。\n77. Array 的 ASan 生命周期测试 # 新增 tests/test_array_memory.c，覆盖：\ncreate 整体申请 table SET 两个键值对 拒绝重复 key GET 验证数据 MOD 替换 value DEL 释放 key/value 并搬移尾元素 删除不存在的 key destroy 释放仍然存活的数据和 table 销毁后状态清零 测试使用：\n-fsanitize=address -fno-omit-frame-pointer -Werror 结果：\narray memory lifecycle test passed 说明本次覆盖的 SET、MOD、DEL 和 destroy 路径没有被 ASan 检测到越界、错误释放、重复释放或内存泄漏。\n78. Array 的 glibc 与 jemalloc 吞吐量对比 # Array 没有自研节点池组，因此只比较：\nArray + glibc malloc Array + jemalloc 测试反复执行同一个 key 的 SET + DEL：\n100000000 轮 每轮 1 次 SET + 1 次 DEL 总计 200000000 次操作 三轮原始结果：\n轮次 分配器 elapsed ops/sec 1 glibc malloc 2.061267 秒 97,027,707 1 jemalloc 1.705103 秒 117,295,001 2 glibc malloc 2.094470 秒 95,489,552 2 jemalloc 1.724745 秒 115,959,176 3 glibc malloc 2.127614 秒 94,001,995 3 jemalloc 1.763674 秒 113,399,655 三轮平均：\n分配器 平均 elapsed 平均 ops/sec glibc malloc 2.094450 秒 95,506,418 jemalloc 1.731174 秒 115,551,277 按平均吞吐量计算，jemalloc 比 glibc malloc 快约 21.0%；平均耗时降低约 17.3%。\n但这是一项微基准。数组中最多同时存活一个元素，因此查重和查找几乎都是常数时间。它证明的是：\n在这个进程和负载下，jemalloc 处理 key/value 的频繁申请与释放更快。\n它不能证明 Array 能以同样速度处理一亿个同时存活的元素。\n79. Array 的算法复杂度与内存测试边界 # kvs_array_set 会先调用线性查询检查重复 key。连续插入 n 个不同 key 时，比较次数为：\n0 + 1 + 2 + ... + (n - 1) = n * (n - 1) / 2 因此单次 SET 查重是 O(n)，连续插入 n 条数据的总成本是 O(n²)。如果假设插入 20 万条数据，理论比较次数约为 200 亿次。\n这说明大规模场景下，Array 首先遇到的是数据结构复杂度瓶颈，而不是选择 glibc、内存池还是 jemalloc。\n当前真实配置为：\n#define KVS_ARRAY_SIZE 1024 因此内存测试按真实上限插入 1024 项，而没有为了制造明显差异擅自扩大业务容量。\n内存数据如下：\n分配器 阶段 VSZ RSS glibc malloc after insert 2256 KiB 1548 KiB glibc malloc after delete 2256 KiB 1548 KiB glibc malloc after destroy 2256 KiB 1548 KiB jemalloc after insert 10808 KiB 3892 KiB jemalloc after delete 10808 KiB 3892 KiB jemalloc after destroy 10808 KiB 3892 KiB 1024 份短字符串很小，三个阶段的变化被进程基础开销、分配器缓存和 ps 的观测粒度覆盖。jemalloc 还需要动态库、arena 和管理元数据，因此小规模场景中显示出更高的固定 VSZ/RSS。\n这组数据说明：\njemalloc 在该微基准中更快 但对只有 1024 容量的 Array，基础内存开销更高 删除或 destroy 后，分配器也未必立刻把内存归还操作系统 80. Array 测试的 Makefile 开关 # Array benchmark 使用：\nARRAY_USE_JEMALLOC ?= 0 当值为 0 时，不添加 jemalloc 参数，程序使用 glibc malloc；当值为 1 时，Makefile 添加：\n-DKVS_BENCHMARK_JEMALLOC -ljemalloc 通过 make -n 可以只展开并打印编译命令，确认开关是否正确，而不真正执行编译。通过 ldd 则可以检查最终二进制是否实际依赖 libjemalloc.so.2。\n81. 合并前的最终回归 # 合并前分别验证了两种完整配置。\n节点池全部开启：\nHASH_USE_MEMORY_POOL=1 RBTREE_USE_MEMORY_POOL=1 SKIPLIST_USE_MEMORY_POOL=1 节点池全部关闭：\nHASH_USE_MEMORY_POOL=0 RBTREE_USE_MEMORY_POOL=0 SKIPLIST_USE_MEMORY_POOL=0 两种配置下均完成：\n内存池自身测试 Hash 分配器集成测试 红黑树分配器集成测试 跳表分配器集成测试 Array 内存生命周期测试 ASan 检查 完整 kvstore 编译和链接 开启节点池时，测试观察到删除节点的地址被 Free List 复用；关闭节点池时，输出明确进入 malloc mode。两条路径均未出现 ASan 错误。\n82. 功能分支合并完成 # 本次工作在 feature/memory-pool 分支分阶段提交。合并前通过：\ngit log --oneline --decorate main..feature/memory-pool 确认了功能分支拥有、但 main 尚未拥有的提交范围。\n随后使用：\ngit merge --no-ff feature/memory-pool \\ -m \u0026#34;merge: integrate memory pool and allocator benchmarks\u0026#34; --no-ff 保留了明确的功能分支合并节点。最终 merge commit 为：\n3d3178a merge: integrate memory pool and allocator benchmarks 合并结果包含 11 个功能提交和 1 个 merge commit，共使本地 main 相对旧 main 前进 12 个提交。随后 main 已推送到 GitHub 和 GitLab。\n83. 本轮真正内化的工程认识 # 这轮工作的核心并不是把 malloc/free 机械替换成两个新函数，而是建立了一套判断方法：\n先确认对象的尺寸是否固定 → 再确认对象是否被频繁独立申请和释放 → 明确每块内存的所有者 → 申请失败时按相反顺序回滚 → 删除时先断开数据结构连接，再释放或归还节点 → destroy 释放所有长期持有的 Chunk → 用编译开关保留可比较的回退路径 → 用 ASan 验证正确性 → 用进程内 benchmark 隔离分配器成本 → 用 VSZ/RSS 观察操作系统看到的内存结果 不同结构得出的选择也不同：\n数据结构 当前内存策略 Hash 固定大小 node 外壳进入内存池；key/value 使用通用分配器 红黑树 普通 node 进入内存池；nil 独立管理；key/value 使用通用分配器 跳表 固定大小 node 外壳进入内存池；header 独立管理；可变 forward 和 key/value 使用通用分配器 Array item 保持连续数组整体分配；key/value 使用通用分配器 最终结论是：\n内存优化必须服从数据结构本身的内存模型。适合节点池的对象才进入节点池；尺寸可变或已经连续预分配的对象继续使用通用分配器。自研内存池和 jemalloc 不是互斥关系，它们可以分别负责业务专用固定对象与通用动态内存。\n","date":"2026年9月8日","externalUrl":null,"permalink":"/MyBlog/p/kv-evolution-memory-management/","section":"Posts","summary":"","title":"KV 进化（三）：内存管理与内存池","type":"posts"},{"content":"","date":"2026年9月8日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%86%85%E5%AD%98%E6%B1%A0/","section":"Tags","summary":"","title":"内存池","type":"tags"},{"content":"","date":"2026年9月8日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86/","section":"Tags","summary":"","title":"内存管理","type":"tags"},{"content":"这篇承接 《KV 进化（一）：给 91kvstore 接上一条跳表》。上一阶段完成了第四种存储引擎的接入，这一阶段继续解决一个更实际的问题：服务退出以后，内存中的数据怎样留下来？\n本文记录从 AOF 基础闭环，到全量快照，再到通过 AOF_OFFSET 连接二者的完整实现过程。\n1. 确立实现模式 # 项目采用混合持久化模式：\n全量快照：保存某一时刻完整的数据集。 AOF 增量日志：每次成功执行写命令后，记录这条命令。 AOF 的基础闭环为：\n执行写命令 → 写入 AOF → 服务重启 → 读取 AOF → 重新执行历史命令 → 恢复内存数据 加入全量快照以后，最终恢复流程变成：\n加载全量快照 → 获得快照对应的 AOF 偏移量 → 从偏移量开始重放 AOF → 恢复出最新数据 2. 建立独立的持久化模块 # 持久化属于独立的辅助模块，不应该把所有文件操作都堆在 kvstore.c 中，因此新增：\ninclude/persistence.h src/persistence/aof.c src/persistence/snapshot.c 其他模块通过：\n#include \u0026#34;persistence.h\u0026#34; 使用持久化功能。\n头文件中声明了 AOF 对外提供的接口：\nint kvs_aof_open(const char *path); int kvs_aof_append(char **tokens, int count); int kvs_aof_close(void); long long kvs_aof_get_offset(void); 为了实现重放，又增加了回调函数类型：\ntypedef int (*aof_replay_handler)( char *msg, int length, char *response ); 以及 AOF 重放接口：\nint kvs_aof_replay( const char *path, long long offset, aof_replay_handler handler ); 快照模块对外提供：\nint kvs_snapshot_save(const char *path); long long kvs_snapshot_load( const char *path, aof_replay_handler handler ); aof_replay_handler 是函数指针类型。重放函数读取到一条命令后，可以通过这个函数指针把命令交给 kvs_protocol() 执行。\n3. 打开和关闭 AOF # AOF 文件使用一个模块内部的文件指针保存：\nstatic FILE *aof_fp = NULL; 打开文件时使用追加模式：\naof_fp = fopen(path, \u0026#34;a\u0026#34;); \u0026quot;a\u0026quot; 表示 append，也就是从文件末尾追加内容，不会覆盖已有日志。\nkvs_aof_open() 需要检查：\npath 是否为 NULL AOF 是否已经打开 fopen() 是否成功 约定返回值：\n0 成功 -1 失败 关闭时使用：\nint ret = fclose(aof_fp); aof_fp = NULL; 即使 fclose() 失败，也要把 aof_fp 设置为 NULL。因为调用 fclose() 以后，不应该继续使用原来的文件指针。\n4. 把命令追加到 AOF # 协议层已经把命令切分到了 tokens 中。例如：\nSET name Jasper 切分以后是：\ntokens[0] = \u0026#34;SET\u0026#34; tokens[1] = \u0026#34;name\u0026#34; tokens[2] = \u0026#34;Jasper\u0026#34; count = 3 kvs_aof_append() 首先检查：\nAOF 文件是否已经打开 tokens 是否为 NULL count 是否为 2 或 3 对应位置的 token 是否存在 之后使用 fprintf() 把 tokens 重新组合成一条完整命令：\nfprintf(aof_fp, \u0026#34;%s %s\\n\u0026#34;, tokens[0], tokens[1]); 或者：\nfprintf(aof_fp, \u0026#34;%s %s %s\\n\u0026#34;, tokens[0], tokens[1], tokens[2]); 因此 AOF 文件中的内容类似：\nSET persist_key persist_value MOD persist_key new_value DEL persist_key 每条命令占一行，这样重放时才能确定不同命令之间的边界。\n5. 从缓冲区同步到文件 # fprintf() 返回值大于等于 0，只能说明格式化写入没有在这一层报告错误。数据可能仍然位于 C 标准库维护的用户态缓冲区中。\n因此先调用：\nfflush(aof_fp); 它会把 C 标准库缓冲区中的数据交给操作系统。\n但是数据此时仍可能位于操作系统的页缓存中，所以继续调用：\nfsync(fileno(aof_fp)); 这里涉及两种文件表示：\nFILE * C 标准库使用的文件流 fd 操作系统使用的整数文件描述符 fsync() 接收文件描述符，所以需要使用：\nfileno(aof_fp) 把 FILE * 转换成对应的 fd。\n于是一次追加的顺序是：\nfprintf → fflush → fileno → fsync 其中任何一步失败，kvs_aof_append() 都返回 -1。\n6. 判断哪些命令需要写入 AOF # AOF 只记录会改变数据的命令：\nSET DEL MOD 查询命令不需要记录：\nGET EXIST 当前项目中，每个数据结构引擎的命令都按照五个一组排列：\nSET GET DEL MOD EXIST 所以可以通过 cmd % 5 判断命令类型：\nstatic int kvs_is_write_command(int cmd) { if (cmd \u0026lt; KVS_CMD_START || cmd \u0026gt;= KVS_CMD_COUNT) { return 0; } switch (cmd % 5) { case 0: case 2: case 3: return 1; default: return 0; } } 对应关系是：\n0 → SET 1 → GET 2 → DEL 3 → MOD 4 → EXIST 7. 只有命令执行成功才记录 # AOF 的写入位置是在业务命令执行完成、拿到 ret 之后。\n原因是：如果命令执行失败，却仍然被写进 AOF，那么下次重启时可能恢复出一个与用户实际操作结果不一致的数据集。\n当前判断为：\nif (!aof_replaying \u0026amp;\u0026amp; ret == 0 \u0026amp;\u0026amp; kvs_is_write_command(cmd)) { int aof_ret = kvs_aof_append(tokens, count); if (aof_ret \u0026lt; 0) { fprintf(stderr, \u0026#34;failed to append AOF\\n\u0026#34;); exit(EXIT_FAILURE); } } 三个条件分别表示：\n!aof_replaying 当前不是重放阶段 ret == 0 业务命令执行成功 kvs_is_write_command(cmd) 当前是写命令 如果业务数据已经修改成功，但是 AOF 写入失败，服务选择立即退出。否则程序继续运行，就会出现：\n内存中修改成功 → AOF 中没有记录 → 服务重启 → 用户的数据丢失 8. AOF 重放 # AOF 重放必须放在 KV 引擎初始化之后，因为历史命令最终要重新写进内存数据结构。\n同时，重放必须发生在以追加模式打开 AOF 之前。\n在只实现 AOF 的阶段，启动顺序是：\n初始化 KV 引擎 → 进入 AOF 重放状态 → 读取并执行历史 AOF → 退出 AOF 重放状态 → 以追加模式打开 AOF → 启动网络服务 aof_replaying 的含义是：\n0 → 正常运行，可以记录新的写命令 1 → 正在恢复历史数据，不能写入 AOF 注意，它表示“正在重放”，不是“正在重写”。\n它只能在函数外定义一次：\nstatic int aof_replaying = 0; 如果在 kvs_filter_protocol() 内再次定义同名变量，就会发生变量遮蔽。这样主函数修改的是外层变量，而协议处理函数读取的是内部变量，二者不是同一个变量。\n9. AOF 重放函数的实际过程 # kvs_aof_replay() 使用只读模式打开 AOF：\nFILE *fp = fopen(path, \u0026#34;r\u0026#34;); 如果文件不存在，说明当前没有历史数据，可以直接返回成功：\nif (errno == ENOENT) { return 0; } 之后使用 getline() 每次读取一行：\nwhile ((line_length = getline(\u0026amp;line, \u0026amp;capacity, fp)) != -1) { 假设读取到：\nSET persist_key persist_value 之后，通过传入的回调函数执行它：\nchar response[1024] = {0}; int response_length = handler( line, (int)line_length, response ); 因为调用 kvs_aof_replay() 时传入的是：\nkvs_protocol 所以这次调用实际相当于：\nkvs_protocol(line, (int)line_length, response); 历史命令因此重新进入原来的协议层和业务层，不需要为重放重新写一套 SET、MOD、DEL 逻辑。\n当返回值有效，并且响应为：\nOK\\r\\n 说明这一条历史命令重放成功。否则重放失败，服务不继续启动。\n10. AOF 基础闭环 # AOF 阶段已经测试：\nSET persist_key persist_value MOD persist_key new_value 服务重启以后执行：\nGET persist_key 能够得到：\nnew_value 同时，重放期间不会把历史命令再次追加到 AOF，所以文件不会随着每次重启产生重复日志。\n至此，AOF 的基础闭环完成。但是如果 AOF 中存在大量历史命令，每次重启都从第一条开始执行，恢复时间会越来越长。所以接下来需要实现全量快照。\n11. 开始实现全量快照 # AOF 保存的是每一次成功执行的写命令，而全量快照保存的是某一个时刻内存中的完整数据集。\n例如执行过：\nSET name Jasper MOD name Sao AOF 中保存的是操作过程：\nSET name Jasper MOD name Sao 而快照只关心最终数据状态：\nSET name Sao 因此，保存快照时不能简单复制 AOF。我们需要遍历当前内存中的数据，把每一个有效的 key-value 写进快照文件。\n项目中存在多个 KV 引擎：\narray rbtree hash skiplist 每一种数据结构的遍历方式不同：\n数组需要遍历每一个槽位。 红黑树需要按照节点关系递归遍历。 哈希表需要遍历桶以及桶中的链表。 跳表需要沿着最底层链表遍历。 但是持久化模块不应该知道这些数据结构的内部实现。\n所以采用统一的设计：\n引擎负责找到 key 和 value → 持久化模块负责把 key 和 value 写入文件 12. 定义统一的访问函数 # 在 kvstore.h 中定义访问函数类型：\ntypedef int (*kvs_visit_handler)( const char *key, const char *value, void *context ); 这个函数指针表示：\n引擎每找到一组 key-value → 就调用一次 visitor → 把 key、value 和 context 交给 visitor 三个参数分别表示：\nkey 当前遍历到的键 value 当前遍历到的值 context 调用者额外传入的信息 每一个引擎分别提供自己的遍历函数：\nint kvs_array_foreach( kvs_array_t *inst, kvs_visit_handler visitor, void *context ); int kvs_rbtree_foreach( kvs_rbtree_t *inst, kvs_visit_handler visitor, void *context ); int kvs_hash_foreach( kvs_hash_t *inst, kvs_visit_handler visitor, void *context ); int kvs_skiplist_foreach( kvs_skiplist_t *inst, kvs_visit_handler visitor, void *context ); 虽然四个引擎内部的遍历方法不同，但是对外接口的形式相同。\n13. foreach 的职责 # 以数组引擎为例：\nint kvs_array_foreach( kvs_array_t *inst, kvs_visit_handler visitor, void *context) { if (inst == NULL || inst-\u0026gt;table == NULL || visitor == NULL) { return -1; } for (int i = 0; i \u0026lt; inst-\u0026gt;total; i++) { if (inst-\u0026gt;table[i].key == NULL || inst-\u0026gt;table[i].value == NULL) { continue; } int ret = visitor( inst-\u0026gt;table[i].key, inst-\u0026gt;table[i].value, context ); if (ret \u0026lt; 0) { return -1; } } return 0; } 每一次循环找到一组有效的 key-value，就调用一次：\nvisitor(key, value, context); 所以遍历函数本身没有耦合具体的文件写入逻辑。它只负责：\n找到数据 → 把数据交给 visitor 真正决定如何使用这些数据的是传进来的 visitor。\n14. context 的意义 # 快照写入函数不只需要 key 和 value，它还需要知道：\n应该写入哪个文件。 当前数据来自哪个引擎。 应该使用 SET、RSET、HSET 还是 SSET。 所以定义结构体：\ntypedef struct snapshot_write_context { FILE *fp; const char *command; } snapshot_write_context_t; 这里：\nfp 快照文件的文件指针 command 当前引擎对应的写命令 然后把这个结构体的地址作为 void *context 传入遍历函数：\nsnapshot_write_context_t ctx; ctx.fp = fp; ctx.command = \u0026#34;SET\u0026#34;; kvs_array_foreach( \u0026amp;global_array, snapshot_write_item, \u0026amp;ctx ); 此时 \u0026amp;ctx 被转换成了 void *。\n进入 snapshot_write_item() 以后，再把它转换回来：\nsnapshot_write_context_t *ctx = (snapshot_write_context_t *)context; 所以 context 并不是“一个数”。它是一个通用指针，这里保存的是 ctx 这个结构体的地址。\n15. 把一组 key-value 写进快照 # 真正负责写入文件的函数是：\nstatic int snapshot_write_item( const char *key, const char *value, void *context) { if (key == NULL || value == NULL || context == NULL) { return -1; } snapshot_write_context_t *ctx = (snapshot_write_context_t *)context; if (ctx-\u0026gt;fp == NULL || ctx-\u0026gt;command == NULL) { return -1; } int written = fprintf( ctx-\u0026gt;fp, \u0026#34;%s %s %s\\n\u0026#34;, ctx-\u0026gt;command, key, value ); if (written \u0026lt; 0) { return -1; } return 0; } 例如数组引擎中存在：\nname → Jasper age → 20 调用两次 snapshot_write_item() 后，快照中会得到：\nSET name Jasper SET age 20 也就是说，每遍历到一个有效数据，就执行一次 snapshot_write_item()。\n16. 为什么快照中只保存 SET 类型的命令 # 快照保存的是当前最终数据集，而不是数据变化的过程。\n例如：\nSET name Jasper MOD name Sao DEL age 最终只剩下：\nname → Sao 所以快照只需要保存一条能够重新创建当前数据的命令：\nSET name Sao 对于不同引擎，分别使用：\nSET 数组引擎 RSET 红黑树引擎 HSET 哈希引擎 SSET 跳表引擎 快照不需要保存 MOD 和 DEL。因为已经被删除的数据不会出现在遍历结果中，而被修改的数据会直接以最终值保存。\n17. 保存所有引擎的数据 # 在 snapshot.c 中访问四个全局引擎：\n#if ENABLE_ARRAY extern kvs_array_t global_array; #endif #if ENABLE_RBTREE extern kvs_rbtree_t global_rbtree; #endif #if ENABLE_HASH extern kvs_hash_t global_hash; #endif #if ENABLE_SKIPLIST extern kvs_skiplist_t global_skiplist; #endif 然后依次遍历已经启用的引擎：\n#if ENABLE_ARRAY if (result == 0) { ctx.command = \u0026#34;SET\u0026#34;; if (kvs_array_foreach( \u0026amp;global_array, snapshot_write_item, \u0026amp;ctx) \u0026lt; 0) { result = -1; } } #endif 其他引擎对应的命令为：\n红黑树 ctx.command = \u0026#34;RSET\u0026#34; 哈希表 ctx.command = \u0026#34;HSET\u0026#34; 跳表 ctx.command = \u0026#34;SSET\u0026#34; result 用于记录整个快照保存过程是否成功：\n0 到目前为止没有失败 -1 某一步已经失败 如果前一个引擎保存失败，后面的引擎就不再继续写。这样可以避免生成一个看起来正常、实际上缺少部分数据的快照。\n18. 使用临时文件保存快照 # 不能直接使用：\nfopen(path, \u0026#34;w\u0026#34;); 覆盖原来的正式快照。因为如果写到一半时程序崩溃，旧快照已经被破坏，新快照又没有写完整。\n所以先生成临时文件路径：\nchar temp_path[1024]; snprintf( temp_path, sizeof(temp_path), \u0026#34;%s.tmp\u0026#34;, path ); 例如：\n正式文件：snapshot.db 临时文件：snapshot.db.tmp 这里使用 snprintf()，是因为它可以接收缓冲区大小，避免写出的字符串超过 temp_path 的容量。\n然后使用 \u0026quot;w\u0026quot; 模式打开临时文件：\nFILE *fp = fopen(temp_path, \u0026#34;w\u0026#34;); \u0026quot;w\u0026quot; 表示重新写入。临时文件如果已经存在，原来的内容会被清空。\n19. 把快照同步到磁盘 # 所有引擎遍历完成以后，依次执行：\nfflush(fp) → fsync(fileno(fp)) → fclose(fp) 含义与 AOF 相同：\nfflush 把 C 标准库缓冲区交给操作系统 fsync 要求操作系统把内容同步到底层存储 fclose 关闭文件 如果任何一步失败：\nunlink(temp_path); return -1; unlink(temp_path) 的意思是删除失败的临时文件，不是关闭文件。文件已经由 fclose() 负责关闭。\n全部成功以后执行：\nrename(temp_path, path); 这一步把：\nsnapshot.db.tmp 替换成：\nsnapshot.db 因此，快照保存过程是：\n写临时文件 → 确认完整写入 → 同步到磁盘 → 关闭临时文件 → 用临时文件替换正式快照 这样可以尽量避免生成半份快照。\n20. 增加 SAVE 命令 # 在持久化头文件中暴露快照保存接口：\nint kvs_snapshot_save(const char *path); 协议层切分完 tokens 后，单独判断：\nif (count == 1 \u0026amp;\u0026amp; strcmp(tokens[0], \u0026#34;SAVE\u0026#34;) == 0) { int save_result = kvs_snapshot_save(\u0026#34;snapshot.db\u0026#34;); if (save_result \u0026lt; 0) { return sprintf(response, \u0026#34;ERROR\\r\\n\u0026#34;); } return sprintf(response, \u0026#34;OK\\r\\n\u0026#34;); } 用户发送：\nSAVE 程序就会把当前四个引擎中的完整数据保存到 snapshot.db。\nSAVE 本身不是修改 KV 数据的普通业务命令，所以不需要写进 AOF。\n21. 快照与 AOF 的重复问题 # 如果快照已经保存了当前全部数据，而服务启动时又从 AOF 开头执行所有命令，就会出现重复恢复。\n例如 AOF 中有：\nSET name Jasper MOD name Sao 快照中已经有：\nSET name Sao 启动时先读取快照，内存里已经存在 name。如果又从 AOF 开头执行 SET name Jasper，就可能因为 key 已经存在而失败。\n所以快照必须知道：\n保存这份快照时，AOF 已经写到了哪个位置。\n22. 获取 AOF 当前偏移量 # 在 AOF 模块中增加：\nlong long kvs_aof_get_offset(void); 实现过程：\nlong long kvs_aof_get_offset(void) { if (aof_fp == NULL) { return -1; } off_t offset = ftello(aof_fp); if (offset == (off_t)-1) { return -1; } return (long long)offset; } off_t 是系统用来表示文件位置和文件大小的类型。\nftello() 返回当前文件位置，也就是 AOF 已经写了多少字节。\n假设返回：\n189 表示保存快照时，AOF 的前 189 个字节已经被这份快照包含。\n23. 把 AOF 偏移量写进快照 # 保存快照之前先获取：\nlong long aof_offset = kvs_aof_get_offset(); 然后把它写在快照第一行：\nfprintf(fp, \u0026#34;AOF_OFFSET %lld\\n\u0026#34;, aof_offset); 最终快照类似：\nAOF_OFFSET 189 SET persist_key new_value SET snapshot_array value_array RSET snapshot_rbtree value_rbtree HSET snapshot_hash value_hash SSET snapshot_skiplist value_skiplist 第一行是元数据，不是业务命令。后面的每一行才是用来恢复数据的命令。\n24. 加载全量快照 # 在持久化头文件中增加：\nlong long kvs_snapshot_load( const char *path, aof_replay_handler handler ); 这个函数做两件事：\n1. 读取并执行快照中的数据命令 2. 返回快照记录的 AOF_OFFSET 使用只读方式打开：\nFILE *fp = fopen(path, \u0026#34;r\u0026#34;); 如果快照文件不存在，说明以前没有保存过快照：\nif (errno == ENOENT) { return 0; } 返回 0 表示：\n没有快照数据 → AOF 应该从第 0 个字节开始重放 25. 读取快照中的偏移量 # 首先使用 getline() 读取第一行，然后解析：\nlong long aof_offset = -1; int parsed = sscanf( line, \u0026#34;AOF_OFFSET %lld\u0026#34;, \u0026amp;aof_offset ); 这里：\nparsed 成功解析出的字段数量 aof_offset 真正保存偏移量的变量 如果第一行是：\nAOF_OFFSET 189 那么：\nparsed = 1 aof_offset = 189 如果没有成功读取一个偏移量，或者偏移量小于 0，就说明快照格式错误。\n26. 逐行恢复快照数据 # 读取完第一行以后，继续使用 getline() 循环读取剩余内容：\nwhile ((line_length = getline(\u0026amp;line, \u0026amp;capacity, fp)) != -1) { getline() 每执行一次，就从文件当前位置读取一行。\n读取完成以后，去掉末尾的换行符：\nwhile (line_length \u0026gt; 0 \u0026amp;\u0026amp; (line[line_length - 1] == \u0026#39;\\n\u0026#39; || line[line_length - 1] == \u0026#39;\\r\u0026#39;)) { line[--line_length] = \u0026#39;\\0\u0026#39;; } 然后准备一个响应缓冲区：\nchar response[1024] = {0}; 把快照命令交给协议函数：\nint response_length = handler( line, (int)line_length, response ); 因为传进来的 handler 是 kvs_protocol，所以实际效果相当于：\nkvs_protocol(line, (int)line_length, response); 例如快照中的：\nHSET snapshot_hash value_hash 会重新进入原来的协议解析和哈希引擎处理流程。\nresponse 不会发送给网络客户端，它只用于检查这条恢复命令是否返回：\nOK\\r\\n 所有快照命令恢复成功后，函数返回：\nreturn aof_offset; 27. 让 AOF 从指定位置开始重放 # AOF 重放函数的接口改成：\nint kvs_aof_replay( const char *path, long long offset, aof_replay_handler handler ); 打开 AOF 后，先执行：\nfseeko(fp, (off_t)offset, SEEK_SET); 三个参数表示：\nfp 当前 AOF 文件 offset 要跳转到的字节位置 SEEK_SET 从文件开头计算位置 例如：\noffset = 189 就表示跳过 AOF 前 189 个字节，只重放后面的增量命令。\n这里并不是逐条判断哪些命令应该跳过，而是直接移动文件的读取位置。\n28. 最终启动恢复顺序 # 最终启动过程变成：\n初始化所有 KV 引擎 → 进入恢复状态 → 加载 snapshot.db → 得到 AOF_OFFSET → 从该偏移量开始重放 appendonly.aof → 退出恢复状态 → 以追加模式打开 AOF → 启动网络服务 代码大致为：\ninit_kvengine(); aof_replaying = 1; long long aof_offset = kvs_snapshot_load( \u0026#34;snapshot.db\u0026#34;, kvs_protocol ); if (aof_offset \u0026lt; 0) { aof_replaying = 0; fprintf(stderr, \u0026#34;failed to load snapshot\\n\u0026#34;); return -1; } int replay_ret = kvs_aof_replay( \u0026#34;appendonly.aof\u0026#34;, aof_offset, kvs_protocol ); aof_replaying = 0; if (replay_ret \u0026lt; 0) { fprintf(stderr, \u0026#34;failed to replay AOF\\n\u0026#34;); return -1; } if (kvs_aof_open(\u0026#34;appendonly.aof\u0026#34;) \u0026lt; 0) { fprintf(stderr, \u0026#34;failed to open AOF\\n\u0026#34;); return -1; } 这里的 aof_replaying 虽然叫做 AOF 重放状态，但是现在它实际上覆盖了整个数据恢复阶段：\n加载快照期间 不允许写入 AOF 重放 AOF 期间 不允许写入 AOF 因为快照和 AOF 中的历史命令都会调用 kvs_protocol()。\n如果没有这个标志，恢复出来的历史命令又会被当成新的写命令追加到 AOF。\n29. 快照与 AOF 最终如何配合 # 可以把二者理解成：\nsnapshot.db 保存稳定的历史基础 appendonly.aof 保存快照之后发生的新变化 假设：\nAOF 前 189 字节 → 已经包含在 snapshot.db 中 AOF 第 189 字节以后 → 是保存快照之后执行的新命令 恢复时：\nsnapshot.db → 恢复保存快照时的完整数据 AOF_OFFSET 之后的 AOF → 补上保存快照以后发生的变化 因此最后恢复出的内存状态是：\n快照中的完整数据 + 快照之后的增量命令 = 服务退出前的最新数据 30. 最终验证 # 保存快照时的数据为：\nSET persist_key new_value SET snapshot_array value_array RSET snapshot_rbtree value_rbtree HSET snapshot_hash value_hash SSET snapshot_skiplist value_skiplist 执行：\nSAVE 快照记录：\nAOF_OFFSET 189 保存完成以后，再执行：\nSET after_snapshot from_aof 这条命令没有进入快照，只进入了 AOF 偏移量之后的增量部分。\n服务重启以后，输出顺序为：\n先执行快照中的五条命令 → 再执行 SET after_snapshot from_aof → 最后开始监听端口 客户端执行：\nGET after_snapshot 能够得到：\nfrom_aof 这说明：\n全量快照恢复成功 AOF 增量恢复成功 偏移量跳过成功 恢复期间没有重复写入 AOF 31. 当前完成的整体闭环 # 目前已经完成的混合持久化流程是：\n正常运行 → 成功的写命令立即追加到 AOF → 用户执行 SAVE → 遍历所有引擎 → 保存当前完整数据集 → 在快照中记录 AOF_OFFSET → 快照保存后继续记录新的 AOF → 服务退出或者崩溃 → 下次启动先加载快照 → 再重放 AOF_OFFSET 之后的日志 → 恢复出最新内存数据 → 开始对外提供服务 我的理解是：\nAOF 负责尽量不丢掉每一次数据变化 快照负责保存某一时刻完整的数据集， 并缩短启动时需要处理的历史范围 AOF_OFFSET 负责把快照和 AOF 连接起来 aof_replaying 负责避免恢复过程再次产生日志 handler 负责让历史命令复用原来的业务处理流程 foreach 负责遍历不同引擎中的有效数据 visitor 负责处理遍历得到的每一组 key-value context 负责向 visitor 传递文件指针和命令类型 至此，全量快照和 AOF 增量日志已经组成了一个完整的混合持久化闭环。\n","date":"2026年8月30日","externalUrl":null,"permalink":"/MyBlog/p/kv-evolution-snapshot-aof-persistence/","section":"Posts","summary":"","title":"KV 进化（二）：Snapshot 全量快照 + AOF 增量日志","type":"posts"},{"content":"","date":"2026年8月30日","externalUrl":null,"permalink":"/MyBlog/tags/snapshot/","section":"Tags","summary":"","title":"Snapshot","type":"tags"},{"content":"","date":"2026年8月30日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%8C%81%E4%B9%85%E5%8C%96/","section":"Tags","summary":"","title":"持久化","type":"tags"},{"content":"","date":"2026年8月30日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/","section":"Tags","summary":"","title":"文件系统","type":"tags"},{"content":"这篇不打算深挖跳表算法，而是记录一个外部数据结构如何从“能够独立运行”，一步步变成 91kvstore 中真正可用的存储引擎。\n目前这个 KV 存储引擎已经支持三种数据结构：\n结构体数组 红黑树 哈希表 那么问题来了：能不能再加入一种新的存储引擎，并且让它像现有的三个引擎一样，通过统一接口和网络命令工作？\n这次选择的是——跳表。\n最终希望实现这样的效果：\nSSET name Jasper SGET name SMOD name Sao SEXIST name SDEL name 表面上看，只是多写一个 kvs_skiplist.c；但真正有价值的部分并不是手搓跳表，而是研究如何把一个外部数据结构完整地融合进现有项目。\n先对跳表有个大致印象 # 开始之前，我先看了一些简短的跳表介绍，比如这个视频：\nB站：跳表相关介绍\n现在并不打算深入研究跳表的每一个概率细节，只需要先形成几个基本认知：\n跳表的底层仍然是一条有序链表； 它在普通链表上增加了多层索引； 查询时可以从高层快速跳过大量节点； 查找、插入、删除的平均时间复杂度是 O(log n)。 在工程中使用一种数据结构时，确实可以把它当成一个黑盒，但也不能完全不理解。至少要知道：\n它需要什么样的输入； 它如何管理内存； 它能提供哪些操作； 它的节点和数据由谁创建、由谁释放。 理解到这个程度，就足够开始融合了。\n写跳表之前，我先整理了项目 # 我多少有点目录洁癖。\n原来的 .c、.o、可执行文件和头文件全部堆在项目根目录，看着实在难受。因此没有立刻开始写跳表，而是先参照常见开源项目的结构，把工程重新整理了一遍：\n91kvstore/ ├── .gitignore ├── .gitmodules ├── Makefile │ ├── clients/ │ ├── go-kvstore.go │ ├── javakvstore.java │ ├── js-kvstore.js │ ├── py-kvstore.py │ └── rust-kvstore.rs │ ├── include/ │ ├── kvstore.h │ └── server.h │ ├── src/ │ ├── kvstore.c │ ├── engines/ │ │ ├── kvs_array.c │ │ ├── kvs_hash.c │ │ ├── kvs_rbtree.c │ │ └── kvs_skiplist.c │ │ │ └── network/ │ ├── ntyco.c │ ├── proactor.c │ └── reactor.c │ ├── tests/ │ └── testcase.c │ ├── third_party/ │ └── NtyCo/ # Git 子模块 │ ├── build/ # 编译产物，不提交 │ ├── kvstore.o │ ├── reactor.o │ ├── proactor.o │ ├── ntyco.o │ ├── kvs_array.o │ ├── kvs_hash.o │ ├── kvs_rbtree.o │ └── kvs_skiplist.o │ └── bin/ # 可执行文件，不提交 ├── kvstore └── testcase 这样一来，各部分的职责就很直观：\ninclude/ 放公共声明； src/engines/ 放存储引擎； src/network/ 放网络模型； tests/ 放测试程序； third_party/ 放第三方依赖； build/ 和 bin/ 只保存编译产物。 规整多了，right？\n第一步：把整数跳表改造成字符串 KV # 找到的简易跳表实现只支持：\nint key; int value; 但 KV 存储引擎需要的是：\nchar *key; char *value; 因此节点最终变成：\ntypedef struct kvs_skiplist_node { char *key; char *value; struct kvs_skiplist_node **forward; } kvs_skiplist_node_t; 这里不只是把 int 改成 char * 那么简单。\n整数可以直接比较：\nnode-\u0026gt;key \u0026lt; key 字符串必须使用：\nstrcmp(node-\u0026gt;key, key) \u0026lt; 0 同时，字符串不能只保存调用方传进来的地址。节点需要为 key 和 value 单独申请内存并复制内容，否则调用方的字符串失效后，跳表里保存的指针也会失效。\n创建一个节点，大致需要依次完成：\n为节点结构体申请内存； 为 key 申请内存； 复制 key； 为 value 申请内存； 复制 value； 为每层 forward 指针申请空间。 每一步都可能失败。\n如果中途失败，就必须把前面已经申请成功的内存释放掉。否则节点虽然没有创建成功，却会留下内存泄漏。\n这部分看起来啰嗦，但它实际上比跳表算法本身更接近真实的 C 工程开发。\n第二步：补齐统一接口 # 原始代码只有插入、查询和展示，但项目中的每个存储引擎都需要提供七个接口：\nkvs_skiplist_create kvs_skiplist_destory kvs_skiplist_set kvs_skiplist_get kvs_skiplist_del kvs_skiplist_mod kvs_skiplist_exist 其中比较复杂的是：\nset：查找插入位置，并更新多层指针； get：沿索引层逐级查找； del：不仅要释放节点，还必须先从每一层中摘除节点。 mod 和 exist 相对简单，因为它们都可以复用内部查询函数：\nstatic kvs_skiplist_node_t * skiplist_search_node(kvs_skiplist_t *inst, const char *key); exist 只需要判断查询结果是否为 NULL。\nmod 则是在找到节点后，先申请并复制新的 value；成功之后再释放旧 value。这个顺序非常重要：\n先申请新内存 ↓ 申请成功 ↓ 释放旧 value ↓ 替换指针 如果先释放旧 value，再发现新内存申请失败，原来的数据也就丢了。\n第三步：统一返回值 # 接口能工作还不够，它必须遵守现有引擎的返回规则。\n最终统一为：\n返回值 含义 0 操作成功，或者 key 存在 1 key 重复，或者 key 不存在 -1 参数或实例不合法 -2 内存申请失败 具体含义需要结合接口理解。\n例如：\nkvs_skiplist_set(...) 返回 1 表示 key 已经存在。\n而：\nkvs_skiplist_del(...) 返回 1 表示 key 不存在。\n统一返回规则非常重要，因为协议层并不关心跳表内部发生了什么，它只根据返回值生成响应：\n0 → OK 1 → EXIST 或 NO EXIST \u0026lt; 0 → ERROR 第四步：别让测试变成纯苦力 # 最开始，每修改一个函数，就在 main() 中增加一堆 if/else，重新编译、运行、检查输出。\n写到后面发现，大量测试代码无非是在做：\n实际返回值是否等于预期返回值？ 于是写了一个辅助函数：\nstatic void check_test(int condition, const char *testName) { if (condition) { printf(\u0026#34;[PASS] %s\\n\u0026#34;, testName); } else { printf(\u0026#34;[FAIL] %s\\n\u0026#34;, testName); } } 测试就能简化为：\nresult = kvs_skiplist_set( skipList, \u0026#34;Dad\u0026#34;, \u0026#34;Jasper\u0026#34; ); check_test( result == 0, \u0026#34;set new key\u0026#34; ); 开发接口期间，可以先用 #if 0 屏蔽 main()，每完成一批接口只进行对象文件编译：\ngcc -Iinclude \\ -c src/engines/kvs_skiplist.c \\ -o /tmp/kvs_skiplist.o 等全部接口完成后，再临时启用 main()，统一进行一次完整测试。\n这样既能及时发现编译错误，也不用每改一个函数就维护一遍测试流程。\n第五步：从“能运行”走向“融入项目” # 底层接口通过测试，只能说明跳表自己能工作。接下来才是真正的工程融合。\n首先，把公共内容移动到 include/kvstore.h：\n最大层数定义； 跳表节点类型； 跳表实例类型； 七个公开接口声明。 而这些内部辅助函数仍然留在 .c 文件中，并使用 static 隐藏：\nskiplist_create_node skiplist_random_level skiplist_search_node 这样外部模块只知道跳表“能做什么”，不需要知道它“具体怎么做”。\n接着，把直接使用的：\nmalloc free 替换为项目统一封装的：\nkvs_malloc kvs_free 再把：\nbuild/kvs_skiplist.o 加入 Makefile，让跳表参与整个项目的编译与链接。\nMakefile 的具体语法还需要继续学习，但目前已经能看懂它最核心的关系：\n源文件 ↓ 编译 对象文件 ↓ 链接 可执行文件 第六步：创建全局跳表实例 # 服务器需要一个长期存在的跳表实例，保存所有客户端写入的数据。\n因此在 kvs_skiplist.c 中定义：\nkvs_skiplist_t global_skiplist; 在 kvstore.c 中声明：\nextern kvs_skiplist_t global_skiplist; 这里的 extern 并不会创建第二个跳表，它只是告诉 kvstore.c：\n这个变量在其他文件中已经定义，你可以直接使用它。\n然后把跳表加入存储引擎生命周期：\nkvs_skiplist_create(\u0026amp;global_skiplist); 服务器退出时：\nkvs_skiplist_destory(\u0026amp;global_skiplist); 至此，跳表拥有了完整的生命过程：\n定义 → 初始化 → 处理请求 → 销毁 第七步：命令协议融合 # 最后，也是最关键的一步，是让客户端真的能够使用跳表。\n加入五条新命令：\nSSET SGET SDEL SMOD SEXIST 项目并不存在一个“当前使用哪个 engine”的开关，而是通过命令前缀直接选择引擎：\nSET → array RSET → rbtree HSET → hash SSET → skiplist 例如客户端发送：\nSSET Dad Jasper 服务器的处理过程是：\n解析 SSET ↓ 匹配 KVS_CMD_SSET ↓ 进入 switch 对应分支 ↓ 调用 kvs_skiplist_set() ↓ 根据返回值生成 OK / EXIST / ERROR 这一步完成后，跳表就不再是一个孤立的 .c 文件，而是真正成为了 KV 存储引擎的一员。\n最后：用 testcase 做端到端测试 # 底层 main() 测试的是跳表接口，而 tests/testcase.c 测试的是完整链路：\n测试客户端 → TCP → 网络层 → 协议解析 → global_skiplist → 跳表接口 → 协议响应 测试程序会自动发送：\nSSET SGET SEXIST SMOD SDEL 然后将服务器的实际响应与预期结果比较：\n相同 → PASS 不同 → FAILED 相比手动输入命令，这种测试不仅省事，还能在后续修改代码时快速确认：跳表功能有没有被意外破坏。\n小结 # 这次表面上是在“增加一个跳表”，实际上完整经历了一个新模块接入现有项目的过程：\n选择基础实现 → 改造成字符串 KV → 补齐统一接口 → 统一返回规则 → 完成底层测试 → 暴露公共声明 → 接入内存管理 → 接入 Makefile → 创建全局实例 → 接入生命周期 → 接入命令协议 → 完成端到端测试 跳表算法当然重要，但这次更重要的收获是：\n一个数据结构能独立运行，和它能成为项目中真正可用的一部分，是两件完全不同的事。\n前者解决算法问题，后者解决接口、内存、构建、生命周期、协议和测试问题。\n而后面这些，才是这次“KV 进化”真正有意思的地方。\n","date":"2026年8月28日","externalUrl":null,"permalink":"/MyBlog/p/kv-evolution-skiplist-engine/","section":"Posts","summary":"","title":"KV 进化（一）：给 91kvstore 接上一条跳表","type":"posts"},{"content":"","date":"2026年8月28日","externalUrl":null,"permalink":"/MyBlog/tags/skiplist/","section":"Tags","summary":"","title":"SkipList","type":"tags"},{"content":"","date":"2026年8月28日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/","section":"Tags","summary":"","title":"数据结构","type":"tags"},{"content":"","date":"2026年8月28日","externalUrl":null,"permalink":"/MyBlog/tags/%E8%87%AA%E5%8A%A8%E5%8C%96%E6%B5%8B%E8%AF%95/","section":"Tags","summary":"","title":"自动化测试","type":"tags"},{"content":"阅读目标：在已有 Array、RBTree 和统一协议的基础上，重点复盘 Hash 如何真正成为一个可用的 KV 引擎；随后理解多语言客户端与服务端的边界，并按同一套接入模式继续扩展 SkipList。\n本文不讲 Hash 数据结构算法本身，重点是 Hash 与现有 KV 项目的融合。\n与前三篇的关系 # 本文承接以下三篇已有笔记：\n《KV 存储项目网络层》已经讲过 Reactor、Proactor、NtyCo 和 msg_handler 回调。 《KV 存储项目：协议层与数组存储》已经讲过 TCP 消息边界、token 解析、协议响应和 Array 的五种操作。 《KV 存储项目：客户端测试、压力测试与多引擎扩展》已经讲过 Makefile 基础、测试用例、压力测试、宏开关、5+2 接口以及 RBTree 的接入模式。 因此本文不再重复网络收发、协议解析、Array/RBTree CRUD、通用测试方法和 5+2 的基础概念，只保留理解 Hash 接入所需的连接点。\n1. 本阶段增加了什么 # 项目之前已经完成：\n统一网络入口 → 文本协议解析 → Array 引擎 → RBTree 引擎 → 客户端测试和压力测试 本阶段增加三项能力：\n1. 把 Hash 接入现有 KVEngine 2. 用 Go / Java / Node.js / Python / Rust 操作同一个 KV 服务 3. 按相同模式接入 SkipList，并为范围查询做准备 Hash 部分最值得掌握的不是“冲突链表怎样写”，而是：已经有一份 Hash 代码以后，还要修改项目的哪些位置，它才能从客户端真正访问到？\n类型与接口声明 → 编译和链接 → 全局实例 → create 初始化 → 协议命令分发 → set/get/del/mod/exist → 客户端测试 → destroy 释放 任何一环缺失，Hash 都没有完整融入项目。\n2. Hash 在项目中的位置 # Hash 接入后，核心关系变成：\nkvs_protocol() │ kvs_filter_protocol() │ ┌─────────────────┼─────────────────┐ │ │ │ Array RBTree Hash 普通命令前缀 R 命令前缀 H 命令前缀 │ │ │ global_array global_rbtree global_hash 网络层仍然只调用 kvs_protocol()，不需要为了 Hash 修改 Reactor、Proactor 或 NtyCo。\n协议层也不需要知道桶、哈希下标和冲突链表，只负责把 Hash 命令映射到 kvs_hash_set/get/del/mod/exist。\n这体现了已有分层设计带来的扩展性：新增存储引擎，主要修改核心层和构建层，不侵入网络层。\n3. 第一个接入点：kvstore.h # 3.1 增加编译开关 # #define ENABLE_HASH 1 它应当像 ENABLE_ARRAY、ENABLE_RBTREE 一样，控制以下内容是否参与编译：\nHash 类型定义； Hash 函数声明与实现； global_hash； Hash 协议分支； Hash 的初始化与销毁。 宏开关的目的不是只隐藏一个结构体，而是完整地装上或拆下整个引擎。\n3.2 Hash 实例在项目中的最小理解 # typedef struct hashnode_s { char *key; char *value; struct hashnode_s *next; } hashnode_t; typedef struct hashtable_s { hashnode_t **nodes; int max_slots; int count; } kvs_hash_t; 从 KV 项目角度只需知道：\nglobal_hash ├── nodes 桶数组，每个元素是一条冲突链的头指针 ├── max_slots 桶数量 └── count 当前键值对数量 nodes 是二级指针，因为它先表示桶数组，再由每个桶元素指向节点：\nnodes[0] → node → node → NULL nodes[1] → NULL nodes[2] → node → NULL 发生 Hash 冲突时，相同桶下标的节点通过 next 串在一起。这里只把它作为理解资源释放和实例生命周期的前提，不展开 Hash 算法。\n3.3 对外仍然使用已有的 5+2 形状 # int kvs_hash_create(kvs_hash_t *hash); void kvs_hash_destroy(kvs_hash_t *hash); int kvs_hash_set(kvs_hash_t *hash, char *key, char *value); char *kvs_hash_get(kvs_hash_t *hash, char *key); int kvs_hash_del(kvs_hash_t *hash, char *key); int kvs_hash_mod(kvs_hash_t *hash, char *key, char *value); int kvs_hash_exist(kvs_hash_t *hash, char *key); 5+2 的设计价值和 RBTree 接入方式已在上一篇说明。这里的新增认识是：Hash 内部可以完全不同，但仍然服从项目已经形成的引擎契约。\n4. 第二个接入点：Makefile # 本阶段开始把每个 .c 文件单独编译成 .o。Hash 必须进入对象文件集合：\nOBJS = kvstore.o reactor.o proactor.o kvs_array.o \\ ntyco.o kvs_rbtree.o kvs_hash.o kvs_hash.o: kvs_hash.c kvstore.h $(CC) $(CPPFLAGS) $(CFLAGS) -c kvs_hash.c -o kvs_hash.o kvstore: $(OBJS) $(CC) -o $@ $(OBJS) $(LDFLAGS) $(LDLIBS) 原笔记中的 OBJS 没有 kvs_hash.o。这意味着即使 kvs_hash.c 已经写好，最终的 kvstore 也没有编译、链接 Hash 实现。\n建议把参数按职责拆开：\nCC = gcc CPPFLAGS = -I./NtyCo/core CFLAGS = -Wall -Wextra -g LDFLAGS = -L./NtyCo LDLIBS = -luring -lntyco -lpthread -ldl 相对上一篇 Makefile 笔记，本阶段只需新增一个检查点：\n每增加一个 KV 引擎，都要确认它的目标文件同时进入编译规则和最终的 OBJS 链接集合。\n如果还要分别生成 kvstore、NtyCo 库和 testcase，可设为三个明确目标：\n.PHONY: all ntyco clean all: ntyco kvstore testcase ntyco: $(MAKE) -C NtyCo testcase 应有自己的对象文件和链接规则，不要与服务端的 main() 混在同一个目标中。\n5. 第三个接入点：实例生命周期 # 5.1 定义全局实例 # #if ENABLE_HASH static kvs_hash_t global_hash; #endif 它与已有的 global_array、global_rbtree 并列，是 Hash 引擎在当前进程中的状态容器。\n5.2 网络服务启动前创建 # #if ENABLE_HASH if (kvs_hash_create(\u0026amp;global_hash) != 0) { /* 初始化失败，不能继续提供 Hash 命令 */ } #endif 必须先创建引擎，再进入网络循环。否则客户端已经能够发来 HSET，协议层却会访问尚未初始化的桶数组。\n5.3 网络服务停止后销毁 # #if ENABLE_HASH kvs_hash_destroy(\u0026amp;global_hash); #endif 完整顺序为：\n创建 Array / RBTree / Hash ↓ 启动所选网络层 ↓ 处理客户端命令 ↓ 网络层退出 ↓ 销毁 Hash / RBTree / Array 如果网络启动函数永不返回，还需要设计退出信号和优雅关闭流程，否则 destroy 虽然写在 main() 中，正常运行时却永远不会执行。\n6. 第四个接入点：协议命令 # 上一篇已经为 RBTree 增加 RSET/RGET/RDEL/RMOD/REXIST。教学阶段为了让多个引擎同时存在，可以继续采用同一规则：\nArray RBTree Hash 语义 SET RSET HSET 新增键值对 GET RGET HGET 查询 value DEL RDEL HDEL 删除键值对 MOD RMOD HMOD 修改已有 value EXIST REXIST HEXIST 判断 key 是否存在 协议层识别到 HSET 后调用：\nkvs_hash_set(\u0026amp;global_hash, tokens[1], tokens[2]); 其余命令同理。协议层负责将引擎内部返回值翻译成项目已有响应：\nOK\\r\\n EXIST\\r\\n NO EXIST\\r\\n 具体 value\\r\\n ERROR\\r\\n 需要保持的边界是：\n协议层：参数数量、命令选择、响应文本 Hash 层：key/value 保存、查询、修改、删除、资源管理 协议层不能直接访问 global_hash.nodes，否则 Hash 内部实现一改，协议代码也必须跟着改，统一接口就失去了意义。\n一条 HSET 的新增路径 # 前三篇已经讲过网络路径和 SET 完整路径，此处只看 Hash 分支的差异：\nHSET Teacher King → 协议层识别 HSET → 检查 token 数量为 3 → kvs_hash_set(\u0026amp;global_hash, \u0026#34;Teacher\u0026#34;, \u0026#34;King\u0026#34;) → 返回内部状态码 → 协议层生成 OK / EXIST / ERROR 从网络层到 token 切分的部分与以前完全相同，不需要为 Hash 重写。\n7. 当前 Hash 代码必须修正的工程问题 # 这些问题的重点是内存所有权和项目稳定性，而不是 Hash 算法。\n7.1 创建后必须把桶数组清零 # hash-\u0026gt;nodes = kvs_malloc(sizeof(hashnode_t *) * hash-\u0026gt;max_slots); memset(hash-\u0026gt;nodes, 0, sizeof(hashnode_t *) * hash-\u0026gt;max_slots); kvs_malloc 返回的内存默认包含不确定值。不清零时，第一次读取 hash-\u0026gt;nodes[idx] 就可能取得随机地址。\n7.2 下标计算应使用实例容量 # 当前各操作固定使用 MAX_TABLE_SIZE：\nint idx = _hash(key, MAX_TABLE_SIZE); 应改为：\nint idx = _hash(key, hash-\u0026gt;max_slots); 否则 max_slots 字段形同虚设，将来改变容量或扩容时也会出错。\n7.3 创建节点失败要逆序回滚 # 指针模式下，一个节点的申请顺序是：\nnode → key 副本 → value 副本 如果 value 申请失败，必须释放已经成功申请的 key 和 node。当前代码释放的是本来就为 NULL 的 kvalue，遗漏了前两块内存。\nkvs_hash_set() 也要判断 _create_node() 是否返回 NULL，不能直接执行 new_node-\u0026gt;next。\n7.4 三条释放路径必须一致 # 开启 ENABLE_KEY_POINTER 后，一个节点拥有三块内存：\nnode ├── key └── value 以下三条路径都必须依次释放 key、value、node：\n删除桶的头节点； 删除桶内的普通节点； 销毁整张 Hash 表。 当前普通节点删除路径释放完整，但头节点删除和 destroy 只释放了 node，会发生内存泄漏。\n7.5 MOD 要先申请，再替换 # 当前代码先 free(node-\u0026gt;value)，然后申请新 value。如果申请失败，旧值已经丢失，节点中还留下悬空指针。\n安全顺序是：\n申请新 value → 复制成功 → 保存 old_value → node-\u0026gt;value = new_value → free(old_value) 这样内存申请失败时，旧值仍然有效。\n7.6 其他接口一致性问题 # kvs_hash_mod() 还要检查 value != NULL。 固定数组模式下，strncpy 后必须保证字符串以 \\0 结束。 kvs_hash_count() 已实现，但头文件中缺少声明。 destory 应逐步统一为正确拼写 destroy。 kvs_hash_set() 的参数类型建议统一写成 kvs_hash_t *。 exist 的 0=存在、1=不存在 必须与 Array、RBTree 和协议响应保持一致。 销毁后应将 nodes=NULL、max_slots=0、count=0，降低误用风险。 8. 多语言支持：本阶段新增的认识 # 上一篇已经介绍过 TCP 客户端测试，第二篇也解释过消息边界，因此这里不再重复 socket 收发和粘包、拆包原理。\n本阶段要建立的新认识是：\nGo、Java、Node.js、Python 和 Rust 并不直接调用 C 的 Hash 函数；它们都是同一个文本协议的客户端。\n语言 TCP 客户端 API 发出的内容 Go net.Dial UTF-8 协议文本 Java Socket UTF-8 协议文本 Node.js net.createConnection UTF-8 协议文本 Python socket.socket UTF-8 协议文本 Rust TcpStream UTF-8 协议文本 五种语言的差异只在 socket API，服务端看到的都应该是：\nHSET Teacher King\\r\\n HGET Teacher\\r\\n 因此“支持第三方语言”的核心不是编写五套服务端适配层，而是稳定以下契约：\n服务器地址和端口； UTF-8 编码； 请求结束标记； 命令名称和参数数量； 响应格式； 连接是单请求、长连接还是支持流水线； 超时与异常处理方式。 原示例中的 IP 地址有两组：172.16.145.132 和 192.168.243.131，实际测试时应统一为配置项。\n用同一场景验证五种语言 # 不要让五份示例各自测试不同命令。它们应执行同一条状态链：\nHSET lang:test C → OK HGET lang:test → C HMOD lang:test common → OK HGET lang:test → common HEXIST lang:test → EXIST HDEL lang:test → OK HGET lang:test → NO EXIST 这样验证的是“协议与 Hash 引擎对所有语言行为一致”，而不只是证明五种语言都能建立 TCP 连接。\n正式测试还应给每个客户端加入：\nsend_all/write_all 语义，防止一次发送没有写完； 按 \\r\\n 或长度字段读取完整响应； 连接和读取超时； 非 UTF-8 或异常响应处理； 失败时返回非零退出码，便于自动化测试发现失败。 9. 课后扩展：把 SkipList 融入 KVEngine # 通常称为 Skip List（跳表），不是 SkipTable。\n这道动手题可以直接复用 Hash、RBTree 的接入模板：\n1. kvstore.h 增加 ENABLE_SKIPLIST 2. 定义 kvs_skiplist_t 3. 声明 SkipList 的 5+2 接口 4. 新增 kvs_skiplist.c 5. Makefile 加入 kvs_skiplist.o 6. 核心层定义 global_skiplist 7. 启动时 create，退出时 destroy 8. 协议层增加 SSET/SGET/SDEL/SMOD/SEXIST 9. 复制已有状态链，编写 SkipList 端到端测试 10. 检查失败回滚、删除和销毁时的内存所有权 这能完成“把 SkipList 当作普通 KV 引擎接入”，但还没有发挥它的有序性。\n9.1 范围查询需要扩展 5+2 # 原有 5+2 只表达单个 key 的操作，不能表达范围查询。可以增加：\nint kvs_skiplist_range( kvs_skiplist_t *inst, const char *start_key, const char *end_key, kvs_pair_t *out, size_t capacity, size_t *count ); 协议层增加：\nSRANGE user:100 user:200 LIMIT 50 设计范围接口时要明确：\n起止 key 是否包含； key 按字符串还是数值排序； 最大结果数量； 输出缓冲区由谁分配和释放； 大结果集如何分页； 遍历期间发生修改时怎样保证一致性。 Hash 适合精确点查，但没有顺序；SkipList 和 RBTree 保持 key 有序，更适合范围查询。\n10. 面试题 # 10.1 KV 存储为什么常采用单线程执行命令？ # 首先修正问题前提：KV 存储不是必须使用单线程。\n教学项目或某些内存 KV 的核心命令路径采用单线程，主要因为：\n非阻塞 I/O 可以让一个事件循环管理大量连接； 内存 KV 的单次操作通常很短； 串行执行避免共享数据上的锁竞争； 不需要频繁进行线程上下文切换； 命令顺序和数据一致性更容易理解与调试。 它的限制也很明显：慢命令会阻塞后续请求，CPU 密集型任务无法充分利用多核，单实例吞吐最终受单核限制。\n常见扩展方式是按 key 分片，每个分片由单线程串行执行，多个分片并行利用多核；也可以让网络 I/O、持久化、内存回收由不同线程负责。\n结合当前项目回答时还要看所选网络模型：如果多个线程可能同时调用协议处理函数并访问同一个 global_hash，Hash 就需要互斥锁、分片锁或单写者队列。不能因为包含了 pthread.h，就认为数据已经线程安全。\n10.2 范围查询应选择什么 KVEngine？ # 范围查询要求 key 有序：\n引擎 范围查询特点 Hash 无序，适合点查，不适合直接做范围查询 SkipList 定位起点后沿底层链表扫描，适合本项目扩展 RBTree 中序遍历有序，也能实现范围查询 B/B+Tree 适合磁盘页和大规模范围扫描 LSM-Tree 适合高写入吞吐的持久化 KV 本项目的课后题应优先使用 SkipList，并在原有 5+2 之外增加 RANGE 接口。\n10.3 频繁 malloc/free 时怎样管理 KV 内存？ # 当前项目已经通过 kvs_malloc/kvs_free 留出了统一入口。后续可以逐步演进：\n在包装层统计分配次数、字节数、峰值和泄漏。 对固定大小的节点使用对象池或 slab。 对不同长度的 key/value 使用 size class 和空闲链表。 一次申请大块内存，再切成多个小对象复用。 多线程下使用线程本地缓存或分片内存池，降低锁竞争。 修改 value 时，如果旧缓冲区容量足够就复用；否则先申请新块，成功后再替换。 设置最大内存、淘汰策略并监控碎片率。 内存池解决的是性能和碎片问题，不能替代正确的所有权规则。无论使用系统分配器还是内存池，都必须明确：谁申请、谁持有、谁释放，以及失败时如何回滚。\n11. 本阶段的实施顺序 # 统一 Hash 返回值与其他引擎的语义 → 修正初始化、失败回滚、MOD 和三条释放路径 → Makefile 加入 kvs_hash.o → 添加 global_hash 的 create/destroy → 协议层增加 HSET/HGET/HDEL/HMOD/HEXIST → 复用上一篇测试状态链验证 Hash → 五种语言运行完全相同的协议用例 → 按相同模板接入 SkipList → 单独设计 RANGE 接口和测试 → 压测后再决定分片、加锁和内存池方案 总结 # 本阶段相对于前三篇真正新增的主线是：\nHash 实现 → 用统一接口包装 → 进入 Makefile → 进入全局生命周期 → 进入协议分发 → 被同一测试链验证 → 通过统一 TCP 协议服务五种语言 最重要的认识是：\n项目扩展的重点不是孤立地增加一种数据结构，而是把它组织成符合现有接口、生命周期、协议和测试约定的 KV 引擎。\nSkipList 的课后扩展也应遵循同一思路；只有范围查询属于新的业务能力，需要在原有 5+2 之外明确增加接口。\n","date":"2026年8月26日","externalUrl":null,"permalink":"/MyBlog/p/kv-store-hash-multilingual-clients-skiplist/","section":"Posts","summary":"","title":"KV 存储项目：Hash 接入、多语言客户端与 SkipList 扩展","type":"posts"},{"content":"","date":"2026年8月26日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%A4%9A%E8%AF%AD%E8%A8%80%E5%AE%A2%E6%88%B7%E7%AB%AF/","section":"Tags","summary":"","title":"多语言客户端","type":"tags"},{"content":"阅读目标：沿着“建立可重复的测试—用压力测试暴露状态问题—在现有协议后接入第二种 KV 引擎”这条主线，回顾项目从数组存储继续向多引擎结构演进的过程。\n本文承接 KV 存储项目网络层：统一接入 Reactor、Proactor 与协程 和 KV 存储项目：协议层与数组存储。网络层的 Reactor、Proactor、ntyco，以及 kvs_protocol()、token 切分、数组五种操作等内容，前文已经完整讲过，这里只保留理解本阶段所需的连接点。\n本文以学习笔记和当前代码为准，重点解释实现过程、测试现象与架构关系，不展开红黑树算法本身。\n1. 这一阶段解决什么问题 # 前一阶段已经形成了一条可以工作的请求链路：\n客户端发送文本命令 ↓ 网络层接收数据 ↓ kvs_protocol() 切分 token ↓ kvs_filter_protocol() 识别命令 ↓ 数组引擎执行 SET / GET / DEL / MOD / EXIST ↓ 协议层生成 OK / EXIST / NO EXIST / value ↓ 网络层把响应发回客户端 项目能处理命令以后，下一步不只是继续添加代码，而是先回答三个问题：\n已有功能能否被稳定、重复地验证？ 测试次数增加后，内部状态是否仍然正确？ 协议层能否在数组之外接入新的存储引擎？ 所以本阶段的主线是：\nMakefile 统一构建 ↓ TCP 客户端实现自动化功能测试 ↓ 重复执行测试并粗测 QPS ↓ 通过高次数测试定位数组状态问题 ↓ 保留原有数组引擎，接入红黑树引擎 ↓ 用同一组语义测试第二种引擎 这里体现了一种项目迭代思路：先观察当前代码已经处于什么形态，再决定下一项功能应该接入哪一层。\n2. 用 Makefile 固定构建过程 # 随着源文件增加，手工重复输入完整的 gcc 命令会越来越不方便。最初可以直接把编译过程写进 all：\nall: gcc -o kvstore kvstore.c reactor.c proactor.c kvs_array.c ntyco.c \\ -I ./NtyCo/core/ -L ./NtyCo/ -luring -lntyco clean: rm -rf kvstore 这条命令由四部分组成：\n部分 当前内容 作用 编译器 gcc 编译并链接 C 程序 target kvstore 最终生成的程序 sources kvstore.c 等 参加编译的源文件 flags -I、-L、-l 指定头文件、库目录和链接库 把这些部分提取成变量后，Makefile 中各项职责更清楚：\nCC=gcc TARGET = kvstore SRCS = kvstore.c reactor.c proactor.c kvs_array.c ntyco.c kvs_rbtree.c INC = -I ./NtyCo/core/ LIBS = -L ./NtyCo/ -luring -lntyco all: $(CC) -o $(TARGET) $(SRCS) $(INC) $(LIBS) clean: rm -rf kvstore 当前阶段加入红黑树后，构建层面的变化很小：在 SRCS 中加入 kvs_rbtree.c。也就是说，存储引擎虽然增加了，但整个项目仍由同一个 target 统一构建。\n2.1 NtyCo 是项目的外部依赖 # INC 和 LIBS 都引用了 ./NtyCo/：\nINC → ./NtyCo/core/ 中的头文件 LIBS → ./NtyCo/ 中的 libntyco 因此，Makefile 描述的是“依赖已经存在时怎样编译”。如果本地没有 NtyCo 目录，还要先完成依赖准备，也就是把对应代码下载到项目约定的位置，再进行编译。\n这可以把构建理解成两个阶段：\n准备第三方依赖 ↓ 编译当前项目 Makefile 的价值不只在于少输入一条命令，更重要的是把当前项目由哪些文件、头文件路径和库组成固定下来。\n3. 客户端测试用例：把人工验证变成固定流程 # 测试客户端的基本流程很直接：\n1. 建立 TCP 连接 2. 发送一条协议命令 3. 接收服务端响应 4. 将实际响应与预期响应比较 5. 一致则 PASS，不一致则 FAILED 测试代码把这五步拆成几个职责单一的函数：\n函数 作用 connect_tcpserver() 创建 socket，并连接指定 IP 和端口 send_msg() 发送一条测试命令 recv_msg() 接收服务端返回值 testcase() 完成一次“发送—接收—比较” array_testcase() 组织数组引擎的一组功能场景 rbtree_testcase() 组织红黑树引擎的一组功能场景 代码中的注释“一个函数只做一件事”在这里得到了体现：网络连接、一次断言和一组业务场景分别由不同函数承担。\n3.1 一个测试用例包含什么 # testcase() 接收四项信息：\nvoid testcase(int connfd, char *msg, char *pattern, char *casename) 它们分别表示：\n参数 含义 示例 connfd 已连接的 TCP socket 与 KV 服务端的连接 msg 发给服务端的命令 SET Dad Jasper pattern 期望得到的响应 OK\\r\\n casename 用于输出的测试名称 SET-Dad 测试执行过程可以压缩为：\nmsg ──send──\u0026gt; KV 服务端 │ └── response ──recv──\u0026gt; result │ strcmp(result, pattern) │ │ 相同 不同 │ │ PASS FAILED 因此，测试用例并不需要知道数组或红黑树内部怎样保存数据。它只站在客户端角度检查协议行为，这正好覆盖了完整链路：\nTCP 收发 + 协议解析 + 引擎操作 + 响应生成 4. 一组完整场景不只是“五条命令” # 项目一共有五种基础操作：\nSET / GET / DEL / MOD / EXIST 但“覆盖五个命令”不等于每个命令只执行一次。当前 array_testcase() 实际组织了九个连续步骤：\n顺序 请求 预期响应 验证的状态 1 SET Dad Jasper OK 新键可以写入 2 GET Dad Jasper 写入后的值可以读出 3 MOD Dad Sao OK 已存在的键可以修改 4 GET Dad Sao 修改结果已经生效 5 EXIST Dad EXIST 已存在的键能被识别 6 DEL Dad OK 已存在的键可以删除 7 GET Dad NO EXIST 删除后无法再读取 8 MOD Dad Jasper NO EXIST 删除后无法再修改 9 EXIST Dad NO EXIST 删除后状态确实不存在 这九步不是彼此独立的命令，而是一条状态变化链：\n不存在 │ SET ▼ Dad = Jasper │ MOD ▼ Dad = Sao │ DEL ▼ 不存在 测试在每个关键状态后使用 GET 或 EXIST 进行确认。这样不仅验证命令返回了什么，还验证前一条命令是否真正改变了存储状态。\n4.1 测试覆盖的两个方向 # 当前场景同时覆盖：\n正向结果：写入成功、读取成功、修改成功、存在、删除成功； 反向结果：删除后读取、修改和判断存在均返回 NO EXIST。 “尽量覆盖项目所有结果，目标达到 90% 以上”可以理解为：不仅让每个函数运行一次，还要让函数中的主要结果分支都被执行到。对于协议层而言，要关注的结果包括：\nOK EXIST NO EXIST ERROR 具体 value 测试用例会贯穿后续项目：新增命令、新增引擎或调整内部结构以后，都可以重复运行同一套预期，确认原有行为仍然成立。这就是回归测试的作用。\n5. 从功能测试扩展到压力测试 # 功能测试回答的是：\n一组操作的结果是否正确？\n压力测试进一步回答：\n同一组操作重复很多次以后，结果是否仍然正确？大约能处理多少请求？\narray_testcase_10w() 将九条命令循环执行：\nint count = 10000; for (i = 0; i \u0026lt; count; i++) { /* 每轮执行 9 条请求 */ } 所以请求总数为：\n10000 轮 × 9 条/轮 = 90000 条请求 计时使用 gettimeofday()：\ntv_begin：循环开始时间 tv_end：循环结束时间 time_used：两者相差的毫秒数 宏 TIME_SUB_MS 把秒和微秒的差值统一换算为毫秒：\n#define TIME_SUB_MS(tv1, tv2) \\ ((tv1.tv_sec - tv2.tv_sec) * 1000 + \\ (tv1.tv_usec - tv2.tv_usec) / 1000) QPS 的基本计算关系是：\nQPS = 请求总数 ÷ 耗时秒数 = 请求总数 × 1000 ÷ 耗时毫秒数 这里的“请求总数”应按实际循环次数和每轮命令数计算。数组测试中是 90000；红黑树测试同样是 10000 × 9 = 90000。阅读输出时先统一这个口径，两个引擎的数据才可以放在一起比较。\n5.1 三次粗测说明了什么 # 笔记记录了 9000 次请求附近的三次结果：\n测试环境 耗时 粗测 QPS 客户端输出每次 PASS，服务端也打印 477 ms 18867 屏蔽客户端 PASS 输出 415 ms 21686 继续去掉服务端打印 285 ms 31578 三组数据的重点不是得到一个绝对性能结论，而是观察到：\n测试总耗时 = 网络收发耗时 + 协议处理耗时 + 引擎操作耗时 + 客户端打印耗时 + 服务端打印耗时 当 printf() 位于高频路径中，它也属于被计时的工作。逐步屏蔽打印后，QPS 上升，说明这次粗测测到的是整个端到端过程，而不只是 KV 引擎本身。\n5.2 当前 QPS 的准确含义 # 当前压力客户端只有一个进程、一条 TCP 连接，并且每次都按下面的顺序执行：\nsend 一条请求 ↓ 等待 recv 一条响应 ↓ 比较结果 ↓ 发送下一条请求 因此当前结果更准确地说是：\n单客户端、单连接、串行请求、包含协议校验的端到端吞吐量。\n它适合观察同一环境下的相对变化，例如开关日志前后的差异，以及数组与红黑树在相同测试场景下的差异。\n6. 为什么小次数正常，高次数却失败 # 压力测试增加到 90000 次请求后，出现了：\nFAILED-\u0026gt; SET-Dad,ERROR!=OK 从客户端场景看，每轮都执行：\nSET Dad Jasper ... DEL Dad 一轮结束时，Dad 已经删除，所以下一轮的 SET Dad Jasper 应该仍然能够成功。实际数据量始终只在 0 和 1 之间变化：\nSET 后：1 个元素 DEL 后：0 个元素 SET 后：1 个元素 DEL 后：0 个元素 但是数组实例使用 total 记录有效元素数量。如果插入成功执行 total++，删除成功却没有对应地维护数量，就会形成两套不同的状态：\n实际元素数量：0 → 1 → 0 → 1 → 0 ... total： 0 → 1 → 1 → 2 → 2 ... 循环次数少时，total 还没有触及 KVS_ARRAY_SIZE，现象不明显；循环次数持续增加后，最终会到达容量判断：\nif (inst-\u0026gt;total \u0026gt;= KVS_ARRAY_SIZE) 于是后续 SET 返回错误。与此同时，查找范围由 total 决定，total 不断增大也会使遍历范围越来越长，所以压力测试还能观察到性能逐渐变化。\n6.1 total 的核心约定 # 数组引擎需要维持一个清楚的不变量：\ntable[0] 到 table[total - 1]：有效元素 table[total] 以及后面的位置：空闲空间 total：当前有效元素数量 按照这个约定：\n插入成功 → total++ 删除成功 → total-- 如果删除数组中间的元素，可以用最后一个有效元素补到删除位置，从而继续保持有效区间连续：\n删除前：[Dad, Mom, Son] total = 3 删除 Mom 后出现空位：[Dad, 空, Son] 把最后一个元素移到空位：[Dad, Son, 空] 最终：total = 2 这个问题说明，容量字段不是只在创建时赋值、插入时递增的计数器，而是数据结构状态的一部分。每一种改变元素数量的操作都必须共同维护它。\n7. 压力测试进一步暴露的几个语义问题 # 高次数运行不仅检查容量，还会不断经过删除、查询不存在和修改不存在等分支，因此能把返回值、内存和状态维护问题一起暴露出来。\n7.1 删除 value 时的内存顺序 # 下面这个表达式先执行赋值：\nkvs_free(inst-\u0026gt;table[i].value = NULL); 它的实际求值顺序是：\ninst-\u0026gt;table[i].value = NULL ↓ kvs_free(NULL) 原来保存在 value 中的地址因此没有被交给 kvs_free()。正确理解“释放后置空”的顺序是：\n先把原地址交给 kvs_free ↓ 再把保存该地址的指针设为 NULL 也就是：\nkvs_free(inst-\u0026gt;table[i].key); inst-\u0026gt;table[i].key = NULL; kvs_free(inst-\u0026gt;table[i].value); inst-\u0026gt;table[i].value = NULL; 这里要记住的不是某一行写法，而是指针生命周期：地址仍然可达时释放内存，释放完成后再清除悬空指针。\n7.2 循环下标与业务状态不是同一种含义 # 如果同时存在：\nint i = 0; for (int i = 0; i \u0026lt; inst-\u0026gt;total; i++) { ... } for 中的 i 属于内层作用域，会遮蔽外层的 i。循环结束后，外层 i 仍是初始值 0。\n更关键的是，即使只保留一个 i，return i 仍然混合了两种概念：\ni：遍历到了哪个数组下标 返回值：本次 KV 操作是什么结果 数组为空时尤其容易看清这个区别：\ni = 0 total = 0 循环一次都不执行 return i 得到 0 这里的 0 只是下标初始值，并不能说明修改或删除成功。\n因此，学习时应把业务状态单独记忆：\n返回值 协议层理解 \u0026lt; 0 参数或执行错误，生成 ERROR 0 操作成功，或 EXIST 查询确认存在 \u0026gt; 0 SET 时表示已存在；DEL/MOD/EXIST 时表示不存在 7.3 两种失败现象来自同一套返回值约定 # 删除成功后如果返回 1，协议层会按照既定规则生成：\nDEL 返回 1 → NO EXIST 于是客户端看到：\nFAILED-\u0026gt; DEL-Dad,NO EXIST!=OK 删除以后再执行 MOD，如果数组为空、循环未执行，最后又通过 return i 返回初始值 0，协议层会生成：\nMOD 返回 0 → OK 于是客户端看到：\nFAILED-\u0026gt; MOD-Dad,OK!=NO EXIST 这两种现象虽然出现在不同命令中，核心都是同一件事：引擎函数的返回值属于内部业务协议，协议层会严格按照该约定翻译成文本响应。\n可以把这条关系记成：\n引擎内部状态 ↓ 固定返回值 ↓ kvs_filter_protocol() ↓ 客户端可见响应 ↓ testcase() 与预期比较 测试失败信息最终帮助定位到的，不一定是 TCP 层，也可能是更深处的状态维护或返回值语义。\n8. 从单一数组引擎扩展为多引擎 # 前一阶段的协议层只连接数组：\nSET / GET / DEL / MOD / EXIST ↓ global_array 本阶段保留已经成型的数组实现，并通过宏加入红黑树：\n#define ENABLE_ARRAY 1 #define ENABLE_RBTREE 1 这两个开关控制相关类型、接口、全局实例、协议分支、初始化和销毁代码是否参与编译。这样可以在接入期间让两种引擎同时存在，并分别运行测试。\n项目结构由此变成：\nkvs_protocol ↓ kvs_filter_protocol ↓ 根据命令选择对应的引擎 ↙ ↘ SET/GET/DEL/MOD/EXIST RSET/RGET/RDEL/RMOD/REXIST ↓ ↓ global_array global_rbtree 这里没有改变底层网络。无论使用 Reactor、Proactor 还是 ntyco，网络层仍然只调用同一个 kvs_protocol()。增加的是协议层后面的存储选择。\n9. 为什么给红黑树命令增加 R 前缀 # 一条普通的 SET Dad Jasper 只表达了“设置键值”，没有说明数据应该存进数组还是红黑树。\n当前阶段采用最直接的区分方法：在协议命令中加入引擎标识。\n数组命令 红黑树命令 语义 SET RSET 新增 key-value GET RGET 查询 value DEL RDEL 删除 key-value MOD RMOD 修改 value EXIST REXIST 判断 key 是否存在 命令字符串表因此扩展为十项：\nconst char *command[] = { \u0026#34;SET\u0026#34;, \u0026#34;GET\u0026#34;, \u0026#34;DEL\u0026#34;, \u0026#34;MOD\u0026#34;, \u0026#34;EXIST\u0026#34;, \u0026#34;RSET\u0026#34;, \u0026#34;RGET\u0026#34;, \u0026#34;RDEL\u0026#34;, \u0026#34;RMOD\u0026#34;, \u0026#34;REXIST\u0026#34; }; 枚举与字符串表保持相同顺序，kvs_filter_protocol() 找到命令编号后，通过 switch 进入对应分支：\nRSET ↓ KVS_CMD_RSET ↓ kvs_rbtree_set(\u0026amp;global_rbtree, key, value) ↓ 按照返回值生成 OK / EXIST / ERROR 因此，R 前缀在当前阶段承担的是一个简单的路由信息：让协议层知道应该调用哪种引擎。\n10. 每种数据结构都遵守“5 + 2”接口 # 数组引擎已经形成七个接口：\ncreate destory set get del mod exist 可以压缩为：\n2 个生命周期操作 + 5 个 KV 操作 = 5 + 2 红黑树接入时也提供相同形态：\nint kvs_rbtree_create(kvs_rbtree_t *inst); void kvs_rbtree_destory(kvs_rbtree_t *inst); int kvs_rbtree_set(kvs_rbtree_t *inst, char *key, char *value); char *kvs_rbtree_get(kvs_rbtree_t *inst, char *key); int kvs_rbtree_del(kvs_rbtree_t *inst, char *key); int kvs_rbtree_mod(kvs_rbtree_t *inst, char *key, char *value); int kvs_rbtree_exist(kvs_rbtree_t *inst, char *key); 协议层因此不需要理解旋转、着色、后继节点等红黑树内部细节。它只关心：\n用哪个实例 调用哪个 KV 操作 如何解释返回值 这正是本次“加入红黑树”最值得回顾的部分：不是从零推导红黑树算法，而是把已有数据结构包装成项目已经形成的 KV 引擎接口。\n同样的思路也可以用于后续哈希引擎：哈希表内部怎样定位 key 属于数据结构实现；接入项目时，仍围绕生命周期和五种 KV 语义组织接口。\n11. 红黑树在项目中的最小理解范围 # 为了理解本项目中的接入，只需要掌握下面几层关系。\n11.1 树实例和节点 # 红黑树实例保存：\ntypedef struct _rbtree { rbtree_node *root; rbtree_node *nil; } rbtree; 其中：\nroot 指向根节点； nil 是哨兵节点，用于表示空孩子和空树边界； nil 初始化为黑色； 创建完成时，root 指向 nil，表示树中还没有 KV 节点。 节点中与 KV 项目直接相关的是：\nkey → 用于比较和查找 value → 与 key 对应的数据 父节点、左右孩子和颜色则服务于红黑树结构本身。\n11.2 五种 KV 操作如何映射到底层树操作 # KV 接口 在红黑树中的主要过程 kvs_rbtree_set() 为 key/value 分配空间，构造节点并插入树 kvs_rbtree_get() 按 key 搜索节点，返回节点的 value kvs_rbtree_del() 搜索节点，删除节点并释放相关空间 kvs_rbtree_mod() 搜索节点，替换已有 value kvs_rbtree_exist() 搜索节点，通过是否到达 nil 判断存在性 这些包装函数把“树节点操作”转换成与数组引擎一致的“KV 操作”。\n11.3 生命周期怎样进入程序主流程 # 程序启动时，两种引擎分别初始化：\ninit_kvengine() ├─ kvs_array_create(\u0026amp;global_array) └─ kvs_rbtree_create(\u0026amp;global_rbtree) 网络服务结束后，再进入统一销毁阶段：\ndest_kvengine() ├─ kvs_array_destory(\u0026amp;global_array) └─ kvs_rbtree_destory(\u0026amp;global_rbtree) 红黑树销毁时需要逐步删除树中的节点，最后处理树实例的哨兵节点。具体采用最小节点、最大节点或其他顺序，是树内部的销毁策略；对项目主线而言，重要的是 destory 与 create 共同构成完整生命周期。\n12. 用同一套语义验证红黑树引擎 # rbtree_testcase() 与 array_testcase() 的业务顺序保持一致，只是命令增加了 R 前缀：\n数组： SET → GET → MOD → GET → EXIST → DEL → GET → MOD → EXIST 红黑树：RSET → RGET → RMOD → RGET → REXIST → RDEL → RGET → RMOD → REXIST 这使测试具有两个层次：\n单独验证每个引擎是否满足五种 KV 操作； 验证两种引擎在相同业务场景下是否表现出相同语义。 可以把两组用例看成一份“行为契约”：\n初始状态和操作 数组响应 红黑树响应 不存在时 SET OK OK SET 后 GET 对应 value 对应 value 已存在时 MOD OK OK 已存在时 EXIST EXIST EXIST 已存在时 DEL OK OK DEL 后 GET/MOD/EXIST NO EXIST NO EXIST 只要对外行为一致，客户端就不需要了解底层究竟使用数组还是红黑树。当前协议用命令前缀显式选择引擎，但测试验证的是二者共同的 KV 语义。\n13. 数组与红黑树在当前项目中的角色 # 本阶段不是简单地用红黑树替换数组，而是让两种实现同时存在：\n维度 数组引擎 红黑树引擎 协议命令 五种普通命令 五种 R 前缀命令 全局实例 global_array global_rbtree 生命周期 create/destory create/destory KV 接口 set/get/del/mod/exist set/get/del/mod/exist 内部组织 连续数组区间 根节点、哨兵和树节点 查找方式 在有效区间中遍历 按 key 在树中搜索 真正稳定下来的不是某一种数据结构，而是数据结构外面的项目边界：\n协议命令 ↓ 统一的 KV 语义 ↓ 不同引擎各自实现 这也解释了“要什么需求、用什么技术方案实现”的关系：\n需求是保存、查询、删除、修改和判断 key； 数组和红黑树是两种实现这些需求的技术方案； 协议层负责把客户端命令路由给具体方案； 测试用例负责确认不同方案满足相同的外部预期。 14. 关于多进程、多线程压力测试的课后思考 # 当前单连接测试中，每条请求都要等待上一条响应，无法同时给服务端制造多个在途请求。要观察并发场景，可以让多个执行单元各自建立连接并运行同一组测试。\n14.1 多进程思路 # 父进程 ├─ fork → 子进程 1 → 建立连接 → 重复测试 ├─ fork → 子进程 2 → 建立连接 → 重复测试 └─ fork → 子进程 N → 建立连接 → 重复测试 每个子进程拥有自己的客户端 socket 和测试循环。父进程等待所有子进程结束，再汇总：\n总请求数 = 进程数 × 每个进程的请求数 整体 QPS = 总请求数 × 1000 ÷ 整体耗时毫秒数 14.2 多线程思路 # 一个客户端进程 ├─ 线程 1 → 独立连接 → 重复测试 ├─ 线程 2 → 独立连接 → 重复测试 └─ 线程 N → 独立连接 → 重复测试 每个线程使用独立连接时，测试流程和当前代码最接近；主线程统一记录开始与结束时间，并统计所有线程完成的请求总数。\n14.3 并发数增加后观察什么 # 并发测试不只是期待 QPS 一定上升，还要同时观察：\n所有响应是否仍然符合预期； 总吞吐量怎样随客户端数量变化； 单次请求延迟是否增加； 服务端采用的网络模型是否能发挥并发处理能力； 打印、网络、协议或存储引擎中的哪一部分先成为主要耗时。 因此，多进程或多线程的意义是改变负载模型：从“一个客户端串行发请求”变成“多个客户端同时发请求”。\n15. 这一阶段形成的完整项目画面 # 把前三个阶段放在一起，可以得到当前项目结构：\n测试客户端 ├─ 功能用例：检查每一步响应 └─ 压力用例：重复场景、计时、计算 QPS │ ▼ TCP 网络 │ ▼ 网络层：Reactor / Proactor / ntyco │ ▼ kvs_protocol()：切分 token │ ▼ kvs_filter_protocol()：识别命令并选择引擎 ┌────┴────┐ ▼ ▼ 数组引擎 红黑树引擎 5 + 2 接口 5 + 2 接口 │ │ └────┬────┘ ▼ 统一返回值与文本响应 Makefile 则从构建角度把这些模块放到一起：\nkvstore.c reactor.c / proactor.c / ntyco.c kvs_array.c kvs_rbtree.c NtyCo 与 liburing ↓ kvstore 16. 最后的复习提纲 # 16.1 测试主线 # connect → send command → recv response → compare pattern → PASS / FAILED 16.2 压力测试主线 # 固定业务场景 → 重复执行 → 统计实际请求数 → 记录总耗时 → 计算 QPS → 从失败轮次和响应反查内部状态 16.3 数组问题定位主线 # 少量测试正常、增加次数后 SET 返回 ERROR → 检查容量条件 → 检查 total 的增减 → 发现实际元素已删除但 total 仍增长 → 恢复“有效元素数量”的统一约定 同时记住：\n释放内存：先 free 原地址，再把指针置 NULL 循环下标：只表示位置 函数返回值：表示业务结果 协议响应：由业务返回值翻译得到 16.4 多引擎扩展主线 # 保留数组成型代码 → 用 ENABLE_ARRAY / ENABLE_RBTREE 控制编译 → 为红黑树提供同样的 5 + 2 接口 → 增加 RSET/RGET/RDEL/RMOD/REXIST → 协议层按命令选择实例 → 用同一套业务场景验证两种引擎 16.5 本阶段最重要的认识 # 测试用例不是项目完成后的附属代码，而是项目继续迭代时用来确认行为的固定标准。\n小次数测试验证基本功能，高次数测试还会验证状态能否长期保持一致。\ntotal、节点数量和指针生命周期都属于数据结构必须维护的内部状态。\n数组、红黑树以及后续哈希表可以采用不同内部实现，但都可以通过相同的 KV 语义接入协议层。\n本阶段加入红黑树的重点，是把一种已有数据结构组织成项目需要的存储引擎，而不是从零实现红黑树算法。\n","date":"2026年8月25日","externalUrl":null,"permalink":"/MyBlog/p/kv-store-testing-multi-engine/","section":"Posts","summary":"","title":"KV 存储项目：客户端测试、压力测试与多引擎扩展","type":"posts"},{"content":"","date":"2026年8月25日","externalUrl":null,"permalink":"/MyBlog/tags/%E7%BA%A2%E9%BB%91%E6%A0%91/","section":"Tags","summary":"","title":"红黑树","type":"tags"},{"content":"","date":"2026年8月25日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%80%A7%E8%83%BD%E6%B5%8B%E8%AF%95/","section":"Tags","summary":"","title":"性能测试","type":"tags"},{"content":"","date":"2026年8月24日","externalUrl":null,"permalink":"/MyBlog/tags/crud/","section":"Tags","summary":"","title":"CRUD","type":"tags"},{"content":"阅读目标：沿着“收到一条命令—解析命令—操作数组—生成响应”这条主线，回顾 KV 项目从 echo 协议发展到实际命令处理的过程。\n本文承接 KV 存储项目网络层：统一接入 Reactor、Proactor 与协程。上一篇已经讲过 Reactor、Proactor、ntyco、msg_handler 和网络收发流程，本文不再重复网络层实现，只说明它怎样把请求交给协议层。\n本文以笔记和现有代码为准，解释代码在项目中的作用与执行过程，不讨论修改方案。\n1. 从网络层进入协议层 # 上一篇已经完成了网络层。无论底层选择 Reactor、Proactor 还是 ntyco，收到请求后都会调用同一个入口：\nkvs_protocol(msg, length, response); 这一篇从这里继续：\n网络层收到客户端数据 ↓ kvs_protocol() 进入协议层 ↓ kvs_split_token() 切分命令 ↓ kvs_filter_protocol() 识别命令 ↓ kvs_array_xxx() 操作 KV 数据 ↓ 把执行结果写入 response ↓ 网络层发送响应 网络层解决“数据怎样到达”，协议层解决“收到的数据表示什么、应该返回什么”。\n2. 为什么 TCP 之上还需要业务协议 # 假设浏览器请求一个包含数据的网页，业务链路可以是：\n浏览器 → NodeServer → KV 存储服务器 NodeServer 与 KV 存储服务器建立 TCP 连接后，还要约定双方发送数据的格式。例如：\nSEND: SET Key Value\\r\\n RECV: OK\\r\\n SEND: GET Key\\r\\n RECV: King\\r\\n TCP 是公共的传输协议，只负责按顺序传输字节；SET 和 GET 的含义属于项目自己定义的应用层协议，也可以称为业务协议或私有协议。\n本项目采用容易观察的文本命令：\nSET Key Value GET Key DEL Key MOD Key Value EXIST Key 其中空格用于分隔命令和参数，响应通过 response 缓冲区交还给网络层。\n3. TCP 消息边界：理解协议的前提 # TCP 是字节流，一次 send() 不一定对应一次 recv()。\n客户端连续发送：\nSET Key Value\\r\\n SET Key1 Value1\\r\\n SET Key2 Value2\\r\\n 接收方可能一次 recv() 收到多条命令，也可能只收到一条命令的一部分。因此应用层需要自己识别完整消息的边界。\n笔记中记录了两种约定边界的思路。\n3.1 在包头记录长度 # 在数据最前面使用固定字节表示后续消息长度：\nshort length = 0; recv(fd, \u0026amp;length, 2, 0); recv(fd, buffer, length, 0); 概念流程是：\n先读取固定长度的包头 ↓ 得到消息体长度 length ↓ 继续读取 length 个字节 ↓ 取得一条完整消息 3.2 使用结束标记 # 文本协议也可以约定用 \\r\\n 表示一行结束，再逐行解析命令。\n无论采用长度字段还是结束标记，目的都是让接收方能够从连续的 TCP 字节流中还原出一条条完整命令。\nET、LT 对应不同的事件触发方式；它们都能处理多个数据包，具体读取过程属于上一篇网络层的延伸问题。本文继续关注完整命令到达 kvs_protocol() 之后的处理。\n4. Redis 协议带来的解析思路 # SET Key Value 可以拆成三个 token：\nSET Key Value 笔记中的 Redis 协议简化示意不仅传输 token 内容，还记录 token 数量和每个 token 的长度：\n3\\r\\n 3\\r\\n SET\\r\\n 3\\r\\n Key\\r\\n 5\\r\\n Value\\r\\n 可以依次理解为：\n共有 3 个 token SET 的长度是 3 Key 的长度是 3 Value 的长度是 5 GET Teacher 则有两个 token：\n2\\r\\n 3\\r\\n GET\\r\\n 7\\r\\n Teacher\\r\\n 对应的读取伪代码是：\nreadline(fd, buffer); count = atoi(buffer); for (int i = 0; i \u0026lt; count; i++) { readline(fd, tokenlen); readline(fd, token); } 解析主线是：\n读取 token 数量 ↓ 循环读取每个 token 的长度 ↓ 按照长度读取 token 内容 ↓ 组合成一条完整命令 当前项目代码采用更直接的形式：收到 SET Key Value 后，以空格调用 strtok() 进行切分。\n5. 文件职责 # 本次代码可以按职责分为三部分：\nkvstore.h ├─ 公共宏与函数声明 ├─ 数组 KV 数据结构 └─ CRUD 接口 kvstore.c：协议部分 ├─ 内存接口封装 ├─ token 切分 ├─ 命令识别与响应生成 ├─ KV 引擎初始化 └─ main 数组存储部分 ├─ global_array ├─ create / destory └─ set / get / del / mod / exist 上一篇的 kvs_protocol() 只是把请求复制到响应中；本次代码让它开始解析并执行真实的 KV 命令。\n6. kvstore.h：数组引擎的数据结构与接口 # 6.1 编译配置 # #define KVS_MAX_TOKENS 128 #define ENABLE_ARRAY 1 #define KVS_ARRAY_SIZE 1024 宏 代码中的含义 KVS_MAX_TOKENS tokens 数组容量 ENABLE_ARRAY 是否编译数组 KV 引擎 KVS_ARRAY_SIZE KV 数组的容量 6.2 一个 KV 元素 # typedef struct kvs_array_item_s { char *key; char *value; } kvs_array_item_t; 数组中的每一个元素保存一对指针：\ntable[i] ├─ key → 键字符串 └─ value → 值字符串 6.3 整个数组实例 # typedef struct kvs_array_s { kvs_array_item_t *table; int idx; int total; } kvs_array_t; 字段 含义 table 指向 KV 元素数组 idx 数组实例中的索引字段 total 当前记录的元素数量 数组引擎向协议层提供以下操作：\nint kvs_array_create(kvs_array_t *inst); void kvs_array_destory(kvs_array_t *inst); int kvs_array_set(kvs_array_t *inst, char *key, char *value); char *kvs_array_get(kvs_array_t *inst, char *key); int kvs_array_del(kvs_array_t *inst, char *key); int kvs_array_mod(kvs_array_t *inst, char *key, char *value); int kvs_array_exist(kvs_array_t *inst, char *key); 7. 封装内存分配接口 # 代码没有让各个数据操作直接依赖 malloc() 和 free()，而是包了一层：\nvoid *kvs_malloc(size_t size) { return malloc(size); } void kvs_free(void *ptr) { free(ptr); } 其他模块统一使用：\nkvs_malloc(size); kvs_free(ptr); 笔记中的目的，是给内存管理保留统一入口。后续如果跨平台、引入内存池或定制内存分配，只需要从这一层接入，不必改变所有调用位置。\n这体现了项目迭代中的一个思路：先通过统一接口隔开业务代码与底层实现。\n8. 创建数组实例：kvs_array_create() # 全局数组实例定义为：\nkvs_array_t global_array = {0}; 创建函数：\nint kvs_array_create(kvs_array_t *inst) { if (inst == NULL) return -1; if (inst-\u0026gt;table) { printf(\u0026#34;table has been alloced\u0026#34;); return -1; } inst-\u0026gt;table = kvs_malloc( KVS_ARRAY_SIZE * sizeof(kvs_array_item_t) ); if (!inst-\u0026gt;table) return -1; inst-\u0026gt;total = 0; return 0; } 执行过程：\n检查 inst ↓ 检查 table 是否已经指向数组 ↓ 申请 KVS_ARRAY_SIZE 个元素的空间 ↓ total 设为 0 程序启动时，init_kvengine() 负责初始化它：\nint init_kvengine(void) { #if ENABLE_ARRAY memset(\u0026amp;global_array, 0, sizeof(kvs_array_t)); kvs_array_create(\u0026amp;global_array); #endif return 0; } 9. SET：保存一组 key-value # 接口：\nint kvs_array_set(kvs_array_t *inst, char *key, char *value); 主要执行顺序：\n检查 inst、key、value ↓ 检查 total 是否达到数组容量 ↓ 调用 kvs_array_get() 查找 key ↓ key 已存在时返回 1 ↓ 分别为 key 和 value 分配内存 ↓ 复制 key 和 value ↓ 寻找存放位置并写入 table ↓ total 加 1，返回 0 代码为字符串申请 strlen(...) + 1 字节：\nchar *kcopy = kvs_malloc(strlen(key) + 1); char *kvalue = kvs_malloc(strlen(value) + 1); 多出的一个字节用于字符串结尾的 \\0。\n返回值在当前协议中的含义：\nret \u0026lt; 0 → 执行错误 ret == 0 → 设置成功 ret \u0026gt; 0 → key 已存在 10. GET：按照 key 查找 value # 接口：\nchar *kvs_array_get(kvs_array_t *inst, char *key); 查询过程：\n检查 inst 和 key ↓ 从 table[0] 开始遍历 ↓ 跳过 key 为空的元素 ↓ strcmp() 比较元素 key 和目标 key ↓ 找到后返回 value ↓ 遍历结束仍未找到则返回 NULL 核心比较：\nif (strcmp(inst-\u0026gt;table[i].key, key) == 0) { return inst-\u0026gt;table[i].value; } 因此 kvs_array_get() 的结果可以直接被 SET、GET 和 EXIST 复用。\n11. DEL：删除 key-value # 接口：\nint kvs_array_del(kvs_array_t *inst, char *key); 执行主线：\n检查 inst 和 key ↓ 遍历 table ↓ strcmp() 找到相同 key ↓ 释放 key 对应的内存 ↓ 释放 value 对应的内存 ↓ 返回执行结果 协议层按照约定把返回值转换为：\nERROR / OK / NO EXIST 12. MOD：修改已有 key 的 value # 接口：\nint kvs_array_mod(kvs_array_t *inst, char *key, char *value); 执行主线：\n检查 inst、key、value ↓ 遍历 table 查找 key ↓ 释放原来的 value ↓ 按照新字符串长度申请内存 ↓ 复制新的 value ↓ 把新 value 写回当前元素 MOD 保留原来的 key，只更新与它关联的 value。\n13. EXIST：复用 GET 判断 key 是否存在 # int kvs_array_exist(kvs_array_t *inst, char *key) { char *str = kvs_array_get(inst, key); if (!str) { return 1; } return 0; } 返回值含义：\n0 → key 存在 1 → key 不存在 这里没有再次编写数组遍历，而是调用 kvs_array_get() 完成查找。\n14. 命令字符串与枚举的对应关系 # 命令字符串表：\nconst char *command[] = { \u0026#34;SET\u0026#34;, \u0026#34;GET\u0026#34;, \u0026#34;DEL\u0026#34;, \u0026#34;MOD\u0026#34;, \u0026#34;EXIST\u0026#34; }; 枚举：\nenum { KVS_CMD_START = 0, KVS_CMD_SET = KVS_CMD_START, KVS_CMD_GET, KVS_CMD_DEL, KVS_CMD_MOD, KVS_CMD_EXIST, KVS_CMD_COUNT, }; 二者按下标形成对应关系：\ncmd command[cmd] 枚举 0 SET KVS_CMD_SET 1 GET KVS_CMD_GET 2 DEL KVS_CMD_DEL 3 MOD KVS_CMD_MOD 4 EXIST KVS_CMD_EXIST 协议解析通过同一个 cmd 同时连接字符串表与 switch 分支，所以代码注释强调二者顺序保持对应。\n15. kvs_split_token()：把命令切成参数 # 函数入口：\nint kvs_split_token(char *msg, char *tokens[]) 首先检查参数：\nif (msg == NULL || tokens == NULL) return -1; 然后以空格为分隔符调用 strtok()：\nint idx = 0; char *token = strtok(msg, \u0026#34; \u0026#34;); while (token != NULL) { tokens[idx++] = token; token = strtok(NULL, \u0026#34; \u0026#34;); } return idx; 对于：\nSET Key Value 切分结果是：\ntokens[0] → \u0026#34;SET\u0026#34; tokens[1] → \u0026#34;Key\u0026#34; tokens[2] → \u0026#34;Value\u0026#34; 返回 count = 3 对于：\nGET Key 切分结果是：\ntokens[0] → \u0026#34;GET\u0026#34; tokens[1] → \u0026#34;Key\u0026#34; 返回 count = 2 笔记中强调：函数进入时先进行参数判断。完整项目还会逐步形成统一的参数错误、内存错误和协议错误等返回值约定。\n16. kvs_filter_protocol()：识别命令并生成响应 # 函数入口：\nint kvs_filter_protocol(char **tokens, int count, char *response); 16.1 查找命令编号 # int cmd = KVS_CMD_START; for (cmd = KVS_CMD_START; cmd \u0026lt; KVS_CMD_COUNT; cmd++) { if (strcmp(tokens[0], command[cmd]) == 0) { break; } } 例如 tokens[0] 是 GET，循环最终得到：\ncmd = KVS_CMD_GET 随后统一取出参数位置：\nchar *key = tokens[1]; char *value = tokens[2]; 16.2 SET 分支 # ret = kvs_array_set(\u0026amp;global_array, key, value); 数组层结果 协议响应 ret \u0026lt; 0 ERROR\\r\\n ret == 0 OK\\r\\n ret \u0026gt; 0 EXIST\\r\\n 16.3 GET 分支 # char *result = kvs_array_get(\u0026amp;global_array, key); 查询结果 协议响应 result == NULL NO EXIST\\r\\n 找到 value value\\r\\n 16.4 DEL 分支 # ret = kvs_array_del(\u0026amp;global_array, key); 数组层结果 协议响应 ret \u0026lt; 0 ERROR\\r\\n ret == 0 OK\\r\\n ret \u0026gt; 0 NO EXIST\\r\\n 16.5 MOD 分支 # ret = kvs_array_mod(\u0026amp;global_array, key, value); 数组层结果 协议响应 ret \u0026lt; 0 ERROR\\r\\n ret == 0 OK\\r\\n ret \u0026gt; 0 NO EXIST\\r\\n 16.6 EXIST 分支 # ret = kvs_array_exist(\u0026amp;global_array, key); 数组层结果 协议响应 ret == 0 EXIST ret != 0 NO EXIST\\r\\n kvs_filter_protocol() 的角色可以概括为：把文本命令转换成数组函数调用，再把数组函数的返回值转换成文本响应。\n17. kvs_protocol()：本篇代码的总入口 # int kvs_protocol(char *msg, int length, char *response) { if (msg == NULL || length \u0026lt;= 0 || response == NULL) return -1; printf(\u0026#34;recv: %d: %s\\n\u0026#34;, length, msg); char *tokens[KVS_MAX_TOKENS] = {0}; int count = kvs_split_token(msg, tokens); if (count == -1) return -1; return kvs_filter_protocol(tokens, count, response); } 三个参数分别来自网络层：\n参数 含义 msg 收到的请求内容 length 请求长度 response 协议层写入响应的缓冲区 执行主线：\n检查参数 ↓ 创建 tokens 数组 ↓ kvs_split_token(msg, tokens) ↓ 得到 token 数量 count ↓ kvs_filter_protocol(tokens, count, response) ↓ 执行命令并形成响应 它把上一篇的“网络回调入口”与本篇的“命令解析和数组 CRUD”连接起来。\n18. 程序启动与数组引擎接入 # main() 先取得端口，再初始化 KV 引擎：\nint port = atoi(argv[1]); init_kvengine(); 之后按照 NETWORK_SELECT 启动对应网络层，并继续把 kvs_protocol 作为回调传入：\nreactor_start(port, kvs_protocol); /* 或 */ ntyco_start(port, kvs_protocol); /* 或 */ proactor_start(port, kvs_protocol); 这里不再展开三种网络模型。只需记住：网络模型可以切换，协议入口和数组引擎的调用主线保持不变。\n19. 一条 SET 命令的完整路径 # 客户端发送：\nSET name King 代码中的执行过程：\n网络层取得 msg ↓ kvs_protocol(msg, length, response) ↓ kvs_split_token() ↓ tokens[0] = \u0026#34;SET\u0026#34; tokens[1] = \u0026#34;name\u0026#34; tokens[2] = \u0026#34;King\u0026#34; ↓ kvs_filter_protocol() ↓ 匹配 KVS_CMD_SET ↓ kvs_array_set(\u0026amp;global_array, \u0026#34;name\u0026#34;, \u0026#34;King\u0026#34;) ↓ 为 key 和 value 分配内存并写入 table ↓ response = \u0026#34;OK\\r\\n\u0026#34; ↓ 网络层发送响应 20. 一条 GET 命令的完整路径 # 客户端发送：\nGET name 执行过程：\nkvs_protocol() ↓ 切分出 \u0026#34;GET\u0026#34; 和 \u0026#34;name\u0026#34; ↓ kvs_filter_protocol() ↓ 匹配 KVS_CMD_GET ↓ kvs_array_get(\u0026amp;global_array, \u0026#34;name\u0026#34;) ↓ 遍历 table 并比较 key ↓ 找到 value = \u0026#34;King\u0026#34; ↓ response = \u0026#34;King\\r\\n\u0026#34; 如果没有找到，则响应：\nNO EXIST\\r\\n 21. 返回值与协议响应是两个层次 # 数组函数首先返回内部执行结果，例如：\n小于 0：错误 等于 0：成功或存在 大于 0：已存在或不存在 协议层再将其转换成客户端能够理解的文本：\nOK ERROR EXIST NO EXIST 具体的 value 这与 HTTP 使用 200、404 等状态表达处理结果的思路相似：一个完整项目会逐步形成统一的参数错误、内存分配失败、协议解析失败等返回值标准。\n22. 从功能实现到项目架构 # 笔记给出的迭代顺序是：\n先保证功能 ↓ 保证协议统一 ↓ 保证参数统一 ↓ 保证返回值统一 数组法先直接实现了 KV 的基本操作：\nSET → 增加 key-value GET → 查询 value DEL → 删除 key-value MOD → 修改 value EXIST → 判断 key 是否存在 随着代码不断迭代，公共接口、模块边界和返回值规则逐渐稳定，进而形成项目架构。\n可以记住两句话：\n架构是在软件不断迭代的过程中逐渐产生的。\n软件代码可以通过分层设计，将网络收发、协议处理和数据存储分开。\n23. 最后的整体记忆 # 这一阶段最重要的不是分别记住所有函数，而是记住数据在各层之间怎样移动：\n客户端文本命令 ↓ 网络层交给 kvs_protocol ↓ kvs_split_token 拆出 command / key / value ↓ kvs_filter_protocol 匹配命令 ↓ kvs_array_set/get/del/mod/exist ↓ global_array 中的 key-value 数据 ↓ 协议层生成 OK、ERROR、value 等响应 ↓ 网络层发送给客户端 按函数压缩为：\nkvs_protocol → kvs_split_token → kvs_filter_protocol → kvs_array_xxx → response ","date":"2026年8月24日","externalUrl":null,"permalink":"/MyBlog/p/kv-store-protocol-array/","section":"Posts","summary":"","title":"KV 存储项目：协议层与数组存储","type":"posts"},{"content":"","date":"2026年8月24日","externalUrl":null,"permalink":"/MyBlog/tags/%E8%87%AA%E5%AE%9A%E4%B9%89%E5%8D%8F%E8%AE%AE/","section":"Tags","summary":"","title":"自定义协议","type":"tags"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/MyBlog/tags/io_uring/","section":"Tags","summary":"","title":"Io_uring","type":"tags"},{"content":"阅读目标：先恢复项目的整体认识，再通过带注释的代码块回忆具体实现。\n本文只解析现有成品代码，不讨论修改方案。ntyco 只讲如何作为 KV 项目的网络层使用。\n1. 先恢复项目的整体画面 # 这个项目的核心不是分别写了三个独立服务器，而是让三个网络服务器共同使用同一个 KV 协议入口：\nkvstore.c main + kvs_protocol │ msg_handler 回调 │ ┌──────────────┼──────────────┐ │ │ │ reactor.c ntyco.c proactor.c epoll 协程 io_uring │ │ │ └──────────────┼──────────────┘ │ TCP 三种网络层只负责连接和收发，kvs_protocol() 负责处理消息：\n客户端请求 ↓ 网络层收到 msg 和 length ↓ kvs_protocol(msg, length, response) ↓ 返回 response 的长度 ↓ 网络层发送 response 项目目前的 kvs_protocol() 是 echo 协议：把请求复制成响应。这一实现展示的重点，是三种网络模型如何接入同一个 KV 协议回调。\n六份文件分成三组：\n公共入口与接口 ├─ kvstore.c └─ kvstore.h Reactor 公共连接定义 └─ server.h 三种网络层 ├─ reactor.c ├─ proactor.c └─ ntyco.c 2. 公共入口：kvstore.h 与 kvstore.c # 2.1 kvstore.h：把三种网络层统一成一种调用方式 # #ifndef KV_STORE_H #define KV_STORE_H /* 三种网络实现的编号，供 NETWORK_SELECT 选择 */ #define NETWORK_REACTOR 0 #define NETWORK_PROACTOR 1 #define NET_WORK_NTYCO 2 /* 当前编译使用 io_uring Proactor 网络层 */ #define NETWORK_SELECT NETWORK_PROACTOR /* * KV 协议回调类型： * msg 网络层收到的请求 * length 请求的实际长度 * response 协议层写入响应的位置 * 返回值 网络层需要发送的响应长度 */ typedef int (*msg_handler)(char *msg, int length, char *response); /* 三种网络层向 kvstore.c 暴露相同形式的启动函数 */ extern int reactor_start(unsigned short port, msg_handler handler); extern int ntyco_start(unsigned short port, msg_handler handler); extern int proactor_start(unsigned short port, msg_handler handler); /* KV 项目支持的命令名称 */ const char *command[] = { \u0026#34;SET\u0026#34;, \u0026#34;GET\u0026#34;, \u0026#34;DEL\u0026#34;, \u0026#34;MOD\u0026#34;, \u0026#34;EXIST\u0026#34; }; /* 响应字符串表，目前未填充 */ const char *response[] = { }; #endif 这一文件的整体作用可以概括为：\nNETWORK_SELECT 决定使用谁 msg_handler 规定协议函数长什么样 三个 start 规定网络层怎样启动 command 列出 KV 命令集合 2.2 kvstore.c：选择网络层，并把协议函数交给它 # #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;string.h\u0026gt; #include \u0026lt;stdlib.h\u0026gt; #include \u0026#34;kvstore.h\u0026#34; /* * 当前是 echo 形式的 KV 协议入口。 * 网络层把请求传进来，本函数把请求复制成响应。 */ int kvs_protocol(char *msg, int length, char *response) { /* 输出本次请求长度与内容 */ printf(\u0026#34;recv: %d: %s\\n\u0026#34;, length, msg); /* 将收到的 length 个字节复制到响应缓冲区 */ memcpy(response, msg, length); /* 把响应长度交还给网络层 */ return strlen(response); } int main(int argc, char *argv[]) { /* 启动形式要求为：./kvstore \u0026lt;port\u0026gt; */ if (argc != 2) return -1; /* 将命令行中的端口字符串转换成整数 */ int port = atoi(argv[1]); /* * 编译期选择网络模型。 * 三个分支都把同一个 kvs_protocol 作为 handler 传入。 */ #if (NETWORK_SELECT == NETWORK_REACTOR) reactor_start(port, kvs_protocol); #elif (NETWORK_SELECT == NETWORK_NTYCO) ntyco_start(port, kvs_protocol); #elif (NETWORK_SELECT == NETWORK_PROACTOR) proactor_start(port, kvs_protocol); #endif } 这里最需要记住的不是条件编译本身，而是下面三个调用具有完全相同的含义：\nreactor_start(port, kvs_protocol); ntyco_start(port, kvs_protocol); proactor_start(port, kvs_protocol); 可以统一读作：\n在指定端口启动某个网络后端；以后收到消息时，请调用 kvs_protocol()。\n3. Reactor 的连接结构：server.h # Reactor 是事件驱动的。一次读事件和下一次写事件不是在同一个函数中连续完成，因此需要 struct conn 保存跨事件存在的连接信息。\n#ifndef SERVER_H #define SERVER_H /* 每个连接的读、写缓冲区都是 1024 字节 */ #define BUFFER_LENGTH 1024 /* 当前 Reactor 上层启用 KVSTORE 协议 */ #define ENABLE_HTTP 0 #define ENABLE_WEBSOCKET 0 #define ENABLE_KVSTORE 1 /* epoll 事件回调：传入发生事件的 fd */ typedef int (*RCALLBACK)(int fd); struct conn { int fd; // 当前连接的 socket fd char rbuffer[BUFFER_LENGTH]; // recv 接收到的请求 int rlength; // 请求的实际长度 char wbuffer[BUFFER_LENGTH]; // KV 协议生成的响应 int wlength; // 响应的实际长度 RCALLBACK send_callback; // EPOLLOUT 时调用 send_cb union { RCALLBACK recv_callback; // 客户端 fd 可读时调用 recv_cb RCALLBACK accept_callback; // 监听 fd 可读时调用 accept_cb } r_action; int status; // 连接/协议状态字段 #if 1 // websocket char *payload; // WebSocket 数据字段 char mask[4]; // WebSocket 掩码字段 #endif }; /* 同一套 Reactor 曾为不同上层协议保留入口 */ #if ENABLE_HTTP int http_request(struct conn *c); int http_response(struct conn *c); #endif #if ENABLE_WEBSOCKET int ws_request(struct conn *c); int ws_response(struct conn *c); #endif #if ENABLE_KVSTORE int kvs_request(struct conn *c); int kvs_response(struct conn *c); #endif #endif struct conn 可以用一条数据流来理解：\nrecv(fd) ↓ rbuffer + rlength ↓ kvs_handler wbuffer + wlength ↓ send(fd) 两个函数指针则决定 epoll 返回事件时执行什么：\n监听 fd 的 EPOLLIN → accept_cb 连接 fd的 EPOLLIN → recv_cb 连接 fd的 EPOLLOUT → send_cb 4. Reactor 网络层：reactor.c # 4.1 Reactor 总体流程 # 创建 epoll ↓ 创建 20 个监听 socket，并注册 EPOLLIN ↓ epoll_wait ├─ 监听 fd 可读 → accept_cb ├─ 客户端 fd 可读 → recv_cb → kvs_protocol └─ 客户端 fd 可写 → send_cb 4.2 KV 回调和连接表 # #define CONNECTION_SIZE 1048576 // 连接表容量 #define MAX_PORTS 20 // 连续监听 20 个端口 /* 计算两次 timeval 之间的毫秒差，用于 accept 统计 */ #define TIME_SUB_MS(tv1, tv2) \\ ((tv1.tv_sec - tv2.tv_sec) * 1000 + \\ (tv1.tv_usec - tv2.tv_usec) / 1000) #if ENABLE_KVSTORE /* reactor_start 会把 kvs_protocol 保存到这里 */ static msg_handler kvs_handler; int kvs_request(struct conn *c) { /* * 将连接的接收缓冲区交给 KV 层； * KV 层把响应写入同一连接的发送缓冲区； * 返回值记录到 wlength。 */ c-\u0026gt;wlength = kvs_handler( c-\u0026gt;rbuffer, c-\u0026gt;rlength, c-\u0026gt;wbuffer ); } int kvs_response(struct conn *c) { /* 当前 KV 响应不再进行二次处理 */ } #endif int epfd = 0; // epoll 实例 fd struct timeval begin; // accept 统计起点 struct conn conn_list[CONNECTION_SIZE]; // 用 fd 直接索引连接 4.3 添加和切换 epoll 事件 # int set_event(int fd, int event, int flag) { struct epoll_event ev; ev.events = event; // EPOLLIN 或 EPOLLOUT ev.data.fd = fd; // epoll 返回事件时带回这个 fd if (flag) { /* 新 fd 第一次进入 epoll */ epoll_ctl(epfd, EPOLL_CTL_ADD, fd, \u0026amp;ev); } else { /* 已登记 fd 在读、写关注之间切换 */ epoll_ctl(epfd, EPOLL_CTL_MOD, fd, \u0026amp;ev); } } 这份 Reactor 的主要节奏，就是不断切换同一个客户端的事件：\n刚建立连接 → EPOLLIN 收到并处理请求 → EPOLLOUT 发送完响应 → EPOLLIN 4.4 初始化一个客户端连接 # int event_register(int fd, int event) { if (fd \u0026lt; 0) return -1; /* fd 同时也是 conn_list 的下标 */ conn_list[fd].fd = fd; /* 客户端可读和可写时对应的处理函数 */ conn_list[fd].r_action.recv_callback = recv_cb; conn_list[fd].send_callback = send_cb; /* 初始化这个连接的输入区域 */ memset(conn_list[fd].rbuffer, 0, BUFFER_LENGTH); conn_list[fd].rlength = 0; /* 初始化这个连接的输出区域 */ memset(conn_list[fd].wbuffer, 0, BUFFER_LENGTH); conn_list[fd].wlength = 0; /* 新连接加入 epoll，调用处传入的是 EPOLLIN */ set_event(fd, event, 1); } 4.5 创建监听 socket # int r_init_server(unsigned short port) { /* 创建 IPv4 TCP socket */ int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in servaddr; servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0 servaddr.sin_port = htons(port); // 监听端口 /* 把 socket 绑定到本机端口 */ if (bind(sockfd, (struct sockaddr *)\u0026amp;servaddr, sizeof(struct sockaddr)) == -1) { printf(\u0026#34;bind failed: %s\\n\u0026#34;, strerror(errno)); } /* 将 socket 变为监听 socket */ listen(sockfd, 10); return sockfd; } 4.6 接受客户端 # int accept_cb(int fd) { struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); /* * fd 是监听 socket； * clientfd 是新建立的客户端连接。 */ int clientfd = accept( fd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len ); printf(\u0026#34;accept finshed: %d,fd:%d\\n\u0026#34;, clientfd, fd); if (clientfd \u0026lt; 0) { printf(\u0026#34;accept errno: %d --\u0026gt; %s\\n\u0026#34;, errno, strerror(errno)); return -1; } /* 新客户端从等待读事件开始 */ event_register(clientfd, EPOLLIN); /* 每当 fd 到达 1000 的整数倍，打印一次时间间隔 */ if ((clientfd % 1000) == 0) { struct timeval current; gettimeofday(\u0026amp;current, NULL); int time_used = TIME_SUB_MS(current, begin); memcpy(\u0026amp;begin, \u0026amp;current, sizeof(struct timeval)); printf(\u0026#34;accept finshed: %d, time_used: %d\\n\u0026#34;, clientfd, time_used); } return 0; } 4.7 接收请求并进入 KV 协议 # int recv_cb(int fd) { /* 本次接收前清空该连接的读缓冲区 */ memset(conn_list[fd].rbuffer, 0, BUFFER_LENGTH); /* 从客户端接收数据 */ int count = recv( fd, conn_list[fd].rbuffer, BUFFER_LENGTH, 0 ); if (count == 0) { /* recv 返回 0：客户端断开 */ printf(\u0026#34;client disconnect: %d\\n\u0026#34;, fd); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); return 0; } else if (count \u0026lt; 0) { /* recv 返回负数：打印错误并结束连接 */ printf(\u0026#34;count: %d, errno: %d, %s\\n\u0026#34;, count, errno, strerror(errno)); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); return 0; } /* 保存这次实际收到的请求长度 */ conn_list[fd].rlength = count; #if 0 /* echo 分支：直接把读缓冲复制到写缓冲 */ conn_list[fd].wlength = conn_list[fd].rlength; memcpy(conn_list[fd].wbuffer, conn_list[fd].rbuffer, conn_list[fd].wlength); #elif ENABLE_HTTP http_request(\u0026amp;conn_list[fd]); #elif ENABLE_WEBSOCKET ws_request(\u0026amp;conn_list[fd]); #elif ENABLE_KVSTORE /* 当前启用的分支：通过 handler 生成 KV 响应 */ kvs_request(\u0026amp;conn_list[fd]); #endif /* 请求处理完成，下一阶段等待发送 */ set_event(fd, EPOLLOUT, 0); return count; } 这里完成了项目中最重要的一次跨层调用：\nrecv_cb → kvs_request(\u0026amp;conn_list[fd]) → kvs_handler(rbuffer, rlength, wbuffer) → kvs_protocol 4.8 发送响应 # int send_cb(int fd) { #if ENABLE_HTTP http_response(\u0026amp;conn_list[fd]); #elif ENABLE_WEBSOCKET ws_response(\u0026amp;conn_list[fd]); #elif ENABLE_KVSTORE /* KV 分支当前不对 wbuffer 做额外处理 */ kvs_response(\u0026amp;conn_list[fd]); #endif int count = 0; /* wlength 非零时，把 KV 响应发给客户端 */ if (conn_list[fd].wlength != 0) { count = send( fd, conn_list[fd].wbuffer, conn_list[fd].wlength, 0 ); } /* 响应阶段结束，再次等待客户端请求 */ set_event(fd, EPOLLIN, 0); return count; } 4.9 启动 Reactor 主循环 # int reactor_start(unsigned short port, msg_handler handler) { printf(\u0026#34;reactor_entry %d\\n\u0026#34;, port); /* 保存由 main 传入的 kvs_protocol */ kvs_handler = handler; /* 创建 epoll 实例 */ epfd = epoll_create(1); /* 从 port 开始连续建立 20 个监听端口 */ for (int i = 0; i \u0026lt; MAX_PORTS; i++) { int sockfd = r_init_server(port + i); /* 监听 fd 可读时执行 accept_cb */ conn_list[sockfd].fd = sockfd; conn_list[sockfd].r_action.recv_callback = accept_cb; /* 将监听 fd 加入 epoll */ set_event(sockfd, EPOLLIN, 1); } /* accept 性能统计的起始时间 */ gettimeofday(\u0026amp;begin, NULL); while (1) { struct epoll_event events[1024] = {0}; /* 阻塞等待，最多取得 1024 个就绪事件 */ int nready = epoll_wait(epfd, events, 1024, -1); for (int i = 0; i \u0026lt; nready; i++) { int connfd = events[i].data.fd; /* * 监听 fd：read callback 是 accept_cb； * 客户端 fd：read callback 是 recv_cb。 */ if (events[i].events \u0026amp; EPOLLIN) { conn_list[connfd] .r_action.recv_callback(connfd); } /* 客户端可写时调用 send_cb */ if (events[i].events \u0026amp; EPOLLOUT) { conn_list[connfd] .send_callback(connfd); } } } } 把整份 Reactor 压缩成一句话：\nepoll 主循环根据 fd 和事件找到回调；读回调接收请求并调用 KV handler，写回调发送 handler 生成的响应。\n5. io_uring Proactor 网络层：proactor.c # 5.1 Proactor 总体流程 # 准备 ACCEPT 操作 ↓ submit 等待完成队列 CQ ↓ ACCEPT 完成 → 得到 connfd → 准备 RECV ↓ READ 完成 → 调 kvs_protocol → 准备 SEND ↓ WRITE 完成 → 再次准备 RECV Reactor 是“事件就绪以后调用 I/O”，这份 io_uring 代码是“先提交具体 I/O，再处理完成结果”。\n5.2 操作类型与操作标记 # /* 用于区分 CQE 对应哪一种操作 */ #define EVENT_ACCEPT 0 #define EVENT_READ 1 #define EVENT_WRITE 2 struct conn_info { int fd; // 操作所属的 socket int event; // ACCEPT / READ / WRITE }; #define ENTRIES_LENGTH 1024 // io_uring 队列项参数 #define BUFFER_LENGTH 1024 // 网络缓冲区长度 /* proactor_start 保存 main 传入的 kvs_protocol */ static msg_handler kvs_handler; 每个 SQE 都通过 user_data 带上 conn_info。CQE 返回以后，程序就知道完成的是谁的什么操作。\n5.3 准备 send # int set_event_send(struct io_uring *ring, int sockfd, void *buf, size_t len, int flags) { /* 从提交队列取得一个 SQE */ struct io_uring_sqe *sqe = io_uring_get_sqe(ring); /* 标记这是 sockfd 的写操作 */ struct conn_info info = { .fd = sockfd, .event = EVENT_WRITE, }; /* 将 SQE 填写为 send 请求 */ io_uring_prep_send(sqe, sockfd, buf, len, flags); /* 操作完成时，通过 user_data 找回 fd 和事件类型 */ memcpy(\u0026amp;sqe-\u0026gt;user_data, \u0026amp;info, sizeof(struct conn_info)); } 5.4 准备 recv # int set_event_recv(struct io_uring *ring, int sockfd, void *buf, size_t len, int flags) { struct io_uring_sqe *sqe = io_uring_get_sqe(ring); /* 标记这是 sockfd 的读操作 */ struct conn_info info = { .fd = sockfd, .event = EVENT_READ, }; /* 将 SQE 填写为 recv 请求 */ io_uring_prep_recv(sqe, sockfd, buf, len, flags); memcpy(\u0026amp;sqe-\u0026gt;user_data, \u0026amp;info, sizeof(struct conn_info)); } 5.5 准备 accept # int set_event_accept(struct io_uring *ring, int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags) { struct io_uring_sqe *sqe = io_uring_get_sqe(ring); /* 标记这是监听 sockfd 的 accept 操作 */ struct conn_info info = { .fd = sockfd, .event = EVENT_ACCEPT, }; /* 将 SQE 填写为 accept 请求 */ io_uring_prep_accept(sqe, sockfd, addr, addrlen, flags); memcpy(\u0026amp;sqe-\u0026gt;user_data, \u0026amp;info, sizeof(struct conn_info)); } 三个函数的结构完全一致，只有 prep 函数和 event 类型不同：\n准备函数 liburing prep 事件标记 set_event_accept io_uring_prep_accept EVENT_ACCEPT set_event_recv io_uring_prep_recv EVENT_READ set_event_send io_uring_prep_send EVENT_WRITE 5.6 创建监听 socket # int p_init_server(unsigned short port) { /* 创建 IPv4 TCP socket */ int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in servaddr; servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(port); /* 绑定端口 */ if (bind(sockfd, (struct sockaddr *)\u0026amp;servaddr, sizeof(struct sockaddr)) == -1) { printf(\u0026#34;bind failed: %s\\n\u0026#34;, strerror(errno)); } /* 开始监听 */ listen(sockfd, 10); return sockfd; } 5.7 初始化 io_uring 并准备第一个 accept # int proactor_start(unsigned short port, msg_handler handler) { /* 创建监听 socket */ int sockfd = p_init_server(port); /* 保存 kvs_protocol */ kvs_handler = handler; /* 初始化 io_uring 参数 */ struct io_uring_params params; memset(\u0026amp;params, 0, sizeof(params)); /* 创建提交队列和完成队列 */ struct io_uring ring; io_uring_queue_init_params( ENTRIES_LENGTH, \u0026amp;ring, \u0026amp;params ); /* accept 完成后，内核会填写客户端地址 */ struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); /* 在进入循环之前，先准备第一个 accept SQE */ set_event_accept( \u0026amp;ring, sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len, 0 ); /* READ 使用 buffer，KV 输出和 WRITE 使用 response */ char buffer[BUFFER_LENGTH] = {0}; char response[BUFFER_LENGTH] = {0}; 5.8 提交和取得完成事件 # while (1) { /* 把当前已经准备好的 SQE 提交给 io_uring */ io_uring_submit(\u0026amp;ring); /* 至少等待一个操作完成 */ struct io_uring_cqe *cqe; io_uring_wait_cqe(\u0026amp;ring, \u0026amp;cqe); /* 一次批量取出最多 128 个完成项 */ struct io_uring_cqe *cqes[128]; int nready = io_uring_peek_batch_cqe( \u0026amp;ring, cqes, 128 ); for (int i = 0; i \u0026lt; nready; i++) { struct io_uring_cqe *entry = cqes[i]; /* 从 user_data 还原这个操作的 fd 和类型 */ struct conn_info result; memcpy(\u0026amp;result, \u0026amp;entry-\u0026gt;user_data, sizeof(struct conn_info)); 这里的两个信息来源要分清：\nresult.fd / result.event 提交 SQE 时由应用保存 用来识别“谁的什么操作” entry-\u0026gt;res 操作完成时由内核返回 用来表示 accept 的新 fd、recv 长度或 send 结果 5.9 分发 ACCEPT、READ 和 WRITE 完成事件 # if (result.event == EVENT_ACCEPT) { /* * 当前 accept 已完成； * 先准备下一个 accept，保持继续接收新连接。 */ set_event_accept( \u0026amp;ring, sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len, 0 ); /* accept CQE 的 res 是新客户端 fd */ int connfd = entry-\u0026gt;res; /* 为新客户端准备第一次 recv */ set_event_recv( \u0026amp;ring, connfd, buffer, BUFFER_LENGTH, 0 ); } else if (result.event == EVENT_READ) { /* recv CQE 的 res 是本次收到的字节数 */ int ret = entry-\u0026gt;res; if (ret == 0) { /* 客户端关闭连接 */ close(result.fd); } else if (ret \u0026gt; 0) { /* * buffer + ret 构成请求； * response 接收 KV 协议输出； * 返回值重新赋给 ret，作为响应长度。 */ ret = kvs_handler(buffer, ret, response); /* 准备把 KV 响应发送给原客户端 */ set_event_send( \u0026amp;ring, result.fd, response, ret, 0 ); } } else if (result.event == EVENT_WRITE) { /* send CQE 的 res 是写操作完成结果 */ int ret = entry-\u0026gt;res; /* 本轮响应结束，再次等待该客户端请求 */ set_event_recv( \u0026amp;ring, result.fd, buffer, BUFFER_LENGTH, 0 ); } } /* 告诉 ring：这一批 CQE 已经处理完 */ io_uring_cq_advance(\u0026amp;ring, nready); } } 把整份 Proactor 压缩成一句话：\n每处理一个完成事件，就准备这个连接的下一项操作；READ 完成与 WRITE 准备之间插入一次 KV handler 调用。\n6. ntyco 协程网络层：ntyco.c # 6.1 ntyco 在项目中的结构 # ntyco_start ↓ 创建 server 协程：socket → bind → listen → accept ↓ 每个客户端创建 server_reader 协程：recv → kvs_protocol → send 这里不展开 nty_coroutine_create 和 nty_schedule_run 的内部实现，只观察项目怎样调用框架。\n6.2 客户端处理协程 # /* ntyco_start 保存 main 传入的 kvs_protocol */ static msg_handler kvs_handler; void server_reader(void *arg) { /* server 创建协程时传入的是 cli_fd 地址 */ int fd = *(int *)arg; int ret = 0; while (1) { /* 每轮准备一个新的接收缓冲区 */ char buf[1024] = {0}; /* 接收这个客户端的请求 */ ret = recv(fd, buf, 1024, 0); if (ret \u0026gt; 0) { /* 为本轮响应准备缓冲区 */ char response[1024] = {0}; /* * buf + ret 是请求； * response 是 KV 输出； * slength 是响应长度。 */ int slength = kvs_handler(buf, ret, response); /* 将 KV 响应发送给当前客户端 */ ret = send(fd, response, slength, 0); if (ret == -1) { close(fd); break; } } else if (ret == 0) { /* 客户端正常关闭连接 */ close(fd); break; } } } 与 Reactor、Proactor 相比，协程版本把同一个客户端的处理过程写在一个顺序循环里：\nrecv(...); kvs_handler(...); send(...); 6.3 监听协程 # void server(void *arg) { /* 还原 ntyco_start 传入的端口 */ unsigned short port = *(unsigned short *)arg; /* 创建 IPv4 TCP 监听 socket */ int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd \u0026lt; 0) return; /* 填写本地监听地址 */ struct sockaddr_in local, remote; local.sin_family = AF_INET; local.sin_port = htons(port); local.sin_addr.s_addr = INADDR_ANY; /* 绑定并监听 */ bind(fd, (struct sockaddr *)\u0026amp;local, sizeof(struct sockaddr_in)); listen(fd, 20); printf(\u0026#34;listen port : %d\\n\u0026#34;, port); while (1) { socklen_t len = sizeof(struct sockaddr_in); /* 接受一个客户端连接 */ int cli_fd = accept( fd, (struct sockaddr *)\u0026amp;remote, \u0026amp;len ); /* * 每个客户端创建一个 server_reader 协程； * 该协程负责此后的 recv、KV 处理和 send。 */ nty_coroutine *read_co; nty_coroutine_create( \u0026amp;read_co, server_reader, \u0026amp;cli_fd ); } } 6.4 ntyco 网络层入口 # int ntyco_start(unsigned short port, msg_handler handler) { /* 保存 kvs_protocol */ kvs_handler = handler; /* 创建负责监听和 accept 的 server 协程 */ nty_coroutine *co = NULL; nty_coroutine_create(\u0026amp;co, server, \u0026amp;port); /* 启动 ntyco 调度器 */ nty_schedule_run(); } 把 ntyco 版本压缩成一句话：\nserver 协程负责产生连接，每个连接再由一个 reader 协程按顺序完成接收、协议处理和发送。\n7. 三种网络层放在一起回顾 # 7.1 同一阶段的代码对应关系 # 阶段 Reactor io_uring Proactor ntyco 保存 KV 函数 kvs_handler = handler kvs_handler = handler kvs_handler = handler 等待连接 监听 fd 的 EPOLLIN 提交 ACCEPT SQE server 协程调用 accept 获得连接 accept_cb() EVENT_ACCEPT CQE accept() 返回 等待请求 客户端 fd 的 EPOLLIN 提交 RECV SQE reader 协程调用 recv KV 接入点 kvs_request() EVENT_READ 分支 server_reader() 发送响应 send_cb() 提交 SEND SQE reader 协程调用 send 继续循环 改回 EPOLLIN WRITE 完成后再提交 RECV while 回到 recv 7.2 一次请求的三条完整路径 # Reactor # epoll_wait → EPOLLIN → recv_cb → conn.rbuffer / conn.rlength → kvs_request → kvs_protocol → conn.wbuffer / conn.wlength → EPOLLOUT → send_cb → EPOLLIN Proactor # 提交 RECV → READ CQE → buffer / entry-\u0026gt;res → kvs_protocol → response / 响应长度 → 提交 SEND → WRITE CQE → 再提交 RECV ntyco # server_reader 协程 → recv → buf / ret → kvs_protocol → response / slength → send → 下一轮 recv 7.3 两种回调不要混淆 # /* Reactor 内部的网络事件回调 */ typedef int (*RCALLBACK)(int fd); /* 三种网络层共同使用的 KV 协议回调 */ typedef int (*msg_handler)(char *msg, int length, char *response); 它们的层次是：\nReactor 中： epoll → RCALLBACK → recv_cb → msg_handler → kvs_protocol Proactor 中： CQE → EVENT_READ 分支 → msg_handler → kvs_protocol ntyco 中： server_reader → msg_handler → kvs_protocol 8. 最后的整体记忆 # 重新回顾这个项目时，只需要先恢复四个关键词：\nNETWORK_SELECT 决定使用 Reactor、Proactor 还是 ntyco msg_handler 把网络层与 KV 协议层连接起来 kvs_protocol 接收请求、生成响应，当前实现为 echo 网络事件循环 三种模型分别用 epoll、io_uring、协程完成连接和收发 整个程序的共同主干是：\n/* main 把协议函数交给网络层 */ network_start(port, kvs_protocol); /* 网络层保存函数地址 */ kvs_handler = handler; /* 网络层收到请求后调用它 */ response_len = kvs_handler( request, request_len, response ); /* 网络层按返回长度发送响应 */ send(clientfd, response, response_len, 0); 这四步就是六份代码共同围绕的核心。三种网络模型改变的是“请求怎样到达这四步、响应怎样离开这四步”，而 KV 协议入口保持一致。\n","date":"2026年8月22日","externalUrl":null,"permalink":"/MyBlog/p/kv-store-network-layer/","section":"Posts","summary":"","title":"KV 存储项目网络层：统一接入 Reactor、Proactor 与协程","type":"posts"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/MyBlog/tags/proactor/","section":"Tags","summary":"","title":"Proactor","type":"tags"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/MyBlog/tags/reactor/","section":"Tags","summary":"","title":"Reactor","type":"tags"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/MyBlog/tags/%E7%BD%91%E7%BB%9C%E7%BC%96%E7%A8%8B/","section":"Tags","summary":"","title":"网络编程","type":"tags"},{"content":"","date":"2026年8月22日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%8D%8F%E7%A8%8B/","section":"Tags","summary":"","title":"协程","type":"tags"},{"content":"","date":"2026年8月20日","externalUrl":null,"permalink":"/MyBlog/tags/epoll/","section":"Tags","summary":"","title":"Epoll","type":"tags"},{"content":" 一、学习目标 # 这部分学习主要围绕两个目标展开：\n编写一个 TCP 客户端测试工具，对比 epoll 和 io_uring 服务端的性能。 整理网络相关面试题，重点理解 TCP、UDP、建链、断链、并发及分包粘包问题。 二、QPS 测试工具需求 # 客户端需要支持以下参数：\n-n request_num 请求总数 -t thread_num 线程数量 -c connection_num 连接数量 -s server_ip 服务端 IP -p server_port 服务端端口 使用示例：\n./test_qps_tcpclient \\ -s 172.16.145.129 \\ -p 2048 \\ -t 50 \\ -c 100 \\ -n 10000 测试采用 Echo 模型：\n客户端向服务端发送一段数据。 服务端将数据原样返回。 客户端比较发送和接收的数据。 数据一致则请求成功，否则失败。 最后统计总耗时和 QPS。 QPS 的计算方式为：\nQPS = 完成的请求数 ÷ 耗时（秒） 代码中使用毫秒计时，因此写成：\nqps = request_num * 1000 / time_used_ms; 三、测试数据的构造 # 测试字符串为：\n#define TEST_MESSAGE \\ \u0026#34;ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890abcdefghijklmnopqrstuvwxyz\\r\\n\u0026#34; 这段字符串的长度正好是 64 字节：\n26 个大写字母 + 10 个数字 + 26 个小写字母 + \\r\\n 两个字符 = 64 字节 通过重复复制，可以构造不同大小的请求：\nfor (i = 0; i \u0026lt; 2; i++) { strcpy( wbuffer + i * strlen(TEST_MESSAGE), TEST_MESSAGE ); } 重复次数与数据长度的关系如下：\n重复次数 数据长度 1 64 字节 2 128 字节 4 256 字节 8 512 字节 16 1024 字节 测试大包时，需要关闭服务端和客户端的数据打印，否则终端输出本身会严重影响测试结果。\n四、64 bytes 测试 # 测试 100 万次、每次发送 64 bytes 时，结果如下：\n数据长度 服务端模型 成功 失败 耗时 QPS 64 bytes epoll 1000000 0 5132 ms 194855 64 bytes io_uring 1000000 0 4400 ms 227272 在 64 bytes 测试中：\nepoll QPS：194855 io_uring QPS：227272 io_uring 的 QPS 比 epoll 高约 16.6%。\n五、不同数据长度下的测试 # 1. io_uring 服务端 # 数据长度 成功 失败 耗时 QPS 128 bytes 1000000 0 4337 ms 230574 256 bytes 1000000 0 4268 ms 234301 512 bytes 1000000 0 4307 ms 232180 1024 bytes 1000000 0 4390 ms 227790 从结果来看，在 128～1024 bytes 范围内，io_uring 的 QPS 比较稳定，基本维持在 22 万～23 万。\n1024 bytes 测试时，笔记中怀疑可能出现“粘包”。\n更准确地说，TCP 本身是字节流，没有一条 send() 必须对应一条 recv() 的保证。一次发送的 1024 bytes，可能需要多次 recv() 才能完整读取，因此客户端必须实现循环接收。\n2. epoll 服务端——阻塞 I/O # 数据长度 成功 失败 耗时 QPS 128 bytes 1000000 0 74935 ms 13344 256 bytes 1000000 0 147749 ms 6768 512 bytes 1000000 0 296207 ms 3376 1024 bytes 1000000 0 721163 ms 1386 这组结果明显异常：数据长度每增加一倍，耗时几乎也增加一倍，QPS 则接近减半。\n问题在于 epoll 服务端使用了阻塞 socket。\nepoll 的作用是通知程序某个 fd 当前可读或者可写。但即使 fd 已经可读，也不代表接下来所有的 recv() 都不会阻塞。\n尤其是在循环读取时，如果已经把当前到达的数据读完，又调用了一次阻塞式 recv()，整个事件循环就可能停在某一个连接上，导致其他连接无法及时处理。\n所以 epoll 一般需要与非阻塞 socket 配合：\nepoll 通知 fd 可读 ↓ 循环调用 recv ↓ 读取当前已经到达的数据 ↓ recv 返回 EAGAIN ↓ 处理下一个就绪事件 3. epoll 服务端——非阻塞 I/O # 将 socket 设置成非阻塞后重新测试：\n数据长度 成功 失败 耗时 QPS 128 bytes 1000000 0 5198 ms 192381 256 bytes 1000000 0 5144 ms 194401 512 bytes 1000000 0 5141 ms 194514 1024 bytes 1000000 0 5216 ms 191717 修改后，epoll 的 QPS 恢复到约 19 万，并且不再随数据长度增加而大幅下降。\n这说明前一组数据反映的主要不是 epoll 本身的性能，而是“epoll 配合阻塞 I/O”造成的异常。\n六、epoll 与 io_uring 的完整对比 # 数据长度 io_uring QPS epoll QPS 64 bytes 227272 194855 128 bytes 230574 192381 256 bytes 234301 194401 512 bytes 232180 194514 1024 bytes 227790 191717 从测试结果来看：\n64 bytes 时，io_uring 比 epoll 高约 16.6%； 128～1024 bytes 时，io_uring 大约比 epoll 高 19%～21%； 所有测试包大小下，io_uring 的 QPS 都高于 epoll； 两种模型在改正 epoll 的阻塞 I/O 问题后，QPS 都没有随着数据大小增加而发生明显下降。 二者的处理思路不同。\nepoll # epoll 是就绪通知模型：\n先等待 fd 可读或可写 → 得到就绪事件 → 应用程序再调用 recv/send 也就是说，epoll 负责告诉应用程序“现在可以尝试进行 I/O”，真正的数据读写仍然由应用程序发起。\nio_uring # io_uring 更接近完成通知模型：\n应用程序先提交 recv/send 请求 → 内核执行 I/O → I/O 完成后通知应用程序 应用程序可以提前向内核提交 I/O 请求，然后通过完成队列取得结果。\n根据本次测试，可以得到以下结论：\n在当前服务端实现、当前客户端模型和当前测试环境下，io_uring 的 QPS 全面高于非阻塞 epoll，整体高约 17%～21%。\n这个结论应当限定在本次测试条件内，不能仅凭这一轮测试认为所有业务中 io_uring 都一定优于 epoll。\n七、业务中的数据包大小 # 实际业务中可能同时存在小包和大包。\n1. 心跳包 # 客户端和服务端之间通常会存在心跳包。\n心跳包只用于检测连接是否仍然存活，因此数据量通常很小，可能只有几个字节。\n这类场景更关注：\n大量连接的管理能力； 小包的处理效率； I/O 事件通知和调度开销； 服务端是否能够及时发现断开的连接。 2. 视频和图片传输 # 视频或者较大的图片需要被拆分成多个数据块。\n应用层可以将每一个业务包组织成约 1 KB，再通过协议头标识数据长度、类型和序号。\n这类场景除了 QPS，还需要关注：\n吞吐量； 单次发送的数据大小； 分包和组包； 内存复制； 慢连接对事件循环的影响。 因此，也可以结合不同数据大小下的测试结果，理解 epoll 和 io_uring 的差别。\n3. 百万并发测试 # 后续还可以使用 io_uring 和 epoll 测试百万并发，包括：\n建立相同数量连接所需要的时间； 相同并发连接数下的 QPS； 百万连接下的内存占用； 建链事件的处理能力； 断链事件的处理能力。 断开连接涉及 TCP 协议栈中的状态转换，但应用层仍然需要正确执行 close()、移除连接状态，并释放自己管理的连接资源。\n八、QPS 客户端代码详解 # 1. 测试上下文 # struct test_context_s { char serverip[16]; int port; int threadnum; int connection; int requestion; #if 1 int failed; #endif }; typedef struct test_context_s test_context_t; 这个结构保存测试参数：\nserverip：服务端 IPv4 地址； port：服务端端口； threadnum：测试线程数； connection：期望建立的连接数量； requestion：请求总数； failed：失败请求数。 requestion 想表达的应该是请求数量，也就是 request_count 或者 requests，只是命名不太准确，不影响程序运行。\nserverip 的长度是 16，刚好可以保存最长的 IPv4 字符串和字符串结尾的 \\0：\n255.255.255.255\\0 不过，代码使用的是：\nstrcpy(ctx.serverip, optarg); 如果传入的参数超过数组长度，就会发生越界写入。\n2. 建立 TCP 连接 # int connect_tcpserver( const char *ip, unsigned short port ) 函数首先创建 TCP socket：\nint connfd = socket(AF_INET, SOCK_STREAM, 0); 其中：\nAF_INET 表示使用 IPv4； SOCK_STREAM 表示使用 TCP； 返回值 connfd 是连接对应的文件描述符。 然后构造服务端地址：\nstruct sockaddr_in tcpserver_addr; memset( \u0026amp;tcpserver_addr, 0, sizeof(struct sockaddr_in) ); 清零后设置地址类型：\ntcpserver_addr.sin_family = AF_INET; 设置服务端 IP：\ntcpserver_addr.sin_addr.s_addr = inet_addr(ip); inet_addr() 将类似下面的 IPv4 字符串：\n172.16.145.129 转换成网络地址。\n设置端口：\ntcpserver_addr.sin_port = htons(port); htons() 将端口从主机字节序转换成网络字节序。\n最后调用：\nint ret = connect( connfd, (struct sockaddr *)\u0026amp;tcpserver_addr, sizeof(struct sockaddr_in) ); 如果连接成功，返回 connfd：\nreturn connfd; 如果连接失败，返回 -1：\nif (ret) { perror(\u0026#34;connect\\n\u0026#34;); return -1; } 当前代码没有检查 socket() 是否创建失败。\n另外，如果 connect() 失败，已经创建的 connfd 没有关闭，会造成文件描述符泄漏。\n3. 时间计算 # #define TIME_SUB_MS(tv1, tv2) \\ ((tv1.tv_sec - tv2.tv_sec) * 1000 + \\ (tv1.tv_usec - tv2.tv_usec) / 1000) 这个宏用来计算两个 timeval 之间相差的毫秒数：\n秒差 × 1000 + 微秒差 ÷ 1000 主函数在线程创建前记录开始时间：\nstruct timeval tv_begin; gettimeofday(\u0026amp;tv_begin, NULL); 所有线程结束后记录结束时间：\nstruct timeval tv_end; gettimeofday(\u0026amp;tv_end, NULL); 然后计算：\nint time_used = TIME_SUB_MS(tv_end, tv_begin); 因此，当前统计结果包含：\n创建线程的时间； 建立 TCP 连接的时间； 发送和接收请求的时间； 等待线程退出的时间。 所以当前 QPS 是一轮完整压测的整体 QPS，并不是纯粹的数据收发 QPS。\n如果后续需要分别测试建链性能和请求处理性能，就需要把建立连接和发送请求分开计时。\n4. 测试消息 # #define TEST_MESSAGE \\ \u0026#34;ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890abcdefghijklmnopqrstuvwxyz\\r\\n\u0026#34; #define RBUFFER_LENGTH 2048 #define WBUFFER_LENGTH 2048 TEST_MESSAGE 是 64 bytes。\n读缓冲区和写缓冲区都设置为 2048 bytes。\n在当前代码中，WBUFFER_LENGTH 宏并没有被使用，因为写缓冲区直接写成了：\nchar wbuffer[2048] = {0}; 5. 单次请求过程 # int send_recv_tcppkt(int fd) 这个函数完成一次请求和响应。\n构造发送数据 # char wbuffer[2048] = {0}; int i = 0; for (i = 0; i \u0026lt; 2; i++) { strcpy( wbuffer + i * strlen(TEST_MESSAGE), TEST_MESSAGE ); } 由于 TEST_MESSAGE 为 64 bytes，循环两次后，wbuffer 中保存了 128 bytes 数据。\n第一次复制的位置是：\nwbuffer + 0 第二次复制的位置是：\nwbuffer + 64 因此，两段 64 bytes 的数据首尾相连，组成 128 bytes 的请求。\n发送数据 # int res = send( fd, wbuffer, strlen(wbuffer), 0 ); 如果发送失败：\nif (res \u0026lt; 0) { exit(1); } 这里的主要问题是，一次 send() 不保证发送完全部数据。\n例如：\nsend(fd, buffer, 1024, 0); 返回值可能是：\n1024：全部发送完成 512：只发送了 512 bytes -1：发送失败 所以不能只判断 res \u0026lt; 0，还需要判断返回值是否等于预期发送长度。\n正确的逻辑应当是：\n记录总共需要发送的长度 ↓ 循环调用 send ↓ 每次根据返回值移动缓冲区指针 ↓ 直到所有字节发送完成 接收数据 # char rbuffer[RBUFFER_LENGTH] = {0}; res = recv( fd, rbuffer, RBUFFER_LENGTH, 0 ); 如果返回值小于或者等于 0：\nif (res \u0026lt;= 0) { exit(1); } recv() 的返回值含义是：\n大于 0：本次实际读取的字节数 等于 0：对端正常关闭连接 小于 0：读取失败 和 send() 一样，一次 recv() 也不保证读取到完整的响应。\n服务端即使完整返回了 1024 bytes，客户端也可能出现：\n第一次 recv：读取 300 bytes 第二次 recv：读取 500 bytes 第三次 recv：读取 224 bytes 因此，需要循环接收，直到读取到预期长度。\n这正是笔记中“很有必要重新封装一个 recv 和 send”的原因。\n比较数据 # if (strcmp(rbuffer, wbuffer) != 0) { printf( \u0026#34;failed: \u0026#39;%s\u0026#39;!=\u0026#39;%s\u0026#39;\\n\u0026#34;, rbuffer, wbuffer ); return -1; } 这里通过 strcmp() 比较接收数据和发送数据。\n但网络数据本质上是一段带长度的二进制数据，使用 strcmp() 的前提是两个缓冲区都以 \\0 结尾。\n更稳定的比较方式是根据实际数据长度使用：\nmemcmp(rbuffer, wbuffer, expected_length); 如果一次 recv() 只读到部分数据，那么当前 strcmp() 会直接认为请求失败，即使剩余数据稍后还能继续收到。\n6. 工作线程 # static void *test_qps_entry(void *arg) 线程首先取得测试上下文：\ntest_context_t *pctx = (test_context_t *)arg; 原始笔记中出现了：\n(test\\_context\\_t *)arg 如果实际代码中确实包含反斜杠，需要将反斜杠删除。它也可能只是复制代码时产生的 Markdown 转义。\n计算每个线程的连接数 # int conn_num = pctx-\u0026gt;connection / pctx-\u0026gt;threadnum; 这里理论上表示每个线程需要管理多少个连接。\n例如：\n连接数：100 线程数：50 每个线程应当管理：2 个连接 但是，conn_num 后面没有被使用。\n实际代码只建立了一次连接：\nint connfd = connect_tcpserver( pctx-\u0026gt;serverip, pctx-\u0026gt;port ); 因此，每个线程只建立一个连接。\n这意味着：\n-t 50 -c 100 实际建立的不是 100 个连接，而是 50 个连接。\n当前代码中的 -c 参数实际上没有生效。\n计算每个线程的请求数 # int count = pctx-\u0026gt;requestion / pctx-\u0026gt;threadnum; 例如：\n请求总数：10000 线程数：50 每个线程：200 次请求 每个线程执行：\nwhile (i++ \u0026lt; count) { res = send_recv_tcppkt(connfd); if (res != 0) { printf(\u0026#34;send_recv_tcppkt failed\\n\u0026#34;); pctx-\u0026gt;failed++; continue; } } 线程中的请求模型为：\n发送一次请求 ↓ 阻塞等待响应 ↓ 收到完整响应 ↓ 再发送下一次请求 所以每个连接同一时间最多只有一个未完成请求。\n整个客户端能够同时处理的请求数量，实际上接近线程数量，而不是 -c 指定的连接数量。\n请求数量不能整除线程数 # 如果：\n-n 10003 -t 50 则：\ncount = 10003 / 50; 整数除法结果为：\n200 实际发送请求数为：\n200 × 50 = 10000 剩余 3 次请求不会发送。\n但是，程序最后仍然按照 10003 计算成功数量和 QPS，所以统计结果会不准确。\n7. 失败请求统计 # 失败时执行：\npctx-\u0026gt;failed++; 所有线程共享同一个 failed，但这里没有加锁，也没有使用原子变量。\n多个线程可能同时执行：\npctx-\u0026gt;failed++; 例如，两个线程同时读到：\nfailed = 10 两个线程都计算得到 11，再分别写回，最终结果可能仍然是 11，而不是正确的 12。\n因此，当前失败数具有以下特点：\n可以粗略判断是否出现了失败； 不能保证失败数量准确； 多线程同时修改时存在数据竞争。 笔记中提到“数据没有那么重要，只需要知道有失败的可能性”，这种处理可以用于临时测试，但最终版本如果需要准确统计失败数，仍然需要使用锁或者原子操作。\n8. 命令行参数解析 # 主函数通过 getopt() 解析参数：\nwhile ( (opt = getopt( argc, argv, \u0026#34;s:p:t:c:n:?\u0026#34; )) != -1 ) 参数含义如下：\n-s：服务端 IP -p：服务端端口 -t：线程数量 -c：连接数量 -n：请求数量 例如：\ncase \u0026#39;s\u0026#39;: strcpy(ctx.serverip, optarg); break; 读取 IP。\ncase \u0026#39;p\u0026#39;: ctx.port = atoi(optarg); break; 读取端口。\ncase \u0026#39;t\u0026#39;: ctx.threadnum = atoi(optarg); break; 读取线程数量。\ncase \u0026#39;c\u0026#39;: ctx.connection = atoi(optarg); break; 读取连接数量。\ncase \u0026#39;n\u0026#39;: ctx.requestion = atoi(optarg); break; 读取请求总数。\ngetopt() 使用了一些全局状态，因此笔记中记录它不是线程安全函数。不过当前代码只在主线程、创建工作线程之前调用，所以不会产生并发调用问题。\n9. 创建工作线程 # 程序首先根据线程数量申请线程 ID 数组：\npthread_t *ptid = malloc( ctx.threadnum * sizeof(pthread_t) ); 然后记录开始时间：\nstruct timeval tv_begin; gettimeofday(\u0026amp;tv_begin, NULL); 创建工作线程：\nfor (i = 0; i \u0026lt; ctx.threadnum; i++) { pthread_create( \u0026amp;ptid[i], NULL, test_qps_entry, \u0026amp;ctx ); } 所有线程都接收同一个 ctx 地址：\n\u0026amp;ctx 其中：\nIP、端口、线程数、连接数和请求数只读； failed 会被所有线程共同修改。 创建完成后，主线程等待所有工作线程退出：\nfor (i = 0; i \u0026lt; ctx.threadnum; i++) { pthread_join(ptid[i], NULL); } 只有全部线程执行完毕，主线程才会继续计算总耗时。\n10. 统计 QPS # 所有线程结束后，记录结束时间：\nstruct timeval tv_end; gettimeofday(\u0026amp;tv_end, NULL); 计算总耗时：\nint time_used = TIME_SUB_MS(tv_end, tv_begin); 最后输出：\nprintf( \u0026#34;success: %d,failed:%d,\u0026#34; \u0026#34;time_used:%d,qps:%d\\n\u0026#34;, ctx.requestion - ctx.failed, ctx.failed, time_used, ctx.requestion * 1000 / time_used ); 输出内容包括：\n成功请求数； 失败请求数； 总耗时； QPS。 当前 QPS 使用的是：\nctx.requestion * 1000 / time_used 这里使用了配置的请求数，而不是实际完成的请求数。\n如果请求数不能整除线程数，或者中间出现失败，QPS 就不能准确表示真正成功完成的请求数量。\n11. 资源释放 # 主函数结束前释放线程数组：\nfree(ptid); 但工作线程建立的 TCP 连接没有关闭：\nclose(connfd); 在线程退出前，应当关闭自己创建的连接。\n虽然进程退出时，操作系统最终会回收文件描述符，但如果后续让一个进程连续执行多轮测试，不关闭连接就会逐渐产生资源泄漏。\n九、当前客户端的实际测试模型 # 虽然命令行参数中包含 -c，但当前代码的实际模型是：\n主线程 ├─ 工作线程 1 │ └─ 一个 TCP 连接 │ └─ 串行发送请求 │ ├─ 工作线程 2 │ └─ 一个 TCP 连接 │ └─ 串行发送请求 │ ├─ 工作线程 3 │ └─ 一个 TCP 连接 │ └─ 串行发送请求 │ └─ 其他工作线程 └─ 每个线程一个 TCP 连接 因此：\n实际连接数 = 线程数 每个连接同时只有一个请求 -c 参数没有生效 当前工具比较的是：\n多线程、每个线程使用一个阻塞 TCP 连接、请求与响应严格交替时，epoll 和 io_uring 服务端的处理能力。\n它还不能测试固定线程数管理大量连接的能力，也不能直接用于百万并发测试。\n如果需要让 -c 生效，每个线程就要创建：\nconnection / threadnum 个连接，并在这些连接之间分配请求。\n十、当前代码需要优先处理的问题 # 按照对测试准确性的影响排序：\n-c 参数没有生效，每个线程实际上只建立一个连接。 send() 没有循环处理短写。 recv() 没有循环处理短读。 请求数不能整除线程数时，实际请求数量少于 -n。 failed++ 存在多线程数据竞争。 工作线程结束后没有执行 close(connfd)。 QPS 使用配置的请求数计算，而不是实际完成数。 没有检查 socket()、malloc() 和 pthread_create() 的返回值。 connect() 失败后没有关闭已经创建的 socket。 工作线程中的 exit(1) 会让一个线程的网络错误终止整个压测进程。 如果源代码确实包含 test\\_context\\_t，需要删除反斜杠。 如果源代码第一行确实是 include\u0026lt;stdio.h\u0026gt;，需要补上 #。 十一、网络面试问题整理 # 原始笔记目前记录了四个核心问题，没有足够内容整理成十个，因此这里只整理已有内容，不额外补题。\n1. TCP 三次握手与四次挥手 # 三次握手 # 客户端 服务端 SYN ------------------------\u0026gt; SYN + ACK \u0026lt;------------------------ ACK ------------------------\u0026gt; 三次握手需要确认：\n客户端能够发送； 服务端能够接收； 服务端能够发送； 客户端能够接收； 双方同步初始序列号。 之所以不能只使用两次握手，是因为服务端发送 SYN + ACK 后，还需要知道客户端是否真正收到了自己的响应。\n第三次 ACK 让服务端确认：\n客户端已经收到服务端的 SYN 客户端也能够正常接收数据 2. 为什么建链是三次，断链通常是四次 # 建立连接时，服务端可以把两个操作合并：\n确认客户端的 SYN + 发送自己的 SYN 因此，服务端可以通过一个 SYN + ACK 同时完成这两个操作，最终形成三次握手。\n断开连接时：\n收到 FIN 后必须先回复 ACK； 但是本端可能还有数据没有发送完； 等剩余数据发送完成后，才能发送自己的 FIN。 因此，ACK 和 FIN 通常不能立即合并，形成四次挥手。\n四次挥手 # 主动关闭方 被动关闭方 FIN ------------------------\u0026gt; ACK \u0026lt;------------------------ FIN \u0026lt;------------------------ ACK ------------------------\u0026gt; TCP 是全双工连接，两个方向需要分别关闭。\n一方发送 FIN，只表示自己不再发送数据，并不表示对方也已经没有数据可发。\n如果对方收到 FIN 时，刚好也没有剩余数据需要发送，那么 ACK 和 FIN 也可能合并。\n3. UDP 的并发如何实现 # UDP 没有 TCP 中的：\nlisten accept connection fd 客户端通过：\nsendto() 向服务端指定的 IP 和端口发送数据。\n服务端通过：\nrecvfrom() 接收数据，同时得到客户端的 IP 和端口。\n假设有 100 万个客户端同时向同一个 UDP 端口发送数据，内核会按照数据报把数据放入 socket 的接收队列。\n服务端每次调用 recvfrom() 时，可以获得：\n一条 UDP 数据报； 发送方的 IP； 发送方的端口。 因此，不会因为多个客户端共用一个服务端端口，就直接把不同客户端的数据拼接成一条“脏数据”。\n应用层可以使用：\n客户端 IP + 客户端端口 标识一个逻辑会话。\n笔记中提出了一种模拟 TCP 的管理方式：\n客户端第一次向服务端发送握手包； 服务端得到客户端的 IP 和端口； 服务端为这个客户端建立逻辑会话； 后续通过会话信息管理双方的数据； 如果需要分散压力，可以使用多个服务端端口或者多个 UDP socket。 例如：\n2000 个端口 每个端口管理约 1000 个逻辑连接 总计约 200 万个逻辑连接 但是，不一定需要为每个 UDP 客户端单独分配一个端口。\nUDP 的并发数量也不是直接受 65535 个端口限制。多个客户端可以通过不同的来源 IP 和来源端口访问同一个服务端端口。\n这一部分需要继续整理成一篇独立的技术笔记：\nUDP 的并发实现与应用层会话管理。\n4. TCP 和 UDP 有哪些区别 # 连接方式 # TCP 是面向连接的协议。\n通信前需要建立连接：\n客户端连接 fd ←→ 服务端连接 fd 服务端通过 accept() 为每个客户端创建连接 fd，因此每条 TCP 连接都有独立的连接状态。\nUDP 是无连接协议。\n客户端不需要先建立 TCP 意义上的连接，可以直接通过 sendto() 发包，服务端通过 recvfrom() 接收。\n数据形式 # TCP 是字节流协议。\n它传输的是连续字节流，本身不保留应用层的消息边界。\nUDP 是数据报协议。\n每次 sendto() 发送一条 UDP 数据报，接收端通过 recvfrom() 接收一条数据报。UDP 会保留数据报边界，但如果接收缓冲区太小，超出的部分可能被截断。\n可靠性和顺序 # TCP 提供：\n有序传输； 丢包重传； 流量控制； 拥塞控制。 先发送的数据，在连接正常的情况下，会先交付给接收方应用程序。\nUDP 本身不保证：\n数据一定到达； 数据按照发送顺序到达； 数据不会重复； 丢失的数据会自动重传。 如果业务需要这些能力，就需要在 UDP 的应用层加入：\n包 ID； 数据序号； ACK 确认； 超时重传； 去重； 乱序重排。 例如，把一份大数据拆成多个 UDP 包时，可以在用户空间定义：\n消息 ID 分片序号 总分片数量 接收端根据 ID 和序号判断当前收到的是第几个包，并将多个数据包重新组装。\n5. TCP 的分包与粘包 # TCP 是字节流。\n应用层调用一次：\nsend(fd, buffer, 1024, 0); 并不代表接收端一定通过一次：\nrecv(fd, buffer, 1024, 0); 得到完整数据。\n可能出现：\n一次 send → 多次 recv 多次 send → 一次 recv 所以所谓的 TCP 分包和粘包，本质上是应用层没有明确消息边界。\n常见解决方法有两种。\n方法一：长度字段 # 在应用数据的头部记录消息体长度：\n| 固定长度的消息头 | 消息体 | 例如使用前两个或者前四个字节表示长度：\n| 2 bytes 长度 | 实际数据 | 接收端先读取固定长度的消息头：\n先循环读取 2 bytes ↓ 解析出消息体长度 ↓ 再循环 recv ↓ 读取完整消息体 由于 TCP 保证字节流有序，因此接收端可以按照这个顺序解析数据。\n方法二：分隔符 # 每条消息后面增加一个特殊分隔符，例如：\n\\r\\n\\r\\n 接收端不断读取数据并放入缓冲区，然后查找分隔符：\n收到数据 ↓ 加入接收缓冲区 ↓ 查找 \\r\\n\\r\\n ↓ 找到后切出一条完整消息 如果消息正文中也可能出现相同分隔符，就需要进行转义，或者使用长度字段。\n6. TCP 和 UDP 的并发区别 # TCP 服务端通常采用：\nlisten socket ↓ accept ↓ 每个客户端对应一个连接 fd ↓ 使用 epoll 管理大量连接 fd TCP 协议栈已经维护了每条连接的状态，因此应用层可以通过不同 fd 区分客户端。\nUDP 没有为每个客户端创建独立的连接 fd。\n服务端一般根据：\n来源 IP 来源端口 应用层会话 ID 管理客户端状态。\n因此，不是说 UDP 不能做高并发，而是 UDP 需要在应用层建立自己的会话管理方式。\n7. UDP 的使用场景 # UDP 更适合以下场景：\n实时音视频； 对实时性要求较高的游戏数据； DNS 等一次请求、一次响应的场景； 能够容忍少量丢包的场景； 由应用层自己控制可靠性的场景。 例如在游戏团战中，玩家位置、技能释放和实时状态对延迟比较敏感。\n如果一个旧的位置数据由于重传很晚才到达，这个数据可能已经失去意义。因此，这类实时状态通常更偏向使用 UDP。\n但是，“对战游戏没有使用 TCP”这个说法过于绝对。\n更合适的理解是：\n游戏中强调实时性的位置、动作等消息更倾向使用 UDP；登录、充值、聊天等要求可靠的数据仍然可能使用 TCP。\n8. TCP 的使用场景 # TCP 更适合：\n文件传输； 需要完整交付的数据； 需要严格有序的数据； HTTP/1.1、HTTP/2 等基于 TCP 的协议； 长连接通信。 文件下载需要保证最终文件完整，因此通常需要可靠传输。\nUDP 本身没有拥塞控制和可靠重传。如果使用 UDP 传输文件，应用层就需要自己实现：\n丢包检测； 重传； 顺序恢复； 流量控制； 拥塞控制。 TCP 已经提供了这些能力，因此使用 TCP 开发更加直接。\n9. 短通信与长连接 # DNS 是典型的短请求、短响应场景：\n发送一次查询 ↓ 服务端返回查询结果 ↓ 本次通信结束 UDP 不需要建立连接，适合这种简单的请求响应。\n“UDP 做短连接”更准确的说法是：\nUDP 没有 TCP 意义上的连接，适合短暂的一次性请求响应。\n有些业务也会选择 TCP 完成短请求，因为：\nTCP 相关框架成熟； 开发更加方便； 不需要自己实现可靠性； 业务本身更重视开发成本和正确性。 TCP 则天然适合需要长期保持通信状态的长连接，但 TCP 并不是只能做长连接。\n十二、阶段总结 # 从传统阻塞 I/O、epoll 再到 io_uring，容易产生一种混沌感。\n当前最需要捋清的是三个层次之间的关系：\nTCP 提供字节流、连接、可靠传输等协议能力 ↓ epoll / io_uring 解决应用程序如何高效处理 I/O ↓ 业务协议 解决消息边界、心跳、会话和错误处理 本轮实验可以得到三个明确认识：\nepoll 应当与非阻塞 I/O 配合，否则单个连接可能阻塞整个事件循环。 当前测试中，io_uring 的 QPS 全面高于非阻塞 epoll，整体优势约为 17%～21%。 客户端的连接模型、收发方式和计数准确性，会直接影响测试结果。当前 -c 参数没有生效，循环收发也尚未实现。 十三、面试复盘 # 面试需要不断复盘。\n简历中能够被问到的内容是有限的，每次面试后都应该记录问题，并重新整理问题背后的知识。\n原笔记中的经验数据为：\n面试时间 个人估计的通过概率 30 分钟 5% 45 分钟 30% 60 分钟 80% 求职过程可以通过两个比例进行观察：\n邀面数量 / 投递数量 → 反映简历的匹配度和成功度 Offer 数量 / 邀面数量 → 反映技术能力和面试表现 ","date":"2026年8月20日","externalUrl":null,"permalink":"/MyBlog/p/io-uring-qps-vs-epoll/","section":"Posts","summary":"","title":"io_uring（二）：QPS 实测，真的比 epoll 强吗？","type":"posts"},{"content":"","date":"2026年8月20日","externalUrl":null,"permalink":"/MyBlog/tags/iocp/","section":"Tags","summary":"","title":"IOCP","type":"tags"},{"content":" 一、网络编程要解决什么问题 # 在 C/S 模式中，服务端通常先执行：\nsocket -\u0026gt; bind -\u0026gt; listen 客户端也会先创建 socket。各种网络模型前面的准备过程基本相同，主要区别在后续如何处理连接和 I/O。\n网络编程中有四件无法预先确定的事情：\n客户端什么时候建立连接； 客户端什么时候断开连接； 客户端什么时候发送数据； 数据什么时候能够写入发送缓冲区。 因此，网络编程主要解决四类问题：\n连接的建立； 连接的断开； 数据的接收； 数据的发送。 每个 socket 都对应接收缓冲区（receive buffer）和发送缓冲区（send buffer）。write 只负责把数据从用户态复制到内核态的发送缓冲区；数据什么时候到达对端、如何到达对端，属于网络协议栈要解决的问题。\n常见的网络模型包括：\n阻塞 I/O 模型； Reactor； Proactor（IOCP）。 二、阻塞 I/O 与 Reactor # 一次 I/O 可以分成两个阶段：\nI/O 检测：判断 I/O 是否就绪； I/O 操作：真正执行 accept、read、write、connect 等操作。 1. 阻塞 I/O # 阻塞 I/O 通过阻塞当前线程来等待 I/O 就绪，从而解决“不知道事件什么时候发生”的问题。\n阻塞和非阻塞主要描述 I/O 检测阶段：\n阻塞：I/O 没有就绪时，线程会等待； 非阻塞：I/O 没有就绪时，函数立即返回。 2. Reactor # Reactor 使用：\nI/O 多路复用（检测 I/O）+ 非阻塞 I/O（操作 I/O） 它先注册事件。当事件触发时，系统发出就绪通知，用户层收到通知后，再调用 POSIX I/O 函数完成操作。因此，Reactor 属于：\n同步 I/O + 异步事件通知 select、poll、epoll 用于同时检测多路 I/O 是否就绪，它们只负责 I/O 检测；Reactor 则包含 I/O 检测和后续的 I/O 操作。\n所以它们的关系是：\nReactor = select/poll/epoll 等 I/O 多路复用 + 非阻塞 I/O 操作 三、IOCP 与 Reactor 的区别 # IOCP（I/O Completion Port，I/O 完成端口）是 Windows 提供的一种高效异步 I/O 机制。\nReactor 的过程：\n注册事件 ↓ 内核检测到 I/O 就绪 ↓ 用户层收到就绪通知 ↓ 用户层调用 accept/read/write 等函数完成 I/O 这是一种就绪通知。\nIOCP 的过程：\n用户层投递异步 I/O 请求 ↓ 内核检测并完成 I/O ↓ 用户层收到完成通知 因此：\nReactor：同步 I/O、异步事件； IOCP：异步 I/O、异步事件。 两者都是为了解决网络 I/O 问题，因此 IOCP 在整体概念上更接近 Reactor；但 IOCP 并不是 select、poll、epoll 这样的 I/O 多路复用机制。\n在 Reactor 中，即使 socket 已经就绪，真正的数据复制仍由用户线程调用 I/O 函数完成。IOCP 则由用户层发起异步操作，由内核完成 I/O；在此期间，用户线程可以处理其他任务。\n四、IOCP 的工作原理 # 网络 I/O 属于外设资源，相关数据需要通过系统调用，由内核进行操作。\nIOCP 的基本流程如下：\n使用 CreateIoCompletionPort 创建完成端口； 将 socket 与 IOCP 绑定； 投递异步 I/O 操作； 内核进行 I/O 检测和 I/O 操作； 操作完成后，内核把完成信息放入完成队列； 用户层通过 GetQueuedCompletionStatus 取得完成通知。 常见的异步操作包括：\nAcceptEx：接受连接； ConnectEx：主动建立连接； DisconnectEx：主动断开连接； WSARecv：接收数据； WSASend：发送数据。 例如建立连接时：\nsocket 绑定 IOCP ↓ 投递 AcceptEx ↓ 内核完成连接接收 ↓ GetQueuedCompletionStatus 取得完成通知 与传统 accept 不同，AcceptEx 是预先投递的异步请求。服务器可以在启动时投递多个 AcceptEx，从而并发接收多条连接。\nIOCP 与线程池 # IOCP 通常会绑定一个线程池。\n当线程调用 GetQueuedCompletionStatus 时，该线程会与 IOCP 产生关联。线程退出或关闭后，关联取消；一个线程只能关联一个 IOCP。\nGetQueuedCompletionStatus 是阻塞接口：\n等待完成队列出现数据 ↓ 取得完成状态 ↓ 在用户态处理完成事件 最后可以使用 CloseHandle 关闭 I/O 完成端口句柄。\n五、完成事件如何与连接关联 # 收到完成通知后，需要知道是哪一条连接、哪一次操作完成了。\n1. Reactor 中的关联方式 # 以 epoll 为例，注册事件时可以把一个值交给内核保存：\nev.data.fd = fd; 也可以关联用户态指针：\nev.data.ptr = user_data; epoll_wait 返回事件时会带回这个值，因此可以判断是哪一条连接发生了事件。\n2. IOCP 中的关联方式 # IOCP 有两种关联信息。\n第一种是 CreateIoCompletionPort 的第三个参数 CompletionKey。它可以保存一个用户态指针，并在其中记录 socket 等连接信息。\n第二种是异步函数最后传入的 OVERLAPPED 指针。可以把 OVERLAPPED 放进自定义结构体：\nstruct OverlappedPerIO { OVERLAPPED overlapped; SOCKET socket; // 其他与本次 I/O 相关的数据 }; 内核使用其中的 OVERLAPPED，用户层则可以在结构体中附加需要关联的数据。\n可以这样理解：\nCompletionKey：关联连接级信息； OVERLAPPED*：关联某一次 I/O 操作的信息。 六、重叠 I/O # 异步 I/O 是投递请求后无需原地等待，可以由其他线程等待完成通知。\n重叠 I/O 是指：无需等待上一个 I/O 完成，就可以继续投递下一个操作，让多个操作同时处于进行状态。\n例如，如果每次只投递一个 AcceptEx，完成一次后再投递下一次，那么客户端较多时，建立连接的效率会比较低。服务器可以在启动时投递多个 AcceptEx：\nAcceptEx 请求 1 ─┐ AcceptEx 请求 2 ─┼─→ 同时等待多个客户端连接 AcceptEx 请求 3 ─┘ 每个重叠 I/O 都使用一个独立的 OVERLAPPED 结构。操作完成后，GetQueuedCompletionStatus 会返回对应的 OVERLAPPED*，用户层据此找到这次操作的上下文。\n七、连接断开的判断 # 客户端主动断开连接时，可以通过以下方式判断。\nReactor # epoll_wait 返回 EPOLLHUP 或 EPOLLRDHUP； read / recv 返回 0。 IOCP # GetLastError 返回相关错误，例如异常断开时的 ERROR_NETNAME_DELETED； GetQueuedCompletionStatus 返回的完成字节数 dwBytes == 0。 超时情况下还可能出现 WAIT_TIMEOUT。\n八、投递操作 # AcceptEx 可以多次投递，以并发接收连接； WSARecv 涉及接收顺序和线程安全问题； WSASend 可以多次投递。 ","date":"2026年8月20日","externalUrl":null,"permalink":"/MyBlog/p/iocp-windows-async-io/","section":"Posts","summary":"","title":"IOCP：Windows 异步机制","type":"posts"},{"content":"","date":"2026年8月20日","externalUrl":null,"permalink":"/MyBlog/tags/linux/","section":"Tags","summary":"","title":"Linux","type":"tags"},{"content":"","date":"2026年8月20日","externalUrl":null,"permalink":"/MyBlog/tags/windows/","section":"Tags","summary":"","title":"Windows","type":"tags"},{"content":"","date":"2026年8月20日","externalUrl":null,"permalink":"/MyBlog/categories/%E5%90%8E%E7%AB%AF%E5%BC%80%E5%8F%91/","section":"Categories","summary":"","title":"后端开发","type":"categories"},{"content":"","date":"2026年8月20日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%BC%82%E6%AD%A5-io/","section":"Tags","summary":"","title":"异步 IO","type":"tags"},{"content":"核心目标：能从代码上说清楚 epoll 和 io_uring 的区别，而不是只记 API。\n1. 今天最重要的结论 # 一句话区分：\nReactor / epoll：内核通知“现在可以做 I/O 了”，真正的 accept/recv/send 由应用执行。 Proactor / io_uring：应用先提交 I/O 操作，内核完成后通知“这个操作已经做完了”。 最值得记住的一组对照：\nepoll 的 EPOLLIN = 可以读了，但数据还没被应用读出 io_uring 的 READ CQE = 读操作已结束，结果在 cqe-\u0026gt;res，数据已进入指定 buffer epoll 的 EPOLLOUT = 现在可以写，但应用还要调用 send/write io_uring 的 WRITE CQE = 写操作已结束，cqe-\u0026gt;res 表示实际写出的字节数 因此，二者虽然都有“注册/提交 → 等待 → 处理事件”的循环，但事件的含义完全不同：\nepoll 返回的是就绪事件。 io_uring 返回的是完成事件。 2. “异步”与“异步 I/O”不是一回事 # 2.1 一般意义上的异步 # 发起任务后，不原地等待它结束，当前执行流可以继续做别的事；任务完成后再通过回调、消息、事件或协程恢复等方式取得结果。\n例如：把日志写盘任务放入线程池。业务线程只投递任务，工作线程实际调用同步 write()。从业务流程看它是异步的，但底层 write() 本身仍可能是同步 I/O。\n2.2 同步 I/O # 普通的：\nread(fd, buffer, length); write(fd, buffer, length); recv(fd, buffer, length, 0); send(fd, buffer, length, 0); 一次函数调用既发起操作，也返回该次操作的结果。即使 fd 被设为非阻塞，调用者仍然要亲自执行 recv/send；“非阻塞”不自动等于“异步 I/O”。\n2.3 epoll 做到的是什么 # epoll_wait() 可以高效等待大量 fd 的就绪状态，避免逐个 fd 轮询。它通常与非阻塞 socket 配合，构成 Reactor。\n但 epoll 不替应用搬运数据：收到 EPOLLIN 后，代码仍要调用 recv()；收到 EPOLLOUT 后，仍要调用 send()。\n2.4 io_uring 做到的是什么 # 应用把“请替我执行 accept/read/write……”描述成 SQE，提交给内核；操作完成后，内核写入 CQE。应用处理 CQE 时，看到的是操作结果，而不只是“可以开始操作”。\n3. io_uring 的两个环 # 应用准备 SQE → SQ（Submission Queue）→ 内核执行 I/O ↓ 应用处理结果 ← CQ（Completion Queue）← 内核生成 CQE SQ / SQE # SQ：提交队列。 SQE：一次待执行 I/O 的描述，例如操作类型、fd、buffer、长度和用户数据。 io_uring_get_sqe()：取得一个可填写的 SQE。 io_uring_prep_accept/read/write(...)：只是在填写 SQE，不会立刻执行 I/O。 io_uring_submit()：把准备好的请求提交给内核。 CQ / CQE # CQ：完成队列。 CQE：一个已经完成的 I/O 结果。 cqe-\u0026gt;res：操作结果。成功时通常是新 fd 或字节数；失败时是负的 errno 值。 cqe-\u0026gt;user_data：应用提交时附带的标识，用来判断这是哪个连接、哪类操作。 io_uring_cq_advance()：告诉 ring，这批 CQE 已处理，可以回收。 为什么使用环形队列和 mmap # 环形队列可以复用固定槽位，减少频繁分配；SQ/CQ 通过 mmap 在用户态和内核态之间共享队列元数据，减少提交、取结果时的额外系统调用和元数据复制。\n但要注意：这不代表所有 I/O 数据都天然“零拷贝”。例如普通 socket 接收的数据通常仍需进入应用提供的 buffer。\n4. io_uring 三个底层系统调用 # io_uring_setup：创建并配置 ring，建立 SQ/CQ 所需资源。 io_uring_enter：提交请求和/或等待完成事件。 io_uring_register：预注册文件、buffer、事件等资源，减少热路径开销；它不是每个普通请求都必须调用。 课堂代码使用的是 liburing 封装：\nio_uring_queue_init_params(ENTRIES_LENGTH, \u0026amp;ring, \u0026amp;params); io_uring_get_sqe(\u0026amp;ring); io_uring_prep_accept(...); io_uring_submit(\u0026amp;ring); io_uring_wait_cqe(\u0026amp;ring, \u0026amp;cqe); 可近似理解为：初始化 ring → 取得请求槽位 → 描述操作 → 提交 → 等结果。\n5. 你的 io_uring Echo Server 怎么走 # 关键入口：\nset_event_accept(\u0026amp;ring,sockfd,(struct sockaddr*)\u0026amp;clientaddr,\u0026amp;len,0); set_event_accept() 做两件事：\n用 io_uring_get_sqe() 取得 SQE。 用 io_uring_prep_accept() 填写 accept 请求，并在 user_data 中记录 fd + EVENT_ACCEPT。 此时只是准备请求，并没有等待客户端。\n主循环：\nio_uring_submit ↓ 提交当前准备好的 accept/recv/send 请求 io_uring_wait_cqe ↓ 没有完成结果时在这里等待 io_uring_peek_batch_cqe ↓ 批量取得已完成操作 根据 user_data 判断 EVENT_ACCEPT ↓ 再次 set_event_accept，同时为新 clientfd 提交 recv ↓ io_uring_cq_advance 你测试时发现程序阻塞在 io_uring_wait_cqe()，这是合理的：\nio_uring_prep_accept() 只准备请求，很快返回。 io_uring_submit() 提交请求。 在客户端真正连接前，accept 尚未完成，CQ 中没有结果，所以 wait_cqe() 等待。 客户端连接后，CQE 到达，cqe-\u0026gt;res 应是新连接的 client fd（失败则为负数）。 5.1 三种事件不是“就绪事件”，而是操作身份 # #define EVENT_ACCEPT 0 #define EVENT_READ 1 #define EVENT_WRITE 2 这里的 EVENT_* 是应用自己定义的标签。准备 SQE 时，代码把 fd + event 写入 sqe-\u0026gt;user_data；操作完成后，再从 CQE 的 user_data 还原标签，据此判断完成的是 accept、recv 还是 send。\n三种 SQE 的含义：\n函数 提交给内核的操作 CQE 到达时 entries-\u0026gt;res 的含义 set_event_accept 接收一个新连接 新连接 fd；负数表示失败 set_event_recv 把数据读进指定 buffer 实际读取字节数；0 表示对端关闭；负数表示失败 set_event_send 发送指定 buffer 中的数据 实际发送字节数；负数表示失败 5.2 完整状态循环 # 启动：准备 ACCEPT SQE ↓ submit ACCEPT 完成 ├─ entries-\u0026gt;res 得到 connfd ├─ 再准备一个 ACCEPT，继续接收其他客户端 └─ 为 connfd 准备 RECV ↓ submit READ 完成 ├─ res == 0：对端断开，close(fd) └─ res \u0026gt; 0：buffer 中已有数据，准备 SEND ↓ submit WRITE 完成 ↓ 再为该 fd 准备 RECV 对单个连接而言，核心循环就是：\nRECV 完成 → SEND 完成 → 再次 RECV 这份代码没有额外解析数据，而是把收到的 buffer 原样发送回去，因此它是一个 io_uring Echo Server。\n5.3 为什么循环开头的 submit 能提交刚刚准备的操作 # 在处理 CQE 时调用的 set_event_accept/recv/send() 都只是取得并填写新的 SQE。它们不会立刻执行；本轮处理结束并 cq_advance 后，程序回到 while 开头，由下一次 io_uring_submit() 统一提交。\n因此时间顺序是：\n本轮处理 CQE → 准备下一批 SQE → advance → 下一轮 submit 这种批量准备、批量提交正是 io_uring 的重要思路。\n为什么处理后要再次设置 accept # 普通的 io_uring_prep_accept() 描述的是一次 accept 操作。完成一次就消费掉一次请求；若想继续接收连接，需要再准备并提交新的 accept。\n这与 epoll 的常规 LT 模式不同：监听 fd 注册 EPOLLIN 后，只要仍处于可读状态，epoll 可以继续报告就绪，无需每完成一次 accept 就重新 EPOLL_CTL_ADD。\n为什么必须 advance # CQE 被读取不等于被消费：\nio_uring_cq_advance(\u0026amp;ring,nready); 这一步更新 CQ 的消费位置。漏掉它，旧 CQE 会一直被当成尚未处理，表现为循环反复看到同一结果。\n6. 你的 Reactor 代码怎么走 # 6.1 初始化 # epoll_create ↓ 创建 20 个监听 socket（端口 2000～2019） ↓ 把监听 fd 以 EPOLLIN 加入 epoll ↓ 进入 epoll_wait 主循环 监听 fd 的回调被设置为 accept_cb：\nconn_list[sockfd].r_action.recv_callback = accept_cb; 6.2 建立连接 # 监听 fd 出现 EPOLLIN ↓ 主循环调用 accept_cb ↓ accept_cb 主动执行 accept() ↓ event_register(clientfd, EPOLLIN) 关键点：EPOLLIN 只表示监听 socket 可以执行 accept；真正建立并取出 client fd 的动作仍由 accept() 完成。\n6.3 接收数据 # clientfd 出现 EPOLLIN ↓ 调用 recv_cb ↓ recv() 把数据读进 rbuffer ↓ ws_request() 处理请求 ↓ 把关注事件修改为 EPOLLOUT 如果 recv() 返回 0，说明对端正常断开；返回负数则表示错误。当前代码随后关闭 fd 并从 epoll 删除。\n6.4 发送数据 # clientfd 出现 EPOLLOUT ↓ 调用 send_cb ↓ ws_response() 生成响应 ↓ send() 主动发送 wbuffer ↓ 把关注事件改回 EPOLLIN 整个连接状态近似为：\n等待读 EPOLLIN → recv + 处理请求 → 等待写 EPOLLOUT ↑ ↓ └──────── send + 改回读 ──────────┘ 7. 逐段对照：Reactor 与 io_uring # 阶段 epoll / Reactor io_uring / Proactor 思路 接收连接 注册监听 fd 的 EPOLLIN 提交一个 accept SQE 通知到达 “监听 fd 可以 accept” “accept 已完成” 新连接 fd 应用调用 accept() 得到 从 accept CQE 的 res 得到 接收数据 EPOLLIN 后应用调用 recv() 预先提交 recv/read SQE，完成后 buffer 已有数据 发送数据 EPOLLOUT 后应用调用 send() 预先提交 send/write SQE，完成后得到实际写入量 事件身份 events[i].data.fd + 回调表 cqe-\u0026gt;user_data 标识 fd/事件/上下文 一次操作结束 修改下一次关注的就绪事件 消费 CQE，再准备下一次 I/O SQE 批量处理 epoll_wait(..., events, 1024, ...) io_uring_peek_batch_cqe(..., cqes, 128) 代码上的一一对应：\nepoll_ctl ADD/MOD ↔ io_uring_get_sqe + prep_xxx epoll_wait ↔ io_uring_wait_cqe / peek_batch_cqe events[i].data.fd ↔ cqe-\u0026gt;user_data EPOLLIN 后 recv ↔ READ CQE 到达时数据已读完 EPOLLOUT 后 send ↔ WRITE CQE 到达时写操作已完成 下一轮 set_event ↔ 下一轮重新准备 SQE ↔ io_uring_cq_advance 消费完成项 8. 对学习笔记中三个说法的校正 # ① “SQE 与 CQE 是同一个节点，共用一块内存”——不准确 # SQE 和 CQE 是不同队列中的不同结构：SQE 描述请求，CQE 描述结果。它们通过 user_data 关联。共享内存指的是这些 ring 能被用户态与内核态共同访问，不是 SQE 原地变成 CQE。\n② “io_uring_prep_accept 底层调用 register”——不准确 # io_uring_prep_accept() 主要是填写 SQE。io_uring_register 是独立的资源注册系统调用，普通 accept 请求不要求每次 register。\n③ “mmap 后就没有 copy”——范围过大 # mmap 主要减少 SQ/CQ 控制信息在用户态和内核态之间的复制及系统调用开销；普通网络 I/O 的 payload 是否复制，是另一层问题。\n9. 最小记忆框架 # 只背下面四句话：\nepoll 给我的是就绪，我还要自己 accept/recv/send。 io_uring 给我的是完成，结果看 cqe-\u0026gt;res，身份看 cqe-\u0026gt;user_data。 io_uring 的路径是：取 SQE → prep → submit → 等 CQE → 处理 → advance。 普通 io_uring 请求通常是一次性的：完成一次，消费一次，再提交下一次。 ","date":"2026年8月19日","externalUrl":null,"permalink":"/MyBlog/p/io-uring-vs-epoll/","section":"Posts","summary":"","title":"io_uring（一）：与 epoll 一较高下","type":"posts"},{"content":"","date":"2026年8月18日","externalUrl":null,"permalink":"/MyBlog/tags/dpdk/","section":"Tags","summary":"","title":"DPDK","type":"tags"},{"content":"本篇只围绕一个问题展开：用户态 TCP 协议栈怎样让一个应用线程并发管理多条 TCP 连接？\n前三篇已经反复讲过 DPDK 收发、mbuf、Ethernet/IPv4/TCP 解析、三次握手、SEQ/ACK、TCP 状态机和 POSIX Socket。本篇只在它们影响 epoll 逻辑时简要回顾，不再重新逐层讲包头和握手细节。\n一、先给出整篇的核心答案 # TCP 并发 epoll 的实现可以浓缩成一个闭环：\n1. 每条 TCP 连接拥有独立的 stream/TCB 和用户态 fd 2. 应用通过 epoll_ctl 注册“我关心哪些 fd 的哪些事件” 3. 协议栈处理报文时发现某个 fd 已经可读或可写 4. 协议栈调用 epoll 回调，把该 fd 放入就绪队列 5. 回调唤醒阻塞在 epoll_wait 的应用线程 6. epoll_wait 返回就绪 fd，应用执行 accept/recv/send/close 7. 应用重新进入 epoll_wait，继续管理所有连接 epoll 没有让 TCP 报文并行到达，也没有替协议栈处理握手。它解决的是：\n当许多连接都可能产生数据时，应用怎样只处理当前已经就绪的连接，而不是为每条连接创建一个长期阻塞的线程。\n理解本篇时始终抓住三种对象：\n对象 解决的问题 ng_tcp_stream 这是谁的连接，当前 TCP 状态和数据是什么 epitem 应用是否关注这个 fd，它现在是否就绪 eventpoll 一个 epoll 实例管理的整集、就绪集和等待线程 二、只保留必要的旧知识：数据最终从哪里来到哪里去 # 完整程序有三个长期运行的执行流：\nmain/lcore：网卡 RX/TX ↕ 全局 in/out ring pkt_process/lcore：Ethernet/IP/TCP 解析、TCP 输出封包 ↕ 每连接 rcvbuf/sndbuf tcp_server_entry/lcore：accept、recv、send、epoll_wait 两层 ring 不要混淆：\n全局 ring-\u0026gt;in/out 传递 rte_mbuf *，用于网卡线程与协议处理线程之间传包。 每个 ng_tcp_stream 的 rcvbuf/sndbuf 传递 TCP fragment，用于协议栈与应用之间传数据。 一段客户端数据的最短路径是：\n网卡 RX → 全局 in ring → ng_tcp_process() → 按四元组找到 stream → payload 放入 stream-\u0026gt;rcvbuf → 触发 EPOLLIN → nepoll_wait() 返回 connfd → 应用 nrecv(connfd) 应用响应的路径相反：\nnsend(connfd) → fragment 放入 stream-\u0026gt;sndbuf → ng_tcp_out() 取出并封装 TCP 报文 → 全局 out ring → 网卡 TX 这些收发与报文构造细节在前几篇已有完整说明。本篇真正关心的是中间这一步：\nstream 的状态发生变化 → 怎样让正在等待的应用知道？ 三、支持并发的第一前提：连接必须彼此独立 # 1. 一条连接对应一个 ng_tcp_stream # struct ng_tcp_stream { int fd; uint32_t sip, dip; uint16_t sport, dport; uint8_t protocol; uint32_t snd_nxt; uint32_t rcv_nxt; NG_TCP_STATUS status; struct rte_ring *sndbuf; struct rte_ring *rcvbuf; pthread_cond_t cond; pthread_mutex_t mutex; }; 它是本代码中的简化 TCB。每个客户端都必须拥有独立的：\n地址与端口； TCP 状态； SEQ/ACK； 接收和发送缓冲区； 用户态 fd； 阻塞与唤醒状态。 因此，TCP 并发的根基不是 epoll，而是连接级状态隔离。如果两条连接共用序列号或接收缓冲区，换成 epoll 也不能让协议栈正确并发。\n2. 报文怎样找到自己的 stream # if (iter-\u0026gt;sip == sip \u0026amp;\u0026amp; iter-\u0026gt;dip == dip \u0026amp;\u0026amp; iter-\u0026gt;sport == sport \u0026amp;\u0026amp; iter-\u0026gt;dport == dport) { return iter; } 一般网络流用五元组标识；这里已经确定是 TCP，所以搜索时比较四元组。两个客户端连接同一个服务端地址和端口时，客户端 IP 或源端口不同，因此能找到不同的 stream。\n3. 监听 stream 和连接 stream # 服务端只有一个监听 stream，但可以有许多已连接 stream：\nlistenfd / LISTEN stream ├── connfd A / ESTABLISHED stream A ├── connfd B / ESTABLISHED stream B └── connfd C / ESTABLISHED stream C 收到 SYN 时，协议栈保留监听 stream，并创建一个新的连接 stream 处理该客户端：\nstruct ng_tcp_stream *syn = ng_tcp_stream_create( iphdr-\u0026gt;src_addr, iphdr-\u0026gt;dst_addr, tcphdr-\u0026gt;src_port, tcphdr-\u0026gt;dst_port); LL_ADD(syn, table-\u0026gt;tcb_set); syn-\u0026gt;status = NG_TCP_STATUS_SYN_RCVD; 监听对象负责“还能不能接新连接”，连接对象负责“这个客户端的数据和状态”。epoll 后面会分别监听这两类 fd。\n四、一连接一线程为什么能工作，又为什么需要 epoll # 1. 一连接一线程 # 最直观的并发方式是：\n主线程：不断 naccept() 连接 A：线程 A 阻塞在 nrecv(A) 连接 B：线程 B 阻塞在 nrecv(B) 连接 C：线程 C 阻塞在 nrecv(C) 它适合验证连接表、stream 隔离和收发缓冲区是否支持多个客户端。问题是大部分连接经常没有数据，线程却仍占用栈空间和调度资源。连接数增长时，线程数也同步增长。\n2. epoll 的改变 # epoll 模型下，应用线程不阻塞在某一个 nrecv(connfd) 上，而是阻塞在“所有已注册 fd 的就绪集合”上：\n应用线程：nepoll_wait(A, B, C, ...) 只有 B 有数据 → nepoll_wait 只返回 B → 应用处理 B → 再次等待所有连接 所以两种模型的区别不是 TCP 连接数，而是等待位置：\n一连接一线程：每个线程等待一个 fd epoll：一个线程等待一组 fd 五、为什么不能直接调用 Linux 原生 epoll # 代码中的 listenfd、connfd 和 epfd 由自己的位图分配：\nstatic int get_fd_frombitmap(void) { for (int fd = DEFAULT_FD_NUM; fd \u0026lt; MAX_FD_COUNT; fd++) { if (fd_table 中该位空闲) { 标记为已用; return fd; } } return -1; } 这些整数只在用户态协议栈内部有意义：\n用户态 fd → get_hostinfo_fromfd(fd) → ng_tcp_stream / localhost / eventpoll Linux 内核没有创建对应的 socket 对象，也不知道 stream-\u0026gt;rcvbuf 是否有数据。因此把这种 connfd 传给内核 epoll_ctl()，内核无法监听它。\n既然 Socket API 是用户态自己实现的，事件机制也要自己补齐：\nnsocket / nbind / nlisten / naccept / nrecv / nsend + nepoll_create / nepoll_ctl / nepoll_wait 六、理解 epoll 的关键：就绪是一种状态 # 应用关注的不是“是否来过一个包”，而是“现在执行某个操作会不会阻塞”。\n1. 监听 fd 可读 # 监听 fd 的 EPOLLIN 表示：\n至少有一条已完成握手的连接可以被 naccept() 取出 它不是说监听 socket 收到了应用 payload，而是说 accept 现在不会阻塞。\n2. 连接 fd 可读 # 连接 fd 的 EPOLLIN 表示：\nnrecv() 现在可以取得 payload，或者取得 EOF/关闭信息 因此，收到 FIN 也可以触发 EPOLLIN。应用随后调用 nrecv() 得到 0，知道对端已关闭。\n3. 连接 fd 可写 # EPOLLOUT 的一般含义是：发送缓冲区有空间，send 不会因为缓冲区已满而阻塞。它不等于“一个数据包已经从网卡发完”。\n本节代码重点实现的是 EPOLLIN 链路；要完整支持 EPOLLOUT，还需要定义 sndbuf 从满变为可用时怎样产生通知。\n这一点非常重要：\nepoll 事件是 Socket 操作条件的变化，不是对每个网络包做一次简单转发。\n七、epoll 的两个集合 # 一个 epoll 实例内部有两份不同用途的集合。\n1. 整集：我关注谁 # 整集保存所有通过 EPOLL_CTL_ADD 注册的 fd。代码用红黑树组织，以 sockfd 为 key：\n整集 = {listenfd, connfd_A, connfd_B, connfd_C, ...} 它回答：\n这个 fd 是否被注册？ 应用对它关心 EPOLLIN 还是 EPOLLOUT？ ADD/DEL/MOD 应该操作哪个节点？ 红黑树提供稳定的 O(log n) 查找、插入和删除，并能随注册数量按节点增长。哈希的平均查找更快，但要额外处理桶容量、冲突和扩容。这里选择红黑树的重点是动态集合与稳定性能，并不是红黑树在所有场景都绝对更快。\n2. 就绪集：现在谁能处理 # 就绪集是链表，只保存当前已经产生事件的节点：\n整集：{listenfd, A, B, C, D} 就绪集：{B, D} nepoll_wait() 无须扫描整棵红黑树，只需从就绪链表取出 B、D。\n3. 同一个节点同时属于两份集合 # struct epitem { RB_ENTRY(epitem) rbn; LIST_ENTRY(epitem) rdlink; int rdy; int sockfd; struct epoll_event event; }; 一个 epitem 中同时有红黑树节点和链表节点：\nrbn：长期挂在整集，表示注册关系 rdlink：就绪时临时挂在就绪链表 rdy：防止同一节点被重复插入就绪链表 节点进入就绪链表后仍保留在红黑树里。应用消费一次就绪事件，不等于取消对这个 fd 的关注；只有 EPOLL_CTL_DEL 才删除注册关系。\n4. eventpoll 管理整个 epoll 实例 # struct eventpoll { int fd; ep_rb_tree rbr; int rbcnt; LIST_HEAD(, epitem) rdlist; int rdnum; pthread_mutex_t mtx; pthread_spinlock_t lock; pthread_cond_t cond; pthread_mutex_t cdmtx; }; 关系可以画成：\nepfd └── eventpoll ├── rbr：全部已注册 epitem ├── rdlist：当前就绪 epitem └── cond：没有就绪事件时，让 nepoll_wait 睡眠 八、三个外部接口怎样组成应用侧逻辑 # 1. nepoll_create()：创建事件管理器 # 它做的事情不是创建 TCP 连接，而是创建一个 eventpoll：\n分配 epfd → 分配 eventpoll → 初始化红黑树 → 初始化就绪链表 → 初始化锁与条件变量 → 建立 epfd 到 eventpoll 的映射 应用以后通过 epfd 找到这一整套事件管理状态。\n2. nepoll_ctl()：维护整集 # ADD：树中不存在 → 分配 epitem → 插入红黑树 DEL：树中存在 → 从红黑树移除 → 释放 epitem MOD：树中存在 → 修改关注的事件 因此，nepoll_ctl() 只是在表达应用的兴趣：\n“如果这个 fd 以后出现我关心的状态，请通知我。”\n注册本身不会凭空制造事件。\n3. nepoll_wait()：没有事件就睡，有事件就返回 # while (ep-\u0026gt;rdnum == 0 \u0026amp;\u0026amp; timeout != 0) { if (timeout \u0026gt; 0) pthread_cond_timedwait(\u0026amp;ep-\u0026gt;cond, \u0026amp;ep-\u0026gt;cdmtx, ...); else if (timeout \u0026lt; 0) pthread_cond_wait(\u0026amp;ep-\u0026gt;cond, \u0026amp;ep-\u0026gt;cdmtx); } 超时语义：\ntimeout \u0026lt; 0：一直等待； timeout == 0：立即检查并返回； timeout \u0026gt; 0：最多等待指定时长。 醒来后，它从就绪链表取出最多 maxevents 个节点，把其中的 epoll_event 复制给应用：\nrdlist 中的 epitem → events[0...n-1] → 返回 nready 至此，epoll 只告诉应用“哪些 fd 可以处理”。真正的 accept/recv/send 仍由应用自己调用。\n九、最关键的内部接口：协议栈怎样触发事件 # 三个 nepoll_* 接口面向应用；epoll_event_callback() 面向协议栈：\nint epoll_event_callback(struct eventpoll *ep, int sockid, uint32_t event) { struct epitem *epi = RB_FIND(..., sockid); if (!epi) return -1; if (epi-\u0026gt;rdy) { epi-\u0026gt;event.events |= event; return 1; } pthread_spin_lock(\u0026amp;ep-\u0026gt;lock); epi-\u0026gt;rdy = 1; LIST_INSERT_HEAD(\u0026amp;ep-\u0026gt;rdlist, epi, rdlink); ep-\u0026gt;rdnum++; pthread_spin_unlock(\u0026amp;ep-\u0026gt;lock); pthread_mutex_lock(\u0026amp;ep-\u0026gt;cdmtx); pthread_cond_signal(\u0026amp;ep-\u0026gt;cond); pthread_mutex_unlock(\u0026amp;ep-\u0026gt;cdmtx); } 它完成四步：\n按 sockid 查整集 → 把事件记录到对应 epitem → 把 epitem 加入就绪链表 → signal 唤醒 nepoll_wait 如果节点已经在就绪链表中，就不重复插入，只合并事件位：\nepi-\u0026gt;event.events |= event; 这避免同一个 fd 连续收到多个通知时，在 rdlist 中出现多个相同节点。\nepoll 模块在整体架构中的位置 # 协议栈 epoll 应用 握手完成 ─┐ 收到数据 ─┼→ event_callback → rdlist → cond_signal → nepoll_wait 收到 FIN ─┘ ↓ accept/recv/close 所以 epoll 不是协议栈之外的独立轮询器。它必须由协议栈在状态真正改变的地方主动通知。\n十、用三条时间线彻底串起实现 # 时间线一：新客户端完成连接 # ① 客户端发送 SYN ② ng_tcp_process 找到 LISTEN stream ③ 为客户端创建新的 stream，进入 SYN_RCVD ④ 协议栈发送 SYN+ACK ⑤ 收到客户端 ACK，新 stream 进入 ESTABLISHED ⑥ 协议栈触发 listenfd 的 EPOLLIN ⑦ nepoll_wait 返回 listenfd ⑧ 应用调用 naccept(listenfd) ⑨ naccept 取出已建立 stream，为它分配 connfd ⑩ 应用用 EPOLL_CTL_ADD 把 connfd 加入 epoll 对应代码的关键通知：\nstream-\u0026gt;status = NG_TCP_STATUS_ESTABLISHED; struct ng_tcp_stream *listener = ng_tcp_stream_search(0, 0, 0, stream-\u0026gt;dport); epoll_event_callback(table-\u0026gt;ep, listener-\u0026gt;fd, EPOLLIN); 为什么通知 listener-\u0026gt;fd，而不是新连接的 fd？\n因为此时应用还没有执行 naccept()，新 stream 还没有分配给应用使用的 connfd。对应用来说，当前可执行的动作是 accept(listenfd)，所以就绪的是监听 fd。\n时间线二：已连接客户端发送 payload # ① 报文按四元组找到 established stream ② 协议栈复制 payload，构造 fragment ③ fragment 进入 stream-\u0026gt;rcvbuf ④ 协议栈触发 stream-\u0026gt;fd 的 EPOLLIN ⑤ callback 找到 connfd 对应的 epitem ⑥ epitem 进入 rdlist，唤醒 nepoll_wait ⑦ 应用得到 connfd ⑧ 应用调用 nrecv(connfd)，从该 stream 的 rcvbuf 取数据 关键顺序必须是：\n先让数据真正可读 → 再发布 EPOLLIN 否则应用被唤醒后可能发现 rcvbuf 仍为空。\n时间线三：客户端发送 FIN # ① 协议栈收到 FIN ② stream 进入 CLOSE_WAIT ③ rcvbuf 中放入长度为 0 的 fragment，表示 EOF ④ 触发 connfd 的 EPOLLIN ⑤ nepoll_wait 返回 connfd ⑥ nrecv(connfd) 返回 0 ⑦ 应用 EPOLL_CTL_DEL，然后 nclose(connfd) 这条时间线解释了为什么“关闭”也能通过可读事件交给应用：读取操作已经不会阻塞，只是结果为 EOF。\n十一、epoll 服务器循环现在应该怎样读 # int epfd = nepoll_create(1); ev.events = EPOLLIN; ev.data.fd = listenfd; nepoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, \u0026amp;ev); while (1) { int nready = nepoll_wait(epfd, events, 128, 5); for (int i = 0; i \u0026lt; nready; i++) { if (events[i].data.fd == listenfd) { int connfd = naccept(listenfd, ...); ev.events = EPOLLIN; ev.data.fd = connfd; nepoll_ctl(epfd, EPOLL_CTL_ADD, connfd, \u0026amp;ev); } else { int connfd = events[i].data.fd; int n = nrecv(connfd, buff, BUFFER_SIZE, 0); if (n \u0026gt; 0) { nsend(connfd, buff, n, 0); } else { nepoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL); nclose(connfd); } } } } 不要只把它看成一个 API 调用顺序。这个循环其实在处理两类状态机入口：\nlistenfd 就绪 → 连接管理路径 → accept 新连接并将 connfd 注册进 epoll connfd 就绪 → 数据路径或关闭路径 → recv 数据，或者识别 EOF 后移除连接 多个客户端的并发体现在：所有 connfd 都在同一棵红黑树中，但某一时刻只有真正就绪的连接进入 rdlist。\n十二、薄弱但非常重要：LT、ET 与“消费事件” # 理解整集和就绪集后，还必须区分两种事件语义。\n1. LT：Level Triggered，水平触发 # 只要可读条件仍然成立，就应该继续报告：\nrcvbuf 里还有数据 → fd 仍然可读 → 下一次 epoll_wait 仍应返回它 应用可以一次只读一部分，剩余数据会让 fd 继续保持就绪。\n2. ET：Edge Triggered，边沿触发 # 只在状态从“不可读”变为“可读”的边沿通知一次：\nrcvbuf：空 → 非空 → 触发一次 EPOLLIN 如果应用没有把数据一直读到 EAGAIN，而 rcvbuf 始终保持非空，就可能没有新的空→非空边沿，因此也不会再次收到通知。\n3. 当前学习代码的核心行为 # epoll_event_callback() 把节点加入 rdlist；nepoll_wait() 返回后将其移出并清除 rdy。以后再次发生协议栈回调时，节点才能重新加入。\n这更接近“协议栈事件通知队列”的实现骨架。要形成严格的 LT 或 ET，还需要把就绪谓词定义清楚：\n监听 fd 可读：accept 队列非空 连接 fd 可读：rcvbuf 非空，或存在 EOF/错误 连接 fd 可写：sndbuf 有可用空间 然后选择策略：\nLT：epoll_wait 返回事件后，如果谓词仍为真，应保留或重新加入就绪集。 ET：只有谓词从假变真时才入就绪集，应用必须把资源处理到再次变为假。 这部分是自实现 epoll 最容易遗漏的核心：就绪链表只是结果，真正决定事件语义的是状态谓词和重新入队规则。\n十三、线程同步：为什么需要三种同步手段 # 代码中不同锁保护不同对象：\n同步对象 主要保护内容 ep-\u0026gt;mtx 红黑树的 ADD/DEL/MOD ep-\u0026gt;lock 自旋锁 短时间修改 rdlist、rdnum、rdy ep-\u0026gt;cdmtx + cond nepoll_wait 睡眠与协议栈唤醒 1. 为什么等待必须检查条件 # 正确思路不是“收到 signal 就相信一定有事件”，而是：\nwhile (ep-\u0026gt;rdnum == 0) pthread_cond_wait(...); 条件变量可能虚假唤醒，也可能多个等待者竞争同一批事件。醒来后必须重新检查 rdnum。\n同理，nrecv() 等待数据时也使用循环检查 rcvbuf，而不是单次 if。\n2. 为什么先入就绪集，再 signal # 发布顺序是：\n修改受保护状态：epitem 进入 rdlist，rdnum++ → 再 signal signal 的作用只是提醒等待线程重新检查条件；真正可信的是受锁保护的 rdlist/rdnum。\n3. DEL 与回调之间的关系 # 应用关闭连接时会删除 epitem，协议栈线程则可能正在为同一 fd 触发回调。完整实现必须让红黑树查找、节点删除和就绪链表访问遵守一致的生命周期规则，避免回调继续访问已经释放的节点。\n这里不展开代码审查，只需记住：并发数据结构除了“查得快”，还要保证节点从注册、就绪到删除期间始终有效。\n十四、单 epoll 与多个 epoll # 当前代码在创建时执行：\nstruct ng_tcp_table *table = tcpInstance(); table-\u0026gt;ep = ep; 协议栈触发事件时也直接使用：\nepoll_event_callback(table-\u0026gt;ep, stream-\u0026gt;fd, EPOLLIN); 这意味着当前教学实现默认整个 TCP table 只有一个 epoll。它足以展示完整通知链路，但还不能表达：\n同一进程创建多个 epoll 实例； 不同 epoll 注册不同连接； 同一个 fd 同时被多个 epoll 关注。 扩展为多个 epoll 时，不能只保留一个 table-\u0026gt;ep。需要建立：\nepfd → eventpoll 以及 sockfd → 所有注册了该 sockfd 的 epitem/eventpoll 协议栈产生事件后，要找到所有相关订阅者并分别更新它们的就绪集。这实际上把单指针通知扩展成一对多的观察者关系。\n十五、多连接 core dump 在本篇中的位置 # 多连接时暴露 core dump，说明“单连接路径成立”并不等于“所有连接级资源都已正确隔离”。本节笔记直接涉及的一点是：每条 stream 都会创建自己的 DPDK ring，而 DPDK 命名对象需要可区分的名称。\nsprintf(sbufname, \u0026#34;sndbuf%x%d\u0026#34;, sip, sport); stream-\u0026gt;sndbuf = rte_ring_create(sbufname, ...); sprintf(rbufname, \u0026#34;bufname%x%d\u0026#34;, sip, sport); stream-\u0026gt;rcvbuf = rte_ring_create(rbufname, ...); 这里用客户端 IP 和源端口区分连接 ring。要记住三层不同的身份：\n四/五元组：让协议栈把报文分给正确 stream DPDK ring 名称：让每条 stream 获得独立缓冲对象 用户态 fd：让应用和 epoll 找到正确 stream 本篇不继续展开具体崩溃排查，因为重点是 epoll 的事件闭环。\n十六、把全部实现压缩成一张图 # 应用线程 │ nepoll_ctl ADD │ 注册兴趣 ▼ ┌───────────────┐ │ eventpoll │ │ │ │ 红黑树 rbr │ ← 全部注册 fd │ 就绪表 rdlist │ ← 当前就绪 fd │ cond │ ← 没事件时睡眠 └───────┬───────┘ │ nepoll_wait 返回 ▼ accept / recv / send / close ▲ │ 用户态 fd → stream ┌───────┴────────┐ │ ng_tcp_stream │ │ 状态/SEQ/ACK │ │ rcvbuf/sndbuf │ └───────▲────────┘ │ ng_tcp_process 按四元组定位 │ 握手完成 / payload 到达 / FIN 到达 │ epoll_event_callback │ 加入 rdlist + signal 从这张图可以看到，TCP 并发 epoll 不是单独一个数据结构，而是四套映射协作：\n报文四元组 → stream 用户态 fd → stream epfd → eventpoll eventpoll 中 sockfd → epitem 缺少任何一层，事件都无法从报文准确传递到应用。\n十七、最终应掌握的实现逻辑 # 多连接首先要求每条连接拥有独立 TCB、状态、序列号和收发缓冲区。 报文通过四元组找到 stream，应用通过用户态 fd 找到同一个 stream。 一连接一线程让每个线程阻塞等一个 fd；epoll 让一个线程等待一组 fd。 用户态 fd 不属于内核，因此必须自实现 epoll，不能直接交给 Linux epoll。 红黑树保存“关注谁”，就绪链表保存“现在谁能处理”。 nepoll_ctl() 建立兴趣关系，epoll_event_callback() 发布事件，nepoll_wait() 消费事件。 握手完成触发监听 fd 的 EPOLLIN，因为此时 accept 不再阻塞。 payload 或 FIN 到达触发连接 fd 的 EPOLLIN，因为此时 recv 能返回数据或 EOF。 必须先改变真实就绪状态，再把事件加入就绪集并唤醒等待线程。 LT/ET 的本质不在链表名字，而在就绪谓词以及事件消费后的重新入队规则。 单 epoll 可以用 tcp_table-\u0026gt;ep；多个 epoll 需要 fd 到多个订阅者的映射。 epoll 解决应用调度问题，不替代 TCP 状态机，也不能弥补连接状态未隔离的问题。 十八、反刍题 # 为什么三次握手完成时触发 listenfd，而不是新连接 fd？ 为什么 FIN 可以通过 EPOLLIN 通知？ 红黑树和就绪链表各自回答什么问题？ 为什么一个 epitem 要同时包含 RB_ENTRY 和 LIST_ENTRY？ nepoll_ctl() 注册了一个 fd 后，为什么它不一定立刻出现在就绪链表？ 从 payload 到达到 nepoll_wait() 返回，中间依次发生了什么？ 如果 rcvbuf 仍有未读数据，LT 和 ET 下一步分别应该怎样表现？ 为什么条件变量的等待必须放在 while 中？ 为什么发布事件时要先更新 rdlist/rdnum，再调用 signal？ 当前的 tcp_table-\u0026gt;ep 为什么只能自然表达单 epoll？ 如果一个 connfd 被两个 epoll 实例注册，协议栈该怎样分发一次 EPOLLIN？ epoll 已经实现正确，但不同连接仍共用 rcvbuf，并发为什么仍然会失败？ ","date":"2026年8月18日","externalUrl":null,"permalink":"/MyBlog/p/dpdk-userspace-tcp-concurrency-epoll/","section":"Posts","summary":"","title":"DPDK 用户态 TCP 协议栈（四）：并发与自实现 epoll","type":"posts"},{"content":"","date":"2026年8月18日","externalUrl":null,"permalink":"/MyBlog/tags/%E7%94%A8%E6%88%B7%E6%80%81%E5%8D%8F%E8%AE%AE%E6%A0%88/","section":"Tags","summary":"","title":"用户态协议栈","type":"tags"},{"content":"系列前文：\nDPDK 用户态协议栈设计与实现 DPDK 用户态协议栈（二）：从收包到 UDP Echo 与 TCP 握手 前两篇已经讲过 DPDK 的收发路径、mbuf、Ethernet/IPv4/UDP/TCP Header、UDP Echo 和基础 TCP 握手。本篇直接沿着当前代码的执行顺序复习 TCP，并从代码中的 flags、seq、ack、rx_win、tcp_status 延伸到滑动窗口、延迟确认、重传、慢启动和拥塞控制。\n一、先看当前代码的 TCP 主线 # 当前程序的 TCP 数据路径可以概括为：\nrte_eth_rx_burst() 从 RX Queue 取包 ↓ 解析 Ethernet Header ↓ 解析 IPv4 Header ↓ next_proto_id == IPPROTO_TCP ↓ 解析 TCP Header ↓ 保存源/目的 MAC、IP、端口 ↓ 读取 flags、seq、ack ↓ 根据 tcp_status 处理 SYN、ACK、PSH ↓ 必要时构造 TCP 响应并通过 TX Queue 发出 代码中与 TCP 直接相关的全局信息是：\nuint8_t global_flags; uint32_t global_seqnum; uint32_t global_acknum; 它们分别保存当前收到的 TCP 报文中的：\nglobal_flags ← TCP 控制位 global_seqnum ← 对方本次发送的 SEQ global_acknum ← 对方本次携带的 ACK 状态机由下面的枚举描述：\ntypedef enum __USTACK_TCP_STATUS { USTACK_TCP_STATUS_CLOSED = 0, USTACK_TCP_STATUS_LISTEN, USTACK_TCP_STATUS_SYN_RCVD, USTACK_TCP_STATUS_SYN_SENT, USTACK_TCP_STATUS_ESTABLISHED, USTACK_TCP_STATUS_FIN_WAIT_1, USTACK_TCP_STATUS_FIN_WAIT_2, USTACK_TCP_STATUS_CLOSING, USTACK_TCP_STATUS_TIMEWAIT, USTACK_TCP_STATUS_CLOSE_WAIT, USTACK_TCP_STATUS_LAST_ACK } USTACK_TCP_STATUS; uint8_t tcp_status = USTACK_TCP_STATUS_LISTEN; 当前代码主要走的是服务端路径：\nLISTEN ↓ 收到 SYN SYN_RCVD ↓ 收到第三次握手 ACK ESTABLISHED ↓ 接收 TCP 数据 二、代码怎样识别并取得 TCP 报文 # 主循环先通过 DPDK 批量收包：\nstruct rte_mbuf *mbufs[BURST_SIZE] = {0}; uint16_t num_recvd = rte_eth_rx_burst( global_portid, 0, mbufs, BURST_SIZE ); num_recvd 表示本次从 RX Queue 中取出的报文数量。程序随后遍历每一个 mbuf：\nfor (i = 0; i \u0026lt; num_recvd; i++) { struct rte_ether_hdr *ethhdr = rte_pktmbuf_mtod( mbufs[i], struct rte_ether_hdr * ); 先判断它是不是 IPv4：\nif (ethhdr-\u0026gt;ether_type != rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)) { continue; } 再取得 IPv4 Header：\nstruct rte_ipv4_hdr *iphdr = rte_pktmbuf_mtod_offset( mbufs[i], struct rte_ipv4_hdr *, sizeof(struct rte_ether_hdr) ); 最后根据 IPv4 Header 中的协议号进入 TCP 分支：\nif (iphdr-\u0026gt;next_proto_id == IPPROTO_TCP) { struct rte_tcp_hdr *tcphdr = (struct rte_tcp_hdr *)(iphdr + 1); } 在当前实验中，IPv4 Header 按基础 20 字节处理，所以使用 (iphdr + 1) 到达 TCP Header。进一步扩展时，可以根据 IHL 取得真实的 IPv4 Header 长度：\nuint16_t ip_hdr_len = (iphdr-\u0026gt;version_ihl \u0026amp; 0x0f) * 4; struct rte_tcp_hdr *tcphdr = (struct rte_tcp_hdr *) ((uint8_t *)iphdr + ip_hdr_len); 这里的层次关系是：\nEthernet Header IPv4 Header TCP Header TCP Payload 三、为什么代码要把地址和端口反过来保存 # 收到客户端发来的 TCP 包后，服务端回包的方向与收包方向正好相反。\n1. MAC 地址交换 # rte_memcpy( global_smac, ethhdr-\u0026gt;d_addr.addr_bytes, RTE_ETHER_ADDR_LEN ); rte_memcpy( global_dmac, ethhdr-\u0026gt;s_addr.addr_bytes, RTE_ETHER_ADDR_LEN ); 收到包时：\n源 MAC = 客户端 MAC 目的 MAC = 服务端 MAC 回包时：\n源 MAC = 服务端 MAC 目的 MAC = 客户端 MAC 所以把收到包的目的 MAC 保存为回包源 MAC，把收到包的源 MAC 保存为回包目的 MAC。\n2. IP 地址交换 # rte_memcpy(\u0026amp;global_sip, \u0026amp;iphdr-\u0026gt;dst_addr, sizeof(uint32_t)); rte_memcpy(\u0026amp;global_dip, \u0026amp;iphdr-\u0026gt;src_addr, sizeof(uint32_t)); 3. TCP 端口交换 # rte_memcpy(\u0026amp;global_sport, \u0026amp;tcphdr-\u0026gt;dst_port, sizeof(uint16_t)); rte_memcpy(\u0026amp;global_dport, \u0026amp;tcphdr-\u0026gt;src_port, sizeof(uint16_t)); 最终得到一组回包方向的数据：\nglobal_smac 服务端 MAC global_dmac 客户端 MAC global_sip 服务端 IP global_dip 客户端 IP global_sport 服务端端口 global_dport 客户端端口 TCP 用下面的四元组区分连接：\n源 IP、源端口、目的 IP、目的端口 对于服务端而言，一般写成：\nlocal_ip、local_port、remote_ip、remote_port 四、代码怎样读取 Flags、SEQ 和 ACK # global_flags = tcphdr-\u0026gt;tcp_flags; global_seqnum = ntohl(tcphdr-\u0026gt;sent_seq); global_acknum = ntohl(tcphdr-\u0026gt;recv_ack); 1. Flags # tcp_flags 是一个字节，其中每一位代表一个控制标志：\nSYN 建立连接并同步初始序列号 ACK ACK 字段有效 PSH 希望接收端尽快把数据交给应用 FIN 本方向没有更多数据需要发送 RST 复位连接 URG Urgent Pointer 有效 一个报文可以同时带多个标志，例如：\nSYN | ACK PSH | ACK FIN | ACK 所以代码使用按位与判断某一位是否存在：\nif (global_flags \u0026amp; RTE_TCP_SYN_FLAG) { /* 包含 SYN */ } 2. SEQ 与 ACK 是 32 位字段 # global_seqnum = ntohl(tcphdr-\u0026gt;sent_seq); global_acknum = ntohl(tcphdr-\u0026gt;recv_ack); 报文使用网络字节序，程序进行加减和比较时使用主机字节序，因此读取时调用 ntohl()。\n回包时方向相反：\ntcp-\u0026gt;sent_seq = htonl(seq); tcp-\u0026gt;recv_ack = htonl(ack); 可以记成：\n网络报文 → 主机整数：ntohs / ntohl 主机整数 → 网络报文：htons / htonl 其中：\n16 位：端口、Window、部分长度字段 32 位：IPv4 地址、SEQ、ACK 五、结合 ustack_encode_tcp_pkt() 看 SYN+ACK 的构造 # 当前编码函数依次组织 Ethernet、IPv4 和 TCP Header。\n1. Ethernet Header # struct rte_ether_hdr *eth = (struct rte_ether_hdr *)msg; rte_memcpy( eth-\u0026gt;d_addr.addr_bytes, global_dmac, RTE_ETHER_ADDR_LEN ); rte_memcpy( eth-\u0026gt;s_addr.addr_bytes, global_smac, RTE_ETHER_ADDR_LEN ); eth-\u0026gt;ether_type = htons(RTE_ETHER_TYPE_IPV4); 这一层说明后面的载荷是 IPv4。\n2. IPv4 Header # struct rte_ipv4_hdr *ip = (struct rte_ipv4_hdr *)(eth + 1); ip-\u0026gt;version_ihl = 0x45; ip-\u0026gt;type_of_service = 0; ip-\u0026gt;total_length = htons( total_len - sizeof(struct rte_ether_hdr) ); ip-\u0026gt;packet_id = 0; ip-\u0026gt;fragment_offset = 0; ip-\u0026gt;time_to_live = 64; ip-\u0026gt;next_proto_id = IPPROTO_TCP; ip-\u0026gt;src_addr = global_sip; ip-\u0026gt;dst_addr = global_dip; ip-\u0026gt;hdr_checksum = 0; ip-\u0026gt;hdr_checksum = rte_ipv4_cksum(ip); total_len 是整个 Ethernet Frame 中由程序填写的长度。IPv4 的 total_length 不包括 Ethernet Header，所以要减去：\nsizeof(struct rte_ether_hdr) 3. TCP Header # struct rte_tcp_hdr *tcp = (struct rte_tcp_hdr *)(ip + 1); tcp-\u0026gt;src_port = global_sport; tcp-\u0026gt;dst_port = global_dport; tcp-\u0026gt;sent_seq = htonl(12345); tcp-\u0026gt;recv_ack = htonl(global_seqnum + 1); tcp-\u0026gt;data_off = 0x50; tcp-\u0026gt;tcp_flags = RTE_TCP_SYN_FLAG | RTE_TCP_ACK_FLAG; tcp-\u0026gt;rx_win = htons(TCP_INIT_WINDOWS); tcp-\u0026gt;cksum = 0; tcp-\u0026gt;cksum = rte_ipv4_udptcp_cksum(ip, tcp); 这几行正好对应三次握手第二步：\n服务端自己的 SEQ = 12345 确认客户端 SYN 的 ACK = 客户端 SEQ + 1 Flags = SYN | ACK data_off = 0x50 的高 4 位是 5，表示 TCP Header 长度为：\n5 × 4 字节 = 20 字节 本例没有 TCP Options，所以是最基础的 20 字节 TCP Header。\nrx_win 是服务端通告给客户端的接收窗口。它是 16 位字段，写入报文时使用 htons()。\n六、结合状态机完整理解三次握手 # 假设：\n客户端初始序列号 client_isn = 1234 服务端初始序列号 server_isn = 12345 第一次握手：客户端发送 SYN # 客户端 → 服务端 SYN=1 SEQ=1234 代码在 TCP 分支中读取到：\nglobal_flags = RTE_TCP_SYN_FLAG; global_seqnum = 1234; 随后进入：\nif (global_flags \u0026amp; RTE_TCP_SYN_FLAG) { if (tcp_status == USTACK_TCP_STATUS_LISTEN) { /* 构造 SYN+ACK */ } } 第二次握手：服务端回复 SYN+ACK # 服务端 → 客户端 SYN=1 ACK=1 SEQ=12345 ACK number=1235 代码中的对应关系：\ntcp-\u0026gt;sent_seq = htonl(12345); tcp-\u0026gt;recv_ack = htonl(global_seqnum + 1); tcp-\u0026gt;tcp_flags = RTE_TCP_SYN_FLAG | RTE_TCP_ACK_FLAG; 发送后进入：\ntcp_status = USTACK_TCP_STATUS_SYN_RCVD; 表示服务端已经发送 SYN+ACK，正在等待客户端确认自己的 SYN。\n第三次握手：客户端回复 ACK # 客户端 → 服务端 ACK=1 SEQ=1235 ACK number=12346 客户端的 SEQ 变成 1235，是因为它的 SYN 占用了一个序列号。\n客户端的 ACK 是 12346，是因为服务端的 SYN 也占用了一个序列号。\n代码收到 ACK 后：\nif (global_flags \u0026amp; RTE_TCP_ACK_FLAG) { if (tcp_status == USTACK_TCP_STATUS_SYN_RCVD) { printf(\u0026#34;enter established\\n\u0026#34;); tcp_status = USTACK_TCP_STATUS_ESTABLISHED; } } 于是状态变化为：\nLISTEN ↓ 收到 SYN SYN_RCVD ↓ 收到确认服务端 SYN 的 ACK ESTABLISHED 在连接数据结构中，可以保存：\niss = 12345 服务端初始发送序列号 irs = 1234 客户端初始发送序列号 snd_una = 12346 服务端最早未确认序号 snd_nxt = 12346 服务端下一个发送序号 rcv_nxt = 1235 服务端下一步期望收到的客户端序号 七、SEQ 的精髓：它是字节序号 # TCP 提供的是字节流。SEQ 不是“第几个包”，而是当前 TCP 段中第一个数据字节的编号。\n假设服务端当前发送：\nSEQ=12345 payload_len=100 那么该段中的 100 个数据字节对应：\n12345～12444 下一段新数据从：\nSEQ=12445 开始。\n如果第二段有 500 字节：\n第二段：SEQ=12445，覆盖 12445～12944 第三段：SEQ=12945 所以发送新数据后：\nSND.NXT = SND.NXT + payload_len 如果报文还包含 SYN 或 FIN，则它们各自还要消耗一个序列号：\nsequence_space_len = payload_len + (SYN ? 1 : 0) + (FIN ? 1 : 0) ACK 和 PSH 本身不占序列空间。\n八、ACK 的精髓：确认连续收到的字节 # 假设服务端收到客户端的数据：\nSEQ=5000 payload_len=1000 数据覆盖：\n5000～5999 如果这些字节都按序收到，服务端返回：\nACK=6000 ACK=6000 表示：\n6000 以前的连续序列字节已经收到，下一步期望 6000。 因此 ACK 是累计确认。假设发送方连续发送：\n[5000, 6000) [6000, 7000) [7000, 8000) 接收方可以直接回复：\nACK=8000 一次确认前面三个连续区间。\n在代码中，接收端用于生成 ACK 的核心状态可以写成：\nconn-\u0026gt;rcv_nxt += payload_len; tcp-\u0026gt;recv_ack = htonl(conn-\u0026gt;rcv_nxt); 九、一条 TCP 连接为什么有两套 SEQ/ACK # TCP 是全双工协议，两个方向可以同时传输数据：\n客户端 ─────客户端数据────→ 服务端 客户端 ←────服务端数据───── 服务端 两个方向分别有自己的序列空间：\n客户端发送方向：从 client_isn 开始编号 服务端发送方向：从 server_isn 开始编号 一个 TCP 报文中的两个字段分别表达：\nSEQ：我这次发送的数据从哪里开始 ACK：你发送的数据，我连续收到了哪里 使用“北京与广州互相运货”的类比：\n北京 → 广州：烤鸭编号是一套序列空间 广州 → 北京：白切鸡编号是另一套序列空间 每辆车都可以同时携带：\nSEQ：本车货物的起始编号 ACK：反方向货物已经连续收到的编号 Window：本地仓库还允许对方运来多少货物 所以双方都同时具有发送状态和接收状态。\n十、代码怎样定位 TCP Payload # 原代码在收到 PSH 后，通过 TCP Header 长度定位数据：\nuint8_t hdrlen = (tcphdr-\u0026gt;data_off \u0026gt;\u0026gt; 4) * sizeof(uint32_t); uint8_t *data = (uint8_t *)tcphdr + hdrlen; data_off 的高 4 位以 4 字节为单位表示 TCP Header 长度：\ntcp_hdr_len = (data_off \u0026gt;\u0026gt; 4) × 4 若没有 TCP Options：\ndata_off 高四位 = 5 TCP Header 长度 = 5 × 4 = 20 字节 TCP payload 长度可以通过 IPv4 总长度计算：\nuint16_t ip_total_len = ntohs(iphdr-\u0026gt;total_length); uint16_t ip_hdr_len = (iphdr-\u0026gt;version_ihl \u0026amp; 0x0f) * 4; uint16_t tcp_hdr_len = (tcphdr-\u0026gt;data_off \u0026gt;\u0026gt; 4) * 4; uint16_t payload_len = ip_total_len - ip_hdr_len - tcp_hdr_len; uint8_t *payload = (uint8_t *)tcphdr + tcp_hdr_len; 这也解释了 TCP Header 为什么不需要像 UDP Header 那样再放一个长度字段：IPv4 总长度、IPv4 Header 长度和 TCP Header 长度已经能够计算出当前 TCP payload 长度。\nPSH 可以理解为“希望接收端尽快把这部分数据交给应用”。处理代码时，是否存在 payload 仍以 payload_len \u0026gt; 0 为准。\n十一、从 rx_win 理解 TCP 接收窗口 # 当前构造 TCP Header 时写入：\n#define TCP_INIT_WINDOWS 14600 tcp-\u0026gt;rx_win = htons(TCP_INIT_WINDOWS); 这个字段是本端向对端通告：\n从 ACK 所表示的下一个期望字节开始， 我目前还愿意接收多少字节。 例如服务端回复：\nACK=6000 Window=14600 可以理解为：\n6000 以前已经连续收到； 从 6000 开始，当前还能接收 14600 字节。 接收窗口通常与接收缓存关联：\nRCV.WND = 接收缓存总容量 - 已收到但应用还没有读取的数据量 如果应用读取很慢，缓存逐渐占满：\nRCV.WND 逐渐减小 如果缓存完全占满：\nRCV.WND = 0 发送方看到零窗口后，暂停发送新的数据。接收端应用继续读取数据、释放缓存后，再通过 ACK 中的 Window 字段通告新的可用空间。\n十二、发送窗口：SND.UNA 与 SND.NXT # 发送端可以把序列空间分成四部分：\n已发送已确认 已发送未确认 窗口内尚未发送 当前不能发送 ────────────┬────────────────┬──────────────────┬──────────→ SND.UNA SND.NXT SND.UNA+SND.WND 1. SND.UNA # Send Unacknowledged，最早一个尚未被确认的序号。\n2. SND.NXT # Send Next，下一个新数据应该使用的序号。\n3. 在途数据量 # flight_size = SND.NXT - SND.UNA 表示已经发送、还没有累计确认的数据量。\n4. 收到新 ACK 时窗口滑动 # 发送端原来是：\nSND.UNA=1000 SND.NXT=4000 说明 [1000, 4000) 已发送未确认。\n收到：\nACK=3000 之后：\nSND.UNA=3000 SND.NXT=4000 [1000, 3000) 已被确认，可以从重传队列和发送缓存中释放；窗口左边界向右移动，这就是“滑动窗口”。\n十三、接收窗口与有序交付 # 接收端维护：\nRCV.NXT：下一步期望收到的序号 RCV.WND：从 RCV.NXT 开始还允许接收多少字节 序列空间可以表示为：\n已经连续收到 当前接收窗口 窗口之外 ───────────────┬─────────────────────────┬────────────→ RCV.NXT RCV.NXT+RCV.WND 假设依次发送四段：\nA：[0, 400) B：[400, 900) C：[900, 1600) D：[1600, 2000) 按序到达 # 收到 A：\nRCV.NXT 从 0 变成 400 收到 B：\nRCV.NXT 从 400 变成 900 C 暂时没有到达，D 先到 # 接收端当前仍缺少从 900 开始的数据，因此：\nRCV.NXT 仍为 900 ACK 仍回复 900 D 可以暂存在乱序队列中 当 C 到达后：\nC 与已经缓存的 D 连续 RCV.NXT 可以从 900 一次推进到 2000 ACK 回复 2000 对应用程序而言，TCP 仍然按顺序交付：\nA → B → C → D 这就是 TCP 利用字节序号、累计 ACK 和乱序缓存实现有序字节流的过程。\n十四、延迟确认怎样接入接收逻辑 # 收到数据后，接收端需要用 ACK 告诉发送端 RCV.NXT。ACK 可以有三种发送方式：\n立即发送纯 ACK 延迟一小段时间后发送 ACK 把 ACK 捎带在反方向数据中 延迟确认的主要目标是减少纯 ACK 数量，并等待是否有反方向数据可以一起发送。\n可以先用下面的学习模型理解：\n收到第一个按序数据段 → 更新 RCV.NXT → 设置 pending_ack → 启动 delayed ACK 定时器 定时器到期前又收到一个按序数据段 → 再次更新 RCV.NXT → 立即发送累计 ACK 定时器到期 → 发送 ACK=RCV.NXT 期间本端正好有数据要发 → 把 ACK=RCV.NXT 填入数据段 → 不再额外发送纯 ACK 遇到乱序段时，可以及时回复当前 RCV.NXT，让发送方看到重复 ACK 并发现连续数据中的缺口。\n延迟确认属于接收端的 ACK 生成策略；超时重传和快速重传则是发送端恢复丢失数据的策略。\n十五、为什么发送过的数据要暂时保存 # 调用：\nrte_eth_tx_burst( global_portid, 0, \u0026amp;mbuf, 1 ); 表示把报文提交给 DPDK TX Queue。对于 TCP 来说，提交发送以后还要等待对方 ACK，才能确认该序列范围已经到达。\n因此 TCP 需要发送缓存和重传队列：\n应用写入数据 ↓ 数据进入发送缓存 ↓ 按 MSS 和窗口切成 TCP 段 ↓ 记录每段的 seq_start、seq_end、发送时间 ↓ 构造 mbuf 并提交 TX ↓ 等待 ACK ↓ 收到累计 ACK 后释放已确认的数据 学习版重传项可以记录：\nstruct tcp_tx_segment { uint32_t seq_start; uint32_t seq_end; uint16_t payload_len; uint8_t flags; uint64_t first_tx_us; uint64_t last_tx_us; uint32_t retransmit_count; }; 例如：\n重传队列： [1000, 2000) [2000, 3000) [3000, 4000) 收到 ACK=3000 后：\n[1000, 2000) 已确认，移除 [2000, 3000) 已确认，移除 [3000, 4000) 继续等待 十六、超时重传：没有收到 ACK 时怎么办 # 发送一个 TCP 段时，发送端记录发送时间并启动重传定时器。如果在 RTO 到期前没有收到能够确认它的 ACK，就重新发送最早未确认的数据。\n发送数据段 → 保存到重传队列 → 启动 RTO 定时器 → 收到新 ACK：推进 SND.UNA，重启或停止定时器 → RTO 到期：重传最早未确认段 1. RTT # RTT 是数据发出到相应 ACK 返回所经历的往返时间：\nsend_time ───────────────────── ack_time RTT 网络时延会变化，因此不能只用某一次 RTT 作为固定重传时间。\n2. SRTT、RTTVAR 与 RTO # TCP 对 RTT 做平滑，并记录波动程度：\nSRTT 平滑 RTT RTTVAR RTT 波动估计 RTO 重传超时时间 核心关系是：\nRTO = SRTT + max(G, 4 × RTTVAR) 其中 G 是定时器时钟粒度。\n第一次取得 RTT 样本 R 时：\nSRTT = R RTTVAR = R / 2 后续样本继续对两者进行平滑更新，使 RTO 能随网络情况变化。\n3. RTO 指数退避 # 如果重传后仍然没有 ACK：\nRTO = RTO × 2 继续超时则继续退避：\n1RTO → 2RTO → 4RTO → 8RTO 这样可以避免网络已经拥塞时还以很高频率不断重传。\n4. DPDK 主循环中的定时器 # 定时器处理不能阻塞轮询收包。主循环可以组织为：\nwhile (1) { 处理 RX burst 处理到期的 TCP timers 发送 TX 待发送队列 } 需要处理的 TCP 定时器可以逐步包括：\nRTO timer delayed ACK timer zero-window persist timer TIME_WAIT timer 十七、快速重传：通过重复 ACK 更早发现缺口 # 继续使用下面的序列：\nA：[0, 400) B：[400, 900) C：[900, 1600) ← 丢失 D：[1600, 2000) E：[2000, 2400) F：[2400, 2800) 接收端收到 D、E、F 时，由于 C 仍未到达，连续前缀一直停在 900，于是多次回复：\nACK=900 ACK=900 ACK=900 发送端看到多个重复 ACK，可以在 RTO 之前推断从 900 开始的数据可能丢失。\n经典快速重传通常使用：\n3 个重复 ACK → 重传从 SND.UNA 开始的缺失段 如果握手时协商了 SACK，接收端还能在 TCP Options 中告诉发送端：\n[1600, 2800) 已经收到 发送端便能更准确地只补发缺失区间 [900, 1600)。\n十八、接收窗口 rwnd 与拥塞窗口 cwnd # 发送方决定本轮还能发送多少数据时，要同时考虑两种窗口。\n1. rwnd：接收端通告窗口 # 来源：\ntcphdr-\u0026gt;rx_win 含义：\n对方接收缓存还能容纳多少数据 它解决的是流量控制问题，避免发送速度超过对方应用和接收缓存的处理能力。\n2. cwnd：拥塞窗口 # cwnd 由发送端本地维护，不直接写在 TCP Header 中。\n含义：\n根据当前网络状态，允许多少数据处于在途未确认状态 它解决的是拥塞控制问题，避免一次向网络注入过多数据。\n3. 两个窗口共同限制发送 # send_limit = min(rwnd, cwnd) flight_size = SND.NXT - SND.UNA available = send_limit - flight_size 例一：\nrwnd = 64 KB cwnd = 12 KB flight_size = 8 KB available = min(64, 12) - 8 = 4 KB 例二：\nrwnd = 4 KB cwnd = 32 KB flight_size = 3 KB available = min(4, 32) - 3 = 1 KB 第一个例子受网络拥塞窗口限制，第二个例子受对端接收窗口限制。\n十九、慢启动：逐步探测网络容量 # 连接刚建立时，发送端并不知道路径能容纳多少在途数据，所以用 cwnd 逐步试探。\n为了理解增长过程，先假设：\nSMSS = 1000 字节 初始 cwnd = 1 × SMSS 第一个 RTT # cwnd=1 MSS 发送 1 个 MSS 收到确认新数据的 ACK cwnd 增长到约 2 MSS 第二个 RTT # cwnd=2 MSS 发送 2 个 MSS 收到相应 ACK cwnd 增长到约 4 MSS 第三个 RTT # cwnd=4 MSS 发送 4 个 MSS cwnd 增长到约 8 MSS 所以慢启动的教学曲线经常写作：\n1 → 2 → 4 → 8 → 16 它表示每经过一个 RTT，窗口大致成倍增长。具体实现中，初始窗口可以大于 1 MSS；理解重点是：\n慢启动阶段每收到确认新数据的 ACK，cwnd 增加； 一轮 RTT 中 ACK 数量随已发送数据增加，因此整体近似指数增长。 二十、拥塞避免：超过阈值后降低增长速度 # TCP 连接还维护：\nssthresh：慢启动阈值 两种阶段的关系是：\ncwnd \u0026lt; ssthresh → 慢启动 cwnd \u0026gt; ssthresh → 拥塞避免 慢启动阶段增长较快；进入拥塞避免后，经典算法让 cwnd 每个 RTT 大约增加一个 MSS。\n慢启动：近似指数增长 拥塞避免：近似线性增长 用窗口大小表示：\n慢启动：1、2、4、8、16 拥塞避免：16、17、18、19、20 实际数值由 ACK、MSS 和算法计算，不一定正好是整数段；这组数字主要用于观察增长形态。\n发生 RTO 时 # 经典学习模型中：\nssthresh = 当前在途数据量的一半，且至少为 2 MSS cwnd = 1 MSS 重新进入慢启动 发生 3 个重复 ACK 时 # 说明后续数据仍在到达，发送端执行：\n快速重传 调整 ssthresh 进入快速恢复 这几个机制组合起来就是经典 TCP Reno 的核心：\n慢启动 拥塞避免 快速重传 快速恢复 二十一、把当前全局状态整理成一条连接的数据结构 # 目前的全局变量适合单连接实验。为了把已学习的概念放进代码，可以先整理成学习版连接控制块：\nstruct tcp_connection { /* 四元组 */ uint32_t local_ip; uint32_t remote_ip; uint16_t local_port; uint16_t remote_port; USTACK_TCP_STATUS state; /* 初始序列号 */ uint32_t iss; uint32_t irs; /* 发送方向 */ uint32_t snd_una; uint32_t snd_nxt; uint32_t snd_wnd; /* 接收方向 */ uint32_t rcv_nxt; uint32_t rcv_wnd; /* 重传定时器 */ uint64_t srtt_us; uint64_t rttvar_us; uint64_t rto_us; uint64_t rto_deadline_us; /* 拥塞控制 */ uint32_t smss; uint32_t cwnd; uint32_t ssthresh; /* 发送缓存、重传队列、接收缓存、乱序队列 */ }; 字段与知识点的对应关系：\n代码字段 TCP 含义 state 当前连接状态 iss 本端初始发送序列号 irs 对端初始发送序列号 snd_una 最早未确认的发送序号 snd_nxt 下一个新数据发送序号 snd_wnd 对端通告给本端的接收窗口 rcv_nxt 本端下一步期望收到的序号 rcv_wnd 本端当前可用接收窗口 srtt_us 平滑 RTT rttvar_us RTT 波动估计 rto_us 当前重传超时时间 cwnd 拥塞窗口 ssthresh 慢启动阈值 收到 TCP 报文后，通过四元组找到这条连接：\n(local_ip, local_port, remote_ip, remote_port) ↓ tcp_connection 每个客户端都有自己的状态、SEQ/ACK、窗口、缓存和定时器。\n二十二、ESTABLISHED 状态收到数据时的完整思路 # 当前代码在看见 PSH 后取得并打印数据：\nif (global_flags \u0026amp; RTE_TCP_PSH_FLAG) { if (tcp_status == USTACK_TCP_STATUS_ESTABLISHED) { uint8_t hdrlen = (tcphdr-\u0026gt;data_off \u0026gt;\u0026gt; 4) * sizeof(uint32_t); uint8_t *data = (uint8_t *)tcphdr + hdrlen; printf(\u0026#34;tcp data: %s\\n\u0026#34;, data); } } 把可靠传输补充进来以后，可以按下面的顺序理解：\n收到 ESTABLISHED 状态报文 ↓ 计算 ip_hdr_len、tcp_hdr_len、payload_len ↓ 读取 SEG.SEQ、SEG.ACK、SEG.WND ↓ 处理 ACK：推进 snd_una ↓ 判断 SEG.SEQ 与 rcv_nxt 的关系 ↓ 按序数据写入接收缓存；乱序数据进入乱序队列 ↓ 推进 rcv_nxt ↓ 根据缓存剩余量更新 rcv_wnd ↓ 立即 ACK、延迟 ACK，或在反向数据中携带 ACK ↓ 根据 min(snd_wnd, cwnd) 尝试继续发送 学习版伪代码：\nif (conn-\u0026gt;state == USTACK_TCP_STATUS_ESTABLISHED) { tcp_process_ack(conn, seg_ack, seg_window); if (payload_len \u0026gt; 0) { if (seg_seq == conn-\u0026gt;rcv_nxt) { recv_buffer_write(conn, payload, payload_len); conn-\u0026gt;rcv_nxt += payload_len; consume_contiguous_out_of_order_data(conn); conn-\u0026gt;rcv_wnd = recv_buffer_free_space(conn); schedule_or_send_ack(conn); } else if (seq_after(seg_seq, conn-\u0026gt;rcv_nxt)) { queue_out_of_order_data( conn, seg_seq, payload, payload_len ); send_ack(conn, conn-\u0026gt;rcv_nxt); } else { send_ack(conn, conn-\u0026gt;rcv_nxt); } } } 这段伪代码把当前“读取 payload”的路径继续延伸为：\n有序接收 + 累计确认 + 乱序缓存 + 接收窗口 二十三、发送数据时的完整思路 # 当服务端应用有数据需要发送时：\n应用数据进入发送缓存 ↓ 读取 snd_wnd 和 cwnd ↓ 计算当前还能发送多少 ↓ 按 SMSS 切分 ↓ 每段 SEQ = snd_nxt ACK = rcv_nxt Window = rcv_wnd ↓ 保存到重传队列 ↓ 构造 Ethernet + IPv4 + TCP + payload ↓ rte_eth_tx_burst() ↓ snd_nxt 增加 payload_len ↓ 启动或维护 RTO 定时器 核心计算：\nuint32_t flight_size = conn-\u0026gt;snd_nxt - conn-\u0026gt;snd_una; uint32_t send_limit = RTE_MIN(conn-\u0026gt;snd_wnd, conn-\u0026gt;cwnd); if (send_limit \u0026gt; flight_size) { uint32_t available = send_limit - flight_size; uint32_t send_len = RTE_MIN(available, conn-\u0026gt;smss); /* 构造一个 send_len 字节的数据段 */ } 构造数据段时：\ntcp-\u0026gt;sent_seq = htonl(conn-\u0026gt;snd_nxt); tcp-\u0026gt;recv_ack = htonl(conn-\u0026gt;rcv_nxt); tcp-\u0026gt;tcp_flags = RTE_TCP_ACK_FLAG | RTE_TCP_PSH_FLAG; tcp-\u0026gt;rx_win = htons(conn-\u0026gt;rcv_wnd); 发送后：\nconn-\u0026gt;snd_nxt += send_len; 收到累计 ACK 后：\nconn-\u0026gt;snd_una = seg_ack; 于是发送窗口不断向右滑动。\n二十四、用一次完整传输串起所有概念 # 假设握手结束后：\n服务端 snd_una = 12346 服务端 snd_nxt = 12346 服务端 rcv_nxt = 1235 对端通告 snd_wnd = 6000 服务端 cwnd = 3000 SMSS = 1000 1. 计算发送能力 # flight_size = 12346 - 12346 = 0 send_limit = min(6000, 3000) = 3000 available = 3000 - 0 = 3000 服务端可以先发送 3 个 1000 字节段：\n段 1：SEQ=12346，范围 [12346,13346) 段 2：SEQ=13346，范围 [13346,14346) 段 3：SEQ=14346，范围 [14346,15346) 发送后：\nsnd_una = 12346 snd_nxt = 15346 flight_size = 3000 2. 对端累计确认 # 如果三个段都按序收到，对端回复：\nACK=15346 服务端处理后：\nsnd_una = 15346 snd_nxt = 15346 flight_size = 0 三个段都可以从重传队列中移除。\n3. 慢启动增加 cwnd # ACK 确认了新数据，慢启动阶段继续增加 cwnd，下一轮允许更多在途数据，但仍受 snd_wnd 限制。\n4. 中间一段丢失 # 如果第二段丢失，第三段到达，对端连续前缀只能到 13346，因此回复：\nACK=13346 继续收到后续乱序段时仍回复相同 ACK。发送端可以通过重复 ACK 快速重传第二段；如果没有足够的后续 ACK，则由 RTO 定时器最终触发重传。\n这一过程把以下概念连在了一起：\nSEQ ACK 滑动窗口 接收窗口 拥塞窗口 慢启动 乱序队列 快速重传 超时重传 二十五、代码与 TCP 知识点对照表 # 当前代码 对应 TCP 含义 后续实现中保存的位置 tcphdr-\u0026gt;sent_seq 当前段第一个序列号 SEG.SEQ tcphdr-\u0026gt;recv_ack 对端累计确认号 SEG.ACK tcphdr-\u0026gt;tcp_flags SYN/ACK/PSH/FIN/RST 当前报文状态输入 tcphdr-\u0026gt;rx_win 报文发送方通告的接收窗口 本端 snd_wnd tcp-\u0026gt;rx_win 本端向对方通告的接收窗口 本端 rcv_wnd tcp_status 当前连接状态 conn-\u0026gt;state global_seqnum + 1 确认一个 SYN conn-\u0026gt;rcv_nxt 12345 学习代码中的服务端 ISN conn-\u0026gt;iss data_off \u0026gt;\u0026gt; 4 TCP Header 的 32 位字数量 计算 tcp_hdr_len rte_eth_tx_burst() 把报文交给 TX Queue 发送路径 TCP_INIT_WINDOWS 初始接收窗口示例 conn-\u0026gt;rcv_wnd 尚未加入 最早未确认序号 conn-\u0026gt;snd_una 尚未加入 下一个新发送序号 conn-\u0026gt;snd_nxt 尚未加入 下一个期望接收序号 conn-\u0026gt;rcv_nxt 尚未加入 重传定时器 conn-\u0026gt;rto_us 尚未加入 拥塞窗口 conn-\u0026gt;cwnd 尚未加入 慢启动阈值 conn-\u0026gt;ssthresh 二十六、整条 TCP 处理路径 # flowchart TD A[\u0026#34;rte_eth_rx_burst 收包\u0026#34;] --\u0026gt; B[\u0026#34;解析 Ethernet 和 IPv4\u0026#34;] B --\u0026gt; C[\u0026#34;定位 TCP Header\u0026#34;] C --\u0026gt; D[\u0026#34;读取四元组、Flags、SEQ、ACK、Window\u0026#34;] D --\u0026gt; E[\u0026#34;按四元组查找连接\u0026#34;] E --\u0026gt; F{\u0026#34;连接状态\u0026#34;} F --\u0026gt;|\u0026#34;LISTEN + SYN\u0026#34;| G[\u0026#34;记录客户端 ISN，发送 SYN+ACK\u0026#34;] G --\u0026gt; H[\u0026#34;进入 SYN_RCVD\u0026#34;] F --\u0026gt;|\u0026#34;SYN_RCVD + ACK\u0026#34;| I[\u0026#34;确认服务端 SYN\u0026#34;] I --\u0026gt; J[\u0026#34;进入 ESTABLISHED\u0026#34;] F --\u0026gt;|\u0026#34;ESTABLISHED\u0026#34;| K[\u0026#34;处理 ACK，推进 SND.UNA\u0026#34;] K --\u0026gt; L[\u0026#34;释放已确认发送缓存，更新 RTT/RTO/cwnd\u0026#34;] F --\u0026gt;|\u0026#34;ESTABLISHED + payload\u0026#34;| M[\u0026#34;按 SEQ 处理按序或乱序数据\u0026#34;] M --\u0026gt; N[\u0026#34;推进 RCV.NXT，更新 RCV.WND\u0026#34;] N --\u0026gt; O[\u0026#34;发送或延迟 ACK\u0026#34;] L --\u0026gt; P[\u0026#34;计算 min(rwnd,cwnd)-flight_size\u0026#34;] P --\u0026gt; Q[\u0026#34;窗口允许时继续发送\u0026#34;] Q --\u0026gt; R[\u0026#34;数据进入重传队列并提交 TX\u0026#34;] S[\u0026#34;RTO 定时器\u0026#34;] --\u0026gt; T[\u0026#34;超时重传并调整拥塞状态\u0026#34;] T --\u0026gt; R 二十七、复习时抓住的主线 # 第一条：连接状态 # LISTEN → SYN_RCVD → ESTABLISHED 状态决定当前收到的 Flags 应该怎样解释。\n第二条：字节序列 # SEQ 标识本方向发送的数据 ACK 确认反方向连续收到的数据 第三条：发送可靠性 # 数据发出后保留 ACK 到达后释放 重复 ACK 可触发快速重传 RTO 到期触发超时重传 第四条：有序接收 # RCV.NXT 之前已经连续收到 等于 RCV.NXT 的数据可以推进确认号 更大的 SEQ 暂存到乱序队列 缺口补齐后连续交付给应用 第五条：发送数量 # rwnd 保护接收端 cwnd 保护网络 实际发送上限取二者较小值 第六条：网络探测 # 慢启动快速扩大 cwnd 到达 ssthresh 后进入拥塞避免 丢包或超时后降低发送强度 二十八、总结 # 当前代码用下面几行打开了理解 TCP 的入口：\nglobal_flags = tcphdr-\u0026gt;tcp_flags; global_seqnum = ntohl(tcphdr-\u0026gt;sent_seq); global_acknum = ntohl(tcphdr-\u0026gt;recv_ack); 沿着它们继续向下，就是完整的 TCP 传输控制：\nFlags 驱动连接状态机 SEQ 给每个方向的数据字节编号 ACK 累计确认已经连续收到的字节 SND.UNA 与 SND.NXT 形成发送滑动窗口 RCV.NXT 与 RCV.WND 管理有序接收和接收缓存 发送缓存保留尚未确认的数据 重复 ACK 与 RTO 负责发现并恢复丢失 rwnd 控制接收端承受能力 cwnd、ssthresh、慢启动和拥塞避免控制网络中的在途数据 因此，从当前代码继续实现 TCP 的核心方向，就是把现在的全局 flags、seq、ack、tcp_status 扩展为每条连接独立的状态、序列空间、窗口、缓存和定时器。\n参考规范 # RFC 9293：Transmission Control Protocol RFC 5681：TCP Congestion Control RFC 6298：Computing TCP\u0026rsquo;s Retransmission Timer RFC 2018：TCP Selective Acknowledgment Options ","date":"2026年8月17日","externalUrl":null,"permalink":"/MyBlog/p/dpdk-userspace-stack-tcp-reliable-transmission/","section":"Posts","summary":"","title":"DPDK 用户态协议栈（三）：从 TCP 握手到可靠传输","type":"posts"},{"content":"","date":"2026年8月17日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%8F%AF%E9%9D%A0%E4%BC%A0%E8%BE%93/","section":"Tags","summary":"","title":"可靠传输","type":"tags"},{"content":"本文承接《dpdk用户态协议栈设计与实现》。本文把重点放在一个新问题上：收到数据以后，怎样在用户态构造一个符合协议的响应包并发送出去？ 为了让代码能够独立读懂，后文会从程序入口开始完整解释执行流程；网络分层等原理知识则只在影响代码理解时说明。\n一、本篇要完成什么 # 上一篇的程序已经可以：\n初始化 EAL、mempool 和网口； 通过 rte_eth_rx_burst() 轮询收包； 识别 Ethernet、IPv4、UDP 和 TCP； 打印报文中的地址、端口与数据。 本篇继续完成三件事：\n为网口配置 TX Queue，使程序具备发包能力； 实现 UDP Echo：收到 UDP 数据后，构造响应包并原样返回 payload； 实现最小 TCP 服务端：收到 SYN 后回复 SYN+ACK，再接收 ACK 和应用数据。 这里实现的是用于学习协议与验证收发链路的最小原型，不是可投入生产的完整协议栈。\n二、用编译期开关隔离增量代码 # 开发新功能时，可以先用宏把新增代码包起来：\n#define ENABLE_SEND 1 #define ENABLE_TCP 1 #if ENABLE_SEND /* TX Queue、UDP 回包等新增代码 */ #endif #if ENABLE_TCP /* TCP 状态机与 TCP 回包代码 */ #endif 这样做的价值不是“上线前把代码删掉”，而是：\n新功能出问题时，可快速回退到仅收包版本； 可以分别验证 RX、UDP TX 和 TCP 三部分； 提交 patch 时，新增逻辑的边界更加清楚。 如果功能会长期存在，最终更适合使用构建选项或运行时配置；长期散落大量 #if 会增加维护成本。\n三、能收包不等于能发包 # 仅配置 RX Queue 时，网卡只能供当前程序收包。要发送数据，还需要完成三步：\nconst uint16_t num_rx_queues = 1; const uint16_t num_tx_queues = ENABLE_SEND ? 1 : 0; if (rte_eth_dev_configure(port_id, num_rx_queues, num_tx_queues, \u0026amp;port_conf) \u0026lt; 0) { rte_exit(EXIT_FAILURE, \u0026#34;Could not configure port\\n\u0026#34;); } #if ENABLE_SEND struct rte_eth_txconf txq_conf = dev_info.default_txconf; txq_conf.offloads = port_conf.txmode.offloads; if (rte_eth_tx_queue_setup(port_id, 0, 512, rte_eth_dev_socket_id(port_id), \u0026amp;txq_conf) \u0026lt; 0) { rte_exit(EXIT_FAILURE, \u0026#34;Could not setup TX queue\\n\u0026#34;); } #endif if (rte_eth_dev_start(port_id) \u0026lt; 0) { rte_exit(EXIT_FAILURE, \u0026#34;Could not start port\\n\u0026#34;); } 完整顺序是：\nrte_eth_dev_configure ↓ rte_eth_rx_queue_setup / rte_eth_tx_queue_setup ↓ rte_eth_dev_start ↓ rte_eth_rx_burst / rte_eth_tx_burst rte_eth_tx_burst() 的返回值 # uint16_t nb_tx = rte_eth_tx_burst(port_id, 0, \u0026amp;tx_mbuf, 1); if (nb_tx == 0) { rte_pktmbuf_free(tx_mbuf); } 返回值表示实际被 TX Queue 接收的 mbuf 数量：\n返回 1：mbuf 的所有权已交给 DPDK/驱动，应用不能立即释放或继续修改； 返回 0：该 mbuf 没有成功入队，仍由应用负责，应重试或释放。 批量发送时，只能释放返回值之后的未发送部分：\nuint16_t nb_tx = rte_eth_tx_burst(port_id, 0, tx_pkts, nb_pkts); for (uint16_t i = nb_tx; i \u0026lt; nb_pkts; i++) { rte_pktmbuf_free(tx_pkts[i]); } 这是 DPDK 发包代码中很重要的内存所有权规则。\n四、为什么把收到的 mbuf 直接发出去不是 Echo # 最初的尝试是：\nrte_eth_tx_burst(port_id, 0, \u0026amp;mbufs[i], 1); 这只是把收到的原始帧再次发出。原包仍然是：\n客户端 MAC/IP/Port → 服务端 MAC/IP/Port 而响应包必须是：\n服务端 MAC/IP/Port → 客户端 MAC/IP/Port 因此至少要处理：\n协议层 响应包需要修改的内容 Ethernet 源 MAC 与目的 MAC 对调 IPv4 源 IP 与目的 IP 对调，重新计算 IPv4 头校验和 UDP 源端口与目的端口对调，重新计算 UDP 校验和 TCP 对调端口，并根据状态设置 Flags、SEQ、ACK 和校验和 以太网 FCS/CRC 通常由网卡在真正发出帧时生成，应用一般不需要在 mbuf 末尾手工追加；但 IPv4、UDP、TCP 的校验和必须由软件计算，或者明确配置硬件 checksum offload。\n五、UDP Echo 的数据路径 # UDP Echo 的完整过程是：\nRX Queue 收到请求 ↓ 检查 Ethernet / IPv4 / UDP 及各层长度 ↓ 保存并对调 MAC、IP、Port ↓ 从 mempool 分配新的 TX mbuf ↓ 依次写入 Ethernet、IPv4、UDP Header ↓ 复制原 UDP payload ↓ 计算 IPv4 与 UDP checksum ↓ rte_eth_tx_burst 发送 为便于学习，草稿使用了全局变量暂存一组通信端点：\nuint8_t global_smac[RTE_ETHER_ADDR_LEN]; uint8_t global_dmac[RTE_ETHER_ADDR_LEN]; uint32_t global_sip; uint32_t global_dip; uint16_t global_sport; uint16_t global_dport; 收到请求后，对调方向：\nrte_memcpy(global_smac, eth-\u0026gt;dst_addr.addr_bytes, RTE_ETHER_ADDR_LEN); rte_memcpy(global_dmac, eth-\u0026gt;src_addr.addr_bytes, RTE_ETHER_ADDR_LEN); global_sip = ip-\u0026gt;dst_addr; global_dip = ip-\u0026gt;src_addr; global_sport = udp-\u0026gt;dst_port; global_dport = udp-\u0026gt;src_port; 这些字段本身仍保持网络字节序，写回协议头时不需要再次 hton*()。\n不要用全局变量保存真实连接 # 全局变量只适合单队列、单线程、一次处理一个包的教学程序。多个客户端或多个 lcore 并发时，后一包会覆盖前一包的端点信息。更合理的做法是把地址信息作为函数参数传入，TCP 则使用以四元组为 key 的连接表。\n六、构造 UDP 响应包 # 1. 先算清三种长度 # 设 UDP payload 长度为 payload_len：\nuint16_t udp_len = sizeof(struct rte_udp_hdr) + payload_len; uint16_t ip_len = sizeof(struct rte_ipv4_hdr) + udp_len; uint16_t frame_len = sizeof(struct rte_ether_hdr) + ip_len; 三者的含义不同：\nudp-\u0026gt;dgram_len：UDP Header + UDP payload； ip-\u0026gt;total_length：IPv4 Header + IPv4 payload，不含 Ethernet Header； mbuf-\u0026gt;pkt_len/data_len：本例中的完整 Ethernet 帧长度，不含硬件生成的 FCS。 2. 分配并扩展 mbuf # 更稳妥的写法是使用 rte_pktmbuf_append()，而不是只手工改长度字段：\nstruct rte_mbuf *tx_mbuf = rte_pktmbuf_alloc(mbuf_pool); if (tx_mbuf == NULL) { return -ENOMEM; } uint8_t *frame = rte_pktmbuf_append(tx_mbuf, frame_len); if (frame == NULL) { rte_pktmbuf_free(tx_mbuf); return -ENOSPC; } rte_pktmbuf_append() 会检查尾部空间，并同步更新 data_len 与 pkt_len。\n3. 写 Ethernet Header # struct rte_ether_hdr *eth = (struct rte_ether_hdr *)frame; rte_memcpy(eth-\u0026gt;dst_addr.addr_bytes, global_dmac, RTE_ETHER_ADDR_LEN); rte_memcpy(eth-\u0026gt;src_addr.addr_bytes, global_smac, RTE_ETHER_ADDR_LEN); eth-\u0026gt;ether_type = rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4); 不同 DPDK 版本中，成员名可能是 d_addr/s_addr 或 dst_addr/src_addr，应以本机头文件为准。\n4. 写 IPv4 Header # struct rte_ipv4_hdr *ip = (struct rte_ipv4_hdr *)(eth + 1); *ip = (struct rte_ipv4_hdr){0}; ip-\u0026gt;version_ihl = RTE_IPV4_VHL_DEF; ip-\u0026gt;total_length = rte_cpu_to_be_16(ip_len); ip-\u0026gt;time_to_live = 64; ip-\u0026gt;next_proto_id = IPPROTO_UDP; ip-\u0026gt;src_addr = global_sip; ip-\u0026gt;dst_addr = global_dip; ip-\u0026gt;hdr_checksum = 0; ip-\u0026gt;hdr_checksum = rte_ipv4_cksum(ip); version_ihl = 0x45 表示 IPv4，且头部长度为 5 × 4 = 20 字节。使用 RTE_IPV4_VHL_DEF 可读性更好。\ntotal_length 是 16 位多字节字段，写入协议头前必须转为网络字节序。\n5. 写 UDP Header 和 payload # struct rte_udp_hdr *udp = (struct rte_udp_hdr *)(ip + 1); udp-\u0026gt;src_port = global_sport; udp-\u0026gt;dst_port = global_dport; udp-\u0026gt;dgram_len = rte_cpu_to_be_16(udp_len); udp-\u0026gt;dgram_cksum = 0; rte_memcpy(udp + 1, payload, payload_len); udp-\u0026gt;dgram_cksum = rte_ipv4_udptcp_cksum(ip, udp); 这里最容易写错的是复制长度。udp_len 包含 8 字节 UDP Header，因此复制 payload 时必须使用：\npayload_len = udp_len - sizeof(struct rte_udp_hdr); 不能把整个 udp_len 字节都从 udp + 1 开始复制，否则会越界读取原报文，并可能越界写入新 mbuf。\n6. 一个整理后的编码函数 # static int encode_udp_echo(uint8_t *frame, const uint8_t *payload, uint16_t payload_len) { uint16_t udp_len = sizeof(struct rte_udp_hdr) + payload_len; uint16_t ip_len = sizeof(struct rte_ipv4_hdr) + udp_len; struct rte_ether_hdr *eth = (struct rte_ether_hdr *)frame; rte_memcpy(eth-\u0026gt;dst_addr.addr_bytes, global_dmac, RTE_ETHER_ADDR_LEN); rte_memcpy(eth-\u0026gt;src_addr.addr_bytes, global_smac, RTE_ETHER_ADDR_LEN); eth-\u0026gt;ether_type = rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4); struct rte_ipv4_hdr *ip = (struct rte_ipv4_hdr *)(eth + 1); *ip = (struct rte_ipv4_hdr){0}; ip-\u0026gt;version_ihl = RTE_IPV4_VHL_DEF; ip-\u0026gt;total_length = rte_cpu_to_be_16(ip_len); ip-\u0026gt;time_to_live = 64; ip-\u0026gt;next_proto_id = IPPROTO_UDP; ip-\u0026gt;src_addr = global_sip; ip-\u0026gt;dst_addr = global_dip; ip-\u0026gt;hdr_checksum = rte_ipv4_cksum(ip); struct rte_udp_hdr *udp = (struct rte_udp_hdr *)(ip + 1); udp-\u0026gt;src_port = global_sport; udp-\u0026gt;dst_port = global_dport; udp-\u0026gt;dgram_len = rte_cpu_to_be_16(udp_len); udp-\u0026gt;dgram_cksum = 0; rte_memcpy(udp + 1, payload, payload_len); udp-\u0026gt;dgram_cksum = rte_ipv4_udptcp_cksum(ip, udp); return 0; } 七、安全取得 UDP payload # 草稿中使用：\nprintf(\u0026#34;udp: %s\\n\u0026#34;, (char *)(udp + 1)); 这只能用于确保 payload 末尾带 \\0 的演示数据。网络报文是带长度的二进制数据，不保证是 C 字符串。\n在不考虑 IPv4 Options 的最小版本中：\nuint16_t udp_len = rte_be_to_cpu_16(udp-\u0026gt;dgram_len); if (udp_len \u0026lt; sizeof(struct rte_udp_hdr)) { /* malformed packet */ } uint16_t payload_len = udp_len - sizeof(struct rte_udp_hdr); uint8_t *payload = (uint8_t *)(udp + 1); printf(\u0026#34;udp: %.*s\\n\u0026#34;, (int)payload_len, (char *)payload); 真正处理报文前，还要结合 rte_pktmbuf_pkt_len(mbuf) 检查 Ethernet、IP、UDP 声明的长度是否落在 mbuf 实际数据范围内。需要记住：构造包和解析包都必须以显式长度为边界。\n打印 IP 和端口 # struct in_addr addr = { .s_addr = ip-\u0026gt;src_addr }; printf(\u0026#34;sip %s:%u --\u0026gt; \u0026#34;, inet_ntoa(addr), rte_be_to_cpu_16(udp-\u0026gt;src_port)); addr.s_addr = ip-\u0026gt;dst_addr; printf(\u0026#34;dip %s:%u\\n\u0026#34;, inet_ntoa(addr), rte_be_to_cpu_16(udp-\u0026gt;dst_port)); inet_ntoa() 使用内部静态缓冲区，不适合并发代码；工程中优先使用 inet_ntop()。\n八、从 UDP 过渡到 TCP：变化不只是 Header # UDP 收到一个数据报就可以立即生成一个独立响应。TCP 则必须维护连接状态，因为一个 TCP 报文的正确响应取决于此前发生过什么。\n最小服务端路径为：\n客户端 DPDK 服务端 | -------- SYN, seq=x --------\u0026gt; | | \u0026lt;--- SYN+ACK, seq=y, ack=x+1 -| | -------- ACK, ack=y+1 ------\u0026gt; | | | ESTABLISHED | ------ PSH+ACK, data --------\u0026gt; | 草稿定义了完整 TCP 状态枚举：\ntypedef enum { USTACK_TCP_STATUS_CLOSED = 0, USTACK_TCP_STATUS_LISTEN, USTACK_TCP_STATUS_SYN_RCVD, USTACK_TCP_STATUS_SYN_SENT, USTACK_TCP_STATUS_ESTABLISHED, USTACK_TCP_STATUS_FIN_WAIT_1, USTACK_TCP_STATUS_FIN_WAIT_2, USTACK_TCP_STATUS_CLOSING, USTACK_TCP_STATUS_TIME_WAIT, USTACK_TCP_STATUS_CLOSE_WAIT, USTACK_TCP_STATUS_LAST_ACK } ustack_tcp_status_t; 当前代码实际只实现了：\nLISTEN → SYN_RCVD → ESTABLISHED 因此准确的描述是“完成服务端三次握手的最小实验”，而不是“已经实现 TCP 协议栈”。重传、乱序、滑动窗口、拥塞控制、RST、FIN 四次挥手、TIME_WAIT、TCP Options 和多连接管理都还没有实现。\n九、TCP 的 SEQ 与 ACK 应该怎样理解 # 收到客户端 SYN：\nclient.seq = x 服务端回复 SYN+ACK：\nserver.seq = y server.ack = x + 1 SYN 虽然不携带应用数据，但会占用一个序列号，所以确认号是 x + 1。FIN 同样占用一个序列号。\n对于普通数据段：\n下一确认号 = 当前 seq + TCP payload 长度 如果同一段还带 SYN 或 FIN，还应分别再加 1。\n草稿中的：\ntcp-\u0026gt;sent_seq = htonl(12345); tcp-\u0026gt;recv_ack = htonl(global_seqnum + 1); 足以演示 SYN+ACK，但 12345 应被视为教学用的初始序列号。真实实现需要为每条连接保存本端/对端序列号，并校验收到的 ACK 是否真正确认了自己发送的 SYN 或数据。\n十、构造最小 SYN+ACK # TCP 响应与 UDP 的 Ethernet、IPv4 部分基本相同；区别在 TCP Header：\nstruct rte_tcp_hdr *tcp = (struct rte_tcp_hdr *)(ip + 1); *tcp = (struct rte_tcp_hdr){0}; tcp-\u0026gt;src_port = global_sport; tcp-\u0026gt;dst_port = global_dport; tcp-\u0026gt;sent_seq = rte_cpu_to_be_32(server_isn); tcp-\u0026gt;recv_ack = rte_cpu_to_be_32(client_seq + 1); tcp-\u0026gt;data_off = (sizeof(struct rte_tcp_hdr) / 4) \u0026lt;\u0026lt; 4; tcp-\u0026gt;tcp_flags = RTE_TCP_SYN_FLAG | RTE_TCP_ACK_FLAG; tcp-\u0026gt;rx_win = rte_cpu_to_be_16(TCP_INIT_WINDOW); tcp-\u0026gt;cksum = 0; tcp-\u0026gt;cksum = rte_ipv4_udptcp_cksum(ip, tcp); 几个容易忽略的点：\ndata_off 的高 4 位表示 TCP Header 占几个 32 位字；基础头 20 字节，所以值为 5 \u0026lt;\u0026lt; 4，即 0x50； rx_win 是多字节字段，应使用网络字节序； 计算 checksum 前，checksum 字段必须先清零； struct rte_tcp_hdr 最好整体清零，避免未初始化的 cksum、tcp_urp 等字段混入报文； IPv4 total_length 必须覆盖 IPv4 Header、TCP Header 和 TCP payload。 为什么 UDP 头有长度，而 TCP 头没有 # UDP 自身以数据报为边界，因此 Header 中有 dgram_len：\nUDP 长度 = UDP Header + UDP payload TCP 是字节流协议，段长度可以从 IPv4 总长度推导：\nTCP 段长度 = IP total_length - IP Header 长度 TCP payload 长度 = TCP 段长度 - TCP Header 长度 对应代码：\nuint16_t ip_hdr_len = (ip-\u0026gt;version_ihl \u0026amp; 0x0f) * 4; uint16_t tcp_hdr_len = (tcp-\u0026gt;data_off \u0026gt;\u0026gt; 4) * 4; uint16_t ip_total_len = rte_be_to_cpu_16(ip-\u0026gt;total_length); uint16_t tcp_payload_len = ip_total_len - ip_hdr_len - tcp_hdr_len; 因此，TCP 的确认号并不是“长度字段”，但它会随着已经确认的字节数向前推进，体现了 TCP 字节流的位置。\n十一、最小 TCP 状态处理 # 1. LISTEN 收到 SYN # if ((flags \u0026amp; RTE_TCP_SYN_FLAG) \u0026amp;\u0026amp; state == USTACK_TCP_STATUS_LISTEN) { client_next_seq = rte_be_to_cpu_32(tcp-\u0026gt;sent_seq) + 1; send_syn_ack(...); state = USTACK_TCP_STATUS_SYN_RCVD; } 2. SYN_RCVD 收到有效 ACK # 不能只检查 ACK 位，还应验证确认号：\nif ((flags \u0026amp; RTE_TCP_ACK_FLAG) \u0026amp;\u0026amp; state == USTACK_TCP_STATUS_SYN_RCVD \u0026amp;\u0026amp; rte_be_to_cpu_32(tcp-\u0026gt;recv_ack) == server_isn + 1) { state = USTACK_TCP_STATUS_ESTABLISHED; } 3. ESTABLISHED 收到数据 # PSH 只是提示接收端尽快把数据交给应用，不是“存在 payload”的唯一判断条件。更可靠的判断是计算 tcp_payload_len \u0026gt; 0：\nuint16_t payload_len = get_tcp_payload_len(ip, tcp); if (state == USTACK_TCP_STATUS_ESTABLISHED \u0026amp;\u0026amp; payload_len \u0026gt; 0) { const uint8_t *payload = (const uint8_t *)tcp + tcp_hdr_len; printf(\u0026#34;tcp data: %.*s\\n\u0026#34;, (int)payload_len, (const char *)payload); /* 回复 ACK 时：ack = peer_seq + payload_len */ } 草稿能够打印 TCP 数据，但还没有为数据段回复 ACK，也没有处理 FIN；一些客户端因此会持续重传或无法正常关闭连接。\n十二、从草稿中提炼出的典型 Bug # 1. IPv4 地址只复制了 2 字节 # 错误版本：\nrte_memcpy(\u0026amp;global_sip, \u0026amp;ip-\u0026gt;dst_addr, sizeof(uint16_t)); IPv4 地址是 32 位，应复制 sizeof(uint32_t)，或者直接赋值：\nglobal_sip = ip-\u0026gt;dst_addr; global_dip = ip-\u0026gt;src_addr; 2. 把 UDP 总长度当成 payload 长度 # 错误思路：\nrte_memcpy(udp + 1, data, udp_len); 正确关系：\npayload_len = udp_len - sizeof(struct rte_udp_hdr); rte_memcpy(udp + 1, data, payload_len); 3. 分配了新 mbuf，却传错发送指针 # 错误版本：\nrte_eth_tx_burst(port_id, 0, \u0026amp;mbufs, 1); 应发送刚构造的单个 mbuf：\nrte_eth_tx_burst(port_id, 0, \u0026amp;mbuf, 1); \u0026amp;mbufs 是整个 RX 指针数组的地址，语义也不正确。\n4. 多字节字段漏做字节序转换 # 典型字段包括：\nip-\u0026gt;total_length = rte_cpu_to_be_16(ip_len); udp-\u0026gt;dgram_len = rte_cpu_to_be_16(udp_len); tcp-\u0026gt;sent_seq = rte_cpu_to_be_32(seq); tcp-\u0026gt;recv_ack = rte_cpu_to_be_32(ack); tcp-\u0026gt;rx_win = rte_cpu_to_be_16(window); MAC 地址和 payload 是字节数组，不需要转换。\n5. 不检查 TX 返回值 # TX Queue 满时，rte_eth_tx_burst() 可以少发甚至不发。未成功发送的 mbuf 若不释放，会产生内存泄漏。\n6. 只看 Flags，不校验状态与序列号 # “收到带 ACK 标志的包”不等于三次握手完成。至少还要确认当前状态是 SYN_RCVD，且 ACK 值为服务端初始序列号加 1。\n十三、完整代码逐行讲解 # 这一节按照程序真正的运行顺序，从头文件开始解释，直到收到、解析、构造并发送一个包。代码前面的数字只是讲解用行号，不是源代码的一部分。\n1. 头文件分别提供什么 # 01 #include \u0026lt;stdio.h\u0026gt; 02 #include \u0026lt;stdint.h\u0026gt; 03 #include \u0026lt;errno.h\u0026gt; 04 #include \u0026lt;arpa/inet.h\u0026gt; 05 06 #include \u0026lt;rte_eal.h\u0026gt; 07 #include \u0026lt;rte_ethdev.h\u0026gt; 08 #include \u0026lt;rte_mbuf.h\u0026gt; 09 #include \u0026lt;rte_ether.h\u0026gt; 10 #include \u0026lt;rte_ip.h\u0026gt; 11 #include \u0026lt;rte_udp.h\u0026gt; 12 #include \u0026lt;rte_tcp.h\u0026gt; 第 1 行：提供 printf()。 第 2 行：提供 uint8_t、uint16_t、uint32_t 等定长整数类型。网络协议字段有固定宽度，因此比普通 int 更合适。 第 3 行：提供 ENOMEM、ENOSPC 等错误码。 第 4 行：提供 inet_ntop()、htons() 等网络地址与字节序函数。 第 6 行：提供 rte_eal_init()、rte_exit() 等 DPDK 运行环境接口。 第 7 行：提供网口、RX/TX Queue 和 rte_eth_rx_burst()、rte_eth_tx_burst()。 第 8 行：提供 mempool、mbuf 分配、释放和数据地址转换接口。 第 9～12 行：分别提供 Ethernet、IPv4、UDP、TCP Header 结构体和协议常量。 #include 的作用是把函数声明、结构体定义和宏定义提供给当前 .c 文件。它不会替代链接步骤；编译时仍要通过 DPDK 的 pkg-config 参数链接对应库。\n2. 全局端口、mbuf 数量与 Burst 大小 # 01 static uint16_t global_portid = 0; 02 03 #define NUM_MBUFS 4096 04 #define BURST_SIZE 128 第 1 行：选择 DPDK 端口 0。它表示 DPDK 枚举得到的端口编号，不一定等于 Linux 中的网卡名称或 PCI 地址。 static：这里表示变量只在当前源文件内可见，减少与其他文件同名变量冲突的机会。 第 3 行：mempool 预备 4096 个 mbuf。RX 和新建的 TX 包都会消耗池中的对象。 第 4 行：一次 rte_eth_rx_burst() 最多取回 128 个 mbuf 指针。 NUM_MBUFS 是整个对象池规模，BURST_SIZE 是一次批量操作的上限，两者不是同一概念。\n3. 功能开关与发送端点 # 01 #define ENABLE_SEND 1 02 #define ENABLE_TCP 1 03 #define TCP_INIT_WINDOW 14600 04 05 #if ENABLE_SEND 06 uint8_t global_smac[RTE_ETHER_ADDR_LEN]; 07 uint8_t global_dmac[RTE_ETHER_ADDR_LEN]; 08 uint32_t global_sip; 09 uint32_t global_dip; 10 uint16_t global_sport; 11 uint16_t global_dport; 12 #endif 第 1 行：控制是否编译发包代码。关闭后可退回仅收包版本。 第 2 行：控制是否编译 TCP 实验代码，可以只验证 UDP。 第 3 行：定义 SYN+ACK 中通告给客户端的接收窗口大小。 第 5～12 行：只有启用发送功能时才定义响应端点。 第 6～7 行：分别保存响应包的源 MAC 与目的 MAC。 第 8～9 行：IPv4 地址是 32 位，因此类型必须是 uint32_t。 第 10～11 行：TCP/UDP 端口是 16 位。 这些变量保存的地址和端口直接来自收到的协议头，所以仍是网络字节序。只有打印或参与主机侧数值计算时，才需要 rte_be_to_cpu_16()、rte_be_to_cpu_32()。\n4. 端口默认配置 # 01 static const struct rte_eth_conf port_conf_default = { 02 .rxmode = { 03 .max_rx_pkt_len = RTE_ETHER_MAX_LEN 04 } 05 }; 第 1 行：定义一个网口配置结构体，并用 const 表示运行中不修改它。 .rxmode = { ... }：这是 C 语言的指定初始化器，只填写 rxmode 成员，其余成员自动初始化为 0。 第 3 行：把最大接收包长设为普通 Ethernet 帧允许的范围。 不同 DPDK 版本的 struct rte_eth_conf 字段可能变化。如果本机版本提示 max_rx_pkt_len 不存在，应检查安装版本的头文件和示例，而不是强行照抄字段名。\n5. 查询可用端口和设备默认能力 # 01 static int ustack_init_port(struct rte_mempool *mbuf_pool) 02 { 03 uint16_t nb_ports = rte_eth_dev_count_avail(); 04 05 if (nb_ports == 0) { 06 rte_exit(EXIT_FAILURE, 07 \u0026#34;No supported Ethernet device found\\n\u0026#34;); 08 } 09 10 if (!rte_eth_dev_is_valid_port(global_portid)) { 11 rte_exit(EXIT_FAILURE, \u0026#34;Invalid port id\\n\u0026#34;); 12 } 13 14 struct rte_eth_dev_info dev_info; 15 int ret = rte_eth_dev_info_get(global_portid, \u0026amp;dev_info); 16 if (ret != 0) { 17 rte_exit(EXIT_FAILURE, 18 \u0026#34;Cannot get device info: %d\\n\u0026#34;, ret); 19 } 第 1 行：函数接收 mempool 指针，因为 RX Queue 需要知道从哪个池获取接收缓冲区。 第 3 行：查询当前由 DPDK 管理且可用的 Ethernet 端口数量。 第 5～8 行：一个可用端口都没有时直接退出。常见原因包括网卡没有绑定到合适驱动、权限不足或 EAL 参数错误。 第 10 行：即使系统存在端口，也要确认准备使用的 global_portid 有效。 第 14 行：声明设备信息结构体，用于接收驱动默认配置和网卡能力。 第 15 行：读取 0 号端口的信息。 第 16～18 行：检查 API 返回值；不能在查询失败后继续使用未初始化的 dev_info。 6. 配置 RX Queue 与 TX Queue 的数量 # 01 const uint16_t num_rx_queues = 1; 02 03 #if ENABLE_SEND 04 const uint16_t num_tx_queues = 1; 05 #else 06 const uint16_t num_tx_queues = 0; 07 #endif 08 09 rte_eth_dev_configure(global_portid, 10 num_rx_queues, 11 num_tx_queues, 12 \u0026amp;port_conf_default); 13 14 #if ENABLE_SEND 15 struct rte_eth_txconf txq_conf = dev_info.default_txconf; 16 txq_conf.offloads = port_conf_default.txmode.offloads; 17 18 if (rte_eth_tx_queue_setup(global_portid, 19 0, 20 512, 21 rte_eth_dev_socket_id(global_portid), 22 \u0026amp;txq_conf) \u0026lt; 0) { 23 rte_exit(EXIT_FAILURE, \u0026#34;Could not setup TX queue\\n\u0026#34;); 24 } 25 #endif 第 1 行：仍然只配置一个 RX Queue。 第 3～7 行：启用发送时创建一个 TX Queue，否则把 TX Queue 数量设为 0。 第 9～12 行：网口配置阶段必须提前声明 RX/TX Queue 的数量。 第 15 行：从设备能力信息中复制驱动提供的默认 TX 配置，避免凭空构造不兼容的参数。 第 16 行：把端口级 TX offload 配置同步给队列。草稿整理前曾写成 rxmode.offloads，此处应与 TX 配置对应。 第 18 行：开始配置 TX Queue。 第 19 行：队列编号为 0，因为当前只创建一个发送队列。 第 20 行：TX 描述符数量为 512，它描述队列容量，不是单次最多只能发送 512 个包。 第 21 行：让队列内存尽量位于网卡所属的 NUMA Socket。 第 22 行：传入刚才复制并调整的 TX 配置。 第 23 行：配置失败就退出，避免程序进入一个必然无法发送的循环。 7. 配置 RX Queue # 01 if (rte_eth_rx_queue_setup(global_portid, 02 0, 03 128, 04 rte_eth_dev_socket_id(global_portid), 05 NULL, 06 mbuf_pool) \u0026lt; 0) { 07 rte_exit(EXIT_FAILURE, \u0026#34;Could not setup RX queue\\n\u0026#34;); 08 } 第 1 行：为指定网口建立接收队列。 第 2 行：队列编号为 0。 第 3 行：配置 128 个 RX 描述符。描述符用于让网卡和驱动追踪接收缓冲区，不等于只创建 128 个 mbuf。 第 4 行：选择网卡所在 NUMA Socket。 第 5 行：传 NULL 表示使用驱动默认 RX Queue 配置。 第 6 行：把前面创建的 mempool 交给 RX Queue。网卡收到包后，驱动会使用池中的 mbuf 承载数据。 第 7 行：队列创建失败就退出。 8. 启动端口并返回 # 01 if (rte_eth_dev_start(global_portid) \u0026lt; 0) { 02 rte_exit(EXIT_FAILURE, \u0026#34;Could not start port\\n\u0026#34;); 03 } 04 05 return 0; 06 } 第 1 行：前面的 configure 和 queue setup 只是准备配置，这里才真正启动设备。 第 2 行：启动失败不能进入收发循环。 第 5 行：返回 0 表示端口初始化成功。 第 6 行：结束 ustack_init_port() 函数。 9. main() 的参数与 EAL 初始化 # 01 int main(int argc, char **argv) 02 { 03 int ret = rte_eal_init(argc, argv); 04 if (ret \u0026lt; 0) { 05 rte_exit(EXIT_FAILURE, \u0026#34;Error with EAL init\\n\u0026#34;); 06 } 第 1 行：argc 是命令行参数数量，argv 是字符串指针数组。 第 3 行：初始化 DPDK EAL。它会处理 lcore、内存、设备等 DPDK 参数。 返回负数表示初始化失败。 返回非负数表示 EAL 消耗掉的命令行参数数量。如果程序还要解析自己的参数，可以使用 argc -= ret; argv += ret; 跳过 EAL 参数。 第 4～5 行：初始化失败时打印错误并结束进程。 EAL 必须先初始化，后面创建 mempool、查询端口和配置设备才有可用的 DPDK 运行环境。\n10. 创建 mbuf pool # 07 struct rte_mempool *mbuf_pool = 08 rte_pktmbuf_pool_create(\u0026#34;mbuf_pool\u0026#34;, 09 NUM_MBUFS, 10 0, 11 0, 12 RTE_MBUF_DEFAULT_BUF_SIZE, 13 rte_socket_id()); 14 15 if (mbuf_pool == NULL) { 16 rte_exit(EXIT_FAILURE, 17 \u0026#34;Could not create mbuf pool\\n\u0026#34;); 18 } 第 7 行：声明 mempool 指针，用来保存创建结果。 第 8 行：池名称用于 DPDK 内部查找和调试，同一进程中应保持唯一。 第 9 行：池中创建 NUM_MBUFS 个 mbuf 对象。 第 10 行：per-lcore cache 大小设为 0，逻辑简单，但性能实验中通常会选择合适缓存值。 第 11 行：每个 mbuf 不添加额外的应用私有区。 第 12 行：每个 mbuf 的数据缓冲区使用 DPDK 默认大小。 第 13 行：在当前执行 lcore 所在 NUMA Socket 分配池内存。 第 15～18 行：创建失败时不能继续配置 RX Queue。 11. 初始化网口并进入无限循环 # 19 ustack_init_port(mbuf_pool); 20 21 while (1) { 22 struct rte_mbuf *mbufs[BURST_SIZE] = {0}; 第 19 行：把刚创建的 mempool 交给端口初始化函数，并建立 RX/TX Queue。 第 21 行：DPDK 常使用轮询模式，所以程序持续运行，不断检查 RX Queue。 第 22 行：在栈上创建一个数组，用来保存本次批量取回的 mbuf 指针。数组中不是报文数据本身，而是指向 mbuf 的指针。 = {0} 会把所有数组元素初始化为空指针；虽然接下来有效元素会被 API 覆盖，但初始化有利于调试。 12. 从 RX Queue 批量取包 # 23 uint16_t num_recvd = 24 rte_eth_rx_burst(global_portid, 25 0, 26 mbufs, 27 BURST_SIZE); 28 29 for (uint16_t i = 0; i \u0026lt; num_recvd; i++) { 30 struct rte_mbuf *rx_mbuf = mbufs[i]; 31 uint32_t frame_len = rte_pktmbuf_pkt_len(rx_mbuf); 第 23～27 行：从 0 号端口、0 号 RX Queue 最多取 BURST_SIZE 个包。 返回 0 表示当前没有包，不是错误；while 会立即进入下一轮轮询。 返回值不会大于传入的 BURST_SIZE，所以原草稿中的 num_recvd \u0026gt; BURST_SIZE 棬查没有实际意义。 第 29 行：只遍历 API 确认有效的前 num_recvd 个元素。 第 30 行：为当前包取一个更易读的局部变量名。 第 31 行：读取整个包的实际字节数，后面定位 Header 前必须先检查长度。 13. 检查并定位 Ethernet Header # 32 if (frame_len \u0026lt; sizeof(struct rte_ether_hdr)) { 33 rte_pktmbuf_free(rx_mbuf); 34 continue; 35 } 36 37 struct rte_ether_hdr *ethhdr = 38 rte_pktmbuf_mtod(rx_mbuf, 39 struct rte_ether_hdr *); 40 41 if (ethhdr-\u0026gt;ether_type != 42 rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)) { 43 rte_pktmbuf_free(rx_mbuf); 44 continue; 45 } 第 32 行：报文连一个 Ethernet Header 都放不下时，不能继续强制转换并读取字段。 第 33 行：当前 mbuf 没有交给 TX，应用仍拥有它，因此需要释放。 第 34 行：跳过当前包，继续处理 Burst 中的下一个包。 第 37～39 行：rte_pktmbuf_mtod() 取得 mbuf 数据起始地址，并转换为 Ethernet Header 指针。 第 41～42 行：EtherType 等于 IPv4 才进入本实验的 IPv4 处理路径。 第 43～44 行：非 IPv4 包当前不处理，但仍必须释放 mbuf。 rte_pktmbuf_mtod() 只是做地址取得与类型转换，不会自动校验数据是否足够长，所以第 32 行不能省略。\n14. 检查并定位 IPv4 Header # 46 uint32_t min_ipv4_frame = 47 sizeof(struct rte_ether_hdr) + 48 sizeof(struct rte_ipv4_hdr); 49 50 if (frame_len \u0026lt; min_ipv4_frame) { 51 rte_pktmbuf_free(rx_mbuf); 52 continue; 53 } 54 55 struct rte_ipv4_hdr *iphdr = 56 rte_pktmbuf_mtod_offset( 57 rx_mbuf, 58 struct rte_ipv4_hdr *, 59 sizeof(struct rte_ether_hdr)); 60 61 uint16_t ip_hdr_len = 62 (iphdr-\u0026gt;version_ihl \u0026amp; 0x0f) * 4; 63 64 if (ip_hdr_len \u0026lt; sizeof(struct rte_ipv4_hdr) || 65 frame_len \u0026lt; sizeof(struct rte_ether_hdr) + 66 ip_hdr_len) { 67 rte_pktmbuf_free(rx_mbuf); 68 continue; 69 } 第 46～48 行：计算 Ethernet Header 加最小 IPv4 Header 需要多少字节。 第 50～53 行：实际帧比这个值短时直接丢弃。 第 55～59 行：从 mbuf 数据起点偏移一个 Ethernet Header，取得 IPv4 Header 地址。 第 61～62 行：IPv4 IHL 位于 version_ihl 的低 4 位，单位是 4 字节。 第 64 行：IHL 小于 20 字节不合法。 第 65～66 行：实际帧必须能容纳 IHL 声明的完整 IPv4 Header。 第 67～68 行：格式不合法就释放并跳过。 当前示例默认 Header 位于 mbuf 的第一个连续数据段。若接收的是 multi-segment mbuf，直接取指针前还要确保所需字节连续，或使用 rte_pktmbuf_read()。\n15. 根据 IHL 定位 UDP 或 TCP Header # 70 uint8_t *l4 = (uint8_t *)iphdr + ip_hdr_len; 71 72 if (iphdr-\u0026gt;next_proto_id == IPPROTO_UDP) { 73 if (frame_len \u0026lt; 74 sizeof(struct rte_ether_hdr) + ip_hdr_len + 75 sizeof(struct rte_udp_hdr)) { 76 rte_pktmbuf_free(rx_mbuf); 77 continue; 78 } 79 80 struct rte_udp_hdr *udphdr = 81 (struct rte_udp_hdr *)l4; 82 /* 进入 UDP Echo 处理 */ 83 84 } else if (iphdr-\u0026gt;next_proto_id == IPPROTO_TCP) { 85 if (frame_len \u0026lt; 86 sizeof(struct rte_ether_hdr) + ip_hdr_len + 87 sizeof(struct rte_tcp_hdr)) { 88 rte_pktmbuf_free(rx_mbuf); 89 continue; 90 } 91 92 struct rte_tcp_hdr *tcphdr = 93 (struct rte_tcp_hdr *)l4; 94 /* 进入 TCP 状态处理 */ 95 } 第 70 行：不能总用 iphdr + 1 找四层 Header，因为 IPv4 可能带 Options；这里使用前面算出的真实 IHL。 第 72 行：next_proto_id 表示 IPv4 payload 使用哪种上层协议。 第 73～78 行：读取 UDP Header 之前，确认帧中至少有 8 字节 UDP Header。 第 80～81 行：把四层起点解释为 UDP Header。 第 84 行：如果不是 UDP，再检查是否为 TCP。 第 85～90 行：TCP 基础 Header 至少 20 字节，读取前同样检查实际长度。 第 92～93 行：把四层起点解释为 TCP Header。 到这里，程序才安全地取得 UDP 或 TCP Header。下面开始解释各协议的响应构造。\n16. 收到 UDP 包后保存响应方向 # 01 if (iphdr-\u0026gt;next_proto_id == IPPROTO_UDP) { 02 struct rte_udp_hdr *udphdr = 03 (struct rte_udp_hdr *)((uint8_t *)iphdr + ip_hdr_len); 04 05 rte_memcpy(global_smac, 06 ethhdr-\u0026gt;dst_addr.addr_bytes, 07 RTE_ETHER_ADDR_LEN); 08 rte_memcpy(global_dmac, 09 ethhdr-\u0026gt;src_addr.addr_bytes, 10 RTE_ETHER_ADDR_LEN); 11 12 global_sip = iphdr-\u0026gt;dst_addr; 13 global_dip = iphdr-\u0026gt;src_addr; 14 global_sport = udphdr-\u0026gt;dst_port; 15 global_dport = udphdr-\u0026gt;src_port; 第 1 行：只进入 UDP 分支。 第 2～3 行：把 IPv4 Header 起点加上刚计算出的 ip_hdr_len，定位 UDP Header；这样 IPv4 带 Options 时也不会找错位置。 第 5～7 行：请求包的目的 MAC 是本机 MAC，它应成为响应包的源 MAC。 第 8～10 行：请求包的源 MAC 是客户端 MAC，它应成为响应包的目的 MAC。 第 12～13 行：用直接赋值对调 IPv4 地址，避免误写复制长度。 第 14～15 行：对调 UDP 端口，使响应发回请求客户端。 这里的“对调”不是在原协议头中原地交换，而是先保存响应方向，稍后写入一个新的 TX mbuf。\n17. 计算收到的 UDP 数据长度 # 16 uint16_t udp_len = 17 rte_be_to_cpu_16(udphdr-\u0026gt;dgram_len); 18 19 if (udp_len \u0026lt; sizeof(struct rte_udp_hdr)) { 20 rte_pktmbuf_free(mbufs[i]); 21 continue; 22 } 23 24 uint16_t payload_len = 25 udp_len - sizeof(struct rte_udp_hdr); 26 27 uint8_t *payload = (uint8_t *)(udphdr + 1); 28 uint16_t ip_len = 29 sizeof(struct rte_ipv4_hdr) + udp_len; 30 uint16_t frame_len = 31 sizeof(struct rte_ether_hdr) + ip_len; 第 16～17 行：从 UDP Header 读取总长度，并转换为主机字节序，方便做减法和比较。 第 19 行：合法 UDP 长度至少为 8 字节，即一个 UDP Header。 第 20 行：这个示例假定当前分支负责释放收到的 mbuf。若外层统一释放，此处不能再次释放。 第 24～25 行：UDP payload 长度等于 UDP 总长度减去 UDP Header。 第 27 行：udphdr + 1 跨过一个完整的 UDP Header，指向 payload 起始位置。 第 28～29 行：IPv4 总长度由 IPv4 Header 和整个 UDP 数据报组成。 第 30～31 行：完整帧长度再加上 Ethernet Header。 在使用 UDP Header 声明的长度前，还必须确认它没有超过 IPv4 total_length 和 RX mbuf 的实际数据范围。前面的 Header 存在性检查只能证明 UDP Header 本身可读，不能证明对方声明的整个 payload 都真实存在。\n18. 为响应包分配 TX mbuf # 32 struct rte_mbuf *tx_mbuf = 33 rte_pktmbuf_alloc(mbuf_pool); 34 35 if (tx_mbuf == NULL) { 36 printf(\u0026#34;rte_pktmbuf_alloc failed\\n\u0026#34;); 37 continue; 38 } 39 40 uint8_t *frame = 41 rte_pktmbuf_append(tx_mbuf, frame_len); 42 43 if (frame == NULL) { 44 rte_pktmbuf_free(tx_mbuf); 45 continue; 46 } 第 32～33 行：从创建好的 mempool 中取一个 mbuf，用于保存响应包。 第 35 行：mempool 耗尽时分配会失败，不能继续解引用空指针。 第 40～41 行：在 mbuf 尾部追加 frame_len 字节，并取得可写的数据地址。 第 41 行：rte_pktmbuf_append() 同时更新 data_len 和 pkt_len，比手工写两个长度字段更安全。 第 43～45 行：如果 mbuf 的可用尾部空间不足，释放刚分配的 mbuf。 这里选择“新建 TX mbuf”，优点是 RX 包保持不变，逻辑直观。后续追求性能时，也可以在满足可写、连续性和所有权条件的前提下原地改写 RX mbuf 再发送。\n19. 逐层写入 UDP 响应包 # 47 struct rte_ether_hdr *eth = 48 (struct rte_ether_hdr *)frame; 49 50 rte_memcpy(eth-\u0026gt;dst_addr.addr_bytes, 51 global_dmac, 52 RTE_ETHER_ADDR_LEN); 53 rte_memcpy(eth-\u0026gt;src_addr.addr_bytes, 54 global_smac, 55 RTE_ETHER_ADDR_LEN); 56 eth-\u0026gt;ether_type = 57 rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4); 第 47～48 行：把帧首地址解释成 Ethernet Header。 第 50～52 行：写目的 MAC，即原请求的源 MAC。 第 53～55 行：写源 MAC，即原请求的目的 MAC。 第 56～57 行：声明 Ethernet payload 是 IPv4。EtherType 是 16 位字段，要使用网络字节序。 接着写 IPv4 Header：\n58 struct rte_ipv4_hdr *ip = 59 (struct rte_ipv4_hdr *)(eth + 1); 60 *ip = (struct rte_ipv4_hdr){0}; 61 62 ip-\u0026gt;version_ihl = RTE_IPV4_VHL_DEF; 63 ip-\u0026gt;total_length = rte_cpu_to_be_16(ip_len); 64 ip-\u0026gt;time_to_live = 64; 65 ip-\u0026gt;next_proto_id = IPPROTO_UDP; 66 ip-\u0026gt;src_addr = global_sip; 67 ip-\u0026gt;dst_addr = global_dip; 68 ip-\u0026gt;hdr_checksum = 0; 69 ip-\u0026gt;hdr_checksum = rte_ipv4_cksum(ip); 第 58～59 行：eth + 1 跨过 Ethernet Header，得到 IPv4 Header 地址。 第 60 行：先把整个 Header 清零，避免未初始化字段影响报文和 checksum。 第 62 行：写入 IPv4 与 20 字节基础头长度。 第 63 行：IPv4 总长度不包含 Ethernet Header。 第 64 行：TTL 设置为 64。 第 65 行：声明上层协议为 UDP。 第 66～67 行：写入已经对调好的源/目的 IPv4 地址。 第 68 行：计算 IPv4 Header checksum 前先把 checksum 字段清零。 第 69 行：计算并写入新的 IPv4 Header checksum。 最后写 UDP Header 和 payload：\n70 struct rte_udp_hdr *udp = 71 (struct rte_udp_hdr *)(ip + 1); 72 73 udp-\u0026gt;src_port = global_sport; 74 udp-\u0026gt;dst_port = global_dport; 75 udp-\u0026gt;dgram_len = rte_cpu_to_be_16(udp_len); 76 udp-\u0026gt;dgram_cksum = 0; 77 78 rte_memcpy(udp + 1, payload, payload_len); 79 udp-\u0026gt;dgram_cksum = 80 rte_ipv4_udptcp_cksum(ip, udp); 第 70～71 行：基础 IPv4 Header 后紧跟 UDP Header。 第 73～74 行：写入对调后的端口；它们已经是网络字节序。 第 75 行：UDP 长度包括 UDP Header 和 UDP payload。 第 76 行：计算 UDP checksum 前先清零。 第 78 行：只复制 payload_len，不能复制包含 UDP Header 的 udp_len。 第 79～80 行：使用 IPv4 伪首部、UDP Header 和 payload 计算 UDP checksum。 20. 发送 UDP 响应并处理所有权 # 81 uint16_t nb_tx = 82 rte_eth_tx_burst(global_portid, 83 0, 84 \u0026amp;tx_mbuf, 85 1); 86 87 if (nb_tx == 0) { 88 rte_pktmbuf_free(tx_mbuf); 89 } 90 } 第 81～85 行：请求 0 号 TX Queue 发送一个 mbuf。 第 84 行：参数必须是 \u0026amp;tx_mbuf，即“mbuf 指针数组的首地址”。只有一个包时，单个指针的地址可以充当长度为 1 的数组。 第 85 行：本次希望发送 1 个包。 第 87 行：若返回 0，说明驱动没有接管这个 mbuf。 第 88 行：发送失败时所有权仍属于应用，所以由应用释放。 若返回 1，不能在这里释放；后续由驱动在发送完成后回收。 外层还必须释放没有转交给 TX 的 RX mbuf。若选择原地改写 RX mbuf 并成功发送，则该 RX mbuf 的所有权已经交给 TX，不能再按接收包释放一次。\n21. TCP 状态与每条连接需要保存的数据 # 草稿中使用一个全局状态：\n01 uint8_t tcp_status = USTACK_TCP_STATUS_LISTEN; 02 uint32_t global_seqnum; 03 uint32_t global_acknum; 04 uint32_t server_isn = 12345; 第 1 行：服务端从监听状态开始。 第 2 行：保存收到报文的 SEQ，转换为主机字节序后便于加减。 第 3 行：保存收到报文的 ACK。 第 4 行：教学代码使用固定初始序列号；真实实现应生成合适的 ISN。 这些字段实际上都属于一条 TCP 连接。支持多个客户端时，应把它们放入连接控制块：\nstruct tcp_control_block { uint32_t local_ip; uint32_t remote_ip; uint16_t local_port; uint16_t remote_port; uint32_t snd_nxt; uint32_t rcv_nxt; ustack_tcp_status_t state; }; 前四项构成连接四元组，收到 TCP 包后用四元组查找对应状态，而不是共享一个全局 tcp_status。\n22. 收到 TCP 包并读取关键字段 # 01 } else if (iphdr-\u0026gt;next_proto_id == IPPROTO_TCP) { 02 struct rte_tcp_hdr *tcphdr = 03 (struct rte_tcp_hdr *)(iphdr + 1); 04 05 global_sip = iphdr-\u0026gt;dst_addr; 06 global_dip = iphdr-\u0026gt;src_addr; 07 global_sport = tcphdr-\u0026gt;dst_port; 08 global_dport = tcphdr-\u0026gt;src_port; 09 10 uint8_t flags = tcphdr-\u0026gt;tcp_flags; 11 uint32_t peer_seq = 12 rte_be_to_cpu_32(tcphdr-\u0026gt;sent_seq); 13 uint32_t peer_ack = 14 rte_be_to_cpu_32(tcphdr-\u0026gt;recv_ack); 第 1 行：进入 TCP 分支。 第 2～3 行：教学版本仍假设 IPv4 Header 固定为 20 字节。 第 5～8 行：对调 IP 和端口，准备构造服务端响应。 第 10 行：TCP Flags 只有一个字节，不需要字节序转换。 第 11～12 行：SEQ 是 32 位字段，转换为主机字节序后保存。 第 13～14 行：ACK 同样转换为主机字节序。 TCP 响应同样需要保存 MAC 方向：\nrte_memcpy(global_smac, ethhdr-\u0026gt;dst_addr.addr_bytes, RTE_ETHER_ADDR_LEN); rte_memcpy(global_dmac, ethhdr-\u0026gt;src_addr.addr_bytes, RTE_ETHER_ADDR_LEN); 第一段把收到包的目的 MAC 保存为响应源 MAC；第二段把收到包的源 MAC 保存为响应目的 MAC。\n23. LISTEN 状态收到 SYN # 15 if ((flags \u0026amp; RTE_TCP_SYN_FLAG) != 0 \u0026amp;\u0026amp; 16 tcp_status == USTACK_TCP_STATUS_LISTEN) { 17 18 uint32_t ack_to_peer = peer_seq + 1; 19 send_syn_ack(server_isn, ack_to_peer); 20 tcp_status = USTACK_TCP_STATUS_SYN_RCVD; 21 } 第 15 行：使用按位与检查 SYN 标志是否存在，因为一个 TCP 包可以同时带多个 Flags。 第 16 行：只有监听状态才把这个 SYN 当作新连接请求。 第 18 行：SYN 占用一个序列号，因此响应 ACK 为客户端 SEQ+1。 第 19 行：构造并发送 SYN | ACK 报文。 第 20 行：发送后进入 SYN_RCVD，等待三次握手的最后一个 ACK。 学习版还应继续改进：如果 SYN+ACK 实际没有成功进入 TX Queue，不能直接认为它已经发送；收到重复 SYN 时也应考虑重发 SYN+ACK。\n24. 逐行构造 SYN+ACK # Ethernet 和 IPv4 Header 的写法与 UDP 响应相同，只需把 IP 上层协议及总长度改为 TCP：\n01 uint16_t tcp_len = sizeof(struct rte_tcp_hdr); 02 uint16_t ip_len = sizeof(struct rte_ipv4_hdr) + tcp_len; 03 uint16_t frame_len = sizeof(struct rte_ether_hdr) + ip_len; 04 05 ip-\u0026gt;total_length = rte_cpu_to_be_16(ip_len); 06 ip-\u0026gt;next_proto_id = IPPROTO_TCP; 07 ip-\u0026gt;hdr_checksum = 0; 08 ip-\u0026gt;hdr_checksum = rte_ipv4_cksum(ip); 09 10 struct rte_tcp_hdr *tcp = 11 (struct rte_tcp_hdr *)(ip + 1); 12 *tcp = (struct rte_tcp_hdr){0}; 13 14 tcp-\u0026gt;src_port = global_sport; 15 tcp-\u0026gt;dst_port = global_dport; 16 tcp-\u0026gt;sent_seq = rte_cpu_to_be_32(server_isn); 17 tcp-\u0026gt;recv_ack = rte_cpu_to_be_32(peer_seq + 1); 18 tcp-\u0026gt;data_off = 19 (sizeof(struct rte_tcp_hdr) / sizeof(uint32_t)) \u0026lt;\u0026lt; 4; 20 tcp-\u0026gt;tcp_flags = RTE_TCP_SYN_FLAG | RTE_TCP_ACK_FLAG; 21 tcp-\u0026gt;rx_win = rte_cpu_to_be_16(TCP_INIT_WINDOW); 22 tcp-\u0026gt;cksum = 0; 23 tcp-\u0026gt;cksum = rte_ipv4_udptcp_cksum(ip, tcp); 第 1 行：SYN+ACK 不带 payload，本例 TCP 段只有基础 TCP Header。 第 2～3 行：依次得到 IPv4 总长度与完整帧长度，用于 Header 和 mbuf。 第 5 行：IPv4 总长度包含 IPv4 Header 与 TCP Header，不含 Ethernet Header。 第 6 行：把 IPv4 上层协议改为 TCP。 第 7～8 行：写完影响 IPv4 Header 的字段后重新计算 Header checksum。 第 10～11 行：TCP Header 紧跟在基础 IPv4 Header 后面。 第 12 行：清零整个 TCP Header，避免窗口、紧急指针等字段含有随机值。 第 14～15 行：写入对调后的端口。 第 16 行：服务端第一次发送 SYN，SEQ 等于本端 ISN。 第 17 行：确认客户端的 SYN，所以 ACK 等于客户端 SEQ+1。 第 18～19 行：TCP Header 长度以 32 位字为单位，并放在 data_off 的高 4 位。 第 20 行：同时置位 SYN 与 ACK。 第 21 行：窗口是 16 位字段，要转换为网络字节序。 第 22～23 行：清零后计算覆盖 IPv4 伪首部和 TCP 段的 checksum。 完成 Header 后，仍要像 UDP 一样分配/扩展 mbuf、调用 rte_eth_tx_burst() 并处理未发送的 mbuf。\n25. 验证三次握手最后一个 ACK # 01 if ((flags \u0026amp; RTE_TCP_ACK_FLAG) != 0 \u0026amp;\u0026amp; 02 tcp_status == USTACK_TCP_STATUS_SYN_RCVD \u0026amp;\u0026amp; 03 peer_ack == server_isn + 1) { 04 05 tcp_status = USTACK_TCP_STATUS_ESTABLISHED; 06 printf(\u0026#34;enter established\\n\u0026#34;); 07 } 第 1 行：检查 ACK 标志存在。 第 2 行：只有已经发送 SYN+ACK 的连接才能通过这个分支建立。 第 3 行：客户端必须确认服务端的 SYN；因为 SYN 占一个序列号，所以期望值是 server_isn + 1。 第 5 行：状态迁移到 ESTABLISHED。 第 6 行：打印日志，方便结合抓包确认状态变化时机。 草稿最初只检查“有 ACK 标志且当前是 SYN_RCVD”，没有验证 ACK 数值。补上第 3 行后，三次握手判断才更完整。\n26. 计算 TCP payload，而不是只检查 PSH # 01 uint16_t ip_hdr_len = 02 (iphdr-\u0026gt;version_ihl \u0026amp; 0x0f) * 4; 03 uint16_t tcp_hdr_len = 04 (tcphdr-\u0026gt;data_off \u0026gt;\u0026gt; 4) * 4; 05 uint16_t ip_total_len = 06 rte_be_to_cpu_16(iphdr-\u0026gt;total_length); 07 08 if (ip_total_len \u0026lt; ip_hdr_len + tcp_hdr_len) { 09 continue; 10 } 11 12 uint16_t payload_len = 13 ip_total_len - ip_hdr_len - tcp_hdr_len; 14 uint8_t *payload = 15 (uint8_t *)tcphdr + tcp_hdr_len; 16 17 if (tcp_status == USTACK_TCP_STATUS_ESTABLISHED \u0026amp;\u0026amp; 18 payload_len \u0026gt; 0) { 19 printf(\u0026#34;tcp data: %.*s\\n\u0026#34;, 20 (int)payload_len, 21 (char *)payload); 22 } 第 1～2 行：从 IHL 低 4 位得到 IPv4 Header 的 32 位字数量，再乘 4 得到字节数。 第 3～4 行：从 data_off 高 4 位得到 TCP Header 的字节数，因此 TCP Options 也会被正确跳过。 第 5～6 行：读取 IPv4 Header 声明的整个 IP 包长度。 第 8～10 行：总长度不能比两个 Header 相加还小，否则减法会发生无符号下溢。 第 12～13 行：用 IP 总长度减去两个 Header，得到 TCP payload 长度。 第 14～15 行：根据真实 TCP Header 长度定位 payload，而不是固定写 tcphdr + 1。 第 17 行：只有已建立连接才把数据交给应用层。 第 18 行：以实际 payload 长度判断是否有数据，不依赖 PSH 标志。 第 19～21 行：使用带长度的格式打印，避免把网络数据误当作以 \\0 结尾的字符串。 收到数据后还应该回复 ACK：\nack_to_peer = peer_seq + payload_len; 如果该段带 SYN 或 FIN，还应为每个标志额外加 1。当前草稿尚未实现数据 ACK，因此还只是握手与数据解析原型。\n27. RX mbuf 的最终释放位置 # 接收循环应为每个包建立唯一、清楚的释放路径。使用新 TX mbuf 构造响应时，可以在本轮处理结束后释放 RX mbuf：\nfor (uint16_t i = 0; i \u0026lt; num_recvd; i++) { bool rx_forwarded_to_tx = false; /* 解析并处理 mbufs[i] */ if (!rx_forwarded_to_tx) { rte_pktmbuf_free(mbufs[i]); } } 当前 UDP/TCP 示例新建了 tx_mbuf，所以原 mbufs[i] 没有转交给 TX，应由接收循环释放。草稿中缺少这一步，会导致 RX mbuf 持续泄漏，最终耗尽 mempool。\n如果以后改为原地改写并发送 RX mbuf，则发送成功后应把 rx_forwarded_to_tx 设为 true，防止重复释放。\n十四、怎样验证实现是否正确 # 建议按层验证，不要一上来同时调 UDP 和 TCP。\n阶段一：只验证 TX Queue # 网口成功配置一个 TX Queue； rte_eth_tx_burst() 返回 1； 抓包能看到 DPDK 网卡发出的帧。 阶段二：验证 UDP Echo # 请求和响应的 MAC、IP、Port 方向相反； Echo payload 与请求完全一致； IPv4 total_length、UDP dgram_len 正确； Wireshark 未报告 IPv4/UDP checksum 错误； 连续发送大量数据时，mempool 可用数量没有持续下降。 若开启了 TX checksum offload，抓包点可能位于网卡真正计算 checksum 之前，抓包工具会显示“校验和错误”；需要结合 offload 配置和抓包位置判断，不能只看这一条提示。\n阶段三：验证 TCP 握手 # SYN 到达时，状态为 LISTEN； SYN+ACK 的 ACK 等于客户端 SEQ+1； 客户端最终 ACK 等于服务端 SEQ+1； 只有 ACK 校验通过后才进入 ESTABLISHED； 发送 payload 后，能正确计算 TCP Header 长度和 payload 长度。 常用观察点：\n源/目的 MAC 源/目的 IP 源/目的端口 EtherType / IP Protocol IP total_length UDP length 或 TCP data offset TCP flags / seq / ack checksum rte_eth_tx_burst 返回值 十五、把实验抽象成 pktgen 发包工具 # 完成 UDP/TCP 编码后，可以把“回包程序”进一步抽象为一个简单的发包工具。命令行可以设计为：\n./ustack-pktgen \\ --udp \\ --src-ip 192.168.1.10 \\ --dst-ip 192.168.1.20 \\ --src-port 10000 \\ --dst-port 20000 \\ --dst-mac 00:11:22:33:44:55 \\ --count 1000 \\ --payload \u0026#34;hello dpdk\u0026#34; TCP 模式可改用 --tcp，并增加：\n--flags syn|ack|psh|fin|rst --seq N --ack N --window N 程序结构可以拆成：\n参数解析 ├── 公共参数：源/目的 MAC、IP、数量、长度 ├── UDP 参数：源/目的端口、payload └── TCP 参数：flags、seq、ack、window ↓ 统一 packet description ↓ encode_ether + encode_ipv4 ↓ encode_udp 或 encode_tcp ↓ burst send + 统计 这个小工具的价值在于：\n为协议栈、网关、防火墙等项目构造可重复的测试流量； 精确控制五元组、TCP Flags、SEQ/ACK 等字段； 练习 DPDK 多包批量构造和发送； 后续可增加速率限制、不同包长、随机地址、时延与丢包统计。 在简历中应写清“实现了哪些能力”和“测得什么结果”，不要泛写成“实现 TCP/IP 协议栈”。例如：\n基于 DPDK 实现用户态 UDP Echo 与最小 TCP 服务端握手原型，完成 Ethernet/IPv4/UDP/TCP 报文构造、校验和计算、TCP 状态迁移及 mbuf 批量收发；进一步抽象可配置五元组与报文数量的流量生成工具，用于协议功能测试。\n若有实测结果，可以继续补充包长、队列数、CPU 核数、吞吐量和丢包率。\n十六、本篇回顾清单 # 读完代码后，应能不看资料回答这些问题：\n为什么配置了 RX Queue 仍不能发送数据？ 为什么把收到的 mbuf 原样送进 TX Queue 不构成 Echo？ UDP Echo 需要交换哪三层的哪些字段？ Ethernet FCS 与 IPv4/UDP/TCP checksum 分别由谁处理？ ip-\u0026gt;total_length、udp-\u0026gt;dgram_len、mbuf-\u0026gt;pkt_len 各自包含什么？ 为什么 payload 复制长度不能直接使用 UDP dgram_len？ rte_eth_tx_burst() 返回 0 时，mbuf 应由谁释放？ SYN 和 FIN 为什么会各占用一个序列号？ 为什么 TCP Header 没有类似 UDP 的长度字段？ 为什么仅凭 PSH 标志不能判断 TCP 包一定携带数据？ 为什么一个全局 tcp_status 无法支持多个 TCP 连接？ 当前原型距离完整 TCP 协议栈还缺哪些关键能力？ 十七、总结 # 本篇真正跨过的一步，是从“解析别人构造好的报文”走向“自己构造一个能被对端接受的报文”。\nUDP Echo 说明了发包的基本方法：\n分配 mbuf → 填各层 Header → 复制 payload → 写网络字节序 → 计算 checksum → 交给 TX Queue TCP 实验则进一步说明：协议栈不只是报文格式的集合，还必须维护状态和时序。能生成 SYN+ACK 只是起点；要成为完整 TCP 实现，还需要可靠传输、序列号校验、超时重传、窗口管理、连接表和连接关闭等机制。\n从工程实践看，本次代码最值得记住的不是某个固定 API，而是四条原则：\n每一层的长度边界必须算清楚； 所有多字节协议字段都要明确主机/网络字节序； checksum 计算前先清零，并明确由软件还是硬件负责； 任何 mbuf 都必须有清晰的所有权和释放路径。 ","date":"2026年8月17日","externalUrl":null,"permalink":"/MyBlog/p/dpdk-userspace-stack-udp-echo-tcp-handshake/","section":"Posts","summary":"","title":"DPDK 用户态协议栈（二）：从收包到 UDP Echo 与 TCP 握手","type":"posts"},{"content":"","date":"2026年8月17日","externalUrl":null,"permalink":"/MyBlog/tags/udp/","section":"Tags","summary":"","title":"UDP","type":"tags"},{"content":"学习目标：理解网卡、网卡驱动、TCP/IP 协议栈、POSIX Socket API 与应用程序之间的关系；搭建 DPDK 环境；使用 DPDK 收取以太网帧；逐步解析 Ethernet、IPv4、UDP、TCP；最终理解用户态协议栈需要补齐哪些能力。\n一、先建立完整的网络数据路径 # 可以把网络程序拆成五个逻辑模块：\n1. 网卡 NIC ↕ 2. 网卡驱动 Driver ↕ 3. TCP/IP 协议栈 ↕ 4. POSIX Socket API：socket / bind / listen / accept / recv / send ↕ 5. Application：Redis / Nginx / 自己编写的服务器 接收数据时，数据总体从左向右、从上向下流动；发送数据时方向相反。\n不过，必须区分下面两条不同的数据路径。\n1. Linux 内核协议栈路径 # 物理网络 → 网卡 → Linux 内核网卡驱动 → sk_buff → 内核 Ethernet/IP/TCP/UDP 协议栈 → Socket 接收缓冲区 → recv()/read() → 应用程序 应用程序使用的是 Linux 已经实现好的协议栈。Redis、Nginx 默认走的就是这条路径。\n2. DPDK 用户态路径 # 物理网络 → 网卡 → VFIO/UIO + DPDK PMD 用户态驱动 → RX Descriptor Ring → rte_mbuf → 自己实现或引入的用户态协议栈 → 自己实现的 Socket/POSIX 兼容层 → 应用程序 网卡绑定给 DPDK 使用的驱动后，该端口的普通数据收发路径通常绕过 Linux 内核网卡驱动和内核 TCP/IP 协议栈。DPDK 负责高速收发原始数据包，但 DPDK 本身不是 TCP/IP 协议栈。\n重要纠错：不能把 DPDK 主路径理解成“网卡 → DPDK → 内核驱动 → 内核 TCP/IP”。正常的 DPDK 用户态收包路径绕过内核协议栈。只有主动使用 KNI、TAP、virtio-user 等机制时，才会把选定的数据包重新送入内核。\n二、网卡是什么 # 1. 网卡的基本职责 # 网卡（NIC，Network Interface Card）连接计算机和网络介质，主要完成：\n接收和发送物理信号； 完成物理层编码、解码、时钟恢复等工作； 识别和生成以太网帧； 校验帧的 FCS； 按配置进行 MAC 地址过滤、VLAN 处理； 通过 DMA 在网卡和主机内存之间搬运数据； 一些网卡还能完成 checksum、TSO、LRO、RSS 等硬件卸载。 “网卡把模拟信号通过 AD/DA 转换为 0 和 1”可以帮助入门理解，但并不完全准确。现代以太网 PHY 会进行复杂的线路编码、调制、均衡和信号恢复，不能简单等同于普通 ADC/DAC。\n2. 网卡属于哪一层 # 网卡不是完全独立于分层模型之外。通常可以这样理解：\nPHY 部分主要工作在物理层； MAC 部分主要工作在数据链路层； checksum、TSO、RSS 等卸载能力会辅助更高层处理。 因此，说网卡“主要横跨物理层和数据链路层”更准确。\n3. DMA：网卡如何把数据交给内存 # 网卡不会要求 CPU 一个字节一个字节地读取数据。典型过程如下：\n驱动在内存中准备一组 RX 描述符和数据缓冲区； 驱动把这些内存地址告诉网卡； 网卡收到数据后，通过 DMA 直接把数据写入内存缓冲区； 网卡更新 RX 描述符，标记该数据包已经完成； CPU 再处理描述符所指向的数据。 这里的 RX 描述符通常按环形结构组织，所以常称为 RX Ring 或 Descriptor Ring。\n三、网卡驱动做什么 # 网卡驱动是操作系统或 DPDK 与具体网卡硬件之间的适配层。它通常负责：\n根据 PCI Vendor ID / Device ID 识别硬件； 初始化网卡寄存器； 配置 MAC、MTU、收发队列和硬件卸载； 分配 RX/TX Descriptor Ring； 建立 DMA 映射； 启停网卡； 处理链路状态和错误； 在中断模式或轮询模式下回收、提交数据包。 Linux 内核中有统一的网络设备框架，e1000、ixgbe、i40e、mlx5、vmxnet3 等具体驱动在这个框架下实现。DPDK 则提供 PMD（Poll Mode Driver），让用户态程序通过轮询直接与网卡队列交互。\n1. 中断模式与轮询模式 # 传统内核路径通常结合中断和 NAPI 轮询：\n流量较低时，网卡通过中断通知 CPU； 流量较高时，NAPI 暂时关闭频繁中断，改用批量轮询； 这样可以避免“中断风暴”。 DPDK 的典型方式是 Poll Mode：\nrte_eth_rx_burst(port_id, queue_id, mbufs, BURST_SIZE); 线程不断轮询指定的端口和队列，避免中断、调度和上下文切换开销。代价是即使没有数据，轮询线程也可能持续占用一个 CPU 核。\n2. 多队列网卡 # 多队列网卡拥有多个 RX/TX 队列。其价值在于：\n不同队列可以由不同 CPU 核或线程独立处理； 减少多个核心竞争同一个队列锁； 利用 RSS 把不同网络流分散到多个 RX 队列； 提升并行处理能力和总体吞吐量。 常见绑定关系是：\nRX Queue 0 → lcore 1 RX Queue 1 → lcore 2 RX Queue 2 → lcore 3 RX Queue 3 → lcore 4 RSS 通常根据源/目的 IP、源/目的端口、协议号计算哈希，将同一个流稳定地送到同一队列，避免同一 TCP 流的数据包在多个核心间乱序处理。\n重要纠错：多队列能显著提高并行性，但“不是多队列网卡就一定无法绑定 DPDK”并不准确。单队列设备也可能被支持，只是扩展能力差。能否绑定和运行取决于设备、PMD、IOMMU、VFIO/UIO 以及虚拟化环境等条件。\n3. 虚拟机中的 vmxnet3 # VMware 中可以在 .vmx 文件中指定：\nethernet0.virtualDev = \u0026#34;vmxnet3\u0026#34; ethernet0.wakeOnPcktRcv = \u0026#34;TRUE\u0026#34; vmxnet3 是 VMware 的半虚拟化高性能网卡，通常比模拟的 e1000 拥有更好的性能和多队列能力。修改配置前应关闭虚拟机，并确认实际使用的 ethernet0、ethernet1 与目标 MAC 地址相匹配。\n四、sk_buff、rte_mbuf 与 Ring # 1. Linux 的 sk_buff # Linux 内核用 struct sk_buff 描述网络包。它不是简单地把所有协议头复制一遍，而是保存：\n数据缓冲区的位置； 当前数据起点和末尾； MAC、网络层和传输层头的位置； 数据包长度； 所属设备； checksum、VLAN、路由等元数据； 链表等组织信息。 协议层之间传递的通常是 sk_buff 指针。各层通过移动或记录头部位置来解析数据，而不是每经过一层就复制整个包。\n2. 海量 sk_buff 如何组织 # “每包一个 sk_buff，海量包如何组织”不能只用一种数据结构回答。不同阶段使用不同结构：\n网卡收发描述符：环形队列 Ring； NAPI 待处理对象：调度列表； Socket 接收队列：链表/队列形式的 skb queue； TCP 乱序数据：红黑树等结构； TCP 重传数据：按发送顺序管理的队列/树； 内存对象分配：slab/slub 缓存。 所以，常用答案是“以 Ring 和各种队列为主，根据查找、排序、重传等需求再使用树结构”。\n3. DPDK 的 rte_mbuf # rte_mbuf 与 sk_buff 的定位相似：都用来描述一个数据包及其元数据。DPDK 通常从预先创建的 mempool 中分配 mbuf，以减少频繁动态分配的开销。\nmempool ├── rte_mbuf + data buffer ├── rte_mbuf + data buffer ├── rte_mbuf + data buffer └── ... 网卡通过 DMA 把数据写入 mbuf 对应的数据缓冲区。rte_eth_rx_burst() 返回的是已经装有数据的 mbuf 指针数组，因此 CPU 不需要再把整个数据包从内核复制到用户态。\n这通常称为零拷贝或少拷贝设计，但严格来说仍然可能发生 DMA、缓存行传输、跨 NUMA 访问以及应用层复制，不能把“零拷贝”理解成整个系统完全没有任何数据移动。\n五、协议栈是什么 # 协议栈是一组按层协作的协议实现。接收一个 UDP/IPv4 以太网帧时，可依次解析：\nEthernet Header → IPv4 Header → UDP Header → Application Payload 接收 TCP 时则是：\nEthernet Header → IPv4 Header → TCP Header → TCP Segment Payload 解析协议头只是用户态协议栈的第一步。一个真正可用的协议栈还需要实现 ARP、路由、校验和、分片重组、TCP 状态机、重传、拥塞控制、Socket 缓冲区等功能。\n六、以太网帧解析 # 不考虑 VLAN 标签时，Ethernet II 头部固定为 14 字节：\n0 5 6 11 12 13 +----------------+----------------+--------+ | 目的 MAC 6字节 | 源 MAC 6字节 | 类型 2 | +----------------+----------------+--------+ 常见 EtherType：\n0x0800：IPv4； 0x0806：ARP； 0x86DD：IPv6； 0x8100：802.1Q VLAN。 DPDK 中对应：\nstruct rte_ether_hdr *ethhdr = rte_pktmbuf_mtod(mbufs[i], struct rte_ether_hdr *); rte_pktmbuf_mtod() 的作用是把 mbuf 中的数据起始地址转换成指定类型的指针。它并没有复制以太网头，只是在同一段内存上按 struct rte_ether_hdr 的布局解释字节。\n判断是否为 IPv4：\nif (ethhdr-\u0026gt;ether_type != rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)) { continue; } 网络协议字段通常采用大端字节序，所以要进行主机字节序与网络字节序转换。\nVLAN 注意事项 # 代码直接把以太网头后的数据当作 IPv4，只适用于没有 VLAN 标签的帧。若 EtherType 为 0x8100，还要解析 4 字节 VLAN Header，然后才能找到真正的上层 EtherType。\n七、IPv4 解析 # IPv4 基础头部最少 20 字节，结构包括：\n+---------+---------+-------------------+ |Version | IHL | DSCP/ECN | Length | +---------+---------+-------------------+ | Identification | Flags/Fragment Offset| +---------------------------------------+ | TTL | Protocol | Header Checksum | +---------------------------------------+ | Source IPv4 Address | +---------------------------------------+ | Destination IPv4 Address | +---------------------------------------+ | Options（可选，IHL \u0026gt; 5 时存在） | +---------------------------------------+ 关键字段：\nVersion：IPv4 时为 4； IHL：头部长度，以 4 字节为单位；最小值为 5，即 20 字节； Total Length：整个 IPv4 数据报长度； Fragment Offset / Flags：分片相关； TTL：每经过一个路由器通常减 1； Protocol：上层协议，UDP=17，TCP=6，ICMP=1； Header Checksum：只校验 IPv4 头； Source/Destination Address：源和目的 IPv4 地址。 原代码通过固定的 14 字节 Ethernet Header 偏移找到 IPv4 头：\nstruct rte_ipv4_hdr *iphdr = rte_pktmbuf_mtod_offset( mbufs[i], struct rte_ipv4_hdr *, sizeof(struct rte_ether_hdr)); 判断 UDP：\nif (iphdr-\u0026gt;next_proto_id == IPPROTO_UDP) { IPv4 解析必须检查的内容 # 健壮的实现至少应检查：\nmbuf 中是否有完整的 Ethernet 和 IPv4 基础头； IPv4 Version 是否为 4； IHL 是否至少为 5； 包长度是否覆盖 IHL * 4； Total Length 是否合理； IPv4 Header Checksum 是否正确，或确认硬件已校验； 是否为分片； 再根据 Protocol 分发给 ICMP、UDP 或 TCP。 原代码使用 (iphdr + 1) 寻找 UDP 头，相当于默认 IPv4 头固定为 20 字节。若 IPv4 带 Options，IHL 会大于 5，UDP 头的真实起点应为 IP Header 起点 + IHL * 4。\n八、UDP 解析 # UDP 头固定为 8 字节：\n+-------------------+-------------------+ | Source Port | Destination Port | +-------------------+-------------------+ | UDP Length | UDP Checksum | +-------------------+-------------------+ | Payload ... | +---------------------------------------+ 关键字段：\nSource Port：源端口； Destination Port：目的端口； Length：UDP 头加 Payload 的总长度，最小为 8； Checksum：覆盖伪首部、UDP 头和 UDP 数据。 原代码：\nstruct rte_udp_hdr *udphdr = (struct rte_udp_hdr *)(iphdr + 1); printf(\u0026#34;udp:%s\\n \u0026#34;, (char *)(udphdr + 1)); 第一行把 IPv4 基础头后的地址解释为 UDP Header；第二行把 UDP Header 后的数据解释为 C 字符串并打印。\n为什么 %s 只适合当前演示 # 网络 Payload 不保证以 \\0 结尾，而 %s 会持续读取，直到遇到 \\0。因此实际代码必须：\n根据 UDP Length 计算 Payload 长度； 确认长度没有超过 mbuf/IPv4 数据长度； 使用带长度的输出方式，例如 %.*s； 对二进制协议应使用十六进制打印，而不是字符串打印。 当前 Packet Sender 发送的是短 ASCII 文本，并且内存后面恰好出现零字节，所以演示中可能正常输出，但这不是安全的通用处理方法。\n九、TCP 解析 # TCP 头最少为 20 字节，可带 Options，最长可达 60 字节。\n+-------------------+-------------------+ | Source Port | Destination Port | +---------------------------------------+ | Sequence Number | +---------------------------------------+ | Acknowledgment Number | +------+----------+---------------------+ |Offset| Flags | Window | +-----------------+---------------------+ | Checksum | Urgent Pointer | +---------------------------------------+ | Options（可选） | +---------------------------------------+ | Payload ... | +---------------------------------------+ 关键字段：\nSequence Number：本段第一个字节的序号； Acknowledgment Number：期望收到的下一个序号； Data Offset：TCP Header 长度，以 4 字节为单位； Flags：SYN、ACK、FIN、RST、PSH、URG 等； Window：接收窗口，参与流量控制； Checksum：覆盖 IPv4/IPv6 伪首部、TCP 头和 TCP 数据。 只把 TCP Header 拆出来还不能叫“实现 TCP”。至少还需要：\n四元组查找连接：源 IP、源端口、目的 IP、目的端口； TCP 状态机； 三次握手和四次挥手； 序列号和 ACK； 乱序重组； 超时重传； RTT/RTO 估计； 滑动窗口和流量控制； 拥塞控制； TIME_WAIT 等定时器管理。 TCP 状态机简图 # 服务器：CLOSED → LISTEN → SYN_RECEIVED → ESTABLISHED 客户端：CLOSED → SYN_SENT → ESTABLISHED 主动关闭的一方：ESTABLISHED → FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT → CLOSED 被动关闭的一方：ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSED 十、POSIX Socket API 与用户态协议栈 # POSIX API 是应用程序看到的接口：\nsocket()：创建 Socket； bind()：绑定本地 IP 和端口； listen()：把 TCP Socket 设为监听状态； accept()：取出一个已经建立的 TCP 连接； connect()：主动发起 TCP 连接； recv()/read()：读取接收数据； send()/write()：发送数据； close()：关闭 Socket。 1. 内核中的关系 # 在 Linux 内核协议栈中，文件描述符 fd 会关联到内核 Socket 对象。TCP 连接还对应一个传输控制块，可广义称为 TCB，其中保存：\n四元组； TCP 状态； 序列号； 收发窗口； 重传队列； 接收队列； 定时器等。 recv(fd, ...) 不是简单地“直接读取 TCB 某个位置”，而是通过 fd 找到 Socket，再从该 Socket 的接收缓冲区读取已经按序整理好的字节。如果没有数据，调用可能阻塞，或者在非阻塞模式下返回 EAGAIN。\n2. 用户态协议栈怎样提供 POSIX API # 如果应用完全绕过内核协议栈，Linux 原生 recv() 不会自动读取 DPDK mbuf。需要增加适配层，常见方法包括：\n修改应用，让它调用用户态协议栈自己的 API； 使用库替换或 LD_PRELOAD 截获 Socket 调用； 提供兼容 POSIX 的用户态 Socket 库； 通过事件通知机制实现类似 epoll 的接口。 用户态需要自己维护：\nfd/table → socket object → UDP endpoint 或 TCP TCB → receive queue / send queue 对于 UDP，recvfrom() 通常从对应端口的报文队列取出一个 Datagram；对于 TCP，recv() 从已经重排并确认连续的字节流中读取数据。\n十一、DPDK 是什么，以及为什么快 # DPDK（Data Plane Development Kit）是一组用于高速数据包处理的库、驱动和工具。它的核心思想包括：\n用户态 PMD 轮询网卡； 批量收发 Burst； 预分配 mempool/mbuf； Hugepage； CPU 亲和性和独占核心； NUMA 感知； 无锁 Ring； 硬件多队列与 RSS； Prefetch 和缓存友好布局； 减少中断、系统调用、上下文切换和数据复制。 DPDK 优化的核心指标 # DPDK 不只是“处理大包、提高吞吐量”。更准确地说，它常用于提高：\n每秒处理的数据包数 PPS，尤其是大量小包； 总吞吐量； 延迟的可预测性和尾延迟； 多核心扩展能力。 是否能提升 Redis/Nginx 性能，取决于瓶颈：\n如果瓶颈在业务逻辑、锁、存储或单线程执行，只有 DPDK 不会神奇地提高 QPS； 如果瓶颈在内核网络路径、软中断、系统调用或数据复制，用户态网络方案可能改善 QPS 或延迟； 把现有 Redis/Nginx 接到 DPDK 上需要完整协议栈和兼容层，并非简单绑定网卡即可。 常见使用场景 # 防火墙、ACL、DPI； 路由器、交换机、网关； 负载均衡器； 5G/电信数据面； 虚拟交换机； 流媒体或报文转发； 流量采集与监控； 高性能存储网络的数据面。 数据备份和 RDMA 可能使用类似的大页、DMA、零拷贝、用户态驱动思想，但 DPDK 与 RDMA 是不同的技术体系，不能简单说“底层一定用 DPDK，应用层用 RDMA”。\n可研究的用户态协议栈/数据面项目包括 mTCP、lwIP、F-Stack、VPP、Seastar networking、基于 BSD 协议栈移植的实现等。项目是否仍活跃、DPDK 版本是否兼容，需要按实际版本确认。\n十二、VFIO、UIO、PCI、KNI 与 NUMA # 1. PCI 地址 # 网卡通常是 PCIe 设备。可以使用：\nlspci lspci -nn lspci -k PCI 地址形如：\n0000:1a:00.0 含义是：\ndomain:bus:device.function 2. VFIO 与 UIO # 它们帮助用户态程序访问 PCI 设备资源：\nUIO：较简单的通用用户态 I/O 框架； VFIO：通常配合 IOMMU，隔离性和安全性更好，现代环境一般优先使用； DPDK PMD：真正理解并操作具体网卡寄存器和队列的用户态驱动。 不能简单理解为 VFIO/UIO“截获 PCI 地址”；更准确地说，它们把设备资源安全地映射或暴露给用户态驱动，并管理中断、DMA/IOMMU 等能力。\n3. KNI # KNI 曾用于在 DPDK 应用与 Linux 内核网络栈之间交换数据包。例如，把控制面流量送回内核处理。但它不是 DPDK 收包必须经过的组件，而且在较新的 DPDK 方案中通常会考虑 TAP、virtio-user 等替代路径。\n4. NUMA # NUMA 是 Non-Uniform Memory Access，即非一致内存访问。多路 CPU 服务器中，每个 CPU Socket 通常拥有本地内存：\nCPU Socket 0 ↔ Local Memory 0 CPU Socket 1 ↔ Local Memory 1 CPU 访问本地内存比跨 Socket 访问远端内存更快。因此应尽量做到：\n网卡所在 NUMA Node； 轮询该网卡的 CPU 核； mbuf mempool 所在内存； 三者位于同一个 NUMA Node。\n代码中的 rte_eth_dev_socket_id() 用于查询设备所在 NUMA Socket，rte_socket_id() 返回当前 lcore 所在 Socket。二者不一定总是相同。\n十三、Hugepage 大页 # 普通页通常为 4 KiB。Hugepage 常见规格为：\n2 MiB； 1 GiB。 DPDK 使用大页主要是为了：\n减少页表项数量； 降低 TLB Miss； 便于大块、稳定、可用于 DMA 的内存管理； 减少运行时内存分配的不确定性。 1. 示例 GRUB 配置 # GRUB_CMDLINE_LINUX_DEFAULT=\u0026#34;default_hugepagesz=2M hugepagesz=2M hugepages=1024\u0026#34; 这表示默认大页为 2 MiB，共预留 1024 页，即约 2 GiB 内存。虚拟机内存有限时，需要根据虚拟机总内存调整，不能机械照抄。\n通常修改后还需要更新 GRUB 并重启，例如 Ubuntu/Debian：\nsudo update-grub sudo reboot 重启后验证：\ngrep -i huge /proc/meminfo 具体命令因发行版和 DPDK 版本而异。现代 DPDK 可能自动处理 hugetlbfs，也可以手动挂载。1 GiB 大页还依赖 CPU 支持，并通常需要在启动参数中预留。\n2. Hugepage 不直接提升网卡物理转换速度 # Hugepage 优化的是主机内存管理、TLB 和 DMA 缓冲区访问，并不会让网卡的光电信号转换本身更快。它提升的是数据包在 CPU/内存数据面中的处理效率和稳定性。\n十四、VMware 三种网络模式 # 1. Bridged（桥接） # 虚拟机像局域网中的独立主机：\n物理局域网 ├── Windows/macOS 宿主机：172.26.185.5 └── 虚拟机：172.26.185.250 虚拟机通常与宿主机处于同一二层网络，可从局域网 DHCP 获取地址，或配置同网段静态地址。桥接绑定到某个宿主机物理接口时，该接口断开可能影响虚拟机网络。\n“复制物理网络连接状态”之类的选项，通常让虚拟网卡随宿主机物理接口的连接/断开状态变化，适合笔记本在 Wi-Fi、有线网络之间切换的场景。不同 VMware 产品中的名称和细节会略有不同。\n2. NAT # VMware 在宿主机上创建虚拟子网，虚拟机通过宿主机做 NAT 访问外网：\n虚拟机 → VMware 虚拟网关/NAT → 宿主机 → 外网 外部设备通常不能直接主动访问虚拟机，除非做端口映射或额外配置。\n3. Host-only（仅主机） # 虚拟机主要与宿主机及同一 Host-only 网络中的虚拟机通信，默认不能直接访问外网。它适合隔离实验环境。\n“Host-only 一定可以 ping 通公网”是不对的；默认情况下它不能上网，除非宿主机另行开启路由/NAT。\n十五、macOS Packet Sender 测试链路 # 已知 DPDK 端口信息：\nMAC：00:0c:29:aa:5b:f0 PCI：0000:1a:00.0 PMD：net_vmxnet3 Link：up 宿主机：\nIP：172.26.185.5 Mask：255.255.0.0 Interface：en0 ustack 测试地址：\n172.26.185.250 1. 为什么需要静态 ARP # 应用把 UDP 发往 172.26.185.250:8888 时，操作系统需要构造以太网帧。因为目标 IP 在同一子网，macOS 必须知道目标 IP 对应的 MAC 地址。\n正常情况是：\nmacOS 广播 ARP Request：谁是 172.26.185.250？ ustack 单播 ARP Reply：172.26.185.250 是 00:0c:29:aa:5b:f0 当前 ustack 没有实现 ARP Reply，所以手工建立：\nsudo arp -s 172.26.185.250 00:0c:29:aa:5b:f0 sudo arp -s 172.26.185.250 00:0c:29:aa:5b:f0 ifscope en0 验证：\narp -n 172.26.185.250 2. Packet Sender 配置 # Address：172.26.185.250 Port：8888 Protocol：UDP ASCII：hello dpdk 完整路径：\nPacket Sender → macOS UDP/IP 处理 → 静态 ARP 表查到目标 MAC → macOS en0 → VMware Fusion 桥接 → vmxnet3 虚拟网卡 → DPDK port 0 / RX queue 0 → rte_eth_rx_burst() → Ethernet / IPv4 / UDP 解析 → 打印 hello dpdk 看到：\nudp:hello dpdk 说明至少以下环节已经连通：\nmacOS 到 Fusion 桥接网络； 目标 MAC 投递； vmxnet3 链路； DPDK 端口初始化； RX Queue 0 收包； 当前测试报文的 Ethernet/IPv4/UDP 基础解析。 但它还不能单独证明 checksum、分片、VLAN、IP Options、异常长度处理等已经正确实现。\n十六、DPDK 环境搭建思路 # 老版本 DPDK 常使用 dpdk-setup.sh、RTE_SDK 和 RTE_TARGET。典型含义是：\nexport RTE_SDK=/home/.../dpdk export RTE_TARGET=... RTE_SDK：DPDK 源码/SDK 根目录； RTE_TARGET：旧版构建系统生成的目标目录名称。 native 一般表示按当前机器架构优化；linuxapp 是旧版目标名称的一部分；具体选项需要以所使用的旧版 DPDK 文档和脚本为准。\n现代 DPDK 已主要使用 Meson + Ninja 构建，不再依赖旧式 make config、RTE_TARGET 流程：\nmeson setup build ninja -C build 因此必须先确认教程使用的 DPDK 版本。不要把老教程里的菜单选项直接套到新版本。\n通用搭建检查表 # 确认 CPU 架构、Linux 发行版和 DPDK 版本； 确认 vmxnet3/物理网卡被当前 DPDK PMD 支持； 配置 Hugepage； 若使用 VFIO，确认 IOMMU/VFIO 条件； 查看 PCI 地址和当前驱动； 将实验网卡绑定到合适驱动； 保留管理网卡，避免把 SSH 所依赖的唯一网卡绑定走； 用 testpmd 检查端口、队列、Link 和收包计数； 再运行自己的程序。 安全提示：绑定网卡前一定确认 PCI 地址。将正在承载 SSH 或默认路由的网卡绑定给 DPDK，会立即让普通 Linux 网络连接中断。\n十七、原始代码（保持不变） # 下面代码完全按原内容保留，没有修改：\n#include\u0026lt;stdio.h\u0026gt; #include\u0026lt;rte_eal.h\u0026gt; #include\u0026lt;rte_ethdev.h\u0026gt; #include\u0026lt;arpa/inet.h\u0026gt; int global_portid=0;//网卡绑定id从0开始 发数据和收数据知道id就好 #define NUM_MBUFS 4096 #define BURST_SIZE 128 static const struct rte_eth_conf port_conf_default={ .rxmode={.max_rx_pkt_len=RTE_ETHER_MAX_LEN} }; static int ustack_init_port(struct rte_mempool *mbuf_pool){ uint16_t nb_sys_ports=rte_eth_dev_count_avail(); if(nb_sys_ports==0){ rte_exit(EXIT_FAILURE,\u0026#34;No Supported eth found/n\u0026#34;); } //struct rte_eth_dev_info dev_info; //rte_eth_dev_info_get(global_portid，\u0026amp;dev_info); //绑定的网卡 //现在有八个 队列 弄1 2 4 8 const int num_rx_queues =1; const int num_tx_queues =0; rte_eth_dev_configure(global_portid,num_rx_queues,num_tx_queues,\u0026amp;port_conf_default); if(rte_eth_rx_queue_setup(global_portid,0,128,rte_eth_dev_socket_id(global_portid),NULL,mbuf_pool)\u0026lt;0){ //在0号网卡的0号队列开辟了RX队列 准备了128个mbuffer rte_exit(EXIT_FAILURE,\u0026#34; Could not setup RX queue/n\u0026#34;); } if (rte_eth_dev_start(global_portid)\u0026lt;0){//启动 rte_exit(EXIT_FAILURE,\u0026#34;Could not start\u0026#34;); } return 0; } int main(int argc,char *argv[]){ if(rte_eal_init(argc,argv)\u0026lt;0){//初始化 检查网卡信息 配置 是否绑定 rte_exit(EXIT_FAILURE,\u0026#34;Error with EAL init\\n\u0026#34;); } struct rte_mempool *mbuf_pool= rte_pktmbuf_pool_create(\u0026#34;mbuf pool\u0026#34;,NUM_MBUFS,0,0,RTE_MBUF_DEFAULT_BUF_SIZE,rte_socket_id()); //接收数据放在mbuff //mbuffer分配在内存上的 rte_socket_id 就是当前使用哪块内存卡槽ID分配的 if(mbuf_pool==NULL){ rte_exit(EXIT_FAILURE,\u0026#34;Could not create mbuf pool\\n\u0026#34;); } ustack_init_port(mbuf_pool); //接收数据 while(1){ struct rte_mbuf *mbufs[BURST_SIZE]={0}; uint16_t num_recvd= rte_eth_rx_burst(global_portid,0,mbufs,BURST_SIZE); if (num_recvd\u0026gt;BURST_SIZE){ rte_exit(EXIT_FAILURE,\u0026#34;Error receiving from eth\\n\u0026#34;); } int i=0; for(i=0;i\u0026lt;num_recvd;i++){ struct rte_ether_hdr *ethhdr=rte_pktmbuf_mtod(mbufs[i],struct rte_ether_hdr *); if(ethhdr-\u0026gt;ether_type != rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)){ continue; } struct rte_ipv4_hdr *iphdr =rte_pktmbuf_mtod_offset(mbufs[i],struct rte_ipv4_hdr *,sizeof(struct rte_ether_hdr)); if (iphdr-\u0026gt;next_proto_id==IPPROTO_UDP){//两个以上都转 struct rte_udp_hdr *udphdr =(struct rte_udp_hdr *)(iphdr+1); printf(\u0026#34;udp:%s\\n \u0026#34;,(char*)(udphdr+1)); } } } printf(\u0026#34;Hello dpdk\\n\u0026#34;); } 十八、代码逐行对照理解 # 1. 头文件 # #include\u0026lt;stdio.h\u0026gt; 引入标准输入输出声明，本程序用它调用 printf()。\n#include\u0026lt;rte_eal.h\u0026gt; 引入 DPDK EAL 接口。EAL 是 Environment Abstraction Layer，负责 Hugepage、lcore、内存、PCI 设备等基础环境初始化，也声明了 rte_eal_init()、rte_exit() 等接口。\n#include\u0026lt;rte_ethdev.h\u0026gt; 引入以太网设备抽象接口，包括端口配置、RX Queue 设置、设备启动和 Burst 收包。\n#include\u0026lt;arpa/inet.h\u0026gt; 提供网络字节序、IP 地址转换等常用声明。当前代码中没有直接调用 htons()、ntohs() 等函数，但 IPPROTO_UDP 等定义可能通过相关系统头可见。实际项目通常还会显式包含 DPDK Ethernet/IP/UDP 头文件，具体取决于 DPDK 版本的包含关系。\n2. 全局端口与常量 # int global_portid=0; 选择 DPDK 逻辑端口 0。这里的 port id 是 DPDK 枚举后的端口编号，不等于 PCI 地址，也不一定等于 Linux 的 eth0。\n#define NUM_MBUFS 4096 准备在 mempool 中创建 4096 个 mbuf。它们不仅供 128 个 RX 描述符使用，还要留给收包后尚未释放、缓存以及其他内部需求。\n#define BURST_SIZE 128 每次调用 rte_eth_rx_burst()，最多希望取回 128 个包。实际返回值可以是 0 到 128。\n3. 默认端口配置 # static const struct rte_eth_conf port_conf_default={ .rxmode={.max_rx_pkt_len=RTE_ETHER_MAX_LEN} }; 创建端口配置结构，把最大接收包长设为标准以太网最大长度对应的 DPDK 常量。\n注意：DPDK 的结构体字段会随版本变化。某些新版本中 max_rx_pkt_len 的位置或用法已变化。如果发生编译错误，应首先对照当前版本 API，而不是认为网络原理有问题。\n4. 端口初始化函数 # static int ustack_init_port(struct rte_mempool *mbuf_pool){ 定义只在本源文件使用的函数，参数是已经创建好的 mbuf 内存池。\nuint16_t nb_sys_ports=rte_eth_dev_count_avail(); 查询当前 EAL 环境中可用的 DPDK Ethernet 设备数量。nb 是 number 的常用缩写。它不是固定表示“绑定网口数量”，更准确地说是当前可用的 ethdev 数量。\nif(nb_sys_ports==0){ rte_exit(EXIT_FAILURE,\u0026#34;No Supported eth found/n\u0026#34;); } 如果没有设备，打印错误并退出。字符串中的 /n 是普通字符，不是换行符；真正的换行符写法是 \\n。本笔记遵守要求，不修改原代码，只指出区别。\n//struct rte_eth_dev_info dev_info; //rte_eth_dev_info_get(global_portid，\u0026amp;dev_info); 这两行被注释，不会执行。设计意图是读取端口能力，比如最大队列数、RSS、offload 能力。第二行参数之间使用了中文逗号 ，，若取消注释需要注意它不能作为 C 语言逗号使用。\nconst int num_rx_queues =1; const int num_tx_queues =0; 配置 1 个接收队列、0 个发送队列。此程序只收不发，所以 TX Queue 设为 0。若以后要回复 ARP、UDP 或 TCP，就必须配置 TX Queue。\nrte_eth_dev_configure(global_portid,num_rx_queues,num_tx_queues,\u0026amp;port_conf_default); 配置端口的 RX/TX 队列数量和总体参数。函数有返回值，当前代码没有检查；健壮程序应检查是否小于 0。\nif(rte_eth_rx_queue_setup(global_portid,0,128, rte_eth_dev_socket_id(global_portid),NULL,mbuf_pool)\u0026lt;0){ 配置 RX Queue：\nglobal_portid：端口 0； 0：RX Queue 0； 128：描述符数量； rte_eth_dev_socket_id(...)：在网卡所在 NUMA Socket 分配队列资源； NULL：使用默认 RX Queue 配置； mbuf_pool：网卡收到的数据放入这个池提供的 mbuf。 注释“准备了 128 个 mbuffer”是便于理解的近似说法。准确说是请求建立 128 个 RX descriptors；描述符与 mbuf 的补充、占用和回收由 PMD 管理，mempool 总大小仍为 4096。\nrte_exit(EXIT_FAILURE,\u0026#34; Could not setup RX queue/n\u0026#34;); 配置失败就退出。同样，/n 不会产生换行。\nif (rte_eth_dev_start(global_portid)\u0026lt;0){ rte_exit(EXIT_FAILURE,\u0026#34;Could not start\u0026#34;); } 启动端口。配置端口和队列只是准备工作，start 后网卡才进入正式收发状态。\nreturn 0; 表示初始化成功。\n5. main 与 EAL 初始化 # int main(int argc,char *argv[]){ 程序入口。DPDK 会先解析命令行中的 EAL 参数，应用自己的参数通常放在 -- 后面并根据 rte_eal_init() 的返回值调整。\nif(rte_eal_init(argc,argv)\u0026lt;0){ rte_exit(EXIT_FAILURE,\u0026#34;Error with EAL init\\n\u0026#34;); } 初始化 DPDK 运行环境，涉及：\n解析 EAL 参数； 发现 CPU lcore； 初始化 Hugepage 内存； 初始化内存管理； 探测并初始化设备和总线； 建立主从 lcore 等运行环境。 它不等于“自动保证所有网卡已经正确绑定”。绑定通常要在运行程序之前完成，EAL 负责发现当前可使用的设备。\n6. 创建 mbuf pool # struct rte_mempool *mbuf_pool= rte_pktmbuf_pool_create(\u0026#34;mbuf pool\u0026#34;,NUM_MBUFS,0,0, RTE_MBUF_DEFAULT_BUF_SIZE,rte_socket_id()); 参数逐个理解：\n\u0026quot;mbuf pool\u0026quot;：内存池名称； NUM_MBUFS：4096 个 mbuf； 第一个 0：每个 lcore 的 cache 数量为 0； 第二个 0：每个对象额外私有区域大小为 0； RTE_MBUF_DEFAULT_BUF_SIZE：每个 mbuf 默认数据缓冲区大小； rte_socket_id()：在当前运行核心所在 NUMA Socket 分配。 这里 rte_socket_id() 指 NUMA 节点编号，不是物理“内存卡槽 ID”。服务器硬件的 DIMM 插槽和 NUMA Node 不是同一个概念。\nif(mbuf_pool==NULL){ rte_exit(EXIT_FAILURE,\u0026#34;Could not create mbuf pool\\n\u0026#34;); } 创建失败立即退出。可能原因包括 Hugepage 不足、名称冲突、socket 内存不够或参数不合法。\nustack_init_port(mbuf_pool); 用刚创建的内存池配置并启动端口。\n7. 轮询收包 # while(1){ 进入无限循环。这是 PMD 轮询模型的核心，线程会持续询问 RX Queue 是否有包。\nstruct rte_mbuf *mbufs[BURST_SIZE]={0}; 创建一个长度为 128 的指针数组。数组保存 rte_mbuf *，并不在栈上创建 128 个完整 mbuf。\nuint16_t num_recvd= rte_eth_rx_burst(global_portid,0,mbufs,BURST_SIZE); 从端口 0、队列 0 最多取 128 个已经收到的包：\n返回 0：当前没有包； 返回 1～128：实际取到的包数； mbuf 指针写入 mbufs[0 ... num_recvd-1]。 rte_eth_rx_burst() 通常通过检查 RX descriptors 批量完成工作，不是每次都触发系统调用。\nif (num_recvd\u0026gt;BURST_SIZE){ rte_exit(EXIT_FAILURE,\u0026#34;Error receiving from eth\\n\u0026#34;); } 按 API 契约，返回值不会大于传入的 BURST_SIZE，所以这个判断正常情况下不会成立。它也不能检测一般收包错误，因为该 API 的返回值语义主要是“本次收到多少包”。\n8. 遍历每一个包 # int i=0; for(i=0;i\u0026lt;num_recvd;i++){ 依次处理本轮收到的所有 mbuf。\nstruct rte_ether_hdr *ethhdr= rte_pktmbuf_mtod(mbufs[i],struct rte_ether_hdr *); 取得包数据起点，把它解释成 Ethernet Header。\nif(ethhdr-\u0026gt;ether_type != rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)){ continue; } 如果 EtherType 不是 IPv4，就跳过后续解析。ARP、IPv6、VLAN 等都会走 continue。\n这里有一个重要的生命周期问题：跳过不等于释放。当前程序没有调用 rte_pktmbuf_free(mbufs[i])，因此收到的 mbuf 不会归还 mempool。运行一段时间后，RX Queue 可能因为拿不到空闲 mbuf 而停止继续收包。\nstruct rte_ipv4_hdr *iphdr =rte_pktmbuf_mtod_offset( mbufs[i],struct rte_ipv4_hdr *,sizeof(struct rte_ether_hdr)); 从数据起点向后移动一个 Ethernet Header 的长度，即 14 字节，再把地址解释成 IPv4 Header。\n当前代码在解引用前没有检查包的实际长度；遇到截断帧或异常帧时可能越界读取。\nif (iphdr-\u0026gt;next_proto_id==IPPROTO_UDP){ 检查 IPv4 Protocol 字段是否等于 UDP 的协议号 17。\nstruct rte_udp_hdr *udphdr =(struct rte_udp_hdr *)(iphdr+1); iphdr + 1 会跳过一个 struct rte_ipv4_hdr，也就是按 20 字节基础 IPv4 头定位 UDP Header。它没有考虑 IHL 大于 5的 IPv4 Options。\nprintf(\u0026#34;udp:%s\\n \u0026#34;,(char*)(udphdr+1)); udphdr + 1 跳过 8 字节 UDP Header，将后续 Payload 当作字符串打印。它没有依据 UDP Length 限制读取长度，也没有保证 Payload 以 \\0 结尾，因此只适合受控的入门测试。\n9. 循环之后的代码 # printf(\u0026#34;Hello dpdk\\n\u0026#34;); 由于前面是无限循环，并且没有 break，正常情况下这一行不可达。\n十九、今天内容的核心总结 # 网卡把网络介质上的信号变成帧，并通过 DMA 把数据放入主机内存；它主要涉及物理层和数据链路层。 驱动负责初始化网卡、配置队列和 DMA，并让软件能够操作具体硬件。 Linux 使用 sk_buff 描述数据包；DPDK 使用 rte_mbuf，两者都不只是数据本身，还包含元数据。 多队列让多个 CPU 核并行处理流量，RSS 负责把流分配到不同队列；多队列很重要，但不是所有 DPDK 绑定的绝对前提。 DPDK 用用户态轮询、Burst、Hugepage、mempool、多队列和 NUMA 优化高速包处理。 网卡绑定给 DPDK 后，常规数据路径绕过内核 TCP/IP；因此要么引入现有用户态协议栈，要么自己实现。 Ethernet 普通头为 14 字节，IPv4 最少 20 字节，UDP 固定 8 字节，TCP 最少 20 字节。 “能找到协议头并打印 Payload”只是解析器原型，不等于完整协议栈。 完整 UDP 还需要 ARP、IP、校验和、端口分发、队列和 API；完整 TCP 还需要状态机、序列号、重传、窗口、定时器等。 POSIX recv() 读取的是 Socket 接收缓冲区；用户态协议栈必须自己建立 fd、Socket、TCB 和收发队列之间的关系。 当前测试通过静态 ARP 绕过了尚未实现的 ARP Reply，成功证明了 macOS、Fusion 桥接、vmxnet3、DPDK RX 和基础 UDP 解析链路已打通。 下一步最重要的不是立即写 TCP，而是先补齐 mbuf 释放、长度校验、ARP Reply、TX 和 UDP Echo。 二十、版本说明与官方参考 # 你的学习材料明显包含旧版 DPDK 构建流程，因此本笔记保留了 dpdk-setup.sh、RTE_SDK、RTE_TARGET 的历史解释，同时把现代版本单独说明。实际执行命令时，必须先运行 dpdk-testpmd -v、查看源码目录中的 release 信息或构建文件，确认本机 DPDK 版本。\nDPDK Linux Getting Started Guide：现代 Linux 环境、编译、驱动和运行入口； DPDK Poll Mode Driver：PMD 绕过传统内核网络栈、直接轮询 RX/TX Descriptor 的官方说明； DPDK Linux Drivers：VFIO、UIO、设备绑定和安全性说明； DPDK VMXNET3 PMD：vmxnet3 收发环、多队列/RSS、功能与限制； DPDK Hugepage Tool：查看、预留和挂载大页； DPDK 22.11 KNI 文档：KNI 的用途、弃用状态及 virtio-user 替代说明。 ","date":"2026年8月15日","externalUrl":null,"permalink":"/MyBlog/p/dpdk-userspace-network-stack/","section":"Posts","summary":"","title":"DPDK 用户态协议栈设计与实现","type":"posts"},{"content":"","date":"2026年8月15日","externalUrl":null,"permalink":"/MyBlog/tags/tcp/ip/","section":"Tags","summary":"","title":"TCP/IP","type":"tags"},{"content":"","date":"2026年8月11日","externalUrl":null,"permalink":"/MyBlog/tags/io-hook/","section":"Tags","summary":"","title":"IO Hook","type":"tags"},{"content":"","date":"2026年8月11日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%B1%87%E7%BC%96/","section":"Tags","summary":"","title":"汇编","type":"tags"},{"content":"本篇承接 yield、resume、ucontext 和简单轮询调度，重点理解阻塞式 I/O 的 Hook、协程状态管理、调度器与 epoll 的协作，以及 x86-64 汇编上下文切换。\n一、从上下文切换走向完整协程运行时 # 已经掌握的两个方向：\nmain → ctx ：resume ctx → main ：yield 它们底层都依赖一次 context switch：\n保存当前执行现场 ↓ 恢复目标执行现场 ↓ 目标协程从上次暂停位置继续 但只有 switch、resume 和 yield 还不够。真正处理网络 I/O 还需要回答：\n1. 普通read/recv没有数据时，怎样自动yield？ 2. fd就绪以后，怎样找到并resume对应协程？ 3. 十万个协程如何保存和组织？ 4. READY、WAITING、SLEEPING、EXITED如何管理？ 5. 定时器和I/O超时如何处理？ 6. 上下文切换怎样用汇编实现？ 7. 多核机器上怎样运行多个调度器？ 最终需要形成：\nPOSIX I/O调用 ↓ Hook层 ↓ 非阻塞I/O + epoll ↓ yield / resume ↓ 协程状态和调度器 ↓ ucontext或汇编context switch 二、为什么需要 Hook I/O # 一个协程中的业务代码希望保持顺序结构：\nwhile(1){ recv(); parser(); send(); } 阅读时仍然是：\n接收数据 ↓ 解析数据 ↓ 发送响应 但如果这里直接调用阻塞式 recv()，没有数据时会阻塞整个线程，线程内其他协程也无法运行。\n协程库希望在不大改业务流程的情况下，把调用替换为自己的实现：\n业务调用recv/read ↓ 进入协程库Hook函数 ↓ 先尝试非阻塞I/O ├── 已就绪：调用真实系统函数并返回 └── 未就绪：注册epoll事件并yield ↓ fd就绪 ↓ resume协程 ↓ 再调用真实系统函数 这就是：\n上层保持同步代码结构，底层通过 epoll 和协程实现异步等待。\n三、Hook 的概念流程 # 笔记中的伪代码：\nrecv（fd,buffer,length）{ struct pollfd fds[1]={0}; fds[0].fd=fd; if(0\u0026lt;poll(fd,1,0)){//io可读 recv_f(fd,buffer,length); }else{ epoll_ctl(epfd,EPOLL_CTL_ADD,fd,\u0026amp;ev); swapcontext(); } } 它表达了四步。\n3.1 检查 fd 是否就绪 # poll(fd,1,0) timeout 为 0，表示立即检查，不在 poll() 中等待。\n返回值 \u0026gt; 0 → 当前fd有事件 返回值 = 0 → 当前没有事件 返回值 \u0026lt; 0 → 调用发生错误 3.2 已就绪时调用真实函数 # recv_f(fd,buffer,length); recv_f 指向真正的系统 recv，而不是 Hook 后的同名函数。\n3.3 未就绪时注册 epoll # epoll_ctl(epfd,EPOLL_CTL_ADD,fd,\u0026amp;ev); 同时记录：\n哪个fd 等待什么事件 哪个协程正在等待它 是否存在超时时间 3.4 当前协程 yield # swapcontext(); 这里代表概念上的 yield。实际实现需要明确传入当前协程上下文和调度器上下文。\n当前协程WAITING ↓ yield回调度器 ↓ 调度器运行其他READY协程 四、使用 dlsym 拦截 read 和 write # 以下是提供的 Hook 代码：\n#define _GNU_SOURCE #include \u0026lt;dlfcn.h\u0026gt; #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;ucontext.h\u0026gt; #include \u0026lt;string.h\u0026gt; #include \u0026lt;unistd.h\u0026gt; #include \u0026lt;fcntl.h\u0026gt; #include \u0026lt;sys/socket.h\u0026gt; #include \u0026lt;errno.h\u0026gt; #include \u0026lt;netinet/in.h\u0026gt; #include \u0026lt;pthread.h\u0026gt; #include \u0026lt;sys/poll.h\u0026gt; #include \u0026lt;sys/epoll.h\u0026gt; 4.1 为什么需要 _GNU_SOURCE # #define _GNU_SOURCE 它在包含系统头文件前启用 GNU 扩展声明，使 dlsym()、RTLD_NEXT 等接口在相应环境中可见。\n4.2 函数指针类型 # 原代码：\ntypedef ssize_t (*read_t)(int fd, void *buf, size_t count); read_t read_f = NULL; typedef ssize_t (*write_t)(int fd, const void *buf, size_t count); write_t write_f = NULL; read_t 描述真实 read() 的函数类型：\n参数1：fd 参数2：接收缓冲区 参数3：最大读取长度 返回值：实际读取长度或错误 write_t 对应真实 write()。\nread_f → 保存原始read地址 write_f → 保存原始write地址 五、init_hook() 如何取得真实函数 # 原代码：\nvoid init_hook(void) { if (!read_f) { read_f = dlsym(RTLD_NEXT, \u0026#34;read\u0026#34;); } if (!write_f) { write_f = dlsym(RTLD_NEXT, \u0026#34;write\u0026#34;); } } dlsym() 根据符号名查找函数地址：\ndlsym(RTLD_NEXT, \u0026#34;read\u0026#34;); RTLD_NEXT 的含义可以理解为：\n从当前 Hook 定义之后继续寻找下一个名为 read 的实现。\n因此调用关系是：\n业务代码调用read() ↓ 进入当前文件定义的Hook read() ↓ Hook内部调用read_f() ↓ 进入真正的系统read() 如果 Hook 内部再次直接调用 read()：\nHook read() ↓ 又进入Hook read() ↓ 无限递归 所以必须使用 read_f 调用真实函数。\n使用 dlsym() 时通常需要链接动态加载库：\ngcc ... -ldl 六、read() Hook 的执行过程 # 原代码：\nssize_t read(int fd, void *buf, size_t count) { struct pollfd fds[1] = {0}; fds[0].fd = fd; fds[0].events = POLLIN; int res = poll(fds, 1, 0); if (res \u0026lt;= 0) { //不可读 // fd --\u0026gt; epoll_ctl(); swapcontext(); // fd --\u0026gt; ctx fd与context一对一 } // io ssize_t ret = read_f(fd, buf, count); printf(\u0026#34;read: %s\\n\u0026#34;, (char *)buf); return ret; } 6.1 构造 pollfd # struct pollfd fds[1] = {0}; fds[0].fd = fd; fds[0].events = POLLIN; 表示只检查一个 fd 的可读事件。\n6.2 立即检查 # int res = poll(fds, 1, 0); 第三个参数为 0：\n不等待 立即返回当前状态 6.3 不可读时暂停协程 # if (res \u0026lt;= 0) { // fd --\u0026gt; epoll_ctl(); swapcontext(); } 完整运行时还需要在 yield 前完成：\n将fd设置为非阻塞 将fd注册到epoll 记录fd等待POLLIN/EPOLLIN 记录当前等待协程 设置可选超时时间 当前协程状态改为WAITING yield到scheduler fd 就绪后：\nepoll_wait返回fd ↓ 找到等待它的协程 ↓ 协程进入READY队列 ↓ scheduler resume协程 ↓ 从swapcontext()之后继续 ↓ read_f()真正读取数据 6.4 调用真实 read # ssize_t ret = read_f(fd, buf, count); 这一步才真正从内核读取数据。\npoll 告诉程序“当前可能可读”，read_f 才完成实际读取。就绪状态可能在检查和读取之间变化，因此 fd 仍然应该是非阻塞的，并正确处理 EAGAIN。\n七、write() Hook # 原代码：\nssize_t write(int fd, const void *buf, size_t count) { printf(\u0026#34;write: %s\\n\u0026#34;, (const char *)buf); return write_f(fd, buf, count); } 当前实现主要展示：\n业务调用write ↓ 进入Hook write ↓ 调用write_f进入真实write 完整协程版 write() 也需要处理：\n真实write成功一部分 → 记录已写偏移，继续写剩余数据 真实write返回EAGAIN → 注册EPOLLOUT → 当前协程yield → fd可写后resume Hook write() 内部使用 printf() 记录日志时要特别小心，因为 printf() 最终也可能调用 write()，从而再次进入 Hook。实际实现通常使用不会再次触发该 Hook 的日志路径，或者直接调用保存下来的真实 write_f。\n八、Hook 示例怎样接入普通服务端 # 原代码：\nint main() { init_hook(); int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in serveraddr; memset(\u0026amp;serveraddr, 0, sizeof(struct sockaddr_in)); serveraddr.sin_family = AF_INET; serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); serveraddr.sin_port = htons(2048); if (-1 == bind(sockfd, (struct sockaddr*)\u0026amp;serveraddr, sizeof(struct sockaddr))) { perror(\u0026#34;bind\u0026#34;); return -1; } listen(sockfd, 10); struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); int clientfd = accept(sockfd, (struct sockaddr*)\u0026amp;clientaddr, \u0026amp;len); printf(\u0026#34;accept\\n\u0026#34;); while (1) { char buffer[128] = {0}; int count = read(clientfd, buffer, 128); if (count == 0) { break; } write(clientfd, buffer, count); printf(\u0026#34;sockfd: %d, clientfd: %d, count: %d, buffer: %s\\n\u0026#34;, sockfd, clientfd, count, buffer); } return 0; } 业务代码仍然调用：\nread(clientfd, buffer, 128); write(clientfd, buffer, count); 但由于程序中定义了同名 read() 和 write()，符号解析会先进入 Hook。\n这正是 Hook 的价值：\n业务代码结构保持不变 ↓ read/write调用被协程库接管 ↓ 等待I/O时自动yield ↓ 就绪后自动resume 当前示例只接收一个 clientfd，重点是展示 Hook 链路。与真正协程服务器结合时，还需要 accept Hook、多连接协程、epoll mainloop 和 scheduler。\n九、为什么第三方库也可以受益 # 例如本地程序使用 hiredis 连接 Redis。hiredis 内部会调用 read/write 或 send/recv 完成网络操作。\n不修改 hiredis 源码时：\n业务调用hiredis ↓ hiredis内部调用read/write ↓ 进入协程库Hook ↓ 没有数据时yield ↓ Redis响应到达后resume 因此原本按照阻塞式 POSIX API 编写的库，有机会直接运行在协程调度环境中。\n前提是 Hook 覆盖了它实际调用的 API，并正确处理：\n阻塞标志 超时 EINTR EAGAIN 部分读写 close和fd复用 线程安全 十、如何定义一个协程 # 笔记中的初始设计：\nstruct coroutinue{ int fd; ucontext_t ctx;//stack ，stack_size,func包含在里面 void *arg }; 一个协程需要保存两类信息。\n10.1 执行现场 # ctx → 寄存器、栈指针、执行位置等上下文 stack → 协程自己的栈 stack_size → 栈大小 entry → 协程入口函数 arg → 入口参数 10.2 调度状态 # id → 协程标识 state → READY、RUNNING、WAITING等 fd → 当前等待的I/O对象 events → 等待EPOLLIN或EPOLLOUT expire_at → 超时时间或唤醒时间 scheduler → 所属调度器 概念上的完整结构：\nstruct coroutine ├── id ├── state ├── context ├── stack / stack_size ├── entry / arg ├── waiting_fd / events ├── expire_at ├── ready_node ├── wait_node ├── sleep_node └── scheduler 本篇沿用原笔记中的 coroutinue 拼写来对应代码，实际项目中可以统一为 coroutine。\n十一、协程入口与创建 # 原始设计：\nvoid func(void){ } typedef void *(*coroutinue_entry)(void *) int create_coroutinue(co_id id,coroutinue_entry entry,void *arg){ struct coroutinue *co =malloc(sizeof(struct coroutinue)); co.ss_sp= makecontext(co-\u0026gt;ctx,func,0); } 创建协程需要完成：\n1. 分配struct coroutine 2. 分配或绑定独立栈 3. 保存entry和arg 4. 初始化context 5. 设置入口包装函数 6. 设置协程退出后的返回目标 7. state设为READY 8. 加入ready_queue 为什么通常需要一个入口包装函数：\nscheduler resume新协程 ↓ 进入wrapper ↓ wrapper调用entry(arg) ↓ entry返回 ↓ wrapper把协程状态设为EXITED ↓ yield回scheduler ↓ scheduler回收资源 如果直接让入口函数随意 return，而没有统一退出路径，调度器很难正确修改状态和释放栈。\n十二、十万个协程如何组织 # 假设采用“一连接一协程”：\n10万个fd ↓ 10万个coroutine对象 但它们不会全部同时运行。\n例如：\n10万个协程 ├── 2万个READY ├── 5万个WAITING ├── 2万个SLEEPING └── 1万个已结束或待回收 调度器必须按状态组织它们，不能每轮扫描全部十万个对象。\n十三、READY 为什么使用队列 # 笔记中的设计：\nqueue_node (corountine,)ready_queue READY 协程表示现在就可以运行。\n常见操作：\n协程变为READY → 从队尾加入 scheduler取任务 → 从队头取出 协程yield但仍可运行 → 再放回队尾 因此队列适合：\nO(1)入队 O(1)出队 天然支持轮询公平调度 如果有 2 万个 READY 协程：\nready_head → co1 → co2 → co3 → ... → co20000 scheduler 每次取队头协程 resume。\n十四、WAITING 与 SLEEPING 为什么需要有序结构 # 笔记中的设计：\nrbtree_node(coroutinue,)wait_rb; rbtree_node(coroutinue,)sleep_rb; 等待 I/O 的协程可能还设置超时：\n协程A等待fd=4，100ms后超时 协程B等待fd=5，500ms后超时 协程C等待fd=6，50ms后超时 睡眠协程也有不同唤醒时间：\n协程D睡眠到10:00:00.100 协程E睡眠到10:00:00.800 如果以 expire_at 为 key 放入有序结构，调度器可以快速找到最早到期节点：\n最小expire_at ↓ 与当前时间比较 ↓ 已经到期就移出 ↓ 状态改为READY ↓ 加入ready_queue 红黑树、最小堆、跳表、时间轮都可以实现定时管理，具体选择取决于插入、删除、精度和规模要求。\nI/O fd 到协程的查找，还可以另外使用：\nepoll_event.data.ptr 哈希表 fd → coroutine 数组 fd → coroutine 时间树解决“谁先超时”，fd 映射解决“哪个事件唤醒谁”，它们是两个不同查找维度。\n十五、一个协程为什么可以同时拥有多个节点 # 笔记中的思路是把数据结构节点直接放进 coroutine：\nready_node wait_node sleep_node 这种方式称为侵入式数据结构：\ncoroutine对象本身包含链表节点或树节点 ↓ 不需要额外分配包装节点 ↓ 从节点可以找回所属coroutine 但协程状态必须保持一致：\nREADY → 位于ready_queue WAITING → 位于I/O等待集合，可能同时位于超时树 SLEEPING → 位于sleep定时结构 RUNNING → 当前正在执行，通常不在ready_queue EXITED → 位于回收集合或等待立即回收 协程从一个状态切到另一个状态时，需要从旧集合移除，再加入新集合，避免同一个节点被重复挂载。\n十六、scheduler 如何定义 # 笔记中的设计：\nscheduler struct scheduler{ int epfd struct epoll_event events[]; queue_node (corountine,)ready_head rbtree_root(coroutinue,)wait_rb; rbtree_root(coroutinue,)sleep_rb; }; 各字段含义：\nepfd → epoll实例 events[] → epoll_wait返回的就绪事件 ready_head → 当前可以运行的协程 wait_rb → 等待I/O并带超时的协程 sleep_rb → 主动睡眠的协程 完整调度器还会需要：\nmain_context → scheduler自己的上下文 current → 当前正在运行的协程 all_coroutines → 所有协程的所有权集合 exited_queue → 等待回收的协程 stop → 退出标志 thread_id → 所属线程 十七、哪些操作由协程执行，哪些由调度器执行 # 17.1 协程主动执行 # 调用read/recv/send/write 发现需要等待后yield 主动sleep 业务处理 入口函数返回并exit 17.2 调度器执行 # 维护ready_queue 计算最近超时时间 调用epoll_wait 处理fd就绪事件 处理wait/sleep超时 把协程转为READY 选择下一个协程resume 回收EXITED协程 核心边界：\n协程表达“我要等待什么”，调度器决定“等待期间运行谁，以及何时唤醒我”。\n十八、调度循环的完整流程 # 一个合理的 scheduler mainloop 可以概括为：\nwhile没有停止： 1. 处理已经到期的sleep节点 2. 处理已经超时的I/O等待节点 3. 处理ready_queue中的协程 4. 根据最近定时器计算epoll_wait超时 5. 调用epoll_wait 6. 根据events找到等待协程 7. 将就绪协程加入ready_queue 8. 回收已经退出的协程 如果 ready_queue 非空：\n可以先继续运行READY协程 epoll_wait使用timeout=0避免阻塞 如果 ready_queue 为空：\n根据最近timer计算等待时间 ↓ epoll_wait可以阻塞到I/O到达或定时器到期 这样线程不会忙等。\n十九、协程状态怎样流转 # 典型状态：\nNEW ↓ create READY ↓ scheduler resume RUNNING ├── 等待I/O → WAITING ├── sleep → SLEEPING ├── 主动yield且仍可运行 → READY └── entry返回 → EXITED WAITING ├── fd就绪 → READY └── 超时 → READY，并携带timeout结果 SLEEPING └── 时间到 → READY EXITED ↓ scheduler回收 DESTROYED 状态与容器必须同步：\nstate = READY ↔ 在ready_queue中 state = WAITING ↔ 在I/O等待映射中 state = SLEEPING ↔ 在timer结构中 二十、setjmp、ucontext 和汇编怎样选择 # 三种方案：\nsetjmp / longjmp ucontext architecture-specific assembly 20.1 setjmp / longjmp # 优点：\n标准C接口 可移植性相对好 适合理解保存点和恢复点 限制：\n不直接提供独立协程栈管理 构造完整栈式协程较复杂 20.2 ucontext # 优点：\ngetcontext/makecontext/swapcontext语义清晰 可以直接指定独立栈和入口函数 教学和原型实现简单 限制：\n已经不属于现代POSIX标准 不同平台支持情况不一致 20.3 汇编 # 优点：\n直接控制保存和恢复的寄存器 切换路径短 适合性能敏感的运行时 限制：\n与CPU架构、ABI和调用约定绑定 x86-64、ARM64等需要不同实现 测试和维护成本高 性能不能只靠固定排名判断，需要在相同编译器、架构和切换模型下基准测试。汇编通常能做得更精简，但正确性和可移植性同样重要。\n二十一、x86-64 汇编上下文切换原代码 # #elif defined(__x86_64__) __asm__ ( \u0026#34; .text \\n\u0026#34; \u0026#34; .p2align 4,,15 \\n\u0026#34; \u0026#34;.globl _switch \\n\u0026#34; \u0026#34;.globl __switch \\n\u0026#34; \u0026#34;_switch: \\n\u0026#34; \u0026#34;__switch: \\n\u0026#34; \u0026#34; movq %rsp, 0(%rsi) # save stack_pointer \\n\u0026#34; \u0026#34; movq %rbp, 8(%rsi) # save frame_pointer \\n\u0026#34; \u0026#34; movq (%rsp), %rax # save insn_pointer \\n\u0026#34; \u0026#34; movq %rax, 16(%rsi) \\n\u0026#34; \u0026#34; movq %rbx, 24(%rsi) # save rbx,r12-r15 \\n\u0026#34; \u0026#34; movq %r12, 32(%rsi) \\n\u0026#34; \u0026#34; movq %r13, 40(%rsi) \\n\u0026#34; \u0026#34; movq %r14, 48(%rsi) \\n\u0026#34; \u0026#34; movq %r15, 56(%rsi) \\n\u0026#34; \u0026#34; movq 56(%rdi), %r15 \\n\u0026#34; \u0026#34; movq 48(%rdi), %r14 \\n\u0026#34; \u0026#34; movq 40(%rdi), %r13 # restore rbx,r12-r15 \\n\u0026#34; \u0026#34; movq 32(%rdi), %r12 \\n\u0026#34; \u0026#34; movq 24(%rdi), %rbx \\n\u0026#34; \u0026#34; movq 8(%rdi), %rbp # restore frame_pointer \\n\u0026#34; \u0026#34; movq 0(%rdi), %rsp # restore stack_pointer \\n\u0026#34; \u0026#34; movq 16(%rdi), %rax # restore insn_pointer \\n\u0026#34; \u0026#34; movq %rax, (%rsp) \\n\u0026#34; \u0026#34; ret \\n\u0026#34; ); #endif 对应的上下文结构：\ntypedef struct _nty_cpu_ctx { void *esp; // void *ebp; void *eip; void *edi; void *esi; void *ebx; void *r1; void *r2; void *r3; void *r4; void *r5; } nty_cpu_ctx; int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx); 函数语义：\nnew_ctx → 即将恢复的目标上下文 cur_ctx → 当前需要保存的上下文 在 System V AMD64 ABI 中，前两个整数或指针参数通常放在：\n第一个参数new_ctx → RDI 第二个参数cur_ctx → RSI 因此：\nRSI作为基地址 → 保存当前上下文 RDI作为基地址 → 加载目标上下文 二十二、汇编第一部分：保存当前上下文 # 22.1 保存栈指针 # movq %rsp, 0(%rsi) 含义：\n把当前RSP保存到cur_ctx偏移0的位置 RSP 指向当前线程正在使用的栈顶。恢复 RSP 才能回到该协程自己的栈。\n22.2 保存帧指针 # movq %rbp, 8(%rsi) 一个指针占 8 字节，所以第二个字段位于偏移 8。\n22.3 保存返回地址 # movq (%rsp), %rax movq %rax, 16(%rsi) 调用 _switch 时，返回地址位于当前栈顶。先读到 RAX，再保存到 cur_ctx 偏移 16。\n这个地址就是以后恢复协程时需要继续执行的位置。\n22.4 保存被调用者保存寄存器 # movq %rbx, 24(%rsi) movq %r12, 32(%rsi) movq %r13, 40(%rsi) movq %r14, 48(%rsi) movq %r15, 56(%rsi) 这些寄存器按照 x86-64 System V ABI 属于 callee-saved registers。函数返回后调用方有权期待它们保持不变，因此 context switch 必须保存和恢复。\n偏移每次增加 8：\n0 → rsp 8 → rbp 16 → rip/返回地址 24 → rbx 32 → r12 40 → r13 48 → r14 56 → r15 原结构中的 esp、ebp、eip 是 32 位风格名称；在这段 x86-64 汇编中实际保存的是 rsp、rbp 和返回地址。\n二十三、汇编第二部分：恢复目标上下文 # movq 56(%rdi), %r15 movq 48(%rdi), %r14 movq 40(%rdi), %r13 movq 32(%rdi), %r12 movq 24(%rdi), %rbx movq 8(%rdi), %rbp movq 0(%rdi), %rsp movq 16(%rdi), %rax movq %rax, (%rsp) ret 这部分按相反方向加载 new_ctx：\n恢复r15、r14、r13、r12、rbx ↓ 恢复rbp ↓ 恢复目标协程rsp ↓ 把目标返回地址放到目标栈顶 ↓ ret弹出返回地址 ↓ 从目标协程保存位置继续执行 最关键的一步：\nmovq 0(%rdi), %rsp RSP 一旦切换，当前使用的栈就从旧协程栈变成了目标协程栈。\n最后：\nret 从目标栈弹出保存的指令地址，于是代码看起来像目标协程之前调用的 _switch() 正常返回。\n二十四、为什么不是保存所有寄存器 # x86-64 有更多通用寄存器，但按照函数调用约定分为：\ncaller-saved → 调用方负责在需要时保存 callee-saved → 被调用函数必须保证返回后不变 _switch() 以普通函数调用形式进入，编译器会按照 ABI 处理 caller-saved 寄存器中的临时值，因此最小切换实现主要保存 callee-saved 寄存器、栈指针和返回位置。\n这依赖：\n明确的ABI 正确的函数声明 编译器遵循调用约定 汇编与结构体布局完全一致 System V AMD64 中，前六个整数或指针参数通常依次使用：\nRDI、RSI、RDX、RCX、R8、R9 函数并不是不能超过六个参数。超过六个的参数会按照 ABI 放到栈上，只是调用方式不再全部使用参数寄存器。\n二十五、从 Hook read 到调度器唤醒的完整链路 # 协程A调用read(fd) ↓ 进入Hook read ↓ poll或真实read发现暂时不可读 ↓ fd注册EPOLLIN ↓ 记录fd → 协程A ↓ 协程A状态RUNNING → WAITING ↓ _switch(scheduler_ctx, coA_ctx) ↓ scheduler运行其他READY协程 ↓ epoll_wait返回fd ↓ 根据event找到协程A ↓ 从等待集合删除协程A ↓ 协程A状态WAITING → READY ↓ 加入ready_queue ↓ scheduler选择协程A ↓ _switch(coA_ctx, scheduler_ctx) ↓ 协程A从Hook read中的yield位置继续 ↓ 调用read_f真正读取 ↓ 返回业务代码 这一整条链路把四个模块连接起来：\nHook层 epoll事件层 scheduler调度层 context switch底层 二十六、多线程与多进程模式 # 26.1 多线程模式 # 一种设计是多个线程各自运行调度器：\n线程1 → scheduler1 → epoll1 → 一组协程 线程2 → scheduler2 → epoll2 → 一组协程 线程3 → scheduler3 → epoll3 → 一组协程 这通常比多个线程共同操作一个 scheduler 更容易控制，因为每个协程和上下文固定属于一个线程。\n如果多个线程共享：\nready_queue wait tree sleep tree coroutine对象 就需要考虑锁、并发状态迁移、跨线程唤醒和缓存竞争。\nCPU affinity 可以让调度线程尽量固定在某个 CPU 核，减少迁移和缓存失效，但是否绑定需要通过测试决定。\n26.2 多进程模式 # 进程1 → scheduler1 → 一组连接 进程2 → scheduler2 → 一组连接 进程3 → scheduler3 → 一组连接 每个进程拥有独立地址空间和调度器，协程数据天然隔离，减少共享锁。\n需要额外考虑：\n监听端口如何共享 连接如何负载均衡 进程间通信 共享状态 进程崩溃恢复 多线程和多进程都能利用多核。区别主要在共享内存、隔离性和通信成本。\n二十七、怎样把协程能力用到实际项目 # 27.1 网络框架 # 以 ntyco 为基础理解：\ncoroutine scheduler epoll Hook timer assembly switch 重点不是只复现 switch，而是打通：\nI/O等待 → yield → epoll唤醒 → ready → resume 27.2 WebServer # 一个连接或请求由一个协程处理 ↓ 协程recv HTTP请求 ↓ 解析协议 ↓ 查询Redis/MySQL ↓ 发送HTTP响应 每个等待点都由 Hook 和 scheduler 自动让出。\n27.3 KV 存储 # 例如 dkvstore：\n连接协程 ↓ 接收命令 ↓ 解析SET/GET ↓ 访问存储结构 ↓ 返回结果 27.4 图床 # 上传连接 ↓ 接收文件数据 ↓ 写磁盘或对象存储 ↓ 写数据库 ↓ 返回图片URL 这些项目能够体现的不是“使用了协程”一句话，而是：\n怎样Hook阻塞API 怎样管理十万连接状态 怎样设计ready/wait/sleep结构 怎样处理超时和部分读写 怎样在多核上部署调度器 怎样测试切换和网络性能 二十八、核心总结 # 重点一：Hook 让同步代码接入异步调度 # 业务仍然调用read/write ↓ Hook判断I/O状态 ↓ 不可用时注册epoll并yield ↓ 就绪后resume 重点二：协程对象既保存执行现场，也保存调度状态 # context/stack → 回来后从哪里继续 state/fd/time → 什么时候可以回来 data nodes → 当前属于哪个调度集合 重点三：不同状态需要不同数据结构 # READY → queue I/O映射 → epoll data、数组或哈希 超时/睡眠 → 红黑树、最小堆或时间轮 EXITED → 回收队列 重点四：scheduler 是整个运行时的中心 # 处理epoll事件 处理定时器 维护协程状态 选择READY协程 执行resume 接收yield 回收退出协程 重点五：汇编 switch 只做保存和恢复 # RSI → 保存当前上下文 RDI → 恢复目标上下文 RSP → 切换协程栈 ret → 跳回目标协程保存位置 最终可以把协程运行时理解成：\nHook 决定何时等待，epoll 决定何时就绪，scheduler 决定运行谁，context switch 决定怎样切过去。\n","date":"2026年8月11日","externalUrl":null,"permalink":"/MyBlog/p/coroutine-runtime-io-hook-scheduler-context-switch/","section":"Posts","summary":"","title":"协程运行时：IO Hook、调度器与汇编上下文切换","type":"posts"},{"content":"","date":"2026年8月10日","externalUrl":null,"permalink":"/MyBlog/tags/posix/","section":"Tags","summary":"","title":"POSIX","type":"tags"},{"content":"本篇承接已有的《网络IO》《IO 多路复用与 Reactor》《Reactor 与 epoll：百万并发网络服务器课后笔记》。\nsocket、fd、服务端基础代码、select/poll/epoll 和 Reactor 不再重复；\n一、为什么网络代码可以跨 Linux、Unix 和 macOS # Linux 受到 Unix 设计影响，不同厂商和社区又形成了不同发行版。它们的内核、工具和扩展功能可能不同，但应用程序仍然可以使用一组相似的接口：\nsocket(); bind(); listen(); accept(); connect(); send(); recv(); close(); 重要原因是这些系统广泛支持 POSIX 和 BSD Socket 风格的接口规范。\n标准接口主要约定：\n函数叫什么 函数接收什么参数 返回值如何表达成功和失败 错误通过什么方式报告 应用程序可以依赖哪些行为 不同系统的内核实现可以不同，但只要对应用程序提供兼容接口，使用标准接口编写的代码就比较容易迁移：\n应用程序 ↓ POSIX / Socket API ↓ 不同操作系统的内核实现 代码可以直接迁移还要求它没有依赖某个系统的专有能力。例如 socket、bind、listen 是通用 Socket API；epoll 是 Linux 专用机制，迁移到 macOS 时通常要换成 kqueue。\n二、客户端和服务端使用的 API # 2.1 TCP 客户端 # 课堂中的客户端调用顺序：\nsocket(); bind();//optional connect(); send(); recv(); close(); 对应作用：\nsocket() → 创建 socket 和 fd bind() → 可选，指定本地 IP 和本地端口 connect() → 指定对端并发起 TCP 连接 send() → 把应用数据交给内核 recv() → 从内核取出收到的数据 close() → 关闭 fd，并推动 TCP 关闭过程 客户端通常不主动调用 bind()。如果直接 connect()，内核会选择合适的本地 IP 和临时端口。\n临时端口范围由系统配置决定，不固定为 1024～65535。Linux 上可以查看：\ncat /proc/sys/net/ipv4/ip_local_port_range 客户端需要固定源 IP 或源端口时，才会在 connect() 前显式调用 bind()。\n2.2 TCP 服务端 # 课堂中的服务端调用顺序：\nsocket(); bind(); listen(); accept(); recv(); send(); close(); 与 Reactor 结合后还会使用：\nepoll_create(); epoll_ctl(); epoll_wait(); fcntl(); 这两组 API 位于不同层次：\nsocket/bind/listen/accept/connect/send/recv/close → 管理 TCP socket 和数据传输 epoll_create/epoll_ctl/epoll_wait → 管理大量 fd 的就绪事件 fcntl() 常用于把 fd 设置为非阻塞。\n三、一次 TCP 连接的三个阶段 # 1. 建立连接：三次握手 2. 数据传输：序号、确认、流量控制、拥塞控制和重传 3. 断开连接：FIN/ACK 与 TCP 状态迁移 应用程序看到的是：\nconnect() / accept() send() / recv() close() 内核真正执行的是 TCP 协议状态机。\n四、socket() 在应用层与内核之间建立了什么 # 课堂代码：\nfd=socket(); socket 的英文含义是插座。可以用“插头和插座”帮助理解应用层 fd 与内核 socket 对象的关系：\n应用层 fd：应用程序使用的整数句柄 ↓ 内核 socket/TCP相关对象：保存协议、地址、状态、缓冲区等 socket() 成功后主要完成两类事情。\n4.1 分配 fd # fd 是当前进程文件描述符表中的一个位置。内核找到可用位置，将它标记为正在使用，然后返回对应整数。\n进程fd表 0 → 标准输入 1 → 标准输出 2 → 标准错误 3 → 新创建的socket 调用 close(fd) 后，该 fd 位置会被释放，之后可能被其他文件或 socket 再次使用。\n4.2 创建并关联内核 socket 状态 # 应用程序用 fd 查找内核中的 socket 对象。TCP 相关对象会逐步保存：\n本地IP 本地端口 远端IP 远端端口 协议类型 TCP状态 发送缓冲区 接收缓冲区 序号和确认号 窗口及重传信息 课堂中把这组 TCP 连接状态概括为 TCB（Transmission Control Block，传输控制块）。\n五元组是：\n源IP 源端口 目的IP 目的端口 传输层协议 例如：\n192.168.1.10:52000 → 192.168.1.20:2000 → TCP 同一个服务端端口可以同时拥有大量连接，因为每条连接的五元组不同。\n五、bind()：把本地地址写入 socket # 课堂代码：\nbind(fd,); bind() 的作用是给 socket 指定本地 IP 和本地端口：\n通过fd找到内核socket对象 ↓ 设置本地IP ↓ 设置本地端口 服务端必须有稳定的监听地址，所以通常显式绑定：\n0.0.0.0:2000 客户端通常直接调用 connect()；如果之前没有 bind()，内核会自动选择本地地址和临时端口。\n六、listen()：让 socket 进入监听状态 # 课堂代码：\nlisten(fd,backlog); listen() 将 socket 变成监听 socket：\n普通TCP socket ↓ listen() LISTEN状态 如果服务端只完成 bind() 而没有 listen()，对应端口没有处于 TCP 监听状态，客户端连接通常会被拒绝。\n6.1 两类连接队列 # 监听 socket 的连接可以从两个阶段理解：\n收到客户端SYN ↓ SYN队列：握手尚未完成 ↓ 收到第三次握手ACK ↓ 已完成连接队列：等待应用accept() 常见名称：\nSYN queue / 半连接队列 accept queue / 已完成连接队列 “半连接”不是只能传一半数据，而是三次握手还没有完成。\n6.2 backlog # 现代 Linux 中，listen(fd, backlog) 的 backlog 主要限制等待 accept() 的已完成连接队列长度：\nlisten(fd,backlog); 可以理解成：\n应用程序暂时没来得及 accept 时，内核允许多少条已经建立完成的连接排队等待。\nSYN 队列还有独立内核配置：\ncat /proc/sys/net/ipv4/tcp_max_syn_backlog 实际队列长度还受内核上限、系统配置和 SYN cookies 等机制影响。\n七、TCP 三次握手 # 三次握手由内核 TCP 协议栈完成。应用程序通过 connect() 发起；服务端 listen() 后由内核接收握手；accept() 取出的是已经完成握手的连接。\n假设双方初始序号分别是：\n客户端初始序号：1234 服务端初始序号：5647 7.1 第一次：客户端发送 SYN # client → server SYN = 1 SEQ = 1234 含义：\n客户端希望建立连接，自己的初始发送序号从 1234 开始。\n客户端状态：\nCLOSED → SYN_SENT SYN 会占用一个序号，所以服务端确认时使用 1234 + 1。\n7.2 第二次：服务端返回 SYN + ACK # server → client SYN = 1 ACK = 1 SEQ = 5647 ACK number = 1235 ACK number = 1235 表示客户端序号 1234 的 SYN 已经收到，下一次期望从 1235 开始。\n同时，服务端告诉客户端自己的初始发送序号是 5647。\n服务端相关连接状态：\nLISTEN → SYN_RECEIVED 7.3 第三次：客户端返回 ACK # client → server ACK = 1 ACK number = 5648 5648 = 5647 + 1，表示服务端的 SYN 已经收到。随后双方进入：\nESTABLISHED 完整过程：\n客户端 服务端 SYN, SEQ=1234 ───────────────────────────────→ SYN+ACK, SEQ=5647, ACK=1235 ←─────────────────────────────── ACK, ACK=5648 ───────────────────────────────→ 双方进入ESTABLISHED 7.4 为什么要交换序号 # 双方分别告诉对方自己的初始序号，后续 TCP 才能利用序号和确认号实现：\n识别数据顺序 发现数据缺失 排除重复数据 确认已经收到的字节范围 进行超时重传 初始序号不是固定从 0 开始，而是由内核生成。这样可以减少旧连接延迟报文的干扰，也提高序号的不可预测性。\n八、connect()、握手和 accept() 的关系 # 客户端：\nconnect(); connect() 让内核发起 TCP 三次握手。阻塞 socket 上，connect() 通常要等待连接成功或失败后才返回，因此服务端不可达时可能等待较长时间。\n服务端：\nlisten(); accept(); 三次握手不是由 accept() 执行的。握手由服务端内核协议栈在监听状态下完成：\n内核完成三次握手 ↓ 连接进入已完成连接队列 ↓ listenfd出现可读事件 ↓ 应用调用accept() accept() 主要完成：\n从已完成连接队列取出一条连接 ↓ 为它分配新的clientfd ↓ 让clientfd关联这条已连接socket 因此：\nlistenfd → 继续监听新连接 clientfd → 与某个客户端收发数据 8.1 Reactor 中的对应关系 # listenfd出现EPOLLIN ↓ accept_cb() ↓ accept() ↓ 得到clientfd ↓ 把clientfd注册到epoll LT 下，如果队列中还有连接，监听 fd 会继续报告可读。\nET 下应循环 accept()，直到当前队列已经取空。课堂代码：\nwhile（1）{ fd =accept(); if(fd==-1){ break } } 这段代码表达的核心是：\n一次EPOLLIN通知 ↓ 连续accept ↓ 把当前已经完成的连接全部取出 实际使用非阻塞 fd 时，accept() 返回 -1 且 errno 为 EAGAIN/EWOULDBLOCK，表示本轮队列已经取空。\n九、SYN 队列如何匹配第三次握手 # 收到第三次握手报文后，内核需要找到它属于哪一条正在建立的连接。\nIP 头和 TCP 头能够提供：\n源IP 目的IP 源端口 目的端口 协议类型TCP 内核结合五元组、序号和连接状态查找对应的半连接状态，验证 ACK 后，将连接推进到已建立状态并放入已完成连接队列。\n连接状态的生命周期不是从 accept() 才开始。服务端收到 SYN 后，内核已经需要保存握手状态；accept() 只是把完成后的连接交给应用程序。\n十、SYN Flood # SYN Flood 的基本过程：\n攻击者发送大量SYN ↓ 服务端为握手保存状态 ↓ 攻击者不完成第三次握手 ↓ 半连接状态和相关资源被持续占用 常见防护包括：\nSYN cookies。 调整 SYN 队列和重试参数。 防火墙、负载均衡器和云安全组。 限速和异常源识别。 DDoS 清洗服务。 listen() 的 backlog 主要用于已完成连接队列，不能单独解决 SYN Flood。\n十一、send() 和 recv() 到底做了什么 # 课堂中的数据路径：\n发送方application ↓ send/write 发送方kernel TCP协议栈 ↓ 网络 ↓ 接收方kernel TCP协议栈 ↓ recv/read 接收方application 11.1 send() # 课堂例子：\nsend（fd,buffer,100,0）; send（fd,buffer,200,0）; send（fd,buffer,400,0）; send() 返回正数，表示本次有多少字节被内核 socket 发送路径接受。它不表示对端应用已经读取这些数据。\n后续什么时候组成 TCP 段、什么时候交给 IP 层、何时重传，由内核协议栈处理。\nTCP 是字节流，不保留应用层每次 send() 的边界。上面三次调用总共交给 TCP 700 字节，接收端不一定按照 100、200、400 三次收到。\n11.2 recv() # 课堂例子：\nrecv（fd，buffer，50,0） recv（fd，buffer，250,0） recv（fd，buffer，250,0） recv（fd，buffer，150,0） 接收端可以按照 50 + 250 + 250 + 150 取出相同的 700 字节。\nrecv() 把已经到达 socket 接收缓冲区的数据复制到应用层 buffer。一次返回多少字节取决于：\n当前已经到达多少数据 应用提供的buffer有多大 socket是否阻塞 协议栈和调度时机 因此：\n发送端三次send 不等于 接收端必须三次recv 这也是 TCP 半包、粘包问题的来源。应用层协议需要自己定义消息边界，例如固定长度、长度字段或分隔符。\n11.3 一次接收 1000 与十次接收 100 # 课堂对比：\nrecv（fd,buffer,1000,0）; 与：\nrecv（fd,buffer,100,0）; // 循环10次 复制总数据量可以相同，但十次 recv() 会产生更多系统调用、循环判断和状态处理开销，因此性能并非完全没有差异。\n实际代码应以协议完整性和程序结构为主，选择合理缓冲区大小，不必把每次读取切得非常小。\n十二、MTU、MSS 与 TCP 分段 # MTU 是链路能够承载的最大 IP 包大小。常见以太网 MTU 是 1500 字节，但它包含 IP 头，不等于 TCP 应用数据长度。\nTCP 常用 MSS 表示一个 TCP 段最多携带的 payload 大小：\nMSS ≈ MTU - IP头 - TCP头 常见 IPv4、无额外选项时：\n1500 - 20 - 20 = 1460字节 课堂例子：\nsend（fd,buffer,1700,0）; 应用一次交给内核 1700 字节，不代表网络上一定只有一个包。协议栈会根据 MSS、网卡卸载、路径 MTU 等条件分段。\n接收端 recv() 的边界与网络分段也没有一一对应关系。网络上传输多个 TCP 段，接收端可能一次取出合并后的数据，也可能分多次取出。\n十三、TCP 如何可靠并尽量快速地传输 # 这些机制主要运行在两端内核 TCP 协议栈之间，不由应用程序逐包控制。\n13.1 序号与确认号 # SEQ → 当前报文携带的数据从哪个字节序号开始 ACK → 下一次期望收到哪个字节序号 如果 ACK number = 1235，可以理解为序号 1235 之前的数据已经连续收到，下一次希望从 1235 开始。\n13.2 接收窗口与滑动窗口 # 接收方通过窗口告诉发送方：\n当前接收缓冲区还允许接收多少数据。\n发送方可以在没有逐段等待 ACK 的情况下发送窗口范围内的多段数据。ACK 推进后，允许发送的范围继续向前滑动。\n它解决：\n既要连续发送提高吞吐 又不能超过接收方处理能力 13.3 慢启动 # 连接开始时，发送方不知道网络能承受多少在途数据，因此从较小的拥塞窗口开始。在慢启动阶段，拥塞窗口按每个 RTT 观察通常接近指数增长。\n13.4 拥塞避免 # 拥塞窗口达到阈值后，增长转为更平缓的方式。检测到丢包或拥塞信号后，具体如何降低窗口取决于拥塞控制算法和丢包检测方式，不能把所有情况都理解为固定“减半重新开始”。\n13.5 延迟确认 # 接收方可以短暂等待，把 ACK 与反向数据一起发送，或者用一个 ACK 确认更多数据，以减少纯 ACK 包数量。\n延迟时间由协议实现和当前状态决定，不是所有系统都固定延迟 200 ms。\n13.6 超时重传与快速重传 # 发送方在规定时间内没有收到确认，会认为数据可能丢失并触发重传。连续收到重复 ACK 时，也可能在超时前触发快速重传。\n这些机制共同实现：\n可靠传输 按序交付 避免重复 适应接收端能力 适应网络拥塞程度 十四、close() 与四次挥手 # 课堂代码：\nclose(fd); close() 首先是通用的 fd 关闭接口。对 TCP socket 来说，当最后一个引用被关闭时，内核会根据 socket 状态和配置推进 TCP 关闭过程。\nTCP 关闭使用 FIN 标志，不是 final。\n14.1 为什么通常是四次 # TCP 是全双工连接，两个方向需要分别关闭：\n主动关闭方 被动关闭方 FIN ───────────────────────────────→ ACK ←─────────────────────────────── FIN ←─────────────────────────────── ACK ───────────────────────────────→ 过程：\n1. 主动方发送FIN：我没有更多数据要发送 2. 被动方返回ACK：我知道你不再发送 3. 被动方处理完自己的发送后，也发送FIN 4. 主动方返回ACK：确认对方也不再发送 第二步和第三步有时会合并在同一个报文中，所以抓包时不一定总能机械地看到四个独立数据包。\n14.2 主动关闭方状态 # ESTABLISHED ↓ close()，发送FIN FIN_WAIT_1 ↓ 收到对方对FIN的ACK FIN_WAIT_2 ↓ 收到对方FIN，返回ACK TIME_WAIT ↓ 等待2MSL CLOSED 14.3 被动关闭方状态 # ESTABLISHED ↓ 收到FIN，返回ACK CLOSE_WAIT ↓ 本地应用调用close()，发送FIN LAST_ACK ↓ 收到对方ACK CLOSED 14.4 为什么 recv() 返回 0 # 被动方收到 FIN 后，如果此前还有已到达的数据，应用应先读出这些数据。全部读完以后，再次调用：\nrecv(fd,buffer,size,0); 会返回：\n0 它表示对方正常关闭了发送方向，后面不会再有数据。非阻塞 socket 暂时没有数据返回的是 -1/EAGAIN，不是 0。\n14.5 数据与 FIN # 应用先调用：\nsend（fd,buffer,100,0） 随后调用：\nclose(fd); TCP 会保持字节流顺序：内核已经接受的待发送数据排在 FIN 之前。接收端先读到数据，数据全部读完后才观察到 EOF，即 recv() == 0。\n14.6 shutdown() # shutdown() 可以只关闭 TCP 的一个方向：\nSHUT_RD → 关闭读方向 SHUT_WR → 关闭写方向，向对端发送FIN SHUT_RDWR → 关闭读写方向 它适合“我已经发送完，但还要继续接收对方响应”的半关闭协议。普通请求响应程序只使用 close() 也很常见，但 shutdown() 并不是没有用途。\n十五、TIME_WAIT、CLOSING 与同时关闭 # 15.1 TIME_WAIT # 通常由最终发送 ACK 的主动关闭方进入 TIME_WAIT。作用：\n如果最后一个ACK丢失，可以重新确认对方重发的FIN 让旧连接的延迟报文在网络中消失 服务端出现大量 TIME_WAIT，通常说明服务端经常主动关闭连接，不等于双方一定同时调用了 close()。\n15.2 同时关闭 # 如果双方几乎同时调用 close()：\n双方都发送FIN ↓ 双方都可能在FIN_WAIT_1时收到对方FIN ↓ 进入CLOSING ↓ 收到自己FIN对应的ACK ↓ 进入TIME_WAIT 简化状态：\nESTABLISHED ↓ 本地发送FIN FIN_WAIT_1 ↓ 先收到对端FIN CLOSING ↓ 收到ACK TIME_WAIT 如果主动方先收到 ACK，再收到 FIN，则通常经过：\nFIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT TCP 状态机用于处理这些不同的报文到达顺序。\n十六、同时打开与 TCP P2P # 常规应用使用客户端/服务端角色：\n客户端调用connect() 服务端调用listen()/accept() 但 TCP 建立后，双方都是全双工通信端点。客户端和服务端主要描述谁主动连接、谁被动监听，以及应用协议中的角色。\nTCP 还定义了“同时打开”：双方都主动发送 SYN，并在 SYN_SENT 状态收到对方 SYN。\n双方CLOSED ↓ 同时主动打开 双方SYN_SENT ↓ 收到对方SYN并返回SYN+ACK 双方SYN_RECEIVED ↓ 收到ACK 双方ESTABLISHED 课堂中的 P2P 方向：\nfd=socket（） bind（）//optional connect（）； 真正跨公网实现 P2P 还需要考虑 NAT、端口映射、防火墙和连接协调。单纯同时调用 connect() 不保证一定能穿透网络设备。\n本节需要理解：\nTCP 建立后没有“只能客户端发、只能服务端收”的限制，双方都可以 send 和 recv。\n十七、把 API 与 TCP 状态对应起来 # 应用调用或事件 内核中的主要含义 socket() 创建 fd 和 socket 相关内核对象 bind() 设置本地 IP 和端口 listen() 进入 LISTEN，准备接收连接 connect() 主动发起三次握手 收到 SYN 创建或查找握手状态，进入 SYN_RECEIVED 握手完成 连接进入 ESTABLISHED，并等待 accept accept() 从已完成连接队列取出连接，返回 clientfd send() 把应用字节交给 socket 发送路径 recv() 把 socket 接收数据复制到应用缓冲区 收到 FIN 对端关闭发送方向，最终让 recv 返回 0 close() 释放 fd 引用，并推动 TCP 关闭过程 完整主线：\nsocket() ↓ bind() ↓ listen() / connect() ↓ 三次握手 ↓ accept()得到clientfd ↓ send()/recv() ↓ 序号、确认、窗口、拥塞控制、重传 ↓ close() ↓ FIN/ACK与四次挥手 ↓ TIME_WAIT或CLOSED 十八、本节课需要真正掌握的内容 # 重点一：API 是入口，协议栈完成真正的 TCP 工作 # connect()不是应用自己发送三个包 accept()不负责执行三次握手 send()不等于对端应用已经收到 recv()不对应某一次send() close()会推动TCP关闭状态机 重点二：监听 fd 和客户端 fd 对应不同状态 # listenfd → LISTEN状态 → 管理握手和等待accept的连接 clientfd → 对应某条已建立TCP连接 → 用于send和recv 重点三：TCP 是可靠字节流 # 没有应用消息边界 使用序号和ACK保证顺序与可靠性 使用接收窗口进行流量控制 使用拥塞窗口适应网络承载能力 使用重传恢复丢失数据 重点四：建立和关闭都是状态机 # 三次握手： CLOSED → SYN_SENT/SYN_RECEIVED → ESTABLISHED 主动关闭： ESTABLISHED → FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT 被动关闭： ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSED 课后实践 # 准备两台虚拟机，观察 TCP P2P：\n1. 双方创建TCP socket 2. 根据实验设计绑定本地地址 3. 协调双方连接时机 4. 抓包观察SYN、SYN+ACK、ACK 5. 建立后让双方都执行send和recv 6. 同时或分别close，观察TCP状态 可以配合：\nss -ant tcpdump -nn -i any tcp ","date":"2026年8月10日","externalUrl":null,"permalink":"/MyBlog/p/posix-api-tcp-connection-lifecycle/","section":"Posts","summary":"","title":"POSIX API 与 TCP 连接生命周期","type":"posts"},{"content":"","date":"2026年8月10日","externalUrl":null,"permalink":"/MyBlog/tags/socket/","section":"Tags","summary":"","title":"Socket","type":"tags"},{"content":"","date":"2026年8月10日","externalUrl":null,"permalink":"/MyBlog/tags/ucontext/","section":"Tags","summary":"","title":"Ucontext","type":"tags"},{"content":"本篇承接已有的网络 I/O、epoll 与 Reactor 笔记。这份笔记重点理解：为什么需要协程、协程怎样把异步 I/O 写成同步流程、上下文切换的三种实现方式，以及 yield、resume 和调度器之间的关系。\n一、这份笔记要解决的问题 # 以开源协程库 ntyco 为参照，尝试逐步理解并实现一套用户态协程框架。\n完整学习路线包括：\n1. 为什么需要协程 2. 协程有哪些原语操作 3. 如何定义 struct coroutine 4. 如何定义 struct scheduler 5. 调度器采用什么策略 6. 如何兼容或封装 POSIX API 7. 协程从创建到退出的执行流程 8. 协程如何扩展到多核 9. 如何测试协程性能 当前真正落到代码上的内容主要是：\n异步回调为什么难写 ↓ 协程如何保留同步代码结构 ↓ 上下文切换需要保存什么 ↓ setjmp/longjmp ↓ ucontext ↓ yield / resume ↓ 最简单的轮询调度器 后续的 struct coroutine、struct scheduler、I/O 调度、多核模式和性能测试，是建立在这些基础之上的下一阶段。\n二、协程要解决什么问题 # 2.1 同步请求 # 同步代码通常按顺序执行：\nfunc(){ send（）； recv(); } 执行过程：\nsend()发起请求 ↓ recv()等待结果 ↓ 结果返回以后继续执行 优点是代码符合人的思考顺序：\n发请求 → 等结果 → 处理结果 缺点是等待 I/O 时，当前执行单元不能继续做其他工作。\n2.2 异步回调 # 异步写法可以表示为：\ncallback(){ recv(); send(fd,cb); } func{ send(fd,callback) } 执行过程：\nfunc()发起请求 ↓ 立即返回，不阻塞当前流程 ↓ fd就绪 ↓ 事件系统调用callback() ↓ callback()继续处理结果 异步的关键不是函数名字叫 async，而是：\n发起操作和处理结果不在同一次连续调用栈中，中间通过事件和回调重新进入业务逻辑。\n2.3 异步不等于并行 # 异步表示等待期间可以处理其他任务；并行表示多个任务在同一时刻由不同 CPU 核执行。\n异步 → 关注等待关系 并行 → 关注是否同时执行 单线程 epoll 也可以实现异步，但它并没有同时使用多个 CPU 核。\n2.4 协程的目标 # 回调的性能模型很好，但业务流程一复杂，就会出现多层嵌套：\n请求A完成 → 回调中发起请求B → B的回调中发起请求C → C的回调继续业务 协程希望实现：\n底层：仍然使用异步事件和非阻塞I/O 上层：代码看起来像同步顺序执行 即：\n底层异步，上层同步写法。\n三、线程池异步与单线程同步的对比 # 线程池任务示例：\nclient_t *rClient = (client_t*)malloc(sizeof(client_t)); memset(rClient, 0, sizeof(client_t));\trClient-\u0026gt;fd = clientfd; job_t *job = malloc(sizeof(job_t)); job-\u0026gt;job_function = client_job; job-\u0026gt;user_data = rClient; workqueue_add_job(\u0026amp;workqueue, job); 这里把 clientfd 封装进任务：\nrClient-\u0026gt;fd = clientfd ↓ job-\u0026gt;job_function = client_job ↓ job-\u0026gt;user_data = rClient ↓ workqueue_add_job() ↓ 工作线程取出任务并执行 两种结构如下。\n3.1 同一个线程检测和处理 I/O # while(1){ int nready =epll_wait(); for(int i=0;i\u0026lt;nready;i++){ recv(event[i].data.fd,buffer) send(event[i]data.fd,buffer) } } } //单线程 流程：\nepoll_wait() ↓ 遍历就绪fd ↓ 当前线程执行recv/send ↓ 处理完成后才能继续下一个事件 3.2 epoll 线程只分发任务 # void *task_callback(void *arg){ recv(event[i].data.fd,buffer) send(event[i]data.fd,buffer) } while(1){ int nready =epll_wait(); for(int i=0;i\u0026lt;nready;i++){ task.fd=event[i].fd task.callback=task_callback push_task_to_threadpool(\u0026amp;task); }//多线程 流程：\nepoll_wait()检测就绪 ↓ 把fd包装成task ↓ 推入线程池 ↓ 工作线程执行recv/send 一次示例测试得到：\n线程池方式：每1000连接约1500ms 直接处理：每1000连接约7300ms 这个结果只能说明当前代码、机器和负载下，任务并行处理改善了吞吐。它不能直接推导出“所有异步程序一定比同步程序快六倍”。\n性能差异还可能来自：\n任务中是否有阻塞操作 CPU核心数量 连接和请求模型 锁与队列开销 日志输出 测试方法 数据大小 协程与线程池也不是同一种实现：\n线程池 → 内核线程并行执行任务 协程 → 通常在线程内由用户态调度器协作切换 四、为什么互联网业务需要并发等待 # 一个网页可能需要同时请求多个动态接口：\n用户信息 文章列表 消息数量 推荐内容 广告数据 权限信息 如果完全串行：\n请求1完成 ↓ 请求2完成 ↓ 请求3完成 总等待时间会累积。\n如果这些请求相互独立，可以在等待某个请求时推进其他请求：\n发起请求1 ─┐ 发起请求2 ─┼→ 分别等待 → 谁先完成先处理谁 发起请求3 ─┘ 同样的模型也会出现在：\nDNS查询 HTTP请求 数据库查询 Redis请求 Kafka或MongoDB访问 文件I/O 多个客户端连接 如果请求之间存在依赖：\nA的结果决定B的参数 B的结果决定C的参数 业务顺序仍然必须是 A → B → C。协程不能消除真实依赖，但可以在 A 等待期间调度其他不相关协程。\n五、从回调流程演化到协程流程 # 5.1 回调写法 # 三个请求流程可以写成：\n请求流程1 callback(){ recv(); send(fd,cb); } func{ send_http(fd,callback) switch(1,2) } 请求流程2 callback(){ recv(); send(fd,cb); } func{ switch(2,3) send(fd,callback) } 请求流程3 callback(){ recv(); send(fd,cb); } func{ send(fd,callback) switch(3,1) } 业务被拆散到多个 callback 中，切换关系也混在业务代码里。\n5.2 协程期望的写法 # 希望得到的目标形式：\n请求流程1 func{ setjmp(); asyn_send(fd,callback) asyn_recv(fd,buffer) } 其他请求也保持相同的顺序结构：\n请求流程2 func{ asyn_send(fd,callback) asyn_recv(fd,buffer) } 请求流程3 func{ asyn_send(fd,callback) asyn_recv(fd,buffer) } 表面看起来仍然是：\n发送 ↓ 接收 ↓ 处理 但 asyn_recv() 发现 fd 尚未就绪时，不阻塞整个线程，而是让出当前协程：\nasync_xxx(){ if(1==poll(fd,0)){ switch(); } } 它表达的思想是：\nI/O已经就绪 → 当前协程继续执行 I/O尚未就绪 → 保存当前协程上下文 → yield到调度器 → 调度其他协程 → fd就绪后resume回来 所以协程不是把阻塞 I/O 变快，而是在等待发生时切走。\n六、协程必须具备哪些原语 # 最基础的协程原语可以概括为：\ncreate → 创建协程 resume → 从调度器进入协程 yield → 协程主动让出执行权 exit → 协程执行结束 switch → 保存一个上下文并恢复另一个上下文 与 I/O 调度结合后还需要：\nwait_io(fd, event) → 当前协程等待某个fd事件 wakeup(coroutine) → fd就绪后把协程变为可运行 schedule() → 选择下一个可运行协程 三者之间的关系：\nresume和yield → 面向协程业务语义 switch → 底层上下文切换动作 schedule → 决定下一次resume谁 七、上下文到底是什么 # 协程执行到一半切走，之后还要从原位置继续，因此必须保存执行现场。\n上下文通常包括：\n程序计数器/指令位置 栈指针 通用寄存器 部分调用约定要求保存的寄存器 协程自己的栈 协程状态 如果只记住“执行到了哪一行”，但没有保存栈和寄存器，函数局部变量、返回地址和调用关系就无法恢复。\n栈式协程通常需要：\n每个协程一个独立栈 每个协程一份上下文 一个调度器上下文 I/O 模型中常见的是：\nfd就绪事件 → 找到等待这个fd的协程 → 将协程加入ready队列 并不是 fd 本身天然等于协程；实现可以选择“一连接一协程”“一请求一协程”，也可以让一个协程等待多个事件。\n八、实现上下文切换的三种方式 # 常见的实现方式：\n1. setjmp / longjmp 2. ucontext 3. 汇编保存和恢复寄存器，例如 ntyco 8.1 setjmp / longjmp # setjmp() 保存可以恢复的执行环境，longjmp() 跳回该位置。\n它适合帮助理解“保存位置并跳回来”，但它本身不自动为每个协程创建独立栈。要实现完整栈式协程，还要管理协程栈和更完整的上下文。\n8.2 ucontext # ucontext 提供：\ngetcontext() → 获取当前上下文 makecontext() → 为上下文指定入口函数 swapcontext() → 保存当前上下文并切换到目标上下文 setcontext() → 直接恢复某个上下文 它非常适合教学，因为能够明确看到栈、入口函数和上下文切换。\nucontext 已从 POSIX.1-2008 中移除，不适合作为现代跨平台库的统一标准接口，但很多 Unix/Linux 环境仍然提供它。\n8.3 汇编切换 # 汇编实现会按照目标架构和调用约定，直接保存、恢复必要寄存器和栈指针。\n优点：\n可控制保存哪些寄存器 切换路径短 适合高性能协程库 代价：\n与CPU架构相关 与ABI和调用约定相关 移植成本更高 实现错误容易破坏栈 ntyco 采用汇编方式实现底层上下文切换。\n九、setjmp / longjmp 原代码 # setjmp / longjmp 示例代码：\n#include\u0026lt;setjmp.h\u0026gt; #include\u0026lt;stdio.h\u0026gt; jmp_buf env;//上下文环境 void func(int arg){ printf(\u0026#34;func: %d\\n\u0026#34;,arg); longjmp(env,++arg); } int main(){ int ret=setjmp(env); if(ret==0){ func(ret); }else if(ret==1){ func(ret); }else if(ret==2){ func(ret); }else if(ret==3){ func(ret); } return 0; } 9.1 jmp_buf # jmp_buf env;//上下文环境 env 用来保存 setjmp() 所需的执行环境。\n9.2 第一次 setjmp() # int ret=setjmp(env); 直接调用 setjmp() 时返回 0：\nret = 0 ↓ 进入if(ret==0) ↓ func(0) 9.3 longjmp() # longjmp(env,++arg); func(0) 中 arg 先变成 1，再跳回 setjmp() 保存的位置。\n这一次 setjmp() 看起来像重新返回，并得到：\nret = 1 于是进入：\n}else if(ret==1){ func(ret); } 执行过程：\nsetjmp首次返回0 ↓ func(0)打印func: 0 ↓ longjmp(...,1) setjmp恢复后返回1 ↓ func(1)打印func: 1 ↓ longjmp(...,2) setjmp恢复后返回2 ↓ func(2)打印func: 2 ↓ longjmp(...,3) setjmp恢复后返回3 ↓ func(3)打印func: 3 ↓ longjmp(...,4) setjmp恢复后返回4 ↓ 没有匹配分支，main结束 预期打印：\nfunc: 0 func: 1 func: 2 func: 3 这段代码展示的是控制流跳转，还不是完整的多协程调度器。\n十、ucontext 原代码：三个协程和 main 上下文 # ucontext 示例代码：\n#include\u0026lt;stdio.h\u0026gt; #include\u0026lt;ucontext.h\u0026gt; ucontext_t ctx[3]; ucontext_t main_ctx; int count=0; //coroutine1 void func1(void){ while(count++\u0026lt;30){ printf(\u0026#34;1\\n\u0026#34;); //swapcontext(\u0026amp;ctx[0],\u0026amp;ctx[1]); swapcontext(\u0026amp;ctx[0],\u0026amp;main_ctx); printf(\u0026#34;4\\n\u0026#34;); } } //coroutine2 void func2(void){ while(count++\u0026lt;30){ printf(\u0026#34;2\\n\u0026#34;); //swapcontext(\u0026amp;ctx[1],\u0026amp;ctx[2]); swapcontext(\u0026amp;ctx[1],\u0026amp;main_ctx); printf(\u0026#34;5\\n\u0026#34;); } } //coroutine3 void func3(void){ while(count++\u0026lt;30){ printf(\u0026#34;3\\n\u0026#34;); //swapcontext(\u0026amp;ctx[2],\u0026amp;ctx[0]); swapcontext(\u0026amp;ctx[2],\u0026amp;main_ctx); printf(\u0026#34;6\\n\u0026#34;); } } //schedule int main(){ char stack1[2048]={0}; char stack2[2048]={0}; char stack3[2048]={0}; getcontext(\u0026amp;ctx[0]); ctx[0].uc_stack.ss_sp=stack1; ctx[0].uc_stack.ss_size=sizeof(stack1); ctx[0].uc_link=\u0026amp;main_ctx; makecontext(\u0026amp;ctx[0],func1,0); getcontext(\u0026amp;ctx[1]); ctx[1].uc_stack.ss_sp=stack2; ctx[1].uc_stack.ss_size=sizeof(stack2); ctx[1].uc_link=\u0026amp;main_ctx; makecontext(\u0026amp;ctx[1],func2,0); getcontext(\u0026amp;ctx[1]); ctx[2].uc_stack.ss_sp=stack3; ctx[2].uc_stack.ss_size=sizeof(stack3); ctx[2].uc_link=\u0026amp;main_ctx; makecontext(\u0026amp;ctx[1],func3,0); printf(\u0026#34;swapcontext\\n\u0026#34;); swapcontext(\u0026amp;main_ctx,\u0026amp;ctx[0]); int i=30; while(count\u0026lt;30){ swapcontext(\u0026amp;main_ctx,\u0026amp;ctx[count%3]); } printf(\u0026#34;\\n\u0026#34;); return 0; } 这段代码希望建立：\nmain_ctx → 调度器上下文 ctx[0] → func1协程 ctx[1] → func2协程 ctx[2] → func3协程 阅读第三个协程的初始化时，要按照这个对应关系理解：\nctx[2]使用stack3 ctx[2]的入口函数是func3 十一、如何创建一个 ucontext 协程 # 以第一个协程为例。\n11.1 保存初始上下文 # getcontext(\u0026amp;ctx[0]); ctx[0] 获得一份可继续配置的上下文。\n11.2 指定独立栈 # ctx[0].uc_stack.ss_sp=stack1; ctx[0].uc_stack.ss_size=sizeof(stack1); 含义：\nstack1 → 栈起始地址 sizeof(stack1) → 栈大小 ctx[0].uc_stack → ctx[0]使用的协程栈 三个协程分别使用：\nctx[0] → stack1 ctx[1] → stack2 ctx[2] → stack3 独立栈让不同协程能够保存自己的函数调用关系和局部变量。\n11.3 指定返回去向 # ctx[0].uc_link=\u0026amp;main_ctx; 如果 func1 正常执行完并返回，运行时会转到 main_ctx。\nuc_link 处理的是入口函数正常返回后的去向；func1 中主动调用 swapcontext() 则是显式 yield。\n11.4 指定入口函数 # makecontext(\u0026amp;ctx[0],func1,0); 含义：\nctx[0]第一次被resume时 ↓ 从func1开始执行 ↓ func1没有参数 最后一个 0 表示传给 func1 的参数数量为 0。\n十二、swapcontext()：保存当前上下文并恢复另一个 # 函数形式：\nswapcontext(\u0026amp;from,\u0026amp;to); 可以理解成：\n把当前执行现场保存到from ↓ 恢复to中的执行现场 ↓ 开始运行to 12.1 main resume 第一个协程 # swapcontext(\u0026amp;main_ctx,\u0026amp;ctx[0]); 含义：\n保存main当前执行位置到main_ctx ↓ 恢复ctx[0] ↓ 第一次进入func1 func1 打印：\nprintf(\u0026#34;1\\n\u0026#34;); 12.2 func1 yield 回 main # swapcontext(\u0026amp;ctx[0],\u0026amp;main_ctx); 含义：\n保存func1当前执行位置到ctx[0] ↓ 恢复main_ctx ↓ main从刚才swapcontext之后继续 func1 的执行位置停在：\nswapcontext(\u0026amp;ctx[0],\u0026amp;main_ctx); 下一次 main 再 resume ctx[0] 时，swapcontext() 返回，继续执行：\nprintf(\u0026#34;4\\n\u0026#34;); 这就是协程能够“从上次让出的位置继续”的原因。\n十三、yield、resume 和 switch # 三者的对应关系：\nmain → ctx[0] → resume协程 ctx[0] → main → yield协程 swapcontext() → 底层switch动作 13.1 resume # 调度器选择一个协程 ↓ 从main_ctx切到该协程ctx ↓ 协程开始或继续运行 13.2 yield # 协程遇到I/O等待或主动让出 ↓ 保存自己的ctx ↓ 切回main_ctx ↓ 调度器选择其他协程 13.3 switch # switch 不关心为什么切换，只负责：\n保存from 恢复to resume 和 yield 是对 switch 在不同业务方向上的命名。\n十四、当前调度器如何工作 # 原代码：\nwhile(count\u0026lt;30){ swapcontext(\u0026amp;main_ctx,\u0026amp;ctx[count%3]); } count % 3 的结果不断在 0、1、2 之间变化：\ncount % 3 == 0 → resume ctx[0] count % 3 == 1 → resume ctx[1] count % 3 == 2 → resume ctx[2] 因此它相当于一个简单轮询调度器：\nmain ↓ ctx[0]运行一段后yield ↓ main ↓ ctx[1]运行一段后yield ↓ main ↓ ctx[2]运行一段后yield ↓ main继续轮询 这些上下文的栈关系可以画成：\nctx3独立栈 ctx2独立栈 ctx1独立栈 main栈 这些栈并不是垂直嵌套成一条普通函数调用链，而是各自保存执行现场，由 swapcontext() 在它们之间切换。\n十五、为什么当前轮询还不是 I/O 调度器 # 当前策略只根据：\nctx[count%3] 选择协程，它不知道：\n哪个协程正在等待fd 哪个fd已经就绪 哪个协程已经结束 哪个协程可以立即运行 真正的 I/O 协程调度需要至少维护：\nready队列 → 当前可以运行的协程 waiting集合 → 等待I/O、定时器或条件的协程 epoll → 检测fd就绪 协程状态 → NEW、READY、RUNNING、WAITING、DEAD 典型过程：\n协程调用异步recv ↓ 数据尚未就绪 ↓ 把“fd → 当前协程”注册给调度器 ↓ 当前协程状态变成WAITING ↓ yield回调度器 ↓ 调度器resume其他READY协程 ↓ epoll报告fd可读 ↓ 对应协程进入READY队列 ↓ 调度器再次resume它 ↓ recv继续执行 这个过程才真正实现：\n代码看起来在等待 recv，但线程没有被这个协程阻塞。\n十六、协程结构体需要保存什么 # 进一步实现时需要定义 struct coroutine。根据前面的执行过程，它至少需要表达：\n协程ID 协程状态 上下文 独立栈和栈大小 入口函数 入口参数 所属调度器 等待的fd和事件 退出结果或错误 概念结构：\nstruct coroutine ├── id ├── state ├── context ├── stack ├── function ├── argument ├── waiting_fd └── scheduler 这里先理解字段为什么存在，不需要脱离后续 ntyco 代码自行发明另一套结构。\n十七、调度器需要保存什么 # 调度器负责管理所有协程并决定下一次运行谁。\n概念结构：\nstruct scheduler ├── main_context ├── current_coroutine ├── ready_queue ├── waiting_coroutines ├── epoll_fd ├── timer └── all_coroutines 调度器要回答：\n当前运行的是谁 哪些协程可以运行 哪些协程在等待I/O 哪些协程等待超时 fd就绪后应该唤醒谁 协程退出后怎样回收 十八、如何封装 POSIX API # 一个重要设计目标，是让协程网络库尽量保持熟悉的 POSIX 调用方式。\n期望上层仍然写：\nconnect accept recv send close 但协程封装后的行为是：\n调用recv ↓ 先尝试非阻塞recv ├── 已就绪：直接返回结果 └── EAGAIN：注册EPOLLIN并yield ↓ fd就绪 ↓ resume协程 ↓ 再次尝试recv 这样业务代码保留同步风格，但底层仍然由 epoll 和协程调度实现异步等待。\n“接口一致”不代表直接调用阻塞版本的 POSIX API，而是协程库提供行为兼容的包装层或 hook。\n十九、协程怎样用于 WebServer # 已有 Reactor WebServer 的逻辑：\nepoll_wait ↓ recv_cb ↓ http_request ↓ send_cb ↓ http_response 协程模型可以把一个连接或一个请求组织成顺序流程：\n协程开始 ↓ 等待并接收HTTP请求 ↓ 解析HTTP ↓ 查询数据库或缓存 ↓ 构造HTTP响应 ↓ 发送响应 ↓ 等待下一次请求或退出 遇到 fd 尚未就绪时：\n当前协程yield ↓ 线程继续处理其他连接协程 ↓ fd就绪后resume Reactor 与协程不是互斥关系：\nReactor/epoll → 检测事件 协程调度器 → 把事件转换成协程唤醒 协程 → 承载顺序业务流程 二十、微信群聊场景应该怎样思考 # 思考这个场景时，不必急着寻找固定答案，先建立分析框架。\n面对“微信群聊如何使用协程”时，可以依次问：\n1. 一个协程对应一个连接、一个用户，还是一次消息处理？ 2. 连接没有消息时，协程处于什么状态？ 3. fd可读后，谁把对应协程唤醒？ 4. 一条消息需要广播给多人时，哪些步骤能够并发？ 5. 慢客户端发送不出去时，是否会阻塞其他用户？ 6. 每个连接的发送队列放在哪里？ 7. 用户断开以后，协程、fd和缓冲区如何回收？ 8. 心跳和超时由谁管理？ 9. 群消息顺序如何保证？ 10. 多核模式下，同一个连接能否跨调度器迁移？ 可以先画：\n用户A连接协程 ↓ 收到群消息 服务端业务处理 ↓ 找到群成员 ↓ 把消息加入B/C/D的发送队列 ↓ 对应连接可写时唤醒发送流程 重点不是简单回答“一个用户一个协程”，而是明确：\n协程在哪里等待 什么事件唤醒 状态保存在哪里 慢连接如何隔离 二十一、协程的多核模式 # 单个调度器通常运行在一个线程中，同一时刻只能使用一个 CPU 核。\n多核模式常见思路：\n一个线程一个调度器 ↓ 每个调度器拥有自己的epoll和ready队列 ↓ 多个线程并行运行 这形成 M:N 模型：\nM个协程 ↓ 用户态调度 N个内核线程 ↓ 多个CPU核 需要处理：\n连接如何分配到各调度器 协程是否允许跨线程迁移 跨线程唤醒 共享队列和锁 负载均衡 work stealing 线程局部状态 上下文通常只能在它所属的线程和栈环境中安全切换，不能随意把 setjmp/ucontext 保存的上下文拿到另一个线程恢复。\n二十二、协程性能如何测试 # 不能只看一个“每 1000 连接耗时”。协程性能至少分成四层。\n22.1 原语性能 # 创建一个协程的耗时 一次yield/resume的耗时 每秒可以上下文切换多少次 销毁协程的耗时 22.2 内存 # 每个协程结构体大小 每个协程栈大小 一万或百万协程的总内存 栈是否按需增长 22.3 I/O 服务性能 # 并发连接数 QPS 吞吐量 P50/P95/P99延迟 超时和错误率 CPU使用率 上下文切换次数 22.4 对比基线 # 应在相同业务和负载下比较：\n单线程Reactor Reactor + 线程池 Reactor + 协程 一连接一线程 只有测试业务、连接数、响应大小和日志配置相同，结果才有可比性。\n二十三、核心总结 # 重点一：协程不是为了创造真正并行 # 单线程协程仍然只使用一个CPU核 协程的主要价值是等待I/O时切换任务 多核需要多个调度线程配合 重点二：协程结合了两种优点 # 上层：同步顺序代码，容易理解 底层：异步事件等待，不阻塞整个线程 重点三：上下文和独立栈是恢复执行的基础 # 上下文 → 记录执行现场 协程栈 → 保存函数调用和局部变量 switch → 保存一个上下文，恢复另一个 重点四：yield、resume 与调度器 # resume → 调度器进入协程 yield → 协程返回调度器 switch → 完成底层上下文切换 scheduler → 决定下一个运行谁 重点五：真正的 I/O 协程需要 epoll # recv遇到EAGAIN ↓ 协程注册等待EPOLLIN ↓ yield ↓ epoll报告fd可读 ↓ 协程进入ready队列 ↓ resume ↓ 继续recv和后续业务 最终理解：\n协程不是替代 epoll，而是建立在事件驱动之上，把回调式异步流程重新组织成可顺序阅读的业务代码。\n","date":"2026年8月10日","externalUrl":null,"permalink":"/MyBlog/p/coroutine-design-ucontext-scheduling/","section":"Posts","summary":"","title":"协程设计原理：从异步回调到 ucontext 调度","type":"posts"},{"content":"","date":"2026年8月10日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%BC%82%E6%AD%A5-i/o/","section":"Tags","summary":"","title":"异步 I/O","type":"tags"},{"content":"","date":"2026年8月9日","externalUrl":null,"permalink":"/MyBlog/tags/http/","section":"Tags","summary":"","title":"HTTP","type":"tags"},{"content":"本篇是《网络IO》《IO 多路复用与 Reactor》《Reactor 与 epoll：百万并发网络服务器》的后续实践。\nsocket、fd、select、poll、epoll 接口、连接对象和 Reactor 基础不再重复；本篇只整理这节课新增的 LT/ET、HTTP WebServer、文件发送状态机和 WebSocket。\n一、本节课做了什么 # 前一节已经搭好了 Reactor 网络层，本节在它上面接入两种协议业务：\nreactor.c：网络 I/O 层 ├── recv_cb() └── send_cb() webserver.c：HTTP 协议层 ├── http_request() └── http_response() websocket.c：WebSocket 协议层 ├── ws_request() └── ws_response() 本节最重要的调用链：\nclientfd 出现 EPOLLIN ↓ recv_cb() 调用 recv() ↓ http_request() / ws_request() ↓ set_event(fd, EPOLLOUT, 0) ↓ clientfd 出现 EPOLLOUT ↓ send_cb() ↓ http_response() / ws_response() ↓ send() ↓ set_event(fd, EPOLLIN, 0) 因此，Reactor 与协议层的分工是：\nreactor.c → 关心 fd 是否可读、可写，以及 recv/send webserver.c → 关心 HTTP 请求和 HTTP 响应 websocket.c → 关心握手、帧解码和帧编码 二、LT 与 ET # 2.1 LT：水平触发 # 只要内核接收缓冲区中还有数据，LT 就会继续报告可读事件。\n例如客户端发送 32 字节，服务端一次只接收 10 字节：\n第一次 recv：10 字节，剩余 22 第二次 recv：10 字节，剩余 12 第三次 recv：10 字节，剩余 2 第四次 recv：2 字节，接收完成 2.2 ET：边沿触发 # ET 主要关注从“没有数据”到“有数据”的状态变化。收到一次通知后，应在当前回调中尽量把数据处理完。\n当前代码在 accept_cb() 中这样注册客户端：\nevent_rigister(clientfd,EPOLLIN|EPOLLET);//|EPOLLET 这表示 clientfd 使用 ET，并且先关注可读事件。\nET 的处理思路：\naccept_cb() → 持续 accept，直到当前没有新连接 recv_cb() → 持续 recv，直到当前数据读完 send_cb() → 持续推进发送，直到发完或暂时不可写 循环读取时要配合非阻塞 fd。当前没有更多数据时，recv() 返回 -1，并且 errno 为 EAGAIN 或 EWOULDBLOCK，这表示本轮读取结束，应回到事件循环。\nLT 和 ET 都不能决定 HTTP 请求或 WebSocket 帧是否完整。TCP 是字节流，协议层仍然需要根据协议格式判断消息边界。\n三、recv_cb()：从网络层进入协议层 # 本节使用你提交的原代码：\nint recv_cb(int fd){ memset(conn_list[fd].rbuffer,0,BUFFER_LENGTH); int count = recv(fd, conn_list[fd].rbuffer, BUFFER_LENGTH, 0); if (count == 0) { // disconnect printf(\u0026#34;client disconnect: %d\\n\u0026#34;, fd); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);//unfinished return -1; }else if(count\u0026lt;0){ printf(\u0026#34;count:%d,errno:%d ,%s\\n\u0026#34;, count,errno,strerror(errno)); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);//unfinished return 0; } conn_list[fd].rlength=count; // printf(\u0026#34;RECV: %s\\n\u0026#34;, conn_list[fd].rbuffer); #if 0//echo conn_list[fd].wlength=conn_list[fd].rlength; memcpy(conn_list[fd].wbuffer,conn_list[fd].rbuffer,conn_list[fd].wlength); printf(\u0026#34;[%d]RECV: %s\\n\u0026#34;,conn_list[fd].rlength,conn_list[fd].rbuffer); #elif0 http_request(\u0026amp;conn_list[fd]); #else ws_request(\u0026amp;conn_list[fd]); #endif set_event(fd,EPOLLOUT,0); return count; } 3.1 接收数据 # int count = recv(fd, conn_list[fd].rbuffer, BUFFER_LENGTH, 0); 数据从内核 socket 接收缓冲区复制到当前连接的 rbuffer，实际接收长度保存到：\nconn_list[fd].rlength=count; 3.2 三种业务切换 # Echo 分支把收到的数据复制到写缓冲区：\n#if 0//echo conn_list[fd].wlength=conn_list[fd].rlength; memcpy(conn_list[fd].wbuffer,conn_list[fd].rbuffer,conn_list[fd].wlength); printf(\u0026#34;[%d]RECV: %s\\n\u0026#34;,conn_list[fd].rlength,conn_list[fd].rbuffer); HTTP 分支把连接对象交给 `http_request()`： ```c #elif0 http_request(\u0026amp;conn_list[fd]); ``` WebSocket 分支把连接对象交给 `ws_request()`： ```c #else ws_request(\u0026amp;conn_list[fd]); ``` 三种业务共用同一个 `recv_cb()`，只替换上层协议处理函数。这正是 Reactor 与业务分层后的好处。 3.3 从读事件切换到写事件 # set_event(fd,EPOLLOUT,0); http_request() 或 ws_request() 处理完输入后，当前 fd 改为关注 EPOLLOUT。等 socket 可写时，Reactor 会调用 send_cb()。\n四、HTTP 请求：http_request() # 浏览器访问服务器时，会发送类似内容：\nGET / HTTP/1.1 Host: 172.16.145.129:2000 Connection: keep-alive Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Accept: text/html,application/xhtml+xml,application/xml Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 请求由请求行、请求头、空行和可选 body 组成：\nGET / HTTP/1.1 → 请求行 Host → 请求头 Connection → 请求头 User-Agent → 请求头 空行 → 请求头结束 原代码：\nint http_request(struct conn *c){ //printf(\u0026#34;request: %s\\n\u0026#34;,c-\u0026gt;rbuffer); memset(c-\u0026gt;wbuffer,0,BUFFER_LENGTH); c-\u0026gt;wlength=0; c-\u0026gt;status=0; } 当前 http_request() 主要完成一次新响应的状态初始化：\nc-\u0026gt;rbuffer → 当前 HTTP 请求 c-\u0026gt;wbuffer → 清空，准备写响应 c-\u0026gt;wlength → 置 0 c-\u0026gt;status → 置 0，从响应状态机起点开始 它位于：\nrecv_cb() 之后 send_cb() 之前 五、HTTP 响应：直接返回 HTML # 原代码：\nint http_response(struct conn *c){ #if 1 c-\u0026gt;wlength=sprintf(c-\u0026gt;wbuffer,\u0026#34;HTTP/1.1 200 OK\\r\\n\u0026#34; \u0026#34;Content-Type: text/html\\r\\n\u0026#34; \u0026#34;Accept-Ranges: bytes\\r\\n\u0026#34; \u0026#34;Content-Length: 82\\r\\n\u0026#34; \u0026#34;Date: Tue,30 Apr 2024 13:16:46 GMT\\r\\n\\r\\n\u0026#34; \u0026#34;\u0026lt;html\u0026gt;\u0026lt;head\u0026gt;\u0026lt;title\u0026gt;0voice.king\u0026lt;/title\u0026gt;\u0026lt;/head\u0026gt;\u0026lt;body\u0026gt;\u0026lt;h1\u0026gt;King\u0026lt;/h1\u0026gt;\u0026lt;/body\u0026gt;\u0026lt;/html\u0026gt;\\r\\n\\r\\n\u0026#34;); return 0; 其中：\nHTTP/1.1 200 OK Content-Type: text/html Accept-Ranges: bytes Content-Length: 82 Date: Tue,30 Apr 2024 13:16:46 GMT 是 HTTP 状态行和响应头。它们告诉浏览器响应是否成功、body 是什么类型、长度是多少。\n下面是 HTTP body：\n\u0026lt;html\u0026gt;\u0026lt;head\u0026gt;\u0026lt;title\u0026gt;0voice.king\u0026lt;/title\u0026gt;\u0026lt;/head\u0026gt;\u0026lt;body\u0026gt;\u0026lt;h1\u0026gt;King\u0026lt;/h1\u0026gt;\u0026lt;/body\u0026gt;\u0026lt;/html\u0026gt; 浏览器看到 Content-Type: text/html 后，会把 body 当作 HTML 渲染，页面中显示 King。\n这一分支中，HTTP 头和 body 一起写入：\nc-\u0026gt;wbuffer 总长度由 sprintf() 的返回值保存到：\nc-\u0026gt;wlength 调用链：\nrecv_cb() ↓ http_request() ↓ 设置 EPOLLOUT ↓ send_cb() ↓ http_response() ↓ HTTP 头和 HTML 写入 wbuffer ↓ send() 六、返回 index.html # 原代码：\n#elif 0 int filefd =open(\u0026#34;index.html\u0026#34;,O_RDONLY); struct stat stat_buf; fstat(filefd,\u0026amp;stat_buf); c-\u0026gt;wlength=sprintf(c-\u0026gt;wbuffer,\u0026#34;HTTP/1.1 200 OK\\r\\n\u0026#34; \u0026#34;Content-Type: text/html\\r\\n\u0026#34; \u0026#34;Accept-Ranges: bytes\\r\\n\u0026#34; \u0026#34;Content-Length: %ld\\r\\n\u0026#34; \u0026#34;Date: Tue,30 Apr 2024 13:16:46 GMT\\r\\n\\r\\n\u0026#34;, stat_buf.st_size); int count=read(filefd,c-\u0026gt;wbuffer+c-\u0026gt;wlength,BUFFER_LENGTH-c-\u0026gt;wlength); c-\u0026gt;wlength +=count; close(filefd); 执行过程：\nopen(\u0026#34;index.html\u0026#34;) ↓ fstat() 得到文件大小 ↓ HTTP 响应头写入 wbuffer ↓ read() 把 index.html 追加在 HTTP 头后面 ↓ wlength = HTTP 头长度 + 本次文件读取长度 ↓ send_cb() 发送整个 wbuffer stat_buf.st_size 被写入：\nContent-Length: 文件大小 这个分支的特点是：HTTP 头和文件内容都先进入应用层 wbuffer，然后统一调用 send()。\n七、状态机与 sendfile() # 当一个响应需要多个发送阶段时，课程使用 status 保存当前进度：\nstatus = 0 → 构造并发送 HTTP 头 status = 1 → 持续发送文件 status = 2 → 文件发送结束，清理状态 原代码：\n#elif 0 int filefd =open(\u0026#34;index.html\u0026#34;,O_RDONLY); struct stat stat_buf; fstat(filefd,\u0026amp;stat_buf); if(c-\u0026gt;status==0){ c-\u0026gt;wlength=sprintf(c-\u0026gt;wbuffer,\u0026#34;HTTP/1.1 200 OK\\r\\n\u0026#34; \u0026#34;Content-Type: text/html\\r\\n\u0026#34; \u0026#34;Accept-Ranges: bytes\\r\\n\u0026#34; \u0026#34;Content-Length: %ld\\r\\n\u0026#34; \u0026#34;Date: Tue,30 Apr 2024 13:16:46 GMT\\r\\n\\r\\n\u0026#34;, stat_buf.st_size); c-\u0026gt;status=1; }else if (c-\u0026gt;status==1){ int ret=sendfile(c-\u0026gt;fd,filefd,NULL,stat_buf.st_size); if(ret==-1){ printf(\u0026#34;error:%d\\n\u0026#34;,errno); } //c-\u0026gt;wlength=0; memset(c-\u0026gt;wbuffer,0,BUFFER_LENGTH); c-\u0026gt;status=2; }else if(c-\u0026gt;status==2){ c-\u0026gt;wlength=0; memset(c-\u0026gt;wbuffer,0,BUFFER_LENGTH); c-\u0026gt;status=0; } close(filefd); 状态流转：\n第一次进入 http_response() ↓ status == 0 构造 HTTP 响应头 ↓ status = 1 send_cb() 发送 wbuffer，并继续关注 EPOLLOUT ↓ 第二次进入 http_response() ↓ status == 1 sendfile() 发送 index.html ↓ status = 2 继续关注 EPOLLOUT ↓ 第三次进入 http_response() ↓ status == 2 清理 wbuffer 和 wlength ↓ status = 0 重新关注 EPOLLIN sendfile() 直接建立文件 fd 到 socket fd 的发送路径：\nint ret=sendfile(c-\u0026gt;fd,filefd,NULL,stat_buf.st_size); 协议层仍然负责构造 HTTP 头，sendfile() 负责发送文件内容。状态机把两个阶段连接起来。\n八、返回图片 # 图片与 HTML 在网络层都是字节数据，主要区别是 HTTP 的 Content-Type。\n原代码：\n#else int filefd =open(\u0026#34;vip.png\u0026#34;,O_RDONLY); struct stat stat_buf; fstat(filefd,\u0026amp;stat_buf); if(c-\u0026gt;status==0){ c-\u0026gt;wlength=sprintf(c-\u0026gt;wbuffer,\u0026#34;HTTP/1.1 200 OK\\r\\n\u0026#34; \u0026#34;Content-Type: image/png\\r\\n\u0026#34; \u0026#34;Accept-Ranges: bytes\\r\\n\u0026#34; \u0026#34;Content-Length: %ld\\r\\n\u0026#34; \u0026#34;Date: Tue,30 Apr 2024 13:16:46 GMT\\r\\n\\r\\n\u0026#34;, stat_buf.st_size); c-\u0026gt;status=1; }else if (c-\u0026gt;status==1){ int ret=sendfile(c-\u0026gt;fd,filefd,NULL,stat_buf.st_size); if(ret==-1){ printf(\u0026#34;error:%d\\n\u0026#34;,errno); } //c-\u0026gt;wlength=0; memset(c-\u0026gt;wbuffer,0,BUFFER_LENGTH); c-\u0026gt;status=2; }else if(c-\u0026gt;status==2){ c-\u0026gt;wlength=0; memset(c-\u0026gt;wbuffer,0,BUFFER_LENGTH); c-\u0026gt;status=0; } close(filefd); 与 HTML 文件的区别：\n响应内容 打开的文件 Content-Type 网页 index.html text/html PNG 图片 vip.png image/png Reactor 的 recv_cb()、send_cb() 不需要知道 body 是网页还是图片。WebServer 协议层设置正确的响应头即可。\n九、send_cb() 如何驱动多阶段发送 # 原代码：\nint send_cb(int fd){ #if 0 http_response(\u0026amp;conn_list[fd]); #else ws_response(\u0026amp;conn_list[fd]); #endif int count=0; if(conn_list[fd].status==1){ //printf(\u0026#34;SEND:%s\\n\u0026#34;,conn_list[fd].wbuffer); count = send(fd, conn_list[fd].wbuffer,conn_list[fd].wlength, 0); set_event(fd,EPOLLOUT,0); }else if(conn_list[fd].status==2){ set_event(fd,EPOLLOUT,0); }else if(conn_list[fd].status==0){ if(conn_list[fd].wlength !=0){ count = send(fd, conn_list[fd].wbuffer,conn_list[fd].wlength, 0); } set_event(fd,EPOLLIN,0); } //set_event(fd,EPOLLOUT,0); return count; } 9.1 选择协议响应 # 学习 HTTP 时，让写回调进入：\n#if 0 http_response(\u0026amp;conn_list[fd]); 学习 WebSocket 时，让写回调进入：\n#else ws_response(\u0026amp;conn_list[fd]); 9.2 状态与事件切换 # status == 1 → send() 发送当前 wbuffer → 继续设置 EPOLLOUT → 等待下一阶段 status == 2 → 继续设置 EPOLLOUT → 进入状态机清理阶段 status == 0 → 如果 wlength 不为 0，就发送当前数据 → 设置 EPOLLIN → 等待下一次请求 这就是“需要发送多次时怎么办”的答案：\n保存当前发送状态，再通过后续的 EPOLLOUT 事件继续推进，而不是把整个过程强行塞进一次回调。\n十、WebSocket 握手 # WebSocket 用于浏览器与服务器之间的长连接和双向通信。\n建立 WebSocket 连接时，浏览器先发送 HTTP Upgrade 请求，其中包括：\nUpgrade: websocket Connection: Upgrade Sec-WebSocket-Key: T29c4Rys861qGqbGGNdkwA== Sec-WebSocket-Version: 13 你提交的 WebSocket 代码：\n#include\u0026#34;server.h\u0026#34; #include\u0026lt;stdio.h\u0026gt; #define GUID \u0026#34;258EAFA5-E914-47DA-95CA-C5AB0DC85B11\u0026#34; /* Key：\u0026#34;T29c4Rys861qGqbGGNdkwA==\u0026#34; T29c4Rys861qGqbGGNdkwA==258EAFA5-E914-47DA-95CA-C5AB0DC85B11 SHA-1 20 bytes base64 handshake transmission decode encode */ 服务端计算 Sec-WebSocket-Accept 的过程：\n客户端 Sec-WebSocket-Key + 固定 GUID ↓ SHA-1 ↓ 160 bit = 20 bytes ↓ Base64 ↓ Sec-WebSocket-Accept 固定 GUID：\n#define GUID \u0026#34;258EAFA5-E914-47DA-95CA-C5AB0DC85B11\u0026#34; 客户端 Key 放在前面，GUID 放在后面。\n服务端响应格式：\nHTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: 计算结果 返回 101 后，同一个 TCP 连接不再传输普通 HTTP 消息，而是进入 WebSocket 帧传输阶段。\n十一、WebSocket 在 Reactor 中的位置 # 原代码：\nint ws_request(struct conn *c){ printf(\u0026#34;request: %s\u0026#34;,c-\u0026gt;rbuffer); } int ws_response(struct conn *c){ } 当前两个函数是课程为后续实现预留的协议接口。\nws_request() 后续负责：\n握手阶段 → 解析 HTTP Upgrade 请求和 Sec-WebSocket-Key 传输阶段 → 解析 WebSocket frame，完成 decode ws_response() 后续负责：\n握手阶段 → 构造 101 和 Sec-WebSocket-Accept 传输阶段 → 把文本或二进制业务数据 encode 成 WebSocket frame 在 Reactor 中的调用位置：\nEPOLLIN ↓ recv_cb() ↓ ws_request() ↓ 设置 EPOLLOUT ↓ send_cb() ↓ ws_response() ↓ send() WebSocket 是应用层协议，底层仍然是普通 TCP clientfd。Reactor 不需要为 WebSocket 创建一种新 fd。\n十二、使用 wrk 测试 WebServer # 测试命令：\nwrk -t10 -c50 -d10s http://172.16.145.129:2000/ 课程中的测试现象：\n打印每次 request 和 Host 数据时：QPS 约 1.5 万 关闭逐请求打印后：QPS 约 4.5 万 逐请求打印会产生格式化、终端输出和系统调用开销，因此会明显降低服务器吞吐量。\nwrk 结果中主要关注：\nRequests/sec → 每秒请求数，即 QPS Latency → 请求延迟 timeout → 超时数量 read/write → 读写错误 Transfer/sec → 每秒传输数据量 局域网延迟较小；公网还会受到网络时延、丢包、带宽和中间设备影响。\n十三、Connection reset by peer # Connection reset by peer 表示连接被对端通过 TCP RST 重置。\n常见情况：\n客户端提前关闭连接 压测工具结束请求 服务器准备发送时客户端已经退出 网络设备重置连接 服务器应识别错误并清理当前连接，不要让单个客户端断开导致整个进程退出。\n十四、本节课的三个重点 # 重点一：事件与协议函数的对应关系 # clientfd + EPOLLIN → recv_cb() → http_request() / ws_request() clientfd + EPOLLOUT → send_cb() → http_response() / ws_response() 重点二：网络层与协议层分离 # reactor.c → 管理 fd、事件、recv、send webserver.c → 处理 HTTP request 和 response websocket.c → 处理 handshake、decode 和 encode 重点三：多次发送使用状态机 # 一次发送即可完成 → wbuffer + wlength 需要多个发送阶段 → status 保存进度 → 多次 EPOLLOUT 推进 发送静态文件 → HTTP 头放入 wbuffer → 文件内容交给 sendfile() 最终理解：\nReactor 提供统一的事件驱动网络层；HTTP 和 WebSocket 负责协议，业务数据何时读取、何时发送，由 fd 的事件和回调共同驱动。\n","date":"2026年8月9日","externalUrl":null,"permalink":"/MyBlog/p/reactor-webserver-websocket/","section":"Posts","summary":"","title":"Reactor 在 WebServer 与 WebSocket 中的应用","type":"posts"},{"content":"","date":"2026年8月9日","externalUrl":null,"permalink":"/MyBlog/tags/websocket/","section":"Tags","summary":"","title":"WebSocket","type":"tags"},{"content":" 1. 本节课要解决什么问题 # 本节课不是直接实现完整 WebServer，而是先搭建一个能够承载大量网络连接的底层 I/O 框架。在这个框架之上，后续才能继续实现 HTTP 解析、路由、业务处理等功能。\n核心目标有两个：\n使用 Linux epoll 管理大量 socket。 使用 Reactor 思想，把不同 I/O 事件分发给不同回调函数。 当前代码实现的是一个简单的多端口 Echo Server：客户端发来什么，服务端就回写什么。\n2. Reactor 的核心思想 # Reactor 可以概括为：\nI/O 对象（fd） → 发生事件（event） → 执行回调（callback） 本例中的映射关系如下：\nI/O 对象 事件 含义 回调函数 listenfd EPOLLIN 有新连接到达 accept_cb() clientfd EPOLLIN 客户端数据可读 recv_cb() clientfd EPOLLOUT socket 当前可写 send_cb() 因此，主循环不需要判断“这个 fd 到底要做什么业务”，而只需要：\n调用 epoll_wait() 等待事件。 从事件中取得 fd。 根据事件类型，调用该 fd 已经注册好的回调。 这就是“事件驱动”：没有事件时线程阻塞等待，有事件时才处理对应连接。\n2.1 Reactor 的两个重点 # 建立 event 与 callback 的对应关系。 为每一个 I/O 保存独立的连接状态。 第二点非常重要。多个连接的数据不能共用一组读写缓冲区，否则连接之间会互相覆盖。因此每个 fd 都需要自己的：\n读缓冲区 rbuffer 当前读取长度 rlength 写缓冲区 wbuffer 待发送长度 wlength 读事件回调 写事件回调 3. epoll 在 Reactor 中的职责 # epoll 是 Linux 提供的 I/O 多路复用机制。一个线程可以通过一个 epoll 实例监听大量 fd，而不必给每条 TCP 连接创建一个线程。\n三个最重要的接口是：\nint epoll_create1(int flags); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); 其作用分别为：\nepoll_create1()：创建一个 epoll 实例。 epoll_ctl()：添加、修改或删除所关注的 fd 和事件。 epoll_wait()：等待已经注册的 fd 产生事件。 当前代码使用的是默认的 LT（Level Triggered，水平触发）模式，因为没有设置 EPOLLET。\nLT 模式下，只要 fd 仍处于可读或可写状态，epoll_wait() 就会继续报告它。若以后改成 ET（边沿触发），则 socket 必须设置成非阻塞，并且 accept()、recv() 等操作通常要循环执行，直到返回 EAGAIN。\n4. 代码中的连接对象 # struct conn { int fd; char rbuffer[BUFFER_LENGTH]; int rlength; char wbuffer[BUFFER_LENGTH]; int wlength; RCALLBACK send_callback; union { RCALLBACK recv_callback; RCALLBACK accept_callback; } r_action; }; 4.1 为什么需要 struct conn # TCP 是字节流协议，一次 recv() 不保证收到一个完整业务消息：\n一个完整消息可能被拆成多次接收。 多个消息也可能在一次 recv() 中一起到达。 一次 send() 也不保证发送全部待发送数据。 所以 Reactor 不仅要保存回调，还必须保存每个连接的处理进度。严格来说，后续还应增加：\n已读取但尚未解析的数据偏移量； 待发送数据的起始偏移量； 协议解析状态； 连接状态和超时信息； 动态扩容或链式缓冲区。 4.2 为什么 accept_callback 与 recv_callback 使用 union # 监听 fd 的读事件表示“有连接可接受”，其回调是 accept_cb()；客户端 fd 的读事件表示“有数据可接收”，其回调是 recv_cb()。\n同一个 fd 在这里不会同时既是监听 fd 又是客户端 fd，因此两个函数指针可以共用同一块存储空间。union 表达了二者“二选一”的关系。\n不过，代码在赋值时使用了：\nconn_list[sockfd].r_action.recv_callback = accept_cb; 语义上更清晰的写法是：\nconn_list[sockfd].r_action.accept_callback = accept_cb; 调用时也应根据 fd 的角色使用对应成员，或者直接将二者合并成一个名称更通用的 read_callback。\n4.3 为什么可以用 fd 作为数组下标 # 当前代码使用：\nstruct conn conn_list[CONNECTION_SIZE]; 并直接通过 conn_list[fd] 找到连接。这种方法查找复杂度为 O(1)，非常直接。\n但它有两个条件：\n必须保证 0 \u0026lt;= fd \u0026lt; CONNECTION_SIZE，否则会越界。 数组内存非常大。 每个连接对象包含两个 1024 字节缓冲区，再加上长度、fd 和函数指针。100 多万个元素大约需要 2 GiB 以上的地址空间和可观的实际内存。因此“支持 100 万个 fd”不等于“采用这种结构后机器就一定能稳定承载 100 万连接”。生产实现通常会根据实际连接动态分配，或者按块分配连接对象与缓冲区。\n5. 程序完整执行流程 # 5.1 初始化 epoll # 代码中：\nepfd = epoll_create(1); 参数在现代 Linux 中只要大于 0 即可，并不表示只能监听一个 fd。更推荐：\nepfd = epoll_create1(EPOLL_CLOEXEC); 创建失败时必须检查返回值。\n5.2 创建 20 个监听 socket # for (i = 0; i \u0026lt; MAX_PORTS; i++) { int sockfd = init_server(port + i); ... } 程序监听 2000~2019 共 20 个端口。每个监听 fd 都注册 EPOLLIN，并对应 accept_cb()。\n监听 fd 上的 EPOLLIN 不是普通业务数据可读，而是表示全连接队列中存在已经完成握手、可以由 accept() 取出的连接。\n5.3 接受新连接 # accept_cb() 的逻辑是：\n调用 accept()，获得 clientfd。 检查 clientfd \u0026lt; 0。 初始化 conn_list[clientfd]。 给客户端 fd 注册 EPOLLIN。 这时事件映射从：\nlistenfd + EPOLLIN → accept_cb 变成新连接上的：\nclientfd + EPOLLIN → recv_cb 5.4 接收数据 # 当客户端 fd 可读时执行 recv_cb()：\ncount = recv(fd, conn_list[fd].rbuffer, BUFFER_LENGTH, 0); 当前 Echo 逻辑把收到的数据复制到写缓冲区：\nconn_list[fd].wlength = conn_list[fd].rlength; memcpy(conn_list[fd].wbuffer, conn_list[fd].rbuffer, conn_list[fd].wlength); 随后把关注事件修改为 EPOLLOUT：\nclientfd + EPOLLOUT → send_cb 5.5 发送数据 # 当 fd 可写时执行 send_cb()。数据发送结束后，再把关注事件切换回 EPOLLIN，等待客户端下一次发送。\n完整状态流转如下：\n监听 EPOLLIN ↓ accept_cb：建立 clientfd ↓ 客户端 EPOLLIN ↓ recv_cb：接收并准备响应 ↓ 客户端 EPOLLOUT ↓ send_cb：发送响应 ↓ 重新监听客户端 EPOLLIN 6. set_event() 与事件注册 # int set_event(int fd, int event, int flag) 当前约定：\nflag == 1：使用 EPOLL_CTL_ADD。 flag == 0：使用 EPOLL_CTL_MOD。 功能上可行，但 flag 的含义不直观。更清晰的设计是直接传入 EPOLL_CTL_ADD、EPOLL_CTL_MOD，或者分别封装成 add_event() 和 mod_event()。\n同时必须检查 epoll_ctl() 的返回值。例如：\nif (epoll_ctl(epfd, op, fd, \u0026amp;ev) == -1) { perror(\u0026#34;epoll_ctl\u0026#34;); return -1; } set_event() 和 event_rigister() 声明为返回 int，但成功路径没有 return，这属于未定义行为。应补充 return 0。另外，rigister 应改为 register。\n7. 主事件循环 # while (1) { int nready = epoll_wait(epfd, events, 1024, -1); for (i = 0; i \u0026lt; nready; i++) { int connfd = events[i].data.fd; if (events[i].events \u0026amp; EPOLLIN) conn_list[connfd].r_action.recv_callback(connfd); if (events[i].events \u0026amp; EPOLLOUT) conn_list[connfd].send_callback(connfd); } } timeout == -1 表示没有事件时一直阻塞，避免空转消耗 CPU。\n两个独立的 if 还是 if ... else if？ # 两个独立 if 理论上允许同一次返回同时处理读、写事件；else if 每轮只处理其中一种。\n但不能简单认为两个 if 一定更好。若读回调发现连接已经断开并关闭了 fd，随后继续执行写回调就可能访问已经失效或被复用的 fd。稳妥做法是：\n先处理 EPOLLERR、EPOLLHUP、EPOLLRDHUP。 回调返回关闭状态后，不再处理该 fd 的其他事件。 再依据连接状态处理读写事件。 8. 课堂代码补充 # 这份代码主要是为了演示 Reactor 的整体流程，所以有些细节课堂上只是带了一下。自己再写一遍时，可以顺手补上下面这些地方。\n关于 send()\n课上代码里有一句：\nint count = send(fd, conn_list[fd].wbuffer, count, 0); 这里最后的 count 还没有赋值，应该写成 conn_list[fd].wlength。\n另外，一次 send() 不一定能把 wlength 个字节全部发完。可以在连接对象里再记一个 woffset，每次发送后往后移动。数据还没发完就继续等 EPOLLOUT，全部发完后再切回 EPOLLIN。\n关于 recv()\n记住三个返回值：\n大于 0：本次收到的字节数； 等于 0：对端关闭连接； 小于 0：要结合 errno 判断，非阻塞情况下可能只是暂时没有数据。 不能让 -1 继续参与后面的 memcpy()，否则长度转换后可能越界。EINTR 可以重试，EAGAIN/EWOULDBLOCK 表示这次先读到这里，其他情况再按真正的错误处理。\n还有一点：recv() 收到的是一段字节，不会自动在末尾补 \\0。要用 %s 打印时需要自己留位置并补结束符；如果处理二进制数据，就一直按长度来处理。\n关于非阻塞\nReactor 一般要和非阻塞 socket 配合。监听 fd 和客户端 fd 都可以设置成 O_NONBLOCK，避免某一次 accept()、recv() 或 send() 卡住整个事件循环。\nfcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK); 如果以后从 LT 改成 ET，accept() 和 recv() 通常都要放在循环里，一直处理到返回 EAGAIN/EWOULDBLOCK。当前是 LT，一次只 accept() 一个连接也能继续触发，不过连接集中到来时，循环取完会更快一些。\n关于 fd 和连接状态\n当前写法直接用 fd 作为 conn_list 的下标，使用前要判断：\nif (fd \u0026lt; 0 || fd \u0026gt;= CONNECTION_SIZE) return -1; 连接关闭时也不只是 close(fd)，对应的读写长度、偏移量和回调最好一起清空。因为 fd 的数字会被复用，下一个新连接有可能再次拿到同一个编号。\nfd 也不能当作累计连接数。想每建立 1000 个连接打印一次，应该另外维护一个 accepted_count，不能用 clientfd % 1000 来判断。\n其他随手记下的点\nsocket()、bind()、listen()、epoll_ctl()、epoll_wait() 等系统调用都要看返回值； 监听 socket 通常会设置 SO_REUSEADDR； listen(sockfd, 10) 中的 10 是 backlog，不是服务器最多只能保持 10 条连接； 主循环除了 EPOLLIN、EPOLLOUT，还要留意 EPOLLERR、EPOLLHUP 和 EPOLLRDHUP； 对端关闭后继续发送可能触发 SIGPIPE，可以忽略该信号，或在 send() 时使用 MSG_NOSIGNAL； TIME_SUB_MS(current, begin) 当前记录的是两次打印之间的间隔，不是从程序启动开始的总耗时。 9. 大包、拆包与粘包 # 代码中的读缓冲区只有 1024 字节。若客户端发送 2048 字节，一次 recv() 可能只收到其中一部分；即使缓冲区足够大，TCP 也不保证一次接收对应一次发送。\n因此不能把：\n一次 send = 一个业务消息 = 一次 recv 当成可靠规律。\n业务协议需要定义消息边界，常见方法有：\n固定长度消息。 特殊分隔符，例如文本协议中的换行。 消息头携带正文长度，例如“4 字节长度 + 消息体”。 HTTP 中使用请求行、首部、Content-Length 或分块传输规则。 Reactor 收到数据后应先追加到该连接自己的缓冲区，再交给协议解析器：\nrecv → 追加到连接缓冲区 → 判断是否形成完整消息 → 未完整：保留并等待下次 EPOLLIN → 已完整：取出消息并处理，剩余数据继续保留 如果固定数组空间不足，可以使用动态缓冲区、环形缓冲区或链式缓冲区。\n10. 为什么会出现 Too many open files # 报错对应 errno == EMFILE（常见值为 24），表示当前进程已经达到可打开 fd 的上限。\n一条 TCP 连接在客户端和服务端各自都占用一个 fd。百万连接测试至少要同时考虑：\n10.1 进程级限制 # 查看当前 shell 的限制：\nulimit -n ulimit -a 临时提高当前 shell 的 soft limit：\nulimit -n 1048576 程序必须从修改后的 shell 中启动，才能继承该限制。还要确保 hard limit 足够高。\n10.2 系统级限制 # cat /proc/sys/fs/file-max cat /proc/sys/fs/file-nr fs.file-max 是系统范围的文件句柄限制，ulimit -n 是单进程/会话限制，两者不是同一个概念。持久化设置还取决于系统的 PAM、systemd unit 和发行版配置，不能只改一个参数就假定生效。\n10.3 程序自身限制 # CONNECTION_SIZE 是否覆盖可能出现的 fd 值； 进程虚拟内存和实际物理内存是否足够； 每连接缓冲区是否过大； 是否存在 fd 泄漏； epoll 实例和监听 socket 自己也会占 fd。 11. 为什么单个目标端口大约只能建立数万连接 # 一条 TCP 连接由五元组唯一标识：\n源 IP、源端口、目的 IP、目的端口、传输层协议 在单台压测客户端连接单台服务器的单个端口时：\n源 IP 固定； 目的 IP 固定； 目的端口固定； 协议固定为 TCP； 主要只能变化源端口。 当可用临时源端口耗尽时，客户端可能报：\nCannot assign requested address 11.1 一个需要校正的细节 # 客户端临时端口范围并非固定为 1024~65535。Linux 实际自动分配范围应通过下面的命令查看：\nsysctl net.ipv4.ip_local_port_range 不同系统、不同配置的范围可能不同，部分端口还可能被占用、保留，或暂时处于 TIME_WAIT 等状态。因此可建立数量通常小于理论上的 65535。\n11.2 为什么增加到 20 个服务端端口有效 # 将目标端口从一个增加到 2000~2019，改变了五元组中的“目的端口”。只要客户端把连接分散到这些端口，同一个源端口就可以与不同目的端口组成不同连接，理论连接空间随之扩大。\n其他扩展方式还包括：\n使用多个客户端源 IP； 使用多台压测机； 使用多个服务端 IP； 使用多个网络命名空间，但必须正确配置各自地址和路由。 不能仅用单台客户端的结果直接代表服务端的最大承载能力，因为压测端本身可能先耗尽端口、fd、CPU、内存或 conntrack 容量。\n12. nf_conntrack 应该怎样理解 # nf_conntrack 是 Linux Netfilter 的连接跟踪机制，防火墙、NAT 等功能会用到它。它不是 epoll 工作所必需的组成部分。\n如果测试路径启用了连接跟踪，并且连接数达到表容量，才需要观察：\nsysctl net.netfilter.nf_conntrack_max cat /proc/sys/net/netfilter/nf_conntrack_count 旧资料中的 ip_conntrack 是较早的名称；现代内核通常使用 nf_conntrack。只有确实需要且模块尚未加载时，才考虑：\nsudo modprobe nf_conntrack 不应看到连接问题就盲目加载模块或增大参数。应先确认机器是否启用了防火墙/NAT、模块是否存在、表是否真的已满。在服务端还是客户端调整，取决于哪台机器实际承担连接跟踪；中间的 NAT/防火墙设备也可能成为瓶颈。\n13. 百万连接断开时为什么 CPU 会突然升高 # 如果一次性终止百万连接的一端，另一端会在短时间内集中处理大量 TCP 状态变化与关闭事件，例如 FIN/RST、epoll 唤醒、连接对象清理、定时器更新等。\n这会形成“惊群式的业务处理洪峰”或资源回收洪峰，表现为 CPU 短时暴增。它不一定表示程序死循环，而可能是系统在集中处理积压事件。\n优化方向包括：\n分批建立和分批关闭连接； 减少关闭路径上的日志和同步操作； 将耗时清理异步化； 使用高效定时器结构，如时间轮； 避免每连接大量内存分配和释放； 做好背压和过载保护； 使用多 Reactor、多线程或多进程分摊事件处理； 用火焰图、perf、系统指标确认 CPU 真正消耗在哪里。 优化目标不是保证 CPU 永不升高，而是让峰值可控、持续时间缩短，并保证服务仍能继续响应。\n14. “百万并发”不等于“百万 QPS” # 性能测试至少要区分四个维度：\n指标 含义 并发连接数 同一时刻保持的连接数量 QPS/吞吐量 单位时间完成的请求或消息数量 延迟 一次请求从发出到收到响应所需时间，通常关注 P50/P95/P99 测试用例 报文大小、收发频率、连接是否长连、业务是否计算或访问存储 保持 100 万条空闲 TCP 连接，和让 100 万条连接持续高速收发数据，是完全不同的负载。前者更考验 fd、内存和内核连接管理；后者还会迅速消耗 CPU、网络带宽、内存带宽以及业务依赖资源。\n因此，一份可信的测试报告至少应说明：\n客户端和服务端硬件配置； 内核和发行版版本； 所有相关内核参数与 fd 限制； 连接建立速率； 活跃连接与空闲连接比例； 请求/响应大小和频率； QPS、吞吐量、错误率和延迟分位数； CPU、内存、网络、fd、conntrack 使用率； 测试持续时间和连接关闭方式。 15. 本节课一句话总结 # Reactor 的本质不是“用了 epoll 就很快”，而是把每个 fd 的状态独立保存起来，由事件循环将 I/O + event 精确分发给相应 callback；百万连接则是在这个正确模型之上，再共同解决 fd、内存、端口空间、内核网络栈和压测方法等系统性限制。\n","date":"2026年8月8日","externalUrl":null,"permalink":"/MyBlog/p/reactor-epoll-million-connections/","section":"Posts","summary":"","title":"Reactor 与 epoll：百万并发网络服务器","type":"posts"},{"content":"","date":"2026年8月8日","externalUrl":null,"permalink":"/MyBlog/tags/%E9%AB%98%E5%B9%B6%E5%8F%91/","section":"Tags","summary":"","title":"高并发","type":"tags"},{"content":"","date":"2026年8月7日","externalUrl":null,"permalink":"/MyBlog/tags/io-%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8/","section":"Tags","summary":"","title":"IO 多路复用","type":"tags"},{"content":" 1. 为什么需要 IO 多路复用 # 网络服务器主要管理两类 socket：\n监听 socket（listenfd / 代码中的 sockfd） 它出现可读事件，通常表示已经完成 TCP 握手的连接正在等待 accept()。 已连接 socket（clientfd / connfd） 它出现可读事件，可能表示收到了数据，也可能表示对端关闭连接；随后由 recv() 判断具体情况。它也可能出现可写、错误、挂断等事件。 最直接的服务器模型是“一连接一线程”：主线程执行 accept()，每建立一个连接便创建一个线程，由该线程阻塞在 recv() 上。\nint clientfd = accept(sockfd, ...); pthread_create(\u0026amp;thid, NULL, client_thread, \u0026amp;clientfd); 这种模型的优点是代码直观，一个线程可以按顺序处理一个连接的收、发逻辑。缺点是连接数增加时，需要大量线程；线程会占用栈空间和内核资源，还会产生调度及上下文切换成本。许多长连接大部分时间并没有数据，线程却仍然存在，因此不适合大量空闲连接并发存在的场景。\nIO 多路复用的核心思想：让一个线程同时等待多个 fd。线程不再阻塞于某一个 recv()，而是阻塞在 select()、poll() 或 epoll_wait() 上；哪些 fd 已经就绪，线程就处理哪些 fd。\n“多路”指多个 IO/fd，“复用”指复用同一个线程进行等待和处理。它复用的是等待能力，并不会自动并行执行业务代码。\n2. “IO 就绪”与“完成 IO” # select、poll、epoll 提供的是就绪通知机制：\n监听 fd 可读：通常可以调用 accept()。 客户端 fd 可读：可以调用 recv()；也可能是对端关闭或发生错误。 客户端 fd 可写：内核发送缓冲区当前有空间，并不等于全部数据已经发送完成。 它们只告诉应用程序“现在适合尝试某项 IO 操作”，不会替应用程序完成请求解析、业务计算和响应组装。\n一个长连接的生命周期会经历许多次事件，而不是“连接一次只触发一个事件”：\n建立连接 → 收到请求 → 发送响应 → 再次收到请求 → …… → 断开连接 因此，用户在线数或 TCP 连接数并不等于同一时刻的活跃请求数。例如服务器可能维护 100 万个连接，但某一时刻只有 1 万个连接真正产生事件。高并发服务器真正希望快速取得并处理的是这 1 万个就绪 fd。\n3. select # 3.1 接口 # int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); 五类参数的含义：\nnfds：三个集合中最大 fd 值加 1，而不是 fd 的数量。 readfds：关注可读事件的 fd 集合。 writefds：关注可写事件的 fd 集合。 exceptfds：关注异常条件的 fd 集合；它并不是所有网络错误的统一集合。 timeout：最长等待时间。 NULL：一直阻塞，直到出现事件或被信号中断。 {0, 0}：立即返回，成为轮询。 指定时间：超时后返回 0，可让事件循环顺便检查定时任务，但精确定时器通常还需要专门的数据结构。 返回值：\n\u0026gt; 0：返回集合中就绪项的数量。 = 0：超时。 = -1：调用失败，例如被信号中断时 errno == EINTR。 3.2 如何理解 fd_set # fd_set 可以理解成一个位图集合：fd 是非负整数，位图中编号为 fd 的那一位表示该 fd 是否属于集合。\nfd 编号： 0 1 2 3 4 5 6 7 8 位图值： 0 0 0 1 1 1 1 0 0 ↑ 关注 3、4、5、6 常用宏：\nFD_ZERO(\u0026amp;set); // 清空集合 FD_SET(fd, \u0026amp;set); // 加入 fd FD_CLR(fd, \u0026amp;set); // 移除 fd FD_ISSET(fd, \u0026amp;set); // 判断 fd 是否位于集合中 在典型 Linux/glibc 环境中，FD_SETSIZE 为 1024，fd_set 可表示编号 0～1023 的 fd，通常占 1024 bit，即 128 byte。但应以当前平台的 FD_SETSIZE 和 sizeof(fd_set) 为准。\n需要注意：在 Linux 上，仅仅尝试修改 FD_SETSIZE 并不能可靠地突破 select() 的 1024 fd 限制；处理大量 fd 应改用 poll 或 epoll。此外，“最多 1024 个 fd”并不完全等于“最多 1024 个客户端”，因为进程还会占用标准输入输出、监听 socket、文件等其他 fd，而且 fd 编号必须小于 FD_SETSIZE。\n3.3 为什么代码需要 rfds 和 rset 两份集合 # 附件代码中：\nfd_set rfds, rset; FD_ZERO(\u0026amp;rfds); FD_SET(sockfd, \u0026amp;rfds); while (1) { rset = rfds; int nready = select(maxfd + 1, \u0026amp;rset, NULL, NULL, NULL); ... } 原因是 select() 的 fd 集合是传入兼传出参数：\n调用前，集合表示“应用程序关注哪些 fd”。 返回后，集合被改写，只保留本次已经就绪的 fd。 因此：\nrfds 保存长期关注的完整集合（整集）。 rset 是每次调用时复制出的工作集合，返回后用于判断本轮哪些 fd 就绪。 代码先判断监听 fd：\nif (FD_ISSET(sockfd, \u0026amp;rset)) { int clientfd = accept(sockfd, ...); FD_SET(clientfd, \u0026amp;rfds); } 然后从较小 fd 一直扫描到 maxfd，找出就绪客户端并执行 recv()：\nfor (int i = sockfd + 1; i \u0026lt;= maxfd; i++) { if (FD_ISSET(i, \u0026amp;rset)) { recv(i, ...); } } 3.4 优缺点 # 优点：\n一个线程可以等待多个 fd。 跨 Unix 类系统的可移植性较好。 fd 数量较少时足够简单实用。 缺点：\n每次调用都要把集合从用户态复制到内核态，返回时还要复制回来。 内核需要检查关注集合；应用层也要扫描到 maxfd 才知道谁就绪。 集合会被改写，调用前必须重新准备。 Linux 上受 FD_SETSIZE 限制。 参数和集合管理较烦琐。 4. poll # 4.1 数据结构与接口 # poll 使用结构体数组描述关注对象：\nstruct pollfd { int fd; // 被监控的 fd short events; // 输入：关注的事件 short revents; // 输出：实际发生的事件 }; int poll(struct pollfd *fds, nfds_t nfds, int timeout); 常见事件包括：\nPOLLIN：可读。 POLLOUT：可写。 POLLERR：发生错误。 POLLHUP：挂断。 POLLNVAL：fd 无效。 timeout 的单位是毫秒：\n-1：一直阻塞。 0：立即返回。 \u0026gt; 0：最多等待指定毫秒数。 与 select 相比，pollfd 把“关注的事件”和“实际发生的事件”分开，因此不必在每轮调用前重建整个关注集合。它也没有 FD_SETSIZE 这种固定位图上限；实际容量仍受进程 fd 限额和内存限制。\n4.2 poll 是否从根本上解决了性能问题 # 没有。每次调用 poll() 时，仍需把 pollfd 数组交给内核；内核和应用层仍需要在数组中检查事件。因此大量 fd 中只有少量就绪时，工作量仍与整个关注集合的规模相关。\npoll 的主要价值是接口更统一、没有 select 的固定 fd 编号限制，而不是在大量连接场景中获得 epoll 那种只返回就绪项的机制。\n适合场景通常包括：\n需要比 select 更方便的事件表达方式。 fd 数量不特别大。 需要较好的 Unix 平台可移植性，而不能使用 Linux 专有的 epoll。 4.3 附件 poll 代码中的两个重要问题 # 原代码连续调用了两次 poll()：\npoll(fds, maxfd + 1, -1); int nready = poll(fds, maxfd + 1, -1); 第一次调用已经取得事件，第二次又重新阻塞；应删除第一次，只保留：\nint nready = poll(fds, maxfd + 1, -1); 此外，代码用 fd 值作为数组下标，但数组被 {0} 初始化。未使用位置的 fd 因而是 0，会意外重复监控标准输入。poll 约定 fd \u0026lt; 0 的项会被忽略，因此所有空槽应初始化为 -1。更常见的写法是紧凑地保存有效 pollfd 项，而不是把 fd 直接当数组下标。\n5. epoll # epoll 是 Linux 专用的 IO 事件通知机制。它将“维护关注集合”和“等待就绪事件”拆开，尤其适合大量连接、少量活跃的服务器场景。\n5.1 三个核心接口 # 1. 创建 epoll 实例 # int epfd = epoll_create(1); 返回的 epfd 本身也是一个 fd，代表一个内核中的 epoll 实例。较新的代码通常使用：\nint epfd = epoll_create1(EPOLL_CLOEXEC); epoll_create(size) 的 size 在现代 Linux 中已被忽略，但必须大于 0；保留它是为了兼容旧接口。它不是“单次最多返回多少个就绪事件”。单次最多返回多少项由 epoll_wait() 的 maxevents 决定。\n2. 增删改关注对象 # int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 三种操作：\nEPOLL_CTL_ADD：加入一个 fd。 EPOLL_CTL_DEL：删除一个 fd。 EPOLL_CTL_MOD：修改该 fd 关注的事件或关联数据。 事件结构体：\nstruct epoll_event { uint32_t events; // EPOLLIN、EPOLLOUT 等 epoll_data_t data; // 可保存 fd、指针等用户数据 }; 附件先注册监听 fd：\nev.events = EPOLLIN; ev.data.fd = sockfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, \u0026amp;ev); accept() 得到新客户端后，再单独将 clientfd 加入关注集合。\n3. 等待就绪事件 # int nready = epoll_wait(epfd, events, maxevents, timeout); events：用户提供的结果数组，用来接收本次就绪项。 maxevents：结果数组最多能装多少项，必须大于 0；它不是 epoll 能管理的 fd 总数。 timeout：-1 一直等待，0 立即返回，正数表示最多等待的毫秒数。 返回值：本轮写入 events 数组的就绪项数，超时返回 0，失败返回 -1。 应用只需遍历 events[0..nready-1]：\nfor (int i = 0; i \u0026lt; nready; i++) { int fd = events[i].data.fd; ... } 5.2 “整集”和“就绪集” # 从概念上看，epoll 实例在内核中维护两个重要集合：\n关注集合（interest list）：应用通过 epoll_ctl() 增、删、改的所有 fd。 就绪集合（ready list）：其中当前有事件需要应用处理的 fd。 传统实现说明中常把关注集合描述为红黑树、就绪集合描述为链表。但这些属于 Linux 内核实现细节，理解接口时更重要的是“长期关注集合”和“当前就绪集合”的分离，不应让业务代码依赖具体内部数据结构。\n5.3 为什么 epoll 更适合大并发 # 以 100 万个连接、当前 1 万个连接活跃为例：\nselect/poll：每轮等待都需要提交并检查规模接近 100 万的关注集合，应用还要从大集合里寻找就绪项。 epoll：连接建立或状态变化时，通过 epoll_ctl() 增量维护关注集合；epoll_wait() 主要把当前就绪项返回给应用，应用遍历约 1 万个结果。 因此，epoll 的优势不应简单概括成“完全没有遍历或拷贝”，而应表述为：\n不必在每次等待时重新提交整个关注集合。 应用遍历的是本轮返回的就绪项，而不是从 0 扫描至最大 fd。 连接管理是增量进行的，适合大量长连接中只有少量活跃的情形。 不过，epoll 不会消除业务处理、数据复制、系统调用等全部成本；当绝大部分连接始终活跃时，它相对 poll 的优势也会缩小。\n5.4 LT 与 ET # epoll 有两种常见触发方式：\nLT（Level Triggered，水平触发）：默认方式。只要 fd 仍处于就绪状态，后续 epoll_wait() 还会继续报告，较容易编写。 ET（Edge Triggered，边沿触发）：只有状态发生变化时重点通知。通常必须配合非阻塞 fd，并持续 accept() / recv() / send()，直到返回 -1 且 errno 为 EAGAIN 或 EWOULDBLOCK，否则可能遗漏尚未处理完的数据。 附件只设置了 EPOLLIN，没有设置 EPOLLET，因此使用的是 LT。\n6. 从 IO 多路复用到 Reactor # select、poll、epoll 解决的是：\n哪些 fd 上发生了应用关心的 IO 事件？\n但实际服务器还需要解决：\n某类事件发生以后，应当执行哪段业务逻辑？\nReactor 是一种事件驱动的程序组织模式。应用先把“事件”和“处理函数”注册起来；事件循环等待事件，事件发生后再把它分派给对应处理函数。\n注册 fd + 关注事件 + 回调函数 ↓ select / poll / epoll 等待 ↓ 得到就绪事件 ↓ Reactor 分派事件 ↓ accept_cb / recv_cb / send_cb 例如：\nlistenfd + READ -\u0026gt; accept_cb() clientfd + READ -\u0026gt; recv_cb() clientfd + WRITE -\u0026gt; send_cb() 因此，可以把两者的关系概括为：\nselect/poll/epoll 是底层的事件检测或事件分离机制。 Reactor 是上层的事件注册、循环等待和回调分派模式。 Reactor 关注的中心从“对某个 fd 写一段固定流程”转为“某个 fd 的某种事件应该调用哪个 handler”。 它和路由有相似之处：路由根据请求路径找到处理器，Reactor 根据 fd 上发生的事件找到处理器。但 Reactor 还负责事件监听、生命周期管理和持续循环，因此不能简单等同于路由。\n典型 Reactor 由以下部分组成：\n事件源：监听 socket、客户端 socket、定时器等。 事件多路分离器：select、poll 或 epoll。 Reactor / Dispatcher：事件循环与分派器。 Handler / Callback：处理连接、读取、写入、关闭等事件的函数。 Reactor 只是组织 IO 事件处理的模式，并不意味着业务一定只使用一个线程。常见结构包括：\n单 Reactor、单线程。 单 Reactor + 工作线程池。 主 Reactor 负责接收连接，多个从 Reactor 负责已连接 socket，再配合工作线程池。 耗时业务不能长期阻塞事件循环，否则一个回调会拖延其他连接；CPU 密集型或阻塞型任务通常交给工作线程池。\n7. 三种工具对比 # 特性 select poll epoll 平台 多种 Unix 类系统 多种 Unix 类系统 Linux 关注集合表示 fd_set 位图 pollfd 数组 内核维护的 epoll 关注集合 每轮是否重新提交整个关注集合 是 是 否，使用 epoll_ctl 增量维护 应用如何找就绪项 扫描到 maxfd 扫描数组 遍历 epoll_wait 返回的就绪数组 固定 1024 fd 编号限制 Linux 上通常有 无此固定限制 无此固定限制 主要适用情况 fd 较少、重视可移植性 fd 中等、重视可移植性和统一事件接口 Linux 大量连接、少量活跃 不能笼统地说谁在所有场景下一定最快。fd 很少时差别可能很小；平台兼容性、接口复杂度、连接活跃比例和业务负载都应纳入选择。\n8. 附件代码的演进关系 # 代码使用条件编译展示了四个阶段：\n单连接阻塞处理 ↓ 循环 accept，但一个连接的 recv 会阻塞后续连接 ↓ 一连接一线程，逻辑简单但线程成本随连接数增长 ↓ select / poll / epoll，一个线程等待多个 fd ↓ 进一步封装事件、状态与回调，即 Reactor 当前真正编译的是最后的 #else，即 epoll 版本；前面的分支因为使用 #if 0 / #elif 0 而不会参与编译。\n9. 先记住三套代码共同的骨架 # 学习这三个接口时，不要一开始背所有参数。先固定服务器永远重复的四步：\n1. 注册：告诉系统要关注哪些 fd、哪些事件 2. 等待：阻塞到至少一个事件发生 3. 分发：判断是监听 fd，还是客户端 fd 4. 更新：新连接加入，断开的连接删除，需要时修改关注事件 把三种接口代入同一骨架：\n阶段 select poll epoll 保存关注对象 fd_set rfds struct pollfd fds[] 内核中的 epoll 关注集合 注册新客户端 FD_SET(clientfd, \u0026amp;rfds) 添加一个 pollfd epoll_ctl(...ADD...) 等待事件 select(...) poll(...) epoll_wait(...) 找出就绪对象 扫描并调用 FD_ISSET 扫描并检查 revents 直接遍历返回的 events 删除客户端 FD_CLR(fd, \u0026amp;rfds) 删除数组项或设为 -1 epoll_ctl(...DEL...) 下面三段代码都实现同一个核心功能：\n监听 fd 可读 → accept 新连接 → 注册 clientfd clientfd 可读 → recv 数据 → send 回显 连接关闭/出错 → 删除 clientfd → close 为了突出多路复用主干，示例省略了非阻塞设置、完整发送缓冲区、信号处理等生产级细节。\n10. select 核心代码逐段对照 # 10.1 初始化：建立长期关注集合 # fd_set rfds, rset; // 完整关注集合、本轮就绪集合 FD_ZERO(\u0026amp;rfds); // 初始为空 FD_SET(sockfd, \u0026amp;rfds); // 首先关注监听 fd int maxfd = sockfd; // select 要知道最大的 fd 此时可以想象：\nrfds = { sockfd } 10.2 每轮复制并等待 # while (1) { rset = rfds; int nready = select(maxfd + 1, \u0026amp;rset, NULL, NULL, NULL); 这里两个集合的职责一定要分清：\n调用前：rfds = 长期关注的完整集合 rset = 复制品，也包含完整集合 返回后：rfds = 保持不变，供下一轮继续使用 rset = 被 select 改写，只保留本轮就绪 fd maxfd + 1 中的 +1 是因为 nfds 表示检查区间的右边界：内核检查 fd 0 到 maxfd，即 [0, maxfd + 1)。\n10.3 监听 fd 就绪：接收并注册新连接 # if (FD_ISSET(sockfd, \u0026amp;rset)) { // sockfd 可读不是有业务数据，而是有连接可以取出 int clientfd = accept(sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len); // accept 不会删除 sockfd；它返回一个新的 clientfd FD_SET(clientfd, \u0026amp;rfds); if (clientfd \u0026gt; maxfd) maxfd = clientfd; } 要点：\n检查的是返回集合 rset，因为要问“本轮谁就绪”。 新连接加入的是长期集合 rfds，因为要从下一轮开始持续关注它。 accept() 从已完成连接队列取出一个连接，但 sockfd 仍然继续监听。 新 clientfd 本轮不在 rset 中，从下一轮开始接受监控。 10.4 扫描客户端：处理并删除 # for (int fd = sockfd + 1; fd \u0026lt;= maxfd; ++fd) { if (!FD_ISSET(fd, \u0026amp;rset)) continue; // 这个 fd 本轮不可读 char buffer[1024] = {0}; int count = recv(fd, buffer, sizeof(buffer), 0); if (count \u0026gt; 0) { send(fd, buffer, count, 0); // Echo：原样回发 } else { // count == 0 表示对端关闭；教学代码也在出错时删除 FD_CLR(fd, \u0026amp;rfds); close(fd); } } } 这里的“可读”应理解为：现在调用读取类操作能够立即得到结果，而不会因为没有事件一直阻塞：\n监听 fd 可读 → 调用 accept()，取出一个连接 客户端 fd 可读 → 调用 recv()，读取数据或得知对端关闭 maxfd = sockfd 也不是说监听 fd 永远最大，而是初始化时集合中只有 sockfd。加入新客户端以后，再通过比较更新 maxfd。\nselect 版最值得背下来的代码主干是：\nrset = rfds; nready = select(maxfd + 1, \u0026amp;rset, NULL, NULL, NULL); if (FD_ISSET(sockfd, \u0026amp;rset)) { clientfd = accept(...); FD_SET(clientfd, \u0026amp;rfds); } for (fd = sockfd + 1; fd \u0026lt;= maxfd; fd++) { if (FD_ISSET(fd, \u0026amp;rset)) recv(fd, ...); } 记忆关键词：复制集合、maxfd + 1、扫描、FD_ISSET。\n11. poll：对照附件代码理解 # 你的写法直接把 fd 当作 fds 数组下标：fd 3 放在 fds[3]，fd 4 放在 fds[4]。下面保留这种写法，方便和原代码逐行对应。\n11.1 初始化数组 # #define MAX_FDS 1024 struct pollfd fds[MAX_FDS]; // poll 会忽略 fd \u0026lt; 0 的项 for (int i = 0; i \u0026lt; MAX_FDS; ++i) { fds[i].fd = -1; fds[i].events = 0; fds[i].revents = 0; } fds[sockfd].fd = sockfd; fds[sockfd].events = POLLIN; int maxfd = sockfd; 此时：\n假设 sockfd = 3： fds[3].fd = 3 fds[3].events = POLLIN events = POLLIN 表示“我希望观察可读”，revents 表示 poll() 返回时“本轮实际发生了什么”。\n11.2 等待事件 # while (1) { int nready = poll(fds, maxfd + 1, -1); 调用前后：\nevents = 输入：希望关注什么 revents = 输出：实际上发生了什么 与 select 不同，events 不会被替换成就绪集合，因此下一轮通常不必重建所有关注事件；但应把 revents 当作当轮结果使用。\n附件原本连续调用了两次 poll()，应删掉第一次，否则第一次取得事件后，第二次又会重新阻塞。\n11.3 监听 fd 就绪：注册新客户端 # if (fds[sockfd].revents \u0026amp; POLLIN) { // 监听 fd 可读：取出一个新连接 int clientfd = accept(sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len); // clientfd 直接作为数组下标 fds[clientfd].fd = clientfd; fds[clientfd].events = POLLIN; if (clientfd \u0026gt; maxfd) maxfd = clientfd; } 例如 sockfd = 3、accept() 返回 clientfd = 4：\nfds[3] 管理监听 fd 3：可读时调用 accept() fds[4] 管理客户端 fd 4：可读时调用 recv() 11.4 扫描客户端的 revents # for (int fd = sockfd + 1; fd \u0026lt;= maxfd; ++fd) { if (!(fds[fd].revents \u0026amp; POLLIN)) continue; // fd 本轮没有发生可读事件 char buffer[1024] = {0}; int count = recv(fd, buffer, sizeof(buffer), 0); if (count \u0026gt; 0) { send(fd, buffer, count, 0); } else { close(fd); fds[fd].fd = -1; // 让 poll 忽略这个位置 fds[fd].events = 0; } } } poll 版最值得背下来的代码主干是：\nfds[clientfd].fd = clientfd; fds[clientfd].events = POLLIN; nready = poll(fds, maxfd + 1, -1); for (fd = sockfd + 1; fd \u0026lt;= maxfd; ++fd) { if (fds[fd].revents \u0026amp; POLLIN) recv(fd, ...); } 记忆关键词：events 是我想观察什么，revents 是本轮实际发生了什么。\n12. epoll 核心代码逐段对照 # epoll 的明显区别是：应用不再把完整关注数组传给每一次等待，而是提前通过 epoll_ctl() 增量维护关注集合。\n12.1 创建实例并注册监听 fd # int epfd = epoll_create(1); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = sockfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, \u0026amp;ev); 此处可以理解为：\nepoll_create：创建一个事件管理器 epoll_ctl ADD：向管理器登记“关注 sockfd 的可读事件” 其中 ev.data.fd = sockfd 可以看成给这次登记贴上“sockfd 身份标签”。内核会保存这个标签，等该 fd 就绪时再原样放进返回结果中。\n12.2 等待并直接遍历就绪数组 # while (1) { struct epoll_event events[1024] = {0}; int nready = epoll_wait(epfd, events, 1024, -1); for (int i = 0; i \u0026lt; nready; ++i) { int fd = events[i].data.fd; 这里两个变量要严格分开：\nev ：调用 epoll_ctl() 时送进去的登记卡 events[] ：调用 epoll_wait() 时拿回来的结果数组 例如注册时写入：\nev.data.fd = 3; epoll_ctl(epfd, EPOLL_CTL_ADD, 3, \u0026amp;ev); 当 fd 3 可读时，epoll_wait() 可能返回：\nnready = 1 events[0].data.fd = 3 events[0].events = EPOLLIN 所以 events[i].data.fd 不是凭空出现的，它就是注册时 ev.data.fd 中保存的身份标签。\n12.3 监听 fd 就绪：ADD 新客户端 # if (fd == sockfd) { // 监听 fd 可读：从连接队列中取出一个连接 int clientfd = accept(sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len); // 给新客户端准备新的登记信息 ev.events = EPOLLIN; ev.data.fd = clientfd; epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, \u0026amp;ev); continue; } accept() 不会删除监听 fd。假设 sockfd = 3、它返回 clientfd = 5，注册完成后可以这样理解：\nfd 3 → 可读时返回标签 3 → 调用 accept() fd 5 → 可读时返回标签 5 → 调用 recv() 12.4 客户端就绪：读取或 DEL # if (events[i].events \u0026amp; EPOLLIN) { char buffer[1024] = {0}; int count = recv(fd, buffer, sizeof(buffer), 0); if (count \u0026gt; 0) { send(fd, buffer, count, 0); } else { // 客户端断开：先从 epoll 删除，再关闭 fd epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } } } } epoll 版最值得背下来的代码主干是：\nepfd = epoll_create(1); ev.events = EPOLLIN; ev.data.fd = sockfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, \u0026amp;ev); nready = epoll_wait(epfd, events, 1024, -1); for (i = 0; i \u0026lt; nready; i++) { fd = events[i].data.fd; if (fd == sockfd) { clientfd = accept(...); ev.events = EPOLLIN; ev.data.fd = clientfd; epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, \u0026amp;ev); } else if (events[i].events \u0026amp; EPOLLIN) { recv(fd, ...); } } 记忆关键词：create 创建、ctl 管理、wait 等待、只遍历就绪项。\n13. 把三段代码放在一起看 # 13.1 注册客户端 # // select：修改用户态位图 FD_SET(clientfd, \u0026amp;rfds); // poll：修改用户态结构体数组 fds[clientfd].fd = clientfd; fds[clientfd].events = POLLIN; // epoll：通过系统调用修改内核关注集合 ev.events = EPOLLIN; ev.data.fd = clientfd; epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, \u0026amp;ev); 13.2 等待事件 # // select：集合会被改写，所以先复制 rset = rfds; nready = select(maxfd + 1, \u0026amp;rset, NULL, NULL, NULL); // poll：传入完整 pollfd 数组 nready = poll(fds, maxfd + 1, -1); // epoll：关注集合已在内核，只传结果数组 nready = epoll_wait(epfd, events, 1024, -1); 13.3 寻找就绪客户端 # // select：监听 fd 已单独处理，这里遍历客户端 fd for (fd = sockfd + 1; fd \u0026lt;= maxfd; ++fd) if (FD_ISSET(fd, \u0026amp;rset)) { ... } // poll：遍历完整 pollfd 数组 for (fd = sockfd + 1; fd \u0026lt;= maxfd; ++fd) if (fds[fd].revents \u0026amp; POLLIN) { ... } // epoll：遍历本轮就绪数组 for (i = 0; i \u0026lt; nready; ++i) if (events[i].events \u0026amp; EPOLLIN) { ... } 13.4 删除客户端 # // select FD_CLR(fd, \u0026amp;rfds); close(fd); // poll close(fd); fds[fd].fd = -1; fds[fd].events = 0; // epoll epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); 最核心的差别就体现在“完整集合放在哪里、每轮遍历谁”：\nselect：应用保存位图 → 每轮复制 → 扫描 0..maxfd poll： 应用保存数组 → 每轮提交 → 扫描完整数组 epoll： 内核保存关注集合 → 每轮只取出并遍历就绪数组 总结 # 一连接一线程用线程等待每个连接；IO 多路复用让一个线程等待多个 fd；select 和 poll 每轮仍需处理整个关注集合，而 epoll 将关注集合长期维护在内核中并返回当前就绪项；Reactor再进一步把这些就绪事件分派给预先注册的回调函数，使服务器从“管理 fd”上升为“管理事件及其处理逻辑”。\n","date":"2026年8月7日","externalUrl":null,"permalink":"/MyBlog/p/io-multiplexing-and-reactor/","section":"Posts","summary":"","title":"IO 多路复用与 Reactor","type":"posts"},{"content":"","date":"2026年8月6日","externalUrl":null,"permalink":"/MyBlog/tags/i/o-%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8/","section":"Tags","summary":"","title":"I/O 多路复用","type":"tags"},{"content":" 一、网络 I/O 的使用场景 # 网络是后端服务开发中的重要环节。生活中大量功能都依赖客户端与服务端之间的网络通信。\n1. 微信发送文字、语音和视频 # 当用户在微信发送消息时，大致过程是：\n发送方微信 ↓ 通过网络将数据发送给微信服务器 ↓ 服务器找到接收方 ↓ 将数据转发给接收方微信 文字、语音、图片、视频在网络上传输时，最终都会被转换成字节数据。\n网络 I/O 主要关心：\n数据从哪里读取 数据向哪里发送 读取了多少字节 发送了多少字节 网络 I/O 本身并不关心这些字节具体表示文字、图片还是视频。\n2. 刷抖音 # 刷视频时：\n抖音 App 请求视频 ↓ 服务器返回视频信息和资源地址 ↓ App 通过网络下载视频数据 ↓ 客户端解码并播放 通常视频会被切分成多个数据块，App 可以一边下载、一边播放。\n3. 使用 git clone # 执行：\ngit clone 仓库地址 大致过程是：\n本地 Git 客户端连接 GitHub ↓ 向 GitHub 请求仓库数据 ↓ GitHub 通过网络发送 Git 对象 ↓ 本地接收、校验和解压 ↓ 形成本地仓库 代码能够到达本地，本质上也是客户端和 GitHub 服务端之间进行了网络 I/O。\n这些场景都可以概括成：\n客户端发起请求 → 服务端处理请求 → 服务端返回响应 二、什么是网络 I/O # 网络 I/O 是建立在客户端和服务端之间的数据传输过程，类似二者之间连接的网络管道。例如十个客户端分别连接服务器，服务端通常会获得十个连接 fd：\n客户端 A ←→ fd 4 客户端 B ←→ fd 5 客户端 C ←→ fd 6 ... 客户端 J ←→ fd 13 随着客户端数量增加，fd 数量也会增加，于是就产生了一个重要问题：\n服务端如何同时管理大量 fd？\n这正是后面需要学习 select、poll 和 epoll 的原因。\n三、socket 与 fd # 在 Linux 中，Linux 通过 socket API 通信。创建 socket：\nint sockfd = socket(AF_INET, SOCK_STREAM, 0); 参数的含义：\nAF_INET：IPv4 SOCK_STREAM：面向字节流，对应 TCP 0：使用该组合默认的 TCP 协议 fd 本质是进程中的非负整数（此处了解，记忆也没啥用）：\n0：标准输入 1：标准输出 2：标准错误 3：监听 socket 4：客户端连接 A 5：客户端连接 B 在当前服务端进程中，一个已连接 socket 的 fd 通常对应一条 TCP 连接。（存在 ACCEPT 的情况）\n四、TCP 服务端的简便理解 # 可以把 TCP 服务端理解成酒店：\nsocket()：聘请迎宾员 bind()：安排迎宾员在哪个门口工作 listen()：正式开始迎宾 accept()：接待一位到达的客人 recv()：听客人提出需求 send()：将处理结果交给客人 close()：结束服务 说说两类重要 fd：\n1. 监听 fd\n定义完 sockfd 经过 bind、listen 后，sockfd 就成为监听 fd。主要职责是监听新连接，不负责收发数据。\n2. 连接 fd\n参考代码中的 int clientfd = accept(sockfd, ...);。accept() 成功后会返回一个新的 fd：\nsockfd：负责监听新客户端 clientfd：负责和某个客户端通信 关系如下：\n┌→ clientfd 4 ←→ 客户端 A listenfd → accept() ─┼→ clientfd 5 ←→ 客户端 B └→ clientfd 6 ←→ 客户端 C 监听 fd 通常只有一个，但每建立一个客户端连接，服务端就会获得一个新的连接 fd。\n五、服务端代码执行流程 # 1. 创建 socket # int sockfd = socket(AF_INET, SOCK_STREAM, 0); 作用：\n在内核中创建 socket 对象。 返回一个 fd。 当前还没有绑定 IP 和端口。 当前也没有开始监听。 更准确的变量名可以理解为：\nint listenfd; 因为它后面会被设置为监听 socket。\n2. 准备服务器地址 # struct sockaddr_in servaddr; servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(2000); sin_family # servaddr.sin_family = AF_INET; 表示使用 IPv4。\nINADDR_ANY # servaddr.sin_addr.s_addr = htonl(INADDR_ANY); INADDR_ANY 表示监听本机所有 IPv4 网络接口，对应：\n0.0.0.0 假设服务器有多个 IP：\n127.0.0.1 192.168.1.10 10.0.0.10 绑定 0.0.0.0，表示这些本机地址上的 2000 端口都可以接收连接。\nhtons() # servaddr.sin_port = htons(2000); htons 表示：\nhost to network short 它把主机字节序的 16 位端口号转换成网络字节序。\n同理：\nhtonl 表示把 32 位数据从主机字节序转换成网络字节序。\n端口范围是：\n0～65535 通常：\n0：让操作系统自动分配端口。 1～1023：传统意义上的特权端口。 1024～65535：普通程序通常可以使用。 但 1024 以上的端口也可能已经被其他程序占用。\n3. 绑定 IP 和端口 # bind(sockfd, (struct sockaddr *)\u0026amp;servaddr, sizeof(struct sockaddr)); bind() 将 socket 与本地 IP、端口建立联系：\nsockfd ←→ 0.0.0.0:2000 可以理解为：\n给迎宾人员安排具体的工作地点。\n如果端口已经被其他服务绑定，可能出现：\nAddress already in use 你的代码通过下面的方式查看错误：\nprintf(\u0026#34;bind error:%s\\n\u0026#34;, strerror(errno)); 不过如果 bind() 失败，程序理论上不应该继续执行 listen()。\n4. 进入监听状态 # listen(sockfd, 10); 执行成功后，socket 从普通 socket 变成监听 socket。\n此时可以使用：\nss -lntp | grep \u0026#39;:2000\u0026#39; 查看监听状态。\n可能看到：\nLISTEN 0 10 0.0.0.0:2000 5. 接收客户端连接 # int clientfd = accept( sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len ); 如果当前没有已经建立完成的客户端连接，阻塞模式下的 accept() 会暂停程序：\n程序阻塞在 accept() ↓ 等待客户端连接 ↓ 客户端完成 TCP 三次握手 ↓ accept() 返回 clientfd accept() 不会把原来的 sockfd 变成客户端连接。\n它会返回一个全新的 clientfd。\n六、代码中的三个服务端版本（代码文末附） # 第一阶段：只处理一个客户端的一次数据 # #if 0 int clientfd = accept(...); char buffer[1024] = {0}; int count = recv(clientfd, buffer, 1024, 0); printf(\u0026#34;RECV:%s\\n\u0026#34;, buffer); count = send(clientfd, buffer, count, 0); #endif 执行过程：\naccept 一个客户端 ↓ recv 一次 ↓ send 一次 ↓ 后面不再 accept 这个版本只能：\n接收一个客户端。 读取一次数据。 返回一次数据。 如果客户端想再次发送，服务端没有循环继续读取。\n第二阶段：循环接收客户端，但串行处理 # #elif 0 while (1) { int clientfd = accept(...); char buffer[1024] = {0}; int count = recv(clientfd, buffer, 1024, 0); printf(\u0026#34;RECV:%s\\n\u0026#34;, buffer); count = send(clientfd, buffer, count, 0); } #endif 这里增加了：\nwhile (1) 因此服务端可以不断接收新客户端。\n但是它仍然是串行处理：\naccept 客户端 A ↓ recv 等待 A 发送数据 ↓ send 返回给 A ↓ 重新 accept 客户端 B 关键问题在这里：\nrecv(clientfd, buffer, 1024, 0); 默认情况下，recv() 是阻塞的。\n假设客户端 A 已经连接，但一直没有发送数据：\n服务端阻塞在 A 的 recv() 此时即使客户端 B 已经发起连接，应用程序也暂时无法回到 accept() 处理 B。\n第三阶段：一个客户端对应一个线程 # 当前实际编译的是 #else：\n#else while (1) { int clientfd = accept(...); pthread_t thid; pthread_create( \u0026amp;thid, NULL, client_thread, \u0026amp;clientfd ); } #endif 每接收一个客户端，创建一个线程。\n线程函数：\nvoid *client_thread(void *arg) { int clientfd = *(int *)arg; while (1) { char buffer[1024] = {0}; int count = recv( clientfd, buffer, 1024, 0 ); printf(\u0026#34;RECV:%s\\n\u0026#34;, buffer); count = send( clientfd, buffer, count, 0 ); } } 运行关系变成：\n主线程 │ ├─ accept 客户端 A │ └─ 创建线程 1 → recv(A) │ ├─ accept 客户端 B │ └─ 创建线程 2 → recv(B) │ └─ accept 客户端 C └─ 创建线程 3 → recv(C) 主线程只负责：\n不断执行 accept() 工作线程负责：\nrecv() → 处理数据 → send() 这样即使线程 1 阻塞在客户端 A 的 recv()，主线程仍然可以继续 accept() 客户端 B。\n准确来说，这是：\n一个连接对应一个线程。\n不是严格的”一次请求对应一个线程”，因为一个连接线程内部可以通过 while 处理多次请求。\n七、对于阶段三一个连接一个线程的问题 # 这种模型简单直观，但连接数量增大后会产生问题。\n1. 线程需要内存 # 每创建一个线程，都需要线程栈及相关管理资源。连接越多，线程越多，百万并发的时候\u0026hellip;\n2. 上下文切换成本 # CPU 核心数量有限。\n假设服务器只有 8 个 CPU 核心，却创建了 5000 个线程，操作系统必须不停地：\n保存线程 A 的状态 切换到线程 B 保存线程 B 的状态 切换到线程 C 线程太多时，大量 CPU 时间可能消耗在线程调度上。\n3. 大部分线程可能只是在等待 # 很多网络连接并不是一直都有数据。\n例如即时通信中的长连接：\n客户端保持在线 但大部分时间没有发送消息 对应线程会长期阻塞在：\nrecv(clientfd, ...); 如果一个空闲连接占用一个线程，就会浪费大量线程资源。\n代码 # #include \u0026lt;stdio.h\u0026gt; // printf #include \u0026lt;string.h\u0026gt; // memset #include \u0026lt;sys/socket.h\u0026gt; #include \u0026lt;arpa/inet.h\u0026gt; // htonl, htons #include \u0026lt;errno.h\u0026gt; // errno #include \u0026lt;netinet/in.h\u0026gt; // struct sockaddr_in #include \u0026lt;unistd.h\u0026gt; // close #include \u0026lt;pthread.h\u0026gt; void *client_thread(void *arg) { int clientfd = *(int *)arg; while (1) { char buffer[1024] = {0}; int count = recv(clientfd, buffer, 1024, 0); // 全部阻塞在 recv 上 printf(\u0026#34;RECV:%s\\n\u0026#34;, buffer); count = send(clientfd, buffer, count, 0); printf(\u0026#34;SEND:%s\\n\u0026#34;, count); } } int main() { int sockfd = socket(AF_INET, SOCK_STREAM, 0); // create socket 聘请迎宾者 // 绑定本地端口 struct sockaddr_in servaddr; servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0 servaddr.sin_port = htons(2000); // 0-1023 系统默认，1024 之后都可以用，不能与其他的冲突 // 在哪个门迎宾，安排的位置 if (-1 == bind(sockfd, (struct sockaddr *)\u0026amp;servaddr, sizeof(struct sockaddr))) { printf(\u0026#34;bind error:%s\\n\u0026#34;, strerror(errno)); } // 登记绑定 listen(sockfd, 10); // 正式上榜 printf(\u0026#34;listen finished\\n\u0026#34;); struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); #if 0 printf(\u0026#34;accept\\n\u0026#34;); int clientfd = accept(sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len); // 建立链接 printf(\u0026#34;accept finished\\n\u0026#34;); char buffer[1024] = {0}; int count = recv(clientfd, buffer, 1024, 0); printf(\u0026#34;RECV:%s\\n\u0026#34;, buffer); count = send(clientfd, buffer, count, 0); printf(\u0026#34;SEND:%s\\n\u0026#34;, count); #elif 0 while (1) { printf(\u0026#34;accept\\n\u0026#34;); int clientfd = accept(sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len); // 建立链接 printf(\u0026#34;accept finished\\n\u0026#34;); char buffer[1024] = {0}; int count = recv(clientfd, buffer, 1024, 0); // 全部阻塞在 recv 上 printf(\u0026#34;RECV:%s\\n\u0026#34;, buffer); count = send(clientfd, buffer, count, 0); printf(\u0026#34;SEND:%s\\n\u0026#34;, count); } // 多个 io 如何接收数据 #else while (1) { printf(\u0026#34;accept\\n\u0026#34;); int clientfd = accept(sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len); // 建立链接 printf(\u0026#34;accept finished\\n\u0026#34;); pthread_t thid; pthread_create(\u0026amp;thid, NULL, client_thread, \u0026amp;clientfd); } #endif getchar(); printf(\u0026#34;exit\\n\u0026#34;); return 0; } ","date":"2026年8月6日","externalUrl":null,"permalink":"/MyBlog/p/network-io-and-io-multiplexing/","section":"Posts","summary":"","title":"网络 I/O 与 I/O 多路复用","type":"posts"},{"content":"","date":"2026年8月1日","externalUrl":null,"permalink":"/MyBlog/tags/ai-infra/","section":"Tags","summary":"","title":"AI Infra","type":"tags"},{"content":"","date":"2026年8月1日","externalUrl":null,"permalink":"/MyBlog/tags/%E4%B8%AA%E4%BA%BA%E6%88%90%E9%95%BF/","section":"Tags","summary":"","title":"个人成长","type":"tags"},{"content":" 期末月回顾 # 时隔两个月，博客写了不少，经历也不少，这段时间打算重启这个记录吧。\n简明扼要地说说消失的这两个月我干了什么。在学校的时间大概就是期末月，平时完全不怎么学，度过了最 free 一学期的我，只能靠这最后一个月找补了。其缺点也是非常明显的，不知为何感觉复习得草草，期末的题都能应付了事，只不过很奇怪，为什么分数还是很低呢？没有认真学，这样的结果我也释怀了。下学期尽量认真学吧，每一门、每一科——除非真的是很无语、没有一点知识性的科目。\n下学期目标 # 希望下学期的主线编排还是在自己能力的提升和找实习上吧。希望绩点能到 3.8，这是我对下学期的 goal，应该朝着这个方向努力。也希望能如愿以偿找到一份好的实习，在寒假努努力、取取经，多涨涨经验。\n希望下学期也可以探索多样的分支，对 Quant 和 Infra 的理解也希望能并行。\n学习与收获 # 其实消失的这几个月我陷入了漩涡。具体到学习上，我觉得还好，因为有一个实习作为导向，无非就是强化力扣、强化八股、强化对项目的理解，这些都是可以日拱一卒的。\n此外，机缘巧合了解到 PKU 的 Infra 夏令营，这无疑是一个契机，让我了解到 Infra 相关的内容，并在一定程度上提供了学习指导。\n顺带一提感悟：难以想象 PKU 的学生接受的兴趣社团内容原来是这样。如此的课程设计，我只能在国外大学的网课上看到，譬如 CS61A。他们的天赋、他们的大学、他们的环境、他们的资源，正在加速分离与其他人的命运，令人艳羡吧。至少就我所知，中九是无法望其项背的。\n总归，网课讲得很玄幻，知识跳跃度很大，很难吸收理解。好在这个时代有了 AI，Assignment 的设置一步一步，慢慢地也不至于被拉下太远。\n状态与计划 # 状态不太好，是因为感觉每天十分单调，作息也不是很健康。对着电脑学习一会儿，总想着干点其他的事，继而脑雾产生。专注力真的是很重要的思维资产，大部分的专注力都喂狗了，哈哈哈哈。\n总体就是这样。希望还剩下一个月的暑假能够认真利用：跟上 Infra，认真做 Assignment，有自己的理解和复盘；对后端项目有所提升。就是这样，自己的身体也希望更加健康，对于一些旁枝的探索也要慢慢拓展。\n就是这样，共勉，加油！\n","date":"2026年8月1日","externalUrl":null,"permalink":"/MyBlog/p/july-summer-review-new-start/","section":"Posts","summary":"","title":"暑假七月总结：从期末月到新起点","type":"posts"},{"content":"","date":"2026年8月1日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%9A%91%E5%81%87%E6%80%BB%E7%BB%93/","section":"Tags","summary":"","title":"暑假总结","type":"tags"},{"content":"","date":"2026年8月1日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%AD%A6%E4%B9%A0%E5%A4%8D%E7%9B%98/","section":"Tags","summary":"","title":"学习复盘","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/ci/cd/","section":"Tags","summary":"","title":"CI/CD","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/devops/","section":"Tags","summary":"","title":"DevOps","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/docker/","section":"Tags","summary":"","title":"Docker","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/feed-%E6%B5%81/","section":"Tags","summary":"","title":"Feed 流","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/go/","section":"Tags","summary":"","title":"Go","type":"tags"},{"content":"承接 Go 项目反推：Feed 流系统实战——数据库与 GORM，这一篇继续拆解 feedsystem_video_go 的核心 Feed 流：从推拉模型到游标分页，再到热榜、关注流、话题流与缓存策略。\n什么是 Feed 流 # 打开抖音/微博，往下刷，不断出来新内容，这就是 Feed 流。\n这个项目的 4 种 Feed 流 # 类型 说明 排序方式 最新视频 全站最新发布的视频 按时间倒序 热榜 最热门的视频 按热度排序 关注流 只看关注的人发的视频 按时间倒序 话题流 某个话题下的视频 按时间倒序 相关文件 # backend/internal/feed/ ├── entity.go # 请求/响应结构体 ├── handler.go # HTTP处理 ├── service.go # 业务逻辑（缓存、singleflight） └── repo.go # 数据库查询 推模型 vs 拉模型 # 拉模型（这个项目用的） # 发视频：只写Video表（不做额外操作） 刷视频：实时查询Video表（拉取数据） 数据流动：用户刷的时候才从 DB 拉。\n推模型 # 发视频：写Video表 + 把视频ID推送到所有粉丝的Feed列表里 刷视频：直接从自己的Feed列表读（不用查DB） 数据流动：发的时候就推给粉丝了，粉丝刷的时候直接读。\n推拉结合 # 普通用户发视频 → 推送给粉丝（粉丝少，推送成本低） 大V发视频 → 不推送，粉丝读的时候实时拉取 对比 # 拉模型 推模型 推拉结合 发视频成本 低 高（大V要推给几百万粉丝） 中 刷视频成本 高（每次都要查DB） 低（直接读列表） 中 实时性 实时 略有延迟 中 复杂度 简单 复杂 最复杂 场景 用户量小 用户量大 抖音/微博 这个项目用拉模型，因为是学习项目，用户量小，拉模型简单够用。\n游标分页 vs 页码分页 # 页码分页 # 第1页：GET /feed?page=1\u0026amp;size=10 第2页：GET /feed?page=2\u0026amp;size=10 对应 SQL：\nSELECT * FROM videos ORDER BY create_time DESC LIMIT 5 OFFSET 0 -- 第1页 SELECT * FROM videos ORDER BY create_time DESC LIMIT 5 OFFSET 5 -- 第2页 问题：数据变化时可能重复或漏数据\n数据库：[100, 90, 80, 70, 60, 50, 40, 30] 第1页（page=1, size=3）：跳过0条，取3条 → [100, 90, 80] 插入新视频110，数据库变成：[110, 100, 90, 80, 70, 60, 50, 40, 30] 第2页（page=2, size=3）：跳过3条，取3条 → [80, 70, 60] 视频80重复出现了！ 为什么？因为 OFFSET 是基于\u0026quot;位置\u0026quot;的，不是基于\u0026quot;数据\u0026quot;的。新视频插入后，原来第 4 个位置的视频 80 变成了第 5 个，但 OFFSET 还是 3，所以又取到了。\n游标分页（这个项目用的） # 第1页：GET /feed?limit=10 返回：video_list + next_time=1000 第2页：GET /feed?limit=10\u0026amp;latest_time=1000 返回：video_list + next_time=900 对应 SQL：\nSELECT * FROM videos ORDER BY create_time DESC LIMIT 10 SELECT * FROM videos WHERE create_time \u0026lt; 1000 ORDER BY create_time DESC LIMIT 10 为什么游标分页不会重复？\n第1页：WHERE create_time \u0026lt; now() LIMIT 3 返回 [100, 90, 80] next_time = 80的时间 插入110 第2页：WHERE create_time \u0026lt; 80的时间 LIMIT 3 返回 [70, 60, 50] 没有重复！ 游标是基于数据的值（时间），不是位置。新视频插入不影响查询条件。\n对比 # 页码分页 游标分页 请求方式 page=2 latest_time=1000 数据变化时 可能重复或漏数据 不会重复 SQL OFFSET 跳过 WHERE 条件过滤 场景 后台管理系统 Feed 流、时间线 游标分页的实现 # 服务器代码 # func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBefore time.Time) ([]*video.Video, error) { query := repo.db.WithContext(ctx).Model(\u0026amp;video.Video{}).Order(\u0026#34;create_time DESC\u0026#34;) // 关键：如果有游标，加上WHERE条件 if !latestBefore.IsZero() { query = query.Where(\u0026#34;create_time \u0026lt; ?\u0026#34;, latestBefore) } query.Limit(limit).Find(\u0026amp;videos) return videos, nil } 游标怎么生成的 # // 取本页最后一条视频的时间作为游标 var nextTime int64 if len(baseVideos) \u0026gt; 0 { nextTime = baseVideos[len(baseVideos)-1].CreateTime.UnixMilli() } 本页最后一条视频的时间 = 下一页的起点。\n响应结构 # type ListLatestResponse struct { VideoList []FeedVideoItem `json:\u0026#34;video_list\u0026#34;` NextTime int64 `json:\u0026#34;next_time\u0026#34;` // 游标 HasMore bool `json:\u0026#34;has_more\u0026#34;` // 还有没有更多 } 完整流程 # 客户端：GET /feed?limit=10 ↓ 服务器：SELECT * FROM videos ORDER BY create_time DESC LIMIT 10 ↓ 返回：video_list + next_time = 本页最后一条的时间 ↓ 客户端：GET /feed?limit=10\u0026amp;latest_time=next_time ↓ 服务器：SELECT * FROM videos WHERE create_time \u0026lt; next_time ORDER BY create_time DESC LIMIT 10 ↓ 返回：video_list + next_time = 新的游标 ↓ ...循环，直到 has_more = false 热榜实现 # 热榜排序算法 # query := repo.db.WithContext(ctx).Model(\u0026amp;video.Video{}). Order(\u0026#34;popularity DESC, create_time DESC, id DESC\u0026#34;) 排序规则：热度高的优先，热度相同按时间倒序，时间也相同按 ID 倒序。\n热度分数怎么算的 # func UpdatePopularityCache(ctx context.Context, cache *rediscache.Client, id uint, change int64) { now := time.Now().UTC().Truncate(time.Minute) windowKey := cache.Key(\u0026#34;hot:video:1m:%s\u0026#34;, now.Format(\u0026#34;200601021504\u0026#34;)) member := strconv.FormatUint(uint64(id), 10) cache.ZincrBy(opCtx, windowKey, member, float64(change)) // 热度+change cache.Expire(opCtx, windowKey, 2*time.Hour) } 每次点赞/评论/关注，热度+1。\n热榜查询流程（冷热分离） # func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf int64, offset int, ...) { // 1. 确定时间窗口（最近60分钟） asOf := time.Now().UTC().Truncate(time.Minute) // 2. 生成60个ZSET的key keys := make([]string, 0, 60) for i := 0; i \u0026lt; 60; i++ { keys = append(keys, f.rediscache.Key(\u0026#34;hot:video:1m:%s\u0026#34;, asOf.Add(-time.Duration(i)*time.Minute).Format(\u0026#34;200601021504\u0026#34;))) } // 3. 合并成一个ZSET（快照） dest := f.rediscache.Key(\u0026#34;hot:video:merge:1m:%s\u0026#34;, asOf.Format(\u0026#34;200601021504\u0026#34;)) f.rediscache.ZUnionStore(opCtx, dest, keys, \u0026#34;SUM\u0026#34;) // 4. 从合并后的ZSET取top N members, _ := f.rediscache.ZRevRange(opCtx, dest, start, stop) // 5. 根据ID去查视频详情 videos, _ := f.repo.GetByIDs(ctx, ids) return videos } 快照 key 是什么 # 快照 key 就是\u0026quot;合并结果的缓存\u0026quot;。\n没有快照key： 用户A请求 → 合并60个ZSET → 返回 用户B请求 → 合并60个ZSET → 返回 100个用户请求 → 合并100次 → 浪费！ 有快照key： 用户A请求 → 合并60个ZSET → 存到快照key → 返回 用户B请求 → 发现快照key已存在 → 直接用 → 返回 100个用户请求 → 只合并1次 → 省资源！ 代码：\ndest := f.rediscache.Key(\u0026#34;hot:video:merge:1m:%s\u0026#34;, asOf.Format(\u0026#34;200601021504\u0026#34;)) exists, _ := f.rediscache.Exists(opCtx, dest) if !exists { f.rediscache.ZUnionStore(opCtx, dest, keys, \u0026#34;SUM\u0026#34;) f.rediscache.Expire(opCtx, dest, 2*time.Minute) } members, _ := f.rediscache.ZRevRange(opCtx, dest, start, stop) 快照 key 2 分钟后过期，下次请求重新合并，拿到最新数据。\n关注流实现 # 核心代码 # func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, viewerAccountID uint, latestBefore time.Time) ([]*video.Video, error) { var videos []*video.Video query := repo.db.WithContext(ctx).Model(\u0026amp;video.Video{}).Order(\u0026#34;create_time DESC\u0026#34;) if viewerAccountID \u0026gt; 0 { // 子查询：找到我关注的所有人 followingSubQuery := repo.db.WithContext(ctx). Model(\u0026amp;social.Social{}). Select(\u0026#34;vlogger_id\u0026#34;). Where(\u0026#34;follower_id = ?\u0026#34;, viewerAccountID) // 只查这些人发的视频 query = query.Where(\u0026#34;author_id IN (?)\u0026#34;, followingSubQuery) } if !latestBefore.IsZero() { query = query.Where(\u0026#34;create_time \u0026lt; ?\u0026#34;, latestBefore) } query.Limit(limit).Find(\u0026amp;videos) return videos, nil } 等价 SQL # SELECT * FROM videos WHERE author_id IN ( SELECT vlogger_id FROM socials WHERE follower_id = 123 -- 我关注的人 ) AND create_time \u0026lt; 1000 -- 游标 ORDER BY create_time DESC LIMIT 10 子查询是什么 # 子查询就是\u0026quot;查询里的查询\u0026quot;，拆成两步理解：\n第1步（子查询）： SELECT vlogger_id FROM socials WHERE follower_id = 123 → 结果：[456, 789]（用户A关注的人） 第2步（主查询）： SELECT * FROM videos WHERE author_id IN (456, 789) → 结果：视频100（B发的）、视频200（C发的） 子查询是一次 SQL 搞定，数据库内部优化，比两条 SQL 更快。\n流程 # 用户A关注了B和C ↓ 子查询：从social表找到 [456, 789] ↓ 主查询：从video表找 author_id 是 456 或 789 的视频 ↓ 返回：视频100（B发的）、视频200（C发的） ↓ 用户D的视频300不会出现（A没关注D） 话题流实现 # 核心代码 # func (repo *FeedRepository) ListByTag(ctx context.Context, tagName string, limit int) ([]*video.Video, error) { var videos []*video.Video err := repo.db.WithContext(ctx).Model(\u0026amp;video.Video{}).Table(\u0026#34;videos\u0026#34;). Joins(\u0026#34;JOIN video_tags ON video_tags.video_id = videos.id\u0026#34;). Joins(\u0026#34;JOIN tags ON tags.id = video_tags.tag_id\u0026#34;). Where(\u0026#34;tags.name = ?\u0026#34;, tagName). Order(\u0026#34;videos.create_time desc\u0026#34;). Limit(limit). Find(\u0026amp;videos).Error return videos, err } 等价 SQL # SELECT videos.* FROM videos JOIN video_tags ON video_tags.video_id = videos.id JOIN tags ON tags.id = video_tags.tag_id WHERE tags.name = \u0026#39;Go语言\u0026#39; ORDER BY videos.create_time DESC LIMIT 10 三张表的关系 # tags表： ┌────┬──────────┐ │ id │ name │ ├────┼──────────┤ │ 1 │ Go语言 │ │ 2 │ Python │ └────┴──────────┘ video_tags表（多对多关联）： ┌──────────┬─────────┐ │ video_id │ tag_id │ ├──────────┼─────────┤ │ 100 │ 1 │ ← 视频100有\u0026#34;Go语言\u0026#34;标签 │ 100 │ 2 │ ← 视频100也有\u0026#34;Python\u0026#34;标签 │ 200 │ 1 │ ← 视频200有\u0026#34;Go语言\u0026#34;标签 └──────────┴─────────┘ videos表： ┌────┬──────────┐ │ id │ title │ ├────┼──────────┤ │ 100 │ Go入门 │ │ 200 │ 并发编程 │ └────┴──────────┘ 查询流程 # 1. 从tags表找到\u0026#34;Go语言\u0026#34;的id = 1 2. 从video_tags表找到tag_id = 1的video_id = [100, 200] 3. 从videos表找到id = [100, 200]的视频 JOIN一次搞定。 三级缓存架构 # 这个项目在 Feed 查询里用了三级缓存：\n请求 → L1本地缓存 → L2 Redis → L3 MySQL ↓ 快 ↓ 较快 ↓ 慢 纳秒级 毫秒级 毫秒级 代码 # type FeedService struct { localcache *cache.Cache // L1：本地缓存（进程内存，3秒过期） rediscache *rediscache.Client // L2：Redis缓存 repo *FeedRepository // L3：MySQL } // L1：本地缓存 localcache: cache.New(3*time.Second, 5*time.Second) // L2：Redis缓存（50ms超时） cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) // L3：MySQL videos, err := f.repo.GetByIDs(ctx, ids) 查询流程 # 请求来了 ↓ 查L1本地缓存（进程内存） ├── 命中 → 直接返回（最快） └── 没命中 ↓ 查L2 Redis（50ms超时） ├── 命中 → 写回L1 → 返回 └── 没命中 ↓ 查L3 MySQL → 写回L2 Redis → 写回L1 → 返回 singleflight 防并发 # 什么是 singleflight # Go 内置的工具，保证同一个请求只执行一次，其他并发请求等着用结果。\n场景 # 100个用户同时请求热榜 ↓ 没有singleflight： 用户1：查DB → 返回 用户2：查DB → 返回 ... 用户100：查DB → 返回 DB被查了100次！ 有singleflight： 用户1：查DB → 返回 用户2-100：等着 → 用户1查完后直接用结果 DB只被查了1次！ 代码 # type FeedService struct { requestGroup singleflight.Group } v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) { return f.repo.GetByIDs(ctx, []uint{videoID}) }) 第 1 个请求：执行 func，查 DB 其他请求：同一个 sfKey，等着，拿到同样的结果 sfKey 是什么 # 请求的唯一标识：\nsfKey := f.rediscache.Key(\u0026#34;sf:entity:%d\u0026#34;, videoID) // 结果：\u0026#34;sf:entity:123\u0026#34; 同一个视频 ID 的请求，sfKey 相同，会共享结果。\n和分布式锁的对比 # 分布式锁 singleflight 作用范围 多台机器 单机内 实现 Redis Go 内存 复杂度 高 低 场景 分布式系统 单机内防并发 面试要点总结 # Q: 推模型和拉模型的区别？ # A: 推是发视频时推送给粉丝，拉是刷视频时实时查 DB。推的读快写慢，拉的读慢写快。抖音用推拉结合。\nQ: 游标分页和页码分页的区别？ # A: 游标用 WHERE 条件过滤，不会重复；页码用 OFFSET 跳过，数据变化时可能重复。Feed 流用游标分页。\nQ: 热榜怎么实现的？ # A: ZSET 按分钟分窗口，点赞时热度+1，查询时合并 60 个窗口算总分，快照 key 缓存合并结果。\nQ: 关注流怎么实现的？ # A: 子查询找关注列表，再查这些人发的视频。一次 SQL 搞定。\nQ: 话题流怎么实现的？ # A: 三表 JOIN（videos + video_tags + tags），查某个标签下的视频。\nQ: 什么是 singleflight？ # A: Go 内置工具，保证同一个请求只执行一次，其他并发请求等着用结果。和分布式锁类似，但作用于单机内。\nQ: 三级缓存是什么？ # A: L1 本地缓存（进程内存）→ L2 Redis → L3 MySQL。先查快的，没命中再查慢的。\n","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/p/go-feed-design/","section":"Posts","summary":"","title":"Go 项目反推：Feed 流系统实战——Feed 流设计","type":"posts"},{"content":"承接 Go 项目反推：Feed 流系统实战——Feed 流设计，这一篇继续拆解 feedsystem_video_go 的两个关键能力：用 SSE 实时推送通知，以及用分片上传和断点续传处理大文件。\n阶段6：SSE实时推送 # SSE 是什么 # SSE = Server-Sent Events，服务器主动给客户端推送消息。\n就像外卖 APP 的订单状态：\n你下单 → 服务器推送\u0026#34;商家已接单\u0026#34; → 服务器推送\u0026#34;骑手已取餐\u0026#34; → 服务器推送\u0026#34;骑手已送达\u0026#34; 客户端不用一直问\u0026quot;好了没？好了没？\u0026quot;，服务器有消息就主动推。\nSSE vs WebSocket # SSE WebSocket 方向 服务器 → 客户端（单向） 双向 协议 HTTP 独立协议 复杂度 简单 复杂 场景 通知、状态更新、新闻推送 聊天、游戏、实时协作 这个项目用 SSE 推送通知（点赞、评论、关注），因为只需要服务器→客户端，不需要客户端→服务器。\n为什么用 SSE 不用轮询 # 方案 做法 问题 轮询 客户端每隔几秒问一次\u0026quot;有新消息吗？\u0026quot; 浪费资源、实时性差、大部分请求返回空 SSE 服务器有消息就推，没有就等着 省资源、实时性高 项目里 SSE 用在哪 # 用户A给用户B的视频点赞 ↓ LikeWorker处理点赞事件 ↓ NotificationWorker收到MQ消息 ↓ 创建Notification记录，写入DB ↓ SSEHub.Push(userID, notification) ↓ 用户B的浏览器收到推送，实时显示\u0026#34;xxx点赞了你的视频\u0026#34; SSEHub 数据结构 # type SSEHub struct { mu sync.RWMutex clients map[uint][]chan *Notification // userID -\u0026gt; 多个通知channel db *gorm.DB } 数据结构：\nmap[uint][]chan *Notification 用户1 → [channel1, channel2] （可能多个设备） 用户2 → [channel3] 用户3 → [channel4, channel5, channel6] （三个设备） 代码实现 # 相关文件 # backend/internal/worker/ssehub.go # SSE Hub，管理所有SSE连接 backend/internal/worker/notificationworker.go # 通知Worker，消费MQ消息并推送 Notification 表结构 # type Notification struct { ID uint RecipientID uint // 接收者ID SenderID uint // 发送者ID Type string // 通知类型：like、comment、follow TargetID uint // 目标ID（视频ID、用户ID） Content string // 通知内容 IsRead bool // 是否已读 CreatedAt time.Time } SSEHub 核心方法 # // 订阅：用户连接时调用 func (h *SSEHub) Subscribe(userID uint) chan *Notification { ch := make(chan *Notification, 20) // 缓冲区20 h.mu.Lock() // 写锁 h.clients[userID] = append(h.clients[userID], ch) h.mu.Unlock() return ch } // 取消订阅：用户断开时调用 func (h *SSEHub) Unsubscribe(userID uint, ch chan *Notification) { h.mu.Lock() // 写锁 defer h.mu.Unlock() chs := h.clients[userID] for i, c := range chs { if c == ch { chs = append(chs[:i], chs[i+1:]...) if len(chs) == 0 { delete(h.clients, userID) } else { h.clients[userID] = chs } close(c) return } } } // 推送：有新通知时调用 func (h *SSEHub) Push(userID uint, n *Notification) { h.mu.RLock() // 读锁 defer h.mu.RUnlock() chs, ok := h.clients[userID] if !ok { return } for _, ch := range chs { select { case ch \u0026lt;- n: // 往channel里塞消息 default: // 塞不进去就跳过（防阻塞） } } } SSEHandler - 处理 SSE 连接 # func (h *SSEHub) SSEHandler(c *gin.Context) { userID, ok := sseAccountID(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{\u0026#34;error\u0026#34;: \u0026#34;invalid account\u0026#34;}) return } // 设置SSE响应头 c.Writer.Header().Set(\u0026#34;Content-Type\u0026#34;, \u0026#34;text/event-stream\u0026#34;) c.Writer.Header().Set(\u0026#34;Cache-Control\u0026#34;, \u0026#34;no-cache\u0026#34;) c.Writer.Header().Set(\u0026#34;Connection\u0026#34;, \u0026#34;keep-alive\u0026#34;) c.Writer.WriteHeader(http.StatusOK) // 订阅通知 ch := h.Subscribe(userID) defer h.Unsubscribe(userID, ch) ctx := c.Request.Context() flusher, _ := c.Writer.(http.Flusher) for { select { case \u0026lt;-ctx.Done(): // 客户端断开 return case n, ok := \u0026lt;-ch: // 收到通知 if !ok { return } b, _ := json.Marshal(n) fmt.Fprintf(c.Writer, \u0026#34;data: %s\\n\\n\u0026#34;, b) // SSE格式 if flusher != nil { flusher.Flush() // 立刻发送给客户端 } case \u0026lt;-time.After(30 * time.Second): // 心跳 fmt.Fprintf(c.Writer, \u0026#34;: keepalive\\n\\n\u0026#34;) if flusher != nil { flusher.Flush() } } } } Push 为什么用 RLock 不用 Lock # 什么是读写锁（RWMutex） # 普通锁（Mutex）：同一时刻只能一个人进去，不管是读还是写。\n读写锁（RWMutex）：\n多个人可以同时读（RLock） 写的时候只能一个人进去，其他人不能读也不能写（Lock） 对比 # 操作 做什么 用什么锁 Push 读 map，往 channel 塞消息 RLock（读锁） Subscribe 往 map 里加一个 channel Lock（写锁） Unsubscribe 从 map 里删一个 channel Lock（写锁） 为什么 Push 能用 RLock # Push 只做两件事：\n从 map 里找到用户的 channel（读） 往 channel 里塞消息（不改 map） map 结构没变，所以多个 Push 可以同时进行。\n一句话：读用 RLock（可以并发），写用 Lock（必须独占）。 # 场景对比 # 场景：100个用户同时收到通知 用普通锁（Mutex）： Push1加锁 → 推送 → 解锁 Push2加锁 → 推送 → 解锁 ... 一个一个来，慢 用读写锁（RWMutex）： Push1加RLock → 推送 Push2加RLock → 推送 Push3加RLock → 推送 ... 100个同时推，快 心跳机制 # 什么是心跳 # SSE 连接可能因为网络问题断开，客户端不知道。\n解决方案：每 30 秒发一次空消息（keepalive），告诉客户端\u0026quot;我还活着\u0026quot;。\ncase \u0026lt;-time.After(30 * time.Second): fmt.Fprintf(c.Writer, \u0026#34;: keepalive\\n\\n\u0026#34;) flusher.Flush() 为什么需要心跳 # 没有心跳： 客户端连接服务器 → 网络抖动 → 连接断了 客户端不知道断了 → 一直等 → 漏掉通知 有心跳： 客户端连接服务器 → 每30秒收到keepalive 30秒没收到 → 知道断了 → 重新连接 select 里的 default 防阻塞 # for _, ch := range chs { select { case ch \u0026lt;- n: // 往channel里塞消息 default: // 塞不进去就跳过 } } channel 缓冲区满了（20条），塞不进去怎么办？\n没有 default：阻塞等着，Push 卡住，其他用户的推送也卡住 有 default：直接跳过，不等，保证其他用户能正常收到推送 这是防阻塞设计：一个用户的消息堆积不影响其他用户。\nNotificationWorker 处理流程 # func (w *NotificationWorker) process(ctx context.Context, d amqp.Delivery) error { routingKey := d.RoutingKey var notif *Notification switch { case routingKey == \u0026#34;like.like\u0026#34;: // 解析点赞事件 // 查询视频作者ID // 如果是自己给自己点赞，不通知 notif = \u0026amp;Notification{RecipientID: authorID, SenderID: evt.UserID, Type: \u0026#34;like\u0026#34;, Content: \u0026#34;点赞了你的视频\u0026#34;} case routingKey == \u0026#34;comment.publish\u0026#34;: // 解析评论事件 // 查询视频作者ID // 如果是自己评论自己，不通知 notif = \u0026amp;Notification{RecipientID: authorID, SenderID: evt.AuthorID, Type: \u0026#34;comment\u0026#34;, Content: \u0026#34;评论了你的视频\u0026#34;} case routingKey == \u0026#34;social.follow\u0026#34;: // 解析关注事件 notif = \u0026amp;Notification{RecipientID: evt.VloggerID, SenderID: evt.FollowerID, Type: \u0026#34;follow\u0026#34;, Content: \u0026#34;关注了你\u0026#34;} } if notif == nil { return nil } // 写入DB w.db.Create(notif) // 实时推送给用户 w.hub.Push(notif.RecipientID, notif) return nil } 接口设计 # 接口 方法 作用 /stream GET SSE 连接，实时接收通知 /list POST 查询通知列表（最近 50 条） /markRead POST 标记已读 /unreadCount POST 查询未读数量 阶段7：分片上传 # 为什么需要分片上传 # 视频文件可能很大（几十 MB 到 200MB），一次上传的问题：\n一次上传100MB： 网络抖动 → 上传到99%断了 → 重头再来 用户体验极差 分片上传：\n100MB文件，分成5MB一片，共20片 第1片上传成功 ✓ 第2片上传成功 ✓ ... 第17片网络断了 ✗ 重新连接 → 只需要重新上传第17片，1-16不用重传 分片上传流程 # 整体流程 # 1. 客户端：计算文件MD5 → 调用Init接口 → 获得upload_id + 已上传分片列表 2. 客户端：逐片上传（跳过已上传的） → 每片带MD5校验 3. 客户端：全部上传完 → 调用Complete接口 → 服务器合并分片 → 返回视频URL 流程图 # 客户端 服务器 | | |--- Init(文件名, 大小, MD5) -----------\u0026gt;| |\u0026lt;-- upload_id + 已上传列表 -------------| | | |--- UploadChunk(分片0, MD5) -----------\u0026gt;| 保存到临时目录 |\u0026lt;-- chunk_index: 0 ----------------------| | | |--- UploadChunk(分片1, MD5) -----------\u0026gt;| 保存到临时目录 |\u0026lt;-- chunk_index: 1 ----------------------| | | | ...（中间可能断线重连） | | | |--- Complete(upload_id) ---------------\u0026gt;| 合并所有分片 |\u0026lt;-- url: 视频地址 -----------------------| 相关文件 # backend/internal/video/chunk_entity.go # 数据结构定义 backend/internal/video/chunk_handler.go # 业务逻辑 核心数据结构 # ChunkUploadSession（上传会话） # const ChunkSize = 5 \u0026lt;\u0026lt; 20 // 5 MB type ChunkUploadSession struct { UploadID string // 上传会话ID（随机生成） AccountID uint // 上传者ID Filename string // 原始文件名 FileSize int64 // 文件总大小 ChunkSize int64 // 每片大小（默认5MB） TotalChunks int // 总片数 FileHash string // 整个文件的MD5 UploadedBits []bool // 每片是否已上传（位图） } UploadedBits 是什么 # UploadedBits: []bool{true, true, true, false, false} 这个数组表示：\n分片 0：已上传 ✓ 分片 1：已上传 ✓ 分片 2：已上传 ✓ 分片 3：未上传 分片 4：未上传 用 bool 数组而不是存已上传的分片列表，好处是：\n判断某片是否已上传：O(1)，直接 index 判断是否全部上传完：遍历一次，O(n) 两个辅助方法 # // 返回已上传的分片索引列表 func (s *ChunkUploadSession) UploadedChunks() []int { var indices []int for i, uploaded := range s.UploadedBits { if uploaded { indices = append(indices, i) } } return indices } // 判断是否全部上传完 func (s *ChunkUploadSession) IsComplete() bool { for _, b := range s.UploadedBits { if !b { return false } } return true } 会话存在哪：Redis # 上传会话存在 Redis，不是 MySQL，原因是：\n存储 读写速度 持久化 适合存什么 Redis 微秒级 可配置过期 临时状态（上传进度） MySQL 毫秒级 永久 持久数据（用户信息、视频记录） 上传会话是临时的（24 小时过期），用 Redis 更合适。\nRedis Key 设计 # // 上传会话 func (h *ChunkUploadHandler) sessionKey(uploadID string) string { return h.cache.Key(\u0026#34;chunk_upload:%s\u0026#34;, uploadID) } // 文件哈希 → upload_id 的映射（用于断点续传） func (h *ChunkUploadHandler) hashKey(accountID uint, fileHash string) string { return h.cache.Key(\u0026#34;chunk_upload_hash:%d:%s\u0026#34;, accountID, fileHash) } 两个 Key 的作用：\nchunk_upload:\u0026lt;upload_id\u0026gt;：存完整的上传会话 chunk_upload_hash:\u0026lt;account_id\u0026gt;:\u0026lt;file_hash\u0026gt;：文件哈希到 upload_id 的映射，用于断点续传 四个接口详解 # 接口 1：InitChunkUpload（初始化上传） # func (h *ChunkUploadHandler) InitChunkUpload(c *gin.Context) 做了什么：\n校验请求参数（文件名、大小、哈希、总片数） 检查文件大小是否超过 200MB 限制 断点续传检查：用文件哈希查 Redis，如果找到之前的会话，直接返回 创建新会话，存入 Redis 返回 upload_id + 已上传列表（新上传为空） 断点续传的实现：\n// 检查是否有同文件的旧会话 hashKey := h.hashKey(accountID, req.FileHash) existingID, err := h.cache.GetBytes(c.Request.Context(), hashKey) if err == nil \u0026amp;\u0026amp; len(existingID) \u0026gt; 0 { session, sessErr := h.getSession(c, string(existingID)) if sessErr == nil { // 找到了！返回旧的upload_id和已上传列表 c.JSON(http.StatusOK, gin.H{ \u0026#34;upload_id\u0026#34;: session.UploadID, \u0026#34;uploaded_chunks\u0026#34;: session.UploadedChunks(), }) return } } 为什么用文件哈希做断点续传？\n用户上传 video.mp4（100MB，MD5: abc123） → Init，upload_id: xxx，开始上传 → 上传了15片，浏览器关闭 用户重新选择同一个 video.mp4 → Init，参数里带同样的 file_hash: abc123 → 服务器查Redis：chunk_upload_hash:\u0026lt;uid\u0026gt;:abc123 → xxx → 找到旧会话！返回已上传的15片 → 客户端跳过这15片，只上传剩下的5片 接口 2：UploadChunk（上传单片） # func (h *ChunkUploadHandler) UploadChunk(c *gin.Context) 做了什么：\n从表单获取 upload_id、chunk_index、chunk_hash 查 Redis 获取会话 校验：会话存在、是本人上传、分片索引合法 幂等检查：如果这片已经上传过，直接返回成功（不重复处理） 读取上传的文件内容，计算 MD5 哈希校验：对比客户端传的 chunk_hash 和实际计算的 MD5 保存分片到临时目录：.run/uploads/tmp/\u0026lt;upload_id\u0026gt;/\u0026lt;chunk_index\u0026gt; 更新 Redis 中的会话状态 幂等设计：\nif session.UploadedBits[req.ChunkIndex] { c.JSON(http.StatusOK, gin.H{\u0026#34;chunk_index\u0026#34;: req.ChunkIndex}) return } 如果分片已上传过，直接返回成功，不重复处理。这保证了重试的安全性。\n哈希校验：\nhash := md5.New() io.Copy(hash, chunkFile) actualHash := fmt.Sprintf(\u0026#34;%x\u0026#34;, hash.Sum(nil)) if actualHash != req.ChunkHash { c.JSON(http.StatusBadRequest, gin.H{\u0026#34;error\u0026#34;: \u0026#34;chunk hash mismatch\u0026#34;}) return } 每个分片都有 MD5 校验，防止传输过程中数据损坏。\n分片存储位置：\n.run/uploads/tmp/ └── \u0026lt;upload_id\u0026gt;/ ├── 0 ← 第0片 ├── 1 ← 第1片 ├── 2 ← 第2片 └── ... 接口 3：ChunkStatus（查询上传状态） # func (h *ChunkUploadHandler) ChunkStatus(c *gin.Context) 返回：upload_id + 已上传分片列表 + 总片数。\n用于客户端轮询或断线后查询进度。\n接口 4：CompleteChunkUpload（完成上传） # func (h *ChunkUploadHandler) CompleteChunkUpload(c *gin.Context) 做了什么：\n校验会话、校验是否本人 检查是否所有分片都已上传（IsComplete()） 如果有未上传的分片，返回错误和缺失信息 合并分片：按顺序读取所有分片，写入最终文件 清理临时文件和 Redis 会话 返回视频 URL 合并过程：\ntmpDir := filepath.Join(\u0026#34;.run\u0026#34;, \u0026#34;uploads\u0026#34;, \u0026#34;tmp\u0026#34;, req.UploadID) for i := 0; i \u0026lt; session.TotalChunks; i++ { chunkPath := filepath.Join(tmpDir, fmt.Sprintf(\u0026#34;%d\u0026#34;, i)) cf, _ := os.Open(chunkPath) io.Copy(finalFile, cf) // 按顺序拼接 cf.Close() } 最终文件位置：\n.run/uploads/ └── videos/ └── \u0026lt;account_id\u0026gt;/ └── \u0026lt;date\u0026gt;/ └── \u0026lt;random\u0026gt;.mp4 清理：\n// 删除临时分片 os.RemoveAll(tmpDir) // 删除Redis会话 h.cache.Del(ctx, sessionKey) h.cache.Del(ctx, hashKey) 安全设计 # 文件大小限制 # const maxSize = 200 \u0026lt;\u0026lt; 20 // 200MB if req.FileSize \u0026gt; maxSize { c.JSON(http.StatusBadRequest, gin.H{\u0026#34;error\u0026#34;: \u0026#34;file size exceeds 200MB limit\u0026#34;}) return } 权限校验 # 每个接口都检查：会话存在 + 是本人操作。\naccountID, _ := jwt.GetAccountID(c) if session.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{\u0026#34;error\u0026#34;: \u0026#34;forbidden\u0026#34;}) return } 分片哈希校验 # 每个分片上传时都验证 MD5，防止数据损坏。\n接口总结 # 接口 方法 作用 /chunk/init POST 初始化上传，返回 upload_id /chunk/upload POST 上传单个分片 /chunk/status POST 查询上传状态 /chunk/complete POST 完成上传，合并分片 面试要点总结 # Q: SSE 是什么？ # A: Server-Sent Events，服务器主动推送消息给客户端，单向，基于 HTTP。场景：通知、状态更新、新闻推送。\nQ: SSE 和 WebSocket 怎么选？ # A: 只要服务器推用 SSE（简单），需要双向通信用 WebSocket（复杂）。这个项目用 SSE 推送通知，因为只需要服务器→客户端。\nQ: 为什么用 SSE 不用轮询？ # A: 轮询客户端每隔几秒问一次，浪费资源、实时性差。SSE 服务器有消息就推，省资源、实时性高。\nQ: 心跳机制是什么？ # A: 每 30 秒发一次空消息（keepalive），告诉客户端\u0026quot;我还活着\u0026quot;。客户端 30 秒没收到，知道连接断了，重新连接。\nQ: Push 为什么用 RLock？ # A: Push 只读 map 不改 map，用读锁可以多个 Push 并发。Subscribe/Unsubscribe 要改 map，用写锁必须独占。\nQ: select 里的 default 是干什么的？ # A: 防阻塞。channel 满了塞不进去就跳过，保证一个用户的消息堆积不影响其他用户。\nQ: 为什么要分片上传？ # A: 大文件一次上传，网络断了要重头来。分片后断点续传，只需重传失败的分片。\nQ: 断点续传怎么实现？ # A: 用文件 MD5 做唯一标识，Init 时查 Redis 是否有同文件的旧会话。有就返回已上传列表，客户端跳过这些分片。\nQ: 上传会话存在哪？为什么？ # A: 存 Redis，因为是临时状态（24 小时过期），读写快。MySQL 存持久数据。\nQ: 怎么保证分片数据没损坏？ # A: 每片上传时计算 MD5，和客户端传的 chunk_hash 对比，不匹配就拒绝。\nQ: 分片重复上传怎么办？ # A: 幂等设计——检查 UploadedBits，已上传的分片直接返回成功，不重复处理。\nQ: 会话里 UploadedBits 是什么？ # A: bool 数组，每个元素对应一个分片是否已上传。长度等于总片数，直接用下标访问，O(1) 判断某片是否已上传。\n","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/p/go-feed-sse-upload/","section":"Posts","summary":"","title":"Go 项目反推：Feed 流系统实战——SSE 实时推送与分片上传","type":"posts"},{"content":"承接 Go 项目反推：Feed 流系统实战——SSE 实时推送与分片上传，这一篇回到工程全景，梳理 feedsystem_video_go 的容器编排、配置管理、性能分析、持续集成与本地启动流程。\nDocker Compose（容器编排） # 什么是 Docker Compose # 把所有服务打包成容器，一键启动。\n没有 Docker Compose：\n手动启动MySQL → 手动启动Redis → 手动启动RabbitMQ → 手动启动Backend → 手动启动Worker → 手动启动Frontend 每次都要敲6条命令，还要记住启动顺序 有 Docker Compose：\ndocker compose up -d 一键启动所有服务 这个项目的 Docker Compose # services: mysql: # 数据库 redis: # 缓存 rabbitmq: # 消息队列 backend: # API进程 worker: # Worker进程 frontend: # 前端 相关文件：~/dev/projects/feedsystem_video_go/docker-compose.yml\ndepends_on + healthcheck（保证启动顺序） # depends_on 是什么 # 告诉 Docker：某个服务依赖其他服务，要等依赖的服务启动后才能启动自己。\nbackend: depends_on: mysql: condition: service_healthy # MySQL健康了才启动backend redis: condition: service_healthy # Redis健康了才启动backend rabbitmq: condition: service_healthy # RabbitMQ健康了才启动backend healthcheck 是什么 # 判断服务是否\u0026quot;健康\u0026quot;（真正能接受连接）。\nmysql: healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;mysqladmin ping -h 127.0.0.1 -uroot -p123456\u0026#34;] interval: 5s # 每5秒检查一次 timeout: 5s # 超时5秒 retries: 20 # 重试20次 MySQL 启动后，每 5 秒 ping 一次，连续 ping 成功才算\u0026quot;健康\u0026quot;。\n为什么要 healthcheck # 没有healthcheck： MySQL容器启动了 → backend立刻启动 但MySQL还没初始化完 → backend连接失败 → 崩溃 有healthcheck： MySQL容器启动了 → 等MySQL真正能接受连接 → backend再启动 所有服务的 healthcheck # 服务 healthcheck 测试 作用 mysql mysqladmin ping 确认 MySQL 能接受连接 redis redis-cli ping 确认 Redis 能接受连接 rabbitmq rabbitmq-diagnostics ping 确认 RabbitMQ 能接受连接 backend wget http://127.0.0.1:8080/healthz 确认 API 进程正常 worker pgrep worker 确认 Worker 进程存在 frontend wget http://127.0.0.1:80/ 确认前端能访问 配置管理（优先级） # 配置文件位置 # ~/dev/projects/feedsystem_video_go/backend/configs/ ├── config.yaml # 本地开发用（MySQL端口3306） ├── config.compose-local.yaml # Docker Compose本地用（MySQL端口3307） └── config.docker.yaml # Docker部署用（容器内部通信） 三个配置文件的区别 # 文件 MySQL 端口 用途 config.yaml 3306 本地开发，MySQL 装在本机 config.compose-local.yaml 3307 Docker Compose，MySQL 容器映射到 3307 config.docker.yaml 3306 Docker 部署，容器内部通信 配置加载优先级 # 环境变量 \u0026gt; .env文件 \u0026gt; YAML文件 来源 举例 优先级 环境变量 export MYSQL_DATABASE=feedsystem 最高 .env 文件 MYSQL_DATABASE=feedsystem 中 YAML 文件 database.dbname: feedsystem 最低 为什么要这样设计 # 开发环境： 用YAML文件，配置写死在代码里，方便开发 Docker部署： 用环境变量覆盖，不用改配置文件 docker run -e MYSQL_DATABASE=feedsystem -e JWT_SECRET=*** ... 环境变量覆盖的好处 # 场景 做法 本地开发 用 YAML 文件的默认配置 Docker 部署 用环境变量覆盖数据库地址、密码等 生产环境 用环境变量覆盖，敏感信息不写进代码 启动时指定配置文件 # # 本地开发 cd backend \u0026amp;\u0026amp; CONFIG_PATH=configs/config.yaml go run ./cmd # Docker Compose本地 cd backend \u0026amp;\u0026amp; CONFIG_PATH=configs/config.compose-local.yaml go run ./cmd API 进程和 Worker 进程为什么要分开 # 两个进程 # cmd/ ├── main.go # API进程（处理HTTP请求） └── worker/ └── main.go # Worker进程（消费MQ消息） 各自做什么 # API 进程 Worker 进程 职责 处理 HTTP 请求 消费 MQ 消息 入口 go run ./cmd go run ./cmd/worker 启动方式 接收 HTTP 请求 订阅 MQ 队列 为什么要分开 # 1. 职责不同\nAPI进程：用户发请求 → 处理 → 返回响应（要求快） Worker进程：从MQ取消息 → 更新DB、推送通知（可以慢） 2. 独立扩展\n场景：API扛不住了 没分开：只能整体加机器 分开后：只加API机器，Worker不动 场景：Worker扛不住了 分开后：只加Worker机器，API不动 3. 故障隔离\nWorker崩了：不影响API，用户还能正常访问 API崩了：不影响Worker，后台任务还在处理 4. 资源分配不同\nAPI进程：需要低延迟，分配更多CPU Worker进程：可以容忍高延迟，分配更多内存 Docker Compose 里的配置 # backend: build: dockerfile: backend/Dockerfile target: api # 构建API镜像 worker: build: dockerfile: backend/Dockerfile target: worker # 构建Worker镜像 同一个 Dockerfile，用不同的 target 构建两个镜像。\npprof（性能分析） # pprof 是什么 # Go 内置的性能分析工具，帮你找出程序哪里慢、哪里吃内存。\n能分析什么 # 类型 作用 CPU 哪个函数最耗 CPU 内存 哪个函数最耗内存 goroutine 有没有 goroutine 泄漏 阻塞 哪里卡住了 配置 # observability: pprof: enabled: true api_addr: localhost:6060 # API进程的pprof端口 worker_addr: localhost:6061 # Worker进程的pprof端口 启动后，访问 http://localhost:6060/debug/pprof/ 就能看到性能数据。\n常用命令 # # CPU分析（30秒） go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 # 内存分析 go tool pprof http://localhost:6060/debug/pprof/heap # goroutine分析 go tool pprof http://localhost:6060/debug/pprof/goroutine 场景举例 # 用户反馈：API响应慢 1. 用pprof抓CPU分析 2. 发现某个函数占了80%的CPU时间 3. 优化这个函数 4. 响应变快了 CI 流程（持续集成） # CI 是什么 # 代码推送到 GitHub 后，自动运行检查，确保代码质量。\n这个项目的 CI # # .github/workflows/ci.yml jobs: test: steps: - go vet ./... # 静态检查 - go test -race ./... # 运行测试，检测数据竞争 go vet 是什么 # 静态分析，找出语法错误和可疑代码，不需要运行程序。\n举例： - 变量声明了但没用 - 格式化字符串参数不对 - 死代码（永远不会执行的代码） go test -race 是什么 # 运行测试，同时检测数据竞争。\n什么是数据竞争 # 多个 goroutine 同时读写同一个变量，没有加锁。\n// 数据竞争的例子 var count int go func() { count++ }() // goroutine1写 go func() { count++ }() // goroutine2写 // 两个goroutine同时写count，结果不确定 -race 怎么检测 # go test -race ./... 如果发现数据竞争： WARNING: DATA RACE Write by goroutine 1 Read by goroutine 2 ... FAIL CI 流程 # 开发者推送代码到GitHub ↓ GitHub Actions自动触发 ↓ go vet ./... → 静态检查 ↓ go test -race ./... → 运行测试 + 检测数据竞争 ↓ 全部通过 → 绿色 ✅ 有失败 → 红色 ❌，不允许合并 好处 # 没有 CI 有 CI 代码质量 靠人工 review 自动检查 数据竞争 上线后才发现 提交时就发现 合并代码 随便合并 检查通过才能合并 启动脚本（start.sh） # 一键启动所有服务 # ./start.sh 相关文件：~/dev/projects/feedsystem_video_go/start.sh\n脚本是什么语言写的 # Bash（Shell 脚本），文件扩展名 .sh = Shell 脚本。\n#!/usr/bin/env bash # 声明用bash解释器 set -euo pipefail # 遇到错误立刻停止 Bash 是 Linux/macOS 终端的脚本语言，用来自动化命令行操作。\n脚本做的事 # 1. 启动Redis（如果没有运行） 2. 启动RabbitMQ（通过Docker Compose） 3. 启动Backend（go run ./cmd） 4. 启动Worker（go run ./cmd/worker） 5. 启动Frontend（npm run dev） 6. Ctrl+C停止所有服务 核心逻辑 # # 启动Redis start_redis() { redis-server --bind 127.0.0.1 --port 6379 } # 启动RabbitMQ（通过Docker Compose） start_rabbitmq_compose() { docker compose up -d rabbitmq } # 启动Backend start_backend_bg() { cd backend \u0026amp;\u0026amp; go run ./cmd \u0026amp; } # 启动Worker start_worker_bg() { cd backend \u0026amp;\u0026amp; go run ./cmd/worker \u0026amp; } # 启动Frontend start_frontend_bg() { cd frontend \u0026amp;\u0026amp; npm run dev \u0026amp; } # Ctrl+C停止所有服务 trap cleanup INT TERM EXIT cleanup() { kill $BACKEND_PID kill $WORKER_PID kill $FRONTEND_PID docker compose stop } 环境变量控制 # START_REDIS=1 # 是否启动Redis START_RABBITMQ=1 # 是否启动RabbitMQ START_BACKEND=1 # 是否启动Backend START_WORKER=1 # 是否启动Worker START_FRONTEND=1 # 是否启动Frontend 可以按需启动：\n# 只启动Backend和Worker START_FRONTEND=0 ./start.sh 面试要点总结 # Q: Docker Compose 怎么保证启动顺序？ # A: depends_on + healthcheck，服务健康了才启动下一个。\nQ: 配置优先级？ # A: 环境变量 \u0026gt; .env 文件 \u0026gt; YAML 文件。\nQ: API 和 Worker 为什么要分开？ # A: 职责不同、独立扩展、故障隔离、资源分配不同。\nQ: pprof 能分析什么？ # A: CPU、内存、goroutine、阻塞。\nQ: CI 做了什么？ # A: go vet 静态检查 + go test -race 检测数据竞争。\nQ: 什么是数据竞争？ # A: 多个 goroutine 同时读写同一个变量，没有加锁。用 go test -race 检测。\nQ: 为什么要用 healthcheck？ # A: 确保服务真正能接受连接，而不是只启动了容器。\n","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/p/go-feed-engineering/","section":"Posts","summary":"","title":"Go 项目反推：Feed 流系统实战——工程化","type":"posts"},{"content":"承接 Go 项目反推：Feed 流系统实战——Redis 缓存设计，这一篇继续反推 feedsystem_video_go 的消息队列与异步架构，梳理 RabbitMQ 的消息路由、消费重试、降级策略、Outbox 模式和死信队列。\n什么是消息队列（MQ） # 想象一个餐厅：\n没有 MQ：顾客点餐 → 厨师做完一个才能做下一个 → 顾客等着 有 MQ：顾客点餐 → 写在小票上放窗口 → 厨师按顺序做 → 做好了喊号 消息队列就是那个\u0026quot;窗口\u0026quot;，消息就是\u0026quot;小票\u0026quot;。\n为什么需要消息队列 # 用户点赞后要做的事：\n用户点赞 → 更新Like表 → 更新Video点赞数 → 更新热度 → 推送通知 → 返回成功 没有 MQ 的问题：\n用户等太久（每个操作都要时间） 任何一个失败，整个流程卡住 高并发时 DB 扛不住 有了 MQ：\n用户点赞 → 更新Like表 → 发一条\u0026#34;点赞消息\u0026#34;到MQ → 立刻返回成功 ↓ Worker从MQ取消息 → 更新点赞数 → 更新热度 → 推送通知 好处：\n用户不用等所有操作完成，体验快 点赞服务和热度计算服务解耦，改一个不影响另一个 高并发时消息排队，DB 不会被打垮 MQ 的三个核心概念 # 概念 比喻 作用 Exchange 邮局 接收消息，根据规则分发 Queue 邮箱 存储消息，等 Worker 来取 Routing Key 地址 消息的\u0026quot;目的地\u0026quot;，Exchange 根据它决定放哪个 Queue 流程：\n生产者（点赞服务） → 发消息到Exchange → Exchange根据Routing Key → 放到Queue → Worker从Queue取消息消费 三种 Exchange 类型 # 类型 规则 举例 Direct Routing Key 完全匹配 发到\u0026quot;like.like\u0026quot;，只有绑定了\u0026quot;like.like\u0026quot;的 Queue 才能收到 Topic 支持通配符（* 和 #） 发到\u0026quot;like.*\u0026quot;，能匹配\u0026quot;like.like\u0026quot;、\u0026ldquo;like.unlike\u0026rdquo; Fanout 广播，忽略 Routing Key 发到所有绑定的 Queue，每个 Queue 都能收到 这个项目用的是 Topic Exchange。\n项目里的 6 种 MQ # Exchange Routing Key 用途 like.events like.like / like.unlike 点赞/取消点赞 comment.events comment.publish / comment.delete 评论/删除评论 social.events social.follow / social.unfollow 关注/取关 video.popularity.events video.popularity.update 热度更新 video.timeline.events video.timeline.publish 视频发布 dlx.events # 死信队列（重试/告警） 代码实现 # MQ 相关文件 # backend/internal/middleware/rabbitmq/ ├── rabbitMQ.go # MQ基础封装（连接、Channel、DeclareTopic、PublishJSON） ├── dlx.go # 死信队列声明和重试计数 ├── likeMQ.go # 点赞MQ ├── commentMQ.go # 评论MQ ├── socialMQ.go # 关注MQ ├── popularityMQ.go # 热度MQ └── timelineMQ.go # Feed流MQ（Outbox模式） Worker 相关文件 # backend/internal/worker/ ├── likeworker.go # 点赞Worker ├── commentworker.go # 评论Worker ├── socialworker.go # 关注Worker ├── popularityworker.go # 热度Worker ├── outboxworker.go # Outbox定时任务 ├── notificationworker.go # 通知Worker └── ssehub.go # SSE推送Hub 消息生产（发消息） # rabbitMQ.go - PublishJSON # func PublishJSON(ctx context.Context, ch *amqp.Channel, exchange string, routingKey string, payload any) error { b, _ := json.Marshal(payload) // 第1步：把数据变成JSON return ch.PublishWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{ ContentType: \u0026#34;application/json\u0026#34;, // 第2步：告诉MQ这是JSON格式 DeliveryMode: amqp.Persistent, // 第3步：消息持久化 Body: b, // 第4步：消息内容 }) } 逐行解释：\njson.Marshal(payload)：把 Go 结构体变成 JSON 字节 ch.PublishWithContext(...)：调用 MQ 客户端，发一条消息 exchange：发到哪个 Exchange（邮局） routingKey：消息地址（like.like） amqp.Persistent：消息存到磁盘，MQ 重启不丢 Body: b：消息内容 消息长什么样 # { \u0026#34;video_id\u0026#34;: 123, \u0026#34;account_id\u0026#34;: 456, \u0026#34;event_type\u0026#34;: \u0026#34;like.like\u0026#34;, \u0026#34;timestamp\u0026#34;: \u0026#34;2026-07-28T14:30:00Z\u0026#34; } 就是一个 JSON，告诉 Worker\u0026quot;用户 456 给视频 123 点了赞\u0026quot;。\n消息消费（Worker 处理） # likeworker.go - Run # func (w *LikeWorker) Run(ctx context.Context) error { deliveries, err := w.ch.Consume(w.queue, \u0026#34;\u0026#34;, false, false, false, false, nil) for { select { case \u0026lt;-ctx.Done(): return ctx.Err() case d, ok := \u0026lt;-deliveries: if !ok { return errors.New(\u0026#34;deliveries channel closed\u0026#34;) } w.handleDelivery(ctx, d) } } } 逐行解释：\nw.ch.Consume(w.queue, ...)：从 Queue 订阅消息，返回一个 channel for { select { ... } }：循环读消息，有消息来就处理 w.handleDelivery(ctx, d)：调用处理函数，执行业务逻辑 handleDelivery - 重试逻辑 # func (w *LikeWorker) handleDelivery(ctx context.Context, d amqp.Delivery) { const maxRetries = 3 for i := 0; i \u0026lt;= maxRetries; i++ { if err := w.process(ctx, d.Body); err != nil { if i \u0026gt;= maxRetries { // 超过3次，放弃，Ack删除消息 log.Printf(\u0026#34;like worker: 重试 %d 次后仍失败, 丢弃\u0026#34;, maxRetries) _ = d.Ack(false) return } // 等一下再重试（指数退避） wait := time.Duration(1\u0026lt;\u0026lt;uint(i)) * time.Second // 1s, 2s, 4s log.Printf(\u0026#34;like worker: 处理失败, %v 后重试 (%d/%d)\u0026#34;, wait, i+1, maxRetries) time.Sleep(wait) continue } // 成功，Ack删除消息 _ = d.Ack(false) return } } 重试流程：\n第1次处理 → 失败 → 等1秒 第2次处理 → 失败 → 等2秒 第3次处理 → 失败 → 等4秒 第4次处理 → 失败 → 放弃，Ack删除消息 指数退避（1s → 2s → 4s） # 1\u0026lt;\u0026lt;uint(i) 是位运算：\ni=0: 1\u0026laquo;0 = 1秒 i=1: 1\u0026laquo;1 = 2秒 i=2: 1\u0026laquo;2 = 4秒 为什么要等这么久？避免疯狂重试打垮服务器。\n为什么失败了还 Ack？ # 不 Ack 的话，消息会一直留在 Queue 里，Worker 会一直重试，卡住后面的消息。Ack 后消息从 Queue 删除，避免阻塞。\nMQ 发布失败的降级策略 # 场景：用户点赞成功，DB 写入了，但发 MQ 消息失败了。\n如果直接忽略：热度不会更新，点赞数据和热度不一致。\nfallback 直写 # func (s *LikeService) publishLikeEvent(ctx context.Context, videoID, accountID uint, routingKey string) { event := LikeEvent{VideoID: videoID, AccountID: accountID} err := rabbitmq.PublishJSON(ctx, s.ch, \u0026#34;like.events\u0026#34;, routingKey, event) if err != nil { // MQ发送失败，降级：直接更新热度 go s.popularityWorker.HandleLikeEvent(event) } } 意思：\nMQ 能用 → 走 MQ 异步处理 MQ 挂了 → 直接调 Worker 的处理函数，同步处理 为什么不用重试？如果 MQ 挂了，重试大概率还是失败，用户要等很久。直接降级：虽然绕过了 MQ，但保证功能正常，用户体验不受影响。\nOutbox 模式 # 什么是 Outbox 模式 # 发视频时用的模式。\n场景：用户发视频 → 更新 DB → 发 MQ 消息\n问题：如果 DB 更新成功，MQ 发送失败，视频发布了但 Feed 流里没有。\n为什么不能放在一个事务里？DB 和 MQ 是两个系统，没法用一个事务包起来。\nOutbox 的解法 # 把 MQ 消息先存到数据库里，用数据库事务保证一致性：\n数据库事务（保证原子性）： INSERT INTO videos (title, play_url, ...) VALUES (...) INSERT INTO outbox_msgs (video_id, status) VALUES (xxx, \u0026#39;pending\u0026#39;) COMMIT 两步在同一个 DB 事务里，要么都成功，要么都失败。\n然后用定时任务把消息发出去：\nOutboxPoller（每秒运行一次）： 查询：SELECT * FROM outbox_msgs WHERE status = \u0026#39;pending\u0026#39; 对于每条消息： 发MQ消息 更新：UPDATE outbox_msgs SET status = \u0026#39;sent\u0026#39; WHERE id = xxx OutboxMsg 表结构 # type OutboxMsg struct { ID uint VideoID uint EventType string CreateTime time.Time Status string // \u0026#34;pending\u0026#34; 或 \u0026#34;sent\u0026#34; } 完整流程 # 00:00 用户发视频 → 写Video表 + 写OutboxMsg表（同一个事务，保证原子） 00:01 OutboxPoller扫到这条消息 → 发MQ消息成功 → 标记为sent 00:02 Worker收到MQ消息 → 更新Feed流 为什么这样能保证消息不丢？ # 消息先存在 DB 里，和业务数据在同一个事务 定时任务保证最终会发出去 即使 MQ 暂时挂了，消息还在 DB 里，等 MQ 恢复了再发 回滚只发生在 DB 层面 # 数据库事务： INSERT INTO videos ... → 成功 INSERT INTO outbox_msgs ... → 失败 ROLLBACK → 两个都撤销 MQ 发送失败不回滚：\n数据库事务：COMMIT（消息已经存到outbox_msgs表了） OutboxPoller： 发MQ消息 → 失败（MQ挂了） → 不回滚！消息还在outbox_msgs表里 → 等MQ恢复了，下次扫描再发 为什么发视频用 Outbox，点赞不用？ # 场景 方案 原因 点赞 直接发 MQ + fallback 点赞失败可以重试，用户能接受 发视频 Outbox 模式 视频必须进 Feed 流，不能丢 视频发布是关键业务，不能丢消息，所以用 Outbox 保证\u0026quot;至少发一次\u0026quot;。\n死信队列（DLX） # 什么是死信队列 # 消息\u0026quot;死了\u0026quot;（消费失败），放到一个专门的队列里等处理。\n就像快递派送失败：\n正常流程：快递 → 送到你家 → 签收 派送失败：快递 → 送到你家 → 没人签收 → 退回快递站（死信队列） → 重新派送（重试） → 联系收件人（告警） 消息为什么会\u0026quot;死\u0026quot; # 原因 举例 业务逻辑报错 热度计算除零了 消费超时 Worker 处理太久，MQ 等不及了 拒绝消息 代码里主动 reject 正常消费流程 # Worker从Queue取消息 ↓ 处理成功 → msg.Ack() → MQ删除消息 消费失败了 # Worker从Queue取消息 ↓ 处理失败 → msg.Nack() 或 超时 ↓ MQ把消息放到死信队列（DLX） ↓ 死信Worker处理： - 记录日志 - 重试（最多N次） - 超过重试次数 → 告警/人工处理 dlx.go - DeclareDLX # const DLXExchange = \u0026#34;dlx.events\u0026#34; const MaxRetryCount = 3 func DeclareDLX(ch *amqp.Channel, queueName string) error { // 1. 声明死信Exchange ch.ExchangeDeclare(DLXExchange, \u0026#34;topic\u0026#34;, true, false, false, false, nil) // 2. 声明死信Queue（名字是 原Queue名 + \u0026#34;.dlx\u0026#34;） dlxQueue := queueName + \u0026#34;.dlx\u0026#34; ch.QueueDeclare(dlxQueue, true, false, false, false, nil) // 3. 绑定：# 匹配所有Routing Key ch.QueueBind(dlxQueue, \u0026#34;#\u0026#34;, DLXExchange, false, nil) } like.events 的死信队列叫 like.events.dlx comment.events 的死信队列叫 comment.events.dlx GetRetryCount - 获取重试次数 # func GetRetryCount(d amqp.Delivery) int { deaths, ok := d.Headers[\u0026#34;x-death\u0026#34;].([]interface{}) if !ok || len(deaths) == 0 { return 0 } death, ok := deaths[0].(amqp.Table) if !ok { return 0 } count, ok := death[\u0026#34;count\u0026#34;].(int64) if !ok { return 0 } return int(count) } MQ 会自动在消息头里记录\u0026quot;这条消息死了几次\u0026quot;，从 x-death 里取出来。\n正常 Queue 声明时绑定 DLX # func DeclareTopic(ch *amqp.Channel, exchange string, queue string, bindingKey string) error { // 声明Exchange ch.ExchangeDeclare(exchange, \u0026#34;topic\u0026#34;, true, false, false, false, nil) // 声明Queue，绑定死信Exchange q, err := ch.QueueDeclare(queue, true, false, false, false, amqp.Table{\u0026#34;x-dead-letter-exchange\u0026#34;: DLXExchange}) // 绑定：Queue订阅Exchange的消息 ch.QueueBind(q.Name, bindingKey, exchange, false, nil) } x-dead-letter-exchange：告诉 MQ，这个 Queue 的消息如果\u0026quot;死了\u0026quot;，发到 DLXExchange。\n完整流程举例 # 正常流程： 点赞 → like.events Queue → LikeWorker消费 → 成功 → Ack 失败流程： 点赞 → like.events Queue → LikeWorker消费 → 失败 → Nack ↓ like.events.dlx Queue（死信队列） ↓ 死信Worker处理（重试/告警） API 进程和 Worker 进程为什么要分开 # cmd/ ├── main.go # API进程（处理HTTP请求） └── worker/ └── main.go # Worker进程（消费MQ消息） 分开的原因：\n职责不同：API 处理请求，Worker 处理后台任务 独立扩展：API 扛不住加 API 机器，Worker 扛不住加 Worker 机器 故障隔离：Worker 崩了不影响 API，API 崩了不影响 Worker 资源分配：API 需要低延迟，Worker 可以容忍高延迟 ch.Qos(50, 0, false) - 预取数量控制 # ch.Qos(50, 0, false) 50：每次最多取 50 条消息 0：不限制消息大小 false：只对当前 Channel 生效 为什么要控制预取数量？\n太多：Worker 内存爆了 太少：Worker 闲着没事干 50：平衡点，Worker 处理完一批再取下一批 面试要点总结 # Q: 为什么用 MQ？ # A: 削峰（高并发时消息排队）、解耦（点赞服务和热度计算独立）、异步（用户不用等所有操作完成）。\nQ: MQ 发布失败了怎么办？ # A: 降级策略，直接调 Worker 的处理函数同步处理。点赞用 fallback 直写，发视频用 Outbox 模式保证消息不丢。\nQ: Outbox 模式是什么？ # A: 把 MQ 消息先存到数据库里，用 DB 事务保证一致性。定时任务扫描 outbox_msgs 表，发 MQ 消息，标记已发送。\nQ: 死信队列怎么用的？ # A: 消息消费失败时，MQ 自动把消息放到死信队列。Worker 里有重试逻辑，最多重试 3 次，指数退避（1s→2s→4s），超过 3 次放弃。\nQ: API 进程和 Worker 进程为什么要分开？ # A: 职责不同、独立扩展、故障隔离、资源分配不同。\nQ: 什么是指数退避？ # A: 重试间隔按 2 的幂次增长（1s→2s→4s），避免疯狂重试打垮服务器。\nQ: 三种 Exchange 的区别？ # A: Direct 精确匹配，Topic 支持通配符，Fanout 广播到所有 Queue。\nQ: 为什么点赞用 fallback，发视频用 Outbox？ # A: 点赞失败用户能接受重试，视频发布是关键业务不能丢消息。Outbox 保证\u0026quot;至少发一次\u0026quot;。\n","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/p/go-feed-message-queue-async-architecture/","section":"Posts","summary":"","title":"Go 项目反推：Feed 流系统实战——消息队列与异步架构","type":"posts"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/outbox/","section":"Tags","summary":"","title":"Outbox","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/pprof/","section":"Tags","summary":"","title":"Pprof","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/rabbitmq/","section":"Tags","summary":"","title":"RabbitMQ","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/redis/","section":"Tags","summary":"","title":"Redis","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/singleflight/","section":"Tags","summary":"","title":"Singleflight","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/sse/","section":"Tags","summary":"","title":"SSE","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%96%AD%E7%82%B9%E7%BB%AD%E4%BC%A0/","section":"Tags","summary":"","title":"断点续传","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%88%86%E7%89%87%E4%B8%8A%E4%BC%A0/","section":"Tags","summary":"","title":"分片上传","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/%E7%BC%93%E5%AD%98/","section":"Tags","summary":"","title":"缓存","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86/","section":"Tags","summary":"","title":"配置管理","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%AD%BB%E4%BF%A1%E9%98%9F%E5%88%97/","section":"Tags","summary":"","title":"死信队列","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97/","section":"Tags","summary":"","title":"消息队列","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%BC%82%E6%AD%A5%E6%9E%B6%E6%9E%84/","section":"Tags","summary":"","title":"异步架构","type":"tags"},{"content":"","date":"2026年7月29日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%B8%B8%E6%A0%87%E5%88%86%E9%A1%B5/","section":"Tags","summary":"","title":"游标分页","type":"tags"},{"content":"承接 Go 项目反推：Feed 流系统实战——数据库与 GORM，这一篇继续反推 feedsystem_video_go 的 Redis 设计，梳理缓存读写、一致性与高并发防护，以及 Lua 限流和 ZSET 热榜的实现。\n项目里 Redis 的 7 种用途 # # 用途 数据结构 Key模式 1 JWT Token 缓存 String account:\u0026lt;id\u0026gt; 2 Refresh Token 缓存 String account:\u0026lt;id\u0026gt;:refresh / refresh:\u0026lt;token\u0026gt; 3 视频详情缓存 String(JSON) video:detail:id=\u0026lt;id\u0026gt; 4 视频实体缓存 String(JSON) video:entity:\u0026lt;id\u0026gt; 5 热榜 ZSET hot:video:1m:\u0026lt;时间窗口\u0026gt; 6 分片上传会话 String(JSON) chunk_upload:\u0026lt;upload_id\u0026gt; 7 接口限流 String(计数器) ratelimit:\u0026lt;prefix\u0026gt;:\u0026lt;subject\u0026gt; 1. 缓存读取流程（video_service.go 的 GetDetail） # func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) { cacheKey := vs.cache.Key(\u0026#34;video:detail:id=%d\u0026#34;, id) // 第一步：查缓存（50ms 超时） getCached := func() (*Video, bool) { opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() b, err := vs.cache.GetBytes(opCtx, cacheKey) if err != nil { return nil, false // 缓存没命中 } var cached Video if err := json.Unmarshal(b, \u0026amp;cached); err != nil { return nil, false // 数据损坏 } return \u0026amp;cached, true // 缓存命中 } // 尝试读缓存 if v, ok := getCached(); ok { return v, nil // 命中，直接返回 } // 缓存没命中，查数据库... } 为什么缓存读取要设 50ms 超时？ # 50ms 超时不是判断\u0026quot;没命中\u0026quot;，而是防止 Redis 卡住的降级策略：\n缓存没命中：Redis 快速返回\u0026quot;key 不存在\u0026quot;，几毫秒就完成 Redis 卡住：网络抖动、Redis 内部阻塞、连接池耗尽，可能卡几秒 没有超时：Redis 卡住 → 请求一直等着 → 用户体验很差 有 50ms 超时：Redis 超过 50ms 没响应 → 放弃缓存 → 直接查数据库\n为什么不设更短的超时，比如 5ms？ # Redis 正常操作本身需要几毫秒，设 5ms 正常情况都可能超时，缓存形同虚设。50ms 是平衡点：覆盖正常操作 + 轻微网络波动，同时不会让用户等太久。\n2. 缓存写入 # setCached := func(video *Video) { b, err := json.Marshal(video) if err != nil { return } opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() _ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL) } 为什么缓存要设过期时间（TTL）？ # TTL = Time To Live，缓存的\u0026quot;保质期\u0026quot;。\n主要原因：数据一致性\n09:00 缓存存了视频标题\u0026#34;今天天气真好\u0026#34; 09:05 作者把标题改成\u0026#34;今天下雨了\u0026#34; → 数据库更新了，但缓存还是旧的 09:06 用户查这个视频 → 从缓存读到\u0026#34;今天天气真好\u0026#34; → 看到的是过期数据 没有 TTL：缓存永远是旧数据，用户看到的和数据库不一致 有了 TTL（比如 10 分钟）：最多 10 分钟后缓存自动过期，下次请求加载最新数据\n这是用时间换一致性：允许短暂不一致，但最终会一致。\n次要原因：缓存空间有限\n缓存满了需要淘汰旧数据，TTL 帮助自动清理。\n3. 缓存删除（主动失效） # 为什么不等 TTL 过期，要主动删缓存？ # 看 video_service.go 删视频时：\nfunc (vs *VideoService) DeleteVideo(ctx context.Context, id uint) error { // 先删数据库 if err := vs.repo.DeleteVideo(ctx, id); err != nil { return err } // 再删缓存 if vs.cache != nil { cacheKey := vs.cache.Key(\u0026#34;video:detail:id=%d\u0026#34;, id) _ = vs.cache.Del(context.Background(), cacheKey) } return nil } DB 更新后立刻删缓存，下次请求来了缓存没命中，从数据库加载最新数据。\n4. 缓存和 DB 的一致性策略 # 两种方案对比 # 方案 做法 风险 先删缓存，再更新 DB 删缓存 → 写 DB 删完缓存、还没写 DB 时，另一个请求读 DB 旧数据写入缓存 先更新 DB，再删缓存 写 DB → 删缓存 写完 DB、还没删缓存时，另一个请求读到旧缓存 为什么\u0026quot;先更新 DB 再删缓存\u0026quot;更安全？ # 方案 1：先删缓存，再更新 DB\n00:00 请求A：删缓存 00:01 请求B：查视频 → 缓存没命中 → 从DB读到旧数据 → 写入缓存 00:02 请求A：更新DB为新数据 结果：DB是新数据，缓存里是旧数据（脏数据） 发生概率不低，因为\u0026quot;删缓存\u0026quot;到\u0026quot;更新 DB\u0026quot;之间可能有几百毫秒（DB 写入比 Redis 慢）。\n方案 2：先更新 DB，再删缓存\n00:00 请求A：更新DB为新数据 00:01 请求B：查视频 → 从缓存读到旧数据（概率小） 00:02 请求A：删缓存 结果：即使请求B读到旧缓存，00:02缓存就被删了 出问题窗口极小：必须在\u0026quot;更新 DB\u0026quot;和\u0026quot;删缓存\u0026quot;之间，恰好有另一个请求来读。\n共同风险点：删缓存失败了怎么办？ # 00:00 更新DB成功 → DB是新数据 00:01 删缓存失败（网络抖动、Redis挂了） → 缓存还是旧数据 解决方案：靠 TTL 兜底\n代码里删缓存都用了 _ = 忽略错误：\n_ = vs.cache.Del(context.Background(), cacheKey) 即使删缓存失败，最多等 TTL 过期（比如 10 分钟），缓存自动失效，下次请求从 DB 加载新数据。\n这是最终一致性：允许短暂不一致，但最终会一致。\n5. 缓存穿透/击穿/雪崩 # 三个概念的区别 # 问题 现象 危害 缓存穿透 查询不存在的数据，缓存永远没命中 每次请求都打到 DB 缓存击穿 热点 key 过期，大量请求同时打到 DB DB 瞬间压力暴增 缓存雪崩 大量 key 同时过期 DB 瞬间压力暴增 缓存击穿的解法（这个项目用的） # 场景：一个热门视频的缓存过期了，100 个用户同时请求\n// 缓存没命中，尝试获取分布式锁 lockKey := \u0026#34;lock:\u0026#34; + cacheKey token, locked, lockErr := vs.cache.Lock(lockCtx, lockKey, 2*time.Second) if lockErr == nil \u0026amp;\u0026amp; locked { // 拿到锁：去查DB，回填缓存 defer func() { _ = vs.cache.Unlock(context.Background(), lockKey, token) }() video, err := vs.repo.GetByID(ctx, id) if err != nil { return nil, err } setCached(video) return video, nil } // 没拿到锁：等待别人回填缓存（最多100ms） for i := 0; i \u0026lt; 5; i++ { time.Sleep(20 * time.Millisecond) if v, ok := getCached(); ok { return v, nil // 别人已经回填了，直接用 } } // 降级：直接查DB video, err := vs.repo.GetByID(ctx, id) 核心逻辑：让一个请求干活，其他请求等着用结果\n❌ 没有锁的击穿场景： 100个请求同时来 → 缓存没命中 → 100个都去查DB → DB压力x100 ✅ 有锁的保护： 100个请求同时来 → 缓存没命中 ↓ 请求1：拿到锁 → 查DB → 写缓存 → 返回 请求2-100：没拿到锁 → 等20ms → 查缓存 → 命中 → 返回 时序示例（DB 查询 15ms）：\n00:00 请求1拿到锁，开始查DB（耗时15ms） 请求2-100没拿到锁，开始sleep 20ms 00:15 请求1查完DB，写入缓存 00:20 请求2-100醒来，查缓存 → 命中！ 降级策略（DB 查询很慢时）：\n请求 2-100 最多等 100ms（5次x20ms），如果请求 1 还没完成，降级去查 DB。\n但这种情况很少见，DB 查询通常几毫秒到几十毫秒。\n缓存穿透的解法（这个项目没做） # 缓存穿透：查询不存在的数据，比如 GET /video/999999999，数据库里没有，每次请求都打到 DB。\n方案 做法 缓存空值 查 DB 没结果，也写入缓存（value=\u0026quot;\u0026quot;），设短 TTL 布隆过滤器 在缓存前加一层，快速判断 key 是否存在 项目为什么没做：视频 ID 通过 Feed 流返回，不存在\u0026quot;查不存在 ID\u0026quot;的正常场景。接口有限流保护，攻击成本高。\n缓存雪崩的解法（这个项目没做） # 缓存雪崩：大量 key 同时过期，DB 瞬间压力暴增。\n方案 做法 随机 TTL 给缓存 TTL 加随机值，避免同时过期 多级缓存 L1 本地缓存 + L2 Redis 缓存 永不过期 + 异步更新 缓存不设 TTL，由后台任务定期更新 项目用的是固定 TTL，没做随机化，业务量可控。\n6. 限流中间件（ratelimit.go） # 限流是什么？ # 防止用户发太多请求打垮服务器。像银行取号机：每分钟最多服务 100 个人，第 101 个人来了告诉他\u0026quot;请稍后再来\u0026quot;。\n限流中间件是什么？ # Gin 中间件，所有请求都要经过它：\n用户请求 → 限流中间件检查 → 通过 → handler处理 → 返回 ↓ 超限 → 直接返回429 \u0026#34;too many requests\u0026#34; 限流实现原理 # func Limit(cache *rediscache.Client, keyPrefix string, maxRequests int64, window time.Duration, keyFunc KeyFunc) gin.HandlerFunc { return func(c *gin.Context) { subject, ok := keyFunc(c) key := buildKey(keyPrefix, subject) count, err := cache.IncrementWithExpire(c.Request.Context(), key, window) if err != nil { c.Next() return } if count \u0026gt; maxRequests { c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{\u0026#34;error\u0026#34;: \u0026#34;too many requests\u0026#34;}) return } c.Next() } } IncrementWithExpire 的 Lua 脚本 # local count = redis.call(\u0026#34;INCR\u0026#34;, KEYS[1]) if count == 1 then redis.call(\u0026#34;PEXPIRE\u0026#34;, KEYS[1], ARGV[1]) end return count 限流流程（1 分钟内最多 100 次）：\n00:00.000 请求1来 → INCR → count=1 因为count==1，是第一个请求 → PEXPIRE设60000毫秒（60秒）过期 00:00.001 请求2来 → INCR → count=2 count!=1，不设过期（沿用之前的） ... 00:00.500 请求100来 → INCR → count=100 → 通过（100 \u0026lt;= 100） 00:00.600 请求101来 → INCR → count=101 → 拒绝！（101 \u0026gt; 100） → 返回429 \u0026#34;too many requests\u0026#34; 01:00.000 key自动过期，Redis删除这个key 计数器归零 01:00.001 请求来 → INCR → count=1（重新开始计数） → PEXPIRE重新设60秒 → 通过 为什么用 Lua 脚本？ # 原子执行 = 两条命令之间不会有其他命令插队\n没有 Lua 脚本（可能被插队）：\n00:00 你的程序：INCR key → count=1 00:01 另一个程序：INCR key → count=2 00:02 你的程序：EXPIRE key 60秒 00:03 另一个程序：EXPIRE key 60秒 你的 INCR 和 EXPIRE 之间，被别人插队了。\n有 Lua 脚本（不会插队）：\n00:00 你的程序：开始执行Lua脚本 INCR key → count=1 EXPIRE key 60秒 00:01 Lua脚本执行完，其他程序才能执行 注意：原子执行 ≠ 回滚，原子执行 = 不插队\n如果 INCR 成功了，EXPIRE 失败了，INCR 的结果不会撤销。\n为什么需要原子执行？ # 如果分开写：\ncount = redis.INCR(key) // count=1 // 这里程序崩了，EXPIRE没执行 redis.EXPIRE(key, 60秒) // 没执行到 结果：key 永远不过期，计数永远不会归零，限流永久生效。\nLua 脚本保证 INCR 和 EXPIRE 一起执行，不会出现\u0026quot;只执行了一半\u0026quot;的情况。\nEXPIRE 和 PEXPIRE # EXPIRE：单位是秒 PEXPIRE：单位是毫秒 限流的 key 设计 # func buildKey(keyPrefix, subject string) string { return fmt.Sprintf(\u0026#34;feedsystem:ratelimit:%s:%s\u0026#34;, keyPrefix, strings.TrimSpace(subject)) } 两种维度：\nKeyByIP：按 IP 限流，防止单 IP 攻击 KeyByAccount：按用户 ID 限流，防止单用户刷接口 7. ZSET 热榜（popularity_cache.go） # ZSET 是什么？ # Redis 的有序集合，每个元素有一个分数，自动按分数排序。\nZSET \u0026#34;热榜\u0026#34; ┌─────────┬─────────┐ │ 元素 │ 分数 │ ├─────────┼─────────┤ │ video_5 │ 1000 │ ← 最热 │ video_2 │ 800 │ │ video_8 │ 500 │ │ video_1 │ 100 │ ← 最冷 └─────────┴─────────┘ 热榜更新代码 # func UpdatePopularityCache(ctx context.Context, cache *rediscache.Client, id uint, change int64) { // 删掉旧缓存 _ = cache.Del(context.Background(), cache.Key(\u0026#34;video:detail:id=%d\u0026#34;, id)) _ = cache.Del(context.Background(), cache.Key(\u0026#34;video:entity:%d\u0026#34;, id)) // 写入热榜 now := time.Now().UTC().Truncate(time.Minute) // 当前时间，精确到分钟 windowKey := cache.Key(\u0026#34;hot:video:1m:%s\u0026#34;, now.Format(\u0026#34;200601021504\u0026#34;)) // 热榜key member := strconv.FormatUint(uint64(id), 10) // 视频ID _ = cache.ZincrBy(opCtx, windowKey, member, float64(change)) // 热度+change _ = cache.Expire(opCtx, windowKey, 2*time.Hour) // 2小时后过期 } 滑动窗口设计 # 热榜 key 是 hot:video:1m:202607281430，意思是\u0026quot;2026 年 7 月 28 日 14:30 这一分钟的热榜\u0026quot;。\n00:00 video_5被点赞 → hot:video:1m:202607281430 → video_5分数+1 00:30 video_5又被点赞 → hot:video:1m:202607281430 → video_5分数+1 01:00 新的一分钟 → hot:video:1m:202607281431 每分钟一个 ZSET，2 小时后自动过期。\n为什么要按分钟分窗口？ # 如果用一个大的 ZSET（不分窗口）：\n所有点赞都写到同一个key \u0026#34;hot:video\u0026#34; 1月1日：video_5被点赞1000次，分数=1000，排第一 1月2日：video_8被点赞500次，分数=500 1月3日：video_5被点赞0次，但分数还是1000，还是排第一 问题：旧视频的热度永远不衰减，新视频永远追不上。\n按分钟分窗口：\n每分钟一个ZSET： hot:video:1m:202607281430 → 14:30这一分钟的热度 hot:video:1m:202607281431 → 14:31这一分钟的热度 hot:video:1m:202607281432 → 14:32这一分钟的热度 查热榜时，把最近几个窗口合并：\n14:35查热榜： → 合并 14:30、14:31、14:32、14:33、14:34、14:35 这6个窗口 → 算总分，排序 好处：\n14:30 的热度，到 14:35 时已经衰减了（只有 1/6 权重） 14:35 新产生的热度立刻反映 热榜是\u0026quot;最近几分钟\u0026quot;的热度，不是\u0026quot;历史累计\u0026quot; ZSET 常用操作 # // 分数增加 cache.ZincrBy(ctx, key, member, score) // 按分数倒序取 top N cache.ZRevRange(ctx, key, 0, 99) // 取前100名 // 合并多个ZSET cache.ZUnionStore(ctx, dst, keys, \u0026#34;SUM\u0026#34;) // 设置过期时间 cache.Expire(ctx, key, 2*time.Hour) 面试要点总结 # Q: 缓存和 DB 的一致性怎么保证？ # A: 先更新 DB，再删缓存。删缓存失败靠 TTL 兜底，最终一致性。\nQ: 缓存穿透/击穿/雪崩的区别和解法？ # A: 穿透是查不存在的数据（缓存空值/布隆过滤器），击穿是热点 key 过期（分布式锁 + 等待重试），雪崩是大量 key 同时过期（随机 TTL）。\nQ: 限流怎么实现的？ # A: Redis 计数器，Lua 脚本原子执行 INCR + PEXPIRE，超限返回 429。\nQ: 为什么用 Lua 脚本？ # A: 保证 INCR 和 EXPIRE 原子执行，不会被其他命令插队，避免\u0026quot;只执行了一半\u0026quot;的问题。\nQ: 热榜怎么实现的？ # A: ZSET 按分钟分窗口，点赞时 ZincrBy 加分数，查热榜时合并最近几个窗口算总分。分窗口实现热度自动衰减。\n","date":"2026年7月28日","externalUrl":null,"permalink":"/MyBlog/p/go-feed-redis-cache-design/","section":"Posts","summary":"","title":"Go 项目反推：Feed 流系统实战——Redis 缓存设计","type":"posts"},{"content":"","date":"2026年7月28日","externalUrl":null,"permalink":"/MyBlog/tags/lua/","section":"Tags","summary":"","title":"Lua","type":"tags"},{"content":"","date":"2026年7月28日","externalUrl":null,"permalink":"/MyBlog/tags/zset/","section":"Tags","summary":"","title":"ZSET","type":"tags"},{"content":"","date":"2026年7月28日","externalUrl":null,"permalink":"/MyBlog/tags/%E9%99%90%E6%B5%81/","section":"Tags","summary":"","title":"限流","type":"tags"},{"content":"","date":"2026年7月27日","externalUrl":null,"permalink":"/MyBlog/tags/coursera/","section":"Tags","summary":"","title":"Coursera","type":"tags"},{"content":"承接 Go 项目反推：Feed 流系统实战——认证体系，这一篇继续反推 feedsystem_video_go 的数据库代码，理解表结构、索引设计和 GORM 的常见用法。\n理解所有表结构、索引设计、GORM 用法。\n1. entity.go 的两种结构体 # entity.go 里有两种东西：\n1. 存数据库的（有 gorm 标签） → Social struct、Like struct... 2. 不存数据库的（没有 gorm 标签）→ FeedVideoItem、Request、Response... gorm:\u0026quot;xxx\u0026quot; 标签 = 给 GORM 看的指令（怎么存数据库） json:\u0026quot;xxx\u0026quot; 标签 = 给 JSON 看的指令（怎么显示给客户端） 两者互不影响，各管各的 2. gorm 标签汇总 # 标签 含义 示例 gorm:\u0026quot;primaryKey\u0026quot; 主键（唯一标识，类似身份证号） ID uint gorm:\u0026quot;primaryKey\u0026quot; gorm:\u0026quot;unique\u0026quot; 唯一（不能重复） Username string gorm:\u0026quot;unique\u0026quot; gorm:\u0026quot;index\u0026quot; 普通索引（加快查询，可以重复） VideoID uint gorm:\u0026quot;index\u0026quot; gorm:\u0026quot;uniqueIndex:idx_name\u0026quot; 唯一索引（不能重复） VideoID uint gorm:\u0026quot;uniqueIndex:idx_like_video_account\u0026quot; gorm:\u0026quot;uniqueIndex:idx_name;not null\u0026quot; 联合唯一索引（多个字段组合唯一） 见 Like 表 gorm:\u0026quot;index:idx_name,priority:1,sort:desc\u0026quot; 索引排序优先级 见 Video 表 gorm:\u0026quot;not null\u0026quot; 不能为空 Title string gorm:\u0026quot;not null\u0026quot; gorm:\u0026quot;default:false\u0026quot; 默认值 IsRead bool gorm:\u0026quot;default:false\u0026quot; gorm:\u0026quot;autoCreateTime\u0026quot; 自动填创建时间 CreateTime time.Time gorm:\u0026quot;autoCreateTime\u0026quot; gorm:\u0026quot;type:varchar(255)\u0026quot; 字符串长度限制 Title string gorm:\u0026quot;type:varchar(255)\u0026quot; gorm:\u0026quot;type:text\u0026quot; 长文本（无固定限制） Content string gorm:\u0026quot;type:text\u0026quot; gorm:\u0026quot;column:likes_count\u0026quot; 指定数据库列名 Go 驼峰 → DB 下划线 gorm:\u0026quot;-\u0026quot; 不存数据库 临时字段 3. json 标签 # 标签 含义 json:\u0026quot;id\u0026quot; JSON 里显示为 \u0026ldquo;id\u0026rdquo; json:\u0026quot;-\u0026quot; JSON 里不显示（密码、Token 等敏感信息） json:\u0026quot;description,omitempty\u0026quot; 空值时不显示这个字段 4. 表结构一览 # Account 表（用户表） # ID → 主键 Username → 唯一（不能重复注册） Password → 不显示在 JSON 里 Token → 不显示在 JSON 里 RefreshToken → 不显示在 JSON 里 AvatarURL → 头像地址 Bio → 个人简介 Video 表（视频表） # ID → 主键 AuthorID → 外键（关联 Account 表，存的是用户ID） Username → 作者名（冗余存储，方便查询） Title → 标题 Description → 描述 PlayURL → 播放地址 CoverURL → 封面地址 CreateTime → 自动填创建时间 LikesCount → 点赞数（统计字段） Popularity → 热度值（统计字段） Video 表的三个索引：\n索引名 排序规则 用途 idx_videos_create_time CreateTime DESC 最新视频 idx_videos_likes_count_id LikesCount DESC 最热视频（按点赞） idx_videos_popularity_time_id Popularity DESC, CreateTime DESC 综合热度 priority = 优先级，数字越小越优先。\nLike 表（点赞表） # ID → 主键 VideoID → 外键 + 联合唯一索引 AccountID → 外键 + 联合唯一索引 联合唯一索引：VideoID + AccountID 的组合不能重复\n张三给视频 A 点赞 ✓ 张三再给视频 A 点赞 ✗（违反唯一索引） 李四给视频 A 点赞 ✓（不同人） 张三给视频 B 点赞 ✓（不同视频） 为什么需要 Like 表 + Video 表的 LikesCount？\nLike 表 → 记录\u0026#34;关系\u0026#34;（谁点了谁） LikesCount → 记录\u0026#34;总数\u0026#34;（点赞数） 两者配合：Like 管关系，LikesCount 管统计 Comment 表（评论表） # ID → 主键 Username → 普通索引 VideoID → 外键 + 普通索引 AuthorID → 外键 + 普通索引 Content → 长文本（type:text） CreatedAt → 自动填创建时间 index vs uniqueIndex：\nindex = 普通索引（可以重复，一个视频可以有很多评论） uniqueIndex = 唯一索引（不能重复） Social 表（关注表） # ID → 主键 FollowerID → 外键 + 联合唯一索引（谁在关注） VloggerID → 外键 + 联合唯一索引（关注谁） 联合唯一索引：一个人不能关注同一个人两次。\nTag 表 + VideoTag 表（标签表） # Tag 表： ID → 主键 Name → 唯一索引（标签名不能重复） VideoTag 表（中间表）： ID → 主键 VideoID → 外键 + 普通索引 TagID → 外键 + 普通索引 多对多关系：一个视频可以有多个标签，一个标签可以属于多个视频，需要中间表 VideoTag 来记录关系。\nMessage 表（私信表） # ID → 主键 FromID → 外键 + 普通索引（谁发的） ToID → 外键 + 普通索引（发给谁） Content → 长文本 IsRead → 是否已读（默认 false） CreatedAt → 自动填创建时间 ChunkUploadSession（分片上传） # 不是 MySQL 表，存 Redis 的。\nUploadID → 上传会话ID AccountID → 谁在上传 UploadedBits → []bool 位图，记录每片上传状态 FeedVideoItem（Feed 流响应格式） # 不是数据库表，是 API 响应格式。\nAuthor → FeedAuthor（包含 ID + Username，不是外键） IsLiked → 动态计算（查 Like 表），不存在数据库里 5. db.go（数据库初始化） # 三个函数：\nNewDB() → 连接数据库（DSN 格式：用户名:密码@tcp(地址:端口)/数据库名） AutoMigrate() → 根据 struct 自动建表/更新表 CloseDB() → 关闭数据库连接 AutoMigrate 的好处：\n手动建表：写 SQL，容易忘、容易错 AutoMigrate：改 struct，重启程序，自动更新表 6. repo.go（数据库操作） # 每个模块都有自己的 repo.go，包含增删改查操作。\n基础套路 # // 创建 ar.db.WithContext(ctx).Create(\u0026amp;data) // 查询（根据ID） ar.db.WithContext(ctx).First(\u0026amp;result, id) // 查询（根据条件） ar.db.WithContext(ctx).Where(\u0026#34;username = ?\u0026#34;, name).First(\u0026amp;result) // 更新（单字段） ar.db.WithContext(ctx).Model(\u0026amp;Account{}).Where(\u0026#34;id = ?\u0026#34;, id).Update(\u0026#34;username\u0026#34;, newName) // 更新（多字段） ar.db.WithContext(ctx).Model(\u0026amp;Account{}).Where(\u0026#34;id = ?\u0026#34;, id).Updates(map[string]interface{}{\u0026#34;token\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;refresh_token\u0026#34;: \u0026#34;\u0026#34;}) // 删除 ar.db.WithContext(ctx).Delete(\u0026amp;Like{}).Where(\u0026#34;video_id = ? AND account_id = ?\u0026#34;, vID, aID) db.WithContext(ctx) 的作用 # 把 context 传进去，支持超时取消。\n事务（db.Transaction） # ar.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 操作1 tx.Model(\u0026amp;Account{}).Where(\u0026#34;id = ?\u0026#34;, id).Update(\u0026#34;username\u0026#34;, newName) // 操作2 tx.Model(\u0026amp;Account{}).Where(\u0026#34;id = ?\u0026#34;, id).Update(\u0026#34;token\u0026#34;, token) return nil }) 事务 = 要么全做，要么全不做。\n类比：银行转账，A 扣钱与 B 加钱必须同时成功或同时失败。\n原子操作（gorm.Expr） # // 错误做法：先读再写，并发会出错 likes_count = 100 用户A读到100，用户B读到100 用户A写入101，用户B写入101 结果：只加了1 ❌ // 正确做法：让数据库直接加 gorm.Expr(\u0026#34;likes_count + ?\u0026#34;, 1) 数据库排队执行：100+1=101, 101+1=102 结果：正确加了2 ✓ GREATEST(likes_count + ?, 0)：防止点赞数变成负数。\nLikeIgnoreDuplicate（忽略重复点赞） # err = r.db.WithContext(ctx).Create(like).Error if err == nil { return true, nil // 创建成功 } // 如果是\u0026#34;重复主键\u0026#34;错误（1062），忽略 var mysqlErr *mysql.MySQLError if errors.As(err, \u0026amp;mysqlErr) \u0026amp;\u0026amp; mysqlErr.Number == 1062 { return false, nil // 已经点过赞了，不报错 } 为什么需要这个？\n联合唯一索引：保证数据正确（一人只能点一次） LikeIgnoreDuplicate：保证用户体验（重复点赞不报错） 两个配合：数据对 + 体验好 gorm.ErrRecordNotFound（判断记录不存在） # if err == gorm.ErrRecordNotFound { return false, nil // 记录不存在 } 关联查询（Joins） # // 查\u0026#34;张三点赞过的所有视频\u0026#34; r.db.WithContext(ctx). Model(\u0026amp;Video{}). Joins(\u0026#34;JOIN likes ON likes.video_id = videos.id\u0026#34;). Where(\u0026#34;likes.account_id = ?\u0026#34;, accountID). Find(\u0026amp;videos) 分步查询（GetAllFollowers） # -- 第1步：从 Social 表查出 follower_id 列表 SELECT follower_id FROM socials WHERE vlogger_id = 1; -- 得到 [2, 5, 8] -- 第2步：用 ID 列表去 Account 表查用户信息 SELECT * FROM accounts WHERE id IN (2, 5, 8); 为什么分两步？\nSocial 表只存 ID（避免数据冗余），Account 表存用户信息。用户改名只改 Account 表，Social 表不用动。\n7. 数据库规范化 # 同一份数据只存一个地方，其他地方只存 ID（外键）。\n反范例：Social 表存用户名 用户改名 → 要更新 Account 表 + Social 表 + 其他表... 牵一发动全身 正范例：Social 表只存 ID 用户改名 → 只改 Account 表 需要名字时，拿着 ID 去 Account 表查 8. 面试要点 # 1. Video 表为什么有三个索引？ # 因为有三种查询需求：\nidx_videos_create_time → 按时间排序（查最新视频） idx_videos_likes_count_id → 按点赞数排序（查最热视频） idx_videos_popularity_time_id → 按热度 + 时间排序（综合排名） 每种查询方式都需要自己的“目录”（索引），没有索引就要全表扫描，很慢。\n2. 点赞表为什么使用联合唯一索引？ # 点赞表用 uniqueIndex:idx_like_video_account，保证 VideoID + AccountID 的组合唯一。一个人只能给一个视频点一次赞，如果重复点赞会违反唯一索引报错。配合 LikeIgnoreDuplicate 函数，重复点赞时静默忽略，不向用户报错。\n3. gorm.Expr 是怎么解决并发问题的？ # 错误做法：先读 likes_count=100，再写 100+1=101。两个用户同时读到 100，都写入 101，结果只加了 1。\n正确做法：用 gorm.Expr(\u0026quot;likes_count + ?\u0026quot;, 1)，让数据库直接执行 likes_count = likes_count + 1，数据库保证原子更新，结果正确。GREATEST(likes_count + ?, 0) 可以防止点赞数变成负数。\n4. 改名 + 更新 Token 为什么要用事务？ # 因为改名后 Token 里的 Username 就过期了，必须同时更新。事务保证“要么全做，要么全不做”：改名成功但更新 Token 失败时回滚，改名也不生效。\n5. 为什么 Social 表只存 ID，不存用户名？ # 如果存用户名，用户改名时要更新 Account 表、Social 表和其他表，牵一发动全身。只存 ID 时，用户改名只改 Account 表，Social 表不用动；需要名字时拿着 ID 去 Account 表查询。\n6. Tag 和 Video 为什么需要中间表？ # 一个视频可以有多个标签，一个标签也可以属于多个视频，这是多对多关系。一个外键字段只能存一个值，因此需要中间表 VideoTag 存储 VideoID + TagID，记录“哪个视频有哪些标签”。\n","date":"2026年7月27日","externalUrl":null,"permalink":"/MyBlog/p/go-feed-database-gorm/","section":"Posts","summary":"","title":"Go 项目反推：Feed 流系统实战——数据库与 GORM","type":"posts"},{"content":"","date":"2026年7月27日","externalUrl":null,"permalink":"/MyBlog/tags/gorm/","section":"Tags","summary":"","title":"GORM","type":"tags"},{"content":"","date":"2026年7月27日","externalUrl":null,"permalink":"/MyBlog/tags/mysql/","section":"Tags","summary":"","title":"MySQL","type":"tags"},{"content":"","date":"2026年7月27日","externalUrl":null,"permalink":"/MyBlog/tags/python/","section":"Tags","summary":"","title":"Python","type":"tags"},{"content":"","date":"2026年7月27日","externalUrl":null,"permalink":"/MyBlog/tags/scikit-learn/","section":"Tags","summary":"","title":"Scikit-Learn","type":"tags"},{"content":"","date":"2026年7月27日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0/","section":"Tags","summary":"","title":"机器学习","type":"tags"},{"content":"","date":"2026年7月27日","externalUrl":null,"permalink":"/MyBlog/tags/%E4%BA%8B%E5%8A%A1/","section":"Tags","summary":"","title":"事务","type":"tags"},{"content":"","date":"2026年7月27日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%95%B0%E6%8D%AE%E5%BA%93/","section":"Tags","summary":"","title":"数据库","type":"tags"},{"content":"","date":"2026年7月27日","externalUrl":null,"permalink":"/MyBlog/tags/%E7%B4%A2%E5%BC%95/","section":"Tags","summary":"","title":"索引","type":"tags"},{"content":"","date":"2026年7月27日","externalUrl":null,"permalink":"/MyBlog/tags/%E7%89%B9%E5%BE%81%E7%BC%A9%E6%94%BE/","section":"Tags","summary":"","title":"特征缩放","type":"tags"},{"content":"","date":"2026年7月27日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%A2%AF%E5%BA%A6%E4%B8%8B%E9%99%8D/","section":"Tags","summary":"","title":"梯度下降","type":"tags"},{"content":" 1. 学习目标 # 这组实验主要解决以下问题：\n从零实现线性回归的代价函数和梯度； 使用批量梯度下降训练模型； 判断梯度下降是否正在收敛； 选择合适的学习率； 使用 Z-score 标准化加快收敛； 通过特征工程实现多项式回归； 使用 Scikit-Learn 完成标准化、训练与预测。 2. 梯度下降的核心流程 # 线性回归模型：\n$$f_{\\mathbf{w},b}(\\mathbf{x}) = \\mathbf{w}\\cdot\\mathbf{x}+b$$整个训练过程可以概括为：\n输入训练数据 X、y ↓ 使用当前 w、b 计算预测 ↓ 预测值减去真实值得到误差 ↓ 根据全部误差计算梯度 ↓ 沿梯度反方向更新 w、b ↓ 重复，直到代价不再明显下降 参数更新规则：\n$$w_j \\leftarrow w_j-\\alpha\\frac{\\partial J}{\\partial w_j}$$$$b \\leftarrow b-\\alpha\\frac{\\partial J}{\\partial b}$$其中：\n$\\alpha$：学习率，决定每一步走多远； 梯度：代价函数上升最快的方向； 减去梯度：向代价下降的方向移动。 3. 实践一：从零实现一元线性回归 # 实验使用城市人口预测餐厅利润：\nx_train：城市人口，单位为 10,000 人； y_train：餐厅月利润，单位为 10,000 美元； 数据集共有 97 个样本。 一元线性回归模型：\n$$f_{w,b}(x^{(i)})=wx^{(i)}+b$$ 3.1 实现代价函数 # 平方误差代价函数：\n$$J(w,b) = \\frac{1}{2m} \\sum_{i=0}^{m-1} \\left( f_{w,b}(x^{(i)})-y^{(i)} \\right)^2$$代码：\ndef compute_cost(x, y, w, b): m = x.shape[0] total_cost = 0.0 for i in range(m): prediction = w * x[i] + b error = prediction - y[i] total_cost += error ** 2 return total_cost / (2 * m) 这里除以 $2m$：\n除以 $m$：计算所有样本的平均损失； 除以 2：求导后平方项产生的 2 可以抵消，使梯度公式更简洁。 3.2 实现梯度 # 梯度公式：\n$$\\frac{\\partial J}{\\partial w} = \\frac{1}{m} \\sum_{i=0}^{m-1} \\left( f_{w,b}(x^{(i)})-y^{(i)} \\right)x^{(i)}$$$$\\frac{\\partial J}{\\partial b} = \\frac{1}{m} \\sum_{i=0}^{m-1} \\left( f_{w,b}(x^{(i)})-y^{(i)} \\right)$$代码：\ndef compute_gradient(x, y, w, b): m = x.shape[0] dj_dw = 0.0 dj_db = 0.0 for i in range(m): prediction = w * x[i] + b error = prediction - y[i] dj_dw += error * x[i] dj_db += error dj_dw /= m dj_db /= m return dj_dw, dj_db dj_dw 多乘了一个 x[i]，因为 $w$ 是和输入 $x$ 相乘的参数；而 $b$ 只是直接相加，因此 dj_db 不需要乘 x[i]。\n3.3 批量梯度下降 # import copy import math def gradient_descent( x, y, w_in, b_in, cost_function, gradient_function, alpha, num_iters ): w = copy.deepcopy(w_in) b = b_in J_history = [] for i in range(num_iters): dj_dw, dj_db = gradient_function(x, y, w, b) # 必须使用同一次迭代计算出的梯度同步更新 w = w - alpha * dj_dw b = b - alpha * dj_db if i \u0026lt; 100_000: J_history.append(cost_function(x, y, w, b)) if i % math.ceil(num_iters / 10) == 0: print( f\u0026#34;Iteration {i:4d}: \u0026#34; f\u0026#34;Cost {J_history[-1]:8.2f}\u0026#34; ) return w, b, J_history 实验参数：\ninitial_w = 0.0 initial_b = 0.0 iterations = 1500 alpha = 0.01 w, b, J_history = gradient_descent( x_train, y_train, initial_w, initial_b, compute_cost, compute_gradient, alpha, iterations ) 实验结果大约为：\nw = 1.1664 b = -3.6303 预测利润：\nprofit_35k = (3.5 * w + b) * 10_000 profit_70k = (7.0 * w + b) * 10_000 print(profit_35k) # 约 4519.77 美元 print(profit_70k) # 约 45342.45 美元 这里的\u0026quot;批量\u0026quot;表示：\n每轮使用全部 $m$ 个训练样本计算平均梯度，然后更新一次参数。\n4. 多变量梯度下降 # 假设训练数据形状为：\nX：(m, n) y：(m,) w：(n,) b：标量 其中：\n$m$：训练样本数量； $n$：每个样本的特征数量。 预测：\ny_hat = X @ w + b 误差：\nerrors = y_hat - y 向量化代价：\ndef compute_cost(X, y, w, b): errors = X @ w + b - y return np.sum(errors ** 2) / (2 * X.shape[0]) 向量化梯度：\ndef compute_gradient(X, y, w, b): m = X.shape[0] errors = X @ w + b - y dj_dw = X.T @ errors / m dj_db = np.sum(errors) / m return dj_dw, dj_db 形状变化：\nX.T ：(n, m) errors ：(m,) X.T @ errors ：(n,) dj_dw ：(n,) dj_db ：标量 最终得到的 dj_dw 恰好包含 $n$ 个梯度，对应 $w$ 中的 $n$ 个参数。\n5. 学习率实验 # 在未缩放的房价数据中，不同学习率会产生不同表现。\n$\\alpha=9.9\\times10^{-7}$ # 学习率过大：\n参数更新越过最低点； 代价不断增加； 模型发散。 $\\alpha=9\\times10^{-7}$ # 学习率稍小：\n代价持续下降； 参数仍会在最低点两侧震荡； 最终能够收敛。 $\\alpha=1\\times10^{-7}$ # 学习率更小：\n参数较平稳地靠近最低点； 基本没有明显震荡； 但收敛速度更慢。 因此：\n学习率太大 → 震荡或发散 学习率太小 → 稳定但很慢 学习率合适 → 快速且稳定下降 6. 为什么需要特征缩放？ # 房价数据包含以下特征：\n面积：几百到几千 卧室：0 到 5 楼层：1 到 3 房龄：数十年 假设：\n$$f(\\mathbf{x})=w_1x_1+w_2x_2+b$$如果 $x_1$ 是房屋面积，最大接近 2000；$x_2$ 是卧室数量，最大只有 5。\n对于面积 2000、5 间卧室的房屋，一组合适的参数可能是：\n$$w_1=0.1,\\quad w_2=50,\\quad b=50$$预测为：\n$$0.1\\times2000+50\\times5+50=500$$单位是千美元，即 50 万美元。\n可以看到：\n数值范围大的特征，参数通常较小； 数值范围小的特征，参数通常较大。 这会让代价函数的等高线变得非常狭长：\n未缩放： 梯度下降在狭长峡谷两侧反复震荡 缩放后： 等高线更接近圆形 梯度下降更直接地靠近最低点 特征缩放通常不会改变模型能够达到的最优结果，主要作用是让训练更快、更稳定。\n7. 三种特征缩放方法 # 7.1 除以最大值 # $$x_j' = \\frac{x_j}{\\max(x_j)}$$例如：\n面积范围：300～2000 缩放后：0.15～1 卧室范围：0～5 缩放后：0～1 7.2 均值归一化 # $$x_j' = \\frac{x_j-\\mu_j} {\\max(x_j)-\\min(x_j)}$$减去均值后，数据会以 0 为中心。\n7.3 Z-score 标准化 # $$x_j' = \\frac{x_j-\\mu_j}{\\sigma_j}$$其中：\n$$\\mu_j = \\frac{1}{m} \\sum_{i=0}^{m-1}x_j^{(i)}$$$$\\sigma_j = \\sqrt{ \\frac{1}{m} \\sum_{i=0}^{m-1} \\left(x_j^{(i)}-\\mu_j\\right)^2 }$$处理后，每个特征通常具有：\n均值接近 0； 标准差接近 1； 各特征具有相似的数值范围。 NumPy 实现：\ndef zscore_normalize_features(X): mu = np.mean(X, axis=0) sigma = np.std(X, axis=0) X_norm = (X - mu) / sigma return X_norm, mu, sigma 使用：\nX_norm, X_mu, X_sigma = zscore_normalize_features(X_train) 实验中，未缩放数据只能使用约 1e-7 的学习率；经过 Z-score 标准化后，可以从 0.1 左右开始尝试，收敛速度明显加快。\n8. 预测新数据时的关键规则 # 标准化训练数据后，必须保存训练集的均值和标准差。\n预测面积 1200 平方英尺、3 间卧室、1 层、房龄 40 年的房屋：\nx_house = np.array([1200, 3, 1, 40]) x_house_norm = ( x_house - X_mu ) / X_sigma price = x_house_norm @ w_norm + b_norm 不能对新样本重新计算均值和标准差。\n错误：\n# 不要这样做 new_mu = np.mean(x_house) new_sigma = np.std(x_house) 正确原则：\n训练集：计算并保存 mu、sigma 验证集：使用训练集的 mu、sigma 测试集：使用训练集的 mu、sigma 新数据：使用训练集的 mu、sigma 否则训练和预测使用的不是同一个坐标系。\n9. 如何判断梯度下降是否收敛？ # 记录每次更新后的代价：\nJ_history.append( compute_cost(X, y, w, b) ) 绘制学习曲线：\nplt.plot(J_history) plt.xlabel(\u0026#34;Iteration\u0026#34;) plt.ylabel(\u0026#34;Cost J\u0026#34;) plt.show() 正常曲线：\nJ │\\ │ \\ │ \\____ │ ─── └──────────── 迭代次数 如果代价持续下降并逐渐变平，说明梯度下降正在收敛。\n也可以使用自动收敛条件：\n$$\\left| J^{(t)}-J^{(t-1)} \\right|\u003c\\epsilon$$例如：\nif abs(J_history[-1] - J_history[-2]) \u0026lt; 1e-3: break 但实际学习中，直接观察学习曲线通常更直观。\n10. 梯度下降调试方法 # 10.1 代价不断增加 # 可能原因：\n学习率太大； 参数更新误写成了加号； 梯度公式实现错误。 错误：\nw = w + alpha * dj_dw 正确：\nw = w - alpha * dj_dw b = b - alpha * dj_db 10.2 使用很小的学习率进行测试 # 把 alpha 设置得非常小。\n如果代码正确，代价函数理论上应该缓慢下降。如果很小的学习率下代价仍然增加，通常说明代码存在错误。\n需要注意：\n很小的学习率适合调试，但不一定适合正式训练，因为它可能导致收敛非常慢。\n10.3 尝试一系列学习率 # 可以按大约 3 倍的间隔尝试：\n0.001 0.003 0.01 0.03 0.1 0.3 1 选择能够让代价快速、持续下降的较大学习率。\n11. 特征工程 # 特征工程是利用业务知识，从已有特征构造更有意义的新特征。\n例如：\n$x_1$：土地宽度； $x_2$：土地深度。 可以构造土地面积：\n$$x_3=x_1x_2$$代码：\narea = width * depth 模型变为：\n$$f(\\mathbf{x}) = w_1x_1+w_2x_2+w_3x_3+b$$相比只使用宽度和深度，面积可能与房价具有更直接的关系。\n12. 多项式回归 # 假设真实数据为：\n$$y=1+x^2$$如果只使用原始特征 $x$，线性回归只能拟合直线：\n$$f(x)=wx+b$$可以构造平方特征：\nx = np.arange(0, 20, 1) y = 1 + x ** 2 X = (x ** 2).reshape(-1, 1) 模型变为：\n$$f(x)=w_1x^2+b$$虽然它对原始变量 $x$ 是非线性的，但对参数 $w_1,b$ 仍然是线性的，所以仍可以使用线性回归的训练方法。\n不知道应该使用几次项时，可以构造多个候选特征：\nX = np.c_[ x, x ** 2, x ** 3 ] 对应模型：\n$$f(x) = w_1x+w_2x^2+w_3x^3+b$$ 多项式特征必须注意尺度 # 如果：\nx：1～1000 那么：\nx²：1～1,000,000 x³：1～1,000,000,000 尺度差异会非常大，因此应进行标准化：\nX = np.c_[x, x ** 2, x ** 3] X_norm, mu, sigma = zscore_normalize_features(X) 特征缩放后，可以使用更大的学习率，训练速度会明显提高。\n13. 使用 Scikit-Learn # Scikit-Learn 可以直接完成标准化和梯度下降回归。\nimport numpy as np from sklearn.linear_model import SGDRegressor from sklearn.preprocessing import StandardScaler 13.1 标准化训练数据 # scaler = StandardScaler() X_norm = scaler.fit_transform(X_train) fit_transform() 包含两步：\nfit → 从训练集计算均值和标准差 transform → 使用这些统计量转换数据 13.2 创建并训练模型 # sgdr = SGDRegressor( max_iter=1000, random_state=0 ) sgdr.fit(X_norm, y_train) 查看训练信息：\nprint(sgdr.n_iter_) # 实际迭代轮数 print(sgdr.t_) # 权重更新次数 查看参数：\nw_norm = sgdr.coef_ b_norm = sgdr.intercept_ print(w_norm) print(b_norm) 这些参数对应的是标准化后的输入数据，不能直接按原始面积、卧室数量的单位解释。\n13.3 进行预测 # y_pred_sgd = sgdr.predict(X_norm) 也可以手动计算：\ny_pred_manual = X_norm @ w_norm + b_norm 检查两种结果：\nnp.allclose( y_pred_sgd, y_pred_manual ) 不要使用：\n(y_pred_sgd == y_pred_manual).all() 浮点数运算可能存在很小的舍入误差，应使用 np.allclose()。\n13.4 预测新房屋 # x_house = np.array([ [1200, 3, 1, 40] ]) x_house_norm = scaler.transform(x_house) price = sgdr.predict(x_house_norm) print(price) 对新数据只能调用：\nscaler.transform() 不能再次调用：\nscaler.fit_transform() 因为重新 fit 会改变标准化使用的均值和标准差。\n13.5 SGD 与批量梯度下降的区别 # 手写实验使用的是批量梯度下降：\n使用全部训练样本 → 计算一次平均梯度 → 更新一次参数 SGDRegressor 使用随机梯度下降：\n依次使用单个样本或小批次 → 更频繁地更新参数 两者都在尝试降低代价函数，但参数更新方式不同。\n14. Notebook 中的 NameError # 实验中出现：\nNameError: name \u0026#39;plt\u0026#39; is not defined NameError: name \u0026#39;load_house_data\u0026#39; is not defined NameError: name \u0026#39;run_gradient_descent\u0026#39; is not defined 这通常不是模型或公式的问题，而是依赖单元格没有运行。\nNotebook 中的变量只存在于当前内核的内存里。重启内核、断线或者跳过前面的单元格，都会导致变量不存在。\n正确运行顺序：\n1. 运行 import 单元格 2. 运行辅助函数单元格 3. 加载训练数据 4. 标准化数据 5. 训练模型 6. 计算预测 7. 绘制图像 如果 Notebook 状态混乱，可以使用：\nRestart Kernel and Run All 15. 高频易错点 # w 和 b 必须同步更新 同一轮参数更新必须使用更新前计算出的同一组梯度。\n更新公式使用减号 梯度指向代价上升方向，因此需要减去梯度。\n特征尺度差异会拖慢收敛 面积、卧室数量、楼层、房龄应进行缩放。\n预测新数据必须复用训练集统计量 不能为新样本重新计算均值和标准差。\n多项式特征更需要缩放 $x,x^2,x^3$ 的数值范围可能相差多个数量级。\n浮点数不要直接使用 == 比较 使用 np.isclose() 或 np.allclose()。\n权重较大不一定代表特征更重要 权重大小受到特征尺度影响，也不代表因果关系。\nSGDRegressor 不是批量梯度下降 它使用随机梯度下降，只是优化目标与线性回归相似。\n16. 一页实践模板 # import numpy as np from sklearn.preprocessing import StandardScaler from sklearn.linear_model import SGDRegressor # 1. 准备数据 X_train, y_train = load_house_data() # 2. 标准化 scaler = StandardScaler() X_norm = scaler.fit_transform(X_train) # 3. 训练 model = SGDRegressor( max_iter=1000, random_state=0 ) model.fit(X_norm, y_train) # 4. 训练集预测 y_pred = model.predict(X_norm) # 5. 新数据预测 x_new = np.array([ [1200, 3, 1, 40] ]) x_new_norm = scaler.transform(x_new) prediction = model.predict(x_new_norm) # 6. 检查手动计算 manual_prediction = ( X_norm @ model.coef_ + model.intercept_ ) print(np.allclose( y_pred, manual_prediction )) 整组实验最重要的主线是：\n准备训练数据 → 检查特征尺度 → 使用训练集统计量标准化 → 选择学习率 → 运行梯度下降 → 观察代价是否持续下降 → 保存参数和标准化器 → 用相同的转换处理新数据 → 进行预测 一句话总结：\n梯度下降负责寻找参数，特征缩放负责让寻找过程更快、更稳定，学习曲线负责判断训练是否正常，特征工程负责让线性模型表达更复杂的关系。\n","date":"2026年7月27日","externalUrl":null,"permalink":"/MyBlog/p/coursera-ml-gradient-descent-practice/","section":"Posts","summary":"","title":"梯度下降实践：从手写实现到 Scikit-Learn","type":"posts"},{"content":"","date":"2026年7月27日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/","section":"Tags","summary":"","title":"学习笔记","type":"tags"},{"content":"","date":"2026年7月26日","externalUrl":null,"permalink":"/MyBlog/tags/numpy/","section":"Tags","summary":"","title":"NumPy","type":"tags"},{"content":"","date":"2026年7月26日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%88%90%E9%95%BF/","section":"Tags","summary":"","title":"成长","type":"tags"},{"content":"","date":"2026年7月26日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%A4%A7%E5%AD%A6%E7%94%9F%E6%B4%BB/","section":"Tags","summary":"","title":"大学生活","type":"tags"},{"content":"平庸的生活方式是麻醉药。他只会束缚你，让你没有作为，甚至没有出息的度过大学的四年。 ——《交大生存手册》\n进入山威，是运气作祟。或者老天爷看我高中经历实在不忍心，给这孩子悲惨的高中生活以问号结尾，勉强给他画个句号吧。也许存着点玩笑意味，让我经历三四次大起大落，在这简略的提起吧。\n我始终不觉得我比别人付出的努力少。高中三年，无论是笔记、网课、作业、学校的高压练习、人际关系的拷打、身体的不适，一切的一切，都是我确切比周遭同学多经历的。可能是因为迟迟没有成长吧，所以相对应还是很幼稚的，可以说我没有开智哈哈哈。到最后三次模考，约莫也是 800～900 名的水平，可以说是波动很小的。到了高考，却很不一样。对卷子的影响出了考场就已模糊，但是成绩倒是惨惨淡淡的——559 分，3435 名。我不会忘记这哑然失笑的成绩与名次。接受吧，又能如何呢。\n着手报志愿，四个冲的，几个稳定。原本打算去央民 CS 保底来着，或许很大的概率能去 UIBE 学什么精算，或者海关。直到投档线出来的夜晚，发现自己落榜外经贸，便以为去央民吧。谁料细细对照，山威爆冷了。这无疑是令我开心的，被好一点的 985 捞了。但是，好似我从来没有了解过山威的专业。没事没事，志愿填报老师和我说过，电子信息不用说，海洋科学是做研究的，经济学嘛大差不差吧。当时填报志愿三者皆可，于是电子信息—海洋科学—经济学，如此排位。\n时间回到那个夜晚。和父母分享喜悦之后，去看看专业吧。这一看便让我的心跌入谷底——本来算是意外之喜，电子信息肯定够不上了，所以大概率 99% 是海洋科学。看我搜索一波吧：映入眼帘的是天坑专业、生化环材的集大成者、毕业底薪、累……一系列词条。至此一夜未眠，遂开始搜罗转专业的事项，这一来就是三四天。\n待到可以查询录取结果的那天，进入官网，点击查询，映入眼帘的竟然是——电子信息。是的，垂怜之下，我大概是以前五狗运的捡漏进入电子信息大类。人生大起大落，在短短三四天水落石出。\n进入山大之后，开启迷茫的大一生活。大一上是充满新奇的，从一个人跨越全中国来到威海，置办生活必需品，与来自其他地方的舍友相处，陌生的城市，陌生却莫名向往的海。\n军训可以暂且略过了，强度不大，也没有起到任何出乎意料的作用。\n真正的大一生活从军训结束开始。面对五花八门的基础课程、独特的课程时间和空闲时间的绝对自主化，我感觉到还不错。除了这些课程有点小难，好像我并不是能完全 handle，即使坐在第一排也如此。\n可能是认真听着听着，耍会手机，到课程过半甚至学不进去直接玩手机了。这也太惨了，课后还需要听网课来补知识，这一点也不好。课余时间分给了手机、一点点游戏，还有图书馆。谁还没有怀揣个保研梦呢？老实说，也是为了保研努力过一次吧，认真做题、复习、鏖战图书馆。但是最后大一上的收效却是很差的。\n何尝不是好事呢？放弃了这条大多数人想争一争但是其实很坑的路。不过绩点倒是足够分流到计算机了，这是好事啊。现在看来计算机是我最适合的专业了，按照兴趣的话确实是这样。让我去学机器人、研究高深的自控？天呐，没那么有毅力。或者选择电子科学，对着最厌恶的电路？\n作为一个对大学好奇的人，我也在充分地尝试各种活动——演讲比赛、短剧大赛、配音大赛。吗的，尝试了一圈发现全他妈都是野鸡比赛。这里不是内陆的顶尖高校，有着充分的人文气息，这里的比赛没有太多意义，负责这些的领导也是野鸡，纯粹是浪费时间之作。\n大一上，跟着毛夏叶学姐打打相对应的比赛吧。依托一个老师、传承的项目比比皆是，但是毛夏叶学姐的好心也是能感受到的。其实没有什么成长，我干的活无非是修改一些文件、修改一些 PPT，但是这和我的专业有毛线关系？如果是为了综测，好的，这坨屎我吃。但是当我大一上考完期末之后便完全摒弃了这个想法，选择退出。\n大一上还有什么重要节点呢？FPG 的考核吧。肖家骏学长负责我的考核，下面 po 出来当时的考核内容，仅供回忆：\nLinux 和 Shell 入门\n难度： ★★★☆☆☆☆\n受试人： 石静远 负责人： 肖家骏\n最好的计算机路线图，CSDIY\n上海交通大学生存手册\nLinux 开发环境的搭建\n了解命令行（CLI）的含义，以及其和图形化界面（GUI）有什么区别 了解 Shell 基本指令和 Shell 的历史 了解 Linux 发展的历史，以及各个发行版之间的区别 为自己的电脑安装任意一个 Linux 发行版（零基础建议 Ubuntu 24.04 LTS），使用 WSL2/双系统而非虚拟机 自行选择使用 WSL2 还是双系统方式安装 学习 Git 的基本操作和 GitHub 的基本使用：用于学习 Git 的趣味交互网站 配置基本的开发工具链（编辑器/IDE） 了解如何在命令行环境编译并运行 C 语言代码 在完成任务时\n确保在纯英文环境中进行操作，增进对英文文档的理解 每次操作前思考其目的，尝试理解每个步骤背后的原理 熟悉你接触的每一个工具或项目的官方文档，遇到问题请尝试查阅官方文档 成功在命令行编译并运行 C 语言后，前往实验室进行最终考核 前置知识\n知识技能 描述 Linux 免费开源的操作系统，就像 Windows 和 macOS，但你可以完全控制并自由定制它 Shell 命令行界面，让用户以文本指令与操作系统交互 C/C++ 编译器 将高级语言代码转换为机器码或字节码的程序 Vim 强大的文本编辑器，通过键盘命令快速编辑文件，初学曲线陡峭，熟练后效率极高 检查形式： 现场检查，使用现场提供的电脑，操作终端完成要求的命令。\n截止日期： 请在 12 月 6 日前联系团队以完成检查。\n现在看来，大致的内容都很基本，但在当时看来却是全新的。这些新奇的家伙让我感受很好，有了慢慢进步的感觉。我每天去准备些相关的考核内容，其实主要还是问问豆包，继而看看上面推荐的网页。对于 CS DIY 我觉得里面的课程要求较高、过于生涩；对于交大笔记倒是令我大跌眼镜，当时看来大逆不道的话，现在看来却是如此。总而言之给我的震撼很大，也算是开启了新的大门。准备的大差不差，考核通过，之后便无消息，除了被拉进群以外。\n这大致是我大一上——探索居多，什么都参加了一点，广度优先。但说起深度，好像并不是很有成效。这也是最迷茫的一学期。\n待到大一下学期开始，相对明确的我开始逐渐探索新的知识内容，对于课内知识一笑了之，自然是能不去则不去。这也造就了虚浮的基础。我不是那种随便突击几天便可以有很大成效、能够拿下 4.0 绩点的人，相反，我的绩点来之不易，即使慢慢学，也未必有高分。\n在下学期，真正的转折节点是二月的 FPG 团建。那天的经历并不重要，但是自从那天，我成为了实验室的常客。人总在交汇中成长，和肖家骏学长、宋博文学长以及各个学长交流，总是拓宽了很多眼界。看似没有一个实际的成果，但是基础薄弱，无可奈何，总归是在见识和其他潜藏的方面慢慢进步了。\n也是在这一学期，我看了 Missing Semester、CS61A，倒腾了 Linux Ubuntu 的输入法，有了自己的博客，慢慢是 GitHub 的常客，开始有意识去看重实习，加入了一位老师的科研。我认为这对我的综合成长是巨大的——醒悟的有点晚，再加上学校从期中考试延伸到期末考试，时间跨度长，感觉没法专心学习自己的内容。对于其他旁枝的知识了解的更是越来越多，我可以大言不惭地称之为“进化前期”吧。所谓进化，指的是在一段时间中，一个人的见解或者工程能力得到了巨大的提升。\n只能寄托进化于暑假吧。这个暑假，我打算：\n认真刷 LeetCode 反复夯实一个 Go 写的 Feed 流系统 至于八股，开学再背也不迟 希望寒假能找到具体的实习，这是我短期的小目标。\n对了，下学期我要认真听课，争取高点的绩点。\n此外对于扩展，我还是希望能再做下 Missing Semester 2026 的作业内容，感觉对于一些知识的运用还是没有那么常规化，譬如 Git 之类。此外看看 Coursera 上的 ML 入门，认真参与一个未名超算队 × LCPU AI Infra Seminars 的暑期夏令营。\n再细读两本书：《亿级流量系统架构设计与实战》——这是本好书，深入浅出；《打开量化投资的黑箱》。越来越能感受到，当 AI 时代来临，我们本以为知识可以通过反复向 AI 求索得到而忽略书本的重要性，但实际上书本的体系化是无与伦比的高知识浓度体系压缩。\n再有就是希望认真锻炼、保持健康，这才是一切的本钱。\n在此感叹下，高考的分界重要，着实是无与伦比。北大相对应俱乐部成员才大一，但是这一年的知识增速与累积，已经大于了很多中 9 大三学生的知识量。他们的机会多，成长也快。人终将走向异化，每个人的路都由着自己决定。希望尽量向上，能够不负时间、不负自己。这也许是一个简短的期许吧。\n好似也没什么了。大一一年带给我的成长，远远比过去十八年任何一年带来的多。我是幸运的，歪打正着进入想学的专业，有着很多前辈毫无保留的教导，有着父母的不懈支持。我还有什么倦怠而不去做的呢？\n从求而不得，到生生不息。 这是我对大一的注脚。\n","date":"2026年7月26日","externalUrl":null,"permalink":"/MyBlog/p/freshman-year-review/","section":"Posts","summary":"","title":"大一回顾：求不得，生不息","type":"posts"},{"content":"","date":"2026年7月26日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%A4%9A%E5%85%83%E7%BA%BF%E6%80%A7%E5%9B%9E%E5%BD%92/","section":"Tags","summary":"","title":"多元线性回归","type":"tags"},{"content":" 1. 多元线性回归 # 1.1 从单特征到多特征 # 单变量线性回归只使用一个特征：\n$$ f_{w,b}(x)=wx+b $$多元线性回归同时使用多个特征。例如预测房价时，可以使用：\n$x_0$：房屋面积 $x_1$：卧室数量 $x_2$：楼层数 $x_3$：房屋年龄 若共有 $n$ 个特征，则一个样本写成：\n$$ \\mathbf{x}^{(i)} = \\left[x_0^{(i)},x_1^{(i)},\\ldots,x_{n-1}^{(i)}\\right] $$其中：\n$i$ 表示第几个训练样本； $j$ 表示第几个特征； $x_j^{(i)}$ 表示第 $i$ 个样本的第 $j$ 个特征。 数学教材常从 1 开始编号；Python 和本实验从 0 开始编号。\n1.2 模型 # 多元线性回归模型为：\n$$ f_{\\mathbf{w},b}(\\mathbf{x}) =w_0x_0+w_1x_1+\\cdots+w_{n-1}x_{n-1}+b $$使用向量点积可以简写为：\n$$ \\boxed{f_{\\mathbf{w},b}(\\mathbf{x})=\\mathbf{w}\\cdot\\mathbf{x}+b} $$其中：\n$\\mathbf{w}=[w_0,w_1,\\ldots,w_{n-1}]$ 是长度为 $n$ 的参数向量； $b$ 是标量偏置； $\\mathbf{w}\\cdot\\mathbf{x}=\\sum_{j=0}^{n-1}w_jx_j$。 例如：\n$$ f(\\mathbf{x})=0.1x_0+4x_1+10x_2-2x_3+80 $$如果房价单位为千美元，可解释为：面积每增加 1 平方英尺，预测价格增加 0.1 千美元；房龄每增加 1 年，预测价格减少 2 千美元。参数表达的是“其他特征保持不变时”的线性影响。\n“多元线性回归”指多个输入特征。它不同于统计学中具有多个输出变量的“多变量回归”。\n2. 训练数据的矩阵表示 # 若有 $m$ 个训练样本，每个样本有 $n$ 个特征，则输入数据组成矩阵：\n$$ \\mathbf{X}\\in\\mathbb{R}^{m\\times n} $$ 每一行是一个训练样本； 每一列是一种特征； X[i, j] 是第 i 个样本的第 j 个特征； X[i] 或 X[i, :] 是第 i 个样本的特征向量。 实验数据：\nimport numpy as np X_train = np.array([ [2104, 5, 1, 45], [1416, 3, 2, 40], [ 852, 2, 1, 35] ]) y_train = np.array([460, 232, 178]) 其形状为：\nX_train.shape # (3, 4)：3 个样本、4 个特征 y_train.shape # (3,)：3 个目标值 参数示例：\nw_init = np.array([ 0.39133535, 18.75376741, -53.36032453, -26.42131618 ]) b_init = 785.1811367994083 w_init.shape # (4,) 3. 用 NumPy 实现预测 # 3.1 循环版本 # def predict_single_loop(x, w, b): p = 0.0 for j in range(x.shape[0]): p += x[j] * w[j] return p + b 3.2 向量化版本 # def predict(x, w, b): return np.dot(x, w) + b 使用方式：\nx_vec = X_train[0] prediction = predict(x_vec, w_init, b_init) x_vec 和 w_init 的形状都是 (4,)，np.dot(x_vec, w_init) 返回一个标量。\n向量化版本的优势：\n代码更短、更容易阅读； 底层使用经过优化的数值计算程序； 可利用 CPU 的并行指令及专门的线性代数库； 对大规模数据通常远快于 Python 层的显式循环。 向量化并不意味着“所有运算一定在一个时钟周期完成”，而是将循环交给优化后的底层实现，从而减少 Python 解释器开销并更充分地利用硬件。\n4. 代价函数 # 单个样本的误差为：\n$$ e^{(i)} =f_{\\mathbf{w},b}(\\mathbf{x}^{(i)})-y^{(i)} $$平方误差代价函数：\n$$ \\boxed{ J(\\mathbf{w},b) =\\frac{1}{2m} \\sum_{i=0}^{m-1} \\left( f_{\\mathbf{w},b}(\\mathbf{x}^{(i)})-y^{(i)} \\right)^2 } $$Python 实现：\ndef compute_cost(X, y, w, b): m = X.shape[0] cost = 0.0 for i in range(m): prediction = np.dot(X[i], w) + b cost += (prediction - y[i]) ** 2 return cost / (2 * m) 进一步向量化：\ndef compute_cost_vectorized(X, y, w, b): errors = X @ w + b - y return np.sum(errors ** 2) / (2 * X.shape[0]) 其中 @ 在这里执行矩阵与向量的乘法：\nX : (m, n) w : (n,) X @ w : (m,) y : (m,) 5. 多元线性回归的梯度 # 对每个参数 $w_j$：\n$$ \\boxed{ \\frac{\\partial J}{\\partial w_j} =\\frac{1}{m} \\sum_{i=0}^{m-1} \\left( f_{\\mathbf{w},b}(\\mathbf{x}^{(i)})-y^{(i)} \\right)x_j^{(i)} } $$对偏置 $b$：\n$$ \\boxed{ \\frac{\\partial J}{\\partial b} =\\frac{1}{m} \\sum_{i=0}^{m-1} \\left( f_{\\mathbf{w},b}(\\mathbf{x}^{(i)})-y^{(i)} \\right) } $$循环实现：\ndef compute_gradient(X, y, w, b): m, n = X.shape dj_dw = np.zeros(n) dj_db = 0.0 for i in range(m): error = np.dot(X[i], w) + b - y[i] for j in range(n): dj_dw[j] += error * X[i, j] dj_db += error return dj_db / m, dj_dw / m 完全向量化实现：\ndef compute_gradient_vectorized(X, y, w, b): m = X.shape[0] errors = X @ w + b - y dj_dw = X.T @ errors / m dj_db = np.sum(errors) / m return dj_db, dj_dw 形状检查：\nerrors : (m,) X.T : (n, m) X.T @ errors : (n,) dj_dw : (n,) dj_db : scalar 6. 批量梯度下降 # 更新规则：\n$$ w_j \\leftarrow w_j-\\alpha\\frac{\\partial J}{\\partial w_j} $$$$ b \\leftarrow b-\\alpha\\frac{\\partial J}{\\partial b} $$向量形式：\n$$ \\boxed{ \\mathbf{w} \\leftarrow \\mathbf{w}-\\alpha\\nabla_{\\mathbf{w}}J } $$实现：\nimport copy import math def gradient_descent( X, y, w_in, b_in, cost_function, gradient_function, alpha, num_iters ): w = copy.deepcopy(w_in) b = b_in J_history = [] for i in range(num_iters): dj_db, dj_dw = gradient_function(X, y, w, b) # 使用同一次迭代计算出的梯度，同时更新所有参数 w = w - alpha * dj_dw b = b - alpha * dj_db if i \u0026lt; 100_000: J_history.append(cost_function(X, y, w, b)) if i % math.ceil(num_iters / 10) == 0: print(f\u0026#34;Iteration {i:4d}: Cost {J_history[-1]:8.2f}\u0026#34;) return w, b, J_history 实验设置：\ninitial_w = np.zeros_like(w_init) initial_b = 0.0 iterations = 1000 alpha = 5.0e-7 w_final, b_final, J_hist = gradient_descent( X_train, y_train, initial_w, initial_b, compute_cost, compute_gradient, alpha, iterations ) 该实验的学习率很小，而且不同特征的数值范围差异大，因此 1000 次迭代后的预测仍不够准确。后续通常会通过特征缩放改善收敛速度。\n7. NumPy 向量基础 # 7.1 一维数组 # 本课程用一维 NumPy 数组表示向量：\na = np.array([1, 2, 3, 4]) a.shape # (4,) 常用创建方式：\nnp.zeros(4) # 4 个 0 np.random.random_sample(4) # 4 个 [0, 1) 随机数 np.arange(4.0) # [0., 1., 2., 3.] np.random.rand(4) # 4 个 [0, 1) 随机数 (4,) 表示含 4 个元素的一维数组，不是二维行矩阵 (1, 4)，也不是二维列矩阵 (4, 1)。\n7.2 索引 # a = np.arange(10) a[2] # 第 3 个元素 a[-1] # 最后一个元素 访问一维数组的单个元素通常得到 NumPy 标量。\n7.3 切片 # 格式：\na[start:stop:step] stop 不包含在结果中。\na[2:7:1] # 索引 2、3、4、5、6 a[2:7:2] # 索引 2、4、6 a[3:] # 从索引 3 到末尾 a[:3] # 索引 0、1、2 a[:] # 全部元素，得到一个视图 7.4 常用向量运算 # a = np.array([1, 2, 3, 4]) -a # 逐元素取负 np.sum(a) # 元素求和，返回标量 np.mean(a) # 均值，返回标量 a ** 2 # 逐元素平方 5 * a # 每个元素乘以 5 7.5 逐元素运算与点积 # a = np.array([ 1, 2, 3, 4]) b = np.array([-1, 4, 3, 2]) a * b # 逐元素乘法，结果仍是向量 np.dot(a, b) # 点积：逐元素相乘后求和，结果是标量 a @ b # 对两个一维向量，同样是点积 手动实现点积：\ndef my_dot(a, b): result = 0.0 for i in range(a.shape[0]): result += a[i] * b[i] return result 两个向量做逐元素运算或点积时，形状必须兼容。\n7.6 向量性能示例 # import time size = 10_000_000 a = np.random.rand(size) b = np.random.rand(size) start = time.time() vectorized_result = np.dot(a, b) vectorized_time = time.time() - start start = time.time() loop_result = my_dot(a, b) loop_time = time.time() - start 两种方法应给出近似相同的结果。浮点数运算顺序不同，可能造成极小的舍入误差，因此比较时宜使用：\nnp.isclose(vectorized_result, loop_result) 8. NumPy 矩阵基础 # 8.1 创建矩阵 # 二维数组使用形状元组：\nnp.zeros((1, 5)) # shape: (1, 5) np.zeros((2, 1)) # shape: (2, 1) np.random.random_sample((3, 4)) 手动创建：\na = np.array([ [5], [4], [3] ]) a.shape # (3, 1) 8.2 reshape # a = np.arange(6).reshape(3, 2) 结果：\n[[0, 1], [2, 3], [4, 5]] 也可以写：\na = np.arange(6).reshape(-1, 2) -1 表示让 NumPy 根据元素总数和另一个维度自动推断该维度。\n8.3 矩阵索引 # a[2, 0] # 第 3 行第 1 列的标量 a[2] # 第 3 行，返回 shape 为 (n,) 的一维数组 a[2, :] # 同上 a[:, 0] # 第 1 列，返回一维数组 8.4 矩阵切片 # a = np.arange(20).reshape(-1, 10) a[0, 2:7] # 第 1 行中索引 2 到 6 a[:, 2:7] # 所有行、索引 2 到 6 的列 a[:, :] # 全部行和列 a[1, :] # 第 2 行，结果是一维数组 9. 广播（Broadcasting） # 广播允许 NumPy 在形状兼容时，将较小的数组“扩展”到较大的数组上执行逐元素运算。\n最简单的例子是标量与向量：\na = np.array([1, 2, 3, 4]) a + 10 # [11, 12, 13, 14] 在线性模型中：\nX @ w + b X @ w 的形状是 (m,)，标量 b 会广播到每个预测值上。\n判断广播是否兼容时，从形状的最后一个维度向前比较；对应维度相等，或其中一个为 1，通常即可广播。\n广播很方便，但也可能隐藏形状错误。机器学习代码中应经常打印或断言 .shape。\n10. 正态方程与梯度下降 # 线性回归还可以通过线性代数方法直接求解参数，常称为正态方程法。\n特点：\n无需像梯度下降那样反复迭代； 主要用于线性回归，不能自然推广到逻辑回归、神经网络等模型； 当特征数很大时，直接求解可能较慢； 实际项目中通常交给成熟的数值计算或机器学习库处理，不应手动计算矩阵逆。 本课程重点学习梯度下降，因为它能推广到更多机器学习模型。\n11. 高频易错点 # 数学下标与 Python 下标不同\n数学讲解可能从 1 开始；NumPy 从 0 开始。\n(n,) 不等于 (n, 1)\n前者是一维数组，后者是二维列矩阵。\na * b 不等于 np.dot(a, b)\n前者逐元素相乘，后者对一维向量返回点积。\n切片右端不包含\na[2:7] 包含索引 2 到 6。\n更新梯度下降参数时要同步\n同一轮中的 w 和 b 都应使用更新前的同一组梯度。\n特征尺度会影响梯度下降速度\n面积可能是上千，楼层数可能只有 1～3；尺度差异大会使代价函数狭长，导致收敛缓慢。\n学习率过大或过小都不好\n过大可能使代价震荡或发散；过小则收敛很慢。\n浮点数结果不要直接用 == 比较\n使用 np.isclose 或 np.allclose。\n课程原文中的翻译错误\n向量 [1,2,3,4] 的 shape (4,) 是一维，不是“二维向量”；向量的元素个数常称为长度或维数，而 NumPy 数组的 ndim 表示轴的数量。\n12. 一页速记 # # 预测 y_hat = X @ w + b # 误差 errors = y_hat - y # 代价 J = np.sum(errors ** 2) / (2 * m) # 梯度 dj_dw = X.T @ errors / m dj_db = np.sum(errors) / m # 更新 w = w - alpha * dj_dw b = b - alpha * dj_db 对应的核心公式：\n$$ \\boxed{ \\hat{\\mathbf{y}}=\\mathbf{X}\\mathbf{w}+b } $$$$ \\boxed{ J(\\mathbf{w},b)=\\frac{1}{2m}\\|\\hat{\\mathbf{y}}-\\mathbf{y}\\|_2^2 } $$$$ \\boxed{ \\nabla_{\\mathbf{w}}J =\\frac{1}{m}\\mathbf{X}^{T}(\\hat{\\mathbf{y}}-\\mathbf{y}) } $$$$ \\boxed{ \\frac{\\partial J}{\\partial b} =\\frac{1}{m}\\sum_{i=0}^{m-1}(\\hat y^{(i)}-y^{(i)}) } $$掌握这一条主线即可：\n样本矩阵 X → 点积得到预测 → 预测减目标得到误差 → 由误差计算代价和梯度 → 用梯度下降更新 w、b ","date":"2026年7月26日","externalUrl":null,"permalink":"/MyBlog/p/coursera-ml-multiple-linear-regression/","section":"Posts","summary":"","title":"多元线性回归与 NumPy 向量化","type":"posts"},{"content":"","date":"2026年7月26日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%B9%B4%E5%BA%A6%E5%9B%9E%E9%A1%BE/","section":"Tags","summary":"","title":"年度回顾","type":"tags"},{"content":"","date":"2026年7月26日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%90%91%E9%87%8F%E5%8C%96/","section":"Tags","summary":"","title":"向量化","type":"tags"},{"content":"","date":"2026年7月26日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%AD%A6%E4%B9%A0/","section":"Tags","summary":"","title":"学习","type":"tags"},{"content":"面向刚进入 Infra / AI Infra 领域的初学者 课程时间：2026-07-25 整理依据：96 页课件 + 约 3 小时逐字稿 说明：本文不是逐页复述，而是按知识依赖关系重构。逐字稿中的语音识别错误（如把 warp 写成 work、grid 写成 grade、Triton 写成 ten、TileLang 写成 tell long）均已按上下文纠正。\n0. 先给出整场讲座的主线 # 这场讲座其实在回答一个连续的问题：\n为什么 AI 需要并行计算？ 大模型中的矩阵乘法、逐元素运算、通信和流水线，都含有大量可以同时完成的工作。 为什么 GPU 适合做这些计算？ GPU 用大量计算单元和高带宽内存换取高吞吐量，并通过并发执行许多 warp 来隐藏单个任务的延迟。 程序员怎样把计算映射到 GPU？ CUDA 用 grid → block/CTA → warp → thread 描述并行任务，并要求程序员显式处理索引、内存、同步和边界。 为什么还需要 Triton / TileLang？ 手写每个线程过于繁琐。Tile-level DSL 让程序员先描述\u0026quot;一块数据如何搬运和计算\u0026quot;，再由编译器完成更细的线程映射。 这与 AI Infra 有什么关系？ 算子、编译器、分布式训练、集群通信、推理服务和强化学习系统，本质上都在解决： 怎样切分计算、放置数据、协调执行，并让昂贵硬件尽可能持续地做有效工作。 可以把全文压缩成一句话：\nAI Infra 的核心不是\u0026quot;GPU 比 CPU 快\u0026quot;，而是识别并行性，并在计算、内存和通信之间设计高效的数据流。\n1. 活动定位与课程地图 # 本次暑期活动的目标是\u0026quot;学习和实践 AI Infra\u0026quot;，除课程外还计划提供算力、练习、评测排行、交流讨论和业界连接。整体内容分为四个主题：\n主题 主要内容 Kernel 与 ML Compiler CUDA、GPU 编程、DSL、数据布局、流水线、编译器栈、性能上限分析 Interconnect 与 Communication Scale-up / Scale-out、网络互联、P2P、集合通信、MoE/EP、通信计算重叠、存储协同 LLM Serving 与 Inference KV Cache、vLLM/SGLang、算子与框架、并行策略、Prefill-Decode 分离、投机解码 Distributed RL Systems RL 算法、Rollout、训推一致性、分布式 RL、Agentic RL Session 1 是后续所有主题的\u0026quot;地基层\u0026quot;：先建立并行计算、GPU 执行模型和 Kernel 编程的共同语言。\n第一部分：从计算到并行计算 # 2. 什么是计算 # 课程给出的朴素定义是：\n计算是把输入值按照某种规则变换为输出值的过程。\n这个定义可以拆成两件事：\n数据及其搬运：输入在哪里？输出写到哪里？中间数据怎样流动？ 计算规则及其执行单元：加法、乘法、比较、矩阵乘法由什么硬件完成？ 这是一个很重要的 Infra 视角。应用开发常把\u0026quot;算法\u0026quot;当作核心，但性能工程会同时追问：\n算了多少次？ 每次计算需要读写多少字节？ 数据位于寄存器、缓存、显存，还是另一张卡？ 计算单元是在工作，还是在等数据？ 很多程序\u0026quot;算得慢\u0026quot;并不是算术运算慢，而是数据没有及时到达计算单元。\n3. 指令、时钟周期、流水线与 IPC # 一个经典处理器执行指令大致经过：\n取指 → 译码 → 执行 → 访存 → 写回\n如果把每条指令完整做完才开始下一条，硬件的大量部件会闲置。流水线让不同指令同时处于不同阶段。例如：\n指令 1 正在写回； 指令 2 正在访存； 指令 3 正在执行； 指令 4 正在译码； 指令 5 正在取指。 IPC（Instructions Per Cycle） 表示平均每周期完成多少条指令。需要注意：\nIPC 不是频率；频率表示一秒有多少周期。 IPC 也不是程序性能的全部；不同指令的工作量、延迟和吞吐不同。 超标量处理器能在同一周期发射或完成多条互不依赖的指令，因此 IPC 可能大于 1。 这已经是一种并行：它发生在单个处理器内部，称为指令级并行（ILP）。\n4. 延迟与吞吐量 # 这是全场最重要的一组区分。\n延迟（Latency） # 完成一个任务要等多久。\n例：一个请求从进入推理服务到收到第一个 token 的时间。\n吞吐量（Throughput） # 单位时间完成多少任务或多少计算。\n例：一个推理集群每秒处理多少 token。\nGPU 的优势主要是吞吐量，而不是每个单独运算的最低延迟。它的策略是：\n当一个任务在等待数据时，迅速执行另一个已就绪的任务，用大量并发工作隐藏等待。\n因此，\u0026ldquo;GPU 快\u0026quot;必须补全为：\n对具有足够并行度、规则控制流和合适访存模式的工作负载，GPU 往往能提供很高的总吞吐量。\n5. 什么样的计算能够并行 # 5.1 数据依赖 # c = a * b d = 3 * c d 必须等待 c，二者存在真数据依赖，不能直接同时执行。\nc = a * b d = 3 * b e = a + b 三条计算只读相同输入、写不同输出，可以并行。\n更系统地说，判断能否并行要看读写集合是否冲突。常见依赖包括：\nRAW（Read After Write）：后者要读前者写出的值，是真依赖。 WAR（Write After Read）：后者写入的位置仍需被前者读取。 WAW（Write After Write）：两个任务写同一位置，最终结果依赖顺序。 5.2 竞争、互斥和锁 # 多个执行单元同时读写同一资源，可能产生 race condition。锁、原子操作、屏障可以恢复正确性，但会带来等待和串行化。\n所以，并行化不只是\u0026quot;把任务分成 N 份\u0026rdquo;，还要保证：\n每份任务的读写范围清楚； 共享状态受到正确保护； 同步频率不过高； 结果与执行顺序无关，或执行顺序被明确控制。 5.3 通信与同步 # 任务即使能拆开，也常常需要交换中间结果。例如：\n多卡训练中聚合梯度； 求所有设备上的最大值或总和； 流水线后一阶段等待前一阶段输出； MoE 中把 token 路由到不同专家。 并行收益最终要减去：\n通信时间； 同步等待； 数据重新布局； 任务调度； 负载不均衡。 这解释了为什么\u0026quot;设备数翻倍\u0026quot;通常不等于\u0026quot;性能翻倍\u0026quot;。\n6. 两类基本拆分方法 # 6.1 Domain Decomposition：按数据域拆分 # 把一个大数据集切成若干块，让不同计算单元处理不同区域。\n常见切法：\n连续分块（block）； 循环分配（cyclic）； block-cyclic； 二维或三维网格切分。 选择切法时要考虑：\n各块计算量是否均衡； 相邻数据是否频繁交互； 数据是否连续，能否高效访问； 每块能否放入更快的存储层。 矩阵乘法是典型例子。输出矩阵 C = A × B 的不同块可以独立计算，因此可以先按 M、N 维切分输出，再沿归约维 K 分块累加。\n6.2 Functional Decomposition：按功能或阶段拆分 # 把不同类型的计算放到不同执行单元，或让不同设备负责流水线的不同阶段。\n例：\nCPU 负责调度和控制，GPU 负责密集计算； 流水线并行中，不同 GPU 负责模型的不同层； 推理系统中，Prefill 和 Decode 由不同资源池承担。 6.3 大模型训练中的典型映射 # 并行方式 拆什么 主要代价 数据并行 DP 不同设备处理不同 batch，模型副本相同 梯度 All-Reduce 张量并行 TP 把单层矩阵按行或列拆到多卡 层内频繁通信 流水线并行 PP 不同设备负责不同层 流水线气泡、激活传输 专家并行 EP 不同设备放不同 MoE 专家 All-to-All、负载不均 序列/上下文并行 按序列维切分 注意力相关通信 现实系统经常组合多种并行方式，形成多维并行拓扑。\n7. Flynn 分类：从指令流与数据流看硬件 # 类型 含义 直觉 SISD 单指令流、单数据流 经典串行处理 SIMD 单指令流、多数据流 同一指令同时作用于多个数据元素 MISD 多指令流、单数据流 很少有严格对应的通用硬件 MIMD 多指令流、多数据流 多核 CPU、集群等通用并行系统 这个分类是抽象模型，不应把复杂现代处理器硬塞进唯一格子。GPU 在不同层级上会同时呈现不同特征：\n不同 block 可以执行不同任务，整体具有 MIMD 味道； 单个 warp 通常以相同指令推进，具有 SIMD/SIMT 味道。 8. 并行编程模型：程序员怎样描述计算 # 课程提到的主要模型包括：\n共享内存：执行单元通过同一地址空间读写数据。 线程模型：创建、同步和管理多个线程。 分布式内存 / 消息传递：不同进程拥有独立内存，通过 send/receive、broadcast、reduce 等显式通信；MPI 是代表。 PGAS：逻辑上提供全局地址空间，但数据具有物理归属。 SPMD：多个执行单元运行同一程序，但依据自身编号处理不同数据。 MPMD：不同执行单元可以运行不同程序。 混合模型：例如节点间 MPI、节点内线程、GPU 上 CUDA。 不要把 SPMD 和\u0026quot;数据并行训练\u0026quot;混为一谈：\nSPMD 描述\u0026quot;程序是否相同\u0026quot;； 数据并行描述\u0026quot;模型和数据如何切分\u0026quot;。 第二部分：CUDA 编程模型 # 9. 为什么 GPU 适合 AI/HPC # 课程用\u0026quot;三胜\u0026quot;概括 GPU：\n9.1 更多晶体管用于吞吐型计算 # CPU 要做好分支预测、乱序执行、低延迟缓存和复杂控制；GPU 把更多面积用于大量相对简单的计算单元。\n这不是\u0026quot;GPU 每个核心都比 CPU 核心强\u0026quot;，而是：\nCPU 强于低延迟、复杂控制和串行任务； GPU 强于规则、密集、批量、可并行的任务。 9.2 用并发隐藏延迟 # GPU 的单次操作或显存访问可能并不低延迟，但 SM 保存大量 warp 的执行状态。一个 warp 等待内存时，调度器可以选择另一个就绪 warp。\n9.3 高显存带宽 # 训练和推理频繁搬运权重、激活和 KV Cache，高带宽显存非常关键。但标称带宽只有在访问足够连续、事务利用率高时才可能接近。\n课堂中的具体延迟、吞吐和\u0026quot;HBM 比 DDR 快多少倍\u0026quot;是用于建立直觉的示例，不是跨所有 CPU/GPU/内存配置都成立的常数。\n10. CUDA 的软件层级与硬件层级 # 10.1 软件视角 # Kernel launch └── Grid ├── Block / CTA │ ├── Warp（NVIDIA 当前通常为 32 threads） │ │ ├── Thread │ │ └── ... │ └── ... └── ... thread：CUDA 编程中最细的逻辑执行实例。 warp：硬件调度与执行的一组线程。 block / CTA：线程合作、共享 shared memory 和执行块级同步的范围。 grid：一次 kernel launch 创建的所有 block。 10.2 硬件视角 # GPU └── GPC 等更高层组织 └── 多个 SM ├── warp schedulers ├── register file ├── shared memory / L1 ├── CUDA cores / ALUs ├── Tensor Cores └── SFU 等专用单元 一个 block 会被调度到某个 SM，并通常在该 SM 上运行至结束。block 数可以远多于 SM 数，剩余 block 等待调度。\n10.3 自动可扩展性的来源 # 一般 CUDA kernel 应让不同 block 相互独立，因为：\nblock 的执行顺序没有保证； 设备可能有几十、几百或更多 SM； 同一程序无需根据 SM 数重写。 这是一种软件与硬件规模解耦。例外包括 cooperative launch、thread block cluster 等有额外约束的机制，但初学阶段先遵守\u0026quot;block 独立\u0026quot;最安全。\n11. CUDA 程序的基本生命周期 # 典型流程：\nCPU（host）准备输入； 分配 GPU（device）内存； 把数据从 host memory 复制到 device memory； 使用 \u0026lt;\u0026lt;\u0026lt;gridDim, blockDim, ...\u0026gt;\u0026gt;\u0026gt; 启动 kernel； GPU 并行执行； 必要时同步； 将结果复制回 CPU； 释放资源并检查错误。 CUDA C++ 中常见执行空间修饰符：\n__host__：在 CPU 上执行； __device__：在 GPU 上执行，由 device 代码调用； __global__：声明 kernel，通常由 host 发起并在 device 上执行，返回类型为 void。 编译链可粗略理解为：\nCUDA C++ (.cu) ├── host code → host compiler → CPU code └── device code → nvcc toolchain → PTX / device binary PTX 是虚拟指令集/中间表示，便于在不同代际 GPU 上进一步生成机器代码。 CUBIN 包含面向具体 GPU 架构的机器代码。 12. 从线程编号映射到数据 # 12.1 一维向量 # int i = blockIdx.x * blockDim.x + threadIdx.x; if (i \u0026lt; N) { c[i] = a[i] + b[i]; } 其中：\nthreadIdx.x：线程在 block 内的局部编号； blockIdx.x：block 在 grid 内的编号； blockDim.x：每个 block 的线程数； 边界判断处理 N 不是 block 大小整数倍的情况。 12.2 二维矩阵 # int row = blockIdx.y * blockDim.y + threadIdx.y; int col = blockIdx.x * blockDim.x + threadIdx.x; if (row \u0026lt; M \u0026amp;\u0026amp; col \u0026lt; N) { c[row * N + col] = a[row * N + col] + b[row * N + col]; } 二维数组在内存中仍然是一段线性地址。以行主序为例：\naddress(i, j) = base + i * stride_row + j * stride_col 对连续张量，通常 stride_col = 1，stride_row = 列数。理解 stride 是之后看 Triton 矩阵乘法指针计算的关键。\n13. Warp、SIMT 与分支发散 # 13.1 SIMT # SIMT 是 Single Instruction, Multiple Threads。程序员看到独立线程，但硬件通常以 warp 为单位发射指令。\nSIMT 与 SIMD 的直觉差异：\nSIMD 直接表达\u0026quot;一条向量指令作用于多个数据 lane\u0026quot;； SIMT 表达\u0026quot;许多逻辑线程运行同一 kernel\u0026quot;。 在 NVIDIA GPU 上，每个线程具有独立执行状态，但同一 warp 中的活跃线程仍以共同指令高效推进。\n13.2 Warp divergence # 若同一 warp 内部分线程走 if，另一部分走 else，硬件可能依次执行不同路径，并 mask 掉当前路径不参与的线程。\nif (threadIdx.x % 2 == 0) { expensive_a(); } else { expensive_b(); } 这会让每个 warp 都经历两条路径。优化方向是让相同分支的数据按 warp 边界聚集，使一个 warp 尽量只执行一条路径。\n但不要把\u0026quot;任何 if 都很慢\u0026quot;当成规则：\n所有线程条件一致时没有发散； 很短的分支可能被编译为 predication； 边界判断通常只影响少量 warp； 真正是否成为瓶颈应由 profiler 判断。 13.3 Warp-level primitives # warp 内线程可以通过 shuffle 等原语交换寄存器数据。例如 reduction 可在 log2(32) 轮中逐步求和，而不必经过 shared memory。\n14. Block 内协作与同步 # 同一 block 内的线程可以：\n访问 shared memory； 使用原子操作； 使用内存屏障； 使用 __syncthreads() 进行块级同步。 关键规则：\n__syncthreads() 必须由 block 中所有相关线程以一致的控制流到达，否则可能死锁或产生未定义行为。\n不能仅仅因为\u0026quot;每个线程最终都会调用一次\u0026quot;就认为安全；它们必须在相同的同步阶段到达。\nCompute Capability 9.0 起，CUDA 还提供可选的 thread block cluster，使一组 block 在受约束的硬件范围内协作，并可使用 distributed shared memory。它是进阶机制，不改变初学者优先保证普通 block 独立的原则。\n15. GPU 内存层次 # 层次 可见范围 容量/速度直觉 主要用途 Register 单个 thread 最小、最快 当前值、累加器 Shared memory 单个 block/CTA 小、低延迟、显式管理 数据复用、线程协作 L1 cache 通常 SM 层级 硬件管理 缓存局部访问 L2 cache 全 GPU 更大、更慢 跨 SM 数据缓存 Global memory / HBM 全 GPU 最大、高延迟、高总带宽 权重、激活、输入输出 Local memory 逻辑上 thread 私有，物理上通常在 device memory 可能很慢 寄存器不足时的 spill 等 Constant memory 全局只读且有缓存 适合 warp 内相同地址读取 常量参数 重要纠偏：\n\u0026ldquo;local\u0026quot;描述可见性，不保证物理位置靠近线程，也不保证快。 shared memory 与 L1 常共享片上资源或可配置容量，但具体组织取决于架构。 寄存器不是无限的；每线程用得太多，会减少同一 SM 可驻留的 warp/block。 16. 合并访存（Coalescing） # 一个 warp 的线程访问 global memory 时，硬件会把地址请求合并为尽量少的内存事务。\n理想模式：\nlane 0 → A[k] lane 1 → A[k+1] lane 2 → A[k+2] ... 不理想模式：\n大步长跨越； 随机地址； 每个事务只使用很少字节； 未对齐访问导致额外事务。 这就是为什么行主序矩阵中，常把 threadIdx.x 映射到连续列：同一 warp 内连续线程访问连续地址。\n17. Tiling 与矩阵乘法 # 朴素矩阵乘法：\nC[m, n] = Σ_k A[m, k] * B[k, n] 一个高性能分块思路：\n每个 block 负责 C 的一个 BLOCK_M × BLOCK_N 输出块； 沿 K 维以 BLOCK_K 为步长循环； 每轮把 A、B 的对应 tile 从 global memory 搬到 shared memory； block 内线程复用这些数据并进行乘加； 结果在寄存器中累加； 最后写回 global memory。 核心收益不是减少数学运算，而是增加数据复用：\nA 的一个元素可参与同一输出块的多列计算； B 的一个元素可参与同一输出块的多行计算； 从 HBM 读取一次后，在 shared memory / register 中多次使用。 这正是后续 Triton 与 TileLang 都以 tile 为核心抽象的原因。\n18. Warp 调度、延迟隐藏与 Occupancy # 18.1 延迟隐藏 # 当 warp A 等待 global memory 时，调度器可以发射 warp B。要做到这一点，SM 中必须有足够多的可运行 warp。\n18.2 Occupancy # 常用定义：\nOccupancy = SM 上实际驻留的 active warps / 该 SM 支持的最大 active warps 限制驻留数量的资源包括：\n每 block 的线程数； 每线程的寄存器数； 每 block 的 shared memory； 架构对 block、warp 的数量上限。 18.3 高 Occupancy 不等于最高性能 # 更高 occupancy 通常有利于隐藏延迟，但可能与其他优化冲突：\n更多寄存器可减少 spill 和重复访存； 更大的 shared-memory tile 可提高数据复用； 但它们会降低可同时驻留的 block/warp 数。 所以目标不是机械追求 100%，而是找到足以隐藏主要延迟、同时保留高数据复用和低指令开销的配置。\n19. CUDA 优化检查表 # 课程总结的四个方向可以扩展成：\n暴露足够并行度：grid 足够大，能覆盖全部 SM。 提高数据复用：用 register/shared memory 减少重复 HBM 访问。 改善访存模式：合并访问、对齐、减少无效事务。 减少控制流浪费：降低 warp divergence。 控制资源使用：在寄存器、shared memory、occupancy 之间权衡。 使用专用单元：适合时使用 Tensor Core、SFU 等。 让搬运与计算重叠：异步拷贝、pipeline、多 stream。 基于测量优化：先验证正确性，再 benchmark，再 profiler。 API 选讲的准确理解 # CUDA events：适合测量 GPU 时间；不要只用 CPU 墙钟且忘记异步执行。 Streams：同一 stream 内有序；不同 stream 的工作\u0026quot;有机会\u0026quot;并发，但是否真正重叠取决于依赖、资源、硬件能力和默认 stream 语义。 Unified Memory：简化地址与迁移管理，但数据迁移仍有成本；性能敏感场景需要关注预取、驻留和缺页。 P2P：在支持的拓扑与配置下允许 GPU 间直接访问或复制；不是所有 GPU 对都支持，性能也取决于 PCIe/NVLink 等互联。 第三部分：从 Thread-level 到 Tile-level # 20. 为什么需要 Tile-level Programming # CUDA 的控制力强，但程序员要显式完成：\n每个 thread 负责什么； thread/block 的索引如何映射到数据； global/shared/register 之间怎样搬运； 如何同步； 如何处理边界； 如何避免 bank conflict、发散和低效访存。 但很多 AI Kernel 的自然表述是：\n取一块张量 → 做一次块级计算 → 把结果写回。\n因此 Tile-level DSL 把 block 内的大量线程协作抽象成 tile 操作：\n确定当前 tile； 加载 tile； 计算； 写回； 用 mask 处理边界。 程序员仍决定关键分块，编译器负责把 tile 内计算映射到 thread/warp。\n21. Triton 编程模型 # 21.1 基本视角 # CUDA 从 thread 出发； Triton 从 program instance 出发； 一个 Triton program 通常负责一个输出 tile； tl.program_id(axis=...) 类似取得当前 program 在 launch grid 中的位置； thread ID 通常不显式出现。 \u0026ldquo;一个 Triton program 约等于一个 CUDA block/CTA\u0026quot;是有用的初学者类比，但不是所有情况下都应理解成严格的一对一源码映射。\n21.2 向量加法 # 核心结构：\npid = tl.program_id(axis=0) offsets = pid * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE) mask = offsets \u0026lt; n_elements x = tl.load(x_ptr + offsets, mask=mask, other=0.0) y = tl.load(y_ptr + offsets, mask=mask, other=0.0) z = x + y tl.store(z_ptr + offsets, z, mask=mask) 理解重点：\noffsets 是一组下标，不是单个线程的一个下标； mask 防止最后一个 tile 越界； other=0.0 为越界 load 提供填充值； 写法表达的是\u0026quot;整个 tile 的向量操作\u0026rdquo;。 21.3 矩阵乘法 # 每个 program 负责 C 的一个输出块：\nacc = zeros(BLOCK_M, BLOCK_N) for k0 in range(0, K, BLOCK_K): a_tile = load A[m:m+BLOCK_M, k0:k0+BLOCK_K] b_tile = load B[k0:k0+BLOCK_K, n:n+BLOCK_N] acc += dot(a_tile, b_tile) store C tile 这里有三个分块尺度：\nBLOCK_M：输出 tile 的行数； BLOCK_N：输出 tile 的列数； BLOCK_K：归约维每轮处理的长度。 指针计算依赖 stride：\nA[i,j] 地址 = A_base + i * stride_am + j * stride_ak B[i,j] 地址 = B_base + i * stride_bk + j * stride_bn 性能还受 program 执行顺序影响，因为相邻 program 是否复用同一批 A/B 数据会改变 L2 命中率。\n21.4 Triton 的真实优缺点 # 优点：\nPython 风格、代码短； 保留 tile shape、program 排列等重要决策； 适合 elementwise、reduction、matmul、attention 和融合算子； 支持 autotune； 可显著降低手写 CUDA 的工程成本。 代价：\n一些线程级布局、shared memory 组织和流水线由编译器决定； 当性能瓶颈恰好位于这些细节时，调优空间较受限； 复杂硬件特化和某些 pipeline 表达可能更困难。 课堂把编译器称为\u0026quot;黑盒\u0026rdquo;、说 PTX/SASS 不透明，是强调控制粒度的说法，不应理解为\u0026quot;完全无法查看生成代码\u0026quot;。实践中可以 dump IR/PTX、结合 profiler 分析；只是源码与最终机器行为之间的映射比手写 CUDA 更间接。\n22. TileLang 编程模型 # TileLang 的目标位置可以理解为：\n保留 tile-level 的简洁，同时把影响性能的数据流和内存层次更明确地写出来。\n课程中的关键层次：\n函数参数 T.Tensor：global memory 中的输入输出； T.alloc_shared：CTA 内共享的 tile； T.alloc_fragment：寄存器中的局部累加结果； T.copy：不同内存层次之间搬运； T.gemm：tile 级矩阵乘加； T.Pipelined：沿分块循环表达搬运与计算重叠； T.Kernel(...)：定义 launch grid / CTA 级程序实例。 一个矩阵乘法的数据流可以写成：\nGlobal A/B │ T.copy ▼ Shared-memory tiles │ T.gemm ▼ Register fragment / accumulator │ T.copy ▼ Global C 其价值在于，读代码时能直接看到：\n数据在哪一层； 什么数据会被复用； 沿哪个维度循环； 搬运和计算是否流水化。 23. CUDA、Triton、TileLang 怎样选择 # 维度 CUDA C++ Triton TileLang 主要抽象 thread / warp / block program / tensor tile tile + 显式内存层次和数据流 控制粒度 最细 较高层 介于二者之间 开发效率 较低 高 中等 硬件细节负担 高 较低 中等 典型场景 极端调优、特殊机制、库级实现 快速开发与融合常见算子 需要显式数据流、pipeline 和可读结构的高性能 kernel 选择不是\u0026quot;谁永远更快\u0026quot;，而要问：\n算子是否规则？ 瓶颈是计算、访存、同步还是 launch overhead？ 是否需要特殊硬件指令或复杂流水线？ 是否需要跨硬件后端？ 团队能承担多少开发和维护成本？ 24. torch.compile 与手写 Kernel 的关系 # 课堂问答认为二者大体正交，这个方向是对的，但机制需要更精确地区分：\nTorchDynamo 负责从 Python 程序捕获可编译的计算图； TorchInductor 等后端进行融合、布局、调度和代码生成，GPU 上常生成 Triton kernel； CUDA Graphs 可用于减少 CPU launch overhead，但它不是 torch.compile 捕获 Python 计算图的同义词； 手写 Triton/TileLang/CUDA 仍有价值，因为通用编译器不一定能生成最适合特殊算子、数据布局或硬件特性的实现。 因此可以同时使用：\n模型/图级优化：torch.compile + 关键热点算子：手写或定制 Kernel 第四部分：把这些知识连回 AI Infra # 25. AI Infra 的共同问题 # 层级 典型对象 核心问题 单条指令/单个 warp SIMD/SIMT、分支 lane 是否都做有效工作 单个 block/CTA tiling、shared memory 数据能否在片上复用 单个 GPU SM 调度、HBM、stream 是否计算饱和、带宽饱和、延迟被隐藏 多 GPU TP/PP/DP/EP、collectives 计算与通信如何重叠 单机推理服务 batching、KV Cache、调度 吞吐、首 token 延迟、显存容量如何权衡 集群 网络拓扑、容错、资源调度 如何扩展并保持利用率 同一个思想不断重复：\n找到可并行部分； 选择分块； 把块映射到硬件； 把数据放到合适存储层； 安排通信和同步； 用并发隐藏不可避免的延迟； 测量瓶颈并迭代。 26. 为什么矩阵乘法贯穿整个课程 # Transformer 的大量计算最终落在 GEMM 或 GEMM-like Kernel 上，包括：\nQ/K/V 线性投影； attention 中的 QKᵀ 和 PV； MLP 的上投影、门控、下投影； 训练中的梯度计算； 张量并行下的分片矩阵乘。 矩阵乘法同时包含：\n大量独立输出元素； 沿 K 维的归约； 明确的数据复用； 对布局、带宽、缓存和专用矩阵单元的强依赖。 因此它是学习并行化、tiling、memory hierarchy 和 pipeline 的最佳样例。\n第五部分：课程之外必须补上的性能模型 # 27. Amdahl 定律：串行部分决定强扩展上限 # 若程序中可并行比例为 p，使用 N 个处理单元，理想加速比为：\nSpeedup(N) = 1 / ((1 - p) + p / N) 例如 95% 可并行，即使设备无限多，最大加速也只有 1 / 0.05 = 20 倍。\n现实还要加上通信、同步和调度开销，所以实际更低。它解释了为什么优化最后一点串行瓶颈可能比继续加卡更重要。\n28. Strong Scaling 与 Weak Scaling # Strong scaling：总问题规模不变，增加设备，看运行时间能否下降。 Weak scaling：每台设备的工作量不变，设备与总问题规模一起增长，看总时间能否保持。 训练一个固定模型更接近 strong scaling；随着 GPU 数增加同时增大 batch 或模型规模，则包含 weak scaling 的味道。\n29. Arithmetic Intensity 与 Roofline # 算术强度：\nArithmetic Intensity = 运算次数 FLOPs / 从慢速内存搬运的字节数 Bytes 粗略性能上限：\nPerformance ≤ min(Peak Compute, Memory Bandwidth × Arithmetic Intensity) 算术强度低：通常 memory-bound； 算术强度高：可能 compute-bound。 向量加法每个元素只做一次加法，却要读两个输入、写一个输出，通常是 memory-bound。分块矩阵乘法可让载入的数据被多次复用，算术强度高得多，更容易接近计算峰值。\n这比\u0026quot;看到 GPU 峰值 FLOPS 就估算程序速度\u0026quot;可靠得多。\n30. Little 定律式的延迟隐藏直觉 # 要维持高吞吐，系统中需要足够多的在途工作：\n并发在途量 ≈ 吞吐量 × 单次延迟 显存延迟很高时，需要许多 warp 在途；推理服务中单请求生成有等待时，需要 continuous batching 等调度策略维持设备利用率。底层 GPU 调度和上层服务调度，在这里有相似的结构。\n31. 数值精度不是附属问题 # GPU 的不同单元和编译选项可能在速度与精度之间取舍：\nFP32、TF32、FP16、BF16、FP8 等格式吞吐不同； 累加精度可能高于输入精度； --use_fast_math 等选项可能使用更快但精度/语义不同的近似； 并行 reduction 改变求和顺序，浮点结果可能与串行结果略有不同。 性能优化必须同时定义正确性容忍度：绝对误差、相对误差、ULP、任务指标或训练稳定性。\n第六部分：容易误解的课堂表述 # 32. 纠偏清单 # \u0026ldquo;GPU 比 CPU 快\u0026rdquo; 只对适合高吞吐并行、具有足够工作量和良好数据访问的任务成立。\n\u0026ldquo;有 N 个核心就快 N 倍\u0026rdquo; 这是无依赖、无通信、无调度开销、负载完全均衡时的理想上限。\n\u0026ldquo;一个 warp 的 32 个线程必须一直执行相同指令\u0026rdquo; 它们可以发生分支，但不同路径通常会被分阶段执行，造成发散开销。\n\u0026ldquo;block 之间绝对不能通信\u0026rdquo; 普通 kernel 的可扩展模型要求 block 独立；现代 CUDA 有 cooperative groups、cluster 等受约束机制，但不能默认依赖任意 block 的调度顺序。\n\u0026ldquo;Shared memory 一定比 global memory 快\u0026rdquo; 通常低延迟且可控，但 bank conflict、额外同步、低复用或过高资源占用都可能抵消收益。\n\u0026ldquo;Occupancy 越高越好\u0026rdquo; 需要\u0026quot;足够高\u0026quot;来隐藏延迟，不必盲目追求 100%。\n\u0026ldquo;多个 stream 就一定并发\u0026rdquo; stream 只提供并发机会；真实重叠取决于依赖和硬件资源。\n\u0026ldquo;Unified Memory 不需要考虑拷贝\u0026rdquo; API 简化了，但迁移、缺页和驻留成本仍然存在。\n\u0026ldquo;Triton 看不到生成代码\u0026rdquo; 可检查编译中间表示和生成代码；真正的限制是控制较间接、调优与定位成本可能较高。\n\u0026ldquo;torch.compile 就是 CUDA Graph\u0026rdquo; 计算图捕获、编译优化、代码生成和 CUDA Graphs 是不同环节，可能组合使用。\n\u0026ldquo;TileLang 一定跨平台且性能都好\u0026rdquo; 可移植抽象能减少迁移成本，但新后端仍需要编译器支持、调度适配和性能验证。\n第七部分：术语速查 # 术语 一句话解释 HPC High Performance Computing，高性能计算 AI Infra 支撑 AI 训练、推理和部署的软硬件系统 Kernel 在加速器上执行的一段计算函数 Host / Device 通常分别指 CPU 侧 / GPU 侧 Grid 一次 CUDA kernel launch 的全部 block Block / CTA 可在同一 SM 上合作、共享 shared memory 的线程组 Warp NVIDIA GPU 的线程调度/执行分组，通常含 32 个线程 SM Streaming Multiprocessor，执行 block/warp 的硬件单元 SIMT Single Instruction, Multiple Threads SIMD Single Instruction, Multiple Data SPMD Single Program, Multiple Data Tile 数据张量的一个分块 Tiling 把大计算分成适合并行和存储层次的小块 Coalescing 合并同一 warp 的连续内存请求 Divergence 同一 warp 的线程走不同控制路径 Occupancy SM 实际驻留 warp 与架构上限之比 Register pressure 每线程寄存器需求对驻留并发度造成的压力 Spill 寄存器不足，变量被放到更慢的 local/device memory Reduction 把多个值按加、最大值等操作聚合 Collective 多进程/多设备共同参与的通信操作 DSL Domain-Specific Language，面向特定领域的语言 PTX / SASS NVIDIA 的虚拟指令集 / 更接近实际 GPU 机器指令的表示 HBM High Bandwidth Memory，高带宽显存 Arithmetic Intensity 每搬运一字节数据能完成多少运算 第八部分：推荐的权威延伸资料 # NVIDIA CUDA Programming Guide：Programming Model 优先读 thread block、grid、SM、block 独立性和内存层次。\nNVIDIA CUDA Programming Guide：Introduction to CUDA C++ 对照课堂中的 kernel、内置索引和 thread block cluster。\nTriton 官方教程总览 推荐依次做 vector add、fused softmax、matrix multiplication。\nTriton Vector Addition 用于掌握 program_id → offsets → mask → load → compute → store。\nTriton Matrix Multiplication 重点看三维分块、pointer arithmetic、L2-friendly program ordering 和 autotune。\nTileLang TileLibrary Reference 查阅 T.alloc_shared、T.Pipelined 等原语。\nPyTorch torch.compile 文档 用于区分图编译、Inductor/Triton 代码生成和 CUDA Graphs。\n结语：应该带走的不是术语，而是一个分析模板 # 以后看到任何 AI Infra 性能问题，可以按这个顺序问：\n计算是什么？ 输入、输出、规则分别是什么？ 并行机会在哪里？ 哪些任务没有依赖？ 怎样分块？ 按数据、功能、模型层、序列还是专家？ 映射到哪里？ thread、warp、CTA、SM、GPU、节点分别承担什么？ 数据在哪里？ register、shared、HBM 还是远端 GPU？ 瓶颈是什么？ 计算、带宽、延迟、同步、通信还是 launch overhead？ 能否重叠？ 搬运与计算、通信与计算、不同请求是否可流水化？ 怎样证明？ 正确性测试、benchmark 和 profiler 是否支持你的判断？ 当你能够沿这八个问题分析一个 Kernel 或一个分布式系统时，就已经不再只是\u0026quot;知道几个 GPU 术语\u0026quot;，而是在用 Infra 的方式思考。\n","date":"2026年7月25日","externalUrl":null,"permalink":"/MyBlog/p/ai-infra-parallel-computing-cuda-triton-tilelang/","section":"Posts","summary":"","title":"AI Infra 讲座：从并行计算到 CUDA、Triton 与 TileLang","type":"posts"},{"content":"","date":"2026年7月25日","externalUrl":null,"permalink":"/MyBlog/tags/cuda/","section":"Tags","summary":"","title":"CUDA","type":"tags"},{"content":"","date":"2026年7月25日","externalUrl":null,"permalink":"/MyBlog/tags/gin/","section":"Tags","summary":"","title":"Gin","type":"tags"},{"content":"承接 Go 项目反推：Feed 流系统实战——请求生命周期，这一篇继续反推 feedsystem_video_go 的认证代码，梳理 JWT、Refresh Token、Token 撤销，以及软硬鉴权中间件之间的关系。\n一、jwt.go 文件结构 # 文件位置：internal/auth/jwt.go\njwtSecret() → 获取密钥 Claims → Token 里存什么 GenerateToken() → 生成 accessToken（JWT 格式，15 分钟） GenerateRefreshToken() → 生成 refreshToken（随机字符串） ParseToken() → 验证 Token（签名 + 过期） 二、密钥与 Claims # jwtSecret()：获取密钥 # 优先使用环境变量 JWT_SECRET，没有就随机生成：\nJWT_SECRET=*** go run ./cmd 如果没有设置环境变量，每次服务器启动时密钥都会改变，之前签发的 Token 将全部失效，所有用户都要重新登录。因此正式环境必须设置稳定且足够随机的 JWT_SECRET。\nClaims：Token 里存什么 # type Claims struct { AccountID uint // 用户 ID Username string // 用户名 jwt.RegisteredClaims // 标准字段（过期时间、签发时间等） } 可以把 Claims 类比为一张有有效期的身份证，上面记录用户名、用户 ID、签发时间和过期时间。\n需要注意：JWT Payload 只是经过 Base64URL 编码，并不是加密，客户端也能读取其中的内容。因此 Claims 里不能放密码、密钥等敏感数据。\n三、生成 Token # GenerateToken()：生成 accessToken # 1. 创建 Claims，写入用户 ID、用户名和 15 分钟后的过期时间 2. 使用 HS256 算法创建 Token 对象 3. 使用 JWT Secret 签名，生成 Token 字符串 结果是一串类似 eyJhbG...xxxx 的 JWT。它内部包含经过编码的 Header 和 Payload，以及用于防止内容被篡改的签名。\nHS256 只负责签名和验签，不负责加密。其他人虽然可以读取 Payload，但没有 JWT_SECRET 就无法生成有效签名。\nGenerateRefreshToken()：生成 refreshToken # Refresh Token 和 Access Token 完全不同：\naccessToken → JWT 格式，携带 Claims，并使用密钥签名 refreshToken → 随机字符串，本身不携带用户信息 项目使用 32 个随机字节，再转换为 64 位十六进制字符串。Refresh Token 不需要携带 Claims，它只需要成为一个难以猜中的长期凭证。\n双 Token 时间设定 # accessToken 15 分钟：泄露后的可利用时间较短 refreshToken 7 天： 用于换取新的 Access Token，减少重复登录 Access Token 有效期短，降低泄露风险；Refresh Token 有效期长，保证使用体验。二者配合，在安全性和便利性之间取得平衡。\n四、验证 Token # ParseToken()：验证 Token # 输入：客户端发送的 Token 字符串 输出：Claims（用户 ID、用户名）或错误 流程：\n1. 解析 Token 2. 确认签名算法是 HS256 3. 使用 JWT Secret 验证签名 4. 检查 Token 是否过期 5. 全部通过 → 返回 Claims 6. 任一失败 → 返回错误 这里不是“解密 Token”，因为 JWT Payload 没有被加密。服务器做的是解析内容并验证签名。\n还需要注意：ParseToken() 只检查格式、签名和过期时间，不检查 Token 是否已被撤销。\n五、Refresh 流程 # Access Token 过期后，可以使用 Refresh Token 换取新的 Access Token：\n客户端：Access Token 过期，这是 Refresh Token，请签发新的 Access Token 服务器： 1. 使用 Refresh Token 查询 Redis 中的 refresh:\u0026lt;token\u0026gt; 2. 查询成功后得到 accountID 3. 查询数据库，确认 Refresh Token 与账户记录匹配 4. 生成新的 Access Token 5. 将新 Token 写入数据库和 Redis 6. 返回给客户端 可以类比为：短期门禁卡过期后，使用长期凭证到前台换一张新卡。\n关键点是：使用 Refresh Token 换取 Access Token，而不是反过来。\n六、Token 撤销 # JWT 的签名和过期时间一旦确定，单靠 ParseToken() 无法让一个尚未过期的 Token 提前失效。因此项目额外在 Redis 和 MySQL 中保存当前有效 Token。\n登出时删除旧 Token：\n删除 Redis 中的三个 Key： account:\u0026lt;id\u0026gt; → accessToken account:\u0026lt;id\u0026gt;:refresh → refreshToken refresh:\u0026lt;token\u0026gt; → accountID 清空 MySQL 中的字段： token refresh_token 删除后，即使旧 JWT 的签名正确且尚未过期，中间件也无法在 Redis 或 MySQL 中匹配到它，因此会拒绝请求。\n修改密码时也可以使用相同思路：\n清空旧 Token → 签发新 Token → 让其他设备上的旧登录状态立即失效 七、JWTAuth 中间件 # 文件位置：internal/middleware/jwt/jwt.go\n处理流程：\n1. 从请求头读取 Authorization 2. 检查格式是否为 Bearer \u0026lt;token\u0026gt; 3. 调用 auth.ParseToken() 验证格式、签名和过期时间 4. 调用 check() 检查 Token 是否仍是当前有效 Token 5. 验证通过后将用户信息写入 Gin Context 客户端发送：\nAuthorization: Bearer *** check()：为什么同时查询 Redis 和 MySQL # 第一步：查询 Redis 查到 → 对比请求 Token 是否为当前有效 Token 相同 → 放行 不同 → 拒绝，Token 已被替换或撤销 Redis 未命中或不可用 → 查询 MySQL 第二步：查询 MySQL 查到账户 → 对比数据库保存的 Token 相同 → 放行，并回填 Redis 不同 → 拒绝 Redis 是缓存，速度快但可能未命中或不可用；MySQL 是持久化的数据来源，可以作为兜底。MySQL 本身也可能发生故障，因此更准确的说法是“Redis 失败时降级查询 MySQL”，而不是“MySQL 永远可用”。\ncheck() 主要完成两件事：\n将请求中的 Token 与服务器保存的当前 Token 对比，判断是否已被撤销 将用户信息写入 gin.Context，方便后续 Handler 使用 八、两种鉴权 # JWTAuth（硬鉴权）： 没有 Token 或 Token 无效 → 拒绝请求 SoftJWTAuth（软鉴权）： 没有 Token → 继续放行 有 Token且有效 → 识别用户身份后放行 项目中的使用示例：\nJWTAuth（必须登录）： /logout /rename /uploadAvatar SoftJWTAuth（不强制登录）： /feed/listLatest /feed/listByPopularity 用户没有 Token 也能浏览 Feed；携带有效 Token 时，系统可以识别用户并提供个性化结果。\n可以类比为：\nJWTAuth → 会员俱乐部，没有会员卡不能进入 SoftJWTAuth → 商场，没有会员卡也能进入，有会员卡则能获得个性化服务 九、面试要点 # JWT 如何生成：Claims + HS256 + JWT Secret 签名 JWT 不等于加密：Payload 可以读取，签名用于防篡改 双 Token：短期 Access Token + 长期 Refresh Token Refresh 流程：使用 Refresh Token 换取新的 Access Token Token 撤销：清理 Redis 与 MySQL 中保存的 Token，使旧 JWT 立即失效 中间件 check()：优先查询 Redis，未命中或不可用时查询 MySQL 两种鉴权：JWTAuth 强制登录，SoftJWTAuth 允许匿名访问 这一套设计实际上让 JWT 从“完全无状态”变成了“带服务端状态的认证方案”。它牺牲了一部分无状态带来的简单扩展能力，换取了主动登出、修改密码后强制失效和单会话控制等能力。\n","date":"2026年7月25日","externalUrl":null,"permalink":"/MyBlog/p/go-feed-authentication/","section":"Posts","summary":"","title":"Go 项目反推：Feed 流系统实战——认证体系","type":"posts"},{"content":"","date":"2026年7月25日","externalUrl":null,"permalink":"/MyBlog/tags/gpu/","section":"Tags","summary":"","title":"GPU","type":"tags"},{"content":"","date":"2026年7月25日","externalUrl":null,"permalink":"/MyBlog/tags/jwt/","section":"Tags","summary":"","title":"JWT","type":"tags"},{"content":"","date":"2026年7月25日","externalUrl":null,"permalink":"/MyBlog/tags/tilelang/","section":"Tags","summary":"","title":"TileLang","type":"tags"},{"content":"","date":"2026年7月25日","externalUrl":null,"permalink":"/MyBlog/tags/token/","section":"Tags","summary":"","title":"Token","type":"tags"},{"content":"","date":"2026年7月25日","externalUrl":null,"permalink":"/MyBlog/tags/triton/","section":"Tags","summary":"","title":"Triton","type":"tags"},{"content":"","date":"2026年7月25日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%B9%B6%E8%A1%8C%E8%AE%A1%E7%AE%97/","section":"Tags","summary":"","title":"并行计算","type":"tags"},{"content":" 一、学习目标 # 梯度下降如何更新参数 $w$ 和 $b$？ 导数项为什么能指引下降方向？ 学习率 $\\alpha$ 应该如何理解和选择？ 如何用梯度下降训练一元线性回归模型？ 二、梯度下降算法 # 梯度下降通过不断调整模型参数，使成本函数 $J(w,b)$ 尽可能小。\n参数更新公式为：\n$$w := w - \\alpha \\frac{\\partial J(w,b)}{\\partial w}$$$$b := b - \\alpha \\frac{\\partial J(w,b)}{\\partial b}$$不断重复以上更新，直到算法收敛。\n符号含义 # 符号 含义 $w, b$ 模型参数 $J(w,b)$ 成本函数，衡量预测误差 $\\alpha$ 学习率，控制每次更新的步长 $\\frac{\\partial J}{\\partial w}$ 成本函数相对于 $w$ 的偏导数 $\\frac{\\partial J}{\\partial b}$ 成本函数相对于 $b$ 的偏导数 $:=$ 赋值，用右侧结果更新左侧变量 赋值与相等的区别 # $w := w - \\alpha \\frac{\\partial J}{\\partial w}$ 表示“计算右边的结果，然后把结果存入 $w$”，而不是断言左右两边在数学上永远相等。\n在 Python 中，= 是赋值，== 是相等性判断。\n三、为什么必须同时更新 $w$ 和 $b$ # 梯度下降要求两个参数基于同一组旧参数进行更新。\n正确做法：\ntemp_w = w - alpha * dj_dw # dj_dw 和 dj_db 都根据更新前的 w, b 计算 temp_b = b - alpha * dj_db w = temp_w b = temp_b 不正确的顺序更新：\ntemp_w = w - alpha * dj_dw w = temp_w # w 已被更新 temp_b = b - alpha * dj_db # 此时计算用的是新的 w，不是旧的 b = temp_b 问题在于：计算新 $b$ 时，$w$ 已经被更新，两个参数不再基于同一个旧状态进行计算。标准梯度下降所指的是同步更新。\n四、导数项的直观含义 # 为了理解导数，暂时只考虑一个参数：\n$$w := w - \\alpha \\frac{dJ(w)}{dw}$$导数表示成本曲线在当前位置的斜率。\n导数为正 # $$\\frac{dJ}{dw} \u003e 0 \\implies w_{\\text{new}} = w - \\text{正数}$$$w$ 变小，在图像上向左移动。当位于最低点右侧时，向左移动能够降低成本。\n导数为负 # $$\\frac{dJ}{dw} \u003c 0 \\implies w_{\\text{new}} = w - \\alpha(\\text{负数}) = w + \\text{正数}$$$w$ 变大，在图像上向右移动。当位于最低点左侧时，向右移动能够降低成本。\n导数为零 # 在局部最小值处，$\\frac{dJ}{dw} = 0$，于是 $w_{\\text{new}} = w$，参数保持不变。梯度下降到达最低点后会自然停止移动。\n核心直觉 # 梯度下降之所以使用“减去导数”，是因为导数指向函数增长最快的方向，而导数的反方向能够降低成本。\n五、学习率 $\\alpha$ # 学习率控制每次参数更新的步长，通常取较小的正数，例如 $\\alpha = 0.01$。\n学习率太小 # 每次更新的步长很小 成本通常仍会下降，但需要大量迭代 训练速度非常慢 学习率太大 # 参数可能跨过最低点 成本可能反而增加 参数可能在最低点两侧来回震荡 算法可能无法收敛，甚至发散 为什么固定学习率也能收敛 # 当参数逐渐接近最低点时，成本函数的斜率会越来越小：$\\left|\\frac{dJ}{dw}\\right| \\to 0$\n即使学习率 $\\alpha$ 保持不变，实际更新量 $\\alpha \\frac{dJ}{dw}$ 也会自然变小。因此梯度下降通常表现为：\n离最低点较远时步长较大 接近最低点时步长逐渐减小 最终停留在最低点附近 六、一元线性回归模型 # 模型为：\n$$f_{w,b}(x) = wx + b$$其中 $x$ 是输入特征（如房屋面积），$f_{w,b}(x)$ 是预测值（如预测房价），$w$ 是斜率，$b$ 是截距。\n七、平方误差成本函数 # 使用 $m$ 个训练样本时，成本函数为：\n$$J(w,b) = \\frac{1}{2m} \\sum_{i=1}^{m} \\left(f_{w,b}(x^{(i)}) - y^{(i)}\\right)^2$$其中 $x^{(i)}$ 是第 $i$ 个样本的输入，$y^{(i)}$ 是真实值，$m$ 是训练样本数量。\n前面的 $\\frac{1}{2}$ 是为了在求导时抵消平方项产生的系数 2，使梯度公式更简洁。\n八、线性回归的梯度公式 # 成本函数相对于 $w$ 的偏导数：\n$$\\frac{\\partial J(w,b)}{\\partial w} = \\frac{1}{m} \\sum_{i=1}^{m} \\left(f_{w,b}(x^{(i)}) - y^{(i)}\\right) x^{(i)}$$成本函数相对于 $b$ 的偏导数：\n$$\\frac{\\partial J(w,b)}{\\partial b} = \\frac{1}{m} \\sum_{i=1}^{m} \\left(f_{w,b}(x^{(i)}) - y^{(i)}\\right)$$两者的区别：$w$ 的导数末尾需要乘 $x^{(i)}$，$b$ 的导数不需要。\n九、完整的梯度下降算法 # 每轮迭代先计算：\n$$d_w = \\frac{1}{m} \\sum_{i=1}^{m} \\left(wx^{(i)} + b - y^{(i)}\\right) x^{(i)}$$$$d_b = \\frac{1}{m} \\sum_{i=1}^{m} \\left(wx^{(i)} + b - y^{(i)}\\right)$$然后同步更新：\n$$w := w - \\alpha \\, d_w$$$$b := b - \\alpha \\, d_b$$重复以上过程，直到成本不再明显下降，或者参数变化已经非常小。\n十、收敛、局部最小值与全局最小值 # 收敛 # 梯度下降“收敛”通常表示：成本函数不再明显下降，$w$ 和 $b$ 的变化越来越小，参数已经接近某个最小值。\n局部最小值 vs 全局最小值 # 局部最小值：某个点比附近其他点都低，但不一定是整个函数的最低点 全局最小值：在所有可能的参数取值中，成本函数值最低的点 线性回归的优势 # 线性回归的平方误差成本函数是凸函数（碗形函数），具有以下性质：\n只有一个全局最小值 不存在多个不同的局部最小值 只要学习率选择合理，梯度下降就能收敛到全局最小值 十一、批量梯度下降 # 上述算法属于批量梯度下降（Batch Gradient Descent）。“批量”表示每次更新参数时都使用全部 $m$ 个训练样本（$\\sum_{i=1}^{m}$），即每轮更新都会遍历整个训练集。\n其他梯度下降方法可能每次只使用一个训练样本或一小批训练样本，但当前的一元线性回归使用的是完整训练集。\n十二、Python 实现 # 计算梯度 # def compute_gradient(x, y, w, b): m = x.shape[0] dj_dw = 0 dj_db = 0 for i in range(m): f_wb = w * x[i] + b # 预测值 error = f_wb - y[i] # 预测误差 dj_dw += error * x[i] # w 的梯度需要乘 x[i] dj_db += error # b 的梯度不需要乘 x[i] dj_dw /= m dj_db /= m return dj_dw, dj_db 计算成本 # def compute_cost(x, y, w, b): m = x.shape[0] cost = 0 for i in range(m): f_wb = w * x[i] + b cost += (f_wb - y[i]) ** 2 return cost / (2 * m) 梯度下降主函数 # def gradient_descent(x, y, w_in, b_in, alpha, num_iters, cost_function, gradient_function): J_history = [] p_history = [] w = w_in b = b_in for i in range(num_iters): # 先计算两个梯度（基于当前的 w, b） dj_dw, dj_db = gradient_function(x, y, w, b) # 同步更新参数 w = w - alpha * dj_dw b = b - alpha * dj_db # 保存代价和参数（用于可视化） if i \u0026lt; 100000: J_history.append(cost_function(x, y, w, b)) p_history.append([w, b]) # 定期输出训练过程 if i % math.ceil(num_iters / 10) == 0: print(f\u0026#34;Iteration {i:4}: Cost {J_history[-1]:0.2e} \u0026#34; f\u0026#34;w: {w:0.3e}, b: {b:0.5e}\u0026#34;) return w, b, J_history, p_history 返回值：训练后的 w、b，每次迭代的代价 J_history，每次迭代的参数 p_history。\n十三、运行实验 # 训练数据 # x_train = np.array([1.0, 2.0]) # 房屋面积（千平方英尺） y_train = np.array([300.0, 500.0]) # 房屋售价（千美元） 训练参数 # w_init = 0 b_init = 0 iterations = 10000 alpha = 1.0e-2 运行 # w_final, b_final, J_hist, p_hist = gradient_descent( x_train, y_train, w_init, b_init, alpha, iterations, compute_cost, compute_gradient ) 最终结果约为 $w \\approx 199.99$，$b \\approx 100.01$，即模型为 $f(x) = 200x + 100$。\n梯度下降的收敛特点 # 成功运行时可以观察到：\n代价在开始阶段迅速下降 dj_dw 和 dj_db 的绝对值逐渐减小 越接近最低点，梯度越小 梯度变小后，参数更新速度也会变慢 代价应持续下降并逐渐稳定 虽然学习率 $\\alpha$ 保持不变，但实际更新量 = $\\alpha \\times$ 梯度，梯度变小时更新幅度自然变小。\n十四、可视化 # 代价变化曲线 # fig, (ax1, ax2) = plt.subplots(1, 2, constrained_layout=True, figsize=(12, 4)) ax1.plot(J_hist[:100]) # 训练初期：代价下降很快 ax2.plot(1000 + np.arange(len(J_hist[1000:])), J_hist[1000:]) # 训练后期：下降较慢 ax1.set_title(\u0026#34;Cost vs. iteration (start)\u0026#34;) ax2.set_title(\u0026#34;Cost vs. iteration (end)\u0026#34;) ax1.set_ylabel(\u0026#34;Cost\u0026#34;) ax2.set_ylabel(\u0026#34;Cost\u0026#34;) ax1.set_xlabel(\u0026#34;Iteration step\u0026#34;) ax2.set_xlabel(\u0026#34;Iteration step\u0026#34;) plt.show() 分开绘制是因为训练初期和后期下降速度差异大，用不同范围可以更清楚地观察变化。\n梯度下降路径（等高线图） # fig, ax = plt.subplots(1, 1, figsize=(12, 6)) plt_contour_wgrad(x_train, y_train, p_hist, ax) plt.show() 等高线代表不同的代价值，箭头代表参数 $(w, b)$ 的更新路径。可以观察到：参数不断向最低点移动，开始时梯度大步幅也大，接近最低点时梯度变小步幅缩短。\n十五、模型预测 # 训练完成后，使用 $\\hat{y} = w_{\\text{final}} x + b_{\\text{final}}$ 进行预测：\nprint(f\u0026#34;1000 sqft: {w_final * 1.0 + b_final:.1f} Thousand dollars\u0026#34;) print(f\u0026#34;1200 sqft: {w_final * 1.2 + b_final:.1f} Thousand dollars\u0026#34;) print(f\u0026#34;2000 sqft: {w_final * 2.0 + b_final:.1f} Thousand dollars\u0026#34;) 房屋面积 预测售价 1000 平方英尺 300 千美元 1200 平方英尺 340 千美元 2000 平方英尺 500 千美元 虽然 1200 平方英尺不在训练数据中，模型仍可以根据学到的线性关系进行预测。\n十六、学习率的影响 # 合适的学习率 # 代价持续下降，参数逐渐靠近最优值，算法最终收敛 学习率过小 # 更新步幅很小，算法能够稳定收敛，但训练速度较慢 学习率过大 # 参数可能跨过最低点，$w$ 和 $b$ 在正负之间振荡 梯度符号反复变化，参数绝对值越来越大 代价不断上升，算法最终发散 判断学习率过大的典型信号：\n代价不降反升 参数绝对值越来越大 梯度不断改变符号 参数在最低点两侧大幅振荡 例如将学习率改为 0.8，只跑 10 次迭代就能观察到发散过程。\n十七、常见错误与检查方法 # 没有同步更新参数：应先计算新 $w$ 和新 $b$，再统一赋值 学习率过小：成本下降但极其缓慢 → 适当增大 $\\alpha$ 学习率过大：成本上下震荡或越来越大 → 减小 $\\alpha$ 导数公式漏乘 $x^{(i)}$：计算 $w$ 的梯度时必须包含 $\\text{error} \\times x^{(i)}$，$b$ 的梯度只有误差项 没有观察成本变化：训练过程中应定期计算 $J(w,b)$，正常情况下成本应总体持续下降 十八、核心知识总结 # 梯度下降的目标是通过调整参数来最小化成本函数 学习率 $\\alpha$ 控制每次更新的步长 导数的符号决定参数移动方向，大小影响移动幅度 $w$ 和 $b$ 必须基于同一组旧值同步更新 学习率太小导致收敛缓慢，太大导致震荡或发散 接近最低点时导数自然变小，更新步长也会变小 线性回归的平方误差成本函数是凸函数，只有一个全局最小值 批量梯度下降每次更新都会使用全部训练样本 训练完成后，使用 $f_{w,b}(x) = wx + b$ 对新数据进行预测 ","date":"2026年7月25日","externalUrl":null,"permalink":"/MyBlog/p/coursera-ml-gradient-descent/","section":"Posts","summary":"","title":"机器学习基础：用梯度下降法训练模型","type":"posts"},{"content":"","date":"2026年7月25日","externalUrl":null,"permalink":"/MyBlog/tags/%E8%AE%A4%E8%AF%81/","section":"Tags","summary":"","title":"认证","type":"tags"},{"content":"","date":"2026年7月25日","externalUrl":null,"permalink":"/MyBlog/tags/%E7%BA%BF%E6%80%A7%E5%9B%9E%E5%BD%92/","section":"Tags","summary":"","title":"线性回归","type":"tags"},{"content":"","date":"2026年7月25日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%AD%A6%E4%B9%A0%E7%8E%87/","section":"Tags","summary":"","title":"学习率","type":"tags"},{"content":"承接 Go 项目反推：Feed 流系统实战——从 Docker 到开源贡献，这一篇继续反推 feedsystem_video_go 的后端代码。这次不再从整体架构看项目，而是以 POST /account/login 为例，追踪一个请求从进来到返回的完整路径。\n路由基础 # router.go 是什么 # router.go = 路由表 = “地址簿”。它告诉服务器：什么方式 + 访问哪个路径 → 转发到哪个函数。\nPOST /account/login → accountHandler.Login POST /account/register → accountHandler.CreateAccount GET /healthz → 返回 {\u0026#34;status\u0026#34;:\u0026#34;ok\u0026#34;} 类比：router.go 是前台接待（“办 login 业务找张三”），handler.go 是张三的工位（“我来处理”）。\n路由组 # accountGroup := r.Group(\u0026#34;/account\u0026#34;) 创建一个路由组，前缀是 /account。组里的所有路由，路径前面自动加上前缀：\naccountGroup.POST(\u0026#34;/login\u0026#34;, ...) → 实际路径 POST /account/login accountGroup.POST(\u0026#34;/register\u0026#34;, ...) → 实际路径 POST /account/register 类比：把用户相关接口都放在 /account 这个“文件夹”下。\n路由注册 # accountGroup.POST(\u0026#34;/login\u0026#34;, loginLimiter, accountHandler.Login) 三个参数：\n\u0026#34;/login\u0026#34; → 路径（拼上 /account，变成 /account/login） loginLimiter → 限流中间件（先过这一关） accountHandler.Login → 最终处理请求的函数（过了限流才到这里） 请求流程：\nPOST /account/login 进来 ↓ loginLimiter（限流中间件） ↓ accountHandler.Login（handler 处理） 函数引用 vs 函数调用 # accountGroup.POST(\u0026#34;/login\u0026#34;, loginLimiter, accountHandler.Login) accountHandler.Login 是函数本身，不是调用。Go 里函数可以当变量传来传去，和 CS61A 的高阶函数一个意思：\naccountHandler.Login → 函数本身（把函数“交给”路由器，等有人请求时再调用） accountHandler.Login(c) → 调用函数（立刻执行） 类比：给你一张纸条写上张三的电话号码（函数引用） vs 现在立刻拨打电话（函数调用）。\n现在不执行，等有人发 POST /account/login 时，路由器才会调用 Login(c) 并传入请求信息。\n三层架构 # 结构创建 # router.go 第 40-42 行：\naccountRepository := account.NewAccountRepository(db) accountService := account.NewAccountService(accountRepository, cache) accountHandler := account.NewAccountHandler(accountService) 创建三个“工人”：\nRepository → 操作数据库的人（查表、写表） Service → 处理业务逻辑的人（校验密码、生成 Token） Handler → 接收请求的人（解析参数、返回结果） 依赖关系：db → Repository → Service → Handler → 路由\n类比：先招档案员（Repository）给数据库钥匙，再招业务员（Service）管档案员，再招前台（Handler）对接业务员，最后门口挂牌子（路由）：“办 login 业务找前台”。\nHandler 结构体 # type AccountHandler struct { accountService *AccountService } func NewAccountHandler(accountService *AccountService) *AccountHandler { return \u0026amp;AccountHandler{accountService: accountService} } AccountHandler 是一个结构体，里面有一个字段 accountService。NewAccountHandler 创建这个结构体并把 Service 存进去。\n分层的意义 # handler 只管：解析请求 → 调用 service → 返回结果 service 处理：校验密码 → 生成 Token → 写数据库 repo 处理： 操作数据库 handler 不关心密码怎么校验、Token 怎么生成，它只管“调用 service”。\n文件目录结构 # internal/http/router.go → 路由目录，负责“找谁” internal/account/handler.go → 用户模块，负责“怎么干” internal/account/service.go → 用户模块，负责“核心逻辑” internal/account/repo.go → 用户模块，负责“操作数据库” 按模块分目录：account 目录下放所有跟用户相关的代码，video 目录下放所有跟视频相关的代码。\nHandler 层：Login 函数 # handler.go 第 116-133 行：\nfunc (h *AccountHandler) Login(c *gin.Context) { var req LoginRequest // 声明容器 if err := c.ShouldBindJSON(\u0026amp;req); err != nil { ... } // 解析 JSON account, err := h.accountService.FindByUsername(...) // 查数据库存不存在 accessToken, refreshToken, err := h.accountService.Login(...) // 校验密码+生成Token c.JSON(200, LoginResponse{Token, RefreshToken, ...}) // 返回给客户端 } 完整流程：\n1. 解析客户端发的 JSON（用户名、密码） 2. 去数据库查这个用户存不存在 3. 存在的话，让 service 校验密码、生成 Token 4. 把 Token 返回给客户端 ShouldBindJSON # c.ShouldBindJSON(\u0026amp;req) 把客户端发的 JSON 解析到 req 变量里。客户端发 {\u0026quot;username\u0026quot;: \u0026quot;zhangsan\u0026quot;, \u0026quot;password\u0026quot;: \u0026quot;***\u0026quot;}，解析后 req.Username = \u0026quot;zhangsan\u0026quot;，req.Password = \u0026quot;123456\u0026quot;。\nShouldBindJSON vs json.NewDecoder：两个干的事一样，都是把 JSON 解析到变量里。c.ShouldBindJSON(\u0026amp;req) 是 Gin 封装的一行搞定；json.NewDecoder(r.Body).Decode(\u0026amp;req) 是原生写法，要三行还要检查 err。类比：自动挡 vs 手动挡。\nService 层：Login 函数 # handler 调用 service 的 Login：\naccessToken, refreshToken, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password) service.go 的 Login 做了 6 件事：\n1. 查数据库拿用户信息（FindByUsername） 2. bcrypt 校验密码 3. 生成 accessToken 和 refreshToken 4. Token 存进 MySQL（调用 repo.Login） 5. Token 存进 Redis（3 个 key） 6. 返回两个 Token ctx（context） # ctx = 请求的“身份证”，带着两个信息：\n超时时间：这个请求最多等多久 取消信号：客户端断开了，别再查了 每一层都要传 ctx，这样整条链路都能感知到“客户端还在不在”。\n类比：你去饭店点菜，等了 5 分钟走了。没有 ctx → 厨师还在炒，炒完发现你走了，菜倒掉。有 ctx → 服务员告诉厨师“客人走了”，厨师立刻停手。\nbcrypt 校验密码 # bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) 传入两个参数：数据库里存的加密密码（哈希）和用户这次输入的明文密码。bcrypt 把明文密码加密，和数据库里的对比，一样返回 nil（成功），不一样返回 error（失败）。\n为什么不存明文？数据库被黑了，黑客拿到的是加密字符串，反推不出原始密码。\n[]byte() = 把字符串转成字节数组，bcrypt 只接收这种格式。\n双 Token（JWT） # 登录成功返回两个 Token：\naccessToken → 短期有效（15分钟），用来证明“我是谁” refreshToken → 长期有效（7天），用来续期 accessToken 为什么两个？只用一个 token 的问题：设太短 → 用户每 15 分钟重新登录，体验差；设太长 → 被偷了就一直能用，不安全。双 token 解决了这个问题：accessToken 短，被偷了也很快过期；refreshToken 长，用户不用频繁登录；accessToken 过期了，用 refreshToken 换新的。\n类比：accessToken = 门禁卡（每天过期，重新刷卡续期），refreshToken = 身份证（长期有效，门禁卡丢了拿身份证补办）。\nToken 根据用户信息生成（不是纯随机）：\naccessToken → 包含用户ID + 用户名 + 过期时间（15分钟） refreshToken → 只包含用户ID + 过期时间（7天） 用密钥加密生成一串乱码字符串 为什么有 ID 又有用户名 # ID（account.ID） → 数据库主键，不可变，用来精确查找 用户名（username）→ 可以改，用来方便识别 类比：身份证号（ID）不可变，查账户用；姓名（username）可以改，写在凭证上让人看。生成 Token 时两个都存：ID 用来查人，用户名用来识别。\nRepo 层：Login 函数 # service 调用 repo 把 Token 存进 MySQL：\nas.accountRepository.Login(ctx, account.ID, accessToken, refreshToken) repo.go 里用 GORM 操作数据库：\nar.db.WithContext(ctx).Model(\u0026amp;Account{}).Where(\u0026#34;id = ?\u0026#34;, id).Updates( map[string]interface{}{\u0026#34;token\u0026#34;: token, \u0026#34;refresh_token\u0026#34;: refreshToken}) 翻译：在 account 表里，找到 id 等于 id 的那一行，更新 token 和 refresh_token。\n等价 SQL：\nUPDATE account SET token = \u0026#39;xxx\u0026#39;, refresh_token = \u0026#39;yyy\u0026#39; WHERE id = 1; GORM = 用 Go 代码描述“我要干什么”，GORM 帮你翻译成 SQL。\nRedis 缓存 # 登录成功后，Token 存两份：\nMySQL 存一份 → 保险，重启不丢，但慢 Redis 存一份 → 快，验证时直接查这里 Redis 存了 3 个 key：\nkey: \u0026#34;account:1\u0026#34; → accessToken TTL 24小时 key: \u0026#34;account:1:refresh\u0026#34; → refreshToken TTL 7天 key: \u0026#34;refresh:xxxxx\u0026#34; → accountID TTL 7天 为什么存 3 个？第 1 个验证 Token 时查，第 2 个刷新 Token 时查，第 3 个用 refreshToken 反查用户 ID。\nRedis 不可用时降级：跳过缓存，只靠 MySQL，功能不丢，只是慢一点。\n中间件 # 怎么执行的 # 中间件 = 挡在 handler 前面的一道关卡。两个关键函数：\nc.Next() → 放行，让请求继续走到下一个中间件或 handler c.Abort() → 拦截，不让请求继续走，handler 不会执行 类比：c.Next() = 安检通过，放你进去；c.Abort() = 安检不过，拦住你。\n为什么是两层函数 # func Limit(cache, name string, ...) gin.HandlerFunc { // 外层：传配置 return func(c *gin.Context) { // 内层：处理请求 c.Next() } } 外层传配置，内层处理请求。外层函数执行一次，创建中间件；内层函数每次有人访问都执行一次。\n类比：外层 = 装修奶茶店（执行一次，买机器、买原料）；内层 = 每天营业（执行无数次，客人来了做奶茶）。\ngin.HandlerFunc = Gin 要求中间件必须是这种格式的函数。\ngin.Context 的数据传递 # 中间件把用户 ID 塞进 c 里：\nc.Set(\u0026#34;accountID\u0026#34;, accountID) // 中间件：往 c 里存 handler 从 c 里取出来：\nvalue, exists := c.Get(\u0026#34;accountID\u0026#34;) // handler：从 c 里取 c 就是一个“传话筒”，中间件往里塞东西，handler 从里拿东西。c 是临时对象，只活在一次请求里，请求结束就没了，不会存到 Redis。\n类比：安检员检查完证件，在你手上盖个章“1号”（中间件 c.Set）；柜员看你手上的章，知道你是“1号”（handler c.Get）。\n错误到 HTTP 状态码的映射 # ClassifyHTTPStatus 就是一张对照表：\nerror 类型 → 状态码 → 含义 nil（没错误） → 200 → 成功 ErrUnauthorized（未登录） → 401 → 未授权 ErrValidation（参数错误） → 400 → 请求格式错 gorm.ErrRecordNotFound（查不到）→ 404 → 资源不存在 其他错误 → 500 → 服务器内部错误 handler 里用法：\nc.JSON(apierror.ClassifyHTTPStatus(err), gin.H{\u0026#34;error\u0026#34;: err.Error()}) 翻译：看一眼 error 是什么类型，返回对应的状态码。\nLogin 请求完整调用链 # 1. 用户发 POST /account/login 2. router.go 找到这条路由，先过 loginLimiter（限流中间件） 3. 进入 handler.go 的 Login 函数： - 解析 JSON（拿到 username 和 password） - 调用 service.FindByUsername（查用户存不存在） - 调用 service.Login（校验密码 + 生成 Token） 4. 进入 service.go 的 Login 函数： - 再查一次数据库（拿到密码哈希） - bcrypt 校验密码 - 生成 accessToken 和 refreshToken - 调用 repo.Login（把 Token 存 MySQL） - 把 Token 存进 Redis（3 个 key） 5. 进入 repo.go 的 Login 函数： - 用 GORM 把 Token 写进 account 表 6. Token 一层一层返回：repo → service → handler → 客户端 涉及 4 个文件：\nrouter.go → 注册路由，把路径和函数连起来 handler.go → 解析请求，调用 service service.go → 校验密码，生成 Token，存数据库和缓存 repo.go → 操作 MySQL 涉及 2 个存储：\nMySQL → 存用户信息和 Token（持久化，重启不丢） Redis → 缓存 Token（快，重启会丢） ","date":"2026年7月21日","externalUrl":null,"permalink":"/MyBlog/p/go-feed-request-lifecycle/","section":"Posts","summary":"","title":"Go 项目反推：Feed 流系统实战——请求生命周期","type":"posts"},{"content":"","date":"2026年7月21日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%88%90%E6%9C%AC%E5%87%BD%E6%95%B0/","section":"Tags","summary":"","title":"成本函数","type":"tags"},{"content":"线性回归是机器学习里最适合入门的模型之一：它足够简单，可以把输入、预测、参数、成本函数这些核心概念讲清楚；它也足够重要，因为后续更复杂的模型训练，仍然离不开这些基本思想。本篇整理 Coursera 机器学习课程中的线性回归内容，从房价预测案例出发，梳理模型函数、训练数据、成本函数和梯度下降之间的关系。\n线性回归的基本概念 # 线性回归（Linear Regression） 是一种监督学习模型，通过为数据拟合一条直线来预测连续数值。\n以房价预测为例：\n输入 x：房屋面积 输出 y：房屋价格 模型：一条尽可能贴合训练数据的直线 可将模型表示为：\n$$\\hat{y} = f_{w,b}(x) = wx + b$$其中：\n$x$：输入特征，例如房屋面积 $\\hat{y}$：模型预测的房屋价格 $w$：直线的斜率 $b$：直线的截距 因为只有一个输入特征，这种模型称为单变量线性回归（Univariate Linear Regression）。“单变量”表示模型只有一个输入变量，而不是只有一个参数，该模型仍然包含两个参数 $w$ 和 $b$。\n以后还可以使用多个输入特征预测房价，例如房屋面积、卧室数量、浴室数量、房屋年龄、地理位置等，属于多变量或多特征线性回归。\n训练集与符号约定 # 训练集 # 用于训练模型的数据称为训练集（Training Set）。\n房价预测的训练数据来自美国波特兰市，包含：\n房屋面积，单位为平方英尺 房屋售价，单位为千美元 同一组数据可以通过两种方式表示：\n散点图：横轴为房屋面积，纵轴为房屋价格，每个数据点对应一套已售房屋。如果训练集有 47 套房屋，图中就有 47 个数据点。\n数据表：\n房屋面积（平方英尺） 房屋价格（千美元） 2104 400 … … 表格中的每一行对应一个训练示例，也对应散点图中的一个数据点。例如面积 2104 平方英尺，售价 400 千美元，即 400,000 美元。\n符号约定 # 符号 含义 房价示例 $x$ 输入特征（Feature） 房屋面积 $y$ 真实值（Label / Target Variable） 房屋实际售价 $\\hat{y}$ 模型预测值 预测房价 $f_{w,b}(x)$ 参数为 $w, b$ 的模型对 $x$ 的预测 $\\hat{y} = wx + b$ $m$ 训练样本总数 47 $(x^{(i)}, y^{(i)})$ 第 $i$ 个训练样本 $(2104, 400)$ 一个完整的训练示例由输入和正确输出组成 $(x, y)$。第 $i$ 个训练示例表示为 $(x^{(i)}, y^{(i)})$，其中 $x^{(i)}$ 是第 $i$ 个样本的输入，$y^{(i)}$ 是第 $i$ 个样本的正确输出。\n需要特别注意：$x^{(2)}$ 表示“第二个训练示例的输入”，不是 $x$ 的平方。括号中的上标只是训练样本的编号。\n为什么称为监督学习 # 训练集中同时提供了输入（房屋面积）和正确输出（房屋实际售价）。每套已售房屋的数据都相当于向模型提供一个带答案的学习示例，因此这种学习方式称为“监督学习”。\n基本流程：\n收集包含输入和正确输出的数据 使用这些数据训练模型 模型学习输入与输出之间的规律 将新输入交给模型 模型预测相应的输出 参数 $w$ 和 $b$ 的作用 # 斜率 $w$ # $w$ 决定直线的倾斜程度：\nw \u0026gt; 0：直线向右上方倾斜，x 增大时预测值增大 w = 0：直线是水平的，预测值不随 x 变化 w \u0026lt; 0：直线向右下方倾斜，x 增大时预测值减小 在房价问题中，$w$ 可以理解为房屋面积每增加一个单位，预测价格增加多少。$w$ 也称为权重或系数。\n截距 $b$ # $b$ 决定直线与纵轴的交点。当 $x = 0$ 时：\n$$f(0) = b$$因此 $b$ 是模型在输入为零时的预测值。\n示例 # 当 $w = 0, b = 1.5$ 时：\n$$f(x) = 1.5$$无论输入是多少，预测结果始终为 1.5。\n当 $w = 0.5, b = 0$ 时：\n$$f(x) = 0.5x$$直线斜率为 0.5，经过原点。\n当 $w = 0.5, b = 1$ 时：\n$$f(x) = 0.5x + 1$$直线斜率仍为 0.5，但与纵轴相交于 1。\n假设 $f(x) = 0.1x + 50$，当房屋面积为 1250 平方英尺时：\n$$\\hat{y} = 0.1 \\times 1250 + 50 = 175$$预测价格为 175,000 美元。\n不同的 $w$ 和 $b$ 会形成不同的直线，也会产生不同的预测结果。\n学习算法的任务 # 训练线性回归模型的核心任务，是根据训练数据选择合适的 $w$ 和 $b$，使直线尽可能贴近数据点。\n完整过程可以表示为：\n$$\\{(x^{(i)}, y^{(i)})\\}_{i=1}^{m} \\longrightarrow \\text{学习算法} \\longrightarrow w, b \\longrightarrow f_{w,b}(x)$$也就是说：\n将训练数据交给学习算法 学习算法寻找合适的 $w$ 和 $b$ 得到模型 $f_{w,b}(x) = wx + b$ 用模型预测新输入对应的 $\\hat{y}$ 客户的房屋尚未出售，因此其真实价格不在训练集中。模型需要先从已售房屋的数据中学习，再预测这套房屋的价格。\n$y$ 与 $\\hat{y}$ 的区别 # $y$：实际目标值 # $y$ 表示训练数据中的真实答案。例如 $y = 400$ 表示某套房屋的实际售价为 400 千美元。\n$\\hat{y}$：预测值 # $\\hat{y}$ 读作 “y hat”，表示模型对 $y$ 的估计：\n$$\\hat{y} = f_{w,b}(x) = wx + b$$预测值不一定等于实际值：\n$$\\hat{y} \\neq y$$例如，模型预测某套房屋售价为 220,000 美元，但只有房屋真正售出后，才能知道真实售价 $y$。\n需要明确区分：\n$$\\boxed{y = \\text{真实值}, \\qquad \\hat{y} = \\text{预测值}}$$ 成本函数 # 为什么需要成本函数 # 不同的 $w$ 和 $b$ 会产生不同的直线。成本函数的作用是：\n衡量一组 $w, b$ 所产生的直线，对训练数据拟合得有多好。\n成本越小，通常说明预测值越接近真实值。\n预测误差 # 对于第 $i$ 个训练样本 $(x^{(i)}, y^{(i)})$，模型预测值为：\n$$\\hat{y}^{(i)} = f_{w,b}(x^{(i)})$$预测误差为：\n$$\\hat{y}^{(i)} - y^{(i)} = \\text{预测值} - \\text{真实值}$$例如，模型预测价格为 280，真实价格为 300：\n$$280 - 300 = -20$$误差为 $-20$，表示模型低估了价格。\n平方误差成本函数 # 线性回归常用的成本函数是平方误差成本函数：\n$$\\boxed{J(w,b) = \\frac{1}{2m} \\sum_{i=1}^{m} \\left(f_{w,b}(x^{(i)}) - y^{(i)}\\right)^2}$$也可以写成：\n$$J(w,b) = \\frac{1}{2m} \\sum_{i=1}^{m} \\left(\\hat{y}^{(i)} - y^{(i)}\\right)^2$$计算过程：\n计算每个样本的预测值 用预测值减去真实值 将误差平方 把所有平方误差相加 除以 $2m$ 为什么误差要平方 # 如果直接将误差相加，正负误差可能互相抵消：\n$$(-20) + 20 = 0$$但模型实际产生了两次误差。平方以后：\n$$(-20)^2 + 20^2 = 800$$因此：\n所有误差都会变成非负数 较大的误差会受到更明显的惩罚 成本不会因为正负抵消而错误地变小 为什么除以 $m$ 和 $2$ # 除以 $m$：$m$ 是训练样本数量。除以 $m$ 相当于计算平均误差，使成本不会仅仅因为训练数据变多而自动增大。\n再除以 $2$：主要是为了让后续求导和梯度下降的公式更简洁。是否除以 $2$ 不会改变最佳参数的位置。\n成本函数的直观示例 # 为了方便观察，暂时令 $b = 0$，模型简化为：\n$$f_w(x) = wx$$训练集为：\n$$(1, 1), \\quad (2, 2), \\quad (3, 3)$$ 当 $w = 1$ # 预测值为 1, 2, 3，全部等于真实值：\n$$J(1) = 0$$模型完美拟合训练数据。\n当 $w = 0.5$ # 预测值为 0.5, 1, 1.5。平方误差为：\n$$(0.5-1)^2 + (1-2)^2 + (1.5-3)^2 = 0.25 + 1 + 2.25 = 3.5$$$$J(0.5) = \\frac{3.5}{2 \\times 3} \\approx 0.58$$ 当 $w = 0$ # 所有预测值都是 0：\n$$J(0) = \\frac{1^2 + 2^2 + 3^2}{6} = \\frac{14}{6} \\approx 2.33$$ 当 $w = -0.5$ # 直线方向与数据趋势相反：\n$$J(-0.5) = 5.25$$ 结果对比 # $w$ $J(w)$ 拟合情况 $-0.5$ $5.25$ 很差 $0$ $2.33$ 较差 $0.5$ $0.58$ 较好 $1$ $0$ 最佳 这个例子中的最佳参数是 $w = 1$。\n模型函数与成本函数的区别 # 两者容易混淆，需要明确区分：\n模型函数 $f_{w,b}(x) = wx + b$ # 输入是 $x$（特征），输出是预测值 $\\hat{y}$ 模型图的坐标轴：横轴 $x$（房屋面积），纵轴 $y$ 或 $\\hat{y}$（价格） 回答的问题：“这套房子预测多少钱？” 成本函数 $J(w,b)$ # 输入是模型参数 $w, b$，输出是模型的成本 成本图的坐标轴是参数和成本，而不是房屋面积与价格 回答的问题：“这组模型参数整体表现有多差？” 成本函数的图像 # 只有参数 $w$ 时（$b = 0$） # 成本函数只有一个参数 $J(w)$，通常呈 U 形曲线：\n成本 J ↑ | \\ / | \\_____/ +------------→ w 最小值 最低点对应成本最小的 $w$。\n同时使用 $w$ 和 $b$ 时 # 完整模型包含两个参数 $J(w,b)$，这时成本函数是一个三维曲面，通常类似碗形：\n一个方向代表 $w$ 一个方向代表 $b$ 曲面的高度代表成本 $J(w,b)$ 碗底对应成本函数的最小值，也就是最佳的 $w, b$。\n等高线图 # 三维成本函数也可以用二维的等高线图表示。每条椭圆线表示成本相同的一组 $w, b$：\n$$J(w_1, b_1) = J(w_2, b_2)$$可以把等高线图理解成从正上方观察碗形曲面：\n外层椭圆：成本通常较高 越靠近中心：成本通常越低 最内层椭圆中心：成本最小值附近 不同的 $w, b$ 即使成本相同，也可能对应不同的模型直线。\n训练目标与梯度下降 # 训练目标 # 线性回归最终需要解决的问题是：\n$$\\boxed{\\min_{w,b} J(w,b)}$$也就是找到一组 $w, b$，使成本函数尽可能小。\n完整逻辑：\n选择 w, b ↓ 得到直线 f(x) = wx + b ↓ 计算每个样本的预测误差 ↓ 计算成本 J(w,b) ↓ 不断调整 w, b ↓ 找到成本最小的参数 为什么需要梯度下降 # 理论上可以手动尝试很多组 $w, b$，计算每一组参数的成本，再选择成本最低的一组。但这种方法效率非常低，当模型参数很多时，几乎无法手动完成。\n因此需要一种能够自动调整参数、寻找成本函数最小值的算法：\n$$\\boxed{\\text{梯度下降（Gradient Descent）}}$$梯度下降不仅用于线性回归，也是训练神经网络和许多复杂人工智能模型的基础算法。\n成本函数的代码实现 # def compute_cost(x, y, w, b): m = x.shape[0] # 训练样本数量 cost_sum = 0 for i in range(m): f_wb = w * x[i] + b # 第 i 个样本的预测值 cost = (f_wb - y[i]) ** 2 # 平方误差 cost_sum = cost_sum + cost # 累加 total_cost = cost_sum / (2 * m) # 计算平均成本 return total_cost 逐步理解：\nm = x.shape[0]：获取训练样本数量 cost_sum = 0：准备变量，累计所有样本的平方误差 for i in range(m)：逐个处理训练样本 f_wb = w * x[i] + b：计算预测值 $\\hat{y}^{(i)} = wx^{(i)} + b$ cost = (f_wb - y[i]) ** 2：计算预测值和真实价格的平方误差 cost_sum = cost_sum + cost：把当前样本的误差加入总误差 total_cost = cost_sum / (2 * m)：按照成本函数公式计算最终成本 对应公式：\n$$J(w,b) = \\frac{1}{2m} \\sum_{i=0}^{m-1} \\left(wx^{(i)} + b - y^{(i)}\\right)^2$$ 验证示例 # import numpy as np x_train = np.array([1.0, 2.0]) y_train = np.array([300.0, 500.0]) w = 200 b = 100 两个预测分别是：\n$$200 \\times 1 + 100 = 300$$$$200 \\times 2 + 100 = 500$$预测值与真实值完全相同：\ncompute_cost(x_train, y_train, 200, 100) # 结果：0.0 这说明 $w = 200, b = 100$ 能完美拟合这两个数据点。\n关键理解：\ncompute_cost() 只负责评价一组 $w, b$ 好不好 成本越小，模型对训练数据的拟合通常越好 两个点可以被一条直线完美穿过，因此成本可以为零；多个不共线的数据点无法全部完美命中，因此最低成本通常大于零 为什么先学线性模型 # 现实中的数据关系不一定是直线，也可能是曲线、抛物线或更复杂的非线性关系。但线性模型具有以下优点：\n数学形式简单 容易理解和实现 便于观察模型参数的作用 是学习复杂机器学习模型的基础 因此，课程首先从线性函数入手，之后再扩展到非线性模型。\n","date":"2026年7月21日","externalUrl":null,"permalink":"/MyBlog/p/coursera-ml-linear-regression/","section":"Posts","summary":"","title":"机器学习基础：线性回归模型","type":"posts"},{"content":"","date":"2026年7月21日","externalUrl":null,"permalink":"/MyBlog/tags/%E8%AF%B7%E6%B1%82%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F/","section":"Tags","summary":"","title":"请求生命周期","type":"tags"},{"content":"机器学习的任务看起来五花八门：预测房价、识别肿瘤、给新闻分组、发现异常交易。理解这些问题的第一步，是分清数据中有没有正确答案。本篇整理 Coursera 机器学习课程的基础内容，从这个区别出发，梳理监督学习与无监督学习的核心概念和典型应用。\n监督学习 # 监督学习（Supervised Learning）让算法学习从输入 x 到输出 y 的映射：\n$$x \\longrightarrow y$$也可以表示为：\n$$\\hat{y}=f(x)$$ x：输入或特征（Feature） y：正确答案，也称标签（Label） f：模型从数据中学习到的映射关系 \\hat{y}：模型给出的预测结果 监督学习的核心特点是：训练数据中同时包含输入和正确答案。一条训练样本可以表示为 $(x,y)$。之所以称为“监督”学习，是因为正确标签 y 为算法提供了学习目标。\n训练与预测 # 训练阶段向算法提供大量带标签的数据：\n$$(x^{(1)},y^{(1)}),(x^{(2)},y^{(2)}),\\ldots,(x^{(m)},y^{(m)})$$算法观察输入与输出之间的关系，学习得到模型 f。训练完成后，将模型从未见过的新输入 x 交给模型，模型根据学到的规律输出预测结果 $\\hat{y}$。\n模型的目标不是简单记住训练数据，而是对未见过的数据也能作出准确预测。这种能力称为泛化能力（Generalization）。\n典型应用 # 应用 输入 x 输出 y 垃圾邮件过滤 邮件内容 垃圾邮件 / 非垃圾邮件 语音识别 音频片段 对应文字 机器翻译 源语言文本 目标语言文本 在线广告 广告信息与用户信息 用户是否会点击 自动驾驶 图像、雷达等传感器数据 周围车辆位置 工业视觉检测 产品照片 有缺陷 / 无缺陷 房价预测 房屋面积等信息 房屋价格 每个应用的本质，都是从大量已标注样本中学习输入与输出之间的关系。\n回归：预测一个数值 # 回归（Regression）是监督学习的一种，目标是预测一个可以连续变化的数值。\n房价预测案例 # 假设输入是房屋面积，输出是房屋价格，现在需要预测一套 750 平方英尺的房屋值多少钱。\n最简单的方法是拟合一条直线：\n$$f(x)=wx+b$$ w：斜率，表示面积变化对价格的影响 b：截距 直线模型可能预测房屋价值约为 15 万美元。也可以拟合一条更复杂的曲线：\n$$f(x)=w_2x^2+w_1x+b$$曲线模型可能预测房屋价值接近 20 万美元。但不能因为某个模型给出的价格更高就选择它，而应该根据模型在数据上的客观表现进行选择。\n模型太简单，可能无法充分学习规律；模型太复杂，则可能只记住训练数据，也就是发生过拟合（Overfitting），导致它在新数据上表现不好。\n常见回归任务包括预测房价、气温、商品销量、运输时间和用户消费金额。它们的输出都可以在一定范围内连续变化。\n分类：预测一个类别 # 分类（Classification）也是监督学习的一种，目标是预测一个离散类别。输出只能从有限数量的类别中选择，例如：\n垃圾邮件 / 非垃圾邮件 有缺陷 / 无缺陷 猫 / 狗 患病 / 未患病 乳腺癌检测案例 # 假设要开发一个辅助医生诊断乳腺癌的系统，根据患者检查数据判断肿瘤属于：\n良性（Benign）：不是癌症，危险程度相对较低 恶性（Malignant）：属于癌症，需要及时治疗 最简单的情况下，只使用肿瘤大小作为输入：\nx：肿瘤大小 y：良性（0）或恶性（1） 每条训练数据可以表示为：\n(1.2, 0) (2.5, 0) (4.8, 1) (6.1, 1) 仅靠肿瘤大小可能不够准确，还可以同时使用患者年龄、肿块厚度、细胞大小均匀性和细胞形状均匀性等特征。此时，输入是一个包含多个特征的向量：\n$$x=(x_1,x_2,\\ldots,x_n)$$监督学习中的 x 不一定是一个数字，也可以是一组特征。\n决策边界 # 当输入包含肿瘤大小和患者年龄时，可以将样本画在二维坐标中：横轴是肿瘤大小，纵轴是患者年龄，圆圈表示良性，叉号表示恶性。\n分类算法会尝试找到一条决策边界（Decision Boundary），将两类样本区分开来。新患者出现时，模型根据其特征位于边界的哪一侧预测类别。决策边界不一定是直线，也可能是曲线或更复杂的形状。\n回归与分类的区别 # 对比项 回归 分类 预测目标 连续数值 离散类别 输出范围 可连续变化 有限个类别 典型输出 183000 良性 / 恶性 数字之间是否有连续意义 有 通常没有 判断标准是：模型在回答“具体是多少”，还是“属于哪一类”？\n分类中的类别也可以用数字表示，例如 0 表示良性、1 表示恶性，但这些数字只是类别编号，不代表连续的数量关系。手写数字识别虽然输出 0 到 9，仍然属于分类，因为模型只需要从十个类别中选择，而不是预测任意连续数值。\n二分类与多分类 # 只有两个可能类别的问题称为二分类（Binary Classification）：\n问题 类别 垃圾邮件检测 垃圾 / 正常 欺诈检测 欺诈 / 正常 疾病检测 患病 / 未患病 广告点击预测 点击 / 不点击 存在三个及以上类别的问题称为多分类（Multiclass Classification）：\n图片分类：猫 / 狗 / 鸟 新闻分类：体育 / 财经 / 科技 / 娱乐 手写数字识别：0～9 疾病诊断：疾病 A / 疾病 B / 疾病 C 类别可以用数字编号，也可以用文字表示。数字在这里只是标签，不能认为“狗 = 猫 + 1”。\n无监督学习 # 无监督学习（Unsupervised Learning）的数据只有输入 x，没有输出标签 y：\n$$x \\quad \\text{而不是} \\quad (x,y)$$算法需要自行从数据中寻找：\n隐藏的结构 相似的数据群体 数据分布规律 异常的数据点 更简洁的数据表示 “无监督”表示训练数据没有为每个输入提供正确答案，并不代表算法不受控制，也不代表它比监督学习差。\n与监督学习的对比 # 对比项 监督学习 无监督学习 输入数据 输入 x 和标签 y 只有输入 x 是否有正确答案 有 没有 学习目标 学习 x 到 y 的映射 发现数据中的结构和模式 典型任务 回归、分类 聚类、异常检测、降维 示例 房价预测、疾病诊断 客户分群、新闻聚类 以乳腺癌检测为例：如果数据包含患者年龄、肿瘤大小和良性/恶性标签，就可以用监督学习预测诊断结果；如果只有患者年龄和肿瘤大小而没有标签，算法只能寻找患者之间可能存在的群体或规律。\n聚类：寻找相似群体 # 聚类（Clustering）的目标是将相似的数据点自动分配到同一个簇（Cluster）中。\n它有三个特点：\n数据没有预先定义的类别标签 算法根据数据之间的相似性进行分组 分组结果通常需要人结合领域知识解释 “相似”取决于选择了哪些特征，以及算法如何计算数据之间的距离。\n谷歌新闻 # 谷歌新闻每天处理大量报道。如果多篇文章都提到“熊猫、双胞胎、动物园、日本”，聚类算法可能判断它们讲述的是同一事件，并将它们归为一组。\n没有员工提前告诉算法哪些关键词一定属于同一主题。新闻内容每天都在变化，算法必须自动判断当天出现了哪些主题，以及哪些文章正在讨论同一件事。\nDNA与基因数据 # DNA 微阵列数据中，每一列表示一个人的基因活动，每一行表示一个特定基因。每个人可以表示为大量基因特征组成的向量。\n聚类算法能够将基因表达模式相似的人分到同一组。算法不知道这些群体应该叫什么，只是在回答：这些基因数据中是否存在自然形成的不同类型？\n客户细分 # 公司可以根据年龄、地区、购买记录和浏览行为等数据，把相似客户划分到不同细分市场。\n例如，一个在线学习社区可能发现：一组用户希望提升技能，一组希望获得晋升或新工作，另一组希望了解 AI 对自身行业的影响。企业可以据此推荐内容并设计服务，这类应用称为市场细分（Market Segmentation）。\n异常检测：寻找偏离正常模式的数据 # 异常检测（Anomaly Detection）的目标是发现与大多数数据明显不同的异常样本。\n在金融系统中，一笔交易可能表现出以下异常：\n交易金额特别大 交易地点异常 短时间内连续交易 登录设备突然变化 消费行为与过去完全不同 这些异常可能是欺诈的信号。常见应用还包括网络攻击检测、设备故障检测、工业产品异常检测和医疗异常指标检测。\n需要注意，异常不等于欺诈，只表示该数据与正常模式不同。异常检测也可以采用监督学习方法，但当异常样本很少或没有可靠标签时，经常采用无监督或半监督方法。\n降维：用更少特征表示数据 # 降维（Dimensionality Reduction）的目标是在尽量保留重要信息的前提下，用更少的特征表示原始数据。\n假设原始数据包含 1000 个特征：\n$$x=(x_1,x_2,\\ldots,x_{1000})$$降维算法可能将其压缩为几十个甚至两三个新特征：\n$$z=(z_1,z_2,\\ldots,z_k),\\quad k \\ll 1000$$降维可以压缩数据、提高部分算法的运行效率、减少重复信息和噪声，也可以将高维数据转换为二维或三维，方便可视化。它不是随意删除数据，而是尽可能保留数据中最重要的信息。\n如何判断学习类型 # 判断任务时，首先问：训练数据中是否提供了标签？\n邮件带有垃圾/非垃圾标签：监督学习中的分类 房屋带有实际价格：监督学习中的回归 新闻没有主题标签，需要自动分组：无监督学习中的聚类 客户没有预设类型，需要自动细分：无监督学习中的聚类 患者带有患糖尿病/未患糖尿病标签：监督学习中的分类 同一个业务问题也可能因为数据是否有标签，而采用不同的学习方式。\n优势与局限 # 无监督学习不需要为所有数据人工标注，可以处理大量无标签数据，并发现人们事先没有注意到的结构，因此很适合探索陌生数据。\n它也有明显局限：没有标准答案，结果较难评估；算法找到的结构不一定具有实际意义；聚类结果会受到特征选择和算法的影响；最终通常仍需要领域知识进行解释。\n知识框架 # 机器学习 ├── 监督学习 │ ├── 回归：预测数值（房价、温度） │ └── 分类：预测类别（垃圾邮件、肿瘤性质） │ ├── 二分类 │ └── 多分类 │ └── 无监督学习 ├── 聚类：寻找相似群体（新闻分组、客户细分） ├── 异常检测：寻找异常数据（欺诈检测） └── 降维：压缩数据并保留重要信息 监督学习使用带标签的 $(x,y)$ 数据，学习输入到输出的映射；无监督学习只有输入 x，目标是自动发现数据中的结构、模式或异常。两者并无高下之分，关键是根据数据和问题选择合适的方法。\n术语表 # 中文 英文 含义 特征 Feature 描述样本的输入属性 标签 Label 训练数据中的正确答案 泛化 Generalization 对未见数据作出有效预测的能力 回归 Regression 预测连续数值 分类 Classification 预测离散类别 二分类 Binary Classification 只有两个输出类别 多分类 Multiclass Classification 存在三个及以上类别 决策边界 Decision Boundary 区分不同类别的边界 聚类 Clustering 将相似数据自动分组 簇 Cluster 聚类形成的数据群体 异常检测 Anomaly Detection 发现偏离正常模式的数据 降维 Dimensionality Reduction 用更少特征表示高维数据 市场细分 Market Segmentation 根据相似特征划分客户群体 ","date":"2026年7月20日","externalUrl":null,"permalink":"/MyBlog/p/machine-learning-supervised-unsupervised/","section":"Posts","summary":"","title":"机器学习基础：监督学习与无监督学习","type":"posts"},{"content":"","date":"2026年7月20日","externalUrl":null,"permalink":"/MyBlog/tags/%E7%9B%91%E7%9D%A3%E5%AD%A6%E4%B9%A0/","section":"Tags","summary":"","title":"监督学习","type":"tags"},{"content":"","date":"2026年7月20日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%97%A0%E7%9B%91%E7%9D%A3%E5%AD%A6%E4%B9%A0/","section":"Tags","summary":"","title":"无监督学习","type":"tags"},{"content":"承接 Go 语言基础：从变量到指针、Go 语言进阶：方法、接口与错误处理、Go 语言工程化：模块管理与项目结构、Go 语言并发：从 Goroutine 到 Worker Pool 和 Go Web 开发：从 net-http 到 Gin，这一篇跳出语法学习，反推一个真实的开源项目——feedsystem_video_go（279 star，Go + Vue 3 短视频 Feed 流系统），从跑通项目到提交 PR，走一遍完整的工程实战。\n项目概览 # 这个项目是一个短视频 Feed 流系统，技术栈：\n后端：Go + Gin + GORM + RabbitMQ + Redis 前端：Vue 3 + TypeScript 基础设施：Docker Compose 编排 MySQL、Redis、RabbitMQ 核心功能包括用户注册登录、视频发布、点赞评论、关注取关、Feed 流推送。架构上采用 API + Worker 的消息队列模式。\nDocker Compose：启动基础设施 # 项目的三个\u0026quot;帮手\u0026quot;——MySQL、Redis、RabbitMQ——通过 Docker Compose 一键启动：\ncd ~/dev/projects/feedsystem_video_go docker compose up -d mysql redis rabbitmq docker compose up — 根据 docker-compose.yml 启动服务 -d — 后台运行（detach），不占终端窗口 mysql redis rabbitmq — 只启动这三个，不启动 backend/worker/frontend 镜像 vs 容器 # Docker 里有两个核心概念：\n镜像 = 打包好的集装箱（里面有程序 + 配置），是静态文件 容器 = 集装箱正在运行的实例 类比：.app 文件是镜像，双击打开后正在运行就是容器。\n端口映射 # MySQL 默认端口 3306，但本机可能已经装了 MySQL 占用了 3306。所以 docker-compose.yml 里写的是：\nports: \u0026#34;3307:3306\u0026#34; 意思是：外部访问 3307 → 转发到容器内部的 3306。就像你家门牌号是 3307，但内部房间编号是 3306。项目配置文件里写的也是 3307，Go 程序连接 MySQL 时用的就是 3307。\n通过 docker compose ps 可以查看所有容器的运行状态：\nNAME IMAGE STATUS PORTS feedsystem_video_go-mysql-1 mysql:8.0 Up 5 minutes (healthy) 0.0.0.0:3307-\u0026gt;3306/tcp feedsystem_video_go-redis-1 redis:7-alpine Up 5 minutes (healthy) 0.0.0.0:6379-\u0026gt;6379/tcp feedsystem_video_go-rabbitmq-1 rabbitmq:3 Up 5 minutes (healthy) 0.0.0.0:5672-\u0026gt;5672/tcp 0.0.0.0 表示\u0026quot;接受任何来源的连接\u0026quot;，即对外部开放。\n启动 Go 后端 # 基础设施就位后，启动后端：\ncd backend CONFIG_PATH=configs/config.compose-local.yaml go run ./cmd CONFIG_PATH=... — 告诉程序用哪个配置文件（里面写好了 MySQL 3307、Redis 6379、RabbitMQ 5672 的地址） go run ./cmd — 编译并运行 cmd/main.go 入口程序 日志解读 # 启动后会看到几段日志：\n第一段：依赖下载\ngo: downloading github.com/gin-gonic/gin v1.11.0 go: downloading gorm.io/gorm v1.31.1 Go 在下载第三方库，类似 npm install，下载一次以后就不需要了。\n第二段：连接建立\nLoading config from configs/config.compose-local.yaml Redis connected (cache enabled) RabbitMQ connected 注意没有 \u0026ldquo;MySQL connected\u0026rdquo;，因为 GORM 连接 MySQL 是在后面 AutoMigrate 时才真正连接。\n第三段：Gin 路由注册\n[GIN-debug] GET /healthz [GIN-debug] POST /account/register [GIN-debug] POST /account/login [GIN-debug] POST /video/publish [GIN-debug] POST /like/like [GIN-debug] POST /feed/listLatest 格式是 HTTP方法 路由路径，后面的 (4 handlers) 表示这条路由经过几个中间件（如限流 + JWT 鉴权 + 业务处理）。\n第四段：消息队列\nDLX ready: exchange=dlx.events queue=like.events.dlx Timeline consumer 已启动, queue=video.timeline.update.queue DLX = Dead Letter Exchange（死信队列），专门处理失败的消息。\n第五段：启动完成\nServer is running on port 8080 为什么不把 Go 也放进 Docker？ # Docker 适合最终部署，不适合日常开发调试。本地直接 go run 的好处：\n日志直接打印在终端，实时可见 改了代码立刻能跑，不用重新打包镜像 出错了能直接看到报错信息 日志不是中间件 # 日志是 Go 标准库 log 包写出来的，是程序员手动写的\u0026quot;程序进行到哪一步了\u0026quot;的提示。中间件是处理请求的\u0026quot;关卡\u0026quot;，两者没有关系。\n核心概念：缓存与消息队列 # Redis 缓存 # 缓存 = 把经常用的数据放在\u0026quot;取用更快的地方\u0026quot;。\n类比：冰箱里有水（MySQL，容量大但取用慢），你拿了一瓶放在桌上（Redis 缓存，取用极快），下次喝水直接从桌上拿，不用再去冰箱。\n代码里 cache enabled 表示数据会先从 Redis 取，取不到再去 MySQL。\nRabbitMQ 消息队列 # 消息队列是 API 和 Worker 之间的\u0026quot;传话筒\u0026quot;。API 处理完核心逻辑后，把\u0026quot;后续要做的事\u0026quot;发到队列，Worker 从队列里取任务执行。\n前后端分离架构 # 后端和前端是两个完全不同的程序：\n后端：Go 写的，负责业务逻辑、读写数据库，监听 8080 端口 前端：Vue 写的，负责页面展示、用户交互，监听 5173 端口 它们通过 HTTP 请求通信：用户在浏览器点\u0026quot;登录\u0026quot; → 前端发请求到 localhost:8080/account/login → 后端处理 → 返回结果 → 前端展示。\n就像餐厅里厨房（后端）和前厅（前端）是两个地方，前厅接到顾客点单传给厨房，厨房做完菜传给前厅。\n前端用 npm（Node Package Manager）管理依赖，跟 Go 的 go get、Python 的 pip install 是同一个道理：\ncd frontend npm install # 下载依赖 npm run dev # 启动开发服务器 Worker 进程模式 # 这是整个项目最核心的架构设计。Worker 是一个独立进程，和 API 分开启动：\ncd backend CONFIG_PATH=configs/config.compose-local.yaml go run ./cmd/worker 为什么要分开？ # 想象一个外卖平台：\n方案 A：一个人又要接单又要送餐 — 接单时有新订单没人接，送餐时又有新订单没人接，效率低。\n方案 B：两个人分工 — 前台专门接单（API），骑手专门送餐（Worker），前台接完单把订单放进\u0026quot;待送餐队列\u0026quot;（RabbitMQ），骑手从队列里取订单去送。\n对应到项目：\n角色 职责 API 进程 接收用户请求（点赞、评论、发布视频），处理核心业务逻辑，把\u0026quot;后续要做的事\u0026quot;发到消息队列 Worker 进程 监听消息队列，处理异步任务：更新点赞数、计算热度、推送通知 这个项目有四个 Worker：\nLikeWorker — 处理点赞/取消点赞事件 PopularityWorker — 更新视频热度 SocialWorker — 处理关注/取关事件 CommentWorker — 处理评论事件 分开的好处 # 性能：用户点赞时，API 只需要返回\u0026quot;点赞成功\u0026quot;，不需要等热度计算完成 独立伸缩：请求量大时启动多个 API 进程，任务多时启动多个 Worker 进程 故障隔离：Worker 挂了 API 还能正常接收请求，API 挂了 Worker 还能把队列里的任务处理完 一句话总结：API 是\u0026quot;接单的\u0026quot;，Worker 是\u0026quot;干活的\u0026quot;，通过消息队列解耦。\n开源贡献实战：修复 JWT 密钥缓存 Bug # 跑通项目后，在实际使用中发现了一个真实 Bug。\n问题复现 # 不设置 JWT_SECRET 环境变量，启动后端 注册一个账号 登录成功（页面提示\u0026quot;登录成功\u0026quot;） 但左侧栏仍然显示\u0026quot;未登录\u0026quot; 浏览器控制台报错：invalid or expired token (401) 登录本身成功，但所有需要鉴权的接口全部返回 401。\n根因定位 # 文件：backend/internal/auth/jwt.go\nfunc jwtSecret() []byte { secret := os.Getenv(\u0026#34;JWT_SECRET\u0026#34;) if secret == \u0026#34;\u0026#34; { b := make([]byte, 32) rand.Read(b) secret = hex.EncodeToString(b) } return []byte(secret) } 问题出在这个函数每次调用都生成一个新的随机密钥：\n登录时：GenerateToken() 调用 jwtSecret() → 拿到密钥 A → 用 A 签名 token 验证时：ParseToken() 调用 jwtSecret() → 拿到密钥 B → 用 B 验证 A ≠ B → 签名不匹配 → 返回 401 修复方案 # 用包级别的变量缓存密钥，保证每个进程只生成一次：\nvar cachedSecret []byte func jwtSecret() []byte { if cachedSecret != nil { return cachedSecret } secret := os.Getenv(\u0026#34;JWT_SECRET\u0026#34;) if secret == \u0026#34;\u0026#34; { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { log.Printf(\u0026#34;FATAL: cannot generate JWT secret: %v\u0026#34;, err) cachedSecret = []byte(\u0026#34;fallback-unsafe-key-change-me\u0026#34;) return cachedSecret } secret = hex.EncodeToString(b) log.Printf(\u0026#34;WARNING: JWT_SECRET not set, generated random key.\u0026#34;) } cachedSecret = []byte(secret) return cachedSecret } 修复方案兼顾安全性（缓存保证签名一致）和灵活性（仍然允许随机密钥兜底）。\n走通开源贡献流程 # 步骤 内容 1. 本地复现 按步骤触发 Bug 2. 阅读源码 从前端 → API → JWT 中间件，定位根因 3. 提 Issue 描述问题，附带复现步骤 → #15 4. Fork + 修复分支 fix/jwt-secret-caching 5. 提 PR 关联 Issue → #16 面试话术 # 在一个 279 star 的开源项目中发现了真实 bug 从前端（Vue/TypeScript）追踪到 API（Gin/Go）再到 JWT 鉴权中间件，定位了根因 提出的修复方案兼顾安全性和灵活性 走通了完整的开源贡献流程 这一篇从\u0026quot;跑通项目\u0026quot;出发，反推了 Docker Compose 编排、Gin 路由注册、Redis 缓存、RabbitMQ 消息队列、Worker 进程模式等工程概念，最后通过一次真实的开源贡献把整条链路串了起来。相比纯语法学习，反推真实项目能更快建立工程直觉。\n","date":"2026年7月19日","externalUrl":null,"permalink":"/MyBlog/p/go-feed-system-reverse-engineering/","section":"Posts","summary":"","title":"Go 项目反推：Feed 流系统实战——从 Docker 到开源贡献","type":"posts"},{"content":"","date":"2026年7月19日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%BC%80%E6%BA%90%E8%B4%A1%E7%8C%AE/","section":"Tags","summary":"","title":"开源贡献","type":"tags"},{"content":"承接 Go 语言基础：从变量到指针、Go 语言进阶：方法、接口与错误处理、Go 语言工程化：模块管理与项目结构 和 Go 语言并发：从 Goroutine 到 Worker Pool，这一篇进入 Web 开发实战：用 Go 写 HTTP 服务器。\nHandler：处理请求的函数 # Go 的 HTTP 服务器一切从 Handler 开始。Handler 就是一个处理请求的函数，签名是固定的：\nfunc handler(w http.ResponseWriter, r *http.Request) w = 响应写入器（往里面写什么，客户端就收到什么） r = 请求信息（客户端发来的 URL、方法、头部等） 把 w 想象成\u0026quot;回复窗口\u0026quot;，r 想象成\u0026quot;来信内容\u0026quot;。\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;net/http\u0026#34; ) func homeHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, \u0026#34;Hello, Go Web!\\n\u0026#34;) } func aboutHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, \u0026#34;About Page\\n\u0026#34;) } 路由注册与启动服务器 # 告诉 Go：访问 / 调用 homeHandler，访问 /about 调用 aboutHandler。\nfunc main() { // 注册路由 http.HandleFunc(\u0026#34;/\u0026#34;, homeHandler) http.HandleFunc(\u0026#34;/about\u0026#34;, aboutHandler) // 启动服务器，监听 8080 端口 fmt.Println(\u0026#34;Server starting on http://localhost:8080 ...\u0026#34;) err := http.ListenAndServe(\u0026#34;:8080\u0026#34;, nil) if err != nil { fmt.Println(\u0026#34;Server error:\u0026#34;, err) } } ListenAndServe 的含义 # Listen = 监听（竖起耳朵听有没有人敲门） And = 并且 Serve = 提供服务（有人来了就接待） 这行代码一旦执行，程序就卡在这不走了，一直等着。下面的 if err 只有服务器出错才会执行。\n端口被占了怎么杀：lsof -ti:8080 | xargs kill -9\nHTTP 方法与 curl # r.Method → 请求方法，r.URL.Path → 请求路径。\ncurl 是在终端发 HTTP 请求的工具，相当于用命令代替浏览器：\ncurl http://localhost:8080/ # GET curl -X POST http://localhost:8080/ # POST curl -X PUT http://localhost:8080/ # PUT curl -X DELETE http://localhost:8080/ # DELETE HTTP 方法表示\u0026quot;我想干什么\u0026quot;：\n方法 含义 类比（快递柜） GET 获取数据 查看 123 号格子 POST 提交数据 往里放新包裹 PUT 更新数据 换掉 123 号格子的东西 DELETE 删除数据 拿走 123 号格子的东西 返回 JSON 响应 # JSON 是程序之间传递数据的标准格式。核心三步：\nimport \u0026#34;encoding/json\u0026#34; type User struct { Name string `json:\u0026#34;name\u0026#34;` Age int `json:\u0026#34;age\u0026#34;` } func userHandler(w http.ResponseWriter, r *http.Request) { user := User{Name: \u0026#34;Tom\u0026#34;, Age: 20} // 第1步：把数据变成 JSON jsonBytes, err := json.Marshal(user) if err != nil { http.Error(w, \u0026#34;序列化失败\u0026#34;, 500) return } // 第2步：告诉客户端\u0026#34;我返回的是 JSON\u0026#34; w.Header().Set(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;) // 第3步：把 JSON 写出去 w.Write(jsonBytes) } struct 的 json tag # type User struct { Name string `json:\u0026#34;name\u0026#34;` // JSON 里字段叫 name（小写） Age int `json:\u0026#34;age\u0026#34;` } 不写 tag 的话字段名就是 Go 里的大写 Name、Age。反引号里整个 json:\u0026quot;xxx\u0026quot; 是一个整体，冒号后面不能有空格。\n响应头是什么 # HTTP 响应 = 响应头（信封）+ 响应体（信的内容）：\nw.Header() = 拿信封 w.Header().Set(\u0026quot;Content-Type\u0026quot;, \u0026quot;application/json\u0026quot;) = 在信封上写\u0026quot;里面是 JSON\u0026quot; w.Write() = 往信里写内容 读取客户端 JSON # 客户端 POST 发来 JSON，服务器解析到结构体：\nfunc createUserHandler(w http.ResponseWriter, r *http.Request) { // 检查方法 if r.Method != \u0026#34;POST\u0026#34; { http.Error(w, \u0026#34;只支持 POST 方法\u0026#34;, http.StatusMethodNotAllowed) return } // 从请求体解析 JSON 到 user var user User err := json.NewDecoder(r.Body).Decode(\u0026amp;user) if err != nil { http.Error(w, \u0026#34;JSON 解析失败\u0026#34;, 400) return } // 返回给客户端 jsonData, _ := json.Marshal(user) w.Header().Set(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;) w.Write(jsonData) } 测试：\ncurl -X POST http://localhost:8080/user -d \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;Jerry\u0026#34;,\u0026#34;age\u0026#34;:25}\u0026#39; 序列化 vs 反序列化 # 操作 函数 用途 结构体 → JSON json.Marshal(data) 返回给客户端 JSON → 结构体 json.NewDecoder(r.Body).Decode(\u0026amp;data) 读取客户端数据 关键点：\nDecode 的参数必须是 \u0026amp;user（指针），不是 user Marshal 和 Decode 都返回 err，必须检查 出错要 http.Error + return，不能光打印 REST API 设计风格 # REST 的核心思想：用 HTTP 方法 + 路径，表达\u0026quot;对资源做什么操作\u0026quot;。路径放名词（资源），HTTP 方法放动词（操作）。\n本质就是 CRUD：\n操作 HTTP 方法 路径 含义 Create POST /user 新建 Read GET /user 或 /user/0 查列表 / 查单个 Update PUT /user/0 改单个 Delete DELETE /user/0 删单个 记忆：GET=看，POST=增，PUT=改，DELETE=删\n设计规则：\nURL 用名词（/user），不用动词（/getUser） 用 HTTP 方法区分操作，而不是路径里写动词 同一个 URL，不同方法 = 不同操作 完整 CRUD 实战 # 四个 Handler 的套路 # 不管你写哪个 handler，步骤都是：\n检查 HTTP 方法对不对 从 URL 拿 id（列表操作跳过） 检查越界（同上） 业务逻辑（创建/返回/替换/删除） 序列化返回（JSON → 设 Header → 写响应） 区别只在第 4 步：\nCreate：从请求体解析 → append 到 users Read：从 users 里取 → 序列化返回 Update：从请求体解析 → 替换 users[id] Delete：从 users 删掉 → 返回文字消息 结构体与全局变量 # type User struct { Name string `json:\u0026#34;name\u0026#34;` Age int `json:\u0026#34;age\u0026#34;` } // 模拟数据库，服务器重启就清空 var users = []User{} 从 URL 提取 id # 客户端访问 GET /user/0，服务器用 TrimPrefix 切掉前缀，再转数字：\nfunc getUserHandler(w http.ResponseWriter, r *http.Request) { if r.Method != \u0026#34;GET\u0026#34; { http.Error(w, \u0026#34;只支持 GET 方法\u0026#34;, 405) return } // /user/0 → \u0026#34;0\u0026#34; idStr := strings.TrimPrefix(r.URL.Path, \u0026#34;/user/\u0026#34;) // \u0026#34;0\u0026#34; → 0 id, err := strconv.Atoi(idStr) if err != nil { http.Error(w, \u0026#34;无效的 id\u0026#34;, 400) return } // 越界检查 if id \u0026lt; 0 || id \u0026gt;= len(users) { http.Error(w, \u0026#34;越界\u0026#34;, 404) return } user := users[id] jsonData, _ := json.Marshal(user) w.Header().Set(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;) w.Write(jsonData) } 越界用 \u0026gt;= 不是 \u0026gt;：长度 2 的列表，有效下标是 0、1，id=2 要拦截。\nUpdate Handler（PUT） # 套路：检查方法 → 拿 id → 越界检查 → 解析请求体新数据 → 替换 users[id] → 序列化返回。\nfunc updateUserHandler(w http.ResponseWriter, r *http.Request) { if r.Method != \u0026#34;PUT\u0026#34; { http.Error(w, \u0026#34;只支持 PUT 方法\u0026#34;, 405) return } idStr := strings.TrimPrefix(r.URL.Path, \u0026#34;/user/\u0026#34;) id, err := strconv.Atoi(idStr) if err != nil { http.Error(w, \u0026#34;无效的 id\u0026#34;, 400) return } if id \u0026lt; 0 || id \u0026gt;= len(users) { http.Error(w, \u0026#34;越界\u0026#34;, 404) return } var user User err = json.NewDecoder(r.Body).Decode(\u0026amp;user) if err != nil { http.Error(w, \u0026#34;解析失败\u0026#34;, 400) return } users[id] = user jsonData, _ := json.Marshal(user) w.Header().Set(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;) w.Write(jsonData) } Delete Handler（DELETE） # 套路：检查方法 → 拿 id → 越界检查 → 从 slice 删元素 → 返回消息。\nfunc deleteUserHandler(w http.ResponseWriter, r *http.Request) { if r.Method != \u0026#34;DELETE\u0026#34; { http.Error(w, \u0026#34;方法错误\u0026#34;, 405) return } idStr := strings.TrimPrefix(r.URL.Path, \u0026#34;/user/\u0026#34;) id, err := strconv.Atoi(idStr) if err != nil { http.Error(w, \u0026#34;无效的 id\u0026#34;, 400) return } if id \u0026lt; 0 || id \u0026gt;= len(users) { http.Error(w, \u0026#34;越界\u0026#34;, 404) return } // 删 slice 元素 users = append(users[:id], users[id+1:]...) fmt.Fprintf(w, \u0026#34;用户 %d 已删除\\n\u0026#34;, id) } Slice 删除元素的原理 # users = append(users[:id], users[id+1:]...) 把 id 前面的人和 id 后面的人拼到一起，跳过 id 本身。假设 users = [张三, 李四, 王五]，删 id=1（李四）：\nusers[:1] → [张三] users[2:] → [王五] append 拼起来 → [张三, 王五] 注意：删除后 users 长度变了，后面的 id 会前移。\n路由分发（switch 重构） # 当一个 URL 要根据 HTTP 方法分发到不同 handler 时：\nfunc main() { http.HandleFunc(\u0026#34;/user/\u0026#34;, func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if path == \u0026#34;/user\u0026#34; || path == \u0026#34;/user/\u0026#34; { // 列表和创建 switch r.Method { case \u0026#34;GET\u0026#34;: listUsersHandler(w, r) case \u0026#34;POST\u0026#34;: createUserHandler(w, r) default: http.Error(w, \u0026#34;方法不支持\u0026#34;, 405) } } else if strings.HasPrefix(path, \u0026#34;/user/\u0026#34;) { // 单个用户操作 switch r.Method { case \u0026#34;GET\u0026#34;: getUserHandler(w, r) case \u0026#34;PUT\u0026#34;: updateUserHandler(w, r) case \u0026#34;DELETE\u0026#34;: deleteUserHandler(w, r) default: http.Error(w, \u0026#34;方法不支持\u0026#34;, 405) } } }) fmt.Println(\u0026#34;Server starting on http://localhost:8080 ...\u0026#34;) err := http.ListenAndServe(\u0026#34;:8080\u0026#34;, nil) if err != nil { fmt.Println(\u0026#34;Server error:\u0026#34;, err) } } 测试 # # 查列表（空的） $ curl http://localhost:8080/user/ [] # 创建两个用户 $ curl -X POST http://localhost:8080/user/ -d \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;Jerry\u0026#34;,\u0026#34;age\u0026#34;:25}\u0026#39; {\u0026#34;name\u0026#34;:\u0026#34;Jerry\u0026#34;,\u0026#34;age\u0026#34;:25} $ curl -X POST http://localhost:8080/user/ -d \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;Tom\u0026#34;,\u0026#34;age\u0026#34;:30}\u0026#39; {\u0026#34;name\u0026#34;:\u0026#34;Tom\u0026#34;,\u0026#34;age\u0026#34;:30} # 再查列表 $ curl http://localhost:8080/user/ [{\u0026#34;name\u0026#34;:\u0026#34;Jerry\u0026#34;,\u0026#34;age\u0026#34;:25},{\u0026#34;name\u0026#34;:\u0026#34;Tom\u0026#34;,\u0026#34;age\u0026#34;:30}] # 查单个 $ curl http://localhost:8080/user/0 {\u0026#34;name\u0026#34;:\u0026#34;Jerry\u0026#34;,\u0026#34;age\u0026#34;:25} ServeMux 的斜杠重定向坑 # 注册 /user/ 这种子树路由后，Go 默认路由器会把 /user 自动重定向到 /user/（301 Moved Permanently）。一些客户端跟随 301 重定向时会把 POST 改成 GET，导致请求体丢失；另一些客户端不会自动跟随重定向。\n解决：访问列表/创建接口时直接使用带斜杠的 /user/，或者同时显式注册 /user 与 /user/ 并自行处理。\nMiddleware 中间件 # 一句话理解 # 在请求到达 handler 之前（或之后）执行的通用逻辑。\n比喻：快递站的安检机——每个包裹都要过一遍，但安检机不是最终处理包裹的地方，安检完再交给真正处理的人。\n实际用途：\n日志记录：每个请求都打印一下 认证检查：检查有没有登录 请求计时：每个请求花了多少时间 核心代码 # func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // 前置：请求进来时 log.Println(\u0026#34;收到请求:\u0026#34;, r.URL.Path) // 调用真正的 handler next(w, r) // 后置：响应返回后 log.Println(\u0026#34;处理完成\u0026#34;) } } 使用方式 # // 原来 http.HandleFunc(\u0026#34;/user\u0026#34;, createUserHandler) // 包一层中间件 http.HandleFunc(\u0026#34;/user\u0026#34;, loggingMiddleware(createUserHandler)) 理解方式：\nloggingMiddleware(createUserHandler) ↑ ↑ 前台（先接客） 厨师（真正干活） 流程：请求进来 → 前台登记 → 转给厨师 → 做完 → 前台记录 多个中间件叠加 # http.HandleFunc(\u0026#34;/user\u0026#34;, authMiddleware(loggingMiddleware(createUserHandler))) // 请求进来 → auth → logging → createUserHandler 本质：函数当参数传 + 函数当返回值。不用完全理解原理，记住套路就行，写多了自然就懂。\nGin 框架 # Gin 是什么 # Go 最流行的 Web 框架，帮你省掉 net/http 的重复代码。\ngo get github.com/gin-gonic/gin 创建服务器 # import \u0026#34;github.com/gin-gonic/gin\u0026#34; r := gin.Default() // 创建路由器，自带 Logger 中间件 r.Run(\u0026#34;:8080\u0026#34;) // 启动服务器 Gin vs net/http 对比 # net/http Gin w http.ResponseWriter, r *http.Request c *gin.Context if r.Method != \u0026quot;GET\u0026quot; ... 不用写（r.GET 自动限定） json.NewDecoder(r.Body).Decode() c.ShouldBindJSON(\u0026amp;user) json.Marshal + w.Header + w.Write c.JSON(200, data) strings.TrimPrefix + Atoi c.Param(\u0026quot;id\u0026quot;) http.HandleFunc + if/switch r.GET/POST/PUT/DELETE 分开注册 http.ListenAndServe(\u0026quot;:8080\u0026quot;, nil) r.Run(\u0026quot;:8080\u0026quot;) 路由注册 # r.GET(\u0026#34;/user\u0026#34;, listUsers) // GET /user → listUsers r.POST(\u0026#34;/user\u0026#34;, createUser) // POST /user → createUser r.GET(\u0026#34;/user/:id\u0026#34;, getUser) // GET /user/0 → getUser r.PUT(\u0026#34;/user/:id\u0026#34;, updateUser) // PUT /user/0 → updateUser r.DELETE(\u0026#34;/user/:id\u0026#34;, deleteUser) // DELETE /user/0 → deleteUser :id 是路由参数，/user/0 里的 0 会被捕获。\nHandler 签名 # func handler(c *gin.Context) { // c 把 w 和 r 合到一起了，用 c 就能完成所有事情 } 获取路由参数与查询参数 # // 路由参数 id := c.Param(\u0026#34;id\u0026#34;) // /user/0 → \u0026#34;0\u0026#34; // 查询参数 name := c.Query(\u0026#34;name\u0026#34;) // /user?name=张三 → \u0026#34;张三\u0026#34; page := c.DefaultQuery(\u0026#34;page\u0026#34;, \u0026#34;1\u0026#34;) // 没传就用默认值 \u0026#34;1\u0026#34; 解析请求体 JSON # var user User if err := c.ShouldBindJSON(\u0026amp;user); err != nil { c.JSON(400, gin.H{\u0026#34;error\u0026#34;: err.Error()}) return } // 解析成功，user 里有数据了 ShouldBindJSON 一行代替 net/http 的三行（NewDecoder + Decode + 检查 err）。\n返回 JSON 响应 # c.JSON(200, user) // 返回单个对象 c.JSON(200, users) // 返回列表 c.JSON(400, gin.H{\u0026#34;error\u0026#34;: \u0026#34;解析失败\u0026#34;}) // 返回错误信息 gin.H 是 map[string]interface{} 的简写。c.JSON 自动帮你做：序列化 + 设 Content-Type + 写响应。\n参数校验 # 在 struct tag 里加 binding 规则，Gin 自动校验：\ntype User struct { Name string `json:\u0026#34;name\u0026#34; binding:\u0026#34;required,min=2,max=50\u0026#34;` Age int `json:\u0026#34;age\u0026#34; binding:\u0026#34;required,gt=0\u0026#34;` } 常用规则：\nrequired → 必填 min=N / max=N → 最小/最大长度（字符串）或值（数字） gt=N / gte=N → 大于 / 大于等于 email → 必须是邮箱格式 校验在 c.ShouldBindJSON 时自动执行，不需要额外代码。\nGin 中间件 # func Logger() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() log.Printf(\u0026#34;\u0026gt;\u0026gt;\u0026gt; %s %s\u0026#34;, c.Request.Method, c.Request.URL.Path) c.Next() // 放行 cost := time.Since(start) log.Printf(\u0026#34;\u0026lt;\u0026lt;\u0026lt; 耗时: %v\u0026#34;, cost) } } 核心两个函数：\nc.Next() → 放行，让请求继续走到 handler c.Abort() → 拦截，不让请求继续走 认证中间件 # func AuthRequired() gin.HandlerFunc { return func(c *gin.Context) { token := c.GetHeader(\u0026#34;Authorization\u0026#34;) if token == \u0026#34;\u0026#34; { c.JSON(401, gin.H{\u0026#34;error\u0026#34;: \u0026#34;未登录\u0026#34;}) c.Abort() // 拦截 return } c.Next() // 放行 } } 路由分组 # 用法1：单个路由加中间件\nr.GET(\u0026#34;/user\u0026#34;, Logger(), listUsers) r.GET(\u0026#34;/user/:id\u0026#34;, Logger(), AuthRequired(), getUser) 用法2：路由分组（推荐）\nauthorized := r.Group(\u0026#34;/user\u0026#34;, AuthRequired()) { authorized.GET(\u0026#34;/:id\u0026#34;, Logger(), getUser) authorized.PUT(\u0026#34;/:id\u0026#34;, Logger(), updateUser) authorized.DELETE(\u0026#34;/:id\u0026#34;, Logger(), deleteUser) } 分组的好处：AuthRequired 写一次，这组路由全部生效。\n完整示例（带中间件） # func main() { r := gin.Default() // 不需要登录的接口 r.GET(\u0026#34;/user\u0026#34;, listUsers) r.POST(\u0026#34;/user\u0026#34;, createUser) // 需要登录的接口 auth := r.Group(\u0026#34;/user\u0026#34;, AuthRequired()) { auth.GET(\u0026#34;/:id\u0026#34;, getUser) auth.PUT(\u0026#34;/:id\u0026#34;, updateUser) auth.DELETE(\u0026#34;/:id\u0026#34;, deleteUser) } r.Run(\u0026#34;:8080\u0026#34;) } 常用函数速查 # 函数 作用 gin.Default() 创建路由器（自带 Logger） r.GET/POST/PUT/DELETE(path, fn) 注册路由 r.Group(path, middleware...) 路由分组 c.Param(\u0026quot;id\u0026quot;) 获取路由参数 c.Query(\u0026quot;key\u0026quot;) 获取查询参数 c.DefaultQuery(\u0026quot;key\u0026quot;, \u0026quot;default\u0026quot;) 获取查询参数（带默认值） c.GetHeader(\u0026quot;key\u0026quot;) 获取请求头 c.ShouldBindJSON(\u0026amp;data) 解析请求体 JSON（含校验） c.JSON(status, data) 返回 JSON 响应 c.Next() 放行 c.Abort() 拦截 gin.H{...} map[string]interface{} 简写 踩坑汇总 # 中文输入法逗号：\u0026quot;错误\u0026quot;，405 → 要用英文逗号 \u0026quot;错误\u0026quot;, 405 := vs =：全局变量用 = 赋值，用 := 会创建局部变量 fmt.Fprinf → fmt.Fprintf（p 和 r 反了） json.Decode 拼写：Docode → Decode application 拼写：spplication → application c.Abort 漏括号 → c.Abort() 才是调用 Gin 里用 net/http 的写法：json.NewDecoder(r.Body) 是 net/http 的，Gin 里用 c.ShouldBindJSON net/http 中 POST /user 不带斜杠：注册的是 /user/ 时会被重定向，测试时直接用 /user/ 参考源 # raw/GoWeb 后端开发.md ","date":"2026年7月18日","externalUrl":null,"permalink":"/MyBlog/p/go-web-net-http-to-gin/","section":"Posts","summary":"","title":"Go Web 开发：从 net-http 到 Gin","type":"posts"},{"content":"","date":"2026年7月18日","externalUrl":null,"permalink":"/MyBlog/tags/net/http/","section":"Tags","summary":"","title":"Net/Http","type":"tags"},{"content":"","date":"2026年7月18日","externalUrl":null,"permalink":"/MyBlog/tags/rest-api/","section":"Tags","summary":"","title":"REST API","type":"tags"},{"content":"","date":"2026年7月18日","externalUrl":null,"permalink":"/MyBlog/tags/web-%E5%BC%80%E5%8F%91/","section":"Tags","summary":"","title":"Web 开发","type":"tags"},{"content":"","date":"2026年7月14日","externalUrl":null,"permalink":"/MyBlog/tags/channel/","section":"Tags","summary":"","title":"Channel","type":"tags"},{"content":"承接 Go 语言基础：从变量到指针、Go 语言进阶：方法、接口与错误处理 和 Go 语言工程化：模块管理与项目结构，这一篇覆盖 Go 最核心的特性：并发编程。\n并发 vs 并行 # 先区分两个概念：\n并行（Parallel）：两个人同时吃两碗面 并发（Concurrency）：一个人交替吃两碗面（这口吃这碗，下口吃那碗） Go 的并发模型就是让你用很少的资源同时做很多事。\nGoroutine # 一句话：轻量级线程，用 go 关键字启动。\n// 普通调用 fmt.Println(\u0026#34;hello\u0026#34;) // 用 go 启动一个 goroutine go fmt.Println(\u0026#34;hello\u0026#34;) // 加个 go 就行 加了 go，这个函数就会在后台跑，不阻塞当前代码。\n先看个例子 # package main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; ) func sayHello() { fmt.Println(\u0026#34;hello\u0026#34;) } func main() { go sayHello() // 后台跑 fmt.Println(\u0026#34;main\u0026#34;) // 主线程继续 } 你猜输出什么？\n答案：只输出 main。\n为什么？因为 main 函数结束了，程序就退出了，goroutine 还没来得及跑。\n这就像你叫了个外卖（go sayHello），但你立刻出门了（main 结束），外卖员来了发现没人。\n怎么等 goroutine 跑完？ # 最简单的方式：加个 sleep（但这是错误示范）\ngo sayHello() time.Sleep(time.Second) // 等1秒，goroutine 有时间跑了 fmt.Println(\u0026#34;main\u0026#34;) 能工作，但不准、不优雅——你不知道 goroutine 到底要跑多久。\n正确方式：用 channel（后面会详细讲）\ndone := make(chan bool) go func() { fmt.Println(\u0026#34;hello\u0026#34;) done \u0026lt;- true // 说一声：我做完了 }() \u0026lt;-done // 等着，直到收到信号 fmt.Println(\u0026#34;main\u0026#34;) 现在输出：\nhello main 用比喻理解 # 普通函数调用：你叫同事做事，站在旁边等他做完 go 函数调用：你叫同事做事，自己先去忙别的 channel：同事做完了发消息通知你 Channel # 为什么需要 Channel？ # goroutine 是独立运行的，它们之间怎么传数据？\n// 两个 goroutine，一个生产数据，一个消费数据 // 怎么把数据从 A 传到 B？ 答案：用 channel，就像一根水管，一头进，一头出。\n基本用法 # // 创建 channel ch := make(chan int) // 传 int 类型的 channel // 发送数据（往管子里塞） ch \u0026lt;- 42 // 接收数据（从管子里取） x := \u0026lt;-ch Channel 的特性：阻塞 # 这是最关键的：\n发送方：ch \u0026lt;- 42 → 没人接收就等着（阻塞） 接收方：x := \u0026lt;-ch → 没人发送就等着（阻塞） 就像一个没有缓冲的传送带：\n你放东西上去，必须有人在另一头接着，否则你就一直举着 你去拿东西，必须有人放了东西，否则你就一直等着 用阻塞特性来同步 # 这就是为什么 channel 能替代 sleep 来等 goroutine：\ndone := make(chan bool) go func() { fmt.Println(\u0026#34;干活中...\u0026#34;) done \u0026lt;- true // 干完了，发信号 }() \u0026lt;-done // 等着，直到收到信号 fmt.Println(\u0026#34;结束\u0026#34;) goroutine 没发信号 → main 在 \u0026lt;-done 那行等着 goroutine 发了信号 → main 继续往下跑 缓冲 Channel # 普通 channel 没有缓冲，一发一收必须配对。可以加缓冲：\nch := make(chan int, 3) // 缓冲区大小为3 ch \u0026lt;- 1 // 塞进去，不等（缓冲区没满） ch \u0026lt;- 2 // 塞进去，不等 ch \u0026lt;- 3 // 塞进去，不等 ch \u0026lt;- 4 // 缓冲区满了，等着（阻塞） 比喻：\n没缓冲：打电话，必须对方接了你才能说话 有缓冲：发微信，对方没看之前你也能发几条 关闭 Channel # 发送方说\u0026quot;我不再发了\u0026quot;：\nclose(ch) 接收方可以检测 channel 是否关闭：\nx, ok := \u0026lt;-ch if !ok { fmt.Println(\u0026#34;channel 已关闭\u0026#34;) } 概念总结 # 操作 含义 make(chan T) 创建 channel ch \u0026lt;- data 发送 x := \u0026lt;-ch 接收 close(ch) 关闭 make(chan T, n) 带缓冲的 channel Select # Select 是什么？ # 一句话：同时监听多个 channel，哪个先有数据就处理哪个。\n为什么需要 Select？ # 假设你有两个 channel：\nch1 := make(chan string) ch2 := make(chan string) 用普通方式只能等一个：\nmsg := \u0026lt;-ch1 // 只能等 ch1，ch2 来了也不知道 用 select 可以同时等：\nselect { case msg := \u0026lt;-ch1: fmt.Println(msg) case msg := \u0026lt;-ch2: fmt.Println(msg) } 哪个先有数据就执行哪个。\n完整例子 # package main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; ) func main() { ch1 := make(chan string) ch2 := make(chan string) // 1秒后往 ch1 塞数据 go func() { time.Sleep(1 * time.Second) ch1 \u0026lt;- \u0026#34;来自 ch1\u0026#34; }() // 2秒后往 ch2 塞数据 go func() { time.Sleep(2 * time.Second) ch2 \u0026lt;- \u0026#34;来自 ch2\u0026#34; }() // 同时等 ch1 和 ch2 select { case msg := \u0026lt;-ch1: fmt.Println(msg) case msg := \u0026lt;-ch2: fmt.Println(msg) } } 输出：来自 ch1（因为 ch1 先到）\nsync.WaitGroup # 为什么需要 WaitGroup？ # 你启动了多个 goroutine，想等它们全部做完再继续：\ngo task1() go task2() go task3() // 怎么知道三个都做完了？ 之前用 channel 可以，但要创建多个 channel 很麻烦。WaitGroup 就是专门解决这个的。\n怎么用？三步 # var wg sync.WaitGroup wg.Add(1) // 第1步：告诉 wg 你要等几个任务 go func() { // 做事情... wg.Done() // 第3步：做完了，报告一下 }() wg.Wait() // 第2步：等着，全部做完才继续 sync.Mutex 互斥锁 # 解决的问题：多个 goroutine 同时读写同一个变量。\nvar mu sync.Mutex mu.Lock() counter++ mu.Unlock() sync.Once # var once sync.Once once.Do(func() { // 这段代码只会执行一次 }) 不管你调多少次 once.Do()，里面的函数只执行第一次。\n什么时候用？初始化操作，比如数据库连接、配置加载，只做一次。\nContext # Context 是什么？ # 一句话：控制 goroutine 的生命周期，告诉它\u0026quot;别干了，取消\u0026quot;。\n为什么需要 context？ # 假设你发了一个 HTTP 请求，用户等不及关了页面：\n用户 → 请求 → 后端启动 goroutine 处理 → 数据库查询中... 用户关了页面 goroutine 还在跑，浪费资源。 用 context 可以通知 goroutine：\u0026ldquo;别查了，用户走了\u0026rdquo;。\n先忘掉代码，想一个场景 # 你叫了一个外卖：\n你：下单（启动 goroutine） 外卖员：开始送餐（goroutine 在跑） 情况1：你等不及了，打电话说\u0026quot;别送了\u0026quot;（手动取消） 情况2：你下单时就说\u0026quot;30分钟不到就取消\u0026quot;（超时取消） context 就是那个电话。\n代码里的角色 # ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx → 外卖员手里的对讲机（能听到\u0026quot;取消\u0026quot;的信号） cancel → 你手里的电话（主动打过去说\u0026quot;取消\u0026quot;） 2*time.Second → 30分钟超时（2秒后自动取消） goroutine 那边怎么听信号？ # select { case \u0026lt;-ctx.Done(): // 对讲机响了，说\u0026#34;取消\u0026#34; fmt.Println(\u0026#34;收到取消，退出\u0026#34;) return default: // 没响，继续干活 fmt.Println(\u0026#34;干活中...\u0026#34;) } ctx.Done() 就是对讲机，context 取消时它就响（channel 关闭）。\n整个流程 # main：创建 context（设定2秒超时） main：启动 worker，把对讲机给它 worker：干活\u0026hellip;干活\u0026hellip; 2秒后：超时，context 自动取消 worker：听到对讲机响了，退出 两种取消方式 # 1. 手动取消：WithCancel # ctx, cancel := context.WithCancel(context.Background()) 你自己决定什么时候取消：\ngo worker(ctx) // 过了一会儿，你决定取消 cancel() // 打电话说\u0026#34;别干了\u0026#34; 就像：你叫了外卖，等了5分钟不耐烦了，主动打电话取消。\n2. 超时取消：WithTimeout # ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 设个时间，到了自动取消：\ngo worker(ctx) // 不用管了，2秒后自动取消 // cancel() 也可以提前调，但不是必须的 Race Detector # 什么是数据竞争？ # 之前 Mutex 的例子，不加锁会出错：\ncounter++ // 多个 goroutine 同时改，结果不对 这就是数据竞争，很难复现，偶尔出错，排查很痛苦。\nRace Detector # Go 自带一个工具，帮你检测数据竞争：\ngo run -race demo.go go build -race -o app go test -race ./... 加一个 -race 就行。\n看个例子 # package main import ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; ) func main() { counter := 0 var wg sync.WaitGroup for i := 0; i \u0026lt; 1000; i++ { wg.Add(1) go func() { defer wg.Done() counter++ }() } wg.Wait() fmt.Println(\u0026#34;counter:\u0026#34;, counter) } go run -race test.go 输出：\nWARNING: DATA RACE ... Found 2 data race(s) -race 告诉你什么 # 哪个 goroutine 在读（goroutine 12） 哪个 goroutine 在写（goroutine 8） 在第几行（第16行：counter++） 一句话总结 # go run -race → 跑代码 + 检测数据竞争 go test -race → 跑测试 + 检测数据竞争（写项目时常用） Worker Pool 并发模式 # 一句话：固定几个 goroutine，从队列里取任务执行。\n为什么需要？ # 假设你有1000个任务：\n// 方式1：每个任务一个 goroutine for i := 0; i \u0026lt; 1000; i++ { go doTask(i) } 启动1000个 goroutine，资源占用大，不好控制。\n// 方式2：Worker Pool，只启动3个 worker for i := 0; i \u0026lt; 3; i++ { go worker(tasks) } // 3个 worker 从 tasks 里取任务，取完一个再取下一个 就像餐厅只有3个厨师，100个菜排队等他们做，做完一个做下一个。\n模型 # 任务队列（channel）：[任务1, 任务2, 任务3, ...任务N] Worker 1：从队列取任务 → 执行 → 再取下一个 Worker 2：从队列取任务 → 执行 → 再取下一个 Worker 3：从队列取任务 → 执行 → 再取下一个 完整代码 # package main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; ) // worker 函数：每个 worker 从 tasks 这个 channel 里不断取任务执行 // id：worker 的编号，用来区分是哪个 worker 在干活 // tasks：任务队列，只读 channel（\u0026lt;-chan 表示只能从里面取，不能塞） func worker(id int, tasks \u0026lt;-chan int) { // range tasks：从 channel 里取任务，取到就执行 // channel 关闭后，range 自动结束，for 循环退出 for task := range tasks { fmt.Printf(\u0026#34;worker %d 开始处理任务 %d\\n\u0026#34;, id, task) time.Sleep(time.Second) // 模拟干活耗时1秒 fmt.Printf(\u0026#34;worker %d 完成任务 %d\\n\u0026#34;, id, task) } } func main() { // 创建一个带缓冲的 channel，缓冲区大小10 tasks := make(chan int, 10) // 启动3个 worker，用 goroutine 并发跑 for i := 1; i \u0026lt;= 3; i++ { go worker(i, tasks) } // 往 tasks 里塞5个任务（编号1到5） for j := 1; j \u0026lt;= 5; j++ { tasks \u0026lt;- j } // 关闭 channel，告诉 worker：\u0026#34;没有新任务了\u0026#34; close(tasks) // 等6秒，让 worker 有时间把任务做完 time.Sleep(6 * time.Second) fmt.Println(\u0026#34;全部完成\u0026#34;) } 执行流程 # main：启动 worker 1、worker 2、worker 3 main：塞任务 1、2、3、4、5 worker 1：取到任务1，开始干 worker 2：取到任务2，开始干 worker 3：取到任务3，开始干 worker 1：任务1做完，取任务4 worker 2：任务2做完，取任务5 worker 3：任务3做完，队列空了，等着\u0026hellip; main：close(tasks) → worker 们收到关闭信号，全部退出 main：打印\u0026quot;全部完成\u0026quot; 到底怎么保证\u0026quot;干完了取下一个\u0026quot;还很有序？ # 关键：channel 的阻塞特性。\nfor task := range tasks { // 干活... } range tasks 会自动做这件事：\n有任务 → 取出来，执行 没任务 → 阻塞，等着 channel 关闭 → 退出 for 循环 展开来看真实流程：\n任务队列（channel）：[1, 2, 3, 4, 5] worker 1：range tasks → 取到 1 → 开始干 worker 2：range tasks → 取到 2 → 开始干 worker 3：range tasks → 取到 3 → 开始干 // 三个 worker 都在干活，队列里还有 4 和 5 worker 1：干完 → range tasks → 取到 4 → 开始干 worker 2：干完 → range tasks → 取到 5 → 开始干 worker 3：干完 → range tasks → 队列空了 → 阻塞，等着 // 没有新任务了 main：close(tasks) worker 3：检测到关闭 → 退出 worker 1：干完任务4 → 检测到关闭 → 退出 worker 2：干完任务5 → 检测到关闭 → 退出 为什么不需要锁？ # 因为 channel 天然安全：\n同一个任务不会被两个 worker 取到 channel 保证：取走一个就少一个 一句话总结 # range channel → 没任务就阻塞，有任务就取一个，取完再取下一个。channel 关闭 → range 自动结束。\n参考源 # raw/Go.md ","date":"2026年7月14日","externalUrl":null,"permalink":"/MyBlog/p/go-concurrency-goroutines-worker-pool/","section":"Posts","summary":"","title":"Go 语言并发：从 Goroutine 到 Worker Pool","type":"posts"},{"content":"","date":"2026年7月14日","externalUrl":null,"permalink":"/MyBlog/tags/goroutine/","section":"Tags","summary":"","title":"Goroutine","type":"tags"},{"content":"","date":"2026年7月14日","externalUrl":null,"permalink":"/MyBlog/tags/worker-pool/","section":"Tags","summary":"","title":"Worker Pool","type":"tags"},{"content":"","date":"2026年7月14日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/","section":"Tags","summary":"","title":"并发编程","type":"tags"},{"content":"","date":"2026年7月13日","externalUrl":null,"permalink":"/MyBlog/tags/go-modules/","section":"Tags","summary":"","title":"Go Modules","type":"tags"},{"content":"承接 Go 语言基础：从变量到指针 和 Go 语言进阶：方法、接口与错误处理，这一篇覆盖 Go 项目的工程化核心：模块管理、包的组织、目录结构。\nGo Modules # 之前写的代码都在单个 .go 文件里，使用 package main，直接 go run xxx.go 就能跑。但真实项目通常会有几十个 .go 文件、多个包，还会用到第三方库。这些东西怎么管理？答案就是 Go Modules。\ngo.mod 是什么？ # go.mod 就像项目的“身份证”，它主要记录三类信息：\n模块路径是什么：module example.com/demo 项目使用哪个 Go 版本：go 1.26.3 项目依赖哪些第三方模块：由 require 指令记录 为什么要用 Go Modules？ # 假设你写了一个项目，用了第三方库。朋友拿到代码后，怎么知道需要哪些依赖和版本？\n没有 go.mod：\n朋友：你用了哪些库？\n你：好像有 xxx、yyy，版本忘了……\n有 go.mod：\n朋友：看 go.mod 就知道了，执行 go mod download 或 go mod tidy 就能准备好依赖。\n常用命令 # go mod init 模块路径 # 在项目开始时创建 go.mod：\ngo mod init example.com/demo 生成的文件类似这样：\nmodule example.com/demo go 1.26.3 练习项目可以使用 demo 这类简单名称；准备发布的模块通常使用代码仓库地址，例如 github.com/用户名/项目名，这样别人才能通过稳定的路径导入它。\ngo mod tidy # go mod tidy 会根据源码中的 import 整理依赖：\n添加代码需要、但 go.mod 还没有记录的模块 删除代码已经不再需要的模块 补充或清理 go.sum 中的校验信息 简单说，它会让模块文件与当前代码保持一致。改完依赖后跑一次，通常是个好习惯。\ngo get 模块路径 # 给当前模块添加或升级依赖：\ngo get github.com/gin-gonic/gin 它会解析并下载依赖，同时更新 go.mod 和 go.sum。如果你要安装一个命令行工具，而不是给当前项目添加依赖，应使用带版本号的 go install：\ngo install example.com/tool@latest go.sum 是什么？ # 执行 go mod tidy、go get 或构建项目后，通常会出现 go.sum。它记录依赖模块内容的加密校验值：\n项目使用某个模块的特定版本 go.sum 保存这个版本及其 go.mod 文件的校验值 再次下载时，Go 会验证拿到的内容是否一致 go.sum 不是用来锁死所有依赖版本的锁文件；真正决定依赖版本的是 go.mod 和 Go 的版本选择规则。go.mod 与 go.sum 都应该提交到 Git，通常不需要手动编辑 go.sum。\npackage 拆分 # 为什么要拆 package？ # 如果所有代码都堆在 main.go 里，用户管理、订单管理和数据库连接会逐渐混在一起。按职责拆包之后，结构会清晰很多：\ndemo/ go.mod main.go # 程序入口 user/ user.go # 用户相关功能 order/ order.go # 订单相关功能 db/ db.go # 数据库相关功能 怎么拆？ # 先创建 user/user.go，声明它属于 user 包：\npackage user import \u0026#34;fmt\u0026#34; type User struct { Name string Age int } func Print(u User) { fmt.Printf(\u0026#34;%s, %d\\n\u0026#34;, u.Name, u.Age) } 再在项目根目录的 main.go 中导入它：\npackage main import \u0026#34;example.com/demo/user\u0026#34; func main() { u := user.User{Name: \u0026#34;Jasper\u0026#34;, Age: 22} user.Print(u) } 这里的导入路径由两部分组成：\nexample.com/demo/user └── 模块路径 ──┘ └包目录 也就是：导入路径 = go.mod 中的模块路径 + 包所在的相对目录。\npackage 与可见性 # 同一目录下的普通 .go 文件必须属于同一个包。包名通常与目录名最后一段一致，这是一条强烈推荐的惯例，但不是“包名必须等于目录名”的语法规则。\n标识符是否能被其他包使用，则由首字母大小写决定：\n写法 可见范围 User、Print 首字母大写，可以被其他包访问 user、print 首字母小写，只能在当前包内访问 internal 访问控制 # internal 是什么？ # 一句话：internal 是 Go 编译器真正认识的特殊目录，用来限制包的导入范围。\n假设项目结构如下：\nmyapp/ go.mod # module example.com/myapp cmd/api/main.go internal/db/db.go example.com/myapp/internal/db 只能被 example.com/myapp 目录树中的代码导入：\n// myapp/cmd/api/main.go：可以导入 import \u0026#34;example.com/myapp/internal/db\u0026#34; 另一个项目则不能导入它：\n// otherapp/main.go：编译报错 import \u0026#34;example.com/myapp/internal/db\u0026#34; 更准确地说，规则不是“同一个模块才能用”，而是：只有位于 internal 父目录树中的代码才能导入它。大多数项目把 internal 放在模块根目录，所以看起来就像“只有本模块能用”。\n目录 编译器是否特殊处理 用途 internal/ 是，限制外部导入 不希望暴露给外部项目的实现 pkg/ 否，只是社区惯例 明确准备对外复用的包，可选 常见项目结构 # Go 官方没有规定一套所有项目都必须照搬的“标准目录结构”。下面是一种常见布局，适合已经出现多个入口和多个业务模块的项目：\nmyproject/ cmd/ # 程序入口 api/ main.go # 启动 HTTP 服务 worker/ main.go # 启动后台任务 internal/ # 不对模块外部开放的代码 config/ config.go # 配置读取 db/ db.go # 数据库连接 user/ user.go # 用户相关逻辑 handler/ handler.go # HTTP 处理函数 pkg/ # 确实需要对外复用时再添加 utils/ utils.go go.mod go.sum 三个常见目录的职责：\ncmd/：每个子目录对应一个可执行程序，包名为 main；通常只负责依赖组装、配置和启动 internal/：放不希望被外部项目导入的实现代码，不代表所有业务逻辑都必须塞在这里 pkg/：表达“这些包准备公开复用”的意图，没有编译器层面的特殊含义；小项目完全可以没有 目录不是越多越工程化。只有一个可执行程序的小项目，把 main.go 放在根目录也很正常；随着入口和职责增多，再引入 cmd/、internal/ 即可。\n为什么要多一层 cmd/？ # 一个项目可能提供多个启动方式：\ncmd/api/main.go # 启动 HTTP 服务 cmd/worker/main.go # 启动后台任务 它们是两个入口，但可以共享 internal/ 里的业务代码。cmd/ 下的程序应尽量轻量：\n// cmd/api/main.go package main import \u0026#34;example.com/myproject/internal/server\u0026#34; func main() { server.Start() } cmd/api 中不一定只能有一个 main.go，也可以有多个属于 package main 的文件；重要的是入口层保持轻薄，不把大量业务逻辑堆在这里。\n引入第三方包 # 标准库是 Go 自带的，例如 fmt、net/http。标准库之外，由其他项目提供的包就是第三方包，例如：\ngithub.com/gin-gonic/gin：Web 框架 github.com/jackc/pgx/v5：PostgreSQL 驱动与工具包 github.com/spf13/cobra：命令行应用框架 以 UUID 包为例，先添加依赖：\ngo get github.com/google/uuid 再在代码里导入并使用：\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;github.com/google/uuid\u0026#34; ) func main() { id := uuid.New() fmt.Println(id) } 此时 go.mod 可能包含：\nmodule example.com/user-api go 1.26.3 require github.com/google/uuid v1.6.0 改动依赖后执行：\ngo mod tidy go test ./... 第一条命令整理模块文件，第二条命令确认整个模块仍能编译并通过测试。到这里，一个 Go 项目从单文件走向模块化工程的基本骨架就搭起来了。\n","date":"2026年7月13日","externalUrl":null,"permalink":"/MyBlog/p/go-engineering-modules-project-structure/","section":"Posts","summary":"","title":"Go 语言工程化：模块管理与项目结构","type":"posts"},{"content":"","date":"2026年7月13日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%B7%A5%E7%A8%8B%E5%8C%96/","section":"Tags","summary":"","title":"工程化","type":"tags"},{"content":"","date":"2026年7月13日","externalUrl":null,"permalink":"/MyBlog/tags/%E9%A1%B9%E7%9B%AE%E7%BB%93%E6%9E%84/","section":"Tags","summary":"","title":"项目结构","type":"tags"},{"content":"一份 Go 入门语法梳理，从变量到指针，覆盖日常开发最常用的核心概念。\n变量声明 # 两种方法。\n1. var 标准形式 # var name type = value // 标准形式 var pi = 22 // 可以自动推断类型，省略 type var score int // 可以不给 value 声明不赋值时，系统自动给零值。\n2. := 简短声明 # name := \u0026#34;草泥马\u0026#34; 比较常见。限制：\n只能在函数里用（全局范围必须用 var） 不能只声明不赋值 常量与 iota # 常量不可更改，直接用 const 声明：\nconst Pi = 3.14 const name = \u0026#34;xxx\u0026#34; 常量必须在编译期确定，不能把运行时的值赋给常量：\nconst now = time.Now() // ✗ 编译报错，time.Now() 是运行时才有的 iota 是 Go 在 const 块里提供的自动计数器：\nconst ( Sunday = iota // 0 Monday // 1 Tuesday // 2 Wednesday // 3 ) 比较简洁。想跳过某个值，用 _ = iota 占位就好。\n注意：Go 没有 enum 关键字，做枚举需要用 const + iota。\n数据类型 # Go 是强类型语言，类型间不能自动转换（区别于 JS、Python），只能手动强制转换：\nvar a = 10 var b = 3.14 // 要算 a + b，必须先转类型 float64(a) + b 基本类型一览 # var i int = 42 // 平台相关，64 位系统就是 64 位 var i8 int8 = 127 // 范围 -128 ~ 127 var u uint = 42 // 无符号，没有负数 var f32 float32 = 3.14 var f64 float64 = 3.14 // 一般都用 float64 var s string = \u0026#34;hello\u0026#34; var b bool = true // 只有 true/false，没有 1/0 rune 与 byte 的区别 # var b byte = \u0026#39;A\u0026#39; // byte 就是 uint8，存储一个 ASCII 字符 var r rune = \u0026#39;中\u0026#39; // rune 就是 int32，存储一个 Unicode 字符 为什么要这样？英文字母只占一个字节，而中文、emoji 占 3~4 个字节，一个 byte 装不下，需要 rune。\ns := \u0026#34;hello\u0026#34; len(s) // 5，len 对 string 返回字节数 len([]rune(s)) // 5，返回字符数 想拿字符数就用 len([]rune(s))。原理：[]rune(s) 把 string 转成 []rune 切片。\n字符串的两种写法 # s1 := \u0026#34;hello\\nworld\u0026#34; // 双引号，解释型：\\n 会被解释成换行 s2 := `hello\\nworld` // 反引号，原始字符串：字面上的 \\n 不会换行 切片 Slice # 先说数组，但几乎不会用它：\nvar arr [3]int = [3]int{1, 2, 3} // 长度固定，声明时定死 所以实际开发用可变数组——切片。\nslice 声明 # nums := []int{1, 2, 3} // 字面量 s := make([]int, 0, 10) // make 指定长度 0，容量 10 var names []string // nil，长度 0，后面 append len 与 cap # 每个 slice 有两个属性：\nlen 长度：里面实际有多少个元素 cap 容量：底层数组最多能装多少个 s := make([]int, 3, 10) fmt.Println(len(s)) // 3 fmt.Println(cap(s)) // 10 append 添加元素 # nums := []int{1, 2, 3} nums = append(nums, 4) // 末尾多了个 4 nums = append(nums, 5, 6, 7) // now [1,2,3,4,5,6,7] 注意：append 返回的是新的 slice，必须赋值回去。\n扩容：当 append 超过 cap 时，Go 会自动分配一个更大的底层数组，把旧数据拷过去。你不需要手动管，但要知道这个机制存在——它会影响性能。\n切片操作 # nums := []int{10, 20, 30, 40, 50} sub := nums[1:3] // 左闭右开 → [20, 30] all := nums[:] // 全部 first := nums[:2] // [10, 20] last := nums[2:] // [30, 40, 50] ⚠️ 陷阱：切片出来的 sub 和原 slice 共享底层数组，修改 sub 会影响 nums：\nsub[0] = 999 fmt.Println(nums) // [10, 999, 30, 40, 50] —— nums 也被改了！ 要独立拷贝用 copy。\n遍历 # fruits := []string{\u0026#34;apple\u0026#34;, \u0026#34;banana\u0026#34;, \u0026#34;cherry\u0026#34;} for i, v := range fruits { fmt.Printf(\u0026#34;[%d] %s\\n\u0026#34;, i, v) } // 不要索引就用 _ for _, v := range fruits { // ... } Map # 其实就是映射。\n// 创建一个 map，key 是 string，value 是 int scores := map[string]int{ \u0026#34;math\u0026#34;: 90, \u0026#34;english\u0026#34;: 85, \u0026#34;chinese\u0026#34;: 92, } // 取值 fmt.Println(scores[\u0026#34;math\u0026#34;]) // 90 // 添加/修改 scores[\u0026#34;science\u0026#34;] = 88 // 新增 scores[\u0026#34;math\u0026#34;] = 95 // 修改（key 已存在就覆盖） // 删除 delete(scores, \u0026#34;english\u0026#34;) 长度用 len()。\nComma-ok 模式 # 取一个不存在的 key 会怎么样？一般直接返回 value 类型的零值。但这会出问题——0 是真实分数，还是因为 key 不存在？所以提供 comma-ok 模式：\nscore, ok := scores[\u0026#34;physics\u0026#34;] if ok { fmt.Println(\u0026#34;找到了：\u0026#34;, score) } else { fmt.Println(\u0026#34;不存在\u0026#34;) } // ok 是 bool 型变量 Map 的零值 # var m map[string]int // nil map m[\u0026#34;a\u0026#34;] = 1 // 🙅 nil map 不能写入 和 slice 一样，声明之后必须初始化才能使用：\nm := map[string]int{} // 方式 1：空字面量 m := make(map[string]int) // 方式 2：make 遍历 # scores := map[string]int{\u0026#34;math\u0026#34;: 90, \u0026#34;english\u0026#34;: 85} for subject, score := range scores { // 同时给 subject、score 赋值 fmt.Printf(\u0026#34;%s: %d\\n\u0026#34;, subject, score) } 结构体 Struct # 结构体的知识在 C/C++ 中已经讲过很多，这里给个框架：\ntype Car struct { Brand string Color string Money float64 } 创建实例：\nvar c Car c.Brand = \u0026#34;特斯拉\u0026#34; fmt.Println(c.Brand) struct 与 map/slice 的区别 # struct 是值类型，不同于 map、slice 底层数据共享。struct 可以直接赋值、修改元素，赋值时会拷贝一份完整数据。\nstruct 与 JSON 互转 # 这是实际开发中的高频操作。\nimport \u0026#34;encoding/json\u0026#34; func main() { c := Car{Brand: \u0026#34;特斯拉\u0026#34;, Color: \u0026#34;白色\u0026#34;, Money: 25.99} // 转成 JSON jsonData, err := json.Marshal(c) if err != nil { fmt.Println(\u0026#34;转换失败:\u0026#34;, err) return } fmt.Println(string(jsonData)) // string() 将字节数组转为可读字符串 } ⚠️ 问题：JSON 里的字段是大写的 Brand，但前端习惯小写。\n解决：struct tag，用反引号包裹：\ntype Car struct { Brand string `json:\u0026#34;brand\u0026#34;` Color string `json:\u0026#34;color\u0026#34;` Money float64 `json:\u0026#34;money\u0026#34;` } 这样序列化出来的 JSON key 就是小写了。注意反引号 ` 里面整个 json:\u0026quot;xxx\u0026quot; 是一个整体，冒号后面不能有空格。\n流程控制 # 三个核心：if/else、switch、for（Go 没有 while）。\nif/else # Go 的 if 可以带初始化语句，用 ; 分隔：\nif age := 20; age \u0026gt;= 18 { fmt.Println(\u0026#34;成年人\u0026#34;) } // age 仅在 if 块内生效 ⚠️ 如果用到了 else，Go 强制要求 } else { 必须在同一行，不然编译报错：\nif condition { // ... } else { // ... } switch # Go 的 switch 不需要写 break，每个 case 自动结束：\nday := \u0026#34;Monday\u0026#34; switch day { case \u0026#34;Monday\u0026#34;: fmt.Println(\u0026#34;星期一\u0026#34;) case \u0026#34;Tuesday\u0026#34;: fmt.Println(\u0026#34;星期二\u0026#34;) default: fmt.Println(\u0026#34;其他\u0026#34;) } for 循环 # Go 只有 for，但有四种写法：\n1. 经典写法（和 C 一样）\nfor i := 0; i \u0026lt; 5; i++ { // ... } 2. 当 while 用（只有一个条件）\ni := 0 for i \u0026lt; 5 { i++ } 3. 无限循环（配合 break）\nfor { fmt.Println(\u0026#34;一直跑\u0026#34;) break } 4. range 遍历（实际开发中用得最多）\n// 遍历 slice nums := []int{10, 20, 30} for i, v := range nums { fmt.Println(i, v) } // 遍历 map scores := map[string]int{\u0026#34;张三\u0026#34;: 90, \u0026#34;李四\u0026#34;: 85} for k, v := range scores { fmt.Println(k, v) } range 后面可以只用一个变量接收值，用 _ 跳过不需要的：\nfor _, v := range nums { // 只要值，不要索引 } 注意：for 的三部分不用括号，但分号不能省。\n函数 # Go 用 func 定义函数，函数必须定义在函数外。格式正常，重点看多返回值——这是 Go 的特色。\nfunc divide(a, b float64) (float64, error) { if b == 0 { return 0, fmt.Errorf(\u0026#34;除数不能为零\u0026#34;) } return a / b, nil } func main() { result, err := divide(10, 3) if err != nil { fmt.Println(\u0026#34;出错了:\u0026#34;, err) } else { fmt.Println(\u0026#34;结果:\u0026#34;, result) } } 几点注意：\nerror 是 Go 内置的接口类型，表示错误 没出错时返回 nil fmt.Errorf 创建一个带错误信息的 error Go 的错误处理模式就是 返回值 + if 判断，没有 try-catch。这在后面写项目时会反复见到。\n指针 # 核心概念：\n概念 符号 作用 取地址 \u0026amp;a 获取变量 a 的内存地址 解引用 *p 通过地址，读取或修改那个地址里的值 指针类型：\n普通类型 对应指针类型 读法 int *int 指向 int 的指针 string *string 指向 string 的指针 User *User 指向 User 的指针 指针的零值是 nil：\nvar p *int // p 的零值是 nil，没有指向任何东西 两个必须记住的事：\nGo 函数传参永远是复制。想改原件，必须传指针。 \u0026amp; 是\u0026quot;取地址\u0026quot;，* 是\u0026quot;去地址找东西\u0026quot;。两个方向相反。 直觉：普通变量是盒子里装着值，指针是纸条上写着\u0026quot;盒子在哪\u0026quot;。\n指针与 struct # func birthday(u *User) { u.Age++ // Go 自动解引用，不用写 *u（语法糖） } func main() { user := User{\u0026#34;Jasper\u0026#34;, 22} birthday(\u0026amp;user) // 传地址 fmt.Println(user.Age) // 23 } Go 中局部变量习惯用小驼峰 maxStudent 而不是大驼峰 MaxStudent。import 用小括号 () 而不是花括号 {}。\n练习：学生成绩管理 # 一个综合 struct、slice、指针的小练习：\npackage main import \u0026#34;fmt\u0026#34; type Student struct { Name string Grade float64 } // 找出成绩最高的学生 func findMaxGrade(students []Student) Student { maxStudent := students[0] for _, student := range students { if student.Grade \u0026gt; maxStudent.Grade { maxStudent = student } } return maxStudent } // 给学生加 5 分（通过指针修改原件，不需要返回值） func plusFive(student *Student) { student.Grade += 5 } func main() { students := []Student{ {Name: \u0026#34;Alice\u0026#34;, Grade: 85}, {Name: \u0026#34;Bob\u0026#34;, Grade: 90}, {Name: \u0026#34;Charlie\u0026#34;, Grade: 78}, } maxStudent := findMaxGrade(students) plusFive(\u0026amp;maxStudent) fmt.Printf(\u0026#34;最高分学生: %s, 成绩: %.2f\\n\u0026#34;, maxStudent.Name, maxStudent.Grade) } 参考源 # raw/go学习之路.md raw/go.md ","date":"2026年7月7日","externalUrl":null,"permalink":"/MyBlog/p/go-basics-variables-to-pointers/","section":"Posts","summary":"","title":"Go 语言基础：从变量到指针","type":"posts"},{"content":"承接 Go 语言基础：从变量到指针，这一篇覆盖 Go 的进阶核心概念：方法、接口、错误处理，以及泛型入门。\n方法 # // 普通函数：参数是 Student func greet(s Student) string { return \u0026#34;Hello, \u0026#34; + s.Name } // 方法：绑在 Student 上 func (s Student) greet() string { return \u0026#34;Hello, \u0026#34; + s.Name } 区别就在于函数名前面多了一个 (s Student)，这叫接收者（receiver）。\n调用方式不同：\nstudent := Student{\u0026#34;Jasper\u0026#34;, 90} // 函数调用 greet(student) // 方法调用 student.greet() 返回一个字符串可以用 fmt.Sprintf。\n值接收者 vs 指针接收者 # 在接收者的类型前加 * 就变成指针接收者。值接收者只改副本，指针接收者改原件。\n// 值接收者：改的是副本 func (s Student) setScore(score float64) { s.Grade = score // 不影响原值 } // 指针接收者：改的是原件 func (s *Student) setScore(score float64) { s.Grade = score // 修改原值 } 接口 # Go 中最重要的概念之一。\n// 定义接口：任何有 speak() 方法的类型，都算 Speaker type Speaker interface { speak() string } // Dog 有 speak() 方法，Dog 实现了 Speaker type Dog struct { Name string } func (d Dog) speak() string { return \u0026#34;Woof\u0026#34; } // 同理 Cat 亦如此 type Cat struct { Name string } func (c Cat) speak() string { return \u0026#34;Meow\u0026#34; } // 通用函数：接受任何 Speaker func makeItSpeak(s Speaker) { fmt.Println(s.speak()) } func main() { dog := Dog{\u0026#34;Buddy\u0026#34;} cat := Cat{\u0026#34;Kitty\u0026#34;} makeItSpeak(dog) // Woof! makeItSpeak(cat) // Meow! } 三个要点：\n接口定义：type Speaker interface { speak() string } — 定义了一个\u0026quot;能力\u0026quot;，不关心谁实现 隐式实现：Dog 和 Cat 都有 speak() 方法，所以它们自动\u0026quot;算\u0026quot;是 Speaker，不需要显式声明 implements 通用函数：makeItSpeak(s Speaker) 接受任何 Speaker，不关心具体是 Dog 还是 Cat 嵌入接口 # 小接口可以组合成大接口：\n// 两个独立接口 type Speaker interface { speak() string } type Walker interface { walk() string } // 嵌入组合 type SpeakerWalker interface { Speaker // 把 Speaker 的方法拿过来 Walker // 把 Walker 的方法拿过来 } 效果：SpeakerWalker 自动拥有 speak() 和 walk() 两个方法。\n接口哲学：小接口 \u0026gt; 大接口 # Go 的接口是隐式实现的：\n// C++ / Java：必须显式声明\u0026#34;我实现了这个接口\u0026#34; class Person implements Speaker { ... } // Go：只要你有方法，就自动满足接口 type Person struct { Name string } func (p Person) speak() string { return \u0026#34;hi\u0026#34; } // Person 自动满足 Speaker，不需要声明 标准库里最常见的接口：\n// io.Reader — 只有一个方法 type Reader interface { Read(p []byte) (n int, err error) } // io.Writer — 只有一个方法 type Writer interface { Write(p []byte) (n int, err error) } Go 标准库里很多接口只有 1~2 个方法，而不是把所有功能堆在一个大接口里。\n为什么小接口好？\n容易实现 — 一个方法的接口，任何类型都能轻松满足 容易组合 — 用嵌入把小接口组合成大接口 灵活 — 你只关心你需要的能力，不关心具体类型 特性 Go Java/C++ 实现方式 隐式（自动满足） 显式（需要声明） 接口大小 通常 1-2 个方法 通常很多方法 设计哲学 小接口，按需组合 大接口，一次定义 io.Reader 与 io.Writer # 这是 Go 标准库里最常用的两个接口，用来处理\u0026quot;数据流\u0026quot;。\n先想一个问题：从哪里读数据？\n从文件读 从网络读 从键盘读 从字符串读 这些地方都能读数据，但它们的实现完全不同。接口解决这个问题：\n// io.Reader：任何\u0026#34;能读出数据\u0026#34;的东西 type Reader interface { Read(p []byte) (n int, err error) } 翻译成大白话：\n\u0026ldquo;你只要有 Read 方法，能往 p 里填数据，你就是个 Reader\u0026rdquo;\n参数/返回 含义 p []byte 一个空的容器（桶），你把读到的数据放进去 返回 n int 实际读了多少字节 返回 err error 读取过程中有没有出错 为什么这个接口牛？因为它只有一个方法，所以实现简单、使用广泛——标准库里几百个函数都接受 Reader。\n// 这些类型都实现了 io.Reader // - os.File （文件） // - strings.Reader （字符串） // - bytes.Buffer （字节缓冲区） // - net.Conn （网络连接） // 通用函数：不关心数据从哪来，只管读 func printAll(r io.Reader) { data := make([]byte, 100) n, _ := r.Read(data) fmt.Println(string(data[:n])) } // 从文件读 file, _ := os.Open(\u0026#34;test.txt\u0026#34;) printAll(file) // 从字符串读 str := strings.NewReader(\u0026#34;hello\u0026#34;) printAll(str) 同一个函数，能处理不同类型的数据源——这就是接口的价值。\n接口 方法数 作用 io.Reader 1 能读 io.Writer 1 能写 io.Closer 1 能关闭 io.ReadWriter 2 能读能写（嵌入组合） Go 的设计哲学：把大能力拆成小接口，按需组合。\n空接口 any 与类型断言 # 有时候需要处理\u0026quot;任意类型\u0026quot;，这时候用空接口：\n// any 是 interface{} 的别名 func printAnything(v any) { fmt.Println(v) } 但是有个问题：拿到的是 any，不知道具体的类型怎么办？此时引出类型断言与 type switch。\n类型断言 # func printAnything(v any) { if s, ok := v.(string); ok { fmt.Println(\u0026#34;是字符串:\u0026#34;, s) } else if n, ok := v.(int); ok { fmt.Println(\u0026#34;是整数:\u0026#34;, n) } else { fmt.Println(\u0026#34;其他类型:\u0026#34;, v) } } Type Switch # 更简洁的写法：\nfunc printAnything(v any) { switch val := v.(type) { case string: fmt.Println(\u0026#34;是字符串:\u0026#34;, val) case Student: fmt.Println(\u0026#34;是学生:\u0026#34;, val) } } v.(type) 是 Go 的特殊语法，只能在 switch 里用，意思是\u0026quot;告诉我 v 的实际类型是什么\u0026quot;。val 会自动变成对应分支的类型。\nComma-Ok 模式详解 # 这是 Go 里处理\u0026quot;可能失败的操作\u0026quot;的通用模式，类型断言只是其中一个用法。\ns, ok := v.(string) 尝试把 v 转成 string 如果成功：s = 转换后的值，ok = true 如果失败：s = 空字符串零值，ok = false 关键点：如果不写 ok，断言失败会直接 panic（程序崩溃）。所以一般都用 , ok 安全地处理：\n// 不安全：失败会 panic s := v.(string) // 安全：失败只是 ok = false s, ok := v.(string) 接口 vs Type Switch # 方式 优点 缺点 接口 扩展性好，加新类型不用改函数 需要提前定义接口 Type Switch 直观，能处理任意类型 加新类型要改函数 错误处理 # strconv 是 Go 标准库里专门处理字符串和数字之间转换的包。strconv.Atoi = ASCII to Integer。\nGo 的错误处理模式：\nresult, err := someFunction() if err != nil { // 处理错误 return ..., err // 往上层传 } // 正常使用 result 核心规则：\n错误是普通值，函数返回 error 接口 nil = 没错，非 nil = 有错（带错误信息） 函数只返回错误，调用方决定怎么处理 error 接口 # error 是 Go 内置的接口，只有一个方法：\ntype error interface { Error() string } 任何实现了 Error() string 方法的类型，都算 error。\n创建自己的错误 # 使用 errors.New 或 fmt.Errorf：\nimport \u0026#34;errors\u0026#34; func divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New(\u0026#34;除数不能为零\u0026#34;) } return a / b, nil } 或者用 fmt.Errorf 带格式化：\nreturn 0, fmt.Errorf(\u0026#34;除数不能为零：%f\u0026#34;, b) 错误包装 # 有时候你想在错误上加点上下文，但是不想丢失原始错误：\nfunc readConfig(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { // 包装原始错误，加上上下文 return \u0026#34;\u0026#34;, fmt.Errorf(\u0026#34;读取配置文件失败：%w\u0026#34;, err) } return string(data), nil } 然后用 errors.Is 判断错误类型：\ncontent, err := readFile(\u0026#34;不存在的文件.txt\u0026#34;) if err != nil { fmt.Println(\u0026#34;错误:\u0026#34;, err) if errors.Is(err, os.ErrNotExist) { fmt.Println(\u0026#34;原因：文件不存在\u0026#34;) } } 设计理念：错误处理就像\u0026quot;层层汇报\u0026quot; # 想象一个公司出了问题：\n员工：硬盘坏了（原始错误） 主管：服务器出问题了（包装一层） 经理：系统故障（再包装一层） 如果只传一句话：经理只知道\u0026quot;系统故障\u0026quot;，不知道根本原因，没法针对性解决。\n如果层层包装但保留原始原因：经理收到 → 系统故障 → 服务器出问题 → 硬盘坏了。经理可以用 errors.Is 查问：\u0026ldquo;是不是硬盘的问题？\u0026quot;——穿透所有包装，直接找到根本原因。\n代码对应：\n// 底层：硬盘坏了 os.ReadFile(\u0026#34;xxx\u0026#34;) // 返回 os.ErrNotExist（文件不存在） // 中层：服务器出问题了 fmt.Errorf(\u0026#34;读取文件失败: %w\u0026#34;, err) // 包装，但保留原始错误 // 顶层：经理 if errors.Is(err, os.ErrNotExist) { // 穿透包装，发现根本原因是\u0026#34;文件不存在\u0026#34; fmt.Println(\u0026#34;原因：文件不存在\u0026#34;) } 三种方式对比：\n// 方式1：只传原始错误（不好） return err // main 只知道 \u0026#34;no such file or directory\u0026#34;，不知道是哪个文件 // 方式2：只传新错误（不好） return errors.New(\u0026#34;读取文件失败\u0026#34;) // main 不知道为什么失败 // 方式3：包装（正确） return fmt.Errorf(\u0026#34;读取文件失败: %w\u0026#34;, err) // main 既知道上下文，又能判断原因 Sentinel Errors（哨兵错误） # 就是预先定义好的错误值，让我们可以用 errors.Is 判断。os.ErrNotExist 就是一个 Sentinel Error。自己也可以定义：\nvar ErrInvalidAge = errors.New(\u0026#34;年龄不能为负数\u0026#34;) func setAge(age int) error { if age \u0026lt; 0 { return ErrInvalidAge } return nil } // 调用方 err := setAge(-1) if errors.Is(err, ErrInvalidAge) { fmt.Println(\u0026#34;年龄不合法\u0026#34;) } panic 与 recover # Go 中大部分用 error 处理错误，但有些情况下程序直接崩溃——panic。\npanic：程序崩溃 recover：捕获 panic，让程序继续运行 什么时候用 panic # 程序遇到无法继续运行的严重错误：\nfunc divide(a, b int) int { if b == 0 { panic(\u0026#34;除数不能为零\u0026#34;) // 程序崩溃 } return a / b } 什么时候用 recover # 在服务器程序里，你不想因为一个请求崩溃就让整个服务器挂掉：\nfunc safeDivide(a, b int) (result int, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf(\u0026#34;捕获 panic: %v\u0026#34;, r) } // %v 是 Go 里最通用的格式化动词，意思是\u0026#34;用默认格式打印值\u0026#34; }() return divide(a, b), nil } 几个要点：\n命名返回值：(result int, err error) — Go 允许给返回值起名字，函数内部可以直接用这些名字，return 时可以省略返回值 defer：延迟执行，函数结束的时候才运行 recover：捕获 panic 的唯一方式，只能在 defer 里用 // defer 里的匿名函数：定义后立即调用 defer func() { if r := recover(); r != nil { // r 是 panic 传出来的信息 fmt.Println(\u0026#34;捕获到 panic:\u0026#34;, r) } }() // 定义完立刻调用 泛型（了解即可） # 为什么需要泛型？ # 没有泛型时，同一个逻辑要写多份：\nfunc maxInt(a, b int) int { if a \u0026gt; b { return a } return b } func maxFloat(a, b float64) float64 { if a \u0026gt; b { return a } return b } 逻辑完全一样，只是类型不同。泛型用一个函数搞定：\nfunc max[T int | float64 | string](a, b T) T { if a \u0026gt; b { return a } return b } 泛型函数语法 # func 函数名[类型参数 约束](参数) 返回类型 拆解 func max[T int | float64 | string](a, b T) T：\nfunc max → 函数名 [T int | float64 | string] → T 可以是 int、float64 或 string (a, b T) → a 和 b 都是 T 类型 T → 返回类型也是 T 调用时 Go 会自动推断 T 的类型：\nmax(1, 2) // T = int max(1.5, 2.5) // T = float64 max(\u0026#34;hello\u0026#34;, \u0026#34;world\u0026#34;) // T = string 类型约束 # 约束就是告诉编译器\u0026quot;T 必须是什么类型\u0026rdquo;。\n常用约束：\n[any] → 任意类型 [comparable] → 可以用 == 比较的类型 [constraints.Ordered] → 可以用 \u0026lt; \u0026gt; 比较的类型（int、float64、string） 自定义约束：\ntype Number interface { int | int32 | int64 | float32 | float64 } func sum[T Number](nums []T) T { var total T for _, n := range nums { total += n } return total } 泛型类型 # 泛型还能用在 struct 上：\ntype Stack[T any] struct { items []T } func (s *Stack[T]) push(item T) { s.items = append(s.items, item) } func (s *Stack[T]) pop() T { item := s.items[len(s.items)-1] s.items = s.items[:len(s.items)-1] return item } // 使用 intStack := \u0026amp;Stack[int]{} intStack.push(1) intStack.push(2) fmt.Println(intStack.pop()) // 2 strStack := \u0026amp;Stack[string]{} strStack.push(\u0026#34;hello\u0026#34;) fmt.Println(strStack.pop()) // hello 参考源 # raw/Go语言.md ","date":"2026年7月7日","externalUrl":null,"permalink":"/MyBlog/p/go-methods-interfaces-errors/","section":"Posts","summary":"","title":"Go 语言进阶：方法、接口与错误处理","type":"posts"},{"content":"","date":"2026年7月7日","externalUrl":null,"permalink":"/MyBlog/tags/%E7%BC%96%E7%A8%8B%E5%9F%BA%E7%A1%80/","section":"Tags","summary":"","title":"编程基础","type":"tags"},{"content":"","date":"2026年7月7日","externalUrl":null,"permalink":"/MyBlog/tags/%E9%94%99%E8%AF%AF%E5%A4%84%E7%90%86/","section":"Tags","summary":"","title":"错误处理","type":"tags"},{"content":"","date":"2026年7月7日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%96%B9%E6%B3%95/","section":"Tags","summary":"","title":"方法","type":"tags"},{"content":"","date":"2026年7月7日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%8E%A5%E5%8F%A3/","section":"Tags","summary":"","title":"接口","type":"tags"},{"content":"","date":"2026年7月7日","externalUrl":null,"permalink":"/MyBlog/tags/%E6%8C%87%E9%92%88/","section":"Tags","summary":"","title":"指针","type":"tags"},{"content":"","date":"2026年6月1日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%A4%8D%E7%94%A8/","section":"Tags","summary":"","title":"复用","type":"tags"},{"content":"","date":"2026年6月1日","externalUrl":null,"permalink":"/MyBlog/tags/%E9%97%B4%E9%9A%94%E9%87%8D%E5%A4%8D/","section":"Tags","summary":"","title":"间隔重复","type":"tags"},{"content":"","date":"2026年6月1日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%86%85%E7%9C%81/","section":"Tags","summary":"","title":"内省","type":"tags"},{"content":"此篇致敬 Thoughts Memo《学会如何学习》，salute to 最伟大的互联网精神。这不是一篇反思文，而是一次复用。\n迭代 # 学习是一个强个性化的终身过程。不必等到找到\u0026quot;正确方法\u0026quot;再出发——寻找能进化你学习方式的线索，去尝试，去犯错，重复这个过程本身就是在完善它。\n学习模式本无高下，习得的内容会替我们说话。所以大胆尝试：深度结合 AI 也好，把知识抽象成不同模块也好，不要因为路径\u0026quot;非主流\u0026quot;就畏缩。最值得的上行风险，往往藏在别人觉得麻烦的地方。\n内省 # 球感、语感、直觉——这些抽象在知识体系之外的东西，占据了学习中相当重要的部分。尊重自己内部的感知信号，它们往往比理性分析更早发现问题所在。\n感到困惑或惊讶时，follow 这种情绪——它在指引你投入精力。 困惑和意外，正是对薄弱点的非理性感知在发光。 困惑时不要硬撑，向他人或 AI 求助。 如果觉得某个结论\u0026quot;似乎没什么适用边界\u0026quot;，往往是忽略了关键前提。 如果感知到\u0026quot;这东西空洞无用\u0026quot;，多半是在纸上谈兵——去实践，亲手弄懂它。 觉察那些\u0026quot;一头雾水、某个环节卡住\u0026quot;的感觉，找到那个 key。这是最重要的事。 觉察你对什么乐在其中，不断深挖，那里有充分的知识扩展和真实的乐趣。 二八法则 # 我们被庞大的信息流包裹，自媒体时代更甚。知识唾手可得，但质量参差不齐。高效学习的前提，是有能力取舍——识别出如何用 20% 的努力获取 80% 的核心价值。\n遇到一个新知识点，不妨先问自己：\n这里到底在讲什么？我的目标是什么？ 我为什么要关心这个？ 如果要从零推导，路径是什么？ 哪里让我感到不快或困惑？ 学会放弃同样是一种能力。不确定某个方向是否值得投入时，直接问 AI——比自己纠结半小时更高效，也更诚实。\n教学相长 # 不把\u0026quot;教学\u0026quot;理解成狭义的站在讲台上，而是一种更抽象的行为模式——复用。\n知识的流转路径大致如此：从不同来源获取信息，以语言为媒介吸收理解，转化为抽象的概念储存在神经元中；遇到合适场景时，再将这一过程逆向输出。这个长链路不可避免地会产生漏洞，而漏洞往往正是困惑和意外出现的时刻。\n教学，是这一逆向工程最极致的体现。当你发现某件事对他人讲不清楚，那就是需要再次强化、重新复用的信号。这篇博客本身，也是复用的一部分。\n向他人解释一个概念，是发现自己到底懂没懂的最快方式。\n间隔重复 # 一个被严重低估的具体技巧。记忆信息最高效的方式，不是反复阅读，而是主动提取——反复接受测试。测试次数越多，遗忘所需的时间就越长，且复习间隔会指数级拉开，全程记忆保持稳固。\nAnki 这类工具已经把这套机制做得很好用了。死记硬背名声不佳，但它有真实价值：确保重要知识能信手拈来，而不是每次都要去翻查。\n把一个知识熟稔于心和需要去查找它之间的差距，远比看起来大——因为它消除了摩擦力。而摩擦力，是行动、创造和进步的重大障碍。\n不过有一点要清醒：间隔重复不适合用来理解知识，它的理想用例是那些你已经有高层次理解的东西——用它来确保那些东西不会丢。\n后记 # 当知识廉价到只需发出疑问便可收获，对知识的筛选、处理与加工能力，反而变得更加稀缺和珍贵。AI 时代对人的变革与分化，我一次次感受得到。\n这是最好的时代，这是最坏的时代。\n","date":"2026年6月1日","externalUrl":null,"permalink":"/MyBlog/p/how-to-learn/","section":"Posts","summary":"","title":"学习这件事","type":"posts"},{"content":"","date":"2026年5月31日","externalUrl":null,"permalink":"/MyBlog/tags/ai%E5%AD%A6%E4%B9%A0/","section":"Tags","summary":"","title":"AI学习","type":"tags"},{"content":"","date":"2026年5月31日","externalUrl":null,"permalink":"/MyBlog/tags/llm/","section":"Tags","summary":"","title":"LLM","type":"tags"},{"content":"近日在学习CS61A，起初的学习是通过看视频课程，做对应lab，project，以求完善知识，但是很快发现视频太过于冗长，虽然保留了最生动的思维模式，可是对于想要快速获得领会其中知识的人来说，无疑是个煎熬，那么此时我便把目光转向了官方的教程文档，文档比起视频精简许多，文字的知识传播效率非常高（前提是你能充分理解）但文档的规整性迫使遣词造句非常复杂化，在学习进度的压迫下，我们无法慢慢啃骨头，所以就会引入ai的帮助。\nAI、LLM、Agent 的区分 # 无论是博主本身还是身边的人都会出现基础的混淆：\nAI：是一个泛称，任何能让机器表现的像智能的技术 LLM：本质类似函数，接受 token 序列，输出下一个 token 的概率分布，训练完成后权重被固定 Agent：在 LLM 下套了系统，包括工具调用、记忆、规划 在此之下还引入了另一个概念——Token：其实就是 LLM 处理文本所接受的最小单元，任何语言图片符号均会被转化成 token 输入给 LLM。\nAI辅助的学习闭环陷阱 # 我通常使用 Claude 帮助，把一大段艰难晦涩的知识点喂给他，而后阅读他精简的版本，获得最好的理解，在此之后，我会让 Claude 输出讲解的 md 文件放在我固定的 vault 文件夹下。\n但是整个逻辑在此闭环了，发现问题所在了吗？\n从始至终，我的知识理解仅限一个环节，就是 ai 的讲解，我确认我理解，然后过。记忆的过程原本是跟着啃文档的长时间生跟发芽的，而现在呢，知识被解耦成易于理解的部分后，时间成本的投入降低，进而导致知识的留存度过低。\n这件事不仅仅限于看课程，当做对应的 lab，project 时，遇到英文，或是不会的太常见了，现在就会进入求助 ai，ai 引导，简化知识，最后你负责填写了一些关键代码思路的空，感觉自己动了，实则当时间的维度被拉长到数天，甚至不用数个月，就会发现，这都是错觉。\n解法 # 那应该怎么去解决这个问题呢？详见对学习的反思篇博客，再次简单的概述：\n我觉得学习的逻辑线收束必定要有输出，无论是自己写一遍代码，或者是自己写一篇习得内容的博客，这本身就是对信息的强化。对于必要的值得记忆的信息，还应该借助 Anki 去按周期复习，才能获得最好的成效。\n在 AI 时代无疑提供了很大的学习便利，但是所谓 AI 幻觉的产生，本质是对快速获取知识红利的追偿。\n","date":"2026年5月31日","externalUrl":null,"permalink":"/MyBlog/p/ai-learning-illusion/","section":"Posts","summary":"","title":"对于AI辅助学习的见解——AI错觉的拨乱","type":"posts"},{"content":"","date":"2026年5月31日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%AD%A6%E4%B9%A0%E9%97%AD%E7%8E%AF/","section":"Tags","summary":"","title":"学习闭环","type":"tags"},{"content":" 第一篇博文 # 每次遇到这种有些重大意义的，为自己作结抑或为自己做首的时候，总是想的多，落到笔头上就寥寥无几了。\n谢谢这一路以来遇到的朋友，老师，家人，是你们给了我经历美好的勇气\n","date":"2026年5月12日","externalUrl":null,"permalink":"/MyBlog/p/hello-world/","section":"Posts","summary":"","title":"JasperSao的首篇博文","type":"posts"},{"content":"","date":"2026年5月12日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%8D%9A%E5%AE%A2/","section":"Tags","summary":"","title":"博客","type":"tags"},{"content":"","date":"2026年5月12日","externalUrl":null,"permalink":"/MyBlog/tags/%E5%BC%80%E5%A7%8B/","section":"Tags","summary":"","title":"开始","type":"tags"}]