面灵AI

拼多多 服务端开发秋招一面:HashMap、Kafka 与 MVCC

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

《面试题目》

  1. 能否先做一下自我介绍?
  2. 介绍一下你参与的平台,重点说明技术难点和解决思路?
  3. HashMap 的底层结构是什么?JDK 7 和 JDK 8 有哪些关键差异?
  4. Kafka 如何尽量保证一条业务数据不丢失?
  5. MySQL 为什么使用 B+ 树作为索引结构,而不是 B 树、红黑树或者哈希表?
  6. Redis 主从切换期间,如何处理已经写入旧主节点但尚未复制的数据?
  7. ACID 分别是什么?InnoDB 通过哪些机制实现?
  8. MVCC 中 Undo Log、Read View、版本链和隐藏字段之间的关系是什么?
  9. MySQL 的 Next-Key Lock 如何解决幻读?哪些情况下仍可能出现意外行为?
  10. 线上 CPU 不高但接口延迟突然升高,应该如何排查?
  11. Java 中 synchronized 与 ReentrantLock 在实现和使用上有什么差异?
  12. AQS 为什么使用双向队列?取消等待的节点会造成什么问题?
  13. 一个线程修改了普通成员变量,另一个线程为什么可能永远看不到?
  14. ThreadLocal 为什么可能造成内存泄漏?

《参考解析》

HashMap 换了尾插,但没变成线程安全

JDK 7 是数组加链表、头插;并发扩容时 transfer 可能把链表连成环,查询直接死循环。JDK 8 改成尾插,桶内结构变为链表加红黑树:桶中节点数达到 8 且数组容量至少 64 才树化,容量不够时优先扩容,节点减少到一定程度再退化回链表。桶位置用 (table.length - 1) & hash,容量取 2 的幂就是为了用位运算代替取模;扩容到两倍后节点新位置只有”原位”和”原位 + 旧容量”两种,判断依据是哈希值中对应旧容量的那一位。两点别答错:HashMap 依然不是线程安全的;树化也不等于把最坏复杂度绝对压到 O(log n),哈希值全部相同且键之间不可比较时,红黑树查找仍要扫描部分节点。

Kafka 不丢消息要分三段看

生产者用 acks=all、开启重试并启用幂等生产者;Broker 端配好副本数与 min.insync.replicas,并禁止不在 ISR 中的副本直接成为 Leader,否则 Leader 故障时会发生数据截断。消费者的正确顺序是读消息、执行本地事务、再提交位点,绝不能业务处理完之前提交。但业务事务和位点提交不是同一个原子操作,重复消费仍然可能,所以业务侧必须按消息 ID、业务单号或事件版本做幂等。Kafka 的 Exactly Once 只覆盖”消费-处理-再写回 Kafka”这一段,写 MySQL 这种跨系统一致性还得靠幂等表、本地消息表或事务发件箱。

B+ 树真正省的是磁盘随机 I/O

非叶子节点只存键和子节点指针,一页能容纳更多索引项,扇出更大、树更矮,查一条记录只要少量页访问;数据全部落在叶子节点且叶子之间有链表,范围查询和顺序扫描天然合适。B 树的非叶子节点也存数据,单节点能放的键更少;红黑树是二叉结构,数据量大时高度明显高于 B+ 树;哈希表适合等值查询,做不了范围查询、排序和最左前缀匹配。InnoDB 聚簇索引的叶子存完整行记录,二级索引的叶子存索引列加主键值,所以用普通索引查非覆盖字段通常还要回表。

异步复制下,WAIT 也不是强一致

Redis 默认异步复制,客户端收到写入成功只说明主节点写完了,命令可能还没同步到从节点;主节点此刻宕机,这部分数据就丢。WAIT numreplicas timeout 能让当前连接之前的写命令等到指定数量副本确认,但超时、故障转移和网络分区下仍有边界情况,Redis 不会因此变成强一致系统。库存、额度、订单这类关键数据要把数据库流水或可靠消息作为事实来源,Redis 只作实时状态与加速层,故障后按持久化流水重建。还要处理旧主恢复后变成从节点的情况:客户端必须通过 Sentinel、Cluster 或代理发现新主,不能长期缓存旧主地址,否则会继续往错误节点写。

MVCC 和锁解决的不是同一种幻读

InnoDB 聚簇索引记录里带事务 ID 和回滚指针等隐藏字段;每次修改生成 Undo Log,回滚指针把历史版本串成版本链。一致性读建立 Read View,用它判断版本链上哪个版本可见,不可见就沿回滚指针往前找。READ COMMITTED 下每次一致性读都新建 Read View,REPEATABLE READ 下通常在第一次一致性读时建立后复用,所以 RR 的快照读可重复。当前读不走快照,直接读最新记录并按需加锁。Next-Key Lock 可以理解为记录锁加间隙锁,RR 下的范围当前读会锁住扫描到的索引区间,阻止其他事务在区间内插入。但实际锁定范围由执行计划和所用索引决定,不完全等于 SQL 文本里的逻辑条件——没走好索引时扫描范围会放大。所以”MVCC 单独解决了所有幻读”不成立:快照读靠 MVCC,当前读和写操作的幻读靠 Next-Key Lock。

CPU 不高只能说明没在计算,说明不了没拥塞

延迟来自等待而不是计算时,CPU 自然不会高。线程可能堵在数据库连接池、HTTP 连接池、磁盘 I/O、锁、线程池队列或下游调用上。先用链路追踪确认耗时发生在哪一层,再看线程池活跃数、队列长度、拒绝次数、连接池等待时间、GC 暂停和 Socket 状态。连续取多次线程快照比只看一次有用:大量线程停在同一个锁对象上就是锁竞争,大量线程等连接池就要继续判断是池太小、SQL 变慢还是连接泄漏。还要算超时与重试的预算——3 秒超时重试两次,一次下游故障就能占住线程接近 9 秒,很快耗尽线程池。

可见性问题与 ThreadLocal 泄漏是两码事

普通成员变量既没用 volatile,访问过程也没有锁或线程启动结束等 happens-before 关系时,线程可以把值缓存在寄存器或本地缓存中,另一个线程不保证及时看到;while (running) 可能一直用已经读过的值。加 volatile 能保证可见性并限制重排,但不保证 count++ 这类复合操作的原子性——它包含读取、加一、写回三步,要原子就得用锁、AtomicInteger 或 LongAdder。

ThreadLocal 的泄漏来自 ThreadLocalMap 的结构:key 是弱引用、value 是强引用。key 被回收后 value 仍挂在 Entry 上,线程池里的线程又长期存活,value 就一直不被释放。规范做法是在 try/finally 里 remove,线程池场景尤其必要。