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 导出)—— 反正导出 / 导入共用同一条代码路径,中转不额外消耗开发成本。
# 编译
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 会把它们当自己的语法。
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 — 保持默认。| 开关 | 默认 | 含义 |
|---|---|---|
-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。
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())
}
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 面向的是"写代码时就知道表结构" 的场景,两者分工明确。
Committer:目标端实现 Commit() error,Run 会在每张表迁完后调用一次。
VRTX 实现了它 —— 千万行的库不能等最后 Close 才落盘。ParseDSN(dsn, opts) (*Endpoint, error):把 vrtx:... / sqlite:... / mysql:...
解析成一个同时能当源和目标的 Endpoint,就是 CLI 用的那个。| 字段 | 含义 |
|---|---|
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() 为起点改。
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 |
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。
三点必须留意:
DECIMAL(p,s) 收成定点数 Decimal(s):盘上是 128 位系数 + 列级 scale 的保序编码,
小数位不会像以前那样折成浮点丢掉。代价是同一列的 scale 必须一致(__vrtx_meta
记着它),s 超过 38 位精度装不下时才退回 Float64。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 存储」。DATE / DATETIME / JSON 不做语义解释:时间列落 8 字节整数(能比较、能索引、能
当时序键),JSON 等其余类型字节原样搬进变长列,往返不丢内容但没法用类型语义去查。Uint64(无符号)列导出时按 int64 的位模式搬运(int64(-1) ↔ uint64 的 0xFFFF...),
所以 uint64 的高位段也能原样带回来—— 1<<63、math.MaxUint64 都在往返测试里验证过。
Int64(有符号)列盘上是保序编码(符号位翻转 ^ 1<<63),__vrtx_meta 记的是"Int64"档位,
导回时按同一套编码写回,数值一字不差。
定长字符串列导出时会去掉尾部 \x00(定长列右填充的产物);如果剩下的字节是合法
UTF-8 就绑成字符串,否则绑成字节数组。读回来再按列宽右填到定长。
__vrtx_metaSQL 表达不了 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 才会被搬过去。
_id + 聚簇 B+ 树)行数据就寄存在主键索引的条目里,pkID 是行的真实身份。导出成 SQL 的 PRIMARY KEY,
回来时用同一个 pkID(InsertRowWithID)插回,编号不漂移。
VRTX 的自增号游标只增不减、且每次开表时从当前最大存活 pkID 之后继续,所以迁完之后
_id 自增也从正确的位置往下走。
行存储索引的键必须能承载行号,而 SQL 的主键得是整数。此时:
_id 列(8 字节)承载行号,作为 SQL 的 PRIMARY KEY;idx_<表名>_<列名>,保住"按原列查"。time)时序索引的键是 _ts(不是行号),所以:
_ts 照常是普通列;__vrtx_meta 的 rs 字段里,导回 VRTX 时原样重建 Time 索引;补充:时序索引内部的
pkID只在库内用来区分同一时间戳下的多行(同一个_ts可以有 多行),跨引擎没有对应物。迁移工具干脆不读它,让目标端按自增号分配。
VRTX 的表必须有行存储索引(否则没法定位行),所以默认报错:
表 Orders 没有主键, 目标 VRTX 的表必须有行主键列 —— 请给源表加 8 字节整数主键, 或开 AutoPK 让它自动补 _id 列
加 -auto-pk 会自动补 _id。不想改源库结构、只想先把能迁的表迁走,可以配
-continue-on-error:这类表单独失败,其余表照常迁完,报告里它在备注列标 [失败] 并写明原因。
按名字、类型、分量列顺序重建。顺序与类型从 __vrtx_meta 还原;没有 meta 时按目标
引擎默认(B+ 树)处理,因为 MySQL / SQLite 都分不出 Hash 与 B+ 树。
注意 VRTX 的两条硬约束:动态列不能建索引;索引名 ≤ 64 字节 (自动命名的名字太长时,工具会退化成短的哈希名,保证能建出来)。
无法建索引的列会被跳过并打日志,不会让整次迁移失败。
# 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
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
Run 在每张表迁完后提交一次,所以中途失败已经迁完的表是安全的。但默认是追加语义,
直接重跑会重复插入。续传时用 -tables 只点名没迁完的表:
./vrtxsql -from ... -to ... -tables Orders,OrderItems -recreate
./vrtxsql -from 'mysql:...' -to vrtx:/data/shop.db -batch 2000 -v
-batch 越大往返越少,但单条 INSERT 的占位符数量有上限(MySQL 65535 / SQLite 32766),
工具会自动按 maxPlaceholders 收敛每批的实际行数,不会撞上限。
AUTO_INCREMENT 不还原,VRTX 主键号由引擎自己的
游标分配。Run 是"读源 → 写目标"的单向流,不锁源库;边写边迁只能拿到
某一时刻的近似结果。VARCHAR 一律当定长:VARCHAR(n) 取 n 对应的定长档位;没有长度上限的
TEXT 落变长。超过 256 字节的字符串在 VRTX 里是变长列,不能再建索引。columns() 只认单列主键),必要时
按"无主键 + -auto-pk"处理,并按普通列重建索引。unique 与 NULL 随迁移走,但各自只落成 SQL 能表达的那一半:
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。FlagNullable ⇄ SQL 的 NULL / NOT NULL)双向搬运:可空列导出
时写 NULL,行里的 NULL 也原样写成 SQL NULL,不会被折成 0 / 空串 / 1970-01-01
(那是不可逆的静默改数据);不可空列仍写 NOT NULL。__vrtx_meta 里保真:SQL 的列级
UNIQUE 会真的生成约束,凭空写出等于替用户造约束,所以导出 DDL 时刻意不写这一半。SetMaxOpenConns(1),同一进程内对同一文件的并发迁移
会串行化。Query.ScanWithPKID 是回调式的、中途停不下来,所以
Source.Scan 也做成"把行推给回调",而不是 Next() 迭代器。Run 攒够 Options.Batch 行调一次 RowWriter.Write,每张表
结束调一次目标端的 Commit。IndexMetaPage.GetIndexes() / GetRowStoreIndex()(core 包外
原本无法枚举索引)与 Query.ScanWithPKID(拿到真实 pkID)都是为迁移补的。VRTX 端直接用 core 的 db.CreateTable / InsertRowWithID,见 §4.1 的说明。TableSchema 是 VRTX FieldType + 有序列 + 索引的统一描述,三个方向
共用同一条代码路径。测试:migrate/roundtrip_test.go 覆盖有符号 / 无符号各档整数、浮点、变长、Hash / B+ 树
索引、uint64 边界值、时序表、外部 SQLite 库、-limit、以及"自我迁移必须报错"。
go test ./migrate/ -v