Zulip 数据库 Schema 迁移指南:从 Django 迁移到在线安全的线上部署实践
【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip
本文档翻译并深度解读 Zulip 仓库中 docs/subsystems/schema-migrations.md 的完整内容,并结合仓库源码(迁移文件、工具脚本、测试框架)进行扩充。Zulip 是开源的团队聊天服务,其数据库迁移体系围绕"迁移必须能在上一版本代码仍运行的情况下安全应用"这一核心约束展开,适用于任何在共享数据库上做在线滚动部署(staging 与 production 共库、旧代码与新 schema 短暂共存)的 Django 项目。读完本文,你将掌握 Zulip 迁移的完整规范:如何安全加列、并发建索引、在大表上添加约束、编写 RunPython 数据迁移、用 stub-out /
migrate --fake工作流处理无法在线安全的迁移,以及如何利用MigrationsTestCase为迁移编写自动化测试。
Zulip 使用标准的 Django 迁移系统。本文记录的是适用于 Zulip 的迁移编写约定与约束——这些约束大多源自 Zulip Cloud 的部署方式,理解了部署模型,就能理解下文绝大多数规则为何存在。
Zulip Cloud 是如何部署迁移的
一条迁移必须能够在上一个版本的应用程序代码仍在运行时安全应用,而且上一个版本的代码必须在新 schema 上持续正常工作,直到 production 完成部署。
这条规则由 Zulip Cloud 的运营方式决定,贡献者在写任何迁移之前都应内化以下事实:
- Staging 与 production 共享同一个数据库。只有一个 PostgreSQL 集群。Staging 是另一组应用服务器(只跑内部测试流量,不跑真实客户流量),但它指向的正是 production 使用的那个数据库。
- Staging 先部署。迁移在 staging 部署时、针对共享数据库执行。production 稍后才部署——通常约半小时,偶尔更长——用新代码跑在已经迁移过的数据库上。
- 在那个时间窗口内,production 运行的是上一版本的代码,面对的却是新 schema。旧代码必须全程能对新迁移后的数据库执行读、插入、更新。
- Staging 是在 production 规模的数据上跑迁移。这是真正的安全网:一条太慢、锁持有太久或存在其他运维问题的迁移,会在 staging 部署时、在真实数据库上暴露出来。但因为数据库是共享的,这些问题发生时同时也是真实的 production 事故。正确的做法是在设计阶段就把迁移写对,而不是指望在 staging 上发现问题。
- 部署工具链可以独立决定哪些 commit 进入 staging、哪些进入 production。这给部署操作员提供了多种手段:把有风险的迁移在 production 暂缓一阵、让 schema 变更先于使用它的代码上线、或完全跳过某条迁移。最干净的版本是 revert 某个特定 commit,但实践中迁移并不总是能被干净地拆分出来,因此 Zulip 也使用下文 当迁移无法在线安全时 中描述的 stub-out /
migrate --fake工作流。
这不仅仅是 staging/production 共享数据库的问题。Cloud 的 Django 进程在部署时还会滚动重启,所以即便在单次部署内部,也存在一段"部分进程跑旧代码、部分跑新代码"的窗口——新旧代码必须都能在线上 schema 上正常工作。(自托管部署通常是停服 → 跑迁移 → 再启动,两个约束都不适用;但 Cloud 两个都要继承。)
如果无法满足上述规则,该迁移必须推迟到依赖它的代码进入 production 之后;参见当迁移无法在线安全时。
简单场景
如果迁移只是反映你在zerver/models/*.py中新增的字段:
- 先
git rebase你的分支; - 运行
./manage.py makemigrations; - 如果 Django 自动起的名字不好,重命名生成的文件;
- 运行
tools/provision在本地应用迁移; - commit 时把迁移文件一并加入。
需要自定义 Python 转换已有数据的迁移,参见编写 RunPython 迁移;涉及大表的迁移,参见让大迁移正常工作。
操作迁移图(Migration Graph)
跨分支的编号冲突
如果你在自己的分支上做了 schema 变更,而与此同时主线上也发生了另一次 schema 变更,Django 就会出现两个编号相同的迁移。有两种简单的修复方式:
- 如果迁移是用
manage.py makemigrations自动生成的,推荐直接删除自己的迁移,在 rebase 之后重新运行该命令。如果是多 commit 分支,记得git rebase到修改models/*.py的那个 commit 上做这件事。 - 如果你在准备迁移时写了一些代码,或更喜欢这种工作流,可以运行
./tools/renumber-migrations,它会重编号你的迁移并修正dependencies条目,直接更新工作区(你仍然需要git add结果)。
renumber-migrations脚本(tools/renumber-migrations)的实现在细节上很有讲究:它维护了一个MIGRATIONS_TO_SKIP = {"0209", "0261", "0501", "0001"}集合(这些编号因历史 backport 原因允许多个迁移共存);能自动解析@{u}上游分支并把你分支本地的迁移连续重编号到上游 tip 之后;还提供了--no-rebase选项,让重命名以未提交改动的方式留在工作区,而不是自动 squash 进引入它们的 commit。值得注意的一个细节是:脚本会拒绝重编号包含多个应用内依赖(migration merge)的文件,因为把两个依赖都替换成同一个前驱会产生自引用迁移,这类文件需要手工处理。
./tools/test-migrations(tools/test-migrations)会检查迁移图与当前模型是否一致,并标记出应该重命名的 auto 命名迁移。它作为test-all的一部分运行,是排查编号或图结构问题时首先应该跑的命令。从源码看,它做了两件事:用manage.py showmigrations找出所有NNNN_auto_*名称的新迁移(排除 2016、2017 年及少数白名单内遗留项)并报错;再用manage.py makemigrations --check --dry-run校验迁移与模型的一致性。
Release 分支
当一个 release 分支需要迁移、而main上已经有新迁移时,迁移图必须分叉(fork)。迁移在main上应按常规序列命名和编号,但它的dependency应指向 release 分支上存在的最后一条迁移。随后,在main上需要再接一条迁移来**合并(merge)**分叉产生的两个 tip——可以用manage.py makemigrations --merge生成这种合并迁移。这样构造分叉,迁移才能安全地 cherry-pick 回 stable release 分支。底层机制是 Django 的dependencies/run_before语义(见 Django 官方文档 "Controlling the order of migrations")。仓库中的实际例子是 zerver/migrations/0754_merge_20251014_1855.py:它同时依赖0752_remove_stream_is_in_zephyr_realm和0753_remove_google_blob_emojiset两个 tip,operations为空,纯粹起汇合作用。
安全地添加列
共享数据库的部署模型意味着加列需要格外小心:production 的旧代码会持续对表执行不包含新列的INSERT和UPDATE。
总是设置数据库默认值
任何通过AddField引入的**非空(non-nullable)**新列,都要同时设置db_default=(在 Django 的default=之外)。没有db_default时,PostgreSQL 在旧代码的INSERT省略该列时没有任何兜底,这些插入会在 production 中违反NOT NULL约束直接失败,直到新代码部署完成。(对可空列则无此顾虑——省略插入时列自然取NULL。)
migrations.AddField( model_name="realmauditlog", name="scrubbed", field=models.BooleanField(db_default=False, default=False), ),default=是 Django 层的默认值,供新代码运行时 ORM 的.create()、save()等使用;db_default=是 PostgreSQL 层的默认值,在部署窗口内保护旧代码。
两者都需要。注意这是 Django 5.x 之后才支持的db_default参数,Zulip 已全面采用(该仓库 Django 版本为 5.x)。
每行不同默认值:可空 → 回填 → NOT NULL
有些新列无法用静态默认值——例如指向某个 per-realmNamedUserGroup的外键,或由同行的其他列推导出的值。标准做法是拆成三个迁移:
- 可空加列:
AddField(..., null=True)。旧代码不受影响(它根本不引用该列);新旧代码的任何插入都会让该列保持NULL。 - 分批回填既有行:用
atomic = False的RunPython数据迁移执行回填,分批模式见让大迁移正常工作。 - 翻转为
NOT NULL:用AlterField重新声明字段、去掉null=True(Django 对非空字段类型默认生成NOT NULL,所以很少显式写null=False)。
最近的一个典型例子是三连迁移0710_realm_topics_policy.py→0711_set_default_value_for_realm_topics_policy.py→0712_alter_realm_topics_policy.py(均在 zerver/migrations/ 下)。许多 per-stream、per-realm 的组权限设置也遵循这一模式。Django 官方文档的 "Migrations that add unique fields" 章节也描述了相同的结构模式。
要让这个序列在共享数据库上安全,应用代码有两个不可妥协的性质:
- 填充该列的代码必须在
NOT NULL迁移运行之前进入 production。它必须在所有创建行的代码路径上填充该列。任何遗漏的路径都会让 production 产生NULL行,约束会拒绝该插入,请求以 500 失败。 - 读取该列的代码必须容忍
NULL,直到回填完成。几乎总是意味着在第 3 步落地前干脆不要读该列。
这两个性质属于应用代码变更,而不是迁移本身。贡献者的职责是:把代码写成满足这两个性质、审计每一条相关代码路径、并让回填幂等(filter(column=None)),这样重跑是安全的。
应用代码和全部三个迁移通常放在同一个 PR 里。部署操作员负责在多个部署周期之间安排它们的顺序:决定哪些 commit 进 staging 还是 production、必要时推迟NOT NULL迁移、或使用 stub-out /migrate --fake工作流。务必在 PR 描述中把这个序列明确写出来,让操作员知道需要处理。
索引
添加索引
Django 常规的AddIndex操作(对应 SQL 的CREATE INDEX)在构建期间会持有阻塞写入的锁。在Message、UserMessage、Subscription、RealmAuditLog等大表上,该锁可能持续到足以影响 production 的时长。对这些表上的任何索引,以及任何大到加锁会明显的表上的索引,默认使用AddIndexConcurrently(对应CREATE INDEX CONCURRENTLY)。
AddIndexConcurrently要求迁移类设置atomic = False——CREATE INDEX CONCURRENTLY不能在事务内运行。
- 对普通列索引,
CREATE INDEX会填充索引自身的 size/row 统计信息,规划器也能从既有列统计中估算选择性,无需后续ANALYZE。 - 对表达式索引(例如
Upper(subject)),PostgreSQL 只在ANALYZE运行时才在pg_statistic中收集逐表达式统计,所以要在AddIndexConcurrently之后追加一条RunSQL("ANALYZE zerver_<table>"),让规划器拿到使用新索引所需的数据分布信息。
重命名索引
当索引命名方案变更时,优先用RenameIndex而不是删除重建(参考 zerver/migrations/0693_add_conditional_indexes_for_topic.py,它同时添加了表达式索引;注意它遗漏了这些表达式索引需要的ANALYZE,这是个反面教材)。
删除索引
要CONCURRENTLY删除索引——例如某外键把db_index设为False时自动创建的索引——请使用 SeparateDatabaseAndState。Django 的AlterField(db_index=False)会发出常规(加锁的)DROP INDEX;separate-state 模式让你在保持 Django 模型跟踪同步的同时,用并发方式真正执行删除。
大表上的约束
Django 的AddConstraint会对表加ACCESS EXCLUSIVE锁,并在返回前针对每一行验证约束,这在大表上可能长时间阻塞写入。标准 Django 不暴露 PostgreSQL 用于规避此问题的特性,所以下面的模式都借助RunSQL/RunPython,并用 SeparateDatabaseAndState 保持 Django 模型状态与我们在数据库层的实际操作同步。
UNIQUE约束
先CONCURRENTLY建唯一索引,再通过ALTER TABLE ... ADD CONSTRAINT ... UNIQUE USING INDEX(一次即时元数据操作)把它提升为表约束。规范实现见 zerver/migrations/0794_alter_directmessagegroup_recipient_and_more.py:从源码可以看到完整的防御性流程——
- 用
connection.introspection.get_constraints检查约束是否已存在; - 若不存在,执行
CREATE UNIQUE INDEX CONCURRENTLY,然后立刻ALTER TABLE ... ADD CONSTRAINT ... UNIQUE USING INDEX提升; - 若约束名存在但只是索引(
existing.get("index")为真),说明上一次运行在建完索引后、提升前崩溃了,此时只需补做提升步骤; - 最后再
DROP INDEX CONCURRENTLY IF EXISTS清理该列上遗留的非唯一索引。
状态侧则用 Django 本应生成的 model 变更即可——例如唯一性在外键上就用AlterField改成OneToOneField,非外键列就用AddConstraint(UniqueConstraint(...))。
CHECK约束
先在模型的Meta.constraints中声明约束(否则makemigrations --check比较 state 与 model 时会检测到漂移)。然后写一个用SeparateDatabaseAndState同时完成数据库操作和模型状态变更记录的迁移:
migrations.SeparateDatabaseAndState( database_operations=[ migrations.RunSQL( "ALTER TABLE zerver_foo ADD CONSTRAINT bar CHECK (...) NOT VALID;", reverse_sql="ALTER TABLE zerver_foo DROP CONSTRAINT bar;", ), migrations.RunSQL( "ALTER TABLE zerver_foo VALIDATE CONSTRAINT bar;", reverse_sql=migrations.RunSQL.noop, ), ], state_operations=[ migrations.AddConstraint( model_name="foo", constraint=models.CheckConstraint(...), ), ], )NOT VALID添加会记录约束并对新行强制执行,但不扫描既有表;随后VALIDATE CONSTRAINT扫描既有表却不持有ACCESS EXCLUSIVE。两者可以在同一条(原子的)迁移中完成。
外键列
用默认方式添加外键列会触发ACCESS EXCLUSIVE锁来验证约束。用两个迁移规避:
1. 用db_constraint=False添加列。普通的AddField——Django 只发出纯列 DDL,不加外键约束,也不需要SeparateDatabaseAndState包裹:
migrations.AddField( model_name="foo", name="bar", field=models.ForeignKey( db_constraint=False, on_delete=models.CASCADE, to="zerver.bar", ), )2. 用SeparateDatabaseAndState包裹约束创建。数据库侧NOT VALID添加约束并验证;状态侧把db_constraint翻回默认值(True),使 Django 模型跟踪保持一致:
migrations.SeparateDatabaseAndState( database_operations=[ migrations.RunSQL( "ALTER TABLE zerver_foo ADD CONSTRAINT zerver_foo_bar_id_fk " "FOREIGN KEY (bar_id) REFERENCES zerver_bar(id) NOT VALID;", reverse_sql="ALTER TABLE zerver_foo DROP CONSTRAINT zerver_foo_bar_id_fk;", ), migrations.RunSQL( "ALTER TABLE zerver_foo VALIDATE CONSTRAINT zerver_foo_bar_id_fk;", reverse_sql=migrations.RunSQL.noop, ), ], state_operations=[ migrations.AlterField( model_name="foo", name="bar", field=models.ForeignKey( on_delete=models.CASCADE, to="zerver.bar", ), ), ], )编写 RunPython 迁移
一个小型数据迁移长这样(改编自 zerver/migrations/0795_rename_old_twitter_profile_fields.py):
from django.db import migrations from django.db.backends.base.schema import BaseDatabaseSchemaEditor from django.db.migrations.state import StateApps def update_twitter_to_x( apps: StateApps, schema_editor: BaseDatabaseSchemaEditor ) -> None: CustomProfileField = apps.get_model("zerver", "CustomProfileField") # Copied from zerver/models/custom_profile_fields.py EXTERNAL_ACCOUNT = 7 CustomProfileField.objects.filter( name="Twitter", field_type=EXTERNAL_ACCOUNT, field_data=... ).update(name="X username", ...) class Migration(migrations.Migration): dependencies = [ ("zerver", "0794_alter_directmessagegroup_recipient_and_more"), ] operations = [ migrations.RunPython( update_twitter_to_x, reverse_code=migrations.RunPython.noop, elidable=True, ), ]对CustomProfileField这种小表,整体.filter(...).update(...)没问题;涉及大表的迁移见让大迁移正常工作。
卫生规则(Hygiene Rules)
- 用
apps.get_model("zerver", "Foo")访问模型。Zulip 服务器管理员可能用比迁移新几千个 commit 的代码来跑迁移。如果直接from zerver.models import Foo,迁移看到的会是它被编写时并不存在的模型定义。apps.get_model返回的是迁移图在那个时间点定义的模型。它支持Foo.objects.filter(...)这类 ORM 操作,但不暴露纯 Python 方法或属性,所以不要尝试调用那些。 - 也不要从
zerver导入其他代码。如果需要常量、枚举值或小型 helper,把它复制进迁移文件并注释来源。lint 规则会强制执行这一点。理由与模型相同:等迁移在自托管服务器上运行时,被导入的代码可能已经面目全非。 - 数据迁移设置
reverse_code=migrations.RunPython.noop,除非你确实有真正的逆操作。这是项目约定;另一种选择(Django 在reverse_code未设置时的默认行为)是把迁移标记为不可逆,这很少是数据迁移的正确语义——其正向效果只是重写或填充行。真正不可逆的罕见 RunPython 迁移应保持reverse_code未设置,并显式注释原因。 - 数据迁移设置
elidable=True:当squashmigrations折叠旧迁移时,效果不再需要的迁移可被安全省略。 - 让数据迁移幂等。迁移可能中途被打断(操作员介入、重启、网络抖动)然后从头重跑。用"尚未迁移"的条件过滤查询集(
field=None、subject__iexact="(no topic)"等),让重跑对已处理的行是 no-op。 - 对可能被移除的 settings 保持防御。如果迁移读取某个驱动行为的 Django setting,用
getattr(settings, "FOO", default),这样在未来该 setting 已被删除的代码库上迁移仍能运行(参考 zerver/migrations/0743_realm_require_e2ee_push_notifications.py)。 - 任何裸 SQL 中表名、列名都用
psycopg2.sql.SQL/Identifier,绝不要字符串格式化。迁移日后可能共享 helper 或被改造;以安全方式构造 SQL 可以避免引入注入向量。
自动化测试
Zulip 支持用MigrationsTestCase测试类为数据库迁移编写自动化测试,此系统受一篇关于该主题的知名博客文章启发。它已与 Zulip 测试框架集成:如果使用use_db_models装饰器,就能在测试内部使用test_classes.py中的一些 helper 方法(这在 Django 迁移框架中通常是不可能的)。
从源码看(zerver/lib/test_classes.py),MigrationsTestCase继承自ZulipTransactionTestCase,要求测试类定义migrate_from和migrate_to两个类属性。setUp中通过MigrationExecutor先回滚到migrate_from指定的旧迁移,调用可覆盖的setUpBeforeMigration(apps)钩子(apps是旧迁移时间点的StateApps,可在此构造旧 schema 下的测试数据),再正向执行到migrate_to,之后测试内可通过self.apps访问目标迁移后的模型。use_db_models装饰器(zerver/lib/test_helpers.py)则会用apps.get_model("zerver", ...)得到的"历史"模型批量 patch 掉zerver.models模块中的对应符号,让你在测试里能直接使用这些模型。
基本用法:
class TestMyMigration(MigrationsTestCase): migrate_from = "0795_rename_old_twitter_profile_fields" migrate_to = "0796_my_new_migration" def setUpBeforeMigration(self, apps: StateApps) -> None: # 在旧 schema 下创建数据 ... @use_db_models def test_backfill(self, apps: StateApps) -> None: ...如果你发现自己在RunPython迁移中写了逻辑,强烈建议用这套框架补一个测试。测试日后可能被删除(离当前迁移足够远后它们会变慢),但它能防止灾难性后果——一条错误的迁移把数据库弄乱到只能靠备份恢复的地步。
混合 schema 与数据变更时的 pending trigger events
在一条原子迁移中混用数据变更(RunPython)和ALTER TABLE操作可能产生:
django.db.utils.OperationalError: cannot ALTER TABLE "table_name" because it has pending trigger eventsPostgreSQL 在仍有延迟触发事件(deferred trigger events)挂起时禁止 schema 变更。Django 默认在 PostgreSQL 中把外键约束创建为DEFERRABLE INITIALLY DEFERRED,所以外键约束检查会排队到 commit 时刻。在这些检查仍排队时不能执行ALTER TABLE。
解决方案:把迁移改为非原子、拆成两个迁移文件(推荐)、或用纯 SQL 替换RunPython逻辑。
让大迁移正常工作
对于触及大量行或长时间持锁的迁移,目标是把单次锁时长保持很短、把总墙钟时间控制在有界范围。
能用do_batch_update就用它
对于整表直截了当的批量更新——把某列在每一行上设为某个值或 SQL 表达式——zerver/lib/migrate.py 的do_batch_updatehelper 帮你处理所有迭代:
from zerver.lib.migrate import do_batch_update def update_is_channel_message(apps, schema_editor): Message = apps.get_model("zerver", "Message") with connection.cursor() as cursor: do_batch_update( cursor, Message._meta.db_table, [SQL("is_channel_message = (SELECT type = 2 ...)")], ) class Migration(migrations.Migration): atomic = False operations = [ migrations.RunPython(update_is_channel_message, ...), ]从源码看,do_batch_update(cursor, table, assignments, batch_size=10000, sleep=0.1)的实现要点:
- 先
SELECT MIN(id), MAX(id)计算表的主键范围并打印进度; - 按
id >= lower AND id < upper的区间以默认10000 行一批执行UPDATE ... SET ...; - 每批之间
time.sleep(0.1)短暂休眠; - 每轮结束后若已达到旧上界,会重新查询
MAX(id)动态延伸上界,所以运行期间并发插入的新行不会被漏掉。
优先使用它;只有当 helper 不适用时(per-realm 作用域、多步批次、或非UPDATE操作)才手写循环。完整示例见 zerver/migrations/0691_backfill_message_is_channel_message.py。
把逻辑推给数据库
一个在 Python 里读行、算值、写回的RunPython循环,几乎总是比每个 chunk 一条UPDATE ... FROM (subquery)更慢、锁更重。能表达为 SQL 就表达为 SQL——参考 zerver/migrations/0705_stream_subscriber_count_data_migration.py,它用每个 realm 一条UPDATE ... FROM (subquery)回填流订阅者计数。
用.iterator()迭代
Django 查询集默认把整个结果集缓存进内存。只要迁移要循环一个可能很大的查询集,就用.iterator()逐行流式读取:
for realm in Realm.objects.all().iterator(): ...这适用于任何逐行处理——emoji 文件、审计日志条目、单个 realm——不仅限于遍历大表。
手写批量循环
当do_batch_update不适用时,自己写批量循环。骨架如下:
class Migration(migrations.Migration): atomic = False ... def backfill(apps, schema_editor): while lower_bound <= max_id: print(f"Processing {lower_bound}/{max_id}...") with transaction.atomic(): Stream.objects.filter( id__range=(lower_bound, lower_bound + BATCH_SIZE - 1), ... ).update(...) lower_bound += BATCH_SIZE完整示例见 zerver/migrations/0765_set_default_can_create_topic_group.py。需要注意的几点:
- 迁移类上设置
atomic = False(Django 官方文档 "Non-atomic migrations"),并把每个批次包在独立的transaction.atomic()中。Django 默认的整条迁移事务会让行锁在整个运行期间被持有;逐批提交则能在迭代之间释放锁。 - 按 ID 区间分批,不要用
OFFSET。id__range=(lower, upper)每页开销是 O(批次大小);OFFSET会让后续每页跳过更多行。ID 区间并不稠密(删除产生的空洞、分散的过滤匹配),所以每批实际触及的行数会有波动,有时还很大——这不是问题;目标是限制单个事务持有锁的行数,而不是做等量的分块工作。 - 有
realm_id的表按 realm 逐个迭代。一次处理一个 realm 可以把每个事务的锁足迹限定在该 realm 内,并限制任何单个批次的爆炸半径。参见 zerver/migrations/0696_rename_no_topic_to_empty_string_topic.py 和 zerver/migrations/0782_delete_unused_anonymous_groups.py。 - 几千行是一个合理的批次大小,根据每行成本微调。如果一批要花几秒以上,要么批次太大,要么这活该交给 SQL。
SeparateDatabaseAndState:分离数据库与状态
偶尔 Django 为某个模型变更自动生成的 SQL 并不是你想执行的 SQL——最常见是因为 Django 标准操作没有并发变体(例如AlterField(db_index=False)会发出加锁的DROP INDEX)。SeparateDatabaseAndState让你用一组操作更新 Django 的模型状态跟踪,同时对数据库实际执行另一组操作:
migrations.SeparateDatabaseAndState( database_operations=[migrations.RunPython(...)], state_operations=[migrations.AlterField(...)], )两侧最终在 schema 上达成一致——Django 的视图与数据库匹配——但数据库侧的实际变更以你的自定义 SQL 或RunPython执行,而不是 Django 自动生成的 DDL。近期示例见 zerver/migrations/0791_alter_archivedusermessage_user_profile_and_more.py:状态侧对外键做AlterField(db_index=False),数据库侧对底层索引执行DROP INDEX CONCURRENTLY。
此外,zerver/lib/migrate.py 还提供了rename_indexes_constraints(old_table, new_table)helper,用于表重命名时一并重命名其上的索引、约束与序列(_idx/_check/_fk_/_pk/_uniq后缀由约束内省结果推导,遇到重名索引会直接丢弃重复索引)。注意其注释警告:get_constraints不含手动命名索引与部分索引的信息,使用前需确认目标表不涉及这两类索引。
多步上线:替换一列
这个模式只是因为 Zulip Cloud 是在线迁移才需要——停服-迁移-启动的自托管升级可以在单条迁移里重命名或替换列而无需协调。在 Cloud 上替换大表的一列时,要把过渡拆成多个部署步骤,每一步都相对于上一步部署的代码单独正确:
- 添加新列(带
db_default),在写旧列的同时开始写新列。暂时不要读它。 - 从旧列回填新列。
- 确认两列数据完全一致。
- 把读者切到新列。停止写旧列。
- 删除旧列。
每一步的部署相对于上一步正在运行的代码都是安全的。
当迁移无法在线安全时(stub-out /migrate --fake工作流)
有时某条迁移确实与部署窗口内运行旧代码的数据库不兼容。常见情形:
- 删除或重命名上一版本代码仍引用的列、表或模型。旧代码的查询指向已不存在的 schema 元素,迁移一应用就失败。
- 中间状态无法被旧代码或新代码正确读取的 schema 重塑。例如重构多对多关系(Django 官方文档中换成
through模型的 worked example 是很好的参考),或把一列的数据拆分到两个新列而上一版本仍期望原列是权威来源。
这类迁移不能简单落在main上就发布:在 production 仍跑旧代码时把它们应用到共享数据库会搞挂 production。部署操作员用 stub-out /migrate --fake工作流处理:
- 迁移正常提交到
main,在 dev 和 CI 中正常运行。 - 在 Cloud 的内部部署分支中,迁移被替换为 stub(no-op),直到依赖它的代码进入 production。
- 把新代码部署到 production。
- 运行
manage.py migrate --fake zerver <previous_migration>把 stub 标记为未应用。 - 恢复真实迁移。
- 运行
manage.py migrate zerver <target_migration>真正应用它。 - 运行
manage.py migrate --fake zerver <last_migration>把迁移状态移回 tip。
如果你写了需要这种处理的迁移,务必在 commit 中显式标注,让操作员在审查待部署迁移时知晓。
stub-out 工作流之所以存在,是因为实践中迁移并不总能与依赖它的应用代码拆分干净。如果它们各自独立成 commit——schema 变更、回填、NOT NULL翻转各自独立——操作员只需 revert 或重排单个 commit,而不必就地编辑迁移文件。对预判操作员可能需要推迟其中一部分的多步迁移,把每条迁移放在独立 commit 里是值得的额外纪律。
本地 schema 重建
如果遵循上述流程,tools/provision和tools/test-backend会检测到声明的迁移有任何变更,并自动运行迁移(./manage.py migrate)或按需重建相关数据库。
值得注意的是,manage.py migrate(git grep post_migrate.connect可查细节)和重启tools/run-dev都会刷新 memcached,所以不用担心前一个 schema 的缓存对象。
开发迁移时,你可能在调试新代码时意外弄坏自己的数据库。随时可以从头重建:
- 用
tools/rebuild-test-database重建test-backend及其他自动化测试使用的数据库。 - 用
tools/rebuild-dev-database重建手动测试(见 docs/development/using.md)所用的数据库。
总结:迁移决策速查
| 场景 | 推荐做法 | 仓库参考 |
|---|---|---|
| 只加新字段 | makemigrations+ 重命名 +tools/provision | 简单场景 |
| 编号冲突 | git rebase后重跑,或./tools/renumber-migrations | tools/renumber-migrations |
| 非空新列 | 同时设db_default=与default= | 总是设置数据库默认值 |
| 每行不同默认值 | 可空加列 → 幂等回填 →NOT NULL翻转 | 0710→0711→0712三连迁移 |
| 大表加索引 | AddIndexConcurrently+atomic = False,表达式索引补ANALYZE | zerver/migrations/0693_add_conditional_indexes_for_topic.py |
| 大表唯一约束 | 并发唯一索引 +UNIQUE USING INDEX提升 | zerver/migrations/0794_alter_directmessagegroup_recipient_and_more.py |
| 大表 CHECK/外键 | NOT VALID+VALIDATE,配SeparateDatabaseAndState | 约束章节 |
| 数据迁移 | apps.get_model、elidable=True、reverse_code=noop、幂等 | zerver/migrations/0795_rename_old_twitter_profile_fields.py |
| 大批量更新 | do_batch_update(10000 行/批、0.1s 休眠、动态延伸上界) | zerver/lib/migrate.py |
| 手写批次 | atomic = False+ 逐批transaction.atomic()+id__range | zerver/migrations/0765_set_default_can_create_topic_group.py |
| 无法在线安全 | commit 标注 + stub-out /migrate --fake工作流 | stub-out 工作流 |
Zulip 的迁移体系把"共享数据库上的在线滚动部署"这一约束翻译成了一整套可执行的工程规范:从db_default到CONCURRENTLY,从SeparateDatabaseAndState到MigrationsTestCase,每条规则都能在上面的源码与迁移文件中找到落地证据。对任何自托管或云部署 Django 服务的团队,这套方法论都值得直接借鉴。
【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考