一个系统上线三周后,客服开始零星反馈某个查询页面"偶尔查不出数据"。应用日志没有异常,慢查询列表里找不到它,把 SQL 拷到客户端里手工执行,结果完全正常。
这类问题通常要查很久,因为它不符合大多数人对故障的想象。它不报错,不告警,监控面板一片正常,只是在某些时刻安静地返回零行。等到定位出来,往往已经过去几天,中间产生的错误数据还要另外花时间对账。
生产环境里真正昂贵的 SQL 问题,很少是语法错误。语法错误在开发阶段就被拦下了。剩下能活到生产的,都是那些"能跑"的写法。
一、一条依赖执行顺序的 SQL
先看一个具体的例子。业务上需要先设置一个上下文变量,再用这个变量参与过滤,有人把两件事写进了同一条语句:
sql
SELECT * FROM my_table
WHERE id1 = pkg_abc.get_id() -- 取值
AND pkg_abc.set_id(10) = 1; -- 赋值并返回 1
写法的意图很清楚,靠 WHERE 子句中条件的书写顺序来控制两个函数的执行先后。开发阶段测过,能出数据;测试环境测过,能出数据;上线以后,开始时好时坏。
在 Oracle 上,两个对等的等式比较通常按从左到右求值。左边的 id1 = get_id() 先执行,此时 set_id 还没有运行,包变量为空,比较结果为假。基于短路求值,右边的 set_id 可能根本不会被触发,整条语句返回零行。更麻烦的情况是等式与不等式混合出现,优化器有可能重新调度条件的求值次序,执行顺序并不严格锁定在代码的书写位置上。
KES 在兼容性设计上对这一点做了明确约定:WHERE 子句中的函数条件,无论等式还是不等式,默认按条件出现的先后顺序从左到右依次求值。规则是确定的,也是可查的。
这里需要说清楚一件事。内核给出了确定行为,不等于这条 SQL 就是安全的。确定的是求值顺序,不确定的是这条语句所依赖的那个变量在执行前处于什么状态------而后者才是它时好时坏的真正原因。
二、真正的隐患在会话,不在语法
包级别的全局变量,生命周期是整个会话。
在客户端工具里调试时,人的操作习惯是先跑一遍正确的写法,变量被赋上了值,会话一直保持着。接下来再执行那条顺序写反的 SQL,因为会话里残留着上一次的值,它照样能返回结果。于是得出结论:这条 SQL 没问题。
这个结论在那个客户端窗口里是成立的,换到生产就不成立了。
应用通过连接池访问数据库,每次请求拿到的是哪条物理连接,上一个使用者在这条连接上留下了什么状态,应用层完全不知道。连接被回收、重建、扩容都会重置这些残留值。于是行为变成:有残留值时正常,无残留值时返回空集;连接池在业务高峰扩出几条新连接,新连接上的请求就集体查不出数据;应用重启一次,现象又变了。
对这类问题,有两条排查经验比较管用。
一是用 explain analyze 看实际执行中 Filter 的条件排列和各条件的实际调用次数,不要凭书写顺序推测。执行计划里写着的才是真的发生过的。
二是只要发现同一条 SQL 的行为与会话相关------重启应用就好了、换条连接又不行、同一会话连着执行两次结果不同------第一顺位怀疑对象就是包变量、会话级临时表和会话级参数,而不是数据本身。顺着会话去查,通常比逐行核对数据快得多。
三、这类风险的共同特征
把上面这个案例抽象一下,会发现它和很多生产事故属于同一类。
程序设计里有个概念叫未定义行为,C 语言这方面的教训尤其多。2009 年 Linux 内核 tun 驱动有一个漏洞(CVE-2009-1897),代码先解引用了一个指针,后面才判断这个指针是否为空。GCC 的推理是:既然已经解引用,按标准这个指针就不可能为空,那么后面的判空分支属于永远不成立的死代码,于是把整个分支优化掉了。删掉之后,这段代码变成了一个可以本地提权的漏洞。
编译器没有做错任何事,它严格按照语言标准的语义在推理。写代码的人按的则是自己脑子里的语义。两套语义在绝大多数情况下重合,直到某次编译器版本升级、某个优化等级变化,它们不再重合。
数据库优化器是同一类角色。SQL 是声明式语言,WHERE a AND b 表达的是"我要同时满足 a 和 b 的行",不是"先做 a 再做 b"。至于先算哪个、算几次、要不要短路跳过,属于执行器和优化器的决策空间。优化器的目标是用最小代价拿到正确结果集,而不是复现开发人员心里那条流程。
由此可以提炼出一条判断标准,用来审视手上任何一段可疑的 SQL:
这个行为,是标准和文档承诺的,还是当前实现恰好如此?
依赖前者是工程,依赖后者是赌博。赌局的开奖时间还不由你决定------可能在下一次小版本升级,可能在数据量涨上去之后优化器换了执行计划,也可能就在迁移上线后的第二周。
这类问题有三个共同特征,识别出来会省很多事:
一是静默。不报错,只是结果不对或者变慢,绕过了大部分告警体系。
二是与环境相关。测试环境复现不出来,因为数据量、并发度、连接池行为、统计信息都不一样。
三是在迁移场景下被放大。同一段代码在原有环境里"一直没事",往往只是因为原环境的实现细节恰好兼容了它。换一个内核,这层运气就没有了。
四、通用编码规范
下面这些条目不追求完整,只挑那些在生产上真正出过血的。我个人的经验是,规范写到二十条以上就没人看了,不如把最关键的十条落到评审和工具里。
1. 不要在 WHERE、ON、HAVING 中调用带副作用的函数
副作用指修改数据、修改会话状态、写日志表、调用外部接口等一切 SQL 之外的动作。这类函数进入过滤条件后,执行次数、执行时机、是否因短路而完全不执行,全部由优化器决定,任何数据库都不会对此作出书面承诺。
规范做法是把状态设置彻底移出查询:在应用层或存储过程中先调用 set_id,再发起一条干净的 SELECT;更好的做法是取消这个中间变量,把值作为参数直接绑定进查询。
改造成本主要不在技术上,而在于搞清楚那个值的来源。老系统里这条链路经常绕了三四层,确认业务含义的时间往往比改代码更长。
2. 如实声明函数的易变性
KES 内核与 PostgreSQL 一脉,用户自定义函数默认是 VOLATILE。优化器看到这个标记,不会做提前求值,不会缓存结果,也不允许它参与表达式索引。
于是有人为了性能一路标成 IMMUTABLE,这是另一个极端。声明 IMMUTABLE 等于向优化器承诺相同入参永远得到相同结果,优化器就有权只算一次并复用。如果这个函数其实读了表,问题会在某次执行计划变化后突然出现,而且现象和函数属性看不出关联。
判断标准:只依赖入参、不访问任何表的纯计算标 IMMUTABLE;需要读表但在同一条语句内保持一致的标 STABLE;写数据或每次结果可能不同的老实标 VOLATILE。表达式索引只接受 IMMUTABLE,这是硬约束。
迁移项目里一次性梳理几百个函数不现实,可行的做法是先全部按 VOLATILE 保底跑通,再针对热点 SQL 涉及的那几十个逐个校正。
3. 不要用会话级状态在语句之间传递数据
包变量、会话级临时表、会话级参数的生命周期都是会话,而在连接池模型下,会话是被复用的资源,不是应用私有的。
规范做法是跨语句传递的状态一律放在应用层,SQL 只接受参数。如果业务上确实绕不开,就必须在借出连接时显式初始化、归还连接前显式清理,两个动作要么都做,要么都不做------只做一半会让问题以更难复现的形式出现。
需要提醒的是,显式清理会给每次借还连接增加开销,高并发场景要实测,不能想当然。
4. 没有 ORDER BY 就没有顺序
"这张表不加 ORDER BY 查出来也是按主键排的"是最常见的经验主义陷阱。那只是当前执行计划的副作用。换成索引扫描、启用并行、做一次表重整,甚至只是数据分布变化导致优化器改变选择,返回顺序就会变。
分页是重灾区。排序键不唯一时,相邻两页的边界行会漂移,用户能看到重复记录,也可能有几行永远翻不到,而这种缺陷在小数据量下几乎复现不出来。
规范做法是分页的 ORDER BY 必须包含唯一列(通常是主键)来打破并列;深度翻页放弃 OFFSET,改用上一页末行的键值继续向下取。
代价是键值分页无法随意跳页,这条在产品侧经常谈不拢。相对有效的说服方式是直接给出 OFFSET 到十万行以后的实测响应时间。
5. 保证比较两端的数据类型一致
字符列与数字比较、日期列与字符串比较,数据库会插入隐式转换。转换一旦作用在列上,该列的索引就用不了,退化成全表扫描。它不报错,只是变慢,小表上完全看不出来,等数据量涨上去,慢查询列表里会突然冒出一批没人动过的老 SQL。
迁移时这块尤其容易翻车,因为各家的隐式转换规则并不完全一致,原环境能走索引的写法搬过来未必还能走。
规范做法是常量书写时类型对齐,参数一律绑定并在驱动层指定正确类型,上线前对核心 SQL 跑一遍 explain analyze,检查是否出现本该走索引却退化成 Seq Scan 的情况。
如果查下来根因是表结构设计有问题(比如手机号存成了数字类型),建议先在 SQL 侧对齐类型止血,结构变更排进独立的版本窗口,不要在故障处理期间做 DDL。
6. 按三值逻辑处理 NULL
NULL 参与比较的结果既不是真也不是假,而是未知。最典型的杀伤场景是 NOT IN 加子查询:只要子查询结果集里出现一个 NULL,整条语句就返回空集。
又是一次静默的空集,和文章开头那个案例的表现完全一样。
规范做法是 NOT IN 遇到子查询一律改写为 NOT EXISTS;判空只能用 IS NULL 和 IS NOT NULL;另外注意 count(列名) 不统计 NULL,与 count(*) 语义不同,做数据核对时这个差异足以让两个部门对账对上半天。
改写之后执行计划可能变化,需要实测比较。多数情况下 NOT EXISTS 更优,但不是绝对,以计划为准。
7. 一律使用绑定变量
拼接字符串带来的注入风险已经讲得够多了,另一半代价常被忽略:每条拼出来的 SQL 在数据库看来都是一条全新语句,都要重新解析、重新生成计划,共享缓存被大量只用一次的计划占满,真正该被复用的计划反而被挤出去。并发上来以后,CPU 消耗在解析上。
规范做法是全部使用预编译语句加参数绑定。IN 列表的参数化确实麻烦,不要去拼一串问号,用数组参数配合 = ANY(?) 的写法,一个参数解决,计划也能复用。
这一条改造成本低、收益明确、动作机械,适合作为团队规范里第一个落地的项目。
8. 把事务边界压到最小
KES 使用 MVCC,行更新后旧版本仍然保留,由后台清理进程回收。一个长时间不提交的事务会阻止清理进程回收所有比它更新的死元组,因为它理论上还可能读到。
结果是表和索引持续膨胀,磁盘占用莫名上涨,扫描的数据块越来越多,查询整体变慢,而单看 SQL 本身挑不出毛病。诱因可能只是某个开发在调试时开了事务然后去吃饭了。
从 Oracle 迁移过来的团队对这一点普遍不敏感,因为两边处理旧版本的机制不同,同样的坏习惯在原环境里没有这么疼。
规范做法是事务中不包含人机交互、外部接口调用和审批等待;能用单条语句完成的操作不要开显式事务;在数据库侧为空闲事务设置超时作为最后一道保险(具体参数名和默认值以所用版本文档为准)。
拆分大事务需要重新界定一致性边界,可能要补幂等和补偿逻辑,这部分工作量要提前预留,不能临时起意。
9. DDL 和批量 DML 要考虑锁的影响
给大表加字段、建索引、批量更新,在测试库上几秒完成,在生产上可能长时间持有锁,把后面所有访问该表的会话堵成一片。更糟的是排队的会话会持续占用连接池,最后表现为整个应用不可用,而不只是那张表不可用。
规范做法是建索引使用并发方式,变更语句执行前设置锁等待超时,宁可失败重试也不要无限期等待;批量 DML 分批提交并控制单批规模;所有生产变更都要有明确的回退路径。
**10. 明确列出需要的列,不要用 SELECT ***
除了多余的 I/O 和网络传输,SELECT * 真正的风险在于把应用代码和表结构的列顺序绑定在了一起。表加一个字段,按下标取值的代码就可能错位,而这种错误同样不报错,只是数据串了。视图上用 SELECT * 尤其危险,基表变更后视图行为会跟着变。
五、规范落地靠工具,不靠人眼
上面这些条目,在代码评审现场基本拦不住。
不是因为评审的人不认真,而是这些写法在评审时看起来都很正常。它们不是"写错了",是"写得能跑"。评审的人看不到执行计划,看不到生产的数据分布,看不到连接池在业务高峰扩容之后发生了什么。一段逻辑通顺、测试通过的代码摆在评审会上,很难有理由拦下来。
能拦住这些问题的只有两件事:SQL 进生产之前有一道自动检查,以及生产运行中持续观察执行计划和资源指标的变化。这两年国产数据库社区在这个方向上的投入明显多了起来,把老 DBA 脑子里的经验规则沉淀成所有人都能运行的检查项,是一件收益很高的事。
顺带提一下,电科金仓目前正在举办 2026 金仓数据库智能运维工具开发大赛,联合了 ITPUB、佰晟智算和海光信息,面向 DBA、开发者和数据库用户,方向围绕智能运维与数据库诊断工具,社区里提供了参赛开发指南,包含 BIC-QA 数据库诊断工具的开发指导。参赛路径大致是注册、报名、下载、学习、部署、提交,具体的赛程安排、作品提交要求和评审标准以社区赛事指南帖为准:
bbs.kingbase.com.cn/activityDet...
对做运维和开发的人来说,这类比赛的实际价值不在名次,而在于它逼着你把平时靠经验判断的东西写成明确规则。一条踩过坑才总结出来的检查项,一旦变成工具里的代码,就不会再让第二个人重新踩一遍。
六、迁移搬的是语法,留下的是隐含约定
这几年大量团队在做同一件事:把运行了十几年的系统从原有数据库迁到国产数据库上。迁移评估阶段,注意力大多集中在语法兼容度、存储过程自动转换率、函数覆盖率这些可以量化的指标上。
但这些指标覆盖不了真正的风险。语法不兼容会在编译或第一次测试时暴露,属于便宜的问题。昂贵的是那些从来没有被写进文档的隐含约定------这段代码为什么这样写,当初参与设计的人心里清楚,但没有落在纸上。系统换过几轮维护人员之后,这些约定就变成了没人敢碰的黑箱,变成了一句"一直都这么写,一直都没出过事"。
它们在原有环境里恰好成立,久而久之就被当成了规则。换一个内核,这层运气消失,问题集中爆发。
如果手上正好有一个准备迁移的系统,比起立刻跑迁移工具,更值得先做的事情是把那些"一直这么写、一直没事"的地方找出来,逐条追问它凭什么没事。能答上来的,写进规范;答不上来的,就是迁移之后最可能出问题的地方。
数据库只负责回答一条 SQL 能不能执行。至于它该不该这样写,只能由写它的人来负责。