SQL是声明式语言,不是过程式语言——一次由WHERE子句函数顺序引发的生产故障复盘
2026/8/6 23:36:02 网站建设 项目流程

一、先跑个脚本,把问题复现出来

很多刚从Oracle迁到金仓KES的DBA都遇到过这种情况:SQL在测试环境跑得好好的,一上生产就出幺蛾子。不是报错,就是查出来的数据不对,更诡异的是——同一个会话里手动执行能查出数据,脚本跑就不行。

我今天就把这个问题的完整复现过程写下来。你直接在KES环境里跑下面这套脚本,就能亲眼看到那个让人抓狂的Bug长什么样-3

1.1 建一张业务表

先创建一张账户余额表,用来存客户的账户信息-3

-- ================================================== -- 脚本段 1:创建业务表 account_balance -- ================================================== 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-销户';

1.2 往里插几条测试数据

-- ================================================== -- 脚本段 2:插入测试数据 -- ================================================== 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;

注意这里的数据,客户101有两条记录,一条正常一条冻结-3。这个细节在后面复现问题的时候会用到。

1.3 创建那个“惹祸”的Package

接下来是重头戏。创建一个包,里面放一个会话级的全局变量,再加一对set和get函数-3-12

-- ================================================== -- 脚本段 3:创建带全局变量的 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; FUNCTION get_cust_id RETURN NUMBER IS BEGIN RETURN g_cust_id; END; PROCEDURE reset_context IS BEGIN g_cust_id := NULL; END; END pkg_session_data; /

1.4 看一眼数据长什么样

-- ================================================== -- 脚本段 4:查看初始数据 -- ================================================== SELECT acct_id, cust_id, balance, acct_status FROM account_balance ORDER BY acct_id; -- 预期结果: -- 10001 | 101 | 5000.00 | NORMAL -- 10002 | 102 | 3000.50 | NORMAL -- 10003 | 101 | 8000.00 | FROZEN -- 10004 | 103 | 1200.00 | NORMAL

二、问题SQL长什么样

下面这条SQL就是当年在Oracle里跑了多年、迁到KES之后出问题的那条-2-1

-- ================================================== -- 脚本段 5:有问题的SQL(依赖函数执行顺序) -- ================================================== SELECT * FROM account_balance WHERE cust_id = pkg_session_data.get_cust_id() AND pkg_session_data.set_cust_id(101) = 1;

开发人员的意图很明确:先用set_cust_id(101)把会话变量设为101,再用get_cust_id()把这个值取出来,去过滤account_balance表,查出客户101的所有账户

他的理由是:“在KES里WHERE子句是从左到右执行的,所以set一定先于get执行,没问题。”-2

听起来有道理,对吧?

但现实是,这条SQL在不同的数据库里跑出来的结果完全不一样。

三、在Oracle里跑是什么结果

先看Oracle。Oracle的优化器是出了名的“有主见”——它不保证WHERE子句里多个函数的执行顺序-2-1。优化器可能基于以下原因调整执行顺序-2-11

  • 谓词重排:根据过滤率和代价模型重新排列条件,尽早过滤掉不合格的行

  • 短路优化:如果一个条件已经能决定整个表达式的真假,后面的直接跳过

  • 并行执行:并行查询时,不同分片可能在不同线程上各跑各的

在Oracle里跑上面那条SQL,结果完全不可预测。运气好,优化器按从左到右执行,先跑set再跑get,能查出数据。运气不好,优化器觉得get_cust_id()的过滤率更高,先执行它——但此时变量还是空的,get返回NULL,条件为FALSE,短路评估直接跳过右边的set,整个查询返回空集-。

Oracle官方社区对这个问题的态度非常明确:WHERE子句中函数的执行顺序没有任何保证-。你今天测出来的顺序,明天执行计划一变就可能反过来。

四、在KES里跑是什么结果

金仓KES在这个问题上走了另一条路:KES严格按WHERE子句中表达式的书写顺序,从左到右依次执行(无论等式还是不等式)--2-1

所以在KES里跑上面那条SQL,set_cust_id(101)一定会先执行,变量被赋值为101,然后get_cust_id()读到101,查询返回客户101的两条记录。

看起来一切正常,对吧?

但事情远没有这么简单。

五、为什么说依赖顺序仍然不安全

5.1 先看第一个坑:会话污染

我刚才说了,g_cust_id是会话级变量。在测试环境里,开发人员手动执行SQL的时候,往往是先执行一遍正确的写法,再执行别的测试用例——但会话一直开着,变量已经被赋过值了-2-1

来,跑一下下面这几条SQL,感受一下什么叫“测试幻觉”:

-- ================================================== -- 脚本段 6:复现"测试幻觉" -- ================================================== -- 第一步:先执行一个正确的查询(set在前) SELECT * FROM account_balance WHERE cust_id = pkg_session_data.get_cust_id() AND pkg_session_data.set_cust_id(101) = 1; -- 返回客户101的两条记录 ✓ -- 第二步:再执行一个"错误"的查询(get在前,但没写set) SELECT * FROM account_balance WHERE cust_id = pkg_session_data.get_cust_id(); -- 猜猜返回什么? -- 因为g_cust_id还残留着101的值,居然也能查出数据! -- 这就是"测试幻觉"——看起来SQL怎么写都能跑通

看到了吗?第二次执行的时候,明明没有调用set_cust_id,但因为变量里还留着第一次执行时赋的值,查询依然能返回结果-2

到了生产环境,应用服务器用连接池管理数据库连接。每次从池里拿出来的连接,可能是全新的会话(变量是空的),也可能是被复用过的(里面残留着上一个请求设的值)-1。结果就是:同一个SQL,有时候能查出数据,有时候查不出来,全看命-1

更可怕的是,这种问题不会报错。语法没错,函数调用也没抛异常,就是数据不对。日志里什么都看不到-3

5.2 再看第二个坑:短路评估

就算KES保证了从左到右执行,短路评估仍然是个坑。

-- ================================================== -- 脚本段 7:短路评估的陷阱 -- ================================================== -- 假设变量当前是空的 EXEC pkg_session_data.reset_context(); -- 这条SQL:get在前面,返回NULL,条件为FALSE -- 短路评估直接跳过右边的set,set根本没执行 SELECT * FROM account_balance WHERE cust_id = pkg_session_data.get_cust_id() -- 返回NULL,FALSE AND pkg_session_data.set_cust_id(101) = 1; -- 被跳过了! -- 返回空集

在AND逻辑里,如果第一个条件是FALSE,第二个条件压根不会被执行-2。你指望set_cust_id去设置变量,但它连跑的机会都没有。

5.3 再看第三个坑:优化器的等价变换

前面说了,等价变换是优化器在逻辑优化阶段的核心工作——在保证结果不变的前提下把SQL重写成更高效的形式-19

比如谓词下推,这是优化器最核心的变换手段之一--19

-- ================================================== -- 脚本段 8:谓词下推示例 -- ================================================== -- 原始SQL(过滤条件在外层) SELECT emp.*, dept.dept_name FROM emp JOIN dept ON emp.dept_id = dept.dept_id WHERE dept.dept_name = '研发部'; -- 优化器等价改写为(谓词下推) SELECT emp.*, sub.dept_name FROM emp JOIN ( SELECT dept_id, dept_name FROM dept WHERE dept_name = '研发部' ) sub ON emp.dept_id = sub.dept_id;

原始写法需要全量扫描两张表完成关联再过滤;改写后先过滤dept表只留研发部数据,再跟emp关联,关联计算量天差地别-19

还有子查询提升-19

-- ================================================== -- 脚本段 9:子查询提升示例 -- ================================================== -- 原始SQL SELECT * FROM t1 WHERE EXISTS (SELECT 1 FROM t2 WHERE t2.a = 1 OR t1.b < 10); -- 等价扩展改写 SELECT * FROM t1 WHERE EXISTS (SELECT 1 FROM t2 WHERE t2.a = 1) OR t1.b < 10;

拆分后t1.b<10可以直接过滤外层表,不用遍历t2表-19

还有常量折叠-19

-- ================================================== -- 脚本段 9:子查询提升示例 -- ================================================== -- 原始SQL SELECT * FROM t1 WHERE EXISTS (SELECT 1 FROM t2 WHERE t2.a = 1 OR t1.b < 10); -- 等价扩展改写 SELECT * FROM t1 WHERE EXISTS (SELECT 1 FROM t2 WHERE t2.a = 1) OR t1.b < 10;

这些变换本身没问题,都是为了性能。但如果你的WHERE条件里调用了有副作用的函数——优化器在做等价变换的时候,可能会改变函数调用的位置和时机-。

金仓KES的等价变换有一套安全校验机制,每一条变换都要过两层校验-19。但再怎么校验,也架不住你在条件里放一个会改状态的函数——因为优化器的等价变换是基于“函数是无副作用的纯函数”这个假设来做的。

六、怎么验证你的SQL有没有问题

6.1 用EXPLAIN看执行计划

-- ================================================== -- 脚本段 11:查看执行计划 -- ================================================== EXPLAIN ANALYZE SELECT * FROM account_balance WHERE cust_id = pkg_session_data.get_cust_id() AND pkg_session_data.set_cust_id(101) = 1;

看执行计划里的Filter顺序,可以看到各个条件实际执行的顺序和耗时-。

6.2 写个测试脚本验证顺序

-- ================================================== -- 脚本段 12:验证函数执行顺序 -- ================================================== -- 先重置状态 EXEC pkg_session_data.reset_context(); -- 执行查询,观察返回结果 SELECT * FROM account_balance WHERE cust_id = pkg_session_data.get_cust_id() AND pkg_session_data.set_cust_id(101) = 1; -- 如果返回空集,说明get先执行了(或者set被短路跳过了) -- 如果返回数据,说明set先执行了

七、正确的写法应该是什么样

7.1 方案一:把状态设置和查询分开

这是最推荐的做法——把“改状态”和“查数据”彻底解耦-:

-- ================================================== -- 脚本段 13:正确的写法(方案一) -- ================================================== -- 第一步:先设置状态 SELECT pkg_session_data.set_cust_id(101) FROM DUAL; -- 第二步:再执行查询 SELECT * FROM account_balance WHERE cust_id = pkg_session_data.get_cust_id();

这样写,逻辑清晰,不依赖任何执行顺序,在任何数据库里行为都是一致的。

7.2 方案二:用普通变量代替函数

如果场景简单,直接用变量:

-- ================================================== -- 脚本段 14:正确的写法(方案二) -- ================================================== DECLARE v_cust_id NUMBER := 101; BEGIN SELECT * FROM account_balance WHERE cust_id = v_cust_id; END; /

7.3 方案三:纯读取函数声明为STABLE/IMMUTABLE

如果函数确实不修改状态(纯读取),在数据库里把它声明成STABLEIMMUTABLE-。这能帮助优化器更好地理解函数行为,做更积极的等价变换。

-- ================================================== -- 脚本段 15:声明函数属性 -- ================================================== -- 纯读取函数,不修改任何状态 CREATE OR REPLACE FUNCTION get_cust_id_safe RETURN NUMBER STABLE -- 告诉优化器:这个函数在同一个事务中返回相同结果 IS BEGIN RETURN pkg_session_data.g_cust_id; END; /

八、总结

这篇文章的核心观点其实就一句话:永远不要在WHERE子句里依赖函数执行顺序来实现业务逻辑。

为了佐证这个观点,我们跑了一套完整的脚本——建表、插数、建Package、写函数、执行有问题的SQL、分析原因、给出修复方案。整个过程你可以在KES环境里完整复现-3

不管用的是Oracle还是金仓KES,不管优化器是自由调度还是严格按顺序执行——在WHERE里放有副作用的函数,把业务正确性押在执行顺序上,都是在给自己埋雷-2-1

SQL是声明式语言,不是过程式语言。逻辑归逻辑,查询归查询。把状态变更塞进查询语句里,不仅违背了数据库的设计初衷,还会埋下极难排查的生产隐患。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询