CatchAdmin PHP 后台管理框架 Logo CatchAdmin

精准定位耗尽数据库连接的 PHP 请求

每一位 PHP 开发者迟早都会遇到这个报错:

SQLSTATE[HY000] [1040] Too many connections

或者它的 Postgres 版本:

SQLSTATE[08006] [7] FATAL: remaining connection slots are reserved for non-replication superuser connections

第一反应通常是把 max_connections 调高。调完撑几个小时,问题再次复发。此时不仅连接耗尽,内存也见底——每条空闲的 MySQL 连接在数据库端大约占用 1MB 内存,从 151 升到 500 也就意味着额外被占掉几百兆。

真正的问题从来不是连接的数量,而是某个 PHP 请求——或许是一个,或许是一类——把连接拿住的时间远超它应有的长度,或者打开了比所需更多的连接,再或者干脆把连接泄漏掉了。

本文要给出的是一套诊断流程,而不是"加连接数"这种创可贴疗法。它会从数据库侧追查到那一段具体出错的 PHP 代码,用到的工具多数开发者的环境里都有,只是很少被组合起来使用。

TL;DR 速览

  • 数据库连接被"吃掉"通常来自三种模式:长时间运行的查询、横跨慢操作的长事务,以及在高并发场景下被错误使用的持久连接。
  • 诊断流程是固定的:先看数据库的连接清单,再关联到 PHP-FPM 工作进程,最后关联到 HTTP 请求。
  • 最有用的一条单独查询是 MySQL 的 SHOW PROCESSLIST 或 Postgres 的 pg_stat_activity。两者都会列出哪些连接是活跃、空闲或卡住的,并附带当下的 SQL。
  • Laravel 的 DB::listen()、查询日志和中间件,就足以把每条查询钉到发起它的请求上,不需要任何 APM。
  • 修复方式几乎都在三件事中:加查询超时、缩短事务、在不需要的地方停用持久连接。

本文要点

  • 真实 PHP 应用中触发"Too many connections"的三种模式
  • 如何像资深 DBA 那样读 SHOW PROCESSLISTpg_stat_activity
  • 如何把数据库侧的连接反查到具体的 PHP-FPM 工作进程和 HTTP 请求
  • 让下一次故障排查提速十倍的应用层工具链:Laravel DB::listen、中间件、请求 ID 日志
  • 为什么持久连接是 PDO 中最被误解的设置,它真正有用的场景是什么
  • 长期治理方案:使用 ProxySQL/PgBouncer 做连接池、按角色限额、熔断器

造成这一现象的三种真实模式

动手用工具之前,先要搞清楚要追的是什么。从业界多起事故来看,95% 的"连接过多"问题都归属下面三类之一。

模式一,长时间运行的查询。 某个请求发起一条需要跑 30 秒、60 秒甚至 120 秒的查询。低负载下只是恼人,但一旦流量上来——比如每秒 100 个请求打到同一端点——就会直接演变成连接耗尽。这 100 个请求每个都把一条连接占满整条查询周期。在一分钟里,相当于消耗了 6,000 条连接秒的容量。

模式二,长事务。 请求开启事务后做了一些数据库操作,紧接着执行一段与数据库无关的慢动作——调用第三方 API、写入 S3、运行耗时计算——最后才 commit。在这整段时间里,事务一直占着连接,而大部分工作与数据库毫无关系。

模式三,泄漏。 请求打开了一个连接(常见做法是不该出现的地方调用了 new PDO(...)),却从未显式关闭。传统 PHP-FPM 请求下不会出问题,因为请求结束连接会被回收。但在 Laravel 队列、Octane、Swoole 这类长驻工作进程里,它就成了慢性泄漏,最终把连接池撑爆。

弄清楚自己面对的是哪一种模式,整场排查的胜负也就定了大半。下面介绍的工具,本质上都是在帮回答这个问题。

第一步,从数据库侧问清楚连接都被谁占着

永远从这里开始。在看到数据库视角下的连接清单之前,不要去翻应用日志。

MySQL:SHOW PROCESSLIST

用管理员账户连到 MySQL:

sql
SHOW FULL PROCESSLIST;

输出大致如下:

+------+------+----------------------+----------+---------+------+-----------+------------------------------------------+
| Id   | User | Host                 | db       | Command | Time | State     | Info                                     |
+------+------+----------------------+----------+---------+------+-----------+------------------------------------------+
| 5821 | app  | 10.0.1.12:52344      | shop     | Query   |   47 | Sending   | SELECT * FROM orders WHERE status = 'pen |
| 5822 | app  | 10.0.1.12:52346      | shop     | Sleep   |  121 |           | NULL                                     |
| 5823 | app  | 10.0.1.13:33102      | shop     | Query   |   42 | Sending   | SELECT * FROM orders WHERE status = 'pen |
| 5824 | app  | 10.0.1.12:52348      | shop     | Sleep   |   98 |           | NULL                                     |
| 5825 | app  | 10.0.1.13:33104      | shop     | Query   |   39 | Sending   | SELECT * FROM orders WHERE status = 'pen |
...

仔细读这张表,能看出三件事。

第一,多条连接正在跑同一条查询:5821、5823、5825 都在执行 SELECT * FROM orders WHERE status = 'pen...,显然是同一条查询的重复执行,而且运行得很慢(Time 在 30–47 秒之间)。这是模式一:一条慢查询被多个并发请求反复命中。触发它的那个端点就是元凶。

第二,Sleep 状态且 Time 很大的连接:5822、5824 处于 Sleep 状态已经 98–121 秒。Sleep 意味着连接打开着但没有查询在跑。一条 Sleep 长达 2 分钟的连接,通常要么是事务在等非数据库工作完成(模式二),要么是工作进程忘记释放的泄漏连接(模式三)。

第三,Host10.0.1.12:52344 告诉你这条连接来自哪台 PHP 服务器和哪个临时端口。这是把连接回溯到具体 PHP-FPM 工作进程的关键线索(第二步会用到)。

常用的跟进查询:

sql
-- 当前最慢的 10 条查询
SELECT Id, User, Host, db, Command, Time, State, LEFT(Info, 100) AS Query
FROM information_schema.PROCESSLIST
WHERE Command != 'Sleep'
ORDER BY Time DESC
LIMIT 10;

-- 闲置过久的连接
SELECT Id, User, Host, db, Time AS idle_seconds
FROM information_schema.PROCESSLIST
WHERE Command = 'Sleep' AND Time > 60
ORDER BY Time DESC;

-- 按客户端 IP 汇总连接数,看哪台应用服务器是重灾区
SELECT SUBSTRING_INDEX(Host, ':', 1) AS client_ip, COUNT(*) AS conn_count
FROM information_schema.PROCESSLIST
GROUP BY client_ip
ORDER BY conn_count DESC;

-- 按查询指纹汇总正在跑的连接数
SELECT LEFT(Info, 60) AS query_pattern, COUNT(*) AS running_count
FROM information_schema.PROCESSLIST
WHERE Info IS NOT NULL
GROUP BY query_pattern
ORDER BY running_count DESC;

最后那条尤其重要。如果看到 SELECT * FROM orders WHERE status = 'pending' 对应的 running_count 是 47,就意味着在此刻这一条慢查询正占用着 47 条连接。找到发起这条查询的端点即可。

Postgres:pg_stat_activity

Postgres 的对应工具是 pg_stat_activity——一张视图,能把每条连接的信息和盘托出:

sql
SELECT pid, usename, client_addr, client_port, state, query_start, state_change,
       NOW() - query_start AS query_age,
       LEFT(query, 100) AS query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY query_start
LIMIT 20;

输出:

pid  | usename | client_addr | client_port |        state        |       query_start       |     query_age     |                                                query
-------+---------+-------------+-------------+---------------------+-------------------------+-------------------+-----------------------------------------------------
 28341 | app     | 10.0.1.12   |       52344 | active              | 2026-04-22 14:32:18.14  | 00:00:47.231      | SELECT * FROM orders WHERE status = 'pending' AND cr
 28342 | app     | 10.0.1.13   |       33102 | active              | 2026-04-22 14:32:20.82  | 00:00:44.421      | SELECT * FROM orders WHERE status = 'pending' AND cr
 28343 | app     | 10.0.1.12   |       52346 | idle in transaction | 2026-04-22 14:31:02.01  | 00:02:05.101      | INSERT INTO orders (user_id, total, ...

Postgres 里最值得警惕的是 idle in transaction 状态。它意味着连接开启了事务、执行了若干语句,之后就一直闲置在那里——持有锁、阻塞 autovacuum、占用连接槽——而应用却在做与数据库无关的事。

定位最严重的那一批:

sql
-- 开启了事务却处于空闲的连接
SELECT pid, usename, client_addr, state,
       NOW() - xact_start AS transaction_age,
       NOW() - state_change AS idle_duration,
       LEFT(query, 100) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;

-- 按客户端 IP 汇总连接数
SELECT client_addr, count(*)
FROM pg_stat_activity
GROUP BY client_addr
ORDER BY count DESC;

-- 跑得最久的活跃查询
SELECT pid, usename, client_addr,
       NOW() - query_start AS duration,
       LEFT(query, 200) AS query
FROM pg_stat_activity
WHERE state = 'active' AND NOW() - query_start > interval '5 seconds'
ORDER BY duration DESC;

idle_duration 达到 2 分钟的事务,几乎可以断定是模式二——应用正在开着事务做非数据库工作。last_query 列会显示这条连接进入空闲前最后执行的语句,常常已足以锁定具体代码路径。

第二步,把数据库连接对应到具体的 PHP-FPM 工作进程

既然已知连接 5821 在跑慢查询,且来自 10.0.1.12:52344,那这究竟对应哪一个 PHP-FPM 工作进程?

在 PHP 服务器 10.0.1.12 上执行:

bash
ss -tnp | grep ':52344'

输出:

ESTAB  0  0  10.0.1.12:52344  10.0.1.42:3306  users:(("php-fpm",pid=23891,fd=7))

工作进程就是它:PID 23891,MySQL 连接对应文件描述符 7。

有了这个 PID,可以做三件事。

第一,看它正在处理哪次请求。 如果开启了 PHP-FPM 的状态页,上面会列出每个活跃工作进程及其当前请求 URL:

bash
# /status 端点在 FPM 中开启后:
curl -s "http://localhost/status?full" | grep -A 20 "pid: 23891"

输出:

pid:                  23891
state:                Running
start time:           22/Apr/2026:14:31:31 +0000
start since:          52
requests:             1847
request duration:     47321ms
request method:       POST
request URI:          /api/orders/search
content length:       1245
user:                 -
script:               /var/www/current/public/index.php
last request cpu:     94.32
last request memory:  67108864

答案就在这里:一次 POST 到 /api/orders/search 的请求已经跑了 47 秒。它就是攥住连接不放的端点。

要开启 FPM 状态页,在 pool 配置里加上:

ini
; /etc/php/8.3/fpm/pool.d/www.conf
pm.status_path = /status
ping.path = /ping

再在 nginx 配置中开放:

nginx
location ~ ^/(status|ping)$ {
    allow 127.0.0.1;
    deny all;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

这是 PHP-FPM 中最被忽视的功能之一,建议在每一台生产服务器都启用。

第二,用 strace 看看它到底在做什么。 想确认工作进程是在等数据库而不是其它动作:

bash
sudo strace -p 23891 -s 200

大致会看到:

poll([{fd=7, events=POLLIN}], 1, -1

被 fd 7(MySQL 连接)阻塞,无限期等待。确认:这个工作进程除了等数据库外什么都没做。

第三,检查它打开了哪些连接。 要查看该工作进程持有的全部文件描述符与网络连接:

bash
ls -l /proc/23891/fd/ | grep socket
ss -tp | grep 'pid=23891'

如果这个进程打开了 3 条 MySQL 连接而实际请求只需要 1 条,那就说明代码在不必要的地方开了多余的连接。

第三步,把查询钉到确切的请求(Laravel)

数据库能给出查询,FPM 能给出端点。但同一个端点可能一次发出十几条查询——究竟是哪一条慢?事件过后还需要翻日志复盘时,更需要一条能把查询与请求长期绑定起来的信息。

答案是给查询日志打上请求级关联 ID。两个小中间件加一个事件监听器,之后任何"这条查询是哪个请求发出的"都能一键检索。

请求 ID 中间件

php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class AttachRequestId
{
    public function handle(Request $request, Closure $next)
    {
        // 优先使用负载均衡器透传的 X-Request-Id,否则生成一个
        $requestId = $request->headers->get('X-Request-Id') ?? (string) Str::uuid();

        // 让整个请求周期都能取到
        $request->attributes->set('request_id', $requestId);
        \Log::withContext(['request_id' => $requestId]);

        $response = $next($request);

        // 回写到响应头,方便客户端关联
        $response->headers->set('X-Request-Id', $requestId);

        return $response;
    }
}

bootstrap/app.php(Laravel 11+)或 Kernel.php(Laravel 10)中注册为全局中间件:

php
->withMiddleware(function (Middleware $middleware) {
    $middleware->prepend(AttachRequestId::class);
})

查询日志监听器

在一个服务提供者中监听每一条查询,并带上上下文记录:

php
<?php

namespace App\Providers;

use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;

class QueryLoggingServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // 测试或 CLI 下默认关闭,避免日志量爆炸
        if (! config('app.log_queries', false)) {
            return;
        }

        DB::listen(function (QueryExecuted $query) {
            // 生产环境只记录慢查询,否则日志量会过大
            if ($query->time < 100) {   // 毫秒
                return;
            }

            Log::channel('queries')->warning('slow_query', [
                'sql'        => $query->sql,
                'bindings'   => $query->bindings,
                'time_ms'    => $query->time,
                'connection' => $query->connectionName,
            ]);
        });
    }
}

中间件里的 Log::withContext(['request_id' => ...]) 意味着这次请求内的每一条日志——包括 DB::listen 触发的那些——都会自动带上请求 ID。配合 JSON 日志格式,日志看起来是这样:

json
{"message":"slow_query","level":"warning","request_id":"a1b2c3d4","sql":"SELECT * FROM orders WHERE status = ?","bindings":["pending"],"time_ms":4721}
{"message":"slow_query","level":"warning","request_id":"a1b2c3d4","sql":"SELECT * FROM order_items WHERE order_id IN (?,?,?,...)","bindings":[1,2,3,...],"time_ms":892}

"这条查询来自哪个请求"此时变成一次普通的日志检索。按 request_id 分组就能拿到某次请求里按顺序执行的全部查询及其耗时。

专用的 queries 日志通道

config/logging.php

php
'channels' => [
    // ...现有通道...

    'queries' => [
        'driver' => 'daily',
        'path'   => storage_path('logs/queries.log'),
        'level'  => 'warning',
        'days'   => 7,
        // JSON 格式,方便 grep/jq 处理:
        'formatter' => \Monolog\Formatter\JsonFormatter::class,
    ],
],

.env 中启用:

LOG_QUERIES=true

下次事故来临时,可以在几秒钟内完成关联分析:

bash
# 查看某个问题请求执行了哪些查询
grep 'a1b2c3d4' storage/logs/queries-2026-04-22.log | jq

# 查看过去一小时里全局最慢的 20 条查询
grep -h 'slow_query' storage/logs/queries-2026-04-22.log | \
    jq -r '[.time_ms, .request_id, .sql] | @tsv' | \
    sort -rn | head -20

第四步,定位到具体的代码路径

查询、端点都有了,接下来要找到发起这条 SQL 的那一行 PHP 代码。

事故中临时可用的最低成本手段,是给查询日志加上一段回溯栈,只开启几分钟:

php
DB::listen(function (QueryExecuted $query) {
    if ($query->time < 1000) {  // 只追真正的慢查询
        return;
    }

    $trace = collect(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 20))
        ->filter(fn($f) => isset($f['file']) && ! str_contains($f['file'], '/vendor/'))
        ->map(fn($f) => ($f['file'] ?? '?') . ':' . ($f['line'] ?? '?'))
        ->values()
        ->all();

    Log::warning('slow_query_with_trace', [
        'sql'       => $query->sql,
        'time_ms'   => $query->time,
        'app_trace' => $trace,
    ]);
});

过滤掉 /vendor/ 目录至关重要,否则栈信息会被 Eloquent 和 PDO 的内部调用塞满,对定位没有帮助。只保留应用自身的调用帧,就能得到一段干净的调用栈:

json
"app_trace": [
    "/var/www/current/app/Services/OrderSearchService.php:47",
    "/var/www/current/app/Http/Controllers/OrderController.php:128",
    "/var/www/current/app/Http/Middleware/CacheHeaders.php:22"
]

栈顶那一帧——OrderSearchService.php:47——正是慢查询被发起的位置。一行指针,直接指到 bug。

排查结束后请关闭。每条查询都采回溯栈的代价很高,会明显拖慢生产环境。

一次真正修复的剖析

查询、端点、代码行都定位到了,接下来怎么改?

修复思路对应前述三种模式。下面分别给出代码形态。

修复模式一,长查询

某个仪表盘端点在每次访问时都会跑这样一段代码:

php
// 改造前——无界扫描,没有时间上限
public function dashboardMetrics() {
    $pending = Order::where('status', 'pending')
        ->where('created_at', '>', now()->subDays(30))
        ->get();   // 可能是十万条

    return [
        'pending_count' => $pending->count(),
        'pending_total' => $pending->sum('total'),
    ];
}

两个问题:把十万行加载进 PHP 内存,以及查询没有执行时间上限。修复如下:

php
public function dashboardMetrics() {
    // 把聚合下推到数据库——返回一行而不是十万行
    $stats = DB::table('orders')
        ->where('status', 'pending')
        ->where('created_at', '>', now()->subDays(30))
        ->selectRaw('COUNT(*) as pending_count, COALESCE(SUM(total), 0) as pending_total')
        ->first();

    return [
        'pending_count' => $stats->pending_count,
        'pending_total' => $stats->pending_total,
    ];
}

并在 database.php 里为连接加上服务器端超时:

php
'mysql' => [
    // ...
    'options' => [
        PDO::ATTR_TIMEOUT => 5,
    ],
],

对于 MySQL 5.7.8+,还可以在会话级别启用单条查询的执行时间上限。Laravel 没有现成的钩子,但可以在 Illuminate\Database\Events\ConnectionEstablished 事件监听器里执行 SET SESSION MAX_EXECUTION_TIME = 10000 实现。核心思想是:在数据库层给每条查询加上硬性上限,避免任何单条查询把连接拖住超过 10 秒。

修复模式二,事务包住了慢的非数据库工作

教科书级的例子——结账端点:

php
// 改造前——事务里套着一次 HTTP 调用
public function checkout(Request $request) {
    return DB::transaction(function () use ($request) {
        $order = Order::create([...]);

        // 在事务内部调用支付网关
        $charge = $this->stripe->charge($order->total, $request->token);   // 2–8 秒

        $order->update(['stripe_charge_id' => $charge->id, 'status' => 'paid']);

        return $order;
    });
}

整笔事务的生命周期被支付网关的一次往返拉长。并发高时,每一次结账都会占住一条连接好几秒而不做数据库相关的事。修复:

php
public function checkout(Request $request) {
    // 阶段一:短事务,先以 pending 状态落库
    $order = DB::transaction(function () use ($request) {
        return Order::create([
            'status' => 'pending_payment',
            'total'  => $request->total,
            // ...
        ]);
    });

    // 阶段二:慢动作完全落在事务之外
    try {
        $charge = $this->stripe->charge($order->total, $request->token);
    } catch (StripeException $e) {
        // 失败时不能留下孤儿订单
        $order->update(['status' => 'payment_failed']);
        throw $e;
    }

    // 阶段三:短事务,标记为已支付
    DB::transaction(function () use ($order, $charge) {
        $order->update([
            'stripe_charge_id' => $charge->id,
            'status' => 'paid',
        ]);
    });

    return $order;
}

两个短事务,支付过程中不再持有任何事务。连接只被持有几毫秒而不是几秒。同样规模的连接池现在能够支撑上百倍的结账吞吐。

修复模式三,长驻工作进程里的连接泄漏

这是最隐蔽的一类问题,只有在 Laravel Horizon、Octane、Swoole 等"一个 PHP 进程连续处理多个任务"的场景里才会显现。

一个手动创建 PDO 连接的队列任务:

php
// 反模式——每次执行新建一条连接,从不释放
class SyncInventoryJob implements ShouldQueue {
    public function handle() {
        $pdo = new PDO(
            'mysql:host=reporting-db;dbname=reports',
            config('services.reporting.user'),
            config('services.reporting.pass')
        );

        $pdo->query('...')->fetchAll();

        // $pdo 离开作用域后,在长驻工作进程中
        // 垃圾回收不一定立刻关闭它;
        // 在 PDO::ATTR_PERSISTENT 开启时,可能根本不会关闭。
    }
}

每次执行都新建一条连接。持续一小时之后,工作进程就积攒了成千上万条连接——其中不少仍被 PDO 驱动内部的连接池持有着。

修复方式:复用 Laravel 的连接管理器,它会为每个工作进程按配置去重:

php
class SyncInventoryJob implements ShouldQueue {
    public function handle() {
        // 使用共享的连接管理器——每个工作进程每个配置只一条连接
        $rows = DB::connection('reporting')->select('...');
    }
}

并在 config/database.php 中配置 reporting 连接:

php
'reporting' => [
    'driver'   => 'mysql',
    'host'     => env('REPORTING_DB_HOST'),
    'database' => env('REPORTING_DB_NAME'),
    'username' => env('REPORTING_DB_USER'),
    'password' => env('REPORTING_DB_PASS'),
    'options'  => [
        // 明确不使用持久连接——工作进程重启时的清理更安全
        PDO::ATTR_PERSISTENT => false,
        PDO::ATTR_TIMEOUT    => 5,
    ],
],

针对 Octane/Swoole 场景,如果工作负载需要,可以在请求之间主动 DB::disconnect()

php
// 在 Octane 的请求生命周期钩子中:
Octane::tick('db-cycle', function () {
    DB::disconnect();   // 定期强制使用新连接
})->seconds(300)->immediate();

持久连接的陷阱

有一件事值得单独强调,因为它把每个初次接触的团队都绊过一次。

PDO 有一个选项叫 PDO::ATTR_PERSISTENT,常被宣传为性能优化——"复用连接,不用每次都新建"。在动辄数百个工作进程的 PHP-FPM 场景里,这通常是个陷阱。

原因如下:持久连接寄生于 PHP-FPM 的进程池中。假设有 100 个工作进程,每个进程跨 3 个数据库配置各持有 5 条持久连接,数据库侧就会多达 1,500 条连接——其中大部分空闲,每一条都在占用数据库服务器的内存。

更糟的是,PHP 并不能可靠地重置持久连接上的会话状态。上一条请求留下的 prepared statement、临时表、用户变量都可能"遗传"给后续请求。这种跨请求污染调起来简直是灾难。

经验法则:

  • 传统 PHP-FPM 处理 Web 请求时,不要开启 PDO::ATTR_PERSISTENT。让 PHP 在请求结束时正常关闭连接。需要跨请求复用时,使用真正的连接池中间件——MySQL 用 ProxySQL,Postgres 用 PgBouncer。
  • **长驻工作进程(Octane、高并发下的 Horizon)**里,进程本身就是长生命周期,连接天然被复用。此时持久连接是多余的,只会带来跨请求状态污染的风险,不带来任何收益。
  • 长时间运行的 CLI 脚本也许可以考虑,但请先度量。多数情况并不划算。

就 MySQL 而言,ProxySQL 的连接池能力远胜 PDO 的持久模式,因为它理解 MySQL 的会话状态重置语义。架构大致如此:

[PHP workers] --brief--> [ProxySQL] --pooled--> [MySQL]
      (1000+ workers)       (shared)            (50 real conns)

每个 PHP 工作进程与 ProxySQL 建立一条轻量"前端"连接,ProxySQL 在后端维护一个规模不大、状态健康的真实连接池。在不改代码的情况下,数据库端常常能节省 80% 以上的内存。

预防下一次事故

在下一次"连接耗尽"的告警半夜把人叫醒之前,有几件事值得提前做好。

第一,对连接数告警,而不是只对报错告警。 如果连接池日常使用率在 30%,在 70% 时就要告警。等打到 100% 再感知就已经进入故障了。Prometheus 写法示例:

# MySQL
rate(mysql_global_status_threads_connected[5m]) / mysql_global_variables_max_connections

# Postgres
pg_stat_database_numbackends / pg_settings_max_connections

第二,到处都要有硬超时。 连接超时、查询超时、HTTP 客户端超时。PHP 发出的每一次对外调用都应该有边界。"无限等待"就是连接池被吃光的根本原因。

第三,启用慢查询日志。 MySQL 的 slow_query_log 配合 long_query_time = 1,能在候选慢查询成为事故之前把它们捞出来。Postgres 对应的是 log_min_duration_statement = 1000

第四,把连接数当作核心 KPI。 任何展示请求速率和错误率的看板,都应该同时展示当前数据库连接数。如果请求速率平稳、连接数却在持续增长,说明有泄漏。

第五,演练诊断流程。 在平静的日子里跑一跑 SHOW PROCESSLIST,看看常态流量下连接清单长什么样。熟悉"健康"的样子,真正出事时才能迅速识别"异常"。

简明 Q&A

Q:是不是必须拿到数据库的 root 权限?
不必。MySQL 上需要一个带 PROCESS 权限的账户(GRANT PROCESS ON *.* TO 'monitor'@'%';),Postgres 上需要 pg_read_all_stats 角色。大多数团队会专门建一个只读监控账户做这件事。不要用应用账户——它本不该拥有这些特权。

Q:应用层已经在 PHP 前挂了 HikariCP 或连接池,会改变流程吗?
PHP 通常不走应用层连接池(那是 Java/JVM 的常见模式)。如果用的是 ProxySQL 或 PgBouncer,诊断思路是一致的,只是连接会显示在池中间件那边。ProxySQL 上的 SHOW PROCESSLIST 看到的是 PHP 侧的连接,MySQL 上的 SHOW PROCESSLIST 看到的是池背后的少量真实连接。

Q:还需要显式调用 mysqli_close() 吗?
在传统 PHP-FPM 请求里不需要——请求结束连接就会释放。在长驻进程(Artisan 命令、队列工作进程、Octane)里,手动打开的连接需要显式关闭。DB:: 创建出来的连接交给 Laravel 的连接管理器即可,它会正确清理。

Q:能不能直接把 max_connections 设成 2000 一劳永逸?
短期可以,长期不行。每条 MySQL 连接大约占用 1–2MB 内存(空闲时)。2000 条连接意味着 2–4GB 的内存被空守着消耗。不久之后会碰上新的上限——内存——甚至连看板查询都会因为数据库内存告急而报错。请修复真正占着连接的查询或事务。

Q:MySQL 的 Sleep 与 Postgres 的 idle 有什么差别?
非常接近——都表示连接已建立、已认证,但没有查询在跑。Postgres 多出一个 idle in transaction,语义更强:事务仍然开着却什么都没做。这个区分很有用——idle in transaction 几乎必然是 bug,而 MySQL 的 Sleep 对等待下一个任务的工作进程来说可能是正常的。

Q:持久连接与 Laravel 的连接管理器如何协同?
Laravel 默认不使用持久连接。如果在 database.php 中设置了 PDO::ATTR_PERSISTENT => true,Laravel 的 DB::disconnect() 也未必能彻底关闭它(最终取决于 PDO 的行为,而 PDO 会把连接留在 FPM 工作进程的持久池里)。这种错配正是"Laravel 应用为什么会打开十倍应有连接"的经典原因。

结语

"连接太多"这类问题几乎从来不是数量问题,而是"每条连接被谁、因何、占了多久"的问题。回答这三个问题所需的工具都已经在既有技术栈里——SHOW PROCESSLIST、FPM 状态页、DB::listenstrace——其中任何一个都不依赖商用看板或 APM 订阅。

处理这一类事故游刃有余的团队,靠的不是更大的连接池,而是在查询、请求与应用代码之间建立起一条稳固的关联链。一旦有了这条关联,后续每次事故都从小时级定位变成分钟级定位。修复动作也几乎总是很小:加一个超时、缩短一个事务、移除一条根本不该被创建的连接。

在需要之前就把工具链建好。当下一次凌晨三点再收到"数据库连接耗尽"的告警时,打开 IDE 前就能明确知道要找哪个 file:line

速查卡

数据库侧诊断:

sql
-- MySQL:连接都在被谁占?
SHOW FULL PROCESSLIST;
SELECT * FROM information_schema.PROCESSLIST ORDER BY Time DESC LIMIT 10;

-- Postgres:连接都在被谁占?
SELECT pid, client_addr, state, NOW()-query_start AS age, LEFT(query, 100)
FROM pg_stat_activity WHERE state != 'idle' ORDER BY query_start;

把连接回溯到 PHP 工作进程:

bash
# 数据库侧:拿到客户端 IP 与端口
# PHP 服务器侧:
ss -tnp | grep ':52344'        # 返回 PHP-FPM 工作进程 PID
curl -s "localhost/status?full" | grep -A 20 "pid: 23891"

Laravel 侧的两个小文件:

php
// 中间件:注入请求 ID
$requestId = $request->headers->get('X-Request-Id') ?? (string) Str::uuid();
Log::withContext(['request_id' => $requestId]);

// 服务提供者:记录慢查询
DB::listen(function ($q) {
    if ($q->time > 100) Log::channel('queries')->warning('slow', [
        'sql' => $q->sql, 'time_ms' => $q->time,
    ]);
});

MySQL 会话级查询时间上限:

sql
SET SESSION MAX_EXECUTION_TIME = 10000;  -- 10 秒,单位毫秒

三种模式速查:

  • 多条连接跑同一条慢查询 → 模式一,长查询 → 把工作下推到数据库,加 MAX_EXECUTION_TIME
  • 连接卡在 Sleepidle in transaction → 模式二,长事务 → 拆成短事务,非数据库工作挪到事务外
  • 连接数随工作进程生命周期持续增长 → 模式三,连接泄漏 → 使用 DB::connection(),避免 PDO::ATTR_PERSISTENT
本作品采用《CC 协议》,转载必须注明作者和本文链接