news 2026/9/12 10:06:06

PostHog 数据库 Schema 变更安全指南:迁移设计、锁风险与规模化实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostHog 数据库 Schema 变更安全指南:迁移设计、锁风险与规模化实践

PostHog 数据库 Schema 变更安全指南:迁移设计、锁风险与规模化实践

【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog

PostHog 的后端以 Django ORM 驱动 PostgreSQL,同时用 ClickHouse 支撑事件类分析负载,其数据库 Schema 与应用功能一样持续演进。然而每一次 Schema 变更都伴随风险:一次设计糟糕的迁移可能锁住大表、拖垮热表上的所有请求,或在滚动发布期间让新旧代码并行运行时出现"列不存在"级别的线上事故。本文以 PostHog 内部工程手册《Making schema changes safely》为骨架,结合仓库中posthog/migration_helpers的迁移辅助工具、posthog/management/migration_analysis的迁移风险分析器以及 ClickHouse 事件表迁移实践,系统梳理在 PostHog 这样的零停机滚动发布环境中安全做 Schema 变更的完整方法论。

读完本文,你将掌握:变更前需要权衡的核心问题、为什么绝不能随意删除/重命名 Django 模型与字段、如何在不锁表的情况下给大表加索引与约束、如何分阶段下线列与表,以及 ClickHouse Schema 变更相比 Postgres 的特殊复杂度。

变更前的通用考量:先问四个问题

PostHog 的手册在动手前给出四组自检问题,它们构成了任何 Schema 变更的第一道防线:

  1. 真的需要这个 Schema 变更吗?很多需求用应用层代码变更就能解决,完全没有必要动数据库。数据库变更成本与风险远超代码变更,能省则省。
  2. 变更是否向后兼容?在 PostHog Cloud 与自托管部署中,新旧两版代码必然并行运行(滚动发布),破坏性变更会直接引发故障。
  3. 能否将 Schema 变更与代码变更分开部署?对非平凡的变更,应先单独部署 Schema 变更(保证可回滚、向后兼容),再部署依赖它的代码。
  4. 是否在做阻塞式迁移?任何会锁住大表的迁移都极易造成线上故障。

总原则是:凡是有点棘手的变更,都要先想清楚它在生产环境中的操作方式——锁的持有时间、锁等待队列的后果、bin/migrate重试时的行为,这些细节决定了迁移是否会从"几分钟的 DDL"演变成"数小时的服务不可用"。

绝不删除或重命名 Django 模型与字段

手册中反复强调的一条铁律:删除和重命名表、列——即使是完全无人使用的——被强烈反对。原因在于 Django ORM 生成的SELECT查询总是显式列出要查询的表和列

SELECT "posthog_mymodel"."id", "posthog_mymodel"."name", "posthog_mymodel"."myfield" FROM ...

当一次迁移移走了表/列,而新服务器尚未完全部署完成时,仍存活在线上、运行旧代码的服务器会继续SELECT那个已经不存在的资源,唯一的回执就是数据库错误。这会带来一段虽短暂但影响巨大的用户痛苦期。

规避方式不是"干脆不做清理",而是换一套策略:

  • 名字不再合适:数据库里保持原名不动,Python/JS 代码中的命名随意改,但禁止把改名反映到数据库
  • 字段不再需要:在代码中用# DEPRECATED注释清晰标记;
  • 让字段不再被查询:通过在 Manager 对象的get_queryset中覆盖逻辑实现(手册引用的示例是 PostHog 的 PR #13512)。

这套"代码退役、列留库中"的思路,正是后面deprecate_field()untrack_field()两个辅助工具的出发点。

设计时考虑规模:本地、自托管与云环境一视同仁

迁移必须能在本地开发、自托管实例与 PostHog Cloud 三处顺畅运行。避免在事件(events)、用户(persons)、用户 distinct ID(person distinct IDs)、日志(logs)等大表上逐行处理数据的迁移——它们要么耗时过长,要么直接锁住整张表。

这一点在仓库的 ClickHouse 事件表迁移文档(clickhouse-event-table-migrations.md)中有充分佐证:2022 年初 PostHog 曾因重构 events 表 Schema(issue #5684)在 Cloud 上做了持续数月、多次返工的迁移。那次实践总结出的四条目标同样适用于任何大规模数据迁移:数据必须正确(无重复、无丢失)、必须及时完成、对摄入延迟影响最小、必须保留物化列。

热表(Hot Table)上的 ALTER:真正的风险是锁等待队列

posthog_teamposthog_userposthog_organizationposthog_project这四张表几乎在每个请求上都会被读取。任何ALTER TABLE——包括手册中那些"安全模式"(如加一个可空列)——都需要ACCESS EXCLUSIVE锁。危险的并不是 ALTER 本身(可空的ADD COLUMN是纯元数据操作,拿到锁后毫秒级完成),而是锁等待队列:当 ALTER 在在途查询后面排队时,Postgres 会把之后到达的、针对该表的每一个查询都排在它后面。在热表上这意味着全站范围的请求堆积与 5xx,直到lock_timeout取消 ALTER;而bin/migrate会用指数退避重试,于是这种停滞会一轮轮重复,直到 ALTER 最终赢得锁竞争。

这不是理论推演:一个普普通通的可空AddField(正是"安全模式"推荐的写法)曾在生产中因反复输掉锁竞争,引发约一小时的周期性 5xx 波动。

安全方案:尽量别动热表

对于Team,绝大多数新字段本就不该落在表上。领域特定字段应该放到Team 扩展模型上(参见 posthog/models/team/README.md)——那是一次CREATE TABLE,完全不会对posthog_team加锁。CREATE INDEX CONCURRENTLY(通过SafeAddIndexConcurrently)也是安全的:它只取SHARE UPDATE EXCLUSIVE锁,不阻塞读写。

确实要动热表:显式承担风险

仓库中的迁移风险分析器HotTableAlterPolicy(policies.py)会在 CI 中拦截针对这些表的任何 DDL。若确实要接受风险:

  1. 确认该字段真的是核心字段(团队身份、跨产品设置、SDK 配置),而不是扩展模型的候选;
  2. <app_label>.<migration_name>追加到 hot_table_acknowledged_migrations.txt——这是明确的"我接受风险"动作,在评审中可见;
  3. 与 #team-infrastructure 协调,在低流量窗口部署。

指向热表的外键:引用侧同样会锁父表

危险还会从引用侧引爆:任何产品 app 中一个普通的CreateModelAddField,只要带ForeignKey(to="posthog.team", ...)(或解析为posthog_usersettings.AUTH_USER_MODEL),创建外键约束时就会对被引用的父表SHARE ROW EXCLUSIVE锁——即使子表是全新创建的。该锁与父表上每个INSERT/UPDATE/DELETE持有的ROW EXCLUSIVE锁冲突,写流量下锁请求就会排在在途写入之后,lock_timeout将其取消,每次bin/migrate重试又重复一次停滞。HotTableAlterPolicy会标记 FK 目标解析为热表的CreateModel/AddField(跳过声明了db_constraint=False的 FK)。

两种解法:

方案 A——db_constraint=False(唯一真正无锁的路径):不发射 FK 约束,对父表零锁,参照完整性仅靠应用代码保证:

# CreateModel / AddField 不发射任何 FK 约束,对父表不加锁 team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE, db_constraint=False)

方案 B——两阶段补真实数据库约束:先用db_constraint=False完成CreateModel/AddField(不加锁),随后在后续迁移中用AddForeignKeyNotValid补约束、再用ValidateForeignKey校验:

# 00xx_add_fk_not_valid.py(对父表有短暂 SHARE ROW EXCLUSIVE 锁,见下方说明) from posthog.migration_helpers import AddForeignKeyNotValid operations = [ AddForeignKeyNotValid( model_name="mymodel", name="mymodel_team_id_fk", column="team_id", to_table="posthog_team", to_column="id", ), ] # 00yy_validate_fk.py(对子表取 SHARE UPDATE EXCLUSIVE,不锁父表) from posthog.migration_helpers import ValidateForeignKey operations = [ ValidateForeignKey(model_name="mymodel", name="mymodel_team_id_fk"), ]

需要诚实看待锁:ADD CONSTRAINT ... NOT VALID为目录元数据添加仍会对父表取短暂SHARE ROW EXCLUSIVE锁;它跳过了行校验扫描,把锁窗口压缩到仅元数据操作,但并没有消除父表锁。真正无锁的只有方案 A。后续的VALIDATE CONSTRAINTSHARE UPDATE EXCLUSIVE下扫描子表行,不锁父表。若 FK 必须在添加时锁热表,就按热表 DDL 的同样流程走(追加到hot_table_acknowledged_migrations.txt并协调部署)。

删除表与列:分阶段退役而非一次 DROP

为什么直接删除危险

DeleteModel会立即删表,RemoveField会立即删列。它们破坏部署期间的向后兼容,且无法回滚——数据一旦删除,任何回滚部署都会因表/列不存在而失败;数据丢失是永久性的。部署风险分析器将裸RemoveField评为 5 分(最高风险)。

安全删表:三步走

第 1 步:一个 PR 内移除模型与所有引用。删除所有引用该模型的代码(导入、API 端点、视图、序列化器、查询/写入的业务逻辑、Celery/插件等后台任务、cron 任务),从models.py删除模型类,运行makemigrations生成DeleteModel,再把它包进SeparateDatabaseAndState,只改 Django 状态、不动数据库:

from posthog.migration_helpers import DropForeignKey class Migration(migrations.Migration): dependencies = [] operations = [ migrations.SeparateDatabaseAndState( state_operations=[ migrations.DeleteModel(name='OldFeature'), ], database_operations=[ # 当表有指向热父表(Team、User、Organization、Project)的 FK 时必须做。 # Django 无法级联到它看不见的表,子行会活过父表删除; # 约束是 DEFERRABLE INITIALLY DEFERRED,父表删除完成级联后会在 COMMIT 时报错。 DropForeignKey("posthog_oldfeature", to_table="posthog_team"), ], ), ]

这里必须附带说明:如果跳过这个约束删除,父表删除将永远失败。一旦模型离开 Django 状态,Team.objects.delete()的级联不再触达该表,其行会持续引用 team;因为约束是延迟的,Postgres 在整个级联跑完后于COMMIT时抛错,删除永远无法成功。对该状态负有责任的表会让团队删除、组织删除在所有含该表行的租户上失效,直到有人手动删除该表。可以用python manage.py audit_orphan_hot_table_fks针对长期运行的数据库列出已处于此状态的表。测试基础设施方面,若表有指向频繁被 TRUNCATE 的表(UserTeamOrganization)的 FK,会出现cannot truncate a table referenced in a foreign key constraint的测试失败——TransactionTestCaseTRUNCATE清理,而模型已从 Django 状态移除,Django 不知道要把它纳入截断清单;在database_operations中删除 FK 约束可一并解决(且本就是必需的)。

第 2 步:等待安全窗口。至少等一个完整的部署周期,确保没有回滚或 hotfix 会重新引入依赖该表的代码,也确保所有应用服务器、worker 与后台任务完成滚动。

第 3 步:(可选)删除表。未使用的表可以暂时留着,但长期会弄乱 Schema 自省并拖慢迁移。删除前确认没有其他模型通过外键引用它(Django 不会自动级联)。DROP TABLE会对其自身外键引用的每张表ACCESS EXCLUSIVE锁,所以绝不要在会触及热父表的 DROP 上SET lock_timeout = 0:要点是快速失败、让bin/migrate重试,而不是排队等锁。迁移默认在MIGRATE_LOCK_TIMEOUT(posthog/settings/data_stores.py 中默认 20 秒)下运行,这对触及热父表的 DROP 仍意味着排 20 秒的ACCESS EXCLUSIVE请求,因此该场景应设置更短的SET LOCAL lock_timeout。用RunSQLDROP TABLE IF EXISTS实现显式控制与幂等:

class Migration(migrations.Migration): dependencies = [] operations = [ migrations.RunSQL( sql="DROP TABLE IF EXISTS posthog_oldfeature", reverse_sql=migrations.RunSQL.noop, ), ]

反面示例:直接用裸DeleteModel(非SeparateDatabaseAndState)是危险写法,禁止使用。

在 PR 描述中引用模型移除 PR(如"Model removed in #12345, deployed X days ago"),让评审者能核实安全窗口。

安全删列:先让字段离开 ORM

推荐做法是不删列:把字段从 ORM 里移除就停手。一个未使用的列几乎零成本,数据保留,也无需部署协调。但"删掉字段再跑 makemigrations"并不是达成此目的的方式——Django 会在它写的每条SELECT/INSERT中指名每个具体字段,于是makemigrations会生成裸RemoveField,在同一部署中既停止代码引用该列又删除该列。加注释标记弃用同样没用,因为字段仍在模型上、ORM 仍会指名它。

仓库提供了两种真正把字段移出 ORM 的受支持方式(均在 posthog/migration_helpers 下):

deprecate_field()——字段留在模型上,不写任何迁移:

from posthog.migration_helpers import deprecate_field class MyModel(models.Model): myfield = deprecate_field(models.BooleanField(null=True, blank=True))

读写会记录DeprecationWarning,该列从所有查询中消失;在makemigrations/migrate命令下真实字段会回来,保证 Django 的迁移状态与 Schema 保持不变。传raise_on_access=True可把警告升级为错误,用于证明已无调用方。字段必须已经是null=True(隐藏后没有任何东西再写该列,NOT NULL列会拒绝后续所有插入),辅助函数会拒绝不符合要求的字段。注意:deprecate_field()对关系字段不可用——它不写迁移,外键约束就没有着落,隐藏列恰好会制造上述孤儿引用问题。

untrack_field()——现在就把字段从模型状态移除,放在一个纯状态迁移里:

from posthog.migration_helpers import untrack_field operations = [untrack_field("mymodel", "myfield")]

从模型删除字段、跑makemigrations,再把生成的RemoveField替换为它。列保留在 Postgres 中。这里同样要求字段已null=True(辅助函数只收字段名、无法代查,需自行确认;外键列通常NOT NULL,untrack 之前要检查)。若列带有外键,必须在同一迁移中删除约束——用DropForeignKey,它会从pg_constraint中读取约束名,无需硬编码:

from posthog.migration_helpers import DropForeignKey, untrack_field operations = [ untrack_field( "mymodel", "owner", database_operations=[DropForeignKey("posthog_mymodel", column="owner_id")], ), ]

DropForeignKey的实现(drop_foreign_key.py)通过pg_constraint联查pg_class/pg_attribute解析真实约束名,这是其幂等性的来源。绝不要手写ALTER TABLE ... DROP CONSTRAINT IF EXISTS <name>:Django 以外键哈希后缀命名约束,IF EXISTS会把错误的猜测变成"迁移成功但什么都没删"。

如果必须最终删列:在上述任一方式部署至少一个完整部署周期后,用RunSQL删除:

operations = [ migrations.RunSQL( sql='ALTER TABLE "posthog_mymodel" DROP COLUMN IF EXISTS "myfield";', reverse_sql='ALTER TABLE "posthog_mymodel" ADD COLUMN IF NOT EXISTS "myfield" varchar(32) NULL;', ), ]

已经部署的那个版本才是删除的安全保证:没有任何运行中的 Pod 再指名该列,滚动期间不会失败。reverse_sql只能重新加回空列,无法恢复数据;删列不可逆。删除前还要检查 Django 之外的读取方——nodejs/rust/、Temporal workers、Metabase 查询都不走 ORM,把字段藏起来对它们没有任何告知作用。

删除整个产品/app 的正确顺序

移除一个产品(products/<name>/)是对每个模型执行上述分阶段删表,外加 app 收尾,不是删个文件夹。正确顺序:先清除所有用法与模型类、用分阶段方式删表(状态级DeleteModel→ 等一个部署周期 →DROP TABLE),并保持 app 在INSTALLED_APPS中让迁移继续跑;等删表迁移到处部署完毕后,再把 app 移出INSTALLED_APPS并删除products/<name>/文件夹;可选地执行DELETE FROM django_migrations WHERE app = '<app_label>'清理孤儿行。

切勿通过删除迁移文件来"移除"表。repo-checksCI 作业中的迁移删除检查(hogli lint:migration-deletions)会拦截任何删除 master 上存在迁移的行为;确有意图且经过评审的删除(产品迁移、回滚、squash)需在.github/scripts/migration-deletion-allowlist.txt中声明。删文件什么都不删:表与 FK 约束仍留在所有已跑过该迁移的数据库里,django_migrations行成为永不清理的孤儿,新数据库不会建表导致生产与 CI 分叉,迁移风险分析器还会把仍在途分支中的该文件当作全新迁移重新分析。

加列:三阶段部署避免整表重写

给大表加一个无默认值的NOT NULL列(或带uuid4()now()等易变默认值),Postgres 必须重写整张表来填充值,会锁表、耗时可能以分钟到小时计、超时、并阻塞所有写入。安全做法是三阶段部署

第 1 步:以可空列添加:

class Migration(migrations.Migration): operations = [ migrations.AddField( model_name='mymodel', name='new_field', field=models.CharField(max_length=100, null=True), # 允许 NULL ), ]

第 2 步:回填数据。中小表、静态值用简单 UPDATE;大表用分批回填避免长时间锁:

from django.db import migrations def backfill_in_batches(apps, schema_editor): MyModel = apps.get_model('myapp', 'MyModel') batch_size = 10000 while True: ids = list( MyModel.objects.filter(new_field__isnull=True) .values_list('id', flat=True)[:batch_size] ) if not ids: break MyModel.objects.filter(id__in=ids).update(new_field='default_value') class Migration(migrations.Migration): operations = [ migrations.RunPython(backfill_in_batches), ]

第 3 步:加 NOT NULL 约束:

class Migration(migrations.Migration): operations = [ migrations.AlterField( model_name='mymodel', name='new_field', field=models.CharField(max_length=100, null=False), # 现在 NOT NULL ), ]

或对更多控制权使用RunSQLALTER TABLE mymodel ALTER COLUMN new_field SET NOT NULL(反向DROP NOT NULL)。

加索引:CONCURRENTLY 的正确打开方式

普通CREATE INDEX在索引创建的整个期间持有排他锁,大表上可能阻塞所有写入数小时。但直接用 Django 的AddIndexConcurrently/RemoveIndexConcurrently也不够:它们不阻塞,但不幂等——Django 发射的是裸CREATE/DROP INDEX CONCURRENTLY,没有IF [NOT] EXISTS,也没有钩子去禁用lock_timeout/statement_timeout。部署在lock_timeout下跑迁移,bin/migrate失败时以指数退避重跑整个迁移;而CREATE INDEX CONCURRENTLY非事务性、以atomic = False运行,一旦构建被取消(一次瞬时的lock_timeout就够了;OOM、部署超时、statement_timeout、SIGTERM、PG 重启同理),Postgres 会留下一个invalid 索引且无处回滚。下次重试重发同样的裸语句,报relation "..." already exists,迁移卡死、阻塞所有部署,直到有人手工删除或REINDEX这个 invalid 索引。仅加IF NOT EXISTS也不够——Postgres 的IF NOT EXISTS是名称级而非状态级,同名即跳过,不管索引是否有效(indisvalid = false),会让迁移"成功"而索引形同虚设。ConcurrentIndexIdempotencyPolicy(policies.py)会在迁移风险分析器中拦截任何不带IF [NOT] EXISTS的并发索引操作。

推荐:SafeAddIndexConcurrently辅助函数

它接收model_name+ DjangoIndex(与 Django 自带操作相同),自己跟踪状态——无需SeparateDatabaseAndState、无需把索引改写成裸 SQL——并补上裸 Django 操作缺失的安全:禁用lock_timeout/statement_timeout、已存在的有效索引直接跳过、对上次中断构建留下的indisvalid = false残留索引自动重建。

from django.db import migrations, models from posthog.migration_helpers import SafeAddIndexConcurrently class Migration(migrations.Migration): atomic = False # CONCURRENTLY 必需 operations = [ SafeAddIndexConcurrently( model_name="mymodel", index=models.Index(fields=["field_name"], name="mymodel_field_idx"), ), ]

从实现(concurrent_index.py)可见其完整逻辑:database_forwards先确认不在事务内、禁用超时、用pg_class/pg_indexindisvalid;有效则直接返回(重试即 no-op),无效则先 DROP 再add_index(..., concurrently=True)重建,并输出日志面包屑让自动恢复可见。删除索引用镜像的SafeRemoveIndexConcurrentlymodel_name+ 索引名)。

裸 SQL 变体:CreateIndexConcurrently/DropIndexConcurrently

索引无法干净映射到 DjangoIndex(如 ORM 无法建模的表达式)时使用。它们继承RunSQL、不碰 Django 状态,必须包在SeparateDatabaseAndState中并配对的AddIndex/RemoveIndex

from django.db import migrations, models from posthog.migration_helpers import CreateIndexConcurrently class Migration(migrations.Migration): atomic = False # CONCURRENTLY 必需 operations = [ migrations.SeparateDatabaseAndState( state_operations=[ migrations.AddIndex( model_name="mymodel", index=models.Index(fields=["field_name"], name="mymodel_field_idx"), ), ], database_operations=[ CreateIndexConcurrently( index_name="mymodel_field_idx", table_name="mymodel", columns="(field_name)", ), ], ), ]

该变体还支持uniqueusing(如"gin")与where(部分索引谓词)参数。退化方案(分区表、自定义操作符类等辅助函数未覆盖的场景):裸RunSQL只要带上IF [NOT] EXISTS仍被策略接受:

migrations.RunSQL( sql=""" SET lock_timeout = 0; SET statement_timeout = 0; CREATE INDEX CONCURRENTLY IF NOT EXISTS mymodel_field_idx ON mymodel (field_name); """, reverse_sql="DROP INDEX CONCURRENTLY IF EXISTS mymodel_field_idx;", )

但此形式严格弱于辅助函数:它不检测、不清理indisvalid = false残留,若先前部署因 lock_timeout 以外的原因中断,需手动REINDEX INDEX CONCURRENTLYDROP INDEX CONCURRENTLY IF EXISTS后才能重跑。优先用辅助函数。

加约束:NOT VALID 两阶段模式

常规加CHECK/FOREIGN KEY约束会校验所有既有行并锁表。Postgres 允许拆成两步,仓库在 not_valid_constraint.py 中实现:

第 1 步:AddConstraintNotValid加 NOT VALID 约束——短暂锁、不扫表,只对新/变更行生效:

from django.db import migrations, models from django.db.models import Q from posthog.migration_helpers import AddConstraintNotValid class Migration(migrations.Migration): operations = [ AddConstraintNotValid( model_name="mymodel", constraint=models.CheckConstraint(condition=Q(field_value__gt=0), name="mymodel_field_check"), ), ]

第 2 步:ValidateConstraint单独校验——在SHARE UPDATE EXCLUSIVE下扫描既有行(允许正常读写):

from django.db import migrations from posthog.migration_helpers import ValidateConstraint class Migration(migrations.Migration): operations = [ ValidateConstraint(model_name="mymodel", name="mymodel_field_check"), ]

两阶段必须放在独立迁移中(避免 ADD 的短暂ACCESS EXCLUSIVE锁被 VALIDATE 扫描长时间持有),或同一迁移内用atomic = False。校验阶段先禁用lock_timeout/statement_timeout(否则部署期 statement_timeout 会在大表扫描中途杀死进程,每次重试都重复);ADD 阶段刻意不碰超时——它是ACCESS EXCLUSIVE下的短暂元数据 ALTER,应当在锁竞争时快速失败而不是排队堵表。两阶段都幂等:add 在约束已存在时跳过,validate 在已校验时跳过。若校验失败,Django 将该迁移标记为未应用——清理违规行后重跑即可。

外键同样适用AddForeignKeyNotValid/ValidateForeignKey,但指向热表的 FK 需额外谨慎(ADD CONSTRAINT ... NOT VALID仍会短暂锁父表,见前文)。

数据迁移:分批、暂停、可恢复

RunSQL中的UPDATE/DELETE会长时间锁行,RunPython可能慢且持锁。原则是把大更新拆成小批量并加入间隔,四个经典模式:

  • Pattern 1:RunSQL 分批 UPDATE——DO $$ ... $$循环,每次取LIMIT batch_size行更新,GET DIAGNOSTICS rows_updated判断退出,pg_sleep(0.1)暂停。注意DO块在单个事务内运行,锁贯穿循环、部分进度无法提交;真正要分段提交就用 Python 级分批或后台任务。
  • Pattern 2:RunPython 分批 UPDATE——batch_size = 10000,循环取id列表、filter(id__in=ids).update(...),每批time.sleep(0.1)并打印进度。批间天然分段,可提交。
  • Pattern 3:.iterator(chunk_size=...)内存高效遍历——避免一次性加载全部行到内存,逐行计算后save(update_fields=[...])
  • Pattern 4:.bulk_update()批量写回——攒满 batch 后一次bulk_update(objects_to_update, ['new_field']),远快于逐条 save。

关键点:批大小 1,000–10,000 行(按行宽调优);批间小延迟降低系统负载;用 WHERE 限定范围;每 N 行打日志监控进度;先在类生产数据上验证性能;百万级行用后台任务而非迁移;用.iterator()省内存;用.bulk_update()提速。

SeparateDatabaseAndState:状态与数据库解耦

SeparateDatabaseAndState把 Django 迁移状态与实际数据库变更分离,是安全多阶段部署的核心工具。两个典型场景:

  1. 安全移除模型——见删表章节,state_operationsDeleteModel,数据库操作留到后续迁移;
  2. 为已存在的表添加模型——表已由手工或其他系统创建,只需更新 Django 状态:
class Migration(migrations.Migration): operations = [ migrations.SeparateDatabaseAndState( state_operations=[ migrations.CreateModel( name='ExistingTable', fields=[ ('id', models.BigAutoField(primary_key=True)), ('name', models.CharField(max_length=255)), ], ), ], database_operations=[], # 表已存在,仅更新 Django 状态 ), ]

没有它,makemigrations可能生成试图同步状态的错误迁移;有了它,就能把"Django 认为存在什么"与"数据库里实际有什么"分开管理,防止状态漂移。

一般性最佳实践

  1. 每个迁移只做一个高风险操作——拆开多个AddIndex+RunSQL UPDATE+AddField的组合,便于回滚、降低部署风险。
  2. atomic = False只用于 CONCURRENTLY 操作——CONCURRENTLY无法在事务内运行;但普通 DDL、数据迁移等应保持原子。原因在于重试问题:atomic=True下失败则什么都没提交、重试干净;atomic=False下部分变更已提交,重试会报 "column already exists"。若一个迁移既需要 Schema 变更又需要并发索引,拆成两个迁移:Schema 用默认原子,并发索引用atomic=False。若确因长任务需要atomic=False,用IF NOT EXISTSWHERE NOT EXISTS保证幂等,或改用 async migrations(参见 async-migrations.md)。
  3. IF EXISTS/IF NOT EXISTS保证幂等——让操作可安全重跑。
  4. 始终有回滚计划——搞清楚失败时会怎样、哪些操作不可回滚(DeleteModelRemoveField不可逆)、如何从部分完成中恢复;atomic=False下失败后 Django 不会自动回滚,务必手动核对 Schema 一致性;记住基础设施团队随时可能因任何原因回滚部署。

ClickHouse Schema 变更:两个额外的复杂度

ClickHouse 是 PostHog 可扩展分析能力的核心,其 Schema 同样可以通过迁移变更,但多了两个关键复杂度:

  1. 没有传统意义上的索引。每张表只有一个排序键(sorting key),定义在表的ORDER BY子句中,决定数据在磁盘上的布局;ClickHouse 按布局顺序读数据,因此排序键必须对表的用例最优。以事件表为例,其排序键形如ORDER BY (team_id, toDate(timestamp), event, cityHash64(distinct_id), cityHash64(uuid)),配合SAMPLE BY cityHash64(distinct_id)支撑多租户下的采样查询。
  2. 存储事件的表在 PostHog Cloud 上是分片 + 分布式的(sharded + distributed)。这提升了多租户架构的性能,但也意味着这些表的更新不像普通表那样直接,可能需要手动写入集群(跨 shard 执行 DDL、管理 ZooKeeper 路径、协调复制)。

为确保新的 ClickHouse 迁移同时解决这两点,务必请有丰富 ClickHouse 运维经验的人评审,并在#team-clickhouseSlack 频道征求意见。Cloud 级的事件表迁移参考案例可见 clickhouse-event-table-migrations.md:其策略是先在每个 shard 的单个节点上创建不含物化列的新表 → 用调优后的设置高速INSERT拷贝数据(max_block_size=200000, max_insert_threads=20, optimize_on_insert=0等,实测约百万行/秒)→ 挂接新 Kafka topic + 物化视图 + 分布式表追赶摄入 → 补建物化列 →OPTIMIZE ... FINAL DEDUPLICATE去重 → 校验 → 复制到各节点 → 停止摄入、RENAME TABLE换名 → 确认无误后删旧表。那次实践还总结了为何弃用 clickhouse-copier(拷贝慢、chunk 超过max_table_size_to_drop报错、摄入中难以保证正确性等),这些教训同样适用于未来的异步迁移。

快速回顾

  • 变更前自问:真的需要吗?向后兼容吗?能分开部署吗?会锁大表吗?
  • 绝不删除/重命名模型与字段;需要退役时用# DEPRECATED标记、用 Managerget_queryset屏蔽查询、用deprecate_field()/untrack_field()让列离开 ORM。
  • 热表上的 ALTER 风险在锁等待队列;新字段优先进扩展模型,绕不开就显式写进hot_table_acknowledged_migrations.txt
  • 删表/删列必须分阶段:先移除代码引用与状态 → 等一个部署周期 → 再动数据库;FK 约束务必同步处理。
  • 加列走"可空 → 回填 → NOT NULL"三阶段;加索引用SafeAddIndexConcurrentlyatomic = False);加约束用 NOT VALID → VALIDATE 两阶段。
  • 数据迁移分批 + 暂停 + 幂等;SeparateDatabaseAndState是状态/数据库解耦的瑞士军刀。
  • ClickHouse 迁移额外关注排序键与分片/分布式特性,变更前请 ClickHouse 专家评审。

仓库中的安全迁移辅助工具集中在 posthog/migration_helpers(含配套测试test_concurrent_index.pytest_deprecate_field.pytest_untrack_field.pytest_drop_foreign_key.pytest_not_valid_constraint.py),迁移风险分析器在 posthog/management/migration_analysis/policies.py,它们在 CI 中把上述规则变成可执行的闸门——理解并善用它们,是让每一次 Schema 变更既顺利又安全的关键。

【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 10:05:32

电子元器件视觉质检系统:YOLO多版本选型与大模型轻量融合实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 10:04:40

ARM交叉编译实战:从工具链构建到Qt5.12.10移植

1. 项目概述&#xff1a;为什么今天还在啃ARM交叉编译这块硬骨头&#xff1f;“DAY17-ARM 架构与交叉编译”——这个标题看起来像某本嵌入式入门教材的第十七节&#xff0c;也像某位工程师在技术复盘笔记里随手记下的日期标签。但如果你真把它当成“照着抄一遍就能跑通”的教学…

作者头像 李华
网站建设 2026/9/12 9:59:14

IDEA内存溢出OutOfMemoryError排查与解决:从JVM参数调优到实践

写代码写着写着&#xff0c;IDEA突然右下角弹出一个红色错误框&#xff0c;紧接着整个编辑器开始卡顿&#xff0c;键盘敲半天没反应&#xff0c;最后只能强制退出。重启之后又一切正常&#xff0c;但过不了一会儿又复现。相信每个Java开发都被java.lang.OutOfMemoryError折磨过…

作者头像 李华
网站建设 2026/9/12 9:58:43

大模型流式输出利器SSE:原理、实战与踩坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 9:58:32

数据库一体机性能调优:从NUMA绑核到混合压测与限流的实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 9:58:23

基于STM32的智能晾衣架:ADC采集、PWM调速与状态机设计

简介&#xff1a;在嵌入式系统开发中&#xff0c;模拟量传感器采集与直流电机控制是两类高频需求&#xff0c;而ADC转换精度、PWM占空比调节及中断实时响应往往决定系统稳定性。以智能晾衣架为例&#xff0c;它需要将雨滴与光照传感器的模拟信号转换为数字量&#xff0c;并通过…

作者头像 李华