# 跨库迁移指南:VRTX ↔ MySQL ↔ SQLite `git.ycl.pub/yl32940/TsDB2/migrate` 与命令行工具 `cmd/vrtxsql` 把**表结构、数据、索引、主键**整包搬到另一个库。 VRTX、MySQL、SQLite 三个库两两互转,共六个方向: ``` VRTX ──► MySQL VRTX ──► SQLite MySQL ──► SQLite VRTX ◄── MySQL VRTX ◄── SQLite MySQL ◄── SQLite ``` MySQL 与 SQLite 之间没有直连通道,走 VRTX 中转(先导进 VRTX,再从 VRTX 导出)—— 反正导出 / 导入共用同一条代码路径,中转不额外消耗开发成本。 --- ## 1. 快速开始 ```bash # 编译 go build -o vrtxsql ./cmd/vrtxsql # VRTX → SQLite ./vrtxsql -from vrtx:/data/app.db -to sqlite:/data/app.sqlite -v # SQLite → VRTX ./vrtxsql -from sqlite:/data/app.sqlite -to vrtx:/data/app.db -v # MySQL → VRTX ./vrtxsql -from 'mysql:root:pw@tcp(127.0.0.1:3306)/shop' -to vrtx:/data/shop.db -v # MySQL → SQLite(经 VRTX 中转,这里由工具内部两步完成) ./vrtxsql -from 'mysql:root:pw@tcp(127.0.0.1:3306)/shop' -to sqlite:/data/shop.sqlite -v # 只看结构,不搬数据 ./vrtxsql -from vrtx:/data/app.db -list # 试跑:只迁 1000 行看看 ./vrtxsql -from vrtx:/data/app.db -to sqlite:/tmp/snap.sqlite --limit 1000 ``` 也可以直接用 `go run`:`go run ./cmd/vrtxsql -from ... -to ...`。 > **PowerShell 注意**:MySQL 的 DSN 含有 `@` `(` `)` `?` `&`,务必用单引号包起来, > 否则 PowerShell 会把它们当自己的语法。 --- ## 2. DSN 格式 DSN 的**前缀**决定库的种类,冒号后面整段就是它的标识: | 前缀 | 后面跟什么 | 例子 | |------|-----------|------| | `vrtx:` | 数据文件路径 | `vrtx:/data/app.db`、`vrtx:D:/data/app.db` | | `sqlite:` | 文件路径或 `file:` URI | `sqlite:/data/app.sqlite`、`sqlite:file:app.sqlite?cache=shared` | | `mysql:` | go-sql-driver/mysql 的 DSN | `mysql:root:pw@tcp(127.0.0.1:3306)/shop?charset=utf8mb4` | MySQL DSN 的完整语法是 `:@tcp(:)/?<参数>`; 常用参数: - `charset=utf8mb4` — 让驱动按 UTF-8 传字符串(强烈建议带上); - `parseTime=false` — 不建议开,工具把 `DATE/DATETIME` 当字节原样搬; - `multiStatements=false` — 保持默认。 --- ## 3. 命令行选项 | 开关 | 默认 | 含义 | |------|------|------| | `-from` | — | 源库 DSN(必填) | | `-to` | — | 目标库 DSN(必填) | | `-tables a,b` | 全部 | 只迁指定的表 | | `-recreate` | false | 目标表已存在时先 `DROP` 再重建;默认**复用已有表并追加** | | `-batch N` | 500 | 每批写入的行数(MySQL 可调大到 2000) | | `-limit N` | 0 | 每张表最多迁 N 行(0 = 不限),用于试跑 | | `-dry-run` | false | 只读结构、不写数据 | | `-no-meta` | false | 不写 `__vrtx_meta` 保真表 | | `-auto-pk` | false | 源表没有整数主键时自动补 `_id` 列 | | `-continue-on-error` | false | 某张表失败时跳过它继续迁其余表;默认第一个错误就中止整趟 | | `-loose` | false | 值装不进目标列时按引擎语义截断,而不是报错 | | `-list` | false | 只打印源库结构 | | `-v` | false | 打印每张表的进度 | 退出码:`0` 成功,`1` 失败(错误信息打到 stderr,前缀 `vrtxsql:`)。开了 `-continue-on-error` 时中途的失败不中断,但只要有表没成功,退出码仍是 `1`。 --- ## 4. 库内 API ```go package main import ( "fmt" "git.ycl.pub/yl32940/TsDB2/migrate" ) func main() { opts := migrate.DefaultOptions() src, err := migrate.OpenVRTX("/data/app.db", opts) if err != nil { panic(err) } defer src.Close() dst, err := migrate.OpenSQLite("/data/app.sqlite", opts) if err != nil { panic(err) } defer dst.Close() rep, err := migrate.Run(src, dst, opts) // 任意方向都是这一个入口 if err != nil { panic(err) } fmt.Println(rep.String()) } ``` ### 4.1 两个接口 ```go type Source interface { Kind() Kind Identity() string Tables() ([]string, error) Schema(table string) (*TableSchema, error) Scan(table string, sc *TableSchema, fn func(Row) error) error Close() error } type Sink interface { Kind() Kind Identity() string Exists(table string) (bool, error) CreateTable(sc *TableSchema, recreate bool) error Open(table string, sc *TableSchema) (RowWriter, error) Close() error } type RowWriter interface { Write(rows []Row) error Close() error } ``` `Run` 只依赖这两个接口,所以加一种数据库只需要再写一个方言实现。 `VRTX` 与 `SQL`(MySQL / SQLite 共用)都同时实现了 `Source` 和 `Sink`。 > **为什么 VRTX 端走 core 层而不是 ORM**:迁移的表结构来自源库元数据(运行时才知道列名 / > 类型 / 行存储索引列),根本没有一个 Go 结构体可以内嵌表基类,而 ORM 的入口 `DB.UseTable[T]` > 恰恰要求 `T` 内嵌 `DB.BaseModel` / `DB.PrimaryModel` / `DB.TimeSeriesModel`(否则报 > `DB.ErrNotModel`)。所以这里直接调 core 层的 `db.CreateTable(...)` / `InsertRowWithID(...)`: > core 只认 `[]types.FieldInfo`,没有"先有结构体"这个前提。ORM 面向的是"写代码时就知道表结构" > 的场景,两者分工明确。 ### 4.2 可选能力 - **`Committer`**:目标端实现 `Commit() error`,`Run` 会在每张表迁完后调用一次。 `VRTX` 实现了它 —— 千万行的库不能等最后 `Close` 才落盘。 - **`ParseDSN(dsn, opts) (*Endpoint, error)`**:把 `vrtx:...` / `sqlite:...` / `mysql:...` 解析成一个同时能当源和目标的 `Endpoint`,就是 CLI 用的那个。 ### 4.3 Options | 字段 | 含义 | |------|------| | `Tables []string` | 只迁这些表;空 = 全部(保真表 `__vrtx_meta` 默认跳过) | | `Create bool` | 目标表不存在时建表 | | `Recreate bool` | 目标表已存在时先删再建 | | `AutoPK bool` | 源表没有整数主键时自动补 `_id` | | `Batch int` | 每批行数 | | `Limit uint64` | 每张表最多迁多少行 | | `DryRun bool` | 只读结构、不写数据 | | `EmbedMeta bool` | 是否写 `__vrtx_meta` 保真表 | | `StrictValue bool` | 值装不下就报错(`false` = 按引擎语义截断) | | `Verbose bool` | 打印每张表的进度 | | `Log func(string, ...any)` | 自定义日志出口;留空走标准库 `log` | `Options` 的**零值不推荐直接用**,请以 `DefaultOptions()` 为起点改。 --- ## 5. 类型映射 ### 5.1 VRTX → SQL VRTX 的整数分**有符号**(`Int8/Int16/Int32/Int64`)与**无符号**(`Uint8/Uint16/Uint32/Uint64`,v9 起的语义名;旧名 `1/2/4/8byte` 已废弃)两档。 有符号档在盘上是保序编码(符号位翻转),SQL 侧没有对应物,只能按等宽的有符号类型导出: | VRTX 定长档位 | MySQL | SQLite | |--------------|-------|--------| | `Int8` | `TINYINT` | `INTEGER` | | `Int16` | `SMALLINT` | `INTEGER` | | `Int32` | `INT` | `INTEGER` | | `Int64` | `BIGINT` | `INTEGER` | | `Uint8` | `TINYINT UNSIGNED` | `INTEGER` | | `Uint16` | `SMALLINT UNSIGNED` | `INTEGER` | | `Uint32` | `INT UNSIGNED` | `INTEGER` | | `Uint64` | `BIGINT UNSIGNED` | `INTEGER` | | `Float64` | `DOUBLE` | `REAL` | | `Float32` | `FLOAT` | `REAL` | | `Decimal(s)` | `DECIMAL(38,s)` | `TEXT` | | `Bool` | `BOOLEAN` | `BOOLEAN` | | `Year` | `YEAR` | `YEAR` | | `UUID` | `BINARY(16)` | `BLOB` | | `Int128` | `VARCHAR(40)` | `TEXT` | | `16/32/64/128/256byte` → v9 起合并为单一 `String`(行内宽度由 Flag 低 5 位宽度码决定) | `VARCHAR(n)` | `VARCHAR(n)` | | `Dynamicbyte` | `LONGBLOB` | `BLOB` | | `Blob` | `LONGBLOB` | `BLOB` | ### 5.2 SQL → VRTX(就近取档) SQL 的整数默认是**有符号**,只有显式写了 `UNSIGNED` 才收编到无符号档: | SQL 声明 | VRTX | |---------|------| | `TINYINT` / `SMALLINT` / `INT` / `BIGINT` 系列 | `Int8` / `Int16` / `Int32` / `Int64` | | 上面四者的 `UNSIGNED` 变体 | `Uint8` / `Uint16` / `Uint32` / `Uint64`(v9 起的语义名;旧名 `1byte`/`2byte`/`4byte`/`8byte` 已废弃,见 README 格式版本说明) | | `INTEGER`(SQLite) | `Int64` | | `FLOAT`(MySQL) | `Float32` | | `FLOAT`(SQLite)/ `DOUBLE` / `REAL` | `Float64` | | `DECIMAL(p,s)` / `NUMERIC(p,s)` | `Decimal(s)`(`p,s ≤ 38`;超出则退回 `Float64`) | | `BOOLEAN` / `BOOL` | `Bool` | | `YEAR` | `Year` | | `UUID` | `UUID` | | `BLOB` / `TINYBLOB` / `MEDIUMBLOB` / `LONGBLOB` / `BINARY` / `VARBINARY` | `Blob` | | `TEXT` / `TINYTEXT` / `MEDIUMTEXT` / `LONGTEXT` / `CLOB` | `Dynamicbyte` | | `CHAR(n)` / `VARCHAR(n)` | `String`(行内宽度码 = `n` 就近取档) | | `DATE` / `DATETIME` / `TIMESTAMP` | `Uint64`(Unix 纳秒) | | `JSON` / `ENUM` / `SET` / `TIME` / `BIT` … | `Dynamicbyte` | > SQLite 只有 `INTEGER`(有符号 64 位),不区分宽度、也没有 `UNSIGNED`,因此一律收编到 `Int64` > (8 字节有符号档)。有符号与无符号档**盘上字节不同**,混用会让索引区间扫描与 `ORDER BY` > 出错 —— 往返保真靠 `__vrtx_meta` 记下原始档位,见 §6。 三点必须留意: 1. **`DECIMAL(p,s)` 收成定点数** `Decimal(s)`:盘上是 128 位系数 + 列级 scale 的保序编码, 小数位不会像以前那样折成浮点丢掉。代价是**同一列的 scale 必须一致**(`__vrtx_meta` 记着它),`s` 超过 38 位精度装不下时才退回 `Float64`。 2. **`BLOB` 收成 `Blob`,`TEXT` 收成 `Dynamicbyte`**:盘上是两条不同的路径。`Blob` 列行内 是 8 字节引用(页号 + 长度),内容落在可跨页的链上,**单值上限 ~4GB**(`core.BlobChainMax`), 迁移读出来的是内容本身、写回去由引擎重新落链;`Dynamicbyte`(含 `TEXT`)行内是 6 字节堆 引用,内容整块落一个堆页,**单值上限 `core.MaxBlobSize` ≈ 16KB**,超限写入报错而不是截断 (迁移侧可用 `-truncate-oversize` 显式改成截断,见 §3)。 `BINARY` / `VARBINARY` 同样收成 `Blob`,所以它们的声明容量现在都能兑现。 落在 blob 列上的源库索引会被跳过并记警告(blob 列不能作索引键)—— 详见 README 「Blob 存储」。 3. **`DATE` / `DATETIME` / `JSON` 不做语义解释**:时间列落 8 字节整数(能比较、能索引、能 当时序键),`JSON` 等其余类型字节原样搬进变长列,往返不丢内容但没法用类型语义去查。 ### 5.3 8 字节列的位模式 `Uint64`(无符号)列导出时按 `int64` 的位模式搬运(`int64(-1)` ↔ `uint64` 的 `0xFFFF...`), 所以 `uint64` 的高位段也能原样带回来—— `1<<63`、`math.MaxUint64` 都在往返测试里验证过。 `Int64`(有符号)列盘上是保序编码(符号位翻转 `^ 1<<63`),`__vrtx_meta` 记的是"Int64"档位, 导回时按同一套编码写回,数值一字不差。 定长字符串列导出时会去掉**尾部 `\x00`**(定长列右填充的产物);如果剩下的字节是合法 UTF-8 就绑成字符串,否则绑成字节数组。读回来再按列宽右填到定长。 --- ## 6. 保真表 `__vrtx_meta` SQL 表达不了 VRTX 的这些细节,所以导出时会在 SQL 侧额外建一张普通表 `__vrtx_meta`: ```sql -- MySQL CREATE TABLE IF NOT EXISTS `__vrtx_meta` ( tbl VARCHAR(191) NOT NULL, payload LONGTEXT NOT NULL, PRIMARY KEY (tbl) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- SQLite CREATE TABLE IF NOT EXISTS "__vrtx_meta" (tbl TEXT PRIMARY KEY, payload TEXT NOT NULL); ``` `payload` 是这张表 schema 的 JSON,记录: - `pk` / `pkn` / `pkt` — 主键列名、行存储索引名、行存储索引类型(primary / time); - `rs` — 行存储索引的分量列(时序表用;普通表为空,表示"就是主键列"); - `cols[]` — 每列的**定长档位**(VRTX `FieldType` 的原始字节)、可空位 `nl`、 列级唯一声明 `uq`(声明与约束是两件事,见 §9.5); - `idx[]` — 每个二级索引的名字、类型(B+ 树 / Hash / 复合)、分量列顺序与唯一位 `u`。 有了它,`VRTX → SQLite → VRTX` 拿回来的库与原库**逐列逐行等价**。 **外部库(没有 `__vrtx_meta`)也能导**:类型按 SQL 声明就近推断,索引一律按目标引擎 默认(B+ 树)建,可空性与唯一性按 SQL 侧读到的来(SQLite 的 `PRAGMA table_info.notnull` 与 `index_list`、MySQL 的 `IS_NULLABLE` 与 `STATISTICS.NON_UNIQUE`)。不想要这张表就加 `-no-meta`。 保真表本身不参与迁移:默认跳过,只有显式 `-tables __vrtx_meta` 才会被搬过去。 --- ## 7. 主键与索引怎么落的 ### 7.1 主键表(`_id` + 聚簇 B+ 树) 行数据就寄存在主键索引的条目里,`pkID` 是行的真实身份。导出成 SQL 的 `PRIMARY KEY`, 回来时用同一个 `pkID`(`InsertRowWithID`)插回,**编号不漂移**。 VRTX 的自增号游标只增不减、且每次开表时从当前最大存活 `pkID` 之后继续,所以迁完之后 `_id` 自增也从正确的位置往下走。 ### 7.2 主键列不是 8 字节整数 行存储索引的键必须能承载行号,而 SQL 的主键得是整数。此时: - 补一个 `_id` 列(8 字节)承载行号,作为 SQL 的 `PRIMARY KEY`; - 原主键列退化成普通列,并自动补一个 B+ 树索引 `idx_<表名>_<列名>`,保住"按原列查"。 ### 7.3 时序表(行存储索引是 `time`) 时序索引的键是 `_ts`(不是行号),所以: - 导出到 SQL 时**不设主键**,`_ts` 照常是普通列; - 索引类型与键列记在 `__vrtx_meta` 的 `rs` 字段里,导回 VRTX 时原样重建 Time 索引; - 这类表的**行号由索引内部维护、跨引擎没有意义**,因此不追求编号一致,只保证每个 时间点的数据一字不差。 > 补充:时序索引内部的 `pkID` 只在库内用来区分**同一时间戳下的多行**(同一个 `_ts` 可以有 > 多行),跨引擎没有对应物。迁移工具干脆不读它,让目标端按自增号分配。 ### 7.4 源表没有主键 VRTX 的表必须有行存储索引(否则没法定位行),所以默认**报错**: ``` 表 Orders 没有主键, 目标 VRTX 的表必须有行主键列 —— 请给源表加 8 字节整数主键, 或开 AutoPK 让它自动补 _id 列 ``` 加 `-auto-pk` 会自动补 `_id`。不想改源库结构、只想先把能迁的表迁走,可以配 `-continue-on-error`:这类表单独失败,其余表照常迁完,报告里它在备注列标 `[失败]` 并写明原因。 ### 7.5 二级索引 按名字、类型、分量列顺序重建。顺序与类型从 `__vrtx_meta` 还原;没有 meta 时按目标 引擎默认(B+ 树)处理,因为 MySQL / SQLite 都分不出 Hash 与 B+ 树。 注意 VRTX 的两条硬约束:**动态列不能建索引**;**索引名 ≤ 64 字节** (自动命名的名字太长时,工具会退化成短的哈希名,保证能建出来)。 无法建索引的列会被跳过并打日志,不会让整次迁移失败。 --- ## 8. 推荐流程 ### 8.1 首次迁移(保留目标库已有表) ```bash # 1. 先看源库结构,确认哪些表要迁 ./vrtxsql -from sqlite:/data/app.sqlite -list # 2. 试跑:只迁 1000 行到临时库,确认类型映射符合预期 ./vrtxsql -from sqlite:/data/app.sqlite -to vrtx:/tmp/probe.db --limit 1000 -v # 3. 正式迁(默认复用已有表并追加) ./vrtxsql -from sqlite:/data/app.sqlite -to vrtx:/data/app.db -v ``` ### 8.2 重建式迁移(目标库是空的或可以清空) ```bash rm -f /data/app.db # VRTX 目标先清掉 ./vrtxsql -from sqlite:/data/app.sqlite -to vrtx:/data/app.db -v ``` 或用 `-recreate` 让工具自己 `DROP`: ```bash ./vrtxsql -from sqlite:/data/app.sqlite -to vrtx:/data/app.db -recreate -v ``` ### 8.3 中断续传 `Run` 在每张表迁完后提交一次,所以中途失败已经迁完的表是安全的。但**默认是追加语义**, 直接重跑会重复插入。续传时用 `-tables` 只点名没迁完的表: ```bash ./vrtxsql -from ... -to ... -tables Orders,OrderItems -recreate ``` ### 8.4 MySQL 大批量 ```bash ./vrtxsql -from 'mysql:...' -to vrtx:/data/shop.db -batch 2000 -v ``` `-batch` 越大往返越少,但单条 `INSERT` 的占位符数量有上限(MySQL 65535 / SQLite 32766), 工具会自动按 `maxPlaceholders` 收敛每批的实际行数,不会撞上限。 --- ## 9. 已知限制 1. **不搬自增 / 序列属性**:SQL 的 `AUTO_INCREMENT` 不还原,VRTX 主键号由引擎自己的 游标分配。 2. **不是一致性快照**:`Run` 是"读源 → 写目标"的单向流,不锁源库;边写边迁只能拿到 某一时刻的近似结果。 3. **`VARCHAR` 一律当定长**:`VARCHAR(n)` 取 `n` 对应的定长档位;没有长度上限的 `TEXT` 落变长。超过 256 字节的字符串在 VRTX 里是变长列,**不能再建索引**。 4. **复合主键不保留**:SQL 的复合主键会被折叠(`columns()` 只认单列主键),必要时 按"无主键 + `-auto-pk`"处理,并按普通列重建索引。 5. **`unique` 与 `NULL` 随迁移走,但各自只落成 SQL 能表达的那一半**: - **索引的唯一位**(VRTX `IndexFlagUnique` ⇄ SQL `UNIQUE INDEX`)双向搬运。读取端按 `PRAGMA index_list.unique` / `information_schema.STATISTICS.NON_UNIQUE` 认出唯一索引 —— 包括 SQLite 里由列级 `UNIQUE` 自动生成、名字不可用的 `sqlite_autoindex_*`(收下后 改名,`sqlite_` 是保留前缀);写出端按 `__vrtx_meta` 的 `u` 字段重建 `CREATE UNIQUE INDEX`。 因此**源表有重复值就会在建唯一索引这一步失败**,需先在源侧清理重复值或去掉唯一约束; `NULL` 不参与唯一性,可共存多行 `NULL`。 - **列的可空性**(VRTX `FlagNullable` ⇄ SQL 的 `NULL` / `NOT NULL`)双向搬运:可空列导出 时写 `NULL`,行里的 `NULL` 也原样写成 SQL `NULL`,**不会被折成 `0` / 空串 / `1970-01-01`** (那是不可逆的静默改数据);不可空列仍写 `NOT NULL`。 - **列上"声明了 unique 但没有对应唯一索引"这一半只在 `__vrtx_meta` 里保真**:SQL 的列级 `UNIQUE` 会真的生成约束,凭空写出等于替用户造约束,所以导出 DDL 时刻意不写这一半。 6. **时序列的行号不保证一致**:见 §7.3。 7. **SQLite 写入串行**:写入端 `SetMaxOpenConns(1)`,同一进程内对同一文件的并发迁移 会串行化。 8. **VRTX 单进程嵌入式**:迁移期间不要有别的进程同时写目标 VRTX 文件。 --- ## 10. 实现要点(给改代码的人) - **推式扫描**:core 的 `Query.ScanWithPKID` 是回调式的、中途停不下来,所以 `Source.Scan` 也做成"把行推给回调",而不是 `Next()` 迭代器。 - **分批 + 分表提交**:`Run` 攒够 `Options.Batch` 行调一次 `RowWriter.Write`,每张表 结束调一次目标端的 `Commit`。 - **core 的两个新口子**:`IndexMetaPage.GetIndexes()` / `GetRowStoreIndex()`(core 包外 原本无法枚举索引)与 `Query.ScanWithPKID`(拿到真实 `pkID`)都是为迁移补的。 - **不经过 ORM**:迁移是"按元数据建表",没有 Go 结构体可以内嵌表基类(ORM 的硬要求), 所以 `VRTX` 端直接用 core 的 `db.CreateTable` / `InsertRowWithID`,见 §4.1 的说明。 - **中间表示**:`TableSchema` 是 VRTX `FieldType` + 有序列 + 索引的统一描述,三个方向 共用同一条代码路径。 - **测试**:`migrate/roundtrip_test.go` 覆盖有符号 / 无符号各档整数、浮点、变长、Hash / B+ 树 索引、`uint64` 边界值、时序表、外部 SQLite 库、`-limit`、以及"自我迁移必须报错"。 ```bash go test ./migrate/ -v ```