Django迁移机制详解:makemigrations与migrate的原理、实践与避坑

1. 项目概述:两个命令,一条数据演进的生命线

刚接触 Django 的时候,我对着终端敲下 python manage.py makemigrations python manage.py migrate ,心里其实挺没底的——这俩命令到底在干啥?为什么有时候只跑一个就报错,有时候又得连着跑?有次我在本地改完模型字段长度,兴冲冲 makemigrations 生成了文件,结果忘了 migrate ,第二天上线前测试发现数据库里字段还是老样子,页面一提交就报 DataError ,硬是卡在发布前两小时手忙脚乱回滚。这种“知道要跑,但不知道为什么跑、什么时候该停”的状态,恰恰是 Django 迁移机制最常被误解的起点。

简单说, makemigrations 写剧本 migrate 演戏 。剧本(migration 文件)描述了“数据库结构该怎么变”,而演戏(执行 migrate)是把剧本真正落地到数据库里,让表结构、索引、约束这些物理存在跟着剧本走。它们不是可互换的开关,而是严格串行的两个阶段:没有剧本,演戏无从谈起;写了剧本却不演,舞台永远停留在旧布景。这个逻辑背后,是 Django 对“代码即契约”原则的坚守——模型类定义的是你期望的数据形态,迁移文件是这份契约的法律存证,而 migrate 操作则是法院强制执行的过程。它不关心你本地数据库当前长什么样,只认迁移历史记录里的“已执行”状态。所以当你看到 “You have 3 unapplied migrations” 这种提示,Django 其实是在说:“你的代码契约已经签了三份新协议,但数据库还没盖章生效。”

这篇文章面向所有正在用 Django 做真实项目的开发者,无论你是刚写完第一个 models.py 的新人,还是带团队维护十年老项目的架构师。如果你曾因迁移失败导致线上服务中断、因回滚失误丢失测试数据、或在多人协作时被同事的 migration 文件搞懵过,那这篇就是为你写的。它不讲抽象理论,只拆解真实场景下的每一步操作意图、每个参数背后的权衡、每次报错背后的数据状态真相。接下来的内容,全部来自我过去八年在电商中台、SaaS 后台、政务系统等不同规模项目里踩过的坑、记下的日志、和凌晨三点对着 django_migrations 表反复核对的实战经验。

2. 核心设计思路:为什么必须分两步?——从“代码-数据库”一致性保障说起

2.1 本质差异:静态描述 vs 动态执行

makemigrations 的核心任务,是 对比当前模型定义与数据库当前状态,生成一份可版本化、可审查、可回溯的变更描述文件 。它不碰数据库一根手指头,纯粹是个“文本生成器”。它读取 models.py ,再查一遍 django_migrations 表里已执行的迁移记录,最后计算出“从当前数据库快照出发,要达到模型定义的新状态,需要执行哪些 DDL 操作”。这个过程完全离线,甚至可以在没有数据库连接的环境下运行(比如 CI 流水线里预生成迁移文件)。我见过最典型的误用,是有人在部署脚本里直接 makemigrations && migrate 一把梭,结果在生产环境因为权限不足或网络问题卡在 makemigrations 阶段,整个发布流程就挂了——这恰恰暴露了对命令本质的误解: makemigrations 不该出现在生产部署环节,它属于开发阶段的“契约签署”行为。

migrate 则完全不同,它是 数据库的实时操作引擎 。它会读取 django_migrations 表,确认哪些迁移文件尚未标记为“已执行”,然后按顺序逐条执行其中的 operations (比如 AlterField CreateModel ),同时在 django_migrations 表里插入一条新记录,表示“此迁移已完成”。这个过程是原子性的:如果某条 SQL 执行失败(比如给一个已有百万数据的字段加非空约束),整个迁移事务会回滚, django_migrations 表也不会新增记录,确保数据库状态不会停留在“半完成”的危险中间态。这也是为什么 Django 要求数据库必须支持事务(PostgreSQL、MySQL 的 InnoDB 引擎都满足),因为迁移的可靠性,就建立在这层事务保障之上。

提示: makemigrations 生成的 .py 文件,本质是 Python 代码,里面包含 operations 列表和 dependencies 字段。你可以像读普通 Python 文件一样打开它,看到 AlterField(model_name='user', name='email', field=models.EmailField(...)) 这样的语句。它不是 SQL,而是 Django 自己的迁移操作 DSL,由 Django 在 migrate 时翻译成对应数据库的 SQL。这意味着同一个迁移文件,在 PostgreSQL 和 MySQL 上生成的最终 SQL 可能不同,但语义完全一致。

2.2 设计哲学:可预测性优先于便利性

Django 选择分两步,根本上是为了 牺牲一点“快捷”,换取十倍的“可控” 。试想如果只有 migrate 一个命令:每次运行时,它既要扫描模型变化,又要决定是否生成新迁移,还要执行变更——那在团队协作中就会变成灾难。A 同学改了 User.email 字段长度,B 同学同时改了 Order.status 的 choices,两人各自 migrate ,很可能生成两份冲突的迁移文件,或者更糟:其中一人 migrate 时,Django 发现模型有变化,自动创建了一个匿名迁移,而另一个人的代码里根本没有这个文件,导致本地环境和 CI 环境数据库状态彻底不一致。

分两步的设计,把“决策点”明确地放在了开发者的手中:

  • makemigrations 人工决策时刻 :你看到 Migrations for 'users': 的输出,就知道“哦,这次改确实影响了数据库结构,我得检查下生成的文件对不对”;
  • migrate 确定性执行时刻 :你确认迁移文件没问题后,才让它去动数据库,而且执行过程完全可预期——它只会执行 django_migrations 表里标记为“未完成”的那些文件,不多不少。

这个设计还天然支持了“迁移审查”流程。在我们团队,所有 makemigrations 生成的文件都必须经过 Code Review,重点看三点:一是 operations 是否符合预期(比如改字段类型是否用了 RunPython 做数据迁移);二是 dependencies 是否正确(避免跨 App 的循环依赖);三是 initial=True 标记是否合理(针对新 App 的首次迁移)。而 migrate 命令,则被封装进标准的部署脚本,由运维同学统一执行,开发同学无需干预。

2.3 一个反直觉事实: makemigrations 有时什么都不做,才是对的

新手常有个误区:觉得“只要改了 models.py,就必须跑一次 makemigrations ”。其实不然。Django 的迁移检测逻辑,是基于 模型定义的语义等价性 ,而非文件内容的字面差异。举个例子:

# models.py 原来是
class Product(models.Model):
    name = models.CharField(max_length=100)

# 你改成这样(只是加了个 blank=True)
class Product(models.Model):
    name = models.CharField(max_length=100, blank=True)

运行 makemigrations ,输出是 No changes detected 。为什么?因为 blank=True 是 Django 表单层的验证属性,它不改变数据库字段的任何物理特性(类型、长度、是否为空),所以不需要 DDL 操作。同理,修改 verbose_name help_text default (注意: default 如果是函数或 timezone.now() 这种动态值,Django 会要求你提供 migrations.RunPython 来处理历史数据,但单纯改字符串 default 通常也不触发迁移)都不会生成新迁移。

反过来,有些看似“没改”的操作却会触发迁移。比如你把 CharField(max_length=50) 改成 CharField(max_length=100) ,Django 会生成 AlterField ;但如果你改成 TextField() ,它会先 AddField 新字段,再 RemoveField 旧字段(因为类型变更无法直接 ALTER COLUMN TYPE 安全完成)。这些细节,正是 makemigrations 输出里值得你逐行审视的地方——它不是黑盒,而是你理解数据变更影响的第一道防线。

3. 实操要点深度解析:从生成到执行的每一个关键环节

3.1 makemigrations :不只是生成,更是建模意图的精准表达

3.1.1 命名的艺术:为什么 --name 不是可选项,而是必选项?

python manage.py makemigrations --name add_user_profile 这个命令,表面看只是给迁移文件起个好记的名字,实则关乎 协作效率与故障定位速度 。默认生成的 0001_auto_20240515_1430.py ,除了告诉你时间戳,什么信息都不提供。而当你的项目运行三年,App 里有 87 个迁移文件时,光靠数字序号根本无法快速定位“用户资料表是哪次加的”、“邮箱唯一性约束是哪次引入的”。

命名规范我们团队强制执行三条:

  • 动词开头 add_ , remove_ , alter_ , rename_ ,明确操作类型;
  • 对象聚焦 :紧接核心模型或字段名,如 add_user_profile , alter_order_status_choices
  • 避免模糊词 :禁用 fix , update , change 这类无法体现具体变更的词。

有一次线上出现 IntegrityError: duplicate key value violates unique constraint "users_email_key" ,运维同学立刻 git blame 找到最近一次涉及 User.email 的迁移—— 0042_alter_user_email_unique.py 。他打开文件,三秒内确认这是上周为合规要求添加的唯一性约束,于是马上联系法务确认是否允许临时降级,而不是花半小时在 Git 历史里翻找“那次改邮箱的提交”。

注意: --name 参数生成的文件名,前面的数字序号(如 0042_ )是 Django 自动管理的,你无法手动指定。它基于当前 App 下已存在的迁移文件数量递增。所以 0042 并不意味着“第 42 次”,而只是“当前目录下第 42 个迁移文件”。这也是为什么不要手动重命名迁移文件——Django 会根据文件名里的数字序号来确定执行顺序,乱改会导致 MigrationNotFound 错误。

3.1.2 处理复杂变更:当 makemigrations 无法自动推断时

Django 的自动检测能力很强,但遇到以下场景,它会直接报错,要求你手动介入:

  • 字段重命名 makemigrations 默认会认为这是“删除旧字段 + 新增新字段”,导致历史数据丢失。正确做法是使用 --fake-initial 或显式编写 RenameField 操作。
  • 数据迁移 :比如要把 User.is_active 的布尔值,按规则映射成新的 User.status 枚举( 'active' , 'inactive' , 'pending' )。 makemigrations 只能生成 AddField ,但填充数据的逻辑必须由你用 migrations.RunPython 编写。
  • 自定义 SQL :某些数据库特有操作(如 PostgreSQL 的全文检索索引、JSONB 字段的 GIN 索引),Django 无法自动生成,需用 migrations.RunSQL

以数据迁移为例,这是 makemigrations 生成的骨架文件里你需要补全的部分:

# 0043_add_user_status.py
from django.db import migrations

def set_initial_status(apps, schema_editor):
    User = apps.get_model('auth', 'User')
    # 注意:这里不能用 from myapp.models import User,必须用 apps.get_model!
    User.objects.filter(is_active=True).update(status='active')
    User.objects.filter(is_active=False).update(status='inactive')

def reverse_set_initial_status(apps, schema_editor):
    User = apps.get_model('auth', 'User')
    User.objects.filter(status='active').update(is_active=True)
    User.objects.filter(status='inactive').update(is_active=False)

class Migration(migrations.Migration):
    dependencies = [
        ('auth', '0042_alter_user_email_unique'),
    ]

    operations = [
        migrations.AddField(
            model_name='user',
            name='status',
            field=models.CharField(max_length=20, default='pending'),
        ),
        migrations.RunPython(set_initial_status, reverse_set_initial_status),
    ]

关键点在于 apps.get_model —— 这是 Django 迁移系统的“沙箱模式”,它加载的是迁移执行时刻的模型状态,而非当前 models.py 的最新版。如果你在 RunPython 里直接 import 模型,当未来你重构了 User 模型(比如把 status 字段移到了 UserProfile 里),这个旧迁移就会因找不到 User.status 而崩溃。 apps.get_model 则永远指向该迁移文件所依赖的那个历史版本的模型。

3.2 migrate :执行不是终点,而是状态同步的开始

3.2.1 目标迁移:精确控制执行范围的三种方式

migrate 命令最常被低估的能力,是它的 目标定位精度 。默认 python manage.py migrate 会执行所有未应用的迁移,但在真实运维中,你往往需要更精细的控制:

  • 回滚到指定版本 python manage.py migrate auth 0041 。这会反向执行 0042 0043 ...直到 0041 (含),将数据库状态倒退回那个时间点。注意: 0041 必须是已执行过的迁移,且其 reverse_code (反向操作)必须能安全执行。比如 CreateModel 的反向是 DeleteModel ,没问题;但 RunPython 的反向函数如果写得不严谨(比如试图删除已被其他逻辑引用的数据),就可能失败。
  • 只执行某个 App 的迁移 python manage.py migrate users 。当你的项目有 20 个 App,而只想更新用户模块时,这能避免意外触发其他模块的迁移(比如某个监控 App 的迁移里包含耗时的 RunPython 数据清洗)。
  • 模拟执行(Dry Run) python manage.py migrate --plan 。它不会真执行任何 SQL,而是打印出“如果现在运行 migrate,将会按什么顺序执行哪些迁移”。这是我们上线前必做的一步:把它和 Git Diff 对比,确认计划执行的迁移,和本次发布的代码变更完全匹配。曾经有次 --plan 显示要执行 0055_add_audit_log ,但我们代码里根本没有这个文件——后来发现是同事误把本地未提交的迁移文件 push 到了测试分支,及时拦截了这次潜在事故。
3.2.2 处理冲突:当 migrate 报错 “Conflicting migrations” 时

这是多人协作中最让人头皮发麻的错误之一。典型场景:A 同学在 feature-a 分支写了 0044_add_discount_field.py ,B 同学在 feature-b 分支写了 0044_rename_coupon_code.py ,两人同时合并到 main,Git 把两个 0044_*.py 文件都保留了下来。Django 启动时发现同一个序号有两个迁移,直接报错。

解决路径非常明确,分三步:

  1. 识别冲突 python manage.py showmigrations 会清晰列出所有 App 的迁移状态,并用 [ ] 标记未应用、 [X] 标记已应用,冲突的迁移会显示为 (!)
  2. 合并迁移 :运行 python manage.py makemigrations --empty your_app_name ,生成一个空的 0045_merge.py 文件。然后手动编辑它,把 dependencies 设为两个冲突迁移的元组, operations 设为空列表(因为实际变更已在那两个文件里):
    class Migration(migrations.Migration):
        dependencies = [
            ('your_app', '0044_add_discount_field'),
            ('your_app', '0044_rename_coupon_code'),
        ]
        operations = []
    
  3. 执行合并 python manage.py migrate your_app 0045 。Django 会把这个合并迁移标记为“已执行”,后续所有迁移都以此为依赖基点。

提示:合并迁移本身不产生任何数据库变更,它只是一个“协调者”,告诉 Django “这两个 0044 的迁移,我已经都认可了”。所以它的 operations=[] 是完全正确的。切忌在合并迁移里写实际操作——那会破坏迁移的幂等性和可追溯性。

3.3 迁移文件的生命周期管理:从生成、审查到归档

3.3.1 何时该删除迁移文件?——一个危险但必要的操作

官方文档几乎从不提“删除迁移文件”,因为这听起来很危险。但在超大型遗留项目中,这是不得不做的清理工作。我们的判断标准只有一条: 该迁移文件所描述的数据库变更,已经在所有环境(开发、测试、预发、生产)中被永久固化,且其变更逻辑已完全融入当前模型定义,不再有任何历史价值

典型场景是“初始迁移”( 0001_initial.py )。一个运行了八年的项目, 0001_initial.py 里创建的表结构,可能和现在的 models.py 已经天差地别。每次 makemigrations 都要扫描这个大文件,拖慢生成速度;CI 流水线里 migrate 也要多执行一次无意义的“已存在表”的检查。我们做过测试:删除 0001_initial.py 后, makemigrations 生成速度提升 40%, migrate 启动时间减少 15%。

操作步骤极其谨慎:

  1. 确保所有环境数据库都已执行到最新迁移( showmigrations 全是 [X] );
  2. 在本地新建一个干净数据库, migrate 到最新版,确认一切正常;
  3. 手动从 django_migrations 表里删除 0001_initial 的记录 DELETE FROM django_migrations WHERE app='your_app' AND name='0001_initial'; );
  4. 删除 0001_initial.py 文件;
  5. 运行 makemigrations ,Django 会生成一个新的 0001_initial.py ,但内容是当前模型的完整快照;
  6. 把这个新文件加入 Git,但不要立即 migrate ;通知所有团队成员,在下次发布时,先手动在生产库执行 DELETE FROM django_migrations WHERE ... ,再 migrate

这本质上是一次“迁移重置”,风险极高,必须全员知晓并协同操作。我们只在季度技术债清理日执行,且提前一周邮件通告。

3.3.2 迁移文件的 Git 管理铁律
  • 必须提交 :所有 makemigrations 生成的 .py 文件,和 models.py 一样,是代码的一部分,必须随功能代码一起提交。禁止“只提交 models.py,不提交迁移文件”。
  • 禁止修改已提交的迁移 :一旦 0042_*.py 被推送到共享分支,任何人不得修改其内容(包括 operations dependencies )。如果发现错误,必须新建 0043_fix_*.py 来修正,而不是篡改 0042 。因为其他同事的本地数据库可能已经执行了 0042 ,你改了它,他们的 migrate 就会校验失败。
  • 小迁移优于大迁移 :鼓励“每次 PR 只包含一个业务相关的迁移”。比如“用户注册流程优化”这个需求,应该拆成 0045_add_referral_code.py (加字段)、 0046_add_referral_reward.py (加奖励逻辑)两个迁移,而不是一个 0045_user_registration_enhancement.py 包含所有变更。这样回滚、调试、审查都更精准。

4. 实操过程全记录:一次真实的电商订单状态迁移实战

4.1 场景还原:从需求到迁移文件生成

背景是我们正在重构电商订单的状态机。老系统用一个 order_status 字符串字段( 'created' , 'paid' , 'shipped' , 'delivered' ),新需求要求:

  • 状态必须是枚举,杜绝非法值;
  • 增加 canceled refunded 状态;
  • 为每个状态增加“可进入来源状态”的校验逻辑(比如 refunded 只能从 delivered shipped 进入);
  • 历史订单的 order_status 需要映射到新状态( 'created' 'pending_payment' , 'paid' 'confirmed' )。

第一步,修改 models.py

# models.py
from django.db import models

class Order(models.Model):
    # ... 其他字段
    STATUS_CHOICES = [
        ('pending_payment', '待支付'),
        ('confirmed', '已确认'),
        ('shipped', '已发货'),
        ('delivered', '已送达'),
        ('canceled', '已取消'),
        ('refunded', '已退款'),
    ]
    status = models.CharField(
        max_length=20,
        choices=STATUS_CHOICES,
        default='pending_payment'
    )

第二步,运行 makemigrations

$ python manage.py makemigrations orders --name add_status_enum
Migrations for 'orders':
  orders/migrations/0028_add_status_enum.py
    - Alter field status on order

Django 检测到 status 字段的 choices default 发生了变化,生成了一个 AlterField 操作。但它 没有 处理历史数据映射——这是我们必须手动补充的。

4.2 手动增强迁移:编写数据迁移逻辑

打开 0028_add_status_enum.py ,在 operations 列表末尾添加 RunPython

# 0028_add_status_enum.py
from django.db import migrations

def migrate_old_status(apps, schema_editor):
    Order = apps.get_model('orders', 'Order')
    # 映射字典:老状态 -> 新状态
    status_map = {
        'created': 'pending_payment',
        'paid': 'confirmed',
        'shipped': 'shipped',
        'delivered': 'delivered',
        # 新增状态,老数据默认设为 pending_payment
        'canceled': 'canceled',
        'refunded': 'refunded',
    }
    # 批量更新,避免 N+1 查询
    for old, new in status_map.items():
        Order.objects.filter(status=old).update(status=new)
    # 处理未知老状态(如有)
    Order.objects.exclude(status__in=status_map.values()).update(status='pending_payment')

def reverse_migrate_old_status(apps, schema_editor):
    # 反向操作:新状态 -> 老状态,仅用于开发环境回滚
    Order = apps.get_model('orders', 'Order')
    reverse_map = {
        'pending_payment': 'created',
        'confirmed': 'paid',
        'shipped': 'shipped',
        'delivered': 'delivered',
    }
    for new, old in reverse_map.items():
        Order.objects.filter(status=new).update(status=old)

class Migration(migrations.Migration):
    dependencies = [
        ('orders', '0027_alter_orderitem_quantity'),
    ]

    operations = [
        migrations.AlterField(
            model_name='order',
            name='status',
            field=models.CharField(choices=[('pending_payment', '待支付'), ('confirmed', '已确认'), ('shipped', '已发货'), ('delivered', '已送达'), ('canceled', '已取消'), ('refunded', '已退款')], default='pending_payment', max_length=20),
        ),
        migrations.RunPython(migrate_old_status, reverse_migrate_old_status),
    ]

关键细节:

  • migrate_old_status 函数里,我们用 filter().update() 而不是 for order in Order.objects.all(): order.save() ,因为前者是单条 SQL,后者是 N 条,面对百万级订单,性能差百倍;
  • reverse_migrate_old_status 里只映射了部分状态,因为 canceled refunded 在老系统里没有对应值,反向操作时直接忽略,避免 IntegrityError
  • apps.get_model 确保了即使未来 Order 模型被拆分,这个迁移依然能正确执行。

4.3 执行与验证:从本地到生产的全流程

4.3.1 本地开发环境
  1. python manage.py migrate orders 0028 执行迁移;
  2. 运行 python manage.py dbshell ,手动查表确认:
    SELECT DISTINCT status FROM orders_order LIMIT 10;
    -- 应该只返回 ['pending_payment', 'confirmed', 'shipped', 'delivered']
    
  3. 启动 Django shell,测试状态机逻辑:
    >>> from orders.models import Order
    >>> o = Order.objects.first()
    >>> o.status = 'refunded'  # 尝试设非法值
    >>> o.full_clean()  # 触发 Django 模型验证
    ValidationError: {'status': ['"refunded" is not a valid choice.']}
    
4.3.2 测试环境(自动化)

我们的 CI 流水线包含一个专门的迁移测试 Job:

  • 创建一个空的 PostgreSQL 数据库;
  • migrate 0027 (上一个迁移);
  • 插入 1000 条模拟历史订单数据, status 字段设为 'created' , 'paid' 等老值;
  • migrate 0028
  • 运行 SQL 查询,验证 SELECT COUNT(*) FROM orders_order WHERE status NOT IN ('pending_payment', 'confirmed', 'shipped', 'delivered'); 返回 0;
  • 运行 Django 测试用例,覆盖所有状态流转路径。
4.3.3 生产环境(灰度发布)

生产发布是最高危环节,我们采用三阶段策略:

  • 第一阶段(只读检查) python manage.py migrate orders 0028 --plan ,确认计划执行的只有 0028 python manage.py showmigrations orders ,确认 0028 确实是未执行状态。
  • 第二阶段(低峰期执行) :选择凌晨 2 点(订单量最低),执行 python manage.py migrate orders 0028 。全程监控数据库 CPU、慢查询日志。由于 RunPython 是批量更新,我们预估耗时约 3 分钟(基于测试环境数据量推算)。
  • 第三阶段(验证与回滚预案) :迁移完成后,立即运行一个验证脚本:
    # verify_status_migration.py
    from orders.models import Order
    # 检查是否有非法状态
    invalid = Order.objects.exclude(status__in=[s[0] for s in Order.STATUS_CHOICES])
    if invalid.exists():
        print(f"ERROR: Found {invalid.count()} orders with invalid status!")
        # 触发告警,并准备回滚
    else:
        print("SUCCESS: All orders have valid status.")
    
    同时,回滚命令 python manage.py migrate orders 0027 已预先写好并测试通过,随时可执行。

5. 常见问题与排查技巧实录:那些年我们一起填过的坑

5.1 经典报错速查表

报错信息 根本原因 排查步骤 解决方案
No changes detected Django 认为模型与数据库状态一致 1. python manage.py showmigrations 确认当前迁移状态;2. python manage.py dbshell django_migrations 表;3. 检查 models.py 是否真的有影响数据库的变更(如只改了 verbose_name 如果确认需要迁移,可加 --empty 手动生成空迁移,或检查 managed=False 设置
You have X unapplied migrations 代码有新迁移文件,但数据库未执行 1. python manage.py showmigrations 查看哪些未执行;2. git status 确认这些文件是否已提交;3. 检查 settings.py INSTALLED_APPS 是否包含该 App python manage.py migrate 或指定 App 执行
Migration <name> is applied before its dependency <dep> 迁移依赖关系错乱(如 A 依赖 B,但 B 未执行) 1. python manage.py showmigrations --plan 查执行顺序;2. cat <migration_file>.py dependencies 字段;3. 检查 Git 历史,确认依赖迁移是否被误删 手动 migrate 依赖迁移,或修复 dependencies 后生成合并迁移
DatabaseError: column "xxx" does not exist 迁移执行失败,但 django_migrations 表已记录为成功 1. SELECT * FROM django_migrations WHERE name='<failed_migration>'; ;2. python manage.py dbshell 手动检查表结构;3. 查数据库错误日志 危险操作 DELETE FROM django_migrations WHERE ... ,然后 migrate 重试;或 migrate --fake 标记为已执行(仅当确认数据库结构已正确)

5.2 高频陷阱与独家避坑技巧

5.2.1 陷阱一:“假成功”迁移——数据库报错但迁移表已记录

这是最隐蔽的坑。Django 的迁移执行逻辑是:先执行 SQL,再往 django_migrations 表插入记录。但如果 SQL 执行成功(比如 ALTER TABLE ADD COLUMN 成功),而后续的 RunPython 函数抛出异常,Django 会回滚 RunPython 的事务,但 不会回滚前面的 SQL !此时 django_migrations 表里已有一条记录,而数据库里字段已加,但数据未填充。

避坑技巧 :永远把 RunPython 操作放在 AlterField / AddField 等 DDL 操作之后,并在 RunPython 函数里做双重校验:

def safe_data_migration(apps, schema_editor):
    Order = apps.get_model('orders', 'Order')
    # 第一步:确认字段已存在
    if not hasattr(Order, 'new_field'):
        raise RuntimeError("Field 'new_field' not found in Order model!")
    # 第二步:确认数据可安全更新
    if Order.objects.filter(new_field__isnull=True).exists():
        # 执行填充逻辑...
        pass
5.2.2 陷阱二: --fake 的双刃剑

python manage.py migrate --fake 会跳过实际执行,只在 django_migrations 表里插入记录。它适用于两种场景:

  • 初始化遗留数据库 :你接手一个老项目,数据库结构早已存在,但没有迁移历史。此时 --fake-initial 会把 0001_initial.py 标记为已执行,而不尝试创建表(因为表已存在)。
  • 紧急绕过失败迁移 :某个 RunPython 因数据脏污失败,你确认数据库结构已正确,只想让迁移状态同步。

致命风险 --fake 后,Django 会认为该迁移已“成功”,后续所有依赖它的迁移都会正常执行。如果 --fake 的迁移里本该有关键数据填充,而你跳过了,后续逻辑就会出错。

我的经验 --fake 永远是最后手段。在执行前,必须:

  1. git checkout 到该迁移文件生成时的代码版本;
  2. 在本地空库上 migrate 一次,确认它本应做什么;
  3. 手动在生产库执行等效 SQL(如 UPDATE ... );
  4. --fake
5.2.3 陷阱三:时区与 auto_now_add 的幽灵错误

DateTimeField(auto_now_add=True) 字段,在迁移时会被 Django 自动设置为 default=timezone.now 。但 timezone.now 是一个函数对象, makemigrations 无法序列化它,所以会生成 default=django.utils.timezone.now 这样的字符串。问题在于,如果服务器时区和开发机器时区不同, migrate default 的计算时机(迁移文件生成时 vs 迁移执行时)会导致数据不一致。

解决方案 :永远用字符串形式的 default ,并在 models.py 里明确定义:

from django.utils import timezone

class MyModel(models.Model):
    created_at = models.DateTimeField(default=timezone.now)  # ✅ 正确:函数对象
    # created_at = models.DateTimeField(default=timezone.now())  # ❌ 错误:调用结果,是固定时间戳

Django 会智能处理 timezone.now 这种可调用对象,在每次创建实例时调用它,确保时区正确。

5.3 性能优化:当迁移变成发布瓶颈

在数据量超大的表(如日志表、订单表)上执行迁移,是线上发布的心腹大患。 ALTER TABLE 在 MySQL 上会锁表,在 PostgreSQL 上虽支持并发,但 ADD COLUMN 仍需短时锁。我们的优化策略是分层应对:

  • 预防层 :在 models.py 设计阶段就规避高危操作。比如,绝不给百万级表的字段加 NOT NULL 约束(除非有默认值);用 CharField(max_length=255) 代替 TextField 存储短文本,因为 TextField 在某些数据库上索引效率更低。
  • 工具层 :对 PostgreSQL,我们封装了 pg_repack 命令到部署脚本,对 MySQL,使用 pt-online-schema-change (Percona Toolkit)。这些工具能在不锁表的情况下完成 DDL。
  • 迁移层 :对于必须的 RunPython 数据迁移,拆分成小批次:
    def batched_data_migration(apps, schema_editor):
        Order = apps.get_model('orders', 'Order')
        batch_size = 1000
        offset = 0
        while True:
    
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值