我最初的实现极其简单:上传原文件、存到目录里、把地址返回给客户端播放。我在电脑浏览器上测了几个片子都能播,以为这块就完事了。换到手机上、换到弱网,四个问题全出来了。
一是有些片子在手机上直接播不出来。我用不同手机拍的片源编码和封装格式并不一致,其中几条在安卓上一片黑、有的只有声音没有画面。而同样的文件在我的电脑浏览器上播得很好——因为电脑上的解码支持范围比移动端宽得多。这让我明白「能播」不是文件的属性,是「文件 + 播放环境」共同决定的。
二是首帧等很久。限速之后,点开一条视频要等好几秒才出画面。我一开始以为这是带宽问题、没什么可做的;后来查了才知道,有些片子的索引信息(描述整个文件结构的那部分)被放在文件末尾,播放器必须先把整个文件下完才能开始解码——而这个位置是可以在转码时移到文件开头的。移过去之后播放器只要下开头一小段就能起播。
三是上传接口超时。我把转码放在上传接口里同步执行。一条几分钟的片子在我的机器上要转几十秒到几分钟,接口直接超时了;而更糟的是超时之后转码进程还在跑,用户以为失败了又传一遍,机器上同时跑起好几个转码,CPU 占满,连查列表的接口都变慢了。
四是转码失败的视频永久停在「处理中」。我没有失败状态,只要转码没成功,那条视频就一直显示处理中,用户既看不到也删不掉。
所以四个改动:统一转码为固定编码与若干档分辨率、转码参数上把索引信息前置并缩短关键帧间隔、转码改为异步排队并限制同时执行的数量、抽帧生成封面并给失败明确状态与重试入口。
这个模块最想说的一句话是:转码不是「为了压缩体积」,它的首要作用是把千奇百怪的输入变成一种播放器一定能处理、而且能快速起播的形式。我原来以为转码就是压缩,所以第一反应是「我的片子不大,不用转」——直到有片子在手机上根本播不出来,我才理解它真正解决的是兼容性和可播性。
用服务器上的 ffmpeg 命令行做转码,而不是接云端转码服务。云服务省事、有弹性。但对个人项目来说它意味着费用和一个我无法在本地完整调试的黑盒;而命令行工具的好处是参数完全可控、我能一条条试出「索引前置」「关键帧间隔」这些参数的效果,也能在本机复现所有问题。代价是转码占本机 CPU、并发能力受机器限制——所以必须限制同时转码的数量。判据是「我需要的是弹性还是可控」:我需要可控,因为这个模块的价值恰恰在于我理解了那些参数在做什么。
转码异步化的代价是引入了「处理中」这个中间态。同步转码的好处是接口返回时视频就能播了、没有中间状态。但它会超时,而且超时后进程还在跑,用户重传会造成 CPU 雪崩。异步之后必须补三件事:状态可查(否则客户端不知道显示什么)、失败态与重试入口(否则永久卡在处理中)、幂等(否则重复入队产生两份输出)。判据是「这个操作的耗时是否远超一次请求的合理等待」:几十秒到几分钟,远超了,那就必须异步。
限制同时转码的数量,而不是来一个转一个。不限制的话吞吐看起来更高。但转码是 CPU 密集的,几个任务同时跑就能把机器占满——而这台机器还要处理所有的业务接口。我实测过:三四个转码同时跑的时候,连查列表这种简单接口的耗时都明显上升了。判据是「这个任务和线上请求是否共享同一份资源」:共享 CPU,那就必须给它设上限,宁可转码排队慢一点,也不能让业务接口一起变慢。
输出多档分辨率而不是只出一档。只出一档最省转码时间和磁盘。但弱网下用户只能等高码率的片子,体验很差;而只出低档则在好网络下画质浪费了。代价是转码时间和存储都要乘以档数——所以我只出了少量几档,而不是像大平台那样一堆档位。档位数量的选择依据是「我的机器转得过来、磁盘装得下」。
踩过的坑一:有些片子在手机上播不出来,而电脑浏览器上完全正常。我一开始以为是客户端代码的问题,查了播放器的配置很久。后来换了几个不同手机拍的片源对比,才发现是编码格式的差异——移动端的解码支持范围比电脑浏览器窄得多。教训是:「能播」不是文件的属性,是「文件 + 播放环境」共同决定的;而转码的首要作用不是压缩,是把千奇百怪的输入变成一种播放环境一定能处理的形式。我原来一直以为转码就是压缩,所以第一反应是「我的片子不大,不用转」。
踩过的坑二:首帧慢,我以为是带宽问题、没什么可做的。后来才知道有些片源的索引信息被放在文件末尾,播放器必须先下完整个文件才能开始解码——而这个位置在转码时是可以移到文件开头的。移过去之后只要下开头一小段就能起播。教训是:把一个问题归因到「物理限制」之前,先确认它是不是真的物理限制——我差点就放弃了这个优化。
踩过的坑三:上传接口超时之后,用户重传导致 CPU 被打满。因为超时只是客户端不等了,服务端的转码进程还在跑。教训是:耗时任务放在请求里的危害不止「超时」,还有「超时后用户重试造成的叠加」——而叠加会影响整台机器上所有的功能。
没做的部分:没做转码的分布式调度(多台机器分担转码任务)。因为我只有一台机器,这个问题不存在——硬做一个用不上的调度层不如说清「单机下靠限制并发数保护业务接口,多机时需要解决任务分发与结果回收」。我认为能说清这个边界比假装处理过更好。
兼容性的报法是覆盖率而不是比例:「用不同机型拍摄的 N 条片源作为输入,转码前有 M 条在测试机上无法正常播放,转码后全部可播」。要说明测试机型和片源来源——这个结论依赖于「我只测了这几台机器」,不能说成普适。
首帧时间是这个模块最有说服力的数字,但必须带限速条件:「下行限到几百 KB 每秒时,同一条片子的首帧等待从 X 秒降到 Y 秒」。并说明这个改善主要来自「索引信息前置」,而不是体积变小——可以补一个对照:只压体积不改索引位置时首帧几乎没有改善,这个对照能直接证明归因是对的。
关键帧间隔的取值要报权衡过程:「分别试几组间隔,记录起播时间与输出体积,取一个平衡点」。直接给一个数字而不说怎么来的就是拍的。
转码耗时要报清片源规格:「一条时长 X、分辨率 Y 的片子,在我的机器上转码耗时 Z」。要说明机器配置,因为转码耗时几乎完全由 CPU 决定。
并发限制的效果用对照实测:「同时跑 N 个转码时,一个简单列表接口的耗时从 A 上升到 B;限制并发数之后回落」。这个对照直接说明了「为什么要限制」,比只说「限制了并发」有力得多。
失败态:喂一个损坏的文件,断言转码任务在超时后进入失败状态、视频显示为处理失败且可重试或删除,而不是永久停在处理中。
幂等:把同一个视频重复入队,断言只产生一份输出。
封面:断言抽帧不取第 0 秒、抽帧失败时使用兜底封面、封面尺寸按列表展示尺寸出图。
不要报什么:不要报「支持所有格式」——我只测了手上这几台手机拍的片源。该报的是「N 条片源中转码前 M 条不可播、转码后全部可播(附测试机型)」「限速条件下首帧时间对比,并用『只压体积不改索引』作对照」「N 个并发转码对业务接口耗时的影响及限制后的回落」「损坏文件进入失败态而非永久处理中」这几件带条件的、可核对的事。
上下滑动播放这个交互看着简单——一个纵向的列表,每一屏一个视频,滑到哪个播哪个。我照这个思路写完之后,在自己手机上滑了几条觉得挺顺。换到旧安卓机、再限一下速,四个问题全出来了。
一是滑到十几条之后明显卡顿,内存一直涨。我的写法是列表里每条视频各渲染一个播放器组件。滑动时旧的组件并没有被销毁,于是播放器实例越来越多——每个实例都占着解码资源和内存,在旧安卓机上滑到十几条就已经很卡了。
二是我加了预加载之后,起播反而变慢了。我以为「多缓冲几条体验更好」,就在进入页面时把前面好几条都开始缓冲。结果在限速条件下,这几路缓冲和当前正在播放的那一条互相抢带宽——当前这条的起播和续播都变慢了。预加载本来是为了让下一条更快,却把当前这条拖慢了。
三是划走的视频还在后台跑。我只做了「划到新的一条就播新的」,没有暂停旧的。结果是划过几条之后,好几路视频同时在下载和解码——带宽和 CPU 都被分走了,而用户只在看一条。
四是起播前是一片黑。从划到位到首帧出来之间有一段空白,限速下这段空白有一两秒。而我明明已经有封面图了,却没用上。
所以四个改动:改为固定三个播放器实例轮转复用、预加载只做相邻的下一条、划走立即暂停并释放缓冲、起播前先展示封面图,另外补了按网络状况选分辨率档位。
这个模块最想说的一句话是:预加载和缓存都不是免费的——它们在和「当前正在用的资源」抢同一份带宽和内存。我一开始把「预加载」当成纯收益,所以越加越多,结果把当前这条拖慢了。想清楚之后我的原则变成:预加载的范围应该是「用户下一步最可能要用的那一点点」,而不是「所有可能用到的」。这条原则我后来在图片轮播、列表分页上都用到了。
固定三个播放器实例轮转,而不是逐条新建再销毁。逐条新建的代码最简单(就是一个 v-for)。但播放器是重对象——它占解码资源、占内存、还可能持有网络连接,实例数随滑动增长在旧机器上很快就撑不住。代价是复用逻辑复杂一些:必须彻底重置状态,否则会串台(新视频显示上一条的进度)和事件重复绑定。判据是「这个对象是轻的还是重的」:重对象就必须池化复用,而不能依赖框架帮你销毁——我最初就是指望组件销毁会自动释放,实际并没有那么干净。
预加载只做一条,这是我从「负优化」里学到的。我最初以为预加载是纯收益、越多越好。实测发现在限速条件下,多路缓冲和当前正在播放的那一条抢同一份带宽——当前这条的起播和续播都变慢了。判据是「预加载抢占的资源会不会影响当前正在用的东西」:会,那范围就必须受限。我总结出的原则是:预加载的范围应该是「用户下一步最可能要用的那一点点」,而不是「所有可能用到的」——这条原则我后来在图片轮播和列表分页上都用到了。
划走立即暂停并释放,而不是让它继续缓冲以备划回来。继续缓冲的好处是划回来能立刻播。但用户划走之后回来的概率并不高,而继续缓冲的成本是持续占带宽——而带宽是当前正在看的那条最需要的东西。取舍是保留播放进度(很轻)但释放缓冲(很重):划回来时从记住的位置重新起播,体验差别不大但资源省很多。
分辨率自适应只做了最简单的版本。完整的自适应需要按实时带宽估计动态切换、还要处理切换时的画面衔接。我做的是「按网络类型选初始档位 + 连续卡顿几次降一档」——它明显是简化版,但我能说清简化在哪、以及完整版要额外解决什么。我认为这比硬做一个半成品的自适应然后说不清细节要好。
踩过的坑一:每条视频各建一个播放器,滑到十几条就卡。我最初指望组件销毁会自动释放播放器资源,实际上并没有那么干净——实例累积、内存持续增长。我是在旧安卓机上滑了一会儿看内存曲线才确认的。教训是:重对象不要依赖框架的自动回收,要显式池化和释放;而且「在我的新手机上很顺」完全不能说明问题,这类资源问题必须在最差的机器上验。
踩过的坑二:预加载做多了反而更慢,这是我第一次遇到「优化变负优化」。我加完之后在好网络下确实感觉不出问题,限速之后才发现当前这条的起播明显变慢了。教训是:任何「提前准备」的优化都要问它抢的是谁的资源——如果抢的是当前正在用的资源,那它很可能是负优化。
踩过的坑三:划走不暂停,好几路视频同时在跑。我是在开发者工具里看网络请求才发现的——同时有好几条视频在下载。教训是:滑动流这种「一屏一个」的交互,必须显式处理「离开」这个事件;只处理「进入」的话,离开的那些会一直留在活跃状态。
没做的部分:没做视频的本地缓存(看过的视频存到本地,再看不重新下载)。它能省流量,但要处理缓存容量上限、淘汰策略、以及缓存文件的清理;而短视频的重复观看率本来就不高。取舍依据是「收益(省流量)小于复杂度(一套本地缓存管理)」——如果是音乐或课程这类会重复播放的内容,我的结论会反过来。
播放器实例数是这个模块最本质的指标:报「滑动到 N 条时的播放器实例数,改造前随滑动线性增长、改造后恒定为 3」。这个数字比帧率更能解释问题,而且很容易核对。
内存要报机型和条数:「在某型旧安卓机上滑动到 N 条时的内存占用,改造前持续增长到 X、改造后稳定在 Y 附近」。要说明是用什么工具看的、以及滑动的速度和节奏(快速滑和慢慢滑的结果不同)。
帧率同样要带机型和条数。只报一个帧率数字没有参考价值。
预加载的负优化要用对照实测,这是最有说服力的一组数据:「限速到几百 KB 时,预缓冲多条与只缓冲一条两种策略下,当前视频的起播时间分别是 X 和 Y」。报出「多缓冲反而更慢」这个反直觉的结果,比报「我做了预加载」有意思得多。
划走暂停的效果:报「连续滑过 N 条后,同时进行的视频下载请求数从 M 降到 1」。这个数字可以直接在开发者工具的网络面板里数出来。
封面兜底:限速条件下断言划到位的瞬间就有画面(封面),首帧到达后无缝切换、过程中不出现黑屏。
复用不串台是断言型用例:快速来回滑动,断言每条视频显示的是自己的进度、不会出现上一条的残留状态;并断言事件监听没有重复绑定(可以数回调触发次数)。
进度续播:划走再划回,断言从上次位置继续。
不要报什么:不要报「滑动流畅度提升 N%」——流畅度不是单一数字。该报的是「实例数从线性增长变为恒定 3」「某机型某条数下的内存与帧率」「两种预加载策略下当前视频起播时间的对照」「连续滑过后并发下载请求数从 M 降到 1」这几件带条件的、可核对的事。
做完播放之后我想加一个「播放量」和「完播率」,本来以为是最简单的一块。实际上它是这个项目里我唯一一个「怎么定义都好像不对」的模块,而问题全在口径上,不在技术上。四个问题。
一是每秒上报一次,请求量大到不合理。我在播放进度回调里直接发请求上报。一条视频看一分钟就是六十个请求——而在滑动流上用户快速划过十几条,请求数就更夸张了。更荒唐的是这些请求里绝大部分数据是没用的(我只关心最终看了多久)。
二是播放量严重虚高。我的口径是「起播就算一次播放」。结果是用户快速划过首页,每条视频都触发了起播——滑动一次首页播放量涨几十。这个数字完全没有意义,而且它会误导我自己(我一开始还以为哪条视频突然火了)。
三是同一次播放被统计多次。上报失败重试、切后台再切回来重新起播、划走划回,都会各产生一次「播放」。同一个人看同一条视频,播放量涨了好几。
四是完播率的口径想错了。我一开始定的是「播放到最后一帧算完播」。但用户可能拖动进度直接跳到结尾——只看了两秒也算完播;而短视频经常循环播放,播到结尾再从头开始,这时候算几次完播也说不清。
所以四个改动:上报改为本地累计、按节流与关键节点合并批量发送、明确「播放」需要实际播放时长超过阈值、引入本次播放的标识做去重、完播率改用「累计观看时长占视频时长的比例」。
这个模块最想说的一句话是:统计类需求的难点几乎全在「口径定义」上,而不在怎么把数字算出来。「播放量」这三个字看起来不言自明,但「划过算不算」「重试算不算」「同一个人看两次算不算」每一个都要定,而定错了数字就是废的。我最大的收获是学会了在写代码之前先把口径写下来——而且写下来之后我发现自己原来的想法有一半是站不住的。
「播放」的口径定成「实际播放时长超过阈值」而不是「起播即算」。起播即算实现最简单、数字也最好看。但它在滑动流上完全失真——用户快速划过首页,每条都触发起播,滑一次播放量涨几十。而失真的数字比没有数字更糟,因为它会误导决策(我自己就一度以为某条视频突然火了)。阈值的取值要说清依据:太短则划过也算,太长则正常观看被漏掉。我是看了自己滑动时的实际停留时长分布之后取的一个值,而不是拍一个数。
上报改成「本地累计 + 合并批量」,而不是继续每秒上报。每秒上报的好处是数据最细、丢失最少。但它产生的请求量与数据价值完全不成比例——我只关心最终看了多久,中间那六十个点没有任何用处。代价是端上崩溃或强杀时最后一段累计值会丢。我接受这个代价,因为统计类数据允许少量误差,而且我在「划走」和「切后台」这两个关键节点都补了上报,真正丢失的窗口很小。判据是「这份数据的精度要求」:统计允许误差,那就不必为了极小的完整性付出巨大的请求量。
完播率用「累计观看时长比例」而不是「到达最后一帧」。后者实现最简单(监听播放结束事件)。但它有两个明确的错误:拖动进度跳到结尾的人只看了两秒也算完播;循环播放的视频播到结尾又从头开始,算几次说不清。用累计时长比例的代价是要在客户端维护累计值、还要处理拖动带来的时长跳变(拖过去的那段不能算看过)。判据是「这个指标想衡量的到底是什么」:想衡量的是「他看完了吗」,而「到达最后一帧」衡量的是「播放头到过结尾吗」,这两件事不一样。
播放标识由客户端生成。和消息去重是同一个道理:去重键必须在第一次上报之前就存在,否则客户端重试时服务端认不出是同一次播放。代价是要相信客户端生成的标识不会碰撞(我用了足够长的随机值加时间)。
踩过的坑一:播放量虚高到我自己都不信。我一度以为某条视频突然火了,查了半天才发现是我自己在滑动测试——每划过一条就算一次播放。教训是:统计类需求的难点几乎全在口径定义上,而不在怎么把数字算出来。「播放量」这三个字看起来不言自明,但「划过算不算」「重试算不算」「同一个人看两次算不算」每一个都要定。我后来的习惯是写统计代码之前先把口径写下来——而写下来之后我发现自己原来的想法有一半站不住。
踩过的坑二:每秒上报一次,请求量荒唐。我是在开发者工具的网络面板里看到密密麻麻的上报请求才意识到的。教训是:在回调里直接发请求是很容易写出来的,但回调的触发频率往往远高于你的实际需要——要先问「我真正需要的是哪几个时刻的数据」。
踩过的坑三:同一次播放被算了好几次。切后台再回来会重新起播、上报重试也会再发一次。教训是:只要客户端会重试或重复触发,服务端就必须有去重依据,而这个依据必须由客户端在第一次就带上——这一点和消息去重完全一样。
没做的部分:没做更细的行为分析(比如观看时长的分布曲线、在哪一秒划走的最多)。它需要保留细粒度的上报明细,存储量会大很多;而这类分析要有足够的数据量才有意义——我这个项目的数据量下画出来的曲线只是噪声。取舍依据是「数据量不支持这个分析」,而不是技术上做不到。
请求量下降是这个模块最直观的数字:报「连续观看一分钟并划过 N 条视频的过程中,上报请求数从 M 个降到 K 个」。这个数字可以直接在开发者工具的网络面板里数出来,很容易核对。
口径修正的效果要报「同一操作下的统计差异」:「快速划过首页 N 条视频,改造前播放量增加 N、改造后增加 0」。这个对照直接说明了口径问题,比抽象地说「修正了统计口径」有力得多。
去重用断言型用例:同一次播放中人为触发多次上报(模拟重试)、以及切后台再切回,断言播放量只增加一次。
阈值的取值要说清依据:「统计自己滑动测试时的停留时长分布,取一个能区分『划过』与『真的在看』的值」。直接给一个秒数而不说依据就是拍的。
完播口径要覆盖两种反例:拖动进度直接跳到结尾,断言不计完播;循环播放两遍,断言完播只计一次(或按定义计,但要和文档一致)。
失败暂存:断网状态下播放并划走,断言数据被本地暂存;恢复网络后断言这些数据被合并上报且不重复计数。
不阻塞播放:让上报接口一直超时,断言播放完全不受影响、不出现卡顿或报错弹窗。
对账:人为把缓存里的播放量改错,断言定时对账能用明细重算修正并记录差异。
不要报什么:不要报「播放量准确率」——没有一个外部的真值可以对照。该报的是「一分钟观看内上报请求数从 M 降到 K」「快速划过 N 条时播放量增加从 N 变为 0」「同一次播放多次上报只计一次」「拖到结尾不计完播」「上报接口超时不影响播放」这几件可核对的事。
图片上传我做过压缩就够了,视频完全不是一个量级——一条手机拍的原片就是几十兆。我一开始沿用了图片那套「压缩后整个传上去」的思路,在限速环境下彻底不可用。四个问题。
一是整文件上传在弱网下几乎不可能成功。把上行限到几十 KB,一条几十兆的视频要传好几分钟。而这几分钟里只要网络抖一下、或者用户切了个应用,整个上传就作废、从零开始——我自己测的时候连续失败了好几次,一次都没传完。
二是我改成分片之后,出现了损坏的文件。我按顺序发分片、服务端收到就往文件末尾追加。但分片是并发发出去的,到达顺序和发送顺序不一致——服务端按到达顺序追加,合并出来的文件内容是乱的。更糟的是这种损坏在上传阶段完全看不出来(接口全部返回成功),要到转码时才报错,而那时候我已经不知道是哪一步出的问题了。
三是断了之后还是从头传。我做了分片,但客户端每次都从第一片开始发——服务端明明已经收到了前面几十片,却又收了一遍。分片的意义只发挥了一半:它让单次请求变小了,但没有让「已经传成功的部分」被保留下来。
四是同一条视频重复上传,每次都完整传一遍。我自己在调试转码参数时反复上传同一个文件,每次都是好几分钟。而这个文件在服务器上明明已经有了。
所以四个改动:按固定大小切片、逐片上传、分片按编号落盘、合并时按编号顺序拼接并校验指纹、开始前先询问已传分片、只补传缺失的、先用文件指纹询问是否已存在,命中则跳过上传。
这个模块最想说的一句话是:分片上传的价值不在「把大请求变成小请求」,而在「让已经传成功的部分能被保留」——而这需要服务端记住它收到了哪些片。我最初只做了切片、没做记录,于是每次断线重来都从第一片开始,等于白做。想清楚这一点之后,「续传」「秒传」「完整性校验」这三件事的设计就都顺下来了,因为它们依赖的是同一样东西:以分片编号和文件指纹为标识去描述上传的进度。
分片必须配合「服务端记录已接收编号」,否则分片本身的意义只发挥了一半。只切片不记录也能跑(每次从第一片开始发),而且实现简单得多。但断线重来时前面几十片又要重传一遍——服务端明明已经收到了。代价是要维护一份「文件指纹到已接收编号」的记录,还要处理它的清理。判据是「分片解决的到底是什么问题」:不是「把大请求变小」,而是「让已经传成功的部分能被保留」——而后者必须有服务端的进度记录才成立。想清楚这一点之后,续传、秒传、完整性校验三件事就都依赖同一样东西了。
按编号落盘再顺序合并,而不是按到达顺序追加。追加实现最简单(一个输出流一直写)。但分片是并发发出的,到达顺序和发送顺序不一致——按到达顺序追加必然拼乱。代价是要为每片单独落盘、合并时多一次完整的读写。判据是「到达顺序可控吗」:不可控(并发 + 网络),那就不能依赖它,必须用一个显式的顺序标识(编号)。
合并后校验整体指纹,即使它意味着多一次完整的读取。不校验能省掉这次读取。但损坏文件在上传阶段完全不报错、要到转码时才暴露——而那时候我已经不知道是哪一步出的问题了。判据是「这个错误如果不在这里发现,会在哪里发现、那时的定位成本有多高」:会在很后面发现、定位成本很高,那就值得在这里付一次读取的代价。这一条我认为是「校验该放在哪」的通用判断方式。
秒传只用文件指纹判断,不做更复杂的相似度判断。指纹相同就是同一个文件,这个判断是确定的。而「相似的视频」(比如同一段素材不同码率)需要内容层面的比较,成本高且不可靠。代价是同一内容的不同编码版本仍会各传一次。判据是「这个判断能不能做到确定无误」:指纹可以,相似度不行——而上传去重这种事一旦判错(把两个不同的文件认成同一个),后果是用户的视频被换掉了,非常严重。
踩过的坑一:整文件上传,限速下我一次都没成功过。连续失败好几次、每次都从零开始。教训是:一个操作的耗时一旦长到「期间必然发生意外」的程度,就不能设计成「要么全成功要么全失败」——而几分钟的上传在移动网络下就属于这个范畴。
踩过的坑二:分片按到达顺序追加,合并出损坏文件,而且上传阶段完全不报错。是转码失败时我才回头查的。教训有两层:一是不能依赖并发操作的到达顺序;二是「接口全部返回成功」不等于「结果是对的」——所以关键流程的末尾要有一次结果校验,否则错误会漂到很后面才暴露。
踩过的坑三:做了分片但没做续传,断线还是从头传。我当时觉得「分片已经做完了」。教训是:一个技术手段往往需要配套的东西才能真正解决问题——分片如果没有服务端的进度记录,它只是把一个大失败拆成了很多个小失败,总的结果还是失败。
没做的部分:没做分片的并行度动态调整(根据实测速度自动增减并发片数)。它理论上能更充分利用带宽,但需要一个可靠的速度估计,而移动网络的速度波动很大、估计不准反而会来回抖动。取舍依据是「固定一个在限速下试出来的保守并发数,表现已经稳定」——而一个会自己抖动的策略比一个稍微保守的固定值更难排查。
所有数字必须带限速条件:我报的是「把上行限到几十 KB 每秒」,并说明片源是手机拍的原片、大小在数十兆量级。不限速的话整文件上传也能成功,这个模块的问题一个都不出现。
整文件上传的失败率是这个模块最有说服力的起点:报「限速条件下整文件上传连续尝试 N 次、成功 0 次」——这个数字直接说明「为什么必须分片」,比讲道理有力得多。
续传的效果用断言型用例:上传到中途人为断开,断言重新开始时只补传缺失的分片(可以在网络面板里数请求数)、已传的片不再重传;报「断在第 N 片时,续传只发出剩余的片数」。
秒传:同一个文件上传两次,断言第二次没有发出任何分片请求、直接返回凭据,并报第二次的总耗时(应该是一次询问的时间)。
合并正确性是踩坑的直接回归:让分片乱序到达(可以人为打乱发送顺序),断言合并结果的指纹与原文件一致;再人为丢弃一片,断言合并前就被拦下(分片不齐不合并)或合并后校验失败。
损坏检出的时机要单独报:「改造前损坏文件要到转码阶段才暴露;改造后在合并校验时就被拦下」。这个「提前暴露」本身就是价值。
分片大小与并发数要报实测选取过程:「在限速条件下试了几组分片大小与并发数,记录总耗时与失败率,取表现稳定的一组」。直接给数字而不说怎么来的就是拍的。
单片重传:让第三片失败,断言只重传该片、其余片不受影响。
临时分片清理:制造一个未完成的上传,断言超过设定时间后临时分片目录被清理、磁盘占用回落。
不要报什么:不要报「上传成功率 100%」——弱网下不可能。该报的是「限速值与片源大小」「整文件上传连续 N 次成功 0 次」「断在第 N 片时续传只发剩余片」「同文件二次上传不发分片请求」「乱序到达时合并指纹仍一致」「损坏从转码阶段提前到合并阶段暴露」这几件带条件的、可核对的事。
滑动流的「取下一批视频」这个接口我最初写得极其简单:按发布时间倒序、用偏移量分页。播放体验做好之后我自己连续刷了几十条,四个问题暴露得很直接。
一是反复刷到同一批视频。原因有两个叠加:一是我下拉刷新时把偏移量重置成了零,于是又从最新的那几条开始;二是有新视频插入时偏移量整体错位,翻页会重复(这和帖子列表的偏移量分页是同一个问题)。而「重复刷到同一条」是这类产品最招骂的点——用户会立刻觉得「没内容了」。
二是我加了已看排除之后,很快就没内容可看了。我把用户看过的全部视频都排除掉。我自己刷了几十条之后,接口开始返回空——因为我造的测试数据只有几百条,而我把看过的都排除了。如果这是真实产品,老用户会很快遇到同样的问题。
三是我想加一点随机性,结果分页彻底乱了。我在排序里加了随机因子想让流更有变化。但随机排序下没有稳定的游标可用——每次请求的顺序都不一样,「取排序在某个位置之后的」这句话失去了意义,结果是既有重复又有遗漏。
四是老视频长期占据前列。我按互动数排序做了个「热门」入口。结果是几条早期的视频因为积累的互动多,一直排在最前面——新视频永远上不来,而这个流看起来永远是那几条。
所以四个改动:维护已看集合并在取流时排除、分页改用游标、已看集合限容量加过期,候选不足时放宽时效而不是返回空、按用户生成有序候选队列、在队列上分页(替代直接随机排序)、排序权重加入时间衰减。
这个模块最想说的一句话是:「随机」和「分页」是天然冲突的——分页需要一个稳定的顺序,而随机每次都不一样。我一开始想在排序里直接加随机因子,怎么调都会重复或遗漏;解法是把随机提前一步:先为这个用户生成一份固定顺序的候选队列(生成那一刻可以随机),然后在这个队列上老老实实地按位置分页。这和我在论坛热度榜上用「定时快照」解决同一类问题是完全一样的思路——让不稳定的东西在一段时间内变得稳定。
把随机提前到「生成候选队列」那一步,而不是在排序里加随机因子。直接加随机因子看起来最省事(一个排序表达式的事)。但它和分页天然冲突——分页需要一个稳定的顺序,而随机每次都不一样,我怎么调都会出现重复或遗漏。代价是要为每个活跃用户存一份候选队列(占缓存空间)、还要处理它的过期与重新生成。判据是「这个不稳定性能不能被固化到某个时刻」:能,那就在那一刻做完随机、之后按固定顺序消费。这和我在论坛热度榜上用定时快照的思路完全一样——我在两个不同的地方遇到了同一类问题,说明它是个通用模式。
已看集合限容量、允许很久以前看过的重新出现,而不是永久排除。永久排除最符合「不要重复」的直觉。但它有两个问题:集合无限增长、以及内容总量有限时用户很快无内容可看——我造了几百条测试数据,自己刷几十条就把流刷空了。判据是「内容供给和消费速度的关系」:供给远小于消费时,「永不重复」这个目标本身就不可能达成,那就该明确地允许「久远的内容重新出现」,而不是让用户面对空白。我把这个取舍写在了文档里,因为它是一个有意的设计而不是缺陷。
候选不足时降级为放宽时效,而不是返回空。返回空最「诚实」。但用户看到的是一个空白页面,他不知道是没内容了还是出错了——而这两种情况他的下一步动作完全不同(一个是等新内容、一个是重试)。所以先降级放宽、真的没有了再明确告知「已看完最新内容」并给「重新开始」的入口。判据是「空白页面传递的信息量」:接近零,那就必须替换成一个明确的状态。
排序权重做成可配置,并明确说自己没有能力调优它。我可以随便定一组权重然后声称「优化了推荐效果」。但我没有真实的用户行为数据——我造的行为数据训不出也验证不了任何东西。所以我做的是:把结构搭好(时间衰减、互动权重、可配置),并诚实说明「具体取值需要真实数据来调」。我认为这比编一个「点击率提升多少」更站得住,而且面试官问「你怎么验证这个权重是好的」时我有答案:我验证不了,所以我把它做成了可配置的。
踩过的坑一:反复刷到同一批视频。两个原因叠加——下拉刷新时我把偏移量重置成了零、以及有新视频插入时偏移量整体错位。教训是:偏移量分页的问题在任何「数据集会变化的列表」上都会出现——我在帖子列表上已经踩过一次,在视频流上又踩了一次,说明我当时只是修了那个页面、没有把结论推广到所有列表。
踩过的坑二:加了已看排除之后,接口很快返回空。我自己刷了几十条就把流刷空了。教训是:一个过滤条件的效果取决于「被过滤掉的比例」——而这个比例在小数据量下会大得离谱。我最初的实现在真实的大内容池里也许能跑,但在我的测试数据上直接崩了;而我意识到老用户面对的情况和我的测试数据是相似的。
踩过的坑三:随机排序让分页彻底乱了,我调了很久排序表达式。后来才想明白这不是排序的问题,而是「随机」和「分页」这两个需求本身冲突。教训是:当一个问题怎么调参数都解决不了时,很可能是两个需求在结构上冲突,而不是参数没调对。
没做的部分:没做个性化推荐(按用户兴趣排序)。它需要真实的行为数据来做特征和排序,而我这是个人项目、没有真实行为数据——用造出来的假行为数据训一个「假的推荐」,不如老实做好「最新加热度加去重」。取舍依据是「支撑这个功能的前提我不具备」,而且我能说清完整方案需要什么。
重复率是这个模块最核心的指标:模拟连续滑动 N 条,断言其中重复出现的视频数为零;并报改造前的重复情况作为对照(「改造前连续滑动 N 条中出现 M 条重复」)。两个数字一起报,才说明这个改动解决了一个真实存在的问题。
要说清测试数据规模:「灌入数百条视频」——因为已看排除的效果完全取决于内容总量与消费速度的比例,不说规模的重复率数字没有意义。
刷空的临界点值得单独报:「在数百条内容的池子里,连续滑动约 N 条后候选开始不足、触发放宽时效的降级」。这个数字说明我测到了边界,而不是只测了顺利路径。
游标分页的正确性用断言型用例:翻页期间人为插入新视频,断言前后两页之间没有重复、也没有遗漏(和帖子列表同一套用例)。
候选队列的稳定性:在队列有效期内多次请求同一位置,断言返回的视频顺序一致;队列过期后断言重新生成、且顺序可以不同。
已看口径:快速划过一批视频(不满足「看过」的时长阈值),断言它们没有被加入已看集合、后续仍可能出现。这条对应「不是划过就算看过」。
集合容量与淘汰:让已看集合超过容量上限,断言淘汰的是最早看过的、集合大小不再增长。
时间衰减:造一条互动数很高的老视频与一条互动数一般的新视频,断言新视频能排到前面(说明衰减生效),并说明衰减系数是可配置的、当前取值没有真实数据支撑。
兜底状态:把候选耗尽,断言界面显示「已看完最新内容」并有「重新开始」入口,而不是空白页面。
不要报什么:不要报「推荐效果提升」或「点击率提升」——我没有真实行为数据,这类数字编不出也验证不了。该报的是「测试数据规模」「连续滑动 N 条的重复数改造前后对比」「候选不足的临界条数」「翻页期间插入新内容无重复无遗漏」「队列有效期内顺序稳定」这几件带条件的、可核对的事。
「把视频文件返回给播放器」这件事我原以为和返回图片没有区别。做完播放流之后我发现拖动进度条完全没反应,才意识到视频文件的返回方式和图片是两件事。四个问题。
一是拖动进度条无效。我的实现是「读整个文件、一次性写回响应」。播放器想跳到视频中间时,它会请求「文件的某一段」——而我的接口不管请求什么都返回整个文件,播放器拿不到它要的那一段,进度条就拖不动。这一点我查了很久,因为我一直在怀疑播放器组件的用法。
二是长视频起播要等很久。同样是因为整段返回:播放器必须先拿到足够的数据才能开始解码,而我把整个文件都塞给它——短视频还好,稍长一点的片子起播明显变慢。
三是整段读进内存再返回,大文件会把内存占满。我用的是「把文件全部读成一个字节数组、再写回响应」。几十兆的文件、几个并发请求,内存立刻就上去了——而这个问题在我单人测试时完全不出现。
四是我加了范围支持之后,构造一个非法范围能让接口异常。我直接把范围头里的数字拿来当读取的起止位置。用一个超出文件长度的范围、或者起始大于结束的范围去请求,读取就抛异常了——而这个请求是任何人都能构造的。
所以四个改动:支持按范围请求(解析范围头、只返回请求的那一段、返回正确的状态码与响应头)、整段读内存改为流式按块读写、范围参数做合法性校验、加防盗链与单连接并发限制,另外把文件名换成内容指纹并配长期缓存头。
这个模块最想说的一句话是:播放器不是「下载完再播」,它是「按需要取文件的某一段」——而这个能力必须由服务端配合,客户端单方面做不到。我花了很久去查播放器组件的用法,以为拖动进度是客户端的事;实际上是我的服务端不支持按范围返回,播放器根本拿不到它想要的那一段。这是我第一次真切体会到「有些客户端能力是服务端协议决定的」。
支持按范围返回不是优化而是必须,因为拖动进度这个能力完全依赖它。我最初以为拖动进度是客户端的事,花了很久去查播放器组件的用法。实际上是服务端不支持按范围返回,播放器根本拿不到它想要的那一段——客户端单方面做不到这件事。代价是要正确处理范围解析、状态码、响应头这一整套(错一项播放器就不认,会退化成整段下载)。判据很简单:这个客户端能力有没有服务端的前置条件——而这是我第一次真切体会到「有些客户端能力是服务端协议决定的」。
流式按块读写而不是整段读进内存。整段读实现最简单(一行读文件、一行写响应)。但几十兆的文件加几个并发请求,内存立刻就上去了——而这个问题在单人测试时完全不出现,我是用脚本并发请求才看到内存曲线抬升的。代价是代码稍复杂(要处理块大小、写入中断)。判据是「单次请求要处理的数据量会不会很大」:视频文件天然很大,那就绝对不能整段进内存——这一条和图片不同,图片压缩后只有几百 KB,整段读是可以接受的。
范围参数一律校验并对过大的范围做截断,而不是照单全收。照单全收更「听话」。但非法范围会让读取抛异常,而这个请求任何人都能构造;而超大范围则等于绕过了范围机制、退化成整段下载。判据是「这个参数来自客户端吗」:来自客户端的一切都要校验,而范围头特别容易被忽略,因为它看起来是「协议的一部分」而不是「用户输入」。
只做简单防盗链,不做签名链接。签名链接(带时效的访问凭据)安全性更高。但我的视频是公开内容(流里所有人都能看),签名的收益主要是防带宽盗用而不是防内容泄露;而带签名的链接每次都不同,会让长期缓存完全失效——那个代价对视频这种大文件是很实在的。判据是「这份资源本身是不是私密的」:不是,那防的只是带宽被占,简单来源校验加并发限制就够。但我把这个边界写清楚了:如果以后有私密视频,必须换成时效凭据。
踩过的坑一:拖动进度条完全没反应,而我一直在怀疑播放器组件。我换了配置、翻了文档、试了不同的属性。后来抓包才看到播放器确实发出了带范围头的请求,而我的接口每次都返回整个文件和一个普通的成功状态码。教训是:客户端功能不生效时,先看看它实际发出的请求是什么、服务端返回的是什么——抓一次包比读十遍文档快。
踩过的坑二:整段读进内存,并发下内存抬升。单人测试完全正常。教训是:内存类问题必须用并发压测才能暴露,而且要看内存曲线而不是看是否报错——因为它在崩掉之前不会有任何异常。
踩过的坑三:构造一个非法范围就能让接口抛异常。我直接把范围头里的数字拿来当读取位置。教训是:范围头虽然是协议的一部分,但它同样是客户端可以任意构造的输入——「看起来像协议」不代表它可信。
没做的部分:没做流式分片播放(把视频切成很多小片、用一个索引文件描述,让播放器逐片拉取)。它是主流做法,能更好地支持码率自适应切换和更快的起播;但它需要转码时额外切片、还要生成索引文件,而客户端也要用支持这种格式的播放方式。取舍依据是「按范围请求已经让拖动和起播正常工作,而分片播放的主要增量收益是码率自适应」——而我的自适应本来就只做了最简单的版本。不过我能说清它是下一步,以及它解决的是什么。
拖动进度是断言型用例,也是这个模块最直接的验证:播放中拖动到视频中后段,断言能正常从该位置继续播放;并抓包断言播放器发出了带范围头的请求、服务端返回了「部分内容」状态码与正确的范围响应头。抓包这一步很关键——它是我定位这个问题的方式,也是证明改动生效的最直接证据。
起播时间要报清片长与限速条件:「一条时长 X 的视频,在下行限速到几百 KB 的条件下,起播时间从 Y 降到 Z」。并说明这个改善来自「只取开头一小段」而不是文件变小了。
内存是这个模块最值得报的一个数字:用脚本并发请求同一个视频文件,报「整段读入内存时服务端内存占用随并发数上升到 X;改为流式读写后基本平稳在 Y」。要说明并发数与文件大小——而且这个问题在单人测试时完全不出现,这一点也要写进去。
非法范围是安全性质的断言用例,要覆盖三种:起始超出文件长度、起始大于结束、范围超大。断言三种都被拒绝且返回明确的状态码,服务端不抛异常。
范围截断:请求一个超大范围,断言实际返回的长度被截断到上限,而不是返回整个文件。
缓存生效:重复播放同一视频,断言范围请求能命中缓存(可以在网络面板里看状态);替换视频内容后断言指纹变化导致路径变化、客户端拉取的是新路径。
防盗链:从一个不允许的来源发起请求,断言被拒绝。
并发限制:用脚本对同一文件开大量并发连接,断言超出限制的被拒绝、且正常播放不受影响(后半句是关键——限制不能把正常用户也挡住)。
不要报什么:不要报「视频加载速度提升 N 倍」——倍数取决于限速和片长。该报的是「抓包证明范围请求与部分内容状态码生效」「某片长与限速下的起播时间对比」「N 个并发请求下整段读入与流式读写的内存对比」「三种非法范围均被拒绝且不抛异常」这几件带条件的、可核对的事。
没有匹配的内容,换个关键词试试。
项目拆解 · 短视频播放流(个人项目 · 无实习档)· 共 6 个模块 · 数字均为示例,需替换成自己项目的真实数据