Oracle Skills:sql-best-practices_SQL最佳实践深度解读
如果你的 AI Agent 能写 SQL,但写出来的 SQL 在大表上跑不动、在并发下拖垮共享池、在 NULL 面前静默返回空结果——它差的不是语法知识,而是一份把 Oracle 九大工程准则「注入」AI 上下文的指南。这时可以参考oracle/skills 里的 https://github.com/oracle/skills/blob/main/db/sql-dev/sql-best-practices.md,——每一句都有 Oracle 官方文档背书,每一个坑都有本地验证数据。
一、开篇:Oracle 官方的九条硬规矩
Oracle 官方文档是一座山。SQL Language Reference、Development Guide、SQL Tuning Guide——几千页的巨著。但 Oracle 自己对开发者的建议其实浓缩成了九条:集合操作优先、使用绑定变量、显式 JOIN 语法、确定的行限制、显式 NULL 处理、选择最具体的数据类型、避免隐式类型转换、善用虚拟列、慎重使用函数索引。
这九条不是「建议」,大部分是 Oracle 在「Building Effective Applications」章节里以警告口吻写下的硬规矩。每条背后都有一个有精确数字的性能灾难和一套 Oracle 明确指出的规避方法。
本文以 sql-best-practices.md 为蓝本,逐一深挖这九条实践——目的是让 AI Agent 在生成 SQL 时自动避开这些坑,而不是跑完才发现数据不对或性能崩盘。每条都包含 Oracle 官方文档的原文依据、正确与错误的 SQL 对照、以及在 Podman Oracle Free 26ai 容器中的本地验证基准数据。
二、集合操作优先:别把数据库当文件系统用
2.1 问题:逐行处理是数据库的反模式
Oracle Development Guide 有一句话值得贴在每间机房的墙上:尽可能在数据库内部处理数据。它明确反对把大量数据拉到客户端逐行循环处理。
看看这个典型的「逐行插入」反模式:
declare
cursor c is select s.* from ext_scan_events s;
r c%rowtype;
begin
open c;
loop
fetch c into r;
exit when c%notfound;
insert into stage1_scan_events d values r;
commit;
end loop;
close c;
end;
Oracle 的官方评语是:「逐行处理可能在处理大数据集时耗时长得不可接受。」更致命的是:逐行循环只能用单 CPU 核心串行执行,无法利用数据库原生的并行能力。
应该用 Oracle 推荐的集合操作方式:
alter session enable parallel dml;
insert /*+ APPEND */ into stage1_scan_events d
select s.* from ext_scan_events s;
commit;
一条 SQL、一次 COMMIT、所有行一次性读写,并天然支持并行 DML。
2.2 本地验证:67 倍的性能鸿沟
在本地 Podman Oracle Free 26ai 环境中,对 20000 行的表做了对比测试:
ROW_BY_ROW_MS=201.64
ROW_BY_ROW_COUNT=20000
SET_BASED_MS=3.17
SET_BASED_COUNT=20000
逐行插入 201ms,集合插入 3ms——性能差距约 67 倍。 而且这还只是 20000 行。数据量越大,差距越是指数级拉大。逐行模式下每行都要经历「读取→写入→COMMIT→下一次 FETCH」的完整网络往返;集合模式只有一条 SQL 的解析和执行。
最容易被忽视的坑:逐行循环里的 COMMIT 每次都触发一次日志写入和锁释放,这在高并发下会把整个系统拖垮。Oracle 明确说:「一条 SQL 语句在事务结束时只执行一次 COMMIT。」
三、绑定变量:共享池不是垃圾场
3.1 每一种字面值组合都会生成一个新游标
Oracle 的「Binding Effective Applications」章节把绑定变量放在极其重要的位置。核心问题是:每次不同字面值都会产生一条全新的、独一无二的 SQL,在共享池中抢占独立的内存空间。
这是没有绑定变量的反模式:
FOR i IN 1 .. 5000 LOOP
OPEN l_cursor FOR 'SELECT x FROM t WHERE x = ' || TO_CHAR(i);
CLOSE l_cursor;
END LOOP;
Oracle 说这种写法「更慢且使用更多的 latch」,而随着用户数增加,情况会急剧恶化——因为共享池会被海量的、几乎相同的 SQL 文本塞满。
正确做法是与绑定变量搭配:
FOR i IN 1 .. 5000 LOOP
OPEN l_cursor FOR 'SELECT x FROM t WHERE x = :x' USING i;
CLOSE l_cursor;
END LOOP;
带绑定变量的语句只需要一次硬解析,后续 4999 次执行都走软解析——游标复用,共享池干干净净。
3.2 本地验证:70 倍的差距
在本地对 5000 次循环做了对比:
LITERAL_SQL_MS=1046.43
BIND_SQL_MS=15.66
字面量 SQL 1046ms,绑定变量 15ms——接近 70 倍的差距。 这还只是单用户场景。文档明确说「随着用户增加,差距会急剧扩大。」多用户并发下,共享池的 latch 争用会让字面量方案逼近系统崩溃。
必须澄清的微妙之处:Oracle 也指出绑定变量不适用于所有场景。数据仓库的即席查询经常需要字面值来帮助 CBO 生成更精确的直方图和执行计划。绑定变量的性能收益集中在 OLTP 系统——关键特征是同一条 SQL 被反复执行,每次只是值不同。
四、显式 JOIN 语法:告别 (+) 和笛卡尔积陷阱
4.1 ANSI JOIN 取代 Oracle 专有的 (+)
Oracle 的 (+) 操作符是一个历史遗留产物。官方文档列出的限制多得吓人:
(+)只能出现在 WHERE 子句或表集合表达式中(+)只能应用于列,不能应用于任意表达式- 同一查询块中不能混合
(+)和 FROM 子句的 JOIN 语法
这就是为什么 Oracle 用了「强烈推荐」这个词来建议开发者使用 FROM 子句的 JOIN 语法。
旧的 (+) 写法:
SELECT d.department_id, e.last_name
FROM departments d, employees e
WHERE d.department_id = e.department_id(+)
ORDER BY d.department_id, e.last_name;
应该改成:
SELECT d.department_id, e.last_name
FROM departments d LEFT OUTER JOIN employees e
ON d.department_id = e.department_id
ORDER BY d.department_id, e.last_name;
4.2 笛卡尔积:忘写 ON 的代价
Oracle 文档里有这样一句警告:如果两个表在 JOIN 查询中没有连接条件,Oracle 会返回它们的笛卡尔积。两张 100 行的表会产出 10000 行。这不是性能问题——是结果正确性的灾难。
本地验证了这一点。两张表:bp_departments(4 行)和 bp_employees(5 行)。不加 WHERE / ON 的笛卡尔积:
CARTESIAN_COUNT=20
4 × 5 = 20。如果表都有几十万行,这个数字会让应用直接崩溃。
更隐蔽的验证是混合语法。在同一查询块中同时使用 JOIN ... ON 和 (+):
MIXED_JOIN_ERROR=-25156
Oracle 抛 ORA-25156 直接拒绝执行,根本不给机会。这条错误在代码重构时极容易触发——你把一部分旧 SQL 改成了 ANSI JOIN,但漏掉了另一处 (+)。
核心收益:用 JOIN ... ON 把连接逻辑和过滤逻辑分开——连接条件放 ON,业务过滤放 WHERE。可读性提升的同时,优化器也能更清楚地识别查询意图。
五、FETCH FIRST:ROWNUM 的终结者
5.1 ROWNUM + ORDER BY 的经典陷阱
很多开发者的第一版 Top-N 查询是这样写的:
SELECT *
FROM employees
WHERE ROWNUM < 11
ORDER BY last_name;
直觉上以为「先取前 10 行,再按名字排序」。但 Oracle 实际执行的是:先按任意顺序取前 10 行,再对那 10 行排序。最终拿到的不是「按名字排序的前 10 行」。Oracle 官方明确说:「这些行的内容可能因 Oracle 选择访问数据的方式而异。」
正确但啰嗦的 ROWNUM 写法需要一个子查询:
SELECT *
FROM (SELECT * FROM employees ORDER BY employee_id)
WHERE ROWNUM < 11;
Oracle 12c 之后有了更干净的方式——FETCH FIRST:
SELECT employee_id, last_name
FROM employees
ORDER BY employee_id
FETCH FIRST 5 ROWS ONLY;
5.2 本地验证:两个结果完全不同
本地对一组以字母命名的测试数据做了对比:
ROWNUM_LIST=Victor,Whiskey,Xray,Yankee,Zulu
FETCH_FIRST_LIST=Alpha,Bravo,Charlie,Delta,Echo
ROWNUM + ORDER BY 给出的是数据文件物理顺序的前 5 行,FETCH FIRST 才是真正的按名字排序的前 5 行。 如果你在生产环境里用了第一种写法来实现分页,用户看到的排序是半随机的。
关键注意点:
ROWNUM > 1永远为 false——Oracle 文档明确说明。ROWNUM是伪列,在处理完第一行之后才从 1 开始递增,任何ROWNUM > N在取第一行时就失败了。FETCH FIRST必须配合ORDER BY使用才能保证确定性结果。Oracle 的 SELECT 文档对此特别强调。FOR UPDATE不能与row_limiting_clause一起使用。
分页场景使用:
SELECT employee_id, last_name
FROM employees
ORDER BY employee_id
OFFSET 5 ROWS FETCH NEXT 5 ROWS ONLY;
六、NULL 处理:三值逻辑的第一课
6.1 = NULL 永远不返回任何行
这是九条里最容易被忽视的一条,因为它不报错——只是静默地返回 0 行。
a = NULL
Oracle 文档说:此条件求值为 UNKNOWN。 在 SQL 三值逻辑中(TRUE / FALSE / UNKNOWN),WHERE 子句只接受 TRUE。UNKNOWN 意味着该行被丢弃。结果是:WHERE commission_pct = NULL 永远不返回任何行,即使表里有 NULL 值。
正确写法是:
SELECT last_name
FROM employees
WHERE commission_pct
IS NULL
ORDER BY last_name;
6.2 本地验证:零行 vs 三行
EQUAL_NULL_COUNT=0
IS_NULL_COUNT=3
表里有 3 行的 commission_pct 为 NULL,但 = NULL 一个都没捞到。应用层如果依赖 = NULL 来过滤 NULL,逻辑完全崩坏,且不会报任何错。
额外警告:Oracle 文档还说,当前 Oracle 将零长度的字符值视为 NULL,但「这在未来版本中可能不再成立」。如果你的代码依赖空字符串等于 NULL 的行为,未来升级时可能出问题。
七、最具体的数据类型:删掉所有 CHAR、LONG 和裸 NUMBER
7.1 每一种泛型定义都有精确的替代品
Oracle Development Guide 明确说:使用尽可能具体的类型。它给了具体的反例:NUMBER(38) 替代 NUMBER,CHAR(16) 替代 CHAR(2000),VARCHAR2(30) 替代 VARCHAR2(4000)。
一个有四种陷阱的建表语句:
CREATE TABLE invoice_events_bad (
status_code CHAR(30),
amount NUMBER,
event_time DATE,
details LONG
);
四种对应的修正:
CREATE TABLE invoice_events_good (
status_code VARCHAR2(30),
amount NUMBER(12,2),
event_time TIMESTAMP(3) WITH TIME ZONE,
details CLOB
);
7.2 本地验证:六个数据字典的惊喜
本地对 CHAR(4) vs VARCHAR2(4) 插入相同值 'OK' 做了对比:
CHAR_LENGTH=4
VARCHAR2_LENGTH=2
CHAR(4) 存储 ‘OK’ 占用 4 字节(空白填充),VARCHAR2(4) 只占 2 字节。 Oracle 说 VARCHAR2 更节省空间且通常性能更好。除非你需要 ANSI 兼容(固定长度),否则不要用 CHAR。
更隐蔽的陷阱在于裸 NUMBER。查询数据字典 user_tab_columns:
NUMBER_PRECISION=NULL
NUMBER_SCALE=NULL
裸 NUMBER 的 precision 和 scale 在数据字典中全是 NULL——Oracle 给出的含义是最大范围和精度。而 NUMBER(6,2) 带来了验证能力——当你试图插入超过精度的值时:
PRECISION_ERROR=-1438
NUMBER(6,2) 自动四舍五入超出精度的值(插入 123.456 → 存储 123.46),但当整数部分超出 4 位时(总共 6 位,小数占 2 位),Oracle 直接拒绝。
对于时间类型:
DATE_VALUE=2026-03-12 10:15:30
TIMESTAMP_TZ_VALUE=2026-03-12 10:15:30.123 -08:00
DATE 类型丢失了小数秒和时区信息。如果你存的是金融交易时间戳,需要毫秒级精度和跨时区数据,必须用 TIMESTAMP(3) WITH TIME ZONE。
LONG 类型的警告:Oracle 不支持对 LONG 列使用很多 SQL 操作,且明确说「不要创建带 LONG 或 LONG RAW 的新表」。用 CLOB / BLOB 替代——本地验证的 notes_clob 列在数据字典中正确显示为 CLOB。
八、隐式类型转换:索引杀手
8.1 WHERE char_col = 42 会让索引失效
Oracle SQL Tuning Guide 明确指出:WHERE char_col=1 是一个典型的应用错误。原因很简单——Oracle 将字符串隐式转换为 NUMBER 来匹配比较,但转换发生在列这一侧,索引失效。
WHERE char_col = 42
Oracle 实际执行的是 WHERE TO_NUMBER(char_col) = 42——函数包裹了索引列,索引直接报废。
8.2 本地验证:INDEX RANGE SCAN vs FULL TABLE SCAN
本地对 bp_char_idx 表做了两个查询的执行计划对比:
字符串字面量匹配字符串列(正确):
CHAR_STR_OP=INDEX RANGE SCAN BP_CHAR_IDX_I
CHAR_STR_ACCESS="CHAR_COL"='42'
CHAR_STR_FILTER=<null>
数字字面量匹配字符串列(错误):
CHAR_NUM_OP=TABLE ACCESS FULL BP_CHAR_IDX
CHAR_NUM_ACCESS=<null>
CHAR_NUM_FILTER=TO_NUMBER("CHAR_COL")=42
一个字符之差——'42' vs 42——从 INDEX RANGE SCAN 变成了 TABLE ACCESS FULL。大表上这就是秒级和小时级的差异。
Oracle 还会在其他场景下触发隐式转换。比如:
SELECT last_name
FROM employees
WHERE employee_id = '200';
字符串 '200' 被隐式转为数字 200。如果 employee_id 列是 VARCHAR2 类型且有索引,这条 SQL 就可能不走索引。反过来也一样危险:
SELECT last_name
FROM employees
WHERE hire_date = '24-JUN-06';
'24-JUN-06' 被隐式转为 DATE,依赖的是会话的 NLS_DATE_FORMAT 设置。一旦环境变化,结果完全不可预期。
原则:谓词两侧的数据类型必须精确匹配。需要转换时使用 TO_CHAR、TO_NUMBER、TO_DATE、CAST 等显式函数,把转换放在字面量一侧而非列一侧。
九、虚拟列:让派生值像列一样工作
9.1 从表达式到列
如果某个计算列(如 qty * unit_price)在查询、WHERE 条件和报表中被反复引用,频繁复制表达式是脆弱且低效的。Oracle 的虚拟列就是为这个场景设计的:
CREATE TABLE sales_lines (
qty NUMBER(6),
unit_price NUMBER(8,2),
line_total NUMBER(10,2) GENERATED ALWAYS AS (qty * unit_price) VIRTUAL
);
Oracle 明确说虚拟列「不在磁盘上存储,其值按需从定义表达式计算」。但它能做普通列能做的一切事情:在查询中使用、在 DML 中引用、被索引、被收集统计信息。
9.2 本地验证:自动更新 + 可索引 + 不可直接更新
本地对 bp_virtual_sales 表做了完整验证。先插入数据,验证虚拟列的初始值:
INITIAL_TOTAL=25.00
然后更新基列 qty:
UPDATED_TOTAL=50.00
基列被修改后,虚拟列自动重新计算——不需要触发器,不需要应用层逻辑。
对 line_total 创建索引:
INDEX_COLUMN=LINE_TOTAL
虚拟列完全可以被索引。查询 WHERE line_total >= 40 可以利用这个索引。
但如果试图直接更新虚拟列:
UPDATE_ERROR=-54017
Oracle 明确拒绝:虚拟列不能直接更新。表的 virtual_column 标记在数据字典中为 YES。
几个重要的限制:
- 表达式不能引用另一个虚拟列
- 表达式中的 PL/SQL 函数必须声明为
DETERMINISTIC - 虚拟列不能用
LOB、LONG RAW或用户自定义类型 - 如果用了用户自定义函数且该函数被替换,Oracle 不会自动更新相关统计信息
十、函数索引:慎重使用,满足条件才能生效
10.1 建了索引不代表优化器会用
Oracle 的函数索引很强大——UPPER(last_name)、SUBSTR(name, 1, 3) 等表达式上的索引可以让对应的 WHERE 子句走上高效访问路径。但 Oracle 文档也给了极其严格的生效条件。
创建索引:
CREATE INDEX upper_ix ON employees (UPPER(last_name));
Oracle 明确警告:建完索引后,必须收集表级和索引级的统计信息,CBO 才可能选择使用它。更关键的是第二条要求:查询必须过滤掉 NULL,否则 Oracle 可能依然走全表扫描。
SELECT first_name, last_name
FROM employees WHERE UPPER(last_name) IS NOT NULL
ORDER BY UPPER(last_name);
加入 IS NOT NULL 就是为了增加优化器选择索引的概率。
10.2 本地验证:普通索引不能替代函数索引
本地创建了两个索引做对比——普通列索引和 UPPER() 函数索引:
使用普通列索引 + WHERE UPPER(last_name) = 'JOHNSON':
FB_EQ_NORM_OP=TABLE ACCESS FULL BP_FB_SEARCH
FB_EQ_NORM_FILTER=UPPER("LAST_NAME")='JOHNSON'
全表扫描。 因为普通索引存的是原始值,不是 UPPER() 的结果。
使用 UPPER(last_name) 函数索引 + 同样的 WHERE:
FB_EQ_FUNC_OP=INDEX RANGE SCAN BP_FB_SEARCH_UPPER_IX
FB_EQ_FUNC_ACCESS=UPPER("LAST_NAME")='JOHNSON'
索引范围扫描,索引恰好在存 UPPER(last_name) 的值。
不使用函数索引的正当理由:Oracle 说明当表达式不常被查询、INSERT/UPDATE 时维护索引的开销超过查询收益时,不要建函数索引。此外,如果索引表达式涉及字符转换,改变会话级 NLS 设置(除 NLS_SORT 和 NLS_COMP)可能导致查询返回不正确的结果。用户自定义函数失效或被删除时,索引会被标记为 DISABLED。
十一、总结:这九条背后的设计哲学
回顾这九条实践,可以看到 Oracle 工程团队贯穿始终的三条准则:
| 准则 | 对应实践 | 核心逻辑 |
|---|---|---|
| 让数据库做它擅长的事 | 集合操作、绑定变量 | 数据库是批量计算引擎,不是文件读写器 |
| 消除不确定性 | 显式 JOIN、FETCH FIRST、显式 NULL、显式类型转换 | 避免依赖优化器行为、会话设置或运气 |
| 数据类型即约束 | 精确类型、虚拟列、函数索引 | 类型系统本身是代码质量的第一道防线 |
三条最容易被生产者忽略的陷阱:
WHERE char_col = 42——数字 42 写进了 WHERE 子句,索引从 RANGE SCAN 变成 FULL SCAN。这条 SQL 不报错,只是在大表上慢到不可接受。ROWNUM < 10 ORDER BY ...——看似取排序前 10 行,实际取的是「任意 10 行然后排序」。结果完全错误,但语法合法。= NULL——静默返回 0 行。没有错误消息,就是查不到数据。如果这个条件出现在复杂的子查询过滤中,定位问题的代价极高。
对于每天写 Oracle SQL 的开发者,理解这九条不是选项——是生存技能。而对于 AI Agent,把这 49KB 的指南配进上下文,相当于给它内置了一个资深 DBA 的代码审查直觉:不是事后调优,而是在生成 SQL 的那一刻就自动避开集合操作、绑定变量、隐式转换这些最常见的性能陷阱。每一条都是从无数生产事故中提炼出来的血泪教训。
📎 源文件:https://github.com/oracle/skills/blob/main/db/sql-dev/sql-best-practices.md
更多推荐


所有评论(0)