十五、Redis的架构设计
1、Redis如何保存数据
1、键值对哈希表
从小的维度看,我们知道是用RedisObject来指向的具体数据结构,具体数据结构里面存放着用户数据。
RedisObject是由dictEntry引用的,dictEntry就是键值对,指向了键和值的RedisObject。
而dictEntry是被哈希表dictht管理的。哈希表dictht又是由dict结构管理的,它维护了两张dictht哈希表,用于重哈希。
所以,保存数据的根源就是Redis用于保存键值对的哈希表结构,一个dict。
2、RedisDB
Redis的核心结构是RedisDB,它不仅管理了保存键值对的dictht,还管理了其他一些重要的东西:
- dict:保存键值对的哈希表
- expires:过期字典,保存了所有Key的过期时间
- blocking_keys 和 ready_keys:实现 BLPOP 等阻塞命令
- watched_keys:记录事务中被watch的Key
2、Redis 的内存淘汰策略
1、如何修改策略
可以在Redis的配置文件中设置淘汰策略。
2、Redis提供了哪些策略
- volatile-ttl:会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除
- volatile-random:在设置了过期时间的键值对中,进行随机删除。
- volatile-lru:会使用 LRU 算法筛选设置了过期时间的键值对。最近最少使用的会被删掉。
- volatile-lfu:会使用 LFU 算法选择设置了过期时间的键值对。首先会筛选并淘汰访问次数少的数据,然后针对访问次数相同的数据,再筛选并淘汰访问时间最久远的数据。
- allkeys-random:从所有键值对中随机选择并删除数据。
- allkeys-lru:使用 LRU 算法在所有数据中进行筛选。
- allkeys-lfu:使用 LFU 算法在所有数据中进行筛选。
Redis的默认策略是不淘汰。Redis在使用完设置的最大内存空间后并不会采取任何操作,而是继续工作,直到物理内存占满报错。
思想:
- Redis把Key分为了两类:有过期时间的、永不过期的。
- 可以给一些允许淘汰的数据配置过期时间,这样就可以设置一个只针对会过期的Key的淘汰策略。
3、Redis 的 LRU
传统的LRU是由链表构成的,Redis没有直接使用常规的方式,因为它存在这些问题:
- 需要用链表管理所有的缓存数据,带来额外的空间开销
- 每次数据被访问就要移动到链表头部,这样会降低Redis的性能
Redis的LRU算法
Redis实现的是一种近似LRU的算法,目的是节省内存空间。
- 在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间
- 进行内存淘汰时,随机取一些key,然后挨个查看上次访问时间,淘汰掉最久没有使用的那个
优点:
- 不用给所有key维护一个大链表,节省空间
- 每次访问key只需要更新它的访问时间,不用移动位置,提高性能
缺点:
LRU算法有一个缓存污染问题。
比如最近频繁读取了一些数据,然后发生了缓存淘汰,LRU就会把历史高频数据丢弃,把最近频繁访问的留下。
但是,历史频繁访问的数据往往还要被访问,所以不应该删掉。
4、Redis 的 LFU
LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。
LFU算法可以解决缓存污染问题,因为它是根据访问次数来淘汰数据的。
这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些,因为Redis肯定需要优先保留热key。
Redis的对象头有24位的lru字段,高 16bit 存储 ldt(Last Decrement Time),低 8bit 存储 logc(Logistic Counter)。
- ldt 是用来记录 key 的访问时间戳
- logc 是用来记录 key 的访问频次,它的值越小表示使用频率越低,越容易淘汰,每个新加入的 key 的logc 初始值为 5。
注意,logc并不是访问次数,而是访问频率,它会随着时间的推移而降低。
Redis 在访问 key 时,对于 logc 是这样变化的:
- 先按照上次访问距离当前的时长,来对 logc 进行衰减;
- 然后,再按照一定概率增加 logc 的值
3、单机系统下实现并发控制
要实现并发场景下,临界区操作的互斥执行,即保证这些操作之间的原子性,有两种方案:
-
单命令操作。
- Redis提供了一些把多条命令统一起来的单条命令,比如setNE、incr、decr
- 缺点:有些比较复杂的业务,涉及到很多判断,Redis没有提供对应的单条命令
-
Lua脚本。
-
把多个操作写到一个 Lua 脚本中,以原子性方式执行单个 Lua 脚本
-
缺点:如果一个Lua脚本包含很多操作,会增加Redis执行该脚本的耗时,降低Redis的并发性能。
应该尽量只把临界区的操作写入脚本。
-
4、Redis内存容量增加后的问题
Redis使用内存存储数据,加内存可以显著提升数据的存储能力。
但是,Redis获得了大内存,会产生这些问题:
- RDB快照文件生成变慢、恢复数据的耗时增加
- 主从节点的全量复制耗时增加、环形缓冲区更容易溢出
5、慢查询优化
1、慢查询日志
获取慢查询日志:showlog get [N]
关于慢查询日志的配置有两个:slowlog-max-len、slowlog-log-slower-than
- slowlog-max-len:
- 慢查询日志最多存储多少条,默认是128条
- 线上建议调大慢查询列表,避免长命令在记录时被截断,且不会占用大量内存。可以设置成1000
- slowlog-log-slower-than:
- 慢查询的阈值。执行时间超过这个值的会被视为慢查询
- 默认是10ms,根据实际场景来配置。高流量场景可以配置成1ms
通过慢查询日志,可以知道是哪些命令的查询执行得比较慢。
- 查询命令的复杂度:https://redis.io/commands
- 如果确实是命令用得不好,就使用更高效的命令代替。
2、常见的命令优化
- 当需要返回一个 SET 中的所有成员时:
- 不要使用 SMEMBERS 命令,而是要使用 SSCAN 多次迭代返回,避免一次返回大量数据,造成线程阻塞
- 需要执行排序、交集、并集操作时:
- 应该在客户端完成,而不要用 SORT、SUNION、SINTER 这些命令,以免拖慢 Redis 实例
- KEYS 命令需要遍历存储的键值对,所以操作延时高。KEYS 命令一般不被建议用于生产环境中
6、Redis变慢时的排查思路
Redis变慢有两种情况:
- Redis服务自身存在问题,通常是不合理的配置引起的
- 业务层对于Redis的调用存在问题
排查Redis服务自身问题:
-
AOF
配置情况,是否配置成了较高的等级,比如每次写入,影响了性能
- 如果业务对数据丢失不太敏感,可以设置成较低的层级,通常使用每秒写入
- 如果业务确实不能丢数据,考虑将Redis实例的磁盘升级成高性能的固态硬盘
-
Redis实例的
内存占用
是否过大?如果是:
- 加内存,同时避免Redis实例和其他占用内存高的服务部署在同一台机器上
- 加机器,做Redis集群
-
是否配置了Redis
主从集群
?如果是:
- 把主库的数据量控制在2~4G,不要太大。否则全量复制时非常耗时
-
Redis的宿主机是否使用了
多核CPU
?如果是:
- 给Redis绑定物理核心
排查业务层对Redis的调用问题:
- 查看慢查询日志,看是否发生了慢查询
- 优化查询操作使用的方式
- 修改数据查询的逻辑,比如把数据聚合的操作放在业务服务中完成
- 是否存在多个键值对同时过期,导致每次查询缓存必须查询MySQL才能构建缓存后返回数据
- 对bigKey的处理是否合适
- 比如不应该对bigKey的集合全体遍历,应该scan
7、Redis的交互对象涉及到哪些
和Redis交互的对象有四种:客户端、磁盘、主从节点、切片集群实例
这些对象在和Redis交互时,操作行为都不同:
- 客户端:存在网络IO交互、键值对的增删查改、对数据库的操作
- 磁盘:记录RDB快照、记录AOF日志、重写AOF日志
- 主从节点:主库生成、传输RDB文件,从库接收RDB文件,清空数据库,加载RDB文件等
- 切片集群实例:向其他实例传输哈希槽信息、数据迁移操作
8、Redis的线程模型
1、Redis是单线程吗
平时说Redis是单线程,指的是Redis服务中,接收客户端命令、解析命令、读写数据、返回数据这一系列操作是由一个主线程来完成的。
但Redis程序本身并不是只有一个主线程,也有一些后台线程(BIO):
-
2.6版本时,会启动2个后台线程,负责关闭文件和AOF刷盘
-
4.0版本之后,增加了一个lazyfree线程,用来异步地释放Redis的内存
-
例如unlink key / flushdb async / flushall async
-
因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除。
因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿
应该使用 unlink 命令来异步删除大key。
-
为什么Redis需要开后台线程
因为关闭文件、AOF刷盘、释放内存这些操作都很耗时,都放在主线程去执行,会阻塞正常的读写请求。
后台线程的具体工作方式
后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。
关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:
- BIO_CLOSE_FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭;
- BIO_AOF_FSYNC,AOF刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘,
- BIO_LAZY_FREE,lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象;
2、Redis的单线程模式工作流程
Redis6.0版本之前的单线程模式如下图:
Redis初始化做的事情:
- 首先,调用 epoll_create() 创建一个 epoll 对象和调用 socket() 一个服务端 socket
- 然后,调用 bind() 绑定端口,调用 listen() 监听该 socket
- 然后,将调用 epoll_crt() 将 listen socket 加入到 epoll,同时注册「连接事件」处理函数
初始化完成之后,主线程就进入一个事件循环函数,主要做这些事情:
- 首先,先调用处理发送队列函数,看发送队列里是否有任务
- 如果有发送任务,则通过 write 函数将客户端发送缓存区里的数据发送出去
- 如果这一轮数据没有发生完,就会注册写事件处理函数,等待 epoll_wait 发现可写后再处理
- 接着,调用 epoll_wait 函数等待事件的到来:
- 如果是连接事件到来,则会调用连接事件处理函数,该函数会做这些事情:调用 accpet 获取已连接的 socket -> 调用 epoll_ctr 将已连接的 socket 加入到 epoll -> 注册「读事件」处理函数
- 如果是读事件到来,则会调用读事件处理函数,该函数会做这些事情:调用 read 获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送
- 如果是写事件到来,则会调用写事件处理函数,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发生完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理
3、为什么Redis是单线程还这么快
redis速度很快,单机就能支撑起10万的并发量。和MySQL比较,Redis的速度是MySQL的几十倍。
这么快的原因:
- 完全在内存上操作数据,基本不涉及到磁盘IO,并且使用C语言实现,对数据结构都做了很好的优化,所以Redis的性能瓶颈是内存和带宽,而不是CPU,那就没必要使用多线程了
- 使用单线程设计,没有线程上下文切换的开销,也不存在竞争锁的问题
- 基于非阻塞的IO多路复用机制(NIO线程模型)来处理大量的客户端Socket请求
4、Redis 6.0 之前为什么使用单线程
单线程的程序是无法利用服务器的多核 CPU 的,那么Redis官方是怎么考虑的?
官方说:
Redis的性能瓶颈是内存和带宽,而不是CPU
,所以 Redis 核心网络模型使用单线程并没有什么问题。- 如果你想要使用服务器的多核CPU,可以在一台服务器上启动多个节点,或者采用分片集群的方式。
- 使用单线程后可维护性高,多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。
5、Redis 6.0 之后为什么引入了多线程
随着网络硬件的性能提升,网络IO也会成为Redis的性能瓶颈。
所以为了提高网络请求处理的并行度,Redis 6.0 对于网络请求采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理
。
Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上。
多线程特性介绍
Redis 6.0 版本支持的 I/O 多线程特性,默认是 I/O 多线程只处理写操作(write client socket),并不会以多线程的方式处理读操作(read client socket)。
要想开启多线程处理客户端读请求,就需要把 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。
//读请求也使用io多线程
io-threads-do-reads yes
同时, Redis.conf 配置文件中提供了 IO 多线程个数的配置项。
// io-threads N,表示启用 N-1 个 I/O 多线程(主线程也算一个 I/O 线程)
io-threads 4
关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。
因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会有 6 个线程:
- Redis-server :Redis的主线程,主要负责执行命令;
- bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务;
- io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3 个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。
十六、LUA 脚本
1、概述
LUA不是Redis的私有脚本,而是一个轻量级、高性能、简洁优雅的嵌入式脚本
从 Redis 2.6.0 版本开始, Redis内置了 Lua 解释器,可以实现在 Redis 中运行 Lua 脚本
2、优势
使用 Lua 脚本的好处 :
- 减少网络开销。将多个请求通过脚本的形式一次发送给服务器,减少网络时延。
- 原子操作。Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。
- 方便复用。客户端发送的脚本会缓存在 Redis 中,其他客户端可以复用这一脚本,而不需要重新传输。
3、使用方式
4、LUA 与 Redis事务
LUA是会连续执行的,Redis事务也是连续执行的,所以LUA基本可以替代Redis事务。
因为脚本功能是 Redis 2.6 才引入的,而事务功能则早就存在了,所以 Redis 才会同时存在两种处理事务的方法。
十七、大Key如何处理
1、什么是Redis大Key
大 key 并不是指 key 的值很大,而是 key 对应的 value 很大。
一般而言,下面这两种情况被称为大 key:
- String 类型的值大于 10 KB;
- Hash、List、Set、ZSet 类型的元素的个数超过 5000个;
2、大Key存在的问题
大Key会带来以下四种影响:
-
客户端超时阻塞
- 由于Redis执行命令是单线程处理,在操作大Key时会比较耗时,导致Redis被阻塞,从客户端角度看就是Redis很久未响应。
-
引发网络阻塞
-
每次获取大Key,由于数据量很大,就会产生很大的网络流量。
比如一个Key大小是1MB,每秒访问量为1000,那么每秒会产生1G的流量,普通的千兆网卡承受不住
-
-
阻塞工作线程
- 使用del删除大Key时,会阻塞工作线程,导致Redis无法处理后续的命令
-
内存分布不均
- 集群在slot分片均匀的情况下,会出现数据和查询倾斜的情况,部分有大Key的Redis节点占用内存多,QPS也较大
3、如何找到大Key
1、redis-cli – bigkeys 命令
可以通过 redis-cli – bigkeys 命令查找大 key:
redis-cli -h 127.0.0.1 -p6379 -a "password" -- bigkeys
注意事项:
- 建议在从节点上执行该命令,因为如果在主节点上执行,会阻塞主节点
- 如果没有从节点,最好选择在Redis实例压力低的时间段进行扫描
- 或者可以使用 -i 参数控制扫描间隔,避免长时间扫描降低Redis实例的性能
缺陷:
- 这个命令只能返回每种类型中最大的key,而不能获得大小排在前几位的大key
- 对于集合类型,这个指令只统计集合元素个数的多少,而不是实际占用的内存量。但是,一个集合元素个数多并不代表它占用的内存多,所以这样判断大Key不太合适
2、scan 命令
使用 SCAN 命令对数据库扫描,然后用 TYPE 命令获取返回的每一个 key 的类型。
-
对于String类型,可以直接使用 strlen 命令获取字符串长度,即占用的内存空间字节数
-
对于集合类型,有两种方式可以获得它占用内存的大小:
-
如果能从业务层面知道每个集合元素的大小,就能通过元素个数 * 元素大小计算
获取元素个数的命令:List 类型:LLEN 命令;Hash 类型:HLEN 命令;Set 类型:SCARD 命令;Sorted Set 类型:ZCARD 命令
-
可以使用 MEMORY USAGE 命令(需要 Redis 4.0 及以上版本),查询一个键值对占用的内存空间。
-
3、RdbTools 工具
使用 RdbTools 第三方开源工具,可以用来解析 Redis 快照(RDB)文件,找到其中的大 key。
比如,下面这条命令,将大于 10 kb 的 key 输出到一个表格文件。
rdb dump.rdb -c memory --bytes 10240 -f redis.csv
4、如何删除大Key
1、异步删除
从 Redis 4.0 版本开始,建议用 unlink 命令代替 del 来删除大Key。
因为 unlink 会用lazyfree异步线程来删除,不会阻塞主线程。
2、分批次删除
如果是 Redis 4.0 以下的版本,就不要一次性删除大集合,应该一波一波删。
每种集合的具体做法如下:
- Hash:使用 hscan 命令,每次获取 100 个字段,再用 hdel 命令,每次删除 1 个字段
- List:通过 ltrim 命令,每次删除少量元素
- Set:使用 sscan 命令,每次扫描集合中 100 个元素,再用 srem 命令每次删除一个键
- ZSet:使用 zremrangebyrank 命令,每次删除 top 100个元素
十八、Redis做消息队列
1、需求
消息队列必须提供三个基本功能:
- 消息保序,先发先被消费
- 处理重复消息,消息不能被重复消费
- 保证消息可靠性,消息最终一定要被消费
Redis的两个数据类型,List和Stream都可以满足这三个要求。
2、实现消息保序
1、轮询获取消息
List本身就是按照先入先出的顺序保存数据的,所以很合适。
消息入队:lpush,如果Key不存在就会创建一个空的队列再插入消息
消息出队:rpop
不过这种做法有一个明显的问题:
生产者向List中写入数据后,消费者并不知道,所以消费者必须循环调用rpop来轮询数据
为了保证及时获取消息,这个轮询的频率只能非常高,导致消费者的CPU消耗太大,带来很大的性能损失。
2、阻塞获取消息
不过Redis提供了一个brpop命令,也称为阻塞式读取
。
使用brpop,客户端在没有读到队列数据时会自动阻塞,直到队列中有新的数据之后,再开始读取数据。
这种阻塞式读取的效率显然比轮询高很多。
3、实现处理重复消息
处理重复消息是指,消费者不要去对同样的信息消费两次。
这就带来两方面的要求:
- 每个消息必须有一个全局唯一的id
- 消费者要记录已经处理过的消息id,然后每次查看新收到的消息id,判断是否处理过,如果处理过就丢弃
1、全局唯一id
List不附带这个功能,需要生产者手动塞入。
这个逻辑不复杂,只要规定消息的格式即可,比如规定消息格式为
id:service:data
4、实现保证消息可靠性
消息可靠性是指,这条消息最终肯定能被消费。
目前的方案,消费者拉取到消息后,List中就不再保存这条消息了。如果消费者还没处理消息就宕机了,那这条消息相当于丢失了,永远没办法再被处理。
List提供了一个brpoplpush命令,它可以实现,从一个List中读取消息,同时把消息插入另一个List
。
这样,如果发生了消息丢失,就可以去备份List中重新消费这条消息
5、List作为消息队列的缺陷
目前的方案,如果有多个消费者从队列拉取消息,只能有一个消费者拿到消息,没办法人手一份。因为每条消息只在List中保存一份,获取了就没了。
要实现一条消息可以给多个消费者人手一份,就需要把多个消费者组成一个消费组,一个消费组可以消费同一条消息。但List并不支持这样。
好消息是,Stream类型同样可以满足消息队列的三大需求,并且可以支持消费组形式的消息读取!
6、发布/订阅机制无法做消息队列
发布订阅机制存在以下缺点,都是跟丢失数据有关:
-
发布订阅机制没有基于数据类型实现,不会被写入日志中,宕机后必然会丢失,所以完全不具备数据持久化能力
-
发布订阅模式是发后既忘的,订阅者断线重连之后无法获得历史消息
-
当消费端有一定的消息积压,如果超过 32M 或者是 60s 内持续保持在 8M 以上,消费端会被强行断开
这个参数是在配置文件中设置的,默认值是 client-output-buffer-limit pubsub 32mb 8mb 60
所以,发布订阅机制只适合即时通讯的场景,比如构建哨兵集群时。
7、Stream作为消息队列的优势
Stream是Redis 5.0新增加的数据类型,是专门设计用来做消息队列的。
它支持消息的持久化、自动生成全局唯一id、支持ack确认消息、支持消费组。
8、Stream的常用命令
- XADD:插入消息,保证有序,可以自动生成全局唯一 ID
- XREAD:用于读取消息,可以按 ID 读取数据
- XDEL : 根据消息 ID 删除消息
- DEL :删除整个 Stream
- XREADGROUP:按消费组形式读取消息
- XPENDING 和 XACK:
- XPENDING 命令可以用来查询每个消费组内所有消费者「已读取、但尚未确认」的消息;
- XACK 命令用于向消息队列确认消息处理已完成
9、Stream的使用方式
1、插入消息
生产者通过XADD插入一条消息:
# * 表示让 Redis 为插入的数据自动生成一个全局唯一的 ID
# 往名称为 mymq 的消息队列中插入一条消息,消息的键是 name,值是 xiaolin
> XADD mymq * name xiaolin
"1654254953808-0"
插入成功后会返回全局唯一的 ID:“1654254953808-0”。消息的全局唯一 ID 由两部分组成:
- 第一部分“1654254953808”是数据插入时服务器的时间戳
- 第二部分表示插入消息在时间戳内的消息序号,从 0 开始。
2、读取消息
消费者通过 XREAD 命令从消息队列中读取消息时,可以指定一个消息 ID,并从这个消息 ID 的下一条消息开始进行读取。
(注意是输入消息 ID 的下一条信息开始读取,不是查询输入ID的消息)
# 从 ID 号为 1654254953807-0 的消息开始,读取后续的所有消息(示例中一共 1 条)。
> XREAD STREAMS mymq 1654254953807-0
1) 1) "mymq"
2) 1) 1) "1654254953808-0"
2) 1) "name"
2) "xiaolin"
如果想要实现阻塞读,可以调用 XRAED 时设定 BLOCK 配置项,实现类似于 BRPOP 的阻塞读取操作
。
比如,下面这命令,设置了 BLOCK 10000 的配置项,10000 的单位是毫秒,表明 XREAD 在读取最新消息时,如果没有消息到来,XREAD 将阻塞 10000 毫秒(即 10 秒),然后再返回。
# 命令最后的“$”符号表示读取最新的消息
> XREAD BLOCK 10000 STREAMS mymq $
(nil)
(10.00s)
3、消费组
1、消费组的概念
每个消费组可以有很多消费者,一条消息只能被同一个消费组的其中一个消费者消费
。
不同消费组的消费者,可以消费同一条消息
。
相当于,不同消费组之间都面向的是自己的队列状态,消费组之间不会相互影响
2、使用消费组
创建消费组
Stream 可以以使用 XGROUP 创建消费组,创建消费组之后,Stream 可以使用 XREADGROUP 命令让消费组内的消费者读取消息。
# 创建一个名为 group1 的消费组,0-0 表示从第一条消息开始读取。
> XGROUP CREATE mymq group1 0-0
消费组内的消费者,从消息队列中读取所有消息:
# 命令最后的参数“>”,表示从第一条尚未被消费的消息开始读取。
> XREADGROUP GROUP group1 consumer1 STREAMS mymq >
1) 1) "mymq"
2) 1) 1) "1654254953808-0"
2) 1) "name"
2) "xiaolin"
消息队列中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取了,即同一个消费组里的消费者不能消费同一条消息。
3、消费组的意义
如果要实现消费者人手一份数据,其实也不难实现,但是这个粒度就太小了。
很多时候,我们希望一个业务下面有多个消费者能去一起处理某类消息,避免消息堆积,提高效率。这就是消费组,它们消费同一个队列的消息,人手一份数据都是不同的。
所以,使用消费组的目的是让组内的多个消费者共同分担读取消息,从而实现消息读取负载在多个消费者间是均衡分布的。
10、Stream实现消息可靠性
Streams 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用 XACK 命令通知 Streams“消息已经处理完成”。
消费确认增加了消息的可靠性,在业务处理完成之后,需要执行 XACK 命令确认消息已经被消费完成
,整个流程的执行如下图所示:
如果消费者没有成功处理消息,就不要给 Streams 发送 XACK 命令,消息仍然会留存
。这样,消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。
11、Redis消息队列比起专业MQ的差距
消息队列要做到可用,必须满足两个功能:
- 消息不能丢失
- 消息可以堆积
1、Redis Stream会丢消息吗
丢消息这件事情,有三个环节可能发生:
- 生产者插入消息失败
- 消息中间件出故障,可能丢失数据
- 消费者拉取了数据,但出故障了没有消费,且这条消息无法被再次获取
分析Redis Stream消息队列会不会丢消息
生产者插入消息这块,一般不会丢消息,因为Stream有一个ack的机制,只要Stream正确收到消息就会返回一个响应,如果没有受到响应,发送方自己做好重传机制就行了。
消费者拉取消息这块,也不会丢消息,因为消费者成功消费消息之后会给Stream发送一个确认,此时Stream才会把消息删掉。如果消费者拉取消息后重启,它依然可以从Stream中获取到“已读取但未确认” 的消息。
关键点在于Redis这块,它是会丢数据的,主要有两个场景:
- Redis的持久化。只要不开启每次都刷AOF,一旦宕机就会丢失内存中尚未刷AOF的数据
- 主从复制是异步的,在发生主从切换时,也存在丢失数据的可能
其他消息队列怎么保证不丢消息
比如RabbitMQ,使用时是部署一个集群,生产者在写消息时会去写多个节点
这样就算一个节点挂了,别的节点依然保存着数据的副本,数据不会丢失。
2、Redis Stream消息可堆积吗
Redis的消息都存储在内存中,这就有可能超过宿主机内存上限,发生OOM。
Stream的做法是,制定一个队列的最大长度,一旦消息数量超过上限,它不会OOM,但是会删除旧消息,存入新消息。所以这块会丢失数据。
专门的MQ,数据都是存储在磁盘上,所以只要磁盘空间足够就没问题。
12、Redis消息队列的使用场景
- 如果业务场景非常简单,对丢失数据不敏感,且消息积压的概率比较小,在已经引入了Redis且不方便引入MQ时,可以使用Redis来作为消息队列
- 如果业务有海量消息,不能容忍丢数据,且经常积压消息,那还是提高配置引入MQ吧
十九、Redis的最佳实践
- 不同业务使用不同的实例
- Redis默认有16个实例,可以进行业务隔离,读写操作互不影响
- 实例的容量控制在2~6GB之间
- 这样生成RDB快照、主从集群同步,都能较快完成,不会阻塞正常请求的处理
- 慢查询日志定期持久化
- 调大慢查询列表 slowlog-max-len = 1000
- 按照并发量调整慢查询参数 slowlog-log-slower-than
- 默认是超过10ms判定为慢查询,可以根据高并发场景来调整
- 能用整数就用整数
- Redis内部维护了0~9999这一万个整数对象,放在了共享池中
- 谨慎使用全量操作的命令
- 比如smembers、hgetall等操作,如果集合很大,会严重阻塞主进程
- 推荐使用sscan、hscan来返回数据
二十、详解Redis内存模型
1、如何统计Redis内存
1、info memory
可以使用 info memory 命令,查看Redis当前的内存使用情况。
info 命令可以显示Redis的很多信息,比如服务器信息、CPU、内存、持久化、客户端连接信息等,memory表示只显示内存相关信息。
解释重要的几个参数:
- used_memory:
- Redis分配器分配的内存总量(单位是字节),包括使用的虚拟内存(即swap)
- used_memory_human:将used_memory转换成带单位的字符串
- used_memory_rss:
- Redis进程占据操作系统的内存(单位是字节),与top及ps命令看到的值是一致的
- 除了分配器分配的内存之外,used_memory_rss还包括进程运行本身需要的内存、内存碎片等,但是不包括虚拟内存
- mem_fragmentation_ratio:
- 内存碎片比率,该值是used_memory_rss / used_memory的比值
- mem_allocator:
- Redis使用的内存分配器,在编译时指定。
- 可以是 libc 、jemalloc、tcmalloc
2、used_memory和used_memory_rss有什么区别
used_memory是Redis角度,Redis的内存分配器分配出去的内存总量,这块也包含虚拟内存,这个不难理解,因为它是在操作系统提供的内存。
used_memory_rss是操作系统角度,整个Redis占用的物理内存总量,除了分配器分配的内存,还包含Redis进程本身占用的内存,以及内存碎片,但是不包含虚拟内存。
这两个值往往不同。可以理解为:
used_memory 是Redis认为自己存数据占用的物理内存大小
used_memory_rss 是Redis实际占用的物理内存大小
在实际使用中,Redis的数据量比较大,Redis进程占用的内存比起Redis的内存分配和空间碎片会小很多,所以used_memory_rss和used_memory的比例就可以当做衡量Redis内存碎片率的参数,这个就是mem_fragmentation_ratio。
3、怎样看Redis的内存占用情况是好还是坏
只需要看mem_fragmentation_ratio这个参数即可,这是Redis的内存碎片比率。
-
这个值一般
大于1
,对于jemalloc来说,一般在1.03左右是比较健康的状态。这个值越大,
内存碎片
比例也就越大,如果太大就应该考虑处理内存碎片。 -
如果这个值
小于1,说明Redis使用了虚拟内存,这就说明物理内存不够
。由于使用虚拟内存需要进行页面置换导致降低效率,所以此时应该立即处理,比如增加Redis内存、增加Redis节点、优化使用等。
2、Redis内存划分
Redis占用的内存,不仅仅是用来存键值对的。
1、数据
数据是最主要的部分,这部分占用的内存会统计在used_memory中。
2、进程运行需要的内存
Redis主进程本身运行肯定需要占用内存,如代码、常量池等等。
这部分内存大约几兆,在大多数生产环境中与Redis数据占用的内存相比可以忽略。
这部分内存不是由jemalloc分配,因此不会统计在used_memory中。
除了主进程外,Redis创建的子进程运行也会占用内存,如Redis执行AOF、RDB重写时创建的子进程。
当然,这部分内存不属于Redis进程,也不会统计在used_memory和used_memory_rss中。
3、缓冲区内存
缓冲内存包括客户端缓冲区、复制积压缓冲区、AOF缓冲区等
- 客户端缓冲存储客户端连接的输入输出缓冲
- 复制积压缓冲用于部分复制功能
- AOF缓冲区用于在进行AOF重写时,保存最近的写入命令
这部分内存由jemalloc分配,因此会统计在used_memory中。
4、内存碎片
1、什么是内存碎片
内存碎片是Redis在分配、回收物理内存过程中产生的。
例如,如果对数据的更改频繁,而且数据之间的大小相差很大,可能导致redis释放的空间在物理内存中并没有释放,但redis又无法有效利用,这就形成了内存碎片。
内存碎片不会统计在used_memory中。
2、为什么会产生内存碎片
3、如何处理内存碎片
如果Redis服务器中的内存碎片已经很大:
- 在4.0以下版本,只能使用重启恢复
- 因为重启之后,Redis重新从日志文件中读取数据,在内存中进行重排,为每个数据重新选择合适的内存单元,减小内存碎片。
- 在4.0以上版本,Redis提供了自动和手动的碎片整理功能
- 原理大致是把数据拷贝到新的内存空间,然后把老的空间释放掉,这期间会阻塞主进程
手动整理碎片:
memory purge命令
自动整理碎片:
使用 config set activedefrag yes 指令,或者在 redis.conf 配置 activedefrag yes 表示启动自动清理功能
自动清理的时机:
- active-defrag-ignore-bytes 200mb:内存碎片的大小达到 200MB,开始清理。
- active-defrag-threshold-lower 6:表示内存碎片空间占操作系统分配给 Redis 的总空间比例达到 6% 时,开始清理。
除此之外,Redis 为了防止清理碎片对 Redis 正常处理指令造成影响,有两个参数用于控制清理操作占用 CPU 的时间比例上下限:
- active-defrag-cycle-min 15:自动清理过程所用 CPU 时间的比例不低于 15%,保证清理能有效展开。
- active-defrag-cycle-max 50:表示自动清理过程所用 CPU 时间的比例不能大于 50%,一旦超过,就停止清理,从而避免在清理时,大量的内存拷贝阻塞 Redis执行命令。
3、jemalloc
Redis在编译时便会指定内存分配器;内存分配器可以是 libc 、jemalloc或者tcmalloc,默认是jemalloc。
jemalloc作为Redis的默认内存分配器,在减小内存碎片
方面做的相对比较好。
jemalloc在64位系统中,将内存空间划分为小、大、巨大三个范围,每个范围内又划分了许多小的内存块单位。
当Redis存储数据时,会选择大小最合适的内存块进行存储。
这里存在一个重要的优化点:
可以根据jemalloc的内存块大小来合理设置Key的长度,避免由于Key多出一两个字节,导致内存块升级,造成不必要的内存占用。
4、再谈RedisObject
之前在数据结构那块介绍过RedisObject,知道了它是用来封装具体的数据类型的。
但是RedisObject还有很多非常重要的功能,Redis对象的类型、内部编码、内存回收、共享对象等功能,都需要redisObject支持。
来看RedisObject的完整结构:
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:REDIS_LRU_BITS;
int refcount;
void *ptr;
} robj;
除了三个服务于存储的属性,还有两个服务于内存回收和共享对象的属性:lru、refcount
1、lru
lru占24位,记录的是对象最后一次被访问的时间。通过对比lru记录的时间和当前时间,可以看出该对象未被访问的时长,称之为空转时间
。
它主要用于Redis的内存淘汰机制。如果Redis打开了maxmemory选项,且内存回收算法选择的是volatile-lru或allkeys—lru
那么当Redis内存占用超过maxmemory指定的值时,Redis会优先选择空转时间最长的对象进行释放
如果是 LFU 策略,那么低 8 位表示访问频率,高 16 位表示访问时间
2、refcount
1、refcount与共享对象
refcount记录的是该对象被引用的次数,用于对象的引用计数和内存回收,占4字节。
-
当创建新对象时,refcount初始化为1
当有新程序使用该对象时,refcount+1
-
当对象不再被一个新程序使用时,refcount-1
-
当refcount变为0时,对象占用的内存会被释放
Redis中被多次使用的对象(refcount>1),称为共享对象
。
2、为什么Redis中会存在对象引用
Redis中的对象引用和JVM的不一样。
RedisObject会被entry引用,RedisObject里面指向的是具体的数据结构,被重复使用的RedisObject,就是共享对象。
Redis为了节省内存,做了一个0~9999的整数池,这些整数值的字符串对象可以被共享,这就导致它的引用计数会大于1。
整数池包含多少个整数是可以修改的,参数是 REDIS_SHARED_INTEGERS
3、共享对象的具体实现
目前共享对象仅支持0~9999的整数池中的整数值字符串对象
,这是有很多考量的。
这是对内存占用和CPU消耗的平衡:共享对象虽然会降低内存消耗,但是判断两个对象是否相等却需要消耗额外的时间
。
- 对于整数值,判断操作复杂度为O(1),因为只需要看是否在整数池的范围内即可,如果在就拿共享整数对象。
- 对于普通字符串,判断复杂度为O(n)
- 而对于哈希、列表、集合和有序集合,判断的复杂度为O(n^2)。
4、共享对象失效的场景
有两个场景会导致整数池失效:
- Redis 中设置了 maxmemory 限制最大内存占用大小,且启用了 LRU 策略(allkeys-lru 或 volatile-lru 策略)
- 因为 LRU 需要记录每个键值对的访问时间,都共享一个整数 对象,LRU 策略就无法进行统计了
- 集合类型的编码采用 ziplist 编码,并且集合内容是整数,也不能共享一个整数对象。
- 因为使用了 ziplist 紧凑型内存结构存储数据,可以不用去判断整数对象是否共享
为了避免误解,这里解释一下:这里的失效应该是指优化失效,而不是类似缓存失效。
3、RedisObject的大小
一个redisObject对象的大小为16字节:类型(4位)+编码(4位)+lru(24位)+引用计数(4字节)+数据结构指针(8字节)。