那天我们发布了一个"看起来很安全"的改动:给任务表加一个
priority小整数类型字段,带默认值 0。本地几万行数据,migration 秒过。生产上 3000 万行,这条 migration 跑了 8 分 20 秒。期间整表被锁,所有写入排队,前端一片 502。更糟的是它排在发布流程的第一步,后面还有代码要发——整个发布被卡了十分钟,期间服务不可用。
事后复盘时我发现,我们对"DDL 会锁多久"这件事完全没有概念。加字段、加索引、加约束看起来都是"改个表结构",但它们的锁行为天差地别。这篇把那次之后整理的知识都写下来:PostgreSQL 的锁对照表、哪些操作安全哪些危险、怎么让危险操作变成安全的,以及我们现在的迁移检查清单。
TL;DR:三个必须记住的点——加索引必须
CONCURRENTLY(否则锁写入,且迁移要设atomic = False);加非空字段走三步(加可空 → 分批回填 →ADD CONSTRAINT ... NOT VALID再VALIDATE,PG 12+ 这样只拿弱锁);所有 DDL 前设lock_timeout(防止 DDL 自己排队把全站堵死)。PostgreSQL 11+ 加"带常量默认值"的字段是即时的(只改元数据),但加可变默认值(如now())会重写全表。
目录
- 一、先搞清楚:哪些操作会锁表
- 二、加字段:什么时候快,什么时候要命
- 三、加非空字段的三步法
- 四、加索引:CONCURRENTLY 是硬性要求
- 五、改字段类型与删字段:Expand-Contract
- 六、分批回填:别写一个巨大的 UPDATE
- 七、lock_timeout:最重要的那一行
- 八、迁移在发布流程里的位置
- 九、上线前的检查清单
- 十、坑清单
一、先搞清楚:哪些操作会锁表
PostgreSQL 的锁有等级,了解这两级就够了:
| 锁等级 | 谁会拿 | 阻塞什么 |
|---|---|---|
| ACCESS EXCLUSIVE | ALTER TABLE(大部分)、DROP TABLE、TRUNCATE、非并发的 CREATE INDEX |
读写全阻塞 |
| SHARE UPDATE EXCLUSIVE | CREATE INDEX CONCURRENTLY、VACUUM、ALTER TABLE ... VALIDATE CONSTRAINT |
只阻塞其他 DDL,不阻塞读写 |
判断一个迁移危不危险,就问它拿的是什么锁、拿多久。
常见操作的对照表(PostgreSQL 11+):
| 操作 | 锁等级 | 3000 万行耗时 | 能不能在线做 |
|---|---|---|---|
ADD COLUMN(无默认值) |
ACCESS EXCLUSIVE(瞬时) | 毫秒 | ✅ 安全 |
ADD COLUMN ... DEFAULT <常量> |
ACCESS EXCLUSIVE(瞬时) | 毫秒(11+) | ✅ 安全 |
ADD COLUMN ... DEFAULT now() 等可变值 |
ACCESS EXCLUSIVE | 重写全表,分钟~小时 | ❌ 危险 |
ADD COLUMN ... NOT NULL DEFAULT |
ACCESS EXCLUSIVE | 看情况,可能要扫表 | ⚠️ 用三步法代替 |
DROP COLUMN |
ACCESS EXCLUSIVE(瞬时) | 毫秒(只标记) | ✅ 安全(但要先停引用) |
ALTER COLUMN TYPE(大部分) |
ACCESS EXCLUSIVE | 重写全表 | ❌ 危险 |
ALTER COLUMN TYPE varchar(50→100) |
ACCESS EXCLUSIVE(瞬时) | 毫秒(9.2+ 免重写) | ✅ 安全 |
CREATE INDEX(普通) |
SHARE(阻塞写) | 分钟 | ❌ 危险 |
CREATE INDEX CONCURRENTLY |
SHARE UPDATE EXCLUSIVE | 分钟(不阻塞) | ✅ 安全 |
ADD CONSTRAINT ... CHECK(立即验证) |
ACCESS EXCLUSIVE | 扫全表 | ⚠️ 用 NOT VALID |
ADD CONSTRAINT ... NOT VALID |
ACCESS EXCLUSIVE(瞬时) | 毫秒 | ✅ 安全 |
VALIDATE CONSTRAINT |
SHARE UPDATE EXCLUSIVE | 扫全表(不阻塞读写) | ✅ 安全 |
ALTER TABLE ... SET NOT NULL |
ACCESS EXCLUSIVE | 扫全表 | ⚠️ 用 CHECK NOT VALID 代替 |
这张表我贴在工位上了(字面意义上的)。每次写迁移之前扫一眼,能避开 90% 的生产事故。
那次事故的根因是:Django 的 AddField 带 default=0 生成的是 ALTER TABLE ... ADD COLUMN priority smallint DEFAULT 0,然后 DROP DEFAULT。在 PostgreSQL 11+ 这应该很快才对——但我们的生产库当时跑的是 PostgreSQL 10,那个版本加带默认值的字段会重写整张表。
第一件事:确认你的 PostgreSQL 版本。 这点差异能决定一条迁移是 10 毫秒还是 10 分钟。
二、加字段:什么时候快,什么时候要命
安全的写法
# migration
migrations.AddField(
model_name='downloadtask',
name='priority',
field=models.SmallIntegerField(default=0, verbose_name='优先级'),
)
生成:
ALTER TABLE "downloader_downloadtask" ADD COLUMN "priority" smallint DEFAULT 0 NOT NULL;
ALTER TABLE "downloader_downloadtask" ALTER COLUMN "priority" DROP DEFAULT;
- PostgreSQL 11+:只改元数据,毫秒级完成(无论表多大);
- PostgreSQL 10 及更早:重写全表,3000 万行就是几分钟到几十分钟。
危险的写法
# 默认值是可变函数 —— 必然重写全表
field=models.DateTimeField(default=timezone.now) # Django 里这是 Python 默认值,不是 SQL DEFAULT
field=models.UUIDField(default=uuid.uuid4)
好消息是:Django 的 default=timezone.now 是 Python 层面的默认值,不会生成 SQL DEFAULT,所以现有行会是 NULL(如果字段可空)或者迁移会报错(如果非空)。
真正要小心的是 RunSQL 里手写的:
# 危险:可变默认值,重写全表
migrations.RunSQL('ALTER TABLE t ADD COLUMN created_at timestamptz DEFAULT now() NOT NULL')
如果需要给历史数据填上当前时间,正确做法是:加可空字段 → 分批回填 → 加约束。
安全的替代方案
# 第一步:加可空字段(毫秒级,安全)
migrations.AddField(
model_name='downloadtask',
name='created_at',
field=models.DateTimeField(null=True),
)
# 第二步:数据迁移分批回填(见第六节)
# 第三步:加非空约束(见下一节)
三、加非空字段的三步法
最笨但最稳的流程:
1. 加可空字段 → 毫秒,安全
2. 分批回填历史数据 → 可控,随时停
3. 加非空约束 → 关键:怎么加决定了锁多久
第 3 步在 PostgreSQL 12+ 有个漂亮的技巧:
-- 错误做法(扫全表且全程 ACCESS EXCLUSIVE):
ALTER TABLE t ALTER COLUMN col SET NOT NULL;
-- 正确做法:分两步
-- 3a. 加一个「不立即验证」的检查约束(瞬时,只改元数据)
ALTER TABLE t ADD CONSTRAINT col_not_null CHECK (col IS NOT NULL) NOT VALID;
-- 3b. 验证已有数据(只拿 SHARE UPDATE EXCLUSIVE,不阻塞读写)
ALTER TABLE t VALIDATE CONSTRAINT col_not_null;
PG 12+ 的额外好处:一旦有了这个 CHECK (col IS NOT NULL) 约束,ALTER TABLE ... SET NOT NULL 就能跳过全表扫描(因为它知道约束已经验证过了)。
完整流程(PG 12+):
-- 1. 加可空字段
ALTER TABLE t ADD COLUMN priority smallint;
-- 2. 回填(分批,见第六节)
-- 3. 加 NOT VALID 约束(毫秒)
ALTER TABLE t ADD CONSTRAINT priority_not_null CHECK (priority IS NOT NULL) NOT VALID;
-- 4. 验证(扫全表但不阻塞读写,可以慢慢跑)
ALTER TABLE t VALIDATE CONSTRAINT priority_not_null;
-- 5. 设为 NOT NULL(PG 12+ 此时是瞬时的)
ALTER TABLE t ALTER COLUMN priority SET NOT NULL;
-- 6. 可选:删掉冗余的 CHECK(PG 12+ 里 SET NOT NULL 之后 CHECK 就多余了)
ALTER TABLE t DROP CONSTRAINT priority_not_null;
第 5 步之后,Django 的 models.SmallIntegerField(default=0)(非空)就和数据库一致了。
PG 11 及更早:没有这个优化,SET NOT NULL 必然全表扫描。这时候要么接受一次短暂的锁(在业务低峰做),要么干脆保持字段可空,在应用层保证非空(很多团队的选择)。
四、加索引:CONCURRENTLY 是硬性要求
普通 CREATE INDEX 会拿 SHARE 锁,阻塞这张表的所有写入。 对一张高频写入的表(我们的任务表每秒几十次更新),这意味着:索引建多久,写入就停多久。3000 万行建索引通常要几分钟——足够造成一次 P1 事故。
Django 里怎么并发建索引
Django 的 AddIndex 迁移默认不是并发的。要并发,得手写 RunSQL,并且迁移必须是非原子的:
from django.db import migrations
class Migration(migrations.Migration):
atomic = False # ← 必须!CONCURRENTLY 不能在事务里跑
dependencies = [('downloader', '0012_xxx')]
operations = [
migrations.RunSQL(
sql='CREATE INDEX CONCURRENTLY idx_task_status_created '
'ON downloader_downloadtask (status, created_at DESC)',
reverse_sql='DROP INDEX CONCURRENTLY IF EXISTS idx_task_status_created',
),
]
atomic = False 这一点非常关键。忘了它,Django 会把 migration 包在事务里,CREATE INDEX CONCURRENTLY 会直接报错:
ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block
(我第一次遇到这个报错时还以为是 SQL 写错了。)
CONCURRENTLY 的代价
- 更慢(要扫两遍表 + 等待事务);
- 失败会留下 invalid 索引——它会被后续查询忽略,但会拖慢写入(每次写入都要维护它)。必须清理:
-- 查无效索引
SELECT indexrelid::regclass AS idx, indisvalid
FROM pg_index
WHERE NOT indisvalid;
-- 清理(也要 CONCURRENTLY)
DROP INDEX CONCURRENTLY idx_xxx;
- 要等待所有并发事务结束,如果有个长事务一直不提交,
CREATE INDEX CONCURRENTLY会一直等(这正是前面说的长事务的危害之一)。
更省事的选择
如果你们大量用 PostgreSQL,可以考虑 django-pg-zero-downtime-migrations 这个库,它把 Django 的 migration 后端换掉,自动用并发方式建索引、用安全方式加字段。配置一行:
# settings.py(仅迁移时用,或者常开)
if env('ZERO_DOWNTIME_MIGRATIONS', default=False):
DATABASES['default']['ENGINE'] = 'django_zero_downtime_migrations.backends.postgres'
我最后没上这个库(团队对"迁移行为被隐式改变"有顾虑),但如果是新项目,用它比每次手写 RunSQL 省心。
五、改字段类型与删字段:Expand-Contract
Expand-Contract(也叫 Parallel Change)模式:所有破坏性变更都拆成三个阶段。
阶段 1 Expand(扩展):加新结构,新旧并存
↓ 部署代码:同时写新旧(双写),读旧的
阶段 2 Migrate(迁移):回填历史数据,切读到新结构
↓ 部署代码:只读新的
阶段 3 Contract(收缩):删掉旧结构
改字段类型(smallint → integer)
-- 危险:重写全表,全程 ACCESS EXCLUSIVE
ALTER TABLE t ALTER COLUMN size TYPE bigint;
Expand-Contract 版:
-- 1. 加新列
ALTER TABLE t ADD COLUMN size_new bigint;
-- 2. 双写(代码里同时写 size 和 size_new)+ 回填历史数据(分批)
-- 3. 切换(这一步要短,但仍是 ACCESS EXCLUSIVE;可以在低峰做)
BEGIN;
ALTER TABLE t RENAME COLUMN size TO size_old;
ALTER TABLE t RENAME COLUMN size_new TO size;
COMMIT;
-- 4. 观察几天,确认没问题后
ALTER TABLE t DROP COLUMN size_old;
第 3 步的 rename 是瞬时的(只改元数据),但它需要 ACCESS EXCLUSIVE 锁。如果拿不到锁(有长事务),会排队,排队期间又阻塞后面所有查询——所以这一步前面要设 lock_timeout(第七节)。
删字段
永远不要在同一次发布里"删字段 + 删代码引用"。顺序必须是:
- 发布 A:代码不再引用该字段(但字段还在);
- 观察几天(确认没有隐藏的引用,比如报表脚本、admin 里的
list_display); - 发布 B:
DROP COLUMN(毫秒级,只标记删除)。
Django 有个便利机制:models.SmallIntegerField() 如果代码里删了,migration 会自动生成 RemoveField。这一步很容易在"顺手清理代码"的时候一起发出去——我们的规范是:删字段的 migration 单独一个 PR,单独发布,标题带 [DESTRUCTIVE]。
六、分批回填:别写一个巨大的 UPDATE
回填历史数据是最容易出事的环节。错误写法:
# 危险:一个巨大的事务,锁大量行,产生大量 WAL,可能撑爆磁盘/复制延迟
def fill_priority(apps, schema_editor):
DownloadTask = apps.get_model('downloader', 'DownloadTask')
DownloadTask.objects.filter(priority__isnull=True).update(priority=0)
3000 万行一次性 UPDATE 的问题:
- 长事务:阻塞 vacuum,产生大量 dead tuple(磁盘翻倍);
- 锁升级风险:可能锁住大量行甚至整表;
- 复制延迟:主从延迟飙升;
- 无法中断:跑了一半你不敢 kill(回滚要很久)。
正确写法:分批 + 小事务 + 可中断
import time
from django.db import connection
def batch_fill(model_name, field, value, batch_size=2000, sleep=0.05):
"""分批回填:每批一个小事务,可随时中断并从中断处继续。"""
Model = apps.get_model('downloader', model_name)
total = 0
while True:
# 取出一批 ID(用子查询限制,避免大 OFFSET)
ids = list(
Model.objects.filter(**{f'{field}__isnull': True})
.values_list('id', flat=True)[:batch_size]
)
if not ids:
break
# 只更新这批(小事务)
Model.objects.filter(id__in=ids).update(**{field: value})
total += len(ids)
print(f'filled {total}')
time.sleep(sleep) # 给主库和 vacuum 喘口气
return total
几个要点:
- 每批一个独立事务(默认 autocommit),不长时间持锁;
values_list('id')[:batch_size]而不是切片对象后update()(Django 不支持带切片的 update);- 批间 sleep(50~200ms),降低对主库和主从复制的压力;
- 可中断:中断后重跑会从剩下的继续(因为条件是
isnull=True); - 批大小:2000~10000 之间测,不是越大越好。我实测 2000 和 20000 的总耗时差不多,但 20000 时主从延迟明显。
回填的时间估算:3000 万行、每批 2000、批间 50ms,假设每批 30ms:
30000000 / 2000 = 15000 批 × 80ms = 1200 秒 = 20 分钟
20 分钟是可以接受的(不阻塞业务),比一次性 8 分钟但全程阻塞强得多。
七、lock_timeout:最重要的那一行
这是我那次事故之后加的第一条规定:所有生产 DDL 之前先设 lock_timeout。
SET lock_timeout = '3s';
SET statement_timeout = '300s';
为什么 lock_timeout 这么重要?
DDL 需要 ACCESS EXCLUSIVE 锁。如果此时有任何一个长事务持着这张表的锁(哪怕只是个读),DDL 就会排队等待。而排队期间:
DDL 在等待 ACCESS EXCLUSIVE 锁
↓
后续所有对这张表的查询(包括 SELECT!)都要排在 DDL 后面
↓
整张表的访问全部阻塞
一个排队的 DDL 能把整张表堵死,即使它自己还没开始执行。这就是"为什么加个索引导致全站不可用"的经典原因——不是索引建得慢,是它在等锁的时候把所有人堵住了。
设了 lock_timeout = 3s 之后:DDL 等 3 秒拿不到锁就自己失败退出,不会堵住后面的查询。你可以稍后重试。
ERROR: canceling statement due to lock timeout
这个错误是好事,它保护了整个系统。
在 Django migration 里设置:
from django.db import migrations
SET_LOCK_TIMEOUT = """
SET lock_timeout = '3s';
SET statement_timeout = '600s';
"""
class Migration(migrations.Migration):
atomic = False
operations = [
migrations.RunSQL(SET_LOCK_TIMEOUT), # 先设超时
migrations.RunSQL('CREATE INDEX CONCURRENTLY ...'),
]
或者更彻底:给执行迁移的数据库连接账号设默认参数:
ALTER ROLE deploy_user SET lock_timeout = '3s';
ALTER ROLE deploy_user SET statement_timeout = '600s';
我们选了后者——这样不管谁来跑迁移,超时保护都在,不依赖每个人记得写。
八、迁移在发布流程里的位置
我们现在的发布流程(前面 gunicorn 那篇讲过零停机部署,这里补充迁移的位置):
1. 备份(大改动前,尤其是有数据回填的)
↓
2. 跑「向前兼容」的迁移(加表、加可空字段、CONCURRENTLY 加索引、NOT VALID 约束)
↓
3. 灰度发布代码(滚动切换,新旧并存)
↓
4. 跑数据回填(异步任务,可中断)
↓
5. 跑「验证类」迁移(VALIDATE CONSTRAINT)—— 不阻塞,可以慢慢来
↓
6. 观察 1~7 天
↓
7. 跑「清理类」迁移(删字段、删旧表)
第 2 步和第 7 步之间要隔一段时间(至少一次完整的业务周期,我们是一周)。这是唯一能保证"回滚不会丢数据"的办法——万一新代码有问题要回滚,数据库还是兼容的。
回滚怎么办:
- 向前兼容的迁移(加字段、加表):不需要回滚,留着无害;
- 破坏性迁移(删字段):无法回滚(数据没了)。所以破坏性迁移必须最后做、单独做;
- 数据回填:可以反向清(
UPDATE ... SET NULL),但没必要——如果代码能容忍 NULL 就留着。
九、上线前的检查清单
现在每个含数据库变更的发布,我都会过一遍这个清单:
[ ] 1. 确认生产 PostgreSQL 版本(10 和 11+ 的行为差异很大)
[ ] 2. 这次迁移涉及的表有多少行?(SELECT count(*) 或者用 reltuples 估算)
[ ] 3. 每条 DDL 拿什么锁?对照第一节的表
[ ] 4. 加索引用了 CONCURRENTLY 吗?migration 设 atomic=False 了吗?
[ ] 5. 加非空约束用了 NOT VALID + VALIDATE 吗?
[ ] 6. 有数据回填吗?是分批的吗?估算过多久?
[ ] 7. 有没有设 lock_timeout?
[ ] 8. 有没有破坏性变更(删字段/改类型)?如果有,拆成独立发布了吗?
[ ] 9. 在「和生产同量级数据」的测试库上跑过一遍并计时了吗?
[ ] 10. 大改动前有备份吗?(如果是不可逆操作)
[ ] 11. 回滚方案是什么?(如果新代码有问题,数据库兼容吗?)
[ ] 12. 安排在业务低峰了吗?
第 9 条最容易被跳过("本地跑过就行了"),但它恰恰是最有价值的。我们的测试库灌了生产的脱敏数据,任何迁移先在测试库跑一遍计时——如果那里要跑 5 分钟,生产上一定更久。
十、坑清单
- 不知道生产数据库版本 → PG 10 上加带默认值字段重写全表,卡了 8 分钟。
- 没设
lock_timeout→ DDL 排队等锁,把整张表的读写全堵死(最危险的一条)。 - 加索引没用 CONCURRENTLY → 阻塞写入几分钟。
- 用了 CONCURRENTLY 但 migration 是原子的 → 报错
cannot run inside a transaction block。 - CONCURRENTLY 失败留下 invalid 索引 → 拖慢写入,要
DROP INDEX CONCURRENTLY清理。 SET NOT NULL直接扫全表 → 用CHECK ... NOT VALID+VALIDATE代替(PG 12+)。- 一次性 UPDATE 几千万行 → 长事务、磁盘暴涨、主从延迟、无法中断。分批。
- 分批的批太大 → 主从延迟飙升。2000~5000 比较稳。
- 删字段和删代码引用同一次发布 → 回滚就没数据了。拆成两次。
- 改字段类型直接 ALTER → 重写全表。用 Expand-Contract。
- 没在测试库计时 → 生产上才发现要跑 8 分钟。
- 迁移放在发布流程最后 → 代码已经切了,迁移失败就是全站错误。迁移要先于代码发布。
- 数据迁移里用了
apps.get_model之外的业务代码 → 业务代码会变,历史迁移会失效。迁移里只用apps.get_model。 - 迁移里 import 了会被删除的模块 → 半年后迁移跑不起来。同上,只用
apps.get_model。 - 有长事务在跑时做 DDL → 拿不到锁(有 lock_timeout 的话是好事,没有就堵死所有人)。迁移前先查长事务。
- 主从延迟没考虑 → 回填导致延迟飙升,读从库的业务读到旧数据。回填要限速。
最后说说这次事故之后最大的改变:我们把"数据库变更"当成了一类独立的、需要单独评审的改动。
以前代码 review 的时候,migration 文件经常是被顺带扫一眼的("就是加个字段嘛")。现在我们的 PR 模板里有一个必填项:"本次是否含数据库变更?如果有,涉及行数、预计耗时、锁类型"。填这一项的过程,就是强制过一遍上面那张检查清单。
成本是多花五分钟,收益是再没出现过"迁移导致线上阻塞"。
还有一点体会:数据库迁移是少数"没法靠重试解决"的操作。代码发错了可以回滚重发,但删掉的字段回不来、重写过的表回不去。所以对它的谨慎程度应该比其他改动高一档——宁可多拆几次发布,也不要图省事一次做完全套。