Redis从入门到渐渐理解~不再迷茫

目录

认识NoSql和Redis

关系型数据库与非关系型数据库的区别

关系型数据库 非关系型数据库
存储结构 结构化数据 非结构化数据
查询方式 SQL NoSQL
关联 可帮助维护关联关系 需要自行维护关联关系
事务 ACID BASE
扩展性 垂直 水平
应用场景 1、数据结构固定 2、安全性.一致性要求高 1、数据结构不固定 2、安全性.一致性要求不高 3、对性能要求高

Redis的特点(6个)

  • 采用的是Key - Value 键值对的方式存储数据,Key基本为String类型,Value可以有丰富的多种数据类型

  • 低延迟、速度快(内存、多路IO、编程良好)

  • 单线程(每条命令具有原子性)

  • 支持数据持久化

  • 支持主从集群、分片集群、哨兵模式

  • 支持多语言客户端

安装Redis以及启动的三种方式

基于Centos7安装Redis

  • 安装gcc依赖
    yum install -y gcc tcl

  • 上传安装包并解压
    image
    tar -xzf redis-6.2.6.tar.gz

  • 进入目录
    cd redis-6.2.6

  • 运行编译命令
    make && make install

安装成功后,默认的安装目录为: /usr/local/bin
该目录已经默认配置到环境变量,因此可以在任意目录下运行这些命令。

  • 以下几个常用的命令
    • redis-cli:是redis提供的命令行客户端
    • redis-server:是redis的服务端启动脚本
    • redis-sentinel:是redis的哨兵启动脚本

前台启动

redis-server

这种启动属于前台启动,会阻塞整个会话窗口,窗口关闭或者按下CTRL + C则Redis停止。不推荐使用。

后台启动,修改配置文件及配置密码

  • 在当初解压redis的目录中,找到redis.conf配置文件

  • 复制一份配置文件
    cp redis.conf redis.conf.bak

  • 修改conf配置文件中的一些配置

# 允许访问的地址,默认是127.0.0.1,会导致只能在本地访问。修改为0.0.0.0则可以在任意IP访问,生产环境不要设置为0.0.0.0
bind 0.0.0.0
# 守护进程,修改为yes后即可后台运行
daemonize yes 
# 密码,设置后访问Redis必须输入密码
requirepass 123321
  • 其他常见配置
# 监听的端口
port 6379
# 工作目录,默认是当前目录,也就是运行redis-server时的命令,日志、持久化等文件会保存在这个目录
dir .
# 数据库数量,设置为1,代表只使用1个库,默认有16个库,编号0~15
databases 1
# 设置redis能够使用的最大内存
maxmemory 512mb
# 日志文件,默认为空,不记录日志,可以指定日志文件名
logfile "redis.log"
  • 启动redis
# 进入redis安装目录
cd /usr/local/src/redis-6.2.6
# 启动
redis-server redis.conf
  • 停止服务
# 利用redis-cli来执行 shutdown 命令,即可停止 Redis 服务,
# 因为之前配置了密码,因此需要通过 -u 来指定密码
redis-cli -u 123321 shutdown

# 或者连接之后先完成认证
auth 密码

将其添加到服务,使用systemctl来控制

  • 新建一个系统服务文件
vim /etc/systemd/system/redis.service
  • 添加如下内容
[Unit]
Description=redis-server
After=network.target

[Service]
Type=forking
ExecStart=/usr/local/bin/redis-server /usr/local/src/redis-6.2.6/redis.conf
PrivateTmp=true

[Install]
WantedBy=multi-user.target
  • 重载系统服务
systemctl daemon-reload
  • 现在,我们可以用下面这组命令来操作redis了
# 启动
systemctl start redis
# 停止
systemctl stop redis
# 重启
systemctl restart redis
# 查看状态
systemctl status redis
  • 执行下面的命令,可以让redis开机自启:
systemctl enable redis

操作Redis的三种方式

使用Redis提供的命令行客户端

Redis安装完成后就自带了命令行客户端:redis-cli,使用方式如下:

redis-cli [options] [commonds]

其中常见的options有:

  • -h 127.0.0.1:指定要连接的redis节点的IP地址,默认是127.0.0.1
  • -p 6379:指定要连接的redis节点的端口,默认是6379
  • -a 123321:指定redis的访问密码

其中的commonds就是Redis的操作命令,例如:

  • ping:与redis服务端做心跳测试,服务端正常会返回pong

不指定commond时,会进入redis-cli的交互控制台

使用图形化界面客户端

进入这个链接可以进行下载:https://github.com/lework/RedisDesktopManager-Windows/releases

建立连接的演示
image

使用高级语言客户端 (后续再说)

Redis常用命令

# 查看string这一组的命令
help @string

# 查询单个命令
help set

Redis通用命令

通用指令指的是任何数据类型都可以使用的命令

  • KEYS:查看符合模板的所有key(不建议在生产环境使用,会模糊查询所有数据
  • DEL:删除一个指定的key(删除成功返回1,失败返回0)
  • EXISTS:判断key是否存在(存在返回1,不存在返回0)
  • EXPIRE:给一个key设置有效期,有效期到期时该key会被自动删除(单位为秒)
  • TTL:查看一个KEY的剩余有效期(单位为秒)

String数据类型(value的不同类型,最大空间,存储类型)

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

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

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


常见命令

  • 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类型的键值对,并且指定有效期

Key结构

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

例如,需要存储用户、商品信息到redis,有一个用户id是1,有一个商品id恰好也是1,此时如果使用id作为key,那就会冲突了,该怎么办?

我们可以通过给key添加前缀加以区分,不过这个前缀不是随便加的,有一定的规范:

Redis的key允许有多个单词形成层级结构,多个单词之间用':'隔开,格式如下:

	项目名:业务名:类型:id

这个格式并非固定,也可以根据自己的需求来删除或添加词条。这样以来,我们就可以把不同类型的数据区分开了。从而避免了key的冲突问题。

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

  • user相关的key:heima:user:1

  • product相关的key:heima:product:1

如果Value是一个Java对象,例如一个User对象,则可以将对象序列化为JSON字符串后存储:

KEY VALUE
heima:user:1
heima:product:1

并且,在Redis的桌面客户端中,还会以相同前缀作为层级结构,让数据看起来层次分明,关系清晰:
image

Hash数据类型

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

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

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


常用命令

  • 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

  • HINCRBY:让一个hash类型key的字段值自增并指定步长

  • HSETNX:添加一个hash类型的key的field值,前提是这个field不存在,否则不执行

List数据类型(LinkedList)

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

特征也与LinkedList类似:

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

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


常见命令

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

Set数据类型(HashSet)

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

  • 无序

  • 元素不可重复

  • 查找快

  • 支持交集、并集、差集等功能


常见命令

  • 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的并集

练习

  1. 将下列数据用Redis的Set集合来存储:

    • 张三的好友有:李四、王五、赵六
    • 李四的好友有:王五、麻子、二狗
  2. 利用Set的命令实现下列功能:

    • 计算张三的好友有几人
    • 计算张三和李四有哪些共同好友
    • 查询哪些人是张三的好友却不是李四的好友
    • 查询张三和李四的好友总共有哪些人
    • 判断李四是否是张三的好友
    • 判断张三是否是李四的好友
    • 将李四从张三的好友列表中移除

答案

sadd zs lisi wangwu zhaoliu
sadd ls wangwu mazi ergou

scard zs
sinter zs ls
sdiff zs ls
sunion zs ls
sismenber zs ls
sismember ls zs
srem zs ls

SortedSet数据类型(跳跃表、hash表、压缩表)

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

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即可,例如:

  • 升序获取sorted set 中的指定元素的排名:ZRANK key member

  • 降序获取sorted set 中的指定元素的排名:ZREVRANK key memeber


练习
将班级的下列学生得分存入Redis的SortedSet中:

Jack 85, Lucy 89, Rose 82, Tom 95, Jerry 78, Amy 92, Miles 76

并实现下列功能:

  • 删除Tom同学
  • 获取Amy同学的分数
  • 获取Rose同学的排名
  • 查询80分以下有几个学生
  • 给Amy同学加2分
  • 查出成绩前3名的同学
  • 查出成绩80分以下的所有同学

Redis的Java客户端

在Redis官网中提供了各种语言的客户端,地址:https://redis.io/docs/clients/

其中Java客户端也包含很多:
image

标记为*的就是推荐使用的java客户端,包括:

  • Jedis和Lettuce:这两个主要是提供了Redis命令对应的API,方便我们操作Redis,而SpringDataRedis又对这两种做了抽象和封装,因此我们后期会直接以SpringDataRedis来学习。

  • Redisson:是在Redis基础上实现了分布式的可伸缩的java数据结构,例如Map、Queue等,而且支持跨进程的同步机制:Lock、Semaphore等待,比较适合用来实现特殊的功能需求。

Jedis客户端(线程不安全的、只能存储String和字节)

Jedis的官网地址: https://github.com/redis/jedis

Jedis快速入门

我们先来个快速入门:

1)引入依赖:

<!--jedis-->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.7.0</version>
</dependency>
<!--单元测试-->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.7.0</version>
    <scope>test</scope>
</dependency>

2)建立连接

新建一个单元测试类,内容如下:

private Jedis jedis;

@BeforeEach
void setUp() {
    // 1.建立连接
    // jedis = new Jedis("192.168.150.101", 6379);
    jedis = JedisConnectionFactory.getJedis();
    // 2.设置密码
    jedis.auth("123321");
    // 3.选择库
    jedis.select(0);
}

3)测试:

@Test
void testString() {
    // 存入数据
    String result = jedis.set("name", "虎哥");
    System.out.println("result = " + result);
    // 获取数据
    String name = jedis.get("name");
    System.out.println("name = " + name);
}

@Test
void testHash() {
    // 插入hash数据
    jedis.hset("user:1", "name", "Jack");
    jedis.hset("user:1", "age", "21");

    // 获取
    Map<String, String> map = jedis.hgetAll("user:1");
    System.out.println(map);
}

4)释放资源

@AfterEach
void tearDown() {
    if (jedis != null) {
        jedis.close();
    }
}

连接池(解决线程安全问题,并且避免频繁创建销毁连接)

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

package com.heima.jedis.util;

import redis.clients.jedis.*;

public class JedisConnectionFactory {

    private static JedisPool jedisPool;

    static {
        // 配置连接池
        JedisPoolConfig poolConfig = new JedisPoolConfig();
        poolConfig.setMaxTotal(8);
        poolConfig.setMaxIdle(8);
        poolConfig.setMinIdle(0);
        poolConfig.setMaxWaitMillis(1000);
        // 创建连接池对象,参数:连接池配置、服务端ip、服务端端口、超时时间、密码
        jedisPool = new JedisPool(poolConfig, "192.168.150.101", 6379, 1000, "123321");
    }

    public static Jedis getJedis(){
        return jedisPool.getResource();
    }
}

SpringDataRedis客户端(默认使用lettuce,自动化序列化与反序列化)

SpringData是Spring中数据操作的模块,包含对各种数据库的集成,其中对Redis的集成模块就叫做SpringDataRedis,官网地址:https://spring.io/projects/spring-data-redis

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

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

快速入门

首先,新建一个maven项目,然后按照下面步骤执行:
1)引入依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.7</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.heima</groupId>
    <artifactId>redis-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>redis-demo</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
        <!--redis依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!--common-pool,因为jedis和lettuce底层实现的连接池都用了它 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        <!--Jackson依赖,使用GenericJackson2JsonRedisSerializer 需要使用到-->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

2)配置Redis

spring:
  redis:
    host: 192.168.150.101
    port: 6379
    password: 123321
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0
        max-wait: 100ms

3)注入RedisTemplate
因为有了SpringBoot的自动装配,我们可以拿来就用:

@SpringBootTest
class RedisStringTests {

    @Autowired
    private RedisTemplate redisTemplate;
}

4)编写测试

@SpringBootTest
class RedisStringTests {

    @Autowired
    private RedisTemplate edisTemplate;

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

自定义序列化(考虑到JDK序列化的缺点,还要考虑class存储的缺点)

RedisTemplate可以接收任意Object作为值写入Redis:
image

只不过写入前会把Object序列化为字节形式,默认是采用JDK序列化,得到的结果是这样的
image

缺点:

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

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

@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的序列化
        template.setKeySerializer(RedisSerializer.string());
        template.setHashKeySerializer(RedisSerializer.string());
        // 设置Value的序列化
        template.setValueSerializer(jsonRedisSerializer);
        template.setHashValueSerializer(jsonRedisSerializer);
        // 返回
        return template;
    }
}

这里采用了JSON序列化来代替默认的JDK序列化方式。最终结果如图:

image

整体可读性有了很大提升,并且能将Java对象自动的序列化为JSON字符串,并且查询时能自动把JSON反序列化为Java对象。不过,其中记录了序列化时对应的class名称,目的是为了查询时实现自动反序列化。这会带来额外的内存开销。

StringRedisTemplate

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

image

因为存入和读取时的序列化及反序列化都是我们自己实现的,SpringDataRedis就不会将class信息写入Redis了。

这种用法比较普遍,因此SpringDataRedis就提供了RedisTemplate的子类:StringRedisTemplate,它的key和value的序列化方式默认就是String方式。

image

省去了我们自定义RedisTemplate的序列化方式的步骤,而是直接使用:

@Autowired
private StringRedisTemplate stringRedisTemplate;
// JSON序列化工具
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);
}

几种不同序列化器的实现原理

  • JdkSerializationRedisSerializer (默认实现方式): 底层采用的是ObjectOutputStream完成的自动序列化与反序列化

    • 可读性差
    • 内存占用更高
  • GenericJackson2JsonRedisSerializer : 底层使用的jackson完成的自动序列化与反序列化

    • 会存储每个类的类型信息,增加内存的消耗
  • StringRedisSerializer: 底层均使用new String(bytes, this.charset)使用UTF-8格式的方式将key value转换

    • 需要手动序列化与反序列化

短信登录(实现Redis的共享session)

基于Session的登录实现流程(原始版本)

  1. 发送验证码
    1.1 校验手机号的格式
    1.2 发送验证码
    1.3 将验证码存入session当中
    1.4 返回200状态码

  2. 短信验证码登录、注册
    2.1 校验手机号和验证码的格式
    2.2 从session中取出验证码与用户的验证码进行校验
    2.3 根据手机号从用户表中查询用户
    2.3.1 查询到了,则将其存入session当中
    2.3.2 没查询到,为用户创建账户,再将其存入session当中
    2.4 响应ok即可

  3. 检验登录状态,使用拦截器
    3.1 定义一个拦截器,拦截除登录请求、静态资源请求以外的全部资源
    3.2 从session中获取当前用户
    3.2.1 获取到了,则将当前用户信息存储ThreadLocal(做到线程隔离)当中,并放行
    3.2.2 没获取到,直接拦截当前用户请求
    3.3 在afterCompletion中移除存放在TheadLocal中的用户信息,避免内存泄露

隐藏用户敏感信息

只需要在存储到session时,就将其封装成一个UserDTO,这里面只放一些非敏感数据即可。

session共享问题

每个tomcat都有属于自己的session,假设用户第一次访问第一台tomcat进行了登录,但是再访问第二台tomcat的时候,发现自己的登录信息竟然失效了,因为第二台tomcat中并没有第一台tomcat中保存的session。

解决方案:

  • 通过session拷贝的方式,虽然每个tomcat上都有不同的session,但是每当任意一台tomcat的session发生修改时,都同步到其他的tomcat上

    • 每台tomcat服务器都有一份完整的session数据,服务器压力过大
    • session拷贝数据时,可能会出现延迟
  • 通过redis来代替session,因为我们的redis本身就是一个共享的服务,所以就可以避免出现如上所述的问题。

基于Redis实现共享Session登录

redis代替session后,对于key的设计

对于①发送验证码时的验证码存储

  • 其中key就使用 业务名称+类型+phone,类似于:user:code:15277001111,过期时间5分钟
  • value由于只是一个简单的6位随机数字符串,因此使用string类型即可

对于②短信验证码登录、注册时的设计

  • 其中key就使用 业务名称+类型+id,类似于:user:login:15277001111,过期时间30分钟
  • value由于存储到是user用户信息,为了能够方便修改,以及内存占用更小,使用hash类型

Redis代替Session后,业务流程发生的改变

  1. 发送验证码
    1.1 校验手机号的格式
    1.2 发送验证码
    1.3 将验证码存入redis当中,其中key为:user:code:15200001111,过期时间2分钟
    1.4 返回200状态码

  2. 短信验证码登录、注册
    2.1 校验手机号和验证码的格式
    2.2 从redis中取出验证码与用户的验证码进行校验
    2.3 生成一个token,使用UUID的方式
    2.3 根据手机号从用户表中查询用户
    2.3.1 查询到了,则将其存入redis当中,
    2.3.2 没查询到,为用户创建账户,再将其存入redis当中
    2.3.3 补充:其中key为:user:login:token,过期时间30分钟,value当然就是用户信息了,不过是hash结构的
    2.4 将生成的token响应给客户端

  3. 检验登录状态,使用拦截器
    3.1 定义一个拦截器,拦截除登录请求、静态资源请求以外的全部资源
    3.2 获取到客户端请求头中的token
    3.3 根据token,进行key的拼接user:login:token,从redis中获取用户信息
    3.3.1 获取到了,则将当前用户信息存储ThreadLocal(做到线程隔离)当中,并放行
    3.3.2 没获取到,直接拦截当前用户请求
    3.4 在afterCompletion中移除存放在TheadLocal中的用户信息,避免内存泄露

解决登录状态刷新问题

  在如上的方案中,用户登陆后,我们并没有再对其存储在redis中的用户信息这个key进行操作。
  而默认这个key将会是30分钟后过期,因此会导致用户在使用过程中,突然出现token过期的情况。

解决问题的思路:我们只需要在用户每次发送请求时,都刷新该登录状态即可(也就是重新设置一下redis中该用户信息的过期时间)

  • 创建一个用于过滤登录请求的拦截器
    该拦截器中只做一件事,那么就是拦截所有需要进行登录认证的请求,从ThreadLocal中获取用户信息,如果获取不到则拦截请求。

  • 将原有的拦截器修改,如果没有查询到用户信息则直接放行
    依然是通过客户端请求头中的token去redis中获取用户信息。但是如果获取不到则直接放行。如果获取到了,那么将会在最后放行前重新设置当前用户信息key的过期时间

缓存查询

什么是缓存? 缓存的优缺点?缓存的几种分类

缓存就相当于系统的避震器,为了不让过高的数据量直接冲垮我们的系统,因此我们就需要缓存来为我们的系统减轻压力。

缓存:就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库获取,存储于本地代码,例如:

例1:Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>(); 本地用于高并发

例2:static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build(); 用于redis等缓存

例3:Static final Map<K,V> map =  new HashMap(); 本地缓存

缓存的优缺点

  • 优点

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

    • 数据一致性成本
    • 运维成本(例如多节点集群的运行与维护)
    • 代码维护成本

缓存分类
实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与redis中的缓存并发使用

  • 浏览器缓存:主要是存在于浏览器端的缓存

  • 应用层缓存:可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存

  • 数据库缓存:在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中

  • CPU缓存:当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存
    image

添加缓存的模型和思路(2种)

  1. 缓存预热:一般是在项目启动阶段,就将一些热点数据从数据库中查询出来,保存到缓冲区作为缓存

  2. 查询时添加缓存

    • 2.1 获取数据时,先查询缓存中是否有对应的数据
    • 2.2 有: 直接返回缓存中的数据
    • 2.3 没有
      • 2.3.1 从数据库中获取到数据
      • 2.3.2 将其存储到数据缓冲区
      • 2.3.3 返回数据

一句话:如果缓存有,则直接返回,如果缓存不存在,则查询数据库,然后存入redis。

缓存更新(淘汰)的3种策略

缓存更新是redis为了节约内存而设计出来的东西,主要是因为内存数据宝贵,当我们向redis插入过多的数据时,可能会导致缓存中的数据过多,导致我们的内存不足。所以redis会对部分数据进行更新,或者我们把它叫做淘汰更合适。

内存淘汰: redis自动进行,当redis内存达到我们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)

  • 不用自己维护,利用Redis的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存。
  • 维护成本: 无
  • 一致性: 差

超时剔除:当我们给redis的key设置了过期时间ttl之后,redis会将超时代数据进行剔除,方便咱们继续使用缓存

  • 给缓存数据添加ttl过期时间,到期后自动删除缓存,下次查询时更新缓存
  • 维护成本: 低
  • 一致性: 一般

主动更新: 我们可以手动调用方法把缓存删除,通常用于解决缓存和数据库不一致问题

  • 编写业务逻辑,在修改数据库的同时,更新缓存
  • 维护成本: 高
  • 一致性: 高

业务场景

  • 低一致性场景: 可以使用内存淘汰来由Redis自动维护。例如店铺类型的查询缓存
  • 高一致性场景: 使用主动更新,并以超时剔除作为兜底方案,来保证数据库与缓存数据的一致性。例如商品详情信息

数据库缓存不一致解决方案

由于我们缓存的数据源来自于数据库,而数据库中的数据是会发生变化的,因此当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在。
后果就是: 数据安全问题、影响业务、用户口碑等。

因此有如下3种解决方案

  • Cache Aside Pattern 人工编码方式: 缓存调用者在更新完数据库后再去更新缓存,也称为双写方案

  • Read/Write Through Pattern : 提供一个系统,由系统本身完成数据库与缓存的同步问题

  • Write Behind Cacheing Pattern : 调用者只操作缓存,其他线程去异步处理数据,实现最终一致

数据库和缓存不一致采用什么方案

综合考虑使用方案一,也就是人工编码方式实现主动更新来解决一致性问题

操作缓存和数据库时,需要考虑3个问题

  • 删除缓存还是更新缓存?

    • 更新缓存:每次更新数据库都更新缓存,无效写次数较多。
    • 删除缓存:更新数据库时让缓存失效,查询时重建缓存。(推荐)
  • 如何保证缓存与数据库的操作的共同成功或失败

    • 单体系统:将缓存与数据库的操作放在同一个事物即可
    • 分布式系统: 采用分布式锁的方案来解决,例如Redision或Seata(TCC)等
  • 先操作数据库还是先操作缓存?

    • 先删除缓存,再操作数据库
    • 先操作数据库,再删除缓存(推荐)
      image

缓存与数据库双写一致(示例)

  1. 设置redis缓存时数据的过期时间,设置为30分钟
    image

  2. 在更新数据库后,删除对应的缓存
    image

缓存穿透问题的解决思路

缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

常见的解决方案有两种:

  • 缓存空对象

    • 优点: 实现简单,维护方便
    • 缺点: 额外的内存消耗、可能造成短期的数据不一致问题
  • 布隆过滤器

    • 优点: 内存占用较少,没有多余key
    • 缺点: 实现复杂,存在误判可能
  • 增强id的复杂度,避免被猜测id规律

  • 做好数据的基础格式校验

  • 加强用户权限校验

  • 做好热点参数的限流

缓存雪崩问题的解决思路

缓存雪崩:缓存雪崩是指在 同一时间有大量的缓存key过期 或者 服务宕机,导致大量的用户请求都无法命中缓存,全部都直接去查询数据库,最终导致数据库无法承受压力而宕机。

解决方案:

  • 给不同key的TTL添加随机值

  • 利用Redis集群提高服务的可用性

  • 给缓存业务添加降级限流策略

  • 给业务添加多级缓存
    image

缓存击穿问题的解决思路

image

缓存击穿:缓存击穿问题有称之为热点key问题,是指某个访问频率很高的key突然过期,并且重建时间过长,而就在重建的这一段时间内,有大量的请求访问该key,由于都无法命中所以都去访问了数据库,最终导致服务器不堪重负宕机。

解决方案:

  • 互斥锁(实现简单,一致性高,但是可用性降低)

    • 当查询缓存未命中时,先获取互斥锁(非阻塞锁需要设置过期时间
    • 获取到互斥锁后,再判断缓存中是否已经有了该数据(避免上一个线程已经完成缓存重建后释放锁
      • 查询数据库,重建缓存
      • 释放互斥锁
    • 而没有获取到互斥锁的线程,就等待一小会后直接return 当前方法,递归调用
      image
  • 逻辑过期(实现复杂一些,一致性低,可用性高)

    • 当查询缓存未命中时,先获取互斥锁(非阻塞锁需要设置过期时间
    • 获取到互斥锁后,再判断缓存中是否已经有了该数据(避免上一个线程已经完成缓存重建后释放锁
      • 使用线程池开启一条新的线程去完成缓存的重建
      • 并且释放互斥锁
    • 直接返回旧数据
      image

对比
image

利用互斥锁解决缓存击穿问题

核心思路:

  • 相较于原来从缓存中查询不到数据后直接查询数据库而言,

  • 现在的方案是 进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获取到,则休眠,过一会再进行尝试重新获取缓存中的数据,直到获取到数据为止。而获取到锁的线程,则进行缓存重建,缓存重建后释放互斥锁
    image

  • 获取互斥锁与删除互斥锁


private boolean tryLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
}

private void unlock(String key) {
    stringRedisTemplate.delete(key);
}
  • 实际操作代码
 public Shop queryWithMutex(Long id)  {
        String key = CACHE_SHOP_KEY + id;
        // 1、从redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get("key");
        // 2、判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 存在,直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        //判断命中的值是否是空值
        if (shopJson != null) {
            //返回一个错误信息
            return null;
        }
        // 4.实现缓存重构
        //4.1 获取互斥锁
        String lockKey = "lock:shop:" + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2 判断否获取成功
            if(!isLock){
                //4.3 失败,则休眠重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            //4.4 成功,根据id查询数据库
             shop = getById(id);
            // 5.不存在,返回错误
            if(shop == null){
                 //将空值写入redis
                stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
                //返回错误信息
                return null;
            }
            //6.写入redis
            stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES);

        }catch (Exception e){
            throw new RuntimeException(e);
        }
        finally {
            //7.释放互斥锁
            unlock(lockKey);
        }
        return shop;
    }

利用逻辑过期解决缓存击穿问题

思路问题:
1、从缓存中查询数据,如果未命中,直接返回null
2、命中后,将其解析成RedisData(实际数据和一个逻辑过期时间)
3、判断逻辑过期时间,看看是否已经过期,如果未过期,直接返回数据
4、如果已过期,则获取互斥锁,获取失败的话就直接返回旧数据
5、获取互斥锁成功,再判断该缓存的逻辑过期时间是否已过期,如果仍然过期,则使用线程池开启一条新的线程去执行缓存重建并释放互斥锁
6、开辟子线程的线程则直接返回旧数据
image

如果封装数据:因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,要么你

步骤一、

新建一个实体类,我们采用第二个方案,这个方案,对原来代码没有侵入性。

@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

步骤二、
新建一个方法,用于将数据存储到Redis中,并且需要添加逻辑过期时间
image

步骤三
进行缓存的预热,也就是先将数据存储到Redis当中

步骤四: 实际代码

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

public Shop queryWithLogicalExpire( Long id ) {
    String key = CACHE_SHOP_KEY + id;
    // 1.从redis查询商铺缓存
    String json = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isBlank(json)) {
        // 3.存在,直接返回
        return null;
    }
    // 4.命中,需要先把json反序列化为对象
    RedisData redisData = JSONUtil.toBean(json, RedisData.class);
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 5.判断是否过期
    if(expireTime.isAfter(LocalDateTime.now())) {
        // 5.1.未过期,直接返回店铺信息
        return shop;
    }
    // 5.2.已过期,需要缓存重建
    // 6.缓存重建
    // 6.1.获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    boolean isLock = tryLock(lockKey);
    // 6.2.判断是否获取锁成功
    if (isLock){
        CACHE_REBUILD_EXECUTOR.submit( ()->{

            try{
                //重建缓存
                this.saveShop2Redis(id,20L);
            }catch (Exception e){
                throw new RuntimeException(e);
            }finally {
                unlock(lockKey);
            }
        });
    }
    // 6.4.返回过期的商铺信息
    return shop;
}

Redis工具类的封装

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

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

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

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

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

  • 方法5: 根据指定的key查询缓存,并反序列化为指定类型,需要利用互斥锁解决缓存击穿问题,并兼顾缓存穿透问题

RedisClient工具类的实现

  • RedisData
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class RedisData {
        private Object value;
        private LocalDateTime expireTime;
    }
  • RedisConstant
public class RedisConstant {
    public static final Long CACHE_NULL_TTL = 2L;

    public static final String LOCK_KEY = "lock:";
    public static final Long LOCK_TTL = 10L;
}
  • RedisClient
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

import static com.woniu.utils.RedisConstant.*;

/**
 * @author codeStars
 * @date 2022/11/1 10:53
 */
@Component
public class RedisClient {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    private final ExecutorService CACHE_REBUILDER_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
     * @param key 保存的key
     * @param value 保存的任意java对象类型
     * @param expireTime 过期时间
     * @param unit 过期时间的单位
     */
    public void setWithTTL(String key, Object value, long expireTime, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), expireTime, unit);
    }

    /**
     * 方法2:* 将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
     * @param key 保存的key
     * @param value 保存的任意java对象类型
     * @param expireTime 逻辑过期时间
     * @param unit 逻辑过期时间的单位
     */
    public void setWithLogicalTTL(String key, Object value, long expireTime, TimeUnit unit) {
        RedisData redisData = new RedisData();
        redisData.setValue(value);
        // 设置逻辑过期时间
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(expireTime)));
        // 保存到redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    /**
     * 根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
     * @param keyPrefix key的前缀
     * @param id 通过拼接id得到key
     * @param type 需要查询的实体类的类型
     * @param dbFallBack 通过id获取到指定实体类类型的函数
     * @param expireTime 如果缓存中不存在,重建缓存时的过期时间
     * @param unit 如果缓存中不存在,重建缓存时的过期单位
     * @param <R>  返回值类型
     * @param <ID> ID的类型,因为不确定id是long还是string又或者是int之类的
     * @return
     */
    public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallBack, Long expireTime, TimeUnit unit ) {
        // 1、根据key的前缀以及id的拼接来获得redis数据库中的key
        String key = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2、判断是否存在,如果存在则返回
        if(StrUtil.isNotBlank(json)){
            return JSONUtil.toBean(json, type);
        }
        // 3、判断是否为缓存的null值,也就是空字符串
        if(Objects.nonNull(json)) {
            // 返回错误信息
            return null;
        }

        // 4、查询数据库
        R r = dbFallBack.apply(id);

        // 5、判断数据库中是否有数据
        if (Objects.isNull(r)) {
            // 缓存空值,这里是2分钟过期时间
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
            // 返回错误信息,因为数据库和缓存中都没有,出现缓存穿透问题
            return null;
        }

        // 6、将从数据库中获取到的数据存储到redis当中
        this.setWithTTL(key, r, expireTime, unit);

        // 7、返回数据
        return r;
    }

    /**
     * * 根据指定的key查询缓存,并反序列化为指定类型,利用逻辑过期的方式解决缓存击穿问题
     * *
     */
    public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallBack, long expireTime, TimeUnit unit) {
       // 1、根据Key去redis中查询缓存
        String key = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(key);

        // 2、 如果缓存中不存在,则直接返回null,告知当前数据不存在,
        // 2.1 因为我们是逻辑删除,因此数据都是经过预热存储到缓存中的,因此缓存中没有则代表用户访问了不存在的数据
        if(StrUtil.isBlank(json)) {
            return null;
        }

        // 3、将缓存中查出来的数据转换为RedisData,再取出过期时间
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        LocalDateTime time = redisData.getExpireTime();

        // 4、获取到数据库中的数据
        JSONObject jsonObject = (JSONObject) redisData.getValue();
        R r = JSONUtil.toBean(jsonObject, type);

        // 5、判断当前key是否过期,在当前的日期之后,说明没有过期
        if(time.isAfter(LocalDateTime.now())) {
            return r;
        }

        // 6、到这说明缓存已过期,尝试获取互斥锁
        String lockKey = LOCK_KEY + id;
        boolean isLock = this.tryLock(lockKey);

        // 7、如果获取锁失败, 说明已经有线程在重建缓存,直接返回旧数据
        if(!isLock) {
            return r;
        }

        // 8、进行双重检索
        // 进行双重检索
        String newJson = stringRedisTemplate.opsForValue().get(key);
        RedisData newRedisData = JSONUtil.toBean(newJson, RedisData.class);
        LocalDateTime newTime = newRedisData.getExpireTime();
        // 如果此时发现已经未过期,直接返回
        if(newTime.isAfter(LocalDateTime.now())) {
            // 顺便解锁
            this.unLock(lockKey);
            return JSONUtil.toBean((JSONObject) newRedisData.getValue(), type);
        }

        // 9、到这说明还是过期的、开启一个子线程去进行缓存重建缓存
        CACHE_REBUILDER_EXECUTOR.submit(() ->{
            try {
                // 从数据库查询数据
                R newR = dbFallBack.apply(id);
                // 将其存储到redis
                this.setWithLogicalTTL(key, newR, expireTime, unit);
            }catch (Exception e) {
                throw new RuntimeException(e);
            }finally {
                // 释放锁
                this.unLock(key);
            }
        });
        // 返回旧数据
        return r;
    }

    /**
     * 利用互斥锁解决缓存击穿问题
     * @param keyPrefix 需要获取的数据key前缀
     * @param id 用于拼接前缀获得redis中的key
     * @param type 需要的数据类型
     * @param dbFallBack 根据ID获取数据的函数
     * @param timeExpire 如果数据不存在,重建缓存保存的时间
     * @param unit 如果数据不存在,重建缓存保存的时间单位
     * @param <R>  返回值类型
     * @param <ID> ID类型
     * @return
     */
    public <R, ID> R queryWithMutex(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallBack, long timeExpire, TimeUnit unit) {
        // 1、从缓存中获取到对应的数据
        String key = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(key);

        // 2、如果不为空字符串,则代表有数据,将其响应给客户端
        if(StrUtil.isNotBlank(json)) {
            return JSONUtil.toBean(json, type);
        }

        // 3、判断null值,解决缓存穿透
        if(Objects.nonNull(json)) {
            // 告知数据不存在
            return null;
        }

        // 4、获取互斥锁
        String lockKey = LOCK_KEY + id;
        boolean isLock = this.tryLock(lockKey);

        // 5、如果没有获取到锁,则反复执行该方法来获取数据=
        if(!isLock) {
            // 等待20毫秒
            try {TimeUnit.MILLISECONDS.sleep(20);} catch (InterruptedException e) {e.printStackTrace(); }
            return this.queryWithMutex(keyPrefix, id, type, dbFallBack, timeExpire, unit);
        }

        // 执行缓存重建
        R r = null;
        try {
            // 再次判断缓存中是否有数据,进行双重检索,则代表有数据,将其响应给客户端
            String doubleJson = stringRedisTemplate.opsForValue().get(key);
            if(StrUtil.isNotBlank(doubleJson)) {
                return JSONUtil.toBean(doubleJson, type);
            }

            // 正式执行缓存重建
            r = dbFallBack.apply(id);

            // 如果数据库中没有,则缓存空值
            if(Objects.isNull(r)) {
                // 缓存空值
                this.setWithTTL(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
                return null;
            }
            // 存储到redis当中
            this.setWithTTL(key, r, timeExpire, unit);
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            // 释放锁
            unLock(lockKey);
        }
        // 响应数据
        return r;
    }

    /**
     * 获取锁
     * @param key 锁的key
     * @return 是否成功获取锁
     */
    public boolean tryLock(String key) {
        Boolean isLock = stringRedisTemplate.opsForValue().setIfAbsent(key, "", LOCK_TTL, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(isLock);
    }

    /**
     * 释放锁
     * @param key 锁的key
     */
    public void unLock(String key) {
        stringRedisTemplate.delete(key);
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class RedisData {
        private Object value;
        private LocalDateTime expireTime;
    }
}
  • 实际使用

优惠券秒杀

本章节将会学习Redis的计数器功能, 结合Lua完成高性能的redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列

全局唯一ID

在进行抢购活动时,如果用户量多,将会生成大量的订单,如果此时我们使用的是数据库的自增ID,那么就会存在一些问题:

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

场景分析:如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天内,卖出了多少单,这明显不合适。

场景分析二:随着我们商城规模越来越大,MYSQL单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表之后,他们从逻辑上来说是同一张表,所以他们的id不能是一样的,于是我们需要保证id的唯一性。

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

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

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

固定部分:符号位,1bit,永远为0
时间戳: 32bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同的ID

Redis实现全局唯一ID

  • 工具类
@Component
public class RedisIdWorker {
    private static final long BEGIN_TIMESTAMP = 946684800L;

    private static final int COUNT_BITS = 32;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 获得全局唯一ID
     * @param keyPrefix 业务key的前缀
     * @return
     */
    public long nextId(String keyPrefix) {
        // 1、获得时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowTimeStamp = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = nowTimeStamp - BEGIN_TIMESTAMP;

        // 2、获得redis数据库自增的值
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + date);

        // 3、将其位移一下
        return timeStamp << COUNT_BITS | count;
    }
}
  • 测试类
@Test
void testIdWorker() throws InterruptedException {
    CountDownLatch latch = new CountDownLatch(300);

    Runnable task = () -> {
        for (int i = 0; i < 100; i++) {
            long id = redisIdWorker.nextId("order");
            System.out.println("id = " + id);
        }
        latch.countDown();
    };
    long begin = System.currentTimeMillis();
    for (int i = 0; i < 300; i++) {
        es.submit(task);
    }
    latch.await();
    long end = System.currentTimeMillis();
    System.out.println("time = " + (end - begin));
}

知识小贴士:关于countdownlatch

countdownlatch名为信号枪:主要的作用是同步协调在多线程的等待于唤醒问题

我们如果没有CountDownLatch ,那么由于程序是异步的,当异步程序没有执行完时,主线程就已经执行完了,然后我们期望的是分线程全部走完之后,主线程再走,所以我们此时需要使用到CountDownLatch

CountDownLatch 中有两个最重要的方法

1、countDown

2、await

await 方法 是阻塞方法,我们担心分线程没有执行完时,main线程就先执行,所以使用await可以让main线程阻塞,那么什么时候main线程不再阻塞呢?当CountDownLatch 内部维护的 变量变为0时,就不再阻塞,直接放行,那么什么时候CountDownLatch 维护的变量变为0 呢,我们只需要调用一次countDown ,内部变量就减少1,我们让分线程和变量绑定, 执行完一个分线程就减少一个变量,当分线程全部走完,CountDownLatch 维护的变量就是0,此时await就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。

实现秒杀下单的思路

下单时需要判断3点

  • 秒杀是否开始、秒杀是否结束,如果尚未开始或已经结束则无法下单

  • 库存是否充足,不足则无法下单

  • 用户是否已经下单过了,如果已经下单过了,则无法再次下单

下单的核心业务逻辑

  1. 获取到下单的商品id,根据商品id查询当前商品的秒杀信息

  2. 判断商品的秒杀是否开始、商品的秒杀是否结束,满足任意一项均直接返回友好提示

  3. 判断商品的库存,当库存 <=0 时,则返回提示:已经秒杀光了

  4. 根据用户id查询订单,条件为:订单中的商品id包含当前秒杀的商品id
    4.1 如果返回的值大于0,则说明用户已经下单过了,直接返回提示

  5. 此时代表用户确实未下单过,并且商品库存还有

  6. 生成订单,扣减库存,业务结束,提示商品秒杀成功!
    image

简单代码实现秒杀业务

@Override
public Result seckillVoucher(Long voucherId) {
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2.判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        // 尚未开始
        return Result.fail("秒杀尚未开始!");
    }
    // 3.判断秒杀是否已经结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
        // 尚未开始
        return Result.fail("秒杀已经结束!");
    }
    // 4.判断库存是否充足
    if (voucher.getStock() < 1) {
        // 库存不足
        return Result.fail("库存不足!");
    }
    //5,扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update();
    if (!success) {
        //扣减库存
        return Result.fail("库存不足!");
    }
    //6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1.订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 6.2.用户id
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    // 6.3.代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);

    return Result.ok(orderId);

}

解决库存超卖问题(使用乐观锁解决)

库存超卖问题的发生原因: 多个线程同时获取到了商品信息。
  例如只有100个商品,但是200个线程同时都获取到了商品信息,并且都去判断了库存是否大于等于0,那么库存肯定是不大于等于0的,因此200个线程同时执行了下单扣减库存的操作,因此出现库存超卖问题
image

为了解决这样的问题,我们首先想到的是加锁,加锁的话就要考虑到是使用悲观锁还是乐观锁。这里将会采用乐观锁的方式解决库存超卖问题。

悲观锁
  悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等

乐观锁(关键是判断之前查询得到的数据是否被修改过)
  实现方式一:版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas

  实现方式二:CAS,利用cas进行无锁化机制加锁,var5 是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值

修改代码方案一

boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1") //set stock = stock -1
            .eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update();

以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败

解决库存遗留问题

修改代码方案二
之前的方式要修改前后都保持一致,但是这样我们分析过,成功的概率太低,所以我们的乐观锁需要变一下,改成stock大于0 即可

boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0

实现秒杀业务的一人一单(使用悲观锁实现)

问题分析:优惠卷是为了引流,但是目前的情况是,一个人可以无限制的抢这个优惠卷,所以我们应当增加一层逻辑,让一个用户只能下一个单,而不是让一个用户下多个单。

实现思路:

  1. 根据商品id获取到商品的秒杀信息

  2. 判断商品的秒杀时间是否未开始 或者 已结束

  3. 判断库存是否充足

  4. 判断当前用户是否已经秒杀过该商品,通过用户id和商品id去订单表查

  5. 生成订单,扣减库存
    image

初步逻辑:实现一人一单代码

@Override
public Result seckillVoucher(Long voucherId) {
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2.判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        // 尚未开始
        return Result.fail("秒杀尚未开始!");
    }
    // 3.判断秒杀是否已经结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
        // 尚未开始
        return Result.fail("秒杀已经结束!");
    }
    // 4.判断库存是否充足
    if (voucher.getStock() < 1) {
        // 库存不足
        return Result.fail("库存不足!");
    }
    // 5.一人一单逻辑
    // 5.1.用户id
    Long userId = UserHolder.getUser().getId();
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    // 5.2.判断是否存在
    if (count > 0) {
        // 用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }

    //6,扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update();
    if (!success) {
        //扣减库存
        return Result.fail("库存不足!");
    }
    //7.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 7.1.订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);

    voucherOrder.setUserId(userId);
    // 7.3.代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);

    return Result.ok(orderId);

}

存在问题:现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作

单机系统的解决(使用到了Spring的代理对象),Spring事物控制的方法内部的锁问题

注意: 在这里提到了非常多的问题,我们需要慢慢的来思考,首先我们的初始方案是封装了一个createVoucherOrder方法,同时为了确保他线程安全,在方法上添加了一把synchronized 锁

@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {

	Long userId = UserHolder.getUser().getId();
         // 5.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2.判断是否存在
        if (count > 0) {
            // 用户已经购买过了
            return Result.fail("用户已经购买过一次!");
        }

        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
                .update();
        if (!success) {
            // 扣减失败
            return Result.fail("库存不足!");
        }

        // 7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 7.1.订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 7.2.用户id
        voucherOrder.setUserId(userId);
        // 7.3.代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        // 7.返回订单id
        return Result.ok(orderId);
}

  但是这样加锁,锁的粒度太粗了,在使用锁的过程中,控制锁粒度是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进行都会锁住,所以我们需要去控制锁的粒度,以上这段代码需要修改。
  修改后,我们使用用户的id来作为同步监视器,保证了锁住的都是同一个用户,但是因为是new出来的对象,我们需要使用intern()方法

@Transactional
public  Result createVoucherOrder(Long voucherId) {
	Long userId = UserHolder.getUser().getId();
	synchronized(userId.toString().intern()){
         // 5.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2.判断是否存在
        if (count > 0) {
            // 用户已经购买过了
            return Result.fail("用户已经购买过一次!");
        }

        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
                .update();
        if (!success) {
            // 扣减失败
            return Result.fail("库存不足!");
        }

        // 7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 7.1.订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 7.2.用户id
        voucherOrder.setUserId(userId);
        // 7.3.代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        // 7.返回订单id
        return Result.ok(orderId);
    }
}

  但是以上代码还是存在问题,问题的原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:如下:

在seckillVoucher 方法中,添加以下逻辑,这样就能保证事务的特性,同时也控制了锁的粒度
image

  但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务

1、引入如下依赖

<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

2、在Spring的启动类上开启代理的暴露

@EnableAspectJAutoProxy(exposeProxy = true)

3、 在业务层中使用,可以获取到当前业务实现类的代理对象
image

集群环境下的并发问题

通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。

锁失效的原因:由于现在我们部署了多个tomcat每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是 集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。
image

分布式锁

基本原理和实现方式对比

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路
image

分布式锁需要满足的几个条件

  • 可见性: 多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能够感知到变化的意思

  • 高可用: 程序不易崩溃,时时刻刻都保证较高的可用性

  • 高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能

  • 安全性:安全也是程序中必不可少的一环

image

常见的三种分布式锁

  • MySQL:本身就带有锁机制,但是由于Mysql性能本身一般,所以采用分布式锁的情况下,其实使用mysql作为分布式锁比较少见

  • Redis:作为分布式锁是非常常见的一种方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁,利用setnx这个方法,如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁。

  • Zookeeper: 企业级开发中较好的一个实现分布式锁的方案。主从时,只有一半以上的节点同步成功了锁之后,才会返回锁获取成功。
    image

Redis分布式锁的实现核心思路

实现分布式锁时需要实现的两个基本方法

  • 获取锁 tryLock

    • 互斥:确保只能有一个线程获取锁
    • 非阻塞:尝试一次,成功返回true,失败返回false,不做重试机制
  • 释放锁 unLock

    • 手动释放
    • 超时释放:获取锁时添加一个超时时间

核心思路:
1、我们利用redis的setnx方法,当有多个线程进入时,我们就利用该方法,
2、第一个线程进入时,redis中就有这个key了,返回了1,如果结果是1,则表示他抢到了锁,那么就可以执行业务了
3、最后再删除锁
4、没抢到锁的线程,等待一定时间重试即可。
image

实现分布式锁的最初版本

  • 锁的接口
    image

  • SimpleRedisLock
    利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性。

private static final String KEY_PREFIX="lock:"
@Override
public boolean tryLock(long timeoutSec) {
    // 获取线程标示
    String threadId = Thread.currentThread().getId()
    // 获取锁
    Boolean success = stringRedisTemplate.opsForValue()
            .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(success);
}

释放锁逻辑

public void unlock() {
    //通过del删除锁
    stringRedisTemplate.delete(KEY_PREFIX + name);
}

修改业务逻辑

  @Override
    public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2.判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 尚未开始
            return Result.fail("秒杀尚未开始!");
        }
        // 3.判断秒杀是否已经结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            // 尚未开始
            return Result.fail("秒杀已经结束!");
        }
        // 4.判断库存是否充足
        if (voucher.getStock() < 1) {
            // 库存不足
            return Result.fail("库存不足!");
        }
        Long userId = UserHolder.getUser().getId();
        //创建锁对象(新增代码)
        SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        //获取锁对象
        boolean isLock = lock.tryLock(1200);
		//加锁失败
        if (!isLock) {
            return Result.fail("不允许重复下单");
        }
        try {
            //获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } finally {
            //释放锁
            lock.unlock();
        }
    }

Redis分布式锁误删情况说明

  持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明

  解决方案:解决方案就是在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则不进行锁的删除,假设还是上边的情况,线程1卡顿,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1反应过来,然后删除锁,但是线程1,一看当前这把锁不是属于自己,于是不进行删除锁逻辑,当线程2走到删除锁逻辑时,如果没有卡过自动释放锁的时间点,则判断当前这把锁是属于自己的,于是删除这把锁。

解决Redis分布式锁误删问题

需求:修改之前的分布式锁实现,满足:在获取锁时存入线程标示(可以用UUID表示)
在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致

  • 如果一致则释放锁
  • 如果不一致则不释放锁

核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。
image

加锁

private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
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);
    }
}

在我们修改完此处代码后,我们重启工程,然后启动两个线程,第一个线程持有锁后,手动释放锁,第二个线程 此时进入到锁内部,再放行第一个线程,此时第一个线程由于锁的value值并非是自己,所以不能释放锁,也就无法删除别人的锁,此时第二个线程能够正确释放锁,通过这个案例初步说明我们解决了锁误删的问题。

分布式锁的原子性问题

更为极端的误删逻辑说明:
  线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生

  为了解决这样的问题,我们需要在判断锁是否为自己的,以及执行锁删除,这两行代码保持原子性。而此时就需要使用到lua脚本

Lua脚本解决多条命令原子性问题

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法大家可以参考网站:https://www.runoob.com/lua/lua-tutorial.html 这里重点介绍Redis提供的调用函数,我们可以使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁比锁删锁是一个原子性动作了,作为Java程序员这一块并不作一个简单要求,并不需要大家过于精通,只需要知道他有什么作用即可。

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

redis.call('命令名称', 'key', '其它参数', ...)

例如,我们要执行set name jack,则脚本是这样:

# 执行 set name jack
redis.call('set', 'name', 'jack')

例如,我们要先执行set name Rose,再执行get name,则脚本如下:

# 先执行 set name jack
redis.call('set', 'name', 'Rose')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name

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

例如,我们要执行 redis.call('set', 'name', 'jack') 这个脚本,语法如下:
image

如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数

image

接下来我们来回一下我们释放锁的逻辑:
​ 1、获取锁中的线程标示

​ 2、判断是否与指定的标示(当前线程标示)一致

​ 3、如果一致则释放锁(删除)

​ 4、如果不一致则什么都不做

如果用Lua脚本来表示则是这样的:

最终我们操作redis的拿锁比锁删锁的lua脚本就会变成这样

-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

利用Java代码调用Lua脚本改造分布式锁

lua脚本本身并不需要大家花费太多时间去研究,只需要知道如何调用,大致是什么意思即可,所以在笔记中并不会详细的去解释这些lua表达式的含义。

我们的RedisTemplate中,可以利用execute方法去执行lua脚本,参数对应关系就如下图
image

java代码

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

public void unlock() {
    // 调用lua脚本
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT,
            Collections.singletonList(KEY_PREFIX + name),
            ID_PREFIX + Thread.currentThread().getId());
}
经过以上代码改造后,我们就能够实现 判断锁是否是自己的以及删除锁的原子性动作了~

第一个线程进来,得到了锁,手动删除锁,模拟锁超时了,其他线程会执行lua来抢锁,当第一天线程利用lua删除锁时,lua能保证他不能删除他的锁,第二个线程删除锁时,利用lua同样可以保证不会删除别人的锁,同时还能保证原子性。

小总结(基于redis的分布式锁实现思路)

  • 利用set ex nx 来获取锁,并设置过期时间,防止死锁

  • 释放锁时判断当前锁是否是当前线程的,以此避免锁误删问题

  • 锁误删问题最终我们采用了:拿锁,比锁,删锁的方式来操作,但是又出现了原子性问题

  • 最终通过lua脚本保证原子性的情况完成了操作

  • 但是还有一个问题锁不住: 那就是超时过期时间的问题,一旦线程在执行的过程中发生了阻塞,而在此时锁超时释放了,那么将会出现线程安全问题。为了解决这个问题,我们可以专门搞一个定时任务,每隔一段时间刷新锁的过期时间。但是我们自己实现起来比较复杂,可以依赖后续学到的一门技术: Redisson

分布式锁Redisson

我们自己基于setnx实现的分布式锁存在下面的4个问题

重入问题:重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。

不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。

超时释放:我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患

主从一致性: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。
image

那么什么是Redission呢
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。说白话就是: 基于Redis实现的一系列分布式服务的集合.

Redission提供了分布式锁的多种多样的功能
image

分布式锁-Redisson快速入门

引入依赖:

<dependency>
	<groupId>org.redisson</groupId>
	<artifactId>redisson</artifactId>
	<version>3.13.6</version>
</dependency>

配置Redisson客户端:

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){
        // 配置,如果是集群模式,则config.useCluster
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.150.101:6379")
            .setPassword("123321");
        // 创建RedissonClient对象
        return Redisson.create(config);
    }
}

如何使用Redission的分布式锁

@Resource
private RedissionClient redissonClient;

@Test
void testRedisson() throws Exception{
    //获取锁(可重入),指定锁的名称
    RLock lock = redissonClient.getLock("anyLock");
    //尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
    boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
    //判断获取锁成功
    if(isLock){
        try{
            System.out.println("执行业务");          
        }finally{
            //释放锁
            lock.unlock();
        }
        
    }
    
    
    
}

使用示例
注入RedissonClient

@Resource
private RedissonClient redissonClient;

@Override
public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2.判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 尚未开始
            return Result.fail("秒杀尚未开始!");
        }
        // 3.判断秒杀是否已经结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            // 尚未开始
            return Result.fail("秒杀已经结束!");
        }
        // 4.判断库存是否充足
        if (voucher.getStock() < 1) {
            // 库存不足
            return Result.fail("库存不足!");
        }
        Long userId = UserHolder.getUser().getId();
        //创建锁对象 这个代码不用了,因为我们现在要使用分布式锁
        //SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        //获取锁对象
        boolean isLock = lock.tryLock();
       
		//加锁失败
        if (!isLock) {
            return Result.fail("不允许重复下单");
        }
        try {
            //获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } finally {
            //释放锁
            lock.unlock();
        }
 }

分布式锁redisson的可重入锁原理

在Lock锁中,他是借助于底层的一个voaltile的一个state变量来记录重入的状态的,比如当前没有人持有这把锁,那么state=0,假如有人持有这把锁,那么state=1,如果持有这把锁的人再次持有这把锁,那么state就会+1 ,如果是对于synchronized而言,他在c语言代码中会有一个count,原理和state类似,也是重入一次就加一,释放一次就-1 ,直到减少成0 时,表示当前这把锁没有被人持有。

在redission中,我们的也支持支持可重入锁

在分布式锁中,他采用hash结构用来存储锁,其中大key表示表示这把锁是否存在,用小key表示当前这把锁被哪个线程持有,所以接下来我们一起分析一下当前的这个lua表达式

这个地方一共有3个参数

KEYS[1] : 锁名称

ARGV[1]: 锁失效时间

ARGV[2]: id + ":" + threadId; 锁的小key

获取锁的伪代码

"if (redis.call('exists', KEYS[1]) == 0) then " +
                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                  "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              "return redis.call('pttl', KEYS[1]);"

image

分布式锁Redisson锁重试和WatchDog机制

说明:由于课程中已经说明了有关tryLock的源码解析以及其看门狗原理,所以笔者在这里给大家分析lock()方法的源码解析,希望大家在学习过程中,能够掌握更多的知识

抢锁过程中,获得当前线程,通过tryAcquire进行抢锁,该抢锁逻辑和之前逻辑相同

1、先判断当前这把锁是否存在,如果不存在,插入一把锁,返回null

2、判断当前这把锁是否是属于当前线程,如果是,则返回null

所以如果返回是null,则代表着当前这哥们已经抢锁完毕,或者可重入完毕,但是如果以上两个条件都不满足,则进入到第三个条件,返回的是锁的失效时间,同学们可以自行往下翻一点点,你能发现有个while( true) 再次进行tryAcquire进行抢锁

long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
    return;
}

接下来会有一个条件分支,因为lock方法有重载方法,一个是带参数,一个是不带参数,如果带带参数传入的值是-1,如果传入参数,则leaseTime是他本身,所以如果传入了参数,此时leaseTime != -1 则会进去抢锁,抢锁的逻辑就是之前说的那三个逻辑

if (leaseTime != -1) {
    return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}

如果是没有传入时间,则此时也会进行抢锁, 而且抢锁时间是默认看门狗时间
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout()

ttlRemainingFuture.onComplete((ttlRemaining, e) 这句话相当于对以上抢锁进行了监听,也就是说当上边抢锁完毕后,此方法会被调用,具体调用的逻辑就是去后台开启一个线程,进行续约逻辑,也就是看门狗线程

RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime,
                                        commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
                                        TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
    if (e != null) {
        return;
    }

    // lock acquired
    if (ttlRemaining == null) {
        scheduleExpirationRenewal(threadId);
    }
});
return ttlRemainingFuture;

此逻辑就是续约逻辑,注意看commandExecutor.getConnectionManager().newTimeout() 此方法
Method( new TimerTask() {},参数2 ,参数3 )
指的是:通过参数2,参数3 去描述什么时候去做参数1的事情,现在的情况是:10s之后去做参数一的事情

因为锁的失效时间是30s,当10s之后,此时这个timeTask 就触发了,他就去进行续约,把当前这把锁续约成30s,如果操作成功,那么此时就会递归调用自己,再重新设置一个timeTask(),于是再过10s后又再设置一个timerTask,完成不停的续约

那么大家可以想一想,假设我们的线程出现了宕机他还会续约吗?当然不会,因为没有人再去调用renewExpiration这个方法,所以等到时间之后自然就释放了。

private void renewExpiration() {
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {
        return;
    }
    
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return;
            }
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return;
            }
            
            RFuture<Boolean> future = renewExpirationAsync(threadId);
            future.onComplete((res, e) -> {
                if (e != null) {
                    log.error("Can't update lock " + getName() + " expiration", e);
                    return;
                }
                
                if (res) {
                    // reschedule itself
                    renewExpiration();
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    
    ee.setTimeout(task);
}

分布式锁-redisson锁的MultiLock原理

为了提高redis到可用性,我们会搭建集群或主从,现在以主从为例

此时我们去写命令,写在主机上,主机会将数据同步给从机,但是假设在主机还没来得及把数据写入到从机去的时候,此时主机宕机,哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中时机并没有锁信息,此时锁信息就已经丢掉了。

image

为了解决这个问题,redission提出来了MutilLock锁,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的,这把锁加锁的逻辑需要写入到每一个主从节点上,只有所有的redis服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只要有个节点拿不到,都不能算是加锁成功,就保证了锁的可靠性。
image


使用示例
image
image


那么MultiLock加锁的原理是什么呢?
当我们去设置了多个锁时,redisson会将多个锁添加到一个集合中,然后用while循环去不停的尝试拿锁,但是会有一个总共的加锁时间,这个时间是用需要加锁的个数 * 1500ms,假设有3个锁,那么时间就是4500ms,假设在这4500ms内,所有的锁都加锁成功,那么此时才算是加锁成功,如果在4500ms有线程加锁失败,则会再次去进行重试。
image

小总结(不可重入redis分布式锁、可重入的redis分布式锁、Redison的MultiLock)

  • 不可重入redis分布式锁

    • 原理:利用setnx的互斥性; 利用setex防止死锁; 释放时判断线程标志避免误删
    • 缺点: 不可重入、不可重试、锁超时失效、主从一致性无法保证
  • 可重入的分布式锁

    • 原理:利用hsetnx的互斥性,记录线程和重入次数;利用WatchDog延续锁时间;利用信号量控制锁等待重试
    • 缺点: 主从一致性无法保证(redis宕机引起锁失效问题)
  • Redisson的MultiLock连锁

    • 原理:多个独立的redis节点,必须在所有节点都获取重入锁,才算获取锁成功
    • 缺点:运维成本高,实现复杂

秒杀优化(提高性能)

回顾我们的秒杀下单流程
1、根据商品id获取到秒杀的商品信息
2、判断秒杀是否开始或者已结束
3、判断库存是否充足
4、判断用户是否已经购买过
5、创建订单(用户id、商品id、订单id)
6、操作数据库添加订单信息
7、响应订单号

从上面的操作可以看出,有很多的操作是需要操作数据库的,而且还是一个线程来串行执行,这样就会导致我们的程序执行的很慢,所以我们需要异步程序执行,那么如何加速呢?

这里有一个思路:比如,我们可不可以使用异步编排来做,或者说开启n个线程,一个线程执行查询商品信息,一个线程判断扣减库存,一个线程去创建订单等,最后再统一做返回。但是,这个思路会有其他的问题,如果采用这种方式,如果访问的人很多,那么线程池中的线程可能一下子就被消耗完了,而且使用上述方案,最大的特点在于,你觉得时效性会非常重要,但是仔细想想,其实不是这样子的。我只要确认他能做这件事,然后我后边慢慢做就可以了,我并不需要他一口气做完这件事,所以我们应当采用类似于消息队列的方式来完成我们的需求,而不是使用线程池或者是异步编排的方式来完成这个需求。

基于Redis优化方案

我们将耗时比较短的逻辑判断放入redis中,比如是否库存足够,比如是否一人一单,并且进行库存的预减少以及用户是否下单的数据存储,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,根本就不用等待下单逻辑走完,我们直接给用户返回成功,而在后台开一个线程,后台线程慢慢的去执行队列里的消息(操作数据库),这样程序不就超级快了吗?而且不用担心线程池消耗殆尽的问题,因为这里我们的程序中并没有手动使用任何线程池,当然这里面有2个难点。

第一: 我们怎么在redis中去快速检验一人一单,还有库存判断

第二: 由于我们的校验和下单,这是2个线程,那么我们如何知道到底哪个单它最后是否成功,或者是下单完成。为了完成这件事,我们在redis操作完之后,我们会将一些信息返回给前端,同时也会把下单成功的 商品id、用户id、订单id等信息放在队列中,而另一个线程则循环阻塞获取队列中的消息,以此来完成创建订单,扣减库存等对数据库的操作。
image


对于库存数据,以及用户是否下单,在redis中的存储结构

库存数据: 采用string数据结构,在秒杀活动开始时,将mysql数据库中的秒杀商品库存信息写入redis,例如: key:seckill:stock:111 value: 100

用户是否下单: 采用set数据结构,由于set中的元素是不可重复的,因此只需要判断该商品对应的订单key中是否有该用户的信息即可。例如:key: seckill:order:111 value: [11, 22]

整体思路:
1、进行缓存数据的预热,活动开始时,将商品的库存信息存储到redis当中
key:seckill:stock:111 value: 100

2、用户在进行下单时,通过redis的lua脚本来进行库存、一人一单的校验
  2.1 库存不足返回1, 需要使用到商品Id来进行key的拼接
  2.2 用户已下单过了则返回2,需要使用到商品Id来进行key的拼接,同时需要用户Id来校验是否已经下单
  2.3 否则返回0,后续到消息队列的话,会在这里发送一个消息,会用到订单Id

3、判断lua脚本的执行结果,如果结果不是0,则返回友好提示,方法结束

4、如果结果是0,那么就创建订单信息,并且将订单信息存放到一个队列当中
  4.1 有个线程在项目启动时就开始监听这个队列,并且采用阻塞获取到方式
  4.2 一旦队列中有了一个订单信息,则执行操作数据库,进行库存的扣减等(需要优化的

5、响应订单id给用户
image

秒杀优化-Redis完成秒杀资格判断(基于阻塞队列)

需求:

  • 新增了秒杀商品的同时,将商品的库存信息保存到Redis中
  • 基于Lua脚本,判断秒杀库存,一人一单,决定用户是否秒杀成功
  • 如果抢购成功,创建一个订单(使用userId、productId来创建),并将该订单放在阻塞队列
  • 项目启动时开启一个线程任务,不断从阻塞队列中获取消息,实现异步下单功能

需求一:新增了秒杀商品的同时,将商品的库存信息保存到Redis中

@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
    // 保存优惠券
    save(voucher);
    // 保存秒杀信息
    SeckillVoucher seckillVoucher = new SeckillVoucher();
    seckillVoucher.setVoucherId(voucher.getId());
    seckillVoucher.setStock(voucher.getStock());
    seckillVoucher.setBeginTime(voucher.getBeginTime());
    seckillVoucher.setEndTime(voucher.getEndTime());
    seckillVoucherService.save(seckillVoucher);
    // 保存秒杀库存到Redis中
    //SECKILL_STOCK_KEY 这个变量定义在RedisConstans中
    //private static final String SECKILL_STOCK_KEY ="seckill:stock:"
    stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}

需求二:基于Lua脚本,判断秒杀库存,一人一单,决定用户是否秒杀成功

-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]

-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2.库存不足,返回1
    return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3.存在,说明是重复下单,返回2
    return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)

return 0

需求三:如果抢购成功,创建一个订单(使用userId、productId来创建),并将该订单放在阻塞队列
需求四:项目启动时开启一个线程任务,不断从阻塞队列中获取消息,实现异步下单功能

//异步处理线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

//在类初始化之后执行,因为当这个类初始化好了之后,随时都是有可能要执行的
@PostConstruct
private void init() {
   SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 用于线程池处理的任务
// 当初始化完毕后,就会去从对列中去拿信息
 private class VoucherOrderHandler implements Runnable{

        @Override
        public void run() {
            while (true){
                try {
                    // 1.获取队列中的订单信息
                    VoucherOrder voucherOrder = orderTasks.take();
                    // 2.创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (Exception e) {
                    log.error("处理订单异常", e);
                }
          	 }
        }
     
       private void handleVoucherOrder(VoucherOrder voucherOrder) {
            //1.获取用户
            Long userId = voucherOrder.getUserId();
            // 2.创建锁对象
            RLock redisLock = redissonClient.getLock("lock:order:" + userId);
            // 3.尝试获取锁
            boolean isLock = redisLock.lock();
            // 4.判断是否获得锁成功
            if (!isLock) {
                // 获取锁失败,直接返回失败或者重试
                log.error("不允许重复下单!");
                return;
            }
            try {
				//注意:由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效
                proxy.createVoucherOrder(voucherOrder);
            } finally {
                // 释放锁
                redisLock.unlock();
            }
    }
     //a
	private BlockingQueue<VoucherOrder> orderTasks =new  ArrayBlockingQueue<>(1024 * 1024);

    @Override
    public Result seckillVoucher(Long voucherId) {
        Long userId = UserHolder.getUser().getId();
        long orderId = redisIdWorker.nextId("order");
        // 1.执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString(), String.valueOf(orderId)
        );
        int r = result.intValue();
        // 2.判断结果是否为0
        if (r != 0) {
            // 2.1.不为0 ,代表没有购买资格
            return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
        }
        VoucherOrder voucherOrder = new VoucherOrder();
        // 2.3.订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 2.4.用户id
        voucherOrder.setUserId(userId);
        // 2.5.代金券id
        voucherOrder.setVoucherId(voucherId);
        // 2.6.放入阻塞队列
        orderTasks.add(voucherOrder);
        //3.获取代理对象
         proxy = (IVoucherOrderService)AopContext.currentProxy();
        //4.返回订单id
        return Result.ok(orderId);
    }
     
      @Transactional
    public  void createVoucherOrder(VoucherOrder voucherOrder) {
        Long userId = voucherOrder.getUserId();
        // 5.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
        // 5.2.判断是否存在
        if (count > 0) {
            // 用户已经购买过了
           log.error("用户已经购买过了");
           return ;
        }

        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0) // where id = ? and stock > 0
                .update();
        if (!success) {
            // 扣减失败
            log.error("库存不足");
            return ;
        }
        save(voucherOrder);
 
    }

秒杀优化的优化思路、基于阻塞队列的异步秒杀存在哪些问题?

  • 先利用Redis进行库存余量、一人一单的判断,完成抢单业务
  • 将下单业务放入阻塞队列,利用独立线程异步下单
  • 基于阻塞队列的异步秒杀的缺陷
    • 内存限制问题
    • 数据安全问题

消息队列

认识消息队列

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

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

使用队列的好处在于 解耦:所谓解耦,举一个生活中的例子就是:快递员(生产者)把快递放到快递柜里边(Message Queue)去,我们(消费者)从快递柜里边去拿东西,这就是一个异步,如果耦合,那么这个快递员相当于直接把快递交给你,这事固然好,但是万一你不在家,那么快递员就会一直等你,这就浪费了快递员的时间,所以这种思想在我们日常开发中,是非常有必要的。

这种场景在我们秒杀中就变成了:我们下单之后,利用redis去进行校验下单条件,再通过队列把消息发送出去,然后再启动一个线程去消费这个消息,完成解耦,同时也加快我们的响应速度。

这里我们可以使用一些现成的mq,比如kafka,rabbitmq等等,但是呢,如果没有安装mq,我们也可以直接使用redis提供的mq方案,降低我们的部署和学习成本。

Redis消息队列-基于List实现消息队列

基于List结构模拟消息队列

消息队列(Message Queue),字面意思就是存放消息的队列。而Redis的list数据结构是一个双向链表,很容易模拟出队列效果。

队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。
不过要注意的是,当队列中没有消息时RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP或者BLPOP来实现阻塞效果。
image

基于List的消息队列有哪些优缺点
优点:

  • 利用Redis存储,不受限于JVM内存上限
  • 基于Redis的持久化机制,数据安全有保证
  • 可以满足消息有序性

缺点:

  • 无法避免消息丢失
  • 只支持单消费者

Redis消息队列-基于PubSub的消息队列

PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。

SUBSCRIBE channel [channel] :订阅一个或多个频道
PUBLISH channel msg :向一个频道发送消息
PSUBSCRIBE pattern[pattern] :订阅与pattern格式匹配的所有频道
image

基于PubSub的消息队列有哪些优缺点?
优点:

  • 采用发布订阅模型,支持多生产、多消费

缺点:

  • 不支持数据持久化
  • 无法避免消息丢失
  • 消息堆积有上限,超出时数据丢失

Redis消息队列-基于Stream的消息队列

Stream 是 Redis 5.0 引入的一种新数据类型,可以实现一个功能非常完善的消息队列。

发送消息的命令

image
image

读取消息的方式之一

image
使用XREAD读取第一个消息
image
使用XREAD阻塞方式,读取最新的消息
image

在业务开发中,我们可以循环的调用XREAD阻塞方式来查询最新消息,从而实现监听队列的效果

image
注意:当我们指定起始ID为$时,代表读取最新的消息,如果我们处理一条消息的过程中,又有超过1条以上的消息到达队列,则下次获取时也只能获取到最新的一条,会出现漏读消息的问题

STREAM类型消息队列的XREAD命令特点

  • 消息可回溯,通过 $ 符号 和 0
  • 一个消息可以被多个消费者读取
  • 可以阻塞读取
  • 有消息漏读的风险

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

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

  • 消息分流:队列中的消息会分流给组内的不同消费者,而不是重复消费,从而加快消息处理的速度

  • 消息标示: 消费者组会维护一个标示,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标示之后读取消息。确保每一个消息都会被消费

  • 消息确认:消费者获取消息后,消息出于pending状态。当处理完成后需要通过XACK来确认消息,标记消息为已处理,才会从pending-list移除。


消费者组的常见命令

  • 创建消费者组
    XGROUP CREATE key groupname id|$ [MKSTREAM] [ENTRIESREAD entries_read]
    key: 队列名称
    groupName:消费者组名称
    ID: 起始ID标示,$代表队列中最后一个消息,0则代表队列中第一个消息
    MKSTREAM: 队列不存在时自动创建队列

  • 删除消费者组
    XGROUP DESTORY key groupName

  • 给指定的消费者组添加消费者

XGROUP CREATECONSUMER key groupname consumername
  • 删除消费者组中的指定消费者
XGROUP DELCONSUMER key groupname consumername

从消费者组读取消息

XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
  • group:消费组名称
  • consumer:消费者名称,如果消费者不存在,则自动创建一个消费者
  • count:本次查询的最大数量
  • BLOCK milliseconds:当没有消息时最长等待时间,单位毫秒
  • NOACK:无需手动ACK,获取到消息后自动确认
  • STREAMS key: 指定队列名称
  • ID: 获取消息都起始ID
    • “>”:从下一个未消费的消息开始
    • “0或其他数字”:根据指定id从pending-list中获取已消费但未确认的消息,例如0,是从pending-list中的第一个消息开始

image

XREADGROUP命令特点

  • 消息可回溯
  • 可以多消费者争抢消息,加快消费速度
  • 可以阻塞读取
  • 没有消息漏读的风险
  • 有消息确认机制,保证消息至少被消费一次
  • 只有消费者消息确认机制,那么在发布过程中消息丢失,则无法保证安全性

Redis中的三种消息队列对比

image

基于Redis的Stream结构作为消息队列,实现异步秒杀下单

实现思路
1、新增秒杀商品时,对秒杀商品的库存进行缓存预热,存储到redis当中
2、当用户发起下单请求时,使用redis来判断用户是否有下单资格
  2.1、如果库存不足,不满足返回1,(需要用到商品的id来拼接key)
  2.2、一人一单判断,不满足返回2,(需要用到用户的id来拼接key)
  2.3、如上2个条件都满足,进行如下操作
3、在redis当中进行库存的扣减
4、使用set来存储当前商品的订单情况,保证一人一单
5、利用STREAM数据结构来发送一个消息到stream.orders队列中,该消息需要包含3个要素,
  5.1 商品id
  5.2 用户id
  5.3 订单id(因为是异步执行真正的下单业务处理,而我们需要主线程返回订单id给用户,因此就不能在子线程中创建订单ID)
6、主线程判断到结果为0,则将订单ID返回

进行缓存的预热

@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
    // 保存优惠券
    save(voucher);
    // 保存秒杀信息
    SeckillVoucher seckillVoucher = new SeckillVoucher();
    seckillVoucher.setVoucherId(voucher.getId());
    seckillVoucher.setStock(voucher.getStock());
    seckillVoucher.setBeginTime(voucher.getBeginTime());
    seckillVoucher.setEndTime(voucher.getEndTime());
    seckillVoucherService.save(seckillVoucher);
    // 保存秒杀库存到Redis中
    //SECKILL_STOCK_KEY 这个变量定义在RedisConstans中
    //private static final String SECKILL_STOCK_KEY ="seckill:stock:"
    stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}

编写lua脚本,实现是否有资格下单的逻辑判断

-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]

-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2.库存不足,返回1
    return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3.存在,说明是重复下单,返回2
    return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0

下单业务中,执行lua脚本判断用户是否有资格下单,并响应用户

@Override
public Result seckillVoucher(Long voucherId) {
    //获取用户
    Long userId = UserHolder.getUser().getId();
    long orderId = redisIdWorker.nextId("order");
    // 1.执行lua脚本
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(),
            voucherId.toString(), userId.toString(), String.valueOf(orderId)
    );
    int r = result.intValue();
    // 2.判断结果是否为0
    if (r != 0) {
        // 2.1.不为0 ,代表没有购买资格
        return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }
    // 3.返回订单id
    return Result.ok(orderId);
}

项目启动时,开启一条线程用于监听消息队列stream.orders

//在类初始化之后执行,因为当这个类初始化好了之后,随时都是有可能要执行的
@PostConstruct
private void init() {
   SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}

private class VoucherOrderHandler implements Runnable {

    @Override
    public void run() {
        while (true) {
            try {
                // 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                    Consumer.from("g1", "c1"),
                    StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                    StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
                );
                // 2.判断订单信息是否为空
                if (list == null || list.isEmpty()) {
                    // 如果为null,说明没有消息,继续下一次循环
                    continue;
                }
                // 解析数据
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> value = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                // 3.创建订单
                createVoucherOrder(voucherOrder);
                // 4.确认消息 XACK
                stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
            } catch (Exception e) {
                log.error("处理订单异常", e);
                //处理异常消息
                handlePendingList();
            }
        }
    }

    private void handlePendingList() {
        while (true) {
            try {
                // 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                    Consumer.from("g1", "c1"),
                    StreamReadOptions.empty().count(1),
                    StreamOffset.create("stream.orders", ReadOffset.from("0"))
                );
                // 2.判断订单信息是否为空
                if (list == null || list.isEmpty()) {
                    // 如果为null,说明没有异常消息,结束循环
                    break;
                }
                // 解析数据
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> value = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                // 3.创建订单
                createVoucherOrder(voucherOrder);
                // 4.确认消息 XACK
                stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
            } catch (Exception e) {
                log.error("处理pendding订单异常", e);
                try{
                    Thread.sleep(20);
                }catch(Exception e){
                    e.printStackTrace();
                }
            }
        }
    }
}

达人探店

探店笔记类似点评网站的评价,往往是图文结合。对应的表有两个:
tb_blog:探店笔记表,包含笔记中的标题、文字、图片等
tb_blog_comments:其他用户对探店笔记的评价

点赞功能(需求分析,实现思路)

原始代码:只要用户点击了任意博客的点赞按钮,就会不断的使其点赞数量+1

@GetMapping("/likes/{id}")
public Result queryBlogLikes(@PathVariable("id") Long id) {
    //修改点赞数量
    blogService.update().setSql("liked = liked +1 ").eq("id",id).update();
    return Result.ok();
}

需求分析

  • 同一个用户只能点赞一次,再次点击则取消点赞
  • 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)
  • 给Blog类中添加一个isLike字段,标示是否被当前用户点赞
  • 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
  • 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
  • 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段

实现思路

  • 我们可以将用户的点赞状态存储到Redis当中

  • 由于是一个用户只能点赞一次,因此用户不能重复,因此可以考虑使用set数据类型

  • 每次用户在发起点赞时,以 blog:liked:blogId 为key,其中的value集合中就放userId

  • 用户发起点赞时,以redis中的blog:liked:blogId为key,去判断用户是否已经点过赞了

  • 如果点过赞了,那么我们就将其取消点赞(先操作数据库,再操作redis)

  • 如果没点过赞,我们就为其选择的blog的点赞字段加一,并将userId存储到redis中作为标识

具体步骤
1、在Blog添加一个字段

@TableField(exist = false)
private Boolean isLike;

2、点赞的业务逻辑代码

 @Override
    public Result likeBlog(Long id){
        // 1.获取登录用户
        Long userId = UserHolder.getUser().getId();
        // 2.判断当前登录用户是否已经点赞
        String key = BLOG_LIKED_KEY + id;
        Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
        if(BooleanUtil.isFalse(isMember)){
             //3.如果未点赞,可以点赞
            //3.1 数据库点赞数+1
            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
            //3.2 保存用户到Redis的set集合
            if(isSuccess){
                stringRedisTemplate.opsForSet().add(key,userId.toString());
            }
        }else{
             //4.如果已点赞,取消点赞
            //4.1 数据库点赞数-1
            boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
            //4.2 把用户从Redis的set集合移除
            if(isSuccess){
                stringRedisTemplate.opsForSet().remove(key,userId.toString());
            }
        }

点赞排行榜(三种集合的对比,注意MySQL中的in,会默认按照id排序)

需求分析

  • 进入到某一条Blog时,可以获取到当前Blog最先点赞的5个人的用户头像,也就是最早点赞的TOP5

  • 但是,想想我们之前使用的set集合,是不能排序的。因此我们可以考虑将set集合换成sortdset

image

代码修改
点赞逻辑代码修改

   @Override
    public Result likeBlog(Long id) {
        // 1.获取登录用户
        Long userId = UserHolder.getUser().getId();
        // 2.判断当前登录用户是否已经点赞
        String key = BLOG_LIKED_KEY + id;
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
        if (score == null) {
            // 3.如果未点赞,可以点赞
            // 3.1.数据库点赞数 + 1
            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
            // 3.2.保存用户到Redis的set集合  zadd key value score
            if (isSuccess) {
                stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
            }
        } else {
            // 4.如果已点赞,取消点赞
            // 4.1.数据库点赞数 -1
            boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
            // 4.2.把用户从Redis的set集合移除
            if (isSuccess) {
                stringRedisTemplate.opsForZSet().remove(key, userId.toString());
            }
        }
        return Result.ok();
    }


    private void isBlogLiked(Blog blog) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        if (user == null) {
            // 用户未登录,无需查询是否点赞
            return;
        }
        Long userId = user.getId();
        // 2.判断当前登录用户是否已经点赞
        String key = "blog:liked:" + blog.getId();
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
        blog.setIsLike(score != null);
    }

点赞列表查询列表

@GetMapping("/likes/{id}")
public Result queryBlogLikes(@PathVariable("id") Long id) {

    return blogService.queryBlogLikes(id);
}
@Override
public Result queryBlogLikes(Long id) {
    String key = BLOG_LIKED_KEY + id;
    // 1.查询top5的点赞用户 zrange key 0 4
    Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
    if (top5 == null || top5.isEmpty()) {
        return Result.ok(Collections.emptyList());
    }
    // 2.解析出其中的用户id
    List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
    String idStr = StrUtil.join(",", ids);
    // 3.根据用户id查询用户 WHERE id IN ( 5 , 1 ) ORDER BY FIELD(id, 5, 1)
    List<UserDTO> userDTOS = userService.query()
            .in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list()
            .stream()
            .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
            .collect(Collectors.toList());
    // 4.返回
    return Result.ok(userDTOS);
}

好友关注

关注和取消关注

image

需求分析

  • 一个登录的A,在打开B用户的主页时,需要查询A是否已经关注B用户,进行相对应的显示

  • 当用户A点击关注按钮时,往数据库中插入一条数据作为关联关系

  • 当用户B点击取消按钮时,数据库中删掉该关联关系的行

代码思路

  • FollowController
//关注 | 取消关注
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {
    return followService.follow(followUserId, isFollow);
}
//是否关注
@GetMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId) {
      return followService.isFollow(followUserId);
}
  • FollowService
// 是否关注
@Override
public Result isFollow(Long followUserId) {
        // 1.获取登录用户
        Long userId = UserHolder.getUser().getId();
        // 2.查询是否关注 select count(*) from tb_follow where user_id = ? and follow_user_id = ?
        Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
        // 3.判断
        return Result.ok(count > 0);
    }

// 关注或取消关注
 @Override
    public Result follow(Long followUserId, Boolean isFollow) {
        // 1.获取登录用户
        Long userId = UserHolder.getUser().getId();
        String key = "follows:" + userId;
        // 1.判断到底是关注还是取关
        if (isFollow) {
            // 2.关注,新增数据
            Follow follow = new Follow();
            follow.setUserId(userId);
            follow.setFollowUserId(followUserId);
            boolean isSuccess = save(follow);

        } else {
            // 3.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
            remove(new QueryWrapper<Follow>()
                    .eq("user_id", userId).eq("follow_user_id", followUserId));

        }
        return Result.ok();
    }

共同关注

需求分析

  • 用户A点进用户B的主页后,点击共同关注,可以查看到各自的关注列表

  • 利用Redis中恰当的数据结构,实现共同关注功能

实现思路

  • 修改关注和取消关注的代码逻辑
    • 在redis当中为每一个用户维护一个关注列表,blog:follow:userId
    • 点击关注则向mysql中插入一条数据,而后存储到redis中
    • 取消关注时,删除mysql中的关联数据,而后清掉缓存中blog:follow:userId这个key中对应的Member
  • 先获取到A用户的关注列表
  • 再获取到B用户的关注列表
  • 对其做一个交集,即可获取到共同的关注列表

代码实现
修改关注、取消关注的逻辑

@Override
public Result follow(Long followUserId, Boolean isFollow) {
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    String key = "follows:" + userId;
    // 1.判断到底是关注还是取关
    if (isFollow) {
        // 2.关注,新增数据
        Follow follow = new Follow();
        follow.setUserId(userId);
        follow.setFollowUserId(followUserId);
        boolean isSuccess = save(follow);
        if (isSuccess) {
            // 把关注用户的id,放入redis的set集合 sadd userId followerUserId
            stringRedisTemplate.opsForSet().add(key, followUserId.toString());
        }
    } else {
        // 3.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
        boolean isSuccess = remove(new QueryWrapper<Follow>()
                .eq("user_id", userId).eq("follow_user_id", followUserId));
        if (isSuccess) {
            // 把关注用户的id从Redis集合中移除
            stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
        }
    }
    return Result.ok();
}

获取相互关注的实现

@Override
public Result followCommons(Long id) {
    // 1.获取当前用户
    Long userId = UserHolder.getUser().getId();
    String key = "follows:" + userId;
    // 2.求交集
    String key2 = "follows:" + id;
    Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
    if (intersect == null || intersect.isEmpty()) {
        // 无交集
        return Result.ok(Collections.emptyList());
    }
    // 3.解析id集合
    List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
    // 4.查询用户
    List<UserDTO> users = userService.listByIds(ids)
            .stream()
            .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
            .collect(Collectors.toList());
    return Result.ok(users);
}

好友关注-Feed流实现方案

当我们关注了用户后,这个用户发了动态,那么我们应该把这些数据推送给用户,这个需求,其实我们又把他叫做Feed流,关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。

对于传统的模式的内容解锁:我们是需要用户去通过搜索引擎或者是其他的方式去解锁想要看的内容
image

对于新型的Feed流的的效果:不需要我们用户再去推送信息,而是系统分析用户到底想要什么,然后直接把内容推送给用户,从而使用户能够更加的节约时间,不用主动去寻找。
image

Feed流实现的两种方式

Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈

  • 优点:信息全面,不会有缺失,并且实现也相对简单
  • 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低

智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户

  • 优点: 投喂用户感兴趣信息,用户粘度很高,容易沉迷
  • 缺点: 如果算法不精准,可能起到反作用

本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。

Timeline的三种实现方案

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

拉模式:也叫做读扩散

该模式的核心含义就是:当张三和李四和王五发了消息后,都会保存在自己的邮箱中,假设赵六要读取信息,那么他会从读取他自己的收件箱,此时系统会从他关注的人群中,把他关注人的信息全部都进行拉取,然后在进行排序

优点:比较节约空间,因为赵六在读信息时,并没有重复读取,而且读取完之后可以把他的收件箱进行清楚。

缺点:比较延迟,当用户读取数据时才去关注的人里边去读取数据,假设用户关注了大量的用户,那么此时就会拉取海量的内容,对服务器压力巨大。
image

推模式:也叫做写扩散。
推模式是没有写邮箱的,当张三写了一个内容,此时会主动的把张三写的内容发送到他的粉丝收件箱中去,假设此时李四再来读取,就不用再去临时拉取了

优点:时效快,不用临时拉取

缺点:内存压力大,假设一个大V写信息,很多人关注他, 就会写很多分数据到粉丝那边去
image

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

推拉模式是一个折中的方案,站在发件人这一段,如果是个普通的人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝中去,因为普通的人他的粉丝关注量比较小,所以这样做没有压力,如果是大V,那么他是直接将数据先写入到一份到发件箱里边去,然后再直接写一份到活跃粉丝收件箱里边去,现在站在收件人这端来看,如果是活跃粉丝,那么大V和普通的人发的都会直接写入到自己收件箱里边来,而如果是普通的粉丝,由于他们上线不是很频繁,所以等他们上线时,再从发件箱里边去拉信息。

好友关注-推送到粉丝收件箱(不能采用传统分页模式)

需求:

  • 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
  • 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
  • 查询收件箱数据时,可以实现分页查询

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

传统的分页在feed流是不适用的,因为我们的数据会随时发生变化。

假设在t1 时刻,我们去读取第一页,此时page = 1 ,size = 5 ,那么我们拿到的就是10~6 这几条记录,假设现在t2时候又发布了一条记录,此时t3 时刻,我们来读取第二页,读取第二页传入的参数是page=2 ,size=5 ,那么此时读取到的第二页实际上是从6 开始,然后是6~2 ,那么我们就读取到了重复的数据,所以feed流的分页,不能采用原始方案来做。

image

Feed流的滚动分页

我们需要记录每次操作的最后一条,然后从这个位置开始去读取数据

举个例子:我们从t1时刻开始,拿第一页数据,拿到了10~6,然后记录下当前最后一次拿取的记录,就是6,t2时刻发布了新的记录,此时这个11放到最顶上,但是不会影响我们之前记录的6,此时t3时刻来拿第二页,第二页这个时候拿数据,还是从6后一点的5去拿,就拿到了5-1的记录。我们这个地方可以采用sortedSet来做,可以进行范围查询,并且还可以记录当前获取数据时间戳最小值,就可以实现滚动分页了
image

核心的意思:就是我们在保存完探店笔记后,获得到当前笔记的粉丝,然后把数据推送到粉丝的redis中去。

修改发布笔记的业务逻辑。

  • 发布笔记时,获取到当前用户的粉丝列表,并分别为每个粉丝维护一个收件箱

  • 将笔记的数据存储到收件箱中

  • 并且由于需要进行逻辑排序,需要从最新发布的开始获取,因此使用sortdset

@Override
public Result saveBlog(Blog blog) {
    // 1.获取登录用户
    UserDTO user = UserHolder.getUser();
    blog.setUserId(user.getId());
    // 2.保存探店笔记
    boolean isSuccess = save(blog);
    if(!isSuccess){
        return Result.fail("新增笔记失败!");
    }
    // 3.查询笔记作者的所有粉丝 select * from tb_follow where follow_user_id = ?
    List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
    // 4.推送笔记id给所有粉丝
    for (Follow follow : follows) {
        // 4.1.获取粉丝id
        Long userId = follow.getUserId();
        // 4.2.推送
        String key = FEED_KEY + userId;
        stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
    }
    // 5.返回id
    return Result.ok(blog.getId());
}

好友关注-实现分页查询收件箱

需求:在个人主页的“关注”卡片中,查询并展示推送的Blog信息:

1、每次查询完成后,我们要分析出查询出数据的最小时间戳,这个值会作为下一次查询的条件

2、我们需要找到与上一次查询相同的查询个数作为偏移量,下次查询时,跳过这些查询过的数据,拿到我们需要的数据

综上:我们的请求参数中就需要携带 lastId:上一次查询的最小时间戳 和偏移量这两个参数。

这两个参数第一次会由前端来指定,以后的查询就根据后台结果作为条件,再次传递到后台。
image

一、定义出来具体的返回值实体类

@Data
public class ScrollResult {
    private List<?> list;
    private Long minTime;
    private Integer offset;
}

BlogController

注意:RequestParam 表示接受url地址栏传参的注解,当方法上参数的名称和url地址栏不相同时,可以通过RequestParam 来进行指定

@GetMapping("/of/follow")
public Result queryBlogOfFollow(
    @RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset){
    return blogService.queryBlogOfFollow(max, offset);
}

BlogServiceImpl

@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
    // 1.获取当前用户
    Long userId = UserHolder.getUser().getId();
    // 2.查询收件箱 ZREVRANGEBYSCORE key Max Min LIMIT offset count
    String key = FEED_KEY + userId;
    Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
        .reverseRangeByScoreWithScores(key, 0, max, offset, 2);
    // 3.非空判断
    if (typedTuples == null || typedTuples.isEmpty()) {
        return Result.ok();
    }
    // 4.解析数据:blogId、minTime(时间戳)、offset
    List<Long> ids = new ArrayList<>(typedTuples.size());
    long minTime = 0; // 2
    int os = 1; // 2
    for (ZSetOperations.TypedTuple<String> tuple : typedTuples) { // 5 4 4 2 2
        // 4.1.获取id
        ids.add(Long.valueOf(tuple.getValue()));
        // 4.2.获取分数(时间戳)
        long time = tuple.getScore().longValue();
        if(time == minTime){
            os++;
        }else{
            minTime = time;
            os = 1;
        }
    }
	os = minTime == max ? os : os + offset;
    // 5.根据id查询blog
    String idStr = StrUtil.join(",", ids);
    List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();

    for (Blog blog : blogs) {
        // 5.1.查询blog有关的用户
        queryBlogUser(blog);
        // 5.2.查询blog是否被点赞
        isBlogLiked(blog);
    }

    // 6.封装并返回
    ScrollResult r = new ScrollResult();
    r.setList(blogs);
    r.setOffset(os);
    r.setMinTime(minTime);

    return Result.ok(r);
}

附近商户

附近商户-GEO数据结构的基本用法

GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:

  • GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
  • GEODIST:计算指定的两个点之间的距离并返回
  • GEOHASH:将指定member的坐标转为hash字符串形式并返回
  • GEOPOS:返回指定member的坐标
  • GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.以后已废弃
  • GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
  • GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。 6.2.新功能

附近商户-导入店铺数据到GEO(redis数据存储哪些部分,redis中没法根据type对数据筛选)

接口设计
当我们点击美食之后,会出现一系列的商家,商家中可以按照多种排序方式,我们此时关注的是距离,这个地方就需要使用到我们的GEO,向后台传入当前app收集的地址(我们此处是写死的) ,以当前坐标作为圆心,同时绑定相同的店家类型type,以及分页信息,把这几个条件传入后台,后台查询出对应的数据再返回。
image

image

我们要做的事情是:将数据库表中的数据导入到redis中去,redis中的GEO,GEO在redis中就一个menber和一个经纬度,我们把x和y轴传入到redis做的经纬度位置去,但我们不能把所有的数据都放入到menber中去,毕竟作为redis是一个内存级数据库,如果存海量数据,redis还是力不从心,所以我们在这个地方存储他的id即可。

但是这个时候还有一个问题,就是在redis中并没有存储type,所以我们无法根据type来对数据进行筛选,所以我们可以按照商户类型做分组,类型相同的商户作为同一组,以typeId为key存入同一个GEO集合中即可

数据缓存到redis当中

@Test
void loadShopData() {
    // 1.查询店铺信息
    List<Shop> list = shopService.list();
    // 2.把店铺分组,按照typeId分组,typeId一致的放到一个集合
    Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
    // 3.分批完成写入Redis
    for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
        // 3.1.获取类型id
        Long typeId = entry.getKey();
        String key = SHOP_GEO_KEY + typeId;
        // 3.2.获取同类型的店铺的集合
        List<Shop> value = entry.getValue();
        List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
        // 3.3.写入redis GEOADD key 经度 纬度 member
        for (Shop shop : value) {
            // stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
            locations.add(new RedisGeoCommands.GeoLocation<>(
                    shop.getId().toString(),
                    new Point(shop.getX(), shop.getY())
            ));
        }
        stringRedisTemplate.opsForGeo().add(key, locations);
    }
}

实现附近商户功能

SpringDataRedis的2.3.9版本并不支持Redis 6.2提供的GEOSEARCH命令,因此我们需要提示其版本,修改自己的POM

第一步:修改pom

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <artifactId>spring-data-redis</artifactId>
            <groupId>org.springframework.data</groupId>
        </exclusion>
        <exclusion>
            <artifactId>lettuce-core</artifactId>
            <groupId>io.lettuce</groupId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>2.6.2</version>
</dependency>
<dependency>
    <groupId>io.lettuce</groupId>
    <artifactId>lettuce-core</artifactId>
    <version>6.1.6.RELEASE</version>
</dependency>

第二步:添加请求接口

@GetMapping("/of/type")
public Result queryShopByType(
        @RequestParam("typeId") Integer typeId,
        @RequestParam(value = "current", defaultValue = "1") Integer current,
        @RequestParam(value = "x", required = false) Double x,
        @RequestParam(value = "y", required = false) Double y
) {
   return shopService.queryShopByType(typeId, current, x, y);
}

第三步:实现获取商铺数据,并包含距离

@Override
    public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
        // 1.判断是否需要根据坐标查询
        if (x == null || y == null) {
            // 不需要坐标查询,按数据库查询
            Page<Shop> page = query()
                    .eq("type_id", typeId)
                    .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
            // 返回数据
            return Result.ok(page.getRecords());
        }

        // 2.计算分页参数
        int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
        int end = current * SystemConstants.DEFAULT_PAGE_SIZE;

        // 3.查询redis、按照距离排序、分页。结果:shopId、distance
        String key = SHOP_GEO_KEY + typeId;
        GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE
                .search(
                        key,
                        GeoReference.fromCoordinate(x, y),
                        new Distance(5000),
                        RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
                );
        // 4.解析出id
        if (results == null) {
            return Result.ok(Collections.emptyList());
        }
        List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
        if (list.size() <= from) {
            // 没有下一页了,结束
            return Result.ok(Collections.emptyList());
        }
        // 4.1.截取 from ~ end的部分
        List<Long> ids = new ArrayList<>(list.size());
        Map<String, Distance> distanceMap = new HashMap<>(list.size());
        list.stream().skip(from).forEach(result -> {
            // 4.2.获取店铺id
            String shopIdStr = result.getContent().getName();
            ids.add(Long.valueOf(shopIdStr));
            // 4.3.获取距离
            Distance distance = result.getDistance();
            distanceMap.put(shopIdStr, distance);
        });
        // 5.根据id查询Shop
        String idStr = StrUtil.join(",", ids);
        List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
        for (Shop shop : shops) {
            shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
        }
        // 6.返回
        return Result.ok(shops);
    }

用户签到

用户签到-BitMap功能演示

我们针对签到功能完全可以通过mysql来完成,比如说以下这张表
image

用户一次签到,就是一条记录,假如有1000万用户,平均每人每年签到次数为10次,则这张表一年的数据量为 1亿条

每签到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共22 字节的内存,一个月则最多需要600多字节

我们如何能够简化一点呢?其实可以考虑小时候一个挺常见的方案,就是小时候,咱们准备一张小小的卡片,你只要签到就打上一个勾,我最后判断你是否签到,其实只需要到小卡片上看一看就知道了

我们可以采用类似这样的方案来实现我们的签到需求。

我们按月来统计用户签到信息,签到记录为1,未签到则记录为0.

把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)。这样我们就用极小的空间,来实现了大量数据的表示

Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是 2^32个bit位。

image

使用示例
在2022年11月7号的时候,用户id为321的用户,在1号, 2号,3号都进行了签到

SETBIT user:sign:2022:11:321 0 1
SETBIT user:sign:2022:11:321 1 1
SETBIT user:sign:2022:11:321 2 1

判断该用户本月1日到现在7日签到共几天

BITCOUNT user:sign:2022:11:321 0 6

判断用户在5日的时候是否签到

GETBIT user:sign:2022:11:321 4

获取指定范围内的签到数据,用于日历展示

# 其中的u6,代表无符号,有符号使用i,这个6代表获取6个比特位,结果是一个十进制
BITFIELD user:sign:2022:11:321 get u6 0

因此如果想要进行日历统计,只需要把这个十进制转换为二进制,再判断0和1即可。

BitMap的常用命令

  • SETBIT:向指定位置(offset)存入一个0或1
  • GETBIT :获取指定位置(offset)的bit值
  • BITCOUNT :统计BitMap中值为1的bit位的数量
  • BITFIELD :操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
  • BITFIELD_RO :获取BitMap中bit数组,并以十进制形式返回
  • BITOP :将多个BitMap的结果做位运算(与 、或、异或)
  • BITPOS :查找bit数组中指定范围内第一个0或1出现的位置

用户签到-实现签到功能

需求:实现签到接口,将当前用户当天签到信息保存到Redis中
思路:我们可以把年和月作为bitMap的key,然后保存到一个bitMap中,每次签到就到对应的位上把数字从0变成1,只要对应是1,就表明说明这一天已经签到了,反之则没有签到。

我们通过接口文档发现,此接口并没有传递任何的参数,没有参数怎么确实是哪一天签到呢?这个很容易,可以通过后台代码直接获取即可,然后到对应的地址上去修改bitMap。
image

登录接口

 @PostMapping("/sign")
 public Result sign(){
    return userService.sign();
 }

登录业务

@Override
public Result sign() {
    // 1.获取当前登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.获取日期
    LocalDateTime now = LocalDateTime.now();
    // 3.拼接key
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    String key = USER_SIGN_KEY + userId + keySuffix;
    // 4.获取今天是本月的第几天
    int dayOfMonth = now.getDayOfMonth();
    // 5.写入Redis SETBIT key offset 1
    stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
    return Result.ok();
}

用户签到-签到统计

问题1 什么叫做连续签到天数?
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。
image

Java逻辑代码:获得当前这个月的最后一次签到数据,定义一个计数器,然后不停的向前统计,直到获得第一个非0的数字即可,每得到一个非0的数字计数器+1,直到遍历完所有的数据,就可以获得当前月的签到总天数了

问题2:如何得到本月到今天为止的所有签到数据?

BITFIELD key GET u[dayOfMonth] 0

假设今天是10号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是10号,那么就是10位,去拿这段时间的数据,就能拿到所有的数据了,那么这10天里边签到了多少次呢?统计有多少个1即可。

问题3:如何从后向前遍历每个bit位?

注意:bitMap返回的数据是10进制,哪假如说返回一个数字8,那么我哪儿知道到底哪些是0,哪些是1呢?我们只需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。

需求:实现下面接口,统计当前用户截止当前时间在本月的连续签到天数

有用户有时间我们就可以组织出对应的key,此时就能找到这个用户截止这天的所有签到记录,再根据这套算法,就能统计出来他连续签到的次数了

image

代码
Controller

@GetMapping("/sign/count")
public Result signCount(){
    return userService.signCount();
}

业务代码

@Override
public Result signCount() {
    // 1.获取当前登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.获取日期
    LocalDateTime now = LocalDateTime.now();
    // 3.拼接key
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    String key = USER_SIGN_KEY + userId + keySuffix;
    // 4.获取今天是本月的第几天
    int dayOfMonth = now.getDayOfMonth();
    // 5.获取本月截止今天为止的所有的签到记录,返回的是一个十进制的数字 BITFIELD sign:5:202203 GET u14 0
    List<Long> result = stringRedisTemplate.opsForValue().bitField(
            key,
            BitFieldSubCommands.create()
                    .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
    );
    if (result == null || result.isEmpty()) {
        // 没有任何签到结果
        return Result.ok(0);
    }
    Long num = result.get(0);
    if (num == null || num == 0) {
        return Result.ok(0);
    }
    // 6.循环遍历
    int count = 0;
    while (true) {
        // 6.1.让这个数字与1做与运算,得到数字的最后一个bit位  // 判断这个bit位是否为0
        if ((num & 1) == 0) {
            // 如果为0,说明未签到,结束
            break;
        }else {
            // 如果不为0,说明已签到,计数器+1
            count++;
        }
        // 把数字右移一位,抛弃最后一个bit位,继续下一个bit位
        num >>>= 1;
    }
    return Result.ok(count);
}

额外加餐-关于使用bitmap来解决缓存穿透的方案

回顾缓存穿透

发起了一个数据库不存在的,redis里边也不存在的数据,通常你可以把他看成一个攻击

解决方案:

  • 判断id<0

  • 如果数据库是空,那么就可以直接往redis里边把这个空数据缓存起来

第一种解决方案:遇到的问题是如果用户访问的是id不存在的数据,则此时就无法生效

第二种解决方案:遇到的问题是:如果是不同的id那就可以防止下次过来直击数据

所以我们如何解决呢?

我们可以将数据库的数据,所对应的id写入到一个list集合中,当用户过来访问的时候,我们直接去判断list中是否包含当前的要查询的数据,如果说用户要查询的id数据并不在list集合中,则直接返回,如果list中包含对应查询的id数据,则说明不是一次缓存穿透数据,则直接放行。
image

现在的问题是这个主键其实并没有那么短,而是很长的一个 主键

哪怕你单独去提取这个主键,但是在11年左右,淘宝的商品总量就已经超过10亿个

所以如果采用以上方案,这个list也会很大,所以我们可以使用bitmap来减少list的存储空间

我们可以把list数据抽象成一个非常大的bitmap,我们不再使用list,而是将db中的id数据利用哈希思想,比如:

id % bitmap.size = 算出当前这个id对应应该落在bitmap的哪个索引上,然后将这个值从0变成1,然后当用户来查询数据时,此时已经没有了list,让用户用他查询的id去用相同的哈希算法, 算出来当前这个id应当落在bitmap的哪一位,然后判断这一位是0,还是1,如果是0则表明这一位上的数据一定不存在, 采用这种方式来处理,需要重点考虑一个事情,就是误差率,所谓的误差率就是指当发生哈希冲突的时候,产生的误差。
image

UV统计

UV统计-HyperLogLog

首先我们搞懂两个概念:

  • UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
  • PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。

通常来说UV会比PV大很多,所以衡量同一个网站的访问量,我们需要综合考虑很多因素,所以我们只是单纯的把这两个值作为一个参考值

UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖,那怎么处理呢?

Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:https://juejin.cn/post/6844903785744056333#heading-0

Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
image

UV统计-测试百万数据的统计

测试思路:我们直接利用单元测试,向HyperLogLog中添加100万条数据,看看内存占用和统计效果如何

image
经过测试:我们会发生他的误差是在允许范围内,并且内存占用极小

Redis的最佳实践

键值设计

优雅的key结构

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

  • 遵循基本格式:[业务名称]:[数据名]:[id]
  • 长度不超过44字节(一个string的key所占用的字节如果超过了44字节,将会使用raw的方式存储)
  • 不包含特殊字符

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

优点

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

拒绝BigKey

BigKey通常以Key的大小和Key中成员的数量来综合判定,例如:

  • key本身的数据量过大:一个String类型的Key,它的值为5MB
  • Key中的成员数过多:一个ZSET类型的Key,它的成员数量为10000个
  • Key中的成员数据量过大:一个Hash类型的Key,它的成员数量虽然只有1000,但是内存大小总占用超过了100MB

推荐值

  • 单个key的value小于10KB(String)

  • 对于集合类型的key,建议元素数量小于1000(但是需要进行相对应的配置hash-max-ziplist-entries

BigKey的危害

  • 网络阻塞
    对BigKey执行读请求时,少量的QPS就可能导致带宽使用率被占满,导致Redis实例,乃至所在物理机变慢

  • 数据倾斜
    BigKey所在的Redis实例内存使用率远超于其他实例,无法使数据分片的内存资源达到均衡

  • Redis阻塞
    对元素较多的Hash、List、Zset等做运算会耗时较久,使主线程被阻塞

  • CPU压力
    对BigKey的数据序列化和反序列化会导致CPU的使用率飙升,影响Redis实例和本机其他应用

如何发现BigKey

  • redis-cli --bigkeys
    利用redis-cli提供的--bigkeys参数,可以遍历分析所有key,并返回Key到整体统计信息与每个数据的Top1的big key

  • scan扫描
    自己编程,利用scan扫描redis中的所有key,利用strlen、hlen、llen等命令判断key的长度(不建议使用MEMORY USAGE),不建议放在主机运行,可以放在从节点运行

import com.heima.jedis.util.JedisConnectionFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.ScanResult;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class JedisTest {
    private Jedis jedis;

    @BeforeEach
    void setUp() {
        // 1.建立连接
        // jedis = new Jedis("192.168.150.101", 6379);
        jedis = JedisConnectionFactory.getJedis();
        // 2.设置密码
        jedis.auth("123321");
        // 3.选择库
        jedis.select(0);
    }

    final static int STR_MAX_LEN = 10 * 1024;
    final static int HASH_MAX_LEN = 500;

    @Test
    void testScan() {
        int maxLen = 0;
        long len = 0;

        String cursor = "0";
        do {
            // 扫描并获取一部分key
            ScanResult<String> result = jedis.scan(cursor);
            // 记录cursor
            cursor = result.getCursor();
            List<String> list = result.getResult();
            if (list == null || list.isEmpty()) {
                break;
            }
            // 遍历
            for (String key : list) {
                // 判断key的类型
                String type = jedis.type(key);
                switch (type) {
                    case "string":
                        len = jedis.strlen(key);
                        maxLen = STR_MAX_LEN;
                        break;
                    case "hash":
                        len = jedis.hlen(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    case "list":
                        len = jedis.llen(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    case "set":
                        len = jedis.scard(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    case "zset":
                        len = jedis.zcard(key);
                        maxLen = HASH_MAX_LEN;
                        break;
                    default:
                        break;
                }
                if (len >= maxLen) {
                    System.out.printf("Found big key : %s, type: %s, length or size: %d %n", key, type, len);
                }
            }
        } while (!cursor.equals("0"));
    }

    @AfterEach
    void tearDown() {
        if (jedis != null) {
            jedis.close();
        }
    }
}
  • 第三方工具
    利用第三方工具,如Redis-Rdb-Tools分析RDB快照文件,全面分析内存使用情况

  • 网络监控(阿里云的redis提供了此服务,但是需要打钱)
    自定义工具,监控进入redis到网络数据,超出预警值时主动告警

如何删除BigKey

BigKey内存占用较多,即便时删除这样的key也需要耗费很长时间,导致Redis主线程阻塞,引发一系列问题。

  • redis 3.0 及以下版本
    如果是集合类型,则遍历BigKey元素,先逐个删除子元素,最后删除BigKey

  • Redis 4.0 以后
    Redis在4.0后提供了异步删除的命令:unlink,异步开启一条线程去删除该key。

恰当的数据类型

存储一个User对象,我们有三种存储方式

  • json字符串(除非是一些简单的数据结构,我们可以采用string,否则如果存储了过多的str,每个string都key都会额外占用一些字节存储头信息)
    例如:user:1 | {"name":"zhangsan", "age": 18}
    优点:实现简单粗暴
    缺点:数据耦合,不够灵活

  • 字段打散
    例如:

user:1:name      |   Jack
user:1:age        | 10

优点:可以灵活访问对象任意字段
缺点:占用空间大、没办法做统一控制

  • hash(推荐,占用内存更小)
user:1  name Jack age 21

优点:底层使用ziplist,空间占用小,可以灵活访问对象的任意字段
缺点:代码相对复杂

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

存在的问题

  • hash的entry数量超过500时,会将ziplist数据结构转换为Hash数据结构,因此内存占用更高

  • 可以通过hash-max-ziplist-entries配置entry上限。但是如果entry过多就会导致BigKey问题,此时应该考虑bigkey会给我们造成的4个危害。
    image
    image


优化方案
方案一:**拆分为string类型 **

存在的问题:

  • string结构底层没有太多内存优化,内存占用较多。

  • 想要批量获取这些数据比较麻烦
    image
    image


方案二:将其打散拆分为多个hash类型
思路分析: 拆分为小的hash,将 id / 100 作为key, 将id % 100 作为field,这样每100个元素为一个Hash!
image
image

    @Test
    void testBigHash() {
        Map<String, String> map = new HashMap<>();
        for (int i = 1; i <= 100000; i++) {
            map.put("key_" + i, "value_" + i);
        }
        jedis.hmset("test:big:hash", map);
    }

    @Test
    void testBigString() {
        for (int i = 1; i <= 100000; i++) {
            jedis.set("test:str:key_" + i, "value_" + i);
        }
    }

    @Test
    void testSmallHash() {
        int hashSize = 100;
        Map<String, String> map = new HashMap<>(hashSize);
        for (int i = 1; i <= 100000; i++) {
            int k = (i - 1) / hashSize;
            int v = i % hashSize;
            map.put("key_" + v, "value_" + v);
            if (v == 0) {
                jedis.hmset("test:small:hash_" + k, map);
            }
        }
    }

总结(Key的最佳实践,Value的最佳实践)

Key的最佳实践:

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

Value的最佳实践

  • 合理的拆分数据,拒绝BigKey
  • 选择合适的数据结构(强烈建议不要只想着string)
  • Hash结构的entry数量不要超过1000
  • 设置合理的超时时间

批处理优化

Pipeline批处理

单个命令及N条命令依次执行、N条命令批量执行的流程

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

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

N次命令的响应时间 = 1次往返的网络传输耗时 + N次Redis执行命令耗时(可以看到这里只需要花费一次网络传输到耗时
image

Mset、Hmset、sadd等命令的局限性

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

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

// 40多秒
    @Test
    void testFor() {
        for (int i = 1; i <= 100000; i++) {
            jedis.set("test:key_" + i, "value_" + i);
        }
    }

// 200毫秒
    @Test
    void testMxx() {
        String[] arr = new String[2000];
        int j;
        long b = System.currentTimeMillis();
        for (int i = 1; i <= 100000; i++) {
            j = (i % 1000) << 1;
            arr[j] = "test:key_" + i;
            arr[j + 1] = "value_" + i;
            if (j == 0) {
                jedis.mset(arr);
            }
        }
        long e = System.currentTimeMillis();
        System.out.println("time: " + (e - b));
    }

Pipeline的使用(Pipeline的多个命令之间不具备原子性)

Pipeline可以使用jedis的各种命令,非常的灵活。

    @Test
    void testPipeline() {
        // 创建管道
        Pipeline pipeline = jedis.pipelined();
        long b = System.currentTimeMillis();
        for (int i = 1; i <= 100000; i++) {
            // 放入命令到管道
            pipeline.set("test:key_" + i, "value_" + i);
            if (i % 1000 == 0) {
                // 每放入1000条命令,批量执行
                pipeline.sync();
            }
        }
        long e = System.currentTimeMillis();
        System.out.println("time: " + (e - b));
    }

批量处理的方案以及注意事项

批量处理的方案

  • 原生的M操作
  • Pipeline批处理

注意事项:

  • 批处理时不建议一次携带太多命令
  • Pipeline的多个命令之间不具备原子性

集群下的批处理(4个实现思路)

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

  • 非springboot项目解决集群下批处理问题
public class Assert {
    public static void notNull(Object obj, String msg){
        if (obj == null) {
            throw new RuntimeException(msg);
        }
    }
    public static void hasText(String str, String msg){
        if (str == null) {
            throw new RuntimeException(msg);
        }
        if (str.trim().isEmpty()) {
            throw new RuntimeException(msg);
        }
    }
}

import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public final class ByteUtils {

	private ByteUtils() {}

	/**
	 * Concatenate the given {@code byte} arrays into one, with overlapping array elements included twice.
	 * <p />
	 * The order of elements in the original arrays is preserved.
	 *
	 * @param array1 the first array.
	 * @param array2 the second array.
	 * @return the new array.
	 */
	public static byte[] concat(byte[] array1, byte[] array2) {

		byte[] result = Arrays.copyOf(array1, array1.length + array2.length);
		System.arraycopy(array2, 0, result, array1.length, array2.length);

		return result;
	}

	/**
	 * Concatenate the given {@code byte} arrays into one, with overlapping array elements included twice. Returns a new,
	 * empty array if {@code arrays} was empty and returns the first array if {@code arrays} contains only a single array.
	 * <p />
	 * The order of elements in the original arrays is preserved.
	 *
	 * @param arrays the arrays.
	 * @return the new array.
	 */
	public static byte[] concatAll(byte[]... arrays) {

		if (arrays.length == 0) {
			return new byte[] {};
		}
		if (arrays.length == 1) {
			return arrays[0];
		}

		byte[] cur = concat(arrays[0], arrays[1]);
		for (int i = 2; i < arrays.length; i++) {
			cur = concat(cur, arrays[i]);
		}
		return cur;
	}

	/**
	 * Split {@code source} into partitioned arrays using delimiter {@code c}.
	 *
	 * @param source the source array.
	 * @param c delimiter.
	 * @return the partitioned arrays.
	 */
	public static byte[][] split(byte[] source, int c) {

		if (ObjectUtils.isEmpty(source)) {
			return new byte[][] {};
		}

		List<byte[]> bytes = new ArrayList<>();
		int offset = 0;
		for (int i = 0; i <= source.length; i++) {

			if (i == source.length) {

				bytes.add(Arrays.copyOfRange(source, offset, i));
				break;
			}

			if (source[i] == c) {
				bytes.add(Arrays.copyOfRange(source, offset, i));
				offset = i + 1;
			}
		}
		return bytes.toArray(new byte[bytes.size()][]);
	}

	/**
	 * Merge multiple {@code byte} arrays into one array
	 *
	 * @param firstArray must not be {@literal null}
	 * @param additionalArrays must not be {@literal null}
	 * @return
	 */
	public static byte[][] mergeArrays(byte[] firstArray, byte[]... additionalArrays) {

		Assert.notNull(firstArray, "first array must not be null");
		Assert.notNull(additionalArrays, "additional arrays must not be null");

		byte[][] result = new byte[additionalArrays.length + 1][];
		result[0] = firstArray;
		System.arraycopy(additionalArrays, 0, result, 1, additionalArrays.length);

		return result;
	}

	/**
	 * Extract a byte array from {@link ByteBuffer} without consuming it.
	 *
	 * @param byteBuffer must not be {@literal null}.
	 * @return
	 * @since 2.0
	 */
	public static byte[] getBytes(ByteBuffer byteBuffer) {

		Assert.notNull(byteBuffer, "ByteBuffer must not be null!");

		ByteBuffer duplicate = byteBuffer.duplicate();
		byte[] bytes = new byte[duplicate.remaining()];
		duplicate.get(bytes);
		return bytes;
	}

	/**
	 * Tests if the {@code haystack} starts with the given {@code prefix}.
	 *
	 * @param haystack the source to scan.
	 * @param prefix the prefix to find.
	 * @return {@literal true} if {@code haystack} at position {@code offset} starts with {@code prefix}.
	 * @since 1.8.10
	 * @see #startsWith(byte[], byte[], int)
	 */
	public static boolean startsWith(byte[] haystack, byte[] prefix) {
		return startsWith(haystack, prefix, 0);
	}

	/**
	 * Tests if the {@code haystack} beginning at the specified {@code offset} starts with the given {@code prefix}.
	 *
	 * @param haystack the source to scan.
	 * @param prefix the prefix to find.
	 * @param offset the offset to start at.
	 * @return {@literal true} if {@code haystack} at position {@code offset} starts with {@code prefix}.
	 * @since 1.8.10
	 */
	public static boolean startsWith(byte[] haystack, byte[] prefix, int offset) {

		int to = offset;
		int prefixOffset = 0;
		int prefixLength = prefix.length;

		if ((offset < 0) || (offset > haystack.length - prefixLength)) {
			return false;
		}

		while (--prefixLength >= 0) {
			if (haystack[to++] != prefix[prefixOffset++]) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Searches the specified array of bytes for the specified value. Returns the index of the first matching value in the
	 * {@code haystack}s natural order or {@code -1} of {@code needle} could not be found.
	 *
	 * @param haystack the source to scan.
	 * @param needle the value to scan for.
	 * @return index of first appearance, or -1 if not found.
	 * @since 1.8.10
	 */
	public static int indexOf(byte[] haystack, byte needle) {

		for (int i = 0; i < haystack.length; i++) {
			if (haystack[i] == needle) {
				return i;
			}
		}

		return -1;
	}

	/**
	 * Convert a {@link String} into a {@link ByteBuffer} using {@link java.nio.charset.StandardCharsets#UTF_8}.
	 *
	 * @param theString must not be {@literal null}.
	 * @return
	 * @since 2.1
	 */
	public static ByteBuffer getByteBuffer(String theString) {
		return getByteBuffer(theString, StandardCharsets.UTF_8);
	}

	/**
	 * Convert a {@link String} into a {@link ByteBuffer} using the given {@link Charset}.
	 *
	 * @param theString must not be {@literal null}.
	 * @param charset must not be {@literal null}.
	 * @return
	 * @since 2.1
	 */
	public static ByteBuffer getByteBuffer(String theString, Charset charset) {

		Assert.notNull(theString, "The String must not be null!");
		Assert.notNull(charset, "The String must not be null!");

		return charset.encode(theString);
	}

	/**
	 * Extract/Transfer bytes from the given {@link ByteBuffer} into an array by duplicating the buffer and fetching its
	 * content.
	 *
	 * @param buffer must not be {@literal null}.
	 * @return the extracted bytes.
	 * @since 2.1
	 */
	public static byte[] extractBytes(ByteBuffer buffer) {

		ByteBuffer duplicate = buffer.duplicate();
		byte[] bytes = new byte[duplicate.remaining()];
		duplicate.get(bytes);

		return bytes;
	}
}

import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.Collection;

public final class ClusterSlotHashUtil {

	private static final int SLOT_COUNT = 16384;

	private static final byte SUBKEY_START = '{';
	private static final byte SUBKEY_END = '}';

	private static final int[] LOOKUP_TABLE = { 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7, 0x8108,
			0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF, 0x1231, 0x0210, 0x3273, 0x2252, 0x52B5, 0x4294, 0x72F7,
			0x62D6, 0x9339, 0x8318, 0xB37B, 0xA35A, 0xD3BD, 0xC39C, 0xF3FF, 0xE3DE, 0x2462, 0x3443, 0x0420, 0x1401, 0x64E6,
			0x74C7, 0x44A4, 0x5485, 0xA56A, 0xB54B, 0x8528, 0x9509, 0xE5EE, 0xF5CF, 0xC5AC, 0xD58D, 0x3653, 0x2672, 0x1611,
			0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4, 0xB75B, 0xA77A, 0x9719, 0x8738, 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC, 0x48C4,
			0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861, 0x2802, 0x3823, 0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A,
			0xB92B, 0x5AF5, 0x4AD4, 0x7AB7, 0x6A96, 0x1A71, 0x0A50, 0x3A33, 0x2A12, 0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E, 0x9B79,
			0x8B58, 0xBB3B, 0xAB1A, 0x6CA6, 0x7C87, 0x4CE4, 0x5CC5, 0x2C22, 0x3C03, 0x0C60, 0x1C41, 0xEDAE, 0xFD8F, 0xCDEC,
			0xDDCD, 0xAD2A, 0xBD0B, 0x8D68, 0x9D49, 0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70, 0xFF9F,
			0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A, 0x9F59, 0x8F78, 0x9188, 0x81A9, 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E,
			0xE16F, 0x1080, 0x00A1, 0x30C2, 0x20E3, 0x5004, 0x4025, 0x7046, 0x6067, 0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D,
			0xD31C, 0xE37F, 0xF35E, 0x02B1, 0x1290, 0x22F3, 0x32D2, 0x4235, 0x5214, 0x6277, 0x7256, 0xB5EA, 0xA5CB, 0x95A8,
			0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D, 0x34E2, 0x24C3, 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, 0xA7DB,
			0xB7FA, 0x8799, 0x97B8, 0xE75F, 0xF77E, 0xC71D, 0xD73C, 0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, 0x4615,
			0x5634, 0xD94C, 0xC96D, 0xF90E, 0xE92F, 0x99C8, 0x89E9, 0xB98A, 0xA9AB, 0x5844, 0x4865, 0x7806, 0x6827, 0x18C0,
			0x08E1, 0x3882, 0x28A3, 0xCB7D, 0xDB5C, 0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A, 0x4A75, 0x5A54, 0x6A37,
			0x7A16, 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92, 0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9, 0x7C26,
			0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83, 0x1CE0, 0x0CC1, 0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9,
			0x9FF8, 0x6E17, 0x7E36, 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0 };

	private ClusterSlotHashUtil() {

	}

	/**
	 * @param keys must not be {@literal null}.
	 * @return
	 * @since 2.0
	 */
	public static boolean isSameSlotForAllKeys(Collection<ByteBuffer> keys) {

		Assert.notNull(keys, "Keys must not be null!");

		if (keys.size() <= 1) {
			return true;
		}

		return isSameSlotForAllKeys((byte[][]) keys.stream() //
				.map(ByteBuffer::duplicate) //
				.map(ByteUtils::getBytes) //
				.toArray(byte[][]::new));
	}

	/**
	 * @param keys must not be {@literal null}.
	 * @return
	 * @since 2.0
	 */
	public static boolean isSameSlotForAllKeys(ByteBuffer... keys) {

		Assert.notNull(keys, "Keys must not be null!");
		return isSameSlotForAllKeys(Arrays.asList(keys));
	}

	/**
	 * @param keys must not be {@literal null}.
	 * @return
	 */
	public static boolean isSameSlotForAllKeys(byte[]... keys) {

		Assert.notNull(keys, "Keys must not be null!");

		if (keys.length <= 1) {
			return true;
		}

		int slot = calculateSlot(keys[0]);
		for (int i = 1; i < keys.length; i++) {
			if (slot != calculateSlot(keys[i])) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Calculate the slot from the given key.
	 *
	 * @param key must not be {@literal null} or empty.
	 * @return
	 */
	public static int calculateSlot(String key) {

		Assert.hasText(key, "Key must not be null or empty!");
		return calculateSlot(key.getBytes());
	}

	/**
	 * Calculate the slot from the given key.
	 *
	 * @param key must not be {@literal null}.
	 * @return
	 */
	public static int calculateSlot(byte[] key) {

		Assert.notNull(key, "Key must not be null!");

		byte[] finalKey = key;
		int start = indexOf(key, SUBKEY_START);
		if (start != -1) {
			int end = indexOf(key, start + 1, SUBKEY_END);
			if (end != -1 && end != start + 1) {

				finalKey = new byte[end - (start + 1)];
				System.arraycopy(key, start + 1, finalKey, 0, finalKey.length);
			}
		}
		return crc16(finalKey) % SLOT_COUNT;
	}

	private static int indexOf(byte[] haystack, byte needle) {
		return indexOf(haystack, 0, needle);
	}

	private static int indexOf(byte[] haystack, int start, byte needle) {

		for (int i = start; i < haystack.length; i++) {

			if (haystack[i] == needle) {
				return i;
			}
		}

		return -1;
	}

	private static int crc16(byte[] bytes) {

		int crc = 0x0000;

		for (byte b : bytes) {
			crc = ((crc << 8) ^ LOOKUP_TABLE[((crc >>> 8) ^ (b & 0xFF)) & 0xFF]);
		}
		return crc & 0xFFFF;
	}
}


import redis.clients.jedis.*;

public class JedisConnectionFactory {

    private static JedisPool jedisPool;
    private static final JedisCluster jedisCluster;

    static {
        // 配置连接池
        JedisPoolConfig poolConfig = new JedisPoolConfig();
        poolConfig.setMaxTotal(8);
        poolConfig.setMaxIdle(8);
        poolConfig.setMinIdle(0);
        poolConfig.setMaxWaitMillis(1000);
        // 创建连接池对象
        // jedisPool = new JedisPool(poolConfig,
        //         "192.168.150.101", 6379, 1000, "123321");
        jedisCluster = new JedisCluster(
                new HostAndPort("192.168.150.101", 7001), poolConfig);
    }

    public static Jedis getJedis(){
        return jedisPool.getResource();
    }

    public static JedisCluster getJedisCluster(){
        return jedisCluster;
    }
}

import com.sun.istack.internal.Nullable;

import java.lang.reflect.Array;
import java.util.Collection;
import java.util.Map;
import java.util.Optional;

public class ObjectUtils {
    public static boolean isEmpty(@Nullable Object obj) {
        if (obj == null) {
            return true;
        }

        if (obj instanceof Optional) {
            return !((Optional<?>) obj).isPresent();
        }
        if (obj instanceof CharSequence) {
            return ((CharSequence) obj).length() == 0;
        }
        if (obj.getClass().isArray()) {
            return Array.getLength(obj) == 0;
        }
        if (obj instanceof Collection) {
            return ((Collection<?>) obj).isEmpty();
        }
        if (obj instanceof Map) {
            return ((Map<?, ?>) obj).isEmpty();
        }

        // else
        return false;
    }
}

  • springboot项目解决集群下批处理问题
    StringBoot的解决方案,内部使用了Lettuce来进行存储
#  批量执行set命令,底层使用的还是mset
void multiSet(Map<? extends K, ? extends V> map);

# 批量执行获取命令
List<V> multiGet(Collection<K> keys);

服务端优化

持久化配置(缓存的redis以及非缓存的redis、aof文件重写,避免主线程阻塞在rewrite或者fork期间)

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

  • 用来做缓存的Redis实例尽量不要开启持久化功能
  • 建议关闭RDB持久化功能,使用AOF持久化
  • 利用脚本定期在slave节点做RDB,实现数据备份
  • 设置合理的rewrite阈值,避免频繁的bgrewrite
  • 配置no-appendfsync-on-rewrite = yes,禁止在rewrite期间做aof,避免因AOF引起的阻塞(禁止在rewrite期间或者rdb的fork期间做aof刷盘操作)。因为如果在rewrite或者fork期间进行aof,并且该aof在2秒内未成功,则主线程会阻塞,无法执行客户端的命令。
    image

部署有关建议:

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

image

慢查询(阈值、长度)

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

慢查询的阈值可以通过配置指定:
slowlog-log-slower-than:慢查询阈值,单位是微秒。默认是10000,建议1000
image

慢查询日志的长度有上限,可以通过配置指定:
slowlog-max-len:慢查询日志(本质是一个队列)的长度。默认是128,建议1000

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

查看慢查询日志列表

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

image

命令及安全配置

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

漏洞重现方式:https://cloud.tencent.com/developer/article/1039000

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

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

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

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

rename 命令使用示例

# 接下来如果想要再使用config命令,就需要使用b840fc02d524045429941cc15f59e41cb7be6c52 来代替config
rename-command CONFIG b840fc02d524045429941cc15f59e41cb7be6c52

内存配置(redis的内存划分为3块区域)

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

redis占用内存主要体现在3个区域

  • 数据内存:存储Redis的键值信息。主要问题是BigKey,以及内存碎片问题。

  • 进程内存: redis的主进程本身运行肯定需要占用内存,如代码、常量池等等;这部分内存大约几M,在大多数生产环境中与redis数据占用的内存相比可以忽略。

  • 缓冲区内存:一般包括客户端缓冲区、AOF缓冲区、复制缓冲区等。客户端缓冲区又包括输入缓冲区和输出缓冲区两种。这部分内存占用波动较大,不当使用BigKey,可能导致内存溢出。

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

info memory
memory xxx, 例如memory stats
image

image

缓冲区内存配置(3种)

复制缓冲区:主从复制的reple_backlog_buf,如果太小可能导致频繁的全量复制,影响性能。通过repl_backlog_size来设置,默认1MB(一般不进行配置)

AOF缓冲区:AOF刷盘之前的缓存区域,AOF执行rewrite的缓冲区。无法设置容量上限,理论无限。

客户端缓冲区:分为输入缓冲区和输出缓冲区,输入缓冲区最大值1G且不能设置。输出缓冲区可以设置

client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>

image

默认的客户端输出缓冲区配置如下

# 普通客户端,不限制大小
client-output-buffer-limit normal 0 0 0

# 主从复制客户端,超过256m则断开客户端, 或者超过64mb的60秒后断开客户端
client-output-buffer-limit replica 256mb 64mb 60

# Pubsub客户端,超过32m则断开客户端, 或者超过8mb的60秒后断开客户端
client-output-buffer-limit pubsub 32mb 8mb 60

集群最佳实践(重点:能不用就别用)

集群完整性问题

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

# 即便集群中部分插槽不可用,也不会导致整个集群服务不可用
cluster-require-full-coverage false

集群带宽问题

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

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

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

解决途径:

  • 避免大集群,集群节点数不要太多,最好少于1000,如果业务庞大,则建立多个集群
  • 避免在单个物理机中运行太多redis实例(10个左右即可)
  • 配置何时的cluster-node-timeout值: 当集群中相互ping检测状态时,超过了这个阈值,则代表该节点客观下线。

搭建集群还是搭建主从

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

  • 集群完整性问题

  • 集群带宽问题

  • 数据倾斜问题(数据出现bigkey)

  • 客户端性能问题(一旦做了集群,不管利用jedis还是其他客户端,在连接redis进行操作时,会对节点的选择,以及插槽进行计算,都会耗费一定的性能。)

  • 命令的集群兼容性问题(mset、mget在集群下都无法正常运行,不得不在客户端进行一些处理,例如并行slot,实现复杂,并且实现后仍有性能问题)

  • lua和事务问题(lua脚本也会面临key不在一个节点)

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

Redis原理篇

7种数据结构及5种数据类型

动态字符串SDS(Simple Dynamic String)

我们都知道Redis中保存的Key是字符串,value往往是字符串或者字符串的集合。可见字符串是Redis中最常用的一种数据结构。

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

  • 获取字符串长度需要经过运算,挨个遍历,时间复杂度达到O(n)级别
  • 非二进制安全
  • 不可修改

例如,我们执行命令:
image
那么Redis将在底层创建两个SDS,其中一个是包含“name”的SDS,另一个是包含“虎哥”的SDS。


通过阅读redis的源码sds.h头文件中看到对于SDS数据结构的定义

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* buf已保存的字符串字节数,不包含结束标示 */
    uint8_t alloc; /* buf申请的总的字节数,不包含结束标示 */
    unsigned char flags; /* 不同SDS的头类型,用来控制SDS的头大小 */
    char buf[]; /*实际存储的string数据,存放在一个char数组当中*/
};

unsigned char flags有如下的5种类型

#define SDS_TYPE_5  0
#define SDS_TYPE_8  1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4

例如:一个包含字符串“name”的sds结构如下:
image

为什么它称为动态字符串

因为它具备动态扩容的能力,例如一个内容为“hi”的SDS:
image
假如我们要给SDS追加一段字符串“,Amy”,这里首先会申请新内存空间:

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

优点:

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

IntSet(可变、有序、二分)

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

结构如下:

typedef struct intset {
    uint32_t encoding; /* 编码方式,支持存放16位、32位、64位整数*/
    uint32_t length; /* 元素个数 */
    int8_t contents[]; /* 整数数组,保存集合数据*/
} intset;

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

#define INTSET_ENC_INT16 (sizeof(int16_t)) /* 2字节整数,范围类似java的short*/
#define INTSET_ENC_INT32 (sizeof(int32_t)) /* 4字节整数,范围类似java的int */
#define INTSET_ENC_INT64 (sizeof(int64_t)) /* 8字节整数,范围类似java的long */

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

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

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

IntSet的升级机制

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

我们向该其中添加一个数字:50000,这个数字超出了int16_t的范围,intset会自动升级编码方式到合适的大小。
以当前案例来说流程如下:

  • 升级编码为INTSET_ENC_INT32, 每个整数占4字节,并按照新的编码方式及元素个数扩容数组
  • 倒序依次将数组中的元素拷贝到扩容后的正确位置
  • 将待添加的元素放入数组末尾
  • 最后,将inset的encoding属性改为INTSET_ENC_INT32,将length属性改为4
    image

image

IntSet新增流程与升级流程的代码

新增流程

intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
    uint8_t valenc = _intsetValueEncoding(value);// 获取当前值编码
    uint32_t pos; // 要插入的位置
    if (success) *success = 1;
    // 判断编码是不是超过了当前intset的编码
    if (valenc > intrev32ifbe(is->encoding)) {
        // 超出编码,需要升级
        return intsetUpgradeAndAdd(is,value);
    } else {
        // 在当前intset中查找值与value一样的元素的角标pos
        if (intsetSearch(is,value,&pos)) {
            if (success) *success = 0; //如果找到了,则无需插入,直接结束并返回失败
            return is;
        }
        // 数组扩容
        is = intsetResize(is,intrev32ifbe(is->length)+1);
        // 移动数组中pos之后的元素到pos+1,给新元素腾出空间
        if (pos < intrev32ifbe(is->length)) intsetMoveTail(is,pos,pos+1);
    }
    // 插入新元素
    _intsetSet(is,pos,value);
    // 重置元素长度
    is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
    return is;
}

升级流程

static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
    // 获取当前intset编码
    uint8_t curenc = intrev32ifbe(is->encoding);
    // 获取新编码
    uint8_t newenc = _intsetValueEncoding(value);
    // 获取元素个数
    int length = intrev32ifbe(is->length); 
    // 判断新元素是大于0还是小于0 ,小于0插入队首、大于0插入队尾
    int prepend = value < 0 ? 1 : 0;
    // 重置编码为新编码
    is->encoding = intrev32ifbe(newenc);
    // 重置数组大小
    is = intsetResize(is,intrev32ifbe(is->length)+1);
    // 倒序遍历,逐个搬运元素到新的位置,_intsetGetEncoded按照旧编码方式查找旧元素
    while(length--) // _intsetSet按照新编码方式插入新元素
        _intsetSet(is,length+prepend,_intsetGetEncoded(is,length,curenc));
    /* 插入新元素,prepend决定是队首还是队尾*/
    if (prepend)
        _intsetSet(is,0,value);
    else
        _intsetSet(is,intrev32ifbe(is->length),value);
    // 修改数组长度
    is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
    return is;
}

IntSet的特点

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

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

Dict

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

Dict的组成三部分

分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

Dict字典

typedef struct dict {
    dictType *type; // dict类型,内置不同的hash函数
    void *privdata;     // 私有数据,在做特殊hash运算时用
    dictht ht[2]; // 一个Dict包含两个哈希表,其中一个是当前数据,另一个一般是空,rehash时使用
    long rehashidx;   // rehash的进度,-1表示未进行
    int16_t pauserehash; // rehash是否暂停,1则暂停,0则继续
} dict;

哈希表

typedef struct dictht {
    // entry数组
    // 数组中保存的是指向entry的指针
    dictEntry **table; 
    // 哈希表大小
    unsigned long size;     
    // 哈希表大小的掩码,总等于size - 1
    unsigned long sizemask;     
    // entry个数
    unsigned long used; 
} dictht;

哈希节点

typedef struct dictEntry {
    void *key; // 键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v; // 值
    // 下一个Entry的指针
    struct dictEntry *next; 
} dictEntry;

添加键值时的步骤

  1. Redis首先根据key计算出hash值(h)
  2. 利用 h & sizemask来计算元素应该存储到数组中的哪个索引位置。
  3. 我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此k1=v1要存储到数组角标1位置。
    image

Dict的整体结构图

image

Dict的扩容

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

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

  • 哈希表的 LoadFactor >= 1,说明entry的个数已经大于等于哈希表的长度了,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程;
  • 哈希表的 LoadFactor > 5 ;
static int _dictExpandIfNeeded(dict *d){
    // 如果正在rehash,则返回ok
    if (dictIsRehashing(d)) return DICT_OK;    // 如果哈希表为空,则初始化哈希表为默认大小:4
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
    // 当负载因子(used/size)达到1以上,并且当前没有进行bgrewrite等子进程操作
    // 或者负载因子超过5,则进行 dictExpand ,也就是扩容
    if (d->ht[0].used >= d->ht[0].size &&
        (dict_can_resize || d->ht[0].used/d->ht[0].size > dict_force_resize_ratio){
        // 扩容大小为used + 1,底层会对扩容大小做判断,实际上找的是第一个大于等于 used+1 的 2^n
        return dictExpand(d, d->ht[0].used + 1);
    }
    return DICT_OK;
}

Dict的缩容

Dict除了扩容以外,每次删除元素时,也会对负载因子做检查,当LoadFactor < 0.1并且元素个数大于4(哈希表初始大小)时,会做哈希表收缩:

if (dictDelete((dict*)o->ptr, field) == C_OK) {
    deleted = 1;
    // 删除成功后,检查是否需要重置Dict大小,如果需要则调用dictResize重置    /* Always check if the dictionary needs a resize after a delete. */
    if (htNeedsResize(o->ptr)) dictResize(o->ptr);
}

int htNeedsResize(dict *dict) {
    long long size, used;
    // 哈希表大小
    size = dictSlots(dict);
    // entry数量
    used = dictSize(dict);
    // size > 4(哈希表初识大小)并且 负载因子低于0.1
    return (size > DICT_HT_INITIAL_SIZE && (used*100/size < HASHTABLE_MIN_FILL));
}

int dictResize(dict *d){
    unsigned long minimal;
    // 如果正在做bgsave或bgrewriteof或rehash,则返回错误
    if (!dict_can_resize || dictIsRehashing(d)) 
        return DICT_ERR;
    // 获取used,也就是entry个数
    minimal = d->ht[0].used;
    // 如果used小于4,则重置为4
    if (minimal < DICT_HT_INITIAL_SIZE)
        minimal = DICT_HT_INITIAL_SIZE;
    // 重置大小为minimal,其实是第一个大于等于minimal的2^n
    return dictExpand(d, minimal);
}

Dict的rehash及渐进式rehash

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

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

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

image

ZipList

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

ZipList的结构

image

image

ZipListEntry的结构(小端字节序)

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

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

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

Encoding编码

ZipListEntry中的encoding编码分为字符串和整数两种:
字符串:如果encoding是以“00”、“01”或者“10”开头,则证明content是字符串
image
例如,我们要保存字符串:“ab”和 “bc”
image

image

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

例如,一个ZipList中包含两个整数值:“2”和“5”
image
image

ZipList的连锁更新问题

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

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

现在,假设我们有N个连续的、长度为250~253字节之间的entry,因此entry的previous_entry_length属性用1个字节即可表示,如图所示:
image
image
ZipList这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新(Cascade Update)。新增、删除都可能导致连锁更新的发生(不过发生的概率比较低)

ZipList的特性

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

QuickList

为什么要使用QuickList,ZipList有哪些问题

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

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

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

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

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

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

解决ZipList中的Entry过多

为了避免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:
      image

对ZipList的中间Entry进行压缩,更节省内存

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

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

QuickList及QuickListNode的部分源码 及整体结构图

typedef struct quicklist {
    // 头节点指针
// 尾节点指    quicklistNode *head; 
针
    quicklistNode *tail; 
    // 所有ziplist的entry的数量
    unsigned long count;    
    // ziplists总数量
    unsigned long len;
    // ziplist的entry上限,默认值 -2 
    int fill : QL_FILL_BITS;         // 首尾不压缩的节点数量
    unsigned int compress : QL_COMP_BITS;
    // 内存重分配时的书签数量及数组,一般用不到
    unsigned int bookmark_count: QL_BM_BITS;
    quicklistBookmark bookmarks[];
} quicklist;


typedef struct quicklistNode {
    // 前一个节点指针
    struct quicklistNode *prev;
    // 下一个节点指针
    struct quicklistNode *next;
    // 当前节点的ZipList指针
    unsigned char *zl;
    // 当前节点的ZipList的字节大小
    unsigned int sz;
    // 当前节点的ZipList的entry个数
    unsigned int count : 16;  
    // 编码方式:1,ZipList; 2,lzf压缩模式
    unsigned int encoding : 2;
    // 数据容器类型(预留):1,其它;2,ZipList
    unsigned int container : 2;
    // 是否被解压缩。1:则说明被解压了,将来要重新压缩
    unsigned int recompress : 1;
    unsigned int attempted_compress : 1; //测试用
    unsigned int extra : 10; /*预留字段*/
} quicklistNode;

image

QuickList的特点

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

SkipList

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

  • 元素按照升序排列存储
  • 节点可能包含多个指针,指针跨度不同。
    image
// t_zset.c
typedef struct zskiplist {
    // 头尾节点指针
    struct zskiplistNode *header, *tail;
    // 节点数量
    unsigned long length;
    // 最大的索引层级,默认是1
    int level;
} zskiplist;

// t_zset.c
typedef struct zskiplistNode {
    sds ele; // 节点存储的值
    double score;// 节点分数,排序、查找用
    struct zskiplistNode *backward; // 前一个节点指针
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 下一个节点指针
        unsigned long span; // 索引跨度
    } level[]; // 多级索引数组
} zskiplistNode;

image
image

ShipList的特点

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

RedisObject

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

不同数据类型的编码方式

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

数据类型(5种),其他的像GEO、BitMap、等扩展的数据类型,其实都是在5种数据类型下扩展的

String(3种编码方式)

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

  • 其基本编码方式是RAW,基于简单动态字符串(SDS)实现,存储上限为512mb。
  • 如果存储的SDS长度小于44字节,则会采用EMBSTR编码,此时object head与SDS是一段连续空间。申请内存时只需要调用一次内存分配函数,效率更高。
  • 如果存储的字符串是整数值,并且大小在LONG_MAX范围内,则会采用INT编码:直接将数据保存在RedisObject的ptr指针位置(刚好8字节),不再需要SDS了。

raw
image

embstr
image

int
image

List(3种编码方式)

Redis的List结构类似一个双端链表,可以从首、尾操作列表中的元素

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

  • 在3.2版本之后,Redis统一采用QuickList的数据结构来实现List
    image

void pushGenericCommand(client *c, int where, int xx) {
    int j;
    // 尝试找到KEY对应的list
    robj *lobj = lookupKeyWrite(c->db, c->argv[1]);
    // 检查类型是否正确
    if (checkType(c,lobj,OBJ_LIST)) return;
    // 检查是否为空
    if (!lobj) {
        if (xx) {
            addReply(c, shared.czero);
            return;
        }
        // 为空,则创建新的QuickList
        lobj = createQuicklistObject();
        quicklistSetOptions(lobj->ptr, server.list_max_ziplist_size,
                            server.list_compress_depth);
        dbAdd(c->db,c->argv[1],lobj);
    }
    // 略 ...
}


robj *createQuicklistObject(void) {
    // 申请内存并初始化QuickList
    quicklist *l = quicklistCreate();
    // 创建RedisObject,type为OBJ_LIST
    // ptr指向 QuickList
    robj *o = createObject(OBJ_LIST,l);
    // 设置编码为 QuickList
    o->encoding = OBJ_ENCODING_QUICKLIST;
    return o;
}

Set(2种编码方式)

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

  • 不保证有序性
  • 保证元素唯一
  • 求交集、并集、差集

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

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

image
image
image

创建Set集合时的源码

image

修改set集合时的源码

image

ZSet(3种)

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

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

因此,zset底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求。之前学习的哪种编码结构可以满足?

  • SkipList:可以排序,并且可以同时存储score和ele值(member)
  • HT(Dict):可以键值存储,并且可以根据key找value

ZSet的定义

// zset结构
typedef struct zset {
    // Dict指针
    dict *dict;
    // SkipList指针
    zskiplist *zsl;
} zset;

robj *createZsetObject(void) {
    zset *zs = zmalloc(sizeof(*zs));
    robj *o;
    // 创建Dict
    zs->dict = dictCreate(&zsetDictType,NULL);
    // 创建SkipList
    zs->zsl = zslCreate(); 
    o = createObject(OBJ_ZSET,zs);
    o->encoding = OBJ_ENCODING_SKIPLIST;
    return o;
}

image

使用ShipList和Dict配合的条件

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

  • 元素数量小于zset_max_ziplist_entries,默认值128
  • 每个元素都小于zset_max_ziplist_value字节,默认值64
  • ziplist本身没有排序功能,而且没有键值对的概念,因此需要有zset通过编码实现
    • ZipList是连续内存,因此score和element是紧挨在一起的两个entry, element在前,score在后
    • score越小越接近队首,score越大越接近队尾,按照score值升序排列
      image
ZSet创建时的源码
int zsetAdd(robj *zobj, double score, sds ele, int in_flags, int *out_flags, double *newscore) {
    /* 判断编码方式*/
    if (zobj->encoding == OBJ_ENCODING_ZIPLIST) {// 是ZipList编码
        unsigned char *eptr;
        // 判断当前元素是否已经存在,已经存在则更新score即可        if ((eptr = zzlFind(zobj->ptr,ele,&curscore)) != NULL) {
            //...略
            return 1;
        } else if (!xx) {
            // 元素不存在,需要新增,则判断ziplist长度有没有超、元素的大小有没有超
            if (zzlLength(zobj->ptr)+1 > server.zset_max_ziplist_entries
 		|| sdslen(ele) > server.zset_max_ziplist_value 
 		|| !ziplistSafeToAdd(zobj->ptr, sdslen(ele)))
            { // 如果超出,则需要转为SkipList编码
                zsetConvert(zobj,OBJ_ENCODING_SKIPLIST);
            } else {
                zobj->ptr = zzlInsert(zobj->ptr,ele,score);
                if (newscore) *newscore = score;
                *out_flags |= ZADD_OUT_ADDED;
                return 1;
            }
        } else {
            *out_flags |= ZADD_OUT_NOP;
            return 1;
        }
    }    // 本身就是SKIPLIST编码,无需转换
    if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {
       // ...略
    } else {
        serverPanic("Unknown sorted set encoding");
    }
    return 0; /* Never reached. */
}


robj *createZsetObject(void) {
    // 申请内存
    zset *zs = zmalloc(sizeof(*zs));
    robj *o;
    // 创建Dict
    zs->dict = dictCreate(&zsetDictType,NULL);
    // 创建SkipList
    zs->zsl = zslCreate();
    o = createObject(OBJ_ZSET,zs);
    o->encoding = OBJ_ENCODING_SKIPLIST;
    return o;
}


robj *createZsetZiplistObject(void) {
    // 创建ZipList
    unsigned char *zl = ziplistNew();
    robj *o = createObject(OBJ_ZSET,zl);
    o->encoding = OBJ_ENCODING_ZIPLIST;
    return o;
}
ZSet在新增元素时的源码
int zsetAdd(robj *zobj, double score, sds ele, int in_flags, int *out_flags, double *newscore) {
    /* 判断编码方式*/
    if (zobj->encoding == OBJ_ENCODING_ZIPLIST) {// 是ZipList编码
        unsigned char *eptr;
        // 判断当前元素是否已经存在,已经存在则更新score即可        if ((eptr = zzlFind(zobj->ptr,ele,&curscore)) != NULL) {
            //...略
            return 1;
        } else if (!xx) {
            // 元素不存在,需要新增,则判断ziplist长度有没有超、元素的大小有没有超
            if (zzlLength(zobj->ptr)+1 > server.zset_max_ziplist_entries
 		|| sdslen(ele) > server.zset_max_ziplist_value 
 		|| !ziplistSafeToAdd(zobj->ptr, sdslen(ele)))
            { // 如果超出,则需要转为SkipList编码
                zsetConvert(zobj,OBJ_ENCODING_SKIPLIST);
            } else {
                zobj->ptr = zzlInsert(zobj->ptr,ele,score);
                if (newscore) *newscore = score;
                *out_flags |= ZADD_OUT_ADDED;
                return 1;
            }
        } else {
            *out_flags |= ZADD_OUT_NOP;
            return 1;
        }
    }    // 本身就是SKIPLIST编码,无需转换
    if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {
       // ...略
    } else {
        serverPanic("Unknown sorted set encoding");
    }
    return 0; /* Never reached. */
}

Hash(2种数据结构)

Hash结构与Redis中的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),就算修改,也不要超过1000
  • ZipList中的任意entry大小超过了hash-max-ziplist-value(默认64字节)
    image
    image

网络模型

用户空间和内核空间

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

  • 进程的寻址空间会划分为两部分:内核空间用户空间
  • 用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问
  • 内核空间可以执行特权命令(Ring0),调用一切系统资源
    image

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

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

阻塞IO(Blocking IO)

顾名思义,阻塞IO就是两个阶段都必须阻塞等待:
image

非阻塞IO(Nonblocking IO)

image

IO多路复用(IO Multiplexing)

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

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

而在单线程情况下,只能依次处理IO事件,如果正在处理的IO事件恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有IO事件都必须等待,性能自然会很差。
就比如服务员给顾客点餐,分两步:

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

要提高效率有几种办法?

  • 方案一:增加更多服务员(多线程)
  • 方案二:不排队,谁想好了吃什么(数据就绪了),服务员就给谁点餐(用户应用就去读取数据)

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

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

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

image

IO多路复用的常见实现方式

IO多路复用是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。不过监听FD的方式、通知的方式又有多种实现,常见的有:

  • select
  • poll
  • epoll

差异:

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

image
存在的弊端:

  • 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
  • select无法得知具体是哪个fd就绪,需要遍历整个fd_set
  • fd_set监听的fd数量不能超过1024(程序写死的
Poll模式对select模式做了简单改进,但性能提升不明显

image

epoll模式

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

struct eventpoll {
    //...
    struct rb_root  rbr; // 一颗红黑树,记录要监听的FD
    struct list_head rdlist;// 一个链表,记录就绪的FD
    //...
};
// 1.创建一个epoll实例,内部是event poll,返回对应的句柄epfd
int epoll_create(int size);

// 2.将一个FD添加到epoll的红黑树中,并设置ep_poll_callback
// callback触发时,就把对应的FD加入到rdlist这个就绪列表中
int epoll_ctl(
    int epfd,  // epoll实例的句柄
    int op,    // 要执行的操作,包括:ADD、MOD、DEL
    int fd,    // 要监听的FD
    struct epoll_event *event // 要监听的事件类型:读、写、异常等
);

// 3.检查rdlist列表是否为空,不为空则返回就绪的FD的数量
int epoll_wait(
    int epfd,                   // epoll实例的句柄
    struct epoll_event *events, // 空event数组,用于接收就绪的FD
    int maxevents,              // events数组的最大长度
    int timeout   // 超时时间,-1用不超时;0不阻塞;大于0为阻塞时间
);

image

epoll模式的事件通知机制(水平触发[redis使用的是这种]、边沿触发)

当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。但是事件通知的模式有两种:

  • LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。
  • EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。

举个栗子:

  • 1、假设一个客户端socket对应的FD已经注册到了epoll实例中
  • 2、客户端socket发送了2kb的数据
  • 3、服务端调用epoll_wait,得到通知说FD就绪
  • 4、服务端从FD读取了1kb数据
  • 5、回到步骤3(再次调用epoll_wait,形成循环)

结果:

  • 如果我们采用LT模式,因为FD中仍有1kb数据,则第⑤步依然会返回结果,并且得到通知
  • 如果我们采用ET模式,因为第③步已经消费了FD可读事件,第⑤步FD状态没有变化,因此epoll_wait不会返回,数据无法读取,客户端响应超时。
基于epoll模式的web服务基本流程

image

IO多路复用在linux的三种实现方式总结

select模式存在的三个问题:

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

poll模式的问题:

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

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

  • 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高
  • 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
  • 利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降

信号驱动IO(Signal Driven IO)

image

异步IO(Asynchronous IO)

image

IO的同步与异步

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

Redis网络模型

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

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

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

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

因此,对于Redis的核心网络模型,在Redis 6.0之前确实都是单线程。是利用epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端情况。

为什么Redis要选择单线程?

  • 抛开持久化不谈,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。

  • 多线程会导致过多的上下文切换,带来不必要的开销

  • 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣

Redis的网络模型通过了IO多路复用提高网络性能

image

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

1、初始化服务,本质就是一个ServerSocket

2、在初始化服务当中创建epoll,并监听redis对应的端口,再得到当前服务的FD
3、将serversocket的FD注册到epoll上,并完成对于tcp连接时连接应答处理器
4、该处理回调函数当中会将当前client连接FD注册到epoll上,并绑定读处理器回调函数readQueryFormClient
5、开始监听事件循环
6、调用前置处理器beforesleep,该处理器中会绑定响应处理器回调函数
7、调用aeApiPoll,相当于epoll_awit,遍历处理就绪的FD,调用对应的处理器

image

beforeSleep的源码,命令回复处理器sendReplyToClient就是在这添加的

void beforeSleep(struct aeEventLoop *eventLoop){
    // ...
    // 定义迭代器,指向server.clients_pending_write->head;
    listIter li;
    li->next = server.clients_pending_write->head;
    li->direction = AL_START_HEAD;
    // 循环遍历待写出的client
    while ((ln = listNext(&li))) {
        // 内部调用aeApiAddEvent(fd,WRITEABLE),监听socket的FD读事件
        // 并且绑定 写处理器 sendReplyToClient,可以把响应写到客户端socket
        connSetWriteHandlerWithBarrier(c->conn, sendReplyToClient, ae_barrier)
    }
}

命令请求处理器rearQueryFromClient

void readQueryFromClient(connection *conn) {
    // 获取当前客户端,客户端中有缓冲区用来读和写
    client *c = connGetPrivateData(conn);
    // 获取c->querybuf缓冲区大小
    long int qblen = sdslen(c->querybuf);
    // 读取请求数据到 c->querybuf 缓冲区
    connRead(c->conn, c->querybuf+qblen, readlen);
    // ... 
    // 解析缓冲区字符串,转为Redis命令参数存入 c->argv 数组
    processInputBuffer(c);
    // ...
    // 处理 c->argv 中的命令
    processCommand(c);
}
int processCommand(client *c) {
    // ...
    // 根据命令名称,寻找命令对应的command,例如 setCommand
    c->cmd = c->lastcmd = lookupCommand(c->argv[0]->ptr);
    // ...
    // 执行command,得到响应结果,例如ping命令,对应pingCommand
    c->cmd->proc(c);
    // 把执行结果写出,例如ping命令,就返回"pong"给client,
    // shared.pong是 字符串"pong"的SDS对象
    addReply(c,shared.pong); 
}
void addReply(client *c, robj *obj) {
    // 尝试把结果写到 c-buf 客户端写缓存区
    if (_addReplyToBuffer(c,obj->ptr,sdslen(obj->ptr)) != C_OK)
            // 如果c->buf写不下,则写到 c->reply,这是一个链表,容量无上限
            _addReplyProtoToList(c,obj->ptr,sdslen(obj->ptr));
    // 将客户端添加到server.clients_pending_write这个队列,等待被写出
    listAddNodeHead(server.clients_pending_write,c);
}

image

通信协议

RESP(Redis Serialization Protocol)通信协议

Redis是一个CS架构的软件,通信一般分两步(不包括pipeline和PubSub):
客户端(client)向服务端(server)发送一条命令

  • 服务端解析并执行命令,返回响应结果给客户端
  • 因此客户端发送命令的格式、服务端响应结果的格式必须有一个规范,这个规范就是通信协议。

而在Redis中采用的是RESP(Redis Serialization Protocol)协议:
Redis 1.2版本引入了RESP协议
Redis 2.0版本中成为与Redis服务端通信的标准,称为RESP2
Redis 6.0版本中,从RESP2升级到了RESP3协议,增加了更多数据类型并且支持6.0的新特性--客户端缓存

但目前,默认使用的依然是RESP2协议,也是我们要学习的协议版本(以下简称RESP)。

RESP协议-数据类型

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

  • 单行字符串:首字节是 ‘+’ ,后面跟上单行字符串,以CRLF( "\r\n" )结尾。例如返回"OK": "+OK\r\n"

  • 错误(Errors):首字节是 ‘-’ ,与单行字符串格式一样,只是字符串是异常信息,例如:"-Error message\r\n"

  • 数值:首字节是 ‘:’ ,后面跟上数字格式的字符串,以CRLF结尾。例如:":10\r\n"

  • 多行字符串:首字节是 ‘$’ ,表示二进制安全的字符串,最大支持512MB:

    • 如果大小为0,则代表空字符串:"$0\r\n\r\n"
    • 如果大小为-1,则代表不存在:"$-1\r\n"
      image
  • 数组:首字节是 ‘*’,后面跟上数组元素个数,再跟上元素,元素数据类型不限:
    image

模拟redis客户端-建立连接并发送命令,解析命令

import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

public class RedisDemo {
    static BufferedReader in;
    static PrintWriter out;

    public static void main(String[] args) throws Exception {
        // 1.定义连接参数
        String host = "81.68.120.66";
        int port = 6379;

        // 2.连接 Redis
        Socket socket = new Socket(host, port);
        // 2.1获取输入流
        in = new BufferedReader(new InputStreamReader(new BufferedInputStream(socket.getInputStream())));
        // 2.1获取输出流
        out = new PrintWriter(socket.getOutputStream());

        // 3、发送请求
        sendRequest("zadd","z1","100", "李四", "20","张三");
        // 4、处理请求
        System.out.println(handleResponse());

        // 3、发送请求
        sendRequest("zrevrange","z1", "0","2");
        // 4、处理请求
        System.out.println(handleResponse());

        // 5.关闭连接
        if (in != null) in.close();
        if (out != null) out.close();
        if (socket != null) socket.close();
    }

    private static Object handleResponse() throws IOException {
        // 获取当前前缀
        char prefix = (char) in.read();
        switch (prefix) {
            case '+': // 单行文本,直接返回
                return in.readLine();

            case '-':  // 异常,直接抛出异常
                throw new RuntimeException(in.readLine());

            case ':': // 数值,转为Long返回
                return Long.valueOf(in.readLine());

            case '$':  // 多行文本
                // 由于已经把$读取走了,所以现在读取的就是长度了
                int length = Integer.parseInt(in.readLine());
                if (length == 0 || length <= -1) {
                    return "";
                }
                return in.readLine();
            case '*':
                // 处理数组
                return readBulkString();
            default:
                return null;
        }
    }

    /**
     * 处理数组类型
     * @return
     * @throws IOException
     */
    private static List<Object> readBulkString() throws IOException {
        // 获取当前数组大小
        int size = Integer.parseInt(in.readLine());
        // 数组为空,直接返回 null     
        if (size == 0 || size == -1) {
            return null;
        }

        List<Object> rs = new ArrayList<>(size);
        // 注意这里的循环次数就行,为什么不是i=0,i小于无关紧要的
        for (int i = size; i > 0; i--) {
            try { // 递归读取             
                rs.add(handleResponse());
            } catch (Exception e) {
                rs.add(e);
            }
        }
        return rs;
    }

    /**
     * 发送命令
     *
     * @param args
     * @throws IOException
     */
    private static void sendRequest(String... args) throws IOException {
        out.println("*" + args.length);
        for (String arg : args) {
            // 字节长度
            out.println("$" + arg.getBytes(StandardCharsets.UTF_8).length);
            out.println(arg);
        }
        // 刷新
        out.flush();
    }
}

内存策略

Redis内存回收

Redis之所以性能强,最主要的原因就是基于内存存储。然而单节点的Redis其内存大小不宜过大,会影响持久化或主从同步性能。
我们可以通过修改配置文件来设置Redis的最大内存:

# 格式:
# maxmemory <bytes>
# 例如:
maxmemory 1gb
![image](https://img2022.cnblogs.com/blog/2344320/202211/2344320-20221109184156494-937593422.png)

当内存使用达到上限时,就无法存储更多数据了。为了解决这个问题,Redis提供了一些策略实现内存回收:

  • 内存过期策略
  • 内存淘汰策略

内存过期策略

在学习Redis缓存的时候我们说过,可以通过expire命令给Redis的key设置TTL(存活时间):
image
可以发现,当key的TTL到期以后,再次访问name返回的是nil,说明这个key已经不存在了,对应的内存也得到释放。从而起到内存回收的目的。

需要思考的2个问题

这里有两个问题需要我们思考:

  • Redis是如何知道一个key是否过期呢?
    利用两个Dict分别记录key-value和key-ttl键值对
  • 是不是TTL到期就立即删除了呢?
    分为惰性删除、周期删除

过期策略-DB结构

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

typedef struct redisDb {
    dict *dict;                 /* 存放所有key及value的地方,也被称为keyspace*/
    dict *expires;              /* 存放每一个key及其对应的TTL存活时间,只包含设置了TTL的key*/
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID,0~15 */
    long long avg_ttl;          /* 记录平均TTL时长 */
    unsigned long expires_cursor; /* expire检查时在dict中抽样的索引位置. */
    list *defrag_later;         /* 等待碎片整理的key列表. */
} redisDb;

image

过期策略-惰性删除

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

// 查找一个key执行写操作
robj *lookupKeyWriteWithFlags(redisDb *db, robj *key, int flags) {
    // 检查key是否过期
    expireIfNeeded(db,key);
    return lookupKey(db,key,flags);
}
// 查找一个key执行读操作
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
    robj *val;
    // 检查key是否过期    if (expireIfNeeded(db,key) == 1) {
        // ...略
    }
    return NULL;
}

----------

int expireIfNeeded(redisDb *db, robj *key) {
    // 判断是否过期,如果未过期直接结束并返回0
    if (!keyIsExpired(db,key)) return 0;
    // ... 略
    // 删除过期key
    deleteExpiredKeyAndPropagate(db,key);
    return 1;
}

过期策略-周期删除

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

  • Redis服务初始化函数initServer()中设置定时任务,按照server.hz的频率来执行过期key清理,模式为SLOW

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

image

SLOW模式规则:

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

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

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

过期删除、周期删除小总结

RedisKey的TTL记录方式:

  • 在RedisDB中通过一个Dict记录每个Key的TTL时间

过期key的删除策略:

  • 惰性清理:每次查找key时判断是否过期,如果过期则删除
  • 定期清理:定期抽样部分key,判断是否过期,如果过期则删除。

定期清理的两种模式:

  • SLOW模式执行频率默认为10,每次不超过25ms
  • FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms

淘汰策略

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

int processCommand(client *c) {
    // 如果服务器设置了server.maxmemory属性,并且并未有执行lua脚本
    if (server.maxmemory && !server.lua_timedout) {
        // 尝试进行内存淘汰performEvictions
        int out_of_memory = (performEvictions() == EVICT_FAIL);
        // ...
        if (out_of_memory && reject_cmd_on_oom) {
            rejectCommand(c, shared.oomerr);
            return C_OK;
        }
        // ....
    }
}

8种不同类型的淘汰策略

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

  • 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的数据都会被封装为RedisObject结构:

typedef struct redisObject {
    unsigned type:4;        // 对象类型
    unsigned encoding:4;    // 编码方式
    unsigned lru:LRU_BITS;  // LRU:以秒为单位记录最近一次访问时间,长度24bit
			  // LFU:高16位以分钟为单位记录最近一次访问时间,低8位记录逻辑访问次数
    int refcount;           // 引用计数,计数为0则可以回收
    void *ptr;              // 数据指针,指向真实数据
} robj;

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

  • 生成0~1之间的随机数R
  • 计算 (旧次数 * lfu_log_factor + 1),记录为P
  • 如果 R < P ,则计数器 + 1,且最大不超过255
  • 访问次数会随时间衰减,距离上一次访问时间每隔 lfu_decay_time 分钟,计数器 -1
    image
posted @ 2022-11-01 20:14  CodeStars  阅读(24)  评论(0)    收藏  举报