一次真实的视频平台故障
凌晨2点37分,我的手机连续弹出17条告警。平台用户反馈视频加载转圈,有的直接白屏。登录服务器看一眼,CPU 12%,内存还剩6GB,MySQL进程还活着——表面看一切正常。
但用户就是看不了视频。这就是最麻烦的故障类型:资源充足,服务"正常",体验全崩。
花了一个通宵才定位到根因。这个过程中踩了无数坑,也把操作系统、网络、数据库三块基础从理论到实践彻底串了一遍。这篇文章完整记录整个过程,代码和命令都能直接用。
环境说明:Ubuntu 22.04 LTS,Nginx 1.24.0,PHP 8.3.3(FPM),MySQL 8.0.35,视频文件存储在本地磁盘,通过PHP读取文件流输出。压力测试工具使用wrk 4.2.0和ab 2.3。
问题复盘:症状与初步判断
我把用户端的表现汇总了一下:
| 时间段 | 症状 | 并发量 |
|---|---|---|
| 22:00前 | 正常 | 平均 300 |
| 22:00-23:00 | 视频加载明显变慢 | 平均 800 |
| 23:00后 | 大量白屏、加载失败 | 峰值 2000+ |
初步排查步骤:
# 1. 检查系统负载,发现CPU、内存都不高
uptime
free -h
# 2. 检查Nginx错误日志,有零星"upstream timed out"
tail -f /var/log/nginx/error.log
# 3. 检查PHP-FPM慢日志,发现大量视频接口超时
tail -f /var/log/php8.3-fpm.log
# 4. 查看当前TCP连接状态
ss -s
几个命令下来,发现一个关键数据:TCP连接数有1.8万个,其中ESTAB状态1.2万,TIME_WAIT状态5000多。系统的net.ipv4.tcp_max_syn_backlog默认是1024,而somaxconn也是4096。也就是说,TCP层的握手队列已经塞满了。
方案对比:两种排障思路
当时团队内部出了两个排查方向。我直接说结论:方案A是大多数人的本能反应,方案B才是正确的系统化做法。
方案A:哪里痛医哪里
看到TCP连接数高,第一反应是调大somaxconn和syn_backlog;看到PHP-FPM慢,就调大pm.max_children;看到Nginx超时就把proxy_read_timeout从30秒调到60秒。一顿操作猛如虎,服务重启后能撑20分钟,然后继续崩。
这种"头痛医头"的思路本质上没有定位到瓶颈,只是不断地把报警阈值往后挪。我实测过,光是调大这些参数,在2000并发下只延后了约11分钟崩溃(见后面的压测数据)。
方案B:沿请求链路逐层排查
正确做法是顺着一个请求的完整路径走一遍:
客户端 → Nginx(网络层) → PHP-FPM(应用层) → MySQL(数据库层) → 磁盘I/O(操作系统层)
每一层只收集三个数据:队列长度(是否有堆积)、响应时间(是否变慢)、错误计数(是否报错)。哪一层的数据突破了基线,就是瓶颈所在。
视频请求的链路比较特殊:视频文件本身不走MySQL,走的是磁盘I/O。MySQL只存储视频的元数据(标题、URL、转码状态)。所以这个案例里,数据库和磁盘是两个潜在瓶颈,而TCP连接堆积是上游瓶颈的"结果",不是"原因"。
三层排查与完整代码实现
下面是我实际执行的完整排查流程。每一步都有代码、有命令、有数据。
第一步:操作系统层的TCP参数排查
先从TCP层开始。刚才看到ss -s的结果,TCP连接数异常高,先看具体是哪些连接。
# 查看所有ESTABLISHED连接按远程IP分组
ss -tan state established | awk '{print $4}' | sort | uniq -c | sort -rn | head -20
# 查看TIME_WAIT连接数(可能是连接未正常关闭)
ss -tan state time-wait | wc -l
# 查看当前内核TCP参数
sysctl net.ipv4.tcp_max_syn_backlog
sysctl net.core.somaxconn
sysctl net.ipv4.ip_local_port_range
sysctl net.ipv4.tcp_tw_reuse
结果很意外:ESTABLISHED连接里,有8000多个来自同一个IP段——是CDN的回源请求。正常情况下CDN回源连接是长连接,不会累积到这么多。这说明CDN回源请求全部堆积在Nginx等待响应。
那Nginx为什么不响应?继续往下一层查。
第二步:网络层的I/O模型排查
检查Nginx的工作模式。我用的是epoll事件驱动模型,正常情况下撑1万连接没问题。但注意一个细节:Nginx的worker_connections配置是1024,而worker_processes是4,总共最大并发连接数只有4096。
# 查看Nginx配置
grep -r "worker_processes\|worker_connections" /etc/nginx/nginx.conf
结果:
# /etc/nginx/nginx.conf 关键配置
worker_processes 4;
events {
worker_connections 1024;
}
算一下:4 × 1024 = 4096 最大连接数。当同时有2000个CDN回源请求 + 几百个用户直接请求时,连接数接近打满。再加上PHP-FPM处理慢,每个连接占用的时间变长,连接很快就耗尽。
但这不是根因。根因是PHP-FPM响应为什么慢。
第三步:数据库层的连接池与慢查询分析
打开MySQL的慢查询日志,这次终于看到问题所在。
# 先开启慢查询日志(生产环境慎用,会影响性能)
mysql -uroot -p -e "
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow-queries.log';
"
等待5分钟后查看慢查询日志:
# 统计慢查询中出现最多的SQL
mysqldumpslow /var/log/mysql/slow-queries.log | head -30
结果触目惊心。一个查询视频元数据的SQL平均耗时2.8秒,执行了600多次。这条SQL长这样:
SELECT v.id, v.title, v.url, v.duration, v.status,
c.name as category_name, u.nickname as uploader_name
FROM videos v
LEFT JOIN categories c ON v.category_id = c.id
LEFT JOIN users u ON v.uploader_id = u.id
WHERE v.status = 1
AND v.id NOT IN (SELECT video_id FROM banned_videos WHERE reason = 'copyright')
ORDER BY v.created_at DESC
LIMIT 20;
问题很明显:
NOT IN子查询每次执行都扫描整张banned_videos表,这张表有8万行ORDER BY v.created_at DESC没有走索引,产生文件排序- 四个表JOIN且缺少统计信息,MySQL选择了错误的执行计划
用EXPLAIN确认:
EXPLAIN
SELECT v.id, v.title, v.url, v.duration, v.status,
c.name as category_name, u.nickname as uploader_name
FROM videos v
LEFT JOIN categories c ON v.category_id = c.id
LEFT JOIN users u ON v.uploader_id = u.id
WHERE v.status = 1
AND v.id NOT IN (SELECT video_id FROM banned_videos WHERE reason = 'copyright')
ORDER BY v.created_at DESC
LIMIT 20;
关键指标:
| 表 | type | rows | Extra |
|---|---|---|---|
| videos | ALL | 850000 | Using filesort |
| categories | eq_ref | 1 | NULL |
| users | eq_ref | 1 | NULL |
| banned_videos | ALL | 80000 | Using where |
找到了:videos表全表扫描 + 文件排序 + 80万行逐行NOT IN子查询,这SQL不慢才怪。
再看PHP-FPM,每个请求进来都需要执行这条SQL,一个请求2.8秒,PHP-FPM的pm.max_children只有20,那每秒只能处理7个请求。但CDN回源请求每秒进来100+,连接全部堆积。
第四步:优化SQL与索引
把NOT IN改成LEFT JOIN + IS NULL,同时给created_at加索引:
-- 添加索引
ALTER TABLE videos ADD INDEX idx_status_created (status, created_at DESC);
-- 重写后的SQL
SELECT v.id, v.title, v.url, v.duration, v.status,
c.name as category_name, u.nickname as uploader_name
FROM videos v
LEFT JOIN categories c ON v.category_id = c.id
LEFT JOIN users u ON v.uploader_id = u.id
LEFT JOIN banned_videos b ON b.video_id = v.id AND b.reason = 'copyright'
WHERE v.status = 1
AND b.id IS NULL
ORDER BY v.created_at DESC
LIMIT 20;
再次EXPLAIN:
EXPLAIN
SELECT v.id, v.title, v.url, v.duration, v.status,
c.name as category_name, u.nickname as uploader_name
FROM videos v
LEFT JOIN categories c ON v.category_id = c.id
LEFT JOIN users u ON v.uploader_id = u.id
LEFT JOIN banned_videos b ON b.video_id = v.id AND b.reason = 'copyright'
WHERE v.status = 1
AND b.id IS NULL
ORDER BY v.created_at DESC
LIMIT 20;
这次的结果:
| 表 | type | rows | Extra |
|---|---|---|---|
| videos | range | 842 | Using index condition |
| categories | eq_ref | 1 | NULL |
| users | eq_ref | 1 | NULL |
| banned_videos | ref | 1 | Using where; Using index |
扫描行数从93万降到845,文件排序消失。优化后这条SQL的执行时间从2.8秒降到0.03秒。
第五步:视频文件读取的I/O优化
SQL搞定后,还有一块大头——视频文件本身的读取。原来PHP是通过readfile()把整个文件读进内存再输出:
// 优化前:这种方式会占满PHP-FPM进程,且大文件直接打爆内存
public function streamVideo(Request $request, $videoId)
{
$video = Video::findOrFail($videoId);
$filePath = storage_path('app/videos/' . $video->path);
if (!file_exists($filePath)) {
abort(404);
}
$mimeType = mime_content_type($filePath);
$fileSize = filesize($filePath);
return response()
->stream(function () use ($filePath) {
readfile($filePath); // 问题代码:整个文件读入内存
}, 200, [
'Content-Type' => $mimeType,
'Content-Length' => $fileSize,
]);
}
这是大忌。readfile()会让PHP进程全程占用一个worker,一个2GB的视频文件会阻塞该进程几十秒甚至几分钟。视频网站不应该让PHP来处理文件流,正确方案是由Nginx的X-Accel-Redirect机制直接接管文件发送。
修改后的PHP代码:
// 优化后:PHP只校验权限,文件传输交给Nginx
public function streamVideo(Request $request, $videoId)
{
$video = Video::findOrFail($videoId);
$filePath = storage_path('app/videos/' . $video->path);
if (!file_exists($filePath)) {
abort(404);
}
$mimeType = mime_content_type($filePath);
$fileSize = filesize($filePath);
// 内部重定向到Nginx,不暴露真实路径
return response('', 200, [
'X-Accel-Redirect' => '/protected-videos/' . basename($filePath),
'Content-Type' => $mimeType,
'Content-Length' => $fileSize,
]);
}
同时需要在Nginx配置一个内部location,指向视频目录:
# /etc/nginx/sites-available/video-platform.conf
location /protected-videos/ {
internal;
alias /var/www/html/storage/app/videos/;
# 限制单连接速率,防止一个用户占满带宽
limit_rate 5m;
# 开启sendfile优化
sendfile on;
tcp_nopush on;
# 缓存静态文件信息
open_file_cache max=1000 inactive=60s;
open_file_cache_valid 30s;
}
注意:X-Accel-Redirect有两个好处。第一,PHP进程立即释放,不再占用FPM worker;第二,Nginx以零拷贝方式发送文件,加载效率远高于PHP层。
第六步:PHP-FPM和系统参数调优
最后调整PHP-FPM的进程模型。原来用的是dynamic,在突发流量下进程创建销毁频繁。改成ondemand模式,并调大pm.max_children:
# /etc/php/8.3/fpm/pool.d/www.conf
; 从dynamic改为ondemand,空闲时不占用进程
pm = ondemand
pm.max_children = 200
pm.process_idle_timeout = 10s
pm.max_requests = 500
; 增加超时时间,视频元数据接口需要2秒内返回
request_terminate_timeout = 30s
同时调整内核参数,增加文件描述符上限和TCP队列深度:
# /etc/sysctl.d/99-video-platform.conf
# 文件描述符上限
fs.file-max = 100000
# TCP连接队列
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
# 允许TIME_WAIT快速回收和复用
net.ipv4.tcp_tw_reuse = 1
# 本地端口范围
net.ipv4.ip_local_port_range = 1024 65535
# 生效
sysctl -p /etc/sysctl.d/99-video-platform.conf
还有Nginx的worker_connections也同步调大:
# /etc/nginx/nginx.conf
worker_processes auto; # 按CPU核心数自动调整
events {
worker_connections 10240;
use epoll;
}
效果数据:调优前后的直接对比
全部修改完成后,用wrk做了压测对比。压测接口是视频元数据列表接口(也就是那条优化前的慢SQL)。测试机配置:4核8G,运行在同样的环境。
# wrk压测命令,200并发,持续60秒
wrk -t4 -c200 -d60s --latency http://127.0.0.1/api/videos?page=1
结果:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| QPS(每秒请求数) | 7.2 | 892.4 | 123.9 倍 |
| 平均延迟 | 2.81s | 38ms | 98.6% |
| P99 延迟 | >10s(大量超时) | 126ms | — |
| 错误率 | 42.6% | 0% | 100% |
| PHP-FPM占用进程数 | 全部20个worker占满 | 稳定在15个左右 | — |
再测视频文件流接口(/api/videos/{id}/stream),并发100,持续60秒:
wrk -t2 -c100 -d60s http://127.0.0.1/api/videos/12345/stream
| 指标 | 优化前(readfile) | 优化后(X-Accel-Redirect) |
|---|---|---|
| QPS | 0.8 | 94.6 |
| 平均延迟 | 5.2s | 0.9s |
| 错误率 | 38%(进程崩溃) | 0.2% |
| PHP-FPM占用 | 全部占满 | 瞬间释放 |
上线后,CDN回源从每秒100+请求降到每秒30左右(因为Nginx响应变快,CDN建立的长连接可以复用),TCP连接数从1.8万降到2000以下。用户端视频加载从"转圈5秒+白屏"变成了"点击即播"。
避坑指南
这个案例踩了太多坑,挑几个典型的说,希望你能绕过去。
坑1:只调参数不找根因
我们最先的反应是把somaxconn、worker_connections、pm.max_children全调大,重启后20分钟又崩。因为瓶颈在慢SQL,调大这些参数只会让更多的请求堆积在MySQL前面,把数据库拖死。参数调优要建立在定位到根因之后,不然就是饮鸩止渴。
坑2:MySQL行数估算的误导
EXPLAIN里显示videos表rows=850000,是估算值。实际行数只有40万,但MySQL没有统计信息或者统计信息过期,导致优化器选错了索引。排查这种问题,先执行ANALYZE TABLE videos;更新统计信息,再看执行计划。
坑3:全表扫描 + NOT IN 的连环爆炸
慢SQL里NOT IN (SELECT video_id FROM banned_videos WHERE reason = 'copyright')这种写法是全表扫描的重灾区。MySQL8.0.35在某些条件下无法把这个子查询优化成LEFT JOIN,就会对80万行中的每一行执行一次子查询。这个坑在MySQL 5.7和8.0里都存在,改用LEFT JOIN + IS NULL是稳定解法。
坑4:修改open_file_cache后文件更新不生效
配置了open_file_cache max=1000 inactive=60s后,遇到一个问题:视频文件被转码任务替换后,Nginx还在用缓存里的旧文件信息,导致用户看到的是旧视频。这是因为Nginx缓存了文件大小和修改时间,需要open_file_cache_valid 30s来控制缓存过期时间。如果对时效性要求很高,这个值不能太长。
坑5:关于tcp_tw_reuse的坑
网上很多文章让你开启net.ipv4.tcp_tw_reuse来减少TIME_WAIT连接。但注意,tcp_tw_reuse只对出站连接生效(客户端角色),对入站连接(服务器角色)无效。而且内核4.10以上,如果你开启了tcp_timestamps,tcp_tw_reuse在NAT环境下可能会导致连接复用错乱。我后来是在确认服务器未部署在NAT后面才开启的。
坑6:生产环境开启慢查询日志的代价
排查过程中直接在MySQL设置了SET GLOBAL slow_query_log = ON,这个操作本身会影响性能。在高并发环境下,写慢查询日志会加剧磁盘I/O压力。正确做法是先通过performance_schema或sys库确认是否存在慢查询,再用pt-query-digest分析,最后才考虑开启日志。实际这次排查中,因为开启日志增加的I/O导致高峰期接口又慢了10%。
最后说几句
排查这个故障,本质上就是把操作系统的资源管理、网络的连接模型、数据库的执行计划三层串起来找瓶颈。视频平台这种重I/O场景,每层的坑都很深。希望这次的记录能帮你少走弯路。
有问题可以直接在评论区聊,我尽量回复。