文章是本站的原创长文。每篇都保持无脚本、来源可溯,并与本站记录的系统现实一致:一个小型、静态、公网服务如何被构建、加固并保持诚实。
看起来像 JSON 的 log_format,不一定能可靠地产生结构化事件。nginx 把变量值代入文本,而变量可能包含引号、反斜杠、控制字符,也可能完全没有数据。正确字符串转义与正确字段类型是两个独立要求。用真实解析器检查输出,能发现肉眼查看配置或 nginx -t 成功无法证明的问题,避免上线后才发现部分日志无法进入查询系统。
nginx 1.28.0 历史实验,重跑仍受阻。无效 JSON 片段是有意展示的反例。空的可选耗时与零不同;编码、尝试列表语法、传送和保留仍需分别检查。 坏掉的样子 把 User-Agent 原样插入带引号的 JSON 字段,遇到请求头中的引号就会失败。关闭转义不会更安全,只会让请求字符改变外层记录语法。nginx 默认日志转义则有另一种问题:它可能使用十六进制 \xXX 序列,而这不是合法 JSON 字符串转义。日志适合人阅读,不代表它符合另一种数据格式的语法。
加入 escape=json 可以处理 JSON 字符串转义,但不会自动添加引号、提供 null,或把变量转换成数字。对于本地生成的响应,上游耗时可能根本不存在。如果没有定义可选值的表示方式,就直接不加引号输出,字段可能在语法上缺值;即使实际存在的字符全部转义正确,整个 JSON 仍然无效。
缺失值表示也取决于实测版本的转义路径。nginx 1.28.0 默认转义会为找不到的变量写入短横线,JSON 转义则不写变量文本,所以外面的双引号最终成为空字符串。解析器还必须把这种情况与变量本身确实包含短横线、或包含多次上游尝试列表的情况区分开,不能把所有形式都硬当成同一个数字。
快速验证 下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。
使用宿主 Python 和可丢弃的本地 Docker 守护进程,并确认具有构建、运行权限。历史运行报告为 Linux arm64 上的 nginx 1.28.0、Docker 29.3.1。固定镜像须兼容所用平台;构建的 --network none 不会阻止镜像仓库解析。脚本验证四种日志格式,通过宿主回环端口发送两次 HTTP 请求并解析文件。它尝试清理但没有验证成功,运行者须检查容器、镜像标签和网络是否仍然存在。不要指向远程或生产守护进程。请求头都是公开测试字符串,不读取真实凭据或业务日志:
...
慢启动不等于进程已死。readiness 与 liveness 分别决定普通 Service 流量是否应该进入这个 Pod,以及终止容器是否有助于恢复,两项决策相互独立。readiness 失败不会暂停 liveness;需要配置 startup 探针,才能在启动阶段抑制另外两种检查。只把接口分成两个名字,不能自动解决这个时序问题。
本文关注健康信号的选择与接线错误的诊断。关键是每次检查实际测到了什么,而不是 URL 名称。共用一个接口可以合理;共用会因下游抖动而失败的检查,并据此触发不必要的重启,却可能扩大故障。设计时应验证具体故障模式与预期恢复动作,不能假定有两个接口就天然安全,也不能把“还没准备好”直接解释成“需要杀死重来”。
普通 Service 路由异步收敛;已有连接与 Pod 直连需要另行考虑。 Readiness 把流量挡在门外 连续 readiness 失败达到阈值,会让 Pod 失去普通 Service 路由资格,但 Pod 仍在运行,好让进程完成热身或自行恢复。端点条件与数据路径在失败、恢复两个方向都异步收敛;已有连接和 Pod 直连需要分别考虑。就绪失败不直接触发探针重启,却可能移除全部服务容量,不能无条件称为安全:缓存可以慢慢热,迁移可以跑完,下游依赖可以回来,全程不丢 Pod 的内存状态,也不付重启的代价。
...
投递不等于完成:示意。
Redis 流不会自动为每个连接维护独立游标。普通 XREAD 使用应用提供的位置;消费组则在服务端保存一份组级最后投递位置,加上待处理记录。混淆这两类进度,可能让重启后的消费者跳过未完成任务,也可能重新执行早已完成的业务操作。
消费组在投递时推进位置 XGROUP CREATE 使用 0-0 时从保留历史之前开始,使用 $ 则从当前末端之后开始。每个组在同一份流记录上维护独立投递状态:账单组可能已投递两条,分析组却只投递一条。同一组内的具名消费者分担新工作,并不是每人都得到每一条消息。
在未启用 CLAIM 或 NOACK 的普通 XREADGROUP 中,> 请求组投递位置之后的条目,并把新投递加入待处理列表 PEL。这一位置在业务处理和确认之前就已经前移。XACK 只移除某一组里指定的待处理 ID,不删除流记录,也不推进该组最后投递 ID。
确认可以乱序到达。第二条完成,不代表第一条已经完成;组的投递位置也可以到达流末端,同时仍有旧条目处于待处理状态。除非应用契约确实保证连续完成,否则不能用最大的已投递 ID 或已确认 ID,反推它之前所有业务都已成功执行。
待处理历史属于具名消费者 XREADGROUP 使用 0-0 时,请求的是当前名字对应的待处理历史,不是随意回放整条流。一个新名字看不到另一位消费者名下的待处理条目。重启时换个名字,只读 >,不会恢复之前遗留的投递;服务可能不断处理新任务,看起来正常运行,旧任务却再也没有推进。
恢复需要读取原消费者的待处理历史,或显式转移符合条件的任务,例如使用 XAUTOCLAIM。空闲阈值必须考虑正常处理时长。Redis 8.4 及更新版本也支持可选的 XREADGROUP CLAIM,本实验未启用。转移归属无法阻止迟到的旧工作进程重复外部动作,因此仍需要与业务风险匹配的去重策略。
可以用 XINFO GROUPS 对比 last-delivered-id、pending 和 lag。lag 估计从组当前位置起仍待投递的条目,不是尚未完成的业务动作。某些重设位置或删除操作后,该指标可能无法计算;空值不等于零。lag 为零而 pending 非零,表示新消息已投递到末端,但仍有投递等待确认。
...
只读副本允许一份数据库服务查询,另一份数据库处理写入。报表、搜索或其他适合的读取,可以由此离开写入节点,但把连接路由到某个地址只是设计的一部分。应用还必须说明返回结果需要包含哪些已经提交的变更。本文采用 PostgreSQL 物理热备库,它在正常恢复模式下可以读取,但不能接受普通业务写入。
坏掉的样子 异步复制下,成功写入之后立刻查询副本,仍然可能读到旧值。用户刚修改账户并刷新页面,之前的内容却又出现了。此时副本可能连接健康、日志接收持续推进,只是相关提交还没有被重放。如果新鲜度检查只看接收位置、不看回放位置,就检查了错误的完成边界。
即使回放已经推进,旧事务快照仍然可能影响结果。可重复读事务本来就需要在恢复继续进行时保留自己的视图。等待副本应用某次写入,不会顺便刷新这个事务。因此,要实现写后读到本次修改,既需要足够的回放进度,也需要查询使用在这个进度条件满足之后取得的新快照,两个条件缺一不可。
新鲜度承诺也需要限定范围。包含这个用户较早的一次写入,不等于保证其他地方不存在更新的事务。若读取余额是为了批准下一笔扣款,仍然必须在写入路径中进行事务性核对。副本提供的查询容量,不能替代保护业务动作的并发控制,也不能使一个只读结果天然变成后续写入的有效授权。
回放覆盖不会刷新已有快照。100 与 80 是待运行验证的 PostgreSQL 夹具值,不是容量基准。 快速验证 在 Linux 环境中以非超级操作系统账户运行脚本,并准备 PostgreSQL 18 服务端和客户端程序。原文报告使用 PostgreSQL 18.6;本轮源文审阅未重跑夹具。脚本建立私有主备集群,通过 Unix 套接字连接,不打开 TCP 监听。Unix 套接字仍是监听端点,需要执行环境允许。一个命名管道保持读会话存活,让实验能够明确控制事务快照边界,无需额外安装数据库驱动。
#!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-read-replica.XXXXXX) mkdir "$lab/socket" primary=$lab/primary standby=$lab/standby reader_pid='' reader_open=0 cleanup() { if test "$reader_open" = 1; then exec 3>&-; fi if test -n "$reader_pid"; then wait "$reader_pid" || :; fi for cluster in "$standby" "$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 wal_level = replica wal_keep_size = '64MB' 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 account (id integer PRIMARY KEY,balance integer); INSERT INTO account VALUES (1,100); SQL pg_basebackup -D "$standby" -R -X stream -c fast -w cat >> "$standby/postgresql.conf" <<CONF port = 56433 hot_standby = on CONF pg_ctl -D "$standby" -l "$lab/standby.log" -w -t 15 start >/dev/null mkfifo "$lab/reader.sql" PGPORT=56433 PGAPPNAME=replica_snapshot psql -X -v ON_ERROR_STOP=1 -qAt <"$lab/reader.sql" >"$lab/reader.log" 2>&1 & reader_pid=$! exec 3>"$lab/reader.sql" reader_open=1 printf '%s\n' 'BEGIN ISOLATION LEVEL REPEATABLE READ;' 'SELECT balance FROM account WHERE id=1;' >&3 attempt=0 while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT EXISTS (SELECT FROM pg_stat_activity WHERE application_name='replica_snapshot' AND state='idle in transaction' AND backend_xmin IS NOT NULL)")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || exit 1 sleep 0.1 done PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_wal_replay_pause()' >/dev/null attempt=0 while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_get_wal_replay_pause_state()='paused'")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || exit 1 sleep 0.1 done psql -X -v ON_ERROR_STOP=1 -qAt -c 'UPDATE account SET balance=80 WHERE id=1' target=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_current_wal_lsn()') printf 'primary_balance ' psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT balance FROM account WHERE id=1' printf 'paused_replica_balance ' PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT balance FROM account WHERE id=1' for attempt in 1 2 3 4 5; do test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_last_wal_replay_lsn()>='$target'::pg_lsn")" = f sleep 0.1 done echo fresh_read_gate_timed_out PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_wal_replay_resume()' >/dev/null attempt=0 while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_last_wal_replay_lsn()>='$target'::pg_lsn")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 150 || exit 1 sleep 0.1 done test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT balance FROM account WHERE id=1')" = 80 echo fresh_snapshot_after_replay_80 printf '%s\n' 'SELECT balance FROM account WHERE id=1;' 'COMMIT;' >&3 exec 3>&- reader_open=0 wait "$reader_pid" reader_pid='' test "$(cat "$lab/reader.log")" = "$(printf '100\n100')" echo old_repeatable_read_snapshot_still_100 printf 'lab_directory %s\n' "$lab" 初始余额是一百。备库上的可重复读事务先执行一次查询,再保持打开。脚本核对这个后端确实处于事务内空闲状态并持有快照,随后请求暂停日志回放,并等待实际暂停。主库把余额提交为八十,捕获提交之后的日志位置,作为这次读取需要达到的进度目标。
...
CNAME 记录把一个别名与另一个 DNS 名称关联起来。通过别名解析地址时,解析器沿目标继续查找,直到获得请求的数据或遇到失败。这可以让公开主机名保持稳定,同时改变它背后的 DNS 依赖。它不会让浏览器跳转到另一个 URL,也不会替换应用正在请求的主机名;是否改变客户端可见地址,应由相应应用协议行为决定。
坏掉的样子 别名解析完全正常,目标服务器仍可能返回错误网站。HTTP 继续标识原始主机名,HTTPS 证书验证也关联所请求的服务身份。如果只配置目标名称对应的虚拟主机或证书,原别名请求未必能够工作。DNS 提供的是获得地址的路径,选中的服务器还必须接受用户和应用真正请求的名称。仅检查最终 IP 相同,无法验证这项应用契约。
CNAME 不能与普通 A、AAAA、TXT 等数据共存于同一个所有者名称。DNSSEC 元数据存在特定例外,但这不会让 CNAME 与 A 共存变得有效。普通区域顶点本来就需要 SOA 和 NS,因此在顶点放传统 CNAME 会产生冲突。服务商提供的别名展平等功能,应依据其实际文档理解,不能把界面里类似的名称直接当成标准 CNAME 规则发生变化。
别名链可能终止于不存在的数据,也可能形成循环。每个依赖还有自己的缓存寿命与管理边界;缩短别名 TTL,不会缩短以另一个记录集缓存的目标地址。同时,并非每增加一个别名都必然增加一次网络往返:服务器可能在同一回答中附带多段链,解析器也可能已经缓存相关数据。延迟分析应结合实际问答,而不是机械按链长计数。
解析别名,保留请求身份。 依据原文与明确限制静态绘制;未为本图重跑原文夹具。 快速验证 将脚本保存为 check-cname.py,准备 Docker 后执行 python3 check-cname.py。固定环境使用 BIND 9.18.49、dnsmasq 2.90 和小型 HTTP 服务。所有名称均属于隔离测试区域;解析配置修改和候选区域文件留在可丢弃容器内部,不改公共域名或宿主解析器。运行结束后,脚本清理自己创建的实例和临时镜像标签:
...
EXPLAIN 打印规划器准备采用的执行计划与代价;EXPLAIN ANALYZE 则真的跑一遍语句,在估算旁增加实测行数与时间。它们回答的是两个不同问题:规划器认为应该怎样做,以及数据库实际怎样做。排障时先看前者理解意图,再用后者测量现实,别把 cost 当成秒数,也别在生产库上无准备地执行一条会写数据的 ANALYZE。
计划提供需要核对的估计与观测,而不是单凭行数差异就能完成的诊断。循环次数和提前停止也会制造表面差距;估计准确时,查询仍可能受到锁等待、溢写或大量必要工作的影响。它告诉你读了哪些表、用了什么索引、预计经过多少行,以及每个节点把多少工作交给下一个节点。把计划保存进工单,和请求参数、统计信息更新时间、数据库版本一起记录,几周后的回归才有可比基线。只贴一句“慢”没有证据;保存计划能帮助定位值得检验的假设,但不能自动解释所有请求延迟。
先读输入依赖,再向上追踪结果。有序索引常会改变工作路径,但十个有序编号必须相同;这是概念对比,并非新一次执行。 自上而下读形状 文本计划先打印父节点,再缩进打印它的子节点。向下读可以理解节点依赖哪些输入,向上看可以理解结果怎样汇合。执行可能按需发生:上层的 LIMIT 得到足够多的行便会停止索取,下层排序却可能需要读完全部输入才能交出第一行。缩进描述依赖关系,并不是所有节点通用的执行时间顺序。
cost 使用规划器的工作量单位,不是毫秒。父节点总成本包含子节点,不能把整棵树的数字直接求和。启动成本表示返回第一行前预计投入多少工作,总成本表示完成节点的估算。实际时间和实际行数在 loops 大于一时按每次执行取平均;判断重复工作要结合循环次数,同时避免把父节点包含的子节点时间再加一次。
阅读时先问三个问题。第一,计划是否走了预期的访问路径,例如选择性很高的条件是否用了索引。第二,估算行数和实际行数是否在同一个数量级,数量级错了通常比单个节点的 cost 更值得处理。第三,数据流在哪里膨胀或等待,宽大的中间结果会把后面的排序、连接和聚合一起拖慢。沿着这三个问题读,计划就从一堆术语变成了可验证的因果链。
追踪工作量与放大点 行数最多只是线索,不能直接证明瓶颈。连续读取的大表扫描可能比随机访问索引更便宜,一个每次很快却反复执行数万次的节点也可能主导延迟。先找估算与实际第一次发生明显偏离的地方,再追踪它怎样影响后续连接、排序与内存。嵌套循环并非天然错误:外表很小、内表有匹配索引时它可能最快;危险的是外表被严重低估后仍反复访问内表。
把计划格式化为 JSON 可以让工具比较节点,手工排障先用普通 EXPLAIN 确认访问路径,再按需执行带 ANALYZE、BUFFERS 的测量。缓冲命中、缓冲读取、临时文件读写能帮助定位数据访问与排序落盘。缓冲读取不等于真实磁盘读取,因为操作系统缓存也可能提供数据;看到 read 就宣布磁盘慢,证据还不够。VERBOSE 会显示更多输出列和别名,适合定位复杂视图。
...
自动二分会反复检查历史版本,并把测试结果转成对提交的判断。在简单区间里,它通常能用近似对数级的探针次数替代大量手动检出,但前提是测试可信。包装脚本若把工具缺失或基础设施故障错误归类为产品回归,就可能给出看似明确、实际错误的提交。因此,设计判断契约本身就是调试工作的一部分,不能当作搜索外面无关紧要的准备步骤。
先确定能够复现的问题 选择一个可观察的回归,并用同一个执行器验证计划采用的正常端点和异常端点。某个发布名字被认为没问题,证据弱于在那个准确版本上实际检查成功。输入、工具链、依赖和相关环境应保持足够一致,让源码版本成为真正接受检验的变量;否则即使最后缩小到一个提交,也无法知道改变结果的究竟是代码还是环境。
启动时先给出异常版本,再给出已知正常版本。Git 会选择中间提交,并从当前检出的工作树调用探针。执行器应放在不会随历史切换而消失的稳定路径,或确保每个待测版本都包含可用实现。相对路径脚本在搜索一半时不存在,属于测试设施问题,不是产品恰好从那次提交开始损坏的证据。
按真实退出码契约映射结果 自动运行模式中,零表示正常;一到一百二十七表示异常,但一百二十五是例外,代表当前版本无法测试,需要跳过。其他状态值会中止自动搜索。这与“所有非零都说明发生回归”的简化说法不同,包装脚本必须明确区分结果,而不能把任何失败都交给 Git 当作同一种判断。
文档规定的异常区间包含常用于命令不可执行或不存在的一百二十六和一百二十七。应提前检查必需工具,并把识别出的基础设施失败映射到中止状态,例如二百。不能不看测试工具自身的含义,就原样转交所有非零返回。另外,超出可表示范围的 Shell 退出值可能被截断,因此需要选择明确、受支持的状态,不能随意用一个巨大数字表示致命错误。
跳过适用于某个确实无法测试、结论未知的版本,而不是所有构建失败。如果正在寻找的回归本来就是构建损坏,把构建失败跳过就等于丢掉主要证据。反过来,与症状无关的过时依赖,也不应自动把旧提交判坏。执行器为什么把两类情况区别对待,需要有可检查的依据,并在日志中保留相应原因。
坏掉的样子 测试输出经过管道时,最后的日志命令成功可能掩盖测试失败;清理命令也可能覆盖原状态。应先保存测试结果,再记录诊断,最后明确返回映射后的判断值。标准错误上出现了一段异常文字,并不会阻止 Git 接受意外返回的零。自动化读取的是状态契约,不会根据人类看起来很严重的日志重新推断好坏。
修改源码的测试、复用陈旧构建产物的测试,以及依赖持续变化远端服务的测试,都可能让不同版本之间失去可比性。应隔离生成文件,并保留足够日志解释每次判断。不要为了让探针可重复,就清空宽泛的用户目录;应使用可丢弃且归属明确的测试环境,把清理范围限定到自己创建的路径。
靠近转换边界的跳过,可能留下多个首坏候选。随机失败或非单调变化的判断条件,也会破坏简单边界假设;增加次数不能修复错误标签。归因之前应检查搜索日志,在报告出的边界及相关父级重新测试。提交图、跳过版本和不同构建耗时还意味着,对数级探针次数并不等于固定的实际运行时间。
判断契约也是搜索的一部分。 依据原文与明确限制静态绘制;未为本图重跑原文夹具。 快速验证 在具有 /dev/null 的 POSIX 系统上,使用 Python 3.7 或更新版本,以及 Git 2.32 或更新版本运行下面实验。运行时不要使用 -O、-OO 或非零的 PYTHONOPTIMIZE,以免断言被跳过。临时仓库排除个人 Git 配置和路由变量,关闭钩子与签名,没有远端。九个合成版本编码正常、异常或不可测状态;第七号版本首次变坏,第四号版本专门触发跳过路径。执行器位于检出仓库之外,因此切换提交不会把它移走或替换成旧版本。
...
同一名称下的多条 A 或 AAAA 记录提供一个地址集合。DNS 服务器可能轮换返回顺序,但收到集合后如何使用,由解析器和应用决定。普通地址记录不携带请求负载测量或应用健康结果;仅仅存在多个地址,不能保证均匀流量、自动故障转移,甚至不能保证不同实现看到相同的第一个地址。应将地址发现与后续选择分别理解。
轮换地址集合不会按应用请求调度,也不会自动检测端点健康。文章中的一次保存选择造成十二比零分布,恢复依赖客户端明确重试。此图未新增运行验证。 坏掉的样子 DNS 回答分布与应用请求分布的分母不同。一个递归解析器可以用缓存回答许多客户端,一个应用也可能只查询一次,随后发出数千次请求。连接池会进一步强化这种差异。即便权威回答中的首个地址完美交替,实际服务器请求量仍可能严重不均。只统计 DNS 查询数量,不能证明用户业务已经按同样比例分配。
普通静态记录集中的端点失败后,其地址仍会保留,直到 DNS 数据被修改或另一个系统主动修改它。DNS 服务器不会仅因返回地址就知道应用健康状态。有的客户端连接失败后会尝试其他地址,有的会耗尽截止时间或者直接向调用方报错。是否重试、何时重试及哪些操作可重试,都应针对实际客户端和业务验证,不能默认由 DNS 完成。
把这种机制描述成保证公平的抛硬币,也不准确。顺序可能由配置决定、被随机化、被缓存保留,或者被地址选择策略重排。客户端可能选择首项、并发尝试,或复用先前结果。要解释一次分布观察,除了后端请求计数,还需要知道 DNS 配置、解析器行为、连接复用和工作负载;少了这些条件,无法判断不均衡出现在哪一层。
快速验证 将脚本保存为 check-dns-addresses.py,准备 Docker 后执行 python3 check-dns-addresses.py。固定环境使用 BIND 9.18.49,并明确把 rrset-order 设置为循环顺序。两个真实 HTTP 监听在隔离容器中绑定不同回环地址、使用相同临时端口,响应正文标识真正处理请求的端点。脚本不依赖公网域名,也不会访问现有业务服务器:
...