精准定位耗尽数据库连接的 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 PROCESSLIST与pg_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:
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 分钟的连接,通常要么是事务在等非数据库工作完成(模式二),要么是工作进程忘记释放的泄漏连接(模式三)。
第三,Host 列:10.0.1.12:52344 告诉你这条连接来自哪台 PHP 服务器和哪个临时端口。这是把连接回溯到具体 PHP-FPM 工作进程的关键线索(第二步会用到)。
常用的跟进查询:
-- 当前最慢的 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——一张视图,能把每条连接的信息和盘托出:
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、占用连接槽——而应用却在做与数据库无关的事。
定位最严重的那一批:
-- 开启了事务却处于空闲的连接
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 上执行:
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:
# /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 配置里加上:
; /etc/php/8.3/fpm/pool.d/www.conf
pm.status_path = /status
ping.path = /ping再在 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 看看它到底在做什么。 想确认工作进程是在等数据库而不是其它动作:
sudo strace -p 23891 -s 200大致会看到:
poll([{fd=7, events=POLLIN}], 1, -1被 fd 7(MySQL 连接)阻塞,无限期等待。确认:这个工作进程除了等数据库外什么都没做。
第三,检查它打开了哪些连接。 要查看该工作进程持有的全部文件描述符与网络连接:
ls -l /proc/23891/fd/ | grep socket
ss -tp | grep 'pid=23891'如果这个进程打开了 3 条 MySQL 连接而实际请求只需要 1 条,那就说明代码在不必要的地方开了多余的连接。
第三步,把查询钉到确切的请求(Laravel)
数据库能给出查询,FPM 能给出端点。但同一个端点可能一次发出十几条查询——究竟是哪一条慢?事件过后还需要翻日志复盘时,更需要一条能把查询与请求长期绑定起来的信息。
答案是给查询日志打上请求级关联 ID。两个小中间件加一个事件监听器,之后任何"这条查询是哪个请求发出的"都能一键检索。
请求 ID 中间件
<?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)中注册为全局中间件:
->withMiddleware(function (Middleware $middleware) {
$middleware->prepend(AttachRequestId::class);
})查询日志监听器
在一个服务提供者中监听每一条查询,并带上上下文记录:
<?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 日志格式,日志看起来是这样:
{"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:
'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下次事故来临时,可以在几秒钟内完成关联分析:
# 查看某个问题请求执行了哪些查询
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 代码。
事故中临时可用的最低成本手段,是给查询日志加上一段回溯栈,只开启几分钟:
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 的内部调用塞满,对定位没有帮助。只保留应用自身的调用帧,就能得到一段干净的调用栈:
"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。
排查结束后请关闭。每条查询都采回溯栈的代价很高,会明显拖慢生产环境。
一次真正修复的剖析
查询、端点、代码行都定位到了,接下来怎么改?
修复思路对应前述三种模式。下面分别给出代码形态。
修复模式一,长查询
某个仪表盘端点在每次访问时都会跑这样一段代码:
// 改造前——无界扫描,没有时间上限
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 内存,以及查询没有执行时间上限。修复如下:
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 里为连接加上服务器端超时:
'mysql' => [
// ...
'options' => [
PDO::ATTR_TIMEOUT => 5,
],
],对于 MySQL 5.7.8+,还可以在会话级别启用单条查询的执行时间上限。Laravel 没有现成的钩子,但可以在 Illuminate\Database\Events\ConnectionEstablished 事件监听器里执行 SET SESSION MAX_EXECUTION_TIME = 10000 实现。核心思想是:在数据库层给每条查询加上硬性上限,避免任何单条查询把连接拖住超过 10 秒。
修复模式二,事务包住了慢的非数据库工作
教科书级的例子——结账端点:
// 改造前——事务里套着一次 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;
});
}整笔事务的生命周期被支付网关的一次往返拉长。并发高时,每一次结账都会占住一条连接好几秒而不做数据库相关的事。修复:
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 连接的队列任务:
// 反模式——每次执行新建一条连接,从不释放
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 的连接管理器,它会为每个工作进程按配置去重:
class SyncInventoryJob implements ShouldQueue {
public function handle() {
// 使用共享的连接管理器——每个工作进程每个配置只一条连接
$rows = DB::connection('reporting')->select('...');
}
}并在 config/database.php 中配置 reporting 连接:
'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():
// 在 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::listen、strace——其中任何一个都不依赖商用看板或 APM 订阅。
处理这一类事故游刃有余的团队,靠的不是更大的连接池,而是在查询、请求与应用代码之间建立起一条稳固的关联链。一旦有了这条关联,后续每次事故都从小时级定位变成分钟级定位。修复动作也几乎总是很小:加一个超时、缩短一个事务、移除一条根本不该被创建的连接。
在需要之前就把工具链建好。当下一次凌晨三点再收到"数据库连接耗尽"的告警时,打开 IDE 前就能明确知道要找哪个 file:line。
速查卡
数据库侧诊断:
-- 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 工作进程:
# 数据库侧:拿到客户端 IP 与端口
# PHP 服务器侧:
ss -tnp | grep ':52344' # 返回 PHP-FPM 工作进程 PID
curl -s "localhost/status?full" | grep -A 20 "pid: 23891"Laravel 侧的两个小文件:
// 中间件:注入请求 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 会话级查询时间上限:
SET SESSION MAX_EXECUTION_TIME = 10000; -- 10 秒,单位毫秒三种模式速查:
- 多条连接跑同一条慢查询 → 模式一,长查询 → 把工作下推到数据库,加
MAX_EXECUTION_TIME - 连接卡在
Sleep或idle in transaction→ 模式二,长事务 → 拆成短事务,非数据库工作挪到事务外 - 连接数随工作进程生命周期持续增长 → 模式三,连接泄漏 → 使用
DB::connection(),避免PDO::ATTR_PERSISTENT