
前阵子帮一个团队排查接口P95 一直在 80ms 上下波动代码里该做的缓存做了连接池也配了一群人折腾两天没有结果。最后发现根子不在业务代码而在每次请求都会重新走一次 DNS 解析而且解析结果完全没有缓存到进程内。把这一行改掉之后P95 直接掉到了 12ms。这个案例让我越来越确信一件事延迟优化不是玄学不是靠猜也不是堆中间件而是要把每一毫秒先拆开再拆成微秒去对账。这篇文章我就用这些年实际验证过的思路聊聊从网络链路、系统调度、内存分配到压测验证的微秒级性能优化适合后端、前端、安卓和游戏方向的性能工程师参考。1. 先把“毫秒”拆开延迟到底花在哪儿了很多团队做性能优化第一个动作就是翻代码这其实搞反了。真正应该先回答的问题是你的平均耗时可不可信你的 P99 是多少延迟是均匀分布还是长尾如果不回答这几个问题优化就变成了碰运气。1.1 先看分布再看平均数平均耗时是一个很有迷惑性的数字。同一套接口平均值 50msP99 可能是 500ms。用户感受最深的不是你一半请求跑得多快而是最差的那一批有多慢。玩游戏的时候尤其明显平均 30 帧不代表不卡只要有一帧掉到 200ms体感立刻变卡。所以做延迟优化第一个动作就是把指标从“平均值”换成“分布图”。常用的指标包括P50一半请求比这个值快代表典型体验。P95 / P99代表长尾体验往往隐藏着真正的性能 bug。P999极端抖动一般和 GC、锁竞争、网络重传、系统调度相关。直方图比单纯分位数更直观多条峰值往往意味着存在多种不同路径的慢请求。我个人的习惯是先把线上流量按照接口、机房、客户端版本分组各画一张耗时直方图。如果 P99 和 P50 差距超过 5 倍先别优化中位数去找长尾的来源。1.2 把延迟拆到每一层优化之前还需要建立一张“延迟地图”。我会先按请求链路拆时间段DNS 解析、TCP 握手、TLS 握手、服务端排队、业务计算、数据库访问、序列化、网络回传。每一段都要能掏出数字否则没有资格说哪一段慢。这张地图的底层是需要对常见操作的耗时数量级有直觉操作典型耗时CPU 寄存器访问约 1nsL1 / L2 缓存访问约 1ns - 10ns内存随机访问约 100ns系统调用 / 上下文切换约 1us - 10usNVMe 顺序读约 10us - 50usSSD 随机读约 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 breakdowncurl -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_namelookupDNS 解析耗时。如果在本地环境都超过 20ms说明解析链路有问题。time_connectTCP 三次握手完成时间。它包含 RTT同时也受系统 backlog 和队列影响。time_appconnectTLS 握手完成时间。TLS 1.2 通常要额外 2 个 RTTTLS 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 21 if errorlevel 1 ( echo 请右键“以管理员身份运行”本脚本 pause exit /b ) echo [1/5] 切换高性能电源计划... powercfg /setactive SCHEME_MIN echo [2/5] 刷新 DNS 缓存... ipconfig /flushdns nul 21 echo [3/5] 恢复 TCP 自动调谐为 normal... netsh interface tcp set global autotuninglevelnormal echo [4/5] 清理当前用户临时文件... if exist %TEMP% ( del /f /s /q %TEMP%\* nul 21 ) echo [5/5] 清理 Windows 临时文件... if exist C:\Windows\Temp ( del /f /s /q C:\Windows\Temp\* nul 21 ) echo 完成。建议重启一次再观察游戏内延迟。 pause这个脚本做的事情解释一下powercfg /setactive SCHEME_MIN切到高性能电源计划。这个对微秒级延迟是有真实影响的因为高性能模式会减少 CPU 进入深度睡眠的频率降低中断唤醒和频率切换带来的抖动。ipconfig /flushdns只是清空 DNS 缓存。如果 DNS 缓存没有坏执行它不会降低任何延迟如果缓存里有过期或错误的记录清掉之后反而能恢复。netsh interface tcp set global autotuninglevelnormal是恢复 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 数据库查询里的 N180ms 到 5ms 的经典案例N1 查询是后端最容易踩的延迟坑之一。一个循环里逐条查询数据库单个查询 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 至少要同时看只看平均值是性能优化里最常见的错误。一个服务平均 5msP999 可能是 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 和偶尔的网络抖动都很常见。一次压测跑得好不代表部署到真实集群后表现一样。把几次压测结果都记录下来如果中位数和长尾都稳定向下那才是真实优化成果。插件化、监控、可观测性这些内容本质上都是为了回答同一个问题时间到底花在哪里。你如果能把一次请求从“毫秒”拆到“微秒”这一层再逐段验证优化那性能问题的答案基本就藏不住了。