1. Redis 基础入门

1.1. Redis 介绍

redis 是一种基于键值对(key-value)数据库,其中 value 可以为 string、hash、list、set、zset 等多种数据结构,可以满足很多应用场景。还提供了键过期,发布订阅,事务,流水线等附加功能

流水线:Redis 的流水线功能允许客户端一次将多个命令请求发送给服务器,并将被执行的多个命令请求的结果在一个命令回复中全部返回给客户端,使用这个功能可以有效地减少客户端在执行多个命令时需要与服务器进行通信的次数

1.2. Redis 优缺点

优点:

  1. 基于内存操作,速度快官方给出的读写性能 10 万/S,与机器性能也有关):
    • 数据放内存中是速度快的主要原因
    • C 语言实现,与操作系统距离近
    • 使用了单线程架构,预防多线程可能产生的竞争问题
  2. 键值对的数据结构服务器,支持多种数据类型:包括 String、Hash、List、Set、ZSet 等,并且每种数据类型底层都做了优化,让读取数据的速度更快。
  3. 丰富的功能:键过期,发布订阅,事务,流水线等等
  4. Reids 是单线程:简单稳定,避免线程切换开销及多线程的竞争问题。单线程是指网络请求使用一个线程来处理,即一个线程处理所有网络请求,但 Redis 运行时不止有一个线程,比如数据持久化的过程会另起线程。Redis 6.0 后开始支持多线程
  5. 支持持久化:有 RDB 和 AOF 两种持久化机制,将数据持久化到硬盘,以有效地避免发生断电或机器故障,数据可能会丢失的问题。
  6. 支持主从复制:实现多个相同数据的 redis 副本
  7. 支持高可用和分布式:哨兵机制实现高可用,保证 redis 节点故障发现和自动转移
  8. 支持事务:所有操作都是原子性的,同时还支持对几个操作合并后的原子性执行。
  9. I/O 多路复用模型:Redis 采用 I/O 多路复用技术。使用单线程来轮询描述符,将数据库的操作都转换成了事件,不在网络I/O上浪费过多的时间。
  10. 客户端语言多:java php python c c++ nodejs 等

缺点:

  1. 对结构化查询的支持比较差。
  2. 数据库容量受到物理内存的限制,不适合用作海量数据的高性能读写,因此 Redis 适合的场景主要局限在较小数据量的操作。
  3. Redis 较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂。

1.3. redis 应用场景

  • 缓存热点数据(最多使用):关系型数据库作为存储层,其吞吐能力有限,由于 Redis 具有支撑高并发的特性,所以使用 Redis 保存一些热点数据进行缓存,如数据查询、短连接、新闻内容、商品内容等等。请求数据时合理使用缓存,通常能起到加速数据的读写速度和降低后端数据库压力的作用。
  • 计数:利用 Redis 原子性的自增操作,可以实现快速计数、查询缓存的功能,同时数据可以异步落地到其他数据源。如:视频网站播放数,网站浏览数统计、统计用户点赞数、用户访问数等。
  • 分布式集群架构中共享/存储 Session 或 token:一个分布式 Web 服务将用户的 Session 信息(例如用户登录信息)保存在各自服务器中,当使用负载均衡时,分布式服务会将用户的访问均衡到不同服务器上,用户刷新一次访问可能会请求到没有保存登陆 session 信息的服务,此时会出现需要重新登录的情况,这种操作对于用户十分不友好。为了解决多个服务器共享 Session 的问题,可以使用 Redis 将用户的 Session 进行集中管理,在这种模式下只要保证 Redis 是高可用和扩展性的,每次用户更新或者查询登录信息都直接从 Redis 中集中获取。
  • 限速器:一般用于安全的考虑或者资源的控制,会在每次进行登录时,让用户输入手机验证码,从而确定是否是用户本人。但是为了短信接口不被频繁访问,会限制用户每分钟获取验证码的频率,例如一分钟不能超过 5 次。一些网站限制一个 IP 地址不能在一秒钟之内方问超过 n 次也可以采用类似限速的思路。Redis 可用于限制某个用户访问某个接口的频率,比如秒杀场景用于防止用户快速点击带来不必要的压力。
  • 应用排行榜:按照热度排名,按照发布时间排行,主要用到列表和有序集合。
  • 社交网络好友关系:利用集合的一些命令,如交集、并集、差集等,实现共同好友、共同爱好、赞、踩、粉丝、下拉刷新、聊天室的在线好友列表等功能。
  • 简单的消息队列:可以使用 Redis 自身的发布/订阅模式或者 List 数据结构来实现简单的消息队列,实现异步操作。如:秒杀、抢购、12306等等
  • 数据过期处理,可以精确到毫秒。

1.4. 重大版本

  1. 版本号第二位为奇数,为非稳定版本(2.7、2.9、3.1)
  2. 第二为偶数,为稳定版本(2.6、2.8、3.0)
  3. 当前奇数版本是下一个稳定版本的开发版本,如 2.9 是 3.0 的开发版本

1.5. Redis 与 Memcached 的区别

  • Redis 是单线程的,只使用单核;而 Memcached 是支持多线程,可以使用多核。
  • Redis 支持多种数据类型,提供 string,list,set,zset,hash 等数据结构的存储;而 MemCached 数据结构单一,只支持简单数据类型,仅用来缓存数据,需要客户端自己处理复杂对象。
  • Redis 支持数据持久化,宕机重启后,将自动加载宕机时刻的数据到缓存中,具有更好的灾备机制;MemCached 不支持数据持久化,重启后数据会消失。
  • Redis 提供主从同步机制和 cluster 集群部署能力,能够提供高可用服务;Memcached 没有提供原生的集群模式,需要依靠客户端实现往集群中分片写入数据(使用 Magent 在客户端进行一致性 hash 做分布式)。
  • Redis 的速度比 Memcached 快很多。
  • Redis 使用单线程的多路 IO 复用模型;Memcached 使用多线程的非阻塞 IO 模型
  • Redis 的 Key 长度支持到 512k;Memcached 最大键的长度为 250 个字符,可以接受的储存数据不能超过 1MB(可修改配置文件变大)。
  • 内存管理区别:
    • Memcached 内存管理:使用 Slab Allocation。原理相当简单,预先分配一系列大小固定的组,然后根据数据大小选择最合适的块存储。避免了内存碎片。(缺点:不能变长,浪费了一定空间)memcached 默认情况下下一个 slab 的最大值为前一个的 1.25 倍。
    • Redis 内存管理:通过定义一个数组来记录所有的内存分配情况,Redis 采用的是包装的 malloc/free,相较于 Memcached 简单很多。由于 malloc 首先以链表的方式搜索已管理的内存中可用的空间分配,导致内存碎片比较多。
  • Redis 使用的是单线程模型,保证了数据按顺序提交;Memcache 需要使用 CAS 保证数据一致性(乐观锁)。

1.6. Redis 相关资料

2. Redis 数据结构介绍

Redis 是一种高级的 Key-Value 的存储系统,其中 Value 支持多种类型的数据结构:

  1. 字符串(String):二进制安全的字符串,
  2. 散列(Hash):由field和关联的value组成的map数据结构。fieldvalue都是字符串的。
  3. 列表(List):按插入顺序排序的字符串元素的集合。基本上就是链表(linked lists)。
  4. 集合(Set):不重复且无序的字符串元素的集合。
  5. 有序集合(SortedSet):类似Set,但是每个字符串元素都关联到一个叫score浮动数值(floating number value)。里面的元素总是通过score进行着排序,所以不同的是,它是可以检索的一系列元素。
  6. Bit arrays (或者说 simply bitmaps):通过特殊的命令,可以将 String 值当作一系列 bits 处理:可以设置和清除单独的 bits,数出所有设为 1 的 bits 的数量,找到最前的被设为 1 或 0 的 bit,等等。
  7. HyperLogLogs:这是被用于估计一个 set 中元素数量的概率性的数据结构。

2.1. Redis keys(键)

Redis key值是二进制安全的,这意味着可以用任何二进制序列作为key值,如foo的简单字符、一个JPEG文件的内容、空字符串都是有效key值。关于key的定义,需要注意的几点:

  1. key不要太长,最好不要操作1024个字节,这不仅会消耗内存还会降低查找效率
  2. key不要太短,如果太短会降低key的可读性
  3. 在项目中,key最好有一个统一的命名规范,如:项目名_模块名_存储内容=""业务名:表名:id

2.2. String(字符串类型)

这是最简单 Redis 类型。值可以是任何种类的字符串(包括二进制数据),例如可以在一个键下保存一副 jpeg 图片。但值的长度不能超过 512MB。

2.3. Hash(哈希)

Redis 的 Hashes(哈希)类型,类似的Java中的哈希类型,数据结构,但是要注意,哈希类型中的映射关系叫作 field-value,注意这里的 value 是指 field 对应的值,不是键对应的值。

2.4. Set(无序去重集合类型)

Redis 的 Set 类型,无序去重的集合。Set 提供了交集、并集等方法。对于实现共同好友、共同关注等功能特别方便

2.5. List(有序可重复集合类型)

Redis 的 List 类型,有序可重复的集合,底层是依赖双向链表实现的。

2.6. SortedSet(有序去重集合类型)

Redis 的 SortedSet 类型,相当于可排序的 Set 集合,内部维护了一个 score 的参数来实现排序。适用于排行榜和带权重的消息队列等场景

2.6.1. 有序集合底层实现数据结构

有序集合是由 ziplist (压缩列表)skiplist (跳跃表) 组成的。

  • 压缩列表 ziplist 本质上就是一个字节数组,是 Redis 为了节约内存而设计的一种线性数据结构,可以包含多个元素,每个元素可以是一个字节数组或一个整数。
  • 跳跃表 skiplist 是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表支持平均 O(logN)、最坏 O(N) 复杂度的节点查找,还可以通过顺序性操作来批量处理节点。

当数据比较少时,有序集合是以压缩列表 ziplist 存储的(反之则以跳跃表 skiplist 存储),使用压缩列表存储必满足以下两个条件:

  1. 有序集合保存的元素个数要小于 128 个;
  2. 有序集合保存的所有元素成员的长度都必须小于 64 字节。

如果不能满足以上两个条件中的任意一个,有序集合将会使用跳跃表 skiplist 结构进行存储。

2.6.2. 跳表插入数据的过程

2.6.2.1. 随机层数

在理解跳跃表的添加流程前,需要了解一个概念:节点的随机层数

所谓的随机层数指的是每次添加节点之前,会先生成当前节点的随机层数,根据生成的随机层数来决定将当前节点存在几层链表中。

2.6.2.2. 为什么这样设计

此设计的目的是为了保证 Redis 的执行效率。哪么为什么要生成随机层数,而不是制定一个固定的规则,比如上层节点是下层跨越两个节点的链表组成,如下图所示:

如果制定了规则,那么就需要在添加或删除时,为了满足其规则,做额外的处理,比如添加了一个新节点,如下图所示:

这样就不满足制定的上层节点跨越下层两个节点的规则了,就需要额外的调整上层中的所有节点,这样程序的效率就降低了,所以使用随机层数,不强制制定规则,这样就不需要进行额外的操作,从而也就不会占用服务执行的时间了。

2.6.2.3. 添加元素的流程

Redis 中跳跃表的添加流程如下图所示:

  1. 第一个元素添加到最底层的有序链表中(最底层存储了所有元素数据)。
  2. 第二个元素生成的随机层数是 2,所以再增加 1 层,并将此元素存储在第 1 层和最低层。
  3. 第三个元素生成的随机层数是 4,所以再增加 2 层,整个跳跃表变成了 4 层,将此元素保存到所有层中。
  4. 第四个元素生成的随机层数是 1,所以把它按顺序保存到最后一层中即可。

其他新增节点以此类推。

2.7. Redis 集合类型存储数据的特点

2.7.1. 共同点

对于集合类型(List/Set/SortSet),有如下共同点:

  • 如果元素都没有,那么这个 key 自动从 Redis 中删除
  • 如果强行删除 key,那么原来的所有 value 也会被删除

2.7.2. SortedSet 和 List 异同点

相同点:

  1. 都是有序的
  2. 都可以获得某个范围内的元素

不同点:

  1. 列表基于链表实现,获取两端元素速度快,访问中间元素速度慢;有序集合基于散列表和跳跃表实现,访问中间元素时间复杂度是 OlogN
  2. 列表不能简单的调整某个元素的位置;有序列表可以调整,只需要更改元素的分数
  3. 有序集合更耗内存

2.8. Redis 特殊的数据类型

  • Bitmap:位图,可以认为是一个以位为单位数组,数组中的每个单元只能存0或者1,数组的下标在 Bitmap 中叫做偏移量。Bitmap 的长度与集合中元素个数无关,而是与基数的上限有关。
  • Hyperloglog:是用来做基数统计的算法,其优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。典型的使用场景是统计独立访客。
  • Geospatial:主要用于存储地理位置信息,并对存储的信息进行操作,适用场景如定位、附近的人等。

3. Jedis(Java 操作 Redis)

3.1. Jedis 概述

Jedis 就是 Java 语法操作 Redis 的技术,类似于JDBC

3.2. Java 连接 Redis

  • 导入jar包
    • commons-pool2-2.3.jar
    • jedis-2.7.0.jar

3.3. Jedis 类相关方法

3.3.1. 构造方法

1
2
3
4
Jedis(String host, String port);
// 获取Jedis对象
// host:Redis服务器ip地址
// port:Redis服务器端口

3.3.2. 常用方法

1
2
3
4
5
String set(String key, String value);
// 设置键值,成功返回“ok”

String get(String key, String value);
// 根据键获取值

3.4. Jedis 连接池配置对象

  • 构造方法
1
JedisPoolConfig config = new JedisPoolConfig();
  • 常用设置初始参数方法
1
2
3
4
5
void setMaxTotal(int maxTotal);
// 设置连接池最大连接数,参数为int类型

void setMaxWaitMillis(long maxWaitMillis);
// 设置最大等待时间,参数为long类型毫秒值

3.5. JedisPool 连接池对象

  • 构造方法
1
2
3
4
JedisPool(JedisPoolConfig poolConfig, String host, int port);
// poolConfig:连接池配置对象,需要设置相关初始化参数
// host:Redis数据库ip地址
// port:Redis数据库端口
  • JedisPool 常用方法
1
2
Jedis getResource();
// 获取Jedis对象

3.6. 单实例与 Jedis 连接池连接

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package lessonDemo;

import org.junit.Test;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

public class TestJedis {
    // 单例连接
    @Test
    public void testJedis() {
        // 设置ip地址和端口,获取jedis对象
        Jedis jedis = new Jedis("192.168.34.128", 6379);

        // 设置数据
        String n = jedis.set("gender", "man");
        System.out.println(n);
        // 获取值
        String value = jedis.get("gender");
        System.out.println(value);
        // 释放资源
        jedis.close();
    }

    // 连接池连接
    @Test
    public void testJedisPool() {
        // 获取连接池配置对象,设置配置项
        JedisPoolConfig config = new JedisPoolConfig();

        // 最大连接数
        config.setMaxTotal(30);
        // 最大空闲连接数
        config.setMaxIdle(10);

        // 获取连接池
        JedisPool jedisPool = new JedisPool(config, "192.168.34.128", 6379);

        // 获取jedis对象
        Jedis jedis = jedisPool.getResource();
        // 设置数据
        jedis.set("java", "kaka2");
        System.out.println(jedis.get("java"));

        // 释放资源
        jedis.close();
        jedisPool.close();
    }
}

3.7. Jedis 连接池工具类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.moonzero.utils;

import java.util.ResourceBundle;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

/**
 * Jedis连接工具类
 */
public class JedisUtil {
    // 设置静态连接池成员变量
    // 最大连接数
    private static String maxTotal;
    // 最大等待时间
    private static String maxWaitMillis;
    // Redis数据库ip地址
    private static String host;
    // Redis数据库端口号
    private static String port;
    // 定义连接池对象
    private static JedisPool pool;

    // 静态代码块
    static {
        // 创建ResourceBundle对象,读取redis.properites配置文件
        ResourceBundle rb = ResourceBundle.getBundle("jedis");
        maxTotal = rb.getString("maxTotal");
        maxWaitMillis = rb.getString("maxWaitMillis");
        host = rb.getString("host");
        port = rb.getString("port");

        // Jedis配置对象,设置初始参数
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(Integer.parseInt(maxTotal));
        config.setMaxWaitMillis(Long.parseLong(maxWaitMillis));

        // 获取Jedis连接池对象
        pool = new JedisPool(config, host, Integer.parseInt(port));
    }

    // 获取Jedis连接对象方法
    public static Jedis getJedis() {
        return pool.getResource();
    }
}

配置文件jedis.properties

1
2
3
4
maxTotal=10
maxWaitMillis=2000
host=192.168.34.128
port=6379

4. 持久化机制

4.1. 概述

Redis 的高性能是由于其将所有数据都存储在了内存中,为了使 Redis 在重启之后仍能保证数据不丢失,需要将数据从内存中同步到硬盘中,这一过程就是持久化。

Redis 支持两种方式的持久化机制,RDB方式与AOF方式。可以单独使用其中一种或将二者结合使用。

  1. RDB 持久化(默认支持,无需配置):该机制是指在指定的时间间隔内将内存中的数据集快照写入磁盘。
  2. AOF 持久化:该机制将以日志的形式记录服务器所处理的每一个写操作,在 Redis 服务器启动之初会读取该文件来重新构建数据库,以保证启动后数据库中的数据是完整的。
  3. 无持久化:可以通过配置的方式禁用 Redis 服务器的持久化功能,即将 Redis 视为一个功能加强版的 memcached。

4.2. RDB

4.2.1. RDB 概述

RDB 持久化(Redis DataBase 缩写快照),在指定的时间间隔内把当前进程数据生成快照(.rdb)文件保存到硬盘的过程。Redis 启动时会读取 RDB 快照文件,将数据从硬盘载入内存。通过 RDB 方式的持久化,一旦 Redis 异常退出,就会丢失最近一次持久化以后更改的数据。类似于内存快照

4.2.2. RDB 触发方式

RDB 持久化有手动触发和自动触发的两种方式。

4.2.2.1. 手动触发

手动触发有 savebgsave 两命令

  • save 命令:阻塞当前 Redis 主线程,直到 RDB 持久化过程完成为止,若内存实例比较大会造成长时间阻塞,线上生产环境不建议使用。
  • bgsave 命令:redis 进程执行 fork 操作创建子线程,由子线程完成持久化,阻塞时间很短(微秒级),是 save 的优化,在执行 redis-cli shutdown 关闭 redis 服务时,如果没有开启 AOF 持久化,会自动执行 bgsave。显然 bgsave 是对 save 的优化。
4.2.2.2. 自动触发

根据配置规则进行自动快照,如 SAVE 100 10,100秒内至少有10个键被修改则进行快照。如果从节点执行全量复制操作,主节点会自动执行 BGSAVE 生成 RDB 文件并发送给从节点。默认情况下执行 shutdown 命令关闭 Reids 时,如果没有开启 AOF 持久化功能则自动执行 BGSAVE 命令。

具体配置操作如下:

  • 修改配置文件 redis.conf,配置快照参数
1
2
3
save 900 1     # 每900秒(15分钟)至少有1个key发生变化,则dump内存快照。
save 300 10    # 每300秒(5分钟)至少有10个key发生变化,则dump内存快照
save 60 10000  # 每60秒(1分钟)至少有10000个key发生变化,则dump内存快照

  • 设置保存位置设置

4.2.3. bgsave 持久化流程

bgsave 是主流的触发 RDB 持久化的方式,执行过程如下:

  1. 执行 BGSAVE 命令。
  2. Redis 父进程判断当前是否存在正在执行的子进程,如果存在,BGSAVE 命令直接返回。
  3. 父进程执行 fork 操作创建子进程,fork 操作过程中父进程会阻塞。
  4. 父进程 fork 完成后,父进程继续接收并处理客户端的请求,而子进程开始将内存中的数据写进硬盘的临时文件。
  5. 当子进程写完所有数据后会用该临时文件替换旧的 RDB 文件。

4.2.4. bgsave 如何实现快照的时候允许数据修改

主要是利用 bgsave 的子线程实现的,具体操作如下:

  • 如果主线程执行读操作,则主线程和 bgsave 子进程互相不影响;
  • 如果主线程执行写操作,则被修改的数据会复制一份副本,然后 bgsave 子进程会把该副本数据写入 RDB 文件,在这个过程中,主线程仍然可以直接修改原来的数据。

4.2.5. RDB 文件备份与恢复操作

备份操作

1
2
3
4
127.0.0.1:6379> config set dir /usr/local
ok

127.0.0.1:6379> bgsave

上述命令的含义是,先设置 rdb 文件保存路径,然后执行持久化后,将 dump.rdb 保存到 usr/local 下

恢复操作:将 dump.rdb 放到 redis 安装目录与 redis.conf 同级目录,重启 redis 即可。

4.2.6. RDB 优劣分析

优势

  1. 一旦采用该方式,那么整个 Redis 数据库将只包含一个文件(dump.rdb),这对于文件备份而言是非常完美的。比如可能打算每个小时归档一次最近24小时的数据,同时还要每天归档一次最近30天的数据。通过这样的备份策略,一旦系统出现灾难性故障,可以非常容易的进行恢复。
  2. 对于备份、全量复制、灾难恢复而言,RDB 是非常不错的选择。因为可以非常轻松的将一个单独的二进制文件压缩后再转移到其它存储介质上。
  3. 性能最大化。对于 Redis 的服务进程而言,在开始持久化时,它唯一需要做的只是fork(分叉)出子进程,之后再由子进程完成这些持久化的工作,这样就可以极大的避免服务主进程执行IO操作了。
  4. 恢复数据效率高。相比于 AOF 机制,如果数据集很大,加载 RDB 恢复数据效率远快于 AOF 方式。

劣势

  1. 无法做到实时持久化,因为系统一旦在定时持久化之前出现宕机现象,此前没有来得及写入磁盘的数据都将丢失。如果想保证数据的高可用性,即最大限度的避免数据丢失,那么 RDB 将不是一个很好的选择。
  2. 由于 RDB 是通过 fork 子进程来协助完成数据持久化工作的,每次都要创建子进程,频繁操作成本过高。因此,如果当数据集较大时,可能会导致整个服务器停止服务几百毫秒,甚至是1秒钟
  3. RDB 文件使用特定二进制格式保存,Redis 版本升级过程中有多个格式的 RDB 版本,会存在老版本 Redis 无法兼容新版 RDB 格式的问题。

4.3. AOF

4.3.1. AOF 概述

针对 RDB 不适合实时持久化,redis 提供了 AOF 持久化(Append Only File)方式。以独立日志的方式记录每次写命令,Redis 重启时会重新执行 AOF 文件中的命令达到恢复数据的目的

AOF 的主要作用是解决了数据持久化的实时性,AOF 是 Redis 持久化的主流方式。开启 AOF 方式持久化后每执行一条写命令,Redis 就会将该命令写进 aof_buf 缓冲区,AOF 缓冲区根据对应的策略向硬盘做同步操作。类似于追加日志文件 binlog

4.3.2. AOF 配置详解

4.3.2.1. 开启 AOF

默认情况下 Redis 没有开启 AOF 方式的持久化,通过 appendonly 参数启用。

  • 修改 redis.conf 设置文件,修改appendonly yes (旧的版本默认 AOF 处于关闭,为 no;在 Redis 6.0 之后已经默认是开启)

1
appendonly yes # 启用 aof 持久化方式
4.3.2.2. AOF 的同步策略选择

通过 appendfsync 参数设置同步策略(时机),可选值如下:

  • always:主线程调用 write 执行写操作后,后台线程( aof_fsync 线程)立即会调用 fsync 函数同步 AOF 文件(刷盘),fsync 完成后线程返回,这样会严重降低 Redis 的性能(write + fsync)。
  • everysec:默认策略,线程调用 write 执行写操作后立即返回,由后台线程( aof_fsync 线程)每秒钟调用 fsync 函数(系统调用)同步一次 AOF 文件(write+fsync,fsync间隔为 1 秒)
  • no:主线程调用 write 执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(write 但不 fsync,fsync 的时机由操作系统决定)。

默认情况下系统每30秒会执行一次同步操作。为了防止缓冲区数据丢失,可以在 Redis 写入 AOF 文件后主动要求系统将缓冲区数据同步到硬盘上。

修改 redis.conf 设置文件,设置 appendfsync 参数

1
2
3
# appendfsync always # 每收到写命令就立即强制写入磁盘,最慢的,但是保证完全的持久化,不推荐使用
appendfsync everysec # 每秒强制写入磁盘一次,性能和持久化方面做了折中,推荐
# appendfsync no # 完全依赖 os,性能最好,持久化没保证(操作系统自身的同步)
4.3.2.3. aof 文件名称配置

默认文件名:appendfilename "appendonly.aof"。可以修改为指定文件名称

4.3.3. AOF 重写

当 AOF 变得太大时,Redis 能够在后台自动重写 AOF 产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。

AOF 重写(rewrite) 是一个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序无须对现有 AOF 文件进行任何读入、分析或者写入操作。

由于 AOF 文件记录每次操作命令,因此会比 RDB 文件大的多。AOF 会记录对同一个 key 的多次写操作,但只有最后一次写操作才有意义。Redis 提供了 AOF 文件重写功能,用最少的命令达到相同效果。由于 AOF 重写会进行大量的写入操作,为了避免对 Redis 正常处理命令请求造成影响,Redis 将 AOF 重写程序放到子进程里执行。

AOF 文件重写期间,Redis 还会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。

Tips: Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。7.0 版本之后,AOF 重写机制得到了优化改进。

可以通过命令方式与配置方式,开启 AOF 重写功能

  1. 命令方式手动开启:
1
BGREWRITEAOF
  1. 修改 redis.conf 文件以下配置项,让程序自动决定触发时机,Redis 也会在触发阈值时自动去重写 AOF 文件。
  • auto-aof-rewrite-min-size:如果 AOF 文件大小小于该值,则不会触发 AOF 重写。默认值为 64 MB。
  • auto-aof-rewrite-percentage:执行 AOF 重写时,当前 AOF 大小(aof_current_size)和上一次重写时 AOF 大小(aof_base_size)的比值。如果当前 AOF 文件大小增加了这个百分比值,将触发 AOF 重写。将此值设置为 0 将禁用自动 AOF 重写。默认值为 100。

redis.conf 配置示例:

1
2
3
no-appendfsync-on-rewrite yes # 正在导出 rdb 快照的过程中,要不要停止同步 aof
auto-aof-rewrite-percentage 100 # aof 文件大小比起上次重写时的大小,增长率100%时,触发重写
auto-aof-rewrite-min-size 64mb # aof 文件大小至少超过 64M 时,触发重写

4.3.4. AOF 流程说明

AOF 持久化执行流程:命令写入(append)、文件写入(write)、文件同步(sync)、文件重写(rewrite)、重启加载(load)

  1. 命令追加(append):所有的写入命令(如:sethset 等)会 append(追加)到 aof_buf(缓冲区)中
  2. 文件写入(write):将 aof_buf(缓冲区)的数据写入到 AOF 文件中。这一步需要调用 write 函数(系统调用),将数据写入到了系统内核缓冲区之后直接返回了(延迟写)。注意!!!此时并没有同步到磁盘
  3. 文件同步(fsync):AOF 缓冲区根据对应的策略(fsync 策略),向硬盘做 sync(同步)操作。这一步需要调用 fsync 函数(系统调用),fsync 针对单个文件操作,对其进行强制硬盘同步,fsync 将阻塞直到写入磁盘完成后返回,保证了数据持久化。
  4. 文件重写(rewrite):随着 AOF 文件越来越大,需要定期对 AOF 文件进行 rewrite(重写),达到压缩文件体积的目的。AOF 文件重写是把 Redis 进程内的数据转化为写命令同步到新 AOF 文件的过程。
  5. 重启加载(load):当 Redis 服务器重启时,可以 load(加载)AOF 文件进行数据恢复。

Tips: Linux 系统直接提供了一些函数用于对文件和设备进行访问和控制,这些函数被称为系统调用(syscall)

4.3.5. AOF 恢复操作

  1. 设置 appendonly yes
  2. 将 appendonly.aof 文件放到 dir 参数指定的目录
  3. 启动 Redis,Redis 会自动加载 appendonly.aof 文件

4.3.6. AOF 优劣分析

优势

  1. 该机制可以带来更高的数据安全性,即数据持久性。Redis中提供了3中同步策略,即每秒同步、每修改同步和不同步
    • 每秒同步是异步完成的,其效率也是非常高的,区别只是一旦系统出现宕机现象,那么这一秒钟之内修改的数据将会丢失。
    • 每修改同步,可以将其视为同步持久化,即每次发生的数据变化都会被立即记录到磁盘中。可以预见,这种方式在效率上是最低的。
    • 无同步,完全依赖操作系统自身的同步,因此持久化没保证,但性能最好。
  2. 由于该机制对日志文件的写入操作采用的是 append-only 模式,所以没有磁盘寻址的开销,写入性能非常高。即使在写入过程中即使出现宕机现象,也不会破坏日志文件中已经存在的内容。然而如果本次操作只是写入了一半数据就出现了系统崩溃问题,也可以在 Redis 下一次启动之前,可以通过 redis-check-aof 工具来帮助解决数据一致性的问题。
  3. 如果日志过大,Redis 可以自动启用 rewrite 机制。即 Redis 以 append 模式不断的将修改数据写入到老的磁盘文件中,同时 Redis 还会创建一个新的文件用于记录此期间有哪些修改命令被执行。因此在进行 rewrite 切换时可以更好的保证数据安全性。AOF 文件没被 rewrite 之前(文件过大时会对命令进行合并重写),可以删除其中的某些命令(比如误操作的 flushall )
  4. AOF 包含一个格式清晰、易于理解的日志文件用于记录所有的修改操作。事实上也可以通过该文件完成数据的重建。

劣势

  1. 对于相同数量的数据集而言,AOF 文件通常要大于 RDB 文件,且恢复速度慢。
  2. 根据同步策略的不同,AOF 在运行启动效率上往往会慢于 RDB。总之,每秒同步策略的效率是比较高的,同步禁用策略的效率和 RDB 一样高效。

4.3.7. Multi Part AOF 机制

从 Redis 7.0.0 开始,Redis 使用了 Multi Part AOF 机制。即将原来的单个 AOF 文件拆分成多个 AOF 文件。在 Multi Part AOF 中,AOF 文件被分为三种类型:

  • BASE:表示基础 AOF 文件,它一般由子进程通过重写产生,该文件最多只有一个。
  • INCR:表示增量 AOF 文件,它一般会在 AOFRW 开始执行时被创建,该文件可能存在多个。
  • HISTORY:表示历史 AOF 文件,它由 BASE 和 INCR AOF 变化而来,每次 AOFRW 成功完成时,本次 AOFRW 之前对应的 BASE 和 INCR AOF 都将变为 HISTORY,HISTORY 类型的 AOF 会被 Redis 自动删除。

4.3.8. AOF 校验机制

AOF 校验机制是 Redis 在启动时对 AOF 文件进行检查,以判断文件是否完整,是否有损坏或者丢失的数据。这个机制的原理其实非常简单,就是通过使用一种叫做校验和(checksum) 的数字来验证 AOF 文件。这个校验和是通过对整个 AOF 文件内容进行 CRC64 算法计算得出的数字。如果文件内容发生了变化,那么校验和也会随之改变。因此,Redis 在启动时会比较计算出的校验和与文件末尾保存的校验和(计算的时候会把最后一行保存校验和的内容给忽略点),从而判断 AOF 文件是否完整。如果发现文件有问题,Redis 就会拒绝启动并提供相应的错误信息。AOF 校验机制十分简单有效,可以提高 Redis 数据的可靠性。

Tips: 类似地,RDB 文件也有类似的校验机制来保证 RDB 文件的正确性。

4.3.9. AOF 为什么是在执行完命令之后记录日志?

关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复),而 Redis AOF 持久化机制是在执行完命令之后再记录日志。

先执行完命令再记录日志的原因:

  • 避免额外的检查开销,AOF 记录日志不会对命令进行语法检查。
  • 在命令执行完之后再记录,不会阻塞当前的命令执行。

先执行完命令再记录日志的风险:

  • 数据可能会丢失:如果 Redis 刚执行完命令,此时发生故障宕机,会导致这条命令存在丢失的风险。
  • 可能阻塞其他操作:AOF 记录日志也是在主线程中执行,所以当 Redis 把日志文件写入磁盘的时候,可能会阻塞后续的其他命令操作无法执行。

4.4. RDB 与 AOF 比较

RDB 和 AOF 各有自己的优缺点,如果对数据安全性要求较高,在实际开发中往往会结合两者来使用。

RDB 比 AOF 优秀的地方

  • RDB 文件存储的内容是经过压缩的二进制数据, 保存着某个时间点的数据集,文件很小,适合做数据的备份,灾难恢复。AOF 文件存储的是每一次写命令,类似于 MySQL 的 binlog 日志,通常会比 RDB 文件大很多。当 AOF 变得太大时,Redis 能够在后台自动重写 AOF。新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。不过,Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。
  • 使用 RDB 文件恢复数据,直接解析还原数据即可,不需要一条一条地执行命令,速度非常快。而 AOF 则需要依次执行每个写命令,速度非常慢。也就是说,与 AOF 相比,恢复大数据集的时候,RDB 速度更快。

AOF 比 RDB 优秀的地方

  • RDB 的数据安全性不如 AOF,没有办法实时或者秒级持久化数据。生成 RDB 文件的过程是比较繁重的, 虽然 BGSAVE 子进程写入 RDB 文件的工作不会阻塞主线程,但会对机器的 CPU 资源和内存资源产生影响,严重的情况下甚至会直接把 Redis 服务干宕机。AOF 支持秒级数据丢失(取决 fsync 策略,如果是 everysec,最多丢失 1 秒的数据),仅仅是追加命令到 AOF 文件,操作轻量。
  • RDB 文件是以特定的二进制格式保存的,并且在 Redis 版本演进中有多个版本的 RDB,所以存在老版本的 Redis 服务不兼容新版本的 RDB 格式的问题。
  • AOF 以一种易于理解和解析的格式包含所有操作的日志。你可以轻松地导出 AOF 文件进行分析,你也可以直接操作 AOF 文件来解决一些问题。比如,如果执行 FLUSHALL 命令意外地刷新了所有内容后,只要 AOF 文件没有被重写,删除最新命令并重启即可恢复之前的状态。

4.4.1. 重启时恢复加载顺序及流程比较

  1. 当 AOF 和 RDB 文件同时存在时,优先加载
  2. 若关闭了 AOF,加载 RDB 文件
  3. 加载 AOF/RDB 成功,redis 重启成功
  4. AOF/RDB 存在错误,redis 启动失败并打印错误信息

4.4.2. RDB 和 AOF 如何选择

通常来说,建议同时开启 RDB 和 AOF 持久化或者开启 RDB 和 AOF 混合持久化,以保证数据安全。

  • 如果数据不敏感,且可以从其他地方重新生成,可以关闭持久化。
  • 如果数据比较重要,且能够承受几分钟的数据丢失,比如缓存等,只需要使用 RDB 即可。
  • 如果是用做内存数据,要使用 Redis 的持久化,建议是 RDB 和 AOF 都开启。
  • 如果只用 AOF,优先使用 everysec 的配置选择,因为它在可靠性和性能之间取了一个平衡。

当 RDB 与 AOF 两种方式都开启时,Redis 会优先使用 AOF 恢复数据,因为 AOF 保存的文件比 RDB 文件更完整。

4.5. Redis 4.0 持久化机制的优化

由于 RDB 和 AOF 各有优势,于是,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。此方式的优缺点如下:

优点:如果把混合持久化打开,AOF 重写的时候就直接把 RDB 格式的内容写到 AOF 文件开头。这样可以结合 RDB 和 AOF 的优点,使得 Redis 可以快速加载同时避免丢失过多数据的风险。

缺点

  • 实现复杂度高:混合持久化需要同时维护 RDB 文件和 AOF 文件,因此实现复杂度相对于单独使用 RDB 或 AOF 持久化方式要高。
  • 可读性差:AOF 文件中添加了 RDB 格式的内容,其压缩格式不再是 AOF 格式,使得 AOF 文件的可读性变得很差。
  • 兼容性差:如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本。

小结:Redis 混合持久化方式适合用于需要兼顾启动速度和减低数据丢失的场景。但需要注意的是,混合持久化的实现复杂度较高、可读性差,只能用于 Redis 4.0 以上版本,因此在选择时需要根据实际情况进行权衡。

可参考官方文档地址:https://redis.io/topics/persistence

5. Redis 事务

5.1. 概述

因为 Redis 是单线程的,所以 Redis 的单条命令是原子性执行的,不可再分,要么执行成功,要么执行失败。提供的所有 API 都是原子操作。

Redis 支持分布式环境下的事务操作,其事务可以一次执行多个命令,其特征是:在事务中的所有命令都会串行化的顺序执行,并且在执行过程中,不会被其他客户端发送来的命令请求打断。服务器在执行完事务中的所有命令之后,才会继续处理其他客户端的其他命令。

TODO: 『如果在一个事务中的命令出现错误,那么所有的命令都不会执行』??这个待确认!

5.2. Redis 事务的特性

  • 原子性:事务不保证原子性,并且没有回滚。即事务中如果有某一条命令执行失败,其后的命令仍然会被继续执行不会被影响
  • 隔离性:Redis 是单进程程序,并且它保证在执行事务时,不会对事务进行中断,事务可以运行直到执行完所有事务队列中的命令为止。因此 Redis 的事务是总是带有隔离性的。

5.3. 事务的执行流程

Redis 的事务操作分为开启事务命令入队列执行事务三个阶段。执行流程如下图:

事务的生命周期如下:

  1. 事务开启:客户端执行 multi 命令开启事务。
  2. 提交请求:客户端提交任意多条命令到事务。
  3. 任务入队列:Redis 将客户端所有请求都放入事务队列中等待执行。
  4. 入队状态反馈:服务器返回 QURUD,表示命令已被放入事务队列。
  5. 执行命令:客户端通过 exec 命令来执行事务块内所有命令。按命令执行的先后顺序排列,返回事务块内所有命令的返回值。当操作被打断时,返回空值 nil
  6. 事务执行错误:在 Redis 事务中如果某条命令执行错误,则其他命令会继续执行,不会回滚。可以通过 watch 监控事务执行的状态并处理命令执行错误的异常情况。
  7. 执行结果反馈:服务器向客户端返回事务执行的结果。

Notes: 通过调用 DISCARD 命令,客户端可以清空事务队列,并放弃执行事务,并且客户端会从事务状态中退出。

5.4. 事务相关命令

Notes: 此部分内容详见《Redis 操作命令》笔记

5.5. 基于 Spring Boot 的实现事务

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
private final RedisTemplate redisTemplate;

public void transactionSet(Map<String, Objects> commandList) {
	// 1. 开启事务权限
	redisTemplate.setEnableTransactionSupport(true);
	try {
		// 2. 开启事务
		redisTemplate.multi();
		// 3. 执行事务命令
		for (Map.Entry<String, Objects> entry : commandList.entrySet()) {
			String key = entry.getKey();
			Objects value = entry.getValue();
			redisTemplate.opsForValue().set(key, value);
		}
		// 4. 成功提交
		redisTemplate.exec();
	} catch (Exception e) {
		// 5. 失败则回滚
		redisTemplate.discard();
	}
}

以上示例方法接收事务命令 commandList 并以事务命令列表在一个事务中执行。具体步骤为:开启事务权限、开启事务、执行事务命令、提供事务和回滚事务。

6. Redis 消息订阅与发布

6.1. 概述

Redis 发布、订阅是一种消息通信模式:发送者(Pub)向频道(Channel)发送消息;订阅者(Sub)接收频道上的消息。Redis 客户端可以订阅任意数量的频道,发送者也可以向任意频道发送数据。

上图是,1个发送者(pub1)、1个频道(channe0)和 3个订阅者(sub1、sub2、sub3)的关系。由于 3 个订阅者 sub1、sub2、sub3 都订阅了频道 channel0,在发送者 pub1 向频道 channel0 发送一条消息后,这条消息就会被发送给订阅它的三个客户端。

6.2. 订阅与发布相关命令

Notes: 此部分内容详见《Redis 操作命令》笔记

7. Redis 的内存管理机制

7.1. 获取当前最大内存与动态设置最大内存值

获取最大内存:

1
config get maxmemory

使用命令方式,设置最大内存:

1
config set maxmemory 1GB

7.2. 过期键的删除策略

Redis 是 key-value 数据库,可以设置 Redis 中缓存的 key 的过期时间。Redis 的过期策略就是指当 Redis 中缓存的 key 过期了,过期策略通常有以下三种:

  • 定时过期:每个设置过期时间的 key 都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
  • 惰性过期:只有当访问一个 key 时,才会判断该 key 是否已过期,过期则清除。该策略可以最大化地节省 CPU 资源,却对内存非常不友好。极端情况可能出现大量的过期 key 没有再次被访问,从而不会被清除,占用大量内存。
  • 定期过期:每隔一定的时间,会扫描一定数量的数据库的 expires 字典中一定数量的 key,并清除其中已过期的 key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得 CPU 和内存资源达到最优的平衡效果。至于要删除多少过期键,以及要检查多少个数据库,则由内部算法决定。
  • 内存不足时过期:Redis 通过 maxmemory 参数设置最大内存的限制,当使用的内存超过了设置的最大内存,就要进行内存释放。在进行内存释放的时候,会按照配置的淘汰策略清理内存。

Redis 中同时使用了惰性过期和定期过期两种过期策略值得注意的是,定期过期每次间隔并不是将所有的key检查一次,而是随机抽取进行检查。主要考虑到全量Key检查会影响性能,因此需要配合惰性过期,在获取某个key的时候再检查一次是否过期,从而确保消除定期随机检查时没有检查出来的过期 key。

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

7.3. 内存淘汰策略

如果 Redis 使用的内存达到设置的上限,默认 Redis 的写命令会返回错误信息,但是读命令还可以正常返回。此时 Redis 会触发内存淘汰策略,删除一些不常用的旧数据。

Redis 的内存淘汰策略是指在 Redis 的用于缓存的内存不足时,怎么处理需要新写入且需要申请额外空间的数据。内存淘汰策略可通过配置文件中配置项 maxmemory-policy 来修改,默认配置是 no-eviction

7.3.1. Redis 的数据淘汰策略

Redis 提供 6 种数据淘汰策略:

全局的键空间选择性移除

  • no-eviction:禁止删除数据,当内存不足以容纳新写入数据时,新写入操作会报错。
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key。
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key。(这个是最常用的)

设置过期时间的键空间选择性移除

  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。
  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,利用 LRU 算法移除设置了过期时间的 key。
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。

Redis v4.0 版本后新增了 2 种淘汰机制:

  • volatile-lfu:最少使用,从已设置过期时间的数据集中挑选最不经常使用的数据淘汰。
  • allkeys-lfu:当内存不足以容纳新写入数据时,从数据集中移除最不经常使用的 key。

Tips:

  • volatile 前缀的策略是对已设置过期时间的数据集淘汰数据;allkeys 前缀的策略是对全部数据集淘汰数据;后缀的 lruttlrandom 则是三种不同的淘汰策略;还有一种特殊 no-enviction 永不回收的策略。
  • LRU(Least Recently Used):最近使用次数最少
  • LFU(Least Frequently Used):最不常用

7.3.2. 淘汰策略使用建议

  • 如果数据呈现幂律分布,也就是一部分数据访问频率高,一部分数据访问频率低,即业务有明显的冷热数据区分,则建议优先使用 allkeys-lru 淘汰策略。充分利用 LRU 算法的优势,把最近最常访问的数据留在缓存中。
  • 如果数据呈现平等分布,也就是所有的数据访问频率都相同。即业务中数据访问频率差别不大,没有明显冷热数据区分。则使用 allkeys-random 随机淘汰策略。
  • 如果业务中有置顶的需求,可以使用 volatile-lru 策略,同时置顶数据不设置过期时间,这些数据就一直不被删除,会淘汰其他设置过期时间的数据。
  • 如果业务中有短时高频访问的数据,可以使用 allkeys-lfu 或 volatile-lfu 策略。

7.3.3. 注意事项

  • Redis 的内存淘汰策略的选取并不会影响过期的 key 的处理。内存淘汰策略用于处理内存不足时的需要申请额外空间的数据,而过期策略用于处理过期的缓存数据。
  • 如果没有设置 expire 的 key,而 volatile 相关的策略是从到了过期时间的键中进行筛选,因此 volatile-lru, volatile-random 和 volatile-ttl 策略的行为和 noeviction(不删除) 基本上一致。

8. Redis 的线程模型

Redis 基于 Reactor 模式开发了网络事件处理器,这个处理器被称为文件事件处理器。它的组成结构为4部分:

  • 多个套接字
  • IO多路复用程序
  • 文件事件分派器
  • 事件处理器

因为文件事件分派器队列的消费是单线程的,所以 Redis 才叫单线程模型。

  • 文件事件处理器使用I/O多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
  • 当被监听的套接字准备好执行连接 accept、read、write、close 等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。

8.1. 为何选择单线程模型?

  • 避免过多的上下文切换开销:程序始终运行在进程中单个线程内,没有多线程切换的场景。
  • 避免同步机制的开销:如果 Redis 选择多线程模型,需要考虑数据同步的问题,则必然会引入某些同步机制,会导致在操作数据过程中带来更多的开销,增加程序复杂度的同时还会降低性能。
  • 实现简单,方便维护:如果 Redis 使用多线程模式,那么所有的底层数据结构的设计都必须考虑线程安全问题,那么 Redis 的实现将会变得更加复杂。

8.2. Redis 6.0 版本后为何引入多线程

Redis 支持多线程主要有两个原因:

  • 可以充分利用服务器 CPU 资源,旧版本中单线程模型的主线程只能利用一个 cpu。
  • 多线程任务可以分摊 Redis 同步 IO 读写的负荷。

9. Redis 分区(待了解)

9.1. 为什么要做 Redis 分区

分区可以让 Redis 使用所有机器的内存,管理更大的内存。如果没有分区,最多只能使用一台机器的内存。分区使 Redis 的计算能力通过简单地增加计算机而得到成倍提升,Redis 的网络带宽也会随着计算机和网卡的增加而成倍增长。

9.2. Redis 分区实现方案

  • 客户端分区,就是在客户端就已经决定数据会被存储到哪个 redis 节点或者从哪个 redis 节点读取。大多数客户端已经实现了客户端分区。
  • 代理分区,意味着客户端将请求发送给代理,然后代理根据分区规则决定请求哪些 Redis 实例,决定到哪个节点写数据或者读数据,然后根据 Redis 的响应结果返回给客户端。redis 和 memcached 的一种代理实现就是 Twemproxy。
  • 查询路由(Query routing),是客户端随机地请求任意一个 redis 实例,然后由 Redis 将请求转发给正确的节点。Redis Cluster 实现了一种混合形式的查询路由,但并不是直接将请求从一个 redis 节点转发到另一个 redis 节点,而是在客户端的帮助下直接 redirected 到正确的 redis 节点。

9.3. Redis 分区的缺点

  • 通常不支持涉及多个 key 的操作。例如不能对两个集合求交集,因为他们可能被存储到不同的 Redis 实例(实际上这种情况也有办法,但是不能直接使用交集指令)。
  • 同时操作多个 key,则不能使用 Redis 事务。
  • 分区使用的粒度是 key,不能使用一个非常长的排序 key 存储一个数据集(The partitioning granularity is the key, so it is not possible to shard a dataset with a single huge key like a very big sorted set)。
  • 当使用分区的时候,数据处理会非常复杂,例如为了备份,必须从不同的 Redis 实例和主机同时收集 RDB/AOF 文件。
  • 分区时动态扩容或缩容可能非常复杂。Redis 集群在运行时增加或者删除 Redis 节点,能做到最大程度对用户透明地数据再平衡,但其他一些客户端分区或者代理分区方法则不支持这种特性。然而有一种预分片的技术也可以较好的解决这个问题。

10. Redis 最佳实践(整理中)

10.1. 键名的生产实践

Redis 没有命令空间,而且也没有对键名有强制要求。设计合理的键名,有利于防止键冲突和项目的可维护性。

  • 推荐使用键命名方式具有可读性和可管理性,建议以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔。例如:业务名:对象名:id:[属性]
  • 推荐保持键的简洁性。在保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视。例如:user:{uid}:friends:messages:{mid}可简化为u:{uid}:fr:m:{mid},从而减少由于键过长的内存浪费
  • 不能包含特殊字符。反例:包含空格、换行、单双引号以及其他转义字符

10.2. Redis 如何与数据库保持双写一致性

保证缓存和数据库的双写一致性,有以下几种同步策略:

  • 先更新缓存再更新数据库先更新数据库再更新缓存(均不推荐)

更新数据库的同时也手动更新缓存,无论更新缓存是前还是后,其优点是每次数据变化时都能及时地更新缓存。但这种操作的消耗很大,如果数据需要经过复杂的计算再写入缓存的话,频繁的更新缓存会影响到服务器的性能。如果是写入数据比较频繁的场景,可能会导致频繁的更新缓存却没有业务来读取该数据。所以这两种方式均不推荐

  • 先删除缓存再更新数据库

先删除缓存的优点是操作简单,无论更新的操作复杂与否,都是直接删除缓存中的数据。更新数据库后,等后续新的读请求获取数据库的最新值,再写入缓存中。

存在问题:删除缓存数据之后,更新数据库完成之前,这个时间段内如果有别的读请求,就会从数据库读取旧数据后并重新写到缓存中,并且后续读取都是旧数据,再次造成数据不一致。

  • 先更新数据库再删除缓存

先更新数据库成功后,再删除缓存,后续读取请求时再将新数据回写缓存。

存在问题:更新数据库和删除缓存这段时间内,别的读请求还是缓存的旧数据,但等数据库更新完成并删除缓存后,就会恢复数据一致,这种影响相对比较小。还一种情况就是,如果出现操作数据库但删除缓存失败的话,也会造成数据不一致,此时一般会采用异步的重试机制来删除旧的缓存。

  • 异步更新缓存

数据库的更新操作完成后不直接操作缓存,而是把这个操作命令封装成消息发送到消息队列中,然后由 Redis 去消费更新数据,消息队列可以保证数据操作顺序一致性,确保缓存系统的数据正常。

存在问题:这种方式的代码开发与部署成本都变高,因为需要引入消息中间件并且要编写服务生产与消费相关逻辑代码。

  • 延迟双删

先删除缓存,再修改数据库,最后再延迟将最新值更新到缓存。这种方案可以防止在某个线程在删除缓存后修改数据库前,有其他线程查询缓存发现没有数据,去查询数据库旧的数据并更新到缓存。这里第二次删除缓存时采取延迟的我的做法,是因为数据库可能涉及主从架构,存在数据同步的延迟。

综上分析后结论是:『先更新数据库再删除缓存』是影响更小的方案。如果第二步出现失败的情况,则可以采用重试机制解决问题。

10.3. Redis 常见性能问题和解决方案(使用中再总结迭代)

  • Master 库最好不要做任何持久化工作,如 RDB 内存快照和 AOF 日志文件。如果 Master 写内存快照,save 命令调度 rdbSave 函数,会阻塞主线程的工作,当快照比较大时会间断性暂停服务,影响性能。
  • 如果数据比较重要,让某个 Slave 开启 AOF 备份数据,策略设置为每秒同步一次。
  • 为了主从复制的速度和连接的稳定性, Master 和 Slave 最好在同一个局域网内。
  • 尽量避免在压力很大的主库上增加从库
  • 主从复制不要用图状结构,用单向链表结构更为稳定,即: Master <- Slave1 <- Slave2 <- Slave3...。这种结构方便解决单点故障问题,实现 Slave 对 Master 的替换。如果 Master 挂了,可以立刻启用 Slave1 做 Master,其他不变。

10.4. Redis 持久化数据和缓存如何扩容

  • 如果 Redis 被当做缓存使用,使用一致性哈希实现动态扩容缩容。
  • 如果 Redis 被当做一个持久化存储使用,必须使用固定的 keys-to-nodes 映射关系,节点的数量一旦确定不能变化。否则的话(即 Redis 节点需要动态变化的情况),必须使用可以在运行时进行数据再平衡的一套系统,而当前只有 Redis 集群可以做到这样。

11. Redis 扩展知识

11.1. Redis 网络模型

Redis 通过『IO多路复用+事件派发』来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装,提供了统一的高性能事件库。

I/O 多路复用是指利用单个线程来同时监听多个 Socket,并在某个 Socket 可读、可写时得到通知,从而避免无效的等待,充分利用 CPU 资源。目前的 I/O 多路复用都是采用的 epoll 模式实现,它会在通知用户进程 Socket 就绪的同时,把已就绪的 Socket 写入用户空间,不需要挨个遍历 Socket 来判断是否就绪,提升了性能。

其中 Redis 的网络模型就是使用 I/O 多路复用结合事件的处理器来应对多个 Socket 请求。比如,提供了连接应答处理器、命令回复处理器,命令请求处理器;

在 Redis 6.0 之后,为了提升更好的性能,在命令回复处理器使用了多线程来处理回复事件,在命令请求处理器中,将命令的转换使用了多线程,增加命令转换速度,在命令执行的时候,依然是单线程。

11.2. Redis 中的 Copy On Write 技术

单线程的 Redis 是通过 Copy On Write 技术来实现一边响应主线程的任务,一边持久化数据。具体是依赖系统的 fork 函数的 Copy On Write 实现,实现过程如下:

  1. 在执行 RDB 持久化时,Redis 进程会 fork 一个子进程来执行持久化,该过程是阻塞的。
  2. 当 fork 过程完成后,父进程会继续接收客户端的命令。
  3. 此时子进程与 Redis 主进程共享内存中的数据,但是子进程并不会修改内存中的数据,而是不断的遍历读取并写入数据到磁盘,也就是持久化数据的过程。
  4. 然而 Redis 主进程则不一样,它需要响应客户端的命令,如果收到写入数据的操作请求,主进程就会使用 COW 机制将数据先复制再修改。
  5. 而此时,子进程使用的数据页并不会发生任何改变,依然是 fork 时的数据,继续进行持久化。