[ruby on rails] pg 数据库性能问题排查与解决完整记录

数据库性能问题排查与解决完整记录

问题概述

本文档记录了在生产环境中遇到的一个关键数据库性能问题:

生产环境查询性能问题:Category.all 等简单查询首次执行异常缓慢(10+ 秒)

问题描述

在生产环境中,即使是简单的查询如 Category.allCategory.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 是会话需要看到的最旧的事务ID
  • ORDER 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 字段显示槽是否活跃
  • xmincatalog_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_marketmarket: 两个不活跃的逻辑复制槽
  • active: false: 没有客户端连接这些槽
  • catalog_xmin: 持有非常旧的事务ID(2874xxxx),而当前事务ID已经是 3661xxxx

分析

这两个不活跃的复制槽就是"时间锚"!它们阻止了 VACUUM 清理在事务 2874xxxx 之后产生的死元组。

问题分析

根本原因

不活跃的逻辑复制槽阻止数据库清理

  1. pg_statistic 表严重膨胀:1.74GB 的表中包含了近 300 万行死数据
  2. 复制槽"时间锚":两个不活跃的复制槽持有非常旧的事务快照
  3. 查询规划器困境 :当需要查询 categories 表的统计信息时,规划器被迫扫描巨大的 pg_statistic 表
  4. VACUUM 阻塞:复制槽阻止了 autovacuum 和手动 VACUUM 的清理工作

技术细节

  • 复制槽机制:PostgreSQL 为复制槽保留所有未同步的数据变更
  • MVCC 约束:由于复制槽的存在,VACUUM 无法清理相关的死元组
  • 查询规划过程:规划器需要从 pg_statistic 获取统计信息,但表膨胀导致扫描缓慢
  • 死循环:autovacuum 进程被阻塞,无法完成清理工作

解决方案

⚠️ 重要安全提醒:VACUUM 锁表说明

在执行任何 VACUUM 操作之前,必须了解锁表机制和业务影响

VACUUM 锁级别说明
  1. 标准 VACUUM (VACUUM)

    • 锁级别 : ACCESS SHARE (最轻量级)
    • 业务影响 : 几乎无影响,可以与其他操作并发执行
    • 安全等级: 🟢 安全,可在业务高峰期执行
  2. VACUUM FULL

    • 锁级别 : ACCESS EXCLUSIVE (最高级别)
    • 业务影响 : 完全阻塞,会锁定整个表,阻止所有读写操作
    • 安全等级: 🔴 危险,必须在维护窗口执行
  3. 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;

经验总结

关键教训

  1. 系统目录维护:PostgreSQL 系统目录的膨胀会影响所有查询的性能
  2. 复制槽管理:不活跃的复制槽可能成为性能杀手
  3. 性能诊断方法EXPLAIN ANALYZE 是定位性能问题的强大工具
  4. 进程阻塞排查:需要从多个角度排查阻塞因素

最佳实践

  1. 性能监控:定期检查系统目录的健康状况
  2. 复制槽管理:及时清理不再使用的复制槽
  3. 维护操作:在业务低峰期执行数据库维护操作
  4. 问题排查:从现象到本质,逐步深入分析

预防措施

  1. 定期 VACUUM :确保 autovacuum 配置合理,定期清理系统目录
  2. 复制槽监控:定期检查复制槽状态,清理不活跃的槽
  3. 监控告警:设置查询执行时间的监控告警
  4. 备份策略:在执行重大维护操作前,确保有完整的备份

技术附录

相关配置参数

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;

本文档记录了从问题发现到最终解决的完整过程,可作为类似问题的排查参考。

相关推荐
安当加密3 小时前
MySQL 数据库如何加密脱敏?TDE透明加密 + DBG数据库网关 双引擎加固实战
数据库·mysql·adb
IT技术分享社区3 小时前
MySQL统计查询优化:内存临时表的正确打开方式
数据库·mysql·程序员
短剑重铸之日3 小时前
7天读懂MySQL|Day 5:执行引擎与SQL优化
java·数据库·sql·mysql·架构
好记忆不如烂笔头abc4 小时前
RECOVER STANDBY DATABASE FROM SERVICE xxx,ORA-19909
数据库
writeone4 小时前
数据库习题
数据库
廋到被风吹走5 小时前
【数据库】【Oracle】分析函数与窗口函数
数据库·oracle
陌北v15 小时前
为什么我从 MySQL 迁移到 PostgreSQL
数据库·mysql·postgresql
北辰水墨5 小时前
Protobuf:从入门到精通的学习笔记(含 3 个项目及避坑指南)
数据库·postgresql
JIngJaneIL6 小时前
基于java+ vue医院管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot
予枫的编程笔记6 小时前
Redis 核心数据结构深度解密:从基础命令到源码架构
java·数据结构·数据库·redis·缓存·架构