抢修实录:KES迁移后因一行SQL引发的“状态污染”血案

一、 背景

上个月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,以及一对setget函数。

注意:​ 这种包级别的全局变量在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;

开发者的逻辑设想:

  1. SQL执行时,数据库会老老实实从左到右干活。

  2. 先调用get_cust_id(),看看变量里存的是啥。

  3. 再调用set_cust_id(101),把101塞进去,并返回1。

  4. 最后筛选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的执行器在处理每一行数据时:

  1. 先看第一个条件:cust_id = pkg_session_data.get_cust_id()

  2. 因为是刚reset过的会话,g_cust_idNULL

  3. 条件变成了cust_id = NULL。在SQL的逻辑世界里,任何值与NULL比较,结果都不是TRUE,而是UNKNOWN(未知)。

  4. 数据库采用了短路评估(Short-circuit evaluation):既然第一个条件已经不可能成立了,那我还费劲执行第二个条件干嘛?于是,pkg_session_data.set_cust_id(101)这个函数根本没有被调用

  5. 变量没被赋值,查询自然返回空集。

这就解释了为什么应用日志没报错——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;
/

为什么好?

  1. 清晰:代码像流水账一样,谁都能看懂先干嘛后干嘛。

  2. 安全:不受优化器重排的影响。

  3. 可控:可以精确控制函数的调用次数。

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 的纯粹性是确保系统长治久安的最佳实践。

细节决定成败,态度决定高度,大家共勉!

posted @ 2026-07-27 02:21  正在走向自律  阅读(2)  评论(0)    收藏  举报