Redis(五):分布式锁、单线程模型与实战场景
Redis(五):分布式锁、单线程模型与实战场景
导语:收官篇讲「把 Redis 用对」。前半部分是分布式锁的完整演进——
SET NX EX的最小实现、看门狗续期、Redisson 可重入、RedLock 的争议与边界;后半部分是工程落地——单线程模型与阻塞点、Pipeline/事务/Lua 的区别、限流、消息与延迟队列,以及版本里程碑,共 17 题。
一、分布式锁
1. 如何用 Redis 实现分布式锁?
答: 一个正确的最小实现只有两步,但每一步都有讲究:
加锁:一条原子命令
# 必须 NX(不存在才写)+ 过期时间,且用一条命令提交
SET lock:order:1001 8f3c-uuid-thread42 NX PX 30000- 为什么必须是
SET ... NX PX一条命令:SETNX+EXPIRE两条命令之间有窗口,客户端在这期间宕机 → 锁没有过期时间 → 永不死锁; - 为什么 value 要唯一(
UUID + threadId):标识「这把锁是谁持有的」,解锁时要校验,否则可能删掉别人的锁; PX/EX必须带:防止客户端崩溃导致锁无法释放。
解锁:必须用 Lua 保证原子性
-- KEYS[1] = 锁名,ARGV[1] = 当前客户端的唯一标识
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0- 为什么不能「先
GET再DEL」:两条命令之间可能发生——锁刚好过期、被别人抢到、你才执行DEL,于是删掉了别人的锁。Lua 脚本在 Redis 中整体原子执行,从根本上消除这个窗口。
这套实现还差什么(面试必须主动补):
| 缺陷 | 说明 | 解决 |
|---|---|---|
| 不可重入 | 同一线程再次加锁会被自己挡住 | 用 Hash 记录「持有者 + 重入次数」(见第 4 题) |
| 业务超时锁提前释放 | 固定过期时间无法适配长事务 | 看门狗自动续期(见第 2 题) |
| 主从切换丢锁 | 异步复制下锁可能丢(见第 3 题) | RedLock 或换 ZK/etcd |
| 等待效率低 | 未获取到锁要自旋重试 | 用 Pub/Sub 订阅释放消息唤醒(Redisson 的做法) |
结论:
SET NX PX+ Lua 解锁是可用的基础版;生产建议直接用 Redisson,它把这些细节都封装了。
2. 锁为什么要设过期时间?固定过期时间够吗?
答: 过期时间是为了防死锁:客户端拿到锁之后崩溃/网络断开,如果不设过期时间,这把锁永远不释放,所有请求都被卡死。
但固定过期时间会引入新问题:
① 线程 A 获取锁,过期时间 30s
② A 的业务执行了 40s(慢 SQL、GC、调用下游超时)
③ 30s 时锁自动过期,线程 B 获取到同一把锁
④ 此时 A 和 B 同时认为自己持有锁 -> 互斥失效,可能产生超卖、重复扣款解决:看门狗(Watch Dog)自动续期
Redisson 的做法:
- 获取锁成功后启动一个后台定时任务;
- 每隔
lockWatchdogTimeout / 3检查一次,如果业务还在执行(客户端存活),就把锁的过期时间重置为lockWatchdogTimeout; lockWatchdogTimeout默认 30 秒,即每 10 秒续期一次;- 业务执行完调用
unlock()→ 取消定时任务 + 释放锁; - 客户端宕机 → 定时任务随之消失 → 锁在
lockWatchdogTimeout后自然过期,不会死锁。
一个极易踩的坑(高频追问):
// ✅ 不指定 leaseTime:启用看门狗,自动续期
redissonLock.lock();
// ❌ 指定了 leaseTime:看门狗【不会】生效,到期就释放(即使业务没跑完)
redissonLock.lock(10, TimeUnit.SECONDS);
// ✅ 推荐写法:用 tryLock 带等待时间,但仍不指定 leaseTime
boolean got = redissonLock.tryLock(3, TimeUnit.SECONDS);所以「固定过期时间够不够」的答案是:不够——除非你能保证「锁的过期时间 > 业务最大执行时间」,而这在生产中几乎无法保证(GC、下游抖动都会打破假设)。看门狗是必须的。
3. Redis 主节点宕机,分布式锁会丢吗?
答: 会丢。这是 Redis 分布式锁最本质的缺陷,根因是主从复制是异步的:
① 客户端 A 向主节点加锁成功(锁在主的本地内存里)
② 主节点还没把这条命令同步给从节点,就宕机了
③ 哨兵/集群把从节点提升为新主——新主上没有这把锁
④ 客户端 B 向新主加锁,同样成功
结果:A 和 B 同时持锁,互斥彻底失效应对方案(按代价递增):
| 方案 | 说明 | 代价 |
|---|---|---|
| 单实例部署(不挂从库) | 没有「主从切换」就没有这个窗口;代价是可用性下降 | 可用性换正确性 |
| RedLock(多节点多数派) | 向 N 个独立节点申请,多数成功才算持有 | 运维复杂、仍有争议(见第 5 题) |
WAIT 命令 | 加锁后用 WAIT 1 <timeout> 等待至少 1 个副本确认 | 增加延迟,且不是强一致保证 |
| 换 ZK / etcd | 基于共识(ZAB/Raft),锁的正确性有强保证 | 引入新组件、性能低于 Redis |
| 业务幂等兜底(必做) | 关键操作(扣款、下单)用唯一键/状态机保证重复执行无副作用 | 无额外成本,最实用 |
面试答法:先明确「会丢,因为异步复制」,再给「业务幂等兜底 + RedLock/ZK」的组合。并主动说明:Redis 锁的正确打开方式是「防重复劳动(效率锁)」,而不是「保证绝对互斥(正确性锁)」——只有少数需要绝对互斥的场景才值得上 ZK/etcd。
4. Redisson 的分布式锁有什么优势?
答: Redisson 把「自己实现分布式锁」的所有坑都封好了,核心有五点:
1)加锁 + 看门狗自动续期
lock()不指定leaseTime时启用看门狗,默认 30s 超时、每 1/3 时间续期一次(见第 2 题)。
2)可重入(用 Hash 结构)
key = 锁名(如 lock:order:1001)
field = 客户端唯一标识(UUID:threadId)
value = 重入次数- 同一线程再次加锁:
field已存在 →HINCRBY重入次数 +1; - 解锁:重入次数 -1,减到 0 才真正删除 key 并广播通知等待者;
- 这样就解决了「同一线程内嵌套调用重复加锁」的自死锁问题。
3)基于 Pub/Sub 的高效等待(而不是自旋)
- 未获取到锁的线程先尝试一次,失败后订阅锁释放的频道并阻塞(
Semaphore/CountDownLatch); - 持锁者释放时
PUBLISH一条消息,唤醒等待者按顺序重试; - 避免了无谓的 CAS 自旋,降低 CPU 与网络开销——这是它比自己写
while(true) tryLock()明显更好的地方。
4)丰富的锁类型
普通可重入锁、公平锁(按请求顺序获锁)、读写锁(RReadWriteLock,读读共享)、联锁 MultiLock、信号量 RSemaphore、闭锁 RCountDownLatch。
版本提醒:早期的
RedissonRedLock在 Redisson 3.12.0 起被标记废弃并移除,官方推荐用RedissonMultiLock(把多个独立的RLock组成联锁)来实现多节点加锁。
5)Lua 保证所有多步操作的原子性
5. 什么是 RedLock?为什么有争议?
答: RedLock 是 antirez 提出的一种「多节点分布式锁」算法,目标是解决第 3 题那个「主从切换丢锁」的问题。
算法流程(N 个完全独立的 Redis 主节点,通常 5 个):
① 记录开始时间
② 依次向 N 个节点发起加锁(SET NX PX,同一 key、同一 value、相同超时时间)
③ 统计成功数:
成功数 >= N/2 + 1 且 总耗时 < 锁的有效期 -> 认为加锁成功
④ 若失败:向所有节点发送释放请求(包括那些没加上的,做清理)
⑤ 有效时间 = 初始有效期 - 加锁总耗时(扣除获取锁本身消耗的时间)它想解决的问题:单个主节点 + 异步复制下,主挂了锁就丢了。多数派写法让「同时两个客户端持锁」需要同时在多数节点上成功,概率大大降低。
争议(2016 年 Martin Kleppmann vs antirez 的著名论战):
Kleppmann 的核心批评:
- 依赖系统时钟:RedLock 的安全性建立在「各节点的时间流逝速度差不多」这个假设上。如果某个节点的时钟发生跳跃(NTP 校正、虚拟机暂停/迁移、容器被挂起),锁的有效期判断就会出错,可能出现两个客户端都认为自己持有锁;
- 没有 fencing token,无法防御 GC 停顿:即使锁机制本身正确,客户端也可能在拿到锁之后发生长时间 GC 停顿,等它恢复时锁早已过期,但它自己并不知道,仍会去操作共享资源——此时锁已经无法保护任何东西;
- 正确解法是 fencing token:让锁服务返回单调递增的 token,客户端操作共享资源时带上 token,由存储层拒绝旧 token 的写入。这样即使客户端超时恢复,它的写入也会被拒绝(例如用
version做条件更新)。而 RedLock 拿不到这样一个全局单调 token; - 性价比不合理:如果只是「防重复劳动(效率锁)」,单节点 Redis 就够了,用不着 5 个节点;如果是「保证正确性(互斥锁)」,RedLock 又不够安全——两头不讨好。
antirez 的回应(《Is Redlock safe?》):
- 时钟跳跃问题需要「人为干预」(如禁用 NTP 大步调整、避免虚拟机长时间暂停),不能因此否定算法;
- GC 停顿导致的问题在单节点锁上同样存在,不是 RedLock 独有的缺陷;
- fencing token 需要目标存储支持,前提是「客户端能拿到单调 token」,很多场景并不具备。
务实结论(面试这样答最稳):
| 场景 | 推荐方案 |
|---|---|
| 防重复劳动(定时任务不重复执行、缓存重建) | 单实例 Redis + 看门狗 + 幂等,够用且简单 |
| 需要绝对互斥(资金、库存扣减的强一致) | ZK / etcd(基于共识、有会话与租约语义),或用 DB 的唯一约束/乐观锁 |
| 折中 | Redis 锁 + fencing token / 版本号 + 存储层校验(把正确性放在存储层,而不是锁上) |
关键认知:锁只能降低并发冲突的概率,不能替代下游的幂等与校验。把「正确性」寄托在锁上,本身就是不可靠的设计。
6. 分布式锁和其他锁的区别?三种方案怎么选?
答:
| 方案 | 实现方式 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
synchronized / ReentrantLock | JVM 内置 | 零成本、性能最好 | 只在单个 JVM 内有效,多实例部署下完全失效 | 单机、方法级互斥 |
| 数据库锁 | 唯一索引(插入冲突即失败)/ SELECT ... FOR UPDATE / 乐观锁 version | 与业务数据同源(数据和锁在同一事务里,天然强一致)、实现简单 | 性能差、行锁会长时间持有、有死锁与锁表风险、DB 成为瓶颈 | 低并发、强一致要求 |
| Redis 分布式锁 | SET NX PX + Lua | 性能高、跨进程跨机房、生态成熟(Redisson) | 异步复制可能丢锁;需处理续期;不是强一致 | 高并发分布式协调(防重复) |
| ZK / etcd 分布式锁 | 临时顺序节点 + Watch(ZK);Lease + Revision(etcd) | 强一致(共识算法)、会话断开自动释放、可做 fencing token | 性能低于 Redis、运维成本、需要额外组件 | 高并发 + 强一致(选主、配置管理) |
选型口诀:
- 单机 →
synchronized; - 低并发 + 要和 DB 事务一致 → 数据库锁;
- 高并发 + 只要能「防重复」→ Redis(首选);
- 高并发 + 必须「严格互斥」→ ZK/etcd(或 Redis + 存储层版本校验)。
二、单线程模型与性能
7. Redis 真的是单线程吗?
答: 「处理命令」是单线程,但 Redis 本身有很多线程。 这个区分是本题的全部要点。
单线程的部分(核心):
- 所有客户端命令的执行都在主线程串行完成——这是 Redis 无并发 bug 的根本原因。
多线程/多进程的部分:
| 组成部分 | 说明 | 版本 |
|---|---|---|
| 多线程 IO | 网络读写与协议解析交给 IO 线程(io-threads,默认 1 即关闭),命令执行仍在主线程 | 6.0+ |
| 后台线程(bio) | AOF 的 fsync、关闭文件描述符、异步释放内存(UNLINK/FLUSHALL ASYNC) | 4.0+ |
| fork 子进程 | RDB 快照、AOF 重写 | 早期版本 |
| jemalloc 后台线程 | 内存分配器的后台清理 | — |
| 集群总线线程 | Cluster 节点间通信 | 3.0+ |
版本补充(体现跟进度):
- Redis 6.0 引入多线程 IO,解决「网络 IO 成为瓶颈」的问题;
- Redis 8.0 重写了 IO threading 实现,多核下吞吐提升明显(官方实测
io-threads 8时吞吐最高提升约 112%); - 即便启用多线程 IO,命令执行依然是单线程,所以「单线程模型」这个说法在语义上仍然成立。
回答模板:「命令执行是单线程的,但 Redis 进程里不止一个线程——网络 IO(6.0+ 可多线程)、异步释放、AOF fsync、fork 子进程、集群通信都有各自的线程/进程。」一句话把边界说清楚,比只答「是单线程」高一档。
8. 为什么单线程还这么快?为什么不用多线程执行命令?
答: 分两个问题回答。
为什么快(第 7 题《Redis(一)》也已涉及,这里聚焦「单线程」这层):
- 瓶颈不在 CPU,而在内存与网络:单线程足以打满带宽,多线程带不来收益;
- 没有锁与上下文切换开销:单线程天然串行,不需要加锁、不会因锁竞争陷入内核态、没有线程调度成本;
- 没有并发 bug:所有命令天然原子(这也是
INCR、SET NX能当原子操作用的根本原因); - IO 多路复用:一个线程用 epoll 管理成千上万连接;
- 纯内存 + 高效数据结构:单次操作是「微秒级」,单线程也能达到十万级 QPS。
为什么不用多线程执行命令(关键权衡):
- 要加锁:多个线程同时操作同一个 Hash/跳表,必须对数据结构加锁,锁竞争和上下文切换的开销会吃掉多线程的收益;
- 复杂度剧增:命令间的事务语义、Lua 脚本的原子性、WATCH 的语义都会变得难以实现(Redis 单线程的原子性是很多功能的基础);
- 收益不确定:CPU 不是瓶颈,多核优化收益有限,而复杂度和 bug 风险是确定的;
- 正确的做法是「分层优化」:把「可并行且无状态」的部分(网络读写、协议解析、异步释放)多线程化;把「必须串行」的部分(数据操作)保持单线程——这正是 Redis 6.0 的方案。
延伸:如果单机 QPS 真的不够,正确做法不是「多线程执行命令」,而是 加从库(读扩展)+ 分片(写扩展)+ 多级缓存(减少请求)。
9. Redis 有哪些阻塞点?如何避免?
答: Redis 单线程模型下,任何一个慢操作都会阻塞所有请求。这是线上事故最常见的根因,按类别梳理:
| 类别 | 具体操作 | 规避手段 |
|---|---|---|
| 大 key 操作 | HGETALL、SMEMBERS、LRANGE 0 -1、ZRANGE 0 -1、DEL 大 key | 拆 key、分批(HSCAN/SSCAN)、UNLINK 异步删除、用 HGET/HMGET 取需要的字段 |
| 全量遍历命令 | KEYS *、FLUSHALL(同步) | 用 SCAN 系列;FLUSHALL ASYNC(4.0+) |
| 集合运算 | 大集合的 SINTER / SUNION / SDIFF、ZUNIONSTORE | 放从库执行、用 SINTERCARD 只求基数、或离线计算 |
| 持久化 | SAVE(完全阻塞)、BGSAVE/BGREWRITEAOF 的 fork 停顿、AOF always 的 fsync | 禁用 SAVE;控制实例内存(≤10~16GB);AOF 用 everysec + no-appendfsync-on-rewrite yes |
| Lua 脚本过长 | 脚本执行期间不处理其他命令;超过 lua-time-limit(默认 5000ms)后其他命令返回 BUSY | 脚本要短小(只做原子小操作,不做大数据遍历);优化业务逻辑 |
| 集群运维命令 | MIGRATE(迁移大 key)、CLUSTER SETSLOT 相关、SWAPDB | 迁移前清理 bigkey、低峰执行 |
| 集中过期 | 同一秒大量 key 过期,activeExpireCycle 反复抽样 | TTL 加随机抖动 |
| 主从全量同步 | fork + RDB 生成与传输 | 调大 repl-backlog-size 减少全量概率、无盘复制、级联复制 |
| 客户端输出缓冲区溢出 | 某个客户端读得太慢(如 MONITOR、大结果集),缓冲区持续膨胀 | 用 client-output-buffer-limit 限制并踢掉慢客户端 |
| 内存 swap | 物理内存不足,页被换出(mem_fragmentation_ratio < 1) | 保证 maxmemory + 系统内存余量,禁用 swap |
DEBUG SLEEP | 调试命令,直接阻塞 | 生产禁用 DEBUG |
排查手段:
- 慢查询日志:
slowlog-log-slower-than(默认 10000 微秒 = 10ms)、slowlog-max-len(默认 128),用SLOWLOG GET查看——这是找阻塞点的第一工具; LATENCY MONITOR/LATENCY HISTORY:记录超过latency-monitor-threshold的事件;INFO commandstats:看各命令的调用次数与平均耗时(usec_per_call),能快速定位「哪个命令最耗 CPU」;redis-cli --latency/--intrinsic-latency:区分是 Redis 自身慢还是机器/系统慢;MEMORY USAGE key+--bigkeys:定位大 key。
一条经验:线上任何「Redis 变慢了」的排查,都从「慢查询日志 +
--bigkeys」开始——90% 的情况能直接定位到某个大 key 或慢命令。
10. Redis 与 Memcached 的区别?
答:
| 维度 | Redis | Memcached |
|---|---|---|
| 数据类型 | 5 种基础 + Bitmap/HLL/GEO/Stream 等 | 仅 KV(value 上限 1MB) |
| 持久化 | 支持 RDB / AOF / 混合 | 不支持,重启即丢 |
| 线程模型 | 命令执行单线程(6.0+ 网络 IO 可多线程) | 多线程,更能吃满多核 CPU |
| 内存管理 | jemalloc + 多种紧凑编码(listpack/intset),小对象内存效率高 | slab allocator 预分配固定大小 chunk,元数据开销小但可能有内部碎片 |
| 分布式 | 服务端支持 Cluster(哈希槽) | 无服务端集群,靠客户端一致性哈希分片 |
| 高级功能 | 事务、Lua、发布订阅、Stream、模块、GEO | 基本没有,专注缓存 |
| Value 上限 | 512MB | 1MB(默认,可编译调整) |
怎么选:
- 纯 KV 缓存、value 不大、想充分吃多核 CPU → Memcached(更简单、多线程);
- 需要数据结构(Hash/ZSet 排行榜)、持久化、分布式协调(锁/限流/队列)、集群 → Redis(绝大多数场景的默认选择);
- 现实中 Memcached 的市场份额已很小,Redis 基本是事实标准。
补充:Memcached 的「多线程」不是绝对优势——它的每个连接由固定线程处理,且无法像 Redis 那样提供跨命令的原子性(Redis 单线程让
INCR、SET NX、Lua 天然原子)。所以在「需要原子计数/锁」的场景,Redis 的单线程反而是优点。
11. Pipeline 是什么?和事务、Lua 有什么区别?
答: Pipeline(管道)是客户端的批量提交优化:把 N 条命令一次性发出、服务端依次执行、一次性返回,把 N 次网络往返压缩成 1 次。
为什么需要它:Redis 单次命令处理的耗时通常在微秒级,而网络 RTT 在毫秒级——批量场景下 RTT 才是真正的瓶颈。比如循环 SET 一万次,用 Pipeline 可能快 10 倍以上。
三种「批量」的区别(高频混淆点):
| 维度 | Pipeline | 事务(MULTI/EXEC) | Lua 脚本 |
|---|---|---|---|
| 本质 | 客户端攒批 + 服务端依次执行 | 服务端事务队列 | 服务端原子脚本 |
| 是否原子 | 否!中间可能插入其他客户端的命令 | 有一定原子性(连续执行不被打断),但不支持回滚 | 是,整体原子执行 |
| 能否条件逻辑 | 不能 | 不能(入队时看不到前一条的结果) | 能(脚本里可以判断返回值) |
| 主要收益 | 减少 RTT,提升吞吐 | 提交/放弃的一体化控制 | 原子性 + 复杂逻辑 |
| 典型场景 | 批量写、批量读(不能用在需要读结果决定下一步的场景) | 一组命令要么都提交要么都不提(DISCARD) | 分布式锁解锁、限流、原子的「取出+删除」 |
Pipeline 的三个注意点:
- 不保证原子性:服务端在处理你的命令批次时,中间可能穿插其他客户端的命令。需要原子性就用 MULTI 或 Lua;
- 一次不能塞太多:几千上万条会占用大量客户端/服务端输出缓冲区内存,可能触发
client-output-buffer-limit被断开。要分批(如每批 500~1000 条); - Pipeline + 事务可以组合:用
.multi()模式发送,实际把MULTI/EXEC也放进管道里,一次网络往返完成一次事务。
记忆点:Pipeline 解决「网络往返多」,事务解决「一起提交」,Lua 解决「原子且要有逻辑」。 三者不是互相替代关系。
三、事务、脚本与实战场景
12. Redis 事务支持回滚吗?
答: 不支持回滚。这是与 MySQL 事务最关键的区别,而且要分清两类错误——它们的行为完全不同:
| 错误类型 | 发生时机 | 举例 | Redis 行为 |
|---|---|---|---|
| 入队错误(编译期错误) | 命令入队时 | 命令名不存在、参数个数不对 | Redis 会记录错误,EXEC 时整个事务拒绝执行,返回 EXECABORT(相当于全都放弃) |
| 运行时错误 | EXEC 执行时 | 对 String 执行 LPOP、INCR 一个非数字值 | 只有出错的那条失败,其余命令照常执行,返回结果里标记错误 |
为什么不支持回滚(设计哲学):
- 认为运行时错误是「编程错误」:类型用错、参数传错,本就不该出现在生产环境,不值得为它实现昂贵的回滚机制;
- 实现回滚需要 undo log:要记录每条命令的逆操作,内存与复杂度成本很高,与 Redis「简单、快」的目标冲突;
- 单线程让「原子执行」很容易实现,但「原子语义」不必等于「可回滚」。
命令与使用方式:
MULTI # 开启事务
SET k1 v1 # 入队(返回 QUEUED)
INCR k2 # 入队
EXEC # 依次执行,返回结果数组
DISCARD # 放弃事务
WATCH k1 # 乐观锁:若 k1 在 EXEC 前被改过,EXEC 返回 nil
UNWATCH # 取消监视三个易错点:
- 事务中看不到中间结果:命令都是入队,
EXEC才执行,因此无法根据前一条命令的返回值决定后一条命令——需要条件逻辑时必须用 Lua; - 「原子」不等于「隔离」:Redis 事务是「连续执行不被打断」,但不提供隔离级别(其他客户端看不到未提交的中间状态,因为根本没有中间状态——全部在 EXEC 时执行);
- 需要真正的原子性,用 Lua(见第 13 题)。
想要完整答案就加一句:「Redis 事务更准确的定位是『命令打包一次性提交』,而不是关系型数据库意义上的事务。」
13. WATCH 与 Lua 脚本如何保证原子性?
答: 两者解决不同层面的问题,经常配合使用。
WATCH:乐观锁(CAS 语义)
WATCH stock:item:1
value = GET stock:item:1 # 读出来判断
MULTI
SET stock:item:1 (value - 1)
EXEC # 若 stock:item:1 在 WATCH 之后被改过 -> EXEC 返回 nil,事务放弃- 原理:
WATCH的 key 一旦被修改,服务端就把该客户端标记为「事务失效」,EXEC直接返回nil; - 用途:需要「读 → 判断 → 写」的乐观并发控制(库存扣减的经典写法);
- 注意:
WATCH是连接级别的,EXEC/DISCARD/UNWATCH都会清除;客户端要自己处理返回 nil 时的重试。
Lua 脚本:服务端原子执行
-- 原子地「取出并删除」一条到期的延迟任务(多消费者场景下不会重复取)
local jobs = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 1)
if #jobs == 0 then
return nil
end
redis.call('ZREM', KEYS[1], jobs[1])
return jobs[1]Lua 的关键规则(面试常问):
| 规则 | 说明 |
|---|---|
| 原子性 | 脚本作为一个整体执行,中间不会插入其他客户端的命令(因为单线程) |
| key 必须显式传入 | 所有要访问的 key 都要作为 KEYS[] 参数传入,不能在脚本里拼 key——集群模式下 Redis 需要据此校验所有 key 是否同槽 |
| 不能执行阻塞/耗时操作 | 脚本越长,阻塞所有请求越久;lua-time-limit(默认 5000ms)超时后其他命令收到 BUSY,此时只能 SCRIPT KILL(脚本还没写)或 SHUTDOWN NOSAVE |
| 不要用随机/时间做写决策 | 早期版本要求脚本是「纯函数」(否则主从/持久化重放结果不一致);Redis 5.0 起用效果复制(redis.replicate_commands()),7.0 起默认 |
EVALSHA 优于 EVAL | 先 SCRIPT LOAD 得到 sha1,后续用 EVALSHA 只传 sha1,避免每次都传整个脚本 |
| Redis 7.0 起有 Functions | FUNCTION LOAD 把脚本作为具名函数注册并可持久化,比 EVALSHA 更易运维(不用自己管 sha1) |
怎么选:
- 只有「一组命令要一起提交」 →
MULTI/EXEC(或 Pipeline 提升吞吐,但不要原子性时); - 需要「读-判断-写」的乐观并发 →
WATCH+MULTI; - 需要「多步操作的原子性 + 条件逻辑」 → Lua(分布式锁解锁、限流、原子取出、库存扣减都是它)。
14. 如何用 Redis 实现消息队列和延迟队列?
答:
消息队列的三种实现(可靠性递增):
| 方案 | 命令 | 可靠性 | 适用 |
|---|---|---|---|
| List(最简) | LPUSH 生产 + BRPOP 消费 | 不可靠:取出即删除,消费失败直接丢 | 允许丢消息的简单异步任务 |
| List + 备份队列(可靠版) | BLMOVE src dst LEFT RIGHT(Redis 6.2+,旧版 BRPOPLPUSH) | 较好:消息原子地移到备份队列,业务成功后再 LREM 删除;进程崩溃可从备份队列恢复 | 中小规模、不想引入 MQ |
| Stream(推荐) | XADD / XREADGROUP / XACK / XPENDING / XCLAIM | 最好:消费组 + ACK + PEL + 消息认领,能处理「消费失败重试」与「消费者宕机」 | 需要可靠投递且不想上专业 MQ |
重要边界:这几种方案都没有死信队列、延迟级别、事务消息、广播、消息回溯、堆积能力(数据在内存)等专业 MQ 的能力。生产级可靠消息仍应使用 RocketMQ/Kafka,Redis 的队列更适合「轻量、低延迟、可容忍少量丢失」的内部异步场景。
延迟队列:用 ZSet 实现
入队:ZADD delay:queue <执行时间戳> <任务ID>
消费:定时任务(每秒)执行 Lua:
ZRANGEBYSCORE delay:queue 0 <当前时间戳> LIMIT 0 N # 取出到期任务
ZREM delay:queue <这些任务> # 原子删除(Lua 保证不重复消费)
然后投递到真正的业务队列 / 直接执行四个实现要点(面试加分):
- 取任务 + 删任务必须原子(用 Lua),否则多实例消费会重复取到同一条;
- 要处理「投递失败」:取出后投递到业务队列失败,任务就丢了——可靠做法是移到「重试队列」或记录到 DB(Redisson 的
RDelayedQueue就是把到期元素原子地转到目标队列); - 空轮询问题:定时任务即使没有到期任务也会扫一次,可用「记录最近到期时间」来调整轮询间隔,减少无谓查询;
- 大量延迟任务的内存:ZSet 成员数多就是 bigkey,注意分片(按任务类型或时间片拆 key)。
发布订阅(Pub/Sub)为什么不能当 MQ:消息不持久化,订阅者离线期间的消息直接丢弃,也没有 ACK 与消费组(Redis 7.0 的 SSUBSCRIBE 只解决集群广播范围问题)。它适合「配置变更通知、缓存失效广播」这类丢了也无所谓的场景。
15. 如何用 Redis 实现限流?
答: 四种主流算法,复杂度和精度递增:
1)固定窗口计数(最简单)
-- 原子:计数 +1,首次设置过期
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
return 0 -- 超限
end
return 1- 优点:实现极简、内存占用小(每个 key 一个计数);
- 致命缺点:临界问题——限制「100 次/分钟」,如果在 59 秒和 61 秒各打 100 次,两秒内实际通过了 200 次。
2)滑动窗口(ZSet)
请求时:ZREMRANGEBYSCORE key 0 (now - window) # 清掉窗口外的
ZCARD key # 统计窗口内的
ZADD key now <唯一ID> # 记录本次请求- 优点:精度高,没有临界问题;
- 缺点:内存开销大——每个请求都要存一个 ZSet 成员,高 QPS 下限流本身成了内存负担(可用 Pipeline 或 Lua 原子完成三步)。
3)滑动窗口的优化版(分桶/计数器)
把窗口切成若干小格(如 1 分钟切成 6 个 10 秒桶),用计数器累加相邻几格。在精度与内存之间取平衡,是很多网关(如 Nginx 的 limit_req、Sentinel 的滑动窗口)的实际做法。
4)令牌桶 / 漏桶(支持突发流量)
-- 令牌桶:记录上次填充时间与当前令牌数
-- 按经过时间补充令牌(速率 rate),最多到容量 capacity
-- 有令牌则放行并扣减,否则拒绝(返回需要等待的时间)- 令牌桶允许一定程度的突发(桶里攒的令牌),漏桶则以恒定速率流出(更适合保护下游);
- 用 Lua 实现「读-算-写」的原子性;
- 也可以用第三方模块
redis-cell的CL.THROTTLE命令(注意它不是 Redis 官方内置模块,需要单独安装)。
工程选型建议:
| 场景 | 推荐 |
|---|---|
| 粗粒度接口限流、可容忍临界误差 | 固定窗口(最简单) |
| 精度要求高、QPS 不高(如短信、验证码) | 滑动窗口 ZSet |
| 需要允许突发(API 网关) | 令牌桶 |
| 保护下游不被压垮 | 漏桶(或信号量并发数限制) |
| 单机限流(不要求全局精确) | Guava RateLimiter / Sentinel 本地模式,完全不占用 Redis |
重要提醒:Redis 做限流是全局精确的,但多一次网络往返;高 QPS 场景下「本地限流(粗)+ Redis 限流(细)」组合更实用。另外集群模式下所有限流 key 都要保证同槽(用 hash tag,如
{rate:api}:user:1),否则 Lua 脚本会因跨槽报错。
16. 如何安全地遍历海量 key?
答: 绝对禁止 KEYS pattern——它是 O(N) 的全量扫描且全程阻塞单线程,几千万 key 的实例上执行一次可能阻塞数秒,直接引发线上雪崩。
正确方式:SCAN 游标遍历
SCAN 0 MATCH user:* COUNT 1000 TYPE string
# 返回:[下一个游标, 元素列表];游标返回 0 表示遍历结束机制:底层用「反向二进制迭代」保证遍历的连续性——游标不是下标,而是被反转的哈希表桶序号。这样即使遍历期间发生 rehash(扩容/缩容),也不会漏掉元素。
SCAN 的三条保证与三条不保证(高频考点):
| 保证 | 说明 |
|---|---|
| ✅ 不会漏掉「全程存在」的元素 | 从头到尾一直在集合里的 key,至少会被返回一次 |
| ✅ 不阻塞 | 每次调用只遍历少量桶,复杂度 O(1)(相对) |
| ✅ 支持 MATCH/COUNT/TYPE | COUNT 只是「提示」而非精确条数 |
| 不保证 | 说明 |
|---|---|
| ❌ 可能返回重复元素 | rehash 期间同一个元素可能被两个桶都扫到 → 客户端必须去重 |
| ❌ 遍历期间新加入的元素不保证被返回 | 只保证「一直存在的」 |
| ❌ 不保证返回已删除的元素 | 遍历期间被删的元素可能不返回 |
使用要点:
- 分批 + 限速:
COUNT建议 100~1000,且两次 SCAN 之间加短 sleep,避免长时间占用 CPU; - 客户端要能容忍重复(去重);
- 遍历期间不要做重活:拿到 key 后如果用
HGETALL/MEMORY USAGE逐个体检,等于把负载从「扫描」换成了「体检」——用--bigkeys这类带节流的工具更安全; - 其他 SCAN 家族:
SSCAN(Set)、HSCAN(Hash)、ZSCAN(ZSet);注意 listpack/intset 编码的小集合上,HSCAN/SSCAN会一次性返回全部元素(内存连续,实现上就是直接遍历),所以别以为用了 SCAN 就一定不会卡——大集合转成 hashtable 后才是真正的分批。
定位大 key / 热 key 的工具(都基于 SCAN,线上可用):
redis-cli --bigkeys # 各类中最大的 key(采样)
redis-cli --memkeys # 按内存占用排序(6.0+)
redis-cli --hotkeys # 访问频率 Top(要求 LFU 策略)
redis-cli --cluster call <node> ... # 集群下逐节点执行17. Redis 有哪些版本里程碑与新特性?
答: 记住这条主线就够用(面试常用来判断「你是不是只会背老八股」):
| 版本 | 关键特性 |
|---|---|
| 2.6 / 2.8 | Lua 脚本;部分重同步 PSYNC(主从复制的关键优化) |
| 3.0 | Cluster 集群、GEO、近似 LRU 改进(淘汰候选池) |
| 3.2 | GEO 命令正式化;从库读过期 key 返回空 |
| 4.0 | 模块系统、LFU 淘汰、UNLINK/lazyfree 异步释放、混合持久化、activedefrag 主动碎片整理 |
| 5.0 | Stream、listpack 结构首次引入 |
| 6.0 | 多线程 IO、ACL 权限、RESP3、客户端缓存(Client-side Caching)、SSL |
| 6.2 | BLMOVE(可靠队列)、GETDEL/GETEX、CLIENT NO-EVICT |
| 7.0 | listpack 全面取代 ziplist、multi-part AOF(appendonlydir)、Functions、分片 Pub/Sub(SSUBSCRIBE)、无盘复制默认开启 |
| 7.4 | Hash 字段级过期(HSETEX/HGETEX/HGETDEL)、maxmemory-clients、多项内存优化 |
| 8.0(2025) | 内置原 Redis Stack 全部模块(JSON、Time Series、Bloom/Cuckoo/Count-min/Top-k/t-digest)、Vector Set、新增 AGPLv3 许可选项、IO threading 重写、复制机制优化 |
三个值得单独展开讲的点:
1)Redis 8.0 的「One Redis」
过去「社区版 + Redis Stack(模块)」双线并行,版本匹配困难、生态碎片化。Redis 8.0 把二者合并为单一发行版(Redis Open Source),所有模块内置。同时在原有 RSALv2/SSPLv1 之外新增 AGPLv3 作为可选许可。
2)Redis 8.0 的性能改进(有具体数字,很能体现关注度)
- 命令 p50 延迟最多降低 87.4%(149 项测试中 90 个命令更快);
io-threads 8时吞吐最高提升约 112%;- 新复制机制:全量同步期间同时跑「数据传输」与「变更流」两条流,复制内存峰值降低约 35%、耗时减少约 18%。
3)Vector Set(Beta)
由 Redis 作者 antirez 主导开发,设计灵感来自 ZSet——把「有序集合」扩展到高维向量:既能存向量,又能像 ZSet 那样按相似度做范围查询,直接服务语义搜索、推荐、RAG 检索等 AI 场景。
面试用法:不必背全表。抓住「3.0 集群 / 4.0 模块+LFU+懒删除 / 5.0 Stream+listpack / 6.0 多线程IO+ACL / 7.0 listpack+多部分AOF+Functions / 8.0 模块内置+Vector Set」这条主线,足以应对版本类追问。
系列完结:五篇覆盖了 Redis 的数据类型与底层结构 → 持久化与内存管理 → 高可用与集群 → 缓存治理与一致性 → 分布式锁与实战完整链路。建议与《Java并发(三)》的锁体系、《数据库》的 MySQL 事务隔离级别对照复习,这几块在「分布式一致性」这个大命题上是相通的。
