量变产生质变:减佣系统的 CPU 性能极致探索之旅

104 阅读9分钟

性能问题最容易被低估的地方,是请求规模带来的放大效应。 减佣链路高峰接近 20 万 QPS。这个量级下,关键路径上再小的固定开销都会被成倍放大;反过来,再小的优化,也可能换成可观的 CPU 与机器成本收益。

一、为什么做这次优化

背景

减佣服务位于司机看单接单的核心链路,历史节假日高峰期间,服务以超过 1000 个实例承载约 20 万 QPS(4c8g配置)。从减佣业务的特点来看,请求下游多,逻辑复杂度高,所以减佣服务使用大量机器是合理的,并且服务性能也是正常的。

问题

但实例数量过多会放大服务发现、连接管理、Kafka 消费组协调以及发布变更等方面的集群压力;规模继续增长时,稳定性与运维复杂度也会随之上升。之前就出现过服务扩容节点数量过多,引起 Kafka 集群持续高频的 Rebalance,最终导致Kafka节点容量不足、过载并停止服务,影响了该节点上的所有Topic消费。

目标

因此这次优化的目标是在保证计算正确性与链路稳定性的前提下,极致压榨服务器性能,减少实例数量,为后续增长留出空间。

CPU 主要消耗在哪里?其中哪些是业务必须支付的成本,哪些是可以从核心路径中抠出来的额外开销?

二、我们是怎么定位的

1. 从先入为主到数据说话:预期截然不同

我们原先默认有两层直觉:

一是 CPU 主要花在减佣计算本身,也就是减佣计算;

二是 减佣服务下游多,SOA 调用的序列化与 RT 耗时是不可避免的,所以先入为主的判定:减佣的CPU使用率高是必然的,也是无解的。

然而我们在生产环境用 async-profiler 采集 CPU 火焰图,从最宽的业务入口一路往下追,看到的分布和原先的判断并不一致:

  1. 真正【执行计算】只占约 3%,大部分 CPU 消耗在计算前的上下文构建中。 方向因此从「优化计算公式」转向「优化计算前的上下文构建和基础操作」。

  2. 构建上下文的开销中有两个大头:

    1. 费用项初始化:纯本地操作但是CPU开销最大,这一点引起了我们的注意。
    2. 请求下游获取减佣规则:必要请求开销无法节省。

2. 从局部调用链到全局聚合:日志浮出水面

火焰图末端的样本分散在 JVM 方法、集合遍历、日志封装和业务方法中,我们把同类方法跨调用链汇总分析后,又一个日志打印的隐藏问题被我们发现:跨整张火焰图聚合自定义减佣Logger.info,占全进程 CPU 样本的 12.4%。

问题总结:

经过业务代码和依赖源码逐项核验,最终确认两个核心问题:

  1. 日志抓栈: 每条 info 日志为了补充文件名和行号,都会同步抓取完整调用栈。
  2. 费用项全表扫描: 按 code 查询没有命中 Map 的 key,而是在批量流程中反复遍历整张缓存。

这两项根因明确、改动可控、不改变计费规则,并且能够通过独立压测验证,因此成为本次优化的重点。

三、问题一:日志链路为什么成为 CPU 热点

1. 现象:日志抓栈稳定出现在 CPU 热点中

火焰图上,Throwable.fillInStackTrace、getStackTraceElement 与 自定义减佣Logger.info 同宽,栈相关方法约占进程 CPU 的 12%~13%。

回看代码后发现,只是为了给日志增加「文件名-行号」,但实现是每次 info() 都同步抓取完整调用栈。为了一行调用位置,每次 info 都先支付完整抓栈成本。

代码示意:

2. 原因:一次抓栈包含哪些成本

getStackTrace() 并不是读取一个现成的调用者名称,为了返回完整的 StackTraceElement[],JVM 需要在业务线程中完成一连串工作:

当前代码最终只使用第 3 个栈帧来拼接“文件名-行号”,却先支付了整条调用栈的采集和物化成本。即使后续日志通过 AsyncAppender 异步写出,抓栈仍然已经同步发生在业务线程中。更重要的是:每条info日志都重复执行前面的动作,随日志条数和 QPS 线性增长,抓栈总成本 ≈ QPS × 每请求日志数 × 平均栈深度。

真正昂贵的不是字符串拼接,而是每条 info 都要求 JVM 同步还原并物化完整调用栈。在高 QPS 下,这个固定成本被重复放大,最终成为 CPU 热点。

3. 后续怎么改:默认不抓栈,按需开启

//代码直接删除抓栈动作:Thread.currentThread().getStackTrace()
public static void info(Logger paramLogger, String format, Object... arguments) {
    paramLogger.info(format, arguments);
}

四、问题二:费用项查询为什么退化为全表扫描

1. 现象:Map.values()与Stream过滤反复出现在 CPU 热点中

在费用项查询的火焰图中,ConcurrentHashMap 遍历和 Stream 过滤反复出现在费用项初始化链路上。回到代码后发现,主缓存没有按业务 code 建立索引,因此每次按 code 查询都要遍历 CACHE.values()。

//每次获取费用项都要从集合中遍历匹配。效率非常低。
public static OriginalBillItem get(String code) {
    return CACHE.values().stream()
            .filter(item -> item.getCode().equals(code))
            .findAny()
            .orElse(null);
}

更明显的现象出现在批量查询中: 获取到某个费用项之后,为了组装费用项的完整信息,又对费用项品类列表做了遍历筛选,导致复杂度成倍上升。

2. 原因:查询字段没有成为索引

费用项列表初始化逻辑是每次减佣请求都会经过的必经路径。单次查询多出来的 CPU,会被「每请求一次 × 高峰 QPS」完整放大;单次若再退化成 O(n²),在高流量下就会稳定占住火焰图热点。

数据虽然放在 Map 中,但查询没有使用 key 定位,代码绕开 Map 的 key,每次都从 values 中重新寻找; Map 的索引能力因此被完全废弃:例如Map中有 100 个费用项,最坏情况下可能产生约 10,000 次判断。换成普通 for 循环只能降低常数,不能消除重复扫描;真正的解决办法是让查询字段成为 Map 的 key。

3. 后续怎么改:优化数据结构,降低查询复杂度到O(1)

private static final Map<String, OriginalBillItem> CACHE_BY_CODE =
        new ConcurrentHashMap<>(320);

public static OriginalBillItem get(String code) {
    return CACHE_BY_CODE.get(code);
}

优化后:单次查询从 O(n) 降为平均 O(1)。getAll() 仍然需要遍历 n 个费用项,但内部每次查询不再扫描全表,整体从 O(n × (n + m)) 降为 O(n)。

五、验证:如何证明定位和改动准确

1. 验证思路:用控制变量和消融对照证明因果关系

只比较优化前后的两次 CPU 数据并不够,因为机器负载、流量和数据量变化都可能造成波动。要证明定位和改动准确,需要在其他条件保持一致的前提下,每次只改变一个变量,再观察对应热点和 CPU 是否按预期变化。因此压测设置四组对照:

  1. 基准版本;
  2. 只关闭日志抓栈;
  3. 只优化费用项缓存;
  4. 两项同时启用。

2. 实际验证结果

2.1 单机压测

场景CPURT
基准65%46ms
只关闭日志抓栈57%46ms
只升级费用项缓存58%46ms
两项同时启用48%46ms

两项优化分别能降低约 7~8 个百分点,同时启用后 CPU 从 65% 降到 48%,下降 17 个百分点;RT 保持 46ms。

RT 没有明显变化并不代表优化无效。这次消除的是业务线程中的 CPU 计算和扫描开销,主要收益体现为单机容量、安全余量和机器成本,而不是下游等待时间。

2.2 火焰图复验

费用项.get 在极限压力下的 CPU 占比:

  • 优化前:约 12%
  • 优化后:约 0.2%

这说明按 code 查询时的全表扫描基本被消除。

2.3 生产环境验证

优化于 8 月 6 日上线。通过 8 月 5 日与 8 月 12 日的单机 CPU 对比,生产 CPU 明显下降;RT、GC 和堆内存没有出现回退。数据来自相同机器配置、规模数量下:

时间08050812
单机cpu
zone2

提前的压测扩容+持续的优化,让减佣服务经受住了2026年930高峰考验,cpu使用率始终保持在30%以内(在相同机器规模下cpu降低10个百分点),为未来公司再次冲击高峰做好了准备。

六、这次优化可以复用的经验

  1. 先确认 CPU 真正花在哪里。 经验可以提供方向,但最终要用生产采样和对照实验验证,不要先入为主地优化业务公式。
  2. 核心路径上的“小动作”也值得审视。 日志、对象分配和集合查询单次看起来都不贵,但只要每个请求都会执行,就可能被大规模流量放大成主要成本。
  3. 使用 Map 不等于查询就是 O(1)。 只有查询字段真正成为 key,才能利用哈希索引;遍历 values 仍然是全表扫描。
  4. 优化复杂度比替换语法更重要。 本次缓存问题的核心是嵌套扫描;把 Stream 换成 for 循环,只能减少常数,不能改变 O(n²)。
  5. 性能收益需要拆开验证。 保持环境和流量一致,每次只启用一项改动,才能确认每项优化的独立收益,并排除环境波动。

这次优化并不意味着性能问题已经一劳永逸。减佣核心链路承载着大规模流量,每一次改动都需要谨慎评估和灰度验证;但也正因为规模足够大,每一点经过验证的细节优化,都会被流量放大成真实的容量收益。性能优化要做的,就是在不影响业务正确性和链路稳定性的前提下,持续识别核心路径中的浪费,一点一点把性能抠出来。