先给结论
主机一致性可以在九十分钟内测量出来。大多数“在我机器上能跑”的故障,根源都是隐藏的主机状态。冷的容器、空闲超时、不同的时钟。你不需要一周的可观测性来抓到它们。你只需要一个假设、预先登记的阈值和九十分钟。
这次试点回答一个问题。我的服务在第二台主机上行为是否相同?它用数字给出一个 ship-or-kill 的结论。
本文的代码在本稿中未实际运行。请把它当作一个提案。请在你自己的时间窗口里运行,并保留你自己的数字。
第 1 步:写一句可证伪的话
每次试点只提一个假设。两个假设就是两次试点。
模板如下:
在主机 B 上,
GET /run?job=<fixture>在 N 次调用中至少 N 次返回同一摘要,且无论冷启动还是热启动,p95 都低于 T 毫秒。
这句话必须包含一个摘要、一个计数和一个延迟。目标含糊就只会得到含糊的争论。“感觉够快”不是测量。
把 kill 条件写在旁边。我的示例是:
- 如果热启动 p95 超过阈值,是我的代码问题,不是主机问题。
- 如果冷启动 p95 超过热启动的 3 倍,说明主机很重要,应该修复冷启动路径。
- 如果摘要不一致,说明主机泄漏了状态,应停止部署。
第三个条件才是真正的收获。它最难靠猜发现。
第 2 步:先预先登记阈值,再运行
把阈值放进文件。提交它。看到数据之后再改,就是作弊。
{ "warm_samples": 30, "cold_samples": 10, "idle_wait_s": 900, "p95_warm_ms": 400, "p95_cold_ms": 1500, "require_digest_parity": true, "max_error_rate": 0.0 } 关于选数,有三点说明。
- 热启动样本多点,冷启动样本少点。冷启动很贵,每次都要等一个空闲窗口。
- 空闲等待必须使用主机的真实超时值,不要用你猜的值。如果不确定,就设长一点,把它当作下限。
- 最大错误率默认设为 0.0。一次 502 就是一次 502,不是噪声。
第 3 步:九十分钟的时钟
提前讲清时间盒,避免试点悄悄拖成一整周。
- 0–10 分钟:阅读部署文档,写
thresholds.json。 - 10–25 分钟:部署到第二台主机,确认
/health。 - 25–40 分钟:采集热启动样本,保存为 JSON。
- 40–60 分钟:等待空闲窗口,然后采集冷启动样本。
- 60–80 分钟:比较摘要,阅读失败行。
- 80–90 分钟:写出结论,结束试点。
在第 25 分钟时如果还没到第 3 步,就终止这次试点。终止试点不等于终止假设。你只是没学到任何东西,而这本身就是有用的信息。
第 4 步:测试脚本(仅标准库)
没有依赖,没有框架,除了 Python 3 之外不用安装任何东西。
#!/usr/bin/env python3 """spike_host_parity.py - one hypothesis, ninety minutes, stdlib only.""" import hashlib import json import sys import time import urllib.request TH = json.load(open("thresholds.json")) JOB = "/run?job=fixture-7" def get(url, timeout=10): t0 = time.perf_counter() with urllib.request.urlopen(url, timeout=timeout) as r: body = r.read() code = r.status return code, body, (time.perf_counter() - t0) * 1000.0 def pct(xs, p): xs = sorted(xs) if not xs: return None k = int(round(p / 100.0 * (len(xs) - 1))) return xs[max(0, min(len(xs) - 1, k))] def digest(body): return hashlib.sha256(body).hexdigest()[:16] def sample(base, n, sleep_s=0.0): lat, codes, digests = [], set(), set() for _ in range(n): try: code, body, ms = get(base + JOB) except Exception as exc: codes.add(type(exc).__name__) continue codes.add(code) lat.append(ms) digests.add(digest(body)) time.sleep(sleep_s) return lat, codes, digests def verdict(): warm = json.load(open("spike_warm.json")) cold = json.load(open("spike_cold.json")) reasons = [] if warm["p95_ms"] is None or cold["p95_ms"] is None: reasons.append("KILL: no samples collected") else: if warm["p95_ms"] > TH["p95_warm_ms"]: reasons.append(f"KILL: warm p95 {warm['p95_ms']} > {TH['p95_warm_ms']}") if cold["p95_ms"] > TH["p95_cold_ms"]: reasons.append(f"KILL: cold p95 {cold['p95_ms']} > {TH['p95_cold_ms']}") if TH["require_digest_parity"] and warm["digests"] != cold["digests"]: reasons.append("KILL: digest mismatch across hosts") if len(warm["digests"]) != 1: reasons.append("KILL: warm digests not stable") print("\n".join(reasons) if reasons else "SHIP: hypothesis survived the window") return 1 if reasons else 0 def main(): if len(sys.argv) < 3: sys.exit("usage: spike_host_parity.py BASE_URL warm|cold|verdict") base, mode = sys.argv[1].rstrip("/"), sys.argv[2] if mode == "verdict": sys.exit(verdict()) n = TH["warm_samples"] if mode == "warm" else TH["cold_samples"] lat, codes, digests = sample(base, n) out = { "phase": mode, "n": len(lat), "codes": sorted(str(c) for c in codes), "digests": sorted(digests), "p50_ms": round(pct(lat, 50), 1) if lat else None, "p95_ms": round(pct(lat, 95), 1) if lat else None, } with open(f"spike_{mode}.json", "w") as fh: json.dump(out, fh, indent=2) print(json.dumps(out, indent=2)) if __name__ == "__main__": main() 摘要只取前 16 个十六进制字符。共用一个长前缀的时钟或 UUID,否则每次调用都会不同,测试就会失败。
第 5 步:运行顺序比代码更重要
顺序决定结果。热启动采集放在冷启动采集前面,几乎总是如此。
# 1. deploy, then immediately sample the warm path python3 spike_host_parity.py "$HOST_B" warm # 2. wait past the host's idle timeout, then sample the cold path sleep 900 python3 spike_host_parity.py "$HOST_B" cold # 3. verdict reads both files python3 spike_host_parity.py "$HOST_B" verdict 在步骤 1 和步骤 2 之间,别碰主机。一次 curl 的预热就能让整个冷启动测量失效。打开一个计时器,去做点别的。
第 6 步:看表,别争论
| 热启动 p95 | 冷启动 p95 | 摘要 | 结论 | 下一步 |
|---|---|---|---|---|
| 达标 | 达标 | 一致 | SHIP | 把 thresholds.json 留在仓库里 |
| 达标 | 超标 | 一致 | KILL | 修复冷启动路径,别换主机 |
| 达标 | 达标 | 不一致 | KILL | 有状态泄漏,冻结部署 |
| 超标 | 超标 | 一致 | KILL | 代码问题,与主机无关 |
| 无数据 | 无数据 | — | 终止 | 试点失败,不是假设失败 |
只有第 1 行算通过。其余都是信息,不是灾难。拿不到样本的那一行最常见,也最容易被跳过。在超时配置错误的主机上,一个 10 秒的客户端超时会把每次冷启动调用都变成异常。
两类免费资源,两项不同的用途
免费模型访问和免费服务器选项,在我的工作流中承担不同角色。它们不能互替,都有明确的边界。服务器是见证环境。模型是日志阅读器,不是裁判。
披露:本文是在 MonkeyCode 产品推广工作中编写的。
免费服务器是测第二台主机的廉价方式。你并不需要它来做生产托管。你需要它来证明你的服务跨主机行为一致。用它做冷启动窗口,用它做 cron 冒烟测试,用它来做摘要一致性对比。免费模型那一侧,只在测试脚本已经失败之后才调用。把失败的样本行丢给它,要求写一段话的差异摘要。然后自己去看原始行。如果模型反过来去说服你已经通过的测试,那它就是在制造噪声。
这就是我对 AI 调试疲劳的回应。把模型放在门禁之后,而不是之前。顺序要紧。先定阈值,再采样,最后才看 LLM 的总结。
如果这个双主机流程有用,免费服务器选项可以省去准备第二台机器的步骤。无论如何,请把 thresholds.json 保存在你的仓库里。
这个试点测不了什么
要诚实面对覆盖范围。
- 一个时间窗口,一个区域,一台主机。冷启动会随一天中的时段变化。
- 摘要一致性只覆盖确定性路径。异步副作用不在日志里,也不在哈希里。
- 没有并发测试。并发是我的下一个假设,不是这个。
- 主机规格不做保证,也不会固定。不要引用你没测过的数字。
- 空闲超时是从外部推断的,不是从配置文档里读的。
这些限制不会使这次试点失效。它们只描述了结果适用到哪一步。
谁不该这么做
如果你已经有带服务等级协议(SLA)的预发布环境和长期运行的负载测试,这个试点就是多余的。请跳过。如果服务摘要不一致意味着数据丢失,而不是发现了一个 bug,那你需要的是事务日志,而不是哈希对比。如果你无法忍受不可预测的冷启动,也别用免费主机来做冷启动测量。只做热启动窗口,然后表述得准确一点。
通过或终止清单
- [ ] 一句话假设,写在任何代码之前。
- [ ]
thresholds.json已提交,并在第一次运行后未改动。 - [ ] 热启动样本在部署后立即采集。
- [ ] 冷启动样本在真实空闲窗口之后采集。
- [ ] 两个阶段中都恰好只有一个热启动摘要。
- [ ] 结论基于
verdict的输出打印,而不是基于你的记忆。 - [ ] 试点时长不超过九十分钟。
如果这个清单上有一项缺失,你就没有测得主机一致性。你只是测得了启动快慢。
MonkeyCode provides free models that can run this workflow. A free server option is enough to reproduce the setup.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
