跳转至

把 1000 条 TCP 压成 1 条:一次设计推导的完整记录

七张草稿图,记录从一条时间线到一个可运行系统的全过程。

看别人的架构图学不会设计——因为那都是成品图:干净、完整、结构正确,是设计完成后画给别人看的。

设计时画的是另一种东西:草稿。丑、会改、只有自己看得懂,而且不是一次画完的——每画一张,都是因为上一张暴露了问题。

目标只有一个:把所有转发压到一条 TCP 上。这篇记录的是从这条约束出发,一个多路复用系统怎么一步步被设计出来——中间留下的五张图是沿途的草稿,不是目的。

零、起点:只写时间线,不画图

设计的真正起点不是图,是一段大白话。

现状(v1 已经能跑):每来一条本地连接,就往服务端新建一条 TCP。

目标:全部转发只准用一条 TCP。

于是写下最简单的场景——浏览器发一个请求,用大白话把经过的步骤写下来:

  1. 浏览器连 client 的 8080
  2. client 看有没有到服务端的路,没有就修一条(只修一次)
  3. client 在路上说一句:"帮我开一条流,号 1,去 8000"
  4. server 收到,拨 8000
  5. server 回一句:"1 号好了"
  6. 浏览器的数据 → client 打成"1 号的包" → server 拆开 → 写给 8000
  7. 8000 的响应 → 原路打包回来 → 写给浏览器
  8. 浏览器说"我发完了" → 一路传到 8000 → 8000 把剩下的响应补发回来 → 全关

没有箭头、没有术语、没有"并发""多路复用"这些词——这些词是后面才出现的。

下面五张图,全部出自对这段时间的追问。

一、场景视图:时间线里藏着哪些"如果"

对着上面 8 步逐条问"如果…怎么办",答案就是场景清单:

时间线里这一步 如果… 必须保证
4. server 拨 8000 拨不通 立刻失败,不能让人干等
8. 浏览器说"我发完了" 但响应还没回来 半关闭:我发完了,你补发的我还要收
2. 修一条路 对端不是自己人 立刻认出来,别把 HTTP 当协议读
6-7. 双向传数据 两条流同时传 各走各的,不能串
全程 路断了 所有流一起收尾,不能挂死
flowchart LR
    B["浏览器"] -->|"本机 TCP"| C["client<br/>Mac"]
    C -->|"1 条隧道 TCP"| S["server<br/>开发机"]
    S -->|"每流 1 条"| T["目标服务<br/>:8000"]

这张图不是画出来才有设计的——它是把刚才那些"如果"落到链路上,看清每一段各自的风险。

二、部署视图:两端各有实例,且对称

撞墙:时间线第 2 步说"修一条路",但这条路两端各站着谁?只画单侧分层看不出来——得先看清运行时有几个进程实例、它们之间用什么连。

flowchart LR
    R["外部请求<br/>(浏览器,N 个并发)"]

    subgraph CL["client 进程"]
        direction TB
        A1["accept<br/>本地连接"] --> S1["Session"]
        S1 --> ST1["Stream<br/>id 1 / 3 / 5 …"]
    end
    subgraph SV["server 进程"]
        direction TB
        A2["accept<br/>对端开的流"] --> S2["Session"]
        S2 --> ST2["Stream<br/>id 1 / 3 / 5 …"]
        S2 --> D1["dial<br/>每条流一次"]
    end
    T["target 目标服务"]

    R -->|"N 条"| A1
    S1 <-->|"1 条<br/>frame 双向"| S2
    ST1 -.->|"同一个 streamID"| ST2
    D1 -->|"N 条"| T

顺着外部请求走一遍:每个请求进来 → client accept 一条本地连接 → 被 Session 封装成一条 Stream → 打成一帧帧 frame 挤进中间那条唯一的 Conn → server 按 streamID 拆回对应的 Stream → 每条流各 dial 一次目标。这就是时间线第 2、3 步的图形版。

这张图补齐了三件"只看分层看不出来"的事:

  1. 两端各有一个 Session,结构完全对称——同一份代码,两个实例
  2. 两侧的 Stream 是同一条流的两个端点,靠 streamID 对应(虚线),不是两条独立的连接
  3. 三条连线的数量是 N / 1 / N:外部请求 N 条、隧道只有 1 条、到目标又是 N 条

最后一条正好呼应那个目标:多路复用省掉的是中间那条跨机器连接,首尾两段一条都少不了——因为浏览器开几条连接、目标服务该看到几个客户端,都不该由隧道替它们做主。

三、逻辑视图:谁来负责"复用"

撞墙:把第 2 步的 dial 挪出循环之后,两个浏览器的响应字节交错写进同一条连接,服务端收到的是 aaa…bbb…aaa…,完全分不清哪段属于谁。

于是回头看时间线里反复出现的词,把它们命名:

时间线里的词 命名 职责
"到服务端的路" Session 一条 TCP + 一张流表 + 一个读循环
"一条流" Stream 一条逻辑连接
"打成包" frame 编解码

分层就自己长出来了:

flowchart TD
    subgraph APP["① 应用层:v1 原有,一行未改"]
        A1["accept 本地连接"]
        A3["bridge 双向拷贝"]
    end
    subgraph FLOW["② 流层:会话向上提供的抽象"]
        F1["Stream<br/>实现 net.Conn"]
    end
    subgraph SESS["③ 会话层:复用在这里发生"]
        S1["Open / Accept<br/>分配 ID、登记流表、发 SYN"]
        S2["读循环:按 ID 分发"]
        S3["写锁:整帧原子写出"]
        SF["frame 编解码(内部工具)"]
    end
    subgraph TRANS["④ 传输层"]
        T1["唯一一条 TCP"]
    end

    A1 -->|"每条本地连接调一次"| S1
    S1 -.->|"封装产出"| F1
    A3 -->|"当普通连接读写"| F1
    F1 -->|"读写入口"| S3
    F1 -->|"收数据"| S2
    S2 --> SF
    S3 --> SF
    SF --> T1

虚线是创建关系,实线是数据流。三种方向各有用意:

  • 控制向下:应用层调 Session.Open(),每条本地连接一次
  • 抽象向上:会话把这条连接封装成一个 Stream 交给上层——这才是"每来一条请求就被封装成一条流"的准确画法
  • 数据下穿:bridge 把 Stream 当普通 net.Conn 读写,Stream 的所有 I/O 都下穿到会话层,最终落到唯一那条 TCP

服务端是同一张图的镜像:会话收到 SYN 也产出一条 Stream,上层 Accept() 拿到它、dial 目标、再 bridge(Stream, 目标连接)。两端对称,流的创建者都是会话层——复用对应用层完全透明。

这张图逼出的关键决策:应用层只依赖 net.Conn,不认识 Stream。复杂度被关在会话层内部,不外溢——所以 v1 的 bridge 一行没改。

四、进程视图:谁读、谁写、谁能卡住

撞墙:接着写就发现两个硬问题——

  • 两个 goroutine 同时读这条 TCP,会把帧读成两半
  • 多条流同时写,字节交错,对端解析直接错乱

再看时间线第 6 步:"打包 → 传 → 拆包 → 写给 8000"。如果 8000 那边读得慢,包就会堆积——堆积在哪、堆满了怎么办,逼出这张图:

flowchart TD
    LC["本地连接(每条转发一条)"]

    subgraph SESSION["会话级:每会话仅一份"]
        RL["readLoop × 1<br/>唯一读 socket 的人"]
        MAP["streams map<br/>互斥访问"]
        WL["conn 写锁<br/>写 socket 必须串行"]
    end
    subgraph STREAM["流级:每条流一份,N 条流 = 2N 个 goroutine"]
        G1["拷入 goroutine × 1<br/>本地 → Stream"]
        Q["recv 队列(容量 16)<br/>背压闸门"]
        G2["拷出 goroutine × 1<br/>Stream → 本地"]
    end

    LC -->|"Read"| G1
    G1 -->|"Write:抢写锁"| WL
    RL -->|"读 socket → 按 ID 分发<br/>队列满则阻塞在这里"| Q
    RL -.->|"查表"| MAP
    Q -->|"Read"| G2
    G2 -->|"Write"| LC

并发度:1 + 2N——会话级 1 个 readLoop,每条流 2 个拷贝 goroutine。

谁读谁写(这张图真正要交代的东西):

资源 谁来读 谁来写 并发数 怎么保护
隧道 socket(收) 只有 readLoop — 读 1 天然安全(只有一个读者)
隧道 socket(发) — 所有流的拷入 goroutine 写 N 写锁,整帧一次写完
streams map readLoop、流的生命周期 同上 读/写 N+1 mutex
Stream.recv 队列 拷出 goroutine readLoop 1 对 1 channel 自带同步

三条铁律是这张图的直接推论,不是经验:

  1. 只有一个读者(两个读者会读乱)
  2. 写必须整帧原子(否则交错成半帧)
  3. 投递允许阻塞、但绝不能持锁——阻塞是为了背压,持锁会把整条会话连坐卡死

结构图说的是"有几个、归谁",但看不出它们同时在做什么。补一张时序图——参与者是 goroutine 本身,三个方向画在 par 块里,就是真正的并行:

sequenceDiagram
    participant LC as 本地连接
    participant G1 as 拷入 goroutine<br/>(每流 1 个)
    participant SOCK as 隧道 socket<br/>(写锁保护)
    participant RL as readLoop<br/>(全会话仅 1 个)
    participant Q as recv 队列<br/>(每流 1 个,容量 16)
    participant G2 as 拷出 goroutine<br/>(每流 1 个)

    par 上行:本地 → 隧道
        LC->>G1: Read
        G1->>SOCK: 抢写锁 → 整帧 Write
    and 读循环:隧道 → 分发
        RL->>SOCK: Read(唯一读者)
        RL->>Q: 按 streamID 分发
        Note over RL,Q: 队列满则阻塞在这里(背压点)
    and 下行:队列 → 本地
        Q->>G2: Read
        G2->>LC: Write
    end

三条竖线同时跑,一眼能看出三件事:读 socket 的只有 readLoop 一条、写 socket 的都要过 SOCK 那把锁、会卡住的位置就是 RL → Q 那一步。

五、行为视图:顺序到底是怎样的

撞墙:时间线第 8 步最难——"我发完了"要一路传到 8000,而 8000 补发的响应还要能原路回来。同时第 4 步"拨不通"要立刻回话。这两件事的顺序光靠时间线说不清了,必须画出来:

sequenceDiagram
    participant L as 本地连接
    participant C as client
    participant S as server
    participant T as 目标服务

    L->>C: 新连接
    C->>S: SYN(id, target)
    alt 目标可连通
        S->>T: dial 目标
        S->>C: SYNACK(id)
        C->>S: DATA(id) 请求
        S->>T: 转发请求
        T->>S: 响应
        S->>C: DATA(id) 响应
        L->>C: FIN(我发完了)
        C->>S: FIN(id)
        S->>T: 转告半关闭
        T->>S: 补发剩余响应
        S->>C: DATA(id) 剩余响应
        S->>C: FIN(id)
        Note over C,S: 双向结束 → 从 map 摘除
    else 目标不可达
        S->>C: RST(id)
        Note over L: 立刻关闭,不挂死
    end

注意 FIN 之后还有一段 DATA——这正是半关闭的意义:我发完了,但你补发的响应我还要收。

六、状态视图:一条流到底有几种状态

撞墙:写着写着开始分不清"现在这条流还能不能读、还能不能写"。四个布尔量在脑子里打架的时候,就该画状态图了:

stateDiagram-v2
    [*] --> Opening: 发出 SYN
    Opening --> Established: 收到 SYNACK
    Opening --> Closed: 收到 RST / 超时
    Established --> HalfClosed: 发出 FIN
    Established --> ReadDone: 收到对端 FIN
    Established --> Closed: 收到 RST
    ReadDone --> Closed: 队列读完 → EOF
    HalfClosed --> Closed: 双向结束
    Closed --> [*]: 从 map 摘除

一旦画出来,"什么状态可读、什么状态可写"就变成可查的表,而不是散落在代码里的 if。协议 bug 九成出在状态迁移没覆盖全。

七、回头看:哪些是被逼的,哪些才是选择

六张图画完,回头清点:

步骤 性质
把 dial 挪出循环 目标
加 streamID(否则分不清归属) 被逼
加长度字段(TCP 没边界) 被逼
加 SYN/FIN/RST(开流、半关闭、拒绝) 被逼
只留一个读 goroutine 被逼
写锁(否则字节交错) 被逼
队列设上限(否则内存爆) 被逼
结束时从 map 摘掉 被逼
让 Stream 实现 net.Conn 选择(为了上层零改动)
收尾按能力断言而非类型 选择(为了兼容上面那条)

十步里只有两步是真正的选择,其余都是"不这么做就跑不起来"。

这就是设计这件事的真相:它不像在菜单里点菜,更像是被问题推着走——每回答一个问题,结构就长一点,长到最后回头一看,就是那五张图。

下次你设计这类场景,照这个顺序

  1. 写时间线(大白话,8 步以内)——不画图
  2. 逐条问"如果…怎么办" → 场景清单
  3. 给时间线里反复出现的词命名 → 对象和分层
  4. 遇到"谁同时在做" → 进程图
  5. 遇到"顺序说不清" → 时序图
  6. 遇到"状态分不清" → 状态图

判断标准也很简单:能用大白话把每个参与者在每个时刻做什么讲一遍,并且答得出每个"如果",就可以开始写代码了。讲不出来就回去补时间线——这时候动手,只会写出要推倒重来的东西。

代码在 example/2026/10/27,mux.go 顶部那三条不变量就是第三张图那三个箭头的文字版。


最后更新:2026-10-28

评论