面灵AI→

虾皮后端一面:Redis 与操作系统内存连问

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

《面试题目》

  1. 手撕:二分实现 LeetCode 4,寻找两个正序数组的中位数。
  2. 介绍一下项目的整体架构。
  3. 后端项目的主要设计模式、思路是什么?
  4. 查询时延是怎么下降的,采用了哪些措施?
  5. 讲一下 Redisson 分布式锁的底层原理;如果要自己实现,主要包含哪些方面?
  6. 单实例 Redis 挂了,还需要缓存重建吗?需要重建哪些?
  7. RDB 文件存储的是什么?怎样生成的 RDB?
  8. bgsave 的原理是什么?为什么要用子进程而不是开辟其他线程?
  9. Redis 是怎么做并发处理的?
  10. 详细解释一下 epoll。
  11. 边缘触发为什么必须用非阻塞 socket?
  12. 讲一下线程和进程。
  13. 进程间通信和线程通信都是怎么做的,区别是什么?
  14. 为什么有了进程还要有线程,还要有协程?
  15. 用户发起 write 到写入磁盘的整个过程是怎样的,从操作系统底层分析。
  16. 申请 100M 内存,虚拟内存和物理内存分别占多少,精确到 KB?
  17. 机器物理内存一共 50M,申请 100M 内存能成功吗?
  18. 页表能不能 swap 到交换分区?
  19. 多级页表为什么能节省物理内存?
  20. 缺页中断的完整流程是怎样的?
  21. 讲一下写时复制的原理。
  22. TLB miss 会带来什么样的代价,怎么避免 miss?
  23. TLB miss 和 Cache miss 的区别?
  24. 你更倾向于做后端开发还是 Agent 开发?

《参考解析》

求两个正序数组中位数的标准解法是对「划分」做二分。 目标是把两个数组各切成左右两半,使左半部分的总元素个数等于 (m+n+1)/2,且左半部分的最大值不大于右半部分的最小值。做法是在较短的数组上二分切点 i,由总长度推出另一个数组的切点 j,比较 A[i-1] <= B[j] 与 B[j-1] <= A[i]:成立就找到了,否则按方向收缩二分的边界。边界处理是这题的坑:切点在 0 或数组末尾时用正负无穷当哨兵;总长度是奇数时中位数就是左半部分最大值,偶数时是(左最大加右最小)除以二,注意用 double 避免整数除法。时间复杂度 O(log min(m,n)),空间 O(1)。如果面试官允许放宽复杂度,也可以讲「归并到第 k 个」的 O(k) 做法作为对照,然后主动说明为什么二分更优——每次都能砍掉一半的搜索空间,而不是线性推进。

Redisson 分布式锁的底层是「Hash 结构加 Lua 脚本加看门狗」。 加锁时执行一段 Lua:key 不存在就 hincrby 建一个哈希、字段是「客户端 ID 加线程 ID」,value 记为 1,并设置过期时间;key 已存在且字段是自己的就 value 加一,实现可重入。解锁同样用 Lua:先判断字段是不是自己的,是就减一,减到 0 才删除 key,保证「不释放别人的锁」。看门狗解决的是业务执行时间超过锁过期时间的问题:默认 30 秒过期,后台定时任务每 10 秒检查一次,只要客户端还持有锁就续到 30 秒;只有不显式传 leaseTime 时才启用,传了就不再续期(这是个常被追问的考点)。如果自己实现,需要覆盖的点是:唯一标识(UUID 加线程 ID)防止误删、原子释放(Lua 或 GETDEL 语义)、过期时间与续期、可重入计数、锁等待与超时、以及客户端崩溃后的自动释放。再往上要谈 RedLock 的争议——它依赖各节点时钟不发生大幅跳变,在出现 GC 停顿或网络分区时无法给出线性一致性保证,工程上更常见的做法是把锁做成「只防重复、不假设绝对安全」,关键操作再加数据库唯一约束或状态机做最后一道防线。

RDB 是内存快照,bgsave 的关键是 fork 加写时复制。 RDB 文件存的是某个时间点的键值对二进制序列化(含数据类型、过期时间、压缩后的字符串等),生成方式有三种:save(阻塞主线程,线上禁用)、bgsave(fork 出子进程写盘)、以及主从复制时自动触发。用子进程而不是线程的原因有三层:一是 fork 只复制页表,父子进程共享物理页,靠写时复制隔离,快照那一刻的内存视图是天然一致的;二是子进程崩溃不影响主进程;三是 Redis 单线程模型下,多线程写盘要和主线程争数据结构与锁,改造成本和一致性风险都高。代价也要会说:fork 在大内存实例上会阻塞主线程(页表拷贝与内存分配),写时复制在写密集时会额外吃内存(极端情况接近翻倍),CPU 上也和主线程抢核。所以大实例通常「主从加 AOF 加定期 RDB」,或者干脆 RDB 放在从库做。至于单实例挂了要不要重建缓存,答案是「要,而且重建路径要能扛住惊群」:优先让流量回源但加互斥(只有一个线程去查库回填,其余等待或返回旧值)、热点数据预热、设随机 TTL 避免集体失效,同时要评估回源会不会打垮数据库,必要时降级返回兜底数据。

epoll 与边缘触发是同一道题的两半。 epoll 在内核里维护一棵红黑树(存关注的 fd)和一个就绪链表,epoll_ctl 增删改,epoll_wait 只返回就绪的 fd,因此每次调用不需要像 select/poll 那样全量拷贝和线性扫描,复杂度从 O(n) 降到 O(就绪数)。它支持水平触发(LT,只要缓冲区还有数据就一直通知)和边缘触发(ET,状态变化时只通知一次)。ET 必须配非阻塞 socket 的原因是:一次通知后你必须循环 read 到 EAGAIN 才算读完,否则剩下的数据不会再触发事件;而如果是阻塞 socket,最后那次「读完了」的 read 会永久阻塞住整个线程。ET 的收益是减少事件通知次数和一次系统调用,代价是编程模型更苛刻,必须一次性排空并处理短读。追问还常问「epoll 惊群」(多进程监听同一 fd,可用 EPOLLEXCLUSIVE 或 SO_REUSEPORT 缓解)和「ET 下写事件为什么一般不开」(写缓冲区通常可写,会一直触发,应该在有数据积压时再注册)。

进程、线程、协程的差别要从「隔离与调度」两个维度讲。 进程是资源分配单位,有独立地址空间,隔离强但切换要换页表、开销大;线程是 CPU 调度单位,同进程内共享地址空间,切换只保存寄存器与栈,更轻,但共享带来的竞态要靠锁解决;协程是用户态调度,切换发生在自己的调度器里不陷内核,栈更小、能开几十万个,适合 IO 密集型。为什么有了前两者还要协程:高并发场景下线程栈占内存、上下文切换成本高,而绝大多数线程都在等 IO——协程把「等待」这件事变成用户态的挂起与恢复,把每连接的资源占用压到极低。进程间通信有管道、消息队列、共享内存、信号、socket,线程间就直接读写共享变量加同步原语(互斥量、条件变量、信号量、原子操作),所以「线程通信」严格说没有独立机制,靠的是共享内存加同步。追问预备:共享内存最快但要自己做同步;管道是半双工的字节流;协程与「非阻塞 IO 加事件循环」本质是一套东西的两种写法。

write 到落盘的路径可以按层次背。 应用调用 write 陷入内核,数据从用户缓冲区拷到内核的 page cache(这一层就已经返回成功,不保证落盘),页被标记为脏页并挂到回写队列;随后由内核的 writeback 机制(脏页比例达到 dirty_ratio、或超过 dirty_expire_centisecs 时间、或 fsync/sync 触发)把脏页交给块层,块层做合并与排序后通过 IO 调度器下发到设备驱动,最终由磁盘控制器写入介质并可能受磁盘自身缓存影响。要落盘可靠就得上 fsync(并注意它只保证文件数据,目录项还要 fsync 目录),代价是每次都等真实 IO。追问常问「为什么 write 很快但 fsync 慢」「mmap 与 write 的区别」(mmap 省一次拷贝但缺页异常与回写时机更难控)、以及「O_DIRECT 绕过 page cache」的取舍。

几个内存问题本质都在考虚拟内存这一套机制。 申请 100M:虚拟内存立即增加约 100M(按页对齐,通常是 102400KB 上下),物理内存不会立即分配,只有真正写入时才逐页触发缺页中断分配——这就是 overcommit。物理内存只有 50M 而申请 100M:能不能成功取决于 overcommit 策略(默认启发式下 malloc 通常能返回成功),但一旦真的全部写入就会 OOM 被杀;所以「能申请成功」和「能用」是两件事。页表能不能被换出:可以,页表本身也是内存页,被换出时用 PTE 里的一个标记位(如 present 位为 0)表示,访问时再缺页换回来,代价是双重缺页。多级页表省内存的原因:单级页表要为整个虚拟地址空间连续存放表项,而实际用到的地址空间很稀疏;多级结构下只有被用到的那些中间层级才需要分配(比如 48 位地址四级页表,大部分进程的页中间目录里绝大多数表项为空,直接不分配下一级)。缺页中断的完整流程:CPU 访问虚拟地址 → MMU 查 TLB → miss 后查页表 → 页表项无效则触发缺页异常 → 内核判断是合法访问还是非法访问(非法就发 SIGSEGV)→ 合法则分配物理页(可能是换入、也可能是从文件映射读入或零页)→ 更新页表并刷新 TLB → 返回用户态重新执行那条指令。写时复制:fork 时父子共享物理页且都标为只读,任一方写入触发写保护异常,内核此时才复制一页给写方——它让 fork 变得廉价,也解释了 bgsave 为什么省内存、以及「fork 后父进程大量写会导致内存翻倍」。

TLB miss 与 Cache miss 要讲清层级关系和代价。 TLB 是页表项的缓存,miss 意味着 MMU 要按多级页表逐级访存(最多几次内存访问),代价是几十到上百个周期;如果此时页表项还不在 Cache 里,就是「TLB miss 叠加 Cache miss」,代价更高,这也被称为 page walk 的放大效应。Cache miss 是数据或指令不在 CPU 缓存,需要去下一级缓存或内存取。避免 TLB miss 的方向是提高页表项命中率与降低访问的地址跨度:用大页(HugePage,2M/1G 页表项覆盖更大范围,同样 TLB 条目能映射更多内存)、改善内存访问局部性(数据布局紧凑、避免随机跳转、按行优先遍历多维数组)、减少进程切换与地址空间抖动。Cache miss 则靠提高时间与空间局部性、数据对齐、避免伪共享(false sharing)、以及分支预测友好的写法。答的时候把「TLB 管地址翻译、Cache 管数据搬运」这条边界点明,再补一句大页的代价(内存碎片与预分配,透明大页在数据库场景下有时要关掉)。