Zulip 数据库 Schema 迁移指南:从 Django 迁移到在线安全的线上部署实践

发布时间:2026/9/12 9:40:53
Zulip 数据库 Schema 迁移指南:从 Django 迁移到在线安全的线上部署实践 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-migrationstools/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两个 tipoperations为空纯粹起汇合作用。安全地添加列共享数据库的部署模型意味着加列需要格外小心production 的旧代码会持续对表执行不包含新列的INSERT和UPDATE。总是设置数据库默认值任何通过AddField引入的**非空non-nullable**新列都要同时设置db_default在 Django 的default之外。没有db_default时PostgreSQL 在旧代码的INSERT省略该列时没有任何兜底这些插入会在 production 中违反NOT NULL约束直接失败直到新代码部署完成。对可空列则无此顾虑——省略插入时列自然取NULL。migrations.AddField( model_namerealmauditlog, namescrubbed, fieldmodels.BooleanField(db_defaultFalse, defaultFalse), ),default是 Django 层的默认值供新代码运行时 ORM 的.create()、save()等使用db_default是 PostgreSQL 层的默认值在部署窗口内保护旧代码。两者都需要。注意这是 Django 5.x 之后才支持的db_default参数Zulip 已全面采用该仓库 Django 版本为 5.x。每行不同默认值可空 → 回填 → NOT NULL有些新列无法用静态默认值——例如指向某个 per-realmNamedUserGroup的外键或由同行的其他列推导出的值。标准做法是拆成三个迁移可空加列AddField(..., nullTrue)。旧代码不受影响它根本不引用该列新旧代码的任何插入都会让该列保持NULL。分批回填既有行用atomic False的RunPython数据迁移执行回填分批模式见让大迁移正常工作。翻转为NOT NULL用AlterField重新声明字段、去掉nullTrueDjango 对非空字段类型默认生成NOT NULL所以很少显式写nullFalse。最近的一个典型例子是三连迁移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(columnNone)这样重跑是安全的。应用代码和全部三个迁移通常放在同一个 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_indexFalse)会发出常规加锁的DROP INDEXseparate-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_sqlALTER TABLE zerver_foo DROP CONSTRAINT bar;, ), migrations.RunSQL( ALTER TABLE zerver_foo VALIDATE CONSTRAINT bar;, reverse_sqlmigrations.RunSQL.noop, ), ], state_operations[ migrations.AddConstraint( model_namefoo, constraintmodels.CheckConstraint(...), ), ], )NOT VALID添加会记录约束并对新行强制执行但不扫描既有表随后VALIDATE CONSTRAINT扫描既有表却不持有ACCESS EXCLUSIVE。两者可以在同一条原子的迁移中完成。外键列用默认方式添加外键列会触发ACCESS EXCLUSIVE锁来验证约束。用两个迁移规避1. 用db_constraintFalse添加列。普通的AddField——Django 只发出纯列 DDL不加外键约束也不需要SeparateDatabaseAndState包裹migrations.AddField( model_namefoo, namebar, fieldmodels.ForeignKey( db_constraintFalse, on_deletemodels.CASCADE, tozerver.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_sqlALTER TABLE zerver_foo DROP CONSTRAINT zerver_foo_bar_id_fk;, ), migrations.RunSQL( ALTER TABLE zerver_foo VALIDATE CONSTRAINT zerver_foo_bar_id_fk;, reverse_sqlmigrations.RunSQL.noop, ), ], state_operations[ migrations.AlterField( model_namefoo, namebar, fieldmodels.ForeignKey( on_deletemodels.CASCADE, tozerver.bar, ), ), ], )编写 RunPython 迁移一个小型数据迁移长这样改编自 zerver/migrations/0795_rename_old_twitter_profile_fields.pyfrom 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( nameTwitter, field_typeEXTERNAL_ACCOUNT, field_data... ).update(nameX username, ...) class Migration(migrations.Migration): dependencies [ (zerver, 0794_alter_directmessagegroup_recipient_and_more), ] operations [ migrations.RunPython( update_twitter_to_x, reverse_codemigrations.RunPython.noop, elidableTrue, ), ]对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_codemigrations.RunPython.noop除非你确实有真正的逆操作。这是项目约定另一种选择Django 在reverse_code未设置时的默认行为是把迁移标记为不可逆这很少是数据迁移的正确语义——其正向效果只是重写或填充行。真正不可逆的罕见 RunPython 迁移应保持reverse_code未设置并显式注释原因。数据迁移设置elidableTrue当squashmigrations折叠旧迁移时效果不再需要的迁移可被安全省略。让数据迁移幂等。迁移可能中途被打断操作员介入、重启、网络抖动然后从头重跑。用尚未迁移的条件过滤查询集fieldNone、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.pyMigrationsTestCase继承自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_size10000, sleep0.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(fProcessing {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 FalseDjango 官方文档 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_indexFalse)会发出加锁的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_indexFalse)数据库侧对底层索引执行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 的内部部署分支中迁移被替换为 stubno-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 migrategit 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-migrationstools/renumber-migrations非空新列同时设db_default与default总是设置数据库默认值每行不同默认值可空加列 → 幂等回填 →NOT NULL翻转0710→0711→0712三连迁移大表加索引AddIndexConcurrentlyatomic False表达式索引补ANALYZEzerver/migrations/0693_add_conditional_indexes_for_topic.py大表唯一约束并发唯一索引 UNIQUE USING INDEX提升zerver/migrations/0794_alter_directmessagegroup_recipient_and_more.py大表 CHECK/外键NOT VALIDVALIDATE配SeparateDatabaseAndState约束章节数据迁移apps.get_model、elidableTrue、reverse_codenoop、幂等zerver/migrations/0795_rename_old_twitter_profile_fields.py大批量更新do_batch_update10000 行/批、0.1s 休眠、动态延伸上界zerver/lib/migrate.py手写批次atomic False 逐批transaction.atomic()id__rangezerver/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),仅供参考

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询