后台回收让 Postgres 表的膨胀维持在小范围。

autovacuum 悄悄修剪死行

PostgreSQL 更新一行时会产生新版本,删除则让旧版本对后来的快照不可见。但这不意味着所有较早开始读取的事务都已经用完旧版本。普通清理,包括自动回收工作者发起的清理,都必须遵守这个可见性边界,之后才能复用空间。把清理任务安排得更频繁,不能越过仍然有效的读取快照。 坏掉的样子 一个持续很久的快照,可能在删除事务提交以后仍然需要旧行。因此,清理命令可以正常执行,却没有回收预期数量的版本。单纯降低自动回收触发门槛,也不会让这些版本突然变成可删除。应先检查长事务和其他持有回收边界的对象,例如复制槽保留的 xmin,再判断是不是工作者吞吐不足。 回收元组空间,通常意味着同一张表以后可以复用这些位置,并不必然让文件长度下降。普通清理可以截断文件末尾连续的空页,却不会把中间所有存活行搬到一起、整理掉每个孔洞。因此,清理之后磁盘文件大小不变,可能是正常的内部空间复用,不能仅凭这个现象宣布任务没有发挥作用。 快速验证 准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。实验只在自己的测试表上暂时关闭自动回收,并手动执行普通清理以控制时序。它验证的是自动与手动清理共同遵守的可见性规则,不把手动命令当作自动调度已经运行的证据。乙先创建五千行数据: 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
碎片化空洞把 Redis 占用抬高到实际数据之上。

Redis 内存随时间堆出碎片

即使应用键已经删除,Redis 进程仍可能保留较大的常驻内存。这个现象值得解释,但它本身既不能证明内存泄漏,也不能直接证明分配器碎片。真正要回答的是哪些字节仍被分配、哪些页面仍驻留,以及这种占用是否威胁实际容量预算。只看一个名字里带“碎片”的比率,很容易选错处理对象。 把不同测量对象分开 INFO memory 中的 used_memory 表示 Redis 统计的分配量,used_memory_rss 则是操作系统观察到的进程常驻内存。它们都不等于应用负载长度的简单相加,键、对象、元数据、客户端及其他开销也要占空间。MEMORY USAGE 可以估计单个键及其值的分配量,包括相关开销,但不测量这个键独立贡献了多少进程 RSS。 mem_fragmentation_ratio 大体比较常驻内存和 Redis 统计的分配量,两者差额还可能包括代码、共享库、栈以及分配器保留的页面。删除数据后分母下降,即使 RSS 几乎不变,比率也可能迅速升高。因此必须同时检查绝对字节数;一个很小的空实例出现较高比率,不等于它正在占用危险数量的内存,更不是必须立即重启的充分理由。 used_memory_peak 有助于解释此前负载峰值留下的占用。used_memory_dataset 是另一个统计视角,不承诺空键空间对应零进程分配。各项数据也并非全都在 INFO 执行的同一瞬间采样。写入活跃时,不应要求经过四舍五入的比率,精确等于独立读取的两个数字相除,否则可能把采样差异误当成实现错误。 先核实分配器和版本 分配器专用指标有助于区分碎片、保留页面和非堆开销,但含义取决于实际构建。Redis 8.10.1 源码通过小尺寸分配桶的碎片计数计算相关指标,并对 Lua 部分作调整。不能把所有版本的指标都简单重算成 active 除以 allocated;通用文档公式与实际行为不符时,应检查对应版本实现,而不是让旧解释覆盖当前事实。 本实验有意使用 libc 编译。该构建没有 jemalloc 的详细统计,Redis 会用其他测量值补算部分分配器字段,碎片计数则保持为零。因此 allocator_frag_ratio 等于一,不证明进程真的完全没有碎片。解释专门字段或选择处理方式之前,应先记录 mem_allocator,确认数字来自真实分配器统计,还是当前构建能够提供的有限信息。 ...

六月 3, 2027 · 3 分钟 · XB Field Notes
一个备份架,用一次恢复演练证明副本能回来。

没验证的备份只是空想

备份任务完成,证明备份过程生成了一份材料;恢复演练则要证明这份材料、现有工具以及必要环境,能共同恢复出可用系统。这是不同的结论。恢复证据应明确使用了哪份备份、恢复到什么状态、补齐了哪些依赖,以及花了多久才让结果对应用真正有用。不能只保留一个任务成功时间,就省略后面的验证。 坏掉的样子 合法的数据库归档仍然可能依赖范围之外的对象。PostgreSQL 的单库导出工具只导出一个数据库,角色却属于整个集群。在独立集群里恢复带授权信息的备份时,可能因为被引用的角色不存在而失败。如果演练始终在原集群进行,这种缺少恢复材料的问题就会被环境中已有的角色掩盖。 恢复错误也需要明确处理。恢复工具默认可以在 SQL 出错后继续,并在末尾报告错误数量。部分表能够查询,不代表半恢复状态就可以接受。这个小型夹具选择把恢复放进一个事务,使成功具有原子性;发生预期失败时,也不会留下已经恢复了一半的表,便于确认依赖修正之后重新执行的结果。 恢复位置正确是另一项要求。完全恢复成功的备份,也可能按设计不包含它之后发生的变更。校验和证明材料与记录一致,不证明其中包含最新业务事件。同样,总行数相同,并不能排除金额、关联关系或权限错误。验证项需要表达应用语义,不能因为容易查询就只检查几个不能区分正确与错误状态的数字。 快速验证 在 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
Redis 键名描述归属和数据形状,但不自动约束类型与权限。

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
一个有版本对象、并带把老对象移到更冷层的规则的桶。

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

版本化把覆盖和删除都变成了追加。它不去替换字节,而是新铸一个版本 ID 并把旧的留着,于是一次写错的 PUT、一次删错的 DELETE 都还有退路。对于任何通过桶而不是通过数据库管理的数据,这是真正便宜的容灾。 同一个性质也正是麻烦所在。你留下的每一个版本都是要付钱的存储,而它的失败方式并不戏剧化——账单随写入量一路爬升,你能看到的对象数量却纹丝不动。更要命的是,这条曲线通常不会触发任何告警,因为它长得太像正常的业务增长了。 版本 ID 与 null 版本 打开版本化后,每次 PUT 都会产生一个新的版本 ID,最新的那个成为当前版本。不带版本 ID 的 GET 返回当前版本,带版本 ID 的 GET 精确返回那一个。所以「这个对象」的真实含义是「这个对象的当前版本」,任何假定「一个 key 一个对象」的工具都在悄悄出错。 在版本化启用之前写入的对象带着版本 ID null,而启用版本化并不会追溯地把它们变成有版本的。把这串操作重放一遍:启用前先 PUT 一次,存下 null;再启用版本化,然后覆盖两次,这个 key 现在有三个版本、合计 80 字节。那个 null 版本还在那里,而且仍然是一个可以被非版本化写入直接盖掉的单一槽位。 删除标记是墓碑,不是删除 在版本化的桶上不带版本 ID 执行 DELETE,会写入一个删除标记,并由这个标记成为当前版本。此后 GET 返回 404——但什么都没被释放。重放里,桶在删除前持有 80 字节,删除后还是 80 字节。标记是一个零字节的表项,把底下的一切都遮住了。 ...

五月 8, 2027 · 1 分钟 · XB Field Notes
同一个固定基础镜像仍可对应不同构建、配置和容器可写状态。

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

按摘要固定容器基础镜像,可以选择确定的镜像对象,避免依赖会移动的标签。这消除了一类意外构建变化,但不会让所有派生镜像或运行实例都完全相同。应用源码、构建参数、下载依赖、目标平台、运行配置和持久数据,仍可能改变构建结果及其行为。因此,基础镜像不可变是一个明确的输入约束,不是完整发布已经可复现的证明。 坏掉的样子 两个 Dockerfile 可以使用相同 FROM 摘要,却生成不同应用。即使 Dockerfile 一样,构建参数变化或未固定的下载取得新内容,也可能改变输出。所以,仅声明基础镜像不可变,无法证明完整构建是确定性的。决定这个性质的是全部输入,以及施加在输入上的转换过程;遗漏任何真正影响结果的部分,都会留下解释缺口。 完全相同的应用镜像,在不同环境变量下运行,也可能选择不同端点、功能行为或存储。可写绑定挂载还能替换镜像内已经测试过的文件。把配置留在镜像外通常有价值,但只要配置参与定义发布行为,它仍然需要版本管理和审阅。外置不等于无须控制,更不意味着配置变化不会影响同一镜像的实际表现。 普通容器还拥有可写文件系统层。紧急编辑可以改变一个实例,而镜像和其他实例保持原样。之后从未修改的镜像替换实例,编辑就会消失。因此,不可变部署实践需要把产物替换与受控运行状态结合起来;只在 FROM 后写摘要,无法自动强制团队遵守这种操作方式,也无法阻止容器内手工修补。 快速验证 使用宿主 Python、Docker 构建器和已取得的固定基础镜像运行脚本。实测环境为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16。构建和容器关闭网络,因此结果不能归因于远端软件包变化。实验结束删除自己的临时容器和标签,不修改任何既有服务,以便把观察限定在明确的受控输入上: 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 · 1 分钟 · XB Field Notes
观察、决策、行动三步示意图,旁边是空闲属于策略而不是故障证明的英文标题。

空闲超时回收边缘连接

空闲超时会在一段时间没有符合定义的活动后关闭连接。它是一项资源保留策略,不是客户端已经消失的证明。健康客户端也可能暂时安静,关键是说清哪种活动重置哪只计时器,以及连接关闭之后,协议允许调用方如何恢复;不能仅凭“连接还在,但没有字节”就判断它应当被立即回收。 空闲属于某个明确的边界 TCP 连接会占用文件描述符、内核缓冲,以及应用或代理记录的连接状态,但通常不需要为每条连接建立独立的路由表项。回收暂时不用的连接,可以限制保留的资源;复用健康连接则能减少重新建连的成本。策略应服务于工作负载,而不是把所有沉默连接都理解成已经被遗弃的泄漏。 Go HTTP Server.IdleTimeout 约束的是持久连接上等待下一次请求的时间。处理器正在工作、但尚未发送任何字节时,并不会仅仅因为超过这个空闲值就被它终止。读取请求头、传输正文、写响应和执行应用逻辑,都需要各自适用的控制项。名字中出现 idle,并不意味着它已经覆盖完整请求的所有阶段。 Nginx 也把不同边界分开:客户端侧的 keepalive_timeout 管理保留的持久连接;proxy_read_timeout 则针对从上游响应读取数据时,两次相邻读取之间的等待,而不是整个响应的总耗时。它们观察代理两侧的不同活动。数值写成一样,并不能让含义也相同,更不能据此断言整条链路已经具有相同的截止点。 零值也不能跨实现直接套用。Nginx 的 keepalive_timeout 设为零,会禁用客户端持久连接;Go 的 IdleTimeout 为零时,则使用 ReadTimeout 的值。如果后者也没有正的限制,才没有建立空闲期限。需要按具体实现核对“零”的契约,不能统一理解成关闭功能,或理解成立即关闭套接字。 坏掉的样子 阈值短于正常静默间隔,会迫使客户端重新建连,或者中断这个计时器实际覆盖的流量;阈值过于宽松,则可能保留超出服务承受范围的套接字。应记录连接数量、关闭原因、复用情况和实际工作模式。安静只能说明被观察的活动没有发生,并不能独立识别坏客户端,也不能告诉你最合适的秒数是多少。 HTTP keep-alive 指的是复用连接,不是定期发送的应用心跳。TCP keepalive 探针又是另一种机制,不会变成服务器读取到的应用请求字节。应用心跳也只有在某个端点确实把它算作活动时,才会影响该端点的计时器。客户端、代理与上游不一定观察到同一批事件,流式传输和多路复用协议尤其需要逐层核对,不能用一个“保活间隔”解释全部行为。 关闭还可能与客户端发起下一次请求发生竞争。连接池中的旧连接需要恢复路径,但重新建连不代表任意请求都可以安全重放。HTTP 明确允许异步关闭连接,是否重试仍取决于请求语义,以及此前副作用是否可能已经发生。“服务器稍微早关一点没有关系”,对修改数据的请求并不是可靠的普遍结论。 快速验证 将代码保存为 idle_probe.go,执行 go run idle_probe.go。程序只使用 Go 标准库,本次在 macOS 的 Go 1.27.0 上验证。测试服务器监听本机回环地址,发送的是普通 HTTP/1.1 请求;没有访问公网边缘节点,也没有启动 Nginx、使用证书或修改任何生产配置。 ...

四月 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