跳转至

服务架构、并发执行与资源边界

1. 从“一个请求”看服务内部到底发生了什么

后端服务接收到一个请求以后,并不会只做一次函数调用。一次请求通常会依次占用若干资源:网络连接把请求交给服务进程,运行时安排某个执行单元处理请求,业务逻辑可能读取缓存、数据库或文件,也可能做序列化、压缩、计算,再把结果写回网络。每一段都有自己的吞吐上限和等待队列,因此服务的真实性能取决于整条链路中最先达到饱和的资源。

可以把一条请求路径抽象成:接收请求 → 等待执行 → 执行业务代码 → 等待下游资源 → 返回结果。这里“等待执行”可能来自线程池或工作队列,“等待下游资源”可能来自数据库连接池、磁盘、远程 RPC 或其他受限资源。服务出现高延迟时,真正需要回答的问题是:请求时间花在了哪一段,是正在执行,还是排队等待某个资源。

这也是后端工程里“并发量”和“吞吐量”不能混为一谈的原因。并发量表示同一时刻系统里有多少个正在进行的请求,吞吐量表示单位时间完成多少个请求。一个系统可以同时挂着很多请求,但如果大部分请求都在等待数据库连接,它的实际吞吐量仍然可能很低。

2. 用 Little 定律理解并发、吞吐和延迟

稳定系统中有一个很实用的关系:平均在途请求数约等于吞吐率乘以平均停留时间,即 L = λ × W。其中 L 是系统中平均同时存在的请求数量,λ 是每秒完成或进入的请求数量,W 是一个请求在系统里平均停留的时间。

例如一个服务稳定处理 2000 req/s,平均响应时间为 50 ms,也就是 0.05 s,那么平均在途请求数大约为 2000 × 0.05 = 100。如果吞吐仍然是 2000 req/s,但平均响应时间升到 500 ms,平均在途请求数会升到约 1000。请求没有凭空消失,它们只是更长时间地占据连接、内存、执行槽位或队列。

这个关系在排查拥塞时非常重要。假设请求到达速度长期超过服务能力,队列会不断增长,延迟也会持续上升。平均 CPU 可能还没有立刻达到 100%,因为瓶颈可能在数据库连接、锁等待、磁盘或其他资源;只看 CPU 使用率很容易误判。

3. 并发、并行、阻塞和等待

并发描述多个任务在时间上重叠推进,并行描述多个任务在同一时刻真正使用不同 CPU 核执行。单核机器可以通过调度实现并发,但不能让两段 CPU 指令真正同时执行;多核机器才具备 CPU 层面的并行能力。

任务还可以按主要成本分成 CPU 密集型和 I/O 密集型。CPU 密集型任务的大部分时间都在执行计算,例如图片编码、压缩、加密、复杂解析;增加过多执行线程通常只会增加上下文切换和调度开销。I/O 密集型任务会花大量时间等待网络、数据库或磁盘,这时允许更多请求并发存在可以提高 CPU 利用率,但并发不能无限增长,因为下游连接数、内存和队列都会成为新的边界。

“异步”也不会让资源成本消失。把同步等待改成异步等待,可以减少某些线程长期占用,但请求状态仍然需要保存在内存中,下游仍然只能按自己的容量处理请求。如果一次性创建十万个异步任务同时访问一个只有 32 个连接的数据库,最终仍然会有大量任务等待这 32 个连接。

4. 为什么后端服务经常需要工作池

如果每来一个请求就无限创建一个新的执行线程,线程创建、销毁、栈空间、调度和上下文切换都会形成成本。工作池通过复用一组长期存在的执行单元,把“请求到达”与“真正开始执行”分离开。常见实现会维护工作线程、等待队列以及队列满时的处理策略。

一个典型工作池可以理解为三部分:正在执行任务的工作者、还没有得到工作者的等待队列,以及容量已满后的拒绝行为。队列的存在可以吸收短时间突发流量,但队列并不会提高系统长期处理能力。如果服务每秒最多处理 1000 个任务,而流量长期维持在 1500 个任务每秒,队列只是让前 500、1000、5000 个多出来的请求暂时等待,最终仍然会填满。

因此,工作池最危险的设计之一是无界队列。流量超过处理能力时,无界队列会让系统短时间看起来“没有拒绝请求”,代价是排队延迟持续增长,同时每个排队任务占用内存。最终系统可能先出现秒级甚至分钟级延迟,再因内存耗尽或垃圾回收压力失去响应。

有界队列会更早暴露过载状态。队列满以后,系统需要明确选择:立即失败、让调用线程承担执行、丢弃低优先级任务,或者向上游施加背压。不同实现会提供不同的拒绝策略,但设计目标相同:系统必须为超出容量的负载定义明确行为。

5. 工作线程数量为什么不能只看“越多越快”

对于纯 CPU 计算,工作线程数量通常与可用 CPU 核数量处于同一个量级,因为同一时刻真正能够执行的计算受到 CPU 核数限制。假设机器只有 8 个核,却让 500 个 CPU 密集型任务同时争抢 CPU,调度器需要频繁切换任务,缓存局部性也会变差,实际吞吐未必提高。

I/O 密集型任务可以容纳更多并发,因为很多任务会睡眠等待 I/O。但需要继续看下游资源。假设服务有 200 个工作线程,而数据库连接池只有 20 个连接,一旦 200 个请求同时执行 SQL,最多只有 20 个请求能真正操作数据库,其余请求都会等待连接。继续把工作线程从 200 增加到 1000,只会让等待数据库连接的请求更多。

所以工作池大小应该从整条资源链判断。CPU、数据库连接数、下游 QPS、内存预算、请求平均占用资源、任务执行时间和峰值流量都会影响合理并发度。工作池大小不能靠固定公式决定,应先判断任务属于哪类,并指出真正限制吞吐的资源在哪里。

6. 队列为什么会把小问题放大成 p99 问题

请求进入队列以后,最终响应时间由“排队时间 + 真正执行时间”组成。假设业务逻辑本身一直只需要 10 ms,但工作池饱和以后某些请求先排队 200 ms,那么客户端看到的响应时间就是约 210 ms。此时只在业务函数内部打耗时日志,可能仍然看到“处理只用了 10 ms”,却无法解释接口 p99 为什么已经超过 200 ms。

因此服务需要分别记录排队等待时间和实际执行时间。队列深度、队列等待时间、正在执行任务数、拒绝次数能够描述工作池是否进入饱和。只记录整体接口耗时会把几个阶段混在一起,定位效率很低。

长尾延迟还会因为少量慢任务被放大。如果一个工作池只有 10 个工作者,其中几个工作者长时间执行异常慢任务,可用执行槽位会快速减少,后续正常任务也开始排队。这种现象常见于数据库慢查询、磁盘阻塞、大对象序列化、外部调用超时等场景。

7. 数据库连接池是另一层并发闸门

数据库连接通常不会为每条 SQL 临时创建。建立数据库连接需要网络握手、认证和服务端状态维护,因此应用通常维护一个连接池,重复使用有限数量的连接。连接池本身就是一个资源边界:当所有连接都被占用,新请求必须等待连接归还。

假设数据库连接池有 32 个连接,一条 SQL 平均占用连接 20 ms,粗略估算单实例理论上最多能提供约 32 / 0.02 = 1600 次这种数据库操作每秒。真实能力还会受到数据库 CPU、锁、缓存命中、事务长度等因素影响,但这个估算已经能说明为什么连接池不能脱离 SQL 时间单独讨论。

连接池过小会让应用大量等待连接;连接池过大也可能把压力直接推给数据库。比如 10 个服务实例各自把连接池从 50 增加到 500,总连接数可能从 500 涨到 5000。数据库需要维护更多会话,同时更多 SQL 并发执行会增加 CPU、锁竞争和内存占用。因此“连接池不够就加连接”只能作为验证手段,不能自动视为最终优化。

连接池至少需要观察活动连接数、空闲连接数、等待连接的请求数、获取连接耗时和超时次数。如果接口 p99 上升,而 SQL 真正执行时间没有明显变化,但连接获取耗时显著升高,瓶颈就在应用侧的连接资源等待。

8. 每种资源都应该有容量边界

后端进程能同时使用的资源远不止 CPU 和数据库连接。每个连接占用文件描述符和内核 socket 状态,请求体和响应体占用内存,排队任务保存参数和上下文,大对象序列化会产生临时缓冲区,后台任务也会占用工作线程和连接池。如果这些资源没有容量限制,某一种高并发场景就可能把整个进程拖垮。

一个简单的内存估算也能说明问题。假设每个排队任务平均保留 100 KB 状态,无界队列积累 50,000 个任务,就需要约 5 GB 内存,而且还没有计算运行时对象开销、线程栈、缓存和业务数据。如果任务中保存的是 5 MB 图片或视频缓冲区,几千个并发请求就足以耗尽几十 GB 内存。

因此服务设计通常会给关键资源设置边界,例如最大并发请求数、工作池大小、队列长度、数据库连接数、单请求体积、单任务内存、文件描述符上限以及后台任务并发度。资源边界的目标是让系统在过载时以可预期方式退化,而不是一直接受任务直到整个进程失去服务能力。

9. 背压:让上游知道下游已经处理不过来

当下游已经饱和时,继续无限接收任务只会扩大队列。背压表示系统把“当前处理能力不足”反馈给上游,让上游减少产生速度、等待、失败或降级。最简单的背压机制就是有界队列加拒绝;更细粒度的实现还可以使用信号量限制某类高成本操作的并发数。

例如服务可以同时处理 1000 个轻量请求,但某个“生成高清视频缩略图”的操作一次需要大量 CPU 和内存。与其让这类任务共享一个无限工作池,可以单独给它设置 8 个并发槽位。第 9 个请求到达时进入有限队列或立即返回繁忙状态,避免它把其他普通接口的资源一并占满。

背压和限流关系密切,但这里关注的是服务内部资源边界。跨服务的限流算法、重试、熔断和流量治理会在 BE-02 进一步展开。

10. 资源隔离:避免一种任务拖垮整个服务

如果所有请求共享同一个工作池和同一个队列,那么少量高耗时任务就可能占满全部工作者,使原本很快的接口一起超时。资源隔离的基本做法是把不同成本、优先级或风险的任务拆到不同资源池,让它们各自有容量上限。

例如图片转码和普通元数据查询都在同一个服务中。元数据查询只需要几毫秒和一次数据库读取,而转码一次可能占用几百毫秒 CPU。如果两类任务使用同一个 32 线程工作池,突发转码请求可能占满全部线程,使简单查询也排队。把转码限制在单独的 8 个工作者,查询保留独立执行能力,就能缩小故障影响范围。

资源隔离也可以发生在数据库层面。后台批处理与在线请求如果共用同一连接池,批处理一次占满全部连接,就会让在线请求等待。为不同类型工作设置独立连接池或明确并发配额,可以减少这种相互干扰。

11. 同步并行执行如何计算真实收益

一个请求内部经常需要完成多个相互独立的步骤。假设三个调用分别耗时 30 ms、50 ms、70 ms,串行执行大约需要 150 ms;如果三者没有依赖并且有足够资源并行执行,请求关键路径接近最慢的 70 ms,再加调度和聚合开销。

这种优化仍然受到资源约束。如果每个请求都并行启动 10 个数据库查询,接口延迟可能下降,但数据库瞬时并发会扩大十倍。服务吞吐不一定提高,甚至可能因为连接池排队和数据库竞争变差。并行化应该同时评估单请求延迟和系统总资源消耗。

并行子任务还需要定义失败语义。例如三个结果都必须存在时,任意一个失败就可能使整个请求失败;如果某个结果只是推荐信息,可以允许部分失败。这里的关键在于业务语义,而不只是“把三个任务都丢进线程池”。

12. 服务过载时应该观察哪些证据

排查高延迟时,建议先把请求时间拆成几个阶段:入口等待、工作池等待、业务执行、数据库连接等待、SQL 执行、其他外部资源等待。每一段对应不同证据。工作池看活跃工作者、队列深度、排队耗时和拒绝次数;数据库连接池看活跃连接、等待者、获取连接耗时和超时;进程看 CPU、内存、垃圾回收、文件描述符、线程或协程数量;数据库本身再看 SQL 延迟、锁等待和负载。

只看平均值很容易漏掉尾部问题。平均队列等待可能只有 5 ms,但 p99 已经达到 300 ms;平均 CPU 只有 55%,某一个核心可能长期满载,或者真正瓶颈在数据库连接。线上排障应优先寻找与用户 p99 同时间变化的资源饱和指标。

还要区分“资源利用率高”和“资源已经饱和”。CPU 80% 可能仍然能够稳定服务,而数据库连接池 32/32 长期全部占满并出现大量等待者,通常已经明确进入饱和状态。队列持续增长也是强烈的饱和信号,因为它说明到达速度长期超过完成速度。

13. 一个完整的资源瓶颈推演

假设一台服务实例有 8 核 CPU,工作池 200 个执行槽位,数据库连接池 32 个连接。接口每个请求先做约 2 ms CPU 计算,然后执行一条平均 25 ms 的 SQL,再做约 1 ms CPU 处理。此时请求的大部分时间都在占用数据库连接,单实例的数据库阶段粗略上限约为 32 / 0.025 = 1280 req/s。

如果入口突然稳定到 2000 req/s,200 个工作者不会让数据库阶段变成 2000 req/s。大部分请求会等待 32 个数据库连接,工作者或协程不断堆积,接口延迟开始上升。如果工作池前面还有一个很大的任务队列,系统可以暂时继续接收请求,于是用户首先观察到 p99 持续恶化,随后队列和内存一起上升。

这时把工作池从 200 改成 500 通常没有帮助,因为真正的瓶颈仍然是数据库阶段。把数据库连接池直接从 32 改成 128也需要谨慎,数据库能否承受更大的并发必须通过自身 CPU、锁等待和 SQL 延迟判断。更有效的处理可能包括降低 SQL 占用时间、减少每个请求的数据库次数、缓存热点数据、降低入口并发或横向扩展数据库读能力,具体选择取决于证据。

这个推演体现了 BE-01 最核心的分析方式:先找容量最小的阶段,再分析请求在这个阶段之前如何排队,最后决定优化服务时间、资源数量还是入口并发。不要先从“加线程、加连接、加机器”开始猜。

14. 服务关闭时资源也需要按顺序释放

服务重启或发布时,如果进程收到退出信号后立即结束,已经接收的请求可能被中断,正在写数据库的事务可能失败,后台任务也可能只完成一半。比较稳妥的关闭过程会先停止接收新任务,再等待在途任务完成到合理边界,最后关闭工作池、数据库连接和其他资源。

这里同样需要容量和时间边界。服务不能无限等待某一个卡住的请求,因此通常会设置一个排空期限,到期后终止剩余工作。真正的发布与流量摘除还会涉及负载均衡和服务发现,这部分在后续服务通信与治理章节再展开。