Redis初探
带着问题去寻找答案往往会是最好的学习方法,开始探究前,先问几个关于Redis的问题:
- Redis有哪些数据结构,其底层是什么
- Redis持久化机制
- Redis为什么是单进程的?为什么快?
- Redis如何保证的原子性
- Redis过期策略及其淘汰机制
- Redis的瓶颈及解决方案
由简入难,一个一个的研究,对每个关键点做到知其然并知其所以然
Redis有哪些数据结构,其底层结构是什么
redis
的常见的5种数据结构,分别是string(字符串)
、list(列表)
、hash(哈希)
、set(集合)
、sorted set(有序集合)
。这些数据结构是暴露给外部接口调用的。
底层的数据结构有6种:robj
、sds(简单动态字符串)
、dict(字典)
、intset(整数集合)
、skiplist(跳跃表)
、ziplist(压缩列表)
、quicklist(快速列表)
。
关于底层数据结构的很多,这里就不赘述了:
对于常用的数据结构已经有了理解,那么不常用的呢?
-
Streams
Redis Stream 主要用于消息队列(MQ,Message Queue),Redis 本身是有一个 Redis 发布订阅 (pub/sub) 来实现消息队列的功能,但它有个缺点就是消息无法持久化,如果出现网络断开、Redis 宕机等,消息就会被丢弃。
简单来说发布订阅 (pub/sub) 可以分发消息,但无法记录历史消息。
而 Redis Stream 提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。
-
Geospatial
可以用来保存地理位置,并作位置距离计算或者根据半径计算位置等。有没有想过用Redis来实现附近的人?或者计算最优地图路径?
-
HyperLogLog
供不精确的去重计数功能,比较适合用来做大规模数据的去重统计,例如统计 UV
-
Bitmaps
位图是支持按 bit 位来存储信息,可以用来实现 布隆过滤器(BloomFilter)
-
Bitfields
将 Redis 字符串视为一个位数组,并且能够处理具有不同位宽和任意非(必要)对齐偏移量的特定整数字段。实际上,使用此命令可以将位偏移量为1234的带符号5位整数设置为特定值,从偏移量4567中检索31位无符号整数。类似地,该命令处理指定整数的递增和递减,提供保证和良好指定的溢出和下溢行为,用户可以配置
Redis持久化机制
Redis支持两种方式的持久化:
- RDB: 在指定的时间间隔内对你的数据进行快照存储
- AOF:记录每次对服务器写的操作,当服务器重启时会重新执行这些命令来回复原始的数据
相关定义网上的文章很多,在这里不做多余的赘述,简单说下优缺点
RDB vs AOF
RDB优点
- RDB是一个紧凑压缩的二进制文件,代表Redis在某一个时间点上的数据快照,非常适合用于备份、全量复制等场景。
- RDB对灾难恢复、数据迁移非常友好,RDB文件可以转移至任何需要的地方并重新加载。
- RDB是Redis数据的内存快照,数据恢复速度较快,相比于AOF的命令重放有着更高的性能。
RDB缺点
- RDB方式无法做到实时或秒级持久化。因为持久化过程是通过fork子进程后由子进程完成的,子进程的内存只是在fork操作那一时刻父进程的数据快照,而fork操作后父进程持续对外服务,内部数据时刻变更,子进程的数据不再更新,两者始终存在差异,所以无法做到实时性。
- RDB持久化过程中的fork操作,会导致内存占用加倍,而且父进程数据越多,fork过程越长。
- Redis请求高并发可能会频繁命中save规则,导致fork操作及持久化备份的频率不可控;
- RDB文件有文件格式要求,不同版本的Redis会对文件格式进行调整,存在老版本无法兼容新版本的问题。
AOF优点
- AOF持久化有更好的实时性,我们可以选择三种不同的方式(appendfsync):no、every second、always,every second作为默认的策略具有最好的性能,极端情况下可能会丢失一秒的数据。
- AOF文件只有append操作,无复杂的seek等文件操作,没有损坏风险。即使最后写入数据被截断,也很容易使用
redis-check-aof
工具修复; - 当AOF文件变大时,Redis可在后台自动重写。重写过程中旧文件会持续写入,重写完成后新文件将变得更小,并且重写过程中的增量命令也会append到新文件。
- AOF文件以已于理解与解析的方式包含了对Redis中数据的所有操作命令。即使不小心错误的清除了所有数据,只要没有对AOF文件重写,我们就可以通过移除最后一条命令找回所有数据。
- AOF已经支持混合持久化,文件大小可以有效控制,并提高了数据加载时的效率。
AOF缺点
- 对于相同的数据集合,AOF文件通常会比RDB文件大;
- 在特定的fsync策略下,AOF会比RDB略慢。一般来讲,fsync_every_second的性能仍然很高,fsync_no的性能与RDB相当。但是在巨大的写压力下,RDB更能提供最大的低延时保障。
- 在AOF上,Redis曾经遇到一些几乎不可能在RDB上遇到的罕见bug。一些特殊的指令(如BRPOPLPUSH)导致重新加载的数据与持久化之前不一致,Redis官方曾经在相同的条件下进行测试,但是无法复现问题。
AOF混合持久
AOF文件重写的流程是什么?听说Redis支持混合持久化,对AOF文件重写有什么影响?
从4.0版本开始,Redis在AOF模式中引入了混合持久化方案,即:纯AOF方式、RDB+AOF方式,这一策略由配置参数aof-use-rdb-preamble
(使用RDB作为AOF文件的前半段)控制,默认关闭(no),设置为yes可开启。所以,在AOF重写过程中文件的写入会有两种不同的方式。当aof-use-rdb-preamble
的值是:
- no:按照AOF格式写入命令,与4.0前版本无差别;
- yes:先按照RDB格式写入数据状态,然后把重写期间AOF缓冲区的内容以AOF格式写入,文件前半部分为RDB格式,后半部分为AOF格式
结合源码(6.0版本,源码太多这里不贴出,可参考aof.c
)及参考资料,绘制AOF重写(BGREWRITEAOF)流程图:
结合上图,总结一下AOF文件重写的流程:
- rewriteAppendOnlyFileBackground开始执行,检查是否有正在进行的AOF重写或RDB持久化子进程:如果有,则退出该流程;如果没有,则继续创建接下来父子进程间数据传输的通信管道。执行fork()操作,成功后父子进程分别执行不同的流程。
- 父进程:
- 记录子进程信息(pid)、时间戳等;
- 继续响应其他客户端请求;
- 收集AOF重写期间的命令,追加至aof_rewrite_buffer;
- 等待并向子进程同步aof_rewrite_buffer的内容;
- 子进程:
- 修改当前进程名称,创建重写所需的临时文件,调用rewriteAppendOnlyFile函数;
- 根据
aof-use-rdb-preamble
配置,以RDB或AOF方式写入前半部分,并同步至硬盘; - 从父进程接收增量AOF命令,以AOF方式写入后半部分,并同步至硬盘;
- 重命名AOF文件,子进程退出。
从持久化中恢复数据
数据的备份、持久化做完了,我们如何从这些持久化文件中恢复数据呢?如果一台服务器上有既有RDB文件,又有AOF文件,该加载谁呢?
其实想要从这些文件中恢复数据,只需要重新启动Redis即可。我们还是通过图来了解这个流程:
Redis 4.0版本后AOF支持了混合持久化,加载AOF文件需要考虑版本兼容性,所以回复数据流程发生了变化:
在AOF方式下,开启混合持久化机制生成的文件是“RDB头+AOF尾”,未开启时生成的文件全部为AOF格式。考虑两种文件格式的兼容性,如果Redis发现AOF文件为RDB头,会使用RDB数据加载的方法读取并恢复前半部分;然后再使用AOF方式读取并恢复后半部分。由于AOF格式存储的数据为RESP协议命令,Redis采用伪客户端执行命令的方式来恢复数据。
如果在AOF命令追加过程中发生宕机,由于延迟写的技术特点,AOF的RESP命令可能不完整(被截断)。遇到这种情况时,Redis会按照配置项aof-load-truncated
执行不同的处理策略。这个配置是告诉Redis启动时读取aof文件,如果发现文件被截断(不完整)时该如何处理:
- yes:则尽可能多的加载数据,并以日志的方式通知用户;
- no:则以系统错误的方式崩溃,并禁止启动,需要用户修复文件后再重启。
Redis为什么是单进程的?为什么快?
为什么是单进程的
官方FAQ:因为Redis是基于内存的操作,CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了(毕竟采用多线程会有很多麻烦!)。
注1
:这里我们一直在强调的单线程,只是在处理我们的网络请求
的时候只有一个线程来处理,一个正式的Redis Server运行的时候肯定是不止一个线程的,这里需要大家明确的注意一下! 例如Redis进行持久化的时候会以子进程或者子线程的方式执行(具体是子线程还是子进程待读者深入研究)
注2
:从Redis 4.0版本开始会支持多线程的方式,但是,只是在某一些操作上进行多线程的操作!以后的版本中是否还是单线程的方式需要考证!
优势与劣势
优势:
- 代码更清晰,处理逻辑更简单
- 不用考虑各种锁问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗
- 不存在多进程或者多线程导致的切换而消耗CPU
劣势:
- 无法发挥多核CPU性能,不过可以通过在单机开多个Redis实例来完善
为什么快
- 完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1)
- 数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的
- 采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;
- 使用多路I/O复用模型,非阻塞IO
- 使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis直接自己构建了VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求
Redis如何保证的原子性
Redis的每个命令都是原子性的,但是这并不代表Redis就不存在原子性问题
库存问题
假设现在redis有个叫stock
的key,用于存放商品的库存数据,只要进货了库存就加一,出货了库存就减一。于是这时候机智的程序员小王这样设计了一下,每次通过get获取stock的值,然后再set stock=stock+1。在某个时间点有两个业务员A、B同时进货了,此时程序运行流程大概是这样的:
-
A 获取stock的值为100
-
B 获取stock的值为100
-
A 设置stock的值为101
-
B 设置stock的值为101
整个流程下来,我们发现库存丢了一个。整个操作并没有保证原子性,最终丢失了库存
解决方式
原生命令
对于上述的增量操作,使用 incr
和 decr
而非直接set
,对于常规 get
+ set
推荐使用 setnx
+ expire
的方式,批量设置多个值的场景可以用 mset
,批量获取多个值的 mget
。从命令和业务上实现原子性,避免非原子性的操作。
加锁
通过加锁的方式去保证原子性,只有获取到了锁的线程,才能执行对应的业务。获取锁的方式跟方式一一样,通过setnx
命令去设置一个固定的代表锁的key,若设置成功,则代表获取锁成功,然后执行上面的get
和set
操作;操作完成后,再将这个代表锁的key删除掉即可。
但是这样加锁的话需要有两个风险点:
(1)客户端在执行了setnx
命令获取到锁之后,在后续的业务操作中发生了异常,没有执行删除锁的操作,导致锁一直被占用,其他线程就无法拿到锁。
**解决方案:**在设置锁的时候,设置一个过期过期时间,到期后自动释放锁。
(2)客户端A获取到了锁,但是业务操作太久了,导致客户端A获取的锁自动释放掉了,且这时客户端B在锁被释放掉后获取到了锁,开始他的业务操作,此时客户端A的业务执行完了,去执行释放锁的操作,就会把B获取到了锁给释放掉了,导致B的业务没有锁的保证。
**解决方案:**在设置锁的时候,客户端给自己设置的锁设置一个唯一值,在释放锁的时候,只释放这个唯一值对应的key。
Lua脚本
即使redis支持很多原子命令,但是还是无法满足所有场景,于是redis在2.6之后开始支持开发者编写lua脚本传到redis中,使用lua脚本的好处就是:
- 减少网络开销,通过lua脚本可以一次性的将多个请求合并成一个请求。
- 原子操作,redis将lua脚本作为一个整体,执行过程中,不会被其他命令打断,不会出现竞态问题。
- 复用,客户端发送的lua脚本会永远存在redis服务中。
1 | 127.0.0.1:6379> EVAL "redis.call('SET',KEYS[1],ARGV[1]);redis.call('EXPIRE',KEYS[1],ARGV[2]);return 1;" 1 name sun 60 |
通过eval
关键字指明导入lua
,在redis收到lua指令时,会把整个lua指令作为一个整体,比如上面的set和expire之间不会有其他客户端请求的指令,一定是set和expire一起做完之后,其他的命令才能执行。
Redis过期策略及其淘汰机制
过期策略
Redis的过期策略是定期删除+惰性删除两种
定期删除+惰性删除
定期删除很好理解,每隔一段时间去执行一次删除即可。redis默认100ms
就随机抽取设置了过期时间的key,检测是否过期,过期了就删除
通过配置文件
redis.conf
中的hz
选项来调整这个次数,hz 10
表示每秒执行10次,默认为10
,建议不超过100,否则会对CPU造成极大的压力
为啥不扫描全部设置了过期时间的key呢?
全部扫描,虽然所有的key都得到了及时释放,但是整个过程十分消耗CPU,可能直接就卡死了。在大并发请求下,CPU要将时间应用在处理请求,而不是删除key。
如果一直没随机到过期key,里面不就存在大量的无效key了吗?
这个时候,惰性删除就派上了用场。惰性嘛,就是懒,我不主动删,当你来主动获取某个key的时候,redis会检查一下,如果已经过期,就直接删了不给你返回。
那万一定期没删,我也没查询,那不还是有无效key吗?
办法自然是有的,内存淘汰机制
内存淘汰机制
在配置文件redis.conf
中,通过设置 maxmemory <bytes>
来设定最大内存,不设置改参数默认是无限制的,通常会设定其为物理内存的四分之三。
我们先看下官网给出的8种内存淘汰策略:
- noeviction*:不淘汰任何数据,当内存不足时,新增操作会报错,Redis 默认内存淘汰策略;
- lkeys-lru:淘汰整个键值中最久未使用的键值
- llkeys-random:随机淘汰任意键值
- olatile-lru:淘汰所有设置了过期时间的键值中最久未使用的键值
- olatile-random:随机淘汰设置了过期时间的任意键值
- olatile-ttl:优先淘汰更早过期的键值
在Redis4.0中新增了两种淘汰机制:
- volatile-lfu:淘汰所有设置了过期时间的键值中,最少使用的键值;
- allkeys-lfu:淘汰整个键值中最少使用的键值
设置Redis淘汰策略的方式有两种:
- 通过命令
“config set maxmemory-policy 策略
可以立即修改策略,但是重启后会失效 - 修改配置文件
redis.conf
中maxmemory-policy
的值,必须重启Redis服务才能生效
当现有内存大于
maxmemory
,就会触发redis通过设置的淘汰策略释放内存,换言之不设置maxmemory
无法通过淘汰策略释放空间如果没有设置 expire 的key,不满足先决条件(prerequisites); 那么 volatile-lru,volatile-random, volatile-ttl,volatile-lfu策略的行为,和 noeviction(不删除) 基本上一致
LRU与LFU
LRU 全称是Least Recently Used
译为最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰
LFU 全称是Least Frequently Used
翻译为最不常用的,最不常用的算法是根据总访问次数来淘汰数据的,它的核心思想是”如果数据过去被访问多次,那么将来被访问的频率也更高”
具体算法内容在此不做解释
Redis的瓶颈及解决方案
官方FAQ:因为Redis是基于内存的操作,CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了(毕竟采用多线程会有很多麻烦!)
网络带宽
实际生产环境可能会出现Redis客户端与服务端部署在不同机器上的情况,因为网络传输距离的原因,会导致速度变慢,这个时候使用Pipeline
可以得到改善
机器内存
Redis的存放量与机器内存大小成正比,而且在实际生产环境中不能让Redis直接把内存打满。
设置maxmemory
调整Redis内存大小,一旦到达阙值,Redis会触发淘汰策略清理内存
PIKA
pika的定位不是为了取代redis(也做不到,毕竟一个内存,一个磁盘),只是作为一个补充,对在数据量巨大(相对内存而言)的场景下仍想使用redis的人,提供一种选择,所以我们对redis近百个命令进行了实现,虽然用kv接口封装list的做法显得很“蠢”,性能较内存list也不够高(类似pika的一些同类开源项目在这里干脆选择不做list,直接做成array或者queue),但毕竟与redis接口兼容,给合适的用户从redis迁移到pika降低成本
pika是360 DBA和基础架构组联合开发的类redis存储系统,使用Redis协议,兼容redis绝大多数命令(String,Hash,List,ZSet,Set),用户不需要修改任何代码, 就可以将服务迁移至pika。因为存储在磁盘,所以速度相对来说要比Redis慢一些,在一些应用场景可以作为Redis
的替代。