news 2026/9/30 15:45:58

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 SDS:String 类型的地基

很多人不知道 Redis 的 String 底层用的不是 C 字符串,而是自己实现的 SDS(Simple 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 字节用 embstr
Listlistpack(7.0 前为 ziplist)quicklist单个元素 > 64 字节切换节点
Hashlistpack(7.0 前为 ziplist)hashtable字段数 > 128 或任一 value > 64 字节
Setintset / listpackhashtable全为整数且元素数 ≤ 512 用 intset
ZSetlistpackskiplist + 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) 的 ZSCORE;skiplist 按 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.ADD(RedisBloom 模块)配合布隆过滤器的变体。

我的选择是两者结合:布隆过滤器挡掉绝大部分不存在的 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 end

Java 侧执行:

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 对应一个 Hash,Hash 的 field 是"客户端 ID 加线程 ID",value 是重入次数。同一个线程再次加锁时,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 重写也会消耗资源。

实际生产里我一般这样配:主节点开启 AOF(everysec)加上周期性 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,每天一个 bit:

SETBIT 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 没有过期时间在无限增长。这些经验比任何面试题的答案都值钱,因为它们是从事故里换来的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 15:45:48

成都有哪些比较专业的中考志愿规划平台?

家长用“专业”这个词&#xff0c;通常是指更信得过。但专业不是靠名字或者宣传语判断的&#xff0c;它有一些具体的、可以核对的表现。下面几条&#xff0c;可以用来衡量一个平台在专业上做到了哪一步。一、看它怎么解释冲稳保冲稳保是志愿规划里比较基础的框架&#xff0c;也…

作者头像 李华
网站建设 2026/9/30 15:45:10

Python报错:NoneType不支持item assignment,怎么排查?

1. 先搞懂这一行报错到底在说什么 如果你在后台日志里连续看到几行 TypeError: NoneType object does not support item assignment &#xff0c;同时又恰好是同一个数据接口在深夜崩掉&#xff0c;那你一定会理解我的感觉&#xff1a;崩溃本身不可怕&#xff0c;可怕的是日志…

作者头像 李华
网站建设 2026/9/30 15:45:05

Linux apt-get安装路径原理与dpkg-L定位指南

1. 理解“apt-get install 默认安装位置”这个提问背后的真正困惑 很多人第一次在 Ubuntu 或 Debian 系统上敲下 sudo apt-get install nginx &#xff0c;回车后看到一串滚动的日志&#xff0c;最后提示“Setting up nginx-core (1.18.0-6ubuntu14.4)...”&#xff0c;就以为…

作者头像 李华
网站建设 2026/9/30 15:44:45

软考高项选老师避坑指南:六大维度+全年备考路径

每年备考软考高项的人里&#xff0c;我见过太多不是挂在题目难&#xff0c;而是挂在选老师这一步。花了大几千报了班&#xff0c;听了几节课发现风格不合&#xff0c;换老师又舍不得沉没成本&#xff0c;硬着头皮跟完&#xff0c;结果案例和论文还是稀碎。这个现象在信息系统项…

作者头像 李华
网站建设 2026/9/30 15:44:09

Model-Optimizer:面向部署约束的模型性能工程方法论

1. 什么是Model-Optimizer&#xff1a;不是“一键加速”&#xff0c;而是模型生命周期里的精密调音师 “Model-Optimizer”这个词最近在技术社区里频繁出现&#xff0c;但很多人第一反应是——这又是个营销包装的黑盒工具&#xff1f;其实恰恰相反&#xff0c;它代表的是一套 …

作者头像 李华
网站建设 2026/9/30 15:40:00

CentOS宝塔部署Django项目:从零到上线全流程指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华