10 · 速答题库与背诵卡(Vistack)
定位:面试前一晚 60 分钟过一遍的速答手册。每行一个问题 + 30 秒内能说完的标准答案 + 代码依据。 事实纪律:所有数字与行为都来自本仓库代码(写作时快照)。凡是代码里没有的,写「需确认」;不确定的说法不要背。 用法:第一遍只看中间一列;答不上来的看第三列去翻代码;最后两节(数字速记表 + 口述稿)必须背到脱口而出。
目录
- ① 架构与三角色拆分
- ② 上传与对象存储
- ③ 转码与 DASH/ABR
- ④ gRPC 与 etcd 服务发现
- ⑤ Kafka 与任务可靠性
- ⑥ 缓存 / 限流 / 计数高并发
- ⑦ 鉴权与安全
- ⑧ 稳定性与工程化
- ⑨ 项目不足与演进
- 数字速记表
- 30 秒版自我介绍(项目段)
- 3 分钟版项目口述稿
- 反问面试官的问题
① 架构与三角色拆分
| 问题 | 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 即可扩容,代价是编排与一致性全压在 worker | internal/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 EXISTS | internal/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-admin | internal/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 transcode | web-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_id | Video.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 释放 lease | transcode/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×2 | ffmpeg.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.mpd | ffmpeg.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 后的 representations | Video.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,只走 MinIO | proto/transcoder/v1/transcoder.proto |
| 服务怎么注册到 etcd | transcoder 启动连 etcd,registry.Register 在 <prefix>/<uuid>(prefix 默认 /vistack/transcoders)写入自己的地址;租约 TTL 10s、每 3s KeepAliveOnce 续约,续约失败就重新 Grant + Put | transcoder/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 DNS | k8s 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 秒内的标准答案 | 代码依据 |
|---|---|---|
| 用了哪些 topic | transcode(转码)、delete_file(删除清理)、danmaku(弹幕落库)、comment_moderation(评论图片审核),定义在 internal/consts/Kafka.go,是裸字符串常量,没有版本号 | internal/consts/Kafka.go:5-10 |
| 生产者的可靠性配置 | segmentio/kafka-go:Async=false(同步发送,错误能被调用方拿到)、Balancer=LeastBytes、WriteTimeout/ReadTimeout 各 10s | internal/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-once | internal/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 ZSet | ZSet 的 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 让用户重试,避免任务永久停在 pending | Video.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 同时过期打爆 DB | cache.go:243-247 |
| 布隆过滤器怎么用的 | Redis bitmap + FNV-1a 双哈希、7 个位、1000 万位(约 1.25MB),元素是「视频详情缓存 key」vistack:video:info:<id>;有 :ready 标志位,未构建完时 Exists 降级返回 true(宁可回源不可误杀);api 启动异步全量 Build,新建视频时 Add | cache/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 这两个 key | Video.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-After | sliding_window.go:16-36 |
| 限流键是什么、挂在哪些路由 | 键是用户 ID 字符串(前缀 vistack:ratelimit:);只挂在 AuthApiGroup(登录后接口),未登录流量不限流;Redis 报错时 fail-open 放行并打 Error 日志。响应带 X-RateLimit-Limit/Remaining/Reset,超限 429 + Retry-After | routers/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 换成 RS256 | HS256 是共享密钥,任何拿到 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 签名直连 MinIO | Video.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、拨号超时 5s | core/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 只换 command | Dockerfile: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 在 8082 | deploy/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 policy | core/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 MiB | index.vue:86 |
| 上传并发 | 6 | index.vue:192 |
| 前端分片重试等待 | 2000 ms(无最大次数) | index.vue:243 |
| 预签名分片 URL 有效期 | 1 小时 | api/v1/Video.go:250 |
| 上传会话 Redis TTL | 24 小时(key upload_session:<user>:<hash>) | Video.go:220 |
| ListObjectParts maxParts | 10000 | Video.go:298 |
| 小文件(头像/封面/评论图)上限 | 5 MiB | api/v1/File.go:33,78,119 |
MinIO 生命周期:status=replaced | 1 天后删除 | core/minio.go:162-176 |
| 公开前缀 | avatars/*、covers/* | core/minio.go:139-149 |
| MinIO Region | us-east-1(写死) | core/minio.go:59,77,98 |
| 原始视频对象前缀 | raw/<uuid><ext> | Video.go:196-198 |
| DASH 产物前缀 | dash/<video_id>/ | transcode/worker.go:75 |
| 封面对象 key | covers/<video_id>.jpg | transcode/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 计数 TTL | 24 小时 | 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 CFR | ffmpeg.go:344-347 |
| 场景切换检出 | 关闭(-sc_threshold 0) | ffmpeg.go:348 |
| 音频 | AAC 192k / 48kHz / 2ch | ffmpeg.go:352-355 |
| ABR 档位 | 240p 500k / 360p 1M / 480p 2M / 720p 4M / 1080p 8M / 1440p 16M / 2160p 35M | ffmpeg.go:39-47 |
| 封面抽帧时间 | 指定值;否则 >10s → 5s,>2s → duration/2,其余 1.0s | transcoder/service.go:51-59 |
| 分片命名 | init-$RepresentationID$.m4s / chunk-$RepresentationID$-$Number%05d$.m4s | ffmpeg.go:363-364 |
| MPD 响应缓存 | Cache-Control: public, max-age=3600 | Video.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)、LeastBytes | core/kafka.go:37-41 |
| 生产超时 | WriteTimeout / ReadTimeout 各 10s | core/kafka.go:39-40 |
| 消费者并发 | concurrency = 4(配置),实际上限 = min(4, 分区数) = 1 | conf/app.toml:80、core/kafka.go:131-139 |
| Fetch 批量 | MinBytes 10KB / MaxBytes 10MB | core/kafka.go:150-151 |
| 提交方式 | CommitInterval = 0,handler 成功才手动提交 | core/kafka.go:152,201-209 |
| 消费组 | vistack-consumer-group | conf/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 60s | core/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 300s | video_cache.go:16,24-30 |
| 视频布隆 | vistack:video:bloom(+:ready),1000 万位 / 7 哈希 | core/cache.go:41-46,62-66 |
| 转码 lease | lease:transcode:<id> 30 分钟 | transcode/worker.go:57-58 |
| 转码 attempts | attempts: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 批量 200 | interaction/keys.go、conf/app.toml:59-60 |
| 弹幕 ZSet | vistack:danmaku:<video_id>(score = time_offset) | danmaku/keys.go |
| 弹幕评论数 | vistack:comment:count:<video_id> / 列表缓存 vistack:comment:list:<video_id> 60s | comment/keys.go、comment/comment.go:332 |
| 评论点赞队列 | vistack:comment:like:pending,5s 批量 200 | comment/counter.go:16、conf/app.toml:71-72 |
etcd / JWT / 网络
| 参数 | 值 | 出处 |
|---|---|---|
| 服务注册租约 TTL | 10 秒 | 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/auth | conf/app.toml:91、config/config.go:142 |
| JWT 算法 / kid / issuer | RS256 / vistack-rs256 / vistack | conf/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 50051 | conf/app.toml:20,95,100-101 |
| 优雅停机等待 | api / worker / auth 各 30 秒 | role/api.go:30、worker.go:25、auth.go:31 |
| MinIO 健康检查超时 / Redis | 2s / 1s | api/v1/health.go:40,54 |
| PG 连接池 | idle 10 / open 100 / lifetime 3600s | conf/app.toml:15-17 |
| Redis 连接池 | pool 10 / 读写超时 3s / 拨号 5s | conf/app.toml:35、core/redis.go:27-29 |
| Snowflake node_id 派生 | FNV32a(实例标识) % 1024 | core/snowflake.go:28-42 |
前端 / 播放器
| 参数 | 值 | 出处 |
|---|---|---|
| HTTP 客户端超时 | 15 秒(axios timeout: 15000) | web/ui/src/api/axios.ts:27 |
| API Base | VITE_API_BASE,缺省 /api;开发环境 http://localhost/api/v1 | web/ui/src/api/axios.ts:8、web-client/.env.development |
| Token 存放 | localStorage['token'],请求头自动加 Bearer | web/ui/src/api/axios.ts:10-22,37-43 |
| STS 提前刷新 | 过期前 2 分钟;时间无效则 10 秒后重试 | VideoPlayer/index.vue:206-221 |
| 前端 SigV4 region | us-east-1 | useDashPlayer.ts:25 |
| 签名 payload hash | UNSIGNED-PAYLOAD | lib/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.9 | views/Creator/index.vue:452,473-484 |
业务限额
| 参数 | 值 | 出处 |
|---|---|---|
| 推荐列表条数 | 20(无分页) | api/v1/Video.go:888 |
| 创作中心列表 | 默认 10、上限 100 | Video.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 / 20 | conf/app.toml:61、api/v1/social.go:186 |
| 弹幕本地缓存 | 1024 条 / TTL 2 秒 | conf/app.toml:65-66 |
| 弹幕响应缓存头 | max-age=5 | api/v1/danmaku.go:101 |
| 限流 | 滑动窗口 60 秒 / 100 次;令牌桶 10/s、burst 20 | conf/app.toml:51-55 |
| k8s 副本数 | api 1 / worker 1 / transcoder 2 / etcd 1 | deploy/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 个在合适的时机问。
- 关于可靠性标准:咱们线上对「消息不丢」的要求到什么级别?比如是否已经有 Outbox / DLQ / 对账这套机制,还是更依赖幂等 + 定时兜底?我想知道在这种团队里,可靠性投入一般是按什么标准决策的。
- 关于可观测性:团队现在排障主要靠什么?是已经有完整的 metrics + trace + 日志平台,还是主要靠日志和人工排查?我很想了解你们衡量服务健康度的核心指标是哪几个(延迟、错误率、还是队列积压这类业务指标)。
- 关于转码与成本:视频转码这块是在自建 GPU/CPU 集群,还是用云厂商的媒体处理服务?如果是自建,成本和质量之间你们怎么权衡(码率阶梯、是否上 AV1/HEVC、是否按热度分级转码)?
- 关于工程节奏:咱们团队的迭代节奏是怎样的——是 spec 先行、评审后再写码,还是更偏快速迭代?我上一段经历里是按「spec → plan → task → checklist」走的,想看下哪种方式在这里更合适。
- 关于这个岗位的期待:入职后前三个月,你们最希望我解决的是哪一类问题——是把现有系统的稳定性/可观测性补齐,还是偏新业务功能的快速交付?我想把精力优先投在最被需要的地方。