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

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

实习级这一档是干什么的 转码编排、信息流召回、长连接这些核心链路,实习生大概率碰不到。这一档收的是媒资体系周边、实习生真的会被分派、但仍然有明确技术含量的活。下面三个模块彼此独立——只做过一个就只写一个。
怎么讲才不显得 low 「我做了个标签管理的增删改查」和「标签树被运营改了一次,几十万条视频的分类全乱了,因为我把标签名当成了关联键,后来改成用不可变的标签 ID 关联、名称只作为展示」——同一件事,后者面试官会顺着追问。这一档的破解办法是找到「看起来是 CRUD、实际有正确性约束」的那一层:标签的关联键、封面的缓存刷新、举报的去重,每一个都有明确的对错。
三条自检 一、能说出不这么做会怎样(用标签名做关联键改名就全乱、封面换了不刷缓存用户看到旧图、举报不去重同一条内容会被重复处置);二、能说出你踩过的具体坑;三、能说出量级(多少视频、多少标签、每天多少举报)。三条都有就能写。
项目背景设定 短视频平台的媒资中台周边,Java + Spring Boot + MySQL + Redis + 对象存储 + 消息队列。核心的上传转码链路由正式同学负责,我接的是围绕媒资的三块支撑能力。
为什么这三块值得写 它们表面上都是「增删改查加一点逻辑」,但每一块都有一个明确的正确性约束:标签的关联不能依赖可变的名称、封面的缓存必须和存储保持一致、举报必须按内容去重而不是按次数堆积。这些约束一旦搞错,影响面都是几十万条内容级别的,而且错误往往是静默的——这才是它们值得讲的原因。

模块一:视频标签与分类管理

  1. 视频标签与分类管理(不可变 ID 关联 + 树结构与路径缓存 + 批量打标 + 变更影响面预演)★★
    简历这样写 视频标签与分类体系(Spring Boot + MySQL + Redis + 批量任务):标签与视频的关联一律用不可变的标签 ID,名称仅作展示(原先用名称关联,运营改一次标签名导致大量视频分类失效);分类树的层级查询用路径字段加缓存避免递归查询,父节点变更时按路径前缀批量更新子树;批量打标做成任务化异步并支持按条件圈选,执行前给出影响条数预演;标签的停用与合并不物理删除关联,改为停用标记加映射转向,保证历史数据可追溯。改造后标签改名不再影响关联关系,批量打标从逐条操作变为一次任务提交。
    展开完整拆解
    为什么要这么设计

    接手时标签功能已经在跑,看起来就是几张表的增删改查。但它有三个坑,而且每一个的影响面都是几十万条视频。

    一是用标签名做关联键。视频表里存的是标签名的字符串列表。运营觉得「搞笑」这个标签不够准确,改成了「幽默搞笑」,结果所有原来挂「搞笑」的视频,在按新标签查询时全都查不到了——因为关联断了。这个问题上线三个月才被发现,因为它是静默的:没有报错,只是某个分类下的内容变少了。

    二是分类树用递归查询。要查「某个一级分类下的所有视频」,得先递归找出所有子分类。层级深一点、请求量大一点,数据库就被这些递归查询打满了。而且递归的实现散在几个地方,各自还不太一样。

    三是批量打标是逐条循环调接口。运营要给一个活动下的几千个视频统一打标签,前端循环调用几千次。中间失败了不知道停在哪,重试又会重复打,而且这几千次请求会把接口打满。

    还有一个后来才暴露的问题:标签被删除后,历史关联怎么办。直接删关联的话,那些视频的分类历史就没了,运营想恢复也恢复不了。

    所以四个改动:关联一律用不可变的标签 ID树查询用路径字段加缓存替代递归批量打标任务化并给影响面预演停用与合并不物理删除关联

    这个模块最想说的一句话是:任何会被人修改的属性都不能当关联键。标签名、部门路径、分类名称,这些都是「展示用的、会变的」,而关联需要的是「不变的身份」。这条原则我后来在做组织架构、商品分类时都遇到了,是个能反复用的判断。

    整体链路
    数据模型 │ ├─ tag 表:id(不可变)· name(可改)· status · parent_id · path │ path 存从根到自己的 ID 路径,如 /1/23/456/ │ 有了 path,查子树就是一次 like 前缀查询,不需要递归 │ ├─ video_tag 关联表:video_id · tag_id · 来源(人工/算法)· 时间 │ 只存 tag_id,绝不存 tag_name │ 来源字段很重要:算法打的标和人工打的标,处理策略不同 │ └─ 展示时按 tag_id 批量查名称(一次 in 查询 + 本地缓存) 标签树的读 │ ├─ 整棵树缓存在 Redis(树不大,几百到几千个节点) │ 变更时整体失效重建,比增量维护简单且不容易错 │ ├─ 查某节点的子树:按 path 前缀匹配,一次查询搞定 │ └─ 查某视频的标签:关联表查 tag_id 列表 → 批量取名称 标签树的写 │ ├─ 新增:算出 path = 父节点 path + 自己的 id │ ├─ 改名:只改 name,path 和 id 不动 → 关联关系完全不受影响 │ 这是「用 ID 关联」带来的最大好处 │ ├─ 移动父节点:按旧 path 前缀批量更新整棵子树的 path │ 要在一个事务里做,中断会留下 path 不一致的节点 │ └─ 停用:改 status,不删除,不动关联 前台不再展示,但历史数据仍可追溯 标签合并(把 A 合并到 B) │ ├─ 建一条映射:A → B(redirect 关系) ├─ 关联表里 A 的记录不删,查询时按映射转向到 B ├─ 异步任务逐步把关联迁移到 B(迁移完可以清理映射) └─ 好处:合并即时生效,且随时可以撤销 批量打标(任务化) │ ├─ 提交:按条件圈选(时间范围 / 分类 / 作者)或按 ID 列表 │ ├─ 预演:先算影响条数并返回,让运营确认 │ 「将给 3,214 个视频添加标签 X」,确认后才执行 │ ├─ 执行:分批处理,每批一个小事务,记录已完成批次 │ 关联表上有唯一索引(video_id + tag_id),天然幂等 │ ├─ 失败:重试该批;超次数置部分完成并输出已处理的区间 │ └─ 结果:成功条数 / 跳过条数(已有该标签)/ 失败条数 「跳过」要单独报,它是正常情况不是错误
    分步拆解
    1. 关联表只存 tag_id,绝不存 tag_name。这是这个模块最重要的一条。名称是展示属性、会被修改;ID 是身份、不可变。用名称关联的后果是改一次名字所有历史关联全断,而且是静默的没有报错。
    2. 展示时批量查名称,不要逐条查。一个列表页里几十个视频、每个视频几个标签,逐条查名称就是几百次查询。做法是收集全部 tag_id 一次 in 查询,再加一层本地缓存(标签数量少、变化不频繁,非常适合缓存)。
    3. 用 path 字段替代递归查询。存从根到自己的 ID 路径,查子树变成一次前缀匹配。递归查询在层级深、并发高时会把数据库打满,而且实现容易散落在多处各不相同。
    4. 移动父节点时要在事务里批量更新整棵子树的 path。中断会留下 path 不一致的节点,而这类脏数据很难发现(查询结果只是「少了一些」)。要有一个校验任务定期检查 path 与 parent_id 是否自洽。
    5. 标签树整体缓存、变更时整体失效重建。树不大(几百到几千节点),整体重建比增量维护简单得多,而增量维护树形结构的缓存极容易出错——移动一个节点要同时更新多个键。这里选简单方案是对的。
    6. 停用而不删除。停用只改状态、不动关联,前台不展示但历史可追溯。物理删除关联之后,运营想恢复分类是恢复不了的——这类「删了就没了」的操作在运营工具里应该默认避免。
    7. 合并用映射转向而不是直接迁移数据。建一条 A→B 的映射,查询时转向,异步任务逐步迁移。好处是合并即时生效且随时可撤销;直接迁移的话,运营发现合并错了就没法回退了。
    8. 关联表要有唯一索引(video_id + tag_id)。这样重复打标天然幂等,批量任务重试不会产生重复关联。这是最省事的幂等实现——让数据库帮你保证。
    9. 批量打标必须任务化,不能让前端循环调接口。循环调用的问题是:中间失败不知道停在哪、重试会重复、几千次请求把接口打满。任务化之后这三个问题一起解决。
    10. 执行前给影响条数预演。「将给 3,214 个视频添加标签」,让运营确认。圈选条件写错是很常见的(少加一个时间限制就圈中了全站视频),预演是最便宜的防护。
    11. 结果要区分「成功」「跳过」「失败」三类。「跳过」是因为已有该标签,这是正常情况不是错误。混在失败里报会让运营以为出了问题。
    12. 关联表要记来源(人工/算法)。因为处理策略不同——算法打的标可以被重新计算覆盖,人工打的标不能被算法覆盖。不记来源的话,一次算法重跑就把运营的人工标注全冲掉了。
    关键决策与取舍

    树结构选「path 字段」而不是「闭包表」或「递归 CTE」。闭包表查询最快但维护成本高(每次移动要维护大量祖先后代记录);递归 CTE 依赖数据库版本且性能不稳定。path 字段的代价是路径长度有上限、移动子树要批量更新,但对「几百到几千个节点、极少移动」的标签树来说完全够用。判据是「读写比例和变更频率」——标签树是读多写极少,那就该优化读、接受写的批量代价。

    标签合并选映射转向而不是直接迁移,多了一层查询转换的复杂度。直接迁移最干净(迁完就没有映射了),但它是不可逆的——运营合并错了只能手工恢复。映射转向的代价是查询时要多一次转换、以及要记得清理已完成迁移的映射,换来的是「合并可撤销」。这个取舍的原则和「停用而非删除」是一样的:运营工具里的破坏性操作应该默认可逆。

    批量打标的圈选条件我坚持要做预演,即使运营觉得多一步麻烦。他们最初的反馈是「我知道我在干什么,不用确认」。但圈选条件写错的代价是几万条视频被误打标,而且清理起来要再跑一次反向任务。后来真的发生过一次(少加了时间限制),预演拦住了。这件事之后运营自己也认可这一步了——所以我的经验是:影响面大的操作,预演不要因为用户嫌麻烦就砍掉,而是要把它做得快(预演只算数量、几百毫秒返回)。

    踩过的坑一:用标签名做关联键,改名导致大量视频分类失效,三个月后才发现。发现的原因是运营问「这个分类下的视频怎么变少了」。这件事最可怕的地方是它完全静默——没有报错、没有异常日志、接口全是 200。我从这里学到的是:数据关联的正确性问题通常不会以报错的形式出现,只会以「数据看起来少了/多了」的形式出现,所以这类设计要在写代码前就想清楚,不能指望测出来。

    踩过的坑二:算法重跑把人工标注冲掉了。关联表没有来源字段,算法任务的逻辑是「先删除该视频的全部标签,再写入算法结果」,运营辛苦标注的精选标签全没了。修法是加来源字段,算法只能覆盖算法来源的记录。通用教训是:多个写入方共享一张表时,必须能区分「这条记录是谁写的」,否则一定会互相覆盖。

    踩过的坑三:移动父节点时事务中断,留下 path 不一致的节点。那些节点的 parent_id 指向新父节点,但 path 还是旧的,结果是「按新父节点查子树查不到它,按旧父节点查却能查到」。这类脏数据很难被发现。修法除了保证事务完整,还加了一个定期校验任务检查 path 与 parent_id 是否自洽。

    没做的部分:没做标签的多语言。平台当时只有单语言,做多语言需要把 name 拆成独立的翻译表。但因为关联用的是 ID,加多语言时不需要改任何关联关系——这是当初那个设计带来的意外好处,值得在面试里提一句。

    数字是怎么测的

    「改名不再影响关联」是断言型结论,用构造用例验证:给一批视频打标签、改标签名、断言按新名称和按 ID 都能查到原来那批视频、且关联记录数不变。报法是「构造用例通过」而不是编百分比。

    树查询的性能对比:递归实现和 path 前缀实现各查同一个子树,报不同层级深度下的耗时对比。要说清树的规模(节点数、最大深度),否则数字不可比。这个对比是「为什么必须换」最直接的证据。

    批量打标的吞吐:「一次任务处理多少条、耗时多久、分几批」,以及和原来逐条调接口的对比(后者要发多少次请求)。请求次数的对比比耗时对比更有说服力,因为它直接解释了为什么会把接口打满。

    幂等验证:同一批数据重复执行打标任务,断言关联记录数不变、且「跳过」计数等于已存在的条数。后半句要断言,它证明幂等是「正确跳过」而不是「静默失败」。

    path 一致性:校验任务每轮发现的不一致节点数(正常应为零)。这个指标的意义是「移动操作的事务完整性有没有问题」,它是个持续监控指标而不是一次性测试。

    不要报什么:不要报「标签系统性能提升 N 倍」——这个模块的价值主要在正确性,硬凑性能数字反而暴露不清楚重点。该报的是「改名不影响关联」「批量打标从几千次请求变成一次任务」「校验任务持续无不一致」这三件可核对的事。

    面试追问
    Q:标签管理不就是增删改查吗,你觉得难点在哪? A:难点在关联关系的正确性,而这一点在写代码时很容易被忽略,因为它不报错。我们真实踩到的:用标签名做关联键,运营改了一次标签名,几十万条视频的分类关联全断了,而且三个月后才被发现——没有报错、没有异常日志、接口全是 200,只是某个分类下的内容「看起来变少了」。这件事让我总结出一条能反复用的原则:任何会被人修改的属性都不能当关联键。标签名、部门路径、商品分类名,这些都是展示属性,关联需要的是不可变的身份。我后来做组织架构同步、商品分类的时候都用到了这条判断。另一个难点是「多个写入方共享一张表」——算法打标和人工打标写同一张关联表,算法重跑时「先删后写」把运营的人工标注冲掉了。这类问题的通用解法是记录来源,让每个写入方只能覆盖自己写的数据。这两条都是「看起来是 CRUD、实际有正确性约束」的例子。
    Q:为什么不用闭包表?树查询性能更好。 A:闭包表的查询确实更快,但它的维护成本和这个场景不匹配。闭包表要为每一对祖先后代关系存一条记录,移动一个子树需要删除并重建大量记录,而且这个维护逻辑一旦写错,产生的脏数据非常难排查。我们的标签树是「几百到几千个节点、最大深度三四层、几乎不移动」——读多写极少。在这个条件下 path 前缀查询已经足够快(一次索引前缀匹配),而实现和维护都简单得多。判据是读写比例和变更频率:如果是一个几十万节点、经常调整结构的树(比如大型组织架构),闭包表才值得。我倾向于在这类选型上先问「规模和变更频率是多少」,而不是先比较理论性能——因为大部分场景下简单方案的性能是够的,而复杂方案的维护成本是持续的。另外我还加了一个 path 与 parent_id 的一致性校验任务,用一个便宜的兜底换掉了对实现完全正确的依赖。
    Q:标签合并做成映射转向,那查询链路是不是变复杂了?会不会有性能问题? A:会多一次转换,但成本很低。映射的数量级很小(合并是低频运营操作,同时存在的映射通常个位数到几十条),所以整张映射表可以缓存在内存里,转换就是一次哈希查找,几乎没有开销。而且映射是临时的——异步任务会逐步把关联迁移到目标标签,迁移完成后映射就可以清理,所以它不会无限累积。选这个方案的核心理由是可撤销:直接迁移数据是不可逆的,运营合并错了只能手工恢复(而且他可能压根不记得原来哪些视频挂的是哪个标签);映射转向可以一键撤销。这和「停用而非删除」是同一个原则——运营工具里的破坏性操作应该默认可逆,因为运营的操作是探索性的,他会犯错,而系统应该让犯错的代价足够小。

模块二:封面与截图服务

  1. 封面与截图服务(多规格按需生成 + 默认封面兜底 + 变更后缓存刷新 + 生成失败可重试)★★
    简历这样写 视频封面与截图服务(Spring Boot + 对象存储 + CDN + 异步任务 + 版本化 URL):封面需要多种尺寸(信息流、详情页、分享卡片),改为按需生成加缓存而非上传时全量预生成(预生成会浪费存储且新增规格要回刷全量);封面变更后的缓存问题用URL 带版本号解决而非依赖 CDN 刷新(刷新有延迟且有配额限制,版本号是即时且免费的);视频无封面时按抽帧兜底再到默认图分级降级,保证前台不出现裂图;抽帧与生成失败进入重试队列并可人工干预,失败原因分类可查。改造后前台裂图与「换了封面还是旧图」两类反馈不再出现。
    展开完整拆解
    为什么要这么设计

    封面这件事听起来简单:用户上传一张图、存起来、前台展示。但它在真实系统里有四个问题,每一个都直接被用户看见。

    一是尺寸不够用,而且规格会不断增加。信息流要小图、详情页要大图、分享卡片要固定比例、后台管理要缩略图。第一版是上传时把所有规格都生成一遍存起来,结果是存储浪费(很多规格压根没被访问过),而且产品新增一个规格就要回刷全量历史数据——几百万条视频,回刷一次要跑很久。

    二是换了封面还是显示旧图。创作者改了封面,前台刷新还是老的。原因是 CDN 和浏览器都缓存了那个 URL,而 URL 没变。我最初的处理是调 CDN 的刷新接口,但刷新有延迟(分钟级)、有配额限制,而且批量改封面时配额直接不够用。用户的体感是「这个平台改封面不生效」。

    三是没有封面就裂图。有些视频转码失败或者用户没设封面,前台的图片地址是空的或者指向一个不存在的对象,信息流里就是一片裂图。这个问题在移动端尤其明显。

    四是生成失败没人管。抽帧或者裁剪失败之后,那条视频就一直没有封面,而没有任何机制去发现和重试——只能等运营发现某个视频显示异常。

    所以四个改动:按需生成加缓存(第一次请求某规格时生成并存下来)、URL 带版本号(改封面即换版本,天然绕过所有缓存)、分级兜底(抽帧 → 默认图,保证永远有图)、失败进重试队列并可查

    这个模块最想说的一句话是:URL 带版本号这个做法,用一个几乎零成本的设计换掉了对 CDN 刷新的依赖。刷新是「事后补救」,有延迟、有配额、可能失败;版本号是「从源头避免」,即时、免费、必然生效。这类「换个思路把问题消灭掉而不是去解决它」的机会,在工程里值得主动去找。

    整体链路
    封面的来源(三级兜底,保证前台永远有图) │ ├─ 1 用户自选封面(上传的图或从视频里选的一帧) ├─ 2 系统抽帧(转码时抽取若干候选帧,取一帧作为默认) └─ 3 兜底默认图(按分类给不同的默认图,比通用占位好一点) 任一级可用就往下不再走,三级都不可用才算异常并告警 存储与版本 │ ├─ 原图存对象存储,路径含视频 ID │ ├─ 封面记录里维护一个 version(每次变更自增) │ └─ 对外 URL 形如 /cover/{videoId}/{spec}?v={version} 改封面 → version 自增 → URL 变化 → CDN 与浏览器必然重新拉取 不依赖 CDN 刷新:刷新有延迟、有配额、批量改时配额不够 多规格:按需生成而不是预生成全部 │ ├─ 请求某规格 → 查是否已生成 │ 已生成 → 直接返回存储地址(走 CDN) │ 未生成 → 同步生成并落存储 → 返回 │ ├─ 生成加分布式锁(同一视频同一规格) │ 防止冷启动或热点内容瞬间大量并发生成同一张图 │ ├─ 规格是白名单枚举,不接受任意参数 │ 开放任意尺寸 = 别人可以用你的服务刷出无限张图占存储 │ └─ 新增规格时不需要回刷历史:第一次被访问时自然生成 生成失败的处理 │ ├─ 分类记录失败原因:源文件不存在 / 格式不支持 / 超时 / 存储写失败 │ ├─ 可重试的进重试队列(退避重试,次数有上限) │ ├─ 不可重试的(源文件损坏)直接置终态并走兜底图 │ └─ 后台可查失败列表并手动触发重试 没有这个入口,失败的视频就永远没有封面 刷新与一致性 ├─ 封面变更 → 版本自增 → 同时清掉服务内的元信息缓存 ├─ 前台拿到的地址一定带当前 version,不会拿到旧图 └─ 旧版本的对象保留一段时间再清理(可能还有页面在引用)
    分步拆解
    1. URL 带版本号,不依赖 CDN 刷新。这是这个模块最有价值的一条。刷新是事后补救:有分钟级延迟、有配额限制、批量操作时配额直接不够、还可能失败。版本号是从源头避免:改封面就换 URL,CDN 和浏览器必然重新拉取,即时、免费、必然生效。
    2. 三级兜底保证前台永远有图。用户自选 → 系统抽帧 → 默认图。裂图对信息流的体验伤害很大,而兜底的成本几乎为零。默认图可以按分类区分,比一张通用占位图好一点。
    3. 按需生成而不是预生成全部规格。预生成的两个问题:存储浪费(很多规格从未被访问)、新增规格要回刷几百万条历史数据。按需生成之后,新增规格只需要改配置,历史数据在被访问时自然生成。
    4. 按需生成必须加锁。热点视频或者冷启动时,同一张图可能被几百个请求同时触发生成。不加锁就是几百次重复的图片处理,CPU 直接打满。锁的粒度是「视频 ID + 规格」。
    5. 规格必须是白名单枚举,不能接受任意参数。如果 URL 里的尺寸是自由参数,别人可以构造无限多的尺寸把你的存储刷满、把 CPU 打满。这是个真实的攻击面,不是理论风险。
    6. 生成失败要分类并记录原因。源文件不存在、格式不支持、处理超时、存储写失败——四类的处理完全不同:前两类不可重试(直接走兜底),后两类可重试。不分类就只能一律重试,浪费资源还解决不了问题。
    7. 必须有失败列表和手动重试入口。没有这个入口,失败的视频就永远没有封面,而且没人知道有多少这样的视频。这是「异步任务必须有兜底可观测」的又一个例子。
    8. 封面变更要同时清服务内的元信息缓存。版本号解决了 CDN 的缓存,但服务自己也缓存了「这个视频的封面版本是多少」,这一层不清的话,服务返回的还是旧版本号,等于版本机制没生效。
    9. 旧版本的对象不要立即删除。可能还有已经渲染出去的页面在引用旧 URL。保留一段时间再清理,否则用户刷新前会看到裂图。
    10. 抽帧的候选帧要多存几张。转码时抽 3 到 5 帧存下来,让创作者可以从中选,而不是只抽一帧硬用。第一帧经常是黑屏或者片头,直接用体验很差。
    11. 默认图要按分类区分。美食视频给一张美食风格的默认图、游戏视频给游戏风格的。比一张统一的灰色占位图好得多,而成本只是多准备几张图。
    12. 生成的图要限制最大尺寸和处理超时。用户可能上传一张超大分辨率的图,处理它会占用大量内存和 CPU。入口就要限制,而不是等处理时才发现。
    关键决策与取舍

    按需生成的代价是第一次访问会慢。某个规格第一次被请求时要同步生成,可能几百毫秒。预生成则任何时候都是直接读缓存。缓解办法是对高频规格(信息流小图)仍然在转码完成后预生成,低频规格按需——混合策略比一刀切好。判据是「这个规格的访问概率有多高」:信息流小图几乎必然被访问,分享卡片图只有分享时才用。

    版本号放 query 参数还是路径里?放 query(?v=3)更简单,但有些 CDN 配置会忽略 query 参数做缓存,那版本机制就失效了。放路径里(/cover/123/v3/small.jpg)最保险。我们最初用 query,后来发现某个 CDN 节点确实忽略了 query,导致部分用户看到旧图,才改到路径里。这个坑说明「依赖中间层的行为」时要验证而不是假设。

    规格白名单的代价是产品加规格要发版(或改配置)。开放任意尺寸更灵活,前端想要什么尺寸自己拼 URL。但那等于把「消耗你的存储和 CPU」的能力开放给了任何人——构造一万个不同尺寸就生成一万张图。这是安全和灵活性的取舍,安全必须优先,而且加规格做成配置项之后并不麻烦。

    踩过的坑一:靠 CDN 刷新解决换封面不生效,批量改封面时配额直接用完。运营做一次专题,批量改了几千个视频的封面,刷新接口的配额当天就用光了,剩下的视频一整天都显示旧图。这件事直接推动了版本号方案。教训是:任何有配额的外部依赖,都不能作为核心链路的正确性保证——它可以是优化,不能是必需。

    踩过的坑二:没加生成锁,一个热门视频上首页时把服务打挂了。那个视频的某个规格还没生成,瞬间几千个请求同时触发生成,每个请求都在做图片解码和缩放,CPU 直接满了,连正常请求也处理不了。加锁之后只有一个请求真正生成、其余等待结果。这类「缓存未命中时的并发穿透」在任何按需生成的场景都会出现,我后来做别的按需计算时都会先想这一层。

    踩过的坑三:默认图用了一张纯灰色占位,运营反馈「看起来像坏了」。用户分不清「这个视频没有封面」和「图片加载失败」。改成按分类的风格化默认图之后,观感明显好转。这一条成本极低但用户能直接感知到。

    没做的部分:没做智能封面(用算法挑最吸引人的一帧)。这需要模型支持,超出了范围。但抽多帧候选让创作者自己选,用很小的成本覆盖了主要需求——大部分创作者会自己选一帧,只有懒得选的才用默认。

    数字是怎么测的

    存储节省:对比预生成全部规格与按需生成的存储占用。要说清规格数量和实际被访问的规格比例——节省的本质是「很多规格从未被访问」,所以这个比例才是关键证据。我会报「实际被访问的规格占全部规格的比例」这个观测值。

    「换封面不生效」不再出现:断言型验证——改封面后立即请求,断言返回的 URL 版本号已变化、且内容是新图。还要在真实 CDN 环境下验证一次(因为我们踩过 CDN 忽略 query 的坑),这一点必须说明,纯本地测试是不够的。

    裂图率:前端上报图片加载失败的比例。这个指标要区分「地址为空」和「地址有但加载失败」——前者是服务端兜底没做好,后者可能是网络问题。只报一个总数会混淆两种原因。

    按需生成的首次耗时:不同规格的生成耗时(和原图尺寸相关),以及加锁后并发场景下的等待时间。要说清这是首次访问的成本,后续走缓存

    生成失败率与失败原因分布:报四类原因各自的占比。分布比总失败率有用得多——如果大部分是「源文件不存在」,那问题在转码链路而不在封面服务。

    不要报什么:不要报「封面加载速度提升」——那主要是 CDN 的功劳,不是这个模块。该报的是「存储占用下降」「换封面即时生效」「裂图不再出现」「失败可发现可重试」这四件事。

    面试追问
    Q:换封面不生效,直接调 CDN 刷新接口不就行了? A:我最初就是这么做的,然后踩了坑。CDN 刷新有三个问题:一是有延迟,分钟级生效,用户改完立刻看还是旧图;二是有配额,运营做专题批量改了几千个封面,刷新配额当天就用光了,剩下的视频一整天显示旧图;三是可能失败,而失败之后你不知道哪些没刷成功。改成 URL 带版本号之后,这三个问题一起消失了——改封面就换 URL,CDN 和浏览器必然重新拉取,即时、免费、必然生效。我从这里学到的一条是:刷新是「事后补救」,版本号是「从源头避免」,能从源头避免的就不要靠补救。而且这个思路是可迁移的——静态资源的文件名带哈希、接口的响应带 ETag,本质都是同一件事。顺带说一个细节坑:版本号最好放路径里而不是 query,因为有些 CDN 配置会忽略 query 做缓存,我们真的遇到过部分节点忽略 query 导致版本机制失效。
    Q:按需生成,那第一个访问的用户不就要等着生成吗?体验不好。 A:是的,所以我们用的是混合策略而不是纯按需:高频规格(信息流小图)在转码完成后就预生成,因为它几乎必然被访问;低频规格(分享卡片、后台缩略图)才按需。判据是「这个规格的访问概率有多高」——概率高就预生成,概率低就按需。这样既避免了「预生成全部规格」的存储浪费和回刷成本,又避免了高频路径上的首次延迟。另外按需生成必须加锁,这一点比首次延迟更重要:我们踩过一个热门视频上首页时,某个规格还没生成,瞬间几千个请求同时触发生成,每个都在做图片解码缩放,CPU 直接打满连正常请求都处理不了。加锁之后只有一个请求真正生成、其余等结果。这类「缓存未命中时的并发穿透」在任何按需计算的场景都会出现,是比首次延迟严重得多的问题。

模块三:举报受理与处置单

  1. 举报受理与处置单(按内容聚合去重 + 处置状态流转 + 结果反馈举报人 + 恶意举报识别)★★
    简历这样写 举报受理与处置(Spring Boot + MySQL + 消息队列 + 状态机):举报由按次堆积改为按被举报内容聚合成一张处置单(同一条视频被百人举报是一个待处理任务而不是一百个),聚合时保留每条举报记录用于结果反馈与举报人画像;处置单走明确的状态机(待处理 / 处理中 / 已处置 / 已驳回)并记录处置人与依据,同一内容重复举报并入已有处置单而非新建;处置完成后异步反馈所有举报人(此前无反馈,用户认为举报无效而重复提交);按举报成立率与举报频次识别恶意举报并降权,避免刷举报干扰审核队列。改造后审核待处理队列的重复任务明显减少,同一内容被反复举报不再产生新任务。
    展开完整拆解
    为什么要这么设计

    第一版的举报就是一张流水表:用户点举报,插一条记录,审核后台按时间倒序列出待处理的举报。看起来没问题,接了真实流量之后四个问题一起出现。

    一是队列被同一条内容刷满。一条有争议的视频被两千人举报,审核后台的待处理列表里就有两千条记录,全指向同一个视频。审核员处理了第一条,剩下一千九百九十九条还在队列里。他要么一条条点掉,要么就被这堆重复淹没,而真正需要处理的其他内容排在了后面。这是最严重的问题——它让审核队列失去了优先级。

    二是同一个视频被处理多次,结论还可能不一致。两个审核员同时领到指向同一视频的两条举报,一个判违规下架、一个判正常放过,最后的状态取决于谁先提交。

    三是举报人得不到任何反馈。用户举报完就没消息了,他不知道有没有人看、有没有处理。结果是他反复举报同一条内容——这既加剧了第一个问题,也让用户觉得举报功能是假的。

    四是有人刷举报。有人为了打击竞争对手,用大量账号举报对方的视频。因为队列是按时间排的,刷得越多越靠前,正常的举报反而被挤到后面。

    所以四个改动:按被举报内容聚合成处置单(把「N 条举报」变成「1 个任务,附带 N 条证据」)、处置单走状态机并有唯一性约束(同一内容同时只有一个待处理单)、处置后异步反馈所有举报人按成立率和频次识别恶意举报并降权

    这个模块最想说的一句话是:举报的正确聚合维度是「被举报的内容」,不是「举报这个动作」。想清楚这一点,队列的优先级、结论的一致性、重复处理这三个问题一起解决了。而我一开始按动作建模,是因为「用户点了一次举报就插一条记录」这个实现最直觉——直觉的建模往往对应错的聚合维度。

    整体链路
    数据模型:两层(举报记录 + 处置单) │ ├─ report 举报记录:谁举报 · 举报什么 · 理由 · 凭证 · 时间 │ 每次举报都存一条,用于结果反馈与举报人画像 │ 不直接作为审核队列的条目 │ └─ case 处置单:被举报对象 · 状态 · 聚合的举报数 · 处置人 · 结论 · 依据 审核队列的条目是处置单,不是举报记录 约束:同一对象同时只能有一个未终态的处置单 举报提交 │ ├─ 校验:对象存在 / 举报人有资格 / 未对同一对象重复举报(限频) │ ├─ 插入 report 记录 │ ├─ 找该对象的未终态处置单 │ 有 → 并入(聚合数加一、关联本条 report) │ 没有 → 新建处置单(待处理) │ 这一步要防并发:用唯一索引(对象ID + 未终态标记)兜底 │ └─ 举报数达到阈值 → 提升处置单优先级 「很多人举报」是有效信号,但不能让它简单等于「排更前」 要结合举报人质量加权,否则可以被刷 审核队列(条目是处置单) │ ├─ 排序:优先级(加权举报数)+ 等待时长,不是单纯按时间 │ ├─ 领取即加锁(同一处置单同时只能被一人处理) │ 解决「两个审核员对同一内容给出不同结论」 │ ├─ 处理界面聚合展示:内容本身 + 全部举报理由分布 + 举报人构成 │ 理由分布很有用:一百人举报里九十人选「色情」,指向性很强 │ └─ 提交结论 → 状态置终态 → 记录处置人与依据 处置后的反馈(异步) │ ├─ 遍历该处置单关联的全部 report → 逐个通知举报人 │ 成立 → 「你举报的内容已处理」 │ 不成立 → 「经核实未违规」(也要通知,否则用户以为没人看) │ ├─ 通知走统一的消息服务,按渠道降级(站内信必发) │ └─ 更新每个举报人的「举报成立率」统计 恶意举报识别(防刷) │ ├─ 维护举报人画像:总举报数 · 成立数 · 成立率 · 近期频次 │ ├─ 成立率长期极低且频次高 → 降低其举报的权重 │ 不是禁止举报(可能误判),而是降权:不再显著提升优先级 │ ├─ 同一对象短时间内来自关联账号的大量举报 → 标记异常并单独审查 │ └─ 所有降权与标记都留痕,可人工复核与撤销
    分步拆解
    1. 聚合维度是「被举报的内容」,这是整个模块的地基。把「N 条举报」变成「1 个处置单 + N 条证据」。队列的条目是处置单,举报记录只是它的附属证据。想清楚这一点,队列优先级、结论一致性、重复处理三个问题一起解决。
    2. 举报记录仍然要一条条存,不能只存聚合数。因为要用于结果反馈(通知每个举报人)和举报人画像(算成立率)。只存计数就丢失了这两个能力。
    3. 「同一对象同时只有一个未终态处置单」要用数据库约束兜底。并发举报时两个请求可能同时发现「没有未终态单」然后各建一个。用唯一索引(对象 ID + 未终态标记)让数据库挡住,比在代码里加锁更可靠。
    4. 领取处置单要加锁。否则两个审核员同时处理同一内容,可能给出相反的结论,最终状态取决于谁先提交。这是「同一任务只能被一人处理」的标准做法。
    5. 队列排序不能单纯按时间,也不能单纯按举报数。按时间会让刷举报的内容靠前(因为它举报多、新举报持续来);按举报数则完全可以被刷。做法是加权举报数结合等待时长,权重来自举报人质量。
    6. 处理界面要聚合展示举报理由的分布。一百个人举报里九十个选了同一个理由,这个指向性对审核员判断很有价值;而如果理由五花八门,可能就是被刷的。这个信息只有聚合之后才有。
    7. 处置完成后必须反馈举报人,包括「不成立」的情况。不反馈的后果是用户以为没人看,反复举报。「经核实未违规」这类反馈也要发——用户需要知道有人处理了,哪怕结论和他期望的不同。
    8. 反馈要异步。一个处置单可能关联几千条举报,逐个发通知不能卡在审核员的提交请求里。提交结论要立刻返回,通知走消息队列。
    9. 举报要限频。同一用户对同一对象在一段时间内只能举报一次。这既防刷也避免同一个人的重复举报污染证据统计。
    10. 恶意举报的处理是降权而不是禁止。因为成立率低可能是审核标准的问题,也可能是这个用户对某类内容特别敏感(不一定是恶意)。降权的后果是「他的举报不再显著提升优先级」,仍然会被处理;禁止则可能让真实的违规内容漏掉。这个取舍偏向了不误伤。
    11. 关联账号的批量举报要单独标记。同一对象短时间内来自注册时间接近、设备相近的账号的大量举报,这是明显的刷举报特征,要标记出来让审核员知道,而不是简单按举报数排到前面。
    12. 所有降权和标记都要留痕且可撤销。识别逻辑可能误判,被误判的用户要能通过申诉恢复,而恢复的前提是这些判定有记录。
    关键决策与取舍

    聚合成处置单的代价是「多一层模型」,代码和查询都比单表复杂。单表流水最简单,但它把「一件需要判断的事」拆成了 N 个任务。判据是「审核员真正的工作单元是什么」——他要判断的是「这条视频违不违规」,不是「这条举报对不对」,所以任务的粒度就该是视频而不是举报。这个判断方法我觉得是可迁移的:建模的聚合维度应该对齐「处理者的工作单元」,而不是对齐「触发事件」。

    恶意举报选择降权而不是禁止,接受「刷举报的人仍然能举报」。禁止最干脆,但风险是误伤——成立率低不一定是恶意(可能是审核标准严、也可能这个用户对某类内容格外敏感)。而误伤的代价是真实违规内容漏审,比「被刷一点」严重。降权的效果是让刷举报失去意义(举得再多也不提升优先级),同时保留了每一条举报被看到的机会

    不成立也要通知,接受「通知量大幅增加」。只通知成立的更省事,通知量能少一大半。但不通知的后果是用户以为举报没人看,于是反复举报——这个反复举报的量比通知的成本高得多,而且它污染队列。算下来通知全部反而是更省的选择,这个反直觉的账要算清楚。

    踩过的坑一:队列被同一条视频的两千条举报刷满,真正需要处理的内容排在后面。审核员反馈「今天一直在点同一个视频」。这件事让我意识到我的建模搞错了聚合维度——我按「举报这个动作」建模,因为「用户点一次就插一条」最直觉,但审核员的工作单元是内容。这是我在这个项目里最重要的一个认知转变。

    踩过的坑二:并发举报建了两个处置单,两个审核员给出相反结论。代码里是「先查有没有未终态单、没有就新建」,查和建之间有并发窗口。修法是加唯一索引让数据库挡住。这就是典型的「先查再写」并发问题,和库存扣减、幂等插入是同一类,只是场景不同。

    踩过的坑三:处置后同步发通知,一个处置单关联三千条举报,审核员的提交请求超时了。他以为提交失败又点了一次。修法是提交立刻返回、通知走队列异步发。教训是「一个操作可能触发的下游工作量是不确定的」时,必须异步——这里的不确定量是举报人数。

    没做的部分:没做举报的自动处置(明显违规的直接下架不等人工)。这需要和内容安全的机审能力打通,而且自动下架的误判代价很高(创作者会强烈反弹)。我们的替代是「机审高置信度的自动提升优先级」而不是自动处置——用很小的风险换到了大部分的效率收益。

    数字是怎么测的

    队列重复任务的减少:「待处理队列条目数」与「涉及的独立内容数」的比值变化。改造前这个比值可能是几十(大量重复),改造后应该接近 1。这个比值比绝对条数有说服力,因为它直接量化了「重复」这件事。

    聚合正确性:断言型用例——对同一内容并发提交多条举报,断言只产生一个处置单、聚合数正确、且每条举报记录都关联到了这个单。并发场景必须构造(多线程同时提交),否则测不出「先查再写」的窗口。

    反馈覆盖率:「处置完成的单中,其关联举报人全部收到通知的比例」。要说清通知失败的情况怎么处理(重试、以站内信为准)。这个指标衡量的是异步链路的可靠性。

    重复举报率:「同一用户对同一内容的重复举报次数」的变化。有了反馈之后这个数字应该明显下降——它是「反馈有效」最直接的证据,比问卷或者主观感受可靠。

    恶意举报识别的效果要谨慎报。可以报「被降权的账号数」和「其举报成立率的分布」,但不要声称「识别准确率」——因为「是否恶意」没有客观标签,你只有成立率这个代理指标。诚实地说明这是代理指标而不是真实标签,比给一个准确率可信得多。

    不要报什么:不要报「审核效率提升 N 倍」——审核效率主要取决于审核员和审核工具,这个模块的贡献是「去掉了重复任务」。该报的是「队列条目与独立内容数的比值接近 1」「重复举报率下降」这两个可算的观测值。

    面试追问
    Q:举报聚合成处置单,那怎么保证每个举报人都能收到反馈? A:靠两层模型:处置单是审核的工作单元,但举报记录仍然一条条存着并关联到处置单上。处置完成后遍历该单关联的全部举报记录逐个发通知——聚合只是改变了「审核怎么看」,没有丢失「谁举报了」这个信息。这一点是设计时就必须想到的,如果为了省事只在处置单上存一个「举报数」,就永远没法反馈了。实现上有两个细节:一是通知必须异步,一个处置单可能关联几千条举报,同步发会让审核员的提交请求超时(我们真的踩过,他以为失败又点了一次);二是不成立也要通知——只通知成立的更省事,但不通知的后果是用户以为没人看、于是反复举报,而反复举报的成本比通知的成本高得多,还会污染队列。这个账算下来,通知全部反而更省。
    Q:你怎么防止有人刷举报打击竞争对手? A:三层,而且都刻意避免「一刀切禁止」。第一层是限频:同一用户对同一对象一段时间内只能举报一次,这挡住了最简单的重复刷。第二层是加权而不是计数:队列排序用的是加权举报数,权重来自举报人画像(历史成立率),所以用低质量账号刷再多也不会显著提升优先级——这是让刷举报「失去意义」而不是「被禁止」。第三层是关联账号识别:同一对象短时间内来自注册时间接近、设备相近的账号的大量举报,标记为异常让审核员知道,而不是简单按举报数排前面为什么不直接禁止低成立率账号举报:因为成立率低不一定是恶意——可能是审核标准比较严,也可能这个用户对某类内容格外敏感。误伤的代价是真实违规内容漏审,比「被刷一点」严重得多,所以取舍偏向不误伤。另外所有降权和标记都留痕且可撤销,因为识别逻辑本身也会误判。
    Q:这三个模块(标签、封面、举报)看起来关系不大,有共同点吗? A:有两条。第一条是它们都「看起来是 CRUD,实际有一个明确的正确性约束」:标签的约束是关联键必须不可变、封面的约束是缓存必须和存储一致、举报的约束是聚合维度必须对齐处理者的工作单元。三个约束一旦搞错,影响面都是几十万条内容级别,而且错误是静默的——不报错、不白屏,只是数据「看起来少了/多了/重复了」。所以这类设计必须在写代码前想清楚,不能指望测出来,这是我最大的收获。第二条是它们都栽在「先查再写」的并发窗口或者「多方写同一份数据」上:举报并发建了两个处置单、算法重跑冲掉了人工标注、并发请求同时触发同一张图的生成。三个问题的解法也类似——用数据库约束兜底、用来源字段区分写入方、用锁收敛并发。这两条现在是我做任何数据模型时的固定检查项。

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

项目拆解 · 媒资周边支撑(后端 · 实习级)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据