第 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 章)。

调整:

  1. net.core.somaxconn=65535 + 应用 listen backlog 跟上(治 accept 队列);
  2. vm.overcommit_memory=1 + 预留 vm.min_free_kbytes 略增 + 限制 MALLOC_ARENA_MAX=4(治 direct reclaim 与 arena 碎片,第 06 章);
  3. 大促前预热连接池与 page cache(减少冷启动缺页与读盘,第 03/07 章)。

观察:P99 回到 85ms,与平日持平。五次推断,三次推错,最后拼成”cgroup 配额 + accept 队列 + direct reclaim”三件叠加。

墨:这个事故最有教益的地方:它不是某一章的题,是 05 + 06 + 10 三章的题撞一起。任何单独一章的知识都只能解三分之一。这就是”全链路”的意义——你脑子里得有一张地图,知道”卡”可能落在哪几段,然后用各自的显微镜去证真/证伪,而不是赌。


五、回到最初的问题:程序到底”经过什么”

墨:还记得你最开始的那句话吗——“顺带介绍一下程序是怎么运行的,要经过什么,创造一个故事来讲。” 我们走了 11 章,现在能给你一句总账:

一个程序的字节,从你的 send() 到对面 recv(),经过的不只是”网络”,而是:

  1. 用户态(你的代码、glibc 的 malloc 空头合同、缓冲)——第 04、06 章
  2. 系统调用墙(syscall 指令、上下文切换、特权级)——第 04 章
  3. 虚拟内存(MMU、页表、TLB、缺页、COW)——第 03 章
  4. 调度器(CFS vruntime、抢占、cgroup 配额、throttling)——第 05 章
  5. 内存管理(buddy/slab、水位线、direct reclaim、swap、OOM)——第 06 章
  6. 文件系统(VFS、inode、page cache、fsync、journal)——第 07 章
  7. 块层(bio、队列、IO 调度器、驱动)——第 08 章
  8. 中断与软中断(网卡叫醒 CPU、NAPI、softirq)——第 09 章
  9. 网络栈(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 个段、各自的显微镜命令、以及若命中对应的逃生阀。

【参考答案】

依次排查(自顶向下,每段用显微镜证真/证伪):

  1. 握手/队列段(第 10 章):ss -lnt 看 accept 队列 Recv-Q/Send-Q、netstat -s 看 listen overflow → 命中则 somaxconn、应用 backlog、tcp_abort_on_overflow。
  2. 调度段(第 05 章):/proc/sched_debug 看 nr_throttled、vmstat 看 procs r 堆积 → 命中则放宽 cgroup cpu.cfs_quota_us、查 nice/实时进程。
  3. 内存段(第 06 章):vmstat 1 看 allocstall(直接回收)、si/so(swap)、cat /proc/buddyinfo 碎片 → 命中则 overcommit_memory、min_free_kbytes、MALLOC_ARENA_MAX。
  4. 网卡/软中段(第 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 章,画一张”程序运行九层”的极简地图(每层一句话:它管什么 + 一个典型故障信号 + 一个对应参数/工具),作为你自己的排障速查卡。

【参考答案】

九层速查卡(从上到下):

  1. 用户态/glibc:malloc 空头合同、缓冲 → 信号:RSS 暴涨/OOM → MALLOC_ARENA_MAX
  2. 系统调用墙:syscall/上下文切换/futex → 信号:strace 见海量 futex → max_map_count/RLIMIT_NOFILE
  3. 虚拟内存/MMU:页表/TLB/缺页 → 信号:大量 minor/major fault → vm.min_free_kbytes(关联回收)
  4. 调度器:CFS vruntime/抢占/cgroup → 信号:nr_throttled 涨 → cpu.cfs_quota_us/sched_latency_ns
  5. 内存管理:buddy/slab/水位/reclaim/swap/OOM → 信号:allocstall/si,so → overcommit_*/swappiness/min_free_kbytes
  6. 文件系统/page cache:VFS/inode/dentry/fsync → 信号:fsync 卡、命中率低 → dirty_*/noatime/vfs_cache_pressure
  7. 块层:bio/队列/调度器 → 信号:await/aqu-sz 高 → nr_requests/io.max(cgroup)
  8. 中断/软中断:网卡叫醒/NAPI/softirq → 信号:si 单列 100%/softnet 丢 → ethtool -L/netdev_budget
  9. 网络栈 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 把账本收成一张表

跳路径正常延迟哪个家族的参数能在这里咬你
1NIC→内存(DMA)微秒vm.min_free_kbytes(原子分配丢包,第14章)
2软中断+协议栈几~几十 μsnet.core.*、netdev_*、RPS(第09/15章)
3read 系统调用微秒~亚msovercommit/watermark(direct reclaim 卡,第12/18章)
4应用处理微秒~ms(你自己的代码)
5write 进 page cache微秒(平时)/百ms(触雷)vm.dirty_ratio(前台sync)、vfs_cache_pressure(第12/22章)
6page cache→磁盘(异步)ms~百msvm.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 未命中,命中)→ 块层(iostat r/s 暴涨、await 3ms,排队)→ 中断/网络(无)。

根因: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_caches cron(第 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 级(应用):业务只 Write 1KB 到文件。应用层”诚实”。
  • 第 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,是逼你把”总账”拆成”每层的小账”,哪层放大异常一眼看出。本书反复讲的”从需求倒推原理、逐层枚举”,在这类跨层问题上是唯一靠谱的方法。

🔧 思考题:如果你的服务也吃”写放大”的亏,但没法换文件系统(云上托管盘),你能在应用层做哪三件事把放大压下来?

【参考答案】

换不了底层,就在应用层治”源头”:

  1. 批量提交:把 N 次小改攒成 1 次大块写(如日志缓冲、配置批量落盘)。放大系数从”每写都付元数据/对齐/SSD 开销”变成”N 次摊一份”,N=100 时放大直接砍 100 倍。最立竿见影。
  2. 顺序写优先:用 LSM-tree 类引擎(RocksDB/LevelDB)或自建 WAL+顺序追加,把随机小写转成顺序大写——顺序写在块层能合并、SSD 放大低(上一节讲的,顺序写放大从 100x 降到 ~10x)。
  3. 减少 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。

安全重试的前提:

  1. 幂等:下游能扛住重复请求(读、或带幂等键的写)。非幂等写重试会重复扣款。
  2. 下游健康:只在”瞬时错误”(超时/5xx 抖动)时重试,下游已返回”永久错误”或熔断打开时绝不重试。
  3. 有退避:指数 backoff + jitter,别瞬间并发重试把下游冲平。
  4. 有上限 + 熔断:单请求最多 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 暴涨。

再观察:解法分层(对应地图各层):

  1. 应用层互斥(singleflight):用 Go 的 golang.org/x/sync/singleflight,让”同一 key 的并发回源”只放一个请求去 DB,其余等待它的结果——2 万并发 miss 变 1 个 DB 查询,击穿瞬间消失。这是最准的解(在”回源”这一跳做合并)。
  2. 缓存层逻辑过期:热点 key 不设物理 TTL,后台异步刷新,永不在”过期瞬间”集体 miss。
  3. 随机过期时间:TTL 加随机抖动,避免大量 key 同一时刻失效(防”缓存雪崩”,比击穿更宽的场景)。
  4. DB 兜底:连池够大 + 熔断(DB 慢时快速失败,别让请求堆连接池占满)。

改进:

  • “同一 key 集中 miss”用 singleflight 合并回源(应用层第 6 级治本)。
  • 热点 key 用逻辑过期/互斥锁,别物理 TTL 齐失效。
  • 雪崩(大量 key 同时失效)用随机 TTL 抖动分散。
  • 配合第 11 章的”超时金字塔 + 重试熔断”,避免击穿瞬间叠加重试风暴放大成 DB 死亡。
  • 监控 Redis miss 率突增 + DB Threads_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:

  1. 先看”是单请求慢还是全机同时卡”:单请求慢→你代码/SQL/锁;全机同时卡→内核暂停类(第 18 章五兄弟)。
  2. 全机同时卡,看 vmstat:si/so 跳=swap 颠簸;b 和 compact_stall 高=THP compaction;allocstall 高=direct reclaim;dmesg “stuck for Ns”=soft lockup。
  3. 单请求慢,先分层(从请求进入顺序):① 网卡/软中断丢包(/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 章)。
  4. 每一层都有”该抡的扳手”(第 25 章工具箱):ethtool -S→网卡;ss→TCP;strace/perf→syscall/CPU;vmstat→内存;iostat→IO;dmesg→内核日志。
  5. 永远先软后硬:缓存/配置/限流→内核参数→换硬件。90% 的”盘慢”锅其实是”缓存没盖住工作集”(第 20 章),90% 的”网络慢”锅其实是”MTU 黑洞或大包被丢”(第 21 章)。

你:这套 SOP 我能直接贴工位上?

墨:能,而且建议你改成自己服务的版本。 它把全书 26 章压缩成”卡了→往哪层查→抡哪把扳手”一条线。真遇到故障,照这条线走,比”重启 + 调 tcp_rmem”强一万倍。这也是第 24 章”先定位再调参”铁律的落地版。

本章完。参数家族专章见。