面灵AI→

网易雷火游戏服务端一面:搜索召回优化与 Redis 实战

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

《面试题目》

  1. 请自我介绍并概括几段实习。哪段实习对技术提升最大,为什么?
  2. 为什么投游戏业务?游戏服务端与互联网服务端有什么区别?为何没有相关实习?
  3. 第二个实习召回模块是什么,为什么会拖慢搜索主链路?
  4. 怎样定位串行瓶颈,比较过哪些方案?
  5. 并发改造怎样实现?是否必须等两路都成功?
  6. 为什么没有在该召回模块增加本地缓存?
  7. POI 品类两级缓存解决什么问题?怎样预热、统计命中率和控制容量?
  8. Guava Cache 的 24 小时过期如何清理无人再访问的条目?
  9. 第三个实习 Redis 集群软删除撤回是什么?为什么做个人项目,想提升什么?
  10. Redis 预扣、MQ 和 MySQL 落单失败时怎样保证一致性?
  11. 缓存穿透、雪崩、击穿分别怎样处理?
  12. 怎样识别热点 Key,如何保存与维护访问频率?
  13. 攻击者每次换一个不存在的 Key,空值缓存导致内存膨胀怎么办?
  14. CAS 与锁有什么区别,分别适合什么场景?
  15. 用户程序怎样进入内核态?栈、PID 和切换开销怎样变化?
  16. 什么是死锁,四个必要条件是什么,怎样规避?
  17. B 树与二叉树的结构、复杂度和使用场景有什么区别,数据库为何不用二叉树?
  18. InnoDB MVCC 的机制和用途是什么?
  19. redo、undo、binlog 分别做什么?一条 UPDATE 中何时写?
  20. TCP 与 UDP 有什么区别,有哪些 UDP 应用?

《参考解析》

为什么没给召回模块加本地缓存:这是面试官在考”方案取舍”。合理的理由是:该数据更新频繁或与用户强相关,本地缓存命中率低、还带来一致性负担;或者该模块的结果维度多(每个用户/每个查询都不同),缓存收益不足以覆盖内存成本;又或者这个链路的瓶颈本来就在下游 RPC 而不在重复计算。反过来如果答不出理由,就是漏了优化点。回答时给一个判断标准比给一个结论更好——「缓存的前提是同一份数据被重复请求,而这个模块的 key 粒度太细,命中率实测很低」。

Guava Cache 的过期清理:Guava 用的是”惰性清理 + 定期清理”的混合策略。expireAfterWrite / expireAfterAccess 本身不启动定时任务;条目只在被访问时才发现过期(并在此时剔除)。同时 Guava 会在写操作时按比例做少量清理(segment 级别的 cleanUp),以及 CacheBuilder 内置的维护任务按 expireAfterAccess 的最小值周期性清理。所以”过期但无人再访问”的条目会一直占内存直到触发清理——这也是它必须配 maximumSize / maximumWeight + 淘汰策略的原因。要精确控制回收可以用 CacheBuilder 的 removalListener 做观测,或者直接用 Caffeine(用时间轮 + 无锁读,过期更及时)。

Redis 预扣 + MQ + MySQL 的一致性:典型的下单/扣库存链路。稳妥做法是:Redis 用 Lua 脚本原子地做”判断余量 + 预扣”(避免先查后扣的竞态),成功后再发 MQ 消息,消费者落 MySQL。失败路径要全覆盖——MQ 发送失败就回滚 Redis 预扣(用相同的 Lua 补偿);MySQL 落单失败则重试,超过次数进死信队列并人工介入;消费端必须幂等(唯一订单号 + 唯一索引),否则重投会重复扣。最终一致性靠”对账”兜底:定时比对 Redis 余量与 MySQL 实际扣减量,发现偏差就修正。要注意 MQ 只能保证最终一致,中间态(已预扣未落单)必须可观测、可补偿。

空值缓存导致内存膨胀:攻击者每次换 Key,空值缓存就等于被用来做内存放大。对策:① 对空值缓存的 TTL 设得很短(几秒到几十秒);② 加请求层限流与来源识别(同 IP/同账号的异常查询频率拦截);③ 用布隆过滤器在缓存之前挡掉大部分不存在的 Key(代价是有假阳性,需要控制位图大小);④ 对 Key 的形态做白名单校验(比如必须是合法 ID 格式),格式非法的直接拒掉不进缓存;⑤ 监控空值 key 的数量与内存占比,设告警。

热点 Key 识别与频率维护:识别靠采样统计而不是全量计数——客户端埋点上报(Top-K 统计)、Redis 的 MONITOR/hotkeys(成本高,只适合临时排查)、或代理层(如基于 LFU 的近似计数)。维护访问频率常用近似结构:Count-Min Sketch(固定内存、可能高估)、滑动窗口计数、或 Redis 自身的 LFU(allkeys-lfu 用 8 位对数计数器,访问时概率性递增、按时间衰减,正好能找出热点)。找 Top K 可以用「Sketch 估算频率 + 小顶堆维护前 K」的组合,不必保存全部 Key 的精确计数。识别出来后处理:本地缓存挡一层、把热点 Key 拆成多个副本分散到不同节点、或读写分离。

进入内核态:系统调用、异常(如缺页、除零)、外部中断都会让 CPU 从用户态切到内核态。切换时 CPU 要保存用户态寄存器上下文、换上内核栈,并更新特权级;PID 不变(还是同一个进程),但栈从用户栈切到内核栈。开销主要来自寄存器保存/恢复、TLB 与 cache 的局部性被破坏(尤其是有安全缓解措施后更贵),所以高频小系统调用(如逐字节 read/write)会被批量化优化(缓冲、io_uring、批处理)。

死锁与四个必要条件:互斥、占有并等待、不可剥夺、循环等待。破坏任一个即可预防——统一加锁顺序(破坏循环等待,最实用)、一次性申请全部资源(破坏占有并等待)、用 tryLock(timeout) 超时放弃并回滚(破坏不可剥夺)、以及尽量减少共享(降低互斥范围)。检测上 jstack 会直接报 Java 级死锁并给出锁的持有等待链。

为什么数据库不用二叉树:二叉树高度是 log₂N,千万级数据树高 20+ 层,每层一次随机磁盘 I/O,性能不可接受。红黑树同属二叉结构,问题一样。B 树每个节点既存 key 也存数据,单页能容纳的 key 少、树更高,范围查询还要跨层回溯。B+ 树非叶子节点只存 key,一页能放几百个 key,三到四层即可覆盖千万级数据;叶子节点用双向链表相连,范围查询与排序只需顺序扫描叶子。本质是”用更矮的树把磁盘 I/O 次数压到 3 次以内,用链表把范围扫描变成顺序访问”。

InnoDB MVCC:用途是让读不加锁——一致性读(快照读)通过 ReadView 判断行的哪个版本对当前事务可见。每行有隐藏列 DB_TRX_ID(最后修改它的事务 ID)和 DB_ROLL_PTR(指向 undo log 中的旧版本)。RC 隔离级别下每次查询都生成新的 ReadView,所以能看到别人已提交的新数据;RR 下只在第一次查询时生成,整个事务复用,从而实现可重复读。undo log 里的版本链配合 ReadView 的可见性判断(已提交且早于本事务、或就是本事务的修改)决定读到哪个版本。

redo / undo / binlog:redo log 是 InnoDB 的物理日志,记录”某页某偏移改了什么”,用于崩溃恢复(WAL:先写日志再刷脏页);undo log 是逻辑日志,记录反向操作,用于事务回滚和 MVCC 读旧版本;binlog 是 MySQL Server 层的逻辑日志(statement/row/mixed),用于主从复制和数据恢复。一条 UPDATE 的写入顺序(两阶段提交):先写 undo 并改内存中的数据页 → 写 redo(prepare 状态)→ 写 binlog → 提交事务并把 redo 标记为 commit。这个顺序保证崩溃后 redo 与 binlog 一致,不会出现主库做了、从库没做的分裂。

TCP 与 UDP:TCP 面向连接、可靠有序、有流量控制与拥塞控制、开销大(握手、ACK、重传、20 字节起头部);UDP 无连接、不保证送达与顺序、头部只有 8 字节、开销小、支持广播组播。UDP 的典型应用是 DNS、DHCP、VoIP、视频直播、游戏(实时性优先,允许丢帧但不允许卡顿)、以及 HTTP/3(QUIC 跑在 UDP 上,自己做可靠传输以避免 TCP 队头阻塞)。