textwidth.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. package migrate
  2. // ============================ TEXT 列采样定宽 ============================
  3. //
  4. // 【问题】inferSQLType 把 TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/LONGTEXT/CLOB/NCLOB)
  5. // 一律落成 FieldType_Dynamicbyte: 动态列不能建索引 (引擎直接拒, 见 vrtx.go 建索引
  6. // 那段的 ErrIndexDynamicField 处理), 读要走堆 —— 而真实库里很多 TEXT 列装的就是
  7. // 几十几百字节的短文本 (备注 / 状态 / 标签), 白白失去索引能力。
  8. //
  9. // 【处理】Options.TextWidthScan (默认开) 让迁移在建目标表之前先【采样】: 对源表跑一次
  10. // SELECT MAX(LENGTH(col)) (同表多列合一句, 每表一次), 按采样到的实际最大长度向上取
  11. // 2 的幂 (下限 textWidthMin, 上限 types.MaxInlineColumnWidth) 落成定长 VARCHAR 档;
  12. // 采样超过 2048 字节的列保持动态列。定宽列可建索引、读不走堆。
  13. //
  14. // 【权衡】采样反映的是【当下】的数据: 定宽列会约束未来写入 (超宽即报错), 2 次幂圆整
  15. // 最多浪费一倍行内空间; 且采样按表做一次, 行数大时是一次额外的全表扫描。在意这些就
  16. // 关掉开关, 退回"TEXT 全落动态列"的旧行为。
  17. //
  18. // 【为什么判定看源声明 (Column.Decl) 而不是看目标列类型】目标是动态列的不止 TEXT:
  19. // JSON / TIME / 未知的兜底类型也都落动态列, 而它们不该被采样改宽 —— 采样定宽只对
  20. // "源库明确说了这是无长度文本"的列生效。带长度的 CHAR/VARCHAR 按声明容量取档
  21. // (bucketColumn), 也不经过这里。
  22. //
  23. // 【为什么用可选接口而不是给 Source 加方法】采样需要 SQL 查询通道, 只有 *SQL 有;
  24. // VRTX 源 (VRTX → VRTX 拷贝) 没有这一层, 也不会声明 TEXT 列。给 Source 接口加方法
  25. // 会强迫 VRTX 实现一个它根本用不上的能力, 断言式可选接口 (TextWidthSampler) 让
  26. // 不支持采样的源端自然退回旧行为。
  27. import (
  28. "git.ycl.pub/yl32940/TsDB2/DB/core/types"
  29. "fmt"
  30. "strings"
  31. )
  32. // TextWidthSampler 源端可选能力: 建目标表前的数据采样 (只有 *SQL 有 SQL 查询通道;
  33. // VRTX 源不实现, 自然退回旧行为)。
  34. type TextWidthSampler interface {
  35. // TextWidths 返回每列的 MAX(LENGTH(col)), 全 NULL / 空表为 0。
  36. // 同表多列应一次查询取回。
  37. TextWidths(table string, cols []string) (map[string]int64, error)
  38. // NumericDomain 返回每列【域外值】的个数: 非 NULL 且取值不在 {0, 1} 的行数。
  39. // 用于裸 NUMERIC → Bool 的数据兜底 (见 scanTextWidths 的 NUMERIC 探测);
  40. // 同表多列应一次查询取回。全 NULL / 全 0/1 / 空表返回 0。
  41. NumericDomain(table string, cols []string) (map[string]int64, error)
  42. }
  43. // textWidthMin 采样定宽的档位下限 (字节)。TEXT 列哪怕当前全空也落 32 档而不是 16 —
  44. // 16 字节装不下一个像样的人名/备注, 而 32 只多付 16 字节; 上限就是行内宽度上限
  45. // types.MaxInlineColumnWidth (2048): 采样超过它只能保持动态列。
  46. const textWidthMin = 32
  47. // isTextFamilyDecl 源声明是否 TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/LONGTEXT/CLOB/NCLOB)。
  48. // 用 splitTypeArgs 取基类型名: JSON / BLOB / BINARY / VARBINARY 与带长度的 CHAR /
  49. // VARCHAR 都不在里面 —— 它们各有各的归宿 (JSON 保持动态列, 二进制落 Blob 类型,
  50. // 声明长度的字符列按声明容量取档), 不参与采样。带长度的 "TEXT(65535)" 声明
  51. // (MySQL 的 information_schema 会给 TEXT 报一个类型自身上界) 同样算 TEXT 家族 —
  52. // 那是类型的上界, 不是用户声明的宽度。
  53. func isTextFamilyDecl(decl string) bool {
  54. base, _ := splitTypeArgs(strings.ToUpper(strings.TrimSpace(decl)))
  55. switch base {
  56. case "TEXT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", "CLOB", "NCLOB":
  57. return true
  58. }
  59. return false
  60. }
  61. // textWidthCode 采样最大长度 → 定长档位: 大于等于 maxLen 的最小 2 次幂 (下限
  62. // textWidthMin, 上限 types.MaxInlineColumnWidth), 返回 (宽度码, 档位宽度, 可否定宽);
  63. // maxLen 超上限返回 ok=false —— 那一列只能保持动态列。
  64. //
  65. // 2 的幂档 (32/64/.../2048) 全部精确落在引擎的 stringWidthTable 上, 所以
  66. // types.WidthCodeFor 正好命中 (不会向上漂到 48/96 这种中间档): "采样 100 字节 →
  67. // 128 档"是精确陈述, 不是约数。
  68. func textWidthCode(maxLen int64) (code byte, width int, ok bool) {
  69. if maxLen > types.MaxInlineColumnWidth {
  70. return 0, 0, false
  71. }
  72. w := textWidthMin
  73. for int64(w) < maxLen {
  74. w *= 2
  75. }
  76. return types.WidthCodeFor(w), w, true
  77. }
  78. // scanTextWidths TEXT 采样定宽主逻辑, 建目标表之前调用: 找出 schema 里"源声明
  79. // TEXT 家族、当前推断为动态列"的列, 让源端按表采样一次, 够窄的改落成
  80. // FieldType_String + 宽度码 (可索引), 超宽的保持动态列。表结构就地修改, 返回写进
  81. // 迁移报告的说明 (无命中列时返回 "")。
  82. //
  83. // 放在 prepareForVRTX / fitRowWidthForVRTX 【之前】: 原主键列退化成普通列时要不要
  84. // 补索引 (ensureColumnIndex 只给非动态列补), 以及行宽闸门要不要把采样得过宽的列
  85. // 再降回动态列, 都该看到采样后的最终列宽。
  86. //
  87. // 采样失败不判死: 记日志并退回旧行为 (全落动态列), 由返回值在报告里说明 —— 采样
  88. // 是"让列更好用"的优化, 不该把以前能迁的表拦下来。
  89. func scanTextWidths(src Source, sc *TableSchema, o *Options) string {
  90. var cols []string
  91. for i := range sc.Columns {
  92. c := &sc.Columns[i]
  93. if c.Type != types.FieldType_Dynamicbyte || !isTextFamilyDecl(c.Decl) {
  94. continue
  95. }
  96. cols = append(cols, c.Name)
  97. }
  98. // 不能提前 return: NUMERIC 域探测 (scanNumericDomain) 在 TEXT 列缺席时仍要跑。
  99. if len(cols) == 0 {
  100. return scanNumericDomain(src, sc, o)
  101. }
  102. sampler, ok := src.(TextWidthSampler)
  103. if !ok {
  104. return scanNumericDomain(src, sc, o) // 源端不支持采样 (VRTX → VRTX): 两条路都退
  105. }
  106. maxLens, err := sampler.TextWidths(sc.Name, cols)
  107. if err != nil {
  108. o.logf("[migrate] 表 %s 的 TEXT 采样失败, 保持动态列 (旧行为): %v", sc.Name, err)
  109. return appendNote(fmt.Sprintf("text 采样失败保持动态列 (%v)", err), scanNumericDomain(src, sc, o))
  110. }
  111. var fixed, kept []string
  112. for i := range sc.Columns {
  113. c := &sc.Columns[i]
  114. if c.Type != types.FieldType_Dynamicbyte || !isTextFamilyDecl(c.Decl) {
  115. continue
  116. }
  117. maxLen := maxLens[c.Name] // 缺项视为 0, 全 NULL 的列落最窄档
  118. code, width, ok := textWidthCode(maxLen)
  119. if !ok {
  120. kept = append(kept, fmt.Sprintf("%s 超 %d 字节", c.Name, types.MaxInlineColumnWidth))
  121. continue
  122. }
  123. c.Type = types.FieldType_String
  124. c.Param = code
  125. fixed = append(fixed, fmt.Sprintf("%s %d 字节", c.Name, width))
  126. o.vlogf("[migrate] 表 %s 的 TEXT 列 %s 采样最大 %d 字节, 定宽 %d 字节",
  127. sc.Name, c.Name, maxLen, width)
  128. }
  129. var note string
  130. if len(fixed) > 0 {
  131. note = appendNote(note, "text 列按采样定宽 ("+strings.Join(fixed, ", ")+")")
  132. }
  133. if len(kept) > 0 {
  134. note = appendNote(note, "text 列采样超宽保持动态 ("+strings.Join(kept, ", ")+")")
  135. }
  136. return appendNote(note, scanNumericDomain(src, sc, o))
  137. }
  138. // isBareNumericDecl 源声明是否【裸 NUMERIC】(基类型 NUMERIC 且无 (p,s) 参数)。
  139. // 只有这种声明会被 inferSQLType (SQLite 分支) 收成 Bool; 它同时是数据探测的
  140. // 目标 — 真定点列要么带精度 NUMERIC(p,s), 要么写 DECIMAL, 都不进这里。
  141. func isBareNumericDecl(decl string) bool {
  142. base, args := splitTypeArgs(strings.ToUpper(strings.TrimSpace(decl)))
  143. return base == "NUMERIC" && len(args) == 0
  144. }
  145. // scanNumericDomain 裸 NUMERIC → Bool 的数据兜底: inferSQLType 按"GORM sqlite
  146. // 方言的 bool 声明是裸 NUMERIC"这一高概率先收成 Bool, 但 SQLite 的 NUMERIC 是
  147. // 亲和性而非真类型 — 手写的定点列也可能裸声明。建表前采样一次域外值: 存在
  148. // 非 {0,1,NULL} 的行就把该列改回 Decimal(0) (裸 NUMERIC 的旧映射), 并写进
  149. // 报告; 探测失败保持 Bool 不动 (bool 仍是高概率, 且写入侧对 Bool 列写非 0/1
  150. // 只会拒值, 不会静默错)。返回报告 note, 无命中列时返回 ""。
  151. func scanNumericDomain(src Source, sc *TableSchema, o *Options) string {
  152. var cols []string
  153. for i := range sc.Columns {
  154. c := &sc.Columns[i]
  155. if c.Type == types.FieldType_Bool && isBareNumericDecl(c.Decl) {
  156. cols = append(cols, c.Name)
  157. }
  158. }
  159. if len(cols) == 0 {
  160. return ""
  161. }
  162. sampler, ok := src.(TextWidthSampler)
  163. if !ok {
  164. return ""
  165. }
  166. od, err := sampler.NumericDomain(sc.Name, cols)
  167. if err != nil {
  168. o.logf("[migrate] 表 %s 的 NUMERIC 域探测失败, 裸 NUMERIC 列保持 Bool: %v", sc.Name, err)
  169. return fmt.Sprintf("numeric 域探测失败保持 Bool (%v)", err)
  170. }
  171. var reverted []string
  172. for i := range sc.Columns {
  173. c := &sc.Columns[i]
  174. if c.Type == types.FieldType_Bool && isBareNumericDecl(c.Decl) && od[c.Name] > 0 {
  175. // 域外值说明这列真不是布尔。旧映射 Decimal(0) 对带小数的域外值
  176. // (1.5 等) 照样拒写 — 兜底不该把能迁的表拦下, 退 float64 (有损,
  177. // 与 DECIMAL 超范围时的既有降级同 philosophy, 报告 note 说明)。
  178. c.Type = types.FieldType_Float64
  179. c.Param = 0
  180. reverted = append(reverted, fmt.Sprintf("%s (%d 行域外值, 降级 float64)", c.Name, od[c.Name]))
  181. o.vlogf("[migrate] 表 %s 的裸 NUMERIC 列 %s 含 %d 行非布尔值, 降级 float64",
  182. sc.Name, c.Name, od[c.Name])
  183. }
  184. }
  185. if len(reverted) == 0 {
  186. return ""
  187. }
  188. return "裸 NUMERIC 探测到域外值降级 float64 (" + strings.Join(reverted, ", ") + ")"
  189. }