同一个数,两把尺子:六组"看着一样、其实不一样"
标签:诊断口径、判据设计、最小改动、性能归因、状态生命周期、版本一致性
引言 :工程排查里最费时间的,往往不是"找不到答案",而是"答案找错了对象 "。同一份数据,用不同的尺子量就得出相反的结论;同一个功能,在不同分支上表现相反;同一个动作,改一条路和改另一条路,风险差出十几倍。今天这六组,都是"看起来是一回事、其实是两回事"------它们的共同点是:不先问清楚"这是哪个口径 / 哪条路径 / 哪个版本",怎么算都是错的。
一、口径:同一个数,两把尺子
1.1 一句"CPU 100%",先问是哪个满格
同一秒、同一份采样,两个地方各写了一个数:一个满格是 100 ,一个满格是核数 × 100。
看着像互相打架,其实是同一次采样的两个字段:
| 口径 | 满格是几 | 怎么算的 |
|---|---|---|
| 多核等效 | 核数 × 100 | 每个核各算各的,加起来 |
| 整机利用率 | 100 | 上面那个 ÷ 核数 |
所以那两个数差的就是"除以核数" :一个核跑满,在多核等效口径下记 100,换算成整机利用率只有 100 ÷ N(N = 核数)。
最容易踩的坑 :告警写"连续几个采样超过某个高水位" ------ 那个水位是整机口径 下的(要换算成多核等效值,得乘核数),不是"某一个核"的水位。
生活类比 :同一个人,"体温 37.5℃"和"体温 310.65 K"------同一个数,两把尺子。不看单位就说"你这体温比我记的高",纯属鸡同鸭讲。
读任何百分比之前,先问一句:它的满格是几?
1.2 数"行数"还是数"事件"
日志里某类报错,"这个月十几条、上个月二十几条" ------ 差近一倍,很容易判成"两个不同的病"。
其实不是。 那类报错是同一个故障反复重试,每重试一次打一行 (间隔固定)。把时间戳排好、间隔超过阈值才算"新事件" ------ 两次的"事件数"其实差不多。
生活类比:一个人连打 5 个喷嚏,你数成"5 次感冒"。
做任何"多 vs 少"的比较之前,先问:我数的是行数,还是事件数?中间那个"放大倍数"是多少?
1.3 "搜不到关键词" ≠ "没发生"
排查时想验证某个怀疑,去日志里搜那个关键词 ------ 一次都没搜到,于是写"排除了这个方向"。
错。 因为那条探针根本不在现场跑的版本里:
bash
git branch -a --contains <那个探针的提交> # 只在别的分支上
git grep -c "<探针关键词>" <现场的分支> -- 路径 # 0
探针不在版本里 → 搜不到是必然的 → 什么也证明不了。
查日志之前,先确认"这根尺子装上了没有"。 (同族坑:搜不到关键词时,也可能是你用了错误的过滤规则把它滤掉了。)
1.4 数之前,先看那行长什么样
grep -c 某类日志 = 好几百行 ------ 看着很多。
细分之后 :真正的"事件"只有几十条 ,另外绝大多数是"周期心跳汇总行"(每固定间隔打一条"目前一切正常")。
生活类比 :数"今天来了多少客人",结果把门口的自动计数器每分钟的自报也算成人头了。
看到一个大数,先抽一行原始日志看看它长什么样,再决定怎么数。
二、判据:比错了对象,结论就反
2.1 一个"看起来合理"的判据,制造了一堆假阳性
要判断"某次异常是不是某个动作引起的",原判据是:
在"开流"之后若干行之内 ,看两个特征谁先出现。
听起来没问题,实际全是假阳性。 原因:正常的那一侧,被比较的那个特征会在另一个标记之后连刷很多行 ,把目标推得远远的 ------ 按"窗口内谁先出现"比,一堆正常的也被判成"抢先"。
2.2 换成"比另一个标记"
正确的做法是换一个比较对象:
正常的:开流 → [某标记] → (被比较的特征连刷很多行) → 目标特征
↑ 先出现这个 = 正常
异常的:开流 → (被比较的特征) → 回退标记
↑ 先出现这个 = 出问题
关键:不要和"那个会连刷的特征"比先手,要和**"那个只在正确时机出现一次的标记"**比。
改完之后,一批正常样本里零误判 ,异常样本里几乎次次命中 (注意:不能说"百分之百"------说满了会被一个反例推翻)。
生活类比:点名时想确认"A 到了没",不能按"谁先到校门口"排(有人在校门口晃悠了一小时),要看**"谁先在教室里落座"**。
2.3 结论的措辞也要配得上证据
同一份数据,两种写法:
| 写法 | 站得住吗 |
|---|---|
| "全部命中、零例外" | ❌ 太满 ------ 一个例外就能推翻 |
| "正常的一侧无一例外;异常的一侧几乎次次命中,只有一次例外" | ✅ 稳,且堵住了"拿个例反驳"的口子 |
证据的强度,决定措辞的强度。 差一点点,就不要写"100%"。
2.4 顺带一条:原以为的"必要条件",其实不是
之前怀疑某类丢包是元凶。统计后发现:卡死的那些里,有一部分周围根本没有那种丢包。
→ 它不是必要条件,从"嫌疑名单"上划掉。
三、最小改动:那条路,跑过吗
3.1 一个真实的改口
遇到一个"某调用惹的祸",第一反应是把它的编译开关关掉 ------ 让代码走到另一条分支去。
这个方案被一句反问推翻了:
"你注释掉这个,那就走那条分支了 ------ 那条路原来根本没走过 ;为什么不把 else 里面那段内容停用?"
对。 换过去等于激活一条从没在产线上跑过的路径。
3.2 决定性的事实
深挖之后发现:那个"惹祸的调用"拿到的缓冲区永远是空指针 ------
csharp
DataBuff = IntPtr.Zero // 注释里写着"这段用不上,可以直接注释"
空指针当图像缓冲 → 必然失败 → 它下面 if (成功) 里的显示/存盘代码 一次都没执行过。
→ 整段 = 一个注定失败的空调用。
3.3 两条路的风险对比
| 方案 | 后果 |
|---|---|
| 改开关(切分支) | 激活一条没跑过的路径,而且那条路径还有个"缺大括号"的老坑 → 风险不可控 |
| 停用那个空调用 | 当前行为一点不变 ,只去掉那个有害的动作 |
生活类比 :房间里有个灯从来不亮(因为灯座没接线)。你的目标是"别让这个开关碰坏别的东西"------
你会去修那个灯(换线路、改开关)?还是直接把那个一直空转的开关拔掉?
3.4 推广成一句可复用的
修 bug 时,"改动最小"往往不是"删掉那个开关",而是"清掉那个本来就没在干活的调用"。
动手前先问一句:我要切过去的那条路径,跑过吗?
3.5 另一个"最小改动"的反面教材:放宽超时 ≠ 修复
现场遇到一类"握手超时",做法是把超时窗口放宽(从一个值放到三倍)。
结果:
| 指标 | 变化 |
|---|---|
| 报警次数 | 消失了 ✅ |
| 停顿次数、交接卡顿次数 | 一次都没少 ❌ |
| 单次运行时长 | 明显变长 ❌ |
报警消失 ≠ 问题消失,只是被超时窗口盖住了。
判据不能看"报警数",要看"停顿/间隔"这类埋点。
生活类比 :家里的烟雾报警器老响,你把它的灵敏度调低 ------ 报警器不叫了,烟还在。
四、排队:硬盘是"收银台",不是"管子"
4.1 一个非常容易想错的心智模型
大多数人把硬盘想成一根管子:"管子粗,就流得快;管子细,就流得慢。"
不对。硬盘是一个"收银台排一队"的排队设备。
| 超市 | 硬盘 | |
|---|---|---|
| 谁在排队 | 所有要结账的人 | 所有写请求 |
| "写入速度"看的是 | 收银员卖得多快 | 每秒写进去多少 MB |
| "单次写延迟"看的是 | 你这瓶水等了多久 | 一个请求从发出到完成 |
4.2 关键推论
- 推着一整车货的人(一个很大的文件)站在队首
- 手里拿瓶水的人 (写一行日志)也得排在他后面
所以卡住的不是"盘总速度慢" ,而是"我们那瓶水等了好几秒"。
判据 :只看"写入速度"会漏掉问题 ------ 要同时看"单次写延迟" 。
前者是"因"(我们往盘里塞了多少),后者是"果"(盘有多累、要排多久)。
4.3 别拿"进程自己的计数"当"盘的压力"
程序里常有一列"本进程的写入速率"。它的来源是进程句柄的 IO 计数:
csharp
if (TryGetIoCounters(进程句柄, out io)) // ← 只数"我们进程发出的"
ioWrite = io.WriteTransferCount;
→ 它看不到盘有没有排队、别的进程在不在抢。
| 指标 | 回答什么 | 从哪来 |
|---|---|---|
| 进程写入速率 | 我们塞了多少(因) | 程序自己 |
| 单次写延迟 | 盘有多累(果) | 系统(任务管理器 / 性能计数器) |
两个都要看。
4.4 一条很实用的判别口诀
停顿很长 + CPU 增量 ≈ 0 + 应用层锁等待 0
⇒ 卡在被调用的"库内部 I/O"
逐条读:
- CPU 增量≈0 → 不是在算,是在等
- 应用锁等待 0 → 不是自己代码的锁,是下面那一层(磁盘/网络/驱动)
- 停顿窗口与某个 I/O 窗口完全重叠 → 钉成因果
另一条对照:停顿时间 ≈ 某任务的实际时长 → 它在忙算 ;≈ 0 → 它在等。
4.5 定位"变慢",先看分布 ,再看时间顺序
一个反直觉的教训:按时间顺序取平均 → 得出"每次处理的耗时变成原来的好几倍"(看起来是退化了)。
真相是双峰:
正常时:很短,而且各轮几乎一致
卡顿时:长得多,但各轮同样一致
变的是:**卡顿的"次数"**(逐轮往上爬)
判别口诀 :双峰 + 严格周期性(每隔固定时间卡一次、每次时长固定)+ GPU 空闲 + CPU 不满
= "固定等待/超时",不是算力退化。
生活类比 :一个人上班平均迟到 8 分钟 ------ 不能直接说"他越来越懒";一看分布:要么准点到,要么迟到 20 分钟 (因为那班公交要么赶上、要么等下一班)。变的是"赶不上的次数"。
五、状态的一生:static 与"永久歇业"
5.1 一个说法上的精确性:"锁释放了"是错的
复盘时写了"重启 ① → 僵尸结束、锁释放"。
这句两个说法都不准:
- "僵尸" = 那个卡死的任务。它的作业早就收尾了,可它自己还活着、还在烧 CPU ------ 活着的尸体。
- "锁释放" = 不对 。那把锁是个
static字段 ------ 它住在进程的内存 里。重启 = 进程退出 → 整个进程内存没了 → 锁对象和占着它的线程一起消失。
准确说法:不是"僵尸把锁交出来了",是"连僵尸带锁一起同归于尽"。 那句 Release() 代码,从头到尾一次都没执行过。
这个区别有实际后果:
锁一旦被占,现场没有别的出路 ------ 只能重启程序。 (三次挂死、三次都靠重启,零自愈。)
推广 :凡是 static 的状态,都只在"进程活着的这段时间"里有效。 要重置它,最小代价是重启进程。
→ 所以挂在 static 上的、"会不会被永久占住"的东西(锁、缓存、标志位),都要单独审一遍。
5.2 try 的位置,决定"保护范围"
一个"接客循环":
csharp
// 写法 A:try 包住整个圈
try {
while (在接客) { ... } // ← 圈里任何一行抛异常 → 连"继续等"一起结束
} catch { } // ← 而且是空的,什么都不打
// 写法 B:try 只包住一次接待
while (在接客) {
try { ...接一次客... }
catch { 记日志; 等一小会儿; } // ← 只跳过一次,转回圈顶继续等
}
try 挪个位置,不是"挪个括号",而是保护范围从"整班岗"缩到"这一次接待"。
后果 :写法 A 出错后永远不再接客 (而且零日志 )→ 现场症状是"对方断了以后再也连不上",看起来特别像对方的问题。
生活类比:
- 写法 A = 保安绊一跤就辞职走人(岗亭从此空着,还没人知道)
- 写法 B = 保安绊一跤爬起来接着站
5.3 "等不到" ≠ "出错"
一个"在门口等人"的调用:
csharp
var client = await 门口.AcceptAsync();
- 一直等不到人 → 它不抛异常 ,就挂在那儿不动(循环也不转,但没死)
- 门口出事 → 它抛异常 → 一路往上冒,冒到最近的
catch才被接住
"等不到"和"出错"是两回事 ------ 这是最容易混的一对。把"没消息"当成"报错",就会做出错误的处理。
5.4 恢复了,要让人知道
给"接客出错"加了日志之后,还有一个缺口:成功路径上一个字都不打("来客人了"要真有人连才打)。
于是"已经恢复了、但暂时没人连"的时候,日志看着还像坏着。
→ 补一条恢复日志 ,和出错那条配成一对:
10:00:00 [ERR] 接受连接失败(后续同类失败不再重复记录...)
10:03:27 [INF] 接受连接已恢复正常 ← 中间坏了一小段时间
"不报错"和"正常"不是一回事 ------ 恢复时要有正向反馈。
5.5 日志限速,该限"记不记",不是"多久记一次"
一开始的想法:在出错分支里加个"等一下再转"(延迟)------ "既防紧循环,又降日志频率"。
但算笔账就知道不行:
不限速:异常潮一来就是每秒成千上万条 → 分钟级写满
加延迟:降到每秒几次 → 拖成"一天几百兆",一周就上 GB
延迟不是解药 ------ 它只把"写满"从几分钟 拖到几天。
能限住日志的只有"别每圈都记" → 加一个标志位,一条异常潮总共只写 1 条。
限速该限"记不记",不是限"多久记一次"。
六、同一功能的两个版本:现场 ≠ 主线
6.1 一个真实的"答案完全取决于版本"
有人问:"某功能修好了吗?"
第一轮查了主线代码,结论是"已经改了,方向是故意不发 "。听着没问题。
但追问之后发现 :现场跑的是另一条分支 ------ 而那两条分支上,同一功能的行为恰好相反:
| 保存时发不发 | 生产切换时发不发 | |
|---|---|---|
| 分支 A(现场在跑) | ❌ 不发 | ✅ 发 |
| 分支 B(主线) | ✅ 发 | ❌ 不发 |
→ "修好了吗"这个问题的答案,完全取决于"现场跑的是哪个构建"。
教训 :任何"是否修复"的判断,必须先确认现场跑的是哪个分支/哪个版本。主线 ≠ 现场。
生活类比 :两本同名教材,第三版和第四版的第 5 章内容相反。你拿着第四版回答"第 5 章讲什么",对方手里拿着第三版 ------ 越说越对不上。
6.2 怎么不查版本号也能知道跑的是哪版
有一招很实用:找一个只在新版才有的行为,制造它,看反应。
比如新版"保存时会给某个设备发一条消息",那就先把那台设备断开再点保存:
- 弹"已保存,但设备未同步 " → 是新版
- 只弹"保存成功"、一个字都不提设备 → 还是旧版
用"行为差异"认版本,比翻版本号可靠。
6.3 同一个改动,两个提交号:合并时会怎样
今天有个改动先在开发分支提交,然后又"摘"到主线提交 → 同一份改动有了两个提交号。
| 情形 | 合并时 |
|---|---|
| 内容相同、提交号不同 | 空合并(没冲突,也没东西可合)------ 无害 |
| 内容不同(两边都改过同一处) | 会冲突 ------ 得人工解 |
所以"改动搬到另一条线"时要问一句 :搬过去的是全量 还是部分 ?如果是部分,两条线在那个文件上就内容不同了,将来整线合并会撞。
七、总结:六组"看着一样、其实不一样"
| 看着一样 | 其实不一样 | 一句话 |
|---|---|---|
| 两个 CPU 数字 | 两套口径(整机利用率 / 多核等效) | 先问"满格是几" |
| 「这个月十几条、上个月二十几条」 | 行数 ≠ 事件数(重试放大) | 先问"数的是什么" |
| 搜不到关键词 | 探针可能不在版本里 | 先确认"尺子装了吗" |
| 两个特征"谁先出现" | 比错了对象 | 和"只出现一次的标记"比 |
| 删开关 vs 停用调用 | 一条跑过、一条没跑过 | 先问"那条路跑过吗" |
| 报警消失了 | 只是被超时窗口盖住 | 看停顿,不看报警数 |
| "写得多快" | "每次等多久" | 盘是收银台不是管子 |
| "锁释放了" | 进程内存整个没了 | static 的寿命 = 进程的寿命 |
| "主线修好了" | 现场可能跑另一条线 | 先确认构建 |
一句话收尾 :排查的一半功夫,花在"先问清楚这是哪个口径、哪条路径、哪个版本"上。 口径没对齐,后面全是空转。