Redis资料整理

一、基础概念

Redis是一个开源的、基于内存的键值对(Key-Value)存储系统。常被用做缓存中间件,基于redis可以实现数据的快速读取、会话(Session)存储、排行榜、消息队列、分布式锁等能力。

二、Redis支持的数据结构

String(字符串)

最基本的类型,可以用于存储字符串,值得注意的是Redis的基本数据类型里,是没有整型、长整型、浮点型这些数字类型的结构的,这些在redis内部都会当作字符串类型存储下来。但是Redis封装了incre、decre命令,方便开发者进行整形的运算,实际内部会把字符串型数据,转成整数进行运算。

List(列表)

列表类型,Redis 3.2之前,List底层是用ZipList(压缩列表)和LinkedList(双向链表)组成。
ZipList :连续的内存空间,但是它不像数组一样,使用指针和下标可以快速的访问到每个元素(时间复杂度:O(1)),它访问元素时,必须从头/尾遍历到目标位置,所以时间复杂度是O(n),本质原因是因为数组存储的元素类型一样,占用的内存大小都是一样的,可以通过数组指针很方便的计算出中间元素的内存起始,而ZipList由于存储的元素内存大小都不一样,所以必须从表头开始,逐个计算每个元素的偏移量,直到目标位置(第一个元素可能只有1字节,第二个元素可能有100字节,而整型数组,java里每个元素都占4个字节)。且ZipList没有指针指向原数据,因此它占用的内存极低,几乎就是数据本身紧凑编码,适用于数据量小的列表。

在头尾插入数据时,由于ZipList记录了表头/表尾的偏移量,因此直接操作即可,无需移动其他元素,时间复杂度是O(1)。而在中间插入数据时,必须把后面的数据往后挪,因此时间复杂度是O(N)
LinkedList :双向链表,内存不连续,每个元素有指针指向前面的元素和后面的元素,查询时仍然需要从表头/表尾遍历到目标位置,时间复杂度为O(N)

而插入时,只需要修改相应位置的prev/next 指针即可,无需移动其他节点。

但是LinkedList有指针开销,每个元素都需要一个指针指向(16/24字节指针),内存利用率差,且内存不连续,缓存友好性差(CPU的L1、L2、L3哪怕只取4字节,一次也会读附近的Cache Line,通常 64 Byte数据到缓存中,所以连续内存的数据读取容易命中CPU缓存)

Redis 中,list 类型默认会优先使用 ZipList(满足:元素数量 < 512 且每个元素大小 < 64KB),当超出阈值后自动转为 LinkedList。

Redis 3.2之后,使用QuickList代替了ZipList和LinkedList,其实就是它们两个的结合体,QuickList本身是一个双向链表,但链表的每一个节点QuickListNode不是存储单个元素,而是存储一个ZipList,所有元素被分散的存储在多个ZipList中,这些ZipList再通过QuickListNode串联成双向链表。由于使用双向链表存储节点,所以还是有指针的额外内存,但是并不是每个数据都需要指针,多个数据可以合到一个ZipList中,通过ZipList降低了内存消耗,相当于在LinkedList的快速插入,和ZipList的低内存消耗两者间取了个折衷。

值得注意的是,redis的List数据结构中,ZipList存储的元素数量是可以自定义的,可以通过参数list-max-ziplist-size=-8配置,表示每个ZipList存8个元素,我们要取list里的第N个元素,redis内部就会通过这个配置,计算出第N个元素,位于第几个QuickListNode里,然后再通过ZipList的偏移量找到元素

Hash(哈希)

Redis 的 Hash 是一种键值对集合,专门用于存储 "字段 - 值"(field-value)形式的结构化数据,比如存储用户信息(name: "张三", age: 20, email: "xxx")

Hash数据结构,底层用的是ZipList(压缩列表)和HashTable(哈希表)。和List同理,当数据量小时

条件 1:Hash 中字段 - 值对的数量 ≤ 512 个(配置项:hash-max-ziplist-entries);

条件 2:Hash 中每个字段和值的长度 ≤ 64KB(配置项:hash-max-ziplist-value)。

它底层就是用的ZipList(3.2之前之后都是ZipList),通过一个一个偏移量从头往目标位置找,时间复杂度O(N)。

当超过阈值时,会自动转化成HashTable来存储,hashTable其实就是数组+链表(或者红黑树)来实现的了,类似JAVA中的HashMap。

数据量小时,其实ZipList利用内存连续性的O(N)复杂度时间和HashTable的O(1)时间几乎差不多,且省去了很多指针的内存空间,所以Redis内部采用了动态转换的方式。

Set(集合)

Redis 的 Set(集合)是一种无序、唯一的字符串集合,核心特性是自动去重(集合内不会有重复元素),并且支持丰富的集合运算(交集、并集、差集),尤其擅长集合运算、随机取元素、自动去重场景

Redis Set 会根据元素特征自动选择两种底层结构,核心是 intset(整数集合) 和 HashTable(哈希表),切换规则如下:

默认满足以下两个条件时,Set 用 intset 存储:

条件 1:集合中所有元素都是整数(如 1、100、-5,而非字符串 "1");

条件 2:集合中元素数量 ≤ 512 个(配置项:set-max-intset-entries)。

当任意条件不满足时(比如插入字符串元素、元素数超过 512),自动切换为 HashTable存储。

InSet是 Set 专属的紧凑结构,和 ziplist 原理类似但仅适配整数,查询 / 插入时会通过二分查找定位元素,InSet的优势是在内存上,不需要额外的指针开销,即使采用二分查找定位元素,时间复杂度也需要O(logn)。而大数据量时转化为HashTable是为了兼顾效率的,虽然多了很多指针的开销,但是HashTable索引的时间复杂度是O(1)。

ZSet(有序集合)

Redis 的 Zset(Sorted Set,有序集合)是 Redis 中最强大的数据结构之一 ------ 它兼具 Set 的唯一性(元素不重复)和有序性(每个元素关联一个浮点型分数 score,按 score 排序),支持按分数范围、排名范围快速查询,是实现排行榜、延时队列等场景的核心利器。

Redis Zset 同时使用两种结构来存储数据,ziplist(压缩列表) 用于小数据量场景,SkipList+ HashTable(跳表 + 哈希表) 用于大数据量场景。

小数据量的和上面是类似的。我们着重看更普遍的场景,也就是跳表 + 哈希表。

  • SkipList(跳表)是 Zset 有序性的核心,它通过 "多层索引" 将有序查找的复杂度从 O(n) 降到 O(logn),比红黑树实现更简单、插入删除更高效;
  • HashTable是为了弥补 SkipList的短板:SkipList按 member 查找需要遍历,而 HashTable可 O(1) 定位 member 的 score。

使用方式如下:

powershell 复制代码
# 往articleSet新增文章 1001,初始阅读量 100
ZADD articleSet:20260324 100 1001

# 将articleSet里的文章1001,阅读量增加50
ZINCRBY articleSet:20260324 50 1001

# 查询articleSet里阅读量前 3 名的记录(WITHSCORES),按阅读量降序(ZREVRANGE)
ZREVRANGE articleSet:20260324 0 2 WITHSCORES

Bitmap(位图)

严格说,Bitmap并非Redis的独立的数据结构,而是基于 String 类型 实现的 "位操作" 功能 ------ 它将 String 视为连续的二进制位(bit)数组,每个位的取值为 0 或 1,支持按位设置、查询、统计等操作,是极致节省内存的高效结构,尤其适合海量二值状态(是 / 否、有 / 无)的存储与统计。

Redis 的 String 最大支持 512MB 容量,而 1 字节 = 8 位,因此一个 Bitmap 最多可存储 512MB×8=232 个二进制位(约 42.9 亿位)。

利用Bitmap的这种特性,可以用很少的内存存储亿级别的二值(也就是是和否)数据。举个例子,存储 1 亿用户的 "今日是否登录" 状态:

  • 用 Set 存储登录用户 ID:假设每个 ID 是 8 字节,1 亿个需约 800MB 内存;
  • 用 Bitmap:仅需 100000000÷8=12.5MB内存,差距近 64 倍。

类似的场景还有:用户签到、活跃统计、权限标记等

超日志(HyperLogLog)

Redis 的 HyperLogLog(简称 HLL)是一种概率型数据结构,专门用于海量数据的基数统计(即统计集合中不重复元素的个数,比如 UV、独立访客数),核心优势是:用极小的内存(约 12KB)就能统计超大规模的数据(理论上支持 2的64方个元素),代价是统计结果存在约 0.81% 的误差(可接受的近似值)。

往HLL里添加的可以是用户ID,IP地址等,查询出来的是这个HLL里不重复元素的个数,无论添加了多少个元素,一个HLL最多只会占用12KB的大小,这12KB内存被分成 16384 个 "桶",每个桶里只存一个数字 ------ 该桶内所有元素的哈希值中,末尾连续 0 的最大个数。然后利用末尾出现连续N个0的概率,利用统计原理,估算出总值。缺点就是存在0.81% 的误差。适合独立访客数统计、每日活跃用户数统计。

布隆过滤器(Bloom Filter)

Redis 布隆过滤器(Bloom Filter)是基于布隆过滤器算法、由 Redis 实现的高效去重数据结构,核心作用是判断一个元素是否存在于集合中,专门解决海量数据下的去重、缓存穿透等问题,且空间占用极低、查询速度极快

优点:

1、空间效率超高:存储百万 / 亿级数据,仅需几 MB~ 几十 MB 内存;

2、查询 / 插入速度极快:O (k) 时间复杂度(k = 哈希函数个数);

3、无冗余存储:不保存原始数据,仅存哈希标记。

缺点:

1、存在误判:判断「元素存在」可能是假的(实际不存在);

2、无法删除:一旦插入元素,不能单独删除(会影响其他元素);

工作原理:

1、初始化一个全为 0 的二进制位数组;

2、元素插入:通过 k 个独立哈希函数,计算出 k 个位置,将对应位设为 1;

3、元素查询:再次哈希计算 k 个位置,所有位都是 1 → 判定存在,任意一位是 0 → 判定不存在。误判原因:多个元素的哈希位可能重叠,导致不存在的元素刚好命中所有哈希位。

适用场景:

缓存穿透防护(防止恶意查询不存在的数据击穿数据库);

海量数据去重(如用户签到、IP 黑名单、爬虫 URL 去重);

推荐系统去重(不重复推荐已看过的内容)。

总结就是需要用到判断元素在系统中不存在,且有海量数据时,就可以用布隆过滤器。如果要统计总的基数,就用HyperLogLog。它们的插入和查询都极快。

三、Redis大Key

什么是大Key

指占用内存过大或元素数量过多的 Key,通常满足任一条件即视为大 Key:

  • 单个 String 类型 > 10KB
  • 哈希 / 列表 / 集合 / 有序集合类型,元素数量 > 1000 个 或 总内存 > 10MB

大Key的危害

  • 阻塞请求:Redis 是单线程工作模型,所有命令串行执行。对大key执行操作时,会长时间占用CPU,导致其他Redis请求阻塞。
  • 内存倾斜:集群中某个节点因大 Key 占用大量内存,其他节点空闲,资源利用率极低
  • 淘汰热点数据:占用内存,触发内存淘汰机制,会淘汰掉热点数据,使关键业务不走缓存,响应变慢
  • 网络带宽占用:高频的大key请求会占用大量的带宽,使节点的其他服务没有网络带宽可用

大Key的查看

redis-cli工具执行命令:

java 复制代码
redis-cli --bigkeys

使用第三方可视化工具:Redis Insight、AnotherRedisDesktopManager

大Key解决方案

根治方案就是拆分,减小Key的大小

  • JSON字符串可以按字段拆分,List/Set/Hash/ZSet元素太多,可以按固定数量(每1000个一个key)拆分,也可以按用户ID、日期、地区等拆分,具体取决于业务形式。
  • 能冷热分离的就冷热分离,redis只存热点数据,对于不常用的数据,放到数据库中去。
  • 大Key不要集中过期,应该设置一定的随机时间戳。
  • 对于太大、且访问频率高,无法拆分的字符串,不要存redis上,可以使用文本形式存到CDN,或者服务器本地缓存。

总结一句话就是,能拆分的就拆分,然后怎么拆分;不能拆分的就尽量不要存,换其他更合适的地方存,存了的话,就避免集中过期。

四、Redis热Key

什么是热Key

指访问频率极高、远超其他 Key 的热点键(比如每秒几千~几万次访问)

典型场景:秒杀商品、热点新闻、热门活动、爆款商品、热点用户信息

热Key的危害

  • 资源倾斜:某个节点网络带宽被占满,但其他节点却是空闲的,无法利用集群优势。
  • 主从同步延迟:热 Key 所在节点写入频繁,主从同步压力大,出现数据不一致、同步延迟

热Key的查看

redis-cli工具执行命令:

java 复制代码
redis-cli --hotkeys -h 主机 -p 端口

使用Redis 监控(Prometheus/Grafana)查看单节点 QPS 突增

热Key解决方案

根治方案就是把访问分散。

1、使用本地缓存,把流量挡在了服务器层面,那就能发挥集群负载均衡的优势。本地缓存 (Caffeine/Guava) → Redis → DB

2、热key创建多个副本,利用hash分散到多个redis节点,请求时随机访问其中一个副本,把压力分散到不同的分片

3、限流,没啥好说的

五、Redis持久化

Redis不仅是个内存存储引擎,同时它还支持备份内存上的数据到磁盘上,保证Redis断电或者重启后,内存数据的恢复。

Redis 提供两种核心持久化机制:RDB 和 AOF,也可以组合使用。

RDB

定时快照在指定时间点,把 Redis 内存中的全量数据写入磁盘的二进制文件(dump.rdb)

触发方式:

  • 手动触发
    SAVE:阻塞 Redis,直到快照完成(生产禁用)
    BGSAVE:后台异步执行,不阻塞服务(推荐)
  • 自动触发(配置文件)
powershell 复制代码
# 900秒内至少1个key修改,自动保存
save 900 1
# 300秒内至少10个key修改
save 300 10
# 60秒内至少10000个key修改
save 60 10000

优点 :

1、文件小,备份 / 恢复速度极快

2、对性能影响小(后台执行)

3、适合全量备份、灾难恢复

缺点 :

1、会丢失数据:两次快照之间的修改会丢失

2、大数据量时,fork 子进程会短暂阻塞主进程

AOF(Append Only File)

日志追加:把每一条写命令(增 / 删 / 改)追加到日志文件(appendonly.aof),重启时重放命令恢复数据。

配置文件:

bash 复制代码
# 开启AOF
appendonly yes

# 三种刷盘策略
appendfsync always   # 每次写都刷盘:最安全,性能差
appendfsync everysec # 每秒刷盘:**默认推荐**,平衡安全+性能
appendfsync no       # 交给操作系统刷盘:性能最好,最不安全

AOF重写(rewrite/压缩) :

随着命令的不断写入,.aof会越来越大,其中有很多命令是重复的,比方同个key的多个set命令,恢复时只需要取最后一个就行了,redis的aof支持文件压缩,把冗余的key自动剔除,生成最小命令集

AOF重写是redis自动触发的,我们可以配置它的触发条件:

bash 复制代码
auto-aof-rewrite-percentage 100  # 文件比上次大100%触发
auto-aof-rewrite-min-size 64mb   # 至少64MB才重写

AOF优点 :

1、数据安全性极高,最多丢失 1 秒数据

2、日志是纯文本,可读性高,可手动修复

3、支持重写压缩

AOF缺点 :

1、文件比 RDB 大

2、恢复速度比 RDB 慢

AOF文件损坏

因为Redis断电或强制重启时,会导致内存数据丢失,而AOF可以做到每秒备份或者每次写入备份,但是再追加命令到aof文件的过程中,也同样存在redis异常的情况(断电或重启、磁盘满的情况),所以AOF文件是有可能为损坏状态的。通常都是结尾处的命令被截断了,写到一半,突然redis崩溃了,导致命令没有写完。

此时可以使用这个工具,检查aof文件的正确性,并试图修复:

bash 复制代码
redis-check-aof --fix appendonly.aof

工具会逐条解析,检查:

1、语法是否合法

2、格式是否完整

3、有没有半截命令

一旦读到不合法、不完整、乱码的内容:

1、认定从这里开始全部损坏

2、保留前面所有正确的命令

3、删除从出错位置到文件末尾的所有内容

值得注意的是,RDB文件也同样存在损坏的可能,但是rdb文件时无法修复的,它是一个紧凑的二进制文件,无法从一堆的杂乱二进制数据中进行恢复。

生产方案

推荐使用:RDB + AOF 混合使用

AOF 做主持久化:保证数据不丢

RDB 做全量备份:加快恢复速度

Redis 4.0+ 支持混合持久化:重启时:加载RDB 全量数据 + 重放AOF 增量命令

六、Redis高可用

Redis支持部署多个Redis节点,每个节点按配置模式承担不同的职责和请求压力。

一共有三种模式:

主从复制(Master-Slave)

核心:读写分离 + 数据备份

主节点负责写,从节点负责读,从节点复制主节点数据一主多从,横向扩展读性能。

缺点也很明显,只有一个节点负责写,所有写压力都分到单台节点上,且容量受单台节点容量限制,不具备故障转移,主节点挂了,就没办法写了。

哨兵(Sentinel)模式

核心:主从 + 自动故障转移

基于主从架构,增加哨兵节点监控主从状态,主节点宕机后,哨兵自动投票选举新主,自动切换,无需人工干预,提供主节点地址发现服务(客户端连接哨兵获取当前主)

缺点仍然是单主架构,写压力受单机限制,数据容量不能水平扩展

集群(Cluster)模式

核心:分布式 + 分片 + 高可用

数据按 slot(哈希槽) 分片存储在多个主节点,每个主节点可配从节点,实现高可用,自动故障转移,支持水平扩展,海量数据、高并发写入。

同时集群模式支持类似主从备份的方式建立集群,也就是每个主节点都可以有一个从节点,作为数据备份。从节点一般只用做主节点的备份,不做读的流量分担,但是也可以配置从节点分担读的流量,相对的就要承担主从不一致时,读到脏数据的风险。

集群模式下,扩容时,数据怎么迁移?

假设之前有三个主节点A、B、C,它们分别管

槽 0~5460 → A

槽 5461~10922 → B

槽 10923~16383 → C

此时,扩容多一个主节点D出来,Redis就会计算出新的4个节点下,它们各自管的槽。分配的策略是,A要给一部分槽给D,B要给一部分槽给D,C也是,所以就存在ABC需要把数据同步给D节点了,但注意不存在A、B、C各自之间互相给数据的场景。也就是说,D此时管理的槽就是4096 ~ 5460 、8192 ~ 10922、 12288~16383:

A 把自己的 4096~5460 槽数据 → 发给 D

B 把自己的 8192~10922 槽数据 → 发给 D

C 把自己的 12288~16383 槽数据 → 发给 D

所以Redis的每个节点管理的槽其实可以是不连续的。那为什么我们平时看到是连续的?因为 redis-cli --cluster reshard,默认是按连续一段一段迁移,方便人类看。但 Redis 底层 根本不要求连续。

七、缓存失效

缓存穿透

大量请求查询根本不存在的数据,缓存里没有 → 直接查数据库 → 数据库也没有,缓存永远无法命中,流量直接打穿到 DB。

场景如:恶意攻击:疯狂请求 id=-1、不存在的用户
解决方案:

1、数据库查不到数据时,也缓存一个null值,让查询不到数据的情况也走Redis

2、布隆过滤器,它的查询很快,只需要运行K个hash运算就能判断数据在系统是否存在,但是同时也需要对数据进行预处理,把所有的数据都先加载到布隆过滤器中

3、拦截非法的请求,不走redis也不走数据库。

缓存击穿

某个热点 key 突然过期,大量并发同时请求这个 key,缓存不命中,全部涌入数据库。
解决方案:

1、互斥锁(mutex lock):只让一个线程去查 DB,其他等待

2、热点 key 永不过期

3、逻辑过期 + 后台异步更新

其实缓存击穿和穿透本质上时一样的,都是需要去数据库查一遍,然后缓存到redis上,防止后续的请求打到数据库。

缓存雪崩

大量 key 在同一时间集体过期,或 Redis 本身宕机,所有请求瞬间压到数据库。
解决方案:

1、过期时间加随机值:避免集体失效

2、集群高可用:主从、哨兵、集群

3、多级缓存:本地缓存 + Redis

4、限流、降级、熔断

八、缓存和数据库一致性

我们来看下Redis缓存更新的2种情况

假如B端编辑商品或者活动,根据活动id或者商品id加了分布式锁,修改几乎没有并发的可能。这种情况,采用更新redis的方案,在修改接口里,同事务去更新数据库,同时直接更新redis的值,因为没有并发更新redis,所以set key不存在互相覆盖的并发可能(且前提是C端只存在读缓存不存在写缓存,写缓存都由B端修改保存接口触发)。这种情况是不需要用到延迟双删的。但这种方案的缺点也很明显:C端缓存丢失时,需要定时任务兜底或者手动触发刷缓存

那再看下面这种情况:

假如某个缓存数据在创建时刷了缓存,但是缓存丢失或者过期了,在触发读请求时,缓存中没有,于是从数据库中读取,然后懒加载到Redis中,后续读只要没过期就从Redis读取,过期了就从数据库读完加载到缓存。这种方案下的缓存,就存在并发写的可能了。假如读请求进来没有缓存,然后加锁去数据库读,此时我们刚好又在修改数据库里的这条数据,就存在谁先刷缓存数据的问题了。

  • 方案一 :先更新数据库,再更新缓存
    存在问题:线程A(查询请求)读缓存没有,再去数据库读到旧数据,线程B(修改请求)更新数据库,然后再更新缓存,线程A拿旧的数据也去更新缓存,此时可能覆盖线程B的更新缓存,导致缓存中一直存了旧的数据。
  • 方案二 :先更新缓存,再更新数据库
    存在问题:假如数据库更新失败了,那缓存就是新的值,就和数据库不一致了。

解决方案 :采用延迟双删,线程B更新前,先把Redis的缓存删了,此时,线程A进来没有缓存,读到了数据库的旧数据。然后线程B更新数据库,延迟一段时间(通常根据接口的响应时间来,设置500ms之类的),在这500ms期间,线程A已经拿着旧数据去更新缓存了,然后线程B又执行了一次删除缓存(延迟500ms后第二次删除),这样就把A设置的缓存旧数据给删掉了。而这第二次删除,就是为了确保删除掉所有和修改接口并发的读接口更新的redis值的。

还可能存在的问题:第二次删除的时候,可能会删失败,导致旧数据仍然在redis中,解决方法是采用MQ发送延迟消息来删除redis的值,因为消息有重试,能确保redis会删成功。
缺点:延迟双删虽然能大概率解决缓存和数据库的不一致性,但是延迟时间不好预估

九、缓存淘汰策略

Redis内存达到配置的阈值时,此时新增Key就会触发它淘汰掉历史旧的key,以确保整体内存在配置的阈值之下,支持的淘汰策略有:

  • noeviction 不淘汰(默认)
  • 仅对【设置了过期时间】的 key 淘汰
    • volatile-lru 淘汰最近最少使用(LRU)的过期 key
    • volatile-lfu 淘汰使用频率最低(LFU)的过期 key
    • volatile-random 随机删除过期 key
    • volatile-ttl 淘汰最快过期的 key(TTL 越小越先删)
  • 对【所有 key】淘汰(生产最常用)
    • allkeys-lru 淘汰所有 key 中最近最少使用的(生产首选)
    • allkeys-lfu 淘汰所有 key 中使用频率最低的
    • allkeys-random 随机删除所有 key

拓展:LRU的实现原理如下:

哈希表:维护key对应双向链表里的node,快速找到 key 对应的节点(O(1))

双向链表:维护访问顺序,头部=最近使用,尾部=最久未使用

操作一个key时,从哈希表中找到它的node节点,然后修改双向链表里的指针指向,再把这个node添加到头部,假如哈希表中没有这个node,则直接添加到头部,链表满时,就踢掉尾部即最不常用的。

十、Redis常用场景

实现消息队列

Redis有个发布 / 订阅(Pub/Sub)功能,是Redis提供的消息广播模型。

发布者(Publisher):往一个 "频道" 发消息

订阅者(Subscriber):监听这个 "频道",一收到消息就消费

特点:发布者只管发,不管有没有人订阅;订阅者只管听,不听就错过

发布者配置:

java 复制代码
@Configuration
public class RedisPubSubConfig {

    // 定义频道名称
    public static final String NOTICE_CHANNEL = "notice.channel";
    public static final String ORDER_CHANNEL = "order.channel";

    /**
     * 注册监听器:绑定 → 通知频道
     */
    @Bean
    public MessageListenerAdapter noticeListenerAdapter(NoticeMessageListener listener) {
        return new MessageListenerAdapter(listener, "onMessage");
    }

    /**
     * 注册监听器:绑定 → 订单频道
     */
    @Bean
    public MessageListenerAdapter orderListenerAdapter(OrderMessageListener listener) {
        return new MessageListenerAdapter(listener, "onMessage");
    }

    /**
     * 消息订阅容器(自动过滤频道)
     */
    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(
            RedisConnectionFactory factory,
            MessageListenerAdapter noticeListenerAdapter,
            MessageListenerAdapter orderListenerAdapter
    ) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(factory);

        // ====================== 关键:自动绑定频道 ======================
        // 通知频道 → 通知监听器
        container.addMessageListener(noticeListenerAdapter, new ChannelTopic(NOTICE_CHANNEL));

        // 订单频道 → 订单监听器
        container.addMessageListener(orderListenerAdapter, new ChannelTopic(ORDER_CHANNEL));

        return container;
    }
}

发布消息:

java 复制代码
@Service
public class RedisPublishService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 发送通知消息
     */
    public void sendNotice(String msg) {
        stringRedisTemplate.convertAndSend(NOTICE_CHANNEL, msg);
        System.out.println("已发送通知消息:" + msg);
    }

    /**
     * 发送订单消息
     */
    public void sendOrder(String msg) {
        stringRedisTemplate.convertAndSend(ORDER_CHANNEL, msg);
        System.out.println("已发送订单消息:" + msg);
    }
}

消费消息:

java 复制代码
@Component
public class NoticeMessageListener {

    /**
     * 只接收 notice.channel 的消息
     */
    public void onMessage(String message) {
        System.out.println("【系统通知】收到消息:" + message);
    }
}

@Component
public class OrderMessageListener {

    /**
     * 只接收 order.channel 的消息
     */
    public void onMessage(String message) {
        System.out.println("【订单消息】收到消息:" + message);
    }
}

这种方案实现的消息队列,没有持久化,消费者只要离线消息就丢失,因为生产者只管发,至于你听不听得到我不管,而且没有消息确认机制,没有消息重试,只适用实时广播通知,且可靠性要求不高的场景。

Redis5.0之后,引入了Stream来实现消息队列,对比pub/sub模型,Stream支持消息持久化、支持消费组、支持负载均衡、支持消息确认、支持消息重试、支持阻塞读取。核心的组件如下:

生产者(发送消息),生产者只需要关心往哪个Stream发就行了,不需要关注消费组之类的:

java 复制代码
@Service
public class StreamProducer {

    @Resource
    private StringRedisTemplate redisTemplate;

    // 发消息
    public String send(String msg) {
        HashMap<String, String> map = new HashMap<>();
        map.put("msg", msg);
        return redisTemplate.opsForStream().add(StreamConst.STREAM, map);
    }
}

消费者(接收消息),一个Stream里可以有多个消费组Group,每个消费组都可以消费同一个消息成功一次,也就是定义5个消费组在Stream里,那一条消息,就会被消费5次,每个组消费一次,比如下单成功了,那订单系统需要消费消息,库存系统需要消费消息这种。同一个消费组Group中的消费者(也就是线程或者实例),只有一个消费线程会获取到同一个消息,它们之间可以配置负载均衡,按比例进行消费。

java 复制代码
@Service
public class StreamConsumer {

    @Resource
    private StringRedisTemplate redisTemplate;

    // 初始化创建消费者组
    @PostConstruct
    public void initGroup() {
        try {
            redisTemplate.opsForStream().createGroup(StreamConst.STREAM, StreamConst.GROUP);
        } catch (Exception ignored) {}
    }

    // 启动消费
    @PostConstruct
    public void start() {
        new Thread(() -> {
            while (true) {
                try {
                    // 阻塞读取消息
                    List<MapRecord<String, String, String>> messages =
                            redisTemplate.opsForStream().read(
                                    Consumer.from(StreamConst.GROUP, StreamConst.CONSUMER),
                                    org.springframework.data.redis.connection.stream.StreamReadOptions.empty()
                                            .block(2000)
                                            .count(1),
                                    StreamOffset.create(StreamConst.STREAM, ReadOffset.lastConsumed())
                            );

                    if (messages == null || messages.isEmpty()) continue;

                    // 处理消息
                    for (MapRecord<String, String, String> msg : messages) {
                        String msgId = msg.getId().getValue();
                        String content = msg.getValue().get("msg");

                        System.out.println("消费消息:" + content);

                        // 确认消息
                        redisTemplate.opsForStream().acknowledge(
                                StreamConst.STREAM,
                                StreamConst.GROUP,
                                msgId
                        );
                    }

                } catch (Exception e) {
                    try { Thread.sleep(1000); } catch (Exception ignored) {}
                }
            }
        }).start();
    }
}

实现延迟队列

Java中使用RedisTemplate,调用Redis的ZSet API可以实现延迟队列功能,延迟队列就是队列里的消息,不是立马执行的,每个消息都有一个执行时间,要等到这个时间了,才会从队列里取出来执行任务。

添加任务:

java 复制代码
// 1. 计算任务执行时间戳(秒)
long executeTime = System.currentTimeMillis() / 1000 + delaySeconds;
// 2. 封装任务对象
DelayTask<T> delayTask = new DelayTask<>();
delayTask.setTaskId(taskId);
delayTask.setData(taskData);
delayTask.setRetryCount(0); // 初始重试次数
// 3. 序列化为 JSON 字符串(作为 ZSet 的 member)
String taskJson = JSONObject.toJsonString(delayTask );
// 4. ZADD 原子添加(score=执行时间戳,重复 taskId 会覆盖)
return redisTemplate.opsForZSet().add(queueKey, taskJson, executeTime);

消费端启动线程池,获取到期执行的任务:

java 复制代码
// 1. 当前时间戳(秒)
long currentTime = System.currentTimeMillis() / 1000;
// 2. 查询 score ≤ 当前时间的任务(已到期)
Set<Object> expiredTaskSet = getZSetOps().rangeByScore(
        queueKey,
        0,          // min score
        currentTime,// max score
        0,          // 起始索引
        batchSize   // 批量大小
);

if (expiredTaskSet == null || expiredTaskSet.isEmpty()) {
    return Collections.emptyList();
}

// 3. 批量删除已获取的任务(原子操作,避免重复消费)
Long deleteCount = getZSetOps().remove(queueKey, expiredTaskSet.toArray());
if (deleteCount == null || deleteCount == 0) {
    return Collections.emptyList();
}

这种是最基础的使用轮询的方式,实现的获取任务,因为rangeByScore命令是非阻塞的,且Redis原生的ZSet没有提供阻塞式命令,所以客户端必须要启动一个线程每秒轮询调用一次rangeByScore这个API。

一般生产上使用的工业级方案是采用Redission,它有个RDelayedQueue,可以实现阻塞式的获取。

java 复制代码
// 延迟队列实例(复用)
private RDelayedQueue<String> delayedQueue;

/**
* 初始化延迟队列
*/
@PostConstruct
public void initDelayedQueue() {
   // 基于 Redisson 的普通队列构建延迟队列
   delayedQueue = redissonClient.getDelayedQueue(
           redissonClient.getQueue(DELAY_QUEUE_NAME)
   );
}

/**
* 添加延迟任务
* @param task 任务内容(字符串/序列化对象均可)
* @param delay 延迟时间
* @param timeUnit 时间单位
*/
public void addDelayTask(String task, long delay, TimeUnit timeUnit) {
   delayedQueue.offer(task, delay, timeUnit);
   System.out.println("添加延迟任务:" + task + ",延迟 " + delay + " " + timeUnit);
}

/**
 * 阻塞式消费任务(核心:take() 方法会阻塞直到有任务到期)
 */
public void consumeTask() {
    // 启动独立线程消费,避免阻塞主线程
    new Thread(() -> {
        try {
            while (!Thread.currentThread().isInterrupted()) {
                // 阻塞获取到期任务(无任务时线程挂起,无资源消耗)
                String task = delayedQueue.take();
                // 执行任务逻辑
                System.out.println("执行到期任务:" + task + ",执行时间:" + System.currentTimeMillis());
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("消费线程被中断,退出阻塞消费");
        }
    }, "delay-queue-consumer").start();
}
}

核心就是利用delayedQueue.offer添加任务并指定执行时间,调用delayedQueue.take();阻塞等待式的获取任务。

但其实这并不是什么黑魔法,Redission内部也是使用了Redis的:ZSet+ 发布订阅(Pub/Sub)模型+ Redis List 类型的阻塞式弹出命令Block Right Pop 实现的。

首先Redis提供阻塞式的API只有List类型的基本数据才有(BRPOP),但List类型又没有到期执行的能力(没有score这个维度),Redission内部实现还是轮询ZSet,当有任务到期执行时,它就往普通的List中添加这条任务,并向Redis发布消息,监听消息的消费端,就使用BRPOP命令从List拿数据执行,这个命令保证多个消费端收到消息后,只有一台能拿到这个任务(原子性),所以本质上Redission还是轮询ZSet的方式。不同的是,Redission并不是简单的1秒1次轮询,而是通过分层轮询 + 动态调整扫描间隔 + 批量处理,把轮询的开销降到最低:

  • 近期到期任务(如未来 10 秒内):放入「高频扫描队列」,扫描间隔极短(默认 100ms 左右),确保任务精准到期;
  • 远期到期任务(如未来 1 小时):放入「低频扫描队列」,扫描间隔很长(默认 10 秒 / 分钟级),减少无效查询。
  • 动态调整扫描间隔
    Redisson 会根据 ZSet 中任务的分布动态调整扫描频率:
    如果 ZSet 中只有 "远期任务",扫描间隔自动拉长(比如 1 分钟扫一次);
    如果 ZSet 中有 "近期任务",扫描间隔自动缩短(比如 100ms 扫一次);
    如果 ZSet 为空,扫描间隔会调到最大(比如 10 分钟),几乎无资源消耗。

同时,Redssion会加锁,所有消费实例中,只有抢到锁的实例才会执行ZSet扫描,也就不会有重复添加同个任务到List里的情况,不会出现重复消费的问题。

思考:为什么Redission要加多一个 发布订阅(Pub/Sub)在中间,而不是每个客户端使用BRPOP命令,阻塞式的等待呢?

假设 Redisson 只做 "后台线程扫 ZSet → 到期任务移 List → 消费线程 BRPOP List",看似能工作,但在多实例部署场景下会有问题:

  • BRPOP 的 "唤醒延迟"(Redis 原生特性) Redis 的 BRPOP 并非 "实时唤醒"------ 当 List新增元素时,Redis 会从阻塞的客户端列表中随机唤醒一个客户端,但这个唤醒过程存在「毫秒级延迟」(极端情况甚至秒级)。

  • 场景:部署了 10 个应用实例,每个实例都有消费线程执行 BRPOP list 0 阻塞监听; 现象:后台线程把任务移到 List 后,Redis 可能需要几百毫秒才唤醒其中一个消费线程,导致任务执行延迟;

Redisson 引入 Pub/Sub 后,形成了 "Pub/Sub 做通知 + BRPOP 做兜底" 的双保险机制。

  • 流程:后台线程把到期任务移到 List 后,立即向 Pub/Sub 频道发送一条 "任务到期" 的通知;
  • 效果:所有实例的消费线程都订阅了这个频道,收到通知后会 "主动检查 List"(而非等 Redis 随机唤醒)------ 相当于 "喊一声所有人都来抢任务",而非 "Redis 挨个叫人";
  • 对比:
    无 Pub/Sub:Redis 随机唤醒一个 BRPOP 线程,延迟几百毫秒;
    有Pub/Sub:所有线程收到通知后立即抢任务,唤醒延迟降到 10ms 内。

需要注意的是,Redission实现的延迟队列,是存在消息丢失的可能的,因为从ZSet中读取数据,转移到List的这段时间,可能因为Redis挂了导致消息丢失。即使成功存入List中,并且开启了RDB和AOF,由于整套体系没有消息确认机制,也有可能在消费端因为消费失败,消息已被取走,消息没办法重试导致消息丢失了,这是这套方案的缺陷,一般生产都是用来做一些对可靠性不需要严格要求的业务,常见的有未支付订单的取消,配合定时任务以及前端实时判断兜底,确保订单的最终取消。

限流器:滑动窗口 / 令牌桶 / 漏斗 + Redis 原子实现

三种限流器都是用Redis的lua脚本实现的,因为并发需要保证请求数量的原子性。而且三大主流网关(Spring Cloud Gateway、Kong Gateway、APISIX)底层全都用 Redis 做限流。

滑动窗口限流器

在一个 "滚动的时间窗口" 里,只允许固定数量的请求通过。比方:最近 1 分钟内,最多只能进 100 个人。来一个人,就以当前时间往前数这1分钟内,进来了多少人,超过则限流

令牌桶限流器

以固定速率生成令牌放入桶中,桶满则不生成新令牌,请求必须获取桶里的令牌才能执行,支持突发流量(瞬间消耗桶内存量令牌)。

漏桶限流器

请求放入漏桶,固定速率流出处理。桶满则新请求直接拒绝,严格匀速,无突发。

十一、常见问题

Redis同样数据,为什么省内存?

  • 动态编码 ,Redis的基本数据结构都采用动态编码,参考第一节内容
    -List:数据量小时用ZipList,数据量大时用LinkedList,5.2之后,采用QuickList
    -Set:数据量小时用IntSet,数据量大时用HashTable
    -ZSet:数据量小时用ZipList,数据量大时用SkipList+HashTable
    -String:SDS动态字符串,配合Jemalloc内存申请,减少了内存碎片的产生
    ZipList和IntSet无链表指针开销,内存利用率接近 100%
  • 对象复用 ,Redis 预创建 0--9999 整数对象,所有相同值共享同一个 redisObject
    存储大量 ID / 状态码时,几乎无额外对象开销
  • 引用计数 ,多个地方用同一个数据,只存一份,不重复存。比方你存了1000 个 key,值都是 1
    -Redis:只存一份,用引用计数记录 "有 1000 个人在用我"
    -普通系统:存 1000 份 1 → 浪费 999 份空间
  • 无额外索引 / 锁 / 事务元数据,也就是没有mysql那样需要建立一堆的索引树来方便查询

什么是SDS、Jemalloc

SDS(Simple Dynamic String) :Redis自己造的一种字符串,结构就三部分:长度剩余空间字符串内容。

这样的数据结构,让Redis读取头部就能知道这个字符串的长度以及还有多少预留空间。
Jemalloc :首先看什么是 内存碎片 ,就是明明是一块连续的内存,但是申请使用时,不连续申请,左一块右一块,导致连续的内存空间被切割成很多不连续的块了,当需要用到大的连续内存时,实际有足够的空闲空间,但由于被切割完的内存没有这么大的连续的,于是就无法申请了。

Jemalloc 是优化过的内存申请工具。系统自带的 malloc 管理混乱,申请内存时随便找空闲块分配,释放后空间东一块西一块,容易产生内存碎片。Jemalloc 把内存按固定大小切块管理(8B、16B、32B...),比如需要 20B 就分配 32B 的块,剩余的做预留空间,让内存布局更规整,减少碎片。

Redis 的 SDS 字符串结构(长度 + 剩余空间 + 内容)会自己做预分配和惰性释放,扩展字符串时优先复用内部预留空间,不够才重新申请内存,减少频繁分配释放。再加上 ziplist、intset 这类连续紧凑的数据结构,整体让内存更规整,进一步减少内存碎片。

集群模式下为什么是16384个槽?

① 槽数太少:扩容 / 迁移时槽分配粒度太粗;太多:槽映射表占用内存大(16384 个槽的映射表仅 2KB)

② 16384 是权衡:既满足分片粒度,又保证节点间传输槽信息时带宽消耗小

集群故障转移 "集群怎么选主?

半数投票机制

① 节点故障:超过半数主节点判定某个主节点下线(PFail→Fail)

② 选主:故障主节点的从节点发起选举,超过半数主节点投票通过后,从节点升级为主节点

③ 槽迁移:新主节点接管原主节点的所有槽

由于Redis节点故障的这个半数投票原则,因此实现集群模式的高可用,最少是3主3从,因为只有超过了半数投票从节点才能晋升为主节点

集群扩容 / 缩容,怎么给集群加节点?

① 添加新节点(主 / 从)到集群;

② 使用命令重新分配和迁移槽(redis-cli --cluster reshard);

③ 验证槽分配(cluster slots);

什么是Redis的脑裂,怎么避免?

因为网络等原因,集群里同时出现了两个主节点(Master),两边都以为自己是主,都能写,导致:数据不一致,两边数据互相冲突。

本来只有一个老大,结果网络一断,两边各选了一个老大,互不相认

脑裂在哨兵模式最容易出现,集群模式因为需要半数主节点都认为故障节点下线了,才能将从节点晋升,因此基本不会出现。

防止的方式就是配置 min-replicas-to-write 1,主节点至少要有一个从节点,才能对外写,这样就避免了2个主节点同时在写数据的情况发生

Keys命令和Scan命令

两个命令的作用是按表达式模糊查找Redis中符合匹配规则的key

Keys命令

语法:KEYS pattern

全量遍历所有键,按照通配符匹配返回结果。支持 * 匹配任意字符、? 匹配单个字符。一次性返回所有匹配的键

工作原理:Redis 会暂停所有其他请求(读写、查询、写入),直到遍历完所有键才恢复服务,如果有 100 万 + 键,会导致 Redis 卡死几秒~几分钟

SCAN命令

语法:SCAN cursor MATCH pattern COUNT count

增量式迭代遍历,分多次、分批返回键,不会阻塞 Redis。基于游标(cursor)分步遍历,不会一次性加载所有键到内存,支持匹配、数量限制

工作原理:第一次传入游标 0,Redis 返回 下一次的游标 + 一批键,用返回的新游标继续遍历,直到游标返回 0,遍历结束

查慢查询

有两个阈值需要关注:

bash 复制代码
# 阈值改为 5毫秒(5000微秒),这个阈值决定多久的查询才叫慢查询
CONFIG SET slowlog-log-slower-than 5000

# 最大记录数改为 1000条,这个配置决定保存多少条慢查询
CONFIG SET slowlog-max-len 1000

获取慢查询日志

bash 复制代码
# 查询前20条慢日志
SLOWLOG GET 20

pipeline/multi 区别

pipeline 内可以执行一系列命令,但是它不保证原子性,也就是这一系列命令中,可能会有其他redis的请求穿插进来中间执行了,且不支持事务。

multil/exec 也可以执行一系列命令,它是支持原子性的,也就是这一批命令,一个一个执行,执行出错了也不回滚,其他命令会继续执行。同时要注意,multi里操作的key必须是同一个节点里的,否则会报错,一条命令都不会执行成功。

相关推荐
catoop1 小时前
API Key 设计与安全生成方案:结构拆解、数据库设计与 Java 实现
java·数据库·安全
Web极客码1 小时前
我用 Claude 分析 Redis 哨兵日志,实现跨机房数据不一致自动对齐
数据库·redis·php
库库的数据库笔记2 小时前
国产信创数据库的MySQL兼容性深度对比解析
大数据·数据库·mysql·oceanbase·政务
microrain2 小时前
SagooIoT 的缓存换后端只改一行配置,文件后端连 Redis 都不需要
物联网·缓存·golang·sagooiot
姚远Oracle ACE3 小时前
为什么无法连接到 PostgreSQL?
数据库·postgresql
ss2733 小时前
AI时代首先数据库-PostgreSQL实战笔记 | 22 PostgreSQL实例与集群
数据库·笔记·postgresql
ss2733 小时前
AI时代首先数据库-PostgreSQL实战笔记 | 21 PostgreSQL进程与内存架构介绍
数据库·笔记·postgresql
Henry-SAP4 小时前
SAP PP模块全景概览
数据库
喜欢吃豆4 小时前
Better Auth 深度解析:从用户认证到企业级身份基础设施
数据库·架构·权限·多租户