起点、口径与因果:今天用错的七把尺子

起点、口径与因果:今天用错的七把尺子

标签:诊断口径、判据设计、因果与相关、探针可信度、统计陷阱、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%」。数字是真的 ------ 但没及格的人根本没被算进来。这在统计上叫幸存者偏差

自查问法(很值得背下来)

「我能看到这个值,是因为它本来就是这样,还是因为『只有它是这样时我才看得见』?」

  • 若是后者 → 这个字段是同义反复不能当证据
  • 同一次探针里,不在跳出条件里的那个字段 (例如硬件线上的实际电平)→ 才是真测量

但还要往下问一步 :这个毛病严重不严重

  1. 它是同义反复吗?(是)
  2. 少掉的这条信息,别处能不能补上?(能 → 不严重)

顺带暴露的另一面 :这个探针只在"事情顺"的时候在场 ------ 一旦进入"一直等不到"的那种故障,它连出现都不会。同一个毛病的两面。

2.3 尺子的抖动 ÷ 要测的差异 > 10 → 这把尺子测不了

要证明"我加的那个探针不拖慢生产",我原来的办法是比总耗时。被连问三轮才拆开:

  • 要称的东西 :探针那两下"看一眼设备"花的时间 ------ 毫秒级
  • 我用的秤 :整趟动作花多久 ------ 十几秒 ,而且它自己就抖 (几百次实测的波动比我要测的差异大三十倍

指针根本不动,读不出来。

生活类比 :想称一根头发 的重量,用的却是体重秤

动手比之前先算一遍

抖动 ÷ 差异 结论
< 1 能测出来
≈ 1 勉强
> 10 这把尺子测不了,换方法

正确做法别绕道比总耗时,直接量那个动作自己 ------ 在它前后各掐一个时间戳,差值就是答案,不受任何外部抖动影响。

还有更要紧的一条看最坏情况,不看平均值。

那个探针带了超时 ------ 万一设备不理人,它会把后续步骤推后最多半秒。这才是真正要盯的风险。

判据就是 :这段时间里超时触发了几次?(零次 → 不影响生产。)

验证"加了东西会不会影响生产",问"最坏那次有没有触发超时",而不是"平均慢了几毫秒"。

2.4 离群值先看,别先算

第 1.3 那个假异常,是拉了那两三个离群样本的完整上下文 才发现的。

如果直接按统计结果报出去,就是一条假告警。

报异常之前,手工拉一两个离群样本,看一眼它前后到底发生了什么。


三、怎么查:三个可复用的手法

3.1 构建指纹法:从"痕迹"反推"版本"

问题 :现场程序不打版本号,怎么知道它跑的是哪一次编译的产物?

手法 :找某次代码改动产生的、必定出现的日志行,去现场日志里搜。

复制代码
某个提交加了一行日志  →  从那之后编译的版本里一定有这行
                     →  现场日志里有没有它,就知道版本在它之前还是之后

选指纹的三条要点

  1. 选"降噪型"改动最灵 ------ 那种让"某类日志永久消失"的改动,指纹最干净(有/无是二元的)
  2. 选"必定出现"的 ------ 启动日志、每循环日志可信;只在特定场景才打的不可信(要等某个条件触发,首现时刻会误导)
  3. ⚠️ 二进制文件坑 :搜索工具撞上二进制字节会静默截断 → 要加"按文本处理"的参数

配套 :某个指纹首次出现 的时间 最后一次换版本的时间。正确做法是把当天所有"程序启动"列出来,看最后一次是哪个。

这次靠它破了一个真问题:现场到底换没换可执行文件、什么时候换的。

"没出现"也是证据 ------ 用来反证"这版里不含那条改动",跟"出现了"同样有用。

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 线或对方设备侧 ⚠️ 分不出"线断"还是"对方真的不给"
跳变 跟着跳 接触不良 / 干扰

第三行那个盲区只能现场解:看对方设备那侧的输出灯,或用万用表量线。

⭐ 而且:我们从来没"命令"过对方必须执行。 我们只是按了个按钮

按不按由我们;执不执行,对方自己判断。


六、收尾:今天最值得背下来的六句话

  1. 报"有多少"之前,先想清楚有几种合理口径 ------ 数字必须带着口径 一起说。只给一个口径的数字,看起来精确、实则误导
  2. 写"因果"之前先自问:我手上这条是"一起出现",还是"因为所以"?
  3. 每写一个探针,问一句 :我能看到这个值,是因为它本来就是这样,还是因为只有它是这样时我才看得见
  4. 动手比之前先算 :尺子的抖动 ÷ 要测的差异。> 10 就换方法。
  5. 一个指标有两个来源 → 先分开统计新指标的起点必须跟判据同源(优先复用现成计时器)。
  6. "看见"比"顺手改"更重要 ------ 先把事实记下来,改动另立议题。

附:今天的三条线(一句话)

线 一句话
设备卡死 把观测补到了毫秒级、把判据从"阶段"精确到"某条命令之前/之后",然后被对方一句话打掉了一条因果推断 ------ 退回到双方都无法否认的事实上重新问
下料被拒 全是"说法/记录 vs 事实",机器一次没吵架;顺带发现一个"看起来像 bug、其实是重试"的设计
现场分支清单 "本分支有什么功能"有三种口径,我第一轮只算了一种
相关推荐
曹牧1 小时前
C#:HashSet<T> 去重
c#
c#上位机14 小时前
C#上位机项目实战——Task.Run执行带参数的方法
c#·上位机
Lost of 程序猿20 小时前
用 YARP 为 .NET 微服务搭一座“立交桥“:路由转发与生产配置实践
c#·asp.net
点纭1 天前
C 语言 第七章 常用函数(3)
c语言·开发语言·算法·oracle·c#
用户788477316341 天前
用 .NET MAUI 一套 C# 同时出 Windows 和 Android:哪些是真能共享,哪些是 marketing
c#
Lost of 程序猿1 天前
用 Quartz.NET 优雅地处理 .NET 定时任务:从选型到 Docker 部署全记录
后端·c#·asp.net
林川~011 天前
Unity 万能物理检测工具:射线检测 / 范围检测 / 层级过滤 / 编辑器可视化(可直接拿去用)
游戏·unity·c#·射线检测·通用工具
He BianGu2 天前
【WPF-VisionMaster】机器视觉通用平台V5.0版本发行说明
opencv·c#·wpf·halcon·机器视觉·visionmaster
tang_04272 天前
【Hi.Ltd 专题】第9期:Interop 配置互操作(JSON/INI/XML/YAML/注册表/扫码)
经验分享·c#·hi.ltd系列