数据库连接池满了以后,Go 服务不该继续排队

Go 服务通过有界连接池和请求预算阻断数据库过载扩散的专业主视觉

一个 Go 接口平时只执行两条 SQL,数据库单次查询也没有明显变慢,但流量上升后,接口延迟却从几十毫秒拉长到数秒。日志显示大量请求最终以 context deadline exceeded 结束,数据库监控中的活跃连接数则一直贴着连接池上限。直觉上,人们容易把它归因于“数据库太慢”,然后扩大连接池或延长超时。

问题可能恰好相反:连接池已经没有空位,新请求仍不断进入进程,先在获取连接的位置排队。它们还没有向数据库发送 SQL,却已经消耗 goroutine、请求上下文和上游耐心。当前面的请求释放连接时,排队者中的一部分早已接近截止时间;即使拿到连接,也没有足够时间完成事务。继续排队没有增加有效吞吐,只制造了更多超时和重试。

连接池因此不能只被理解成复用连接的容器。它同时是一道并发边界,而池外等待队列也必须有容量、时间和失败语义。

先算容量

假设服务配置 40 个数据库连接,一次请求在持有连接期间平均执行 50 毫秒。只为说明计算,在连接始终有工作且其他条件不变时,理论处理能力约为 40 ÷ 0.05 = 800 次每秒。这个结果不是性能结论,因为锁等待、事务长尾、网络抖动和数据库 CPU 都会改变实际值;它只是揭示一条约束:持有时间翻倍到 100 毫秒,同一连接数对应的理论能力就降到 400 次每秒。

更关键的是,一个 HTTP 请求不一定只占用一次连接时间。若它先查询、进行外部调用,再在事务中更新,而事务在外部调用期间保持打开,连接可能被占用数百毫秒。容量规划真正要统计的是“连接持有时间”,不是 SQL 条数,也不是整个接口的平均耗时。

连接池上限还不能等同于数据库允许连接数。假设数据库最多承受 300 个业务连接,有 6 个服务实例,每个都配置 60,就可能提出 360 个连接需求;滚动发布期间短暂存在 8 个实例,需求又变成 480。必须先从数据库总预算扣除管理、迁移和应急连接,再按最大实例数分配,而不是让每个进程单独选择一个看起来不大的数字。

等待也耗时

八百毫秒请求预算中连接等待、SQL 和提交响应的可检查分配

请求的截止时间是一份总预算。若上游只愿意等待 800 毫秒,请求已经在认证、路由和业务计算中使用 150 毫秒,那么数据库阶段最多只剩 650 毫秒。把数据库语句超时也设置为 800 毫秒,会让局部配置超过全局承诺。

获取连接的等待必须包含在这份预算内。可以进一步把剩余 650 毫秒拆成示例预算:最多等待连接 100 毫秒,执行 SQL 400 毫秒,提交与响应预留 150 毫秒。100 + 400 + 150 = 650。如果 100 毫秒内拿不到连接,请求应在准入处失败,而不是获得连接后才发现只剩几十毫秒。

短等待仍有价值,它能吸收连接释放时的微小抖动;无限等待则把连接池变成没有显式上限的内存队列。队列越长,排在后面的请求越不可能在截止时间内完成。超时虽然最终会清除它们,但清除发生得太晚,期间上游可能已经重试,从一个请求变成多个竞争者。

准入要有界

Go 的 database/sql 会在达到最大打开连接数后等待可用连接,但应用不能只依赖这个隐式等待。更清楚的做法是在昂贵数据库路径前建立有界准入:限制同时进入该路径的请求数,并让等待准入使用短于总截止时间的上下文。拿不到名额时,返回明确的过载错误。

准入上限不一定等于连接数。一个请求可能在同一事务内完成多条语句,只占一个连接;另一个请求可能并行启动多个查询,需要多个连接。还要给健康检查、后台任务和关键写入留出空间。若 40 个连接全部允许普通查询竞争,故障时连记录恢复状态的写操作也可能拿不到连接。

可以按工作重要性划分边界,但不要轻易建立多个彼此独立、总和失控的连接池。独立池能隔离流量,也会增加数据库总连接数,并让空闲连接无法共享。更常见的选择是共用连接池,在应用入口分别限制普通读取、关键写入和后台任务的并发,再为关键路径保留可计算的余量。

事务要缩短

扩大连接池之前,应先寻找连接为何迟迟不归还。最典型的问题是事务范围大于一致性范围:代码开启事务后读取数据,调用远端服务,等待消息发送,再更新并提交。数据库连接在网络等待期间没有工作,却不能交给其他请求。

事务中只应保留必须共同提交或共同回滚的数据库操作。远端调用通常应移到事务外;如果需要保证“数据库已写入,消息最终会发出”,可以在同一事务写入待发送记录,再由后台任务投递。这样并没有消除失败处理,却把不可控的网络等待移出了连接占用区间。

流式读取也可能偷偷延长持有时间。查询返回的行集如果没有及时关闭,或者消费速度受下游阻塞,连接会一直被占用。循环中的提前返回、扫描错误和取消路径都要确保资源释放。检查连接泄漏不能只看代码是否调用了查询函数,还要沿每条返回路径确认事务、行集和预编译资源的生命周期。

重试会放大

数据库连接等待、截止超时和立即重试形成的过载正反馈回路

当请求因等连接超时而失败,上游立刻重试通常不会带来新机会。假设入口每秒有 500 个原始请求,其中 20% 超时且各重试一次,下一轮就额外产生 100 个请求;如果它们仍然超时并继续重试,连接池面对的是被失败放大的负载。数据库没有恢复窗口,排队时间反而继续上升。

只有具备幂等性、仍有剩余时间、且错误具有短暂性时,重试才可能合理。连接池过载属于本实例的容量信号,重试应带随机退避,并受总请求预算与次数上限约束。对于已经接近截止时间的请求,最正确的动作是停止,而不是把必然迟到的工作再做一遍。

写请求还要考虑结果不确定。提交事务时连接断开,调用方可能不知道提交是否成功。此时简单重放会产生重复副作用。业务写入应使用幂等键、唯一约束或状态条件,让重复请求得到同一个结果;否则连接故障与自动重试结合,会把容量问题升级成数据正确性问题。

看见排队

只观察 SQL 执行耗时,会遗漏池外等待。服务至少要区分总请求耗时、获取连接等待、持有连接时间和实际查询时间,并同时记录池内正在使用、空闲、等待获取的数量以及等待累计时间。若查询时间稳定而获取等待持续上升,瓶颈就在并发边界;若持有时间上升,则要继续定位慢事务、锁等待或资源未释放。

过载失败也应单独计数,不能与普通业务错误混在一起。明确的错误类别可以帮助上游决定是否退避,也能让告警回答“请求为什么没进入数据库”。但观测本身不能阻塞关键路径,尤其不能在连接耗尽时再同步写同一个数据库记录错误。

测试不需要制造虚假的性能数字。用一个很小的测试连接上限即可验证边界:让若干操作占住全部连接,确认后续请求在准入期限内失败;释放一个连接,确认仍有预算的请求能够继续;取消排队请求,确认它不会稍后获得连接并执行副作用。再检查事务的所有错误分支是否关闭行集并回滚。

数据库连接池满时,首要目标不是让更多请求留下来,而是保护仍有机会完成的工作。先从总连接预算和持有时间计算并发能力,把获取连接纳入端到端截止时间,再用有界准入、短事务和受控重试阻止队列扩张。连接池可以等待,但服务必须知道等待多久、为谁等待,以及何时拒绝才比继续排队更可靠。