欢迎光临
我们一直在努力

KES 性能调优实战:执行计划、索引优化与查询重写完全指南

KES 性能调优实战:执行计划、索引优化与查询重写完全指南

开篇的话

做数据库这行,性能问题是最让人头疼的。白天系统跑得挺顺,一到晚高峰就开始卡,用户投诉电话一个接一个。排查下来,十有八九是 SQL 写得有问题或者索引没建对。我干 DBA 这些年,处理过的性能工单没有一千也有八百,总结下来就一句话:大部分性能问题都是可以避免的,关键是你要懂执行计划,会看索引,知道怎么改写查询。

这篇文章专门聊 KingbaseES 的性能调优,重点讲三个核心技能:读懂执行计划、设计高效索引、优化慢查询。内容偏实操,理论部分点到为止,大量使用真实案例和对比数据。如果你经常要跟慢 SQL 打交道,或者想系统学习 KES 的性能优化方法,这篇文章应该能帮到你。

一、执行计划基础:看懂优化器的决策

执行计划(EXPLAIN)是性能调优的起点。它告诉你数据库准备怎么执行这条 SQL:用哪个索引、用什么连接方式、预计返回多少行。看不懂执行计划,调优就无从谈起。

EXPLAIN 的基本用法

— 最简单的用法,只看计划不执行
EXPLAIN SELECT * FROM orders WHERE user_id = 1001;

— 加上 ANALYZE,实际执行并显示真实统计信息
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 1001;

— 显示缓冲区使用情况(需要开启 track_io_timing)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id = 1001;

— JSON 格式输出,方便程序解析
EXPLAIN (ANALYZE, FORMAT JSON) SELECT * FROM orders WHERE user_id = 1001;

日常调优我最常用的是 EXPLAIN (ANALYZE, BUFFERS),既能看到真实的执行时间,又能看到 IO 情况。不过要注意,ANALYZE 会真正执行 SQL,所以别在生产环境随便跑,特别是那些涉及大量数据的查询。

执行计划的核心要素

一条典型的执行计划长这样:

Nested Loop (cost=0.86..23.45 rows=10 width=128) (actual time=0.032..0.089 rows=8 loops=1)
> Index Scan using idx_orders_user_id on orders (cost=0.43..8.45 rows=10 width=64)
(actual time=0.018..0.045 rows=8 loops=1)
Index Cond: (user_id = 1001)
Buffers: shared hit=12
> Index Scan using idx_users_pkey on users (cost=0.43..1.45 rows=1 width=64)
(actual time=0.002..0.003 rows=1 loops=8)
Index Cond: (id = orders.user_id)
Buffers: shared hit=8

看着复杂,拆解开来就几个关键信息:

1. 节点类型(Node Type)

常见的节点类型有:

  • Seq Scan: 全表扫描,最慢的操作,数据量大时要避免
  • Index Scan: 索引扫描,通过索引找到数据行
  • Index Only Scan: 只查索引就能拿到所有需要的字段,不用回表,最快
  • Bitmap Index Scan: 位图索引扫描,适合多个条件组合查询
  • Nested Loop: 嵌套循环连接,小表驱动大表时效率高
  • Hash Join: 哈希连接,适合大表关联
  • Merge Join: 归并连接,适合两个都已排序的大表关联
  • Sort: 排序操作,如果内存不够会用临时文件
  • Aggregate: 聚合操作(GROUP BY、COUNT、SUM 等)
  • Limit: 限制返回行数

2. cost(成本估算)

cost=0.86..23.45

第一个数字是启动成本(返回第一行的代价),第二个是总成本(返回所有行的代价)。这个值是优化器估算的,单位不是时间,而是一个抽象的成本值。不同节点的 cost 不能直接比较,但同一种操作的 cost 可以用来判断优劣。

3. rows(预估行数)

rows=10

优化器预计这个节点会返回多少行。如果跟 actual rows 差距很大,说明统计信息不准确,需要 ANALYZE 更新。

4. width(平均每行宽度)

width=128

预计每行数据的字节数。这个值影响内存使用和传输开销。

5. actual time(实际执行时间)

actual time=0.032..0.089

只有在 EXPLAIN ANALYZE 模式下才有。第一个数字是返回第一行的时间(毫秒),第二个是返回所有行的总时间。这是真实性能数据,比 cost 更有参考价值。

6. Buffers(缓冲区命中)

Buffers: shared hit=12

显示缓存命中情况:

  • hit: 从共享缓冲区读取(内存,快)
  • read: 从磁盘读取(慢)
  • dirty: 脏页数量
  • written: 写回的页数

hit 越多越好,read 多了说明缓存命中率低,可能需要加大 shared_buffers 或者优化查询减少 IO。

7. loops(循环次数)

loops=1

这个节点被执行了多少次。Nested Loop 的内层节点 loops 通常大于 1,表示对每一行外层数据都执行了一次内层查询。

执行计划的阅读顺序

执行计划是从下往上、从右往左读的。最底层的节点最先执行,结果传给上层节点。以上面的例子为例:

  • 先用 idx_orders_user_id 索引查出 user_id=1001 的订单(8 行)
  • 对这 8 个订单的每一个,用 idx_users_pkey 索引查对应的用户信息
  • 最后用 Nested Loop 把两边结果拼起来
  • 理解了这个执行顺序,才能看出哪里可以优化。比如上面的例子,如果 orders 表有 100 万行,但符合条件的只有 8 行,那用索引扫描就很合适。但如果符合条件的有 10 万行,可能全表扫描反而更快(因为避免了大量的随机 IO)。

    常见执行计划模式解读

    模式一:理想的高效查询

    Index Only Scan using idx_orders_user_id_status on orders
    (cost=0.43..8.45 rows=10 width=32)
    (actual time=0.015..0.025 rows=8 loops=1)
    Index Cond: (user_id = 1001 AND status = 1)
    Buffers: shared hit=5

    特点:

    • Index Only Scan,不需要回表
    • 实际行数跟预估接近
    • 全部 buffer hit,没有磁盘 IO
    • 执行时间在毫秒级

    这种查询基本不用优化,已经是最优了。

    模式二:需要警惕的全表扫描

    Seq Scan on orders (cost=0.00..23456.78 rows=500000 width=128)
    (actual time=12.345..456.789 rows=498234 loops=1)
    Filter: (created_at > '2026-01-01')
    Rows Removed by Filter: 1501766
    Buffers: shared hit=1234 read=5678

    特点:

    • Seq Scan 全表扫描
    • 过滤掉了大量行(Rows Removed by Filter 很大)
    • 有很多磁盘 read
    • 执行时间长

    这种查询通常是因为缺少合适的索引,或者统计信息不准导致优化器选错了计划。

    模式三:嵌套循环的性能陷阱

    Nested Loop (cost=0.86..123456.78 rows=1000000 width=128)
    (actual time=0.032..12345.678 rows=998234 loops=1)
    > Seq Scan on large_table_a (cost=0.00..1234.56 rows=10000 width=64)
    (actual time=0.015..12.345 rows=10000 loops=1)
    > Index Scan using idx_b_fk on large_table_b (cost=0.43..12.34 rows=100 width=64)
    (actual time=0.001..1.200 rows=100 loops=10000)
    Index Cond: (a_id = large_table_a.id)
    Buffers: shared hit=12345 read=6789

    特点:

    • Nested Loop 的外层是大表(10000 行)
    • 内层索引扫描执行了 10000 次(loops=10000)
    • 总执行时间很长(12 秒+)
    • 大量磁盘 IO

    这种场景应该考虑改用 Hash Join,或者优化索引让内层查询更快。

    二、索引设计与优化策略

    索引是提升查询性能最直接有效的手段,但也不是越多越好。索引建对了事半功倍,建错了适得其反。这部分讲讲 KES 里常用的索引类型和设计原则。

    B-Tree 索引:最常用的选择

    B-Tree 是默认索引类型,适合等值查询、范围查询、排序等操作。

    — 创建普通索引
    CREATE INDEX idx_orders_user_id ON orders(user_id);

    — 创建复合索引
    CREATE INDEX idx_orders_user_status ON orders(user_id, status);

    — 创建唯一索引
    CREATE UNIQUE INDEX idx_users_email ON users(email);

    — 降序索引(适合 ORDER BY … DESC 的场景)
    CREATE INDEX idx_orders_created_desc ON orders(created_at DESC);

    复合索引的顺序很重要

    复合索引遵循"最左前缀"原则。比如 idx_orders_user_status(user_id, status) 这个索引:

    • ✅ WHERE user_id = 1001 – 能用上索引
    • ✅ WHERE user_id = 1001 AND status = 1 – 能用上索引
    • ❌ WHERE status = 1 – 用不上索引(跳过了第一列)
    • ✅ WHERE user_id = 1001 ORDER BY status – 能用上索引排序

    设计复合索引时,要把区分度高、常用于等值查询的列放前面。比如 (user_id, status) 比 (status, user_id) 更好,因为 user_id 的区分度更高。

    覆盖索引(Covering Index)

    如果索引包含了查询需要的所有字段,就可以实现 Index Only Scan,避免回表:

    — 查询只需要 user_id 和 status
    SELECT user_id, status FROM orders WHERE user_id = 1001;

    — 创建覆盖索引
    CREATE INDEX idx_orders_cover ON orders(user_id, status);

    — 执行计划会显示 Index Only Scan
    EXPLAIN SELECT user_id, status FROM orders WHERE user_id = 1001;

    覆盖索引能显著提升性能,特别是当表有很多列而查询只需要其中几列的时候。

    部分索引(Partial Index)

    如果只需要索引表中的一部分数据,可以用部分索引节省空间:

    — 只索引未完成的订单
    CREATE INDEX idx_orders_pending ON orders(user_id, created_at)
    WHERE status = 0;

    — 查询时自动用上这个索引
    SELECT * FROM orders
    WHERE user_id = 1001 AND status = 0
    ORDER BY created_at DESC;

    部分索引特别适合状态字段有明显倾斜的场景。比如订单表里 90% 都是已完成的,只索引未完成的那 10%,索引体积小很多,查询也快。

    GIN 索引:JSONB 和数组的利器

    前面文章提到过 JSONB,GIT 索引是查询 JSONB 和数组类型的最佳选择:

    — JSONB 字段的 GIN 索引
    CREATE INDEX idx_profiles_gin ON user_profiles USING GIN (profile);

    — 数组字段的 GIN 索引
    CREATE INDEX idx_tags_gin ON articles USING GIN (tags);

    — 查询会自动用上索引
    SELECT * FROM user_profiles
    WHERE profile @> '{"city": "北京"}';

    SELECT * FROM articles
    WHERE tags && ARRAY['技术', '数据库'];

    GIN 索引的缺点是写入性能比普通索引差,因为要维护倒排列表。如果表写入频繁,要权衡一下。

    GiST 索引:空间和全文检索

    GiST 索引适合几何数据类型和全文检索:

    — 地理位置查询
    CREATE INDEX idx_locations_gist ON locations USING GiST (coordinates);

    — 查询附近的点
    SELECT * FROM locations
    WHERE coordinates <> point(116.4, 39.9) < 0.01
    ORDER BY coordinates <> point(116.4, 39.9)
    LIMIT 10;

    — 全文检索
    CREATE INDEX idx_articles_tsvector ON articles USING GiST (to_tsvector('chinese', content));

    SELECT * FROM articles
    WHERE to_tsvector('chinese', content) @@ to_tsquery('数据库 & 性能');

    索引维护与监控

    索引不是建完就完了,需要定期维护和监控。

    检查索引使用情况

    — 查看哪些索引从来没被用过
    SELECT schemaname, relname, indexrelname, idx_scan
    FROM sys_stat_user_indexes
    WHERE idx_scan = 0
    ORDER BY pg_relation_size(indexrelid) DESC;

    长期不用的索引可以删掉,节省空间并提升写入性能。但要注意,有些索引可能在特定场景下才用到(比如月度报表),删除前要确认。

    检查索引膨胀

    频繁的 UPDATE 和 DELETE 会导致索引膨胀:

    — 检查索引大小与表大小的比例
    SELECT
    schemaname || '.' || relname AS table_name,
    indexrelname AS index_name,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
    pg_size_pretty(pg_relation_size(relid)) AS table_size,
    round(100.0 * pg_relation_size(indexrelid) / NULLIF(pg_relation_size(relid), 0), 1) AS ratio
    FROM sys_stat_user_indexes
    WHERE pg_relation_size(indexrelid) > 100 * 1024 * 1024 — 大于 100MB
    ORDER BY pg_relation_size(indexrelid) DESC;

    如果索引大小超过表大小的 50%,可能需要重建索引:

    — 重建索引(会锁表,建议在低峰期执行)
    REINDEX INDEX idx_orders_user_id;

    — 或者并发重建(不锁表,但耗时更长)
    REINDEX INDEX CONCURRENTLY idx_orders_user_id;

    统计信息更新

    优化器依赖统计信息来做决策,统计信息不准会导致选错执行计划:

    — 手动更新统计信息
    ANALYZE orders;

    — 更新所有表
    ANALYZE;

    — 查看上次更新时间
    SELECT relname, last_analyze, last_autoanalyze
    FROM sys_stat_user_tables
    ORDER BY last_analyze DESC;

    autovacuum 会自动执行 ANALYZE,但对于写入量特别大的表,可能需要手动增加频率。

    三、慢查询定位与分析

    知道了怎么看执行计划和索引,下一步就是找出系统中的慢查询并优化它们。

    开启慢查询日志

    最简单的方式是通过日志记录慢查询:

    — kingbase.conf 配置
    log_min_duration_statement = 1000 — 记录超过 1 秒的查询
    log_statement = 'none' — 不记录所有语句(性能影响大)
    log_duration = off — 不记录每条语句的耗时

    — 重新加载配置
    sys_ctl D /data/kingbase/data reload

    日志文件在数据目录的 sys_log 子目录下:

    # 查找慢查询
    grep "duration:" /data/kingbase/data/sys_log/kingbase-2026-06-05.log | sort -t':' -k2 -rn | head -20

    这种方式简单直接,但日志量大的时候分析起来费劲。更好的办法是用 sys_stat_statements 扩展。

    使用 sys_stat_statements

    sys_stat_statements 会累积统计所有执行过的 SQL,包括调用次数、总耗时、平均耗时等:

    — 启用扩展
    CREATE EXTENSION IF NOT EXISTS sys_stat_statements;

    — 查询最慢的 TOP 20 SQL
    SELECT query, calls, total_time, mean_time, rows
    FROM sys_stat_statements
    ORDER BY mean_time DESC
    LIMIT 20;

    — 查询总耗时最高的 TOP 20 SQL
    SELECT query, calls, total_time, mean_time, rows
    FROM sys_stat_statements
    ORDER BY total_time DESC
    LIMIT 20;

    — 重置统计数据(一般在性能测试前后执行)
    SELECT sys_stat_statements_reset();

    mean_time 高说明单次执行慢,total_time 高说明调用频繁。优先优化 total_time 高的 SQL,对整体性能提升更大。

    实时监控活跃会话

    对于正在发生的性能问题,可以实时查看活跃会话:

    — 当前正在执行的查询
    SELECT pid, usename, application_name, client_addr,
    now() query_start AS duration, state, wait_event_type, wait_event, query
    FROM sys_stat_activity
    WHERE state = 'active'
    AND query NOT LIKE '%sys_stat_activity%'
    ORDER BY duration DESC;

    — 等待锁的会话
    SELECT pid, usename, now() query_start AS duration, query
    FROM sys_stat_activity
    WHERE wait_event_type = 'Lock';

    — 长时间未提交的事务
    SELECT pid, usename, now() xact_start AS xact_duration, query
    FROM sys_stat_activity
    WHERE state = 'idle in transaction'
    AND now() xact_start > INTERVAL '5 minutes';

    把这些查询做成定时任务,每分钟采集一次,存入历史表,就能形成完整的性能监控体系。

    四、查询重写技巧

    有时候光靠加索引解决不了问题,需要改写 SQL。以下是一些实用的重写技巧。

    避免 SELECT *

    — 不好的写法
    SELECT * FROM orders WHERE user_id = 1001;

    — 好的写法
    SELECT id, order_no, amount, created_at
    FROM orders WHERE user_id = 1001;

    SELECT * 会返回所有列,增加网络传输开销,而且无法使用覆盖索引。只查需要的字段,性能会更好。

    优化分页查询

    传统的 LIMIT/OFFSET 在大偏移量时性能很差:

    — 不好的写法:偏移量越大越慢
    SELECT * FROM orders
    ORDER BY created_at DESC
    LIMIT 20 OFFSET 100000;

    — 改进方案1:基于游标的分页
    SELECT * FROM orders
    WHERE created_at < :last_created_at
    OR (created_at = :last_created_at AND id < :last_id)
    ORDER BY created_at DESC, id DESC
    LIMIT 20;

    — 改进方案2:延迟关联
    SELECT o.* FROM orders o
    INNER JOIN (
    SELECT id FROM orders
    ORDER BY created_at DESC
    LIMIT 20 OFFSET 100000
    ) t ON o.id = t.id;

    延迟关联的思路是先在索引上完成分页(只查主键),然后再回表查完整数据。这样避免了大量的回表操作。

    用 EXISTS 替代 IN

    — 不好的写法:子查询会返回所有结果
    SELECT * FROM users
    WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);

    — 好的写法:EXISTS 找到第一个匹配就返回
    SELECT * FROM users u
    WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.user_id = u.id AND o.amount > 1000
    );

    当子查询结果集很大时,EXISTS 的性能优势很明显。

    避免在索引列上使用函数

    — 不好的写法:函数导致索引失效
    SELECT * FROM orders
    WHERE DATE(created_at) = '2026-06-05';

    — 好的写法:用范围查询
    SELECT * FROM orders
    WHERE created_at >= '2026-06-05'
    AND created_at < '2026-06-06';

    — 另一个例子
    — 不好的写法
    SELECT * FROM users
    WHERE UPPER(username) = 'ADMIN';

    — 好的写法:创建函数索引
    CREATE INDEX idx_users_username_upper ON users(UPPER(username));

    — 或者在应用层处理好大小写
    SELECT * FROM users
    WHERE username = 'admin';

    在索引列上使用函数会让优化器无法使用索引,除非创建对应的函数索引。

    合理使用 UNION vs UNION ALL

    — UNION 会去重,需要额外的排序操作
    SELECT user_id FROM orders_2025
    UNION
    SELECT user_id FROM orders_2026;

    — 如果确定没有重复,用 UNION ALL 更快
    SELECT user_id FROM orders_2025
    UNION ALL
    SELECT user_id FROM orders_2026;

    UNION ALL 不做去重,性能比 UNION 好。只有在确实需要去重时才用 UNION。

    批量操作代替逐条处理

    — 不好的写法:在应用层循环插入
    for order in orders:
    INSERT INTO orders VALUES (...);

    — 好的写法:批量插入
    INSERT INTO orders (user_id, amount, created_at) VALUES
    (1, 100, '2026-06-05'),
    (2, 200, '2026-06-05'),
    (3, 300, '2026-06-05');

    — 或者用 COPY 命令(最快)
    COPY orders FROM '/data/orders.csv' WITH (FORMAT csv);

    批量操作能大幅减少网络往返和事务开销。百万级数据导入,COPY 比逐条 INSERT 快几十倍。

    五、参数调优实战

    KES 有很多配置参数会影响性能,但不是所有参数都要改。这里挑几个最关键的说。

    内存相关参数

    shared_buffers

    共享缓冲区大小,建议设为物理内存的 25%:

    — 32GB 内存的服务器
    shared_buffers = 8GB

    这个参数改完要重启。设太大反而不好,因为操作系统也需要缓存。

    work_mem

    每个排序或哈希操作使用的内存:

    — 根据并发情况调整
    work_mem = 64MB

    注意 work_mem 是每个操作各自分配的。如果有 100 个并发查询都在做排序,可能会用到 6.4GB 内存。所以并发高的话不要设太猛。

    maintenance_work_mem

    VACUUM、CREATE INDEX 等维护操作使用的内存:

    maintenance_work_mem = 512MB

    这个可以适当设大一点,加快维护操作的速度。

    effective_cache_size

    优化器假设的可用缓存大小(包括 shared_buffers 和操作系统缓存):

    — 一般设为物理内存的 50%-75%
    effective_cache_size = 24GB — 32GB 内存的服务器

    这个参数不影响实际内存分配,只是给优化器提供参考。设得太小会导致优化器倾向于选择 Seq Scan。

    并发相关参数

    max_connections

    最大连接数:

    max_connections = 200

    不要盲目加大这个参数。每个连接都会占用内存,连接太多会导致内存不足。更好的做法是在应用层用连接池,控制并发连接数。

    连接池推荐

    推荐使用 PgBouncer 或 Kingbase Proxy 做连接池:

    # pgbouncer.ini 配置
    [databases]
    testdb = host=192.168.1.100 port=54321 dbname=testdb

    [pgbouncer]
    listen_port = 6432
    pool_mode = transaction
    max_client_conn = 1000
    default_pool_size = 20

    应用连 PgBouncer,PgBouncer 复用后端连接,能有效控制并发。

    WAL 和检查点参数

    wal_buffers

    WAL 缓冲区大小:

    wal_buffers = 64MB

    写入频繁的系统可以适当加大这个值。

    checkpoint_completion_target

    检查点完成目标:

    checkpoint_completion_target = 0.9

    这个值越大,检查点分散得越均匀,IO 波动越小。默认 0.5 意味着检查点在 50% 的时间内完成,改成 0.9 意味着在 90% 的时间内完成,更平滑。

    min_wal_size / max_wal_size

    WAL 文件大小范围:

    min_wal_size = 1GB
    max_wal_size = 4GB

    写入量大的系统可以加大这些值,减少 WAL 文件的频繁切换。

    查询优化器参数

    random_page_cost

    随机 IO 的成本估算:

    — SSD 环境下可以降低这个值
    random_page_cost = 1.1

    — 传统机械硬盘
    random_page_cost = 4.0

    SSD 的随机读性能比机械硬盘好很多,降低这个值可以让优化器更倾向于使用索引扫描。

    effective_io_concurrency

    并发 IO 数量:

    — SSD 可以设高一些
    effective_io_concurrency = 200

    — 机械硬盘
    effective_io_concurrency = 2

    这个参数影响预读和并行 IO 的行为。

    六、实战案例分析

    最后分享几个真实的性能优化案例。

    案例一:电商订单查询优化

    问题描述

    某电商平台的订单列表查询很慢,高峰期响应时间超过 5 秒:

    SELECT o.id, o.order_no, o.amount, o.status,
    u.username, u.phone
    FROM orders o
    JOIN users u ON o.user_id = u.id
    WHERE o.user_id = 1001
    AND o.status IN (1, 2, 3)
    ORDER BY o.created_at DESC
    LIMIT 20 OFFSET 0;

    排查过程

  • 查看执行计划:
  • Sort (cost=1234.56..1234.67 rows=100 width=128)
    Sort Key: o.created_at DESC
    -> Nested Loop (cost=0.86..1230.45 rows=100 width=128)
    -> Index Scan using idx_orders_user_id on orders o
    Index Cond: (user_id = 1001)
    Filter: (status IN (1, 2, 3))
    -> Index Scan using idx_users_pkey on users u
    Index Cond: (id = o.user_id)

    发现虽然用了索引,但还是做了 Sort 操作,而且 Nested Loop 的效率不高。

  • 检查索引:
  • SELECT indexname, indexdef
    FROM sys_indexes
    WHERE tablename = 'orders';

    — 发现只有 idx_orders_user_id(user_id)

    优化方案

    创建复合索引,覆盖查询条件和排序字段:

    CREATE INDEX idx_orders_user_status_created
    ON orders(user_id, status, created_at DESC);

    同时改写 SQL,利用覆盖索引:

    — 先查主键,再回表
    SELECT o.id, o.order_no, o.amount, o.status,
    u.username, u.phone
    FROM (
    SELECT id, user_id, order_no, amount, status, created_at
    FROM orders
    WHERE user_id = 1001
    AND status IN (1, 2, 3)
    ORDER BY created_at DESC
    LIMIT 20
    ) o
    JOIN users u ON o.user_id = u.id;

    优化效果

    • 优化前:平均响应时间 5.2 秒
    • 优化后:平均响应时间 0.08 秒
    • 提升:65 倍

    关键在于复合索引消除了 Sort 操作,延迟关联减少了回表次数。

    案例二:报表统计查询优化

    问题描述

    一个月度销售报表查询要跑 30 多秒:

    SELECT
    date_trunc('month', o.created_at) AS month,
    p.category,
    count(*) AS order_count,
    sum(o.amount) AS total_amount,
    avg(o.amount) AS avg_amount
    FROM orders o
    JOIN order_items oi ON o.id = oi.order_id
    JOIN products p ON oi.product_id = p.id
    WHERE o.created_at >= '2026-01-01'
    AND o.created_at < '2027-01-01'
    GROUP BY 1, 2
    ORDER BY 1, 2;

    排查过程

    执行计划显示用了 Hash Join,但因为数据量大(一年约 500 万订单),哈希表的构建和探测都很慢。而且 GROUP BY 需要做大量的聚合计算。

    优化方案

    方案一:创建物化视图

    CREATE MATERIALIZED VIEW mv_monthly_sales AS
    SELECT
    date_trunc('month', o.created_at) AS month,
    p.category,
    count(*) AS order_count,
    sum(o.amount) AS total_amount,
    avg(o.amount) AS avg_amount
    FROM orders o
    JOIN order_items oi ON o.id = oi.order_id
    JOIN products p ON oi.product_id = p.id
    GROUP BY 1, 2;

    CREATE INDEX idx_mv_monthly_sales_month ON mv_monthly_sales(month);

    — 每天凌晨刷新
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_sales;

    方案二:预聚合表

    — 创建日粒度聚合表
    CREATE TABLE daily_sales_summary (
    stat_date DATE,
    category VARCHAR(50),
    order_count INT,
    total_amount NUMERIC(15,2),
    avg_amount NUMERIC(10,2),
    PRIMARY KEY (stat_date, category)
    );

    — 每天定时任务汇总前一天的数据
    INSERT INTO daily_sales_summary
    SELECT
    date_trunc('day', o.created_at)::date AS stat_date,
    p.category,
    count(*),
    sum(o.amount),
    avg(o.amount)
    FROM orders o
    JOIN order_items oi ON o.id = oi.order_id
    JOIN products p ON oi.product_id = p.id
    WHERE date_trunc('day', o.created_at) = CURRENT_DATE 1
    GROUP BY 1, 2
    ON CONFLICT (stat_date, category)
    DO UPDATE SET
    order_count = EXCLUDED.order_count,
    total_amount = EXCLUDED.total_amount,
    avg_amount = EXCLUDED.avg_amount;

    — 查询时从聚合表查,按月汇总
    SELECT
    date_trunc('month', stat_date) AS month,
    category,
    sum(order_count) AS order_count,
    sum(total_amount) AS total_amount,
    avg(avg_amount) AS avg_amount
    FROM daily_sales_summary
    WHERE stat_date >= '2026-01-01'
    AND stat_date < '2027-01-01'
    GROUP BY 1, 2
    ORDER BY 1, 2;

    优化效果

    • 物化视图方案:查询时间从 30 秒降到 0.2 秒
    • 预聚合表方案:查询时间从 30 秒降到 0.5 秒,且支持实时更新

    物化视图简单但实时性差,预聚合表复杂但灵活。根据业务需求选择。

    案例三:死锁问题排查

    问题描述

    某个库存扣减功能频繁报死锁错误:

    ERROR: deadlock detected
    DETAIL: Process 12345 waits for ShareLock on transaction 67890; blocked by process 54321.

    排查过程

  • 查看死锁日志,发现两个事务互相等待:

    • 事务 A:先更新商品表,再更新库存表
    • 事务 B:先更新库存表,再更新商品表
  • 检查代码,发现两个业务的更新顺序不一致。

  • 解决方案

    统一更新顺序,所有业务都按"先商品后库存"的顺序操作:

    @Transactional
    public void deductStock(Long productId, int quantity) {
    // 第一步:更新商品信息(加锁)
    productMapper.update(productId);

    // 第二步:更新库存
    stockMapper.deduct(productId, quantity);
    }

    另外,对于高并发场景,可以用 SKIP LOCKED 避免锁等待:

    — 查询可用的库存记录,跳过已被锁定的
    SELECT * FROM stock
    WHERE product_id = 1001
    AND quantity > 0
    FOR UPDATE SKIP LOCKED
    LIMIT 1;

    预防措施

  • 制定开发规范,明确多表更新的固定顺序
  • 缩短事务持有锁的时间,不要在事务中做耗时操作
  • 设置锁超时:
  • — kingbase.conf
    lock_timeout = 5000 — 5 秒
    deadlock_timeout = 1000 — 1 秒

    写在最后

    性能调优是个系统工程,需要从多个维度入手:执行计划分析帮你找到瓶颈,索引优化提供加速手段,查询重写改善逻辑效率,参数调优优化资源配置。这四个环节缺一不可。

    KingbaseES 的性能优化思路跟其他关系型数据库大同小异,关键是要掌握方法论。遇到问题不要慌,按照"监控发现 → 定位瓶颈 → 分析原因 → 实施优化 → 验证效果"的流程一步步来,大部分性能问题都能解决。

    最后提醒一点:不要过早优化。先把功能做对,再做性能测试,找出真正的瓶颈再优化。很多时候你以为的瓶颈其实不是瓶颈,盲目优化反而浪费时间。用数据说话,用执行计划说话,这才是性能调优的正确姿势。

    希望这篇文章能帮你建立起系统的性能优化思维。遇到问题多看看执行计划,多试试不同的方案,慢慢就能写出高效的 SQL 了。共勉。

    赞(0)
    未经允许不得转载:171主机测评 » KES 性能调优实战:执行计划、索引优化与查询重写完全指南
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址