提示

返回博客列表

给 3000 万行的表加个字段:不停机数据库迁移的完整清单

那天我们发布了一个"看起来很安全"的改动:给任务表加一个 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())会重写全表。

目录

一、先搞清楚:哪些操作会锁表

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 的代价

  1. 更慢(要扫两遍表 + 等待事务);
  2. 失败会留下 invalid 索引——它会被后续查询忽略,但会拖慢写入(每次写入都要维护它)。必须清理:
-- 查无效索引
SELECT indexrelid::regclass AS idx, indisvalid
FROM pg_index
WHERE NOT indisvalid;

-- 清理(也要 CONCURRENTLY)
DROP INDEX CONCURRENTLY idx_xxx;
  1. 要等待所有并发事务结束,如果有个长事务一直不提交,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(第七节)。

删字段

永远不要在同一次发布里"删字段 + 删代码引用"。顺序必须是:

  1. 发布 A:代码不再引用该字段(但字段还在);
  2. 观察几天(确认没有隐藏的引用,比如报表脚本、admin 里的 list_display);
  3. 发布 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 的问题:

  1. 长事务:阻塞 vacuum,产生大量 dead tuple(磁盘翻倍);
  2. 锁升级风险:可能锁住大量行甚至整表;
  3. 复制延迟:主从延迟飙升;
  4. 无法中断:跑了一半你不敢 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

几个要点:

  1. 每批一个独立事务(默认 autocommit),不长时间持锁;
  2. values_list('id')[:batch_size] 而不是切片对象后 update()(Django 不支持带切片的 update);
  3. 批间 sleep(50~200ms),降低对主库和主从复制的压力;
  4. 可中断:中断后重跑会从剩下的继续(因为条件是 isnull=True);
  5. 批大小: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 分钟,生产上一定更久。

十、坑清单

  1. 不知道生产数据库版本 → PG 10 上加带默认值字段重写全表,卡了 8 分钟。
  2. 没设 lock_timeout → DDL 排队等锁,把整张表的读写全堵死(最危险的一条)。
  3. 加索引没用 CONCURRENTLY → 阻塞写入几分钟。
  4. 用了 CONCURRENTLY 但 migration 是原子的 → 报错 cannot run inside a transaction block。
  5. CONCURRENTLY 失败留下 invalid 索引 → 拖慢写入,要 DROP INDEX CONCURRENTLY 清理。
  6. SET NOT NULL 直接扫全表 → 用 CHECK ... NOT VALID + VALIDATE 代替(PG 12+)。
  7. 一次性 UPDATE 几千万行 → 长事务、磁盘暴涨、主从延迟、无法中断。分批。
  8. 分批的批太大 → 主从延迟飙升。2000~5000 比较稳。
  9. 删字段和删代码引用同一次发布 → 回滚就没数据了。拆成两次。
  10. 改字段类型直接 ALTER → 重写全表。用 Expand-Contract。
  11. 没在测试库计时 → 生产上才发现要跑 8 分钟。
  12. 迁移放在发布流程最后 → 代码已经切了,迁移失败就是全站错误。迁移要先于代码发布。
  13. 数据迁移里用了 apps.get_model 之外的业务代码 → 业务代码会变,历史迁移会失效。迁移里只用 apps.get_model。
  14. 迁移里 import 了会被删除的模块 → 半年后迁移跑不起来。同上,只用 apps.get_model。
  15. 有长事务在跑时做 DDL → 拿不到锁(有 lock_timeout 的话是好事,没有就堵死所有人)。迁移前先查长事务。
  16. 主从延迟没考虑 → 回填导致延迟飙升,读从库的业务读到旧数据。回填要限速。

最后说说这次事故之后最大的改变:我们把"数据库变更"当成了一类独立的、需要单独评审的改动。

以前代码 review 的时候,migration 文件经常是被顺带扫一眼的("就是加个字段嘛")。现在我们的 PR 模板里有一个必填项:"本次是否含数据库变更?如果有,涉及行数、预计耗时、锁类型"。填这一项的过程,就是强制过一遍上面那张检查清单。

成本是多花五分钟,收益是再没出现过"迁移导致线上阻塞"。

还有一点体会:数据库迁移是少数"没法靠重试解决"的操作。代码发错了可以回滚重发,但删掉的字段回不来、重写过的表回不去。所以对它的谨慎程度应该比其他改动高一档——宁可多拆几次发布,也不要图省事一次做完全套。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部