怎么用 这是格式与深度的参考,不是让你照抄的模板。数字全部是示例,直接抄进简历一问就穿——必须换成你自己的真实数据,而且要能说清是怎么测出来的。下面每个模块都有「数字是怎么测的」和「面试追问」两段,写不出那两段的模块就不要往简历上写

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

无实习这一档是给谁的 完全没有实习经历时,简历上唯一能写的就是自己做的项目。但「图书馆座位预约」「学生成绩管理」这类选题面试官一年看几百份,而硬套企业项目(微服务、分布式事务、消息中间件)会被一问就穿——他很清楚学生不可能有那种环境。这一档收的是复刻主流产品的核心链路、一个人真能做出来、技术栈朴素但有真实难点的项目。这一档的模块比企业档多——因为企业项目里你只被分到一个模块,而个人项目是从上传转码到静态资源分发全都你自己写的,能讲的面本来就宽。下面六个模块彼此独立——只做过哪几个就只写哪几个。
怎么讲才不像课设 短视频这个方向的含金量不在「我能播视频」,而在你知道为什么它们能滑一下就出画面:手机拍的原片有各种编码和分辨率、直接播会卡甚至播不出来,所以要转码;首帧要快,所以关键帧要放在开头、还要把索引信息移到文件前面;滑动要跟手,所以下一条要提前缓冲、播放器要复用而不是每条都新建。这几件事都不需要云服务,一台机器上用命令行工具就能做,但能讲清的人不多。
只写你当场证得出来的 个人项目最容易穿的不是技术,是那些用来撑场面、但一追问就散的外部背书:「有几百个注册用户」会被问用户从哪来、怎么收集反馈、留存多少;「开源项目」「有人 star」「参赛获奖」同样如此——面试时你拿不出来,说了等于给对方一个突破口正确的立足点只有一条:我没有真实流量,但我能复现问题、能讲清每个数字的来路。这一页的数字都来自几十条自己拍的真实视频、限速环境、以及旧安卓机上的实测——这些条件面试时你能一句话说清。
三条自检 一、你踩的坑能不能复现——能说清「用什么片源、什么机型、限速到多少,它就必然出现」;二、能说出坑的根因,尤其是「在我电脑上播得很好」的那种;三、每个数字都能回答「这个数是怎么来的」(几条视频、多大码率、什么机型)。三条都有就能写。
项目背景设定 我照着主流短视频产品的样子做了一个上下滑动的播放流(上传、转码、封面、竖屏滑动播放、进度与互动),Spring Boot + MySQL + Redis + uni-app转码用服务器上装的 ffmpeg 命令行工具,视频文件存在服务器本地目录,一台服务器,没有用任何云端转码服务。
数据和测试条件先说清楚 这是个人项目,没有真实用户量。数字都来自我构造的条件:用手机拍了几十条真实视频作为片源(不同机型、不同分辨率与编码、时长从几秒到几分钟,包含竖屏与横屏、以及一条刻意很大的高码率原片);用开发者工具把下行限到几百 KB 每秒模拟弱网;在一台旧安卓机上验证滑动流畅度与内存这些条件我能当场复述,也能现场再跑一遍。
为什么这六块值得写 它们的共同点是在我的电脑上用浏览器播完全正常,换到手机、换到弱网就全都不行。前三块(上传转码、滑动流预加载、播放上报)是这条主线:有的片子直接播不出来、首帧要等好几秒、滑到第十几条开始卡;而我把这三件事解决之后,反过来看懂了主流短视频产品的几个设计——为什么它们要转码、为什么滑一下就有画面、为什么滑很久也不卡。后三块(分片上传、流的排序与去重、静态资源与拖动进度)是一个人做完整链路绕不开的部分几十上百兆的视频不分片根本传不上去「刷到重复的视频」是这类产品最容易被骂的一点而拖动进度条能不能生效取决于服务端支不支持按范围取数据六块都是我自己写的,所以每一块我都能讲到根因;这也是个人项目相比「企业里被分到一个模块」的唯一优势——面比较宽,别浪费。

模块一:上传与本地转码流水线

  1. 上传与本地转码流水线(统一编码与分辨率解决兼容 + 索引信息前置让首帧可提前 + 转码异步化不阻塞上传 + 抽帧生成封面与失败兜底)★★★
    简历这样写 短视频播放流(个人项目)(Spring Boot + ffmpeg 命令行 + MySQL + uni-app):不同机型拍摄的原片编码与分辨率各异,部分片源在移动端直接无法播放,改为上传后统一转码为固定编码与若干档分辨率;转码参数上把索引信息移到文件头部并缩短关键帧间隔,使播放器无需下载整个文件即可开始解码,首帧等待明显缩短;转码耗时较长(一条几分钟的片子在我的机器上要几十秒到几分钟),原先同步在上传接口内执行导致接口超时,改为上传后立即返回、转码异步排队执行、状态可查,并对同时转码的数量做限制(不限制会把机器 CPU 占满导致其他请求也变慢);转码时抽取指定时间点的帧作为封面,并对抽帧失败与转码失败给出明确状态与重试入口,避免视频永久停在「处理中」。以上问题均在真机与限速条件下复现。
    展开完整拆解
    为什么要这么设计

    我最初的实现极其简单:上传原文件、存到目录里、把地址返回给客户端播放我在电脑浏览器上测了几个片子都能播,以为这块就完事了。换到手机上、换到弱网,四个问题全出来了。

    一是有些片子在手机上直接播不出来。我用不同手机拍的片源编码和封装格式并不一致,其中几条在安卓上一片黑、有的只有声音没有画面而同样的文件在我的电脑浏览器上播得很好——因为电脑上的解码支持范围比移动端宽得多。这让我明白「能播」不是文件的属性,是「文件 + 播放环境」共同决定的。

    二是首帧等很久。限速之后,点开一条视频要等好几秒才出画面。我一开始以为这是带宽问题、没什么可做的;后来查了才知道,有些片子的索引信息(描述整个文件结构的那部分)被放在文件末尾,播放器必须先把整个文件下完才能开始解码——而这个位置是可以在转码时移到文件开头的。移过去之后播放器只要下开头一小段就能起播。

    三是上传接口超时。我把转码放在上传接口里同步执行。一条几分钟的片子在我的机器上要转几十秒到几分钟,接口直接超时了而更糟的是超时之后转码进程还在跑,用户以为失败了又传一遍,机器上同时跑起好几个转码,CPU 占满,连查列表的接口都变慢了。

    四是转码失败的视频永久停在「处理中」。我没有失败状态,只要转码没成功,那条视频就一直显示处理中,用户既看不到也删不掉。

    所以四个改动:统一转码为固定编码与若干档分辨率转码参数上把索引信息前置并缩短关键帧间隔转码改为异步排队并限制同时执行的数量抽帧生成封面并给失败明确状态与重试入口

    这个模块最想说的一句话是:转码不是「为了压缩体积」,它的首要作用是把千奇百怪的输入变成一种播放器一定能处理、而且能快速起播的形式。我原来以为转码就是压缩,所以第一反应是「我的片子不大,不用转」——直到有片子在手机上根本播不出来,我才理解它真正解决的是兼容性和可播性。

    整体链路
    上传(不要在接口里做转码) │ ├─ 客户端选片 → 上传原文件 → 服务端存盘 → 立即返回 │ 视频记录状态置为「处理中」,同时入转码队列 │ ├─ 同步转码的后果(我踩过) │ 一条几分钟的片子要转几十秒到几分钟 → 接口超时 │ 超时后转码进程还在跑,用户以为失败又传一遍 │ 机器上同时跑起好几个转码 → CPU 占满 → 连列表接口都变慢 │ └─ 上传本身也要限制大小与格式,明显不合规的直接拒绝 不拦的后果:一个超大文件把磁盘和转码队列都堵住 转码(用服务器上的 ffmpeg 命令行,一台机器就够) │ ├─ 统一输出编码与封装格式 │ 不统一的后果:不同手机拍的片源编码不一,部分在移动端播不出来 │ 同样的文件在电脑浏览器上却能播 —— 解码支持范围不同 │ ├─ 输出若干档分辨率(高 / 中 / 低) │ 客户端按网络状况选择,弱网选低档 │ ├─ 关键参数:把索引信息移到文件头部 │ 有些片源的索引在文件末尾 → 播放器必须下完整个文件才能解码 │ 移到开头后,只下开头一小段就能起播 —— 这是首帧变快的主因 │ ├─ 关键参数:适当缩短关键帧间隔 │ 间隔太长 → 起播和拖动进度都要等到下一个关键帧 │ 间隔太短 → 体积增大,要权衡 │ └─ 转码是 CPU 密集的,必须限制同时执行的数量 不限制的后果:CPU 被占满,整台机器上所有接口都变慢 任务与状态(用户要能知道进展) │ ├─ 状态:处理中 → 可播放 / 处理失败 │ ├─ 队列串行或小并发消费,每个任务记录开始与结束时间 │ ├─ 失败要有明确状态与重试入口 │ 不做失败态的后果:视频永久停在「处理中」,看不到也删不掉 │ ├─ 转码要幂等:同一个视频重复入队不产生两份输出 │ └─ 超长或异常片源要有超时保护 一个损坏的文件可能让转码进程长时间挂住 封面 ├─ 转码时抽取指定时间点的帧作为封面(不要取第 0 秒) │ 第 0 秒经常是黑屏或者镜头还没稳 ├─ 抽帧失败要有兜底封面,不要让列表出现空白卡片 └─ 封面按列表展示尺寸出图,不要用原始分辨率 清理 ├─ 转码成功后原片可按策略保留或删除(我选择保留一段时间) ├─ 上传后长时间未关联到任何视频记录的临时文件要定时清理 └─ 转码失败重试多次仍失败的任务要落到一个可人工查看的列表
    分步拆解
    1. 转码必须移出上传接口。同步执行的后果是接口超时;而超时后转码还在跑,用户以为失败又传一遍,机器上同时跑起好几个转码把 CPU 占满。
    2. 上传后立即返回并把状态置为「处理中」。客户端据此展示,而不是让用户干等。
    3. 上传本身要限制大小与格式。不拦的后果是一个超大文件把磁盘和转码队列都堵住。
    4. 必须统一输出编码与封装格式。不统一的后果是部分片源在移动端播不出来(一片黑或只有声音)——而同样的文件在电脑浏览器上能播,因为解码支持范围不同。
    5. 输出若干档分辨率,让客户端按网络状况选。只出一档的话,弱网下用户只能等高码率的片子。
    6. 把索引信息移到文件头部,这是首帧变快的主因。有些片源的索引在末尾,播放器必须下完整个文件才能开始解码;移到开头后只下开头一小段就能起播。
    7. 适当缩短关键帧间隔。间隔太长则起播和拖动进度都要等到下一个关键帧,太短则体积增大,要权衡。
    8. 转码是 CPU 密集的,必须限制同时执行的数量。不限制的后果是CPU 被占满,整台机器上所有接口都变慢——包括跟视频完全无关的接口。
    9. 转码要幂等,同一个视频重复入队不产生两份输出。重试和重复提交都会导致重复入队。
    10. 必须有「处理失败」状态和重试入口。没有失败态的后果是视频永久停在「处理中」,用户既看不到也删不掉。
    11. 要有超时保护。一个损坏的文件可能让转码进程长时间挂住,占着并发额度不放。
    12. 封面抽帧不要取第 0 秒。第 0 秒经常是黑屏或镜头还没稳,取一个稍后的时间点效果好得多。
    13. 抽帧失败要有兜底封面。不要让列表出现空白卡片。
    14. 封面按列表展示尺寸出图。用原始分辨率会让列表下载量大幅增加。
    15. 上传后长时间未关联到视频记录的临时文件要定时清理。用户选完片放弃发布的情况很常见。
    16. 多次重试仍失败的任务要落到一个可查看的列表。否则失败就消失了,你永远不知道有多少片子转不了、为什么转不了。
    关键决策与取舍

    用服务器上的 ffmpeg 命令行做转码,而不是接云端转码服务。云服务省事、有弹性。但对个人项目来说它意味着费用和一个我无法在本地完整调试的黑盒而命令行工具的好处是参数完全可控、我能一条条试出「索引前置」「关键帧间隔」这些参数的效果,也能在本机复现所有问题代价是转码占本机 CPU、并发能力受机器限制——所以必须限制同时转码的数量。判据是「我需要的是弹性还是可控」:我需要可控,因为这个模块的价值恰恰在于我理解了那些参数在做什么。

    转码异步化的代价是引入了「处理中」这个中间态。同步转码的好处是接口返回时视频就能播了、没有中间状态。但它会超时,而且超时后进程还在跑,用户重传会造成 CPU 雪崩异步之后必须补三件事:状态可查(否则客户端不知道显示什么)、失败态与重试入口(否则永久卡在处理中)、幂等(否则重复入队产生两份输出)。判据是「这个操作的耗时是否远超一次请求的合理等待」:几十秒到几分钟,远超了,那就必须异步。

    限制同时转码的数量,而不是来一个转一个。不限制的话吞吐看起来更高。但转码是 CPU 密集的,几个任务同时跑就能把机器占满——而这台机器还要处理所有的业务接口我实测过:三四个转码同时跑的时候,连查列表这种简单接口的耗时都明显上升了判据是「这个任务和线上请求是否共享同一份资源」:共享 CPU,那就必须给它设上限,宁可转码排队慢一点,也不能让业务接口一起变慢。

    输出多档分辨率而不是只出一档。只出一档最省转码时间和磁盘。但弱网下用户只能等高码率的片子,体验很差而只出低档则在好网络下画质浪费了。代价是转码时间和存储都要乘以档数——所以我只出了少量几档,而不是像大平台那样一堆档位。档位数量的选择依据是「我的机器转得过来、磁盘装得下」。

    踩过的坑一:有些片子在手机上播不出来,而电脑浏览器上完全正常。我一开始以为是客户端代码的问题,查了播放器的配置很久。后来换了几个不同手机拍的片源对比,才发现是编码格式的差异——移动端的解码支持范围比电脑浏览器窄得多。教训是:「能播」不是文件的属性,是「文件 + 播放环境」共同决定的而转码的首要作用不是压缩,是把千奇百怪的输入变成一种播放环境一定能处理的形式。我原来一直以为转码就是压缩,所以第一反应是「我的片子不大,不用转」。

    踩过的坑二:首帧慢,我以为是带宽问题、没什么可做的。后来才知道有些片源的索引信息被放在文件末尾,播放器必须先下完整个文件才能开始解码——而这个位置在转码时是可以移到文件开头的。移过去之后只要下开头一小段就能起播。教训是:把一个问题归因到「物理限制」之前,先确认它是不是真的物理限制——我差点就放弃了这个优化。

    踩过的坑三:上传接口超时之后,用户重传导致 CPU 被打满。因为超时只是客户端不等了,服务端的转码进程还在跑教训是:耗时任务放在请求里的危害不止「超时」,还有「超时后用户重试造成的叠加」——而叠加会影响整台机器上所有的功能。

    没做的部分:没做转码的分布式调度(多台机器分担转码任务)。因为我只有一台机器,这个问题不存在——硬做一个用不上的调度层不如说清「单机下靠限制并发数保护业务接口,多机时需要解决任务分发与结果回收」。我认为能说清这个边界比假装处理过更好。

    数字是怎么测的

    兼容性的报法是覆盖率而不是比例:「用不同机型拍摄的 N 条片源作为输入,转码前有 M 条在测试机上无法正常播放,转码后全部可播」。要说明测试机型和片源来源——这个结论依赖于「我只测了这几台机器」,不能说成普适。

    首帧时间是这个模块最有说服力的数字,但必须带限速条件:「下行限到几百 KB 每秒时,同一条片子的首帧等待从 X 秒降到 Y 秒」。并说明这个改善主要来自「索引信息前置」,而不是体积变小——可以补一个对照:只压体积不改索引位置时首帧几乎没有改善,这个对照能直接证明归因是对的。

    关键帧间隔的取值要报权衡过程:「分别试几组间隔,记录起播时间与输出体积,取一个平衡点」。直接给一个数字而不说怎么来的就是拍的。

    转码耗时要报清片源规格:「一条时长 X、分辨率 Y 的片子,在我的机器上转码耗时 Z」。要说明机器配置,因为转码耗时几乎完全由 CPU 决定。

    并发限制的效果用对照实测:「同时跑 N 个转码时,一个简单列表接口的耗时从 A 上升到 B;限制并发数之后回落」。这个对照直接说明了「为什么要限制」,比只说「限制了并发」有力得多。

    失败态:喂一个损坏的文件,断言转码任务在超时后进入失败状态、视频显示为处理失败且可重试或删除,而不是永久停在处理中。

    幂等:把同一个视频重复入队,断言只产生一份输出。

    封面:断言抽帧不取第 0 秒、抽帧失败时使用兜底封面、封面尺寸按列表展示尺寸出图。

    不要报什么:不要报「支持所有格式」——我只测了手上这几台手机拍的片源。该报的是「N 条片源中转码前 M 条不可播、转码后全部可播(附测试机型)」「限速条件下首帧时间对比,并用『只压体积不改索引』作对照」「N 个并发转码对业务接口耗时的影响及限制后的回落」「损坏文件进入失败态而非永久处理中」这几件带条件的、可核对的事。

    面试追问
    Q:视频上传上去直接播不就行了,为什么要转码? A:我一开始就是直接播的,而且以为转码只是为了压缩体积——所以第一反应是「我的片子不大,不用转」。是两个问题打醒我的。第一个是兼容性:我用不同手机拍的片源编码和封装格式并不一致,其中几条在安卓测试机上一片黑、有的只有声音没有画面——而同样的文件在我的电脑浏览器上播得很好。我一开始以为是客户端代码的问题,查播放器配置查了很久;后来换几个不同机型拍的片源对比,才发现是编码差异——移动端的解码支持范围比电脑浏览器窄得多这让我明白「能播」不是文件的属性,是「文件 + 播放环境」共同决定的;而转码的首要作用不是压缩,是把千奇百怪的输入变成一种播放环境一定能处理的形式。第二个是首帧慢,这个更有意思:限速之后点开一条视频要等好几秒才出画面,我以为这是带宽问题、没什么可做的。后来才知道有些片源的索引信息(描述整个文件结构的那部分)被放在文件末尾,播放器必须先把整个文件下完才能开始解码——而这个位置在转码时可以移到文件开头,移过去之后只要下开头一小段就能起播,首帧时间明显下降。教训是:把一个问题归因到「物理限制」之前,先确认它是不是真的物理限制——我差点就放弃了这个优化。另外我还适当缩短了关键帧间隔(间隔太长的话起播和拖动进度都要等到下一个关键帧,太短则体积增大,我试了几组取的平衡点)。这些参数我是用服务器上的 ffmpeg 命令行一条条试出来的,没有用云端转码服务——因为这个模块的价值恰恰在于我理解了那些参数在做什么。
    Q:转码放在上传接口里有什么问题?加大超时时间不行吗? A:加大超时解决不了根本问题,而且我踩到的坑比「超时」严重得多。现象是:一条几分钟的片子在我的机器上要转几十秒到几分钟,上传接口直接超时但真正糟糕的是超时之后——客户端不等了,可服务端的转码进程还在跑;用户以为失败了又传一遍,于是机器上同时跑起好几个转码CPU 被占满,连查列表这种简单接口的耗时都明显上升了教训是:耗时任务放在请求里的危害不止「超时」,还有「超时后用户重试造成的叠加」——而叠加会影响整台机器上所有的功能,包括和视频完全无关的接口。所以我改成上传后立即返回、状态置为「处理中」、转码异步排队执行异步之后必须补三件事,否则会引入新问题状态可查(否则客户端不知道显示什么)、失败态与重试入口(我一开始没做失败态,结果转码失败的视频永久停在「处理中」,用户既看不到也删不掉)、以及转码幂等(重试和重复提交都会导致重复入队,产生两份输出)。还有一个我认为最关键的配套决策:限制同时转码的数量。不限制的话吞吐看起来更高,但转码是 CPU 密集的,而这台机器还要处理所有业务接口——我实测过三四个转码同时跑的时候,列表接口耗时明显上升判据是「这个任务和线上请求是否共享同一份资源」:共享 CPU,那就必须设上限,宁可转码排队慢一点,也不能让业务接口一起变慢。另外还加了超时保护,因为一个损坏的文件可能让转码进程长时间挂住、占着并发额度不放。

模块二:滑动流的预加载与播放器复用

  1. 滑动流的预加载与播放器复用(只预缓冲相邻一条而非全部 + 三个播放器实例轮转替代逐条新建 + 划走即暂停并释放 + 封面兜底消除起播空白)★★★
    简历这样写 上下滑动播放流(uni-app + Vue 3):初版为列表内每条视频各创建一个播放器实例,滑到十几条后出现明显卡顿与内存增长(旧安卓机上更严重),改为固定三个实例轮转复用(当前、上一条、下一条),实例数不再随滑动增长;预加载由进入页面即缓冲多条改为只预缓冲相邻的下一条——全部预缓冲会与当前正在播放的视频抢带宽,反而拖慢起播;划走的视频立即暂停并释放缓冲,避免多路视频同时占用带宽与解码资源;起播前先展示封面图,消除了首帧到达前的黑屏空白;同时按网络状况选择分辨率档位(弱网选低档)。改造后滑动到数十条时帧率与内存基本平稳,相关问题均在旧安卓机与限速条件下复现。
    展开完整拆解
    为什么要这么设计

    上下滑动播放这个交互看着简单——一个纵向的列表,每一屏一个视频,滑到哪个播哪个。我照这个思路写完之后,在自己手机上滑了几条觉得挺顺。换到旧安卓机、再限一下速,四个问题全出来了。

    一是滑到十几条之后明显卡顿,内存一直涨。我的写法是列表里每条视频各渲染一个播放器组件滑动时旧的组件并没有被销毁,于是播放器实例越来越多——每个实例都占着解码资源和内存,在旧安卓机上滑到十几条就已经很卡了。

    二是我加了预加载之后,起播反而变慢了。我以为「多缓冲几条体验更好」,就在进入页面时把前面好几条都开始缓冲结果在限速条件下,这几路缓冲和当前正在播放的那一条互相抢带宽——当前这条的起播和续播都变慢了。预加载本来是为了让下一条更快,却把当前这条拖慢了。

    三是划走的视频还在后台跑。我只做了「划到新的一条就播新的」,没有暂停旧的结果是划过几条之后,好几路视频同时在下载和解码——带宽和 CPU 都被分走了,而用户只在看一条。

    四是起播前是一片黑。从划到位到首帧出来之间有一段空白,限速下这段空白有一两秒而我明明已经有封面图了,却没用上。

    所以四个改动:改为固定三个播放器实例轮转复用预加载只做相邻的下一条划走立即暂停并释放缓冲起播前先展示封面图,另外补了按网络状况选分辨率档位

    这个模块最想说的一句话是:预加载和缓存都不是免费的——它们在和「当前正在用的资源」抢同一份带宽和内存。我一开始把「预加载」当成纯收益,所以越加越多,结果把当前这条拖慢了想清楚之后我的原则变成:预加载的范围应该是「用户下一步最可能要用的那一点点」,而不是「所有可能用到的」。这条原则我后来在图片轮播、列表分页上都用到了。

    整体链路
    播放器实例:固定数量轮转,不要逐条新建 │ ├─ 只保留三个实例:当前 · 上一条 · 下一条 │ 滑动时不是「创建新的」,而是「把最远的那个实例挪过来复用」 │ ├─ 每条视频各建一个实例的后果(我踩过) │ 滑动时旧组件没被销毁,实例越来越多 │ 每个实例都占解码资源和内存 │ 旧安卓机上滑到十几条就已经很卡了 │ ├─ 复用时要彻底重置状态:地址 · 进度 · 播放状态 · 事件监听 │ 不重置的后果:串台(新视频显示上一条的进度)、事件重复绑定 │ └─ 实例数为三是因为「上下各留一个」就够了 留更多不会更流畅,只会占更多内存 预加载:只做相邻的下一条 │ ├─ 当前条播放中 → 让「下一条」的实例开始缓冲一小段 │ ├─ 缓冲多条的后果(我踩过,是负优化) │ 限速下这几路缓冲和当前播放的那一条互相抢带宽 │ 当前这条的起播和续播都变慢了 │ 预加载本来是为了让下一条更快,却把当前这条拖慢了 │ ├─ 只缓冲开头一小段就够,不要缓冲整条 │ 用户可能划过去就走了,缓冲整条是浪费 │ └─ 弱网时可以进一步关掉预加载 判断依据是网络类型或实测下载速度 划走就要停:释放资源 │ ├─ 划出屏幕 → 立即暂停 → 释放缓冲 │ ├─ 不暂停的后果 │ 划过几条之后好几路视频同时在下载和解码 │ 带宽和 CPU 都被分走,而用户只在看一条 │ ├─ 划回来时从上次位置继续(记住每条的播放进度) │ └─ 页面切到后台 → 全部暂停 不暂停的后果:用户切走了还在耗流量和电 起播体验:先封面,后画面 │ ├─ 划到位的瞬间先把封面图铺满,首帧到达后再切换 │ 不铺封面的后果:划到位到首帧之间是一片黑(限速下有一两秒) │ ├─ 封面来自转码时抽的帧,尺寸按屏幕出图 │ ├─ 首帧到达后再淡出封面,切换要无缝 │ └─ 加载超过一定时间才显示加载指示 立刻显示转圈的话,正常情况下会看到一闪,反而显得慢 分辨率档位选择 ├─ 按网络类型或实测速度选择档位,弱网选低档 ├─ 播放中网络变差可以降档(我做的是简单策略:卡顿几次后降一档) └─ 不做自适应的后果:弱网下反复卡顿,用户直接划走 进度与状态 ├─ 记住每条视频的播放进度,划回来能续播 ├─ 播放失败要有明确提示与重试,不要一直转圈 └─ 进度上报要节流,不要每秒都发请求
    分步拆解
    1. 播放器实例必须固定数量轮转复用,不能逐条新建。逐条新建的后果是实例越来越多,每个都占解码资源和内存,旧安卓机上滑到十几条就很卡了。
    2. 三个实例就够:当前、上一条、下一条。留更多不会更流畅,只会占更多内存。
    3. 复用时必须彻底重置状态:地址、进度、播放状态、事件监听。不重置的后果是串台(新视频显示上一条的进度)和事件重复绑定。
    4. 预加载只做相邻的下一条。缓冲多条是负优化——限速下这几路缓冲和当前播放的互相抢带宽,把当前这条拖慢了。
    5. 只缓冲开头一小段,不要缓冲整条。用户可能划过去就走了,缓冲整条是浪费。
    6. 弱网时可以进一步关掉预加载。因为这时带宽本来就不够,预加载抢走的那部分影响更明显。
    7. 划出屏幕要立即暂停并释放缓冲。不暂停的后果是好几路视频同时在下载和解码,而用户只在看一条。
    8. 划回来时要从上次位置继续。需要记住每条的播放进度——这是很小的成本但体验差别明显。
    9. 页面切到后台要全部暂停。不暂停的后果是用户切走了还在耗流量和电。
    10. 划到位的瞬间先铺封面图,首帧到达后再切换。不铺封面的后果是划到位到首帧之间是一片黑,限速下有一两秒。
    11. 封面切换要无缝(淡出),不要硬切。硬切会有一次明显的跳变。
    12. 加载指示要延迟一点才显示。立刻显示转圈的话,正常情况下会看到一闪,反而显得慢。
    13. 按网络状况选分辨率档位,弱网选低档。不做的后果是弱网下反复卡顿,用户直接划走。
    14. 播放中卡顿多次可以降一档。我做的是最简单的策略(连续卡顿几次就降),没有做复杂的自适应——但要能说清这是简化版。
    15. 播放失败要有明确提示与重试。不要一直转圈让用户不知道发生了什么。
    16. 进度上报要节流。每秒都发请求的话,滑动流上的请求量会非常大。
    关键决策与取舍

    固定三个播放器实例轮转,而不是逐条新建再销毁。逐条新建的代码最简单(就是一个 v-for)。但播放器是重对象——它占解码资源、占内存、还可能持有网络连接实例数随滑动增长在旧机器上很快就撑不住代价是复用逻辑复杂一些:必须彻底重置状态,否则会串台(新视频显示上一条的进度)和事件重复绑定。判据是「这个对象是轻的还是重的」:重对象就必须池化复用,而不能依赖框架帮你销毁——我最初就是指望组件销毁会自动释放,实际并没有那么干净。

    预加载只做一条,这是我从「负优化」里学到的。我最初以为预加载是纯收益、越多越好。实测发现在限速条件下,多路缓冲和当前正在播放的那一条抢同一份带宽——当前这条的起播和续播都变慢了判据是「预加载抢占的资源会不会影响当前正在用的东西」:会,那范围就必须受限。我总结出的原则是:预加载的范围应该是「用户下一步最可能要用的那一点点」,而不是「所有可能用到的」——这条原则我后来在图片轮播和列表分页上都用到了。

    划走立即暂停并释放,而不是让它继续缓冲以备划回来。继续缓冲的好处是划回来能立刻播。但用户划走之后回来的概率并不高,而继续缓冲的成本是持续占带宽——而带宽是当前正在看的那条最需要的东西取舍是保留播放进度(很轻)但释放缓冲(很重):划回来时从记住的位置重新起播,体验差别不大但资源省很多。

    分辨率自适应只做了最简单的版本。完整的自适应需要按实时带宽估计动态切换、还要处理切换时的画面衔接。我做的是「按网络类型选初始档位 + 连续卡顿几次降一档」——它明显是简化版,但我能说清简化在哪、以及完整版要额外解决什么我认为这比硬做一个半成品的自适应然后说不清细节要好。

    踩过的坑一:每条视频各建一个播放器,滑到十几条就卡。我最初指望组件销毁会自动释放播放器资源,实际上并没有那么干净——实例累积、内存持续增长。我是在旧安卓机上滑了一会儿看内存曲线才确认的。教训是:重对象不要依赖框架的自动回收,要显式池化和释放而且「在我的新手机上很顺」完全不能说明问题,这类资源问题必须在最差的机器上验。

    踩过的坑二:预加载做多了反而更慢,这是我第一次遇到「优化变负优化」。我加完之后在好网络下确实感觉不出问题,限速之后才发现当前这条的起播明显变慢了教训是:任何「提前准备」的优化都要问它抢的是谁的资源——如果抢的是当前正在用的资源,那它很可能是负优化。

    踩过的坑三:划走不暂停,好几路视频同时在跑。我是在开发者工具里看网络请求才发现的——同时有好几条视频在下载教训是:滑动流这种「一屏一个」的交互,必须显式处理「离开」这个事件;只处理「进入」的话,离开的那些会一直留在活跃状态。

    没做的部分:没做视频的本地缓存(看过的视频存到本地,再看不重新下载)。它能省流量,但要处理缓存容量上限、淘汰策略、以及缓存文件的清理而短视频的重复观看率本来就不高取舍依据是「收益(省流量)小于复杂度(一套本地缓存管理)」——如果是音乐或课程这类会重复播放的内容,我的结论会反过来。

    数字是怎么测的

    播放器实例数是这个模块最本质的指标:「滑动到 N 条时的播放器实例数,改造前随滑动线性增长、改造后恒定为 3」这个数字比帧率更能解释问题,而且很容易核对。

    内存要报机型和条数:「在某型旧安卓机上滑动到 N 条时的内存占用,改造前持续增长到 X、改造后稳定在 Y 附近」。要说明是用什么工具看的、以及滑动的速度和节奏(快速滑和慢慢滑的结果不同)。

    帧率同样要带机型和条数。只报一个帧率数字没有参考价值。

    预加载的负优化要用对照实测,这是最有说服力的一组数据:「限速到几百 KB 时,预缓冲多条与只缓冲一条两种策略下,当前视频的起播时间分别是 X 和 Y」。报出「多缓冲反而更慢」这个反直觉的结果,比报「我做了预加载」有意思得多。

    划走暂停的效果:报「连续滑过 N 条后,同时进行的视频下载请求数从 M 降到 1」。这个数字可以直接在开发者工具的网络面板里数出来。

    封面兜底:限速条件下断言划到位的瞬间就有画面(封面),首帧到达后无缝切换、过程中不出现黑屏

    复用不串台是断言型用例:快速来回滑动,断言每条视频显示的是自己的进度、不会出现上一条的残留状态;并断言事件监听没有重复绑定(可以数回调触发次数)。

    进度续播:划走再划回,断言从上次位置继续。

    不要报什么:不要报「滑动流畅度提升 N%」——流畅度不是单一数字。该报的是「实例数从线性增长变为恒定 3」「某机型某条数下的内存与帧率」「两种预加载策略下当前视频起播时间的对照」「连续滑过后并发下载请求数从 M 降到 1」这几件带条件的、可核对的事。

    面试追问
    Q:滑动流不就是一个列表每项放一个播放器吗?为什么要复用实例? A:我一开始就是那么写的(一个 v-for,每条一个播放器组件),在我自己的新手机上滑几条觉得挺顺——换到旧安卓机上滑到十几条就已经很卡了,而且内存一直在涨。我在旧机器上看内存曲线才确认根因:滑动时旧的播放器组件并没有被干净地销毁,实例越来越多,每个实例都占着解码资源和内存我最初是指望组件销毁会自动释放播放器资源,实际上并没有那么干净。教训是:重对象不要依赖框架的自动回收,要显式池化和释放——播放器是典型的重对象,它占解码资源、占内存、还可能持有网络连接。改法是固定三个实例轮转复用:当前、上一条、下一条;滑动时不是「创建新的」,而是把最远的那个实例挪过来复用为什么是三个?因为上下各留一个就够了,留更多不会更流畅,只会占更多内存。复用有一个必须处理的细节:每次复用都要彻底重置状态——地址、进度、播放状态、事件监听。不重置会串台(新视频显示上一条的进度)和事件重复绑定,我为此专门写了一条回归用例:快速来回滑动,断言每条显示的是自己的进度、并数回调触发次数确认没有重复绑定。另外一个配套的改动是「划走就暂停并释放缓冲」——我最初只处理了「划到新的一条就播新的」,没有暂停旧的,结果在开发者工具的网络面板里看到同时有好几条视频在下载教训是:滑动流这种「一屏一个」的交互,必须显式处理「离开」这个事件,只处理「进入」的话离开的那些会一直留在活跃状态。
    Q:预加载多缓冲几条不是更流畅吗?为什么只缓冲一条? A:我一开始就是多缓冲的,而且以为预加载是纯收益、越多越好——实测发现它是负优化,这是我第一次遇到这种情况。我在进入页面时就把前面好几条都开始缓冲。在好网络下确实感觉不出问题;把下行限到几百 KB 之后就很明显了:这几路缓冲和当前正在播放的那一条互相抢带宽,当前这条的起播和续播都变慢了预加载本来是为了让下一条更快,结果把当前这条拖慢了。判据是「预加载抢占的资源会不会影响当前正在用的东西」:会,那范围就必须受限。我总结出的原则是:预加载的范围应该是「用户下一步最可能要用的那一点点」,而不是「所有可能用到的」——后来我在图片轮播、列表分页上都用了这条原则。具体做法是只让「下一条」开始缓冲,而且只缓冲开头一小段而不是整条(用户可能划过去就走了,缓冲整条是浪费);弱网时进一步关掉预加载,因为这时带宽本来就不够、抢走的那部分影响更明显。我报这个数据的时候是用对照的方式:限速到几百 KB,预缓冲多条与只缓冲一条两种策略下,当前视频的起播时间分别是多少——报出「多缓冲反而更慢」这个反直觉的结果,比说「我做了预加载」有意思得多,而且它证明我是实测过的。另外一个几乎零成本但效果很直接的改动是起播前先铺封面图:划到位到首帧之间限速下有一两秒的黑屏,而我明明已经有转码时抽的封面帧了,却一直没用上。

模块三:播放数据上报与去重统计

  1. 播放数据上报与去重统计(上报节流与合并批量发送 + 划过不算播放的口径定义 + 同一次播放的去重 + 完播率统计口径与拖动的处理)★★
    简历这样写 播放数据上报(uni-app + Spring Boot + Redis):初版每秒上报一次播放进度,滑动流上请求量极大且大部分数据无用,改为本地累计、按节流与关键节点(起播、划走、完播)合并批量上报,请求量下降到原来的很小一部分;明确「播放」的口径——快速划过不计入播放量(要求实际播放时长超过阈值),修复了「滑动一次首页播放量涨几十」的统计失真;同一次播放引入本次会话内的播放标识,避免网络重试与前后台切换导致同一次播放被统计多次;完播率的口径明确为「累计观看时长占视频时长的比例超过阈值」而非「播放到最后一帧」,从而正确处理拖动进度与循环播放;上报失败时本地暂存并在下次合并发送,弱网下不丢数据也不阻塞播放。统计结果与本地记录可对账。
    展开完整拆解
    为什么要这么设计

    做完播放之后我想加一个「播放量」和「完播率」,本来以为是最简单的一块。实际上它是这个项目里我唯一一个「怎么定义都好像不对」的模块,而问题全在口径上,不在技术上。四个问题。

    一是每秒上报一次,请求量大到不合理。我在播放进度回调里直接发请求上报。一条视频看一分钟就是六十个请求——而在滑动流上用户快速划过十几条,请求数就更夸张了更荒唐的是这些请求里绝大部分数据是没用的(我只关心最终看了多久)。

    二是播放量严重虚高。我的口径是「起播就算一次播放」。结果是用户快速划过首页,每条视频都触发了起播——滑动一次首页播放量涨几十这个数字完全没有意义,而且它会误导我自己(我一开始还以为哪条视频突然火了)。

    三是同一次播放被统计多次。上报失败重试、切后台再切回来重新起播、划走划回,都会各产生一次「播放」同一个人看同一条视频,播放量涨了好几。

    四是完播率的口径想错了。我一开始定的是「播放到最后一帧算完播」。但用户可能拖动进度直接跳到结尾——只看了两秒也算完播而短视频经常循环播放,播到结尾再从头开始,这时候算几次完播也说不清。

    所以四个改动:上报改为本地累计、按节流与关键节点合并批量发送明确「播放」需要实际播放时长超过阈值引入本次播放的标识做去重完播率改用「累计观看时长占视频时长的比例」

    这个模块最想说的一句话是:统计类需求的难点几乎全在「口径定义」上,而不在怎么把数字算出来。「播放量」这三个字看起来不言自明,但「划过算不算」「重试算不算」「同一个人看两次算不算」每一个都要定,而定错了数字就是废的我最大的收获是学会了在写代码之前先把口径写下来——而且写下来之后我发现自己原来的想法有一半是站不住的。

    整体链路
    口径先定义清楚(这一步比写代码重要) │ ├─ 一次「播放」= 实际播放时长超过某个阈值 │ 「起播就算」的后果:快速划过首页,播放量涨几十,数字是废的 │ ├─ 同一次播放 = 同一个本地播放标识 │ 从起播时生成,直到划走或结束才失效 │ 不定义的后果:重试 · 切后台再回来 · 划走划回 都各算一次 │ ├─ 「完播」= 累计观看时长占视频时长的比例超过阈值 │ 不是「播放到最后一帧」 │ 定成最后一帧的问题 │ 用户拖动进度跳到结尾 → 只看两秒也算完播 │ 短视频经常循环播放 → 播到结尾再从头,算几次说不清 │ └─ 口径要写在文档里,展示与统计共用同一个定义 两处各算一次的后果:页面显示和后台数据对不上 客户端上报:本地累计 + 合并发送 │ ├─ 播放进度回调里只更新本地累计值,不发请求 │ 每秒发一次的后果 │ 一条视频看一分钟就是六十个请求 │ 滑动流上快速划过十几条,请求数更夸张 │ 而这些请求里绝大部分数据是没用的 │ ├─ 触发上报的时机 │ 节流:每隔较长一段时间发一次 │ 关键节点:起播(超过阈值后)· 划走 · 完播 · 页面切后台 │ ├─ 多条视频的上报可以合并成一个请求批量发送 │ 滑动流上用户短时间内会经过多条,合并能显著减少请求数 │ ├─ 上报失败 → 本地暂存,下次合并时一起发 │ 不暂存的后果:弱网下数据丢失 │ 但暂存要有条数上限,不能无限堆积 │ └─ 上报绝对不能阻塞播放 失败就失败,播放优先 服务端:幂等与聚合 │ ├─ 按播放标识去重:同一个标识只计一次播放 │ 客户端重试、重复发送都被挡住 │ ├─ 播放量与完播数用缓存原子自增,定时回写数据库 │ 直接更新视频行的后果:热门视频上同一行的并发更新会串行等待 │ ├─ 明细可以按需保留(我保留了一段时间的明细用于对账) │ └─ 定时对账:用明细重算统计值并与缓存比对,差异记录并修正 因为聚合值是增量维护的,一定会因为某条路径漏更新而漂移 展示 ├─ 播放量与完播率用同一套口径展示,不要另算一遍 ├─ 数字很小的时候可以不展示(避免「播放量 1」的尴尬) └─ 统计有延迟要说明(我的回写是定时的,不是实时)
    分步拆解
    1. 先把口径写下来再写代码。「播放量」看起来不言自明,但「划过算不算」「重试算不算」每一个都要定,定错了数字就是废的。
    2. 一次「播放」要求实际播放时长超过阈值。「起播就算」的后果是快速划过首页播放量涨几十——这个数字完全没有意义,还会误导自己。
    3. 「完播」用「累计观看时长占视频时长的比例」而不是「播放到最后一帧」。定成最后一帧的问题是拖动进度跳到结尾只看两秒也算完播,而且循环播放时算几次说不清。
    4. 口径要写在文档里,展示与统计共用同一个定义。两处各算一次的后果是页面显示和后台数据对不上。
    5. 播放进度回调里只更新本地累计值,不要发请求。每秒发一次的后果是一条视频看一分钟就是六十个请求,而其中绝大部分数据是没用的。
    6. 上报由节流与关键节点触发。关键节点是起播(超过阈值后)、划走、完播、切后台——这几个点足以还原一次播放的全貌。
    7. 多条视频的上报要合并成一个请求批量发送。滑动流上用户短时间内会经过多条,合并能显著减少请求数。
    8. 上报失败要本地暂存、下次一起发。不暂存的后果是弱网下数据丢失;但要有条数上限,不能无限堆积。
    9. 上报绝对不能阻塞播放。失败就失败,播放优先——这一条要在代码上保证(不要在播放路径里等上报结果)。
    10. 要有本次播放的标识,从起播生成、划走或结束才失效。不做的后果是重试、切后台再回来、划走划回都各算一次播放。
    11. 服务端按播放标识去重。这是幂等的根本手段——客户端的重试和重复发送都被挡在这里。
    12. 播放量用缓存原子自增、定时回写数据库。直接更新视频行的后果是热门视频上同一行的并发更新会串行等待。
    13. 保留一段时间的明细用于对账。没有明细的话聚合值一旦漂移就无法重建。
    14. 必须做定时对账:用明细重算并与缓存比对。因为聚合值是增量维护的,一定会因为某条路径漏更新而漂移。
    15. 统计有延迟要在界面上说明。我的回写是定时的、不是实时,不说明的话用户会觉得数字不动是坏了。
    16. 数字很小的时候可以不展示。避免「播放量 1」这种尴尬,这是产品细节但成本为零。
    关键决策与取舍

    「播放」的口径定成「实际播放时长超过阈值」而不是「起播即算」。起播即算实现最简单、数字也最好看。但它在滑动流上完全失真——用户快速划过首页,每条都触发起播,滑一次播放量涨几十而失真的数字比没有数字更糟,因为它会误导决策(我自己就一度以为某条视频突然火了)。阈值的取值要说清依据:太短则划过也算,太长则正常观看被漏掉。我是看了自己滑动时的实际停留时长分布之后取的一个值,而不是拍一个数。

    上报改成「本地累计 + 合并批量」,而不是继续每秒上报。每秒上报的好处是数据最细、丢失最少。但它产生的请求量与数据价值完全不成比例——我只关心最终看了多久,中间那六十个点没有任何用处代价是端上崩溃或强杀时最后一段累计值会丢我接受这个代价,因为统计类数据允许少量误差,而且我在「划走」和「切后台」这两个关键节点都补了上报,真正丢失的窗口很小判据是「这份数据的精度要求」:统计允许误差,那就不必为了极小的完整性付出巨大的请求量。

    完播率用「累计观看时长比例」而不是「到达最后一帧」。后者实现最简单(监听播放结束事件)。但它有两个明确的错误:拖动进度跳到结尾的人只看了两秒也算完播;循环播放的视频播到结尾又从头开始,算几次说不清用累计时长比例的代价是要在客户端维护累计值、还要处理拖动带来的时长跳变(拖过去的那段不能算看过)判据是「这个指标想衡量的到底是什么」:想衡量的是「他看完了吗」,而「到达最后一帧」衡量的是「播放头到过结尾吗」,这两件事不一样。

    播放标识由客户端生成。和消息去重是同一个道理:去重键必须在第一次上报之前就存在,否则客户端重试时服务端认不出是同一次播放。代价是要相信客户端生成的标识不会碰撞(我用了足够长的随机值加时间)。

    踩过的坑一:播放量虚高到我自己都不信。我一度以为某条视频突然火了,查了半天才发现是我自己在滑动测试——每划过一条就算一次播放教训是:统计类需求的难点几乎全在口径定义上,而不在怎么把数字算出来「播放量」这三个字看起来不言自明,但「划过算不算」「重试算不算」「同一个人看两次算不算」每一个都要定。我后来的习惯是写统计代码之前先把口径写下来——而写下来之后我发现自己原来的想法有一半站不住。

    踩过的坑二:每秒上报一次,请求量荒唐。我是在开发者工具的网络面板里看到密密麻麻的上报请求才意识到的。教训是:在回调里直接发请求是很容易写出来的,但回调的触发频率往往远高于你的实际需要——要先问「我真正需要的是哪几个时刻的数据」。

    踩过的坑三:同一次播放被算了好几次。切后台再回来会重新起播、上报重试也会再发一次。教训是:只要客户端会重试或重复触发,服务端就必须有去重依据,而这个依据必须由客户端在第一次就带上——这一点和消息去重完全一样。

    没做的部分:没做更细的行为分析(比如观看时长的分布曲线、在哪一秒划走的最多)。它需要保留细粒度的上报明细,存储量会大很多;而这类分析要有足够的数据量才有意义——我这个项目的数据量下画出来的曲线只是噪声取舍依据是「数据量不支持这个分析」,而不是技术上做不到。

    数字是怎么测的

    请求量下降是这个模块最直观的数字:「连续观看一分钟并划过 N 条视频的过程中,上报请求数从 M 个降到 K 个」这个数字可以直接在开发者工具的网络面板里数出来,很容易核对。

    口径修正的效果要报「同一操作下的统计差异」:「快速划过首页 N 条视频,改造前播放量增加 N、改造后增加 0」。这个对照直接说明了口径问题,比抽象地说「修正了统计口径」有力得多。

    去重用断言型用例:同一次播放中人为触发多次上报(模拟重试)、以及切后台再切回,断言播放量只增加一次。

    阈值的取值要说清依据:「统计自己滑动测试时的停留时长分布,取一个能区分『划过』与『真的在看』的值」。直接给一个秒数而不说依据就是拍的。

    完播口径要覆盖两种反例:拖动进度直接跳到结尾,断言不计完播;循环播放两遍,断言完播只计一次(或按定义计,但要和文档一致)。

    失败暂存:断网状态下播放并划走,断言数据被本地暂存;恢复网络后断言这些数据被合并上报且不重复计数。

    不阻塞播放:让上报接口一直超时,断言播放完全不受影响、不出现卡顿或报错弹窗。

    对账:人为把缓存里的播放量改错,断言定时对账能用明细重算修正并记录差异。

    不要报什么:不要报「播放量准确率」——没有一个外部的真值可以对照。该报的是「一分钟观看内上报请求数从 M 降到 K」「快速划过 N 条时播放量增加从 N 变为 0」「同一次播放多次上报只计一次」「拖到结尾不计完播」「上报接口超时不影响播放」这几件可核对的事。

    面试追问
    Q:统计播放量不就是播放的时候加一吗?这有什么可讲的? A:我一开始就是「起播就加一」,结果数字虚高到我自己都不信——我一度以为某条视频突然火了,查了半天才发现是我自己在滑动测试,每划过一条就算一次播放。在滑动流上这个口径是完全失真的:用户快速划过首页,每条都触发起播,滑一次播放量涨几十。而失真的数字比没有数字更糟,因为它会误导决策。所以我把口径改成「实际播放时长超过某个阈值才算一次播放」,阈值是看了我自己滑动时的实际停留时长分布之后取的,不是拍一个数——太短则划过也算,太长则正常观看被漏掉。第二个口径问题是完播率:我最初定的是「播放到最后一帧算完播」,但它有两个明确的错误——用户拖动进度直接跳到结尾,只看两秒也算完播;而短视频经常循环播放,播到结尾又从头开始,算几次说不清。改成「累计观看时长占视频时长的比例超过阈值」判据是「这个指标想衡量的到底是什么」:想衡量的是「他看完了吗」,而「到达最后一帧」衡量的是「播放头到过结尾吗」——这两件事不一样。第三个问题是同一次播放被算多次:上报重试、切后台再回来、划走划回都各算一次。解法是客户端在起播时生成一个本次播放的标识,服务端按它去重——和消息去重完全一样:去重键必须由客户端在第一次上报时就带上,否则服务端认不出是同一次。我最大的收获是:统计类需求的难点几乎全在口径定义上,而不在怎么把数字算出来。「播放量」这三个字看起来不言自明,但每一个边界都要定,而我写下来之后发现自己原来的想法有一半站不住。
    Q:每秒上报进度有什么问题?数据不是越细越好吗? A:数据细的价值要和它的代价比,而我这里的代价大得不合理。我最初是在播放进度回调里直接发请求,一条视频看一分钟就是六十个请求;而在滑动流上用户快速划过十几条,请求数就更夸张了。我是在开发者工具的网络面板里看到密密麻麻的上报请求才意识到的。更荒唐的是这些请求里绝大部分数据是没用的——我真正关心的只是「最终看了多久」,中间那六十个点没有任何用处。改法是本地累计、按节流和关键节点合并批量上报:进度回调里只更新本地累计值不发请求;在起播(超过阈值后)、划走、完播、切后台这几个关键节点触发上报——这几个点足以还原一次播放的全貌;而且多条视频的上报可以合并成一个请求批量发(滑动流上用户短时间内会经过多条)。代价是端上被强杀或崩溃时最后一段累计值会丢我接受这个代价,判据是「这份数据的精度要求」:统计类数据允许少量误差,而且我在「划走」和「切后台」这两个节点都补了上报,真正丢失的窗口很小另外两个必须做的配套上报失败要本地暂存、下次合并时一起发(弱网下不丢数据),但要有条数上限不能无限堆积;以及上报绝对不能阻塞播放——这一条要在代码上保证,不要在播放路径里等上报结果,我专门写了一条用例:让上报接口一直超时,断言播放完全不受影响。更一般的教训是:在回调里直接发请求很容易写出来,但回调的触发频率往往远高于你的实际需要——要先问「我真正需要的是哪几个时刻的数据」。

模块四:大文件分片上传与断点续传

  1. 大文件分片上传与断点续传(整文件上传在弱网下几乎不可能成功 + 已传分片记录支撑续传 + 合并前校验完整性 + 指纹相同直接跳过上传)★★★
    简历这样写 视频分片上传(Spring Boot + 本地文件存储 + uni-app):视频原片数十兆,整文件一次上传在限速环境下耗时数分钟且中途断开即全部作废;改为按固定大小切片并逐片上传,服务端逐片落盘并记录已接收的分片编号,客户端在开始前先询问已传分片、只补传缺失的部分,实现断点续传;全部分片到齐后按编号顺序合并,并校验合并结果的指纹与客户端上报的一致(不校验会因分片错序或缺失产生损坏文件,而损坏要到转码时才暴露);上传前先用文件指纹询问服务端是否已存在,命中则直接跳过上传;分片上传做限并发与单片重试(单片失败只重传该片);对长时间未完成的临时分片目录定时清理。上述结论均在把上行限速到几十 KB 的条件下实测。
    展开完整拆解
    为什么要这么设计

    图片上传我做过压缩就够了,视频完全不是一个量级——一条手机拍的原片就是几十兆我一开始沿用了图片那套「压缩后整个传上去」的思路,在限速环境下彻底不可用。四个问题。

    一是整文件上传在弱网下几乎不可能成功。把上行限到几十 KB,一条几十兆的视频要传好几分钟而这几分钟里只要网络抖一下、或者用户切了个应用,整个上传就作废、从零开始——我自己测的时候连续失败了好几次,一次都没传完。

    二是我改成分片之后,出现了损坏的文件。我按顺序发分片、服务端收到就往文件末尾追加。但分片是并发发出去的,到达顺序和发送顺序不一致——服务端按到达顺序追加,合并出来的文件内容是乱的更糟的是这种损坏在上传阶段完全看不出来(接口全部返回成功),要到转码时才报错而那时候我已经不知道是哪一步出的问题了。

    三是断了之后还是从头传。我做了分片,但客户端每次都从第一片开始发——服务端明明已经收到了前面几十片,却又收了一遍分片的意义只发挥了一半:它让单次请求变小了,但没有让「已经传成功的部分」被保留下来。

    四是同一条视频重复上传,每次都完整传一遍。我自己在调试转码参数时反复上传同一个文件,每次都是好几分钟而这个文件在服务器上明明已经有了。

    所以四个改动:按固定大小切片、逐片上传分片按编号落盘、合并时按编号顺序拼接并校验指纹开始前先询问已传分片、只补传缺失的先用文件指纹询问是否已存在,命中则跳过上传

    这个模块最想说的一句话是:分片上传的价值不在「把大请求变成小请求」,而在「让已经传成功的部分能被保留」——而这需要服务端记住它收到了哪些片。我最初只做了切片、没做记录,于是每次断线重来都从第一片开始,等于白做想清楚这一点之后,「续传」「秒传」「完整性校验」这三件事的设计就都顺下来了,因为它们依赖的是同一样东西:以分片编号和文件指纹为标识去描述上传的进度。

    整体链路
    上传前:先问一句能不能不传 │ ├─ 客户端算出整个文件的指纹(可分段读取计算,避免一次读进内存) │ ├─ 拿指纹问服务端:这个文件已经存在了吗 │ 已存在 → 直接返回它的凭据,完全跳过上传 │ 我自己调转码参数时反复上传同一个文件,每次都是好几分钟 │ 而这个文件在服务器上明明已经有了 │ └─ 不存在 → 问:这个文件我传到哪了(已接收的分片编号列表) 已传过一部分 → 只补传缺失的 没传过 → 从第一片开始 切片与上传 │ ├─ 按固定大小切片,每片带:文件指纹 · 分片编号 · 总片数 │ 整文件一次传的后果(限速下我一次都没成功过) │ 几十兆的视频要传好几分钟 │ 期间网络抖一下或用户切个应用,整个上传作废、从零开始 │ ├─ 限制并发片数(和图片上传同理:全并发会互相抢上行带宽) │ ├─ 单片失败只重传该片,不影响其他片 │ ├─ 上传进度按「已完成片数 / 总片数」计算并展示 │ └─ 服务端按分片编号落盘(例如存成以编号命名的临时文件) 按到达顺序往同一个文件末尾追加的后果(我踩过) 分片是并发发出的,到达顺序和发送顺序不一致 服务端按到达顺序追加 → 合并出来的文件内容是乱的 合并与校验(这一步不能省) │ ├─ 全部分片到齐后,按编号从小到大顺序拼接 │ ├─ 合并完成后校验整体指纹与客户端上报的是否一致 │ 不校验的后果 │ 分片错序或缺失产生的损坏文件在上传阶段完全看不出来 │ 接口全部返回成功,要到转码时才报错 │ 而那时候已经不知道是哪一步出的问题了 │ ├─ 校验失败 → 丢弃合并结果、保留分片、告知客户端重传 │ └─ 校验通过 → 归档为正式文件 → 清理临时分片 → 进入转码队列 服务端的进度记录 ├─ 记录:文件指纹 → 已接收的分片编号 · 总片数 · 创建时间 ├─ 这份记录是「续传」和「秒传」共同依赖的东西 └─ 长时间未完成的临时分片目录要定时清理 不清理的后果:用户放弃上传的残片会一直占磁盘 其他 ├─ 分片大小要权衡:太小则请求次数过多,太大则单片失败代价高 ├─ 上传过程不要绑定页面生命周期(用户会切走) └─ 服务端要限制单文件总大小与单片大小,不信任客户端上报的总片数
    分步拆解
    1. 上传前先用文件指纹问服务端「这个文件已经有了吗」。命中就完全跳过上传——我自己调转码参数时反复上传同一个文件,每次都白等好几分钟。
    2. 指纹要分段读取计算,不要把整个文件读进内存。几十兆的文件一次读进内存在移动端是有风险的。
    3. 不存在时再问「这个文件我传到哪了」。拿到已接收的分片编号列表,只补传缺失的部分。
    4. 按固定大小切片,每片携带文件指纹、分片编号、总片数。这三样是服务端拼装和校验的依据。
    5. 分片大小要权衡。太小则请求次数过多(每次都有额外开销),太大则单片失败的重传代价高。
    6. 限制同时上传的片数。和图片上传同理——全并发会互相抢上行带宽,总时间没有改善反而失败率上升。
    7. 单片失败只重传该片。这是分片相比整文件上传最直接的收益。
    8. 服务端必须按分片编号落盘,不能按到达顺序往同一个文件末尾追加。因为分片是并发发出的,到达顺序和发送顺序不一致——按到达顺序追加会把文件内容拼乱。
    9. 服务端要记录「这个文件已接收哪些编号」。这份记录是续传和秒传共同依赖的东西——没有它,分片就只是「把大请求变小」而已。
    10. 全部到齐后按编号从小到大顺序拼接。顺序由编号决定,与到达顺序无关。
    11. 合并后必须校验整体指纹。不校验的后果是损坏文件在上传阶段完全看不出来(接口全部返回成功),要到转码时才报错,那时已经不知道是哪一步出的问题。
    12. 校验失败要保留分片、只丢弃合并结果。让客户端能只重传可疑的部分,而不是整个文件重来。
    13. 校验通过后清理临时分片再进入转码队列。不清理会占用双份磁盘。
    14. 长时间未完成的临时分片目录要定时清理。用户放弃上传的残片会一直占磁盘。
    15. 上传过程不要绑定页面生命周期。用户会切走,而他点了上传就认为它会继续。
    16. 服务端要限制单文件总大小与单片大小,不信任客户端上报的总片数。客户端上报的一切都可以被构造。
    关键决策与取舍

    分片必须配合「服务端记录已接收编号」,否则分片本身的意义只发挥了一半。只切片不记录也能跑(每次从第一片开始发),而且实现简单得多但断线重来时前面几十片又要重传一遍——服务端明明已经收到了代价是要维护一份「文件指纹到已接收编号」的记录,还要处理它的清理判据是「分片解决的到底是什么问题」:不是「把大请求变小」,而是「让已经传成功的部分能被保留」——而后者必须有服务端的进度记录才成立。想清楚这一点之后,续传、秒传、完整性校验三件事就都依赖同一样东西了。

    按编号落盘再顺序合并,而不是按到达顺序追加。追加实现最简单(一个输出流一直写)。但分片是并发发出的,到达顺序和发送顺序不一致——按到达顺序追加必然拼乱代价是要为每片单独落盘、合并时多一次完整的读写判据是「到达顺序可控吗」:不可控(并发 + 网络),那就不能依赖它,必须用一个显式的顺序标识(编号)。

    合并后校验整体指纹,即使它意味着多一次完整的读取。不校验能省掉这次读取。但损坏文件在上传阶段完全不报错、要到转码时才暴露——而那时候我已经不知道是哪一步出的问题了判据是「这个错误如果不在这里发现,会在哪里发现、那时的定位成本有多高」:会在很后面发现、定位成本很高,那就值得在这里付一次读取的代价。这一条我认为是「校验该放在哪」的通用判断方式。

    秒传只用文件指纹判断,不做更复杂的相似度判断。指纹相同就是同一个文件,这个判断是确定的。而「相似的视频」(比如同一段素材不同码率)需要内容层面的比较,成本高且不可靠代价是同一内容的不同编码版本仍会各传一次判据是「这个判断能不能做到确定无误」:指纹可以,相似度不行——而上传去重这种事一旦判错(把两个不同的文件认成同一个),后果是用户的视频被换掉了,非常严重。

    踩过的坑一:整文件上传,限速下我一次都没成功过。连续失败好几次、每次都从零开始。教训是:一个操作的耗时一旦长到「期间必然发生意外」的程度,就不能设计成「要么全成功要么全失败」——而几分钟的上传在移动网络下就属于这个范畴。

    踩过的坑二:分片按到达顺序追加,合并出损坏文件,而且上传阶段完全不报错。是转码失败时我才回头查的。教训有两层一是不能依赖并发操作的到达顺序二是「接口全部返回成功」不等于「结果是对的」——所以关键流程的末尾要有一次结果校验,否则错误会漂到很后面才暴露。

    踩过的坑三:做了分片但没做续传,断线还是从头传。我当时觉得「分片已经做完了」。教训是:一个技术手段往往需要配套的东西才能真正解决问题——分片如果没有服务端的进度记录,它只是把一个大失败拆成了很多个小失败,总的结果还是失败。

    没做的部分:没做分片的并行度动态调整(根据实测速度自动增减并发片数)。它理论上能更充分利用带宽,但需要一个可靠的速度估计,而移动网络的速度波动很大、估计不准反而会来回抖动取舍依据是「固定一个在限速下试出来的保守并发数,表现已经稳定」——而一个会自己抖动的策略比一个稍微保守的固定值更难排查。

    数字是怎么测的

    所有数字必须带限速条件:我报的是「把上行限到几十 KB 每秒」,并说明片源是手机拍的原片、大小在数十兆量级不限速的话整文件上传也能成功,这个模块的问题一个都不出现。

    整文件上传的失败率是这个模块最有说服力的起点:「限速条件下整文件上传连续尝试 N 次、成功 0 次」——这个数字直接说明「为什么必须分片」,比讲道理有力得多。

    续传的效果用断言型用例:上传到中途人为断开,断言重新开始时只补传缺失的分片(可以在网络面板里数请求数)、已传的片不再重传报「断在第 N 片时,续传只发出剩余的片数」。

    秒传:同一个文件上传两次,断言第二次没有发出任何分片请求、直接返回凭据并报第二次的总耗时(应该是一次询问的时间)。

    合并正确性是踩坑的直接回归:让分片乱序到达(可以人为打乱发送顺序),断言合并结果的指纹与原文件一致再人为丢弃一片,断言合并前就被拦下(分片不齐不合并)或合并后校验失败。

    损坏检出的时机要单独报:「改造前损坏文件要到转码阶段才暴露;改造后在合并校验时就被拦下」。这个「提前暴露」本身就是价值。

    分片大小与并发数要报实测选取过程:「在限速条件下试了几组分片大小与并发数,记录总耗时与失败率,取表现稳定的一组」。直接给数字而不说怎么来的就是拍的。

    单片重传:让第三片失败,断言只重传该片、其余片不受影响。

    临时分片清理:制造一个未完成的上传,断言超过设定时间后临时分片目录被清理、磁盘占用回落。

    不要报什么:不要报「上传成功率 100%」——弱网下不可能。该报的是「限速值与片源大小」「整文件上传连续 N 次成功 0 次」「断在第 N 片时续传只发剩余片」「同文件二次上传不发分片请求」「乱序到达时合并指纹仍一致」「损坏从转码阶段提前到合并阶段暴露」这几件带条件的、可核对的事。

    面试追问
    Q:视频上传为什么要分片?直接传一个文件不行吗? A:在限速环境下我一次都没成功过——我把上行限到几十 KB,一条几十兆的原片要传好几分钟,而这几分钟里只要网络抖一下、或者用户切了个应用,整个上传就作废、从零开始,我连续失败了好几次。教训是:一个操作的耗时一旦长到「期间必然发生意外」的程度,就不能设计成「要么全成功要么全失败」——几分钟的上传在移动网络下就属于这个范畴。但我想强调的是,分片本身并不解决问题——我第一版做了分片,断线之后还是从头传。因为客户端每次都从第一片开始发,而服务端明明已经收到了前面几十片所以分片的价值不在「把大请求变成小请求」,而在「让已经传成功的部分能被保留」——而这需要服务端记住它收到了哪些片。补上这份记录之后,客户端在开始前先问一句「这个文件我传到哪了」,只补传缺失的部分教训是:一个技术手段往往需要配套的东西才能真正解决问题——分片如果没有服务端的进度记录,它只是把一个大失败拆成了很多个小失败,总的结果还是失败。而这份记录建好之后还顺带解决了另一个问题:秒传。我自己在调转码参数时反复上传同一个文件,每次都白等好几分钟——现在上传前先用文件指纹问服务端「这个文件已经有了吗」,命中就完全跳过上传续传、秒传、完整性校验这三件事最后依赖的是同一样东西:以分片编号和文件指纹去描述上传进度。
    Q:分片传上去之后怎么合并?合并完还要校验吗? A:按编号顺序拼接,而且必须校验——我第一版两件事都做错了,而且错得很隐蔽。第一个错误是我按到达顺序往同一个文件末尾追加但分片是并发发出去的,到达顺序和发送顺序不一致,服务端按到达顺序追加,合并出来的文件内容就是乱的教训是:不能依赖并发操作的到达顺序,必须用一个显式的顺序标识——所以现在每片都带分片编号,服务端按编号单独落盘、合并时按编号从小到大拼接,与到达顺序完全无关第二个错误更麻烦:我没有做合并后的校验。损坏的文件在上传阶段完全看不出来——所有分片接口都返回成功、合并接口也返回成功,要到转码时才报错,而那时候我已经不知道是哪一步出的问题了,回头查了很久。所以现在合并完成后要校验整体指纹与客户端上报的是否一致不一致就丢弃合并结果、保留分片、告知客户端重传(保留分片是为了让它只重传可疑的部分,而不是整个文件重来)。代价是多一次完整的读取。我判断「校验该放在哪」的方式是:这个错误如果不在这里发现,会在哪里发现、那时的定位成本有多高。这里的答案是「会在转码阶段发现、定位成本很高」,那就值得在合并时付一次读取的代价更一般的教训是:「接口全部返回成功」不等于「结果是对的」——关键流程的末尾要有一次结果校验,否则错误会漂到很后面才暴露。

模块五:视频流的排序与已看去重

  1. 视频流的排序与已看去重(重复刷到同一条是这类产品最招骂的问题 + 已看集合的容量与时效 + 随机排序与游标分页天然冲突 + 刷到底之后的兜底策略)★★★
    简历这样写 视频流排序与去重(Spring Boot + MySQL + Redis 集合):滑动流初版按发布时间倒序分页,实测中反复刷到同一批视频(下拉刷新回到顶部、以及新视频插入导致偏移量错位);改为维护每个用户的已看集合并在取流时排除,同时把分页从偏移量改为游标;已看集合限制容量并设过期(不限制会无限增长,而全部排除会导致老用户很快无内容可看);发现随机排序与游标分页天然冲突(随机顺序下没有稳定的游标可用),改为按用户维度生成一份有序的候选队列并在其上分页,队列消耗完后再重新生成;候选不足时降级为放宽已看时效而非返回空,并明确告知用户「已看完最新内容」;排序权重包含时间衰减,避免老视频长期占据前列。相关问题通过灌入数百条视频与模拟连续滑动复现。
    展开完整拆解
    为什么要这么设计

    滑动流的「取下一批视频」这个接口我最初写得极其简单:按发布时间倒序、用偏移量分页播放体验做好之后我自己连续刷了几十条,四个问题暴露得很直接。

    一是反复刷到同一批视频。原因有两个叠加:一是我下拉刷新时把偏移量重置成了零,于是又从最新的那几条开始二是有新视频插入时偏移量整体错位,翻页会重复(这和帖子列表的偏移量分页是同一个问题)。而「重复刷到同一条」是这类产品最招骂的点——用户会立刻觉得「没内容了」。

    二是我加了已看排除之后,很快就没内容可看了。我把用户看过的全部视频都排除掉。我自己刷了几十条之后,接口开始返回空——因为我造的测试数据只有几百条,而我把看过的都排除了如果这是真实产品,老用户会很快遇到同样的问题。

    三是我想加一点随机性,结果分页彻底乱了。我在排序里加了随机因子想让流更有变化。但随机排序下没有稳定的游标可用——每次请求的顺序都不一样,「取排序在某个位置之后的」这句话失去了意义结果是既有重复又有遗漏。

    四是老视频长期占据前列。我按互动数排序做了个「热门」入口。结果是几条早期的视频因为积累的互动多,一直排在最前面——新视频永远上不来,而这个流看起来永远是那几条。

    所以四个改动:维护已看集合并在取流时排除、分页改用游标已看集合限容量加过期,候选不足时放宽时效而不是返回空按用户生成有序候选队列、在队列上分页(替代直接随机排序)排序权重加入时间衰减

    这个模块最想说的一句话是:「随机」和「分页」是天然冲突的——分页需要一个稳定的顺序,而随机每次都不一样。我一开始想在排序里直接加随机因子,怎么调都会重复或遗漏解法是把随机提前一步:先为这个用户生成一份固定顺序的候选队列(生成那一刻可以随机),然后在这个队列上老老实实地按位置分页这和我在论坛热度榜上用「定时快照」解决同一类问题是完全一样的思路——让不稳定的东西在一段时间内变得稳定。

    整体链路
    已看集合(解决重复刷到) │ ├─ 每个用户一个集合,记录他已经看过的视频标识 │ 「看过」的口径要和播放上报一致(见播放上报那一块) │ 不是「划过」就算看过,否则用户快速划过一批就全被排除了 │ ├─ 取流时排除集合中的视频 │ ├─ 集合必须限制容量并设过期 │ 不限制的后果:随使用无限增长 │ 而全部永久排除的后果:老用户很快无内容可看 │ 我造的测试数据只有几百条,自己刷几十条接口就开始返回空了 │ └─ 容量满时淘汰最早看过的(也就是允许很久以前看过的重新出现) 这是一个有意的设计:时间久了重复出现是可以接受的 分页:游标而不是偏移量 │ ├─ 偏移量的两个问题(和帖子列表完全同源) │ 下拉刷新重置偏移量 → 又从最新那几条开始 │ 有新视频插入时偏移量整体错位 → 翻页重复 │ └─ 游标以「排序键 + 视频标识」组成,保证唯一 随机性与分页的冲突(这一块是关键) │ ├─ 直接在排序里加随机因子的后果 │ 每次请求的顺序都不一样 │ 「取排序在某个位置之后的」这句话失去了意义 │ 结果既有重复又有遗漏 │ ├─ 正确做法:把随机提前一步 │ 为该用户生成一份「候选队列」(有序的视频标识列表) │ 生成那一刻可以引入随机、可以做打散 │ 然后在这个固定队列上按位置分页 —— 顺序是稳定的 │ ├─ 队列存一段时间(放在缓存里),消耗完或过期后重新生成 │ └─ 这和论坛热度榜用「定时快照」是同一个思路 让不稳定的东西在一段时间内变得稳定 排序权重 ├─ 基础:发布时间(新内容优先) ├─ 叠加:互动数(点赞 · 完播率,见播放上报那一块) ├─ 必须有时间衰减 │ 纯按互动数排的后果 │ 几条早期视频因为积累的互动多,一直排在最前面 │ 新视频永远上不来,这个流看起来永远是那几条 └─ 权重的具体取值我没有能力调优(没有真实行为数据),所以做成可配置 候选不足时的兜底(不要返回空) │ ├─ 第一步:放宽已看的时效(把很久以前看过的重新纳入候选) ├─ 第二步:明确告知「已看完最新内容」,并给出「重新开始」的入口 └─ 直接返回空的后果 用户看到的是一个空白页面,他不知道是没内容了还是出错了 其他 ├─ 已删除 / 审核未通过的视频要在取流时过滤 ├─ 自己发的视频是否出现在自己的流里,要明确定义(我选择出现) └─ 取流接口要限制单次返回条数,配合播放器的预加载策略
    分步拆解
    1. 为每个用户维护一个已看集合,取流时排除。「重复刷到同一条」是这类产品最招骂的点——用户会立刻觉得「没内容了」。
    2. 「看过」的口径要和播放上报一致。不是「划过」就算看过——否则用户快速划过一批就全被排除了,而他其实什么都没看。
    3. 已看集合必须限制容量并设过期。不限制会随使用无限增长,而全部永久排除会导致老用户很快无内容可看——我造的测试数据只有几百条,自己刷几十条接口就开始返回空了。
    4. 容量满时淘汰最早看过的。也就是允许很久以前看过的重新出现——这是一个有意的设计,时间久了重复是可以接受的。
    5. 分页必须用游标,不能用偏移量。两个问题和帖子列表完全同源:下拉刷新重置偏移量会又从最新那几条开始、有新视频插入时偏移量整体错位。
    6. 游标要以「排序键 + 视频标识」组成。只用排序键的话,排序键相同的多条会被跳过。
    7. 不要在排序里直接加随机因子。随机排序下没有稳定的游标可用,「取排序在某个位置之后的」失去意义,结果既有重复又有遗漏。
    8. 把随机提前一步:为该用户生成一份有序的候选队列,在队列上按位置分页。生成那一刻可以随机、可以打散,而之后的顺序是稳定的。
    9. 候选队列存一段时间,消耗完或过期后重新生成。这和论坛热度榜的定时快照是同一个思路:让不稳定的东西在一段时间内变得稳定。
    10. 排序权重必须包含时间衰减。纯按互动数排的后果是几条早期视频一直排在最前面,新视频永远上不来。
    11. 权重的具体取值做成可配置。我没有真实行为数据、没有能力调优它——做成可配置并说清这一点,比假装调过要好。
    12. 候选不足时先放宽已看时效,而不是返回空。把很久以前看过的重新纳入候选。
    13. 确实没有更多时要明确告知「已看完最新内容」并给「重新开始」的入口。直接返回空的后果是用户看到空白页面,不知道是没内容了还是出错了。
    14. 已删除、审核未通过的视频要在取流时过滤。而且过滤条件要收敛在一处,不要每个取流入口各写一遍。
    15. 自己发的视频是否出现在自己的流里要明确定义。我选择出现——因为个人项目里内容少,而且作者也想看到自己的内容在流里长什么样。
    16. 取流接口要限制单次返回条数。配合播放器只预加载相邻一条的策略——返回太多没有意义,只是白占带宽。
    关键决策与取舍

    把随机提前到「生成候选队列」那一步,而不是在排序里加随机因子。直接加随机因子看起来最省事(一个排序表达式的事)。但它和分页天然冲突——分页需要一个稳定的顺序,而随机每次都不一样我怎么调都会出现重复或遗漏代价是要为每个活跃用户存一份候选队列(占缓存空间)、还要处理它的过期与重新生成判据是「这个不稳定性能不能被固化到某个时刻」:能,那就在那一刻做完随机、之后按固定顺序消费这和我在论坛热度榜上用定时快照的思路完全一样——我在两个不同的地方遇到了同一类问题,说明它是个通用模式。

    已看集合限容量、允许很久以前看过的重新出现,而不是永久排除。永久排除最符合「不要重复」的直觉。但它有两个问题:集合无限增长、以及内容总量有限时用户很快无内容可看——我造了几百条测试数据,自己刷几十条就把流刷空了判据是「内容供给和消费速度的关系」:供给远小于消费时,「永不重复」这个目标本身就不可能达成那就该明确地允许「久远的内容重新出现」,而不是让用户面对空白我把这个取舍写在了文档里,因为它是一个有意的设计而不是缺陷。

    候选不足时降级为放宽时效,而不是返回空。返回空最「诚实」。但用户看到的是一个空白页面,他不知道是没内容了还是出错了——而这两种情况他的下一步动作完全不同(一个是等新内容、一个是重试)所以先降级放宽、真的没有了再明确告知「已看完最新内容」并给「重新开始」的入口判据是「空白页面传递的信息量」:接近零,那就必须替换成一个明确的状态。

    排序权重做成可配置,并明确说自己没有能力调优它。我可以随便定一组权重然后声称「优化了推荐效果」。但我没有真实的用户行为数据——我造的行为数据训不出也验证不了任何东西所以我做的是:把结构搭好(时间衰减、互动权重、可配置),并诚实说明「具体取值需要真实数据来调」我认为这比编一个「点击率提升多少」更站得住而且面试官问「你怎么验证这个权重是好的」时我有答案:我验证不了,所以我把它做成了可配置的。

    踩过的坑一:反复刷到同一批视频。两个原因叠加——下拉刷新时我把偏移量重置成了零、以及有新视频插入时偏移量整体错位教训是:偏移量分页的问题在任何「数据集会变化的列表」上都会出现——我在帖子列表上已经踩过一次,在视频流上又踩了一次,说明我当时只是修了那个页面、没有把结论推广到所有列表。

    踩过的坑二:加了已看排除之后,接口很快返回空。我自己刷了几十条就把流刷空了。教训是:一个过滤条件的效果取决于「被过滤掉的比例」——而这个比例在小数据量下会大得离谱我最初的实现在真实的大内容池里也许能跑,但在我的测试数据上直接崩了;而我意识到老用户面对的情况和我的测试数据是相似的。

    踩过的坑三:随机排序让分页彻底乱了,我调了很久排序表达式。后来才想明白这不是排序的问题,而是「随机」和「分页」这两个需求本身冲突教训是:当一个问题怎么调参数都解决不了时,很可能是两个需求在结构上冲突,而不是参数没调对。

    没做的部分:没做个性化推荐(按用户兴趣排序)。它需要真实的行为数据来做特征和排序,而我这是个人项目、没有真实行为数据——用造出来的假行为数据训一个「假的推荐」,不如老实做好「最新加热度加去重」取舍依据是「支撑这个功能的前提我不具备」,而且我能说清完整方案需要什么。

    数字是怎么测的

    重复率是这个模块最核心的指标:模拟连续滑动 N 条,断言其中重复出现的视频数为零并报改造前的重复情况作为对照(「改造前连续滑动 N 条中出现 M 条重复」)。两个数字一起报,才说明这个改动解决了一个真实存在的问题。

    要说清测试数据规模:「灌入数百条视频」——因为已看排除的效果完全取决于内容总量与消费速度的比例不说规模的重复率数字没有意义。

    刷空的临界点值得单独报:「在数百条内容的池子里,连续滑动约 N 条后候选开始不足、触发放宽时效的降级」。这个数字说明我测到了边界,而不是只测了顺利路径。

    游标分页的正确性用断言型用例:翻页期间人为插入新视频,断言前后两页之间没有重复、也没有遗漏(和帖子列表同一套用例)。

    候选队列的稳定性:在队列有效期内多次请求同一位置,断言返回的视频顺序一致队列过期后断言重新生成、且顺序可以不同。

    已看口径:快速划过一批视频(不满足「看过」的时长阈值),断言它们没有被加入已看集合、后续仍可能出现这条对应「不是划过就算看过」。

    集合容量与淘汰:让已看集合超过容量上限,断言淘汰的是最早看过的、集合大小不再增长。

    时间衰减:造一条互动数很高的老视频与一条互动数一般的新视频,断言新视频能排到前面(说明衰减生效),并说明衰减系数是可配置的、当前取值没有真实数据支撑。

    兜底状态:把候选耗尽,断言界面显示「已看完最新内容」并有「重新开始」入口,而不是空白页面。

    不要报什么:不要报「推荐效果提升」或「点击率提升」——我没有真实行为数据,这类数字编不出也验证不了。该报的是「测试数据规模」「连续滑动 N 条的重复数改造前后对比」「候选不足的临界条数」「翻页期间插入新内容无重复无遗漏」「队列有效期内顺序稳定」这几件带条件的、可核对的事。

    面试追问
    Q:视频流不就是按时间倒序分页吗?为什么会重复刷到同一条? A:我第一版就是按时间倒序加偏移量分页,自己连续刷了几十条就撞到重复了,原因是两个问题叠加。一是我下拉刷新时把偏移量重置成了零,于是又从最新的那几条开始;二是有新视频插入时偏移量整体错位,翻页本身就会重复——这和帖子列表的偏移量分页是完全同一个问题而我在帖子列表上已经踩过一次、在视频流上又踩了一次说明我当时只是修了那个页面、没有把「偏移量分页在任何数据集会变化的列表上都有问题」这个结论推广到所有列表而「重复刷到同一条」在这类产品里是最招骂的点——用户会立刻觉得「没内容了」。所以除了改成游标分页,我还为每个用户维护了一个已看集合、取流时排除但这里有一个我没预料到的连锁问题:加了已看排除之后,接口很快就开始返回空。因为我把看过的全部永久排除,而我造的测试数据只有几百条,自己刷几十条就把流刷空了教训是:一个过滤条件的效果取决于「被过滤掉的比例」,而这个比例在内容总量有限时会大得离谱——而老用户面对的情况和我的测试数据其实是相似的。所以已看集合要限制容量并设过期,满了淘汰最早看过的也就是有意地允许很久以前看过的重新出现候选不足时先放宽已看时效,真的没有了再明确告知「已看完最新内容」并给「重新开始」的入口,而不是返回一个空白页面——空白页面用户分不清是没内容了还是出错了,而这两种情况他的下一步动作完全不同。
    Q:想让流有点随机性,直接在排序里加个随机因子不行吗? A:我就是这么做的,然后分页彻底乱了——既有重复又有遗漏,我调了很久排序表达式都没用。后来才想明白:这不是排序的问题,而是「随机」和「分页」这两个需求在结构上冲突分页需要一个稳定的顺序(「取排序在某个位置之后的」这句话才有意义),而随机每次请求的顺序都不一样——所以游标失去了意义教训是:当一个问题怎么调参数都解决不了时,很可能是两个需求在结构上冲突,而不是参数没调对。解法是把随机提前一步先为这个用户生成一份「候选队列」(一个有序的视频标识列表),生成那一刻可以引入随机、可以做打散;然后在这个固定队列上老老实实按位置分页——队列在有效期内顺序是稳定的,游标就又成立了;队列消耗完或过期后再重新生成。代价是要为每个活跃用户存一份队列(占缓存空间),还要处理过期与重新生成。判据是「这个不稳定性能不能被固化到某个时刻」:能,那就在那一刻做完随机、之后按固定顺序消费。而这个思路和我在论坛热度榜上用「定时快照」解决「排序键会变导致游标失效」是完全一样的——我在两个不同的地方遇到了同一类问题,说明「让不稳定的东西在一段时间内变得稳定」是个通用模式顺带说排序权重我做成了可配置,并且明确说自己没有能力调优它:我没有真实的用户行为数据,造出来的行为数据训不出也验证不了任何东西——所以我只把结构搭好(时间衰减、互动权重),具体取值留给真实数据。我觉得这比编一个「点击率提升多少」更站得住。

模块六:视频文件的按范围响应与访问控制

  1. 视频文件的按范围响应与访问控制(拖动进度依赖服务端支持按范围取数据 + 整段返回让长视频无法起播 + 单次请求的流量与并发限制 + 缓存头与内容指纹)★★★
    简历这样写 视频文件服务(Spring Boot + 本地文件存储):视频文件初版为整段读取后一次性返回,导致拖动进度条无效(播放器无法请求文件中间的片段)且长视频起播需等待较多数据;改为支持按范围请求(解析范围头、只读取并返回请求的那一段、返回正确的状态码与范围响应头),拖动进度与起播均恢复正常;补上范围参数的合法性校验(越界、倒序、超大范围一律拒绝,原实现会因非法范围导致读取异常);加入简单防盗链与单连接并发限制,避免文件被其他站点直接引用或被脚本并发拉满;文件名采用内容指纹并配长期缓存头(内容变则路径变,无需失效缓存);对整段读进内存再返回的写法改为流式按块读写,避免大文件占满内存。问题均通过拖动进度、构造非法范围头与脚本并发请求复现。
    展开完整拆解
    为什么要这么设计

    「把视频文件返回给播放器」这件事我原以为和返回图片没有区别。做完播放流之后我发现拖动进度条完全没反应,才意识到视频文件的返回方式和图片是两件事。四个问题。

    一是拖动进度条无效。我的实现是「读整个文件、一次性写回响应」。播放器想跳到视频中间时,它会请求「文件的某一段」——而我的接口不管请求什么都返回整个文件播放器拿不到它要的那一段,进度条就拖不动这一点我查了很久,因为我一直在怀疑播放器组件的用法。

    二是长视频起播要等很久。同样是因为整段返回:播放器必须先拿到足够的数据才能开始解码,而我把整个文件都塞给它——短视频还好,稍长一点的片子起播明显变慢。

    三是整段读进内存再返回,大文件会把内存占满。我用的是「把文件全部读成一个字节数组、再写回响应」。几十兆的文件、几个并发请求,内存立刻就上去了——而这个问题在我单人测试时完全不出现。

    四是我加了范围支持之后,构造一个非法范围能让接口异常。我直接把范围头里的数字拿来当读取的起止位置。用一个超出文件长度的范围、或者起始大于结束的范围去请求,读取就抛异常了——而这个请求是任何人都能构造的。

    所以四个改动:支持按范围请求(解析范围头、只返回请求的那一段、返回正确的状态码与响应头)整段读内存改为流式按块读写范围参数做合法性校验加防盗链与单连接并发限制,另外把文件名换成内容指纹并配长期缓存头

    这个模块最想说的一句话是:播放器不是「下载完再播」,它是「按需要取文件的某一段」——而这个能力必须由服务端配合,客户端单方面做不到。我花了很久去查播放器组件的用法,以为拖动进度是客户端的事;实际上是我的服务端不支持按范围返回,播放器根本拿不到它想要的那一段这是我第一次真切体会到「有些客户端能力是服务端协议决定的」。

    整体链路
    为什么必须支持按范围返回 │ ├─ 播放器不是「下载完再播」,它按需要取文件的某一段 │ 起播:先取文件开头一小段(配合转码时把索引前置,见上传转码那块) │ 拖动:直接取目标位置附近的那一段 │ └─ 整段返回的两个后果(我都踩了) 拖动进度条完全无反应 —— 播放器拿不到它要的那一段 长视频起播明显变慢 —— 它必须先拿到足够数据才能开始解码 我查了很久,因为一直在怀疑播放器组件的用法 按范围响应的处理 │ ├─ 请求里没有范围头 → 正常返回整个文件(并声明支持范围请求) │ ├─ 有范围头 → 解析出起止位置 │ 只读取并返回这一段 │ 返回「部分内容」的状态码,而不是普通成功 │ 响应头要带:本次返回的范围 · 文件总长度 · 本段长度 │ 状态码或响应头不对的后果:播放器不认,退化成整段下载 │ └─ 范围参数必须校验(我踩过) 起始超出文件长度 → 拒绝(返回范围不满足的状态码) 起始大于结束 → 拒绝 范围过大 → 截断到一个上限,而不是照单全收 直接拿数字去读的后果:读取抛异常,而这个请求任何人都能构造 读写方式:流式而不是整段进内存 │ ├─ 按块读取文件、按块写入响应 │ └─ 整段读成字节数组再返回的后果 几十兆的文件、几个并发请求,内存立刻上去 而这个问题在单人测试时完全不出现 访问控制与流量 │ ├─ 简单防盗链:校验来源,不允许其他站点直接引用 │ 不做的后果:别人的页面直接用我的视频,带宽由我承担 │ 而视频的带宽成本比图片高一个量级 │ ├─ 单连接的并发请求数限制 │ 不限的后果:脚本可以开很多连接同时拉同一个文件 │ ├─ 单次范围请求的最大长度限制 │ 防止「一个请求就要整个文件」绕过范围机制 │ └─ 私密视频不能只靠「地址难猜」 需要签发时效访问凭据(我的视频是公开内容,所以只做了防盗链) 缓存 ├─ 文件名用内容指纹 → 内容变则路径变 → 可以放心加长期缓存头 ├─ 这和图片那边是同一个做法(见论坛项目的静态资源那块) └─ 范围响应也要能被缓存(响应头要正确,否则每次都重新拉 其他 ├─ 转码产出的多个分辨率档位各自独立存储与访问 ├─ 文件不存在要返回明确的状态码,不要返回空内容加成功状态 └─ 访问日志记录足够定位问题,但不要记录完整的请求体
    分步拆解
    1. 先认清播放器是「按需要取文件的某一段」,不是「下载完再播」。我花了很久查播放器组件的用法,实际上是我的服务端不支持按范围返回。
    2. 没有范围头时正常返回整个文件,但要声明自己支持范围请求。不声明的话播放器不会尝试用范围请求。
    3. 有范围头时只读取并返回请求的那一段。这是拖动进度能生效的唯一前提。
    4. 必须返回「部分内容」的状态码而不是普通成功。状态码不对的后果是播放器不认,退化成整段下载。
    5. 响应头要带本次返回的范围、文件总长度、本段长度。缺一项播放器就可能无法正确定位。
    6. 范围参数必须做合法性校验。起始超出文件长度、起始大于结束、范围过大——直接拿数字去读的后果是读取抛异常,而这个请求任何人都能构造。
    7. 范围过大要截断到一个上限,而不是照单全收。否则「一个请求要整个文件」就绕过了范围机制。
    8. 读写必须流式按块进行,不能整段读进内存。几十兆的文件加几个并发请求内存立刻上去,而这个问题在单人测试时完全不出现。
    9. 要做简单防盗链。不做的后果是别人的页面直接引用我的视频、带宽由我承担——而视频的带宽成本比图片高一个量级。
    10. 要限制单连接的并发请求数。不限的后果是脚本可以开很多连接同时拉同一个文件。
    11. 私密内容不能只靠「地址难猜」。需要签发时效访问凭据——我的视频是公开内容,所以只做了防盗链,但要把这个边界说清楚。
    12. 文件名用内容指纹并配长期缓存头。内容变则路径变,不需要「失效缓存」这个动作——和图片那边是同一个做法。
    13. 范围响应也要能被正确缓存。响应头不对的话每次拖动都重新拉。
    14. 转码产出的多个分辨率档位要各自独立存储与访问。客户端按网络状况选档位。
    15. 文件不存在要返回明确的状态码。返回空内容加成功状态的后果是播放器一直在等数据。
    16. 访问日志要够定位问题,但不要记录完整请求体。视频请求量大,日志会迅速膨胀。
    关键决策与取舍

    支持按范围返回不是优化而是必须,因为拖动进度这个能力完全依赖它。我最初以为拖动进度是客户端的事,花了很久去查播放器组件的用法实际上是服务端不支持按范围返回,播放器根本拿不到它想要的那一段——客户端单方面做不到这件事代价是要正确处理范围解析、状态码、响应头这一整套(错一项播放器就不认,会退化成整段下载)判据很简单:这个客户端能力有没有服务端的前置条件——而这是我第一次真切体会到「有些客户端能力是服务端协议决定的」。

    流式按块读写而不是整段读进内存。整段读实现最简单(一行读文件、一行写响应)。但几十兆的文件加几个并发请求,内存立刻就上去了——而这个问题在单人测试时完全不出现,我是用脚本并发请求才看到内存曲线抬升的代价是代码稍复杂(要处理块大小、写入中断)判据是「单次请求要处理的数据量会不会很大」:视频文件天然很大,那就绝对不能整段进内存——这一条和图片不同,图片压缩后只有几百 KB,整段读是可以接受的。

    范围参数一律校验并对过大的范围做截断,而不是照单全收。照单全收更「听话」。但非法范围会让读取抛异常,而这个请求任何人都能构造而超大范围则等于绕过了范围机制、退化成整段下载判据是「这个参数来自客户端吗」:来自客户端的一切都要校验,而范围头特别容易被忽略,因为它看起来是「协议的一部分」而不是「用户输入」。

    只做简单防盗链,不做签名链接。签名链接(带时效的访问凭据)安全性更高。但我的视频是公开内容(流里所有人都能看),签名的收益主要是防带宽盗用而不是防内容泄露而带签名的链接每次都不同,会让长期缓存完全失效——那个代价对视频这种大文件是很实在的判据是「这份资源本身是不是私密的」:不是,那防的只是带宽被占,简单来源校验加并发限制就够但我把这个边界写清楚了:如果以后有私密视频,必须换成时效凭据。

    踩过的坑一:拖动进度条完全没反应,而我一直在怀疑播放器组件。我换了配置、翻了文档、试了不同的属性。后来抓包才看到播放器确实发出了带范围头的请求,而我的接口每次都返回整个文件和一个普通的成功状态码教训是:客户端功能不生效时,先看看它实际发出的请求是什么、服务端返回的是什么——抓一次包比读十遍文档快。

    踩过的坑二:整段读进内存,并发下内存抬升。单人测试完全正常。教训是:内存类问题必须用并发压测才能暴露,而且要看内存曲线而不是看是否报错——因为它在崩掉之前不会有任何异常。

    踩过的坑三:构造一个非法范围就能让接口抛异常。我直接把范围头里的数字拿来当读取位置。教训是:范围头虽然是协议的一部分,但它同样是客户端可以任意构造的输入——「看起来像协议」不代表它可信。

    没做的部分:没做流式分片播放(把视频切成很多小片、用一个索引文件描述,让播放器逐片拉取)。它是主流做法,能更好地支持码率自适应切换和更快的起播;但它需要转码时额外切片、还要生成索引文件,而客户端也要用支持这种格式的播放方式取舍依据是「按范围请求已经让拖动和起播正常工作,而分片播放的主要增量收益是码率自适应」——而我的自适应本来就只做了最简单的版本不过我能说清它是下一步,以及它解决的是什么。

    数字是怎么测的

    拖动进度是断言型用例,也是这个模块最直接的验证:播放中拖动到视频中后段,断言能正常从该位置继续播放并抓包断言播放器发出了带范围头的请求、服务端返回了「部分内容」状态码与正确的范围响应头抓包这一步很关键——它是我定位这个问题的方式,也是证明改动生效的最直接证据。

    起播时间要报清片长与限速条件:「一条时长 X 的视频,在下行限速到几百 KB 的条件下,起播时间从 Y 降到 Z」。并说明这个改善来自「只取开头一小段」而不是文件变小了。

    内存是这个模块最值得报的一个数字:用脚本并发请求同一个视频文件,报「整段读入内存时服务端内存占用随并发数上升到 X;改为流式读写后基本平稳在 Y」要说明并发数与文件大小——而且这个问题在单人测试时完全不出现,这一点也要写进去。

    非法范围是安全性质的断言用例,要覆盖三种:起始超出文件长度、起始大于结束、范围超大。断言三种都被拒绝且返回明确的状态码,服务端不抛异常。

    范围截断:请求一个超大范围,断言实际返回的长度被截断到上限,而不是返回整个文件。

    缓存生效:重复播放同一视频,断言范围请求能命中缓存(可以在网络面板里看状态);替换视频内容后断言指纹变化导致路径变化、客户端拉取的是新路径。

    防盗链:从一个不允许的来源发起请求,断言被拒绝。

    并发限制:用脚本对同一文件开大量并发连接,断言超出限制的被拒绝、且正常播放不受影响(后半句是关键——限制不能把正常用户也挡住)。

    不要报什么:不要报「视频加载速度提升 N 倍」——倍数取决于限速和片长。该报的是「抓包证明范围请求与部分内容状态码生效」「某片长与限速下的起播时间对比」「N 个并发请求下整段读入与流式读写的内存对比」「三种非法范围均被拒绝且不抛异常」这几件带条件的、可核对的事。

    面试追问
    Q:视频文件返回给播放器,和返回图片有什么区别? A:区别很大,而我最初以为没区别——结果是拖动进度条完全没反应,我还花了很久去查播放器组件的用法。根本原因是:播放器不是「下载完再播」,它是按需要取文件的某一段——起播时取文件开头一小段,拖动时直接取目标位置附近的那一段而我的实现是「读整个文件、一次性写回响应」,不管播放器请求什么都返回整个文件,它拿不到想要的那一段,进度条就拖不动。我是抓包才看到播放器确实发出了带范围头的请求,而我的接口每次都返回整个文件和一个普通的成功状态码。教训是:客户端功能不生效时,先看看它实际发出的请求是什么、服务端返回的是什么——抓一次包比读十遍文档快。改法是支持按范围返回:解析范围头、只读取并返回那一段、返回「部分内容」的状态码而不是普通成功、响应头要带本次范围、文件总长度和本段长度——状态码或响应头错一项播放器就不认,会退化成整段下载这也让长视频的起播明显变快了,因为播放器只需要先拿到开头一小段就能开始解码(配合转码时把索引信息前置那个改动)。另一个和图片的重要区别是读写方式视频不能整段读进内存再返回——几十兆的文件加几个并发请求,内存立刻就上去了,而这个问题在单人测试时完全不出现,我是用脚本并发请求才看到内存曲线抬升的而图片压缩后只有几百 KB,整段读是可以接受的——所以「同一个做法在不同数据量级下是不同的结论」。
    Q:范围头是播放器发的,还需要校验吗? A:需要,而且我因为没校验被自己构造的一个请求打出了异常。我最初是直接把范围头里的数字拿来当读取的起止位置——用一个超出文件长度的范围、或者起始大于结束的范围去请求,读取就抛异常了而这个请求任何人都能构造,不只有播放器会发。教训是:范围头虽然是协议的一部分,但它同样是客户端可以任意构造的输入——「看起来像协议」不代表它可信。现在的校验有三类:起始超出文件长度就拒绝并返回「范围不满足」的状态码起始大于结束直接拒绝范围过大则截断到一个上限而不是照单全收最后一条的理由是:如果允许一个请求要整个文件,那就等于绕过了范围机制、退化成整段下载,我做范围支持的意义就没了。除了参数校验,这一块还有两个和「不可信客户端」相关的控制简单防盗链——校验来源,不允许其他站点直接引用,不做的后果是别人的页面用我的视频、带宽由我承担,而视频的带宽成本比图片高一个量级以及单连接的并发请求数限制不限的话脚本可以开很多连接同时拉同一个文件不过我没有做签名链接我的视频是公开内容(流里所有人都能看),签名的收益主要是防带宽盗用而不是防内容泄露;而带签名的链接每次都不同,会让长期缓存完全失效——那个代价对视频这种大文件很实在判据是「这份资源本身是不是私密的」——不是,那简单来源校验加并发限制就够;但我把边界写清楚了:如果以后有私密视频,必须换成时效访问凭据。

没有匹配的内容,换个关键词试试。

项目拆解 · 短视频播放流(个人项目 · 无实习档)· 共 6 个模块 · 数字均为示例,需替换成自己项目的真实数据