前几天有人丢给我一个三十多兆的 .dmp 文件,说“你把里面的建表语句和初始化数据抠出来,给我一份能直接跑的 .sql”。我用文本编辑器打开一看,全是二进制乱码。这件事的根源在于,达梦数据库(DM8)里“导出”这个词至少背了三种完全不同的含义,而大部分人心里想的那一种——生成一份纯文本、别人拿到手用disql一跑就能建库建表灌数据的脚本——恰好是dexp不负责的那一种。
这篇东西就是把这个模糊地带拆开讲。我会说清楚 DM8 里导出 SQL 文件的几条主流路径各自解决什么问题、图形客户端里那些勾选框到底在控制什么、用DBMS_METADATA批量捞 DDL 时哪里会被截断、数据行拼成INSERT时字符串和日期怎么转义、以及最要命的一步:为什么你导出的脚本在自己机器上好好的,换一台库就跑不通。适合已经能连上 DM8、手上有建表权限、需要把结构和数据交付出去或做版本管理的开发者;纯新手也能跟,因为关键步骤我都写了为什么。
1. 先分清“导出 SQL 文件”在 DM8 里到底指哪件事
1.1 二进制备份、纯 DDL、可执行数据脚本是三样东西
在达梦里提出“导出一份 SQL”这个需求,实际可能对应三种交付物,它们的生成工具、内部格式和下游用途完全不同。分不清这一点,后面所有操作都是在碰运气。
| 产物形态 | 主力工具 | 能否用文本编辑器打开 | 典型用途 | 增量可控性 |
|---|---|---|---|---|
.dmp二进制备份 | dexp/dimp | 不能 | 整库或整模式的备份迁移 | 高,支持按表、按条件 |
| 纯 DDL 脚本 | DBMS_METADATA、图形客户端 | 能 | 建库建表、结构纳入版本管理 | 一般,按对象粒度 |
| DDL + DML 混合脚本 | 图形客户端导出向导、自拼INSERT | 能 | 交付演示数据、小体量库整体搬运 | 视方案而定,手工拼最灵活 |
这三者的关系不是替代,而是分工。你要是做生产库的例行备份,dexp生成的.dmp是最稳的,速度快、一致性好、支持并行;你要是要把表结构提交到代码仓库做 review,那必须是纯文本 DDL;你要是给测试环境准备一份能一键起库的初始化脚本,那就得是 DDL 加 DML 的混合体。
我见过最常见的误区是拿.dmp当交付物发给外部,对方环境版本稍有差异,dimp直接报错,然后两边开始互相怀疑对方的库有问题。二进制格式天生就把版本绑死了,跨版本、跨平台、跨字符集的场景下,纯文本脚本的容错空间要大得多。
判断标准很简单:这份东西的接收方能不能连上你的库?能连,走
dexp;不能连,走文本脚本。
1.2 dexp 的输出格式决定了它不是给人看的
dexp是达梦的逻辑导出工具,输出的是达梦私有的二进制文件,只有配套的dimp能解析。它的设计目标是完整、快速、可恢复,不是可读。你可以在导出时选择是否包含索引、约束、触发器、权限,但没法让它吐出CREATE TABLE这样的文本。
一个典型的整模式导出长这样:
dexp USERID=SYSDBA/YourPassword@127.0.0.1:5236 \ DIRECTORY=/data/export \ FILE=app_schema_20240115.dmp \ LOG=app_schema_20240115.log \ SCHEMAS=APP \ ROWS=Y \ INDEX=Y \ CONSTRAINTS=Y \ TRIGGERS=Y \ GRANTS=Y几个参数值得单独说。SCHEMAS和OWNER都表示按模式导出,前者可以一次跟多个模式名,后者是早期写法;ROWS=Y控制是否带数据,只要结构的话设成N;QUERY参数可以加过滤条件,做条件导出,比如只导最近三个月的数据,这个在超大表上非常实用;DIRECTORY指的是执行dexp那台机器上的本地路径,不是数据库服务器上的路径——这一点很多人第一次用会搞错,以为文件会落在服务器上。
日志文件一定要看。dexp失败的时候不一定在终端里把错误打全,但日志里会有完整的对象级错误信息,比如某张表因为锁等待超时被跳过。我有一次导出一个两百多张表的模式,终端里显示成功,日志里却有四张表因为“对象不存在”被跳过——原因是那几张表属于另一个模式,只是通过同义词访问。这种问题不看日志根本发现不了。
1.3 四条路径怎么选:按交付对象倒推
与其记住所有工具,不如从“这份脚本最终要给谁、在什么环境跑”倒推。下面这张表是我自己在不同场景下的实际选择,可以直接对照。
| 场景 | 推荐路径 | 理由 |
|---|---|---|
| 只交付表结构,接收方自建库 | DBMS_METADATA+spool | 纯文本、可版本管理、批量可控 |
| 结构 + 少量配置数据(几十张表以内) | 图形客户端导出向导 | 勾选即出,不用写脚本 |
| 结构 + 大量数据(百万行以上) | dexp出.dmp,接收方dimp | 文本脚本在这个体量下会慢到不可接受 |
| 需要精确控制导出内容、做数据脱敏 | 自拼INSERT语句 | 每一列都能改,能过滤能替换 |
表格里第四行是很多人忽略的。做数据脱敏或者构造测试数据时,图形工具给不了你“把手机号中间四位打码再导出”这种能力,只有自己拼 SQL 才能做到。代价是要处理类型转换和转义,这部分我在第 4 节会展开。
选型的核心判断点其实就两个:数据量和接收方环境。数据量决定你能不能承受文本脚本的体积和导入耗时,接收方环境决定你能不能直接用二进制格式。
2. 图形客户端导出向导:每个勾选框背后的含义
2.1 对象范围的圈定比想象中更需要小心
达梦的图形管理客户端里,导出入口通常挂在对象树的右键菜单上,可以对整个模式、单张表、或者一批选中的对象操作。看起来很简单,但“选中什么”这件事本身就有歧义。
如果你在模式节点上右键导出,导出的范围通常是该模式下的全部对象,包括表、视图、序列、存储过程、自定义函数、甚至同义词。这里有个坑:视图和存储过程的 DDL 里可能引用其他模式的对象,如果你的目标库没有对应的模式,脚本在执行时会报“对象不存在”。稳妥的做法是导出前先查一遍依赖。
-- 查当前模式下所有视图及其定义长度,先看规模 SELECT VIEW_NAME, TEXT_LENGTH FROM ALL_VIEWS WHERE OWNER = 'APP' ORDER BY VIEW_NAME;对于表级导出,我一般会先按体量排序,把大表和配置表分开处理。配置表走文本脚本,大表走dexp,两条线并行,最后在目标库合并。
-- 按行数粗排,找出体量大的表 SELECT TABLE_NAME, NUM_ROWS FROM ALL_TABLES WHERE OWNER = 'APP' ORDER BY NUM_ROWS DESC NULLS LAST;NUM_ROWS依赖统计信息,如果库很久没收集过统计,这个值可能是空的或者严重失真,那就得用COUNT(*)实测。我在一次迁移里就因为信了统计信息,把一张实际有八百万行、统计里显示两万行的表当小表处理,结果文本脚本导出跑了四十分钟,中途还因为临时空间不足挂了。
2.2 导出选项里的三个决策点
图形向导的选项页通常有一堆勾选框,其中真正影响结果的只有三个:是否包含数据、DDL 和 DML 是否分文件、以及对象类型的勾选范围。
“包含数据”这一项决定输出是纯结构还是结构加数据。如果你的目的是做结构版本管理,务必关掉它,否则每次导出的文件里都混着成千上万行INSERT,diff 出来一片红,根本没法 review。
“是否分文件”这一项在小规模时无所谓,但对象一多就体现出价值。分文件的好处是可以单独重跑某一类对象,比如只重建索引;坏处是文件数量爆炸,几百张表就是几百个文件。我通常的做法是:表结构、索引约束、序列、视图存储过程各出一个文件,四到五个文件,既能分批执行又好管理。
对象类型的勾选范围最容易出事。默认往往全选,但“全选”里包含了一些你未必想要的东西——比如表的存储参数(表空间名、初始簇大小、增长方式)。这些参数在你本地环境里指向的是某个具体表空间,导到目标库时如果那个表空间不存在,建表语句直接失败。要么在导出时关掉存储参数,要么在目标库先建好同名表空间。
一个实用技巧:导出后先用文本搜索把
STORAGE、TABLESPACE、USING INDEX TABLESPACE这些关键字过一遍,看看有没有硬编码的环境信息。这一步花两分钟,能省掉后面半小时的报错排查。
2.3 产物结构和二次编辑的实际处理
向导生成的脚本一般是有序的:先是各种CREATE TABLE,然后是主键、唯一约束、索引,再是外键,最后是数据。这个顺序是有讲究的,因为外键依赖被引用表的存在,索引建在数据加载之后比边插边维护快得多。
但顺序正确不代表可以直接跑。实际拿到的脚本通常需要三轮加工。
第一轮处理环境相关的部分。把表空间名换掉或者删掉,把STORAGE子句里的具体数值改成目标库的合理值。如果源库的表建在小表空间上,而目标库用的是自动扩展的大表空间,这些参数留着反而是负担。
第二轮处理字符和标识符。达梦默认大小写敏感,不加双引号的标识符会被折叠成大写。如果源库建表时用了带引号的小写表名,导出的脚本里也会带引号,这部分必须原样保留,一旦有人手工把引号删了,表名就变了。这个坑我在第 5 节还会展开。
第三轮是补上脚本缺失的部分。图形工具导出的内容通常不包含用户创建、权限授予、角色分配,也不包含COMMENT ON注释语句(取决于版本和勾选项)。如果目标库需要完整的权限体系,这些得自己补。
-- 补齐注释:先查出所有列注释,再拼成 COMMENT ON 语句 SELECT 'COMMENT ON COLUMN "' || OWNER || '"."' || TABLE_NAME || '"."' || COLUMN_NAME || '" IS ''' || REPLACE(COMMENTS, '''', '''''') || ''';' FROM ALL_COL_COMMENTS WHERE OWNER = 'APP' AND COMMENTS IS NOT NULL;这段查询本身就是个“生成语句的语句”,跑出来的结果直接粘贴到脚本末尾就行。同类思路可以用于生成权限语句、生成索引重建语句,非常省事。
3. 用 DBMS_METADATA 把 DDL 批量捞出来
3.1 单表 GET_DDL 的参数顺序与常见报错
达梦提供了兼容的DBMS_METADATA系统包,其中GET_DDL是最常用的一个,能从数据字典里直接把对象的建语句取出来。基本调用形式是这样:
SELECT DBMS_METADATA.GET_DDL('TABLE', 'T_ORDER', 'APP') FROM DUAL;三个参数分别是对象类型、对象名、所属模式。顺序不能错,而且这三个参数的大小写处理跟数据库的大小写敏感设置直接相关。在大小写敏感的实例里,如果表是以大写形式存储的,你传小写表名就会报“对象不存在”;反过来,如果表名本身建的时候带了双引号是小写的,你就必须传小写。
我一般先用数据字典确认存储形式,再决定传什么:
SELECT OWNER, TABLE_NAME FROM ALL_TABLES WHERE UPPER(TABLE_NAME) = 'T_ORDER';看到返回的是T_ORDER就传大写,看到t_order就传小写,别猜。这个查询看起来啰嗦,但比反复试错快。
对象类型的取值不止TABLE。索引是INDEX,视图是VIEW,序列是SEQUENCE,约束是CONSTRAINT,存储过程是PROCEDURE。不同小版本支持的类型范围不完全一致,用之前可以先拿一个不存在的类型试一下,看报错信息里有没有列出支持列表。
3.2 CLOB 结果被截断的两级处理
GET_DDL返回的是 CLOB 类型,问题就出在这里。命令行工具默认的显示宽度很小,一张有几十个字段、带完整存储参数的表,返回的语句可能有几千个字符,你在终端里看到的往往只有开头几行加一个省略标记。
对付截断有两个层次的解法。轻量级的是调整disql的显示参数,把行宽和长字段显示长度都调大:
SET LINESIZE 32767; SET LONG 999999; SELECT DBMS_METADATA.GET_DDL('TABLE', 'T_ORDER', 'APP') FROM DUAL;这些SET命令的具体可用性和取值范围跟版本有关,如果执行报错就换成图形客户端来做。图形客户端里 CLOB 字段通常可以直接双击展开或者另存为文件,不看截断问题。
重量级的解法是绕开显示,直接把结果写进文件。这时用spool:
SPOOL /data/export/ddl_app.sql; SET HEADING OFF; SET FEEDBACK OFF; SET PAGESIZE 0; SELECT DBMS_METADATA.GET_DDL('TABLE', 'T_ORDER', 'APP') FROM DUAL; SPOOL OFF;SET HEADING OFF去掉列标题,SET FEEDBACK OFF去掉“已选择 1 行”这类提示,SET PAGESIZE 0去掉分页。这三个设置不做的话,导出的文件里会混进一堆非 SQL 内容,跑脚本时直接语法错误。
我踩过的坑:
spool文件默认编码跟随客户端环境,如果客户端是 UTF-8 而目标库实例是 GB18030,中文注释导过去就是乱码。导出前先用SELECT SF_GET_UNICODE_FLAG();确认实例字符集,返回值 0 是 GB18030,1 是 UTF-8,2 是 UTF-16,然后让客户端编码跟它保持一致。
3.3 按模式批量生成 DDL 的循环写法
单表调用一次一次写,几百张表显然不现实。达梦的disql支持一些脚本控制语句,可以用游标循环把整个模式的表全捞出来。思路是先查数据字典拿到表名列表,再对每个表名调用GET_DDL,把结果拼接进一个 CLOB 变量里输出。
如果不想写复杂的存储过程,有个更土但更可靠的办法:先用查询生成“调用GET_DDL的语句”,再把生成结果当作脚本执行。
-- 第一步:生成一批 SELECT 语句 SELECT 'SELECT DBMS_METADATA.GET_DDL(''TABLE'', ''' || TABLE_NAME || ''', ''' || OWNER || ''') FROM DUAL;' FROM ALL_TABLES WHERE OWNER = 'APP' AND TABLE_NAME NOT LIKE 'TMP_%' ORDER BY TABLE_NAME;跑出来的结果是一批SELECT语句,把它们复制出来配合spool一起执行,就能得到一个模式里所有表的 DDL。这种“先生成再执行”的两段式写法,好处是完全可控——你可以在第一步的查询里加任意过滤条件,排除临时表、排除分区子表、只导某几个前缀的表,比图形工具灵活得多。
第二步执行时记得把SET LONG调大,否则每张表都截断,导出来的脚本全是半截语句,比没有更糟。
3.4 spool 落盘之后的清洗工作
spool出来的文件不能直接用,通常要清洗三件事。
一是去掉首尾的空白行和连接信息。disql启动时会打印版本号和连接提示,这些内容会混进spool文件的开头,如果SPOOL命令是在连接之后才执行的,一般不会有这个问题,但保险起见还是搜一下有没有非 SQL 的残留行。
二是处理GET_DDL返回值末尾的分号问题。有些版本返回的 DDL 自带结尾分号,有些没有。如果批量拼接后每个语句都少了分号,整个文件就是一堆连在一起的语法错误。快速检查的办法是搜一下文件中;的数量和CREATE的数量是否大致匹配。
三是补上分隔符。disql执行脚本时默认用分号作为语句结束符,但如果 DDL 里包含存储过程或触发器这种内部有分号的块,就必须用/作为块结束符。这类对象建议单独放一个文件,用SET SQLBLANKLINES ON之类的设置配合,避免解析错乱。
4. 数据行怎么变成 INSERT:自己拼才可控
4.1 一个能覆盖大多数场景的拼接模板
结构和数据分开处理之后,数据部分的自由度就大了。核心思路是对每一列按类型做分支处理,用CASE WHEN拼出合法的字面量。下面这个模板适用于数值、字符串、日期三类常见字段:
SELECT 'INSERT INTO "APP"."T_ORDER"("ID","ORDER_NO","AMOUNT","STATUS","CREATE_TIME") VALUES(' || CASE WHEN "ID" IS NULL THEN 'NULL' ELSE TO_CHAR("ID") END || ',' || CASE WHEN "ORDER_NO" IS NULL THEN 'NULL' ELSE '''' || REPLACE("ORDER_NO", '''', '''''') || '''' END || ',' || CASE WHEN "AMOUNT" IS NULL THEN 'NULL' ELSE TO_CHAR("AMOUNT") END || ',' || CASE WHEN "STATUS" IS NULL THEN 'NULL' ELSE '''' || REPLACE("STATUS", '''', '''''') || '''' END || ',' || CASE WHEN "CREATE_TIME" IS NULL THEN 'NULL' ELSE 'TO_TIMESTAMP(''' || TO_CHAR("CREATE_TIME", 'YYYY-MM-DD HH24:MI:SS.FF6') || ''',''YYYY-MM-DD HH24:MI:SS.FF6'')' END || ');' FROM "APP"."T_ORDER" WHERE "CREATE_TIME" >= TO_TIMESTAMP('2024-01-01 00:00:00', 'YYYY-MM-DD HH24:MI:SS');几个必须说清楚的地方。
REPLACE("ORDER_NO", '''', '''''')这一串引号看起来吓人,拆开看就明白了:SQL 里表示一个单引号字符需要写四个连续单引号,表示两个连续单引号需要写六个。所以第一个参数''''代表待替换的单引号,第二个参数''''''代表替换成的两个单引号。这是 SQL 标准里转义单引号的唯一办法,字符串里出现O'Brien这种名字时不处理就会导致整条语句语法错误。
日期部分用TO_TIMESTAMP而不是直接写字符串,是因为达梦对隐式类型转换的处理受会话参数影响,显式转换能保证在任何环境下结果一致。FF6表示保留六位小数秒,如果你的表只到秒精度,去掉它就行。
WHERE条件是这套方案的核心优势。你可以加任意过滤,只导特定时间段、特定状态的数据,甚至可以对敏感字段做替换——把"PHONE"换成'138****0000',导出的脚本天然就是脱敏的。
4.2 分类型处理的细节清单
不同数据类型各有各的脾气,我把实际遇到过的整理成一张表,照着填基本不会错。
| 数据类型 | 拼接写法 | 注意点 |
|---|---|---|
INT/BIGINT | TO_CHAR(col) | 空值必须走IS NULL分支,否则TO_CHAR(NULL)结果不确定 |
DECIMAL/NUMBER | TO_CHAR(col) | 大精度数字可能被转成科学计数法,必要时指定格式掩码 |
VARCHAR/CHAR | 单引号包裹 +REPLACE转义 | 这是唯一必须做转义的类型 |
DATE | TO_DATE('...','YYYY-MM-DD') | 显式给格式,别依赖会话默认 |
TIMESTAMP | TO_TIMESTAMP(...) | 注意小数秒位数 |
CLOB | 不建议用拼接 | 见下一小节 |
BLOB | 不建议用拼接 | 见下一小节 |
| 自增列 | 跳过该列 | 见 4.4 |
科学计数法这个坑特别隐蔽。DECIMAL(30,10)类型的字段,用TO_CHAR转出来在某些情况下会变成1.2345678901E+10这种形式,而INSERT语句里写科学计数法是合法的,只是精度会丢。安全做法是加格式掩码:
TO_CHAR("AMOUNT", 'FM99999999999999999990.0999999999')FM去掉前导空格,整数部分按最大位数写 9,小数部分按精度写 9,这样转出来是定点格式,不会有精度损失。
4.3 大字段和二进制:硬拼 SQL 是下策
CLOB和BLOB这两类字段,用字符串拼接的方式生成INSERT会遇到硬性限制:SQL 语句里的字符串字面量长度受参数约束,通常在一万字节上下,超过就报错。一张表里只要有一个字段存了几万字的文本,这套方案就废了。
技术上可以做分段拼接——先用INSERT插入除大字段外的所有列,再用多条UPDATE配合||逐段追加。但这会把脚本体积放大好几倍,性能也差。
现实的选择是绕开。含大字段的表走dexp导出,或者用图形客户端单独处理这张表,文本脚本只负责其余部分。如果接收方必须拿到纯文本脚本,那就得接受“大字段内容被截断到某个长度”或者“大字段单独存成文本文件、脚本里只放文件名”这类妥协。
二进制字段更麻烦。RAWTOHEX能把BLOB转成十六进制字符串,再用HEXTORAW转回去,理论上是闭环的:
CASE WHEN "ATTACH" IS NULL THEN 'NULL' ELSE 'HEXTORAW(''' || RAWTOHEX("ATTACH") || ''')' END但同样受字面量长度限制,只能处理小文件。而且十六进制编码会把体积翻倍,一张存了几百张缩略图的表,导出的脚本能到几个 G,用文本编辑器打开就卡死。这类数据老老实实走二进制备份通道,不要为难自己。
4.4 自增列、序列和触发器的处理顺序
自增列的处理是数据导入阶段最容易出问题的地方。达梦的自增列(IDENTITY)在插入时如果不指定该列,会自动按种子和步长生成;如果显式指定,需要额外的开关控制。
我的建议是导出时直接跳过自增列,让目标库重新生成。这样做的代价是自增值跟源库对不上,如果业务上依赖这个值的连续性,就得手动把起始值调过去。
-- 查当前自增值 SELECT IDENT_CURRENT('APP.T_ORDER'); -- 导入完成后调整起始值 ALTER TABLE "APP"."T_ORDER" ALTER COLUMN "ID" SET INCREMENT BY 10000;IDENT_CURRENT和相关的IDENT_SEED、IDENT_INCR函数在不同小版本上的行为可能有差异,用之前先在测试库上验一遍,别直接在生产上试。
序列的处理相对简单,但顺序不能错。序列必须先于使用它的表创建,否则表的默认值引用不到序列会报错。如果序列的当前值有业务意义(比如单号不能重复),导出时要单独记录当前值,导入后执行ALTER SEQUENCE ... RESTART WITH n把它推到正确位置。这一步很容易漏,漏了的后果是新库产生的单号跟旧库冲突。
触发器必须最后建。数据导入过程中如果触发器已经存在,每一行插入都会触发一次业务逻辑,轻则变慢,重则数据被改得面目全非。正确顺序是:建表 → 建约束索引 → 导数据 → 建触发器 → 建外键。
5. 从“能导出”到“能导进去”之间隔着什么
5.1 大小写敏感:最容易翻车的一处
达梦实例在初始化时可以设置是否大小写敏感,这是个建库时定死、后期改不了的参数。敏感模式下,不加双引号的标识符会被统一折叠成大写;不敏感模式下则保留原样或统一折叠成小写。这个差异会直接决定你的脚本能不能跑。
查当前实例的设置:
SELECT CASE_SENSITIVE();返回 1 表示敏感。如果源库是敏感的、目标库不敏感,或者反过来,同一个脚本在两个库上的行为就不一样。最典型的翻车场景是:源库里有一张用双引号建的小写表"t_user",导出的 DDL 里带引号,一切正常;但有人在目标库手工执行时把引号删了,表就变成了T_USER,后续所有引用"t_user"的语句全部报对象不存在。
我的处理原则是:导出的脚本一个字都不要手工改标识符,尤其是引号。要改就在源头上改,重新生成一遍。
5.2 字符集:中文乱码的两个来源
中文乱码通常来自两个地方,得分开排查。
一是实例字符集不一致。源库是 GB18030,目标库是 UTF-8,脚本文件本身是什么编码就变得很关键。如果脚本文件按 GB18030 存,在 UTF-8 的库上执行,中文注释和中文数据都会变乱码。解决办法是在导出时就把客户端编码对齐到目标库的字符集,或者导出后统一转码。
二是客户端工具的编码设置。disql和图形客户端的编码不一定跟实例一致,中间多了一层转换。比较稳的做法是让导出和执行两端使用同一套工具、同一份编码配置。
-- 确认实例字符集 SELECT SF_GET_UNICODE_FLAG();返回 0 是 GB18030,1 是 UTF-8,2 是 UTF-16。这个函数的返回值我在多个版本上验证过,比较可靠。导出前查一次,执行前在目标库再查一次,两个值不一致就得走转码流程,别指望自动兼容。
5.3 表空间与存储子句:能导出但导不进去的头号原因
前面提过一次,这里展开说。图形工具和GET_DDL导出的建表语句,通常会把存储信息带上,形如:
CREATE TABLE "APP"."T_ORDER" ( ... ) STORAGE(ON "APP_DATA", CLUSTERBTR);ON "APP_DATA"指定了表放在哪个表空间。目标库如果没有APP_DATA这个表空间,这条语句直接失败。更麻烦的是,有些语句里的表空间名藏在索引定义、分区定义里,搜一遍TABLESPACE未必能全找出来。
两个处理方向。一是目标库提前建好同名表空间,参数按目标环境的实际情况配,这样脚本一个字不用改。二是导出时就剥掉存储信息,让表落到用户的默认表空间里。第二种更省事,适合临时环境和测试库;生产环境我倾向于第一种,因为表空间规划本身就是部署的一部分,不该让脚本自己决定。
索引的表空间也要注意。索引和表放在不同表空间是常见做法,导出时如果只处理了表的存储子句,索引建的时候还是会去找不存在的表空间。
5.4 约束、索引、外键的执行顺序
一个能跑的脚本,对象顺序必须满足依赖关系。我用的顺序是这样的:
- 创建用户和模式(脚本里通常没有,得手工补)
- 创建表空间(如果目标库没有)
- 创建序列表
- 创建所有表,不带外键
- 创建主键和唯一约束
- 创建普通索引
- 导入数据
- 创建外键
- 创建触发器
- 授予权限、添加注释
- 收集统计信息
把外键放到数据导入之后,是因为外键约束会在每行插入时做一次检查,几百万行的表这么搞会慢到离谱。索引同理,先建索引再插数据,每次插入都要维护 B 树,比插完再统一建索引慢一个数量级。
这个顺序在手工整理脚本时容易搞乱,尤其是图形工具一次导出全部对象、文件里的顺序未必符合上面的要求。我的做法是导出后按对象类型拆成多个文件,按上面的顺序编号命名,执行时按文件名排序跑,一眼就能看出顺序对不对。
6. 验证:让脚本在空库里跑一遍
6.1 回放验证的最小流程
导出完成不等于交付完成。我给自己定的规矩是:任何交付出去的脚本,必须在干净环境里完整跑通至少一次。这个动作花不了多少时间,但能拦下绝大部分低级错误。
流程大致是这样。先起一个干净的实例或者建一个空模式,字符集和大小写敏感设置尽量贴近目标环境——这两项是建库时定死的,事后模拟不了,所以必须一开始就对上。然后用disql按顺序执行拆分好的脚本文件。
disql SYSDBA/YourPassword@127.0.0.1:5236 \` -e "START /data/export/01_tablespace.sql" \ -e "START /data/export/02_tables.sql" \ -e "START /data/export/03_constraints.sql"disql的START命令用来执行脚本文件,也可以用-e直接跟语句。执行过程中把输出重定向到日志,跑完搜一遍日志里的ERROR和错误关键字。达梦的报错信息有时候中英文混着出,两个都搜。
跑通之后再做一轮反向检查:源库和目标库分别统计对象数量,逐个比对。
-- 表数量比对 SELECT COUNT(*) FROM ALL_TABLES WHERE OWNER = 'APP'; -- 索引数量比对 SELECT COUNT(*) FROM ALL_INDEXES WHERE TABLE_OWNER = 'APP'; -- 约束数量比对 SELECT CONSTRAINT_TYPE, COUNT(*) FROM ALL_CONSTRAINTS WHERE OWNER = 'APP' GROUP BY CONSTRAINT_TYPE;三类数量都对得上,基本可以认为结构完整。数量对不上就按对象名做差集,找出漏掉的。
6.2 行数与抽样比对
结构之外还要验数据。全表COUNT(*)比对是最直接的方式,源库和目标库各跑一遍,看差值。
-- 源库 SELECT 'T_ORDER' AS TBL, COUNT(*) AS CNT FROM APP.T_ORDER UNION ALL SELECT 'T_USER', COUNT(*) FROM APP.T_USER; -- 目标库执行同样的语句,结果做比对表多的时候手工比对不现实,可以把两边的结果导成文本,用diff比。或者更省事一点,在源库生成一段拼接好的统计 SQL,目标库直接跑,输出格式统一,diff 起来干净。
行数一致不代表内容一致,还得抽样看。我一般挑三类表抽:有中文内容的、有日期字段的、有金额字段的。这三类分别覆盖了字符集问题、时区或格式问题、精度问题。抽的时候直接SELECT *挑几行对比,肉眼扫一遍,比写复杂的校验脚本快。
对精度要求高的场景,可以做聚合校验,比如对金额字段求和、对字符串字段算长度总和:
SELECT SUM("AMOUNT") AS TOTAL_AMT, SUM(LENGTH("ORDER_NO")) AS TOTAL_LEN, COUNT(DISTINCT "STATUS") AS STATUS_KINDS FROM "APP"."T_ORDER";这三个值在两边都算一遍,对上了说明数据和编码都没出大问题。这个方法不能发现行内错位这类问题,但作为快速验证足够用。
6.3 我自己的几条检查清单
攒了几年,我把每次交付前必查的项目固定成了几条:
- 脚本文件开头没有
disql的版本信息、连接提示、空白行 - 每条语句以分号结尾,存储过程和触发器单独成文件并用块结束符
- 搜索
TABLESPACE、STORAGE、ON "确认没有硬编码的环境信息 - 搜索小写的带引号标识符,确认它们全部原样保留
- 自增列的当前值记录在单独的文件里,导入后有对应的调整语句
- 序列的当前值同上
- 中文内容和中文注释在目标库抽查过,没有乱码
- 目标库的对象数量、行数、聚合校验三项都通过
这套清单看起来琐碎,但每一条都对应着我真实踩过的坑。特别是第三条和第四条,几乎每次换环境部署都会中一次。养成导出后先搜一遍关键字的习惯,能挡掉大半问题。
最后再分享一个小技巧:如果你的导出脚本要反复用在不同环境,把环境相关的部分抽成变量放在文件头,比如表空间名、模式名、字符集转换方式,下面所有语句引用变量。disql支持DEFINE和&变量替换,虽然不如专业迁移工具那么健全,但应付多环境部署足够了。这样一份脚本维护一套,比每个环境复制一份改一遍要可靠得多,也不会出现改了 A 环境忘了改 B 环境的情况。