面灵AI→

字节飞书 AI全栈开发 一面:二级缓存冷启动与 2000ms 慢接口

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

《面试题目》

个人背景与项目深挖(广告系统 & 缓存治理)

  1. 请先做一个简单的自我介绍。
  2. 讲一下出价链路与广告召回项目?
  3. 简历中提到的「二级缓存替代全量内存缓存」,这块的重构背景与实现逻辑是什么?
  4. 缓存冷启动问题:如果服务集群刚启动,本地内存缓存和 Redis 缓存都是空的,可能会面临什么问题?
  5. Redis 里的数据是怎么来的?具体怎么从 MySQL 批量拉取?
  6. 怎么筛选需要写入/更新到 Redis 的数据(是全量写还是部分写?如何圈选)?
  7. 本地内存缓存会不会有内存占用过大的问题?如果超过设定的 50 万条限制会怎样?

计算机网络与 Web 基础

  1. 常见的 HTTP 请求头(Headers)有哪些?各自的含义和作用是什么?
  2. Cookie 的用处是什么?

生产性能调优与接口排障(2000ms 慢接口定位)

  1. 如果线上业务系统的接口响应时间突增到 2000ms,你会如何分步骤排查、定位和解决这个问题?
  2. 如果排查后确认是 MySQL 慢查询导致的,具体如何分析与优化?
  3. 除了索引失效和全表扫描,还有哪些常见的 MySQL 慢查询成因?
  4. 非数据库瓶颈排查:如果慢查询优化后耗时降到了 800ms,SQL 已经不慢了但接口依然达不到性能要求,还有哪些场景会导致接口耗时偏高?
  5. 针对当前 800ms 的慢接口,除了刚才提到的排查手段,有哪些通用的工程优化手段?
  6. 一次常见的 Redis 请求耗时大概是多久(能否解释单次 800ms 的接口瓶颈)?

数据结构与算法原理(堆结构与海量数据处理)

  1. 了解小顶堆(最小堆)吗?
  2. 什么是最小堆?它的严格数学与数据结构定义是什么(完全二叉树、父子节点约束条件)?
  3. 给你一堆杂乱无章的无序数据,如何构建一个小顶堆(建堆的过程与动作是什么)?
  4. 弹出的动作底层是如何运行的?
  5. 海量数据求交题:A、B 两个文件各存放 50 亿个 URL,每个 URL 占 64 字节,内存限制 4GB,如何高效找出两个文件共同的 URL?

算法手写题(手撕 LRU 缓存)

  1. 手写 LRU 缓存:基于双向链表加哈希表实现一个标准 LRU 缓存类,实现 get 和 put,时间复杂度均为 O(1),容量满时淘汰最近最少使用的节点,禁止使用标准库内置的现成容器。
  2. 代码实现细节追问:put 一个已经存在的 key 时,更新了对应节点的 value 之后,是否需要将该节点移动到双向链表的头部?

数据库底层原理(MySQL 死锁与隔离级别)

  1. 在 MySQL 并发场景下,两个数据库事务同时执行,在什么条件下会触发死锁?
  2. 如果业务代码中已经写出了这种存在潜在加锁冲突的事务 SQL,线上流量同时发起请求时,这两个事务一定会发生死锁吗(执行时机与时间差影响)?
  3. 在不同的数据库事务隔离级别(如 RC、RR、串行化)下,死锁发生的条件和加锁表现是一样的吗?具体表现有什么差异?

《参考解析》

「二级缓存替代全量内存缓存」这题要讲清三件事:为什么换、边界在哪、换完怎么保证一致。 全量内存缓存的原始问题是每个实例都存一份完整数据:内存随数据量线性涨、扩容时每台都要重新加载、更新要靠广播且各实例时刻不同步、进程一重启就全丢。改成二级之后,L1 是进程内的热点小缓存(容量受限、按命中率淘汰),L2 是 Redis 共享层,回源才是 MySQL。重构的实质是把「数据放在哪」变成「哪些数据在哪个温度层」:L1 只放访问频次前 N 的条目,L2 放全量可缓存集,两者都有 TTL 且不同步强一致——读路径允许短时间不一致,写路径先更新 DB 再删 L2(不是更新),L1 靠短 TTL 或版本号自然过期。要主动交代代价:一次请求在 L1 未命中时多一跳网络(同机房 Redis 通常 0.21ms,跨可用区 13ms),以及「删缓存 + 并发读」仍可能把旧值刷回 L1,所以要么给 L1 带版本号校验,要么接受秒级不一致并在业务上保证可容忍。

冷启动是这套结构最疼的地方,答案要给出「谁来挡第一波」。 集群刚起来时 L1、L2 全空,所有请求同时回源 MySQL,而且是同一批热点 key——这就是击穿叠加雪崩,数据库连接池会先被打满,表现为接口全站超时而不只是慢。工程上的四层挡法:一是预热,发布流程里在接流量前用离线导出的热点 key 列表灌 L2、再让每个实例预热自己的 L1(按历史访问日志取 Top N,通常几千到几万条就够覆盖大部分请求);二是入口侧限流加请求合并,同一个 key 在途只允许一个回源,其余等待复用结果(singleflight);三是数据库保护,给回源查询设并发上限和超时,超了直接返回旧值或降级默认值,别让慢查询堆死连接池;四是灰度放量,先 1% 流量再逐步放开,让缓存自然热起来。要能说出判断标准:冷启动期间看「回源 QPS / 总 QPS」这个比值,正常应在几秒内快速下降,如果一直高位说明预热或 key 设计有问题。

Redis 的数据怎么来,决定了这套缓存能不能维护。 全量写和增量写要分开说:可接受延迟的维度表、配置类数据走离线/定时全量(按主键分页扫 MySQL,where id > lastId order by id limit N 而不是深分页 offset,单批 500~1000 条,用 pipeline 批量写,带 TTL 兜底);变化频繁的业务数据走 binlog 订阅(Canal/DTS)或业务双写,只推变更的 key。圈选的判据是「读多写少 + 能容忍秒级不一致 + 有明确主键」,写多读少或强一致的(余额、库存扣减、订单状态)根本不进这层缓存。50 万条上限那题要答出淘汰策略与观测:本地缓存必须是有界结构,超限按 LRU/LFU 淘汰并打点记录淘汰率;如果淘汰率长期偏高说明容量估小了或热点分布太散,正确做法是调容量或把这类 key 挪到 L2;同时要防单个超大 value 把内存撑爆(限制单条大小、对列表类数据只缓存 ID 列表不缓存实体)。

2000ms 到 800ms 这段排障,考的是分层而不是工具名。 顺序应该是:先确认范围(全站还是单接口、全机房还是单机、什么时间开始、有无发布/配置变更/流量突增),再看入口指标(QPS、P99、错误率、线程池/连接池水位、GC),然后按依赖拆耗时(在链路上打点或看 Trace,把总耗时拆成自身 CPU、DB、缓存、RPC、序列化、网络五段),定位到最慢那段再往下钻。这一步的价值在于:2000ms 里如果有 1200ms 花在某个下游 RPC 上,你去优化 SQL 就是白干。定位手段要具体:EXPLAIN 看执行计划(type、key、rows、Extra 里的 filesort/temporary)、慢查询日志按 Query_time 排序看 Top SQL、SHOW PROCESSLIST 看是否卡在 Sending data/Waiting for lock、SHOW ENGINE INNODB STATUS 看锁等待,应用侧看 GC 日志与线程栈(连续几次 jstack 看是不是都停在同一个调用上)。

如果 SQL 已经不慢、接口仍是 800ms,就要从「次数 × 单次成本」两个方向继续找。常见成因:N+1 查询(一个列表页发 200 次单条查询,每次 2ms 也是 400ms)、循环里调 RPC 或 Redis(100 次 × 1ms 起步)、连接池过小导致排队(等待时间算在接口耗时里)、大结果集序列化与网络传输(一次拉 10MB JSON)、锁竞争(同一行热点更新串行化)、GC 停顿(老年代大对象导致百毫秒级 STW)、同机资源争抢(CPU 限流、容器 throttling)、以及日志同步刷盘。对应的通用手段:批量合并(IN 查询、pipeline、批量接口)、加一层针对该接口的读缓存、把串行依赖改并行(CompletableFuture/协程,注意线程池隔离)、分页与字段裁剪(只查需要的列、大字段拆表)、异步化非关键路径(写日志、发通知走 MQ)、连接池与超时参数按 P99 调。Redis 那问别答错量级:同机房单次 GET 通常 0.21ms,跨机房 13ms,所以 Redis 解释不了 800ms——除非是 hot key 打满单分片、大 key(几十万元素的 list/hash 一次取回)、或客户端连接池耗尽在排队,这三种都能到几百毫秒,要能说出排查方法(SLOWLOG、--bigkeys、INFO commandstats、分片监控)。

小顶堆三问要连着答:定义、建堆、弹出。 定义是完全二叉树且每个节点都不大于它的两个孩子(a[i] <= a[2i+1] 且 a[i] <= a[2i+2],0 基),根就是全局最小;用数组存,父子下标靠 (i-1)/2 和 2i+1/2i+2 算,不需要指针。建堆是自底向上的「下沉」:从最后一个非叶节点 n/2 - 1 开始往前,对每个节点做 sift-down(与较小的孩子交换,直到满足堆序或到叶子),总复杂度 O(n) 而不是 O(n log n)——因为高度为 h 的节点有 n/2^(h+1) 个、每次下沉代价 O(h),求和收敛到 2n。弹出(poll)是:取走根,把数组最后一个元素搬到根,size 减一,然后对新根做一次 sift-down,代价 O(log n);插入则是放到末尾再 sift-up,同样 O(log n)。追问常落在「为什么建堆是 O(n)」和「取 Top K 为什么用小顶堆」——后者答:维护大小为 K 的小顶堆,堆顶是当前 K 个里最小的,新元素比堆顶大就替换并下沉,总代价 O(n log K),内存 O(K),比全排序 O(n log n) 省。

50 亿 URL 求交,先把算术摆出来再谈方案。 单文件 50 亿 × 64B = 320GB,两个文件 640GB,4GB 内存显然放不下。标准解法是哈希切分:用同一个哈希函数按 hash(url) % N 把两个文件各自切成 N 份,保证同一个 URL 一定落在编号相同的分片上;然后逐对处理分片 i,把小分片读进内存用哈希集合求交。N 怎么定要算:320GB / N 要显著小于 4GB,取 N=256 时每片约 1.25GB 原始字节,但哈希表存字符串有额外开销(指针 + 头 + 负载因子,实际常是原始大小的 1.5~2 倍),所以 256 比 128 稳妥,128 时每片 2.5GB 很容易 OOM。还能加分的方向:如果只判断「是否存在共同 URL」而不要求输出全部,可以先用布隆过滤器把 A 的 URL 建成位图(50 亿条、1% 误判率约需 6GB,还是偏大,需要配合切分),或者用「先按 hash 前缀再按位图」的两级结构;工程上更实际的是多机并行,每台负责若干分片。要主动说出代价:需要两遍磁盘 IO(切分 + 逐片处理),临时文件占空间,且哈希函数必须对两边完全一致。

LRU 的追问只有一个正确答案:要移到头部。 get 命中要移到头部,put 已存在的 key 也必须移到头部——因为 LRU 的定义是「最近被使用」,一次 put 更新同样是使用;如果只改 value 不动链表,这个节点的位置还停留在上次读的时间,淘汰时就会把一个刚写过的热 key 先淘汰掉,行为退化成「最近读取」而不是「最近使用」。实现上的顺序是:查 map 命中 → 更新 value → 从链表摘除 → 插到头部。手写时的其他得分点:用哨兵 head/tail 省掉空指针判断;容量满时删 tail 前驱并从 map 里 erase;get 未命中返回 -1 或空;put 时 key 已存在不要重复计数。再往深可以主动提:带过期时间要做惰性删除加定期清理,且淘汰顺序要考虑过期项优先;并发场景 get 也改链表结构,读写锁收益有限,实际用分段锁或近似 LRU(采样淘汰)。

MySQL 死锁三问,核心是把「条件」和「时机」分开。 死锁的四个必要条件是互斥、持有并等待、不可剥夺、循环等待;在 InnoDB 里通常表现为两个事务以相反顺序持有行锁再互相申请,典型的 update ... where id=1 和 update ... where id=2 交叉执行。会不会一定死锁?不会——它依赖执行时序:只有两边都先拿到第一把锁、再同时申请对方的锁,才形成环;如果事务 A 提交得早,B 只是正常等待。所以线上表现是「偶发、低频、和并发量正相关」,很难在测试环境复现。防法是从设计上消灭环:按固定顺序加锁(比如永远按主键升序批量更新)、缩短事务(把 RPC 和耗时计算挪到事务外)、给唯一索引上的等值更新走记录锁而不是范围锁、必要时用 SELECT ... FOR UPDATE 一次性锁住需要的行,以及给 innodb_lock_wait_timeout 设合理值让等待方快速失败重试。

隔离级别的差异最终要落到「锁的范围」:RC 下间隙锁基本关闭(除了外键和唯一性检查的特殊场景),只加记录锁,锁的范围小、死锁概率低,代价是幻读;RR 下用 next-key lock(记录锁 + 间隙锁)防幻读,锁范围明显变大,两个事务对相邻区间做插入或范围更新时更容易成环,所以 RR 的死锁率高于 RC——这也是很多互联网业务把隔离级别设成 RC 的实际原因。串行化下读也会加共享锁(普通 SELECT 退化成 LOCK IN SHARE MODE 语义),读写之间互相阻塞,两个事务「先读后写同一批行」会形成 S 锁升级 X 锁的经典死锁,并发度最差。另外两个常被追问的点:唯一索引上的等值命中会退化成记录锁,没命中则退化成间隙锁;没有索引的更新会锁全表扫描到的所有行(并对间隙加锁),这是线上最危险的一类死锁来源,所以「更新条件必须有索引」既是性能要求也是稳定性要求。