面灵AI→

字节跳动交易与广告后端一面:B+ 树与幻读连环追问

轮次
一面
时间
2026-09
来源
牛客网

《面试题目》

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

《参考解析》

**B+ 树为什么非叶子节点不存数据:**索引树的一次查找对应若干次页读取,所以优化的目标是把树高压低。非叶子节点只放键和子指针,一个页能容纳的键就多,扇出随之变大——以常见的页大小和主键长度算,三层高度就能覆盖千万级行,一次等值查询最多三次页访问。如果非叶子节点也存整行数据,扇出会骤降到几倍到几十倍,树高上升,磁盘 IO 成倍增加,而且非叶子节点还会被数据挤得不稳定。至于叶子节点的双向链表,那是为范围扫描服务的:定位到起点后顺着链表往后读即可,不需要回到上层。两件事服务于两个不同目标(降低树高 vs 加速范围读),没有强关联——面试官问这句,是在看你会不会把两个独立的设计理由混成一个。

**联合索引与最左前缀:**索引 (user_id, status, created_at) 的排序是先按 user_id、再按 status、最后按 created_at。查询条件不带 user_id 时,这个 B+ 树的最左列完全没被约束,优化器无法用它做有序定位,通常判定为不可用(除非能走覆盖索引做全索引扫描,那也基本等于扫全表,代价更高)。不改表结构的补救办法有三种思路:一是把 user_id 补进查询条件(很多「不带 user_id」的查询其实能从上文推导出会话所属用户);二是改写成能命中其他已有索引的等价形式,或把需要的列收进覆盖索引减少回表;三是如果这类查询是高频刚需,就承认索引设计要改,新增一条以 status 或 created_at 打头的索引。顺带一个高频点:把超长文本内容放进索引不合适——索引页被撑大、写入变慢、区分度低,要检索就另建全文索引或倒排。

**可重复读与幻读:**四种隔离级别是读未提交、读已提交、可重复读、串行化,分别解决脏读、不可重复读、幻读。InnoDB 的可重复读靠 MVCC 快照读做到「同一事务里读到的行不变」,再加 next-key lock(记录锁 + 间隙锁)在锁定读时阻止区间内插入,从而在大部分场景下避免幻读。但有两类漏洞要能举出来:一是快照读与当前读混用——事务 A 先普通 select 确认某行不存在,事务 B 插入并提交,A 再执行 update 或 select … for update 时走的是当前读,会突然「看见」这行并改动它,这就是典型的幻读实例;二是先快照读再更新同一个范围。答这道题的关键是把「快照读靠 MVCC、当前读靠锁」这条分界线说清楚。

**间隙锁与幻读的实际解法:**间隙锁锁的是索引记录之间的开区间(以及第一条记录之前的区间、最后一条之后的区间),目的是让别人无法在区间内插入新记录,因此只在可重复读及以上隔离级别生效。它的代价很直接:并发插入被阻塞、锁范围随索引与查询条件变化而难以预测、多个事务交叉加间隙锁容易死锁。串行化会给读加范围锁、冲突直接阻塞,性能最差,生产上几乎不用。实际项目里更常见的是组合拳:RR + 唯一索引 + 当前读(用唯一约束把并发插入变成唯一键冲突)、插入用 insert ignore/on duplicate key、业务层做幂等与去重,从而不必依赖大范围加锁。另外注意间隙锁只在索引列上生效,没走索引的更新会退化成锁全表。

**RabbitMQ 异步落库与消息可靠性:**把聊天记录异步入库的动机是让写路径不阻塞对话主流程——尤其是对话接口本身要等模型输出、响应时间敏感。但被问「入库到底慢不慢」时要诚实拆解:单条 insert 本身通常不慢,真正的瓶颈更可能是长事务、锁竞争、大字段写入、或者同步写多副本与刷盘策略。异步化引入三个必须回答的问题:消息丢失(生产者开启 confirm、队列与消息持久化、消费端手动 ack)、重复消费(业务幂等,用消息 ID 或数据库唯一键去重,落库用 upsert)、顺序性(同一会话的消息用同一路由键投到同一队列并单消费者串行处理)。还要说清失败兜底:消费重试与死信队列、以及最终一致下的对账补偿。

**聊天记录的表设计:**会话表一条记录代表一个会话(或一个用户在一个会话里的参与关系),典型字段有会话 ID、参与者标识、会话类型、最后一条消息的摘要与时间、未读数、状态与创建更新时间——最后消息冗余在这里是为了会话列表一次查询就能展示,不用去消息表做聚合。消息表存消息 ID、会话 ID、发送者、内容、消息类型、创建时间,并建 (session_id, created_at) 的复合索引支撑「按会话拉历史 + 翻页」。内容字段用 TEXT/LONGTEXT 而不是放进索引;如果要按关键词检索,另建全文索引或外部检索,别指望 B+ 树。分库分表时才需要考虑分片键——按会话 ID 哈希能让同一会话的消息落在同一分片,避免跨片查询。

**岛屿数量与优化思路:**基础解法是遍历网格,遇到陆地就计数并做一次 DFS/BFS 把整块陆地「淹没」,时间 O(m·n)。可讲的优化方向有四类:① 用显式栈或队列替代递归,避免大网格下爆栈;② 原地修改 grid 代替额外的 visited 数组,省空间(如果输入不允许改就另说);③ 换成并查集,适合边遍历边合并、或需要动态加陆地并查询连通块个数的变体;④ 大规模场景下的分块处理与位图压缩,把每行状态压成位运算加速访问。如果面试官追问「如果网格大到装不进内存」,思路就要转向流式/分块 + 边界连通性合并,这已经接近并查集的多趟合并解法。