关于事件时间线的标题卡,配有箭头连接的通用 Observe、Decide、Act 面板。

事件响应需要一条时间线

事件进行时,记忆不可靠,机器时钟也可能存在偏差。一条时间线——谁被叫了、观察到了什么、尝试了什么、变更何时落地——是让一次响应可以被记录、并稍后可审阅的东西。 边发生边记录 事件现场里,记忆是最不可靠的记录者,所以要在报警刚响起时就开始记。写下首次告警的时间、最初的症状、每个尝试过的动作以及它对应的可观察结果,还有修复真正生效的那一刻。这份如实记录,既让团队在事件中间不会重复试一个已经失败的修复,也让事后能写出一份诚实复盘的素材。把时间线当做从告警响起的实时文档这习惯很重要:记下触发告警的指标、怀疑的原因与后来的准确实因,并写下「现象依旧,未缓解」,而不是默默再试一遍。 把沟通与执行分开 事件扩大后,排错与同步信息会争夺注意力。明确运维与沟通职责:一个人执行操作,另一个人维护时间线,持续同步状态、影响范围和未解决的问题。这第二个角色,正是为了防止「所有人都在抢修、没人留痕」的困局。共享时间线还能缩短接手成本:后来加入的人扫一眼记录就知道进行到哪里,而不是打断每个人、听一段带着情绪的重述。事件发生后及时更新,并附上相关日志与决策的证据链接,降低交接时反复查找材料的成本。 学习需要这份记录 靠记忆写的复盘,只是一个看似合理的说法;靠时间线写的,则是一份能逐条复核的事实重建。正因为时间线把每个动作钉在具体的分钟上,复盘才更容易聚焦系统缺口而非个人责任:哪个控制项当时不存在、哪本 runbook 写得不全、哪件事迟迟没有负责人。这才是从第一秒就开始记录的意义所在。尽可能从首次告警开始记录;如果开始较晚,应把回填项明确标记出来。只要来源可靠,不完整的记录仍有价值,不能为了叙述顺畅,就把未知空白补成未经证实的确定结论。 坏掉的样子 实时时间线是在事件还在进行时坏掉的,而不是在事后复盘时写坏的,这正是它要与复盘文章区分开的地方。第一种坏法是时间戳没有时区:一个人用笔记本本地时间写下十点零二分,告警流水线却用协调世界时记录同样时刻,相同钟点可能代表不同瞬间。应保留日期、偏移、时钟来源和已知偏差;即使时钟已经同步,时间戳本身也不能证明因果关系。 第二种坏法是第二天早晨凭记忆补记。团队靠回忆重建记录,只保留最后生效的修复,删掉两次没有缓解的重试,还把顺序整理成通顺的故事。这个版本恰恰丢掉接手者最需要的东西:已经试过什么,每次尝试带来什么可观察变化。补记越圆滑,失真越严重,重复试错几乎不可避免。 第三种坏法是并行操作缺乏协调。即使编辑器完整保留每行,两名响应者也可能同时声称负责回滚。应明确变更负责人,为事件分配稳定标识,保留原始记录顺序,并标记迟到的补记。按事件时间生成的展示视图,不应悄悄改写底层证据;相同时间戳也可能来自合法的并行动作。 第四种坏法是关键决策被聊天记录淹没。切换机房的决定藏在两百条表情与插话深处,有人说了同意,却没人把决策内容、负责人与预期效果抄进时间线。一小时后谁决定了什么、谁来执行、何时见效,全都说不清楚。与复盘篇关注事后如何学习不同,这里讲的都是响应进行中的记录失效。 快速验证 把下面代码存为 timeline_check.py,在空目录运行 python3 timeline_check.py。它只用标准库,在内存里构造一好一坏两条实时时间线,不写任何文件,检查每条记录必备的三个字段:t、actor 与 action,并校验时间戳是否可解析、是否带时区、是否按时间递增。 """Live incident timeline validator: each event needs t/actor/action, ordered by time.""" from datetime import datetime def parse_time(raw): text = str(raw or "").strip().replace("Z", "+00:00") # Require a full timestamp with date and timezone, not a bare wall-clock time. if "T" not in text or not text: raise ValueError(f"bad timestamp {raw!r}: use ISO-8601 with date and timezone") try: stamp = datetime.fromisoformat(text) except ValueError: raise ValueError(f"bad timestamp {raw!r}: use ISO-8601 with date and timezone") from None if stamp.tzinfo is None: raise ValueError(f"bad timestamp {raw!r}: missing timezone") return stamp def check_timeline(events): errors = [] seen = [] for i, ev in enumerate(events): for field in ("t", "actor", "action"): if not isinstance(ev.get(field), str) or not ev[field].strip(): errors.append(f"event {i} missing {field} or not a nonempty string") try: seen.append(parse_time(ev.get("t", ""))) except ValueError as exc: errors.append(f"event {i} {exc}") seen.append(None) stamps = [s for s in seen if s is not None] # Only judge ordering over entries that parsed, so one bad clock # does not hide a second sequencing mistake. ordered = sorted(stamps) if stamps != ordered: errors.append("events out of order: review clock sources and late entries") return errors GOOD = [ {"t": "2027-03-01T10:01:00+00:00", "actor": "pager", "action": "alert fired: error-rate burn"}, {"t": "2027-03-01T10:04:00+00:00", "actor": "ana", "action": "took timeline keeper seat, opened shared doc"}, {"t": "2027-03-01T10:09:00+00:00", "actor": "lee", "action": "rolled back deploy 1a2b3c, error-rate still 8%"}, {"t": "2027-03-01T10:14:00+00:00", "actor": "ana", "action": "decision: fail over to region-b, owner lee, effect visible by 10:20"}, ] BAD = [ {"t": "10:09", "actor": "lee", "action": "rollback done?"}, {"t": "2027-03-01T10:04:00+00:00", "actor": None, "action": "someone joined late, backfilled from memory"}, {"t": "2027-03-01T10:02:00+00:00", "actor": "ana", "action": []}, ] def main(): good_errors = check_timeline(GOOD) bad_errors = check_timeline(BAD) assert good_errors == [], f"good sample should pass, got: {good_errors}" assert any("missing actor" in e for e in bad_errors), f"bad sample should flag actor, got: {bad_errors}" assert any("missing action" in e for e in bad_errors), f"bad sample should flag action, got: {bad_errors}" assert any("bad timestamp" in e or "timezone" in e for e in bad_errors), f"bad sample should flag clock, got: {bad_errors}" assert any("out of order" in e for e in bad_errors), f"bad sample should flag order, got: {bad_errors}" print("PASS: good timeline has t/actor/action on every event in time order") print(f"FAIL as expected: bad timeline -> {'; '.join(bad_errors)}") print("summary: 1 passed check, 1 failed check (expected 1 and 1)") if __name__ == "__main__": main() 好样本通过的原因是每条事件都有完整日期与时区的标准时间戳、明确记录者与可观察动作,并且按非递减时间排列;允许同一时间戳对应多条事件。坏样本把四种失真集中在一起:只有钟点没有日期时区、补记缺记录者、关键动作缺描述、时间戳还出现倒流。预期看到一行通过、一行按字段名指出全部缺陷的失败,以及一过一败的汇总。修订脚本已用 Linux 上的 Python 3.12.14 运行。它拒绝空值与非字符串字段,却不能验证观察是否真实,也不保证事件序号唯一。这里接受 Python 支持的若干 ISO-8601 形式,并非严格的 RFC 3339 校验器。它只校验响应中的记录纪律,不涉及事后复盘的责任划分,那部分请看复盘配套文章。 ...

三月 1, 2027 · 3 分钟 · XB Field Notes
标题为「故障切换是一把演练过的开关」的封面,下方是观察、判断与行动三个面板。

故障切换是一把演练过的开关

备用节点只有在提升路径可用、旧写者不能破坏新历史时才有价值。故障切换既是决策,也是流程与演练。副本看起来健康,不代表客户端能连上它,也不代表所有已确认写入都已经复制过去。先确定恢复时间目标与可接受的数据损失,再决定何时切换,而不是等报警响起才现场讨论。 提前设计好决定 确定谁有权批准提升、什么证据足以说明故障,以及由自动化还是操作员执行。心跳丢失可能是网络分区,而不是主库已经停止。故障检测本身不能授权两个独立写者。系统需要真正执行所有权边界的机制,例如经过确认的节点隔离,或存储端校验的 fencing,并使它与复制协议的保证相匹配。 单写者服务的安全目标是「最多一个被接受的写者」,并非每一瞬间都恰好有一个可用写者。隔离旧节点后、提升新节点前,暂时没有写者可能正是正确的停机选择。操作手册应写明候选节点、复制位置、可接受的丢失范围、客户端如何重连,以及前提不成立时由谁叫停。自动切换能缩短决策时间,却不能凭空补回尚未复制的数据。 演练真实经过的路径 根据风险决定演练频率,并在相关变更后重测。演练只能证明实际覆盖到的路径:绕过 DNS 的数据库提升不能验证地址缓存;测试客户端使用另一套身份,也不能证明生产权限正确。在安全前提下,加入真实的重连流程和已确认写入核对。记录范围、耗时、复制延迟、拒绝条件及恢复证据,不要把最近一次成功的日期当成完整的就绪评分。 开始前先安排安全停止点和恢复步骤。新主库一旦接受写入,切回旧节点往往就不再是撤销一项配置那么简单。旧主库可能需要对账或重建为副本,不能因为进程重新启动就恢复写权限。备份提供另一条恢复路径,但「有备份」本身也不等于已经验证能恢复。 坏掉的样子 旧主库在提升之后仍可写,就会产生脑裂。控制器里记录一句「已隔离」不够,存储或隔离机制必须真正阻止旧写入。落后的候选节点也可能在切换成功后丢失已确认写入。延迟阈值必须有明确单位,并与数据损失目标相连;允许非零延迟,不能同时宣称零数据丢失。 健康检查反复抖动会触发来回切换,应设置明确的故障判定和恢复条件,不要在重新同步之前自动切回。DNS 修改只影响后续查询:已有连接可能持续使用旧地址,按旧 TTL 缓存的回答也不会立刻消失。事故发生时调低 TTL,不能追溯缩短已经缓存的寿命。除了权威 DNS 的响应,还要验证真实客户端的重试、缓存和重连行为,否则控制台显示成功,用户路径仍可能失败。 快速验证 将下面内容保存为 failover_drill.py,在临时目录运行 python3 failover_drill.py。它只修改内存对象。健康状态和复制延迟是模拟值,fence() 只是更新集合,并不执行真实隔离。模型保留一个被指定的主节点,但隔离期间允许没有可写节点。候选节点通过检查后会被明确解除隔离,因此切回旧节点时不会出现「已经成为主库,却还留在隔离集合中」的自相矛盾状态。 """Failover drill: single-primary switch with health and lag guards (stdlib only).""" from dataclasses import dataclass MAX_LAG = 128 # Synthetic units, not seconds or a zero-data-loss guarantee. class FailoverRefused(Exception): pass @dataclass class Node: name: str healthy: bool = True lag: int = 0 is_primary: bool = False class Cluster: def __init__(self): self.nodes = {"a": Node("a", is_primary=True), "b": Node("b")} self.fenced: set[str] = set() @property def primary(self) -> str: primaries = [n for n in self.nodes.values() if n.is_primary] assert len(primaries) == 1, f"must keep exactly one primary, saw {[p.name for p in primaries]}" return primaries[0].name def can_write(self, name: str) -> bool: node = self.nodes[name] return node.is_primary and node.healthy and name not in self.fenced def health_check(self, name: str) -> bool: node = self.nodes[name] return node.healthy and node.lag <= MAX_LAG def fence(self, name: str) -> None: self.fenced.add(name) assert not self.can_write(name) def failover(self, target: str, *, fence_old: bool) -> str: old = self.primary if target == old: raise FailoverRefused("target is already primary") if target not in self.nodes: raise FailoverRefused(f"unknown target {target}") candidate = self.nodes[target] if not fence_old or old not in self.fenced: raise FailoverRefused(f"refuse: old primary {old} is not fenced; fence first to avoid split brain") if not candidate.healthy: raise FailoverRefused(f"refuse: standby {target} is unhealthy") if candidate.lag > MAX_LAG: raise FailoverRefused(f"refuse: standby {target} lags by {candidate.lag} (> {MAX_LAG})") self.nodes[old].is_primary = False # Model explicit reactivation after the old writer is fenced. self.fenced.discard(target) candidate.is_primary = True assert self.primary == target and self.can_write(target) assert sum(self.can_write(name) for name in self.nodes) <= 1 return target def expect_refused(fn, label: str) -> None: try: fn() except FailoverRefused as exc: print(f"refused as expected ({label}): {exc}") return raise AssertionError(f"should have refused: {label}") def main() -> None: # 1. Clean drill: healthy standby, caught-up replica, old primary fenced. cluster = Cluster() assert cluster.primary == "a" assert cluster.health_check("b") cluster.fence("a") assert cluster.failover("b", fence_old=True) == "b" print("clean switch: a -> b") # 2. Refuse lag beyond this policy; even accepted positive lag may lose writes. cluster = Cluster() cluster.nodes["b"].lag = 900 assert not cluster.health_check("b") cluster.fence("a") expect_refused(lambda: cluster.failover("b", fence_old=True), "stale replica") assert cluster.primary == "a" # 3. Unhealthy standby must not promote. cluster.nodes["b"].lag = 0 cluster.nodes["b"].healthy = False expect_refused(lambda: cluster.failover("b", fence_old=True), "unhealthy standby") assert cluster.primary == "a" # 4. Unfenced old primary must block promotion (split-brain guard). cluster.nodes["b"].healthy = True unfenced = Cluster() # nobody fenced yet expect_refused(lambda: unfenced.failover("b", fence_old=False), "split brain") expect_refused(lambda: unfenced.failover("b", fence_old=True), "split brain without fence record") assert unfenced.primary == "a" # 5. Failback: resynchronize the candidate, then fence the CURRENT writer. cluster = Cluster() cluster.fence("a") cluster.failover("b", fence_old=True) cluster.nodes["a"].healthy = True cluster.nodes["a"].lag = 0 cluster.fence("b") assert cluster.failover("a", fence_old=True) == "a" print("failback: b -> a") print("PASS: at most one writable node; 4 refusals + clean switch + failback passed") if __name__ == "__main__": main() 输出包括一次正常切换、分属三类的四次拒绝,以及一次切回,最后为 PASS: at most one writable node; 4 refusals + clean switch + failback passed。拒绝提升会保留原有的主节点标记,但已经被隔离的旧主库仍不可写,并不代表服务自动恢复可用。正常切换与切回场景的延迟都是零;策略另外允许最多 128 个模拟单位的正延迟,这包含可能丢失数据的取舍,不应误读为真实数据库的推荐阈值。 ...

二月 25, 2027 · 3 分钟 · XB Field Notes
观察、决策、行动三个面板,旁边的标题说明容量规划靠数字,而非猜测。

容量规划是一个数字,不是猜测

容量规划把需求预测转化成一个有依据的运行范围。真正有用的问题,不是机器在崩溃之前还能接收多少请求,而是服务能以满足延迟和正确性要求的方式持续处理多少工作。答案受到请求成本、共享依赖和故障假设影响。只有把这些假设的来源、适用范围与负责人写清楚,表格里的实例数量才能支持采购与可靠性决策,而不只是把直觉包装成一个数字。 测出能够接受的运行点 压测应使用具有代表性的请求组合、数据规模、缓存状态、并发方式和软件配置。在增加压力时,同时记录延迟、错误、处理器、内存、队列、存储与连接使用情况。满足要求的最高已测试点,是针对该工作负载的证据,并不证明所有中间点或下一版本都表现相同。只有吞吐量而没有结果质量的测试,甚至可能把快速拒绝请求的服务器评成性能优秀。 不同瓶颈需要分别统计。增加应用副本可能带来更多计算资源,却没有扩大共享数据库的连接额度。请求变贵时,每秒请求数与资源消耗的关系也会改变。因此,按请求速率规划只在成本分布可比时才有意义,还需要监测底层资源,并在工作负载变化后修订模型。不能拿安静时段的平均负载,直接推导高峰时的服务承诺,也不能假设所有资源会同时到达上限。 预测需求,并主动减掉会失去的容量 从实际峰值出发,明确准备应对的是增长、季节波动还是某次活动,再把采购或扩容提前期和预测误差写入计划。运行余量应该对应具体原因,例如负载不均、需求低估或新增实例需要时间,而不是沿用旧表格里来源不明的百分比。出现有意义的版本、数据量或流量结构变化后,应同时复查单机测量和需求假设,避免只更新其中一个数字。 接着按可靠性设计减去可能失效的容量。服务分布在三个可用区,如果要求失去一个区后仍能承接同样峰值,所需实例数就不同于只考虑健康集群的方案。还要核对共享依赖是否幸存、流量如何迁移,以及新增实例能否在队列失控前就绪。均匀部署不等于请求必然均匀分配,自动扩缩容也无法瞬间提供资源,这些都应进入故障演练与容量边界。 坏掉的样子 常见错误是用预测流量除以单机最大吞吐量,向上取整后便结束计算。这样的结果可能通过健康状态检查,却在损失一个可用区后立刻超载。另一个问题是重复计算预留容量:必须区分压测已经验证的速率,与规划时主动采用的更低运行速率。汇报剩余容量时也应说明相对于哪个边界,避免把规划额度内的余量误称为硬件物理吞吐上限。 线性扩展可能在共享锁、数据库、网络链路或外部配额处失效。冷缓存和同步重试还可能使故障切换后的请求比原压测更昂贵。应在真实约束下验证失效场景,并记录模型何时不再成立。如果现有资源无法安全承接预测需求,可能需要经过验证的准入控制或降级方式,等待资源到位;在表格里加入乐观假设,不能改变实际排队和超时行为。 快速验证 保存为 capacity.py 后,用 Python 3 执行。输入是固定工作负载下的人造压测记录,实验用于核对实例计算与故障假设,不代表当前机器的真实性能。示例把可接受测试点的百分之七十作为规划速率,这是为本例明确选择的余量规则,不是适用于所有系统的利用率建议,实际团队应解释自己的预留原因。 from math import ceil # Synthetic load-test results for one fixed request mix and machine size. samples = [ {"rps": 150, "p99_seconds": 0.11, "error_ratio": 0.0001}, {"rps": 200, "p99_seconds": 0.22, "error_ratio": 0.0003}, {"rps": 250, "p99_seconds": 0.50, "error_ratio": 0.0020}, ] acceptable = [s["rps"] for s in samples if s["p99_seconds"] <= 0.30 and s["error_ratio"] <= 0.001] tested_rps = max(acceptable) forecast_rps = ceil(1600 * 1.25) zones = 3 surviving_zones = zones - 1 planning_rps = tested_rps * 0.70 naive_per_zone = ceil(ceil(forecast_rps / tested_rps) / zones) planned_per_zone = ceil(forecast_rps / (planning_rps * surviving_zones)) naive_survivor_rps = naive_per_zone * surviving_zones * tested_rps planned_survivor_rps = planned_per_zone * surviving_zones * planning_rps assert tested_rps == 200 and forecast_rps == 2000 assert naive_per_zone * zones == 12 and naive_survivor_rps == 1600 assert planned_per_zone * zones == 24 and planned_survivor_rps == 2240 assert naive_survivor_rps < forecast_rps <= planned_survivor_rps print(f"tested={tested_rps} rps/node; forecast={forecast_rps} rps") print(f"naive={naive_per_zone * zones} nodes; zone-loss capacity={naive_survivor_rps} rps") print(f"planned={planned_per_zone * zones} nodes; zone-loss planning capacity={planned_survivor_rps:.0f} rps") print(f"zone-loss spare={planned_survivor_rps - forecast_rps:.0f} rps") 已列出的记录中,每节点每秒二百次请求满足要求,二百五十次的记录则未达到延迟和错误标准。需求经过增长假设后变为每秒二千次。只按测试速率计算并均匀分配的方案共有十二个节点,失去一个区后只剩八个,即使每个节点都运行在已接受的测试速率,也只能承接每秒一千六百次请求,低于预测需求。 ...

二月 21, 2027 · 1 分钟 · XB Field Notes
标题为「功能开关把发布与暴露解耦」的封面,下方是观察、判断与行动三个面板。

功能开关把发布与暴露解耦

部署代码和向用户开放功能,是不同动作。功能开关可以在代码部署后选择运行路径,支持渐进开放和快速关闭;前提是旧路径仍兼容,配置也真正到达负责求值的进程。开关控制一次决定,不会撤销部署或新行为已经造成的全部效果。讨论回滚时,应明确能停止什么、不能恢复什么,而不是把关闭按钮当作万能撤销。 在明确的门控后部署 发布开关通常默认选择既有路径,让新实现先接受验证。不过部署阶段仍可能变化:导入、启动钩子、表结构迁移、依赖和资源使用,都可能位于被保护分支之外。因此关闭开关不能保证用户完全无感。应同时测试关闭和开启状态的真实产物,包括启动流程,以及两条路径与已有数据的兼容性,避免只看功能入口是否隐藏。 关闭开关可以阻止后续请求进入新路径,却不会撤销已经完成的写入、已发送消息、迁移或在途工作。回滚窗口内应让两条路径都能处理相关数据格式,并定义部分执行后可能需要的修复。紧急关闭只有在传播延迟和实际停止边界符合响应计划时才有效;控制台显示关闭,不代表所有正在运行的操作已经中止。 按含义选择默认行为 对于尚未完成的发布功能,未知配置时退回旧路径可能是安全选择。但这是具体开关的语义,不是任何情况下 false 都安全。关闭保护性检查,或者反转名为 disable-validation 的开关,可能产生相反结果。应明确备用值、配置新鲜度,以及无法求值时的处理,不能只用“失败为关”四个字替代对实际行为的审查。 在合适的操作边界求值一次,并在该操作内部复用决定。多个服务或独立请求之间,需要明确传递兼容决定的方式;每个进程本地求值一次,不会自然形成全局一致性。权限校验也应独立存在。客户端控制显示的开关,不能变成服务器接受受保护操作的授权凭证,否则隐藏按钮并没有真正限制访问。 用稳定身份逐步开放 把稳定用户标识与稳定开关键一起散列,可以形成可重复的分组。改变身份字段、盐值或分桶算法,即使百分比不变,也可能让用户重新分组。用户比例也不等于请求比例:少量重度用户可能产生大量流量。因此解释放量指标前,先记录分配单位,确认观察的是账号、请求还是其他业务实体,不能把几个百分比直接互换。 紧急关闭应覆盖普通分桶选择,并具有经过测试的配置传递路径。SDK 缓存和配置服务故障,可能让部分实例继续使用旧值。应按实际求值的变体观察错误与延迟,不能把控制面修改成功当作每次请求已经切换的证明。放量节奏应依据服务风险和观察延迟决定,不存在对所有业务都适用的固定百分比或等待时间。 坏掉的样子 临时发布开关如果没有删除负责人,会变成长期分支。应指定负责人和退出条件,在回滚窗口结束后删除不再需要的路径。运维开关和权益控制则可能有意长期存在,需要持续测试和维护,不能要求所有开关都很快过期。区分类型,才能既避免死配置堆积,又保留真正需要的长期运行控制。 互相矛盾的开关还可能形成无人测试的组合。可行时集中决定规则,定义优先级,同时验证备用与开启路径。日志只保留足以诊断不一致的非敏感上下文,避免无限保留真实用户标识。本例使用的名字全部是合成测试数据;把这份内存审计列表搬到生产环境前,还需另行设计保留、访问和脱敏规则。 快速验证 把代码保存为 flag_check.py,用 Python 3.10 或更新版本运行。原始实验记录使用 Python 3.14.4,本轮用 Python 3.12.14 重新执行其决定逻辑。脚本只模拟一个百分比发布开关、紧急关闭、稳定用户分桶和内存审计列表。百分比假定已通过零至一百的输入验证,不包含真实配置下载、SDK 缓存或身份服务。 import hashlib class Flag: """One percentage flag with kill switch, default-closed, and audit log.""" def __init__(self, key, percent): self.key = key self.percent = percent self.killed = False self.audit = [] def _bucket(self, user): digest = hashlib.sha256(f"{self.key}:{user}".encode()).hexdigest() return int(digest, 16) % 100 def evaluate(self, user): if self.killed: decision, reason = False, "killed" elif not user: decision, reason = False, "default-closed" else: hit = self._bucket(user) < self.percent decision, reason = hit, ("in-bucket" if hit else "out-of-bucket") self.audit.append({"user": user, "decision": decision, "reason": reason}) return decision flag = Flag("new-checkout", 20) users = [f"user-{i}" for i in range(200)] # 1. Default closed: a missing user never gets the new path. assert flag.evaluate("") is False assert flag.evaluate(None) is False # 2. Stickiness: the same user gets the same answer twice. first = flag.evaluate("user-1024") second = flag.evaluate("user-1024") assert first == second # 3. Kill switch overrides every bucket. flag.killed = True assert flag.evaluate("user-1024") is False assert flag.evaluate("user-7") is False flag.killed = False # 4. The dial moves exposure for a fixed fixture set. open_20 = sum(flag.evaluate(u) for u in users) flag.percent = 100 open_100 = sum(flag.evaluate(u) for u in users) flag.percent = 0 open_0 = sum(flag.evaluate(u) for u in users) flag.percent = 20 assert open_0 == 0 assert 0 < open_20 < len(users) assert open_100 == len(users) # 5. A different bucketing key disagrees somewhere: the inconsistency to avoid. session_flag = Flag("new-checkout-session", 20) mismatch = [u for u in users if flag.evaluate(u) != session_flag.evaluate(u)] assert mismatch, "expected at least one user where two flag keys disagree" # 6. Audit log holds one entry per evaluation with its reason. assert len(flag.audit) == 2 + 2 + 2 + 200 * 3 + 200 assert len(session_flag.audit) == 200 assert all(set(e) == {"user", "decision", "reason"} for e in flag.audit) print(f"default-closed empty user: off; sticky user-1024: {'on' if first else 'off'} twice") print(f"dial 0/20/100 over 200 users: {open_0}/{open_20}/{open_100}") print(f"killed flag forces off; different flag keys disagree on {len(mismatch)} users, e.g. {mismatch[0]}") print(f"audit entries: flag={len(flag.audit)}, session_flag={len(session_flag.audit)}") print("PASS: default-closed, sticky bucketing, kill switch, dial, and audit log behave as asserted") 在百分之二十时,缺失用户保持关闭,紧急关闭覆盖全部具名用户,同一用户重复求值保持稳定。两百个固定测试用户在零、二十、一百三档,分别有零、三十五、两百人开启。三十五是这组输入的确定结果,不保证任意有限用户集合都精确达到百分之二十,也不说明相同比例的请求会使用新路径。 ...

二月 9, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调探针各有用途且检查成本必须受限。

一个为用途而建的健康检查值得保留

健康端点是一份会驱动后续动作的契约。处理器能够返回 200,并不证明实例可以完成有价值的业务请求。写代码之前应先明确观察者要据此做什么:等待初始化、接入新流量,还是重启一个无法自行恢复的进程。问题不同,需要的证据就不同,不能把它们压成一个名字叫“健康”的布尔值。 区分进程恢复和流量准入 存活检查应该对应重启能够改善的本地故障,例如无法继续工作的死锁。共享数据库不可用,通常不能靠把所有调用方重启一遍来修复。就绪检查回答这个实例现在是否适合接收它负责的流量,只纳入这条业务路径必需的依赖;可选的推荐功能失败,不应自动把仍能结算的实例整个摘出流量池。 启动检查保护耗时初始化不被过早判定为故障。在 Kubernetes 中配置 startup probe 后,存活和就绪探针要等启动探针成功才运行。就绪失败影响流量准入;启动或存活探针连续失败达到配置容忍度后,才可能结合重启策略触发重启。单独收到一份 503,并不代表重启、摘流或恢复已经在控制面完成。 状态契约必须以真实消费者为准。Kubernetes 的 HTTP 探针把大于等于 200、小于 400 的响应视作成功,所以误接入登录跳转的探针可能显示绿色。下面的三个 GET 健康路由刻意只返回 200 或 503,不靠正文里的“正常”字样决定健康,也不允许通过自动跟随重定向掩盖原始结果。 给探针引入的工作设预算 选择便宜、只读而且确实代表关键依赖的操作。取得连接或者完成一次小请求,只能证明那条具体路径,不代表所有业务事务都成功。内部期限应短于外层调用者的超时,并把取消信号传到下游;响应体和计时器都要释放。外层提前返回错误、内部请求却继续排队和执行,并不算真正限制了探针成本。 总负载还取决于实例数量、独立观察者数量和轮询频率。不要每次探测都展开整张依赖图,也不要让健康检查抢光业务恢复所需的连接。如果项目已有后台检查器,可以复用其结果,但必须定义结果年龄和失败含义,不能把很久以前的绿色无限期当成当前事实。中间缓存同样不能把旧成功响应发给负载均衡,让已经失败的实例继续被接入。 坏掉的样子 永远返回成功的端点,会在数据库断连、工作池耗尽或初始化尚未完成时继续放行流量;相反,把共享依赖故障挂到存活检查上,会让整批实例进入无效重启循环。探针自己也可能消耗稀缺连接,或者因容忍阈值过低,在短暂暂停时同时摘掉过多容量。应当用实际失败场景证明状态含义,而不是仅仅证明路由可以访问。 测试至少覆盖未启动、依赖明确失败、依赖不响应、主动排空以及恢复。本设计中的存活路由不能被共享依赖拖着失败。排空首先禁止新流量准入,之后才能等已有请求结束;但一个健康端点本身不会等待负载均衡传播,也不会替应用完成连接排空。公开响应保持简短,不返回内部依赖地址、凭据或堆栈,详细原因放在受控的运维观测中。 快速验证 保存下面程序为 health_probe.go,运行 go run health_probe.go。两个回环服务分别模拟应用及其唯一必需依赖,依赖返回 204 才算通过。应用通过一百毫秒的子 context 限制下游请求,外层测试客户端允许两秒;启动与排空由内存标志表示,不会改动真实服务、负载均衡或集群配置。 package main import ( "context" "fmt" "io" "net/http" "net/http/httptest" "runtime" "sync/atomic" "time" ) func must(err error) { if err != nil { panic(err) } } func check(ok bool, message string) { if !ok { panic(message) } } func main() { var mode, dependencyCalls atomic.Int64 var started, draining atomic.Bool canceled := make(chan struct{}, 1) dependency := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dependencyCalls.Add(1) switch mode.Load() { case 1: w.WriteHeader(503) case 2: <-r.Context().Done() canceled <- struct{}{} default: w.WriteHeader(204) } })) defer dependency.Close() transport := &http.Transport{} defer transport.CloseIdleConnections() client := &http.Client{Transport: transport, Timeout: 2 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if r.Method != "GET" { w.Header().Set("Allow", "GET") w.WriteHeader(405) return } ready := false switch r.URL.Path { case "/livez": ready = true // No shared-dependency restart policy. case "/startupz": ready = started.Load() case "/readyz": if started.Load() && !draining.Load() { ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond) defer cancel() request, err := http.NewRequestWithContext(ctx, "GET", dependency.URL, nil) must(err) response, err := client.Do(request) if err == nil { ready = response.StatusCode == 204 must(response.Body.Close()) } } default: w.WriteHeader(404) return } if !ready { w.WriteHeader(503) return } w.WriteHeader(200) })) defer server.Close() probe := func(method, route string, status int, calls int64) { before := dependencyCalls.Load() request, err := http.NewRequest(method, server.URL+route, nil) must(err) response, err := client.Do(request) must(err) _, readError := io.Copy(io.Discard, response.Body) closeError := response.Body.Close() must(readError) must(closeError) check(response.Proto == "HTTP/1.1" && response.StatusCode == status, "wrong probe status") check(response.Header.Get("Cache-Control") == "no-store", "probe response must not be cached") check(dependencyCalls.Load() == before+calls, "unexpected dependency work") if status == 405 { check(response.Header.Get("Allow") == "GET", "method contract missing") } fmt.Printf("%s: %d, dependency calls=%d\n", route, status, dependencyCalls.Load()) } probe("GET", "/livez", 200, 0) probe("GET", "/startupz", 503, 0) probe("GET", "/readyz", 503, 0) started.Store(true) probe("GET", "/startupz", 200, 0) probe("GET", "/readyz", 200, 1) mode.Store(1) probe("GET", "/livez", 200, 0) probe("GET", "/readyz", 503, 1) mode.Store(2) probe("GET", "/readyz", 503, 1) select { case <-canceled: fmt.Println("deadline canceled the stalled dependency request") case <-time.After(time.Second): panic("dependency did not observe cancellation") } mode.Store(0) probe("GET", "/readyz", 200, 1) draining.Store(true) probe("GET", "/readyz", 503, 0) probe("GET", "/livez", 200, 0) draining.Store(false) probe("GET", "/readyz", 200, 1) probe("POST", "/readyz", 405, 0) probe("GET", "/unknown", 404, 0) check(dependencyCalls.Load() == 5, "only five readiness checks should query the dependency") fmt.Println("PASS: fourteen probes; live, starting, unready, stalled, draining, and recovered") fmt.Println(runtime.Version()) } 十四次探测只产生五次依赖请求。启动前,startup 和 readiness 返回 503,liveness 仍为 200;初始化完成后 readiness 变为成功。依赖明确返回 503 时,只影响就绪结果,不影响存活。依赖停止响应时,子期限使就绪检查返回 503,依赖服务确实观察到请求取消;接下来一次健康请求成功,证明失败之后仍能恢复,而不是残留一个占着连接的检查。 ...

十二月 31, 2026 · 3 分钟 · XB Field Notes
一层摞一层的堆叠,地基就是全量备份。

全量与增量备份

全量备份提供一个不依赖同种方法更早备份的基线。增量备份保存相对于指定参考点的变化,恢复时仍然依赖补全省略内容所需的材料。这一区别描述的是恢复依赖,不是对执行速度或传输字节数的普遍保证。如果变化过的数据范围很大,增量备份本身也可能相当大,不能仅凭“增量”两个字就承诺备份很快。 坏掉的样子 全量备份损坏,可能使所有依赖它的增量都失去恢复能力。同样,删除必要的中间增量,即使最新目录与清单还完整,也可能破坏后续恢复。保留策略必须沿着依赖关系作决定,不能只按时间戳独立删除看起来较旧的目录。一个较新的文件,并不自动替代所有较旧的恢复材料。 参考对象同样重要。相对于上一次增量建立备份,会形成一条链;相对于某个全量基线建立备份,则具有另一种依赖形状,通常称为差异备份。合成全量通过已有备份材料重建一个新基线。这些名词在不同工具中不保证实现完全相同,因此,不能只依据名称设计轮换,必须核对实际产品的清单格式与恢复规则。 物理数据库备份还需要让捕获文件达到一致状态的必要日志。全量独立于更早备份,并不意味着它独立于全部恢复日志、软件、配置或外部依赖。本实验通过流式收集带上所需 WAL,并保持整个备份链中的数据库版本与页面校验配置一致。这样验证的是明确的一套恢复条件,而不是省略前提的“有全量就一定能启动”。 快速验证 在 Linux 环境中,以非超级操作系统账户运行下面脚本,准备 PostgreSQL 18 服务端和客户端程序,包括合成备份工具。本次实际执行版本是 PostgreSQL 18.6。脚本创建私有临时主库与恢复集群,不打开网络监听。取得备份之前先开启日志摘要,让数据库能够为增量请求识别发生变化的数据块。 #!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-incremental.XXXXXX) mkdir "$lab/socket" primary=$lab/primary restored=$lab/restored cleanup() { for cluster in "$restored" "$primary"; do if test -f "$cluster/postmaster.pid"; then pg_ctl -D "$cluster" -m fast -w stop >/dev/null fi done } trap cleanup EXIT initdb -D "$primary" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null cat >> "$primary/postgresql.conf" <<CONF listen_addresses = '' unix_socket_directories = '$lab/socket' port = 56432 summarize_wal = on CONF pg_ctl -D "$primary" -l "$lab/primary.log" -w -t 15 start >/dev/null export PGHOST=$lab/socket PGPORT=56432 PGDATABASE=postgres PGUSER=$(id -un) export PGCONNECT_TIMEOUT=2 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' CREATE TABLE marker (id integer PRIMARY KEY,value text); INSERT INTO marker VALUES (1,'full'); SQL pg_basebackup -D "$lab/base" -X stream -c fast -w psql -X -v ON_ERROR_STOP=1 -qAt -c "INSERT INTO marker VALUES (2,'first_increment')" pg_basebackup -D "$lab/inc1" --incremental="$lab/base/backup_manifest" -X stream -c fast -w psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' UPDATE marker SET value='final' WHERE id=1; DELETE FROM marker WHERE id=2; INSERT INTO marker VALUES (3,'second_increment'); SQL pg_basebackup -D "$lab/inc2" --incremental="$lab/inc1/backup_manifest" -X stream -c fast -w for backup in "$lab/base" "$lab/inc1" "$lab/inc2"; do pg_verifybackup -q "$backup" done echo input_backups_verified if pg_combinebackup -o "$lab/missing-middle" "$lab/base" "$lab/inc2" >"$lab/missing-middle.log" 2>&1; then echo 'incomplete chain unexpectedly combined' >&2 exit 1 fi grep -E 'starts at LSN .*but expected' "$lab/missing-middle.log" echo missing_middle_rejected pg_combinebackup -o "$lab/combined" "$lab/base" "$lab/inc1" "$lab/inc2" pg_verifybackup -q "$lab/combined" echo combined_backup_verified mkdir "$restored" cp -a "$lab/combined/." "$restored/" cat >> "$restored/postgresql.conf" <<CONF port = 56433 summarize_wal = off CONF pg_ctl -D "$restored" -l "$lab/restored.log" -w -t 15 start >/dev/null PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' SELECT pg_is_in_recovery(); SELECT id,value FROM marker ORDER BY id; DO $$ BEGIN IF (SELECT string_agg(id||':'||value,',' ORDER BY id) FROM marker) IS DISTINCT FROM '1:final,3:second_increment' THEN RAISE EXCEPTION 'incremental restore result mismatch'; END IF; END $$; SQL printf 'lab_directory %s\n' "$lab" 全量备份包含第一行,值为 full。第一次增量参考这份全量的清单,并增加第二行。第二次增量参考第一次增量的清单,把第一行改为 final,删除第二行,再增加第三行。这不仅测试新行出现,也测试已有行的更新和删除,避免把简单追加成功误当成整个增量恢复语义正确。 ...

十二月 27, 2026 · 2 分钟 · XB Field Notes
三个相连方块,依次标有 Observe、Decide 和 Act。

让持久状态和临时空间拥有不同生命周期

持久应用状态和可丢弃临时文件,需要不同的保留、容量与恢复策略。分开路径有助于表达这些策略,但目录本身不会创造持久性,也不会隔离磁盘容量。真正决定进程重启、容器替换、宿主故障或清理后保留什么的,是路径背后的存储。可靠布局需要把每类数据连接到明确生命周期,而不是仅仅给文件夹起两个不同名字。 坏掉的样子 如果清理任务把混合目录全部当作可丢弃内容,可能在删除临时下载时一起删除已提交记录。相反,也可能备份了大量可重建缓存,却漏掉真正重要的小数据库。文件扩展名和年龄不能代替对应用所有权的理解;需要知道路径由谁管理,以及其内容是否真的能从权威来源重新构造,才能决定能否删除。 容器重启和替换也不同。停止再启动同一个容器,通常保留其可写文件系统层;删除容器才会丢弃该层,而明确保留的命名卷可以继续存在。tmpfs 又有不同生命周期,容器停止时其内容会消失。把这三类位置统称为临时,会掩盖影响恢复流程的区别,导致测试重启成功后就误以为替换也安全。 同一个文件系统里的两个目录,仍然竞争该文件系统的剩余空间。只把临时路径从 /data/tmp 改成 /scratch,却不改变存储策略,并不会自动保护数据库写入。容量隔离需要合适的挂载、配额或其他可执行预算,同时监控应区分临时空间增长与持久状态增长。路径整齐不能替代真正的资源边界。 快速验证 先取得固定镜像,再用宿主 Python 和 Docker 运行脚本。实测为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16 提供 SQLite 模块。它创建一个私有命名卷和八 MiB 的 tmpfs 临时挂载,只删除自身容器与卷。小规模临时空间耗尽被限制在实验内部,不修改既有服务存储: import subprocess,uuid BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],text=True,capture_output=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def execute(container,code):return docker('exec',container,'python','-c',code).stdout.strip() name='xbcatm-state-'+uuid.uuid4().hex[:10];volume=name+'-data';containers=[name+'-one',name+'-replacement'] try: docker('volume','create',volume) def start(container): docker('run','-d','--name',container,'--network','none','--mount','type=volume,src='+volume+',dst=/state','--tmpfs','/scratch:rw,size=8m',BASE,'python','-c','import time;time.sleep(180)') start(containers[0]) execute(containers[0],'from pathlib import Path;import sqlite3;c=sqlite3.connect("/state/data.sqlite");c.execute("create table records(value text)");c.execute("insert into records values (?)",("committed",));c.commit();c.close();Path("/ephemeral").write_text("container-layer");Path("/scratch/crumb").write_text("scratch")') docker('stop','--time','1',containers[0]);docker('start',containers[0]) execute(containers[0],'from pathlib import Path;import sqlite3;assert Path("/ephemeral").exists();assert not Path("/scratch/crumb").exists();c=sqlite3.connect("/state/data.sqlite");assert c.execute("select value from records").fetchall()==[("committed",)];c.close()') print('stop/start retains the container writable layer and volume data, while the tmpfs scratch mount loses its file') docker('rm','-f',containers[0]);start(containers[1]) execute(containers[1],'from pathlib import Path;import sqlite3;assert not Path("/ephemeral").exists();c=sqlite3.connect("/state/data.sqlite");assert c.execute("select value from records").fetchall()==[("committed",)];c.close()') print('replacement loses the old writable layer but retains the named-volume database') execute(containers[1],'''import errno,os,sqlite3 fd=os.open('/scratch/fill',os.O_CREAT|os.O_WRONLY,0o600) try: try: while True:os.write(fd,b'x'*1048576) except OSError as error:assert error.errno==errno.ENOSPC finally:os.close(fd) source=sqlite3.connect('/state/data.sqlite');source.execute('insert into records values (?)',('after-scratch-full',));source.commit() backup=sqlite3.connect('/state/check-copy.sqlite');source.backup(backup);backup.close();source.close() restored=sqlite3.connect('/state/check-copy.sqlite');assert restored.execute('select value from records order by rowid').fetchall()==[('committed',),('after-scratch-full',)];restored.close() ''') print('bounded scratch reaches ENOSPC while state still commits; a SQLite backup copy reopens with both committed records') finally: docker('rm','-f',*containers,check=False);docker('volume','rm',volume,check=False) 第一个容器在 /state 下创建 SQLite 数据库并提交一条记录,在普通可写层写入 /ephemeral,再把临时文件放进 /scratch。三个路径都通过普通文件接口访问,背后却是不同存储位置。持久应用记录通过 SQLite commit 提交,不依赖尚未关闭的输出缓冲,从而让后续生命周期对照具有明确数据边界。 ...

十二月 3, 2026 · 1 分钟 · XB Field Notes
浅色网格上显示文章标题,旁侧是依次标注 Observe、Decide、Act 的三个相连方框。

健康的黄金信号

延迟、流量、错误与饱和度,是判断服务能否满足用户需求的四个互补角度。它们提供的是一组起始问题,并不是四个放到任何系统里都能自动证明健康的通用计数器。是否有用,取决于在哪里测量、描述哪些工作、采用什么时间窗口,以及各项统计如何对应。响应很快也可能意味着业务操作失败,因此不能看到延迟下降就直接宣布用户体验改善。 区分用户体验与工作需求 延迟描述一项工作在指定边界内花了多长时间。成功操作与失败操作应该分开观察,避免立即返回的错误让总体分布显得更健康。尾部信息很有价值,但必须同时保留观测数量;很少的样本即使算出了高分位数,也不能证明已经稳定掌握稀有行为。客户端延迟与服务器内部处理时间覆盖的链路不同,比较它们之前应先说明各自包含和遗漏的阶段。 流量描述需求或已经完成的工作,应采用请求每秒、字节每秒等合适单位,并明确统计的是到达、完成还是重试。队列卡住时,完成速率可以下降,但外部请求需求仍然持续增加。并发请求数则是数量,不是请求速率;它可以作为补充信息,却不能直接替换流量面板中的速率。批处理系统也应选择适合自身任务的工作单位,而不是机械套用接口请求数。 说明失败条件与资源压力 错误描述未达到承诺结果的工作。状态码失败是一种信号,但正常响应中包含错误业务数据,也可能违反服务契约。应明确哪些结果、哪些延迟违规进入指标,并使用相同统计范围的分母。除了比例,还应展示实际数量,使安静时段的一次错误与高峰时段影响大量用户的事故不会因为百分比相似而被混淆。 饱和度描述受约束资源距离可用上限有多近。处理器、内存、连接池、工作线程占用和队列等待时间,可以揭示不同的瓶颈。线程池全部忙碌不一定意味着不健康,但如果队列持续增长、最老任务不断等待,就能看出占用率为何值得关注。某一时刻的快照并不是整段时间的平均值,而很低的平均值也可能藏住短暂过载。容量阈值需要结合工作负载与实测,不能只在图上画一条没有依据的红线。 坏掉的样子 混用统计总体会制造难以解释的故事,例如延迟来自应用成功请求,错误来自入口代理,流量却来自另一个区域。它们可以独立变化,所以关联之前必须写明边界与过滤条件。快速拒绝请求可以改善全请求平均延迟,同时使错误率上升;只统计已经完成的请求,还会遗漏尚未结束的等待,让用户正在经历的停顿从延迟和吞吐图中同时消失。 四个信号中的任何一个都不能独立证明根因。处理器空闲时,数据库连接池可能已经耗尽;队列增长既可能源于更多需求,也可能因为下游变慢。应利用信号选择下一步排查方向,再查看链路、日志、依赖和变更证据。告警应针对可采取行动的用户影响或即将耗尽的资源,不能把任何单次仪表波动都等同于需要立刻叫醒值班人员的事故。 快速验证 保存为 golden.py 后使用 Python 3 执行。实验包含一个十秒窗口内的人造已完成请求,以及另行标注的窗口结束时线程与队列快照,用于核对聚合与解读。它没有向真实服务施压,也没有测量当前服务器;分位数直接来自原始样本的最近秩计算,与利用直方图桶进行插值估计的方式不同。 from math import ceil from statistics import mean # Synthetic completed requests in one 10-second observation interval. records = [(True, 0.05)] * 90 + [(True, 0.8)] * 5 + [(False, 0.005)] * 5 success = sorted(duration for ok, duration in records if ok) failure = [duration for ok, duration in records if not ok] p99_success = success[ceil(0.99 * len(success)) - 1] traffic_rps = len(records) / 10 error_ratio = len(failure) / len(records) # These are end-of-interval gauges, not interval averages. busy_workers, total_workers = 4, 4 queued_requests, oldest_queue_seconds = 12, 1.2 assert p99_success == 0.8 and traffic_rps == 10 and error_ratio == 0.05 assert mean(failure) < mean(success) assert busy_workers / total_workers == 1 print(f"traffic={traffic_rps:.1f} completed requests/s; errors={error_ratio:.1%}") print(f"success p99={p99_success:.3f}s; failed mean={mean(failure):.3f}s") print(f"success mean={mean(success):.6f}s; all-request mean={mean(d for _, d in records):.6f}s") print(f"worker snapshot={busy_workers}/{total_workers}; queue={queued_requests}; oldest={oldest_queue_seconds}s") 输出显示,每秒完成十次请求,错误比例为百分之五,成功请求的百分之九十九分位数为零点八秒,失败请求平均只耗时零点零零五秒。成功请求平均约零点零八九四七四秒,而加入快速失败后,全请求平均值降低到零点零八五二五秒。这个可复现的差异说明,混合平均值下降不等于业务变得更有用,必须同时查看结果是否成功。 ...

十一月 29, 2026 · 1 分钟 · XB Field Notes