边车和服务之间怎么说话最快:HTTP、gRPC、UDS 的一场同机较量

Posted on 日 02 8月 2026 in Tech

Abstract 边车和服务之间怎么说话最快:HTTP、gRPC、UDS 的一场同机较量
Authors Walter Fan
Category Tech
Version v1.0
Updated 2026-08-02
License CC-BY-NC-ND 4.0

边车和服务之间怎么说话最快:HTTP、gRPC、UDS 的一场同机较量

先说个画面。一个 Kubernetes Pod 里挤着两个容器:一个是你的业务服务,另一个是边车(sidecar)——可能是 Envoy 代理,可能是日志收集器,也可能是个鉴权/加密的旁挂进程。它俩共享网络命名空间,物理上就贴在一起,中间只隔着一层操作系统内核。

可就是这么近的两个邻居,绝大多数时候还在用 http://127.0.0.1:8080 这种方式说话——把请求打包成 HTTP 报文,塞进 TCP 协议栈,走一遍三次握手、拥塞控制、Nagle 算法……然后在同一台机器上兜一圈再回来。这就好比你要跟隔壁工位的同事说句话,非得先写封邮件、经过公司邮件服务器转发一圈。能通,但绕。

这篇文章想把"边车和服务之间到底怎么通信最高效、最便捷"这件事讲清楚。我会对比三条主流路子:HTTP over TCPgRPC、以及走 Unix Domain Socket(UDS)(TCP-over-UDS 或 HTTP-over-UDS)。急的读者先记这一句:

同机通信,"协议选型"和"传输通道选型"是两件事。协议决定序列化和语义(HTTP 还是 gRPC),通道决定数据怎么跨进程(TCP 环回还是 UDS)。真正的性能杠杆,往往在通道那一侧。

  • 不是什么:这不是"gRPC 一定比 HTTP 快"的口水战。同机场景下,很多性能差异来自传输层,不是应用层协议本身。
  • 是什么:这是一次"同一台机器上,两个进程怎么把字节搬得又快又省心"的工程权衡。

术语先垫两句。边车(Sidecar):和主服务部署在一起、分担某类横切职责(代理、鉴权、日志、加密)的辅助进程,典型如 Service Mesh 里的 Envoy。UDS(Unix Domain Socket):一种只在同一台机器内部用的进程间通信(IPC)通道,用文件路径当地址(比如 /tmp/app.sock),不走网卡、不走 IP 协议栈。下面会反复用到。


一、先分清两层:协议 vs 通道

聊性能之前,得先把两个常被混为一谈的东西拆开,否则永远是鸡同鸭讲。

第一层是应用协议——你的两个进程"用什么语言、什么格式"交换信息:

  • HTTP/1.1 + JSON:最通用。人能读、工具遍地、调试方便。代价是 JSON 序列化慢、报文冗长(字段名重复出现)。
  • gRPC(HTTP/2 + Protobuf):二进制序列化紧凑,天生支持多路复用和流式,跨语言有强类型契约(.proto 文件)。代价是不能拿浏览器直接看,得有工具链。

第二层是传输通道——字节到底"从哪条路"跨过进程边界:

  • TCP 环回(loopback,即 127.0.0.1):数据在本机 TCP/IP 协议栈里走一遭。虽然不出网卡,但该有的 TCP 状态机、校验、缓冲区管理一样不少。
  • UDS(Unix Domain Socket):内核直接在两个进程间搬数据,跳过整个 IP 协议栈——没有 IP 头、没有 TCP 三次握手(连接建立是本地的)、没有拥塞控制、没有校验和计算。

关键在于:这两层可以自由组合。 HTTP 可以走 TCP,也可以走 UDS;gRPC 默认走 TCP,同样可以配成走 UDS。

组合 应用协议 传输通道 常见场景
HTTP over TCP HTTP/1.1 + JSON 127.0.0.1 最传统的边车通信,Envoy 早期默认
gRPC over TCP HTTP/2 + Protobuf 127.0.0.1 内部服务高性能调用
HTTP over UDS HTTP/1.1 /run/app.sock Docker daemon、部分 Web Server
gRPC over UDS HTTP/2 + Protobuf /run/xds.sock Envoy ↔ 控制面 xDS、高性能同机代理

所以"HTTP 还是 gRPC"是在第一层选,"TCP 还是 UDS"是在第二层选。同机场景下,换通道(TCP → UDS)往往比换协议(HTTP → gRPC)带来的收益更直接、改动更小。 这是全文最想让你记住的一点。


二、UDS 到底省在哪:把内核那条捷径讲透

很多人对 UDS 的印象停留在"听说更快",但快在哪说不清楚。咱们把 loopback 和 UDS 两条路径的差别摆出来。

一个请求从进程 A 到进程 B,走 TCP 环回时,内核大致要做这些事:

  1. 应用层把数据交给 socket;
  2. 走 TCP 层:分段、加序号、维护滑动窗口、算校验和、跑拥塞控制状态机;
  3. 走 IP 层:加 IP 头、查路由表(发现目标是 127.0.0.1,回环);
  4. 数据在协议栈里"发出去又收回来",B 那边再把 IP 头、TCP 头一层层剥掉。

UDS 时,这条链子被砍掉一大半:

  1. 应用层把数据交给 socket;
  2. 内核直接把数据从 A 的发送缓冲区拷到 B 的接收缓冲区——没有 IP 头、没有 TCP 分段、没有校验和、没有拥塞控制
  3. B 直接读。

一句话:UDS 是"内核内部的一次内存搬运",loopback 是"让数据假装出趟门再回来"。

省下来的具体是什么?

  • 省协议栈开销:没有 TCP/IP 头的封装解封装,没有校验和计算。同机数据本来就不会丢,这些保障纯属多余。
  • 省系统调用与上下文:连接建立是纯本地的,没有三次握手的网络往返(虽然 loopback 的握手也在本机,但状态机维护成本仍在)。
  • 天然的访问控制:UDS 是文件系统里的一个 socket 文件,可以用文件权限(chmod/chown)控制谁能连——0700 加上专属用户,比"监听 127.0.0.1 但同机任意进程都能连"安全得多。
  • 不占端口:不用担心端口冲突、端口被扫、端口耗尽。

同机通信里,TCP 的可靠性机制大多是为"数据要跨网络"设计的。数据根本不出机器时,这些机制就成了纯开销。UDS 的价值,就是把这份"为远方准备的保险"退掉。 这也是为什么 Envoy、Docker、PostgreSQL、MySQL 这些老牌软件,同机通信默认或推荐走 UDS。

那 UDS 到底快多少?这里我要诚实:具体数字高度依赖负载、报文大小、内核版本和机器,网上流传的"快 30%~50%"之类的说法没有一个统一答案。可以确定的定性结论是:

  • 小报文、高频调用(边车最典型的场景)下,UDS 的相对优势最明显——因为固定开销(协议栈、系统调用)占比高。
  • 大报文、低频时,两者差距会被数据拷贝本身摊薄,UDS 优势变小。
  • 延迟(latency)比吞吐(throughput)更能体现 UDS 的好处,尤其是尾延迟(P99)。

怎么做压力测试:不要只测一个 QPS

这里不直接引用网上的“快了 30%”或“快了 50%”。同一台机器、不同内核版本、不同报文大小,结果都可能不一样。更稳妥的办法是做一组可复现的压力测试,把“UDS 更快”拆成可以验证的假设。

先划清安全边界:压力测试只对隔离的测试环境或明确授权的灰度环境执行;并发数、到达率和报文大小要设置上限,不能把下面的场景直接打到生产服务。UDS socket 文件也要使用测试路径和最小权限,测试结束后清理连接和临时文件。

1. 先固定比较边界

至少准备两条完全等价的链路:

方案 服务端监听 客户端连接
TCP loopback 127.0.0.1:18080 TCP dialer
UDS /run/sidecar/bench.sock Unix socket dialer

两条链路必须使用同一个 handler、同一套序列化方式、同样的响应内容。否则一边测 HTTP+JSON,另一边测 gRPC+Protobuf,测出来的不是 TCP 和 UDS 的差异,而是协议、序列化和连接池的混合差异。

压测客户端也要尽量复用同一套代码,只替换 dialer。不要拿 wrk 测 TCP,再拿一条单线程的 curl 测 UDS,然后宣布 UDS 更快——这属于把实验变量和实验工具一起换了。

2. 用四个维度组织压力场景

维度 建议档位 想回答的问题
并发数 1、8、32、128、512 低并发和高并发下,差距是否扩大?
报文大小 64B、1KB、64KB 固定协议开销在总耗时中的占比有多大?
连接方式 短连接、长连接、连接池复用 性能差异来自建连,还是来自每次请求?
运行模式 固定并发、固定到达率 系统在排队前和饱和后的表现分别怎样?

其中,固定并发适合观察“并发加上去以后延迟如何变化”;固定到达率适合观察“给定 QPS 下系统能否稳定承载”。只测一种模式,容易漏掉排队和饱和点。

3. 每组测试按“预热—施压—冷却”执行

建议每组测试遵循同一套节奏:

  1. 预热:先运行一段时间,让连接池、JIT、文件页缓存和 CPU 频率进入稳定状态;预热数据不计入结果。
  2. 施压:逐级增加并发或到达率,每个档位保持足够长的时间,直到延迟稳定或错误率开始上升。
  3. 冷却:停止流量,确认进程、socket 文件和连接都恢复正常,再开始下一组。
  4. 重复:每个场景至少重复 3 次,并打乱 TCP 和 UDS 的测试顺序,避免机器温度、邻居进程和缓存状态造成固定偏差。

不要只测“跑通的最高 QPS”。压力测试更重要的是找到拐点:从哪个并发档位开始,P99 明显上升;从哪个到达率开始,错误、超时或连接排队出现。

4. 同时采集四类指标

只看 QPS,往往会把系统“忙到排队”误判成性能好。每组至少记录:

  • 吞吐:完成请求数、成功率、错误率、超时率;
  • 延迟:P50、P95、P99,条件允许时再看 P99.9;
  • 资源:客户端和服务端 CPU(user/system)、内存、RSS、网络字节数;
  • 内核开销:上下文切换、系统调用、CPU migration,以及 socket 连接排队情况。

Linux 上可以用 pidstat 观察进程级 CPU 和上下文切换,用 perf stat 在测试窗口内采集 cycles、instructions、context-switches 等计数。工具不是重点,重点是 TCP 和 UDS 必须使用同样的采集窗口、同样的进程范围和同样的过滤条件。

结果表可以先按这个模板记录,数字等真实压测后再填:

场景 并发 报文 方案 QPS P50 P99 错误率 服务端 CPU 上下文切换
小报文高频 128 64B TCP 待测 待测 待测 待测 待测 待测
小报文高频 128 64B UDS 待测 待测 待测 待测 待测 待测
大报文 32 64KB TCP 待测 待测 待测 待测 待测 待测
大报文 32 64KB UDS 待测 待测 待测 待测 待测 待测

5. 预先写好验收标准

压力测试不是为了证明 UDS 必须赢,而是验证它是否值得引入。验收标准可以这样写:

在相同报文、相同并发和相同连接复用策略下,UDS 的 P99 延迟比 TCP loopback 低至少 X%,错误率不增加,服务端 CPU 不超过基线 Y%;如果优势只在 64B 小报文和高并发场景出现,就只把 UDS 用在这些热点路径。

这里的 XY 应该由业务 SLO、容量余量和实测基线决定,不要凭经验硬填。最终要回答的也不只是“谁更快”,而是:快在什么场景、快了多少、付出了什么运维成本,是否足以抵消迁移和排障复杂度。


三、那 gRPC 的优势又在哪:别把功劳记错账

那 gRPC 不就没用了?并非如此。gRPC 的优势和 UDS 的优势是两个维度上的事,别混在一起算账。

gRPC 真正值钱的地方,同机、跨机都成立:

  • 强类型契约.proto 文件就是接口文档,改字段编译器会报错,跨语言(Go 服务 ↔ Java 边车)不用手写对接协议。这一点 HTTP+JSON 天生弱。
  • 序列化又小又快:Protobuf 二进制编码,比 JSON 省字节、省 CPU。报文越复杂、字段越多,优势越大。
  • HTTP/2 多路复用与流式:一条连接跑多个并发请求不互相阻塞,还支持 Server Streaming / Bidirectional Streaming(比如边车持续推送配置、上报指标)。HTTP/1.1 想干这个很别扭。

但要注意两个"账别记错"的地方:

  1. HTTP/2 多路复用在同机场景收益打折。多路复用主要解决"网络连接昂贵、建连有往返延迟"的问题——而同机 loopback/UDS 建连几乎不要钱,你完全可以开一堆连接。所以 gRPC 在同机的性能优势,主要来自 Protobuf 序列化,而不是 HTTP/2 传输
  2. gRPC 一样能走 UDS。别以为"要快就得上 gRPC",也别以为"gRPC 就得走 TCP"。Envoy 和它的控制面之间跑 xDS 协议,就是 gRPC over UDS——序列化用 Protobuf 的紧凑,通道用 UDS 的低开销,两头的好处都占了。

所以真正的性能"全家桶"其实是:gRPC(拿 Protobuf 的序列化)+ UDS(拿传输通道的低开销)。协议和通道各取所长,而不是二选一。


四、HTTP over UDS:迁移最省心,可双向通信是个坎

同机场景"换通道比换协议更划算",最省事的一条路就是 HTTP over UDS——应用层代码几乎不动,只把监听/连接地址从 host:port 换成一个 socket 文件路径。

为什么这条路迁移成本最低?因为你的 HTTP 语义一个字都没变:还是 GET /healthPOST /v1/verify,还是那套 header、status code、body。变的只是"底下那根管子"。主流库都原生支持:

# 服务端(以 Go 为例,核心就是把 net.Listen  tcp 换成 unix)
ln, _ := net.Listen("unix", "/run/app/app.sock")
http.Serve(ln, handler)

# 客户端调试,curl 直接用 --unix-socket
$ curl --unix-socket /run/app/app.sock http://localhost/health

Python 的 requests(配 requests-unixsocket)、Node 的 http.request({ socketPath })、Nginx 的 upstream { server unix:/run/app.sock; }——都支持。一句话:HTTP over UDS 是"零学习成本地拿到 UDS 的传输红利"。 对一个已经跑在 HTTP over TCP 上的边车,这是投入产出比最高的第一步。

坎在哪:HTTP/1.1 是"半双工"的问答机

HTTP over UDS 的软肋是双向通信不好办,但根子不在 UDS——UDS 本身是全双工的字节流,两个方向随时都能写。坎在上层的 HTTP/1.1:严格的"请求-响应"模型,一问一答,客户端不发问,服务端就没法主动开口。

具体到边车场景,这些需求 HTTP/1.1 就很别扭:

  • 边车想主动推一条配置变更给主服务(服务端主动发起);
  • 主服务想和边车持续对话(一次握手后来回交换多条消息);
  • 双方想在同一条连接上并发跑多个请求还互不阻塞。

HTTP/1.1 硬凑双向,历史上有三种老办法,各有各的难受:

做法 原理 难受在哪
轮询 / 长轮询 客户端反复问"有更新没" 延迟高、空转浪费;长轮询还占着连接
SSE(Server-Sent Events) 服务端单向推事件流 只能服务端→客户端,反方向还得另开请求
分块传输(chunked) 复用 chunked 编码做流 半吊子,方向和边界都要自己硬约定

SSE 其实挺适合"边车单向推配置"这种场景,而且它就是普通 HTTP,走 UDS 毫无障碍。但只要你需要真·双向(两头都能主动说话),HTTP/1.1 就到头了。

按"双向到什么程度"分三档

先分清到底需要哪种双向,按需求强度从轻到重:

第一档:只要服务端单向推 → SSE over UDS。 边车单向推送(配置下发、事件通知),SSE 就够。它是纯 HTTP,迁移成本和 HTTP over UDS 一样低,客户端挂一条长连接收事件即可,反方向的少量请求照旧走普通 HTTP。能用 SSE 解决就别上 WebSocket——少一层协议就少一份运维。

第二档:要真双向、又想少改架构 → WebSocket over UDS。 WebSocket 经一次 HTTP Upgrade 握手把连接升级成全双工,两头随时收发。它复用 HTTP 握手,照样跑在 UDS 上——现有的 HTTP over UDS 监听器加个 upgrade 处理就行。适合"双方持续对话、消息不那么结构化"的场景,代价是消息格式得自己定(它只搬字节,不管内容)。

第三档:要双向 + 强类型 + 高性能 → gRPC over UDS。 gRPC 的 Bidirectional Streaming(双向流)天生就是干这个的:一条流上两头都能持续发消息,Protobuf 管类型契约,HTTP/2 管多路复用,同样跑在 UDS 上(第三节说过,Envoy 的 xDS 就是这个组合)。

一个容易被忽略的账:迟早要双向的话,与其在 HTTP/1.1 上用"SSE + 反向请求"硬拼伪双向,不如直接上 gRPC over UDS。 前者迁移成本看着低,但双向逻辑全是手写胶水,越往后越难维护;后者前期要引 Protobuf 工具链,但双向是协议原生能力,逻辑干净。

基本单向 → HTTP over UDS(配 SSE 推送);"真双向"成硬需求 → 别在 HTTP/1.1 上打补丁,要么 WebSocket over UDS 保迁移平滑,要么 gRPC over UDS 一步到位拿下双向、契约、性能。 若已预感双向躲不掉,起点就直接选 gRPC over UDS,省得中途换赛道。

一个常见误区:ChatGPT 用的就是 SSE,那全改成 SSE over UDS 行不行

ChatGPT / OpenAI 的流式接口(stream=true)用的正是 SSE:发一个 prompt,模型花几十秒逐字吐 token,那种"一个字一个字冒出来"的效果,底层就是服务端把 response body 当成事件流慢慢写。于是有人会想:把边车通信从 HTTP over TCP 全迁到 SSE over UDS,是不是更好?

先纠正一个概念错位:SSE 不是 HTTP 的替代品,它就是 HTTP 的一种用法——一次普通请求,服务端把响应做成长连接的事件流。ChatGPT 的例子也一样:"发 prompt"那一下还是普通 HTTP POST,SSE 只负责"答案怎么流回来",是"普通请求 + SSE 响应",不是"纯 SSE 双向通道"。

想清楚这点就会发现,所谓"迁移"其实是两件独立的事叠在一起

  1. 换通道:TCP → UDS。纯收益,同机场景就该做,跟用不用 SSE 无关。
  2. 换交互模式:请求-响应 → 服务端流式推送。这改变了通信语义,不是无损平移。

第一件尽管做。第二件不是"能不能"的问题,而是"值不值"的问题——得看这条路径的交互形态,别一刀切。

两件事的优缺点先摊平:

优点 缺点 / 代价
换通道 TCP→UDS 省协议栈开销、不占端口、文件权限做访问控制;仍是纯 HTTP,curl --unix-socket 照样调 只在同机有效(第七节的坑);tcpdump 抓不到
换模式 →SSE 服务端能主动持续推(配置热更新、进度、事件流),普通 HTTP 做不到;比 WebSocket 简单(纯文本、无需 Upgrade 握手,自带断线重连) 下行单向,上行得另开请求;只能传文本,二进制要 base64;长连接的连接数/内存/超时得自己盯

"有来有回,用 HTTP + SSE 不也挺好"——对,但要看清它是"两条腿走路"

这里得替 SSE 说句公道话。有人会问:服务和边车明明有来有回,用 HTTP + SSE 感觉挺顺的呀?

没错,这方案确实能做"有来有回",ChatGPT 网页版就是活例子——你打字提问是上行的普通 HTTP,它逐字回答是下行的 SSE。但要看清它的真实形态

边车 ──普通 HTTP 请求──▶ 主服务     上行:一次一次地问
边车 ◀──SSE 长连接──── 主服务     下行:持续地推

它是两条独立的连接在配合:一条 SSE 长连接管服务端往下推,另一条(或每次新开的)普通 HTTP 请求管客户端往上说。合起来效果上"有来有回",但底层是两个方向、两套连接,不是 WebSocket 那种"一条连接上双向随便发"。

看清这点,什么时候用它就清楚了——关键看"上行有多重"

交互形态 举例 推荐
纯问答(无下行推送) /health/verify 普通 HTTP over UDS,套 SSE 是过度设计
下行为主、上行零星 边车持续推配置/指标,主服务偶尔回 ack、改订阅 HTTP + SSE over UDS,简单够用,全程纯 HTTP
上行也高频 / 要低延迟 / 上下行要严格关联 双方对等地来回交换消息 WebSocket over UDS,一条连接双向,省掉关联胶水
上述 + 强类型 + 高性能 跨语言、结构复杂的双向流 gRPC over UDS

"有来有回"不是一个点,是一条光谱:越靠"下行为主",HTTP + SSE 越舒服;越靠"上行也重、要严格交错",它那"两条腿"就越吃力——每个上行新开连接的开销、上下行怎么关联、跨连接没有顺序保证,这些胶水都得自己兜,这时候 WebSocket / gRPC 双向流更顺。

换通道(TCP→UDS)是纯赚,放心换;换模式(→SSE)看上行有多重——下行为主就大胆用 HTTP + SSE,上行也变重再抬到 WebSocket / gRPC。 别把所有接口一股脑全流式化,也别一见"有来有回"就急着上 WebSocket。

深挖:HTTP + SSE 到底有哪些坑

前面说它是"甜区",但甜区不等于没坑。HTTP + SSE 的问题大多不是 SSE 自己的 bug,而是"两条腿走路"这个架构、加上 HTTP/1.1 的底子带来的结构性代价——平时压测风平浪静,一上量、一上生产就咬人。分三层看。

协议层的三个硬伤

  • HTTP/1.1 下,一条 SSE 就占死一条 TCP 连接。 SSE 是一个"永不结束的响应",只要流开着,这条连接就被独占,干不了别的。HTTP/1.1 的连接是"一条一次只能跑一个请求",于是每多一路 SSE,连接池就少一个坑。浏览器对此有个著名的每域名 6 连接上限(MDN 明确警告过,超了新请求排队),边车通信不走浏览器、没有这个 UA 硬限制,但底层机制一模一样:你的 HTTP client 连接池会被长连接吃光,短请求排队饿死。解药是走 HTTP/2——多路复用让一条 TCP 连接跑上百路 stream,SSE 才算真正好用。但这又绕回来了:既然都上 HTTP/2 了,双向流本是 HTTP/2 的原生能力,此时 gRPC 往往比"HTTP/2 + SSE + 反向请求"更顺。

  • 上行、下行是两条独立连接,天生没有关联和顺序保证。 "边车发一个上行请求"和"主服务在 SSE 流里推的响应",是两条腿上发生的两件事,内核不会帮你把它们对应起来。想知道"这段下行是回应哪个上行的",得自己塞关联 ID、自己维护 session、自己处理"上行到了但下行还没来"的时序。这层胶水,就是 WebSocket / gRPC 双向流帮你省掉的东西。 上行越频繁、交错越密,这层胶水越厚越易出 bug。

  • SSE 只能传 UTF-8 文本。 规范如此。要传二进制(Protobuf、图片、压缩块),得 base64,白白涨 33% 体积还多两次编解码。下行是结构化二进制数据的场景,SSE 天然不趁手。

架构层的两个陷阱

  • 半打开连接(half-open)最阴。 SSE 是长连接,最怕"连接其实已经断了,但两头都以为还活着"。客户端傻等永远不来的事件,服务端对着一个已死的 socket 继续写。TCP 的 keepalive 默认以小时计,根本指望不上。标准做法是应用层心跳:服务端每隔十几秒推一个注释行(: heartbeat\n\n)当探活,客户端设读超时,超时没收到就主动重连。这套东西 SSE 不会白送,得自己搭。

  • 断线重连要"接得上",得靠你配合。 SSE 协议自带 Last-Event-ID(客户端重连时带上最后收到的事件 ID)和 retry:(服务端建议的重连间隔),这是它比裸 WebSocket 贴心的地方。但能不能续上,取决于服务端认不认这个 ID、补不补发断连期间漏掉的事件。不实现这套,"自动重连"就只是"重新连上",中间丢的消息照丢。

运维层的两个暗礁

  • 中间的代理/负载均衡会"帮倒忙"。 Nginx 这类反向代理默认会缓冲响应——它想凑一批数据再转发,可这正好把 SSE"实时推"的命根子掐了:事件卡在代理的 buffer 里,客户端半天收不到。必须显式关掉缓冲(Nginx 要设 proxy_buffering offX-Accel-Buffering: no),还要调大读超时,否则代理会把"长时间没数据"的 SSE 连接当成超时给掐了。走 UDS 少一层网络代理,这个坑轻些,但只要链路上有代理就得防。

  • 长连接的资源账要自己算。 每条 SSE 都是一条常驻连接,占着 fd、占着内存缓冲、占着服务端一个处理协程/线程。同机边车通常连接数不多还好,可一旦是"一个主服务对多个边车实例"或连接数上规模,fd 上限、内存、超时清理就得盯着。短请求用完即走,长连接是养着的——养着的东西都有成本。

一句话收口:

HTTP + SSE 的坑,几乎都长在"长连接"和"两条腿"这两个词上:连接被独占、上下行要自己关联、要自己做心跳与重连、要防代理缓冲、要算资源账。 下行为主、上行零星时,这些代价可控,值得用;一旦上行变重、连接上规模,与其把这些胶水全手写一遍,不如换 WebSocket / gRPC 一步到位。

上手:SSE / WebSocket over UDS 的 Python 最小示例

前两档各给一个能直接跑的 Python demo。这里用 aiohttp,因为它对 UDS 两头都原生支持——服务端 web.UnixSite,客户端 aiohttp.UnixConnector,SSE 和 WebSocket 都不用额外装适配库。

先装依赖:

$ pip install aiohttp

第一档:SSE over UDS(服务端单向推)

服务端把响应保持成 text/event-stream 的长连接,按 SSE 格式(event: + data: + 空行)不断往外写;客户端挂着一条连接收事件。整段就是普通 HTTP,只是监听在 socket 文件上:

# sse_server.py —— 服务端单向推配置
import asyncio, os
from aiohttp import web

SOCK = "/tmp/sse_demo.sock"

async def events(request):
    resp = web.StreamResponse(
        status=200,
        headers={"Content-Type": "text/event-stream",
                 "Cache-Control": "no-cache"},
    )
    await resp.prepare(request)
    for i in range(3):                       # 真实场景里这里是"配置变了就推"
        await resp.write(f"event: config\ndata: version={i}\n\n".encode())
        await asyncio.sleep(1)
    return resp

async def main():
    app = web.Application()
    app.router.add_get("/events", events)
    runner = web.AppRunner(app)
    await runner.setup()
    if os.path.exists(SOCK):
        os.remove(SOCK)                      # 清理残留的 socket 文件(第七节的坑)
    await web.UnixSite(runner, SOCK).start()
    await asyncio.Event().wait()             # 挂住别退出

asyncio.run(main())
# sse_client.py —— 客户端挂着收事件
import asyncio, aiohttp

SOCK = "/tmp/sse_demo.sock"

async def main():
    conn = aiohttp.UnixConnector(path=SOCK)  # 关键:走 UDS,不是 host:port
    async with aiohttp.ClientSession(connector=conn) as session:
        async with session.get("http://localhost/events") as resp:
            async for line in resp.content:
                s = line.decode().rstrip()
                if s:
                    print("收到:", s)

asyncio.run(main())

先跑 server 再跑 client,客户端会陆续打印出服务端推来的 event: config / data: version=0..2。想用 curl 验一下也行:curl --unix-socket /tmp/sse_demo.sock http://localhost/events注意 SSE 是单向的——反方向要给服务端发东西,还得另开一个普通 HTTP 请求。

第二档:WebSocket over UDS(真双向)

WebSocket 靠一次 HTTP Upgrade 握手把连接升级成全双工,之后两头随时收发。aiohttp 的服务端和客户端都自带 WS 支持,同样跑在 socket 文件上:

# ws_server.py —— 双向:收到什么回什么(真实场景可主动 push)
import asyncio, os
import aiohttp
from aiohttp import web

SOCK = "/tmp/ws_demo.sock"

async def ws_handler(request):
    ws = web.WebSocketResponse()
    await ws.prepare(request)                # 完成 Upgrade 握手
    async for msg in ws:                     # 持续收客户端消息
        if msg.type == aiohttp.WSMsgType.TEXT:
            await ws.send_str(f"echo: {msg.data}")   # 也能随时主动 send
            if msg.data == "bye":
                await ws.close()
    return ws

async def main():
    app = web.Application()
    app.router.add_get("/ws", ws_handler)
    runner = web.AppRunner(app)
    await runner.setup()
    if os.path.exists(SOCK):
        os.remove(SOCK)
    await web.UnixSite(runner, SOCK).start()
    await asyncio.Event().wait()

asyncio.run(main())
# ws_client.py —— 双向:能发也能收
import asyncio, aiohttp

SOCK = "/tmp/ws_demo.sock"

async def main():
    conn = aiohttp.UnixConnector(path=SOCK)
    async with aiohttp.ClientSession(connector=conn) as session:
        async with session.ws_connect("http://localhost/ws") as ws:
            for text in ("hello", "world", "bye"):
                await ws.send_str(text)
                reply = await ws.receive()
                print("收到:", reply.data)

asyncio.run(main())

跑起来客户端会打印 echo: hello / echo: world / echo: bye。对比一下就能体会差别:SSE 里客户端只能收,WebSocket 里 send_strreceive 两个方向都有——这就是"半双工问答"和"全双工对话"的实感差距。

两段代码都只有一个地方跟"跑在 TCP 上"不同:服务端用 UnixSite(runner, SOCK)、客户端用 UnixConnector(path=SOCK)这也再次印证前面那句——UDS 是全双工的,双向能不能做,取决于你在它上面铺的是 SSE 还是 WebSocket,跟 UDS 本身无关。


五、四种组合,横向拉平了比

把前面的分析收进一张表,维度按边车场景最关心的几项排。

维度 HTTP/JSON over TCP gRPC over TCP HTTP over UDS gRPC over UDS
序列化开销 高(JSON) 低(Protobuf) 高(JSON) 低(Protobuf)
传输通道开销 高(IP 栈) 高(IP 栈) 低(无 IP 栈) 低(无 IP 栈)
同机延迟 一般 较好 最好
可调试性 最好(curl 直连) 差(要工具) 一般(curl 需 --unix-socket) 差(要工具)
跨语言契约 弱(靠约定) 强(.proto)
流式支持 单向可(SSE);真双向需 WebSocket 单向可(SSE);真双向需 WebSocket
运维复杂度 最低 中(socket 文件管理) 中偏高
访问控制 端口,同机可连 端口,同机可连 文件权限,可精细 文件权限,可精细

别把表里的 "HTTP over UDS" 看窄了:它是一整列,已经把第四节的 SSE over UDS 和 WebSocket over UDS 都算在内——SSE 是 HTTP 的流式响应,WebSocket 是 HTTP Upgrade 后的全双工,通道都是 UDS。所以这一列不是"只能一问一答的 HTTP",而是"跑在 UDS 上的 HTTP 家族":默认请求-响应,要单向推用 SSE,要真双向用 WebSocket。gRPC 两列同理,普通调用和双向流都在内。

读表的窍门:从右往左看是"性能优先",从左往右看是"省心优先"。 大多数团队的起点在最左,终点很少需要到最右——多数场景,往中间挪一格(换通道 or 换协议)就够了。

用做饭打个比方:HTTP over TCP 是外卖——通用、谁都会点、出问题好排查,但每次都要经过平台、骑手绕一圈。gRPC over UDS 是自家厨房现炒——最快最省,但你得先把厨房建好、把食材备齐(工具链、socket 管理)。中间两种,是"点了外卖但让骑手抄近道"或"在楼下便利店热个饭"。没有哪种绝对好,看你饿到什么程度、有没有精力开火。


六、真实场景该怎么选:给几条能直接用的判断

落到具体场景,按"从省心到极致性能"排开。

场景 A:边车只是偶尔调一下主服务(低频、报文不大)HTTP/JSON over TCP,别折腾。 比如边车做个偶发的健康检查、拉个配置。这点流量,UDS 省下的那点开销你根本感知不到,而 curl http://127.0.0.1:8080 能直接调试的便利,是实打实的生产力。过早优化通信通道,是给自己挖坑。

场景 B:边车和主服务高频通信(每请求都要过一遍,比如 Mesh 数据面代理、旁挂鉴权/加密)优先把通道换成 UDS,协议先不动。 这是投入产出比最高的一步:改动小(多数框架就是把监听地址从 host:port 换成 unix:/path/to.sock),收益直接(省掉每次请求的协议栈开销),尾延迟改善明显。Envoy、Istio 在数据面就是这么做的。

场景 C:跨语言、接口复杂、字段多、还要流式上 gRPC。 强类型契约省下的联调和维护成本,比性能更值钱。同机部署的话,再把通道配成 UDS,一步到位。Envoy ↔ 控制面的 xDS 就是这个组合的经典案例。

场景 D:需要双向通信(边车主动推、双方持续对话)看双向的强度选(详见第四节)。 只是服务端单向推 → 现有 HTTP over UDS 加个 SSE 就够;要真双向又想少改架构 → WebSocket over UDS;要双向 + 强类型 + 高性能 → gRPC over UDS 一步到位别在 HTTP/1.1 上用长轮询硬凑双向,那是给未来的自己埋雷。

场景 E:既要高性能,又要方便运维/开发插进去看一眼 → 折中:主链路走 gRPC/UDS 保性能,另外暴露一个 HTTP/TCP 调试端口(只读、按需开)。别为了极致性能把可观测性和可调试性全砍掉——半夜排障时你会哭。

一条压过所有场景的原则:

先问这条通信路径"热不热"。冷路径(低频)怎么省心怎么来,HTTP/TCP 就行;热路径(高频)先换 UDS 通道拿最大收益,报文复杂再叠加 gRPC。 反过来做——上来就为一条冷路径上 gRPC over UDS——纯属自找麻烦。


七、选 UDS 之前,先知道这几个坑

UDS 好归好,不是白捡的。下面几个是真实会绊倒人的地方:

  • socket 文件的生命周期:进程崩溃后,.sock 文件可能残留,导致下次启动 bind 报 "address already in use"。启动前要清理,或用 SO_REUSEADDR 之类的处理。
  • 权限与属主:两个容器共享 socket 文件,得保证挂载的 volume 权限对得上(同一个 uid/gid 或合适的 group),否则连不上。容器场景这里最容易踩。
  • 共享卷的挂载:同 Pod 两容器要走 UDS,得把 socket 所在目录挂成 emptyDir 共享卷,两边都挂上。
  • 不能跨机器:这是废话但常有人忘——UDS 只在同一台机器内有效。哪天你把边车和主服务拆到两台机器,代码里写死的 UDS 路径就得改回 TCP。所以最好把"用 TCP 还是 UDS"做成配置项,别硬编码。
  • 可观测性变难tcpdump 抓不到 UDS 流量(它不走网络栈),得用 stracesocat 或专门工具。调试心智成本会上升。

一句话:

UDS 用文件权限换来了安全和性能,也用文件的麻烦换走了网络的通用。把通道做成可配置的,是给未来的自己留后路。


最后一句

回到开头那个画面:隔壁工位的同事,别再给他发邮件了。

同机通信这件事,说到底就一句话——数据不出机器,就别让它假装出趟门。 HTTP over TCP 通用省心,是不错的起点;真到了高频热路径,把通道换成 UDS 是性价比最高的一招;跨语言、结构复杂再叠加 gRPC 的契约与序列化优势;两头好处都想要,就是 gRPC over UDS。

但请记住:最快的方案,不一定是你现在最该选的方案。 先量清楚你的路径热不热,再决定要不要为它开火下厨。

行动清单

  • [ ] 先分清两层:你要改的是协议(HTTP/gRPC)还是通道(TCP/UDS)?
  • [ ] 给关键通信路径打上"冷/热"标签,只优化热路径。
  • [ ] 热路径优先尝试把 TCP 换成 UDS——改动小、收益直接。
  • [ ] 迁移图省事、又基本单向 → HTTP over UDS + SSE;预感到"真双向"躲不掉 → 起点直接选 gRPC over UDS,别中途换赛道。
  • [ ] 压测用真实报文大小和频率,重点看 P99 尾延迟,别只看平均 QPS。
  • [ ] 通道(TCP/UDS)做成配置项,别硬编码,给未来跨机部署留路。
  • [ ] UDS 场景检查:socket 文件清理、共享卷挂载、容器间权限、属主。
  • [ ] 无论走哪条路,保留一个可开关的调试入口,别把可观测性优化没了。

全文思维导图

@startmindmap
<style>
mindmapDiagram {
  node {
    BackgroundColor #F8F9FA
    RoundCorner 10
    Padding 10
    FontSize 13
  }
  :depth(0) {
    BackgroundColor #1E3A5F
    FontColor white
    FontSize 18
    FontStyle bold
  }
  :depth(1) {
    FontSize 15
    FontStyle bold
  }
  :depth(2) {
    FontSize 13
  }
}
</style>

* 边车通信怎么选
** 分两层
*** 协议:HTTP vs gRPC(序列化+语义)
*** 通道:TCP 环回 vs UDS(字节怎么跨进程)
*** 可自由组合,通道收益更直接
** UDS 省在哪
*** 无 IP 头、无 TCP 分段、无校验和、无拥塞控制
*** 内核内存搬运,不占端口
*** 文件权限做访问控制
*** 小报文高频最占便宜,看 P99
** gRPC 值钱在哪
*** .proto 强类型契约、跨语言
*** Protobuf 序列化又小又快
*** 同机优势主要来自序列化,非 HTTP/2
*** 一样能走 UDS
** HTTP over UDS
*** 迁移最省心,只换地址不改语义
*** 坎:HTTP/1.1 半双工,双向难
*** SSE=HTTP 的一种用法,ChatGPT 流式就是它
*** 有来有回=两条腿:SSE 下行 + HTTP 上行
*** HTTP+SSE 的坑:长连接独占、上下行要自己关联
*** 还要自己做心跳/重连、防代理缓冲、算资源账
*** HTTP/1.1 占死连接,要好用得上 HTTP/2
*** 纯问答 → 普通 HTTP;下行为主 → HTTP+SSE
*** 上行也重/要严格关联 → WebSocket
*** 双向+契约+性能 → gRPC over UDS
** 怎么选
*** 冷路径 → HTTP over TCP
*** 热路径 → 先换 UDS 通道
*** 跨语言复杂流式 → gRPC
*** 极致 → gRPC over UDS
** UDS 的坑
*** socket 文件残留
*** 容器权限与共享卷
*** 不能跨机,做成配置
*** tcpdump 抓不到,调试变难
@endmindmap

边车通信怎么选 - 思维导图


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。