同样是 O(n),为什么你的循环慢 30 倍?一次订单导出接口从 4.2s 压到 380ms 的复盘

🔑 关键词:代码优化,性能优化,缓存友好,火焰图,O(n)复杂度

📖 摘要:两个都标着 O(n) 的循环,同一台机器上实测差 20 到 30 倍。这篇文章记录一次真实的接口优化过程:从 async-profiler 火焰图定位到 ArrayList.contains 占 41% 采样,到分批 IN 查询、集合预分配、内存布局调整,把 P99 从 4.2 秒压到 380 毫秒。里面包含 perf、JMH、async-profiler 的具体命令和参数,也有我自己踩过的两个坑,以及我判断「不值得优化」的三类代码。

先坦白,我一开始也走错了方向

图片

去年十一月接手一个订单导出接口,P99 是 4.2 秒。前端那边 3 秒就转圈,用户投诉过三次。我第一反应是数据库,因为拼一个导出行要取 12 个字段,其中 8 个来自三张不同的表。我打开慢查询日志翻了半小时,最慢的一条 80 毫秒,全部加起来还不到 1 秒。剩下 3 秒多去哪了?

这个问题现在回头看挺蠢的,但当时我确实在 SQL 上耗了半天。后来用 async-profiler 抓了 30 秒,命令是 ./profiler.sh -d 30 -f out.html 12873,火焰图打开,java.util.ArrayList.contains 占 41% 的采样。我盯着看了一会儿才认出来,因为这行代码在业务里几乎没存在感,写的时候就是为了「过滤一下重复的 SKU」。

那次之后我养成一个习惯:改代码之前先抓一次火焰图。Java 用 async-profiler,Go 用 pprof,C/C++ 用 perf。没有图的优化,本质上就是在猜。

第一段改动只省了不到一秒

第一处是 N+1。3000 个订单,循环里每条查一次 SELECT name, spec FROM product WHERE id = ?,3000 次往返。改成按 500 一批做 IN 查询,6 次搞定,这一段从大约 900 毫秒掉到 210 毫秒左右。

图片

500 这个数不是拍的。我在 MySQL 8.0 上试过 200、500、1000、3000 四档,1000 往后收益基本平了,而且 SQL 文本拉太长会让解析器多花点时间。200 到 500 之间差异不大,我选了 500。max_allowed_packet 默认 64MB 一般够用,不用急着调。

但这一步只省了 700 毫秒。P99 从 4.2 秒到 3.5 秒,投诉还是照来。

真正的大头:两个都叫 O(n) 的循环

把 contains 换成 HashSet 之后,接口降到了 380 毫秒以内。但这件事让我回头补了一课。为了确认「过滤」那段能不能换数据结构,我写了个小测试,比较遍历 1000 万个整数:一个是 int[],一个是 LinkedList<Integer>。

同一台机器,i7-8700K,L1 数据缓存 32KB,L2 256KB,L3 12MB。

图片

顺序遍历 int[]:11 毫秒左右。

遍历 LinkedList<Integer>:350 到 390 毫秒,跑了五次取中位数。

差 30 倍。而它俩的时间复杂度都写作 O(n)。

原因不复杂,只是教科书不太讲:LinkedList 每个节点是独立分配的对象,1000 万个节点散在堆里,每次 next 基本都要去主存捞一次,一次 miss 大概 100 纳秒。int[] 是连续内存,CPU 预取器能把后面几条 cache line 提前拉进 L1,一条 line 64 字节,正好装 16 个 int。这也解释了 Mike Acton 在 CppCon 2014 那场 Data-Oriented Design 里为什么反复强调数据布局比算法重要,游戏行业一个帧 16.6 毫秒,链表多跳几次就没了。

我现在的优化顺序,和大家常说的那个反着来

图片

常见的建议是:先看算法复杂度,再看数据结构,最后看微观。我现在的顺序要倒一点,先看这段代码一年被调用多少次。

具体分三步:

  1. 抓火焰图,找采样占比最高的三个函数。低于 5% 的直接跳过,改完也测不出来。
  2. 按「调用次数 × 单次耗时」排序。我遇到过一堆单次 0.2 毫秒、但每天调用 40 万次的工具函数,加起来比一个单次 800 毫秒的报表任务还费时间。
  3. 到这一步才动手,而且先写基准测试再改。

基准测试这块我踩过坑。早期用 System.currentTimeMillis() 包一圈,测出来某段代码优化后快了 8 倍,挺得意。后来换成 JMH 重测,@Warmup(iterations = 5, time = 1)、@Fork(1),实际只快了 1.3 倍。第一次测的时候 JIT 还没编译,第二次测的时候它已经内联了。加 -XX:+PrintCompilation 能看到 C1、C2 编译的时机,看一会儿还挺上瘾。

图片

一个被低估的点:预分配

换成 HashSet 那次,contains 从 O(n) 变成 O(1),这个谁都懂。但真正省下那 3 秒的,不全是复杂度的功劳。

我后来把集合写成 new HashSet<>(3000 * 4 / 3 + 1),直接按负载因子 0.75 反推容量,避开 rehash。就这一行,在 3000 条数据上又省了大概 20 毫秒。数据量小看不出什么,但如果这个集合是 30 万条,扩容时要重新分配、重新算 hash、重新插入,卡顿会非常明显。

同理还有 ArrayList 的默认容量 10 和 1.5 倍增长。知道要装多少就先给多少,new ArrayList<>(expectedSize)。这是我代码 review 时挑得最多的一个小问题,没有之一。

有三类代码我基本不优化

图片

第一类,一年跑不到 100 次的启动脚本、迁移工具、一次性跑批。花两小时优化,省下 3 秒,一辈子也追不回来。

第二类,采样占比不到 5% 的函数。你觉得它慢,多半是因为它在 IDE 里看起来丑。

第三类,我还没测就「感觉」慢的地方。这个最多,也最容易骗人。我有一次笃定某个 JSON 序列化是瓶颈,换了个库,压了三天测,P99 波动在 1 毫秒以内。等于什么都没做,还白引入一个依赖,后来老老实实回滚了。

现在我的流程基本固定:async-profiler 或者 perf 抓图(perf record -F 99 -g -p <pid> -- sleep 30,接着 perf script | stackcollapse-perf.pl | flamegraph.pl > out.svg),排序,只改一个地方,重测,再改下一个。

那个订单接口最后停在 380 毫秒,我压到 800 QPS,机器负载从 3.2 掉到 0.9。代价是三天时间,外加一次猜错的 JSON 库替换。值不值另说,但现在翻回那几行代码,我清楚它为什么快,而不是「反正就是快了一点」。

🏷️ 标签: