Redis--架构设计、LUA 脚本、大Key处理、Redis做消息队列、Redis内存模型

十五、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的配置文件中设置淘汰策略。

img

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 是这样变化的:

  1. 先按照上次访问距离当前的时长,来对 logc 进行衰减;
  2. 然后,再按照一定概率增加 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

通过慢查询日志,可以知道是哪些命令的查询执行得比较慢。

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) 释放跳表对象;

image-20220725174502820

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

img

不过这种做法有一个明显的问题:

生产者向List中写入数据后,消费者并不知道,所以消费者必须循环调用rpop来轮询数据

为了保证及时获取消息,这个轮询的频率只能非常高,导致消费者的CPU消耗太大,带来很大的性能损失。

2、阻塞获取消息

不过Redis提供了一个brpop命令,也称为阻塞式读取

使用brpop,客户端在没有读到队列数据时会自动阻塞,直到队列中有新的数据之后,再开始读取数据。

这种阻塞式读取的效率显然比轮询高很多。

img

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的使用方式

img

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 命令确认消息已经被消费完成,整个流程的执行如下图所示:

img

如果消费者没有成功处理消息,就不要给 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、为什么会产生内存碎片

image-20220823161851783

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字节)。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值