把 1000 条 TCP 压成 1 条:一次设计推导的完整记录
七张草稿图,记录从一条时间线到一个可运行系统的全过程。
看别人的架构图学不会设计——因为那都是成品图:干净、完整、结构正确,是设计完成后画给别人看的。
设计时画的是另一种东西:草稿。丑、会改、只有自己看得懂,而且不是一次画完的——每画一张,都是因为上一张暴露了问题。
目标只有一个:把所有转发压到一条 TCP 上。这篇记录的是从这条约束出发,一个多路复用系统怎么一步步被设计出来——中间留下的五张图是沿途的草稿,不是目的。
零、起点:只写时间线,不画图
设计的真正起点不是图,是一段大白话。
现状(v1 已经能跑):每来一条本地连接,就往服务端新建一条 TCP。
目标:全部转发只准用一条 TCP。
于是写下最简单的场景——浏览器发一个请求,用大白话把经过的步骤写下来:
- 浏览器连 client 的 8080
- client 看有没有到服务端的路,没有就修一条(只修一次)
- client 在路上说一句:"帮我开一条流,号 1,去 8000"
- server 收到,拨 8000
- server 回一句:"1 号好了"
- 浏览器的数据 → client 打成"1 号的包" → server 拆开 → 写给 8000
- 8000 的响应 → 原路打包回来 → 写给浏览器
- 浏览器说"我发完了" → 一路传到 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 步的图形版。
这张图补齐了三件"只看分层看不出来"的事:
- 两端各有一个
Session,结构完全对称——同一份代码,两个实例 - 两侧的
Stream是同一条流的两个端点,靠streamID对应(虚线),不是两条独立的连接 - 三条连线的数量是 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 自带同步 |
三条铁律是这张图的直接推论,不是经验:
- 只有一个读者(两个读者会读乱)
- 写必须整帧原子(否则交错成半帧)
- 投递允许阻塞、但绝不能持锁——阻塞是为了背压,持锁会把整条会话连坐卡死
结构图说的是"有几个、归谁",但看不出它们同时在做什么。补一张时序图——参与者是 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 | 选择(为了上层零改动) |
| 收尾按能力断言而非类型 | 选择(为了兼容上面那条) |
十步里只有两步是真正的选择,其余都是"不这么做就跑不起来"。
这就是设计这件事的真相:它不像在菜单里点菜,更像是被问题推着走——每回答一个问题,结构就长一点,长到最后回头一看,就是那五张图。
下次你设计这类场景,照这个顺序
- 写时间线(大白话,8 步以内)——不画图
- 逐条问"如果…怎么办" → 场景清单
- 给时间线里反复出现的词命名 → 对象和分层
- 遇到"谁同时在做" → 进程图
- 遇到"顺序说不清" → 时序图
- 遇到"状态分不清" → 状态图
判断标准也很简单:能用大白话把每个参与者在每个时刻做什么讲一遍,并且答得出每个"如果",就可以开始写代码了。讲不出来就回去补时间线——这时候动手,只会写出要推倒重来的东西。
代码在 example/2026/10/27,mux.go 顶部那三条不变量就是第三张图那三个箭头的文字版。
最后更新:2026-10-28