第 11 章 全链路综合故事——一次点击,30 秒里的微观全景
前面十章,我们每层都单独刨过:源码怎么变进程(02)、虚拟地址怎么走 MMU(03)、系统调用怎么进内核(04)、调度器怎么挑线程(05)、malloc 怎么空头合同(06)、page cache 怎么撒谎(07)、块层怎么把请求排进盘(08)、网卡怎么中断叫醒 CPU(09)、TCP 怎么握手怎么流(10)。 但你工作里遇到的故障,从来不分章。一次”页面卡了 2 秒”,可能是网卡丢包、可能是 accept 队列满、可能是 CFS 把线程饿死、可能是 malloc 触发 direct reclaim、可能是脏页写回卡住、可能是 TCP 重传。 这一章,我们把它们串成一条时间线:你手指按下按钮,到页面刷新出来,中间那 30 秒里,每一毫秒都发生了什么;以及”卡 2 秒”到底卡在链路的哪一段。这是全教程最像”实战排障”的一章。
一、先立一个铁规矩:链路是分段的,故障也分段
墨:老哥,先别急着串。我问你一个最关键的问题:为什么”懂微观”才谈得上优化?
你:因为不知道哪段慢,就乱调,越调越乱?
墨:对,但更精确一点——因为每一条链路都有”队列 + 溢出信号 + 逃生阀”,而它们彼此独立。 你不知道链路分段,就不知道”卡 2 秒”是卡在 A 段还是 B 段,于是你对着 B 段的参数调了半天,A 段纹丝不动。这就是”盲目调参”的本质:在错误的段上,拧错误的阀。
墨:所以这一章的方法论只有一句:把”卡”拆成”在哪一段卡的”,每一段都有它自己的显微镜。 下面这条时间线,我每过一段就标一个”🔬 显微镜”——那是你该拿什么工具去盯这一段。
二、一条完整时间线:从手指到刷新
场景:你在浏览器点”提交订单”,对面是 Go 写的网关 + 后端,走 HTTP/1.1 短连接。RTT 同机房约 1ms。你感觉”卡了约 2 秒”。
T0 浏览器侧(你感知不到的 30ms)
你按鼠标 → 浏览器事件循环 → 构造 HTTP 请求 → 调 socket send()
- 浏览器先查 DNS(可能命中本地缓存,也可能
getaddrinfo走系统解析器,慢的话能卡几百 ms——这是应用/解析层,不在内核协议栈范围,但常背锅)。 - 若无可用连接,
connect()触发三次握手(第 10 章):syn 队列 → accept 队列 → 你的accept()。 - 🔬 显微镜:浏览器 DevTools 的 Network 面板看
Stalled/DNS/Initial connection各占多少。这一段卡,问题在客户端或握手队列(第 10 章)。
T1 请求包出门:你的发送路径(内核协议栈)
send() 拷贝进 TCP 发送缓冲 → TCP 分段/编号/算校验和 → 受 min(rwnd,cwnd) 限
→ IP 层查路由/封装 → 邻居子系统查 MAC(ARP) → 驱动进 tx ring → DMA 到网卡
- 若 Nagle 开着且你在发小包(第 10 章 4.2),可能等 ACK 才发下一个,撞上延迟确认互锁 → 每轮多 40ms。
- 若发送缓冲满(
tcp_wmem上限 + autotuning 撑到顶,且对端窗口为 0),send()阻塞,你的写线程卡住。 - 🔬 显微镜:
ss -tin看对端rwnd、本地cwnd、retrans(重传数)、rtt;tcpdump看是否小包互锁。卡在这 → 第 10 章 TCP 缓冲/Nagle。
T2 包上路:网线与中间设备
网卡 DMA 发出 → 交换机 → (可能 GRE/IPsec 隧道,MTU 变化) → 对面网卡
- 若中间某段 MTU 比你小且 PMTU 被墙(第 10 章事故二),大段静默丢弃 + TCP 重传,重传率 5% 那种。
- 若链路真丢包,ICMP 0 丢包但 TCP 重传飙——又是第 10 章信号。
- 🔬 显微镜:
mtr/ping看 RTT 与 ICMP 丢包;tcpdump两端对比看”哪些段到了哪些没到”。卡在这 → 第 10 章 MTU/重传/第 09 章链路。
T3 对面网卡收包:中断与软中断(第 09 章)
包 DMA 进内存 → 网卡发中断 → 上半部极快收尾 → 下半部 softirq 处理
→ NAPI 在轮询/中断间自适应 → 协议栈逐层解包 → 进 socket 接收缓冲
- 若多队列没开,单核
si打满(第 09 章事故:32 核 12% CPU 丢包)→ 吞吐上不去、丢包。 - 若
netdev_budget/netdev_max_backlog不够,/proc/net/softnet_stat第三列涨 → softnet 丢包。 - 🔬 显微镜:
top看si单列是否 100%;ethtool -l看 combined 队列;cat /proc/net/softnet_stat看丢弃。卡在这 → 第 09 章。
T4 网关 accept:两级队列(第 10 章)
包解到 TCP → 若第三次 ACK 且 accept 队列有空 → 连接进 accept 队列
→ 你的 Go runtime 的 acceptor 线程 accept() 取走 → 交给 worker
- 若
somaxconn太小(默认 128)+ worker 被占,accept 队列满(第 10 章事故一)→ 客户端连不上/超时。 - 🔬 显微镜:
ss -lnt看Recv-Q是否顶Send-Q。卡在这 → 第 10 章队列。
T5 调度器挑线程:CFS / 实时(第 05 章)
worker 线程被唤醒 → 调度器决定它上哪个 CPU、何时上 → 上下文切换
- 若 worker 在 cgroup 里 CPU 配额被限(第 05 章 CFS Throttling),
nr_throttled涨,线程被”冻”几十 ms~几秒 → 请求处理延迟毛刺。凌晨定时任务抢配额的典型。 - 若
nice被打低、或被实时进程(看门狗/irq)挤,普通 worker 饿死。 - 🔬 显微镜:
/proc/sched_debug看nr_throttled;perf sched看唤醒延迟;top看wa/si。卡在这 → 第 05 章。
T6 处理请求:进内核干活(第 04 章)
worker 调 read() 收请求体 → 系统调用进内核(第 04 章)
→ 可能涉及 futex 抢锁(高并发下 futex 竞争是隐形 CPU 杀手)
strace -c若看到大量futex,说明锁竞争(第 04 章事故)。- 若
max_map_count或RLIMIT_NOFILE触顶,open()/mmap()失败 → 连接建立后处理直接报错。 - 🔬 显微镜:
strace -c -p <pid>看 syscall 分布;cat /proc/<pid>/limits看 nofile。卡在这 → 第 04 章。
T7 查缓存 / 分配内存(第 06 章)
业务逻辑:先查本地缓存(可能命中 page cache 或应用内 map)
→ 若 miss,要 malloc 一块内存装结果 / 或读盘
malloc是空头合同(第 06 章):平时不真给,访问时才缺页;但若 overcommit 之外物理也紧,触发 direct reclaim(同步回收内存,卡住线程几百 ms~几秒)→ 延迟毛刺。- 若 glibc arena 碎片化(
MALLOC_ARENA_MAX没限),多线程下 arena 暴涨吃光内存 → OOM 连环杀(第 06 章事故)。 - 应用内缓存若用
map+ 引用,触 Page Fault(第 03 章),TLB miss 也会小卡。 - 🔬 显微镜:
vmstat 1看si/so(swap 进出)、pgscan/pgsteal、allocstall(直接回收次数);cat /proc/buddyinfo看碎片。卡在这 → 第 06 章。
T8 可能读盘:page cache → 块层(第 07、08 章)
查缓存 miss → 内核查 page cache(第 07 章)→ 若不在,生成读请求
→ 进块层:bio → 请求队列 → IO 调度器 → 驱动 → 磁盘(第 08 章)
- 若 page cache 命中,纳秒~微秒级,无盘参与——这是”为什么加缓存快”的微观真相。
- 若 miss 且盘是机械盘,寻道 8~15ms/次(第 08 章),随机读能卡到几十 ms。
- 若写响应要落盘且调用
fsync(第 07 章事故:journal 连带 300ms 卡顿),一次 fsync 可能卡几百 ms。 - 若脏页积压到阈值(第 06/07 章 dirty_*),后台写回或被前台
sync阻塞。 - 🔬 显微镜:
iostat -x 1看%util/await/aqu-sz/r_await;cachestat(bpftrace)看 page cache 命中率。卡在这 → 第 07、08 章。
T9 写回响应:原路返回
业务拼好响应 → send() 进 TCP 发送缓冲 → 同 T1~T3 反向 → 经网卡发出
→ 对面浏览器收包 → 解 HTTP → 渲染刷新
- 响应大 → 又受
cwnd/rwnd/Nagle/bufferbloat 影响(第 10 章)。 - 若对面接收窗口小或 BBR 没开且链路 bufferbloat,延迟飙(第 10 章 5.4)。
- 🔬 显微镜:同 T1,
ss -tin看cwnd/rtt/retrans。
T10 浏览器渲染(你看到刷新)
收到完整响应 → 解析 DOM/CSS/JS → 重绘 → 你看到页面刷新
- 这一段的”卡”通常是前端问题(JS 执行、大图解码),不在本教程内核范围,但常被误算进”后端慢”。务必用 DevTools 把
Content Download之后的时间剥掉。
三、把”卡 2 秒”拆开:一张决策树
墨:现在你有了完整时间线。来,实战:用户报”提交订单卡了 2 秒”。你第一件事不是猜,是拆段。给你一张决策树,每一跳都对应前面某章的显微镜:
"卡 2 秒" 从哪段来?
│
├─ 浏览器 Network 面板:"Initial connection" 久?
│ → 握手/队列问题 → 第 10 章(syn/accept 队列、somaxconn、tcp_abort_on_overflow)
│
├─ "Stalled" 久(请求发出前空等)?
│ → DNS 解析 / 连接池耗尽 / 应用 accept 慢 → 第 10 章队列 + 第 04 章 fd 上限
│
├─ 服务端收到请求但处理慢(ss -tin 看 rtt 正常、retrans 低)?
│ → 进服务端内部:
│ ├─ 调度毛刺:调度器把 worker 冻了? → 第 05 章(nr_throttled、cgroup 配额)
│ ├─ 锁竞争:strace 看到 futex 海量? → 第 04 章
│ ├─ 内存回收:vmstat allocstall 涨、si/so 动? → 第 06 章(direct reclaim、swap)
│ ├─ 读盘:await 高、cache 命中率低? → 第 07/08 章(page cache、IO 调度器)
│ └─ fsync 卡:写响应前落盘? → 第 07 章(journal 连带)
│
├─ 传输中丢/重传(ss -tin retrans 高,但 ping 正常)?
│ → MTU 黑洞 / TCP 重传 → 第 10 章(tcp_mtu_probing、PMTU)
│
└─ 吞吐上不去但 CPU 闲(top si 单列 100%)?
→ 网卡队列/软中断 → 第 09 章(combined 队列、netdev_budget)
墨:看见没?同一句”卡 2 秒”,可能落在 7 个不同的章节、对应 7 套完全不同的显微镜和逃生阀。 你不分段,就只能”都调一点”——结果就是线上流传的那句玩笑:“重启解决 90% 问题,剩下 10% 靠调 tcp_rmem”。
你:等等,为什么说”调 tcp_rmem”是玩笑?
墨:因为 tcp_rmem(第 10 章)只管”接收窗口能开多大”,只在长肥管道且接收缓冲是瓶颈时有效。你”卡 2 秒”要真是 accept 队列满(somaxconn 128),调 tcp_rmem 一万个字节都救不了——门都不对。这就是”懂微观才谈得上优化”的全部含义:先定位门,再拧对应的阀。
四、一个综合事故的完整迭代环(把前面全用上)
真实事故改写自某电商大促:把 02~10 章的机制一次性串进诊断环。
4.1 现象
大促开始 5 分钟,订单接口 P99 从 80ms 飙到 2.1s,但 CPU 总利用率才 35%,内存也没满,磁盘 %util 看着正常。所有人都懵:资源都没满,凭什么慢?
4.2 第一推断(推错):网络/网卡
你:先查网卡,top 看 si——结果 si 才 8%,不是第 09 章那种单核打满。排除网卡软中断。
再 ss -tin 看 retrans——重传率 0.1%,正常,排除第 10 章 MTU/重传。
又 iostat 看 await——机械盘但命中缓存,await 低,排除第 08 章读盘。
4.3 第二推断(推错):以为是 GC
你:Go 服务,先怀疑 GC STW。看 GOGC 和 pprof —— STW 才 0.3ms,根本不是 2s 的来源。排除应用层 GC。
4.4 第三推断(命中一半):调度毛刺
你:vmstat 1 看到 procs r(运行队列)偶尔堆到 30+,且 /proc/sched_debug 里 nr_throttled 在涨。怀疑 CFS Throttling(第 05 章)——订单服务被放进了一个 CPU 配额偏紧的 cgroup,大促流量一来,throttling 把 worker 冻住。
调整:放宽 cgroup CPU 配额(cpu.cfs_quota_us 上调)。观察:P99 从 2.1s 降到 0.9s,好了大半但还慢。
4.5 第四推断(命中根因):accept 队列 + direct reclaim 叠加
你:还剩 0.9s。继续拆——ss -lnt 看订单服务 Recv-Q 偶尔顶到 Send-Q(somaxconn 默认 128,大促连接建立洪峰时 accept 队列满,第 10 章)。同时 vmstat 看 allocstall(直接内存回收)在波动——业务高峰期 malloc 触发 direct reclaim,线程卡几百 ms(第 06 章)。
调整:
net.core.somaxconn=65535+ 应用listenbacklog 跟上(治 accept 队列);vm.overcommit_memory=1+ 预留vm.min_free_kbytes略增 + 限制MALLOC_ARENA_MAX=4(治 direct reclaim 与 arena 碎片,第 06 章);- 大促前预热连接池与 page cache(减少冷启动缺页与读盘,第 03/07 章)。
观察:P99 回到 85ms,与平日持平。五次推断,三次推错,最后拼成”cgroup 配额 + accept 队列 + direct reclaim”三件叠加。
墨:这个事故最有教益的地方:它不是某一章的题,是 05 + 06 + 10 三章的题撞一起。任何单独一章的知识都只能解三分之一。这就是”全链路”的意义——你脑子里得有一张地图,知道”卡”可能落在哪几段,然后用各自的显微镜去证真/证伪,而不是赌。
五、回到最初的问题:程序到底”经过什么”
墨:还记得你最开始的那句话吗——“顺带介绍一下程序是怎么运行的,要经过什么,创造一个故事来讲。” 我们走了 11 章,现在能给你一句总账:
一个程序的字节,从你的 send() 到对面 recv(),经过的不只是”网络”,而是:
- 用户态(你的代码、glibc 的 malloc 空头合同、缓冲)——第 04、06 章
- 系统调用墙(syscall 指令、上下文切换、特权级)——第 04 章
- 虚拟内存(MMU、页表、TLB、缺页、COW)——第 03 章
- 调度器(CFS vruntime、抢占、cgroup 配额、throttling)——第 05 章
- 内存管理(buddy/slab、水位线、direct reclaim、swap、OOM)——第 06 章
- 文件系统(VFS、inode、page cache、fsync、journal)——第 07 章
- 块层(bio、队列、IO 调度器、驱动)——第 08 章
- 中断与软中断(网卡叫醒 CPU、NAPI、softirq)——第 09 章
- 网络栈(TCP 握手、窗口、拥塞、重传、TIME_WAIT)——第 10 章
九层,每一层都有自己的队列、自己的溢出信号、自己的逃生阀。所谓”懂微观”,就是你知道这九层分别长什么样、各自在哪报警、各自拧哪个阀。
你:那接下来呢?参数还没系统整理完吧?
墨:对。故事线到这告一段落(第 02~11 章是”程序运行微观故事”)。接下来我们要把系统参数家族单独分类整理——/proc/sys/vm 每个参数含义与默认值、net/fs/kernel 各家族、以及 /sys/cgroup、ulimit 这些”长得像但不是 sysctl”的对照。然后是一堆真实诊断案例集,每个走完整迭代环。最后给工具箱(perf/ftrace/bpftrace/vmstat/iostat/sar)和 benchmark 陷阱。
六、🔧 思考题(都配参考答案)
思考题 1(基础): “卡 2 秒”的故障,为什么不能上来就调 net.ipv4.tcp_rmem?请用一个本章时间线的具体段说明”门不对”的后果。
【参考答案】
因为 tcp_rmem 只影响”TCP 接收窗口能开多大”,只在长肥管道且接收缓冲是瓶颈时有效。若”卡 2 秒”实际是 accept 队列满(somaxconn=128,第 10 章 T4 段)或 CFS Throttling 把 worker 冻住(第 05 章 T5 段),调 tcp_rmem 一万个字节都救不了——门不对,阀拧了也白拧。这正是”懂微观才谈得上优化”的核心:先分段定位”卡在哪一段”,再动该段的逃生阀。盲目调参 = 在错误的链路段上拧错误的阀。
思考题 2(深入): 大促订单 P99 从 80ms 飙到 2.1s,CPU 总利用率 35%、内存没满、磁盘 await 正常。请按本章决策树,列出你会依次排查的 4 个段、各自的显微镜命令、以及若命中对应的逃生阀。
【参考答案】
依次排查(自顶向下,每段用显微镜证真/证伪):
- 握手/队列段(第 10 章):
ss -lnt看 accept 队列Recv-Q/Send-Q、netstat -s看 listen overflow → 命中则somaxconn、应用 backlog、tcp_abort_on_overflow。 - 调度段(第 05 章):
/proc/sched_debug看nr_throttled、vmstat看procs r堆积 → 命中则放宽 cgroupcpu.cfs_quota_us、查nice/实时进程。 - 内存段(第 06 章):
vmstat 1看allocstall(直接回收)、si/so(swap)、cat /proc/buddyinfo碎片 → 命中则overcommit_memory、min_free_kbytes、MALLOC_ARENA_MAX。 - 网卡/软中段(第 09 章):
top看si单列、/proc/net/softnet_stat第三列 → 命中则开多队列ethtool -L、调netdev_budget。
注意:综合事故里往往是多段叠加(如本题 cgroup + accept 队列 + direct reclaim 三件),要逐段排除、各治各的,不能指望一个阀解全局。
思考题 3(机制串联): 为什么说”page cache 命中”是”加缓存快”的微观真相?请从第 03 章(MMU/缺页)和第 07 章(page cache)解释一次”读一条已缓存的数据”在硬件上走了什么。
【参考答案】
应用 read() 一条数据,内核先在 page cache(物理页构成的 RADIX 树,按 inode+offset 索引)里查:若命中,数据已在内存的某物理页,直接 copy_to_user 拷给用户缓冲,全程不碰磁盘。这个物理页在内核页表里已有映射,用户态访问时(第 03 章)若 TLB 命中则直接拿到物理地址(纳秒级),仅 TLB miss 时才走 MMU 查页表(仍远快于磁盘)。对比 cache miss:要生成 bio → 块层排队 → 机械盘寻道 815ms。所以”加缓存快”的微观真相是把”磁盘毫秒级随机访问”换成”内存纳秒级拷贝 + TLB 命中”——差 45 个数量级。cachestat(bpftrace)能直接看命中率,比 iostat 的 await 更接近”业务感知的快慢”。
思考题 4(进阶总账): 综合第 02~11 章,画一张”程序运行九层”的极简地图(每层一句话:它管什么 + 一个典型故障信号 + 一个对应参数/工具),作为你自己的排障速查卡。
【参考答案】
九层速查卡(从上到下):
- 用户态/glibc:malloc 空头合同、缓冲 → 信号:RSS 暴涨/OOM →
MALLOC_ARENA_MAX - 系统调用墙:syscall/上下文切换/futex → 信号:
strace见海量 futex →max_map_count/RLIMIT_NOFILE - 虚拟内存/MMU:页表/TLB/缺页 → 信号:大量 minor/major fault →
vm.min_free_kbytes(关联回收) - 调度器:CFS vruntime/抢占/cgroup → 信号:
nr_throttled涨 →cpu.cfs_quota_us/sched_latency_ns - 内存管理:buddy/slab/水位/reclaim/swap/OOM → 信号:
allocstall/si,so→overcommit_*/swappiness/min_free_kbytes - 文件系统/page cache:VFS/inode/dentry/fsync → 信号:fsync 卡、命中率低 →
dirty_*/noatime/vfs_cache_pressure - 块层:bio/队列/调度器 → 信号:
await/aqu-sz高 →nr_requests/io.max(cgroup) - 中断/软中断:网卡叫醒/NAPI/softirq → 信号:
si单列 100%/softnet 丢 →ethtool -L/netdev_budget - 网络栈 TCP:握手/窗口/拥塞/重传/TW → 信号:listen overflow/retrans →
somaxconn/tcp_*家族
心法:每层”队列 + 溢出信号 + 逃生阀”三件套独立,排障 = 逐层用显微镜证真/证伪,定位门再拧阀。
实战补遗一:个 POST 写请求从网卡到磁盘的”延迟账本”
墨:第 11 章前面把”卡 2 秒”拆成了决策树。这一节我们换个角度——不从一个故障出发,而是顺着一个正常的写请求,把它的每一跳都标上”这一跳典型延迟是多少、哪一层的 sysctl 能在这里咬你”。等你有了这张”正常账本”,将来任何一跳超了标,你立刻知道去哪章翻哪把扳手。
📦 我们以"客户端 POST 一个 4KB 的 JSON,服务端把它写进磁盘日志文件"为例,从网卡的 PHY 层一路追到磁盘的磁头。
11.x.1 第一跳:网卡收包(NIC → 内存),微秒级
包从网线进 NIC,DMA 直接写入内核环形缓冲区(内存),不经过 CPU。这一步的延迟是硬件 + 总线决定的(纳秒~微秒)。但注意第 09、14 章的坑:如果 vm.min_free_kbytes 太小,原子分配(skb)失败→网卡丢包,这一跳直接丢,你连”请求到了”都不知道。ethtool -S 看 rx_*_errors、/proc/net/softnet_stat 第三列看软中断丢。
11.x.2 第二跳:软中断 + 协议栈(NAPI → IP → TCP),几微秒~几十微秒
NIC 触发中断,CPU 进**软中断(softirq)**跑 NAPI 收包,逐层解包到 TCP,把数据放进 socket 接收缓冲区。这一步的瓶颈常是”单核软中断打满”(第 09 章)——net.core.netdev_max_backlog/netdev_budget、RPS/XPS、多队列在这里发力。延迟通常几微秒到几十微秒,但单核 si 100% 时会排队到毫秒级。
11.x.3 第三跳:系统调用 read() 把数据从内核搬到用户态,微秒~亚毫秒
用户态服务 recv()/read() 系统调用,内核把 socket 缓冲区的数据 copy 到用户缓冲区(第 04 章的上下文切换 + 拷贝)。一次 4KB 拷贝 + 一次陷入内核,约几微秒;但若此时发生 page fault / 调度抢占 / 直接内存回收,可能跳到亚毫秒甚至毫秒(第 18 章幽灵卡顿同源)。
11.x.4 第四跳:应用处理(你的 Go/Java 代码),微秒~毫秒
JSON 解析、业务逻辑。这一步是你自己的事,但它会被前面任何一跳的毛刺放大——比如第三跳因为 direct reclaim 卡了 5ms,这一跳的 P99 就跟着抖。
11.x.5 第五跳:write() 写日志文件,微秒(平时)→ 百毫秒(触雷时)
应用调用 write(fd, logline)。关键点:write 默认写进 page cache 就返回”成功”(第 07 章),这一步平时只要几微秒——数据还在内存,没碰盘。但有两个雷区:
- 雷区 A(
vm.dirty_ratio前台 sync):如果脏页已占vm.dirty_ratio比例,这个write会被阻塞,亲自把脏页刷盘,卡几百 ms~秒(第 12、14 章)。日志服务在脏页触顶时,“写一行日志”能卡几百毫秒,肉眼可见的卡顿。 - 雷区 B(
vfs_cache_pressure/ inode 分配):如果日志是新文件,write要先分配 inode,若 slab(dentry/inode 缓存)紧张,分配也可能卡(第 22 章)。
11.x.6 第六跳:page cache → 块层 → 磁盘(异步,后台),毫秒~百毫秒
之后内核的 writeback 线程(或 pdflush/kjournald)在后台把脏页经 块层(第 08 章)队列、调度器、合并 写进磁盘。这一步对本次请求是异步的——请求早返回了。但它决定了”宕机丢多少脏数据”(第 12、13 章 dirty_ratio 的赌注),也决定了磁盘 %util(第 20 章)。
11.x.7 把账本收成一张表
| 跳 | 路径 | 正常延迟 | 哪个家族的参数能在这里咬你 |
|---|---|---|---|
| 1 | NIC→内存(DMA) | 微秒 | vm.min_free_kbytes(原子分配丢包,第14章) |
| 2 | 软中断+协议栈 | 几~几十 μs | net.core.*、netdev_*、RPS(第09/15章) |
| 3 | read 系统调用 | 微秒~亚ms | overcommit/watermark(direct reclaim 卡,第12/18章) |
| 4 | 应用处理 | 微秒~ms | (你自己的代码) |
| 5 | write 进 page cache | 微秒(平时)/百ms(触雷) | vm.dirty_ratio(前台sync)、vfs_cache_pressure(第12/22章) |
| 6 | page cache→磁盘(异步) | ms~百ms | vm.dirty_*、/sys/block队列、IO调度(第08/12/20章) |
墨:这张表就是全书的”微观地图”的具象版。任何一次”卡 2 秒”,都落在这六跳的某一跳或某几跳的叠加上。第 11 章前面的决策树,本质就是”对着这张表,逐跳排除”。而每一次”参数咬你”,都精确对应表里”哪个家族”那一列——这正是你前 26 章学的所有东西的用武之地。
11.x.8 用账本反查一个真事故
📦 案例:某日志服务"写一行日志偶尔卡 300ms~1s",所有人以为是网络(第 21 章方向),但客户端超时和服务器写日志时间对不上。
用账本逐跳排除:跳 1/2(ethtool -S、softnet_stat)正常,跳 3(read 不慢)正常,跳 4 应用逻辑没瓶颈;跳 5(write)对不上——strace -e write -T 抓到 write() 本身耗时 400ms+。再看 vm.dirty_ratio=20(默认),日志突发写时脏页触顶,这个 write 被前台 sync 冻住(雷区 A)。调 dirty_ratio=10 + dirty_background_ratio=5(第 12 章画像 A 组合)后,write 长尾消失。复盘:账本让你”跳过所有不相关的跳”,一击命中第五跳;而”网络的锅”只是因为客户端超时和服务器卡写的时间窗口偶合,误导了所有人。这就是第 11 章这张表值钱的地方——它把”猜故障”变成”按跳查故障”。
实战补遗二:次”间歇式 2 秒超时”的全链路账本归类
墨:第 11 章你见过一个 POST 写请求的延迟账本。现在给你一个间歇式的:每天 10:00 准时一波 2 秒超时,平时 20ms。你拿”全链路账本”思路,会先钉哪一级?
你:定时任务呗,10:00 有 cron。但它在哪一级造成 2 秒?
墨:对,是 cron 引发的连锁,但要定位”2 秒卡在链路的哪一跳”。我带你把账本拆开——这正是把第 2~10 章全用上的时刻。
现象
每天 10:00,订单服务的写接口 P99 从 20ms 跳到 2000ms,持续约 90 秒,然后恢复。其他时间正常。
全链路账本逐级钉
- **应用层:**10:00 业务 QPS 没涨(不是请求量),但处理耗时 spike → 排除”请求多”,怀疑”被某资源卡”。
- syscall 层:
strace抽样本服务的write/fsync,发现 10:00 起fsync耗时从 1ms 飙到 1.5s → 卡在文件系统/IO。 - 块层:
iostat看那块盘await从 1ms 到 80ms、aqu-sz堆到 30 → 磁盘在排队。 - **中断/软中断:**无关,这是写盘。
- **网络层:**写请求回包走网络,但卡在
fsync之前,网络不是主因。 - **调度:**10:00 有 cron 跑
updatedb(遍历全盘建 locate 库)+ 一个日志压缩任务,它们把磁盘 IO 打满,订单服务的fsync排队 → 2 秒。
结论
账本归到”块层 IO 被 cron 的批量扫描挤占”,根因是 10:00 的 updatedb/logrotate 没限 IO。治本:cgroup io.max 或 ionice 给批量任务限速,或错峰;把订单服务的数据盘和扫描盘分离(呼应第 8/17 章)。
再观察
给 updatedb 套 ionice -c 3 + cgroup io.max 限速后,10:00 订单 P99 回到 25ms。
改进(权衡:拿什么换什么)
| 动作 | 拿什么 | 换什么 |
|---|---|---|
批量任务 io.max 限速 | 扫描变慢 | 不影响在线服务 |
| 数据盘/扫描盘分离 | 硬件规划 | 物理隔离 IO |
🔧 思考题:为什么”间歇式”超时比”持续慢”更难查,又更好查?
【参考答案】
难在它不在你观察的窗口里(你 10:01 看一切正常);好在你有”规律时间点”这个强线索——持续慢得全链路盲扫,间歇式只需对比”发作窗口 vs 正常窗口”的差值,账本里哪一级在窗口期异常一眼可见(本例 fsync/await 只在 10:00 涨)。所以查间歇式:先抓发作时间,再在发作窗口内采各层级指标,和基线一减就定位。这正是一张”延迟账本”的价值——让”卡在哪一级”从玄学变减法。
实战补遗三:次”读多写少”查询变慢,用全链路账本归到 page cache 命中率
墨:你个读多写少的查询服务,某索引文件查询 P99 从 5ms 涨到 80ms,但 CPU、网络、磁盘 %util 都正常。你第一反应会去哪一级?
你:都正常那肯定是网络或锁?或者查慢 SQL?
墨:都不在那些级。账本走下来,卡在”文件/cache 级”的 page cache 命中率——索引没常驻 cache,每次查询都去磁盘读,%util 看着不高(因为单次读小、但次数巨多),await 却堆着。这是读路径的 cache 故事,和第 11 章写路径账本正好成对。
现象:读查询 P99 抖,但”磁盘没满”
查询服务,索引文件 20G,机器 64G。平时 P99 5ms(fincore 看索引 95% 在 page cache)。某天发布后 P99 涨到 80ms,iostat %util 才 15%、await 却从 0.2ms 到 3ms。运维看 %util 低,以为”磁盘闲”,没查 IO。
推断(可能推错):先查慢查询和锁
第一推断:「SQL/查询计划变慢。」——同一个查询计划,排除。
第二推断:「锁竞争。」——perf 无锁热点,排除。
都推错,因为没看读路径的 cache 命中率——%util 低掩盖了”大量小随机读在排队”。
真相:账本归到”文件/cache 级”的 cache miss
全链路账本逐级:
- 应用层(处理快)→ syscall(
vfs_read耗时占比高,命中)→ 调度/CPU(无)→ 文件/cache(vfs_read等 page cache 未命中,命中)→ 块层(iostatr/s暴涨、await3ms,排队)→ 中断/网络(无)。
根因:fincore/vmtouch 一看,索引文件 cache 命中率从 95% 掉到 40%——发布时 drop_caches(又是那个 cron!第 0 章)或内存被别的进程挤掉,索引没常驻,查询全变”读盘”,小随机读次数巨多 → await 堆、%util 却不高(单次 IO 小)。
调整:让热索引常驻 cache
- 发布后预热:
posix_fadvise(fd, 0, 0, POSIX_FADV_WILLNEED)或vmtouch -t把索引摸进 cache; - 给查询服务 cgroup
memory.low保命(不被别的服务挤掉 cache,第 17/19 章); - 查
cachestat命中率纳入监控,掉就告警; - 别再用
drop_cachescron(第 0 章)。
再观察
发布流程加 vmtouch -t <索引> 预热 + 服务 memory.low 兜底后,命中率回 95%,P99 回 5ms。
改进(权衡:拿什么换什么)
| 动作 | 拿什么 | 换什么 |
|---|---|---|
| 预热索引进 cache | 启动时多读一遍 | 查询不读盘 |
memory.low 护 cache | 内存预留 | 不被邻居挤掉 |
🔧 思考题:为什么”读多写少”服务对 page cache 命中率如此敏感,而”写多”服务相对不敏感?
【参考答案】
读多写少的查询,每次读都指望 cache 命中——命中就从内存拿(亚 ms),miss 就去磁盘(ms 级,差百倍)。命中率从 95% 掉到 40%,意味着 55% 的读从”内存”变”磁盘”,P99 直接被磁盘延迟拖垮,且小随机读巨多会把 await 堆高(虽 %util 低)。写多服务呢?它主要走脏页回写(第 18 章),读占比小,cache 命中率对整体延迟影响没那么大,瓶颈常在”脏页触顶前台 sync”而非 cache miss。所以 cache 命中率是读路径的命门,监控它比看 %util 更早暴露读服务的真实瓶颈——这又把全链路账本收进了一个具体指标:vfs_read 慢 + cache 命中率低 = 读路径的”该常驻没常驻”。和写路径账本(第 11 章实战补遗)合起来,你就有了一张覆盖读写两端的延迟归因图。
实战补遗四:次跨机房慢请求的”九级反查”实战
墨:前面讲了九级地图(应用→VFS→页缓存→文件系统→块层→调度器→驱动→驱动队列→硬件)。纸上看很清爽,真出事时你不一定想得起来从哪一级下手。给你走一遍真实的反查,你就知道这地图怎么用。
现象:一个跨机房调用(北京→上海)的 P99 延迟,从平时的 8ms 突然飙到 300ms,但上海机房本地测试很快,北京机房本地也快——只有”跨”的时候慢。两边基础设施团队互相甩锅。
你:跨机房慢,第一反应不是网络链路的问题吗?
墨:常识是对的,但”网络”是个筐,啥都往里装。我们按九级地图自顶向下、用证据排除,而不是猜。
第 1 级(应用):看调用链 trace,慢在”发起请求”到”收到响应”之间,排除应用自身逻辑(本地直接 curl 也慢)。 第 2–4 级(文件系统/VFS/页缓存):跨机房 RPC 不涉及本地磁盘 IO,这三级与本次无关,跳过。但这步很关键——知道哪些级不相关,比知道哪级相关更省时间。 第 5–6 级(块层/调度器):同样不涉及本地块设备,跳过。 第 7–8 级(驱动/驱动队列):也不涉及。
所以九级地图直接把”锅”指向第 7 级之外的网络栈,但网络栈内部还要细分:tcp 重传?conntrack?MTU?路由?
用证据,不靠猜:
ss -ti看这条连接的retrans(重传)计数 → 在涨!说明确实有丢包导致重传。ping跨机房 → 延迟正常 3ms、无丢包。诶?ping(ICMP)不丢,但 TCP 重传?这是 MTU 黑洞的经典信号。tracepath 上海IP→ 发现某跳 MTU 突然从 1500 变成 1390,且设置了DF(不分片)。TCP 大包(1460 字节)过这跳因超 MTU 且不让分片被静默丢弃,只能等重传超时后发小包——这就是 P99 飙高的根因。
推断验证:开 net.ipv4.tcp_mtu_probing=1(允许 TCP 主动探测 PMTU 并缩小报文),或临时 ip route add ... advmss 1300。再观察:重传计数归零,P99 回到 10ms 内。
你:那如果 ss -ti 看 retrans 没涨、但延迟还是高呢?
墨:那就要往九级地图的”另一侧”想——不是发包丢,是收包慢。可能是:
- 接收端
tcp窗口(rwnd)被打满(应用消费慢,recv buffer 不读)→ss -ti看rwnd趋近于 0; - 或接收端软中断(
ksoftirqd)CPU 打满,包收上来但处理不过来 → 看/proc/softirqs的NET_RX和top里si列; - 或中间有 conntrack 表满导致新建连接慢(回到第 7 级网络栈的 netfilter 子系统)。
这就是九级地图的价值:它不替你定位具体 bug,但它逼你逐层枚举、用工具拿证据排除,而不是在”网络不好”这种结论上打转。每一级你都能对应一个具体工具(ss/iostat/cat /proc/softirqs/blktrace),把”感觉”变成”数字”。
🔧 思考题:这次事故里,九级地图帮我们”排除了”哪些层?如果当时没有这张地图,最可能卡在哪一步?
【参考答案】
被九级地图直接排除的层:第 2–4 级(文件系统/VFS/页缓存)、第 5–6 级(块层/IO 调度器)、第 7–8 级(块设备驱动/驱动队列)。因为跨机房 RPC 请求本身不落本地磁盘、不走本地块设备,这些层与本次延迟无关。把”无关层”快速划掉,剩下的怀疑面就只剩网络栈内部,定位效率翻倍。
没有地图时最可能卡在:在”网络不好”这个结论上空转,反复找链路、找交换机、找带宽,却没想到 ICMP 通而 TCP 重传这个关键分野指向 MTU 黑洞。很多人会浪费数小时抓两端 tcpdump 比对”请求到了没”,却忘了先看本机 ss -ti 的 retrans 和 tracepath 的 MTU 跳变——这两步只要一分钟,直接指向 PMTU 问题。
地图的本质是检查清单(checklist):它把”凭经验蒙”变成”按层过”,确保你不会漏掉某一层,也不会在错误的层上耗太久。这正呼应第一章”先测量后调优”——每一级都对应一个可测的量。
实战补遗五:写放大跨层追踪——应用写 1KB,落盘 100KB 的”幽灵账单”
墨:前面用九级地图反查过”跨机房慢”(网络层)。这次反查一个更隐蔽的:应用只写了 1KB,为什么磁盘写了 100KB? 这账从哪一层开始算假?
你:这得层层算吧?应用写 1KB,到文件系统可能变 4K(块对齐),到块层又…但 100 倍也太夸张了。
墨:100 倍听着夸张,但”日志 + 复制 + COW 文件系统”叠起来真能到。讲个真实反查。
一个配置中心,每次”改一个配置项”(业务层约 1KB 数据),监控看磁盘写入 80~120KB/次,盘 IOPS 被这种”小改”吃满,其他服务受拖累。按九级地图逐层拆:
- 第 1 级(应用):业务只
Write1KB 到文件。应用层”诚实”。 - 第 2–4 级(VFS/页缓存/文件系统):配置中心用了 append-only 日志 + 定期 compaction(类似 Kafka/RocksDB)。每次改 1KB,除了写这 1KB 到日志,还触发:① 索引更新(另写几 KB)② 定时 compaction 把整段日志重写(摊销到每次约几十 KB)③ 文件系统是 btrfs/COW 文件系统,每次写都 Copy-On-Write,元数据也要写——这里翻了好倍。
- 第 5–6 级(块层/调度器):COW 的随机写让块层没法合并,且文件系统 4K 块对齐,小写给成 4K 整块(×4)。
- 第 7–9 级(驱动/队列/硬件):SSD 写放大(上一节讲的,小随机写触发块擦除,×8)。
把每层放大系数列出来:应用 1KB × 索引/compaction(×30) × COW元数据(×2) × 块对齐(×4) × SSD放大(×8) ≈ 接近 100KB。每一层单独看”只翻几倍”,九层叠加就 100 倍——这就是”幽灵账单”:没有哪一层在撒谎,但每层都在”加一点”,最后总账吓人。
推断验证:用 bpftrace(第 25 章)挂 tracepoint:block:block_rq_issue,按 PID 统计实际块层写入字节,确认”应用写 1KB、块层写 100KB”;再用 filetop(bpftrace 工具)看这个进程触发了哪些文件的写,定位到 compaction + COW 是主因。
调整:① 换非 COW 文件系统(如 ext4/xfs)或给配置目录关 COW(btrfs 子卷 nodatacow),去掉 COW 元数据放大;② 减少 compaction 频率 / 用批量提交(攒 100 个改再落盘,把 1KB×100 的合成一次大块写,放大骤降);③ 确认 SSD 写放大已按上一节优化。再观察:每次改配置的磁盘写入从 100KB 降到 5KB,盘 IOPS 释放,其他服务不再被拖累。
你:这账”应用层看 1KB、磁盘看 100KB”,谁该背锅?监控该看哪层?
墨:没人”背锅”,是多层机制叠加的必然。关键是监控要看”对的那层”:
- 应用层监控(
Write字节)只反映业务逻辑量,不代表真实 IO 负担——拿它做容量规划会严重低估; - 正确:看块层实际写入(
iostat的wkB/s/bpftrace block_rq_issue按进程)和文件系统层(filetop看哪些文件在写),这两层才是”机器真在干多少活”。
这事故教会我们一个排障心法:“幽灵账单”类问题(看着小、实际大)必须用九级地图从应用层一路算到硬件层,逐层乘放大系数——任何一层单独看都合理,叠加才离谱。和”跨机房慢”那次一样,地图不是替你定位 bug,是逼你把”总账”拆成”每层的小账”,哪层放大异常一眼看出。本书反复讲的”从需求倒推原理、逐层枚举”,在这类跨层问题上是唯一靠谱的方法。
🔧 思考题:如果你的服务也吃”写放大”的亏,但没法换文件系统(云上托管盘),你能在应用层做哪三件事把放大压下来?
【参考答案】
换不了底层,就在应用层治”源头”:
- 批量提交:把 N 次小改攒成 1 次大块写(如日志缓冲、配置批量落盘)。放大系数从”每写都付元数据/对齐/SSD 开销”变成”N 次摊一份”,N=100 时放大直接砍 100 倍。最立竿见影。
- 顺序写优先:用 LSM-tree 类引擎(RocksDB/LevelDB)或自建 WAL+顺序追加,把随机小写转成顺序大写——顺序写在块层能合并、SSD 放大低(上一节讲的,顺序写放大从 100x 降到 ~10x)。
- 减少 fsync 频率:每次
fsync强制落盘,不仅慢还打断批量。按业务容忍度降低 fsync 频率(如攒一批再fdatasync),或组提交(group commit)让多次写共用一次刷盘。代价是崩溃可能丢最近一批——用”能重算/可补发”的业务设计兜底。
外加一条:监控盯块层真实写入(iostat/bpftrace),别信应用层 Write 字节数做容量规划。这三件事不碰文件系统、纯应用层,就能把”100KB 幽灵账单”压到接近真实业务量。口诀:底层改不了,就从”减少触发次数 + 改随机为顺序 + 减少强制落盘”三处掐源头——放大是被”次数”和”随机度”放大的,治这两点就够。
实战补遗六:gRPC 超时连锁与重试风暴——一个 1s 超时如何把下游打挂
墨:老哥,你调过 gRPC 的超时吗?context.WithTimeout(ctx, time.Second) 这种。
你:设过,客户端调下游超过 1 秒就取消。
墨:取消是对的。但我要问你:取消之后发生了什么? 如果下游没真的停,或者你又重试了——故事就变了。我拆过一次”超时设了反而雪崩”的事故。
现象:网关 A 调订单服务 B(gRPC,1s 超时),B 调库存服务 C(也是 gRPC,800ms 超时)。某天 C 慢了(数据库锁),B 在 800ms 取消对 C 的调用;但 A 的 1s 超时还没到,A 以为”再试一次可能就通了”,重试了 3 次。结果 C 被重试流量打爆,彻底雪崩,连带 B、A 全挂。
推断:以为是 C 自己扛不住。
可能推错:C 慢是诱因,但真正的放大器是重试风暴。A 重试 3 次,B 因为 A 的每个请求又各自调 C 也重试 3 次,乘法效应——1 个用户请求变成 9 个打到 C。C 本来就慢,被 9 倍流量冲垮。超时设得太”自信”(1s 太长、重试太积极),反而把局部慢扩散成全局挂。
调整:先量”取消是否真的传播”。gRPC 的 Cancel 会发 RST_STREAM 给对端,理论上 B 收到后应立即停对 C 的调用。用 grpcurl + 服务端日志看 B 是否真的在 A 取消后停了 C——发现了第二个坑:B 用的是”fire-and-forget”模式,A 取消的 context 没往下传,B 仍在傻等 C 的 800ms 才返回,资源白白占着。
再观察:链路里每一跳的超时必须大于下游,否则下游还在算、上游已经cancel,制造大量无效在途请求。正确是 A(1s) > B(800ms) > C(600ms),且取消信号(context)必须透传。更要命的是重试:没有”熔断+退避”的裸重试,在下游已慢时等于往火里浇油。
改进:
- 超时金字塔:上游超时 > 下游超时,留余量。
context全链路透传,取消即停。- 重试必须带:上限(最多 2 次)+ 退避(指数 backoff)+ 熔断(下游错误率高时直接不放行,见第 11 章链路篇)。
- 用
circuitbreaker(如 go 的gobreaker)在 C 慢时让 B 直接快速失败,而不是重试放大。
🔧 思考题:为什么”下游已经很慢了,你还重试”是最危险的?重试要满足什么前提才安全?如果非要重试,怎样避免把流量放大 9 倍?
【参考答案】
下游慢时重试危险,是因为慢本身就在消耗下游稀缺资源(连接、CPU、锁)。你重试 = 在它最虚弱时再砸一波请求,而且因为是分布式调用链,每一跳各自重试会指数放大(N 跳 × M 次重试 = N^M 量级打到底层),这就是重试风暴/重试放大(retry amplification),经典 case 是 2008 年 AWS S3 的 cascading failure。
安全重试的前提:
- 幂等:下游能扛住重复请求(读、或带幂等键的写)。非幂等写重试会重复扣款。
- 下游健康:只在”瞬时错误”(超时/5xx 抖动)时重试,下游已返回”永久错误”或熔断打开时绝不重试。
- 有退避:指数 backoff + jitter,别瞬间并发重试把下游冲平。
- 有上限 + 熔断:单请求最多 2 次;全局错误率超阈值就熔断,整条链路快速失败。
避免 9 倍放大:关键在只在链路最顶端重试一次,下游不重试(下游失败就向上抛,由最外层统一重试),且重试次数 M 和跳数 N 解耦——即”每跳重试”改成”端到端重试”。配合 hedged requests(同时发两个、取快的那个、取消慢的)能在不明显放大流量的前提下降尾延迟,但实现要小心取消传播。
延伸:这类问题本质是第 11 章”全链路延迟账本”的放大版——时间花在哪不仅是性能问题,更是稳定性问题。超时/重试/熔断是一套,单设超时不设熔断就是埋雷。
实战补遗七:缓存击穿与热点 key——一个爆款把数据库打挂的全链路透视
墨:老哥,你服务有缓存(Redis)吧?有没有过”缓存里一个 key 过期,瞬间几万请求打到 DB”?
你:缓存击穿?加互斥锁或者逻辑过期就行。
墨:对,但我想带你用”全链路延迟账本”的视角看这次事故——它和前面讲的超时/重试/队列是同一张地图的不同格子。这节把缓存击穿讲成”微观运行”案例,补第 11 章综合故事。
现象:一个商品详情接口,99% 流量走 Redis 缓存,某爆款商品缓存 TTL 到点的一瞬间,2 万 QPS 同时发现缓存 miss,全扑向 MySQL 查同一行,MySQL CPU 瞬间 100%、连接池耗尽,接口全线超时,连带其他商品的查询也跟着挂(连接池被占满)。
推断:是 DB 扛不住,加从库/加连接池。
可能推错:加从库/连接池是”扩容扛量”,但根因是”同一时刻大量请求绕过缓存打同一行”(缓存击穿),加 DB 容量只能缓解、不能治本,且爆款流量放大时还是会被打挂。更糟的是:这次超时叠加了第 11 章讲的”重试风暴”——客户端超时重试,把 2 万打向 DB 的请求放大成 6 万,DB 死得更透。
调整:用全链路地图定位”时间花在哪”:
- Redis 层:同一 key 集中 miss,是缓存设计问题(单 key 热点 + 同时失效)。
- 应用层:
ss -tni看到 2 万连接同时write突增打向 MySQL(连接建立+查询),连接池max太小被占满,后续请求在应用侧排队等连接(应用线程阻塞)。 - DB 层:慢查询日志里同一 SQL 出现 2 万次/秒,CPU 100%,
Threads_running暴涨。
再观察:解法分层(对应地图各层):
- 应用层互斥(singleflight):用 Go 的
golang.org/x/sync/singleflight,让”同一 key 的并发回源”只放一个请求去 DB,其余等待它的结果——2 万并发 miss 变 1 个 DB 查询,击穿瞬间消失。这是最准的解(在”回源”这一跳做合并)。 - 缓存层逻辑过期:热点 key 不设物理 TTL,后台异步刷新,永不在”过期瞬间”集体 miss。
- 随机过期时间:TTL 加随机抖动,避免大量 key 同一时刻失效(防”缓存雪崩”,比击穿更宽的场景)。
- DB 兜底:连池够大 + 熔断(DB 慢时快速失败,别让请求堆连接池占满)。
改进:
- “同一 key 集中 miss”用 singleflight 合并回源(应用层第 6 级治本)。
- 热点 key 用逻辑过期/互斥锁,别物理 TTL 齐失效。
- 雪崩(大量 key 同时失效)用随机 TTL 抖动分散。
- 配合第 11 章的”超时金字塔 + 重试熔断”,避免击穿瞬间叠加重试风暴放大成 DB 死亡。
- 监控 Redis
miss率突增 + DBThreads_running突增,做”击穿早期预警”。
🔧 思考题:singleflight(合并回源)和”加 DB 连接池/从库”是治标还是治本?为什么”请求合并”比”扩容扛量”在击穿场景更对?如果并发 miss 的是不同 key(不是同一个),singleflight 还有用吗?
【参考答案】
治标 vs 治本:
-
“加 DB 连接池/从库/扩容”是扩容扛量——它假设”流量就是会打到 DB,我把 DB 能力提上去”。对普通高流量是对的,但对”击穿”是错的:击穿的本质是”本不该打到 DB 的流量(缓存该挡的)因为瞬时 miss 全漏下来了”,这是流量路由错误,不是”DB 能力不够”。你扩容 DB 到能扛 2 万并发查同一行,下次爆款 20 万还是打挂,且平时这 2 万请求走 Redis 根本不该碰 DB——扩容是浪费且无效的治标。
-
singleflight(合并回源)是治本:它在”回源这一跳”把 N 个并发的”查同一 key”合并成 1 个 DB 查询,其余 N-1 个直接复用结果。DB 只看到 1 个查询,击穿从根上消失,且不依赖 DB 扩容。这是”从流量路径上消除不该存在的请求”,比”把不该存在的请求扛住”正确得多。对应全链路地图:问题出在”缓存 miss 后回源”这一格,就在这一格做合并,而不是在下游 DB 格硬扛。
不同 key 并发 miss,singleflight 还有用吗:singleflight 的 key 是”回源请求的标识”。如果你按”缓存 key”做 singleflight(常见做法),那么不同 key 是不同的 singleflight 组,互不影响——每个 key 各自合并自己的并发,不同 key 之间不合并。所以”不同 key 并发 miss”时,singleflight 对每个 key 内部仍有效(如 100 个不同 key 各 200 并发,变成 100 个 DB 查询而非 2 万),但它不跨 key 合并(也没必要,不同 key 查不同行,DB 本就该查 100 次)。所以 singleflight 对”同 key 击穿”是银弹,对”大量不同 key 同时 miss”只能缓解(把每 key 并发压成 1,但 key 数不变)——后者要靠”逻辑过期/预热/随机 TTL”避免集体 miss,而非 singleflight。
一句话:击穿(同 key 集中 miss)= singleflight 治本;雪崩(大量 key 同时失效)= 随机 TTL/逻辑过期治本;二者都配合”超时金字塔+熔断”防止叠加重试风暴。这跟第 11 章全链路故事一脉相承——缓存层、应用层、DB 层是同一张地图的相邻格子,在哪格出问题就在哪格治,别一股脑推给最底层 DB。
下一章预告
墨:故事线讲完了(第 02~11 章)。接下来进入系统参数家族专章(第 12 章起):把 /proc/sys/vm 下的每个参数——含义、默认值、调大调小的后果——一个个钉死。这是你最早点名要的:“/proc/sys/vm 这下面很多参数,介绍在什么场景怎么用。”
第 12 章我们不从 admin_reserve_kbytes 这种冷门参数讲起,而是先建立 /proc/sys/vm 的总地图:哪些参数是”安全阀”(溢出才生效)、哪些是”日常旋钮”(持续影响)、哪些是”泄洪阀”(宁可错杀)。然后逐个展开:overcommit 三兄弟、水位线三件套、脏页四件套、swap/swappiness、NUMA/zone_reclaim、碎片/compact、drop_caches 的正经用法。每个都配”什么场景、拿什么换什么”。
七、卡 2 秒的分段决策树(文字版 + 检索)
墨:第 11 章前面给了”卡 2 秒”的树状图。这里用纯文字再描一遍,方便你搜索定位,也方便你照着改造成自己服务的排查 SOP:
- 先看”是单请求慢还是全机同时卡”:单请求慢→你代码/SQL/锁;全机同时卡→内核暂停类(第 18 章五兄弟)。
- 全机同时卡,看
vmstat:si/so跳=swap 颠簸;b和compact_stall高=THP compaction;allocstall高=direct reclaim;dmesg“stuck for Ns”=soft lockup。 - 单请求慢,先分层(从请求进入顺序):① 网卡/软中断丢包(
/proc/net/softnet_stat,第 09 章);② TCP 建连/重传(ss -tin,第 10/21 章);③ 系统调用/上下文切换(strace -c,第 04 章);④ 调度饥饿(vmstat r,第 05 章);⑤ 内存分配/换页(vmstat,第 06/18 章);⑥ 文件读/fsync(iostat,第 07 章);⑦ 块层排队(iostat aqu-sz,第 08/20 章)。 - 每一层都有”该抡的扳手”(第 25 章工具箱):
ethtool -S→网卡;ss→TCP;strace/perf→syscall/CPU;vmstat→内存;iostat→IO;dmesg→内核日志。 - 永远先软后硬:缓存/配置/限流→内核参数→换硬件。90% 的”盘慢”锅其实是”缓存没盖住工作集”(第 20 章),90% 的”网络慢”锅其实是”MTU 黑洞或大包被丢”(第 21 章)。
你:这套 SOP 我能直接贴工位上?
墨:能,而且建议你改成自己服务的版本。 它把全书 26 章压缩成”卡了→往哪层查→抡哪把扳手”一条线。真遇到故障,照这条线走,比”重启 + 调 tcp_rmem”强一万倍。这也是第 24 章”先定位再调参”铁律的落地版。
本章完。参数家族专章见。