字节飞书 AI全栈开发 一面:二级缓存冷启动与 2000ms 慢接口
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
个人背景与项目深挖(广告系统 & 缓存治理)
- 请先做一个简单的自我介绍。
- 讲一下出价链路与广告召回项目?
- 简历中提到的「二级缓存替代全量内存缓存」,这块的重构背景与实现逻辑是什么?
- 缓存冷启动问题:如果服务集群刚启动,本地内存缓存和 Redis 缓存都是空的,可能会面临什么问题?
- Redis 里的数据是怎么来的?具体怎么从 MySQL 批量拉取?
- 怎么筛选需要写入/更新到 Redis 的数据(是全量写还是部分写?如何圈选)?
- 本地内存缓存会不会有内存占用过大的问题?如果超过设定的 50 万条限制会怎样?
计算机网络与 Web 基础
- 常见的 HTTP 请求头(Headers)有哪些?各自的含义和作用是什么?
- Cookie 的用处是什么?
生产性能调优与接口排障(2000ms 慢接口定位)
- 如果线上业务系统的接口响应时间突增到 2000ms,你会如何分步骤排查、定位和解决这个问题?
- 如果排查后确认是 MySQL 慢查询导致的,具体如何分析与优化?
- 除了索引失效和全表扫描,还有哪些常见的 MySQL 慢查询成因?
- 非数据库瓶颈排查:如果慢查询优化后耗时降到了 800ms,SQL 已经不慢了但接口依然达不到性能要求,还有哪些场景会导致接口耗时偏高?
- 针对当前 800ms 的慢接口,除了刚才提到的排查手段,有哪些通用的工程优化手段?
- 一次常见的 Redis 请求耗时大概是多久(能否解释单次 800ms 的接口瓶颈)?
数据结构与算法原理(堆结构与海量数据处理)
- 了解小顶堆(最小堆)吗?
- 什么是最小堆?它的严格数学与数据结构定义是什么(完全二叉树、父子节点约束条件)?
- 给你一堆杂乱无章的无序数据,如何构建一个小顶堆(建堆的过程与动作是什么)?
- 弹出的动作底层是如何运行的?
- 海量数据求交题:A、B 两个文件各存放 50 亿个 URL,每个 URL 占 64 字节,内存限制 4GB,如何高效找出两个文件共同的 URL?
算法手写题(手撕 LRU 缓存)
- 手写 LRU 缓存:基于双向链表加哈希表实现一个标准 LRU 缓存类,实现 get 和 put,时间复杂度均为 O(1),容量满时淘汰最近最少使用的节点,禁止使用标准库内置的现成容器。
- 代码实现细节追问:put 一个已经存在的 key 时,更新了对应节点的 value 之后,是否需要将该节点移动到双向链表的头部?
数据库底层原理(MySQL 死锁与隔离级别)
- 在 MySQL 并发场景下,两个数据库事务同时执行,在什么条件下会触发死锁?
- 如果业务代码中已经写出了这种存在潜在加锁冲突的事务 SQL,线上流量同时发起请求时,这两个事务一定会发生死锁吗(执行时机与时间差影响)?
- 在不同的数据库事务隔离级别(如 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 锁的经典死锁,并发度最差。另外两个常被追问的点:唯一索引上的等值命中会退化成记录锁,没命中则退化成间隙锁;没有索引的更新会锁全表扫描到的所有行(并对间隙加锁),这是线上最危险的一类死锁来源,所以「更新条件必须有索引」既是性能要求也是稳定性要求。