多媒体素材处理与视频自动化分发平台 — 面试备战手册
零、简历精简版(可直接粘贴到简历)
多媒体素材处理与视频自动化分发平台
项目角色:核心开发
技术栈: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 影响,一旦视频处理服务慢、重启或者某条视频卡住,素材服务的扫描线程就会被拖住。
更稳的优化方向是改成异步任务模型:
- 素材服务扫描待处理素材,创建
video_process_task- 写任务表和投递 MQ 放到同一事务或本地消息表里
- 视频处理服务消费任务,异步处理视频
- 每条视频独立记录
WAITING、PROCESSING、SUCCESS、FAILED、RETRYING等状态- 处理完成后通过结果 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。但视频处理服务内部是否保留,要看任务粒度:
- 如果 MQ 一条消息就是一个视频任务,消费者独立处理,成功或失败直接更新任务状态,这种场景天然异步,基本不需要
CountDownLatch。- 如果 MQ 一条消息包含一批视频,处理服务内部仍然要把这一批视频丢进线程池并发处理,最后统一汇总结果,那么
CountDownLatch还能用。- 如果一个视频内部有多个依赖步骤,比如切割、压缩、润色、封面提取,且步骤之间有 DAG 依赖,那么
CompletableFuture、CompletionService或分阶段 MQ 流水线会比CountDownLatch更合适。所以异步化不是绝对不用
CountDownLatch,而是不要拿它跨服务等结果。它可以保留在视频处理服务内部,作为单机批处理的等待工具。
Q3-3:CountDownLatch 面试会追哪些点?
CountDownLatch是 JUC 里的同步工具,底层基于 AQS,内部的state就是计数值。await()在计数不为 0 时阻塞,countDown()把计数减 1,计数归零后唤醒等待线程。使用时有几个坑必须主动说清楚:
countDown()必须放在finally,否则子任务异常后主线程可能永久等待。await()必须设置超时时间,视频处理任务最怕单条卡死拖住整批。CountDownLatch只负责等待,不负责结果收集,处理结果要放到线程安全容器或任务表里。- 它是一次性的,计数归零后不能重置;如果要多轮复用,可以考虑
CyclicBarrier或Phaser。- 在线程池里用时要防止线程池太小、子任务互相等待造成死锁。
示例代码核心结构是:
面试口径可以这么说:
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:本地消息表的完整流程是什么?
核心思路是把消息投递和业务事务绑定,保证要么都成功、要么都失败。
具体流程:
- 素材落库的同时,往本地消息表插一条"待发送"的记录,这两步在同一个本地事务里
- 事务提交成功后,一个定时任务会扫描本地消息表,把"待发送"的消息投递到 RabbitMQ
- 投递成功后,更新本地消息表状态为"已发送"
- 下游消费端消费成功后,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。
不要在浏览列表时移除,因为浏览只是候选展示,用户可能只是看一眼;也不要等发布成功后才移除,因为这期间它还会被反复推荐出来,造成大量无效候选。
推荐流程:
- 用户浏览视频列表:
SRANDMEMBER随机展示候选,不移除 Redis ID- 用户点击使用:MySQL 条件更新
AVAILABLE -> RESERVED- DB 事务提交成功后:
SREM video:available:pool {videoId}- 用户确认发布:
RESERVED -> PUBLISHING,投递 MQ- 发布成功:
PUBLISHED,不再回池- 预占超时或发布失败且视频可复用:
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",也解决不了大面积漂移。
更完整的方案是四件套:
- 本地消息表同步 Redis,
SREM/SADD失败可重试- 读时回查 MySQL 状态,过滤脏 ID,顺手异步清理
- 库存水位监控,有效候选不足时自动补池
- 定时分片对账,按租户、活动、标签重建 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:发布流程为什么采用异步?
因为活动期峰值高,第三方接口又不可控。如果同步调用第三方接口,请求线程会一直等外部响应,接口一慢就会把应用线程池拖住。异步发布可以把用户请求和第三方发布解耦,用户侧快速返回"发布中",后端慢慢消化任务。
更稳的流程是:
- 浏览视频列表:
SRANDMEMBER随机展示候选,不占用- 用户点击使用:数据库条件更新
AVAILABLE -> RESERVED,设置预占过期时间- 如果预占失败:系统自动换一个同类视频重试
- 用户确认发布:数据库条件更新
RESERVED -> PUBLISHING- 创建发布任务,投递 MQ,立即返回"发布中"
- 消费者异步调用第三方发布接口
- 成功后改成
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。第三方接口最恶心的地方是:超时不等于失败,失败也可能已经成功了一半。我会按失败类型处理:
- 明确成功:更新视频为
PUBLISHED,记录第三方发布 ID,不再回 Redis 可用池。- 明确业务失败:比如参数非法、素材违规、账号无权限,更新发布任务为
PUBLISH_FAILED,记录失败原因。视频是否释放回AVAILABLE要看原因,如果视频本身没问题可以释放;如果素材违规或不可用,就不能释放。- 网络超时、连接断开、第三方 5xx:不能立刻判失败,先标记为
PUBLISH_UNKNOWN或WAIT_RETRY,优先调用第三方查询接口确认结果。- 重试超过上限:进入
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:你觉得这个系统最大的瓶颈在哪里?
我认为有五个方向可以优化:
- 视频合成的稳定性:PyAutoGUI 控制 Windows 桌面的方案成功率约 95%,主要是桌面环境不可控(弹窗、窗口失去焦点等)。后续可以考虑引入 FFmpeg 做离线合成,减少对环境状态的依赖
- 成品视频拉取的随机性:虽然用了 Redis ID 池优化了
ORDER BY RAND(),但如果视频被大量消费后没有补充,池子会越来越空。后续可以加一个"库存水位"告警,自动从成品库补充新视频到 Redis- 发布任务补偿能力:第三方接口可能超时、抖动或返回结果不明确,异步发布链路需要继续完善短预占回收、任务重试、幂等号、状态机和结果查询补偿能力。
- 视频处理链路耦合:素材服务同步等待视频处理服务返回,容易被重任务拖住。后续可以通过任务表 + MQ + 结果回调,把跨服务同步等待改成异步处理。
- Redis 候选池漂移:Redis ID 池和 MySQL 状态大面积不一致时会影响候选命中率,需要本地消息表同步、读时修复、库存补池和定时对账兜底。
Q17:如果让你重构这个系统,你会怎么规划?
如果资源允许,我会考虑:
- 把 Windows 桌面自动化替换成 FFmpeg + 视频处理微服务,容器化部署,提高合成成功率和扩展性
- 把素材服务同步等待视频处理服务的模式,改成任务表 + MQ 驱动的异步处理模型;视频处理服务内部可以继续用线程池并发处理,或者进一步拆成单视频任务独立消费
- 成品视频池引入"推荐算法",不是简单随机分配,而是根据用户画像、酒店类型等做智能匹配,提升发布效果
- 发布链路继续强化异步任务编排:用户点击使用后先落库
RESERVED短预占,确认发布后改成PUBLISHING并投递 MQ,消费者调用第三方接口,最终通过PUBLISHED、FAILED、待补偿等状态沉淀结果。- 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:你觉得这个系统最大的瓶颈在哪里?
- 视频合成的稳定性:PyAutoGUI 方案 95% 成功率仍有提升空间,桌面环境不可控是主要痛点。后续可考虑 FFmpeg 离线合成方案
- 成品视频库存管理:Redis ID 池在大量消费后可能变空,需要库存水位告警 + 自动补货机制
- 队列积压风险:活动期突发流量可能导致线程池队列打满,需要动态扩容机制
- 发布链路补偿能力:第三方发布接口不稳定时,需要短预占回收、异步发布任务、幂等请求号、状态机和结果查询补偿,避免同步调用拖垮请求链路
- 素材处理链路解耦:素材服务不应同步等待视频处理服务,后续可用任务表、MQ、结果事件和状态机改造成异步处理链路
- 缓存一致性治理: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 容量)。