redis学习笔记

Redis

一.缓存简介

1.1 缓存的读写模式

  • Cache Aside Pattern(旁路缓存 )

    先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    更新的时候,先更新数据库,然后再删除缓存。

    截屏2023-08-10 15.19.32

    为什么是删除缓存,而不是更新缓存呢?

    • 缓存的值是一个hash、list结构时更新数据需要先遍历(耗时)后修改
    • 是一种懒加载,使用的时候才更新缓存,使用的时候才从DB中加载
    • 可以采用异步的方式填充缓存,开启一个线程 定时将DB的数据刷到缓存中

    高并发脏读的四种情况

    • 先更新数据库,再更新缓存

      update与commit之间,更新缓存,commit失败

      则DB与缓存数据不一致

    • 先更新数据库,再删除缓存(推荐)

      update与commit之间,删除缓存,然后有新的读,缓存空,读DB数据到缓存 数据是旧的数据

      commit后 DB为新数据

      则DB与缓存数据不一致,可以使用采用延时双删策略,删除缓存后隔一定时间(此时数据库已经更新)再删除一次缓存

    • 先删除缓存,再更新数据库

      commit之前删除缓存,有新的读,缓存空,读DB数据到缓存 数据是旧的数据

      commit后 DB为新数据

      则DB与缓存数据不一致

    • 先更新缓存,再更新数据库

      更新缓存后commit失败

      则DB与缓存数据不一致

  • Read/Write Through Pattern

    应用程序只操作缓存,缓存操作数据库。

    • Read-Through(穿透读模式/直读模式):应用程序读缓存,缓存没有,由缓存回源到数据库,并写入缓存。(guavacache)

    • Write-Through(穿透写模式/直写模式):应用程序写缓存,缓存写数据库。该种模式需要提供数据库的handler,开发较为复杂。

  • Write Behind Caching Pattern

    应用程序只更新缓存。缓存通过异步的方式将数据批量或合并后更新到DB中。不能时时同步,甚至会丢数据

1.2 缓存架构的设计

1.多层次

分布式缓存宕机,本地缓存还可以使用

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

2.数据类型

  • 简单数据类型

    value是字符串或整数或二进制

    value的值比较大(大于100K)

    只进行setter和getter

    可采用Memcached,Memcached纯内存缓存,多线程 K-V

  • 复杂数据类型

    value是hash、set、list、zset

    需要存储关系,聚合,计算.可采用Redis

3.要做集群

Redis集群方案:codis、哨兵+主从、RedisCluster

4.缓存的数据结构设计

  • 与数据库表一致

    数据库表和缓存是一一对应的

    缓存的字段会比数据库表少一些

    缓存的数据是经常访问的用户表,商品表

  • 与数据库表不一致

    需要存储关系,聚合,计算等,需要根据不同的业务设计不同的数据结构,几乎不会和数据表一致

1.3 Redis应用场景

  • 缓存使用,减轻DB压力

  • DB使用,用于临时存储数据(字典表,购买记录)

  • 解决分布式场景下Session分离问题(登录信息)

  • 任务队列(秒杀、抢红包等等)

  • 乐观锁

  • 应用排行榜 zset

  • 签到 bitmap

  • 分布式锁

  • 冷热数据交换

二.Redis数据类型和应用

Redis是一个Key-Value的存储系统,使用ANSI C语言编写。

key的类型是字符串。

value的数据类型有:

  • 常用的:string字符串类型、list列表类型、set集合类型、sortedset(zset)有序集合类型、hash类型。

  • 不常见的:bitmap位图类型、geo地理位置类型。

  • Redis5.0新增一种:stream类型

注意:Redis中命令是忽略大小写,(set SET),key是不忽略大小写的 (NAME name)

redis命令

2.1 string

Redis的String能表达3种值的类型:字符串、整数、浮点数 100.01 是个六位的串

常见命令:

命令描述
SET设置指定 key 的值
GET获取指定 key 的值
GETRANGE返回 key 中字符串值的子字符
GETSET将给定 key 的值设为 value ,并返回 key 的旧值 ( old value )
GETBIT对 key 所储存的字符串值,获取指定偏移量上的位 ( bit )
MGET获取所有(一个或多个)给定 key 的值
SETBIT对 key 所储存的字符串值,设置或清除指定偏移量上的位(bit)
SETEX设置 key 的值为 value 同时将过期时间设为 seconds
SETNX只有在 key 不存在时设置 key 的值
SETRANGE从偏移量 offset 开始用 value 覆写给定 key 所储存的字符串值
STRLEN返回 key 所储存的字符串值的长度
MSET同时设置一个或多个 key-value 对
MSETNX同时设置一个或多个 key-value 对
PSETEX以毫秒为单位设置 key 的生存时间
INCR将 key 中储存的数字值增一
INCRBY将 key 所储存的值加上给定的增量值 ( increment )
INCRBYFLOAT将 key 所储存的值加上给定的浮点增量值 ( increment )
DECR将 key 中储存的数字值减一
DECRBY将 key 所储存的值减去给定的减量值 ( decrement )
APPEND将 value 追加到 key 原来的值的末尾

应用场景:

  • key和命令是字符串
  • 普通的赋值
  • incr用于乐观锁:递增数字,可用于实现乐观锁 watch(事务)
  • setnx用于分布式锁:当value不存在时采用赋值,可用于实现分布式锁

2.2 list

可以存储有序、可重复的元素

获取头部或尾部附近的记录是极快的

元素个数最多为2^32-1个(40亿)

常见命令:

命令描述
BLPOP移出并获取列表的第一个元素
BRPOP移出并获取列表的最后一个元素
BRPOPLPUSH从列表中弹出一个值,并将该值插入到另外一个列表中并返回它
LINDEX通过索引获取列表中的元素
LINSERT在列表的元素前或者后插入元素
LLEN获取列表长度
LPOP移出并获取列表的第一个元素
LPUSH将一个或多个值插入到列表头部
LPUSHX将一个值插入到已存在的列表头部
LRANGE获取列表指定范围内的元素
LREM移除列表元素
LSET通过索引设置列表元素的值
LTRIM对一个列表进行修剪(trim)
RPOP移除并获取列表最后一个元素
RPOPLPUSH移除列表的最后一个元素,并将该元素添加到另一个列表并返回
RPUSH在列表尾部中添加一个或多个值
RPUSHX为已存在的列表尾部添加值

应用场景:

  • 作为栈或队列使用:列表有序可以作为栈和队列使用

  • 可用于各种列表:比如用户列表、商品列表、评论列表等

2.3 set

无序、唯一元素

集合中最大的成员数为 2^32 - 1

常见命令:

命令描述
SADD向集合添加一个或多个成员
SCARD获取集合的成员数
SDIFF返回给定所有集合的差集
SDIFFSTORE返回给定所有集合的差集并存储在 destination 中
SINTER返回给定所有集合的交集
SINTERSTORE返回给定所有集合的交集并存储在 destination 中
SISMEMBER判断 member 元素是否是集合 key 的成员
SMEMBERS返回集合中的所有成员
SMOVE将 member 元素从 source 集合移动到 destination 集合
SPOP移除并返回集合中的一个随机元素
SRANDMEMBER返回集合中一个或多个随机数
SREM移除集合中一个或多个成员
SUNION返回所有给定集合的并集
SUNIONSTORE所有给定集合的并集存储在 destination 集合中
SSCAN迭代集合中的元素

应用场景:

适用于不能重复的且不需要顺序的数据结构

比如:关注的用户,还可以通过spop进行随机抽奖

2.4 sortedset

元素本身是无序不重复的

每个元素关联一个分数(score)

可按分数排序,分数可重复

常见命令:

命令描述
ZADD向有序集合添加一个或多个成员,或者更新已存在成员的分数
ZCARD获取有序集合的成员数
ZCOUNT计算在有序集合中指定区间分数的成员数
ZINCRBY有序集合中对指定成员的分数加上增量 increment
ZINTERSTORE计算给定的一个或多个有序集的交集并将结果集存储在新的有序集合 key 中
ZLEXCOUNT在有序集合中计算指定字典区间内成员数量
ZRANGE通过索引区间返回有序集合成指定区间内的成员
ZRANGEBYLEX通过字典区间返回有序集合的成员
ZRANGEBYSCORE通过分数返回有序集合指定区间内的成员
ZRANK返回有序集合中指定成员的索引
ZREM移除有序集合中的一个或多个成员
ZREMRANGEBYLEX移除有序集合中给定的字典区间的所有成员
ZREMRANGEBYRANK移除有序集合中给定的排名区间的所有成员
ZREMRANGEBYSCORE移除有序集合中给定的分数区间的所有成员
ZREVRANGE返回有序集中指定区间内的成员,通过索引,分数从高到底
ZREVRANGEBYSCORE返回有序集中指定分数区间内的成员,分数从高到低排序
ZREVRANK返回有序集合中指定成员的排名,有序集成员按分数值递减(从大到小)排序
ZSCORE返回有序集中,成员的分数值
ZUNIONSTORE计算一个或多个有序集的并集,并存储在新的 key 中
ZSCAN迭代有序集合中的元素(包括元素成员和元素分值)

应用场景:

由于可以按照分值排序,所以适用于各种排行榜。比如:点击排行榜、销量排行榜、关注排行榜等。

2.5 hash

是一个 string 类型的 field 和 value 的映射表,它提供了字段和字段值的映射。

每个 hash 可以存储 2^32 - 1 键值对(40多亿)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

常见命令:

命令说明
HDEL用于删除哈希表中一个或多个字段
HEXISTS用于判断哈希表中字段是否存在
HGET获取存储在哈希表中指定字段的值
HGETALL获取在哈希表中指定 key 的所有字段和值
HINCRBY为存储在 key 中的哈希表指定字段做整数增量运算
HKEYS获取存储在 key 中的哈希表的所有字段
HLEN获取存储在 key 中的哈希表的字段数量
HSET用于设置存储在 key 中的哈希表字段的值
HVALS用于获取哈希表中的所有值

应用场景:

对象的存储 ,表数据的映射

2.6 bitmap

bitmap是进行位操作的

通过一个bit位来表示某个元素对应的值或者状态,其中的key就是对应元素本身。

bitmap本身会极大的节省储存空间。

常见命令:

命令说明
setbit设置key在offset处的bit值(只能是0或者1)
getbit获得key在offset处的bit值
bitcount获得key的bit位为1的个数
bitpos返回第一个被设置为bit值的索引值
bitop对多个key 进行逻辑运算后存入destkey

应用场景:

  • 用户每月签到,用户id为key , 日期作为偏移量 1表示签到
  • 统计活跃用户, 日期为key,用户id为偏移量 1表示活跃
  • 查询用户在线状态, 日期为key,用户id为偏移量 1表示在线

2.7 geo

geo是Redis用来处理位置信息的。在Redis3.2中正式使用。主要是利用了Z阶曲线、Base32编码和geohash算法

Z阶曲线

在x轴和y轴上将十进制数转化为二进制数,采用x轴和y轴对应的二进制数依次交叉后得到一个六位数编码。把数字从小到大依次连起来的曲线称为Z阶曲线,Z阶曲线是把多维转换成一维的一种方法。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Base32编码

Base32这种数据编码机制,主要用来把二进制数据编码成可见的字符串,其编码规则是:任意给定一个二进制数据,以5个位(bit)为一组进行切分(base64以6个位(bit)为一组),对切分而成的每个组进行编码得到1个可见字符。Base32编码表字符集中的字符总数为32个(0-9、b-z去掉a、i、l、o),这也是Base32名字的由来。

geohash算法

Gustavo在2008年2月上线了geohash.org网站。Geohash是一种地理位置信息编码方法。 经过geohash映射后,地球上任意位置的经纬度坐标可以表示成一个较短的字符串。可以方便的存储在数据库中,附在邮件上,以及方便的使用在其他服务中。以北京的坐标举例,[39.928167,116.389550]可以转换成 wx4g0s8q3jf9 。

Redis中经纬度使用52位的整数进行编码,放进zset中,zset的value元素是key,score是GeoHash的52位整数值。在使用Redis进行Geo查询时,其内部对应的操作其实只是zset(skiplist)的操作。通过zset的score进行排序就可以得到坐标附近的其它元素,通过将score还原成坐标值就可以得到元素的原始坐标。

常见命令:

命令说明
geoadd添加地理坐标
geohash返回标准的geohash串
geopos返回成员经纬度
geodist计算成员间距离
georadiusbymember根据成员查找附近的成员
georadius以给定的经纬度为中心, 找出某一半径内的元素

应用场景:

  • 记录地理位置
  • 计算距离
  • 查找"附近的人"

2.8 stream

stream是Redis5.0后新增的数据结构,用于可持久化的消息队列。

几乎满足了消息队列具备的全部内容:

  • 消息ID的序列化生成
  • 消息遍历
  • 消息的阻塞和非阻塞读取
  • 消息的分组消费
  • 未完成消息的处理
  • 消息队列监控

每个Stream都有唯一的名称,它就是Redis的key,首次使用 xadd 指令追加消息时自动创建

常见命令:

命令说明
xadd将指定消息数据追加到指定队列(key)中
xread从消息队列中读取,COUNT:读取条数,
xrange读取队列中给定ID范围的消息 COUNT:返回消息条数(消息id从小到大)
xrevrange读取队列中给定ID范围的消息 COUNT:返回消息条数(消息id从大到小)
xdel删除队列的消息
xgroup create key groupname id创建一个新的消费组
xgroup destory key groupname删除指定消费组
xgroup delconsumer key groupname cname删除指定消费组中的某个消费者
xgroup setid key id修改指定消息的最大id
xreadgroup group groupname consumer COUNT streams key从队列中的消费组中创建消费者并消费数据(consumer不存在则创建)

应用场景:

消息队列的使用

三.Redis 扩展

3.1 发布与订阅

Redis的发布订阅机制包括三个部分,publisher,subscriber和Channel

截屏2023-08-21 18.17.02

发布者和订阅者都是Redis客户端,Channel则为Redis服务器端。

发布者将消息发送到某个的频道,订阅了这个频道的订阅者就能接收到这条消息。

发布者通过publish命令来在渠道上发布消息

publish ch1 hello 

订阅者通过subscribe命令来订阅某些渠道

subscribe ch1 ch2 

订阅者通过unsubscribe命令来退订某些渠道

unsubscribe ch1 

订阅者通过psubscribe命令(模式匹配)来批量订阅某些渠道

psubscribe ch*

订阅者通过punsubscribe命令来退订批量订阅

psubscribe ch*

3.1.1 机制

订阅某个频道或模式:

  • 客户端(client)

    属性为pubsub_channels,该属性表明了该客户端订阅的所有频道,是一个dict,以channel为key

    属性为pubsub_patterns,该属性表示该客户端订阅的所有模式,是一个list

  • 服务器端(RedisServer):

    属性为pubsub_channels,该服务器端中的所有频道以及订阅了这个频道的客户端,是一个dict,它以channel 为key,订阅channel的client list为value

    属性为pubsub_patterns,该服务器端中的所有模式和订阅了这些模式的客户端,是一个list,每个节点都包含一个被订阅的模式以及其所有的客户端

当客户端向某个频道发送消息时,Redis首先在redisServer中的pubsub_channels中找出键为该频道的结点,遍历该结点的值,即遍历订阅了该频道的所有客户端,将消息发送给这些客户端。

然后遍历pubsub_patterns,找出包含该频道的模式的节点,将消息发送给订阅了该模式的客户端。

3.2 事务

redis的事务是一个单独隔离的操作,它会将一系列指令按需排队并顺序执行,期间不会被其他客户端的指令插队

  • Redis的事务是通过multi、exec、discard和watch这四个命令来完成的。
  • Redis的单个命令都是原子性的,所以这里需要确保事务性的对象是命令集合。
  • Redis将命令集合序列化并确保处于同一事务的命令集合连续且不被打断的执行
  • Redis不支持回滚操作

3.2.1 redis的事务与ACID

  • 原子性

    redis设计者认为他们是支持原子性的,因为原子性的概念是:所有指令要么全部执行,要么全部不执行。而非一起成功或者失败。

  • 一致性

    redis事务保证命令失败(组队时出错)的情况下可以回滚,确保了一致性。但是在运行时出现错误不能回滚也没有确保一致性

  • 隔离性

    redis是基于单线程的,所以执行指令时不会被其他客户端打断,保证了隔离性。但是redis并没有像其他关系型数据库一样设计隔离级别。

  • 持久性

    持久性的定义为事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。考虑到性能问题,redis无论rdb还是aof都是异步持久化,所以并不能保证持久性

3.2.2 Redis事务的其他实现方式

基于lua脚本可以保证redis指令一次性执按顺序执行完成,并且不会被其他客户端打断。我们可以将其想象为redis实现悲观锁的一种方式,但是这种方式却无法实现事务回滚

Redis事务三特性

  • 单独的隔离操作

    事务中的命令都会序列化并且按序执行,执行过程中不会被其他客户端的指令打断。

  • 没有隔离级别的概念
    事务提交前所有指令都不会被执行。

  • 无原子性
    运行时出错某段指令,事务过程中的指令仍然会生效

3.2.3 命令

  • multi:用于标记事务块的开始,Redis会将后续的命令逐个放入队列中,然后使用exec原子化地执行这个命令队列

  • exec:执行命令队列

  • discard:清除命令队列

  • watch:监视key,如果监视的key对应的值发生了变化,那么不会执行命令队列中的命令

  • unwatch:清除监视key

3.2.4 机制

3.2.4.1 事务的执行
  • 事务开始

    在RedisClient中,有属性flags,用来表示是否在事务中flags=REDIS_MULTI

  • 命令入队

    RedisClient将命令存放在事务队列中(EXEC,DISCARD,WATCH,MULTI除外)

  • 事务队列

    multiCmd *commands 用于存放命令,是一个数组,FIFO顺序 ,先入队的命令在前,后入队在后

  • 执行事务

    RedisClient向服务器端发送exec命令,RedisServer会遍历事务队列,执行队列中的命令,最后将执行的结果一次性返回给客户端。

如果某条命令在入队过程中发生错误,redisClient将flags置为REDIS_DIRTY_EXEC,EXEC命令将会失败返回

弱事务
  • Redis语法错误

    整个事务的命令在队列里都清除,不会执行命令

  • Redis运行错误

    依次执行命令,如果某条命令执行失败会继续执行命令,对于失败命令会返回异常,已经执行的命令不支持回滚

  • Redis不支持事务回滚

    大多数事务失败是因为语法错误或者类型错误,这两种错误,在开发阶段都是可以预见的

    Redis为了性能方面就忽略了事务回滚。 (回滚需要记录历史版本)

3.2.4.2 watch的执行

redisDb有一个watched_keys字典,key是某个被监视的数据的key,值是一个链表.记录了所有监视这个数据的客户端。

监视机制的触发,当修改数据后,监视这个数据的客户端的flags置为REDIS_DIRTY_CAS

RedisClient向服务器端发送exec命令,服务器判断RedisClient的flags,如果为REDIS_DIRTY_CAS,则清空事务队列。

3.3 Lua

lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

Lua应用场景:游戏开发、独立应用脚本、Web应用脚本、扩展和数据库插件。

nginx上使用lua 实现高并发

OpenRestry:一个可伸缩的基于Nginx的Web平台,是在nginx之上集成了lua模块的第三方服务器,内部集成了大量精良的Lua库、第三方模块以及大多数的依赖项。用于方便地搭建能够处理超高并发(日活千万级别)、扩展性极高的动态Web应用、Web服务和动态网关功能和nginx类似,就是由于支持lua动态脚本,所以更加灵活

OpenRestry通过Lua脚本扩展nginx功能,可提供负载均衡、请求路由、安全认证、服务鉴权、流量控制与日志监控等服务。类似的还有Kong(Api Gateway)、tengine(阿里)

在redis中lua脚本命令是原子的,RedisServer在执行脚本命令中,不允许插入新的命令

在redis中lua脚本的命令可以复制,RedisServer在获得脚本后不执行,生成标识返回,Client根据标识就可以随时执行

3.3.1 EVAL命令

通过执行redis的eval命令,可以运行一段lua脚本

EVAL script numkeys key [key ...] arg [arg ...]
  • script

    是一段Lua脚本程序,它会被运行在Redis服务器上下文中,这段脚本不必(也不应该)定义为一个Lua函数。

  • numkeys

    用于指定键名参数的个数。

  • key [key …]

    从EVAL的第三个参数开始算起,使用了numkeys个键(key),表示在脚本中所用到的那些Redis键(key),这些键名参数可以在Lua中通过全局变量KEYS数组,用1为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)。

  • arg [arg …]

    可以在Lua中通过全局变量ARGV数组访问,访问的形式和KEYS变量类似(ARGV[1] 、 ARGV[2] ,诸如此类)。

eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second

在脚本中使用redis命令

  • redis.call()

    返回值就是redis命令执行的返回值

    如果出错,则返回错误信息,不继续执行

  • redis.pcall()

    返回值就是redis命令执行的返回值

    如果出错,则记录错误信息,继续执行

  • 注意事项

    在脚本中,使用return语句将返回值返回给客户端,如果没有return,则返回nil

eval "return redis.call('set',KEYS[1],ARGV[1])" 1 n1 zhaoyun

3.3.2 EVALSHA命令

EVAL 命令要求你在每次执行脚本的时候都发送一次脚本主体(script body)。

Redis 有一个内部的缓存机制,因此它不会每次都重新编译脚本,不过在很多场合,付出无谓的带宽来传送脚本主体并不是最佳选择。

为了减少带宽的消耗, Redis 实现了 EVALSHA 命令,它的作用和 EVAL 一样,都用于对脚本求值,但它接受的第一个参数不是脚本,而是脚本的 SHA1 校验和(sum)

3.3.3 SCRIPT命令

SCRIPT FLUSH **:**清除所有脚本缓存

SCRIPT EXISTS **:**根据给定的脚本校验和,检查指定的脚本是否存在于脚本缓存

SCRIPT LOAD **:**将一个脚本装入脚本缓存,返回SHA1摘要,但并不立即运行它

SCRIPT KILL **:**杀死当前正在运行的脚本

192.168.24.131:6380> script load "return redis.call('set',KEYS[1],ARGV[1])" "c686f316aaf1eb01d5a4de1b0b63cd233010e63d" 
192.168.24.131:6380> evalsha c686f316aaf1eb01d5a4de1b0b63cd233010e63d 1 n2 lisi OK192.168.24.131:6380> get n2

3.3.4 脚本复制

Redis 传播Lua脚本,在使用主从模式和开启AOF持久化的前提下:

当执行lua脚本时,Redis 服务器有两种模式:脚本传播模式和命令传播模式。

3.3.4.1 脚本传播模式

脚本传播模式是 Redis 复制脚本时默认使用的模式 Redis会将被执行的脚本及其参数复制到 AOF 文件以及从服务器里面。

执行以下命令:

eval "redis.call('set',KEYS[1],ARGV[1]);redis.call('set',KEYS[2],ARGV[2])" 2 n1 n2
zhaoyun1 zhaoyun2

那么主服务器将向从服务器发送完全相同的 eval 命令:

eval "redis.call('set',KEYS[1],ARGV[1]);redis.call('set',KEYS[2],ARGV[2])" 2 n1 n2
zhaoyun1 zhaoyun2

注意:在这一模式下执行的脚本不能有时间、内部状态、随机函数(spop)等。执行相同的脚本以及参数 必须产生相同的效果。在Redis5,也是处于同一个事务中。

3.3.4.2 命令传播模式

处于命令传播模式的主服务器会将执行脚本产生的所有写命令用事务包裹起来,然后将事务复制到 AOF 文件以及从服务器里面。

因为命令传播模式复制的是写命令而不是脚本本身,所以即使脚本本身包含时间、内部状态、随机函数 等,主服务器给所有从服务器复制的写命令仍然是相同的。

为了开启命令传播模式,用户在使用脚本执行任何写操作之前,需要先在脚本里面调用以下函数:

redis.replicate_commands()

redis.replicate_commands() 只对调用该函数的脚本有效:在使用命令传播模式执行完当前脚本之后, 服务器将自动切换回默认的脚本传播模式。

如果我们在主服务器执行以下命令:

eval
"redis.replicate_commands();redis.call('set',KEYS[1],ARGV[1]);redis.call('set',KEYS[2],ARGV[2])" 2 n1 n2 zhaoyun11 zhaoyun22

那么主服务器将向从服务器复制以下命令:

EXEC
*1
$5
MULTI
*3
$3
set
$2
n1
$9
zhaoyun11
*3
$3
set
$2
n2
$9
zhaoyun22
*1
$4
EXEC
3.3.4.3 管道(pipeline),事务和脚本(lua)三者的区别
  • 三者都可以批量执行命令
  • 管道无原子性,命令都是独立的,属于无状态的操作
  • 事务和脚本是有原子性的,其区别在于脚本可借助Lua语言可在服务器端存储的便利性定制和简化操作
  • 脚本的原子性要强于事务,脚本执行期间,另外的客户端其它任何脚本或者命令都无法执行,脚本的执行时间应该尽量短,不能太耗时的脚本

3.4 慢日志

Redis使用列表存储慢查询日志,采用队列方式(FIFO)

3.4.1 慢查询设置

在redis.conf中可以配置和慢查询日志相关的选项:

#执行时间超过多少微秒的命令请求会被记录到日志上 0 :全记录 <0 不记录 
slowlog-log-slower-than 10000 
#slowlog-max-len 存储慢查询日志条数 
slowlog-max-len 128

config set的方式可以临时设置,redis重启后就无效

config set slowlog-log-slower-than 微秒

config set slowlog-max-len 条数

查看日志:slowlog get [n]

3.4.2 慢日志保存

在redisServer中保存了和慢查询日志相关的信息

{  
// 下一条慢查询日志的 ID 
long long slowlog_entry_id; 
// 保存了所有慢查询日志的链表 FIFO 
list *slowlog; 
// 服务器配置 slowlog-log-slower-than 选项的值 
long long slowlog_log_slower_than; 
// 服务器配置 slowlog-max-len 选项的值 
unsigned long slowlog_max_len;
};

lowlog 链表保存了服务器中的所有慢查询日志, 链表中的每个节点都保存了一个 slowlogEntry 结构, 每个 slowlogEntry 结构代表一条慢查询日志

typedef struct slowlogEntry { 
// 唯一标识符 
long long id; 
// 命令执行时的时间,格式为 UNIX 时间戳 
time_t time; 
// 执行命令消耗的时间,以微秒为单位 
long long duration; 
// 命令与命令参数 
robj **argv; 
// 命令与命令参数的数量 
int argc; 
} slowlogEntry;
3.4.3 慢查询定位与处理

使用slowlog get 可以获得执行较慢的redis命令,针对该命令可以进行优化:

  • 尽量使用短的key,对于value有些也可精简,能使用int就int。
  • 避免使用keys *、hgetall等全量操作。
  • 减少大key的存取,打散为小key 100K以上
  • 将rdb改为aof模式
  • rdb fork 子进程数据量过大主进程阻塞 redis性能大幅下降
  • 关闭持久化 (适合于数据量较小,有固定数据源)
  • 想要一次添加多条数据的时候可以使用管道
  • 尽可能地使用哈希存储
  • 尽量限制下redis使用的内存大小,这样可以避免redis使用swap分区或者出现OOM错误

3.5 监视器

Redis客户端通过执行MONITOR命令可以将自己变为一个监视器,实时地接受并打印出服务器当前处理的命令请求的相关信息。

redisServer 维护一个 monitors 的链表,记录自己的监视器,每次收到 MONITOR 命令之后,将客户端追加到链表尾

此时,当其他客户端向服务器发送一条命令请求时,服务器除了会处理这条命令请求之外,还会将这条命令请求的信息发送给所有监视器。

四.Redis 原理

4.1 持久化

Redis是内存数据库,宕机后数据会消失。

Redis重启后快速恢复数据,要提供持久化机制

Redis持久化是为了快速的恢复数据而不是为了存储数据

Redis有两种持久化方式:RDB和AOF

注意:Redis持久化不保证数据的完整性。

当Redis用作DB时,DB数据要完整,所以一定要有一个完整的数据源(文件、mysql)在系统启动时,从这个完整的数据源中将数据load到Redis中

4.1.1 RDB

RDB(Redis DataBase),是redis默认的存储方式,RDB方式是通过快照( snapshotting )完成的。

备份这一刻的数据,不保存过程

触发方式
  • 自定义配置的快照规则
  • 执行save或者bgsave命令
  • 执行flushall命令
  • 执行主从复制操作 (第一次)

在redis.conf可以配置save参数来控制多少秒内数据变了多少时触发快照。save 可以配置多个,是一个漏斗设计,这样可以提供更高的性能

# save "" # 不使用RDB存储 不能主从 

save 900 1 # 表示15分钟(900秒钟)内至少1个键被更改则进行快照。 

save 300 10 # 表示5分钟(300秒)内至少10个键被更改则进行快照。 

save 60 10000 # 表示1分钟内至少10000个键被更改则进行快照。 
执行流程

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • Redis父进程首先判断:当前是否存在在执行save,或bgsave/bgrewriteaof(aof文件重写命令)的子进程,如果在执行则bgsave命令直接返回。
  • 父进程执行fork(调用OS函数复制主进程)操作创建子进程,这个复制过程中父进程是阻塞的,Redis不能执行来自客户端的任何命令。
  • 父进程fork后,bgsave命令返回”Background saving started”信息并不再阻塞父进程,并可以响应其他命令。
  • 子进程创建RDB文件,根据父进程内存快照生成临时快照文件,完成后对原有文件进行原子替换。(RDB始终完整)
  • 子进程发送信号给父进程表示完成,父进程更新统计信息。
  • 父进程fork子进程后,继续工作。
文件结构

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • REDIS

    头部5字节固定为“REDIS”字符串

  • RDB_VERSION

    4字节“RDB”版本号(不是Redis版本号),当前为9,填充后为0009

  • AUX_FIELD_KEY_VALUE_PAIRS

    辅助字段,以key-value的形式储存

    字段
    redis-ver5.0.5
    redis-bits64/32
    ctime当前时间戳
    used-mem使用内存
    aof-preamble是否开启aof
    repl-stream-db主从复制
    repl-id主从复制
    repl-offset主从复制
  • DB_NUM

    存储数据库号码

  • DB_DICT_SIZE

    字典大小

  • EXPIRE_DICT_SIZE

    过期key

  • KEY_VALUE_PAIRS

    主要数据,以key-value的形式存储

  • EOF

    结束标志

  • CHECK_SUM

    校验,文件是否损坏,或者是否被修改

优缺点

优点

RDB是二进制压缩文件,占用空间小,便于传输(传给slaver)

主进程fork子进程,可以最大化Redis性能,主进程不能太大,Redis的数据量不能太大,复制过程中主进程阻塞

缺点

不保证数据完整性,会丢失最后一次快照以后更改的所有数据

4.1.2 AOF

AOF(append only file)是Redis的另一种持久化方式。Redis默认情况下是不开启的。开启AOF持久化后

Redis 将所有对数据库进行过写入的命令(及其参数)(RESP)记录到 AOF 文件, 以此达到记录数据库状态的目的,这样当Redis重启后只要按顺序回放这些命令就会恢复到原始状态了。

# 可以通过修改redis.conf配置文件中的appendonly参数开启 
appendonly yes 
# AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的。 
dir ./ 
# 默认的文件名是appendonly.aof,可以通过appendfilename参数修改 
appendfilename appendonly.aof
4.1.2.1 原理

AOF文件中存储的是redis的命令,同步命令到 AOF 文件的整个过程可以分为三个阶段:

  • 命令传播

    Redis 将执行完的命令、命令的参数、命令的参数个数等信息发送到 AOF 程序中。

  • 缓存追加

    AOF 程序根据接收到的命令数据,将命令转换为网络通讯协议的格式,然后将协议内容追加到服务器的 AOF 缓存中。

  • 文件写入和保存

    AOF 缓存中的内容被写入到 AOF 文件末尾,如果设定的 AOF 保存条件被满足的话,fsync 函数或者 fdatasync 函数会被调用,将写入的内容真正地保存到磁盘中。

命令传播

当一个 Redis 客户端需要执行命令时, 它通过网络连接, 将协议文本发送给 Redis 服务器。服务器在接到客户端的请求之后, 它会根据协议文本的内容, 选择适当的命令函数, 并将各个参数从字符串文本转换为 Redis 字符串对象( StringObject )。每当命令函数成功执行之后, 命令参数都会被传播到AOF 程序。

缓存追加

当命令被传播到 AOF 程序之后, 程序会根据命令以及命令的参数, 将命令从字符串对象转换回原来的协议文本。协议文本生成之后, 它会被追加到 redis.h/redisServer 结构的 aof_buf 末尾。

redisServer 结构维持着 Redis 服务器的状态, aof_buf 域则保存着所有等待写入到 AOF 文件的协议文本(RESP)。

文件写入和保存

每当服务器常规任务函数被执行、 或者事件处理器被执行时, flushAppendOnlyFile 函数都会被调用,

这个函数执行以下两个工作:

  • WRITE

    根据条件,将 aof_buf 中的缓存写入到 AOF 文件。

  • SAVE

    根据条件,调用 fsync 或 fdatasync 函数,将 AOF 文件保存到磁盘中。

4.1.2.2 保存模式

Redis 目前支持三种 AOF 保存模式,它们分别是:

  • AOF_FSYNC_NO

    不保存。在这种模式下, 每次调用 flushAppendOnlyFile 函数, WRITE 都会被执行, 但 SAVE 会被略过。

    在这种模式下, SAVE 只会在下述情况下被执行:

    • redis 被关闭

    • AOF 功能被关闭

    • 系统的写缓存被刷新(可能是缓存已经被写满,或者定期保存操作被执行)

    这三种情况下的 SAVE 操作都会引起 Redis 主进程阻塞。

  • AOF_FSYNC_EVERYSEC

    每一秒钟保存一次。(默认)在这种模式中, SAVE 原则上每隔一秒钟就会执行一次, 因为 SAVE 操作是由后台子线程(fork)调用的, 所以它不会引起服务器主进程阻塞。

  • AOF_FSYNC_ALWAYS

    每执行一个命令保存一次。(不推荐)在这种模式下,每次执行完一个命令之后, WRITE 和 SAVE 都会被执行。

    另外,因为 SAVE 是由 Redis 主进程执行的,所以在 SAVE 执行期间,主进程会被阻塞,不能接受命令

    请求。

    对于三种 AOF 保存模式, 它们对服务器主进程的阻塞情况如下

    模式write是否阻塞save是否阻塞停机丢失的数据量
    AOF_FSYNC_NO阻塞阻塞最后一次save操作之后的数据
    AOF_FSYNC_EVERYSEC阻塞不阻塞不超过2s的命令
    AOF_FSYNC_ALWAYS阻塞阻塞一个命令
4.1.2.3 重写、触发方式、混合持久化

AOF记录数据的变化过程,越来越大,需要重写“瘦身”

Redis可以在 AOF体积变得过大时,自动地在后台(Fork子进程)对 AOF进行重写。重写后的新 AOF文件包含了恢复当前数据集所需的最小命令集合。 所谓的“重写”其实是一个有歧义的词语,

实际上,AOF 重写并不需要对原有的 AOF 文件进行任何写入和读取, 它针对的是数据库中键的当前值

举例如下:

set s1 11

set s1 22

set s1 33

优化后:

set s1 33
lpush list1 1 2 3
lpush list1 4 5 6

优化后

lpush list1 1 2 3 4 5 6

Redis 不希望 AOF 重写造成服务器无法处理请求, 所以 Redis 决定将 AOF 重写程序放到(后台)子进程里执行,

这样处理的最大好处是:

  • 子进程进行 AOF 重写期间,主进程可以继续处理命令请求。

  • 子进程带有主进程的数据副本,使用子进程而不是线程,可以在避免锁的情况下,保证数据的安全性。

因为子进程在进行 AOF 重写期间, 主进程还需要继续处理命令, 而新的命令可能对现有的数据进行修改, 这会让当前数据库的数据和重写后的 AOF 文件中的数据不一致。为了解决这个问题, Redis 增加了一个 AOF 重写缓存, 这个缓存在 fork 出子进程之后开始启用,Redis 主进程在接到新的写命令之后, 除了会将这个写命令的协议内容追加到现有的 AOF 文件之外,还会追加到这个缓存中。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

重写过程

整个重写操作是绝对安全的

Redis 在创建新 AOF 文件的过程中,会继续将命令追加到现有的 AOF 文件里面,即使重写过程中发生停机,现有的 AOF 文件也不会丢失。 而一旦新 AOF 文件创建完毕,Redis 就会从旧 AOF 文件切换到新 AOF 文件,并开始对新 AOF 文件进行追加操作。

当子进程在执行 AOF 重写时, 主进程需要执行以下三个工作:

  • 处理命令请求。
  • 将写命令追加到现有的 AOF 文件中。
  • 将写命令追加到 AOF 重写缓存中。

这样一来可以保证:

  • 现有的 AOF 功能会继续执行,即使在 AOF 重写期间发生停机,也不会有任何数据丢失。

  • 所有对数据库进行修改的命令都会被记录到 AOF 重写缓存中。

当子进程完成 AOF 重写之后, 它会向父进程发送一个完成信号, 父进程在接到完成信号之后, 会调用一个信号处理函数,

并完成以下工作:

  • 将 AOF 重写缓存中的内容全部写入到新 AOF 文件中。执行完毕之后, 现有 AOF 文件、新 AOF 文件和数据库三者的状态就完全一致了。

  • 对新的 AOF 文件进行改名,覆盖原有的 AOF 文件。执行完毕之后,程序就完成了新旧两个 AOF 文件的交替。

这个信号处理函数执行完毕之后, 主进程就可以继续像往常一样接受命令请求了。 在整个 AOF 后台重写过程中, 只有最后的写入缓存和改名操作会造成主进程阻塞, 在其他时候, AOF 后台重写都不会对主进程造成阻塞, 这将 AOF 重写对性能造成的影响降到了最低。

截屏2023-08-24 11.20.45

触发方式
  • 配置触发

    在redis.conf中配置

    # 表示当前aof文件大小超过上一次aof文件大小的百分之多少的时候会进行重写。如果之前没有重写过, 以启动时aof文件大小为准 
    auto-aof-rewrite-percentage 100 
    # 限制允许重写最小aof文件大小,也就是文件大小小于64mb的时候,不需要进行优化 
    auto-aof-rewrite-min-size 64mb 
    
  • 执行bgrewriteaof命令

混合持久化

RDB和AOF各有优缺点,Redis 4.0 开始支持 rdb 和 aof 的混合持久化。如果把混合持久化打开,aofrewrite 的时候就直接把 rdb 的内容写到 aof 文件开头。

混合AOF文件是rdb文件的头和aof格式的内容,在加载时,首先会识别AOF文件是否以REDIS字符串开头,如果是就按RDB格式加载,加载完RDB后继续按AOF格式加载剩余部分

RDB的头+AOF的身体---->appendonly.aof

开启混合持久化

aof-use-rdb-preamble yes
4.1.2.4 文件的载入与数据还原

因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态

Redis读取AOF文件并还原数据库状态的详细步骤如下:

  • 创建一个不带网络连接的伪客户端(fake client)

    因为Redis的命令只能在客户端上下文中执行,而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端执行命令的效果完全一样

  • 从AOF文件中分析并读取出一条写命令

  • 使用伪客户端执行被读出的写命令

  • 一直执行步骤2和步骤3

    直到AOF文件中的所有写命令都被处理完毕为止

    当完成以上步骤之后,AOF文件所保存的数据库状态就会被完整地还原出来

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

4.1.3 RDB与AOF对比

  • RDB存某个时刻的数据快照,采用二进制压缩存储,AOF存操作命令,采用文本存储(混合)

  • RDB性能高、AOF性能较低

  • RDB在配置触发状态会丢失最后一次快照以后更改的所有数据,AOF设置为每秒保存一次,则最多丢2秒的数据

  • Redis以主服务器模式运行,RDB不会保存过期键值对数据,Redis以从服务器模式运行,RDB会保存过期键值对,当主服务器向从服务器同步时,再清空过期键值对。

  • AOF写入文件时,对过期的key会追加一条del命令,当执行AOF重写时,会忽略过期key和del命令。

内存数据库 rdb+aof 数据不容易丢

  • 有原始数据源

    每次启动时都从原始数据源中初始化 ,则不用开启持久化 (数据量较小)

    缓存服务器 rdb一般性能高

  • 在数据还原时

    有rdb+aof 则还原aof,因为RDB会造成文件的丢失,AOF相对数据要完整。

    只有rdb,则还原rdb

4.2 数据结构

Redis没有表的概念,Redis实例所对应的db以编号区分,db本身就是key的命名空间。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

4.2.1 redisDB结构

Redis中存在“数据库”的概念,该结构由redis.h中的redisDb定义。

当redis 服务器初始化时,会预先分配 16 个数据库

所有数据库保存到结构 redisServer 的一个成员 redisServer.db 数组中

redisClient中存在一个名叫db的指针指向当前使用的数据库

RedisDB结构体源码:

typedef struct redisDb { 

int id; //id是数据库序号,为0-15(默认Redis有16个数据库) 

long avg_ttl; //存储的数据库对象的平均ttl(time to live),用于统计 

dict *dict; //存储数据库所有的key-value 

dict *expires; //存储key的过期时间 

dict *blocking_keys;//blpop 存储阻塞key和客户端对象 

dict *ready_keys;//阻塞后push 响应阻塞客户端 存储阻塞后push的key和客户端对象 

dict *watched_keys;//存储watch监控的的key和客户端对象 

} redisDb;

4.2.2 RedisObject结构

redis中Value是一个RedisObject对象

包含字符串对象,列表对象,哈希对象,集合对象和有序集合对象

typedef struct redisObject { 

unsigned type:4;//类型 对象类型 

unsigned encoding:4;//编码 

void *ptr;//指向底层实现数据结构的指针 

//... 

int refcount;//引用计数 

//... 

unsigned lru:LRU_BITS; //LRU_BITS为24bit 记录最后一次被命令程序访问的时间 

//... 

}robj; 
4.2.2.1 type

type 字段表示对象的类型,占 4 位;

当我们执行 type 命令时,便是通过读取 RedisObject 的 type 字段获得对象的类型

字符串(string、int)

Redis 使用了 SDS(Simple Dynamic String)。用于存储字符串和整型数据。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

struct sdshdr{ 
//记录buf数组中已使用字节的数量 
int len; 
//记录 buf 数组中未使用字节的数量 
int free; 
//字符数组,用于保存字符串 
char buf[]; 
}

buf[] 的长度=len+free+1

SDS的优势:

  • 在 C 字符串的基础上加入了 free 和 len 字段,获取字符串长度:SDS 是 O(1),C 字符串是O(n)。buf数组的长度=free+len+1

  • 由于记录了长度,在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。

  • 可以存取二进制数据,以字符串长度len来作为结束标识

使用场景:

存储字符串和整型数据、存储key、AOF缓冲区和用户输入缓冲

跳跃表(zset)

跳跃表是有序集合(sorted-set)的底层实现,效率高,实现简单。

跳跃表的基本思想:将有序链表中的部分节点分层,每一层都是一个有序链表

在查找时优先从最高层开始向后查找,当到达某个节点时,如果next节点值大于要查找的值或next指针指向null,则从当前节点下降一层继续向后查找。

跳跃表特点

  • 每层都是一个有序链表
  • 查找次数近似于层数(结点的1/2)
  • 底层包含所有元素
  • 空间复杂度 O(n) 扩充了一倍

举个例子

查找元素9,按道理我们需要从头结点开始遍历,一共遍历8个结点才能找到元素9。

0125678910

如果在分一层,那遍历五个结点就可以找到9

路径:第一层的0—>第一层的2—>第一层的6—>第一层的8—>第二层的9

026810
0125678910

如果在分二层,那遍历四个结点就可以找到9

路径:第一层的0—>第一层的6—>第二层的8—>第三层9

0610
026810
0125678910

如果在分三层,那遍历四个结点就可以找到9

路径:第一层的0—>第二层的6—>第三层的8—>第四层9

010
0610
026810
0125678910

这种数据结构,就是跳跃表,它具有二分查找的功能。

上面例子中,9个结点,一共4层,是理想的跳跃表。

  • 插入

    通过抛硬币(概率1/2)的方式来决定结点是否要在上层体现

  • 删除

    找到指定元素并删除每层的该元素即可

Redis跳跃表的实现

//跳跃表节点 

typedef struct zskiplistNode { 
sds ele; /* 存储字符串类型数据 redis3.0版本中使用robj类型表示, 但是在redis4.0.1中直接使用sds类型表示*/ 
double score;//存储排序的分值 
struct zskiplistNode *backward;//后退指针,指向当前节点最底层的前一个节点 
/* 层,柔性数组,随机生成1-64的值 
*/
struct zskiplistLevel { 
struct zskiplistNode *forward; //指向本层下一个节点 
unsigned int span;//本层下个节点到本节点的元素个数 
} level[]; 
} zskiplistNode; 
//链表 
typedef struct zskiplist{ 
//表头节点和表尾节点 
structz skiplistNode *header, *tail; 
//表中节点的数量 
unsigned long length; 
//表中层数最大的节点的层数 
int level; 
}zskiplist;
字典(hash、zet)

字典dict又称散列表(hash),是用来存储键值对的一种数据结构。

Redis整个数据库是用字典来存储的。(K-V结构)

对Redis进行CURD操作其实就是对字典中的数据进行CURD操作

Redis字典实现包括:字典(dict)、Hash表(dictht)、Hash表节点(dictEntry)。

截屏2023-08-24 16.40.13

hash表

typedef struct dictht { 
dictEntry **table; // 哈希表数组 
unsigned long size; // 哈希表数组的大小 
unsigned long sizemask; // 用于映射位置的掩码,值永远等于(size-1) 
unsigned long used; // 哈希表已有节点的数量,包含next单链表数据 
} dictht;

hash表的数组初始容量为4,随着k-v存储量的增加需要对hash表数组进行扩容,新扩容量为当前量的一倍,即4,8,16,32

索引值=Hash值&掩码值(Hash值与Hash表容量取余)

Hash表节点

typedef struct dictEntry { 
void *key; // 键 
union { // 值v的类型可以是以下4种类型 
void *val; 
uint64_t u64; 
int64_t s64; 
double d; 
} v; 
struct dictEntry *next; // 指向下一个哈希表节点,形成单向链表 解决hash冲突
} dictEntry; 

key字段存储的是键值对中的键

v字段是个联合体,存储的是键值对中的值。

next指向下一个哈希表节点,用于解决hash冲突

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

dict字典

typedef struct dict { 
dictType *type; // 该字典对应的特定操作函数 
void *privdata; // 上述类型函数对应的可选参数 
dictht ht[2]; /* 两张哈希表,存储键值对数据,ht[0]为原生哈希表, ht[1]为 rehash 哈希表 */ 
long rehashidx; /*rehash标识 当等于-1时表示没有在 rehash, 否则表示正在进行rehash操作,存储的值表示hash表 ht[0]的rehash进行到哪个索引值 (数组下标)*/ 
int iterators; // 当前运行的迭代器数量 
} dict;

type字段,指向dictType结构体,里边包括了对该字典操作的函数指针

typedef struct dictType { 
// 计算哈希值的函数 
unsigned int (*hashFunction)(const void *key); 
// 复制键的函数 
void *(*keyDup)(void *privdata, const void *key); 
// 复制值的函数 
void *(*valDup)(void *privdata, const void *obj); 
// 比较键的函数 
int (*keyCompare)(void *privdata, const void *key1, const void *key2); 
// 销毁键的函数 
void (*keyDestructor)(void *privdata, void *key); 
// 销毁值的函数 
void (*valDestructor)(void *privdata, void *obj); 
} dictType;

Redis字典除了主数据库的K-V数据存储以外,还可以用于:散列表对象、哨兵模式中的主从节点管理等在不同的应用中,字典的形态都可能不同,dictType是为了实现各种形态的字典而抽象出来的操作函数(多态)。

完整的Redis字典数据结构:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

字典扩容

字典达到存储上限(阈值 0.75),需要rehash(扩容)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 初次申请默认容量为4个dictEntry,非初次申请为当前hash表容量的一倍。
  • rehashidx=0表示要进行rehash操作。
  • 新增加的数据在新的hash表h[1]
  • 修改、删除、查询在老hash表h[0]、新hash表h[1]中(rehash中)
  • 将老的hash表h[0]的数据重新计算索引值后全部迁移到新的hash表h[1]中,这个过程称为rehash。
压缩列表(zset、hash)

压缩列表是由一系列特殊编码的连续内存块组成的顺序型数据结构(节省内存)

是一个字节数组,可以包含多个节点(entry)。每个节点可以保存一个字节数组或一个整数。

压缩列表的数据结构如下:

截屏2023-08-25 10.37.16

  • zlbytes:压缩列表的字节长度

  • zltail:压缩列表尾元素相对于压缩列表起始地址的偏移量

  • zllen:压缩列表的元素个数

  • entry1…entryX : 压缩列表的各个节点

  • zlend:压缩列表的结尾,占一个字节,恒为0xFF(255)

entry元素的编码结构:

previous_entry_lengthencodingcontent
  • previous_entry_length:前一个元素的字节长度

  • encoding:表示当前元素的编码

  • content:数据内容

ziplist结构体

struct ziplist<T>{ 
unsigned int zlbytes; // ziplist的长度字节数,包含头部、所有entry和zipend。 
unsigned int zloffset; // 从ziplist的头指针到指向最后一个entry的偏移量,用于快速反向查询
unsigned short int zllength; // entry元素个数 
T[] entry; // 元素值 
unsigned char zlend; // ziplist结束符,值固定为0xFF 
}

typedef struct zlentry { 
unsigned int prevrawlensize; //previous_entry_length字段的长度 
unsigned int prevrawlen; //previous_entry_length字段存储的内容 
unsigned int lensize; //encoding字段的长度 
unsigned int len; //数据内容长度 
unsigned int headersize; //当前元素的首部长度,即previous_entry_length字段长度与 encoding字段长度之和。 

unsigned char encoding; //数据类型 
unsigned char *p; //当前元素首地址 
} zlentry;

应用场景

  • sorted-set和hash元素个数少且是小整数或短字符串(直接使用)

  • list用快速链表(quicklist)数据结构存储,而快速链表是双向列表与压缩列表的组合。(间接使用)

整数集合(set)

整数集合是一个有序的(整数升序)、存储整数的连续存储结构。

当Redis集合类型的元素都是整数并且都处在64位有符号整数范围内(2^64),使用该结构体存储。

encodinglengthelement1element2element3
typedef struct intset{ 
//编码方式 
uint32_t encoding; 
//集合包含的元素数量 
uint32_t length; 
//保存元素的数组 
int8_t contents[]; 
}intset; 

应用场景

  • 可以保存类型为int16_t、int32_t 或者int64_t 的整数值,并且保证集合中不会出现重复元素
快速列表(list)

快速列表(quicklist)是Redis底层重要的数据结构。是list的底层实现。(在Redis3.2之前,Redis采用双向链表(adlist)和压缩列表(ziplist)实现。)在Redis3.2以后结合adlist和ziplist的优势Redis设计出了quicklist。

双向列表(adlist)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

双向链表特点:

  • 双向

    双向链表具有前置节点和后置节点的引用,获取这两个节点时间复杂度都为O(1)。

  • 普通链表(单链表)

节点类保留下一节点的引用。链表类只保留头节点的引用,只能从头节点插入删除

  • 无环

    双向链表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问都是以 NULL 结束。

  • 环状(环状表)

    头的前一个节点指向尾节点

  • 带链表长度计数器

    通过 len 属性获取链表长度的时间复杂度为 O(1)。

  • 多态

    链表节点使用 void* 指针来保存节点值,可以保存各种不同类型的值。

应用场景

  • 列表(List)的底层实现、发布与订阅、慢查询、监视器等功能。

快速列表(quicklist-list)

quicklist是一个双向链表,链表中的每个节点是一个ziplist结构。quicklist中的每个节点ziplist都能够存储多个数据元素。

quicklist的结构

typedef struct quicklist { 
quicklistNode *head; // 指向quicklist的头部 
quicklistNode *tail; // 指向quicklist的尾部 
unsigned long count; // 列表中所有数据项的个数总和 
unsigned int len; // quicklist节点的个数,即ziplist的个数 
int fill : 16; // ziplist大小限定,由list-max-ziplist-size给定 (Redis设定) 
unsigned int compress : 16; // 节点压缩深度设置,由list-compress-depth给定 (Redis设定) 
} quicklist; 

quicklistNode的结构

typedef struct quicklistNode { 
struct quicklistNode *prev; // 指向上一个ziplist节点 
struct quicklistNode *next; // 指向下一个ziplist节点 
unsigned char *zl; // 数据指针,如果没有被压缩,就指向ziplist结构,反之指向quicklistLZF结构 
unsigned int sz; // 表示指向ziplist结构的总长度(内存占用长度) 
unsigned int count : 16; // 表示ziplist中的数据项个数 
unsigned int encoding : 2; // 编码方式,1--ziplist,2--quicklistLZF 
unsigned int container : 2; // 预留字段,存放数据的方式,1--NONE,2--ziplist 
unsigned int recompress : 1; // 解压标记,当查看一个被压缩的数据时,需要暂时解压,标记此参数为 1,之后再重新进行压缩 
unsigned int attempted_compress : 1; // 测试相关 
unsigned int extra : 10; // 扩展字段,暂时没用 
} quicklistNode; 

数据压缩

quicklist每个节点的实际数据存储结构为ziplist,这种结构的优势在于节省存储空间。为了进一步降低ziplist的存储空间,还可以对ziplist进行压缩。Redis采用的压缩算法是LZF。其基本思想是:数据与前面重复的记录重复位置及长度,不重复的记录原始数据。

压缩过后的数据可以分成多个片段,每个片段有两个部分:解释字段和数据字段。quicklistLZF的结构体如下:

typedef struct quicklistLZF { 
unsigned int sz; // LZF压缩后占用的字节数 
char compressed[]; // 柔性数组,指向数据部分 
} quicklistLZF;
流对象

stream主要由:消息、生产者、消费者和消费组构成

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Redis Stream的底层主要使用了listpack(紧凑列表)和Rax树(基数树)。

listpack

listpack表示一个字符串列表的序列化,listpack可用于存储字符串或整数。用于存储stream的消息内容。

结构如下图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Rax树

Rax 是一个有序字典树 (基数树 Radix Tree),按照 key 的字典序排列,支持快速地定位、插入和删除操作。

截屏2023-08-29 15.43.49

Rax 被用在 Redis Stream 结构里面用于存储消息队列,在 Stream 里面消息 ID 的前缀是时间戳 + 序号,这样的消息可以理解为时间序列消息。使用 Rax 结构 进行存储就可以快速地根据消息 ID 定位到具体的消息,然后继续遍历指定消息之后的所有消息。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

应用场景:

  • stream的底层实现
4.2.2.2 encoding

encoding 表示对象的内部编码,占 4 位

每个对象有不同的实现编码,Redis 可以根据不同的使用场景来为对象设置不同的编码,大大提高了 Redis 的灵活性和效率。

通过 object encoding 命令,可以查看对象采用的编码方式

4.2.2.3 LRU

lru 记录的是对象最后一次被命令程序访问的时间,( 4.0 版本占 24 位,2.6 版本占 22 位)。

高16位存储一个分钟数级别的时间戳,低8位存储访问计数(lfu : 最近访问次数)

4.2.2.4 refcount

refcount 记录的是该对象被引用的次数,类型为整型。

refcount 的作用,主要在于对象的引用计数和内存回收。

当对象的refcount>1时,称为共享对象

Redis 为了节省内存,当有一些对象重复出现时,程序不会创建新的对象,而是仍然使用原来的对象。

4.2.2.5 ptr

ptr 指针指向具体的数据,比如:set hello world,ptr 指向包含字符串 world 的 SDS。

4.3 缓存过期和淘汰策略

4.3.1 maxmemory

默认为0 不限制,达到物理内存后性能急剧下架,甚至崩溃

不设置的场景

  • Redis的key是固定的,不会增加

  • Redis作为DB使用,保证数据的完整性,不能淘汰 ,

  • 可以做集群,横向扩展

  • 缓存淘汰策略:禁止驱逐 (默认)

设置的场景

Redis是作为缓存使用,不断增加Key,但是设置多少与业务有关

1个Redis实例,保证系统运行 1 G ,剩下的就都可以设置Redis ,一般是物理内存的3/4

slaver 需要留出一定的内存用来同步

设置maxmemory后,当趋近maxmemory时,通过缓存淘汰策略,从内存中删除对象

4.3.2 expire

在Redis中可以使用expire命令设置一个键的存活时间(ttl: time to live),过了这段时间,该键就会自动被删除。

expire key ttl(单位秒) 
4.3.2.1 原理
typedef struct redisDb { 
dict *dict; -- key Value 
dict *expires; -- key ttl 
dict *blocking_keys; 
dict *ready_keys; 
dict *watched_keys; 
int id; 
} redisDb; 

在Redis 中关于数据库的结构体定义中expires用于维护一个 Redis 数据库中设置了失效时间的键(即key与失效时间的映射)。

当我们使用 expire命令设置一个key的失效时间时,Redis 首先到 dict 这个字典表中查找要设置的key是否存在,如果存在就将这个key和失效时间添加到 expires 这个字典表。

当我们使用 setex命令向系统插入数据时,Redis 首先将 Key 和 Value 添加到 dict 这个字典表中,然后将 Key 和失效时间添加到 expires 这个字典表中。

4.3.3 删除策略

Redis的数据删除有定时删除、惰性删除和主动删除三种方式。

Redis目前采用惰性删除+主动删除的方式。

4.3.3.1 定时删除

在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。

需要创建定时器,而且消耗CPU,一般不推荐使用。

4.3.3.2 惰性删除

在key被访问时如果发现它已经失效,那么就删除它。

调用expireIfNeeded函数,该函数的意义是:读取数据之前先检查一下它有没有失效,如果失效了就删除它。

int expireIfNeeded(redisDb *db, robj *key) { 
//获取主键的失效时间 get当前时间-创建时间>ttl 
long long when = getExpire(db,key); 
//假如失效时间为负数,说明该主键未设置失效时间(失效时间默认为-1),直接返回0 
if (when < 0) return 0; 
//假如Redis服务器正在从RDB文件中加载数据,暂时不进行失效主键的删除,直接返回0 
if (server.loading) return 0; 
... 
//如果以上条件都不满足,就将主键的失效时间与当前时间进行对比,如果发现指定的主键 
//还未失效就直接返回0
if (mstime() <= when) return 0; 
//如果发现主键确实已经失效了,那么首先更新关于失效主键的统计个数,然后将该主键失 效的信息进行广播,最后将该主键从数据库中删除 
server.stat_expiredkeys++; 
propagateExpire(db,key); 
return dbDelete(db,key); 

}
4.3.3.3 主动删除

在redis.conf文件中可以配置主动删除策略,默认是no-enviction(不删除)

maxmemory-policy allkeys-lru
LRU

LRU (Least recently used) 最近最少使用,算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。

最常见的实现是使用一个链表保存缓存数据

  • 新数据插入到链表头部;
  • 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;
  • 当链表满的时候,将链表尾部的数据丢弃。
  • 在Java中可以使用LinkHashMap(哈希链表)去实现LRU

Redis的LRU 数据淘汰机制

在服务器配置中保存了 lru 计数器 server.lrulock,会定时(redis 定时程序 serverCorn())更新,server.lrulock 的值是根据 server.unixtime 计算出来的。

另外,从 struct redisObject 中可以发现,每一个 redis 对象都会设置相应的 lru。可以想象的是,每一次访问数据的时候,会更新 redisObject.lru。

LRU 数据淘汰是在数据集中随机挑选几个键值对,取出其中 lru 最大的键值对淘汰。

不可能遍历key 用当前时间-最近访问越大说明访问间隔时间越长

  • volatile-lru

    从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰

  • allkeys-lru

    从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰

LFU

LFU (Least frequently used) 最不经常使用,如果一个数据在最近一段时间内使用次数很少,那么在将来一段时间内被使用的可能性也很小。

  • volatile-lfu

    从已设置过期时间的数据集(server.db[i].expires)中选择数据淘汰

  • allkeys-lfu

    从数据集(server.db[i].dict)中选择数据淘汰

random
  • volatile-random

    从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰

  • allkeys-random

    从数据集(server.db[i].dict)中任意选择数据淘汰

ttl

从过期时间的表中随机挑选几个键值对,取出其中 ttl 最小的键值对淘汰。

volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰

noenviction

禁止驱逐数据,不删除,是默认策略

4.3.3.4 策略选择
  • allkeys-lru

    在不确定时一般采用策略。 冷热数据交换

  • volatile-lru

    因为要存过期时间性能比allkeys-lru性能差

  • allkeys-random

    希望请求符合平均分布(每个元素以相同的概率被访问)

  • volatile-ttl

    自己控制但是有缓存穿透的风险

4.4 通信协议及事件处理

4.4.1 通信协议

Redis是单进程单线程的。应用系统和Redis通过Redis协议(RESP)进行交互。

4.4.1.1 请求响应模式

Redis协议位于TCP层之上,即客户端和Redis实例保持双工的连接。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 串行的请求响应模式(ping-pong)

    串行化是最简单模式,客户端与服务器端建立长连接

    连接通过心跳机制检测(ping-pong) ack应答

    客户端发送请求,服务端响应,客户端收到响应后,再发起第二个请求,服务器端再响应。

    telnet和redis-cli 发出的命令 都属于该种模式

    特点

    • 有问有答
    • 耗时在网络传输命令
    • 性能较低

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 双工的请求响应模式(pipeline)

    批量请求,批量响应

    请求响应交叉进行,不会混淆(TCP双工)

    pipeline的作用是将一批命令进行打包,然后发送给服务器,服务器执行完按顺序打包返回。

    通过pipeline,一次pipeline(n条命令)=一次网络时间 + n次命令时间

截屏2023-09-01 11.36.10

  • 原子化的批量请求响应模式(事务)

    Redis可以利用事务机制批量执行命令

  • 发布订阅模式(pub/sub)

    一个客户端触发,多个客户端被动接收,通过服务器中转

  • 脚本化的批量执行(lua)

    客户端向服务器端提交一个lua脚本,服务器端执行该脚本。

4.4.1.2 请求数据格式

Redis客户端与服务器交互采用序列化协议(RESP)。

请求以字符串数组的形式来表示要执行命令的参数

Redis使用命令特有(command-specific)数据类型作为回复。

Redis通信协议的主要特点有:

  • 客户端和服务器通过 TCP 连接来进行数据交互, 服务器默认的端口号为 6379 。

  • 客户端和服务器发送的命令或数据一律以 \r\n (CRLF)结尾。

  • 在这个协议中, 所有发送至 Redis 服务器的参数都是二进制安全(binary safe)的。

  • 简单,高效,易读。

  • 内联格式

    可以使用telnet给Redis发送命令,首字符为Redis命令名的字符,格式为 str1 str2 str3…

    [root@localhost bin]# telnet 127.0.0.1 6379
    Trying 127.0.0.1...
    Connected to 127.0.0.1.
    Escape character is '^]'.
    ping
    +PONG
    exists name
    :1
    
  • 规范格式(redis-cli) RESP

    间隔符号,在Linux下是\r\n,在Windows下是\n

    简单字符串 Simple Strings, 以 "+"加号 开头

    错误 Errors, 以"-"减号 开头

    整数型 Integer, 以 “:” 冒号开头

    大字符串类型 Bulk Strings, 以 "$"美元符号开头,长度限制512M

    数组类型 Arrays,以 "*"星号开头

    redis> SET mykey Hello
    "OK"
    

    实际发送的请求数据:

    *3\r\n$3\r\nSET\r\n$5\r\nmykey\r\n$5\r\nHello\r\n
    *3
    $3
    SET
    $5
    mykey
    $5
    Hello
    

    实际收到的响应数据:

    +OK\r\n
    
4.4.1.3 命令处理流程

整个流程包括:服务器启动监听、接收命令请求并解析、执行命令请求、返回命令回复等

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • Server启动时监听socket

    启动调用 initServer方法:

    创建eventLoop(事件机制)

    注册时间事件处理器

    注册文件事件(socket)处理器

    监听 socket 建立连接

  • 建立Client

    redis-cli建立socket

    redis-server为每个连接(socket)创建一个 Client 对象

    创建文件事件监听socket

    指定事件处理函数

  • 读取socket数据到输入缓冲区

    从client中读取客户端的查询缓冲区内容

  • 解析获取命令

    将输入缓冲区中的数据解析成对应的命令

    判断是单条命令还是多条命令并调用相应的解析器解析

  • 执行命令

    解析成功后调用processCommand 方法执行命令,如下图:

    大致分三个部分:

    • 调用 lookupCommand 方法获得对应的 redisCommand

    • 检测当前 Redis 是否可以执行该命令

    • 调用 call 方法真正执行命令

      外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

协议解析

用户在Redis客户端键入命令后,Redis-cli会把命令转化为RESP协议格式,然后发送给服务器。服务器再对协议进行解析,分为三个步骤

  • 解析命令请求参数数量

命令请求参数数量的协议格式为"N\r\n" ,其中N就是数量,比如set name:1 zhaoyun在aof文件中3(/r/n)

*3(/r/n)
$3(/r/n)
set(/r/n)
$7(/r/n)
name:10(/r/n)
$7(/r/n)
zhaoyun(/r/n)

首字符必须是“*”,使用"\r"定位到行尾,之间的数就是参数数量了。

  • 循环解析请求参数

    首字符必须是" " ,使用 " / r " 定位到行尾,之间的数是参数的长度,从 / n 后到下一个 " ",使用"/r"定位到行尾,之间的数是参数的长度,从/n后到下一个" ",使用"/r"定位到行尾,之间的数是参数的长度,从/n后到下一个""之间就是参数的值了

  • 循环解析直到没有"$"。

协议执行

协议的执行包括命令的调用和返回结果

RedisServer解析完命令后,会调用函数processCommand处理该命令请求

  • quit校验,如果是“quit”命令,直接返回并关闭客户端

  • 命令语法校验,执行lookupCommand,查找命令(set),如果不存在则返回:“unknown command”错误。

  • 参数数目校验,参数数目和解析出来的参数个数要匹配,如果不匹配则返回:“wrong number of arguments”错误。

  • 权限校验,最大内存校验,集群校验,持久化校验等等。

校验成功后,会调用call函数执行命令,并记录命令执行时间和调用次数

如果执行命令时间过长还要记录慢查询日志

执行命令后返回结果的类型不同则协议格式也不同,分为5类:状态回复、错误回复、整数回复、批量回复、多条批量回复

协议响应格式
  • 状态回复

    对于状态,回复的第一个字节是 +

    "+OK"
    
  • 错误回复

    对于错误,回复的第一个字节是 -

    -ERR unknown command 'foobar'
    -WRONGTYPE Operation against a key holding the wrong kind of value
    
  • 整数回复

    对于整数,回复的第一个字节是 :

    ":6"
    
  • 批量回复

    对于批量字符串,回复的第一个字节是 $

    "$6 foobar"
    
  • 多条批量回复

    对于多条批量回复(数组),回复的第一个字节是“*”

    "*3"
    

4.4.2 事件处理机制

Redis将事件分为两大类:文件事件和时间事件

4.4.2.1 文件事件

文件事件即Socket的读写事件,也就是IO事件。

客户端的连接、命令请求、数据回复、连接断开

socket

套接字(socket)是一个抽象层,应用程序可以通过它发送或接收数据

Reactor

Redis事件处理机制采用单线程的Reactor模式,属于I/O多路复用的一种常见模式。

IO多路复用( I/O multiplexing )指的通过单个线程管理多个Socket。

Reactor pattern(反应器设计模式)是一种为处理并发服务请求,并将请求提交到 一个或者多个服务处理程序的事件设计模式。

Reactor模式是事件驱动的

有一个或多个并发输入源(文件事件)

有一个Service Handler

有多个Request Handlers

这个Service Handler会同步的将输入的请求(Event)多路复用的分发给相应的Request Handler

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

截屏2023-09-04 15.16.21

  • Handle

    I/O操作的基本文件句柄,在linux下就是fd(文件描述符)

  • Synchronous Event Demultiplexer

    同步事件分离器,阻塞等待Handles中的事件发生。

  • Reactor

    事件分派器,负责事件的注册,删除以及对所有注册到事件分派器的事件进行监控, 当事件发生时会调用Event Handler接口来处理事件。

  • Event Handler

    事件处理器接口,这里需要Concrete Event Handler来实现该接口

  • Concrete Event Handler

    真实的事件处理器,通常都是绑定了一个handle,实现对可读事件进行读取或对可写事件进行写入的操作

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

主程序向事件分派器(Reactor)注册要监听的事件

Reactor调用OS提供的事件处理分离器,监听事件(wait)

当有事件产生时,Reactor将事件派给相应的处理器来处理 handle_event()

IO多路复用

select,poll,epoll、kqueue都是IO多路复用的机制。

I/O多路复用就是通过一种机制,一个进程可以监视多个描述符(socket),一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

select 函数监视的文件描述符分3类

文件事件分派器

在redis中,对于文件事件的处理采用了Reactor模型。采用的是epoll的实现方式。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Redis在主循环中统一处理文件事件和时间事件,信号事件则由专门的handler来处理

事件处理器
  • 连接处理函数 acceptTCPHandler

    当客户端向 Redis 建立 socket时,aeEventLoop 会调用 acceptTcpHandler 处理函数,服务器会为每个链接创建一个 Client 对象,并创建相应文件事件来监听socket的可读事件,并指定事件处理函数。

  • 请求处理函数 readQueryFromClient

    当客户端通过 socket 发送来数据后,Redis 会调用 readQueryFromClient 方法,readQueryFromClient方法会调用 read 方法从 socket 中读取数据到输入缓冲区中,然后判断其大小是否大于系统设置的client_max_querybuf_len,如果大于,则向 Redis返回错误信息,并关闭 client。

  • 命令回复处理器 sendReplyToClient

    sendReplyToClient函数是Redis的命令回复处理器,这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端。

    • 将outbuf内容写入到套接字描述符并传输到客户端
    • aeDeleteFileEvent 用于删除文件写事件
4.4.2.2 时间事件

时间事件分为定时事件与周期事件

  • 定时事件

    让一段程序在指定的时间之后执行一次

    aeTimeProc(时间处理器)的返回值是AE_NOMORE

    该事件在达到后删除,之后不会再重复。

  • 周期性事件

    让一段程序每隔指定时间就执行一次

    aeTimeProc(时间处理器)的返回值不是AE_NOMORE

    当一个时间事件到达后,服务器会根据时间处理器的返回值,对时间事件的 when 属性进行更新,让这个事件在一段时间后再次达到。serverCron就是一个典型的周期性事件。

一个时间事件主要由以下三个属性组成:

  • id(全局唯一id)

  • when (毫秒时间戳,记录了时间事件的到达时间)

  • timeProc(时间事件处理器,当时间到达时,Redis就会调用相应的处理器来处理事件)

/* Time event structure
*
* 时间事件结构
*/

typedef struct aeTimeEvent {
// 时间事件的唯一标识符
long id; /* time event identifier. */
// 事件的到达时间,存贮的是UNIX的时间戳
lon when_sec; /* seconds */
long when_ms; /* milliseconds */
// 事件处理函数,当到达指定时间后调用该函数处理对应的问题
aeTimeProc *timeProc;
// 事件释放函数
aeEventFinalizerProc *finalizerProc;
// 多路复用库的私有数据
void *clientData;
// 指向下个时间事件结构,形成链表
struct aeTimeEvent *next;
} aeTimeEvent;
serverCron

时间事件的最主要的应用是在redis服务器需要对自身的资源与配置进行定期的调整,从而确保服务器的长久运行,这些操作由redis.c中的serverCron函数实现。该时间事件主要进行以下操作:

  • 更新redis服务器各类统计信息,包括时间、内存占用、数据库占用等情况。
  • 清理数据库中的过期键值对。
  • 关闭和清理连接失败的客户端。
  • 尝试进行aof和rdb持久化操作。
  • 如果服务器是主服务器,会定期将数据向从服务器做同步操作。
  • 如果处于集群模式,对集群定期进行同步与连接测试操作。

redis服务器开启后,就会周期性执行此函数,直到redis服务器关闭为止。默认每秒执行10次,平均100毫秒执行一次,可以在redis配置文件的hz选项,调整该函数每秒执行的次数。

server.hz

serverCron在一秒内执行的次数 , 在redis/conf中可以配置,server.hz是100,也就是servreCron的执行间隔是10ms

run_with_period

#define run_with_period(_ms_) 返回1表示执行
if ((_ms_ <= 1000/server.hz) || !(server.cronloops%((_ms_)/(1000/server.hz))))

定时任务执行都是在10毫秒的基础上定时处理自己的任务(run_with_period(ms)),即调用run_with_period(ms)[ms是指多长时间执行一次,单位是毫秒]来确定自己是否需要执行。

假如有一些任务需要每500ms执行一次,就可以在serverCron中用run_with_period(500)把每500ms需要执行一次的工作控制起来

4.4.2.3 aeEventLoop

aeEventLoop 是整个事件驱动的核心,Redis自己的事件处理机制

它管理着文件事件表和时间事件列表,不断地循环处理着就绪的文件事件和到期的时间事件。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

typedef struct aeEventLoop {
//最大文件描述符的值
int maxfd; /* highest file descriptor currently registered */
//文件描述符的最大监听数
int setsize; /* max number of file descriptors tracked */
//用于生成时间事件的唯一标识id
long long timeEventNextId;
//用于检测系统时间是否变更(判断标准 now<lastTime)
time_t lastTime; /* Used to detect system clock skew */
//注册的文件事件
aeFileEvent *events; /* Registered events */
//已就绪的事件
aeFiredEvent *fired; /* Fired events */
//注册要使用的时间事件
aeTimeEvent *timeEventHead;
//停止标志,1表示停止
int stop;
//这个是处理底层特定API的数据,对于epoll来说,该结构体包含了epoll fd和epoll_event
void *apidata; /* This is used for polling API specific data */
//在调用processEvent前(即如果没有事件则睡眠),调用该处理函数
aeBeforeSleepProc *beforesleep;
//在调用aeApiPoll后,调用该函数
aeBeforeSleepProc *aftersleep;
} aeEventLoop;
init

Redis 服务端在其初始化函数 initServer 中,会创建事件管理器 aeEventLoop对象。函数 aeCreateEventLoop将创建一个事件管理器,主要是初始化 aeEventLoop 的各个属性值,比如events 、 fired 、 timeEventHead 和 apidata :

  • 首先创建 aeEventLoop 对象。
  • 初始化注册的文件事件表、就绪文件事件表。 events 指针指向注册的文件事件表、 fired 指针指向就绪文件事件表。表的内容在后面添加具体事件时进行初变更。
  • 初始化时间事件列表,设置 timeEventHead 和 timeEventNextId 属性。
  • 调用 aeApiCreate 函数创建 epoll 实例,并初始化 apidata 。
stop

停止标志,1表示停止,初始化为0。

文件事件: events, fired, apidata
  • aeFileEvent 结构体为已经注册并需要监听的事件的结构体。

    typedef struct aeFileEvent {
    // 监听事件类型掩码,
    // 值可以是 AE_READABLE 或 AE_WRITABLE ,
    // 或者 AE_READABLE | AE_WRITABLE
    int mask; /* one of AE_(READABLE|WRITABLE) */
    // 读事件处理器
    aeFileProc *rfileProc;
    // 写事件处理器
    aeFileProc *wfileProc;
    // 多路复用库的私有数据
    void *clientData;
    } aeFileEvent;
    
  • aeFiredEvent已就绪的文件事件

    typedef struct aeFiredEvent {
    // 已就绪文件描述符
    int fd;
    // 事件类型掩码,
    // 值可以是 AE_READABLE 或 AE_WRITABLE
    // 或者是两者的或
    int mask;
    } aeFiredEvent;
    
  • apidata

    在ae创建的时候,会被赋值为aeApiState结构体

    typedef struct aeApiState {
    // epoll_event 实例描述符
    int epfd;
    // 事件槽
    struct epoll_event *events;
    } aeApiState;
    

    这个结构体是为了epoll所准备的数据结构。redis可以选择不同的io多路复用方法。因此 apidata 是个 void类型,根据不同的io多路复用库来选择不同的实现

时间事件: timeEventHead, beforesleep, aftersleep

aeTimeEvent结构体为时间事件,Redis 将所有时间事件都放在一个无序链表中,每次 Redis 会遍历整个链表,查找所有已经到达的时间事件,并且调用相应的事件处理器。

typedef struct aeTimeEvent {
/* 全局唯一ID */
long long id; /* time event identifier. */
/* 秒精确的UNIX时间戳,记录时间事件到达的时间*/
long when_sec; /* seconds */
/* 毫秒精确的UNIX时间戳,记录时间事件到达的时间*/
long when_ms; /* milliseconds */
/* 时间处理器 */
aeTimeProc *timeProc;
/* 事件结束回调函数,析构一些资源*/
aeEventFinalizerProc *finalizerProc;
/* 私有数据 */
void *clientData;
/* 前驱节点 */
struct aeTimeEvent *prev;
/* 后继节点 */
struct aeTimeEvent *next;
} aeTimeEvent;

beforesleep 对象是一个回调函数,在 redis-server 初始化时已经设置好了。

功能

  • 检测集群状态
  • 随机释放已过期的键
  • 在数据同步复制阶段取消客户端的阻塞
  • 处理输入数据,并且同步副本信息
  • 处理非阻塞的客户端请求
  • AOF持久化存储策略,类似于mysql的bin log
  • 使用挂起的输出缓冲区处理写入

aftersleep对象是一个回调函数,在IO多路复用与IO事件处理之间被调用。

4.4.2.5 aeMain

aeMain 函数其实就是一个封装的 while 循环,循环中的代码会一直运行直到 eventLoop 的 stop 被设置为1(true)。它会不停尝试调用 aeProcessEvents 对可能存在的多种事件进行处理,而aeProcessEvents 就是实际用于处理事件的函数。

void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
if (eventLoop->beforesleep != NULL)
eventLoop->beforesleep(eventLoop);
aeProcessEvents(eventLoop, AE_ALL_EVENTS);
}
}

aemain函数中,首先调用Beforesleep。这个方法在Redis每次进入sleep/wait去等待监听的端口发生I/O事件之前被调用。当有事件发生时,调用aeProcessEvent进行处理。

4.4.2.6 aeProcessEvent

首先计算距离当前时间最近的时间事件,以此计算一个超时时间;

然后调用 aeApiPoll 函数去等待底层的I/O多路复用事件就绪;

aeApiPoll 函数返回之后,会处理所有已经产生文件事件和已经达到的时间事件。

int aeProcessEvents(aeEventLoop *eventLoop, int flags){//processed记录这次调度执行了多少事件
                int processed = 0, numevents;
                if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS)) return 0;
                if (eventLoop->maxfd != -1 ||
                        ((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
                    int j;
                    aeTimeEvent *shortest = NULL;
                    struct timeval tv, *tvp;
                    if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT))
                        //获取最近将要发生的时间事件
                        shortest = aeSearchNearestTimer(eventLoop);
                    //计算aeApiPoll的超时时间
                    if (shortest) {
                        long now_sec, now_ms;
                        //获取当前时间
                        aeGetTime(&now_sec, &now_ms);
                        tvp = &tv;
                        //计算距离下一次发生时间时间的时间间隔
                        long long ms =
                                (shortest->when_sec - now_sec)*1000 +
                                        shortest->when_ms - now_ms;
                        if (ms > 0) {
                            tvp->tv_sec = ms/1000;
                            tvp->tv_usec = (ms % 1000)*1000;
                        } else {
                            tvp->tv_sec = 0;
                            tvp->tv_usec = 0;
                        }
                    } else {//没有时间事件
                        if (flags & AE_DONT_WAIT) {//马上返回,不阻塞
                            tv.tv_sec = tv.tv_usec = 0;
                            tvp = &tv;
                        } else {
                            tvp = NULL; //阻塞到文件事件发生
                        }
                    }//等待文件事件发生,tvp为超时时间,超时马上返回(tvp为0表示马上,为null表示阻塞到事件发生)
                    numevents = aeApiPoll(eventLoop, tvp);
                    for (j = 0; j < numevents; j++) {//处理触发的文件事件
                        aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
                        int mask = eventLoop->fired[j].mask;
                        int fd = eventLoop->fired[j].fd;
                        int rfired = 0;
                        if (fe->mask & mask & AE_READABLE) {
                            rfired = 1;//处理读事件
                            fe->rfileProc(eventLoop,fd,fe->clientData,mask);
                        }
                        if (fe->mask & mask & AE_WRITABLE) {
                            if (!rfired || fe->wfileProc != fe->rfileProc)
//处理写事件
                                fe->wfileProc(eventLoop,fd,fe->clientData,mask);
                        }
                        processed++;
                    }
                }
                if (flags & AE_TIME_EVENTS)//时间事件调度和执行
                    processed += processTimeEvents(eventLoop);
                return processed;
            }

计算最早时间事件的执行时间,获取文件时间可执行时间

aeProcessEvents中aeSearchNearestTimer 会先 计算最近的时间事件发生所需要等待的时间 ,然后调用 aeApiPoll 方法在这段时间中等待事件的发生,在这段时间中如果发生了文件事件,就会优先处理文件事件,否则就会一直等待,直到最近的时间事件需要触发

堵塞等待文件事件产生

aeApiPoll 用到了epoll,select,kqueue和evport四种实现方式。

处理文件事件

rfileProc 和 wfileProc 就是在文件事件被创建时传入的函数指针

处理读事件:rfileProc

处理写事件:wfileProc

处理时间事件

processTimeEvents取得当前时间,循环时间事件链表,如果当前时间>=预订执行时间,则执行时间处理函数

五.Redis实战

5.1 缓存问题

5.1.1 缓存穿透

一般的缓存系统,都是按照key去缓存查询,如果不存在对应的value,就应该去后端系统查找(比如DB)。

缓存穿透是指在高并发下查询key不存在的数据(不存在的key),会穿过缓存查询数据库。导致数据库压力过大而宕机

解决方案:

  • 对查询结果为空的情况也进行缓存,缓存时间(ttl)设置短一点,或者该key对应的数据insert了之后清理缓存。

    问题:缓存太多空值占用了更多的空间

  • 使用布隆过滤器。在缓存之前在加一层布隆过滤器,在查询的时候先去布隆过滤器查询 key 是否存在,如果不存在就直接返回,存在再查缓存和DB。

5.1.2 缓存击穿

对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。

缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。

解决方案:

  • 用分布式锁控制访问的线程

    使用redis的setnx互斥锁先进行判断,这样其他线程就处于等待状态,保证不会有大并发操作去操作数据库。

  • 不设超时时间,volatile-lru 但会造成写一致问题

    当数据库数据发生更新时,缓存中的数据不会及时更新,这样会造成数据库中的数据与缓存中的数据的不一致,应用会从缓存中读取到脏数据。可采用延时双删策略处理

5.1.3 缓存雪崩

当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如DB)带来很大压力。

突然间大量的key失效了或redis重启,大量访问数据库,数据库崩溃

解决方案:

  • key的失效期分散开 不同的key设置不同的有效期
  • 设置二级缓存(数据不一定一致)
  • 高可用(脏读)

5.1.4 数据不一致

缓存和DB的数据不一致的根源 : 数据源不一样

强一致性很难,追求最终一致性(时间)

互联网业务数据处理的特点是:高吞吐量、低延迟、数据敏感性低于金融业

先更新数据库再更新缓存或者先更新缓存再更新数据库在本质上不是一个原子操作,所以时序控制不可行,高并发情况下就会产生不一致

保证数据的最终一致性(延时双删)

  • 先更新数据库同时删除缓存项(key),等读的时候再填充缓存
  • 2秒后再删除一次缓存项(key)
  • 设置缓存过期时间 Expired Time 比如 10秒 或1小时
  • 将缓存删除失败记录到日志中,利用脚本提取失败记录再次删除(缓存失效期过长 7*24)

升级方案

通过数据库的binlog来异步淘汰key,利用工具(canal)将binlog日志采集发送到MQ中,然后通过ACK机制确认处理删除缓存。

5.1.5 Hot Key

当有大量的请求(几十万)访问某个Redis某个key时,由于流量集中达到网络上限,从而导致这个redis的服务器宕机。造成缓存击穿,接下来对这个key的访问将直接访问数据库造成数据库崩溃,或者访问数据库回填Redis再访问Redis,继续崩溃。

截屏2023-09-05 15.02.20

如何发现热key

  • 预估热key,比如秒杀的商品、火爆的新闻等
  • 在客户端进行统计,实现简单,加一行代码即可
  • 如果是Proxy,比如Codis,可以在Proxy端收集
  • 利用Redis自带的命令,monitor、hotkeys。但是执行缓慢(不要用)
  • 利用基于大数据领域的流式计算技术来进行实时数据访问次数的统计,比如 Storm、SparkStreaming、Flink,这些技术都是可以的。发现热点数据后可以写到zookeeper中

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

如何处理热Key

  • 变分布式缓存为本地缓存

    发现热key后,把缓存数据取出后,直接加载到本地缓存中。可以采用Ehcache、Guava Cache都可以,这样系统在访问热key数据时就可以直接访问自己的缓存了。(数据不要求时时一致)

  • 在每个Redis主节点上备份热key数据

    这样在读取时可以采用随机读取的方式,将访问压力负载到每个Redis上。

  • 利用对热点数据访问的限流熔断保护措施

    每个系统实例每秒最多请求缓存集群读操作不超过 400 次,一超过就可以熔断掉,不让请求缓存集群,直接返回一个空白信息,然后用户稍后会自行再次重新刷新页面之类的。(首页不行,系统友好性差)

    通过系统层自己直接加限流熔断保护措施,可以很好的保护后面的缓存集群

5.1.6 Big Key

大key指的是存储的值(Value)非常大

大key的影响

  • 大key会大量占用内存,在集群中无法均衡
  • Redis的性能下降,主从复制异常
  • 在主动删除或过期删除时会操作时间过长而引起服务阻塞

如何发现大key

  • redis-cli --bigkeys命令。可以找到某个实例5种数据类型(String、hash、list、set、zset)的最大key。但如果Redis 的key比较多,执行该命令会比较慢

  • 获取生产Redis的rdb文件,通过rdbtools分析rdb生成csv文件,再导入MySQL或其他数据库中进行分析统计,根据size_in_bytes统计bigkey

大key的处理

优化big key的原则就是string减少字符串长度,list、hash、set、zset等减少成员数

  • string类型的big key,尽量不要存入Redis中,可以使用文档型数据库MongoDB或缓存到CDN上。如果必须用Redis存储,最好单独存储,不要和其他的key一起存储。采用一主一从或多从。

  • 单个简单的key存储的value很大,可以尝试将对象分拆成几个key-value, 使用mget获取值,这样分拆的意义在于分拆单次操作的压力,将操作压力平摊到多次操作中,降低对redis的IO影响。

  • hash, set,zset,list 中存储过多的元素,可以将这些元素分拆。(常见)

  • 删除大key时不要使用del,因为del是阻塞命令,删除时会影响性能。

  • 使用 lazy delete (unlink命令)

    删除指定的key(s),若key不存在则该key被跳过。但是,相比DEL会产生阻塞,该命令会在另一个线程中回收内存,因此它是非阻塞的。 这也是该命令名字的由来:仅将keys从key空间中删除,真正的数据删除会在后续异步操作

5.2 缓存一致性

5.2.1 更新策略

缓存更新策略
  • 利用Redis的缓存淘汰策略被动更新 LRU 、LFU
  • 利用TTL被动更新
  • 在更新数据库时主动更新 (先更数据库再删缓存----延时双删)
  • 异步更新 定时任务 数据不保证时时一致 不穿DB
策略优缺点
策略一致性成本
利用Redis的缓存淘汰策略被动更新最差最低
利用TTL被动更新较差较低
在更新数据库时主动更新较强最高

5.2.2 与Mybatis整合

  • 实现org.apache.ibatis.cache.Cache
  • 在mapper中增加二级缓存开启(默认不开启)
  • 在启动时允许缓存
public class RedisCache implements Cache {
    //缓存对象唯一标识
    private final String id; //orm的框架都是按对象的方式缓存,而每个对象都需要一个唯一标识.
    //用于事务性缓存操作的读写锁
    private static ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    //处理事务性缓存中做的
//操作数据缓存的--跟着线程走的
    private RedisTemplate redisTemplate; //Redis的模板负责将缓存对象写到redis服务器里面去
    //缓存对象的是失效时间,30分钟
    private static final long EXPRIRE_TIME_IN_MINUT = 30;

    //构造方法---把对象唯一标识传进来
    public RedisCache(String id) {
        if (id == null) {
            throw new IllegalArgumentException("缓存对象id是不能为空的");
        }
        this.id = id;
    }

    @Override
    public String getId() {
        return this.id;
    }

    //给模板对象RedisTemplate赋值,并传出去
    private RedisTemplate getRedisTemplate() {
        if (redisTemplate == null) { //每个连接池的连接都要获得RedisTemplate
            redisTemplate = ApplicationContextHolder.getBean("redisTemplate");
        }
        return redisTemplate;
    }

    /*
    保存缓存对象的方法
    */
    @Override
    public void putObject(Object key, Object value) {
        try {
            RedisTemplate redisTemplate = getRedisTemplate();
            //使用redisTemplate得到值操作对象
            ValueOperations operation = redisTemplate.opsForValue();
            //使用值操作对象operation设置缓存对象
            operation.set(key, value, EXPRIRE_TIME_IN_MINUT, TimeUnit.MINUTES);
            //TimeUnit.MINUTES系统当前时间的分钟数
            System.out.println("缓存对象保存成功");
        } catch (Throwable t) {
            System.out.println("缓存对象保存失败" + t);
        }
    }

    /*
    获取缓存对象的方法
    */
    @Override
    public Object getObject(Object key) {
        try {
            RedisTemplate redisTemplate = getRedisTemplate();
            ValueOperations operations = redisTemplate.opsForValue();
            Object result = operations.get(key);
            System.out.println("获取缓存对象");
            return result;
        } catch (Throwable t) {
            System.out.println("缓存对象获取失败" + t);
            return null;
        }
    }

    /*
    删除缓存对象
    */
    @Override
    public Object removeObject(Object key) {
        try {
            RedisTemplate redisTemplate = getRedisTemplate();
            redisTemplate.delete(key);
            System.out.println("删除缓存对象成功!");
        } catch (Throwable t) {
            System.out.println("删除缓存对象失败!" + t);
        }
        return null;
    }
    

    /*
    清空缓存对象
    当缓存的对象更新了的话,就执行此方法
    */
    @Override
    public void clear() {
        RedisTemplate redisTemplate = getRedisTemplate();
        //回调函数
        redisTemplate.execute((RedisCallback) collection -> {
            collection.flushDb();
            return null;
        });
        System.out.println("清空缓存对象成功!");
    }

    //可选实现的方法
    @Override
    public int getSize() {
        return 0;
    }

    @Override
    public ReadWriteLock getReadWriteLock() {
        return readWriteLock;
    }
}

5.3 分布式锁

分布式锁特性

  • 互斥性

    任意时刻,只能有一个客户端获取锁,不能同时有两个客户端获取到锁。

  • 同一性

    锁只能被持有该锁的客户端删除,不能由其它客户端删除。

  • 可重入性

    持有某个锁的客户端可继续对该锁加锁,实现锁的续租

  • 容错性

    锁失效后(超过生命周期)自动释放锁(key失效),其他客户端可以继续获得该锁,防止死锁

分布式锁的实际应用

  • 数据并发竞争

  • 防止库存超卖

分布式锁的对比

rediszketcd
一致性算法paxos(ZAB)raft
CAPAPCPCP
高可用主从集群n+1 (n至少为2)n+1
接口类型客户端客户端http/grpc
实现setNxcreateEphemeralrestful API

5.3.1 watch实现乐观锁

乐观锁基于CAS(Compare And Swap)思想(比较并替换),是不具有互斥性,不会产生锁等待而消耗资源,但是需要反复的重试,但也是因为重试的机制,能比较快的响应。利用redis来实现乐观锁如下:

  • 利用redis的watch功能,监控这个redisKey的状态值
  • 获取redisKey的值
  • 创建redis事务
  • 给这个key的值+1
  • 然后去执行这个事务,如果key的值被修改过则执行失败

5.3.2 senx实现悲观锁

利用Redis的单线程特性对共享资源进行串行化处理

  • 获取锁

    /**
     * 使用redis的set命令实现获取分布式锁
     *
     * @param lockKey    可以就是锁
     * @param requestId  请求ID,保证同一性 uuid+threadID
     * @param expireTime 过期时间,避免死锁
     * @return
     */
    public boolean getLock(String lockKey, String requestId, int expireTime) {
        //NX:保证互斥性
        // hset 原子性操作 只要lockKey有效 则说明有进程在使用分布式锁
        String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);
        if ("OK".equals(result)) {
            return true;
        }
        return false;
    }
    
  • 释放锁

    public static boolean releaseLock(String lockKey, String requestId) {
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            Object result = jedis.eval(script, Collections.singletonList(lockKey),
                    Collections.singletonList(requestId));
            if (result.equals(1L)) {
                return true;
            }
            return false;
        }
    

存在的问题

  • redis是单机时无法保证高可用

  • redis是主从部署时无法保证数据的强一致性,比如某个客户端在主服务器刚成功获取锁成功但是主服务器还没来得及复制就挂了,那其他客户端可能会重复获得锁

  • 无法续租,超过expireTime后会自动释放锁,不能继续使用

问题的本质

分布式锁是CP模型,Redis集群是AP模型。 (base) Redis集群不能保证数据的随时一致性,只能保证数据的最终一致性。

那为什么还可以用Redis实现分布式锁?当业务不需要数据强一致性时,比如:社交场景,就可以使用Redis实现分布式锁

当业务必须要数据的强一致性,即不允许重复获得锁,比如金融场景(重复下单,重复转账)就不要使用redis ,可以使用CP模型实现,比如:zookeeper和etcd。

5.3.3 Redisson

Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。

Redisson在基于NIO的Netty框架上,生产环境使用分布式锁。

<dependency>
	<groupId>org.redisson</groupId>
	<artifactId>redisson</artifactId>
	<version>2.7.0</version>
</dependency>

public class RedissonManager {
    private static Config config = new Config();
    //声明redisson对象
    private static Redisson redisson = null;

    //实例化redisson
    static {
        config.useClusterServers()
                // 集群状态扫描间隔时间,单位是毫秒
                .setScanInterval(2000)
                //cluster方式至少6个节点(3主3从,3主做sharding,3从用来保证主宕机后可以高可用)
                .addNodeAddress("redis://127.0.0.1:6379")
                .addNodeAddress("redis://127.0.0.1:6380")
                .addNodeAddress("redis://127.0.0.1:6381")
                .addNodeAddress("redis://127.0.0.1:6382")
                .addNodeAddress("redis://127.0.0.1:6383")
                .addNodeAddress("redis://127.0.0.1:6384");
        //得到redisson对象
        redisson = (Redisson) Redisson.create(config);
    }

    //获取redisson对象的方法
    public static Redisson getRedisson() {
        return redisson;
    }
}

public class DistributedRedisLock {
    //从配置类中获取redisson对象
    private static Redisson redisson = RedissonManager.getRedisson();
    private static final String LOCK_TITLE = "redisLock_";

    //加锁
    public static boolean acquire(String lockName) {
        //声明key对象
        String key = LOCK_TITLE + lockName;
        RLock mylock = redisson.getLock(key);
        //加锁,并且设置锁过期时间3秒,防止死锁的产生 uuid+threadId
        mylock.lock(2, 3, TimeUtil.SECOND);
        //加锁成功
        return true;
    }

    //锁的释放
    public static void release(String lockName) {
        //必须是和加锁时的同一个key
        String key = LOCK_TITLE + lockName;
        //获取所对象
        RLock mylock = redisson.getLock(key);
        //释放锁(解锁)
        mylock.unlock();
    }
}

    public String discount() throws IOException {
        String key = "lock001";
        //加锁
        DistributedRedisLock.acquire(key);
        //执行具体业务逻辑
        dosoming
        //释放锁
        DistributedRedisLock.release(key);
        //返回结果
        return soming;
    }
实现原理

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

加锁机制

如果该客户端面对的是一个redis cluster集群,他首先会根据hash节点选择一台机器。发送lua脚本到redis服务器上,脚本如下:

"if (redis.call('exists',KEYS[1])==0) then "+ --看有没有锁
		"redis.call('hset',KEYS[1],ARGV[2],1) ; "+ --无锁 加锁
		"redis.call('pexpire',KEYS[1],ARGV[1]) ; "+ --设置过期时间
		"return nil; end ;" +
"if (redis.call('hexists',KEYS[1],ARGV[2]) ==1 ) then "+ --我加的锁
		"redis.call('hincrby',KEYS[1],ARGV[2],1) ; "+ --重入锁
		"redis.call('pexpire',KEYS[1],ARGV[1]) ; "+
		"return nil; end ;" +
"return redis.call('pttl',KEYS[1]) ;" --不能加锁,返回锁的时间

使用lua脚本是为了保证这段复杂业务逻辑执行的原子性。

KEYS[1]) : 加锁的key

ARGV[1] : key的生存时间,默认为30秒

ARGV[2] : 加锁的客户端ID (UUID.randomUUID()+ “:” + threadId)

第一段if判断语句,就是用“exists myLockKey”命令判断一下,如果你要加锁的那个锁key不存在的话,你就进行加锁。如何加锁呢?很简单,用下面的命令:

hset myLockKey clientID 1

通过这个命令设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:

myLockKey :{“clientID”:1 }

上述就代表clientID这个客户端对myLockKey这个锁key完成了加锁。

接着会执行“pexpire myLockKey myLockEx”命令,设置myLockKey这个锁的生存时间是myLockEx秒。

锁互斥机制

如果客户端2来尝试加锁,在第一个if判断会执行“exists myLockKey”,发现myLockKey这个锁key已经存在了。

然后进入第二个if判断,myLockKey锁key的hash数据结构中,是否包含客户端2的ID,但是因为上次加锁不是当前客户端(两个客户端的clientID不一样)所以,客户端2会获取到pttl myLockKey返回的一个数字,这个数字代表了myLockKey这个锁key的剩余生存时间比如还剩15000毫秒的生存时间。

此时客户端2会进入一个while循环,不停的尝试加锁。

自动延时机制

只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。

可重入锁机制

进行重入时第一个if判断肯定不成立,exists myLockKey会显示锁key已经存在了。

然后进入第二个if判断,因为myLockKey的hash数据结构中包含的clientID和当前客户端的一样,所以就会执行可重入加锁的逻辑,执行 incrby myLockKey clientID 1,对客户端1的加锁次数,累加1。数据结构会变成:myLockKey :{“clientID”:2 }

释放锁机制
#如果key已经不存在,说明已经被解锁,直接发布(publish)redis消息
"if (redis.call('exists', KEYS[1]) == 0) then " +
	"redis.call('publish', KEYS[2], ARGV[1]); " +
	"return 1; " +
	"end;" +
# key和field不匹配,说明当前客户端线程没有持有锁,不能主动解锁。 不是我加的锁 不能解锁
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
	"return nil;" +
	"end; " +
# 将value减1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3],-1); " +
# 如果counter>0说明锁在重入,不能删除key
"if (counter > 0) then " +
	"redis.call('pexpire', KEYS[1], ARGV[2]); " +
	"return 0; " +
# 删除key并且publish 解锁消息
"else " +
	"redis.call('del', KEYS[1]); " + #删除锁
	"redis.call('publish', KEYS[2], ARGV[1]); " +
	"return 1; "+
	"end; " +
"return nil;"

KEYS[1] :需要加锁的key,这里需要是字符串类型。

KEYS[2] :redis消息的ChannelName,一个分布式锁对应唯一的一个channelName:“redisson_lockchannel{” + getName() + “}”

ARGV[1] :reids消息体,这里只需要一个字节的标记就可以,主要标记redis的key已经解锁,再结合redis的Subscribe,能唤醒其他订阅解锁消息的客户端线程申请锁。

ARGV[2] :锁的超时时间,防止死锁

ARGV[3] :锁的唯一标识,也就是刚才介绍的 id(UUID.randomUUID()) + “:” + threadId如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也是非常简单的。

每次都对myLockKey数据结构中的那个加锁次数减1。

如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用del myLockKey命令,从redis里删除这个key。然后呢,另外的客户端2就可以尝试完成加锁了。

六.Redis高可用

“高可用性”(High Availability)通常来描述一个系统经过专门的设计,从而减少停工时间,而保持其服务的高度可用性。CAP的A

单机的Redis是无法保证高可用性的,当Redis服务器宕机后,即使在有持久化的机制下也无法保证不丢失数据

Redis的高可用部署方案有主从复制、哨兵模式、集群模式

6.1 主从复制

Redis支持主从复制功能,可以通过执行slaveof(Redis5以后改成replicaof)或者在配置文件中设置slaveof(Redis5以后改成replicaof)来开启复制功能

作用:

  • 读写分离
    • 一主多从,主从同步
    • 主负责写,从负责读
    • 提升Redis的性能和吞吐量
  • 数据容灾
    • 利用哨兵可以实现主从切换,做到高可用

缺点:

  • 主可写从不可写

  • 主从的数据一致性问题

  • 主挂了,从不可为主,但是利用哨兵可以实现主从切换,做到高可用

一主一从

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

一主多从

截屏2023-09-07 11.50.18

传递复制

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在主从模式中主不需要配置,从服务器需要修改 redis.conf 文件

# slaveof <masterip> <masterport>
# 表示当前【从服务器】对应的【主服务器】的IP是192.168.10.135,端口是6379。
replicaof 127.0.0.1 6379

6.1.1 复制流程

  • 保存主节点信息

    当客户端向从服务器发送slaveof(replicaof) 主机地址(127.0.0.1) 端口(6379)时:从服务器将主机ip(127.0.0.1)和端口(6379)保存到redisServer的masterhost和masterport中。

    Struct redisServer{
    char *masterhost;//主服务器ip
    int masterport;//主服务器端口
    
    }
    

    从服务器将向发送SLAVEOF命令的客户端返回OK,表示复制指令已经被接收,而实际上复制工作是在OK返回之后进行

  • 建立socket连接

    slaver与master建立socket连接,slaver关联文件事件处理器,该处理器接收RDB文件(全量复制)、接收Master传播来的写命令(增量复制)

    主服务器accept从服务器Socket连接后,创建相应的客户端状态。相当于从服务器是主服务器的Client端。

  • 发送ping命令

    • Slaver向Master发送ping命令
      • 检测socket的读写状态
      • 检测Master能否正常处理
    • Master的响应
      • 发送“pong” , 说明正常
      • 返回错误,说明Master不正常
      • timeout,说明网络超时
  • 权限验证

    主从正常连接后,进行权限验证

    主未设置密码(requirepass=“”) ,从也不用设置密码(masterauth=“”)

    主设置密码(requirepass!=“”),从需要设置密码(masterauth=主的requirepass的值)

    或者从通过auth命令向主发送密码

  • 发送端口信息

    在身份验证步骤之后,从服务器将执行命令REPLCONF listening-port ,向主服务器发送从服务器的监听端口号

  • 同步数据

  • 命令传播

    当同步数据完成后,主从服务器就会进入命令传播阶段,主服务器只要将自己执行的写命令发送给从服务器,而从服务器只要一直执行并接收主服务器发来的写命令

6.1.2 同步数据集

Redis 的主从同步,分为全量同步增量同步

只有从机第一次连接上主机是全量同步

断线重连有可能触发全量同步也有可能是增量同步( master 判断 runid 是否一致)

除此之外的情况都是增量同步

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

全量同步

  • 同步快照阶段

    Master 创建并发送快照RDB给 Slave , Slave 载入并解析快照。 Master 同时将此阶段所产生的新的写命令存储到缓冲区

  • 同步写缓冲阶段

    Master 向 Slave 同步存储在缓冲区的写操作命令。

  • 同步增量阶段

    Master 向 Slave 同步写操作命令。

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

增量同步

  • Redis增量同步主要指Slave完成初始化后开始正常工作时, Master 发生的写操作同步到 Slave的过程。

  • 通常情况下, Master 每执行一个写命令就会向 Slave 发送相同的写命令,然后 Slave 接收并执行。

6.1.3 心跳检测

在命令传播阶段,从服务器默认会以每秒一次的频率向主服务器发送命令:

replconf ack <replication_offset>
#ack :应答
#replication_offset:从服务器当前的复制偏移量

作用

  • 检测主从的连接状态

    检测主从服务器的网络连接状态

    通过向主服务器发送INFO replication命令,可以列出从服务器列表,可以看出从最后一次向主发送命令距离现在过了多少秒。lag的值应该在0或1之间跳动,如果超过1则说明主从之间的连接有故障。

  • 辅助实现min-slaves

    Redis可以通过配置防止主服务器在不安全的情况下执行写命令

    min-slaves-to-write 3 (min-replicas-to-write 3 )

    min-slaves-max-lag 10 (min-replicas-max-lag 10)

    上面的配置表示:从服务器的数量少于3个,或者三个从服务器的延迟(lag)值都大于或等于10秒时,主服务器将拒绝执行写命令。这里的延迟值就是上面INFO replication命令的lag值

  • 检测命令丢失

    如果因为网络故障,主服务器传播给从服务器的写命令在半路丢失,那么当从服务器向主服务器发送replconf ack命令时,主服务器将发觉从服务器当前的复制偏移量少于自己的复制偏移量,然后主服务器就会根据从服务器提交的复制偏移量,在复制积压缓冲区里面找到从服务器缺少的数据,并将这些数据重新发送给从服务器。

6.2 哨兵模式

由一个或多个sentinel实例组成sentinel集群可以监视一个或多个主服务器和多个从服务器。

当主服务器进入下线状态时,sentinel可以将该主服务器下的某一从服务器升级为主服务器继续提供服务,从而保证redis的高可用性。

截屏2023-09-07 21.39.01

  • 启动并初始化Sentinel

    • Sentinel是一个特殊的Redis服务器

    • 不会进行持久化

    • Sentinel实例启动后每个Sentinel会创建2个连向主服务器的网络连接

      命令连接:用于向主服务器发送命令,并接收响应;

      订阅连接:用于订阅主服务器的—sentinel—:hello频道。

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 获取主服务器信息

    Sentinel默认每10s一次,向被监控的主服务器发送info命令,获取主服务器和其下属从服务器的信息

  • 获取从服务器信息

    当Sentinel发现主服务器有新的从服务器出现时,Sentinel还会向从服务器建立命令连接和订阅连接。

    在命令连接建立之后,Sentinel还是默认10s一次,向从服务器发送info命令,并记录从服务器的信息

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 向主服务器和从服务器发送消息(以订阅的方式)

    默认情况下,Sentinel每2s一次,向所有被监视的主服务器和从服务器所订阅的—sentinel—:hello频道上发送消息,消息中会携带Sentinel自身的信息和主服务器的信息

    PUBLISH _sentinel_:hello "< s_ip > < s_port >< s_runid >< s_epoch > < m_name > <m_ip >< m_port ><m_epoch>"
    
  • 接收来自主服务器和从服务器的频道信息

    当Sentinel与主服务器或者从服务器建立起订阅连接之后,Sentinel就会通过订阅连接,向服务器发送以下命令:

    subscribe —sentinel—:hello
    

    Sentinel彼此之间只创建命令连接,而不创建订阅连接,因为Sentinel通过订阅主服务器或从服务器,就可以感知到新的Sentinel的加入,而一旦新Sentinel加入后,相互感知的Sentinel通过命令连接来通信就可以了。

  • 检测主观下线状态

    Sentinel每秒一次向所有与它建立了命令连接的实例(主服务器、从服务器和其他Sentinel)发送PING命令

    实例在down-after-milliseconds毫秒内返回无效回复(除了+PONG、-LOADING、-MASTERDOWN外)或者实例在down-after-milliseconds毫秒内无回复(超时),Sentinel就会认为该实例主观下线(SDown)

  • 检查客观下线状态

    当一个Sentinel将一个主服务器判断为主观下线后

    Sentinel会向同时监控这个主服务器的所有其他Sentinel发送查询命令

    SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>
    

    其他Sentinel回复

    <down_state>< leader_runid >< leader_epoch >
    

    根据回复判断它们是否也认为主服务器下线。如果达到Sentinel配置中的quorum数量的Sentinel实例都判断主服务器为主观下线,则该主服务器就会被判定为客观下线(ODown)。

  • 选举Leader Sentinel

    当一个主服务器被判定为客观下线后,监视这个主服务器的所有Sentinel会通过选举算法(raft),选出一个Leader Sentinel去执行failover(故障转移)

    Raft

    Raft协议是用来解决分布式系统一致性问题的协议。Raft协议描述的节点共有三种状态:Leader, Follower, Candidate

    Raft协议将时间切分为一个个的Term(任期),可以认为是一种“逻辑时间”。

    Raft选举流程:

    • Raft采用心跳机制触发Leader选举

    • 系统启动后,全部节点初始化为Follower,term为0。

    • 节点如果收到了RequestVote或者AppendEntries,就会保持自己的Follower身份

    • 节点如果一段时间内没收到AppendEntries消息,在该节点的超时时间内还没发现Leader,Follower就会转换成Candidate,自己开始竞选Leader。

    • 一旦转化为Candidate,该节点立即开始下面几件事情:

      • 增加自己的term。

      • 启动一个新的定时器。

      • 给自己投一票。

      • 向所有其他节点发送RequestVote,并等待其他节点的回复。

      如果在计时器超时前,节点收到多数节点的同意投票,就转换成Leader。同时向所有其他节点发送AppendEntries,告知自己成为了Leader。

      每个节点在一个term内只能投一票,采取先到先得的策略

      Candidate投给直接,Follower会投给它收到的第一个RequestVote的节点。

      Raft协议的定时器采取随机超时时间,这是选举Leader的关键。

      在同一个term内,先转为Candidate的节点会先发起投票,从而获得多数票。

    Sentinel的leader选举流程

    • 某Sentinel认定master客观下线后,该Sentinel会先看看自己有没有投过票,如果自己已经投过票给其他Sentinel了,在一定时间内自己就不会成为Leader。

    • 如果该Sentinel还没投过票,那么它就成为Candidate。

    • Sentinel需要完成几件事情:

      • 新故障转移状态为start
      • 当前epoch加1,相当于进入一个新term,在Sentinel中epoch就是Raft协议中的term。
      • 向其他节点发送 is-master-down-by-addr 命令请求投票。命令会带上自己的epoch。
      • 给自己投一票(leader、leader_epoch)
    • 当其它哨兵收到此命令时,可以同意或者拒绝它成为领导者;(通过判断epoch)

    • Candidate会不断的统计自己的票数,直到他发现认同他成为Leader的票数超过一半而且超过它配置的quorum,这时它就成为了Leader。

    • 其他Sentinel等待Leader从slave选出master后,检测到新的master正常工作后,就会去掉客观下线的标识

    故障转移

    当选举出Leader Sentinel后,Leader Sentinel会对下线的主服务器执行故障转移操作,主要有三个步骤:

    • 它会将失效 Master 的其中一个 Slave 升级为新的 Master , 并让失效 Master 的其他 Slave 改为复制新的 Master ;
    • 当客户端试图连接失效的 Master 时,集群也会向客户端返回新 Master 的地址,使得集群可以使用现在的 Master 替换失效 Master 。
    • Master 和 Slave 服务器切换后, Master 的 redis.conf 、 Slave 的 redis.conf 和sentinel.conf 的配置文件的内容都会发生相应的改变,即 Master 主服务器的 redis.conf 配置文件中会多一行 replicaof 的配置, sentinel.conf 的监控目标会随之调换。
  • 主服务器的选择

    哨兵leader根据以下规则从客观下线的主服务器的从服务器中选择出新的主服务器。

    • 过滤掉主观下线的节点
    • 选择slave-priority最高的节点,如果由则返回没有就继续选择
    • 选择出复制偏移量最大的系节点,因为复制偏移量越大则数据复制的越完整,如果有就返回了,没有就继续
    • 选择run_id最小的节点,因为run_id越小说明重启次数越少

6.3 集群模式

Redis3.0之后,Redis官方提供了完整的集群解决方案。

方案采用去中心化的方式,包括:sharding(分区)、replication(复制)、failover(故障转移)。称为RedisCluster。

Redis5.0前采用redis-trib进行集群的创建和管理,需要ruby支持,Redis5.0可以直接使用Redis-cli进行集群的创建和管理

节点数不能超过一千,不然会出问题

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

6.3.1 概念解释

  • 去中心化

RedisCluster由多个Redis节点组构成,是一个P2P无中心节点的集群架构,依靠Gossip协议传播的集群。

  • Gossip协议

    Gossip协议是一个通信协议,一种传播消息的方式。

    Gossip协议基本思想就是:

    • 一个节点周期性(每秒)随机选择一些节点,并把信息传递给这些节点。

    • 这些收到信息的节点接下来会做同样的事情,即把这些信息传递给其他一些随机选择的节点。

    • 信息会周期性的传递给N个目标节点。这个N被称为fanout(扇出)

    gossip协议包含多种消息,包括meet、ping、pong、fail、publish等等。

    通过gossip协议,cluster可以提供集群间状态同步更新、选举自助failover等重要的集群功能。

    命令说明
    meetsender向receiver发出,请求加入sender的集群
    ping节点检测其他节点是否在线
    pongreceiver收到meet或ping后的回复信息;在failover后,新的Master也会广播pong
    fail节点A判断节点B下线后,A节点广播B的fail信息,其他收到节点会将B节点标记为下线
    publish节点A收到publish命令,节点A执行该命令,并向集群广播publish命令,收到publish命令的节点都会执行相同的publish命令
  • slot

    redis-cluster把所有的物理节点映射到[0-16383]个slot上,基本上采用平均分配和连续分配的方式。

    • 平均分配:初始化的时候每个节点对应的槽数量差不多,如果后期添加主节点手动输入节点的槽数,会出现分配不平均

    • 连续分配:一定会分到16383,也就是说16383个槽都必须有对应的节点

    比如有5个主节点,这样在RedisCluster创建时,slot槽可按下表分配:

    节点slot范围
    Redis10-3270
    Redis23271-6542
    Redis36543-9814
    Redis49815-13087
    Redis513088-16383

    cluster 负责维护节点和slot槽的对应关系,当需要在 Redis 集群中放置一个 key-value 时,redis 先对 key 使用 crc16 算法算出一个结果,然后把结果对 16384 求余数,这样每个 key 都会对应一个编号在 0-16383 之间的哈希槽,redis 会根据节点数量大致均等的将哈希槽映射到不同的节点。

    比如:

    set name zhaoyun

    hash(“name”)采用crc16算法,得到值:1324203551%16384=15903

    根据上表15903在13088-16383之间,所以name被存储在Redis5节点。

    slot槽必须在节点上连续分配,如果出现不连续的情况,则RedisCluster不能工作

  • RedisCluster的优势

    • 高性能

      Redis Cluster 的性能与单节点部署是同级别的。

      多主节点、负载均衡、读写分离

    • 高可用

      Redis Cluster 支持标准的 主从复制配置来保障高可用和高可靠。

    • failover

      Redis Cluster 也实现了一个类似 Raft 的共识方式,来保障整个集群的可用性。

    • 易扩展

      向 Redis Cluster 中添加新节点,或者移除节点,都是透明的,不需要停机。

      水平、垂直方向都非常容易扩展。

      数据分区,海量数据,数据存储

    • 原生

      部署 Redis Cluster 不需要其他的代理或者工具,而且 Redis Cluster 和单机 Redis 几乎完全兼容。

6.3.2 分片

Redis Cluster 不存在单独的proxy或配置服务器,所以需要将客户端路由到目标的分片。

6.3.2.1 客户端路由

Redis Cluster的客户端相比单机Redis 需要具备路由语义的识别能力,且具备一定的路由缓存能力。

moved重定向

  • 每个节点通过通信都会共享Redis Cluster中槽和集群中对应节点的关系

  • 客户端向Redis Cluster的任意节点发送命令,接收命令的节点会根据CRC16规则进行hash运算与16384取余,计算自己的槽和对应节点

  • 如果保存数据的槽被分配给当前节点,则去槽中执行命令,并把命令执行结果返回给客户端

  • 如果保存数据的槽不在当前节点的管理范围内,则向客户端返回moved重定向异常

  • 客户端接收到节点返回的结果,如果是moved异常,则从moved异常中获取目标节点的信息

  • 客户端向目标节点发送命令,获取命令执行结果

截屏2023-09-08 18.21.10

ask重定向

在对集群进行扩容和缩容时,需要对槽及槽中数据进行迁移

当客户端向某个节点发送命令,节点向客户端返回moved异常,告诉客户端数据对应的槽的节点信息

如果此时正在进行集群扩展或者缩空操作,当客户端向正确的节点发送命令时,槽及槽中数据已经被迁移到别的节点了,就会返回ask,这就是ask重定向机制

  • 客户端向目标节点发送命令,目标节点中的槽已经迁移支别的节点上了,此时目标节点会返回ask转向给客户端

  • 客户端向新的节点发送Asking命令给新的节点,然后再次向新节点发送命令

  • 新节点执行命令,把命令执行结果返回给客户端

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

moved和ask的区别

  • moved:槽已确认转移

  • ask:槽还在转移过程中

Smart智能客户端

JedisCluster是Jedis根据RedisCluster的特性提供的集群智能客户端

JedisCluster为每个节点创建连接池,并跟节点建立映射关系缓存(Cluster slots)

JedisCluster将每个主节点负责的槽位一一与主节点连接池建立映射缓存

JedisCluster启动时,已经知道key,slot和node之间的关系,可以找到目标节点

JedisCluster对目标节点发送命令,目标节点直接响应给JedisCluster

如果JedisCluster与目标节点连接出错,则JedisCluster会知道连接的节点是一个错误的节点

此时节点返回moved异常给JedisCluster

JedisCluster会重新初始化slot与node节点的缓存关系,然后向新的目标节点发送命令,目标命令执行命令并向JedisCluster响应

如果命令发送次数超过5次,则抛出异常"Too many cluster redirection!"

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

6.3.2.2 迁移

在RedisCluster中每个slot 对应的节点在初始化后就是确定的。在某些情况下,节点和分片需要变更:

  • 新的节点作为master加入;

  • 某个节点分组需要下线;

  • 负载不均衡需要调整slot 分布

此时需要进行分片的迁移,迁移的触发和过程控制由外部系统完成。包含下面 2 种:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 向节点B发送状态变更命令,将B的对应slot 状态置为importing。
  • 向节点A发送状态变更命令,将A对应的slot 状态置为migrating。
  • 向A 发送migrate 命令,告知A将要迁移的slot对应的key迁移到B。
  • 当所有key 迁移完成后,cluster setslot 重新设置槽位。
6.3.2.3 扩容
  • 添加主节点

    • 先创建无数据的主节点,并启动
    • 执行./redis-cli --cluster add-node newMasterHost:newMasterPort clusterHost:clusterPort将添加的主节点添加到集群,其中clusterHost:clusterPort是集群中任意一个节点的host与端口号,可以使用cluster nodes命令来查看是否添加成功
  • hash槽重新分配(数据迁移)

    添加完主节点需要对主节点进行hash槽分配,这样该主节才可以存储数据

    使用cluster nodes命令来查看集群中槽占用情况

    • 连接上集群(连接集群中任意一个可用结点都行) 使用 ./redis-cli --cluster reshard host:port 命令,其中host:port是要分配hash槽节点的host:port
    • 输入要分配的槽数量
    • 输入接收槽的结点id(可通过cluster nodes命令来查看)
    • 输入源结点id。这里全部重新分配输入all,如果要从指定的节点分出槽就输入源结点id,如果是all master节点都会分一些槽给接收节点
    • 输入yes开始移动槽到目标结点id
  • 添加从节点

    ./redis-cli --cluster add-node 新节点的ip和端口 master节点ip和端口 --cluster-slave – cluster-master-id master主节点id

6.3.2.4 缩容

./redis-cli --cluster del-node host:port 结点id

如果删除已经分配hash槽的结点则会报错,需要先将该节点的hash槽分配出去

6.3.3 容灾

故障检测
  • 集群中的每个节点都会定期地(每秒)向集群中的其他节点发送PING消息

  • 如果在一定时间内(cluster-node-timeout),发送ping的节点A没有收到某节点B的pong回应,则A将B标识为pfail。

  • A在后续发送ping时,会带上B的pfail信息, 通知给其他节点。

  • 如果B被标记为pfail的个数大于集群主节点个数的一半(N/2 + 1)时,B会被标记为fail,A向整个集群广播,该节点已经下线。

  • 其他节点收到广播,标记B为fail。

故障转移

从节点选举

  • 每个从节点,都根据自己对master复制数据的offset,来设置一个选举时间,offset越大(复制数据越多)的从节点,选举时间越靠前,优先进行选举。
  • slave 通过向其他master发送FAILVOER_AUTH_REQUEST 消息发起竞选,
  • master 收到后回复FAILOVER_AUTH_ACK 消息告知是否同意。
  • slave 发送FAILOVER_AUTH_REQUEST 前会将currentEpoch 自增,并将最新的Epoch 带入到FAILOVER_AUTH_REQUEST 消息中
  • 所有的Master开始slave选举投票,给要进行选举的slave进行投票,如果自己未投过票,则回复同意,否则回复拒绝。如果大部分master node(N/2+1)都投票给了某个从节点,那么选举通过,那个从节点可以切换成master。

当slave 收到过半的master 同意时,会成为新的master。此时会以最新的Epoch 通过PONG 消息广播自己成为master,让Cluster 的其他节点尽快的更新拓扑结构(node.conf)。

变更通知:

  • 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己;
  • 新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
  • 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。

RedisCluster失效的判定:

  • 集群中半数以上的主节点都宕机(无法投票)

  • 宕机的主节点的从节点也宕机了(slot槽分配不连续)

人工故障切换

人工故障切换是预期的操作,而非发生了真正的故障,目的是以一种安全的方式(数据无丢失)将当前master节点和其中一个slave节点(执行cluster-failover的节点)交换角色

  • 向从节点发送cluster failover 命令(slaveof no one)
  • 从节点告知其主节点要进行手动切换(CLUSTERMSG_TYPE_MFSTART)
  • 主节点会阻塞所有客户端命令的执行(10s)
  • 从节点从主节点的ping包中获得主节点的复制偏移量
  • 从节点复制达到偏移量,发起选举、统计选票、赢得选举、升级为主节点并更新配置
  • 切换完成后,原主节点向所有客户端发送moved指令重定向到新的主节点

以上是在主节点在线情况下。如果主节点下线了,则采用cluster failover force或cluster failover takeover 进行强制切换

副本漂移

我们知道在一主一从的情况下,如果主从同时挂了,那整个集群就挂了。

为了避免这种情况我们可以做一主多从,但这样成本就增加了。

Redis提供了一种方法叫副本漂移,这种方法既能提高集群的可靠性又不用增加太多的从机。

截屏2023-09-12 11.43.52

Master1宕机,则Slaver11提升为新的Master1

集群检测到新的Master1是单点的(无从机)

集群从拥有最多的从机的节点组(Master3)中,选择节点名称字母顺序最小的从机(Slaver31)漂移到单点的主从节点组(Master1)。

具体流程如下(以上图为例)

  • 将Slaver31的从机记录从Master3中删除
  • 将Slaver31的的主机改为Master1
  • 在Master1中添加Slaver31为从节点
  • 将Slaver31的复制源改为Master1
  • 通过ping包将信息同步到集群的其他节点
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值