Redis 深入浅出
一、Redis 是什么:定位与本质
1.1 核心定义
Redis(Re mote Di ctionary S erver)是一个基于内存 的、开源 的、高性能 的键值对(Key-Value)存储系统。
它常被称为"数据结构服务器",因为它的 Value 不只是字符串,而是支持多种复杂数据结构。
1.2核心性能原理
| 因素 | 说明 | 量级影响 |
|---|---|---|
| 纯内存操作 | 数据存在内存中,读写纳秒级 | 内存比磁盘快 10万倍 |
| 单线程模型 | 避免线程切换开销和锁竞争 | 6.0前网络+命令单线程;6.0后网络IO多线程,命令执行仍单线程 |
| IO多路复用 | epoll/kqueue 实现高并发连接 | 单实例可支撑 10万+ QPS |
| 高效数据结构 | 精心设计的底层编码 | 跳表、压缩列表、哈希表等 |
| C语言实现 | 贴近操作系统,无VM开销 | 相比Java减少GC停顿 |
关键认知 :Redis 的"单线程"指的是命令执行是单线程的,网络IO在6.0后是多线程的。单线程之所以快,是因为内存操作本身极快,瓶颈不在CPU而在网络和内存带宽。
1.3 Redis vs 传统数据库 vs Memcached
| 维度 | Redis | MySQL | Memcached |
|---|---|---|---|
| 存储介质 | 内存(可持久化) | 磁盘 | 纯内存 |
| 数据结构 | 丰富(8种+) | 表结构 | 仅字符串 |
| 持久化 | RDB + AOF | 原生支持 | 不支持 |
| 集群 | 原生Cluster | 主从/分库分表 | 客户端分片 |
| 事务 | 弱事务(乐观) | ACID | 不支持 |
| 适用场景 | 缓存、计数、排行榜 | 核心业务数据 | 纯缓存 |
二、Redis 数据结构:从使用到底层原理
2.1 总览:8种核心数据类型
| 类型 | 底层编码(可能) | 典型场景 |
|---|---|---|
| String | int / embstr / raw | 缓存、计数器、分布式锁 |
| Hash | ziplist / hashtable | 对象存储、购物车 |
| List | quicklist(ziplist+linkedlist) | 消息队列、最新列表 |
| Set | intset / hashtable | 标签、共同好友、去重 |
| ZSet | ziplist / skiplist+hashtable | 排行榜、延时队列 |
| Bitmap | String(位操作) | 签到、用户在线状态 |
| HyperLogLog | String(概率算法) | UV统计 |
| Stream | radix tree + listpack | 消息队列(5.0+) |
2.2 String 字符串
基本命令
bash
SET key value [EX seconds] [PX milliseconds] [NX|XX]
GET key
MSET key1 value1 key2 value2 # 批量设置,原子
MGET key1 key2 # 批量获取
INCR key # 原子自增 +1
DECR key
INCRBY key 100 # 原子增加指定值
SETEX key 60 value # 设置并带过期时间
SETNX key value # key不存在才设置(分布式锁核心)
底层编码
Redis 的 String 有三种内部编码,会自动转换:
int → 当值是64位有符号整数(-9223372036854775808 ~ 9223372036854775807)
embstr → 长度 ≤ 44字节的字符串(RedisObject + SDS 连续分配,一次内存分配)
raw → 长度 > 44字节的字符串(RedisObject 和 SDS 分开分配)
为什么是44字节? RedisObject 占16字节,SDS头占3字节,加上结尾
\0共1字节,jemalloc分配器的小对象分配单元是64字节。64 - 16 - 3 - 1 = 44。这样 embstr 能在一个64字节的内存块中放下,缓存友好。
SDS原理
Redis 没有用C语言的原生字符串(char*),而是自己实现了 SDS:
c
struct sdshdr {
int len; // 已使用长度
int free; // 剩余空间
char buf[]; // 实际数据
};
SDS 的优势:
- O 1 获取长度 :直接读
len,C字符串要遍历到\0 - 防止缓冲区溢出 :拼接前检查
free,不够就扩容 - 减少内存重分配 :空间预分配 + 惰性释放
- 修改后长度 < 1MB:分配 2倍 空间
- 修改后长度 ≥ 1MB:额外分配 1MB 空间
- 二进制安全 :不依赖
\0判断结束,可以存图片、序列化对象等二进制数据
Java 代码示例
java
// 使用 Jedis
Jedis jedis = new Jedis "localhost", 6379 ;
// 1. 基本缓存
jedis.set "user:1001", JSON.toJSONString user ;
String json = jedis.get "user:1001" ;
User user = JSON.parseObject json, User.class ;
// 2. 原子计数器(防超卖)
Long stock = jedis.decr "product:stock:999" ;
if stock < 0 {
jedis.incr "product:stock:999" ; // 回滚
throw new RuntimeException "库存不足" ;
}
// 3. 分布式锁
String lockKey = "lock:order:1001";
String token = UUID.randomUUID .toString ;
// SET NX EX = 原子加锁,NX确保互斥,EX防止死锁
String result = jedis.set lockKey, token, SetParams.setParams .nx .ex 30 ;
if "OK".equals result {
try {
// 执行业务逻辑
processOrder ;
} finally {
// 释放锁:必须校验token是自己的,用Lua保证原子性
String script = "if redis.call 'get', KEYS[1] == ARGV[1] " +
"then return redis.call 'del', KEYS[1] else return 0 end";
jedis.eval script, Collections.singletonList lockKey ,
Collections.singletonList token ;
}
}
2.3 Hash 哈希
基本命令
bash
HSET user:1001 name "张三" age 25 email "zhangsan@example.com"
HGET user:1001 name
HGETALL user:1001 # 获取所有字段
HDEL user:1001 age # 删除字段
HINCRBY user:1001 age 1 # 字段原子自增
HEXISTS user:1001 name # 判断字段是否存在
HLEN user:1001 # 字段数量
底层编码
- ziplist(压缩列表) :当哈希元素个数 <
hash-max-ziplist-entries(默认512)且所有值长度 <hash-max-ziplist-value(默认64字节)时使用 - hashtable(哈希表):超过阈值自动转换
ziplist 原理:连续内存块,用长度前缀编码存储每个元素,节省内存但读写是O n 。小数据量时n很小,性能可接受且内存极省。
适用场景对比
方案A:String + JSON
SET user:1001 '{"name":"张三","age":25}'
缺点:修改一个字段要读-改-写整个对象
方案B:Hash
HSET user:1001 name "张三" age 25
优点:可以单独修改某个字段,节省网络流量
缺点:不支持嵌套结构,过期只能对整个key设置
2.4 List 列表
基本命令
bash
LPUSH queue:task "task1" "task2" # 左侧插入(头插)
RPUSH queue:task "task3" # 右侧插入(尾插)
LPOP queue:task # 左侧弹出
RPOP queue:task # 右侧弹出
LRANGE queue:task 0 -1 # 范围查询(0到-1=全部)
LLEN queue:task # 长度
BRPOP queue:task 30 # 阻塞弹出(30秒超时),实现消息队列
底层编码:quicklist
Redis 3.2 之后,List 的底层实现统一为 quicklist,它是 ziplist 和 linkedlist 的混合体:
quicklist = 双向链表,每个节点是一个 ziplist
[ziplist 128个元素 ] <-> [ziplist 128个元素 ] <-> [ziplist 128个元素 ]
设计权衡:
- 纯 linkedlist:每个节点都有 prev/next 指针(16字节),小数据浪费内存,且内存碎片多
- 纯 ziplist:插入删除需要搬移内存,大数据量性能差
- quicklist:兼顾两者,每个 ziplist 存
list-max-ziplist-size(默认-2,即8KB)个元素
应用:简单消息队列
java
// 生产者
jedis.lpush "queue:email", emailJson ;
// 消费者(阻塞模式,避免空轮询消耗CPU)
while true {
// BRPOP 会阻塞等待,0表示无限等待
List<String> result = jedis.brpop 0, "queue:email" ;
String emailJson = result.get 1 ;
sendEmail emailJson ;
}
List 做队列的缺点:不支持多消费者组、消息确认机制、消息持久化保障。专业场景用 Kafka/RabbitMQ,或 Redis 5.0 的 Stream。
2.5 Set 集合
基本命令
bash
SADD tags:article:1001 "Java" "Redis" "架构"
SMEMBERS tags:article:1001 # 所有元素
SISMEMBER tags:article:1001 "Java" # 是否存在
SREM tags:article:1001 "架构" # 删除
SCARD tags:article:1001 # 元素个数
SINTER set1 set2 # 交集
SUNION set1 set2 # 并集
SDIFF set1 set2 # 差集
SRANDMEMBER tags 3 # 随机取3个(抽奖)
SPOP tags # 随机弹出一个
底层编码
- intset :元素全是整数且数量 <
set-max-intset-entries(默认512) - hashtable:超过阈值转换
经典场景:共同好友
java
// 用户A的好友
jedis.sadd "friends:A", "B", "C", "D", "E" ;
// 用户B的好友
jedis.sadd "friends:B", "C", "D", "F", "G" ;
// 共同好友(交集)
Set<String> commonFriends = jedis.sinter "friends:A", "friends:B" ;
// 结果: {C, D}
// 我可能认识的人(B有但A没有的 = 差集)
Set<String> mayKnow = jedis.sdiff "friends:B", "friends:A" ;
// 结果: {F, G}
2.6 ZSet有序集合
基本命令
bash
ZADD rank:game 1000 "player1" 2000 "player2" 500 "player3"
ZRANGE rank:game 0 -1 WITHSCORES # 按分数升序,带分数
ZREVRANGE rank:game 0 9 WITHSCORES # 按分数降序,Top10
ZRANK rank:game "player1" # 排名(从0开始,升序)
ZREVRANK rank:game "player1" # 排名(降序)
ZSCORE rank:game "player1" # 分数
ZINCRBY rank:game 100 "player1" # 增加分数
ZRANGEBYSCORE rank:game 1000 2000 # 分数范围查询
ZCOUNT rank:game 1000 2000 # 分数范围内元素数
ZREM rank:game "player1" # 删除
底层编码:跳表 + 哈希表
ZSet 同时使用两种数据结构:
- hashtable:member → score 的映射,O 1 查分数
- skiplist:按 score 排序的有序结构,支持范围查询和排名
为什么用跳表而不是红黑树?
| 对比项 | 跳表 | 红黑树 |
|---|---|---|
| 范围查询 | 简单(找到起点后沿链表遍历) | 复杂(需要中序遍历+边界判断) |
| 实现难度 | 简单 | 复杂(旋转、变色) |
| 内存占用 | 略多(多层指针) | 较少 |
| 并发修改 | 更容易(局部修改) | 复杂(旋转影响大) |
| 排名查询 | 天然支持(维护span) | 需要额外维护子树大小 |
跳表原理图解:
Level 3: HEAD ───────────────────────────────────→ TAIL
Level 2: HEAD ────→ 节点B ───────────→ 节点D ───→ TAIL
Level 1: HEAD → 节点A → 节点B → 节点C → 节点D → 节点E → TAIL
Level 0: HEAD → A → B → C → D → E → F → G → H → I → J → TAIL 最底层全量
- 每个节点随机决定层数(概率1/4升一层)
- 查找从最高层开始,类似二分查找,平均 O log n
- 每个节点的层间指针维护
span(跨越的节点数),用于计算排名
应用1:排行榜
java
// 玩家得分更新
jedis.zincrby "rank:game:2026", 100, "player:1001" ;
// 获取Top10(降序)
Set<Tuple> top10 = jedis.zrevrangeWithScores "rank:game:2026", 0, 9 ;
for Tuple tuple : top10 {
System.out.println "玩家: " + tuple.getElement +
" 分数: " + tuple.getScore ;
}
// 获取某玩家排名(从1开始)
Long rank = jedis.zrevrank "rank:game:2026", "player:1001" ;
System.out.println "排名: " + rank + 1 ;
应用2:延时队列
java
// 生产延时任务:score = 执行时间戳
long executeTime = System.currentTimeMillis + 30000; // 30秒后执行
jedis.zadd "delay:queue", executeTime, "task:1001" ;
// 消费者轮询(实际可用定时任务)
while true {
long now = System.currentTimeMillis ;
// 取出到期的任务(0到now)
Set<Tuple> tasks = jedis.zrangeByScoreWithScores "delay:queue", 0, now, 0, 1 ;
for Tuple task : tasks {
String taskId = task.getElement ;
// 用ZREM竞争删除,只有一个消费者能成功(防重复消费)
Long removed = jedis.zrem "delay:queue", taskId ;
if removed > 0 {
processTask taskId ; // 执行任务
}
}
Thread.sleep 1000 ;
}
2.7 Bitmap 位图
Bitmap 本质是 String,但按位操作,非常节省内存。
bash
SETBIT sign:user:1001:202608 1 1 # 第1天签到(设为1)
GETBIT sign:user:1001:202608 1 # 查第1天是否签到
BITCOUNT sign:user:1001:202608 # 统计签到天数
BITOP AND result key1 key2 # 位运算(交集)
内存计算 :1亿用户的日活状态,只需 100,000,000 bit = 12.5 MB。
java
// 用户签到(8月第N天)
int dayOfMonth = 18;
jedis.setbit "sign:1001:202608", dayOfMonth, true ;
// 统计本月签到天数
long signDays = jedis.bitcount "sign:1001:202608" ;
// 连续签到判断(BITFIELD 可以批量读多个位)
2.8 HyperLogLog ------ 基数统计
用于不精确的去重计数,标准误差 0.81%,但内存极小(每个key固定12KB)。
bash
PFADD uv:page:home user1 user2 user3 ...
PFCOUNT uv:page:home # 估算UV
PFMERGE dest src1 src2 # 合并
原理:基于概率算法,通过记录元素哈希值的最大前导零数量来估算基数。12KB可以统计 2^64 个元素的基数。适合 UV、PV 等允许误差的大规模统计。
2.9 Stream ------ 专业消息队列
Redis 5.0 引入的专门消息队列类型,支持消费者组、ACK确认、消息持久化。
bash
XADD messages * name "张三" content "你好" # 追加消息,*自动生成ID
XREAD COUNT 10 STREAMS messages 0 # 读取消息
XGROUP CREATE messages group1 0 # 创建消费者组
XREADGROUP GROUP group1 consumer1 COUNT 10 STREAMS messages > # 消费
XACK messages group1 1680000000000-0 # 确认消息
三、Redis 持久化机制
Redis 是内存数据库,但提供两种持久化方式,防止宕机数据丢失。
3.1 RDB
原理
在某个时间点,将内存中的数据全量快照 写入磁盘的 .rdb 文件二进制压缩格式。
触发方式
bash
# 1. 手动触发
SAVE # 阻塞主线程,数据量大时会卡顿
BGSAVE # fork子进程异步保存,不阻塞主线程
# 2. 自动触发(redis.conf)
save 900 1 # 900秒内至少1个key变化
save 300 10 # 300秒内至少10个key变化
save 60 10000 # 60秒内至少10000个key变化
BGSAVE 执行流程
1. Redis 主线程 fork 出子进程
2. 子进程将内存数据写入临时 RDB 文件
(借助 OS 的 Copy-On-Write 机制,父子进程共享内存页,
只有修改的页才会复制,所以 fork 后数据是快照时刻的)
3. 子进程写完后,用新 RDB 替换旧 RDB
4. 通知主线程完成
RDB 优缺点
| 优点 | 缺点 |
|---|---|
| 文件紧凑,适合备份和全量恢复 | 两次快照间的数据会丢失 |
| 恢复速度快(直接加载二进制) | fork 子进程有内存开销,大数据量时fork可能卡顿 |
| 对性能影响小(子进程处理) | 频繁BGSAVE对磁盘IO压力大 |
3.2 AOF
原理
将每条写命令以文本格式追加到 AOF 文件末尾,恢复时重新执行所有命令。
配置
conf
appendonly yes # 开启AOF
appendfilename "appendonly.aof"
appendfsync everysec # 刷盘策略
appendfsync 三种策略
| 策略 | 行为 | 安全性 | 性能 |
|---|---|---|---|
always |
每条命令都 fsync 刷盘 | 最安全,最多丢1条 | 最差 |
everysec |
每秒刷盘一次(默认) | 最多丢1秒数据 | 好(推荐) |
no |
由OS决定何时刷盘 | 可能丢较多 | 最好 |
AOF 重写
AOF 文件会越来越大,需要重写来压缩:
重写前 AOF:
SET count 1
INCR count
INCR count
SET count 100
重写后 AOF:
SET count 100 # 只保留最终状态
重写触发:
bash
BGREWRITEAOF # 手动触发
# 自动触发配置
auto-aof-rewrite-percentage 100 # AOF比上次重写增长100%时触发
auto-aof-rewrite-min-size 64mb # AOF至少64MB才触发
重写原理:fork 子进程,遍历内存数据生成新的AOF命令,期间新写命令同时写入"重写缓冲区"和旧AOF,重写完成后将缓冲区命令追加到新AOF并替换。
3.3 RDB vs AOF 对比与混合持久化
| 维度 | RDB | AOF |
|---|---|---|
| 数据安全性 | 丢两次快照间数据 | 最多丢1秒(everysec) |
| 文件大小 | 小(二进制压缩) | 大(命令文本) |
| 恢复速度 | 快 | 慢(需重放命令) |
| 对性能影响 | fork开销大 | 每命令追加,IO压力持续 |
Redis 4.0+ 混合持久化
conf
aof-use-rdb-preamble yes
AOF 重写时,先把当前数据以 RDB 格式写入 AOF 文件开头,再把后续增量命令以 AOF 格式追加。
[AOF文件] = [RDB二进制快照] + [增量AOF命令]
优势:兼顾 RDB 的恢复速度和 AOF 的数据安全性。
3.4 数据恢复优先级
启动时检查:
1. 是否开启 AOF → 是则优先用 AOF 恢复(数据更完整)
2. 未开启 AOF → 用 RDB 恢复
四、Redis 内存管理与淘汰策略
4.1 内存设置
conf
maxmemory 4gb # 最大内存限制
maxmemory-policy allkeys-lru # 淘汰策略
4.2 8种淘汰策略
| 策略 | 范围 | 算法 | 说明 |
|---|---|---|---|
noeviction |
- | - | 不淘汰,写操作报错(默认) |
allkeys-lru |
所有key | LRU | 淘汰最近最少使用 |
volatile-lru |
设了过期的key | LRU | 淘汰最近最少使用 |
allkeys-lfu |
所有key | LFU | 淘汰最不经常使用(4.0+) |
volatile-lfu |
设了过期的key | LFU | 淘汰最不经常使用 |
allkeys-random |
所有key | 随机 | 随机淘汰 |
volatile-random |
设了过期的key | 随机 | 随机淘汰 |
volatile-ttl |
设了过期的key | TTL | 淘汰最早过期的 |
LRU vs LFU:
- LRU(Least Recently Used):最近最少使用,基于"最近访问时间"
- LFU(Least Frequently Used):最不经常使用,基于"访问频率",更能识别热点数据
例如:某key每天被访问1000次但刚才1小时没访问,LRU可能淘汰它,LFU不会。
4.3 Redis 的近似 LRU 算法
Redis 不是严格 LRU维护双向链表开销大,而是采样近似 LRU:
1. 随机采样 maxmemory-samples(默认5)个key
2. 记录每个key的 idle time(空闲时间,存在RedisObject的lru字段,24位)
3. 淘汰 idle time 最大的那个
4. 淘汰后内存还不够,继续采样淘汰
采样数越大越接近真实LRU,但CPU开销越大。生产环境建议设为10。
4.4 过期键删除策略
Redis 对设置了 TTL 的key,采用惰性删除 + 定期删除组合:
1. 惰性删除
访问key时检查是否过期,过期则删除并返回nil。
- 优点:CPU友好,只在访问时检查
- 缺点:过期key如果不被访问,永远占内存
2. 定期删除
每隔一段时间(默认每秒10次),随机抽取设置了过期时间的key检查:
1. 从过期字典中随机抽取20个key
2. 删除其中已过期的
3. 如果过期比例 > 25%,重复步骤1(继续清理)
4. 单次执行时间不超过25ms,避免阻塞
为什么不用定时删除? 为每个key设定时器,大量key时CPU开销不可接受。
4.5 内存优化技巧
| 技巧 | 说明 |
|---|---|
| 利用小数据编码 | 控制Hash/List/Set/ZSet元素数量,使用ziplist/intset |
| 缩短key名 | u:1001:n 比 user:1001:name 省内存(但可读性差) |
| 共享整数对象 | 0-9999的整数对象Redis会共享,不额外分配内存 |
| 合理设置过期 | 避免无用数据常驻内存 |
| 使用BitMap/HLL | 替代Set做大规模统计,内存节省百倍 |
| 压缩大value | 应用层用Snappy/Gzip压缩后存入 |
五、Redis 高可用架构
5.1 主从复制
作用
- 读写分离:master写,slave读,提升读性能
- 数据备份:slave是master的副本
- 高可用基础:master挂了可以提升slave
复制流程
全量复制(首次/断线重连后无法增量时):
1. slave 发送 PSYNC 命令给 master
2. master 执行 BGSAVE 生成 RDB
3. master 将 RDB 发送给 slave
4. slave 清空旧数据,加载 RDB
5. master 将缓冲区中的增量命令发送给 slave
6. slave 执行增量命令,完成同步
增量复制(网络抖动后重连):
1. slave 发送 PSYNC runid offset
2. master 检查 runid 是否匹配 + offset 是否在复制积压缓冲区
3. 如果在,只发送 offset 之后的增量命令
关键概念
- runid:每个Redis实例启动时生成的唯一ID,重启会变
- 复制偏移量(offset):master和slave各自维护,标识同步进度
- 复制积压缓冲区(repl_backlog):master维护的固定大小环形缓冲区(默认1MB),保存最近的写命令,用于增量复制
配置
conf
# slave 配置
replicaof 192.168.1.100 6379 # 旧版用 slaveof
replica-read-only yes # slave只读(默认)
5.2 哨兵模式
主从复制中,master挂了需要手动切换,Sentinel 实现自动故障转移。
架构
┌─────────────┐
│ Sentinel-1 │
└──────┬──────┘
│ 监控
┌──────┴──────┐
│ Sentinel-2 │ ← 3个Sentinel组成集群(奇数,用于投票)
└──────┬──────┘
│
┌──────┴──────┐
│ Sentinel-3 │
└─────────────┘
│ │
┌─────┘ └─────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ Master │────→│ Slave-1 │
└─────────┘ └─────────┘
│
└──────────→┌─────────┐
│ Slave-2 │
└─────────┘
核心功能
- 监控(Monitoring):持续检查master和slave是否正常
- 通知(Notification):故障时通知管理员/应用
- 自动故障转移(Automatic failover):master挂了,选举新master
- 配置提供者:客户端从Sentinel获取当前master地址
故障转移流程
1. 主观下线(SDOWN):单个Sentinel认为master挂了(PING超时)
2. 客观下线(ODOWN):超过 quorum(法定人数,通常设为2)个Sentinel
都认为master挂了
3. 选举领头Sentinel:所有Sentinel投票选出一个负责故障转移
4. 选举新master:从slave中选最优的
优先级:slave-priority > 复制偏移量最大 > runid最小
5. 将选中的slave提升为master(SLAVEOF NO ONE)
6. 让其他slave指向新master
7. 旧master恢复后变为新master的slave
配置
conf
# sentinel.conf
port 26379
sentinel monitor mymaster 192.168.1.100 6379 2
sentinel down-after-milliseconds mymaster 5000 # 5秒无响应判主观下线
sentinel failover-timeout mymaster 60000 # 故障转移超时
sentinel parallel-syncs mymaster 1 # 同时同步的slave数
5.3 Redis Cluster 集群
Sentinel 解决了高可用,但单master写能力和容量有上限 。Cluster 实现数据分片,水平扩展。
数据分片:哈希槽
Redis Cluster 有 16384个哈希槽,分配给各个节点:
槽位计算:slot = CRC16 key % 16384
节点分布示例:
Node-A: 槽 0 ~ 5460
Node-B: 槽 5461 ~ 10922
Node-C: 槽 10923 ~ 16383
为什么是16384(2^14)个槽? 作者回答:心跳包中节点槽位信息用bitmap传输,16384个bit = 2KB,适中。如果用65536则8KB,浪费。且集群节点数通常不超过1000,16384足够。
集群架构
┌─────────────────────────────────────┐
│ Redis Cluster │
│ │
│ Master-A ←→ Slave-A' │
│ Master-B ←→ Slave-B' │
│ Master-C ←→ Slave-C' │
│ │
│ 节点间通过Gossip协议通信 │
└─────────────────────────────────────┘
关键特性
- 无中心节点:每个节点都知道所有key的槽位分布
- 智能重定向 :客户端访问错误节点时,返回
MOVED重定向响应 - 在线扩缩容:可以动态添加/删除节点,迁移槽位
- 高可用:每个master有slave,master挂了自动故障转移(类似Sentinel内置)
Hash Tag:强制相同槽位
默认不同key可能在不同节点,无法做事务/多key操作。用 {} 包裹相同部分可强制同槽:
key = "user:{1001}:profile" → CRC16 "1001" % 16384
key = "user:{1001}:orders" → CRC16 "1001" % 16384
# 两个key在同一节点,可以用MGET/事务
集群限制
- 不支持跨节点事务(除非用hash tag)
- 不支持跨节点多key操作(MSET/MGET等)
- 数据库只能用0号(不支持多db)
- 批量操作需确保同槽
- 复制只支持一层(slave不能再有slave)
六、Redis 事务与 Lua 脚本
6.1 Redis 事务
基本命令
bash
MULTI # 开启事务
SET key1 value1
INCR key2
EXEC # 执行(所有命令原子执行)
DISCARD # 取消事务
WATCH key1 # 乐观锁:key被修改则事务失败
事务的三个阶段
1. 开启:MULTI
2. 入队:命令不立即执行,放入队列,返回 QUEUED
3. 执行:EXEC 时依次执行队列中的所有命令
Redis 事务的"坑"------不是真正的ACID
| ACID特性 | Redis事务 | 说明 |
|---|---|---|
| 原子性 A | 不保证 | 命令入队时语法错误会全部不执行;但运行时错误(如对String用HSET)不会回滚,错误的跳过,其他继续 |
| 一致性 C | 保证 | 不会损坏数据 |
| 隔离性 I | 保证 | 事务执行期间不会被其他命令打断(单线程) |
| 持久性 D | 取决于持久化配置 | 宕机可能丢数据 |
为什么不支持回滚? Redis作者认为:运行时错误只在开发阶段出现,生产环境不应存在;不支持回滚可以保持Redis简单快速。
乐观锁:WATCH
bash
WATCH balance # 监控balance
# 此时如果其他客户端修改了balance,事务会失败
MULTI
DECRBY balance 100
INCRBY other_account 100
EXEC # 返回nil表示失败(被WATCH的key被修改了)
6.2 Lua 脚本 ------ 更强大的原子操作
Redis 从 2.6 开始支持 Lua 脚本,可以将多个命令打包成一个脚本原子执行,是事务的更优替代。
基本用法
bash
EVAL "return redis.call 'SET', KEYS[1], ARGV[1] " 1 key1 value1
EVALSHA <sha1> 1 key1 value1 # 用脚本缓存的SHA1执行,减少网络传输
SCRIPT LOAD "..." # 加载脚本到缓存
Java 中使用 Lua分布式锁释放
java
String unlockScript =
"if redis.call 'get', KEYS[1] == ARGV[1] " +
"then return redis.call 'del', KEYS[1] " +
"else return 0 end";
// 方式1:每次传脚本
Object result = jedis.eval unlockScript,
Collections.singletonList "lock:order:1001" ,
Collections.singletonList token ;
// 方式2:预加载脚本(生产推荐,减少网络传输)
String sha1 = jedis.scriptLoad unlockScript ;
Object result = jedis.evalsha sha1,
Collections.singletonList "lock:order:1001" ,
Collections.singletonList token ;
Lua 脚本的优势
- 原子性:整个脚本作为一个整体执行,中间不会插入其他命令
- 减少网络开销:多个命令一次网络往返
- 可复用:脚本缓存后用 SHA1 调用
- 逻辑复杂:可以写条件判断、循环等逻辑
注意事项
- 脚本执行期间阻塞Redis,不要写耗时操作
- 不要在脚本中使用随机函数(如
math.random不带seed),主从复制会不一致 - 脚本长度不要太大,默认
lua-time-limit 5000(5秒)
七、Redis 常见问题与解决方案
7.1 缓存穿透
问题 :查询一个数据库和缓存都没有 的数据,每次请求都打到数据库。
恶意攻击时用不存在的key高频请求,压垮数据库。
解决方案:
方案1:缓存空值
查不到也缓存一个空对象(设较短过期时间,如60秒)
缺点:占内存,可能缓存不一致
方案2:布隆过滤器(Bloom Filter)
将所有可能存在的key哈希到布隆过滤器中
请求先查布隆过滤器,不存在直接返回
优点:内存极小(1亿数据只需~12MB)
缺点:有假阳性(说存在的可能不存在,但说不存在的一定不存在)
java
// 布隆过滤器示例(Guava)
BloomFilter<String> bloomFilter = BloomFilter.create
Funnels.stringFunnel StandardCharsets.UTF_8 ,
10000000, // 预期插入1000万
0.01 // 误判率1%
;
// 启动时加载所有用户ID
for Long userId : allUserIds {
bloomFilter.put userId.toString ;
}
// 查询时先过布隆过滤器
if !bloomFilter.mightContain userId.toString {
return null; // 一定不存在
}
// 再查缓存/数据库
7.2 缓存击穿
问题 :某个热点key过期的瞬间,大量并发请求同时打到数据库。
解决方案:
方案1:互斥锁(Mutex Key)
缓存失效时,不是所有请求都查DB
先用 SETNX 抢锁,抢到的查DB并回写缓存
没抢到的等待重试(或返回默认值)
方案2:热点key永不过期
物理上不设过期,逻辑上在value中存过期时间
后台线程定时更新,发现快过期时异步刷新
java
// 互斥锁方案
public String getDataWithLock String key {
String value = jedis.get key ;
if value != null return value;
String lockKey = "lock:" + key;
String token = UUID.randomUUID .toString ;
// 尝试加锁
String locked = jedis.set lockKey, token, SetParams.setParams .nx .ex 10 ;
if "OK".equals locked {
try {
// 双重检查:可能其他线程已经回写了
value = jedis.get key ;
if value != null return value;
// 查数据库
value = db.query key ;
jedis.setex key, 300, value ; // 回写缓存
return value;
} finally {
// 释放锁
jedis.eval UNLOCK_SCRIPT, Collections.singletonList lockKey ,
Collections.singletonList token ;
}
} else {
// 没抢到锁,等待50ms重试
Thread.sleep 50 ;
return getDataWithLock key ; // 递归重试(实际应限制次数)
}
}
7.3 缓存雪崩
问题 :大量key同时过期,或 Redis 实例宕机,导致大量请求同时打到数据库。
解决方案:
方案1:过期时间加随机值
set key, value, 300 + random 0, 60 // 300~360秒,避免同时过期
方案2:多级缓存
本地缓存(Caffeine)+ Redis缓存
Redis挂了还有本地缓存兜底
方案3:高可用架构
Sentinel / Cluster,避免单点故障
方案4:限流降级
数据库前加限流(Sentinel/Hystrix),超过阈值返回降级数据
7.4 缓存与数据库一致性
经典问题:先更新数据库还是先删缓存?
方案对比
| 方案 | 问题 |
|---|---|
| 先更新DB,再删缓存 | 删缓存失败 → 缓存旧数据 |
| 先删缓存,再更新DB | 并发读可能把旧数据写回缓存 |
| 先更新DB,再更新缓存 | 并发写可能导致数据错乱,且写多读少时浪费 |
推荐方案:Cache Aside Pattern旁路缓存
读:先读缓存 → 没有则读DB → 写入缓存
写:先更新DB → 再删除缓存(不是更新缓存)
延迟双删
java
// 1. 先删缓存
jedis.del key ;
// 2. 更新数据库
db.update data ;
// 3. 延迟一段时间(如500ms)后再删一次
Thread.sleep 500 ;
jedis.del key ;
为什么延迟?因为读请求可能在"删缓存后、更新DB前"读到旧DB数据并写入缓存,延迟后再删可以清掉这个旧缓存。延迟时间应大于一次读请求的耗时。
最终一致性方案
通过 MQ + 订阅binlog 异步删除缓存,保证最终一致:
1. 应用更新数据库
2. Canal/Debezium 监听binlog变化
3. 发送消息到MQ
4. 消费者收到消息后删除对应缓存
5. 失败重试 + 告警
7.5 大Key
定义:value 过大(String > 10KB,或集合元素 > 5000个)的key。
危害:
- 内存不均:集群中大key所在节点内存压力大
- 网络阻塞:读取大key耗时久,占用带宽
- 删除卡顿:
DEL大key会阻塞主线程(4.0前) - 过期删除卡顿:大key过期时惰性/定期删除也会卡
解决方案:
1. 拆分大key
- 大String:分块存储,user:1001:profile:part1, part2...
- 大Hash:按字段范围拆分,或按ID取模分桶
- 大List/ZSet:按时间/范围拆分
2. 异步删除(UNLINK)
Redis 4.0+ 用 UNLINK 替代 DEL,后台线程异步释放内存
3. 扫描发现大key
redis-cli --bigkeys # 扫描各类型最大的key
redis-cli --memkeys # 按内存排序(需安装redis-tools)
4. 合理设计数据结构
避免把整个列表/对象塞一个key
7.6 热Key
定义:某个key访问量特别大(如秒杀商品、热门话题),导致所在节点/网卡压力过大。
解决方案:
1. 本地缓存
在应用层用 Caffeine/Guava Cache 缓存热key
减少对Redis的请求(注意一致性)
2. 热key分散(加随机后缀)
将热key复制多份:hotkey_0, hotkey_1, ..., hotkey_9
读的时候随机选一个,写的时候更新所有
(适合读多写极少的场景)
3. 多级缓存架构
Nginx缓存 → 应用本地缓存 → Redis → DB
4. Redis 6.0 客户端缓存(Tracking)
服务端推送key失效通知,客户端本地缓存自动更新
八、Redis 性能优化
8.1 命令层面
| 优化点 | 说明 |
|---|---|
| 避免全量操作 | 不用 KEYS *(阻塞),用 SCAN 迭代 |
| 批量操作 | 用 MGET/MSET 替代多次 GET/SET,减少网络RTT |
| Pipeline | 多条命令一次性发送,减少网络往返(非原子) |
| 避免大value | 大value压缩或拆分 |
| 合理选择数据结构 | 用Hash存对象比String+JSON省内存、省流量 |
Pipeline 示例
java
// 不用Pipeline:1000次GET = 1000次网络往返
for String key : keys {
jedis.get key ; // 每次都等响应
}
// 用Pipeline:1000次GET = 1次网络往返
Pipeline pipeline = jedis.pipelined ;
for String key : keys {
pipeline.get key ; // 只发命令,不等响应
}
List<Object> results = pipeline.syncAndReturnAll ; // 一次性获取所有结果
Pipeline vs 事务 vs Lua:
- Pipeline:只是打包发送,不保证原子性,中间可能被其他命令插入
- 事务:MULTI/EXEC,原子执行,但不支持条件判断
- Lua:原子执行 + 条件逻辑,最灵活
8.2 网络层面
- 客户端使用连接池,避免频繁建连
- 合理设置连接池大小(通常 20-50 足够,不是越大越好)
- 客户端和Redis部署在同机房,降低网络延迟
- 大结果集用游标迭代(SCAN/SSCAN/HSCAN/ZSCAN),避免一次性返回过多
8.3 内存层面
- 设置
maxmemory和合理的淘汰策略 - 开启
activedefrag yes(4.0+),自动内存碎片整理 - 监控
mem_fragmentation_ratio,>1.5说明碎片严重 - 定期做
MEMORY PURGE(需要时)
8.4 持久化层面
- 不要在主节点做 RDB,用从节点做备份
- AOF 用
everysec,平衡安全和性能 - 开启混合持久化
- 避免在高峰时段触发 BGSAVE / BGREWRITEAOF
8.5 架构层面
- 读多写少:主从 + 读写分离
- 写量大/数据量大:Cluster 分片
- 热点数据:本地缓存 + Redis 多级缓存
- 超大规模:加上 CDN / 本地缓存层
九、Redis 高级特性
9.1 发布订阅
bash
SUBSCRIBE channel:news # 订阅频道
PUBLISH channel:news "消息内容" # 发布消息
PSUBSCRIBE channel:* # 模式匹配订阅
注意:Pub/Sub 消息不持久化,订阅者离线会丢消息,不适合做消息队列。适合实时通知、聊天室等。
9.2 GEO 地理位置
底层是 ZSet,用 Geohash 编码。
bash
GEOADD cities 116.40 39.90 "北京" 121.47 31.23 "上海"
GEODIST cities 北京 上海 km # 距离
GEORADIUS cities 116.40 39.90 500 km # 附近500km的城市
9.3 客户端缓存
服务端协助客户端做本地缓存,key变化时主动通知失效,实现"服务端推送式缓存"。
9.4 多线程IO
Redis 6.0 将网络IO (读请求、写响应)改为多线程,但命令执行仍单线程。
conf
io-threads 4 # IO线程数
io-threads-do-reads yes # 读也用多线程(默认只有写用多线程)
适合高并发、大value场景,普通场景提升不明显。
9.5 Redis 7.0 新特性
- Multi-part AOF:AOF拆分为多个文件,重写更高效
- Functions:替代Lua脚本,支持持久化和复制的函数
- Client-eviction:内存不足时可驱逐客户端连接
- Sharded Pub/Sub:集群模式下的分片发布订阅
十、Redis 监控与运维
10.1 关键监控指标
bash
INFO memory # 内存信息
INFO stats # 统计信息(命中率、连接数)
INFO replication # 复制信息
INFO clients # 客户端连接
INFO cpu # CPU使用
| 指标 | 含义 | 告警阈值 |
|---|---|---|
used_memory |
已用内存 | 超过 maxmemory 的 80% |
mem_fragmentation_ratio |
内存碎片率 | >1.5 需关注 |
hit_rate |
缓存命中率 | <90% 需优化 |
connected_clients |
连接数 | 接近 maxclients |
blocked_clients |
阻塞客户端数 | >0 持续需关注 |
instantaneous_ops_per_sec |
QPS | 结合业务判断 |
rejected_connections |
拒绝连接数 | >0 说明连接数超限 |
latest_fork_usec |
fork耗时 | >1000ms 1s 需关注 |
10.2 慢查询
conf
slowlog-log-slower-than 10000 # 超过10ms记录(微秒)
slowlog-max-len 128 # 最多保留128条
bash
SLOWLOG GET 10 # 查看最近10条慢查询
SLOWLOG LEN # 慢查询数量
SLOWLOG RESET # 清空
10.3 常用运维命令
bash
CLIENT LIST # 查看所有客户端连接
CLIENT KILL ip:port # 踢掉某个连接
CONFIG GET maxmemory # 查看配置
CONFIG SET maxmemory 4gb # 动态修改配置(不需重启)
DEBUG OBJECT key # 查看key的编码、引用计数等
MEMORY USAGE key # 查看key占用内存
DBSIZE # key总数
FLUSHDB/FLUSHALL # 清空(生产慎用!)
十一、Redis 典型应用场景总结
| 场景 | 数据结构 | 说明 |
|---|---|---|
| 数据缓存 | String | 用户信息、商品信息、会话Token |
| 分布式锁 | String SETNX | 互斥锁、防重复提交 |
| 计数器 | String INCR | 文章阅读量、点赞数、库存 |
| 排行榜 | ZSet | 游戏排名、热销榜、积分排行 |
| 消息队列 | List/Stream | 简单队列用List,专业用Stream |
| 延时队列 | ZSet | score=执行时间戳 |
| 共同好友/标签 | Set | 交集、并集、差集 |
| 购物车 | Hash | field=商品ID,value=数量 |
| 最新列表 | List | LPUSH + LRANGE 实现最新N条 |
| 签到/在线状态 | Bitmap | 按位存储,极省内存 |
| UV统计 | HyperLogLog | 大规模去重计数 |
| 附近的人 | GEO | 地理位置查询 |
| 限流 | String + Lua | 滑动窗口/令牌桶算法 |
| 分布式Session | Hash/String | 多应用共享会话 |