Redis (Remote Dictionary Server)远程字典服务器,是用C语言开发的一个开源的高性能键值 对( key-value )内存数据库。 它是一种 NoSQL 数据存储。
Redis中命令是忽略大小写,(set SET),key是不忽略大小写的 (NAME name)
redis应用场景:
- 缓存使用,减轻DB压力
- DB使用,用于临时存储数据(字典表,购买记录)
- 解决分布式场景下Session分离问题(登录信息)
- 任务队列(秒杀、抢红包等等) 乐观锁
- 应用排行榜 zset
- 签到 bitmap
- 分布式锁
- 冷热数据交换
key的类型是字符串。
- 用:分割用:分割
- 把表名转换为key前缀, 比如: user:
- 第二段放置主键值
- 第三段放置列名
比如:用户表user, 转换为redis的key-value存储
mysql:{userid:9,username:zhangf}
redis:username 的 key: user:9:zhangf
Redis的String能表达3种值的类型:字符串、整数、浮点数 100.01 是个六位的串
应用场景:
- 1、key和命令是字符串
- 2、普通的赋值
- 3、incr用于乐观锁 incr:递增数字,可用于实现乐观锁 watch(事务)
- 4、setnx用于分布式锁 当value不存在时采用赋值,可用于实现分布式锁
list列表类型可以存储有序、可重复的元素,获取头部或尾部附近的记录是极快的,list的元素个数最多为2^32-1个(40亿)
应用场景:
- 1、作为栈或队列使用 列表有序可以作为栈和队列使用
- 2、可用于各种列表,比如用户列表、商品列表、评论列表等。
无序、唯一元素,集合中最大的成员数为 2^32 - 1个(40亿)
应用场景:
- 1、适用于不能重复的且不需要顺序的数据结构 比如:关注的用户,还可以通过spop进行随机抽奖
元素本身是无序不重复的,每个元素关联一个分数(score) ,可按分数排序,分数可重复
应用场景:
- 1、由于可以按照分值排序,所以适用于各种排行榜。比如:点击排行榜、销量排行榜、关注排行榜等。
Redis hash 是一个 string 类型的 field 和 value 的映射表,它提供了字段和字段值的映射。每个 hash 可以存储 2^32 - 1 键值对(40多亿)。
应用场景:
- 1、对象的存储 ,表数据的映射
bitmap是进行位操作的,通过一个bit位来表示某个元素对应的值或者状态,其中的key就是对应元素本身。bitmap本身会极大的节省储存空间。
应用场景:
- 1、用户每月签到,用户id为key , 日期作为偏移量 1表示签到
- 2、统计活跃用户, 日期为key,用户id为偏移量 1表示活跃
- 3、查询用户在线状态, 日期为key,用户id为偏移量 1表示在线
geo是Redis用来处理位置信息的。在Redis3.2中正式使用。主要是利用了Z阶曲线、Base32编码和 geohash算法。
应用场景:
- 1、记录地理位置
- 2、计算距离
- 3、查找"附近的人"
Z阶曲线:
在x轴和y轴上将十进制数转化为二进制数,采用x轴和y轴对应的二进制数依次交叉后得到一个六位数编码。把数字从小到大依次连起来的曲线称为Z阶曲线,Z阶曲线是把多维转换成一维的一种方法。
stream是Redis5.0后新增的数据结构,用于可持久化的消息队列。几乎满足了消息队列具备的全部内容,包括:消息ID的序列化生成、 消息遍历、 消息的阻塞和非阻塞读取、 消息的分组消费、 未完成消息的处理、 消息队列监控。 每个Stream都有唯一的名称,它就是Redis的key,首次使用 xadd 指令追加消息时自动创建。
应用场景:
- 1、消息队列的使用
- Redis是内存数据库,宕机后数据会消失。
- Redis重启后快速恢复数据,要提供持久化机制
- Redis持久化是为了快速的恢复数据而不是为了存储数据
…注意:Redis持久化不保证数据的完整性。
当Redis用作DB时,DB数据要完整,所以一定要有一个完整的数据源(文件、mysql),在系统启动时,从这个完整的数据源中将数据load到Redis中
数据量较小,不易改变,比如:字典库(xml、Table)
Redis有RDB和AOF两种持久化方式:
RDB(Redis DataBase):是redis默认的存储方式,RDB方式是通过快照( snapshotting )完成的。只关注这一刻的数据,不关注过程。
AOF(append only file):是Redis的另一种持久化方式,默认情况下是不开启的。开启AOF持久化后,Redis 将所有对数据库进行过写入的命令(及其参数)(RESP)记录到 AOF 文件, 以此达到记录数据库状态的目的。
RDB(Redis DataBase),是redis默认的存储方式,RDB方式是通过快照( snapshotting )完成
的。只关注这一刻的数据,不关注过程。
触发快照的方式:
- 符合自定义配置的快照规则
- 执行save或者bgsave命令
- 执行flushall命令
- 执行主从复制操作 (第一次)
配置参数定期执行:
在redis.conf中配置:save 多少秒内 数据变了多少
save "" # 不使用RDB存储 不能主从
save 900 1 # 表示15分钟(900秒钟)内至少1个键被更改则进行快照。
save 300 10 # 表示5分钟(300秒)内至少10个键被更改则进行快照。
save 60 10000 # 表示1分钟内至少10000个键被更改则进行快照。
**漏斗设计 提供性能**
命令显式触发:
在客户端输入bgsave命令。
127.0.0.1:6379> bgsave
Background saving started
RDB执行流程(原理)
- Redis父进程首先判断:当前是否在执行save,或bgsave/bgrewriteaof(aof文件重写命令)的子
进程,如果在执行则bgsave命令直接返回。- 父进程执行fork(调用OS函数复制主进程)操作创建子进程,这个复制过程中父进程是阻塞的,Redis不能执行来自客户端的任何命令。
- 父进程fork后,bgsave命令返回”Background saving started”信息并不再阻塞父进程,并可以响应其他命令。
- 子进程创建RDB文件,根据父进程内存快照生成临时快照文件,完成后对原有文件进行原子换。(RDB始终完整)
- 子进程发送信号给父进程表示完成,父进程更新统计信息
- 父进程fork子进程后,继续工作。
RDB文件结构:
可以用winhex打开dump.rdb文件查看:
- 头部5字节固定为“REDIS”字符串
- 4字节“RDB”版本号(不是Redis版本号),当前为9,填充后为0009
- 辅助字段,以key-value的形式
- 存储数据库号码
- 字典大小
- 过期key
- 主要数据,以key-value的形式存储
- 结束标志
- 校验和,就是看文件是否损坏,或者是否被修改。
第3个辅助字段详情如下:
字段名 | 字段值 | 字段名 | 字段值 |
---|---|---|---|
redis-ver | 5.0.5 | aof-preamble | 是否开启aof |
redis-bits | 64/32 | aof-preamble | 主从复制 |
redis-bits | 当前时间戳 | aof-preamble | 主从复制 |
used-mem | 使用内存 | aof-preamble | 主从复制 |
RDB的优缺点
优点:
- RDB是二进制压缩文件,占用空间小,便于传输(传给slaver)
- 主进程fork子进程,可以最大化Redis性能,主进程不能太大,Redis的数据量不能太大,复制过程主进程阻塞
缺点:
- 不保证数据完整性,会丢失最后一次快照以后更改的所有数据
AOF(append only file)是Redis的另一种持久化方式。Redis默认情况下是不开启的。开启AOF持久化后Redis 将所有对数据库进行过写入的命令(及其参数)(RESP)记录到 AOF 文件, 以此达到记录数据库状态的目的,这样当Redis重启后只要按顺序回放这些命令就会恢复到原始状态了。
AOF会记录过程,RDB只管结果。
AOF持久化实现:
配置redis.conf文件
# 可以通过修改redis.conf配置文件中的appendonly参数开启
appendonly yes
# AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的。
dir ./
# 默认的文件名是appendonly.aof,可以通过appendfilename参数修改
appendfilename appendonly.aof
AOF实现原理
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)。
文件写入和保存:
每当服务器常规任务函数被执行、 或者事件处理器被执行时, aof.c/flushAppendOnlyFile 函数都会被调用, 这个函数执行以下两个工作:
- WRITE:根据条件,将 aof_buf 中的缓存写入到 AOF 文件。
- SAVE:根据条件,调用 fsync 或 fdatasync 函数,将 AOF 文件保存到磁盘中。
AOF 保存模式
Redis 目前支持三种 AOF 保存模式,它们分别是:
- AOF_FSYNC_NO :不保存。
- AOF_FSYNC_EVERYSEC:每一秒钟保存一次。(默认)
- AOF_FSYNC_ALWAYS :每执行一个命令保存一次。(不推荐)
不保存(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 | 阻塞 | 阻塞 | 操作系统最后一次对AOF文件触发SAVE操作之后的数据 |
AOF_FSYNC_EVERYSEC | 阻塞 | 不阻塞 | 一般情况下不超过2秒钟的数据 |
AOF_FSYNC_ALWAYS | 阻塞 | 阻塞 | 最多只丢失一个命令的数据 |
AOF重写
AOF记录数据的变化过程,越来越大,需要重写“瘦身”。
Redis可以在 AOF体积变得过大时,自动地在后台(Fork子进程)对 AOF进行重写。重写后的新 AOF文件包含了恢复当前数据集所需的最小命令集合。
所谓的“重写”其实是一个有歧义的词语, 实际上,AOF 重写并不需要对原有的 AOF 文件进行任何写入和读取, 它针对的是数据库中键的当前值。
例:
// 没有优化的:
set s1 11
set s1 22
set s1 33
// 优化后:
set s1 33
Redis 不希望 AOF 重写造成服务器无法处理请求, 所以 Redis 决定将 AOF 重写程序放到(后台)子进程里执行, 这样处理的最大好处是:
- 1、子进程进行 AOF 重写期间,主进程可以继续处理命令请求。
- 2、子进程带有主进程的数据副本,使用子进程而不是线程,可以在避免锁的情况下,保证数据的安全性。
不过, 使用子进程也有一个问题需要解决: 因为子进程在进行 AOF 重写期间, 主进程还需要继续处理命令, 而新的命令可能对现有的数据进行修改, 这会让当前数据库的数据和重写后的AOF 文件中的数据不一致。
为了解决这个问题, Redis 增加了一个 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 后台重写, 也即是 BGREWRITEAOF 命令(AOF重写)的工作原理。
触发方式
触发AOF重写的2种方式:
在redis.conf中进行触发配置
# 表示当前aof文件大小超过上一次aof文件大小的百分之多少的时候会进行重写。
# 如果之前没有重写过,以启动时aof文件大小为准
auto-aof-rewrite-percentage 100
# 限制允许重写最小aof文件大小,也就是文件大小小于64mb的时候,不需要进行优化
auto-aof-rewrite-min-size 64mb
执行bgrewriteaof命令
127.0.0.1:6379> bgrewriteaof
Background append only file rewriting started
混合持久化
RDB和AOF各有优缺点,Redis 4.0 开始支持 rdb 和 aof 的混合持久化。如果把混合持久化打开,aof rewrite 的时候就直接把 rdb 的内容写到 aof 文件开头。
即: RDB的头+AOF的身体---->appendonly.aof
开启混合持久化:
aof-use-rdb-preamble yes
我们可以看到该AOF文件是rdb文件的头和aof格式的内容,在加载时,首先会识别AOF文件是否以REDIS字符串开头,如果是就按RDB格式加载,加载完RDB后继续按AOF格式加载剩余部分。
AOF文件的载入与数据还原
因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。
Redis读取AOF文件并还原数据库状态的详细步骤如下:
- 创建一个不带网络连接的伪客户端(fake client):因为Redis的命令只能在客户端上下文中执行,而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端执行命令的效果完全一样。
- 从AOF文件中分析并读取出一条写命令
- 使用伪客户端执行被读出的写命令
- 一直执行步骤2和步骤3,直到AOF文件中的所有写命令都被处理完毕为止
RDB与AOF对比
- RDB存某个时刻的数据快照,采用二进制压缩存储,AOF存操作命令,采用文本存储(混合)
- RDB性能高、AOF性能较低
- RDB在配置触发状态会丢失最后一次快照以后更改的所有数据,AOF设置为每秒保存一次,则最多丢2秒的数据
- Redis以主服务器模式运行,RDB不会保存过期键值对数据,Redis以从服务器模式运行,RDB会保存过期键值对,当主服务器向从服务器同步时,再清空过期键值对。
应用场景
内存数据库: 推荐rdb+aof,数据不容易丢
有原始数据源: 每次启动时都从原始数据源中初始化 ,则 不用开启持久化 (数据量较小)
缓存服务器: rdb, 一般性能高
在数据还原时:
有rdb+aof 则还原aof,因为RDB会造成文件的丢失,AOF相对数据要完整。
只有rdb,则还原rdb
Redis作为Key-Value存储系统,数据结构如下:
Redis没有表的概念,Redis实例所对应的db以编号区分,db本身就是key的命名空间。
比如:user:1000作为key值,表示在user这个命名空间下id为1000的元素,类似于user表的id=1000的
行。
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;
id:id是数据库序号,为0-15(默认Redis有16个数据库)
dict:存储数据库所有的key-value,后面要详细讲解
expires:存储key的过期时间,后面要详细讲解
Value是一个对象。包含字符串对象,列表对象,哈希对象,集合对象和有序集合对象
typedef struct redisObject {
unsigned type:4;//类型 对象类型
unsigned encoding:4;//编码
void *ptr;//指向底层实现数据结构的指针
//...
int refcount;//引用计数
//...
unsigned lru:LRU_BITS; //LRU_BITS为24bit 记录最后一次被命令程序访问的时间
//...
}robj;
4位type
type 字段表示对象的类型,占 4 位。
常用的有:REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有序集合)。
当我们执行 type 命令时,便是通过读取 RedisObject 的 type 字段获得对象的类型:
127.0.0.1:6379> type a1
string
4位encoding
encoding 表示对象的内部编码,占 4 位。
每个对象有不同的实现编码。
Redis 可以根据不同的使用场景来为对象设置不同的编码,大大提高了 Redis 的灵活性和效率。
通过 object encoding 命令,可以查看对象采用的编码方式
127.0.0.1:6379> object encoding a1
"int"
24位LRU
lru 记录的是对象最后一次被命令程序访问的时间,( 4.0 版本占 24 位,2.6 版本占 22 位)。
高16位存储一个分钟数级别的时间戳,低8位存储访问计数(lfu : 最近访问次数)
lru----> 高16位: 最后被访问的时间
lfu----->低8位:最近访问次数
refcount
refcount 记录的是该对象被引用的次数,类型为整型。
refcount 的作用,主要在于对象的引用计数和内存回收。
当对象的refcount>1时,称为共享对象
Redis 为了节省内存,当有一些对象重复出现时,新的程序不会创建新的对象,而是仍然使用原来的对象。
ptr
ptr 指针指向具体的数据,比如:set hello world,ptr 指向包含字符串 world 的 SDS(简单动态字符串)。
字符串对象
C语言: 采用字符数组,以 “\0” 结尾 ,不能存储二进制数据
Redis 使用了 SDS(Simple Dynamic String),用于存储字符串和整型数据。
struct sdshdr{
//记录buf数组中已使用字节的数量
int len;
//记录 buf 数组中未使用字节的数量
int free;
//字符数组,用于保存字符串。buf[] 的长度=len+free+1
char buf[];
}
SDS的优势:
- SDS 在 C 字符串的基础上加入了 free 和 len 字段,获取字符串长度:SDS 是 O(1),C 字符串是O(n)。buf数组的长度=free+len+1。
- SDS 由于记录了长度,在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。
- 可以存取二进制数据,以字符串长度len来作为结束标识。
C: \0 空字符串 二进制数据包括空字符串,所以没有办法存取二进制数据
SDS :
非二进制: \0
二进制: 字符串长度 可以存二进制数据
使用场景:
SDS的主要应用在:存储字符串和整型数据、存储key、AOF缓冲区和用户输入缓冲。
跳跃表(重点)
跳跃表是有序集合(sorted-set)的底层实现,效率高,实现简单。
跳跃表的基本思想:将有序链表中的部分节点分层,每一层都是一个有序链表。
在查找时优先从最高层开始向后查找,当到达某个节点时,如果next节点值大于要查找的值或next指针指向null,则从当前节点下降一层继续向后查找。
例:
查找元素9,按道理我们需要从头结点开始遍历,一共遍历8个结点才能找到元素9。这种数据结构,就是跳跃表,它具有二分查找的功能。
上面例子中,9个结点,一共4层,是理想的跳跃表。
插入:
通过抛硬币(概率1/2)的方式来决定新插入结点跨越的层数:
正面:插入上层
背面:不插入
达到1/2概率(计算次数,抛多次,正面多的话就按插入,不以单次抛面来定)
删除:
找到指定元素并删除每层的该元素即可
跳跃表特点:
每层都是一个有序链表
查找次数近似于层数(1/2)
底层包含所有元素
空间复杂度 O(n) 扩充了一倍
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;
跳跃表的优势:
- 可以快速查找到需要的节点 O(logn)
- 可以在O(1)的时间复杂度下,快速获得跳跃表的头节点、尾结点、长度和高度。
应用场景:
有序集合的实现
字典(重点+难点)
字典dict又称散列表(hash),是用来存储键值对的一种数据结构。
Redis整个数据库是用字典来存储的。(K-V结构)
对Redis进行CURD操作其实就是对字典中的数据进行CURD操作。
压缩列表
压缩列表(ziplist)是由一系列特殊编码的连续内存块组成的顺序型数据结构。
节省内存
是一个字节数组,可以包含多个节点(entry)。每个节点可以保存一个字节数组或一个整数。
压缩列表的数据结构如下:
zlbytes:压缩列表的字节长度
zltail:压缩列表尾元素相对于压缩列表起始地址的偏移量
zllen:压缩列表的元素个数
entry1…entryX : 压缩列表的各个节点
zlend:压缩列表的结尾,占一个字节,恒为0xFF(255)
- previous_entry_length:前一个元素的字节长度
- encoding:表示当前元素的编码
- content:数据内容
应用场景:
- sorted-set和hash元素个数少且是小整数或短字符串(直接使用)
- list用快速链表(quicklist)数据结构存储,而快速链表是双向列表与压缩列表的组合。(间接使用)
整数集合
整数集合(intset)是一个有序的(整数升序)、存储整数的连续存储结构。
当Redis集合类型的元素都是整数并且都处在64位有符号整数范围内(2^64),使用该结构体存储。
127.0.0.1:6379> sadd set:001 1 3 5 6 2
(integer) 5
127.0.0.1:6379> object encoding set:001
"intset"
127.0.0.1:6379> sadd set:004 1 100000000000000000000000000 9999999999
(integer) 3
127.0.0.1:6379> object encoding set:004
"hashtable"
typedef struct intset{
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}intset;
应用场景:
- 可以保存类型为int16_t、int32_t 或者int64_t 的整数值,并且保证集合中不会出现重复元素。
快速列表(重点)
快速列表(quicklist)是Redis底层重要的数据结构。是列表的底层实现。
在Redis3.2之前,Redis采用双向链表(adlist)和压缩列表(ziplist)实现。
在Redis3.2以后,结合adlist和ziplist的优势,Redis设计出了quicklist。
127.0.0.1:6379> lpush list:001 1 2 5 4 3
(integer) 5
127.0.0.1:6379> object encoding list:001
"quicklist"
- 双向:链表具有前置节点和后置节点的引用,获取这两个节点时间复杂度都为O(1)。
- 普通链表(单链表):节点类保留下一节点的引用。链表类只保留头节点的引用,只能从头节点插入删除
- 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问都是以 NULL 结束。
环状:头的前一个节点指向尾节点- 带链表长度计数器:通过 len 属性获取链表长度的时间复杂度为 O(1)。
- 多态:链表节点使用 void* 指针来保存节点值,可以保存各种不同类型的值。
快速列表
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;
应用场景:
- 列表(List)的底层实现、发布与订阅、慢查询、监视器等功能。
流对象
stream主要由:消息、生产者、消费者和消费组构成。
Redis Stream的底层主要使用了listpack(紧凑列表)和Rax树(基数树)。
listpack(紧凑列表)
listpack表示一个字符串列表的序列化,listpack可用于存储字符串或整数。用于存储stream的消息内容。
其结构如下:
Rax树(基数树)
Rax 是一个有序字典树 (基数树 Radix Tree),按照 key 的字典序排列,支持快速地定位、插入和删除操作。
Rax 被用在 Redis Stream 结构里面用于存储消息队列,在 Stream 里面消息 ID 的前缀是时间戳 + 序号,这样的消息可以理解为时间序列消息。使用 Rax 结构 进行存储就可以快速地根据消息 ID 定位到具体的消息,然后继续遍历指定消息 之后的所有消息。
应用场景:
- stream的底层实现
encoding 表示对象的内部编码,占 4 位。
Redis通过 encoding 属性为对象设置不同的编码。
对于少的和小的数据,Redis采用小的和压缩的存储方式,体现Redis的灵活性大大提高了 Redis 的存储量和执行效率。
String
String的编码是int、raw、embstr。
int:REDIS_ENCODING_INT(int类型的整数)
127.0.0.1:6379> set n1 123
OK
127.0.0.1:6379> object encoding n1
"int"
embstr:REDIS_ENCODING_EMBSTR(编码的简单动态字符串),小字符串,长度小于44个字节
127.0.0.1:6379> set name:001 zhangfei
OK
127.0.0.1:6379> object encoding name:001
"embstr"
raw:REDIS_ENCODING_RAW (简单动态字符串),大字符串,长度大于44个字节
127.0.0.1:6379> set address:001 asdasdasdasdasdasdsadasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdas
OK
127.0.0.1:6379> object encoding address:001
"raw"
list
列表的编码是quicklist。
127.0.0.1:6379> lpush list:001 1 2 5 4 3
(integer) 5
127.0.0.1:6379> object encoding list:001
"quicklist"
hash
散列的编码是字典(dict)、压缩列表(ziplist)
字典(dict):REDIS_ENCODING_HT(字典),当散列表元素的个数比较多或元素不是小整数或短字符串时。
127.0.0.1:6379> hmset user:003 username11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 zhangfei password 111 num 2300000000000000000000000000000000000000000000000000
OK
127.0.0.1:6379> object encoding user:003
"hashtable"
压缩列表(ziplist):REDIS_ENCODING_ZIPLIST(压缩列表),当散列表元素的个数比较少,且元素都是小整数或短字符串时。
127.0.0.1:6379> hmset user:001 username zhangfei password 111 age 23 sex M
OK
127.0.0.1:6379> object encoding user:001
"ziplist"
set
集合的编码是整形集合(intset)、字典(dict)
整形集合(intset):REDIS_ENCODING_INTSET(整数集合),当Redis集合类型的元素都是整数并且都处在64位有符号整数范围内(<18446744073709551616)
127.0.0.1:6379> sadd set:001 1 3 5 6 2
(integer) 5
127.0.0.1:6379> object encoding set:001
"intset"
字典(dict):REDIS_ENCODING_HT(字典),当Redis集合类型的元素是非整数或都处在64位有符号整数范围外(>18446744073709551616)
127.0.0.1:6379> sadd set:004 1 100000000000000000000000000 9999999999
(integer) 3
127.0.0.1:6379> object encoding set:004
"hashtable"
zset
有序集合的编码是压缩列表(ziplist)、跳跃表+字典(skiplist + dict)
压缩列表(ziplist):REDIS_ENCODING_ZIPLIST(压缩列表),当元素的个数比较少,且元素都是小整数或短字符串时。
127.0.0.1:6379> zadd hit:1 100 item1 20 item2 45 item3
(integer) 3
127.0.0.1:6379> object encoding hit:1
"ziplist"
跳跃表+字典(skiplist + dict):REDIS_ENCODING_SKIPLIST(跳跃表+字典),当元素的个数比较多或元素不是小整数或短字符串时。
127.0.0.1:6379> zadd hit:2 100
item1111111111111111111111111111111111111111111111111111111111111111111111111111
1111111111111111111111111111111111 20 item2 45 item3
(integer) 3
127.0.0.1:6379> object encoding hit:2
"skiplist"
Redis性能高:
官方数据
读:110000次/s
写:81000次/s
长期使用,key会不断增加,Redis作为缓存使用,物理内存也会满
内存与硬盘交换(swap) 虚拟内存 ,频繁IO 性能急剧下降
maxmemory(最大内存) : 默认为0,也就是不限制内存的使用。
不设置的场景:
缓存淘汰策略:禁止驱逐 (默认)
- Redis的key是固定的,不会增加
- Redis作为DB使用,保证数据的完整性,不能淘汰 , 可以做集群,横向扩展
不设置maxmemory, 无最大内存限制, maxmemory-policy noeviction (禁止驱逐), 不淘汰
设置的场景:
- Redis是作为缓存使用,不断增加Key
redis作为缓存使用,不设置maxmemory会导致的问题:
- 达到物理内存后性能急剧下架,甚至崩溃
- 内存与硬盘交换(swap) 虚拟内存 ,频繁IO 性能急剧下降
maxmemory设置多大?
与业务有关
- 如果系统上只有一个redis实例,无其它应用,则留出 1 G内存供系统运行使用 ,剩下的就都可以设置给Redis ;或者设置系统内存的3/4。
- 如果系统里还有其它服务,则需要根据情况判断可以给redis设置多少最大内存
在redis.conf中设置maxmemory:
maxmemory 1024mb
命令: 获得maxmemory数:
CONFIG GET maxmemory
设置maxmemory后,当趋近maxmemory时,通过缓存淘汰策略,从内存中删除对象。
设置maxmemory, maxmemory-policy 要配置
在Redis中可以使用expire命令设置一个键的存活时间(ttl: time to live),过了这段时间,该键就会自动
被删除。
expire的使用:
expire命令的使用方法如下:
expire key ttl(单位秒)
127.0.0.1:6379> expire name 2 #2秒失效
(integer) 1
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> set name zhangfei
OK
127.0.0.1:6379> ttl name #永久有效
(integer) -1
127.0.0.1:6379> expire name 30 #30秒失效
(integer) 1
127.0.0.1:6379> ttl name #还有24秒失效
(integer) 24
127.0.0.1:6379> ttl name #失效
(integer) -2
expire原理:
typedef struct redisDb {
dict *dict; -- key Value
dict *expires; -- key ttl
dict *blocking_keys;
dict *ready_keys;
dict *watched_keys;
int id;
} redisDb;
上面的代码是Redis 中关于数据库的结构体定义,这个结构体定义中除了 id 以外都是指向字典的指针,其中我们只看 dict 和 expires。
dict: 用来维护一个 Redis 数据库中包含的所有 Key-Value 键值对;
expires: 用于维护一个 Redis 数据库中设置了失效时间的键(即key与失效时间的映射)。
- 当我们使用 expire命令设置一个key的失效时间时,Redis 首先到 dict 这个字典表中查找要设置的key是否存在,如果存在就将这个key和失效时间添加到 expires 这个字典表。
- 当我们使用 setex命令向系统插入数据时,Redis 首先将 Key 和 Value 添加到 dict 这个字典表中,然后将 Key 和失效时间添加到 expires 这个字典表中。
简单地总结来说就是,设置了失效时间的key和具体的失效时间全部都维护在 expires 这个字典表中。
Redis的数据删除有定时删除、惰性删除和主动删除三种方式。
Redis默认采用的是:no-enviction(不删除)
Redis目前推荐采用:惰性删除+主动删除的方式。
在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。
需要创建定时器,而且消耗CPU,一般不推荐使用。
在key被访问时如果发现它已经失效,那么就删除它。
调用expireIfNeeded函数,该函数的意义是:读取数据之前先检查一下它有没有失效,如果失效了就删除它。
在redis.conf文件中配置主动删除策略,默认是no-enviction(不删除)
maxmemory-policy allkeys-lru
LRU (Least recently used) 最近最少使用,算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。
最常见的实现是使用一个链表保存缓存数据,详细算法实现如下:
- 新数据插入到链表头部;
- 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;
- 当链表满的时候,将链表尾部的数据丢弃。
- 在Java中可以使用LinkHashMap(哈希链表)去实现LRU
在服务器配置中保存了 lru 计数器 server.lrulock,会定时(redis 定时程序 serverCorn())更新,server.lrulock 的值是根据 server.unixtime 计算出来的。
另外,从 struct redisObject 中可以发现,每一个 redis 对象都会设置相应的 lru。可以想象的是,每一
次访问数据的时候,会更新 redisObject.lru。
Redis的LRU 数据淘汰机制是这样的:
在数据集中随机挑选几个键值对,取出其中 lru 最大的键值对淘汰。
不可能遍历所有key。
用当前时间-最近访问 越大 说明 访问间隔时间越长
随机挑选的数据集可以分为以下2种情况:
volatile-lru
:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰allkeys-lru
:从所有数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
LFU (Least frequently used) 最不经常使用,如果一个数据在最近一段时间内使用次数很少,那么在将
来一段时间内被使用的可能性也很小。
可以分为以下2种情况:
volatile-lfu
:从已设置过期时间的数据集(server.db[i].expires)中挑选最近使用次数最少的数据淘汰allkeys-lfu
:从所有数据集(server.db[i].dict)中挑选最近使用次数最少的数据淘汰
随机淘汰
随机挑选的数据集可以分为以下2种情况:
volatile-random
:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰allkeys-random
:从所有数据集(server.db[i].dict)中任意选择数据淘汰
redis 数据集数据结构中保存了键值对过期时间的表,即 redisDb.expires。
TTL 数据淘汰机制:从过期时间的表中随机挑选几个键值对,取出其中 ttl 最小的键值对淘汰。
volatile-ttl
: 从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
noenviction,禁止驱逐数据,不删除 默认
缓存淘汰策略如何选择?
- allkeys-lru : 在不确定时一般采用策略。 冷热数据交换
- volatile-lru : 比allkeys-lru性能差,需要存 : 过期时间
- allkeys-random : 希望请求符合平均分布(每个元素以相同的概率被访问)
- volatile-ttl:自己控制,将想要删除的缓存数据过期时间设置小一点,就可以尽快删除
解决缓存穿透问题(查询缓存不存在时,设置一个空值的key,这时就需要把这个空值的key过期时间设置小一点,以便有数据的时候可以及时同步)- 禁止驱逐: 用作DB 不设置maxmemory
缓存的设计要分多个层次,在不同的层次上选择不同的缓存,包括JVM缓存、文件缓存和Redis缓存。
JVM缓存
JVM缓存就是本地缓存,设计在应用服务器中(tomcat)。
通常可以采用Ehcache和Guava Cache,在互联网应用中,由于要处理高并发,通常选择Guava Cache。
适用本地(JVM)缓存的场景:
- 对性能有非常高的要求。
- 不经常变化
- 占用内存不大
- 有访问整个集合的需求
- 数据允许不时时一致
文件缓存
这里的文件缓存是基于http协议的文件缓存,一般放在nginx中。
因为静态文件(比如css,js, 图片)中,很多都是不经常更新的。nginx使用proxy_cache将用户的请求缓存到本地一个目录。下一个相同请求可以直接调取缓存文件,就不用去请求服务器了。
server {
listen 80 default_server;
server_name localhost;
root /mnt/blog/;
location / {
}
#要缓存文件的后缀,可以在以下设置。
location ~ .*\.(gif|jpg|png|css|js)(.*) {
proxy_pass http://ip地址:90;
proxy_redirect off;
proxy_set_header Host $host;
proxy_cache cache_one;
proxy_cache_valid 200 302 24h;
proxy_cache_valid 301 30d;
proxy_cache_valid any 5m;
expires 90d;
add_header wall "hello.";
}
}
Redis缓存
应用场景:
- 分布式缓存,采用主从+哨兵或RedisCluster的方式缓存数据库的数据。
- 在实际开发中,作为数据库使用,数据要完整
- 作为缓存使用
- 作为Mybatis的二级缓存使用
官方说Redis单例能处理key:2.5亿个
一个key或是value大小最大是512M
读写峰值:
Redis采用的是基于内存的采用的是单进程单线程模型的 KV 数据库,由C语言编写,官方提供的数据是可以达到110000+的QPS(每秒内查询次数)。80000的写
一般的缓存系统,都是按照key去缓存查询,如果不存在对应的value,就应该去后端系统查找(比如DB)。
缓存穿透是指在高并发下查询key不存在的数据(不存在的key),会穿过缓存查询数据库。导致数据库压力过大而宕机。
解决方案:
布隆过滤器:
布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列机hash映射函数。
布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法。
布隆过滤器原理:
当一个元素被加入集合时,通过K个不同的Hash函数将这个元素映射成一个数组中的K个点,把它们置为1。检索时,我们只要再通过这K个Hash函数对key进行映射,看看这些点是不是都是1就(大约)知道集合中有没有它了:
- 如果这些点有任何一个0,则被检索元素一定不在;
- 如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。
优点:
- 把字符串转化成位,结果只能是(1或0), 省空间
- 不用循环------>比较位置 省时间
对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热
点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题。这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。
缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
解决方案:
- 用分布式锁控制访问的线程
使用redis的setnx互斥锁先进行判断,这样其他线程就处于等待状态,保证不会有大并发操作去操作数据库。- 不设超时时间,volatile-lru 但会造成写一致问题
当数据库数据发生更新时,缓存中的数据不会及时更新,这样会造成数据库中的数据与缓存中的数据的不一致,应用会从缓存中读取到脏数据。可采用延时双删策略处理,这个我们后面会详细讲到。
当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如
DB)带来很大压力。
突然间大量的key失效了或redis重启,大量访问数据库,数据库崩溃
解决方案:
- key的失效期分散开, 不同的key设置不同的有效期
- 设置二级缓存(数据不一定一致)
- 高可用(会造成脏读)
缓存预热就是系统启动前,提前将相关的缓存数据直接加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询实现被预热的缓存数据。
加载缓存思路:
- 数据量不大,可以在项目启动的时候自动进行加载
- 利用定时任务刷新缓存,将数据库的数据刷新到缓存中
一般情况下,是在高并发下会产生数据不一致问题
数据不一致在redis里有2种体现:
解决方案:
强一致性很难,追求最终一致性(时间)
该逻辑适用业务(互联网业务数据处理的特点):
- 高吞吐量
- 低延迟
- 数据敏感性低于金融业
做时序控制是否可解决数据不一致问题,即是否可以先更新数据库再更新缓存或者先更新缓存再更新数据库?或者可不可以在确定数据更新到DB成功后再去更新缓存?
- 这个时序性没有办法控制,无法获取DB到底成不成功,不知道在提交了commit命令后有没有其它异常
- 本质上不是一个原子操作,所以时序控制不可行
保证数据的最终一致性(延时双删):
- 延时双删:
a. 先更新数据库同时删除缓存项(key),等读的时候再填充缓存
b. 2秒后再删除一次缓存项(key)- 设置缓存过期时间 Expired Time, 比如 10秒 或1小时
(一般情况下延时双删都能解决问题,如果不能解决,可以通过设置过期时间解决,过期自动失效)- 将缓存删除失败记录到日志中,利用脚本提取失败记录再次删除(缓存失效期过长 7*24)
(此种情况基本上99.9%不会出现)
升级方案:
通过数据库的binlog来异步淘汰key,利用工具(canal)将binlog日志采集发送到MQ中,然后通过ACK机制确认处理删除缓存。
缓存更新策略:
不同策略之间的优缺点:
策略 | 一致性 | 维护成本 |
---|---|---|
利用Redis的缓存淘汰策略被动更新 | 最差 | 最低 |
利用TTL被动更新 | 较差 | 较低 |
在更新数据库时主动更新 | 较强 | 最高 |
这里的并发指的是多个redis的client同时set 同一个key引起的并发问题。
多客户端(Jedis)同时并发写一个key,一个key的值是1,本来按顺序修改为2,3,4,最后是4,但是顺序变成了4,3,2,最后变成了2。
整体技术方案
这种情况,主要是准备一个分布式锁,大家去抢锁,抢到锁就做set操作。
加锁的目的实际上就是把并行读写改成串行读写的方式,从而来避免资源竞争。
- 我们希望的kev更新顺序是v1->v2->v3->v4;
- 由于并发问题,key更新的顺序变成了v1->v3->v4->v2;
- 引入分布式锁zookeeper,客户端要更新数据必须也要获得锁;
- 加入set mykey v2这条指令先获得了锁,那么这是数据就会变成2;
- 接下来set mykey v4这条指令获取到了锁,数据变成了v4;
- 然后set mykey v3获得了锁,难道数据要被改成v3了吗?这样数据不就又错了了?
所以数据必须带有时间戳,当v3想要去更新时,就比较自己的时间戳和v4的时间戳谁更早,如果自己更早放弃更新,否则覆盖v4。
理想顺序:v1->v2->v3->v4
混乱顺序: v1->v3->v4->v2
Redis分布式锁的实现
主要用到的redis函数是setnx(),用SETNX实现分布式锁
由于上面举的例子,要求key的操作需要顺序执行,所以需要保存一个时间戳判断set顺序。
系统A key 1 {ValueA 7:00}
系统B key 1 { ValueB 7:05}
假设系统B先抢到锁,将key1设置为{ValueB 7:05}。接下来系统A抢到锁,发现自己的key1的时间戳早于缓存中的时间戳(7:00<7:05),那就不做set操作了。
在并发量过大的情况下,可以通过消息中间件进行处理,把并行读写进行串行化。
把Redis的set操作放在队列中使其串行化,必须的一个一个执行。
当有大量的请求(几十万)访问某个Redis某个key时,由于流量集中达到网络上限,从而导致这个redis的服务器宕机。造成缓存击穿,接下来对这个key的访问将直接访问数据库造成数据库崩溃,或者访问数据库回填Redis再访问Redis,继续崩溃。
如何发现热key?
- 预估热key,比如秒杀的商品、火爆的新闻等
- 在客户端进行统计,实现简单,加一行代码即可
- 如果是Proxy,比如Codis,可以在Proxy端收集
- 利用Redis自带的命令,monitor、hotkeys。但是执行缓慢(不要用)
- 利用基于大数据领域的流式计算技术来进行实时数据访问次数的统计,比如 Storm、Spark
Streaming、Flink,这些技术都是可以的。发现热点数据后可以写到zookeeper中
- 变分布式缓存为本地缓存
发现热key后,把缓存数据取出后,直接加载到本地缓存中。可以采用Ehcache、Guava Cache都可以,这样系统在访问热key数据时就可以直接访问自己的缓存了。(数据不要求时时一致)- 在每个Redis主节点上备份热key数据,这样在读取时可以采用随机读取的方式,将访问压力负到每个Redis上。
- 利用对热点数据访问的限流熔断保护措施
每个系统实例每秒最多请求缓存集群读操作不超过 400 次,一超过就可以熔断掉,不让请求缓存集群,直接返回一个空白信息,然后用户稍后会自行再次重新刷新页面之类的。(首页不行,系统友好性差)
通过系统层自己直接加限流熔断保护措施,可以很好的保护后面的缓存集群。
大key指的是存储的值(Value)非常大
大key常见场景:
大key的影响:
如何发现大key:
大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 中存储过多的元素,可以将这些元素分拆。(常见)
以hash类型举例来说,对于field过多的场景,可以根据field进行hash取模,生成一个新的key,例如
原来的
hash_key:{filed1:value, filed2:value, filed3:value ...},可以hash取模后形成如下
key:value形式
hash_key:1:{filed1:value}
hash_key:2:{filed2:value}
hash_key:3:{filed3:value}
...
取模后,将原先单个key分成多个key,每个key filed个数为原先的1/N
删除大key时不要使用del,因为del是阻塞命令,删除时会影响性能。
使用 lazy delete (unlink命令)
删除指定的key(s),若key不存在则该key被跳过。但是,相比DEL会产生阻塞,该命令会在另一个线程中回收内存,因此它是非阻塞的。 这也是该命令名字的由来:仅将keys从key空间中删除,真正的数据删除会在后续异步操作。
redis> SET key1 "Hello"
"OK"
redis> SET key2 "World"
"OK"
redis> UNLINK key1 key2 key3
(integer) 2
分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。如果不同的系统或同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性。
分布式锁特性:
- 互斥性
任意时刻,只能有一个客户端获取锁,不能同时有两个客户端获取到锁。- 同一性
锁只能被持有该锁的客户端删除,不能由其它客户端删除。- 可重入性
持有某个锁的客户端可继续对该锁加锁,实现锁的续租- 容错性
锁失效后(超过生命周期)自动释放锁(key失效),其他客户端可以继续获得该锁,防止死锁
利用Watch实现Redis乐观锁。
乐观锁基于CAS(Compare And Swap)思想(比较并替换),是不具有互斥性,不会产生锁等待而消耗资源,但是需要反复的重试,但也是因为重试的机制,能比较快的响应。因此我们可以利用redis来实现乐观锁。
实现思路:
- 利用redis的watch功能,监控这个redisKey的状态值
- 获取redisKey的值
- 创建redis事务
- 给这个key的值+1
- 然后去执行这个事务,如果key的值被修改过则回滚,key不加1
共享资源互斥,共享资源串行化,单应用中使用锁:(单进程多线程)
Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。
Redisson在基于NIO的Netty框架上,生产环境使用分布式锁。
Redisson分布式锁的实现原理:
加锁机制:
如果该客户端面对的是一个redis cluster集群,他首先会根据hash节点选择一台机器。
“高可用性”(High Availability)通常来描述一个系统经过专门的设计,从而减少停工时间,而保持其服
务的高度可用性。CAP的A AP模型。
单机的Redis是无法保证高可用性的,当Redis服务器宕机后,即使在有持久化的机制下也无法保证不丢失数据。
所以我们采用Redis多机和集群的方式来保证Redis的高可用性。
单进程+单线程 + 多机 (集群)
Redis支持主从复制功能,可以通过执行slaveof(Redis5以后改成replicaof),或者在配置文件中设置
slaveof(Redis5以后改成replicaof)来开启复制功能。
主从复制类型:
主对外从对内,主可读可写,从可读不可写
主挂了,从不可为主
主redis配置
无需特殊配置
从redis配置
修改从服务器上的 redis.conf 文件:
# slaveof
# 表示当前【从服务器】对应的【主服务器】的IP是192.168.10.135,端口是6379。
replicaof 127.0.0.1 6379
保存主节点信息
当客户端向从服务器发送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命令:
1、检测socket的读写状态
2、检测Master能否正常处理
Master的响应:
1、发送“pong” , 说明正常
2、返回错误,说明Master不正常
3、timeout,说明网络超时
权限验证
主从正常连接后,进行权限验证:
发送端口信息
在身份验证步骤之后,从服务器将执行命令REPLCONF listening-port ,向主服务器发送从服务器的监听端口号。
同步数据
Redis 2.8之后分为全量同步和增量同步,具体的后面详细讲解。
命令传播
当同步数据完成后,主从服务器就会进入命令传播阶段,主服务器只要将自己执行的写命令发送给从服务器,而从服务器只要一直执行并接收主服务器发来的写命令。
Redis 2.8以前使用SYNC命令同步复制。
Redis 2.8之后采用PSYNC命令替代SYNC。
旧版本(Redis 2.8以前)
实现方式:Redis的同步功能分为同步(sync)和命令传播(command propagate)。
1). 同步操作:
- 通过从服务器发送到SYNC命令给主服务器
- 主服务器生成RDB文件并发送给从服务器,同时发送保存所有写命令给从服务器
- 从服务器清空之前数据并执行解释RDB文件
- 保持数据一致(还需要命令传播过程才能保持一致)
2). 命令传播操作:
- 同步操作完成后,主服务器执行写命令,该命令发送给从服务器并执行,使主从保存一致。
缺陷:
- 没有全量同步和增量同步的概念,从服务器在同步时,会清空所有数据。
- 主从服务器断线后重复制,主服务器会重新生成RDB文件和重新记录缓冲区的所有命令,并全量同步到从服务器上。
新版本(Redis 2.8之后)
实现方式:在Redis 2.8之后使用PSYNC命令,具备完整重同步和部分重同步模式。
同步方式:
1). 全量同步:Redis 的全量同步过程主要分三个阶段:
- 同步快照阶段: Master 创建并发送快照RDB给 Slave , Slave 载入并解析快照。 Master 同时将此阶段所产生的新的写命令存储到缓冲区。
- 同步写缓冲阶段: Master 向 Slave 同步存储在缓冲区的写操作命令。
- 同步增量阶段: Master 向 Slave 同步写操作命令
2). 增量同步:
- Redis增量同步主要指Slave完成初始化后开始正常工作时, Master 发生的写操作同步到 Slave 的过程。
- 通常情况下, Master 每执行一个写命令就会向 Slave 发送相同的写命令,然后 Slave 接收并执行。
心跳检测
在命令传播阶段,从服务器默认会以每秒一次的频率向主服务器发送命令:
replconf ack <replication_offset>
#ack :应答
#replication_offset:从服务器当前的复制偏移量
主要作用有3个:
- 检测主从的连接状态
检测主从服务器的网络连接状态。
通过向主服务器发送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秒时,主服务器将拒绝执行写命令。这里的延迟值就是上面INFOreplication命令的lag值。- 检测命令丢失
如果因为网络故障,主服务器传播给从服务器的写命令在半路丢失,那么当从服务器向主服务器发
送REPLCONF ACK命令时,主服务器将发觉从服务器当前的复制偏移量少于自己的复制偏移量,然后主服务器就会根据从服务器提交的复制偏移量,在复制积压缓冲区里面找到从服务器缺少的数据,并将这些数据重新发送给从服务器。(补发) 网络不断。
增量同步:网断了,再次连接时
哨兵(sentinel)是Redis的高可用性(High Availability)的解决方案:
在一台机器上采用伪分布式的方式部署。(生产环境应该是多台机器)
启动并初始化Sentinel
Sentinel是一个特殊的Redis服务器,不会进行持久化。
Sentinel实例启动后,每个Sentinel会创建2个连向主服务器的网络连接:
获取主服务器信息
Sentinel默认每10s一次,向被监控的主服务器发送info命令,获取主服务器和其下属从服务器的信息。
127.0.0.1:6379> info
# Server
redis_version:5.0.5
os:Linux 3.10.0-229.el7.x86_64 x86_64
run_id:a4e06ab61b4116660aa37b85079ed482b0b695b1
# Replication
role:master
connected_slaves:2
slave0:ip=127.0.0.1,port=6380,state=online,offset=1571684,lag=1
slave1:ip=127.0.0.1,port=6381,state=online,offset=1571551,lag=1
master_replid:366322125dd7dc9bc95ed3467cfec841c112e207
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:1571684
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:523109
repl_backlog_histlen:1048576
获取从服务器信息
当Sentinel发现主服务器有新的从服务器出现时,Sentinel还会向从服务器建立命令连接和订阅连接。
在命令连接建立之后,Sentinel还是默认10s一次,向从服务器发送info命令,并记录从服务器的息。
127.0.0.1:6380> info
# Server
redis_version:5.0.5
os:Linux 3.10.0-229.el7.x86_64 x86_64
run_id:e289b3286352aaf8cc9f1ac7ebcc6d36131b8321
# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up
master_last_io_seconds_ago:0
master_sync_in_progress:0
slave_repl_offset:1699595
slave_priority:100
slave_read_only:1
connected_slaves:0
master_replid:366322125dd7dc9bc95ed3467cfec841c112e207
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:1699595
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:651020
repl_backlog_histlen:1048576
向主服务器和从服务器发送消息(以订阅的方式)
默认情况下,Sentinel每2s一次,向所有被监视的主服务器和从服务器所订阅的—sentinel—:hello频道上发送消息,消息中会携带Sentinel自身的信息和主服务器的信息。
PUBLISH _sentinel_:hello "< s_ip > < s_port >< s_runid >< s_epoch > < m_name > <
m_ip >< m_port >"
接收来自主服务器和从服务器的频道信息
当Sentinel与主服务器或者从服务器建立起订阅连接之后,Sentinel就会通过订阅连接,向服务器发送以下命令:
subscribe —sentinel—:hello
Sentinel彼此之间只创建命令连接,而不创建订阅连接
,因为Sentinel通过订阅主服务器或从服务器,就可以感知到新的Sentinel的加入,而一旦新Sentinel加入后,相互感知的Sentinel通过命令连接来通信就可以了。
检测主观下线状态
Sentinel每秒一次向所有与它建立了命令连接的实例(主服务器、从服务器和其他Sentinel)发送PING命令。
Sentinel就会认为该实例主观下线(SDown)
检查客观下线状态
当一个Sentinel将一个主服务器判断为主观下线后,Sentinel会向同时监控这个主服务器的所有其他Sentinel发送查询命令
主机的命令:
SENTINEL is-master-down-by-addr
其他Sentinel回复:
< leader_runid >< leader_epoch >
判断它们(其它Sentinel)是否也认为主服务器下线。
如果达到Sentinel配置中的quorum数量的Sentinel实例都判断主服务器为主观下线,则该主服务器就会被判定为客观下线(ODown)。
选举Leader Sentinel
当一个主服务器被判定为客观下线后,监视这个主服务器的所有Sentinel会通过选举算法(raft),选出一个Leader Sentinel去执行failover(故障转移)操作。
Raft:
Raft协议是用来解决分布式系统一致性问题的协议。
Raft协议描述的节点共有三种状态:Leader, Follower, Candidate。
term:Raft协议将时间切分为一个个的Term(任期),可以认为是一种“逻辑时间”。
选举流程:
Raft采用心跳机制触发Leader选举。
系统启动后,全部节点初始化为Follower,term为0。
节点如果收到了RequestVote或者AppendEntries,就会保持自己的Follower身份,
节点如果一段时间内没收到AppendEntries消息,在该节点的超时时间内还没发现Leader,Follower就会转换成Candidate,自己开始竞选Leader。
一旦转化为Candidate,该节点立即开始下面几件事情:
如果在计时器超时前,节点收到多数节点的同意投票,就转换成Leader。同时向所有其他节点发送AppendEntries,告知自己成为了Leader。
每个节点在一个term内只能投一票,采取先到先得的策略,Candidate前面说到已经投给了自己,
Follower会投给第一个收到RequestVote的节点。
Raft协议的定时器采取随机超时时间,这是选举Leader的关键。
在同一个term内,先转为Candidate的节点会先发起投票,从而获得多数票。
Sentinel的leader选举流程
故障转移
当选举出Leader Sentinel后,Leader Sentinel会对下线的主服务器执行故障转移操作,主要有三个步骤:
Master 和 Slave 服务器切换后, Master 的 redis.conf 、 Slave 的 redis.conf 和sentinel.conf 的配置文件的内容都会发生相应的改变,即, Master 主服务器的 redis.conf配置文件中会多一行 replicaof 的配置, sentinel.conf 的监控目标会随之调换
。主服务器的选择
哨兵leader根据以下规则从客观下线的主服务器的从服务器中选择出新的主服务器。
分区是将数据分布在多个Redis实例(Redis主机)上,以至于每个实例只包含一部分数据。
根据分区键(id)进行分区。
根据id数字的范围比如1–10000、100001–20000…90001-100000,每个范围分到不同的Redis实例中。
id范围 | Redis实例 |
---|---|
1–10000 | Redis01 |
100001–20000 | Redis02 |
… | |
90001-100000 | Redis10 |
优点:
- 实现简单,方便迁移和扩展
缺点:
- 热点数据分布不均,性能损失
适用场景:
- id是数字,能排序
(非数字型key,比如uuid无法使用(可采用雪花算法替代))- 主键用 雪花算法,都是数字
利用简单的hash算法即可:
Redis实例 = hash(key)%N
key:要进行分区的键,比如user_id
N:Redis实例个数(Redis主机)
优点:
- 支持任何类型的key
- 热点分布较均匀,性能较好
缺陷:
- 迁移复杂,需要重新计算,扩展较差(利用一致性hash环)
对于一个给定的key,客户端直接选择正确的节点来进行读写。许多Redis客户端都实现了客户端分区(JedisPool),也可以自行编程实现。
hash
普通hash,hash(key)%N。
hash:可以采用hash算法,比如CRC32、CRC16等
N:是Redis主机个数
比如:
user_id : u001
hash(u001) : 1844213068
Redis实例 = 1844213068%3
余数为2,所以选择Redis3
普通Hash的优势:
- 实现简单,热点数据分布均匀
普通Hash的缺陷:
- 节点数固定,扩展的话需要重新计算
- 查询时必须用分片的key来查,一旦key改变,数据就查不出了,所以要使用不易改变的key进行分片
一致性hash
普通hash是对主机数量取模,而一致性hash是对2^32 (4 294 967 296)取模。我们把2^32 想象成一个圆,就像钟表一样,钟表的圆可以理解成由60个点组成的圆,而此处我们把这个圆想象成由2^32个点组成的圆,示意图如下:
图 | 介绍 |
---|---|
圆环的正上方的点代表0,0点右侧的第一个点代表1,以此类推,2、3、4、5、6……直到2^32-1 , 也就是说0点左侧的第一个点代表2^32-1 。我们把这个由2的32次方个点组成的圆环称为hash环 。 |
假设我们有3台缓存服务器,服务器A、服务器B、服务器C,那么,在生产环境中,这三台服务器肯定有自己的IP地址,我们使用它们各自的IP地址进行哈希计算,使用哈希后的结果对2^32取模,可以使用如下公式:
hash(服务器的IP地址) % 2^32
hash环偏移:
优点:
- 添加或移除节点时,数据只需要做部分的迁移,比如上图中把C服务器移除,则数据4迁移到服务器A中,而其他的数据保持不变。添加效果是一样的。
缺点:
- 复杂度高
客户端需要自己处理数据路由、高可用、故障转移等问题使用分区,数据的处理会变得复杂,不得不对付多个redis数据库和AOF文件,不得在多个实例和主机之间持久化你的数据。- 不易扩展
一旦节点的增或者删操作,都会导致key无法在redis中命中,必须重新根据节点计算,并手动迁移全部或部分数据。
Redis3.0之后,Redis官方提供了完整的集群解决方案。
方案采用去中心化的方式,包括:sharding(分区)、replication(复制)、failover(故障转移)。称为RedisCluster。
Redis5.0前采用redis-trib进行集群的创建和管理,需要ruby支持。
Redis5.0可以直接使用Redis-cli进行集群的创建和管理。
去中心化
RedisCluster由多个Redis节点组构成,是一个P2P无中心节点的集群架构,依靠Gossip协议传播的集群。
Gossip协议:
Gossip协议是一个通信协议,一种传播消息的方式。
起源于:病毒传播
Gossip协议基本思想:
一个节点周期性(每秒)随机选择一些节点,并把信息传递给这些节点。
这些收到信息的节点接下来会做同样的事情,即把这些信息传递给其他一些随机选择的节点。
信息会周期性的传递给N个目标节点。这个N被称为fanout(扇出)
gossip协议包含多种消息: 包括meet、ping、pong、fail、publish等等
命令 | 说明 |
---|---|
meet | sender向receiver发出,请求receiver加入sender的集群 |
ping | 节点检测其他节点是否在线 |
pong | receiver收到meet或ping后的回复信息;在failover后,新的Master也会广播pong |
fail | 节点A判断节点B下线后,A节点广播B的fail信息,其他收到节点会将B节点标记为下线 |
publish | 节点A收到publish命令,节点A执行该命令,并向集群广播publish命令,收到publish命令的节点都会执行相同的publish命令 |
通过gossip协议,cluster可以提供集群间状态同步更新、选举自助failover等重要的集群功能。
slot
redis-cluster把所有的物理节点映射到[0-16383]个slot上,基本上采用平均分配和连续分配的方式。
比如上图中有5个主节点,这样在RedisCluster创建时,slot槽可按下表分配:
节点名称 | slot范围 |
---|---|
Redis1 | 0-3270 |
Redis2 | 3271-6542 |
Redis3 | 6543-9814 |
Redis4 | 9815-13087 |
Redis5 | 13088-16383 |
cluster 负责维护节点和slot槽的对应关系 value------>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的优势
不同节点分组服务于相互无交集的分片(sharding),Redis Cluster 不存在单独的proxy或配置服务器,所以需要将客户端路由到目标的分片。
客户端路由
Redis Cluster的客户端相比单机Redis 需要具备路由语义的识别能力,且具备一定的路由缓存能力。
moved重定向
ask重定向
在对集群进行扩容和缩容时,需要对槽及槽中数据进行迁移。
当客户端向某个节点发送命令,节点向客户端返回moved异常,告诉客户端数据对应的槽的节点信息。
如果此时正在进行集群扩展或者缩空操作,当客户端向正确的节点发送命令时,槽及槽中数据已经被迁移到别的节点了,就会返回ask,这就是ask重定向机制。
moved和ask的区别
:
- moved:槽已确认转移
- ask:槽还在转移过程中
Smart智能客户端:JedisCluster
- 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!"
迁移
在RedisCluster中每个slot 对应的节点在初始化后就是确定的。在某些情况下,节点和分片需要变更:
此时需要进行分片的迁移,迁移的触发和过程控制由外部系统完成。包含下面 2 种:
迁移步骤:
故障检测
从节点选举
RedisCluster失效的判定:
1. 集群中半数以上的主节点都宕机(无法投票)
2. 宕机的主节点的从节点也宕机了(slot槽分配不连续)
变更通知
当slave 收到过半的master 同意时,会成为新的master。此时会以最新的Epoch 通过PONG 消息广播自己成为master,让Cluster 的其他节点尽快的更新拓扑结构(node.conf)。
主从切换
自动切换:
就是上面讲的从节点选举
手动切换:
人工故障切换是预期的操作,而非发生了真正的故障,目的是以一种安全的方式(数据无丢失)将当前master节点和其中一个slave节点(执行cluster-failover的节点)交换角色
- 向从节点发送cluster failover 命令(slaveof no one)
- 从节点告知其主节点要进行手动切换(CLUSTERMSG_TYPE_MFSTART)
- 主节点会阻塞所有客户端命令的执行(10s)
- 从节点从主节点的ping包中获得主节点的复制偏移量
- 从节点复制达到偏移量,发起选举、统计选票、赢得选举、升级为主节点并更新配置
- 切换完成后,原主节点向所有客户端发送moved指令重定向到新的主节点
以上是在主节点在线情况下。
如果主节点下线了,则采用cluster failover force或cluster failover takeover 进行强制切换。
副本漂移
我们知道在一主一从的情况下,如果主从同时挂了,那整个集群就挂了。
为了避免这种情况我们可以做一主多从,但这样成本就增加了。
Redis提供了一种方法叫副本漂移,这种方法既能提高集群的可靠性又不用增加太多的从机。
如图:
Master1宕机,则Slaver11提升为新的Master1
集群检测到新的Master1是单点的(无从机)
集群从拥有最多的从机的节点组(Master3)中,选择节点名称字母顺序最小的从机(Slaver31)漂移到单点的主从节点组(Master1)。
副本漂移流程:
- 将Slaver31的从机记录从Master3中删除
- 将Slaver31的的主机改为Master1
- 在Master1中添加Slaver31为从节点
- 将Slaver31的复制源改为Master1
- 通过ping包将信息同步到集群的其他节点