MIGRATION_GUIDE.md 20 KB

跨库迁移指南: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. 快速开始

# 编译
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 的完整语法是 <user>:<password>@tcp(<host>:<port>)/<db>?<参数>; 常用参数:

  • 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

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 两个接口

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:

-- 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 首次迁移(保留目标库已有表)

# 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 重建式迁移(目标库是空的或可以清空)

rm -f /data/app.db                      # VRTX 目标先清掉
./vrtxsql -from sqlite:/data/app.sqlite -to vrtx:/data/app.db -v

或用 -recreate 让工具自己 DROP:

./vrtxsql -from sqlite:/data/app.sqlite -to vrtx:/data/app.db -recreate -v

8.3 中断续传

Run 在每张表迁完后提交一次,所以中途失败已经迁完的表是安全的。但默认是追加语义, 直接重跑会重复插入。续传时用 -tables 只点名没迁完的表:

./vrtxsql -from ... -to ... -tables Orders,OrderItems -recreate

8.4 MySQL 大批量

./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、以及"自我迁移必须报错"。

    go test ./migrate/ -v