尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

我实现了一个 RPC:sRPC实现与 gRPC 对照

我实现了一个 RPC:sRPC实现与 gRPC 对照 一、为什么要自研一个 RPC动机与取舍上一篇我们学会了 gRPC——它是标准答案。这一篇反过来讲我自己造的 sRPC一个不依赖 gRPC、不依赖 HTTP/2、不依赖 protoc 的自研 RPC 框架然后全程跟 gRPC 对照着看。你会发现自己写一遍比读十遍理解更深刻。1. 起因一个全栈自研的执念先交代背景。我的 ai-chat 项目类 ChatGPT 对话系统拆成了多个微服务backend前端入口、service会话编排、qa-engine问答引擎、keywords-filter敏感词。服务之间要互相调用得有一套 RPC。这个项目里KV 存储已经自研了kvstore一个 C 写的内存 KV带主从、持久化、向量检索网络层有三套自研模型NtyCo 协程、epoll、io_uring。底层的存储和网络都自研了那服务间通信这一层为什么还要依赖一个外部框架自己写把存储→网络→RPC整条链路都攥在自己手里形成一个闭环。这就是 srpc 的起点。2. 为什么不用 gRPC四笔账决定自研之前先算清 gRPC 的四笔成本。第一笔依赖重。gRPC 要引入 protoc 编译器、protoc-gen-go / protoc-gen-go-grpc 两个生成插件、HTTP/2 那套庞大的库还有一堆传递依赖。对一个要全栈可控的项目这是侵入式的。第二笔生成代码不可控。.proto编译出来几百上千行生成代码出了问题难排查proto3的默认值、oneof、JSON 映射这些魔法在出现诡异行为时很难一眼看穿。第三笔抽象隔了一层。gRPC 把所有复杂度藏进了 HTTP/2 和 protobuf。对一般业务这很好——别让我管网络。但对一个想彻底搞懂每一层的项目来说这是黑盒。它不给你看你想看就得读源码。第四笔性能有取舍。HTTP/2 那套帧层、HPACK 头压缩、流控是通用设计什么场景都能跑但不可能为你的场景做极致优化。自研协议可以只保留你需要的把不需要的层次砍掉。结论gRPC 是通用最优解但我要的不是通用是够用且可控。3. srpc 的设计原则定下目标就两条都是围绕简单、够用、快第一协议足够简单。一条消息 一个 12 字节定长帧头 一个载荷。帧头就四样东西魔数、版本、类型、流 ID、长度。没有 HTTP/2 那些 HPACK、SETTINGS、流控、优先级——把能砍的都砍掉只留下支持请求/响应/流式的最小集。第二够用就好。项目需要的能力普通调用unary、服务端流式qa-engine 的流式答题、鉴权Bearer token、健康检查、优雅停止、连接复用、超时取消。这七样做好别的双向流、复杂流控不做——不做也是一种设计决策。4. 跟 gRPC 的第一层对照哲学差异在动任何代码之前先看到两套方案的哲学差异后面每一章都在印证它。gRPC 的思路是大而全交给框架HTTP/2 管传输、protobuf 管序列化、protoc 管代码生成、拦截器管横切、标准错误码管语义。你几乎不用写底层代码换来的是庞大的抽象。srpc 的思路是小而专自己掌控帧格式自己定12 字节帧头、序列化用标准库 JSON先不引 protobuf、代码生成不靠编译器手写类型定义、中间件自己实现、错误码自己定。换来的是每一条链路都能看懂、都能改。一句话gRPC 帮你把一切藏起来srpc 需要你把一切摊开。5. 一个重要的决策先不上 protobuf用 JSON这里要特别交代一个为什么。gRPC 靠 protobuf 拿到二进制小报文和编译期强类型这是它的招牌。而 srpc 第一版序列化用的是标准库 JSON不是 protobuf。原因有三点。第一项目当时没有.proto 生成代码的诉求。srpc 的调用是Client.Stream(AiQaEngine/Answer, req)这种方法名字符串 参数的形式消息体是手写的 Go 结构体。没有自动生成这层就不需要 protocJSON 用标准库encoding/json就能序列化零额外依赖。第二换取简单接受代价。JSON 报文比 protobuf 大、解析比二进制慢——这是自研够用原则下的主动取舍项目内部服务间通信报文几百字节到几 KBJSON 的开销完全可以接受换来的是不用维护 IDL 文件、不用装生成插件、出错时还能直接看明文。第三给未来留了口子。协议里帧载荷是不透明字节具体是 JSON 还是别的格式帧层完全不关心。哪天要换更紧凑的格式只换序列化那一步帧协议不动。这背后的权衡正是自研的意义每个选择都有明确的理由和代价而不是照搬通用方案。二、总体架构一次调用的完整路径第一部分定下了小而专、自己掌控的设计原则。这一部分走进代码看 srpc 分成哪些模块、一个真实请求从客户端发起到服务端返回到底走了哪些路。全程对照 gRPC 那条线你会发现框架骨架一致、零件不同。1. 模块地图九个文件各司其职srpc 是一个独立的 Go 包核心代码就九个文件每个职责单一frame.go 定义帧格式12 字节帧头 载荷和读写方法——这是整个协议的根。codec.go 负责序列化与反序列化第一版用标准库 JSON还定义了请求信封和错误载荷两种结构。conn.go 管理一条 TCP 连接负责连接上的多路复用给每个请求分配流 ID、按流 ID 分发返回的帧。pool.go 是连接池维护一组共享连接按轮询复用用健康检查剔除坏连接。client.go 是客户端门面提供 Callunary和 Stream服务端流两种调用入口。server.go 是服务端门面注册方法、监听端口、接收连接、路由请求、管理流生命周期。stream.go 是流式调用的双方实现客户端侧 Recv 循环、服务端侧 StreamWriter。meta.go 定义 META 帧的内容来源、token 统计、阶段标记。middleware.go 定义中间件鉴权、指标统计。对照 gRPCclient.go server.go 对应 gRPC 的 ClientConn 和 Serverconn.go pool.go 对应 gRPC 的 HTTP/2 连接管理和连接池codec.go 对应 protobuf 的 Marshal/Unmarshalmiddleware.go 对应 gRPC 的拦截器。模块划分几乎一一对应只是实现各自不同。2. 从客户端发起走一遍全链路用一个真实调用当例子ai-chat-service 要调 qa-engine 的流式答题方法代码是client.Stream(ctx, AiQaEngine/Answer, req)。这一行背后发生了什么拆开看。第一步进连接池取连接。调用 Stream先去 pool.Get()。连接池里有健康连接就复用多个调用共享轮询选一条没有就新建一条 TCP 连接并加入池。这就是 gRPC 连接池的概念只是 gRPC 靠 HTTP/2 协议层维护连接srpc 自己用 Pool 管理。第二步分配流 ID。从连接上 allocStream() 拿一个自增的流 ID。这是多路复用的关键一条连接上同时跑着很多请求每个请求靠唯一的流 ID 区分。gRPC 里对应 HTTP/2 的 stream ID。第三步组帧发送。把请求包进一个信封请求信封里装方法名、鉴权 token、超时毫秒数、请求体序列化成 JSON包成一个 REQUEST 帧带流 ID写到连接上。第四步服务端解帧路由。服务端有个读循环不停地 ReadFrame。收到 REQUEST 帧后解开信封取出方法名去注册表里查有没有对应的 handler。这就是路由gRPC 里靠路径分发到对应的方法。第五步过中间件。路由到了请求不直接进业务代码先过一个中间件链鉴权、统计耗时全部通过才放行。这对应 gRPC 的拦截器链。中间件拒绝时业务代码根本不会执行。第六步执行业务。放行后按 handler 的类型分流unary 就调用处理函数、拿返回值流式就用 StreamWriter 往连接上写 DATA 帧。qa-engine 的 Answer 是流式的所以它用 SendMeta 发思考中阶段标记、用 SendText 一段段发答案。第七步返回与收尾。流式结束后服务端发一个 DONE 帧表示结束出错发 ERROR 帧。客户端 Recv 循环收到 DONE 转成 io.EOF、收到 ERROR 转成错误返回。最后客户端把连接放回池里Put连接继续给别的调用用。3. 关键设计一连接是共享的不是独占的这里有个和传统直觉不同的设计值得单独讲。很多 RPC 框架里拿连接是独占式的用的时候别人不能用用完才还。srpc 相反——一条连接是共享的多个调用同时在这条连接上跑靠流 ID 区分。看 pool.go 的注释就知道Get 返回的连接may be used concurrentlyPut 只做健康检查、不放回独占。这个设计的收益是连接的复用率极高一个客户端到 qa-engine 可能始终只有少数几条连接几十个并发请求全挤在上面靠多路复用消化。代价是实现复杂度全压在了 conn.go 上它必须把这条连接上回来的帧按流 ID 送进对应调用的信箱并处理好一个流结束后连接还能继续用。对照 gRPCHTTP/2 本质上也是这个思路——一条连接承载多个 stream。所以 gRPC 和 srpc 的共享连接 多路复用是同一个哲学只是 gRPC 由协议层保证srpc 由代码保证。4. 关键设计二请求信封把元信息和业务数据分开组帧时有个细节值得展开REQ 请求的载荷不是一个裸的请求体而是一个信封。信封里有四样Method方法名路由用、Auth鉴权 token、DeadlineMs超时毫秒数、Body真正的业务数据。这跟上一期博客讲的 metadata 是同一件事——把关于请求的信息和请求本身分开。方法名、token、超时属于运输信息业务代码不关心Body 才是业务代码要的东西。这个信封由客户端塞好、服务端解出中间件和路由都在信封层面干活查方法名、验 token、算超时业务 handler 只拿到 Body。gRPC 里对应的就是 metadata 加 HTTP/2 路径加 deadline 传播——srpc 用一个 JSON 信封就实现了同一件事更直白。5. 一次 unary 调用的完整时序流式链路讲完了补一个 unary 的对照让你看到两种模式的区别就一个帧的类型和数量。unary 调用client.Call(ctx, keywords_filter.Filter/Validate, req)的时序客户端 allocStream 拿流 ID写一个 REQUEST 帧信封里是请求体。服务端读循环收到路由到方法过中间件执行 handler。执行完服务端写一个 RESPONSE 帧载荷是返回值或出错写一个 ERROR 帧。客户端收帧反序列化返回给调用方连接放回池。对比流式unary 是一个请求帧 一个响应帧一问一答干净利落服务端流是一个请求帧 多个 DATA/META 帧 一个 DONE/ERROR 帧一问多答。底层帧协议是同一套不同模式只是帧的组合方式不同——这正是简单协议的好处模式是组合出来的不是各写一套。6. 和 gRPC 对照骨架一致零件不同把这一部分的架构对照收拢成一段话。宏观上srpc 和 gRPC 是完全同构的都有客户端门面 服务端门面 连接池 多路复用 序列化 中间件 健康检查都靠路由 中间件 handler处理请求。这是任何 RPC 框架都逃不开的骨架——RPC 要解决的是同一组问题所以架构必然趋同。微观上每个零件实现不同gRPC 用 HTTP/2 做传输、protobuf 做序列化、protoc 生成代码、标准拦截器做横切srpc 用自研 12 字节帧、JSON 做序列化、手写类型、自研中间件。gRPC 把复杂度藏在标准库里srpc 把复杂度摊开在自己代码里。三、核心协议帧格式设计第二部分说帧是 srpc 的根。这一部分把根刨开一条消息在网络上到底长什么样、有哪些帧类型、为什么这么设计。看完你会发现gRPC 那套复杂的 HTTP/2 帧层核心意图和 srpc 这 12 个字节一模一样。1. 一条消息在网络上长什么样srpc 的数据传输单位叫帧frame。一帧 一个定长帧头 一段载荷。帧头固定 12 字节按小端序排四块信息前 2 字节是魔数 0x5352ASCII 就是 SR 两个字符是 srpc 的身份证。接收方读到帧先验魔数对不上就判定不是 srpc 流量、直接报错。这相当于 HTTP 请求头的 GET/POST 那行让接收方能认出这是不是我要的协议。第 3 字节是版本号当前是 1。版本校验是为了将来协议升级时老程序能发现对方的协议我不认识不至于把新版帧当旧版解析、产生诡异错乱。第 4 字节是帧类型这个最关键下面单独讲。第 5 到 8 字节是流 ID4 字节无符号整数。多路复用的根基就是它这条连接上的每个请求都有一个唯一流 ID所有帧都带着它接收方靠它知道这帧属于哪个请求。第 9 到 12 字节是载荷长度4 字节无符号整数。接收方先读 12 字节头知道载荷多长再精确读那么长的载荷。这就是定长帧头 变长载荷的经典组帧方式——用定长头解决一条消息到哪结束的边界问题不做就不行做了就能稳。这里有个保护上限载荷最长 16 MiB超过直接拒绝。为什么要有上限因为帧头里长度字段理论上能写很大的数如果接收方无脑按它分配内存一个恶意或损坏的长度就能让它分配几 GB 内存。协议必须设一个不合理就拒收的闸门这是所有自研协议的必修课。2. 帧类型一套最小的语义集合载荷里装什么由帧类型决定。srpc 定义了八种帧类型覆盖了请求、响应、流、错误、心跳、取消这几件最基本的事。TypeRequest0x01是请求帧客户端发起调用的第一个帧载荷是请求信封方法名、鉴权、超时、请求体。TypeResponse0x02是响应帧unary 调用的服务端回包载荷是返回值。TypeData0x03是数据帧服务端流式推给客户端的正文片段载荷是裸字节语义由双方约定。TypeDone0x04是结束帧服务端发表示流正常结束客户端收到它就认为说完了。TypeError0x05是错误帧载荷是错误码加错误消息表示这个流出错了。TypePing0x06和 TypePong0x07是心跳帧客户端发 Ping、服务端必回 Pong用来探测连接是否还活着。TypeCancel0x08是取消帧客户端发告诉服务端我不等了你那个正在跑的活别干了。TypeMeta0x09是元信息帧流式通信中携带关于这段流的信息——比如 qa-engine 用它发答案来源、token 统计、思考中还是正文的阶段标记。这八种帧就是 srpc 的全部语义单元。对照 gRPC 的 HTTP/2HTTP/2 有 SETTINGS、HEADERS、DATA、RST_STREAM、PING、WINDOW_UPDATE 等十几种帧还有 HPACK 头压缩、流控窗口、优先级这些机制。srpc 砍掉了几乎所有锦上添花的部分只保留支撑请求/响应/流/取消的最小集。能砍的都砍掉这是帧设计的第一原则。3. unary 的帧时序三帧完成一次调用用帧的视角重看 unary 调用就三步非常干净。客户端发一个 TypeRequest 帧载荷是请求信封。服务端执行完回一个 TypeResponse 帧载荷是返回值。如果出错回一个 TypeError 帧载荷是错误码加消息。客户端收到响应或错误本次调用就结束了。一次 unary 一帧请求 一帧响应或错误。这就是第二部分说的模式是组合出来的——unary 是所有模式里帧最少的一问一答。4. 服务端流的帧时序META DATA DONE流式就多了几个帧核心是边做边推。客户端发一个 TypeRequest 帧还是那个信封方法名是流式方法。服务端开始执行后不发一次性响应而是持续发帧用 TypeMeta 帧发阶段信息比如 qa-engine 的思考中用 TypeData 帧一帧帧推正文。全部推完发一个 TypeDone 帧表示结束中途出错发 TypeError 帧。这里有个设计细节值得注意Meta 和 Data 是两种帧而不是塞在一个帧里用字段区分。为什么因为读端要简单——Recv 循环遇到 Meta 帧就直接知道这是元信息遇到 Data 帧就直接知道这是正文不用解析载荷去猜。帧类型本身就是一种免费的类型标记读端靠类型分流逻辑简单又清晰。这正好对应 qa-engine 的流式实现LLM 思考阶段发 Metaphasereasoning输出阶段一帧帧发 Data全部完成后服务端发 Done。读端收到 Meta 更新阶段状态、收到 Data 拼正文、收到 Done 收尾。5. 取消的帧时序CANCEL 如何让服务端停手取消是流式里最容易出错、也最能体现帧设计得当的一块。客户端如果中途不想等了超时、用户点了停止、主动 Close就发一个 TypeCancel 帧带相同的流 ID。服务端收到后找到这条流对应的 context调用它的取消函数——服务端那个正在跑的 handler 里监听 ctx.Done() 的地方就会收到信号提前退出、释放资源。这个设计的精妙在于取消走的是帧通道不是另开一条连接。CANCEL 帧和普通数据帧一样在连接上复用服务端读循环读到它就能处理不需要为取消单独建连接、单独维护状态。gRPC 里对应的是 RST_STREAM 帧加 context 取消思路一致——取消本质上也是连接上的一个普通信号帧。6. 为什么定长帧头这么重要说清边界问题如果只能从这一部分带走一个知识点那就是定长帧头解决的是接收方怎么知道一条消息到哪结束这个根本问题。TCP 是流式协议它只保证字节按顺序到达不保证你发一条我就收到一条。你发两条消息接收方可能一次读走也可能一条被拆成两半。如果没有帧头接收方完全不知道这条消息该读多长。srpc 用先读定长 12 字节头再从里面读载荷长度解决——这就是组帧framing。很多自研协议踩的第一个坑就是粘包半包两次消息粘在一起、或一条被拆开解析错乱。srpc 从 S1 就把组帧做对了之后所有帧解析都建立在这个可靠的基础上。这是从零造 RPC最重要的地基gRPC 的 HTTP/2 同样解决这个问题只是它用的帧头更复杂9 字节、多种字段srpc 用 12 字节定长头简单直接。7. 和 gRPC 对照同一意图不同复杂度把帧层和 gRPC 的 HTTP/2 收拢对比。两者都要解决同一组问题怎么识别协议魔数 vs HTTP 方法、怎么拆帧定长头 vs HTTP/2 帧头、怎么区分并发请求流 ID vs stream ID、怎么传元信息Meta 帧 vs HEADERS、怎么取消CANCEL vs RST_STREAM、怎么心跳PING/PONG vs PING。区别在于复杂度gRPC 要兼容整个 HTTP/2 生态所以有 HPACK 压缩、流控窗口、优先级、SETTINGS 协商一大堆srpc 只要服务自己所以 12 字节定长头 八种帧就够。这就是自研的意义不为用不到的通用性付费。gRPC 是万能工具srpc 是为你量身定做的工具干的活一样但 srpc 更轻、更直白、每行都能看懂。四、多路复用与连接池第三部分说帧是 srpc 的根这一部分讲它身上最值钱的两块肌肉一条连接怎么同时跑几百个请求连接池怎么复用、怎么剔除坏连接。它们共同决定了 srpc 的性能上限。1. 一个核心矛盾TCP 连接是稀缺资源先建立一个直觉。程序和服务端通信TCP 连接不是免费的建一条连接要三次握手连接越多系统开销越大文件描述符、内存、内核资源都有限。如果每个请求都新建一条连接高并发下连接数会爆炸还会遇到端口耗尽、握手开销拖慢一切。所以 RPC 框架都想做到一件事少建连接多复用连接。最理想是一条连接承载成千上万个请求。怎么做到靠多路复用。2. 多路复用一条连接无数请求靠流 ID 区分多路复用multiplexing的意思就是把很多独立的请求塞进同一条连接里同时传输互不干扰。srpc 的实现核心在 conn.go 里就是那本流 ID 账本。连接上有一个自增的 nextID每次发起新请求就分配一个唯一的流 ID1、2、3……用完了归零重来。连接维护一张映射表流 ID → 这个请求的信箱。请求发出后这条连接上所有回来的帧都先看帧头里的流 ID然后从映射表里找到对应请求的信箱把帧投递进去。每个请求的调用方只从自己的信箱里收帧。一句话总结这个机制发送时每个请求帧打上自己的流 ID接收时按流 ID 把帧分拣回对应请求。这就是多路复用也是第三部分帧头里流 ID字段的真正价值——它是连接上几百个请求的分拣标签。对照 gRPCHTTP/2 的 stream ID 干的是同一件事一条连接承载多个 stream每个 stream 一个 ID。所以 gRPC 和 srpc 的多路复用哲学完全一致只是 srpc 用自增整数 一张 Go map 实现gRPC 由 HTTP/2 协议层实现。3. 信道的设计为什么每个请求有个信箱多路复用有个必须解决的并发难题连接上只有一个读循环在收帧但调用方有很多个帧该给谁srpc 的解法很优雅每个流请求分配一个带缓冲的信道inbox当信箱。连接唯一的读循环收到帧后按流 ID 找到信箱把帧投进去就完事调用方比如 Recv 循环阻塞在自己的信箱上等帧到达。读循环和调用方彻底解耦读循环只负责收帧、分拣、投递调用方只负责从自己的信箱里取。这个信道还有容量上限默认 64 帧。如果某个请求收得太慢、信箱满了新帧就投不进去——这是背压机制防止一个慢请求无限积压帧、把内存吃光。遇到信箱满连接直接让这个流出错而不是无限缓冲。这是所有并发系统都要面对的快生产、慢消费问题srpc 用有界信箱 满了就报错来兜底。对照 gRPCHTTP/2 有流控窗口WINDOW_UPDATE做背压机制更细srpc 用信箱容量做粗粒度背压简单但够用。4. 连接池复用 健康检查 轮询有了多路复用还要有连接池来管建几条连接、坏了怎么换。srpc 的 pool.go 干三件事。第一件复用。池里维护一个空闲连接列表。Get 时先遍历列表剔除不健康的下面讲有健康连接就轮询取一条用没有就新建一条加入池。Put 时连接如果还健康就留在池里继续共享不健康就关掉剔除。注意这里共享的含义连接不按用完就独占归还管理而是大家共用、靠流 ID 区分所以池里的连接可以同时被多个调用使用。第二件健康检查。连接是不是还活着由 Healthy() 判断连接没关闭且最近收到过帧的时间没超过超时阈值。Put 回来的连接会先过这道检查坏的直接扔掉。这是避免复用一条已经死掉的连接——死连接复用是 RPC 最隐蔽的坑之一请求发出去像石沉大海。srpc 用帧到达时间戳判断活性比发了请求等超时早发现问题。第三件上限控制。池有 maxIdle 上限默认 4 条。连接数超过上限时新建的临时连接用完即关不会无限膨胀。连接数被锁死在一个小常数内这是少建连接原则的硬约束。5. 心跳主动探测连接是否还活着健康检查是被动的看时间戳但连接可能半死TCP 连接还在但对方已经挂了或网络断了数据根本传不到。这种半开连接靠被动等帧是等不出来的。所以 srpc 有 keepalive 心跳机制连接上有个保活循环每 30 秒发一个 Ping 帧如果超过 90 秒都没收到任何帧包括 Pong就判定连接已死、主动关掉。这保证了死连接能被及时清理不会一直占着池里的名额。对照 gRPCHTTP/2 也有 PING 帧做保活同样是周期性发、超时断连。心跳是 RPC 层的体检保证连接池里的都是活连接。6. 取消与连接池的协作坏连接不进池把取消机制和连接池串起来看你会发现它们配合得很严密。客户端中途不要这个请求了发一个 CANCEL 帧第三部分讲过。流结束后这个请求用的连接会被放回池里Put。但放回前有个健康检查如果这次调用是连接坏了导致的失败连接会被关掉剔除绝不把一个坏连接放回池里给下一个人用。这就形成一个闭环健康检查保证池里都是活连接 → 复用活连接省掉建连开销 → 遇到坏连接立刻剔除不污染池 → 心跳兜底探测半死连接。整个连接管理体系自洽且严格这是自研框架里比较难得的成熟度。7. 和 gRPC 对照性能思路一致实现层次不同把多路复用和连接池和 gRPC 收拢对比。gRPC 的多路复用由 HTTP/2 协议层保证连接池是 grpc-go 的 ClientConn 内部实现同样有复用、健康检查、心跳keepalive、连接数控制。思路完全一致。区别在实现层次和透明度gRPC 把这一切封装在框架里你用grpc.NewClient拿到连接内部的黑盒你不用管srpc 把这一切摊开在自己代码里pool.go 的 Get/Put/Healthy、conn.go 的 readLoop/keepaliveLoop每一行你都能读、都能改。这再次印证那句话gRPC 帮你把一切藏起来srpc 逼你把一切摊开。8. 这层设计在项目里怎么落地项目里这个多路复用 连接池是真实跑在生产上的。ai-chat-service 访问 qa-engine用的是 srpc 的 Client Poolai-chat-backend 访问 ai-chat-service 也是同一套。连接数被限制在很小比如 1 到 4 条但能支撑所有并发请求——靠的就是多路复用。这就是少建连接、多复用带来的实际收益几十个并发请求挤在少数几条连接上建连开销被摊到极限系统的连接数上限也天然受限不容易被打爆。对照 gRPC 项目的部署形态你会发现生产环境的 gRPC 服务同样保持连接少而热这是所有 RPC 框架共同的性能观。五、流式DATA/META 帧第四部分讲完了多路复用和连接池——那是帧协议在连接层面的应用。这一部分聚焦流式qa-engine 的流式答题靠 DATA 和 META 两类帧配合实现了答案一个字一个字蹦出来的效果。这里把整条链路完整讲透。1. 为什么要流式从一次问答说起先回到场景。用户问 qa-engine什么是 RPC大模型要生成几千字的答案。如果等全部生成完再一次性返回用户得对着空白界面干等几十秒——这是灾难。流式的思路是边生成边推。生成一段就发一段用户屏幕上文字不断出现体验上是AI 在打字。这就是你前端看到的效果loading 状态里先出现思考中的灰字然后答案一段段浮现。流式要解决的本质问题是一次请求返回多个结果。前面第三部分说模式是帧的组合——服务端流就是一个请求帧 多个 DATA/META 帧 一个 DONE 帧。2. 帧的类型分工谁是内容谁是信号流式通信里只有三类帧参与分工很明确。DATA 帧TypeData是内容的唯一载体。思考文本、答案文本都是 DATA 帧每帧装一块文本载荷是裸字节。它是正文。META 帧TypeMeta是阶段信号。它不装正文装的是怎么理解后面的内容的结构化信息答案来源缓存命中还是 LLM、token 统计、阶段标记思考中还是正式答案。它是段落标题。DONE 帧TypeDone是结束信号。服务端所有内容推完发一个 DONE客户端读到就认为流正常结束。为什么 Meta 和 Data 必须分开成两种帧而不是塞在一个帧里用字段区分因为读端靠帧类型分流不用解析内容去猜。Recv 循环收到 TypeMeta 帧就走 Meta 分支、收到 TypeData 帧就走 Data 分支、收到 TypeDone 就走收尾。如果都塞 DATA 里读端得解析载荷才能知道这是思考还是答案逻辑就复杂了。帧类型本身是免费的标记这是流式设计最优雅的地方。3. 客户端视角Recv 循环怎么一帧帧收客户端怎么把一串帧变成一段完整的流看 stream.go 的 Recv 方法核心就是死循环收帧按类型分流。Recv 每次调用阻塞在自己的信箱上等下一帧。收到 TypeData 帧返回一段文本给调用方项目里前端拿到的就是它收到 TypeMeta 帧解析出 Meta 结构体返回收到 TypeDone 帧返回 io.EOF表示流结束收到 TypeError 帧返回错误。调用方写一个 for 循环不断调 Recv直到 EOF 或错误就拼出了一整段答案。这个循环就是流式在客户端的全部形态不是一次性拿到结果而是一帧帧地收边收边处理。前端的流式渲染后端就是这么一帧帧推上去的。4. 服务端视角StreamWriter 怎么一帧帧发服务端这边业务代码不直接发帧而是通过一个 StreamWriter 对象。它提供两个方法SendText 发一个 DATA 帧SendMeta 发一个 META 帧。qa-engine 的 Answer 方法里业务代码就是不断调用这两个方法把内容一帧帧推给客户端。这个抽象很关键业务代码只需要往流里写不需要关心帧头、流 ID、序列化这些底层细节。Write 时用 SendMeta 发阶段信息、SendText 发正文写完了返回 nil服务端自动补发一个 DONE 帧收尾中途出错返回 error服务端自动补发 ERROR 帧。StreamWriter 把推流封装成了业务友好的接口。5. 真实链路qa-engine 的 Answer 完整帧序列把项目里 qa-engine 的流式答题完整还原一遍看帧是怎么流转的。第一帧REQUEST。客户端发请求帧载荷是信封方法名 AiQaEngine/Answer、鉴权、超时、请求体。请求体里装着多轮消息、用户问题和续写前缀——这些在信封的 body 里业务 handler 能拿到。第二帧起流式开始。q-engine 先判断答案来源精确缓存、语义缓存还是 LLM。它用 SendMeta 发一个 Meta 帧里面带 source 字段缓存命中或 LLM告诉客户端这个答案的出处。前端的缓存命中 / 公有大模型标签就是这一帧的功劳。接着如果是 LLM 生成会有一个思考阶段。发送前先 SendMeta 发一个带 phasereasoning 的 Meta 帧然后一帧帧 SendText 发思考内容。你的前端收到 phasereasoning 的 Meta就知道接下来这段要显示成思考中的灰字。思考结束切到正式答案。再 SendMeta 发一个 phaseanswer 的 Meta 帧然后一帧帧 SendText 发答案正文。你的前端收到 phaseanswer就把灰字清掉换成正常答案。这正好对应你项目里先显思考再换答案的效果。答案推完还有收尾信息。服务端再发一个 Meta 帧带 token 统计tokens_used、tokens_saved让你前端在流结束时显示累计消耗/节省多少 token。最后一帧DONE。服务端全部发完补发 DONE 帧客户端 Recv 返回 io.EOF流结束。如果中途出错服务端发的是 ERROR 帧客户端拿到错误。完整序列一句话REQUEST → META(来源) → META(思考阶段) → DATA×N(思考) → META(答案阶段) → DATA×N(答案) → META(统计) → DONE。6. 为什么这个设计能承载思考流式项目里有个改造叫LLM 思考流式DeepSeek V4 支持边思考边输出reasoning_content和content分开。这个能力能落地靠的正是 DATA/META 的帧设计。思考内容和答案内容都是 DATA 帧但两者之间夹了一个 Meta 帧phase 从 reasoning 切到 answer读端就有了明确的阶段边界。如果帧协议不支持 Meta你就得想办法在 DATA 里塞阶段标记、读端解析内容去猜——又慢又容易错。Meta 帧的存在让阶段切换成了一件简单、可靠、类型明确的事。这算是 srpc 帧设计在你项目里直接吃到红利的例子。7. 和 gRPC 对照流式语义一致实现层次不同gRPC 的服务端流式用的是 HTTP/2 的 DATA 帧流服务端stream.Send(msg)连续发消息客户端stream.Recv()连续收直到 EOF。语义和 srpc 完全一样——都是一个请求多个响应边做边推。区别在元信息的处理gRPC 的元信息header、trailer、metadata走 HTTP/2 的 HEADERS 帧是在流的头部/尾部一次性传的不像 srpc 能随时夹一个 Meta 帧在流中间。这其实是 srpc 的流式比 gRPC 更灵活的地方——你项目里思考中→换答案的阶段切换gRPC 的标准做法要额外设计srpc 的 Meta 帧天然支持。这就是自研的价值被放大的时刻当你的业务需要一个流中间夹信号的机制时自己定协议可以顺手做进来用 gRPC 就要绕路。8. 小结DATA/META/DONE 的职责一句话DATA 是内容META 是阶段信号DONE 是结束信号。三者配合让一个请求返回一串带阶段标记的内容变得简单可靠。六、生产能力鉴权、健康检查、优雅停止、指标前五部分把 srpc 的骨架帧、连接、流式讲完了。但一个 RPC 框架能上线、能运维光有骨架不够还得有生产能力——鉴权挡住没权限的人、健康检查让运维知道服务活没活、优雅停止让重启不掉线、指标让监控看得到状态。这一部分四件事每一件都在你项目里有真实落地。1. 鉴权怎么防止没权限的人调用先想清楚问题服务开了端口任何能连上的人都能发请求。怎么保证只有自己人能调答案是Bearer token一种携带凭证的鉴权方式请求里带一串 token服务端验对就放行。srpc 的鉴权在客户端和服务端各有一步。客户端这边创建 client 后调用SetAuth(token)之后每个请求的信封里都会自动带上auth字段自动加 Bearer 前缀。这一步把带 token这件事做成了一次配置业务代码不用每个请求手动传。服务端这边AuthMiddleware(accessToken)创建一个鉴权中间件传进去的是这个服务认哪个 token。请求到达业务代码之前中间件先检查信封里的 auth 和它认的 token 是否一致不一致直接拒绝、返回未鉴权错误业务代码根本不会执行。这里有个关键细节每个服务可以挂不同的 accessToken。service 挂 service 的 tokenqa-engine 挂 qa-engine 的 token互相之间不能互调——这正是微服务内部的安全边界跟 gRPC 的 metadata 加拦截器鉴权是同一套思路只是 srpc 用信封里的 auth 字段 中间件实现更直白。还有一个贴心设计健康检查方法免鉴权。探活请求如果也要 token那运维脚本就得额外配 token麻烦。srpc 对 Health 方法跳过鉴权探活不设防。这个设计很实用后面健康检查部分还会用到。2. 健康检查让运维知道服务活没活生产环境必须回答一个问题服务是活的吗健康检查就是回答这个的。srpc 内置了 Health 服务客户端随时可以问你还活着吗服务端返回活着。你在 middleware.go 里看到IsHealthMethod判断就是给健康检查开免鉴权的口子。这个能力直接服务于两类场景。一是运维探活监控系统周期性调 Health/Check发现返回异常就知道服务挂了该告警告警。二是部署流程新版本上线时先起服务、等 Health 通过再把流量切过来Health 不过就回滚避免把坏版本放进生产。对照 gRPCgRPC 有官方的 health 服务语义一样都是你活着吗→我活着。健康检查是 RPC 框架的标配srpc 把它做进了框架里比让每个服务自己实现探活接口省事得多。3. 优雅停止重启不能一刀切掉线服务要更新、要重启最怕的就是正在处理一半的请求服务直接停了用户那边请求失败、数据丢失。优雅停止解决的就是这个——先停止接收新请求把手头正在处理的请求处理完再真正关闭。srpc 的 GracefulStop 就是这么做的分三步。第一步关闭监听不再接收新连接新请求进不来。第二步等待正在处理中的请求全部完成但设了一个等待上限默认 10 秒——不会无限等真有请求卡住了超时后强制关闭保证服务不会卡死在关闭流程里。第三步处理完或超时后关闭所有连接服务真正退出。你项目里 srpc 的 Server 就带这个能力Stop() 是入口GracefulStop 是核心SetStopWait 可以调等待时间。优雅停止的边界要拿捏好太短会砍掉慢请求太长会拖住重启——10 秒默认值是个务实的平衡。对照 gRPCgRPC 的 GracefulStop 做同样的事先停 accept、等在途请求、再关连接。优雅停机是所有服务框架的标配srpc 用约一百行代码实现了逻辑可读、可调。4. 指标让监控看得到状态最后是服务干了多少活、有没有出错——没有指标运维就是睁眼瞎。srpc 有 MetricsMiddleware自动统计每个方法的调用情况。它统计三件事调用次数、错误次数、耗时总耗时和最大耗时。每个方法各有一组计数。接入 Prometheus 后就能画出这个方法调用量曲线、错误率、P99 延迟这些监控面板。项目里 ai-chat-service 暴露了 Prometheus 指标端口35881就是这套 Metrics 接出去的。关键设计指标统计也是中间件。你只要在服务启动时挂一次 MetricsMiddleware所有方法自动开始统计不用在每个业务方法里加计时代码。这正是中间件横切逻辑统一收口的价值——和鉴权同一个套路只是这次统计的不是权限是性能数据。对照 gRPCgRPC 生态有丰富的拦截器实现指标统计Prometheus 也有官方的 gRPC 指标。框架 中间件 Prometheus是标配组合srpc 的 MetricsMiddleware 就是这组合里中间件那一环。5. 四件事串起来一个服务从启动到退役的完整生命周期把鉴权、健康检查、优雅停止、指标串起来就是一个服务的完整生命周期。服务启动时挂上 AuthMiddleware设好 token和 MetricsMiddleware开始统计然后监听端口对外服务。运行中健康检查被运维周期性地调指标被 Prometheus 周期性地抓——这两件事一个问你活着吗一个记你干了什么。请求进来先过鉴权中间件验不过直接拒绝验过了进业务Metrics 记下这次调用的耗时和成败。要更新服务了调 GracefulStop停止接收新请求、等手头请求处理完、关连接、退出监控的 Health 会暂时探测失败部署系统据此把流量切走。这四件生产能力缺一件服务都算不上可上线。这就是为什么我要专门用一部分讲它们——它们不是锦上添花是生产三件套再加上前面讲的超时取消里的硬通货。6. 和 gRPC 对照全套能力一一对应把 srpc 的生产能力和 gRPC 收拢对比你会发现一个都不少全都有对应物。鉴权上srpc 的 AuthMiddleware 对应 gRPC 的鉴权拦截器metadata 里验 token健康检查上srpc 内置 Health/Check 对应 gRPC 的官方 health 服务优雅停止上srpc 的 GracefulStop 对应 gRPC 的 GracefulStop指标上srpc 的 MetricsMiddleware 对应 gRPC 生态的 Prometheus 拦截器。结论这些能力是RPC 框架这个物种的标配不管用 gRPC 还是自研 srpc你要解决的问题一样所以要提供的功能也一样。区别只在实现——gRPC 给你一套标准封装srpc 让你亲手写一遍写完你会更懂生产级 RPC 到底要什么。这也印证了整篇博客的主线先学透 gRPC 这个标准答案再亲手造一个 srpc两边一对照RPC 的原理就彻底通了。七、踩坑与代价自研的另一面前六部分讲了 srpc 怎么造出来的、每块设计为什么这么定。这一部分讲另一面自研 RPC 不是只有好处它带来的坑和成本是决定要不要自研时必须算清的账。作为一个亲手踩过的人我把这些如实写出来——这才是一篇博客最诚实、也最值钱的部分。1. 手写协议的第一个坑粘包与半包TCP 是流式协议只保证字节按顺序到不保证你发一条我收一条。你连续发两帧接收方可能一次全读走也可能一帧被拆成两半。如果接收方不按帧边界读解析必然错乱——这就是粘包、半包问题。srpc 怎么解决就是第三部分讲的那个定长帧头先读 12 字节头再从里面读载荷长度精确读那么多字节再读下一帧。定长帧头是防粘包半包的地基从一开始就做对了之后所有帧解析都建立在这上面。但你别小看这个简单问题。很多手写协议在没做定长帧头时会写出读到一个奇怪长度就崩溃的 bug。srpc 的 ReadFrame 对每个边界都做了防御魔数不对报错、版本不对报错、载荷超过上限直接拒收。每一个看起来多余的校验都是踩过坑之后才补上的。2. 并发最容易踩的坑数据竞态多路复用的核心是一条连接上几百个请求并发。并发就带来一个绕不开的问题多个 goroutine 同时读写同一块数据顺序乱了数据就错了。srpc 是怎么防的conn.go 里有两个锁writeMu 保证同一时刻只有一个 goroutine 在写连接防止帧被写乱mu 保护 streams 映射表防止同时增删流时 map 被并发读写崩掉。连接关闭时遍历所有活跃流、把它们的信箱都关掉、通知每个调用方连接没了——这一步如果漏了锁会直接 panic。这些锁不是加着好看的每一个都对应一类真实崩溃写锁不拆并发写会串帧流表不加锁并发读写 map 在 Go 里会直接 panic关闭时忘了通知在途流调用方会永久阻塞。自研 RPC 的并发正确性是拿一个又一个 crash 换来的。3. 没有 protobuf 的代价手写类型的维护成本gRPC 最大的红利之一是 protoc 生成代码你改 .proto代码自动生成两端同步更新。srpc 不用 protoc换来零额外依赖、全可控但代价是类型定义要手写维护。我的项目就是这个真实写照。srpc 改造时删掉了 protoc 生成的 gRPC stub改成手写的类型定义proto/types.go。这意味着每加一个字段、改一个消息结构你得手写对应的 Go 结构体还得保证两端的字段名、JSON 标签、编号全对上。没有了编译器帮你检查两端协议是否一致只能靠人肉对齐——这是自研 RPC 最沉的一笔隐性成本。还有一个连带代价没有编译期强类型。gRPC 的 stub 是类型安全的方法调用参数写错编译就报错srpc 是Client.Stream(AiQaEngine/Answer, req)这种字符串方法名加泛型参数方法名拼错、参数类型对不上要等到运行时才暴露。少了一层编译期保护就多了一层运行时排查。这是不用 protoc必须认下的账。4. 自研的边界什么时候值得什么时候该用 gRPC讲完坑必须回答那个最现实的问题到底什么时候值得自研 RPC什么时候老老实实用 gRPC我的答案是满足以下条件的场景自研才划算。第一有全栈自研的强烈诉求。像我的项目存储自研、网络自研RPC 再自研整条链路都在自己手里这是控的执念也是学习投资。第二协议需求很明确且简单。只服务自己内部需求就是 unary 加服务端流不需要 HTTP/2 那套通用性砍掉它收益明显。第三有足够的人力做正确性和性能验证。自研协议要自己写测试、自己压测、自己踩坑修 bug没有这个投入协议随时会出诡异问题。第四RPC 是你想搞懂的核心技术。自研一次你对帧、多路复用、连接池、流式、鉴权的理解会远超用过 gRPC的人——这是学习回报也是一种投资。反过来满足这些条件就老老实实用 gRPC对外提供 API、需要跨团队跨语言协作、协议要长期演进。这种场景gRPC 的标准、生态、生成代码、强类型是巨大的保障自研协议维护成本会淹没收益。gRPC 是通用最优解srpc 是特定问题的最优解——选哪个看你的问题是不是特定。5. 写在最后实现才能真正读懂到这一部分整篇博客到了收尾。回到最初的问题为什么我放着 gRPC 不用非要自研一个 srpc因为造一遍比读十遍更懂。用 gRPC 时你看到的是调用一个方法、拿到一个结果中间的全是黑盒。自研 srpc 时你亲手定了帧格式、写了多路复用、设计了流式、加了鉴权和健康检查——每一个概念都不再是名词而是你亲手敲过的代码、调过的 bug、踩过的坑。这七部分讲的东西——帧、流 ID、信封、DATA/META/DONE、连接池、中间件——你在 gRPC 里都用过但只有自研时才知道它们为什么存在、为什么这样设计。这也正好串起这两篇博客先学透 gRPC 这个标准答案再亲手造一个 srpc 这个自研实现两边一对照RPC 的原理就彻底通了。可以这么说gRPC 是你走进 RPC 世界的钥匙srpc 是你真正拥有 RPC 世界的地方。
返回列表