带着问题去寻找答案往往会是最好的学习方法,开始探究前,先问几个关于Redis的问题:

  • Redis有哪些数据结构,其底层是什么
  • Redis持久化机制
  • Redis为什么是单进程的?为什么快?
  • Redis如何保证的原子性
  • Redis过期策略及其淘汰机制
  • Redis的瓶颈及解决方案

由简入难,一个一个的研究,对每个关键点做到知其然并知其所以然

Redis有哪些数据结构,其底层结构是什么

redis的常见的5种数据结构,分别是string(字符串)list(列表)hash(哈希)set(集合)sorted set(有序集合)。这些数据结构是暴露给外部接口调用的。

底层的数据结构有6种:robjsds(简单动态字符串)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)流程图:

E5CAEE8F-32FB-4A33-A09E-39633394A7D5

结合上图,总结一下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即可。我们还是通过图来了解这个流程:

image-20221007193840977

Redis 4.0版本后AOF支持了混合持久化,加载AOF文件需要考虑版本兼容性,所以回复数据流程发生了变化:

image-20221007203352211

在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同时进货了,此时程序运行流程大概是这样的:

image-20221008081240945

  • A 获取stock的值为100

  • B 获取stock的值为100

  • A 设置stock的值为101

  • B 设置stock的值为101

整个流程下来,我们发现库存丢了一个。整个操作并没有保证原子性,最终丢失了库存

解决方式

原生命令

对于上述的增量操作,使用 incrdecr 而非直接set,对于常规 get + set 推荐使用 setnx + expire 的方式,批量设置多个值的场景可以用 mset,批量获取多个值的 mget。从命令和业务上实现原子性,避免非原子性的操作。

加锁

通过加锁的方式去保证原子性,只有获取到了锁的线程,才能执行对应的业务。获取锁的方式跟方式一一样,通过setnx命令去设置一个固定的代表锁的key,若设置成功,则代表获取锁成功,然后执行上面的getset操作;操作完成后,再将这个代表锁的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
2
3
4
5
6
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
(integer) 1
127.0.0.1:6379> get name
"sun"
127.0.0.1:6379> ttl name
(integer) 56

通过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.confmaxmemory-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的替代。