数据库文件不该进 Git 仓库:迁移脚本、种子数据与团队协作实践

📅 发布时间:2026/10/20 22:54:46
数据库文件不该进 Git 仓库:迁移脚本、种子数据与团队协作实践
上周有个同事在公司群里发了一张截图Merge Request 里躺着一个 40MB 的 SQLite 文件评论区一片安静。安静不是因为文件太大而是所有人都知道这个文件不该进仓库但谁都不想当那个在代码评审里开口的人。后来那个分支拖了两周谁动这个文件都冲突最后只能把整份数据删掉让全组重新准备新环境。数据库文件不该提交这句话在技术圈几乎算常识了但真正被拿出来讲清楚“为什么”的机会反而很少。很多开发者第一次接触这个规则是在某个群里被一句“不要提交数据库文件”怼回去然后自己去搜文档搜到的要么是笼统的“不建议”要么是工具操作层面的“加进 .gitignore”很少有人把背后的二进制特性、状态一致性问题、迁移策略、备份边界这些东西串起来讲。这篇内容我想把这些讲透也不只是说“别这么做”而是给你一套能落地的替代方案从迁移脚本到初始化数据到团队协作约定让数据库真正成为代码仓库里可管理、可回溯、可复现的一部分。适合正在带项目、刚接手团队仓库、或者因为在评审里被问到“为什么这有个 db 文件”而想搞清楚来龙去脉的开发者看。1. 从一次“数据库文件进仓库”翻车现场说起1.1 事故还原合并时数据库怎么会坏掉先说那次事故。某个项目用 SQLite 做本地开发库表结构还在频繁调整某个开发者图省事每次改完本地表结构后直接把 app.db 文件提交进仓库。第一次提交很正常看起来仓库里多了一个文件别人拉下来也能跑。问题是第二次之后另一个人改了表结构也提交了一个 app.db两个文件在 Git 里被识别成完全不同的二进制内容合并时必然出现冲突。如果是代码冲突还好办看看两边的 diff 就能改。但 SQLite 文件一旦在版本库里冲突Git 根本没法把两个“几十MB的二进制块”按行合并它只能给出一个结果要么留我这版要么留你那版没有第三种选择。有人试过把两个文件都下载下来用工具手动比对 schema再重建一份数据听起来能解决但真实操作下来会发现两张表的数据差几条、某条记录的外键指向不同、甚至有一边已经跑过 3 次迁移另一边还停在老版本整个数据库内容早就不是简单“合并”两个字能解决的了。这不是个例。我见过好几个团队在项目早期图方便把开发用的数据库文件直接塞进仓库。一开始没人觉得有问题因为单人开发、单人使用提交就像存档。可到了两个人同时动结构、三个人同时改数据、CI 环境还要重新拉库的时候这个文件就成了灾难源。本质上你把一个“运行时的状态快照”当成“静态代码”来管理版本控制工具的能力边界直接被用错了地方。1.2 “提交数据库”的三种常见动机抛开事故不看我复盘过身边“坚持提交数据库文件”的人理由其实能归结成三类但每一类背后都有更深的问题。第一类是“省环境搭建时间”。觉得新同事克隆仓库就能跑不用装数据库、不用跑迁移、不用配初始数据。这份初心能理解但代价是被提交的数据库文件会越来越老老到一定程度它和新代码根本不匹配反而比重新搭环境更慢。第二类是“怕本地数据丢”。开发到一半改坏表结构想靠 Git 当保险箱但这应该是本地备份工具的活不是代码仓库该承担的。第三类是“数据量小图省事”。项目前期几张表几百行数据放进仓库确实不痛不痒但等到数据一行一行变多、敏感字段出现、多人协作时再想拔出来就得经历一次“大型迁移手术”。这三个动机站在开发者视角看都是“为了跑起来更快”但站在项目可持续维护的视角看都是在透支后面的稳定性。所以接下来的内容我想从技术底层把这些“坑”的原因拆干净再给一套明确的替代做法。2. 数据库文件为什么不是理想的版本控制对象2.1 二进制格式的本质不可读、不可比、不可理解几乎所有主流数据库SQLite、PostgreSQL、MySQL、SQL Server的底层存储文件都是二进制格式哪怕 PostgreSQL 的 basebackup 压缩包解出来能看到文件树里面的数据块也不是给人直接读的。二进制格式有一套自己的页面组织、页内空闲空间管理、事务日志和校验机制这些内容对 Git 来说就是一堆变化莫测的字节流。Git 本身是文本友好型的版本控制系统它的 diff、合并、补丁机制建立在“按行比较文本”的假设上。你可以给两个文本文件做合并是因为人类能理解每一行的语义给两个二进制数据库文件做合并Git 只能报告“文件不同无法合并”。你没法说“保留 1 号表的修改同时保留 2 号表的新增”因为这层语义存在于数据库内部版本控制工具完全感知不到。有人会提到 Git LFS它能把大文件存到独立空间解决的是“传输和存储体积”问题但解决不了“二进制数据库文件无法语义合并”这个根本矛盾。就算用 LFS 把这 40MB 的 SQLite 文件存起来两个人同时改表结构该冲突还是冲突该没法合并还是没法合并只是文件不见了问题依然在。2.2 数据库文件的“状态耦合”会让仓库失去一致性数据库文件在 Git 里最让人头疼的一点它其实是“数据库实例运行到某一时刻的完整快照”。快照里不光有你关心的业务数据还有内部自动生成的系统表、索引结构、统计信息、事务状态、未清理的垃圾页甚至可能有还没来得及同步到日志里的脏数据。你把这个快照提交进仓库它会和一个特定的代码 commit 绑在一起但没有任何机制保证“这份数据库文件对应代码仓库里的哪个版本”。典型场景是这样周一我提交了一份 database.db里面有 A 表和 B 表的数据周三另一个同事提交了一次代码删掉了 A 表改成 C 表周五新同事克隆仓库按最新代码启动项目却发现代码里访问的是 C 表而仓库里那份 database.db 还停留在“A 表 B 表”的时代。他当然可以再补一份新的数据库文件但这份“补”又是快照又没有版本对应关系周而复始。这就是“状态耦合”的麻烦代码文件之间存在明确的父子依赖和提交历史数据库快照却和代码历史是两条平行线。你用 Git 管理了一个根本不属于 Git 管理范畴的对象最后得到的是一个永远慢半拍的“脏快照”。2.3 体积增长今天的“方便文件”是明天的仓库水肿我见过一个项目仓库总大小 1.2GB其中数据库文件占了 900MB剩下全是历史提交里的“旧数据库文件”。这些旧文件一旦进了 Git 的提交对象数据库就不会因为后面删除而消失。Git 为了保证可回溯性会把每一次版本的文件内容都保留下来就算你后面从工作区删掉这个文件checkout 到任意历史 commit 时它还是能回来。于是仓库体积呈阶梯式增长。刚提交时几百 KB跑了一段时间数据多了变成 10MB再过一阵变 50MB每一次变化 Git 都完整保存一份。更麻烦的是数据库文件经常在“极小修改”下产生“极大变化”底层页重新分配后字节流彻底改变Git 的压缩算法对二进制内容又不太高效仓库膨胀速度远超同量级文本文件。数据量大到一定程度克隆仓库的时间会变成团队所有人的等待成本。新同事拉一次代码要 20 分钟CI 环境每次构建都要重新拉取全量历史磁盘成本、带宽成本、时间成本一起涨。这也是我在实操中最先解决的问题动不动几 GB 的仓库不仅维护痛苦还容易把构建流水线拖垮。2.4 安全边界凭据、隐私和内部数据全在裸奔数据库文件里往往不只有你自己的业务 demo 数据还可能包含测试账号、本地假数据甚至某些人为了省事直接连真实数据库导出的部分数据。这些内容放进 Git 仓库后凡是能访问仓库的人都能看到。你以为“反正仓库是私有的”但私有仓库的访问权限会随协作扩展、人员流动、远程备份同步而扩散。更隐蔽的风险是历史归档问题。就算你现在把敏感数据从数据库文件里删掉再提交一个新版本旧版本的数据库文件依然深埋在 Git 历史里。任何一个有仓库访问权限的人都能git log、git checkout到某个历史 commit 把旧文件拿回来。我处理过一次类似事件某个测试库里带了一条包含手机号的真实用户数据提交后过了两周才发现最后只能改历史提交对象操作起来既繁琐又容易出错。往数据库文件里放真实生产数据等于把密码明文写进代码风险等级完全不同。3. 正确的做法schema 与迁移文件的责任边界3.1 用迁移脚本管理表结构而不是用数据库文件管理既然不能提交数据库文件那数据库结构怎么在团队里同步答案是用迁移脚本业界主流叫 Database Migration 或 Schema Migration常见工具包括 FlywayJava 系常用、AlembicPython SQLAlchemy、golang-migrateGo 生态、Prisma MigrateNode 生态等不同语言下的思路是统一的把每次结构变化写成一个有序的、可重复执行的脚本放进代码仓库由工具记录当前已经执行到哪个版本。举个例子我用 Python SQLAlchemy 时会用 Alembic 管理。初始版本生成一个create_users_table的迁移内容类似create users table Revision ID: 20240401120000 Revises: Create Date: 2024-04-01 12:00:00 from alembic import op import sqlalchemy as sa def upgrade(): op.create_table( users, sa.Column(id, sa.Integer(), primary_keyTrue), sa.Column(name, sa.String(64), nullableFalse), sa.Column(created_at, sa.DateTime(), server_defaultsa.func.now()), ) def downgrade(): op.drop_table(users)所有团队成员共享这套迁移文件本地跑alembic upgrade head就能从任意旧版本爬到最新版本整个过程完全可控。数据库文件本身成为“可以随时从迁移脚本重建”的产物它的内容不是资产脚本才是资产。这套机制解决的核心矛盾正是 2.2 里提到的“状态耦合”迁移脚本有确定的执行顺序有和代码提交对应的历史版本不会出现“数据库比代码老三个版本”的尴尬。3.2 种子数据与初始化数据的正确分工表结构能靠迁移同步那“数据”怎么办需要区分两类数据一类是环境必须的基础配置数据比如权限枚举、地区列表、系统常量一类是本地开发用的演示数据。基础配置数据建议用种子脚本管理放到仓库的seed/或fixtures/目录里由程序启动时自动检测并写入。比如系统启动时检查roles表如果没有任何角色记录就插入admin、user、guest三条基础数据。种子脚本重复执行不产生重复数据这是它和普通初始化 SQL 的关键区别。你可以把种子脚本做成幂等操作用INSERT ... ON CONFLICT DO NOTHING之类的方式实现。演示数据则不应该进仓库也不需要每个人都完全一致。本地开发数据是给开发者本地调试用的每个人可以有自己的用户、订单、测试记录这些纯粹是运行时状态。我给团队定过一个简单规则基础数据走 seed演示数据按需写入谁有特殊需求谁自己在本地造。3.3 配合 CI 的自动校验机制迁移和种子脚本写好后必须在 CI 里跑一遍确保新克隆环境能完整走通。我在项目里加过一个很基础的检查 job拉最新代码、安装依赖、启动一个临时数据库或者用 SQLite 内存库、按顺序执行所有迁移、加载种子数据、执行一轮最小健康检查比如查询某张基础表是否存在全部通过才允许合并分支。这个检查看着简单作用非常大。它能拦截到“某个迁移脚本写错了”、“某个种子数据依赖了不存在的外键”、“某个迁移在空数据库上执行报错”这些日常问题。如果你们项目已经有 CI 基础设施建议把迁移检查做成默认 job新团队成员加入后克隆仓库、跑迁移、跑 seed30 分钟就能得到一份可复现的本地环境这才是替代“提交数据库文件”的最直接证明。4. 实操经验我把“数据库文件”请出仓库的全过程4.1 从仓库里移除已有文件的完整步骤如果你的仓库已经躺着一个甚至多个数据库文件移除过程不能只是“删掉然后加 gitignore”因为历史还在。我按实际操作顺序整理一套相对干净的步骤供参考第一步停止项目进程避免数据库文件处于运行中状态确保工作区文件稳定。第二步备份一份当前数据库文件到仓库之外比如放到本地~/backups/或团队约定的网络存储以备后续需要查旧数据。第三步从工作区删除文件并执行git rm --cached path/to/your.db注意--cached参数会把它从 Git 跟踪中移除但本地文件还在。如果想彻底删掉本地文件再执行rm path/to/your.db。第四步编辑.gitignore添加规则把数据库文件排除在跟踪之外。第五步在提交信息里明确说明“移除仓库中的数据库文件后续数据库结构统一由迁移脚本管理”让团队看到这个变更是有意为之不是误删。# 移除已跟踪的数据库文件保留本地文件 git rm --cached app.db # 如果你确定本地也不需要直接删 rm app.db # 别忘了提交 git commit -m chore: remove tracked database file, use migration scripts instead这里有个细节值得提醒git rm --cached不会删除本地文件但会把你对这份文件的所有跟踪记录标记为“删除”下次提交后其他人拉取代码时 Git 会删除他们工作区里的同名文件。如果你的队友本地还有未备份的数据建议先逐个通知备份再同步这个变更。4.2 .gitignore 的正确写法与误区.gitignore不是写一次就万事大吉的它有几个常见的误区。第一个误区是只写一个文件名比如*.db结果发现还有.sqlite、.sqlite3、*.db-journal、*.db-wal、*.db-shm这些后缀没覆盖到。SQLite 在启用 WAL 模式时会产生-wal和-shm文件这些也必须忽略。第二个误区是写太宽泛比如直接忽略所有.db文件但仓库其他地方可能恰好需要一个固定的 SQLite 模板文件作为测试基准数据这时应该对该文件路径单独放行。我的推荐写法是加一个专门的.gitignore段落并附注释# Local database files *.db *.sqlite *.sqlite3 *.db-journal *.db-wal *.db-shm *.sqlite-journal *.sqlite-wal *.sqlite-shm # Database exports and dumps *.sql.gz *.dump写完.gitignore后可以用git check-ignore -v app.db验证某一文件是否真正被忽略并定位是哪条规则生效的。这一步新手容易忽略经常写完规则后发现文件还是被跟踪原因是该文件在规则生效前已经被git add过必须按 4.1 的步骤先git rm --cached才能彻底解除跟踪。4.3 团队协作约定用 Docker Compose 或本地脚本初始化环境规范一旦建立就要把它变成可执行的东西。我给团队推的标准方案是迁移脚本 种子脚本 一条初始化命令。后端项目我用 Docker Compose 管理数据库服务开发环境里docker compose up -d postgres拉起一个空库再执行迁移脚本和种子脚本项目就能跑起来。整个流程封装进 Makefile 或 npm script新成员只需执行一句make setup这个命令内部大致做的事是启动容器、等待数据库健康检查通过、运行迁移、加载 seed、验证基础接口。关键点是“等待数据库健康检查通过”早期我跳过这一步容器刚起来数据库还没接受连接迁移脚本直接报错后面还得手动重试。后来在命令里加入明确的等待循环这一层体验才稳定下来。4.4 怎么说服队友不要用“这个文件改起来最快”技术方案再好最难改的往往是习惯。我在和团队同步新规则时没有直接发一堆文档让他们读而是做了一次“翻车演示”拉着所有人看一次把数据库文件提交进仓库后两人同时改表结构产生的合并冲突再对比跑一次迁移脚本解决同样场景的效果。亲眼看到冲突之后几乎不需要再多解释。当有人觉得“本地文件放进仓库最方便”的时候我一般会提醒一个关键点你觉得方便只是因为现在团队成员少、数据量小、没有太多并发修改。等团队规模到 20 人每天几十个 commit数据库文件迟早变成“今天谁提交谁就等着被 diss ”的烫手山芋。与其到时候再做转移不如趁项目早期把规则定好迁移脚本和初始化流程一次性搭起来后面每天省下的都是团队整体时间。5. 常见问题与踩坑记录5.1 “我的 SQLite 文件很小提交也没事吧”问出这个问题很正常几个 KB 的文件确实看不出风险。我的回答是如果项目只有你一个人用、数据永远不会增长、永远不包含敏感信息、也不可能多个设备并行开发那你可以提交这属于“明知风险且能承受”的个人选择。但事实是绝大多数问出这句话的项目后续都长成了多人协作、需要长期维护的产品。等数据量从 KB 变 MB、再变 GB你再回头处理代价远高于一开始就用迁移脚本。我可以理解小文件短期提交的便利性但建议至少给仓库加一个“定时体检”机制比如每个季度检查仓库体积和文件类型分布发现数据库文件明显沉淀下来了趁体量还小赶紧清理。5.2 “老板让我提交一份全量数据”怎么处理有一种特殊情况不叫“数据库文件进仓库”而是“需要把全量数据交付给某个环境”。这类需求本质是数据分发不是版本控制。正确做法是用数据库自带的导出工具生成 SQL dump 或 CSV 文件传到团队约定的独立存储位置比如 NAS、云对象存储、私有网盘而不是塞进 Git 仓库。导出的文件可以按日期命名配合简单的校验和记录比如导出时间、数据版本来管理。如果确实需要一个“可复现的测试数据集”我会把脱敏后的子集压缩成一个稳定的 fixture 文件放进仓库但前提是数据经过脱敏处理和体积压缩并用脚本说明它的来源和导出规则。这类 fixture 文件仍然不是真正的“数据库文件”它只是可读文本或序列化格式的数据源。5.3 已在仓库里的损坏数据库文件怎么处理有个典型事故某人提交了一个数据库文件后来文件落到别人机器上时已经损坏一启动就报错。排查链路很长因为既可能是文件损坏也可能是数据库版本不兼容还可能是旧文件和新代码结构冲突。如果你遇到这种情况先别急着去 Git 里翻“上一个正常版本”而是把当前这份文件直接用数据库工具检查一遍。我用 SQLite 时会先执行PRAGMA integrity_check;结果返回ok说明文件本身没坏接下来应该调整迁移顺序或检查版本兼容性返回database disk image is malformed之类则说明文件确实坏了这时候该做的是从备份恢复或重新初始化环境而不是在仓库里找“上古版本”。记住Git 历史里的数据库文件只会越来越少地匹配当前代码永远不要迷信“仓库里的旧数据一定是对的”。5.4 备份策略和版本控制不是一回事把数据库文件提交进 Git 的一个隐形误导是让人觉得“我在给数据做备份”。实际上Git 仓库的备份能力非常有限它不会记录数据库的定期快照、不会保留事务日志、不能按时间点恢复某个业务时刻更不能解决误删某条记录后的细粒度回滚。真实的数据库备份应当用数据库自身的工具PostgreSQL 用pg_dump/pg_basebackupMySQL 用mysqldump/xtrabackupSQLite 用sqlite3 .backup。备份文件要放到独立存储定期验证“能不能恢复”而不是依赖代码仓库里的临时快照。我在项目文档里专门加过一节“开发数据备份方案”把本地数据的每日备份、重要分支数据的手动导出、以及恢复演练的步骤写清楚这才解决了“怕数据丢”的根本顾虑。6. 例外情况与我的最终建议6.1 什么场景下数据库文件可以提交无论规则多绝对总得有边界。我见过少数可以接受“数据库文件进仓库”的场景比如只读的、存放固定字典数据的 SQLite 文件内容半年才更新一次作为代码库的一部分提供给应用使用。这类文件的本质其实是“静态资源”不是“运行状态”它的变更频率极低、合并冲突几乎为零放进仓库反而方便分发。还有一种场景是演示项目为了让别人一键跑起来附带一个小小的 SQLite 测试库数据完全脱敏体积控制在几十 KB 内也能接受。但我会给这些场景加几个条件文件体积始终保持在很小范围、内容是只读或接近只读、不包含真实用户数据、团队明确知晓并同意这种例外管理。最关键的是这类文件变更时要走人工审查流程不能成为“大家都这么提交所以我也提交”的通道。6.2 关于数据库和代码协同演进的一个建议交流和方案讲完了最后想掏点实际经验。数据库和代码的协同演进本质上是一个“把不可控状态变成可控状态”的过程。数据库文件是运行态它是代码执行的结果不是代码本身。用迁移脚本管理结构、用种子脚本管理基础数据、用备份方案保障数据安全这一套组合下来Git 仓库才真正回到它该管的事情上。我见过太多项目在“改一行表结构”这种小事上痛苦也见过团队被一个 50MB 的 SQLite 文件拖住整个发布流程。这些事情的共同点都是把状态和代码混在一个仓库里管理。按我这几年的经验凡是能在启动时用脚本重建出来的东西都不应该进仓库凡是带时间状态、会持续变化、依赖具体环境的东西都应该交给专门的工具去管。这不是什么高深理论就是把责任边界划清楚。如果你现在正被仓库里某个数据库文件反复折磨别犹豫趁数据还不大按 4.1 那套流程把它请出去然后把迁移脚本和初始化命令搭起来。转换的过程可能得花一两个下午但之后每次新环境部署、每次同事改表结构、每次 CI 跑完的一致性检查省下来的都是成倍的时间。这是我踩过坑之后最想说的一句话你提交的不是数据是未来的麻烦而那个麻烦其实真的可以不用发生。