上周我给一批机器推了个配置项,脚本跑完告诉我:120 台里成了 114 台。我扫了一眼那 6 台失败的去查通道问题,剩下的就没管了。

隔了几天我随手抽了几台复查,发现有台机器的配置压根没变。我以为是我记错了机器名,又查了两台,还是没变。这时候我才反应过来:那 114 台里到底有多少是真的改成了,我根本不知道——我只知道脚本说它成功了。

我决定把这件事从头拆一遍。

「成功」这个词其实混着两件事

我以前写批量脚本,判断成功的条件就一行:if ($LASTEXITCODE -eq 0)。这行代码回答的是「命令觉得自己干成了吗」,而我真正想知道的是「设备现在是不是那个状态了」。这两个问题在大多数时候答案一样,所以我一直没出事;一旦不一样,我是最后一个知道的。

要量化这件事,我得先造一批「答案不一样」的机器。我写了个模拟器,120 台,按我这些年见过的比例塞了七种情况:

const plan = [
  ['ok',          0.60],  // 报成功,也真生效
  ['silent',      0.14],  // 报成功,压根没写进去(权限被吞了)
  ['errbut',      0.08],  // 报错了,但其实写进去了
  ['delay',       0.08],  // 报成功,但要等刷新才落地
  ['rollback',    0.05],  // 报成功,也生效了,刷新周期一到被刷回去
  ['partial',     0.03],  // 报成功,五个键只写了三个
  ['unreachable', 0.02],  // 真失败
];

真值我定义在「下发完 15 分钟后稳定生效」,短命的成功不算成功。

只看退出码:36 台是假的

第一档,完全按我以前的老写法:

A 只看退出码          报成功 114 | 真生效  82 | 假成功  36 | 假失败   4
B 退出码+输出关键字     报成功 114→107 | 真生效  82 | 假成功  36→29 | 假失败   4

114 台「成功」里 36 台是假的,接近三成。这 36 台我全都会标绿,然后在报告里写「本次下发成功率 95%」。

加上输出关键字之后只救回 7 台,就是 partial 那一类——它其实在输出里说了 warning: 2 keys skipped,只是从来没人读。剩下的 29 台,退出码是 0,输出也漂亮,撒谎撒得很整齐。

我在这儿明白了一件事:退出码和输出都是设备自己说的话,不是设备的状态。想在"它说了什么"这一层打补丁,能打的洞很有限。

读回真实状态:我第一版还是错的

那就不听它说,自己去读。下发完立刻把配置读回来,跟目标值比:

C 独立通道立即读回     报成功  78 | 假成功   7 | 假失败  11

假成功从 36 掉到 7,但冒出来 11 台假失败。我先以为程序写错了,查完发现是我自己蠢:「立刻读回」和「延迟生效」天生冲突——那 11 台还在待生效队列里,我读得太早了,把它们判成了失败。

更让我后背发凉的是这一档:

C2 同一通道读回       报成功 107 | 假成功  36

这是我第一版的真实写法。下发走的是一条远程会话,读回我图省事复用了同一个会话——结果假成功一个没少,还是 36。我等于是问犯人他自己有没有作案。 那条通道既然能把写入失败吞成「成功」,它读回来的一样是这个假象。

等一个刷新周期再读,才终于干净

把读回挪到刷新之后:

D 独立通道延迟读回 300s   报成功  82 | 真生效  82 | 假成功  0 | 假失败  0

干净了。我顺手扫了一遍「到底该等多久」:

等   0s  错判合计  18(假成功 7 + 假失败 11)
等  60s  错判合计  18(假成功 7 + 假失败 11)
等  90s  错判合计  11(假成功 0 + 假失败 11)
等 120s  错判合计   0
等 300s  错判合计   0

拐点在 120 秒,正好是 max(延迟生效 120s, 回滚 90s)。这两个盲区方向是反的:读早了把「还没落地」当失败,读晚了把「已被刷回」当成功。等待窗口的下界由前一个决定,上界由后一个决定。

用真文件再跑一遍,别只信自己造的模拟器

上面那组数字我自己是不太敢用的,毕竟故障模式是我自己编的,我想让它多假它就多假。所以我又写了个版本:真在磁盘上建 120 个文件,故障也全是真的。

silent 那 22 台,我把它们的目标换成了同名目录,写的时候是真的撞 EISDIR;unreachable 那 2 台的父目录是真的不存在;rollback 是在刷新步骤里真的被另一个文件覆盖回去;partial 是真的只写了一行、少了一行。

A 只看退出码        报成功 114 | 真生效  82 | 假成功  36 | 假失败   4
B 退出码+输出关键字    报成功 107 | 真生效  82 | 假成功  29 | 假失败   4
C 立即读回(t+0)     报成功  78 | 真生效  82 | 假成功   7 | 假失败  11
D 刷新后读回        报成功  82 | 真生效  82 | 假成功   0 | 假失败   0

和模拟版一模一样。两套代码、一套内存一套磁盘,最后给同一个答案,我才敢把这篇发出来。

但真实世界的刷新周期是 90 分钟,我等不起

上面那个 90 秒是我自己编的小周期。换成真实的策略刷新周期就崩了:

等  300s 复查  假成功 7   总耗时 5 分钟
等 1800s 复查  假成功 7   总耗时 30 分钟
等 3600s 复查  假成功 7   总耗时 60 分钟
等 7200s 复查  假成功 0   总耗时 120 分钟

按微软 MS-GPOL 协议文档 的 Computer Policy Refresh Interval:非域控客户端是 90 分钟 + 0~30 分钟随机偏移,域控制器是 5 分钟;Group Policy processing 那页写的也是同一个数(核对日期 2026-10-23)。也就是说我要拿到真实答案,得挂机两小时。

所以我的做法不是干等,而是主动触发刷新再验:微软那页里写了可以用 gpupdate.exe 或 Invoke-GPUpdate 手动催一次。等不了的场景,我就老老实实把这批标成「未确认」,绝不标成功——宁可报告上挂几台待办,也不要一个漂亮的成功率。

重试救不回来,这是我最后放弃的那条路

读回不通过就重试,是我想当然以为有效的最后一招:

重试上限 0  报成功 82 | 挂起人工 38 | 下发次数 120 | 总耗时  5.1 分钟
重试上限 1  报成功 84 | 挂起人工 36 | 下发次数 158 | 总耗时 10.2 分钟
重试上限 2  报成功 84 | 挂起人工 36 | 下发次数 194 | 总耗时 15.2 分钟
重试上限 3  报成功 84 | 挂起人工 36 | 下发次数 230 | 总耗时 20.2 分钟

重试 1 次多救回 2 台(就是那 2 台瞬时不可达的),重试上限从 1 加到 3,一台都没多救回来,白等 10 分钟、白下发 72 次。剩下那 36 台是「根本写不进去」和「写完被策略刷回去」,重来一百次也是同样的结果。

我给重试留了上限 1 次,再不通过就挂起人工。挂起 36 台听着难看,但比假装 114 台成功强得多。

我现在的写法:三段式

// 1. 目标写成「最终应该是什么」,不是「执行哪条命令」
const desired = { config: 'Hardened=1', audit: 'Audit=1' };

// 2. 读回必须走独立通道,且按完整契约比对(不然只改了一半也过)
function verify(host) {
  const actual = readViaOtherChannel(host);   // 关键:不是下发用的那条路
  return deepEqual(pick(actual, Object.keys(desired)), desired);
}

// 3. 不通过 → 重试 1 次 → 还不过就挂起,绝不标成功
async function ensure(host) {
  for (let i = 0; i < 2; i++) {
    await apply(host);
    await triggerRefresh(host);
    if (verify(host)) return 'done';
  }
  return 'pending-human';
}

写完这套我才意识到,这就是 PowerShell DSC 那个 Get/Test/Set 三段式——我绕了一大圈,最后撞回了微软早就写好的东西。它的资源创作清单里有一句我特别认同:应用完配置之后要单独调 Get-DscConfiguration 去核,因为「调用 Start-DscConfiguration 时不会出现该区域的问题」;同一篇还要求反复调 Set-TargetResource 验证幂等(核对日期 2026-10-23)。

我的判断

我以前把「命令返回 0」当成成功,本质上是我把执行权交出去了,却把验收权也一起交了出去。下发模块既是运动员又是裁判,它当然说自己跑完了。

现在我看到任何批量操作的结果,第一个问题不再是「成了几台」,而是「这个数字是谁报的」。报数的人如果也能决定自己算不算成功,那这个数就是废纸。

另外那个「同通道回读」的坑我记很久。它不报错、不失败、日志干净,只是让你白验一遍然后继续心安。这类 bug 比报错的 bug 危险得多。

最后修改:2026 年 10 月 23 日
如果觉得我的文章对你有用,请随意赞赏