前阵子帮一个团队排查接口,P95 一直在 80ms 上下波动,代码里该做的缓存做了,连接池也配了,一群人折腾两天没有结果。最后发现根子不在业务代码,而在每次请求都会重新走一次 DNS 解析,而且解析结果完全没有缓存到进程内。把这一行改掉之后,P95 直接掉到了 12ms。这个案例让我越来越确信一件事:延迟优化不是玄学,不是靠猜,也不是堆中间件,而是要把每一毫秒先拆开,再拆成微秒去对账。这篇文章我就用这些年实际验证过的思路,聊聊从网络链路、系统调度、内存分配到压测验证的微秒级性能优化,适合后端、前端、安卓和游戏方向的性能工程师参考。
1. 先把“毫秒”拆开:延迟到底花在哪儿了
很多团队做性能优化,第一个动作就是翻代码,这其实搞反了。真正应该先回答的问题是:你的平均耗时可不可信?你的 P99 是多少?延迟是均匀分布还是长尾?如果不回答这几个问题,优化就变成了碰运气。
1.1 先看分布,再看平均数
平均耗时是一个很有迷惑性的数字。同一套接口,平均值 50ms,P99 可能是 500ms。用户感受最深的不是你一半请求跑得多快,而是最差的那一批有多慢。玩游戏的时候尤其明显,平均 30 帧不代表不卡,只要有一帧掉到 200ms,体感立刻变卡。所以做延迟优化,第一个动作就是把指标从“平均值”换成“分布图”。
常用的指标包括:
- P50:一半请求比这个值快,代表典型体验。
- P95 / P99:代表长尾体验,往往隐藏着真正的性能 bug。
- P999:极端抖动,一般和 GC、锁竞争、网络重传、系统调度相关。
- 直方图:比单纯分位数更直观,多条峰值往往意味着存在多种不同路径的慢请求。
我个人的习惯是,先把线上流量按照接口、机房、客户端版本分组,各画一张耗时直方图。如果 P99 和 P50 差距超过 5 倍,先别优化中位数,去找长尾的来源。
1.2 把延迟拆到每一层
优化之前还需要建立一张“延迟地图”。我会先按请求链路拆时间段:DNS 解析、TCP 握手、TLS 握手、服务端排队、业务计算、数据库访问、序列化、网络回传。每一段都要能掏出数字,否则没有资格说哪一段慢。
这张地图的底层,是需要对常见操作的耗时数量级有直觉:
| 操作 | 典型耗时 |
|---|---|
| CPU 寄存器访问 | 约 1ns |
| L1 / L2 缓存访问 | 约 1ns - 10ns |
| 内存随机访问 | 约 100ns |
| 系统调用 / 上下文切换 | 约 1us - 10us |
| NVMe 顺序读 | 约 10us - 50us |
| SSD 随机读 | 约 100us |
| 数据中心内网络往返 | 约 100us - 500us |
| 跨地域网络 RTT | 几十 ms 到几百 ms |
这张表不是让你背,而是让你建立“预算感”。比如你想做一个 16.6ms 帧预算的手游,那 CPU 侧可能只有 8ms,再拆下去,每次不必要的 JSON 序列化如果花掉 1ms,就已经吃掉八分之一预算了。同样,如果你在做一个高频交易网关,目标微秒级别,那你连系统调用都要省着用。
拆解工具上,Linux 下我用perf和bpftrace做 CPU 热点和内核事件采样,Java 用async-profiler出火焰图,前端直接用 Chrome DevTools Performance,移动端用 Perfetto。有一个通用的采样命令值得记住:
# 对指定 PID 采样 3 秒,-F 99 表示每秒 99 次 perf record -F 99 -g -p PID -- sleep 3 perf report采样不是全量记录,但足够告诉你时间花在哪个函数上。火焰图横向是时间占比,纵向是调用栈,一眼就能看出主路径上哪些函数是“看着小但频繁被调用”的。
1.3 微秒级优化需要的测量精度
当目标从毫秒降到微秒,测量工具本身也会影响结果。比如你在 JavaScript 里用Date.now()测一段 5us 的操作,会因为时间分辨率不足而得到一堆跳变的数据。在 C/C++ 或 Rust 里,我会用clock_gettime(CLOCK_MONOTONIC),在 Java 里用System.nanoTime(),在 Julia 里用 BenchmarkTools 而不是手动@time跑一次。
微秒级优化有个很反直觉的点:很多瓶颈不是慢在“执行”,而是慢在“等待”。它可能是在等锁、等内存分配、等一次分支预测失败、等缓存行同步。这类问题只有在高精度测量和足够多的采样下才会暴露。
2. 网络链路里的毫秒:DNS、握手和复用是三个最值钱的地方
网络延迟是最容易“背锅”的部分。很多人一开口就是“网络不好”,但网络里其实藏了很多可以优化的固定开销。我见过太多项目,业务响应只要 5ms,网络链路却占掉 75ms。
2.1 curl -w 是判断网络延迟的第一把手术刀
我不太喜欢用“感觉慢”来描述问题。要拆网络,最直接的方式是看curl的 time breakdown:
curl -o /dev/null -s -w "DNS:%{time_namelookup}s TCP:%{time_connect}s TLS:%{time_appconnect}s TTFB:%{time_starttransfer}s 总耗时:%{time_total}s\n" https://www.example.com/api输出里的几个值对应的是:
time_namelookup:DNS 解析耗时。如果在本地环境都超过 20ms,说明解析链路有问题。time_connect:TCP 三次握手完成时间。它包含 RTT,同时也受系统 backlog 和队列影响。time_appconnect:TLS 握手完成时间。TLS 1.2 通常要额外 2 个 RTT,TLS 1.3 降为 1 个 RTT。time_starttransfer:从请求发出到收到第一个字节的时间,即 TTFB,包含服务端处理耗时。
这个命令在压测前先跑三遍,基本能定位问题在网络哪一段。如果 DNS 和连接耗时占了总耗时一半以上,就不用急着优化业务代码了。
2.2 连接复用和握手优化实际怎么落地
DNS 优化是最容易见效的。应用层不要每次发起请求都重新getaddrinfo,进程内要有解析结果缓存;SDK 层面要设置合理的 TTL,同时区分“连接专用 IP”和“域名解析”。如果你用的是主流 HTTP 客户端,开连接池是基本操作。
TCP 和 TLS 上,值得做的几件事:
- 使用连接池,避免每次请求都重新握手。短连接在高并发下会让服务器被迫处理大量 SYN 和 TLS 握手,CPU 和延迟都会上去。
- 服务端开启 TCP_NODELAY,关闭 Nagle 算法。否则小包可能被延迟发送,和 Delayed ACK 叠加后,一次交互能硬生生多出 40ms。
- 配置 TLS 会话复用,客户端和服务端都开启 session ticket。这样后续连接可以省掉一次完整握手。
- 如果条件允许,优先支持 HTTP/2 和 HTTP/3。HTTP/2 的多路复用能减少队头阻塞,HTTP/3 基于 QUIC,握手开销更低,弱网下有更好的表现。
我之前帮一个海外业务项目做过一次优化,把原先每次请求都新建连接改成连接池复用,光这一步,P99 从 260ms 降到 80ms。后面又补齐了 TLS 会话复用,再降到 52ms。没有改一行业务逻辑,纯粹是网络链路的基础建设。
2.3 别把 CDN 当成网络优化的唯一答案
很多团队一说到网络延迟,第一反应是上 CDN。CDN 对静态资源非常有效,但对动态 API 和弱网下的长连接,价值有限。更要命的是,很多人上完 CDN 之后,回源链路没优化,DNS 没优化,客户端连接没复用,最后效果和图里的 TTFB 曲线一样难看。
真正的思路应该是:先把 RTT 从链路层到传输层逐段拆清,再决定哪些用 CDN、哪些做边缘计算、哪些做连接加速。不要拿着一个方案到处套场景。
3. 内存与序列化的微秒账本:从 JSON.stringify 到 Julia 的 allocation
网络优化完了,下一个大头通常不是 CPU 计算,而是内存分配和序列化。很多毫秒级延迟,本质上是分配导致的 GC,而微秒级优化要做的,就是把分配从热路径里请出去。
3.1 主线程上的 JSON.stringify:毫秒级卡顿的元凶
前端最常见的微秒级变毫秒级案例,就是JSON.stringify和JSON.parse。本身这两个函数单次调用并不慢,几 KB 的对象可能也就几十微秒。但如果在 React 的渲染路径上,每次状态更新都去 stringify 一个几十 MB 的大对象做比较,主线程就会被卡出肉眼可见的掉帧。
一个典型反模式:
function App() { const data = useSelector((state) => state.data); // 每次 data 变化,这里都会全量序列化一次 const snapshot = useMemo(() => JSON.stringify(data), [data]); return <Child snapshot={snapshot} />; }数据小的时候没事,数据大了以后,JSON.stringify的时间会随着对象大小线性上升,可能从 0.1ms 涨到 20ms。用户在输入框里打字,每敲一个字符都触发一次全量序列化,体感就是键盘卡顿。
优化其实不复杂:
- 不要序列化整个对象,只取需要比较的字段。
- 用 immutable 数据结构和浅比较,从跟上避免全量 deep compare。
- 如果确实需要序列化大数据,把它丢到 Web Worker 里执行,避免阻塞主线程。
- 对列表数据,考虑虚拟滚动和分页,让一次参与序列化的数据量可控。
我在实际项目里见过把 5MB 的图表配置数据放在 Redux 里,每次 hover 都触发 stringify,最后改成按需加载和字段裁剪后,交互耗时从 120ms 降到 8ms。数据量没有变小,只是不要一遍又一遍地把所有东西都变成字符串。
3.2 Julia 的性能与内存管理:类型不稳定比写得慢更可怕
后面再聊 Julia,是因为 Julia 的性能优化特别能说明“微秒级瓶颈藏在分配里”这件事。Julia 的 JIT 编译很吃类型信息,一旦函数里出现不稳定的类型,就会生成大量动态派发和装箱分配,导致运行时间从纳秒级别跳到微秒甚至毫秒级别。
最简单的例子:
function sum_loop(n) total = 0 for i in 1:n total += i end return total end当total没有类型声明且上下文模糊时,Julia 可能把它推断成Any,每次加法都要做类型检查。实际上写total = 0通常能推断成 Int,但更稳妥的做法是显式total::Int = 0,或者在模块里避免全局变量。要检查类型稳定性,可以用:
@code_warntype sum_loop(100_000)输出里如果有红色的Any,就说明这里存在类型不稳定。注意,Julia 里@time的第一次调用会包含编译时间,所以微基准必须用BenchmarkTools的@btime多次采样,否则测出来的并不是真实运行时间,而是“编译+运行”时间。这个误差在微秒级测量中是灾难性的。
优化内存分配在 Julia 里同样重要。比如尽量减少循环内的数组分配,提前用Vector{T}(undef, n)预分配输出。GC 在多数语言里都不可怕,可怕的是你在延迟敏感路径上频繁触发 GC。偶尔一次 Full GC 可能就是几十毫秒,这对微秒级服务来说是不可接受的。
3.3 把“分配”移出热路径
不管是 Go、Java、C++ 还是 Python,热路径上的内存分配都要小心。常见做法有几类:
- 对象池/复用缓冲区:处理完请求后归还对象,而不是让 GC 统一收尾。
- 避免 hot loop 里的字符串拼接,使用
strings.Builder或数组 join。 - 尽量使用栈上分配的临时对象,避免逃逸到堆上。
- 多线程环境下,注意 false sharing:不同线程同时修改相邻的变量,会导致缓存行不断失效,看起来是 CPU 跑满,实际都在等缓存同步。
微秒级优化到最后,拼的不是语法技巧,而是对数据到底在哪一层流动的理解。数据在寄存器里,是 1ns 的事;在 L2 缓存,是 10ns 的事;在主存,是 100ns 的事;如果在 GC 堆里飘着,可能就是 1ms 的事。
4. Windows 游戏延迟的 bat 优化:哪些是真收益,哪些是安慰剂
聊完服务器和前端,再回到一个很多人关心的场景:Windows 游戏性能优化。网上经常有人求“一键优化 bat”,要求是关闭后台服务、调整高性能电源、优化网络延迟、清理临时文件。我直接说结论:这类 bat 里有一部分是有效果的,有一部分纯属心理安慰,还有一部分可能带来副作用。
4.1 一个保守可用的 bat 脚本
下面是经过我验证、危险性相对低的保守版脚本。可以右键“以管理员身份运行”,但我不建议你在此基础上再批量关服务。
@echo off title Windows 延迟优化(保守版) net session >nul 2>&1 if errorlevel 1 ( echo 请右键“以管理员身份运行”本脚本 pause exit /b ) echo [1/5] 切换高性能电源计划... powercfg /setactive SCHEME_MIN echo [2/5] 刷新 DNS 缓存... ipconfig /flushdns >nul 2>&1 echo [3/5] 恢复 TCP 自动调谐为 normal... netsh interface tcp set global autotuninglevel=normal echo [4/5] 清理当前用户临时文件... if exist "%TEMP%" ( del /f /s /q "%TEMP%\*" >nul 2>&1 ) echo [5/5] 清理 Windows 临时文件... if exist "C:\Windows\Temp" ( del /f /s /q "C:\Windows\Temp\*" >nul 2>&1 ) echo 完成。建议重启一次再观察游戏内延迟。 pause这个脚本做的事情解释一下:
powercfg /setactive SCHEME_MIN切到高性能电源计划。这个对微秒级延迟是有真实影响的,因为高性能模式会减少 CPU 进入深度睡眠的频率,降低中断唤醒和频率切换带来的抖动。ipconfig /flushdns只是清空 DNS 缓存。如果 DNS 缓存没有坏,执行它不会降低任何延迟;如果缓存里有过期或错误的记录,清掉之后反而能恢复。netsh interface tcp set global autotuninglevel=normal是恢复 TCP 自动调谐默认值。网上很多教程让你改成disabled,这对大多数网络环境反而有害,会降低吞吐并增加高延迟丢包。- 清理临时文件属于磁盘整理,不是延迟优化。除非磁盘快满了,导致页面文件抖得厉害,否则它对游戏帧率几乎没有影响。
4.2 为什么“关闭一堆服务”不是万能钥匙
网上流传的“游戏优化 bat”最喜欢做的事情就是批量禁用服务。但我实际对比过,很多默认服务在平时是休眠状态,并不会占用 CPU 和磁盘。真正影响游戏的往往是你自己装的加速器、杀毒软件、桌面小组件、弹窗广告进程,这些东西不是 Windows 服务,而是在用户目录下常驻的 exe。
与其一键关服务,不如在游戏全屏运行前打开任务管理器,按 CPU、磁盘、GPU 排序,看谁是真正抢资源的进程。如果是某个国产软件全家桶,那就在设置里退掉它;如果只是某个不知名后台进程,再考虑结束任务。
我理解很多人需要一个能一键执行的脚本,但性能优化最忌讳的就是“不知道每一步在干什么”地照搬。关错关键服务,轻则系统卡顿,重则网络组件失效,反而把低延迟搞成高延迟。
4.3 真正拉高游戏延迟的三个系统因子
从系统底层看,Windows 上游戏延迟变高的常见原因无非三类:
第一,CPU 电源状态太激进。默认的均衡电源计划会让 CPU 频繁降频和进入 C 状态,虽然省电,但唤醒和频率切换的延迟可能从微秒级涨到毫秒级。切到高性能或卓越性能模式,把处理器状态最小值拉到 100%,能明显改善帧生成时间抖动。
第二,后台进程抢占时间片。这里的后台进程主要不是系统服务,而是各种自动更新、云同步、录屏工具。它们每次抢占 CPU 和磁盘,都可能在游戏主线程上制造一次卡顿。如果机器有多核,还可以考虑把游戏的 CPU 亲和性固定到未被干扰的核心上,但实际作用因游戏而异。
第三,网络协议栈被“优化”乱改。很多网络优化工具会去改 TCP 参数、禁用 NetBIOS、修改 DNS 超时。不是说全都不能用,而是很多数值只适用于特定网络环境。普通家庭宽带,最好就是保持默认。改错一个参数,可能让上网环境从稳定 20ms 变成不稳定 80ms。
5. 放大收益的架构动作:缓存、预取和批量
微秒级优化做的是把单个操作变快,而架构层优化做的是让“快”能被放大到整个系统的用户体验。很多团队把精力全放在单个函数上,却忘了砍掉一些不必要的请求,后者带来的收益往往大得多。
5.1 少发一次请求,比把响应加快 1ms 更重要
如果一次额外请求的 RTT 是 50ms,你再怎么把接口内部从 20ms 优化到 2ms,整体收益也就 18ms。但如果你能合并两次请求,或者去掉一次轮询,直接省掉的可能就是 50ms 甚至 100ms。
我见过一个监控后台,前端每秒钟拉一次列表全量数据,单次响应只有 5ms,但网络和渲染加起来每秒钟积累不少开销。后来改成只有在数据变化时才推送,或者按需增量拉取,页面加载和交互的体感直接从“还行”变成“很跟手”。这种优化不涉及任何高深算法,核心就是减少不必要的数据传输。
在服务端也一样。不必要的下游调用、重复查询、无用字段序列化,都是隐形的毫秒。先把请求图列出来,凡是不影响主路径的,都可以砍掉或异步化。
5.2 数据库查询里的 N+1:80ms 到 5ms 的经典案例
N+1 查询是后端最容易踩的延迟坑之一。一个循环里逐条查询数据库,单个查询 2ms 似乎不慢,但循环 40 次就是 80ms,而且每次查询都伴随着一次网络往返和 SQL 解析。
一个典型的反模式:
# 先查用户列表,再循环查每个用户的订单数 users = get_users() for user in users: users[user] = get_order_count(user.id)改成批量查询后:
users = get_users() ids = [user.id for user in users] order_counts = get_order_count_by_ids(ids)两种方式可能查询的数据量完全一样,但耗时差了十几倍。原因很简单:批量查询只需要一次网络往返和一次 SQL 执行,循环查询则把同样的开销重复了 N 次。优化之后,这个接口从 80ms 降到 5ms 是很正常的。
再进一步,可以在 Redis 或本地内存里做热点用户订单数的缓存,不过缓存引入之后要考虑一致性。缓存不是用来解决所有延迟问题的,它解决的是“同一份数据被反复读取”的问题。
5.3 预热与缓存设计:微秒级命中的前提是“人肉加载过”
很多团队上线后第一次请求特别慢,之后才快,这就是冷启动延迟。缓存只有在热点数据已经加载好的时候才能提供微秒级访问。如果用户是第一个触发缓存加载的人,他感受到的就是一次数据库慢查询。
所以在高延迟敏感的场景里,常见做法是:
- 应用启动时做缓存预热,把核心配置和热点数据提前加载。
- 对低频但重要的接口做预取,比如进入某个页面之前,先拉取下一步会用到的数据。
- 使用 singleflight 机制,在同一时刻多个请求打到同一个未命中 key 时,只让一个请求去回源,其他请求等待结果,避免“缓存击穿”造成的连锁慢请求。
移动端和手游也一样。资源包和关卡配置如果能在进入战斗前提前加载好,战斗中就不会出现“正在加载”卡住一秒的情况。这个不是代码层面的微秒优化,而是资源调度层面的毫秒优化,但对玩家体验的影响远远超过单个函数的加速。
6. 压测数据里的“幻影延迟”:验证优化时要盯住的东西
最后一个部分,我想聊验证。很多人优化完之后,跑一次压测,看到平均耗时降了 50%,就宣布成功。但这个数字很可能是不稳定的,可能只是运气好,也可能根本没测到真实瓶颈。
6.1 冷启动、热启动和 JIT 编译会骗你
服务端代码第一次执行时,类加载、JIT 编译、连接池初始化都可能拖慢请求。如果你压测时直接打流量,不先预热,那测出来的前几百秒往往包含了大量的编译和连接开销。压测结束后看平均数据,可能被大量“冷请求”拉高,也可能因为跑到后面 JIT 优化完成后变得特别好看。
正确做法是先预热足够多的请求,把 JIT、连接池、缓存都热起来,再开始采样。但同样要小心别把“热过头的缓存”当成常态。更合理的做法是分别测冷缓存和热缓存两条路径,然后按真实线上命中率加权得到最终结论。
6.2 平均数和 P999 至少要同时看
只看平均值是性能优化里最常见的错误。一个服务平均 5ms,P999 可能是 2 秒。如果优化让平均值从 5ms 降到 4.5ms,但 P999 从 2s 涨到 3s,用户体验不但没变好,反而更差。
微秒级的系统尤其要重视“尾部延迟”。CPU 频率切换、GC、锁等待、JVM 线程调度、内核中断,随时都可能给你插进来一次 10ms 级的延迟。这类抖动不会明显影响平均值,但会让 P99 和 P999 变得极其难看。验证时,我会同时在压测端和服务端采集perf数据,看尾部延迟出现时,CPU 是不是撞上了上下文切换或 IRQ。
6.3 验证 checklist:别把运气当成优化结果
我在优化完一轮之后,会走一个固定 checklist:
- 压测样本量是否足够,是否跑过至少 3 轮。
- 是否同时记录系统级指标:CPU 使用率、上下文切换、磁盘 IO、网络重传。
- 是否对比了 P50、P95、P99 和 P999,而不只是平均值。
- 是否考虑过噪声,比如压测机器上的杀毒扫描、云主机的邻居干扰。
- 是否用小流量灰度上线,用真实用户延迟数据做最终确认。
尤其在云环境里,CPU steal 和偶尔的网络抖动都很常见。一次压测跑得好,不代表部署到真实集群后表现一样。把几次压测结果都记录下来,如果中位数和长尾都稳定向下,那才是真实优化成果。
插件化、监控、可观测性这些内容,本质上都是为了回答同一个问题:时间到底花在哪里。你如果能把一次请求从“毫秒”拆到“微秒”这一层,再逐段验证优化,那性能问题的答案基本就藏不住了。