Skip to content

10 · 速答题库与背诵卡(Vistack) ​

定位:面试前一晚 60 分钟过一遍的速答手册。每行一个问题 + 30 秒内能说完的标准答案 + 代码依据。 事实纪律:所有数字与行为都来自本仓库代码(写作时快照)。凡是代码里没有的,写「需确认」;不确定的说法不要背。 用法:第一遍只看中间一列;答不上来的看第三列去翻代码;最后两节(数字速记表 + 口述稿)必须背到脱口而出。

目录 ​

  1. ① 架构与三角色拆分
  2. ② 上传与对象存储
  3. ③ 转码与 DASH/ABR
  4. ④ gRPC 与 etcd 服务发现
  5. ⑤ Kafka 与任务可靠性
  6. ⑥ 缓存 / 限流 / 计数高并发
  7. ⑦ 鉴权与安全
  8. ⑧ 稳定性与工程化
  9. ⑨ 项目不足与演进
  10. 数字速记表
  11. 30 秒版自我介绍(项目段)
  12. 3 分钟版项目口述稿
  13. 反问面试官的问题

① 架构与三角色拆分 ​

问题30 秒内的标准答案代码依据
这个项目是什么?一句话讲清架构Go 云原生视频点播平台。单二进制拆成 5 个角色:api(Gin HTTP + 预签名 + 投消息)、worker(Kafka 消费 + 编排 + 落库)、transcoder(gRPC + ffmpeg,无状态不连 DB)、auth(持 RSA 私钥签 JWT + JWKS + gRPC 用户查询)、migrate(一次性迁移)cmd/vistack/main.go:24-38、internal/role/*.go
角色是怎么选出来的(细节坑)优先 VISTACK_ROLE 环境变量,其次第一个非 flag 位置参数,都没有才默认 api。注意:compose 与 k8s 清单里其实没有设 VISTACK_ROLE,用的是 command: ["api"] / args: ["api"] 这种位置参数形式cmd/vistack/main.go:41-49、compose.yml:97,118,139,153、deploy/k8s/api.yaml:20
为什么是单二进制多角色,不是多个 main 包一个镜像多个角色:compose 里 api/worker/auth 共用 vistack:latest,只换 command: ["api"] / ["worker"] / ["auth"],部署面小;角色边界靠代码评审守住(worker 里没有任何 exec ffmpeg)。代价是共享一份 AppConfig 与全部编译依赖Dockerfile:38-51、compose.yml:76-139、internal/role/worker.go:29-75
api 启动时初始化了什么DB → MinIO → Redis → Cache(含布隆)→ Snowflake → JWKS 验签器(后台每 1h 自动刷新)→ auth gRPC user client → Kafka producer;再按开关装配 interaction / danmaku / comment 三个服务并各自 StartFlusher;最后异步全量构建视频布隆internal/role/api.go:34-116
api 会消费 Kafka 吗不会。api 只 InitKafka 建 producer(api.go:63),消费者只在 worker(worker.go:52-55)。api 侧额外调了一次 EnsureTopic(danmaku) 保证 topic 存在internal/role/api.go:63-68、internal/role/worker.go:52-55
transcoder 为什么不连数据库要它无状态、可被任意调度:只 InitMinioClient(transcoder.go:17-18),输入参数(bucket/objectKey/outputPrefix/coverKey/qualityHeights)全由 gRPC 请求带进来,结果由 worker 落库。好处是 --scale transcoder=3 即可扩容,代价是编排与一致性全压在 workerinternal/role/transcoder.go:15-29、internal/transcoder/service.go:25-154
worker 里的单例任务怎么保证只有一份retry dispatcher 与 watchdog 走 etcd concurrency.Election(key /vistack/leaders/worker-singleton,租约 TTL 10s),只有 leader 跑;leader 挂掉无主窗口 ≈ TTL,随后自动重选。etcd 未配或连不上时降级为直接运行并打 Warn(多副本不安全)internal/role/worker.go:79-122、internal/core/leader/leader.go:12-18
为什么单独一个 migrate 角色多副本 api 同时 AutoMigrate 会互相竞争;拆成一次性任务(compose run / k8s Job)。迁移本身幂等:默认角色 FirstOrCreate,索引 CREATE INDEX IF NOT EXISTSinternal/role/migrate.go:10-26、migrations/migrate.go:18-95
各角色的依赖差异api:DB + Redis + Cache + Kafka(producer) + MinIO + snowflake + JWKS;worker:DB + Redis + MinIO + Kafka(consumer) + transcoder client + snowflake;transcoder:只有 MinIO;auth:DB + MinIO(生成头像 URL)+ RSA 私钥四个 internal/role/*.go 的 Init 调用
部署形态compose 起 10 个服务(PG/Redis/MinIO/Kafka/etcd + api/worker/auth/transcoder/Traefik);Traefik 按路径分流。另有 deploy/k8s/:api 1 副本、worker 1 副本、transcoder 2 副本、etcd 1 副本compose.yml:76-171、deploy/traefik/dynamic.yml、deploy/k8s/*.yaml
前端产物怎么托管api 内置静态托管:web_dir → /、admin_web_dir → /admin/;自定义 spaFileSystem 做 SPA 回退,/api/ 前缀不参与回退(保持 404 语义)。镜像内 COPY 到 /app/web 与 /app/web-admininternal/api/v1/web.go:20-66、Dockerfile:45-46、conf/app.docker.toml:25-26
这套架构最大的取舍用 Kafka + gRPC + etcd 换来水平扩展能力,但当前部署仍是三处单点:Kafka 1 分区 1 副本、etcd 单节点、PG/Redis/MinIO 单实例;可靠性靠「DB 状态机 + Redis 租约 + watchdog」自建兜底,而不是靠消息系统本身internal/core/kafka.go:108-112、compose.yml:39-74

追问预警:一定会被问「单二进制怎么保证角色之间不互相污染」——答:靠初始化边界(transcoder 不 import DB)、镜像分离(vistack 镜像不含 ffmpeg)、配置段按需读取;诚实补充:目前没有编译期约束,角色漏调 Init 只能在运行时暴露。


② 上传与对象存储 ​

问题30 秒内的标准答案代码依据
上传链路整体怎么走前端 SparkMD5 分块算整文件 MD5 → POST /videos/upload/init(① hash 秒传 ② Redis 续传 ③ MinIO NewMultipartUpload)→ GET /videos/upload/sign 逐片取预签名 URL(1h)→ 前端 6 并发 PUT 直传 MinIO → GET /videos/upload/parts 补断点 → POST /videos/upload/complete 合并 + 事务建表 + 投 Kafka transcodeweb-client/src/views/Creator/index.vue:121-279、internal/api/v1/Video.go:81-447
为什么浏览器直传 MinIO、不走 api 中转api 只负责签 URL,不占带宽和内存;8MB 分片、6 并发的吞吐完全由客户端与对象存储决定。代价是前端要自己收集 ETag 和处理失败重传Video.go:238-274、index.vue:192-250
秒传(去重)怎么实现init 时按 hash = ? AND status = active 查 files(hash 列有普通索引);命中就在同一事务里 ref_count + 1、建 Video(processing)、建 VideoSource(复用同一个 file_id)、建 VideoTranscode(pending),投 Kafka 后返回 uploaded=true + video_idVideo.go:97-179、internal/model/entity/file/file.go:44
断点续传怎么实现两层:init 期用 Redis upload_session:<user_id>:<file_hash> 缓存 24h 直接回放 uploadId/objectKey;已拿到 uploadId 时用 ListObjectParts(maxParts=10000)列出已传分片,前端只补缺失的 PartNumber,并把已传分片 ETag 一起提交Video.go:181-193、Video.go:287-313、index.vue:161-190
分片大小和并发是多少、怎么定的8MB 分片 / 6 并发(chunkSize = 8*1024*1024、concurrency = 6)。8MB 是「单请求体大小 vs 重传代价」的折中(重传最多 8MB),6 并发是浏览器同域连接数与 MinIO 写压的折中;没有自适应index.vue:38、index.vue:192
分片上传 URL 怎么签core.MinioCorePublic.Presign("PUT", bucket, objectKey, 1h, {uploadId, partNumber}),用公网 endpoint 的 Core client 签,避免签出容器内网地址;前端直接 PUT,ETag 从响应头取Video.go:246-273、internal/core/minio.go:87-108
前端失败重试策略很朴素:catch 里把 partNumber 塞回队列、sleep 2000ms 重试,没有最大重试次数(代码注释自己承认应该加)。这是要主动承认的短板index.vue:237-244
complete 里做了哪些一致性动作先 CompleteMultipartUpload 合并 → 开事务建 Video(processing) → File(video_source, ref_count=1, hash) → VideoSource → VideoTranscode(pending) → Commit → best-effort 加布隆 → 同步发 Kafka;投递失败则把 transcode 标 failed、塞进 ZSet 重试队列、返回 500 让用户重试Video.go:342-441
文件生命周期(引用计数)怎么管files 每行有 ref_count:秒传分支 +1;删除走 delete_video_worker:先软删视频并 ref_count - 1,再用真实引用数(查 video_sources/video_manifest/video_transcodes/videos.cover_file_id 四处)二次校验,不一致就用实际值修正,归零才删 DB 行与 MinIO 对象delete_video_worker.go:99-121、delete_video_worker.go:204-283
头像 / 封面 / 评论图怎么传走 api 中转的小文件上传(/file/avatar、/file/cover、/file/comment),硬限 5MB,分别落 avatars/、covers/、comments/ 前缀并建 files 记录internal/api/v1/File.go:33/78/119
bucket 策略做了什么启动时确保 bucket 存在、每次启动重写 policy(只放开 avatars/* 与 covers/* 公开读,DASH 分片不公开),并配置生命周期:带 tag status=replaced 的对象 1 天后删除internal/core/minio.go:115-186
为什么要三个 MinIO 客户端内网 client 做服务端读写(minio:9000)、Core client 做 multipart 低层调用、Public Core client 专门签 URL,保证签名 host 是浏览器可达地址;Region 统一写死 us-east-1 以避免 ?location 探测internal/core/minio.go:52-108
秒传的 hash 是怎么算的前端用 SparkMD5.ArrayBuffer 按 2MB 逐块喂给同一个 SparkMD5 实例,最后 spark.end() 得到整文件 MD5;哈希阶段进度映射到 0–10%,上传阶段 10–95%index.vue:83-119、index.vue:234

追问预警:「两阶段上传怎么防止用户拿到 uploadId 去传别人的对象」——对象 key 是 raw/<uuid>,uploadId 只对这一个 key 有效,且 sign 接口要鉴权;但要承认:sign 接口没有校验 object_key 归属当前用户(谁能拿到 uploadId 就能签),这是可以立刻补的点。


③ 转码与 DASH/ABR ​

问题30 秒内的标准答案代码依据
转码怎么触发的上传完成或秒传命中后 api 投 transcode topic(key = videoID),worker 消费;消息体 {video_id, transcode_id, object_key, attempt}Video.go:415-423、transcode/worker.go:22-39
worker 收到消息做了哪些幂等保护① 查 VideoTranscode,completed 直接 return;② Redis SetNX lease:transcode:<id>(TTL 30 分钟)当分布式锁,抢不到就跳过;③ 处理完 defer Del 释放 leasetranscode/worker.go:49-62
gRPC 调用超时多少、为什么25 分钟(transcodeCallTimeout)。三层时间常数刻意逐级放大:gRPC 调用 25min < lease 30min < watchdog 阈值判定「holder 已死」;watchdog 看到 lease 还在就不动这个任务transcode/worker.go:20、worker.go:58、watchdog.go:23-34
ProcessVideo 内部按顺序做什么建临时目录 → FGetObject 下载原片 → ffprobe 探时长 → 按规则抽封面帧 → ffmpeg 多档 DASH 转码到本地 output → filepath.Walk 逐个 FPutObject 回 MinIO(按扩展名设 Content-Type:.mpd→application/dash+xml、.m4s→video/iso.segment)→ 返回时长 / 清单 key+size / 封面 key+size / 档位列表internal/transcoder/service.go:25-154
封面时间点怎么选请求可指定 cover_time_seconds;为 0 时代码自动选:时长 > 10s 取第 5 秒,> 2s 取时长一半,否则 1.0s;抽帧用 ffmpeg -ss <t> -frames:v 1 -q:v 2;抽帧失败只 Warn 不失败整个任务service.go:51-79
ABR 档位怎么选按源高度选 ladder:≥2160→[480,720,1080,1440,2160];≥1440→[480,720,1080,1440];≥1080→[360,480,720,1080];≥720→[360,480,720];≥480→[240,360,480];否则 [240,360]。若源高度 < 360 只出一档(按源高度、800k/baseline)。请求显式给 quality_heights 时优先用它ffmpeg.go:158-261
每档码率 / CRF 是多少240p 500k/CRF23/baseline;360p 1M/CRF22/main;480p 2M/CRF21/main;720p 4M/CRF20/high/medium;1080p 8M/CRF18/high/slow;1440p 16M/CRF17;2160p 35M/CRF16。统一 maxrate = bitrate×1.25、bufsize = bitrate×2ffmpeg.go:39-47
DASH 的关键参数-f dash -seg_duration 4 -use_template 1 -use_timeline 1;init 段 init-$RepresentationID$.m4s,媒体段 chunk-$RepresentationID$-$Number%05d$.m4s;adaptation_sets id=0,streams=v id=1,streams=a;-movflags +faststart;清单固定 manifest.mpdffmpeg.go:358-368
GOP 与帧率怎么处理强制 -r 30 -vsync cfr -g 120 -keyint_min 120 -sc_threshold 0:固定 30fps,GOP 120 帧 = 4 秒,与 seg_duration 4s 对齐,保证每片可独立起播与切档;sc_threshold=0 禁用场景切换插关键帧。代价是非 30fps 源会被重采样(要主动承认)ffmpeg.go:343-349
音频怎么处理只编一路 AAC 192k / 48kHz / 立体声(-c:a aac -b:a 192k -ar 48000 -ac 2),不按档位分别编音频ffmpeg.go:351-356
竖屏 / 非 16:9 源怎么办用标准 16:9 宽度映射表(240→426、360→640、480→854、720→1280、1080→1920、1440→2560、2160→3840),scale 用 lanczos;需要缩放时再 setsar=1,setdar=16/9,源高度已在 ladder 内则只做 SAR/DAR 归一ffmpeg.go:28-37、ffmpeg.go:316-329
转码结果怎么落库worker 开一个事务:建 manifest File → 更新 VideoTranscode(completed、manifest_file_id、resolution 逗号拼接、codec 固定 "h264,aac")→ 建 VideoManifest(dash, profiles=JSONB) → 有封面则建 cover File → 更新 Video.status=published + duration + cover_file_id → Commit;成功后清 attempts 计数transcode/worker.go:106-191
转码失败怎么处理标 failed,INCR attempts:transcode:<id>(TTL 24h),≤7 次就 AddTranscodeRetry 进 Redis ZSet 延迟队列,handler 返回 nil 避免 Kafka 无限重投worker.go:92-103
播放链路怎么走拉 /videos/:id/manifest.mpd(api 从 MinIO 代理 MPD,Content-Type: application/dash+xml、Cache-Control: public, max-age=3600)→ dash.js 解析 → addRequestInterceptor 把 .m4s 请求改写成 base_url + filename 并加 SigV4 头 → 直连 MinIO;ABR 自动切档,清晰度列表来自 streamInitialized 后的 representationsVideo.go:721-769、useDashPlayer.ts:63-101、s3-signer.ts:68-111
为什么 MPD 由 api 代理、分片却直连MPD 很小且需要按 videoID 查库校验存在性,代理成本低;分片数量多、体积大,走 api 会成倍放大带宽,所以用「api 发 STS 凭证 + 浏览器直连 + 浏览器端签名」Video.go:735-769、Video.go:771-871

追问预警:「为什么不用 HLS / 为什么不用 GPU」——答:DASH + dash.js 是当时选的(协议字段 protocol 已留 hls 位置但代码只写 dash);libx264 纯 CPU 编码,选 preset 时对 1080p 以上用 slow,牺牲速度换码率;GPU(NVENC)是明确的下一步,因为转码时长直接决定用户等待。


④ gRPC 与 etcd 服务发现 ​

问题30 秒内的标准答案代码依据
为什么要把 ffmpeg 拆成 gRPC 服务三层收益:镜像(api/worker/auth 用的 vistack 镜像不含 ffmpeg,只有 vistack-transcoder 装)、故障隔离(ffmpeg 崩了不影响 api/worker)、扩容粒度(转码最吃 CPU,可单独 --scale transcoder=3)Dockerfile:38-65、compose.yml:141-154
gRPC 契约是什么单 RPC ProcessVideo:入参 bucket/object_key/output_prefix/cover_object_key/cover_time_seconds/quality_heights[];返回 duration_seconds/manifest_object_key+size/cover_object_key+size/profiles[height,resolution]。视频字节不走 gRPC,只走 MinIOproto/transcoder/v1/transcoder.proto
服务怎么注册到 etcdtranscoder 启动连 etcd,registry.Register 在 <prefix>/<uuid>(prefix 默认 /vistack/transcoders)写入自己的地址;租约 TTL 10s、每 3s KeepAliveOnce 续约,续约失败就重新 Grant + Puttranscoder/registry/etcd.go:11-61、transcoder/server.go:34-57
注册的地址是怎么算的advertiseAddr:优先 POD_IP(k8s 必须注入,否则会注册容器 IP)→ 本机第一个非回环 IPv4 → hostname → localhost,端口取监听端口(默认 50051)transcoder/server.go:75-112、deploy/k8s/transcoder.yaml
worker 怎么发现 transcoder自研 gRPC resolver:grpc.NewClient("etcd:///"+prefix, WithResolvers(discovery.NewEtcdBuilder(cli, prefix)), 默认 serviceConfig round_robin);resolver 启动先 Get prefix,再 Watch(WithPrefix) 变更即 UpdateState,实例下线自动从地址列表移除transcoder/client.go:24-55、discovery/etcd.go:24-82
为什么自研 resolver 而不用 k8s DNSk8s DNS 只能服务集群内且不带元数据;etcd 注册让 compose(非 k8s)也能发现,也为将来加 role/version/capacity 留了口子。代价:当前 value 只是裸地址字符串,resolver 没有健康检查与权重docs/specs/distributed-architecture.md:136-139
auth 服务也注册吗会:注册到 /vistack/auth(config.DefaultAuthPrefix,uuid 作 id)。api 侧 authclient 用同一个 etcd resolver 发现并 round_robin 调 GetUserInfos 批量查作者信息role/auth.go:60-79、authclient/client.go:24-53、config/config.go:142
为什么不用 gRPC 流式传视频视频几百 MB~几 GB:走对象存储能分片并发、断点续传、天然可重试;走 gRPC 流对内存、超时、重试都不友好。当前 gRPC 只传 KB 级元数据,25 分钟超时留给 ffmpeg 本身service.go:30-39、transcode/worker.go:72-88
etcd 挂了 / 没配会怎样配置 transcoder.use_etcd=false 或 etcd 端点为空时退化到静态 transcoder.addr(compose 里 transcoder:50051),单机照常跑;已在运行的 worker 地址列表停止更新(Watch 断线没有重连退避逻辑)transcoder/client.go:25-54、discovery/etcd.go:44-59
etcd 在项目里还做了什么领导选举保证 retry dispatcher + watchdog 全局单例:key /vistack/leaders/worker-singleton,默认 TTL 10s,竞选失败退避 3s,leader 租约丢失时 leadCtx 被 cancel 并重新竞选internal/core/leader/leader.go、role/worker.go:104-121

追问预警:「resolver 怎么处理 etcd 里出现死实例」——答:靠租约,进程被 kill 后 10s 内 key 消失;但要承认如果实例是假死(进程活着但不再续约成功),key 会消失而 gRPC 连接仍指向它,需要靠 gRPC 自身重连 + 调用超时兜底。


⑤ Kafka 与任务可靠性 ​

问题30 秒内的标准答案代码依据
用了哪些 topictranscode(转码)、delete_file(删除清理)、danmaku(弹幕落库)、comment_moderation(评论图片审核),定义在 internal/consts/Kafka.go,是裸字符串常量,没有版本号internal/consts/Kafka.go:5-10
生产者的可靠性配置segmentio/kafka-go:Async=false(同步发送,错误能被调用方拿到)、Balancer=LeastBytes、WriteTimeout/ReadTimeout 各 10sinternal/core/kafka.go:35-42
消费者并发模型每实例起 kafka.concurrency(默认 4)个同 group 的 reader goroutine;同一 partition 只会被组内一个 reader 持有,所以单 partition 内仍顺序处理、顺序提交(at-least-once);实际并发上限 = min(concurrency, 分区数),而 topic 只建了 1 分区,所以现在等于单消费者internal/core/kafka.go:119-140、kafka.go:108-112
为什么 CommitInterval = 0关掉自动提交,改成 handler 返回 nil 才手动 CommitMessages:失败的消息不提交 offset(会被重投),配合业务幂等实现 at-least-onceinternal/core/kafka.go:146-210
handler 返回 error 会重试吗不会,只记日志、不提交 offset。真正的重试靠业务层:转码失败进 Redis ZSet 延迟队列,超时任务由 watchdog 兜底kafka.go:192-199、transcode/retry.go
退避重试策略具体是什么指数退避,base 1 分钟,第 n 次延迟 2^(n-1) 分钟,上限 8 小时,再加 d/5 以内的随机抖动;最多 7 次,超过静默丢弃(无 DLQ)transcode/retry.go:21-34、worker.go:96-102
延迟队列为什么用 Redis ZSetZSet 的 score 天然就是「到期时间戳」:定时器每 5s ZRangeByScore -inf..now 取最多 100 条回投 Kafka,投递成功才 ZRem(失败留到下个周期),不需要额外中间件retry.go:36-81
为什么 dispatcher/watchdog 必须单例多副本 worker 同时扫同一个 ZSet 会把同一任务投多次(lease 能去重,但会产生无谓重复消费与放大);所以用 etcd 选举只让 leader 跑role/worker.go:77-122
watchdog 兜底哪两类任务① processing 且 updated_at 早于 15 分钟、且 lease 已不存在(说明 holder 死了)→ 重新入队,attempts > 7 就放弃;② pending 且超过 10 分钟(消息丢了没进 processing)→ 先触碰 updated_at 防下周期重复投递,再按 attempt=1 重投watchdog.go:22-69
消息投递失败怎么办api 侧同步发送并把错误当业务错误处理:标 transcode failed + AddTranscodeRetry(attempt=1) + 返回 500 让用户重试,避免任务永久停在 pendingVideo.go:423-435
幂等是怎么做的三处:① 转码 completed 跳过 + Redis lease SetNX;② 弹幕落库 clause.OnConflict{DoNothing}(主键是 snowflake ID);③ 点赞/收藏按 (video,user) 取净效果,DB 侧 OnConflict DoNothing / Delete,播放日志按事件 ID 去重worker.go:49-61、danmaku/worker.go:19-25、interaction/flusher.go:49-101
这套可靠性最薄弱的环节无 DLQ(7 次后静默丢,没有告警)、无 Outbox(DB 提交与发消息不是一个原子操作)、消息裸 JSON 无版本字段、Kafka 单 broker 且 1 分区 1 副本、dispatcher 自身若卡住会拖慢整体重试节奏docs/specs/distributed-architecture.md:83-88

追问预警:「消息丢了怎么办」——两层兜底:投递失败立即标 failed + 入 ZSet;即使连 ZSet 都没进,watchdog 会在 10 分钟后扫 pending 重投。要承认的是:如果 DB 事务提交成功但进程在发 Kafka 前被杀,这条任务只会在 10 分钟后被兜底,用户会看到 10 分钟的等待。


⑥ 缓存 / 限流 / 计数高并发 ​

问题30 秒内的标准答案代码依据
缓存三件套怎么落地的一个通用 cache.Cache 组件:穿透 = 空值哨兵 \x00NULL\x00(TTL 60s)+ 可选布隆;击穿 = singleflight 合并同进程并发 + Redis SetNX 互斥锁 + Lua owner-check 释放;雪崩 = TTL 在 [300,600]s 间随机internal/core/cache/cache.go:16-32,100-248、internal/core/cache.go:17-69
为什么「锁 + singleflight」要两层singleflight 只能挡本进程并发;多副本还得靠 Redis 锁。没抢到锁的实例不是直接回源,而是 50ms 轮询等最多 2s(lock_wait_ms=2000)拿别人写好的缓存,2s 仍无才自己回源cache.go:155-224
随机 TTL 怎么实现每次写缓存时在 [ttlMin, ttlMax] 随机取(timeutil.RandomRangeExpire),避免同批 key 同时过期打爆 DBcache.go:243-247
布隆过滤器怎么用的Redis bitmap + FNV-1a 双哈希、7 个位、1000 万位(约 1.25MB),元素是「视频详情缓存 key」vistack:video:info:<id>;有 :ready 标志位,未构建完时 Exists 降级返回 true(宁可回源不可误杀);api 启动异步全量 Build,新建视频时 Addcache/bloom.go:31-107、api/v1/video_cache.go:65-84
布隆能防什么、不能防什么只能挡「一定不存在」的 id(穿透);有假阳性会多回源一次,不会漏数据;删视频时不删除位(标准布隆不支持),所以已删 ID 仍可能被判「可能存在」bloom.go:66-79
缓存接在哪两个接口、怎么写失效GET /videos/:id/info(带 WithBloom)与 GET /videos/recommend(固定 TTL 300s);写路径 PUT /videos/:id、DELETE /videos/:id 显式 delete 这两个 keyVideo.go:513、Video.go:882、Video.go:486,715
限流为什么做了两种算法令牌桶(进程内 map,rate 10/s、burst 20)实现简单无网络开销,但多副本各算各的;滑动窗口(Redis ZSet + Lua 原子脚本,窗口 60s 限 100 次)跨实例共享窗口,代价是每请求一次 Lua 往返。默认走滑动窗口(配置可切)middlewares/ratelimit.go:24-49、ratelimit/token_bucket.go、ratelimit/sliding_window.go:16-85
滑动窗口的 Lua 做了什么原子地:ZREMRANGEBYSCORE 清窗口外 → ZCARD 计数 → 未超限则 ZADD(member 用 uuid,防止同毫秒多请求被 ZSet 合并)+ PEXPIRE;超限时返回窗口内最早记录到期时间用于算 Retry-Aftersliding_window.go:16-36
限流键是什么、挂在哪些路由键是用户 ID 字符串(前缀 vistack:ratelimit:);只挂在 AuthApiGroup(登录后接口),未登录流量不限流;Redis 报错时 fail-open 放行并打 Error 日志。响应带 X-RateLimit-Limit/Remaining/Reset,超限 429 + Retry-Afterrouters/router.go:32-41、middlewares/ratelimit.go:52-91
点赞/收藏/播放怎么抗高并发全走 Redis:点赞用 Set(SISMEMBER/SADD/SREM + SCARD 计数)、播放用 INCR;三个写入都用 Lua 把「状态变更 + 计数 + 榜单 ZINCRBY + 推事件」做成一次原子操作;异步 flusher 每 5s 批量 LPOP 最多 200 条事件落库interaction/interaction.go:73-169、interaction/flusher.go:136-177
异步落库怎么保证幂等、不丢事件带 snowflake ID 与时间戳;like/fav 按 (video,user) 取最后状态(净效果)写 DB,DB 侧 OnConflict DoNothing / Delete 幂等;播放日志按事件 ID 去重后批量插入;计数以 Redis 为权威回写 videos 冗余列flusher.go:49-133
榜单怎么实现两个 ZSet vistack:hot:play / vistack:hot:like,写入时 ZINCRBY,读取 ZREVRANGE 取 top N(默认榜单容量 50,接口 limit 默认 20),再按 id 批量查 DB 补标题封面interaction/keys.go、leaderboard.go:9-30、api/v1/social.go:180-229
计数读不到 Redis 怎么办回落读 videos 的 like_count/favorite_count/play_count 冗余列(GetVideoStats);视频列表用 enrichCounts 一次 pipeline 批量读三个计数,失败就不填(保持 0)api/v1/social.go:113-144,242-260
弹幕的三级缓存本地 LRU(1024 条、TTL 2s,key 是 videoID:start:end)→ Redis ZSet vistack:danmaku:<videoID>(ZRANGEBYSCORE 按时间轴)→ DB 回源;响应用 Cache-Control: public, max-age=5 供 CDN 缓存danmaku/danmaku.go:97-127、danmaku/local_cache.go
弹幕为什么本地 TTL 只有 2 秒弹幕要「发完立刻可见」:发送时写 Redis,本地缓存太旧会让用户看不到自己刚发的弹幕。2s / 1024 条是在「削 Redis 读」与「新鲜度」之间的折中danmaku.go:81-95、conf/app.toml:63-67

追问预警:「缓存和 DB 的一致性怎么保证」——答:写路径是「改 DB → 删缓存」(Cache-Aside),并发下仍有「读线程回填旧值」的窗口;当时的判断是可接受(视频元数据容忍秒级不一致);要主动给出改进方向:延迟双删、或订阅 binlog 失效。


⑦ 鉴权与安全 ​

问题30 秒内的标准答案代码依据
认证是怎么拆出去的注册/登录/改资料/JWKS 都在 auth 角色(HTTP :8081),用户查询走 gRPC :50052;api 侧只做 JWKS 本地验签,不持有私钥、不回调 auth 服务role/auth.go:33-118、auth/handler.go:33-49
为什么从 HS256 换成 RS256HS256 是共享密钥,任何拿到 secret 的服务都能伪造 token;RS256 私钥只在 auth,消费方拿公钥验签,边界清晰,还能靠 kid + JWKS 做密钥轮换pkg/auth/token_manager.go、pkg/auth/verifier.go、docs/specs/auth-iam-service/spec.md
api 侧验签流程TokenVerifier 按 header 的 kid 从内存缓存取 RSA 公钥;未命中会立刻强制刷新一次 JWKS(应对轮换后的第一个请求);后台每 1 小时自动刷新;只接受 RS256(WithValidMethods 防算法混淆)pkg/auth/verifier.go:44-94
JWT 里放了什么只有 user_id + 标准 exp/iss(issuer=vistack,jwt_expiration=3600s),没有角色/权限位——所以 api 侧做不了细粒度授权pkg/auth/claim.go:12-15、conf/app.toml:24-28
auth 服务内部路由怎么验签用 TokenManager(持私钥)直接验签,不走 JWKS 自引用,避免自己 HTTP 调自己role/auth.go:86、token_manager.go:58-74
密码怎么存bcrypt 哈希;注册要求密码 ≥ 6 位(binding:"required,min=6");响应里 password_hash 带 json:"-" 不序列化pkg/hashutil/bcrypt.go、auth/handler.go:53、entity/user/user.go
播放防盗链怎么做的MPD 由 api 代理下发(不暴露对象地址);m4s 分片用 STS 临时凭证:api 用 root 凭证 AssumeRole 生成只允许 s3:GetObject、资源限定 arn:aws:s3:::vistack/dash/<id>/* 的 policy,前端拿 accessKey/secretKey/sessionToken 在浏览器做 SigV4 签名直连 MinIOVideo.go:771-871
STS 凭证怎么缓存与刷新服务端按 video:sts:<id> 缓存 25 分钟(凭证本身 30 分钟过期);前端按 expiration 提前 2 分钟重新拉(时间无效则 10s 后重试)Video.go:786-853、VideoPlayer/index.vue:206-221
前端签名怎么实现的自己用 js-sha256 实现 AWS SigV4(canonical request / credential scope / signing key,payload hash 用 UNSIGNED-PAYLOAD,region 写死 us-east-1),挂在 dash.js 的 addRequestInterceptor 上,只对 .m4s 请求生效web-client/src/lib/s3-signer.ts:68-111、useDashPlayer.ts:69-81
目前有哪些安全缺口① transcoder gRPC 无鉴权(内网任何人可发起转码);② STS 用 root 凭证签;③ 上传只校验大小不校验 MIME/魔数(SVG 可能存储型 XSS);④ 登录接口无独立限流/账号锁定;⑤ RBAC 只有表结构没有执行点;⑥ k8s ConfigMap 里明文写凭证,且用的是已废弃的 jwt_secret 字段docs/specs/distributed-architecture.md:78-81、docs/review/backend-logic-review.md:42-53、deploy/k8s/configmap.yaml

追问预警:「JWT 无状态,怎么实现登出/踢人」——诚实答:当前没实现(无 token 黑名单、无 refresh token、无 jti),只能等 1 小时过期;改进方向是引入 jti + Redis 黑名单,或把 exp 缩短到 15 分钟配 refresh token。


⑧ 稳定性与工程化 ​

问题30 秒内的标准答案代码依据
优雅停机怎么做的三个长驻角色都 signal.NotifyContext 监听 SIGINT/SIGTERM:api 收到信号 srv.Shutdown 最多等 30s 排空在途请求(超时 os.Exit(1));worker 先停消费、再用 WaitGroup 等消费者把在途消息处理完(30s);auth 先 Shutdown HTTP 再 gs.GracefulStop();transcoder 直接 GracefulStop()role/api.go:130-153、role/worker.go:60-74、role/auth.go:99-118、transcoder/server.go:66-72
Kafka 消费者停机为什么要等因为 CommitInterval=0,正在处理的消息还没提交 offset,直接退出会导致重投(重复消费);所以用 consumerWG 等它处理完再退core/kafka.go:20-21,143-144,216-228
配置怎么加载Viper:-c 参数 > VISTACK_CONFIG_PATH > 默认 conf/app.local.toml;SetEnvPrefix("VISTACK") + .→_ 自动映射(如 VISTACK_DATABASE_HOST);WatchConfig 只打印变更日志不做热加载core/vipper.go:17-54、config/env.go
有几份配置、分别给谁app.toml(最全,本地)、app.local.toml(本地覆盖,node_id=1)、app.docker.toml(容器内服务名 + web_dir/admin_web_dir);k8s 期望 /app/conf/app.k8s.toml,仓库里没有这个文件(由 ConfigMap 现场生成)conf/*.toml、deploy/k8s/configmap.yaml
日志怎么做的zap Production 配置,level 从配置读(debug/warn/error,默认 info),时间 ISO8601;Gin 用 ginzap 记访问日志 + RecoveryWithZap 兜 panic;每请求注入 X-Request-ID(uuid)core/logger.go:14-38、core/server.go:12-25、middlewares/requestid.go
/health 检查了什么依次 DB Ping、MinIO ListBuckets(2s 超时)、Redis Ping(1s 超时),各返回 ok/error/skip;但恒返回 200,不区分 readiness/liveness,K8s 清单也没配探针api/v1/health.go:20-67、deploy/k8s/*.yaml
连接池怎么配的PG:max_idle_conns=10 / max_open_conns=100 / conn_max_lifetime=3600s;Redis:pool_size=10、读写超时 3s、拨号超时 5score/db.go:42-58、core/redis.go:22-30、conf/app.toml:15-17
主键怎么生成的snowflake:显式 node_id 优先,否则从 POD_IP(回退 hostname)做 FNV32a % 1024 派生(node_id<=0 时),避免多副本撞 ID;所有实体在 BeforeCreate 钩子里生成core/snowflake.go:14-42、各 entity/**/*.go
测试情况如何6 个测试文件、26 个 Test:web SPA 回退、cache(用 miniredis)、danmaku、interaction、ratelimit 两处;没有集成测试、没有压测、没有覆盖率门禁find . -name '*_test.go'、go.mod(miniredis v2.38.0)
镜像怎么构建的三阶段:① node:22-alpine 构建 web-client + web-admin(admin 注入 VITE_BASE=/admin/);② golang:1.26-alpine 编译单二进制(CGO_ENABLED=0、-trimpath -ldflags "-s -w");③ 两个运行镜像:vistack(alpine 3.20,不含 ffmpeg,非 root uid 10001)与 vistack-transcoder(装 ffmpeg)。api/worker/auth 共用 vistack 只换 commandDockerfile:1-65
网关怎么分流的Traefik 按路径 + priority:/api/v1/auth 或 /api/v1/user → auth:8081(20);/vistack → minio:9000(15);/api/v1 → api:8080(10);/ → api:8080(1,前端页面);dashboard 在 8082deploy/traefik/dynamic.yml、compose.yml:158-171
前端发布方式两条:① 默认内置进 api 镜像由 api 托管;② deploy/cdn-publish.sh 发到 Cloudflare Pages 或 S3(含 SPA 回退规则)Dockerfile:45-46、deploy/cdn-publish.sh、README:199-205

追问预警:「为什么 /health 恒返回 200」——因为它只做「报告状态」不做判定,探针若接上去会永远判定健康;正确做法是拆成 /healthz(进程存活,永远 200)与 /readyz(依赖就绪,失败返回 503)。


⑨ 项目不足与演进 ​

问题30 秒内的标准答案代码依据
最大的不足是什么所有中间件都是单点:Kafka 单 broker 且 topic 是 1 分区 1 副本(concurrency=4 实际只能有 1 个消费者)、etcd 单节点、PG/Redis/MinIO 单实例。架构「可水平扩展」,但部署上还没扩起来core/kafka.go:108-112、compose.yml:39-74、docs/specs/distributed-architecture.md:57-71
可观测性缺口只有 zap 结构化日志:无 Prometheus metrics(HTTP QPS/延迟、Kafka lag、队列深度、转码时长与成功率都看不到)、无 OpenTelemetry 链路(上传→Kafka→worker→gRPC→MinIO 串不起来)、无日志聚合docs/specs/distributed-architecture.md:73-76
消息可靠性缺口无 DLQ(重试 7 次后静默丢弃、无告警)、无 Outbox(CompleteVideoUpload 是事务提交后才发 Kafka,中间挂掉只能靠 watchdog 的 pending 10 分钟兜底)、消息裸 JSON 无版本字段docs/specs/distributed-architecture.md:83-88
重做的话最先改什么① topic 提到 8~16 分区 + 副本因子 ≥2,让 concurrency=4 真正生效;② 加 outbox + DLQ;③ 接 Prometheus/OTel + 拆 readiness;④ transcoder gRPC 加 mTLS;⑤ 上传校验文件魔数同上 + docs/specs/distributed-architecture.md:195-205
缓存还能怎么优化写路径「先改 DB 再删缓存」在极端并发下仍有旧值回填窗口 → 延迟双删或订阅 binlog;布隆不支持删除,删视频后仍可能判存在;推荐列表是全站单 key、无分页、无个性化api/v1/Video.go:878-925、docs/review/backend-logic-review.md:74
限流还能怎么改进令牌桶是进程内 map 且没有淘汰,key 多了会涨内存;只按用户 ID 限流,未覆盖未登录 IP 维度与登录暴力破解;可做多维度(用户+接口+IP)与分级限流ratelimit/token_bucket.go:39-72、docs/review/backend-logic-review.md:47-49
计数系统的一致性风险Redis 是权威、DB 是副本,Redis 丢数据 = 计数丢;flusher 只在 api 角色启动,api 挂掉期间事件堆在 Redis List(不丢但延迟落库),且没有对账任务interaction/flusher.go、role/api.go:85-94
数据库层面全是 GORM AutoMigrate,没有版本化迁移文件与回滚;无分库分表/读写分离;video_play_logs 每次播放一行,量大后需要归档;评论列表靠 (video_id, root_id, id) 索引兜migrations/migrate.go:66-83
直播呢README 和弹幕 spec 提到 live777(Rust SFU) 的 WebRTC 直播,但代码库里没有任何直播相关代码(无 RTMP/WHIP/WebRTC 集成,也没有推流密钥校验)——文档超前于实现,面试要主动说明grep -rni 'live777|webrtc|rtmp' internal web cmd 无结果;README:41-42
RBAC 呢表结构齐全(roles/authorities/role_authority/user_authority,迁移里都建了,注册时分配默认角色),但代码里没有任何授权判定点——README 说的「细粒度权限」目前是目标不是现状migrations/migrate.go:25-32、docs/specs/auth-iam-service/spec.md 背景段
还有哪些小坑值得主动说① core.ValidateConfig 现在是空实现(_ = cfg);② k8s ConfigMap 还在用已废弃的 jwt_secret,且缺 auth_service 段(会退化成 127.0.0.1:8081 的 JWKS URL);③ deploy/k8s/ 里没有 auth 的 Deployment;④ 上传只校验大小不校验类型;⑤ 前端分片重试无上限;⑥ InitMinioClient 每次启动重写 bucket policycore/validate.go:10-12、deploy/k8s/configmap.yaml、core/minio.go:151-159
评论的敏感词为什么不生效是一个真 bug:POST /admin/sensitive-words 只重建了 danmaku 服务的 AC 自动机(danmaku.go:163,171),而评论服务在启动时加载一次后就再没刷新过(role/api.go:114),审核 worker 里那个 service 甚至是空词表。所以新增敏感词对评论要等进程重启才生效internal/danmaku/danmaku.go:156-172、internal/comment/comment.go:69-80、core/message_queue/comment/worker.go:23-27
图片审核真的接了吗没有。PassthroughModerator.Review 永远返回 true,注入用的 SetModerator 全仓库没有任何调用点;所以带图评论会走 pending → Kafka → 立即自动通过。文字评论根本不进审核队列(只有带附件才置 pending),纯靠敏感词过滤internal/comment/moderation.go:21-26、internal/comment/comment.go:136-139
互动事件的丢数据风险flusher 用 LPopCount 弹出即删除:如果 applyEvents 写库失败,这批事件已经丢了(没有回塞、没有 DLQ)。所以"幂等可重试"只对"重放同一批"成立,对"丢批"不成立——这是我很想补的一点internal/interaction/flusher.go:23-46,136-153
删除 worker 重投安全吗不完全安全:它没有"已处理"标记,也不检查 video.status。如果 TX1 提交后 TX2 或 MinIO 删除失败,消息重投会再次对所有文件 ref_count - 1(这里还是无下限钳制的直接减法),引用计数会二次失真。TX2 里那 3 条 Delete 的返回值也没检查core/message_queue/video/delete_video_worker.go:47-54,99-121,195-199
/admin/sensitive-words 有权限控制吗只有「登录 + 限流」两层,没有角色判定——任意登录用户都能改敏感词表。根因还是 RBAC 只有表没有执行点internal/routers/api/v1/danmaku.go:20-33、internal/routers/router.go:32-41
Redis 里的计数和队列为什么不会爆会爆:vistack:like:* / vistack:fav:* / vistack:play:* / 两个榜单 ZSet / 待落库 List / vistack:danmaku:* 全部没有设置 TTL,只有评论列表缓存有 60s。后续要么给榜单加时间窗,要么定期归档+裁剪internal/interaction/keys.go、internal/danmaku/keys.go(grep Expire|LTrim|ZRemRange 无命中)

追问预警:「你自己最不满意哪一点」——推荐答「可观测性」:因为排障时最痛苦的不是没做某个功能,而是出了问题看不到(Kafka 有没有堆积、转码成功率多少、这条视频卡在哪个环节),这是当时优先级的失误。


数字速记表 ​

以代码为准(逐个核对过)。凡是配置项都能被 conf/*.toml 覆盖,表里给的是代码默认值 / 配置文件默认值。

上传 / 对象存储 ​

参数值出处
前端分片大小8 MiB(8*1024*1024)web-client/src/views/Creator/index.vue:38
前端哈希分块2 MiBindex.vue:86
上传并发6index.vue:192
前端分片重试等待2000 ms(无最大次数)index.vue:243
预签名分片 URL 有效期1 小时api/v1/Video.go:250
上传会话 Redis TTL24 小时(key upload_session:<user>:<hash>)Video.go:220
ListObjectParts maxParts10000Video.go:298
小文件(头像/封面/评论图)上限5 MiBapi/v1/File.go:33,78,119
MinIO 生命周期:status=replaced1 天后删除core/minio.go:162-176
公开前缀avatars/*、covers/*core/minio.go:139-149
MinIO Regionus-east-1(写死)core/minio.go:59,77,98
原始视频对象前缀raw/<uuid><ext>Video.go:196-198
DASH 产物前缀dash/<video_id>/transcode/worker.go:75
封面对象 keycovers/<video_id>.jpgtranscode/worker.go:76

转码 / DASH ​

参数值出处
gRPC 转码调用超时25 分钟transcode/worker.go:20
转码 lease(Redis SetNX TTL)30 分钟worker.go:58
重试上限7 次worker.go:99、watchdog.go:46
attempts 计数 TTL24 小时worker.go:98
退避基数 / 上限 / 抖动1 分钟 / 8 小时 / d/5 内随机transcode/retry.go:21-34
重试派发周期 / 单次批量5 秒 / 100 条retry.go:47,59
watchdog 扫描周期1 分钟watchdog.go:15
processing 超时阈值15 分钟watchdog.go:23
pending 兜底阈值10 分钟watchdog.go:54
DASH 分片时长4 秒(-seg_duration 4)transcoder/ffmpeg.go:360
GOP / 帧率120 帧 / 30 fps CFRffmpeg.go:344-347
场景切换检出关闭(-sc_threshold 0)ffmpeg.go:348
音频AAC 192k / 48kHz / 2chffmpeg.go:352-355
ABR 档位240p 500k / 360p 1M / 480p 2M / 720p 4M / 1080p 8M / 1440p 16M / 2160p 35Mffmpeg.go:39-47
封面抽帧时间指定值;否则 >10s → 5s,>2s → duration/2,其余 1.0stranscoder/service.go:51-59
分片命名init-$RepresentationID$.m4s / chunk-$RepresentationID$-$Number%05d$.m4sffmpeg.go:363-364
MPD 响应缓存Cache-Control: public, max-age=3600Video.go:759

Kafka ​

参数值出处
Topic 数4(transcode / delete_file / danmaku / comment_moderation)internal/consts/Kafka.go
topic 分区 / 副本1 / 1(EnsureTopic 建 topic 时写死)core/kafka.go:108-112
生产者模式同步(Async: false)、LeastBytescore/kafka.go:37-41
生产超时WriteTimeout / ReadTimeout 各 10score/kafka.go:39-40
消费者并发concurrency = 4(配置),实际上限 = min(4, 分区数) = 1conf/app.toml:80、core/kafka.go:131-139
Fetch 批量MinBytes 10KB / MaxBytes 10MBcore/kafka.go:150-151
提交方式CommitInterval = 0,handler 成功才手动提交core/kafka.go:152,201-209
消费组vistack-consumer-groupconf/app.toml:79

Redis 键与 TTL ​

用途key / TTL出处
视频详情缓存vistack:video:info:<id>,TTL 300–600s 随机api/v1/video_cache.go:20、core/cache.go:23-27
空值缓存哨兵 \x00NULL\x00,TTL 60score/cache/cache.go:16、core/cache.go:29-31
缓存互斥锁<key>:lock,LockTTL 5s,等待上限 2000ms(50ms 轮询)cache.go:226-234,215-224
推荐列表缓存vistack:video:recommend,TTL 300svideo_cache.go:16,24-30
视频布隆vistack:video:bloom(+:ready),1000 万位 / 7 哈希core/cache.go:41-46,62-66
转码 leaselease:transcode:<id> 30 分钟transcode/worker.go:57-58
转码 attemptsattempts:transcode:<id> 24 小时worker.go:96-98
转码重试队列transcode:retry:zset(score = 到期 unix 秒)retry.go:17,39
上传会话upload_session:<user>:<hash> 24 小时Video.go:184,220
STS 凭证缓存video:sts:<id> 25 分钟(凭证 30 分钟)Video.go:786,837-851
限流窗口vistack:ratelimit:<userID>,60s / 100 次sliding_window.go:12、conf/app.toml:54-55
点赞/收藏/播放vistack:like:<id> / vistack:fav:<id> / vistack:play:<id>interaction/keys.go
榜单vistack:hot:play / vistack:hot:like(容量 50)interaction/keys.go、conf/app.toml:61
互动事件队列vistack:interaction:pending,5s 批量 200interaction/keys.go、conf/app.toml:59-60
弹幕 ZSetvistack:danmaku:<video_id>(score = time_offset)danmaku/keys.go
弹幕评论数vistack:comment:count:<video_id> / 列表缓存 vistack:comment:list:<video_id> 60scomment/keys.go、comment/comment.go:332
评论点赞队列vistack:comment:like:pending,5s 批量 200comment/counter.go:16、conf/app.toml:71-72

etcd / JWT / 网络 ​

参数值出处
服务注册租约 TTL10 秒transcoder/registry/etcd.go:12
保活周期3 秒(KeepAliveOnce,失败重 Grant)registry/etcd.go:13,43-60
resolver 拉取超时3 秒discovery/etcd.go:62
etcd 拨号超时5 秒role/worker.go:94 等多处
领导选举 key / TTL/vistack/leaders/worker-singleton / 10s(竞选失败退避 3s)core/leader/leader.go:12-18
注册前缀transcoder /vistack/transcoders,auth /vistack/authconf/app.toml:91、config/config.go:142
JWT 算法 / kid / issuerRS256 / vistack-rs256 / vistackconf/app.toml:25-26
JWT 有效期3600 秒conf/app.toml:27
JWKS 路径 / 自动刷新/.well-known/jwks.json / 每 1 小时conf/app.toml:28、role/api.go:50
端口api 8080、auth HTTP 8081、auth gRPC 50052、transcoder gRPC 50051conf/app.toml:20,95,100-101
优雅停机等待api / worker / auth 各 30 秒role/api.go:30、worker.go:25、auth.go:31
MinIO 健康检查超时 / Redis2s / 1sapi/v1/health.go:40,54
PG 连接池idle 10 / open 100 / lifetime 3600sconf/app.toml:15-17
Redis 连接池pool 10 / 读写超时 3s / 拨号 5sconf/app.toml:35、core/redis.go:27-29
Snowflake node_id 派生FNV32a(实例标识) % 1024core/snowflake.go:28-42

前端 / 播放器 ​

参数值出处
HTTP 客户端超时15 秒(axios timeout: 15000)web/ui/src/api/axios.ts:27
API BaseVITE_API_BASE,缺省 /api;开发环境 http://localhost/api/v1web/ui/src/api/axios.ts:8、web-client/.env.development
Token 存放localStorage['token'],请求头自动加 Bearerweb/ui/src/api/axios.ts:10-22,37-43
STS 提前刷新过期前 2 分钟;时间无效则 10 秒后重试VideoPlayer/index.vue:206-221
前端 SigV4 regionus-east-1useDashPlayer.ts:25
签名 payload hashUNSIGNED-PAYLOADlib/s3-signer.ts:76
签名生效范围仅 URL 以 .m4s 结尾的请求useDashPlayer.ts:48-61
弹幕拉取窗口start=0,end=max(60, duration),duration 缺失按 300 秒VideoPlayer/index.vue:104-112
弹幕引擎:固定弹幕停留 / 滚动间隔 / 队列上限5 秒 / 32 px / 400 条danmaku/useDanmaku.ts:32-34
倍速档位 / 长按倍速[0.5, 1, 1.25, 1.5, 2] / 1.5×(按住 500ms 触发)DashPlayer.vue:80,163,226
键盘/右键快进步长5 秒(音量步长 0.05)DashPlayer.vue:187,202,213
评论分页 / 回复分页 / 相关推荐20 / 50 / 6 条comment/CommentSection.vue:23,38,56、VideoPlayer/index.vue:136
封面裁剪画布1280×720、16:9、JPEG 质量 0.9views/Creator/index.vue:452,473-484

业务限额 ​

参数值出处
推荐列表条数20(无分页)api/v1/Video.go:888
创作中心列表默认 10、上限 100Video.go:589-594
评论分页默认 20、上限 100;首屏内嵌 2 条回复comment/comment.go:201-202、api/v1/comment.go:117
评论附件上限9 个comment/attachment.go:14
弹幕拉取默认区间start=0、end=60 秒api/v1/danmaku.go:88-92
榜单容量 / 接口默认50 / 20conf/app.toml:61、api/v1/social.go:186
弹幕本地缓存1024 条 / TTL 2 秒conf/app.toml:65-66
弹幕响应缓存头max-age=5api/v1/danmaku.go:101
限流滑动窗口 60 秒 / 100 次;令牌桶 10/s、burst 20conf/app.toml:51-55
k8s 副本数api 1 / worker 1 / transcoder 2 / etcd 1deploy/k8s/*.yaml
单测规模6 个测试文件、26 个 Test仓库快照

30 秒版自我介绍(项目段) ​

背到能自然说出来,不要像念稿。

「我最近做的项目叫 Vistack,一个 Go 写的云原生视频点播平台。它用单二进制多角色的设计,通过环境变量把同一个镜像拆成 api、worker、transcoder、auth 四个可独立扩容的角色:api 只做 HTTP 和预签名直传,worker 消费 Kafka 编排任务,transcoder 把 FFmpeg 关在独立容器里通过 gRPC 提供服务,auth 单独持有 RSA 私钥签 JWT 并对外发 JWKS。最核心的那条链路是——浏览器分片直传 MinIO,Kafka 解耦转码,远程 transcoder 出 DASH 多档 ABR,播放时用 STS 临时凭证防盗链。我个人负责了整个后端的架构拆分层和转码、缓存、限流这几块。」


3 分钟版项目口述稿 ​

结构固定:一句话架构 → 4 个技术亮点 → 1 个量化结果 → 1 个不足与改进。每段都可以单独抽出来答追问。

【第 0 段】电梯稿(15 秒,任何场合都能用) ​

「Vistack 是一个 Go + Kubernetes 友好的云原生视频点播平台,核心是把「上传 → 转码 → 播放」这条链路做成可水平扩展的分布式系统:分片直传对象存储、Kafka 解耦异步任务、gRPC 远程转码、etcd 服务发现,另外配了一套高并发应用层能力——Redis 缓存三件套、分布式限流、点赞播放计数。」

【第 1 段】架构一句话(20 秒) ​

「架构上我用单二进制 + 角色分发:main.go 读 VISTACK_ROLE,分发出 api / worker / transcoder / auth / migrate 五个角色。这样一套代码、一个镜像,compose 里 api、worker、auth 共用同一个 vistack:latest 镜像只改启动参数;但角色之间的依赖是硬隔离的——transcoder 只初始化 MinIO,不碰数据库不碰 Redis,所以它可以无状态地随便扩副本。」

【第 2 段】亮点一:上传链路(30 秒) ​

「上传我没让流量过 API。前端用 SparkMD5 按 2MB 分块算出整文件 MD5,先调 /videos/upload/init:服务端先用这个 hash 查 files 表做秒传,一秒命中就直接复用物理文件、ref_count+1、建一条新的转码任务;没命中就调 MinIO NewMultipartUpload 拿 uploadId。之后前端按 8MB 分片、6 并发,每片先去 /upload/sign 换一个 1 小时的预签名 URL,然后直接 PUT 到 MinIO,ETag 自己收集。断点续传是两层:init 期用 Redis 缓存 24 小时的 upload 会话,拿到 uploadId 之后再用 ListObjectParts 列出已传分片只补缺失的。最后 /upload/complete 合并对象,并且在一个事务里建 Video、File、VideoSource、VideoTranscode 四条记录,再发 Kafka。」

【第 3 段】亮点二:远程转码 + 任务可靠性(35 秒) ​

「转码这块我把 FFmpeg 从主进程彻底挪出去了:api 和 worker 的镜像不含 ffmpeg,只有 transcoder 镜像装。worker 消费 transcode topic 后,先做幂等(已完成就跳过,再抢一把 Redis 租约 SetNX 30 分钟),然后通过 gRPC 调 ProcessVideo,25 分钟超时;transcoder 收到参数后从 MinIO 下载原片、ffprobe 探时长、抽封面帧、按源分辨率选 ABR 档位做 DASH 转码(4 秒一片、GOP 120 帧对齐)、再把产物全量写回 MinIO;worker 拿到结果在一个事务里写 manifest、transcode、video 三张表,视频状态推到 published。可靠性是三层兜底:失败退避重试(Redis ZSet 延迟队列,1 分钟起步指数退避、上限 8 小时、最多 7 次)、watchdog(每分钟扫一次:processing 超 15 分钟且租约已释放的重新入队,pending 超 10 分钟的直接重投)、以及etcd 领导选举保证重试派发器和 watchdog 全局只有一个实例在跑。」

【第 4 段】亮点三:高并发应用层(30 秒) ​

「应用层我做了三件事。第一是缓存三件套:写成一个通用组件——穿透用空值哨兵加 Redis 布隆过滤器(1000 万位、7 个哈希),击穿用 singleflight 加 Redis 互斥锁(释放锁用 Lua 校验持有者),雪崩用 300 到 600 秒的随机 TTL。第二是分布式限流:登录后接口按用户 ID 限流,实现了令牌桶和 Redis Lua 滑动窗口两种算法,默认滑动窗口 60 秒 100 次,超限返回 429 加 Retry-After,Redis 挂了 fail-open。第三是计数系统:点赞收藏播放全走 Redis,用 Lua 把「状态变更 + 计数 + 榜单 + 推事件」做成一次原子操作,后台每 5 秒批量拉 200 条事件异步落库,DB 侧用净效果和 OnConflict DoNothing 保证幂等,榜单是 ZSet。」

【第 5 段】亮点四:认证与防盗链(25 秒) ​

「认证我拆成了独立服务:auth 持有 RSA 私钥签 RS256 JWT,对外暴露 JWKS;api 侧不持私钥,拉 JWKS 本地验签,kid 未命中会强制刷新一次以支持密钥轮换,后台每小时自动刷新。播放防盗链是两层:MPD 清单由 api 代理下发(同时校验视频存在),m4s 分片则走 STS:api 用 AssumeRole 生成一个只允许读 dash/<id>/* 的临时凭证,缓存在 Redis 25 分钟,前端提前 2 分钟刷新,然后在浏览器里用自己实现的 SigV4 给每个分片请求签名直连 MinIO——这样分片流量完全不经过应用服务器。」

【第 6 段】量化结果(20 秒,必须记住这几个数) ​

「几个能说清的量:8MB 分片 / 6 并发直传,服务端零流量转发;转码4 秒一片、GOP 120 帧 = 4 秒严格对齐,保证任意片可独立起播和切档;7×24 小时的任务重试窗口(退避上限 8 小时),watchdog 15 分钟 / 10 分钟双阈值兜底;服务注册租约 10 秒、保活 3 秒,leader 挂掉无主窗口约 10 秒;缓存 TTL 300–600 秒随机、空值 60 秒;限流 60 秒 100 次;后端 6 个测试文件 26 个用例,单二进制一套代码撑起 5 个角色的部署形态。」

⚠️ 只有这些是代码里真实存在的数字,不要说 QPS、不要说压测结果、不要说缓存命中率——仓库里没有压测数据。

【第 7 段】不足与改进(25 秒,一定要说,而且要具体) ​

「最大的不足是可观测性和真正的水平扩展。一是所有中间件都还是单点:Kafka 单 broker,而且我建 topic 时写死了 1 分区 1 副本,导致 concurrency=4 的消费者配置实际上只能跑起一个消费者,这是我很明确的优化点——提到 8 到 16 分区并把副本因子做到 2 以上。二是没有任何 metrics 和链路追踪,/health 恒返回 200 且不区分 readiness,K8s 清单里连探针都没配,出问题只能翻日志。三是消息可靠性上还缺 DLQ 和 Outbox:重试 7 次之后是静默丢弃、没有告警,而上传完成后的 Kafka 投递是在事务提交之后,中间挂掉只能靠 watchdog 的 10 分钟兜底。如果继续做,我会按「分区扩容 → Outbox+DLQ → Prometheus/OTel + 探针 → transcoder gRPC 上 mTLS」这个顺序推进。」


反问面试官的问题 ​

原则:问「团队怎么做、有什么取舍」,不要问「你们用什么技术栈」(README 能查到的别问)。挑 2~3 个在合适的时机问。

  1. 关于可靠性标准:咱们线上对「消息不丢」的要求到什么级别?比如是否已经有 Outbox / DLQ / 对账这套机制,还是更依赖幂等 + 定时兜底?我想知道在这种团队里,可靠性投入一般是按什么标准决策的。
  2. 关于可观测性:团队现在排障主要靠什么?是已经有完整的 metrics + trace + 日志平台,还是主要靠日志和人工排查?我很想了解你们衡量服务健康度的核心指标是哪几个(延迟、错误率、还是队列积压这类业务指标)。
  3. 关于转码与成本:视频转码这块是在自建 GPU/CPU 集群,还是用云厂商的媒体处理服务?如果是自建,成本和质量之间你们怎么权衡(码率阶梯、是否上 AV1/HEVC、是否按热度分级转码)?
  4. 关于工程节奏:咱们团队的迭代节奏是怎样的——是 spec 先行、评审后再写码,还是更偏快速迭代?我上一段经历里是按「spec → plan → task → checklist」走的,想看下哪种方式在这里更合适。
  5. 关于这个岗位的期待:入职后前三个月,你们最希望我解决的是哪一类问题——是把现有系统的稳定性/可观测性补齐,还是偏新业务功能的快速交付?我想把精力优先投在最被需要的地方。

持续学习,持续构建。