PostgreSQL连接池打满导致服务雪崩:我用pgBouncer+连接数监控把并发从200稳定到2000

上周三下午,我正在喝茶,监控群突然炸了。

“订单服务全部超时!” “支付接口 502!” “数据库 CPU 只有 30%,但所有请求都在排队!”

我打开 Grafana 一看,PgBouncer 的连接数飙到 200(我们的 max_connections 设的 200),但 CPU 和内存都很闲。说白了,连接池满了,新请求进不来,服务一层层拖垮。

这篇文章记录我从发现到解决的完整过程,包括几个踩坑点。如果你也在用 PostgreSQL,建议看完。


现象:CPU 很闲,但服务全挂了

先说说当时的现场。

14:23,订单服务开始出现大量 connection timeout。

14:25,支付、库存、用户服务相继告警——它们共用同一个 PostgreSQL 集群。

14:28,我登录数据库主机,执行:

SELECT count(*) FROM pg_stat_activity;

返回 200。再执行:

SELECT state, count(*) FROM pg_stat_activity GROUP BY state;

结果让我愣了一下:

statecount
active23
idle177

177 个连接是 idle 状态! 活跃连接只有 23 个,但连接池已经满了。

这说明问题不是慢查询拖垮的,而是连接没有被及时释放。


根因:连接池配置 + 应用层没关连接

我拉了一个 idle 连接的明细:

SELECT pid, usename, application_name, client_addr, state, state_change
FROM pg_stat_activity
WHERE state = 'idle'
ORDER BY state_change;

发现大量连接的 application_name 是 java-order-service,state_change 都是几分钟前的。明显是 Java 应用拿了连接没归还。

去查代码,发现问题很蠢但很常见:

// 有问题的代码(简化版)
public void createOrder(Order order) {
    Connection conn = dataSource.getConnection();
    // ... 执行业务逻辑
    if (order.getAmount() > 1000) {
        // 这里抛了个异常,连接没关闭!
        throw new IllegalArgumentException("金额超限");
    }
    conn.close();
}

异常路径没走 conn.close(),连接就泄漏了。类似的代码还有好几处。

但光是修代码不够。我问自己:就算代码完美了,200 个连接能撑多大并发?

算了一笔账:

  • 每个请求平均持有连接 50ms
  • 200 连接理论上 QPS 上限 = 200 / 0.05 = 4000
  • 但实际有网络延迟、业务逻辑、事务等待,有效 QPS 大概 800-1000

我们高峰期并发能到 1500,200 连接根本不够。代码泄漏只是加速器,容量不足才是根本问题。


方案:pgBouncer + 三层防护

我决定上三层防护,而不是只修代码。

第一层:引入 pgBouncer 做连接池化

pgBouncer 是 PostgreSQL 的连接池中间件,作用很简单:

  • 应用连 pgBouncer,不是直接连 PostgreSQL
  • pgBouncer 维护一小撮真实连接,复用给大量客户端
  • 支持事务级池化(transaction pooling),一个连接可以给多个请求复用

安装很简单,Ubuntu/Debian:

sudo apt-get install pgbouncer

配置文件 /etc/pgbouncer/pgbouncer.ini:

[databases]
order_db = host=10.0.1.10 port=5432 dbname=order_db

[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

# 池化模式:transaction 模式最高效
pool_mode = transaction

# 关键参数
max_client_conn = 2000
default_pool_size = 50
reserve_pool_size = 10
reserve_pool_timeout = 3

# 连接超时释放
server_idle_timeout = 60
server_lifetime = 3600

划重点:

  • pool_mode = transaction:事务结束立即归还连接,复用率最高
  • max_client_conn = 2000:应用端可以发 2000 个并发请求过来
  • default_pool_size = 50:pgBouncer 只向 PostgreSQL 开 50 个真实连接

这意味着:应用并发 2000,数据库只承受 50 连接。

userlist.txt 格式:

"app_user" "md5xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

密码用 PostgreSQL 的 MD5 格式,可以用这个 SQL 生成:

SELECT rolname, rolpassword FROM pg_authid WHERE rolname = 'app_user';

启动 pgBouncer:

sudo systemctl enable pgbouncer
sudo systemctl start pgbouncer
第二层:应用层连接配置兜底

就算有了 pgBouncer,应用层的连接池配置也要对齐。以 HikariCP 为例:

# 改之前:直接连 PostgreSQL
# spring.datasource.url=jdbc:postgresql://10.0.1.10:5432/order_db

# 改之后:连 pgBouncer
spring.datasource.url=jdbc:postgresql://10.0.1.10:6432/order_db

# 应用连接池不用开太大,pgBouncer 已经做了池化
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=5

# 关键:连接超时和空闲回收
spring.datasource.hikari.connection-timeout=5000
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.max-lifetime=600000

# 泄漏检测
spring.datasource.hikari.leak-detection-threshold=60000

leak-detection-threshold 是神器:如果连接被占用超过 60 秒,HikariCP 会打日志告诉你哪个线程泄漏了。

第三层:连接数监控 + 告警

没有监控等于盲人摸象。我加了三层监控:

1. pgBouncer 连接数监控

pgBouncer 自带 stats 接口,可以用 SHOW POOLS:

-- 连到 pgbouncer 的管理端口
psql -p 6432 pgbouncer -c "SHOW POOLS"

输出:

 database  | user  | cl_active | cl_waiting | sv_active | sv_idle | sv_used | max_wait
-----------+-------+-----------+------------+-----------+---------+---------+----------
 order_db  | app   |        45 |          2 |        10 |      15 |      25 |       0.2

关键字段:

  • cl_waiting:客户端等待连接的请求数,> 0 说明池子不够用了
  • sv_active:正在执行的真实连接数
  • max_wait:最长等待时间,秒级

2. PostgreSQL 真实连接数监控

SELECT count(*) as total_conn,
       count(*) FILTER (WHERE state = 'active') as active_conn,
       count(*) FILTER (WHERE state = 'idle') as idle_conn
FROM pg_stat_activity
WHERE backend_type = 'client backend';

3. Prometheus + Grafana 告警

我把 pgBouncer 的指标用 pgbouncer_exporter 接到 Prometheus:

# alert rules
groups:
  - name: postgresql-alerts
    rules:
      - alert: PgBouncerClientsWaiting
        expr: pgbouncer_pools_client_waiting > 0
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "pgBouncer 有客户端在等待连接"
          
      - alert: PostgreSQLConnectionsHigh
        expr: |
          (
            pg_stat_activity_count{state="active"} 
            / pg_settings_max_connections 
          ) > 0.7
        for: 2m
        labels:
          severity: critical

效果:并发从 200 稳定到 2000

改完之后,我做了压测对比:

指标改之前改之后
应用并发连接200(硬上限)2000
PostgreSQL 真实连接20050(pgBouncer 池化)
P99 响应时间超时/挂掉45ms
连接泄漏导致故障每月 1-2 次0

最直观的感受:高峰期 Grafana 的 cl_waiting 一直是 0,连接池终于不再是瓶颈。


踩坑记录

这次踩了几个坑,记录一下。

坑 1:pgBouncer 的 transaction 模式不支持 PREPARE

我们有几条用 PREPARE + EXECUTE 的查询,切到 pgBouncer 后直接报错:

ERROR: prepared statement "S_1" does not exist

原因是 transaction 模式下,每个事务可能用不同的后端连接,PREPARE 的状态没法跨连接保持。

解决:要么改代码不用 PREPARE,要么把这条业务切到 session 模式(复用率低点,但兼容性好)。

坑 2:HikariCP 的 max-lifetime 要小于 PostgreSQL 的 idle_timeout

刚开始配置没对齐,HikariCP 的连接 lifetime 是 30 分钟,PostgreSQL 的 idle_in_transaction_session_timeout 是 15 分钟。结果 HikariCP 以为连接还活,其实数据库端已经断了,报一堆 connection reset。

解决:统一时间线——

  • HikariCP max-lifetime = 10 分钟
  • PostgreSQL idle_in_transaction_session_timeout = 5 分钟
  • pgBouncer server_lifetime = 1 小时

坑 3:pgBouncer 的 auth_file 密码格式不对

第一次配的时候,直接把明文密码写进 userlist.txt,启动报错。后来才搞清楚要 MD5 格式,或者配 auth_type = scram-sha-256 对齐 PostgreSQL 14+ 的默认认证。

坑 4:只改连接池,没修代码泄漏

一开始想偷懒,觉得上了 pgBouncer 就高枕无忧了。结果压测跑了 2 小时,pgBouncer 的 sv_active 一直飙满,应用层的连接还是占着不放。

解决:必须修代码,用 try-with-resources:

public void createOrder(Order order) {
    try (Connection conn = dataSource.getConnection()) {
        // ... 执行业务逻辑
        if (order.getAmount() > 1000) {
            throw new IllegalArgumentException("金额超限");
        }
        // 自动关闭,不管有没有异常
    }
}

写在最后

这次事故给我的教训是:连接池不是无限资源,200 个连接在微服务架构下很容易打满。

pgBouncer 不是银弹,但它确实把「应用并发」和「数据库连接」解耦了。以前 200 并发就炸,现在 2000 并发数据库还是 50 连接,从容很多。

如果你也在用 PostgreSQL,建议检查一下:

  1. 你的应用连接池有没有泄漏?
  2. 你的 max_connections 真的够吗?
  3. 你有没有监控 pg_stat_activity 的 idle 连接比例?

有问题欢迎评论区交流。

更多推荐