<?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/%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://www.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://www.xbcatm.com/zh/posts/zero-downtime-migrations/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0000</pubDate><guid>https://www.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;code&gt;NOT NULL&lt;/code&gt; 永远排在回填之后。在一张 50 万行的 sqlite 表上，&lt;code&gt;ADD COLUMN note TEXT&lt;/code&gt; 耗时 2.0 ms，页数仍停在 13926——这是一次只改元数据的编辑。把它改成 &lt;code&gt;NOT NULL&lt;/code&gt; 则必须真的重写：115.4 ms，页数从 13926 变成 28532。同一个结构变更，两种代价，区别只在于引擎要不要碰每一行。&lt;/p&gt;
&lt;p&gt;分批比阶段顺序更重要。50 万行放在一个事务里回填，写锁被持有 671.2 ms；同样这些行按每批 2 万切分，共 25 个事务，锁持有时间中位数 7.3 ms、p90 是 7.8 ms。总工作量完全一样——不同的只有最长的那一次持锁，而那恰好就是应用真正感受到的数字。&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;SELECT&lt;/code&gt; 取的 &lt;code&gt;ACCESS SHARE&lt;/code&gt;。&lt;code&gt;TRUNCATE&lt;/code&gt;、&lt;code&gt;VACUUM FULL&lt;/code&gt;、不带 &lt;code&gt;CONCURRENTLY&lt;/code&gt; 的 &lt;code&gt;REFRESH MATERIALIZED VIEW&lt;/code&gt;，以及很多 &lt;code&gt;ALTER TABLE&lt;/code&gt; 形式都会取它。读者和写者一起等。&lt;/p&gt;
&lt;p&gt;迁移本身也可能被挡住，一旦被挡住，它就变成了挡别人的那一方。在回滚日志模式下，另一会话开着读事务时执行 &lt;code&gt;ALTER TABLE&lt;/code&gt;，会为等它结束而卡住 1512 ms，这期间应用有 4 次写入失败；同样的迁移在 WAL 模式下 0.3 ms 就完成，零失败。教训不是关于日志模式的：一个忘了提交的 &lt;code&gt;BEGIN&lt;/code&gt;、一条 idle-in-transaction 的连接，就能把 3 ms 的结构编辑变成 1.5 秒的中断，并且让所有人排在它后面。&lt;/p&gt;
&lt;h2 id="校验放在锁外强制放在锁内"&gt;校验放在锁外，强制放在锁内&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;SET NOT NULL&lt;/code&gt; 是文档里把这层拆分写得最清楚的一例：它通常会扫描整张表来证明没有 NULL，除非已经存在一个有效的 &lt;code&gt;CHECK&lt;/code&gt; 约束能证明这一点，或者该约束是以 &lt;code&gt;NOT VALID&lt;/code&gt; 加上的。&lt;code&gt;ADD CONSTRAINT ... NOT VALID&lt;/code&gt; 只登记约束、不检查存量行，&lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; 之后再用更弱的锁去做那次扫描。&lt;/p&gt;
&lt;p&gt;这正是该有的分工。夹具里对 50 万行的校验扫描耗时 16.3 ms，完全不需要写锁——期间应用写入零失败；而真正重写表的强制步骤要 210.4 ms。贵的那部分是扫描，而扫描恰好是可以在不挡任何人的前提下做完的那部分。&lt;/p&gt;
&lt;h2 id="坏掉的样子"&gt;坏掉的样子&lt;/h2&gt;
&lt;p&gt;第一种失败是放在一个事务里的回填。它既锁表又膨胀：一条覆盖大表的 &lt;code&gt;UPDATE&lt;/code&gt; 在整个执行期间握着写锁，在 PostgreSQL 里还会为每一行被碰过的数据写一个新版本，之后都得靠 vacuum 收回去。实测形态就是 671 ms 的独占访问对 p90 的 7.8 ms，而影响范围是应用里的每一次写入——只要连接池超时比这把锁短，它们就会在同一瞬间集体失败。&lt;/p&gt;
&lt;p&gt;第二种失败是迁移持锁久到触发了应用自己的超时。普通 &lt;code&gt;CREATE INDEX&lt;/code&gt; 取的是 &lt;code&gt;SHARE&lt;/code&gt;，挡写不挡读，而且整个构建期间都握着；夹具里三次构建在 50 万行上分别持有写锁 67、83、69 ms，而生产表比这大得多。&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; 就是为此存在的，代价也不小：它取 &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt;，要扫两遍表，必须等待任何可能修改该表的事务结束，不能放在事务块里，而且一旦失败会留下一个无效索引——查询会忽略它，写入却仍要替它付账。&lt;/p&gt;
&lt;h2 id="快速验证"&gt;快速验证&lt;/h2&gt;
&lt;p&gt;先找出锁的持有者，再让 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;xact_start&lt;/code&gt; 非空且 &lt;code&gt;age&lt;/code&gt; 很大就是元凶。第二条显示正在等锁的迁移，如果有读者排在它后面，你就找到了影响范围。第三条抓的是失败的 &lt;code&gt;CONCURRENTLY&lt;/code&gt; 留下的无效索引。第四条是你应该对每一条 DDL 都采用的跑法——&lt;code&gt;lock_timeout&lt;/code&gt; 把「堵住所有人四十秒」换成「快速失败然后重试」。最后一条给出 &lt;code&gt;convalidated&lt;/code&gt;，用来确认那个 &lt;code&gt;CHECK&lt;/code&gt; 约束确实已经被验证过，然后才敢靠它去支撑 &lt;code&gt;SET NOT NULL&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;/ul&gt;</content:encoded></item></channel></rss>