数据库迁移现场有一种问题很磨人:SQL 不报错,数据也不是完全不对,只是偶尔查不到。
拿到开发工具里重跑,结果又出来了。多跑几遍还是正常。于是大家开始怀疑连接池、网络,或者怀疑金仓数据库执行计划不稳定。折腾一圈,真正的问题可能只是WHERE里放了两个函数,而且后一个函数要等前一个函数执行完才能正常工作。
代码大致是这个样子:
SELECTid1,name1FROMmy_tableWHEREpkg_abc.set_id(10)=1ANDid1=pkg_abc.get_id();写这段代码的人想得很顺:先用set_id(10)把值存起来,再由get_id()取出来。两行条件从上往下排着,似乎已经把步骤交代清楚了。
可WHERE不是流程图。
A AND B只说明 A、B 最终都要成立,并没有规定数据库必须先算 A,再算 B。优化器关心的是怎样找到符合条件的行,它可能调整条件的处理方式,也可能把表达式放到另一个计划节点。今天看执行计划,set_id()确实在前面;明天数据量变了,计划跟着变,这个顺序未必还在。
有人会把条件上下交换:
SELECTid1,name1FROMmy_tableWHEREid1=pkg_abc.get_id()ANDpkg_abc.set_id(10)=1;这样更糟。新会话里,get_id()读到的可能是初始值,查询直接返回空集。偏偏它又不是每次都错。如果当前连接以前调用过set_id(),包变量还留着旧值,这条 SQL 可能正常返回。
这种“有时候能跑”比直接报错麻烦得多。手工测试一般在同一个客户端窗口里连续进行,前面的操作已经把会话状态准备好了。到了应用端,连接从池里取出来,谁也不知道它刚创建,还是刚服务过另一个请求。测试时看不见的问题,换个连接就冒出来了。
所以我看到查询条件里有set、init、put这类函数,第一反应不是研究它应该写左边还是右边,而是先查函数有没有改状态。只要函数会修改包变量、临时数据或者业务表,就不该依赖它在过滤过程中“恰好先执行”。
该设置的状态,在查询前明确设置:
-- 伪代码:由应用或存储过程先完成上下文设置CALLpkg_abc.set_id(10);-- 查询阶段只读取,不再偷偷改变状态SELECTid1,name1FROMmy_tableWHEREid1=pkg_abc.get_id();如果10本来就是应用传进来的业务参数,那就更简单,直接绑定给查询:
SELECTid1,name1FROMmy_tableWHEREid1=:current_id;少了一层看似聪明的封装,问题反而清楚了。谁传的值、这次查询用了什么值,都能顺着调用链查到,不必猜连接里还残留着什么。
“前面已经拦住了”,这句话也靠不住
同一种误解,还会出现在数据转换上。
比如导入表里有一个字符字段amount_text。有人为了跳过脏数据,会这样写:
SELECTamount_textFROMpayment_importWHEREis_number(amount_text)=1ANDto_number(amount_text)>1000;他的解释通常是:“前面已经判断过是不是数字了,不是数字的行走不到to_number。”
听上去很合理,这是写过程代码时常用的短路思路。放进 SQL,就不能只靠条件位置作保证。数据库并没有义务按照我们看到的顺序逐项求值。只要非法字符真的进入to_number(),转换错误照样会发生。
除零也有人这么防:
WHEREdenominator<>0ANDnumerator/denominator>0.8我更愿意让除法本身能接住零,而不是安排另一个条件站在前面:
WHEREnumerator/NULLIF(denominator,0)>0.8分母为零时,NULLIF返回NULL,表达式不会满足大于条件。这里的安全性来自表达式本身,不来自“希望数据库先判断哪一行”。
字符转数字要麻烦一点。可以使用实际环境支持的安全转换方式,也可以在数据入库时先完成校验,把合法值转换后放入数值列,把原始内容留在旁边供追溯。至少别让核心业务查询一边猜字符串是不是数字,一边又立刻做数值运算。
这不是语法洁癖。导入数据早晚会出现一条'待确认'、一个全角数字,或者一串只在页面上看不出的空格。正常样本跑通,只能说明样本太正常。
条件顺序的错觉,不只影响函数
再看一条常见的订单查询:
SELECTo.order_id,p.pay_statusFROMorders oLEFTJOINpayments pONo.order_id=p.order_idWHEREp.pay_status='SUCCESS';业务人员说:“先左连接,订单已经全保留了,后面只是筛一下支付成功状态。”
这句话的问题仍然在“先”和“后”。连接没匹配到支付记录时,右表字段会被补成NULL;到了WHERE,NULL不满足pay_status = 'SUCCESS',整行订单就被过滤了。写了LEFT JOIN,最后却只剩匹配成功的记录。
金仓数据库的优化器发现补空行必然会被后续条件排除时,可能直接把外连接转换成内连接。看到计划变化,不要急着下结论说优化器改错了。原 SQL 的结果本来就和内连接相同。
若需求真是保留所有订单,只关联支付成功的数据,条件应该放在连接规则里:
SELECTo.order_id,p.pay_statusFROMorders oLEFTJOINpayments pONo.order_id=p.order_idANDp.pay_status='SUCCESS';我一般用一个很土、但好用的问题判断条件放哪:右边没有符合条件的数据,左边这条还要不要?
要,那就不能让右表条件在WHERE里把它删掉。不要,原写法可能没有任何问题。并不是见到右表条件就往ON里挪,还是得把业务要求问清楚。
顺便说一句,执行计划中出现Hash Join或Nested Loop,不等于已经判断出内外连接。那是所用的连接算法。真正要看的是计划节点的连接类型,以及条件落在哪个节点。拿算法名称直接判断语义,很容易查偏。
排查这类问题,我会先换一个新会话
遇到“工具里正常、应用里异常”的 SQL,我会先关掉当前连接,重新开一个干净会话。这个动作很简单,却能排掉不少由包变量、临时状态和初始化脚本造成的假象。
接着去看自定义函数。不是只看函数返回什么,还要看它背后做了什么:有没有写包变量,有没有查依赖会话的对象,有没有修改数据,同一个输入连续调用两次是否一定返回同样结果。函数名字叫get_xxx,也不代表它真的只读,代码还是要打开看。
测试数据我也不会只用正常值。字符列里放一条不能转换的内容,分母放一个零,连接右表故意缺一条记录,再让连接池把同一个会话交给两个不同业务上下文。很多 SQL 平时“特别稳定”,只是因为没人拿这些数据试过它。
至于条件到底先执行哪个,可以通过实际执行计划观察,但观察结果只能解释这一次,不能拿来当长期承诺。优化器今天选择这个计划,并没有答应以后永远照这个顺序走。
说到底,查询条件应该像筛子:不管数据库从哪一层开始筛,最后都得到同一个正确结果。它不应该像一排机关,必须先碰第一个、再碰第二个,顺序错一下,整个查询就失灵。
传统数据库迁移到金仓数据库时,真正值得修掉的不是某个条件的摆放位置,而是这种对执行顺序的依赖。把状态设置移出查询,把请求值改成明确参数,让可能报错的表达式自己处理边界输入。代码可能没以前那么“巧”,但接手的人看得懂,换个会话还能跑,执行计划变了也不怕。
这就够了。