保留固定点扣住堆框中的叉号旧版本;释放后,同样大小的框出现可复用空格,尾部存活行仍在。

清理在可见性边界允许后回收旧版本

PostgreSQL 更新一行时会产生新版本,删除则让旧版本对后来的快照不可见。但这不意味着所有较早开始读取的事务都已经用完旧版本。普通清理,包括自动回收工作者发起的清理,都必须遵守这个可见性边界,之后才能复用空间。把清理任务安排得更频繁,不能越过仍然有效的读取快照。 坏掉的样子 一个持续很久的快照,可能在删除事务提交以后仍然需要旧行。因此,清理命令可以正常执行,却没有回收预期数量的版本。单纯降低自动回收触发门槛,也不会让这些版本突然变成可删除。应先检查长事务和其他持有回收边界的对象,例如复制槽保留的 xmin,再判断是不是工作者吞吐不足。 回收元组空间,通常意味着同一张表以后可以复用这些位置,并不必然让文件长度下降。普通清理可以截断文件末尾连续的空页,却不会把中间所有存活行搬到一起、整理掉每个孔洞。因此,清理之后磁盘文件大小不变,可能是正常的内部空间复用,不能仅凭这个现象宣布任务没有发挥作用。 示意格区分可见性、回收与复用。手动双会话夹具仍待运行验证;堆长度不变不说明清理失败。 快速验证 本轮没有重跑原样保留的双会话快照与清理实验。早先的 PostgreSQL 观察属于历史记录,单用户后端无法复现保留读者的边界。 准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。实验只在自己的测试表上暂时关闭自动回收,并手动执行普通清理以控制时序。它验证的是自动与手动清理共同遵守的可见性规则,不把手动命令当作自动调度已经运行的证据。使用尚不存在的实验模式,以及有权创建和维护其中表的角色;乙须保持自动提交,因为 VACUUM 不能在事务块内执行。乙先创建五千行数据: CREATE SCHEMA vacuum_demo; CREATE TABLE vacuum_demo.items (id integer PRIMARY KEY,payload text) WITH (autovacuum_enabled=false); INSERT INTO vacuum_demo.items SELECT g,repeat(md5(g::text),10) FROM generate_series(1,5000) AS g; SELECT pg_stat_force_next_flush(); VACUUM (ANALYZE) vacuum_demo.items; CREATE TABLE vacuum_demo.baseline AS SELECT pg_relation_size('vacuum_demo.items') AS heap_bytes; 统计刷新辅助函数让待发布计数在下一个机会写出,这里用于减少短实验中的观察滞后。基线记录的是堆文件字节数,不包括索引和其他分支文件。甲开启可重复读事务,读取五千行后保持事务打开: ...

六月 4, 2027 · 1 分钟 · XB Field Notes
共同比例条形图对比历史 libc 近似观察:加载后分配量约 16.7 MiB,删除所有键后约 1.04 MiB,清空后的 RSS 约 20.8 MiB,最终比率约 20。这些观察不是目标值,也不证明泄漏。

Redis 内存比率需要结合分配背景判断

即使应用键已经删除,Redis 进程仍可能保留较大的常驻内存。这个现象值得解释,但它本身既不能证明内存泄漏,也不能直接证明分配器碎片。真正要回答的是哪些字节仍被分配、哪些页面仍驻留,以及这种占用是否威胁实际容量预算。只看一个名字里带“碎片”的比率,很容易选错处理对象。 历史 libc 近似观察值与分配量断言关系需分开。更高比率本身不证明泄漏、碎片或回收有效。 把不同测量对象分开 INFO memory 中的 used_memory 表示 Redis 统计的分配量,used_memory_rss 则是操作系统观察到的进程常驻内存。它们都不等于应用负载长度的简单相加,键、对象、元数据、客户端及其他开销也要占空间。MEMORY USAGE 可以估计单个键及其值的分配量,包括相关开销,但不测量这个键独立贡献了多少进程 RSS。 mem_fragmentation_ratio 大体比较常驻内存和 Redis 统计的分配量,两者差额还可能包括代码、共享库、栈以及分配器保留的页面。删除数据后分母下降,即使 RSS 几乎不变,比率也可能迅速升高。因此必须同时检查绝对字节数;一个很小的空实例出现较高比率,不等于它正在占用危险数量的内存,更不是必须立即重启的充分理由。 ...

六月 3, 2027 · 3 分钟 · XB Field Notes
同一份摘要正确的归档,先因新集群缺少角色而恢复失败;补齐角色后恢复两笔订单,合计 6075 分。

没验证的备份只是空想

备份任务完成,证明备份过程生成了一份材料;恢复演练则要证明这份材料、现有工具以及必要环境,能共同恢复出可用系统。这是不同的结论。恢复证据应明确使用了哪份备份、恢复到什么状态、补齐了哪些依赖,以及花了多久才让结果对应用真正有用。不能只保留一个任务成功时间,就省略后面的验证。 坏掉的样子 合法的数据库归档仍然可能依赖范围之外的对象。PostgreSQL 的单库导出工具只导出一个数据库,角色却属于整个集群。在独立集群里恢复带授权信息的备份时,可能因为被引用的角色不存在而失败。如果演练始终在原集群进行,这种缺少恢复材料的问题就会被环境中已有的角色掩盖。 恢复错误也需要明确处理。恢复工具默认可以在 SQL 出错后继续,并在末尾报告错误数量。部分表能够查询,不代表半恢复状态就可以接受。这个小型夹具选择把恢复放进一个事务,使成功具有原子性;发生预期失败时,也不会留下已经恢复了一半的表,便于确认依赖修正之后重新执行的结果。 恢复位置正确是另一项要求。完全恢复成功的备份,也可能按设计不包含它之后发生的变更。校验和证明材料与记录一致,不证明其中包含最新业务事件。同样,总行数相同,并不能排除金额、关联关系或权限错误。验证项需要表达应用语义,不能因为容易查询就只检查几个不能区分正确与错误状态的数字。 摘要不能补齐缺失的集群角色。将同一归档恢复到新目标后,还要核对业务状态、约束与实际权限;本轮未重跑原文 PostgreSQL 演练。 快速验证 在 Linux 环境中使用非超级操作系统账户运行脚本,准备 PostgreSQL 18 服务端、客户端和摘要校验工具。本次实际执行版本是 PostgreSQL 18.6。脚本建立互相独立的临时源集群与目标集群,使用私有 Unix 套接字,不打开网络监听。实验角色没有登录能力,通过受控角色切换验证授权,不涉及真实账户凭据。 #!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-restore.XXXXXX) mkdir "$lab/socket" source=$lab/source target=$lab/target cleanup() { for cluster in "$target" "$source"; do if test -f "$cluster/postmaster.pid"; then pg_ctl -D "$cluster" -m fast -w stop >/dev/null fi done } trap cleanup EXIT for cluster in "$source" "$target"; do initdb -D "$cluster" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null cat >> "$cluster/postgresql.conf" <<CONF listen_addresses = '' unix_socket_directories = '$lab/socket' CONF done printf 'port = 56432\n' >> "$source/postgresql.conf" printf 'port = 56433\n' >> "$target/postgresql.conf" pg_ctl -D "$source" -l "$lab/source.log" -w -t 15 start >/dev/null pg_ctl -D "$target" -l "$lab/target.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 ROLE backup_reader NOLOGIN; CREATE TABLE customers (id integer PRIMARY KEY,name text NOT NULL); CREATE TABLE orders (id integer PRIMARY KEY,customer_id integer REFERENCES customers,amount_cents integer CHECK (amount_cents>0)); INSERT INTO customers VALUES (1,'Ada'); INSERT INTO orders VALUES (10,1,4210),(11,1,1865); CREATE VIEW order_totals AS SELECT c.name,sum(o.amount_cents) AS total_cents FROM customers c JOIN orders o ON o.customer_id=c.id GROUP BY c.id,c.name; GRANT SELECT ON customers,orders,order_totals TO backup_reader; SQL pg_dump -Fc -w -f "$lab/snapshot.dump" postgres sha256sum "$lab/snapshot.dump" > "$lab/snapshot.sha256" psql -X -v ON_ERROR_STOP=1 -qAt -c 'INSERT INTO orders VALUES (12,1,1000)' printf 'source_after_backup_orders ' psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT count(*) FROM orders' sha256sum -c "$lab/snapshot.sha256" if PGPORT=56433 pg_restore --single-transaction --exit-on-error -w -d postgres "$lab/snapshot.dump" >"$lab/missing-role.log" 2>&1; then echo 'restore unexpectedly ignored a missing role' >&2 exit 1 fi grep -F 'role "backup_reader" does not exist' "$lab/missing-role.log" test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT to_regclass('public.orders') IS NULL")" = t echo failed_restore_rolled_back PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'CREATE ROLE backup_reader NOLOGIN' PGPORT=56433 pg_restore --single-transaction --exit-on-error -w -d postgres "$lab/snapshot.dump" PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' SELECT count(*) FROM orders; SELECT name,total_cents FROM order_totals; SELECT has_table_privilege('backup_reader','orders','SELECT'),has_table_privilege('backup_reader','orders','INSERT'); DO $$ BEGIN IF (SELECT count(*) FROM orders)<>2 OR (SELECT string_agg(id||':'||amount_cents,',' ORDER BY id) FROM orders) IS DISTINCT FROM '10:4210,11:1865' OR (SELECT total_cents FROM order_totals WHERE name='Ada') IS DISTINCT FROM 6075 OR (SELECT count(*) FROM orders o LEFT JOIN customers c ON c.id=o.customer_id WHERE c.id IS NULL)<>0 THEN RAISE EXCEPTION 'restored business data mismatch'; END IF; END $$; DO $$ BEGIN BEGIN INSERT INTO orders VALUES (98,999,100); RAISE EXCEPTION 'restored foreign key unexpectedly accepted an orphan'; EXCEPTION WHEN foreign_key_violation THEN RAISE NOTICE 'restored foreign key rejected orphan'; END; END $$; SET ROLE backup_reader; SELECT id,amount_cents FROM orders ORDER BY id; DO $$ BEGIN BEGIN INSERT INTO orders VALUES (99,1,100); RAISE EXCEPTION 'read-only role unexpectedly wrote data'; EXCEPTION WHEN insufficient_privilege THEN RAISE NOTICE 'read-only role rejected write'; END; END $$; SQL printf 'lab_directory %s\n' "$lab" 源库包含一个客户、两笔订单、汇总视图、外键以及只读角色授权。自定义格式归档捕获这个状态,之后源库又增加第三笔订单。输出中源库订单数为三,证明选定备份点之外确实存在后续活动,避免测试把当前源库和已经保存的快照混为一谈。恢复目标是否应该包含第三笔,需要由选择的备份点决定。 ...

六月 2, 2027 · 3 分钟 · XB Field Notes
SCAN MATCH 将前缀加星号的模式匹配到示例兄弟键;DEL 仅指向名称中含字面星号的那个精确键。

Redis 的键名要起得清楚

Redis 键是平面键空间中的二进制安全名称,不是文件路径,也不是数据库表结构声明。冒号可以按约定分隔业务归属、环境、实体和标识,例如 cart:prod:string:42 能表达意图。但 Redis 不会把这些字段解释成类型检查或访问限制,不能仅凭名字就推断服务端已经替应用建立了约束。 让约定没有歧义 写入方、读取方、维护任务和迁移逻辑,应采用相同字段顺序与标识编码。不同前缀可以让两个服务分别存储同一个业务编号;若最后编码出来的完整名字相同,仍然会碰撞。命名规范帮助人和代码协作,并没有创建由 Redis 独立管理的命名空间。 类型标签也只是说明。SET 可以在名字带 hash 的键下写入字符串,之后 HSET 就会报 WRONGTYPE。排查时应用 TYPE 查看真实数据类型,序列化格式与类型要求则属于应用契约。不能把名字中的“列表”“哈希”当作当前数据已经符合预期的证据。 名字宜简短清晰,不必压缩成难懂缩写,也不要把整段文档塞进去。键名占内存并随命令传输,但对象开销和负载本身同样重要;没有测量就不能宣称一次改名显著加快查询。标识编码还应避免分隔符、通配符和集群花括号取得本来不希望赋予的含义。 模式匹配不等于模式删除 SCAN MATCH 接受 glob 模式。从游标零开始,把每次返回的新游标用于下一次调用,直到再次返回零。COUNT 是工作量提示,不保证精确返回多少条,也不是严格内存上限。某一页可能没有匹配项,但游标还不为零,不能据此提前结束。每次调用仍需要服务端工作,增量扫描不等于完全没有阻塞成本。 完整迭代保证能看到在迭代全过程始终存在、且符合模式的键,但可能重复返回;迭代中新增或删除的键没有同样保证。应用应按需要去重,或让处理逻辑可以安全重复。扫描不是快照,更不能把一个空页面当作集合已经遍历完毕。 DEL 的语义不同:它接收精确键名,不接收模式。参数末尾带星号,不会删除所有同前缀兄弟键;只有真的存在名字包含这个星号的键,才会删除那一个。命令行工具之外还有 shell 展开这一层,应正确引用参数,不要把终端的展开行为误认为 Redis 自己支持通配删除。 坏掉的样子 维护脚本若假定 DEL 会展开前缀,可能实际什么也没删,却报告清理完成。相反,先用宽泛模式收集 SCAN 结果,再无条件删除每个匹配,也可能误伤不属于自己的数据。必须核对精确目标与修改策略;而且发现与删除之间,同名键可能已经被重建,之前审阅过的名字不一定还指向同一个逻辑对象。 前缀也不是安全边界。访问限制应通过合适的 ACL 键模式和命令权限落实,必要时使用独立部署边界。键模式权限无法约束不携带键参数、直接作用于整个数据库的命令,所以给账户一个前缀模式不等于万事安全。服务名前缀也不会自动预留内存,更不构成淘汰策略隔离。 ...

六月 1, 2027 · 3 分钟 · XB Field Notes
已启用版本化的版本栈顶部是删除标记,下方仍有较早数据;未指定版本的 GET 返回 404。

桶版本化与生命周期管理成本

S3 版本化在同一个键下保留多个版本,只要所需版本仍在,就能恢复许多误覆盖和普通删除。它并不防止所有删除,也不会独立构成备份。真正能否恢复应用,取决于保留策略、访问权限、加密密钥是否仍然可用,以及恢复程序是否经过验证。版本存在与业务恢复成功之间,还有需要明确检查的步骤。 分清版本化状态 在启用版本化的桶中,PUT 产生新版本 ID,同时保留之前的数据版本。每个版本存储完整对象,并非只记录变化的字节。因此,反复覆盖一个大对象,即使可见键数量保持不变,存储成本也可能持续增长。这与块级增量快照的空间模型不同,不能只用修改了多少字节估算对象版本的费用。 启用之前已存在的对象保留 null 版本 ID,新产生的版本化写入不会覆盖那个旧版本。一旦启用,桶就不能回到从未版本化的状态,但可以暂停版本化。暂停期间,后续写入使用 null 版本槽位,可能替换已有 null 版本。讨论覆盖是否安全时,必须明确桶当前处于哪一种状态,不能把暂停与启用混为一谈。 null 是真正的版本标识,不是永久保留保证。null 版本变成非当前版本后,可以受到非当前版本过期规则影响;在授权允许时,也能够按明确版本 ID 删除。因此,列表中出现 null 并不能证明那份旧数据不参与生命周期清理,更不能把它当成永远不会消失的恢复基线。 删除标记改变可见性 启用版本化时,不带版本 ID 的普通 DELETE 创建删除标记。标记处于当前位置后,不指定版本的 GET 通常返回 404;显式请求删除标记的版本 ID 则返回 405。较早数据仍可通过其数据版本 ID 获取,但还受到访问权限、存储类别及加密条件影响。看见 404 不能直接推断底下所有版本已经消失。 删除标记没有对象载荷,却不是免费零字节条目,S3 会按它保存的键名字节收费。删除那个明确的当前标记,可以暴露前一版本,但前一版本本身也可能仍是标记。另一种恢复方式是复制明确的历史数据版本,生成新的当前数据版本,同时保留历史。标记没有数据,不能把它当成普通对象复制来恢复。 按指定 ID 删除数据版本,是另一种永久操作,版本化本身不能把被永久删除的版本找回来。配置 MFA Delete 后,永久删除版本和改变版本化状态会增加相应认证要求,但开启版本化并不会自动开启这种保护。AWS 不支持将 MFA Delete 与生命周期配置一起使用,不能假设所有保护开关可以无条件同时启用。 ...

五月 8, 2027 · 2 分钟 · XB Field Notes
两个不同应用输出共享同一固定基础,所选构建参数不同;只固定 FROM 并不能确定完整发布。

固定基础镜像只固定一个输入,完整发布还需要其他记录

按摘要固定容器基础镜像,可以选择确定的镜像对象,避免依赖会移动的标签。这消除了一类意外构建变化,但不会让所有派生镜像或运行实例都完全相同。应用源码、构建参数、下载依赖、目标平台、运行配置和持久数据,仍可能改变构建结果及其行为。因此,基础镜像不可变是一个明确的输入约束,不是完整发布已经可复现的证明。 坏掉的样子 两个 Dockerfile 可以使用相同 FROM 摘要,却生成不同应用。即使 Dockerfile 一样,构建参数变化或未固定的下载取得新内容,也可能改变输出。所以,仅声明基础镜像不可变,无法证明完整构建是确定性的。决定这个性质的是全部输入,以及施加在输入上的转换过程;遗漏任何真正影响结果的部分,都会留下解释缺口。 完全相同的应用镜像,在不同环境变量下运行,也可能选择不同端点、功能行为或存储。可写绑定挂载还能替换镜像内已经测试过的文件。把配置留在镜像外通常有价值,但只要配置参与定义发布行为,它仍然需要版本管理和审阅。外置不等于无须控制,更不意味着配置变化不会影响同一镜像的实际表现。 普通容器还拥有可写文件系统层。紧急编辑可以改变一个实例,而镜像和其他实例保持原样。之后从未修改的镜像替换实例,编辑就会消失。因此,不可变部署实践需要把产物替换与受控运行状态结合起来;只在 FROM 后写摘要,无法自动强制团队遵守这种操作方式,也无法阻止容器内手工修补。 固定基础只控制一个输入。只读根约束特定写入,而挂载、数据与设置仍相互独立。基础更新需要主动审阅、重建和测试。Docker 示例执行仍受阻,本次未重跑。 快速验证 使用宿主 Python、Docker 构建器和已取得的固定基础镜像运行脚本。此前记录的实测环境为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16;本轮没有重跑 Docker。容器网络与构建 RUN 网络被关闭,但镜像解析仍可能访问仓库;构建步骤没有下载软件包。实验结束删除自己的临时容器和标签,不修改任何既有服务,以便把观察限定在明确的受控输入上: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,uuid BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def inspect(image):return json.loads(docker('image','inspect',image).stdout)[0] name='xbcatm-pinning-'+uuid.uuid4().hex[:10];tags=[name+':one',name+':two'];container=name+'-running' try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory) (root/'Dockerfile').write_text(f'FROM {BASE}\nARG MESSAGE\nRUN printf "%s" "$MESSAGE" > /built-message\nENV MODE=default\nCOPY app.py /app.py\nCMD ["python", "/app.py"]\n') (root/'app.py').write_text('from pathlib import Path\nimport os\nprint(Path("/built-message").read_text(),os.environ["MODE"])\n') layers=inspect(BASE)['RootFS']['Layers'] for tag,message in zip(tags,['one','two']): docker('build','--network','none','--build-arg','MESSAGE='+message,'-t',tag,str(root)) assert inspect(tag)['RootFS']['Layers'][:len(layers)]==layers assert inspect(tags[0])['Id']!=inspect(tags[1])['Id'] assert docker('run','--rm','--network','none',tags[0]).stdout.strip()=='one default' assert docker('run','--rm','--network','none',tags[1]).stdout.strip()=='two default' assert docker('run','--rm','--network','none','-e','MODE=changed',tags[0]).stdout.strip()=='one changed' print('both builds use the same pinned base layers; different build arguments produce different artifacts') print('the identical application image behaves differently when runtime configuration changes') docker('run','-d','--name',container,'--network','none',tags[0],'python','-c','import time;time.sleep(60)') docker('exec',container,'python','-c','from pathlib import Path;Path("/built-message").write_text("edited")') assert docker('exec',container,'python','/app.py').stdout.strip()=='edited default' assert docker('run','--rm','--network','none',tags[0]).stdout.strip()=='one default' failed=docker('run','--rm','--network','none','--read-only',tags[0],'python','-c','from pathlib import Path;Path("/built-message").write_text("edited")',check=False) assert failed.returncode!=0 and 'Read-only file system' in failed.stderr print('a writable container can drift while its image stays unchanged; a read-only root rejects that particular write') finally: docker('rm','-f',container,check=False);docker('image','rm',*tags,check=False) 两次构建使用相同基础引用和应用源码,选择的构建差异只有 MESSAGE;Dockerfile 把它写入 /built-message。第一份镜像输出 one default,第二份输出 two default。完整镜像身份不同,但检查确认它们有序的基础文件系统层前缀完全一致。这里同时对照了公共输入和最终行为,避免把基础相同误当成全部相同。 ...

四月 30, 2027 · 2 分钟 · XB Field Notes
原有 Go HTTP/1.1 实验在 IdleTimeout 200 ms 下,让两次 /ping 和执行 350 ms 的 /slow 复用同一连接。响应完成后等待 StateClosed,再发 GET 才使用新编号。另一个不完整请求头探测由 ReadHeaderTimeout 500 ms 约束,读到 EOF 且未调用处理器。客户端 2 s 等待上限另算;未测试 Nginx,本轮也未重跑原文实验。

空闲超时回收边缘连接

空闲超时会在一段时间没有符合定义的活动后关闭连接。它是一项资源保留策略,不是客户端已经消失的证明。健康客户端也可能暂时安静,关键是说清哪种活动重置哪只计时器,以及连接关闭之后,协议允许调用方如何恢复;不能仅凭“连接还在,但没有字节”就判断它应当被立即回收。 原有 Go HTTP/1.1 实验在 IdleTimeout 200 ms 下,让两次 /ping 和执行 350 ms 的 /slow 复用同一连接。响应完成后等待 StateClosed,再发 GET 才使用新编号。另一个不完整请求头探测由 ReadHeaderTimeout 500 ms 约束,读到 EOF 且未调用处理器。客户端 2 s 等待上限另算;未测试 Nginx,本轮也未重跑原文实验。 仅为原文实验示意,本轮未重跑。 空闲属于某个明确的边界 TCP 连接会占用文件描述符、内核缓冲,以及应用或代理记录的连接状态,但通常不需要为每条连接建立独立的路由表项。回收暂时不用的连接,可以限制保留的资源;复用健康连接则能减少重新建连的成本。策略应服务于工作负载,而不是把所有沉默连接都理解成已经被遗弃的泄漏。 ...

四月 26, 2027 · 3 分钟 · XB Field Notes
滚动三十天窗口移除最旧一天后,预算从负三百三十变为正九百二十。旁边的红色暂停标志仍然有效:示例策略中,事故未结束仍会暂停非必要功能发布。

错误预算是真预算

错误预算把可靠性约定变成能够参与排期的依据。产品与运维先确定目标,再度量失败是否超出允许范围,并提前约定预算耗尽后如何调整工作。这个数字支持共同决策,却不能预测明天的部署会不会引发事故。发布会引入风险,真正消耗已度量预算的是观察到的不良事件;不能把每次发布机械地扣成固定额度,也不能把余额充足当成变更必然安全的证明。 请求数量与判断分支来自原文合成样例,条形使用同一请求数比例。本图未执行代码,也未批准部署;事故关闭且余额为正的场景仅可进入发布审查。 先定义这本账如何计算 对于基于请求的目标,允许失败数等于符合统计条件的请求总数乘以一减目标值。例如,三百万次请求的目标为百分之九十九点九,便允许三千次不良请求。接入发布规则之前,需要写清请求范围、成功条件、测量位置和观察窗口。按流量加权的失败额度,不会自动变成一个固定的宕机分钟数;高峰和低谷的同样一分钟可能影响完全不同数量的请求。 滚动三十天的统计会持续移除旧记录并加入新记录。流量变化时,分母与允许失败数都可能变化,即使目标百分比没有改动。自然月统计则在约定的日历边界重新开始,两种窗口回答的问题不同。事故处理中临时换窗口,可能让看似精确的余额失去可追溯含义。因此应将计算定义与策略一起版本化,并在余额旁显示实际窗口、请求范围和统计截止时间。 在争论之前约定如何响应 一种示例策略是:预算超支时暂停非必要功能发布,把资源转向可靠性工作,同时允许紧急安全修复和事故修复经过审核后执行。需要明确决策负责人、例外所需材料、记录方式以及恢复普通发布审查的条件。谷歌公开的预算策略针对书中的示例服务,不能不加判断地把所有阈值和升级处理规则照搬到另一项业务。 预算余额也不能独立决定是否具备发布条件。事故仍未结束、监控覆盖不足、回归失败或缺少可靠回退方案,都可能继续阻止变更。反过来,修复当前事故的必要变更,不应只因为该事故已经花光预算而被机械阻挡。把判断及依据写下来,才能让规则服务于用户影响和工程取舍,而不是让仪表盘变红的团队承担未经分析的责任。 坏掉的样子 一个大事故离开滚动窗口后,预算可能突然恢复。计算本身没有错,但这不能说明根因已经消除。悄悄更改排除条件,把无法采集的样本填成成功,或者直接平均不同流量服务的成功百分比,同样会制造虚假的盈余。看到余额改善时,应先检查测量覆盖与定义是否变化,再判断服务状态是否确实改善,尤其不能把未知数据解释为零失败。 短窗口燃烧率提供另一种观察角度:把实际不良事件比例除以目标允许的不良比例,可以看出近期消耗速度是否异常。高燃烧率能够在长窗口预算耗尽前发出警告,但由它推算剩余时间,需要假设流量与错误行为继续保持可比,不能当成确定的倒计时。应结合长窗口余额、短窗口变化和低流量条件设置可执行的告警,而不是每出现一次失败就叫醒值班人员。 快速验证 把下面代码保存为 budget-policy.py,使用 Python 3 执行。数据是人为构造的每日请求统计,用来核对滚动窗口及一个刻意保持简单的策略。程序不会连接发布系统,也没有对任何真实变更的事故概率作出估计;运行输出只证明给定输入下的计算和判断分支。 from fractions import Fraction target = Fraction(999, 1000) # Every synthetic daily bin has 100,000 eligible requests. days = [(100000, 1300)] + [(100000, 70)] * 29 def account(window): eligible = sum(total for total, _ in window) bad = sum(errors for _, errors in window) allowed = eligible * (1 - target) return bad, allowed, allowed - bad def feature_policy(remaining, incident_open): return "hold" if remaining < 0 or incident_open else "eligible for review" before = account(days) after = account(days[1:] + [(100000, 50)]) assert before == (3330, 3000, -330) assert after == (2080, 3000, 920) assert feature_policy(before[2], False) == "hold" assert feature_policy(after[2], True) == "hold" assert feature_policy(after[2], False) == "eligible for review" for label, (bad, allowed, remaining) in [("before", before), ("after", after)]: print(f"{label}: bad={bad}, allowed={float(allowed):.0f}, remaining={float(remaining):.0f}, spent={float(bad / allowed):.2%}") print("after rotation, incident still open:", feature_policy(after[2], True)) print("after rotation, incident closed:", feature_policy(after[2], False)) 第一个窗口包含三千三百三十次不良请求,允许三千次,预算使用率为百分之一百一十一,余额为负三百三十。移除最旧一天、加入新一天后,不良请求降为二千零八十次,使用率约百分之六十九点三三,剩余九百二十次。示例特意让两个窗口都包含三百万次符合条件的请求,以便单独观察旧事故离开窗口的影响;真实系统的分母不一定保持不变。 ...

三月 25, 2027 · 1 分钟 · XB Field Notes