ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java后端面试Redis高频题:从底层编码到分布式锁实战

Java后端面试Redis高频题:从底层编码到分布式锁实战 面过一轮又一轮 Java 后端岗之后我发现一个挺反直觉的现象Redis 这一关挂掉的人八成不是不会用而是说不清为什么这么用。set、get、expire 谁都会敲但面试官一旦往下追——为什么存个数字还要单独做一种编码、为什么删缓存要删两次、Redisson 的看门狗到底在续什么——很多人就开始含糊其辞了。这篇《Java面试题超详细整理》的 Redis 篇我打算换个写法不给你一份背完就忘的问答清单而是把每一道高频 Redis 面试题拆成四层——他为什么问、底层是什么、现场怎么答、答完还能补什么。这样你在面试桌上接住第一问只是及格能把第二、第三轮追问也接稳才算真的过关。下面这些内容一部分是标准答案的骨架另一部分是我自己在项目和面试里反复验证过的细节你可以按需取用。1. 面试官问 Redis 时他真正想听的是什么1.1 从会用到敢用的分水岭大部分简历上写熟悉 Redis的人实际掌握程度是知道它能做缓存知道 key-value 结构知道加个过期时间。这种水平在面试里的表现就是——问到缓存和数据库怎么保持一致回答更新数据库之后删缓存然后就没了。面试官再问一句为什么是删而不是更新立刻卡壳。真正的分水岭在敢用这两个字上。敢用意味着你知道这个命令在什么数据量下会退化、知道这个方案在什么并发下会失效、知道出问题之后从哪里开始排查。比如同样是缓存穿透有人说加布隆过滤器有人说我这边 QPS 峰值三万多布隆过滤器误判率控制在 1% 以内key 规模大概两千万所以我用了 64MB 的位图加 7 个哈希函数——这两个回答背后的信息量差着一个量级。所以准备 Redis 面试重点不是背命令而是补取舍逻辑。Redis 几乎所有问题都是取舍题性能和一致性取舍、内存和命中率取舍、开发速度和运维复杂度取舍。你只要能说清楚我为什么选 A 不选 B代价是什么面试官就会认为你是真的用过。1.2 一条能把追问接住的主线我自己的经验是把 Redis 的所有面试考点串成一条主线记忆和临场调用都会轻松很多。这条主线是四个问题数据放哪数据类型和底层编码决定了你的内存占用和命令复杂度。数据怎么不丢持久化、主从复制、集群决定了故障时你会损失什么。数据怎么不崩穿透、击穿、雪崩、大 key、热 key决定了流量峰值时系统还在不在。数据怎么不出错双写一致性、分布式锁、并发扣减决定了业务结果对不对。面试官的任何一道 Redis 题基本都能归到这四类里。比如Redis 为什么快表面上是性能题实际上考的是数据放哪内存 高效编码和数据怎么不崩单线程避免锁竞争 IO 多路复用的组合。你答的时候先定位到主线上的位置再往下展开逻辑会非常清楚也不容易漏点。举一个具体的例子。Redis 6.0 引入多线程是不是就不再是单线程了这个问题的标准答法是命令执行仍然是单线程多线程只用在网络 IO 的读写和协议解析上。但如果你能把主线串起来还能补一句之所以命令执行不敢多线程是因为绝大部分操作本身就在内存里完成瓶颈不在 CPU 而在网络往返多线程处理 IO 才是收益最大的地方真要做 CPU 密集的计算应该用 Lua 之外的方式挪到客户端或者用 Redis 的模块。这样一补面试官会觉得你不只是看过文章而是想过设计者的思路。主线之外的加分项是量化。Redis 的内存开销、单实例的 QPS 上限、过期时间的精度、主从复制的延迟这些数字你脑子里得有大概量级。不用精确到个位但十万级 QPS毫秒级延迟秒级复制延迟这种量级判断要有否则你的方案听起来就很虚。2. 数据类型背后的编码选择是区分度最高的一档题2.1 SDSString 类型的地基很多人不知道 Redis 的 String 底层用的不是 C 字符串而是自己实现的 SDSSimple Dynamic String。这个点一旦被问到答不上来就挺尴尬。SDS 相比 C 字符串解决了三个问题。第一是取长度的时间复杂度。C 字符串要用 strlen 遍历到 \0 才知道长度是 O(n)SDS 结构体里直接存了一个 len 字段取长度是 O(1)。这个差别在 STRLEN 命令上很直观也避免了每次追加都要重新计算长度。第二是二进制安全。C 字符串以 \0 作为结束标志所以没法存图片、序列化对象这种中间带 \0 的数据SDS 用 len 字段标识长度内容里出现什么都无所谓。这就是为什么你能把一张小图片 Base64 之后塞进 Redis也能直接存 Protobuf 序列化后的字节流。第三是内存分配策略。SDS 扩容时不是要多少给多少而是小于 1MB 时按两倍扩容大于等于 1MB 时每次多给 1MB。缩容时也不会立刻释放而是保留下来等下次用。这套空间换时间的策略是为了减少内存重分配的次数——毕竟 Redis 是单线程的一次内存拷贝阻塞的就是所有请求。顺带记一个数字append操作触发的重分配次数用这种方式能从 N 次降到最多 N 次但均摊更低实际压测里高频 append 场景的吞吐能提升一个明显档位。这个细节答出来基本就说明你真读过源码或者至少读过靠谱的解析。2.2 五种集合类型的编码阈值与切换时机String 之外List、Hash、Set、ZSet 都有一个小数据量用一种紧凑编码、超阈值自动切换成另一种的机制。这套机制是面试的重灾区因为它同时考了你知道阈值和你知道为什么要设阈值。类型小数据量编码大数据量编码切换阈值默认Stringint / embstrraw纯数字且 ≤ 20 位用 int≤ 44 字节用 embstrListlistpack7.0 前为 ziplistquicklist单个元素 64 字节切换节点Hashlistpack7.0 前为 ziplisthashtable字段数 128 或任一 value 64 字节Setintset / listpackhashtable全为整数且元素数 ≤ 512 用 intsetZSetlistpackskiplist dict元素数 128 或任一元素 64 字节阈值对应的配置项是hash-max-listpack-entries、hash-max-listpack-value、zset-max-listpack-entries、set-max-intset-entries这几个默认值分别是 128、64、128、512。这些值是可以改的但改之前要想清楚调大能省内存紧凑编码省指针开销代价是插入删除时可能触发连锁更新或者整体重编码单次操作变慢。为什么设阈值核心原因是紧凑编码ziplist / listpack的读写是 O(n) 的元素多了就慢而 hashtable 和 skiplist 的查找接近 O(1) 和 O(log n)但每个元素都要额外存指针内存开销大。所以小数据量用紧凑的、大数据量用查找快的这是一笔很划算的买卖。ZSet 的编码尤其值得说。它在元素数或元素长度超阈值之后会同时使用 skiplist 和 dict 两个结构dict 存 member 到 score 的映射用来做 O(1) 的 ZSCOREskiplist 按 score 排序用来做范围查询和排名。同一份数据存两遍看起来浪费但换来的是两类操作都快这就是典型的空间换时间。而且两个结构之间共享 member 和 score 对象实际额外开销主要是指针。2.3 跳表为什么能顶掉平衡树ZSet 为什么用跳表不用红黑树是经典题标准答案有三条范围查询友好、实现更简单、并发场景更容易做无锁化。范围查询这条最好理解跳表最底层是一个有序链表找到起点之后沿着链表往后走就行红黑树做范围查询需要中序遍历得借助栈或者线索化写起来和调起来都麻烦。而 Redis 的 ZRANGEBYSCORE、ZREVRANGE 这类命令恰恰是高频操作。实现复杂度这条也很实在。跳表插入删除只要维护多层索引代码量大概是平衡树的三分之一红黑树的旋转、各种 case 分支即使写完了也不好维护。Redis 作者在源码注释里也提过跳表在实现难度和调试难度上更友好。至于并发虽然 Redis 本身是单线程执行命令的但跳表这种结构在需要并发控制的场景比如其他存储引擎里更容易做 CAS 式的无锁实现算是一个额外优势。跳表的期望时间复杂度是 O(log n)空间复杂度是 O(n)。层数是怎么定的Redis 里用一个随机函数每次插入时以 1/4 的概率往上加一层最大层数 32 层。为什么是 1/4因为这样每个节点的平均层数是 1/(1-1/4) ≈ 1.33指针开销最小同时查找效率还有保障。这个概率值答出来是很强的加分点。2.4 编码题怎么答成加分题光背阈值容易显得是死记硬背。我一般会在答完之后补一个实操层面的例子我们线上有个 Hash 存用户标签字段数大概在 80 到 150 之间波动结果是——字段数没超 128 的时候用 listpack一旦超过就整体转成 hashtable内存直接涨了将近一倍。后来我们在业务层做了分片把一个大 Hash 拆成两个固定大小的 Hash内存降下来了而且因为没再触发过编码切换延迟也更稳定。这个例子的价值在于它说明阈值不只是知识是会影响线上的东西。面试官听到这种回答基本上会认为你至少碰过真实数据。类似的还有 Set如果本来是整数集合你插入了一个字符串元素intset 会立刻转成 hashtable而且再也转不回来。这个不可逆的特性很容易被忽略在面试里提一句效果很好。另一个容易被问的是redis-cli里怎么确认一个 key 用的什么编码命令是OBJECT ENCODING key。你可以现场说线上我一般用redis-cli --bigkeys先扫一遍再对有疑问的 key 用 OBJECT ENCODING 确认。这句话的信息密度很高既显示了你知道排查工具又暗示你处理过线上问题。3. 穿透、击穿、雪崩三个名字像的题本质完全不同3.1 一句话分清三者这三个词因为都带缓字特别容易混。我用一句话区分穿透是查的数据压根不存在击穿是一个热点 key 过期了雪崩是一大批 key 同时过期或者 Redis 整体挂了。从影响面上看穿透的影响取决于恶意请求量击穿的影响是单点 DB 压力陡增雪崩的影响是整个系统雪崩式下跌。从解法上看穿透靠拦住不存在的请求击穿靠控制重建缓存的并发雪崩靠让过期时间分散 提高可用性。三者的解法几乎没有重叠混着答会显得很外行。面试时如果面试官只问了其中一个我建议主动把三者一起对比着说一遍然后指出它们的共同点是最终都会把压力打到数据库。这种主动展开的回答方式能把一道小题变成一次完整的表达机会。3.2 缓存穿透布隆过滤器和空值缓存怎么选缓存穿透的典型场景是恶意攻击拿一个不存在的用户 ID 疯狂请求缓存永远不命中每次都打到数据库。如果数据库扛不住就出事了。方案一空值缓存。查不到数据时往缓存里写一个空值或者特殊标记过期时间设短一点比如 60 秒。优点是实现极简单几行代码。缺点是如果攻击者每次用不同的随机 ID你缓存的全是空值内存会被打爆防不住每次都不一样的攻击。方案二布隆过滤器。在缓存前面加一层布隆过滤器把所有可能存在的 key 提前放进去。请求进来先问过滤器过滤器说不存在直接返回不查缓存也不查库。布隆过滤器的特性必须说清楚判断不存在是准确的判断存在有误判可能。因为它是用多个哈希函数把元素映射到一个位数组上不同元素的位可能重叠。误判率可以通过位数组大小和哈希函数数量来控制公式是(1 - e^(-kn/m))^k其中 m 是位数组长度、n 是元素数量、k 是哈希函数个数。工程上一般取 k 使得误判率最低经验值是 k ≈ 0.7 × (m/n)。布隆过滤器还有个坑不能删除元素。因为一个位可能被多个元素共用删掉一个元素对应的位会误伤其他元素。要支持删除得用计数布隆过滤器每个位换成计数器但内存开销涨几倍。所以实际项目里一般用定时任务重建整个过滤器或者用 Redis 的BF.ADDRedisBloom 模块配合布隆过滤器的变体。我的选择是两者结合布隆过滤器挡掉绝大部分不存在的 key剩下少量误判的请求再用空值缓存兜一下。另外请求入口一定要做基础校验比如 ID 必须是数字、长度必须在范围内能挡掉一大波扫描式攻击。3.3 缓存击穿互斥重建还是逻辑过期击穿针对的是热点 key。比如首页的配置、秒杀商品的库存这些 key 访问量极高一旦过期瞬间会有大量请求同时去查数据库然后重建缓存数据库可能直接被压垮。方案一互斥锁重建。缓存失效时只有一个线程能拿到锁去查数据库并回填缓存其他线程短暂等待或者直接返回旧值。实现上可以用 Redis 的SET key value NX EX 3做分布式锁。优点是保证同一时间只有一个请求打库数据实时性高。缺点是有等待高峰期可能堆积请求锁本身的获取失败也要有降级策略。方案二逻辑过期。缓存里的 value 不设置真实 TTL而是在 value 内部带一个 expireTime 字段。读到数据后判断是否逻辑过期如果过期了开一个异步线程去重建缓存当前请求直接返回旧值。优点是全程无阻塞吞吐高。缺点是要接受一段时间的数据不一致而且异步重建的线程池要单独隔离别和其他业务抢线程。方案三热点 key 永不过期。后台用一个定时任务定期更新前台永远读到值。最简单粗暴但只适合数据变化有规律的场景且要注意更新失败时的告警。我实际用的是组合核心配置类数据用逻辑过期能接受秒级不一致库存这类强实时的用互斥锁宁可等也不能超卖。这个选择逻辑讲出来比单纯罗列方案要好得多。3.4 缓存雪崩过期时间打散只是第一步雪崩分两种一种是同一时刻大量 key 集中过期另一种是 Redis 实例整体不可用。针对第一种最直接的办法是给过期时间加随机量。比如原本统一设 30 分钟改成 30 分钟 随机 0 到 5 分钟。这样过期时间就被打散了。更进一步如果是活动类场景可以做成基础时间 业务维度的扰动比如按 key 的哈希值取模来偏移。针对第二种能做的事情更多多级缓存。本地缓存Caffeine Redis 数据库。本地缓存抗住最热的那部分流量Redis 挂了还有本地兜底。代价是本地缓存有一致性问题一般设很短的过期时间比如 1 到 3 秒。熔断降级。用 Sentinel 或者 Hystrix 对数据库访问做限流Redis 不可用时直接返回兜底数据或者友好提示而不是让请求全部涌向数据库。高可用架构。哨兵或者 Cluster 保证单节点故障能自动切换这是基础设施层面的事。持久化 快速恢复。如果 Redis 真的重启AOF 或者 RDB 能让它尽快恢复数据但这个恢复时间往往是被低估的几十 GB 的数据恢复可能要好几分钟。这里有一个容易被忽略的点过期时间打散只是缓解不是根治。真正的根治方案是无论如何数据库都不能被压垮所以限流和降级是必须的。面试时把这一层说出来会显得你的方案是完整的而不是只有前半截。4. 缓存与数据库的一致性先动谁这件事得说清楚理由4.1 四种双写顺序的失效场景缓存和数据库双写理论上就四种组合每一种都有问题操作顺序典型问题先更新数据库再更新缓存并发下缓存可能被旧值覆盖且缓存可能被频繁无效更新先删缓存再更新数据库并发读会把旧值重新读回缓存导致长期不一致先更新数据库再删缓存概率最低但理论上仍有一致性窗口先删缓存再更新数据库再删缓存即延迟双删能大幅降低不一致概率重点解释一下为什么先更新数据库再删缓存是最推荐的。假设两个请求并发A 更新数据库、B 查询。如果 B 在 A 更新之前读到了旧值然后 A 更新完数据库、删掉缓存B 再把旧值写回缓存——这时候缓存里就是旧值了。要发生这种情况需要 B 的读库 写缓存跨越了 A 的整个写库 删缓存过程而且 B 的写缓存发生在 A 的删缓存之后。这个时间窗口非常窄概率极低。而先删缓存再更新数据库的问题窗口就大多了B 在 A 删缓存之后、更新数据库之前读库读到的是旧值然后写进缓存等 A 更新完数据库缓存里却还是旧值而且这个旧值会一直留到下次过期。这个窗口等于整个数据库更新耗时可能几十毫秒甚至更久风险明显更高。至于为什么不更新缓存而是删缓存理由是更新缓存需要算出新值这个计算可能很复杂比如要关联多张表而且如果这段时间内缓存被更新了多次每次计算都是浪费最后可能算出来的还是错的。删缓存则把什么时候重建交给读请求简单且不易错。4.2 延迟双删到底在延迟什么延迟双删的流程是删缓存 → 更新数据库 → 睡一小段时间 → 再删一次缓存。第二次删除是为了清掉在数据库更新期间被读请求写回的旧值。关键是这个一小段时间要睡多久。理论上它应该大于一次读请求从查库到写缓存的耗时也就是读请求中最慢的那一次。实际项目里一般设 300 到 500 毫秒但这个值很难精确只能靠压测估。更稳妥的做法是异步延迟比如把第二次删除丢到一个延迟队列里可以用 MQ 的延迟消息或者基于 Redis 的 ZSet 做一个简单的延迟任务等 500 毫秒后再执行。这样业务线程不用真的睡着不会拖慢接口响应。延迟双删的局限也要说清楚它只能降低不一致的概率不能保证强一致。而且如果延迟任务执行失败比如服务重启第二次删除就丢了缓存里会一直留着旧值。所以延迟任务最好有重试和持久化。4.3 基于 binlog 的最终一致方案如果要做得更彻底可以订阅数据库的 binlog在数据变更事件发生之后异步去删缓存。常见的做法是 Canal 监听 MySQL 的 binlog解析出变更的数据行然后通过 MQ 发给缓存更新服务。这个方案的好处是把缓存失效的逻辑从业务代码里彻底剥离了业务代码只管写数据库不用关心缓存。而且它是基于数据库的最终变更时序上是准确的不会出现先删后写回的问题。缺点也很明显引入的组件多Canal、MQ、消费者服务链路长排查问题麻烦而且它仍然是最终一致延迟可能到几十毫秒甚至秒级。还有一点值得注意binlog 方案一般也是删除缓存而不是更新缓存因为消费端拿到的是一整行数据重建缓存需要的可能不止这一行直接删掉让读请求自己重建更稳。4.4 什么时候该放弃一致性方案这是一个很多人不敢回答的问题不是所有场景都需要保证一致性。我的判断标准是这样的。如果业务能接受秒级甚至分钟级的不一致比如商品详情页的浏览量、文章的点赞数那最简单的过期时间 定期更新就够了不需要任何复杂的双写逻辑。如果业务能接受短暂不一致但最终必须一致比如用户昵称、商品标题延迟双删或者 binlog 方案都可以。如果业务要求强一致比如账户余额、库存扣减那就不要用缓存做主数据源。可以缓存只读、扣减走数据库加行锁或者用 Redis 做预扣加异步落库的模式这个下一节展开。硬要在缓存上做强一致付出的是性能代价和极高的复杂度通常不划算。面试时把这三档说清楚比一上来就说我用延迟双删解决了一致性问题要专业得多因为它体现了你在做方案选型而不是套模板。5. 分布式锁从 SET NX 的三个坑到看门狗续期5.1 SETNX 单独用为什么一定会出事SETNX这个命令本身没问题问题在于只用它一个。三个坑按顺序出现坑一锁不会释放。如果拿到锁的进程崩溃了没有删除 key其他进程永远拿不到锁。所以必须加过期时间。但SETNX加EXPIRE是两条命令中间可能失败所以要用一条原子命令SET key value NX EX seconds。这个变化是面试必问的。坑二误删别人的锁。加了过期时间之后如果业务执行时间超过了锁的过期时间锁会自动释放另一个进程拿到锁开始执行。这时候第一个进程执行完了去删锁删除的是第二个进程的锁。等第三个进程进来会发现锁不见了两个人同时执行。解法是 value 存一个唯一标识比如 UUID 加线程 ID删除前先比较 value 是不是自己的是才删。坑三比较和删除不是原子的。用GET判断再加DEL这两步之间锁可能刚好过期并被别人拿到又误删了。所以必须用 Lua 脚本把比较和删除打包成一个原子操作。这三个坑是递进关系面试官特别喜欢顺着问。你要是能一口气把三个坑和对应解法都说出来这道题基本满分。5.2 一段能直接抄的加解锁代码加锁public boolean tryLock(String key, String requestId, long expireSeconds) { Boolean ok redisTemplate.opsForValue() .setIfAbsent(key, requestId, expireSeconds, TimeUnit.SECONDS); return Boolean.TRUE.equals(ok); }解锁的 Lua 脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endJava 侧执行String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(script, Long.class), Collections.singletonList(key), requestId);这里有个细节要注意requestId建议用UUID.randomUUID().toString() : Thread.currentThread().getId()这样即使同一个进程里有多个线程也能区分开。如果只用 UUID同一线程重入时会被自己挡住这算不算 bug 取决于你的设计目标。另外setIfAbsent在 Spring Data Redis 里就是SET key value NX PX ms的封装是原子的可以放心用。但要注意序列化器的问题——如果 value 用了 JDK 序列化Lua 脚本里比较的是反序列化后的字符串可能对不上。统一用StringRedisSerializer能避免这类坑。5.3 Redisson 的可重入和自动续期是怎么实现的自己手写上面那套能解决大部分问题但有两个需求满足不了可重入和自动续期。Redisson 这两点做得比较完整。可重入的实现是靠 Hash 结构。锁的 key 对应一个 HashHash 的 field 是客户端 ID 加线程 IDvalue 是重入次数。同一个线程再次加锁时field 已经存在就把 value 加一解锁时减一减到零才真正删 key。这样同一线程可以多次获取同一把锁。看门狗watchdog是自动续期机制。默认情况下锁的过期时间是 30 秒Redisson 会启动一个后台任务每 10 秒也就是过期时间的三分之一检查一次如果业务还没执行完就把锁的过期时间重新设为 30 秒。这样就不会出现业务没执行完锁就过期的情况。这里有一个非常重要的细节也是面试高频考点只有在你调用lock()不指定过期时间的时候看门狗才会生效如果你调用了lock(10, TimeUnit.SECONDS)指定了 leaseTime看门狗就不启动了锁到 10 秒就自动释放。很多人被这个问题坑过——以为指定了时间还有续期保障结果业务执行了 15 秒锁在第 10 秒就没了。Redisson 看门狗为什么用 30 秒和 10 秒因为续期间隔要小于过期时间而且要留出网络抖动和执行时间的余量。三分之一这个比例是经验值既不会太频繁地增加 Redis 压力也不会因为一次续期失败就过期。5.4 Redlock 的争论与我的选择Redlock 是 Redis 作者提出的多节点锁算法向 N 个独立的 Redis 节点一般 5 个依次请求加锁如果在超过半数节点上成功并且总耗时小于锁的有效时间就认为加锁成功。这个算法的争议点主要是两个一是它依赖各个节点的时钟不能大幅漂移如果某个节点发生时钟跳变可能导致锁提前过期二是它假设各个节点是相互独立的但现实里它们可能共享同一套运维链路同时挂掉。有个分布式系统领域的学者专门写文章质疑过 Redlock 的安全性Redis 作者也做过回应两边各有道理。我的实际选择是如果业务只是防止重复执行、允许极小概率的失效用单实例 Redis 加锁就够了配合合理的过期时间和业务侧的幂等校验。如果需要更强的保证我倾向于用有共识协议的组件来做锁或者干脆用数据库的唯一索引、状态机来做幂等而不是把宝押在分布式锁上。这条建议其实比技术细节更重要锁的正确性不只取决于锁本身还取决于业务是否幂等。如果业务本身是幂等的锁失效一次也不会出大问题如果业务不幂等再强的锁也可能在极端情况下出问题。面试时说出这一层面试官会觉得你有工程判断力。6. 持久化、高可用与内存治理稳定性问题的答法6.1 RDB 和 AOF 的取舍不是二选一RDB 是快照把某一时刻的全量数据写成一个二进制文件。优点是文件紧凑、恢复速度快、对主进程影响小靠 fork 出的子进程写。缺点是两次快照之间的数据会丢而且 fork 的时候如果数据量大会短暂阻塞主进程——数据量到几十 GB 时fork 的耗时可能到几百毫秒甚至秒级。AOF 是追加日志把每条写命令记下来。优点是丢数据少appendfsync always模式下基本不丢但性能差everysec模式最多丢一秒。缺点是文件体积大恢复时要重放所有命令比 RDB 慢很多而且 AOF 重写也会消耗资源。实际生产里我一般这样配主节点开启 AOFeverysec加上周期性 RDB从节点只用 RDB。理由是主节点要尽量少丢数据从节点主要负责备份和读扩展用 RDB 恢复更快。如果数据量特别大又对丢失不敏感可以只用 RDB。还有一个点fork用的是操作系统的写时复制COW机制。fork 出来的子进程和父进程共享内存页只有当父进程写某一页时才会复制。这意味着如果在 RDB 期间有大量写入内存占用可能接近翻倍。所以配置maxmemory的时候要预留出这部分空间不然可能触发 OOM。6.2 主从、哨兵、Cluster 各自的适用边界主从复制是最基础的。从节点通过 PSYNC 命令和主节点同步支持全量同步和增量同步。增量同步靠的是 repl_backlog 这个环形缓冲区如果从节点断开的时间太长缓冲区被覆盖了就只能做全量同步。全量同步的流程大致是从节点发送 PSYNC主节点 fork 出子进程生成 RDB同时把这段时间的写命令缓存起来RDB 传完之后再把这部分命令发给从节点。这里要注意如果 RDB 传输时间长、缓冲区又小主节点可能因为缓冲区溢出而断开连接形成全量同步失败 - 重试 - 再失败的循环。所以repl-backlog-size和client-output-buffer-limit这两个参数要调大。哨兵在主从的基础上加了自动故障转移。哨兵集群一般至少三个节点通过主观下线和客观下线判断主节点是否真的挂了然后从从节点里选一个升级成主节点。选主规则大致是先看优先级replica-priority再看复制偏移量越新越好最后看 runid。Cluster是分片方案把整个 keyspace 分成 16384 个槽每个节点负责一部分。路由规则是CRC16(key) mod 16384。它同时具备分片和高可用的能力但限制不少不支持多 key 操作跨槽除非用 hash tag 让它们落到同一个槽、没有选主的强一致保证、客户端需要感知集群拓扑。选型的判断数据量小、只是想读写分离和故障转移用主从加哨兵就够了数据量大到一个实例放不下比如超过 20GB 或者 QPS 超过单实例上限再用 Cluster。Cluster 的运维复杂度比哨兵高不少扩容缩容时槽的迁移会影响性能没到必要的时候别上。6.3 过期删除与八种内存淘汰策略Redis 的过期删除用的是惰性删除 定期删除的组合。惰性删除是访问某个 key 时才检查它是否过期过期就删。这样处理及时但如果某个过期 key 一直没人访问就会一直占着内存。定期删除是每秒执行十次默认hz是 10每次随机抽取一定数量的设置了过期时间的 key删掉其中过期的如果过期比例超过 25% 就再抽一轮。注意是随机抽取理论上可能有 key 一直抽不到所以这两种策略是互补的。当内存达到maxmemory之后就要靠淘汰策略了。八种策略可以分为三组分组策略说明不淘汰noeviction内存满了直接报错写命令失败全键空间allkeys-lru / allkeys-lfu / allkeys-random在所有 key 里按 LRU、LFU 或随机淘汰仅过期键volatile-lru / volatile-lfu / volatile-random / volatile-ttl只在设了过期时间的 key 里淘汰LRU 是最近最少使用LFU 是最不经常使用。LRU 的问题是它只看最后一次访问时间如果一个 key 很久以前被访问过一次之后就再没用过但恰好最近被访问了一次它就会被保住。LFU 用计数器统计访问频率更适合有明显热点的场景。Redis 的 LFU 实现里还有衰减机制防止老热点一直霸占内存。Redis 的 LRU 也不是严格的 LRU而是近似 LRU每个对象里存一个 24 位的时钟戳或者 LFU 的计数器淘汰时随机采样一批 key从中挑最旧的那个删掉。采样数量由maxmemory-samples控制默认 5调到 10 会更接近准确 LRU 但更耗 CPU。选策略的经验如果所有数据都设了过期时间用allkeys-lru最省心如果缓存和持久数据混在同一个实例里不建议但现实里常见用volatile-lru避免把持久数据淘汰掉如果有明显的冷热分层且热点固定用allkeys-lfu。6.4 大 key 和热 key 的排查手法大 key 指单个 key 的 value 特别大String 超过 10KB或者集合类元素超过 5000 个。危害是操作它耗时长会阻塞单线程删除它可能导致延迟抖动迁移的时候会卡住内存分布不均。排查手段redis-cli --bigkeys扫描整个键空间统计每种类型的最大 key。注意它会全量扫描在从节点上执行更安全。MEMORY USAGE key看单个 key 占多少字节。redis-cli --scan --pattern xx:*配合STRLEN、LLEN、HLEN等命令批量过滤。处理大 key 的思路是拆分把一个大 Hash 按业务维度拆成多个小 Hash把一个大 List 按时间分片或者用HSCAN、SSCAN渐进式遍历代替HGETALL、SMEMBERS。删除大 key 一定要用UNLINK异步删除不要用DEL。热 key 指访问量特别集中的 key危害是单个节点被打满集群的负载均衡失效。排查手段redis-cli --hotkeys需要先开启 LFU 策略才能用。客户端埋点统计在本地做采样成本可控且能定位到业务。代理层比如自己写的 proxy统计。MONITOR命令能看到所有命令但生产环境慎用它会显著降低性能。处理热 key 的思路是本地缓存或者复制多份。比如一个热点商品可以在 key 后面加随机后缀让它分散到多个节点读取时随机取一个或者干脆用本地缓存Caffeine放一份设置很短的过期时间。7. 把 Redis 答进项目里四个场景的落地细节7.1 秒杀扣库存的 Lua 脚本与异步落库秒杀的核心问题是高并发下的超卖和性能。如果先用GET查库存再DECR两步之间会有并发问题所以必须把判断和扣减放在一个原子操作里也就是 Lua 脚本-- KEYS[1] 库存 key -- 返回值-1 表示 key 不存在0 表示库存不足大于 0 表示扣减后的剩余库存 local stock redis.call(GET, KEYS[1]) if not stock then return -1 end if tonumber(stock) 0 then return 0 end return redis.call(DECR, KEYS[1])Lua 脚本在 Redis 里是原子执行的中间不会被其他命令打断。这就保证了不会超卖。扣减成功之后不要把订单直接写数据库那样数据库还是会被打满而是把消息扔进 MQ让消费端慢慢落库。消费端要做幂等一般用用户 ID 加商品 ID作为唯一键插库时靠唯一索引去重。库存预热也很关键。活动开始前把库存从数据库加载到 Redis避免活动一开始就去查库。同时要给库存 key 设置一个合理的过期时间活动结束后自动清理。这里有个坑要提醒Lua 脚本里不要写随机值否则主从复制和 AOF 重放的时候可能产生不同的结果。Redis 5.0 之前脚本里的随机写会被直接拒绝5.0 之后提供了redis.replicate_commands()让脚本按命令传播但能避免还是避免。另外Lua 脚本的执行时间也要控制。Redis 有个参数lua-time-limit默认 5 秒超时之后其他客户端发来的命令会返回 BUSY但脚本不会自动终止。所以脚本逻辑一定要简单绝不要在脚本里做复杂计算或者大范围遍历。7.2 排行榜与签到排行榜是 ZSet 的经典应用。加分用ZINCRBY key score member取前 N 名用ZREVRANGE key 0 N-1 WITHSCORES查某个人的排名用ZREVRANK注意是从小到大排行榜一般要反过来算。用户量大的时候如果所有用户放在一个 ZSet 里这个 ZSet 会变得非常大操作效率下降。可以做分层只把前 1000 名放进 Redis剩下的用数据库或者离线计算。或者按业务维度分片比如按地区、按班级各建一个 ZSet。如果榜单需要按日、周、月分别统计可以用多份 ZSet 加不同的过期时间或者用 key 里带时间戳的方式比如rank:20260101、rank:202601配合定时任务合并。签到功能用 Bitmap 特别合适。一个用户一个 key或者一个月一个 key每天一个 bitSETBIT sign:1001:202601 5 1 # 用户 1001 在 2026 年 1 月 6 日签到 BITCOUNT sign:1001:202601 # 本月签到天数 GETBIT sign:1001:202601 5 # 查某天是否签到Bitmap 的优势是极省内存。一个 30 天的月份只需要 30 个 bit差不多 4 个字节。一千万用户一个月的签到数据也就 40MB 左右。如果需要连续签到的天数可以用BITFIELD做位域操作或者把每个月的 bitmap 拿出来在应用层做位运算。7.3 接口限流的三种实现限流是 Redis 的另一个高频场景常见有三种做法。固定窗口计数。INCR key加EXPIRE key 1超过阈值就拒绝。实现简单但有个临界问题如果阈值是 100 次每分钟用户在 0:59 发了 100 次1:01 又发了 100 次两个窗口各不超限但实际两秒内发了 200 次。滑动窗口。用 ZSet 存每次请求的时间戳每次请求前先ZREMRANGEBYSCORE删掉窗口外的记录再ZCARD统计数量。精度高但每次请求要写一条记录内存和性能开销都大适合低 QPS 的接口。令牌桶。用 Lua 脚本实现记录上次填充时间和当前令牌数每次请求按时间差补充令牌够就扣一个放行不够就拒绝。这个方案能应对突发流量桶里攒的令牌可以一次用掉而且只需要存两个字段内存开销小。我一般推荐用这个。-- KEYS[1] 限流 key -- ARGV[1] 速率每秒生成令牌数ARGV[2] 桶容量 ARGV[3] 当前时间戳秒ARGV[4] 请求令牌数 local rate tonumber(ARGV[1]) local capacity tonumber(ARGV[2]) local now tonumber(ARGV[3]) local requested tonumber(ARGV[4]) local bucket redis.call(HMGET, KEYS[1], tokens, ts) local tokens tonumber(bucket[1]) or capacity local ts tonumber(bucket[2]) or now local delta math.max(0, now - ts) tokens math.min(capacity, tokens delta * rate) local allowed tokens requested if allowed then tokens tokens - requested end redis.call(HMSET, KEYS[1], tokens, tokens, ts, now) redis.call(EXPIRE, KEYS[1], math.ceil(capacity / rate) * 2) return allowed and 1 or 0注意now是从应用层传进去的不要用redis.call(TIME)因为那会导致主从复制时的非确定性。7.4 我在生产环境踩过的几个坑第一个坑KEYS *直接搞挂了线上。早期不懂事在几百万 key 的实例上执行了KEYS user:*Redis 直接卡住好几秒所有请求超时。正确做法是用SCAN做游标遍历每次返回一小批虽然不保证全量一致遍历期间的新增删除可能漏掉或重复但不会阻塞。HGETALL、SMEMBERS、LRANGE 0 -1这类命令也是同理要换成对应的HSCAN、SSCAN。第二个坑RedisTemplate 的序列化把 key 搞成了乱码。Spring 的RedisTemplate默认用JdkSerializationRedisSerializer存进去的 key 前面会带一串不可见的字节用redis-cli看是转义字符别的语言或者别的服务读不到。后来统一换成StringRedisSerializer做 key 和 hash key 的序列化value 用 Jackson 或 FastJson 做 JSON 序列化跨服务就一致了。第三个坑连接池配置太小导致超时。默认的 Lettuce 或者 Jedis 连接池最大连接数可能只有 8 或者 16。QPS 上去之后请求全在排队等连接日志里全是Timeout waiting for idle object。后来按峰值 QPS × 平均耗时估算配合压测调到合适值并且设置了合理的maxWait避免请求无限等待。第四个坑缓存 key 没有统一规范。早期不同模块各写各的有user_1001也有user:1001也有USER-1001排查问题时很难按前缀统计也很难做批量清理。后来定了规范业务线:模块:实体:标识比如order:detail:1001。这样既能用SCAN按前缀定位也方便做容量规划和监控。第五个坑把 Redis 当数据库用。有过一段时间一些配置数据只存 Redis没落库。结果某次实例故障加上持久化配置不当数据丢了只能人工补。这个教训是Redis 可以当缓存也可以当高性能的结构化存储但前提是数据要有可重建的来源。如果数据在别处没有副本那 Redis 就必须有可靠的持久化并且要有备份策略不能靠运气。最后再分享一个我一直在用的习惯给每一个接入 Redis 的业务模块都在监控上挂几个基础指标——命中率、平均耗时、慢查询数量、连接池使用率、内存增长曲线。这四个指标里任何一个异常基本都能提前发现问题。命中率突然从 95% 掉到 60%多半是有大批 key 集中过期或者被误删慢查询突然增多可能是有人写了KEYS或者产生了大 key内存曲线斜率变陡可能是缓存 key 没有过期时间在无限增长。这些经验比任何面试题的答案都值钱因为它们是从事故里换来的。
返回列表