适用场景

生产环境执行 python manage.py migrate 后长时间没有结束,应用发布流水线被阻塞;或者迁移最终报出 Lock wait timeout exceededOperationalError。这类问题常见于 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_userinnodb_trx 则显示事务已经运行 38 分钟。根因不是新增字段耗时,而是 Worker 在 transaction.atomic() 中边分页导出边调用外部接口,迟迟没有提交。

处理顺序应当是:

  1. 暂停该报表任务的继续投递,避免终止后立即重新出现阻塞;
  2. 与业务方确认 7998 对应任务可安全重试;
  3. 先让任务正常完成;若发布窗口无法等待,再只终止确认过的会话;
  4. 等迁移完成后恢复任务,并检查失败重试是否幂等。

确认可以中断时,执行的最小化操作是:

KILL 7998;

随后持续观察迁移会话,而不是重复发起第二个 migrate。被中断事务会回滚,回滚期间仍可能占用资源。

把高风险迁移拆成可发布步骤

线上表很大时,即使没有阻塞,带默认值的字段、索引和非空约束也可能造成明显锁表或 IO 波动。推荐采用 expand/contract 流程:

  1. Expand:先增加可空字段,不写数据库默认值;
  2. 发布兼容新旧字段的应用代码,新写入双写或允许空值;
  3. 用限速任务分批回填历史数据;
  4. 确认回填完成后再添加索引或非空约束;
  5. 稳定观察后删除旧字段与兼容代码。

批量回填不要一次性加载全部 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 迁移卡住时,优先把它当作数据库锁等待事件处理:识别迁移会话、找出确切阻塞者、核实业务影响后再做最小化处置。长期方案是缩短业务事务,并把大表结构变更拆为可兼容、可回填、可回滚的多个发布步骤。这样既能缩短变更窗口,也能避免一次迁移把线上发布变成事故。