一条语句就重写完繁忙大表的迁移,既耗时长又全程加锁。把它换成一系列小而向后兼容的改动,同一份表结构照样能往前推,而且不需要停机窗口——原因不是技巧,而是每一步都能单独部署、单独回退。

真正被管理的东西是一把锁。每一次结构变更都会取锁;问题只在于锁被拿多久,而答案取决于这条语句要碰多少数据。

先扩展,再回填,后收缩

三个阶段:扩展先加上新列,同时旧结构继续可用;回填负责把数据灌进去,而且必须分批;收缩只在没有任何代码再读旧结构之后,才把它请出去。

夹在扩展和回填之间的是双写阶段:应用会同时写新旧两列,这样回退不丢任何东西,等新列可信了再把读切过去。

旧代码还需要旧结构,于是顺序规则也就定了:加一个可空列是安全的,删列不安全,改名要拆成两步,NOT NULL 永远排在回填之后。在一张 50 万行的 sqlite 表上,ADD COLUMN note TEXT 耗时 2.0 ms,页数仍停在 13926——这是一次只改元数据的编辑。把它改成 NOT NULL 则必须真的重写:115.4 ms,页数从 13926 变成 28532。同一个结构变更,两种代价,区别只在于引擎要不要碰每一行。

分批比阶段顺序更重要。50 万行放在一个事务里回填,写锁被持有 671.2 ms;同样这些行按每批 2 万切分,共 25 个事务,锁持有时间中位数 7.3 ms、p90 是 7.8 ms。总工作量完全一样——不同的只有最长的那一次持锁,而那恰好就是应用真正感受到的数字。

锁才是全部问题

PostgreSQL 的 ACCESS EXCLUSIVE 与所有其他锁模式都冲突,包括一条裸 SELECT 取的 ACCESS SHARE。TRUNCATE、VACUUM FULL、不带 CONCURRENTLY 的 REFRESH MATERIALIZED VIEW,以及很多 ALTER TABLE 形式都会取它。读者和写者一起等。

迁移本身也可能被挡住,一旦被挡住,它就变成了挡别人的那一方。在回滚日志模式下,另一会话开着读事务时执行 ALTER TABLE,会为等它结束而卡住 1512 ms,这期间应用有 4 次写入失败;同样的迁移在 WAL 模式下 0.3 ms 就完成,零失败。教训不是关于日志模式的:一个忘了提交的 BEGIN、一条 idle-in-transaction 的连接,就能把 3 ms 的结构编辑变成 1.5 秒的中断,并且让所有人排在它后面。

校验放在锁外,强制放在锁内

SET NOT NULL 是文档里把这层拆分写得最清楚的一例:它通常会扫描整张表来证明没有 NULL,除非已经存在一个有效的 CHECK 约束能证明这一点,或者该约束是以 NOT VALID 加上的。ADD CONSTRAINT ... NOT VALID 只登记约束、不检查存量行,VALIDATE CONSTRAINT 之后再用更弱的锁去做那次扫描。

这正是该有的分工。夹具里对 50 万行的校验扫描耗时 16.3 ms,完全不需要写锁——期间应用写入零失败;而真正重写表的强制步骤要 210.4 ms。贵的那部分是扫描,而扫描恰好是可以在不挡任何人的前提下做完的那部分。

坏掉的样子

第一种失败是放在一个事务里的回填。它既锁表又膨胀:一条覆盖大表的 UPDATE 在整个执行期间握着写锁,在 PostgreSQL 里还会为每一行被碰过的数据写一个新版本,之后都得靠 vacuum 收回去。实测形态就是 671 ms 的独占访问对 p90 的 7.8 ms,而影响范围是应用里的每一次写入——只要连接池超时比这把锁短,它们就会在同一瞬间集体失败。

第二种失败是迁移持锁久到触发了应用自己的超时。普通 CREATE INDEX 取的是 SHARE,挡写不挡读,而且整个构建期间都握着;夹具里三次构建在 50 万行上分别持有写锁 67、83、69 ms,而生产表比这大得多。CREATE INDEX CONCURRENTLY 就是为此存在的,代价也不小:它取 SHARE UPDATE EXCLUSIVE,要扫两遍表,必须等待任何可能修改该表的事务结束,不能放在事务块里,而且一旦失败会留下一个无效索引——查询会忽略它,写入却仍要替它付账。

快速验证

先找出锁的持有者,再让 DDL 带超时执行,这样它会快速失败而不是把别人堵住:

psql -c "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;"
psql -c "SELECT pid, mode, granted, left(relation::regclass::text,40) FROM pg_locks WHERE NOT granted;"
psql -c "SELECT indexrelid::regclass, indisvalid FROM pg_index WHERE NOT indisvalid;"
psql -c "SET lock_timeout='3s'; ALTER TABLE events ADD COLUMN note text;"
psql -c "SELECT conname, convalidated FROM pg_constraint WHERE conrelid='events'::regclass;"

第一条点出你的迁移将要排在其后的那个事务:xact_start 非空且 age 很大就是元凶。第二条显示正在等锁的迁移,如果有读者排在它后面,你就找到了影响范围。第三条抓的是失败的 CONCURRENTLY 留下的无效索引。第四条是你应该对每一条 DDL 都采用的跑法——lock_timeout 把「堵住所有人四十秒」换成「快速失败然后重试」。最后一条给出 convalidated,用来确认那个 CHECK 约束确实已经被验证过,然后才敢靠它去支撑 SET NOT NULL。

来源