
一、 背景上个月24号接到运维来电“快帮忙看看账务对账跑不平批处理作业卡住了但奇怪的是手动执行SQL又能查到数据”经过两个小时的排查罪魁祸首指向了一段看似正常的代码里面藏着一个在Oracle时代就存在的坏习惯在WHERE子句中依赖函数执行顺序。下面我就把整个复盘过程包括建表、造数、复现Bug以及最终的修复方案完整地记录下来。这套脚本你直接拿去KES电科金仓环境里跑绝对能复现那个让人抓狂的Bug。二、 搭建“犯罪现场”环境初始化为了还原当时的场景我们首先要在KES里构建一个包含全局变量的包Package和业务表。这种写法在老系统里太常见了利用包变量在不同SQL之间传递参数。2.1 创建业务数据表我们先建一张account_balance账户余额表用来存客户的账户信息。-- -- 脚本段 1: 创建业务表并初始化数据 -- 注意以下脚本请在 KES 环境中执行 -- DROP TABLE IF EXISTS account_balance; CREATE TABLE account_balance ( acct_id NUMBER(10) PRIMARY KEY, cust_id NUMBER(10) NOT NULL, balance NUMBER(15, 2) DEFAULT 0.00, acct_status VARCHAR2(20) DEFAULT NORMAL, update_time DATE DEFAULT SYSDATE ); COMMENT ON TABLE account_balance IS 账户余额表; COMMENT ON COLUMN account_balance.acct_status IS 状态NORMAL-正常, FROZEN-冻结, CLOSED-销户; -- 插入4条核心测试数据 -- 注意这里的数据客户101有两条记录这很重要 INSERT INTO account_balance (acct_id, cust_id, balance, acct_status) VALUES (10001, 101, 5000.00, NORMAL); INSERT INTO account_balance (acct_id, cust_id, balance, acct_status) VALUES (10002, 102, 3000.50, NORMAL); INSERT INTO account_balance (acct_id, cust_id, balance, acct_status) VALUES (10003, 101, 8000.00, FROZEN); INSERT INTO account_balance (acct_id, cust_id, balance, acct_status) VALUES (10004, 103, 1200.00, NORMAL); COMMIT;2.2 创建“祸根”带全局变量的Package接下来是重头戏。我们创建一个pkg_session_data包。这个包里有一个全局变量g_cust_id以及一对set和get函数。注意 这种包级别的全局变量在Oracle和KES中都是会话隔离的。也就是说只要你不断开连接这个变量的值就会一直存在。-- -- 脚本段 2: 创建带有全局变量的 Package -- CREATE OR REPLACE PACKAGE pkg_session_data IS -- 全局变量存储当前操作的客户ID -- 这就是我们要重点关注的“污染源” g_cust_id NUMBER(10); -- 设置函数修改全局变量并返回状态码 FUNCTION set_cust_id(p_cust_id IN NUMBER) RETURN NUMBER; -- 获取函数读取全局变量 FUNCTION get_cust_id RETURN NUMBER; -- 清理函数重置状态用于测试 PROCEDURE reset_context; END pkg_session_data; / CREATE OR REPLACE PACKAGE BODY pkg_session_data IS FUNCTION set_cust_id(p_cust_id IN NUMBER) RETURN NUMBER IS BEGIN -- 模拟复杂的业务逻辑 IF p_cust_id IS NULL THEN g_cust_id : NULL; RETURN 0; ELSE g_cust_id : p_cust_id; -- 修改会话级状态 RETURN 1; -- 返回成功标志 END IF; END set_cust_id; FUNCTION get_cust_id RETURN NUMBER IS BEGIN RETURN g_cust_id; END get_cust_id; PROCEDURE reset_context IS BEGIN g_cust_id : NULL; -- 清空状态 END reset_context; END pkg_session_data; / -- 初始化确保环境干净 CALL pkg_session_data.reset_context();三、 复现“幽灵Bug”危险的查询环境准备好了。现在我们来看看那条让系统崩溃的SQL。业务逻辑很简单查询客户101的所有账户。但开发人员的写法非常“巧妙”。3.1 编写那条“毒SQL”-- -- 脚本段 3: 高危SQL - 依赖 WHERE 子句执行顺序 -- SELECT acct_id, cust_id, balance, acct_status FROM account_balance WHERE -- 陷阱试图先获取值 cust_id pkg_session_data.get_cust_id() -- 更大的陷阱试图后设置值并依赖上面的 get 能拿到这个值 AND pkg_session_data.set_cust_id(101) 1;开发者的逻辑设想SQL执行时数据库会老老实实从左到右干活。先调用get_cust_id()看看变量里存的是啥。再调用set_cust_id(101)把101塞进去并返回1。最后筛选cust_id 101。完美。现实打脸时刻请打开你的KES客户端比如KStudio在一个全新的会话中执行上面的SQL。3.2 第一次执行令人绝望的空结果-- -- 脚本段 4: 场景 A - 新会话首次执行静默失败 -- -- 确保变量是空的 CALL pkg_session_data.reset_context(); -- 执行高危SQL SELECT acct_id, cust_id, balance, acct_status FROM account_balance WHERE cust_id pkg_session_data.get_cust_id() AND pkg_session_data.set_cust_id(101) 1;执行结果no rows selected (未选定行)为什么会这样我盯着执行计划琢磨了一会儿才反应过来。KES的执行器在处理每一行数据时先看第一个条件cust_id pkg_session_data.get_cust_id()。因为是刚reset过的会话g_cust_id是NULL。条件变成了cust_id NULL。在SQL的逻辑世界里任何值与NULL比较结果都不是TRUE而是UNKNOWN未知。数据库采用了短路评估Short-circuit evaluation既然第一个条件已经不可能成立了那我还费劲执行第二个条件干嘛于是pkg_session_data.set_cust_id(101)这个函数根本没有被调用变量没被赋值查询自然返回空集。这就解释了为什么应用日志没报错——SQL语法完全正确只是逻辑上返回了空。3.3 第二次执行可怕的“会话污染”重点来了。不要断开这个数据库连接不要重置变量紧接着在同一个窗口里再执行一遍刚才那条SQL。-- -- 脚本段 5: 场景 B - 同一会话二次执行数据回来了 -- -- 注意我们没有 Reset直接再次执行 SELECT acct_id, cust_id, balance, acct_status FROM account_balance WHERE cust_id pkg_session_data.get_cust_id() AND pkg_session_data.set_cust_id(101) 1;执行结果ACCT_ID CUST_ID BALANCE ACCT_STATUS 10001 101 5000.00 NORMAL 10003 101 8000.00 FROZEN数据居然出来了这就是生产环境最恐怖的地方。虽然上一次查询没返回结果但在某些边缘情况下比如全表扫描的最后一行触发了函数调用或者优化器的某种特定行为set_cust_id函数可能被执行了。一旦变量g_cust_id被赋值为101本次会话后续的查询就全部“正确”了。这就解释了客户的现象测试环境长连接、反复跑很难发现问题因为变量早就被“预热”了。生产环境使用连接池如果某个连接刚好被“污染”了残留了上一次的ID那么分配到这个连接的请求就会出错如果分配到干净的连接就一切正常。这种随机性差点让项目组集体崩溃。四、 深挖原理KES与Oracle的微妙差异当时项目组里有个老Oracle开发跟我杠说他在Oracle里跑了十年都没事。我让他把同样的脚本在Oracle里跑了一遍确实表现略有不同但这不能成为写烂代码的理由。4.1 Oracle的“假象”在Oracle中对于这种简单的等式条件优化器CBO很多时候确实会按照从左到右的顺序执行。但这只是“碰巧”不是“承诺”。如果我在表里加个索引或者数据分布发生变化Oracle的CBO觉得右边的条件过滤性更强它随时可能改变执行顺序。4.2 KES的确定性KES电科金仓在设计上为了兼容Oracle确实在很大程度上保持了从左到右的执行顺序。但这依然挡不住短路评估的影响。只要左边的条件是NULL右边就不执行。而且KES未来的版本优化器肯定会越来越智能如果为了并行计算或者更优的查询计划而重排了谓词顺序这种依赖顺序的代码立马就会爆炸。4.3 修改数据的“副作用”最关键的一点是我们在WHERE子句里调用了一个修改数据状态的函数set_cust_id修改了包变量。SQL是声明式语言不是过程式语言。优化器有权决定何时、何地、执行多少次这个函数。理论上它可以被执行0次、1次或N次每一行一次。在WHERE子句中引入这种“副作用”等于主动放弃了SQL的稳定性。五、 正确的姿势整改方案找到原因后整改就很简单了。核心原则只有一个把状态设置Set和查询Query彻底分开。5.1 方案一逻辑解耦强烈推荐这是最标准、最安全的写法。把业务逻辑拆清楚不要试图在一行SQL里耍小聪明。-- -- 脚本段 6: 正确写法一 - 逻辑解耦PL/SQL块 -- DECLARE v_result NUMBER; BEGIN -- 第一步单独设置会话状态Set v_result : pkg_session_data.set_cust_id(101); -- 可选检查设置结果 IF v_result 1 THEN -- 第二步纯粹地查询Query FOR rec IN ( SELECT acct_id, cust_id, balance FROM account_balance WHERE cust_id pkg_session_data.get_cust_id() ) LOOP -- 处理数据例如打印或写入临时表 DBMS_OUTPUT.PUT_LINE(Acct: || rec.acct_id || , Balance: || rec.balance); END LOOP; END IF; -- 第三步清理现场非常重要 pkg_session_data.reset_context(); END; /为什么好清晰代码像流水账一样谁都能看懂先干嘛后干嘛。安全不受优化器重排的影响。可控可以精确控制函数的调用次数。5.2 方案二使用子查询折中方案如果因为某些原因必须在单条SQL里完成可以用标量子查询把set操作“包裹”起来强制其先执行。-- -- 脚本段 7: 正确写法二 - 利用子查询固化顺序 -- SELECT acct_id, cust_id, balance FROM account_balance WHERE cust_id pkg_session_data.get_cust_id() AND (SELECT pkg_session_data.set_cust_id(101) FROM dual) 1;注意 虽然这比直接写要靠谱一点但我依然不推荐。因为它依然保留了在查询中修改状态的反模式。六、 举一反三更多的“坑”与排查手段修完这个Bug我又顺手把代码库翻了一遍发现了不少类似的问题。6.1 UPDATE中的陷阱不仅在SELECT里UPDATE里也有这种坑。-- 危险写法 UPDATE account_balance SET balance balance 100 WHERE cust_id pkg_session_data.get_cust_id() AND pkg_session_data.set_cust_id(101) 1;如果set_cust_id没执行WHERE条件不成立更新行数为0。应用端如果不检查SQL%ROWCOUNT就会以为更新成功了造成资损。6.2 DBA的排查利器EXPLAIN ANALYZE作为DBA怎么快速发现这类问题光靠肉眼看代码太累了。要用工具。-- -- 脚本段 9: 使用执行计划诊断 -- EXPLAIN ANALYZE SELECT acct_id FROM account_balance WHERE cust_id pkg_session_data.get_cust_id() AND pkg_session_data.set_cust_id(101) 1;看输出结果里的Filter部分。如果你看到Filter: ((cust_id pkg_session_data.get_cust_id()) AND (pkg_session_data.set_cust_id(101) 1))就要警惕了。特别是看到函数名在Filter里而且这个函数明显是干“脏活累活”修改状态的基本就可以定性了。6.3 监控函数调用次数我们还可以做一个简单的测试看看函数在WHERE子句里到底被调用了几次。-- -- 脚本段 10: 测试函数调用频率 -- CREATE OR REPLACE FUNCTION noisy_function(p_val NUMBER) RETURN NUMBER IS BEGIN DBMS_OUTPUT.PUT_LINE(函数被调用了参数: || p_val); RETURN p_val; END; / SET SERVEROUTPUT ON; SELECT COUNT(*) FROM account_balance WHERE acct_id noisy_function(10001); SET SERVEROUTPUT OFF;你会发现即使表里只有4条数据noisy_function也会被调用4次全表扫描。如果你的set函数里有复杂的逻辑或者修改操作这性能开销和对状态的多次修改将是灾难性的。七、 总结与规范这次事故给我最大的教训就是迁移国产数据库不仅仅是语法替换更是编程习惯的洗礼。数据库执行引擎虽然在特定配置下能提供稳定的函数执行顺序但 SQL 语义的安全性不应建立在“巧合”之上。依赖 WHERE 子句的先后顺序来实现状态转换不仅违背了关系型数据库的设计初衷更会埋下难以排查的生产隐患。逻辑归逻辑查询归查询保持 SQL 的纯粹性是确保系统长治久安的最佳实践。细节决定成败态度决定高度大家共勉