Fleet 数据库迁移完全指南:用 Go 代码驱动 MySQL Schema 的版本化演进 📅 发布时间:2026/9/20 21:17:54 👁 浏览次数: 后端前端企业应用运维网络安全【免费下载链接】fleetOpen device management项目地址https://gitcode.com/GitHub_Trending/fl/fleet点击查看免费下载Fleet 是一款开源设备管理平台其服务端数据库MySQL的表结构与内置数据全部通过一段段写在 Go 代码里的迁移migration来版本化管理工具是经过深度定制的 Goose。本文以 migrations 开发指南 为核心完整介绍如何新增/修改数据表、如何填充默认数据并深入当前仓库源码剖析迁移的注册、排序、事务执行与状态追踪机制帮助你在 Fork 或二次开发 Fleet 时安全、规范地演进数据库结构。迁移体系总览两条独立的迁移链Fleet 的数据库迁移由两条相互独立的迁移链组成它们共用同一套定制的 Goose 引擎但各自维护一份独立的已应用迁移记录迁移链包状态表职责表迁移tablesmigration_status_tables建表、改表DDL以及现在的所有数据变更DML数据迁移datamigration_status_data填充内置数据已弃用的旧模式两条链的入口分别在 server/datastore/mysql/migrations/tables/migration.go 与 server/datastore/mysql/migrations/data/migration.go// tables 链 var MigrationClient goose.New(migration_status_tables, goose.MySqlDialect{}) // data 链 var MigrationClient goose.New(migration_status_data, goose.MySqlDialect{})goose.New(tableName, dialect)创建一个独立的迁移客户端server/goose/goose.go每个客户端拥有自己的TableName、Dialect和迁移列表Migrations。状态表会在首次查询版本时自动创建见 server/goose/dialect.go结构为CREATE TABLE migration_status_tables ( id serial NOT NULL, version_id bigint NOT NULL, is_applied boolean NOT NULL, tstamp timestamp NULL default now(), PRIMARY KEY(id) );执行顺序有硬性约定所有表迁移必须先于任何数据迁移执行。这在fleet prepare db命令中被明确体现cmd/fleet/prepare.goif err : ds.MigrateTables(cmd.Context()); err ! nil { initFatal(err, migrating db schema) } if err : ds.MigrateData(cmd.Context()); err ! nil { initFatal(err, migrating builtin data) }表迁移新增与修改数据表核心铁律迁移一旦提交即不可变Fleet 对表迁移有明确的不可变要求原文档明确强调Table migrations should be considered immutable once committed to the Fleet repo. Any changes to an existing table should occur in a new migration executing ALTERs.也就是说一旦迁移文件被合并进仓库就永远不能再编辑它。如果之后发现表结构需要调整正确的做法是再写一条新的迁移在其中执行ALTER TABLE语句。这一点在测试框架中也有呼应——迁移测试会跳过超过 60 天的旧迁移见下文测试基础设施一节因为不可变迁移在发布时已验证过无需反复回归。前向迁移Down 函数已经废弃Fleet 目前只使用前向迁移forward-only migrations不再使用 Goose 原生的回滚Down能力。新写的迁移中Down函数必须直接返回nil。仓库中最新的迁移文件即为范例20260522195232_CreateTableVPPClientUsers.gofunc Down_20260522195232(tx *sql.Tx) error { return nil }注意仓库里早期的迁移如 20161118193812_CreateTableAppConfigs.go仍带有真实的Down实现那是历史遗留新代码一律按Down 返回 nil的规范编写。生成迁移文件make migration在仓库根目录执行Makefilemake migration nameNameOfMigration该 target 实际执行的是go run ./server/goose/cmd/goose -dir server/datastore/mysql/migrations/tables create $(name) gofmt -w server/datastore/mysql/migrations/tables/*_$(name)*.go它会在 server/datastore/mysql/migrations/tables/ 下生成两个文件文件名以 UTC 时间戳开头格式20060102150405保证版本号全局单调递增且不会冲突2026xxxxxxxx_NameOfMigration.go迁移主体2026xxxxxxxx_NameOfMigration_test.go自动生成的迁移测试骨架生成的迁移主体模板server/goose/migration.go长这样package tables import ( database/sql ) func init() { MigrationClient.AddMigration(Up_2026xxxxxxxx, Down_2026xxxxxxxx) } func Up_2026xxxxxxxx(tx *sql.Tx) error { return nil } func Down_2026xxxxxxxx(tx *sql.Tx) error { return nil }每个迁移文件通过init()里的MigrationClient.AddMigration(Up, Down)完成注册。AddMigration的实现非常巧妙——它通过runtime.Caller(1)拿到调用者即当前迁移文件的路径再用NumericComponent从文件名中解析出版本号server/goose/migrate.gofunc (c *Client) AddMigration(up func(*sql.Tx) error, down func(*sql.Tx) error) { _, filename, _, _ : runtime.Caller(1) v, _ : NumericComponent(filename) migration : Migration{Version: v, Next: -1, Previous: -1, UpFn: up, DownFn: down, Source: filename} c.Migrations append(c.Migrations, migration) }NumericComponent取文件名中第一个_之前的部分解析为 int64server/goose/migration.go这就是为什么文件名前缀必须是纯数字时间戳且不能出现第二个下划线干扰解析描述性名称中的下划线位于数字之后不影响。编写迁移逻辑CREATE TABLE 示例用read_file读取最早的表迁移 20161118193812_CreateTableAppConfigs.go 可以看到一个完整的建表 初始化数据范例func Up_20161118193812(tx *sql.Tx) error { sqlStatement : CREATE TABLE app_configs ( id INT(10) UNSIGNED NOT NULL DEFAULT 1, org_name VARCHAR(255) NOT NULL DEFAULT , // ... 更多列 ... PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8; if _, err : tx.Exec(sqlStatement); err ! nil { return err } // create an app config record with defaults because there is only one, and it // always needs to exist if _, err : tx.Exec(INSERT INTO app_configs VALUES ()); err ! nil { return err } return nil }从中可以看到两条基本范式Up函数接收*sql.Tx事务所有语句在同一事务中执行任一步出错则整体回滚建表的同时可以插入初始行——这正是原文档所说现在的新数据变更也走表迁移流程的体现建表与填数据放在同一条迁移里完成。再看一条现代迁移 20260522195232_CreateTableVPPClientUsers.go展示了 Fleet 迁移中常见的完整建表手法ENUM字段、TIMESTAMP(6)精度、多个UNIQUE KEY、以及带ON DELETE CASCADE的外键func Up_20260522195232(tx *sql.Tx) error { if _, err : tx.Exec( CREATE TABLE vpp_client_users ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, vpp_token_id INT UNSIGNED NOT NULL, managed_apple_id VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL, client_user_id VARCHAR(36) COLLATE utf8mb4_unicode_ci NOT NULL, apple_user_id VARCHAR(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, status ENUM(pending,registered,retired) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT pending, created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (id), UNIQUE KEY idx_vpp_client_users_token_apple_id (vpp_token_id, managed_apple_id), UNIQUE KEY idx_vpp_client_users_token_client_user_id (vpp_token_id, client_user_id), CONSTRAINT fk_vpp_client_users_vpp_token_id FOREIGN KEY (vpp_token_id) REFERENCES vpp_tokens (id) ON DELETE CASCADE ) ); err ! nil { return fmt.Errorf(creating vpp_client_users table: %w, err) } return nil }通过 ALTER 修改已有表因为表迁移不可变任何结构变更都必须另起新迁移执行ALTER。仓库里的经典示例 20161230162221_AddUniqueIndexToPackTargets.gofunc Up_20161230162221(tx *sql.Tx) error { _, err : tx.Exec( ALTER TABLE pack_targets ADD CONSTRAINT constraint_pack_target_unique UNIQUE (pack_id, target_id, type);, ) return err }生产环境的ALTER通常还伴随存在性检查见下文迁移辅助函数以保证迁移在部分执行的状态下也能安全重试。自动生成的配套测试make migration会一并生成_test.go测试骨架模板见 server/goose/migration.gopackage tables import testing func TestUp_2026xxxxxxxx(t *testing.T) { db : applyUpToPrev(t) // Insert data to test the migration // ... // Apply current migration. applyNext(t, db) // Check data, insert new entries, e.g. to verify migration is safe. // ... }一个真实的、极具参考价值的测试是 20260522195232_CreateTableVPPClientUsers_test.go。它验证了该迁移的全部行为契约默认status值pending、NULL的apple_user_id、时间戳自动填充、两列唯一约束的冲突拒绝、非法ENUM值拒绝、外键约束强制、以及ON DELETE CASCADE的级联删除func TestUp_20260522195232(t *testing.T) { db : applyUpToPrev(t) tokenID : execNoErrLastID(t, db, INSERT INTO vpp_tokens (organization_name, location, renew_at, token) VALUES (?, ?, ?, ?), org, loc, time.Now(), token, ) applyNext(t, db) // 验证默认 status 与 NULL apple_user_id // 验证唯一约束、ENUM 校验、外键与级联删除 ... }测试辅助函数applyUpToPrev把数据库迁移到当前待测迁移之前、applyNext逐条应用下一条迁移、execNoErrLastID等定义在 server/datastore/mysql/migrations/tables/migration_test.go。其中applyUpToPrev还会自动跳过超过 60 天历史的旧迁移测试const maxMigrationTestAge 60 * 24 * time.Hour // ... if err nil time.Since(testDateTime) maxMigrationTestAge { t.Skip(Skipping migration test for old migration, DB migrations are immutable so once tested for a release they dont need to be tested again.) }应用迁移make fleet 与 prepare db编辑完迁移文件后按原文档给出的流程构建并执行迁移make fleet ./build/fleet prepare dbmake fleet编译出build/fleet可执行文件fleet prepare db是负责数据库初始化的子命令。它首先读取配置并连接 MySQL然后执行MigrationStatus检查、必要时给出交互式确认最后依次调用MigrateTables与MigrateDatacmd/fleet/prepare.goswitch status.StatusCode { case fleet.NoMigrationsCompleted: // OK case fleet.AllMigrationsCompleted: fmt.Println(Migrations already completed. Nothing to do.) return case fleet.SomeMigrationsCompleted: if !noPrompt { printMissingMigrationsPrompt(status.MissingTable, status.MissingData) bufio.NewScanner(os.Stdin).Scan() } case fleet.NeedsFleetv4732Fix, fleet.UnknownFleetv4732State: printFleetv4732UnknownStateMessage(status.StatusCode) case fleet.UnknownMigrations: printUnknownMigrationsMessage(status.UnknownTable, status.UnknownData) if dev_mode.IsEnabled { os.Exit(1) } } if err : ds.MigrateTables(cmd.Context()); err ! nil { initFatal(err, migrating db schema) } if err : ds.MigrateData(cmd.Context()); err ! nil { initFatal(err, migrating builtin data) } fmt.Println(Migrations completed.)prepare db支持以下常用参数cmd/fleet/prepare.go参数作用--no-prompt跳过执行前的一切交互确认适用于脚本化部署--dev开启开发者模式自动套用开发配置并把noPrompt置为true--with-table-stats迁移前后打印各表近似行数JSON 输出数据迁移填充默认数据已弃用的旧模式原文档明确指出Note: This pattern is now deprecated, new data changes are done using the same migrations process as for tables.仓库中 server/datastore/mysql/migrations/data/README.md 也给出了同样的结论Data migrations arenow deprecated, and any data changes (DML statements - Data Modification Language) should be done in standard tables migrations, alongside any table (DDL statements - Data Definition Language) changes.因此凡是新写的数据变更DML请直接写进 tables 目录下的普通迁移中与 DDL 放在一起。但由于历史上有若干数据迁移仍在使用这种旧模式如内置标签的插入文档保留了其操作方法第一步同样从项目根目录生成迁移make migration nameNameOfMigration第二步把生成的迁移文件从 server/datastore/mysql/migrations/tables/ 移动到 server/datastore/mysql/migrations/data/并把文件内的package tables改为package data# 移动文件并改写包名 mv server/datastore/mysql/migrations/tables/2026xxxxxxxx_NameOfMigration.go \ server/datastore/mysql/migrations/data/第三步与表迁移一样编辑迁移逻辑后执行make fleet ./build/fleet prepare db应用。历史上真实存在的数据迁移范例是 20161229171615_InsertBuiltinLabels.go它在首次初始化时向labels表插入All Hosts、Mac OS X、Ubuntu Linux、CentOS Linux、MS Windows等内置标签func Up_20161229171615(tx *sql.Tx) error { sql : INSERT INTO labels ( name, description, query, platform, label_type ) VALUES (?, ?, ?, ?, ?) for _, label : range Labels1() { _, err : tx.Exec(sql, label.Name, label.Description, label.Query, label.Platform, label.LabelType) if err ! nil { return err } } return nil }数据迁移的可变性例外与表迁移的不可变铁律不同数据迁移是允许被修改的。原文档说明Data migrations can be mutable. If tables are altered in a way that would render a data migration invalid (columns changed/removed), data migrations should be updated to comply with the new schema. Data migrations will not be re-run when they have already been run against a database, but they must be updated to maintain compatibility with a fresh DB.即数据迁移不会对已执行过的数据库重复运行但如果后续表结构调整使旧数据迁移对全新数据库不再兼容例如列被删除就必须同步更新该数据迁移让它在新库上仍能正确执行。prepare db 背后的状态机迁移状态如何判定fleet prepare db在真正执行迁移前会先调用MigrationStatus实现于 server/datastore/mysql/mysql.go把代码中已知的迁移集合与数据库状态表里已应用的迁移做对比得到一个状态码。loadMigrations从两张状态表中读取已应用版本号server/datastore/mysql/mysql.go_, err tables.MigrationClient.GetDBVersion(writer) // 触发状态表创建 _, err data.MigrationClient.GetDBVersion(writer) // version_id 0 跳过创建状态表的 bootstrap 迁移 if err : sqlx.SelectContext(ctx, reader, tableRecs, SELECT version_id FROM tables.MigrationClient.TableName WHERE version_id 0 AND is_applied ORDER BY id ASC, ); err ! nil { ... } if err : sqlx.SelectContext(ctx, reader, dataRecs, SELECT version_id FROM data.MigrationClient.TableName WHERE version_id 0 AND is_applied ORDER BY id ASC, ); err ! nil { ... }compareMigrationsserver/datastore/mysql/mysql.go随后把已应用集合与已知集合比对产出以下状态状态码含义prepare db 行为NoMigrationsCompleted全新数据库一条迁移都未应用直接继续执行迁移AllMigrationsCompleted代码与数据库完全一致打印Migrations already completed. Nothing to do.并退出SomeMigrationsCompleted有缺失的迁移代码比库新打印缺失清单并提示先备份按回车继续UnknownMigrations数据库里有代码不认识的迁移库比代码新例如用旧版 Fleet 连了新库打印警告--dev模式下直接退出NeedsFleetv4732Fix/UnknownFleetv4732State检测到 v4.73.2 特定发行版引入的编号错误迁移自动修复或提示联系支持其中UnknownMigrations的提示信息明确说明原因cmd/fleet/prepare.go# WARNING: # Your Fleet database has unrecognized migrations. This could happen when # running an older version of Fleet on a newer migrated database.特殊地compareMigrations还内置了两组已知未知迁移白名单knownUnknownTableMigrations与knownUnknownDataMigrations见 server/datastore/mysql/mysql.go用于容忍历史上被删除或改过时间戳的迁移避免误报。整套状态判定逻辑都有对应的单元测试覆盖server/datastore/mysql/migrations_test.go 与 cmd/fleet/datastore_test.go。源码级原理迁移如何被加载与执行迁移的收集、排序与逐条执行执行迁移的入口是 server/goose/up.go 的Up它反复查询当前版本号、取下一条迁移并执行直到没有下一条为止func (c *Client) Up(db *sql.DB, dir string) error { migrations, err : c.collectMigrations(dir, minVersion, maxVersion) if err ! nil { return err } for { current, err : c.GetDBVersion(db) if err ! nil { return err } next, err : migrations.Next(current) if err ! nil { if err ErrNoNextVersion { return nil } return err } if err c.runMigration(db, next, migrateUp); err ! nil { return err } } }collectMigrations同时收集两种来源server/goose/migrate.go目录下的.sql脚本Fleet 实际不使用和代码中通过AddMigration注册的 Go 迁移然后统一按版本号排序并串联成链表sortAndConnectMigrations。Fleet 运行时以空目录调用因此只使用 Go 注册的迁移。事务执行与状态记录每条 Go 迁移在执行时都会开启事务Up函数在事务内运行随后FinalizeMigration向状态表写入一条is_applied true的记录并提交server/goose/migrate.go 与 server/goose/migration.gostmt : c.Dialect.insertVersionSql(c.TableName) // INSERT INTO migration_status_tables (version_id, is_applied) VALUES (?, ?); if _, err : tx.Exec(stmt, v, direction); err ! nil { tx.Rollback() return err } return tx.Commit()执行过程中若Up返回错误事务整体回滚迁移不会留下半成品状态。由于状态写入与迁移语句在同一事务内可以保证要么迁移完整生效要么完全没执行。迁移辅助函数tables/migration.go 提供了一套专为生产迁移设计的辅助函数写迁移时应优先复用basicMigrationStep(statement, errorMessage)/basicMigrationStepWithArgs(statement, args, errorMessage)把一条 SQL 语句包装成带错误上下文的迁移步骤withSteps(steps, tx)按顺序执行多个步骤步骤数大于 1 时会打印Step i of n进度tables/migration.goincrementalMigrationStep(count, execute)对超大数据集做分批增量迁移并每 5 秒向stderr输出进度百分比tables/migration.go一组存在性检查tableExists、columnExists/columnsExists、indexExists/indexExistsTx、fkExists、constraintExists全部基于information_schema查询tables/migration.go用于让迁移具备幂等性——在部分执行或历史数据不一致时仍能安全推进updateAppConfigJSON(tx, fn)读取app_config_json首行、经回调修改后写回专门用于改一行全局配置 JSON类的数据迁移tables/migration.go。例如一个安全的加列迁移通常会写成func Up_2026xxxxxxxx(tx *sql.Tx) error { if columnExists(tx, hosts, new_column) { return nil // 已存在则跳过保证幂等 } return withSteps([]migrationStep{ basicMigrationStep( ALTER TABLE hosts ADD COLUMN new_column VARCHAR(255) NOT NULL DEFAULT , adding hosts.new_column, ), }, tx) }最佳实践与注意事项综合原文档与仓库实现编写 Fleet 数据库迁移时应遵循以下要点表迁移一旦提交即冻结。合并进仓库后就禁止修改任何结构调整一律走新的ALTER迁移数据迁移虽可变也仅在旧数据迁移不再兼容全新数据库时才允许更新。只写 UpDown 返回 nil。Fleet 是前向迁移模型回滚通过发布修复迁移实现而不是通过 Down。命名即版本。文件名前缀必须是 14 位 UTC 时间戳20060102150405Goose 会把它解析为迁移版本号并据此排序make migration已自动处理请勿手工命名。善用辅助函数与幂等检查。大表ALTER前用columnExists/indexExists判断多步骤用withSteps包装大数据量变更用incrementalMigrationStep输出进度。每条迁移配测试。make migration生成的_test.go骨架应填上正向、边界与约束违反用例参考 VPPClientUsers 测试迁移测试框架会自动把库推进到待测迁移之前再应用它。执行前备份。prepare db在发现缺失迁移时也会提示 Please back up your data before continuing正式环境务必先备份。保持 schema.sql 同步。server/datastore/mysql/schema.sql 是由当前全部迁移推导生成的快照新增迁移后可通过make test-schemago run ./tools/dbutils ...见 Makefile重新生成保证 CI 与文档一致。版本兼容新迁移必须能被所有在线的旧版 Fleet 识别UnknownMigrations状态会中断旧版启动因此不要随意删除或改写历史迁移的时间戳若确实需要移除请参照knownUnknown*Migrations白名单机制同步登记。无需手写 SQL 迁移文件虽然 Goose 同时支持.sql迁移Fleet 的所有迁移均为 Go 代码逻辑、事务与测试统一在 Go 侧完成。结语Fleet 用Go 代码 定制 Goose构建了一套严谨的数据库演进体系make migration生成带测试的迁移模板fleet prepare db通过双链状态表与五类状态码保障迁移安全不可变迁移、前向执行与幂等辅助函数共同维护了长期演进时 Schema 的稳定。无论是新增一张表、执行一次大表ALTER还是补充内置数据按本文的流程与源码指引操作即可与 Fleet 的既有迁移体系无缝衔接。赞分享后端前端企业应用运维网络安全【免费下载链接】fleetOpen device management项目地址https://gitcode.com/GitHub_Trending/fl/fleet点击查看免费下载相关推荐VoiceStudio 数据库迁移指南Alembic 从手写 _migrate 到版本化 schema 演进VoiceStudio 数据库迁移指南Alembic 从手写 _migrate 到版本化 schema 演进 本篇技术指南以 backend/migratio人工智能语音音频本地部署MCP 服务桌面应用RTranslator跨版本数据迁移数据库Schema升级完全指南RTranslator跨版本数据迁移数据库Schema升级完全指南 引言移动应用数据迁移的痛点与解决方案 你是否曾因应用升级导致用户数据丢失而收到差评是否人工智能AI 应用本地部署语音NLP移动开发Grist 数据库迁移与 Schema 变更完整指南两库三 Schema 的演进体系Grist 数据库迁移与 Schema 变更完整指南两库三 Schema 的演进体系 本文以 documentation/migrations.md http后端前端数据库数据分析上一篇5分钟快速上手用websockets库搭建你的第一个WebSocket服务器下一篇DeepChem深度学习化学建模实战从分子设计到药物发现的完整技术栈创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考