高性能服务CPU与内存优化:NUMA、伪共享、大页与容量规划 高性能服务这个话题一旦聊到CPU和内存很多人的第一反应是“加机器”。可真到了线上你会发现加机器这件事经常失效CPU使用率卡在40%上不去QPS也不涨或者内存看着还剩一半进程却被系统杀掉了。这类问题的根子往往不在业务逻辑而在你对CPU缓存、内存访问路径、页表、NUMA这些底层机制的默认假设。这一篇是我在做高并发服务过程中关于CPU和内存的实操笔记属于“高性能服务系列”的第二篇重点讲清楚三件事CPU的时间到底花在哪、内存的钱到底花得值不值、以及怎么用一套可复现的观测和改造流程把这两块资源真正压榨出来。内容偏工程落地适合已经能写出跑得通的服务、但还没搞明白“为什么它跑不快”的后端同学也适合正在做容量规划和压测的同学参考。文中涉及的参数和命令都是我在实际环境里验证过的口径不同硬件上数值会有差异但分析方法通用。1. 先把地图画清楚CPU与内存谁在拖后腿1.1 一次没有告警的性能滑坡我遇到过最典型的一次是一个接口的P99从18ms慢慢爬到60ms整个过程没有任何错误日志CPU使用率甚至还降了。这种“越优化越慢”的现象新手容易往代码逻辑上找实际上答案常常在硬件层面服务从一台机器迁到了另一台核心数更多的机器线程数没变但线程被调度到了不同的NUMA节点上每次访问全局哈希表都要跨节点取数据延迟从80ns变成160ns一段循环里访问上千次时间就这么被吃掉了。这件事让我形成了一个习惯任何性能问题先判断它是算得太多、等得太久还是搬得太远。算得太多是CPU指令层面的问题等得太久是同步和调度层面的问题搬得太远是内存层级和拓扑层面的问题。这三类问题的排查工具、改造手段完全不同混在一起谈就会变成玄学。1.2 分层心智模型把延迟当尺子做性能优化我建议大家脑子里常备一把尺子单位是纳秒。CPU执行一条简单指令大概0.3nsL1缓存命中约1ns4个周期左右L2约4nsL3在15到20ns之间跨socket访问远端内存可以到130ns以上。这些数字不是让你背而是让你在写代码时有个直觉一次随机内存访问的时间够CPU执行几百条指令。有了这把尺子很多事情就解释得通了。为什么链表遍历比数组慢不是因为链表“高级”而是因为数组能顺着缓存行预取链表每个节点都在赌一次L3 miss。为什么哈希表扩容会抖因为重新散列时所有key的访问模式从局部变成了全局随机。为什么同样的算法Go写的比C慢一点点除了GC还有对象布局导致的缓存行利用率差异。我一般会跟团队里新同学说把“一次内存访问等于几百条指令”这句话贴在显示器上能省掉一半的无效优化。1.3 观测口径统一先定义指标再谈优化优化之前一定要先把指标定义清楚否则你会陷入“改了之后感觉快了”的自我欺骗。我固定看这么几个数指标采集方式关注点IPC每周期指令数perf stat -e cycles,instructions低于1说明在等高于2说明算得密LLC-load-missesperf stat -e LLC-load-misses占比高说明工作集超过L3上下文切换pidstat -w -p PID 1非自愿切换多说明CPU争抢严重主缺页major fault/proc/PID/stat第12列持续增长说明内存不够或swap内存带宽pcm-memory或 STREAM接近理论上限说明该拆分了这几个指标的组合能覆盖八成的判断。IPC低加上LLC miss高基本可以锁定是访存模式的问题IPC高但QPS上不去多半是锁或者IO上下文切换飙升先去看线程池配置和锁竞争。注意perf在容器里默认可能拿不到硬件计数器需要宿主机放开权限否则你看到的一直是0很容易误判成“没有cache miss”。2. CPU侧把买来的核真正用满2.1 核数、频率与超线程的取舍账选机器时最容易被销售话术带偏的就是“核心数”。我的经验是计算密集型看频率和IPCIO密集型看核数混合型看单核性能和调度策略。一个32核2.4GHz的机器理论算力和16核3.5GHz差不太多但前者能扛更多并发连接后者单请求延迟更低。超线程这件事更需要实测。超线程本质是把一个物理核的两个执行上下文交替使用浮点运算密集的场景收益可能只有10%到15%甚至因为共享L1/L2而变负但如果是大量短连接、频繁上下文切换的场景收益能到30%。我一般会在压测阶段做两组对比开超线程和关超线程各跑一轮看P99和吞吐的变化而不是一刀切。还有一个常被忽略的点核多不等于并行度高。如果你的服务是单线程处理单个请求那么核数再多也只能靠多进程/多线程分摊而线程之间的锁竞争会让实际并行度远低于核数。判断方法很简单看pidstat -u里每个线程的%CPU如果总和远小于核数×100说明并行度没打上去。2.2 缓存层次与局部性代码写法的隐形税缓存这件事说到底就一句话让接下来的访问尽量落在刚刚访问过的数据附近。硬件预取器能识别顺序访问模式一次预取一整个缓存行64字节正好16个int32或者8个int64所以顺序遍历数组的效率远高于随机跳转。我做过一个很朴素的对比同样是一百万个结构体按字段求和。第一种是数组结构体AoS每个元素包含id、score、timestamp三个字段遍历时求和score第二种是结构体数组SoA三个字段各自一个数组。在数据量超过L3的规模下SoA的耗时大约是AoS的三分之一。原因不复杂AoS每个元素32字节求和一个字段却要把整行都拉进缓存有效带宽只有三分之一SoA只加载需要的字段缓存行里全是有效数据。这就是所谓的数据布局优化它不需要改算法不需要加机器纯粹是把内存排布调成硬件喜欢的样子。很多团队一上来就想着上SIMD、上多线程其实先把数据布局理顺收益往往更大。判断自己的工作集是否超过缓存也有简单办法看perf stat -e cache-references,cache-misses的比值如果miss率超过20%基本说明随机访问占比太高或者在处理的数据规模已经超出L3。这时候优化方向就很明确了。2.3 伪共享最容易被忽视的隐形锁伪共享是我见过最隐蔽的性能杀手。两个线程各自修改自己独立的变量逻辑上毫无关系但如果这两个变量落在同一个64字节缓存行里硬件一致性协议会让缓存行在核之间来回弹性能可能直接掉一半以上。我印象最深的一次是一个统计模块的计数器数组long counters[8]八个工作线程各写各的。压测时吞吐只有预期的40%perf c2c一看全在同一个缓存行上打架。改成每个计数器padding到64字节之后吞吐立刻回到正常水平。处理办法有三种我按推荐顺序排填充对齐结构体里加padding或者用alignas(64)、__attribute__((aligned(64)))强制缓存行对齐线程局部累加每个线程先写自己的局部变量定期汇总避免高频写共享内存分片把计数器拆成按CPU核编号的数组读的时候再求和。注意JDK里的Contended注解、Go里手动padding到64字节都是同一个思路。不要以为用了高级语言就躲得过去只要写共享变量伪共享就存在。2.4 分支预测与指令级并行现代CPU靠流水线吃饭而流水线最怕分支。一个预测失败的分支代价大约15到20个周期在热循环里出现就是灾难。我做过一个统计把一段按类型分发的switch改成查表加函数指针数组分支失败率从8%降到1%以下整体耗时下降约12%。当然查表也有代价如果表太大导致缓存miss反而更慢这就要权衡。另一个容易被低估的是无分支写法。比如求绝对值、求最大最小值、判断条件后赋值编译器往往能生成cmov指令比跳转更稳。但这也取决于编译器版本和优化级别最可靠的方式是压测对比而不是凭感觉。我一般的做法是先用perf stat -e branch-misses看看分支失败率如果低于2%就别在这上面花时间了如果超过5%再考虑重构热路径的分支结构。性能优化最怕的是在没有瓶颈的地方使劲。2.5 NUMA与绑核别让内存跑长途NUMA架构下每个CPU插槽有自己的内存控制器本地内存访问约80ns跨插槽访问要130到180ns。这个差距在单次访问上不明显但在高并发下会被放大成实实在在的吞吐差异。判断当前是否存在跨节点访问我最常用的命令是numactl --hardware # 看节点数、每节点核数和内存 numastat -p pid # 看进程在各个节点上的内存分布 numastat -m # 看全局如果numastat -p显示某个进程的NumaForeign很高说明它的内存大量分配在远端节点上每次访问都在跑长途。解决办法有两个一是用numactl --cpunodebind0 --membind0把进程和它的内存都锁在节点0二是对延迟敏感的服务直接把整个节点独占。对于线程绑核taskset -c 2-5 ./server是最简单的手段。绑核的好处是减少线程在核之间的迁移保住L1/L2的热数据坏处是失去了负载均衡能力如果绑定的核被其他进程抢占你的服务就只能干等。所以我一般只对延迟敏感的在线服务绑核离线批处理任务反而不绑。注意绑核之前一定要确认对应的核没有被系统进程或其他服务占用可以用mpstat -P ALL 1看一下各核的空闲情况否则绑核反而会把延迟抬高。2.6 上下文切换看不见的时间税一次上下文切换的直接开销大约2到5微秒看起来不多但如果你的服务每秒切换十万次那就是0.2到0.5秒的CPU时间被打包扔掉。更麻烦的是切换会打掉缓存热度恢复执行之后要重新把数据拉回L1/L2。排查方法很直接vmstat 1 # cs列看全局切换速率 pidstat -w -p pid 1 # cswch/s 自愿切换nvcswch/s 非自愿切换 grep ctxt /proc/pid/status # 累计值自愿切换多通常是锁等待、IO阻塞、条件变量唤醒非自愿切换多说明线程数远超可用核数或者在和其他进程抢CPU。我踩过的一个坑是线程池配了200个线程机器只有16核结果nvcswch/s飙到二十万大量的CPU时间花在调度上而不是干活上。把线程数降到核数的1到2倍IO等待多的可以再高一点之后吞吐反而涨了。这件事说明一个朴素的道理并发度高不等于吞吐高超过某个点之后纯粹是内耗。3. 内存侧从页表到分配器3.1 虚拟内存、页表与TLB每次访问内存CPU都要把虚拟地址翻译成物理地址这个翻译靠页表而页表本身存在内存里。为了不让翻译变成两次访存CPU里有TLB做缓存。L1 dTLB一般只有几十个条目L2 TLB在1500到2000条之间。按4KB页算L2 TLB最多覆盖6到8MB的地址空间而现在的服务动辄占用几个GB这意味着大量访问会TLB miss每次miss都要走一次页表遍历代价可能是十几个周期甚至更多。这也是大页存在的根本原因。一个2MB的大页一条TLB条目就能覆盖2MB空间同样的TLB容量覆盖范围扩大512倍。所以大页省的不是带宽而是TLB miss和页表遍历的开销。判断是否TLB压力大可以看perf stat -e dTLB-load-misses,dTLB-loads如果miss率超过1%就值得考虑大页了。3.2 大页省的是TLB不是带宽大页有两种用法。透明大页THP由内核自动管理配置在/sys/kernel/mm/transparent_hugepage/enabled取值always、madvise、never。我的经验是数据库类和内存分配频繁的服务把THP设成madvise让程序自己决定因为always会在内存碎片化时触发khugepaged后台合并造成不可预期的延迟抖动。有些团队遇到过每隔几分钟一次的长尾延迟最后查出来就是THP合并导致的。显式大页HugePages更可控需要预留# 预留1024个2MB大页即2GB echo 1024 /proc/sys/vm/nr_hugepages grep Huge /proc/meminfo然后程序通过mmap加MAP_HUGETLB标志或者JVM的-XX:UseLargePages来使用。好处是内存不会被换出、地址固定、TLB友好坏处是必须提前规划用不上就浪费了。注意大页预留之后普通进程是看不到这块内存的所以容量规划时要把这部分单独算进去否则会出现“free显示还有很多但服务申请不到内存”的困惑。3.3 分配器选型malloc、tcmalloc、jemalloc与池化glibc的malloc在多线程场景下一直有争议。它用arena来减少锁竞争但arena数量有上限默认是核数的8倍受MALLOC_ARENA_MAX控制超过之后线程还是要抢锁。表现就是高并发下malloc和free在perf top里排到前几名。我的选型思路大致是这样场景推荐方案理由短生命周期小对象高频分配jemalloc或tcmalloc线程本地缓存减少锁竞争大块固定大小对象自建对象池完全避开通用分配器开销内存敏感、要求可观测jemalloc带统计支持内存profile和泄漏定位简单脚本/低并发glibc malloc够用别过度设计切换分配器最省事的办法是LD_PRELOADLD_PRELOAD/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 ./server换完之后一定要用同样的压测脚本复跑我见过换了分配器之后RSS涨了30%的情况原因是jemalloc的size class对某些对象尺寸不够友好产生了内部碎片。所以换分配器不是无脑换要看对象尺寸分布。对象池是我最推荐的手段尤其是网络服务里的请求上下文、缓冲区、协议包。一次性申请一大块自己维护空闲链表用完归还而不是释放给系统。好处有三分配退化成指针操作、内存不会碎片化、RSS稳定可预测。代价是需要处理对象生命周期写错就是内存泄漏或者重复释放。3.4 内存带宽与通道配置很多人只关注内存容量忽略了带宽。单条DDR4-3200的理论带宽是25.6GB/s一台双路机器每路8个通道理论峰值能到200GB/s以上。但这是理论值实测用STREAM跑出来通常只有60%到75%。带宽成为瓶颈的典型信号是CPU使用率不高但吞吐上不去perf stat显示IPC正常用pcm-memory看到带宽利用率超过70%。这种情况下的优化方向不是加CPU而是减少内存流量。手段包括数据压缩后再处理、把热数据放进本地缓存、减少不必要的数据拷贝、把多次小访问合并成批量访问。我做过一次改造把逐条写入改成攒批写入内存带宽占用从75%降到35%吞吐直接翻倍。另一个容易忽略的点是内存通道配置不均衡。如果插内存条的时候没插满所有通道带宽会打折而且是不对称打折。上架前看一眼dmidecode -t memory确认每通道都插了是很划算的十分钟。3.5 RSS、VSZ、Page Cache与OOM这三个概念混起来的人太多了我简单说清楚VSZ虚拟内存包含已申请未使用的部分包括mmap的文件映射通常很大且没有参考价值RSS常驻内存是真正占用的物理页是你看内存的核心指标Page Cache是内核的文件缓存属于“可回收内存”free命令里的buff/cache就是它。OOM的常见误判是这样free显示可用内存很少以为要OOM了其实大部分是Page Cache内核在压力下会自动回收。真正要看的是/proc/meminfo里的MemAvailable这个值才是内核估算的“还能给新进程用多少”。排查内存泄漏我的流程是ps -o rss,vsz -p PID观察RSS是否单调增长cat /proc/PID/smaps_rollup看匿名页和文件页的占比用jemalloc的malloc_stats_print或者Go的pprof heap定位分配点对比GC前后RSS如果GC后RSS不降说明是真正的常驻增长而不是垃圾没回收。注意容器里的内存限制看的是cgroup不是宿主机的free。cat /sys/fs/cgroup/memory.maxcgroup v2或者memory.limit_in_bytesv1才是真实上限。我见过太多人盯着宿主机内存判断结果容器被OOM Killer干掉还一脸茫然。4. 一次完整的实操演练把指标从红拉回绿4.1 环境与压测准备为了避免纸上谈兵我把一次真实的调优过程完整梳理一遍。场景是一个内存型查询服务单机QPS上不去P99抖动明显。环境是双路机器每路16核关闭超线程内存每路4通道DDR4-2933服务用固定线程池32个工作线程。压测工具用固定并发200、持续5分钟的模型采集QPS、P50、P99、CPU使用率、RSS五个指标。基线数据是QPS 6200P99 78msCPU使用率58%RSS 6.8GB。第一轮perf stat的结果cycles 1.42e12 instructions 1.51e12 IPC 1.06 cache-misses 8.7% LLC-load-misses 31.2% context-switches 1.8e5/sIPC只有1.06LLC miss超过30%这两个数放一起基本可以断定瓶颈在访存而不是算力。CPU使用率58%却上不去说明有相当一部分周期在等内存。4.2 用perf找热点perf record -g -F 999 -p PID -- sleep 30之后看火焰图最上面几层是哈希查找和对象拷贝。哈希表的key是指针做二次散列时对指针取模访问模式完全随机每次基本都miss。perf c2c record的结果更有意思显示有大量HITMcache line被多个核修改说明存在共享写。顺着这条线索找到了统计模块果然是八个计数器挤在一个缓存行里。4.3 用numastat验证跨节点numastat -p 12345输出里节点1的NumaForeign占比达到62%也就是说超过一半的内存访问是跨节点的。原因是进程启动时没有做绑定内核把大部分内存分在了节点1而线程大部分跑在节点0。处理办法是启动脚本加一层绑定numactl --cpunodebind0 --membind0 -- ./server改完之后NumaForeign降到5%以下单这一项P99就从78ms降到61ms。4.4 代码改造从AoS到SoA加padding接下来动代码。原来的查询对象长这样struct record { uint64_t id; uint32_t type; uint32_t score; uint64_t ts; char name[32]; };一个结构体56字节扫描一万条记录只为了读score却要把56字节全部拉进缓存有效载荷只有4字节。改成列式之后uint64_t ids[N]; uint32_t types[N]; uint32_t scores[N]; uint64_t ts[N];扫描score时只加载4字节数组缓存行利用率从7%提到接近100%。这一项改完查询耗时下降约40%。统计计数器改成缓存行对齐struct alignas(64) counter { std::atomiclong value; char pad[64 - sizeof(std::atomiclong)]; };改完perf c2c里的HITM基本消失。4.5 参数落地绑核、大页与分配器参数层面做了三件事。第一把内存分配器换成jemalloc通过LD_PRELOAD加载MALLOC_CONF里打开background_thread:true让回收在后台线程做避免主线程卡在归还大块内存上。第二给服务预留256个2MB大页把主索引结构放进去dTLB-load-misses从1.8%降到0.2%。第三把32个工作线程分别绑到节点0的16个核上每个核两个线程一个计算一个IO等待避免线程漂移。这里有个细节值得说一开始我用--cpunodebind整体绑定结果节点1的16个核完全空闲CPU利用率不均衡。后来改成用taskset按线程分别绑让不同类型的工作线程落在不同的核上才把这个坑填上。整体绑定适合单进程独占整机的场景多线程服务要更细粒度一点。4.6 复测数据说话指标改造前改造后变化QPS62001140084%P9978ms26ms-67%CPU使用率58%74%16ptLLC-load-misses31.2%9.4%-70%RSS6.8GB7.4GB9%RSS涨了9%主要是jemalloc的size class和对象池预留导致的这个代价换来的是吞吐翻倍和延迟腰斩我完全能接受。如果内存更紧张可以调小对象池的预分配量或者用MALLOC_CONFdirty_decay_ms:1000加快归还。这套流程我后来又用在三个服务上收益幅度不同但方向一致。关键在于先测再改改完再测每一步只动一个变量否则你永远不知道是哪一项起了作用。5. 常见问题与排查速查5.1 现象、原因与命令对照表现象最可能的原因首查命令处理方向CPU高但QPS不涨忙等、锁自旋、无意义循环perf top -p PID定位热点函数去掉空转CPU低但延迟高等锁、等IO、跨NUMA访问pidstat -w、numastat减少争用绑核绑内存P99周期性抖动THP合并、GC、后台任务perf sched、GC日志关THP或改madviseRSS持续增长泄漏、缓存无上限、分配器碎片smaps_rollup、heap profile加淘汰策略换分配器频繁major fault内存不足、swap被使用vmstat 1的si/so列扩内存或减少常驻容器被OOM但宿主机内存充足cgroup限制cat /sys/fs/cgroup/memory.max调整limit或降RSS多核扩展性差伪共享、全局锁perf c2cpadding、分片、无锁化换机器后变慢NUMA拓扑变化、缓存大小变化lscpu、numactl --hardware重新绑定与容量评估5.2 踩过的坑与经验第一个坑是盲目相信CPU使用率。使用率是采样得到的采样周期内如果线程在等内存CPU就是“空闲”的但服务并没有变快。所以我更信IPC和LLC miss这两个数不会骗人。第二个坑是过早优化。有次我看到malloc在perf里排第三兴冲冲换了分配器结果总耗时只降了2%。原因是分配只占很小一部分真正的瓶颈在别处。后来我给自己定了个规矩热点占比低于5%的一律不动。第三个坑是忽略编译选项。同一份代码-O2和-O3的差别可能到15%尤其是循环展开和向量化。上线前一定要确认构建用的是发布配置别拿debug版本去压测。第四个坑是在容器里看错指标。容器的/proc/cpuinfo、free看到的都是宿主机视角真正的CPU配额在cgroup里。用cat /sys/fs/cgroup/cpu.max看配额用cpu.stat里的nr_throttled看是否被限流。这个数如果持续增长说明你的服务一直被掐着脖子跑。第五个坑是大页开了却没生效。JVM加了-XX:UseLargePages但没预留大页启动会直接失败或者悄悄回退到普通页。加参数之后一定要在日志里确认别假设它生效了。6. 容量规划把CPU和内存在上线前算清楚6.1 一个可复用的估算公式容量规划最怕拍脑袋。我给团队用的方法是从“每请求资源占用”反推公式不复杂但很实用。内存侧常驻内存 ≈ QPS × 单请求峰值持有量 × 平均持有时间 × (1 碎片系数) × 副本数举个具体例子。某服务QPS 8000单请求在内存中持有约50KB的中间结果平均持有3秒含等待下游响应碎片系数按1.3算双副本部署8000 × 50KB × 3 1.17GB 1.17GB × 1.3 1.52GB 1.52GB × 2 3.04GB再加上基础运行时语言runtime、连接池、索引缓存大约2GB单机需要5GB出头。实际给8GB留60%余量。这个余量不是浪费是给流量尖峰、GC、内存碎片留的缓冲。CPU侧所需核数 ≈ QPS × 单请求CPU时间 / 0.7单请求CPU时间要用压测数据算不能用感觉。比如QPS 8000、单请求CPU时间1.2ms需要约13.7个核除以0.7的利用率上限约20核。这里的0.7不是随便定的超过70%之后排队延迟会非线性上升P99会明显恶化。6.2 留多少余量、怎么验证余量这件事我的经验值是这样在线服务CPU留30%到40%内存留40%到50%。低于这个数一次流量尖峰或者一次GC就能把你打穿高于这个数成本又太浪费。但余量不是算完就完事一定要做验证。我一般会做两个测试一个是单机压到70%利用率观察P99是否还在SLO内另一个是模拟一台机器下线看剩余机器能否承接这时候单机利用率会到多少是否超限。第二个测试经常能暴露问题因为很多服务的容量是按“全部机器都在”算的一旦有一台挂掉剩下的立刻雪崩。还有一点值得提醒容量规划要跟着代码走。每次大版本上线如果核心路径的数据结构变了之前的估算就得重算。我见过一个服务把缓存从LRU换成全量加载单机内存从4GB涨到14GB规划文档还是老的结果上线当天就OOM了。我个人在实际操作中的体会是CPU和内存这两块最贵的成本从来不是硬件而是你不知道它在干什么。把perf、numastat、smaps这几个工具用熟把IPC、LLC miss、cgroup配额这几个数盯住很多看起来玄学的问题都会变成可解释、可复现、可修复的工程问题。至于优化顺序我的建议永远是先看数据布局再看并发模型最后才考虑加机器。前两步不花钱第三步花钱还未必有效。