【性能优化】人大金仓SQL优化实战:一条UPDATE语句从119分钟到2.68秒的蜕变

人大金仓SQL优化实战:一条UPDATE语句从119分钟到1.5秒的蜕变

一、问题背景

近期在某生产系统性能排查中,发现一个简单的UPDATE语句执行时间竟长达1小时59分钟,直接导致业务超时、引起生产系统问题。表结构与数据量如下(已脱敏):

  • 设备信息表*_*_list):记录交换机等设备的基本信息,约69,080行。
  • 端口状态表*_*_port):记录每个设备端口的实时状态,约412,073行,按区域划分为17个分区。

业务逻辑 :统计每个设备下状态为特定值(如'02'、'04')的端口数量,并更新到设备信息表的port_count字段。

原始SQL如下:

sql 复制代码
UPDATE *_*_list d
SET d.port_count = (
    SELECT COUNT(*)
    FROM *_*_port p
    WHERE p.current_state IN ('02', '04')
      AND p.device_id = d.device_id
);

二、性能分析:逐行循环的噩梦

2.1 无索引状态(1h59m)

在没有任何索引的情况下,执行计划揭示出灾难的根源:

  • 外层对*_*_list全表扫描(69,080行)。
  • 每行执行一次子查询,而子查询需要对*_*_port全表扫描(412,073行)。
  • 总扫描行数 ≈ 69,080 × 412,073 ≈ 284亿行

这是典型的**逐行循环(Row-by-Row)**导致的性能灾难。每一次子查询都是全表扫描,即使数据量不大,乘积效应也能让数据库崩溃。

2.2 创建复合索引后(10.9秒)

*_*_port上创建复合索引:

sql 复制代码
CREATE INDEX idx_device_state ON *_*_port(device_id, current_state);

执行时间降至10.9秒 ,但仍需执行69,080次索引扫描 ,每次扫描涉及17个分区,总索引扫描次数高达117万次

执行计划(已脱敏)

复制代码
Update on *_*_list a  (cost=0.00..142132776.59 rows=165004 width=3480) (actual time=10935.552..10935.558 rows=0 loops=1)
  ->  Seq Scan on *_*_list a  (cost=0.00..142132776.59 rows=165004 width=3480) (actual time=817.255..8980.587 rows=69080 loops=1)
        SubPlan 1
          ->  Aggregate  (cost=860.86..860.87 rows=1 width=8) (actual time=0.115..0.115 rows=1 loops=69080)
                ->  Append  (cost=0.42..860.22 rows=258 width=0) (actual time=0.046..0.113 rows=8 loops=69080)
                      ->  Index Only Scan using idx_device_state on *_*_port_rm_01 p  (cost=0.42..31.00 rows=6 width=0) (actual time=0.004..0.005 rows=1 loops=69080)
                            Index Cond: (device_id = (a.device_id)::numeric)
                            Filter: (current_state = ANY ('{02,04}'::text[]))
                      -- ... 其余16个分区类似,每个分区平均返回0~2行

执行计划解读

  • loops=69080 :这是最关键的性能杀手。子计划节点(SubPlan 1)被执行了69,080次,恰好等于外层表*_*_list的行数,说明这是一个典型的相关子查询,对每一行都会触发一次子查询。
  • Aggregate:每个子查询内部先进行聚合(COUNT),虽然每个子查询很快(0.115ms),但69,080次累加就是约8秒。
  • Append:由于表有17个分区,每次子查询需要扫描所有分区(通过Append合并),即使每个分区都使用了索引,但累积扫描次数为69,080×17≈117万次,产生了巨大的开销。
  • Index Only Scan :虽然使用了索引,但索引条件device_id = a.device_id是每次传入不同值,导致无法批量处理。

虽然比无索引状态快了近660倍,但对实时业务而言依然不可接受,因为循环次数没有减少。

三、优化历程:三次迭代,逐步逼近最优

3.1 第一版优化:一次聚合 + LEFT JOIN(5.88秒)

核心思想:先聚合,后连接。将逐行子查询改为一次聚合所有设备的端口数,再用JOIN更新设备表。

考虑到原始SQL中无端口设备会被聚合函数COUNT自动设为0,我们尝试用LEFT JOIN保证所有设备行都被更新,并用COALESCE将NULL转为0:

sql 复制代码
UPDATE *_*_list d
SET d.port_count = COALESCE(p_sum.port_cnt, 0)
FROM (
    SELECT d.device_id, p.port_cnt
    FROM *_*_list d
    LEFT JOIN (
        SELECT device_id, COUNT(*) AS port_cnt
        FROM *_*_port
        WHERE current_state IN ('02', '04')
        GROUP BY device_id
    ) p ON d.device_id = p.device_id
) p_sum
WHERE d.device_id = p_sum.device_id;

执行计划(简化)

复制代码
Update on *_*_list a  (actual time=5881.078..5881.087 rows=0 loops=1)
  ->  Hash Left Join  (actual time=3492.059..3981.436 rows=138160 loops=1)
        Hash Cond: (a_1.device_id = p.device_id)
        ->  Merge Join  (actual time=3077.651..3312.883 rows=138160 loops=1)
              Merge Cond: (a.device_id = a_1.device_id)
              ->  Sort (rows=69080) (actual time=1567..1578)
              ->  Sort (rows=69080) (actual time=1510..1525)
        ->  Hash  (rows=29715) (actual time=414..414)
              ->  Subquery Scan on p  (actual time=387..404)
                    ->  HashAggregate  (actual time=387..396)
                          ->  Append  (actual time=0.024..279 rows=281933)

执行计划解读

  • Hash Left Join :最终连接使用了左连接,确保左表(*_*_list)所有行都保留,右表无匹配时补NULL。这是为了将所有设备都纳入更新范围。
  • Merge Join :注意这里出现了一个奇怪的Merge Join,它连接了*_*_list两次(别名a和a_1),导致对同一张表做了两次全表扫描并排序。这是由SQL写法中的子查询FROM (SELECT d.device_id, ... FROM *_*_list d LEFT JOIN ...)引起的,它先对*_*_list做了一次左连接,结果集再与外部更新表关联,产生了冗余操作。
  • 两次Sort:两次排序分别耗时1.5秒左右,合计3秒,成为主要开销。
  • HashAggregate:好消息是端口统计只做了一次,扫描所有分区耗时仅279ms,聚合耗时396ms,效率很高。
  • 总耗时5.88秒:虽然比10.9秒提升近一倍,但冗余的表扫描和排序仍有优化空间。

3.2 第二版优化:一次聚合 + INNER JOIN(3.00秒)

经与业务方深入沟通,确认业务需求只需更新有端口 的设备,无端口的设备保持原值即可(不需要设为0或NULL)。于是改用INNER JOIN,只更新匹配到的行。

SQL中直接通过FROM子句的隐式内连接实现(等价于INNER JOIN):

sql 复制代码
UPDATE *_*_list d
SET d.port_count = p_sum.port_cnt
FROM (
    SELECT device_id, COUNT(*) AS port_cnt
    FROM *_*_port
    WHERE current_state IN ('02', '04')
    GROUP BY device_id
) p_sum
WHERE d.device_id = p_sum.device_id;  -- 这里的WHERE条件实现了内连接语义

执行计划

复制代码
Update on *_*_list a  (actual time=2938.928..2938.986 rows=0 loops=1)
  ->  Hash Join  (actual time=1248.242..1434.675 rows=59430 loops=1)
        Hash Cond: (a.device_id = p_sum.device_id)
        ->  Seq Scan on *_*_list a  (actual time=820.344..891.651 rows=69080 loops=1)
        ->  Hash  (actual time=413.498..413.554 rows=29715 loops=1)
              ->  Subquery Scan on p_sum  (actual time=385.424..402.093 rows=29715 loops=1)
                    ->  HashAggregate  (actual time=385.373..394.167 rows=29715 loops=1)
                          ->  Append  (actual time=0.217..274.903 rows=281933 loops=1)

执行计划解读

  • Hash Join :去掉了冗余的Merge Join,直接使用哈希连接将设备表与聚合结果关联。哈希连接适合左表较大、右表可装入内存的场景,这里右表(聚合结果)仅29715行,内存占用约2701KB,完美适用。
  • Seq Scan:设备表只扫描一次,耗时约891ms,没有多余的排序。
  • HashAggregate:端口表依然只聚合一次,耗时约394ms。
  • rows=59430:Hash Join结果集为59,430行,即有端口的设备数,只有这些行会被更新,避免了全量更新。
  • 总耗时3.00秒:相比第一版提升近一倍,执行计划非常简洁高效。

3.3 第三版优化:只更新端口数变化的行(1.53秒)

如果大部分设备的端口数并未变化,可以进一步减少写操作,仅当新值与旧值不同时才更新:

sql 复制代码
UPDATE *_*_list d
SET d.port_count = p_sum.port_cnt
FROM (
    SELECT device_id, COUNT(*) AS port_cnt
    FROM *_*_port
    WHERE current_state IN ('02', '04')
    GROUP BY device_id
) p_sum
WHERE d.device_id = p_sum.device_id
  AND d.port_count != p_sum.port_cnt;  -- 只更新变化的行

执行计划

复制代码
Update on *_*_list a  (actual time=1527.475..1527.550 rows=0 loops=1)
  ->  Hash Join  (actual time=1527.472..1527.481 rows=0 loops=1)
        Hash Cond: (a.device_id = p_sum.device_id)
        Join Filter: (a.port_count <> p_sum.port_cnt)
        Rows Removed by Join Filter: 59430
        ->  Seq Scan on *_*_list a  (actual time=921.558..1008.833 rows=69080 loops=1)
        ->  Hash  (actual time=412.319..412.326 rows=29715 loops=1)
              ->  Subquery Scan on p_sum  (actual time=382.895..400.683 rows=29715 loops=1)

执行计划解读

  • Join Filter :在哈希连接上增加了过滤条件a.port_count <> p_sum.port_cnt。这个过滤发生在连接过程中,只有满足条件的行才会进入更新阶段。
  • Rows Removed by Join Filter: 59430 :这是教学中的关键数字!它告诉我们,有59,430行通过了连接条件(即有端口),但它们的port_count与旧值相同,被过滤掉了,最终需要更新的行数为0(本例测试数据中所有匹配行的值都未变)。如果实际有变化,这里会显示被过滤掉一部分,剩余的行被更新。
  • 更新行数为0rows=0 loops=1表示UPDATE实际修改了0行,因此执行时间更短(仅1.53秒),因为写操作被最小化。
  • 适用场景:当大多数设备的端口数稳定时,此优化效果显著;即使部分变化,也只会更新变化行,减少写开销和锁竞争。

四、性能对比总结

SQL版本 执行时间 子查询次数 更新行数 关键特征
原始SQL(无索引) 1h59m 69,080次 69,080 每行全表扫描
原始SQL(有索引) 10.9秒 69,080次 69,080 每行索引扫描(循环未减)
第一版优化(LEFT JOIN) 5.88秒 1次 69,080 聚合一次,但冗余表扫描
第二版优化(INNER JOIN) 3.00秒 1次 59,430 聚合一次,仅更新有端口设备
第三版优化(INNER JOIN + 过滤) 1.53秒 1次 变化行 聚合一次,仅更新变化行

优化成果

  • 相比无索引状态:性能提升最高4600倍
  • 相比有索引状态:性能提升7倍
  • 最终版本1.53秒,满足业务要求。

五、执行计划关键指标教学

5.1 如何识别逐行循环?

  • 关注**loops**字段:如果某个子计划节点的loops值等于外表行数(如69,080),说明存在逐行执行。
  • 相关子查询是常见原因,尽量改写为一次集合操作。

5.2 如何判断表扫描次数?

  • Seq Scan出现多次,且对同一张表,说明可能有冗余扫描。
  • Sort节点往往伴随大量内存消耗,应尽量避免不必要的排序。

5.3 哈希连接 vs 嵌套循环

  • Hash Join适合右表较小可装入内存的场景,本例聚合结果仅29715行,完美适用。
  • 若右表极大且无索引,嵌套循环(Nested Loop)可能导致性能问题。

5.4 过滤条件的影响

  • Join FilterFilter显示的行过滤信息(如Rows Removed by Filter)能帮我们判断有多少数据被提前过滤,从而减少后续操作。

5.5 成本估算(cost)与实际时间(actual time)

  • cost是优化器的估算值,actual time是实际执行时间,两者对比可发现估算偏差,帮助调整统计信息。

六、优化心得与建议

  1. 识别逐行循环 :当UPDATE中相关子查询引用外表时,极易导致逐行执行。使用EXPLAIN ANALYZE观察loops字段,如果接近外表行数,就是性能瓶颈。

  2. 先聚合,后连接:对于统计类更新,尽量先在内表上完成聚合,再用JOIN更新外表。这样将N次查询降为1次,从根本上消除循环。

  3. 索引不是万能:索引能加速单次查询,但无法解决循环次数问题。当循环次数巨大时,再快的索引也无法挽回性能。

  4. 业务确认至关重要 :优化前务必与业务方确认更新范围,避免因过度优化导致业务逻辑错误。本例中业务只需要更新有端口的行,因此INNER JOIN完美契合。

  5. 只更新变化行:若大部分数据不变,加上过滤条件可大幅减少写操作,进一步提升性能。

  6. 多次迭代,逐步逼近最优:从119分钟到10.9秒,再到5.88秒、3.00秒、1.53秒,每次优化都基于执行计划分析和业务理解。不要期望一步到位,逐步迭代才能找到最适合业务的方案。

七、结语

一个看似简单的UPDATE语句,在数据量并不巨大(几十万行)的情况下,竟然能跑出接近2小时的恐怖时间。通过将相关子查询改写为一次聚合+JOIN,并针对业务需求逐步优化,我们实现了最高4600倍的性能飞跃,从接近2小时降至1.53秒。

这次优化再次印证了SQL性能调优的黄金法则:减少循环,批量处理。希望本文的案例能为读者提供宝贵的实战经验,在今后遇到类似问题时少走弯路。


相关推荐
橙露1 小时前
SpringBoot 接口性能优化:从接口慢到毫秒级响应实战
spring boot·后端·性能优化
Emotional。2 小时前
AI Agent 性能优化和成本控制
人工智能·深度学习·机器学习·缓存·性能优化
吹晚风吧2 小时前
实现一个mybatis插件,方便在开发中清楚的看出sql的执行及执行耗时
java·sql·mybatis
码云数智-大飞2 小时前
像写 SQL 一样搜索:dbVisitor 如何用 MyBatis 范式颠覆 ElasticSearch 开发
sql·elasticsearch·mybatis
m0_738120722 小时前
应急响应——Solar月赛emergency靶场溯源过程(内含靶机下载以及流量分析)
java·开发语言·网络·redis·web安全·系统安全
科技块儿2 小时前
开发者需要为网站或应用集成IP归属地显示功能,如何选择可靠的数据源?
服务器·网络·数据库·tcp/ip·edge·ip
上海合宙LuatOS2 小时前
LuatOS核心库API——【json 】json 生成和解析库
java·前端·网络·单片机·嵌入式硬件·物联网·json
芝士雪豹只抽瑞克五2 小时前
Keepalived 高可用VRRP笔记
网络
21号 12 小时前
Http粘包问题回顾
网络·网络协议·http