前言

Redis到底有多快?官方提供的数据中,Redis可以达到100000+的QPS(每秒内查询次数);十万多的QPS,已经非常厉害了,为啥它的速度这么快呢?总的来说是因为Redis 有五大特性以及Redis 优秀的过期策略和内存淘汰策略,下面我们来一起看一下。

Redis的五大特性

基于内存实现

Redis完全基于内存,大部分都是简单的存取操作,大量的时间花费在IO上。Redis绝大部分操作时间复杂度为 O(1) ,所以速度十分快。

高效的数据结构

Redis底层支持五种不同的数据机构,多种数据结构可以在不同场景下,支持不同的数据类型。

丰富合理的编码

Redis底层提供了丰富而合理的编码。根据长度及元素的个数的不同,五种数据结构也适配了不同的编码格式。
每种数据类型都提供了最少两种内部的编码格式(在Redis版本小于3.2的时候),而且每个数据类型内部编码方式的选择对用户是完全透明的,Redis会根据数据量自适应地选择较优化的内部编码格式。

String类型

字符串是 Redis最基本的数据结构,Redis并没有使用C语言的字符串,而是使用了简单动态字符串(SDS)。Redis 中字符串对象的编码有三种:intraw 或者 embstr

  • int 编码:保存long 型的64位有符号整数

  • embstr 编码:保存长度小于44字节的字符串

  • raw 编码:保存长度大于44字节的字符串

大家可以测试一下,通过Ohject encoding keyname命令去查看字符串所使用的编码类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
127.0.0.1:6379> set testKey 111
OK
127.0.0.1:6379> OBJECT encoding testKey
"int"
127.0.0.1:6379> set testKey testValue
OK
127.0.0.1:6379> OBJECT encoding testKey
"embstr"
127.0.0.1:6379>
127.0.0.1:6379> set testKey tesssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss
OK
127.0.0.1:6379> OBJECT encoding testKey
"raw"
127.0.0.1:6379>

List类型

网上查阅了Redis List数据结构使用的内部编码的相关资料,发现绝大部分说的都是ziplist + linkedlist,于是乎我拿着我的Redis测试了一下:

1
2
3
4
5
6
127.0.0.1:6379> rpush listKey teeeeeeeeeeeeeee dsad asda dwqwe qeq
(integer) 6
127.0.0.1:6379>
127.0.0.1:6379>
127.0.0.1:6379> OBJECT encoding listKey
"quicklist"

这个quicklist又是个啥玩意,于是乎发现问题没那么简单。

Redis < 3.2 版本的时候,当元素个数较少且没有大元素时,内部编码为ziplist;当元素个数超过512个或者某个元素超过64个字节时,内部编码为 linkedlist 编码。

Redis >= 3.2 后,Redis 重新引入了一个 quicklist 的数据结构,列表的底层都由quicklist实现

quicklist 实际上是 zipListlinkedList 的混合体,它将 linkedList 按段切分,每一段使用 zipList 来紧凑存储,多个 zipList 之间使用双向指针串接起来。

所以说,Redis 在 3.2 版本之后,List数据结构使用的内部编码为 quicklist;所以啪啪打脸了前面说的Redis每种数据类型都提供了最少两种内部的编码格式。

Hash类型

Hash也是 Redis 使用非常频繁的数据结构,Redis 中hash结构的编码有两种:ziplisthashtable

  • ziplist 编码:当哈希类型元素个数小于hash-max-ziplist-entries配置(默认512个),同时所有值都小于hash-max-ziplist-value配置(默认64个字节)时,Redis会使用ziplist作为哈希的内部实现。
    ziplist使用更加紧凑的结构实现多个元素的连续存储,所以在节省内存方面比hashtable更加优秀。
  • hashtable 编码:当哈希类型无法满足ziplist的条件时,Redis会使用hashtable作为哈希的内部实现。因为此时ziplist的读写效率会下降,而hashtable的读写时间复杂度为O(1)。
1
2
3
4
5
6
7
8
9
10
11
127.0.0.1:6400> hset hKey fieldName testValue
(integer) 1
127.0.0.1:6400> Object encoding hKey
"ziplist"
127.0.0.1:6400>
127.0.0.1:6400> hset hKey fieldName testValuerrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrppppppppppppppppppppppppppppppp
(integer) 0
127.0.0.1:6400>
127.0.0.1:6400> Object encoding hKey
"hashtable"
127.0.0.1:6400>

Set类型

Redis 中 Set 结构的编码也有两种:intsethashtable

  • intset(整数集合)编码:当集合中的元素都是整数且元素个数小于set-max-intset-entries配置(默认512个)时,Redis会选用intset来作为集合内部实现,从而减少内存的使用。

  • hashtable(哈希表)编码:当集合类型无法满足intset的条件时(比如当元素个数超过512个或某个元素不为整数时),Redis会使用hashtable作为集合的内部实现。

1
2
3
4
5
6
7
8
9
10
11
127.0.0.1:6400> sadd setKey 2 3 4 5
(integer) 4
127.0.0.1:6400>
127.0.0.1:6400> Object encoding setKey
"intset"
127.0.0.1:6400>
127.0.0.1:6400> sadd setKey 2 3 4 5 sdasafaf
(integer) 1
127.0.0.1:6400> Object encoding setKey
"hashtable"
127.0.0.1:6400>

Zset类型

Redis 中 Zset 结构的编码也有两种:ziplistskiplist

  • ziplist(压缩列表)编码:当有序集合的元素个数小于zset-max-ziplist-entries配置(默认128个)同时每个元素的值小于zset-max-ziplist-value配置(默认64个字节)时,Redis会用ziplist来作为有序集合的内部实现,ziplist可以有效减少内存使用。

  • skiplist(跳跃表)编码:当ziplist条件不满足时,有序集合会使用skiplist作为内部实现,因为此时ziplist的读写效率会下降。

1
2
3
4
5
6
7
8
9
10
11
127.0.0.1:6400> zadd zsetKey 50 a 60 b 30 c
(integer) 3
127.0.0.1:6400>
127.0.0.1:6400> Object encoding zsetKey
"ziplist"
127.0.0.1:6400>
127.0.0.1:6400> zadd zsetKey 50 a 60 b 30 ceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
(integer) 1
127.0.0.1:6400> Object encoding zsetKey
"skiplist"
127.0.0.1:6400>

单线程

Redis的单线程是指处理网络请求只有一个线程。那么Redis的核心网络模型为什么选择用单线程来实现呢?

Redis 官方回答的核心意思是:对于一个 DB 来说,CPU 通常不会是瓶颈,因为大多数请求不会是 CPU 密集型的,而是 I/O 密集型;如果不考虑 RDB/AOF 等持久化方案,Redis 是完全的纯内存操作,执行速度是非常快的,因此这部分操作通常不会是性能瓶颈,Redis 真正的性能瓶颈在于网络 I/O,也就是客户端和服务端之间的网络传输延迟,因此 Redis 选择了单线程的 I/O 多路复用来实现它的核心网络模型。

实际上更加具体的选择单线程的原因可以归结为以下三点:

(1)避免过多的上下文切换开销;

(2)避免同步机制的开销;

(3)简单可维护。

也因此,对于单线程来讲就不存在上下文切换问题,也不用考虑锁的问题,不存在加锁释放锁的操作,也就没有因为可能出现死锁而导致的性能消耗。

虽然单线程无法发挥出多个CPU的性能,但是可以在单机开启多个Redis实例解决这个问题;但实际上,为了保证高可用,线上业务一般不太可能会是单机模式,更加常见的是利用 Redis 分布式集群多节点和数据分片负载均衡来提升性能和保证高可用。

非阻塞IO、多路IO复用模型

Redis采用多路IO复用模型,在内部采用epoll代理。多路是指多个网络连接,IO复用是指复用同一个线程。epoll会同时监察多个流的IO事件,在空闲时,当前线程进入阻塞,如果有IO事件时,线程会被唤醒,并且epoll会通知线程是哪个流发生了IO事件,然后按照顺序处理,减少了网络IO的时间消耗,避免了大量的无用操作。

Redis 6.0后引入多线程提速

前面提到 Redis 最初选择单线程网络模型的理由是:CPU 通常不会成为性能瓶颈,瓶颈往往是内存网络,因此单线程足够了。那么为什么现在 Redis 又要引入多线程呢?很简单,就是 Redis 的网络 I/O 瓶颈已经越来越明显了。

随着互联网的飞速发展,互联网业务系统所要处理的线上流量越来越大,Redis 的单线程模式会导致系统消耗很多 CPU 时间在网络 I/O 上从而降低吞吐量,要提升 Redis 的性能有两个方向:

  • 优化网络 I/O 模块
  • 提高机器内存读写的速度

后者依赖于硬件的发展,暂时无解。所以只能从前者下手,网络 I/O 的优化又可以分为两个方向:

  • 零拷贝技术或者 DPDK 技术
  • 利用多核优势

零拷贝技术有其局限性,无法完全适配 Redis 这一类复杂的网络 I/O 场景;而 DPDK 技术通过旁路网卡 I/O 绕过内核协议栈的方式又太过于复杂以及需要内核甚至是硬件的支持。

因此,利用多核优势成为了优化网络 I/O 性价比最高的方案。

所以Redis支持多线程主要就是两个原因:
(1)可以充分利用服务器 CPU 资源,目前主线程只能利用一个核;
(2)多线程任务可以分摊 Redis 同步 IO 读写负荷。

关于多线程须知:
Redis 6.0 版本 默认多线程是关闭的 io-threads-do-reads no
Redis 6.0 版本 开启多线程后线程数也要谨慎设置。
多线程可以使得性能翻倍,但是多线程只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。

Redis 过期策略和内存淘汰策略

Redis的过期策略

Redis中key的过期策略通常有以下三种:

定时删除

Redis对每个设置过期时间的key都创建了一个定时器,到过期时间就会立即对key进行清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。

惰性删除

只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。

定期删除

每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。

expires字典会保存所有设置了过期时间的key的过期时间数据,其中 key 是指向键空间中的某个键的指针,value是该键的毫秒精度的UNIX时间戳表示的过期时间。键空间是指该Redis集群中保存的所有键。

Redis默认采用的过期策略:惰性删除 + 定期删除,在不同情况下使得CPU和内存资源达到最优的平衡效果。memcached采用的过期策略:惰性删除

6种内存淘汰策略

Redis的内存淘汰策略是指在Redis的可用内存不足时,Redis自身有什么策略去应对这种情况。

1
2
3
4
5
6
volatile-lru:内存不足时,从已设置过期时间的数据集中挑选最近最少使用的数据淘汰 
volatile-ttl:内存不足时,从已设置过期时间的数据集中挑选将要过期的数据淘汰
volatile-random:内存不足时,从已设置过期时间的数据集中任意选择数据淘汰
allkeys-lru:内存不足时,从数据集中挑选最近最少使用的数据淘汰
allkeys-random:内存不足时,从数据集中任意选择数据淘汰
no-enviction(驱逐):禁止驱逐数据,不删除的意思;当内存不足以容纳新写入数据时,新写入操作会报错。

Redis 5.0.3版本中,默认值是noeviction。不同版本会有不同的默认值,具体可以看配置文件中如何说明的。

总结:Redis的内存淘汰策略的选取并不会影响过期的key的处理。内存淘汰策略用于处理内存不足时的需要申请额外空间的数据,过期策略用于处理过期的缓存数据。

如何让Redis运行得那么地快速

前面我们已经了解到Redis为什么这么快,但是我们也要正确的使用Redis,才能将它的速度真正发挥好来,下面我们来了解一下日常使用中,Redis的注意点或者说优化点:

尽量使用短的key

由于Redis单线程的特性,操作数据过长的key的通常比较耗时,也就意味着阻塞Redis可能性越大,这样会造成客户端阻塞或者引起故障切换。还可能会产生内存空间不均匀、网络拥塞的问题,所以日常开发中key在表达其意义的基础上,尽量的短。

避免使用keys和模糊查询操作

keys *返回所有的key,该命令会引起Redis进入阻塞!慎用。

模糊查询也会导致阻塞,如果有需求可以使用SCAN命令代替。

设置key有效期

给key设置有效期可以避免很多问题,如:Redis作为缓存时的双写问题,保证缓存和数据库的一致性。另外设置缓存过去时间时不要设置为一样,否则会同时把压力压到数据库。

尽可能地使用哈希存储

持久化最好在备库做

可以减少主库写的压力。

多条命令使用管道

使用管道可以降低网络的开销。

限制Redis的内存大小

Redis在bgsvae的时候,会fork子进程,而这个操作需要拷贝父进程的空间内存页表,会耗费一定的时间。所以每个Redis实例的内存最好控制在10G以内,避免因为fork太久而导致的性能问题。

尽可能使用SSD

Redis在持久化的时候首先会写入aof buffer,再进行fsync。这时,主线程每次进行AOF会对比上次fsync成功的时间;如果距上次不到2s,主线程直接返回;如果超过2s,则主线程阻塞直到fsync同步完成。因此,如果系统硬盘负载过大导致fsync速度太慢,会导致Redis主线程的阻塞;此外,使用everysec配置,AOF最多可能丢失2s的数据,而不是1s。所以应使用SSD,来提高磁盘读写速度。

开启slowlog

开启slowlog可以帮助我们定位数据库性能问题。