接手时标签功能已经在跑,看起来就是几张表的增删改查。但它有三个坑,而且每一个的影响面都是几十万条视频。
一是用标签名做关联键。视频表里存的是标签名的字符串列表。运营觉得「搞笑」这个标签不够准确,改成了「幽默搞笑」,结果所有原来挂「搞笑」的视频,在按新标签查询时全都查不到了——因为关联断了。这个问题上线三个月才被发现,因为它是静默的:没有报错,只是某个分类下的内容变少了。
二是分类树用递归查询。要查「某个一级分类下的所有视频」,得先递归找出所有子分类。层级深一点、请求量大一点,数据库就被这些递归查询打满了。而且递归的实现散在几个地方,各自还不太一样。
三是批量打标是逐条循环调接口。运营要给一个活动下的几千个视频统一打标签,前端循环调用几千次。中间失败了不知道停在哪,重试又会重复打,而且这几千次请求会把接口打满。
还有一个后来才暴露的问题:标签被删除后,历史关联怎么办。直接删关联的话,那些视频的分类历史就没了,运营想恢复也恢复不了。
所以四个改动:关联一律用不可变的标签 ID、树查询用路径字段加缓存替代递归、批量打标任务化并给影响面预演、停用与合并不物理删除关联。
这个模块最想说的一句话是:任何会被人修改的属性都不能当关联键。标签名、部门路径、分类名称,这些都是「展示用的、会变的」,而关联需要的是「不变的身份」。这条原则我后来在做组织架构、商品分类时都遇到了,是个能反复用的判断。
树结构选「path 字段」而不是「闭包表」或「递归 CTE」。闭包表查询最快但维护成本高(每次移动要维护大量祖先后代记录);递归 CTE 依赖数据库版本且性能不稳定。path 字段的代价是路径长度有上限、移动子树要批量更新,但对「几百到几千个节点、极少移动」的标签树来说完全够用。判据是「读写比例和变更频率」——标签树是读多写极少,那就该优化读、接受写的批量代价。
标签合并选映射转向而不是直接迁移,多了一层查询转换的复杂度。直接迁移最干净(迁完就没有映射了),但它是不可逆的——运营合并错了只能手工恢复。映射转向的代价是查询时要多一次转换、以及要记得清理已完成迁移的映射,换来的是「合并可撤销」。这个取舍的原则和「停用而非删除」是一样的:运营工具里的破坏性操作应该默认可逆。
批量打标的圈选条件我坚持要做预演,即使运营觉得多一步麻烦。他们最初的反馈是「我知道我在干什么,不用确认」。但圈选条件写错的代价是几万条视频被误打标,而且清理起来要再跑一次反向任务。后来真的发生过一次(少加了时间限制),预演拦住了。这件事之后运营自己也认可这一步了——所以我的经验是:影响面大的操作,预演不要因为用户嫌麻烦就砍掉,而是要把它做得快(预演只算数量、几百毫秒返回)。
踩过的坑一:用标签名做关联键,改名导致大量视频分类失效,三个月后才发现。发现的原因是运营问「这个分类下的视频怎么变少了」。这件事最可怕的地方是它完全静默——没有报错、没有异常日志、接口全是 200。我从这里学到的是:数据关联的正确性问题通常不会以报错的形式出现,只会以「数据看起来少了/多了」的形式出现,所以这类设计要在写代码前就想清楚,不能指望测出来。
踩过的坑二:算法重跑把人工标注冲掉了。关联表没有来源字段,算法任务的逻辑是「先删除该视频的全部标签,再写入算法结果」,运营辛苦标注的精选标签全没了。修法是加来源字段,算法只能覆盖算法来源的记录。通用教训是:多个写入方共享一张表时,必须能区分「这条记录是谁写的」,否则一定会互相覆盖。
踩过的坑三:移动父节点时事务中断,留下 path 不一致的节点。那些节点的 parent_id 指向新父节点,但 path 还是旧的,结果是「按新父节点查子树查不到它,按旧父节点查却能查到」。这类脏数据很难被发现。修法除了保证事务完整,还加了一个定期校验任务检查 path 与 parent_id 是否自洽。
没做的部分:没做标签的多语言。平台当时只有单语言,做多语言需要把 name 拆成独立的翻译表。但因为关联用的是 ID,加多语言时不需要改任何关联关系——这是当初那个设计带来的意外好处,值得在面试里提一句。
「改名不再影响关联」是断言型结论,用构造用例验证:给一批视频打标签、改标签名、断言按新名称和按 ID 都能查到原来那批视频、且关联记录数不变。报法是「构造用例通过」而不是编百分比。
树查询的性能对比:递归实现和 path 前缀实现各查同一个子树,报不同层级深度下的耗时对比。要说清树的规模(节点数、最大深度),否则数字不可比。这个对比是「为什么必须换」最直接的证据。
批量打标的吞吐:报「一次任务处理多少条、耗时多久、分几批」,以及和原来逐条调接口的对比(后者要发多少次请求)。请求次数的对比比耗时对比更有说服力,因为它直接解释了为什么会把接口打满。
幂等验证:同一批数据重复执行打标任务,断言关联记录数不变、且「跳过」计数等于已存在的条数。后半句要断言,它证明幂等是「正确跳过」而不是「静默失败」。
path 一致性:报校验任务每轮发现的不一致节点数(正常应为零)。这个指标的意义是「移动操作的事务完整性有没有问题」,它是个持续监控指标而不是一次性测试。
不要报什么:不要报「标签系统性能提升 N 倍」——这个模块的价值主要在正确性,硬凑性能数字反而暴露不清楚重点。该报的是「改名不影响关联」「批量打标从几千次请求变成一次任务」「校验任务持续无不一致」这三件可核对的事。
封面这件事听起来简单:用户上传一张图、存起来、前台展示。但它在真实系统里有四个问题,每一个都直接被用户看见。
一是尺寸不够用,而且规格会不断增加。信息流要小图、详情页要大图、分享卡片要固定比例、后台管理要缩略图。第一版是上传时把所有规格都生成一遍存起来,结果是存储浪费(很多规格压根没被访问过),而且产品新增一个规格就要回刷全量历史数据——几百万条视频,回刷一次要跑很久。
二是换了封面还是显示旧图。创作者改了封面,前台刷新还是老的。原因是 CDN 和浏览器都缓存了那个 URL,而 URL 没变。我最初的处理是调 CDN 的刷新接口,但刷新有延迟(分钟级)、有配额限制,而且批量改封面时配额直接不够用。用户的体感是「这个平台改封面不生效」。
三是没有封面就裂图。有些视频转码失败或者用户没设封面,前台的图片地址是空的或者指向一个不存在的对象,信息流里就是一片裂图。这个问题在移动端尤其明显。
四是生成失败没人管。抽帧或者裁剪失败之后,那条视频就一直没有封面,而没有任何机制去发现和重试——只能等运营发现某个视频显示异常。
所以四个改动:按需生成加缓存(第一次请求某规格时生成并存下来)、URL 带版本号(改封面即换版本,天然绕过所有缓存)、分级兜底(抽帧 → 默认图,保证永远有图)、失败进重试队列并可查。
这个模块最想说的一句话是:URL 带版本号这个做法,用一个几乎零成本的设计换掉了对 CDN 刷新的依赖。刷新是「事后补救」,有延迟、有配额、可能失败;版本号是「从源头避免」,即时、免费、必然生效。这类「换个思路把问题消灭掉而不是去解决它」的机会,在工程里值得主动去找。
按需生成的代价是第一次访问会慢。某个规格第一次被请求时要同步生成,可能几百毫秒。预生成则任何时候都是直接读缓存。缓解办法是对高频规格(信息流小图)仍然在转码完成后预生成,低频规格按需——混合策略比一刀切好。判据是「这个规格的访问概率有多高」:信息流小图几乎必然被访问,分享卡片图只有分享时才用。
版本号放 query 参数还是路径里?放 query(?v=3)更简单,但有些 CDN 配置会忽略 query 参数做缓存,那版本机制就失效了。放路径里(/cover/123/v3/small.jpg)最保险。我们最初用 query,后来发现某个 CDN 节点确实忽略了 query,导致部分用户看到旧图,才改到路径里。这个坑说明「依赖中间层的行为」时要验证而不是假设。
规格白名单的代价是产品加规格要发版(或改配置)。开放任意尺寸更灵活,前端想要什么尺寸自己拼 URL。但那等于把「消耗你的存储和 CPU」的能力开放给了任何人——构造一万个不同尺寸就生成一万张图。这是安全和灵活性的取舍,安全必须优先,而且加规格做成配置项之后并不麻烦。
踩过的坑一:靠 CDN 刷新解决换封面不生效,批量改封面时配额直接用完。运营做一次专题,批量改了几千个视频的封面,刷新接口的配额当天就用光了,剩下的视频一整天都显示旧图。这件事直接推动了版本号方案。教训是:任何有配额的外部依赖,都不能作为核心链路的正确性保证——它可以是优化,不能是必需。
踩过的坑二:没加生成锁,一个热门视频上首页时把服务打挂了。那个视频的某个规格还没生成,瞬间几千个请求同时触发生成,每个请求都在做图片解码和缩放,CPU 直接满了,连正常请求也处理不了。加锁之后只有一个请求真正生成、其余等待结果。这类「缓存未命中时的并发穿透」在任何按需生成的场景都会出现,我后来做别的按需计算时都会先想这一层。
踩过的坑三:默认图用了一张纯灰色占位,运营反馈「看起来像坏了」。用户分不清「这个视频没有封面」和「图片加载失败」。改成按分类的风格化默认图之后,观感明显好转。这一条成本极低但用户能直接感知到。
没做的部分:没做智能封面(用算法挑最吸引人的一帧)。这需要模型支持,超出了范围。但抽多帧候选让创作者自己选,用很小的成本覆盖了主要需求——大部分创作者会自己选一帧,只有懒得选的才用默认。
存储节省:对比预生成全部规格与按需生成的存储占用。要说清规格数量和实际被访问的规格比例——节省的本质是「很多规格从未被访问」,所以这个比例才是关键证据。我会报「实际被访问的规格占全部规格的比例」这个观测值。
「换封面不生效」不再出现:断言型验证——改封面后立即请求,断言返回的 URL 版本号已变化、且内容是新图。还要在真实 CDN 环境下验证一次(因为我们踩过 CDN 忽略 query 的坑),这一点必须说明,纯本地测试是不够的。
裂图率:前端上报图片加载失败的比例。这个指标要区分「地址为空」和「地址有但加载失败」——前者是服务端兜底没做好,后者可能是网络问题。只报一个总数会混淆两种原因。
按需生成的首次耗时:报不同规格的生成耗时(和原图尺寸相关),以及加锁后并发场景下的等待时间。要说清这是首次访问的成本,后续走缓存。
生成失败率与失败原因分布:报四类原因各自的占比。分布比总失败率有用得多——如果大部分是「源文件不存在」,那问题在转码链路而不在封面服务。
不要报什么:不要报「封面加载速度提升」——那主要是 CDN 的功劳,不是这个模块。该报的是「存储占用下降」「换封面即时生效」「裂图不再出现」「失败可发现可重试」这四件事。
第一版的举报就是一张流水表:用户点举报,插一条记录,审核后台按时间倒序列出待处理的举报。看起来没问题,接了真实流量之后四个问题一起出现。
一是队列被同一条内容刷满。一条有争议的视频被两千人举报,审核后台的待处理列表里就有两千条记录,全指向同一个视频。审核员处理了第一条,剩下一千九百九十九条还在队列里。他要么一条条点掉,要么就被这堆重复淹没,而真正需要处理的其他内容排在了后面。这是最严重的问题——它让审核队列失去了优先级。
二是同一个视频被处理多次,结论还可能不一致。两个审核员同时领到指向同一视频的两条举报,一个判违规下架、一个判正常放过,最后的状态取决于谁先提交。
三是举报人得不到任何反馈。用户举报完就没消息了,他不知道有没有人看、有没有处理。结果是他反复举报同一条内容——这既加剧了第一个问题,也让用户觉得举报功能是假的。
四是有人刷举报。有人为了打击竞争对手,用大量账号举报对方的视频。因为队列是按时间排的,刷得越多越靠前,正常的举报反而被挤到后面。
所以四个改动:按被举报内容聚合成处置单(把「N 条举报」变成「1 个任务,附带 N 条证据」)、处置单走状态机并有唯一性约束(同一内容同时只有一个待处理单)、处置后异步反馈所有举报人、按成立率和频次识别恶意举报并降权。
这个模块最想说的一句话是:举报的正确聚合维度是「被举报的内容」,不是「举报这个动作」。想清楚这一点,队列的优先级、结论的一致性、重复处理这三个问题一起解决了。而我一开始按动作建模,是因为「用户点了一次举报就插一条记录」这个实现最直觉——直觉的建模往往对应错的聚合维度。
聚合成处置单的代价是「多一层模型」,代码和查询都比单表复杂。单表流水最简单,但它把「一件需要判断的事」拆成了 N 个任务。判据是「审核员真正的工作单元是什么」——他要判断的是「这条视频违不违规」,不是「这条举报对不对」,所以任务的粒度就该是视频而不是举报。这个判断方法我觉得是可迁移的:建模的聚合维度应该对齐「处理者的工作单元」,而不是对齐「触发事件」。
恶意举报选择降权而不是禁止,接受「刷举报的人仍然能举报」。禁止最干脆,但风险是误伤——成立率低不一定是恶意(可能是审核标准严、也可能这个用户对某类内容格外敏感)。而误伤的代价是真实违规内容漏审,比「被刷一点」严重。降权的效果是让刷举报失去意义(举得再多也不提升优先级),同时保留了每一条举报被看到的机会。
不成立也要通知,接受「通知量大幅增加」。只通知成立的更省事,通知量能少一大半。但不通知的后果是用户以为举报没人看,于是反复举报——这个反复举报的量比通知的成本高得多,而且它污染队列。算下来通知全部反而是更省的选择,这个反直觉的账要算清楚。
踩过的坑一:队列被同一条视频的两千条举报刷满,真正需要处理的内容排在后面。审核员反馈「今天一直在点同一个视频」。这件事让我意识到我的建模搞错了聚合维度——我按「举报这个动作」建模,因为「用户点一次就插一条」最直觉,但审核员的工作单元是内容。这是我在这个项目里最重要的一个认知转变。
踩过的坑二:并发举报建了两个处置单,两个审核员给出相反结论。代码里是「先查有没有未终态单、没有就新建」,查和建之间有并发窗口。修法是加唯一索引让数据库挡住。这就是典型的「先查再写」并发问题,和库存扣减、幂等插入是同一类,只是场景不同。
踩过的坑三:处置后同步发通知,一个处置单关联三千条举报,审核员的提交请求超时了。他以为提交失败又点了一次。修法是提交立刻返回、通知走队列异步发。教训是「一个操作可能触发的下游工作量是不确定的」时,必须异步——这里的不确定量是举报人数。
没做的部分:没做举报的自动处置(明显违规的直接下架不等人工)。这需要和内容安全的机审能力打通,而且自动下架的误判代价很高(创作者会强烈反弹)。我们的替代是「机审高置信度的自动提升优先级」而不是自动处置——用很小的风险换到了大部分的效率收益。
队列重复任务的减少:报「待处理队列条目数」与「涉及的独立内容数」的比值变化。改造前这个比值可能是几十(大量重复),改造后应该接近 1。这个比值比绝对条数有说服力,因为它直接量化了「重复」这件事。
聚合正确性:断言型用例——对同一内容并发提交多条举报,断言只产生一个处置单、聚合数正确、且每条举报记录都关联到了这个单。并发场景必须构造(多线程同时提交),否则测不出「先查再写」的窗口。
反馈覆盖率:报「处置完成的单中,其关联举报人全部收到通知的比例」。要说清通知失败的情况怎么处理(重试、以站内信为准)。这个指标衡量的是异步链路的可靠性。
重复举报率:报「同一用户对同一内容的重复举报次数」的变化。有了反馈之后这个数字应该明显下降——它是「反馈有效」最直接的证据,比问卷或者主观感受可靠。
恶意举报识别的效果要谨慎报。可以报「被降权的账号数」和「其举报成立率的分布」,但不要声称「识别准确率」——因为「是否恶意」没有客观标签,你只有成立率这个代理指标。诚实地说明这是代理指标而不是真实标签,比给一个准确率可信得多。
不要报什么:不要报「审核效率提升 N 倍」——审核效率主要取决于审核员和审核工具,这个模块的贡献是「去掉了重复任务」。该报的是「队列条目与独立内容数的比值接近 1」「重复举报率下降」这两个可算的观测值。
没有匹配的内容,换个关键词试试。
项目拆解 · 媒资周边支撑(后端 · 实习级)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据