1. 先把症状分清楚m3u8 播不动通常不是同一个病做前端音视频这块最消耗时间的不是写播放器而是排查为什么这个链接在别人电脑上能播到我这儿就黑屏。我经手过的项目里m3u8 播放失败的原因分布大概是这样MIME 类型不对占三成跨域响应头缺失占三成缓存与 Range 处理不当占两成剩下两成才是切片本身和路径的问题。也就是说八成的播放故障根子在 Nginx 那几十行配置上而不是播放器代码写错了。先建立一个基本认知HLS 播放不是下载一个文件然后播放而是播放器反复发请求。一个视频被切成几十上百个.ts分片配一个.m3u8索引文件播放器先取索引再按顺序取分片播到中途要切换码率时还要再取一次索引。这条链路上任何一个请求的响应头不对播放器就会安静地停在那里——它不会给你报错弹窗最多在 console 里丢一行 warning。所以排查 HLS 的本质是逐个请求检查响应头而不是盯着播放器源码看。1.1 黑屏、无限转圈、格式不支持分别意味着什么这三种表现对应的故障点完全不同先分清再动手能省掉一半时间。黑屏但控制条正常、时间在走说明播放器已经解析了索引、拿到了时长但分片解码失败。重点查.ts的Content-Type、有没有被 gzip 压缩、切片本身是否完整。这种情况下网络面板里通常能看到 200 或 206 的响应但播放器就是不出画面。无限转圈、加载动画不消失播放器卡在取索引这一步.m3u8请求大概率失败或者返回了 HTML。最常见的是路径不对导致的 404或者服务器返回了一个自定义的错误页——播放器把 HTML 当 m3u8 解析自然什么都读不出来。直接提示格式不支持这是canPlayType层面的判断失败。要么是 MIME 类型没配对浏览器认为这不是它认识的 HLS要么是 http 页面里嵌了 https 的视频流或者反过来被混合内容策略拦掉了。还有一种容易被忽略的播了十几秒突然停住进度条还在但再也不动了。这百分之百是分片请求出了问题绝大多数是某个.ts返回了 403 或者 404——要么防盗链规则误伤要么切片已经被清理但索引还是旧的。1.2 动手前先用三个请求把范围圈死我的习惯是先不碰配置文件直接拿 curl 打三个请求出来看。这一步花两分钟能省下后面半小时的瞎猜。# 第一个索引文件重点看状态码和 Content-Type curl -I -H Origin: https://example.com https://example.com/vod/demo/index.m3u8 # 第二个第一个分片重点看 Content-Type 和 Accept-Ranges curl -I https://example.com/vod/demo/seg-000.ts # 第三个带 Range 的分片请求重点看是不是 206 curl -I -H Range: bytes0-1023 https://example.com/vod/demo/seg-000.ts三条命令下来判断逻辑很清晰观察结果指向的问题m3u8 返回 200 但Content-Type是text/plain或application/octet-streamMIME 未声明Safari 可能能放其他浏览器不行m3u8 返回 404 或 403路径错、重写错、防盗链误伤带Origin的请求头里没有Access-Control-Allow-Origin跨域配置缺失或add_header继承断裂ts 返回 200 而不是 206Range 没生效拖进度条必卡ts 的Content-Encoding: gzip分片被压缩解码器读不了Accept-Ranges: none服务器关掉了 Range 支持注意带Origin去测跨域响应头这一步很多人会跳过。浏览器里看 network 面板时跨域失败的请求有时候会被折叠或者标红成 CORS error看不到完整的响应头用 curl 反而更干净。2. MIME 类型一行默认配置就能让整条链路停摆Nginx 自带的mime.types文件里默认没有.m3u8和.ts的映射。这不是配置写错了是这两个扩展名本来就不在通用 Web 服务器的默认清单里——.ts在别的语境下是 TypeScript 源文件.m3u8更是流媒体专用的。所以你把切片目录往root下面一放Nginx 找不到映射就统统按application/octet-stream返回。这一步为什么致命分两种情况看。iOS Safari 是原生支持 HLS 的它对 MIME 的容忍度相对高一些有时候application/octet-stream也能硬放但换到 Chrome、Edge、Firefox 上用 hls.js 播放情况就完全不同了。hls.js 内部会检查MediaSource.isTypeSupported()而这个判断依赖于分片的实际编码信息一旦响应头给的类型让浏览器认为这不是媒体资源它连解析都不会开始。表现出来就是加载完索引之后彻底静默连错误都不抛。2.1 该声明哪些类型为什么不止两个很多人只知道补.m3u8和.ts实际项目里往往会用到更多。下面这份清单是我在不同项目里逐渐攒出来的建议整段抄走types { application/vnd.apple.mpegurl m3u8; application/x-mpegURL m3u; video/mp2t ts; video/iso.segment m4s; application/dashxml mpd; video/mp4 m4v; } # 兜底未识别的类型不要强行当二进制下发 default_type application/octet-stream;有几个细节值得展开说。application/vnd.apple.mpegurl是 m3u8 的官方注册类型绝大多数播放器都认application/x-mpegURL是老写法有些老版本的播放 SDK 只认这个所以我一般两个都写上用扩展名区分不同目录。video/mp2t是 MPEG-TS 的标准类型这个写错的话普通浏览器必挂。.m4s出现在 fMP4 封装的 HLS 里video/iso.segment是通用写法也有服务端用video/mp4的实测都能用关键是别漏。default_type这一行容易被误解成随便兜底。它真正的价值是当某个扩展名没在 types 里声明时你至少知道自己拿到的是application/octet-stream而不是一个误导性的类型排查时一眼能看出哦这里没配。2.2 配置放的位置决定了它会不会生效types块可以放在http、server、location三个层级但只有在当前 location 没有自己的types时才向下继承。如果你在某个location里写了types { }空块或者只声明了一两个类型父级的映射就全丢了。这个坑我踩过一次为了给某个目录单独加.json类型顺手写了个types块结果那个目录下所有视频全部黑屏查了快一个小时才反应过来。更稳妥的做法是统一放在http层或者单独抽一个mime.types文件用include引进来location里绝对不要再写types。验证方式很简单改完nginx -t检查语法nginx -s reload之后再用前面的 curl 命令看Content-Type有没有变。还有一个隐蔽点有些团队会把静态资源统一交给上游应用服务处理配置里写的是proxy_pass。这时候 Nginx 只是个转发层MIME 是由上游返回的你在 Nginx 里补 types 完全没用。判断方法是看响应头里有没有Server字段的差异或者直接看 Nginx 的access.log里这个请求是不是走到了 proxy 的 location。如果是走代理要么在上游改要么用proxy_hide_header Content-Type加add_header Content-Type强改——但后者属于补救手段长远看还是应该在上游解决。3. 跨域hls.js 发出的请求为什么被浏览器按下不表这是最容易让人困惑的一类故障。表现是你在浏览器地址栏直接粘贴 m3u8 的 URL能正常下载或者能播但把它塞进页面里的video或者 hls.js就是放不出来console 里一行红字blocked by CORS policy。原因在于请求的发起方不一样。地址栏直接访问是顶层导航不受同源策略约束而video标签加载媒体、hls.js 用fetch/XMLHttpRequest拉索引和分片都是受同源策略管的跨域请求。只要视频域名和页面域名不一致就必须有Access-Control-Allow-Origin。3.1add_header的继承规则是这块最大的雷先看一段看起来没问题、实际有问题的配置server { listen 80; server_name static.example.com; add_header Access-Control-Allow-Origin *; location /vod/ { add_header Cache-Control public, max-age3600; root /data/media; } }这段配置的意图很清楚全局允许跨域/vod/目录加缓存。但实际效果是——/vod/下的响应完全没有Access-Control-Allow-Origin。因为 Nginx 的add_header继承规则是子层级一旦定义了自己的add_header父层级的全部不继承而不是叠加。这是add_header和很多人的直觉最大的分歧点也是我在排查跨域问题时第一个怀疑的地方。正确写法是把需要的头在同一个层级写全或者用include把公共头抽出来在每个location里引一次# /etc/nginx/snippets/cors.conf add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Methods GET, HEAD, OPTIONS always; add_header Access-Control-Allow-Headers Range, If-Range, Origin, Accept always; add_header Access-Control-Expose-Headers Content-Length, Content-Range, Accept-Ranges always; add_header Access-Control-Allow-Credentials true always;然后每个需要跨域的location里都include snippets/cors.conf;。注意always参数——不加它4xx 和 5xx 响应不会带上这些头跨域失败时你连错误原因都看不到只看到一句笼统的 network error。Access-Control-Expose-Headers这一行经常被漏掉。分片请求的Content-Range、Content-Length如果不暴露给前端 JShls.js 在处理 seek 和缓冲判断时会拿不到准确信息表现就是能播但拖进度条不准。3.2 用$http_origin而不是写死星号Access-Control-Allow-Origin: *写起来爽但如果你的播放地址带 Cookie 或者签名*和Allow-Credentials: true是冲突的浏览器会直接拒绝。而且把来源全放开在有防盗链需求的场景下等于白做。更实际的做法是按来源白名单回显map $http_origin $cors_ok { default 0; ~^https://www\.example\.com$ 1; ~^https://m\.example\.com$ 1; ~^https://player\.example\.com$ 1; } server { location /vod/ { if ($cors_ok 1) { add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Credentials true always; } add_header Access-Control-Allow-Methods GET, HEAD, OPTIONS always; add_header Access-Control-Allow-Headers Range, If-Range, Origin always; add_header Access-Control-Expose-Headers Content-Length, Content-Range always; ... } }这里有个细节if块里的add_header和外面的add_header又是两个层级虽然 Nginx 在if里的行为比较特殊但为了避免踩坑建议把白名单判断也交给map在map里直接算出该给什么值add_header只用一次map $http_origin $cors_origin { default ; ~^https://www\.example\.com$ $http_origin; ~^https://m\.example\.com$ $http_origin; }当来源不在白名单时$cors_origin是空字符串Nginx 会直接不下发这个头浏览器的跨域请求自然失败——这正是我们想要的效果而且不用写if。另外别忘了OPTIONS预检。虽然浏览器对Range这种简单请求列表外的头会发预检但GET带Range实际上属于简单请求范围内的扩展多数情况不触发预检可一旦你的前端加了自定义鉴权头预检就来了。所以在 location 里加一段if ($request_method OPTIONS) { return 204; }省得预检请求走完整业务流程被鉴权拦掉。4. Range 与缓存拖进度条卡死、二次播放白屏的根子HLS 的分片虽然是独立文件但播放器在加载每个.ts时依然会用Range请求做分段读取特别是在做预加载、快进快退、切换码率的时候。如果服务器不支持 Range每次都会把整个文件从头返回一遍网络开销翻几倍不说播放器内部的时间戳映射也会乱掉。Nginx 默认是支持 Range 的前提是底层是静态文件并且开了sendfile。但如果你加了gzip或者走了proxy_pass到上游而没转发Range头Range 就失效了。判断标准特别简单日志里看到的是 206 就对了看到 200 就是没生效。4.1 别让 gzip 碰到视频分片这是我在一个项目里被坑得最惨的一次。当时为了优化首屏全局开了 gzipgzip_types里图省事写了text/plain application/javascript application/xml——注意这里有text/plain。而.ts文件因为 MIME 没配返回的正是text/plain上游服务的默认行为于是所有分片都被 gzip 压缩了。播放器拿到的Content-Length是压缩后的长度解出来的字节流对不上时间戳表现是画面花屏、声音断续。所以第一条铁律gzip_types里绝对不能出现text/plain也不能出现任何video/*。显式地只列文本类gzip on; gzip_vary on; gzip_comp_level 4; gzip_min_length 1024; gzip_types text/css text/xml application/javascript application/json image/svgxml;第二条如果你确实需要给.m3u8做压缩索引是纯文本压缩收益不小单独用location匹配别让它连带把.ts也带上location ~* \.m3u8$ { gzip on; gzip_types application/vnd.apple.mpegurl application/x-mpegURL; } location ~* \.(ts|m4s|mp4)$ { gzip off; add_header Accept-Ranges bytes; sendfile on; tcp_nopush on; aio on; }sendfile on配合tcp_nopush on能让大文件走内核零拷贝分片密集请求时 CPU 占用能降不少。aio on在机械盘或者网络存储上收益更明显SSD 上差别不大但开了没坏处。4.2 索引可以短缓存分片必须长缓存两者别搞反这个问题的典型症状是视频更新了用户那边还在放旧的或者反过来每次播放都要重新拉几百个分片流量成本居高不下。原则很清楚。.m3u8是索引内容会随直播推进或者点播切片更新而变化必须不缓存或者极短缓存.ts分片一旦生成内容就固定了文件名通常带序号或者哈希可以放心长缓存。location ~* \.m3u8$ { add_header Cache-Control no-cache, no-store, must-revalidate; add_header Pragma no-cache; expires -1; etag off; if_modified_since off; } location ~* \.(ts|m4s)$ { expires 30d; add_header Cache-Control public, max-age2592000, immutable; access_log off; }immutable这个指令值得单独说。它告诉浏览器这个 URL 的内容永远不会变别发条件请求验证了。HLS 分片天生满足这个条件只要你的文件名不重复使用加上之后用户在进度条上来回拖动时浏览器直接从本地拿不发任何请求体验差异非常明显。access_log off是给分片单独关掉日志。一个 10 分钟的视频如果切成 4 秒一片就是 150 个请求一个用户一次播放就能刷出 150 行日志量大起来日志文件会很夸张而且这些日志对排查的帮助远不如索引请求的日志大。真要排查某个分片的问题临时打开就行。反过来的坑有些团队为了保险给 m3u8 也加了expires 1h。结果点播视频更新后用户一小时之内看到的还是旧版本直播场景更糟播放器一直拿着开局那个索引永远播不到新内容。这个错误我在两个项目里都见过属于典型的缓存策略抄了一半。5. 切片本身的问题Nginx 配得再对也救不了上游配置全对、跨域全通、Range 正常视频还是有问题这时候基本可以确定问题在切片生成环节。这类故障的特点是时好时坏或者只有部分设备有问题排查起来最费劲因为你会反复怀疑自己的 Nginx 配置。5.1 关键帧间隔、切片时长与起播速度的三角关系HLS 的切片点必须落在关键帧I 帧上这是硬约束。如果你的转码参数里 GOP 设置成 250 帧、切片时长设成 4 秒而视频是 25fps那一个 GOP 就是 10 秒——切片器为了让每个切片都从关键帧开始只能把切片拉到 10 秒你配置的 4 秒根本没生效。这带来的连锁反应是起播要等第一个 10 秒的切片下载完用户感知就是点了播放转半天。而且切片太大弱网下很容易超时。经验参数是这样切片时长 4 到 6 秒GOP 严格等于切片时长乘以帧率。1080p 25fps 的视频切 4 秒就是 GOP100。这样每个切片刚好一个 GOP切片边界和关键帧完全对齐起播只需要下载一个切片。场景建议切片时长建议 GOP说明点播长视频6s帧率×6切片少索引小请求数低短视频/教育课件4s帧率×4起播快拖动定位准直播低延迟2s帧率×2延迟低但请求数翻倍移动端弱网4s帧率×4兼顾起播和卡顿率还有一个经常被忽略的点多码率自适应时各档位的切片边界必须严格对齐。如果 720p 切在 4.0s、480p 切在 4.03s播放器在切换码率时找不到对应时间点的分片就会短暂黑屏或者跳帧。这个对齐工作要靠转码参数保证不是 Nginx 能解决的。5.2 相对路径与目录结构一个斜杠引发的 404m3u8 索引文件内部引用分片时可以写相对路径也可以写绝对路径。写相对路径的话播放器会以索引文件的 URL 为基准拼接。这里出问题的场景通常是 CDN 回源或者路径重写。举个真实遇到的例子索引里写的是seg-000.ts页面请求的是https://cdn.example.com/vod/2024/demo/index.m3u8经过 CDN 重写后的地址而实际文件在源站是/data/media/demo/seg-000.ts。CDN 回源时把路径改成了/demo/index.m3u8播放器就按/vod/2024/demo/seg-000.ts去拼直接 404。解决办法有两个方向。一是让切片工具直接生成绝对路径但如果域名不固定就不合适二是在 Nginx 里保证索引和分片在同一个目录层级下路径重写规则保持一致。我倾向于第二个因为绝对路径会绑死域名后期换域名或者加 CDN 都要重新生成切片成本更高。location /vod/ { alias /data/media/; try_files $uri $uri/ 404; autoindex off; } # 拒绝访问隐藏文件但别误伤正常目录 location ~ /\.(?!well-known) { deny all; }那个location ~ /\.的正则要小心。有些团队写的location ~ /\.会把路径里任何带点的目录都拦掉包括.开头的正常业务目录。上面这个写法用负向断言排除了.well-known是更稳的版本。同理如果你有以点开头的分片目录命名习惯这条规则会直接让你的视频全挂排查时记得看一眼。6. 线上排查把日志和网络面板串起来读本地调试和线上的差别在于线上你没法随便改配置重启也没法让用户按 F12。所以排查能力要建立在日志上。Nginx 的默认combined格式对 HLS 排查不够用至少要把$request_time、$upstream_response_time和完整的$request加进去。log_format hls $remote_addr - [$time_local] $request $status $body_bytes_sent $http_referer $http_user_agent rt$request_time urt$upstream_response_time range$http_range; access_log /var/log/nginx/hls.log hls;range$http_range这个字段是关键。拿到日志之后判断逻辑就变得很机械日志特征结论m3u8 请求 200但body_bytes_sent很小几百字节且下一条是 ts 的 404路径拼接错分片不在预期位置ts 请求大量 200range字段为空播放器没发 Range或者 Nginx 没透传ts 请求有range但状态是 200Range 未生效检查 gzip 和 proxy 配置ts 请求 403防盗链规则命中或者权限问题ts 请求 499客户端主动断开通常是播放器等不及了切片太大或网络差同一 IP 短时间大量 206 但urt很高磁盘 IO 瓶颈或者上游慢6.1 499 这个状态码值得单独说499 是 Nginx 自定义的客户端关闭连接。在 HLS 场景里它的含义很明确播放器等得不耐烦自己把请求掐了。出现原因通常有三个——切片太大导致下载时间超过播放器缓冲阈值、磁盘或者网络带宽打满、上游服务响应慢。我在一个项目里遇到过 499 飙升的情况查下来是分片放在网络存储上单个分片 8MB弱网用户下载要十几秒播放器等 10 秒就放弃。改成 4 秒切片之后单分片降到 1MB 左右499 直接降了一个数量级。这个改动的收益比调 Nginx 缓冲参数大得多也说明了排查时不能只盯着 Web 服务器。6.2 浏览器侧的三个必看位置服务端日志只能告诉你请求到了没、返回了什么前端的问题还得在浏览器里看。固定的三个位置Network 面板过滤m3u8和ts看每个请求的状态码和响应头。重点确认Content-Type、Access-Control-Allow-Origin、Content-Range这三个。Console 里的 hls.js 日志。hls.js 默认日志级别比较安静可以在初始化时打开 debugconst hls new Hls({ debug: true, enableWorker: true });打开之后能看到每个分片的加载状态、缓冲区变化、码率切换决策。排查播到某处卡住这类问题时这行日志的价值极高。Media 面板Chrome DevTools 里有。能看到实际的缓冲区间、丢帧数、解码器状态。如果缓冲区一直填不满说明分片下载跟不上如果缓冲区满了但画面不动那是解码环节的问题跟 Nginx 无关了。7. 上线前的自检清单和几条踩出来的经验配置这东西写完能跑通和线上稳定是两回事。我现在的习惯是上线前跑一遍固定清单五分钟能挡掉大部分低级问题。7.1 一份可以直接照着核对的清单# 1. MIME 是否正确 curl -sI https://your.domain/vod/demo/index.m3u8 | grep -i content-type curl -sI https://your.domain/vod/demo/seg-000.ts | grep -i content-type # 2. 跨域头是否下发带 Origin 测 curl -sI -H Origin: https://www.your-page.com \ https://your.domain/vod/demo/index.m3u8 | grep -i access-control # 3. Range 是否生效必须是 206 curl -sI -H Range: bytes0-1023 \ https://your.domain/vod/demo/seg-000.ts | head -1 # 4. 分片有没有被压缩不应出现 Content-Encoding curl -sI https://your.domain/vod/demo/seg-000.ts | grep -i content-encoding # 5. 缓存策略是否符合预期 curl -sI https://your.domain/vod/demo/index.m3u8 | grep -i cache-control curl -sI https://your.domain/vod/demo/seg-000.ts | grep -i cache-control第五条经常查出问题。缓存策略配错不会导致播放失败但会带来更新不生效和流量账单异常这两类更难处理的麻烦而且它们往往要过几天才被发现。7.2 几个用时间换来的经验别在location里写types块。这一条前面说过但我还是要重复一遍因为它造成的故障现象是视频全黑屏而不是某个类型不对误导性极强。add_header的继承断裂要养成条件反射。只要你在某个location里加了一个add_header就顺手检查一下这个 location 是不是也需要父级的跨域头。我现在的做法是全部用include引入公共头从根上避免这个问题。if在location里的行为要谨慎。Nginx 官方文档明确说过if在 location 里属于如果实在没办法才用的范畴因为它的执行时机和配置上下文有自己的规则。能用map解决的判断一律用map。分片数量要控制。一个两小时的电影切 4 秒一片就是 1800 个文件索引文件会有几十 KB播放器解析索引本身就要花时间。长视频建议切 6 到 10 秒短视频再切细。这不是 Nginx 的参数但它直接影响你配置的缓存和并发策略是否合理。HTTPS 页面里别混 http 的视频流。这个错误在新项目里出现频率意外地高表现是接口全通、链接单独打开也能播、就是页面里放不出来。Chrome 会把混合内容的请求直接标成 blocked而且不显示响应头非常容易误判成跨域。上线前统一检查一遍协议播放地址用相对协议或者根据location.protocol动态拼。留一个可以临时关掉缓存的调试入口。线上排查时最怕的就是改了配置但浏览器还在用缓存导致你看到的现象和实际配置对不上。我会在测试环境留一个带随机参数的入口或者用 curl 加-H Cache-Control: no-cache绕过避免自己被缓存骗。多码率场景下先测切片对齐再测播放器。如果不同码率的切片时间戳对不齐切码率时必卡而这个现象跟网络状况高度相关很容易被误判成用户网不好。测试方法很简单手动把播放器锁到最低码率播一分钟再放开看有没有跳帧或者黑屏。最后一条也是我觉得最值钱的遇到播放不正常先别改 Nginx。先用 curl 打那三个请求把Content-Type、Access-Control-Allow-Origin、状态码是不是 206 这三件事确认清楚。这三项对了问题基本不在 Web 服务器层往切片和播放器方向找会快得多。我见过太多人一上来就改一堆配置参数改到最后连最初的现象都复现不了了。