1. 动优化之前,先搞清“卡在哪一层”,别让技巧失效
最近在客户现场调一套资金对账系统,业务方给的反馈很直接:每天晚上批量跑到某个环节就变慢,日志里全是“等待资源”“执行超时”,整个月结要拖到凌晨三四点。我接手后的第一反应不是急着改 SQL,而是先把慢的路径摸清楚。很多人在数据库上花大力气加索引、拆分区,结果性能没起来,原因往往就是优化对象搞错了。
要提升 YashanDB 这类通用关系数据库的处理效率,先得给“效率”做一个分层。我习惯把性能问题拆成三类来对号入座:
- 并发型效率问题:系统本身 SQL 很简单,但几十上百个会话同时执行时,整体吞吐量上不去,往往表现为 CPU 高、闩锁和锁等待明显。这类问题的高频原因是数据库在做太多重复解析或者无谓的争用。
- 数据量型效率问题:单条 SQL 不慢,但要处理的数据范围太大,比如全表扫描几千万行、大范围关联,跑一次要很久。这在批处理和报表场景里非常常见。
- 写入型效率问题:业务逻辑不复杂,但数据积压严重,比如循环逐条插入、反复的日志刷盘、事务粒度太碎,导致写入吞吐远低于磁盘理论能力。
怎么定位是哪一层?可以先看两个东西:慢查询日志和数据库的等待事件。慢日志能告诉你哪条 SQL 占用的累计时间最长;等待事件能告诉你它到底在等什么——等 CPU、等磁盘 I/O,还是在等锁、等 GC。我曾经在 YashanDB 上排查过一个慢接口,执行计划看半天没毛病,后来查等待事件才发现瓶颈是会话间游标缓存抖动,而不是 SQL 本身。这种情况就算你把表结构重设计一遍也没用。
定位之后,再对症下药。下面的五个技巧,是我在实际项目中反复用过、验证过有效的手段,分别覆盖解析、写入、扫描、执行计划和预计算这几个维度。每个技巧我都会讲清楚适用的场景、为什么能提升效率、以及落地时容易踩的坑。
2. 技巧一:绑定变量与语句复用,压掉并发场景的硬解析开销
这是 YashanDB 上最被低估的一个优化点,特别是业务系统里使用 ORM 或者动态拼 SQL 的地方,问题非常突出。
2.1 为什么一条 SQL 只执行一次,却要反复“硬解析”
数据库处理一条 SQL 的时候,并不只是“读数据”那么简单。第一次收到一条新 SQL,它要做词法分析、语法校验、生成执行计划,这整个过程叫做硬解析。硬解析的代价在 OLTP 场景里可能是执行本身的好几倍。如果同一个 SQL 只是查询条件里的值不同,而文本结构完全相同,数据库就可以把执行计划缓存下来,下次直接复用,省掉大多数解析步骤,这就是软解析。
问题在于,很多人写的代码喜欢这样拼 SQL:
String sql = "select amount from trans_order where order_no = '" + orderNo + "'";这种写法在一个高并发的订单查询接口里就会酿成灾难。假设一秒钟来了一千个不同订单号,数据库收到一千条 SQL 文本不同的语句,每一条都要硬解析。共享池里塞满了一千个几乎一样的执行计划副本,内存白白浪费,解析过程中的闩锁竞争还会把 CPU 彻底打满。你回头看慢日志,可能每一条 SQL 本身都只跑了 10 毫秒,但系统整体就是慢。
2.2 用绑定变量的方式改写,能省多少开销
把上面的代码改成 PreparedStatement 绑定变量:
PreparedStatement ps = conn.prepareStatement( "select amount from trans_order where order_no = ?" ); for (int i = 0; i < orderList.size(); i++) { ps.setString(1, orderList.get(i)); try (ResultSet rs = ps.executeQuery()) { // 处理结果 } }这里的变化是,SQL 的文本结构始终只有一条,第一次执行生成执行计划,之后所有订单号都直接复用。我做过一个对比测试,同样是一万个订单号的批量查询,完全不绑定的版本在并发测试中吞吐量只有绑定版本的六成左右,而且 CPU 占用高出将近三成。换到 YashanDB 上,绑定变量也意味着游标缓存可以稳定命中,数据库不需要反复构造执行计划对象,内存和 CPU 的压力同步下降。
有人可能会问:是不是所有 SQL 都适合绑定变量?不是。比如数据分布极度不均,A 值能查到一万行,B 值只能查到一行,绑定变量会导致优化器无法针对具体值选择最优执行计划。这种“绑定变量窥视不准”的极端场景在日常业务里并不多,通常只在报表类或者参数化差异特别大的查询中才需要特殊处理。对于绝大多数在线交易系统,绑定变量的收益远远大于风险。
2.3 配套检查:游标缓存和共享池设置
代码改完之后,还要检查 YashanDB 的游标缓存和共享池相关参数是不是够用。很多项目默认参数在处理低并发时没事,一旦业务起来,游标缓存太小就会发生频繁淘汰,导致本来能命中的软解析变成硬解析,优化效果缩水。
我一般关注两点:一是共享池大小是否随着业务数据量同步调整过,二是是否存在高版本计数过高的问题。高版本计数简单说就是同一条 SQL 因为环境差异产生了太多执行计划版本,这会让复用失效。遇到这种情况,优先检查权限、会话参数、优化器模式这些是否统一,而不是一味去清缓存。
3. 技巧二:批量提交与整表集合操作,摆脱逐行操作的性能陷阱
第二个技巧针对的是写入场景。数据处理中大量时间花在数据导入、批量更新、批量删除上,而很多人的第一版实现就是写一个循环,一条条 UPDATE,一条条 INSERT。
3.1 逐条提交到底慢在哪
用 JDBC 往 YashanDB 里插入一万条数据,如果不开批量,就像一个人去银行柜台一张一张存钱:每次都要排队、填单、等柜员处理、拿到回执,然后继续下一笔。数据库端的表现就是事务频繁提交、重做日志反复刷写、网络来回开销巨大。遇到批量刷新类的任务,比如把外部文件里的几百万条数据清洗进库,逐条插入基本是跑不完的。
举个实际例子。我优化过一个数据同步作业,初次实现是循环单条 INSERT,每次自动提交,五百万行数据跑了将近一个半小时。改成每批五百条、事务提交一次的批量写入后,耗时降到十几分钟,效率提升极其显著。
3.2 批量写入的正确姿势
在 Java 里配合 YashanDB 的 JDBC 驱动,标准写法是使用 addBatch 和 executeBatch:
Connection conn = dataSource.getConnection(); conn.setAutoCommit(false); PreparedStatement ps = conn.prepareStatement( "insert into settle_detail(trade_no, amount, state) values (?, ?, ?)" ); for (int i = 0; i < data.size(); i++) { ps.setString(1, data.get(i).getTradeNo()); ps.setBigDecimal(2, data.get(i).getAmount()); ps.setString(3, data.get(i).getState()); ps.addBatch(); if (i % 500 == 499) { ps.executeBatch(); conn.commit(); } } ps.executeBatch(); conn.commit();有三个细节我在项目里反复强调:
- 批量粒度不是越大越好。单批太大,事务持有锁的时间变长,而且一旦中途出错,回滚代价很高。五百到一千比较平衡。
- 关闭自动提交。很多人忘了这一步,导致 executeBatch 之后每个批次还是自动提交,批量的效果大打折扣。
- 避免在循环里夹杂查询操作。批量写入的循环体里尽量不要做 SELECT,否则事务之间的交互会让锁持有时间不可控。
3.3 用一条 MERGE 替代 R 循环里的判断更新
数据同步中另一个常见的差劲写法是:先 SELECT 判断记录是否存在,存在就 UPDATE,不存在就 INSERT。这个模式在数据量大时不仅慢,还会引入并发下的重复键风险。正确做法是使用 MERGE 语句,把判断逻辑交给数据库自己去处理。
MERGE INTO target_table t USING source_table s ON (t.id = s.id) WHEN MATCHED THEN UPDATE SET t.amount = s.amount WHEN NOT MATCHED THEN INSERT (id, amount) VALUES (s.id, s.amount);这种集合操作的效率远高于逐行判断,因为数据库在执行计划层面就可以按块处理,而不是一次处理一行。我给这个技巧的定位是:凡是能用一条 SQL 表达的数据变更,就不要用程序循环去表达。这不是偷懒,而是充分利用数据库本身的计算能力。
4. 技巧三:分区键设计与并行执行,海量数据扫描的破局点
当单表数据量从几百万涨到几千万甚至上亿的时候,你迟早会遇到“表太大,怎么查都慢”的阶段。这时候最有效的两个手段是分区和并行。
4.1 分区不是把表拆掉,而是让数据库知道“只看该看的部分”
分区表看起来还是一张表,逻辑上没有变化,但物理存储上数据被切成了多个独立的分区。以订单表为例,按月份做范围分区:
CREATE TABLE yashan_order ( order_no NUMBER, create_time DATE NOT NULL, user_id NUMBER, total_amount NUMBER(12,2) ) PARTITION BY RANGE (create_time) ( PARTITION p_202501 VALUES LESS THAN (TO_DATE('2025-02-01','YYYY-MM-DD')), PARTITION p_202502 VALUES LESS THAN (TO_DATE('2025-03-01','YYYY-MM-DD')), PARTITION p_202503 VALUES LESS THAN (TO_DATE('2025-04-01','YYYY-MM-DD')) );之后执行查询时,只要带上 create_time 的过滤条件,优化器就能通过分区裁剪,只扫描对应月份的分区,其他几个月的数据碰都不碰。我优化过一个批量报表,表里十五个月的数据,报表只统计上一个月。没分区前需要全表扫,一次跑六分钟;加上分区后扫描量直接减少到原来的几十分之一,查询秒级返回。
分区还能给数据维护带来额外红利。以前删除某个时间段的历史数据,要 DELETE 几百万行,日志量大、回滚段压力大。分区之后直接ALTER TABLE yashan_order TRUNCATE PARTITION p_202501;这种操作不产生逐行删除的重做日志,瞬间完成。
4.2 分区键选不好,分区反而拖后腿
分区不是随便找个列就行。核心原则是:查询条件里必须稳定出现这个列,否则分区裁剪发挥不了作用。很多人选分区键时只看“这个列有没有索引”,却没有想过有多少查询会拿它当过滤条件。如果一个表有 90% 的查询都按 create_time 过滤,那按 create_time 分区就是对的;如果业务全是按 user_id 查最新一张订单,那按 user_id 做哈希分区可能更合理。
分区粒度也需要克制。按月分区和按天分区之间,扫描性能差距很小,但分区数量太多了,管理成本和元数据查询开销都会上来。我一般建议在生产环境,报表类大表按月,流水类海量表按周或者按天,具体看数据增长速度和保留周期。
4.3 并行执行:让多个 CPU 一起干活,但别滥用
分区解决的是扫描范围问题,并行解决的是单个任务慢的问题。YashanDB 的并行执行可以把一条全表扫描分成多个子任务,同时交给多个 CPU 处理,相当于把一条流水线拆成四条流水线,只要机器有空闲资源,整体耗时就能大幅缩短。
SELECT /*+ PARALLEL(t, 4) */ user_id, SUM(total_amount) FROM yashan_order t WHERE create_time >= TO_DATE('2025-01-01','YYYY-MM-DD') AND create_time < TO_DATE('2025-03-01','YYYY-MM-DD') GROUP BY user_id;这属于分析型查询,用并行很合理。但注意:OLTP 的高并发短查询绝对不能开并行。假设你原本有一百个会话在跑小查询,开了并行之后每个查询又拆成几个并行子任务,几百个并行进程会把 CPU 和内存全部吃光,最后反而拖垮整个系统。我见过一个团队给一个高频接口的表加了并行提示,上线后数据库直接卡死,回滚后才恢复正常。
并行度的设置一般不超过机器 CPU 核心数的一半。判断标准是跑批量任务的时段有没有其他重要业务在跑,如果有,宁可并行度低一点,也要保住核心交易链路的资源。
5. 技巧四:统计信息与索引策略,让优化器做出正确的选择
很多性能问题不是数据库处理慢,而是优化器做出了糟糕的执行计划。YashanDB 的优化器依赖统计信息来估算结果集大小,统计信息不准,再好的索引也白搭。
5.1 索引加了没走,很可能是统计信息在“误导”
有一次排查一个查询,表上明明建了 (user_id, create_time) 的复合索引,条件就是这两个列,可执行计划显示的是全表扫描。原因是这张表导入大量数据之后没有及时更新统计信息,优化器认为表里只有几千行,直接扫描更划算。后来执行了统计信息采集,执行计划立刻切成索引扫描,查询耗时从八百毫秒降到四十毫秒。
在大表上,统计信息的重要性甚至超过索引本身。索引只是给执行计划提供一条路,走不走这条路要由估算决定。统计数据里包含的行数、列的唯一值数量、数据分布直方图等,都会直接影响这个判断。
常见的统计信息维护动作:
EXEC DBMS_STATS.GATHER_TABLE_STATS(USER, 'YASHAN_ORDER');在生产环境,我建议在每次大规模数据装载完成之后、第二天业务高峰期之前执行一次统计信息采集。如果表数据每天变化幅度不大,可以每周做一次,没必要天天跑,毕竟采集本身也有开销。
5.2 索引策略:少建精确建,别把索引当万金油
初学者容易陷入“给每列都建索引”的误区。索引能加快查询,但会拖慢写入,因为每一行数据变了,索引也要跟着维护。一个表如果有五六个索引,单条 INSERT 的代价就很高。我常用的决策框架是:
- 点查条件:
where user_id = ?,建单列索引。 - 组合查询:
where state = ? and create_time >= ?,建联合索引,并把区分度高的列放前面。 - 排序或者分组字段:如果经常
group by user_id,在 user_id 上建索引能避免临时表排序。 - 频繁更新的列:谨慎建索引,索引维护成本可能超过收益。
- 几乎不用于过滤的列:不要建索引,纯属浪费空间和写入性能。
联合索引的列顺序也值得说一句。用 (state, create_time) 还是 (create_time, state),取决于业务里哪个字段过滤后剩下的行数更少。通常把等值条件放前面,范围条件放后面,这样索引利用率最高。
5.3 函数条件导致索引失效,用函数索引绕开
SQL 里对索引列做函数转换是另一个高频问题。最常见的写法是:
SELECT * FROM yashan_order WHERE TO_CHAR(create_time, 'YYYY-MM-DD') = '2025-02-01';因为 create_time 被 TO_CHAR 处理过,索引无法直接定位,优化器只能把整列数据取出来算一遍,这叫做索引失效。如果不改 SQL,可以建函数索引:
CREATE INDEX idx_yd_order_dt ON yashan_order(TO_CHAR(create_time, 'YYYY-MM-DD'));不过更干净的方案是直接改成范围条件:
SELECT * FROM yashan_order WHERE create_time >= TO_DATE('2025-02-01', 'YYYY-MM-DD') AND create_time < TO_DATE('2025-02-02', 'YYYY-MM-DD');这样既保留索引的有效性,也让优化器有更多选择空间。函数索引能解决问题,但会增加维护成本,能用改 SQL 的方式解决就优先改 SQL。
6. 技巧五:物化视图与查询重写,把反复计算的活提前干完
最后一个技巧面向一个非常典型的场景:同一套统计逻辑,每次查询都要重新扫描全量数据,算半天,结果其实和昨天差不了多少。这种问题用物化视图解决最合适。
6.1 物化视图:拿空间换时间的预计算方案
普通视图只是一个查询定义,本身不存数据,每次访问都实时跑一遍。物化视图则不同,它是把查询结果真正保存在数据库里的一张物理表,数据在后台按照设定的策略刷新。以后查询直接读已经算好的汇总结果,而不需要回到源头做聚合了。
举个我真实处理过的例子。某个经营分析系统有一个订单汇总查询,每天业务要做几十次,每次都是按省份、按渠道统计当天的订单金额和订单数。明细表超过八千万行,单次查询要二十多秒。天天查,天天扫,资源浪费非常严重。
我建了物化视图:
CREATE MATERIALIZED VIEW mv_order_summary REFRESH COMPLETE ON DEMAND AS SELECT province, channel_code, SUM(total_amount) sum_amount, COUNT(*) cnt FROM yashan_order WHERE create_time >= TRUNC(SYSDATE) GROUP BY province, channel_code;再把原本复杂的统计 SQL 改成直接查这个物化视图,查询耗时从二十多秒变成几十毫秒。这些汇总结果相当于提前算好放在那儿,业务想怎么查都行。
6.2 刷新策略:增量刷新还是全量刷新要想清楚
物化视图不是建完就完,刷新策略才是真正的设计决策点。
- ON COMMIT:源表事务提交时同步刷新,适合对实时性要求高、写入频率不高的场景。它的代价是每次事务提交都会多出刷新开销,大事务下很容易拖慢主流程。
- ON DEMAND:由用户手动或定时任务触发刷新,适合汇总报表这类对时间不敏感的场景。我一般建议用这种方式,把刷新放在了凌晨低峰期。
- REFRESH FAST:增量刷新,只处理变化的数据,效率高,但要求源表有对应的物化视图日志,建起来复杂一些。
- REFRESH COMPLETE:全量刷新,相当于把物化视图的查询重新执行一遍,逻辑简单,但数据量大时刷得很慢。
生产环境我的建议是:宁可慢一点用 COMPLETE + ON DEMAND,也别轻易上 ON COMMIT。物化视图一旦影响主表的事务延迟,会引发一堆连锁问题,实在需要“准实时”效果,可以每五分钟刷新一次,刷新频率可控可调,比 ON COMMIT 更容易维护。
6.3 查询重写:让优化器自动命中物化视图
YashanDB 支持查询重写的能力,意思是优化器遇到一条和物化视图等价的 SQL 时,可以自动改写去查物化视图,而不需要业务代码显式感知物化视图的存在。这个特性很优雅,但有个前提:物化视图的查询要和用户 SQL 的结构匹配。差的太远,重写就不会发生。
我在项目里的做法是:先分析最耗资源的 Top 查询,确定通用的聚合口径,然后针对这个口径建物化视图,最后用原 SQL 做对比测试验证是否命中。不要贪多,一个物化视图能覆盖多个查询就去覆盖。物化视图越多,刷新任务越重,存储开销也越大,最后数据库变成了一台只维护汇总表的机器,得不偿失。
物化视图也需要索引。因为它本质是一张物理表,查询它的 WHERE 条件和 JOIN 条件照样可以建索引。如果刷新频率高,还要确保刷新任务不会和业务查询争抢资源,通常安排在同一时段会互相影响。还有一个容易忽略的点:物化视图的日志空间会随时间增长,记得定期清理,否则会出现磁盘被性能诊断日志撑满的情况。
7. 落到自己的库上,建议按这个顺序消化这五个技巧
五个技巧讲完了,写出来感觉很短,落地的时候其实是一条条踩过来的。我建议你下次再遇到 YashanDB 上的数据处理慢问题,不要冲上去就改 SQL。先把慢日志和等待事件翻出来,确认瓶颈在解析、写入还是扫描;并发接口慢,优先查绑定变量;数据导入慢,优先批量化;扫描大表慢,思考分区和并行怎么组合;执行计划歪了,从统计信息找原因;高频聚合慢,考虑物化视图。每个技巧都能单独产生效果,但组合起来威力更大。
另外说一句我在现场经常讲的话:优化是留给需要保留的业务的,脏数据、多余的循环、为了应付检查写出来的临时表,这些该清理的清理掉,该重构的重构掉。数据库再强,也顶不住程序把简单的事情反复做一万遍。把引擎该做的事交还给引擎,把系统里无意义的重复开销去掉,这才是提升数据处理效率的根本思路。