建了两个真实的 app(shop、orders,后者靠外键依赖前者),本机真实跑了一遍 makemigrations/migrate/sqlmigrate/--fake,验证了几个反直觉的真相:makemigrations 从头到尾不会去查一眼真实数据库;非交互模式下把字段改名当成"删一个、加一个",遇到非空字段直接放弃退出;migrate 执行顺序靠的是迁移文件里显式声明的依赖图,不是 app 名字母顺序。
MigrationAutodetector 需要两个 ProjectState 才能工作,这两个状态从哪来是整篇文章的关键。
django/core/management/commands/makemigrations.py · django @ 6.0.7, L215 autodetector = self.autodetector( loader.project_state(), # from_state:把已有的迁移文件按依赖顺序重放一遍,拼出"迁移记录里的状态" ProjectState.from_apps(apps), # to_state:直接读当前 Python 进程里加载的 models.py,不查数据库 questioner, )
django/db/migrations/loader.py · django @ 6.0.7, L402 def project_state(self, nodes=None, at_end=True): """Return a ProjectState object representing the most recent state that the loaded migrations represent.""" return self.graph.make_state(nodes=nodes, at_end=at_end, real_apps=self.unmigrated_apps)
两边都不碰数据库:from_state 是把 migrations/ 目录下所有 .py 文件按依赖顺序在内存里"重放"一遍算出来的,to_state 是直接读当前 models.py 里定义的类。makemigrations 做的事情,自始至终就是拿这两份纯 Python 数据结构做 diff。
既然比的不是数据库,那反过来:数据库被人手动改了,models.py 没跟着改,会怎样?
# 原生 SQL 直接给 shop_product 表加一列,models.py 完全没碰
sqlite3.connect('db.sqlite3').execute(
'ALTER TABLE shop_product ADD COLUMN sku VARCHAR(20)'
)
$ python3 manage.py makemigrations
No changes detected
数据库里 shop_product 表已经真实多出了一列 sku,但 makemigrations 一个字都没提——因为它压根没去看数据库长什么样,to_state 只认 models.py 里写了什么。反过来同样成立:如果有人在 models.py 里加了字段却忘了跑 makemigrations,数据库和代码就会一直安静地不一致下去,直到某次查询真的报错才会被发现。
把 Product.price 改名成 Product.unit_price,分别用 --no-input 和交互确认跑一遍。
# models.py: price 改名为 unit_price(非空字段,无默认值) $ python3 manage.py makemigrations --no-inputField 'unit_price' on model 'product' not migrated: it is impossible to add a non-nullable field without specifying a default.
--no-input 模式下,makemigrations 默认不会把这当成改名——它当成"删掉 price,新增一个 unit_price"。新增字段又非空又没默认值,没法问用户该填什么,直接放弃退出,一个迁移文件都不生成。
django/db/migrations/questioner.py · django @ 6.0.7, L67 def ask_rename(self, model_name, old_name, new_name, field_instance): """Was this field really renamed?""" return self.defaults.get("ask_rename", False) # 基类默认值就是 False class NonInteractiveMigrationQuestioner(MigrationQuestioner): def ask_not_null_addition(self, field_name, model_name): # We can't ask the user, so act like the user aborted. self.log_lack_of_migration(field_name, model_name, "it is impossible to add a non-nullable field without specifying a default") sys.exit(3)
换成交互模式,手动确认"是的,这确实是改名":
$ python3 manage.py makemigrations Was product.price renamed to product.unit_price (a DecimalField)? [y/N] yMigrations for 'shop': shop/migrations/0002_rename_price_product_unit_price.py ~ Rename field price on product to unit_price
sqlmigrate 看一眼真实生成的 SQL——干净的一条 RENAME COLUMN,没有任何数据丢失风险:
$ python3 manage.py sqlmigrate shop 0002ALTER TABLE "shop_product" RENAME COLUMN "price" TO "unit_price";
同一个改名动作,回答方式不同,结果天差地别:非交互模式默默放弃(如果新字段恰好允许为空或有默认值,它甚至会静默生成"删+加"的迁移,真实执行后老数据全部丢失,不会有任何报错提醒你);交互模式确认之后,是一条无损的 RENAME COLUMN。CI 流水线里跑 makemigrations --check --no-input 之所以是个好习惯,一部分原因就是逼着开发者在本地交互式生成迁移、把这类判断做在本地,而不是丢给自动化脚本去猜。
orders.Order 有一个指向 shop.Product 的外键,这条依赖关系会被真实写进迁移文件。
orders/migrations/0001_initial.py(本机真实生成) class Migration(migrations.Migration): initial = True dependencies = [ ('shop', '0001_initial'), # 因为 Order.product 是指向 shop.Product 的外键,自动加上这条依赖 ]
MigrationGraph 把所有迁移文件的 dependencies 拼成一张有向图,migrate 执行前对这张图做一次深度优先的拓扑排序,不看 app 名字母顺序:
django/db/migrations/graph.py · django @ 6.0.7, L221 def iterative_dfs(self, start, forwards=True): """Iterative depth-first search for finding dependencies.""" visited = [] stack = [(start, False)] while stack: node, processed = stack.pop() if node in visited_set: pass elif processed: visited_set.add(node) visited.append(node.key) # 后序:自己的依赖都处理完了,才轮到自己入队 else: stack.append((node, True)) stack += [(n, False) for n in sorted(node.parents if forwards else node.children)] return visited
# shop、orders 两个 app 全新数据库,真实的 migrate 顺序(只看这两个 app) $ python3 manage.py migrate --planshop.0001_initial orders.0001_initial shop.0002_rename_price_product_unit_price
orders 按字母排在 shop 前面,但真实执行顺序里 shop.0001_initial 排第一——因为 orders.0001_initial 的 dependencies 显式指向它,拓扑排序保证一个节点的所有"父节点"(它依赖的迁移)必须先出现。shop.0002(改名)反而排在 orders.0001 后面,因为它俩之间没有依赖关系,谁先谁后只取决于 sorted(node.parents) 这一步的字符串排序,不是什么特殊逻辑。
这决定了 --fake 到底在"骗过"什么——不是所有加字段操作都用同一种 SQL。
django/db/backends/sqlite3/schema.py · django @ 6.0.7, L311 elif ( field.primary_key or field.unique or not field.null # 主键/唯一/非空 都不行 or self.effective_default(field) is not None # 带默认值也不行(ALTER TABLE 不支持之后 DROP DEFAULT) or (field.has_db_default() and not isinstance(field.db_default, Value)) ): self._remake_table(model, create_field=field) # 整表重建:建新表、拷数据、删旧表、改名 else: super().add_field(model, field) # 唯一能走真正 ALTER TABLE ADD COLUMN 的情况
只有"可空、无默认值、非唯一、非主键"这一种字段,SQLite 后端才会真的执行一句 ALTER TABLE ... ADD COLUMN;其余情况(哪怕只是加个带默认值的字段)都会把整张表重建一遍。先用一个带默认值的字段手动预演,证实这条路径下"表已经有这一列"反而不会报错(重建时按字段名对齐,平安无事);再换一个可空无默认值的字段命中真正的快速路径,才等到真实的报错:
# 运维手动给 orders_order 表加了一列(可空、无默认值,命中快速路径)
sqlite3.connect('db.sqlite3').execute(
"ALTER TABLE orders_order ADD COLUMN tracking_no varchar(40) NULL"
)
$ python3 manage.py makemigrations # 生成 0003_order_tracking_no.py
$ python3 manage.py migrate orders
django.db.utils.OperationalError: duplicate column name: tracking_no
不加 --fake,migrate 真的会去执行 sqlmigrate 打印出来的那句 ALTER TABLE ... ADD COLUMN,列已经存在,数据库真实报错。--fake 的作用就是跳过"真的执行 SQL"这一步,只在 django_migrations 表里补一行记录:
$ python3 manage.py migrate orders --fakeApplying orders.0003_order_tracking_no... FAKED
输出里明确写着 FAKED 而不是 OK——数据库表结构一次都没被碰,只有 django_migrations(靠 MigrationRecorder 维护的一张普通表,字段就是 app/name/applied 三列)多了一条记录,让下次 migrate 认为这个迁移"已经处理过了"。
跟上面真实实验完全同构的 5 个迁移:shop.0001 没有依赖,后面 4 个各自依赖前一个相关节点。
| 迁移 | 依赖 | 状态 |
|---|
依赖关系和最终顺序都取自本机真实跑出来的 migrate --plan 结果(shop.0001 → shop.0002 → orders.0001 → orders.0002 → orders.0003),演示动画只是把"一个节点的依赖必须先被处理完,自己才能入队"这个规则逐步可视化,每一步的"已确定顺序"都和真实结果逐条核对过。