拼多多服务端开发一面:SQL 优化、MySQL、Redis、MQ 与手撕算法
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
一、实习项目与 SQL 优化
- 请简单做一下自我介绍。
- 看到你简历中有一段 MySQL 查询优化的经历,具体是怎么优化的?
- 你提到将多条 SQL 聚合成一条,具体是统计什么业务指标?
- 这些指标是每天都需要计算,还是只需要计算一次?
- SQL 聚合查询有没有使用窗口函数?
- 除了项目中提到的优化方案,还有没有做过其他 SQL 优化,例如慢查询优化、索引优化等?
二、MySQL
- 如果线上出现一条慢 SQL,你会怎么排查和优化?
- 使用 EXPLAIN 分析 SQL 时,重点关注哪些字段?
- 假设现在有一条 SQL,包含 user_id、state 两个等值查询条件,以及 create_time 范围查询和排序,你会怎么设计索引?为什么?
- 哪些情况下会导致 MySQL 索引失效?
- 如果接触一个全新的业务,需要从零开始设计数据库表,你会怎么设计?
- 数据库设计时,为什么有时候需要增加冗余字段?
- 假设用户表和地址表存在关联关系,是否可以直接在用户表中冗余存储地址信息?
- 增加冗余字段是否会破坏数据库范式?
- 数据库范式和实际业务需求发生冲突时,你会怎么权衡?
三、Redis
- Redis 常见的数据结构有哪些?String、Hash、List、Set、ZSet 分别适合什么使用场景?
- 如果使用 Redis 作为缓存,如何解决缓存和数据库的数据一致性问题?
- 先更新数据库再删除缓存,是否还会出现数据不一致?有哪些场景?
- 除了延迟双删,还有没有其他缓存一致性解决方案?
- 如果通过监听 MySQL Binlog,再经过消息队列处理缓存更新,这种方案有什么好处?
四、消息队列
- 了解消息队列的设计原理吗?
- 使用消息队列时,如何保证消息不会丢失?
- 如果消费者处理一条消息到一半时突然宕机了,应该怎么处理?
- 如何保证消息不会漏消费,同时避免重复消费造成业务问题?
- 消息消费的幂等性通常怎么实现?
五、Spring
- Spring 中的 @Transactional 在哪些情况下会失效?
六、手撕算法
- 二分插入排序。
- 任务调度 / 最少线程数(力扣 253)。
《参考解析》
慢 SQL 的排查链路
不要一上来就加索引,先确认「慢」的定义和范围:是偶发还是持续、是某条 SQL 还是整个接口、QPS 多少、数据量多大、慢在数据库还是慢在应用侧连接池或网络。数据来源分两类——事后看慢查询日志(long_query_time 阈值调低一点,按 Query_time 和扫描行数排序,找出 Top SQL),事前用 EXPLAIN 或 EXPLAIN ANALYZE 看执行计划。
看执行计划要重点盯:type(ALL 全表扫、index 全索引扫、range、ref、const,最好到 ref 以上)、key 与实际用的索引、rows 预估扫描行数、filtered 过滤比例、Extra(Using filesort、Using temporary、Using index condition 分别说明排序没走索引、用了临时表、以及索引下推)。如果预估行数和实际差很多,说明统计信息过期,ANALYZE TABLE 一下。
定位到之后按性价比排序处理:建合适的联合索引或覆盖索引(让查询只走索引不用回表)、改写 SQL(避免函数作用在索引列上、避免 %like 前置、把大 in 拆小、分页用游标而不是大 offset)、减少返回列与数据量、冷热分离与归档。如果再不行才考虑物化中间结果、读写分离、或者上缓存。改完必须回看监控指标——包括那条 SQL 的耗时和整个库的 CPU、IOPS,避免把压力转移而不是消除。
等值 + 范围混合的联合索引怎么设计
题目给的是 user_id、state 两个等值条件 + create_time 范围 + 按 create_time 排序。联合索引的设计依据是两条经典规则:等值条件在前、范围条件在后,以及索引列顺序要能同时服务过滤与排序。
所以优先考虑 (user_id, state, create_time):user_id 和 state 两个等值列先定位到一个很小的范围,再在剩下的连续区间里按 create_time 做范围扫描——因为在这个索引里,同一组 (user_id, state) 内部 create_time 是有序的,范围过滤和 ORDER BY create_time 可以同时走索引,省掉 filesort。
如果 state 的基数很低(比如只有几个状态),把它放在等值列第二位收益有限、还会让索引变宽,也可以只建 (user_id, create_time),让 state 回到回表后过滤,具体要看选择性数据。判断标准永远是选择性(区分度)× 使用频率,不是列的顺序好看。另外要问清查询是否固定带 user_id:如果存在「只按 state + 时间」的查询,就得再补一条索引,或者用覆盖索引把常用字段带出来。
先更新数据库再删除缓存,为什么还会不一致
这个顺序比「先删缓存再更新库」好,但仍有几个典型漏洞:
- 并发读回填:请求 A 发现缓存未命中,从库里读到旧值;此时请求 B 更新库并删除缓存;A 才把旧值写回缓存——缓存从此长期是旧值,且不会有任何人再来修正它。这类不一致最难发现,因为它是「静默长期错」。
- 删除缓存失败:更新库成功、删缓存这一步超时或异常,如果业务没有重试机制,缓存就一直是旧值。
- 主从延迟:写走主库、读走从库,删缓存后立刻有请求打到从库,读到尚未同步的旧数据又回填。
- 事务未提交就删缓存:删缓存在事务提交前执行,提交前的读请求把旧值写回。
缓解手段按强度递增:给缓存设过期时间(兜底,接受最终一致)、延迟双删(更新前后各删一次,中间 sleep 一段时间覆盖读回填窗口,但 sleep 时长的经验值难定、且不可靠)、读写都加分布式锁/单飞(防止回填旧值)、用 Binlog 订阅驱动缓存失效(应用只负责改库,缓存删除由消费 Binlog 的下游执行,天然在事务提交后触发、失败可重试、还能跨服务统一处理,缺点是多了一条链路和延迟)。
最后要给结论:在并发读写场景下,缓存与数据库不存在既高性能又强一致的免费方案,工程上的目标是把不一致窗口压到可接受范围,并让业务对不一致有容忍度——回答里能主动点出这一点,比背方案更得分。
消息队列怎么保证不丢、不重
「不丢」要分三段分别看:生产者丢失——同步发送并处理返回、或用带回调的异步发送 + 本地消息表/事务消息保证「业务提交」和「消息发出」二选一必成;Broker 丢失——刷盘策略(同步刷盘 vs 异步)、多副本与 acks=all 之类的确认级别、以及节点故障时的副本选举;消费者丢失——关闭自动提交位点,改成业务处理成功后再手动 ack,否则「先 ack 后处理、处理到一半宕机」这条消息就永久丢了。
消费者处理到一半宕机,本质上就是位点没提交、消息会被重新投递,所以要求业务侧可重放:处理逻辑要幂等,或者把「业务写入」和「位点提交」放进同一个本地事务/同一张表。
「不重」在分布式投递语义里只能做到至少一次 + 消费幂等。幂等的常用实现:用业务唯一键(订单号、消息 ID)建唯一索引,重复插入直接冲突忽略;用 INSERT ... ON DUPLICATE KEY UPDATE 或状态机判断(只在特定前置状态下才允许流转);把消息 ID 存 Redis 做去重窗口;或者用版本号/乐观锁防止旧消息覆盖新数据。注意去重窗口是有代价的(存储 + 内存),要跟消息量和重试窗口匹配。
@Transactional 失效的常见场景
按「代理没生效」和「事务没按预期回滚」两类记:
代理没生效——同类内部自调用(this.method() 不经过代理,事务注解形同虚设,解决办法是注入自身代理、拆到另一个 Bean、或用 AopContext.currentProxy());方法不是 public(Spring AOP 默认只代理 public 方法);类没有被 Spring 管理(手动 new 出来的对象);切面顺序问题(比如被自定义 AOP 包裹,异常没传到事务拦截器)。
事务没按预期工作——异常类型不对:默认只对 RuntimeException 和 Error 回滚,受检异常需要显式写 rollbackFor = Exception.class;异常被自己 catch 掉没有重新抛出;传播行为选错(REQUIRES_NEW、NESTED 与预期的边界不同,NOT_SUPPORTED 会让当前方法根本没事务);多数据源/多事务管理器没指定 transactionManager;只读事务里执行了写操作;以及事务里做远程调用或耗时操作导致的连接占用与超时。还有一个容易被忽略的:事务方法里把异常包装成了新的异常但丢失了原始类型,回滚规则匹配不上。
手撕:二分插入排序与最少会议室
二分插入排序的要点是在已排序区间里用二分找插入位置,再整体后移。写的时候注意三点:二分边界统一用「左闭右开」或「左闭右闭」其中一种,不要混;插入位置取的是第一个大于目标值的位置(用 lowerBound 语义,保证稳定性);后移要用 System.arraycopy 或从后往前倒着搬,别写成从前往后的循环把数据覆盖了。复杂度是比较次数 O(n log n)、移动次数仍是 O(n²),所以整体 O(n²),适合数据量小或近乎有序的场景。
力扣 253(最少会议室 / 最少线程数)的经典解法是排序 + 小根堆:把所有区间按开始时间排序,用一个堆维护「当前正在占用的会议的结束时间」;遍历每个区间,如果堆顶的结束时间小于等于当前区间的开始时间,说明那个会议室已经空了,弹出复用;把当前区间的结束时间压入堆;遍历结束后堆的大小就是所需的最少会议室数。复杂度 O(n log n)。
另一个等价解法是差分/扫描线:把所有开始时间记为 +1、结束时间记为 -1,排序后从左到右累加,过程中出现的最大值就是答案(注意「结束时间 == 下一个开始时间」时是否算重叠,要按题目口径决定是先处理 −1 还是 +1)。面试里可以两个都说:堆的做法直观、扫描线的做法代码更短,但扫描线对「同一时刻先加还是先减」的边界更敏感。