适用场景
生产环境执行 python manage.py migrate 后长时间没有结束,应用发布流水线被阻塞;或者迁移最终报出 Lock wait timeout exceeded、OperationalError。这类问题常见于 Django + MySQL/InnoDB:迁移本身很短,却在等待另一条业务 SQL 释放元数据锁(MDL)或行锁。
本文以给用户表新增可空字段为例,说明如何先定位阻塞链路,再决定等待、终止会话或拆分迁移。示例基于 MySQL 8.0,MySQL 5.7 可用 information_schema 中的同名视图替代部分 performance_schema 查询。
现象与风险
典型现象包括:
- CI/CD 日志停在
Applying app.0042_add_profile_source...; - 数据库 CPU、QPS 看起来正常,但迁移进程持续运行;
- 应用仍能读写旧表,新的代码却因字段尚未上线不能部署;
- 强行重试会排队更多 DDL,扩大发布窗口。
不要在不了解阻塞方的情况下直接 KILL。长事务可能正在处理支付、订单或批量任务;盲目杀掉它会造成业务失败和重试风暴。
第一原则:确认迁移实际在等待什么
先在发布主机保留完整输出,并为迁移设置合理的数据库连接超时。例如临时执行:
python manage.py migrate --verbosity 2
同时连接 MySQL,找到迁移会话及其状态:
SHOW FULL PROCESSLIST;
SELECT
p.ID,
p.USER,
p.HOST,
p.DB,
p.COMMAND,
p.TIME,
p.STATE,
p.INFO
FROM information_schema.PROCESSLIST AS p
WHERE p.DB = 'app_prod'
ORDER BY p.TIME DESC;
STATE 出现 Waiting for table metadata lock 表示 DDL 正在等 MDL;INFO 中一般能看到 ALTER TABLE。若 Django 已显示某个 migration 名称,但没有对应 DDL,先检查是否卡在连接、前置数据迁移或自定义 RunPython。
定位阻塞会话
MySQL 8.0 可以用 sys.schema_table_lock_waits 直接查看等待者和阻塞者:
SELECT
object_schema,
object_name,
waiting_pid,
waiting_query,
waiting_query_secs,
blocking_pid,
blocking_account,
blocking_query
FROM sys.schema_table_lock_waits
WHERE object_schema = 'app_prod'
AND object_name = 'accounts_user'\G
关注三个字段:waiting_pid 应是迁移连接;blocking_pid 是需要进一步核实的业务连接;blocking_query 能判断它是长查询、未提交事务,还是空闲但未提交的连接。
再检查阻塞者是否持有长事务:
SELECT
trx.trx_mysql_thread_id AS thread_id,
trx.trx_started,
TIMESTAMPDIFF(SECOND, trx.trx_started, NOW()) AS trx_age_seconds,
trx.trx_state,
trx.trx_query
FROM information_schema.innodb_trx AS trx
ORDER BY trx.trx_started;
如果阻塞线程不在 innodb_trx 中,也不能立刻排除它:一个正在访问表的 SELECT 或显式 LOCK TABLES 同样可能挡住 DDL。此时回到 PROCESSLIST,结合应用实例、任务名称和调用链确认来源。
一次真实的定位流程
假设迁移要执行:
# accounts/migrations/0042_add_profile_source.py
migrations.AddField(
model_name="user",
name="profile_source",
field=models.CharField(max_length=32, null=True, blank=True),
)
排查发现迁移会话 8123 等待 MDL,blocking_pid 为 7998。该连接来自报表 Worker,INFO 显示它在读取 accounts_user;innodb_trx 则显示事务已经运行 38 分钟。根因不是新增字段耗时,而是 Worker 在 transaction.atomic() 中边分页导出边调用外部接口,迟迟没有提交。
处理顺序应当是:
- 暂停该报表任务的继续投递,避免终止后立即重新出现阻塞;
- 与业务方确认 7998 对应任务可安全重试;
- 先让任务正常完成;若发布窗口无法等待,再只终止确认过的会话;
- 等迁移完成后恢复任务,并检查失败重试是否幂等。
确认可以中断时,执行的最小化操作是:
KILL 7998;
随后持续观察迁移会话,而不是重复发起第二个 migrate。被中断事务会回滚,回滚期间仍可能占用资源。
把高风险迁移拆成可发布步骤
线上表很大时,即使没有阻塞,带默认值的字段、索引和非空约束也可能造成明显锁表或 IO 波动。推荐采用 expand/contract 流程:
- Expand:先增加可空字段,不写数据库默认值;
- 发布兼容新旧字段的应用代码,新写入双写或允许空值;
- 用限速任务分批回填历史数据;
- 确认回填完成后再添加索引或非空约束;
- 稳定观察后删除旧字段与兼容代码。
批量回填不要一次性加载全部 ORM 对象。下面的管理命令按主键窗口更新,单批提交,减少长事务:
from django.db import connection, transaction
from accounts.models import User
BATCH_SIZE = 1000
last_id = 0
while True:
ids = list(
User.objects.filter(id__gt=last_id, profile_source__isnull=True)
.order_by("id")
.values_list("id", flat=True)[:BATCH_SIZE]
)
if not ids:
break
with transaction.atomic():
User.objects.filter(id__in=ids, profile_source__isnull=True).update(
profile_source="legacy"
)
last_id = ids[-1]
connection.close_if_unusable_or_obsolete()
关键点是查询始终按递增主键取一小段,update 带上 isnull=True 条件以保证重复执行安全;transaction.atomic() 的范围只覆盖一批更新,不能覆盖整个回填循环。
发布前后检查清单
发布前:
- 使用
python manage.py showmigrations确认目标迁移及依赖顺序; - 在接近生产数据量的环境测试 DDL 时间和锁影响;
- 暂停会产生长事务的报表、批处理,或给它们设定最大执行时间;
- 确保应用代码能够同时兼容“字段尚未存在”和“字段值为空”的阶段。
发布后:
python manage.py showmigrations accounts
python manage.py migrate --plan
第一条确认迁移被标记为已执行;第二条应不再列出待执行项。再观察慢查询、数据库连接数和任务重试量,确认没有因终止阻塞会话引入新的错误。
总结
Django 迁移卡住时,优先把它当作数据库锁等待事件处理:识别迁移会话、找出确切阻塞者、核实业务影响后再做最小化处置。长期方案是缩短业务事务,并把大表结构变更拆为可兼容、可回填、可回滚的多个发布步骤。这样既能缩短变更窗口,也能避免一次迁移把线上发布变成事故。
Discussion
评论