TT Lab
开始
学习 学习路径 课程

改表结构 — 别让发版停掉服务

迁移把服务停住的机制

在 TT Lab 中继续学习

一句话总结

迁移事故不是因为操作耗时长,而是因为在等待锁的期间,后面的请求排起了队。

过时的建议已经不对了

“添加带默认值的列会把整张表重写一遍”是一条老建议。从 PostgreSQL 11 起,默认值只记录在元数据中,读取时才填充。

在 30 万行的表上测一下,结果是这样的。

alter table big add column status text not null default 'new';
Time: 0.510 ms

只有几毫秒。所以这个操作本身并不是问题。

真正的问题是等待锁

ALTER TABLE 会获取 ACCESS EXCLUSIVE 锁。这是最强的锁,连读取也会阻塞。

即使操作只需要 0.5ms,也必须先等到拿到这把锁。如果前面有一个很长的查询,就要等它结束。

真正的事故就在这里发生。

正在等待的 ALTER 之后到来的所有查询,都会一起排队。

锁请求是按顺序处理的。ALTER 在队列最前面等待时,后面到来的普通 SELECT 也要排在它后面。这些查询本来与 ALTER 毫无关系,可以直接通过。

在实验中复现,就是这样。

긴 SELECT 실행 중  →  ALTER 가 기다림  →  단순 SELECT 도 막힘
ERROR:  canceling statement due to lock timeout

锁等待让请求排起队的样子。长 SELECT 正在执行,所以 ALTER TABLE 在等待;锁请求按顺序处理,因此在它之后到来的两个普通 SELECT 也一起被阻塞。长 SELECT 结束后,ALTER 的实际工作只用 0.5 毫秒就完成了

看起来整个服务都停了。但数据库指标却一切正常——CPU 和磁盘都很空闲,每条查询本身都很快。所以找原因要花很长时间。

所以规则只有一条

set lock_timeout = '3s';
alter table ...;

迁移之前一定要设置 lock_timeout。如果 3 秒内拿不到锁,就放弃,之后再重试。失败的迁移可以重新运行,但让请求排队的那 5 分钟是无法挽回的。

不要与 statement_timeout 混淆。后者限制的是执行时间,而 lock_timeout 限制的是等待的时间。迁移需要的是后者,因为后面还有排队的请求。

索引会阻塞写入

CREATE INDEX 会获取 SHARE 锁,它与 INSERT、UPDATE、DELETE 冲突。读取可以,写入会被阻塞。表很大的话,要持续好几分钟。

create index concurrently idx_n on big(n);

CONCURRENTLY 会扫描表两次,并且不阻塞写入。但需要接受三件事。

  1. 更慢——要扫描两次
  2. 不能在事务中使用——如果迁移工具自动包裹事务,就会失败
  3. 失败时会留下不可用的索引——indisvalid = false。必须删除后重新创建

如果不知道第 3 点,就会为“明明建了索引却没被使用”困惑很久。确认方法如下。

select indexrelid::regclass, indisvalid from pg_index where not indisvalid;

删除列同样危险

drop column 本身很快(只修改元数据)。问题在于仍有读取该列的代码在运行的时候。

所以要把顺序拆开。

  1. 部署 A——让代码不再使用该列
  2. 部署 B——删除该列

如果把两者放进同一次部署,中间会有几秒钟出现错误。添加列也一样——先创建列,然后在下一次部署中使用它。

分批回填

update big set note = 'x';        -- 30만 행을 한 트랜잭션에

这样做,这些行会全部被锁住,WAL 会一下子膨胀,复制延迟也会飙升。

update big set note = 'x' where id between 1 and 10000;
-- 커밋하고, 잠깐 쉬고, 다음 구간

分批提交的话,锁的持续时间就很短,复制也能跟上。看起来慢,但服务不会停。

总结

操作 锁 风险
add column(含默认值) ACCESS EXCLUSIVE 操作很快,等待才是风险
create index SHARE 写入被阻塞
create index concurrently 较弱 很慢,且不能在事务中使用
set not null ACCESS EXCLUSIVE 全表扫描
大批量 update 大量行锁 WAL 与复制延迟

只要全部设置 lock_timeout,就能避开最坏的情况。

在实际项目中

这种故障发生在部署窗口之内,所以格外醒目。执行迁移的人只看到命令一直没结束,其他人看到的则是整个服务停了。而这期间数据库指标全是绿色,所以找到原因之前,好几分钟就这样白白流逝了。

因此,团队里最好约定三件事。第一,迁移会话一定要设置 lock_timeout——失败比让全部停摆要好。第二,执行之前,先确认那张表上是否有长时间运行的事务。第三,先写下回滚的办法——添加列很容易,但类型变更很难回滚。

如果使用迁移工具,还要确认该工具是否会把语句包裹在事务中。如果会,CREATE INDEX CONCURRENTLY 就无法在其中执行,必须把这一条语句单独拿出来运行。