客户端加到 8 个线程,QPS 变成 0.98 倍、P50 涨 8.6 倍; 换成 4 个服务进程,QPS 从 1917 涨到 5233,是 2.73 倍。加线程没用,加进程才有用。
上一节把模型放在用户机器上。 这一节把它集中到机房——这是最常见的做法,也是「扩展性」这三个字真正的含义。
一、服务器端部署解决什么
模型只存一份。用户那边只需要一个界面,几 GB 的权重放在机房。模型升级对用户无感。 算力可集中。一张 A100 可以服务几十个用户,比每人配一张卡划算得多。 可观测。 所有请求都过你的手,日志、延迟、报错、输入分布全都能看到——这正是上一章「持续监控」能落地的前提。 代价是三件:数据要出机器(合规问题)、有网络往返(延迟下限被锁死)、有持续的服务器账单。
二、扩展有两种方向,别搞混
垂直扩展(scale up):给一台机器加 CPU、加显存。 简单,但有物理上限,而且贵得不成比例——顶配机器的价格常常是次顶配的三倍,性能只差 30%。 水平扩展(scale out):加更多机器,前面放一个负载均衡。 理论无上限,但要处理会话一致性、模型分发、监控聚合。 工程上几乎都是先水平扩,实在不行才垂直扩。 原因很朴素:两台普通机器的总价比一台顶配低,容错还更好——坏一台还剩一半。
三、实测:加线程没用,加进程才有用
Demo 起了个真的 HTTP 推理服务,然后做两组对照。 第一组:客户端从 1 个线程加到 8 个。 QPS 变成 0.98 倍——基本没动,但 P50 从 0.48 ms 涨到 4.14 ms,是 8.6 倍。 多出来的时间全是排队。 第二组:换成 4 个服务进程、4 个客户端进程。QPS 从 1917 涨到 5233,是 2.73 倍。 同一个 4 倍并发,这次真的变快了。 差别在 GIL。 服务端是一个 Python 进程,请求解析、JSON 编解码这些活被一把锁串起来了, 开线程只是让它们排得更整齐;而每个进程有自己独立的解释器和 GIL,才能真同时算。 这就是为什么 gunicorn 用多 worker(进程)而不是多线程, 为什么 K8s 的水平扩展是加副本(进程)而不是加线程。 线程在 Python 里解决的是「同时在等 I/O」,不是「同时在算」。 还有一件事要交代:4 个进程只拿到 2.73 倍,不是 4 倍。 因为这台机器只有 8 个核,还要分给客户端和操作系统。 真实的多副本部署同样拿不到线性加速,规划容量时不能按 1 比 1 算。
四、什么时候选服务器端,代价是什么
选它的条件:用户量大且分散、模型大到装不进用户设备、需要统一观测和快速迭代。 三条里占两条就该上服务器。 最大的代价是延迟下限。用户到机房的往返,同城几毫秒、跨省十几到几十毫秒、跨国一两百毫秒。 这个数不可能通过优化模型消掉。 第二条代价是数据出机器。 这条常常是合规红线,不是技术问题而是流程问题——要么走审批,要么改成本地部署。 第三条是成本随调用量线性增长。 这正好是下一节「无服务器」要解决的事:让成本只跟着真实调用走,不跟着「服务器开着」走。
五、动手 Demo:扩展实测:8 线程 QPS 0.98 倍,4 进程 2.73 倍
同一个 HTTP 推理服务,先在同一进程里用 1 个和 8 个客户端线程压测(各 600 次请求), 再改成 1 个和 4 个独立的服务进程(各自绑定端口),客户端也用同样数量的进程分开压, 比较总 QPS 和延迟分位数。
pr = mp.Process(target=_serve_on, args=(port,)) # 每个进程一个服务
pr.start()
with mp.Pool(n) as pool:
pool.map(_client_job, [(ports[i % n], per) for i in range(n)])
实跑输出:
--------------------------------------------------------------------
服务器端部署:线程加到 8 个没用,换 4 个服务进程快 2.7 倍
--------------------------------------------------------------------
服务端是一个 32 维输入、16 维输出的 HTTP 推理服务,每个请求一次矩阵乘加。
配置 QPS P50 P95
-------------------------------------------------------
单服务进程 + 1 个客户端线程 1784 0.48 ms 1.02 ms
单服务进程 + 8 个客户端线程 1749 4.14 ms 7.12 ms
1 个服务进程 + 1 个客户端进程 1917 — —
4 个服务进程 + 4 个客户端进程 5233 — —
第 1 行到第 2 行:客户端从 1 个线程加到 8 个,QPS 变成 0.98 倍。这个数反复跑会在 0.5 到 1.1 之间晃——**总之没有随线程数增长**,但 P50 从 0.48 ms 涨到 4.14 ms,是 8.6 倍。
多出来的时间全是排队。原因是服务端是一个 Python 进程,GIL 把请求解析、JSON 编解码这些活串起来了,开线程只是让它们排得更整齐。
第 3 行到第 4 行:同样是 4 倍并发,换成**4 个服务进程**,QPS 从 1917 涨到 5233,是 **2.73 倍**。
因为每个进程有自己独立的解释器和 GIL,才能真正同时干活。
**这就是服务器端部署真正的扩展方式:加进程(或加实例),不是加线程。** gunicorn 的多 worker、K8s 的多副本,本质都是这件事。
线程在 Python 里只解决「同时在等 I/O」,解决不了「同时在算」。
还有两点要交代:这里测的是本机回环,没有网络时延;而且 4 个进程只拿到 2.73 倍而不是 4 倍,因为这台机器只有 8 个核,还要分给客户端和操作系统。真实的多副本部署同样拿不到线性加速。
第一组里 P50 涨了 8.6 倍而 QPS 没涨,说明请求在排队等同一把锁。 线程池越大,队伍越长,单个请求等得越久——这就是「并发不等于并行」的具体样子。 第二组里每个服务进程绑定不同的端口,客户端按端口均分。 没有负载均衡器,是手工轮询——真实部署里这一步由 Nginx 或者 K8s Service 做。 QPS 从 1917 到 5233,2.73 倍。 这个数每次跑会有点浮动,但方向很稳:多进程一定比多线程快,差距在一个数量级以内。 这个 Demo 测的是本机回环,没有网络时延。 真实服务器部署里,客户端到机房的往返往往比推理本身还大——那时候讨论 QPS 之前, 先看看用户能不能接受那个固定的延迟。
30 秒实验
- 把服务进程从 4 个加到 8 个,看 QPS 还能不能继续涨。
- 把客户端进程数改成 2(服务进程仍 4 个),看 QPS 掉多少。
- 想一想:为什么垂直扩展(加显存)的性价比通常比水平扩展差?
常见误区
- 「加线程就能提高并发」——Python 里线程受 GIL 限制,加线程只加排队。
- 「加机器就能线性提速」——4 个进程只拿到 2.73 倍,规划容量不能按 1 比 1 算。
- 「服务器部署没有延迟问题」——网络往返是硬下限,优化模型消不掉。
自测三问(点开对答案)
1. 水平扩展和垂直扩展的区别是什么? —— 水平是加机器数量,垂直是给单机加资源;水平理论无上限,垂直有物理上限。 2. 为什么 Python 里多线程不能提高计算吞吐? —— GIL 让同一时刻只有一个线程执行 Python 字节码,多线程只解决等 I/O。 3. 服务器端部署的三条代价是什么? —— 网络往返的固定延迟、数据要出机器、成本随调用量线性增长。服务器一直开着就要一直付钱。下一节讲怎么让成本只跟着调用走。
- ☐ (本节完,下一节 10.2.3 无服务器部署:管理简便、成本较低)