这篇不是"Redis 命令速查表",而是按**「为什么快 → 数据怎么存 → 怎么保证不丢 → 怎么保证高可用 → 怎么扛住并发 → 怎么用对场景」**这条主线,把 Redis 的核心知识体系串起来。全文含 40+ 段可执行代码(Lua / Java / redis-cli),读完可以直接上手生产配置和应付面试深挖。
目录
- [Redis 为什么这么快:六层原因拆解](#Redis 为什么这么快:六层原因拆解)
- 数据类型与底层数据结构(面试必考对照表)
- [持久化:RDB / AOF / 混合,到底怎么选](#持久化:RDB / AOF / 混合,到底怎么选)
- [内存管理:过期删除 + 8 种淘汰策略](#内存管理:过期删除 + 8 种淘汰策略)
- 主从复制:第一次全量,之后增量
- [哨兵 Sentinel:自动故障转移是怎么做到的](#哨兵 Sentinel:自动故障转移是怎么做到的)
- [Redis Cluster:16384 个槽是怎么分的](#Redis Cluster:16384 个槽是怎么分的)
- [分布式锁:从 SETNX 到 Redisson 看门狗](#分布式锁:从 SETNX 到 Redisson 看门狗)
- [九大典型使用场景 + 完整代码](#九大典型使用场景 + 完整代码)
- 缓存三兄弟与一致性方案
- [面试高频 30 问快答](#面试高频 30 问快答)
- [生产上线 Checklist](#生产上线 Checklist)
一、Redis 为什么这么快
官方 benchmark:单机 10 万 QPS 级别。原因不是单一的"内存",而是六层叠加:
┌─────────────────────────────────────────────────────────┐
│ ① 纯内存操作 纳秒级访问,无磁盘随机 IO │
│ ② 单线程命令执行 无锁、无上下文切换、无竞态 │
│ ③ IO 多路复用 epoll/kqueue,一个线程处理万级连接 │
│ ④ 高效数据结构 SDS/跳表/listpack/哈希表定制优化 │
│ ⑤ 网络模型优化 6.0 起多线程 IO(只负责读写+协议解析) │
│ ⑥ 零拷贝与批处理 pipeline / MGET / Lua 减少 RTT │
└─────────────────────────────────────────────────────────┘
1.1 单线程到底"单"在哪
很多人答错的点 :Redis 的"单线程"指的是命令执行(内存操作)是单线程,不是整个进程只有一个线程。
- Redis 4.0:引入 lazy free (
UNLINK、FLUSHALL ASYNC),用后台线程释放大对象内存; - Redis 6.0:引入 IO 多线程 (
io-threads),网络读、写、协议解析交给多线程,命令执行仍然是单线程; - Redis 的
bgsave、bgrewriteaof靠 fork 子进程完成。
ini
# redis.conf(6.0+)
io-threads 4 # 建议:4 核设 2-3,8 核设 6;超过 8 核收益递减
io-threads-do-reads yes # 开启读多线程(默认只写多线程)
面试追问 :既然单线程,为什么还需要分布式锁? 答:单线程保证的是单条命令的原子性,而业务层的"读-判断-写"是多条命令,中间可能被其他客户端插入,所以仍需分布式锁或 Lua 脚本。
1.2 单线程的代价:必须知道的"慢命令"
单线程意味着一个命令卡顿,所有请求排队。生产禁用清单:
bash
KEYS * # O(N) 全量遍历,百万 key 直接卡死 → 用 SCAN
FLUSHALL / FLUSHDB # → 用 ASYNC 版本
DEL bigkey # 释放大对象阻塞 → 用 UNLINK
SMEMBERS bigset # → 用 SSCAN
HGETALL bighash # → 用 HSCAN
LRANGE biglist 0 -1 # → 分页
# 对 bigkey 操作前先探测
redis-cli --bigkeys
redis-cli --memkeys # Redis 7.4+,按内存排序找大 key
二、数据类型与底层数据结构
2.1 五种基本类型 → 底层编码对照表(必背)
| 对外类型 | 底层编码(低版本) | 底层编码(Redis 7) | 转换条件 |
|---|---|---|---|
| String | int / embstr / raw |
同左 | 整数用 int;≤44 字节用 embstr;更大用 raw |
| List | ziplist / linkedlist |
quicklist(listpack 节点) | 3.2 起统一 quicklist |
| Hash | ziplist / hashtable |
listpack / hashtable | 元素数 ≤512 且所有值 ≤64 字节 |
| Set | intset / hashtable |
同左 | 元素都是整数且 ≤512 个 |
| ZSet | ziplist / skiplist+dict |
listpack / skiplist+dict | 元素 ≤128 且 member ≤64 字节 |
bash
# 实际观察编码
127.0.0.1:6379> SET a 100
127.0.0.1:6379> OBJECT ENCODING a
"int"
127.0.0.1:6379> SET b hello
127.0.0.1:6379> OBJECT ENCODING b
"embstr"
127.0.0.1:6379> HSET h f1 v1
127.0.0.1:6379> OBJECT ENCODING h
"listpack" # Redis 7;6.x 是 ziplist
Redis 7 最重要的底层变化 :
ziplist被listpack全面取代。原因:ziplist 每个节点存prevlen(前一节点长度),前一节点从 253 字节变成 254 字节会触发连锁更新(cascade update),最坏 O(N²);listpack 的节点只记录自身长度,彻底消除连锁更新。
2.2 核心结构原理速览
SDS(Simple Dynamic String)
c
struct sdshdr {
uint32_t len; // 已用长度,O(1) 获取 strlen
uint32_t alloc; // 总分配空间(free 计算:alloc - len)
unsigned char flags; // 头类型:sdshdr5/8/16/32/64
char buf[]; // 字节数组,仍以 \0 结尾(兼容 C 函数)
};
三个设计价值:O(1) 取长度 、空间预分配 + 惰性释放 (减少内存重分配)、二进制安全 (用 len 判断结束而非 \0,所以能存图片/protobuf)。
跳表(Skip List)------ ZSet 的灵魂
Level 3: 1 ────────────────────────▶ 21
Level 2: 1 ─────────▶ 9 ───────────▶ 21
Level 1: 1 ───▶ 5 ──▶ 9 ───▶ 15 ───▶ 21
Level 0: 1 → 3 → 5 → 7 → 9 → 12 → 15 → 18 → 21
(每一层都是有序链表,上层是下层的"快速通道")
ZSet 用 跳表 + 哈希表 双结构:跳表支持范围查询(ZRANGEBYSCORE),哈希表支持 O(1) 单点查询(ZSCORE)。层数是随机生成的(ZSKIPLIST_P = 0.25,最高 32 层)。
为什么不用红黑树? 跳表实现更简单、范围查询只需遍历链表、且可以通过调整节点指针做局部更新,不需要像红黑树那样 rebalance。
哈希表渐进式 rehash
Redis 的字典有 ht0、ht1 两个哈希表。扩容时不是一次性搬迁(会阻塞),而是:
1. 为 ht[1] 分配空间(大小为 > ht[0].used*2 的第一个 2^n)
2. rehashidx = 0
3. 每次增删改查时,顺带把 ht[0].rehashidx 位置的桶搬到 ht[1](分摊成本)
4. 全部搬完后 ht[0] = ht[1],rehashidx = -1
注意:渐进式 rehash 期间,查询要同时查两张表 ,且新增只写 ht1。
2.3 三种扩展类型
| 类型 | 底层 | 典型场景 | 误差/限制 |
|---|---|---|---|
| Bitmap | SDS(按位操作) | 用户签到、活跃标记 | 1 亿用户 1 天仅 12.5MB |
| HyperLogLog | 概率算法(分桶调和平均) | UV 统计 | 标准误差 0.81%,单 key 12KB |
| GEO | ZSet(Geohash 编码为 score) | 附近的人/门店 | 底层就是 ZSet |
| Stream | Radix Tree + listpack | 消息队列(Redis 5.0+) | 支持消费组、ACK、PEL |
| Bloom Filter | 需 RedisBloom 模块 | 缓存穿透防护 | 有误判率,不可删除(Cuckoo 可删) |
bash
# HyperLogLog:UV 统计,1 亿用户只占 12KB
PFADD uv:20261102 user1 user2 user3
PFCOUNT uv:20261102 # 3
PFMERGE uv:202611 uv:20261101 uv:20261102 # 合并多天(注意:是去重后的并集)
# Bitmap:签到
SETBIT sign:202611:1001 1 1 # 用户 1001 在 11-02 签到(offset=1 表示第 2 天)
BITCOUNT sign:202611:1001 # 本月签到天数
BITFIELD sign:202611:1001 GET u30 0 # 一次性取 30 天的位图(u30 = 无符号 30 位整数)
# GEO
GEOADD shops 116.397 39.909 "王府井店" 121.473 31.230 "上海店"
GEORADIUS shops 116.40 39.90 5 km WITHDIST COUNT 10 ASC # Redis 7 推荐用 GEOSEARCH
GEOSEARCH shops FROMLONLAT 116.40 39.90 BYRADIUS 5 km ASC COUNT 10
# Stream:真正可用的消息队列
XADD mystream * order_id 1001 status created
XGROUP CREATE mystream g1 $ MKSTREAM # 创建消费组
XREADGROUP GROUP g1 c1 COUNT 10 BLOCK 5000 STREAMS mystream > # 消费
XACK mystream g1 1698892800000-0 # 确认(不 ACK 会留在 PEL 里)
XPENDING mystream g1 # 查看未确认消息
三、持久化
3.1 RDB:内存快照
bash
# 触发方式
SAVE # 主进程同步做,阻塞所有命令(生产禁用)
BGSAVE # fork 子进程做,主进程继续服务
# 自动触发(redis.conf)
save 900 1 # 900 秒内至少 1 次修改
save 300 10
save 60 10000
核心原理:fork + Copy-On-Write
┌─────────── 主进程 ───────────┐ ┌──── 子进程 ────┐
│ 内存页 A B C D │ fork() │ 共享同一份内存 │
│ 修改页 B ─────────────────▶│ 页 B 被复制一份副本给子进程
│ (COW:主进程复制原页再改) │ │ 子进程写 RDB 的是修改前的快照
└──────────────────────────────┘ └───────────────┘
关键风险 :fork 瞬间如果内存 50GB,理论上 COW 最坏需要额外 50GB 内存。生产建议:maxmemory 设为物理内存的 60%-70%,并留足 swap 空间;同时要关闭 THP(Transparent Huge Page):
bash
echo never > /sys/kernel/mm/transparent_hugepage/enabled
RDB 的缺点:两次快照之间宕机,会丢失这期间的全部数据。
3.2 AOF:追加写命令
ini
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec # always | everysec | no
auto-aof-rewrite-percentage 100 # 比上次重写后增大 100% 触发
auto-aof-rewrite-min-size 64mb
| 策略 | 行为 | 数据安全性 | 性能 |
|---|---|---|---|
always |
每条命令 fsync | 最多丢 1 条 | 差 |
everysec(默认) |
每秒 fsync | 最多丢 1 秒 | 好 |
no |
交给 OS(通常 30s) | 最多丢 30 秒 | 最好 |
AOF 重写(BGREWRITEAOF) :AOF 会越来越大(对同一个 key 操作 100 次就有 100 条记录)。重写不是读旧 AOF,而是直接读当前内存数据,生成"能重建当前状态"的最小命令集。
# 重写前 AOF
SET counter 1 ... SET counter 100 (100 条)
# 重写后 AOF
SET counter 100 (1 条)
Redis 7.0 起 AOF 拆成三部分:
appendonly.aof.1.base.rdb(基础全量,RDB 格式)+incr.aof(增量)+manifest(清单),不再需要在内存中做协议转换,重写效率大幅提升。
3.3 混合持久化(推荐)
ini
aof-use-rdb-preamble yes # 4.0+,7.0 默认开启
AOF 重写时,前半段用 RDB 二进制格式,后半段用 AOF 增量命令。兼顾 RDB 的快速加载与 AOF 的低丢失。
3.4 生产怎么选
| 场景 | 推荐方案 |
|---|---|
| 纯缓存、可丢失 | 关闭持久化,或只开 RDB |
| 一般业务(推荐) | RDB + AOF(everysec) + 混合持久化 |
| 金融/不可丢 | AOF always + RDB + 从库备份(性能代价大) |
| 最佳实践 | 一定要有从库做持久化备份,主库可关闭持久化以换取性能 |
血的教训 :Redis 宕机重启后,如果 AOF 损坏,可用
redis-check-aof --fix修复(会丢弃损坏部分);如果用redis-check-aof直接改,可能丢数据。
四、内存管理
4.1 过期删除策略:惰性 + 定期
ini
hz 10 # 每秒执行 10 次后台任务(含过期扫描)
- 惰性删除:访问 key 时才检查是否过期,过期就删。优点省 CPU,缺点过期 key 一直占内存。
- 定期删除 :每秒
hz次,每次从expires字典随机抽 20 个 key ,删除其中已过期的;如果过期比例超过 25%,则重复该过程(上限 25ms/次,避免阻塞)。
面试常问 :如果一个 key 过期了但一直没被访问,也没被随机抽中,会怎样? 答:它会一直留在内存里,直到被惰性删除或内存淘汰。所以不要以为设了 TTL 内存就一定释放。
4.2 八种内存淘汰策略
ini
maxmemory 8gb
maxmemory-policy allkeys-lru
maxmemory-samples 5 # LRU/LFU 采样数量,越大越精确越耗 CPU
| 策略 | 范围 | 算法 | 适用 |
|---|---|---|---|
noeviction(默认) |
- | 不淘汰,写请求报错 | 数据绝不能丢(但有隐患) |
allkeys-lru |
所有 key | LRU | 纯缓存首选 |
volatile-lru |
设了 TTL 的 key | LRU | 需保留持久化数据 |
allkeys-lfu |
所有 key | LFU | 热点稳定、有"爆款"的场景 |
volatile-lfu |
设了 TTL 的 key | LFU | 同上 |
allkeys-random |
所有 key | 随机 | 访问均匀 |
volatile-random |
设了 TTL 的 key | 随机 | - |
volatile-ttl |
设了 TTL 的 key | 剩余 TTL 最短优先 | 希望快过期的先走 |
Redis 的 LRU 是"近似 LRU" :不是维护全局链表(太耗内存),而是每个对象记录 24 位 lru 时钟,淘汰时随机采样 N 个,淘汰其中最久未访问的。N 越大越接近真 LRU。
LFU 的实现(Redis 4.0+) :24 位拆成 16 位频率计数器 + 8 位衰减时间。
┌─────────────────┬──────────────┐
│ 16 bits LDT │ 8 bits CTR │
│ (上次衰减时间) │ (访问频次) │
└─────────────────┴──────────────┘
# CTR 的递增是对数型的(不是每次+1),
# lfu-log-factor 10 时,要达到 255 需约 1000 万次访问
# lfu-decay-time 1 表示每分钟衰减 1
bash
CONFIG SET maxmemory-policy allkeys-lfu
CONFIG SET lfu-log-factor 10
CONFIG SET lfu-decay-time 1
OBJECT FREQ mykey # 查看某个 key 的访问频次(0-255)
4.3 内存碎片
bash
INFO memory
# mem_fragmentation_ratio = used_memory_rss / used_memory
# 1.0-1.5 正常;>1.5 碎片严重;<1.0 说明用了 swap(危险!)
ini
activedefrag yes # 4.0+ 开启自动碎片整理
active-defrag-ignore-bytes 100mb
active-defrag-threshold-lower 10
五、主从复制
5.1 第一次全量同步
① Slave: REPLICAOF 10.0.0.1 6379
② Slave ──── PSYNC ? -1 ─────────▶ Master
③ Master ── FULLRESYNC {replid} {offset} ──▶ Slave
④ Master: BGSAVE 生成 RDB ─────────▶ Slave(同时新命令写入 replication buffer)
⑤ Slave: 清空旧数据 → 加载 RDB
⑥ Master ──── 发送 replication buffer 中的增量命令 ────▶ Slave
⑦ 之后进入命令传播阶段:Master 每执行一条写命令,异步发给 Slave
5.2 断线重连的增量同步(关键优化点)
Redis 2.8 前断线重连必须全量重传 (SYNC),代价巨大。2.8 后用 PSYNC:
Master 端:
┌────────────────────────────────────────┐
│ replication backlog (环形缓冲区) │
│ ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ │
│ │ │ │ │ │ │ │ │ │ │ │ │
│ └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ │
│ ↑ master_repl_offset │
│ slave 的 offset 在这个环内 → 增量同步 │
│ slave 的 offset 已被覆盖 → 全量同步! │
└────────────────────────────────────────┘
生产必调 :repl-backlog-size 默认只有 1MB,主库写入一忙,从库断线几秒就被覆盖,触发全量同步(RDB 生成 + 传输,几 GB 数据直接把主库打挂)。
ini
# 经验公式:backlog 大小 ≥ 平均每秒写命令字节数 × 最大可容忍断线秒数 × 2
repl-backlog-size 256mb # 生产建议 128MB-512MB
repl-backlog-ttl 3600 # 从库断开后 backlog 保留时间
repl-diskless-sync yes # 无盘复制:RDB 直接走 socket 不落盘(SSD 环境可选 no)
repl-diskless-sync-delay 5 # 等待 5s 聚合多个从库的请求,共用一份 RDB
replica-serve-stale-data yes # 从库同步中断时继续服务旧数据(no 则报错)
replica-read-only yes # 从库只读(默认)
5.3 复制的三个常见问题
① 主从数据不一致 :复制是异步的。INFO replication 里的 master_repl_offset 与 slave_repl_offset 差值就是延迟字节数。
bash
# 监控脚本
redis-cli -h master INFO replication | grep master_repl_offset
redis-cli -h slave INFO replication | grep slave_repl_offset
# 差值持续增长 = 从库跟不上(写入压力大 / 从库有慢命令 / 网络问题)
② 主从切换丢数据 :主库挂了,还没同步到从库的数据永久丢失。Redis 本身不保证,只能靠: - min-replicas-to-write 1 + min-replicas-max-lag 10(至少 1 个从库延迟 <10s 才允许写,否则主库拒绝写)------代价是可用性下降; - 或者用 WAIT 命令(会阻塞):WAIT 1 1000 表示等 1 个副本确认,最多 1 秒。
③ 复制风暴 :主库挂掉重启后,所有从库同时发起全量同步,主库 fork 多个子进程 → 内存/CPU 打满。解决:级联复制(树形拓扑,从库再挂从库),或增大 backlog。
六、哨兵 Sentinel
Sentinel 解决的是"主库挂了谁来顶上"的问题,它本身不存数据,只做监控与故障转移。
6.1 三个定时任务
① 每 10s:向主/从库发 INFO,发现从库列表、主从关系
② 每 2s :通过 __sentinel__:hello 频道广播自己的信息(发现其他哨兵、交换节点看法)
③ 每 1s :向所有节点和其他哨兵发 PING,做心跳检测
6.2 故障转移完整流程
① 主观下线(sdown)
某个哨兵 PING 主库超过 down-after-milliseconds 无响应 → 标记 sdown
② 客观下线(odown)
该哨兵向其他哨兵发 SENTINEL is-master-down-by-addr 询问
同意票数 ≥ quorum → 标记 odown(真正的"确认挂了")
③ 选举 Leader 哨兵(Raft 算法)
多个哨兵间选出一个 Leader 来执行故障转移
每个哨兵只有一票,先到先得,需获得多数票
④ Leader 选新主库(按优先级排序)
┌──────────────────────────────────────────┐
│ 1. 过滤掉已下线/断连的从库 │
│ 2. 选 slave-priority 最小的(可手动配权重) │
│ 3. priority 相同 → 选复制 offset 最大的 │
│ (数据最全) │
│ 4. 还相同 → 选 runid 最小的(字典序) │
└──────────────────────────────────────────┘
⑤ 执行切换
SLAVEOF no one → 新主库
其他从库 SLAVEOF 新主库
旧主库恢复后自动降级为新主库的从库
通过 pub/sub 发布 +switch-master 事件通知客户端
6.3 最小配置
conf
# sentinel.conf(三个哨兵节点配置相同)
port 26379
sentinel monitor mymaster 10.0.0.1 6379 2 # 2 = quorum
sentinel down-after-milliseconds mymaster 5000
sentinel parallel-syncs mymaster 1 # 切换后同时同步的从库数(1 避免压垮新主)
sentinel failover-timeout mymaster 180000
sentinel auth-pass mymaster xxx
bash
redis-sentinel /path/to/sentinel.conf
# 或 redis-server /path/to/sentinel.conf --sentinel
# 查看状态
redis-cli -p 26379 SENTINEL masters
redis-cli -p 26379 SENTINEL get-master-addr-by-name mymaster
redis-cli -p 26379 SENTINEL ckquorum mymaster # 检查 quorum 是否健康
quorum 怎么定? 哨兵节点数 N(建议 3 或 5,奇数),quorum = N/2 + 1。注意区分两个"多数": - quorum:判定 odown 需要的票数; - 选举 Leader 需要 max(quorum, N/2+1) 票,且每个哨兵每轮只能投一票。
6.4 脑裂问题(面试高频)
网络分区发生
┌─────────────┐ ✂ ┌─────────────┐
│ 旧主库 M │ │ 从库 S1 S2 │
│ 客户端还在写│ │ 哨兵选出新主│
└─────────────┘ └─────────────┘
分区期间旧主库接收到的数据 → 恢复后被全量清空 → 数据永久丢失!
Redis 的防线:
ini
min-replicas-to-write 1 # 主库必须有至少 1 个可用从库才接受写
min-replicas-max-lag 10 # 且从库延迟(最后一次 ACK)不超过 10 秒
这个配置是拿可用性换一致性:不满足条件时旧主库直接拒绝写,客户端会报错,但不会丢数据。需要根据业务权衡。
6.5 客户端怎么感知切换
java
// Spring Boot + Lettuce(推荐)
@Configuration
public class RedisSentinelConfig {
@Bean
public RedisConnectionFactory connectionFactory() {
RedisSentinelConfiguration cfg = new RedisSentinelConfiguration()
.master("mymaster")
.sentinel("10.0.0.1", 26379)
.sentinel("10.0.0.2", 26379)
.sentinel("10.0.0.3", 26379);
cfg.setPassword(RedisPassword.of("xxx"));
return new LettuceConnectionFactory(cfg);
}
}
客户端订阅 Sentinel 的
+switch-master频道获取新主地址。Jedis 需要显式配置哨兵集合;Lettuce 默认支持拓扑刷新 ,生产建议开启:ClusterTopologyRefreshOptions/SocketOptions(哨兵模式 Lettuce 会自动重连)。
七、Redis Cluster
Sentinel 解决高可用,但所有节点存的都是全量数据 ,单机内存上限就是集群上限。Cluster 用分片解决容量与并发问题。
7.1 哈希槽:16384 是怎么来的
key → CRC16(key) mod 16384 → 槽位 → 节点
┌────────────────────────────────────────────────┐
│ 0-5460 → Node A (Master) + Node A' (Slave)│
│ 5461-10922 → Node B (Master) + Node B' (Slave)│
│ 10923-16383 → Node C (Master) + Node C' (Slave)│
└────────────────────────────────────────────────┘
每个节点持有一部分槽,槽可以在节点间迁移(扩容/缩容)
为什么是 16384(2¹⁴)而不是 65536(2¹⁶)? 作者 antirez 的原话:
- 集群节点数不会超过 1000 个,16384 个槽够分了;
- 心跳消息需要携带节点槽位Bitmap ,16384 个槽的 bitmap 是 2KB(16384/8/1024),65536 就是 8KB,每秒的 ping/pong 消息会占用太多带宽;
- 槽数少,bitmap 压缩率高(槽位稀疏时)。
7.2 集群通信:Gossip 协议
| 消息 | 作用 |
|---|---|
MEET |
邀请节点加入集群 |
PING |
每秒随机向 5 个节点发 PING,携带自身 + 已知节点状态 |
PONG |
响应 PING/MEET |
FAIL |
广播某节点已下线 |
PUBLISH |
广播 pub/sub 消息 |
故障检测 :节点 A 标记 B 为 PFAIL(疑似下线),当半数以上持有槽的主节点 都认为 B 下线时,标记为 FAIL 并广播。
7.3 请求重定向:MOVED 与 ASK
bash
$ redis-cli -c -p 7001 GET user:1001
# 假设 user:1001 的槽在 7002
(error) MOVED 9842 10.0.0.2:7002
# 客户端应更新本地槽映射表,后续直接访问 7002
# 迁移中的临时重定向
(error) ASK 9842 10.0.0.2:7002
# ASK 是一次性的,客户端不更新映射表(因为槽还在迁移中)
正确流程 :ASK → 客户端先发 ASKING 命令 → 再发原命令。
7.4 搭建与运维
bash
# 快速创建 3 主 3 从(Redis 5.0+ 一条命令)
redis-cli --cluster create \
10.0.0.1:7001 10.0.0.2:7002 10.0.0.3:7003 \
10.0.0.1:7004 10.0.0.2:7005 10.0.0.3:7006 \
--cluster-replicas 1
# 检查集群
redis-cli --cluster check 10.0.0.1:7001
redis-cli --cluster info 10.0.0.1:7001
# 扩容:加入新节点 + reshard
redis-cli --cluster add-node 10.0.0.4:7007 10.0.0.1:7001
redis-cli --cluster reshard 10.0.0.1:7001 --cluster-from all --cluster-to <new-node-id> --cluster-slots 4096
# 缩容:先迁移槽,再删节点
redis-cli --cluster del-node 10.0.0.1:7001 <node-id>
# 客户端查看
redis-cli -c -p 7001 CLUSTER NODES
redis-cli -c -p 7001 CLUSTER SLOTS
ini
# redis.conf 集群必配
cluster-enabled yes
cluster-config-file nodes-7001.conf
cluster-node-timeout 15000 # 15s 无响应判定为 FAIL
cluster-require-full-coverage no # 建议 no:部分槽不可用时其余仍可服务
cluster-replica-validity-factor 10
cluster-migration-barrier 1 # 主节点至少有 1 个从库才允许从库迁移
7.5 Cluster 的限制(面试常问)
| 限制 | 说明 | 规避 |
|---|---|---|
| 不支持多 key 跨槽操作 | MSET 多 key 在不同槽会报错 |
用 Hash Tag :{user1001}.name / {user1001}.age 强制同槽 |
| 不支持多数据库 | 只有 db0 | - |
| 事务/Lua 跨槽受限 | 所有 key 必须在同一槽 | 同上 Hash Tag |
| 批量操作需分组 | MGET 要按槽分组再发 |
客户端(Lettuce/Jedis)已自动处理 |
bash
# Hash Tag 示例:{} 内的内容才参与 hash
MSET {order:1001}.id 1001 {order:1001}.amount 99.9 # ✅ 同槽
MSET order:1001:id 1001 order:1001:amount 99.9 # ❌ 可能跨槽
八、分布式锁
这是 Redis 面试最高频、最能看出深浅的考点。按演进顺序讲。
8.1 演进史:五个版本
❌ V1:SETNX + EXPIRE(非原子)
java
// 错误示范
if (setnx("lock", "1") == 1) {
expire("lock", 30); // 👈 如果这行执行前宕机 → 死锁,锁永不释放
try { doBiz(); } finally { del("lock"); }
}
❌ V2:SETNX + 把过期时间写进 value(依赖客户端时钟)
java
// 用时间戳做值,判断时依赖所有客户端时钟一致 ------ 分布式下不可靠
✅ V3:SET key value NX PX(原子加锁,2.6.12+)
bash
SET lock:order:1001 <uuid> NX PX 30000
# NX = 仅当不存在才设置(原子)
# PX 30000 = 30 秒过期(原子)
# 一条命令解决 V1 的原子性问题
java
String lockKey = "lock:order:" + orderId;
String requestId = UUID.randomUUID().toString(); // 唯一标识,防止误删别人的锁
Boolean ok = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, Duration.ofSeconds(30));
if (!Boolean.TRUE.equals(ok)) {
throw new BizException("系统繁忙,请稍后重试");
}
✅ V4:释放锁必须原子(Lua 脚本)
java
// 错误示范:两步判断 + 删除,中间可能被插入
if (requestId.equals(redis.get(lockKey))) { // 判断时还是自己的锁
// 👇 此时锁恰好过期,且被别的线程获取,这里就会删掉别人的锁!
redis.del(lockKey);
}
正确做法:Lua 脚本保证判断 + 删除的原子性
java
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>();
static {
UNLOCK_SCRIPT.setScriptText(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end");
UNLOCK_SCRIPT.setResultType(Long.class);
}
public void unlock(String lockKey, String requestId) {
redisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(lockKey), requestId);
}
✅ V5:Redisson ------ 自动续期(看门狗)
V3/V4 还有个致命问题:业务执行时间 > 锁过期时间,锁提前释放,导致并发。
Redisson 的 Watchdog(看门狗) 机制:如果没显式指定 leaseTime,默认锁 30 秒,每 10 秒(1/3 周期)自动续期,业务不结束锁就不会过期;业务完成或线程挂掉(看门狗线程随之停止)后自动释放。
java
// Maven: org.redisson:redisson-spring-boot-starter
@Resource
private RedissonClient redissonClient;
public void createOrder(OrderDTO dto) {
RLock lock = redissonClient.getLock("lock:order:" + dto.getUserId());
try {
// 默认 30s + 自动续期;waitTime=10s 最多等 10 秒
boolean locked = lock.tryLock(10, TimeUnit.SECONDS);
if (!locked) {
throw new BizException("操作过于频繁");
}
doCreateOrder(dto);
} finally {
// 必须判断:只有当前线程持有锁才释放
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
底层 Lua(加锁,支持可重入):
lua
-- KEYS[1]=锁名 ARGV[1]=租约时间 ARGV[2]=线程标识(uuid:threadId)
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hincrby', KEYS[1], ARGV[2], 1); -- Hash 结构:field=线程标识,value=重入次数
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1); -- 可重入:计数 +1
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]); -- 返回剩余时间,客户端订阅后自旋等待
8.2 主从架构下的锁失效(Redlock 的由来)
① 客户端向 Master 加锁成功
② Master 还没把锁同步给 Slave 就宕机
③ Sentinel 把 Slave 提升为新 Master
④ 新 Master 上没有这把锁 → 另一个客户端也能加锁成功 → 两个客户端同时持锁 ❌
Redlock 算法(antirez 提出) :部署 N 个(通常 5 个)独立的 Redis Master(无主从、无集群),加锁时:
1. 获取当前毫秒级时间 T1
2. 依次向 5 个实例发加锁请求(相同的 key/value,超时时间远小于锁 TTL)
3. 统计成功数:≥ N/2 + 1(即 3 个)且 总耗时 < 锁有效时间 → 加锁成功
4. 锁的真实有效时间 = TTL - (T2 - T1) - 时钟漂移
5. 释放锁时向所有实例发删除请求(包括失败的)
java
// Redisson 的 Redlock(3.19+ 已标记为 deprecated,官方建议用普通锁或改用其他方案)
RLock lock1 = redisson1.getLock("myLock");
RLock lock2 = redisson2.getLock("myLock");
RLock lock3 = redisson3.getLock("myLock");
RLock redLock = redissonClient.getMultiLock(lock1, lock2, lock3); // 联锁
redLock.lock();
重要观点(面试加分项) :分布式系统专家 Martin Kleppmann 曾撰文批评 Redlock(主要质疑点:依赖时钟、GC STW/网络延迟导致锁失效后不自知)。antirez 也做了回应。业界共识是:Redlock 性能差、运维复杂,且并不能 100% 保证安全 。 实际落地建议 : - 99% 的业务用单 Redis 实例 + Redisson 看门狗 就够了; - 要求极高时,不要用 Redis 做锁,用 etcd / ZooKeeper / 数据库悲观锁 ; - 更根本的思路:把业务改造成无锁(乐观锁 + CAS / 幂等 + 状态机) ,比如扣库存直接
UPDATE stock SET num=num-1 WHERE id=? AND num>0,靠数据库的行锁保证原子性。
8.3 分布式锁的正确使用姿势(总结)
| 要点 | 做法 |
|---|---|
| 加锁原子性 | SET key value NX PX ttl 或 Redisson |
| 释放锁原子性 | Lua 脚本(判断 value 再删) |
| 防误删 | value 用 UUID/线程标识 |
| 防业务超时 | 看门狗自动续期 |
| 可重入 | Hash 结构存重入计数 |
| 释放前判断 | lock.isHeldByCurrentThread() |
| 加锁必须有超时 | 避免永久阻塞线程 |
| 兜底 | 数据库层面仍需做 CAS / 唯一约束做最后防线 |
九、典型使用场景与代码
场景 1:缓存(最基础)
java
public Order getOrder(Long orderId) {
String key = "order:" + orderId;
Order order = (Order) redisTemplate.opsForValue().get(key);
if (order != null) {
return order;
}
order = orderMapper.selectById(orderId);
if (order != null) {
// 随机 TTL 防雪崩
long ttl = 30 + ThreadLocalRandom.current().nextInt(10);
redisTemplate.opsForValue().set(key, order, Duration.ofMinutes(ttl));
} else {
// 缓存空值防穿透(短 TTL)
redisTemplate.opsForValue().set(key, NullValue.INSTANCE, Duration.ofMinutes(2));
}
return order;
}
场景 2:分布式限流(Lua 原子实现滑动窗口)
lua
-- rate_limit.lua:ZSet 实现滑动窗口
-- KEYS[1]=限流key ARGV[1]=窗口(ms) ARGV[2]=阈值 ARGV[3]=当前时间(ms)
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window) -- 清理窗口外记录
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now .. '-' .. math.random(1000000))
redis.call('PEXPIRE', key, window)
return 1 -- 放行
end
return 0 -- 限流
java
@Around("@annotation(rateLimit)")
public Object doRateLimit(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
String key = "rl:" + rateLimit.key() + ":" + getUserId();
Long pass = redisTemplate.execute(rateLimitScript,
List.of(key),
String.valueOf(rateLimit.windowMs()),
String.valueOf(rateLimit.limit()),
String.valueOf(System.currentTimeMillis()));
if (!Long.valueOf(1L).equals(pass)) {
throw new BizException("操作过于频繁,请稍后再试");
}
return pjp.proceed();
}
场景 3:排行榜(ZSet)
java
// 更新分数
redisTemplate.opsForZSet().add("rank:game:1", playerId, score);
// 查 Top 10(带分数)
Set<ZSetOperations.TypedTuple<String>> top10 = redisTemplate.opsForZSet()
.reverseRangeWithScores("rank:game:1", 0, 9);
// 查某玩家的排名(从 0 开始,+1 变成名次)
Long rank = redisTemplate.opsForZSet().reverseRank("rank:game:1", playerId);
// 查分数区间(如"青铜段位")
Set<String> bronze = redisTemplate.opsForZSet().rangeByScore("rank:game:1", 0, 1000);
大排行榜优化:Top N 用 Redis,全量排名定期落库(避免 ZSet 过大)。
场景 4:延迟队列(订单 30 分钟未支付自动取消)
java
// 方案 A:ZSet + 定时扫(简单可靠)
// 下单时
redisTemplate.opsForZSet().add("delay:order",
orderId.toString(), System.currentTimeMillis() + 30 * 60 * 1000);
// 每 10 秒扫描到期任务
@Scheduled(fixedDelay = 10_000)
public void scanDelayQueue() {
long now = System.currentTimeMillis();
Set<ZSetOperations.TypedTuple<String>> tasks = redisTemplate.opsForZSet()
.rangeByScoreWithScores("delay:order", 0, now, 0, 100);
for (var task : tasks) {
// 用 Lua 保证"取出即删除"的原子性,避免重复消费
if (removeIfExists("delay:order", task.getValue())) {
orderService.cancelIfUnpaid(Long.valueOf(task.getValue()));
}
}
}
// 方案 B:Redisson 的 RDelayedQueue(基于 ZSet + 阻塞队列封装)
RQueue<OrderTask> queue = redissonClient.getQueue("order:delay");
RDelayedQueue<OrderTask> delayedQueue = redissonClient.getDelayedQueue(queue);
delayedQueue.offer(new OrderTask(orderId), 30, TimeUnit.MINUTES);
// 消费端阻塞take
OrderTask task = queue.take();
场景 5:分布式 Session
java
// Spring Session + Redis(零侵入)
// pom: spring-session-data-redis
@EnableRedisWebSession // 或 @EnableRedisHttpSession
public class SessionConfig { }
yaml
spring:
session:
store-type: redis
redis:
namespace: myapp:session
timeout: 30m
场景 6:分布式 ID 生成器
java
// 利用 Redis 原子自增,配合时间戳,每天一个 key
public long nextId(String bizTag) {
String key = "id:" + bizTag + ":" + LocalDate.now();
Long seq = redisTemplate.opsForValue().increment(key);
if (seq == 1) {
redisTemplate.expire(key, Duration.ofDays(2));
}
return Long.parseLong(LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)) * 100000 + seq;
}
// 更高性能:一次取一段(号段模式),本地内存发号,减少 Redis 交互
场景 7:秒杀扣库存(Lua + 数据库兜底)
lua
-- seckill.lua
-- KEYS[1]=库存key ARGV[1]=购买数量
local stock = tonumber(redis.call('get', KEYS[1]))
if stock == nil then return -1 end -- 库存未预热
if stock < tonumber(ARGV[1]) then return 0 end -- 库存不足
redis.call('decrby', KEYS[1], ARGV[1])
return 1 -- 扣减成功
java
// 关键:Redis 只做"前置拦截",最终一致性由数据库保证
public boolean seckill(Long productId, int num) {
Long r = redisTemplate.execute(seckillScript, List.of("stock:" + productId), String.valueOf(num));
if (r == null || r == 0) return false;
// 数据库 CAS 兜底(幂等 + 防超卖最后一道防线)
int updated = productMapper.deductStock(productId, num);
if (updated == 0) {
// 回滚 Redis(极少发生)
redisTemplate.opsForValue().increment("stock:" + productId, num);
return false;
}
return true;
}
sql
-- 数据库兜底 SQL:靠行锁保证原子
UPDATE t_product SET stock = stock - #{num}
WHERE id = #{productId} AND stock >= #{num};
场景 8:布隆过滤器防穿透
java
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("order:bloom");
bloomFilter.tryInit(10_000_000L, 0.01); // 预期 1000 万元素,1% 误判
// 数据写入 DB 时同步加入
bloomFilter.add(orderId);
// 查询前
if (!bloomFilter.contains(orderId)) {
return null; // 一定不存在
}
场景 9:消息队列(Stream,Redis 5.0+)
java
// 生产
MapRecord<String, String, String> record = StreamRecords.newRecord()
.ofMap(Map.of("orderId", "1001", "amount", "99.9"))
.withStreamKey("stream:order");
redisTemplate.opsForStream().add(record);
// 消费(消费组 + ACK,支持消息重试、PEL 未确认列表)
@Bean
public StreamMessageListenerContainer<String, MapRecord<String,String,String>> container(
RedisConnectionFactory factory) {
var options = StreamMessageListenerContainer.StreamMessageListenerContainerOptions
.builder().pollTimeout(Duration.ofSeconds(5)).build();
var container = StreamMessageListenerContainer.create(factory, options);
container.receive(Consumer.from("group1", "consumer1"),
StreamOffset.create("stream:order", ReadOffset.lastConsumed()),
msg -> {
try {
handle(msg.getValue());
redisTemplate.opsForStream().acknowledge("stream:order", "group1", msg.getId());
} catch (Exception e) {
// 不 ACK,消息留在 PEL,等待重新投递
}
});
container.start();
return container;
}
Stream vs List vs PubSub: -
List:无 ACK,消息丢失风险; -PubSub:无持久化,断连即丢 (只适合实时通知); -Stream:有持久化 + 消费组 + ACK + PEL,是 Redis 里唯一"认真"的 MQ(但仍不如 Kafka/RocketMQ,重要业务别用 Redis 当 MQ)。
十、缓存三兄弟与一致性
10.1 三大问题与解法
| 问题 | 根因 | 解法 |
|---|---|---|
| 穿透 | 查不存在的 key | 布隆过滤器 / 缓存空值 / 参数校验 / 限流 |
| 击穿 | 单个热点 key 过期 | 互斥锁重建 / 逻辑过期(永不过期 + 异步刷新) |
| 雪崩 | 大量 key 同时过期 | TTL 加随机值 / 多级缓存 / 熔断降级 / 高可用集群 |
10.2 一致性方案(三选一,按业务严格度)
方案 A(90% 业务):Cache Aside
读:命中返回;未命中读 DB → 写缓存
写:先更新 DB → 再删缓存(不是更新缓存!)
兜底:TTL
方案 B(较高要求):延迟双删
更新 DB → 删缓存 → sleep 500ms → 再删缓存
方案 C(最高要求):binlog 订阅 + MQ 重试删除
业务只改 DB → Canal 监听 binlog → 投递 MQ → 消费者删缓存(失败自动重试)
10.3 大 Key / 热 Key 治理
bash
# 发现
redis-cli --bigkeys # 找大 key
redis-cli --hotkeys # 找热 key(需先设 maxmemory-policy 为 lfu)
redis-cli INFO commandstats # 命令耗时统计
redis-cli SLOWLOG GET 10 # 慢日志(默认 10ms)
CONFIG SET slowlog-log-slower-than 10000 # 微秒
CONFIG SET slowlog-max-len 1000
大 Key 治理 : - 拆分:user:orders:1001 这个大 Hash → 按月份拆成 user:orders:1001:202611; - 压缩:value 用 protobuf / gzip(权衡 CPU); - 删除:用 UNLINK(异步)而不是 DEL。
热 Key 治理 : - 本地缓存(Caffeine)扛住热点; - 读写分离,多副本分摊读; - 客户端缓存(Redis 6 的 Client-side caching,CLIENT TRACKING ON); - key 打散 :hot:product:1001 → hot:product:1001:1..10 分片,代价是更新要改多次。
十一、面试高频 30 问快答
基础原理 1. Redis 为什么快? 内存 + 单线程无锁 + IO 多路复用 + 高效数据结构 + 6.0 多线程 IO。 2. Redis 是单线程吗? 命令执行单线程;4.0 起有 lazy free 线程,6.0 起有 IO 多线程,持久化靠 fork。 3. Redis 6 多线程会不会有并发安全问题? 不会,多线程只做网络读写与协议解析,命令执行仍单线程。 4. IO 多路复用是什么? 一个线程通过 epoll 监听多个 socket,有事件才处理,避免阻塞等待。 5. SDS 相比 C 字符串好在哪? O(1) 取长度、二进制安全、空间预分配减少重分配、杜绝缓冲区溢出。
数据结构 6. 跳表为什么不用红黑树? 实现简单、范围查询高效、无需 rebalance。 7. ZSet 为什么用两个结构? 跳表支持范围查询,哈希表支持 O(1) 单点查询,空间换时间。 8. ziplist 为什么被 listpack 取代? ziplist 的 prevlen 会引发连锁更新(最坏 O(N²)),listpack 只记录自身长度。 9. 渐进式 rehash 是什么? 扩容时不是一次搬迁,而是每次操作顺带搬一个桶,分摊阻塞时间。 10. embstr 和 raw 的区别? ≤44 字节用 embstr(一次内存分配,redisObject 与 SDS 连续),更大用 raw(两次分配)。
持久化 11. RDB 和 AOF 区别? RDB 是二进制快照(加载快、体积小、丢数据多);AOF 是命令追加(丢数据少、体积大、加载慢)。 12. bgsave 会阻塞吗? fork 瞬间会短暂阻塞(内存越大越明显),之后主进程正常服务。 13. AOF 重写会不会阻塞? 用 bgrewriteaof,fork 子进程做,主进程的写命令同时写 AOF 缓冲和重写缓冲。 14. AOF 丢了会怎样? 数据还没 fsync 到磁盘就宕机,everysec 模式最多丢 1 秒。 15. 混合持久化是什么? AOF 重写时前半段用 RDB 格式,兼顾加载速度与数据安全。
内存 16. 过期 key 一定会被立即删除吗? 不会。惰性删除 + 定期采样删除,可能长期留存。 17. LRU 是标准的吗? 不是,是近似 LRU(随机采样 N 个淘汰最久未访问的)。 18. LFU 怎么实现的? 24 位拆成 16 位访问计数(对数增长)+ 8 位衰减时间。 19. 内存满了会怎样? 按 maxmemory-policy 淘汰;如果 noeviction,写请求返回 OOM 错误。
主从与集群 20. 主从第一次同步流程? PSYNC ? -1 → FULLRESYNC → RDB 全量 → replication buffer 增量 → 命令传播。 21. 断线后为什么有时全量有时增量? 看从库 offset 是否还在 replication backlog 环形缓冲区内。 22. repl-backlog-size 为什么重要? 太小会导致频繁全量同步,拖垮主库。 23. Sentinel 怎么判断主库真挂了? 主观下线 sdown(单个哨兵判定)→ 客观下线 odown(≥quorum 个哨兵同意)。 24. Sentinel 怎么选新主? 过滤不健康的 → priority 最小 → offset 最大(数据最全)→ runid 最小。 25. 脑裂怎么防? min-replicas-to-write + min-replicas-max-lag,主库无可用从库时拒绝写。 26. Cluster 为什么是 16384 个槽? 节点数 ≤1000 够用;心跳 bitmap 2KB,65536 会占 8KB 带宽。 27. MOVED 和 ASK 区别? MOVED 是永久迁移(客户端更新映射表),ASK 是迁移中的临时重定向(不更新)。 28. Cluster 如何实现多 key 操作? 用 Hash Tag {xxx} 强制分到同一槽。
分布式锁 29. Redis 分布式锁怎么实现? SET key value NX PX 原子加锁 + Lua 判断 value 后删除 + 看门狗续期(Redisson)。 30. Redlock 有什么问题? 依赖时钟、需要处理 GC/网络停顿、性能与运维成本高,且存在安全性质疑;生产建议优先用单实例 + Redisson,或改用 etcd/ZK,或业务层改为无锁 CAS。
十二、生产上线 Checklist
配置 - maxmemory 设为物理内存的 60%-70% - maxmemory-policy:缓存场景用 allkeys-lru - 关闭 THP:echo never > /sys/kernel/mm/transparent_hugepage/enabled - repl-backlog-size 调大到 128MB+ - 开启慢日志:slowlog-log-slower-than 10000 - 设置密码 + rename-command 禁用 KEYS/FLUSHALL/CONFIG - 绑定内网 IP,protected-mode yes
高可用 - 至少 1 主 2 从 + 3 个 Sentinel(奇数部署,跨机架) - min-replicas-to-write 1 + min-replicas-max-lag 10(按业务权衡) - 集群模式开启 cluster-require-full-coverage no - 客户端开启拓扑刷新与重连(Lettuce 默认支持)
使用规范 - key 命名统一:业务:对象:ID[:属性],禁止过长(< 100 字节) - 所有 key 必须有 TTL - 禁止 KEYS,用 SCAN;禁止 DEL bigkey,用 UNLINK - 禁止在生产执行 FLUSHALL(改用 FLUSHALL ASYNC) - 批量操作用 pipeline / MGET,减少 RTT - Lua 脚本必须做参数校验,避免死循环(Redis 有 lua-time-limit 5000)
监控告警 - used_memory / mem_fragmentation_ratio > 1.5 - 命中率:keyspace_hits / (hits + misses),< 90% 需排查 - 连接数 connected_clients(接近 maxclients 告警) - 主从 offset 差值持续增长 - 慢日志条数、bigkey 定期巡检
写在最后
Redis 看起来简单,但"简单"背后全是权衡:
- 快是因为牺牲了持久化实时性(换来异步落盘);
- 单线程是牺牲了多核利用率(换来无锁简单性);
- Cluster 分片是牺牲了跨槽事务(换来水平扩展);
- 分布式锁 本质上是不完美的------Redis 从设计上就不是一个强一致的协调服务。
真正理解 Redis 的标志,不是背下多少命令,而是在每一个使用它的场景里,都能说清楚"我放弃了什么,以及我为此准备了什么兜底"。
参考资料
- Redis 官方文档:https://redis.io/docs/latest/
- 《Redis 设计与实现》黄健宏
- 《Redis 开发与运维》付磊、张益军
- antirez 关于 Redlock 的争论:http://antirez.com/news/101
- Martin Kleppmann《How to do distributed locking》
如果这篇文章对你有帮助,欢迎点赞收藏。你在生产中踩过的 Redis 坑,也欢迎在评论区分享,我会持续补充到本文。