<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>恢复 · XB 现场笔记</title><link>https://www.xbcatm.com/zh/tags/%E6%81%A2%E5%A4%8D/</link><description>基于 HTTP、DNS、TLS、Caddy、SSH、发布与恢复的可复现运维现场笔记。</description><generator>Hugo</generator><language>zh-CN</language><atom:link href="https://www.xbcatm.com/zh/tags/%E6%81%A2%E5%A4%8D/index.xml" rel="self" type="application/rss+xml"/><item><title>ZFS 快照给文件系统一个回退键</title><link>https://www.xbcatm.com/zh/posts/zfs-snapshots-multiversion-safety/</link><pubDate>Tue, 21 Mar 2028 00:00:00 +0000</pubDate><guid>https://www.xbcatm.com/zh/posts/zfs-snapshots-multiversion-safety/</guid><description>ZFS 快照在某个瞬间记录一份一致视图，数据变化前几乎零成本，且可随时回滚。</description><content:encoded>&lt;p&gt;ZFS 快照就是给某个数据集在某个 transaction group 上的块树起的一个名字。它不复制任何东西、不冻结任何东西，代价只是几个元数据块：&lt;code&gt;zfs snapshot tank/data@before-upgrade&lt;/code&gt; 会在 shell 提示符重画完之前就返回。由于 ZFS 从不原地覆盖仍在使用的块，旧块树在磁盘上一直有效，直到最后一个引用消失——而快照就是这个引用。&lt;/p&gt;
&lt;p&gt;正是这一点让“一墙的每日历史”变得可行，也正是本文标题最危险的地方。快照给你数据的许多个版本，却不给你“池不会丢”这件事的保障。&lt;code&gt;tank/data&lt;/code&gt; 的每一份快照都住在 &lt;code&gt;tank&lt;/code&gt; 里，所以带走池的那次故障，会在同一瞬间带走数据集和它的全部历史。多版本与多版本安全是两种不同的性质，后者只能由复制来提供。&lt;/p&gt;
&lt;h2 id="为一个一致的树命名"&gt;为一个一致的树命名&lt;/h2&gt;
&lt;p&gt;快照建立在 transaction group 边界上，所以它捕获的树内部自洽——数据库的文件与元数据冻结在同一瞬间，而不是相差几微秒。&lt;code&gt;-r&lt;/code&gt; 会连同所有后代数据集一起打快照，凡是有子数据集的地方都该这么用；否则一个递归布局会在不同层级留下时间不同的快照，层与层之间的增量复制就变得别扭。&lt;/p&gt;
&lt;p&gt;名字由你决定，而它是保留策略唯一的抓手：&lt;code&gt;tank/data@daily-2026-09-16&lt;/code&gt; 可以按模式清理，自动生成的名字则需要额外记账。&lt;code&gt;zfs list -t snapshot -o name,used,refer,creation -s creation&lt;/code&gt; 列出整个快照集以及每一份钉住的空间。&lt;/p&gt;
&lt;h2 id="空间在哪里zfs-list--o-space"&gt;空间在哪里：zfs list -o space&lt;/h2&gt;
&lt;p&gt;ZFS 用一组列来回答唯一重要的问题：这是谁的数据？&lt;code&gt;zfs list -o space tank/data&lt;/code&gt; 会给出 USED、USEDSNAP、USEDDS、USEDREFRESERV、USEDCHILD、REFER 与 AVAIL。REFER 是数据集自身需要的量，USEDSNAP 是快照钉住的量，USED 是二者之和。USEDSNAP 持续上涨而 REFER 保持不变，说明吃掉池子的是快照，不是数据。具体到某一份快照，&lt;code&gt;used&lt;/code&gt; 是它单独钉住的块，&lt;code&gt;refer&lt;/code&gt; 是它能引用到的全部块，两者之差正是与其他版本共用的那部分。&lt;/p&gt;
&lt;p&gt;fixture 在 64 MiB 的数据集上搭了一墙 30 份每日快照，每天翻动 2 MiB。每一次创建都花费 0 KiB——代价落在紧随其后的那次写入上，重写 2 MiB 就是 2048 KiB——整墙历史的代价是 60 MiB，而 30 份完整拷贝需要 1920 MiB。这个比例就是快照存在的全部理由，也解释了为什么池子直到快满才有人注意到。反过来，当池子告急时，该做的第一件事是按 &lt;code&gt;zfs list -t snapshot -o name,used&lt;/code&gt; 倒序找出钉住空间最多的那一份。&lt;/p&gt;
&lt;h2 id="holddiff-与版本对比"&gt;hold、diff 与版本对比&lt;/h2&gt;
&lt;p&gt;hold 是一个具名引用，它让销毁以 EBUSY 失败：&lt;code&gt;zfs hold keep tank/data@daily-2026-09-16&lt;/code&gt; 保护一份快照，&lt;code&gt;zfs holds&lt;/code&gt; 列出持有者，&lt;code&gt;zfs release keep&lt;/code&gt; 解除。备份软件会给正在发送的那份快照加 hold，免得保留脚本把它删掉；代价是保留任务会一直以“快照忙”失败，直到 hold 被释放，而一个卡住的 hold 能让一份快照被钉住好几个月。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;zfs diff tank/data@daily-2026-09-15 tank/data@daily-2026-09-16&lt;/code&gt; 按路径报告变化——&lt;code&gt;+&lt;/code&gt; 新建、&lt;code&gt;-&lt;/code&gt; 删除、&lt;code&gt;M&lt;/code&gt; 修改、&lt;code&gt;R&lt;/code&gt; 重命名——并且两份快照都必须仍然存在，所以对比昨天很容易，对比上个月未必。要看快照里的内容，走数据集下隐藏的 &lt;code&gt;.zfs/snapshot/&amp;lt;名字&amp;gt;&lt;/code&gt; 目录；快照没有单独的挂载点，而 &lt;code&gt;snapdir=visible&lt;/code&gt; 能让这个目录被列出来。&lt;/p&gt;
&lt;h2 id="坏掉的样子"&gt;坏掉的样子&lt;/h2&gt;
&lt;p&gt;真正要紧的失败，恰恰是标题诱导你去相信的那种：池丢了。控制器故障、背板故障或者池级损坏，会原子地删掉数据集以及它的每一份快照，没有部分幸存这回事。唯一的防线是池外的那份拷贝：&lt;code&gt;zfs send -i tank/data@yesterday tank/data@today | zfs receive backup/data&lt;/code&gt; 会把一条独立、带校验和的流写进第二个池，只有它能挺过第一个池的死亡。一墙快照却没有副本，是换了个好看界面的单点故障。池内的冗余（镜像、RAIDZ）与 scrub 解决的是另一类问题——坏盘与静默损坏——它们替代不了池外副本，因为控制器或背板故障会把同一个池里的冗余一起带走。&lt;/p&gt;
&lt;p&gt;第二种是空间悄悄爬升、保留任务却跑不动。USEDSNAP 上涨而 REFER 不动，池越过 80%，随后 ZFS 开始以“空间不足”拒绝写入，尽管从数据集上看一切正常——池的 slop 与预留量在文件系统内部根本看不见。如果某份快照还被某个 clone 钉住，销毁会失败；而要回滚到 clone 的源之前，就必须连带递归销毁那个 clone。长寿命的 hold 会把保留策略变成一张依赖图，所以 hold 的存活时间要短、要可审计。&lt;/p&gt;
&lt;h2 id="快速验证"&gt;快速验证&lt;/h2&gt;
&lt;p&gt;看清快照的代价，以及它们能不能被删掉：&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;zfs list -t snapshot -o name,used,refer,creation -s creation tank/data
zfs list -o space tank/data
zfs holds tank/data@daily-2026-09-16
zfs diff tank/data@daily-2026-09-15 tank/data@daily-2026-09-16
zfs send -i tank/data@daily-2026-09-15 tank/data@daily-2026-09-16 | zfs receive backup/data
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;每份快照的 &lt;code&gt;used&lt;/code&gt; 是它单独钉住的空间，刚创建的快照应接近零；&lt;code&gt;space&lt;/code&gt; 那组列则给出数据集上的 USEDSNAP 与 REFER，USEDSNAP 上涨而 REFER 不变就是该清理的信号。若 &lt;code&gt;zfs destroy&lt;/code&gt; 报快照忙，&lt;code&gt;zfs holds&lt;/code&gt; 会告诉你要释放哪个 tag。最后一条是唯一能防住池丢失的操作，应该定时执行。&lt;/p&gt;
&lt;h2 id="来源"&gt;来源&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-snapshot.8.html"&gt;zfs-snapshot(8)&lt;/a&gt;，快照创建语义与递归快照，已核验 2026-09-16。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-list.8.html"&gt;zfs-list(8)&lt;/a&gt;，&lt;code&gt;space&lt;/code&gt; 列集，含 USEDSNAP、USEDDS 与 REFER，已核验 2026-09-16。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-hold.8.html"&gt;zfs-hold(8)&lt;/a&gt;，hold、&lt;code&gt;zfs holds&lt;/code&gt; 以及销毁返回的 EBUSY，已核验 2026-09-16。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-send.8.html"&gt;zfs-send(8)&lt;/a&gt;，把数据送出本池的增量复制流，已核验 2026-09-16。&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>没验证的备份只是空想</title><link>https://www.xbcatm.com/zh/posts/backups-are-untested-hope-without-proof/</link><pubDate>Wed, 02 Jun 2027 00:00:00 +0000</pubDate><guid>https://www.xbcatm.com/zh/posts/backups-are-untested-hope-without-proof/</guid><description>恢复演练需要证明数据可用、恢复位置正确且必要权限齐全；通过隔离 PostgreSQL 恢复先暴露缺失角色，再验证真实业务事实。</description><content:encoded>&lt;p&gt;备份任务完成，证明备份过程生成了一份材料；恢复演练则要证明这份材料、现有工具以及必要环境，能共同恢复出可用系统。这是不同的结论。恢复证据应明确使用了哪份备份、恢复到什么状态、补齐了哪些依赖，以及花了多久才让结果对应用真正有用。不能只保留一个任务成功时间，就省略后面的验证。&lt;/p&gt;
&lt;h2 id="坏掉的样子"&gt;坏掉的样子&lt;/h2&gt;
&lt;p&gt;合法的数据库归档仍然可能依赖范围之外的对象。PostgreSQL 的单库导出工具只导出一个数据库，角色却属于整个集群。在独立集群里恢复带授权信息的备份时，可能因为被引用的角色不存在而失败。如果演练始终在原集群进行，这种缺少恢复材料的问题就会被环境中已有的角色掩盖。&lt;/p&gt;
&lt;p&gt;恢复错误也需要明确处理。恢复工具默认可以在 SQL 出错后继续，并在末尾报告错误数量。部分表能够查询，不代表半恢复状态就可以接受。这个小型夹具选择把恢复放进一个事务，使成功具有原子性；发生预期失败时，也不会留下已经恢复了一半的表，便于确认依赖修正之后重新执行的结果。&lt;/p&gt;
&lt;p&gt;恢复位置正确是另一项要求。完全恢复成功的备份，也可能按设计不包含它之后发生的变更。校验和证明材料与记录一致，不证明其中包含最新业务事件。同样，总行数相同，并不能排除金额、关联关系或权限错误。验证项需要表达应用语义，不能因为容易查询就只检查几个不能区分正确与错误状态的数字。&lt;/p&gt;
&lt;h2 id="快速验证"&gt;快速验证&lt;/h2&gt;
&lt;p&gt;在 Linux 环境中使用非超级操作系统账户运行脚本，准备 PostgreSQL 18 服务端、客户端和摘要校验工具。本次实际执行版本是 PostgreSQL 18.6。脚本建立互相独立的临时源集群与目标集群，使用私有 Unix 套接字，不打开网络监听。实验角色没有登录能力，通过受控角色切换验证授权，不涉及真实账户凭据。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/bin/sh
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -eu
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;umask&lt;/span&gt; &lt;span class="m"&gt;077&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;lab&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;mktemp -d /tmp/xbcatm-restore.XXXXXX&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mkdir &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;&lt;span class="s2"&gt;/socket&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;/source
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;/target
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cleanup&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; cluster in &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$source&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;test&lt;/span&gt; -f &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$cluster&lt;/span&gt;&lt;span class="s2"&gt;/postmaster.pid&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; pg_ctl -D &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$cluster&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; -m fast -w stop &amp;gt;/dev/null
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;trap&lt;/span&gt; cleanup EXIT
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; cluster in &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$source&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; initdb -D &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$cluster&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; --no-locale --encoding&lt;span class="o"&gt;=&lt;/span&gt;UTF8 --auth-local&lt;span class="o"&gt;=&lt;/span&gt;trust --auth-host&lt;span class="o"&gt;=&lt;/span&gt;reject &amp;gt;/dev/null
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; cat &amp;gt;&amp;gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$cluster&lt;/span&gt;&lt;span class="s2"&gt;/postgresql.conf&amp;#34;&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;&amp;lt;CONF
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;listen_addresses = &amp;#39;&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;unix_socket_directories = &amp;#39;$lab/socket&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;CONF&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;port = 56432\n&amp;#39;&lt;/span&gt; &amp;gt;&amp;gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$source&lt;/span&gt;&lt;span class="s2"&gt;/postgresql.conf&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;port = 56433\n&amp;#39;&lt;/span&gt; &amp;gt;&amp;gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;/postgresql.conf&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pg_ctl -D &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$source&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; -l &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;&lt;span class="s2"&gt;/source.log&amp;#34;&lt;/span&gt; -w -t &lt;span class="m"&gt;15&lt;/span&gt; start &amp;gt;/dev/null
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pg_ctl -D &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; -l &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;&lt;span class="s2"&gt;/target.log&amp;#34;&lt;/span&gt; -w -t &lt;span class="m"&gt;15&lt;/span&gt; start &amp;gt;/dev/null
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PGHOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;/socket &lt;span class="nv"&gt;PGPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;56432&lt;/span&gt; &lt;span class="nv"&gt;PGDATABASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres &lt;span class="nv"&gt;PGUSER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;id -un&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PGCONNECT_TIMEOUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;psql -X -v &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; -qAt &lt;span class="s"&gt;&amp;lt;&amp;lt;&amp;#39;SQL&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;CREATE ROLE backup_reader NOLOGIN;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;CREATE TABLE customers (id integer PRIMARY KEY,name text NOT NULL);
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;CREATE TABLE orders (id integer PRIMARY KEY,customer_id integer REFERENCES customers,amount_cents integer CHECK (amount_cents&amp;gt;0));
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;INSERT INTO customers VALUES (1,&amp;#39;Ada&amp;#39;);
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;INSERT INTO orders VALUES (10,1,4210),(11,1,1865);
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;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;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;GRANT SELECT ON customers,orders,order_totals TO backup_reader;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;SQL&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pg_dump -Fc -w -f &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;&lt;span class="s2"&gt;/snapshot.dump&amp;#34;&lt;/span&gt; postgres
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sha256sum &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;&lt;span class="s2"&gt;/snapshot.dump&amp;#34;&lt;/span&gt; &amp;gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;&lt;span class="s2"&gt;/snapshot.sha256&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;psql -X -v &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; -qAt -c &lt;span class="s1"&gt;&amp;#39;INSERT INTO orders VALUES (12,1,1000)&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;source_after_backup_orders &amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;psql -X -v &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; -qAt -c &lt;span class="s1"&gt;&amp;#39;SELECT count(*) FROM orders&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sha256sum -c &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;&lt;span class="s2"&gt;/snapshot.sha256&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;PGPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;56433&lt;/span&gt; pg_restore --single-transaction --exit-on-error -w -d postgres &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;&lt;span class="s2"&gt;/snapshot.dump&amp;#34;&lt;/span&gt; &amp;gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;&lt;span class="s2"&gt;/missing-role.log&amp;#34;&lt;/span&gt; 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;restore unexpectedly ignored a missing role&amp;#39;&lt;/span&gt; &amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;grep -F &lt;span class="s1"&gt;&amp;#39;role &amp;#34;backup_reader&amp;#34; does not exist&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;&lt;span class="s2"&gt;/missing-role.log&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;PGPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;56433&lt;/span&gt; psql -X -v &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; -qAt -c &lt;span class="s2"&gt;&amp;#34;SELECT to_regclass(&amp;#39;public.orders&amp;#39;) IS NULL&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; t
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; failed_restore_rolled_back
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;PGPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;56433&lt;/span&gt; psql -X -v &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; -qAt -c &lt;span class="s1"&gt;&amp;#39;CREATE ROLE backup_reader NOLOGIN&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;PGPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;56433&lt;/span&gt; pg_restore --single-transaction --exit-on-error -w -d postgres &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;&lt;span class="s2"&gt;/snapshot.dump&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;PGPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;56433&lt;/span&gt; psql -X -v &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; -qAt &lt;span class="s"&gt;&amp;lt;&amp;lt;&amp;#39;SQL&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;SELECT count(*) FROM orders;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;SELECT name,total_cents FROM order_totals;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;SELECT has_table_privilege(&amp;#39;backup_reader&amp;#39;,&amp;#39;orders&amp;#39;,&amp;#39;SELECT&amp;#39;),has_table_privilege(&amp;#39;backup_reader&amp;#39;,&amp;#39;orders&amp;#39;,&amp;#39;INSERT&amp;#39;);
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;DO $$ BEGIN
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; IF (SELECT count(*) FROM orders)&amp;lt;&amp;gt;2 OR
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; (SELECT string_agg(id||&amp;#39;:&amp;#39;||amount_cents,&amp;#39;,&amp;#39; ORDER BY id) FROM orders) IS DISTINCT FROM &amp;#39;10:4210,11:1865&amp;#39; OR
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; (SELECT total_cents FROM order_totals WHERE name=&amp;#39;Ada&amp;#39;) IS DISTINCT FROM 6075 OR
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; (SELECT count(*) FROM orders o LEFT JOIN customers c ON c.id=o.customer_id WHERE c.id IS NULL)&amp;lt;&amp;gt;0 THEN
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; RAISE EXCEPTION &amp;#39;restored business data mismatch&amp;#39;;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; END IF;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;END $$;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;DO $$ BEGIN
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; BEGIN
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; INSERT INTO orders VALUES (98,999,100);
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; RAISE EXCEPTION &amp;#39;restored foreign key unexpectedly accepted an orphan&amp;#39;;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; EXCEPTION WHEN foreign_key_violation THEN
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; RAISE NOTICE &amp;#39;restored foreign key rejected orphan&amp;#39;;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; END;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;END $$;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;SET ROLE backup_reader;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;SELECT id,amount_cents FROM orders ORDER BY id;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;DO $$ BEGIN
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; BEGIN
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; INSERT INTO orders VALUES (99,1,100);
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; RAISE EXCEPTION &amp;#39;read-only role unexpectedly wrote data&amp;#39;;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; EXCEPTION WHEN insufficient_privilege THEN
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; RAISE NOTICE &amp;#39;read-only role rejected write&amp;#39;;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; END;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;END $$;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;SQL&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;lab_directory %s\n&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$lab&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;源库包含一个客户、两笔订单、汇总视图、外键以及只读角色授权。自定义格式归档捕获这个状态，之后源库又增加第三笔订单。输出中源库订单数为三，证明选定备份点之外确实存在后续活动，避免测试把当前源库和已经保存的快照混为一谈。恢复目标是否应该包含第三笔，需要由选择的备份点决定。&lt;/p&gt;
&lt;p&gt;摘要检查能够成功，首次恢复却必须由于目标缺少只读角色而失败。脚本核对具体错误，并确认订单表随事务回滚。这就是只检查归档文件无法发现的环境依赖。随后，在目标创建已经说明的实验角色，再使用同一份归档重复恢复，不通过跳过授权来掩盖缺失对象。&lt;/p&gt;
&lt;p&gt;恢复目标必须包含原来的两笔订单及其正确标识、金额，视图必须给出客户 Ada 和六千零七十五分的汇总。第三笔订单此时缺席才是正确结果。插入不存在客户的订单，必须被已经恢复的外键拒绝。权限查询显示允许读取、拒绝插入；切换到只读角色后，实际查询应成功，真实写入尝试则必须失败。&lt;/p&gt;
&lt;p&gt;这些断言分别覆盖数据内容、应用查询、关联约束以及实际权限。所有实验服务在退出时停止，打印目录保留归档和日志。演练没有测试外部认证、加密密钥恢复或应用进程启动；若这些属于真实系统的恢复依赖，就必须在完整恢复方案中补充，不能把角色授权验证直接当成整套登录链路已经正常。&lt;/p&gt;
&lt;h2 id="留下能支持决策的恢复结果"&gt;留下能支持决策的恢复结果&lt;/h2&gt;
&lt;p&gt;生产演练应从准备承担恢复职责的来源取回材料，重建所需环境，恢复数据，再执行代表性的应用行为。安装兼容软件是正常恢复工作的一部分，应把所需时间和前提计入，而不是假定一台已经准备好的主机永远存在。生产系统不可用时，恢复所需访问能力也应该仍然能够取得。&lt;/p&gt;
&lt;p&gt;把恢复总耗时与恢复时间目标比较，把恢复出来的终点与允许丢失的数据范围比较。计时应包括获取材料、验证以及应用真正可用，而不只是数据库命令本身运行了几秒。较大的恢复任务可以选择适合负载的事务与并行选项，但要明确发生部分失败之后如何判断和处理，不能一边要求原子性一边又采用不兼容的执行方式。&lt;/p&gt;
&lt;p&gt;备份工具、模式、角色、保留策略或基础设施发生变化后，应重新演练。保存成功检查和已经修正的失败作为证据，让下一次恢复能够沿着确定输入重现。具有已知边界的可重复恢复流程，才能支持运行决策；备份调度器显示绿色，单独回答不了系统是否已经具备可用的恢复能力。&lt;/p&gt;
&lt;h2 id="延伸阅读"&gt;延伸阅读&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.xbcatm.com/zh/notes/backups-are-only-useful-after-a-restore-rehearsal/"&gt;备份只有在演练过恢复之后才有用&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.xbcatm.com/zh/notes/reliable-defaults-beat-clever-recovery/"&gt;可靠的默认行为胜过聪明的恢复&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="来源"&gt;来源&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/app-pgdump.html"&gt;PostgreSQL 18 pg_dump&lt;/a&gt;，单库导出范围及不包含的集群级对象，已核验 2026-09-12。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/app-pgrestore.html"&gt;PostgreSQL 18 pg_restore&lt;/a&gt;，错误处理、单事务恢复、授权及并行限制，已核验 2026-09-12。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.cisa.gov/stopransomware/ransomware-guide"&gt;CISA 勒索软件防护指南&lt;/a&gt;，结合恢复依赖测试备份可用性与完整性，已核验 2026-09-12。&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item></channel></rss>