自建视频平台:网络/数据库/OS排障实录
发布日期: 2026/08/14 阅读总量: 0

一次真实的视频平台故障

凌晨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连接数高,第一反应是调大somaxconnsyn_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;

关键指标:

typerowsExtra
videosALL850000Using filesort
categorieseq_ref1NULL
userseq_ref1NULL
banned_videosALL80000Using 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;

这次的结果:

typerowsExtra
videosrange842Using index condition
categorieseq_ref1NULL
userseq_ref1NULL
banned_videosref1Using 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.2892.4123.9 倍
平均延迟2.81s38ms98.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)
QPS0.894.6
平均延迟5.2s0.9s
错误率38%(进程崩溃)0.2%
PHP-FPM占用全部占满瞬间释放

上线后,CDN回源从每秒100+请求降到每秒30左右(因为Nginx响应变快,CDN建立的长连接可以复用),TCP连接数从1.8万降到2000以下。用户端视频加载从"转圈5秒+白屏"变成了"点击即播"。

避坑指南

这个案例踩了太多坑,挑几个典型的说,希望你能绕过去。

坑1:只调参数不找根因

我们最先的反应是把somaxconnworker_connectionspm.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_timestampstcp_tw_reuse在NAT环境下可能会导致连接复用错乱。我后来是在确认服务器未部署在NAT后面才开启的。

坑6:生产环境开启慢查询日志的代价

排查过程中直接在MySQL设置了SET GLOBAL slow_query_log = ON,这个操作本身会影响性能。在高并发环境下,写慢查询日志会加剧磁盘I/O压力。正确做法是先通过performance_schemasys库确认是否存在慢查询,再用pt-query-digest分析,最后才考虑开启日志。实际这次排查中,因为开启日志增加的I/O导致高峰期接口又慢了10%。

最后说几句

排查这个故障,本质上就是把操作系统的资源管理、网络的连接模型、数据库的执行计划三层串起来找瓶颈。视频平台这种重I/O场景,每层的坑都很深。希望这次的记录能帮你少走弯路。

有问题可以直接在评论区聊,我尽量回复。