第一版是最直白的同步流程:客户端把文件传给业务服务,业务服务落盘、调转码、等转码完、写库、返回成功。这个流程在测试环境跑得通,一上量就全线崩。
一是业务服务被文件流量打死。几万条视频、每条几十兆走同一批应用服务器,带宽和内存都撑不住,而且上传占用的线程会长时间挂住,正常接口跟着被拖慢。二是转码是分钟级的任务,压根不能放在请求里等,HTTP 超时、网关超时、用户切后台,任何一个都会让这次上传白费。三是失败没法恢复——转码在第 8 分钟失败了,前面 7 分钟的工作全部丢弃,重来一遍。
所以三个改造方向:文件不经过业务服务(直传)、长任务彻底异步化(事件驱动)、把大任务拆成可重试的小阶段(阶段化 + 幂等)。第三点是最关键的——它把「一个 8 分钟的不可靠操作」变成了「若干个几十秒的可重试操作」,可靠性是质变而不是量变。
要点是状态机 + 阶段产物落地。视频记录上有明确的状态(待转码 / 转码中 / 转码失败 / 已上架 / 已下架),每个阶段的产物(切片、各码率文件、封面)都落在对象存储的确定路径上。重试时先看产物在不在,在就跳过——这是幂等的实现基础。
为什么不用现成的云转码服务。评估过,云服务省事但两个问题:成本(按分钟计费,量大之后比自建集群贵不少)和可控性(转码参数、水印、审核插桩这些定制需求受限)。最后的选择是混合——自建集群跑常规任务,突发流量溢出到云服务。这个方案的代价是要维护两套接入,但避免了「集群按峰值容量建设」的浪费。
切片并行的代价。并行度上去了,但引入了新问题:各段的编码参数必须完全一致,否则合并后码率突变,播放器会卡顿甚至报错。而且合并阶段成了新的串行瓶颈。实测下来切片并行对长视频收益大(10 分钟素材节省一半以上),对短视频反而是负收益(切片和合并的固定开销超过并行收益)。所以按时长分流:短视频不切片直接转,长视频才走切片。这个「优化不是无条件生效」的判断是能加分的。
踩过的坑:消息重复消费导致重复转码。队列的投递语义是至少一次,同一个转码事件可能被消费两次,两个 worker 同时转同一个视频,浪费资源还可能互相覆盖产物。修法是用视频 ID 加阶段名作为分布式锁的 key,抢到锁才执行,同时阶段入口检查产物是否已存在。幂等和锁要一起用——锁防并发,幂等防重复执行。
没做的部分:没做转码任务的优先级队列。实际上大 V 的视频应该优先转码,我们是先到先转。这在流量平稳时没问题,但活动期间队列积压时,头部作者的内容和普通用户的内容一起排队,业务上不合理。
转码耗时:固定一批测试素材(几个不同时长和分辨率的样本),在集群空载的条件下跑,记录从消费转码事件到上架事件的时间差。「10 分钟素材 7 分钟降到 3 分钟」是这个条件下的数字,必须说明是空载测的——集群有负载时的耗时完全取决于排队情况,那个数字不能用来说明优化效果。
首帧起播 P95:这是客户端埋点,从「开始请求视频」到「第一帧渲染」的耗时,按国家或地区分别看。取 P95 而不是平均值,因为平均值会被大量的本地快速命中拉低,掩盖掉真正的问题用户。要说清是哪个地区的数字——国际业务里不同地区的 CDN 覆盖差异很大,混在一起算没有意义。
为什么不报「转码成功率从 X% 提升到 Y%」:因为分母很难界定——用户传了个损坏文件算失败吗?这类比率一被追问分母就说不清。改用「阶段化之后失败只需重试单个阶段,重试后的最终完成率接近全部,人工介入的死信每天个位数」这种绝对数描述更稳。
update ... where status = '转码中'),重复执行时第二次影响行数为 0,直接跳过后续逻辑。配额是在创建记录时扣的,和转码重试无关。关键是把「创建」和「加工」分开——创建只发生一次且有唯一约束保护,加工可以重复执行但幂等。
第一版的 Feed 接口是串行的:先查关注的人的新视频,不够就补热门,还不够就补兴趣推荐,最后过滤已看过的。每一路都是一次或多次远程调用,串起来 P95 到了 430ms,而且任何一路慢了整个接口就慢。
另外两个体验问题更致命。一是重复内容——用户刷到第三页会看到第一页出现过的视频,因为不同召回路径可能返回同一条,而去重只在单次请求内做。二是翻页错乱——用 offset 分页,翻页期间有新内容插入到前面,第二页就会出现第一页看过的内容(或者跳过一些)。
所以三个改造:召回并行化并允许单路降级、曝光去重做成跨请求持久的、分页改成游标式。这三个问题的共同特征是:它们都是「多数据源聚合」这个模式的固有问题,不是某一处的 bug。
CompletableFuture 并行发起,每路设自己的超时。整体等待时间取决于最慢的一路,所以必须有整体超时,到点就用已经拿到的结果拼一个可用的 Feed 返回。Feed 场景下「少几条」用户几乎感知不到,「慢一秒」则明显。MGET 会造成 Redis 的单次操作耗时飙升,影响同实例的其他请求。为什么不做完全的推模式(预先算好每个用户的 Feed 存起来)。推模式读的时候最快,但存储和计算成本随用户数线性增长,而且大部分用户当天压根不来,算了也是浪费。我们的量级下选了读时聚合 + 重度缓存:热榜和兴趣召回的结果是预先算好并缓存的(这部分是「推」),但最终的混排和去重是请求时做的(这部分是「拉」)。这个折中的判断依据是「用户活跃比例」和「内容时效性」——活跃比例低、内容时效强的场景,读时聚合更划算。
布隆过滤器的误判要有兜底。误判会导致用户永远看不到某条内容。对普通内容无所谓,但如果是用户关注的人刚发的视频被误判过滤了,用户会明确察觉「我关注的人发了我没看到」。所以做了区分:关注流不走布隆过滤,用精确的 Set 存最近一段时间的已读(关注流的量级小得多,存得下);热门和兴趣流才走布隆。按数据重要性选择不同的去重精度,是这个模块最值得讲的取舍。
踩过的坑:并行召回之后 P95 反而更差。改成并行后压测发现 P95 比串行还高。原因是线程池配置不当——核心线程数太小、队列很长,请求量上来后任务在队列里排队,等待时间比串行执行还久。修法是把队列改短、核心线程数按「并发请求数乘以召回路数」估算,并且队列满了直接走降级而不是排队。这个坑很典型:并行化本身不会带来性能,配置错了反而更慢。
接口 P95:压测环境用 50 到 200 QPS 的梯度压,取服务端埋点的耗时分布。要说清压测的数据规模——用几万个用户、几十万条视频的数据集压出来的数字,和用 100 个用户压出来的完全不同(后者全部命中缓存,压不出问题)。「压测 200 QPS 下 P95 从 430ms 到 150ms」这个描述里,QPS 和数据规模都要能报出来。
重复内容:写脚本模拟一个用户连续翻 10 页,统计出现过两次以上的视频 ID 数量。改造前每页能看到几条重复,改造后偶发(布隆过滤器的窗口边界和并发请求会有极少数漏网)。用「每页可见数条」和「偶发」这种描述,比编一个「重复率 0.03%」更可信——后者一被问「你怎么统计的分母」就答不上来。
不要报「Feed 点击率提升 X%」。那是推荐算法的指标,不是接口优化的成果,而且受太多因素影响。把工程指标和业务指标分清楚,往自己身上揽不属于自己的指标是最容易被问穿的。
最初的实现是「点赞就 update video set like_count = like_count + 1」。问题在压测时立刻暴露:
一是热点行锁竞争。一条爆款视频同一秒有几千人点赞,全部去更新同一行,行锁排队,接口耗时从几毫秒涨到几百毫秒甚至超时。二是数据库写压力和请求量 1:1,点赞的 QPS 就是数据库的写 QPS,扛不住。三是重复点赞没防住——用户快速连点,或者客户端重试,会导致计数多加。
核心矛盾是:计数是「读多写多但精度要求不高」的数据,用强一致的数据库更新是错配。用户不会核对一条视频到底是 10238 还是 10239 个赞,但会明确感知「点了赞数字没变」和「点赞转圈半秒」。所以设计方向是用缓存承接读写,异步合并落库,牺牲强一致换吞吐和延迟。
SADD 的返回值判断是不是新增——返回 1 说明之前没点过,返回 0 说明已经点过。这一步既是业务判重也是幂等保证,客户端重试多少次结果都一样。集合太大的话要考虑分片或者只存热点视频的,冷视频回源查关系表。INCR 是单线程原子操作,没有锁竞争问题,耗时在亚毫秒级。读接口也直接读这个值,不读数据库——这样读写都在缓存,数据库完全不在关键路径上。SREM 判断是否真的存在,存在才 DECR 并发消息。要防止计数被减成负数——Redis 层面用 Lua 脚本判断后再减,或者读的时候做下限保护。压测里很容易压出负数,因为并发的加和减顺序不可控。为什么敢让 Redis 当读源,不怕丢数据。因为丢的是计数不是关系。点赞关系在数据库里是完整的,Redis 的计数即使整体丢失,也能从关系表重算恢复。所以最坏情况是「计数短时间不准,重算后恢复」,不是「数据丢了」。这个判断的前提是把「可重算的派生数据」和「不可重算的源数据」分开——源数据走强一致,派生数据可以放在缓存里。
为什么不用数据库的原子更新加分段(把一行拆成多行分摊锁竞争)。那也是一种方案(类似分段锁的思路),能缓解热点行问题。但它没有解决数据库写 QPS 和请求量 1:1 的问题,只是把一行的锁分散到多行。量级再上去还是会顶到数据库的写入上限。所以选了缓存承接加批量合并——它同时解决了锁竞争和写放大。
踩过的坑:Redis 计数被淘汰导致数字跳变。计数 key 如果设了过期时间或者被内存淘汰策略清掉,读接口会回源数据库,而数据库里的值是批量合并落库的、可能滞后几秒,于是用户看到点赞数突然变小了。修法有两个:计数 key 不设过期(用单独的实例或者配置 noeviction 相关的隔离),以及回源时取数据库值和本地记录的最大值。这个坑在压测里不容易发现,是线上偶发的用户反馈才定位到的。
没做的部分:没做多机房的计数合并。如果业务扩展到多个地域各自有 Redis,计数怎么汇总是个新问题(要么单点写、要么各自计数最后合并,都有取舍)。当时是单机房,这个问题没遇到,但要能说出它是个已知的未解决问题。
点赞接口 P95:压测工具按 1000 / 3000 / 5000 QPS 梯度压,请求要打散到多个视频 ID,同时也要有一个热点 ID 承接高比例流量——只压随机 ID 压不出热点问题,只压单个 ID 又不真实。取服务端埋点耗时的 P95,5000 QPS 下约 25ms。
数据库写 QPS:压测期间看数据库的监控指标(写入 QPS 或者 TPS)。改造前和请求量基本持平(5000 左右),改造后稳定在 200 上下。这个数字是批量大小和时间窗口共同决定的,被追问时要能说出攒批的参数以及为什么这么设——批太大延迟高,太小合并效果差。
偏差收敛:校准任务跑完后对比修正的记录数和偏差幅度。不要说「偏差为 0」——异步链路不可能没有偏差,说 0 反而说明没测过。说「偏差在校准周期内收敛」「单次校准修正的记录是个位数到十几条」这种描述更真实。
SMEMBERS 之类的操作直接卡住实例。处理办法几个:按用户 ID 哈希分片成多个小 key;只对热点视频用集合,冷视频直接查关系表(冷视频的 QPS 低,查库没压力);或者用布隆过滤器替代集合做判重(但会有误判导致少数用户点不了赞,体验上不能接受,所以这个方案不适合)。我们用的是分片加冷热分离。
没有匹配的内容,换个关键词试试。
项目拆解 · 短视频内容平台(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据