ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PostgreSQL 连接数耗尽:dify 1.9.0 “too many clients already” 排查实践

PostgreSQL 连接数耗尽:dify 1.9.0 “too many clients already” 排查实践 dify 1.9.0 上了生产环境跑了没几天监控群里突然开始刷屏。日志里反复出现一行很扎眼的异常org.postgresql.util.PSQLException: FATAL: sorry, too many clients already。如果你部署的是 pgsql 作为元数据库那这行报错几乎意味着所有依赖数据库的接口全部开始报错API 调用、工作流执行、会话记录写入都会跟着抖动。这个报错本质上是 PostgreSQL 连接数被打满了但在 dify 这种多组件架构里它往往不是数据库单点的问题。API 服务、Worker、Sandbox、Web 前端、定时任务甚至你额外挂的监控探针每一条链路都可能各自握着一批数据库连接。排查时如果只盯着max_connections去调大大概率治标不治本。这篇文章我按实际排障的完整路径来写从现象、根因、定位、应急、根治到预防一次性把“too many clients already”在 dify 1.9.0 里会踩的坑都讲透。1. 问题现象与影响范围1.1 报错长什么样哪些功能会遭殃先看典型日志形态。dify 后端起服务时连接池初始化阶段一般不会报错问题基本都出现在运行中高峰期。常见位置包括FATAL: sorry, too many clients already Connection to pgsql failed: FATAL: sorry, too many clients already如果用的是 SQLAlchemy 连接池还会伴随类似queue_pool超时或TimeoutError的报错这其实是客户端连接池在等待数据库释放连接根子仍然是数据库端连接数耗尽。受影响范围比想象中广对话类 API 接口新建会话、读取历史消息、保存用户反馈全都直接读写 pgsql。工作流执行节点运行状态写入、日志落库、队列任务标记任何一个节点需要数据库时都可能失败。后台 Worker消费队列任务时频繁查询数据库数据库连接耗尽后任务会卡在队列里反复重试。管理后台页面打开应用列表、成员列表、模型供应商配置只要涉及数据库查询都会超时或者直接白屏。我在一套模拟项目环境里实测连接数被打满后最直观的表现不是所有功能同时挂掉而是“部分请求成功、部分请求失败”。因为连接池可能还有少数几根连接在排队复用前几个请求成功了后续请求就卡在等待连接释放这比全部挂掉更难排查。1.2 为什么 dify 特别容易触发这个上限PostgreSQL 默认的max_connections通常是 100很多 docker-compose 起的实例甚至没专门调过这个参数。而 dify 1.9.0 的服务拓扑决定了它天生就是“多进程抢连接”的架构。简单数一下会连数据库的组件API 服务通常有多个 gunicorn workerWorker 服务异步任务Sandbox 服务代码执行环境Python 代码执行时也可能走数据库Web 前端服务部分页面接口走后端定时任务或 Celery Beat 类型进程额外的监控脚本、备份工具、迁移任务每个进程不是只占 1 个连接。SQLAlchemy 连接池默认会在每个进程里维护一组长连接如果你的配置里pool_size10、max_overflow5那单个进程最多占用 15 个连接。假设 API 服务有 4 个 worker光这一层就能占 60 个连接。再加上 Worker、Sandbox数十个进程叠加pgsql 的 100 个默认上限根本不够用。我之前帮某团队排查一套 1.9.0 环境当时pg_stat_activity里看到的连接数是 200而max_connections只有 100也就是说数据库端早就开始拒绝新连接了但应用端日志因为各种缓存和重试过了一段时间才集中爆发。这也是这个问题的隐蔽之处它不是配置错了而是默认配置跟不上架构规模。2. 先搞清楚 PostgreSQL“客户端太多”的根因2.1 PG 的 max_connections 到底是什么机制先补一个底层认知。PostgreSQL 的每一个客户端连接都对应数据库端的一个后端进程而不是像 Redis 那样基于内存事件循环处理多路复用。连接数越多PG 进程数越多共享内存和进程管理开销也越大。max_connections是在数据库实例启动时分配好共享内存的不能像改普通配置一样随意热加载改完必须重启实例才会生效。这也是很多人踩坑的地方临时用ALTER SYSTEM SET max_connections 300改完发现没起作用因为忘了重启或者连接一旦耗尽时连重启都会很费力。另外max_connections也不是设置得越大越好。每个后端进程都要消耗内存连接数从 100 调到 500内存占用会明显上升。在小内存机器上连接数调太大反而可能导致 PG 启动失败或者运行中 OOM。所以合理的做法是控制连接需求而不是无脑放大上限。2.2 dify 各组件抢占连接的模型要理解 dify 为什么容易打满连接可以把每个服务进程看成一栋楼的住户把 PostgreSQL 看成一个停车场。每个连接就是一个车位。服务进程启动时因为要复用通常不会用完就还车位而是长期占着几个车位。如果车位总数是 100 个住户却越来越多最后新来的车就只能堵在门口报“too many clients”。具体到 difyAPI 服务用的是 gunicorn 或类似 WSGI 服务器默认可能开启多个 worker 进程。每个 worker 在第一次访问数据库时会初始化自己的 SQLAlchemy 连接池。这个连接池是进程级别的不是全局共享的。所以“连接池大小 × worker 数量”才是实际最大占用值。举个例子一个 API 服务容器里开了 4 个 worker每个 worker 的 SQLAlchemy 连接池pool_size10、max_overflow5那么光 API 一个容器峰值就能占用4 × (10 5) 60 个连接如果同样的配置再开一个 Worker 容器、一个 Sandbox 容器三个容器叠加就是 180 个连接。此时max_connections100的数据库必然报警。这里还要注意 docker-compose 部署时如果你对某个服务做了水平扩展比如 API 服务副本数从 1 调成 3那么连接数占用也会直接乘以 3。很多团队只扩容不调数据库参数问题就是这么来的。2.3 长连接与空闲连接的累积效应另一个容易被忽略的点是连接池里的长连接空闲时不会主动断开。SQLAlchemy 的pool_pre_ping可以检测失效连接但如果没配置回收策略一个进程跑上几天后连接池里的连接可能已经积累了多个状态包括空闲、闲置事务、甚至死事务。我在现场排查时见过一种很典型的情况某个服务代码里有手动开事务但忘记提交的操作事务一直挂起连接一直处于idle in transaction状态既不算活跃查询也不会被自动回收。这种连接最容易被忽略因为它不产生慢查询日志但实实在在地占着一个连接名额。还有一种是容器重建后旧连接没有及时断开。如果服务通过滚动更新或者docker compose up -d重建旧容器的 TCP 连接可能还在数据库端处于半开状态直到 TCP 超时才被清理。短时间内新旧容器连接叠加数据库连接数会短暂冲高。3. 三步定位到底是谁占满了连接3.1 先查 pg_stat_activity 聚合视图遇到“too many clients already”第一步不是去改配置而是先搞清楚“谁”在占用。直接在 psql 里执行SELECT datname, usename, application_name, client_addr, count(*) FROM pg_stat_activity GROUP BY datname, usename, application_name, client_addr ORDER BY count(*) DESC;这条 SQL 会把当前所有连接按数据库、用户名、应用名、客户端 IP 聚合。你大概率会看到某个application_name占了绝大部分连接比如dify-api、dify-worker或者某个 Python 驱动的默认名。如果application_name没设置可以通过client_addr来判断是哪个容器。一般 docker-compose 网络内API、Worker、Sandbox 各有不同的容器 IP 段定位起来很直接。3.2 结合容器网络层二次确认数据库层的连接信息只能看到 IP不一定能对应到具体容器。如果服务比较多可以进一步在容器节点上用网络命令统计连接来源。ss -tnp | grep 5432这条命令会列出所有到 5432 端口的 TCP 连接包括源 IP、源端口、进程 PID。结合pg_stat_activity里的client_addr可以准确对上到底是谁在连接数据库。我在实际操作中更习惯先查数据库侧再回容器侧核对。数据库侧看到的连接是真实存在的容器侧统计会因为 TIME_WAIT 状态有所偏差两者结合才是完整视图。3.3 区分活跃查询、空闲连接、空闲事务pg_stat_activity里的state字段是判断连接性质的关键state含义是否占用连接名额active正在执行查询是idle空闲等待复用是idle in transaction事务挂起未提交/回滚是fastpath function call特殊场景是排查时不能只看 active。我见过太多生产环境里真正活跃查询只有 20 个剩下 100 个全是 idle 或 idle in transaction。这些空闲连接大概率来自某个连接池没有正确回收或者代码里事务未关闭。进一步查询空闲事务SELECT pid, usename, state, query, age(now(), xact_start) AS tx_age, client_addr FROM pg_stat_activity WHERE state idle in transaction ORDER BY tx_age DESC;如果发现存在非常老的空闲事务那基本是应用代码里begin后没commit或rollback。这种连接即使你重启数据库服务如果代码不修复过一段时间还是会复发。4. 解决手段从应急到根治4.1 紧急处理先救火但别乱杀连接已经满了的情况下首先面临一个问题连 psql 都可能连不上去。因为新连接也要占用连接名额。此时可以用数据库所在节点的本地 socket 连接或者通过重启 PostgreSQL 服务的方式来腾出连接。重启数据库是应急手段中最有效但最暴力的方式。如果生产环境能接受短暂中断直接用systemctl restart postgresql或容器内重启 PG 进程。但重启后所有应用连接池会重新建立连接瞬间会有大量建连请求可能再次冲高所以不建议反复重启。另一个紧急操作是清理空闲连接。比如把空闲超过一定时间的连接全部断掉SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state idle AND state_change now() - interval 1 minute AND pid pg_backend_pid();注意这条 SQL 会把所有空闲超过 1 分钟的应用连接全部断开属于强制手段。执行前务必确认datname和usename过滤条件准确最好先查询预览再执行 terminate。别把其他重要业务的连接也一并杀了。如果max_connections确实偏小临时调大也是一种应急思路但要清楚这是“扩容车位”不是“减少车辆”。调大后需重启 PG且要评估内存是否能支撑。4.2 调整 dify 侧连接池参数应急过后必须从源头控制连接占用。如果你是用源码方式部署 dify后端服务基于 SQLAlchemy连接池相关参数一般通过环境变量或配置文件控制。常见的就是这几个SQLALCHEMY_POOL_SIZE连接池保持的最小连接数。SQLALCHEMY_MAX_OVERFLOW超过 pool_size 时最多还能额外创建的连接数。SQLALCHEMY_POOL_RECYCLE连接最大复用时间超过后回收重建。SQLALCHEMY_POOL_PRE_PING每次取连接时校验是否存活。我建议的调整方向是“宁小勿大”。把pool_size从默认值调低比如SQLALCHEMY_POOL_SIZE5 SQLALCHEMY_MAX_OVERFLOW3 SQLALCHEMY_POOL_RECYCLE300 SQLALCHEMY_POOL_PRE_PINGtrue这样单个进程最多占用 8 个连接。配合合理的 worker 数量连接占用量会大幅下降。如果你用的是 docker-compose 方式部署可以在对应服务的environment里加上这些变量然后重建服务。不同版本的 dify 可能对连接池参数的支持不完全一致以官方文档和配置项说明为准。我这边给的是通用实践不是针对某个具体版本的唯一答案。4.3 生产推荐引入 PgBouncer 做连接代理在连接池调优之后如果服务规模依然大或者无法精确控制每个进程的连接数生产环境更稳的方案是引入一个数据库连接代理层常见选型就是 PgBouncer。PgBouncer 的核心理念是应用进程不再直接连 PostgreSQL而是连 PgBouncer。PgBouncer 和后端 PostgreSQL 之间维护一小批真正的连接所有应用共享这一批连接。这样数据库端的连接数就变得可控了。对 dify 这种场景PgBouncer 推荐用transaction pooling模式。理由很简单dify 大多数数据库操作是短事务在一个事务结束后连接状态可以被重置并交给下一个应用使用。这样 20 个后端连接就能扛住几百个应用连接。一个简化版 PgBouncer 配置示例[databases] dify hostpgsql port5432 dbnamedify [pgbouncer] listen_addr 0.0.0.0 listen_port 6432 auth_type trust pool_mode transaction max_client_conn 1000 default_pool_size 20 min_pool_size 5 server_idle_timeout 300配置完成后把 dify 数据库连接地址指向 PgBouncer 的 6432 端口即可。此时即使应用侧连接池配置偏大真正打到 PostgreSQL 的连接数也不会超过 PgBouncerdefault_pool_size的设定值。注意使用 PgBouncer 的 transaction 模式时如果应用依赖数据库会话级变量或服务端预处理语句可能会踩坑。报错形式通常是 prepared statement 相关提示。遇到这种情况要么把应用侧驱动改为禁用服务端预处理语句要么调整 PgBouncer 的 pool_mode 策略验证兼容性。我在某套环境里遇到过改成禁用预处理语句后问题消失。4.4 调整容器副本数和并发控制连接数问题不只是参数问题还可能和扩容策略有关。API 服务的 worker 数、副本数越多连接占用越高。如果你的连接池参数已经调小但副本数有 5 个每个占 6 个连接光 API 就 30 个加上其他服务也会累计到 50 以上。所以做水平扩展时要同步评估数据库连接容量。更稳的做法是优先调小单个副本的连接池再按需扩展副本。对 async 任务尽量让 Worker 的并发数可控避免一批任务同时起来把连接瞬间吃满。5. 参数计算与推荐配置参考5.1 一个可复制的计算过程假设当前环境如下PostgreSQLmax_connections100API 服务 2 个副本每个 4 个 gunicorn workerWorker 服务 1 个副本4 个并发进程Sandbox 服务 1 个副本2 个进程每个进程初始 SQLAlchemypool_size10、max_overflow5那么峰值占用估算API2 × 4 × (10 5) 120 Worker1 × 4 × (10 5) 60 Sandbox1 × 2 × (10 5) 30 合计120 60 30 210这个环境下max_connections100必然被瞬间打爆。即使你调大到 250也只是勉强不报错而且每个进程的连接池都在同时空闲维护内存开销非常大。这个计算过程告诉我们先算账再调参。如果改用 PgBouncer把default_pool_size30、min_pool_size10那无论应用侧有多少并发进程最终 PostgreSQL 上看到的连接就是几十个完全可控。5.2 分角色推荐配置环节参数推荐值说明PostgreSQLmax_connections按业务余量 1.5~2 倍设置配合 PgBouncer 后可以控制在 200 内dify APISQLALCHEMY_POOL_SIZE5~10worker 数多时取小值dify APISQLALCHEMY_MAX_OVERFLOW3~5应对短时突发dify WorkerSQLALCHEMY_POOL_SIZE5Worker 并发高时避免放大dify Sandbox连接池上限保持默认偏小代码执行场景不稳定PgBouncerdefault_pool_size20~50视后端 PG 实例规格而定PgBouncerpool_modetransactiondify 默认场景最合适5.3 监控与告警不要等爆了才看连接数问题完全可以提前发现。最简单的方式就是定期对pg_stat_activity做聚合统计看连接总数趋势。也可以建一个简易看板监控当前连接数相对max_connections的百分比。超过 70% 就要开始关注超过 85% 基本可以准备应急了。我习惯在日志侧也加一层监控专门检索关键字too many clients already。这样即使数据库监控告警延迟日志侧也能第一时间暴露问题。这个关键字一旦出现说明连接数已经归零必须立刻介入。6. 常见问题与排障速查症状直接原因处理建议报错 too many clients但活跃查询很少大量空闲连接或 idle in transaction 堆积清理空闲事务检查代码事务提交遗漏调大 max_connections 后仍复现应用连接池配置过大服务副本多调小 pool_size、限制并发、引入 PgBouncer重启数据库后短暂恢复又打满应用进程连接池被重新初始化数量没变必须改连接池参数不能只靠重启事务某个容器重建后连接不释放TCP 半开连接或未优雅退出缩短连接池回收时间配置服务优雅停机用了 PgBouncer 后部分查询报错事务模式下 prepared statement 冲突禁用应用侧预处理语句或切换模式伴随大量 TimeoutError客户端连接池排队等待数据库端连接满先看数据库端连接数再调服务侧并发定时任务瞬间打满连接Worker 并发过高任务内部慢查询占连接控制任务并行度给 Worker 做限流实际踩坑中还有一个容易被忽略的细节如果你用 docker-compose 部署 dify有些服务镜像里默认会配置较大的连接池你想通过环境变量覆盖却不生效。这时候要先确认变量的命名是否被应用代码读取。可以进入容器内检查实际运行参数而不是盲改.env。关于事务遗漏问题我在一套环境里排查了半天最后发现是某个自定义节点代码里写了一大段with db.session.begin()操作其中某个分支抛异常后没有回滚导致事务一直挂着。这种问题在 dify 的工作流自定义节点场景很容易出现建议所有自定义节点的数据库操作都严格用 try/except 包裹确保异常时能关掉连接。我个人在实际操作中的体会是这种连接打满的故障最忌讳的就是慌了手脚反复重启数据库。应该先花三分钟看pg_stat_activity聚合结果搞清楚谁是连接大户再决定是调参、杀连接还是上 PgBouncer。大多数情况下把 dify 各服务的连接池参数调到匹配业务并发的水平问题就能解决只有规模持续上量后才值得引入代理层来统一管理。希望这篇排障记录能帮你少走几趟弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进