文档目录

4.7 数据与中间件观测:是数据库慢,还是等连接慢?

上一节:4.6 应用可观测性 | 下一节:4.8 容器与编排的干扰 配套代码:04-toolchain/07-data-and-middleware


一句话结论

「数据库慢」和「等连接慢」是两件完全不同的事,修法也完全相反。 前者要优化 SQL 或加索引,后者要调连接池——搞错了会把问题放大(盲目加连接池 = 把压力转移到数据库)。


一、用「食堂打饭」理解两个不同的瓶颈

食堂里你等了 20 分钟才吃上饭。原因是哪个?

可能原因 类比 修法
打饭窗口只有一个,大家在排队 连接池太小 增加窗口(加连接)——但要小心
厨师做菜很慢,每个人都要等很久 查询慢 让厨师做快点(优化 SQL/加索引)
窗口很多,但厨师只有 2 个 连接够了,但数据库处理能力不足 优化查询,减少并发

关键区别:排队(等窗口)vs 服务慢(厨师慢)。

判断方法:看两个不同的指标——

连接池 pending > 0  →  在排队(等窗口)
连接池 pending = 0 但查询耗时长  →  服务慢(厨师慢)

这两个指标必须同时看,只看一个必然误判。


二、PostgreSQL:四个必看的视图与命令

2.1 pg_stat_statements:最贵语句排行榜

-- 前提:在 postgresql.conf 里加 shared_preload_libraries = 'pg_stat_statements'
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- 按总耗时排序(谁最消耗数据库时间)
SELECT
    calls,
    round(total_exec_time::numeric, 1)  AS total_ms,
    round(mean_exec_time::numeric, 2)   AS mean_ms,
    round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 1) AS pct,
    left(query, 70) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

读法(关键):

模式 特征 含义
高 calls + 低 mean 调用了 100 万次,每次 1 ms N+1 问题!总量很大
低 calls + 高 mean 调用 100 次,每次 500 ms 单条慢查询(缺索引、复杂 join)
高 calls + 高 mean 两者都高 最严重,优先处理

只看 mean_exec_time 会漏掉 N+1——因为单次很快。必须同时看 calls 和 total_exec_time。

2.2 pg_stat_activity:现在谁在等什么 ⭐

SELECT
    pid,
    now() - query_start AS duration,
    state,
    wait_event_type,          -- ⭐ 关键:等待类型
    wait_event,               -- ⭐ 关键:具体在等什么
    left(query, 60) AS query
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY duration DESC;

wait_event_type 是最有价值的一列:

wait_event_type 含义 你该做什么
Lock 等锁(被别的事务阻塞) 找阻塞源,检查事务范围
IO 等磁盘 检查索引、缓存命中、磁盘性能
Client 等客户端读取结果 客户端处理慢(可能是应用层问题)
LWLock 等轻量级锁(内部竞争) 高并发下的内部竞争
CPU(无等待) 在算 查询本身消耗 CPU
Timeout 等超时 检查配置

这是判断「等锁」还是「等 IO」最直接的手段——比看应用层指标精确得多。

2.3 锁与阻塞链

-- 谁在阻塞谁
SELECT
    blocked.pid       AS blocked_pid,
    blocked.query     AS blocked_query,
    blocking.pid      AS blocking_pid,
    blocking.query    AS blocking_query,
    now() - blocked.query_start AS blocked_duration
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;

典型发现:一个「忘记提交」或「事务范围过大」的事务把行锁持有了几十秒,导致后续所有同行的更新排队。

2.4 EXPLAIN (ANALYZE, BUFFERS):为什么这条查询慢

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status = 'PAID' ORDER BY created_at DESC LIMIT 20;

重点看三件事:

看什么 危险信号
扫描方式 Seq Scan(全表扫描)
行数估计 vs 实际 rows=20 但 actual rows=90000 → 统计信息过期
Rows Removed by Filter 扫描了大量行只保留少数 → 索引不合适
Buffers: shared read 大量磁盘读(缓存未命中)

「行数估计偏差」是最容易被忽略的根因:优化器基于统计信息选择计划,如果统计信息过期,它可能选错计划(比如该走索引却全表扫描)。修法:ANALYZE <table>;。

自动记录慢查询:

-- 在 postgresql.conf 里
-- shared_preload_libraries = 'auto_explain'
-- auto_explain.log_min_duration = '200ms'
-- auto_explain.log_analyze = on
-- auto_explain.log_buffers = on

这样所有超过 200 ms 的查询会自动记录执行计划——这是生产环境最有价值的配置之一。


三、连接池:三个必须同时看的数字

curl -s localhost:8080/metrics | grep -E "db_pool_(active|idle|pending|total)"
指标 含义 危险信号
active 正在被使用的连接 长期等于 total → 池已满
idle 空闲连接 长期为 0 → 没有余量
pending ⭐ 正在等待连接的线程数 > 0 持续存在 → 已经在排队
total 池中连接总数 达到 maximumPoolSize

pending > 0 时的决策树

pending > 0
    │
    ├─ 先查:单条 SQL 是不是很慢?(pg_stat_statements)
    │      └─ 是 → 优化 SQL / 加索引(不要先加池!)
    │
    ├─ 再查:事务范围是否过大?(长时间持有连接)
    │      └─ 是 → 缩小事务范围
    │
    └─ 都正常,仍是 pending > 0
           └─ 用 Little's Law 计算所需连接数(第 0 章 0.4 节)
              所需连接 = 目标 QPS × 单次查询耗时

为什么「不要先加池」:连接池是数据库的并发闸门。加大它 = 允许更多并发查询打到数据库 = 更激烈的锁竞争和上下文切换,整体可能更慢。正确顺序永远是:先优化查询,再调池。

池大小的正确推导

目标:支撑 1000 QPS
单次查询平均耗时:20 ms
→ 平均所需连接 = 1000 × 0.020 = 20 个

但要用 P99 做悲观估计:
单次查询 P99:80 ms
→ 最坏情况下需要 = 1000 × 0.080 = 80 个

结论:池大小取 20~80 之间,配合「获取连接超时」保护。
      如果 80 个连接会让数据库受不了,说明查询需要优化,而不是池需要加大。

四、Redis:三个必看的命令

# ① 慢查询日志(超过阈值都记录)
redis-cli SLOWLOG GET 10

# ② 命令统计(哪些命令调用最多、最耗时)
redis-cli INFO commandstats

# ③ 延迟监控(记录超过阈值的延迟事件)
redis-cli LATENCY MONITOR 10
redis-cli LATENCY RESET
redis-cli LATENCY HISTORY event-name

Redis 的三个常见性能问题:

问题 表现 修法
大 key 单个 key 几 MB,读写慢 拆分 key、用 Hash 分片
热 key 某个 key 被高频访问,单分片过载 本地缓存、key 加随机后缀分片
O(N) 命令 KEYS *、HGETALL、SMEMBERS 大集合 改用 SCAN、HSCAN;避免在生产用 KEYS

排查大 key:

redis-cli --bigkeys        # 扫描各类型最大的 key
redis-cli --hotkeys        # 需要 LFU 策略
redis-cli MEMORY USAGE <key>

五、一个完整的排查流程

现象:接口 P99 从 80ms 涨到 400ms,CPU 不高

① 看依赖级指标:dep.postgres 的 P99 涨了 → 问题在数据库方向
        ↓
② 看连接池:pending = 0,active = 8(池上限 10)
   → 没有排队,是【查询本身慢】
        ↓
③ 看 pg_stat_statements:某条查询 mean_exec_time 从 2ms 涨到 350ms
        ↓
④ EXPLAIN ANALYZE:发现 Seq Scan + Rows Removed by Filter: 90000
        ↓
⑤ 结论:缺索引 / 统计信息过期

如果第 ② 步是 pending = 18、active = 10(打满)
→ 结论改为【连接池排队】
→ 但要继续查:是查询变慢了(占用连接更久),还是并发上来了?
   → 前者优化 SQL,后者才考虑调池

注意第 ② 步的分叉:这是本节的核心——pending 这一个数字决定了完全不同的排查方向。


六、本节小结

  1. 「等连接」和「查询慢」是两件事:看 pending 是否 > 0 来区分。
  2. pg_stat_statements 要同时看 calls 和 total_exec_time——只看均值会漏掉 N+1。
  3. pg_stat_activity.wait_event_type 是最有价值的一列:区分等锁、等 IO、等客户端。
  4. EXPLAIN ANALYZE 里的「行数估计偏差」是常见根因(统计信息过期)。
  5. auto_explain 应该在生产开启(记录超过阈值的查询计划)。
  6. pending > 0 时先优化查询,不要先加池——池是数据库的并发闸门,加大它可能让整体更慢。
  7. Redis 三个问题:大 key、热 key、O(N) 命令。

七、自测

  1. 连接池 pending = 15、active = 10、total = 10,同时 pg_stat_activity 显示 10 个查询都是 state=active、wait_event_type=CPU(无等待),每条已运行 3 秒。请判断问题在哪,以及你会先做什么。
  2. pg_stat_statements 显示:查询 A(calls=2000000, mean=0.8ms),查询 B(calls=50, mean=800ms)。哪个更值得优化?为什么?
  3. 为什么「pending > 0 就加大连接池」是危险的?请说出至少两个后果。
  1. 问题在查询本身慢(而不是池太小)。证据:① 10 个连接全部 active 且 wait_event_type=CPU 说明它们都在真正执行(不是在等锁或等 IO),每条已经跑了 3 秒——这是慢查询;② pending=15 是结果而不是原因:连接被慢查询占用太久,新请求只能排队。先做什么:① 找出这 10 条 3 秒的查询是什么(pg_stat_activity 里有 query 字段),用 EXPLAIN (ANALYZE, BUFFERS) 看执行计划;② 检查是否缺索引、统计信息是否过期、是否有全表扫描;③ 不要先加大连接池——那只会让 25 条慢查询同时打到数据库,加剧锁竞争与 IO 压力。
  2. 查询 A 更值得优化(通常)。理由:虽然 A 单次只要 0.8 ms,但调用量是 200 万次,总耗时 = 200万 × 0.8ms = 1600 秒;B 的总耗时 = 50 × 800ms = 40 秒。A 的总消耗是 B 的 40 倍,而且 A 高频调用通常意味着 N+1 问题(可以通过批量查询一次解决)——优化收益巨大。但要补充:① 如果 B 是用户直接等待的关键路径(比如首页接口),它的单次延迟直接影响用户体验,即使总耗时小,优先级也可能更高;② 正确的做法是两个都看:用 total_exec_time 找「总消耗大户」,用 mean_exec_time 找「单次慢查询」,再结合业务重要性排序。
  3. 危险的后果:① 把压力转移到数据库——连接池是数据库的并发闸门,加大它意味着允许更多并发查询同时执行,会导致数据库侧的锁竞争加剧、上下文切换增多、IO 队列变长,整体可能更慢;② 掩盖真正的问题——如果瓶颈是慢查询,加池只是让排队从「应用侧」移到「数据库侧」,问题依然存在,而且更难定位(因为应用层的 pending 消失了);③ 数据库资源耗尽——PostgreSQL 每个连接都有内存开销(每连接一个后端进程),连接数过多可能触发 OOM 或达到 max_connections 上限,影响所有依赖该数据库的服务;④ 极端情况下会形成正反馈:更多连接 → 锁等待更久 → 查询更慢 → 连接被占用更久 → 需要更多连接。