如何衡量一个接口的性能

  • 单次请求在开发机上的执行时间很短;
  • 平均响应时间很低;
  • 在某个压测并发下仍然能够维持较高吞吐量;
  • 大多数请求都很快,只有极少数请求较慢;
  • 在流量增长以后,系统仍然没有明显的超时和错误。

这些说法描述的是不同的性质。一个接口可以在低并发时只需要 10 ms,却在高并发时因为线程池排队而变成 1 s;也可以平均响应时间只有 20 ms,但第 99 百分位延迟达到 2 s。前者说明接口的单次执行成本可能不高,后者则说明尾部请求存在明显问题。它们都不能简单地用“快”或“慢”概括。

因此,接口性能不是一个单一指标,而是接口在特定负载下对时间、资源和稳定性的综合表现。

单次执行时间不等于接口延迟

一次请求从进入系统到返回客户端,通常会经历多个阶段:

1
2
3
4
5
6
7
8
9
请求进入
-> 接入层处理
-> 等待线程或协程调度
-> 参数解析与鉴权
-> 本地业务逻辑
-> 调用缓存、数据库或其他服务
-> 组装响应
-> 序列化
-> 网络返回

开发者在本地测量的函数执行时间,往往只覆盖了其中很小的一段。即使本地业务函数只执行了几毫秒,请求仍然可能因为以下原因变慢:

  • 在线程池或连接池中等待可用资源;
  • 等待锁、信号量或其他并发控制结构;
  • 等待下游服务返回;
  • 等待数据库连接;
  • 等待网络数据传输;
  • 等待垃圾回收或运行时调度;
  • 在高并发下发生请求排队。

可以用一个近似模型表示请求延迟:

$$
T_{request}
= T_{admission} + T_{queue} + T_{local} + T_{dependency} + T_{response}
$$

其中:

  • $T_{admission}$ 表示网关、限流和接入层处理请求的时间;
  • $T_{queue}$ 表示等待线程池、连接池、锁或其他资源的时间;
  • $T_{local}$ 表示当前服务自身执行逻辑的时间;
  • $T_{dependency}$ 表示访问缓存、数据库和其他服务的时间;
  • $T_{response}$ 表示响应组装、序列化和网络发送的时间。

这个公式不是对真实执行过程的精确模拟。在并行调用下,多个下游请求可能同时执行,$T_{dependency}$ 更接近最慢分支的耗时,而不是所有分支耗时的简单相加。这个模型的价值在于提醒我们:接口延迟不仅由业务代码的执行时间决定,也由请求在整个路径上的等待时间决定。

用P95+上游重试来综合评价延迟

接口延迟通常需要结合分布来观察,而不能只看平均值。常见的延迟分位数包括:

  • p50:一半请求的延迟不超过这个值,可以反映典型请求;
  • p95:95% 的请求不超过这个值,可以反映大多数请求的体验;
  • p99:99% 的请求不超过这个值,用来观察尾部延迟;
  • p999:99.9% 的请求不超过这个值,常用于对稳定性要求很高的场景。

假设一个接口在一分钟内的统计结果如下:

1
2
3
4
平均延迟:35 ms
p50:18 ms
p95:60 ms
p99:1800 ms

如果只观察平均延迟,这个接口看起来并不慢。但 p99 表明,仍然有一部分请求需要等待接近两秒。对于单个用户来说,这部分请求可能正好对应登录、支付或下单等关键操作;对于一个有大量请求的系统来说,尾部请求的数量也并不少。

尾延迟通常不是简单的偶然噪声。它可能来自:

  • 某个下游服务的偶发慢请求;
  • 锁竞争造成的排队;
  • 连接池耗尽;
  • 数据库慢查询;
  • 垃圾回收暂停;
  • 缓存失效引起的集中回源;
  • 重试导致的额外等待;
  • 突发流量造成的队列积压。

更重要的是,分布式系统中的尾延迟具有传递和放大效应。一个聚合接口如果要调用多个下游服务,那么只要其中一个分支出现慢请求,聚合接口就可能被拖慢。若上游再对聚合接口设置较短超时,重试又会把额外流量施加到原本已经变慢的链路上。

并发数、吞吐量与响应时间

并发数、吞吐量和响应时间经常被混为一谈。它们描述的是不同的概念:

  • 并发数:某个时刻正在处理或等待处理的请求数量;
  • 吞吐量:单位时间内完成的请求数量;
  • 响应时间:一个请求从进入到完成经历的时间。

在稳定状态下,可以使用 Little’s Law 对三者进行近似描述:

$$
L = \lambda W
$$

其中:

  • $L$ 是系统中的平均请求数;
  • $\lambda$ 是平均吞吐量;
  • $W$ 是平均响应时间。

例如,当系统以每秒 500 个请求的速率运行,平均响应时间为 100 ms 时,系统中平均存在的请求数约为:

$$
L = 500 \times 0.1 = 50
$$

如果平均响应时间因为下游变慢而上升到 1 s,而吞吐量暂时仍然保持每秒 500 个请求,那么系统中的平均请求数就会增加到约 500 个。更多请求意味着更多线程、连接、内存和队列占用,系统也更容易继续恶化。

这个关系可以解释一种常见的性能现象:系统刚开始只是下游变慢,但随后接口自身的线程池、连接池和内存也开始耗尽,最终演变成整个服务的超时雪崩。

资源利用率也属于性能的一部分

接口性能不能只看请求耗时,还需要关注完成这些请求所消耗的资源。例如:

  • 每个请求消耗了多少 CPU 时间;
  • 每个请求产生了多少内存分配;
  • 是否频繁触发垃圾回收;
  • 是否消耗过多数据库连接;
  • 是否产生过大的网络流量;
  • 是否需要大量线程或协程维持并发;
  • 是否存在大量锁等待。

两个接口可能拥有相同的 p99 延迟,但资源效率完全不同。接口 A 使用 20% 的 CPU 完成每秒 1000 个请求,接口 B 使用 90% 的 CPU 完成同样的请求。在当前流量下,两者的用户体验可能相同,但接口 B 的容量余量更小,也更容易在流量增长或机器抖动时进入不稳定状态。

因此,性能分析至少要同时回答三个问题:

  1. 请求需要等待多久?
  2. 系统每秒可以稳定完成多少请求?
  3. 为了完成这些请求,系统消耗了多少资源?

性能目标应当由 SLO 表达

“接口尽量快”无法直接指导实现和测试。一个可以执行的性能目标应该包含负载条件、延迟目标、稳定性目标和资源边界,例如:

1
2
3
4
5
6
7
在正常请求比例和每秒 2000 次请求的负载下:

p95 延迟小于 100 ms;
p99 延迟小于 300 ms;
错误率小于 0.1%;
服务实例 CPU 使用率低于 70%;
连接池和线程池不存在持续增长的等待队列。

不同接口的目标也不应该完全相同。健康检查接口、搜索接口、支付接口和异步任务提交接口的用户预期、业务风险和资源模型不同。性能目标需要服务于业务场景,而不是追求某个脱离场景的统一数字。

从这个角度看,接口性能优化的目标并不是单纯地缩短某个函数的执行时间,而是:

在给定负载模型和 SLO 的前提下,降低关键路径上的执行成本与等待成本,同时保留足够的容量余量和故障恢复能力。

接口类型:计算型、转发型、内存型与混合型

一个接口通常可以抽象为:

1
2
3
4
5
输入请求
-> 读取必要的数据
-> 执行若干处理步骤
-> 访问外部依赖
-> 生成响应

如果主要成本来自本地计算,可以称为计算密集型接口;如果服务主要负责等待和编排其他服务,可以称为转发型接口;如果主要成本来自大规模内存访问、对象分配和数据整理,可以称为内存密集型接口这里的“主要成本”需要结合实际负载判断。同一个接口在小请求下可能是计算型,在大批量请求下又可能表现为内存型;在下游服务变慢以后,原本的本地计算成本也可能被网络等待完全掩盖。

计算密集型接口:派单候选车辆评分

在自动驾驶出租车或网约车系统中,可以有一个类似下面的接口:

1
POST /dispatch/match

它接收一个待调度需求和当前可参与调度的车辆集合,返回最适合执行该需求的车辆。

接口的运行逻辑可以简化为:

1
2
3
4
5
6
7
接收乘客订单
-> 获取候选车辆集合
-> 检查车辆硬约束
-> 计算每辆车的接驾距离
-> 计算等待时长、区域、车型等评分
-> 对候选车辆排序或执行 Top-K 选择
-> 返回得分最高的车辆

假设当前有 $N$ 辆候选车辆,每辆车需要计算 $K$ 个评分因子,那么本地计算量大致可以表示为:

$$
T_{compute} \approx N \times (T_{filter} + K \times T_{score}) + T_{select}
$$

当候选车辆数量增加时,接口的主要工作不是等待某个远程服务,而是重复执行大量本地操作:

  • 读取车辆的位置、状态和能力字段;
  • 判断车辆是否满足硬约束;
  • 计算车辆与乘客之间的距离;
  • 根据多个业务因子计算分数;
  • 对候选结果进行排序或维护 Top-K 集合。

如果评分逻辑只是简单的加减乘除,那么单次计算可能并不昂贵。但当候选集合从 100 辆增长到 10000 辆时,计算次数会大幅增加;如果每辆车还要经过复杂的规则匹配、路线估算或多个条件分支,CPU 就会成为主要瓶颈。

这类接口通常具有几个明显特征:

  1. 延迟主要来自本地执行

在上下游数据已经准备好,或者候选车辆状态已经驻留在本地内存的情况下,接口可以在本地完成大部分工作。请求延迟主要由以下因素决定:

1
2
3
4
5
候选数量
每个候选对象的计算复杂度
评分因子数量
排序或选择算法
CPU 单核性能

因此,链路追踪中可能看不到明显的远程调用耗时,但 CPU profile 会显示某些规则计算、距离计算或排序函数占据大量时间。

  1. 并发上升后首先出现 CPU 饱和

假设单个请求平均需要处理 5000 个候选车辆。当并发数增加时,每个请求都会重复消耗 CPU。系统可能出现以下变化:

1
2
3
4
5
6
7
8
9
10
11
并发较低:
CPU 有余量,p50 和 p99 接近

并发上升:
CPU 使用率增加,运行队列变长

CPU 接近饱和:
线程或协程获得 CPU 的时间变少,p99 开始快速上升

超过容量:
请求排队,超时和错误增加

这里有一个容易误判的地方:当 CPU 已经饱和时,单个请求的计算逻辑并没有变复杂,但接口延迟仍然会增加。这部分增加的时间属于调度和排队成本,而不是业务计算成本。

转发型接口:订单详情聚合

一个常见的转发型接口是订单详情聚合接口:

1
GET /orders/{order_id}/detail

用户打开订单详情页时,API 服务本身可能并不保存全部信息,而是需要组合多个服务的数据:

1
2
3
4
5
API 服务
-> 订单服务:订单状态、商品和金额
-> 支付服务:支付状态、退款状态
-> 配送服务:骑手、路线和预计送达时间
-> 营销服务:优惠券和积分抵扣

接口的运行逻辑可能是:

1
2
3
4
5
6
7
接收订单 ID
-> 校验用户是否有权访问订单
-> 调用订单服务
-> 调用支付、配送和营销服务
-> 检查各个响应是否成功
-> 将多个响应转换为统一的前端结构
-> 序列化并返回

这类接口的本地计算通常很少。API 服务主要做的是请求转发、结果等待、错误处理和响应拼装。因此它的延迟模型更接近:

1
2
3
4
5
总延迟
= 本地处理时间
+ 下游网络和排队时间
+ 下游执行时间
+ 响应组装与序列化时间

如果这些下游调用按照顺序执行:

1
订单服务 -> 支付服务 -> 配送服务 -> 营销服务

那么下游延迟大致会累加:

$$
T_{dependency}
\approx T_{order}

  • T_{payment}
  • T_{delivery}
  • T_{marketing}
    $$

假设四个服务平均分别需要 30 ms、40 ms、50 ms 和 20 ms,API 服务即使本地只需要 5 ms,也很难把总延迟控制在 100 ms 以内。

但如果支付、配送和营销数据彼此独立,就可以在获取订单基础信息后并行发起请求:

1
2
3
4
5
获取订单
-> 并行调用支付服务
-> 并行调用配送服务
-> 并行调用营销服务
-> 等待结果并组装

此时依赖调用的理想耗时更接近:

$$
T_{dependency}
\approx T_{order}

  • \max(T_{payment}, T_{delivery}, T_{marketing})
    $$

并行化可以缩短关键路径,但它不会消除下游服务的成本,还会增加并发连接数、下游瞬时压力和错误处理复杂度。因此并行调用必须配合超时、并发上限和降级策略使用。

转发型接口的一个重要特征是:它的尾延迟常常由下游服务决定。

例如,配送服务平时 p99 为 100 ms,但在部分时间段由于数据库慢查询,p99 上升到 1 s。订单详情接口可能只增加了很少的本地代码,却会同步出现尾延迟上升。

如果接口同时调用多个下游服务,那么只要其中一个关键分支变慢,整体请求就可能被拖慢。下游服务越多,出现至少一个慢分支的概率越高。对于非关键数据,还需要考虑:

  • 是否允许超时后返回部分结果;
  • 是否可以使用缓存数据;
  • 是否可以异步补充;
  • 是否可以直接降级为空;
  • 是否必须等待所有依赖完成。

这决定了一个聚合接口的性能上限,也决定了它在依赖异常时的退化方式。

在转发型接口中,API 服务自身 CPU 很低,并不意味着它没有容量问题。常见瓶颈包括:

  • HTTP 客户端连接池太小,等待可用连接;
  • 下游响应变慢,连接长期占用;
  • 线程池被阻塞请求占满;
  • 超时设置过长,导致大量请求同时挂起;
  • 重试机制扩大了下游请求量;
  • 一个入口请求产生多个下游请求,形成扇出。

例如,一个订单详情请求调用 4 个下游服务,入口流量为 1000 RPS,理论上就可能产生约 4000 次下游调用。如果某个下游失败后还进行两次重试,下游实际承受的请求量会继续增加。

因此,转发型接口需要关注的不仅是自身 RPS,还包括:

1
2
3
4
5
6
入口请求量
下游调用量
平均扇出数
连接池等待
下游响应时间
超时率和重试率

内存密集型接口:批量候选结果过滤

可以考虑一个内容推荐或搜索系统中的候选过滤接口:

1
POST /recommend/filter

它接收用户上下文和一批候选内容,对候选内容执行去重、黑名单过滤、地域过滤、标签匹配、排序和结果组装。

为了降低延迟,候选内容的部分特征和规则可能已经加载到服务实例的本地内存中。接口的运行逻辑大致如下:

1
2
3
4
5
6
7
8
接收候选 ID 列表
-> 从本地缓存读取候选对象
-> 查找用户黑名单和已曝光集合
-> 过滤不符合条件的候选
-> 合并多个来源的候选集合
-> 去重
-> 按多个字段排序
-> 创建响应对象并序列化

这个接口不一定执行复杂的数学计算,也不一定频繁访问远程服务。它的主要成本可能来自大量数据在内存中的读取、比较、复制和重新组织。

假设一次请求包含 10000 个候选内容,每个候选对象包含:

1
2
3
4
5
6
内容 ID
作者 ID
标签集合
地域信息
时间戳
多个推荐特征

请求处理时可能同时存在多份数据:

1
2
3
4
5
6
7
原始候选列表
本地缓存对象
过滤后的列表
去重用的哈希集合
排序用的临时结构
最终响应对象
序列化缓冲区

即使每个处理步骤的算法复杂度看起来合理,数据在不同容器和对象之间反复复制,也可能产生很高的内存流量。接口性能的限制因素此时不一定是 CPU 算术运算,而是:

  • 工作集是否能够有效地留在 CPU Cache 中;
  • 内存访问是否连续;
  • 是否需要频繁访问指针指向的离散对象;
  • 是否创建了大量临时对象;
  • 是否发生了多次列表复制;
  • 响应缓冲区是否过大。

在使用带垃圾回收运行时的语言中,批量结果处理通常会产生大量短生命周期对象:

1
2
3
4
每个候选对象的转换对象
每个过滤结果的包装对象
去重和排序产生的临时结构
最终响应的嵌套对象

当分配速率很高时,垃圾回收线程需要更频繁地工作。接口可能表现为:

1
2
3
4
5
6
7
8
9
10
11
正常请求:
延迟稳定,CPU 使用率中等

流量增加:
对象分配速率升高,GC 更频繁

内存压力增大:
部分请求受到 GC 或分配暂停影响

最终表现:
p50 变化不大,但 p99 和 p999 明显抖动

这类问题容易被误判为“CPU 不高,所以不是性能问题”。实际上,CPU 总使用率不高可能只是因为请求大量时间消耗在内存等待、垃圾回收或分配器上。

混合型接口:创建订单

真实业务接口通常不是单一类型。创建订单接口就是一个典型例子:

1
POST /orders

它可能经历以下步骤:

1
2
3
4
5
6
7
8
解析请求和鉴权
-> 校验商品和用户状态
-> 计算价格、优惠和运费
-> 查询库存
-> 创建订单
-> 发起支付或生成支付单
-> 写入消息队列
-> 组装响应

其中同时存在多种成本:

  • 价格、优惠和运费计算属于计算型;
  • 查询库存、支付和消息队列属于转发或 I/O 型;
  • 订单对象、优惠明细和响应结构的创建属于内存处理;
  • 库存扣减和订单写入可能受到锁、事务和连接池影响。

因此,接口的主导类型会随着场景改变:

1
2
3
4
5
6
7
8
9
10
11
商品规则复杂:
计算成本占主导

库存服务变慢:
下游等待占主导

批量优惠明细很大:
对象创建和序列化占主导

高并发抢购:
锁竞争、队列和数据库连接成为主导
1
2
3
在当前负载下,
请求时间主要花在计算、等待、内存访问,
还是并发资源竞争上?

分类的作用是指导观测和实验

接口分类最终是为了帮助我们选择正确的观测手段和验证方式:

接口类型 优先观察的指标 常见实验
计算密集型 CPU、单核热点、函数耗时、运行队列 减少计算量、优化算法、改变并行度
转发型 下游延迟、连接池、线程池、扇出、重试 调整调用拓扑、并行调用、设置超时和降级
内存密集型 内存分配、GC、工作集、响应大小、Cache miss 减少复制、压缩对象、调整数据结构和分页
混合型 分段耗时、资源利用率、锁等待、依赖状态 拆分关键路径,逐段验证主导瓶颈

分类本身不是结论,而是提出问题的方式。只有当某一类资源的变化能够解释接口延迟、吞吐量或稳定性的变化时,分类才真正有意义。

接口性能的影响因素与调优策略

接口性能问题很少由某一行代码单独造成。一次请求经过多个处理阶段,任何位于关键路径上的执行或等待,都可能成为最终延迟的一部分;而当流量增加以后,原本不明显的等待还可能因为排队和资源竞争被放大。

因此,调优的第一步不是选择技术,而是回答:

1
2
3
4
请求实际花费了多少时间?
这些时间是在执行,还是在等待?
哪个资源限制了接口的吞吐量?
这个资源是否位于当前请求的关键路径上?

如果一个接口总耗时为:

1
2
3
4
5
本地计算:20 ms
锁等待:10 ms
数据库连接池等待:40 ms
数据库执行:80 ms
响应序列化:10 ms

那么优先优化本地计算,即使把它从 20 ms 降到 5 ms,整体延迟也只减少 15 ms;而如果能够消除连接池等待,收益可能更大。

关键路径决定接口延迟

所谓关键路径,是指一个请求从进入到返回必须经过的处理链路。关键路径上的步骤会直接影响响应时间,非关键路径上的工作则可能在响应返回后继续执行。

一个典型的同步接口可以表示为:

1
2
3
4
5
6
7
请求接入
-> 鉴权
-> 查询订单
-> 查询库存
-> 计算价格
-> 写入订单
-> 返回响应

如果这些步骤全部串行执行,接口延迟近似为各个步骤耗时之和:

$$
T_{request}
\approx \sum_{i=1}^{n} T_i
$$

但并不是所有步骤都必须串行。例如,用户信息、优惠信息和推荐信息之间没有依赖关系时,可以并行获取:

1
2
3
4
5
鉴权
-> 并行查询用户信息
-> 并行查询优惠信息
-> 并行查询推荐信息
-> 组装响应

这时并行部分对关键路径的贡献更接近最慢分支:

$$
T_{parallel}
\approx \max(T_1, T_2, \cdots, T_n)
$$

调优策略

关键路径的优化通常有以下顺序:

  1. 删除不必要的步骤;
  2. 合并重复查询;
  3. 将互相独立的操作并行化;
  4. 将不影响当前响应的工作异步化;
  5. 对非关键数据设置降级和部分返回;
  6. 缩短关键路径中最慢的步骤。

这里需要区分“并行化”和“异步化”:

  • 并行化仍然需要在当前请求返回前等待结果,只是让多个步骤重叠执行;
  • 异步化通常意味着当前请求不再等待某项工作完成,把工作转移到消息队列或后台任务。

如果客户端必须等待异步任务的结果,那么这项工作并没有真正离开关键路径,只是改变了实现方式。异步化只有在业务允许“先返回、后完成”时,才会直接降低接口延迟。

上下游链路影响端到端性能

服务端接口通常不是孤立运行的。一个请求可能经过:

1
2
3
4
5
6
客户端
-> DNS、网关和负载均衡
-> 当前服务
-> 缓存、数据库和其他微服务
-> 当前服务
-> 网关和客户端

任何一段链路变慢,都可能反映到最终接口的延迟上。

下游调用的串行与并行

转发型和聚合型接口尤其容易受到调用拓扑影响。假设一个接口依次调用三个服务:

1
A -> B -> C -> D

那么 B、C、D 的网络往返和执行时间会累加。如果 B、C、D 之间不存在数据依赖,可以改成:

1
2
3
4
        -> B
A -> C
-> D
-> 汇总

但并行调用也有代价:

  • 当前服务需要同时持有更多连接;
  • 下游服务会承受更大的瞬时并发;
  • 任意一个关键分支变慢,都可能拖慢整体响应;
  • 错误处理和部分返回逻辑更加复杂。

因此,并行化不应该只根据代码结构决定,还需要结合下游容量、连接池大小和接口 SLO 进行验证。

扇出会放大请求量

如果一个入口请求平均调用 $F$ 个下游服务,那么入口流量为 $R$ 时,下游调用量大致为:

$$
R_{downstream} \approx R \times F
$$

假设接口流量为 2000 RPS,平均扇出为 5,那么下游链路需要承受约 10000 RPS 的调用。若其中部分调用还会重试,下游实际接收到的请求量会更高。

扇出不仅增加流量,也增加失败概率。一个接口依赖的服务越多,出现至少一个慢分支或失败分支的可能性越高。因此,聚合接口通常需要区分:

1
2
强依赖:没有结果就无法完成当前请求
弱依赖:超时后可以使用缓存、默认值或部分结果

超时和重试会改变系统负载

超时控制应该沿着请求链路传递,而不是每一层都设置一个互不相关的固定超时。

例如,入口请求只剩 300 ms 的剩余预算,但某个下游客户端仍然设置 1 s 超时,那么当前服务可能会继续等待一个已经不可能及时完成的请求。

重试也不是简单的可靠性增强。假设一个请求第一次调用失败后重试两次,则一次入口请求最多可能产生三次下游调用。在高并发和下游故障时,重试会形成额外压力:

1
2
3
4
5
下游变慢
-> 当前服务超时
-> 当前服务重试
-> 下游收到更多请求
-> 下游进一步变慢

调优策略

上下游链路的调优重点包括:

  • 为每个下游调用设置明确的超时预算;
  • 传递请求的 deadline,避免无效等待;
  • 只对幂等操作进行有限重试;
  • 使用重试次数上限和重试预算;
  • 对弱依赖支持缓存、默认值和部分返回;
  • 限制单个入口请求的最大扇出;
  • 合并可以批量处理的下游请求;
  • 对故障依赖使用熔断、隔离和降级;
  • 通过链路追踪确认真正的慢调用。

需要注意,超时、熔断和降级主要改善的是故障状态下的尾延迟和资源保护,并不一定降低正常状态下的平均延迟。

锁和并发控制决定等待成本

锁的作用是保护共享状态,但锁本身也会把并发请求转换成排队请求。

考虑一个库存扣减接口:

1
POST /inventory/deduct

它需要保证同一个商品的库存不会被并发请求扣成负数。一个简单实现可能是:

1
2
3
4
5
6
获取全局锁
-> 查询库存
-> 判断库存是否足够
-> 扣减库存
-> 写入结果
释放全局锁

如果所有商品共用一把全局锁,那么商品 A 的请求会阻塞商品 B 的请求。即使它们操作的是完全不同的数据,也必须排队。

锁竞争的几个来源

锁导致的性能问题通常来自以下因素:

  • 锁粒度过大,保护了不必要的数据;
  • 临界区过长,把数据库、网络调用放在锁内;
  • 热点 Key 集中访问同一把锁;
  • 锁的获取顺序不一致,产生死锁或额外等待;
  • 读操作也使用互斥锁,导致读之间互相阻塞;
  • 锁竞争被线程池放大,等待线程占满工作线程。

尤其需要避免在临界区内执行远程调用:

1
2
3
4
5
获取锁
-> 调用数据库
-> 调用库存服务
-> 调用消息服务
释放锁

网络延迟的不确定性会直接转化为锁持有时间。一次偶发的下游慢请求,可能使大量请求同时等待这把锁。

调优策略

锁相关的调优通常包括:

  1. 缩小锁保护的数据范围;
  2. 缩短临界区,只保留必要的状态检查和修改;
  3. 将网络调用和耗时计算移出临界区;
  4. 将全局锁拆成分片锁或按 Key 加锁;
  5. 对读多写少的数据考虑读写锁,但必须通过压测确认收益;
  6. 使用原子操作、乐观并发控制或版本号更新;
  7. 对热点 Key 做排队、合并请求或限流;
  8. 记录锁等待时间,而不仅仅是锁持有时间。

“无锁”也不是性能优化的同义词。无锁结构通常会增加实现复杂度、重试次数和内存回收要求。只有当锁竞争确实是主要瓶颈时,才值得考虑更复杂的并发控制方案。

线程池和连接池会把资源限制暴露为排队

很多服务使用线程池处理请求,使用连接池访问数据库或其他服务。池的大小决定了系统可以同时处理多少工作,但池越大并不代表接口一定越快。

当池太小时,请求会等待资源:

1
2
3
4
请求进入
-> 等待线程
-> 等待数据库连接
-> 执行业务

当池太大时,系统可能出现另一种问题:

1
2
3
4
5
更多请求同时执行
-> CPU 上下文切换增加
-> 数据库连接数增加
-> 下游排队加重
-> 内存和队列占用增长

线程池和连接池实际上构成了背压边界。边界过早会降低吞吐量,边界过晚则可能把过载传递到更深的依赖链路。

调优策略

  • 根据请求服务时间、目标吞吐量和下游容量估算池大小;
  • 使用有界队列,避免请求无限积压;
  • 为不同类型的任务配置隔离的线程池;
  • 区分 CPU 型任务和阻塞 I/O 型任务;
  • 为连接池设置最大连接数、获取超时和空闲回收策略;
  • 监控池中活跃数量、空闲数量和等待时间;
  • 对过载请求尽早拒绝,而不是让它们长期占用资源;
  • 避免通过无限增大线程数来掩盖下游变慢。

判断池大小是否合理时,不能只看“使用率是否达到 100%”。更关键的是观察等待时间、队列长度、请求延迟和下游是否被压垮。

数据访问影响执行成本和等待成本

数据库、缓存和消息系统通常同时影响两类成本:

  • 获取连接、等待网络和等待资源属于等待成本;
  • 查询、索引扫描、结果传输和数据转换属于执行成本。

一个常见的接口问题是 N+1 查询:

1
2
3
先查询 100 个订单
-> 再为每个订单查询一次用户信息
-> 总共发起 101 次查询

单次查询可能只需要几毫秒,但大量查询会增加网络往返、连接占用和数据库调度开销。

调优策略

  • 避免 N+1 查询,使用批量查询或合理的 JOIN;
  • 只读取接口实际需要的字段;
  • 控制分页大小,避免一次返回过多数据;
  • 对重复读取的数据使用合适的缓存;
  • 合并短小且可以批量执行的请求;
  • 对慢查询记录完整参数分布和执行计划;
  • 区分数据库执行时间和连接池等待时间;
  • 避免在事务中执行无关的远程调用;
  • 对写入操作评估批量写入和异步化的可能性。

批量处理也不是越大越好。批次过小会产生大量往返,批次过大则可能增加锁持有时间、内存占用和单次失败成本。批量大小需要在吞吐量、延迟和资源使用之间找到平衡。

缓存改变的是访问路径,而不是消除成本

缓存可以把原本的访问路径:

1
接口 -> 数据库

变成:

1
2
接口 -> 缓存
缓存未命中 -> 数据库 -> 回填缓存

缓存命中时通常能够降低访问延迟,但缓存本身仍然有网络、序列化、内存和一致性成本。

缓存引入的典型问题包括:

  • 命中率不足;
  • 热点 Key 访问集中;
  • 缓存过期导致大量请求同时回源;
  • 缓存击穿、穿透和雪崩;
  • 缓存数据过期或不一致;
  • 大对象缓存造成内存压力和网络开销。

调优策略

  • 先确认缓存命中率和命中、未命中两条路径的耗时;
  • 根据数据变化频率设置过期时间;
  • 对大量同时过期的 Key 增加时间抖动;
  • 对热点数据预热或使用本地缓存;
  • 对同一个未命中 Key 合并并发回源请求;
  • 对不存在的数据设置合理的空值缓存;
  • 限制缓存对象大小,避免把数据库问题转化为内存问题;
  • 明确哪些数据允许短暂不一致。

缓存优化最容易出现的误区是只关注命中时延,而忽略未命中、回源和失效时的尾延迟。生产环境中的 p99 往往正是由这些非典型路径决定的。

序列化和数据规模影响响应阶段

接口的业务逻辑执行完成后,还需要将内部对象转换成 JSON、Protobuf 或其他格式,再通过网络发送给调用方。

响应阶段的成本与以下因素有关:

1
2
3
4
5
6
7
返回字段数量
嵌套层级
列表元素数量
字符串和二进制数据大小
对象转换次数
压缩和加密
网络带宽

例如,一个订单列表接口把每个订单关联的全部商品、支付记录和物流轨迹都返回,即使数据库查询本身不慢,响应对象创建、序列化和网络传输也可能成为主要成本。

调优策略

  • 只返回当前场景需要的字段;
  • 使用分页、游标或数量上限控制结果规模;
  • 避免同一份数据在多个对象模型之间重复转换;
  • 复用合理的缓冲区,减少频繁分配;
  • 对大响应评估压缩收益和 CPU 成本;
  • 区分网络发送时间和服务端序列化时间;
  • 对不需要立即返回的数据改为异步获取;
  • 只有在协议和数据模型都适合时,才考虑更换序列化格式。

更换 JSON 为二进制协议可能降低数据体积,但不会自动解决数据库慢查询、锁竞争或下游等待问题。协议优化只能改善它覆盖的那一段成本。

运行时和部署资源决定容量上限

服务最终运行在具体的 CPU、内存、容器配额和网络环境中。代码相同,在不同资源限制下可能表现不同。

常见影响因素包括:

  • CPU 配额导致实例被节流;
  • 内存不足触发频繁回收或 OOM;
  • 多个服务共享机器造成资源争用;
  • 实例数量不足导致单实例负载过高;
  • 实例之间负载不均衡;
  • 冷启动、缓存未预热和 JIT 编译造成初始延迟;
  • 网络带宽或连接跟踪资源不足。

调优策略

  • 确认容器 CPU 和内存限制,而不是只看宿主机总资源;
  • 对实例做容量测试,确定合理的扩容阈值;
  • 通过负载均衡减少实例间的不均衡;
  • 区分冷启动指标和稳定运行指标;
  • 对本地缓存、连接池和线程池设置合理的预热策略;
  • 在资源接近上限前进行限流、扩容或降级;
  • 将资源使用率与 p99、错误率和吞吐量一起观察。

单纯增加实例数量可以提高总容量,但不一定降低单请求延迟。如果瓶颈位于共享数据库、热点锁或单个下游服务,扩容当前服务可能只是把更多请求压向同一个瓶颈。

调优策略应当服从瓶颈定位

前面的影响因素可能同时存在,但一次性能分析通常需要先找到主导因素。可以使用下面的顺序:

1
2
3
4
5
6
建立基线
-> 采集分段延迟
-> 区分执行和等待
-> 判断 CPU、内存、网络或并发资源是否饱和
-> 选择针对性优化
-> 在相同负载下重新验证

先建立可比较的基线

基线至少应该固定:

1
2
3
4
5
6
代码版本
实例规格和数量
请求速率和并发模型
请求、响应和数据分布
缓存命中状态
上下游服务版本和延迟

如果优化前后使用了不同的请求数据、不同的缓存状态或不同的下游负载,就很难判断改动本身是否带来了收益。

再定位时间花在哪里

建议同时观察:

  • 接口整体 p50、p95 和 p99;
  • 各阶段耗时和关键路径;
  • CPU 使用率和热点函数;
  • 内存分配、GC 和堆变化;
  • 锁等待和线程调度;
  • 线程池、连接池和队列等待;
  • 数据库、缓存和下游服务延迟;
  • 超时、错误和重试。

单一指标通常无法说明问题。例如 CPU 很高可能表示计算密集,也可能表示大量自旋和无效重试;CPU 很低可能表示接口在等待数据库、锁或连接池。

最后进行单变量验证

一次改动应该尽量只验证一个假设:

1
2
3
假设:数据库连接池等待是主要瓶颈
改动:增大连接池或减少连接占用时间
验证:观察连接等待、接口 p99、数据库负载和错误率

如果 p99 降低了,但数据库 CPU、锁等待或错误率明显上升,那么这次改动可能只是把瓶颈转移到了下游,而不是改善了系统的整体性能。

优化的优先级

通常可以按照下面的顺序思考:

  1. 减少不必要的工作:删除无效查询、重复计算和多余字段;
  2. 缩短关键路径:并行独立调用,异步化非必要工作;
  3. 减少等待和竞争:优化锁、连接池、线程池和队列;
  4. 降低单请求成本:优化算法、数据结构、内存分配和序列化;
  5. 提高系统容量:调整资源、实例数量和部署方式。

这个顺序不是绝对规则,但它提醒我们:增加机器和扩大线程池通常应该放在定位瓶颈之后。否则,优化可能只是提高了请求进入瓶颈的速度。


本站由 Edison.Chen 创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。