Skip to content

08 · 面试官追问链实战(六条主链 + 一条架构演进链) ​

用法:每一条链都是一次"连环追问"。先盖住答案自己答一遍,再对照 🎤 口述 校准,最后看 🚨 翻车信号 自我体检。

所有答案都对应真实代码。刻意保留了几处"被追到边界"的位置,那些正是你要主动承认的地方。


链 1:上传链路 —— 从"秒传"追到"引用计数并发安全" ​

Q1.1 前端上传一个 2 GB 的视频,数据是怎么走的? ​

🎤 口述:

"数据不经过我的后端。前端先算整文件哈希,调 upload/init 拿到 upload_id 和一个对象 key;然后按 8 MB 切片,对每个分片单独向后端要一个预签名 PUT URL,拿到 URL 后直接 PUT 到 MinIO,6 个分片并发;全部传完把 PartNumber 和 ETag 列表发给 upload/complete,由后端调 MinIO 的 CompleteMultipartUpload 合并,最后在事务里建 video / file / source / transcode 四条记录,再投一条 Kafka 转码消息。所以 2 GB 流量走的是客户端到 MinIO 的直连,我的 API 只处理几百字节的 JSON 和签名。"

🔍 追问要点:预签名 URL 是 MinIO SDK 用 root 凭证按 SigV4 算出来的、带过期时间的 URL(GetUploadPartURL 里 MinioCorePublic.Presign,有效期 1 小时)。注意实现里用的是 Public 端点的 Core client,因为签名是给浏览器用的,必须签公网可达的地址。

Q1.2 为什么用预签名 URL 而不是后端中转?后端中转不是更好控制? ​

🎤 口述:

"三点:① 带宽和内存——2 GB 走 API 中转,Nginx/Go 进程要扛全部上行带宽,还要处理临时文件和超时,扩容成本远高于让对象存储接;② 可扩容性——直传后 API 是无状态的,加副本没有数据错乱问题;③ 成本——对象存储的入口带宽比应用服务器便宜。 代价是失去了"服务端逐字节校验"的能力,我接受这个代价,因为视频最终会被 FFmpeg 完整解码一遍,损坏文件在转码阶段就会失败,等于把校验推到了下游。"

🚨 翻车信号:如果你说"因为预签名更安全"——错,预签名不等于更安全,只是把写入权限临时、受限地授出去,主讲理由应该是带宽与解耦。

Q1.3 预签名 URL 有效期 1 小时,攻击者拿到了能干什么?怎么限制? ​

🎤 口述:

"它能做的只有一件事:往那一个 object key 的那一个 part number 上 PUT 数据,1 小时内有效,不能列桶、不能读别人的对象、不能改别的 key,因为 URL 里的签名绑定了 method、路径和 query 参数。 但这个能力仍然不小——用户可以拿到 URL 后不传,或者传垃圾数据。我的缓解是:① 上传是登录接口,URL 是绑定 upload_session:{user_id}:{hash} 的会话签发的;② 真正落库是在 complete 阶段,那一步要重新校验用户身份并建记录,不 complete 的分片就是 MinIO 里的孤儿分片,不会变成可播放视频;③ 更彻底的做法是给不同用户用不同的 STS 凭证限定前缀(我在播放侧就是这么做的),上传侧目前还是共享 root 凭证签 URL,是一个可以改进的点。"

⚠️ 这三条里第 ③ 条是真实缺口(上传侧用 minioAdmin root 凭证签 URL),主动说出来比被问出来好。

Q1.4 秒传怎么实现的?有没有被玩坏的可能? ​

🎤 口述:

"upload/init 里先按 hash + status=active 查 files 表。命中就认为物理文件已存在,直接在一个事务里做三件事:把那个 file 的 ref_count 加一、建一条新的 video 记录(状态是 processing)、建一条新的 transcode 任务,复用同一个 FileID;然后照常投 Kafka 转码消息,因为这个用户的视频需要自己的 DASH 产物。整个过程用户不需要传一个字节。 风险有三个:① 哈希碰撞——MD5 理论上可构造碰撞,攻击者可以上传一个哈希相同但内容不同的文件,让后续用户拿到错误内容。缓解做法是秒传前做一次抽样字节比对,或者用大小 + hash 双重校验、甚至换 SHA-256;② 秒传被用来'白嫖'别人的文件——如果哈希能猜到,就能不传数据直接拿到视频;③ 并发秒传同一文件——两个请求同时进来会各自加一次 ref_count,虽然计数最终是对的,但如果同时有人删除就可能出现引用计数和真实引用不一致,删除 worker 里我专门做了兜底。"

🔍 讲解/备注:删除 worker(delete_video_worker.go)在准备物理删除前,会再数一遍 video_sources / video_manifest / video_transcodes.manifest_file_id / videos.cover_file_id 四处引用,totalRefs > 0 就不删,并且回写正确的 ref_count。这就是"引用计数不可信时用真实引用数兜底"的思路,面试可以主动讲这一段,很加分。

Q1.5 断点续传具体断在哪、怎么续? ​

🎤 口述:

"两层。第一层是会话级:upload/init 用 upload_session:{user_id}:{hash} 把 upload_id + object_key 在 Redis 里缓存 24 小时,用户刷新页面重新 init 时直接拿回同一个 upload_id,不会新建一个 Multipart 会话。 第二层是分片级:前端拿到 upload_id 后调 upload/parts,后端用 ListObjectParts 把已经传成功的分片(PartNumber + ETag + Size)列出来,前端算出 1..totalParts 里缺哪些,只补缺的那些,然后把已有分片和新传分片一起交给 complete。"

🔍 边界:本地开发环境分片很小,MinIO 对小于 5 MB 的非末尾分片会在 complete 时直接报错——这是 S3 兼容存储的通用约束,也是"为什么分片要选 8 MB"的原因之一(见 Q1.6)。

Q1.6 分片为什么是 8 MB?前端哈希为什么用 2 MB 一块?并发为什么是 6? ​

🎤 口述:

"8 MB 是权衡的结果:S3/MinIO 的 Multipart 要求除最后一片外每片不小于 5 MB,同时分片太大(比如 64 MB)会让断点续传的粒度过粗、失败重传成本变高,8 MB 大约是'重传代价'和'请求数'的平衡点,2 GB 视频是 256 片。 哈希的 2 MB 是另一个维度的问题:SparkMD5 在浏览器主线程算哈希会阻塞 UI,所以我用 2 MB 一块、每块之间 setTimeout(0) 让出主线程,既能显示 0–10% 的进度条,也不卡页面。块越小 UI 越流畅、但事件循环次数越多,2 MB 是经验值。 并发 6 是针对浏览器 HTTP/1.1 每域名 6 连接的上限设的;如果是 HTTP/2 多路复用,可以放到 10–16,但要考虑客户端上行带宽的争抢。"

🚨 翻车信号:如果说"分片越大越快"——错,分片大小影响的是重传成本和并发度,不是单流速度。

Q1.7 前端并发上传失败怎么处理?有限流吗? ​

🎤 口述:

"目前的实现是失败就把分片号 push 回队列、等 2 秒重试,没有最大重试次数——这是我在代码里明确留的已知问题(Creator/index.vue 里连着注释写了'In production, should have max retry count')。正确的做法是:每个分片记重试次数、超过 3 次就标记这次上传失败并让用户点'继续上传',因为纯 while 循环重试在网络彻底断开时会变成死循环。 另外上传接口本身没有做分片级的限流,只在登录后接口做了用户级限流(60 秒 100 次),而签名接口是 GET、按次调用,256 片就是 256 次签名请求,这是可以优化成'一次签名多个分片'的地方。"

🔍 备注:这段是"我能指出自己代码的问题"的绝佳素材。面试官会因此相信代码真是你写的。


链 2:转码链路 —— 从"为什么拆"追到"扩不扩容得起来" ​

Q2.1 为什么把 FFmpeg 拆成独立服务? ​

🎤 口述:

"四个理由:① 依赖隔离——FFmpeg 是几百 MB 的系统二进制,放进业务镜像会让镜像变大、升级困难;② 故障隔离——转码会把 CPU 打满,甚至 OOM,混在 API 进程里会连累所有在线请求;③ 独立扩容——API 按 QPS 扩、转码按队列深度扩,两种负载曲线完全不同;④ 最小权限——transcoder 只连 MinIO 和 etcd,不持有数据库凭证,一台转码机被攻破拿不到任何用户数据。"

Q2.2 gRPC 调用具体传什么?为什么不让 transcoder 直接连数据库? ​

🎤 口述:

"契约是 ProcessVideo(bucket, object_key, output_prefix, cover_object_key, cover_time_seconds, quality_heights),返回 duration_seconds / manifest_object_key / manifest_size / cover_object_key / cover_size / profiles。 请求里只有存储位置和转码参数,没有数据库 ID 语义;响应里只有产物信息,没有业务状态。所以 transcoder 完全不知道"视频"这个概念,它只是一个"把对象 A 转成对象 B"的纯函数。好处是:任务状态机只有 worker 一个写入者,不存在两个服务同时改 video_transcodes.status 的并发问题;transcoder 崩溃重启不影响状态机,由 worker 侧的超时和看门狗兜底。"

🔍 追问点:为什么不用 HTTP?——protobuf 强类型契约、代码生成、HTTP/2 多路复用、以及未来做进度上报可以直接加 server-streaming(现在的 spec 里明确写了"进度流式上报不做")。

Q2.3 etcd 在中间起什么作用?没它不行吗? ​

🎤 口述:

"worker 需要知道 transcoder 的地址。两种模式我都实现了:静态地址(transcoder.addr)和 etcd 发现(transcoder.use_etcd)。 etcd 模式下,transcoder 启动时用 Grant 拿一个 10 秒租约,把 {addr} 写在 /vistack/transcoders/{uuid} 上,每 3 秒续约一次;worker 侧我没有用 etcd 官方的 resolver,而是自己写了一个 gRPC resolver.Builder:scheme 是 etcd,启动时按前缀 Get 一次,然后 Watch 前缀变化,每次变化重新 UpdateState 把所有地址推给 gRPC。配合 round_robin 负载均衡策略,请求会轮流打到各个 transcoder 副本上。"

Q2.4 round_robin 和默认策略有什么区别?为什么这里必须显式配? ​

🎤 口述:

"gRPC 默认是 pick_first——它只挑第一个连上的地址,一直用到底,也就是说即使你在 etcd 里注册了 3 个 transcoder,实际只有 1 个在干活。round_robin 会在 resolver 返回的地址列表上轮询,才能真正用满副本。但 round_robin 也有个前提:它需要 resolver 一次性给出多个地址,如果 resolver 只返回一个,它就会退化成 pick_first。所以自定义 resolver 是前提,round_robin 才生效。 更进一步的方案是用 gRPC 的 least_request 或自定义 LB 做容量感知(比如按 transcoder 当前任务数派发),那是这个项目可以继续演进的方向。"

Q2.5 一个 4K 视频要转很久,会不会超时?超时了任务怎么办? ​

🎤 口述:

"ProcessVideo 的 context 超时是 25 分钟。超过之后 gRPC 返回错误,worker 走 markFailed:把 transcode 状态置成 failed、用 Redis INCR 记录尝试次数,然后进延迟队列重试,最多 7 次。 但这里我踩过一个设计冲突,值得展开讲:看门狗是每分钟扫"processing 状态且 updated_at 超过 15 分钟"的任务——如果按 15 分钟判断,一个正常跑了 20 分钟的转码就会被误判成卡死。所以看门狗的第一道判断是 Redis 租约还在不在:lease:transcode:{id} 的 TTL 是 30 分钟,转码进行中租约一定存在,看门狗就直接跳过;只有租约已经释放(说明 worker 挂了或进程被杀了)才会真正重投。也就是说:租约是'有人在处理'的心跳,看门狗是'没人处理'的兜底,两者必须一起看,只看 DB 时间会误杀。"

🔍 备注:这是 05 里最值钱的一段。能主动讲出"15 分钟阈值和 30 分钟租约为什么不能独立看",说明你真的想过并发正确性。

Q2.6 那 25 分钟超时之后,transcoder 那边的 ffmpeg 进程会停吗? ​

🎤 口述:

"gRPC 的 ctx 取消会被传到 exec.CommandContext 的层级吗——目前没有。我的实现里 ffmpeg 是用 exec.Command 起的,context 取消并没有把它杀掉,所以 worker 侧超时返回了,transcoder 里的 ffmpeg 可能还在跑,直到跑完把产物传上 MinIO。 这个行为有两面:好处是它跑完的产物是完整的,重试时会被覆盖(-y 也是这个用意);坏处是资源泄漏——如果大量任务超时,transcoder 上会堆积僵尸 ffmpeg 进程,把 CPU 打满。正确做法是用 exec.CommandContext + 进程组 kill,或者给每个转码任务加独立的工作目录 + 定时清理。这是我明确知道的缺陷。"

⚠️ 这段强烈建议主动讲。"我知道我的超时没有真正杀掉子进程"这种话,面试官会觉得你在真实生产环境里干过。

Q2.7 转码怎么扩容?--scale transcoder=3 之后会发生什么? ​

🎤 口述:

"compose 里 docker compose up --scale transcoder=3 起来三个副本,每个副本启动时都用 advertiseAddr 算自己的对外地址(优先 POD_IP、其次第一个非回环 IPv4、再退到 hostname),注册到 etcd 各自的 uuid key 下。worker 侧的 resolver watch 到三个地址,round_robin 把 gRPC 调用轮过去。 需要注意的是扩容的上限其实是 Kafka 的分区数:我的 EnsureTopic 建的是 1 分区,worker 每实例 4 个并发消费者,但同一个分区只能被一个 reader 持有,所以 1 分区时即使 worker 副本再多,同一时刻也只有一个实例在处理转码任务。要真正并发转码,应该把 transcode topic 提到 8–16 分区,再配合 worker 副本数。这是我做出来之后才意识到的瓶颈。"

🚨 翻车信号:说不清"1 分区 + 4 消费者 = 实际只有 1 个在消费"这个点,会被认为不懂 Kafka。这条务必背下来。


链 3:可靠性 —— 从"消息会不会丢"追到"死信队列" ​

Q3.1 从"上传完成"到"视频可播放",中间消息丢了怎么办? ​

🎤 口述:

"分三段看。① 生产端:Kafka writer 是同步发送(Async=false,10 秒超时),发送失败会立刻返回错误;我的处理是标记 transcode 为 failed 并把它塞进 Redis 延迟重试队列,同时给用户返回 500,所以不会出现'DB 有记录但消息凭空消失'。② 消费端:手动提交 offset(CommitInterval=0),只有 handler 返回 nil 才提交,处理失败就不提交,消息会在下次读取/重启时重新投递。③ 兜底:看门狗每分钟扫 pending 超过 10 分钟的任务(说明消息可能丢了或没被消费到),重新投递一次。"

Q3.2 消费失败不提交 offset,会不会无限循环、阻塞后面所有消息? ​

🎤 口述:

"会,而且这是我实现里一个真实的缺陷。目前 consumer 处理失败时只是记日志、不提交 offset,然后继续读下一条;但 offset 没推进,意味着进程重启后这条消息还会被重新消费。如果是一条永远处理不了的消息(比如原片被删了),它就会变成反复重试的毒丸。 正确做法有两个:① 在 handler 内部做有限重试 + 死信投递,处理不了就投到 transcode.dlq 并提交 offset,让人工介入;② 用 Kafka 的 RetryTopic 模式按延迟分层重投。我在 Redis 侧其实已经有延迟队列和"最多 7 次"的上限,但没有把它和 offset 提交打通(失败时 handler 返回的是 nil,所以 offset 会被提交,靠重试队列兜),这一点在 docs/specs/distributed-architecture.md 里我自己也列成了 P0-2 待办。"

🔍 备注:注意代码事实——markFailed 最后 return nil,注释写着"返回 nil 避免 Kafka 重复投递",所以是靠 Redis 重试队列兜、offset 照常提交。这个设计是自洽的,但代价是丢掉了 Kafka 原生的重投能力,需要靠看门狗补偿。请务必按这个事实来说,不要讲成"offset 不提交"。

Q3.3 同一条消息被消费两次会怎样? ​

🎤 口述:

"三重幂等:① 进来先按 transcode_id 查 DB,如果状态已经是 completed 就直接 return;② 抢 Redis 的 lease:transcode:{id}(SetNX,TTL 30 分钟),抢不到说明别的实例正在处理,直接 return;③ 结果写入是一个数据库事务,要么全写要么全不写,不会出现'清单写了但视频状态没更新'。 另外 persistTranscodeResult 成功后会删掉 attempts:transcode:{id} 计数键,让重试次数归零。"

Q3.4 如果 worker 在处理到一半被 kill 了呢? ​

🎤 口述:

"进程退出时租约键不会被删(我没有在 defer 里删,因为 defer core.Redis.Del 只在函数正常返回时执行——实际上被 SIGKILL 时什么都不执行),所以租约会自然过期,最多 30 分钟后释放。这期间任务状态停留在 processing,看门狗看到"processing 超 15 分钟 + 租约不存在"才会重投——也就是说最坏情况下任务要等 30 分钟才能被恢复。这是我这个设计的一个代价。 更好的做法是把租约做成带续约的长租约(比如 5 分钟 TTL、处理中每 30 秒续一次),这样进程死掉后 5 分钟就能被发现,恢复更快。另外正常的 SIGTERM 我是做了优雅停机的:收到信号后停止消费、WaitKafkaConsumers 最多等 30 秒排空在途任务。"

🔍 事实核对:worker.go 里 defer core.Redis.Del(ctx, leaseKey) 是有的,所以在正常返回(成功或失败)时租约会删除;异常退出(panic/SIGKILL)才依赖过期。面试时按这个说更准确:正常路径会主动释放,异常路径靠 TTL。

Q3.5 提到看门狗和重试派发器,多副本会不会重复投递? ​

🎤 口述:

"这是我在做完第一版架构评审后修掉的最严重的问题。第一版里 StartTranscodeRetryDispatcher 和 StartTranscodeWatchdog 是每个 worker 副本都会启动的——多副本时两个 dispatcher 会同时扫同一个 Redis ZSet,把同一条重试消息投两次。 修法是引入 etcd 领导选举:用 concurrency.NewSession + NewElection,key 是 /vistack/leaders/worker-singleton,只有抢到领导的实例才启动这两个单例循环;领导租约过期(默认 10 秒)或实例挂掉时,其他副本会自动接任,无主窗口大约是租约时间。etcd 不可用时我选择降级为直接运行并打警告日志——单实例安全、多实例有重复风险,这个取舍是显式的(要可用性还是要严格单例,我选了可用性)。"

Q3.6 那 7 次重试之后就丢弃了,合适吗? ​

🎤 口述:

"不合适,这是一个明确的待改进项。现在是 attempts > 7 就 continue,消息彻底消失,只留下一条 failed 状态的 DB 记录,用户看不到任何提示,运维也不会收到告警。 应该做的是:超限后投到死信 topic transcode.dlq 并打错误日志 + 告警指标,保留现场供人工重放;同时给用户一个"转码失败,可重试"的按钮。我在设计文档里把'DLQ + 失败告警'列成了 P0-2。"


链 4:缓存 —— 从"三件套"追到"布隆过滤器删不掉怎么办" ​

Q4.1 你视频详情接口的缓存是怎么设计的? ​

🎤 口述:

"Cache-Aside,读路径统一走一个 GetOrLoad 组件:先读缓存,命中就返回;未命中回源 DB 并把结果写回缓存。防护做了三件事——穿透用空值缓存加布隆过滤器、击穿用 singleflight 加 Redis 互斥锁、雪崩用随机 TTL。 key 是 vistack:video:info:{id},TTL 在 300–600 秒之间随机,空值缓存 60 秒,锁 5 秒。推荐的列表单独一个 key,TTL 300 秒。写路径(改视频信息、删视频)直接删这两个 key。"

Q4.2 单机 singleflight 和 Redis 锁不是重复了吗? ​

🎤 口述:

"不重复,它们解决的问题范围不同。singleflight 只能合并同一个进程内的并发请求,多副本部署时 3 个 API 实例会各自回源一次,DB 上还是 3 个查询;Redis 的 SetNX 锁是跨实例的,保证同一时刻全网只有一个实例回源。所以是两层:进程内 singleflight 合并本地并发,进程间用锁做全局互斥。 没抢到锁的请求不会立刻回源,而是在 2 秒内每 50 毫秒轮询一次缓存——因为大概率持锁者马上就会把数据写进去;2 秒还没等到就直接回源,这是为了避免锁持有者挂掉导致请求全被拖死。"

🔍 追问点:锁释放为什么用 Lua?——if redis.call("get", KEYS[1]) == ARGV[1] then del,校验 token 防止"业务执行太久、锁自动过期、被别人拿到,然后自己把别人的锁删了"。

Q4.3 布隆过滤器怎么用的?视频删了怎么办? ​

🎤 口述:

"用在视频详情查询上。过滤器存在 Redis bitmap 里,1000 万位、7 个哈希函数,哈希是 FNV-1a 双哈希构造的(h1 + i*h2)。api 启动时会异步全量扫 videos 表把已存在的视频 key 灌进去,新视频创建时再单独 Add 一次。查询时先问布隆:返回"一定不存在"就直接返回 404,不查缓存也不查库。 关键的两个工程细节:① 我用一个 bloom:ready 标志位区分'构建完成'和'构建中/被清空'——构建用事务先删 key 再逐位写,如果构建过程中有查询进来,误判率会很高,所以未就绪时直接降级返回'可能存在';② Redis 报错或 key 不存在时也降级放行,宁可穿透也不能误拦截真实数据。 删视频的问题我承认:布隆过滤器只增不减,删除视频后它的 key 还留在过滤器里,只会产生假阳性(放行去查库),不会导致漏查 404 的错误结果——因为 404 是由 DB 查询决定的,布隆只用来短路。所以删数据场景下布隆退化成无效,但不产生正确性问题。要支持删除就得用计数布隆(counting bloom)或者定期重建。"

🚨 翻车信号:说"布隆过滤器可以删数据"或"布隆能精确判断存在"——直接暴露基础不牢。

Q4.4 缓存和数据库一致性怎么保证的? ​

🎤 口述:

"用的是 Cache-Aside 的常规做法:先更新数据库,再删除缓存,不做更新缓存。我目前在删除上是直接 Del,没有做延迟双删。 严格说这里有经典的不一致窗口:读请求 A 未命中缓存、回源读到旧值,此时写请求 B 更新完 DB 并删掉缓存,A 再把旧值写回缓存 —— 缓存里就留下了脏数据,直到 TTL 过期。业界标准的补法是延迟双删(更新库、删缓存、延迟几百毫秒再删一次)或者给缓存值带逻辑过期时间、异步刷新。 我之所以先用 TTL 兜底(300–600 秒随机),是因为视频元数据是低频写、短时间不一致可接受;如果是库存、余额这类场景,我会直接上延迟双删 + 版本号校验。"

Q4.5 Redis 整个挂了会发生什么? ​

🎤 口述:

"分模块看,我的原则是缓存和限流都不能成为单点:① 缓存——GetOrLoad 里 Redis 报错时直接回源 DB,功能不受影响,只是 DB 压力上升;布隆过滤器也降级成'可能存在';② 限流——limiter 返回 error 时中间件 fail-open 放行,可用性优先(代价是短时间失去防刷能力);③ 计数——点赞会直接失败报错(因为 Lua 脚本执行不了),这块没有降级方案,是我的短板;④ 上传/调度——Redis 挂了秒传、断点续传、租约、重试队列全都会受影响,相当于核心链路降级。"

🔍 备注:这条回答体现"分模块评估降级",比一句"缓存挂了就走数据库"高一个层次。


链 5:限流与计数 —— 从"为什么限流"追到"计数为什么能到 Redis 里" ​

Q5.1 你的限流怎么做?为什么选这个算法? ​

🎤 口述:

"对登录后接口按 user_id 限流,两种算法可配置切换:令牌桶(进程内,rate 10/s、burst 20)和 Redis Lua 滑动窗口(60 秒 100 次,配置里默认用这个)。 滑动窗口是这么实现的:Redis ZSet 存请求时间戳,Lua 脚本一次原子完成三件事——ZREMRANGEBYSCORE 清理窗口外的旧记录、ZCARD 数当前窗口内的请求数、没超限就 ZADD 一条新记录并 PEXPIRE 续期。返回 {allowed, remaining, reset} 给中间件拼 X-RateLimit-* 和 Retry-After 响应头,超限返回 429。 选滑动窗口是因为固定窗口有临界问题:窗口边界两侧各打满一次,瞬时可以是限额的两倍。滑动窗口用真实时间戳统计,边界更平滑。代价是每个用户一个 ZSet,内存比计数器高,所以 key 带窗口过期时间自动清理。"

🔍 追问点:为什么 member 用 UUID?——如果两个请求落在同一毫秒,ZSet 会用 member 去重导致少算一次,所以用 UUID 保证 member 唯一。

Q5.2 令牌桶是进程内的,多副本下不就失效了吗? ​

🎤 口述:

"对,进程内令牌桶只在单副本部署时是准的,3 个副本就等于把额度放大 3 倍。我保留它的原因是它是零依赖方案:Redis 不可用时它是唯一还能工作的限流器,而且它的 Allow 用了一把 mutex 保护 map,实现简单、延迟极低。 生产上正确做法是:全局配额放网关或 Redis 侧(比如入口按 IP 限流放在 Traefik/Nginx,用户级配额用 Redis),本地令牌桶只作为"每个实例的自我保护"做粗粒度限流。"

Q5.3 点赞计数为什么放 Redis?直接写 MySQL 不行吗? ​

🎤 口述:

"点赞是典型的高频写、低价值单条、需要去重的场景。热门视频可能每秒上千次点赞,每次一行 INSERT 加一次计数 UPDATE,MySQL 上会直接形成热点行锁竞争;而 Redis 的 SADD/SISMEMBER 是 O(1),SCARD 直接拿去重后的点赞数。 我的实现是一条 Lua 脚本做原子 toggle:SISMEMBER 判断是否已赞,已赞就 SREM + 榜单 ZINCRBY -1 + 推一条 unlike 事件,未赞就 SADD + ZINCRBY 1 + 推一条 like 事件,脚本返回最新点赞数。判重、改状态、更新榜单、发事件这四件事在一个原子操作里完成,不会出现'状态改了但事件丢了'的中间态。"

Q5.4 那这些点赞到底存哪了?Redis 丢数据怎么办?播放量会不会被刷? ​

🎤 口述:

"Redis 是实时权威,MySQL 是最终持久。每次 toggle 会往一个 Redis List(vistack:interaction:pending)里 RPUSH 一条事件,后台 flusher 每 5 秒批量取出 200 条落库:点赞事件按 video_id + user_id 做 OnConflict DoNothing 插入 video_likes,取消就删除;播放事件带 snowflake 事件 ID 插入 video_play_logs(plays 表的 ID 就是 snowflake,OnConflict DoNothing 保证同一条事件重复落库不会重复计)。落库后再以 Redis 计数为权威回写 videos.like_count / favorite_count / play_count 冗余列。 丢数据的窗口是存在的:Redis 没持久化时宕机会丢最近的计数。缓解手段是 flusher 每 5 秒落一次,所以最坏丢 5 秒 + 未落库的事件。播放量被刷的问题目前没有做防刷(同一个 IP 反复调播放上报接口就会一直 INCR),这也是我已知的短板,正常做法是加'同一用户/IP + 视频维度的时间窗口去重'。"

Q5.5 为什么要有冗余计数列?不一致了听谁的? ​

🎤 口述:

"冗余列是为了列表页和排序:推荐列表、创作中心列表要展示点赞/播放数,如果每行都去 Redis 拿,一次列表 20 行就是 60 次 Redis 调用;直接 SELECT 列能省掉这一跳。列表接口其实也会批量从 Redis 读计数(Counts 用 pipeline 一次拿 60 个 key)做实时覆盖。 不一致时以 Redis 为准——flusher 每次落库后都会用 SCARD/GET 重新读一遍 Redis 并覆盖写 DB。所以 DB 是 Redis 的投影,方向是单向的,不会互相打架。"


链 6:播放与鉴权 —— 从"防盗链"追到"前端签名安全吗" ​

Q6.1 你的防盗链是怎么做的? ​

🎤 口述:

"播放链路拆成两段,因为 MPD 清单和 m4s 分片的量级完全不同。 清单段(每次播放 1 次):客户端调 GET /videos/{id}/manifest.mpd,后端查出 manifest 记录、从 MinIO 读出 MPD 内容代理返回,顺便做鉴权。 分片段(一次播放几百个请求):客户端先调 GET /videos/{id}/segments/signature,后端用 MinIO 的 STS AssumeRole 申请一组临时凭证,策略限定只能 GetObject 桶里 dash/{video_id}/* 这个前缀,有效期 30 分钟,后端把凭证缓存在 Redis 25 分钟(避免每次播放都去 STS 换);前端 dash.js 通过 request interceptor 把 MPD 里的相对 m4s 地址改写成 MinIO 直连地址,并用这组临时凭证在前端做 AWS SigV4 签名后请求。 这样 99% 的流量(分片)是浏览器直连对象存储的,API 只承担一次清单读取和一次签名下发。"

Q6.2 把 SecretKey 发给前端,这安全吗? ​

🎤 口述:

"要区分长期密钥和临时凭证。发给前端的不是 MinIO 的 root 密钥,而是 STS 换来的 AccessKeyId + SecretAccessKey + SessionToken 三元组,它有四个约束:有时间限制(30 分钟)、有权限限制(策略里只能读 dash/{video_id}/* 这一个视频目录)、绑定了会话令牌(没有 SessionToken 这组密钥无效)、可撤销(换密钥或改策略即可失效)。 所以泄露的实际风险是:攻击者在 30 分钟内、能读这一个视频的分片——这些分片本来就是给用户看的。真正的风险点是它没有做用户级鉴权:只要有视频 ID 就能拿凭证看任何视频,包括私有视频(如果 visibility 是 private 的话)。要更严格的话,签名接口里应该校验视频可见性和用户权限,再签发凭证。"

⚠️ 这是一个真实缺口(GetVideoSegmentsSignature 只查视频是否存在,不校验可见性),主动说。

Q6.3 前端实现 SigV4 会不会有兼容问题? ​

🎤 口述:

"有一个必须注意的点:我用的是 UNSIGNED-PAYLOAD 作为 payload hash,也就是不计算请求体的哈希,这在 GET 请求上是安全的、也是 AWS 允许的。签名要覆盖 host、x-amz-content-sha256、x-amz-date、以及有 SessionToken 时的 x-amz-security-token,并且 CanonicalQuery 必须按 key 排序、URI 按 RFC3986 编码——这几个格式错一个就是 SignatureDoesNotMatch。我当时 debug 最久的就是签名不匹配,最后是靠对比 MinIO 服务端返回的错误信息逐字段核对的。"

🔍 备注:这段细节(UNSIGNED-PAYLOAD、canonical 排序、SignatureDoesNotMatch)能让面试官立刻相信你真的手写过 SigV4,而不是调了个 SDK。

Q6.4 为什么不干脆让 MinIO 桶公开,或者上 CDN? ​

🎤 口述:

"桶公开就等于放弃防盗链,任何人都能爬我的视频,所以我的桶策略只公开了 avatars/* 和 covers/* 两个头图类前缀(它们本来就要在列表页展示),视频相关的前缀一律私有。 上 CDN 是更正确的终态:把 MinIO 作为回源,CDN 做边缘缓存和签名校验(Cloudflare 的 Signed URL 或 Token 鉴权),这样带宽成本进一步下降、播放体验也更好。我目前只把前端静态资源发布到了 CDN(deploy/cdn-publish.sh 支持 Cloudflare Pages 和 S3),视频分片还没接 CDN,是一个明确的演进方向。"


链 7:架构演进 —— "如果给你更多时间/资源,你怎么做" ​

这链通常是终面或交叉面,问题是开放式的,但答案要有优先级。

🎤 口述(完整版,约 90 秒):

"我会按'正确性 → 可观测性 → 高可用 → 平台化'的顺序来推。

第一优先级是正确性和兜底:补死信队列 + 失败告警(现在重试 7 次之后是静默丢弃),补 Outbox 表保证'DB 事务和消息投递'原子,Kafka 消息加版本号以便跨版本演进。

第二是可观测性,这是目前最大的短板:只有 zap 日志,出问题只能靠日志串。要加 Prometheus 指标(HTTP QPS/p95、Kafka lag、队列深度、转码时长和成功率)、OpenTelemetry 把'一次上传 → Kafka → worker → gRPC → MinIO'整条链路串起来,再把 /health 拆成 readiness 和 liveness 接 K8s 探针。

第三是高可用:中间件现在全是单点——etcd 单节点、Kafka 单 broker 单分区、PG 单实例、MinIO 单实例、Redis 单实例。要上 etcd 三节点、Kafka 三 broker 且 transcode topic 提到 8–16 分区、PG 主从 + PgBouncer、Redis Sentinel。

第四是平台化:把 transcoder 的注册发现抽成通用的 registry/discovery 给所有角色用,把 Kafka 消费抽象成 JobHandler 接口(新任务 = 新 handler),把转码任务按 transcoder 容量派发。

如果只让我做一件事,我选第一优先级——因为可观测性和高可用是'系统变慢/挂掉'的问题,而正确性是'系统悄悄算错'的问题,后者更难发现。"


自测清单 ​

  • [ ] 链 1 能答到 Q1.4 的引用计数兜底,并主动说出"上传侧仍是 root 凭证"
  • [ ] 链 2 能讲清"15 分钟阈值必须和 30 分钟租约一起看"以及"1 分区限制并发"
  • [ ] 链 2 能主动承认"超时没有真正 kill 掉 ffmpeg 子进程"
  • [ ] 链 3 能讲清投递失败/消费失败/进程崩溃三种情况分别怎么兜
  • [ ] 链 3 能说清 markFailed 返回 nil 的含义(offset 照常提交、靠重试队列兜)
  • [ ] 链 4 能讲清 singleflight 与 Redis 锁的分工,以及延迟双删的缺口
  • [ ] 链 4 能讲清布隆只增不减带来的影响(只假阳性、不误拦)
  • [ ] 链 5 能讲清滑动窗口 Lua 的三步与 UUID member 的原因
  • [ ] 链 5 能讲清计数的"Redis 权威、DB 投影、5 秒批量落库"与幂等手段
  • [ ] 链 6 能讲清 STS 临时凭证与预签名 URL 的区别和各自的约束
  • [ ] 链 6 能主动承认签名接口未校验视频可见性
  • [ ] 链 7 能用"正确性 → 可观测性 → 高可用 → 平台化"的优先级结构回答

背诵卡 ​

  • 上传:预签名直传,服务端零字节,哈希秒传 + ListObjectParts 续传。
  • 秒传是复用 FileID + ref_count+1,每个用户各有自己的 DASH 产物。
  • 删除复用真实引用数兜底引用计数(删前再数四处引用)。
  • 转码:DB 状态 + Redis 租约 + 事务结果 = 三重幂等。
  • 租约是"有人在做"的心跳,看门狗是"没人做"的兜底,缺一不可。
  • 重试:ZSet 延迟队列,2^(n-1) 分钟退避 + 抖动,上限 8 小时、7 次。
  • 单例任务用 etcd 领导选举;etcd 挂了降级直跑并告警。
  • Kafka 1 分区 → 4 个 reader 也只有 1 个在消费,扩容要先加分区。
  • 缓存穿透:空值 + 布隆;击穿:singleflight + Redis 锁;雪崩:随机 TTL。
  • 布隆只增不减:删除后只有假阳性,不会误拦。
  • 限流失败 fail-open,计数失败直接报错——取舍不同。
  • 前端拿到的是 STS 临时凭证:30 分钟、限定前缀、可撤销。
  • 认不足要带方案:DLQ、Outbox、指标、mTLS。

持续学习,持续构建。