拼多多服务端研发二面:Redis 大 key 拆分、多副本一致性与线程池
- 轮次
- 二面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 自我介绍
- 介绍实习
- 详细讲一下 Redis 大 key 拆分是怎么做的?原来的 key、value 和拆分后的 key、value 分别是什么?
- Redis 某个节点上有个缓存,不是大 key,但访问量很高,占了所有访问的 80% 以上,怎么办?
- 你说要在多个节点上维护多个副本,这种方法适用于什么场景?
- 多副本情况下一致性怎么保证?比如写操作写了一部分副本后失败了,其他副本没有写,怎么办?
- 讲一讲分布式锁怎么实现?
- 讲一下实现过程中具体的 key 和 value 是什么?
- Redis 的 Hash 结构可能会有什么问题?你刚刚提到了大 key,还有什么其他问题吗?
- 线程池怎么用,有哪些参数?
- 线程池怎么停止?
- 非核心线程怎么销毁,具体过程讲一下?
- 消息队列的可靠性怎么保证?
- 消息队列的幂等性怎么保证?
- 讲一下大模型 tool 调用的具体原理
- 手撕:LeetCode 221. 最大正方形
《参考解析》
大 key 拆分要能给出「前后对照」,这是面试官追问的重点。 假设原来的 key 是 user:feed:{uid} 存一个用户的全量动态列表(几十万元素),拆分有两种主流做法:按维度切(feed:{uid}:{bucket},bucket 由内容 id 取模或按时间分片,读的时候按范围取需要的几段再合并),或者按字段拆(把大 Hash 拆成多个小 Hash,key 里带字段分组或序号,读单个字段就只取对应那个 key)。回答时必须说清三件事:拆分后读路径变复杂了(需要合并、需要控制并发)、跨分片的原子性没了(原本一个 HINCRBY 能保证的,现在要用 Lua 或分布式锁)、以及线上如何迁移(双写 + 灰度读 + 对账,不能停机改结构)。
热点 key(非大 key)的处理与多副本方案的边界。 访问量占 80% 说明是流量集中而不是数据量大,最前面应该加本地缓存(进程内缓存挡掉绝大部分请求,代价是更新有延迟、多实例间不一致),其次是多副本分散(把内容复制成 N 份、key 加随机后缀、客户端随机选一个读)。但多副本方案的适用场景很窄:读远多于写、且业务能容忍副本间的短暂不一致(比如榜单、配置、素材)。如果是库存、余额这类强一致数据,多副本只会引入对账麻烦,正确的做法是分片(sharding)而不是复制,或者干脆换存储。
多副本写失败的一致性,答案是「要么不做多副本,要么做版本与对账」。 真正的解法不是让写操作同时在 N 个副本上成功(那需要分布式事务),而是:以单一权威源为准(比如主 key 或数据库),副本只是加速读取的派生数据,写入时先落权威源、再异步刷新副本,每个副本带版本号或时间戳,读到旧版本时触发修复;定期跑对账任务比对权威源与各副本。所以「写了一部分副本后失败」的正确反应是重试 + 版本校验 + 后台补偿,而不是试图在写路径上保证全部成功。
分布式锁要讲到 key/value 的具体形状和续期。 典型实现是 SET lock:{resource} {requestId} NX PX 30000:key 是资源标识(粒度和并发度直接相关,太粗会导致无谓竞争),value 必须是唯一的请求标识(UUID 或「实例 id + 线程 id」),它的作用只有一个——解锁时校验「锁还是我加的」,防止误删别人的锁;NX 保证互斥,PX 给过期时间防死锁。解锁要用 Lua 做「比较 value 再 DEL」的原子操作。再往下是必须主动补的两个坑:业务执行超过锁过期时间(需要看门狗续期,或者一开始就别让锁的持有时间依赖业务耗时)和主从切换导致的锁丢失(Redis 异步复制,主挂了锁还没同步过去,新主上别人就能拿到同一把锁;强一致场景要用 Redlock 或换 ZooKeeper/etcd 这类基于共识的实现)。
Redis Hash 除了大 key 还有几个坑。 小 Hash 用 ziplist/listpack 编码,字段过多或单个 value 过大时会转成 hashtable,转换是一次性且阻塞的,所以要么在设计上控制字段数量,要么提前用 config set hash-max-listpack-entries 配合预转换。其次是过期粒度:Hash 只能整个 key 过期,没法给单个 field 设 TTL,需要 TTL 的场景得拆成独立 key 或自己在 value 里存时间戳。还有 HGETALL 在大 Hash 上会阻塞主线程、HKEYS/HVALS 同理,线上要避免。
线程池这道题的关键是「队列决定线程数」。 核心参数是 corePoolSize、maximumPoolSize、keepAliveTime、workQueue、threadFactory、handler。执行顺序是:核心线程未满就新建;核心满了先入队;队列满了才扩到 maximumPoolSize;再满就走拒绝策略。所以「任务堆积但 CPU 低、线程数没到 maximumPoolSize」的最典型原因就是用无界队列(LinkedBlockingQueue 默认容量是 Integer.MAX_VALUE),任务永远能入队,线程数永远扩不上去——这也是很多人「以为设了 maximumPoolSize 就安全」的陷阱。其他原因是工作线程本身卡在等待上(下游慢、锁竞争、连接池耗尽),这时线程不算忙但 CPU 也很低,要用 jstack 看线程栈和 getActiveCount/getQueue().size() 判断。
线程池的停止与非核心线程销毁。 停止分两步:shutdown() 停止接收新任务、等在队列里的任务执行完;shutdownNow() 中断正在执行的任务并返回未执行的任务列表。生产上还要配优雅停机(先停上游流量、再 shutdown、给一个超时、超时后 shutdownNow),否则会丢任务或产生半完成状态。非核心线程销毁的执行者是 Worker:空闲线程在 getTask() 里通过 workQueue.poll(keepAliveTime, ...) 带超时取任务,超时返回 null 就跳出循环,processWorkerExit 把 Worker 从集合里移除并释放线程;如果是 allowCoreThreadTimeOut(true),核心线程走同一条路径。
MQ 的可靠性和幂等要按「三段」拆开讲。 可靠性覆盖生产端(同步发送 + 确认回调 + 本地消息表/事务消息解决「业务成功但消息没发出去」)、Broker 端(副本、刷盘策略、至少 acks=all + min.insync.replicas)、消费端(手动 ack、失败重试 + 死信队列,注意重试要有上限和退避)。幂等则是消费端自己的责任:用业务唯一键建去重表、用状态机做条件更新(UPDATE ... WHERE status = 'INIT',影响行数为 0 就说明已处理)、或者把去重键写进 Redis 配 SETNX。核心认知是 MQ 一般只保证 at-least-once,所以幂等不是可选项。
大模型 tool 调用的原理,一句话是「用结构化输出换一次外部执行」。 请求里除了消息,还带上工具的 JSON Schema 描述(名字、参数类型、必填项、说明);模型被训练成在需要外部信息时输出一个结构化的调用意图(工具名 + 参数),而不是自然语言;调用方解析这个结构、在本地/远端执行、把结果作为一条新的消息(role=tool)回灌进上下文,模型再基于结果继续推理或给出最终答复。工程上的关键点全在模型之外:参数校验(模型可能给错类型或漏必填)、权限边界(哪些工具可被自动调用、哪些必须人工确认)、失败语义(可重试/不可重试/需要澄清)、循环上限(防止反复调用同一个工具烧钱)、以及幂等(重放不能产生副作用)。
手撕 221 最大正方形。 标准解法是二维 DP:dp[i][j] 表示以 (i,j) 为右下角的最大正方形边长,转移是 dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1(当前格子为 ‘1’ 时),答案取最大值;可以滚动成一维数组把空间降到 O(n)。写的时候先确认输入是字符矩阵还是数字矩阵、边界行列怎么处理,这类题面试官主要看转移方程的来源能不能讲清楚(为什么取三者最小值)。