Skip to content

Redis

字数: 0 字 时长: 0 分钟

第 1 章 Redis 入门

1.1 认识 NoSQL

1.2 认识 Redis

Redis 全称 Remote Dictionary Server,远程词典服务器,是一个基于内存的键值型 NoSQL 数据库

特征:

  • 键值型,value 支持多种不同的数据结构,功能丰富
  • 单线程,每个命令具备原子性
  • 低延迟,速度快(基于内存、IO 多路复用、良好的编码)
  • 支持数据持久化
  • 支持主从集群、分片集群
  • 支持多语言客户端

Redis 为什么快呢?

  1. Redis 的所有数据都放在内存中
  2. Redis 采用了基于 IO 多路复用技术的事件驱动模型来处理客户端请求和执行 Redis 命令,[[什么是 IO 多路复用]]

在 Redis 6.0 之前,包括连接建立、请求读取、响应发送,以及命令执行都是在主线程中顺序执行的,这样可以避免多线程环境下的锁竞争和上下文切换,因为 Redis 的绝大部分操作都是在内存中进行的,性能瓶颈主要是内存操作和网络通信,而不是 CPU

为了进一步解决网络 IO 的性能瓶颈,Redis 6.0 引入了多线程机制,把网络 IO 和命令执行分开,网络 IO 交给线程池来处理,而命令执行仍然在主线程中进行,这样就可以充分利用多核 CPU 的性能

select、poll、epoll 的区别?

select 的缺点是单个进程能监视的文件描述符数量有限,一般为 1024 个,且每次调用都需要将文件描述符集合从用户态复制到内核态,然后遍历找出就绪的描述符,性能较差

poll 的优点是没有最大文件描述符数量的限制,但是每次调用仍然需要将文件描述符集合从用户态复制到内核态,依然需要遍历,性能仍然较差

epoll 是 Linux 特有的 IO 多路复用机制,支持大规模并发连接,使用事件驱动模型,性能更高,其工作原理是将文件描述符注册到内核中,然后通过事件通知机制来处理就绪的文件描述符,不需要轮询,也不需要数据拷贝,更没有数量限制,所以性能非常高

  1. Redis 对底层数据结构做了极致的优化

1.3 安装 Redis

Redis安装说明

第 2 章 Redis 的常见命令

2.1 Redis 数据结构介绍

Redis 是一个 key-value 的数据库,key 一般是 String 类型,不过 value 的类型多种多样

2.2 Redis 通用命令

通用指令是部分数据类型的,都可以使用的指令,常见的有:

  • keys:查看符合模板的所有 key,不建议在生产环境设备上使用
  • del:删除一个指定的 key
  • exists:判断 key 是否存在
  • expire:给一个 key 设置有效期,有效期到期时该 key 会被自动删除
  • ttl:查看一个 key 的剩余有效期

通过 help [command] 可以查看一个命令的具体用法:

2.3 String 类型

String 类型,也就是字符串类型,是 Redis 中最简单的存储类型

其 value 是字符串,不过根据字符串的格式不同,又可以分为 3 类:

  • string:普通字符串
  • int:整数类型,可以做自增、自减操作
  • float:浮点类型,可以做自增、自减操作

不管是哪种格式,底层都是字节数组形式存储,只不过是编码方式不同,字符串类型的最大空间不能超过 512m

String 的常见命令有:

  • set:添加或者修改已经存在的一个 String 类型的键值对
  • get:根据 key 获取 String 类型的 value
  • mset:批量添加多个 String 类型的键值对
  • mget:根据多个 key 获取多个 String 类型的 value
  • incr:让一个整型的 key 自增 1
  • incrby:让一个整型的 key 自增并指定步长,例如:incrby num 2让 num 值自增 2
  • incrbyfloat:让一个浮点类型的数字自增并指定步长
  • setnx:添加一个 String 类型的键值对,前提是这个 key 不存在,否则不执行
  • setex:添加一个 String 类型的键值对,并且指定有效期

Redis 没有类似 MySQL 中的 Table 的概念,我们该如何区分不同类型的 key 呢?

例如:需要存储用户、商品信息到 Redis,有一个用户 id 是 1,有一个商品 id 恰好也是 1

key 的结构:Redis 的 key 允许有多个单词形成层级结构,多个单词之间用 :隔开,格式:项目名:业务名:类型:id,这个格式并非固定,也可以根据自己的需求来删除或添加词条

例如我们的项目名称叫 zhishu,有 user 和 product 两种不同类型的数据,我们可以这样定义 key:

  • user 相关的 key:zhishu:user:1
  • product 相关的 key:zhishu:product:1

2.4 Hash 类型

Hash 类型也叫散列,其 value 是一个无序字典,类似于 Java 中的 HashMap 结构

String 结构是将对象序列化为 JSON 字符串后存储,当需要修改对象某个字段时很不方便:

Hash 结构可以将对象中的每个字段独立存储,可以针对单个字段做 CRUD:

Hash 的常见命令:

  • hset key field value:添加或者修改 hash 类型 key 的 field 的值
  • hget key field:获取一个 hash 类型 key 的 field 的值
  • hmset:批量添加多个 hash 类型 key 的 field 的值
  • hmget:批量获取多个 hash 类型 key 的 field 的值
  • hgetall:获取一个 hash 类型的 key 中的所有的 field 和 value
  • hkeys:获取一个 hash 类型的 key 中的所有的 field
  • hvals:获取一个 hash 类型的 key 中的所有的 value
  • hincrby:让一个 hash 类型的 key 的字段值自增并指定步长
  • hsetnx:添加一个 hash 类型的 key 的 field 值,前提是这个 field 不存在,否则不执行

2.5 List 类型

Redis 中的 List 类型与 Java 中的 LinkedList 类似,可以看做是一个双向链表结构,既可以支持正向检索也可以支持反向检索

特征也与 LinkedList 类似:

  • 有序
  • 元素可以重复
  • 插入和删除快
  • 查询速度一般

常用来存储一个有序数据,例如:朋友圈点赞列表,评论列表等

List 的常见命令有:

  • lpush key element ...:向列表左侧插入一个或多个元素
  • lpop key:移除并返回列表左侧的第一个元素,没有则返回 nil
  • rpush key element ...:向列表右侧插入一个或多个元素
  • rpop key:移除并返回列表右侧的第一个元素
  • lrange key star end:返回一段角标范围内的所有元素
  • blpop 和 brpop:与 lpop 和 rpop 类似,只不过在没有元素时等待指定时间,而不是直接返回 nil

2.6 Set 类型

Redis 的 Set 结构与 Java 中的 HashSet 类似,可以看做是一个 value 为 null 的 HashMap,因为也是一个 hash 表,因此具备与 HashSet 类似的特征:

  • 无序
  • 元素不可重复
  • 查找快
  • 支持交集、并集、差集等功能

Set 的常见命令有:

  • sadd key member ...:向 set 中添加一个或多个元素
  • srem key member ...:移除 set 中的指定元素
  • scard key:返回 set 中元素的个数
  • sismember key member:判断一个元素是否存在于 set 中
  • smembers:获取 set 中的所有元素
  • sinter key1 key2 ...:求 key1 与 key2 的交集
  • sdiff key1 key2 ...:求 key1 与 key2 的差集
  • sunion key1 key2 ...:求 key1 和 key2 的并集

2.7 SortedSet 类型

Redis 的 SortedSet 是一个可排序的 set 集合,与 Java 中的 TreeSet 有些类似,但底层数据结构却差别很大,SortedSet 中的每一个元素都带有一个 score 属性,可以基于 score 属性对元素排序,底层的实现是一个跳表(SkipList)加 hash 表

SortedSet 具备下列特性:

  • 可排序
  • 元素不重复
  • 查询速度快

因为 SortedSet 的可排序特性,经常被用来实现排行榜这样的功能

SortedSet 的常见命令有:

  • zadd key score member:添加一个或多个元素到 sorted set,如果已经存在则更新其 score 值
  • zrem key member:删除 sorted set 中的一个指定元素
  • zscore key member:获取 sorted set 中的指定元素的 score 值
  • zrank key member:获取 sorted set 中的指定元素的排名
  • zcard key:获取 sorted set 中的元素个数
  • zcount key min max:统计 score 值在给定范围内的所有元素的个数
  • zincrby key increment member:让 sorted set 中的指定元素自增,步长为指定的 increment 值
  • zrange key min max:按照 score 排序后,获取指定排名范围内的元素
  • zrangebyscore key min max:按照 score 排序后,获取指定 score 范围内的元素
  • zdiff、zinter、zunion:求差集、交集、并集

注意:所有的排名默认都是升序,如果要降序则在命令的 z 后面添加 rev 即可

第 3 章 Redis 的 Java 客户端

Jedis:以 Redis 命令作为方法名称,学习成本低,简单实用。但是 Jedis 实例是线程不安全的,多线程环境下需要基于连接池来使用

Lettuce:Lettuce 是基于 Netty 实现的,支持同步、异步和响应式编程方式,并且是线程安全的。支持 Redis 的哨兵模式、集群模式和管道模式

Redisson:Redisson 是一个基于 Redis 实现的分布式、可伸缩的 Java 数据结构集合。包含了诸如 Map、Queue、Lock、Semaphore、AtomicLong 等强大功能

Spring 中有个 Spring Data Redis 集成了 Jedis 和 Lettuce

3.1 Jedis

3.1.1 Jedis 的使用

  1. 引入依赖
xml
<dependency>
	<groupId>redis.clients</groupId>
	<artifactId>jedis</artifactId>
	<version>3.7.0</version>
</dependency>
  1. 建立连接
java
private Jedis jedis;

@BeforeEach
void setUp() {
	//建立连接
	jedis = new Jedis("192.168.200.130", 6379);
	//设置密码
	jedis.auth("123456");
	//选择库,默认选择 0 号库
	jedis.select(0);
}
  1. 测试
java
@Test
void testString() {
	//插入数据,方法名称就是 redis 命令名称
	String result = jedis.set("name", "张三");
	System.out.println("result = " + result);
	//获取数据
	String name = jedis.get("name");
	System.out.println("name = " + name);
}
  1. 释放资源
java
@AfterEach
void tearDown() {
	//释放资源
	if (jedis != null) {
		jedis.close();
	}
}

3.1.2 Jedis 连接池

Jedis 本身是线程不安全的,并且频繁的创建和销毁连接会有性能损耗,因此我们推荐大家使用 Jedis 连接池代替 Jedis 的直连方式

java
public class JedisConnectionFactory {
	private static final JedisPool jedisPool

	static {
		JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
		//最大连接
		jedisPoolConfig.setMaxTotal(8);
		//最大空闲连接
		jedisPoolConfig.setMaxIdle(8);
		//最小空闲连接
		jedisPoolConfig.setMinIdle(0);
		//设置最长等待时间,ms
		jedisPoolConfig.setMaxWaitMillis(200);
		jedisPool = new JedisPool(jedisPoolConfig, "192.168.200.130", 6379, 1000, "123456");
	}

	//获取 Jedis 对象
	public static Jedis getJedis() {
		return jedisPool.getResource();
	}
}

3.2 SpringDataRedis

3.2.1 SpringDataRedis 入门

SpringData 是 Spring 中数据操作的模块,包含对各种数据库的集成,其中对 Redis 的集成模块就叫做 SpringDataRedis

  • 提供了对不同 Redis 客户端的整合(Lettuce 和 Jedis)
  • 提供了 RedisTemplate 统一 API 来操作 Redis
  • 支持 Redis 的发布订阅模型
  • 支持 Redis 哨兵和 Redis 集群
  • 支持基于 Lettuce 的响应式编程
  • 支持基于 JDK、JSON、字符串、Spring 对象的数据序列化及反序列化
  • 支持基于 Redis 的 JDKCollection 实现

SpringDataRedis 中提供了 RedisTemplate 工具类,其中封装了各种对 Redis 的操作,并且将不同的数据类型的操作 API 封装到了不同的类型中:

3.2.2 SpringDataRedis 使用

  1. 引入依赖
xml
<!--Redis 依赖-->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!--连接池依赖-->
<dependency>
	<groupId>org.apache.commons</groupId>
	<artifactId>commons-pool2</artifactId>
</dependency>
  1. 配置文件
yaml
spring:
  redis:
    host: 192.168.200.130
    port: 6379
    password: 123456
    lettuce:
      pool:
        max-active: 8 # 最大连接
        max-idle: 8 # 最大空闲连接
        min-idle: 0 # 最小空闲连接
        max-wait: 100 # 连接等待时间
  1. 注入 RedisTemplate
java
@Autowired
private RedisTemplate redisTemplate;
  1. 编写测试
java
@SpringBootTest
public class RedisTest {
	@Autowired
	private RedisTemplate redisTemplate;

	@Test
	void testString() {
		//插入一条 string 类型数据
		redisTemplate.opsForValue().set("name", "李四");
		//读取一条 string 类型数据
		Object name = redisTemplate.opsForValue().get("name");
		System.out.println("name = " + name);
	}
}

3.2.3 SpringDataRedis 的序列化方式

RedisTemplate 可以接收任意 Object 作为值写入 Redis,只不过写入前会把 Object 序列化为字节形式,默认是采用 JDK 序列化,得到的结果是这样的:

缺点:

  • 可读性差
  • 内存占用较大

我们可以自定义 RedisTemplate 的序列化方式,代码如下:

java
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory){
        // 创建RedisTemplate对象
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        // 设置连接工厂
        template.setConnectionFactory(connectionFactory);
        // 创建JSON序列化工具
        GenericJackson2JsonRedisSerializer jsonRedisSerializer = 
            							new GenericJackson2JsonRedisSerializer();
        // 设置Key的序列化,key 和 hashKey 采用 string 序列化
        template.setKeySerializer(RedisSerializer.string());
        template.setHashKeySerializer(RedisSerializer.string());
        // 设置Value的序列化,value 和 hashValue 采用 JSON 序列化
        template.setValueSerializer(jsonRedisSerializer);
        template.setHashValueSerializer(jsonRedisSerializer);
        // 返回
        return template;
    }
}

尽管 JSON 的序列化方式可以满足我们的需求,但依然存在一些问题,如图:

为了在反序列化时知道对象的类型,JSON 序列化器会将类的 class 类型写入 json 结果中存入 Redis,会带来额外的内存开销

为了节省内存空间,我们并不会使用 JSON 序列化器来处理 value,而是统一使用 String 序列化器,要求只能存储 String 类型的 key 和 value,当需要存储 Java 对象时,手动完成对象的序列化和反序列化

Spring 默认提供了一个 StringRedisTemplate 类,它的 key 和 value 的序列化方式默认就是 String 方式,省去了我们自定义 RedisTemplate 的过程:

java
@SpringBootTest
class RedisStringTests {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Test
    void testString() {
        // 写入一条String数据
        stringRedisTemplate.opsForValue().set("verify:phone:13600527634", "124143");
        // 获取string数据
        Object name = stringRedisTemplate.opsForValue().get("name");
        System.out.println("name = " + name);
    }

    private static final ObjectMapper mapper = new ObjectMapper();

    @Test
    void testSaveUser() throws JsonProcessingException {
        // 创建对象
        User user = new User("虎哥", 21);
        // 手动序列化
        String json = mapper.writeValueAsString(user);
        // 写入数据
        stringRedisTemplate.opsForValue().set("user:200", json);

        // 获取数据
        String jsonUser = stringRedisTemplate.opsForValue().get("user:200");
        // 手动反序列化
        User user1 = mapper.readValue(jsonUser, User.class);
        System.out.println("user1 = " + user1);
    }

}

第 4 章 短信登录

4.1 导入黑马点评项目

4.2 基于 Session 实现登录

基于 Session 实现登录的流程:

  • 发送验证码:用户在输入手机号后,点击发送验证码按钮后,会携带着手机号向后端发起请求,后端首先会校验手机号是否合法,如果不合法,则返回错误信息让用户重新输入手机号;如果手机号合法,我们可以随机生成一个验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户

代码实现:

java
/**
     * 发送手机验证码
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        //发送短信验证码并保存验证码
        //1. 校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) { //如果手机号不合法返回 true
            return Result.fail("手机格式不正确,请重新输入");
        }
        //2. 手机号符合,生成随机验证码
        String code = RandomUtil.randomNumbers(6);
        //3. 保存验证码
        session.setAttribute("code", code);
        //4. 通过短信把生成的验证码发送给用户
        //这里没有短信服务,所以这里只记录一下
        
        log.info("生成的验证码: " + code);
        return Result.ok();
    }

4.3 集群的 Session 共享问题

Session 共享问题:多台 Tomcat 并不共享 Session 存储空间,当请求切换到不同 Tomcat 服务时导致数据丢失的问题

Session 的替代方案应该满足:

  • 数据共享
  • 内存存储
  • key、value 结构

4.4 基于 Redis 实现共享 Session 登录

4.5 登录拦截器的优化

因为我们之前的拦截器只拦截需要登录的路径,如果我们一直访问的是不需要登录的路径那么就不会刷新 token,所以我们要再加一个拦截器用于拦截所有路径,该拦截器专门用于刷新 token

第 5 章 商户查询缓存

5.1 什么是缓存

缓存就是数据交换的缓冲区(Cache),是存储数据的临时地方,一般读写性能较高

缓存的作用:

  • 降低后端负载
  • 提高读写效率,降低响应时间

缓存的成本:

  • 数据一致性成本
  • 代码维护成本
  • 运维成本

5.2 添加 Redis 缓存

缓存作用模型

根据 id 查询商铺缓存的流程

5.3 缓存更新策略

内存淘汰超时剔除主动更新
说明不用自己维护,利用 Redis 的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存给缓存数据添加 TTL 时间,到期后自动删除缓存。下次查询时更新缓存编写业务逻辑,在修改数据库的同时更新缓存
一致性一般
维护成本

业务场景:

  • 低一致性需求:使用内存淘汰机制,例如店铺类型的查询缓存
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案,例如店铺详情查询的缓存

5.3.1 主动更新策略

  1. 由缓存的调用者在更新数据库的同时更新缓存,综合考虑这个策略最终胜出
  2. 缓存与数据库整合为一个服务,由服务来维护一致性,调用者调用该服务,无需关心缓存一致性问题
  3. 调用者只操作缓存,由其它线程异步的将缓存数据持久化到数据库,保证最终一致

在操作缓存和数据库时有三个问题需要考虑:

  1. 删除缓存还是更新缓存?

选择删除缓存:

  • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
  • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
  1. 如何保证缓存与数据库的操作的同时成功或失败?
  • 单体系统:将缓存与数据库操作放在一个事务
  • 分布式系统:利用 TCC 等分布式事务方案
  1. 先操作缓存还是先操作数据库?
  • 先删除缓存,再操作数据库

正常情况:

异常情况:线程安全问题

  • 先操作数据库,再删除缓存,这个方案比前一个方案好

正常情况:

异常情况:缓存如果突然失效了

[[如果对缓存和数据库一致性要求很高,怎么办?]]

[[如何保证本地缓存(Caffeine)和分布式缓存(Redis)的一致?]]

5.4 缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库,不断发起这样的请求会给数据库带来巨大的压力

常见的解决方案有两种:

  • 缓存空对象

优点:实现简单,维护方便

缺点:额外的内存消耗,可能造成短期的不一致

  • 布隆过滤

优点:内存占用较少,没有多余 key

缺点:实现复杂,存在误判可能

5.5 缓存雪崩

缓存雪崩是指在同一时段大量的缓存 key 同时失效或者 Redis 服务宕机,导致大量请求到达数据库,带来巨大压力

解决方案:

  • 给不同的 key 的 TTL 添加随机值
  • 利用 Redis 集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

5.6 缓存击穿

缓存击穿问题也叫热点 Key 问题,就是一个被高并发访问并且缓存重建业务较复杂的 Key 突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击

常见的解决方案有两种:

  • 互斥锁

  • 逻辑过期

热点 Key 缓存永不过期,而是设置一个逻辑过期时间,查询到数据时通过对逻辑过期时间判断来决定是否需要重建缓存

解决方案优点缺点
互斥锁没有额外的内存消耗;保证一致性;实现简单线程需要等待,性能受影响;可能有死锁的风险
逻辑过期线程无需等待;性能较好不保证一致性;有额外内存消耗;实现复杂

基于互斥锁方式解决缓存击穿问题

基于逻辑过期方式解决缓存击穿问题

5.7 缓存工具封装

基于 StringRedisTemplate 封装一个缓存工具类,满足下列需求:

方法 1:将任意 Java 对象序列化为 json 并存储在 string 类型的 key 中,并且可以设置 TTL 过期时间

方法 2:将任意 Java 对象序列化为 json 并存储在 string 类型的 key 中,并且可以设置逻辑过期时间,用于处理缓存击穿问题

方法 3:根据指定的 key 查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题

方法 4:根据指定的 key 查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题

第 6 章 优惠券秒杀

6.1 全局唯一 ID

如果使用数据库自增 ID 会有以下问题:

  • id 的规律性太明显
  • 受单表数据量的限制

全局 ID 生成器是一种在分布式系统下用来生成全局唯一 ID 的工具,一般要满足下列特性:

  • 唯一性
  • 高可用
  • 高性能
  • 递增性
  • 安全性

为了增加 ID 的安全性,我们可以不直接使用 Redis 自增的数值,而是拼接一些其它信息

ID 的组成部分:

  • 符号位:1 bit,永远为 0
  • 时间戳:31 bit,以秒为单位,可以使用 69 年
  • 序列号:32 bit,秒内的计数器,支持每秒产生 2^32 个不同的 ID
java
public long nextId(String keyPrefix) {
	//1. 生成时间戳
	LocalDateTime now = LocalDateTime.now();
	long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
	long timestamp = nowSecond - BEGIN_TIMESTAMP;
	
	//2. 生成序列号
	//2.1 获取当前日期,精确到天
	String date = now.format(DateTimeForMatter.ofPattern("yyyy:MM:dd"));
	//2.2 自增长
	long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
	
	//3. 拼接并返回
	return timestamp << COUNT_BITS | count;
}

全局唯一 ID 生成策略:

  • UUID
  • Redis 自增
  • snowflake 算法
  • 数据库自增

Redis 自增 ID 策略:

  • 每天一个 key,方便统计订单量
  • ID 构造是时间戳 + 计数器

6.2 实现优惠券秒杀下单

下单时需要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  • 库存是否充足,不足则无法下单

6.3 超卖问题

正常情况:

超卖情况:

  • 悲观锁:添加同步锁,让线程串行执行

    • 优点:简单粗暴
    • 缺点:性能一般
  • 乐观锁:

    • 优点:性能好
    • 缺点:存在成功率低的问题

乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:

  • 版本号法

  • CAS 法

6.4 一人一单

需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单

想要保证一人一单,就要去判断订单库里是否已经有该用户的订单了,如果已经存在就直接返回,如果不存在就可以下单

如果有一个人开启 200 个线程来下单,这里因为是先判断再创建订单,这里就存在一个并发安全问题,比如在第一个线程判断通过后还没有创建订单之前就有很多线程进来了,那么很多线程的判断就是成立的,那么一个人还是可以抢到多个订单

为了解决并发安全问题,可以考虑使用悲观锁或者乐观锁,但是乐观锁用于修改,所以这里使用悲观锁来解决新增的并发问题:

这里我们需要给查询订单、判断订单、创建订单这个逻辑加锁,以保证一次只能一个线程来处理

但是这里还会有问题:这里该方法是会操作多个数据库表的,为了保证原子性要加入事务,事务会在执行完该方法之后才会提交,当创建完订单之后,方法执行完之后,如果此时事务还没有提交,数据库里是不会有已经创建完订单这个记录的,这时进来一个线程,判断订单不存在,就又下了一单,这里锁就失效了

解决办法就是在还没有解锁的时候把事务给提交了,这里就可以把逻辑封装成一个方法,给这个方法添加事务,然后在主方法加锁,锁里调用这个方法,这样方法执行完后,提交完事务后才会解锁

这里还会隐藏一个事务失效的问题:如下图代码:

原本的代码是:

java
class VoucherOrderServiceImpl {
	方法 {
	synchronized (userId.toString().intern()) {
			return createVoucherOrder(voucherId);
		}
	}

	@Transactional
	public Result createVoucherOrder(Long voucherId) {
		...
	}
}

这里如果直接调用 createVoucherOrder 的话实际上是 return this.createVoucherOrder(voucherId) 因为事务注解是 Spring 管理的,要用代理对象调用才会生效,所以如果使用 this 本身的对象调用的话事务就会失效,这里要获取代理对象然后调用,正确代码就是上图中的代码

6.5 分布式锁

集群下一人一单的并发安全问题:

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁:

  • 多进程可见
  • 互斥
  • 高可用
  • 高性能
  • 安全性

分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:

存在问题:

原因:锁误删

改进:

实现:

java
public boolean tryLock(long timeoutSec) {
	//获取线程标识
	String threadId = ID_PREFIX + Thread.currentThread().getId();
	//获取锁
	Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
	return Boolean.TRUE.equals(success);
}

public void unlock() {
	//获取线程标识
	String threadId = ID_PREFIX + Thread.currentThread().getId();
	//获取锁中的标识
	String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
	//判断标识是否一致
	if (threadId.equals(id)) {
		//释放锁
		stringRedisTemplate.delete(KEY_PREFIX + name);
	}
}

还存在问题:

因为判断锁标识和释放锁不是原子的,所以会出问题:如下图:

那么如何保证获取锁标识和释放锁是原子的?

Redis 提供了 Lua 脚本功能,在一个脚本中编写多条 Redis 命令,确保多条命令执行时的原子性,Lua 是一种编程语言

这里重点介绍 Redis 提供的调用函数,语法如下:

写好脚本以后,需要用 Redis 命令来调用脚本,调用脚本的常见命令如下:

那么释放锁的 Lua 脚本表示是这样的:

那么怎么调用 Lua 脚本?

需求:基于 Lua 脚本实现分布式锁的释放锁逻辑 提示:RedisTemplate 调用 Lua 脚本的 API 如下:

总结:

基于 Redis 的分布式锁实现思路:

  • 利用 set nx ex 获取锁,并设置过期时间,保存线程标识
  • 释放锁时先判断线程标识是否与自己一致,一致则删除锁

特性:

  • 利用 set nx 满足互斥性
  • 利用 set ex 保证故障时锁依然能释放,避免死锁,提高安全性
  • 利用 Redis 集群保证高可用和高并发特性

6.5.1 基于 Redis 的分布式锁优化

基于 setnx 实现的分布式锁存在下面的问题:

  • 不可重入:同一个线程无法多次获取同一把锁
  • 不可重试:获取锁只尝试一次就返回 false,没有重试机制
  • 超时释放时间不好掌握:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
  • 主从一致性:如果 Redis 提供了主从集群,主从同步存在延迟,当主宕机时,如果从没有同步主中的锁数据,则会出现锁失效

6.5.2 Redisson

Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格,它不仅提供了一系列的分布式的 Java 常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现

6.5.2.1 Redisson 可重入锁的原理

我们原先的代码不能实现可重入:

Redisson 可重入锁原理:

6.5.2.2 Redisson 锁重试和 WatchDog 机制

Redisson 的[[锁重试]]机制:

Redisson 的[[看门狗机制]]:

Redisson 的看门狗机制是一种[[自动续期机制]],用于解决分布式锁的过期时间,基本原理是这样的,当调用 lock() 方法加锁时,如果没有显示设置过期时间,Redisson 会默认给锁加一个 30s 的过期时间,同时启用一个名为看门狗的定时任务,每隔 10 秒(默认是过期时间的 1/3),去检查一次锁是否还被当前线程持有,如果是,就自动续期,将过期时间延长到 30 秒。续期的 Lua 脚本会检查锁的 value 是否匹配当前线程,如果匹配就延长过期时间,这样就能保证只有锁的真正持有者才能续期;当调用 unlock() 方法时,看门狗任务会被取消,或者如果业务逻辑执行完但忘记 unlock 了,看门狗也会帮我们自动检查锁,如果锁已经不属于当前线程了,也会自动停止续期,这样我们就不用担心业务执行时间过长导致锁被提前释放,也避免了手动估算过期时间的麻烦,同时也解决了分布式环境下的死锁问题

总结:

Redisson 分布式锁原理:

  • 可重入:利用 hash 结构记录线程 id 和重入次数
  • 可重试:利用信号量和 PubSub 功能实现等待、唤醒,获取锁失败的重试机制
  • 超时续约:利用 watchDog,每隔一段时间(releaseTime / 3),重置超时时间
6.5.2.3 Redisson 分布式锁主从一致性问题

Redisson 解决:

Redisson 的解决方案非常粗暴,不就是因为主从同步出现的问题嘛,那就干脆不要主从了,全都是主节点,然后所有的主节点都存放锁信息,Java 想要获取锁就要从所有的主节点都去获取,都能获取到才算获取锁成功,为了增加可用性可以多增加一些主节点,或者在主节点后加从节点,加从节点不会有问题,因为要都获取到锁才算获取到锁,这种需要从多个主节点都获取到锁才算获取到锁的结构叫联锁

[[什么是 Redlock?]]

6.6 Redis 优化秒杀

目前存在的问题:

目前的问题是:多个功能串行执行,一次请求的耗时是整个功能的耗时总和

可以举个例子:比如饭店里刚开始只有一个人来处理接待客人、记录吃啥、收钱、做饭,这样当客人多的时候就要一个客人一个客人的处理,那其它的顾客一看你在处理这个顾客然后我还要一直等着可能顾客就走了,这样处理客人的数量就大大减少了

那么我们就可以再请一个后厨专门用来负责耗时长的做饭的任务,前台就负责接待客人、记录吃啥、收钱这些耗时较短的业务,然后把耗时长的任务交给后厨慢慢做,这样前台就可以接待更多的客人

实现判断秒杀库存和校验一人一单:

代码实现:

需求:

  • 新增秒杀优惠券的同时,将优惠券信息保存到 Redis 中
  • 基于 Lua 脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
  • 如果抢购成功,将优惠券 id 和用户 id 封装后存入阻塞队列
  • 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

基于阻塞队列的异步秒杀存在哪些问题:

  • 内存限制问题:因为阻塞队列是基于 JVM 实现,JVM 的内存是有限制的
  • 数据安全问题:因为阻塞队列是基于 JVM 实现,服务重启或者宕机数据就丢失了

6.7 Redis 消息队列实现异步消息队列

消息队列:字面意思就是存放消息的队列,最简单的消息队列模型包括 3 个角色:

  • 消息队列:存储和管理消息,也被称为消息代理
  • 生产者:发送消息到消息队列
  • 消费者:从消息队列获取消息并处理消息

Redis 提供了三种不同的方式来实现消息队列:

  • list 结构:基于 List 结构模拟消息队列
  • PubSub:基本的点对点消息模型
  • Stream:比较完善的消息队列模型

6.7.1 基于 List 结构模拟消息队列

6.7.2 基于 PubSub 的消息队列

6.7.3 基于 Stream 的消息队列

基于 Stream 的消息队列 - 消费者组

消费者组:将多个消费者划分到一个组中,监听同一个队列,具备下列特点:

6.8 Redis 实现延时消息队列

核心思路是利用 ZSet 的有序特性,将消息作为 member,把消息的执行时间作为 score,这样消息就会按照执行时间自动排序,我们只需要定期扫描当前时间之前的消息进行处理就可以了

补充 达人探店

发布探店笔记

实现查看发布探店笔记的接口:

需求:点击首页的探店笔记,会进入详情页面,实现该页面的查询接口

点赞

在首页的探店笔记排行榜和探店图文详情页面都有点赞的功能:

点赞排行榜

关注

关注和取关

共同关注

利用 Redis 的 Set 结构的求交集功能就能实现查询出共同关注的需求,我们可以在每个登录的当前用户在关注别人的时候往 Redis 的 Set 结构中存一份,然后在查找共同关注的时候可以从取出当前登录用户的集合和目前查看的用户的集合,然后求交集,就能查出共同关注的用户,然后去数据库查出具体数据后返回

关注推送

关注推送也叫作 Feed 流,直译为投喂,为用户持续的提供沉浸式的体验,通过无限下拉刷新获取新的信息

Feed 流产品有两种常见模式:

本例中的个人页面,是基于关注的好友来做 Feed 流,因此采用 Timeline 模式,该模式的实现方案有三种:

  • 拉模式
  • 推模式
  • 推拉结合

拉模式:也叫做读扩散

推模式:也叫作写扩散

推拉结合模式:也叫作读写混合,兼具推和拉两种模式的优点:

对于活跃粉丝可以直接推送到它的收件箱,对于普通粉丝,不直接推送到它的收件箱,而是需要的时候从发件箱中取

基于推模式实现关注推送功能:

Feed 流的分页的问题:

Feed 流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式:

可以使用滚动分页来解决:

每次查询都要记录查到哪了,下次查询从记录的值接着查

推送:当前登录用户发表笔记后,查询自己的关注列表,然后遍历每一个关注的粉丝创建收件箱,利用 ZSet 创建收件箱,key 是粉丝的 id,field 是笔记 id,value 是时间戳,这样就可以给每个用户推送笔记了,然后粉丝登录账号后,进入关注,然后根据自己的 id 找到收件箱,就可以看到推送的笔记了,笔记按照时间戳就行倒序排列,然后通过滚动分页按最新的笔记优先展示,然后就可以展示出来了

为什么不用 List 而是用 ZSet?

Redis 里的 List 结构也是有序的,但是它的遍历是要依靠下标的,这就是传统的分页会产生的问题,而 ZSet 是可以决定每次从上次查到的位置继续查的,也就是滚动分页,所以用 ZSet,具体的滚动分页逻辑就是我们不按下标查,我们按分数查,我们记录上次查到的分数,然后再查比这个分数小的就行

附近商户

GEO 数据结构

附近商户搜索

用户签到

BitMap 的用法

签到功能

签到统计

UV 统计

HyperLogLog 用法

实现 UV 统计

补充:多级缓存

传统缓存的问题

JVM 进程缓存

Caffeine

Lua 语法入门

多级缓存

缓存同步策略

第 7 章 分布式缓存

单点 Redis 的问题:

  • 数据丢失问题:Redis 是内存存储,服务重启可能会丢失数据
  • 并发能力问题:单节点 Redis 并发能力虽然不错,但也无法满足如 618 这样的高并发场景
  • 故障恢复问题:如果 Redis 宕机,则服务不可用,需要一种自动的故障恢复手段
  • 存储能力问题:Redis 基于内存,单节点能存储的数据量难以满足海量数据需求

解决:

  • 数据丢失问题:实现 Redis 数据持久化
  • 并发能力问题:搭建主从集群,实现读写分离
  • 故障恢复问题:利用 Redis 哨兵,实现健康检测和自动恢复
  • 存储能力问题:搭建分片集群,利用插槽机制实现动态扩容

7.1 Redis 持久化

7.1.1 RDB 持久化

RDB 全称 Redis Database Backup file(Redis 数据备份文件),也被叫做 Redis 数据快照,简单来说就是把内存中的所有数据都记录到磁盘中,当 Redis 实例故障重启后,从磁盘读取快照文件,恢复数据

快照文件称为 RDB 文件,默认是保存在当前运行目录

Redis 停机时会执行一次 RDB

Redis 内部有触发 RDB 的机制,可以在 redis.conf 文件中找到,格式如下:

RDB 的其它配置也可以在 redis.conf 文件中设置:

7.1.1.1 RDB 持久化原理

bgsave 开始时会 fork 主进程得到子进程,子进程共享主进程的内存数据,完成 fork 后读取内存数据并写入 RDB 文件

但是会有问题:因为是异步写 RDB 文件,所以子进程在写数据到磁盘的时候,主进程是可以修改数据(写数据)的,这样子进程的读操作就和主进程的写操作冲突了,会有可能读到脏数据,为避免这样的情况发生 fork 采用了 copy-on-write 的技术:

  • 当主进程执行读操作时,访问共享内存
  • 当主进程执行写操作时,则会拷贝一份数据,执行写操作

RDB 的缺点:

  • RDB 执行间隔时间长,两次 RDB 之间写入的数据有丢失的风险,即 T0 时刻进行 RDB,然后继续写数据,当 T1 时刻快要进行第二次 RDB 时 Redis 宕机了,那么从 T0 到 T1 之间写入的数据就丢失了
  • fork 子进程、压缩、写出 RDB 文件都比较耗时

7.1.2 AOF 持久化

AOF 全称为 Append Only File(追加文件),Redis 处理的每一个写命令都会记录在 AOF 文件,可以看做是命令日志文件

AOF 默认是关闭的,需要修改 redis.conf 配置文件来开启 AOF:

AOF 的命令记录的频率也可以通过 redis.conf 文件来配:

配置项刷盘时机优点缺点
always同步刷盘(刷到磁盘)可靠性高,几乎不丢数据性能影响大
everysec每秒刷盘性能适中最多丢失 1 秒数据
no操作系统控制性能最好可靠性较差,可能丢失大量数据

因为是记录命令,AOF 文件会比 RDB 文件大的多,而且 AOF 会记录对同一个 key 的多次写操作,但只有最后一次写操作才有意义。通过执行 bgrewriteaof 命令可以让 AOF 文件执行重写功能,用最少的命令达到相同效果

Redis 也会在触发阈值时自动去重写 AOF 文件,阈值也可以在 redis.conf 中配置:

7.1.3 RDB 和 AOF 的对比

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

Redis 如何恢复数据:当 Redis 服务重启时,它会优先查找 AOF 文件,如果存在就通过重放其中的命令来恢复数据,如果不存在或未启用 AOF,则会尝试加载 RDB 文件,直接将二进制数据载入内存来恢复

[[为什么 AOF 文件比 RDB 大?]]

[[Redis数据的可靠性怎么保证?AOF重写期间命令可能会写⼊两次,会造成什么影响?]]

[[RDB + AOF 的混合模式]]

7.2 Redis 主从

7.2.1 搭建主从架构

单节点 Redis 的并发能力是有上限的,要进一步提高 Redis 的并发能力,就需要搭建主从集群,实现读写分离

搭建 Redis 集群教程

7.2.2 主从数据同步原理

7.2.2.1 全量同步

主从第一次同步是全量同步:

master 如何判断 slave 是不是第一次来同步数据?这里会用到两个很重要的概念:

  • Replication Id:简称 replid,是数据集的标记,id 一致则说明是同一个数据集,每一个 master 都有唯一的 replid,slave 则会继承 master 节点的 replid
  • offset:偏移量,随着记录在 repl_baklog 中的数据增多而逐渐增大,slave 完成同步时也会记录当前同步的 offset,如果 slave 的 offset 小于 master 的 offset,说明 slave 数据落后于 master,需要更新

因此 slave 做数据同步,必须向 master 声明自己的 replication id 和 offset,master 才可以判断到底需要同步哪些数据

所以 master 可以通过判断 Relication Id 是否一致来判断 slave 节点是不是第一次来做数据同步

流程:因为从节点在还没有成为从节点之前肯定是主节点,所以会有自己的 replid,当它开始成为从节点时,会先执行 replicaof 命令和主节点建立连接,并把自己的 replid 和 offset 发送给主节点,主节点判断 replid 不一致,说明是第一次,然后返回主节点的 replid 和 offset,从节点就记录下主节点的 replid 和 offset,然后就会开始全量同步。如果不是第一次,那么主节点判断 replid 是一致的,然后就会根据 offset 看看从节点同步的进度,然后开始增量同步

7.2.2.2 增量同步

主从第一次同步是全量同步,但是如果 slave 重启后同步,即不是第一次同步,则执行增量同步

流程:首先 slave 重启后会向主节点发送 replid 和 offset,主节点判断 replid 一致,则不是第一次,则会向从节点发送 continue,然后主节点根据从节点发来的 offset 去 repl_baklog 中找到 offset 以后的数据,然后向从节点发送 offset 后的命令,最后从节点执行命令

7.2.2.2.1 详解 repl_baklog

repl_baklog 是一个环形数组,如果主节点把 repl_baklog 写满了就会覆盖之前的数据

master 往 repl_baklog 写数据,如下图,图中 master 所指的地方就是主节点的 offset 的位置,图中 slave 所指的地方就是从节点的 offset 的位置,两者中间的就是 slave 还没有同步的数据,当从节点开始增量同步的时候,主节点就会找到从节点的 offset 的位置,把从节点的 offset 位置到主节点的 offset 的位置之间的数据发送给从节点

如下图所示,主节点不断地写,从节点不断地同步,当主节点写到 repl_baklog 的上限时,因为之前的数据从节点已经同步过了,所以主节点再写的时候就会把之前的数据覆盖掉

如下图所示,从节点宕机了,但是主节点要一直写数据,当主节点写数据多到超过 repl_baklog 的上限时就会覆盖之前的数据,这样的话也会覆盖从节点还没有来的及备份的数据,此时就不会使用增量同步而是使用全量同步

7.2.3 优化 Redis 主从集群

  1. 在 master 中配置 repl-diskless-sync yes 启用无磁盘复制,避免全量同步时的磁盘 IO
  2. Redis 单节点上的内存占用不要太大,减少 RDB 导致的过多磁盘 IO
  3. 适当提高 repl_baklog 的大小,发现 slave 宕机时应尽快把它恢复,尽可能避免全量同步
  4. 限制一个 master 上的 slave 节点数量,如果实在有太多的 slave,则可以采用 主-从-从 链式结构,减少 master 的压力

7.2.4 总结

简述全量同步和增量同步的区别?

  • 全量同步:master 将完整内存数据生成 RDB,发送 RDB 到 slave,后续命令则记录在 repl_baklog,逐个发送给 slave
  • 增量同步:slave 提交自己的 offset 到 master,master 获取repl_baklog 中从 offset 之后的命令给 slave

什么时候执行全量同步?

  • slave 节点第一次连接 master 节点时
  • slave 节点断开时间太久,repl_baklog 中的 offset 已经被覆盖时

什么时候执行增量同步?

  • slave 节点断开又恢复,并且在 repl_baklog 中能找到 offset 时

7.3 Redis 哨兵

slave 节点宕机恢复后可以找 master 节点同步数据,那如果 master 节点宕机怎么办?

我们可以监测每个节点的状态,如果 master 宕机了,那么我们可以让 slave 当 master,因为 slave 一直同步 master 的数据,和 master 的数据是一致的,如果 master 恢复了,让 master 当 slave 就好了。那么这个监测和重启的动作就交由哨兵来做了

7.3.1 哨兵的作用和原理

Redis 提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复,哨兵的结构和作用如下:

  • 监控:Sentinel 会不断检查你的 master 和 slave 是否按预期工作
  • 自动故障恢复:如果 master 故障,Sentinel 会将一个 slave 提升为 master,当故障实例恢复后也以新的 master 为主
  • 通知:Sentinel 充当 Redis 客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给 Redis 的客户端

Sentinel 基于心跳机制监测服务状态,每隔 1 秒向集群的每个实例发送 ping 命令:

  • 主观下线:如果某个 Sentinel 节点发现某实例未在规定时间响应,则认为该实例主观下线
  • 客观下线:若超过指定数量(quorum)的 Sentinel 都认为该实例主观下线,则该实例客观下线,quorum 值最好超过 Sentinel 实例数量的一半

一旦发现 master 故障,Sentinel 需要在 salve 中选择一个作为新的 master,选择依据是这样的:

  • 首先会判断 slave 节点与 master 节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该 slave 节点
  • 然后判断 slave 节点的 slave-priority 值,越小优先级越高,如果是 0 则永不参与选举
  • 如果 slave-prority 一样,则判断 slave 节点的 offset 值,越大说明数据越新,优先级越高
  • 最后是判断 slave 节点的运行 id 大小,越小优先级越高

当选中了其中一个 slave 为新的 master 后(例如下图的 slave1),故障的转移的步骤如下:

  • Sentinel 给备选的 slave1 节点发送 slaveof no one 命令,让该节点成为 master
  • Sentinel 给所有其它 slave 发送 slaveof 192.168.200.130 7002 命令,让这些 slave 成为新 master 的从节点,开始从新的 master 上同步数据
  • 最后,Sentinel 将故障节点标记为 slave,当故障节点恢复后自动成为新的 master 的 slave 节点

[[Redis 哨兵集群的脑裂问题?]]

7.3.2 搭建哨兵集群

搭建哨兵集群

7.3.3 RedisTemplate 的哨兵模式

在 Sentinel 集群监测下的 Redis 主从集群,其节点会因为自动故障转移而发生变化,Redis 的客户端必须感知这种变化,及时更新连接信息,Spring 的 RedisTemplate 底层利用 lettuce 实现了节点的感知和自动切换,即哨兵模式可以起到服务发现的作用

  1. 在 pom文件中引入 Redis 的 starter 依赖:

  1. 然后在配置文件 application.yml 中指定 Sentinel 的相关信息

  1. 配置主从读写分离

这里的 ReadFrom 是配置 Redis 的读取策略,是一个枚举,包括下面的选择:

  • master:从主节点读取
  • master_preferred:优先从 master 节点读取,master 不可用才读取 slave
  • replica:从 slave 节点读取
  • replica_preferred:优先从 slave 节点读取,所有的 slave 都不可用才读取 master

7.4 Redis 分片集群

7.4.1 搭建分片集群

7.4.1.1 分片集群结构

主从和哨兵可以解决高可用、高并发读的问题,但是依然有两个问题没有解决:

  • 海量数据存储问题
  • 高并发写的问题

使用分片集群可以解决上述问题,分片集群特征:

  • 集群中有多个 master,每个 master 保存不同数据
  • 每个 master 都可以有多个 slave 节点
  • master 之间通过 ping 监测彼此健康状态,这样就不用哨兵了
  • 客户端请求可以访问集群任意节点,最终都会被转发到正确节点

7.4.1.2 搭建分片集群

搭建分片集群

7.4.2 散列插槽

Redis 会把每一个 master 节点映射到 0~16383 共 16384 个插槽(hash slot)上,查看集群信息时就能看到:

数据 key 不是与节点绑定,而是与插槽绑定,Redis 会根据 key 的有效部分计算插槽值,分两种情况:

  • key 中如果包含 {},且 {} 中至少包含 1 个字符,那么 {} 中的部分就是有效部分,那么就会根据 {} 中的部分计算插槽值
  • key 中不包含 {},整个 key 都是有效部分

例如:key 是 num,那么就根据 num 计算插槽值,如果是 {zhishu}num,则根据 zhishu 计算插槽值,计算方式是利用 CRC16 算法得到一个 hash 值,然后对 16384 取余,得到的结果就是插槽值

7.4.3 集群伸缩

集群伸缩指我们可以随意的添加和删除节点

redis-cli --cluster 提供了很多操作集群的命令,可以通过下面方式查看:

比如,添加节点的命令:

案例:向集群中添加一个新的 master 节点,并向其中存储 num = 10

需求:

  • 启动一个新的 Redis 实例,端口为 7004
  • 添加 7004 到之前的集群,并作为一个 master 节点
  • 给 7004 节点分配插槽,使得 num 这个 key 可以存储到 7004 实例中

[[Redis 的一致性 hash]]

7.4.4 故障转移

7.4.4.1 自动故障转移

Redis 分片集群自带哨兵,当有 master 宕机了会自动进行故障转移

当集群中有一个 master 宕机会发生什么呢?

  1. 首先是该实例与其它实例失去连接
  2. 然后是疑似宕机:

  1. 最后是确定下线,自动提升一个 slave 为新的 master:

7.4.4.2 手动故障转移

利用 cluster failover 命令可以手动让集群中的某个 master 宕机,切换到执行 cluster failover 命令的这个 slave 节点,实现无感知的数据迁移,其流程如下:

案例:在 7002 这个 slave 节点执行手动故障转移,重新夺回 master 地位

步骤如下:

  • 利用 redis-cli 连接 7002 这个节点
  • 执行 cluster failover 命令

7.4.5 RedisTemplate 访问分片集群

RedisTemplate 底层同样基于 lettuce 实现了分片集群的支持,而使用的步骤与哨兵模式基本一致:

  1. 引入 Redis 的 starter 依赖
  2. 配置分片集群地址
  3. 配置读写分离

与哨兵模式相比,其中只有分片集群的配置方式略有差异,如下:

第 8 章 Redis 最佳实践

8.1 Redis 键值设计

8.1.1 优雅的 key 结构

Redis 的 Key 虽然可以自定义,但最好遵循下面的几个最佳实践约定:

  • 遵循基本格式:[业务名称]:[数据名]:[id]
  • 长度不超过 44 字节
  • 不包含特殊字符

例如:我们的登录业务,保存用户信息,其 key 是这样的:

优点:

  • 可读性强
  • 避免 key 冲突
  • 方便管理
  • 更节省内存:key 是 string 类型,底层编码包含 int、embstr 和 raw 三种,embstr 在小于 44 字节使用,采用连续内存空间,内存占用更小

8.1.2 拒绝 BigKey

在 Redis 中,BigKey 指的是占用内存过大或包含过多元素的键,具体定义没有严格标准,通常根据业务场景判断:

  • 字符串类型:值大小超过 10MB(例如存储大文件、长文本)
  • 集合类型(哈希、列表、集合、有序集合):元素数量过多(例如哈希表包含 10 万+ 字段,列表包含百万级元素)

BigKey 的危害:

  • 内存占用不均:单个键占用大量内存,可能导致 Redis 实例内存使用率突增,甚至触发内存淘汰,影响其它正常值
  • 操作阻塞:Redis 是单线程模型,对 BigKey 的读写、删除等操作会消耗大量 CPU 和 IO 资源,阻塞其它命令执行,导致业务超时
    • 例如:读取一个 100MB 的字符串,网络传输耗时可能达数百毫秒;删除一个包含 100 万元素的哈希表,DEL 命令会阻塞主线程几秒甚至更久
  • 持久化效率低:
    • RDB 生成时,BigKey 会导致快照文件过大,写入磁盘耗时增加,甚至触发 Redis 卡顿
    • AOF 重写时,BigKey 的操作日志会占用大量磁盘空间,重写过程耗时更长
  • 集群迁移困难:在 Redis Cluster 中,BigKey 所在的哈希槽迁移时,数据传输量过大,可能导致迁移超时或集群抖动

如何发现 BigKey:

  • redis-cli --bigkeys:利用 redis-cli 提供的 --bigkeys 参数,可以遍历分析所有的 key,并返回 key 的整体统计信息与每个数据的 Top1 的 bigkey
  • scan 扫描:自己编程,利用 scan 扫描 Redis 中的所有的 key,利用 strlen、hlen 等命令判断 key 的长度(此处不建议使用 memory usage)
  • 第三方工具:利用第三方工具,如 Redis-Rdb-Tools 分析 RDB 快照文件,全面分析内存使用情况
  • 网络监控:自定义工具,监控进出 Redis 的网络数据,超出预警值时主动告警

BigKey 的处理方案:

处理原则:预防为主、治理为辅,避免 BigKey 产生;若已存在,需平滑拆分或删除,避免业务中断

1. 预防:避免生成 BigKey
  • 键设计拆分

    • 集合类型:按规则拆分元素,避免 “一个键存所有数据”。
      例:用户订单列表(原键user:1001:orders可能包含 10 万条订单)→ 拆分为user:1001:orders:2023user:1001:orders:2024(按年份拆分)。
    • 字符串类型:大文件(如图片、长日志)避免存入 Redis,改用对象存储(如 S3、OSS),Redis 仅存文件 URL。
  • 控制元素数量:业务层限制单个集合的最大元素数(如列表只保留最近 1000 条记录),超过则自动清理旧数据。

  • 过期时间管理:为可能变大的键设置合理过期时间,避免长期积累(如会话数据设 24 小时过期)。

2. 治理:已存在的 BigKey 处理

根据键类型选择不同策略,核心是避免阻塞主线程

(1)字符串类型 BigKey
  • 若内容可拆分(如大 JSON):拆分为多个小字符串,例如user:1001:info→拆分为user:1001:basic(基础信息)、user:1001:address(地址信息)。
  • 若必须存储完整内容:使用GETRANGE分批读取(避免一次性加载全量数据),更新时用SETRANGE局部修改。
(2)集合类型 BigKey(哈希、列表、集合、有序集合)

核心思路:渐进式拆分 / 删除,避免一次性操作全部元素。

  • 拆分策略
    • 哈希(Hash):按字段前缀拆分,例如order:all(存储所有订单)→ 拆分为order:0-9999order:10000-19999(按订单 ID 范围)。
    • 列表(List):按时间 / 页数拆分,例如feed:all→ 拆分为feed:20240918feed:20240919(按日期)。
    • 集合(Set/ZSet):按元素特征拆分,例如tag:user:all(所有带某标签的用户)→ 拆分为tag:user:0-999tag:user:1000-1999(按用户 ID 尾号)。
  • 删除策略
    • 避免直接用DEL命令(会阻塞主线程),改用渐进式删除
      • Redis 4.0 + 支持UNLINK命令:异步删除,将 BigKey 放入后台线程处理,不阻塞主线程。
      • 手动分批删除:用HSCAN(哈希)、SSCAN(集合)等命令遍历元素,每次删除部分(如 100 个),循环执行直至删完。
(3)集群环境下的迁移

若 BigKey 在 Redis Cluster 中,需迁移至新节点时:

  • 先拆分 BigKey 为小键,再迁移拆分后的键(避免单槽数据量过大)。
  • 迁移时使用CLUSTER MIGRATE命令,配合COPY参数(保留原键),迁移完成后再删除原键,减少业务影响。

8.1.3 热 Key

热 Key 的问题: 由于 Redis 是单线程模型,大量请求集中到同一个键会导致该 Redis 节点的 CPU 使用率飙升,响应时间变长 在 Redis 集群环境下,热 Key 还会导致数据分布不均衡,某个节点承受的压力过大而其它节点相对空闲 更严重的是:当热 Key 过期或被误删,会引发缓存击穿问题

怎么监控热 Key:

临时方案使用 redis-cli --hotkeys 命令来监控 Redis 中的热 key

或者在访问缓存时,在本地维护一个计数器,当某个键的访问次数在一分钟内超过设定阈值,就将其标记为热 key

怎么处理热 Key:

最有效的解决方法是增加本地缓存,将热 Key 缓存到本地内存中,这样请求就不需要访问 Redis 了

对于一些特别热的 Key,可以将其拆分成多个子 Key,然后随机分布到不同的 Redis 节点上,比如将 hot_product:12345 拆分成 hot_product:12345:1、hot_product:12345:2 等多个副本,读取时随机选择其中一个:

8.1.4 恰当的数据类型

例一:比如存储一个 User 对象,有三种存储方式:

方式一:json 字符串

方式二:字段打散

方式三:hash

例二:假如有 hash 类型的 key,其中有 100 万对 field 和 value,field 是自增 id,这个 key 存在什么问题?如何优化?

存在的问题:

  • hash 的 entry (一组 field-value)数量超过 500 时,会使用哈希表而不是 ZipList,内存占用较多

方案一:可以通过 hash-max-ziplist-entries 配置 entry 上限,但是如果 entry 过多就会导致 BigKey 问题

方案二:拆分为 string 类型:

存在的问题:

  • string 结构底层没有太多内存优化,内存占用较多
  • 想要批量获取这些数据比较麻烦

方案三:拆分为小的 hash,将 id / 100 作为 key,将 id % 100 作为 field,这样每 100 个元素为一个 Hash

8.1.5 缓存预热

要理解缓存预热,首先需要明确其解决的核心问题 ——缓存冷启动:当系统(如应用、缓存服务)刚上线、重启或缓存大面积失效后,缓存中无任何数据,此时所有用户请求会直接穿透到数据库,可能导致数据库过载、响应延迟甚至宕机。

缓存预热的本质是在系统接收实际业务流量前,主动将 “热点数据” 提前加载到缓存中,从而提升缓存命中率,避免冷启动对数据库和业务的冲击。

  1. 手动预热(适合小规模、静态数据场景)

适用于数据量小(如配置类数据、固定热点列表)、更新频率低的场景,操作简单直接,无需复杂开发。

实现方式:

  • 脚本批量加载:编写脚本(Python/Shell/Java),从数据库查询热点数据,调用缓存 API(如 Redis 的SET/HMSET)写入缓存。
  • 手动执行缓存接口:若系统提供了 “手动刷新缓存” 的 HTTP 接口(如/api/cache/refresh?type=hot-product),可在上线前通过 Postman、curl 手动调用接口触发预热。
  1. 自动预热(适合大规模、动态数据场景)

适用于数据量大、热点数据动态变化(如实时热门新闻、用户实时访问行为)的场景,无需人工干预,通过系统自动触发加载。

常见实现方案:

方案类型核心逻辑技术工具 / 框架示例适用场景
基于历史日志分析分析过去一段时间(如 1 小时 / 1 天)的业务访问日志,筛选 TOP N 热点数据,异步加载日志收集:ELK/Filebeat;分析:Flink/Spark;存储:Redis电商、内容平台(热点随流量变)
服务启动钩子(Startup Hook)应用启动完成后,通过框架钩子自动触发预热逻辑,无需手动触发Java:Spring 的 CommandLineRunner;Python:Django 的 AppConfig微服务启动、单机应用重启
基于配置的热点列表提前在配置中心(如 Nacos/Apollo)配置热点数据标识(如商品 ID 列表),启动时读取配置加载Nacos/Apollo + Redis固定热点(如活动专属商品)
  1. 增量预热(适合超大规模数据场景)

当数据总量极大(如千万级商品),全量预热耗时久、易压垮数据库时,采用 “分批次、分范围” 的增量加载策略,降低单次加载压力。

实现方式:

  • 按维度分片加载:按数据 ID 范围(如商品 ID 1-1000、1001-2000)、时间片(如近 1 小时、1-2 小时)分批次加载,每批加载后暂停 1-5 秒,避免数据库并发过高。
  • 仅加载 “活跃热点”:不加载所有历史热点,只加载 “近 N 小时内有访问记录” 的热点数据(如通过数据库的access_time字段筛选),减少无效数据占用缓存内存。
  • 异步增量补充:全量预热后,通过后台定时任务(如每 10 分钟)补充加载最新产生的热点数据(如刚被大量加入购物车的新商品),保证缓存时效性。

实际场景示例:电商大促(618)缓存预热 以电商 618 大促为例,完整预热流程如下:

  1. 数据筛选:大促前 1 天,通过 Flink 分析过去 7 天的商品访问日志,筛选出 “加购数 TOP 5000 + 历史销量 TOP 3000” 的商品(去重后共 6000 个热点商品);
  2. 执行预热:凌晨 2 点,通过 Python 脚本分 12 批加载(每批 500 个商品),每批加载后 sleep 10 秒,避免数据库压力;
  3. 一致性保障:预热数据设置过期时间 12 小时,同时在大促期间,通过定时任务每小时补充加载 “近 1 小时加购数 TOP 100” 的新热点商品;
  4. 效果验证:凌晨 5 点检查 Redis 命中率达 95%,数据量 6100 个(符合预期),大促开始后无数据库过载问题。

8.1.6 Redis 无底洞问题

[[Redis 的无底洞问题]]

8.1.7 总结

Key 的最佳实践:

  • 固定格式:[业务名]:[数据名]:[id]
  • 足够简短:不超过 44 字节
  • 不包含特殊字符

Value 的最佳实践:

  • 合理的拆分数据,拒绝 BigKey
  • 选择合适的数据结构
  • Hash 结构的 entry 数量不要超过 500(可以调)
  • 设置合理的超时时间

8.2 批处理优化

8.2.1 单机下的批处理 - Pipeline

Pipeline 允许客户端一次性向 Redis 服务器发送多个命令,而不必等待一个命令响应后才能发送下一个,Redis 服务器会按照命令的顺序依次执行,并将所有结果打包返回给客户端

单个命令的执行流程:

一次命令的响应时间 = 1 次往返的网络传输耗时 + 1 次 Redis 执行命令的耗时

N 条命令依次执行的流程:

N 次命令的响应时间 = N 次往返的网络传输耗时 + N 次 Redis 执行命令耗时

N 条命令批量执行的流程:

N 次命令的响应时间 = 1 次往返的网络传输耗时 + N 次 Redis 执行命令耗时

什么场景下适合使用 Pipeline 呢?

需要批量插入、更新或删除数据,或者需要执行大量相似的命令时,比如:系统启动时的缓存预热 -> 批量加载热点数据;比如统计数据的批量更新;比如大批量数据的导入导出;比如批量删除过期或无效的缓存

8.2.1.1 mset

Redis 提供了很多 mxxx 这样的命令,可以实现批量插入数据,例如:

  • mset
  • hmset

利用 mset 批量插入 10 万条数据:

注意:不要在一次批处理中传输太多命令,否则单次命令占用带宽过多,会导致网络阻塞

8.2.1.2 Pipeline

mset 虽然可以批处理,但是却只能操作部分数据类型,因此如果有对复杂数据类型的批处理需要,建议使用 Pipeline 功能:

Pipeline 的多个命令之间不具备原子性,原生的 m 操作具有原子性

8.2.2 集群下的批处理

如 mset 或 Pipeline 这样的批处理需要在一次请求中携带多条命令,而此时如果 Redis 是一个集群,那批处理命令的多个 key 必须落在一个插槽中,否则就会导致执行失败

推荐使用并行 slot,可以使用 Spring 提供的方法实现并行 slot

8.3 服务端优化

8.3.1 持久化配置

Redis 的持久化虽然可以保证数据安全,但也会带来很多额外的开销,因此持久化遵循下列建议:

  • 用来做缓存的 Redis 实例尽量不要开启持久化功能
  • 建议关闭 RDB 持久化功能,使用 AOF 持久化
  • 利用脚本定期在 slave 节点做 RDB,实现数据备份
  • 设置合理的 rewrite 阈值,避免频繁的 bgrewrite
  • 配置 no-appendfsync-on-rewrite = yes,禁止在 rewrite 期间做 AOF,避免因 AOF 引起的阻塞

部署有关建议:

  • Redis 实例的物理机要预留足够内存,应对 fork 和 rewrite
  • 单个 Redis 实例内存上限不要太大,例如 4G 或 8G,可以加快 fork 的速度、减少主从同步、数据迁移压力
  • 不要与 CPU 密集型应用部署在一起
  • 不要与高硬盘负载应用一起部署,例如:数据库、消息队列

8.3.2 慢查询

慢查询:在 Redis 执行时耗时超过某个阈值的命令,称为慢查询

慢查询的阈值可以通过配置指定:

  • slowlog-log-slower-than:慢查询阈值,单位是微秒,默认是 10000,建议 1000

慢查询会被放入慢查询日志中,日志的长度有上限,可以通过配置指定:

  • slowlog-max-len:慢查询日志(本质是一个队列)的长度,默认是 128,建议 1000

可以用 config set命令 修改这两个配置

查看慢查询日志列表:

  • slowlog len:查询慢查询日志长度
  • slowlog get [n]:读取 n 条慢查询日志
  • slowlog reset:清空慢查询列表

8.3.3 命令及安全配置

Redis 会绑定在 0.0.0.0:6379,这样将会把 Redis 服务暴露到公网上,而 Redis 如果没有做身份认证,会出现严重的安全漏洞

Redis未授权访问配合SSH key文件利用分析

漏洞出现的核心的原因有以下几点:

  • Redis 未设置密码
  • 利用了 Redis 的 config set 命令动态修改 Redis 配置
  • 使用了 root 账号权限启动 Redis

为了避免这样的漏洞,这里给出一些建议:

  • Redis 一定要设置密码
  • 禁止线上使用下面命令:keys、flushall、flushdb、config set 等命令,可以利用 rename-command 禁用
  • bind:限制网卡,禁止外网网卡访问
  • 开启防火墙
  • 不要使用 root 账户启动 Redis
  • 尽量不使用默认的端口

8.3.4 内存配置

当 Redis 内存不足时,可能导致 key 频繁被删除、响应时间变长、QPS 不稳定等问题,当内存使用率达到 90% 以上时就需要我们警惕,并快速定位到内存占用的原因

Redis 提供了一些命令,可以查看到 Redis 目前的内存分配状态:

  • info memory

  • memory xxx

8.3.4.1 缓冲区内存配置

内存缓冲区常见的有三种:

  • 复制缓冲区:主从复制的 repl_backlog_buf,如果太小可能导致频繁的全量复制,影响性能,通过 repl-backlog-size 来设置,默认是 1mb
  • AOF 缓冲区:AOF 刷盘之前的缓存区域,AOF 执行 rewrite 的缓冲区,无法设置容量上限
  • 客户端缓冲区:分为输入缓冲区和输出缓冲区,输入缓冲区最大 1G 且不能设置,但是输出缓冲区可以设置:

默认的配置如下:

8.4 集群最佳实践

集群虽然具备高可用特性,能实现自动故障恢复,但是如果使用不当,也会存在一些问题:

  • 集群完整性问题
  • 集群带宽问题
  • 数据倾斜问题
  • 客户端性能问题
  • 命令的集群兼容性问题
  • lua 和事务问题

8.4.1 集群完整性问题

在 Redis 的默认配置中,如果发现任意一个插槽不可用,则整个集群都会停止对外服务:

为了保证高可用特性,这里建议将 cluster-require-full-coverage 配置为 false

8.4.2 集群带宽问题

集群节点之间会不断的互相 ping 来确定集群中其它节点的状态,每次 ping 携带的信息至少包括:

  • 插槽信息
  • 集群状态信息

集群中节点越多,集群状态信息数据量也越大,10 个节点的相关信息可能达到 1kb,此时每次集群互通需要的带宽会非常高

解决途径:

  • 避免大集群,集群节点数不要太多,最好少于 1000,如果业务庞大,则建立多个集群
  • 避免在单个物理机中运行大多 Redis 实例
  • 配置合适的 cluster-node-timeout

8.4.3 集群还是主从

单体 Redis(主从 Redis)已经能达到万级别的 QPS,并且也具备很强的高可用特性,如果主从能满足业务需求的情况下,尽量不搭建 Redis 集群

第 9 章 Redis 底层数据结构

9.1 动态字符串 SDS

Redis 中保存的 Key 是字符串,value 往往是字符串或者字符串的集合,可见字符串是 Redis 中最常见的一种数据结构

不过 Redis 没有直接使用 C 语言中的字符串,因为 C 语言字符串存在很多问题:

  • 获取字符串的长度需要通过计算
  • 非二进制安全
  • 不可修改

Redis 构建了一种新的字符串结构,称为简单动态字符串(Simple Dynamic String),简称 SDS

例如执行命令:set name zhishu

那么 Redis 将在底层创建两个 SDS,其中一个是包含 name 的 SDS,另一个是包含 zhishu 的 SDS

Redis 是 C 语言实现的,其中 SDS 是一个结构体,源码如下:

例如:一个字符串 name 的 SDS 结构如下:

SDS 之所以叫做动态字符串,是因为它具备动态扩容的能力,例如一个内容为 hi 的 SDS:

假如我们要给 SDS 追加一段字符串 ,Amy,这里首先会申请新内存空间:

  • 如果新字符串小于 1M,则新空间为扩展后字符串的长度的两倍 + 1
  • 如果新字符串大于 1M,则新空间为扩展后字符串长度 +1M +1,称为内存预分配

因为 hi,Amy 小于 1M,所以新空间扩展成 6 * 2 + 1 = 13,len 记录已保存的字符串的字节数,不包含结束标识,因为是 6 个字符,一个字符占 1 个字节,所以 len 是 6;alloc 记录申请的总字节数,不包含结束标识,所以 alloc 是 12

优点:

  • 获取字符串长度的时间复杂度为 O(1)
  • 支持动态扩容
  • 减少内存分配次数
  • 二进制安全

9.2 IntSet

IntSet 是 Redis 中 set 集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征

结构如下:

其中的 encoding 包含三种模式,表示存储的整数大小不同:

为了方便查找,Redis 会将 intset 中所有的整数按照升序依次保存在 contents 数组中,结构如图:

现在,数组中每个数字都在 int16_t 的范围内,因此采用的编码方式是 INTSET_ENC_INT16,每部分占用的字节大小为:

  • encoding:4 字节
  • length:4 字节
  • contents:2 字节*3 = 6 字节

这里的 contents 里的数据都被固定为 2 字节,5 占用 2 个字节、10 占用 2 个字节,这个是由 INTSET_ENC_INT16 决定的,那么为什么要固定每个元素的字节数呢?是为了方便寻址

假设起始地址为 0x001 那么下一个元素的地址就是前一个的地址加 2 字节,因为每个数据都被固定为 2 字节,所以每个元素的间隔都是 2 字节,这样计算下一个的元素的地址就只用在前一个地址加 2 即可

那么如果存入的数据超过 2 字节怎么办,即超过 32767?IntSet 支持升级

现在,假设有一个 intset,元素为 {5,10,20},采用的编码是 INTSET_ENC_INT16,则每个整数占 2 字节:

我们向其中添加一个数字 50000,这个数字超出了 INTSET_ENC_INT16 的范围,intset 会自动升级编码方式到合适的大小,流程如下:

  • 升级编码为 INTSET_ENC_INT32,每个整数占 4 字节,并按照新的编码方式及元素个数进行扩容数组
  • 倒序依次将数组中的元素拷贝到扩容后的正确位置

为什么是倒序?因为如果正序的话把 5 扩容为 4 字节则会覆盖 10 的那两个字节,10 就被覆盖了。倒序先拷贝 20,20 是下标为 2 的元素,那么它的其实位置就为 2 * 4 = 8 所以 20 就会被拷贝到 8 的位置

  • 将待添加的元素放入数组末尾

  • 最后,将 intset 的 encoding 属性改为 INTSET_ENC_INT32,将 length 属性改为 4

Intset 可以看做是特殊的整数数组,具备一些特点:

  • Redis 会确保 Intset 中的元素唯一、有序
  • 具备类型升级机制,可以节省内存空间
  • 底层采用二分查找方式来查询

9.3 Dict

我们知道 Redis 是一个键值型(Key-Value)的数据库,我们可以根据键实现快速的增删改查,而键与值的映射关系正是通过 Dict 来实现的

Dict 由三部分组成,分别是:哈希表(DicHashTable)、哈希节点(DictEntry)、字典(Dict)

哈希表组成:

哈希节点组成:

当我们向 Dict 添加键值对时,Redis 首先根据 key 计算出 hash 值(h),然后利用 h & sizemask 来计算元素应该存储到数组中的哪个索引位置。我们存储 k1 = v1,假设 k1 的哈希值 h = 1,则 1 & 3 = 1,因此 k1 = v1 要存储到数组角标 1 的位置,如果又有一个 k2 = v2,k2 的哈希值也等于 1,那么就会把 k2 存储在 k1 的前面从而形成一个链表

字典组成:

9.3.1 Dict 的扩容

Dict 中的 HashTable 就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低

Dict 在每次新增键值对时都会检查负载因子(LoadFactor = used / size),满足以下两种情况时会触发哈希表扩容:

  • 哈希表的 LoadFactor >= 1,并且服务器没有执行 bgsave 或者 bgrewriteaof 等后台进程
  • 哈希表的 LoadFactor > 5

Dict 除了扩容以外,每次删除元素时,也会对负载因子做检查,当 LoadFactor < 0.1 时,会做哈希表收缩:

9.3.2 Dict 的 rehash

不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的 size 和 sizemask 变化,而 key 的查询与 sizemask 有关,因此必须对哈希表中的每一个 key 重新计算索引,插入新的哈希表,这个过程称为 rehash,过程是这样的:

  1. 计算新 hash 表的 realeSize,值取决于当前要做的是扩容还是收缩:
    • 如果是扩容,则新 size 为第一个大于等于 dict.ht[0].used + 12 ^ n
    • 如果是收缩,则新 size 为第一个大于等于 dict.ht[0].used2 ^ n(不得小于 4)
  2. 按照新的 realeSize 申请内存空间,创建 dictht,并赋值给 dict.ht[1]
  3. 设置 dict.rehashidx = 0,标示开始 rehash
  4. dict.ht[0]中的每一个 dictEntry 都 rehash 到 dict.ht[1]
  5. dict.ht[1]赋值给 dict.ht[0],给 dict.ht[1] 初始化为空哈希表,释放原来的 dict.ht[0] 的内存

Dict 的 rehash 并不是一次性完成的,试想一下,如果 Dict 中包含数百万的 entry,要在一次 rehash 完成,极有可能导致主线程阻塞,因此 Dict 的 rehash 是分多次、渐进式的完成,因此称为渐进式 rehash,流程如下:

  1. 计算新 hash 表的 realeSize,值取决于当前要做的是扩容还是收缩:
    • 如果是扩容,则新 size 为第一个大于等于 dict.ht[0].used + 12 ^ n
    • 如果是收缩,则新 size 为第一个大于等于 dict.ht[0].used2 ^ n(不得小于 4)
  2. 按照新的 realeSize 申请内存空间,创建 dictht,并赋值给 dict.ht[1]
  3. 设置 dict.rehashidx = 0,标示开始 rehash
  4. 每次执行新增、查询、修改、删除操作时,都检查一下 dict.rehashidx 是否大于 -1,如果是则将 dict.ht[0].table[rehashidx] 的 entry 链表 rehash 到 dict.ht[1],并且将 rehashidx++,直至 dict.ht[0] 的所有数据都 rehash 到 dict.ht[1]
  5. dict.ht[1]赋值给 dict.ht[0],给 dict.ht[1] 初始化为空哈希表,释放原来的 dict.ht[0] 的内存
  6. 将 rehashidx 赋值为 -1,代码 rehash 结束
  7. 在 rehash 过程中,新增操作,则直接写入 ht[1],查询、修改和删除则会在 dict.ht[0]dick.ht[1] 依次查找并执行,这样可以确保 ht[0] 的数据只减不增,随着 rehash 最终为空

9.4 ZipList

ZipList 是一种特殊的双端链表(不是双端链表,只是像双端链表,它没有用指针),由一系列特殊编码的连续内存块组成,可以在任意一端进行压入/弹出操作,并且该操作的时间复杂度为 O(1)

其中 entry 的长度是不固定的,这样可以节省内存。但是长度不固定(违背数组的遍历方式)又没有指针(违背链表的遍历方式)那怎么寻址遍历呢?这就跟 entry 的数据结构有关系了

ZipList 中的 Entry 并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用 16 个字节,浪费内存,而是采用下图的结构:

  • previous_entry_length:前一个 entry 的长度,占 1 个或 5 个字节
    • 如果前一个 entry 的长度小于 254 字节,则采用 1 个字节来保存这个长度值
    • 如果前一个 entry 的长度大于 254 字节,则采用 5 个字节来保存这个长度值,第一个字节为 0xfe,后四个字节才是真实长度数据
  • encoding:编码属性,记录 content 的数据类型(字符串还是整数)以及 content 的长度,占用 1 个、2 个或 5 个字节
  • contents:负责保存节点的数据,可以是字符串或整数

其中 encoding 编码分为字符串和整数两种:

  • 字符串:如果 encoding 是以 0001或者 10 开头,则证明 content 是字符串

例如:保存字符串:"ab""bc"

加入 "bc"

整个 ZipList 的组成:

  • 整数:如果 encoding 是以 11 开始,则证明 content 是整数,且 encoding 固定只占用 1 个字节

例如:一个 ZipList 中保存两个整数值 2 和 5

保存 2:

保存 5:

整个 ZipList:

小端字节序:ZipList 中所有存储长度的数值均采用小端字节序,即低位字节在前,高位字节在后,例如:数值 0x1234,采用小端字节序后实际存储值为:0x3412

9.4.1 ZipList 的连锁更新问题

ZipList 的每个 Entry 都包含 previous_entry_length 来记录上一个节点的大小,长度是 1 个或 5 个字节:

  • 如果前一个 entry 的长度小于 254 字节,则采用 1 个字节来保存这个长度值
  • 如果前一个 entry 的长度大于 254 字节,则采用 5 个字节来保存这个长度值,第一个字节为 0xfe,后四个字节才是真实长度数据

现在假设有 N 个连续的、长度为 250~253 字节之间的 entry,因此 entry 的 previous_entry_length 属性用 1 个字节即可表示,如图:

此时我要在第一个元素前加一个 entry,长度为 254 字节,那么第一个元素的原本的 previous_entry_length 用 1 个字节表示就不够了,就要增加到 5 个字节,然后第一个元素的整个 entry 就增加了 4 个字节到 254 字节了,那么后一个元素的 previous_entry_length 因为要记录前一个 entry 的大小(254 字节)也就不够了,也要用 5 个字节来表示,那么这后一个的整个 entry 也到 254 字节了,然后依次类推,后面的所有的 entry 的 previous_entry_length 都要变成用 5 个字节来表示,如图:

ZipList 这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新,新增、删除都可能导致连锁更新的发生

9.4.2 总结

  • 压缩列表可以看做一种连续内存空间的“双向链表”
  • 列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低
  • 如果列表数据过多,导致链表过长,可能影响查询性能
  • 增或删较大数据时可能发生连续更新问题

9.5 QuickList

问题 1:ZipList 虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低,怎么办?

为了缓解这个问题,我们必须限制 ZipList 的长度和 entry 的大小

问题 2:但是我们要存储大量数据,超出了 ZipList 最佳的上限该怎么办?

我们可以创建多个 ZipList 来分片存储数据

问题 3:数据拆分后比较分散,不方便管理和查找,这多个 ZipLIst 如何建立联系?

Redis 在 3.2 版本引入了新的数据结构 QuickList,它是一个双端链表,只不过链表中的每个节点都是一个 ZipList

为了避免 QuickList 中的每个 ZipList 中的 entry 过多,Redis 提供了一个配置项:list-max-ziplist-size 来限制

  • 如果值为正,则代表 ZipList 允许的 entry 的个数的最大值
  • 如果值为负,则代表 ZipList 的最大内存大小,分 5 种情况:
    • -1:每个 ZipList 的内存占用不能超过 4kb
    • -2:每个 ZipList 的内存占用不能超过 8kb
    • -3:每个 ZipList 的内存占用不能超过 16kb
    • -4:每个 ZipList 的内存占用不能超过 32kb
    • -5:每个 ZipList 的内存占用不能超过 64kb

其默认值为 -2:

除了控制 ZipList 的大小,QuickList 还可以对节点的 ZipList 做压缩,通过配置项 list-compress-depth 来控制,因为链表一般都是从首尾访问较多,所以首尾是不压缩的,这个参数是控制首尾不压缩的节点个数:

  • 0:特殊值,代表不压缩
  • 1:标识 QuickList 的首尾各有 1 个节点不压缩,中间节点压缩
  • 2:标识 QuickList 的首尾各有 2 个节点不压缩,中间节点压缩
  • 以此类推

默认值:

以下是 QuickList 和 QuickListNode 的源码:

QuickList 的特点:

  • 是一个节点为 ZipList 的双端链表
  • 节点采用 ZipList,解决了传统链表的内存占用问题
  • 控制了 ZipList 的大小,解决连续内存空间申请效率问题
  • 中间节点可以压缩,进一步节省了内存

9.6 SkipList

问题:ZipList 和 QuickList 都是双端链表,双端链表在查询两端的元素时效率高,但是如果查询中间的元素就不得不遍历到中间元素,效率低

SkipList(跳表)首先是链表,但与传统链表相比有几点差异:

  • 元素按照升序排列存储
  • 节点可能包含多个指针,指针跨度不同

SkipList 结构源码:

查找顺序:先从最大的 level 找,图中是 level3,如果要找的元素的 score 比第二个大则往后找,如果在两个之间,则进入下一个 level 继续查找

SkipList 的特点:

  • 跳表是一个双向链表,每个节点都包含 score 和 ele 值
  • 节点按照 score 值排序,score 值一样则按照 ele 字典排序
  • 每个节点都可以包含多层指针,层数是 1 到 32 之间的随机数
  • 不同层指针到下一个节点的跨度不同,层级越高,跨度越大
  • 增删改查效率与红黑树基本一致,实现却更简单

9.7 RedisObject

Redis 中的任意数据类型的键和值都会被封装为一个 RedisObject,也叫作 Redis 对象,源码如下:

encoding 的 11 中编码方式:Redis 中会根据存储的数据类型不同,选择不同的编码方式,共包含 11 种不同类型:

5 种不同的数据类型有自己不同的编码方式:

9.8 五种数据结构

9.8.1 String

String 是 Redis 中最常见的数据存储类型:

  • 其基本编码方式是 raw,基于简单动态字符串(SDS)实现,存储上限为 512MB

  • 如果存储的 SDS 长度小于 44 字节,则会采用 embstr 编码,此时 RedisObject 的头信息与 SDS 是一段连续空间,申请内存时只需要调用一次内存分配函数,效率更高

  • 如果存储的字符串是整数值,并且大小在 long_max 范围内,则会采用 int 编码,直接将数据保存在 RedisObject 的 ptr 指针位置(刚好 8 个字节),不再需要 SDS 了

9.8.2 List

Redis 的 List 类型可以从首、尾操作列表中的元素:

推断 Redis 的 List 类型的底层编码:

  • LinkedList:普通链表,可以从双端访问,但是内存占用较高,内存碎片较多
  • ZipList:压缩列表,可以从双端访问,内存占用低,但是存储上限低
  • QuickList:LinkedList + ZipList,可以从双端访问,内存占用较低,包含多个 ZipList,存储上限高,所以是 QuickList 编码

在 3.2 版本之前,Redis 采用 ZipList 和 LinkedList 来实现 List,当元素数量小于 512 并且元素大小小于 64 字节时采用 ZipList 编码,超过则采用 LinkedList 编码

在 3.2 版本之后,Redis 统一采用 QuickList 来实现 List

9.8.3 Set

Set 是 Redis 中的单列集合,满足下列特点:

  • 不保证有序性
  • 保证元素唯一(可以判断元素是否存在)
  • 求交集、并集、差集

可以看出,Set 对查询元素的效率要求非常高,那么什么样的数据结构可以满足?

HashTable,也就是 Redis 中的 Dict,不过 Dict 是双列集合,可以存键值对

Set 是 Redis 中的集合,不一定确保元素有序,可以满足元素唯一、查询效率要求极高

  • 为了查询效率和唯一性,set 采用 HT 编码(Dict),Dict 中的 key 用来存储元素,value 统一为 null
  • 当存储的所有数据都是整数,并且元素数量不超过 set-max-intset-entries 时,Set 会采用 IntSet 编码,以节省内存

set-max-intset-entries 的默认值是 512

内存结构图:

9.8.4 ZSet

ZSet 也就是 SortedSet,其中每一个元素都需要指定一个 score 值和 member 值:

  • 可以根据 score 值排序
  • member 必须唯一
  • 可以根据 member 查询分数

因此,ZSet 底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求,那么哪种编码结构可以满足?

  • SkipList:可以排序,并且可以同时存储 score 和 ele 值(member),但是无法保证键唯一和根据 member 查询分数
  • HT(Dict):可以键值存储,并且可以根据 key 找 value,但是不可排序
  • 所以要两者结合

内存结构图:

这种结构内存占用过高,所以还有第二种方式

当元素数量不多时,HT 和 SkipList 的优势不明显,而且更耗内存,因此 ZSet 还会采用 ZipList 结构来节省内存,不过需要同时满足两个条件:

  • 元素数量小于 zset_max_ziplist_entries,默认值 128
  • 每个元素都小于 zset_max_ziplist_value 字节,默认值 64

那 ZSet 在初始化时是哪种结构?

如果 zset_max_ziplist_entries 为 0,初始化的元素大于 64 字节就用 HT + SkipList 编码,否则就用 ZipList 编码

那么既然有两种不同的编码,那就会存在编码转换,源码如下:

ZipList 本身没有排序功能,而且没有键值对的概念,因此需要由 ZSet 通过编码实现:

  • ZipList 是连续内存,因此 score 和 element 是紧挨在一起的两个 entry,element 在前,score 在后
  • score 越小越接近队首,score 越大越接近队尾,按照 score 值升序排列

9.8.5 Hash

Hash 结构与 Redis 中的 ZSet 非常类似:

  • 都是键值存储
  • 都需要根据键获取值
  • 键必须唯一

Hash 的:

ZSet 的:

区别如下:

  • ZSet 的键是 member,值是 score;hash 的键和值都是任意值
  • ZSet 要根据 score 排序;hash 则无需排序

因此,Hash 底层采用的编码与 ZSet 也基本一致,只需要把排序有关的 SkipList 去掉即可:

  • Hash 结构默认采用 ZipList 编码,用以节省内存,ZipList 中相邻的两个 entry 分别保存 field 和 value

  • 当数据量较大时,Hash 结构会转为 HT 编码,也就是 Dict,触发条件有两个:
    • ZipList 中的元素数量超过了 hash-max-ziplist-entries 默认是 512
    • ZipList 中的任意 entry 大小超过了 hash-max-ziplist-value 默认是 64 字节

源码分析:

第 10 章 Redis 网络模型

10.1 用户空间和内核空间

服务器大多都是采用 Linux 系统,所以着重学习 Linux

任何 Linux 的发行版本,其系统内核都是 Linux,我们的应用都需要通过 Linux 内核与硬件交互

但是有一个问题,因为内核本身也是应用,它也要用到 CPU 等硬件,这里如果让用户应用直接操作 CPU 等硬件,很有可能会产生冲突

为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的:

  • 进程的寻址空间会划分为两部分:内核空间、用户空间

  • 用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问
  • 内核空间可以执行特权命令(Ring0),调用一切系统资源

如果有一个进程在用户空间执行但是要调用系统资源,那么它就会在用户空间和内核空间转换,也就是从用户态转为内核态,内核态转为用户态

Linux 系统为了提高 IO 效率,会在用户空间和内核空间都加入缓冲区:

  • 写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
  • 读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区

可以看出 IO 耗时的地方就是等待数据和用户态和内核态之间的缓冲区的 copy 的部分,所以 Linux 的那些 IO 模型其实优化的就是这两部分

10.2 阻塞 IO

再来看一下流程:

用户向读取磁盘中的数据,不能直接操作,要调用接口访问内核空间,然后内核空间访问磁盘,然后就等待磁盘返回数据,磁盘返回数据到内核缓冲区,内核缓冲区再把数据 copy 到用户缓冲区,等待的其实就是等硬件把数据写到内核缓冲区

所以可以把流程分为两个阶段,一个是等待数据就绪,一个是读取数据

阻塞 IO 顾名思义就是用户进程在这两个阶段都必须阻塞等待:

用户进程在等待数据的时候是阻塞的,没有数据就一直阻塞;在从内核拷贝数据到用户空间(即读取数据的时候)的时候也是阻塞的,一直阻塞到拷贝完

10.3 非阻塞 IO

非阻塞 IO 顾名思义就是用户进程在调用 recvfrom 操作时会立即返回结果而不是阻塞用户进程:

非阻塞 IO 的用户进程在调用 recvfrom 获取数据时,如果内核缓冲区没有数据不会傻傻的阻塞,而是会立即返回失败结果,此时内核空间知道用户空间要数据,然后就会去调用硬件,在硬件准备数据还没有返回数据给内核空间的时间段内用户进程会不断地调用直到数据就绪,然后内核空间会将数据拷贝到用户空间,但在拷贝的过程中用户进程是阻塞的,用户进程会一直等到数据拷贝完成

可以看出,非阻塞 IO 模型中,用户进程在第一阶段不阻塞,第二阶段阻塞,虽然是非阻塞,但性能并没有得到提高,而且忙等机制会导致 CPU 空转,CPU 使用率暴增

10.4 IO 多路复用

无论是阻塞 IO 还是非阻塞 IO,用户进程在第一阶段都需要调用 recvfrom 来获取数据,差别在于无数据时的处理方案:

  • 如果调用 recvfrom 时,恰好没有数据,阻塞 IO 会使进程阻塞,非阻塞 IO 使 CPU 空转,都不能充分发挥 CPU 的作用
  • 如果调用 recvfrom 时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据

例如服务端处理客户端 Socket 请求时,在单线程情况下,只能依次处理每一个 Socket,如果正在处理的 Socket 恰好未就绪(数据不可读或者不可写),线程就会被阻塞,所有其它客户端 Socket 都必须等待,性能自然会很差

就好像服务员给顾客点餐,分两步:

  • 顾客思考要吃什么(等待数据就绪)
  • 顾客想好了,开始点餐(读取数据)

如果第一个顾客一直犹豫,而后面的顾客早就想好了,那么第一个顾客就影响了整体的性能

要提高效率的话有什么办法?

方案一:增加更多的服务员(增加多个线程),但是这样会增大开销

方案二:顾客不用排队,谁想好了就谁先来点餐,服务员就给谁点餐

放在 IO 中就是用户进程先去获取已经就绪的数据,还没有就绪的等着后面获取

那么问题来了,用户进程如何知道内核中数据是否就绪呢?

文件描述符(File Descriptor):简称 FD,是一个从 0 开始递增的无符号整数,用来关联 Linux 中的一个文件,在 Linux 中,一切皆是文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字 Socket

IO 多路复用:是利用单个线程来同时监听多个 FD,并在某个 FD 可读、可写时得到通知,从而避免无效的等待,充分利用 CPU 资源

用户进程不再调用 recvfrom 方法,而是调用 select 方法,其中 recvfrom 方法只能接收一个 FD 然后进行后续处理,而 select 方法可以接收多个 FD 进行后续处理

首先用户进程调用 select 方法到内核空间,内核空间判断 select 方法中的 FD 哪些是准备好的,如果有准备好的 FD 就立马返回数据给用户进程,然后用户进程就会依次调用 recvfrom 方法读取 FD 的数据;这里如果所有的 FD 都没有准备好那么 select 方法就会被阻塞,这种很多 FD 都没有准备好的情况发生的概率很低

不过监听 FD 的方式、通知的方式又有多种实现,常见的有:

  • select
  • poll
  • epoll

select 和 poll 的监听和通知方式:

就相当于顾客坐在自己的桌子上,每个桌子都有一个按钮,按下按钮服务员前面的灯会亮,这时有一个顾客想好了,然后按下按钮,服务员的灯亮了,但是只有一个灯,所以服务员根本不知道是哪个顾客按的,然后服务器就要去一个一个的问是谁按的

epoll 的监听和通知方式:

顾客点餐时按下按钮会在服务员的电脑上显示是几号桌的顾客要点餐,这样就可以快速找到要点餐的顾客

总结:

  • select 和 poll 只会通知用户进程有 FD 就绪,但不确定具体是哪个 FD,需要用户进程逐个遍历 FD 来确认
  • epoll 则会在通知用户进程 FD 就绪的同时把已就绪的 FD 写入用户空间

10.4.1 IO 多路复用 - select

select 是 Linux 中最早的 IO 多路复用实现方案:

select 模式存在的问题:

  • 需要将整个 fd_set 从用户空间拷贝到内核空间,select 结束还要再次拷贝回用户空间
  • select 无法得知具体是哪个 fd 就绪,需要遍历整个 fd_set
  • fd_set 监听的 fd 数量不能超过 1024

10.4.2 IO 多路复用 - poll

poll 模式对 select 模式做了简单改进,但性能提升不明显,部分关键代码如下:

IO 流程:

  • 创建 pollfd 数组,向其中添加关注的 fd 信息,数组大小自定义
  • 调用 poll 函数,将 pollfd 数组拷贝到内核空间,转链表存储,无上限
  • 内核遍历 fd,判断是否就绪
  • 数据就绪或超时后,拷贝 pollfd 数组到用户空间,返回就绪 fd 数量 n
  • 用户进程判断 n 是否大于 0
  • 大于 0 则遍历 pollfd 数组,找到就绪的 fd

与 select 对比:

  • select 模式中的 fd_set 大小固定为 1024,而 pollfd 在内核中采用链表,理论上无上限
  • 监听 FD 越多,每次遍历消耗时间也越久,性能反而会下降

10.4.3 IO 多路复用 - epoll

epoll 模式是对 select 和 poll 的改进,它提供了三个函数:

总结:

select 模式存在的三个问题:

  • 能监听的 FD 最大不超过 1024
  • 每次 select 都需要把所有要监听的 FD 都拷贝到内核空间
  • 每次都要遍历所有 FD 来判断就绪状态

poll 模式的问题:

  • poll 利用链表解决了 select 中监听 FD 上限的问题,但依然要遍历所有 FD,如果监听较多,性能会下降

epoll 模式中如何解决这些问题的?

  • 基于 epoll 实例中的红黑树保存要监听的 FD,理论上无上限,而且增删改查效率都非常高,性能不会随监听的 FD 数量增多而下降
  • 每个 FD 只需要执行一次 epoll_ctl 添加到红黑树,以后每次 epol_wait 无需传递任何参数,无需重复拷贝 FD 到内核空间
  • 内核会将就绪的 FD 直接拷贝到用户空间的指定位置,用户进程无需遍历所有 FD 就能知道就绪的 FD 是谁

10.4.4 IO 多路复用 - 事件通知机制

当 FD 有数据可读时,我们调用 epoll_wait 就可以得到通知,但是事件通知的模式有两种:

  • LevelTriggered:简称 LT,当 FD 有数据可读时,会重复通知多次,直至数据处理完成,是 Epoll 的默认模式
  • EdgeTriggered:简称 ET,当 FD 有数据可读时,只会被通知一次,不管数据是否处理完成

例如:

  1. 假设一个客户端 socket 对应的 fd 已经注册到了 epoll 实例中
  2. 客户端 socket 发送了 2kb 的数据
  3. 服务端调用 epoll_wait,得到通知说 fd 就绪
  4. 服务端从 fd 读取了 1kb 的数据
  5. 回到步骤 3(再次调用 epoll_wait,形成循环),这里两种事件通知机制就不一样了,如果是 LT,那么这里回到步骤 3 就会得到通知说 fd 就绪;如果是 ET,那么这里回到步骤 3 就不会得到通知说 fd 就绪,那么剩下的 1kb 的数据就丢失了

LT 模式下,list_head 在把就绪的 fd 形成链表后拷贝到用户空间,如果用户空间没有处理完会再把链表拷贝到用户空间

ET 模式下,list_head 在把链表拷贝到用户空间后就直接销毁链表了,所以不会重复通知

ET 模式避免了 LT 模式可能出现的惊群现象,ET 模式最好结合非阻塞 IO 读取 FD 数据,相比 LT 会复杂一些

10.4.5 IO 多路复用 - web 服务流程

基于 epoll 模式的 web 服务的基本流程如图:

10.5 信号驱动 IO

信号驱动 IO 是与内核建立 sigio 的信号关联并设置回调,当内核有 FD 就绪时,会发出 sigio 信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待

阶段一:

  • 用户进程调用 sigaction,注册信号处理函数
  • 内核返回成功,开始监听 fd
  • 用户进程不阻塞等待,可以执行其它业务
  • 当内核数据就绪后,回调用户进程的 sigio 处理函数

阶段二:

  • 收到 sigio 回调信号
  • 调用 recvfrom,读取
  • 内核将数据拷贝到用户空间
  • 用户进程处理数据

当有大量 IO 操作时,信号较多,sigio 处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低,所以使用少

10.6 异步 IO

异步 IO 的整个过程都是非阻塞的,用户进程调用完异步 API 后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程

阶段一:

  • 用户进程调用 aio_read,创建信号回调函数
  • 内核等待数据就绪
  • 用户进程无需阻塞,可以做任何事情

阶段二:

  • 内核数据就绪
  • 内核数据拷贝到用户缓冲区
  • 拷贝完成,内核递交信号触发 aio_read 中的回调函数
  • 用户进程处理数据

可以看到,异步 IO 模型中,用户进程在两个阶段都是非阻塞状态

IO 操作是同步还是异步,关键看数据在内核空间与用户空间的拷贝过程(数据读写的 IO 操作),也就是第二阶段是同步还是异步:

10.7 Redis 网络模型

Redis 到底是单线程还是多线程?

  • 如果仅仅聊 Redis 的核心业务部分(命令处理),答案是单线程
  • 如果是聊整个 Redis,那么答案就是多线程

在 Redis 版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:

  • Redis4.0:引入多线程异步处理一些耗时较长的任务,例如异步删除命令 unlink
  • Redis6.0:在核心网络模型中引入多线程,进一步提高对于多核 CPU 的利用率

为什么 Redis 要选择单线程?

  • 抛开持久化不谈,Redis 是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升
  • 多线程会导致过多的上下文切换,带来不必要的开销
  • 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣

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

Redis 单线程网络模型的整个流程:

Redis6.0 版本中引入了多线程,目的是为了提高 IO 读写效率,因此在解析客户端命令、写响应结果时采用了多线程,核心的命令执行、IO 多路复用模块依然是由主线程执行

10.8 Redis 通信协议

10.8.1 RESP 协议

Redis 是一个 CS 架构的软件,通信一般分为两步(不包括 Pipeline 和 PubSub)

  • 客户端(Client)向服务端(Server)发送一条命令
  • 服务端解析并执行命令,返回响应结果给客户端

因此客户端发送命令的格式、服务端响应结果的格式必须有一个规范,这个规范就是通信协议

而在 Redis 中采用的是 RESP(Redis Serialization Protocol)协议:

  • Redis1.2 版本中引入了 RESP 协议
  • Redis2.0 版本中成为与 Redis 服务端通信的标准,称为 RESP2
  • Redis6.0 版本中,从 RESP2 升级到了 RESP3 协议,增加了更多数据类型并且支持 6.0 的新特性 - 客户端缓存

但目前,默认使用的依然是 RESP2 协议

在 RESP 中,通过首字节的字符来区分不同数据类型,常用的数据类型包括 5 种:

10.8.2 模拟 Redis 客户端

10.9 Redis 内存策略

Redis 之所以性能强,最主要的原因就是基于内存存储,然而单节点的 Redis 其内存大小不宜过大,会影响持久化或主从同步性能

我们可以通过修改配置文件来设置 Redis 的最大内存:

当内存使用达到上限时,就无法存储更多数据了

10.9.1 过期策略

可以通过 expire 命令给 Redis 的 key 设置 TTL(存活时间):

可以发现,当 key 的 TTL 过期以后,再次访问 name 返回 nil,说明这个 key 已经不存在了,对应的内存也得到释放,从而起到内存回收的目的

两个问题:

  1. Redis 是如何知道一个 key 是否过期呢?

Redis 本身是一个典型的 key-value 内存存储数据库,因此所有的 key、value 都保存在 Dict 结构中,不过在其 database 结构体中,有两个 Dict:一个用来记录 key-value,另一个用来记录 key-TTL

Redis 利用两个 Dict 分别记录 key-value 和 key-ttl,想知道该 key 有没有过期直接查 key-ttl 即可

  1. 是不是 TTL 到期就立即删除了呢(过期删除策略)?
  • 惰性删除

惰性删除并不是在 TTL 到期后就立即删除,而是在访问一个 key 的时候,检查该 key 的存活时间,如果已经过期才执行删除

  • 周期删除

周期删除是通过一个定时任务,周期性的抽样部分过期的 key,然后执行删除,执行周期有两种:

  • Redis 会设置一个定时任务 serverCron(),按照 server.hz 的频率来执行过期 key 清理,模式为 slow

  • Redis 的每个事件循环前会调用 beforeSleep() 函数,执行过期 key 清理,模式为 fast

slow 模式规则:

  • 执行频率受 server.hz 影响,默认为 10,即每秒执行 10 次,每个执行周期 100ms
  • 执行清理耗时不超过一次执行周期的 25%
  • 逐个遍历 db,逐个遍历 db 中的 bucket,抽取 20 个 key 判断是否过期
  • 如果没达到时间上限(25ms)并且过期 key 比例大于 10%,再进行一次抽样,否则结束

fast 模式规则:(过期 key 比例小于 10% 不执行)

  • 执行频率受 beforeSleep() 调用频率影响,但两次 fast 模式间隔不低于 2ms
  • 执行清理耗时不超过 1ms
  • 逐个遍历 db,逐个遍历 db 中的 bucket,抽取 20 个 key 判断是否过期
  • 如果没达到时间上限(1ms)并且过期 key 比例大于 10%,再进行一次抽样,否则结束

10.9.2 淘汰删除策略

内存淘汰:就是当 Redis 内存使用达到设置的阈值时,Redis 主动挑选部分 key 删除以释放更多内存的流程,Redis 会在处理客户端命令的方法 processCommand() 中尝试做内存淘汰:

Redis 支持 8 种不同策略来选择要删除的 key:

  • noeviction:不淘汰任何 key,但是内存满时不允许写入新数据,默认就是这种策略
  • volatile-ttl:对设置了 TTL 的 key,比较 key 的剩余 TTL 值,TTL 越小越先被淘汰
  • allkeys-random:对全体 key,随机进行淘汰,也就是直接从 db->dict 中随机挑选
  • volatile-random:对设置了 TTL 的 key,随机进行淘汰,也就是从 db->expires 中随机挑选
  • allkeys-lru:对全体 key,基于 lru 算法进行淘汰
  • volatile-lru:对设置了 TTL 的 key,基于 lru 算法进行淘汰
  • allkeys-lfu:对全体 key,基于 lfu 算法进行淘汰
  • volatile-lfu:对设置了 TTL 的 key,基于 lfu 算法进行淘汰

lru(Least Recently Used):最少最近使用,用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高

lfu(Least Frequently Used):最少频率使用,会统计每个 key 的访问频率,值越小淘汰优先级越高

那么 Redis 如何记录最近使用和频率呢?

Redis 的数据都会被封装为 RedisObject 结构:

LFU 的访问次数之所以叫做逻辑访问次数,是因为并不是每次 key 被访问都计数,而是通过运算:

  1. 生成 0~1 之间的随机数 R
  2. 计算 1 / (旧次数 * lfu_log_factor + 1),记录为 P,lfu_log_factor 默认为 10
  3. 如果 R < P,则计数器 + 1,且最大不超过 255
  4. 访问次数会随时间衰减,距离上一次访问时间每隔 lfu_decay_time 分钟(默认 1),计数器 -1

第 11 章 Redis 事务

12.1 Redis 的事务是什么

原子性:单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务在执行过程中如果某个命令失败了,其它命令还是会继续执行,不会回滚

一致性:一致性是指如果数据在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据也应该是一致的,但 Redis 事务并不保证一致性,因为如果事务中的某个命令失败了,其它命令仍然会执行,就会出现数据不一致的情况

隔离性:Redis 是单线程执行事务的,并且不会中断,直到执行完所有事务队列中的命令为止,因此,我认为 Redis 的事务具有隔离性的特征

持久性:Redis 事务的持久性完全依赖于 Redis 本身的持久化机制,如果开启了 AOF,那么事务中的命令会作为一个整体记录到 AOF 文件中,当然也要看 AOF 的 fsync 策略,如果只开启了 RDB,事务中的命令可能会在下次快照前丢失,如果两个都没有开启,肯定是不满足持久性的

第 12 章 补充面试题

假如 Redis 里面有 1 亿个 key,其中有 10w 个 key 是以某个固定的已知的前缀开头的,如何将它们全部找出来?

我会使用 scan 命令配合 match 参数来解决,比如要找以 user: 开头的 key,可以执行 scan 0 match user:* count 1000 scan 的优势在于它是基于游标的增量迭代,每次只返回一小批结果,不会阻塞服务器,可以从游标 0 开始,每次处理返回的 key 列表,然后用返回的下一个游标继续扫描,直到游标回到 0 表示扫描完成

千万不能使用 keys 命令,因为 keys 会阻塞 Redis 服务器直到遍历完所有 key,在生产环境中对 1 亿个 key 执行 keys 是非常危险的

Redis 在秒杀场景下可以扮演什么角色?

秒杀是⼀种⾮常特殊的业务场景,它的特点是在极短时间内会有⼤量⽤户涌⼊系统,对系统的并发处理能⼒、响应速度和数据⼀致性都提出了极⾼的要求。在这种场景下,Redis 作为⼀种⾼性能的内存数据库,能够发挥多⽅⾯的关键作⽤。

⽐如说在秒杀开始前,我们可以将商品信息、库存数据等预先加载到 Redis 中,这样⼤量的⽤户读请求就可以直接从 Redis 中获取响应,⽽不必每次都去访问数据库,这样就能⼤⼤减轻数据库的访问压⼒。

其次,解决超卖问题:Redis 在库存控制⽅⾯具有得天独厚的优势。秒杀最核⼼的问题之⼀就是容易发⽣超卖。Redis 提供的原⼦ 操作如 DECR、DECRBY 等命令,可以确保在⾼并发环境下库存计数的准确性。更复杂的逻辑,可以通过 Lua 脚本来实现,因为 Lua 脚本在 Redis 中是原⼦执⾏的,所以可以包含复杂的判断和操作逻辑,⽐如先检查库存是否充⾜,再进⾏扣减,这整个过程是不会被其他操作打断的。

第三点,解决一人一单问题:Redis 的分布式锁可以确保多个⽤户同时抢购同⼀件商品时的操作是互斥的,保证数据⼀致性的同时,还可以⽤来防⽌⽤户重复下单。

第四点,限流削峰。秒杀开始的瞬间,可能会有成千上万的请求同时到达,如果不加控制,很容易导致系统崩溃。 Redis 可以实现多种限流算法,⽐如简单的计数器限流、令牌桶或漏桶算法等。通过限流算法我们可以控制单位时间内系统能够处理的请求数量,超出部分可以排队或者直接拒绝,从⽽保护系统的稳定运⾏。

Redis 具体如何实现削峰呢?

削峰的本质是将瞬时的⾼流量请求缓冲起来,通过排队、限流等机制,使系统以⼀个可承受的速度来处理请求。

那第⼀步就是缓存预热。在秒杀活动开始前,先把商品信息这些热点数据提前加载到 Redis 中。这样⽤户访问商品⻚⾯时,可以直接从 Redis 读取,数据库基本上不会有压⼒。

第⼆步是引⼊消息队列,特别是下单这种写操作,不能让⽤户等太久,但后端处理订单、扣库存这些操作⼜⽐较重。所以可以⽤ Redis 的 List 做了个队列,或者直接⽤ RocketMQ 这种标准的消息中间件,⽤户下单后⽴即返回"订单提交成功",然后把订单数据丢到队列⾥,后台服务慢慢消费。这样既保证了⽤户体验,⼜避免了系统被瞬时写请求压垮。

第三步,可以在秒杀活动中加⼊答题环节,只有答对题⽬的⽤户才能参与秒杀活动,这样可以最⼤程度减少⽆效请求。

Redis 如何做限流呢?

限流是为了控制系统的请求速率,防⽌系统被过多的请求压垮。

Redis 实现限流最简单的⽅法是基于计数器的固定窗⼝限流。⽐如限制⽤户每分钟最多访问 100 次,我们就⽤ INCR 命令给每个⽤户设个计数器,key 是 rate_limit:⽤户ID:分钟时间戳 ,每次请求就加 1,同时设置 60 秒过期。如果计数超过 100 就拒绝请求。种⽅法简单粗暴,但有个问题就是临界时间会有突刺,⽐如⽤户在第 59 秒访问了 100 次,第 61 秒⼜访问 100 次,相当于 2 秒内访问了 200 次。

第⼆种就是滑动窗⼝限流,通过 Redis 的 ZSET 来实现,把每次请求的时间戳作为 score 存进去,然后⽤ zremrangebyscore 删除窗⼝外的旧数据,再⽤ ZCARD 统计当前窗⼝内的请求数。这样限流就⽐较均匀了。

在实际开发中,通常会采⽤令牌桶算法,它就像在帝都/魔都买⻋,摇到号才有资格,没摇到就只能等下⼀次。可以在 Redis ⾥存两个值,⼀个是令牌数量,⼀个是上次更新时间。每次请求时⽤ Lua 脚本计算应该补充多少令牌,然后判断是否有⾜够的令牌。

客户端宕机后 Redis 服务端如何感知到?

TCP 的 keepalive 是 Redis ⽤来检测客户端连接状态的主要机制,默认值为 300 秒。当客户端与服务器在指定时间内没有任何数据交互时,Redis 服务器会发送 TCP ACK 探测包,如果连续多次没有收到响应,TCP 协议栈会通知 Redis 服务端连接已断开,之后,Redis 服务端会清理相关的连接资源,释放连接。

另外还有⼀个 timeout 参数,⽤来控制客户端连接的空闲超时时间。默认值为 0,表示永不断开连接;当设置为⾮零值时,如果客户端在指定时间内没有发送任何命令,服务端会主动断开连接。 Redis 服务器会定期检查空闲连接是否超时,检查频率由 hz 参数控制;这将有助于释放那些客户端异常退出但 TCP 连接未正常关闭的资源。不同的连接池也会有⾃⼰的连接检测机制,⽐如 Jedis 连接池可以通过设置 testOnBorrow 和 testWhileIdle 来启⽤连接检测

Released under the MIT License.

本站访客数 人次 本站总访问量