自托管 Sentry 升级跑到一半,终端突然甩出一行django.db.utils.OperationalError: table "labels_manager_label" already exists,紧接着就是Sentry is attempting ...的提示。第一次碰到时,我也愣了一下:明明是新装的环境,怎么数据库里已经有表了?后来排查下来才发现,这根本不是 Sentry 特有的问题,而是 Django 迁移里非常典型的“表已存在但迁移记录缺失”状态。这篇文章就围绕这个报错,把原理、排查步骤和修复方案完整过一遍,适合自托管 Sentry 的维护者,也适合遇到类似迁移冲突的 Django 开发者。1. 先还原现场:这条报错通常在什么时刻炸出来1.1 两种最容易踩中的触发方式自托管 Sentry 的日常维护里,升级操作是重灾区。跑官方安装脚本,或者用 docker compose 执行upgrade,前面的镜像拉取、配置更新都正常,等到数据库迁移阶段突然中断,控制台里就剩这行报错。为什么升级时特别容易踩?因为每次升级都会引入新的迁移文件,Django 要落一批新表;只要中途出一次小意外,比如容器被反复重启、网络抖动导致数据库连接受断、或者迁移进程执行到一半被外部杀掉,就很容易留下半成品状态。这个“半成品”不是数据半写入,而是 DDL 建表完成但迁移记账没完成。除了升级,初始化新实例或重建数据库也会触发同样的坑。比如你用一个旧的数据库备份来初始化新环境,备份里面可能带了labels_manager_label这张表,但备份里的django_migrations记录不完整;又或者你之前手动用数据库管理工具建过同名表、导入过手工 SQL,都可能导致账实不符。还有一类场景是编排平台拉起容器时,多个迁移进程同时启动,并发竞态也会撞出这个报错。所以在排查时,先想清楚自己是在哪个阶段遇到它的,能省下不少摸索时间。1.2 报错文本拆解:每一段英文究竟在说什么django.db.utils.OperationalError是 Django 在数据库操作层抛出的异常,意思是和底层数据库交互时出了问题,不是代码层面的逻辑错误。也就是说,整个升级流程本身没问题,是数据库当前的状态不允许继续往下走。table "labels_manager_label" already exists是 PostgreSQL 拒绝执行建表语句的返回结果——目标表已经躺在库里,再建一次当然不行。最后那句Sentry is attempting ...说明 Sentry 正处于迁移尝试阶段,它必须把数据库 schema 改成和当前版本代码匹配,才允许服务继续启动。理解这行报错的关键在于“继续尝试”四个字:Sentry 在迁移没有完成之前不会放行 Web 服务和后台 worker。因为它不知道代码里的labels_manager_label到底对应哪一种表结构,也不敢假设数据一定存在。宁可卡在迁移这一步,也不让运行时代码撞上不存在的列或索引。这种保守策略是对的,但代价就是你必须手工介入,把数据库实际状态和迁移记录对齐。2. Django 迁移为什么会撞上“表已存在”:背后的机制2.1 迁移的记账机制:django_migrationsDjango 能知道哪些迁移跑过、哪些没跑过,靠的是一张叫django_migrations的表。它记录了 app 名、迁移文件名和应用时间。正常流程是这样:执行某个迁移前,Django 查询这张表,发现没有对应记录,才执行建表、建索引等 DDL;全部执行成功之后,再往表里插一条记录。下次再跑,它确认记录存在,就会跳过。问题恰恰出在“记录还没有来得及写入”的窗口期。如果CREATE TABLE执行成功,但接下来写django_migrations记录之前,迁移进程突然退出——容器被杀、数据库连接断开、内存不够被系统 OOM 掉——那张表就已经真实存在于数据库里,而 Django 的账本上却没有。下次升级再跑到同一张表时,Django 看账本觉得没迁移过,于是又来一次CREATE TABLE,PostgreSQL 直接拒绝,报出already exists。打个比方:就像去医院做检查,检查已经做完了,但分诊系统里没登记,第二天系统又让你重做一遍,做检查的医生说你这单子早就开过,于是撞车。Django 迁移的账本和真实表结构必须保持同步,否则就会出现这类问题。理解了这一点,后面所有的修复思路其实都围绕一件事:让账本和真实状态重新对齐。2.2 为什么偏偏是 labels_manager_labellabels_manager是 Sentry 内部的标签管理模块,labels_manager_label这张表在版本升级时由代码自动创建。它不是用户日常会手动维护的表,所以排查方向应该集中在新版本引入的迁移上。最典型的场景是:从旧版本跳到新版本时,升级脚本执行到这个新模块的初始迁移,恰好赶上一次中断。表建完了,记录没写进django_migrations,于是变成一个永远对不上账的残留。还有一种情况是环境迁移:你把生产库的数据备份恢复到另一台机器,备份数据里带了这张表,但备份时间点早于这条迁移记录;或者恢复过程把django_migrations表的一部分给截掉了。更隐蔽的一种是并发竞态:如果你用 docker compose 的方式让多个容器同时执行迁移,两个进程可能同时查询账本,发现都没有记录,于是一起建表,后完成的一方就会撞车。这种情况在自动扩容、编排平台重复拉起容器时特别容易出现。不管来源是哪一种,处理路径都一致:先看表里有没有数据,再对照迁移记录决定是删除重建还是补记录。3. 动手排查:三分钟定位残留与缺失3.1 第一步:确认实体表与数据量先确认这张表是不是真的存在、里面有多少数据,避免在错误的假设上操作。如果用的是 docker compose 部署,可以进 postgres 容器直接查。命令大概是:docker compose exec postgres psql -U postgres -d sentry -c "SELECT count(*) FROM labels_manager_label;"如果你的容器名、数据库名或密码和默认配置不同,按实际部署环境调整,数据库名通常叫sentry。执行成功后,结果可能有两种:0表示这张表是个空壳;大于 0 表示表里有历史数据。这条信息几乎决定了后面的修复方向:空表可以直接删除重跑,有数据的表就得考虑保留数据、伪造迁移记录。注意这里不要用SELECT *去刷这张表,数据量大时会把注意力带偏,先看 count 就够了。如果连查询都报“relation does not exist”,说明表其实不在库里,那是另一个方向的问题,本文不展开。3.2 第二步:对照 django_migrations 迁移记录接着查 Django 的迁移账本,看labels_manager这个 app 到底有没有已应用记录:SELECT id, app, name, applied FROM django_migrations WHERE app = 'labels_manager' ORDER BY id;如果查询结果为空,说明账本里根本没有这个 app 的迁移记录,和表已存在的情况形成了典型冲突。如果结果只有部分记录,例如只有0002_xxx而没有0001_initial,同样要做进一步对照。再去 Sentry 安装目录里看看这个 app 实际有哪些迁移文件。通常在容器内可以这样找:find / -type d -path "*labels_manager*migrations*" 2>/dev/null找到目录后ls -1看一下文件名,把文件列表和账本里的记录做差集,就能知道哪些迁移缺失、应该补哪些。这一步虽然多花两分钟,但能避免后面补错名字。迁移文件名必须精准匹配,Django 是靠这个做判断的。3.3 第三步:判断残留来源判断残留来源能帮你避免下次再犯。你可以反问自己三个问题:最近跑过upgrade吗?如果跑过并且中途失败过,那大概率是中断残留。有没有用旧备份恢复过数据库?恢复时如果备份时间点和迁移记录之间有间隔,就会造成表存在但记录缺失。有没有用多个方式同时触发迁移?比如一边跑自动脚本一边手工执行升级命令,造成的并发竞态也可能留下同样痕迹。多数情况下答案是第一个:升级中断残留。少数情况是备份恢复导致。不管来源如何,确认之后进入下一环节,选择适合自己的修复方案。关键是不要在没确认数据量之前盲目删表,那一步错下去,就真的要把数据找回来了。4. 修复方案二选一,数据去留决定操作进入修复之前,先明确一个判断原则:如果表里有数据,且这些数据是业务运行产生的,优先选择补迁移记录;如果表是空壳,或者数据可以完整丢弃,删除重建更省事。拿不准时,走补记录方案更保守,因为它不碰数据本身。下面两条路径都给出完整步骤,先想清楚自己属于哪一种。4.1 方案A:确认残留表是空壳,清掉重迁最省事的路径,前提是表里确实没数据。直接删表,再重新执行升级迁移。删除之前,强烈建议先做一次数据库备份,哪怕确认是空表,备份成本也很低:docker compose exec postgres pg_dump -U postgres sentry > sentry_backup_$(date +%F).sql备份之后执行删除:docker compose exec postgres psql -U postgres -d sentry -c "DROP TABLE IF EXISTS labels_manager_label;"这里有一个容易被忽略的坑:如果这张表被其他表以外键的方式引用,DROP TABLE会被拒绝。这时候不要随手加CASCADE,应该先把引用关系查清楚,确认被连带删除的都是无关紧要的表再用。对 Sentry 的正常版本迁移来说,这张表多数是独立残留,直接删掉就能继续跑。删除后重新执行升级命令:docker compose run --rm sentry upgrade --noinput如果顺利跑完,说明问题解决。如果又冒出另一张表的already exists,说明历史上中断的不止一张表,重复同样的步骤处理即可。但每张表都要重新做一次数据量确认,不要因为第一张表是空表,就想当然地认为后面所有表都是空表。4.2 方案B:表里有历史数据,必须标记迁移当表里有数据时,直接删表风险很高。这些数据可能是旧版本里真实产生的标签记录,删除后再重建会变成空表,后续功能可能出现查不到历史标签的连锁问题。更好的方式是让 Django 认为这些迁移已经执行过,也就是在django_migrations里补上缺失的记录。操作分三步。第一步,找到这个 app 缺失的迁移文件名,这一步在 3.2 节已经做了。第二步,确认当前账本里确实没有对应的 app 和 name。第三步,插入记录。可以用下面这段幂等 SQL,在有备份的前提下直接执行:INSERT INTO django_migrations (app, name, applied) SELECT 'labels_manager', '0001_initial', now() WHERE NOT EXISTS ( SELECT 1 FROM django_migrations WHERE app = 'labels_manager' AND name = '0001_initial' );如果有多个迁移名缺失,把上面语句复制多份,换掉name的值,逐个执行。这里有三个细节必须注意:name必须和迁移文件名字完全一致,大小写、扩展名都别搞错,Django 靠这个做匹配;app必须写成labels_manager,不能写表名labels_manager_label;applied可以写当前时间,但不要留空,Django 排序迁移时依赖这个字段。如果你用的是标准 Django 项目,也可以直接用 MigrationRecorder 完成同样的事,下面这段逻辑与 SQL 完全等价:from django.db.migrations.recorder import MigrationRecorder from datetime import datetime MigrationRecorder.Migration.objects.get_or_create( app='labels_manager', name='0001_initial', defaults={'applied': datetime.now()} )考虑到 Sentry 容器的管理命令入口在不同版本里略有差异,直接在django_migrations表里补记录是最可控、最少依赖环境的方式。补完之后再跑一次 upgrade,就能跳过这些已补记的迁移,继续执行后面的部分。4.3 修复后验证无论选了哪条路,跑完 upgrade 都要做验证。首先重新查询django_migrations:SELECT app, name, applied FROM django_migrations WHERE app = 'labels_manager' ORDER BY id;确认缺失记录已经补上,或者原来的记录与表状态一致。其次,确认 Sentry Web 能正常响应,容器日志里不再出现迁移相关报错。最后,如果你业务里确实用到标签功能,去界面上看历史标签是否正常显示——这一步虽然简单,但最能验证方案 B 是否真的保住了数据。完整流程走完,这个table already exists的问题才算闭环。5. 这条迁移坑还会不会再见:连锁反应与预防5.1 修复一半继续报错的连锁场景这种迁移问题还容易演变成连环坑。你按方案 A 删掉一张表后跑 upgrade,结果系统又报下一张表 already exists,说明中断时不止留了一张残留。这时候继续用相同思路处理即可,但不要为了省事把所有疑似表一次性删掉——你无法确认每张表是不是空壳,删错了数据就真的没了。补记录时同样要注意依赖顺序。如果labels_manager后续还有0002_xxx、0003_xxx等迁移,补记录时应该从0001_initial开始往后来,不要跳过中间的版本直接补靠后的名字。Django 的迁移是按文件顺序执行的,中间缺失时直接跳层会留下隐患,后续版本升级很可能再次对账失败。最好每补一个迁移就查一次账,确认顺序没问题。5.2 自托管升级的操作纪律经历过一次这种折腾后,我现在对自托管 Sentry 的升级操作有几条固定纪律。升级之前一定做一次全量备份,哪怕只是小版本更新,备份的收益远大于成本。升级期间只允许一个迁移实例在跑,把 Web、Worker 临时停掉,只保留数据库和迁移执行容器,避免并发竞态。同时记录当前 Sentry 版本与目标版本,排查时对照迁移文件会快很多。还建议在测试环境里先完整跑一遍升级流程,确认迁移全部通过后再上生产。这不是什么高深技巧,纯粹是避免夜里改到一半对着数据库干瞪眼的土办法,但非常有效。如果用的是 docker compose,升级命令尽量固定为同一个入口,不要一个用脚本、一个用手工命令,交叉执行时最容易埋竞态隐患。5.3 Django 迁移红线:不要随意 DROP 有条数据最后说一条我觉得值得反复强调的红线:不要因为报错是already exists就把目光只盯在“删表”上。Django 项目里,django_migrations这张表就是 schema 的账本,真实表结构和账本不一致时,删表和补账要按实际情况选择。表里有数据,优先补账;表是空壳,才考虑删表重迁。至于 Sentry 这个具体场景,标签表往往和 issue 分组等核心元数据有业务关联,删错了不会马上在内存里炸给人看,等到查历史数据时才会发现少了一块。所以动手前多做一次备份、多查一眼账本,这种摩擦成本,比深夜补救数据库要便宜得多。
自托管 Sentry 升级跑到一半,终端突然甩出一行django.db.utils.OperationalError: table "labels_manager_label" already exists,紧接着就是Sentry is attempting ...的提示。第一次碰到时,我也愣了一下:明明是新装的环境,怎么数据库里已经有表了?后来排查下来才发现,这根本不是 Sentry 特有的问题,而是 Django 迁移里非常典型的“表已存在但迁移记录缺失”状态。这篇文章就围绕这个报错,把原理、排查步骤和修复方案完整过一遍,适合自托管 Sentry 的维护者,也适合遇到类似迁移冲突的 Django 开发者。1. 先还原现场:这条报错通常在什么时刻炸出来1.1 两种最容易踩中的触发方式自托管 Sentry 的日常维护里,升级操作是重灾区。跑官方安装脚本,或者用 docker compose 执行upgrade,前面的镜像拉取、配置更新都正常,等到数据库迁移阶段突然中断,控制台里就剩这行报错。为什么升级时特别容易踩?因为每次升级都会引入新的迁移文件,Django 要落一批新表;只要中途出一次小意外,比如容器被反复重启、网络抖动导致数据库连接受断、或者迁移进程执行到一半被外部杀掉,就很容易留下半成品状态。这个“半成品”不是数据半写入,而是 DDL 建表完成但迁移记账没完成。除了升级,初始化新实例或重建数据库也会触发同样的坑。比如你用一个旧的数据库备份来初始化新环境,备份里面可能带了labels_manager_label这张表,但备份里的django_migrations记录不完整;又或者你之前手动用数据库管理工具建过同名表、导入过手工 SQL,都可能导致账实不符。还有一类场景是编排平台拉起容器时,多个迁移进程同时启动,并发竞态也会撞出这个报错。所以在排查时,先想清楚自己是在哪个阶段遇到它的,能省下不少摸索时间。1.2 报错文本拆解:每一段英文究竟在说什么django.db.utils.OperationalError是 Django 在数据库操作层抛出的异常,意思是和底层数据库交互时出了问题,不是代码层面的逻辑错误。也就是说,整个升级流程本身没问题,是数据库当前的状态不允许继续往下走。table "labels_manager_label" already exists是 PostgreSQL 拒绝执行建表语句的返回结果——目标表已经躺在库里,再建一次当然不行。最后那句Sentry is attempting ...说明 Sentry 正处于迁移尝试阶段,它必须把数据库 schema 改成和当前版本代码匹配,才允许服务继续启动。理解这行报错的关键在于“继续尝试”四个字:Sentry 在迁移没有完成之前不会放行 Web 服务和后台 worker。因为它不知道代码里的labels_manager_label到底对应哪一种表结构,也不敢假设数据一定存在。宁可卡在迁移这一步,也不让运行时代码撞上不存在的列或索引。这种保守策略是对的,但代价就是你必须手工介入,把数据库实际状态和迁移记录对齐。2. Django 迁移为什么会撞上“表已存在”:背后的机制2.1 迁移的记账机制:django_migrationsDjango 能知道哪些迁移跑过、哪些没跑过,靠的是一张叫django_migrations的表。它记录了 app 名、迁移文件名和应用时间。正常流程是这样:执行某个迁移前,Django 查询这张表,发现没有对应记录,才执行建表、建索引等 DDL;全部执行成功之后,再往表里插一条记录。下次再跑,它确认记录存在,就会跳过。问题恰恰出在“记录还没有来得及写入”的窗口期。如果CREATE TABLE执行成功,但接下来写django_migrations记录之前,迁移进程突然退出——容器被杀、数据库连接断开、内存不够被系统 OOM 掉——那张表就已经真实存在于数据库里,而 Django 的账本上却没有。下次升级再跑到同一张表时,Django 看账本觉得没迁移过,于是又来一次CREATE TABLE,PostgreSQL 直接拒绝,报出already exists。打个比方:就像去医院做检查,检查已经做完了,但分诊系统里没登记,第二天系统又让你重做一遍,做检查的医生说你这单子早就开过,于是撞车。Django 迁移的账本和真实表结构必须保持同步,否则就会出现这类问题。理解了这一点,后面所有的修复思路其实都围绕一件事:让账本和真实状态重新对齐。2.2 为什么偏偏是 labels_manager_labellabels_manager是 Sentry 内部的标签管理模块,labels_manager_label这张表在版本升级时由代码自动创建。它不是用户日常会手动维护的表,所以排查方向应该集中在新版本引入的迁移上。最典型的场景是:从旧版本跳到新版本时,升级脚本执行到这个新模块的初始迁移,恰好赶上一次中断。表建完了,记录没写进django_migrations,于是变成一个永远对不上账的残留。还有一种情况是环境迁移:你把生产库的数据备份恢复到另一台机器,备份数据里带了这张表,但备份时间点早于这条迁移记录;或者恢复过程把django_migrations表的一部分给截掉了。更隐蔽的一种是并发竞态:如果你用 docker compose 的方式让多个容器同时执行迁移,两个进程可能同时查询账本,发现都没有记录,于是一起建表,后完成的一方就会撞车。这种情况在自动扩容、编排平台重复拉起容器时特别容易出现。不管来源是哪一种,处理路径都一致:先看表里有没有数据,再对照迁移记录决定是删除重建还是补记录。3. 动手排查:三分钟定位残留与缺失3.1 第一步:确认实体表与数据量先确认这张表是不是真的存在、里面有多少数据,避免在错误的假设上操作。如果用的是 docker compose 部署,可以进 postgres 容器直接查。命令大概是:docker compose exec postgres psql -U postgres -d sentry -c "SELECT count(*) FROM labels_manager_label;"如果你的容器名、数据库名或密码和默认配置不同,按实际部署环境调整,数据库名通常叫sentry。执行成功后,结果可能有两种:0表示这张表是个空壳;大于 0 表示表里有历史数据。这条信息几乎决定了后面的修复方向:空表可以直接删除重跑,有数据的表就得考虑保留数据、伪造迁移记录。注意这里不要用SELECT *去刷这张表,数据量大时会把注意力带偏,先看 count 就够了。如果连查询都报“relation does not exist”,说明表其实不在库里,那是另一个方向的问题,本文不展开。3.2 第二步:对照 django_migrations 迁移记录接着查 Django 的迁移账本,看labels_manager这个 app 到底有没有已应用记录:SELECT id, app, name, applied FROM django_migrations WHERE app = 'labels_manager' ORDER BY id;如果查询结果为空,说明账本里根本没有这个 app 的迁移记录,和表已存在的情况形成了典型冲突。如果结果只有部分记录,例如只有0002_xxx而没有0001_initial,同样要做进一步对照。再去 Sentry 安装目录里看看这个 app 实际有哪些迁移文件。通常在容器内可以这样找:find / -type d -path "*labels_manager*migrations*" 2>/dev/null找到目录后ls -1看一下文件名,把文件列表和账本里的记录做差集,就能知道哪些迁移缺失、应该补哪些。这一步虽然多花两分钟,但能避免后面补错名字。迁移文件名必须精准匹配,Django 是靠这个做判断的。3.3 第三步:判断残留来源判断残留来源能帮你避免下次再犯。你可以反问自己三个问题:最近跑过upgrade吗?如果跑过并且中途失败过,那大概率是中断残留。有没有用旧备份恢复过数据库?恢复时如果备份时间点和迁移记录之间有间隔,就会造成表存在但记录缺失。有没有用多个方式同时触发迁移?比如一边跑自动脚本一边手工执行升级命令,造成的并发竞态也可能留下同样痕迹。多数情况下答案是第一个:升级中断残留。少数情况是备份恢复导致。不管来源如何,确认之后进入下一环节,选择适合自己的修复方案。关键是不要在没确认数据量之前盲目删表,那一步错下去,就真的要把数据找回来了。4. 修复方案二选一,数据去留决定操作进入修复之前,先明确一个判断原则:如果表里有数据,且这些数据是业务运行产生的,优先选择补迁移记录;如果表是空壳,或者数据可以完整丢弃,删除重建更省事。拿不准时,走补记录方案更保守,因为它不碰数据本身。下面两条路径都给出完整步骤,先想清楚自己属于哪一种。4.1 方案A:确认残留表是空壳,清掉重迁最省事的路径,前提是表里确实没数据。直接删表,再重新执行升级迁移。删除之前,强烈建议先做一次数据库备份,哪怕确认是空表,备份成本也很低:docker compose exec postgres pg_dump -U postgres sentry > sentry_backup_$(date +%F).sql备份之后执行删除:docker compose exec postgres psql -U postgres -d sentry -c "DROP TABLE IF EXISTS labels_manager_label;"这里有一个容易被忽略的坑:如果这张表被其他表以外键的方式引用,DROP TABLE会被拒绝。这时候不要随手加CASCADE,应该先把引用关系查清楚,确认被连带删除的都是无关紧要的表再用。对 Sentry 的正常版本迁移来说,这张表多数是独立残留,直接删掉就能继续跑。删除后重新执行升级命令:docker compose run --rm sentry upgrade --noinput如果顺利跑完,说明问题解决。如果又冒出另一张表的already exists,说明历史上中断的不止一张表,重复同样的步骤处理即可。但每张表都要重新做一次数据量确认,不要因为第一张表是空表,就想当然地认为后面所有表都是空表。4.2 方案B:表里有历史数据,必须标记迁移当表里有数据时,直接删表风险很高。这些数据可能是旧版本里真实产生的标签记录,删除后再重建会变成空表,后续功能可能出现查不到历史标签的连锁问题。更好的方式是让 Django 认为这些迁移已经执行过,也就是在django_migrations里补上缺失的记录。操作分三步。第一步,找到这个 app 缺失的迁移文件名,这一步在 3.2 节已经做了。第二步,确认当前账本里确实没有对应的 app 和 name。第三步,插入记录。可以用下面这段幂等 SQL,在有备份的前提下直接执行:INSERT INTO django_migrations (app, name, applied) SELECT 'labels_manager', '0001_initial', now() WHERE NOT EXISTS ( SELECT 1 FROM django_migrations WHERE app = 'labels_manager' AND name = '0001_initial' );如果有多个迁移名缺失,把上面语句复制多份,换掉name的值,逐个执行。这里有三个细节必须注意:name必须和迁移文件名字完全一致,大小写、扩展名都别搞错,Django 靠这个做匹配;app必须写成labels_manager,不能写表名labels_manager_label;applied可以写当前时间,但不要留空,Django 排序迁移时依赖这个字段。如果你用的是标准 Django 项目,也可以直接用 MigrationRecorder 完成同样的事,下面这段逻辑与 SQL 完全等价:from django.db.migrations.recorder import MigrationRecorder from datetime import datetime MigrationRecorder.Migration.objects.get_or_create( app='labels_manager', name='0001_initial', defaults={'applied': datetime.now()} )考虑到 Sentry 容器的管理命令入口在不同版本里略有差异,直接在django_migrations表里补记录是最可控、最少依赖环境的方式。补完之后再跑一次 upgrade,就能跳过这些已补记的迁移,继续执行后面的部分。4.3 修复后验证无论选了哪条路,跑完 upgrade 都要做验证。首先重新查询django_migrations:SELECT app, name, applied FROM django_migrations WHERE app = 'labels_manager' ORDER BY id;确认缺失记录已经补上,或者原来的记录与表状态一致。其次,确认 Sentry Web 能正常响应,容器日志里不再出现迁移相关报错。最后,如果你业务里确实用到标签功能,去界面上看历史标签是否正常显示——这一步虽然简单,但最能验证方案 B 是否真的保住了数据。完整流程走完,这个table already exists的问题才算闭环。5. 这条迁移坑还会不会再见:连锁反应与预防5.1 修复一半继续报错的连锁场景这种迁移问题还容易演变成连环坑。你按方案 A 删掉一张表后跑 upgrade,结果系统又报下一张表 already exists,说明中断时不止留了一张残留。这时候继续用相同思路处理即可,但不要为了省事把所有疑似表一次性删掉——你无法确认每张表是不是空壳,删错了数据就真的没了。补记录时同样要注意依赖顺序。如果labels_manager后续还有0002_xxx、0003_xxx等迁移,补记录时应该从0001_initial开始往后来,不要跳过中间的版本直接补靠后的名字。Django 的迁移是按文件顺序执行的,中间缺失时直接跳层会留下隐患,后续版本升级很可能再次对账失败。最好每补一个迁移就查一次账,确认顺序没问题。5.2 自托管升级的操作纪律经历过一次这种折腾后,我现在对自托管 Sentry 的升级操作有几条固定纪律。升级之前一定做一次全量备份,哪怕只是小版本更新,备份的收益远大于成本。升级期间只允许一个迁移实例在跑,把 Web、Worker 临时停掉,只保留数据库和迁移执行容器,避免并发竞态。同时记录当前 Sentry 版本与目标版本,排查时对照迁移文件会快很多。还建议在测试环境里先完整跑一遍升级流程,确认迁移全部通过后再上生产。这不是什么高深技巧,纯粹是避免夜里改到一半对着数据库干瞪眼的土办法,但非常有效。如果用的是 docker compose,升级命令尽量固定为同一个入口,不要一个用脚本、一个用手工命令,交叉执行时最容易埋竞态隐患。5.3 Django 迁移红线:不要随意 DROP 有条数据最后说一条我觉得值得反复强调的红线:不要因为报错是already exists就把目光只盯在“删表”上。Django 项目里,django_migrations这张表就是 schema 的账本,真实表结构和账本不一致时,删表和补账要按实际情况选择。表里有数据,优先补账;表是空壳,才考虑删表重迁。至于 Sentry 这个具体场景,标签表往往和 issue 分组等核心元数据有业务关联,删错了不会马上在内存里炸给人看,等到查历史数据时才会发现少了一块。所以动手前多做一次备份、多查一眼账本,这种摩擦成本,比深夜补救数据库要便宜得多。