跳转至

自用项目模拟面经50问学习

约 27236 个字 预计阅读时间 136 分钟

自用项目模拟面经50问学习

项目github地址

VidFlow: https://github.com/foorgange/VidFlow

LifeMate: https://github.com/foorgange/LifeMate

怎么用这份文档:先遮住解析,只读问题,自己试着用「口语化解答示例」的调子讲一遍(校招生说话,短句、口语、会停顿,别背书);讲不顺就照抄示例;然后再读「详细解析」把原理吃透,最后合上文档再讲一遍。
红线(先说三遍):① 项目是你改的别人的(基于开源项目改造/重构),面试主动说清楚,配合 AI Coding 辅助,别讲成从零原创;② 不报无法复现的数字,50ms 只能说成「任务受理接口」的响应时间,上传本身是分钟级;③ 不把本地 Demo 说成大规模生产验证。


一、开场与项目总览(2 问)

Q1. 请先做个自我介绍

简历关键词:大学、软件工程、后端开发、

【口语化解答示例】

面试官您好,我叫xx,xx大学软件工程专业,2028 届(简历按 2028.07 毕业口径)。我的技术方向是 Java 后端,主要技术栈是 Spring Boot、MySQL、Redis、RocketMQ 这一套。大学期间参加了一些算法竞赛,拿过ACM省赛铜牌、蓝桥杯全国总决赛三等奖。项目方面有两个比较完整的:一个是 VidFlow,一个 AI 视频解析与智能总结平台;另一个是 LifeMate,一个本地生活服务平台,重点做了秒杀场景的高并发优化。两个项目都是我自己花时间做出来、推到 GitHub 上的,日常开发也大量借助 Claude Code 这类 AI 编程工具。我目前人在xx,一周内可以到岗。

【详细解析】
自我介绍要控制在 1 分钟左右,逻辑是:我是谁(学校+专业)→ 我擅长什么(技术栈关键词,和岗位 JD 对齐)→ 我有什么证明(竞赛+项目)→ 我现在什么状态(能入职)。三个要点:

  1. 关键词要背下来:Spring Boot、MySQL、Redis、RocketMQ 这些是简历上的词,面试官会顺着你报的关键词往下问,你报什么他就问什么,所以只报你有把握的。
  2. 项目一句话讲清"是什么":VidFlow 是"AI 视频解析平台",LifeMate 是"本地生活服务+秒杀优化"——每个项目备好一句"做了什么、用了什么、解决了什么"。
  3. AI 编程工具主动提:现在校招面试官普遍会问 AI 辅助编程,你主动说"我用 Claude Code/Codex",显得真实、不藏着掖着(简历专业技能里也写了)。
  4. 竞赛别展开,除非对方追问;学历、可入职时间这种事实信息简短带过即可。

Q2. 挑一个项目详细讲讲:它是干什么的?你负责什么?难点在哪?

简历关键词:VidFlow、AI视频解析、Spring Boot、RocketMQ、LangChain4j

【口语化解答示例】

我讲一下 VidFlow。它是一个长视频内容理解平台:用户上传课程、会议、操作录屏这种一两个小时的视频,提出"帮我生成复习笔记"“提取操作步骤"这样的目标,系统会把视频的语音转成文字、关键画面识别出文字,再围绕目标找相关片段,最后用 Agent 生成带时间戳证据的结构化总结,用户还能继续追问。这个项目我是在参考一个开源项目(DoVideoAI)的基础上自己重构改造的,大量代码借助 AI Coding 辅助完成,我主要做的是方案取舍和把异常链路补齐。它最难的地方我拆成两块:工程上,视频大、处理时间长、第三方接口不稳定,所以做了分片续传、MQ 异步、限流重试、状态恢复这一套;智能上,模型输出不可信,所以做了证据绑定、Critic 校验和预算控制,让它"不能乱说”。

【详细解析】
面试官问项目,想听三件事:项目解决什么问题、你在里面干了什么、难点怎么解决的。回答套路是"背景 → 方案 → 难点 → 我做了什么":

  • 背景:一句话说清"给谁用、解决什么痛点"。VidFlow 的痛点是长视频"又大又长又贵"——文件大(GB 级)、处理时间长(FFmpeg+ASR+LLM)、第三方调用贵(烧 Token)。
  • 方案:按主线讲,不要背流水账。VidFlow 两条主线:工程可靠性(上传/MQ/幂等/限流/重试/恢复)+ Agent 质量(证据/校验/预算)。
  • 难点:挑 1-2 个讲透,比列 5 个讲不清强。上面的答案讲了"工程难在长耗时链路可靠、智能难在模型不可信"两个,每个都接了一句"所以我做了什么"。
  • 归属口径(重要):这个项目是你改的别人的,面试官大概率会问"这个项目是你自己写的吗"。口径是:主动说基础来自开源项目、你做了改造重构、AI 辅助编码,然后立刻把话题引到你真正做的部分——方案对比、状态边界、异常链路。诚实+展示思考,比硬撑"全是我写的"安全得多(面试官一查 GitHub 历史就穿帮)。

二、VidFlow:工程可靠性主线(9 问)

Q3. 为什么用 RocketMQ 把视频分析任务异步化?异步化到底带来了什么?

简历关键词:RocketMQ、消息异步、任务削峰、60秒→50毫秒

【口语化解答示例】

视频分析要干的事特别多:转码、抽音频、ASR、OCR,还要调大模型,一趟下来几分钟甚至更久。如果这些都在 HTTP 请求里同步做,一个请求就把服务器的线程占死了,用户那边也一直转圈。所以我把流程拆开:用户提交任务,后端只做校验、限流、把消息丢进 RocketMQ 就返回"受理成功";真正干活的是后台的消费者。这样请求线程很快被释放,任务也不会因为客户端断线就丢,服务重启了消息还在队列里,还能重新消费。这就是异步化加削峰——把突发的任务先存起来,慢慢消费,而不是一下子打爆服务器。

【详细解析】
几个基础名词先解释:

  • 同步 vs 异步:同步就像在柜台办事,办完才走人;异步就像取号排队,你取完号就回去,叫到你再来。HTTP 请求是同步的(必须返回响应),MQ 让"响应"和"干活"解耦。
  • 线程池/请求线程:Tomcat 这种 Web 服务器用线程处理请求,线程是有限的(比如默认 200 个)。一个请求占用一个线程 5 分钟,200 个请求就把服务器榨干了,后面的请求全部排队。
  • 消息队列(MQ):一个"邮局"。生产者(发消息的人)把信投进去就完事,消费者(收信的人)有空了来取。RocketMQ 就是这样一个邮局,信(消息)存在 Broker(服务器)上,不怕丢。
  • 削峰:秒杀、活动这种瞬间流量很大,MQ 把洪峰"削平"——请求瞬间全收下(很快),处理慢慢来。就像水库蓄水,洪峰来了先存起来,慢慢放水。
  • 简历里"上传接口响应时间由 60 秒以上缩短至 50 毫秒级"这个数字,面试口径:指的是任务提交(受理)接口——原来上传+处理全在一个请求里要 60 秒以上,现在提交任务只做校验+限流+发消息,几十毫秒返回受理结果,真正的处理在消费者异步完成;上传本身还是分钟级(GB 级视频+弱网),靠分片断点续传保证可靠性。

Q4. 为什么幂等 key 不能只拿视频的 MD5?

简历关键词:MD5、内容指纹、幂等拦截、分布式锁

【口语化解答示例】

MD5 只能代表"视频内容一样",但同一个视频可以提不同的分析目标:比如同一节课,用户今天要"复习笔记",明天要"操作步骤"。如果只拿 MD5 当幂等 key,两个不同目标的任务就会被当成同一个,后提交的会被错误地拦截掉,或者复用错结果。所以我的任务 key 是"视频内容指纹 + 目标摘要"两个拼在一起,内容指纹管"是不是同一个视频",目标摘要管"是不是同一个分析目标",只有两者都一样才算同一个任务。

【详细解析】

  • MD5 是什么:把任意数据算成一串固定长度的"指纹"(32 位十六进制字符)。内容一样 → 指纹一样;内容变一点 → 指纹完全不同。项目里上传合并完视频后算一次 MD5,用来识别"同一个视频"。
  • 幂等(Idempotent)是什么:同一个操作执行多少次,结果都和执行一次一样。比如"给订单付款"这个操作,用户手抖点了两下,系统不能收两次钱。幂等就是防止重复操作产生重复后果。
  • 为什么 MD5 不够:幂等 key 要能唯一标识"一个任务"。一个任务 = 视频 + 目标(+ 模式)。只用 MD5,就把"同视频不同目标"误判成同一个任务;只用目标文本,又会把"不同视频同目标"误判。所以 key 是 contentHash + goalDigest(目标摘要,SHA-256 算的),不同模式(通用/学习/审查)还要再区分,防止串键。
  • 顺带回答"幂等到底靠什么兜底":锁只能管"同一时刻只有一个在执行",Redis key 有过期时间也不能当永久证据,真正的兜底是数据库状态 + Checkpoint + 可重复写——消息重复了,先查结果在不在,在就直接复用,不在再从断点继续。

Q5. Redisson 分布式锁是什么?WatchDog 是怎么工作的?

简历关键词:Redisson、WatchDog、分布式锁、并发

【口语化解答示例】

消费者处理同一个视频任务时,可能会因为消息重投或者多台机器同时消费而撞车,两个人同时跑同一个任务,模型就白烧钱了。这时候需要一把"分布式锁":让同一时刻只有一个消费者在跑这个任务。我用的 Redisson 是 Redis 官方生态的 Java 客户端,它封装好了分布式锁。有个细节是 WatchDog 自动续期:任务可能跑很久,锁如果不续期会自己过期,被别的机器抢走;WatchDog 就是后台一个定时任务,只要持锁线程还活着,就不断帮锁续期,防止锁提前过期。线程死了续期就停,锁自然过期释放,不会死锁。

【详细解析】

  • 锁是什么:限制"同一时刻只能一个人进"的机制。单机多线程用 JVM 的锁(synchronized),但那是"一个进程内"的锁,多台机器部署(集群)就锁不住了——A 机器的锁管不了 B 机器。
  • 分布式锁:把锁放到所有机器都能访问的公共地方——Redis。实现核心就一句话:SETNX(Set if Not eXists,不存在才写入)。谁先写成功谁拿到锁,用完删除释放。手写容易出问题:忘了过期时间(线程死了锁永远不释放)、误删别人的锁、续期麻烦。
  • Redisson 做了什么:把上面的坑都填了——加锁、过期时间、释放前校验是不是自己的锁、自动续期(WatchDog)。
  • WatchDog 原理:默认锁 30 秒过期。Redisson 起一个后台任务,每 10 秒(过期的 ⅓)检查持锁线程还活着没,活着就再续 30 秒。这样"任务没跑完锁不会提前没,任务挂了锁迟早会没"。
  • 记住边界:WatchDog 只降低"锁提前过期"的风险,Redis 挂了、JVM 长时间停顿还是可能出问题,所以最终靠业务状态兜底(见 Q4)。

Q6. 分片上传和断点续传是怎么做的?为什么"先写 MinIO 再记 Redis"?

简历关键词:分片上传、断点续传、Redis、MinIO、大文件

【口语化解答示例】

大文件整包上传,网络一断就得从头再来。我的方案是前端把视频切成 5MB 的小片,一片一片传。后端先生成一个 uploadId 当作这次上传的身份证,每传一片,先把这一片写进 MinIO 对象存储,写成功了才把"这一片序号"记到 Redis 里。为什么要先写 MinIO 再记 Redis?因为 Redis 里的序号代表"这片真的落盘了",如果先记 Redis 再写 MinIO,万一 MinIO 写失败,Redis 却显示这片传完了,最后合并的时候才发现缺片,进度就是假的。全部分片传完后,前端调合并接口,后端按顺序把分片拼成完整文件,边拼边算 MD5。

【详细解析】

  • MinIO 是什么:一个开源的"对象存储",类似简化版 AWS S3,用来存大文件(视频、图片)。项目里视频、分片、关键帧都存在 MinIO。
  • 为什么要分片:把"一个大失败"拆成"很多小失败"。整包传 1GB,第 99% 断了,全部重来;分片传,只有最后那一片要重传。失败粒度从"整个文件"变成"5MB 一块"。
  • 断点续传流程:① 前端初始化:告诉后端文件名、总分片数 → 后端返回 uploadId;② 一片片传:每片带 uploadId+序号,后端校验后先写 MinIO 成功,再把序号加入 Redis 的 Set;③ 断网恢复:前端问后端"哪些片传过了"(读 Redis Set),只补传缺失的;④ 全部传完 → 调合并:后端用 Redisson 锁防止两个人同时合并,检查片数齐了,按 0 到 N-1 顺序拼文件,用 DigestOutputStream 边写边算 MD5,最后写数据库、清理临时分片。
  • 为什么先写 MinIO 再记 Redis(面试爱问):Redis 里的序号 = “这片的进度凭证”。凭证必须在"事实落盘之后"才生成,否则凭证可能是假的(见上面答案)。反过来如果 MinIO 写成功但 Redis 没记上,顶多前端重传这片,对象路径是固定的,覆盖写就行——用可重复写换进度可信。
  • uploadId 的作用:防止用户乱传(归属校验:查进度、传片、合并都要校验 uploadId 属于当前用户);不用前端算整文件 MD5 当任务主键,避免浏览器读一遍 GB 级文件。

Q7. 消息重复消费怎么办?消息丢了怎么办?

简历关键词:RocketMQ、重复消费、幂等、可靠传输

【口语化解答示例】

RocketMQ 保证"至少一次",也就是说消息可能重复,但一般不会丢。重复了怎么办?我的消费者做几层防护:先抢分布式锁,同一任务同一时刻只有一个人能进;进去先查这个任务是不是已经做完了(查 completedKey 和数据库),做完了直接复用结果不重跑;没做完就查 Checkpoint,从上次的断点继续,而不是从头再来。至于消息丢失,RocketMQ 本身有同步刷盘、主从复制这些机制,我在代码层面做的是一旦消费失败就抛异常让 MQ 重新投递,重试三次还不行就进失败台账和死信主题,管理员可以手动重放,不会无声无息地丢。

【详细解析】

  • At Least Once(至少一次):MQ 的投递语义。发送方发消息,收方确认(ACK)可能因为网络问题没送到,MQ 就会重投,所以"可能会收到重复消息,但不会丢"。反过来还有一种语义叫 Exactly Once(恰好一次),很难做到,一般靠业务层幂等来实现。
  • 幂等三件套:锁(同一时刻一个执行者)+ 状态查重(completedKey/数据库查结果)+ 断点恢复(Checkpoint,见 Q17)。
  • 消息丢失的三个环节:① 生产者发消息时丢了(解决办法:同步发送,失败重试,代码里投递失败会删 activeKey 撤销任务);② Broker 收到后丢了(RocketMQ 刷盘、主从复制解决,这是中间件层面);③ 消费者收到后处理失败(抛异常让 MQ 重投,重试有上限,最后进死信/失败表)。
  • 死信(Dead Letter):怎么都处理不了的消息,专门丢进一个"停尸房"主题,人工排查。项目里是自建失败主题 + 失败任务表,不是 RocketMQ 原生 DLQ 运营体系,这个边界要主动说清。

Q8. Redis 令牌桶限流是怎么做的?为什么直接用 Redisson 的 RRateLimiter?

简历关键词:Redis、令牌桶、限流、RRateLimiter

【口语化解答示例】

一次分析会调很多次 ASR、OCR 和大模型,都是要花钱的。如果用户脚本狂点,或者有人恶意刷,成本就失控了,所以我提交任务前先限流。我用的 Redisson 的 RRateLimiter,它底层就是令牌桶:按固定速率往桶里放令牌,用户每提交一次任务拿走一个令牌,桶空了就拒绝(返回 429)。我配了两个维度:每个用户每分钟最多 5 次,全局每分钟最多 30 次,防止一个人独占也保护整体容量。选 RRateLimiter 而不是自己写,是因为它把"令牌桶"这种算法封装好了,而且 Redis 不可用时我让它 fail closed——直接拒绝,宁可暂时不能用,也不能放行后烧钱。

【详细解析】

  • 限流是什么:限制单位时间内的请求数,防止系统被冲垮、防止成本失控。
  • 令牌桶算法:想象一个桶,每秒往里面放一个令牌,桶最多装 N 个。请求来了必须拿走一个令牌才放行,没令牌就拒绝。好处:允许短时间突发(桶里攒了令牌可以连拿),但长期速率被锁死。对比:固定窗口(1 分钟 60 次,最后 1 秒可以连打 60 次,有临界问题)、漏桶(匀速流出,一点突发都不行)。
  • Redisson RRateLimiter:Redis 官方推荐的 Java 客户端 Redisson 里现成的限流器,底层用 Redis 存令牌状态,多台机器共享同一份配额(这就是"分布式限流"——每个实例各限各的没用,合起来会超)。
  • fail closed vs fail open:Redis 挂了怎么办?放行(open)省钱还是拒绝(closed)省钱?对付费 AI 调用来说必须拒绝——放行等于限流失效,成本失控。这个"边界取舍"是加分回答。
  • 简历里写的"Redis 令牌桶",实际是 RRateLimiter(它内部就是 Redis 令牌桶),口径一致,放心讲。

Q9. 指数退避重试是什么?API 层重试和 MQ 层重试有什么区别?

简历关键词:指数退避、重试、ASR、第三方接口

【口语化解答示例】

调第三方 ASR、大模型接口经常遇到网络抖动或者限流,这时候直接失败太可惜,我做了指数退避重试:第一次失败等 1 秒再试,第二次失败等 2 秒,最多试三次。为什么要越等越久?因为下游刚挂了的时候你立刻重试大概率还是失败,给它一点恢复时间;而且大家都失败的时候,如果都按固定间隔重试,会一起醒来一起打,形成"羊群效应"。另外我区分了两层重试:ASR、LLM 这种单次 API 调用内部的短重试,和整个任务失败后交给 RocketMQ 的任务级重投,两层都有限制,不会无限放大。

【详细解析】

  • 指数退避(Exponential Backoff):重试等待时间按 2 的幂增长:1s、2s、4s、8s……项目里 ASR 是 1s、2s 后放弃(最多 3 次)。目的:给下游恢复时间 + 打散重试时间点,避免"同时失败同时重试"。
  • 两层重试的区别(面试高频,别混):
  • API 层(内部重试):一次具体外部调用(一个 ASR 片段、一次模型调用),失败等 1s/2s 重试,粒度小,上下文不变。
  • 任务层(MQ 重投):整个分析任务失败后,消费者抛异常,RocketMQ 重新投递消息,重新进消费者,利用 Checkpoint 跳过已完成的阶段。
  • 为什么不能只有一层:只有 API 层,任务级失败没人管;只有 MQ 层,一个网络抖动就让整个任务重跑一遍(浪费)。
  • 什么错误不该重试:参数错误、鉴权失败这种"重试一万次也一样失败"的错误要直接判死,别浪费。代码里 ASR 对 429/5xx 重试,4xx 直接抛不可重试异常,这就是错误分类。

Q10. SSE 和轮询有什么区别?为什么用 SSE 推进度?

简历关键词:SSE、Vue、前端、实时进度

【口语化解答示例】

分析任务要跑很久,前端需要知道进度。两种做法:轮询是前端每几秒问一次后端"好了没",简单但浪费请求,而且可能错过状态变化;SSE 是后端主动推——建立一条长连接,后端有阶段变化就推给前端,前端实时展示"正在解析语音""正在检索证据"这种阶段。我用的 SSE,断线了前端还会指数退避重连,重连后先拉一次当前状态,所以中间事件丢了也没关系,最终结果不会丢。

【详细解析】

  • 轮询(Polling):前端定时器每 3 秒发一个请求问"好了没"。缺点:① 浪费——大部分请求都是"还没好"的空回答;② 延迟——状态变化后最多要等一个轮询周期才发现;③ 服务器压力。
  • SSE(Server-Sent Events):HTTP 长连接,服务器单向推数据给浏览器。特点:基于普通 HTTP(比 WebSocket 简单,不需要双向通信),自动重连,断线续传(带 Last-Event-ID 可续)。
  • WebSocket 是什么:双向全双工连接,浏览器和服务器都能主动发。SSE 只需要"服务器→浏览器"单向推送,用 SSE 就够了,没必要上 WebSocket。
  • 为什么最终结果不依赖连接:SSE 只是"通知通道",真正的状态在 MySQL/Checkpoint 里。前端断线重连后先调状态接口拿当前状态,再继续订阅——所以"通知可以断,业务不能丢"。
  • 简历没写轮询,但面试官可能拿"轮询 vs SSE"考你,这个对比要会讲。旧文档里的"3 秒轮询"是过时方案,别说。

Q11. 你的系统怎么防止别人恶意上传、越权访问别人的视频?

简历关键词:鉴权、归属校验、上传安全

【口语化解答示例】

我在两个层面做了防护。第一层是登录鉴权:所有媒体和分析接口都要过登录拦截器,从请求头里的 token 解析出用户身份。第二层是归属校验:光登录了还不够,你只能操作自己的视频——查上传进度、传分片、合并、发起分析、拿播放地址,每一步都会校验这个视频的 userId 是不是当前登录用户,不是就拒绝。另外上传环节还有一堆参数校验:文件名去掉路径、只允许视频后缀、uploadId 必须是合法 UUID、分片序号和大小都由服务端校验,不能信前端传什么就是什么。

【详细解析】

  • 鉴权(Authentication)vs 授权(Authorization):鉴权是"你是谁"(登录验证),授权是"你能干什么"(权限控制)。上面的答案两层都有:拦截器管鉴权,归属校验管授权。
  • 越权(IDOR):最常见的漏洞类型——用户 A 把 URL 里的 id 改成 B 的 id,就能看 B 的视频。防法就是"每个资源操作都校验归属",不能只在列表接口校验、详情接口不校验。
  • 为什么不能信前端:所有参数都可能被伪造(用 Postman 就能改),所以文件名、后缀、分片大小、序号、UUID 格式全部服务端再校验一遍。这是后端开发的常识:前端校验是体验,后端校验是安全。

三、VidFlow:Agent 智能链路(6 问)

Q12. 什么是 Agent?你为什么用 Planner-Executor-Critic 这种架构?

简历关键词:LangChain4j、Agent、Function Calling、Planner、Executor、Critic

【口语化解答示例】

Agent 可以理解成"会自己规划、自己执行、自己检查的 AI 程序",跟普通的一次性问答不一样——它不是问一句答一句,而是围绕一个目标,拆任务、找证据、干活、检查结果。我的场景是视频分析,用户目标不固定,我就把 Agent 拆成三个角色:Planner 负责把目标拆成 1 到 5 个可执行的小任务;Executor 负责照着计划,结合检索到的视频证据,生成带时间戳的结论;Critic 负责挑毛病——检查有没有遗漏用户要求、结论有没有证据支撑,不过就返回结构化反馈,让系统补证据或者改计划再来一轮。为什么不用更复杂的多 Agent?因为我的场景里一个模型配合确定性程序规则就够了,多 Agent 通信成本高、状态难一致,没必要。

【详细解析】

  • Agent 是什么:有"目标-计划-行动-反馈"循环的 AI 程序。判断标准不是调用几次模型,而是是否围绕目标维护状态、根据反馈改变下一步动作。你的项目里:目标(userGoal)、计划(Plan)、行动(Executor 生成结果)、反馈(Critic)、循环(最多两轮)、终止(预算)。
  • 为什么拆三个角色:一次性 Prompt 生成总结,漏了哪个要求、哪条结论没依据,程序完全不知道,只能整篇重来。拆开后:Planner 让"漏没漏要求"可检查,Executor 让"每条结论都绑定证据"可验证,Critic 让"不合格"变成可执行的结构化反馈(缺哪个任务、哪条结论没证据、需要哪段时间的证据)。
  • 和 ReAct、Multi-Agent 的区别(能说出来就是加分):ReAct 适合工具不确定的开放任务(思考-行动循环);Multi-Agent 是多个模型角色互相通信;你是"受控 Video Agent Workflow"——模型负责理解判断,程序负责状态、证据、预算、终止这些确定性的事。
  • LangChain4j 是什么:Java 版的 LangChain,一个"大模型应用开发框架",帮你封装模型调用、消息、工具调用(Function Calling)。你的项目用它接 DeepSeek(OpenAI 兼容接口)。

Q13. Function Calling 是什么?你在项目里怎么用的?

简历关键词:LangChain4j、Function Calling、结构化信息查询

【口语化解答示例】

Function Calling 就是让大模型"会调用函数"。模型本身不能访问你的系统,但你可以告诉它"有几个函数可用、参数是什么",它生成回答的时候如果需要,就会输出"我要调用这个函数、传这些参数"的结构化结果,程序拿到后执行真正的函数(比如查视频证据),把结果再喂回给模型。我在项目里用它做追问的证据检索:用户追问"老师讲的递归时间复杂度是多少",模型会输出一个检索函数调用,参数是提炼出的检索词,程序去检索视频证据,把结果给模型,模型基于证据回答。这样回答有依据,而且比把整个视频上下文全塞给模型省钱。

【详细解析】

  • 背景:大模型训练完就"冻结"了,不知道你的数据,也不能执行操作。Function Calling 是 OpenAI 提出的机制,给模型一把"钥匙":你声明函数清单(函数名+参数 JSON Schema),模型按需输出调用请求,程序执行后把结果作为"工具执行结果消息"再给模型,模型继续生成。这就是"模型+工具"的雏形。
  • 和"把数据塞进 Prompt"的区别:全塞进去(RAG 式)上下文巨大、烧钱、还可能被无关内容干扰;Function Calling 让模型"按需取"——需要什么证据才查什么,查完只把结果喂回来。
  • 简历那句话:“结合 LangChain4j 与 Function Calling 实现结构化信息查询与视频内容精准总结”——讲清楚"模型输出调用→程序查证据→回喂→生成结论"这个闭环就够了。
  • 结构化输出:Function Calling 顺带解决了"模型输出格式不可控"的问题——用 JSON Schema 约束输出,程序才能稳定解析。你的 AiService 里还有"非法 JSON 重试一次"的逻辑。

Q14. 什么是 Embedding 和向量检索?Qdrant 是干什么的?

简历关键词:Embedding、Qdrant、混合检索、LangChain4j

【口语化解答示例】

Embedding 就是把文字变成一串数字(向量),让意思相近的文字在数字空间里离得近。比如"递归的效率"和"递归的时间复杂度",字面不一样但意思接近,它们的向量就很接近。我先把视频按 5 分钟切成块,每块生成摘要、关键词和向量存到 Qdrant 向量数据库;用户提问时,把问题也转成向量,去 Qdrant 里找最相似的几块(语义检索),再跟关键词匹配的结果按 0.7 和 0.3 的权重融合,取前 3 个作为候选证据。Qdrant 不可用的时候我会降级成本地计算相似度,保证任务还能跑。

【详细解析】

  • Embedding(向量化):一段文字 → 一串几百上千维的数字。核心性质:语义相近 → 向量相近(余弦相似度高)。项目用 BGE-M3 模型生成向量(硅基流动提供)。
  • 为什么需要它:关键词匹配是"字面匹配",“递归效率"搜不到"递归时间复杂度”;向量检索是"语义匹配",能跨字面找意思。两者互补:向量管语义,关键词管精确符号(B+树、函数名、数字)。
  • 向量数据库 Qdrant:专门存向量、算相似度的数据库,支持按条件过滤(比如按 mediaId 隔离每个视频的数据)。用 OkHttp 直连它的 HTTP 接口,没引 Java SDK(这个细节可以提,显得你真看过代码)。
  • 混合检索:语义分 0.7 + 关键词分 0.3 融合,Top3。降级链路:Qdrant 挂了 → 本地余弦相似度;Embedding 也挂了 → 纯关键词。降级要有记录(Trace),不能静默降级当没事。
  • RAG 是什么:Retrieval-Augmented Generation,检索增强生成——先检索相关材料,再让模型基于材料回答。你的"检索证据→生成结论"就是 RAG 思路,这个词面试官爱听。

Q15. ASR 和 OCR 分别是什么?你怎么把两路结果融合成一个"视频上下文"?

简历关键词:ASR、OCR、多模态、VideoContext、FFmpeg

【口语化解答示例】

ASR 是语音转文字,OCR 是图片里的文字识别。我同时做两条路:语音侧,用 FFmpeg 把音频切成 60 秒一段,每段调一次转写服务,得到带时间范围的文字;画面侧,先做场景变化检测——画面变化大的时候(比如 PPT 翻页)抽一帧,超过 30 秒没变化也保底抽一帧,再用感知哈希去掉重复的帧,最后用 Tesseract 识别帧里的文字。两路结果按 60 秒一个窗口合并,同一时间窗口里的语音和画面文字就拼成一个"VideoSegment",一串 Segment 组成 VideoContext,后续检索和 Agent 只认这个统一结构。两路是并行跑的,一路失败另一路还能用。

【详细解析】

  • ASR(Automatic Speech Recognition):语音转文字。项目里走硅基流动的托管接口(TeleSpeechASR),60 秒切片是为了获得"分钟级时间定位"——托管接口不返回句子级时间戳,固定切片就能算出每段文字对应视频的哪一分钟。
  • OCR(Optical Character Recognition):从图片里识别文字。Tesseract 是本地开源 OCR 工具(chi_sim+eng 中英文语言包)。
  • 为什么"纯 ASR 不够":老师讲"看这个公式"时,公式在画面上,语音里没有;操作演示可能全程不说话。画面文字和语音是两类互补证据。
  • 场景变化检测:FFmpeg 的 select 过滤器给每帧算 scene 分数,>0.35 认为"画面变了",选帧;30 秒保底防止静态板书一直不选帧;dHash(感知哈希)把图片缩成 9×8 灰度,逐像素比较生成 64 位哈希,两张图哈希差异(汉明距离)≤5 就认为重复,跳过 OCR——避免一页 PPT 被反复识别。
  • 为什么用 CompletableFuture 并行而不是再拆两个 MQ topic:ASR 和 OCR 是"一个任务内部要汇合的两个分支",用线程池并行总耗时≈较慢的一路;拆两个 topic 要引入子任务状态、聚合器、单边失败协调,当前规模没收益。这个"为什么不拆"的理由很加分。
  • 融合窗口左闭右开:恰好 120 秒的数据只属于 [120,180),不会同时出现在两个窗口——细节但面试官喜欢听精确。

Q16. 大模型会胡说八道,你怎么防止它编造证据?

简历关键词:Critic、证据校验、幻觉、时间戳

【口语化解答示例】

这是这个项目最核心的问题,我从三层来防:第一层,强制结构绑定——每条结论必须带证据,证据里必须有时间戳、来源(ASR 还是 OCR)、原文,模型想输出结论但没给证据就过不了校验;第二层,确定性校验——程序检查这个时间戳是不是真的落在某个视频片段里、来源对不对、引用的原文能不能在真实转写里找到,找不到就判不合格;第三层,语义校验——Critic 这个 LLM 角色检查"证据能不能推出结论、用户要求有没有遗漏"。前两层是程序规则,一定能拦住伪造;第三层是模型判断,可能有误判,所以我不敢说"消除了幻觉",只能说"把无依据的结论变成了可检测、可追溯的问题"。

【详细解析】

  • 幻觉(Hallucination):模型一本正经地编造不存在的"事实"。面试官问"怎么防幻觉"是 Agent 项目的必考题。
  • 三个关键概念:
  • Claim(结论/主张):模型输出的每条结论。
  • Evidence(证据):支撑结论的时间戳 + 来源 + 原文。
  • 结构化校验:程序按规则检查"证据是否真实存在"——时间戳落在真实 Segment 范围、来源是 ASR/OCR、引用文本在原文中能找到(去空格标点后做包含匹配)。
  • 为什么"引用存在"≠"结论正确":模型可以引用一段真实存在的原文,但结论是过度推断的。所以还有 Critic 做语义判断。边界要主动说:Claim 校验保证"绑定+来源+文本匹配",不能证明语义蕴含。
  • Critic 怎么让第二轮不是重复生成:Critic 必须返回结构化失败原因:missingRequirements(漏了用户要求→重新规划)、unsupportedClaims(结论没证据→把结论当新查询去检索)、requiredTimestamps(需要某时间段的原始证据→直接加载)。上下文真的变了,循环才有意义。

Q17. 任务最多跑两轮,预算用完怎么办?任务中途失败怎么恢复?

简历关键词:预算、Checkpoint、状态恢复、BUDGET_EXHAUSTED

【口语化解答示例】

我做了两个机制管"终止"和"恢复"。终止方面,除了最多两轮,还有四个预算:轮次、执行时长、预估 Token、预估费用,任何一个超了就直接进入 BUDGET_EXHAUSTED 终态,不再交给 MQ 无限重试——重试不会改变预算条件,只会继续烧钱。恢复方面,我用 Checkpoint 记录每个阶段的产物:视频上下文、分块、计划、Executor 草稿、Critic 状态、最终结果。任务中途挂了,消息重投后先读 Checkpoint,哪个阶段完成了就跳过哪个,比如 Executor 草稿已保存而 Critic 没跑,就直接从 Critic 继续,不重跑 ASR 和 LLM。Checkpoint 的存储是 MySQL 为恢复真源、Redis 做 7 天热缓存。

【详细解析】

  • 预算(Budget):给"可能失控的 Agent"上锁。轮次限制循环次数,时长限制总耗时,Token 和费用限制成本。代码里四类预算在阶段之间检查,超了就终止。
  • 边界(面试官会抠):时长预算只能在"模型阶段之间"检查,不能强行中断一个已经发出去的阻塞请求;Token 是字符规则估算的,不是精确计费。这些边界主动说,显得诚实。
  • Checkpoint(检查点):游戏存档的概念——每过一个关键阶段存一次档,挂了从最近的存档继续,不用从头打。分两类:视频级(上下文、分块,跨目标复用:同一视频生成复习笔记和操作步骤共享这些)和目标级(计划、草稿、Critic、结果,按目标摘要隔离)。
  • MySQL 和 Redis 的分工:MySQL 是"真相"(恢复真源),Redis 是"加速"(热缓存)。写入:先写 MySQL 事务提交,再更新 Redis;读取:先读 Redis,没有再读 MySQL 回填。所以 Redis 丢了只是慢一点,任务不会永久丢。
  • 和 MQ 重试、幂等、缓存的区别(高频四连问):MQ 重试=失败有没有机会再跑;幂等=重复跑会不会有重复后果;缓存=能不能更快读;Checkpoint=从哪一步继续。四个问题,四个答案,背熟。

四、LifeMate:高并发秒杀(10 问)

Q18. 什么是超卖?你分几步解决超卖的?

简历关键词:秒杀、超卖、Redis、Lua、库存

【口语化解答示例】

超卖就是"卖出去的比库存多"。比如库存 100,1000 个人同时抢,如果大家都先查出"库存还有",然后各自扣减,就可能扣成负数,卖出 105 单。我解决超卖经历了三个阶段:最开始用数据库悲观锁(select for update),把查询和扣减串行化,性能很差,连接池直接被打满;后来换成乐观锁(update … where stock > 0),能防止超卖但数据库在高并发下还是扛不住;最终方案是把"判断库存 + 扣库存 + 一人一单"全部放进一个 Lua 脚本在 Redis 里原子执行,Redis 单线程执行脚本不会被打断,所以绝对不会有两个人同时看到"库存还有 1"。这一步从数据库压力变成了纯内存操作,性能是毫秒级的。

【详细解析】

  • 超卖(Oversell):并发下多个请求同时通过"库存检查",导致实际扣减超过库存。本质是"检查"和"扣减"不是原子操作——两个操作之间被插入了别的请求。
  • 原子(Atomic):不可分割。要么全部执行完,要么一个都不执行,中间不会被其他操作插入。Java 里 i++ 就不是原子的(读-改-写三步)。
  • 三个阶段(面试官喜欢听演进过程):
  • 悲观锁:select * from t where id=1 for update——查询时就把这行锁住,别人只能等。防超卖,但所有请求串行,慢。
  • 乐观锁(CAS):update t set stock=stock-1 where id=1 and stock>0——不锁,靠"更新时再检查条件",影响行数为 0 说明被别人抢了。防超卖,但 DB 扛不住高并发写。
  • Redis+Lua:库存和"谁买过"都存在 Redis,Lua 脚本一次性原子完成"查库存→查是否买过→扣库存→登记"。最快,也是最终方案。
  • 为什么 Redis 执行 Lua 是原子的:Redis 是单线程执行命令的,一个脚本从头到尾执行完之前,不会有其他命令插进来(见 Q20)。

Q19. "一人一单"怎么实现的?为什么在 Redis 里做而不是数据库里做?

简历关键词:一人一单、Redis Set、Lua

【口语化解答示例】

一人一单就是同一张秒杀券,一个用户只能买一次。我的做法是在 Redis 里用 Set 记录"这个券都有谁买过":Lua 脚本里用 SISMEMBER 检查这个用户的 id 在不在 Set 里,在就返回"重复下单",不在才继续扣库存、把用户加进 Set。为什么放 Redis?因为判断资格是秒杀链路里最高频的操作,全部在内存里做,毫秒级;如果放数据库,每人一单的判断要查订单表,高并发下数据库扛不住。数据库那边也有兜底:订单 id 是主键,重复插入会主键冲突失败,这是防重复消费的最后一道闸。

【详细解析】

  • 一人一单(防重复下单):秒杀场景每张券每人限购一份。两个层面:
  • Redis 层(资格判断):Set 存 userId,SISMEMBER 判断——快,但 Redis 数据可能丢(内存),所以是"资格预判"。
  • 数据库层(最终一致性):真正落库时,订单表主键唯一。消费者重复收到消息,第二次 save 会主键冲突,直接失败,不会产生两条订单——这就是"幂等靠主键"。
  • Redis Set 是什么:Redis 的一种数据结构,无序不重复集合,支持 SISMEMBER(判断在不在)、SADD(添加)、SINTER(交集)。项目里还用它做"共同关注"(两个 Set 取交集)。
  • 面试口径提醒:现在 RocketMQ 消费者直接"保存订单+扣 MySQL 库存",没有再查一次数据库的一人一单——重复消费的幂等靠订单 id 主键唯一。别人问你"数据库层没有再查一遍吗",要能说清楚这个设计(见 MEMORY.md)。

Q20. 为什么 Redis 执行 Lua 脚本是原子的?

简历关键词:Lua、原子性、Redis 单线程

【口语化解答示例】

因为 Redis 是单线程模型——所有命令都是排队一个个执行的。Lua 脚本一旦开始执行,就会一口气把脚本里的命令全部跑完,中间不会有其他客户端命令插进来。所以脚本里的"查库存、查是否买过、扣库存、登记"是一个不可分割的整体,不会出现两个请求同时通过检查的情况。如果不用 Lua,靠客户端发四条命令,两个请求可能交错执行,比如都先查库存都发现有货,那就超卖了。这也是我为什么把判断逻辑全部放进 Lua 而不是在 Java 代码里一步步调 Redis。

【详细解析】

  • 单线程模型:Redis 处理命令的线程只有一个(6.0 之后网络 IO 有多线程,但命令执行还是单线程)。好处:没有并发问题、实现简单;坏处:单个慢命令会阻塞后面所有命令。所以 Lua 脚本也别写太慢的循环。
  • 为什么不直接在 Java 里一步步调:Java 客户端发 4 条命令 = 4 次网络往返,且每条命令之间可能插入别人的命令,检查-扣减就不是原子的了。Lua 脚本把逻辑"下推"到 Redis 内部一次执行:快(一次网络往返)+ 原子(单线程执行)。
  • 事务(MULTI/EXEC)为什么不更好:Redis 事务是"批量排队执行",但执行前不能根据前面的结果做条件判断(不能"查了库存再决定扣不扣")。Lua 有完整的编程能力,能表达 if-else,所以用 Lua。
  • 面试答法:一句话"Redis 单线程 + Lua 脚本整体执行 = 原子",然后举项目例子(seckill.lua 干了四件事)。

Q21. 缓存击穿、缓存穿透、缓存雪崩分别是什么?你分别怎么解决?

简历关键词:缓存击穿、缓存穿透、逻辑过期、缓存空对象、Redis

【口语化解答示例】

这三个概念名字像,问题完全不同。击穿:一个热点 key 正好过期的那一瞬间,几千个请求同时打到数据库。我用的逻辑过期方案——不给 key 设物理过期时间,把过期时间存进 value 里,请求发现"逻辑上过期"了,先返回旧数据,同时抢锁的线程在后台重新查数据库更新缓存。穿透:恶意请求疯狂查一个根本不存在的 id,缓存里没有、数据库里也没有,每次都穿透到数据库。我的方案是缓存空值——查不到就把空值也缓存 2 分钟,下次同样的请求直接命中空缓存返回。雪崩:大量 key 同一时间集体过期,数据库瞬间被打崩。我用的手段是给过期时间加随机值,避免集体过期,另外 Redis 集群高可用兜底。核心思想是:热点数据过期时"宁可给旧数据,也不让数据库被打穿"。

【详细解析】

  • 三个"穿"怎么记:
  • 击穿(Breakdown):单个热点 key 过期瞬间 → 大量请求打 DB。重点在"热点"和"瞬间"。
  • 穿透(Penetration):查不存在的数据 → 缓存和 DB 都没有,每次都白查。重点在"不存在"。
  • 雪崩(Avalanche):大量 key 同时过期(或 Redis 宕机)→ DB 被打崩。重点在"集体"。
  • 逻辑过期 vs 互斥锁(面试对比题):
  • 互斥锁:过期后所有请求抢锁,抢到的查 DB 重建缓存,其他人等待。一致性更好,但抢锁失败要等,可用性略降(CP 取向)。
  • 逻辑过期:过期后直接返回旧数据,后台异步重建。永远不阻塞,但短暂不一致(AP 取向,重可用)。项目选逻辑过期,因为秒杀详情页"晚 2 秒更新"无伤大雅,可用性更重要。
  • 为什么缓存空值能防穿透:缓存 key 存在(值是空字符串),下次请求走缓存层就返回了,不再查 DB。空值 TTL 短(2 分钟),防止"假数据"长期占用。布隆过滤器是另一种方案(判断"id 大概不存在"),但实现复杂,项目数据量小用空值就够了。
  • 雪崩的解法:① TTL 加随机值打散过期时间;② 多级缓存(本地缓存 Caffeine+Redis,项目 README 提过但代码没实现,别讲成已落地);③ Redis 高可用(哨兵/集群)。

Q22. 数据库和缓存的一致性怎么保证?为什么更新数据库后要删缓存而不是更新缓存?

简历关键词:Cache-Aside、缓存一致性、删除缓存、MQ 补偿

【口语化解答示例】

我用的是 Cache-Aside 模式:读的时候先读缓存,没有就读数据库然后回填;写的时候先更新数据库,然后把缓存删掉,下次读的时候再重新回填。为什么不直接更新缓存?因为更新缓存有个坑——两个并发请求同时写,后写的可能把先写的覆盖,缓存里存的是旧值;而且更新缓存要算好新值,容易出错,删缓存让"下次读时自然重建"更简单可靠。删缓存也可能失败,我的兜底是:删失败就发一条 RocketMQ 消息,由消费者补偿重删;就算消息也丢了,缓存还有 TTL,过期后自动重建,保证最终一致。

【详细解析】

  • Cache-Aside(旁路缓存):最常用的缓存模式。读:先查缓存→未命中查 DB→回填缓存;写:先写 DB→删缓存。关键点是"删"而不是"改"。
  • 为什么删缓存而不是更新缓存:① 并发写场景下"更新缓存"会产生覆盖错乱(先写 DB 的 A 后更新缓存,后写 DB 的 B 先更新缓存,缓存里是 A 的旧值);② 缓存里的数据常常是"聚合后的视图"(比如详情页),重算容易错;③ 删掉让下次读自然重建,简单可靠。面试加分点:先更新 DB 还是先删缓存也有讲究,项目是"先 DB 后删缓存",配合延迟双删/MQ 补偿处理中间窗口。
  • 为什么不能只靠 TTL:TTL 是兜底不是主方案——TTL 期间数据不一致。项目:DB 更新 → 删缓存 → 删失败 → MQ 补偿重删 → 都失败 → TTL 兜底。分层递进,最终一致。
  • 最终一致(Eventual Consistency):不是"永远一致",而是"经过一小段时间后一定一致"。秒杀详情这种场景,最终一致足够。

Q23. 你的限流是怎么做的?自定义注解 + AOP 是什么原理?

简历关键词:AOP、限流注解、滑动窗口、Redis ZSet

【口语化解答示例】

我用 Redis 的 ZSet 做滑动窗口限流:每个请求带一个时间戳,存进 ZSet;每次先删掉窗口外的旧时间戳(ZREMRANGEBYSCORE),再数窗口内还剩多少个(ZCARD),没超就放行并加进去,超了就拒绝。这块我封装成了注解 @RateLimiter,可以配 key 前缀、窗口大小、次数、维度(全局/IP/用户),然后用 AOP 切面统一处理——方法上标了注解,切面就在方法执行前自动跑限流逻辑,业务代码不用改。比如秒杀接口配"用户维度 1 秒 5 次",商铺查询配"IP 维度 1 秒 20 次"。

【详细解析】

  • 滑动窗口 vs 固定窗口:固定窗口按"1 分钟"整块计数,59 秒时攒的请求和下一秒的请求可能连在一起超过限制(临界问题);滑动窗口按"最近 1 分钟"实时滚动计数,任何时刻往前看 1 分钟都不会超。
  • ZSet 是什么:有序集合,每个成员带一个分数(score)。这里用时间戳当分数,天然按时间排序,配合 ZREMRANGEBYSCORE(删窗口外)、ZCARD(计数)、ZADD(添加)正好实现滑动窗口。
  • AOP(面向切面编程)是什么:把"横切逻辑"(限流、日志、事务这种到处都要的逻辑)从业务代码里抽出来统一处理。注解就是"标记",切面就是"拦截器"——方法被调用时,切面代码先执行,再决定放不放行进业务方法。
  • 为什么限流逻辑要放 Lua 脚本:ZCARD 判断和 ZADD 添加之间也有并发窗口(两个请求都查到 99,都放行就 101 了)。用 Lua 把"删、数、加"一次原子执行,见 Q20。
  • 和 VidFlow 限流的对比(主动讲,很加分):LifeMate 秒杀要的是"风控精准",用滑动窗口(死板、均匀);VidFlow 视频提交要的是"允许合理突发",用令牌桶(弹性)。两个项目两种算法,正好展示你理解选型差异。

Q24. 订单超时未支付怎么处理?为什么用 RocketMQ 延迟消息而不是定时任务?

简历关键词:RocketMQ、延迟消息、订单超时、关单

【口语化解答示例】

下单后如果 1 分钟没支付,订单要自动关闭,库存要释放。我的做法:消费者成功落单后,发一条延迟消息到 order-timeout-topic(RocketMQ 的 delayLevel=5,大约 1 分钟),到期后 OrderTimeoutListener 收到消息,检查订单还是不是"未支付",是的话用乐观锁把它改成"已取消"并归还库存。为什么不用定时任务每 1 分钟扫一次数据库?定时任务要频繁扫描全表,数据量大时对数据库压力大,而且触发不精准;延迟消息是"精准触发",哪个订单该关就只处理哪个,还能配合乐观锁安全释放库存。对比过 Redis 过期监听,那个不保证实时性,所以没用。

【详细解析】

  • 延迟消息(Delayed Message):发消息时指定"延迟多久后再投递给消费者"。RocketMQ 用 delayLevel 指定,level=5 对应约 1 分钟(默认配置:1s 5s 10s 30s 1m 2m…)。
  • 定时任务 vs 延迟消息:定时任务(Spring @Scheduled 每分钟全表扫)简单但低效——大部分扫描都是"没有超时订单";延迟消息精准——每个订单发一条,到期只处理它自己。数据量大时差别巨大。
  • Redis 过期监听为什么不靠谱:Redis 的 key 过期是"惰性删除"(有人访问才删)+ 定期删除,通知可能延迟甚至丢失,不适合做精确业务触发。
  • 为什么关单要带条件(乐观锁):update orders set status=4 where id=? and status=1——只有"未支付"的订单能被关掉,影响行数是 0 说明已经被支付了。防止"用户刚付完钱,关单把它关了"。
  • 支付回调 vs 超时关单并发:两个操作都带 where status=未支付,数据库行锁保证只有一个成功——这就是状态机流转(见 Q26)。

Q25. 秒杀的整体流程串一遍(从用户点按钮到订单生成)

简历关键词:秒杀、Redis+Lua、RocketMQ、异步、乐观锁

【口语化解答示例】

用户点秒杀按钮,请求先进限流切面(1 秒 5 次)。然后生成一个雪花订单 id,执行 Lua 脚本:查 Redis 库存够不够、这个人买没买过,够且没买过就扣 Redis 库存、登记用户,返回成功。拿到资格后把订单消息发到 RocketMQ,接口立刻返回订单号给前端(“排队中”)。消费者收到消息:保存订单到数据库 + 用乐观锁扣 MySQL 库存(stock>0 才扣),再发一条延迟消息用于 1 分钟后超时关单。用户支付回调来了,乐观锁把订单从"未支付"改成"已支付"。如果 1 分钟没支付,延迟消息触发关单,同样用乐观锁改成"已取消"并归还库存。整个链路里,Redis 管"资格",MQ 管"削峰",数据库管"最终落账",乐观锁管"状态不乱"。

【详细解析】

  • 这条链路背下来,秒杀题就稳了:限流 → 雪花 ID → Lua 判资格(库存+一人一单)→ MQ 异步落单 → 乐观锁扣 DB 库存 → 延迟消息 → 超时关单/支付回调(乐观锁状态机)。
  • 为什么"先 Redis 后 MySQL":Redis 判断资格是内存操作毫秒级,挡掉 99% 的无效请求;MySQL 只处理"真正抢到的人"的落账,压力骤减。这就是分层削峰。
  • 雪花 ID(Snowflake):分布式 ID 生成算法,保证全局唯一且趋势递增。RedisIdWorker 实现:时间戳 + 当天自增序列拼成 64 位 long。为什么要自己生成订单 id:主键唯一是幂等兜底(Q19),而且先有 id 才能发消息。
  • 注意口径:Redis 库存扣了、MySQL 没扣成的窗口怎么办?设计上"Redis 扣了 = 秒杀成功",DB 最终一致(对账/补偿是后续演进),别吹成强一致。
  • 各环节崩溃的连环问:MQ 挂了→下单失败返回错误(不能假装成功);消费者挂了→消息重投;DB 挂了→落账失败重试;延迟消息丢了→兜底定时扫描(README 讨论过,代码未实现,别讲成已落地)。

Q26. 订单状态为什么用乐观锁?状态机怎么保证不乱?

简历关键词:乐观锁、状态机、支付回调、超时关单

【口语化解答示例】

订单有状态流转:未支付→已支付、未支付→已取消。最怕的并发是"用户刚好支付的那一刻,超时关单也来了"——两个操作都想改状态,处理不好就会重复释放库存或者状态错乱。我的做法是状态迁移必须带条件:改已支付要 where status=未支付,改已取消也要 where status=未支付。数据库行锁保证这两个更新只有一个能成功,另一个影响行数为 0,直接返回失败。这就是乐观锁:不提前锁行,更新时校验状态,靠"更新条件"保证只有一个赢家。关单成功才归还库存,所以不会超卖。

【详细解析】

  • 乐观锁(Optimistic Lock):假设并发少,不锁,靠"更新时带条件"保证正确:update ... set status=2 where id=? and status=1,影响行数 0 = 条件不满足 = 被别人改了。对比悲观锁(select for update 提前锁住)。
  • 为什么这里不用 Redis 分布式锁:状态迁移的正确性由"数据库条件更新"保证就够了,加分布式锁是多余的复杂度。能用数据库约束解决的,别引分布式组件——这个判断力很加分。
  • 状态机(State Machine):订单状态只能在合法路径上流转:1未支付 → 2已支付 / 4已取消,不能乱跳(比如已取消不能直接变已支付)。每个迁移都带"前置状态条件",就是状态机的实现。
  • 面试扩展:如果"关单成功但库存归还失败"怎么办?项目里库存归还是同事务的;更复杂场景要引入异步补偿 + 对账。README 讨论过,讲设计思路即可,别说成已实现。

Q27. 消息重复消费在秒杀链路里怎么处理?会不会重复发券?

简历关键词:RocketMQ、重复消费、幂等、主键

【口语化解答示例】

RocketMQ 是"至少一次"投递,消息可能重复。我的消费者收到消息后直接 save 订单——订单 id 是主键,第二次消费同样的消息,插入同主键的记录会主键冲突失败,代码里直接返回,不会产生第二条订单,券也不会重复发。因为"下单资格"在 Redis 里已经判定过了(一人一单),数据库这层只要保证"一条消息只落一条订单"就行,主键唯一就是这层保障。另外,就算消费者处理一半挂了,消息重投后重新 save,结果也是一样的——这就是幂等:重复执行和一次执行的效果相同。

【详细解析】

  • 幂等(Idempotency)复习:同一个操作执行 N 次 = 执行 1 次的效果。Q4 讲的是"分析任务幂等"(Redis key + 状态查重),这里是"订单幂等"(主键唯一),都是幂等的具体实现。
  • 主键唯一为什么能挡重复:数据库主键不允许重复,第二次 INSERT 同主键抛 DuplicateKey 异常(或更新 0 行),业务上直接当作"已经处理过"跳过。
  • "至少一次 + 业务幂等"组合:MQ 保证消息不丢(至少一次),业务层保证重复无害(幂等),合起来效果≈"恰好一次"。这是分布式系统的经典组合拳,面试官听到这个组合会点头。
  • 注意口径:这里的幂等是"业务结果不重复",不是"第三方不重复计费"(模型调用那种做不到,见 VidFlow 的 Q9 延伸)。

五、MySQL(6 问)

Q28. MySQL 的索引是什么?为什么用 B+ 树?

简历关键词:MySQL、索引、慢 SQL

【口语化解答示例】

索引就是书的目录。没有目录,找内容要一页页翻(全表扫描);有目录,先定位到章节再翻过去。MySQL 的索引底层是 B+ 树——一种多叉平衡树。为什么是 B+ 树而不是二叉树?因为数据存在磁盘上,磁盘读一次很慢,树越矮需要的"读盘次数"越少。B+ 树每个节点能存很多个 key,三层就能存几百万条数据,也就是说查任意一条数据最多读 3 次磁盘。另外 B+ 树的叶子节点是排好序的链表,范围查询(where id > 100)直接顺着链表扫,特别快。项目里订单表、用户表这些查询频繁的字段都建了索引。

【详细解析】

  • 索引(Index):为加速查询建立的数据结构,以空间换时间。建立索引的列,查询时可以快速定位,不用全表扫描。
  • B+ 树为什么快:① 矮:多叉树,一层能分很多叉,3 层 ≈ 几百万数据 → 查询 3 次磁盘 IO;二叉树要 20 多层 → 20 次磁盘 IO;② 叶子有序:叶子节点串成链表,范围查询和排序友好;③ 非叶子节点只存 key:一个节点能塞更多 key,树更矮。
  • 聚簇索引 vs 二级索引:聚簇索引(主键)的叶子直接存整行数据;二级索引(普通索引)的叶子存主键值,查完整数据要"回表"(拿主键再查一次聚簇索引)。这就是为什么"select * 用不上索引"有时比"只查索引列"慢——覆盖索引可以避免回表。
  • 最左前缀原则:联合索引 (a,b,c),查询条件要"从 a 开始"才能用上,where b=1 用不上。面试爱考。
  • 什么时候索引失效:对索引列做函数运算、隐式类型转换、like ‘%xx’ 开头模糊、or 连接非索引列——背几个典型的。

Q29. 事务的 ACID 是什么?事务隔离级别有哪些?

简历关键词:MySQL、事务、隔离级别

【口语化解答示例】

事务就是"一组要么全成功要么全不成功的操作",比如下单要"扣库存+写订单"两步,不能只成功一步。ACID 是事务的四个特性:原子性(Atomicity,操作要么全做要么全不做)、一致性(Consistency,数据始终符合业务规则,比如库存不能为负)、隔离性(Isolation,事务之间互不干扰)、持久性(Durability,提交了就一定在)。MySQL 有四个隔离级别,从松到严:读未提交(能读到别人没提交的数据,脏读)、读已提交(只能读已提交的,但有不可重复读问题)、可重复读(MySQL 默认,同一个事务里多次读结果一致,靠 MVCC 实现)、串行化(所有事务排队,最安全最慢)。项目里秒杀下单的扣库存和写订单就放在一个事务里,保证要么都成功要么都回滚。

【详细解析】

  • 事务(Transaction):一组 SQL 的集合,要么全部提交(commit)要么全部回滚(rollback)。数据库通过它保证"业务操作不半途而废"。
  • ACID 逐个解释:原子性=全有或全无;一致性=数据永远合法(转账不会凭空多钱);隔离性=并发事务不互相踩;持久性=提交后断电也不丢。
  • 并发读的三个问题:
  • 脏读:读到别人没提交的数据,别人回滚了你就读了个"假数据"。
  • 不可重复读:同一事务里两次读同一行,结果不一样(别人提交了修改)。
  • 幻读:同一事务里两次查询,结果行数不一样(别人插入了新行)。
  • 隔离级别怎么选:MySQL 默认可重复读,因为 InnoDB 用 MVCC 实现它,性能损失小(不是靠锁硬隔离);串行化才真正杜绝所有问题但性能最差。面试答"默认级别 + 为什么默认"就到位了。
  • 项目落点:LifeMate 的 @Transactional 用在扣库存+写订单、支付回调、超时关单上——“多个写操作要么一起成要么一起败”,这就是事务在项目里的实际位置。

Q30. MVCC 是什么?可重复读是怎么实现的?

简历关键词:MVCC、事务、隔离级别

【口语化解答示例】

MVCC 是多版本并发控制,核心思想是"读旧版本,写新版本,互不阻塞"。每一行数据在修改时都会生成新版本,并且记录"这个版本是哪个事务改的"。读的时候,事务根据自己的快照(开始时间点)选择"自己该看到的版本"——所以可重复读级别下,一个事务里多次读同一行,看到的都是自己开始时刻的版本,别人提交了修改也影响不到我。这就实现了"读不加锁、读写不互斥",性能比"读也加锁"好得多。我理解它就像 git 的分支:每个事务在自己的分支上干活,提交时才合并,别人没提交的改动你永远看不到。

【详细解析】

  • 快照(Snapshot):事务开始时刻数据库的"版本快照"。可重复读 = 整个事务期间都用这一个快照,所以读永远一致。
  • 版本链:每行数据有多个历史版本(存在 undo log 里),每个版本带"创建它的事务 id"。读的时候顺着版本链找"在我快照之前创建、且未删除"的最新版本。
  • 和锁的关系:MVCC 解决"普通读"(快照读)的并发;“当前读”(select for update、update、delete)还是要加锁的——因为要真正改数据。这是面试常考的区分:快照读走 MVCC,当前读走锁。
  • 和事务隔离级别的关系:读已提交=每次读都生成新快照;可重复读=事务开始生成一次快照,全程复用。所以两者都是 MVCC,只是快照生成时机不同。
  • 理解不了就记 git 类比:每个事务一条分支,提交(commit)才合并到主干,未提交的改动别人看不到——可重复读就是在自己分支上反复看,永远一致。

Q31. MySQL 的三大日志(redo log、undo log、binlog)分别是什么?

简历关键词:MySQL、三大日志、事务、崩溃恢复

【口语化解答示例】

三个日志管三件事:redo log 管"崩溃恢复"——事务提交前先把"我要改什么"记到磁盘日志里,万一断电,重启后照着日志把没写完的数据补上,保证持久性;undo log 管"回滚和 MVCC"——记录"改之前长什么样",事务回滚时恢复旧值,MVCC 的版本链也是它;binlog 管"复制和恢复"——记录所有写操作,主从复制靠它,误删数据也可以用它恢复。简单记:redo 保命(不丢),undo 保退(能回滚),binlog 保备(能复制)。

【详细解析】

  • redo log(重做日志):为什么先写日志再写数据?直接改磁盘数据太慢(随机 IO),先顺序写日志(快),再异步刷数据。崩溃时数据没刷完没关系,按 redo 重做一遍。这就是 WAL(Write-Ahead Logging,先写日志再写数据)。面试答出 WAL 是加分项。
  • undo log(回滚日志):每条修改的反向操作(INSERT 记 DELETE,UPDATE 记旧值)。回滚 = 执行反向操作;MVCC 版本链 = 顺着 undo 找历史版本(Q30)。
  • binlog(二进制日志):MySQL Server 层日志,记录逻辑操作(“把 id=1 的库存改成 99”)。用途:主从复制(从库重放 binlog)、数据恢复(binlog 回放)。注意 binlog 是 Server 层的,redo/undo 是 InnoDB 存储引擎层的——这个"两层"区别说出来很显功底。
  • redo 和 binlog 的两阶段提交:写库要同时保证两个日志一致(崩溃恢复时对齐),InnoDB 用两阶段提交协调。校招能说出"两阶段提交"名字即可,细节不必深挖。

Q32. 数据库锁有哪些?什么是死锁?

简历关键词:MySQL、锁、死锁

【口语化解答示例】

从粒度分:表锁锁整张表,行锁只锁一行,InnoDB 默认用行锁,并发度高。从模式分:共享锁(S 锁,读锁,可以多个一起读)和排他锁(X 锁,写锁,只能一个持有)。行锁还有个细节是间隙锁——锁住"两个值之间的范围",防止幻读(别人往这个范围插新行)。死锁就是两个事务互相等对方释放锁:事务 A 锁了行 1 想锁行 2,事务 B 锁了行 2 想锁行 1,谁也不让谁。MySQL 会自动检测死锁,牺牲(回滚)其中一个小的事务,另一个继续。我的项目里秒杀扣库存用乐观锁(where stock>0),基本不涉及行锁等待,这也是选择乐观锁的一个原因。

【详细解析】

  • 表锁 vs 行锁:表锁实现简单但并发差(锁住整张表,别人都写不了);行锁只锁目标行,并发好,但实现复杂、有死锁风险。InnoDB 支持行锁所以成为默认引擎(对比 MyISAM 只有表锁)。
  • 共享锁 vs 排他锁:S 锁之间兼容(都能读),S 和 X 互斥,X 和 X 互斥。select 默认不加锁(快照读),select for update 才加 X 锁(当前读)。
  • 间隙锁(Gap Lock):可重复读级别下,为了防止幻读,除了锁住命中的行,还锁住"索引区间",让这个区间插不进新行。代价:可能锁的范围比预期大,影响并发。
  • 死锁四条件(能背就背):互斥、持有并等待、不可剥夺、循环等待。MySQL 处理:死锁检测 + 回滚代价小的事务。业务侧预防:按固定顺序加锁、减少事务持锁时间。
  • 乐观锁 vs 悲观锁在项目里的位置:秒杀扣库存用乐观锁(条件更新),订单状态流转用乐观锁(where status=1),都是"靠条件更新保证正确,不提前持锁"——所以死锁风险低。

Q33. 慢 SQL 怎么排查和优化?

简历关键词:慢 SQL、索引、优化、Explain

【口语化解答示例】

排查分三步:第一,打开慢查询日志,把执行超过阈值(比如 1 秒)的 SQL 捞出来;第二,用 EXPLAIN 看这条 SQL 的执行计划——重点看 type(是不是全表扫描 ALL)、key(用没用上索引)、rows(扫描了多少行);第三,对症下药。优化手段按性价比排序:① 加合适的索引(where、order by、join 的字段);② 避免索引失效(不要在索引列上做函数、隐式转换);③ 只查需要的列,别 select *;④ 分页优化(深分页用 id 或游标定位再取);⑤ 实在不行就分库分表或者上缓存。项目里热点数据(店铺、券)就是先上 Redis 缓存,把数据库压力降下来。

【详细解析】

  • 慢查询日志(Slow Query Log):MySQL 的"行车记录仪",把超过阈值的 SQL 记下来。排查慢 SQL 的第一步。
  • EXPLAIN:分析 SQL 执行计划的命令,不真的执行。重点字段:
  • type:ALL=全表扫描(最差)、index、range、ref、const(最好)。
  • key:实际用到的索引;possible_keys:可能用的。
  • rows:预估扫描行数,越少越好。
  • 深分页问题:limit 100000, 10 要先扫 100010 行再丢前 100000 行。优化:where id > 上次最大id order by id limit 10(游标分页),或子查询先定位 id。项目里关注流的滚动分页(ScrollResult:minTime + offset)就是游标分页思想,能主动关联项目讲。
  • 加缓存是最快的"优化":很多慢 SQL 的本质是"不该查库"。项目里店铺详情走逻辑过期缓存、分类列表走 Redis List,就是"减少 DB 访问"层面的优化。
  • 校招能讲清"慢日志 → EXPLAIN → 加索引/改写 SQL"三步就合格了,别背太多冷门细节。

六、Redis(5 问)

Q34. Redis 的 5 种基本数据结构是什么?你的项目里各用在哪?

简历关键词:Redis、数据结构、点赞榜、GEO、BitMap、HyperLogLog

【口语化解答示例】

五种:String(字符串)、List(列表)、Hash(哈希)、Set(集合)、ZSet(有序集合)。String 我用来存验证码、库存、限流计数;Hash 存登录用户信息(字段多,像一个小对象);List 存商铺分类列表(按顺序 push);Set 存"谁买过这张券"(去重 + 判断在不在)、关注列表(还能取交集算共同关注);ZSet 存点赞榜(成员是用户 id,分数是点赞时间,天然按时间排序)、关注推送的收件箱(分数是时间戳,滚动分页)。另外还有 GEO(附近商户,搜 5 公里内的店按距离排序)、BitMap(用户签到,一天一个 bit,一年才 46 字节)、HyperLogLog(UV 统计,百万级数据只用 12KB 内存)这几个高级结构也都在项目里用过。

【详细解析】

  • 五种结构一句话记忆:String=字符串/数字;List=有序可重复列表(排队);Hash=对象(字段-值);Set=无序不重复集合(去重、交集);ZSet=带分数的有序集合(排行榜)。
  • 项目落点逐个对应(面试官就爱问"你项目里用在哪"):
  • String:login:code:(验证码)、seckill:stock:(库存)、limit:ai:user:(限流计数)。
  • Hash:login:token:(用户信息以字段形式存,取某个字段不用整个反序列化)。
  • List:shop_type:(分类列表按序缓存)。
  • Set:seckill:order:(一人一单)、follows:(关注列表,SINTER 求共同关注)。
  • ZSet:blog:liked:(点赞榜)、feed:(收件箱滚动分页——按时间戳排序 + 游标翻页)。
  • GEO:shop:geo:(附近商户);BitMap:sign:(签到);HyperLogLog:UV。
  • 为什么这些结构快:Redis 全部在内存操作 + 单线程无锁 + 数据结构都是精心设计的(跳表实现 ZSet 的排序)。内存是 Redis 快的第一原因。
  • 面试提示:把"结构 → 项目用途"一一对应背下来,比背概念有用得多——这正好是简历专业技能第一行"熟悉 Redis,深刻理解核心数据结构"的展开。

Q35. Redis 的持久化:RDB 和 AOF 有什么区别?

简历关键词:Redis、持久化、RDB、AOF

【口语化解答示例】

Redis 是内存数据库,断电数据就没了,所以要把数据落到磁盘。两种方式:RDB 是"快照"——定期把整个内存的数据拍一张照存到磁盘文件,恢复快、文件小,但两次快照之间的数据可能丢(比如每 5 分钟拍一次,第 4 分钟断电,这 4 分钟的数据就没了);AOF 是"日志"——把每条写命令追加到日志文件里,重启时重放日志恢复数据,最多丢 1 秒(默认 everysec 每秒刷盘),但文件大、恢复慢。生产上一般两个都开:AOF 保数据,RDB 加速重启恢复。我的项目里 Redis 主要存缓存和任务状态,缓存丢了可以重建(MySQL 还在),任务状态有 Checkpoint 兜底,所以对持久化要求不高,理解原理即可。

【详细解析】

  • 为什么需要持久化:Redis 默认数据全在内存,进程退出/机器断电 = 数据清零。持久化 = 把内存数据变成磁盘文件,重启恢复。
  • RDB(快照):定时 fork 一个子进程把内存全量写成 dump.rdb。优点:文件紧凑、恢复快(直接加载);缺点:两次快照之间丢数据、fork 大内存实例有开销。
  • AOF(追加日志):每一条写命令 append 到 .aof 文件。优点:丢数据少(默认每秒刷盘最多丢 1 秒);缺点:文件越来越大(需要 rewrite 压缩)、恢复要重放所有命令(慢)。
  • 怎么选:对数据敏感 → AOF(甚至 always 每命令刷盘);追求性能和恢复速度 → RDB;生产通常混合(RDB 做基础快照 + AOF 补增量,Redis 4.0 的混合持久化)。
  • 项目关联(很加分):主动讲"我的 Redis 数据分两类:缓存类(丢了能重建,无所谓)和状态类(任务 activeKey、Checkpoint 热缓存,MySQL 才是真源),所以 Redis 挂了最坏是任务暂时不可用/变慢,不会永久丢"——体现你理解"缓存和事实源分离"。

Q36. Redis 的过期删除策略是什么?key 过期了会立刻被删吗?

简历关键词:Redis、过期、惰性删除、定期删除、内存淘汰

【口语化解答示例】

不会立刻删。Redis 用的是"惰性删除 + 定期删除"组合:惰性删除是"用的时候才检查"——每次访问 key 时看它过期没,过期就删掉返回空;定期删除是"主动巡检"——每隔一段时间随机抽一批 key 检查,过期的删掉。为什么不搞"定时器到点就删"?因为 key 太多了,每个都挂个定时器内存和 CPU 都受不了。另外还有最后一道防线:内存淘汰策略——内存满了还没被删的 key,按策略(比如 LRU 最近最少使用)踢掉一些,防止 Redis 被撑爆。所以 Redis 的过期是"近似实时"的,业务上不能依赖"过期马上触发"——这也是我不用 Redis 过期监听做订单超时的原因。

【详细解析】

  • 惰性删除(Lazy):被动。访问 key 时才判断过期。优点:省 CPU;缺点:过期了但一直没人访问的 key 会一直占内存(内存泄漏感)。
  • 定期删除(Active/Periodic):主动。每秒跑几次,随机抽样检查并删除过期 key。控制每次耗时,避免阻塞主线程。
  • 内存淘汰(Eviction):内存写满时的兜底。常见策略:noeviction(拒绝写入)、allkeys-lru(淘汰最久没用的)、volatile-lru(只淘汰设置了 TTL 的)。生产一般 allkeys-lru 或 allkeys-lfu。
  • 业务启示:过期时间不是精确的"闹钟",是"大概在这个时间附近失效"。所以:① 缓存 TTL 是兜底不是主方案;② Redis 过期监听(keyspace notifications)不保证实时——项目里超时关单用 RocketMQ 延迟消息,就是对比后选型(见 Q24)。
  • 一句话记忆:惰性删(访问时)+ 定期删(巡检时)+ 淘汰(满时)= 三道防线。

Q37. 哨兵和集群是什么?Redis 怎么做高可用?

简历关键词:Redis、哨兵、集群、高可用

【口语化解答示例】

单机 Redis 挂了,整个系统就瘫了,所以要做高可用。哨兵(Sentinel)是"监工":它盯着主节点和从节点,主节点挂了,哨兵自动把某个从节点提升为新主节点(故障转移),客户端自动连新主,整个过程不用人工干预。从节点平时负责"主从复制"——主节点写,从节点同步数据,分担读压力。集群(Cluster)更进一步:数据分片存到多个主节点上,每个主节点又有从节点备份,既解决了单机容量上限,又解决了单点故障。我的项目是学习性质的,用的单机 + 理解哨兵/集群原理;如果上线,至少上哨兵保证不单点。

【详细解析】

  • 主从复制(Replication):一个主节点负责写,从节点同步数据(异步复制),读可以分流到从节点。但主挂了,从不会自动上位,需要哨兵。
  • 哨兵(Sentinel):独立的一组进程,职责:监控(心跳检测)、通知、故障转移(选新主)、配置下发。注意哨兵自己也要集群部署(至少 3 个),否则哨兵自己也成单点。选主用"投票",多数派同意才算。
  • 集群(Cluster):数据按 slot(16384 个槽)分片到多个主节点,每个主节点挂从节点。读写可以横向扩展,单点故障自动迁移。对比哨兵:哨兵是"一主多从 + 自动切换",容量还是单机;集群是"多主分片",容量和吞吐都能扩展。
  • 简历关键词:专业技能里写了"哨兵机制"——能讲清"监控、通知、自动故障转移"三个词 + 项目关联(单机学习、生产至少哨兵)就够校招水平。
  • 项目关联:VidFlow 的 Redisson 锁/限流都依赖 Redis,Redis 挂了系统 fail closed(拒绝新任务)。所以高可用不是"锦上添花"而是"核心依赖的保障"——这句话说出来面试官会觉得你有全局观。

Q38. 手写 Redis 分布式锁有哪些坑?为什么用 Redisson?

简历关键词:Redis、分布式锁、SETNX、Redisson

【口语化解答示例】

手写分布式锁看起来就一句 SETNX(不存在才设置),但坑很多:第一个坑,忘了设过期时间——拿到锁的线程崩了,锁永远不释放,死锁;第二个坑,过期时间设了但任务没跑完——锁自己过期了,别的线程拿到锁,两个线程同时在跑,互斥失效;第三个坑,释放锁的时候不校验是不是自己的——A 的锁过期了,B 拿到锁,A 跑完把 B 的锁删了,C 又进来了,锁完全乱套。所以手写要"SETNX + 过期时间 + 释放前校验值 + Lua 原子删除"一套组合拳。Redisson 把这些都封装好了,还加了 WatchDog 自动续期(Q5),我直接用现成的,把精力放在业务上。

【详细解析】

  • SETNX 是什么:SET key value NX EX 30 一条命令搞定"不存在才设置 + 过期时间"——注意必须一条命令(分两条会有窗口期)。这就是"加锁"。
  • 三个经典坑(面试必考,背熟):
    1. 没设过期时间 → 持锁线程崩溃 → 永久死锁。
    2. 过期时间太短 → 任务没跑完锁先没了 → 互斥失效(WatchDog 解决)。
    3. 误删别人的锁 → 释放前先 GET 校验 value 是不是自己放的(value 用唯一标识,如 UUID),校验+删除要用 Lua 保证原子(否则校验完到删除之间锁可能换了主人)。
  • 为什么项目用 Redisson:① 上述坑全部封装好;② WatchDog 自动续期;③ 项目已经依赖 Redis,不增加新组件;④ 可重入(同一线程可重复拿锁)。
  • Redis 分布式锁的本质边界(讲出来是加分):锁是"尽力而为"的——Redis 主从切换瞬间可能丢锁(主还没同步给从就挂了,新主上没有锁记录)。所以锁不能当唯一保证,业务状态要能兜底(项目里 Checkpoint + completedKey + 数据库状态才是最终防线)。这正好呼应 Q4 的"锁只管并发,幂等靠业务"。

七、Java / JUC(4 问)

Q39. HashMap 的底层原理是什么?

简历关键词:Java、集合、HashMap

【口语化解答示例】

HashMap 底层是"数组 + 链表 + 红黑树"。存数据时先算 key 的哈希值,再对数组长度取模,定位到数组的一个槽位(桶);如果两个 key 落到同一个槽(哈希冲突),就在这个槽上挂链表;链表太长(超过 8 个)就转成红黑树,查找从 O(n) 降到 O(log n)。数组长度默认 16,装到 75% 就扩容(负载因子 0.75),扩容时重新计算所有元素的位置。它不是线程安全的——多个线程同时 put 可能数据错乱,并发场景要用 ConcurrentHashMap。项目里我用 HashMap 的场景都是单线程的(比如组装查询参数),多线程共享的数据都用的 ConcurrentHashMap 或者 Redis。

【详细解析】

  • 哈希表(Hash Table):数组 + 哈希函数。哈希函数把 key 算成一个数字,直接当数组下标——查找 O(1)。“哈希冲突”:两个 key 算出同一个下标,挂链表解决。
  • 为什么要转红黑树:链表一长,查找退化成 O(n)(一个个遍历)。红黑树是自平衡二叉搜索树,查找 O(log n)。触发条件:链表长度 ≥ 8 且数组长度 ≥ 64。
  • 扩容(Resize):数组装太满(超过负载因子 0.75)就翻倍扩容、重新哈希。所以 HashMap 的容量最好是 2 的幂(取模用位运算,快)。
  • 为什么线程不安全:并发 put 时可能两个线程同时往同一个链表插入(JDK7 头插法甚至可能死循环)、size 计数错乱。解决:Hashtable(全表锁,太慢)、ConcurrentHashMap(分段/细粒度锁 + CAS,快)。
  • 简历关联:专业技能"熟悉常用集合及数据结构"——面试官从 HashMap 切入是最高频的开局,答出"数组+链表+红黑树、负载因子 0.75、线程不安全"三段就过关。

Q40. 线程池的参数有哪些?你的项目里哪里用了线程池?

简历关键词:JUC、线程池、ThreadPoolExecutor

【口语化解答示例】

线程池核心参数 7 个:核心线程数(corePoolSize,常驻线程)、最大线程数(maximumPoolSize)、空闲存活时间(keepAliveTime,非核心线程空闲多久回收)、时间单位、任务队列(workQueue,排队的任务放哪)、线程工厂(命名线程)、拒绝策略(队列也满了怎么办,有 Abort 直接抛异常、CallerRuns 让调用方自己跑、Discard 丢弃等)。执行逻辑:来任务先给核心线程,核心满了进队列,队列满了开新线程到最大,最大也满了走拒绝策略。我的项目里 VidFlow 配了 4 个线程池:AI 编排、ASR、OCR、模型调用,都是有界队列 + Abort 拒绝,防止任务无限堆积把内存打爆;ASR 和 OCR 两个线程池并行跑,互不阻塞。

【详细解析】

  • 为什么要线程池:频繁创建销毁线程开销大(创建线程要分配栈、系统调用),池化复用。类比:餐厅的服务员是"池",客人来了有服务员就用现成的,忙不过来就排队(队列),再忙就加临时工(最大线程),还忙就拒客(拒绝策略)。
  • 核心参数逐个理解(背 7 个):corePoolSize(固定员工)、maximumPoolSize(含临时工上限)、keepAliveTime(临时工闲多久辞退)、unit(时间单位)、workQueue(排队区)、threadFactory(给线程起名,方便排查日志)、handler(满员拒客策略)。
  • 任务处理顺序(爱考):核心线程 → 队列 → 非核心线程 → 拒绝策略。注意顺序:先排队后扩线程(不是先扩线程再排队)——很多面试题就考这个。
  • 拒绝策略 4 种:AbortPolicy(抛异常,默认)、CallerRunsPolicy(调用方线程自己执行,慢但不会丢)、DiscardPolicy(静默丢弃)、DiscardOldestPolicy(丢最老的)。
  • 项目落点:VidFlow ThreadPoolConfig——aiTaskExecutor/asrExecutor/ocrExecutor/modelCallExecutor,队列满 Abort + 优雅关闭(shutdown 等任务完成)。OCR 线程数按 CPU 核数算(availableProcessors/2),这个细节讲出来很加分。
  • 为什么用有界队列:无界队列(如 LinkedBlockingQueue 不设容量)会让"最大线程数"失效——永远在排队,内存被撑爆。

Q41. ThreadLocal 是什么?你的项目里怎么用的?

简历关键词:ThreadLocal、UserHolder、登录用户

【口语化解答示例】

ThreadLocal 是"线程本地变量":每个线程有自己独立的一份副本,线程 A 存的值线程 B 读不到。它最典型的应用就是"在同一个请求的整个处理链路里传递当前用户"——我的 LifeMate 里有个 UserHolder,里面就是一个 ThreadLocal:拦截器从 Redis 里取出登录用户,存进 ThreadLocal;后面的 Controller、Service 任何地方要用当前用户,直接 UserHolder.getUser() 就能拿到,不用把 userId 一层层当参数传。请求处理完,拦截器要 remove() 清掉,否则线程池复用的线程会串号——下个请求可能读到上个请求的用户,这是 ThreadLocal 最容易踩的坑。

【详细解析】

  • ThreadLocal 原理:每个 Thread 对象内部有个 ThreadLocalMap,以 ThreadLocal 为 key 存值。所以"每个线程一份",天然线程隔离,不用加锁。
  • 为什么请求链路要用它:一次 HTTP 请求 = 一个线程从头处理到尾(Controller→Service→Mapper),中间任何一层都要"当前用户"。用参数传递要改所有方法签名;用 ThreadLocal,谁需要谁取。
  • 和"把用户放 Redis/放全局变量"的区别:放全局变量(static)所有线程共享,会串号;ThreadLocal 每个线程独立,正好对应"每个请求一个线程"。
  • 两大坑(面试必讲):
    1. 内存泄漏:ThreadLocalMap 的 key 是弱引用,value 是强引用,线程池的线程不销毁,value 就一直被持有 → 用完后 remove()。
    2. 线程复用串号:线程池的线程处理完 A 请求不销毁,下一个 B 请求复用它,如果不 remove,B 能读到 A 的用户。
  • 项目落点:LifeMate UserHolder(ThreadLocal)+ RefreshTokenInterceptor(写入)+ LoginInterceptor(校验)+ 业务代码读取(UserHolder.getUser().getId())。VidFlow 的 AuthInterceptor 是把 userId 放 request attribute(也是同思路的另一种实现),可以对比讲。

Q42. Synchronized、volatile、CAS 分别是什么?它们解决什么问题?

简历关键词:JUC、Synchronized、CAS、JMM、并发

【口语化解答示例】

三个都是并发工具,解决的问题不同。synchronized 是"互斥锁":同一时刻只有一个线程能进临界区,简单粗暴,管用但会阻塞(其他线程等着)。volatile 是"可见性保证":一个线程改了变量,其他线程立刻能看到(禁止指令重排序),但它不解决"多个线程同时改"的原子性问题——比如 count++ 用 volatile 还是会错。CAS 是"无锁更新":比较并交换,更新前先比一下"现在的值是不是我以为的值",是就更新,不是就重试;它不阻塞、性能好,但高并发下会"自旋"空转,还有 ABA 问题。项目里并发量级没有到需要我手写这些的程度,我用的是 Redisson(锁)、ConcurrentHashMap(容器)、AtomicLong(计数)这些现成的并发组件,但理解原理才能选对工具。

【详细解析】

  • JMM(Java 内存模型):规定"线程之间怎么共享变量"。核心:每个线程有自己的工作内存(缓存副本),主内存是共享的。线程 A 改了副本,线程 B 的副本可能还是旧的——这就是"可见性"问题。volatile 解决可见性(写立刻刷主内存 + 读强制读主内存),synchronized 和锁也顺带保证可见性。
  • 原子性、可见性、有序性(并发三性,背熟):原子性=操作不可分割(count++ 不是原子的:读-加-写三步);可见性=一个线程的修改别的线程看得见;有序性=指令不被乱排(编译器和 CPU 会重排,volatile 禁止重排)。
  • CAS(Compare And Swap):compareAndSet(期望值, 新值),硬件指令级原子。乐观锁思想:先比后换,失败就重试(自旋)。AtomicInteger、ConcurrentHashMap 底层都用它。ABA 问题:A→B→A,CAS 以为没变过——用版本号解决(AtomicStampedReference)。
  • synchronized 的升级:锁可以升级:无锁→偏向锁→轻量级锁(自旋)→重量级锁(阻塞)。能说出"锁升级"三个字就是加分项,细节不用背。
  • 项目关联:Redis 的 Lua、Redisson 锁、数据库乐观锁都是"并发控制"的不同实现,可以串起来讲:并发控制的本质是"原子性 + 可见性",不同工具只是实现方式不同(锁 vs CAS vs 单线程执行)。

八、JVM(3 问)

Q43. JVM 的内存结构有哪些区域?

简历关键词:JVM、内存结构、堆、栈

【口语化解答示例】

JVM 内存分几块:堆(Heap)——最大的一块,所有 new 出来的对象都在这,GC 的主要战场;虚拟机栈(Stack)——每个线程一个,存方法调用和局部变量,方法调用就是"压栈",返回就是"弹栈",栈溢出(StackOverflowError)就是递归太深把栈压爆了;方法区(Metaspace,JDK8 叫元空间)——存类信息、常量、静态变量;程序计数器——记录当前线程执行到哪条字节码,线程切换后靠它恢复;还有本地方法栈(跑 native 方法)。简单记:对象进堆,方法进栈,类信息进元空间。

【详细解析】

  • 为什么要有这么多区域:Java 程序跑起来,数据分两类:生命周期短的(方法里的局部变量)和长的(对象、类信息)。分开存放,GC 才能"只扫堆",效率高。
  • 堆 vs 栈的对比(面试爱问):堆——所有线程共享、存对象实例、GC 管理、空间大但慢;栈——线程私有、存局部变量/引用、方法结束自动销毁、空间小但快。基本类型在栈上,对象在堆上,对象的引用在栈上。
  • JDK8 的变化:永久代(PermGen)→ 元空间(Metaspace),最大的区别是元空间用本地内存(不在 JVM 堆内),不再有"永久代 OOM"。
  • 怎么记:new 出来的对象在堆;方法调用的"现场"(参数、局部变量、返回地址)在栈;类的"说明书"(Class 对象、常量池、静态变量)在元空间;每个线程一个栈和一个程序计数器。
  • OOM(OutOfMemoryError):堆满了且 GC 也救不回来 → OOM。排查:看堆内存(-Xmx)、找大对象、看是否内存泄漏。校招能说出"OOM 一般是堆溢出"即可。

Q44. 垃圾回收(GC)是什么?有哪些常见的垃圾回收器?

简历关键词:JVM、GC、垃圾回收器

【口语化解答示例】

GC 就是自动回收"没人再用的对象"的内存,不用程序员手动释放(对比 C++)。怎么判断"没人用"?主流是可达性分析:从 GC Roots(栈上的引用、静态变量这些"根")出发,能遍历到的对象就是活的,遍历不到的就是垃圾,回收掉。对象在堆里分代:新对象进新生代(Eden + 两个 Survivor),活过几次 Minor GC 晋升到老年代。回收算法:标记-清除(有碎片)、标记-复制(新生代用,无碎片但浪费一半空间)、标记-整理(老年代用,移动对象消除碎片)。常见的回收器:新生代 ParNew、老年代 CMS(并发标记清除,追求低停顿)、G1(把堆分成小块 region,可预测停顿,JDK9+ 默认)。项目里我主要知道调 JVM 参数(-Xms/-Xmx)和控制对象大小,深入调优还没到那个阶段。

【详细解析】

  • GC Roots 有哪些:栈上的局部变量/引用、静态变量、常量池引用、JNI 引用。可达性分析 = 从根出发 BFS,到的了=活,到不了=死。
  • 分代收集(Generational GC):经验规律:大部分对象"朝生夕死"。新生代频繁回收(Minor GC),用复制算法(Eden 和 Survivor 互相倒,浪费小);老年代很少回收(Major/Full GC),用标记-整理。
  • 三种回收算法:
  • 标记-清除:标记垃圾→清除。快但有内存碎片(大对象可能放不下)。
  • 标记-复制:把存活对象复制到另一块,整块清空。无碎片,但需要双倍空间(用 Eden:Survivor=8:1 缓解)。
  • 标记-整理:标记→把存活对象往一端移动→清掉后面。无碎片但慢(要移动对象)。
  • 常见回收器一句话:Serial(单线程,客户端)、Parallel(并行吞吐优先,服务端默认组合)、CMS(并发,低停顿,已废弃)、G1(分 region,可预测停顿,默认)。能说出 G1 是默认 + "region + 可预测停顿"就很好。
  • 和项目的关系(说实话即可):校招项目一般不需要深入 GC 调优,但为什么对象生命周期短有利于 GC、为什么不要在大循环里 new 大对象这种"代码和 GC 的关系"能讲。简历写了"GC 算法、常见垃圾回收器",把上面的概念讲清就达标。

Q45. 双亲委派机制是什么?为什么要这样设计?

简历关键词:JVM、双亲委派、类加载

【口语化解答示例】

类加载就是"把 .class 文件读进内存变成 Class 对象"。双亲委派是加载规则:当一个类要被加载时,先不自己加载,而是把请求向上抛给父加载器,一直抛到最顶层的启动类加载器(Bootstrap),它加载不了再一层层往下让子加载器试。为什么要这样?保证核心类不被篡改——比如有人写了一个假的 java.lang.String,双亲委派保证 String 永远由 Bootstrap 加载,你写的假 String 根本没机会被加载,这样 Java 核心库的信任根基就不会被破坏;顺带也避免同一个类被加载两份。打破双亲委派的例子是 Tomcat(每个 Web 应用一个类加载器,实现应用隔离),这个我知道但没深入实现过。

【详细解析】

  • 类加载器(ClassLoader):负责把字节码变成 Class。三层:Bootstrap(JDK 核心,如 java.lang)、Extension/Platform(JDK 扩展)、Application(classpath 下的业务代码)。
  • 双亲委派流程:加载一个类 → 问父加载器"你能加载吗" → 父再问父 → Bootstrap 先试 → 不行往下传 → 直到某个加载器能加载。父先子后,层层向上再向下。
  • 为什么这么设计(三个理由,背两个就够):
    1. 安全:核心类只能由 Bootstrap 加载,防止自定义的 java.lang.String 混进来(类名冲突、篡改)。
    2. 避免重复加载:同一个类只被加载一次(都由同一个上层加载器加载)。
    3. 一致性:所有代码用的核心类都是同一份。
  • 怎么打破:重写 loadClass 方法(不往上抛)。典型:Tomcat(应用隔离)、JDBC(SPI 让 Bootstrap 加载的代码能找到第三方驱动——线程上下文类加载器)。
  • 简历关联:专业技能写了"双亲委派机制",能讲"是什么(父先子后)+ 为什么(安全+不重复)+ 打破的例子(Tomcat/JDBC 名字)"就够校招。

九、Spring / 框架(3 问)

Q46. IOC 和 AOP 是什么?用你的项目举例子

简历关键词:Spring Boot、IOC、AOP、MyBatis-Plus

【口语化解答示例】

IOC(控制反转)就是"对象的创建和管理交给 Spring 容器,而不是自己 new"。以前要自己 new Service、new Mapper,现在用 @Service、@Resource 标注,Spring 启动时自动创建好,用的时候直接注入——好处是解耦、方便替换实现、统一管理生命周期。我的两个项目里所有 Service、Mapper、RedisTemplate 都是这么注入的。AOP(面向切面)就是"把横切逻辑抽出来统一处理",我的 LifeMate 限流就是 AOP 的典型:@RateLimiter 注解标在方法上,切面在方法执行前自动跑限流,业务代码里看不到任何限流逻辑,加限流就像"贴标签"一样简单。还有 Spring 的事务管理也是 AOP 实现的(@Transactional 注解)。

【详细解析】

  • IOC/DI(控制反转/依赖注入):控制权从"自己 new"反转给"容器注入"。为什么好:① 解耦(面向接口,换实现不用改调用方);② 单例管理(默认一个 bean 共享);③ 生命周期统一(销毁、初始化钩子)。Spring 容器启动时:扫描注解 → 创建 bean → 注入依赖(构造器注入优先)。
  • AOP 术语(背熟):切面(Aspect,横切逻辑本身,如限流切面)、切点(Pointcut,哪些方法被切,如"标了 @RateLimiter 的方法")、通知(Advice,切面里的逻辑何时执行:前置/后置/环绕)、连接点(JoinPoint,被切的方法调用)。面试能说出"切点+通知+环绕"就够。
  • AOP 的原理:动态代理——Spring 为 bean 生成代理对象,调用方法时先经过代理(切面逻辑),再进真实方法。两种代理:JDK 动态代理(基于接口)、CGLIB(基于子类,无接口时用)。
  • 项目里的 AOP 落点:LifeMate RateLimitAspect(环绕通知:先执行 Lua 限流,通过才 proceed 进业务方法)。“我项目里 AOP 用在了限流上”——这句话直接证明你用过而不只是背过。
  • 事务为什么也是 AOP:@Transactional 标注的方法,Spring 代理在方法前开启事务、正常返回提交、异常回滚——都是"代理拦截"。

Q47. Spring 事务是什么?@Transactional 什么情况下会失效?

简历关键词:Spring、事务、@Transactional

【口语化解答示例】

Spring 事务就是让一组数据库操作"要么全成功要么全回滚",用 @Transactional 标注就行。但有几个常见的失效场景:第一,方法内部自调用——同一个类里,方法 A 调方法 B,B 标了 @Transactional 也不生效,因为事务靠代理实现,A 内部调 B 是直接调 this.B(),没走代理;第二,异常被吞掉——catch 住异常不抛,事务以为成功了不会回滚;第三,抛的是受检异常——默认只对 RuntimeException 回滚,受检异常要指定 rollbackFor;第四,方法不是 public——private/protected 方法不生效;第五,类没有被 Spring 管理——没标 @Component/@Service,根本没代理。我的项目里秒杀落单、支付回调、超时关单都用了 @Transactional,自调用那个坑我踩过,所以写代码时特别注意。

【详细解析】

  • 事务和 AOP 的关系:@Transactional 本质是 AOP 通知(见 Q46),所以"代理失效"的场景 = 事务失效的场景。
  • 五个失效场景(背熟,面试高频):
    1. 自调用:this.method() 不走代理 → 解决:注入自己/用 AopContext.currentProxy()(LifeMate 之前代码里有这个痕迹,后来删了死代码,别主动提)。
    2. 异常被 catch 吞掉:catch 后没抛 → 事务不知道失败 → 不回滚。
    3. 受检异常:默认只回滚 RuntimeException/Error,IOException 这种要 @Transactional(rollbackFor = Exception.class)。
    4. 非 public 方法:代理只能拦 public。
    5. 类没进容器:没有代理。
  • 传播行为(Propagation):事务方法调事务方法怎么处理——REQUIRED(默认,有事务就加入,没有就新建)、REQUIRES_NEW(无论如何新开一个,互不影响)。能说出 REQUIRED 默认就够。
  • 项目落点:VoucherOrderServiceImpl 的支付回调、超时关单用 @Transactional——“关单 + 归还库存在一个事务里,要么都成功要么都回滚”。注意现在落单在 RocketMQ 消费者里做(save + 扣库存),这也是要讲清的口径(见 Q25)。

Q48. MyBatis-Plus 是什么?和 MyBatis 有什么区别?

简历关键词:MyBatis-Plus、MyBatis、ORM

【口语化解答示例】

MyBatis 是一个 ORM 框架,解决"Java 对象和数据库表之间互相转换"的问题:以前要手写 JDBC(连接、PreparedStatement、ResultSet 一个个取字段),MyBatis 让你写 SQL 映射就行,复杂 SQL 写在 XML 里。MyBatis-Plus 是 MyBatis 的增强插件(国产,官方叫"为简化开发而生"),最大的特点:单表 CRUD 不用写 SQL——Mapper 接口继承 BaseMapper,自带 selectById、insert、update 这些方法;Service 层继承 IService 还有更高级的链式查询(query().eq(“type_id”, id).page(…))。我的两个项目都用的 MyBatis-Plus,像秒杀订单的 save、店铺的分页查询、条件更新(.eq(“status”,1).set(“status”,2))全是它提供的,我只写业务逻辑不写基础 SQL。复杂 SQL 还是自己写,比如 VoucherMapper.xml 里查"店铺的券列表"那种多表查询。

【详细解析】

  • ORM 是什么:Object-Relational Mapping,对象和关系表的映射。Java 对象 ↔ 数据库行,框架自动转换,不用手写 ResultSet 取字段。
  • MyBatis 核心概念:Mapper 接口 + XML/注解写 SQL;#{} 预编译占位(防 SQL 注入)、${} 字符串拼接(有注入风险,别用)。项目里 order by field(id, ...) 的 last() 拼接是动态 SQL 的用法。
  • MyBatis-Plus 的三大件:① BaseMapper:内置单表 CRUD;② IService/ServiceImpl:业务层封装 + 链式查询(query()/update()/lambda 表达式);③ 分页插件(PaginationInnerInterceptor,项目 MybatisConfig 里注册了)——Page 对象分页,不用手写 limit。
  • MP 为什么快:反射 + 泛型推断表名/字段名(@TableName/@TableId 注解),自动生成 SQL。约定大于配置:实体类名 → 表名(@TableName(“tb_shop”) 显式指定更稳)。
  • 项目落点:LifeMate 的 update().setSql("stock=stock-1").eq("voucher_id", x).gt("stock", 0) 就是 MP 的条件更新(乐观扣库存);VidFlow 的 QueryWrapper/链式查询同样。

十、RocketMQ 深入(2 问)

Q49. RocketMQ 怎么保证消息不丢?可靠传输的三个环节?

简历关键词:RocketMQ、可靠传输、消息丢失

【口语化解答示例】

消息要经过三个环节:生产者 → Broker(消息服务器)→ 消费者,每个环节都可能丢,要分别防。生产者环节:用同步发送(send 方法等待 Broker 确认),失败就重试,还不行就报错让上层处理——我的代码里投递失败会删除 activeKey,避免留下一个永远"处理中"的假任务。Broker 环节:消息先写内存再异步刷盘,默认同步刷盘(写完磁盘才确认)可以防宕机丢失;另外主从复制,主挂了从还有备份。消费者环节:消费成功才 ACK,处理失败抛异常让 MQ 重投,不会"假装成功"把消息丢掉。总结:发送同步确认、存储刷盘+主从、消费成功才确认,三层都堵住,消息就不容易丢。

【详细解析】

  • 三个环节的"防丢"口诀:发要确认(同步 send + 重试)、存要落盘(刷盘策略 + 主从复制)、收要回执(ACK)。
  • ACK 是什么:Acknowledge,确认。消费者处理完消息,告诉 Broker"我处理好了,可以删了"。没收到 ACK,Broker 会重投。所以消费者代码里 catch 住异常不抛 = 假装成功 = 消息悄悄丢(对应 Q47 事务失效场景 2,两个坑是同一个根源:吞异常)。
  • 刷盘(Flush):消息先到内存,同步刷盘=写磁盘后才回 ACK(慢但稳),异步刷盘=先回 ACK 后台刷(快但宕机可能丢最近几条)。
  • 和"消息重复"的关系:可靠传输的代价就是"至少一次"——为了不丢,可能重投,所以业务层必须幂等(Q7/Q27)。"不丢"和"不重"是跷跷板,业务幂等是解法——这句话是面试的黄金总结。
  • 项目关联:VidFlow 消费者"消费失败抛异常交给 MQ 重试、3 次后进死信主题 + 失败任务表",就是"收要回执 + 兜底可追溯"的实现;投递失败删 activeKey 是"发要确认"的实现。两个项目都能串起来讲。

Q50. 消息堆积怎么办?顺序消费是什么?你的项目需要吗?

简历关键词:RocketMQ、消息堆积、顺序消费

【口语化解答示例】

消息堆积就是"生产速度 > 消费速度",队列里的消息越积越多。排查先定位原因:是生产突增(秒杀、活动),还是消费者变慢(下游慢、模型调用阻塞),还是毒消息反复重试卡住队列?然后对症:消费者变慢就加消费者实例/提高并发,但要先看瓶颈在哪——我的场景里瓶颈经常是 ASR/LLM 第三方限流,盲目加消费者只会把第三方打得更满;毒消息就隔离到死信主题,别让它占着正常消费。顺序消费就是"消息按发送顺序被消费"——比如订单要先创建再支付,乱序就出问题。RocketMQ 的顺序消息要生产者按业务 key 选队列(hash 到同一个队列)+ 消费者单线程消费该队列。我的两个项目都没有用顺序消费——秒杀订单创建和支付回调之间没有强顺序依赖(状态机保证正确),分析任务更是完全无序;所以简历和面试里我都不会主动提顺序消费,被问到就说"我的场景不需要,原因是……"。

【详细解析】

  • 消息堆积(Backlog):队列积压。第一反应不是"加机器"而是"看瓶颈"——这是面试官想听的判断力。瓶颈在消费侧:加实例/提并发/批量消费;瓶颈在下游(第三方限流、数据库慢):降生产速率、隔离慢任务;瓶颈是毒消息:隔离 + 人工处理。
  • 顺序消费(Ordered Message):全局有序(所有消息严格按序,性能差)vs 分区有序(同一业务 key 的消息有序,够用)。实现:生产端按 key 哈希选固定队列,消费端队列内单线程。代价:吞吐下降、单队列故障影响面大。
  • 为什么你的项目不需要(重点,主动讲):
  • LifeMate:订单状态流转靠"乐观锁 + 状态条件"(where status=1),乱序到达也不会错——先支付后关单/先关单后支付,只有一个能成功。状态机替代顺序,这就是不用顺序消息还能保证正确的设计。
  • VidFlow:分析任务之间完全独立,天然无序。
  • 简历关联:简历没写顺序消费——"没写的技能不要主动吹,被问到能给出’我的场景不需要+理由’"才是正确姿势。这也呼应文档开头的红线:诚实边界比堆名词重要。

结尾:面试收尾小贴士

  • 被问倒怎么办:先承认"这块我还没深入",再把你理解的 30% 讲出来,最后问"能给我讲讲吗"——态度比答案重要,校招生没人要求全会。
  • 所有数字口径:50ms 只指"任务受理接口";不报 Stars、不报"提升 50%"这类没有复现依据的数字;"生产级"三个字慎用,说"本地 Demo 验证过"更稳。
  • 两个项目的主线一句话:VidFlow = “工程可靠性(分片/MQ/幂等/限流/重试/恢复)+ Agent 质量(证据/校验/预算)”;LifeMate = “秒杀五板斧(Lua 原子 / MQ 削峰 / 缓存三件套 / 限流 / 乐观锁状态机)”。
  • AI Coding 的口径:主动说"大量代码 AI 辅助完成,我做的是方案取舍、状态边界、异常链路审查";被问"AI 写的代码你检查什么":编造的 API、前后端契约不一致、异常被吞、为显先进堆无用组件——四个检查点背熟。
  • 项目归属口径(最后再强调一次):两个项目都是"基于开源项目改造/重构",不是从零原创。主动讲清楚,然后立刻展示你改造的部分——诚实 + 思考,才是校招生最稳的人设。