数据库性能问题排查与解决完整记录
问题概述
本文档记录了在生产环境中遇到的一个关键数据库性能问题:
生产环境查询性能问题:Category.all 等简单查询首次执行异常缓慢(10+ 秒)
问题描述
在生产环境中,即使是简单的查询如 Category.all 或 Category.find,首次执行都需要 10+ 秒,而数据量只有十几条记录。
现象:
- 简单查询
Category.all需要 10+ 秒才能返回结果 - 数据量很小,只有十几条记录
- 问题出现在生产环境,不是开发环境的代码加载问题
问题排查过程
第一步:排除常见原因
1.1 表锁检查
查询代码:
sql
SELECT
l.pid,
l.mode,
l.granted,
a.usename,
a.query
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE l.relation = 'categories'::regclass;
代码分析:
pg_locks视图显示当前数据库中的所有锁relation = 'categories'::regclass过滤出针对 categories 表的锁mode字段显示锁的类型(如 ACCESS SHARE, ROW SHARE 等)granted字段显示锁是否已获得(true)或正在等待(false)
结果:未发现表锁,排除了锁争用问题。
1.2 僵尸事务检查
查询代码:
sql
SELECT
pid,
usename,
age(now(), xact_start) AS duration,
query,
state
FROM pg_stat_activity
WHERE state != 'idle'
AND now() - query_start > interval '1 minute'
ORDER BY duration DESC;
代码分析:
pg_stat_activity显示所有数据库会话的活动状态state != 'idle'过滤出非空闲状态的会话now() - query_start > interval '1 minute'找出运行超过1分钟的查询age(now(), xact_start)计算事务持续时间
结果:未发现长时间运行的事务,排除了僵尸事务问题。
1.3 表膨胀检查
查询代码:
sql
ANALYZE VERBOSE categories;
代码分析:
ANALYZE命令收集表的统计信息VERBOSE参数提供详细的输出信息- 输出包括表的大小、行数、死元组数量等
结果:
INFO: analyzing "public.categories"
INFO: "categories": scanned 17 of 17 pages, containing 37 live rows and 74 dead rows; 37 rows in sample, 37 estimated total rows
分析:
- 表有 17 个数据页,37 行有效数据,74 行死数据
- 死数据比例较高,但不足以解释 10+ 秒的查询时间
- 问题可能不在 categories 表本身
第二步:深入诊断
2.1 使用 EXPLAIN ANALYZE 进行性能分析
查询代码:
sql
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM categories;
代码分析:
EXPLAIN ANALYZE实际执行查询并显示详细的执行计划BUFFERS参数显示缓存和磁盘 I/O 的详细信息- 这是定位性能问题的核心工具
结果:
json
{
"QUERY PLAN": "Seq Scan on categories (cost=0.00..16.37 rows=37 width=875) (actual time=0.016..0.116 rows=37 loops=1)",
"Buffers: shared read=16",
"Planning:",
" Buffers: shared hit=18280 read=1804451 written=9",
"Planning Time: 10126.752 ms",
"Execution Time: 0.157 ms"
}
关键发现:
- Execution Time: 0.157 ms(查询执行本身很快)
- Planning Time: 10126.752 ms(查询规划耗时 10+ 秒)
- Planning Buffers: shared read=1804451(规划阶段读取了 180 万个数据块)
分析 :
问题不在查询执行,而在查询规划阶段。规划器读取了 180 万个数据块,这绝对不正常。
第三步:定位根本原因
3.1 查询系统目录膨胀情况
查询代码:
sql
SELECT
schemaname || '.' || relname AS table_name,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS total_size,
n_live_tup,
n_dead_tup,
CASE WHEN n_live_tup > 0 THEN round(n_dead_tup::numeric / n_live_tup::numeric, 2) ELSE 0 END AS dead_tup_ratio,
last_autovacuum,
last_autoanalyze
FROM
pg_stat_all_tables
WHERE
schemaname = 'pg_catalog' AND n_dead_tup > 1000
ORDER BY
n_dead_tup DESC
LIMIT 20;
代码分析:
pg_stat_all_tables包含所有表的统计信息(包括系统目录)schemaname = 'pg_catalog'过滤出系统目录表n_dead_tup > 1000找出有显著死元组的表pg_size_pretty()格式化表大小显示dead_tup_ratio计算死元组与活元组的比例
结果:
json
{
"table_name": "pg_catalog.pg_statistic",
"total_size": "1740 MB",
"n_live_tup": 3946,
"n_dead_tup": 2990647,
"dead_tup_ratio": 757.89,
"last_autovacuum": "2025-08-29T02:37:07.261+00:00",
"last_autoanalyze": null
}
关键发现:
pg_catalog.pg_statistic: 总大小 1740 MB,有效数据 3946 行,死数据 2,990,647 行- 死数据比例: 757.89(死数据是有效数据的 750 多倍)
分析 :
pg_statistic 是 PostgreSQL 查询规划器的核心表,存储所有表的统计信息。它的严重膨胀导致规划器在查找统计信息时需要进行巨大的磁盘扫描。
第四步:尝试修复
4.1 标准 VACUUM
查询代码:
sql
VACUUM (VERBOSE, ANALYZE) pg_catalog.pg_statistic;
代码分析:
VACUUM回收死元组,释放空间VERBOSE提供详细输出ANALYZE更新统计信息
结果:
INFO: vacuuming "pg_catalog.pg_statistic"
INFO: "pg_statistic": found 0 removable, 2999613 nonremovable row versions in 197290 pages
DETAIL: 2995479 dead row versions cannot be removed yet.
分析 :
VACUUM 找到了死元组,但无法删除,说明存在"时间锚"阻止清理。
4.2 VACUUM FULL
查询代码:
sql
VACUUM FULL VERBOSE pg_catalog.pg_statistic;
代码分析:
VACUUM FULL重建表文件,彻底清理死元组- 需要 ACCESS EXCLUSIVE 锁,会阻塞所有操作
结果:操作被阻塞,无法完成。
4.3 REINDEX
查询代码:
sql
REINDEX TABLE categories;
代码分析:
REINDEX重建表的索引- 可能解决索引碎片问题
结果:无效,问题不在用户表索引。
第五步:发现真正原因
5.1 检查活动事务
查询代码:
sql
SELECT
pid,
usename,
age(now(), xact_start) AS transaction_age,
state,
query
FROM
pg_stat_activity
WHERE
xact_start IS NOT NULL
ORDER BY
xact_start ASC
LIMIT 10;
代码分析:
xact_start IS NOT NULL过滤出有活动事务的会话ORDER BY xact_start ASC按事务开始时间排序,最早的在前面- 寻找长时间运行的事务
结果:
json
{
"pid": 3733,
"usename": null,
"transaction_age": "PT17.833013S",
"state": "active",
"query": "autovacuum: VACUUM pg_catalog.pg_statistic"
}
关键发现:
- PID 3733 :
autovacuum: VACUUM pg_catalog.pg_statistic进程被卡住 - 状态 :
active,事务持续了 17.8 秒
分析 :
autovacuum 进程正在尝试清理 pg_statistic 表,但被巨大的索引拖慢了。
5.2 寻找"时间锚"
查询代码:
sql
SELECT
pid,
datname,
usename,
age(now(), xact_start) as transaction_age,
state,
backend_xmin,
query
FROM
pg_stat_activity
WHERE
backend_xmin IS NOT NULL
代码分析:
backend_xmin是会话需要看到的最旧的事务IDORDER BY backend_xmin ASC找出持有最旧快照的进程- 这是寻找"时间锚"的关键查询
结果:
json
{
"pid": 8297,
"datname": "huiliu_web_staging_before_channel",
"usename": null,
"transaction_age": "PT21.419714S",
"state": "active",
"backend_xmin": "36618551",
"query": "autovacuum: VACUUM pg_catalog.pg_statistic"
}
分析 :
找到了 autovacuum 进程,但它的 backend_xmin 不是最旧的。
5.3 检查复制槽
查询代码:
sql
SELECT
slot_name,
plugin,
slot_type,
database,
active,
xmin,
catalog_xmin
FROM
pg_replication_slots
LIMIT 10;
代码分析:
pg_replication_slots显示所有复制槽active字段显示槽是否活跃xmin和catalog_xmin显示槽持有的最旧事务ID- 不活跃的槽可能持有"时间锚"
结果:
json
{
"slot_name": "test_market",
"plugin": "pgoutput",
"slot_type": "logical",
"database": "huiliu_web_staging_before_channel",
"active": false,
"xmin": null,
"catalog_xmin": "28748908"
},
{
"slot_name": "market",
"plugin": "pgoutput",
"slot_type": "logical",
"database": "huiliu_web_staging_before_channel",
"active": false,
"xmin": null,
"catalog_xmin": "28751576"
}
关键发现:
test_market和market: 两个不活跃的逻辑复制槽active: false: 没有客户端连接这些槽catalog_xmin: 持有非常旧的事务ID(2874xxxx),而当前事务ID已经是 3661xxxx
分析 :
这两个不活跃的复制槽就是"时间锚"!它们阻止了 VACUUM 清理在事务 2874xxxx 之后产生的死元组。
问题分析
根本原因
不活跃的逻辑复制槽阻止数据库清理
pg_statistic表严重膨胀:1.74GB 的表中包含了近 300 万行死数据- 复制槽"时间锚":两个不活跃的复制槽持有非常旧的事务快照
- 查询规划器困境 :当需要查询
categories表的统计信息时,规划器被迫扫描巨大的 pg_statistic 表 - VACUUM 阻塞:复制槽阻止了 autovacuum 和手动 VACUUM 的清理工作
技术细节
- 复制槽机制:PostgreSQL 为复制槽保留所有未同步的数据变更
- MVCC 约束:由于复制槽的存在,VACUUM 无法清理相关的死元组
- 查询规划过程:规划器需要从 pg_statistic 获取统计信息,但表膨胀导致扫描缓慢
- 死循环:autovacuum 进程被阻塞,无法完成清理工作
解决方案
⚠️ 重要安全提醒:VACUUM 锁表说明
在执行任何 VACUUM 操作之前,必须了解锁表机制和业务影响:
VACUUM 锁级别说明
-
标准 VACUUM (VACUUM)
- 锁级别 :
ACCESS SHARE(最轻量级) - 业务影响 : 几乎无影响,可以与其他操作并发执行
- 安全等级: 🟢 安全,可在业务高峰期执行
- 锁级别 :
-
VACUUM FULL
- 锁级别 :
ACCESS EXCLUSIVE(最高级别) - 业务影响 : 完全阻塞,会锁定整个表,阻止所有读写操作
- 安全等级: 🔴 危险,必须在维护窗口执行
- 锁级别 :
-
VACUUM ANALYZE
- 锁级别 :
ACCESS SHARE+ 统计信息更新锁 - 业务影响: 轻微影响,统计信息更新时可能有短暂阻塞
- 安全等级: 🟡 谨慎,建议在低峰期执行
- 锁级别 :
生产环境操作建议
🟢 安全操作(可随时执行):
sql
-- 标准清理,不会锁表
VACUUM pg_catalog.pg_statistic;
🟡 谨慎操作(建议低峰期执行):
sql
-- 带统计信息更新,可能有轻微阻塞
VACUUM ANALYZE pg_catalog.pg_statistic;
🔴 危险操作(必须在维护窗口执行):
sql
-- 完全重建表,会锁表
VACUUM FULL pg_catalog.pg_statistic;
操作时机检查清单
在执行 VACUUM FULL 之前,请确认:
- 业务低峰期:确认当前没有重要业务操作
- 维护窗口:在预定的维护时间内执行
- 团队通知:已通知相关团队和用户
- 监控告警:监控系统已准备就绪
- 回滚计划:有应急回滚方案
- 备份确认:数据库有完整备份
最终解决步骤
1. 删除不活跃的复制槽
操作代码:
sql
-- 删除 "test_market" 复制槽
SELECT pg_drop_replication_slot('test_market');
-- 删除 "market" 复制槽
SELECT pg_drop_replication_slot('market');
代码分析:
pg_drop_replication_slot()删除指定的复制槽- 这会释放"时间锚",允许 VACUUM 清理死元组
- 删除前需要确认这些槽确实不再使用
⚠️ 安全提醒:
- 删除复制槽前,请确认没有其他系统依赖这些槽
- 建议先备份复制槽配置信息
2. 执行彻底的清理
⚠️ 重要:必须在维护窗口执行!
操作代码:
sql
VACUUM FULL VERBOSE pg_catalog.pg_statistic;
代码分析:
VACUUM FULL重建表文件,彻底清理死元组- 删除复制槽后,这个操作应该能够成功执行
VERBOSE提供详细的执行信息
锁表影响:
- 锁类型 :
ACCESS EXCLUSIVE - 影响范围 : 整个
pg_statistic表 - 阻塞操作: 所有对该表的读写操作
- 持续时间: 取决于表大小,可能需要几分钟到几小时
执行前检查:
sql
-- 检查当前是否有活跃查询在使用 pg_statistic
SELECT
pid,
usename,
query_start,
state,
query
FROM pg_stat_activity
WHERE query LIKE '%pg_statistic%'
OR query LIKE '%statistics%';
3. 验证结果
查询代码:
sql
-- 检查表大小和死元组
SELECT
schemaname || '.' || relname AS table_name,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS total_size,
n_live_tup,
n_dead_tup
FROM
pg_stat_all_tables
WHERE
relname = 'pg_statistic';
-- 检查查询规划时间
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM categories;
替代方案(如果无法获得维护窗口)
如果无法获得足够的维护窗口来执行 VACUUM FULL,可以考虑以下替代方案:
方案 A:分批清理(推荐)
sql
-- 第一步:标准清理(安全)
VACUUM (VERBOSE, ANALYZE) pg_catalog.pg_statistic;
-- 第二步:检查清理效果
SELECT
schemaname || '.' || relname AS table_name,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS total_size,
n_dead_tup
FROM pg_stat_all_tables
WHERE relname = 'pg_statistic';
-- 第三步:如果效果不佳,在下一个维护窗口执行 VACUUM FULL
方案 B:监控和预防
sql
-- 定期检查系统目录健康状态
SELECT
schemaname || '.' || relname AS table_name,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS total_size,
n_dead_tup,
CASE
WHEN n_dead_tup > n_live_tup * 10 THEN '严重膨胀'
WHEN n_dead_tup > n_live_tup * 5 THEN '中度膨胀'
WHEN n_dead_tup > n_live_tup * 2 THEN '轻度膨胀'
ELSE '正常'
END AS status
FROM pg_stat_all_tables
WHERE schemaname = 'pg_catalog'
AND n_dead_tup > 1000
ORDER BY n_dead_tup DESC;
经验总结
关键教训
- 系统目录维护:PostgreSQL 系统目录的膨胀会影响所有查询的性能
- 复制槽管理:不活跃的复制槽可能成为性能杀手
- 性能诊断方法 :
EXPLAIN ANALYZE是定位性能问题的强大工具 - 进程阻塞排查:需要从多个角度排查阻塞因素
最佳实践
- 性能监控:定期检查系统目录的健康状况
- 复制槽管理:及时清理不再使用的复制槽
- 维护操作:在业务低峰期执行数据库维护操作
- 问题排查:从现象到本质,逐步深入分析
预防措施
- 定期 VACUUM :确保
autovacuum配置合理,定期清理系统目录 - 复制槽监控:定期检查复制槽状态,清理不活跃的槽
- 监控告警:设置查询执行时间的监控告警
- 备份策略:在执行重大维护操作前,确保有完整的备份
技术附录
相关配置参数
sql
-- 查看 autovacuum 配置
SELECT name, setting, unit, short_desc
FROM pg_settings
WHERE name LIKE 'autovacuum%'
ORDER BY name;
-- 查看系统目录膨胀
SELECT
schemaname || '.' || relname AS table_name,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS total_size,
n_live_tup,
n_dead_tup
FROM pg_stat_all_tables
WHERE schemaname = 'pg_catalog' AND n_dead_tup > 1000;
常用诊断命令
sql
-- 检查表锁
SELECT * FROM pg_locks WHERE relation = 'categories'::regclass;
-- 检查长时间运行的事务
SELECT pid, usename, age(now(), xact_start) AS duration, query, state
FROM pg_stat_activity
WHERE state != 'idle' AND now() - query_start > interval '1 minute';
-- 检查复制槽状态
SELECT slot_name, active, xmin, catalog_xmin
FROM pg_replication_slots;
-- 分析表统计信息
ANALYZE VERBOSE table_name;
-- 重建索引
REINDEX TABLE table_name;
-- 彻底清理表
VACUUM FULL VERBOSE table_name;
本文档记录了从问题发现到最终解决的完整过程,可作为类似问题的排查参考。