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 这一个数字决定了完全不同的排查方向。
六、本节小结
- 「等连接」和「查询慢」是两件事:看
pending是否 > 0 来区分。 pg_stat_statements要同时看calls和total_exec_time——只看均值会漏掉 N+1。pg_stat_activity.wait_event_type是最有价值的一列:区分等锁、等 IO、等客户端。EXPLAIN ANALYZE里的「行数估计偏差」是常见根因(统计信息过期)。auto_explain应该在生产开启(记录超过阈值的查询计划)。pending > 0时先优化查询,不要先加池——池是数据库的并发闸门,加大它可能让整体更慢。- Redis 三个问题:大 key、热 key、O(N) 命令。
七、自测
- 连接池
pending = 15、active = 10、total = 10,同时pg_stat_activity显示 10 个查询都是state=active、wait_event_type=CPU(无等待),每条已运行 3 秒。请判断问题在哪,以及你会先做什么。 pg_stat_statements显示:查询 A(calls=2000000, mean=0.8ms),查询 B(calls=50, mean=800ms)。哪个更值得优化?为什么?- 为什么「
pending > 0就加大连接池」是危险的?请说出至少两个后果。
- 问题在查询本身慢(而不是池太小)。证据:① 10 个连接全部
active且wait_event_type=CPU说明它们都在真正执行(不是在等锁或等 IO),每条已经跑了 3 秒——这是慢查询;②pending=15是结果而不是原因:连接被慢查询占用太久,新请求只能排队。先做什么:① 找出这 10 条 3 秒的查询是什么(pg_stat_activity里有query字段),用EXPLAIN (ANALYZE, BUFFERS)看执行计划;② 检查是否缺索引、统计信息是否过期、是否有全表扫描;③ 不要先加大连接池——那只会让 25 条慢查询同时打到数据库,加剧锁竞争与 IO 压力。 - 查询 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找「单次慢查询」,再结合业务重要性排序。 - 危险的后果:① 把压力转移到数据库——连接池是数据库的并发闸门,加大它意味着允许更多并发查询同时执行,会导致数据库侧的锁竞争加剧、上下文切换增多、IO 队列变长,整体可能更慢;② 掩盖真正的问题——如果瓶颈是慢查询,加池只是让排队从「应用侧」移到「数据库侧」,问题依然存在,而且更难定位(因为应用层的
pending消失了);③ 数据库资源耗尽——PostgreSQL 每个连接都有内存开销(每连接一个后端进程),连接数过多可能触发 OOM 或达到max_connections上限,影响所有依赖该数据库的服务;④ 极端情况下会形成正反馈:更多连接 → 锁等待更久 → 查询更慢 → 连接被占用更久 → 需要更多连接。