香港服务器中的 CentOS 7:我如何调教 CFS,把高并发下的 CPU 利用率榨干

“凌晨 2 点,香港荃湾机房第 3 排的那台服务器风扇声比地铁还响。Nginx 的 502 像烟花一样蹦出来,监控上 CPU 占用只有 55%——可延迟却炸了。我当时就知道,问题不在‘CPU 不够’,而在‘CPU 不会用’。第二天早上,我把咖啡放在机柜门口,开始了一场和 CFS(Completely Fair Scheduler)的较量。”
现场环境 & 问题画像
硬件(香港机房,机柜内实际部署)
- 机型:1U 定制服务器
- CPU:2 × Intel Xeon Silver 4210R(10C/20T)= 20 物理核 / 40 逻辑核
- 内存:128 GB DDR4(双路 NUMA,节点间延迟 ~85ns)
- 磁盘:2 × NVMe 1.92TB(RAID1)
- 网络:双口 10GbE(bonding active-backup)
系统与内核
- OS:CentOS 7.9(3.10.0-1160.*.el7.x86_64,红帽长期支持内核,带大量回溯补丁)
- 调度器:CFS(SCHED_OTHER),系统默认开启 irqbalance
业务
- 北向流量:Nginx + Envoy
- 计算:Java(Spring Boot)+ Go 微服务(大量短任务,QPS 峰值 > 12w/s,P99 延迟目标 < 20ms)
- 容器:部分进程以 systemd 管理,部分以 Docker 运行(默认 cgroup v1)
症状
- 峰值时 CPU 总利用率 ~55%–60%,但 run queue 层层排队,P95/P99 延迟抬头
- vmstat 可见 r(可运行队列)飙升,mpstat -P ALL 显示部分核被打爆而其余核闲着
- perf stat/pidstat -w 显示 cpu-migrations 偏高、context switches 频繁
直觉告诉我:任务在核之间来回搬家(迁移),缓存全废;CFS 的时间片与唤醒抢占策略,对这类“短小高密度任务”也不够友好。
先测一轮“基线”
# 基线度量(持续 5 分钟抽样)
lscpu
numactl --hardware
vmstat 1
mpstat -P ALL 1
pidstat -wtl 1
perf stat -e context-switches,cpu-migrations,sched:sched_switch -a --timeout 30000
基线摘录(峰值期)
| 指标 | 数值(基线) |
|---|---|
| CPU 总利用率 | 56% |
| run queue 平均(vmstat r) | 18–28 |
| context-switches | ~ 2.3M / 30s |
| cpu-migrations | ~ 420k / 30s |
| P95 / P99(业务) | 28ms / 65ms |
| 单核不均衡 | 个别逻辑核 > 95%,多数 30–40% |
CFS 关键点(CentOS 7 口径)
为了“对症下药”,我只盯与 迁移、时间片、唤醒抢占 相关的 CFS 调优点:
- kernel.sched_migration_cost_ns:迁移成本。越大越“护犊子”,任务更愿意留在当前 CPU,减少 cache miss。
- kernel.sched_autogroup_enabled:交互式自动分组。服务器上通常关掉,避免“人为组队”导致不必要的隔离。
- kernel.numa_balancing:自动 NUMA 负载均衡。对低延迟、固定核/内存亲和的服务,常关闭,控制跨节点迁移。
- kernel.sched_latency_ns:目标调度周期(任务少于阈值时),决定每轮公平调度的“总窗口”。
- kernel.sched_min_granularity_ns:最小时间片;小到一定程度会引发过度切换。
- kernel.sched_wakeup_granularity_ns:唤醒抢占阈值,新唤醒任务若“更紧急”,能否抢走 CPU。
另外两块:
- CFS 带宽控制(cgroup:cpu.cfs_quota_us/cpu.cfs_period_us)防止被“节流”;
- CPU 亲和/隔离(cpuset、isolcpus、nohz_full)减少跨核干扰。
调优策略一览(先给结论,再讲过程)
| 方向 | 项 | 取值(我最后落地) | 目的 |
|---|---|---|---|
| 迁移 | kernel.sched_migration_cost_ns |
500000 → 5000000 | 明显提高迁移成本,减少任务“串门” |
| 分组 | kernel.sched_autogroup_enabled |
0 | 服务器场景禁用自动分组 |
| NUMA | kernel.numa_balancing |
0 | 避免自动跨节点迁移 |
| 时间片 | kernel.sched_latency_ns |
24000000 → 12000000 | 将调度周期适度缩短,提升响应性 |
| 时间片下限 | kernel.sched_min_granularity_ns |
3000000 → 4500000 | 提高最小片,减少短任务过度切换 |
| 唤醒抢占 | kernel.sched_wakeup_granularity_ns |
4000000 → 8000000 | 降低“轻易抢占”,稳住在核执行 |
| CFS 带宽 | cpu.cfs_quota_us |
-1(不限)或充足配额 | 避免容器/服务被节流 |
| 亲和/隔离 | cpuset/isolcpus,nohz_full,rcu_nocbs |
绑定/隔离关键核 | 降干扰、提缓存命中 |
| 频率 | CPU governor | performance | 低延迟优先,锁频不降频 |
注:初始值可能随内核小版本略有差异;我的原始默认值如上左列(CentOS 7.9 常见情况)。
Step 0:安全网与回滚
所有变更写进独立文件:/etc/sysctl.d/90-cfs.conf,可一键回滚。
GRUB 变更写成单独条目,保留上一版内核与参数。
变更前后做 10–15 分钟 A/B 记录(相同负载),确保可对比。
Step 1:锁定 CPU 性能状态(别让频率来捣乱)
yum install -y kernel-tools tuned
cpupower frequency-info
# 切到性能模式
cpupower frequency-set -g performance
# tuned 保底
tuned-adm profile latency-performance
坑 1:在这台服务器 BIOS 里默认是“Balanced”。不改 BIOS,内核 governor 也可能被平台策略“拉回”。
处理:BIOS 设为 Performance,关 C 状态深度(或拉低),并确认 cpupower 输出稳定。
Step 2:把“乱跑”的任务摁住——迁移与自动分组
cat >/etc/sysctl.d/90-cfs.conf <<'EOF'
kernel.sched_migration_cost_ns = 5000000
kernel.sched_autogroup_enabled = 0
kernel.numa_balancing = 0
EOF
sysctl --system
为什么这么设?
- 我们的短任务(Go/Java 线程)命中 L1/L2 cache 很关键。一旦跨核/跨 NUMA,延迟飙升。
- 提高 sched_migration_cost_ns 后,CFS 更愿意让任务在原核继续跑,缓存“热起来”。
- 禁用 autogroup,避免 tty/会话“自动分家”,让 CFS 面向真实优先级和负载调度。
- 关闭自动 NUMA,配合 cpuset 手工定向(下一步)更可靠。
Step 3:时间片与唤醒抢占的再平衡
cat >>/etc/sysctl.d/90-cfs.conf <<'EOF'
kernel.sched_latency_ns = 12000000
kernel.sched_min_granularity_ns = 4500000
kernel.sched_wakeup_granularity_ns = 8000000
EOF
sysctl --system
取值思路(经验法)
我把 sched_latency_ns 从 24ms 拉到 12ms:对“并发中等但短任务密集”的场景,缩短调度窗口有助降低尾延迟。
min_granularity 提升到 4.5ms:避免被切得太碎(碎片化切换=上下文切换/缓存抖动)。
wakeup_granularity 设为 8ms:让“刚被唤醒的小任务”不那么容易抢走 CPU,提升在核连续度。
坑 2:把 sched_latency_ns 一味调小,反而会造成切换风暴;我试过 8ms,context-switches 直接高出 20%+。
折中:12ms + 4.5ms 的下限,在我们这批服务的曲线最平。
Step 4:CFS 带宽(cgroup)——别被“无形的限流器”卡喉咙
容器或 systemd slice 下,若 cpu.cfs_quota_us 太小,会出现节流(throttling),表现为“CPU 看似不高,但延迟高”。
排查
# 容器
docker inspect <container> | jq '.[0].HostConfig.NanoCpus, .[0].HostConfig.CPUQuota, .[0].HostConfig.CPUPeriod'
# 或直接看 cgroup
cat /sys/fs/cgroup/cpu/docker/<id>/cpu.stat
# systemd slice
systemctl show <svc>.service | egrep 'CPUQuotaPerSecUSec|CPUAccounting'
处理
对核心服务:放开限流 CPUQuotaPerSecUSec=0(systemd)或 Docker 配置 --cpus/--cpu-quota=-1。
若必须限流:把 cpu.cfs_period_us 保持默认 100000,cpu.cfs_quota_us ≥ period × 逻辑核数 的 0.8–1.0;或给稳定余量(例如 40 线程给 380000–400000)。
Step 5:亲和与隔离——给关键线程“保留车道”
方案 A:cpuset(在线变更,风险低)
# 预留 NUMA0 的 8 个逻辑核给网关与网卡中断
mkdir -p /sys/fs/cgroup/cpuset/gateway
echo 0 > /sys/fs/cgroup/cpuset/gateway/cpuset.mems
echo 0-7 > /sys/fs/cgroup/cpuset/gateway/cpuset.cpus
echo 1 > /sys/fs/cgroup/cpuset/gateway/cpuset.cpu_exclusive
# 迁移进程(示例:envoy)
echo $(pidof envoy) > /sys/fs/cgroup/cpuset/gateway/tasks
同理为计算服务划出 16–24 个逻辑核,保持 同 NUMA 节点。
方案 B:内核隔离(需改 GRUB,风险高但收益稳定)
/etc/default/grub 增加:
GRUB_CMDLINE_LINUX="... isolcpus=8-31 nohz_full=8-31 rcu_nocbs=8-31"
然后:
grub2-mkconfig -o /boot/grub2/grub.cfg
reboot
把 8–31 号核“拉出”一般调度循环(user-space 负载独享),与 cpuset 配合使用。
nohz_full 减少时钟中断打扰,rcu_nocbs 把 RCU 回调移出这些核。
坑 3:隔离过多核导致系统守护/中断找不到“落脚点”。务必留一组核给系统与中断(例如 0–7)。
坑 4:irqbalance 会把 NIC 中断均匀打到所有核,和隔离策略打架。解决:保留 irqbalance,但配置 IRQ affinity(/proc/irq/*/smp_affinity_list)只落在“系统核”。
Step 6:系统服务与应用的落位
systemd 服务绑定(示例:Java 网关)
/etc/systemd/system/gateway.service.d/cpu.conf:
[Service]
CPUAffinity=0-7
TasksMax=infinity
Nice=-5
重载:
systemctl daemon-reload && systemctl restart gateway
Java/Go 应用
Java:-XX:ActiveProcessorCount=<核数> 配合亲和;线程池核心数 = 逻辑核 × 0.8–1.2,避免 2×核数的盲目配置。
Go:GOMAXPROCS 与绑定核一致,压测下微调。
验证与对比(真实采样数据)
同一时段同一压测负载(15 分钟),仅开启上文调优项
| 指标 | 基线 | 调优后 | 变化 |
|---|---|---|---|
| CPU 总利用率 | 56% | 81% | ↑ 25pp |
| run queue(vmstat r) | 18–28 | 9–14 | ↓ ~45% |
| context-switches(/30s) | ~2.3M | ~1.7M | ↓ 26% |
| cpu-migrations(/30s) | ~420k | ~130k | ↓ 69% |
| P95 / P99(ms) | 28 / 65 | 17 / 31 | P95 ↓ 39%,P99 ↓ 52% |
| 单核不均衡 | 明显 | 显著改善 | - |
抽样命令
pidstat -wtl 1 | awk '{if($1 ~ /^[0-9]+$/) print}' | head
perf stat -e context-switches,cpu-migrations -a --timeout 60000
mpstat -P ALL 1 60
现象非常直观:迁移次数大幅下降,缓存命中率稳定;队列长度回落,尾延迟显著改善,CPU 使用率被“榨出来”了。
现场遇到的坑 & 处理手记
Docker 默认 CPU 配置“悄悄限流”
现象:容器内 cpu.stat 出现 nr_throttled 快速累加。
处理:为核心容器取消配额或把 quota 提到足够(如 400000us/100000us≈4 核等效),并统一到 cpuset 管控。
自动 NUMA 迁移“帮倒忙”
现象:numastat 看到远端内存访问增多,P99 上扬。
处理:kernel.numa_balancing=0 + cpuset 固定在同一节点;必要时使用 numactl --membind 启动应用。
irqbalance 与隔离核冲突
现象:被隔离核仍出现中断负载。
处理:为关键网卡手工设置 smp_affinity_list 到 0–3(系统核),irqbalance 仍开但不碰隔离范围。
时间片调太小导致“切换风暴”
现象:context-switches 飙升,P95 反而变差。
处理:把 sched_latency_ns 拉回 12ms,min_granularity 提高到 4.5ms,wakeup_granularity 提升到 8ms,稳定了。
混用 RT/Deadline 线程影响 CFS
现象:某组件擅自把线程设为 SCHED_RR,CFS 任务饥饿。
处理:统一禁止 RT 策略(除音视频/硬实时),systemd 单元 CPUSchedulingPolicy= 保持默认;必要时把该类线程也定向到系统核。
运维落地清单(可直接复用)
/etc/sysctl.d/90-cfs.conf
kernel.sched_migration_cost_ns = 5000000
kernel.sched_autogroup_enabled = 0
kernel.numa_balancing = 0
kernel.sched_latency_ns = 12000000
kernel.sched_min_granularity_ns = 4500000
kernel.sched_wakeup_granularity_ns = 8000000
GRUB(可选,高级隔离)
GRUB_CMDLINE_LINUX="... isolcpus=8-31 nohz_full=8-31 rcu_nocbs=8-31"
systemd(示例)
/etc/systemd/system/gateway.service.d/cpu.conf
[Service]
CPUAffinity=0-7
TasksMax=infinity
Nice=-5
容器(示例)
# 计算服务容器:不节流 + 绑定核(与 cpuset 搭配)
docker run --cpuset-cpus="8-31" --cpu-quota=-1 ...
观测(放在自动化脚本里)
vmstat 1 | ts
mpstat -P ALL 1 | ts
perf stat -e context-switches,cpu-migrations -a --timeout 60000 | ts
cat /proc/schedstat | head -n 20
适用性与风险提示
本文调优 针对“短任务高并发 + 低延迟敏感”的在线服务。
批处理/吞吐优先型(如大批量离线计算),min_granularity 未必需要拉高,甚至可以增大 sched_latency_ns 来提高吞吐。
GRUB 隔离属侵入式操作,需变更窗口 + 回滚预案。
NUMA 绑定若与内存压力不匹配,可能触发 OOM/kswapd 抖动,务必结合 numastat 和 sar -B 观察。
结尾:那一杯凉掉的咖啡
“那天晚上 10 点,我在机房外的过道坐了很久。监控曲线像被熨斗抚平,P99 从 60 多毫秒落到 30 出头。那杯咖啡已经凉透,但我知道,CFS 这道‘隐形的墙’被推开了一道缝。
后来新业务再上量,我不再一上来就加机器,而是先看迁移、时间片、亲和与配额。我更确信:CPU 不止要多,更要会用。”
TL;DR(复盘要点)
- 先量化:迁移、上下文切换、run queue、P95/P99。
- 控迁移:sched_migration_cost_ns=5ms,关 autogroup 与自动 NUMA。
- 稳时间片:latency=12ms、min_granularity=4.5ms、wakeup=8ms。
- 查节流:CFS 带宽别卡喉咙。
- 亲和/隔离:给关键线程“专用车道”。
- 结果验证与回滚预案不可少。
如果你也在香港机房盯着同样的图,这套打法值得一试;当然,参数不是教条,数据才是你最好的指南针。