字节面经-字节跳动后端开发岗面经-18
- 轮次
- 面试合集
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
(面经 01)
- 挑一个能体现你能力和亮点的内容讲一下
- 难点在哪?
- 还有呢?
- Kafka 性能为啥好?
- 磁盘顺序读写既然快,为啥不都用磁盘顺序读写?
- Kafka 怎么保证顺序消费,代码里怎么实现?
- 内部的语言以及框架是什么?
- 内部用的容器是 Tomcat 还是哪一种?
- 算法:矩阵转置
(面经 02)
- 自我介绍
- 实习期间,优化具体是指什么?
- 是偏前端的工作吗?
- 实习是偏前端还是偏后端?
- 项目想达成怎样的效果?
- 定位是什么?
- 仿照的 Claude Code 和 ChatGPT,在你认知中它们在做什么?
- 项目的前后端交互是如何完成的?
- 项目是否实现了类似 ChatGPT 的流式输出效果?
- 为什么通过 RabbitMQ 实现聊天记录异步入库?
- 聊天记录入库会是一个很慢的动作吗?
- 造成了什么时间瓶颈?
- 数据库表是怎么设计的?
- 第二张表(会话表)的一条记录代表什么?
- 涉及哪些字段?
- 聊天内容字段定义为什么类型?
- 放到索引里合适吗?
- 多用户多会话场景下,数据库有建立索引吗?
- 数据库索引的底层结构是怎样的?
- 为什么 B+ 树的非叶子节点不存数据?
- 如果存了会有什么问题?
- 双向链表和存不存数据有强关联吗?
- MySQL 有哪些事务隔离级别?
- 可重复读有什么问题?
- 举一个可重复读下触发幻读的具体实例?
- 串行化牺牲性能,实际怎么解决幻读?
- 间隙锁是如何运作的?
- 联合索引 (user_id, status, created_at) 下,如果查询条件未包含 user_id,索引覆盖情况如何?
- 会不会走索引?
- 如果不改表,怎样修改查询语句才能走索引?
- 岛屿数量(LeetCode 200)
- 针对岛屿数量的解法,有什么优化的思路?
(面经 03)
- 自我介绍
- 挑一个最体现能力的点讲
- 设计一个抢红包系统
- 手撕:超运算
(面经 04)
- redis 的大 key 和热 key 怎么解决
- 令牌桶的实现
- 算法 1:手写 singleflight 机制
- 算法 2:二叉树层序遍历
(面经 05)
- 先讲一下你在实习做的第一个项目,整体的框架是怎样的,用来做什么的?
- 你负责的部分 Agent 是怎么实现的,有没有底层的设计优化?
- 你怎么看待 jev,和 bert 有什么区别,你认为 jev 对于你的 agent 有什么优化点?
- rpc 和 mcp 的接口区别是什么?
- 开发 MCP 最重要的是什么?
- MCP 定义出参入参,实际过程中有没有出现调用 mcp 入参和出参出错的,怎么防止呢?
- skill、workflow、Agent 三者的联系和区别?
- 你的项目中有没有用到 rsi,怎么做的?
- 你的项目为什么要用一个 Agent 而不是直接写一个 skill 嵌入到里面?
- 你们的评审准确率等一些指标是怎么计算的?
- 聊一下你的后端项目,简单介绍一下
- redis 通过 lua 预扣减为什么还要乐观锁兜底?
- 如果假设投递消息是 exactly once,不丢不重复,还有必要乐观锁兜底吗?
- 强一致和最终一致性
- redis 是强一致还是最终一致性?
- redis 怎么做主从同步的?
- master 向 slave 传递数据,这里的数据都是什么?
- 手撕:k 个一组的链表反转
- 手撕:两数相加 II(不能用额外空间、输出链表)
(面经 06)
- 自我介绍
- 你做过的几个项目里,哪个最有挑战?
- RAG 智能知识库主要解决什么问题?
- 具体应用场景是什么?
- 是给模拟面试使用,还是独立知识库功能?
- 学习小组具体怎么使用?
- RAG 知识库和大模型是怎么连接的?
- 完整请求链路是什么?
- 用户问题从哪里进入?
《参考解析》
Kafka:为什么快、顺序读写的边界与顺序消费
快的核心是把随机写变成顺序写:消息以 append 方式追加到 partition 的 segment 文件,磁盘顺序写能逼近内存带宽;生产者把多个消息批量发送并压缩(batch + snappy/lz4/zstd),把网络和磁盘的每次往返摊薄;broker 侧用 sendfile 做零拷贝,数据从页缓存直接送到网卡,绕开用户态拷贝;再叠上页缓存命中、分区并行、稀疏索引(.index 按偏移量定位、.timeindex 按时间定位)。被追问「既然顺序读写快为什么不都用」,答案要落在访问模式上:顺序写只适合追加型数据(日志、事件流),一旦需要按键随机查、范围查、原地更新,就必须有索引和随机读——数据库的更新、按主键查单行、按条件过滤都做不到「只往后写」,能用顺序写的地方(WAL、binlog、MQ)业界也确实都在用。顺序消费靠 partition 内有序这个前提:同一 partition 只被同一个消费组内的一个消费者消费,单线程按 offset 递增处理就是全局有序。代码实现上要么 max.in.flight.requests.per.connection=1(或配合幂等生产者开 enable.idempotence=true 且 acks=all)保证生产端不重排,要么在消息里带业务序号,消费端按 key 落到同一 partition(key 相同必进同一分区),消费时对乱序做窗口缓冲或按序号去重。代价要说清:为保证顺序就只能牺牲并发度,所以业务上常见的折中是「分区内有序」而不是「全局有序」。
RabbitMQ 异步入库、时间瓶颈与聊天表设计
聊天记录异步入库解决的是把慢操作从用户请求链路上挪走:LLM 流式返回时,首字延迟是体验命门,如果每一轮对话都要等「写库成功」才继续,写库的几毫秒到几十毫秒会直接叠加到响应上;而且写库失败会让整轮对话失败。改成投递 MQ 后,主链路只做一次内存入队,入库由消费者按 batch 落库,失败可重试、可堆积、可削峰。要说清它不是因为「写库很慢」——单条 insert 通常只有几毫秒,真正的时间瓶颈在:写入频率高(流式输出期间可能多次更新同一条消息)、一条消息可能被多次更新(先写占位再补内容)、大文本字段的写入与索引维护、事务与锁竞争、以及用户量上来后的 IO 争用。表设计上典型是三张:会话表(一行 = 一个会话,含 user_id、标题、模型、创建与更新时间、最后一条消息 id)、消息表(一行 = 一条消息,含 session_id、role、content、token 数、状态、时间)、用户表。消息内容字段一般用 TEXT / MEDIUMTEXT / LONGTEXT 或 JSON,不适合直接进索引——大字段进索引会让索引文件膨胀、写入变慢,而且长文本的比较没有业务意义;要做检索应该单独建全文索引或用 ES / 向量库,MySQL 只存原文。索引按查询模式建:(session_id, created_at) 支撑「拉某个会话的历史消息」,(user_id, updated_at) 支撑会话列表;追问「双向链表和存不存数据有强关联吗」的正确回答是没有强关联:叶子节点的双向链表解决的是范围扫描的顺序访问,而「非叶子节点不存数据」解决的是单页能放多少键、树有多高,是页利用率问题,两者是不同层面的设计取舍。
B+ 树索引:非叶子节点为什么不存数据
先讲清楚 B+ 树的分工:非叶子节点只存键和子指针,所有数据(或主键值)都在叶子节点,叶子之间用双向链表串起来。不存数据的原因有三条。① 扇出(fan-out)最大化:一个 16KB 的 InnoDB 页,如果只存 8 字节主键加指针,能放上千个键;一旦塞进整行数据,一页只能放几行,树高立刻从 3 层涨到 5~6 层,每次查询的磁盘 IO 次数随之增加。② 查询代价稳定:数据全在叶子层,任何一次查找走的层数相同、IO 次数可预测,不会像 B 树那样「查到中间节点就返回」导致快慢不一。③ 范围查询友好:叶子层有序且相邻,between / order by 只要定位到起点然后顺着链表扫,不必回到上层做中序遍历。如果非叶子节点存了数据,除了树变高,还会带来页分裂更频繁、缓存命中率下降、更新时上层节点也要跟着改的问题。
联合索引、最左前缀与覆盖索引
联合索引 (user_id, status, created_at) 的排列顺序决定了它能服务哪些查询:最左前缀原则——只有从 user_id 开始的连续列才能被有效利用,缺了 user_id,后面的 status、created_at 都用不上索引树的有序性。所以「查询条件不含 user_id」时,MySQL 大概率走全表扫描或全索引扫描(type=index),而不是 ref / range。EXPLAIN 里看 key 与 type 就能确认。注意两个细节:一是索引覆盖指查询需要的列全在索引里、不需要回表,即便不满足最左前缀,优化器也可能选择扫描整个二级索引(比扫聚簇索引窄),这算「用上了覆盖索引但没用上过滤」;二是 status 这种区分度极低的列放在 user_id 后面还行,单独做索引几乎没意义。不改表怎么写 SQL 才能走索引:把 user_id 补进查询条件(很多场景能从会话或登录态拿到);如果确实拿不到,可以先把 user_id 反查出来再带上(两次查询换一次索引命中);给条件列加函数或隐式类型转换会直接让索引失效,要去掉 date(created_at) = ... 这类写法改成范围条件;实在绕不过去就建一个以 status 打头的索引,或者用覆盖索引减少回表代价。
隔离级别、可重复读的幻读与间隙锁
SQL 标准四个级别:读未提交、读已提交、可重复读、串行化。MySQL InnoDB 的默认是可重复读,而且靠 MVCC 把「不可重复读」基本消掉了:事务第一次读时生成 ReadView,之后按 undo log 里的版本链找到可见版本,所以同一个事务里读到的行内容不变。可重复读剩下的问题是幻读——同一个事务里两次执行同样的范围查询,第二次多出了别的事务插入的行(比如统计「余额 > 100 的账户数」,期间别的事务插入了一条新记录)。MVCC 能挡住已存在行的变化,但挡不住新插入的行,因为快照读看不到它们、而当前读(for update、lock in share mode、更新语句)本来就要读最新数据。InnoDB 的解法是间隙锁(Gap Lock)+ 行锁,合起来叫 Next-Key Lock:在可重复读级别下,对范围条件加锁时,不仅锁住命中的记录,还锁住记录之间的「间隙」和最后一条记录之后的区间,让别的事务无法在这些空隙里插入新行,于是幻读被堵住。要说的取舍:间隙锁会显著降低并发(不同事务锁相邻间隙容易互相阻塞、甚至死锁),所以读已提交级别干脆关掉间隙锁只留行锁;串行化则是所有读都加锁、彻底牺牲并发,实际很少用。除了锁,业务上也可以用「先查后插 + 唯一索引」或「把统计口径改成快照读 + 定期汇总」来规避。
Redis 大 key 与热 key 的治理
大 key 指单个 value 过大(经验阈值:String 超过 10KB,集合类元素数过万)。危害是:单次操作阻塞主线程、网络传输变慢、主从同步和 AOF 重写变慢、集群迁移时卡住、删除时(del)直接阻塞。治理分三步:发现——redis-cli --bigkeys、memory usage <key>、scan 采样统计,或从业务侧埋点看 value 大小分布;拆——把大 Hash 按字段哈希拆成多个小 key、大 List / ZSet 按时间或范围分片、大 String 改成对象存储加指针;删——用 unlink(异步删除)或 hscan 分批删,别用 del;设计阶段就要求 value 有大小上限。热 key 指某个 key 的 QPS 远高于平均(比如秒杀商品、首页配置)。危害是单个节点 CPU / 网卡打满,集群的负载均衡形同虚设。治理:本地缓存挡在最前面(最有效,但要处理失效同步)、读写分离(多加从节点、读走从库)、key 打散(把 hot:1 复制成 hot:1:1…hot:1:N,随机挑一个读,写时全部更新或靠定时刷新)、限流降级(对热点接口单独限流)、预热(活动前把数据提前加载好)。两者要一起看:热 key 往往就是大 key 被高频读,处理手段有重叠,但根因一个是访问集中、一个是数据量集中。
令牌桶限流与 singleflight 请求合并
令牌桶的实现:桶里有按固定速率补充的令牌(rate 个/秒),桶容量为 burst,请求来了先取令牌,取到就放行、取不到就拒绝或排队。它的价值在于允许突发——桶里攒了 token 就能瞬间放行一波流量,漏桶则严格匀速。工程实现要点:用 Redis + Lua 做分布式限流时,把「算时间差补令牌 + 判断 + 扣减」写在一个脚本里保证原子性,时间戳用 Redis 服务器时间避免各节点时钟不一致;返回 Retry-After 让调用方知道该等多久;rate 与 burst 要按后端真实容量设定,限流值高于容量等于没限。单机实现直接用一个 mutex 保护的 (tokens, lastRefill) 结构就够。singleflight 解决的是「缓存击穿时同一个 key 被并发查库」:为每个 key 维护一个 in-flight 调用,第一个请求真正去查,后续相同 key 的请求挂起等待并共享同一个结果;实现上是一张 map[string]*call 加互斥锁,call 里放 sync.WaitGroup 和结果 / 错误,第一个进来的创建 call 并执行 fn,其余 wg.Wait() 后直接读结果,结束后从 map 里删除。要注意:fn 返回的错误会被所有等待者共享;不同的 key 互不影响;fn 内部要带超时和 context,否则一个慢查询会把所有等待者拖住;如果 fn 有副作用(不是纯读)就不能随便合并。
Redis 预扣减、乐观锁兜底与 Redis 的一致性模型
问「Lua 预扣减为什么还要乐观锁兜底」,前提是Lua 只保证了 Redis 内部那一次操作的原子性,管不了 Redis 之外的一切:扣减成功但消息没投出去、投出去了但订单创建失败、消费者重复消费、主从切换时未同步的写丢失、扣减逻辑本身与实际库存对不上(预热数据错)。所以乐观锁(或版本号 / 数据库唯一约束 / 条件更新 where stock >= 1)兜的是「最终落到数据库那一步」的并发与幂等,是链路级的保险,不是对 Redis 的不信任。如果投递是 exactly once 还需要吗——要看 exactly once 的范围:它只覆盖「消息不丢不重」,不覆盖「Redis 与数据库的一致性」。如果整个方案里 Redis 只是缓存、库存权威在数据库且扣减本身是原子的条件更新,那乐观锁确实可以省;但如果 Redis 是权威库存、DB 在后面异步对齐,就必须有对账和版本校验来发现并纠正偏差。强一致 vs 最终一致:强一致要求任何时刻读到的都是最新写,代价是写要等多数派确认;最终一致允许中间态不一致,只保证停止写入后收敛。Redis 主从是最终一致——复制是异步的,WAIT 只能降低窗口不能消除;主从切换时未同步的写会丢,所以不能用它存不能丢的账。主从同步传的是什么:初次连接走全量同步——slave 发 PSYNC,master bgsave 生成 RDB 快照发给它,期间的新写命令缓存在 replication buffer 里,快照传完后补发;之后走增量同步——master 把写命令传播给 slave,同时用 repl_backlog 环形缓冲保存最近一段命令流,断线重连时如果 offset 还在 backlog 范围内就只补发差值(部分重同步),否则退回全量。传输的内容就是写命令流(及 RDB 里的键值数据),读命令不传播,过期删除由 master 触发 DEL 传播给 slave,这也是为什么从库读可能读到逻辑上已过期的数据。
抢红包系统设计
拆成「发、抢、拆、退」四步,每一步都要解决并发和一致性问题。发红包:金额在创建时就算好分配方案(不要抢的时候才算,否则并发下没法保证总额准确),常见的二倍均值法保证每个人期望相等且总额精确;金额单位用「分」存整数避免浮点误差;红包元数据(总金额、个数、剩余个数、剩余金额、过期时间)写库并缓存。抢红包:核心是原子扣减 + 幂等——Redis 用 Lua 把「判断剩余个数 > 0 + 扣个数 + 记录用户已抢」合成一个原子操作,或用 DECR 后判断返回值小于 0 再回补;(红包 id, 用户 id) 要唯一,防止一次点击重复入账;库存为 0 直接返回「已抢完」,不要落库。拆红包:金额分配算法的结果要么预生成列表(Redis List,抢的时候 LPOP,最简单且天然原子),要么在 Lua 里算好返回并写入明细表;异步落库(MQ)把抢的高并发与写库解耦。兜底与回收:24 小时未领取的金额要退回原账户,靠延时任务或定时扫描;金额精度、幂等重放、对账(红包总额 = 已发明细之和)是必须有的校验。要主动提的坑:热点红包的 key 会成为热 key,需要本地缓存或分片;「已经抢过」的判断不能只放应用内存里。
手撕题要点
矩阵转置:最简单的一类,注意原地转置只对方阵可行,做法是只遍历上三角交换 a[i][j] 与 a[j][i];非方阵必须开新数组,行列互换时行数变成原列数。超运算:本质是与加减乘除同类的自定义运算,按题意实现四则运算法则(优先级、括号)即可,关键是先把运算定义拆成「解析 → 计算」两步,别一上来写表达式解析。K 个一组反转链表:标准做法是先数出总长度(或边遍历边判断剩余是否够 k 个),然后用「头插法」分组反转,翻转前记录该组的前驱与后继,用哑结点(dummy)统一处理第一组,注意组内指针顺序别写错,最后把上一组的尾巴接到本组的新头。复杂度 O(n) / O(1)。两数相加 II(不能用额外空间):链表是高位在前的,正常思路是反转两条链表或借助栈,但题目禁用额外空间,所以要么先反转链表(原地反转不算额外空间)相加后再反转回来,要么先递归到末尾再回溯相加并带回进位,输出链表同样用哑结点拼。岛屿数量:DFS / BFS 洪水填充,遇到 '1' 计数加一并把连通块全部置成 '0';优化方向是改用显式栈避免大网格爆栈、原地修改省掉 visited 数组、用并查集支持动态加陆地的变体。二叉树层序遍历:BFS 配队列,按「当前层节点数」切分每层结果(先取 size = queue.size() 再循环),要求之字形就按层决定是否反转或用双端队列。
RAG 智能知识库的链路与应用场景
典型场景是把内部文档(产品手册、课程资料、历史问答)变成可问答的知识库,用在模拟面试的追问、学习小组的答疑这类「答案必须来自指定资料」的地方——它解决的核心问题是让模型的回答有出处、可溯源、不凭记忆编。完整请求链路:用户问题从对话入口进入(App / 小程序 / 网页的输入框)→ 服务端做一轮 query 理解(改写、拆多问、抽关键词、识别是否需要检索)→ 召回阶段并行跑向量检索与关键词检索、按租户 / 知识库过滤 → rerank 精排取 topK → 拼上下文(带出处信息与指令,比如「只依据以下资料回答,不足就说不知道」)→ 调用大模型生成(流式返回)→ 后处理(引用标注、敏感词、缓存)→ 记录本轮问答到历史。要主动说清职责边界:用户是在跟 Agent 对话,Agent 决定要不要检索、检索几路、答案怎么组织,模型只负责在被给的上下文里生成。关于帖中提到的 JEV 与 BERT:BERT 是掩码语言模型,输出的是 token 级向量,直接拿 [CLS] 当句向量效果一般;面向检索的编码器(JEV 这类)通常用对比学习(正负样本对、in-batch negatives)训练,优化目标就是「语义相近的句子向量距离近」,所以做召回明显更好用,代价是需要标注对或构造难负例,且换模型要重新灌库。
MCP、RPC、Skill、Workflow、Agent 的边界与实战坑
RPC 与 MCP 的接口区别:RPC 是「服务调用」——接口由服务提供方定义、强类型、版本随发版走,调用方必须知道方法名和参数结构;MCP 是「模型与工具之间的协议」——它要额外提供给模型看的描述(工具名、自然语言说明、参数 schema),并且约定传输方式(stdio / HTTP+SSE)、能力协商和资源暴露,核心目标是让模型能自己选工具、自己拼参数。所以 RPC 面向程序员,MCP 面向模型,MCP 多出来的一层是「可被模型理解的元数据」。开发 MCP 最重要的是什么:工具描述的准确与克制——描述写清「什么时候该用、什么时候不该用」,参数用 schema 严格约束并给例子,工具粒度要单一(一个大而全的工具会让模型选错参数),返回结果要有大小上限并把大结果外置,错误信息要能指导模型纠正而不是抛栈。入参出参出错怎么防:schema 校验(类型、枚举、必填)、默认值兜底、宽松解析(截取 JSON 子串、修常见畸形)、把校验错误原文回灌让模型自己改、同一工具同参数短时间去重、关键工具加幂等键。Skill、Workflow、Agent 的联系与区别:Skill 是知识与流程的封装,写给模型看的作业指导,本身不执行;Workflow 是固定编排,步骤和分支提前确定,可控可测但不够灵活;Agent 是运行时决策,由模型自己选下一步动作,适应性强但成本和不确定性都高。选择判据是「路径是否可预先枚举」——能枚举就 Workflow,不能枚举又需要工具就 Agent,Skill 是两者共用的知识层。为什么用 Agent 而不是写个 Skill 嵌进去:因为任务需要的步骤依赖中间结果,预先写死的 Skill 覆盖不了分支;但要能说清代价(token 消耗、延迟、行为不确定性)以及为控制它做的约束(工具白名单、步数上限、校验器)。