4.7 配套代码:数据库、连接池与缓存观测
对应小节:4.7 数据与中间件观测 核心:先区分「等连接」和「查询慢」,再决定优化方向。
一、PostgreSQL:四个必开配置
# postgresql.conf
# ① 语句统计(必须!否则无法知道哪条 SQL 最贵)
shared_preload_libraries = 'pg_stat_statements, auto_explain'
pg_stat_statements.max = 10000
pg_stat_statements.track = all
pg_stat_statements.track_planning = on
# ② 自动记录慢查询的执行计划(生产最有价值的配置之一)
auto_explain.log_min_duration = '200ms'
auto_explain.log_analyze = on
auto_explain.log_buffers = on
auto_explain.log_verbose = on
auto_explain.log_format = 'text'
auto_explain.sample_rate = 0.1 # 只记录 10% 的慢查询,避免日志爆炸
# ③ 记录锁等待
log_lock_waits = on
deadlock_timeout = '1s'
# ④ 慢查询日志(作为兜底)
log_min_duration_statement = '500ms'
auto_explain是最值得开的一项:它让「慢查询」自动带上执行计划,不用等你复现。
二、四个诊断查询(放在一个 SQL 文件里)
-- tools/pg-diagnostics.sql
-- ═══════════════════════════════════════════════════════════
-- ① 最贵的语句(同时看 calls 和 total,防止漏掉 N+1)
-- ═══════════════════════════════════════════════════════════
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_of_total,
rows,
left(regexp_replace(query, '\s+', ' ', 'g'), 70) AS query
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat_statements%'
ORDER BY total_exec_time DESC
LIMIT 20;
-- ⭐ 读法:
-- 高 calls + 低 mean → N+1(总量大)
-- 低 calls + 高 mean → 单条慢查询(缺索引)
-- 两者都高 → 最优先处理
-- ═══════════════════════════════════════════════════════════
-- ② 现在谁在等什么(wait_event_type 是最有价值的一列)
-- ═══════════════════════════════════════════════════════════
SELECT
pid,
round(extract(epoch from (now() - query_start))::numeric, 1) AS duration_s,
state,
wait_event_type, -- Lock / IO / Client / LWLock / Timeout
wait_event,
left(regexp_replace(query, '\s+', ' ', 'g'), 60) AS query
FROM pg_stat_activity
WHERE state <> 'idle'
AND pid <> pg_backend_pid()
ORDER BY duration_s DESC NULLS LAST
LIMIT 20;
-- ⭐ 读法:
-- wait_event_type=Lock → 等锁(查下面的阻塞链)
-- wait_event_type=IO → 等磁盘(查索引与缓存命中)
-- wait_event_type=Client → 等客户端读取(应用层问题)
-- 无等待 + state=active → 在算(查询本身消耗 CPU)
-- duration_s 很大 → 长事务(可能是连接池被占住的原因)
-- ═══════════════════════════════════════════════════════════
-- ③ 阻塞链(谁在阻塞谁)
-- ═══════════════════════════════════════════════════════════
SELECT
blocked.pid AS blocked_pid,
round(extract(epoch from (now() - blocked.query_start))::numeric, 1) AS blocked_s,
left(regexp_replace(blocked.query, '\s+', ' ', 'g'), 50) AS blocked_query,
blocking.pid AS blocking_pid,
left(regexp_replace(blocking.query, '\s+', ' ', 'g'), 50) AS blocking_query,
blocking.state AS blocking_state
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;
-- ⭐ 典型发现:一个"忘记提交"的事务持有行锁几十秒,
-- 导致后续所有同行的更新排队。
-- ═══════════════════════════════════════════════════════════
-- ④ 连接使用情况(对比应用侧的连接池指标)
-- ═══════════════════════════════════════════════════════════
SELECT
COALESCE(application_name, 'unknown') AS app,
state,
count(*) AS connections,
round(max(extract(epoch from (now() - state_change)))::numeric, 1) AS max_state_s
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
GROUP BY app, state
ORDER BY connections DESC;
-- 与 max_connections 对比
SELECT
(SELECT count(*) FROM pg_stat_activity) AS used,
current_setting('max_connections')::int AS max_conn,
round(100.0 * (SELECT count(*) FROM pg_stat_activity)
/ current_setting('max_connections')::int, 1) AS used_pct;
三、连接池判定决策树(脚本化)
#!/usr/bin/env bash
# tools/diagnose-pool.sh <APP_METRICS_URL> [PG_CONN_STRING]
#
# 自动判断"是等连接慢还是查询慢",并给出建议方向。
set -uo pipefail
METRICS_URL="${1:-http://127.0.0.1:8080/metrics}"
echo "═══ 连接池诊断 ═══"
echo
M=$(curl -s --max-time 5 "$METRICS_URL" 2>/dev/null)
[ -z "$M" ] && { echo "❌ 无法获取指标($METRICS_URL)"; exit 1; }
ACTIVE=$(echo "$M" | awk '/^db_pool_active/{printf "%.0f", $2; exit}')
IDLE=$(echo "$M" | awk '/^db_pool_idle/{printf "%.0f", $2; exit}')
PENDING=$(echo "$M" | awk '/^db_pool_pending/{printf "%.0f", $2; exit}')
TOTAL=$(echo "$M" | awk '/^db_pool_total/{printf "%.0f", $2; exit}')
echo "连接池状态:"
echo " active = ${ACTIVE:-?}"
echo " idle = ${IDLE:-?}"
echo " pending = ${PENDING:-?} ← 关键指标"
echo " total = ${TOTAL:-?}"
echo
if [ "${PENDING:-0}" -gt 0 ]; then
echo "结论:❌ 存在排队(pending > 0)"
echo
echo "下一步(按顺序):"
echo " ① 查慢查询(不要先加池!):"
echo " psql -f tools/pg-diagnostics.sql # 看第 ① 与 ② 节"
echo " ② 查长事务(可能是连接被占住的原因):"
echo " SELECT pid, now()-xact_start AS xact_age, state, left(query,50)"
echo " FROM pg_stat_activity WHERE xact_start IS NOT NULL"
echo " ORDER BY xact_age DESC LIMIT 10;"
echo " ③ 只有在【查询正常】且【并发确实高】时,才用 Little's Law 计算新池大小:"
echo " 所需连接 = 目标 QPS × 单次查询 P99"
else
echo "结论:✅ 没有排队"
echo
if [ "${ACTIVE:-0}" -ge "${TOTAL:-1}" ] && [ "${TOTAL:-0}" -gt 0 ]; then
echo "⚠️ 但 active == total(池已满、但没有排队)"
echo " 说明池容量刚好够用,没有余量应对突发。建议观察一段时间。"
else
echo " 延迟问题不在连接池,转向:"
echo " - 依赖级指标(dep.postgres 的 P99)"
echo " - 慢查询统计(pg_stat_statements)"
echo " - 网络与下游"
fi
fi
echo
echo "参考:pool 总连接数应 ≈ 目标QPS × 平均查询耗时(Little's Law)"
四、Redis 观测
#!/usr/bin/env bash
# tools/redis-diagnostics.sh [REDIS_CLI_ARGS]
set -uo pipefail
R="${REDIS_CLI_ARGS:--h 127.0.0.1}"
echo "═══ Redis 诊断 ═══"
echo
echo "① 基本信息"
redis-cli $R INFO server | grep -E "redis_version|uptime_in_days"
redis-cli $R INFO clients | grep -E "connected_clients|blocked_clients"
redis-cli $R INFO memory | grep -E "used_memory_human|maxmemory_human|mem_fragmentation_ratio"
echo
echo "② 命中率(低命中率说明缓存设计有问题)"
redis-cli $R INFO stats | grep -E "keyspace_hits|keyspace_misses" | awk -F: '
/hits/{h=$2} /misses/{m=$2}
END{ if (h+m>0) printf " 命中率: %.1f%% (hit=%d, miss=%d)\n", 100*h/(h+m), h, m }'
echo
echo "③ 慢查询日志(最近 10 条)"
redis-cli $R SLOWLOG GET 10
echo
echo "④ 延迟事件"
redis-cli $R LATENCY LATEST 2>/dev/null || echo " (无延迟事件或无权限)"
echo
echo "⑤ 大 key 扫描(耗时操作,生产慎用)"
echo " 提示:redis-cli --bigkeys 会遍历所有 key,建议在从库或低峰期执行"
echo
echo "⑥ 命令统计 Top 10(按累计耗时)"
redis-cli $R INFO commandstats | sed 's/^cmdstat_//' | sort -t= -k4 -rn | head -10
echo
echo "判读要点:"
echo " used_memory 接近 maxmemory → 频繁淘汰,命中率下降"
echo " mem_fragmentation_ratio > 1.5 → 内存碎片严重"
echo " blocked_clients > 0 → 有客户端在阻塞命令(如 BLPOP)"
echo " 慢查询里有 KEYS/HGETALL 大集合 → 典型的 O(N) 命令误用"
五、一个完整的排查示例
## 现象
订单查询接口 P99 从 80ms 涨到 400ms,CPU 45%,QPS 未变。
## 排查过程
### ① 依赖级指标
dep.postgres 的 P99 从 20ms 涨到 350ms ← 问题在数据库方向
dep.redis 的 P99 正常(2ms)
### ② 连接池(关键分叉)
db_pool_active = 10
db_pool_idle = 0
db_pool_pending = 18 ← 有排队
### ③ 但先不急着加池——查为什么连接被占住
psql -f tools/pg-diagnostics.sql
第 ② 节(pg_stat_activity)显示:
10 个 backend 全部 state=active, wait_event_type=NULL(在算),已运行 3 秒
→ 不是等锁、不是等 IO,是【查询本身慢】
第 ① 节(pg_stat_statements)显示:
某条查询 calls=50000, mean_ms=3400, total_ms=170000000
→ 单条查询慢
### ④ EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status='PAID' ORDER BY created_at DESC LIMIT 20;
→ Seq Scan,Rows Removed by Filter: 900000
→ 行数估计 rows=20 但 actual rows=900000(统计信息过期)
### ⑤ 结论
缺索引(status)+ 统计信息过期。
连接池 pending=18 是【结果】而不是【原因】——连接被慢查询占住了。
### ⑥ 修复
CREATE INDEX CONCURRENTLY idx_orders_status_created ON orders(status, created_at DESC);
ANALYZE orders;
### ⑦ 验证
组件基准复测:P99 从 3400ms 降到 12ms
连接池 pending 回到 0
全链路 P99 从 400ms 降到 85ms
注意第 ③ 步的分叉:如果
pg_stat_activity显示的是wait_event_type=Lock,结论就完全不同(要查阻塞链);如果显示IO,要去查索引与磁盘。这就是「四个诊断查询」存在的价值——它们把猜测变成分类。
六、动手改造
| 改动 | 观察什么 |
|---|---|
| 故意去掉一个索引,跑组件基准 | pg_stat_statements 里该查询的 mean 与 total 如何变化 |
制造一个长事务(BEGIN; SELECT ... FOR UPDATE; 然后不提交) |
pg_stat_activity 的 wait_event_type 变成 Lock,阻塞链查询能找出它 |
| 把连接池从 10 调到 3 | pending 上升——观察"排队"如何被制造出来 |
在 diagnose-pool.sh 里加上 Little’s Law 计算 |
让脚本直接给出建议池大小 |
用 redis-cli --bigkeys 找一个真实的大 key |
理解大 key 对延迟的影响 |
把 auto_explain.log_min_duration 从 200ms 降到 50ms |
会记录更多查询计划,观察日志量与开销 |
七、这段代码的局限
pg_stat_statements的统计是累计值,跨重启会清零,且无法区分"过去 5 分钟"与"过去 1 小时"。要看趋势需要定期pg_stat_statements_reset()或导出到监控系统。auto_explain有开销(尤其log_analyze=on),生产上要设置sample_rate与合理的log_min_duration。--bigkeys会遍历整个 keyspace,在大实例上可能阻塞数秒——在从库或低峰期执行。pg_stat_activity看到的是瞬时状态,偶发问题需要连续采样(或用pg_stat_statements的累计值补充)。