接手一套YashanDB生产环境之后,我做得最多的一件事不是写SQL,而是把那些被业务方标成“慢查询”的语句一条条捞出来,先看执行计划、再看统计信息、然后是连接会话、锁等待和表空间状态。YashanDB作为一款兼容Oracle语法的国产数据库,优化思路和Oracle有不少相通之处,但也有自己的脾气。很多看起来像是SQL写得烂的问题,根子其实在数据库运行状态上:统计信息过期、索引冗余、连接数爆炸、事务锁竞争、表碎片堆积。这篇文章把我实际踩过坑之后沉淀下来的5个优化技巧分享出来,不保证能让你手上每个查询都秒回,但至少能让数据库在持续运行之后不会越跑越慢。适合已经有基础SQL技能、正在负责YashanDB性能保障的DBA或后端开发参考。
1. 统计信息过期:执行计划跑偏的罪魁祸首
1.1 三个信号判断统计信息是否需要“体检”
优化器决定一个SQL走全表扫描还是走索引,依赖的是统计信息。YashanDB和大多数关系型数据库一样,采用基于代价的优化器,代价估算的起点就是表上的行数、列的唯一值个数、数据分布等统计信息。如果统计信息滞后,优化器可能把一个明明只有几百行的表估算成几十万行,也可能把几百万行的表估算成空表,从而选择完全不合适的执行计划。
我自己判断统计信息是否需要更新,主要看三个信号:
- 数据字典里表的
last_analyzed时间已经是很久以前,比如超过一周或者一个月; - 表上的业务数据明显快速变化,每天几万行增删改,而统计信息却长期不动;
- 同一个SQL在不同时段的执行表现差异极大,有时毫秒级返回,有时几十秒不出结果。
出现其中任意一条,我都建议先做统计信息收集,而不是去改SQL文本。很多人踩的坑就是反复改写SQL,把IN换成EXISTS,把子查询拆开又合并,折腾一整天,结果只是统计信息不准导致优化器选错了路径。
怎么实时确认统计信息状态?YasndanDB兼容了Oracle风格的数据字典视图,可以直接查:
SELECT table_name, num_rows, last_analyzed FROM user_tables WHERE table_name = 'ORDERS';如果last_analyzed为空或者时间很老,num_rows明显小于实际行数,那基本可以判断统计信息脱节了。
1.2 用DBMS_STATS做一次快速“体检”
确认统计信息过期后,最好的办法是调用YasndanDB兼容的DBMS_STATS包。完整收集一张大表的统计信息是有代价的,尤其生产环境不能随便跑全量收集,要有节制。
日常我习惯这么用:
-- 单表统计信息收集,级联收集索引统计信息 CALL DBMS_STATS.GATHER_TABLE_STATS( ownname => 'APP', tabname => 'ORDERS', cascade => TRUE );如果表特别大,例如超过千万行,我一般加上采样比例和并行度:
CALL DBMS_STATS.GATHER_TABLE_STATS( ownname => 'APP', tabname => 'ORDERS', estimate_percent => 15, degree => 8, cascade => TRUE );estimate_percent是采样比例,15%的意思就是只读表中15%的数据块来估算统计信息。这个数字不是越小越好,也不是越大越好。数据分布相对均匀的表,10%到15%已经能得出足够准确的基数估算;如果表上有严重的倾斜列,比如一个状态字段90%的行都是同一个值,建议对这个字段单独做列级直方图,或者干脆100%采样。
我见过不少同学怕收集统计信息影响业务,把采样比例调到1%,结果估算偏差被放大,执行计划反而更差。根据实际数据量,我通常的建议是:
| 表规模 | 建议采样比例 | 收集频率 |
|---|---|---|
| 小于100万行 | 100% | 每周或关键批量后 |
| 100万到1000万 | 30% | 每周 |
| 大于1000万 | 10%-15% | 紧急时或每月 |
收集完成后,可以用EXPLAIN PLAN FOR重新查看目标SQL的执行计划,确认基数估算是否贴近实际。
1.3 一次因统计信息滞后引发的性能回退实测
我之前遇到过一个典型案例:订单表ORDERS大概180万行,每天新增3万行,但统计信息已经三个月没更新。一个分页查询要把订单表和用户表关联起来,优化器把ORDERS估算成了1000行,于是选择了嵌套循环连接,逐行去探测用户表索引。结果这个查询耗时超过20秒,前端直接超时。
当时我先没收统计信息,而是试图通过提示改写SQL,比如LEADING、USE_HASH,虽然能解决当下问题,但换一个查询条件又不行。后来冷静下来,先做了统计信息收集,num_rows更新到180万,优化器立刻改成哈希连接,同样的SQL耗时降到300毫秒。
从那以后,我给自己定了一条规矩:任何SQL慢问题,在没有确认统计信息新鲜度之前,不轻易动SQL文本。这个习惯省掉了很多无用功。
2. 复合索引设计:字段顺序、覆盖扫描与去冗
2.1 最左前缀原则与字段顺序的取舍
统计信息没问题之后,下一步该看索引。索引设计里最容易出问题的是复合索引的列顺序。
YashanDB的B树索引和Oracle类似,遵循最左前缀原则:查询条件只有用到复合索引第一列时,索引才能生效。但仅仅“生效”还不够,列顺序直接决定了索引过滤效率。
我的经验法则是:等值条件放最前,范围条件放最后,排序字段看情况排中间。
举个例子:
SELECT * FROM orders WHERE customer_id = 'C10001' AND create_time >= DATE '2025-01-01' AND create_time < DATE '2025-02-01' ORDER BY order_id;这种情况下,建议建索引:
CREATE INDEX idx_orders_cust_time ON orders(customer_id, create_time, order_id);customer_id是等值条件,放在第一列;create_time是范围条件,放在第二列;order_id被ORDER BY用到,放在第三列可以避免排序。如果把create_time放在第一列,customer_id放在后面,索引只能用于过滤时间范围,还得额外回表去匹配客户,性能就会差一个量级。
当然,这不是绝对的。如果业务上对create_time的等值查询更频繁,对customer_id的等值查询很少,那么顺序可以反过来。所以架构师在定索引之前,最好先列出现有查询的谓词模式,按“等值列的候选值个数”来排序,候选值个数越少越适合放前面。
2.2 让索引把查询“盖”住
回表是行式数据库常见的性能损耗点。一个普通二级索引,通过索引找到主键或ROWID之后,还得再访问表本身拿到其余列。如果查询只需要少数几个列,完全可以做一个覆盖索引,让索引自身就包含这些列,避免回表。
比如有一个高频曲线查询:
SELECT order_id, status, create_time FROM orders WHERE status = 'PAID' ORDER BY create_time DESC;如果只建idx_status(status),每次查询都要根据order_id回表,最后再做一次排序。更优的做法是:
CREATE INDEX idx_status_time_id ON orders(status, create_time DESC, order_id);这里status用于等值过滤,create_time用于排序,order_id放进索引是为了让查询的所有列都从索引拿到,实现全覆盖扫描。实测中,同样一张千万级表,覆盖索引比频繁回表少了一半以上的逻辑读,响应时间也显著下降。
不过覆盖索引不是列越多越好。如果往索引里塞进varchar(500)之类的长字段,索引块变多、层级加深,反而会降低整体效率。我的做法是:只有高频查询的列组合长度不超过50字节时,才考虑覆盖索引;超过的话,优先考虑简短冗余字段或回表。
2.3 找出那些“吃空间不出力”的冗余索引
很多系统经过多轮迭代,索引越建越多,但真正使用的可能只有20%。冗余索引不但占磁盘空间,还拖累DML性能——每次插入、删除、更新都需要同步维护索引。
我一般通过两类手段筛查冗余索引:
第一种,从SQL计划使用情况反推。查YashanDB的动态性能视图,统计每个索引在v$sql_plan里被引用次数偏低,甚至为0的索引,基本可以列入可疑名单。例如:
SELECT object_name, COUNT(*) AS uses FROM v$sql_plan WHERE operation LIKE '%INDEX%' GROUP BY object_name ORDER BY uses ASC;第二种,直接比较索引定义。复合索引存在前缀重复,例如已有(a,b,c),又建了(a,b),后者大概率是冗余的。可以查user_ind_columns拼出索引键列表来分析:
SELECT index_name, LISTAGG(column_name, ',') WITHIN GROUP (ORDER BY column_position) AS cols FROM user_ind_columns GROUP BY index_name ORDER BY cols;看到两行索引键列完全相同,或者一个索引的键是另一个索引的前缀,就要考虑合并或删除。
删除之前务必确认:数据库里没有挂着依赖这个索引的约束,业务侧也没有用索引提示强制指定。我在线下环境先ALTER INDEX xxx INVISIBLE观察两天,确认没有告警后才真正DROP,这个办法能最大程度降低误删风险。
3. 连接池与会话瘦身:别让资源被空转会话吃掉
3.1 会话数高并不代表并发能力强
很多团队有个误区:把数据库最大连接数配得很大,仿佛连接数越多,系统处理能力越强。实际上每个会话都会占用数据库内存,比如排序区、游标区、堆栈空间,即使会话空闲,资源也被占着。会话数过高还会导致操作系统上下文切换频繁,CPU时间大量消耗在调度而不是干活上。
我接过一个YashanDB环境,max_sessions配到了2000,结果日常连接只有300个,数据库内存已经吃紧。真正高峰期冲上去500个会话时,活跃SQL没多少,CPU反而被打满。问题出在客户端连接池没有合理限制,业务侧每个服务实例都开了超大连接池。
优化连接池有两个方向:应用侧和数据库侧,必须同时下手。
3.2 连接池参数要“双向克制”
应用侧以Java的HikariCP为例,我常用的配置是:
spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000这里的核心思想是:连接池的大小配置应该等于“正常峰值并发所需的连接数”,而不是“极端瞬间最大连接数”。如果系统峰值并发只有40,你配置200,那大概率80%的会话在空等。连接池过小会产生获取连接等待,过大则白白消耗数据库资源,需要靠压测找到那个“既不排队又不浪费”的平衡点。
数据库侧也要有对应的封顶措施。YashanDB里类似PROCESSES、SESSIONS的参数需要调到一个合理值,不要让应用侧无限扩展。我一般将数据库侧硬上限设置为应用侧所有实例连接池总和上限的1.2倍,预留20%给运维后台和紧急任务。
举例:应用有5个实例,每个连接池最大50,那数据库侧SESSIONS就在250到300之间。连接数求多不求精,最后往往是自杀式的资源耗尽。
3.3 定位“僵尸会话”并清理
连接池参数调整后,还要定期清理历史遗留的僵尸会话。最常见的是应用报错后没释放连接,长期挂在数据库上,占着会话不干活。
排查时我常用这条SQL:
SELECT s.sid, s.serial#, s.username, s.status, s.machine, s.program, s.last_call_et FROM v$session s WHERE s.status = 'INACTIVE' AND s.type = 'USER' ORDER BY s.last_call_et DESC;last_call_et代表会话最后一次执行操作到现在过了多少秒。如果一个INACTIVE会话的last_call_et超过30分钟,且不是开发者打开的事务窗口,基本可以判断是僵尸连接。清理前先用下面的SQL找到它正在执行的SQL:
SELECT s.sid, s.serial#, q.sql_text FROM v$session s LEFT JOIN v$sql q ON s.sql_id = q.sql_id WHERE s.sid = &sid;确认不是长事务后,用ALTER SYSTEM KILL SESSION 'sid,serial#'清理。注意,如果有正在进行的活动事务,强行kill后应用端要做好回滚处理,否则可能残留不一致数据。所以正规流程还是先推动应用侧确保连接池能正确回收失效连接,数据库侧的kill只是兜底。
4. 事务批量提交与隔离级别:锁等待时间骤降的实践经验
4.1 长事务为什么拖累整个库
锁竞争是数据库性能顽疾,而锁竞争的一大来源就是长事务。一个事务开启后长时间不提交,它持有的行级锁或表级锁会阻塞其他事务,导致数据库出现大量enq: TX row lock contention类的等待。
我见过最典型的场景:一个跑批程序在循环里执行了10万条INSERT,却没有分批提交。前台业务不断更新订单状态,结果被后几条未提交的行锁阻塞,整个业务事务积压,响应时间从几十毫秒恶化到几十秒。这是典型的“事务粒度过大,害死全库”的案例。
4.2 批量提交的粒度和节奏
优化方向是减少单事务持有的锁数量和持续时间。最简单有效的手段是:将大事务拆成多个小事务,分批提交。
PL/SQL里可以这样写:
BEGIN FOR i IN 1..100000 LOOP INSERT INTO t_order_log VALUES (...); IF MOD(i, 500) = 0 THEN COMMIT; END IF; END LOOP; COMMIT; END;每500条提交一次,每个事务只持有500条记录的行锁,持续时间很短,不会长时间阻塞其他更新。但不要盲目追求更小批次,比如每10条提交一次,会增加事务提交次数,导致redo同步频繁,日志写放大明显。我在实际项目里的经验值,批量维护类任务一般200到1000条提交一次比较均衡,具体看单条数据的长度和表上的索引数量,索引越多,批量提交控制在500左右更安全。
这里有一个容易被忽略的点:分批提交会破坏任务的原子性。如果批次5执行到一半业务系统崩溃,前4批已提交,第5批未提交,任务需要支持断点续做。所以DBA在设计跑批时一定要和应用开发确认:这个批处理允不允许中间状态被读到?如果不允许,反而需要保留大事务,同时优化锁的持有时间。
4.3 隔离级别按需放低
事务隔离级别对锁竞争的影响同样很大。YashanDB默认的读已提交隔离级别在大多数业务场景下是够用的。如果贸然把整个库的隔离级别提升到序列化,读操作也可能被写操作阻塞,并发能力断崖式下降。
以会话级别调整是可以的:
ALTER SESSION SET ISOLATION_LEVEL = READ COMMITTED;除非业务明确需要可重复读或串行化的场景,比如资金对账、库存扣减等,否则不建议全局提升。特殊业务可以在事务边界内精准设置隔离级别,用完立刻恢复,而不是把所有SQL都塞进高隔离级别框架里跑。
锁优化不是一句“加索引”就能解决的。很多锁等待来自更新少量行时全表扫描,这时加一个合适的索引就能把表锁或大量行锁变成精确行锁,效果往往立竿见影。所以排查锁问题时,我一般同时抓v$session里的阻塞者和被阻塞SQL,然后看它们执行计划里是不是有不必要的全表扫描,两边一起处理。
5. 表碎片与空间回收:性能优化中经常被漏掉的最后一环
5.1 碎片产生机制:delete并不归还空间
数据库运行久了之后,碎片是一个很容易被忽视的问题。当表上频繁执行DELETE或者UPDATE,导致数据块里出现大量空闲空间。YashanDB的段空间管理在这些块被清空后,通常会将其保留在段的空闲列表中,并不会立刻把空间交还文件系统。
带来的性能影响很直接:全表扫描时依然会扫描这些已经半空的块,逻辑读增加;如果块内行数变少,同样的行集需要访问更多块;UPDATE产生行迁移,原块里只留下一个迁移指针,再次访问时需要顺着指针去新块读,性能更差。
我见过一张100万行的日志表,实际数据量不大,但段空间已经扩展到2GB。全表扫描一张小表都要好几秒,就是碎片造成的。
5.2 定位碎片表的SQL“体检单”
怎么快速找出碎片严重的表?我一般用统计信息估算空闲空间。基本公式是:表段大小对比实际数据占用大小。
SELECT table_name, blocks * 8192 / 1024 / 1024 AS seg_size_mb, num_rows * avg_row_len / 1024 / 1024 AS data_size_mb, blocks - CEIL(num_rows * avg_row_len / 8192) AS wasted_blocks FROM user_tables WHERE num_rows > 10000 ORDER BY wasted_blocks DESC;注意这里8KB是常见的数据库块大小设置,如果你那边的YashanDB块大小不同,要先把blocks * block_size换算准确。另一种更直接的方式是查看段空间视图,例如user_segments中表段已经占用的extent数,结合表行数和平均行长估算浪费比例。
这个SQL不一定能完全准确算出碎片,但足以排出一个“可疑表优先级清单”。看到浪费块数高的表,再单独确认实际业务访问频率,优先处理热表的碎片。
5.3 收缩/重建表的操作与避坑
碎片表处理,原则是收缩段空间,把行重新排列紧凑。YashanDB如果支持在线收缩,操作类似:
ALTER TABLE orders ENABLE ROW MOVEMENT; ALTER TABLE orders SHRINK SPACE CASCADE; ALTER TABLE orders DISABLE ROW MOVEMENT;SHRINK SPACE会移动行,所以需要先允许行移动。过程中数据库会取得一定级别的锁,业务高峰期执行会把正常DML堵住。我的习惯是在凌晨低峰期跑,并且观察v$session上的等待事件,一旦发现有大量阻塞,立刻终止收缩任务。
如果不支持在线收缩,或者表上存在不支持收缩的对象,可以采用重建表的方案:
- 创建新表,结构和现有表一致;
INSERT INTO new_table SELECT * FROM old_table;- 重命名表;
- 重建索引、约束、授权;
- 收集统计信息。
这种方法有较长的业务不可用窗口,适合离线环境或停机维护窗口。无论哪种方式,收缩完成后都必须重新收集统计信息,否则段空间是紧凑了,优化器还按旧统计信息估算高水位线,执行计划依然可能跑偏。
碎片整理不是一劳永逸的。如果业务本身以INSERT为主且很少DELETE,碎片增长速度很慢,一季度整理一次就够了;如果表上高频UPDATE或DELETE,比如订单历史表频繁清理,建议每月跑一次健康检查脚本。
最后再分享一个我自己习惯的巡检节奏:我每两周做一次统计信息状态确认,每月做一次索引使用率分析和碎片扫描,每季度做一次连接池参数复盘。性能优化没有一锤子买卖,把底层的统计信息、索引、连接、事务粒度、空间碎片这几件基础功抓牢,再复杂的业务高峰也能稳住。这套方法论不限于YashanDB,放到任何兼容Oracle语法的数据库上,稍微调整参数名就能复用。