Redis
简介
Redis,英文全称是Remote Dictionary Server(远程字典服务),Key-Value数据库。
可用于缓存,事件发布或订阅,高速队列等场景。
提供字符串、哈希、列表、队列、集合结构直接存取,基于内存,可持久化。
使用场景
- 热点数据的缓存
- 限时业务的运用
redis中可以使用expire命令设置一个键的生存时间,到时间后redis会删除它。 - 计数器相关问题
redis由于incrby命令可以实现原子性的递增,所以可以运用于高并发的秒杀活动、分布式序列号的生成。
具体业务还体现在比如限制一个手机号发多少条短信、一个接口一分钟限制多少请求、一个接口一天限制调用多少次等等。 - 分布式锁
这个主要利用redis的setnx命令进行,setnx:”set if not exists”就是如果不存在则成功设置缓存,同时返回1,否则返回0 。
这个特性在很多后台中都有所运用,因为我们服务器是集群的,定时任务可能在两台机器上都会运行,所以在定时任务中首先 通过setnx设置一个lock,如果成功设置则执行,如果没有成功设置,则表明该定时任务已执行。
当然结合具体业务,我们可以给这个lock加一个过期时间,比如说30分钟执行一次的定时任务,那么这个过期时间设置为小于30分钟的一个时间就可以,这个与定时任务的周期以及定时任务执行消耗时间相关。
在分布式锁的场景中,主要用在比如秒杀系统等。 - 延时操作
比如在订单生产后我们占用了库存,10分钟后去检验用户是否真正购买,如果没有购买将该单据设置无效,同时还原库存。
由于redis自2.8.0之后版本提供Keyspace Notifications功能,允许客户订阅Pub/Sub频道,以便以某种方式接收影响Redis数据集的事件。
所以我们对于上面的需求就可以用以下解决方案,我们在订单生产时,设置一个key,同时设置10分钟后过期,我们在后台实现一个监听器,监听key的实效,监听到key失效时将后续逻辑加上。
当然我们也可以利用rabbitmq、activemq等消息中间件的延迟队列服务实现该需求。 - 排行榜相关问题
关系型数据库在排行榜方面查询速度普遍偏慢,所以可以借助redis的SortedSet进行热点数据的排序。
比如点赞排行榜,做一个SortedSet, 然后以用户的openid作为上面的username, 以用户的点赞数作为上面的score, 然后针对每个用户做一个hash, 通过zrangebyscore就可以按照点赞数获取排行榜,然后再根据username获取用户的hash信息。 - 点赞、好友等相互关系的存储
Redis 利用集合的一些命令,比如求交集、并集、差集等。 - 简单队列
由于Redis有list push和list pop这样的命令,所以能够很方便的执行队列操作。
为什么
为什么单线程的Redis可以支持高并发访问?
- 为什么Redis选择单线程的实现方式
- redis是基于内存的,内存的读写速度非常快。CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存的大小或者网络带宽;
- 核心是基于非阻塞的IO多路复用机制;
- redis是单线程的,反而省去了很多上下文切换线程的时间,避免了多线程的实现复杂度;
- 为什么Redis能支持高并发访问
从IO模型角度来说,Redis使用的是IO多路复用模型,使得它可以在网络IO操作并发处理数十万的客户端网络连接,实现非常高的网络吞吐率。
这也是Redis可以实现高并发访问的最主要的原因。
如何
如何解决redis的并发竞争key问题
所谓redis的并发竞争key的问题也就是多个系统同时对一个key进行操作,但是最后执行的顺序和我们期望的顺序不同,这样也就导致了结果的不同。
解决方案:分布式锁(zookeeper和redis都可以实现分布式锁)。如果不存在redis的并发竞争key问题,不要使用分布式锁,这样会影响性能。
基于zookeeper临时有序节点可以实现的分布式锁。
大致思想为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务器宕机导致的锁无法释放,而产生的死锁问题。完成业务流程后,删除对应的子节点释放锁。
如何保证缓存与数据库双写时的数据一致性?
只要使用缓存,就可能会涉及到缓存与数据库双存储双写,只要是双写,就一定会有数据一致性的问题,那么你如何解决一致性问题?
Redis的线程模型
Redis内部使用文件事件处理器 file event handler
,这个文件事件处理器是单线程
的,所以redis才叫做单线程的模型。
Redis的线程模型是基于单Reactor单线程模型实现。
文件事件处理器的结构包含4个部分:
- 多个socket
- IO多路复用程序
- 文件事件分派器
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
文件事件处理器采用IO多路复用机制同时监听多个socket,根据socket上的事件来选择对应的事件处理器进行处理。
多个socket可能会并发产生不同的操作,每个操作对应不同的文件事件,但是IO多路复用程序会监听多个socket,会将socket产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。
Redis的IO多路复用
Redis的多路IO复用模型核心思想就是IO多路复用+事件派发,有事件发生了,通过事件派发器将其交给不同的处理器进行处理。
Redis 6.0的多线程
Redis的瓶颈主要在 IO 而不是 CPU,所以为了省开发量,在6.0版本前是单线程模型;其次,Redis 是单线程主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。(但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的)。
Redis 6.0版本中引入了多线程,目的是为了提高IO读写效率,因此在解析客户端命令和写响应结果时采用了多线程。
核心的读写命令执行和IO多路复用模块依然是由主线程执行:
Redis数据类型
- 5种基础类型
- String 字符串
- Hash 哈希
- List 列表
- Set 集合
- Sorted Set 有序集合
- 3种特殊类型
- Bitmap 位图
- HyperLogLog 基数统计
- Geospatial 地理位置
- Stream类型 v5.0
基本数据类型 1.String 字符串
String是redis中最基本的数据类型,一个key对应一个value。
String类型是二进制安全的,意思是 redis 的 string 可以包含任何数据。如数字,字符串,jpg图片或者序列化的对象。
Redis中string(字符串)是动态字符串,允许修改,,可以对字符串进行操作,比如增加字符或者求子串:如果是整数或者浮点数,可以实现计算,比如自增等
string类型是Redis最基本的数据结构,一个redis中字符串value最多可以是512M。
命令 | 简述 | 使用 |
---|---|---|
GET | 获取存储在给定键中的值 | GET name |
SET | 设置存储在给定键中的值 | SET name value |
DEL | 删除存储在给定键中的值 | DEL name |
INCR | 将键存储的值加1 | INCR key |
DECR | 将键存储的值减1 | DECR key |
INCRBY | 将键存储的值加上整数 | INCRBY key amount |
DECRBY | 将键存储的值减去整数 | DECRBY key amount |
基本数据类型 2.Hash 哈希
Redis hash 是一个 string 类型的 field(字段) 和 value(值) 的映射表,hash 特别适合用于存储对象。
命令 | 简述 | 使用 |
---|---|---|
HSET | 添加键值对 | HSET hash-key sub-key1 value1 |
HGET | 获取指定散列键的值 | HGET hash-key key1 |
HGETALL | 获取散列中包含的所有键值对 | HGETALL hash-key |
HDEL | 如果给定键存在于散列中,那么就移除这个键 | HDEL hash-key sub-key1 |
基本数据类型 3.List 列表
Redis中的List其实就是链表(Redis用双端链表实现List)。
命令 | 简述 | 使用 |
---|---|---|
RPUSH | 将给定值推入到列表右端 | RPUSH key value |
LPUSH | 将给定值推入到列表左端 | LPUSH key value |
RPOP | 从列表的右端弹出一个值,并返回被弹出的值 | RPOP key |
LPOP | 从列表的左端弹出一个值,并返回被弹出的值 | LPOP key |
LRANGE | 获取列表在给定范围上的所有值 | LRANGE key 0 -1 |
LINDEX | 通过索引获取列表中的元素。也可以使用负数下标,以-1表示列表的最后一个元素,-2表示列表的倒数第二个元素,以此类推。 | LINDEX key index |
- lpush+lpop=Stack(栈)
- lpush+rpop=Queue(队列)
- lpush+ltrim=Capped Collection(有限集合)
- lpush+brpop=Message Queue(消息队列)
基本数据类型 4.Set 集合
Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。
Redis 中集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。
命令 | 简述 | 使用 |
---|---|---|
SADD | 向集合添加一个或多个成员 | SADD key value |
SCARD | 获取集合的成员数 | SCARD key |
SMEMBERS | 返回集合中的所有成员 | SMEMBERS key member |
SISMEMBER | 判断 member 元素是否是集合 key 的成员 | SISMEMBER key member |
基本数据类型 5.Sorted Set 有序集合
Redis 有序集合,和集合一样也是 string 类型元素的集合,且不允许重复的成员。
不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。
有序集合的成员是唯一的, 但分数(score)却可以重复。
有序集合是通过两种数据结构实现:
- 压缩列表(ziplist)
ziplist是为了提高存储效率而设计的一种特殊编码的双向链表。
它可以存储字符串或者整数,存储整数时是采用整数的二进制而不是字符串形式存储。
它能在O(1)的时间复杂度下完成list两端的push和pop操作。
但是因为每次操作都需要重新分配ziplist的内存,所以实际复杂度和ziplist的内存使用量相关 - 跳跃表(zSkiplist)
跳跃表的性能可以保证在查找,删除,添加等操作的时候在对数期望时间内完成,这个性能是可以和平衡树来相比较的,而且在实现方面比平衡树要优雅,这是采用跳跃表的主要原因。
跳跃表的复杂度是O(log(n))。
命令 | 简述 | 使用 |
---|---|---|
ZADD | 将一个带有给定分值的成员添加到有序集合里面 | ZADD zset-key 178 member1 |
ZRANGE | 根据元素在有序集合中所处的位置,从有序集合中获取多个元素 | ZRANGE zset-key 0-1 withccores |
ZREM | 如果给定元素成员存在于有序集合中,那么就移除这个元素 | ZREM zset-key member1 |
特殊类型 6.Bitmaps
Bitmap 即位图数据结构,都是操作二进制位来进行记录,只有0和1两个状态。
在平时开发过程中,经常会有一些 bool 类型数据需要存取。比如记录用户一年内签到的次数,签了是 1,没签是 0。如果使用 key-value 来存储,那么每个用户都要记录 365 次,当用户成百上亿时,需要的存储空间将非常巨大。为了解决这个问题,可以考虑Redis提供的位图结构。
位图(bitmap)同样属于 string 数据类型。Redis 中一个字符串类型的值最多能存储 512 MB 的内容,每个字符串由多个字节组成,每个字节又由 8 个 Bit 位组成。位图结构正是使用位来实现存储的,它通过将比特位设置为0或1来达到数据存取的目的,这大大增加了 value 存储数量,它存储上限为2^32 。
位图本质上就是一个普通的字节串,也就是 bytes 数组。
特殊类型 7.HyperLogLog
Redis 2.8.9 版本更新了 Hyperloglog 数据结构,用来做基数统计!
基数定义:一个集合中不重复的元素个数就表示该集合的基数,比如集合{1,2,3,1,2},它的基数集合为 {1,2,3},所以基数为 3。
HyperLogLog 是通过基数估计算法来统计输入元素的基数。
HyperLoglog 采用了一种基数估计算法,因此,最终得到的结果会存在一定范围的误差(标准误差为 0.81%)。每个 HyperLogLog key 只占用 12 KB 内存,所以理论上可以存储大约2^64个值,而 set(集合)则是元素越多占用的内存就越多,两者形成了鲜明的对比。
- PFADD key element [element …] 添加指定元素到 HyperLogLog key 中。
- PFCOUNT key [key …] 返回指定 HyperLogLog key 的基数估算值。
- PFMERGE destkey sourcekey [sourcekey …] 将多个 HyperLogLog key 合并为一个 key。
- hyperloglog 只统计基数数量,不在内部存储数据,所以无法判断元素是否存在(因此不能提供contains、exist方法)。
应用场景
最典型的应用场景就是统计网站用户月活量,或者网站页面的UV(网站独立访客)数据等。
UV 与 PV(页面浏览量) 不同,UV 需要去重,同一个用户一天之内的多次访问只能计数一次。这就要求用户的每一次访问都要带上自身的用户 ID,无论是登陆用户还是未登陆用户都需要一个唯一ID来标识。
当一个网站拥有巨大的用户访问量时,我们可以使用 Redis 的 HyperLogLog 来统计网站的 UV(网站独立访客)数据,它提供的去重计数方案,虽说不精确,但 0.81% 的误差足以满足 UV 统计的需求。
特殊类型 8.Geospatial
在 Redis 3.2 版本中,新增了存储地理位置信息的功能,即 GEO(英文全称 geographic)。
- GEOADD 将指定的地理空间位置(纬度、经度、名称)添加到指定的 key 中。
- GEOPOS 从 key 里返回所有给定位置元素的位置(即经度和纬度)
- GEODIST 返回两个地理位置间的距离,如果两个位置之间的其中一个不存在, 那么命令返回空值。
- GEORADIUS 根据给定地理位置坐标(经纬度)获取指定范围内的地理位置集合。
- GEORADIUSBYMEMBER 根据给定地理位置(具体的位置元素)获取指定范围内的地理位置集合。
- GEOHASH 获取一个或者多个的地理位置的 GEOHASH 值。
- ZREM 通过有序集合的 zrem 命令实现对地理位置信息的删除。
应用场景
举一个简单的例子,外卖以及打车软件,在这种 APP上会显示“店家距离你有多少米”或者“司机师傅距离你有多远”,类似这种功能就可以使用 Redis GEO 实现。数据库中存放着商家所处的经纬度,你的位置则由手机定位获取,这样 APP 就计算出了最终的距离。
再比如微信中附近的人、摇一摇、实时定位等功能都依赖地理位置实现。
Stream类型
Redis5.0中增加了一个数据类型Stream,它借鉴了Kafka的设计,是一个新的强大的支持多播的可持久化的消息队列。
Redis持久化机制
为什么需要持久化?
Redis是个基于内存的数据库。服务一旦宕机,内存中的数据将全部丢失。
通常的解决方案是从后端数据库恢复这些数据,但后端数据库有性能瓶颈,如果是大数据量的恢复,1、会对数据库带来巨大的压力,2、数据库的性能不如Redis,导致程序响应慢。
所以对Redis来说,实现数据的持久化,避免从后端数据库中恢复数据,是至关重要的。
RDB(Redis DataBase)持久化
RDB持久化是把当前进程数据生成快照保存到磁盘上的过程,由于是某一时刻的快照,那么快照中的值要早于或者等于内存中的值。
触发方式
手动触发
- save命令
阻塞当前Redis服务器,直到RDB过程完成为止。
对于内存比较大的实例会造成长时间阻塞,线上环境不建议使用。 - bgsave命令
Redis进程执行fork操作创建子进程,RDB持久化过程由子进程负责,完成后自动结束。
阻塞只发生在fork阶段,一般时间很短。
- save命令
自动触发
redis.conf
中配置save m n
,即在m秒内有n次修改时,自动触发bgsave生成rdb文件;- 主从复制时,从节点要从主节点进行全量复制时也会触发bgsave操作,生成当时的快照发送到从节点;
- 执行debug reload命令重新加载redis时也会触发bgsave操作;
- 默认情况下执行shutdown命令时,如果没有开启aof持久化,那么也会触发bgsave操作;
redis.conf中配置RDB
- Redis中默认的周期性设置
1
2
3
4
5
6
7
8# 周期性执行条件的设置格式为
save <seconds> <changes>
# 默认的设置为:
save 900 1
save 300 10
save 60 10000
# 以下设置方式为关闭RDB快照功能
save "" - 其它相关配置
1
2
3
4
5
6
7
8
9
10# RDB文件在磁盘上的名称
dbfilename dump.rdb
# 文件保存路径
dir /home/work/app/redis/data/
# 如果持久化出错,主进程是否停止写入
stop-writes-on-bgsave-error yes
# 是否压缩
rdbcompression yes
# 导入时是否检查
rdbchecksum yes
RDB优缺点
- 优点
- RDB文件是某个时间节点的快照,默认使用LZF算法进行压缩,压缩后的文件体积远远小于内存大小,适用于备份、全量复制等场景;
- Redis加载RDB文件恢复数据要远远快于AOF方式;
- 缺点
- RDB方式实时性不够,无法做到秒级的持久化;
- 每次调用bgsave都需要fork子进程,fork子进程属于重量级操作,频繁执行成本较高;
- RDB文件是二进制的,没有可读性,AOF文件在了解其结构的情况下可以手动修改或者补全;
- 版本兼容RDB文件问题;
RDB深入理解:Copy-on-Write
由于生产环境中我们为Redis开辟的内存区域都比较大(例如6GB),那么将内存中的数据同步到硬盘的过程可能就会持续比较长的时间,而实际情况是这段时间Redis服务一般都会收到数据写操作请求。那么如何保证数据一致性呢?
RDB中的核心思路是Copy-on-Write(写时复制),来保证在进行快照操作的这段时间,需要压缩写入磁盘上的数据在内存中不会发生变化。
在正常的快照操作中,一方面Redis主进程会fork一个新的快照进程专门来做这个事情,这样保证了Redis服务不会停止对客户端包括写请求在内的任何响应。另一方面这段时间发生的数据变化会以副本的方式存放在另一个新的内存区域,待快照操作结束后才会同步到原来的内存区域。
针对RDB不适合实时持久化的问题,Redis提供了AOF持久化方式来解决
AOF(append-only file)持久化
Redis是写后日志,Redis先执行命令,把数据写入内存,然后才记录日志。
日志里记录的是Redis收到的每一条命令,这些命令是以文本形式保存。
PS: 大多数的数据库采用的是写前日志(Write Ahead Log,WAL),例如MySQL,通过写前日志和两阶段提交,实现数据和逻辑的一致性。
AOF日志采用写后日志,即先写内存,后写日志
。
- 为什么MySQL是日志先行?
- 首先区分MySQL的日志类型:InnoDB引擎层的undo log和redo log,Server层的binlog
- InnoDB插入一条数据先写到Buffer pool中,并不立即刷盘,所以要先写redo log日志保证持久化,事务提交成功之后写入binlog。数据刷入磁盘后会将redo log中的记录删除,redo log只保存没有落盘的数据。
- MySQL在server层会对sql语句进行合法性检查,记入日志中的sql语句一定是没有错误的。
- Redis为什么先执行命令再写日志?
- Redis没有预检测,在执行时才知道命令是否正确,正确执行之后再写日志。
- Redis的AOF持久化日志的定位与MySQL的定位的区别。
- Redis是基于内存的,先修改到内存再持久化命令到日志文件。
如何实现AOF
AOF日志记录Redis的每个写命令,步骤分为:命令追加(append)、文件写入(write)和文件同步(sync)。
- 命令追加
当AOF持久化功能打开了,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器的 aof_buf 缓冲区。 - 文件写入和同步
关于何时将 aof_buf 缓冲区的内容写入AOF文件中,Redis提供了三种写回策略:- Always 同步写回
每个写命令执行完,立马同步地将日志写回磁盘。可靠性高,数据基本不丢失。每个写命令都要落盘,性能影响较大。 - Everysec 每秒写回
每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘。性能适中。宕机时丢失1秒内的数据。 - No 操作系统控制的写回
每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。性能好。宕机时丢失数据较多。
- Always 同步写回
上面的三种写回策略体现了一个重要原则:trade-off,取舍,指在性能和可靠性保证之间做取舍。
关于AOF的同步策略是涉及到操作系统的 write 函数和 fsync 函数的,在《Redis设计与实现》中是这样说明的:
1 | 为了提高文件写入效率,在现代操作系统中,当用户调用write函数,将一些数据写入文件时,操作系统通常会将数据暂存到一个内存缓冲区里,当缓冲区的空间被填满或超过了指定时限后,才真正将缓冲区的数据写入到磁盘里。 |
redis.conf中配置AOF
默认情况下,Redis是没有开启AOF的,可以通过配置redis.conf文件来开启AOF持久化,关于AOF的配置如下
1 | # appendonly参数开启AOF持久化 |
AOF重写
AOF会记录每个写命令到AOF文件,随着时间越来越长,AOF文件会变得越来越大。如果不加以控制,会对Redis服务器,甚至对操作系统造成影响,而且AOF文件越大,数据恢复也越慢。
为了解决AOF文件体积膨胀的问题,Redis提供AOF文件重写机制来对AOF文件进行“瘦身”。
Redis 提供了 bgrewriteaof 指令用于对 AOF 日志进行瘦身。
其原理就是开辟一个子进程对内存进行遍历转换成一系列 Redis 的操作指令,序列化到一个新的 AOF 日志文件中。序列化完毕后再将操作期间发生的增量 AOF 日志追加到这个新的 AOF 日志文件中,追加完毕后就立即替代旧的 AOF 日志文件了,瘦身工作就完成了。
AOF重写会阻塞吗?
AOF重写过程是由后台进程bgrewriteaof来完成的。
主线程fork出后台的bgrewriteaof子进程,fork会把主线程的内存拷贝一份给bgrewriteaof子进程,这里面就包含了数据库的最新数据。然后,bgrewriteaof子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志。
所以aof在重写时,在fork进程时是会阻塞住主线程的。
AOF日志何时会重写?
有两个配置项控制AOF重写的触发:auto-aof-rewrite-min-size
:表示运行AOF重写时文件的最小大小,默认为64MB。auto-aof-rewrite-percentage
:这个值的计算方式是,当前aof文件大小和上一次重写后aof文件大小的差值,再除以上一次重写后aof文件大小。也就是当前aof文件比上一次重写后aof文件的增量大小,和上一次重写后aof文件大小的比值。
重写日志时,有新数据写入咋整?
主线程fork出子进程重写aof日志;
子进程重写日志完成后,主线程追加aof日志缓冲;
替换日志文件。
主线程fork出子进程时是如何复制内存数据的?
fork采用操作系统提供的写时复制(copy on write)机制,就是为了避免一次性拷贝大量内存数据给子进程造成阻塞。fork子进程时,子进程时会拷贝父进程的页表,即虚实映射关系(虚拟内存和物理内存的映射索引表),而不会拷贝物理内存。这个拷贝会消耗大量cpu资源,并且拷贝完成前会阻塞主线程,阻塞时间取决于内存中的数据量,数据量越大,则内存页表越大。拷贝完成后,父子进程使用相同的内存地址空间。
但主进程是可以有数据写入的,这时候就会拷贝物理内存中的数据。如下图(进程1看做是主进程,进程2看做是子进程):
在主进程有数据写入时,而这个数据刚好在页c中,操作系统会创建这个页面的副本(页c的副本),即拷贝当前页的物理数据,将其映射到主进程中,而子进程还是使用原来的的页c。
在重写日志整个过程时,主线程有哪些地方会被阻塞?
- fork子进程时,需要拷贝虚拟页表,会对主线程阻塞。
- 主进程有bigkey写入时,操作系统会创建页面的副本,并拷贝原有的数据,会对主线程阻塞。
- 子进程重写日志完成后,主进程追加aof重写缓冲区时可能会对主线程阻塞。
为什么AOF重写不复用原AOF日志?
- 父子进程写同一个文件会产生竞争问题,影响父进程的性能。
- 如果AOF重写过程中失败了,相当于污染了原本的AOF文件,无法做恢复数据使用。
RDB和AOF的混合持久化
重启 Redis 时,我们很少使用 rdb 来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 rdb 来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。
Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。
将 rdb 文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是自RDB持久化开始到RDB持久化结束的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小。
这样一来,快照不用很频繁地执行,这就避免了频繁 fork 对主线程的影响。而且,AOF 日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免重写开销。
在 Redis 重启的时候,可以先加载 rdb 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升。
Redis消息传递:发布订阅模式
Redis发布订阅(pub/sub)是一种消息通信模式:发送者(pub)发送消息,订阅者(sub)接收消息。
发布/订阅使用
基于频道(Channel)的发布/订阅
发布者可以向指定的频道(channel)发送消息; 订阅者可以订阅一个或者多个频道(channel),所有订阅此频道的订阅者都会收到此消息。
- 发布
- publish channel message
- 订阅
- 订阅 subscribe channel1 [channel2 …]
- 退订 unsubscribe
基于模式(pattern)的发布/订阅
- 订阅 psubscribe c? b* d?*
使用psubscribe命令可以重复订阅同一个频道。
如客户端执行了psubscribe c? c*
。这时向c1发布消息客户端会接受到两条消息,而同时publish命令的返回值是2而不是1。
如果客户端执行了subscribe c1
和psubscribe c?*
的话,向c1发送一条消息该客户顿也会受到两条消息(但是是两种类型:message和pmessage),同时publish命令也返回2。 - 退订
punsubscribe命令可以退订指定的规则,用法是: punsubscribe [pattern [pattern …]],如果没有参数则会退订所有规则。
使用punsubscribe只能退订通过psubscribe命令订阅的规则,不会影响直接通过subscribe命令订阅的频道;同样unsubscribe命令也不会影响通过psubscribe命令订阅的规则。
punsubscribe命令退订某个规则时不会将其中的通配符展开,而是进行严格的字符串匹配,所以punsubscribe *
无法退订c*
规则,而是必须使用punsubscribe c*
才可以退订。(它们是相互独立的,后文可以看到数据结构上看也是两种实现)
深入理解
基于频道(Channel)的发布/订阅如何实现的?
底层是通过字典(图中的pubsub_channels)实现的,这个字典用于保存订阅频道的信息:字典的键为正在被订阅的频道,而字典的值则是一个链表,链表中保存了所有订阅这个频道的客户端。
数据结构
在下图展示的这个 pubsub_channels 示例中, client2、client5和client1就订阅了channel1,而其他频道也分别被别的客户端所订阅:
基于模式(pattern)的发布/订阅如何实现的?
底层是pubsubPattern节点的链表。
redisServer.pubsub_patterns 属性是一个链表,链表中保存着所有和模式相关的信息:
1 | struct redisServer { |
链表中的每个节点都包含一个 redis.h/pubsubPattern 结构:client 属性保存着订阅模式的客户端,而 pattern 属性则保存着被订阅的模式。
每当调用 PSUBSCRIBE 命令订阅一个模式时, 程序就创建一个包含客户端信息和被订阅模式的 pubsubPattern 结构, 并将该结构添加到 redisServer.pubsub_patterns 链表中。
SpringBoot结合Redis发布/订阅实例?
springboot集成redis实现消息发布订阅模式-双通道
Redis事件机制
Redis的单线程模型核心的文件事件处理器采用事件驱动机制来处理大量的网络IO。
自己实现一个非常简洁的事件驱动库 ae_event。
Redis中的事件驱动库只关注网络IO,以及定时器。
- 文件事件(file event)
用于处理 Redis 服务器和客户端之间的网络IO。 - 时间事件(time eveat)
Redis 服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间事件就是处理这类定时操作的。
aeEventLoop是整个事件驱动的核心,它管理着文件事件表和时间事件列表,不断地循环处理着就绪的文件事件和到期的时间事件。
文件事件
Redis基于Reactor模式开发了自己的网络事件处理器,也就是文件事件处理器。
文件事件处理器使用IO多路复用技术,同时监听多个套接字,并为套接字关联不同的事件处理函数。当套接字的可读或者可写事件触发时,就会调用相应的事件处理函数。
Redis事件响应框架ae_event
Redis 使用的IO多路复用技术主要有:select、epoll、evport和kqueue等。
每个IO多路复用函数库在 Redis 源码中都对应一个单独的文件,比如ae_select.c,ae_epoll.c,ae_kqueue.c等。
Redis 会根据不同的操作系统,按照不同的优先级选择多路复用技术。
事件响应框架一般都采用该架构,比如 netty 和 libevent。
文件事件处理器
文件事件处理器有四个组成部分,它们分别是套接字、I/O多路复用程序、文件事件分派器以及事件处理器。
文件事件是对套接字操作的抽象,每当一个套接字准备好执行accept、read、write和close等操作时,就会产生一个文件事件。
Redis IO多路复用模型
在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
时间事件
aeEventLoop的实现
Redis事务
Redis 事务的本质是一组命令的集合。
事务支持一次执行多个命令,一个事务中所有命令都会被序列化。
在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。
Redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。
Redis事务相关命令和使用
- MULTI :开启事务,redis会将后续的命令逐个放入队列中,然后使用EXEC命令来原子化执行这个命令系列。
- EXEC:执行事务中的所有操作命令。
- DISCARD:取消事务,放弃执行事务块中的所有命令。
- WATCH:监视一个或多个key,如果事务在执行前,这个key(或多个key)被其他命令修改,则事务被中断,不会执行事务中的任何命令。
- UNWATCH:取消WATCH对所有key的监视。
CAS操作实现乐观锁
Redis事务执行步骤
深入理解Redis事务
为什么Redis不支持回滚
如何理解Redis与事务的ACID
- 原子性
- 一致性
- 隔离性
- 持久性
Redis事务的其他实现
- 基于Lua脚本,Redis可以保证脚本内的命令一次性、按顺序地执行,其同时也不提供事务运行错误的回滚,执行过程中如果部分命令运行错误,剩下的命令还是会继续运行完
- 基于中间标记变量,通过另外的标记变量来标识事务是否执行完成,读取数据时先读取该标记变量判断是否事务执行完成。但这样会需要额外写代码实现,比较繁琐
Redis高可用 - 主从复制
要避免单点故障,即保证高可用,便需要冗余(副本)方式提供集群服务。
Redis 提供了主从库模式,以保证数据副本的一致,主从库之间采用的是读写分离的方式。
Redis的主从复制,是指将一台Redis服务器的数据,复制到其他的Redis服务器。
前者称为主节点(master),后者称为从节点(slave);数据的复制是单向的,只能由主节点到从节点。
主从复制的作用主要包括:
- 数据冗余:主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
- 故障恢复:当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复;实际上是一种服务的冗余。
- 负载均衡:在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务(即写Redis数据时应用连接主节点,读Redis数据时应用连接从节点),分担服务器负载;尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高Redis服务器的并发量。
- 高可用基石:除了上述作用以外,主从复制还是哨兵和集群能够实施的基础,因此说主从复制是Redis高可用的基础。
主从复制原理
注意:在2.8版本之前只有全量复制,而2.8版本后有全量和增量复制
- 全量复制:用于初次复制或其他无法进行部分复制的情况,将主节点中的所有数据都发送给从节点,是一个非常重型的操作。
- 增量复制:用于网络中断等情况后的复制,只将中断期间主节点执行的写命令发送给从节点,与全量复制相比更加高效。需要注意的是,如果网络中断时间过长,导致主节点没有能够完整地保存中断期间执行的写命令,则无法进行部分复制,仍使用全量复制。
全量复制
当我们启动多个 Redis 实例的时候,它们相互之间就可以通过replicaof(Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系,之后会按照三个阶段完成数据的第一次同步。
确立主从关系
例如,现在有实例1(ip:172.16.19.3)和实例2(ip:172.16.19.5),我们在实例2上执行以下这个命令后,实例2就变成了实例1的从库,并从实例1上复制数据:
1 | replicaof 172.16.19.3 6379 |
全量复制的三个阶段
- 第一阶段是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。
具体来说,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。
psync 命令包含了主库的 runID 和复制进度 offset 两个参数。runID,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设为“?”。offset,此时设为 -1,表示第一次复制。
主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。
这里有个地方需要注意,FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。 - 第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。
具体来说,主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。
从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。
在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的replication buffer,记录 RDB 文件生成后收到的所有写操作。 - 第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。
具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。
增量复制
为什么会设计增量复制
如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制,开销非常大。
从 Redis 2.8 开始,网络断了之后,主从库会采用增量复制的方式继续同步。
- repl_backlog_buffer:它是为了从库断开之后,如何找到主从差异数据而设计的环形缓冲区,从而避免全量复制带来的性能开销。如果从库断开时间太久,repl_backlog_buffer环形缓冲区被主库的写命令覆盖了,那么从库连上主库后只能乖乖地进行一次全量复制,所以repl_backlog_buffer配置尽量大一些,可以降低主从断开后全量复制的概率。而在repl_backlog_buffer中找主从差异的数据后,如何发给从库呢?这就用到了replication buffer。
- replication buffer:Redis和客户端通信也好,和从库通信也好,Redis都需要给分配一个内存buffer进行数据交互,客户端是一个client,从库也是一个client,我们每个client连上Redis后,Redis都会分配一个client buffer,所有数据交互都是通过这个buffer进行的:Redis先把数据写到这个buffer中,然后再把buffer中的数据发到client socket中再通过网络发送出去,这样就完成了数据交互。所以主从在增量同步时,从库作为一个client,也会分配一个buffer,只不过这个buffer专门用来传播用户的写命令到从库,保证主从数据一致,我们通常把它叫做replication buffer。
如果在网络断开期间,repl_backlog_buffer环形缓冲区写满之后,从库是会丢失掉那部分被覆盖掉的数据,还是直接进行全量复制呢?
深入理解
为什么还有无磁盘复制模式?
Redis 默认是磁盘复制,但是如果使用比较低速的磁盘,这种操作会给主服务器带来较大的压力。Redis从2.8.18版本开始尝试支持无磁盘的复制。使用这种设置时,子进程直接将RDB通过网络发送给从服务器,不使用磁盘作为中间存储。
无磁盘复制模式:master创建一个新进程直接dump RDB到slave的socket,不经过主进程,不经过硬盘。适用于disk较慢,并且网络较快的时候。
使用repl-diskless-sync配置参数来启动无磁盘复制。
使用repl-diskless-sync-delay 参数来配置传输开始的延迟时间;master等待一个repl-diskless-sync-delay的秒数,如果没slave来的话,就直接传,后来的得排队等了; 否则就可以一起传。
为什么还会有从库的从库的设计?
通过分析主从库间第一次数据同步的过程,你可以看到,一次全量复制中,对于主库来说,需要完成两个耗时的操作:生成 RDB 文件和传输 RDB 文件。
如果从库数量很多,而且都要和主库进行全量复制的话,就会导致主库忙于 fork 子进程生成 RDB 文件,进行数据全量复制。fork 这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。此外,传输 RDB 文件也会占用主库的网络带宽,同样会给主库的资源使用带来压力。
那么,有没有好的解决方法可以分担主库压力呢?其实是有的,这就是主-从-从模式。
读写分离及其中的问题
延迟与不一致问题
数据过期问题
在单机版的Redis中,存在两种删除策略:- 惰性删除:服务器不会主动删除数据,只有当客户端查询某个数据时,服务器判断该数据是否过期,如果过期则删除。
- 定期删除:服务器执行定时任务删除过期数据,但是考虑到内存和CPU的折中(删除会释放内存,但是频繁的删除操作对CPU不友好),该删除的频率和执行时间都受到了限制。
在主从复制场景下,为了主从节点的数据一致性,从节点不会主动删除数据,而是由主节点控制从节点中过期数据的删除。
由于主节点的惰性删除和定期删除策略,都不能保证主节点及时对过期数据执行删除操作,因此,当客户端通过Redis从节点读取数据时,很容易读取到已经过期的数据。
Redis 3.2中,从节点在读取数据时,增加了对数据是否过期的判断:如果该数据已过期,则不返回给客户端;将Redis升级到3.2可以解决数据过期问题。故障切换问题
Redis高可用 - 哨兵机制
在 Redis 主从集群中,哨兵机制是实现主从库自动切换的关键机制,它有效地解决了主从复制模式下故障转移的问题。
Redis Sentinel,即Redis哨兵,在Redis 2.8版本开始引入。哨兵的核心功能是主节点的自动故障转移。
下图是一个典型的哨兵集群监控的逻辑图:
哨兵实现了什么功能呢?下面是Redis官方文档的描述:
- 监控(Monitoring):哨兵会不断地检查主节点和从节点是否运作正常。
- 自动故障转移(Automatic failover):当主节点不能正常工作时,哨兵会开始自动故障转移操作,它会将失效主节点的其中一个从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
- 配置提供者(Configuration provider):客户端在初始化时,通过连接哨兵来获得当前Redis服务的主节点地址。
- 通知(Notification):哨兵可以将故障转移的结果发送给客户端。
哨兵集群的组建
哨兵实例之间可以相互发现,要归功于 Redis 提供的 pub/sub 机制,也就是发布 / 订阅机制。
在主从集群中,主库上有一个名为**sentinel:hello**的频道,不同哨兵就是通过它来相互发现,实现互相通信的。
在下图中,哨兵1把自己的IP(172.16.19.3)和端口(26579)发布到**sentinel:hello**频道上,哨兵2和3订阅了该频道。那么此时,哨兵2和3就可以从这个频道直接获取哨兵1的IP地址和端口号。然后,哨兵2、3可以和哨兵1建立网络连接。通过这个方式,哨兵2和3也可以建立网络连接,这样一来,哨兵集群就形成了。它们相互间可以通过网络连接进行通信,比如说对主库有没有下线这件事儿进行判断和协商。
Redis 1主4从,5个哨兵,哨兵配置quorum为2,如果3个哨兵故障,当主库宕机时,哨兵能否判断主库“客观下线”?能否自动切换?
- 哨兵集群可以判定主库“主观下线”。
由于quorum=2,所以当一个哨兵判断主库“主观下线”后,询问另外一个哨兵后也会得到同样的结果,2个哨兵都判定“主观下线”,达到了quorum的值,因此,哨兵集群可以判定主库为“客观下线”。 - 不能完成主从切换。
哨兵标记主库“客观下线后”,在选举“哨兵领导者”时,一个哨兵必须拿到超过多数的选票(5/2+1=3票)。但目前只有2个哨兵活着,无论怎么投票,一个哨兵最多只能拿到2票,永远无法达到N/2+1选票的结果。
Redis高可扩展 - 分片技术
主从复制和哨兵机制保障了高可用,就读写分离而言,虽然slave节点扩展了主从的读并发能力,但是写能力和存储能力是无法进行扩展,就只能是master节点能够承载的上限。如果面对海量数据,那么必然需要构建mster(主节点分片)之间的集群,同时必然需要吸收高可用(主从复制和哨兵机制)能力,即每个master分片节点还需要有slave从节点,这是分布式系统中典型的纵向扩展(集群的分片技术)的体现。
在Redis 3.0版本中对应的设计就是Redis Cluster。
缓存并发问题
数据库和缓存一致性
更新缓存的的Design Pattern有四种:Cache aside, Read through, Write through, Write behind caching
Cache Aside Pattern
- 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
- 更新的时候,先更新数据库,然后再删除缓存。
缓存雪崩
缓存同一时间大面积的失效,所以后面的请求都会落到数据库上,导致数据库压力巨大。
如果在高并发的情况下,可能瞬间就会导致数据库宕机。这时候如果运维马上重启数据库,马上又会有新的流量把数据库打死。
造成缓存雪崩的关键在于同一时间大规模的key失效。第一种可能是Redis宕机,第二种可能是采用了相同的过期时间。
解决办法:
- 事前:
- 尽量保证整个redis集群的高可用性,发现机器宕机尽快补上。
- 选择合适的内存淘汰策略,避免采用相同的过期时间。
- 事中:本地ehcache+hystrix限流&降级,避免mysql崩掉
- 事后:利用redis持久化机制保存的数据尽快恢复缓存
缓存击穿
缓存雪崩是大规模的key失效。
缓存击穿是一个热点的key,有大并发集中对其进行访问,突然间这个key失效了,导致大并发全部打在数据库上,导致数据库压力剧增。
造成缓存击穿的关键在于某个热点的key失效了,导致大并发集中打在数据库上。所以要从两个方面解决,第一是否可以考虑热点key不设置过期时间,第二是否可以考虑降低打在数据库上的请求数量。
解决方案:
- 设置热点数据永不过期。
- 加互斥锁。
如果缓存失效,只有拿到锁才可以查询数据库,降低了在同一时刻打在数据库上的请求,防止数据库被打死,当然这样会导致系统的性能变差。
缓存穿透
缓存穿透是查询的key是redis和数据库中都不存在的数据。
一般是黑客故意去请求缓存和数据库中都不存在的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。
解决办法:
- 采用布隆过滤器
布隆过滤器的作用是某个 key 不存在,那么就一定不存在,它说某个 key 存在,那么很大可能是存在(存在一定的误判率)。于是我们可以在缓存之前再加一层布隆过滤器,在查询的时候先去布隆过滤器查询 key 是否存在,如果不存在就直接返回。 - 缓存空结果
如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。
缓存污染
缓存污染问题说的是缓存中一些只会被访问一次或者几次的的数据,被访问完后,再也不会被访问到,但这部分数据依然留存在缓存中,消耗缓存空间。
缓存污染会随着数据的持续增加而逐渐显露,随着服务的不断运行,缓存中会存在大量的永远不会再次被访问的数据。
缓存空间是有限的,如果缓存空间满了,再往缓存里写数据时就会有额外开销,影响Redis性能。
这部分额外开销主要是指写的时候判断淘汰策略,根据淘汰策略去选择要淘汰的数据,然后进行删除操作。
最大缓存设置
建议把缓存容量设置为总数据量的 15% 到 30%,兼顾访问性能和内存空间开销。
1 | # 设置缓存大小为4G |
缓存被写满是不可避免的, 所以需要数据淘汰策略。
缓存淘汰策略
触发条件是Redis使用的内存达到阈值。
- 不淘汰
- noeviction (v4.0后默认的)
- 对设置了过期时间的数据中进行淘汰
- 随机:volatile-random
- ttl:volatile-ttl 移除即将过期的键值对
- lru:volatile-lru 移除最近最少使用的键值对 跟使用的最后一次时间有关,淘汰最近使用时间离现在最久的
- lfu:volatile-lfu 移除最近最不频繁使用的键值对 跟使用的次数有关,淘汰使用次数最少的
- 全部数据进行淘汰
- 随机:allkeys-random
- lru:allkeys-lru
- lfu:allkeys-lfu
版本特性 - Redis 4.0
布隆过滤器 Bloom Filter
布隆过滤器(Bloom Filter)是 Redis 4.0 版本提供的新功能,它被作为插件加载到 Redis 服务器中,给 Redis 提供强大的去重功能。
相比于 Set 集合的去重功能而言,布隆过滤器在空间上能节省 90% 以上,但是它的不足之处是去重率大约在 99% 左右,也就是说有 1% 左右的误判率,这种误差是由布隆过滤器的自身结构决定的。
常用命令
- bf.add 添加一个元素到布隆过滤器。
- bf.exists 判断某个元素是否在于布隆过滤器中。
- bf.madd 同时添加多个元素到布隆过滤器。
- bf.mexists 同时判断多个元素是否存在于布隆过滤器中。
- bf.reserve 以自定义的方式设置布隆过滤器参数值,共有 3 个参数分别是 key、error_rate(错误率)、initial_size(初始大小)。
实现原理
布隆过滤器(Bloom Filter)是一个高空间利用率的概率性数据结构,由二进制向量(即位数组)和一系列随机映射函数(即哈希函数)两部分组成。
当布隆过滤器判定某个值存在时,其实这个值只是有可能存在;当它说某个值不存在时,那这个值肯定不存在,这个误判概率大约在 1% 左右。
- bf.add
当使用布隆过滤器添加 key 时,会使用不同的 hash 函数对 key 存储的元素值进行哈希计算,从而会得到多个哈希值。根据哈希值计算出一个整数索引值,将该索引值与位数组长度做取余运算,最终得到一个位数组位置,并将该位置的值变为 1。每个 hash 函数都会计算出一个不同的位置,然后把数组中与之对应的位置变为 1。 - bf.exist
当我们需要判断一个元素是否存时,其流程如下:首先对给定元素再次执行哈希计算,得到与添加元素时相同的位数组位置,判断所得位置是否都为 1,如果其中有一个为 0,那么说明元素不存在,若都为 1,则说明元素有可能存在。 - 为什么是可能存在
那些被置为 1 的位置也可能是由于其他元素的操作而改变的。比如元素1 和 元素2,这两个元素同时将一个位置变为了 1(如图所示)。在这种情况下,我们就不能判定元素1和元素2一定存在,这是布隆过滤器存在误判的根本原因。