美团 后端开发 笔试加一面二面 30 题
- 轮次
- 笔试+一面+二面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
笔试
- 实现一个购物车合并接口,同一用户多设备加购要合并,写出思路和边界。
- 给定订单列表,按城市汇总金额,输出金额最高的 K 个城市。
- 一道 AI Coding 题,题干给了一堆需求,你第一步干嘛?
- 字符串里找出第一个不重复字符,怎么做到一遍扫描?
- 限流怎么做,令牌桶和漏桶你选哪个?
- 缓存设计题,热点 key 怎么防被打爆?
一面深挖
- 介绍下你最熟的项目,重点说技术选型。
- 项目里缓存和数据库双写,有几种保一致的办法,线上你敢用哪种?
- 分库分表后跨分片查询怎么办?
- 你遇到过最难的线上 bug 是什么,怎么定位的?
- 接口耗时突然翻倍,你的排查链路是什么?
- 消息队列你用来解耦还是削峰,消费重复怎么处理?
- 分布式锁用 Redis 还是 ZK,过期了业务没跑完怎么办?
- 你的服务怎么保证幂等?
- 限流熔断降级,你们线上怎么配的?
- 你做过压测吗,QPS 上不去一般卡在哪?
八股
- MySQL 索引为什么用 B+ 树,不用跳表?
- 聚簇索引和非聚簇索引区别,回表是什么?
- 事务隔离级别,幻读怎么解决?
- Redis 数据结构你用过哪些,各自什么场景?
- TCP 三次握手,为啥不是两次?
- HTTPS 握手过程,对称和非对称分别用在哪?
- 进程间通信有哪些方式?
- 你了解的操作系统调度策略?
手撕
- 【手撕】手写一个固定容量的 LRU 缓存,要求 get / put 都是 O(1),并讲清头尾指针边界。
二面与架构
- 项目如果流量翻十倍,架构要改哪几处?
- 服务拆分你按什么维度切,怎么定边界?
- 你怎么做容量评估和预案?
- 你带过人吗,需求排期冲突怎么协调?
- 让你从零搭一个高并发网关,你会怎么设计?
《参考解析》
购物车合并的边界才是考点:主体逻辑是「按 userId 归并、以 skuId 为键、数量累加、取最新时间戳」,但边界必须一条条说出来:同一 sku 不同规格(颜色/尺码)要当两行,键得是 skuId 而不是 itemId;取消态的条目不能参与累加;同一 sku 在 A 设备删除、B 设备新增(并发写)要有确定规则,一般以「最后一次修改时间戳」为准,时间戳相同则取删除;数量要夹在业务上下限内(比如单品最多 99 件);合并过程要幂等——用 userId + 设备id 做去重键,或者让客户端带一个合并请求 id,重试不会把数量加两遍。实现上把合并放进一次 Redis 原子操作或数据库唯一键 upsert,别用「先查再算再写」。
Top K 城市汇总:哈希聚合是第一步(O(n)),第二步取前 K 用大小为 K 的最小堆(O(n log K)),比全排序 O(n log n) 好,K 远小于城市数时优势明显。边界有两个:金额可能为负(退款),如果题目要的是「净额最高的 K 个城市」就别先过滤负数,如果过滤了要说明口径;并列金额的排序要有稳定兜底(按城市名再排一次),否则结果不可复现。数据量超过内存时先按城市哈希分片落盘,再对每个分片分别聚合。
第一个不重复字符:哈希表记频次,第一遍扫字符计数,第二遍按原顺序找第一个计数为 1 的,时间 O(n)、空间 O(Σ)(字符集大小,全 ASCII 可以用 256 长度的数组,比哈希表更快)。要做到「一遍扫描」需要额外结构:一边计数一边维护候选队列,遇到计数从 1 变 2 就把候选丢掉,这样摊还下来也是 O(n),但代码复杂度和常数都更差,面试里说清取舍比硬写一遍扫描更好。
缓存与数据库双写一致性:常见方案有四种——先更新 DB 再删缓存(Cache Aside,最常用)、先删缓存再更新 DB(并发下有旧值回填风险)、延迟双删(更新后再延迟几百毫秒删一次,覆盖读请求把旧值写回缓存的窗口)、订阅 binlog 异步删缓存(Canal,业务代码无侵入,是生产上最稳的一种)。线上敢用的组合是 Cache Aside + 缓存过期时间兜底,对一致性要求高的走 binlog 方案;强一致场景(比如余额)干脆不要让缓存承载真值,缓存只做热点加速、读时校验版本。要明确说出「缓存和 DB 无法做到强一致,只能把不一致窗口压到可接受」这句话。
B+ 树为什么而不是跳表:B+ 树的节点大小对齐磁盘页(InnoDB 默认 16KB),一次 IO 读一整页、页内有序数组二分,三到四层就能覆盖千万级数据,且叶子节点用双向链表连起来,范围查询顺着链表扫描即可。跳表是内存结构(Redis 的 zset 用它),靠多层索引把查找降到 O(log n),但没有磁盘页的概念、单次访问要跳很多节点,每个节点还带多层指针,磁盘 IO 次数远高于 B+ 树。另外 B+ 树所有数据在叶子层、非叶子只存键,扇出更高,树更矮。选型的本质是「磁盘 IO 次数」。
接口耗时翻倍怎么排查:先分层定位,别一上来就翻代码。第一步看监控大盘确认是单接口还是全线,是某个机房还是全局,是 P99 还是均值;第二步看依赖,把接口耗时拆成 DB、缓存、RPC、外部 HTTP 几段,哪段涨了一眼就能看到(有全链路 trace 就更快);第三步看变化面——最近有没有发版、上下线、配置变更、流量变化、数据量增长,先怀疑变更;第四步定位到具体依赖后查它自己的指标:慢 SQL 有没有新增(执行计划变没变、索引失效、统计信息过期)、Redis 有没有大 key 或热 key、连接池是否被打满(等待时间上升但执行时间不变是典型信号)、下游是否触发了限流。最后复现和验证:压测环境重放同一批请求,或者用火焰图看 CPU 热点。
LRU 的 O(1) 实现:哈希表 + 双向链表(哨兵头尾节点),哈希表存 key -> 节点,链表按访问时间排序,头部是最近使用、尾部是最久未用。get 命中后把节点摘下来插到头部;put 命中则更新值并移到头部,未命中则新建节点插头部,若超容量就删尾节点并从哈希表移除。用头尾两个哨兵节点可以避免一堆空指针判断,所有指针操作只动 prev/next 两个方向,边界只有「链表为空」和「删的是尾节点」两种情况。Java 里 LinkedHashMap 开 accessOrder=true 并重写 removeEldestEntry 就是现成的,但要先能手写再提库。
get(k): node = map[k]
if node == null: return -1
moveToHead(node); return node.val
put(k,v): if map has k: node.val = v; moveToHead(node)
else: node = new Node(k,v); map[k]=node; addToHead(node)
if size > cap: tail = removeTail(); map.remove(tail.key)
流量翻十倍的改造顺序:先加缓存和读副本把读流量挡掉(收益最大、改动最小),再做限流和降级保住核心链路,然后才是拆服务和无状态化水平扩容;写路径上看瓶颈在 DB 还是队列,DB 走分库分表、队列走分区扩容和消费并发;最后补容量压测和预案。顺序很重要,一上来就拆微服务是把复杂度加上去却没解决单点瓶颈。