如果你的 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 ReferenceDevelopment GuideSQL 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) 替代 NUMBERCHAR(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_CHARTO_NUMBERTO_DATECAST 等显式函数,把转换放在字面量一侧而非列一侧。


九、虚拟列:让派生值像列一样工作

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
  • 虚拟列不能用 LOBLONG 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_SORTNLS_COMP)可能导致查询返回不正确的结果。用户自定义函数失效或被删除时,索引会被标记为 DISABLED


十一、总结:这九条背后的设计哲学

回顾这九条实践,可以看到 Oracle 工程团队贯穿始终的三条准则:

准则 对应实践 核心逻辑
让数据库做它擅长的事 集合操作、绑定变量 数据库是批量计算引擎,不是文件读写器
消除不确定性 显式 JOIN、FETCH FIRST、显式 NULL、显式类型转换 避免依赖优化器行为、会话设置或运气
数据类型即约束 精确类型、虚拟列、函数索引 类型系统本身是代码质量的第一道防线

三条最容易被生产者忽略的陷阱

  1. WHERE char_col = 42——数字 42 写进了 WHERE 子句,索引从 RANGE SCAN 变成 FULL SCAN。这条 SQL 不报错,只是在大表上慢到不可接受。
  2. ROWNUM < 10 ORDER BY ...——看似取排序前 10 行,实际取的是「任意 10 行然后排序」。结果完全错误,但语法合法。
  3. = NULL——静默返回 0 行。没有错误消息,就是查不到数据。如果这个条件出现在复杂的子查询过滤中,定位问题的代价极高。

对于每天写 Oracle SQL 的开发者,理解这九条不是选项——是生存技能。而对于 AI Agent,把这 49KB 的指南配进上下文,相当于给它内置了一个资深 DBA 的代码审查直觉:不是事后调优,而是在生成 SQL 的那一刻就自动避开集合操作、绑定变量、隐式转换这些最常见的性能陷阱。每一条都是从无数生产事故中提炼出来的血泪教训。


📎 源文件:https://github.com/oracle/skills/blob/main/db/sql-dev/sql-best-practices.md

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐