虾皮后端一面:Redis 与操作系统内存连问
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 手撕:二分实现 LeetCode 4,寻找两个正序数组的中位数。
- 介绍一下项目的整体架构。
- 后端项目的主要设计模式、思路是什么?
- 查询时延是怎么下降的,采用了哪些措施?
- 讲一下 Redisson 分布式锁的底层原理;如果要自己实现,主要包含哪些方面?
- 单实例 Redis 挂了,还需要缓存重建吗?需要重建哪些?
- RDB 文件存储的是什么?怎样生成的 RDB?
- bgsave 的原理是什么?为什么要用子进程而不是开辟其他线程?
- Redis 是怎么做并发处理的?
- 详细解释一下 epoll。
- 边缘触发为什么必须用非阻塞 socket?
- 讲一下线程和进程。
- 进程间通信和线程通信都是怎么做的,区别是什么?
- 为什么有了进程还要有线程,还要有协程?
- 用户发起 write 到写入磁盘的整个过程是怎样的,从操作系统底层分析。
- 申请 100M 内存,虚拟内存和物理内存分别占多少,精确到 KB?
- 机器物理内存一共 50M,申请 100M 内存能成功吗?
- 页表能不能 swap 到交换分区?
- 多级页表为什么能节省物理内存?
- 缺页中断的完整流程是怎样的?
- 讲一下写时复制的原理。
- TLB miss 会带来什么样的代价,怎么避免 miss?
- TLB miss 和 Cache miss 的区别?
- 你更倾向于做后端开发还是 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 管数据搬运」这条边界点明,再补一句大页的代价(内存碎片与预分配,透明大页在数据库场景下有时要关掉)。