文章是本站的原创长文。每篇都保持无脚本、来源可溯,并与本站记录的系统现实一致:一个小型、静态、公网服务如何被构建、加固并保持诚实。

在多版本存储里,删除只是记录变化、而不立刻释放空间。回收这些空位需要维护工作,一旦被跳过,代价就会悄悄累积。
删除不等于已经回收 UPDATE 或 DELETE 会留下旧行版本,直到相关快照都不再需要它们。长事务可以在其他会话持续修改数据时推迟这个时刻,但长事务本身不会凭空让一张没有写入的表膨胀。提交删除、旧版本不再可见、空间得到回收,是不同边界。不能只看删除语句成功,就认定这些页面已经可以重新分配。
普通 VACUUM 通常只是让表文件内部的空间可供新行复用,不会缩小文件。末尾完全空闲的页面有时能够截断;一般压缩则需要 VACUUM FULL 一类重写操作,付出额外空间和更强锁的代价。清理成功但文件大小不变,并不意味着失败。要把活数据、内部可复用空间以及真正归还操作系统的空间分别观察,才不会对正常状态反复执行昂贵重写。
清理与统计是不同任务 autovacuum 由启动进程和多个工作进程组成,并非一名常驻 worker 按固定时刻清完所有表。它根据活动估计和年龄相关规则决定是否执行 VACUUM 或 ANALYZE。ANALYZE 独立收集规划器分布统计,普通 VACUUM 不能替代它。插入驱动的工作与防回卷工作也说明,死元组数量并不是维护的唯一触发条件,不能因为暂时没有大量删除就忽略事务年龄。
基于代价的延迟,会在估计 I/O 工作累计到一定程度后暂停一段时间。代价上限不是“一次调用最多删掉多少行,达到后就退出”。阈值影响资格,worker 是否有空影响调度,存储能力和限流则影响推进速度。排查需要先判断卡在哪个阶段,不能把各种迟缓都归结为某个比例系数太大,也不能把模拟程序里的清理额度当成真实数据库参数。
坏掉的样子 第一类问题是产生旧版本的速度超过回收速度。连续几个观察窗口中,死元组估计与关系体积不断上涨。应结合已经完成的运行、取消情况、worker 可用性、I/O 与写入速率检查。资源确实成为瓶颈时,额外硬件可能有帮助;但重启数据库本身不会让仍需保留的版本变得可回收。在相同 I/O 上限下,降低阈值或增加 worker 数量,也不保证最终吞吐更高。
第二类问题是清理在跑,却因为旧快照或保留的 xmin 而无法回收特定版本。干预之前要检查事务及复制状态。事务开始得很早只是线索,不能证明它持有这次受影响的可见性边界。结束应用事务或删除复制槽可能中断工作,甚至让消费者需要重建,因此应先找到负责人、确认保留目的与恢复要求,而不是为了让监控数字下降就直接清除对象。
第三类问题是仅凭一个 n_dead_tup 值或一条慢计划断定膨胀。统计数量是估计,页面布局、可见性、选择率和统计过时都可能改变读取成本。应比较趋势、vacuum 报告和真实执行计划。文件较大但内部空间可复用,可能是健康稳态;死元组较少,也不排除版本保留或冻结问题。不能让单一计数替代完整因果判断。
...

调度器启动的是一次尝试,业务关心的通常是某个逻辑任务是否完成。一笔每日额度可能因为报错、重启或人工补跑而尝试多次。如果每次尝试都生成新的身份,重复执行就无法与新工作区分。应使用能够代表预期任务的键,例如任务名称与名义计划时间,并在修改业务状态之前定义重复键的含义。尝试日志中的唯一编号可以保留,但不能代替业务层面的去重身份。
调度触发与业务执行是两件事 不能假设所有调度器都会补跑遗漏工作。普通 cron 的具体行为随实现变化,没有统一补跑承诺。启用 Persistent=true 的 systemd 日历定时器,在停用期间至少错过一次触发时,可以在重新启用后补触发;这不等于把所有错过的时间点逐个重放。Kubernetes 文档也明确说明,某些情况下 CronJob 可能重复创建 Job,或者没有创建对应任务。
因此要先确定业务规则:过时任务直接跳过、每个缺失周期都处理,还是对当前状态做一次校正。去重键应使用名义上的逻辑周期,并明确时区。实际启动时间会随重试变化,不能用于识别同一次计划工作。诊断需要的尝试编号与业务身份应分开记录,这样才能同时看见执行了几次尝试,以及到底完成了几个逻辑周期。
坏掉的样子 先查询有没有处理过,再单独修改数据,存在竞态。两个工作进程可能都看到键不存在,然后都应用业务效果。进程锁也许能阻止一台机器上的重叠运行,却不能让业务更新与完成标记文件自动形成原子操作。如果更新成功后、写标记之前失败,重试就会重复生效;先写标记再执行业务,则可能把尚未发生的工作误记为完成。调整两个步骤的先后顺序不能消除中间窗口。
当业务状态与完成记录位于同一个事务数据库时,应在唯一约束下共同提交。提交之前失败,两者都不应留下;提交之后重试,应识别已经完成的逻辑键。SQLite 的 BEGIN IMMEDIATE 会提前开始写事务,其他写入者可能等待,也可能因为繁忙而失败。超时限制等待长度,并不保证任何并发量下都不会冲突;调用方仍需正确区分重试、失败和重复完成。
这个原子性边界不覆盖外部邮件、支付或 HTTP 调用。本地回滚无法撤销远端已经接受的效果。此类流程需要远端提供合适的幂等契约,或采用持久投递设计并在接收方去重。同一个键如果可能携带不同负载,还需要明确拒绝这种冲突的规则。下面示例始终增加固定额度,因此只验证同一逻辑操作的重复尝试,没有把变更参数的重用键视为新请求。
固定额度与唯一完成键在一个 SQLite 事务中提交。历史八线程实验仅在此本地边界内通过;调度送达、强制终止恢复和外部恰好一次效果仍未验证。本次绘图未重跑示例。 快速验证 本轮以 Python 3.12.14 与 SQLite 3.53.1 完整运行下列示例,实际使用临时数据库与并发线程。
...

预写日志允许数据库把事务变成持久状态,而不必立即刷出这个事务修改过的每一个表页与索引页。在 PostgreSQL 中,恢复某个页面所需的日志,必须在这个页面写入存储之前先持久化。正常同步提交还会等待提交记录完成刷盘。因此,缓冲区里的表变更与已经持久化的日志记录,沿着不同路径向前推进。
坏掉的样子 WAL 不是一张稍后再执行 SQL 的预约单。执行过程已经修改内存页面,日志记录描述的是恢复动作。有些日志来自最终没有提交的事务。恢复需要结合重做与事务状态,确保一个未完成事务不会仅因部分日志或数据页已经落盘,就变成其他查询可见的提交结果。把“日志存在”当成“事务已经成功”,会误解恢复后数据的可见性。
持久性设置会改变承诺。关闭同步提交后,成功响应可能先于日志持久化,崩溃时最近已经确认的事务因此可能消失。关闭文件同步的风险更大,系统故障后甚至可能留下不一致存储。整页写入解决的是另一类问题:在页面只写入一部分时,提供重新构造页面的材料。这些机制都无法弥补存储虚假确认同步,也不能补回所有持久副本同时丢失的数据。
客户端没有收到回复,又是另一种不确定性。服务端可能已经成功提交,连接随后才断开。恢复后事务仍然存在,客户端却从未看到成功,此时直接重试业务动作可能造成重复。因此,对需要保证业务操作唯一性的场景,应使用稳定操作标识并核对结果,不能把网络失败一概解释成数据库已经回滚。
快速验证 在 Linux 环境中,以非超级操作系统账户运行脚本,并准备 PostgreSQL 18 服务端与客户端程序。本次实际使用 PostgreSQL 18.6。脚本初始化隔离的临时集群,使用私有 Unix 套接字,并显式开启正常持久性设置。立即停止操作只针对它刚创建的集群。因为演练会故意打断数据库进程,应使用完整测试夹具,不要把路径替换成生产数据目录。
#!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-crash.XXXXXX) mkdir "$lab/socket" cluster=$lab/data writer_pid='' cleanup() { if test -f "$cluster/postmaster.pid"; then pg_ctl -D "$cluster" -m fast -w stop >/dev/null fi if test -n "$writer_pid"; then wait "$writer_pid" || :; fi } trap cleanup EXIT 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' port = 56432 fsync = on synchronous_commit = on full_page_writes = on checkpoint_timeout = '1h' CONF pg_ctl -D "$cluster" -l "$lab/server.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,'base'); CHECKPOINT; SQL checkpoint_before=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT checkpoint_lsn FROM pg_control_checkpoint()') psql -X -v ON_ERROR_STOP=1 -qAt -c "INSERT INTO marker VALUES (2,'committed')" commit_end=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_current_wal_lsn()') test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_current_wal_flush_lsn()>='$commit_end'::pg_lsn")" = t PGAPPNAME=crash_uncommitted psql -X -v ON_ERROR_STOP=1 -qAt >"$lab/writer.log" 2>&1 <<'SQL' & BEGIN; UPDATE marker SET value='uncommitted' WHERE id=1; SELECT pg_sleep(120); COMMIT; SQL writer_pid=$! attempt=0 while test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT EXISTS (SELECT FROM pg_stat_activity WHERE application_name='crash_uncommitted' AND wait_event='PgSleep')")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || { echo 'uncommitted writer was not ready' >&2; exit 1; } sleep 0.1 done test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT checkpoint_lsn='$checkpoint_before'::pg_lsn FROM pg_control_checkpoint()")" = t echo committed_wal_flushed_without_new_checkpoint pg_ctl -D "$cluster" -m immediate -w stop >/dev/null if wait "$writer_pid"; then writer_pid='' echo 'interrupted writer unexpectedly succeeded' >&2 exit 1 fi writer_pid='' pg_ctl -D "$cluster" -l "$lab/server.log" -w -t 15 start >/dev/null grep -F 'redo starts at' "$lab/server.log" psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' 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:base,2:committed' THEN RAISE EXCEPTION 'crash recovery result mismatch'; END IF; END $$; SQL printf 'lab_directory %s\n' "$lab" 第一行以 base 值完成检查点。随后脚本提交第二行,并核对已经刷出的日志位置覆盖提交之后捕获的位置。另一个后端在事务里修改第一行,然后在提交之前进入休眠。通过统计视图观察该后端真实进入休眠状态,证明这项未完成更新确实执行了,而不是只把一条尚未送达数据库的命令放在客户端等待。
...

死锁需要不兼容的等待关系形成一个环。一个事务持有行锁、另一个事务等待时,持锁者仍可能正常结束并放锁;双方各自握着对方必须获得的资源时,就无法沿当前路径完成。PostgreSQL 会检测这种循环,中止其中一个事务,让另一个继续。应用需要区分服务器明确拒绝事务与普通的慢等待,而不是把所有停顿都叫作死锁。
死锁需要循环等待,任意一方都可能失败,全部更新随之回滚。只重试失败的完整操作;所有相关资源都应遵守一致顺序,并为重试设定有限预算。 坏掉的样子 即使没有显式锁语句,更新行也会获取锁。因此,两段看起来简单的更新,如果以相反顺序访问两行,就可能互相等待。参与者更多、包含外键检查、触发器、建议锁或锁模式升级时,依赖环会更难看见。给某一段行编号排序,只能解决这部分访问顺序,不能据此证明整个业务路径再也没有死锁。
数据库检测到死锁时返回 40P01。应用不能依赖“后开始者必输”或“工作量少的一方会被中止”之类规则。锁等待超时、语句超时也可能先于死锁检测触发,返回另一种错误。因此必须查看实际错误码和服务器诊断,再判断发生了什么。没有看到死锁码,并不等于没有形成过循环;重试了一次,也不代表原来的取锁顺序已经修好。
快速验证 准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。甲创建两行数据,并显示死锁检测等待参数:
CREATE SCHEMA deadlock_demo; CREATE TABLE deadlock_demo.items (id integer PRIMARY KEY,n integer); INSERT INTO deadlock_demo.items VALUES (1,0),(2,0); SHOW deadlock_timeout; 本次环境显示一秒。下面给语句十秒预算,让检测有时间发生,并确保没有更短的锁超时抢先干预。如果你的检测间隔更长,应相应调整实验预算。甲记录自己要增加的十,并先更新第一行,暂不提交:
...

最小权限是把某项明确工作需要的操作,授予正确主体,并限定资源范围与有效时间。它是一份能够验证的政策,而不是令人安心的标签。应从服务实际启动身份和依赖开始梳理;闲置权限与缺失权限是不同发现,不能用同一种处理方式解决,也不能因为一个接口暂时调用成功就认为边界已经合适。
把最小值写清楚 列出服务读取的文件、写入的目录、需要访问的网络目标以及管理操作,同时覆盖启动、日志、轮转、恢复与退出。业务数据虽然只在一个目录,服务仍可能合理地需要临时空间和共享库。一份有用清单解释每项访问为什么存在,而不是笼统宣称数据目录以外的一切都不必要,更不应遗漏低频但关键的恢复路径。
对 systemd 单元,User= 与 Group= 决定运行身份,文件权限和沙箱设置进一步约束行为。ProtectSystem=strict 使单元视图中的大部分文件系统只读,但 API 文件系统有文档规定的例外,也可能另行开放写入路径。只读不等于不可读;若目的是保护秘密内容,需要独立的访问权限或可见性约束,例如适合当前服务的 ProtectHome= 政策,不能把写入失败当作读取也被隔离的证明。
检验具体边界 不同指令解决不同问题。PrivateDevices= 限制设备暴露,NoNewPrivileges= 防止通过执行程序取得新权限,RestrictAddressFamilies= 限制套接字地址族。最后这一项不是目标地址或端口防火墙。内核支持、系统或用户服务管理器、既有描述符和其他设置,都可能影响实际效果;列出一串指令名称,并不能证明已经拥有完整沙箱。
单项 capability 可能比不受限 root 更窄,却未必是一项很小的权限,其中有些覆盖广泛管理操作。授予之前,应查看它实际允许什么,以及文件所有权、ACL 或独立受限辅助程序能否更好地满足需求。测试时要在真实服务上下文中同时执行一个应允许的操作和一个相邻但应拒绝的操作,分别记录结果,避免管理员手动验证掩盖服务自身边界。
宽权限为什么总会留下来 事件期间的临时授权经常活得比事件更久。应记录负责人、原因和到期或复核时间,使恢复权限不会悄悄成为日常权限。变更后比较线上绑定与获准政策;源码仓库中存在配置文件,并不说明正在运行的工作负载真的使用了它。临时例外需要明确归宿,而不能只靠某个人记得事后撤销。
凭据寿命属于另一项控制。范围较窄的令牌在有效期内仍可被滥用,轮换权限过大的凭据也不会自动减少权限。应使用适当限定的凭据,避免把它们复制到日志或运维手册。复核既要看它能做什么,也要看工作或人员关系结束后如何撤销;权限范围、保存方式与有效时间共同决定暴露,不能互相替代。
坏掉的样子 在 Kubernetes 中,通配动词与资源会在规则的 API 组和绑定范围内授予广泛权限,但并不自动等于跨所有组和命名空间的集群管理员。即便如此,新出现的匹配资源或动词仍可能无需再次修改政策就得到授权。判断实际权限时,需要检查主体、命名空间、资源名和全部叠加授权,不能看到一个很窄的 Role 就认为总权限也一样窄。
一段时间没有使用,只能成为复核线索,不能直接证明权限应删除。恢复操作可能很重要,却不会出现在普通一周的记录里。反过来,某个角色使用过某项动作,也不能成为给所有角色都保留它的理由。使用记录应按身份和角色关联;例外需要业务依据,不能把一份全局动作集合误当作逐角色的授权证据。
过度收紧可能阻断合法发布,并诱使人绕过正规流程。删除权限之前,应提供且验证更窄的受支持路径。身份模拟本身也受权限控制,不是加一个参数就凭空变成管理员。紧急运维流程应保留责任与记录,而不是让工作流转入秘密共享账号,再把正规路径的零授权当成安全成功。
快速验证 保存为 privilege_audit.py,运行 python3 privilege_audit.py。标准库模型回放七次合成请求,另外按每个角色独立的合成复核窗口记录比较授权与使用。它不读取凭据、不查询生产身份,也不修改任何权限,因此可以安全地观察纯集合判定,同时明确实际授权验证仍然另有工作。
...

多版本并发控制让 PostgreSQL 保留不同的行版本,再判断查询可以看到哪些版本。普通读者可以继续读取较早已提交的版本,同时另一个事务写出新版本。这减少了读写之间的冲突,但不表示每个事务都永久拥有一幅固定画面。快照寿命取决于隔离级别,而且事务能看到自己此前的修改,即使其他事务尚看不到。把这些边界说清楚,才能理解读取结果为何既稳定又可能变化。
A 的可重复读快照保留他人数据的较早状态,也允许读取自己的新插入。提交后的新读取才能看到当前三行。原文历史并发测试的未重跑状态保持不变;此图不增加运行证据。 可见性跟随快照 在读已提交级别,普通读取通常每条语句取得一个新快照。PostgreSQL 的可重复读则复用事务第一条非事务控制语句建立的快照。仅仅发送 BEGIN,还没有建立实际读取使用的边界。因此,一个较早开始、较晚才第一次读取的事务,可能看到这两个时刻之间已经完成的提交。“事务启动时间”与“读取快照已经建立”不能随意混用。
更新会创建一个新行版本。可见性规则决定旧快照是否仍读取之前的版本,而新快照是否看到更新。元组物理上仍然存在,并不足以说明当前查询能看到它;SQL 也不是直接返回数据页上最新写下的字节。只有结合事务状态与快照,才能判断哪一版记录属于这次读取的结果。
事务自己的修改,是简单“历史照片”比喻的重要例外。即使快照早已建立,事务仍然可以读到之后由自己插入的记录。它看到的数据,可能同时包含其他事务的较早状态与自己的新工作。这种行为让事务能够实际完成读写操作,但不能把它误解为外部世界某一个已提交时刻的完整照片。理解这个例外,也能避免错误地认为自己的更新必须提交以后才可读。
坏掉的样子 普通读取取得的常规表锁不会阻塞正常行更新,但锁并没有消失。两个写者仍可能争用同一行;SELECT FOR UPDATE 会明确请求行锁;ACCESS EXCLUSIVE 表锁则与普通读者冲突。因此,MVCC 不承诺所有带有 SELECT 的命令都永远不等待。是否阻塞还需要看语句请求的锁以及当前其他操作。
...

TLS 1.3 握手负责建立流量密钥,并按照应用指定的信任规则认证对端。普通 HTTPS 通常认证服务器,客户端证书需要另外配置,并不是连接一加密就自动双向认证。TCP 建连成功只说明传输连接建立了,既不证明证书身份正确,也不证明 HTTP 服务能够完成请求。
一次往返究竟计算什么 常见的证书认证握手由 ClientHello 开始,其中包含客户端支持的版本、算法和密钥份额。服务器通过 ServerHello 选择参数并提交自己的份额,随后发送加密扩展、证书、CertificateVerify 和 Finished。客户端验证证书与握手证明后,才发送自己的 Finished 和普通应用数据,而不是看到任意证书就立即信任对端。
没有 HelloRetryRequest 时,客户端可以在已建立的传输连接上经过一次 TLS 往返后发送这些数据。这一数字不包含 DNS 查询、TCP 建连,以及 HTTP 请求发出后的响应路程。如果客户端首次没有提供服务器需要的密钥份额,服务器可能要求 HelloRetryRequest,增加一次交换;如果根本没有共同参数,协商则可能直接失败。因此,一次 RTT 描述的是特定协议路径,不是整个网页加载时间的承诺。
TLS 1.3 的密码套件名称表示记录加密算法与哈希,并不会独自告诉你证书使用了什么签名算法,也不完整表示协商出的密钥交换组。看到套件里有 AES,不等于已经确认身份验证成功,更不能据此推断它使用了哪种临时交换。
身份认证与前向保密 在证书认证的临时 Diffie-Hellman 交换中,只要临时机密正确销毁、通信期间端点没有被攻破,攻击者日后仅取得证书私钥,不能据此还原此前记录的会话。这是有前提的前向保密,不是对任何形式的端点入侵都免疫。RSA 证书仍可以给临时交换做认证;证书是 RSA,不代表正在使用旧式 RSA 密钥传输。
...

主机名查询首先由具体程序发起。在 Linux 上,使用 glibc 名称服务接口的应用,可以依照 /etc/nsswitch.conf 中 hosts 项列出的服务查询;使用自有 DNS 客户端的程序则可能走另一条路径。并不存在所有应用都遵守的“先缓存、再 hosts、最后互联网”固定顺序。定位问题的起点是实际调用方,而不是凭操作系统名称假定完整流程。
同名可以经本地文件与直接 DNS 得到不同结果。修改上游记录前,先检查 NSS 动作、调用标志和实际进程环境。此图整理文章中的机制,未重跑实验。 坏掉的样子 当 files 位于 dns 前面时,hosts 文件条目可以覆盖正确的 DNS 回答。修改权威区域不会删除这个本地覆盖。直接 DNS 查询可能已经看到新地址,应用却仍使用本地文件中的旧值;这两项观察可以同时正确,因为它们实际回答的是不同来源的查询。仅凭 dig 正常就认定应用一定使用同一个地址,会漏掉最直接的配置原因。
...