Redis 深入浅出

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 的优势:

  1. O 1 获取长度 :直接读 len,C字符串要遍历到\0
  2. 防止缓冲区溢出 :拼接前检查 free,不够就扩容
  3. 减少内存重分配 :空间预分配 + 惰性释放
    • 修改后长度 < 1MB:分配 2倍 空间
    • 修改后长度 ≥ 1MB:额外分配 1MB 空间
  4. 二进制安全 :不依赖\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 同时使用两种数据结构:

  1. hashtable:member → score 的映射,O 1 查分数
  2. 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:nuser: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 │
                   └─────────┘
核心功能
  1. 监控(Monitoring):持续检查master和slave是否正常
  2. 通知(Notification):故障时通知管理员/应用
  3. 自动故障转移(Automatic failover):master挂了,选举新master
  4. 配置提供者:客户端从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/事务
集群限制
  1. 不支持跨节点事务(除非用hash tag)
  2. 不支持跨节点多key操作(MSET/MGET等)
  3. 数据库只能用0号(不支持多db)
  4. 批量操作需确保同槽
  5. 复制只支持一层(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 脚本的优势
  1. 原子性:整个脚本作为一个整体执行,中间不会插入其他命令
  2. 减少网络开销:多个命令一次网络往返
  3. 可复用:脚本缓存后用 SHA1 调用
  4. 逻辑复杂:可以写条件判断、循环等逻辑
注意事项
  • 脚本执行期间阻塞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。

危害:

  1. 内存不均:集群中大key所在节点内存压力大
  2. 网络阻塞:读取大key耗时久,占用带宽
  3. 删除卡顿:DEL 大key会阻塞主线程(4.0前)
  4. 过期删除卡顿:大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 多应用共享会话

相关推荐
傻啦嘿哟1 小时前
英雄联盟皮肤爬虫:爬取全皮肤价格与特效,做比价工具
java·c++·爬虫
码匠许师傅2 小时前
【C++ 面试真题】24. 聊聊 C++ 的时间日期处理
java·c++·面试
闻道且行之2 小时前
图片处理助手|泊松融合原理 + C++ 工程实现,seamlessClone 三模式一次讲透
数据库·c++·人工智能·opencv
mqiqe3 小时前
AgentScope Java 2.0 Agent 状态存储(AgentStateStore)深度解析:构建可恢复、可扩展的智能体运行时
java·运维·网络
zhifou1234563 小时前
java 17升级安装
java·开发语言
用户3721574261353 小时前
如何使用 Java 将 Markdown 转换为 PDF(含自定义设置)
java
夏炳辉.3 小时前
PostgreSQL 高可用集群核心配置参数全解:从原生流复制到 Patroni 企业级方案
数据库·postgresql
用户3721574261354 小时前
如何使用 Java 在 Word 文档中添加和删除水印:分步指南
java
努力的小雨4 小时前
KES 开启 SSL 前,证书、端口和客户端要一起验
数据库
DevOps老兵4 小时前
AI全栈知识07:向量数据库 - Milvus/Chroma实战
数据库·ai·milvus