跳到正文
Kale的数字花园
目录 文章
/23 分钟/9 阅读/0 评论

多媒体素材处理与视频自动化分发平台 — 面试备战手册


零、简历精简版(可直接粘贴到简历)

多媒体素材处理与视频自动化分发平台

项目角色:核心开发
技术栈:Spring Boot、MySQL、Redis、Redisson、RabbitMQ、阿里云 OSS、Python(PyAutoGUI)
项目描述:面向线下酒店扫码发视频活动的 B2B2C 视频 SaaS 平台,支持素材异步处理、自动化合成、多平台分发。日均上传 500+,素材库千万级,活动期峰值 2000 QPS。
核心工作与产出:

  • 处理耗时降低 67%:视频处理服务基于线程池 + CountDownLatch 实现批量视频并发处理,对每条视频独立执行切割、压缩等操作并汇总结果,单条 100M 视频处理从 60s 降至 20s
  • 消息零丢失:通过 RabbitMQ + 本地消息表保证"素材落库 → 租户空间扣减 → MQ 通知"的最终一致性
  • 2000 QPS 高并发防护:设计"短预占 + 数据库状态机 + MQ 异步发布"方案,基于 Redisson 降低瞬时并发冲突,通过 AVAILABLE -> RESERVED -> PUBLISHING -> PUBLISHED 状态流转防止重复发布
  • 替代 ORDER BY RAND():用 Redis SRANDMEMBER 随机取样候选视频,结合 MySQL 主键查询和数据库条件更新实现高效拉取与短预占,千万级数据毫秒级响应
  • 前端直传 OSS:设计签名直传方案,上传流量不经后端,服务器带宽成本显著降低

一、项目口述(面试自我介绍时用)

"这个项目主要服务线下酒店的扫码发视频活动,所以流量有个特点:平时很平稳,但做活动的时候并发会瞬间飙到 2000 QPS。

先说素材处理。每天大概有 500+ 的高质量视频上传进来,素材库总量是千万级。素材服务通过定时任务扫描待处理素材,拿到视频 URL 后请求本地机房的视频处理服务。处理服务内部会把一批视频放进线程池并发执行,每条视频独立做切割、压缩等操作,再用 CountDownLatch 按完成数量做汇总。串行处理一条 100M 视频大概 1 分钟,并发优化后降到了 20 秒左右,提升了大概 3 倍。不过这个时间还是受 CPU/GPU 和磁盘 IO 影响,所以线程池队列和超时控制不能放飞自我。

再说 C 端发布。活动期峰值能到 2000 QPS,但日活其实只有几百——说明用户在活动期间的操作非常密集。这里的问题是多个用户可能同时选中同一个视频去发布,而第三方平台不支持重复提交。我当时做的是"浏览不占用、点击短预占、确认异步发布":浏览列表只用 Redis 随机取样展示候选,不占用库存;用户点击使用时,先做单视频粒度的短预占,把视频从 AVAILABLE 改成 RESERVED 并设置过期时间;确认发布后再改成 PUBLISHING,创建发布任务并投递 MQ,接口直接返回"发布中"。后续由消费者调用第三方平台,成功后改成 PUBLISHED,失败则进入重试、查询确认或补偿流程。

如果要说还有什么可以优化的,我觉得有两个方向:

  • MySQL 随机拉取视频列表,如果数据量继续增长,ORDER BY RAND() 可能会成为瓶颈,后续可以改成 Redis 成品 ID 池方案:展示用 SRANDMEMBER 随机取样,真正发布靠数据库 RESERVED 短预占和超时回收兜底
  • 视频合成的 95% 成功率还有提升空间,主要是 PyAutoGUI 控制 Windows 桌面的稳定性问题,后续可以考虑引入 FFmpeg 做离线合成,减少对环境状态的依赖。"
flowchart LR
    A["酒店活动用户"] --> B["扫码进入视频活动"]
    B --> C["Redis 随机取候选视频"]
    C --> D["MySQL 校验状态"]
    D --> E["点击使用短预占 RESERVED"]
    E --> F["确认发布进入 PUBLISHING"]
    F --> G["RabbitMQ 发布任务"]
    G --> H["消费者调用第三方平台"]
    H --> I{"发布结果"}
    I -->|"成功"| J["PUBLISHED沉淀发布结果"]
    I -->|"失败 / 未知"| K["重试 / 查询确认 / 补偿"]

    M["素材上传"] --> N["OSS 存储"]
    N --> O["视频处理服务切割 / 压缩 / 合成"]
    O --> P["成品视频入库"]
    P --> Q["写入 Redis 可用 ID 池"]
    Q --> C

二、面试官可能会问的问题 & 参考回答

模块一:线程池设计

Q1:你的线程池参数是怎么定的?为什么核心线程数是 CPU 核心数的一半?

因为我们虽然是 CPU 密集型任务,但处理过程中涉及 GPU 算力(压缩、润色都需要 GPU 参与)。我通过压测发现,当线程数超过 CPU 核心数的一半时,GPU 利用率就已经饱和了,再增加线程只会增加 CPU 上下文切换的开销,不会带来吞吐量提升。所以核心线程数就定在了 CPU 核心数的一半。

最大线程数设为 CPU 核心数的两倍,搭配有界的 ArrayBlockingQueue。当突发流量打满队列后,线程池可以临时扩容来快速消化峰值。超过这个上限就触发拒绝策略。

Q2:为什么用有界队列而不是默认队列?

Java 线程池默认的 LinkedBlockingQueue 是无界的。无界队列有一个致命问题:线程池的调度逻辑是先往队列里塞,队列满了才创建非核心线程。如果队列无界,永远塞不满,那最大线程数的配置就形同虚设,线程池实际上永远只有核心线程数在工作。

所以我改成了有界的 ArrayBlockingQueue,容量根据业务峰值估算。这样队列打满后,线程池才会创建非核心线程,最大线程数才能真正生效。

Q3:拒绝策略为什么选 CallerRunsPolicy?

CallerRunsPolicy 的作用是:当队列和线程池都满了,新任务由调用线程自己来执行。这样做的好处是起到了反压效果——调用线程被阻塞,上游不会继续往线程池里塞任务,防止任务无限堆积导致 OOM。对于我们这个场景,宁可处理慢一点,也不能丢任务。

Q3-1:视频素材处理链路现在是怎么做的?有什么优化空间?

当前链路是素材服务的定时任务扫描素材库,捞一批待处理视频记录,拿到视频 URL 后请求本地机房部署的视频处理服务。视频处理服务内部使用线程池并发处理这一批视频,批量做切割、压缩等操作,再用 CountDownLatch 等待整批任务完成,最后把处理后的素材 URL 返回给素材服务落库。

这套方案能提升单批处理吞吐,但最大的问题是素材服务在同步等待视频处理服务返回。视频处理属于重任务,耗时受文件大小、网络、CPU/GPU、磁盘 IO 影响,一旦视频处理服务慢、重启或者某条视频卡住,素材服务的扫描线程就会被拖住。

更稳的优化方向是改成异步任务模型:

  1. 素材服务扫描待处理素材,创建 video_process_task
  2. 写任务表和投递 MQ 放到同一事务或本地消息表里
  3. 视频处理服务消费任务,异步处理视频
  4. 每条视频独立记录 WAITING、PROCESSING、SUCCESS、FAILED、RETRYING 等状态
  5. 处理完成后通过结果 MQ 或回调通知素材服务落库处理后 URL

这样素材服务不需要同步等待视频处理服务,处理服务失败或变慢也不会拖垮扫描任务。同步调用这坨东西能跑,但链路耦合太紧,迟早把定时任务憋成老年机。

flowchart TB
    A["当前方案素材服务定时扫描"] --> B["同步请求视频处理服务"]
    B --> C["处理服务线程池并发处理"]
    C --> D["CountDownLatch 等待整批完成"]
    D --> E["返回处理后 URL"]
    E --> F["素材服务落库"]
    B -.-> G["风险:处理慢或卡住拖住扫描线程"]

    H["优化方案异步任务模型"] --> I["创建 video_process_task"]
    I --> J["本地消息表 / MQ 投递任务"]
    J --> K["视频处理服务消费任务"]
    K --> L["单视频独立状态流转"]
    L --> M["结果 MQ / 回调通知素材服务"]
    M --> N["落库处理后 URL"]

Q3-2:如果采用异步任务模型,CountDownLatch 还用不用?

不一定。异步化以后,素材服务侧不再同步等待视频处理服务返回结果,所以素材服务侧基本不需要 CountDownLatch。

但视频处理服务内部是否保留,要看任务粒度:

  1. 如果 MQ 一条消息就是一个视频任务,消费者独立处理,成功或失败直接更新任务状态,这种场景天然异步,基本不需要 CountDownLatch。
  2. 如果 MQ 一条消息包含一批视频,处理服务内部仍然要把这一批视频丢进线程池并发处理,最后统一汇总结果,那么 CountDownLatch 还能用。
  3. 如果一个视频内部有多个依赖步骤,比如切割、压缩、润色、封面提取,且步骤之间有 DAG 依赖,那么 CompletableFuture、CompletionService 或分阶段 MQ 流水线会比 CountDownLatch 更合适。

所以异步化不是绝对不用 CountDownLatch,而是不要拿它跨服务等结果。它可以保留在视频处理服务内部,作为单机批处理的等待工具。

Q3-3:CountDownLatch 面试会追哪些点?

CountDownLatch 是 JUC 里的同步工具,底层基于 AQS,内部的 state 就是计数值。await() 在计数不为 0 时阻塞,countDown() 把计数减 1,计数归零后唤醒等待线程。

使用时有几个坑必须主动说清楚:

  1. countDown() 必须放在 finally,否则子任务异常后主线程可能永久等待。
  2. await() 必须设置超时时间,视频处理任务最怕单条卡死拖住整批。
  3. CountDownLatch 只负责等待,不负责结果收集,处理结果要放到线程安全容器或任务表里。
  4. 它是一次性的,计数归零后不能重置;如果要多轮复用,可以考虑 CyclicBarrier 或 Phaser。
  5. 在线程池里用时要防止线程池太小、子任务互相等待造成死锁。

示例代码核心结构是:

面试口径可以这么说:CountDownLatch 适合主线程等待多个子任务完成,我们项目里用于视频处理服务内部批量并发处理。后续如果链路异步化,它不再承担跨服务等待职责,只在服务内部短范围使用,或者用 CompletableFuture / CompletionService 替代。

CountDownLatch latch = new CountDownLatch(tasks.size());
ConcurrentHashMap<Long, ProcessResult> resultMap = new ConcurrentHashMap<>();

for (VideoTask task : tasks) {
    executor.submit(() -> {
        try {
            resultMap.put(task.getId(), process(task));
        } catch (Exception e) {
            resultMap.put(task.getId(), ProcessResult.fail(e.getMessage()));
        } finally {
            latch.countDown();
        }
    });
}

boolean finished = latch.await(30, TimeUnit.MINUTES);

模块二:RabbitMQ + 本地消息表(分布式事务)

Q4:本地消息表的完整流程是什么?

核心思路是把消息投递和业务事务绑定,保证要么都成功、要么都失败。

具体流程:

  1. 素材落库的同时,往本地消息表插一条"待发送"的记录,这两步在同一个本地事务里
  2. 事务提交成功后,一个定时任务会扫描本地消息表,把"待发送"的消息投递到 RabbitMQ
  3. 投递成功后,更新本地消息表状态为"已发送"
  4. 下游消费端消费成功后,ACK 是回给 RabbitMQ 的确认,不会天然回写生产者侧本地消息表。如果生产者侧需要感知业务最终完成,需要通过下游回调、反向事件或状态同步机制更新业务状态。
sequenceDiagram
    participant App as 素材服务
    participant DB as MySQL业务表 + 本地消息表
    participant Scan as 消息扫描任务
    participant MQ as RabbitMQ
    participant Consumer as 下游消费者

    App->>DB: 同一事务内写素材数据和待发送消息
    DB-->>App: 事务提交成功
    Scan->>DB: 扫描待发送消息
    Scan->>MQ: 投递消息
    alt 投递成功
        Scan->>DB: 标记消息为已发送
        MQ->>Consumer: 推送消息
        Consumer-->>MQ: 消费成功后 ACK
    else 投递失败
        Scan->>DB: 保持待发送,等待下次重试
    end

Q5:消息重复发送怎么办?消费端怎么保证幂等?

本地消息表的定时任务可能投递成功但状态更新失败,导致重试时同一条消息发了两次。所以消费端必须做幂等处理。我们的做法是:每条消息带一个全局唯一的 messageId,消费端通过 Redis SETNX 或者数据库唯一索引去重。

Q6:消息发送成功了,但业务事务回滚了怎么办?

不会。因为本地消息表和业务数据在同一个事务里,如果业务回滚,本地消息表的 insert 也会回滚,这条消息根本不存在,自然不会发送。

Q7:消费端消费失败怎么处理?

消费端处理失败时不 ACK,消息会重回队列。同时我们配了死信队列,重试超过 N 次后进入死信队列,人工介入处理。生产者侧本地消息表主要记录投递状态;如果要记录下游处理失败,需要消费端通过回调、反向事件或统一任务状态表同步。

Q8:本地消息表的性能问题?表数据量会不会越来越大?

本地消息表只存"待发送"状态的消息,发送成功后就标记为已完成。我们会定期清理或归档历史数据,避免表膨胀。同时定时任务的扫描频率可以根据负载动态调整。


模块三:MySQL 随机查询优化

Q9:你是怎么优化 ORDER BY RAND() 的?

我们用 Redis 维护了一个"未发布成品视频 ID 池"。视频合成完成后,把 ID 写入 Redis;用户请求拉取时,不再让 MySQL 做 ORDER BY RAND() 全表随机排序,而是先从 Redis 取一批 ID,再回 MySQL 走主键查询并校验状态,效率会稳定很多。

这里要区分两个场景:如果只是展示候选视频列表,用 SRANDMEMBER 随机取样就够了,它不会删除元素,也不会提前占用库存;如果用户点击使用或发布,就回到 MySQL 做条件更新,把视频从 AVAILABLE 改成 RESERVED,并写入 occupy_user_id 和 occupy_expire_time。这样既避免 ORDER BY RAND(),又不会因为用户只是看了一眼视频就把库存锁死。

如果条件更新失败,说明视频已经被别人预占或发布,系统会自动从同类候选池里再取一个可用视频尝试预占,尽量不把"被抢了"这种破体验直接甩给用户。

flowchart LR
    A["MySQL 成品视频表事实源"] --> B["初始化 / 补池任务"]
    B --> C["Redis 可用视频 ID 池"]
    C --> D["SRANDMEMBER随机取候选 ID"]
    D --> E["MySQL 主键查询校验状态和标签"]
    E --> F{"是否 AVAILABLE"}
    F -->|"否"| G["过滤脏 ID异步 SREM 清理"]
    G --> D
    F -->|"是"| H["展示给用户"]
    H --> I["点击使用"]
    I --> J["MySQL 条件更新AVAILABLE -> RESERVED"]
    J --> K{"预占是否成功"}
    K -->|"成功"| L["事务后 SREM Redis ID"]
    K -->|"失败"| D

Q10:Redis 里千万级 ID,内存够吗?

千万个 Long 类型的 ID,纯数据大约 80MB。Redis 的 Set 结构有一些额外开销,但也就几百 MB 级别。我们的 Redis 实例内存是 16G,占比很小,没有压力。

Q11:Redis 冷启动怎么处理?

冷启动的时候我们有一个初始化脚本,从 MySQL 查询出所有"未发布"状态的成品视频 ID,批量写入 Redis。线上用的是 Pipeline 批量写入,千万级数据几分钟就能刷完。

Q12:多个用户同时从 Redis 拉 ID,会不会拉到同一个?

要看用哪个命令。如果用 SRANDMEMBER,它本身是原子操作,但语义只是"随机取样",不会删除元素,所以不同请求之间仍然可能拿到同一个 ID。如果用 SPOP,它也是原子操作,并且会把元素从 Set 里移除,多个用户并发占用时就不会弹到同一个 ID。

但用户选择发布这个链路,我不会直接依赖 SPOP 作为最终占用手段。更稳的是 Redis 只负责给候选 ID,最终能不能占用由 MySQL 条件更新决定。这样即使多个用户看到同一个候选视频,也只有一个用户能把它从 AVAILABLE 改成 RESERVED。

SPOP 更适合系统自动分配且"取出即占用"的场景,但它必须配合预占记录、过期时间和回收任务,否则用户拿到视频后不发布,ID 就会被弹走但业务没完成,这锅 Redis 可不背。

Q12-1:SRANDMEMBER 是原子操作吗?

是,SRANDMEMBER key [count] 这条 Redis 命令本身是原子执行的,执行过程中不会被其他命令插队。但它只是原子读,不是原子占用,因为它不会删除元素。

所以展示候选列表可以用 SRANDMEMBER;用户点击使用时,必须通过数据库条件更新做短预占;如果是系统自动分配,也可以用 SPOP 或 Lua 脚本把"取出 ID + 写入预占记录 + 设置过期时间"做成一个原子组合操作。千万别把 SRANDMEMBER 当锁用,不然并发一上来,同一个视频被多个人看到很正常。

Q12-2:什么时候移除 Redis 成品 ID 池里的视频 ID 比较合适?

最合适的时机是:数据库短预占成功后,再移除 Redis ID 池里的 videoId。

不要在浏览列表时移除,因为浏览只是候选展示,用户可能只是看一眼;也不要等发布成功后才移除,因为这期间它还会被反复推荐出来,造成大量无效候选。

推荐流程:

  1. 用户浏览视频列表:SRANDMEMBER 随机展示候选,不移除 Redis ID
  2. 用户点击使用:MySQL 条件更新 AVAILABLE -> RESERVED
  3. DB 事务提交成功后:SREM video:available:pool {videoId}
  4. 用户确认发布:RESERVED -> PUBLISHING,投递 MQ
  5. 发布成功:PUBLISHED,不再回池
  6. 预占超时或发布失败且视频可复用:SADD 回 Redis 可用池

千万别先删 Redis 再改数据库。否则 DB 更新失败或者事务回滚,Redis 里 ID 没了,库存就被自己慢慢删空,这锅比慢 SQL 还阴。

Q12-3:Redis ID 池和数据库状态的一致性怎么保证?

这里不能说强一致。更准确的说法是:MySQL 是事实源,Redis 只是候选池;通过事务后更新缓存、失败重试、读时校验和定时对账保证最终一致。

预占时:

如果 DB 成功但 Redis 删除失败,最多是 Redis 里残留了一个脏 ID。下次用户拿到这个 ID 时,还要回查 MySQL 状态,不是 AVAILABLE 就过滤掉,并异步 SREM 清理。

释放时:

如果 SADD 失败,视频只是暂时不会被推荐出来,不会导致重复发布。后续靠重试或对账任务补回。

更稳的做法是用本地消息表做缓存同步:

所以这里的底线是:Redis 可以短暂不准,MySQL 不能乱。用户真正能不能占用视频,只看数据库条件更新。

1. 从 Redis 随机拿候选 ID
2. 回 MySQL 条件更新 AVAILABLE -> RESERVED
3. DB 提交成功后,再 SREM Redis ID 池
1. MySQL 把 RESERVED / FAILED 改回 AVAILABLE
2. DB 提交成功后,SADD 回 Redis ID 池
DB 状态更新 + 写 cache_sync_event 在同一个事务
事务提交后异步消费 cache_sync_event
根据事件执行 SREM / SADD
失败就重试,超过次数告警
flowchart TB
    A["MySQL 状态变更"] --> B["同事务写 cache_sync_event"]
    B --> C["事务提交"]
    C --> D["异步消费缓存同步事件"]
    D --> E{"事件类型"}
    E -->|"视频不可用"| F["SREM Redis ID 池"]
    E -->|"视频重新可用"| G["SADD Redis ID 池"]
    F --> H{"是否成功"}
    G --> H
    H -->|"成功"| I["事件标记完成"]
    H -->|"失败"| J["重试 / 告警"]
    K["读时回查 MySQL"] --> L["过滤脏 ID"]
    L --> M["异步清理 Redis 残留"]
    N["定时分片对账"] --> O["重建或修复 Redis Set"]

Q12-4:如果 Redis 和数据库大量不一致,会不会影响用户体验?延迟双删能解决吗?

会影响体验。比如 Redis 里残留大量已经 RESERVED、PUBLISHING、PUBLISHED 的视频 ID,用户会频繁拿到不可用候选,系统虽然能靠 MySQL 状态过滤掉,但链路会变成:

正确性不会炸,但接口耗时会变长,严重时用户会看到"库存紧张"。这就是典型的缓存不一致不伤命,但伤体验。

延迟双删可以作为补刀,但不能当主方案。因为这里 Redis 不是普通对象缓存,而是候选 ID Set。延迟双删只能缓解"DB 已不可用但 Redis 还残留 ID"的问题,解决不了"DB 可用但 Redis 缺 ID",也解决不了大面积漂移。

更完整的方案是四件套:

  1. 本地消息表同步 Redis,SREM / SADD 失败可重试
  2. 读时回查 MySQL 状态,过滤脏 ID,顺手异步清理
  3. 库存水位监控,有效候选不足时自动补池
  4. 定时分片对账,按租户、活动、标签重建 Redis Set

面试可以说:延迟双删能补刀,不能扛旗;主力是事件同步、读时校验、库存补池和定时对账。

随机拿候选 -> 回查不可用 -> 自动换片 -> 再查 -> 再换

模块四:Redisson 分布式锁

Q13:用户选择视频发布时,怎么保证视频不被别人抢占?

我们采用"浏览不占用、点击短预占、确认异步发布"的方案。用户浏览视频列表时,只用 Redis 随机取样候选视频,不改变视频状态;用户点击"使用/发布"时,才进入短预占逻辑。

预占时先按 videoId 获取单视频粒度的 Redisson 锁,锁只保护短临界区。拿到锁后执行数据库条件更新,只有 AVAILABLE 状态能改成 RESERVED,同时写入占用人和过期时间。真正的视频归属以数据库状态为准,不靠 Redis 锁长期续命。

更新成功才算预占成功;更新失败说明视频已经被别人预占或发布,系统会自动从同类候选池里再取一个视频继续尝试,尽量不让用户感知"抢占失败"。

UPDATE video
SET status = 'RESERVED',
 occupy_user_id = ?,
 occupy_expire_time = ?
WHERE id = ?
 AND status = 'AVAILABLE';
stateDiagram-v2
    [*] --> AVAILABLE
    AVAILABLE --> RESERVED: 点击使用 / 条件更新成功
    RESERVED --> AVAILABLE: 预占超时 / 放弃使用
    RESERVED --> PUBLISHING: 确认发布 / 创建任务
    PUBLISHING --> PUBLISHED: 第三方明确成功
    PUBLISHING --> WAIT_RETRY: 超时 / 5xx / 结果未知
    WAIT_RETRY --> PUBLISHING: 退避重试
    WAIT_RETRY --> WAIT_COMPENSATE: 超过重试上限
    PUBLISHING --> PUBLISH_FAILED: 明确业务失败
    PUBLISH_FAILED --> AVAILABLE: 可复用素材释放
    PUBLISH_FAILED --> [*]: 素材不可用不回池
    PUBLISHED --> [*]

Q14:锁冲突的时候为什么不做阻塞,而是直接提示用户?

活动期并发高(峰值 2000 QPS),如果做阻塞等待,线程资源会被大量占用,反而影响整体系统稳定性。所以锁冲突时不等待,数据库条件更新失败也不硬等。

用户体验上更好的做法不是直接弹一句"被抢了",而是系统自动换片:从同类型、同标签或同活动的视频池里重新取候选,再尝试短预占。只有连续重试都失败,才提示用户"当前视频库存紧张,请稍后重试"。这么做用户感知更顺,系统也不被阻塞线程拖死。

Q15:如果第三方发布接口耗时很长,锁会不会影响发布结果?

不会,因为第三方发布接口不在用户请求线程里同步调用,也不在 Redisson 锁的临界区里执行。锁只覆盖短预占或确认发布前的状态更新动作,状态更新成功后就释放锁,后面的第三方发布由 MQ 消费者异步执行。

用户确认发布时,会把视频从 RESERVED 改成 PUBLISHING,创建发布任务并投递 MQ,接口直接返回"发布中"。

第三方接口慢、超时或者返回结果不明确,都交给发布任务状态机处理。消费者按规则重试;如果接口超时,会先查询第三方结果,确认失败后再重试;超过重试次数进入死信或待补偿状态。这样外部接口不会拖住前端请求线程,也不会让 Redis 锁承担业务状态管理的职责。

UPDATE video
SET status = 'PUBLISHING',
 publish_task_id = ?
WHERE id = ?
 AND occupy_user_id = ?
 AND status = 'RESERVED'
 AND occupy_expire_time > NOW();

Q15-1:异步发布中,视频会不会被其他人抢占?

正常不会。因为用户点击使用后,视频状态已经通过数据库条件更新从 AVAILABLE 改成 RESERVED。其他用户就算后续再拿到同一个 videoId 的锁,也会因为数据库状态不是 AVAILABLE 而更新失败。

如果用户只是预占但没有发布,不能让这个视频一直挂死,所以 RESERVED 必须有过期时间。定时任务会把过期未发布的视频释放回可用池:

发布成功后改成 PUBLISHED;发布失败则按业务规则改成 FAILED 或释放回 AVAILABLE。这套状态机才是最终防重,Redisson 只是减小并发冲突的辅助工具。

UPDATE video
SET status = 'AVAILABLE',
 occupy_user_id = NULL,
 occupy_expire_time = NULL
WHERE status = 'RESERVED'
 AND occupy_expire_time < NOW();

Q15-2:为什么不长期持有 Redis 锁直到发布完成?

因为第三方平台发布耗时不可控,可能慢、超时、抖动,甚至出现"实际成功但响应超时"的情况。如果一直持有 Redis 锁等第三方结果,请求链路和锁生命周期都会被外部系统拖住,这设计就跟把刹车线绑在别人车上差不多,迟早出事。

所以我们把锁控制在短临界区,只负责避免并发预占和重复创建任务;发布过程交给数据库状态机和 MQ 异步任务。最终防重靠 AVAILABLE -> RESERVED -> PUBLISHING -> PUBLISHED 的条件更新、任务幂等号和第三方结果查询,不靠 Redis 锁一直续命。

Q15-3:发布流程为什么采用异步?

因为活动期峰值高,第三方接口又不可控。如果同步调用第三方接口,请求线程会一直等外部响应,接口一慢就会把应用线程池拖住。异步发布可以把用户请求和第三方发布解耦,用户侧快速返回"发布中",后端慢慢消化任务。

更稳的流程是:

  1. 浏览视频列表:SRANDMEMBER 随机展示候选,不占用
  2. 用户点击使用:数据库条件更新 AVAILABLE -> RESERVED,设置预占过期时间
  3. 如果预占失败:系统自动换一个同类视频重试
  4. 用户确认发布:数据库条件更新 RESERVED -> PUBLISHING
  5. 创建发布任务,投递 MQ,立即返回"发布中"
  6. 消费者异步调用第三方发布接口
  7. 成功后改成 PUBLISHED,失败后进入重试、查询确认或死信补偿

异步以后不能只靠 Redis 锁防重。因为 MQ 可能重复投递,消费者可能重试,真正的防重要靠数据库状态机、任务幂等号和第三方结果查询。如果第三方支持幂等参数,就传 publishRequestNo;如果不支持,就要本地记录请求状态,超时时先查询第三方结果,确认失败后再重试。

sequenceDiagram
    participant User as 用户
    participant App as 发布服务
    participant DB as MySQL状态机
    participant MQ as RabbitMQ
    participant Worker as 发布消费者
    participant Third as 第三方平台

    User->>App: 确认发布
    App->>DB: RESERVED -> PUBLISHING,创建 publish_task
    App->>MQ: 投递发布消息
    App-->>User: 立即返回发布中
    MQ->>Worker: 消费发布任务
    Worker->>DB: 校验 requestNo 和任务状态
    Worker->>Third: 调用发布接口
    alt 明确成功
        Worker->>DB: 更新 PUBLISHED,记录第三方 ID
    else 明确失败
        Worker->>DB: 更新 PUBLISH_FAILED,记录原因
    else 超时或结果未知
        Worker->>Third: 查询第三方发布结果
        Worker->>DB: WAIT_RETRY 或 WAIT_COMPENSATE
    end

Q15-4:异步调用第三方发布接口失败怎么办?

不能一看到调用失败就直接把视频释放回 AVAILABLE。第三方接口最恶心的地方是:超时不等于失败,失败也可能已经成功了一半。

我会按失败类型处理:

  1. 明确成功:更新视频为 PUBLISHED,记录第三方发布 ID,不再回 Redis 可用池。
  2. 明确业务失败:比如参数非法、素材违规、账号无权限,更新发布任务为 PUBLISH_FAILED,记录失败原因。视频是否释放回 AVAILABLE 要看原因,如果视频本身没问题可以释放;如果素材违规或不可用,就不能释放。
  3. 网络超时、连接断开、第三方 5xx:不能立刻判失败,先标记为 PUBLISH_UNKNOWN 或 WAIT_RETRY,优先调用第三方查询接口确认结果。
  4. 重试超过上限:进入 WAIT_COMPENSATE 或死信队列,由补偿任务或人工处理。

发布任务要有幂等号,比如 publishRequestNo = videoId + userId + activityId,消费者每次执行前先查任务状态。如果任务已经是 PUBLISHED,直接 ACK,不再调用第三方,避免 MQ 重投导致重复发布。

状态更新也要用条件更新:

重试要用退避策略,比如 30 秒、2 分钟、10 分钟、30 分钟逐步拉开,别第三方刚抖一下,我们这边就跟开机关枪一样把人家接口打穿。

面试口径可以这么说:异步发布失败时先区分明确失败和结果未知。明确失败再决定是否释放视频;结果未知不能释放,要先查第三方发布结果,查不到再退避重试。整个任务用 requestNo 做幂等,状态更新用条件更新控制,超过重试上限后进入死信或待补偿。

UPDATE publish_task
SET status = 'PUBLISHED',
    third_publish_id = ?,
    finish_time = NOW()
WHERE request_no = ?
  AND status IN ('PUBLISHING', 'WAIT_RETRY', 'PUBLISH_UNKNOWN');

模块五:架构与扩展(加分项)

Q16:你觉得这个系统最大的瓶颈在哪里?

我认为有五个方向可以优化:

  1. 视频合成的稳定性:PyAutoGUI 控制 Windows 桌面的方案成功率约 95%,主要是桌面环境不可控(弹窗、窗口失去焦点等)。后续可以考虑引入 FFmpeg 做离线合成,减少对环境状态的依赖
  2. 成品视频拉取的随机性:虽然用了 Redis ID 池优化了 ORDER BY RAND(),但如果视频被大量消费后没有补充,池子会越来越空。后续可以加一个"库存水位"告警,自动从成品库补充新视频到 Redis
  3. 发布任务补偿能力:第三方接口可能超时、抖动或返回结果不明确,异步发布链路需要继续完善短预占回收、任务重试、幂等号、状态机和结果查询补偿能力。
  4. 视频处理链路耦合:素材服务同步等待视频处理服务返回,容易被重任务拖住。后续可以通过任务表 + MQ + 结果回调,把跨服务同步等待改成异步处理。
  5. Redis 候选池漂移:Redis ID 池和 MySQL 状态大面积不一致时会影响候选命中率,需要本地消息表同步、读时修复、库存补池和定时对账兜底。

Q17:如果让你重构这个系统,你会怎么规划?

如果资源允许,我会考虑:

  1. 把 Windows 桌面自动化替换成 FFmpeg + 视频处理微服务,容器化部署,提高合成成功率和扩展性
  2. 把素材服务同步等待视频处理服务的模式,改成任务表 + MQ 驱动的异步处理模型;视频处理服务内部可以继续用线程池并发处理,或者进一步拆成单视频任务独立消费
  3. 成品视频池引入"推荐算法",不是简单随机分配,而是根据用户画像、酒店类型等做智能匹配,提升发布效果
  4. 发布链路继续强化异步任务编排:用户点击使用后先落库 RESERVED 短预占,确认发布后改成 PUBLISHING 并投递 MQ,消费者调用第三方接口,最终通过 PUBLISHED、FAILED、待补偿 等状态沉淀结果。
  5. Redis 候选池增加缓存同步事件、有效候选水位监控和分片对账任务,避免缓存漂移导致用户反复拿到不可用视频。
flowchart TB
    A["重构目标架构"] --> B["素材接入层前端直传 OSS"]
    A --> C["视频处理任务层任务表 + MQ"]
    A --> D["视频处理执行层FFmpeg / GPU Worker"]
    A --> E["成品视频候选池Redis Set + MySQL事实源"]
    A --> F["发布任务层状态机 + MQ + 幂等号"]
    A --> G["治理能力补偿 / 对账 / 水位监控"]

    B --> C
    C --> D
    D --> E
    E --> F
    F --> G
    G --> E
    G --> F

三、架构扩展与优化方向(加分项)

Qn:你觉得这个系统最大的瓶颈在哪里?

  1. 视频合成的稳定性:PyAutoGUI 方案 95% 成功率仍有提升空间,桌面环境不可控是主要痛点。后续可考虑 FFmpeg 离线合成方案
  2. 成品视频库存管理:Redis ID 池在大量消费后可能变空,需要库存水位告警 + 自动补货机制
  3. 队列积压风险:活动期突发流量可能导致线程池队列打满,需要动态扩容机制
  4. 发布链路补偿能力:第三方发布接口不稳定时,需要短预占回收、异步发布任务、幂等请求号、状态机和结果查询补偿,避免同步调用拖垮请求链路
  5. 素材处理链路解耦:素材服务不应同步等待视频处理服务,后续可用任务表、MQ、结果事件和状态机改造成异步处理链路
  6. 缓存一致性治理:Redis 候选池与 MySQL 状态不一致会影响体验,需要读时校验、事件同步、库存补池、定时对账,而不是只靠延迟双删

四、技术要点速查表

技术点 关键词 面试话术要点
线程池参数 核心数/2、有界队列、CallerRunsPolicy 压测确定、GPU饱和点、反压
CountDownLatch finally countDown、await 超时、结果收集 只适合服务内部批处理,不做跨服务同步等待
视频处理异步化 任务表、MQ、结果回调、状态机 素材服务不阻塞等待处理服务,单条任务独立成功失败
本地消息表 同事务、定时扫描、幂等 最终一致性、不依赖 MQ 事务消息
Redis ID 池 SRANDMEMBER、Pipeline、冷启动 展示随机取样、不提前占用、替代 ORDER BY RAND()
缓存一致性 事务后更新、读时校验、库存补池、定时对账 Redis 只是候选池,MySQL 状态才是事实源
Redisson 锁 一视频一锁、短临界区、快速失败 防瞬时并发、锁不做最终归属、数据库状态兜底
发布状态机 AVAILABLE、RESERVED、PUBLISHING、PUBLISHED、FAILED 短预占、自动换片、异步发布、超时回收、补偿查询
异步发布失败处理 requestNo、结果未知、退避重试、死信补偿 超时不等于失败,结果未知不能释放视频
OSS 直传 前端签名、带宽解耦 流量不经后端、降低成本

本文档由面试讨论整理,用于项目复盘与面试备战。标 XX 的地方需要根据线上实际情况补充具体数值(如 ArrayBlockingQueue 容量)。

相关阅读

27 分钟

质检模块业务全景与现状梳理

数据来源:7份技术方案文档 + 代码库实际代码扫描 质检模块从0到1经历了以下迭代,每一版都是前一版的改造和扩展: 材料问题、工艺问题、破损问题、二次工艺、印绣花工艺、规格问题、版型问题、外观问题、包装问题、功能问题、性能问题 Contro

面试#简历项目12 阅读 · 0 评论

评论需要登录。生产环境使用 GitHub 登录,本地可前往 登录页 使用开发登录。

还没有评论,来抢沙发。