ソースを参照

fix(migrate): 声明长度字符列采样升档 — SQLite 不强制执行声明长度的坑

DockerManager 实测: sys_ssl.cert 声明 VARCHAR(2048), SQLite 不强制,
实际值 4803 字节 → 按声明建 2048 定长档 → 写入报'超过定长列容量'.
MySQL 强制声明长度恒安全, 坑专属 SQLite 系.

scanTextWidths 第二路: 声明 CHAR/VARCHAR(n) 列 (定长档或动态列) 并入同
一句 per-table MAX(LENGTH) 采样 — 实际最大 ≤ 档宽不动 (声明兑现);
超档按三态升档: 升 2 次幂定长档 (≤2048) / 落动态列 (≤16360) / 落 Blob
链式列 (>16360), note '列 %s 声明 %d 字节但实际最大 %d 字节, 升档 %s'.
顺带修了声明 >2048 落动态列后超 16360 写入必败的同类坑 (VARCHAR(8192)
存 20000 字节 → Blob)。采样失败不拦迁移原则不变。
测试: 4803B→动态列往返 / 9000B→动态列 / 20000B→Blob / ≤声明不动 /
报告 note / 双路 (引擎直读 + 迁回 sqlite) 逐字节对照。
yl32940 3 週間 前
親
コミット
771b4a2698
5 ファイル変更、391 行追加、44 行削除
  1. 4 3
      migrate/run.go
  2. 3 1
      migrate/sqltype.go
  3. 132 31
      migrate/textwidth.go
  4. 240 0
      migrate/textwidth_test.go
  5. 12 9
      migrate/types.go

+ 4 - 3
migrate/run.go

@@ -152,9 +152,10 @@ func runTable(src Source, dst Sink, name string, o *Options) (TableReport, error
 	}
 	sc = sc.Clone() // 迁移会往 schema 里补列, 不能污染源端那份
 
-	// TEXT 采样三态分流 (见 textwidth.go): 建目标表之前把"源声明 TEXT 家族"的动态列按
-	// 源端采样的实际最大长度分档 —— 够窄的落定长档, 中间的保持动态列, 超单值上限的落
-	// Blob 链式列。先于 prepareForVRTX / fitRowWidthForVRTX: 退化主键列要不要补索引、
+	// 宽度采样三态分流 (见 textwidth.go): 建目标表之前把两类源列按源端采样的实际最大
+	// 长度分档 —— "源声明 TEXT 家族"的动态列够窄的落定长档、中间的保持动态列、超单值
+	// 上限的落 Blob 链式列; 声明长度的字符列同一句采样, 实际数据超当前档宽的升档。
+	// 先于 prepareForVRTX / fitRowWidthForVRTX: 退化主键列要不要补索引、
 	// 行宽闸门要不要把采样得过宽的列降回动态列, 都该看到采样后的最终列宽。DryRun 不
 	// 碰数据, 连采样也不做。
 	textWidthNote := ""

+ 3 - 1
migrate/sqltype.go

@@ -21,7 +21,9 @@ import (
 //	BOOLEAN / BOOL             Bool
 //	YEAR                       Year      (2 字节年份, 0..32767)
 //	UUID                       UUID      (16 字节 RFC4122)
-//	CHAR(n) / VARCHAR(n)       按字节容量取最近的宽度档 (16..2048, 超过则 dynamic)
+//	CHAR(n) / VARCHAR(n)       按字节容量取最近的宽度档 (16..2048, 超过则 dynamic;
+//	                           SQLite 不强制声明长度, 建表前采样发现实际数据超档时
+//	                           按三态升档, 见 textwidth.go 的 declaredCharWidth 一路)
 //	TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/LONGTEXT/CLOB/NCLOB)
 //	                           采样三态分流 (默认, 见 Options.TextWidthScan): 建表前采样
 //	                           实际最大长度, ≤2048 向上取 2 的幂 (32..2048) 落 VARCHAR 档

+ 132 - 31
migrate/textwidth.go

@@ -1,27 +1,37 @@
 package migrate
 
-// ============================ TEXT 列采样定宽 ============================
+// ============================ 文本列采样定宽 / 字符列超档升档 ============================
 //
-// 【问题】inferSQLType 把 TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/LONGTEXT/CLOB/NCLOB)
+// 【问题 1】inferSQLType 把 TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/LONGTEXT/CLOB/NCLOB)
 // 一律落成 FieldType_Dynamicbyte: 动态列不能建索引 (引擎直接拒, 见 vrtx.go 建索引
 // 那段的 ErrIndexDynamicField 处理), 读要走堆 —— 而真实库里很多 TEXT 列装的就是
 // 几十几百字节的短文本 (备注 / 状态 / 标签), 白白失去索引能力。
 //
+// 【问题 2】带声明长度的 CHAR/VARCHAR(n) 按 bucketColumn 取定长档, 但 SQLite 不强制
+// 声明长度 (MySQL 强制, 数据不可能超档) —— DockerManager 实测: sqlite 的 sys_ssl.cert
+// 声明 VARCHAR(2048), 实际值 4803 字节, 按声明建 2048 定长档后写入即报"值 4803 字节
+// 超过定长列容量 2048"。
+//
 // 【处理】Options.TextWidthScan (默认开) 让迁移在建目标表之前先【采样】: 对源表跑一次
-// SELECT MAX(LENGTH(col)) (同表多列合一句, 每表一次), 按采样到的实际最大长度三态分流:
-// 不超过 types.MaxInlineColumnWidth (2048) 的向上取 2 的幂 (下限 textWidthMin) 落成
-// 定长 VARCHAR 档 (可索引、读不走堆); 超过 2048 但不超过单值上限 types.MaxStringSize
-// (16360) 的保持动态列; 超过单值上限的落 Blob 链式列 (行内 8B 引用 + 链页, 上限约
-// 4GB) —— 动态列按单块上限拒写, 保持动态列会在【写入时】整表失败, 落 Blob 才能迁得动。
+// SELECT MAX(LENGTH(col)) (两路候选 —— TEXT 家族动态列 + 声明长度字符列 —— 同表合
+// 一句, 每表一次), 按采样到的实际最大长度三态分流:
+//   - 不超过 types.MaxInlineColumnWidth (2048) 的向上取 2 的幂 (下限 textWidthMin)
+//     落成定长 VARCHAR 档 (可索引、读不走堆);
+//   - 超过 2048 但不超过单值上限 types.MaxStringSize (16360) 的保持/升为动态列;
+//   - 超过单值上限的落 Blob 链式列 (行内 8B 引用 + 链页, 上限约 4GB) —— 动态列按
+//     单块上限拒写, 保持动态列会在【写入时】整表失败, 落 Blob 才能迁得动。
+//
+// 声明长度字符列 (declaredCharWidth 那一路) 多一层"当前档宽"对照: 采样值没超当前
+// 档宽就不动 (声明兑现), 超了才按上面三态升档 (见 scanCharWidthUpgrade)。
 //
 // 【权衡】采样反映的是【当下】的数据: 定宽列会约束未来写入 (超宽即报错), 2 次幂圆整
 // 最多浪费一倍行内空间; 且采样按表做一次, 行数大时是一次额外的全表扫描。在意这些就
-// 关掉开关, 退回"TEXT 全落动态列"的旧行为。
+// 关掉开关, 退回"TEXT 全落动态列 / 字符列按声明取档"的旧行为。
 //
 // 【为什么判定看源声明 (Column.Decl) 而不是看目标列类型】目标是动态列的不止 TEXT:
-// JSON / TIME / 未知的兜底类型也都落动态列, 而它们不该被采样改宽 —— 采样定宽只对
-// "源库明确说了这是无长度文本"的列生效。带长度的 CHAR/VARCHAR 按声明容量取档
-// (bucketColumn), 也不经过这里。
+// JSON / TIME / 未知的兜底类型也都落动态列, 而它们不该被采样改宽 —— 采样只对"源库
+// 明确说了这是文本"的列生效: TEXT 家族看 isTextFamilyDecl, 声明长度字符列看
+// declaredCharWidth, 两者都从声明原文判定。
 //
 // 【为什么用可选接口而不是给 Source 加方法】采样需要 SQL 查询通道, 只有 *SQL 有;
 // VRTX 源 (VRTX → VRTX 拷贝) 没有这一层, 也不会声明 TEXT 列。给 Source 接口加方法
@@ -53,11 +63,12 @@ type TextWidthSampler interface {
 const textWidthMin = 32
 
 // isTextFamilyDecl 源声明是否 TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/LONGTEXT/CLOB/NCLOB)。
-// 用 splitTypeArgs 取基类型名: JSON / BLOB / BINARY / VARBINARY 与带长度的 CHAR /
-// VARCHAR 都不在里面 —— 它们各有各的归宿 (JSON 保持动态列, 二进制落 Blob 类型,
-// 声明长度的字符列按声明容量取档), 不参与采样。带长度的 "TEXT(65535)" 声明
-// (MySQL 的 information_schema 会给 TEXT 报一个类型自身上界) 同样算 TEXT 家族 —
-// 那是类型的上界, 不是用户声明的宽度。
+// 用 splitTypeArgs 取基类型名: JSON / BLOB / BINARY / VARBINARY 都不在里面 —— 它们
+// 各有各的归宿 (JSON 保持动态列, 二进制落 Blob 类型), 不参与采样。带长度的
+// "TEXT(65535)" 声明 (MySQL 的 information_schema 会给 TEXT 报一个类型自身上界)
+// 同样算 TEXT 家族 —— 那是类型的上界, 不是用户声明的宽度。带长度的 CHAR/VARCHAR
+// 也不在这里: 那一路由 declaredCharWidth 单独判定 (取档规则不同, 见
+// scanCharWidthUpgrade)。
 func isTextFamilyDecl(decl string) bool {
 	base, _ := splitTypeArgs(strings.ToUpper(strings.TrimSpace(decl)))
 	switch base {
@@ -67,6 +78,25 @@ func isTextFamilyDecl(decl string) bool {
 	return false
 }
 
+// declaredCharWidth 源声明为【带长度的字符列】时返回声明字节数 args[0], 其余声明
+// (无长度 / 非 CHAR/VARCHAR 家族) 返回 0。基类型名单与 inferSQLType 的字符列分支
+// 同源: 这族声明按 bucketColumn 取定长档, 但 SQLite 不强制声明长度, 实际数据可能
+// 超档 (DockerManager 实测 sys_ssl.cert VARCHAR(2048) 存 4803 字节) —— 声明字节数
+// 就是"承诺兑现"的对照线, 采样超了它升档 (见 scanCharWidthUpgrade)。MySQL 侧读的
+// 是 CHARACTER_OCTET_LENGTH (字节数), 同一函数兼容两种来源。
+func declaredCharWidth(decl string) int {
+	base, args := splitTypeArgs(strings.ToUpper(strings.TrimSpace(decl)))
+	switch base {
+	case "CHAR", "CHARACTER", "VARCHAR", "NCHAR", "NVARCHAR", "VARCHAR2":
+	default:
+		return 0
+	}
+	if len(args) == 0 {
+		return 0
+	}
+	return args[0]
+}
+
 // textWidthClass 采样最大长度的三态归宿。
 type textWidthClass int
 
@@ -104,28 +134,31 @@ func textWidthCode(maxLen int64) (code byte, width int, class textWidthClass) {
 	return types.WidthCodeFor(w), w, widthToFixed
 }
 
-// scanTextWidths TEXT 采样定宽主逻辑, 建目标表之前调用: 找出 schema 里"源声明
-// TEXT 家族、当前推断为动态列"的列, 让源端按表采样一次, 按三态归宿改类型 (见
-// textWidthCode): 够窄的改落 FieldType_String + 宽度码 (可索引), 中间的保持动态列,
-// 超过动态列单值上限的落 FieldType_Blob (链式存储, 否则写入时必败)。表结构就地修改,
-// 返回写进迁移报告的说明 (无命中列时返回 "")。
+// scanTextWidths 宽度采样主逻辑, 建目标表之前调用。两路候选 (见
+// isTextWidthCandidate) 合进同一次 per-table 采样:
+//
+//   - TEXT 家族动态列: 按三态归宿改类型 (见 textWidthCode) —— 够窄的改落
+//     FieldType_String + 宽度码 (可索引), 中间的保持动态列, 超过动态列单值上限的落
+//     FieldType_Blob (链式存储, 否则写入时必败);
+//   - 声明长度字符列: 实际数据超当前档宽的按同一套三态升档 (见 scanCharWidthUpgrade),
+//     没超档宽不动 (声明兑现)。
+//
+// 表结构就地修改, 返回写进迁移报告的说明 (无命中列时返回 "")。
 //
 // 放在 prepareForVRTX / fitRowWidthForVRTX 【之前】: 原主键列退化成普通列时要不要
 // 补索引 (ensureColumnIndex 只给非动态列补), 以及行宽闸门要不要把采样得过宽的列
 // 再降回动态列, 都该看到采样后的最终列宽。
 //
-// 采样失败不判死: 记日志并退回旧行为 (全落动态列), 由返回值在报告里说明 —— 采样
+// 采样失败不判死: 记日志并退回旧行为 (保持推断类型), 由返回值在报告里说明 —— 采样
 // 是"让列更好用"的优化, 不该把以前能迁的表拦下来。
 func scanTextWidths(src Source, sc *TableSchema, o *Options) string {
 	var cols []string
 	for i := range sc.Columns {
-		c := &sc.Columns[i]
-		if c.Type != types.FieldType_Dynamicbyte || !isTextFamilyDecl(c.Decl) {
-			continue
+		if isTextWidthCandidate(&sc.Columns[i]) {
+			cols = append(cols, sc.Columns[i].Name)
 		}
-		cols = append(cols, c.Name)
 	}
-	// 不能提前 return: NUMERIC 域探测 (scanNumericDomain) 在 TEXT 列缺席时仍要跑。
+	// 不能提前 return: NUMERIC 域探测 (scanNumericDomain) 在采样列缺席时仍要跑。
 	if len(cols) == 0 {
 		return scanNumericDomain(src, sc, o)
 	}
@@ -135,16 +168,20 @@ func scanTextWidths(src Source, sc *TableSchema, o *Options) string {
 	}
 	maxLens, err := sampler.TextWidths(sc.Name, cols)
 	if err != nil {
-		o.logf("[migrate] 表 %s 的 TEXT 采样失败, 保持动态列 (旧行为): %v", sc.Name, err)
-		return appendNote(fmt.Sprintf("text 采样失败保持动态列 (%v)", err), scanNumericDomain(src, sc, o))
+		o.logf("[migrate] 表 %s 的宽度采样失败, 保持推断类型 (旧行为): %v", sc.Name, err)
+		return appendNote(fmt.Sprintf("text 采样失败保持推断类型 (%v)", err), scanNumericDomain(src, sc, o))
 	}
-	var fixed, kept, blobbed []string
+	var fixed, kept, blobbed, upgraded []string
 	for i := range sc.Columns {
 		c := &sc.Columns[i]
+		maxLen := maxLens[c.Name] // 缺项视为 0, 全 NULL 的列落最窄档
 		if c.Type != types.FieldType_Dynamicbyte || !isTextFamilyDecl(c.Decl) {
+			// TEXT 家族以外走字符列那一路 (非候选列在里面自行排除)。
+			if up := scanCharWidthUpgrade(c, maxLen, o); up != "" {
+				upgraded = append(upgraded, up)
+			}
 			continue
 		}
-		maxLen := maxLens[c.Name] // 缺项视为 0, 全 NULL 的列落最窄档
 		code, width, class := textWidthCode(maxLen)
 		switch class {
 		case widthToBlob:
@@ -178,9 +215,73 @@ func scanTextWidths(src Source, sc *TableSchema, o *Options) string {
 		note = appendNote(note, fmt.Sprintf("text 列超 %d 字节落 Blob 链式列 (%s)",
 			types.MaxStringSize, strings.Join(blobbed, ", ")))
 	}
+	if len(upgraded) > 0 {
+		note = appendNote(note, "声明长度字符列超档升档 ("+strings.Join(upgraded, "; ")+")")
+	}
 	return appendNote(note, scanNumericDomain(src, sc, o))
 }
 
+// isTextWidthCandidate 这一列是否参与建表前的宽度采样。两路与源声明一一对应:
+//   - TEXT 家族动态列 (isTextFamilyDecl): 采样实际最大长度, 够窄的从动态列【升】定长档;
+//   - 声明长度字符列 (declaredCharWidth): 定长档装不下实际数据时【升档】兜住。
+//
+// 其余列各有各的归宿, 不碰: JSON / TIME / 未知兜底类型保持动态列, 二进制落 Blob 类型
+// (声明多大的容量都兑现得了, v10 起 Blob 行内是 8B 链引用、单值约 4GB), 无长度字符
+// 声明按 32 字节档处理 —— 它们都没有"声明了宽度却装不下"的落差。
+func isTextWidthCandidate(c *Column) bool {
+	if c.Type == types.FieldType_Dynamicbyte && isTextFamilyDecl(c.Decl) {
+		return true
+	}
+	// 声明长度字符列: 定长档 (FieldType_String) 的核对档宽; 声明超 2048 落成动态列
+	// 的也要 —— 那种列只剩"超单值上限落 Blob"一种升档, 不采样就会在写入时整表失败
+	// (与 TEXT 家族超单值上限落 Blob 是同一个坑)。
+	return declaredCharWidth(c.Decl) > 0 &&
+		(c.Type == types.FieldType_String || c.Type == types.FieldType_Dynamicbyte)
+}
+
+// scanCharWidthUpgrade 声明长度字符列的【超档升档】: 采样 maxLen 没超当前档宽就不动
+// (声明兑现; MySQL 强制声明长度, 数据不可能超档, 恒走这条, 所以这路对 MySQL 无害);
+// 超了按 textWidthCode 三态升档 —— 能定长则升 2 次幂定长档 (上限 2048), 超 2048 落
+// 动态列, 超 types.MaxStringSize (16360) 落 Blob 链式列 (动态列按单块上限拒写, 不升
+// Blob 这类大值会在写入时让整表迁移失败)。就地修改列类型, 返回写进迁移报告的说明
+// (无升档时返回 "")。
+func scanCharWidthUpgrade(c *Column, maxLen int64, o *Options) string {
+	declared := declaredCharWidth(c.Decl)
+	if declared <= 0 ||
+		(c.Type != types.FieldType_String && c.Type != types.FieldType_Dynamicbyte) {
+		return ""
+	}
+	// 当前档宽: 定长列看宽度码对应的档位; 已是动态列的按单值上限算 (动态列的"档"
+	// 就是整个堆页, 装得下任何不超单值上限的值) —— 那种列只剩超单值上限落 Blob
+	// 这一种升档, textWidthCode 自然会给出。
+	curWidth := types.MaxStringSize
+	if c.Type == types.FieldType_String {
+		curWidth = int(types.FieldSize(c.Type, c.Param))
+	}
+	if maxLen <= int64(curWidth) {
+		return "" // 声明兑现: 实际数据装得进当前档, 不动。
+	}
+	code, width, class := textWidthCode(maxLen)
+	var target string
+	switch class {
+	case widthToBlob:
+		c.Type = types.FieldType_Blob
+		c.Param = 0
+		target = "Blob 链式列"
+	case widthToDynamic:
+		c.Type = types.FieldType_Dynamicbyte
+		c.Param = 0
+		target = "动态列"
+	default:
+		c.Type = types.FieldType_String
+		c.Param = code
+		target = fmt.Sprintf("%d 字节档", width)
+	}
+	o.vlogf("[migrate] 字符列 %s 声明 %d 字节但采样最大 %d 字节, 升档 %s",
+		c.Name, declared, maxLen, target)
+	return fmt.Sprintf("列 %s 声明 %d 字节但实际最大 %d 字节, 升档 %s", c.Name, declared, maxLen, target)
+}
+
 // isBareNumericDecl 源声明是否【裸 NUMERIC】(基类型 NUMERIC 且无 (p,s) 参数)。
 // 只有这种声明会被 inferSQLType (SQLite 分支) 收成 Bool; 它同时是数据探测的
 // 目标 — 真定点列要么带精度 NUMERIC(p,s), 要么写 DECIMAL, 都不进这里。

+ 240 - 0
migrate/textwidth_test.go

@@ -520,3 +520,243 @@ func TestTextWidthOversizeFallsToBlob(t *testing.T) {
 		t.Errorf("NULL 行迁回应是 NULL, 实际 %q", backNull.String)
 	}
 }
+
+// TestCharDeclOversizeUpgrade 声明长度的字符列"实际数据超档"的采样升档 —— 复刻
+// DockerManager 实测: SQLite 的 sys_ssl.cert 声明 VARCHAR(2048), 但 SQLite 不强制
+// 声明长度, 实际值 4803 字节; 旧行为按声明建 2048 定长档, 写入即报"值 4803 字节
+// 超过定长列容量 2048"。现在这类列与 TEXT 列合一句采样, 按 textWidthCode 三态升档:
+// cert (声明 2048 / 实际 4803) → 动态列; payload (声明 8192 / 实际 9000) → 动态列;
+// bulky (声明 8192 / 实际 20000, 超动态列单值上限) → Blob 链式列; code (声明 100 /
+// 实际 100) → 定长 128 档不动。数据逐字节往返, 报告写升档 note。
+func TestCharDeclOversizeUpgrade(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.sqlite")
+	midPath := filepath.Join(dir, "mid.db")
+	backPath := filepath.Join(dir, "back.sqlite")
+
+	cert := strings.Repeat("c", 4803)
+	payload := strings.Repeat("m", 9000)
+	bulky := strings.Repeat("h", 20000)
+
+	db, err := sql.Open("sqlite", srcPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := db.Exec(`CREATE TABLE sys_ssl (
+		id      INTEGER PRIMARY KEY,
+		cert    VARCHAR(2048),
+		payload VARCHAR(8192),
+		bulky   VARCHAR(8192),
+		code    VARCHAR(100)
+	)`); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := db.Exec(`INSERT INTO sys_ssl (id, cert, payload, bulky, code) VALUES (1, ?, ?, ?, ?)`,
+		cert, payload, bulky, strings.Repeat("k", 100)); err != nil {
+		t.Fatal(err)
+	}
+	// 第二行短值 + NULL: 升档后小值与 NULL 的读写也必须正常。
+	if _, err := db.Exec(`INSERT INTO sys_ssl (id, cert, payload, bulky, code) VALUES (2, 'ok', NULL, NULL, 'go')`); err != nil {
+		t.Fatal(err)
+	}
+	_ = db.Close()
+
+	src, err := OpenSQLite(srcPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	mid, err := OpenVRTX(midPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	rep, err := Run(src, mid, quiet())
+	if err != nil {
+		t.Fatalf("SQLite→VRTX (超档字符列应自动升档迁过去): %v", err)
+	}
+	_ = src.Close()
+	_ = mid.Close()
+
+	if rep.Rows != 2 {
+		t.Fatalf("应迁 2 行, 实际 %d", rep.Rows)
+	}
+
+	// 列类型: cert / payload 升 (保持) 动态列, bulky 超单值上限落 Blob, code 定长不动。
+	if f := vrtxColumn(t, midPath, "sys_ssl", "cert"); f.FieldType != types.FieldType_Dynamicbyte {
+		t.Errorf("列 cert (声明 2048 / 实际 4803) 应升档动态列, 实际 %v", f.FieldType)
+	}
+	if f := vrtxColumn(t, midPath, "sys_ssl", "payload"); f.FieldType != types.FieldType_Dynamicbyte {
+		t.Errorf("列 payload (声明 8192 / 实际 9000) 应为动态列, 实际 %v", f.FieldType)
+	}
+	if f := vrtxColumn(t, midPath, "sys_ssl", "bulky"); f.FieldType != types.FieldType_Blob {
+		t.Errorf("列 bulky (声明 8192 / 实际 20000, 超单值上限) 应落 Blob 链式列, 实际 %v", f.FieldType)
+	}
+	if f := vrtxColumn(t, midPath, "sys_ssl", "code"); f.FieldType != types.FieldType_String || colWidth(f) != 128 {
+		t.Errorf("列 code (声明 100 / 实际 100) 应保持定长 128 档, 实际 %v 宽 %d", f.FieldType, colWidth(f))
+	}
+
+	// 值逐字节对照 (不经 migrate 读端, 直接开引擎文件读, 免得"两端同一段代码"把
+	// 不对称的错抹平): 定长/动态列读回 []byte, Blob 列是 BlobRef 要走 BlobRead;
+	// NULL 列读侧缺项 (位图置位跳过), 缺列即 NULL。
+	vdb, err := core.Open(midPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	vtbl, ok := vdb.FindTable("sys_ssl")
+	if !ok {
+		t.Fatal("mid.db 里没有 sys_ssl")
+	}
+	got := map[uint64]map[string]string{}
+	err = vtbl.Query().ScanWithPKID(func(pkID uint64, vals []types.FieldValue) bool {
+		m := map[string]string{}
+		for _, fv := range vals {
+			name := util.BytesToString(fv.FieldName)
+			switch name {
+			case "cert", "payload", "bulky", "code":
+			default:
+				continue // id (行号列) 等其它列不在逐字节对照范围
+			}
+			if ref, isBlob := fv.Value.(types.BlobRef); isBlob {
+				data, err := vtbl.BlobRead(ref, types.CompressNone)
+				if err != nil {
+					t.Fatalf("读回 %s 的 blob 链: %v", name, err)
+				}
+				m[name] = fmt.Sprintf("bytes:%x", data)
+				continue
+			}
+			b, ok := fv.Value.([]byte)
+			if !ok {
+				t.Fatalf("列 %s 的行内值应是 []byte, 实际 %T", name, fv.Value)
+			}
+			m[name] = fmt.Sprintf("bytes:%x", b)
+		}
+		got[pkID] = m
+		return true
+	})
+	_ = vdb.Close()
+	if err != nil {
+		t.Fatal(err)
+	}
+	wantRow1 := map[string]string{
+		"cert":    fmt.Sprintf("bytes:%x", []byte(cert)),
+		"payload": fmt.Sprintf("bytes:%x", []byte(payload)),
+		"bulky":   fmt.Sprintf("bytes:%x", []byte(bulky)),
+		"code":    fmt.Sprintf("bytes:%x", []byte(strings.Repeat("k", 100))),
+	}
+	for col, want := range wantRow1 {
+		if got[1][col] != want {
+			t.Errorf("行 1 列 %s 逐字节不一致 (got %d 字节)", col, len(got[1][col]))
+		}
+	}
+	if got[2]["cert"] != fmt.Sprintf("bytes:%x", []byte("ok")) ||
+		got[2]["code"] != fmt.Sprintf("bytes:%x", []byte("go")) {
+		t.Errorf("行 2 短值行 = %v", got[2])
+	}
+	if _, ok := got[2]["payload"]; ok {
+		t.Errorf("行 2 的 payload 应是 NULL, 实际有值")
+	}
+	if _, ok := got[2]["bulky"]; ok {
+		t.Errorf("行 2 的 bulky 应是 NULL, 实际有值")
+	}
+
+	// 报告: 升档必须可见, 且只有 cert / bulky 升档 (payload 没超单值上限,
+	// code 没超档宽, 都不该出现在升档名单里)。
+	if len(rep.Tables) != 1 {
+		t.Fatalf("应有 1 张表的报告, 实际 %d", len(rep.Tables))
+	}
+	note := rep.Tables[0].Note
+	for _, want := range []string{
+		"升档",
+		"列 cert 声明 2048 字节但实际最大 4803 字节, 升档 动态列",
+		"列 bulky 声明 8192 字节但实际最大 20000 字节, 升档 Blob 链式列",
+	} {
+		if !strings.Contains(note, want) {
+			t.Errorf("报告 Note 里缺少 %q: %q", want, note)
+		}
+	}
+	if strings.Contains(note, "payload 声明") {
+		t.Errorf("payload (9000 ≤ 单值上限) 不该升档: %q", note)
+	}
+	if strings.Contains(note, "code 声明") {
+		t.Errorf("code (数据没超档) 不该升档: %q", note)
+	}
+
+	// 反向再迁一轮 (VRTX → SQLite): 升档后的列经 migrate 读端解出来建表,
+	// 内容必须逐字节回来。
+	vmid, err := OpenVRTX(midPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	back, err := OpenSQLite(backPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := Run(vmid, back, quiet()); err != nil {
+		t.Fatalf("VRTX→SQLite: %v", err)
+	}
+	_ = vmid.Close()
+	_ = back.Close()
+
+	bdb, err := sql.Open("sqlite", backPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer bdb.Close()
+	var backCert, backPayload, backBulky, backCode []byte
+	if err := bdb.QueryRow(`SELECT cert, payload, bulky, code FROM sys_ssl WHERE id = 1`).
+		Scan(&backCert, &backPayload, &backBulky, &backCode); err != nil {
+		t.Fatal(err)
+	}
+	if fmt.Sprintf("bytes:%x", backCert) != wantRow1["cert"] {
+		t.Errorf("迁回 SQLite 的 cert 逐字节不一致 (got %d 字节)", len(backCert))
+	}
+	if fmt.Sprintf("bytes:%x", backPayload) != wantRow1["payload"] {
+		t.Errorf("迁回 SQLite 的 payload 逐字节不一致 (got %d 字节)", len(backPayload))
+	}
+	if fmt.Sprintf("bytes:%x", backBulky) != wantRow1["bulky"] {
+		t.Errorf("迁回 SQLite 的 bulky 逐字节不一致 (got %d 字节)", len(backBulky))
+	}
+	if fmt.Sprintf("bytes:%x", backCode) != wantRow1["code"] {
+		t.Errorf("迁回 SQLite 的 code 逐字节不一致 (got %d 字节)", len(backCode))
+	}
+	var backShort string
+	var backNull1, backNull2 sql.NullString
+	if err := bdb.QueryRow(`SELECT cert, payload, bulky FROM sys_ssl WHERE id = 2`).
+		Scan(&backShort, &backNull1, &backNull2); err != nil {
+		t.Fatal(err)
+	}
+	if backShort != "ok" {
+		t.Errorf("短值行迁回 cert = %q", backShort)
+	}
+	if backNull1.Valid || backNull2.Valid {
+		t.Errorf("NULL 行迁回应是 NULL, 实际 %q / %q", backNull1.String, backNull2.String)
+	}
+}
+
+// TestDeclaredCharWidth 只有带长度的 CHAR/VARCHAR 家族声明返回声明字节数 —— 这是
+// 字符列采样升档那一路的入场券; TEXT 家族 / 无长度声明 / 非字符类型都返回 0,
+// 不采样。名单与 inferSQLType 的字符列分支同源。
+func TestDeclaredCharWidth(t *testing.T) {
+	for decl, want := range map[string]int{
+		"VARCHAR(2048)": 2048, // DockerManager 实测的 sys_ssl.cert
+		"varchar(100)":  100,
+		"CHAR(8)":       8,
+		"CHARACTER(16)": 16,
+		"NCHAR(4)":      4,
+		"NVARCHAR(50)":  50,
+		"VARCHAR2(32)":  32,
+		" VARCHAR(64) ": 64,
+		"VARCHAR(8192)": 8192, // 声明超 2048 落成动态列, 仍要采样 (超单值上限落 Blob)
+	} {
+		if got := declaredCharWidth(decl); got != want {
+			t.Errorf("declaredCharWidth(%q) = %d, 期望 %d", decl, got, want)
+		}
+	}
+	for _, decl := range []string{
+		"", "TEXT", "TINYTEXT", "CLOB", "BLOB", "JSON", "BINARY", "VARBINARY",
+		"VARCHAR", "CHAR", "NCHAR", "DECIMAL(18,2)", "DATETIME", "INT",
+	} {
+		if got := declaredCharWidth(decl); got != 0 {
+			t.Errorf("declaredCharWidth(%q) = %d, 期望 0 (不参与字符列采样)", decl, got)
+		}
+	}
+}

+ 12 - 9
migrate/types.go

@@ -507,18 +507,21 @@ type Options struct {
 	// 列名 / 原长 / 截后长), 用户据此回源库补那一行的内容, 而不用满表翻。文本列会退到
 	// UTF-8 字符边界再截, 不留半个汉字。
 	TruncateOversize bool
-	// TextWidthScan 目标端是 VRTX 时, 对源库声明为 TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/
-	// LONGTEXT/CLOB/NCLOB) 且无显式宽度的字符列做【采样定宽】: 建目标表前对源表跑一次
-	// SELECT MAX(LENGTH(col)) (同表多列合一句, 每表一次), 按采样到的实际最大长度三态分流:
-	// 不超过 2048 字节的向上取 2 的幂 (下限 32) 落成定长 VARCHAR 档 —— 定宽列可建索引、
-	// 读不走堆; 超过 2048 但不超过动态列单值上限 (types.MaxStringSize, 16360) 的保持
-	// 动态列; 超过单值上限的落 Blob 链式列 (行内 8B 引用 + 链页, 上限约 4GB) —— 动态列
-	// 按单块上限拒写, 不转 Blob 这类大值会在写入时让整表迁移失败。
+	// TextWidthScan 目标端是 VRTX 时, 对两类源列做【宽度采样】: 一是声明为 TEXT 家族
+	// (TEXT/TINYTEXT/MEDIUMTEXT/LONGTEXT/CLOB/NCLOB) 的文本列, 二是声明了长度的字符列
+	// (CHAR/CHARACTER/VARCHAR/NCHAR/NVARCHAR/VARCHAR2 —— SQLite 不强制声明长度, 实际
+	// 数据可能超档, 如 DockerManager 实测的 sys_ssl.cert VARCHAR(2048) 存 4803 字节)。
+	// 建目标表前对源表跑一次 SELECT MAX(LENGTH(col)) (同表多列合一句, 每表一次), 按
+	// 采样到的实际最大长度三态分流: 不超过 2048 字节的向上取 2 的幂 (下限 32) 落定长
+	// VARCHAR 档 —— 定宽列可建索引、读不走堆; 超过 2048 但不超过动态列单值上限
+	// (types.MaxStringSize, 16360) 的保持/升为动态列; 超过单值上限的落 Blob 链式列
+	// (行内 8B 引用 + 链页, 上限约 4GB) —— 动态列按单块上限拒写, 不转 Blob 这类大值
+	// 会在写入时让整表迁移失败。字符列数据没超当前档宽则不动 (声明兑现; MySQL 强制
+	// 声明长度, 恒如此)。
 	//
 	// 权衡: 采样反映的是【当下】的数据 —— 定宽列会约束未来写入 (超宽即报错), 2 次幂
 	// 圆整最多浪费一倍行内空间; 且采样按表做一次, 行数大时是一次额外的全表扫描。
-	// false 则 TEXT 家族全落动态列 (旧行为): 不约束未来写入, 但动态列不能当索引键、
-	// 读要走堆。
+	// false 则退回旧行为: TEXT 家族全落动态列, 字符列按声明容量取档。
 	TextWidthScan bool
 	// DryRun 只读结构不搬数据。
 	DryRun bool