这篇文章不是方法论,是一份错题本。
从 9 月中旬起,我们让一个自动化班次每天 06:30 开工,跑同一个静态站的运营闭环:扫询盘、巡检链路、发一篇文章、推收录、落数据。45 天下来,真正让我做错决定的,几乎都不是脚本报错——报错反而是最友善的信号,它会停下来等你。真正贵的是那些「看起来一切正常」的时刻:部署命令退出码是 0、接口返回 ok、桶里躺着数据、日志里没有 ERROR。
下面这 12 个假信号,每一个都真实发生过一次,并且都让我在那一天做出了错误判断。我把当时的读数、误判过程、以及后来固定下来的判定方法一起写出来。
| 类别 | 典型表现 | 真实成因 | 代价 |
|---|---|---|---|
| 部署类 | 刚部署完的新页面 404 | CDN 缓存未过期 / 编译其实失败了 | 白等 5 分钟,或把失败当成功 |
| 可达性类 | 所有 URL 都 200 | 重定向被跟随,404 被洗成 200 | 死链上线一个月无人知 |
| 通道类 | 接口 ok:true,通道全 sent | 多腿冗余掩盖单腿死亡 | 询盘静默丢失 |
| 计数类 | 数据「在涨」,条数「正常」 | 口径错:字符当字节、CSS 当内容、满桶当空桶 | 所有趋势判断失真 |
四类的共同点是:错误的信号都来自「主路径自己」。部署脚本告诉你部署成功,接口告诉你送达成功,计数脚本告诉你数字在涨。要破这个局,唯一办法是给每个结论配一条不经过主路径的第二证据。
10-08 那次,部署输出 Deployment complete,我立刻巡检,得到三个读数:新文章 URL 404、sitemap 条数还是 36(应为 37)、version.json 的 built_at 还是前一天。三个信号全部一致地指向「部署没生效」,我差点回去重跑一遍部署。
实际上只是 Cloudflare Pages 静态资源带了 max-age=300,我读到的是 5 分钟前的镜像。把 ?cb=$(date +%s) 加在 URL 后面,同一秒三个读数立刻变成 200 / 37 / 当天。
固定判定:判「部署失败」之前,必须先用缓存穿透参数复测一次;或者显式等满缓存期。这条规则后来救了我至少三次,因为「未生效」和「缓存」在所有外部表现上完全同形。
# 错:直接读,读到的是缓存
curl -s -o /dev/null -w "%{http_code}" https://example.com/blog/new-post
# 对:带时间戳穿透
CB=$(date +%s)
curl -s -o /dev/null -w "%{http_code}" "https://example.com/blog/new-post?cb=$CB"
我们的部署脚本会在调用 wrangler 之前先写 version.json,把 built_at 改成当前时间。10-23 08:25:53,wrangler 编译失败(Unexpected "||"),但 version.json 已经被写成新时间。于是「版本号前进」这个信号与部署是否成功完全脱钩——它只证明脚本跑到了这一步。
固定判定:版本文件里的时间戳只能当「脚本执行时刻」,不能当「线上生效时刻」。线上生效时刻必须从线上实读,且必须与「新 URL 200」同时成立才算数。
部署输出 Uploaded 0 files (84 already uploaded) 时,第一反应是「这次什么都没更新」。实际是内容寻址去重:文件内容没变的就不重传,只传变化的那个。真正改了文件的那次,输出可能是 Uploaded 1 files (96 already uploaded),也可能是 0——取决于 CDN 侧是否已有同一份内容哈希。
固定判定:上传条数不是任何结论的证据。唯一证据是线上产物:新 slug 是否 200、sitemap 条数、version.json 内容。
10-28 早上,所有部署命令突然全部失败,报 ETARGET miniflare@5.20261023.1-beta。代码一行没改,是 npx wrangler 当天解析到了上游刚发的 beta 依赖链,装不上。整个部署能力被上游的一次发版掐断。
固定做法:在项目目录装定版 npm i --no-save wrangler@4.42.0,让 npx 优先用本地 node_modules。另外注意:如果上级目录有 package.json,安装可能被它吞掉(报 added 48 packages 但目标目录里没有),必须 ls node_modules/wrangler/package.json 核实。
10-04,我用带 -L 的批量脚本核查 sitemap 里的 25 条 URL,结论是「全站健康」。后来换成不带 -L 的口径重跑,发现其中 16 条实际是 404,只是站点把 404 页做了 308 跳转到首页,-L 一路跟到首页拿到了 200。
固定判定:核查可达性一律不带 -L,把 3xx 单独计数;只有需要确认「最终落点」时才跟,并且要同时记录跳转链。这两个问题不是同一个问题。
线上 /blog.html 返回 308 且响应体为空。直接 grep 这个响应,得到「NEW 标记 0 个、新卡片 0 张」,看上去像「新文章没进列表页」。实际上真实地址是 /blog,内容一直都在。
固定判定:对任何站点做内容断言前,先确认这条 URL 是终态还是中间态。-L 在这里该用(要落点),在第 5 条里不该用(要原始状态码)——同一个参数,两种语义,混用必错。
IndexNow 的提交接口只要格式对就返 200/202,它不校验 URL 是否真实存在。更糟的是 key 也不校验内容:随手编一个 32 位 hex 提交,同样返 200。这意味着「推送成功」这个结论可以完全是假的。
固定判定:推送前先确认两件事——该 URL 线上是 200;keyLocation 指向的 key 文件线上可访问。另外,站点根目录若存在两个同格式的 key txt,用 ls | grep 取 key 会一次拿到两行,直接导致 400,必须写死 key。
顺带记一条:Bing 的 ping 接口已经返回 410 Gone,接口下线,不是站点问题,别在这上面浪费时间排查。
我们的询盘接口把同一条数据同时写 5 个落库桶 + 1 个邮件通道,目的是「任意一条活着就不丢单」。这个设计在可用性上是对的,但在可观测性上是灾难:它会系统性地把单腿死亡伪装成整体健康。
10-21 的响应里,顶层 ok 是 true,而顶层 sink 字段是 null——因为真实结果挂在 results.sink 里。我第一眼读顶层 sink 为 null,差点判成「全部通道失败」;反过来,只看 ok:true 又会判成「全部成功」。两个方向都能错。
固定判定:健康检查必须逐腿读 results 下每个通道的字段值,把「sent 数 / 总数」当唯一指标。10-27 的实测是 sink = sent×5、ses = sent,同一时刻 ntfy 是 522、formsubmit 是 429、mail 是 401——三条腿已经死了两天,因为还有 sent 所以从来没报过警。
早期为了省事,页面遥测和询盘落库共用了几个桶。结果是:遥测把桶的日额度吃光,询盘腿稳定出现 failed:429。这个错误看起来像「网络抖动」,重试就好;实际是单腿永久死亡,重试一万次也一样。10-19 那天 5 条腿里 4 条是 429,只有 1 条 sent,链路「可用」,但询盘有 80% 概率落到已经死掉的腿上。
固定做法:计数/遥测这类量大且对丢失不敏感的流量,必须与业务落库物理隔离,各自独立桶。隔离后当天 5/5 全 sent。
| 读数 | 真实状态 | 处理 |
|---|---|---|
| GET 桶地址返回空 | 桶已被删除(过期) | 立即换桶 |
接口里出现 failed:404 | 该腿桶已过期 | 立即换桶,不要重试 |
| 总条数冻结在 50、末条时间不再前移 | 桶没死,但已满,新数据全丢 | 换桶,并降低采样率 |
| 首次请求耗时 20 秒以上但成功 | 函数冷启动 | 重试即可,不算故障 |
第三种是最危险的:桶地址照常返回数据、照常显示「total: 50」,如果你只看「有没有数据」就会认为一切正常。判定要看「末条时间是否还在前移」,不是看「有没有数据」。
还有一种反直觉的:GET /token/<uuid> 返回的过期时间对免费桶恒为 null,不能用它判到期。到期时间只在建桶时的 POST 响应里有。我们据此改成「建桶即登记 expires_at 到代码注释」,之后每次开班第一件事就是对日期。
判断「列表页里有几个 NEW 标记」时,grep -o "new-tag" | wc -l 得到 3,实际只有 1 个——因为样式表里有一行 .new-tag{...} 的定义,也被算进去了。同理,统计卡片数量时用 class="blog-card" 也会多算一行 CSS。
# 错:把 CSS 定义一起数进去
curl -s https://example.com/blog | grep -o "new-tag" | wc -l # 3
# 对:精确到完整标签
curl -s https://example.com/blog | grep -o '<span class="new-tag">NEW</span>' | wc -l # 1
固定判定:统计口径要精确到「完整标签串」,而不是类名或属性名。报数之前先问一句:这个模式串能不能匹配到样式表?
用 Python 读文件后 len(s) 得到的是字符数,不是磁盘字节数。同一份中文 HTML,字符数 36,490、实际磁盘 50,694 字节,差 39%。更隐蔽的是 io.open().read() 会把 CRLF 折成 LF,再用 newline="
" 写回,文件会真的变小——于是「改前 55,311、改后 55,468」这种数字看着像增长了,其实前后口径都不是磁盘尺寸。
固定判定:所有尺寸只用两种来源——本地 os.path.getsize(),或 curl -w "%{size_download}"。Shell 里 ${#VAR} 拿到的同样是字符数,在中文页面里误差可以到 30% 以上。
我们按「每天约 7 个真实页面访问」去设计遥测抽样率(1/10),预期 50 条容量可以用满一周。实际新建桶 24 小时内就积到 48/50。反推真实页面命中约 480 次/天,是原假设的 70 倍。这不只是容量问题——它同时说明我们对「有多少流量」的认知整整差了两个数量级,而所有基于这个认知做的决策(要不要加内容、要不要投广告、转化率是多少)全部建立在一个错数字上。
固定做法:任何有硬容量上限的计数器,上线时先用「容量 ÷ 实测日均」反算寿命,并把这个反算写进代码注释。抽样率不是拍脑袋的常数,是容量约束下的解。
| # | 结论 | 不要信 | 要信 |
|---|---|---|---|
| 1 | 部署生效 | 部署命令输出 | 带 cb 的新 URL 200 + 线上 version 实读 |
| 2 | 线上版本时间 | 本地 version.json | 线上拉取的 version.json |
| 3 | 文件上传 | Uploaded N files | 线上产物内容比对 |
| 4 | 部署工具可用 | npx 最新版 | 本地锁定版本的 wrangler |
| 5 | URL 可达 | curl -L 的最终码 | 不跟跳转的原始状态码 + 跳转链 |
| 6 | 列表页内容 | 带 .html 后缀的地址 | 先确认终态 URL |
| 7 | 推送成功 | 接口 200 | URL 线上 200 + key 文件可访问 |
| 8 | 通道送达 | 顶层 ok / 顶层 sink | results 下逐腿字段 |
| 9 | 链路健康 | 「还有 sent」 | sent 数 / 总腿数,并逐腿记死因 |
| 10 | 桶还活着 | 「有数据」 | 末条时间是否还在前移 |
| 11 | 内容计数 | 类名 grep | 完整标签串匹配 |
| 12 | 文件尺寸 | len(str) / ${#VAR} | os.path.getsize / size_download |
12 条逐个记是记不住的,但归到三条原则就够用:
一、每个「成功」都要有一条不经过主路径的第二证据。部署脚本说成功不算,线上要能读到;接口说送达不算,落库桶里要有那条记录;推送接口说 200 不算,URL 要真的能打开。主路径的自证永远是循环论证。
二、每个「失败」都必须写出具体成因,写不出就记为「未知」。这是我们最有效的一条纪律。「部署失败」不是成因,「缓存未过期导致读到旧镜像」才是。45 天里被误判为故障的案例,几乎全部落在这四类:缓存、冷启动、抽样、口径。分不清是哪一类,就不要动手「修」,因为很可能根本没有坏。
三、所有数字都要能被别人用另一条命令重算出来。尺寸写「字节」并注明获取方式,条数写「精确匹配串」,比例写「分子/分母」。一个不能被重算的数字,在自动化系统里等于一个随机数——它会一路传递下去,污染后面所有决策。
最后一句实话:这套东西不是设计出来的,是被坑出来的。上面每一条背后都是一次「我以为」和一次「实际是」。自动化真正的门槛不在写脚本,而在于你得先承认自己的观测系统本身会撒谎。
如果你的站也在跑自动化(定时发布、埋点落库、多通道通知),并且遇到过「明明发了但搜不到」「接口没报错但数据没进来」这类问题,可以直接找我们做一次链路体检:逐条通道实测送达、逐项判据核对口径,输出一份带真实读数的报告。
不要只看部署命令的退出码和输出。我们固定用三条独立证据交叉:①带缓存穿透参数(?cb=时间戳)请求新页面,必须是 200 且字节数与本地文件一致;②读 version.json 的 built_at,必须是本次部署时刻;③读 sitemap 条数,必须是本次的新值。三条里任意一条不动,就当没部署成功,不要用「等一会儿会好」糊过去。
Cloudflare Pages 静态资源的 max-age 常见是 300 秒。我们在 10-08 那次就是部署报完成后立刻巡检,拿到新页面 404、sitemap 还是旧值、version.json 还是前一天,三个信号全部指向「部署失败」,实际只是缓存。加 ?cb= 后同一秒就变成 200。结论:判失败前必须先穿透缓存复测一次。
ok 只代表请求被函数接住并跑完,不代表下游每个通道都成功。我们的询盘接口有 5 条落库通道 + 1 条邮件通道,10-27 实测顶层 ok 为 true、sink 显示五条全 sent,同一时刻 ntfy 522、formsubmit 429、mail 401 三条已经死了。判健康必须逐条读 results 里的每个通道字段,不能读总开关。
实测三种,表现完全不同:①GET 桶地址返回空 → 桶已被删除(过期后自动删);②接口里出现 failed:404 → 该腿桶已过期;③总条数冻在 50、末条时间不再前移 → 桶没死但已满,新请求全部丢弃。第三种最阴,因为桶地址还能正常返回数据,看着一切正常。
给每个「成功」配一条与主路径无关的第二证据,给每个「失败」强制写出具体成因。写不出成因的失败,一律记为「未知」而不是「故障」——我们的 45 天里,被误判为故障的绝大多数其实是缓存、冷启动、抽样、口径四类问题。另外所有统计都要用能被别人重算的口径,比如尺寸只用 os.path.getsize 或 curl 的 size_download,绝不用字符串长度。