<?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://hk.xbcatm.com/zh/tags/%E8%A1%A8%E7%BB%93%E6%9E%84/</link><description>基于 HTTP、DNS、TLS、Caddy、SSH、发布与恢复的可复现运维现场笔记。</description><generator>Hugo</generator><language>zh-CN</language><atom:link href="https://hk.xbcatm.com/zh/tags/%E8%A1%A8%E7%BB%93%E6%9E%84/index.xml" rel="self" type="application/rss+xml"/><item><title>零停机迁移在旧代码运行时就改表结构</title><link>https://hk.xbcatm.com/zh/posts/zero-downtime-migrations/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/zh/posts/zero-downtime-migrations/</guid><description>分阶段协调兼容的结构与应用变更，处理并发写入，并在移除旧结构之前验证锁与发布边界。</description><content:encoded>&lt;p&gt;零停机迁移是兼容性与可用性目标，不是把语句变短之后自动获得的保证。数据变化时，新旧应用可能同时运行，因此需要一起安排表结构、写入行为、回填、读取切换以及恢复方式。删除信息并不会因为前面采用了增量步骤就变得可逆；是否还能回退，取决于旧程序需要的数据是否仍然存在且正确。&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;p&gt;PostgreSQL 的 &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; 会与普通读写冲突。许多 &lt;code&gt;ALTER TABLE&lt;/code&gt; 形式即使不用重写数据，也要获取它。普通 &lt;code&gt;UPDATE&lt;/code&gt; 则获取 &lt;code&gt;ROW EXCLUSIVE&lt;/code&gt; 和行锁，不会因此把所有其他写入方都全局排除。具体冲突取决于命中的行和并发操作；长事务会一直持有相关锁，直到事务结束，而不是语句返回后就全部释放。&lt;/p&gt;
&lt;p&gt;排队中的强锁可能继续延迟后来的冲突请求。应识别实际阻塞关系、事务边界与等待事件，不能仅凭哪个事务最老就认定它是原因。SQLite 的回滚日志与 WAL 模式属于不同引擎契约。旧文中的毫秒和页数对比，在本次审阅中没有建立精确基准证据；它们不能当作 PostgreSQL 预测，也不是本轮测量结果。&lt;/p&gt;
&lt;h2 id="校验放在锁外强制放在锁内"&gt;在较弱锁下验证&lt;/h2&gt;
&lt;p&gt;以 PostgreSQL 17 为例，只有当前写入方已经满足条件时，才添加合适的 &lt;code&gt;CHECK ... NOT VALID&lt;/code&gt;。这个选项延后检查旧行，却立即约束新的插入和更新。随后 &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; 用 &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt; 扫描，它允许普通数据读写，但会与部分维护和结构操作冲突。因此验证并非完全不取锁，也不是对所有并发活动都没有影响。&lt;/p&gt;
&lt;p&gt;若已经验证的 CHECK 能证明该列不会为空，且没有在同一条命令中删除它，&lt;code&gt;SET NOT NULL&lt;/code&gt; 可以跳过扫描。这仍不会免除最终修改所需的更强锁，而设置 NOT NULL 本身也不必然重写整表。PostgreSQL 18 扩展了非空约束能力，所以必须按实际部署的大版本查语法，不能把不同版本的规则混在一起。&lt;/p&gt;
&lt;h2 id="坏掉的样子"&gt;坏掉的样子&lt;/h2&gt;
&lt;p&gt;回填刚完成一次，仍存活的旧写入方又把新表示变旧。切读前要监控不一致计数和写入方版本。收缩涉及破坏性删除时，恢复可能依赖备份恢复或向前修复；应在删除数据之前演练，而不是承诺任何阶段都能简单撤销。只有进度百分比到达一百，还不足以证明线上不变量成立。&lt;/p&gt;
&lt;p&gt;普通 &lt;code&gt;CREATE INDEX&lt;/code&gt; 在构建期间阻止写入。&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; 允许普通数据修改，但需要额外扫描和等待，不能放进事务块，失败后还可能留下无效索引。这样的索引可能继续增加更新开销，甚至继续执行唯一性约束。应检查具体状态，再进行获授权的恢复操作，不能把无效索引一概当作没有任何效果。&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;
&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/posts-zero-downtime-migrations.detail.zh.svg" alt="兼容写入、条件回填、验证、切读及延迟收缩，与 PostgreSQL 约束锁和恢复边界分别呈现。" width="560" height="2153"&gt;
&lt;figcaption&gt;迁移兼容性：机制与证据边界。&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id="快速验证"&gt;快速验证&lt;/h2&gt;
&lt;p&gt;下面保留的是运维检查清单，不是隔离的迁移实验。只应在明确选定的一次性 PostgreSQL 数据库中使用，其中已有自己拥有的 &lt;code&gt;events&lt;/code&gt; 表且没有 &lt;code&gt;note&lt;/code&gt; 列。先确认连接、search_path、目录可见权限、DDL 权限，以及独立的整条语句超时。第四条命令确实改变表结构，不要直接粘贴到生产数据库。&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;psql -c &amp;#34;SELECT pid, state, now()-xact_start AS age, left(query,50) FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start;&amp;#34;
psql -c &amp;#34;SELECT pid, mode, granted, left(relation::regclass::text,40) FROM pg_locks WHERE NOT granted;&amp;#34;
psql -c &amp;#34;SELECT indexrelid::regclass, indisvalid FROM pg_index WHERE NOT indisvalid;&amp;#34;
psql -c &amp;#34;SET lock_timeout=&amp;#39;3s&amp;#39;; ALTER TABLE events ADD COLUMN note text;&amp;#34;
psql -c &amp;#34;SELECT conname, convalidated FROM pg_constraint WHERE conrelid=&amp;#39;events&amp;#39;::regclass;&amp;#34;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;活动查询只列出候选事务，不能证明谁正在阻塞；&lt;code&gt;pg_blocking_pids&lt;/code&gt; 才提供阻塞关系。未授予的锁也不是所有持锁者清单。&lt;code&gt;lock_timeout&lt;/code&gt; 限制每次取锁等待，不限制执行时间或总体影响。单看 &lt;code&gt;convalidated&lt;/code&gt; 也不能知道证明了哪个条件。本轮没有运行这些命令；发布兼容性、并发回填、约束状态转换和恢复，仍需获授权且固定版本的夹具验证。&lt;/p&gt;
&lt;h2 id="来源"&gt;来源&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/sql-altertable.html"&gt;PostgreSQL：ALTER TABLE&lt;/a&gt;，&lt;code&gt;SET NOT NULL&lt;/code&gt;、&lt;code&gt;NOT VALID&lt;/code&gt; 与 &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt;，已核验 2026-09-16。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/sql-createindex.html"&gt;PostgreSQL：CREATE INDEX&lt;/a&gt;，并发建索引与失败后留下无效索引的形态，已核验 2026-09-16。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/explicit-locking.html"&gt;PostgreSQL：显式锁&lt;/a&gt;，哪些命令会取 &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; 与 &lt;code&gt;SHARE&lt;/code&gt;，已核验 2026-09-16。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://martinfowler.com/bliki/ParallelChange.html"&gt;Martin Fowler：并行变更&lt;/a&gt;，扩展/收缩模式的另一个名字，已核验 2026-09-16。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/17/sql-altertable.html"&gt;PostgreSQL 17：ALTER TABLE&lt;/a&gt;，特定版本的 CHECK 验证与非空状态转换，已核验 2026-10-18。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/17/runtime-config-client.html"&gt;PostgreSQL 17：客户端默认设置&lt;/a&gt;，取锁等待超时与语句时长的区别，已核验 2026-10-18。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/17/functions-info.html"&gt;PostgreSQL 17：系统信息函数&lt;/a&gt;，通过 pg_blocking_pids 查询实际阻塞关系，已核验 2026-10-18。&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/17/transaction-iso.html"&gt;PostgreSQL 17：事务隔离&lt;/a&gt;，并发更新与条件重新检查，已核验 2026-10-18。&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item></channel></rss>