绑定变量是 Oracle OLTP 的命根子,没有绑定变量,Library Cache 会被硬解析撑爆,CPU 全花在解析上。但绑定变量有个致命的副作用:执行计划跳变。同一条 SQL,第一次执行传了值 A,走了 FTS;第二次执行传了值 B,本来应该走 INDEX,但因为计划被缓存了,还是走 FTS,结果从 2 秒变成 2 分钟。这就是绑定变量窥视(Bind Peeking)和自适应游标共享(ACS)要解决的问题。这篇文章把窥视机制、ACS 的判定逻辑、以及生产环境的治理方法拆清楚。
1 绑定变量窥视:第一次执行决定终身
绑定变量窥视发生在硬解析阶段:当 SQL 第一次执行时,优化器会"偷看"(peek)绑定变量的实际值,然后根据这个值的数据分布选择执行计划。比如 SQL:SELECT * FROM orders WHERE status = :1。第一次执行时 :1 = 'DONE'(占 80% 的行),优化器认为选择性差,选 FULL TABLE SCAN。执行计划被缓存(cursor sharing),后续执行无论传什么值,都用这个 FTS 计划。第二次执行 :1 = 'PENDING'(只占 0.1% 的行),本来应该 INDEX RANGE SCAN,但还是走 FTS,结果从 2 秒变成 2 分钟。这就是"第一次执行决定终身"的陷阱。
绑定变量窥视的设计初衷是好的:既然有绑定变量,说明这个 SQL 会执行很多次,第一次 peek 一下,选个相对合理的计划,后续复用,省掉解析开销。但如果数据分布极度倾斜(skewed),第一次 peek 的值刚好是异常值(比如高频值或低频值),计划就会偏离最优。而且 Oracle 9i/10g 时代,窥视只发生在硬解析时,一旦计划缓存,永远不变,直到 cursor 被 age out。11g 引入了 ACS,试图解决这个问题,但 ACS 也不是银弹。
2 ACS:自适应游标共享,聪明但有限
Adaptive Cursor Sharing(ACS)是 11g 引入的机制,目的是让同一条 SQL 根据绑定变量的不同值,使用不同的执行计划。ACS 的工作原理是:第一步,绑定变量敏感度检测。Oracle 在 SQL 执行时,比较 peek 值的选择性和实际选择性。如果差异大(比如 peek 时估算 10 万行,实际只有 100 行),Oracle 标记该 cursor 为"绑定敏感"(bind-sensitive)。第二步,子游标(child cursor)生成。对于 bind-sensitive 的 SQL,Oracle 允许生成多个 child cursor,每个 child cursor 对应不同的绑定变量值范围。比如 child 0 对应 status='DONE'(走 FTS),child 1 对应 status='PENDING'(走 INDEX)。第三步,运行时选择。后续执行时,Oracle 根据绑定变量的值,选择最合适的 child cursor。听起来很完美,但实际有三个限制。
限制一:ACS 只对包含绑定变量的谓词有效,而且该谓词必须影响执行计划的选择(通常是索引列或分区键)。如果绑定变量用在非索引列上,ACS 不会触发。限制二:ACS 的 child cursor 数量有上限(默认 1024),超过后新的绑定值只能复用现有的 child cursor,可能选不到最优计划。限制三:ACS 只在 cursor 被标记为 bind-sensitive 后才生效,标记需要多次执行(通常 3-5 次)才能确认。所以在标记完成前,执行计划可能一直错。我们给一个客户做调优时,发现某条 SQL 执行了 1000 次,但 ACS 始终没标记为 bind-sensitive,因为该列的直方图缺失,Oracle 无法判断选择性差异。加了直方图后,ACS 才生效,生成了 3 个 child cursor,性能问题迎刃而解。这说明 ACS 依赖直方图,没有直方图,ACS 就是瞎子。
3 实验:Bind Peeking 与 ACS 的行为验证
实验环境:Oracle 19c,测试表 orders,1000 万行。status 列数据倾斜:'DONE' 占 85%,'PENDING' 占 10%,'CANCEL' 占 5%。在 status 上建索引,观察不同绑定值下的执行计划变化。
-- 1. 构造倾斜数据
INSERT INTO orders (order_id, status, amount)
SELECT rownum,
CASE WHEN rownum <= 8500000 THEN 'DONE'
WHEN rownum <= 9500000 THEN 'PENDING'
ELSE 'CANCEL' END,
DBMS_RANDOM.VALUE(100,10000)
FROM dual CONNECT BY ROWNUM <= 10000000;
COMMIT;
-- 2. 收集统计信息(带直方图)
EXEC DBMS_STATS.GATHER_TABLE_STATS('APP', 'ORDERS',
METHOD_OPT => 'FOR COLUMNS STATUS SIZE 254');
-- 3. 第一次执行:绑定变量 = 'DONE'
VARIABLE v_status VARCHAR2(20);
EXEC :v_status := 'DONE';
SELECT * FROM orders WHERE status = :v_status;
-- 预期执行计划:FULL TABLE SCAN(因为 85% 的数据,索引不划算)
-- 4. 查看 cursor 状态
SELECT sql_id, child_number, is_bind_sensitive, is_bind_aware,
plan_hash_value
FROM v$sql WHERE sql_text LIKE '%orders%' AND sql_text LIKE '%:v_status%';
-- child_number: 0, is_bind_sensitive: Y, is_bind_aware: N
-- 5. 第二次执行:绑定变量 = 'PENDING'
EXEC :v_status := 'PENDING';
SELECT * FROM orders WHERE status = :v_status;
-- 由于 ACS 还没标记 bind-aware,复用 child 0 的 FTS 计划
-- 执行时间:45 秒(应该走 INDEX,只需 0.5 秒)
-- 6. 第三次执行:绑定变量 = 'CANCEL'
EXEC :v_status := 'CANCEL';
SELECT * FROM orders WHERE status = :v_status;
-- 仍然复用 child 0
-- 7. 多次执行后,ACS 生成新 child cursor
-- 第 5-8 次执行 'PENDING' 后,Oracle 发现 FTS 总是很差,
-- 生成 child 1:INDEX RANGE SCAN
SELECT sql_id, child_number, is_bind_sensitive, is_bind_aware,
plan_hash_value, executions
FROM v$sql WHERE sql_text LIKE '%orders%' AND sql_text LIKE '%:v_status%';
-- child 0: plan_hash=12345 (FTS), executions=5
-- child 1: plan_hash=67890 (INDEX), executions=3, is_bind_aware=Y
实验结果验证了 ACS 的延迟性:前 4 次执行 'PENDING' 都走了 FTS(child 0),每次 45 秒,直到第 5 次,Oracle 才生成 child 1(INDEX),后续 'PENDING' 执行只需 0.5 秒。这 4 次"错误执行"的代价是 180 秒,对于高频 SQL(每小时执行 1000 次),这个代价不可接受。另外,我们发现如果直方图缺失(METHOD_OPT = 'FOR ALL COLUMNS SIZE 1'),ACS 永远不会标记 bind-sensitive,因为 Oracle 无法判断 'DONE' 和 'PENDING' 的选择性差异——没有直方图,优化器认为所有值的选择性都一样(均匀分布假设)。这再次证明:直方图是 ACS 的前提条件。
4 治理方案:SQL Plan Baseline + 绑定变量拆分
ACS 虽然智能,但"学习期"的代价太高。生产环境不能容忍前几次执行用错计划。我们的治理方案有两个:方案一,SQL Plan Baseline 预绑定。对于已知有倾斜列的 SQL,预先创建多个 Baseline,分别对应不同的绑定值范围。比如 Baseline A 对应 status='DONE'(FTS),Baseline B 对应 status='PENDING'(INDEX),Baseline C 对应 status='CANCEL'(INDEX)。然后用 SQL Patch 或 Outline 把绑定值和 Baseline 关联。这种方法需要 DBA 手动维护,工作量不小,但对于核心 SQL(比如订单查询、支付接口),值得做。方案二,应用层拆分绑定变量。如果业务逻辑允许,把一条 SQL 拆成多条,每条用字面量(literal)而不是绑定变量。比如:IF status = 'DONE' THEN SELECT * FROM orders WHERE status = 'DONE';ELSE SELECT * FROM orders WHERE status = :status;这样 'DONE' 有独立的执行计划(FTS),其他值走绑定变量计划(INDEX)。代价是 Library Cache 里多了一条 SQL,但对于只执行几次的高频值,这个开销可以忽略。我们给一个电商客户做了这个改造,把订单查询拆成"已完成订单"和"非已完成订单"两条 SQL,前者走 FTS(因为总是查大量数据),后者走 INDEX(点查),整体查询延迟从 P95 800ms 降到 P95 45ms。
【踩坑笔记】对于数据倾斜严重的列,如果 ACS 无法及时生效,可以考虑用 DBMS_SQLDIAG 创建 SQL Patch,强制在绑定变量为特定值时使用指定计划。但 Patch 是"拐杖",不是"治本",治本还是优化数据分布(比如把高频值单独分表)或改写 SQL 逻辑。
5 总结:绑定变量的双刃剑
绑定变量是 OLTP 的必需品,但也是执行计划跳变的根源。治理绑定变量问题的三层漏斗:第一层,数据层:减少数据倾斜。比如把历史订单和活跃订单分开存,历史订单表 99% 是 'DONE',不需要 ACS;活跃订单表状态分布均匀,绑定变量不会跳变。第二层,SQL 层:对倾斜值做特殊处理。高频值单独写 SQL,低频值走绑定变量。第三层,数据库层:启用 ACS + 直方图 + SQL Plan Baseline。ACS 处理一般情况,Baseline 保底核心 SQL。最后送一句话:绑定变量是"一夫一妻制",但数据分布是"一夫多妻制",Oracle 的 ACS 试图搞"开放式婚姻",但学习期太长,不如应用层直接"离婚分家"来得痛快。该绑定的绑定,该拆分的拆分,别一根筋。