多路复用共享传输并按流保序,这正是长连接推送比一问一答的 WebSocket 更契合它的原因。
一条连接,多条流 HTTP/2 把一条 TCP 连接劈成许多逻辑流,gRPC 又让每次调用都占一条流,于是啰嗦的微服务不再每调一次就新建一次 socket。这层共享把建连、TLS 握手、空闲连接这些一次性成本真正压缩到「每条连接一次」。流与流之间顺序彼此独立、相互错开:一条昂贵的长时间流,不会把同管道里那条短调用卡住。换来的是整批连接的显著下降:每次调用的额外开销,只剩几个帧头。
流式比 HTTP/1.1 扇出更省 因为 gRPC 是双向的,服务器可以在客户端还在发请求时就开始回传部分结果,客户端也能在同一条调用上持续推送。搜索、文件流、日志传输、交互式订阅……这些都天然适配:一个「先等人把整包交完」的单回包,在这会被切成一条持续流的不断产出。跟 HTTP/1.1 相比,扇出(很多客户端同时来取)的优势尤其明显——那里每个客户端都要自建连接、自握一次手。凡是天然要来回绕圈的协议,都该映射成一条长生命流,而不是一堆临时请求。
超时也共用一根管道 共享这根连接确实解决了问题,但它仍然遵守传输层的边界:重试要开新流,而不能在当前流上再试;背压也依然真实存在,一个慢消费者可以把整根共享连接拖到退化。于是 deadline 也是整条线一起权衡的:一个端点慢出天际,看起来就像同连接上所有调用的时延一起涨。实际工程习惯是给每次调用一对真实 deadline 和取消途径,别让共享管道替某个卡住的调用默默承担后果。
坏掉的样子 流控窗口卡死最具迷惑性:连接正常不断开,但吞吐直接掉零。协议同时维护流级窗口与连接级窗口,只有接收方消费并回送更新,发送方才能继续。当某个处理器消费缓慢而对端持续生产时,该流先被顶住;连接级窗口占满后,同连接所有流一起停顿。排查要分开两类信号:单流缓冲堆积,还是连接级更新停滞。每次调用都应使用有界缓冲与消费超时,而不是用无界队列掩盖慢消费。
元数据超大会快速失败,且原样重试永不恢复。初始元数据被编码为头部帧,服务端普遍存在严格上限,常见值在数千字节量级。认证令牌叠加追踪行李与租户标签后很容易越线,调用在进入业务逻辑前就以资源耗尽结束。二进制元数据还有编码膨胀,体积比直觉更大。治理方法是保持元数据精简,把关联信息放到请求体或带外传递,并在网关提前校验长度。
长连接会打坏朴素均衡。每个客户端只持有一条复用连接时,其全部流都会钉在建连瞬间选中的后端上。扩容后新实例空闲而旧实例饱和,客户端却毫无感知。应引入客户端侧均衡或数据面发现,让地址随实例变化;给连接设置存活上限并定期重建;限制单连接并发流数,逼迫客户端摊薄压力。复用越高效,缺少再均衡时倾斜越严重。
截止时间丢失会把单个慢叶子放大为整树延迟。调用预算随请求向下传递,但网关扇出时若使用全新超时或无关的后台上下文,就会悄悄丢掉预算,下游用默认宽松超时继续等待。正确做法是从入站上下文派生全部出站调用,每次尝试短于端到端剩余预算,并在拿到确定性失败后取消仍在飞行的兄弟调用。
快速验证 把下面程序保存为 mux_sim.py,运行 python3 mux_sim.py。它只用标准库模拟一条连接承载三条流的交错帧:二号流先发一帧随后停滞,调度器须送完一号与三号流。断言检查每流保序、线路交错,以及窗口恢复后二号流在同一线路上续传。
from collections import deque # 一条共享连接,三条逻辑流。每条流是有序消息队列, # 线路是交错帧 (stream_id, payload) 的单一日志。 streams = { 1: deque(["a1", "a2", "a3"]), 2: deque(["b1", "b2", "b3"]), 3: deque(["c1", "c2"]), } stalled = set() # 窗口耗尽而停滞的流 wire = [] # 共享传输日志 delivered = {sid: [] for sid in streams} def pump(): """轮询一轮:每条就绪流最多发出一帧。""" progressed = False for sid in sorted(streams): if sid in stalled: continue if streams[sid]: payload = streams[sid].popleft() wire.append((sid, payload)) delivered[sid].append(payload) progressed = True return progressed def drain(): while pump(): pass # 第一轮:每条流各放一帧到线路上。 assert pump() is True assert wire == [(1, "a1"), (2, "b1"), (3, "c1")], wire # 二号流窗口耗尽,不再回送更新。 stalled.add(2) drain() # 二号流卡在只交付一帧时,一号与三号流必须完成。 assert delivered[1] == ["a1", "a2", "a3"], delivered[1] assert delivered[3] == ["c1", "c2"], delivered[3] assert delivered[2] == ["b1"], delivered[2] assert len(wire) == 6, wire assert list(streams[2]) == ["b2", "b3"] # 窗口恢复:二号流在同一条连接上续传。 stalled.discard(2) drain() assert delivered[2] == ["b1", "b2", "b3"], delivered[2] for sid, items in delivered.items(): assert items == sorted(items), (sid, items) # 每条流内部保序 assert len(wire) == 8, wire assert all(not q for q in streams.values()) print("wire:", wire) print("PASS: 3 streams, 8 frames, one shared wire; stall of stream 2 did not block 1 and 3") 脚本先让每条流各放一帧,随后停滞二号流。排空须完成一号与三号流,而二号流仍只交付一帧,证明单流阻塞不会拖住他流。恢复窗口后排空剩余帧,最终确认各流按序到达且共用同一日志。该实验只建模调度,不实现压缩加密与真实窗口字节,调优生产参数前仍需在真实调用栈复核。
...