6000条数据执行时间9s??

前言

前两天其他小组负责的项目出现数据库CPU飙高的问题,发现了有很多慢SQL,联系了数据库(国产库)厂家,全部是通过建索引来解决的问题。

最牛B的是有个表只有6000多条数据,单表查询竟然需要好9秒钟!就算全表扫描也不可能耗时这么多啊!联系厂家,厂家的方案依然是建索引🙈

除了无脑的建索引还能干嘛❓每次联系厂家SQL查询缓慢就是建索引,真的服了。还有就是我们的国产数据库是pgsql改的。

6k条数据单表查询为什么慢

正常来说6K条数据,单表查询不管怎么操作应该都很快。除非一次性查询全部数据(数据大IO传输有限制),当然实际情况我们也不是查询全部数据,查询结果的数据大概在50条数数据左右。 慢SQL:

sql 复制代码
SELECT * FROM "activity" WHERE
"del" = 0 AND "current_dept_id" = 27 AND ("status" = '1'OR "status" = '6');

猜想一: 表字段是否存有大字段(比如图片数据等)

因为我们服务的磁盘性能很低,如果数据总量太大,通过全盘扫描,那么IO的时间肯定长。

也看了数据库的字段,没有存在一些特别大的数据,最大的字段就是存的文本。

sql 复制代码
-- 统计text类型字段的大小
SELECT
    MAX(LENGTH(desc))                AS max_chars,
    AVG(COALESCE(LENGTH(desc), 0))   AS avg_chars,
    MAX(OCTET_LENGTH(desc))          AS max_bytes,
    AVG(COALESCE(OCTET_LENGTH(desc), 0)) AS avg_bytes
FROM "xxx".activity;

📢 执行结果:平均大小2kB、最大字段约8MB。平均数据不大,所以数据总量也不会很大

max_chars avg_chars max_bytes avg_bytes
8440062 2094.0095359186268277 8440062 2202.6894469167196440

这时候我们就查询一下数据的总的大小。

查询表数据总体大小:1856MB

sql 复制代码
-- 查询数据总大小: 输出结果 1872MB
SELECT pg_size_pretty(pg_table_size('activity')) AS data_plus_toast_size;

为什么6千条数能占用这么多内存?难道有大字段?分别统计了字段类型为text的字段的平均大小也就2KB。 那就可能是数据,在频繁的使用过程中产生了很多垃圾。

复制表数据到copy表中:重新统计大小,发现数据大小只有27兆数据

sql 复制代码
-- 迁移数据 (_copy 表未创建任何索引)
insert into activity_copy select * from activity;
SELECT pg_size_pretty(pg_table_size('activity_copy')) AS data_plus_toast_size;
输出结果27MB

同样的数据量,频繁更新的表所占的磁盘远大于了 同样数据未频繁更新的表

猜想二:数据被频繁修改产生大量的垃圾,导致IO效率低?

在上面一步已经确定了,表占用的磁盘大小已经是数据本身大小的800倍了。大概率就是这个问题引起的了

1. 哪些情况会导数据膨胀

deepSeek 了一下,主要还是下面pgSQL 的mvcc机制 可能导致数据膨胀

  • 每次 UPDATEDELETE 不会立即物理删除旧数据,而是产生一个死元组(dead tuple)
  • 这些死元组占用的空间暂时不会被回收,表文件只会增长,不会自动收缩。
  • 只有 VACUUM 会标记空间为可复用,但通常不会把空间归还给操作系统(文件大小不变)。
  • 如果你长期没有执行 VACUUM FULL(或 CLUSTERpg_repack),表文件中会积累大量死元组和空闲空间,导致实际有效数据只占很小一部分。

2.查询死元组的情况

sql 复制代码
SELECT 
    relname AS table_name,
    last_vacuum,           -- 最后一次手动 VACUUM 的时间
    last_autovacuum,       -- 最后一次自动 VACUUM 的时间
    vacuum_count,          -- 手动 VACUUM 的总次数
    autovacuum_count,      -- 自动 VACUUM 的总次数
    n_dead_tup             -- 当前估算的死元组数量
FROM 
    pg_stat_user_tables
WHERE 
    relname = 'activity';

执行结果如下:

table_name last_vacuum last_autovacuum vacuum_count autovacuum_count n_dead_tup
activity 2026-04-29 01:02:18.697389+08 0 14867 1298

只有1000多条死元组。1000多条数据也只有几兆的数据量,和1.8G的数据量还是匹配不上

3.被vacuum整理的死元组空间去哪儿了?

既然死元组的数据量不大,清理机制正常运行。本身这个表也是更新非常频繁的一个表了。如果清理了四元组但是空间未被回收呢? 如果清理之后的死元组空间没有被回收,磁盘的空间占用达到1.8G也是能说通的。

DeepSeek : autovacuum 命令背后的操作逻辑:

autovacuum 本质是后台自动调度的 VACUUM + ANALYZE ,由 launcher 与 worker 进程协作完成,核心是清理死元组、冻结事务 ID、更新可见性映射与统计信息,全程不锁表、不归还磁盘空间给 OS。

如果被清理的死元组空间没有被回收,一致空闲的话,那么就可以确定这1.8G的数据就是空间没有释放造成的。

DeepSeek 查看空闲空间是多少?

  • total_pages:表一共有多少个数据页
  • total_free_bytes所有页面空闲空间的总和
  • avg_free_percent平均每个页面的空闲空间百分比
sql 复制代码
WITH freespace AS (
    SELECT * FROM sys_freespace('station_activity.act_activity')
)
SELECT 
    COUNT(*) AS total_pages,
    SUM(avail) AS total_free_bytes,
    pg_size_pretty(SUM(avail)) AS total_free_pretty,
    ROUND(AVG(avail) / 8192 * 100, 2) AS avg_free_percent
FROM freespace;

执行结果

total_pages total_free_bytes total_free_pretty avg_free_percent
237554 1910823232 1822 MB 98.19

🔈 到这儿的话基本可以确定是因为表的死元组数据被标记复用之后,但是没有被利用起来!

结论:数据膨胀800倍导致查询缓慢

通过我们上面的排查得出,这个表占用的磁盘是真实数据的800倍左右。这个应该就是导致我们全表扫描查询缓慢的元凶。

验证一:创建新表

  1. 在生产环境同一个模式下面建copy表:在同样数据的 新表(activity_copy)下面,执行同样的sql,执行计划也是全表扫描 几十毫秒就执行完成了。(修改了一下条件,执行时间又来到了几秒钟😭)
  2. 在原来已经创建索引的表(activity),关闭临时会话的索引,并且使用hint 语法指定使用全表扫描,执行第一次的时候 时间是能达到 9s(有时候能复现),同一个会话重新执行 突然发现执行时间就正常了几十毫秒就返回了?????(缓存😭????)
  3. 新的模式下面创建相同的表和相同的数据,执行SQL 执行时间都在1.5秒内

结论:

执行时间可能受到其他因素的影响也比较大,比如数据库的缓存,或者说网络IO(把select * 改成 select count(*)尽量减少网络IO的影响),或者还有其他因素。

🔈执行时间如下:原表全表> 同模式新建的表> 其他模式的表;也不能说一定就是 表膨胀带来的影响吧。

全表扫描会去扫描1.8G的非数据空间吗

执行了很多次SQL 有一个规律就是,打开新的会话 可能慢,后面再执行这条SQL就变得非常快了。有可能是缓存的影响,所以单纯的看SQL返回的时间还是有一定的局限性。

🤶那么我们就看看能不能直接看SQL扫描磁盘的情况,如果SQL扫描了磁盘1.8G的数据,那么这个肯定是影响SQL查询非常重要的一个环节

DeepSeek:执行全表扫描,回去读取1.8G的数据吗?
情况一:优化器决定进行"全表扫描",但数据已在内存

这是优化器"无奈"的选择(例如表没有合适索引),但其实际读取量也分情况:

  • 数据在 shared_buffers 中是"热的" :之前执行过该查询,数据加载后仍留在这里。第二次执行时,逻辑读(Logical Read) 会命中,直接从内存返回结果,真正的磁盘 I/O 几乎为 0

  • 数据在操作系统 Page Cache 中是"温的"

    1. shared_buffers 没有,但被操作系统 Page Cache 缓存了
    2. KingbaseES(不包括Oracle直接路径读)将数据读入 shared_buffers
    3. 这次仍然会产生磁盘 I/O(需要从 OS 缓存复制到 DB 缓存),但比真实读物理磁盘快。这就是"第一次慢,第二次快"的原因。
  • 并发预热 :另一个会话读取相同数据后,您当前会话的扫描也会受益于已加载到 shared_buffers 的页面

情况二:真的是"冷"数据,必须从磁盘物理读取

如果数据在各级缓存中都未命中,那就是最坏情况,数据库必须发起真正的物理读heap_blks_read 增加),逐个将数据页(通常为 8KB)从磁盘加载到 shared_buffers。扫描完整个表后,相比全表 1.8 GB 的大小,这个 I/O 开销可能比较明显。

验证二:查看sql 读取的磁盘数据大小

EXPLAIN (ANALYZE, BUFFERS) 能够看到扫描磁盘的情况

sql 复制代码
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM activity WHERE
"del" = false AND "current_dept_id" = 27 AND ("status" = '1'OR "status" = '6');;

执行结果:

QUERY PLAN
Aggregate (cost=226779.52..226779.53 rows=1 width=8) (actual time=10930.243..10930.245 rows=1 loops=1)
Buffers: shared hit=1243 read=225404 written=35428
-> Seq Scan on activity (cost=0.00..226779.33 rows=73 width=0) (actual time=39.342..10930.184 rows=52 loops=1)
Filter: ((NOT del) AND (current_dept_id = 27) AND (((status)::text = '1'::text) OR ((status)::text = '6'::text)))
Rows Removed by Filter: 6241
Buffers: shared hit=1243 read=225404 written=35428
Planning Time: 13.351 ms
Execution Time: 10930.349 ms

✅结果分析:

  • 总页面数1243 (hit) + 225404 (read) = 226647
  • 每页大小:KingbaseES 默认 8 KB
  • 表总大小226647 × 8 KB ≈ 1.73 GB(与 pg_table_size 显示的 1.8 GB 基本吻合)
  • 从磁盘读取(物理读read)225404 × 8 KB ≈ 1.7 GB
  • 从共享缓冲区命中(逻辑读hit)1243 × 8 KB ≈ 9.7MB
  • 过滤移除行数: 6241
  • 实际返回行数: 52
  • 计划执行时间:13ms
  • 实际执行时间:10930ms

🎆到这儿其实已经完全能够确认,这1.8个G的空闲空间肯定是拖慢SQL的重要原因之一

解决方案

这时候我们就不考虑索引的方式了,这个可以作为临时紧急的处理方案。看看deepSeek 的解决方案吧。 (或者新建一个表)

清理方案 🟢 VACUUM (日常维护) 🟡 VACUUM FULL (重型清理) 🔵 pg_repack / sys_squeeze (在线清理)
锁机制 轻量级锁,不阻塞读写 排他锁 ,会完全阻塞对该表的所有操作 非阻塞 ,仅在开始和结束时短暂持有排他锁
空间释放 标记为可重用,不释放 给操作系统 彻底释放 磁盘空间(表文件缩小) 彻底释放 磁盘空间,效果相当于VACUUM FULL
索引 优化索引空闲空间复用 索引会重建,但会被加锁 索引会被无锁重建 ,性能更佳
适用场景 日常防患于未然 停机维护窗口(如凌晨业务低峰期) 7x24小时高可用生产环境

使用VACUUM已经不能解决我们的问题了,autovacuum 是没有问题的。

vacuum full 执行前后对比

因为要锁表,所以这个不能白天执行。等今天晚上执行后再补充。

为什么表会膨胀如此之大呢?

数据库的自动清理机制是没有问题的,死元组达到表的20%,就会执行auto vacuum,死元组就会被标记可复用。新的SQL又去更新,实际上执行的是 insert ,为什么这个insert 没有覆盖之前的 被标记的死元组空间呢?

目前初步猜测可能是,表中有text 字段,导致数据大小差别比较大,难以复用之前标记的空间。

总结

初步的结论(猜测),就是表的单条数据大小差异大,存在几条大数据行,最大的行超过了8MB,是一个富文本字段,里面存了图片的base64数据,加上定时任务的全表更新,导致死元组空间不能被复用,数据表一直膨胀。

理想情况下,表的膨胀大小在20%左右,因为死元组的数量超过20%(可配置),autovacuum 会自动执行,把这些死元组标记可复用,新的更新操作进来会把这些标记的空间利用起来。

无限膨胀的根本原因,目前还在探索中,后面也会更新这篇文章。先下班了🏃🏾‍♂️

EXPLAIN (ANALYZE, BUFFERS) 命令很重要,能直接看到扫描磁盘 和 扫描缓存的大小,能帮我们少走很多弯路

相关推荐
一只鹿鹿鹿几秒前
信息化项目管理规范(参考Word文件)
java·大数据·运维·开发语言·数据库
这个DBA有点耶3 分钟前
多模融合数据库深度解析:关系、文档、向量、图如何统一?
数据库·自然语言处理·aigc·dba·改行学it
ruxingli18 分钟前
Golang iota详解
开发语言·后端·golang
anew___20 分钟前
《数据库原理》精要解读(三)—— SQL:与数据库对话的艺术
数据库·sql·oracle
KaiwuDB20 分钟前
KWDB 3.2.0 版本发布,数据管理查询增强,安装部署体验全面升级
数据库
暴躁小师兄数据学院27 分钟前
【AI大数据工程师特训笔记】第10讲:数据库用户、权限管理、数据库约束
大数据·数据库·笔记·sql·postgresql
前端环境观察室33 分钟前
别只看 task success:AI Agent 浏览器自动化真正要补的是环境证据链
前端·后端
凤山老林38 分钟前
DDD(领域驱动设计)在复杂业务系统中的落地指南
java·开发语言·数据库·ddd·领域驱动
浩风祭月39 分钟前
把 Docker 镜像从 2GB 瘦身到 180MB,AI 帮我找到了那些看不见的“脂肪”
后端·ai编程
凯瑟琳.奥古斯特1 小时前
子查询原理与实战案例解析
开发语言·数据库·职场和发展·数据库开发