关于我在做数据库迁移踩到的坑以及思考

📅 发布时间:2026/9/4 17:52:29
关于我在做数据库迁移踩到的坑以及思考
笼中鸟何时飞今天分享下最近做网站数据库迁移遇到过的坑顺便再讲下 Alembic 和 Prisma 的机制。Alembic 是咋实现迁移的写 Py 的朋友应该不陌生。先拿本地开发来说项目里会存在一个/alembic/versions目录下面是所有迁移文件的 py 脚本下面我们都称之为example_auto.py。它底层其实维护的是一张有向无环图DAG正常情况下就是一条线性的链会分别记录当前迁移文件的上一次执行版本号和该文件自己的版本号。alembic upgrade head的执行顺序alembic upgradehead执行顺序分别是下面这些其中无论哪一个步骤检查失败都直接报错返回检查基础配置文件是否存在包括数据库连接配置、迁移脚本目录地址检查数据库连接是否正常根据刚刚的脚本目录代码内容构建一张 DAG把每个脚本的down_revision和它自己的revision连起来把head解析成一个具体的版本号 ——这一步要求 head 唯一图里要是存在多个 head那就直接报错返回再去看数据库里面对应的alembic_version如果跟目标对应不上那就根据图的结构从当前节点一直执行到 head整体执行完毕。alembic revision --autogenerate干了啥alembic revision--autogenerate-mautoAlembic 会对比当前数据库跟model.py里面的差异自动生成迁移脚本example_auto.py然后把父节点设置成当前alembic表里面的 head。讲下我的踩坑过程当时是做了一个比赛信息来源的平台不过现在数据质量还比较差暂时不给大家网址了其中当时就用上了 Alembic。最开始呢我的设计是这样的本地就改models里面的数据库模型然后写个同步脚本entrypoint.sh塞进镜像服务器每次启动容器的时候跑一遍执行数据库迁移除此之外把alembic/versions这个执行历史目录挂载了数据卷不然容器一重启迁移历史就没了。这里得先把entrypoint.sh摊开讲一下不然后面容易蒙。它的核心就两步先 autogenerate 生成迁移脚本再 upgrade 把脚本执行掉#!/bin/shset-e# 第一步对比 models 和当前数据库自动生成迁移脚本alembic revision--autogenerate-mauto# 第二步把刚生成出来的脚本执行到数据库里alembic upgradeheadexec$有两个前提先记住后面全靠它俩本地和服务端跑的是同一套命令所以两边都是「先生成、再执行」不是只有服务端才会生成脚本服务端生成的那些 py 脚本躺在数据卷里不会回流到 git而我本地生成的会跟着 commit 一起 push 上去。项目初期是没啥问题的前两天突然跑容器发现挂了。当时做了些啥呢我在本地改了个功能分支压根没动数据库里边的字段但是我手贱在本地把上面这个同步脚本执行了一遍然后提交了 git 然后合到了远端 main。按道理来说我本地跟远端的 Alembic 应该是同步的因为这次数据库结构压根没变 —— 敲重点来了没有数据库改动的时候alembic revision --autogenerate也会生成个空的、无意义的example_auto.py。所以我本地这一跑就凭空迁了一条分支出去而服务端每次发布容器的时候也在它自己那边新迁一条分支出去。可服务端新增的这个 py 文件他不会进行 git 追踪啊那我们本地一旦运行了一次同步并更新那么他的迁移历史必然分叉这时候就必报错。如果本地不执行那就没事。我们再详细讲下这个逻辑。假设一个从 0 到 1 的项目我们的版本是从 version A → B → C。假设现在时间线是在 B且我们在提交 A 和 B 的时候本地都没有运行脚本迁移。那在 B 这个节点的时候本地的alembic/versions下面的脚本是不是一定是全空的因为项目一开始就是空的。然后服务器在部署的时候呢会生成两个迁移文件我们假设是migrate_a.py和migrate_b.py并且服务端对应的 head 我们也能一一对应。此时神奇的操作出现了。我在改完之后提交了 C但是我手贱执行了本地同步那么本地会生成一个迁移文件假设就是migrate_c_local.py他是一个根节点因为本地只执行了一次。而且我们的 git 也会把这个 py 文件 push。那么到了服务端会发生什么灾难呢同样运行同步脚本先对比数据库差距生成迁移脚本migrate_c_web.py然后再执行同步。此时在执行前置检查的时候会发现版本图里居然出现了两个根节点两个根各自带着一个 headupgrade head不知道该往哪儿走那完蛋就直接崩溃了。这里要澄清一下很多人包括我一开始会以为是「DAG 被破坏了」才报的错报错跟 DAG 合不合法没关系 —— 图本身是好的Alembic 也允许分支存在。真正的问题是upgrade head只认一个head而现在有两个它没法替你决定。题外话Alembic 其实还有个alembic upgrade heads命令它会把通向所有 head 的边都遍历执行一遍。emm 听着也挺抽象的现在大多数软件工程里一般也不用这个。刚说过必然分叉为啥这里是出现了两个根节点呢其实道理很简单把根节点想象成从虚拟的「超级源点」走出来的节点就可以了。如何判断是从哪分叉的呢其实就是本地和远端服务器上一次同步的地方。本质上来说就是因为同步脚本不管有没有数据库改动都会生成对应的 py 脚本所以当服务器和本地同时执行迁移脚本的时候就出现了分叉,最终生成了多个head。三种解决方案解决方案我后面想了下我这边给出三种每次只让服务器执行alembic revision --autogenerate和alembic upgrade head然后做 git 提交本地只执行alembic upgrade head反过来只让本地执行alembic revision --autogenerate和alembic upgrade head然后做 git 提交服务端只执行alembic upgrade head还有一种比较有脑洞的思路可以支持本地和服务端都能执行数据库同步脚本而且两端用同样的脚本即可唯一需要的就是两端也都得进行 git 提交。最后选择了第二种答案显而易见服务器资源主要做项目资源存储不做资源管理而且在服务器推 git 历史本身就是一个不规范的操作。这不就是 git 的 merge 和 rebase 吗说实话写这篇文章的时候让我不禁想起了 git 的 rebase 和 merge。大家想下把上面的服务端和本地想成两个人假设服务器对应小明本地对应小红上面的分叉是不是相当于 git 的分叉提交不过 Alembic 还真有 merge 的逻辑但风险比较高他只是死板的把两个分支迁移代码执行一遍然后搞个合并节点出来类似这样然后再回头看那几个方案第二种方案是不是就相当于小明只能每次 pull 代码小红每次负责进行代码提交和 push第一种方案同理。第三种方案是不是就相当于先把小明的分支 rebase 到小红分支也就是先把小红的提交搞过来然后再提交小明的分支更改很神奇吧两个人互相 rebase。讲完 Alembic我们讲下 Prisma它是个服务于 TS、JS 的框架他的维护更改的方式跟 Alembic 不太一样他是基于时间戳去进行维护同时会直接生成原生 SQL 迁移文件。我们可以想象他就是一个链表。他的同步方式官方也提供了两种npx prisma migrate dev npx prisma db pushmigrate dev影子数据库第一种比较高级他会搞个影子数据库先去执行历史的所有migration.sql然后他能很好的处理正常分叉的情况。比如 A 新加了张表B 新加了另外张表此时能直接运行不会报错而 Alembic 处理不了这种情况 —— 一旦冒出多个 headupgrade head就直接报错得你自己去 merge。为什么要说「正常分叉」的情况呢那假如说 A 改了某张表的字段但是 B 之前就把这张表删了那肯定是工程上的事故了。但我们的 Prisma 也兼容这种情况影子数据库发生报错后会反馈到用户脚本主线程统一进行错误回报。db push暴力覆盖第二种呢就比较暴力不管历史啥提交直接把现有的 schema 跟数据库对比强行进行更改差啥改啥。在项目初期阶段或者开发人员少的时候可以选择这种方法。一个大家可能忽略的点还有一个我想说的点也是大家可能忽略的点就是Prisma 第一种方式其实不能追溯跨数据库同步。就比如说我本地开发的时候连的 localhost 的数据库然后同时又想让服务器端数据库也同步 —— 这是做不到的因为本身的 version 历史是写到数据库的。至于为什么 Alembic 可以支持本地远端同步留个思考任务大家可以看看刚刚讲的机制想下。顺带说下 client最后就是Prisma 这个东西他其实也代理了 client他会自动生成 Prisma Client然后直接在代码里面就能用了。Alembic 的话还是得借助 SQLAlchemy 去执行业务查询。两者放一起对比下维度AlembicPrisma生态PythonTS / JS迁移文件py 脚本原生 SQL历史结构DAG记上一版本号 自身版本号链表基于时间戳正常分叉多个 head 时upgrade head直接报错影子数据库可以处理业务查询得配合 SQLAlchemy自动生成 client 直接用好了今天的分享到此结束了这里是HUY下次我们不见不散