传统数据库迁移国产化,别把 WHERE 条件当成程序执行

数据库迁移现场有一种问题很磨人:SQL 不报错,数据也不是完全不对,只是偶尔查不到。

拿到开发工具里重跑,结果又出来了。多跑几遍还是正常。于是大家开始怀疑连接池、网络,或者怀疑金仓数据库执行计划不稳定。折腾一圈,真正的问题可能只是 WHERE 里放了两个函数,而且后一个函数要等前一个函数执行完才能正常工作。

代码大致是这个样子:

sql 复制代码
SELECT id1, name1
FROM my_table
WHERE pkg_abc.set_id(10) = 1
  AND id1 = pkg_abc.get_id();

写这段代码的人想得很顺:先用 set_id(10) 把值存起来,再由 get_id() 取出来。两行条件从上往下排着,似乎已经把步骤交代清楚了。

WHERE 不是流程图。

A AND B 只说明 A、B 最终都要成立,并没有规定数据库必须先算 A,再算 B。优化器关心的是怎样找到符合条件的行,它可能调整条件的处理方式,也可能把表达式放到另一个计划节点。今天看执行计划,set_id() 确实在前面;明天数据量变了,计划跟着变,这个顺序未必还在。

有人会把条件上下交换:

sql 复制代码
SELECT id1, name1
FROM my_table
WHERE id1 = pkg_abc.get_id()
  AND pkg_abc.set_id(10) = 1;

这样更糟。新会话里,get_id() 读到的可能是初始值,查询直接返回空集。偏偏它又不是每次都错。如果当前连接以前调用过 set_id(),包变量还留着旧值,这条 SQL 可能正常返回。

这种"有时候能跑"比直接报错麻烦得多。手工测试一般在同一个客户端窗口里连续进行,前面的操作已经把会话状态准备好了。到了应用端,连接从池里取出来,谁也不知道它刚创建,还是刚服务过另一个请求。测试时看不见的问题,换个连接就冒出来了。

所以我看到查询条件里有 setinitput 这类函数,第一反应不是研究它应该写左边还是右边,而是先查函数有没有改状态。只要函数会修改包变量、临时数据或者业务表,就不该依赖它在过滤过程中"恰好先执行"。

该设置的状态,在查询前明确设置:

sql 复制代码
-- 伪代码:由应用或存储过程先完成上下文设置
CALL pkg_abc.set_id(10);

-- 查询阶段只读取,不再偷偷改变状态
SELECT id1, name1
FROM my_table
WHERE id1 = pkg_abc.get_id();

如果 10 本来就是应用传进来的业务参数,那就更简单,直接绑定给查询:

sql 复制代码
SELECT id1, name1
FROM my_table
WHERE id1 = :current_id;

少了一层看似聪明的封装,问题反而清楚了。谁传的值、这次查询用了什么值,都能顺着调用链查到,不必猜连接里还残留着什么。

"前面已经拦住了",这句话也靠不住

同一种误解,还会出现在数据转换上。

比如导入表里有一个字符字段 amount_text。有人为了跳过脏数据,会这样写:

sql 复制代码
SELECT amount_text
FROM payment_import
WHERE is_number(amount_text) = 1
  AND to_number(amount_text) > 1000;

他的解释通常是:"前面已经判断过是不是数字了,不是数字的行走不到 to_number。"

听上去很合理,这是写过程代码时常用的短路思路。放进 SQL,就不能只靠条件位置作保证。数据库并没有义务按照我们看到的顺序逐项求值。只要非法字符真的进入 to_number(),转换错误照样会发生。

除零也有人这么防:

sql 复制代码
WHERE denominator <> 0
  AND numerator / denominator > 0.8

我更愿意让除法本身能接住零,而不是安排另一个条件站在前面:

sql 复制代码
WHERE numerator / NULLIF(denominator, 0) > 0.8

分母为零时,NULLIF 返回 NULL,表达式不会满足大于条件。这里的安全性来自表达式本身,不来自"希望数据库先判断哪一行"。

字符转数字要麻烦一点。可以使用实际环境支持的安全转换方式,也可以在数据入库时先完成校验,把合法值转换后放入数值列,把原始内容留在旁边供追溯。至少别让核心业务查询一边猜字符串是不是数字,一边又立刻做数值运算。

这不是语法洁癖。导入数据早晚会出现一条 '待确认'、一个全角数字,或者一串只在页面上看不出的空格。正常样本跑通,只能说明样本太正常。

条件顺序的错觉,不只影响函数

再看一条常见的订单查询:

sql 复制代码
SELECT o.order_id, p.pay_status
FROM orders o
LEFT JOIN payments p
  ON o.order_id = p.order_id
WHERE p.pay_status = 'SUCCESS';

业务人员说:"先左连接,订单已经全保留了,后面只是筛一下支付成功状态。"

这句话的问题仍然在"先"和"后"。连接没匹配到支付记录时,右表字段会被补成 NULL;到了 WHERENULL 不满足 pay_status = 'SUCCESS',整行订单就被过滤了。写了 LEFT JOIN,最后却只剩匹配成功的记录。

金仓数据库的优化器发现补空行必然会被后续条件排除时,可能直接把外连接转换成内连接。看到计划变化,不要急着下结论说优化器改错了。原 SQL 的结果本来就和内连接相同。

若需求真是保留所有订单,只关联支付成功的数据,条件应该放在连接规则里:

sql 复制代码
SELECT o.order_id, p.pay_status
FROM orders o
LEFT JOIN payments p
  ON o.order_id = p.order_id
 AND p.pay_status = 'SUCCESS';

我一般用一个很土、但好用的问题判断条件放哪:右边没有符合条件的数据,左边这条还要不要?

要,那就不能让右表条件在 WHERE 里把它删掉。不要,原写法可能没有任何问题。并不是见到右表条件就往 ON 里挪,还是得把业务要求问清楚。

顺便说一句,执行计划中出现 Hash JoinNested Loop,不等于已经判断出内外连接。那是所用的连接算法。真正要看的是计划节点的连接类型,以及条件落在哪个节点。拿算法名称直接判断语义,很容易查偏。

排查这类问题,我会先换一个新会话

遇到"工具里正常、应用里异常"的 SQL,我会先关掉当前连接,重新开一个干净会话。这个动作很简单,却能排掉不少由包变量、临时状态和初始化脚本造成的假象。

接着去看自定义函数。不是只看函数返回什么,还要看它背后做了什么:有没有写包变量,有没有查依赖会话的对象,有没有修改数据,同一个输入连续调用两次是否一定返回同样结果。函数名字叫 get_xxx,也不代表它真的只读,代码还是要打开看。

测试数据我也不会只用正常值。字符列里放一条不能转换的内容,分母放一个零,连接右表故意缺一条记录,再让连接池把同一个会话交给两个不同业务上下文。很多 SQL 平时"特别稳定",只是因为没人拿这些数据试过它。

至于条件到底先执行哪个,可以通过实际执行计划观察,但观察结果只能解释这一次,不能拿来当长期承诺。优化器今天选择这个计划,并没有答应以后永远照这个顺序走。

说到底,查询条件应该像筛子:不管数据库从哪一层开始筛,最后都得到同一个正确结果。它不应该像一排机关,必须先碰第一个、再碰第二个,顺序错一下,整个查询就失灵。

传统数据库迁移到金仓数据库时,真正值得修掉的不是某个条件的摆放位置,而是这种对执行顺序的依赖。把状态设置移出查询,把请求值改成明确参数,让可能报错的表达式自己处理边界输入。代码可能没以前那么"巧",但接手的人看得懂,换个会话还能跑,执行计划变了也不怕。

这就够了。

相关推荐
小陈工1 小时前
第8篇:Flask轻量级框架与扩展生态深度解析(下)
后端·python·面试
Conan在掘金1 小时前
鸿蒙 韶非 UI 系列:能力调用 startAbilityForResult,跳能力拿回参,鸿蒙能力路由入门
后端
王中阳Go1 小时前
面试拷打实录:候选人聊Agent/RAG时的典型误区,我给了这些“避坑指南”
后端·面试·agent
Conan在掘金1 小时前
鸿蒙 韶非 UI 系列:后台任务 backgroundTaskManager,延迟挂起 + 持续后台跑,告别前台才活
后端
xianjixiance_2 小时前
HarmonyOS应用开发实战:萌宠日记 - json5-配置文件详解
后端
b130538100492 小时前
HarmonyOS应用开发实战:萌宠日记 - 应用启动流程与闪屏页面设计
后端
寒草2 小时前
「寒草呈献」工作六年,是否仍有创造未来的勇气 ✨
前端·后端
程序员爱钓鱼3 小时前
为什么学习 Go?Go 能做什么?
后端·面试·go
程序员爱钓鱼3 小时前
Rust 切片 Slice 详解:安全访问连续数据
前端·后端·rust