同一条业务 SQL,在 disql 中写成字面量(WHERE status = 'PAID')执行很快;放到应用里改为绑定变量(WHERE status = ?)后,多数时候正常,偶尔耗时骤增甚至超时。重启应用后暂时恢复,过一段时间再次出现。
这类问题通常不是偶发抖动,而是绑定变量的估算方式与计划缓存共同作用的结果。
字面量将常量直接写在 SQL 文本中,例如 status = 'PAID'。CBO 能够看到具体取值,并在直方图中定位对应桶,选择率估算相对准确。
绑定变量使用 ? 或 :name 占位,SQL 文本保持不变,实际值在执行时传入。达梦在硬解析阶段会窥探当时的绑定值,并据此估算选择率。相关行为由 BEXP_CALC_ST_FLAG 控制。
缺省值为 128 时,对列与绑定参数之间的过滤条件,通过统计信息直方图计算选择率。直方图会参与估算,但查询直方图需要一个具体值,该值即硬解析时窥探到的第一个绑定值。
取值为 64 时,列与绑定参数之间的条件改为动态采样,在解析阶段抽取部分数据估算选择率。也可以用 HINT 指定:
EXPLAIN SELECT /*+BEXP_CALC_ST_FLAG(64)*/ * FROM t WHERE c1 = ?;
因此,绑定变量并不是跳过直方图,而是 直方图主要为硬解析时的首个绑定值服务一次。对首值而言,估算可以相当准确;计划生成之后,后续不同参数不会再次查询直方图。
开启计划重用(USE_PLN_POOL ≠ 0)。约束是:相同 SQL 文本在计划缓存中只保留一份执行计划。
与首值窥探叠加后,典型过程如下。
首次执行传入稀疏值 'CANCELLED',命中约 100 行。优化器按 100 行选择嵌套循环加索引回表,并将该计划写入缓存。
后续执行传入热点值 'PAID',命中约 850 万行。SQL 文本未变,直接复用按 100 行生成的计划。大结果集仍按索引逐行回表,耗时会显著上升。
知识库中有一则对应案例:某列 10951 个不同值,其中一个值约占 43%。首次解析传入稀疏值,生成 nested loop 加索引回表计划;之后传入对应 65664 行的热点值,仍复用该计划,回表约 440 次,性能明显恶化。
「时快时慢」的直接原因是:同一条绑定变量语句在不同参数下可能需要不同计划,缓存却只保存一份。参数与当前计划匹配时表现正常,不匹配时变慢。重启应用有时会清空相关缓存,因此会出现暂时恢复的现象。
需要区分两个开关的作用:BEXP_CALC_ST_FLAG 只影响硬解析时如何估算,不会使每个参数都更换计划;是否更换计划取决于计划缓存是否开启。关闭 USE_PLN_POOL 后每次执行都会重新优化,不同绑定值确实可能得到不同计划,但高并发场景下硬解析的 CPU 开销通常难以接受。
另外,修改 INI 参数不会自动清理已生成的计划缓存。缓存开启时,旧计划会继续被复用。参数调整后应手动清理计划缓存,否则容易误判为「参数未生效」。
disql 中手工执行的往往是字面量,估算路径是「读取常量并查询直方图」。应用侧是绑定变量,并叠加计划重用。两条路径不同,EXPLAIN 得到的计划与生产实际使用的计划可能不是同一份。
因此,排查耗时抖动时,应查看应用会话正在使用的计划,而不能只依据 disql 中的 EXPLAIN。
-- 筛选 MAX 与 AVG 耗时差距较大的语句,用于发现抖动
SELECT SQL_TEXT, MAX_TIME_USED/1000 AS max_s, AVG_TIME_USED/1000 AS avg_s
FROM V$SQL_STAT
WHERE SQL_TEXT LIKE '%特征%'
ORDER BY MAX_TIME_USED DESC;
-- 查看缓存中的实际计划
SELECT * FROM V$SQL_PLAN WHERE SQL_TEXT LIKE '%特征%';
SELECT * FROM V$CACHEPLN WHERE SQLSTR LIKE '%特征%';
-- 查看应用实际绑定值分布(需先设置 ENABLE_MONITOR_BP = 1)
SELECT * FROM V$SQL_BINDDATA_HISTORY WHERE ...;
热点值应以 V$SQL_BINDDATA_HISTORY 中的绑定次数为依据。
在能够修改应用的前提下,优先考虑将显著热点值拆成独立的字面量 SQL,其余值继续使用绑定变量。SQL 文本不同,缓存条目随之分离,各值域可以拥有各自的计划。该方法效果明确,代价是需要改代码;热点值过多时,分支数量会明显增加。
无法改代码时,可考虑 ENHANCE_BIND_PEEKING = 1,由优化器按变量值修正选择率。其限制包括:主要适用于单表过滤条件、列上需要直方图、单条语句支持的变量数量有限(文档示例量级约为 4 个)。该参数与计划缓存的交互在文档中说明不充分,上线前应在目标版本上实测。
将 BEXP_CALC_ST_FLAG 调整为 64,可使绑定条件改为动态采样,无需改 SQL,但会增加解析阶段的采样开销。计划缓存开启时,仍然是一份 SQL 对应一份计划。
SF_INJECT_HINT() 可在不修改业务 SQL 的情况下注入 Hint、锁定计划形态。数据分布变化后计划不会自动调整,适合作为兜底手段。
选择顺序可以概括为:倾斜显著且热点值很少时采用拆分;无法改代码时再考虑增强窥探或动态采样;仍无法接受时再锁定计划。
达梦没有与 Oracle ACS(自适应游标共享)等价的机制,不能按绑定值范围为同一 SQL 文本缓存多份计划。ACTRL 自适应计划是在一份计划内保留主备分支,按中间结果的实际行数切换,并不按绑定值切换,且在 MPP 与并行查询下会失效,不能替代 ACS。
一种常见理解是:拆分之后,热点值走全表扫描,其余值走索引加回表。这把典型结果当成了机制本身。
拆分只完成计划隔离:将不同值域路由到不同的 SQL 文本,使它们在缓存中各自对应一份计划。每份计划采用全表扫描、索引、回表,或嵌套循环、HASH JOIN,由 CBO 根据统计信息按代价选择。拆分并不指定形态,只是为准确估算创造条件。
热点值经常对应全表扫描,原因在于回表成本。索引访问通常包含定位(SSEK)与按 ROWID 回表(BLKUP),后者是随机 IO。命中行约占表的 1% 时,回表往往仍优于全表扫描;命中约占 85% 时,数百万次随机读的代价会高于一次顺序扫描。经验拐点大约在命中行数占表的 5%~15%,行越宽、数据聚簇越差,拐点越低。'PAID' 占比达到 85% 时,CBO 选择全表扫描是代价计算的结果,并非拆分规则所规定。
下列情况中, 热点值等于全表扫描 不成立:
status=? AND tenant_id=? AND create_time>? 时,驱动索引可能是 tenant_id,status 仅作为过滤条件;拆分还会改变首值窥探的效果。绑定变量分支的首值只能来自剩余的稀疏值域,若该值域近似均匀,首值可以代表其余取值,一次窥探生成的计划适用于整个剩余值域。拆分之前,首值既可能是约 100 行,也可能是约 850 万行,无法代表后续参数。
另外需要区分 计划切换 与 估算误差 :
剩余值域近似均匀 是拆分的前提,不是自动结果。若存在两个热点(例如 PAID 占 40%、SHIPPED 占 35%),只拆走 PAID,绑定变量分支仍呈双峰,首值窥探仍可能选错形态。应将足以单独越过计划拐点的热点值全部剥离。经验上,单值行数超过表总量的 5%~10% 时(还需结合行宽与回表成本),不宜与稀疏值共用一份计划;热点值超过四五个时,拆分带来的语句数量与分支维护成本会明显上升,应改用增强窥探或 Hint,而不是继续增加字面量分支。
以 1000 万行订单表为例:status 共 16 个取值,PAID 约 850 万行,其余各约 10 万行。将 PAID 写成字面量,其余使用绑定变量后,字面量语句可通过直方图直接估算 PAID;绑定变量语句的首值约 10 万行,对其余取值同样适用。若不拆分,且首值为稀疏值,索引计划进入缓存后再传入 PAID,就会出现大规模回表。
热点值列表应放在配置中,以便数据分布变化时调整,而不必修改代码。
Set<String> hotValues = config.get("order.status.hot"); // 例如 PAID、SHIPPED
public List<Order> queryOrders(String status) {
if (hotValues.contains(status)) {
String sql = "SELECT ... WHERE status='" + status + "'";
return execute(sql);
}
String sql = "SELECT ... WHERE status=?";
return execute(sql, status);
}
字面量分支的取值必须经过白名单校验。未命中枚举的输入应回退到绑定变量分支,以避免拼接 SQL 带来的注入风险。
MyBatis 可用 <choose> 实现同样的路由:
<select id="queryOrders" resultType="Order">
SELECT * FROM orders
<choose>
<when test="status == 'PAID'.toString()">
WHERE status = 'PAID'
</when>
<when test="status == 'SHIPPED'.toString()">
WHERE status = 'SHIPPED'
</when>
<otherwise>
WHERE status = #{status}
</otherwise>
</choose>
</select>
需要注意:test 中若写成 'Y' 这类单字符,OGNL 可能按 char 与 String 比较,条件恒为假,应写成 'Y'.toString()。多字符枚举一般不受影响。
字面量分支并不等于每次硬解析。计划重用开启时,完全相同的 SQL 文本同样命中缓存。将 'PAID' 写在语句中后,文本固定,缓存命中方式与绑定变量相同。增加的只是缓存条目数量(例如由 1 条变为 2~3 条),通常可以忽略。
拆分应只针对倾斜列,同一语句中分布较均匀的列继续使用绑定变量。上线前应对各分支分别执行 EXPLAIN,计划形态可以不同,但应各自合理。上线后通过 V$CACHEPLN 确认缓存项已经分离,并用 V$SQL_STAT 对比耗时。此后应定期根据直方图与绑定历史复查热点集合。
实验前应更新统计信息(具体语句以环境为准,例如对相关列收集直方图)。统计过期时,EXPLAIN 不能作为决策依据。实验使用的取值应来自 V$SQL_BINDDATA_HISTORY,而不是临时假定。
对热点值与稀疏值分别执行 EXPLAIN:
「单值超过表的 5%~10% 即拆分」只是经验范围。两次 EXPLAIN 的对比结果更接近这条 SQL 在当前数据上的实际情况。
绑定变量的收益是避免反复硬解析。优化过程(包括查询直方图)通常在首次硬解析时完成,计划写入缓存后被后续执行复用。当列值明显倾斜时,首个绑定值不能代表后续参数,就会出现耗时不稳定。
处理思路不是让每个绑定值都重新优化,而是让一份缓存计划只服务于选择性相近的值域。热点值很少时,用不同 SQL 文本做计划隔离;热点值较多或无法改代码时,再考虑增强窥探、动态采样或 Hint。调整 INI 后应清理计划缓存。排查问题时应读取应用侧的实际计划,而不是只参考 disql 中的字面量 EXPLAIN。
拆分解决的是计划隔离,并不指定全表扫描或索引访问。计划形态仍由 CBO 根据代价选择。
以上做法需要在目标库上实测后再采用。部分在理论上更优的改写,在具体数据分布下可能反而变慢。
文章
阅读量
获赞
