本文整合了关于 Redis 的全面知识,从高性能的原因到底层数据结构,从持久化到高可用部署,再到高级数据结构和实践场景,循序渐进地带你掌握这个全球最受欢迎的键值存储系统。
引言:Redis 是什么?
Redis(Remote Dictionary Server)是一个开源的高性能键值对存储系统。它完全基于内存,支持多种数据结构,并提供持久化、高可用、分布式等能力。因其极致的性能和丰富的数据类型,Redis 已成为后端架构中不可或缺的组件,被广泛应用于缓存、会话管理、实时统计、消息队列等场景。
第一部分:Redis 为什么这么快?
1.1 基于内存
Redis 将所有数据存放在内存中,读写操作在内存总线上完成,延迟通常在微秒级别,避免了磁盘 I/O 的巨大开销。
1.2 单线程模型 + I/O 多路复用
- 单线程:避免了多线程上下文切换和锁竞争的开销,同时保证了命令的原子性。
- I/O 多路复用:使用
epoll等机制,一个线程可以同时处理成千上万个客户端连接,当某个连接可读/可写时再处理,避免了阻塞等待。
1.3 高效的数据结构
Redis 为每种数据类型设计了专门的内存布局和算法(如 SDS、跳表等),确保操作的时间复杂度最优。
1.4 命令执行的原子性
单线程保证了每个命令的执行不会被其他命令打断,天然适合实现计数器、分布式锁等场景。
1.5 瓶颈在哪里?
- 网络 I/O:数据在网络传输中的延迟可能成为瓶颈,Redis 6.0+ 引入了 I/O 多线程来并行处理网络读写。
- 内存大小:受物理内存限制,大 Key 会阻塞主线程。
- 慢操作:如
KEYS、HGETALL等 O(N) 命令,以及集中过期、AOF 重写等。
第二部分:Redis 核心数据结构详解
2.1 对外数据类型与底层编码
| 类型 | 底层编码(可能) | 说明 |
|---|---|---|
| String | int, embstr, raw | 整数、短字符串、长字符串 |
| Hash | ziplist / listpack, hashtable | 小数据量用紧凑编码 |
| List | quicklist | 双向链表 + ziplist |
| Set | intset, hashtable | 整数集合或哈希表 |
| ZSet | ziplist / listpack, skiplist | 小数据量用紧凑编码,大数据用跳表 |
2.2 八大底层数据结构
2.2.1 SDS(简单动态字符串)
- 结构:
len(已用长度)、free(空闲)、buf(字节数组) - 优势:O(1) 获取长度、杜绝缓冲区溢出、空间预分配、惰性释放、二进制安全
2.2.2 双向链表(LinkedList)
- 双向无环链表,有头尾指针,用于早期 List 实现
2.2.3 压缩列表(ZipList)
- 一块连续内存,包含
zlbytes、zltail、zllen、多个entry、zlend - 特点:内存紧凑,支持双向遍历,但存在“连锁更新”问题
- 插入/删除:需要内存重分配和数据搬移,可能导致后续节点
prevlen扩展,引发连锁更新
2.2.4 哈希表(Dict)
- 结构:
dict持有两个dictht(ht[0] 和 ht[1]),用于渐进式 rehash - 冲突解决:链地址法(头插)
- 渐进式 rehash:将 rehash 分摊到每次增删改查中
- 头插法原因:利用时间局部性,最新插入的数据最可能被访问
2.2.5 整数集合(Intset)
- 结构:
encoding、length、contents[] - 升级机制:当插入更大范围的整数时,自动升级编码,节省内存
2.2.6 跳表(SkipList)
- 结构:
zskiplist(头尾指针、长度、最大层数)和zskiplistNode(成员、分值、后退指针、层数组) - 层数组:每个层有前进指针和跨度(
span),用于快速定位和排名计算 - 查找:从最高层开始,横向比较,无法前进时下降一层
- 插入:先查找并记录每层前驱节点(
update[])和排名(rank[]),然后随机生成层高,更新指针和跨度 - 删除:利用
update[]调整指针和跨度 - 范围查找:找到起点后沿 L1 层遍历
- 跨度(
span):用于计算排名,插入时利用rank数组精确拆分
2.2.7 快速列表(QuickList)
- 替代 LinkedList + ZipList,每个节点是一个 ziplist,用双向指针连接,平衡内存与性能
2.2.8 列表包(Listpack)
- 旨在替代 ZipList,每个 entry 自带长度信息,彻底解决连锁更新问题(Redis 7.0+)
第三部分:持久化机制
3.1 RDB(快照)
- 原理:fork 子进程,利用写时复制(Copy-on-Write)生成数据快照,写入临时文件后原子替换
- 触发:
SAVE(阻塞)、BGSAVE(后台)、自动根据save配置 - 优点:文件紧凑,恢复快,适合备份
- 缺点:可能丢失最后一次快照后的数据
3.2 AOF(追加文件)
- 原理:将写命令追加到 AOF 文件,通过
fsync策略控制落盘 - 策略:
always(最安全)、everysec(默认)、no - 重写机制:子进程读取内存生成最小命令集,期间父进程将增量命令写入重写缓冲区,通过管道传给子进程,最后原子替换
- Redis 7.0 多部分 AOF:将重写期间的数据分离为 BASE、INCR、HISTORY 文件,避免内存翻倍
3.3 混合持久化(Redis 4.0+)
- AOF 重写时生成 RDB 格式的 BASE 文件,后续增量用 AOF 格式,兼顾重启速度与数据完整性
3.4 数据恢复顺序
- 优先加载 AOF(若开启),否则加载 RDB
第四部分:高可用部署架构
4.1 主从复制
- 全量同步:主节点生成 RDB 发送给从,同时缓冲新写命令
- 增量同步:通过
repl_backlog_buffer和偏移量实现部分重同步 - 无盘复制:主节点直接通过网络发送 RDB 给从
4.2 哨兵模式(Sentinel)
- 架构:独立进程集群,监控主从,自动故障转移
- 三个定时任务:
- 每 1 秒 PING 所有节点
- 每 2 秒通过
__sentinel__:hello频道交换信息 - 每 10 秒 INFO 获取拓扑
- 主观下线:单个哨兵判定节点不可达
- 客观下线:多个哨兵(
quorum)确认主节点不可达 - 领导者选举:基于 Raft 算法,候选人获得半数+1票且 ≥ quorum 成为 leader
- 新主节点选择:优先级 → 复制偏移量 → 运行 ID
- 脑裂问题:可通过
min-slaves-to-write和min-slaves-max-lag缓解
4.3 集群模式(Cluster)
- 哈希槽:固定 16384 个槽,每个键通过
CRC16(key) & 16383映射到槽 - 节点通信:Gossip 协议(PING、PONG、MEET、FAIL),端口为服务端口 +10000
- 客户端路由:智能客户端缓存槽位映射,直接连接目标节点;收到
MOVED更新缓存,收到ASK临时重定向 - 故障转移:从节点根据复制偏移量发起选举,多数主节点投票后晋升
- 数据迁移:
- 准备:标记源节点
MIGRATING、目标节点IMPORTING - 迁移:使用
MIGRATE命令逐个/批量迁移 key(原子性) - 迁移中请求:若 key 还在源节点则处理,否则返回
ASK引导客户端去目标节点(需先发ASKING) - 迁移完成:广播槽位归属变更
- 准备:标记源节点
- 为什么不支持多 DB?集群仅 DB0,因为跨槽事务复杂
4.4 部署方式对比
| 模式 | 数据容量 | 写扩展 | 高可用 | 复杂度 | 场景 |
|---|---|---|---|---|---|
| 单机 | 单机内存 | 无 | 无 | 低 | 开发测试、小缓存 |
| 主从 | 单机内存 | 读可扩展 | 手动切换 | 中低 | 读写分离、热备 |
| 哨兵 | 单机内存 | 读可扩展 | 自动故障转移 | 中 | 中小规模生产 |
| 集群 | 水平扩展 | 水平扩展 | 内置 | 高 | 海量数据、高并发 |
| 云托管 | 弹性 | 视规格 | 云厂商保障 | 低 | 快速迭代 |
第五部分:内存管理与淘汰策略
5.1 内存淘汰策略(8 种)
| 策略 | 范围 | 算法 |
|---|---|---|
| noeviction | – | 不淘汰,写满报错 |
| allkeys-lru | 所有键 | 近似 LRU |
| allkeys-lfu | 所有键 | 近似 LFU(4.0+) |
| allkeys-random | 所有键 | 随机 |
| volatile-lru | 仅过期键 | 近似 LRU |
| volatile-lfu | 仅过期键 | 近似 LFU |
| volatile-random | 仅过期键 | 随机 |
| volatile-ttl | 仅过期键 | 淘汰最快过期的 |
- 近似 LRU:采样
maxmemory-samples(默认 5)个键,淘汰其中最旧的 - 近似 LFU:维护访问频次,动态衰减
5.2 过期键删除策略
- 惰性删除:访问时检查是否过期
- 定期删除:每秒执行多次,随机抽样过期键删除
第六部分:高级数据结构与应用
6.1 Bitmap(位图)
- 基于 String 的位操作,每个 bit 表示一个状态
- 应用:用户签到、日活统计(1 亿用户仅 12MB)
6.2 HyperLogLog
- 原理:通过哈希值的前缀零个数(ρ)估算基数,分桶(16384)存储最大 ρ,最后调和平均
- 内存:固定 12KB,误差 0.81%
- 命令:
PFADD、PFCOUNT、PFMERGE - 应用:UV 统计、留存率、页面独立访客
6.3 Bloom Filter(布隆过滤器)
- 原理:多个哈希函数映射到位数组,查询时检查所有位;若有一位为 0 则一定不存在,全为 1 则可能存在
- 特点:假阳性(可调节)、无法删除、空间效率高
- 参数:期望元素数 n、误判率 p → 确定位数组大小 m 和哈希函数个数 k
- 应用:缓存穿透防护、爬虫去重、推荐系统去重
6.4 Count-Min Sketch(CMS)
- 原理:d × w 矩阵,每个元素经 d 个哈希映射到对应列,计数器 +1;查询时取最小值
- 特点:可估计频率,误差受宽度 w 控制,置信度受深度 d 控制
- 应用:热点识别(heavy hitters)、流量监控、查询优化
6.5 GEO(地理位置)
- 原理:将经纬度用 GeoHash 编码为 52 位整数,作为 Sorted Set 的 score 存储
- 附近的人:
GEORADIUS先计算 9 宫格范围,取出候选集,再精确过滤距离 - 注意事项:使用只读版本
GEORADIUS_RO避免主节点压力
6.6 Streams
- 消息队列:支持消费者组、ACK、消息持久化
- 特性:可追溯历史消息,支持阻塞读取
第七部分:实践与优化
7.1 常见应用场景
- 缓存:String + 淘汰策略
- 分布式会话:Hash 存储用户信息
- 计数器:
INCR命令 - 排行榜:ZSet
- 消息队列:List 或 Streams
- 分布式锁:
SET NX EX - 社交关系:Set 交集、并集
- 地理位置:GEO
- 实时统计:HyperLogLog、Bitmap
- 限流:ZSet 记录时间戳
7.2 大 Key 问题
- 危害:阻塞主线程、内存不均、迁移超时
- 发现:
--bigkeys扫描、MEMORY USAGE - 处理:拆分、压缩、异步删除(
UNLINK)
7.3 慢查询
- 配置
slowlog-log-slower-than,使用SLOWLOG GET分析 - 避免 O(N) 命令,控制集合大小
7.4 Pipeline 与事务
- Pipeline:批量发送命令,减少网络 RTT
- 事务(MULTI/EXEC):保证原子性,但不支持回滚
- Lua 脚本:实现复杂原子操作
7.5 安全配置
- 设置密码
requirepass - 重命名危险命令
rename-command FLUSHALL "" - 绑定内网 IP,禁用保护模式
7.6 监控指标
INFO命令:内存、命中率、复制延迟、持久化状态- 第三方工具:Redis-exporter + Prometheus + Grafana
第八部分:分布式锁深度解析
分布式锁是协调分布式系统中多个进程对共享资源互斥访问的重要工具。Redis 因其高性能和原子操作,成为实现分布式锁的热门选择。本节将从基础实现到进阶方案,全面剖析 Redis 分布式锁的原理与实践。
8.1 分布式锁的核心要求
一个完备的分布式锁需要满足以下四个条件:
| 要求 | 说明 |
|---|---|
| 互斥性 | 任意时刻,只有一个客户端能持有锁 |
| 无死锁 | 即使持有锁的客户端崩溃,锁也能最终被释放 |
| 解铃还须系铃人 | 客户端只能释放自己持有的锁,不能误删别人的锁 |
| 高可用 | 锁服务本身应具备高可用性,避免单点故障 |
8.2 基于 Redis 的简易实现
加锁
最基础的方式是使用 Redis 的 SET 命令配合 NX 和 PX 选项:
SET resource_name my_random_value NX PX 30000
NX:保证键不存在时才设置,实现互斥。PX 30000:设置 30 秒自动过期,防止死锁。my_random_value:全局唯一的随机字符串(如 UUID),用于安全解锁。
解锁
解锁需要保证“检查+删除”的原子性,通常使用 Lua 脚本:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
该脚本确保只有锁的持有者才能删除锁。
优缺点
- 优点:实现简单,性能高。
- 缺点:不可重入;无自动续期,业务超时可能导致锁提前释放;无法阻塞等待。
8.3 Redisson 的高级实现
Redisson 是 Redis 官方推荐的 Java 客户端,它提供了功能丰富的分布式锁实现,解决了简易方案中的诸多问题。
8.3.1 可重入锁
Redisson 使用 Hash 数据结构存储锁信息:
- Key:锁名称
- Field:
UUID + 线程ID(唯一标识客户端线程) - Value:重入次数
加锁的核心 Lua 脚本如下:
-- KEYS[1] 锁名称
-- ARGV[1] 锁过期时间
-- ARGV[2] 线程标识 (UUID:线程ID)
-- 锁不存在,直接加锁
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 锁已存在且是当前线程持有,重入次数+1
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 其他线程持有,返回剩余过期时间
return redis.call('pttl', KEYS[1]);
解锁时,Lua 脚本会递减重入次数,减到 0 时删除锁。
8.3.2 看门狗(Watchdog)自动续期
如果业务执行时间超过锁的过期时间,锁自动释放会导致并发问题。Redisson 的看门狗机制解决了这个问题。
- 触发条件:使用默认的
lock()方法且未指定leaseTime时,看门狗启动。 - 工作原理:
- 加锁成功后,锁的默认过期时间设为 30 秒(可配置)。
- Redisson 客户端内部维护一个共享的后台线程(定时线程池),为所有启用了看门狗的锁提供服务。
- 对于每个需要续期的锁,后台线程会每隔 10 秒(过期时间的 1/3)执行一次续期 Lua 脚本,检查锁是否仍被当前线程持有,若是则将其过期时间重新设置为 30 秒。
- 当业务完成调用
unlock()时,会同时取消该锁的续期任务。
关键点:看门狗是客户端进程内的线程,如果持有锁的进程突然崩溃,看门狗线程也随之消失,续期停止,锁自然在 30 秒后被 Redis 删除,从而避免死锁。续期任务是由一个共享线程池统一调度的,并非每个锁一个独立线程。
8.3.3 阻塞等待与发布订阅
当锁被其他线程持有时,Redisson 的 tryLock() 可以阻塞等待。它通过发布订阅机制实现:
- 当前线程订阅锁释放的频道(channel)。
- 若获取锁失败,线程进入等待状态。
- 持有锁的线程释放锁时,会向该频道发送一条释放消息。
- 等待线程收到通知后,重新尝试获取锁。
这种方式避免了频繁自旋,减少了 Redis 压力。
8.4 现有方案的问题与挑战
尽管 Redisson 已经相当完善,但在极端场景下仍存在一些理论挑战:
1. 主从异步复制导致的锁失效
在 Redis 主从架构中,主节点异步复制数据到从节点。如果客户端 A 在主节点加锁成功,但数据尚未同步到从节点时主节点宕机,从节点升为新主节点,此时新主节点上没有锁信息,客户端 B 可以成功加锁,导致互斥失效。
2. 时钟跳跃问题
如果某个 Redis 节点的系统时间发生大幅跳跃,锁的过期时间计算可能出现偏差,导致锁提前释放或延迟释放。
3. 长业务续期风暴
对于执行时间极长的业务,看门狗会不断续期。虽然单次续期开销很小,但在大规模集群中,大量续期任务可能对调度线程造成压力。
8.5 进阶解决方案
针对上述问题,社区和云厂商提出了多种改进方案。
8.5.1 Redlock 算法
Redlock 是 Redis 作者提出的更安全的分布式锁算法,旨在解决主从切换导致的锁失效问题。
- 原理:客户端向多个(通常为 5 个)独立的 Redis 主节点依次请求锁,必须满足两个条件才算加锁成功:
- 在大多数节点(N/2+1)上成功加锁。
- 加锁的总耗时小于锁的过期时间。
- 释放锁:客户端向所有节点发送解锁请求。
争议与局限性:
- Redlock 依赖各节点的时钟同步,对时钟跳跃敏感。
- 实现复杂,性能开销较大。
- 业界(包括许多云厂商)并不推荐在生产环境中使用 Redlock,日常场景下正确配置主从+哨兵的高可用架构已足够。
8.5.2 Tair 的 CAS/CAD 命令
Tair(阿里云自研的 KV 数据库,兼容 Redis 协议)提供了增强命令 CAS(Compare and Set) 和 CAD(Compare and Delete),从架构和原子性层面优化了分布式锁。
- CAS 命令:原子性地比较并设置值,同时维护一个版本号。每次成功修改都会递增版本号。这天然适合实现乐观锁和可重入锁。
- CAD 命令:原子性地比较并删除,用于安全释放锁。
- 半同步复制:Tair 支持配置在数据写入到备节点后才返回成功,从架构上避免了主从切换导致的锁失效。
相比传统方案,Tair 的 CAS/CAD 具有以下优势:
| 特性 | 传统 SET NX + Lua | Tair CAS/CAD |
|---|---|---|
| 原子性保证 | 依赖 Lua 脚本 | 原生命令内置 |
| 版本控制 | 需自行实现 | 内置版本号,支持乐观锁 |
| 主从切换安全性 | 可能失效 | 半同步复制可避免 |
| 实现复杂度 | 中等 | 低 |
8.5.3 极端场景的终极方案:IO Fence
对于金融等对一致性要求极高的场景,即使锁本身安全,仍可能因 GC 或网络延迟导致旧锁持有者继续写入资源。IO Fence(IO 隔离)通过让锁服务生成严格递增的令牌(token),后端资源在收到写请求时校验令牌,拒绝来自旧锁持有者的过期请求,从根本上杜绝了锁失效的风险。
8.6 实践建议
- 大多数业务场景:使用 Redisson 等成熟客户端即可,它提供了可重入、自动续期、阻塞等待等特性,满足 99% 的需求。
- 对一致性要求极高:可考虑使用 Tair 等企业级 KV 存储,利用其 CAS/CAD 命令和半同步复制获得更强的保障。
- 避免过早优化:Redlock 实现复杂且存在争议,除非你完全理解其代价并有极端需求,否则不推荐。
- 结合业务兜底:无论采用何种锁机制,业务层都应设计幂等性和最终一致性兜底,以应对极端故障。
第九部分:常见问题与盲区补充
9.1 为什么是 16384 个槽位?
- 心跳包中用位图表示槽位,16384 位 = 2KB,65535 位 = 8KB,带宽节省显著
- 集群规模通常 ≤ 1000 节点,16384 足够均匀分配
- 与 CRC16 取余时可用位运算优化
9.2 CRC16 与位运算
HASH_SLOT = CRC16(key) & 16383(因为 16384 = 2^14,取模等价于与 16383 按位与)
9.3 集群限制
- 只支持 DB0
- 多键操作(如
MSET)需所有键在同一槽(可使用哈希标签{user:123}) - 事务/Lua 脚本也受槽限制
9.4 与 Memcached 对比
| 特性 | Redis | Memcached |
|---|---|---|
| 数据类型 | 丰富 | 仅字符串 |
| 持久化 | 支持 | 不支持 |
| 高可用 | 主从、哨兵、集群 | 需客户端实现 |
| 性能 | 极高 | 极高 |
9.5 RESP 协议
Redis 序列化协议(RESP),简单高效,支持多种数据类型。
结语
Redis 之所以成为现代架构的基石,不仅因为它快,更因为它设计精巧、功能丰富。从单线程到集群,从 SDS 到跳表,从 RDB 到 Streams,每一处设计都体现了对性能与实用的极致追求。希望本文能帮助你构建完整的 Redis 知识体系,并在实际项目中游刃有余。