蛮啾网络一面:索引设计、延迟任务与 Kafka 消费并发
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 项目中遇到最大的难题?
- 实习学到了什么?
- 数据库理论和实际使用有思考过区别吗?(比如数据库范式)
- 设计索引要考虑什么?你会怎么设计索引?
- 索引为什么用 B+ 树?(为什么非叶子节点存储键值对层级更低?为什么层级低就好?)
- 时间、类型、唯一 id 怎么建立索引?为什么?(顺序)时间有范围查询怎么设计呢?
- 联合索引,什么时候回表,什么时候不会回表?
- 前缀索引(回答不了解)。
- 索引的缺点?(回答占用内存空间,继续追问对插入有什么影响)
- 你实习里提到 ZSet 实现延迟任务,具体讲一下。
- 超时关单怎么设计?分库分表情况下怎么分发?
- Kafka 消费速度取决于什么?(比如 12 个分区、200ms)
- Kafka 保证消息不丢失?
- Kafka 消息提交策略有哪些?
- DDD 的缺点?(追问:哪些是简单业务场景呢?)
- Spring 循环依赖,为什么是三级不是两级?
《参考解析》
索引设计与 B+ 树「层级低就好」的原因
设计索引要考虑的顺序是:先看查询模式(哪些列出现在 where、join、order by、group by,以及各自的频率),再看选择性(区分度高的列优先,性别这类低区分度列单独建索引基本没用),然后是联合索引的列顺序(把等值条件放前面、范围条件放最后,因为范围之后的条件用不上索引),接着是覆盖能力(把查询要返回的列加进索引做成覆盖索引,避免回表),最后是写入代价与空间(索引越多,写入越慢、占用越大)。实际工作中还要考虑冗余索引((a) 与 (a,b) 同时存在时前者冗余)、以及是否要用前缀索引来减小长度。
索引为什么用 B+ 树、为什么层级低就好:B+ 树的非叶子节点只存键值与子节点指针,不存整行数据,所以同样大小的一个页(InnoDB 默认 16KB)能容纳更多的键,扇出更大——这意味着「同样数据量下树更矮」。而磁盘 IO 的次数等于树高(每次跳一层就是一次随机 IO,且这些页通常不能全部缓存),所以三层的树最多三次 IO 就能定位到数据,四层则多一次;对于千万级乃至亿级的表,B+ 树能把 IO 控制在三到四次,这是它相对 B 树(节点也存数据、扇出小、树更高)与相对二叉树/红黑树(高度是 O(log n) 且每层一次 IO)的核心优势。再加上叶子节点用双向链表连接,范围查询与排序只需要顺序扫描叶子,不用回溯到上层。
主键与时序列的索引顺序、范围查询
「时间、类型、唯一 id 怎么建索引」要先把查询模式问清楚。如果最常见的查询是「某类型下按时间倒序取一页」,联合索引按 (类型, 时间, id) 建最合适:类型是等值条件,放最左;时间是范围与排序条件,紧随其后;id 放在最后用于唯一性排序与分页游标(同一时间戳多条时可以稳定排序)。反过来把时间放最左、类型放中间就不行——时间一旦是范围条件,后面的列无法用于定位。如果还有「某用户在某类型下按时间查」的模式,就要考虑 (用户, 类型, 时间) 或按组合拆分,但索引数不能无限增加,要按实际最频繁的两三种查询来设计并接受其他查询慢一些。存在时间范围查询时的实践要点:把等值条件放最左、范围列放最后;如果范围列之后还需要排序,考虑把排序列并入范围列之前(若它能等值);对大范围时间扫描(如月报)用专门的汇总表或物化视图,而不是让线上查询扫大区间。
回表与覆盖索引、前缀索引、索引的代价
回表是「二级索引找到主键后,再拿主键去聚簇索引取整行」的过程,多一次随机 IO。不触发回表的条件就是这个查询所需的列全部在索引里(覆盖索引):比如 select id, name from t where name = ?,若存在 (name, id) 或 (name, id, ...) 索引,则直接从索引取到结果。判断方法很直接:EXPLAIN 的 Extra 出现 Using index 就是覆盖索引、不出现则可能回表。工程上的用法是把高频查询的返回列并入联合索引,但要权衡索引体积——把大字段(长文本)塞进索引会让索引膨胀,得不偿失。
前缀索引是「只对列的前 N 个字符建索引」(index(name(10))),用于长字符串列(URL、邮箱、地址),能显著减小索引体积、提高写入效率。代价是:无法用于覆盖索引(索引里没有完整值),也无法支持 order by 完整列的排序;以及需要选一个合适的前缀长度——判据是「前缀的选择性接近完整列的选择性」,可以用 count(distinct left(col, n)) / count(*) 对不同 n 做对比,取达到目标区分度的最小 n。
索引的缺点要答全:占用磁盘与内存(缓冲池里索引页多了,热数据的页就少了,命中率可能下降);拖慢写入——每次 insert/update/delete 都要维护所有相关索引,插入还可能触发页分裂(尤其是随机主键如 UUID,导致 B+ 树频繁分裂与碎片),写入放大明显;优化器可能选错索引(统计信息不准、或者区分度低时选全表扫更划算);以及过多的索引让 DDL 与备份恢复更慢。所以索引不是越多越好,要按查询模式做减法。
ZSet 延迟任务与超时关单的分发
ZSet 做延迟任务的机制是:把「执行时间戳」作为 score、把任务标识作为 member,用 ZADD 写入;轮询用 ZRANGEBYSCORE key 0 <now> LIMIT 0 N 取出到期任务,取出后用 ZREM(配合 Lua 保证原子,避免多实例重复取)并执行。要注意的坑:轮询频率与批量大小的取舍(太频繁浪费、太大延迟高)、多实例下必须用「取出即删除」的原子操作或分布式锁来防重复处理、任务失败要有重试与死信(可以把失败任务写入另一个 ZSet 延迟重试)、以及 Redis 重启后的数据可靠性(延迟任务往往关乎钱,通常用持久化或改走专业的延时消息)。
超时关单的设计要落到「不重不漏」上:订单创建时写入延迟任务(或投递延时消息),到点检查订单状态——仍是「待支付」才关闭并回补库存,已是「已支付」就跳过;这个检查必须原子(用 update ... where status = 待支付 的条件更新或状态机 + 乐观锁),否则会出现「用户刚支付成功却被关单」。分库分表下的分发是关键追问点:任务本身要带路由信息(订单号或用户 ID,用于定位分片),分发方式有两类——一是按分片划分消费:每个分片自己有扫描任务,或者消费者按分片键取模只处理属于自己的任务(避免跨分片写);二是用消息队列:定时扫描把到期任务投递到 MQ,消费端按订单号哈希到固定分区实现并行与保序。原帖面试官提示「先加分布式锁、线程池分发」只是拿到了任务之后的处理,真正的难点是「如何把海量任务高效地分派给多个实例」——所以要答成「扫描层按分片或时间段切分任务集,再由消费端按分片键路由」这一层。
Kafka 的消费速度、不丢消息与提交策略
消费速度取决于最慢的一环,要按因素列:分区数(并行度的上限,消费者数超过分区数就闲置)、消费者实例与线程模型(一个线程处理一个分区的单线程消费 vs 拉批后交给线程池,后者能提高吞吐但要自己保证分区内顺序)、单条处理耗时(下游数据库写入、远程调用)、拉取参数(fetch.min.bytes、max.poll.records 决定一次处理多少,批量大吞吐高但失败重放范围大)、以及再平衡(rebalance 期间的停顿)。原帖提到的「12 个分区、200ms」这个设定,可以理解为「单条处理 200ms、12 个分区」时理论吞吐是 12 / 0.2 = 60 条/秒——这是并行上限,但实际会更低,因为处理时间有波动、拉取与提交也有开销,且木桶效应取决于最慢的分区(某个分区有热点 key 或某批数据更难处理,会拖累整体)。所以准确答案不是简单的「分区数 × 单条速度」,而是「受最慢分区与最慢一批的处理时间约束」。
不丢消息要三段都保证:生产端开 acks=all 并配合 min.insync.replicas ≥ 2(避免 ISR 只剩一个时仍然接受写入)、开生产者重试与幂等(enable.idempotence=true);Broker 端设置合理的副本数与 unclean.leader.election=false(避免选出落后副本导致丢数据)、并关注磁盘与刷盘策略;消费端关闭自动提交、改为「处理完成后再提交位点」,失败时不提交并重试(配合死信队列)。要顺带说清「至少一次」的必然结果是有重复,所以消费端必须幂等(按业务唯一键或消息 ID 去重),否则「不丢」会变成「重复扣款」。
提交策略有四种:自动提交(enable.auto.commit=true,按时间间隔提交,简单但可能丢也可能重复——提交后处理失败就丢、处理成功后崩在提交前就重复);手动同步提交(commitSync,处理完成后提交,可靠但阻塞并受重试影响,吞吐低);手动异步提交(commitAsync,吞吐高但无重试会漏提交,通常在关闭前配合一次同步提交兜底);以及按批次/按分区粒度提交(更精细地控制重放范围)。实践结论是:业务处理与位点提交必须绑定(处理成功才提交),并接受重复、用幂等消解。
DDD 的缺点与 Spring 三级缓存
DDD 的缺点(更准确说是「成本」)要说清:一是学习与落地成本高,聚合、限界上下文、领域事件、CQRS 这些概念需要团队达成一致,沟通与设计成本不小;二是对简单业务是过度设计——CRUD 为主、变更不频繁、团队规模小的系统,引入聚合根与领域服务会让一个简单的增删改查变成多层跳转,代码量翻倍而收益有限;三是工程与基础设施要求高,事件驱动、最终一致、跨聚合事务都要额外的机制(消息、对账、幂等),否则一致性会出问题;四是与既有系统(尤其是遗留的贫血模型表结构)融合困难,改造往往演变成长期的重构;五是性能上聚合内的对象图加载可能带来额外查询,需要仓储层精心设计。追问「哪些是简单业务场景」时给具体的判据:以增删改查为主、业务规则少且稳定、没有复杂的多实体不变量约束、团队人少、以及以报表和表单为中心的内部系统——这类场景用分层架构(Controller/Service/Mapper)+ 适度的事务脚本就够了。
Spring 循环依赖为什么用三级而不是两级:一级缓存是成品单例,二级缓存是「已实例化但未完成属性注入」的早期引用,三级缓存存的是「生成早期引用的工厂(ObjectFactory)」。关键在于 AOP 代理——如果只保留两级(即把早期引用直接放进二级缓存),那么当 A 需要被代理时,注入给 B 的要么是原始对象(与最终放进容器的代理对象不是同一个,导致依赖注入的对象与容器里的对象不一致,AOP 失效),要么就得在属性注入阶段提前创建代理(但此时还不知道最终是否需要代理、代理的目标也可能还没准备好)。三级缓存的工厂让「什么时候需要代理、就什么时候生成代理」变成惰性行为,并且保证同一个 bean 只生成一次代理、B 拿到与容器里同一个对象。补充两个边界:构造器注入的循环依赖无法解决(对象还没实例化就无法暴露早期引用,直接抛异常),原型作用域的 bean 也不走这套三级缓存。Spring Boot 2.6 之后默认禁止循环依赖,需要显式开启才允许。