| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497 |
- package migrate
- import (
- "git.ycl.pub/yl32940/TsDB2/DB/core"
- "git.ycl.pub/yl32940/TsDB2/DB/core/page"
- "git.ycl.pub/yl32940/TsDB2/DB/core/types"
- "errors"
- "fmt"
- "strings"
- "time"
- )
- // errStop 内部哨兵: 回调拿它请求"提前结束扫描"(Limit 生效), 由 Run 吞掉,
- // 不会当成失败往外抛。
- var errStop = errors.New("migrate: 提前结束扫描")
- // Committer 目标端可选实现: 每张表写完调用一次, 让引擎把这一批落盘。
- // VRTX 实现了它 (千万行的库不能等最后 Close 才提交)。
- type Committer interface {
- Commit() error
- }
- // Run 把 src 的表结构 + 数据 + 索引 + 主键搬到 dst。
- //
- // VRTX → SQLite / MySQL 导出
- // SQLite / MySQL → VRTX 导入
- // VRTX → VRTX 整库拷贝 / 压实
- //
- // 任意方向共用这一条路径: src.Schema 给出中间表示, src.Scan 推流, dst.CreateTable
- // 建结构 (含索引/主键), dst.Open 拿批量写入器。opts 传 nil 等价于 DefaultOptions()。
- //
- // 出错时返回的部分报告仍可用 (已完成的表照常记着), 方便断点续迁 —— 重跑时带
- // 同样的 opts 但 Recreate=false, 已迁完的表会走"复用已有表 + 追加", 会重复插数据,
- // 所以务实用 Tables 只点名没迁完的表。
- //
- // 开关 ContinueOnError 决定"一张表出问题要不要连坐整趟": 打开时失败的表记进
- // 报告的 TableReport.Error 并继续迁后面的表, 最后 Run 用聚合错误返回失败清单;
- // 关闭时(默认)第一个错误就中止 —— 中止的那张表同样带着 Error, 只是不再往下走。
- func Run(src Source, dst Sink, opts *Options) (*Report, error) {
- if src == nil || dst == nil {
- return nil, errors.New("migrate: 源与目标都需要提供")
- }
- o := opts.normalized()
- if src.Identity() == dst.Identity() {
- return nil, fmt.Errorf("migrate: 源与目标是同一个库 (%s), 拒绝自我迁移", src.Identity())
- }
- started := time.Now()
- rep := &Report{From: src.Kind(), To: dst.Kind()}
- defer func() { rep.Elapsed = time.Since(started) }()
- plan, skipped, err := planTables(src, o)
- if err != nil {
- return rep, err
- }
- rep.Tables = append(rep.Tables, skipped...)
- if len(plan) == 0 && len(skipped) == 0 {
- return rep, nil
- }
- var failed []error
- for i, name := range plan {
- // 先报"开始": 表一多、或头一张就是大表时, 只有"完成"行的日志会让人
- // 以为卡住了, 分不清是在跑还是死锁。
- o.vlogf("[migrate] 表 %s (%d/%d): 开始", name, i+1, len(plan))
- tr, err := runTable(src, dst, name, o)
- if err != nil {
- // 原因先落进报告再说 —— 跳过还是中止都是后话, 但"为什么失败"丢了就
- // 只剩一句"失败", 界面和报告都无从解释。
- tr.Error = err.Error()
- }
- if err != nil && o.ContinueOnError {
- // 单表失败不拖垮整趟: 记进报告接着迁后面的表, 全部跑完再统一报错。
- failed = append(failed, err)
- o.logf("[migrate] 表 %s 失败, 跳过继续: %v", name, err)
- }
- rep.Tables = append(rep.Tables, tr)
- rep.Rows += tr.Rows
- if err != nil && !o.ContinueOnError {
- return rep, err
- }
- }
- if len(failed) > 0 {
- return rep, fmt.Errorf("有 %d 张表没能迁完 (其余已照常迁完): %w", len(failed), errors.Join(failed...))
- }
- return rep, nil
- }
- // planTables 决定迁哪些表。返回 (待迁清单, 直接标记为跳过的报告)。
- func planTables(src Source, o *Options) ([]string, []TableReport, error) {
- names, err := src.Tables()
- if err != nil {
- return nil, nil, fmt.Errorf("列出源库的表: %w", err)
- }
- avail := make(map[string]bool, len(names))
- for _, n := range names {
- avail[n] = true
- }
- var (
- plan []string
- skipped []TableReport
- )
- if len(o.Tables) == 0 {
- for _, n := range names {
- // 保真表是迁移的"随身行李", 不是业务表: 不带 __vrtx_meta 去 VRTX,
- // 除非调用方点名要它。
- if n == MetaTable {
- continue
- }
- plan = append(plan, n)
- }
- return plan, nil, nil
- }
- for _, want := range o.Tables {
- if !avail[want] {
- skipped = append(skipped, TableReport{Table: want, Skipped: true, Note: "源库没有这张表"})
- continue
- }
- plan = append(plan, want)
- }
- return plan, skipped, nil
- }
- // progressEvery 进行中进度的上报间隔: 太密会把日志刷没, 太疏又看不出"在动"。
- const progressEvery = 2 * time.Second
- // abortWriter 出错时收掉写入器。
- //
- // 走 Discarder 而不是 Close: Close 是【成功语义】(SQL 侧 flush 剩余缓冲 + Commit),
- // 用它收尾等于把缓冲区里那批行当成功提交。扫描失败 / flush 失败的调用方都带着错误
- // 返回, 库里却躺了半张表 —— 这种"报了错但还是写进去了"最难查。
- // 没实现 Discarder 的写入器 (VRTX) 退回 Close: 它的 Close 不提交, 提交由 Committer
- // 在整表成功后单独调用, 因此没有这个问题。
- func abortWriter(w RowWriter) {
- if d, ok := w.(Discarder); ok {
- _ = d.Discard()
- return
- }
- _ = w.Close()
- }
- // runTable 迁一张表: 读结构 → (必要时) 建表 → 推流写入 → 提交。
- // 第二个返回值是这张表的报告 (即使出错也带着已写行数, 便于定位)。
- func runTable(src Source, dst Sink, name string, o *Options) (TableReport, error) {
- begin := time.Now()
- tr := TableReport{Table: name}
- sc, err := src.Schema(name)
- if err != nil {
- return tr, fmt.Errorf("读表 %s 的结构: %w", name, err)
- }
- sc = sc.Clone() // 迁移会往 schema 里补列, 不能污染源端那份
- // 宽度采样三态分流 (见 textwidth.go): 建目标表之前把两类源列按源端采样的实际最大
- // 长度分档 —— "源声明 TEXT 家族"的动态列够窄的落定长档、中间的保持动态列、超单值
- // 上限的落 Blob 链式列; 声明长度的字符列同一句采样, 实际数据超当前档宽的升档。
- // 先于 prepareForVRTX / fitRowWidthForVRTX: 退化主键列要不要补索引、
- // 行宽闸门要不要把采样得过宽的列降回动态列, 都该看到采样后的最终列宽。DryRun 不
- // 碰数据, 连采样也不做。
- textWidthNote := ""
- if dst.Kind() == KindVRTX && o.TextWidthScan && !o.DryRun {
- textWidthNote = scanTextWidths(src, sc, o)
- }
- if dst.Kind() == KindVRTX {
- if err := prepareForVRTX(sc, o); err != nil {
- return tr, err
- }
- // 结构闸门必须在建表【之前】: 源库的结构能力比 VRTX 宽 (MySQL 1017 列 / 表名 64 字节 /
- // 复合主键…), 不先查一遍就会在建表时才炸, 报错还看不出是"源库比目标库宽"
- // (见 validateForVRTX)。放在 prepareForVRTX 之后是因为补出的 _id 列也算列数。
- if err := validateForVRTX(sc); err != nil {
- return tr, err
- }
- // 行宽闸门 (同样必须在建表之前, 且要在列清单定形之后): 源库那张表可能宽到 VRTX
- // 一页装不下两行 —— 那是建出来之后【每一行】都写不进去的死局, 不能等写数据才发现。
- // 能自动救的就地救 (宽度 > 256 字节的定长字符串列 → 动态列), 救不了才拒绝,
- // 见 fitRowWidthForVRTX 的文件头。
- demoted, err := fitRowWidthForVRTX(sc)
- if err != nil {
- return tr, err
- }
- if demoted > 0 {
- o.vlogf("[migrate] 表 %s 行宽超限: 已把 %d 个宽列降为动态列", sc.Name, demoted)
- }
- }
- tr.Columns = len(sc.Columns)
- tr.Indexes = len(sc.Indexes)
- if sc.PKColumn != "" {
- tr.Indexes++ // 主键索引也是一条索引
- }
- if o.DryRun {
- tr.Note = "试跑"
- o.vlogf("[migrate] %s → %s 表 %s: %d 列 %d 索引",
- src.Kind(), dst.Kind(), name, tr.Columns, tr.Indexes)
- tr.Elapsed = time.Since(begin)
- return tr, nil
- }
- exists, err := dst.Exists(name)
- if err != nil {
- return tr, fmt.Errorf("检查目标表 %s: %w", name, err)
- }
- if !exists && !o.Create {
- return tr, fmt.Errorf("目标库没有表 %s, 且未开启建表 (Options.Create)", name)
- }
- if exists && o.Recreate {
- o.vlogf("[migrate] 目标表 %s 已存在, 按 Recreate 重建", name)
- }
- if err := dst.CreateTable(sc, o.Recreate); err != nil {
- return tr, err
- }
- if len(sc.SkippedIndexes) > 0 {
- // 跳过的索引必须报出来: 静默丢索引等于悄悄改了结构。
- tr.Note = appendNote(tr.Note, "跳过索引 "+strings.Join(sc.SkippedIndexes, "; "))
- }
- if sc.PKNote != "" {
- // 源表的主键没能原样承载 (复合主键)。这是"结构变了", 比丢一条索引更要紧,
- // 单独报一句而不是塞进别的列表里。
- tr.Note = appendNote(tr.Note, sc.PKNote)
- }
- if len(sc.DegradedColumns) > 0 {
- // 类型从定点退成浮点、主键降级成普通列这类: 类型都换了, 存进去的东西语义不同。
- // 与容量封顶分开报 —— 那个是"上限变小, 类型没变"。
- tr.Note = appendNote(tr.Note, "降级 "+strings.Join(sc.DegradedColumns, "; "))
- }
- if len(sc.CappedColumns) > 0 {
- // 同理: 源库声明了 60000 字节的列到这里只能装 types.MaxStringSize, 不报等于
- // 悄悄把容量改小。超限只影响"这一列的上限比声明的小", 数据照迁 (真有大值会在
- // 写入时报出列名), 所以是报告而不是拒绝整张表。
- tr.Note = appendNote(tr.Note, "容量封顶 "+strings.Join(sc.CappedColumns, "; "))
- }
- if textWidthNote != "" {
- // TEXT 采样定宽的结果 (哪些列定到了哪一档 / 哪些列超宽保持动态): 定宽会约束
- // 未来写入, 必须让用户在报告里看得见。
- tr.Note = appendNote(tr.Note, textWidthNote)
- }
- if o.SchemaOnly {
- // 两阶段迁移的第一步: 到这儿结构就算立好了, 数据一行不碰。
- tr.Note = appendNote(tr.Note, "仅结构")
- tr.Elapsed = time.Since(begin)
- o.vlogf("[migrate] %s 表 %s: 结构就绪 (%d 列 %d 索引)",
- dst.Kind(), name, tr.Columns, tr.Indexes)
- return tr, nil
- }
- writer, err := dst.Open(name, sc)
- if err != nil {
- return tr, err
- }
- var (
- written uint64
- batch = make([]Row, 0, o.Batch)
- lastRep = time.Now()
- )
- flush := func() error {
- if len(batch) == 0 {
- return nil
- }
- if err := writer.Write(batch); err != nil {
- return fmt.Errorf("写入表 %s: %w", name, err)
- }
- written += uint64(len(batch))
- batch = batch[:0]
- if time.Since(lastRep) >= progressEvery {
- lastRep = time.Now()
- o.progress(name, written, time.Since(begin))
- }
- return nil
- }
- scanErr := src.Scan(name, sc, func(r Row) error {
- if o.Limit > 0 && written+uint64(len(batch)) >= o.Limit {
- return errStop
- }
- batch = append(batch, r)
- if o.EachRow != nil {
- o.EachRow(name, written+uint64(len(batch)), r)
- }
- if len(batch) >= o.Batch {
- return flush()
- }
- return nil
- })
- if scanErr != nil && !errors.Is(scanErr, errStop) {
- abortWriter(writer)
- tr.Rows = written
- return tr, fmt.Errorf("扫描表 %s: %w", name, scanErr)
- }
- if err := flush(); err != nil {
- abortWriter(writer)
- tr.Rows = written
- return tr, err
- }
- if err := writer.Close(); err != nil {
- tr.Rows = written
- return tr, fmt.Errorf("收尾表 %s: %w", name, err)
- }
- // 每张表落一次盘: 中途出错前面迁完的不会白费。
- if c, ok := dst.(Committer); ok {
- if err := c.Commit(); err != nil {
- tr.Rows = written
- return tr, fmt.Errorf("提交表 %s: %w", name, err)
- }
- }
- tr.Rows = written
- tr.Elapsed = time.Since(begin)
- if o.Limit > 0 && written >= o.Limit {
- tr.Note = appendNote(tr.Note, fmt.Sprintf("截断到 %d 行", o.Limit))
- }
- o.vlogf("[migrate] %s → %s 表 %s: %d 行 %d 列 %d 索引 (%s)",
- src.Kind(), dst.Kind(), name, tr.Rows, tr.Columns, tr.Indexes, tr.Elapsed.Round(time.Millisecond))
- return tr, nil
- }
- // appendNote 往报告备注里续一条 (分号分隔), 已有内容时追加而不是覆盖。
- func appendNote(note, add string) string {
- if note == "" {
- return add
- }
- return note + "; " + add
- }
- // prepareForVRTX 把一张(可能来自 SQL 的) schema 收拾成 VRTX 能建表的样子。
- //
- // VRTX 的行数据寄存在"行存储索引"(主键/时序)的条目里, 而这个索引的键必须能承载
- // 行号 —— 落到 SQL 上就是"主键只能是 8 字节整数列"。所以:
- // - 行存储索引的键列已由 __vrtx_meta 指明 (时序表) → 不用动;
- // - 主键已经是 uint64 列 → 什么都不用做;
- // - 主键是字符串/其它宽度 → 补一个 _id 列承载行号, 原主键列退化成普通列
- // (顺手给它建个 B+ 树索引, 找回"按原主键查"的能力);
- // - 压根没有主键 → 只有 AutoPK 打开才补 _id, 否则报错 (VRTX 不接受没有行主键的表)。
- //
- // 后两条都是【改结构】, 所以会往 sc.DegradedColumns 记一笔由 runTable 报给用户;
- // 复合主键 (源表有主键但承载不了) 则记在 sc.PKNote 上, 报错时也用它说准话。
- func prepareForVRTX(sc *TableSchema, o *Options) error {
- if len(sc.RowStoreColumns) > 0 {
- // 时序表: 行存储索引建在时间列上 (RowStore), 行号由引擎分配, 不占列。
- return nil
- }
- if sc.PKColumn != "" {
- // 已有的主键列若是 8 字节整数, 钉到 uint64 档即可直接承载行号 (SQL 的整数会先被
- // 收编到有符号的 Int64 档, 见 rowStorePKType); 窄列/非整数列才需要补 _id。
- if pinRowStorePK(sc, sc.PKColumn) {
- return nil
- }
- } else if !o.AutoPK {
- if sc.PKNote != "" {
- // 源表【有】主键, 只是多列 —— 报"没有主键"是假消息, 用户会去源库找不存在的问题。
- return fmt.Errorf("表 %s: %s —— 请改用单列 8 字节整数主键, "+
- "或开 AutoPK 让它自动补 %s 列", sc.Name, sc.PKNote, "_id")
- }
- return fmt.Errorf("表 %s 没有主键, 目标 VRTX 的表必须有行主键列 —— "+
- "请给源表加 8 字节整数主键, 或开 AutoPK 让它自动补 %s 列", sc.Name, "_id")
- }
- origPK := sc.PKColumn
- idName := freeColumnName(sc, "_id")
- sc.Columns = append(sc.Columns, Column{Name: idName, Type: types.FieldType_Uint64})
- sc.MarkSynthetic(idName)
- sc.PKColumn = idName
- sc.PKIndexType = page.IndexTypePrimary
- sc.PKIndexName = "" // 让 VRTX 侧按默认 pk_<表名> 命名
- if origPK == "" {
- return nil
- }
- // 原主键列不再是行主键, 但"按它查"是源库的既有用法, 用二级索引续上。
- ensureColumnIndex(sc, origPK)
- // 记下源库的主键列名: 行号已经换成引擎分配的 _id, 只有这个列名能把写失败的行报回源库
- // 认得的身份 (见 vrtxWriter.rowTag 与 TableSchema.SourcePKColumn)。
- sc.SourcePKColumn = origPK
- // 这一步是【约束降级】: 源库的主键唯一性由主键约束强制, 而补出来的二级索引不是唯一
- // 索引。静默丢掉一个约束比报错更糟 —— 用户会以为主键还在, 继续依赖它。
- sc.DegradedColumns = append(sc.DegradedColumns, fmt.Sprintf(
- "主键 %s 承载不了行号 (不是 8 字节整数列): 已改用引擎分配的 %s 列, "+
- "原主键列降为普通列 + 普通索引 (源库的主键唯一性不再强制)", origPK, idName))
- return nil
- }
- // ensureColumnIndex 给某一列补一个 B+ 树二级索引 (已有同列索引则跳过)。
- //
- // 主键列退化成普通列时用它兜底: 少了主键的"按列查"能力, 至少让查询还能走索引。
- //
- // 动态列 / blob 列不补: 它们的行内只有引用 (堆页号 / 链引用), 按值查不到, 建了也会被
- // core.CreateIndex 拒 (ErrIndexDynamicField / ErrIndexBlobField) —— 与其让整张表的迁移
- // 因此失败, 不如干脆不补这一个索引。
- func ensureColumnIndex(s *TableSchema, col string) {
- if i, ok := s.ColumnIdx(col); ok {
- if c := s.Columns[i]; c.IsDynamic() || c.Type == types.FieldType_Blob {
- return
- }
- }
- for _, idx := range s.Indexes {
- if idx.SameColumns([]string{col}) {
- return
- }
- }
- s.Indexes = append(s.Indexes, IndexSchema{
- Name: indexNameFor(s.Name, col),
- Type: page.IndexTypeBTree,
- Columns: []string{col},
- })
- }
- // indexNameFor 给"自动补出来的索引"起个不会超长的名字。
- // VRTX 索引名有长度上限, 表名本身就可能占掉大半, 因此超长时退化成短名。
- func indexNameFor(table, col string) string {
- name := "idx_" + table + "_" + col
- if len(name) <= core.IndexNameSize {
- return name
- }
- // 超长时退化成短名: 保住"能建出来"比名字好看重要。
- sum := uint64(1469598103934665603) // FNV-1a 偏移
- for i := 0; i < len(name); i++ {
- sum ^= uint64(name[i])
- sum *= 1099511628211
- }
- short := fmt.Sprintf("idx_%06x", sum&0xFFFFFF)
- if len(short) <= core.IndexNameSize {
- return short
- }
- return short[:core.IndexNameSize]
- }
- // ============================ 便捷入口 ============================
- // ParseDSN 把命令行参数翻译成一个 Endpoint (既能当源也能当目标)。
- //
- // 前缀决定库的种类, 后面整段就是它的标识:
- //
- // vrtx:/data/app.db
- // sqlite:/data/app.db 也可以是 file:... URI
- // mysql:root:pw@tcp(127.0.0.1:3306)/db?charset=utf8mb4
- //
- // 返回的对象已经打开, 调用方负责 Close。
- func ParseDSN(dsn string, opts *Options) (*Endpoint, error) {
- kind, rest, ok := strings.Cut(dsn, ":")
- if !ok {
- return nil, fmt.Errorf("DSN %q 缺少前缀, 应形如 vrtx:/path 或 sqlite:/path 或 mysql:user:pw@tcp(host)/db", dsn)
- }
- switch Kind(strings.ToLower(kind)) {
- case KindVRTX:
- v, err := OpenVRTX(rest, opts)
- if err != nil {
- return nil, err
- }
- return &Endpoint{Kind: KindVRTX, Src: v, Dst: v}, nil
- case KindSQLite:
- s, err := OpenSQLite(rest, opts)
- if err != nil {
- return nil, err
- }
- return &Endpoint{Kind: KindSQLite, Src: s, Dst: s}, nil
- case KindMySQL:
- s, err := OpenMySQL(rest, opts)
- if err != nil {
- return nil, err
- }
- return &Endpoint{Kind: KindMySQL, Src: s, Dst: s}, nil
- }
- return nil, fmt.Errorf("DSN %q 的前缀 %q 不是 vrtx/sqlite/mysql", dsn, kind)
- }
- // Endpoint 解析出来的一个库: 同时能当源和目标用。
- // 两个字段指向同一个底层对象, 只是按接口名区分, 让调用点读起来清楚。
- type Endpoint struct {
- Kind Kind
- Src Source
- Dst Sink
- }
- // Identity 该库的唯一标识 (路径 / DSN)。
- func (e *Endpoint) Identity() string {
- if e.Src != nil {
- return e.Src.Identity()
- }
- if e.Dst != nil {
- return e.Dst.Identity()
- }
- return ""
- }
- // Close 关掉底层连接。
- func (e *Endpoint) Close() error {
- if e.Dst != nil {
- return e.Dst.Close()
- }
- if e.Src != nil {
- return e.Src.Close()
- }
- return nil
- }
|