在金仓数据库的开发和运维现场,最让人头疼的往往不是那些一执行就报错的 SQL。报错至少明确,顺着错误信息往下查,总能找到线索。
真正麻烦的,是另一类 SQL:在开发环境里跑得好好的,测试人员也验证过,上线后却偶尔查不到数据。重试一次又好了,换个连接又不行。翻了日志,没有报错;对了参数,也和预期一样。最后大家很容易把原因归到"数据库当时不稳定"。
但很多时候,数据库并没有不稳定。不稳定的是 SQL 依赖的那些隐含前提。
这篇文章不打算罗列一遍 SQL 语法,而是结合金仓数据库中常见的函数调用、包内会话状态、连接池复用和执行计划变化,聊聊那些平时不太显眼、真到生产环境才会露出来的风险。
一、从一条"很会办事"的 SQL 说起
假设一个运行在金仓数据库上的业务系统定义了包内函数,set_id 负责把标识写入当前会话的包状态,get_id 负责再把这个值取出来。某段查询是这样写的:
sql
SELECT id, business_no, status
FROM order_info
WHERE pkg_context.set_id(10) = 1
AND id = pkg_context.get_id();
第一眼看上去,它的意图甚至有点"一目了然":先设置 10,然后取出 10,再用这个值过滤记录。如果在客户端里连续执行几次,它也可能次次都返回正确结果。
可问题恰恰藏在"先"和"然后"这两个词里。
WHERE 子句用来描述哪些行符合条件,它不是过程式代码,也不是业务流程编排器。开发人员把两个条件按照上下顺序写出来,并不等于要求数据库严格按这个顺序完成两次函数调用。
金仓数据库优化器关心的是:在保持正常 SQL 逻辑语义的前提下,怎样更高效地得到结果。它可能调整过滤条件的位置,也可能做谓词下推、常量折叠或子查询改写。某个条件可能先算,可能后算,也可能因为其他条件已经能确定结果而没有实际调用。扫描多行时,函数还可能被调用多次。
所以,即使今天在金仓数据库中查看执行计划,发现 set_id 确实先执行,也只能说明当前环境下出现了这个结果,不能把它当成 SQL 对未来的承诺。统计信息变了、数据量变了、参数变了,执行计划都可能跟着变。
在金仓数据库中写生产 SQL,这里应该守住一条很朴素的底线:业务正确性不能依赖 WHERE 中并列条件的求值顺序。
二、问题为什么到了生产环境才暴露
2.1 会话里留下的"上次现场"
上面那条 SQL 为什么在开发环境很容易测试通过?一个常见原因是,开发人员一直在同一个会话里调试。
也许他在执行查询前,手工调用过一次 set_id;也许上一轮测试恰好给包变量赋了值。接下来再执行错误的 SQL 时,即使这一次 set_id 没有按预期发生,get_id 仍然可能读到上一次留下的值。查询返回了数据,但这不是证明它写对了,只是上一次的状态帮它掩盖了错误。
到了生产环境,应用通常从连接池中获取会话。这次拿到的可能是新建连接,会话状态还没有初始化;也可能是复用连接,里面留着上一个请求的值。这就解释了为什么有些问题"重试就好",也解释了为什么工程师很难在自己的客户端里复现。
如果这个状态保存的不只是普通查询参数,而是租户标识、机构标识或权限上下文,问题就不再只是"偶尔查不到数据"。后一个请求读到前一个请求的会话状态,可能直接演变成数据隔离风险。
我们在评审这类设计时,不能只问"设置成功了吗",还要问两句:请求结束时谁来清理?中途发生异常时,清理还会执行吗?
2.2 函数有副作用,SQL 就不再只是查询
不少项目喜欢在函数里"顺手"做点其他事情:写一条日志、更新一个计数器、记录函数被调用的次数,或者像前面一样修改会话变量。这些代码单独看都不复杂,可一旦它被放进 WHERE、JOIN ON 或 HAVING,业务就开始依赖优化器如何选择路径。
这是一个很实用的判断方法:如果某个函数在一条查询中被调用零次、一次或多次,业务结果会不会不一样?如果答案是"会",这个函数就不应该出现在过滤条件中。
函数特性的声明也必须和实现一致。不能为了让执行计划看起来更好,就把会读写数据、依赖会话状态的函数声明成不符合其真实行为的类型。这等于给优化器提供了错误前提,当前省下的一点成本,可能会在以后变成更难解释的结果错误。
三、几种不显眼,却很容易"埋雷"的写法
3.1 NULL 不是一个普通值
下面这个错误很基础,但在动态 SQL 里仍然不少见:
sql
-- 错误
WHERE cancel_time = NULL
-- 正确
WHERE cancel_time IS NULL
SQL 使用三值逻辑,除了真和假,还有"未知"。NULL 参与普通等值比较时,结果不是开发人员习惯理解的真或假。
更隐蔽的是 NOT IN。当子查询结果里混入一个 NULL 时,最终过滤可能一行都得不到。这类问题在测试数据很干净时不会出现,到生产环境只要冒出一条不完整数据,查询结果就变了。对可空列做反向匹配时,通常更适合使用关联条件明确的 NOT EXISTS。
3.2 把日期当字符串,后面往往要还债
为了写起来方便,有人会把日期列先格式化成字符串,再和入参比较:
sql
-- 不推荐
WHERE to_char(created_at, 'YYYY-MM-DD') = :day_text
这样写有两个问题。一是每行数据都要做格式化,二是列上有了函数计算后,原本可直接利用的普通索引可能难以发挥作用。数据少时感觉不到,表数据涨到一定规模后,问题才突然显现。
更稳妥的方式是让应用传入类型正确的时间边界:
sql
WHERE created_at >= :day_start
AND created_at < :next_day_start
"左闭右开"的时间范围还有一个好处:连续两个批次可以无缝衔接,既不重复,也不会因为时间精度差异遗漏记录。
同样的道理也适用于数值列。字符串参数与数值列直接比较,依赖的是隐式转换。输入内容、转换方向或会话格式一变,就可能报错或走出意料之外的计划。应用程序应该使用绑定参数,并按字段的真实类型传值,不要把一切都当成字符串交给数据库猜。
3.3 分页没有稳定排序,"丢一条"就只是时间问题
不少分页查询只按创建时间排序:
sql
ORDER BY created_at
如果同一时刻有多条记录,它们之间的相对位置并不稳定。第一页和第二页分别执行时,某条数据就可能换了位置,结果是一条记录重复出现,另一条被跳过。通常应在排序末尾加上能唯一确定记录位置的键:
sql
ORDER BY created_at, id
这不是语法上的必选项,却是生产环境中很重要的确定性要求。
四、回到最开始的问题,怎么改才对
如果业务只是想用 10 查询数据,最干净的做法是直接把它作为参数传入:
sql
SELECT id, business_no, status
FROM order_info
WHERE id = :target_id;
这样的 SQL 没有什么技巧,却很好理解:相同输入对应相同的查询含义,也不依赖会话中上一次发生过什么。
如果业务确实要求"先设置上下文,再执行查询",那么就应该由应用服务或存储过程显式编排这两个步骤。同时把事务边界、异常处理和状态清理都写清楚。特别是使用连接池时,设置与清理必须成对出现,异常路径也不能漏掉清理。
如果做不到这些,就不应用会话状态传递业务参数。
五、把这些经验落到 SQL 评审中
实际做 SQL 评审时,不能只看语法对不对、当前跑得快不快,还应该固定多问几个问题。这些问题看起来很朴素,但真能挡住不少生产事故。
5.1 先看查询是否"纯粹"
WHERE、JOIN ON 和 HAVING 中的函数,只应根据输入计算结果。不在里面写业务表,不修改会话状态,也不承担必须执行的日志、计数或通知任务。
5.2 再看条件换个顺序还对不对
把 AND 两边的条件交换,假设某个函数不执行或执行多次。如果业务结果因此变化,就不要再纠结如何"保证它先执行",直接重构这段逻辑。
5.3 所有参数都按真实类型传递
日期就传日期,数值就传数值。不拼接 SQL,不把会话默认格式当作系统接口,也不让数据库为每一行数据反复做本可在入参端完成的转换。
5.4 为 NULL 单独写测试用例
不只测正常值,还要测 NULL、空集、重复值和不完整数据。对 NOT IN、否定条件和外连接要格外警惕,因为人的直觉很容易和三值逻辑不一致。
5.5 需要固定结果时,就给出确定条件
查询必须返回一行时,应该由唯一条件保证,不要用"随便取第一条"掩盖数据不唯一。分页、排名和批处理则必须有稳定排序,排序键最终要能唯一确定每行的位置。
5.6 核心 SQL 上线前要看执行计划
检查扫描方式、估算行数与实际行数的差异、过滤比例以及函数调用的成本。但也不要反过来迷信某一份计划:它是当前数据分布和当前参数下的选择,不是永久不变的执行脚本。
5.7 最后一定要换会话测
一个全新会话、一个被连接池复用过的会话,都应该得到符合预期的结果。如果查询行为与"这个连接之前做过什么"有关,那就不是一条可以放心上线的查询。
六、金仓数据库场景下的排查和延伸实践
6.1 不只看 SQL 文本,还要看它在金仓数据库中如何执行
遇到这类问题,先不要反复在同一个客户端会话里"多跑几遍看看"。这样做有时反而会因为会话状态残留,让问题更难被看见。更有效的方法是把验证拆开:
- 在金仓数据库中分别使用全新会话和复用会话执行 SQL,对比结果。
- 单独验证包内状态的设置、读取和清理,特别检查异常分支。
- 查看执行计划,关注过滤条件、扫描方式、估算行数和实际数据量之间是否存在明显偏差。
- 准备包含
NULL、重复值、空结果和时间边界的数据,不要只用"漂亮数据"验证。 - 如果 SQL 来自应用系统,同时核对连接池归还连接前是否做了必要的状态清理。
这些检查并不高深,但能把"偶尔不对"还原成可观察、可对比的条件差异。对金仓数据库的日常运维来说,这比简单地记一句"重试后恢复"有价值得多。
6.2 从一条问题 SQL,走向更大的技术交流
一线排障的经验,只留在个人笔记里很可惜。金仓社区里有不少来自开发、运维和项目交付现场的讨论,类似本文这样的 SQL 安全边界、连接池会话污染和执行计划分析,都适合拿出来和同行交流。
如果手头恰好有项目需求、行业线索或可落地的数据库场景,可以留意金仓社区的"同行者计划"。它所强调的"荐商机、赢好礼"只是表层呈现,对技术人员来说,更值得关注的是如何把真实场景、技术方案和交付能力连接起来。
如果关心的是工具化和自动化,则可以关注2026 金仓数据库智能运维工具开发大赛说明以及相关补充信息。本文提到的会话残留检查、高风险 SQL 识别、执行计划对比和连接池状态巡检,其实都可以继续延伸为智能运维工具的功能点。把一次人工排障总结成可重复执行的检查规则,往往比单纯堆叠功能更有实际价值。
而对于愿意把排障过程、实测数据和改写方案完整写下来的开发者,金仓社区的征文活动与内容专区也是一个可以继续延伸这个话题的地方。一篇有价值的技术文章,不一定非要讲复杂的内核原理;能把问题怎么出现、为什么难以复现、最后怎么改讲清楚,就能帮助后来者少走一段弯路。
七、结语
不规范 SQL 很少在写完的当天就证明自己有问题。它往往会安静地运行一段时间,直到数据多了、连接池忙了、某个字段第一次出现 NULL,或者执行计划恰好发生变化。到那时,开发人员看到的就只剩下一个"偶现问题"。
所以,判断一条 SQL 能不能上线,不能只看它今天能不能跑通。更重要的是问:换个会话还对不对?换个执行计划还对不对?函数没有按我们想象的顺序调用,还对不对?
真正稳定的 SQL,应该清楚表达"我要什么",而不是暗中要求数据库"先帮我做这件事,再帮我做那件事"。把状态变更从过滤条件里拿出来,把隐式依赖改成显式参数,把边界数据和异常路径真正放进测试。这些写法不会让 SQL 显得更"聪明",却能让金仓数据库上的业务系统更可预期,也让以后接手这段代码的人少走一些弯路。