上个月帮人看一个数据库迁移的活儿,一套跑了三年多的业务系统要从 MySQL 切到达梦数据库,脚本跑批跑到一半卡住了,报错信息大概是"未找到 IF 函数"。写这条 SQL 的人很委屈:SELECT IF(amount > 1000, '大额', '小额')在原来的库里跑得好好的,怎么换个库就不认了。这个问题其实是很多团队从 MySQL 迁到达梦时最先撞上的一堵墙——达梦数据库里的IF和CASE WHEN跟 MySQL 并不在同一个语法层面上,前者是过程化的控制语句,后者才是能塞进查询里的条件表达式。搞清楚这条界线,后面绝大部分报错都能自己顺着推出来。
这篇内容适合三类人看:正在做 MySQL 到达梦迁移、需要批量改 SQL 的开发者;刚接触达梦、写条件判断总报语法错的同学;以及想提前避坑、不想等到上线前才发现问题的人。我会把 IF 在达梦里能待在哪儿、CASE WHEN 的两种形态怎么选、从 MySQL 照搬过去最容易翻车的几个具体点、CASE WHEN 该用在哪些场景不该用在哪些场景,以及性能上哪些写法会自废武功,一条一条拆开讲。里面给的 SQL 都是我实际跑过或者验证过的思路,换你手上的版本可能个别行为有差异,我也会告诉你遇到不确定的时候怎么用一条 SELECT 自己验。
1. 达梦里的 IF 为什么不是一个函数
1.1 一条报错语句暴露的语法层级差异
在 MySQL 的世界里,IF(expr, v1, v2)是一个内置函数,返回值是一个标量,所以它可以出现在任何能放表达式的位置——SELECT 列表、WHERE、ORDER BY、甚至 INSERT 的 VALUES 里。这种设计让写 SQL 的人很舒服,因为条件判断被降维成了一个值。
达梦走的是另一条路。它把IF ... THEN ... END IF定义成过程化语句,只能在 PL/SQL 块(存储过程、函数、触发器、匿名块)里出现,是一种控制流,不产生返回值。你把它写进 SELECT 列表,解析器根本不会往函数那个方向去找,直接告诉你找不到这个名字。
这个区别听起来像咬文嚼字,但它决定了你在迁移时该怎么改。如果只是把IF(换成CASE WHEN,大部分情况就够了;但如果你指望在达梦里找到等价的同名函数,那是走不通的。我见过有人为了少改代码,硬要自己建一个叫 IF 的自定义函数,结果踩了一串坑,1.3 节会专门说这件事。
顺带提一句,达梦确实提供了一些兼容性的内置函数,不同版本、不同初始化参数下支持范围不完全一样。所以最靠谱的做法不是背文档,而是在你的实例上直接跑一条最短的验证语句,报错就是没有,能跑就是有。这个习惯建议养成,比查半天资料快得多。
1.2 IF 在达梦中的合法位置:过程块与匿名块
IF 在达梦里要用,就得给它一个过程化的容器。最常见的是匿名块,用来做临时验证或者一次性脚本:
BEGIN IF 1 = 1 THEN PRINT '条件成立'; END IF; END; /几个细节必须注意。IF后面跟的是条件,然后必须写THEN,结尾必须写END IF;,这三段缺一不可。多分支用ELSIF而不是ELSEIF,也不像 MySQL 那样有ELSEIF和ELSE IF两种拼法。这点很坑,因为写惯了 MySQL 的人手指头是有肌肉记忆的。
BEGIN IF v_score >= 90 THEN v_level := 'A'; ELSIF v_score >= 80 THEN v_level := 'B'; ELSIF v_score >= 60 THEN v_level := 'C'; ELSE v_level := 'D'; END IF; END; /在命令行客户端里执行这种块,写完END;之后要在单独一行敲一个/提交执行,否则客户端会一直等着你继续输入。这个/是客户端的分隔符,不是 SQL 的一部分,写进脚本文件的时候要注意别被某些工具当成非法字符。如果是在图形化工具里执行,通常选中整块执行就行,具体行为以你用的工具为准。
还有一点:IF 的条件里判断空值必须用IS NULL,写成= NULL永远不会成立,这一条和 CASE WHEN 里的空值判断是同一个道理,后面 2.3 节会展开。
1.3 查询里要做条件分支,三种替代方案的取舍
既然 IF 不能用在查询里,那SELECT IF(a > 1, 'x', 'y')这种需求怎么办?有三条路。
第一条,也是最推荐的:改写成 CASE WHEN。语义完全等价,标准 SQL 语法,达梦和 MySQL 都认,改完之后如果将来还要往别的库迁,这段代码不用再动。
第二条,用 DECODE 函数。达梦提供了 DECODE,写法是DECODE(expr, search1, result1, search2, result2, ..., default)。它做的是等值匹配,不能写范围条件。所以DECODE(amount, 1000, 'A', 2000, 'B', 'C')是可以的,但amount > 1000这种就没法直接塞进去,得先用 CASE 或者别的办法把范围压成一个值,绕了一圈反而更复杂。
第三条,自己封装一个 IF 函数。这是我见过最多人尝试、也最容易后悔的方案:
CREATE OR REPLACE FUNCTION f_if( p_cond IN INT, p_true IN VARCHAR(200), p_false IN VARCHAR(200) ) RETURN VARCHAR(200) AS BEGIN IF p_cond = 1 THEN RETURN p_true; ELSE RETURN p_false; END IF; END; /建完之后调用是SELECT f_if(CASE WHEN amount > 1000 THEN 1 ELSE 0 END, '大额', '小额') FROM t;——你看,调用方还是得自己写一个 CASE WHEN 把布尔条件转成 0/1,等于绕了一大圈什么都没省,还多了一次函数调用开销,并且返回类型被写死成 VARCHAR(200),遇到数字或者日期分支又得再建一个。
| 方案 | 写法示例 | 适用情况 | 主要局限 |
|---|---|---|---|
| CASE WHEN | CASE WHEN amount > 1000 THEN '大额' ELSE '小额' END | 绝大多数场景,首选 | 写法比 IF() 长一些 |
| DECODE | DECODE(status, 1, '启用', 2, '停用', '未知') | 纯等值映射,分支多 | 不支持范围条件、不支持复杂表达式 |
| 自定义函数 | f_if(...) | 极特殊的统一封装需求 | 调用侧仍需转换条件,类型受限,有性能开销 |
我的建议很直接:别封装,直接改 CASE WHEN。迁移阶段最怕的就是引入一层"自制的兼容层",短期看着省事,长期维护的时候没人记得这层是干嘛的,出了问题排查链路还变长了。改 SQL 是一次性成本,自制兼容层是长期负债。
2. CASE WHEN 的两种形态与达梦的类型收敛规则
2.1 简单 CASE 和搜索 CASE 的匹配逻辑不是一回事
CASE 在达梦(以及标准 SQL)里有两副面孔:
-- 简单 CASE:拿一个表达式去和一堆值做等值比较 SELECT CASE status WHEN 1 THEN '待付款' WHEN 2 THEN '已付款' ELSE '其他' END AS status_name FROM t_order;-- 搜索 CASE:每个 WHEN 后面是一个完整的布尔条件 SELECT CASE WHEN amount >= 10000 THEN 'S' WHEN amount >= 1000 THEN 'A' ELSE 'B' END AS level_tag FROM t_order;简单 CASE 的 WHEN 后面只能放值,不能放条件表达式。这一点 MySQL 也是一样的,但很多人写混了,因为 MySQL 有时候隐式转换比较宽松,写错了不报错但结果不对,等到迁到达梦才暴露出来。
这里有个高频错误:CASE col WHEN NULL THEN 'x' ELSE 'y' END。简单 CASE 的等值比较走的是=语义,而任何值 = NULL的结果是 UNKNOWN 而不是 TRUE,所以这个分支永远命中不了,全部走 ELSE。要判空必须写成搜索 CASE:CASE WHEN col IS NULL THEN 'x' ELSE 'y' END。
另一个容易忽略的点是分支顺序敏感。CASE 从上往下逐条判断,第一个命中的分支生效,后面的不再看。所以写范围条件时一定要从窄到宽、从严到松排:
-- 正确:先严后松 CASE WHEN amount >= 10000 THEN 'S' WHEN amount >= 1000 THEN 'A' WHEN amount >= 100 THEN 'B' ELSE 'C' END如果把WHEN amount >= 100放在最前面,那所有大于 100 的记录都会被判成 'B',后面两个分支形同虚设。这个错误在代码审查里极难发现,因为语法没错、能跑、也不报错,只是业务数据悄悄错了一大片。我在做数据核对的时候,习惯拿边界值单独造几条测试数据跑一遍,比人眼扫 SQL 靠谱得多。
2.2 分支返回类型必须收敛,达梦比 MySQL 严格
MySQL 在类型处理上比较"随和",CASE 各分支一个返回数字、一个返回字符串的时候,它会按一套隐式转换规则给你一个结果,可能是字符串也可能是数字,取决于上下文。这种宽松在开发阶段很舒服,但在数据一致性上是隐患。
达梦的做法是:CASE 表达式整体需要一个确定的返回类型,各分支的类型要能收敛到一起。如果差异太大,轻则被隐式转换(出现意料之外的截断或格式变化),重则直接报类型不匹配的错误。
实际的坑长这样:
-- 有隐患:一个分支返回数字,一个分支返回字符串 SELECT CASE WHEN score IS NULL THEN '未评分' ELSE score END AS score_desc FROM t_student;这段在某些场景下能跑,但返回类型的判定取决于哪一支被当作基准,不同版本可能表现不一致。稳妥的写法是显式统一:
SELECT CASE WHEN score IS NULL THEN '未评分' ELSE CAST(score AS VARCHAR(20)) END AS score_desc FROM t_student;还有一个更隐蔽的问题:数值精度。如果各分支返回的数值精度不一致,比如一支返回DECIMAL(10,2)、另一支返回DECIMAL(18,6),收敛之后的精度可能不是你期望的,涉及金额计算的时候一定要显式 CAST。
| 类型冲突场景 | 可能的表现 | 建议处理 |
|---|---|---|
| 数字与字符串混用 | 隐式转换、格式变化,或直接报错 | 全部 CAST 成 VARCHAR |
| 不同精度的数值混用 | 精度被收敛,小数位丢失 | 显式 CAST 到统一的 DECIMAL |
| 日期与字符串混用 | 按字符串比较,排序结果异常 | 统一用 TO_CHAR 转成固定格式字符串 |
| 大文本与普通字符串混用 | 长度被截断 | 统一 CAST 到足够长的 VARCHAR |
经验上,只要 CASE 的各分支出现类型不一致,立刻加 CAST,别指望隐式转换。这条在迁移项目里能省掉大量"数据看着差不多但就是不对"的排查时间。
2.3 三值逻辑下,NULL 会悄悄吃掉你的分支
SQL 的逻辑是三值的:TRUE、FALSE、UNKNOWN。NULL = NULL的结果不是 TRUE 而是 UNKNOWN,而 CASE 只认 TRUE,所以这个分支不成立。这就导致了一个非常经典的差异:
-- 这条返回 0,因为 NULL = NULL 是 UNKNOWN SELECT CASE WHEN NULL = NULL THEN 1 ELSE 0 END FROM DUAL; -- 这条返回 1,因为 DECODE 把两个 NULL 视为相等 SELECT DECODE(NULL, NULL, 1, 0) FROM DUAL;同一个"判空"的意图,CASE 和 DECODE 给出了相反的结果。这不是 bug,是两个函数的设计哲学不同:DECODE 是 Oracle 体系传下来的老函数,它把 NULL 当作一个可以比较的特殊值;CASE 是标准 SQL 表达式,严格遵守三值逻辑。
在迁移场景里这个差异杀伤力很大。原来 MySQL 里如果有IFNULL或者用<=>(NULL 安全等于)写的判断,搬到达梦改写成 CASE WHEN 的时候,如果直接把col <=> NULL翻成col = NULL,结果就是永远走 ELSE。
达梦里做 NULL 安全比较有几种办法,我常用的是这两种:
-- 方式一:显式判空,最直白 CASE WHEN col IS NULL THEN 'x' ELSE 'y' END -- 方式二:用 DECODE 做等值映射,天然把 NULL 当同类 DECODE(col, NULL, 'x', 'y')这里还有个边界要注意:CASE WHEN col <> 'A' THEN ...在 col 为 NULL 的时候也不会命中,因为NULL <> 'A'同样是 UNKNOWN。所以只要列可能为空,条件里就显式把 NULL 的情况写出来,别指望 ELSE 兜底能兜对——ELSE 兜的是"条件为假",不是"条件未知",虽然在这个例子里两者最终都走 ELSE,但在嵌套 CASE 或者 NOT 取反的场景里,UNKNOWN 的行为会变得非常反直觉。我在写涉及可空列的 CASE 时,习惯第一条分支就写WHEN col IS NULL,把它单独处理掉,剩下的分支就不用再操心了。
3. 从 MySQL 照搬过去最容易翻车的几个点
3.1 函数名对不上:IFNULL、ISNULL、NVL、COALESCE 到底该用哪个
空值处理是条件表达式里绕不开的话题,但两边的函数名差异很大,这是迁移报错的第二大来源(第一是 IF)。
| MySQL 写法 | 达梦常见可用写法 | 说明 |
|---|---|---|
IFNULL(a, b) | NVL(a, b)或COALESCE(a, b) | 部分版本在兼容模式下支持 IFNULL,以实测为准 |
ISNULL(a) | a IS NULL | MySQL 的 ISNULL 返回 0/1,达梦里直接用判断式更稳 |
IF(cond, a, b) | CASE WHEN cond THEN a ELSE b END | 语法层级不同,必须改写 |
NULLIF(a, b) | NULLIF(a, b) | 这个两边都有,写法一致 |
COALESCE(...) | COALESCE(...) | 标准 SQL,两边都支持,优先用它 |
我的建议是统一往 COALESCE 上收。它是标准 SQL 函数,参数个数可变,MySQL 和达梦都认,写一次两边都能跑,将来再迁到别的库也不用改。NVL 虽然达梦支持,但它只有两个参数,遇到三个以上候选值就得嵌套,可读性差。
这里有个实操上的小技巧:迁移的时候不要一个文件一个文件地手工找IFNULL,直接用系统视图或者文本搜索把整个代码库扫一遍,统计出所有出现的位置,批量替换之后再统一回归测试。手工边看边改一定会漏。
3.2 空串和 NULL 的等价性,两边处理不一样
这一条在迁移里极其隐蔽,因为它不报错,只是数据统计结果对不上。
MySQL 里空字符串''和NULL是两个完全不同的值,'' IS NULL返回 0。而在达梦的部分兼容模式下,空串会被当作 NULL 处理,'' IS NULL可能返回 1。这直接影响 CASE WHEN 的判断结果:CASE WHEN col = '' THEN ...和CASE WHEN col IS NULL THEN ...在两个库里的命中范围会不一样。
别猜,直接在你的实例上跑一条:
SELECT CASE WHEN '' IS NULL THEN '空串等于NULL' ELSE '空串不等于NULL' END AS flag FROM DUAL;跑出来是什么,就以什么为准写后续逻辑。如果业务上确实需要区分空串和 NULL,那就得在表设计层面约定清楚(比如统一用 NULL 表示"没有值",禁止写入空串),光靠 SQL 层面兜是兜不住的。
3.3 布尔表达式不能直接当值来用
MySQL 里这个写法很常见:
-- MySQL:条件表达式直接产出 0/1 SELECT id, amount > 100 AS is_big FROM t_order;达梦不支持把布尔表达式的结果当标量返回,这条搬过去大概率报错。改写方式就是包一层 CASE:
SELECT id, CASE WHEN amount > 100 THEN 1 ELSE 0 END AS is_big FROM t_order;还有动态拼 SQL 时常见的WHERE 1,达梦这边建议统一写成WHERE 1=1。别看只差三个字符,WHERE 1在很多库里会被当成"常量条件恒真",但在达梦里可能被解析成一个无效的列名或者类型不匹配,报错信息还不太直观。动态拼接 SQL 的时候,WHERE 1=1本来就是标准做法(方便后面无脑拼AND xxx),顺手统一掉就行。
3.4 注释符、结束符、标识符大小写,脚本层面的坑
这几个不属于 CASE WHEN 本身,但脚本整体迁过去跑不起来的时候,往往就是它们捣的鬼。
注释符:MySQL 支持#开头的行注释,达梦不认,只认--和/* */。一个几百行的初始化脚本,中间夹着几行# 初始化基础数据,整个脚本可能就在那儿报语法错,而且报错位置还未必指向注释那一行,排查起来很费劲。批量替换成--就行。
结束符:普通语句用分号结束没问题,但过程块、匿名块在命令行客户端里需要/来提交。写脚本文件的时候要注意你的执行工具是怎么切分语句的,有些工具会把块内部的;当成语句结束,把一个完整的过程切成碎片。遇到这种情况,通常需要调整工具的分隔符设置,或者改用工具提供的"整块执行"功能。
标识符大小写:达梦默认对标识符大小写敏感,未加双引号的标识符在存储时会统一处理,而 MySQL 在部分操作系统上表名是大小写不敏感的。迁移之后如果代码里写的是Select * From T_Order,而实际建表时对象名被存成了大写,就可能出现"无效的列名"或者"表或视图不存在"。这类问题的排查方法是:先用一条最简单的SELECT * FROM 表名确认对象名到底叫什么,别一上来就怀疑复杂逻辑。
4. CASE WHEN 用在哪些场景,怎么写才不容易错
4.1 按条件做行列转换统计
这是 CASE WHEN 最高频的用法,没有之一。一张订单表要按部门统计各状态的单量,横着排成几列:
SELECT dept_id, COUNT(*) AS cnt_all, SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS cnt_wait_pay, SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) AS cnt_paid, SUM(CASE WHEN status IN (3, 4) THEN 1 ELSE 0 END) AS cnt_closed, SUM(CASE WHEN status = 2 AND amount >= 1000 THEN 1 ELSE 0 END) AS cnt_paid_big FROM t_order WHERE create_time >= TO_DATE('2025-01-01', 'YYYY-MM-DD') GROUP BY dept_id;这里用SUM(1/0)而不是COUNT,是因为 0 也会被 SUM 统计进求和结果,而 COUNT 会忽略 0,两种写法在逻辑上都成立:
SUM(CASE WHEN 条件 THEN 1 ELSE 0 END):条件成立加 1,不成立加 0,全部计入;COUNT(CASE WHEN 条件 THEN 1 END):不写 ELSE 时默认是 NULL,COUNT 天然忽略 NULL,也能得到正确结果。
我更倾向第一种,因为显式写出 0 让阅读的人一眼就知道意图,不用去想 COUNT 忽略 NULL 这个隐含规则。但如果统计列特别多,第二种写起来更短,看团队习惯。
要提醒一句:这种写法在宽表上会扫很多列,如果统计维度多、数据量大,执行时间可能不理想。可以考虑在业务低峰跑,或者预计算成一张汇总表按天刷新。CASE WHEN 是表达能力的工具,不是性能的银弹。
4.2 UPDATE 里的条件赋值
批量给不同区间的数据打不同标签,这是典型的 CASE WHEN 场景:
UPDATE t_order SET level_tag = CASE WHEN amount >= 10000 THEN 'S' WHEN amount >= 1000 THEN 'A' WHEN amount >= 100 THEN 'B' ELSE 'C' END, update_time = SYSDATE WHERE status = 1 AND level_tag IS NULL;这段有几个地方值得说。
第一,条件顺序又一次从严到宽,理由和 2.1 节一样。
第二,WHERE里加了level_tag IS NULL,这是幂等性保护。不加的话,一条语句重跑两次,第一次打上的标签第二次又会被重算一遍,虽然结果一样,但update_time会被刷新,影响后续基于更新时间的增量同步逻辑。批量脚本一定要考虑重跑。
第三,达梦的 UPDATE 支持在 SET 里用 CASE WHEN,也支持用 MERGE INTO 做条件更新。如果更新逻辑复杂到需要关联多张表判断,MERGE INTO 会比一大坨 CASE WHEN 清楚得多,可读性和可维护性都更好。
4.3 ORDER BY 里做业务自定义排序
状态字段的排序需求几乎每个系统都有:希望"进行中"排最前面,"已完成"次之,"已取消"最后,而且同一状态内按时间倒序。
SELECT order_id, status, create_time FROM t_order WHERE dept_id = 1001 ORDER BY CASE status WHEN 'RUNNING' THEN 1 WHEN 'WAIT' THEN 2 WHEN 'DONE' THEN 3 ELSE 4 END, create_time DESC;这种写法好处是不用改表结构、不用加字段,逻辑集中在 SQL 里,业务规则变了改一处就行。
但它有个代价:排序表达式是计算出来的,数据库没法直接利用 status 列上的索引来完成排序,通常会先做一次全量取数再排序。数据量在几十万以内,感受不明显;上到千万级,这条 SQL 可能就是压垮系统的那一根稻草。
如果确认这个排序是业务刚需且高频,比较务实的做法是加一个sort_no字段,在数据写入或者后台任务里维护好,然后ORDER BY sort_no, create_time DESC,索引直接能用。用空间和一点维护成本换查询性能,在核心列表页上是划算的。我在做这类改造之前,一般会先看执行计划确认是不是真的走了排序,别凭感觉优化。
4.4 存储过程里 IF 和 CASE 的分工
前面说 IF 只能在过程块里用,那在过程块里,IF 和 CASE 该怎么分工?我的原则是:IF 管流程控制,CASE 管取值。
IF 用来决定"走哪条路",比如参数校验、分支处理、提前返回;CASE 用来产出"一个值",比如给变量赋值、拼返回结果。看一个完整的例子:
CREATE OR REPLACE PROCEDURE p_calc_level( p_score IN INT, p_level OUT VARCHAR(20) ) AS BEGIN -- IF 负责流程:先处理异常输入,避免后面反复判空 IF p_score IS NULL THEN p_level := 'UNKNOWN'; RETURN; END IF; -- CASE 负责取值:纯映射逻辑,写成一整块更好读 p_level := CASE WHEN p_score >= 90 THEN 'A' WHEN p_score >= 80 THEN 'B' WHEN p_score >= 60 THEN 'C' ELSE 'D' END; END; /这个结构的好处是判空只做一次,后面的取值逻辑干干净净,不用在那五个分支里各写一遍IS NOT NULL。如果把判空揉进 CASE 里,逻辑也能跑,但过一个礼拜自己回来看就得重新理一遍。
顺带说一个调试上的经验:过程里想知道变量走到哪一步了,可以用输出语句打印,但命令行客户端里可能需要先开启输出开关才能看到结果,具体命令和行为各版本略有不同。如果输出死活看不到,最省事的方法是建一张临时调试表,把中间变量 INSERT 进去,跑完看一眼表,比跟客户端较劲快得多。
5. 性能这块别踩坑:CASE WHEN 写在哪决定了它是不是负担
5.1 WHERE 里给列套 CASE,等于自己把索引扔掉
这是我在代码审查里见到最多的一类性能反模式:
-- 反例:WHERE 里对表达式做计算 SELECT * FROM t_order WHERE CASE WHEN status = 1 THEN create_time ELSE update_time END >= TO_DATE('2025-01-01', 'YYYY-MM-DD');这条 SQL 的语义是"未付款的看创建时间,已付款的看更新时间",业务意图没问题,但执行的时候数据库没法用create_time或者update_time上的任何索引,因为 WHERE 里比较的不是列,而是一个算出来的表达式。它只能把表扫一遍,逐行算这个 CASE 再判断。
改写的方向是把"对列的运算"变成"对查询的拆分":
-- 改法一:拆成两个条件用 OR 连接,各自能走各自的索引 SELECT * FROM t_order WHERE (status = 1 AND create_time >= TO_DATE('2025-01-01', 'YYYY-MM-DD')) OR (status <> 1 AND update_time >= TO_DATE('2025-01-01', 'YYYY-MM-DD'));-- 改法二:UNION ALL,两条子查询各自走索引,再合并去重 SELECT * FROM t_order WHERE status = 1 AND create_time >= TO_DATE('2025-01-01', 'YYYY-MM-DD') UNION ALL SELECT * FROM t_order WHERE status <> 1 AND update_time >= TO_DATE('2025-01-01', 'YYYY-MM-DD');两种改法各有适用场景。OR 的写法更紧凑,但优化器对 OR 的处理不一定都给力;UNION ALL 把两条路径彻底分开,执行计划清晰可控,缺点是 SQL 变长、需要确认两边没有重复数据(有重叠的话要换成 UNION 去重,但 UNION 有排序开销)。
我的选择标准是:数据量小、逻辑简单就用 OR;数据量大、两条分支的过滤条件差异明显就用 UNION ALL。判断的依据不是拍脑袋,跑一下执行计划看实际走了什么。
5.2 用执行计划确认改写到底有没有生效
改完不回验证,等于没改。达梦里查看执行计划,最直接的方式是在语句前面加 EXPLAIN:
EXPLAIN SELECT * FROM t_order WHERE status = 1 AND create_time >= TO_DATE('2025-01-01', 'YYYY-MM-DD');看执行计划的时候,重点盯几个地方。一是扫描方式,如果出现全表扫描的操作符(不同版本显示的名称略有差异,常见的如 CSCN 表示全表扫描、SSEK 表示二级索引扫描),说明索引没用上。二是看谓词有没有下推到扫描节点,如果过滤条件是在上层节点做的,说明扫描阶段没过滤掉多少数据。三是看估算的行数和实际行数差多少,差得离谱通常意味着统计信息过期了。
统计信息这一点值得单独说。达梦里表的统计信息不会自动实时更新,数据量变化大之后执行计划可能变得很奇怪——明明有索引却不用,或者用了索引反而更慢。遇到这种情况,先别急着改 SQL,把统计信息重新收集一下再看。这个动作在做性能排查的时候应该排在很前面,成本低、收益大。
另外,执行计划的输出格式和操作符名称会随版本变化,别死记某个字符串,理解"这个节点是在扫表还是在走索引、过滤是在哪一层做的"这个判断逻辑就够了。
5.3 大批量条件更新要分批,别一条语句打到底
前面 4.2 节给的 UPDATE 语句,如果 WHERE 命中的行数是几十万甚至上百万,直接执行会带来几个问题:事务日志暴涨、锁持有时间过长、回滚段压力大,严重的话会影响其他会话的正常读写。
稳妥的做法是分批处理:
-- 每批处理 5000 行,循环执行直到影响行数为 0 UPDATE t_order SET level_tag = CASE WHEN amount >= 10000 THEN 'S' WHEN amount >= 1000 THEN 'A' ELSE 'B' END WHERE level_tag IS NULL AND ROWNUM <= 5000;每批执行完提交一次,让事务小步快跑。批大小没有标准答案,5000 到 20000 之间是比较常见的区间,具体要看单行数据的大小和系统负载,先在测试环境跑一遍观察耗时和日志增长,再定到生产上。
还有一个容易忽略的点:分批更新的时候,如果 CASE 的条件依赖其他会被并发修改的列(比如 amount),可能出现前后不一致。这种情况要么在业务低峰执行,要么先把待处理的主键捞出来存到临时表,再按主键分批更新,保证处理的数据集在过程中是稳定的。
6. 一份可以直接照着做的改写对照与自查清单
6.1 常见写法的改写对照表
| 原始写法(MySQL 风格) | 达梦可用写法 | 备注 |
|---|---|---|
IF(a > 1, 'x', 'y') | CASE WHEN a > 1 THEN 'x' ELSE 'y' END | 查询里必须改 |
IFNULL(a, 'none') | COALESCE(a, 'none') | 优先用标准函数 |
ISNULL(a) | a IS NULL | 布尔值当条件用 |
a <=> NULL | a IS NULL或DECODE(a, NULL, ...) | 注意 CASE 不支持 NULL 安全等于 |
SELECT a > 1 AS flag | SELECT CASE WHEN a > 1 THEN 1 ELSE 0 END AS flag | 布尔表达式不能直接当值 |
WHERE 1 | WHERE 1=1 | 动态拼 SQL 常用 |
# 注释 | -- 注释 | 脚本批量替换 |
LIMIT 10 | 各版本支持情况不同,可用WHERE ROWNUM <= 10兜底 | 以实测为准 |
这张表建议直接贴到迁移项目的文档里,改 SQL 的人手一份。它不解决全部问题,但能挡掉八成以上的低级报错。
6.2 上线前的自查清单
改完 SQL 不是终点,得有一套验证动作。我通常按这几步走:
第一步,语法层验证。把改过的脚本在测试库完整跑一遍,注意是完整跑,不是挑几条跑。很多语法错误藏在长脚本的中间,挑着跑很容易漏掉。
第二步,结果集比对。挑十到二十条有代表性的 SQL,把原库和新库的执行结果导出来做差异比对。重点挑那些涉及可空列、涉及边界值(刚好等于阈值)的语句,这些地方最容易出问题。比对的时候别只看行数,要看具体值。
第三步,执行计划检查。对核心业务 SQL 逐条看执行计划,确认没有出现意外的全表扫描。这一步很多人跳过,等到上线后慢查询告警了才回头查,代价高得多。
第四步,可空列专项排查。把所有用到 CASE WHEN 的语句过一遍,检查每条分支里的判空是否用了IS NULL,是否所有可空列都被显式覆盖。这是最容易埋雷的地方,值得单独花时间。
第五步,小流量灰度。如果条件允许,先切一小部分流量到新库,观察一段时间。这一步能发现测试环境模拟不出来的问题,比如并发下的锁行为差异、长事务的影响。
6.3 一个降低改造成本的小办法
最后分享一个我在实际项目里用过的做法:把 CASE WHEN 的映射逻辑抽成视图。
比如"订单状态码转名称"这种映射,如果散落在十几个查询里,将来状态码一改就得挨个找。把它做成一个视图:
CREATE OR REPLACE VIEW v_order_status AS SELECT code, CASE code WHEN 1 THEN '待付款' WHEN 2 THEN '已付款' WHEN 3 THEN '已发货' WHEN 4 THEN '已完成' WHEN 5 THEN '已取消' ELSE '未知状态' END AS status_name FROM t_dict_status;查询的时候 JOIN 一下就行。好处是映射规则只有一份,改动只改一处;坏处是多一次 JOIN,极端性能场景下要考虑清楚。
我个人在实操中的体会是,迁移项目里真正拖慢进度的往往不是那些复杂的大 SQL,而是一堆零碎的、写法五花八门的条件判断。与其一条条硬改,不如先花半天时间把重复模式识别出来,能抽视图的抽视图,能统一函数名的统一函数名,后面改起来会顺很多。至于达梦和 MySQL 在 CASE WHEN 上那些细微的行为差异,别指望一次看完就全记住,把验证语句攒成一个小脚本,遇到不确定的地方跑一下,比翻文档快,也比凭印象写代码靠谱。