面灵AI→

字节面经-字节跳动后端开发岗面经-18

轮次
面试合集
时间
2026-10
来源
牛客网

《面试题目》

(面经 01)

  1. 挑一个能体现你能力和亮点的内容讲一下
  2. 难点在哪?
  3. 还有呢?
  4. Kafka 性能为啥好?
  5. 磁盘顺序读写既然快,为啥不都用磁盘顺序读写?
  6. Kafka 怎么保证顺序消费,代码里怎么实现?
  7. 内部的语言以及框架是什么?
  8. 内部用的容器是 Tomcat 还是哪一种?
  9. 算法:矩阵转置

(面经 02)

  1. 自我介绍
  2. 实习期间,优化具体是指什么?
  3. 是偏前端的工作吗?
  4. 实习是偏前端还是偏后端?
  5. 项目想达成怎样的效果?
  6. 定位是什么?
  7. 仿照的 Claude Code 和 ChatGPT,在你认知中它们在做什么?
  8. 项目的前后端交互是如何完成的?
  9. 项目是否实现了类似 ChatGPT 的流式输出效果?
  10. 为什么通过 RabbitMQ 实现聊天记录异步入库?
  11. 聊天记录入库会是一个很慢的动作吗?
  12. 造成了什么时间瓶颈?
  13. 数据库表是怎么设计的?
  14. 第二张表(会话表)的一条记录代表什么?
  15. 涉及哪些字段?
  16. 聊天内容字段定义为什么类型?
  17. 放到索引里合适吗?
  18. 多用户多会话场景下,数据库有建立索引吗?
  19. 数据库索引的底层结构是怎样的?
  20. 为什么 B+ 树的非叶子节点不存数据?
  21. 如果存了会有什么问题?
  22. 双向链表和存不存数据有强关联吗?
  23. MySQL 有哪些事务隔离级别?
  24. 可重复读有什么问题?
  25. 举一个可重复读下触发幻读的具体实例?
  26. 串行化牺牲性能,实际怎么解决幻读?
  27. 间隙锁是如何运作的?
  28. 联合索引 (user_id, status, created_at) 下,如果查询条件未包含 user_id,索引覆盖情况如何?
  29. 会不会走索引?
  30. 如果不改表,怎样修改查询语句才能走索引?
  31. 岛屿数量(LeetCode 200)
  32. 针对岛屿数量的解法,有什么优化的思路?

(面经 03)

  1. 自我介绍
  2. 挑一个最体现能力的点讲
  3. 设计一个抢红包系统
  4. 手撕:超运算

(面经 04)

  1. redis 的大 key 和热 key 怎么解决
  2. 令牌桶的实现
  3. 算法 1:手写 singleflight 机制
  4. 算法 2:二叉树层序遍历

(面经 05)

  1. 先讲一下你在实习做的第一个项目,整体的框架是怎样的,用来做什么的?
  2. 你负责的部分 Agent 是怎么实现的,有没有底层的设计优化?
  3. 你怎么看待 jev,和 bert 有什么区别,你认为 jev 对于你的 agent 有什么优化点?
  4. rpc 和 mcp 的接口区别是什么?
  5. 开发 MCP 最重要的是什么?
  6. MCP 定义出参入参,实际过程中有没有出现调用 mcp 入参和出参出错的,怎么防止呢?
  7. skill、workflow、Agent 三者的联系和区别?
  8. 你的项目中有没有用到 rsi,怎么做的?
  9. 你的项目为什么要用一个 Agent 而不是直接写一个 skill 嵌入到里面?
  10. 你们的评审准确率等一些指标是怎么计算的?
  11. 聊一下你的后端项目,简单介绍一下
  12. redis 通过 lua 预扣减为什么还要乐观锁兜底?
  13. 如果假设投递消息是 exactly once,不丢不重复,还有必要乐观锁兜底吗?
  14. 强一致和最终一致性
  15. redis 是强一致还是最终一致性?
  16. redis 怎么做主从同步的?
  17. master 向 slave 传递数据,这里的数据都是什么?
  18. 手撕:k 个一组的链表反转
  19. 手撕:两数相加 II(不能用额外空间、输出链表)

(面经 06)

  1. 自我介绍
  2. 你做过的几个项目里,哪个最有挑战?
  3. RAG 智能知识库主要解决什么问题?
  4. 具体应用场景是什么?
  5. 是给模拟面试使用,还是独立知识库功能?
  6. 学习小组具体怎么使用?
  7. RAG 知识库和大模型是怎么连接的?
  8. 完整请求链路是什么?
  9. 用户问题从哪里进入?

《参考解析》

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 消耗、延迟、行为不确定性)以及为控制它做的约束(工具白名单、步数上限、校验器)。