起点、口径与因果:今天用错的七把尺子
标签:诊断口径、判据设计、因果与相关、探针可信度、统计陷阱、git 版本定位、C# 语法
引言 :今天三条线并行(一条查设备卡死、一条查下料拒绝、一条给现场分支做清单),最后收工时发现一个共同点 ------ 今天所有的"矛盾",没有一个是机器吵架,全是「说法/记录 vs 事实」。
具体说,就是尺子拿错了:数错了对象、算错了起点、把相关当成了因果、拿一把测不了的秤去称东西。
这篇记七把用错的尺子,外加今天新学的几个语法点。
一、数什么:同一份日志,四种数法
1.1 「行数」≠「次数」
我统计"上料发生了多少次",报了一个数;实际差了三十倍。
原因:我用的关键词是个大词,它在别处也会出现 ------ 每次上料失败时会连打十几行警告,全被算进去了。
正确做法 :关键词要能唯一标志一次事件 。用那种只在事件入口打一行的"开始行",别用到处都出现的大词。
配套陷阱 :有「开始/结束」成对打点的,行数要除以二 。所以统计前先抽一条样例看清楚格式,再动手数。
生活类比:数"来了几桌客人",不能数"餐厅里出现了多少次'盘子'" ------ 一桌会用好几个盘子。
做任何"多 vs 少"的比较之前,先问:我数的是行数,还是事件数?中间那个放大倍数是多少?
1.2 「标准差」≠「集不集中」
我统计一组时间偏差,标准差是个挺大的数,看着像"很发散"。分桶一看 ------ 是双峰:一个主峰占了绝大部分,另有一个很小的尾巴。
标准差被那条小尾巴抬起来了,不代表主峰不集中。
正确做法 :报"集中/发散"要用分桶直方图 或中位数 ,别直接报标准差。先看一眼分布形状,再决定用什么统计量。
生活类比 :一个班平均身高 1.65 米 ------ 可能全班都 1.6 上下,也可能一半是幼儿园一半是篮球队。平均值和标准差都看不出这个区别。
注意 :这条和 09-18 记的"两把尺子"是同一族 ------ 那次是满格口径 不同,这次是统计量选错。
1.3 「距某个动作」≠「距判决起点」
我给一个耗时指标选了个起点,看着挺自然。结果统计出一堆"异常值",我以为发现了新故障。
拉出上下文才发现:那个"起点"和判据真正的起点,差了将近四秒 ------ 中间隔着一串我们自己的流程。
于是 :指标算出"四秒",看着吓人;而按判据真正的起点算,只有百来毫秒,离超时线还远。
修法 :不要新造起点 ------ 找到"判据自己在用的那个计时器",直接复用它。口径天然一致,不用校准。
生活类比:你想知道"客人从进门到坐下要多久",却从"餐厅开门"开始计时 ------ 量出来的是"今天店里有多闲",不是服务速度。
造新指标前,先找现成的、跟判据同源的计时器。 这比新造一个再校准可靠得多。
1.4 「一个指标」可能混着「两个来源」
接上一条。我那个指标其实有两个不同的调用来源:
- 一种是每个小周期都会发一次
- 一种是每换一块料才发一次 ------ 而它后面还跟着一长串准备工作,所以耗时天然就长
我把两者混在一个分布里统计,于是"每块料一次的长耗时"变成了假的周期性异常。
修法 :埋点必须标注"谁调的" ;统计时先按来源分组,再看分布。
通用规则 :一个指标如果可能有两个来源/两个起点,先分开统计,再下结论。
二、怎么推:从数据到结论的四道坎
2.1 「总是一起出现」≠「前者导致后者」
这是今天最贵的一课。
我们观察到 A 和 B 总是同时出现 :几百轮里,A 出现几次,B 就出现几次,一次不差。故障时两者一起消失。
于是我们写下了"B 是 A 的标志 ""A 是导致 B 的原因"。
对方一句话就把这条打掉了:「就算 A 不发生,也不影响 B 的结果。」
回头一看:我们手上的证据从来只有"一起出现"。 那叫相关 ,不叫因果。
生活类比 :雨伞和雨总是一起出现 ------ 但伞不会让天下雨。你不能因为"今天没人打伞"就断言"今天肯定没下雨"。
写"因果"之前先自问:我手上这条是"一起出现",还是"因为所以"?
如果只是"一起出现",就只能写"总是一起出现"。
2.2 「能看见」≠「测得准」:自证 vs 同义反复
今天写了个探针,每次都能打印出一列值,看起来证据很足。被人追问才想明白:
这一列必然等于某个值 ------ 因为**"读到这个值"正是它跳出循环的条件**。
也就是说,那个字段不是"测出来的",是"挑出来的":只有它等于那个值时才被记录。
生活类比 :只挑考及格的同学来统计 ,然后宣布「我们班及格率 100%」。数字是真的 ------ 但没及格的人根本没被算进来。这在统计上叫幸存者偏差。
自查问法(很值得背下来):
「我能看到这个值,是因为它本来就是这样,还是因为『只有它是这样时我才看得见』?」
- 若是后者 → 这个字段是同义反复 ,不能当证据
- 同一次探针里,不在跳出条件里的那个字段 (例如硬件线上的实际电平)→ 才是真测量
但还要往下问一步 :这个毛病严重不严重?
- 它是同义反复吗?(是)
- 少掉的这条信息,别处能不能补上?(能 → 不严重)
顺带暴露的另一面 :这个探针只在"事情顺"的时候在场 ------ 一旦进入"一直等不到"的那种故障,它连出现都不会。同一个毛病的两面。
2.3 尺子的抖动 ÷ 要测的差异 > 10 → 这把尺子测不了
要证明"我加的那个探针不拖慢生产",我原来的办法是比总耗时。被连问三轮才拆开:
- 要称的东西 :探针那两下"看一眼设备"花的时间 ------ 毫秒级
- 我用的秤 :整趟动作花多久 ------ 十几秒 ,而且它自己就抖 (几百次实测的波动比我要测的差异大三十倍)
指针根本不动,读不出来。
生活类比 :想称一根头发 的重量,用的却是体重秤。
动手比之前先算一遍:
| 抖动 ÷ 差异 | 结论 |
|---|---|
| < 1 | 能测出来 |
| ≈ 1 | 勉强 |
| > 10 | 这把尺子测不了,换方法 |
正确做法 :别绕道比总耗时,直接量那个动作自己 ------ 在它前后各掐一个时间戳,差值就是答案,不受任何外部抖动影响。
还有更要紧的一条 :看最坏情况,不看平均值。
那个探针带了超时 ------ 万一设备不理人,它会把后续步骤推后最多半秒。这才是真正要盯的风险。
判据就是 :这段时间里超时触发了几次?(零次 → 不影响生产。)
验证"加了东西会不会影响生产",问"最坏那次有没有触发超时",而不是"平均慢了几毫秒"。
2.4 离群值先看,别先算
第 1.3 那个假异常,是拉了那两三个离群样本的完整上下文 才发现的。
如果直接按统计结果报出去,就是一条假告警。
报异常之前,手工拉一两个离群样本,看一眼它前后到底发生了什么。
三、怎么查:三个可复用的手法
3.1 构建指纹法:从"痕迹"反推"版本"
问题 :现场程序不打版本号,怎么知道它跑的是哪一次编译的产物?
手法 :找某次代码改动产生的、必定出现的日志行,去现场日志里搜。
某个提交加了一行日志 → 从那之后编译的版本里一定有这行
→ 现场日志里有没有它,就知道版本在它之前还是之后
选指纹的三条要点:
- 选"降噪型"改动最灵 ------ 那种让"某类日志永久消失"的改动,指纹最干净(有/无是二元的)
- 选"必定出现"的 ------ 启动日志、每循环日志可信;只在特定场景才打的不可信(要等某个条件触发,首现时刻会误导)
- ⚠️ 二进制文件坑 :搜索工具撞上二进制字节会静默截断 → 要加"按文本处理"的参数
配套 :某个指纹首次出现 的时间 ≠ 最后一次换版本的时间。正确做法是把当天所有"程序启动"列出来,看最后一次是哪个。
这次靠它破了一个真问题:现场到底换没换可执行文件、什么时候换的。
"没出现"也是证据 ------ 用来反证"这版里不含那条改动",跟"出现了"同样有用。
3.2 逐行对齐:从「筛」到「发现」
之前我们查问题时,一直是拿已知判据去筛日志("有没有那一行""那一行在不在某个阶段")。
筛只能验证你已经想到的假设。 这次换了个做法:把一次正常 和一次故障 从同一个锚点 起,剥掉时间戳和编号、归一化后逐行并排。
结果 :找出唯一差异就是一行 ,而且它出现的位置很关键 ------ 比之前所有的"阶段"判据都精确。
排查"两类现象到底差在哪"时,先做一次全量逐行对齐。
筛是"验证",对齐才是"发现"。
配套思路:找一个「绝佳对照」 ------ 找一个也有这个现象、但结果正常的样本。
这次就找到一个:它也出现了那一行,但位置不同 ,结果正常。就是它把判据钉死的 ------ 它证明了"有那一行"本身不是原因,位置才是。
要证明 A 是原因,去找一个「有 A 但没出问题」的样本 ------ 往往比找十个「有 A 且出问题」的更有说服力。
3.3 逐步对照表:讲"异常"最有力的形式
被人指出"没展示正常流程和异常的对比"之后,补了这张表 ------ 一目了然:
| 步 | 正常 | 异常 |
|---|---|---|
| 1 动作开始 | ✅ | ✅ |
| 2 问设备 | ✅ | ✅ |
| ... | ... | ... |
| 7 设备判决 | 通过 ✅ | ❌ 拒绝 |
| 8 结果 | 完成 | 没有完成 |
→ 前六步逐字相同,第七步才岔开 ------ 这一句话比任何描述都有力。
讲"异常"时,先把"正常"逐步摆出来,再逐步对照,而不是直接讲异常。
四、语法附录(今天新学的)
4.1 C#:_ = 丢弃符(fire-and-forget)
csharp
await DoSomethingAsync(); // 等它跑完再往下
_ = DoSomethingAsync(); // 调用它,但不要结果 ------ 立刻往下走
_ 是 C# 的丢弃符 ,意思是"我调用它,但不关心返回值"。
它真的会跑 :async 方法一被调用就开始执行,一直跑到第一个真正的 await 才让出。
csharp
_ = TraceAsync();
↓ 内部
守卫 → await ReadSomethingAsync() → 让出(这里才交还控制权)
↓
调用方继续往下(不等那后半段)
所以「后台任务的第一件事」是执行时机最接近"那一刻"的位置 ------ 比在调用点 await 还好:既不延迟调用方,又尽量早采样。
4.2 C#:Interlocked.CompareExchange(抢车位)
csharp
Interlocked.CompareExchange(ref _flag, 1, 0)
// ↑ ↑ ↑
// 要动的变量 改成 期望它现在是
语义 :「如果它现在是 0,就改成 1;然后告诉我它原来是几。 」
返回值是"改之前的原值",不管改成功没有。
| 返回 | 含义 |
|---|---|
0 |
原来 0 → 我抢到了(且已改成 1) |
1 |
原来 1 → 已有人在跑 |
为什么不用 if (_flag == 0) { _flag = 1; } :那是两步 ------ 两个线程可能都读到 0,都进去。
生活类比 :不是「先看一眼车位空不空,再开进去」(两辆车可能同时看到空位),而是「伸手占位 和看空不空是同一个动作」。
配套 :用完必须在 finally 里复位,否则永久锁死。
4.3 C#:信号量非重入 → 死锁自查
csharp
private readonly SemaphoreSlim sem = new(1);
// 所有操作都走:await sem.WaitAsync(); try { ... } finally { sem.Release(); }
关键性质 :SemaphoreSlim(1) 非重入 ------ 同一条异步流里再次 WaitAsync 会自己等自己 → 死锁。
新增任何走这把锁的代码前,必须问:
我的调用点,会不会已经在锁内部?
自查方法:列出新调用点的宿主方法 → 看它们是否被包在锁里 → 再列出全部"锁回调",确认没有一个会调回来。
顺带一条开销常识 :读几个点 = 抢几次锁 。要一次读多个值,用批量 API,别循环单点读。
4.4 C#:整字节取位前先 & 0xFF
读一个字节再按位取某一 bit 时:
csharp
(v >> Bit) & 1 // 取第 Bit 位
⚠️ 那个字节可能是有符号类型 → 显示或取位前先 & 0xFF ,否则负值会打出 FFFFFF20 这种"看起来像数据"的鬼东西。
另一条 :批量读时,协议保证按请求顺序返回结果 → 下标映射是安全的(vals[0]/[1]/[2] 一一对应)。
4.5 C#:参数是"先求值、后传参"
csharp
Watchdog.Begin($"检测[{category}] {id}");
这个插值字符串是在进入 Begin 之前就构造好的 ------ 所以哪怕看门狗没启动、哪怕正常返回,它每次都分配一次。
想在方法内部才能决定"要不要构造"时 ,得改成惰性形式:
- 传
Func<string>(方法内部需要时才调用) - 或拆成两个参数,内部需要时才拼
这次的结论是"不改" ------ 收益约等于零(被下游那个更大的分配淹没),改了反而把调用点写复杂。
但要能说出"我知道这里有一次分配、我评估过、我选择不动"。 这和不知道,是两回事。
4.6 git:判断"某提交在不在某分支"
bash
git merge-base --is-ancestor <提交> <分支>
echo $? # 0 = 是(分支里有它),非 0 = 不是
成批打表:
bash
for c in aaa bbb ccc; do
m=$(git merge-base --is-ancestor $c origin/main && echo "有" || echo "无")
printf "%s | %s | %s\n" "$c" "$m" "$(git log -1 --format=%s $c)"
done
找一条分支是"从哪儿拉出来的":
bash
git merge-base <分支> origin/main
4.7 git:A..B 与 --not
git log A..B= B 有、A 没有 的提交(右边的减左边的,容易记反)git log A --not B= 同上,但能接多个来源:
bash
git log --author="某人" 分支1 分支2 --not 目标分支
--author 分组一眼看"哪些是自己的":
bash
git log --format="%an" A..B | sort | uniq -c | sort -rn
4.8 git:cherry-pick vs merge ------ 怎么选
| merge | cherry-pick | |
|---|---|---|
| 带过去的东西 | 分支上的全部历史 | 只有你点名的那几个提交 |
| 适用 | 两边是同一条线的正常推进 | 只想要其中一两个(尤其目标分支不想被新东西污染时) |
| 代价 | 没事 | 提交哈希变了 ,以后源分支再改同一文件不会自动同步 |
⚠️ cherry-pick 的隐性代价 :挑的次数越多,两条分支的同一文件差异越大 ------ 将来"整线合并"时冲突越难解。
干跑冲突检查(只读,改动前先跑):
bash
git merge-tree --write-tree --merge-base=<提交>^ <提交> <目标分支>
--merge-base=<提交>^ = 把"那个提交的父"当三方合并的基线 → 语义上等价于"把这个提交挑到目标分支" 。
退出码 0 = 无冲突;非 0 = 有冲突(会列出冲突文件)。
五、一条设计规律:什么该"修",什么该"留"
5.1 「疯狂重复同一个动作」可能不是 bug,是没有节流的重试
现场日志里,一次动作会在几十毫秒内把同一个命令写几十遍。看起来像典型的 bug。
根因是这样写的:
csharp
public short Command
{
get => _local; // 读本地字段
set => connection.WriteNode($"...Command", value); // 写:只写设备,不更新本地字段!
}
本地字段只靠设备回传(订阅)才更新。于是:
第 1 圈:本子是 0 → 写设备 → 本子还是 0 →(循环末尾没有等待)→ 立刻下一圈
第 2 圈:本子还是 0 → 再写
⋮
第 N 圈:设备回传 → 本子改成 1 → 停
但被人一句话点出了要害:
「所以这里置 1 不对吗?如果自己改 1,万一设备没收到怎么办?」
| 做法 | 本地字段是什么 | 设备没收到时 |
|---|---|---|
| 只信设备回传(现状) | 设备真实状态的镜像 | 本子还是 0 → 下一圈再写 = 自动重试 ✅ |
| setter 也改本子 | 我以为的状态 | 本子是 1 → 再也不写 = 永久卡死 ❌ |
结论 :本子只信回传 = 「设备没确认,我就继续喊」 ------ 这正是"防止漏发"的保护。
改成自己置位 = 「喊完就假设对方听到了」------ 反而丢掉了保护。
⭐ 通用规律 :当一个"状态字段"的更新依赖外部回传 (订阅/ACK)时,上层的"重复写"往往不是 bug,而是重试机制 。
修它要「加节流」,不要「改状态源」。
5.2 加节流加在哪:最后那个空位
矛盾是:「不想狂写」↔「不想让循环变慢」。
但先把"加在哪"想清楚:
csharp
while (true)
{
if (state == 0) { 写命令; } // ← 重复写发生在这
if (busy) { ...; await Task.Delay(100); continue; } // 已有等待
if (error) { throw ...; } // ← 直接抛,不等
if (done) { ...; break; }
// ← 只有【三个标志全 false】才落到这里 ------ 等待加这
}
| 情形 | 现在 | 加一句等待后 |
|---|---|---|
| 正常 | 狂写几十遍,耗时更长 | 写一遍 + 等一次 ← 反而更快 |
| 报错路径 | 直接抛 | 不受影响(等待在它后面) |
| 设备无响应 | 转得飞快 | 转得慢一些 |
⭐ 为什么"转得慢"不要紧 :那几个标志位是订阅推送 的字段 ------ 设备一推就变 ,循环只是看一眼本地字段。
→ 轮询密度不影响"发现异常"的及时性。
可复用 :想让循环少做点,先看循环里那几条分支 ------ 把等待加在"最后那个空位"(不走任何已处理分支时),就不会影响已处理的路径。
这次的结论是"只记录、不改" :那个收益是真的,但它不是本次故障的根因(正常生产时也在犯)。
"看见"比"顺手改"更重要。
5.3 ⚠️ 比方要小心:一个比方可能引入错误前提
我用「门铃(物理)→ 灯(内部)」比喻一组信号,对方立刻追问:
「指示灯是那个设备根据"我们发它的命令"做的状态反应?」
这个追问点出了比方的问题 :门铃→灯的比方暗示"灯只由门铃决定" ------ 而实际上:
灯是那台设备按它自己的逻辑点亮的,那根输入线只是它的输入之一**。**
教训 :比方要同时说清「它由什么决定」和「它还受什么影响」。 只说映射关系,会让对方以为排他。
但这个追问本身很有价值 ------ 它提示了一个可验证的假设:如果某个值在某个动作之前 是一种、之后 变成另一种,就证明那个信号受我们的动作影响 ------ 一个很反直觉的发现方向。
5.4 三层结构:别把"对方说的"当成"我们读到的"
今天把一组信号彻底钉死了 ------ 三层:
对方设备内部 那根线 输入点 控制程序 程序结论
"我准备好了" ──▶ 电平 1 ──▶ [某输入位] ──▶ [那段逻辑] ──▶ [某内部变量]
第 1 层 第 2 层 第 2 层 第 3 层
为什么要分三层 :对方说"好" ≠ 我们读到 1 ------ 中间那根线可能断、可能接触不良。
| 第 2 层(线) | 第 3 层(程序结论) | 结论 | 探针能判吗 |
|---|---|---|---|
| 1 | 1 | 正常 | ✅ |
| 1 | 0 | 线好、那段逻辑有问题 | ✅ |
| 0 | 0 | 线或对方设备侧 | ⚠️ 分不出"线断"还是"对方真的不给" |
| 跳变 | 跟着跳 | 接触不良 / 干扰 | ✅ |
第三行那个盲区只能现场解:看对方设备那侧的输出灯,或用万用表量线。
⭐ 而且:我们从来没"命令"过对方必须执行。 我们只是按了个按钮 。
按不按由我们;执不执行,对方自己判断。
六、收尾:今天最值得背下来的六句话
- 报"有多少"之前,先想清楚有几种合理口径 ------ 数字必须带着口径 一起说。只给一个口径的数字,看起来精确、实则误导。
- 写"因果"之前先自问:我手上这条是"一起出现",还是"因为所以"?
- 每写一个探针,问一句 :我能看到这个值,是因为它本来就是这样,还是因为只有它是这样时我才看得见?
- 动手比之前先算 :尺子的抖动 ÷ 要测的差异。> 10 就换方法。
- 一个指标有两个来源 → 先分开统计 ;新指标的起点必须跟判据同源(优先复用现成计时器)。
- "看见"比"顺手改"更重要 ------ 先把事实记下来,改动另立议题。
附:今天的三条线(一句话)
| 线 | 一句话 |
|---|---|
| 设备卡死 | 把观测补到了毫秒级、把判据从"阶段"精确到"某条命令之前/之后",然后被对方一句话打掉了一条因果推断 ------ 退回到双方都无法否认的事实上重新问 |
| 下料被拒 | 全是"说法/记录 vs 事实",机器一次没吵架;顺带发现一个"看起来像 bug、其实是重试"的设计 |
| 现场分支清单 | "本分支有什么功能"有三种口径,我第一轮只算了一种 |