Redis 单线程模型详解

Redis 基于 Reactor 模式来设计开发了自己的一套高效的事件处理模型 (Netty 的线程模型也基于 Reactor 模式,Reactor 模式不愧是高性能 IO 的基石),这套事件处理模型对应的是 Redis 中的文件事件处理器(file event handler)。由于文件事件处理器(file event handler)是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。
Redis 通过IO 多路复用程序 来监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。
好处I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和 NIO 中的 Selector 组件很像)。
另外, Redis 服务器是一个事件驱动程序,服务器需要处理两类事件:1. 文件事件(缓存数据?); 2. 时间事件(过期?)。

文件事件(客户端进行读取写入等操作,涉及一系列网络通信)
Redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关 闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字(socket)之前关联好的事件处理器(handler?)来处理这些事件。
虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。

文件事件处理器(file event handler)主要是包含 4 个部分:

  • 多个 socket(客户端连接)
  • IO 多路复用程序(支持多个客户端连接的关键)
  • 文件事件分派器(将 socket 关联到相应的事件处理器)
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
    Redis 单线程模型详解_第1张图片

Redis 没有使用多线程?为什么不使用多线程?
虽然说 Redis 是单线程模型,但是,实际上,Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。
Redis 单线程模型详解_第2张图片

不过,Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主处理之外的其他线程来“异步处理”。

大体上来说,Redis 6.0 之前主要还是单线程处理。
那,Redis6.0 之前 为什么不使用多线程?

  1. 单线程编程容易并且更容易维护;
  2. Redis 的性能瓶颈不在 CPU ,主要在内存和网络;
  3. 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。

Redis6.0 之后为何引入了多线程?
Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。虽然,Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了,执行命令仍然是单线程顺序执行。因此,你也不需要担心线程安全问题。Redis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要修改 redis 配置文件 redis.confio-threads-do-reads yes;开启多线程后,还需要设置线程数,否则是不生效的。同样需要修改 redis 配置文件 redis.conf :io-threads 4 #官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程

过期时间除了有助于缓解内存的消耗,还有什么其他用么?
很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在 1 分钟内有效,用户登录的 token 可能只在 1 天内有效。这就需要自定义。

127.0.0.1:6379> exp key 60 # 数据在 60s 后过期
(integer) 1
127.0.0.1:6379> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)
OK
127.0.0.1:6379> ttl key # 查看数据还有多久过期
(integer) 56

Redis 是如何判断数据是否过期的呢?
Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。
Redis 单线程模型详解_第3张图片

过期字典是存储在 redisDb 这个结构里的:

typedef struct redisDb {
    ...

    dict *dict;     //数据库键空间,保存着数据库中所有键值对
    dict *expires   // 过期字典,保存着键的过期时间
    ...
} redisDb;

过期的数据的删除策略了解么?

常用的过期数据的删除策略就两个(重要!自己造缓存轮子的时候需要格外考虑的东西):

  1. 惰性删除 :只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
  2. 定期删除 : 每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。

定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性/懒汉式删除 。但仅仅如此还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就 Out of memory 了。为了解决这个问题,使用Redis 内存淘汰机制:
3. volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
4. volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
5. volatile-random:从已设置过期时间的数据集(server.db[i].expires)中随机选择数据淘汰
6. allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中移除最近最少使用的 key这个是最常用的,用的LRU算法,双向队列,每次把用到的数据放在头部,则尾部将是最少使用的数据)

4.0 版本后增加以下两种:
7. allkeys-random:从数据集(server.db[i].dict)中随机选择数据淘汰(随机选择也太傻瓜式了,应该用的少吧?)
8. no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。(这个应该没人使用吧!)

Redis 持久化机制(怎么保证 Redis 挂掉之后再重启数据可以进行恢复)

Redis 的一种持久化方式叫快照(snapshotting,RDB),另一种方式是只追加文件(append-only file, AOF)。

快照(snapshotting)持久化(RDB)
Redis 可以创建快照(存储在内存里面的数据在某个时间点上的副本)。Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。
快照持久化是 Redis 默认采用的持久化方式,在 Redis.conf 配置文件中默认有此下配置:

save 900 1           #在900(15分钟)之后(是说相对于现在这个时间点而言,即当前时间+900s判断),如果至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。

save 300 10          #在300(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。

save 60 10000        #在60(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。

AOF(append-only file)持久化
与快照持久化相比,AOF 持久化的实时性更好,因此已成为主流的持久化方案。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化,可以通过 appendonly 参数开启:

appendonly yes

开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入硬盘中的 AOF 文件(记录的是命令而不是数据,这样怎么)。AOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名是 appendonly.aof。
在 Redis 的配置文件中存在三种不同的 AOF 持久化方式:

appendfsync always    #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
appendfsync everysec  #每秒钟同步一次,显示地将多个写命令同步到硬盘(一般用这个)
appendfsync no        #让操作系统决定何时进行同步

为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec 选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能几乎没受到任何影响。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。

Redis 事务
Redis 可以通过 MULTIEXECDISCARDWATCH 等命令来实现事务(transaction)功能。使用 MULTI 命令后可以输入多个命令。Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC 命令将执行所有命令。
这个过程是这样的:

  1. 开始事务(MULTI)。
  2. 命令入队(批量操作 Redis 的命令,先进先出(FIFO)的顺序执行)。
  3. 执行事务(EXEC)。
> MULTI
OK
> SET USER "Guide哥"
QUEUED
> GET USER
QUEUED
> EXEC
1) OK
2) "Guide哥"

通过 DISCARD 命令取消一个事务,它会清空事务队列中保存的所有命令。

> MULTI
OK
> SET USER "Guide哥"
QUEUED
> GET USER
QUEUED
> DISCARD
OK

WATCH 命令用于监听指定的键,当调用 EXEC 命令执行事务时,如果一个被 WATCH 命令监视的键被修改的话,整个事务都不会执行,直接返回失败。

> WATCH USER
OK
> MULTI
> SET USER "Guide哥"
OK
> GET USER
Guide> EXEC
ERR EXEC without MULTI

Redis 的事务和我们平时理解的关系型数据库的事务不同。我们知道事务具有四大特性: 1. 原子性2. 隔离性3. 持久性4. 一致性
Redis 是不支持 roll back 的,因而不满足原子性的(而且不满足持久性)。(Redis 官网也解释了自己为啥不支持回滚。简单来说就是 Redis 开发者们觉得没必要支持回滚,这样更简单便捷并且性能更好。Redis 开发者觉得即使命令执行错误也应该在开发过程中就被发现而不是生产过程中。)你可以将 Redis 中的事务就理解为 :Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。

缓存穿透

当请求的key在数据库中本就不存在时,缓存中当然没有,就肯定会发生缓存穿透,会查询两次,而采用布隆过滤器可以解决这个问题,将所有可能存在的数据hash到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉(是因为一定不存在的数据hash值不存在于这个bitmap中?万一发生hash冲突了呢?哦,是通过数据hash后的值在bitmap里看有没有对应hash值,如果没有的话,说明hash冲突都没发生,hash值都不一样那肯定不一样了,hash值一样却不一定真的一样)。

当一个元素加入布隆过滤器中的时候,会进行哪些操作:

  1. 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
  2. 根据得到的哈希值,在位数组中把对应下标的值置为 1。
    当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行哪些操作:
  3. 对给定元素再次进行相同的哈希计算;
  4. 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。
    然后,一定会出现这样一种情况:不同的字符串可能哈希出来的位置相同。 (可以适当增加位数组大小或者调整我们的哈希函数来降低概率
    Redis 单线程模型详解_第4张图片

还有简单粗暴的方法是如果查询返回数据为空(不管是数据不存在还是系统故障),我们仍然把这个空结果进行缓存,但设置其过期时期很短,最多不超过5分钟。这样第二次缓冲获取就直接从缓存中获取空结果了。(如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key。)
一般情况下我们是这样设计 key 的: 表名:列名:主键名:主键值。Java 代码展示的话,差不多是下面这样的:

public Object getObjectInclNullById(Integer id) {
    // 从缓存中获取数据
    Object cacheValue = cache.get(id);
    // 缓存为空
    if (cacheValue == null) {
        // 从数据库中获取
        Object storageValue = storage.get(key);
        // 缓存空对象
        cache.set(key, storageValue);
        // 如果存储数据为空,需要设置一个过期时间(300秒)
        if (storageValue == null) {
            // 必须设置过期时间,否则有被攻击的风险
            cache.expire(key, 60 * 5);
        }
        return storageValue;
    }
    return cacheValue;
}

缓存雪崩

**有一些被大量访问数据(热点缓存)在某一时刻大面积失效,导致对应的请求直接落到了数据库上。**举个例子 :秒杀开始 12 个小时之前,我们统一存放了一批商品到 Redis 中,设置的缓存过期时间也是 12 个小时,那么秒杀开始的时候,这些秒杀的商品的访问直接就失效了。导致的情况就是,相应的请求直接就落到了数据库上,就像雪崩一样可怕。

解决办法:

针对 Redis 服务不可用的情况:

  1. 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。
  2. 限流,避免同时处理大量的请求。
    针对热点缓存失效的情况:
  3. 设置不同的失效时间比如随机设置缓存的失效时间(随机性使得它们在相同时间失效可能极小)。
  4. 缓存永不失效(是说等待内存不足时再把使用频率最少的淘汰吧?allkeys-lru(least recently used))。

你可能感兴趣的:(Java)