
最近在帮客户做数据仓库搬迁源端是MySQL目标端是Doris。业务方丢过来三百多张表的建表语句大部分是五六年攒下来的老表字段各种奇奇怪怪的类型和默认值要求一周内把Doris侧的表结构全部准备好。面对这种工作量光靠手工翻译肯定不现实我写了一个MySQL to Doris的创表语句转换脚本把重复劳动变成一条命令。这篇文章就来聊聊这个脚本的设计思路、核心转换规则、关键实现和踩过的坑。如果你正好在准备MySQL到Doris的数据迁移或者只是想搞清楚两种数据库建表语法之间的差异这篇文章应该能给你省下不少试错时间。我会尽量把转换规则聊透也会把实际跑脚本容易翻车的地方摆出来。1. 先说清楚MySQL到Doris迁移为什么要先处理建表语句1.1 MySQL建表语句和Doris建表语句的本质差异Doris虽然兼容MySQL的客户端协议但底层完全是两套东西。MySQL是行式存储Doris是列式存储MySQL靠InnoDB的索引组织表Doris则依赖分区分桶加排序键模型。所以一条CREATE TABLE语句在两端执行外层框架看起来差不多细节却几乎没有一条能直接复用。举几个最直接的差异MySQL必须有主键Doris可以选择Duplicate Key、Unique Key、Aggregate Key三种表模型没有主键也能建表。MySQL的索引就是B树上的普通索引、唯一索引、全文索引Doris则用Key列替代索引不单独维护列索引。MySQL支持AUTO_INCREMENT自增列Doris不支持你只能在应用层或ETL阶段去生成自增逻辑。MySQL的字段类型里有TEXT、ENUM、SET这类Doris没有的类型需要重新落位。这些差异意味着你不能拿着Navicat导出的建表语句直接去Doris里执行。每条语句都得经过一轮“翻译”。1.2 手工转换的三宗罪慢、容易错、不可维护最开始我确实尝试过手工改表结构。改到三五十张的时候问题就开始冒头一是太慢。一张普通表少说十几个字段每张都要对照类型映射表确认默认值、注释、索引平均一张表要十分钟以上。三百张表就是五十个小时谁受得了。二是容易错。人眼盯久了会疲劳经常会把DECIMAL(12,2)看成了DECIMAL(12,4)或者顺手把TINYTEXT映射成VARCHAR(255)结果存了几个超长文本直接报错。这种错误在review时还不容易发现。三是不可维护。表结构不是一成不变的业务方两天一小改、三天一大改每次改动都要重新翻译一遍纯手工根本跟不上。1.3 脚本化转换的边界在哪里写脚本之前我需要先给自己定个原则脚本只做机械翻译不替业务做语义决策。比如一张表到底是该用Unique模型还是Duplicate模型这是业务语义问题你不可能靠一个正则猜出来。脚本能做的是根据源表有没有主键自动给出一个合理默认值再把候选方案抛给DBA去确认。所以这个脚本的目标不是“零人工”而是把人工介入的范围压缩到真正需要动脑子的地方。模块化好之后后面遇到新的奇葩类型只需要扩展映射字典脚本本身的框架不用动。2. 转换脚本的整体设计思路2.1 输入输出约定我设计的脚本是命令行工具输入一个纯文本SQL文件里面可以包含很多条CREATE TABLE语句。输出是一个新的SQL文件里面是Doris能直接执行的建表语句同时终端会打印一份转换报告列出每张表的处理结果、告警项、跳过的约束。调用方式python convert_mysql_to_doris.py input.sql output.sql为了减少解析歧义我约定输入文件里每条语句必须以分号结尾支持反引号包裹库名和表名不包含其他DML语句。实际业务导出的SQL文件基本都是这个格式。2.2 四步处理流程整个转换流程可以拆成四步和我平时写代码的思路完全一致先拆解再解析再映射最后重组。第一步切分SQL语句。用sqlparse把文件按分号拆成独立语句过滤出以CREATE TABLE开头的语句。第二步提取建表结构。把表名、字段列表、表约束从建表语句中拆出来。这一步的难点在于如何处理括号嵌套、字符串内的逗号。第三步字段级映射。把MySQL的数据类型、默认值、注释、索引属性逐一映射成Doris的语法。第四步拼装Doris建表语句。按照Doris的语法顺序组装字段、Key、分区、分桶、Properties。2.3 为什么选Python加sqlparse而不是纯正则我见过很多人在做SQL转换时直接用一串长正则去匹配整个建表语句结果遇到DEFAULT CURRENT_TIMESTAMP、COMMENT a,b这种内容就翻车。因为建表语句虽然模板化但内部括号和引号很多正则很难一次吃透。所以我的方案是分层处理用sqlparse做语句切分因为它是成熟的开源库能识别语句边界不用自己写分号匹配再用括号深度扫描去拆分表体。括号深度扫描虽然也是自己实现的但比一个巨型正则可控得多因为它逐字符处理能明确知道逗号在第几层括号里。3. 核心转换规则详解3.1 数据类型映射表这是转换的核心底表我维护了一张映射表重点类型如下MySQL类型Doris类型说明TINYINTTINYINTSMALLINTSMALLINTMEDIUMINTSMALLINTDoris没有MEDIUMINT等宽映射到SMALLINT但要注意数据范围可能溢出INT / INTEGERINTBIGINTBIGINTFLOATFLOATDOUBLEDOUBLEDECIMAL(p,s)DECIMAL(p,s)Doris最多支持DECIMAL(38,18)超过精度要改STRINGDATEDATEDATETIMEDATETIMETIMESTAMPDATETIMEDoris没有TIMESTAMP要考虑时区换算TIMEVARCHAR(32)建议存成文本方便应用层处理YEARSMALLINTVARCHAR(n)VARCHAR(n)Doris VARCHAR的最大长度比MySQL小过长改STRINGCHARCHARTEXT / TINYTEXT / MEDIUMTEXT / LONGTEXTSTRINGBLOB 系列STRING需要业务确认二进制内容怎么处理JSONJSONENUM / SETVARCHAR(n)把枚举定义简化成字符串这只是基础映射。实际跑的时候还要额外处理无符号属性和ZEROFILL属性。Doris虽然也支持部分无符号类型但为了不让后续ETL导入阶段因为正负数边界问题出岔子我一般统一去掉UNSIGNED和ZEROFILL。TINYINT UNSIGNED就会映射成SMALLINTINT UNSIGNED映射成BIGINT这样把无符号带来的正数范围扩大问题接住。3.2 MySQL特有属性怎么处理去掉或者替换MySQL建表语句尾部往往带一堆专属属性比如ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_bin ROW_FORMATDYNAMIC在Doris里这些统统不存在。ENGINE直接删掉字符集和排序规则由Doris的库属性控制ROW_FORMAT、COMPRESSION这些物理属性更不用管。AUTO_INCREMENT要特殊处理。Doris不支持自增所以我会把AUTO_INCREMENT属性去掉然后在转换报告里加一条提示自增逻辑需要由写入端保证。常见的做法是在ETL导入的时候统一生成一个全局唯一的ID。ON UPDATE CURRENT_TIMESTAMP也是MySQL的常用写法Doris同样不维护保留DEFAULT CURRENT_TIMESTAMP就行ON UPDATE部分直接丢弃。3.3 索引与约束主键、唯一键、普通索引的落脚点这是整个转换里最需要动脑筋的地方。MySQL一张表可以同时拥有主键、唯一键、多个普通索引Doris没有传统意义上的索引体系它只通过Key列来组织排序和唯一性。我给出的默认规则如下如果原表有主键Doris建表用Unique模型主键列就是UNIQUE KEY。这样可以保证主键唯一性也方便后续做更新操作。如果原表没有主键默认用Duplicate模型排序列取前几个字段具体取哪些我会在报告里提示人工确认。原表的UNIQUE KEY约束需要单独提示。因为Doris的Unique模型只允许一组Key列不可能同时让主键和另一个字段都做到数据库层面的唯一约束。业务方必须接受在写入端保证唯一性的方案。普通索引在Doris中直接忽略。Doris的查询优化靠分区分桶裁剪不靠传统索引普通索引映射过去没有意义。这个规则一定要在脚本输出里写清楚否则业务方看到线上Doris表把自己的唯一索引丢了会来敲门的。3.4 分区与分桶从MySQL分区到Doris分布式设计MySQL常见的分区有RANGE、HASH、LIST。Doris支持RANGE分区和LIST分区并且多了一个分桶概念。RANGE分区可以做语法映射比如MySQL里常见的PARTITION BY RANGE (YEAR(create_time))在Doris里可以转成PARTITION BY RANGE(create_time)但需要按照时间值生成初始分区范围。这个没法自动完美生成我的脚本只会把分区表达式提取出来生成一个占位分区具体边界由DBA补充。HASH分区在Doris里通常转换成DISTRIBUTED BY HASH也就是分桶。分桶列我默认取Key列的第一个因为Doris要求分桶列必须是Key列的子集否则建表直接报错。分桶数量根据经验值给一个默认数建议后续按数据量调整。3.5 注释和默认值保留的技巧MySQL列注释直接保留Doris支持COMMENT xxx。表注释也一样放在建表语句最外层。默认值有两类坑。一类是字符串默认值比如DEFAULT 0.00这个直接原样保留。一类是数值默认值比如DEFAULT 1要注意不能加引号。还有布尔类型在MySQL中经常用tinyint(1)Doris虽然也有BOOLEAN类型但为了和应用层兼容我倾向于继续映射成TINYINT避免业务代码大改。4. 脚本代码实现与实操讲解4.1 第一步读取SQL文件并切分出CREATE TABLE语句这个部分用sqlparse就能干净地解决。import sqlparse import re def split_create_table_statements(sql_text): statements sqlparse.split(sql_text) create_stmts [] for stmt in statements: stripped stmt.strip() if not stripped: continue if re.match(rCREATE\sTABLE, stripped, re.I): create_stmts.append(stripped.rstrip(;)) return create_stmtssqlparse.split能正确处理分号字符串里的分号比如注释或者默认值里的a;b这个比你自己做sql.split(;)要稳得多。rstrip(;)只是为了后面拼装时末尾干净。4.2 第二步提取表名和表体建表语句去掉CREATE TABLE前缀后我通常用一个正则提取库名、表名和括号里的表体。def extract_table_and_body(stmt): m re.match( rCREATE\sTABLE\s(?:IF\sNOT\sEXISTS\s)? r(?)([\w$])\1\.(?)([\w$])\3\s* r\((.*)\)\s*.*$, stmt, re.S | re.I ) if not m: m re.match( rCREATE\sTABLE\s(?:IF\sNOT\sEXISTS\s)?(?)([\w$])\1\s*\((.*)\)\s*.*$, stmt, re.S | re.I ) if not m: return None, stmt table_name m.group(2) body m.group(3) return table_name, body db_name m.group(2) table_name m.group(4) body m.group(5) return f{db_name}.{table_name}, body这里要兼顾两种写法带库名和不带库名。有库名最好生成的Doris表会放到同名库没有库名就用默认库。4.3 第三步按括号深度拆分字段定义和表约束拿到表体之后第一件事是把字段定义和PRIMARY KEY、UNIQUE KEY这些表约束分开。直接body.split(,)会出大问题因为DECIMAL(10,2)里的逗号和a,b里的逗号会被误伤。所以必须扫描括号深度和引号状态。def split_top_level(body): parts [] start 0 depth 0 quote None for i, ch in enumerate(body): if ch in (, , ): if quote is None: quote ch elif quote ch: quote None if quote is not None: continue if ch (: depth 1 elif ch ): depth - 1 elif ch , and depth 0: parts.append(body[start:i]) start i 1 parts.append(body[start:]) return [p.strip() for p in parts if p.strip()]这个函数是脚本最核心的部分之一。我踩过一次很深的坑就是把单引号处理漏了导致默认值里带英文逗号的字段被拆成两半后边所有字段全部错位。拆分之后每一项先判断是不是以KEY、PRIMARY、UNIQUE、CONSTRAINT开头。如果是说明是表约束否则就是字段定义。字段定义用这个正则提取名字和后面的类型串FIELD_RE re.compile(r^(?)([\w$])\1\s(.)$, re.S) def parse_field_def(part): m FIELD_RE.match(part) if not m: return None, None, part field_name m.group(2) rest m.group(3).strip() return field_name, rest, None4.4 第四步字段层面的类型映射与属性处理拿到field_name和rest之后要从rest中拆出类型、精度、属性和注释。我用一个简单函数处理TYPE_RE re.compile(r^([A-Za-z])\s*(\([^)]*\))?\s*(.*)$, re.S) def parse_field_type_rest(rest): m TYPE_RE.match(rest) if not m: return rest, , rest type_name m.group(1).upper() precision m.group(2) or attributes m.group(3).strip() return type_name, precision, attributes然后进入映射逻辑。我维护一个字典同时处理无符号和各种带精度类型的边界。这里关键点在于控制精度的转换。比如DECIMAL(65,30)Doris最多支持DECIMAL(38,18)我就会自动把类型改成STRING并且打一条告警日志。这种日志非常有用因为迁移后发现插入大数失败再回头查表结构就晚了。4.5 一个完整的转换示例与验证方式我们用一张典型的订单表演示输入SQLCREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(64) NOT NULL, user_id bigint NOT NULL, amount decimal(10,2) NOT NULL DEFAULT 0.00, status tinyint NOT NULL DEFAULT 1, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_bin;脚本转换后的Doris建表语句大概是CREATE TABLE dws.orders ( id BIGINT NOT NULL, order_no VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) UNIQUE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 10 PROPERTIES(replication_num 1);注意到几个细节AUTO_INCREMENT被去掉了。PRIMARY KEY变成了Unique模型的UNIQUE KEY(id)。UNIQUE KEYuk_order_no被移除了因为Doris只允许一组Key列脚本会在报告里提醒业务方order_no的唯一性需要写入端保证。普通索引idx_user_id被忽略了。ENGINE和CHARSET被丢弃。验证方式分两步。第一步是把转换后的SQL拿到Doris执行看能不能建成功。第二步是写一个简单的字段清单对比程序把原表字段名、类型、是否为空、默认值和目标表逐项比对不一致的列出来。前期我习惯随机抽20张表做人工review确认脚本生成的表模型符合业务预期后面再放手批量跑。5. 实测中的坑与常见问题排查实录5.1 逗号拆分把DECIMAL(10,2)拆成了两半这个坑我在初版代码里必踩。一开始我图简单用正则先匹配字段没有做括号深度扫描。遇到DECIMAL(10,2)这种字段时括号里的逗号也会被当成字段分隔符。结果一个字段被拆成两个后边所有字段全部错位生成了一堆完全看不懂的SQL。解决方式就是前面写的split_top_level用depth变量跟踪括号层级。后来还把单引号和双引号的状态也加进去默认值里的逗号才没有继续捣乱。5.2 反引号缺失导致Doris建表失败MySQL的字段名经常有order、desc、key这些保留字。原表SQL里都带反引号如果转换时我只保留字段名而丢掉反引号Doris会直接报语法错误。后面我在拼装字段时统一给字段名套上反引号不管是不是保留字。这样生成的SQL比较啰嗦但胜在安全而且和Doris执行时的行为一致。5.3 分桶列没有包含在Key列中Doris建表要求分桶列必须是Key列的子集。刚开始转换几张没有主键的表时我默认分桶列取第一个字段结果有的表第一个字段没被选为Key列建表报错Distributed column should be subset of Key columns之后我调整了规则分桶列优先取Key列的第一个字段如果Key列是多个字段的组合默认就用整个Key组合作为分桶键。这样至少不会报错。5.4 DECIMAL精度超过Doris上限有一张旧表定义了DECIMAL(40,10)MySQL允许Doris最多支持38位整数精度。这个问题不是建表阶段报错的而是在数据加载的时候发现某些大数被截断排查了很久才找到根源。后来我把精度超限的字段自动映射成STRING并且在报告里标红提示让业务方确认。5.5 常见问题速查表症状原因解决方法建表字段错位表体内逗号没有按括号深度拆分使用括号深度扫描忽略字符串内的逗号Doris报错保留字字段名没加反引号统一给字段名套反引号Distributed column should be subset of Key columns分桶列不在Key列里分桶列默认取第一个Key列DECIMAL精度超限MySQL精度超过38位超过上限自动转STRING数据导入时唯一键冲突原表多个唯一约束被忽略告知业务方由写入端保证唯一性分区边界不对原RANGE分区表达式复杂只生成占位分区人工确认边界6. 脚本的扩展用法和落地效果6.1 批量转换整个目录下的建表语句单个文件转换毕竟还是太单薄实际项目中我通常会把建表脚本拆成多个文件按业务模块分目录。脚本可以加一个目录遍历参数用glob匹配所有.sql文件逐个转换后输出到一个汇总文件里。python convert_mysql_to_doris.py ./mysql_schema ./doris_schema --recursive这个扩展不复杂重点是保持每个文件的转换结果可追溯最好给每张表生成独立的SQL文件方便DBA单表重跑。6.2 转换后直接执行到Doris我后来加了一个--execute参数用pymysql连上Doris直接把转换好的SQL一条条执行。执行失败的语句记录下来输出到日志文件然后继续执行后面的表。这种方式一次能处理几百张表比手动复制粘贴高效得多。注意Doris建表有时候会因为副本数设置不合适或者资源组未配置而执行失败比如replication_num超过了下线节点数。所以直接执行时要预留重试机制失败的表单独捞出来人工处理。6.3 多表转换后的依赖顺序问题这个坑比较隐蔽。Doris建表本身没有外键依赖但如果你的MySQL表之间有视图或者物化视图依赖直接建表不维护依赖顺序会带来困扰。我的建议是先只建表后建视图脚本不处理视图因为视图转换涉及查询重写复杂度完全不在一个量级。6.4 实际落地效果与后续优化这次迁移最终在Doris上生成了两百多张表剩下十几张涉及复杂分区和特殊约束的表由人工处理。整个建表阶段从预估的三四天压缩到大半天而且由于脚本会在报告里列出所有需要人工确认的点DBA只盯着报告看就行不用再逐行比对SQL。后续如果再遇到新的需求比如MySQL表字段非常多、或者要顺手生成Doris的物化视图定义只需要在映射表和拼装函数里加分支即可。脚本最核心的价值是帮团队把重复且易错的机械劳动剥离出去把注意力留给真正需要判断的部分。最后分享一点我个人的体会。写这个转换脚本最难的地方不是写代码而是理解Doris的Key模型和分桶策略背后的语义。很多人在迁移时习惯把MySQL那套思路原封不动搬到Doris结果表建好了查询效率反而更差。建表语句转换只是元数据迁移的入口真正决定效果的是你对目标系统模型有没有想清楚。如果你最近也在做类似的迁移建议先拿几十张有代表性的表跑一遍转换人工review差异再决定哪些表需要单独重写表模型。这个脚本的框架就聊到这里后面有新的奇葩语法再补充。