Redis学习笔记

目录

SQL VS NoSQL

Redis基础

简介

下载与安装

服务启动与停止

redis服务端

      启动redis服务端

      停止redis服务端服务

Redis客户端

      通过客户端连接redis服务

      连接本地redis服务器

      退出当前redis客户端

      连接外部redis服务器(另一台机子的或者另一个端口的)

      设置redis服务器密码

Redis客户端图形界面

Redis中常用数据类型以及特点

五种常用数据类型一览

五种数据类型的特点

Redis常用命令

字符串操作命令

哈希操作命令

列表操作命令

集合操作命令

有序集合操作命令

通用命令

在java中操作redis

Redis的java客户端

Spring Data Redis

在项目中引入

导入依赖的maven坐标(pom.xml)

配置redis数据源(application.yml & application-dev.yml)

编写配置类,创建RedisTemplate对象

通过RedisTempate对象操作redis

使用Spring Data Redis操作redis数据

操作String类型数据

操作hash类型数据

操作List类型数据

操作Set类型数据

操作zset类型数据

通用命令操作

Redis实战

短信登录

基于session的短信登录

流程解析

发送短信验证码

产品原型 & 接口分析

代码实现

短信验证码登录校验

Controller

Service

登录验证功能——基于MVC拦截器实现

拦截器实现类

拦截器配置类

拦截后基于本地线程获取用户信息,响应回前端

集群的session共享问题分析

基于redis实现共享session登录

流程图

Redis存储用户信息的value数据类型方案

代码修改

短信验证码发送的修改

短信登录验证的修改

登录验证功能修改

对刷新token有效期的优化

问题分析

解决方案

代码实现

用于获取redis中用户信息并且刷新token的拦截器

登录拦截器

拦截器配置注册

商户查询缓存

缓存介绍

通过Redis缓存查询商户信息

在业务层添加根据id查询商户方法

缓存更新策略

策略一览

主动更新策略

总结

通过主动更新策略实现商户查询中数据库与缓存的一致

为查询操作设置自动删除时间

为更新操作设置对缓存的删除

缓存穿透

缓存穿透与解决方案介绍

具体实现(缓存空对象)

思路

代码修改(对查询的修改)

总结

缓存雪崩

缓存雪崩与解决方案介绍

缓存击穿

缓存击穿与解决方案介绍

解决方案详解与对比

解决方案实践

基于互斥锁解决缓存击穿

基于逻辑过期解决缓存击穿

缓存工具封装

工具类构造

方法一的封装

方法二的封装

方法三的封装

前言

封装

调用该方法的示例

方法四的封装

方法四所用到的对象以及方法(获取互斥锁、线程池)

封装

调用示例

优惠券秒杀

全局唯一ID

介绍与分析

问题分析

特性

安全性的实现

总结

使用redis实现全局唯一ID

思路分析

代码实现

代码解析

优惠券秒杀下单

秒杀券类型以及对应的数据库表

新增秒杀券接口(已提供)

实现秒杀券下单功能

业务信息

代码实现

超卖问题

问题分析

解决方案

乐观锁判断数据修改的方案

版本号法

CAS法

乐观锁解决超卖问题(CAS法)

简单的乐观锁方案

问题分析

代码修改

一人一单

需求分析

代码实现(简陋版)

问题分析

解决方案

通过悲观锁解决一人一单问题

前言

修改1

修改2

修改3

最终修改

一人一单的并发安全问题

案例演示

原因分析

分布式锁

基本原理

分布式锁需要满足的特征

常见实现方式一览

基于redis的分布式锁

Redis设置锁与释放锁操作以及对应业务流程

代码实现(初级版本)

redis锁误删问题

Redis原子性问题

基于redis的分布式锁实现总结

Redis分布式锁优化

基于setnx分布式锁存在的问题

Redisson实现分布式锁优化

总结

Redis优化秒杀

问题分析

问题解决

Redis数据结构选择

代码实现

业务流程

需求分析

代码

总结

Redis消息队列实现异步秒杀

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

消息队列介绍

Redis实现消息队列

基于list结构模拟消息队列

基于pubsub实现消息队列

基于stream实现消息队列

Redis消息队列总结

达人探店

发布探店笔记——文件上传

点赞功能——一人一赞

需求

代码

Controller

Service

点赞排行榜

需求

redis数据结构选取

对之前点赞业务更新

查询榜单的代码实现

Controller

Service

好友关注

关注和取关

需求

尝试关注用户

Controller

Service

Mapper(删除操作)

是否关注用户

Controller

Service

共同关注

需求

对关注业务改造,使关注信息同时存入redis

共同关注实现

Controller

Service

关注推送

Feed流

简介

常见模式

Timeline实现方案

Feed流推送的分页查询问题(Redis数据结构选取)

传统分页模式问题分析

游标模式实现分页

数据结构选取

使用推模式实现关注推送

笔记推送到收件箱(保存至redis)

基于SortedSet(ZSet)实现滚动分页查询

上述代码的bug分析

附近商铺

GEO数据结构&对应命令

附近商家搜索

将商户信息存入redis

思路

实现

测试结果

实现附近商户功能

首先需要对SpringDataRedis的版本进行更新

附近商户查询代码实现

用户签到

BitMap用法

签到功能

需求

实现思路

代码实现

签到统计——统计连续签到天数

实现思路分析

接口需求

代码实现

代码解析

解析1——通过查询redis获取签到情况

解析2——遍历获取签到情况

且重复插入时并不会添加新的元素

UV统计

HyperLogLog用法

实现UV统计

测试前redis的内存占用情况

单元测试以及统计结果

测试后redis的内存占用情况

且重复插入时并不会添加新的元素

Redis高级

分布式缓存

单点redis存在的问题

Redis持久化

RDB持久化

RDB介绍

RDB的fork原理

RDB的总结

AOF持久化

AOF介绍

RDB与AOF对比

Redis主从

搭建主从架构

主从架构基本模式

搭建主从架构(在一台虚拟机内搭建)

主从数据同步原理

全量同步原理

增量同步原理

优化主从模式

数据同步总结

Redis哨兵

哨兵作用&原理

哨兵作用

哨兵原理

哨兵搭建

使用RedisTemplate连接哨兵

Redis分片集群

搭建分片集群

散列插槽

介绍

总结

集群伸缩

命令示例

案例演示

故障转移

自动转移——节点故障宕机时

手动转移——节点维护升级时

手动转移案例

RedisTemplate访问分片集群

多级缓存

多级缓存前瞻

传统缓存存在的问题

多级缓存解决方案预览

JVM进程缓存

导入商品查询案例(需要用到进程缓存)

Caffeine入门

缓存分类

Caffeine介绍

Caffeine示例

实现进程缓存

需求

Cache配置注册

通过CaffenieCache查询

进程缓存的另一个思路(瑕疵)

Lua语法入门

说明

初识lua

重要概念

简介

快速编写一个lua程序

变量和循环

Lua的数据类型

变量

循环

条件控制、函数

函数

条件控制

一个案例

需求

代码

多级缓存

安装OpenResty

OpenResty快速入门

介绍

案例需求

需求实现

通过OpenResty获取并处理请求参数

获取请求参数

案例需求

需求实现

查询Tomcat

目的分析

案例需求

需求实现

根据商品id对tomcat集群进行负载均衡处理

场景分析

负载均衡实现(对OpenResty的nginx.conf配置进行修改)

负载均衡结果测试

Redis缓存预热

冷启动VS缓存预热

在docker容器上新安装一个redis

在完成redis相关配置情况下进行缓存预热

启动项目查看测试结果

查询Redis缓存

OpenResty的Redis模块

对common.lua文件进行修改,封装查询redis函数,并将该函数作为该类的属性返回

修改item.lua文件,让查询时优先查询redis缓存,未命中再查询tomcat服务器

修改完所有配置后,切记对nginx进行重启

结果测试

Nginx本地缓存

OpenResty提供的实现本地缓存的方法

查询顺序变更案例

缓存同步策略

数据同步策略

Canal简介

安装Canal

监听Canal

Canal的java客户端

案例实践

Redis优化

Redis键值设计

优雅的key结构

拒绝BigKey

什么是BigKey

BigKey的危害

如何发现BigKey

如何删除BigKey

恰当的数据类型

例1:user对象存储数据结构选取

例2:100w键值对的hash数据结构的问题分析与优化

总结

批处理优化

大量数据处理的分析与对比

每次执行一条命令

每次执行一批命令

Redis提供的批处理方案

MSET

Pipeline

二者对比

集群模式下的批处理

服务端优化

持久化配置

持久化建议

Redis部署建议——减轻持久化压力

慢查询

慢查询介绍

慢查询相关参数

查看慢查询日志

命令及安全配置

安全问题分析

安全建议

内存配置

Redis内存占用分类

查看redis的内存分配情况

内存缓冲区分类以及默认配置

集群最佳实践

集群模式存在的问题

集群完整性问题

集群带宽问题

数据倾斜问题

客户端性能问题

命令的集群兼容性问题

Lua和事务问题

主从or集群

Redis原理

Redis数据结构

Redis底层数据结构

动态字符串SDS

C语言字符串存在的问题 & Redis的解决方案

SDS介绍

SDS的动态扩容

总结

IntSet

IntSet介绍

IntSet寻址分析

IntSet升级

总结

Dict

Dict介绍

Dict键值对存放逻辑

Dict伸缩

Dict的rehash

总结

ZipList

ZipList介绍

ZipList中的Entry结构

Entry编码解析

ZipList连锁更新问题

总结

QuickList

ZipList的问题 & QuickList的产生

QuickList防止ZipList变成BigKey的方案

QuickList对ZipList的压缩

QuickList数据结构展示

总结

SkipList

SkipList介绍

SkipList数据结构构成

总结

RedisObject

RedisObject结构

RedisObject的encoding

encoding总览

Redis五种数据结构对应的encoding

五种数据结构

String

String常见的编码格式总览

不同编码格式中的内存结构(RedisObject与SDS间的关系)

List

List底层数据结构选取分析

不同版本的List底层实现

使用QuickList实现的List内存结构

Set

Set底层数据结构选取分析

Set底层实现

Set的内存结构图

ZSet

ZSet底层数据结构选取分析

ZSet的内存结构图

ZSet底层实现

Hash

Hash底层数据结构选取分析

Hash的内存结构图

Hash底层实现

Redis网络模型

用户空间 & 内核空间

五种IO模型

阻塞IO

非阻塞IO

IO多路复用

介绍

实现思路

监听FD的方法

epoll机制深入

信号驱动IO

异步IO

总结

Redis网络模型

Redis是单线程 or 多线程?

Redis坚持在核心业务使用单线程的原因

Redis单线程实现的源码分析

Redis的IO多路复用API库

Redis单线程IO多路复用主要源码分析(linux系统下)

Redis单线程网络模型总结

Redis多线程网络模型

Redis通信协议

RESP协议

RESP概述

RESP通信协议的数据类型

模拟Redis客户端——基于Socket的自定义redis客户端

总体流程 & DEMO构建

具体细节说明

向redis发出请求的函数详解(数据写死version)

解析redis响应数据的函数详解

解析数组类型响应数据的函数

测试结果查看

对于请求函数的修改

Redis内存策略

过期Key处理——expire命令底层源码分析

Redis如何识别key是否过期

RedisDB底层数据结构

RedisDB内存结构图

过期key删除策略

惰性删除

周期删除

总结

内存淘汰策略

Redis内存淘汰总览

Redis的八种内存淘汰策略

RedisObject底层数据结构

实现LFU的策略解析

淘汰策略流程图

核心流程分析


SQL VS NoSQL

Redis基础

简介

下载与安装

服务启动与停止

redis服务端
      启动redis服务端

              在redis的解压目录输入cmd:

                    

              输入命令“redis-server.exe redis.windows.conf”并回车(注意空格!):

               

      停止redis服务端服务

              在当前界面使用ctrl+c结束当前进程即可:

                   

--

Redis客户端
      通过客户端连接redis服务

       仍然在解压缩目录输入cmd:

             

      连接本地redis服务器

       输入命令“redis-cli.exe”:

             

              可以看到,占用端口6379即redis默认占用的端口。

       我们可以输入”keys *”来确认是否连接成功:

             

              输出信息表示当前数据库内容为空,说明连接到了本地redis服务器。

      退出当前redis客户端

              输入”exit”回到根目录:

                    

      连接外部redis服务器(另一台机子的或者另一个端口的)

              在cmd界面输入”redis-cli.exe -h #{主机名} -p #{端口号}”:

                    

      设置redis服务器密码

              在redis.windows.conf文件中使用cltr+f搜索“pass ”(注意空格!):

                    

              把注释删了,然后指定密码即可:

                    

              我们可以重新启动服务端&客户端,在客户端同样输入”keys *”:

                    

                     提示了服务未认证。

              我们可以”redis-cli.exe -a #{配置文件中的密码}”来提供服务认证:

                    

       连接外部服务器同理,输入” redis-cli.exe -h #{主机名} -p #{端口号} -a #{密码}”即可。

--

Redis客户端图形界面

设置对应属性并建立连接:      

连接后界面:

Redis中常用数据类型以及特点

五种常用数据类型一览

五种数据类型的特点

Redis常用命令

对于mysql数据库,我们只需要执行sql语句就可以对任意类型对象进行操作(前提是有实体表作为对应)。但是在redis数据库中,由于其区分了数据类型,因此每种数据类型对应的操作命令都是不相同的。

字符串操作命令

哈希操作命令

列表操作命令

该列表存储结构为队列,即FIFO。我们可以查看以下示例:

       我们插入的数据顺序如下:

             

       这是插入后的names:

             

       我们删除最后一个元素:

             

       可以看到,被删除的元素是最早被插入的。而我们根据names内部结构也可以看出,这个队列的插入采用的是插入头部的方式,因此新元素在队头。

集合操作命令

有序集合操作命令

通用命令

在java中操作redis

Redis的java客户端

Spring Data Redis
在项目中引入
导入依赖的maven坐标(pom.xml)

配置redis数据源(application.yml & application-dev.yml)

我们仍建议在开发环境的配置文件中指定具体值,而在正式的配置文件中进行引用。这样便于我们后期项目上线后的改造。

Application-dev.yml

      

Application.yml

      

       关于database,redis服务在创建时自动为我们创建了16个数据库,分别为DB0 ~ DB15:

             

       且每一个数据库中的数据都是相互隔离,互不影响的。我们若想指定在项目中操作的数据库,直接将数据库编号进行指定即可。当我们未指定时,默认使用0号数据库。

编写配置类,创建RedisTemplate对象

通过RedisTempate对象操作redis

使用Spring Data Redis操作redis数据
操作String类型数据

我们可以打开redis客户端看一眼数据情况:

    

Value中乱码是因为java操作redis数据库是对数据进行了序列化处理,而key未出现乱码是因为我们在配置类中为key设置了序列化器:

 

操作hash类型数据

操作List类型数据

操作Set类型数据

操作zset类型数据

通用命令操作

       由于这些操作对于任意类型均有效,因此我们不需要使用对应类型opration对象去操作。

Redis实战

项目导入

短信登录

基于session的短信登录
流程解析

发送短信验证码

产品原型 & 接口分析

代码实现

Controller

Service

       验证手机号实际上就是通过匹配手机号的正则表达式去实现的,这是提供的正则表达式工具类:

       正则表达式类:

      

       判断正则表达式合法的工具类:

      

短信验证码登录校验

Controller

Service

登录功能

创建新用户功能

注意事项

  1. 我们需要重新校验一遍手机号,因为发送验证码与登录实际上是两次不同的请求,若用户在验证码发送后将手机号进行了修改,这将会影响登录时对数据库的查询。
  2. 对session的操作,我们可以通过setAttribute去设置session中的参数,同时可以通过getAttribute去获取session中的参数。
  3. MybatisPlus对单表的增删改查进行了增强,我们通过继承ServiceImpl<Mapper类, 单表映射的enity>,来进行映射,这样就可以直接通过内置的诸如save(enity)、eq(字段)等方法,对单表进行简单操作。
  4. 登录功能无需返回登录凭证,因为session的原理是cookie,当我们访问tomcat服务器时,sessionId就已经写入cookie中。所以每一次请求携带cookie,我们都可以基于sessionId找到tomcat服务器中session,从而找到对应的用户(即我们通过业务流程写入session中的信息)。这点应与token作区分,token是登录成功后需要返回给浏览器,且浏览器请求时需要携带的登录凭证。
登录验证功能——基于MVC拦截器实现

拦截器实现类

       核心逻辑是从请求中获取session,然后通过判断session中的user对象是否存在来判断当前是否已经登录。

       在拦截器处理完请求之后(可以理解为当前页面关闭,不会再收到任意请求),我们需要将拦截时保存在ThreadLocal中的用户信息删除,避免内存泄漏。

       我们在把用户信息保存到本地线程时,使用的是DTO对象,这样可以有效避免使用User实体类泄漏过多信息。使用DTO后,前端获得的响应信息就将是DTO中的属性了。

拦截器配置类

拦截后基于本地线程获取用户信息,响应回前端

       此处获取的实际上是UserDTO对象,有效避免了用户敏感信息的泄漏。

集群的session共享问题分析

       我们知道,sessionId是单独存储与一台服务器上的,当我们设置了多台tomcat服务器进行负载均衡时,多次请求可能会发送到不同服务器上,这就导致了服务器无法通过cookie中的sessionId去进行登录校验。

       那么如何解决呢?首先需要解决多台tomcat服务器之间的数据共享问题;其次,session实际上是基于内存的,对读写效率要求高,需要内存存储满足高并发需求;最后,由于session的结果简单,是典型的“字段: 值”的结果,我们可以直接使用key——value结构进行存储。

       想要满足上述要求,结果显而易见——redis。

基于redis实现共享session登录
流程图

       之所以使用随机生成的token作为key存储用户数据,是为了避免使用手机存储key导致浏览器前端可以直接看到登录者手机号,造成用户信息泄漏。

Redis存储用户信息的value数据类型方案

代码修改
短信验证码发送的修改

流程一览

代码改动

/**
     * 发送验证码
     * @param phone
     * @param session
     */
    @Override
    public Result sendCode(String phone, HttpSession session) {
        //校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            //不符合,返回错误信息
            return Result.fail("手机号格式错误!");
        }

        //符合,生成验证码
        String code = RandomUtil.randomNumbers(6);

        /*//保存验证码到session中
        session.setAttribute("code", code);*/

        //保存验证码到redis中,key为手机号
        //设置有效期为2min
        redisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);

        //发送验证码
        log.info("发送验证码成功,验证码为:{}", code);

        //返回Result.ok()
        return Result.ok();
    }

代码解析

  1. 此前,我们直接将短信验证码存入session中,由session为我们维护验证码是否生效等功能。但此时我们需要存入redis,对于验证码什么时候失效都只能由我们自己维护,所以此处在把验证码存入redis时,我们为其指定了有效时间为2min,这样可以避免没有指定有效期导致redis被填满的问题。
  2. 对于验证码key的存入,我们采用分层结构,即:login/code/手机号,这样验证码相关数据都存放入一个文件夹下方,直观明了。
短信登录验证的修改

流程一览

代码修改

/**
     * 登录功能
     * @param loginForm
     * @param session
     * @return
     */
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        //校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            //不符合,返回错误信息
            return Result.fail("手机号格式错误!");
        }

        //校验验证码是否合理
        String code = loginForm.getCode();  //表单中的验证码
        if (RegexUtils.isCodeInvalid(code)) {
            //不符合,返回错误信息
            return Result.fail("验证码格式错误!");
        }

        //从redis中获取验证码
        String cacheCode = redisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);

        //判断验证码与redis中是否一致
        //不一致,返回错误信息
        if (cacheCode == null || !cacheCode.toString().equals(code)){
            return Result.fail("验证码错误!");
        }
        //一致,查询对应用户
        User user = query().eq("phone", phone).one();

        //判断用户是否为新用户
        //是,创建用户数据到数据库
        if (user == null){
            user = createUserWithPhone(phone);
        }

        //将用户信息保存到redis中
        //随机生成token,作为key
        String token = UUID.randomUUID().toString(true);
        String key = RedisConstants.LOGIN_USER_KEY + token;
        //将user对象转成hash存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create().setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
        //存入redis
        redisTemplate.opsForHash().putAll(key, userMap);
        //设置有效期
        redisTemplate.expire(key, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);

        //返回结果
        return Result.ok(token);

        /*        //判断验证码与session中的一致,不一致,返回错误信息;一致,根据手机号查询用户
        Object cacheCode = session.getAttribute("code");    //session中的验证码
        //不一致,返回错误信息
        if (cacheCode == null || !cacheCode.toString().equals(code)){
            return Result.fail("验证码错误!");
        }
        //一致,根据手机号查询用户
        User user = query().eq("phone", phone).one();
        //判断是否为新用户,若是,则创建新用户,保存到数据库,然后将数据保存到session
        if (user == null){
            user = createUserWithPhone(phone);
        }
        //将用户信息保存到session
        session.setAttribute("user", user);*/
    }

代码解析

  1. 从redis中获取验证码的难度实际上并不高,主要的逻辑在于如何将用户信息保存到redis服务器中。我们在优化分析的时候已经讨论过,用户信息可以采用string(实际上是json的序列化)和hash两种数据结构进行存储,各有优劣,此处将采用hash数据结构进行存储。
  2. 对于存入redis中用户数据的key,采用分层形式存储,即:login/user/UUID。此处请勿混淆token与key,key是作为存入redis内存中的字段,而token是将要返回给前端的信息,我们只是使用token去构造key,但二者实际上不是一个东西!
  3. 由于我们想要避免用户敏感数据的泄漏,所以需要使用UserDTO对象进行数据的存储,同样的,存入redis中的hash数据也是基于userDTO生成。我们可以使用BeanUtil这个包进行简单的属性拷贝构造。
  4. 我们需要为这个用户信息设置有效期,避免长时间不操作但多个用户存入导致内存爆满。我们将有效期设置为30min。
  5. 需要注意,此处的有效期是指从存入开始的30min,但我们需要的有效期功能应当是用户满30min未操作才将用户信息进行删除。那么什么叫做用户进行操作呢?也就是用户的每一次点击。用户每一次点击实际上都是一次请求,而这些请求都需要经过拦截器进行用户是否登录的校验,那么我们就可以在拦截器中,为redis中的用户信息刷新有效时间。
  6. 最后,由于不再通过session进行登录校验,所以浏览器每一次请求都需要携带令牌,即我们设置的token,因此我们在短信登陆验证成功后,需要将token返回给前端,让前端在以后的每次请求都进行携带,由拦截器进行解析。
  7. 需注意,使用stringRedisTemplate时要保证key&value均为string类型,若直接使用BeanUtil.beanToMap(userDTO),由于userDTO中的id字段是Long类型,将会出现类型转换异常。该方法实际上为我们提供了自定义设置,即:CopyOptions.create(),我们可以通过setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()),将id的值转换为字符串类型。详细代码为:

Map<String, Object> userMap = BeanUtil.beanToMap                                (userDTO, new HashMap<>(),    CopyOptions.create().setFieldValueEditor((fieldname, fieldValue) -> fieldValue.toString())     );。

登录验证功能修改

流程一览

代码修改

代码解析

  1. 由于我们存入redis中的是一个hash对象(也就是一个Map),且取出时我们不想要单独取出字段,而想要将全部内容取出,所以我们使用entries方法。注意,key是经过token构造后的常量,不要直接将token作为key去查询!
  2. 我们存入本地线程的应当为一个UserDTO对象,所以我们需要将Map转成UserDTO,可以通过BeanUtil.fillBeanWithMap来将Map的内容全部填充到目标实体类中(前提,字段能一一对应!)。
  3. 最后,刷新token有效期,其实就是重新指定一遍redis中这个key的有效期啦。
对刷新token有效期的优化
问题分析

       这是我们初始配置的拦截器,那么可以看到,若用户发起的请求是被放行的,那么就不会执行拦截器中的方法,那么token将不会刷新。

解决方案

     我们建立两个拦截器,第一个拦截器拦截所有路径,功能为:获取token、通过token查询redis中的用户、将查询到的用户保存到ThreadLocal内、刷新token,也就是说,第一个拦截器只是为了获取用户并且进行刷新,不做用户是否登录的校验。那么我们此时将用户信息保存到本地线程后,就可以在第二个拦截器中对需要登录才能访问的路径进行拦截,然后验证ThreadLocal中的用户是否存在,也就是在第二个拦截器中实现登录拦截功能。

代码实现
用于获取redis中用户信息并且刷新token的拦截器
/*
将用户存入本地线程以及刷新token的拦截器
 */
public class RefreshInterceptor implements HandlerInterceptor {

    private StringRedisTemplate redisTemplate;

    //前置,拦截请求
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //获取请求头中的token
        String token = request.getHeader("authorization");

        //构造访问redis的key
        String key = RedisConstants.LOGIN_USER_KEY + token;

        //判断token是否为空
        if (StrUtil.isBlank(token)){
            //直接放行
            return true;
        }

        //根据token获取redis中的用户(Map类型)
        Map userMap = redisTemplate.opsForHash().entries(key);

        //判断用户是否存在
        if (userMap.isEmpty()){
            response.setStatus(401);
            //直接放行
            return true;
        }

        //将用户数据从Map解析为UserDTO
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        //存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);

        //刷新token有效期
        redisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.DAYS);

        //放行
        return true;
    }

    //拦截请求完全处理后,避免内存泄露
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception{
        //移除用户
        UserHolder.removeUser();
    }

    public RefreshInterceptor(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
}

       这个拦截器实现了获取token,获取用户,保存用户到本地线程的功能。若token为空、用户不存在等内容出现,我们也直接放行,这样在登录拦截器中就无法获取到有效信息,自然就拦截下来这个请求了。

登录拦截器
/*
登录拦截器
 */
public class LoginInterceptor implements HandlerInterceptor {

    //前置,拦截请求
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //判断本地线程用户是否存在
        UserDTO user = UserHolder.getUser();
        //用户不存在,拦截
        if (user == null) {
            response.setStatus(401);
            return false;
        }
        //用户存在,放行
        return true;
    }
}

       我们仍将后置方法写入登录拦截器中,进行内存清空。由于刷新拦截器会将获取到的用户信息放到LocalThread中,因此我们在登录拦截器中无需通过redis进行用户信息查询。

拦截器配置注册
@Configuration
@Slf4j
public class WebMVCConfiguration extends WebMvcConfigurationSupport {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 注册拦截器配置
     * @param registry
     */
    protected void addInterceptors(InterceptorRegistry registry){
        log.info("开始注册拦截器...");

        //为刷新拦截器注册的配置
        registry.addInterceptor(new RefreshInterceptor(stringRedisTemplate))
                .addPathPatterns("/**").order(0);

        //为登录拦截器注册的配置
        registry.addInterceptor(new LoginInterceptor())
                //放行请求
                .excludePathPatterns(
                        "/user/login",
                        "/user/code",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/upload.**",
                        "/voucher/**")
                .order(1);
    }
}

       我们为刷新拦截器注册全部拦截的路径,为登录拦截器注册登录才能访问的请求路径。为了保证刷新拦截器先执行,获取token以及将用户保存至本地线程,所以我们将刷新拦截器的添加顺序设置在前面。实际上,使用@Order指定权重也是可以的。

商户查询缓存

缓存介绍

通过Redis缓存查询商户信息
在业务层添加根据id查询商户方法

       注意,我们采用字符串作为数据结构存入redis,所以需要将shop对象封装为json字符串,这样才能提升传输效率。由于我们采用json字符串进行封装,所以取出后,我们需要对json字符串转为shop对象再进行返回!

缓存更新策略
策略一览

主动更新策略

总结

通过主动更新策略实现商户查询中数据库与缓存的一致
为查询操作设置自动删除时间

为更新操作设置对缓存的删除

       由于这是一个单体项目,我们可以通过事务来保证数据库更新、缓存删除的原子性。

缓存穿透
缓存穿透与解决方案介绍

具体实现(缓存空对象)
思路

代码修改(对查询的修改)

       由于我们使用””去填入不存在的商品id对应的信息,因此判断redis中是否存在商品信息时会出现三种可能:获取到有内容的商品信息json字符串、获取到value = ””的json字符串、查询不到对应的key而返回值为null的空值。

       我们使用StrUtil.isNotBlank()可以筛选出存在内容的字符串(仅字符串非空才会true,如”abc”,但是转义字符串如”\n\t”或空字符串””以及空值null均返回false),所以若执行到该方法内部,则代表redis中成功查询到有效的商品信息,那么直接返回即可。

       若查询到的json字符串不包含值(不可能为转义字符串),那么此时就有两种可能性:结果为null或结果为””。其中,结果为null代表该信息在缓存中不存在,由于我们不知道此时数据库中是否存在,我们只能通过查询数据库来判断,所以放行;若结果为””,就表示我们此前已经查询了数据库且并未查到对应信息,也就意味着这个信息并不存在,我们当时将””作为空值存入redis,那么此时就返回错误信息,阻止对数据库的访问。

总结

缓存雪崩
缓存雪崩与解决方案介绍

缓存击穿
缓存击穿与解决方案介绍

解决方案详解与对比

       逻辑过期实际上也是互斥锁的应用,当获取互斥锁失败,那么其余线程就会先去查询并且返回缓存中的旧数据,直到进行缓存更新的线程完成更新并释放互斥锁,此后的线程才能返回新数据。但是由于逻辑过期需要额外开辟过期时间字段,这将会增加redis的维护,也增加了内存消耗。且该方案对旧数据查询不受限,因此数据一致性难以保证。

       总结而言,互斥锁通过使得各线程之间串行而增加了数据一致性,但由于只有单一线程能进行访问而其余线程均需等待,所以可用性下降;逻辑过期通过各线程间并行增加了可用性,但是对于缓存的更新只有一个线程维护,其余线程仍可能访问过期数据,所以数据一致性下降。

       这也就意味着,数据一致性与软件可用性之间是动态平衡的,需要我们做出抉择。

解决方案实践
基于互斥锁解决缓存击穿

业务流程

获取与释放锁思路

       我们可以通过redis的setnx命令模拟锁:

我们看到,当已经存在”lock”的key时,其余对”lock”的设置是无法成功的。那么我们就可以利用这个特性来模拟一个互斥锁。假设此时存在多个线程,那么A线程成功对key = ”lock”执行了setnx操作,那么其余线程就无法执行setnx(lock, value)的操作,那么我们就可以认为线程A取得了互斥锁,其余线程无法取得互斥锁。

综上,当一个线程获取到互斥锁,就代表它能够通过setnx创建一个当前相关的key。其他线程对同一key进行setnx操作,而由于这个key已经存在,其余线程无法对这个key的value进行设置,这就表示其他线程无法获取该互斥锁。

但应该注意,我们需要为锁设置有效期,避免锁未被释放,产生死锁!

代码实现

获取锁方法

       我们通过setnx来模拟锁是否存在,当此时查询店铺的线程为这个店铺锁设置了key,那么后续查询的线程尝试获取时将会失败,因为此时key已经存在,无法重复设置。

释放锁方法

查询商户的缓存击穿解决方法

       实际上与缓存击穿的解决方案类似,但是缓存击穿未考虑多线程情况,所以在缓存击穿的方法上,为每一个线程都添加了获取互斥锁的方法,确保同一时间只能有一个线程对当前商铺进行缓存写入操作。

注意事项

  1. 我们要尝试获取锁,所以此时的key应当是针对当前商户锁的key,而不是用于进行查询的key。也就是,当我们能够通过锁的key获取互斥锁对商户进行操作,才能通过查询key对redis数据写入。即:若tryLock返回true,表示获取锁成功,执行对数据库查询以及写入redis缓存操作;tryLock返回false,表示获取锁失败,休眠并重试,直到获取锁成功或查询对象成功。
  2. 未命中情况下,通过线程休眠来实现查询休眠,通过休眠后的递归实现重试获取互斥锁。休眠结束的条件为:>a.前面的线程查询数据库发现没有数据,将空对象写入redis,当前线程获取了空对象,则直接将null返回;>b.前面的线程将数据库信息写入redis,该线程成功获取到redis中数据,将从缓存中查询到的shop对象返回;>c.商户的key又被清除,但前面的线程将锁释放,该线程成功访问数据库,写入redis,返回shop对象。
  3. 由于释放互斥锁是无论当前线程是否遇上错误均需要进行的。若线程未遇上错误,则正常将数据库信息写入redis,然后释放;若线程遇上错误,则该线程更不能占用互斥锁,否则将造成死锁的情况,当前线程锁不被释放,其他线程无法获取。
基于逻辑过期解决缓存击穿

业务流程

代码实现

前提

       我们认为热点key的数据将由逻辑过期时间决定,因此该key相关的数据除非人为被删除,否则将会永久存在于redis数据库。我们将根据逻辑过期时间与当前时间的匹配去判断是否需要对缓存进行重建。

封装逻辑过期时间和数据的实体

       通过这个对象,我们可以将逻辑过期时间与数据封装为一个JSONStr存入redis中,在取出时,只需要反序列化然后分别取出即可。

用于redis重建的方法

用于重建redis的线程池

核心业务代码

注意事项

  1. 由于我们不想要每一次都创建并且销毁线程,所以我们创建了一个线程池,通过该线程池实现线程复用。这样每次重建redis时都可以直接使用线程池中已经存在的线程(前提是线程已经被创建)。
  2. 我们通过key从redis取出的数据实际上是RedisData的类型。由于我们假定这些热点数据将永久存在于redis,所以除非该商户被删除,否则若该商户存在,无论信息是否过期,都能够在redis中被查询出来。因此若查出的数据为空,那么该数据一定也不存在于数据库中,我们无需往下进行,返回空值给前端即可。
  3. 由于RedisData中的data是object类型,所以从redis取出后,该属性将是被序列化后的JSONObject,所以我们仍需要通过JSONUtil将该对象转换成我们所需要的Shop对象。
  4. 若数据过期,那么我们应当尝试获取锁。若当前调用线程获取互斥锁成功,则通过submit方法向线程池提交任务,让其中的线程去完成redis重建。我们需要强调是当前线程通过线程池内的线程进行重建,所以需要指定只有当前调用线程才能去调用线程池,所以我们使用this来指定当前线程。由于释放锁是必须操作,不应被错误打断,所以使用try-catch-finally来进行redis重建逻辑实现。
  5. 由于redis重建是获取到互斥锁的线程进行的任务,而与当前调用者无关,所以无论是否成功获取锁,当我们判断到数据过期后,都需要返回旧的数据,于是我们把return shop放到了函数的最后。
缓存工具封装

@Slf4j
@Component
/*
redis相关工具类
 */
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

    //构造方法
    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    //将key-value存入redis(直接向redis设置过期时间)
    public void set(String key, Object value, Long time, TimeUnit unit){
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    //将key-value存入redis(逻辑过期)
    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
        RedisData redisData = new RedisData();
        redisData.setExpireTime(LocalDateTime.now().plusMinutes(unit.toSeconds(time)));
        redisData.setData(value);
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }


    /**
     * 利用空值解决缓存穿透
     * @param keyPrefix    查询redis数据库key的前缀
     * @param id        查询数据库所用的id
     * @param type      返回对象的类型
     * @param dbFallBack    用户查询数据库的方法,ID为查询所用的字段,R为返回对象类型
     * @param time      存入redis的过期时间
     * @param unit      存入redis过期时间的类型
     * @return
     * @param <R>   返回对象类型
     * @param <ID>  当前数据的id
     */
    public <R, ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallBack, Long time, TimeUnit unit
    ){
        //构造redis中的key
        String key = keyPrefix + id;

        //通过查询缓存判断是否存在商品
        String json = stringRedisTemplate.opsForValue().get(key);

        //判断商品是否存在,不存在则查数据库并写入缓存
        //商品信息存在,反序列化为shop对象后返回
        if (StrUtil.isNotBlank(json)){  //只有当字符串有内容时才为真
            R r = JSONUtil.toBean(json, type);
            return r;
        }
        //商品信息缓存中不存在,查询数据库并写回redis,返回
        //判断缓存命中的是否为空值
        if (json != null){
            return null;
        }
        //查询数据库
        R r = dbFallBack.apply(id);
        if (r == null) {
            //将空值写入redis,设置有效期为短有效期
            stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, unit);
            return null;
        }
        this.set(key, r, time, unit); //将shop序列化为json存储
        return r;
    }



    //用于缓存重建的缓存池
    private static final ExecutorService CACHE_REBUILD_EXCUTOR = Executors.newFixedThreadPool(10);

    //通过setnx尝试获取锁
    //若对应锁的key不存在,则返回值为true,代表可以进行获取
    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);
    }

    /**
     * 缓存击穿逻辑过期解决方案
     * @param keyPrefix     key前缀
     * @param id        查询用的id
     * @param type      调用者需要返回对象的类型
     * @param dbFallBack        查询数据库的方法
     * @param time      逻辑过期时间
     * @param unit      逻辑过期指定的时间类型
     * @return
     * @param <R>       返回对象的参数类型
     * @param <ID>      id的参数类型
     */
    public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallBack, Long time, TimeUnit unit){
        //构造redis中的key
        String key = RedisConstants.CACHE_SHOP_KEY + id;

        //通过查询缓存判断是否存在商品
        String shopJSON = stringRedisTemplate.opsForValue().get(key);    //实际上是RedisData类型数据
        //判断商品是否存在,不存在则查数据库并写入缓存
        //商品信息不存在,直接返回空
        if (StrUtil.isBlank(shopJSON)){
            return null;
        }

        //商品信息存在,判断是否过期
        RedisData redisData = JSONUtil.toBean(shopJSON, RedisData.class);
        //获取逻辑过期的时间,然后与当前时间进行比较
        LocalDateTime expireTime = redisData.getExpireTime();
        JSONObject data = (JSONObject) redisData.getData();
        R r = JSONUtil.toBean(data, type);
        if (expireTime.isAfter(LocalDateTime.now())){  //未过期
            //取出shop并返回
            return r;
        }
        //过期,进行缓存重建
        //尝试获取互斥锁
        String lock = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lock);
        //获取成功,开辟新线程重建缓存
        if (isLock){
            CACHE_REBUILD_EXCUTOR.submit(()->{  //通过submit方法提交redis重建任务
                try {
                    //重建缓存
                    //查询数据库
                    R r1 = dbFallBack.apply(id);
                    //写入redis
                    setWithLogicalExpire(key, r1, time, unit);
                }catch (Exception e){
                    e.printStackTrace();
                }finally {
                    //释放锁
                    unLock(lock);
                }
            });
        }
        //获取失败or成功都返回旧数据
        return r;
    }


    /**
     *
     /**
     *
     * @param keyPrefix    key前缀
     * @param id    查询用的id
     * @param type  返回对象的类对象
     * @param dbFallBack    查询数据库的函数
     * @param time      正常数据的TTL
     * @param unit      正常数据的时间单位
     * @return
     * @param <R>       返回对象的类型
     * @param <ID>      id的类型
     */
    public <R, ID> R queryWithMutex(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallBack, Long time, TimeUnit unit){
        //构造redis中的key
        String key = keyPrefix + id;

        //通过查询缓存判断是否存在商品
        String shopJSON = stringRedisTemplate.opsForValue().get(key);

        //判断商品是否存在,不存在则查数据库并写入缓存
        //商品信息存在,反序列化为shop对象后返回
        if (StrUtil.isNotBlank(shopJSON)){  //只有当字符串有内容时才为真
            R r = JSONUtil.toBean(shopJSON, type);
            return r;
        }
        //商品信息缓存中不存在,查询数据库并写回redis,返回
        //判断缓存命中的是否为空值
        if (shopJSON != null){
            return null;
        }
        String lockKey = null;   //这是查询互斥锁的key
        R r = null;

        try {
            //未命中
            //尝试获取锁
            lockKey = RedisConstants.LOCK_SHOP_KEY + id;
            boolean isLock = tryLock(lockKey);
            //判断是否成功
            //失败,休眠并重试
            if (!isLock) {
                Thread.sleep(50);   //休眠
                queryWithMutex(keyPrefix, id, type, dbFallBack, time, unit);
            }
            //成功,查询数据库
            r = dbFallBack.apply(id);
            /*Thread.sleep(200);*/
            if (r == null) {
                //将空值写入redis,设置有效期为短有效期
                stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, unit);
                return null;
            }
            //存在数据,写入redis
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(r), time, unit); //将shop序列化为json存储
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            //释放互斥锁
            unLock(lockKey);
        }
        return r;
    }
}
工具类构造

方法一的封装

//将key-value存入redis(直接向redis设置过期时间)
    public void set(String key, Object value, Long time, TimeUnit unit){
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }
方法二的封装

//将key-value存入redis(逻辑过期)
    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
        RedisData redisData = new RedisData();
        redisData.setExpireTime(LocalDateTime.now().plusMinutes(unit.toSeconds(time)));
        redisData.setData(value);
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }
方法三的封装
前言
  1. 对于前两个方法而言,我们只需要将用户传入的java对象进行序列化并存入即可,所以对象形参可以简单使用Object来指定。
  2. 但是对于后两个方法,由于我们很难知道调用者需要的返回类型,毕竟这个东西将会及其复杂,所以我们需要通过编写泛型方法去设置该方法的返回值为泛型。且用户用于查询redis的id类型也难以确定,所以也需要使用泛型来进行处理。通常的,R用来指定泛型为返回值的类型(return),T用来指定类的类型(Type,通常用于泛型类上)。V用来指定value的类型等,这些都是为了便于维护。虽然使用Object也是可以的,但是若直接使用Object,在获取对象后势必需要进行类型转换,很可能造成数据丢失,因此我们才直接使用泛型方法,直接为参数以及返回值定义泛型。
  3. 用户对数据库的查询是基于mybatisplus实现的,但是该工具类并没有继承mybatisplus实现好的那些service,所以我们需要用户为我们指定所需要调用的查询方法,来获取对应类型的返回值。由于这个参数实际上是一个有参有返回值的方法,所以这个功能我们可以通过java的Function接口实现,其中ID代表参数数据类型,R代表返回值数据类型。我们通过Function接口的实现对象,就可以调用Function定义的apply方法,去调用用户为我们传递进来的方法,并且获取方法的返回值。用户在使用时,就相当于写了一个lambda表达式,在隐式函数中实现了对数据库的查询。
封装

/**
     * 利用空值解决缓存穿透
     * @param keyPrefix    查询redis数据库key的前缀
     * @param id        查询数据库所用的id
     * @param type      返回对象的类型
     * @param dbFallBack    用户查询数据库的方法,ID为查询所用的字段,R为返回对象类型
     * @param time      存入redis的过期时间
     * @param unit      存入redis过期时间的类型
     * @return
     * @param <R>   返回对象类型
     * @param <ID>  当前数据的id
     */
    public <R, ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallBack, Long time, TimeUnit unit
    ){
        //构造redis中的key
        String key = keyPrefix + id;

        //通过查询缓存判断是否存在商品
        String json = stringRedisTemplate.opsForValue().get(key);

        //判断商品是否存在,不存在则查数据库并写入缓存
        //商品信息存在,反序列化为shop对象后返回
        if (StrUtil.isNotBlank(json)){  //只有当字符串有内容时才为真
            R r = JSONUtil.toBean(json, type);
            return r;
        }
        //商品信息缓存中不存在,查询数据库并写回redis,返回
        //判断缓存命中的是否为空值
        if (json != null){
            return null;
        }
        //查询数据库
        R r = dbFallBack.apply(id);
        if (r == null) {
            //将空值写入redis,设置有效期为短有效期
            stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, unit);
            return null;
        }
        this.set(key, r, time, unit); //将shop序列化为json存储
        return r;
    }
调用该方法的示例

       标蓝部分是将这个方法传入的示例,实际上就是通过lambda表达式,设置查询方法所需要的参数(即id)为id1,然后设置查询方法为getById,这样queryWithPassThrough方法就能够知道查询数据库需要的方法。

方法四的封装
方法四所用到的对象以及方法(获取互斥锁、线程池)

封装

//用于缓存重建的缓存池
    private static final ExecutorService CACHE_REBUILD_EXCUTOR = Executors.newFixedThreadPool(10);

    //通过setnx尝试获取锁
    //若对应锁的key不存在,则返回值为true,代表可以进行获取
    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);
    }

    /**
     * 缓存击穿逻辑过期解决方案
     * @param keyPrefix     key前缀
     * @param id        查询用的id
     * @param type      调用者需要返回对象的类型
     * @param dbFallBack        查询数据库的方法
     * @param time      逻辑过期时间
     * @param unit      逻辑过期指定的时间类型
     * @return
     * @param <R>       返回对象的参数类型
     * @param <ID>      id的参数类型
     */
    public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallBack, Long time, TimeUnit unit){
        //构造redis中的key
        String key = RedisConstants.CACHE_SHOP_KEY + id;

        //通过查询缓存判断是否存在商品
        String shopJSON = stringRedisTemplate.opsForValue().get(key);    //实际上是RedisData类型数据
        //判断商品是否存在,不存在则查数据库并写入缓存
        //商品信息不存在,直接返回空
        if (StrUtil.isBlank(shopJSON)){
            return null;
        }

        //商品信息存在,判断是否过期
        RedisData redisData = JSONUtil.toBean(shopJSON, RedisData.class);
        //获取逻辑过期的时间,然后与当前时间进行比较
        LocalDateTime expireTime = redisData.getExpireTime();
        JSONObject data = (JSONObject) redisData.getData();
        R r = JSONUtil.toBean(data, type);
        if (expireTime.isAfter(LocalDateTime.now())){  //未过期
            //取出shop并返回
            return r;
        }
        //过期,进行缓存重建
        //尝试获取互斥锁
        String lock = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lock);
        //获取成功,开辟新线程重建缓存
        if (isLock){
            CACHE_REBUILD_EXCUTOR.submit(()->{  //通过submit方法提交redis重建任务
                try {
                    //重建缓存
                    //查询数据库
                    R r1 = dbFallBack.apply(id);
                    //写入redis
                    setWithLogicalExpire(key, r1, time, unit);
                }catch (Exception e){
                    e.printStackTrace();
                }finally {
                    //释放锁
                    unLock(lock);
                }
            });
        }
        //获取失败or成功都返回旧数据
        return r;
    }
调用示例

优惠券秒杀

全局唯一ID
介绍与分析
问题分析

       由于秒杀过程可能出现表中增加大量数据,由于单表数据量的限制,必然会对表进行拆分。若使用自增id,那么每张表id分别计算,将出现重复id,无法区分数据唯一性。

       此外,使用自增id,将会导致商户核心信息(每天的售卖单数)等内容暴露给用户,数据将可能是不安全的。

特性

       高性能、唯一性、高可用,那么redis将会是一个很好的解决手段!

安全性的实现

       当时间戳相同(同一时间)时,序列号将不同。这样就能够唯一区分这个id了。

总结

使用redis实现全局唯一ID

思路分析

       我们通过将上面二进制转为十进制作为全局唯一ID进行存储。时间戳可以通过LocalDateTime类进行获取,而redis的用处就是生成序列号。通过redis在java提供的StringRedisTemplate.increment()方法,我们可以生成一串序列号的key,这个key在生成同时也存入了redis,我们在后续从表中取数据进行统计时,也可以通过redis中存入的key进行数据统计。

代码实现
@Component
@Slf4j
public class RedisGlobalIdWorker {

    /*
    开始时的时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1744502400L;

    /*
    序列号位数
     */
    private static final int COUNT_BITS = 32;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 生成全局唯一id
     * @param keyPrefix
     * @return
     */
    public Long nextId(String keyPrefix){
        //生成时间戳(当前时间-初始时间 の秒数)
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

        //生成序列号
        //不能使用单一key进行自增长!最多为64位,序列号会存不下,不管是否为同一业务
        //以当天时间作为key的变量,进行redis自增长,格式如:2025:04:13,以天为单位,便于后续统计
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

        //拼接并返回(使用位运算)
        return timestamp << COUNT_BITS | count;
    }
}
代码解析
  1. 由于每个业务用到的key不同,所以我们需要不同的前缀进行区分。
  2. 生成序列号是这个全局id生成的核心逻辑。我们通过redis提供的API——increment来实现自增id获取与添加。我们在形参中指定了前缀,但是由于redis中最多支持64位存储,我们若只使用这个前缀所拼接的key进行自增id生成,将会导致序列号的32位无法支持那么多位的自增值,造成重复。所以我们将当天日期的字符串作为key的后缀拼接,这样不仅能解决序列号位数不够的问题,还能够便于后续从redis取出每天数据进行统计。
  3. 由于我们需要的全局自增id是时间戳—redis自增key的组合,且是Long类型数据,所以我们无法直接通过String类进行拼接(也不是不行,拼接后转换就可以)。于是我们在这里使用位运算的方式进行“拼接”。逻辑为:将时间戳转为二进制对象,然后左移COUNT_BITS位,这样后32位全为0,就可以让自增id进行填充。我们使用或运算(“|”),让自增id的每一位(二进制下)与32位的0进行或运算,结果将是自增id自身。最后我们将得到的二进制转换为Long类型,即可完成转换。
优惠券秒杀下单
秒杀券类型以及对应的数据库表

新增秒杀券接口(已提供)

实现秒杀券下单功能
业务信息

代码实现

代码逻辑

代码编写

       下单功能涉及到两张表的数据操作:对秒杀券表库存的减少 + 对订单表订单信息的添加。因此需要使用事务来保证业务的原子性。由于使用mybatisplus框架,所以需要特别注意是对哪个表的操作,该service是voucherOrder的service,拓展了voucherOrder表相关mybatisplus接口,所以直接使用函数将是对voucherOrder表的操作,因此我们需要注入seckillVoucherService对象来实现对voucher的操作。

超卖问题
问题分析

       本质是事务并发带来的问题。

解决方案

       悲观锁要求在对数据操作前必须获取锁,因此串行性很高,但同时导致了程序运行效率低;乐观锁不会加锁,只在修改数据时判断数据是否已经被修改,来判断自己是否能够进行数据操作,由于乐观锁将要判断其他线程是否修改数据,所以其实现难点就在于此。

乐观锁判断数据修改的方案
版本号法

       在此处,版本号将作为判断数据是否修改的依据,线程每次执行修改前都需要判断版本号与查询时获取的是否一致,只有当版本号一致时,才能认为其他线程没有修改数据,自己可以安全修改。

例:线程1操作完数据后,将版本号更新为2。线程2修改时,对比查询时获取的版本号(1)与修改时获取的版本号(2),发现不一致,代表这个数据已经被其他线程修改,则线程2的修改操作无法执行。

CAS法

       该方法实际上是对版本号法的简化,线程修改前通过比较库存是否修改来判断其他线程是否操作数据,无需引入版本号这一变量。

       例:线程1修改库存后,库存减为0。线程2对比查询时获取的库存(1)和修改前获取的库存(0),发现二者不一致,说明数据已经被修改,那么操作将无法执行。

乐观锁解决超卖问题(CAS法)
简单的乐观锁方案

问题分析

       由于我们必须确保修改前与查询时库存一致,所以即便库存修改后是充足的,这些线程也将无法执行对应售卖操作。

代码修改

        //库存充足,扣减库存
        boolean success = seckillVoucherService.
                update().setSql("stock = stock - 1")
                .eq("voucher_id", voucherOrder.getVoucherId())
                .gt("stock", 0) //保证修改时获取的库存充足即可售卖
                .update();
一人一单
需求分析

       我们只需要在执行下单修改数据库前判断用户数据(对于当前订单而言,即voucher_id相同)是否已经存在于订单数据库中,若存在则无法继续下单。

代码实现(简陋版)

       注意,判断一人一单应在下单前,若已经购买则无法执行下单、扣减库存的操作!

问题分析

       显然,一人一单并未实现。这个问题与库存修改一致,多个线程穿插执行时,可能这些线程此时都是没有查询到数据的,所以都执行了购买操作。

解决方案

       乐观锁是通过修改前与查询时数据一致性来决定是否执行,但显然新增数据的操作无法进行此比较,所以此处我们采用悲观锁,即为新增数据库的操作设置为串行。

通过悲观锁解决一人一单问题
前言

       由于一人一单的全部流程的目的是为了提交订单,因此超卖问题(库存相关)也囊括其中。所以我们将一人一单逻辑和超卖逻辑整合进同一个方法中,在这个方法中生成订单数据,返回响应结果。

       且由于生成订单的操作涉及对数据库多个表的操作,应当将其原子化为同一事务,因此@Transactional注解也添加在该方法上,交给spring容器管理。

修改1

代码

问题分析

       我们直接对方法进行了加锁,也就是说,当所有调用这个方法的请求到达时,均需要等待前一个请求释放该方法上的锁才能继续执行。

       也就是说,不管是不是同一个用户的请求,都将串行等待,当前一个请求释放锁后,后续用户才能进行下单操作。那么若请求非常之多,显然效率将是极其低的。

修改2

代码

代码分析

由于我们要针对的是同一用户不能同时发起多个请求去下单(一人一单的真正核心),所以我们可以考虑为用户id上锁。这样的话,不同用户请求调用方法时将不用等待,只有同一用户的请求才会被锁给串行化(根据userId的锁)。

由于每次生成的字符串都将是一个新对象,所以单纯对userId.toString()加锁是不可行的,同一个userId的字符串将可能成为不同对象,从而绕开锁。所以我们需要进一步通过intern()方法,去比较字符串的值是否一致,当userId值一致,那么就会被锁定。

问题分析

       此处我们将锁定义在了事务方法的内部,这将导致锁被释放时事务并未被提交。那么当事务提交前,有另外的线程(同一用户)又调用了方法,由于锁的释放,方法调用成功;由于事务未提交,下单也将成功,那么一人一单也并没能够实现。

修改3

代码

代码分析

       既然我们需要在事务提交后才能释放锁,那么我们就可以在调用处添加锁,当方法执行完(即事务提交后)我们再去释放锁,这就保证了事务执行期间其他事务是不能够修改数据库数据的。

问题分析

       Spring对事务的管理是通过生成当前对象的一个动态代理对象来进行的。此代码中对拥有事务的方法createVoucherOrder的调用实际上是this.createVoucherOrder,这个this指的就是当前调用该方法的实现类。显然,这个实现类在调用时并不是一个动态代理对象,是一个原始实现类对象,那么@Transactional注解此时就失效了,事务并不能够成功执行。

最终修改

代码

代码分析

       我们通过注入一个已经交给spring动态代理的对象(自身实现类)来让其调用事务方法即可。

代码测试      

       我们可以看到,当多线程(同一用户)请求时,只添加了一份数据,这表明一人一单实现成功!

一人一单的并发安全问题
案例演示

启动多个springboot服务

IDEA——>Edit Configuration——>复制一份项目的信息——>Add VMoption——>添加另一个端口:

启动服务,生成两个节点集群:

修改nginx端口以实现负载均衡

在nginx.conf文件下添加:

       这样nginx服务会轮询这两个端口,以实现负载均衡代理。

在nginx目录下输入cmd,然后输入:nginx.exe -s reload:

检查动态代理是否生效

打开浏览器的localhost:8080(nginx服务默认端口),然后向后端发起请求(任意均可),刷新:

我们发现服务端两个服务均被请求到了:

分布式环境下测试一人一单功能

       我们使用两个postman发起同一个请求(使用同一个token),结果发现两个服务都能够收到请求,但是userId相同,没有被锁住。

原因分析

这是我们理想状态下的一人一单逻辑(单体+悲观锁)

这是集群模式下的一人一单逻辑

       在每个tomcat服务(即一个JVM环境)内,存在一个锁监视器,会监视通过该tomcat服务器的所有请求,在这个情景下,同一用户的所有请求都将经过这个服务器的锁监视器,那么一人一单的用户锁就能完美执行。

       但是在负载均衡情况下(也就是集群环境),将会有多个tomcat(多个JVM),那么由于每个JVM的锁监视器只能监视当前服务的请求。当我们同时通过同一个用户发送多个请求(假设被代理后进行了负载均衡),那么多个请求将被分配到不同tomcat服务上,这就导致了锁的失效,因为锁只能对当前服务器生效!

分布式锁
基本原理

       我们上面说到,synchronize的锁只能在JVM内部生效,因为每个JVM拥有独立的锁监视器。那么我们若想要让多个JVM同时被锁监视器管理,那么就需要定义一个在JVM外部的锁监视器,监视所有JVM的锁。

分布式锁需要满足的特征

常见实现方式一览

基于redis的分布式锁
Redis设置锁与释放锁操作以及对应业务流程

       之所以不选择先setnx然后再通过expire设置过期时间,是因为要避免setnx结束后tomcat服务立刻宕机导致过期时间未能够设置。简而言之,就是要保证设置锁及其过期时间应当是原子性的。

代码实现(初级版本)

接口类

实现类

代码

注意事项

  1. 由于多个线程将会想要使用多个锁,因此这个实现类不能偷懒交给springboot容器自动配置,需要由用户自行创建!
  2. 由于实现类没有交给spring容器管理,那么redisTemplate也就需要由用户传入了;此外,由于锁与业务相关,因此每个锁的名称都不一样。综合前述两点,我们需要为该实现类创建一个构造方法,让用户指定这个锁的名称以及redisTemplate对象。

实现一人一单功能

代码

注意事项

  1. 我们不应只使用业务名称作为key。因为若许多用户都发起请求,那么只使用业务名称将导致这些请求都将申请同一把锁,那就出现了逻辑错误。所以我们使用目录结构,根据业务名称与用户id进行锁的构造,构造结果为”order:userId”,这样就实现了针对同一用户下单进行加锁,而其他用户下单不受影响。
  2. 获取锁失败的逻辑有两种思路,一种是睡眠+重试,但是这个思路对性能消耗可能是巨大的(当同一用户恶意发起多个请求时),所以此处我们直接返回错误信息即可。
  3. 由于锁的释放不管是否下单成功都将是必须的,所以使用try-finally结构,将unLock放于finally中。此操作的目的是:虽然每个锁都存在自动释放时间,但是若同一用户发起多个请求,当持有锁的请求下单失败时,锁未能够释放将会导致后续请求必须等到锁的自然释放,降低了程序的性能。

代码测试

我们仍然发送多个请求,这是控制台debug情况:

8081端口获取锁成功:

8082端口获取锁失败:

完美!

redis锁误删问题

这是初级代码将会存在的问题

       我们假定线程1、2、3均为同一个用户发起的请求。线程1获取锁并执行业务时,发生了业务阻塞,导致锁被超时释放。此时线程2趁虚而入,拿到了锁并开始业务执行。但是线程1在线程2开始执行业务的时候苏醒,完成业务,执行了删除锁操作。由于锁的key是由userId决定的,所以线程1进行删除锁操作时,实际上将会删除线程2持有的锁。那么当线程2持有的锁被删除后,线程3又可以趁虚而入……这就又导致了线程并发安全问题。

       我们简单理解就是,由于业务阻塞,当线程被唤醒时执行的删除锁操作实际上是删除另一个线程持有的锁,也就是拿错车了(通俗!)。

解决方案

       初级代码存在问题的原因在于,我们执行删除锁操作时,二话不说直接将锁就删掉了,不管锁是不是当前线程所持有。那么修改方案也很简单了,联想到我们进行setnxex操作时同时将threadId作为value存入到redis中,那么我们每次执行删除操作时,都将自身线程号与value值进行对比,一致后才允许删除。

新的业务流程

代码修改

使用UUID对加锁操作进行改善

    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-"; //线程ID的前缀,防止线程重复

    @Override
    public boolean tryLock(long timeoutSec) {
        String key = KEY_PREFIX + name;     //锁的名称

        String threadId = ID_PREFIX + Thread.currentThread().getId(); //线程id,作为值存入,表示当前获取锁的线程

        //尝试获取锁
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(key, threadId, timeoutSec, TimeUnit.SECONDS);

        //返回结果
        return BooleanUtil.isTrue(success); //自动拆箱可能存在空指针风险!
    }

       由于不同JVM可能会出现相同的线程,所以若只使用线程ID为值进行判断依据,仍可能存在误删其他线程的redis分布锁问题。因此我们使用UUID对线程标识进行复杂化,能更好避免不同JVM线程标识相同的问题。

释放锁的代码修改

       在删除锁的时候,首先判断是否来自同一线程,即通过取出的value是否与自身构造的标识相同来决定,若相同则可删,若不相同则不可删(啥也不管即可,图中输出是为了方便测试)。

代码测试

发起两个请求后查看控制台

8082获取锁成功:

8081获取锁失败:

手动删除当前添加的锁,模拟超时删除

让另一个线程获取锁(重启线程)

然后唤醒阻塞线程,让其进行删除锁操作判断

发现自己的线程标识与redis中标识不同,说明别的线程获取锁了,无法删除。

Redis原子性问题

问题分析

       我们已经通过存入线程信息为删除锁时添加了判断条件,但由于JVM内部的垃圾回收机制,判断锁能够删除后仍然会造成阻塞,导致超时误删。

       我们假设线程1已经判断了锁的value是自身线程的UUID+ThreadId,即能够成功删除,但是就在此时,JVM垃圾回收机制启动,导致了删除操作超时执行,锁被自动删除,线程2趁虚而入获取了锁。那么当线程1重新唤醒时,由于已经判断了锁是自己的(其实此时已经是线程2持有的了),所以线程1会直接删除线程2的锁,这时线程3就可以趁虚而入。就这样,又一次出现了三个线程并发修改同一数据的情况。

问题解决

       既然删除锁判断与删除锁操作之间可能被打断,那么我们就需要让这两个操作成为原子性的操作。

Redis的Lua脚本

使用lua脚本编写释放锁的逻辑操作

逻辑说明

脚本实现

       参数需由调用者传入,所以我们使用KEYS[1]来接收查询时传入的key,用ARGV[1]来接收查询时传入的value。

简化写法

       不单独定义变量接收,直接执行判断。

通过java调用lua脚本实现分布式锁释放的原子化

Java调用lua脚本的API

       回顾EVAL执行lua脚本的流程,我们需要在脚本后指定传入给key的参数个数、参数以及传入给value的参数,这都是为了脚本可以动态化适应不同请求。在spring提供的redisTemplate中,API直接为我们指定了传入的key集合、value集合,我们无需单独为其指定key的数量。

在java中编写lua脚本

--判断是否一致,一致则释放
if(redis.call('get', KEYS[1]) == ARGV[1]) then
    --一致,释放锁
    return redis.call('del', KEYS[1])
end
--不一致,啥也不做
return 0

       我们不建议直接在.java代码中编写脚本,因为这将会带来项目维护的困难。在spring框架的项目中,常常将脚本文件放入.resources目录下。

提前加载lua脚本

//提前加载lua脚本
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

       为了每次释放锁时均可以使用已经加载好的lua脚本而无需在调用时重复加载影响性能,我们可以在成员位置提前加载好lua脚本。

       具体内容为:为脚本对象新建一个RedsiScript类——>设置脚本对象的脚本位置(通过spring提供的API:ClassPathResource加载类目录下的文件资源,将lua脚本的文件位置获取出来)——>为脚本对象设置返回值。

调用lua脚本执行释放锁操作

    @Override
    public void unLock() {
        String key = KEY_PREFIX + name;     //锁的名称

        String threadId = ID_PREFIX + Thread.currentThread().getId(); //线程id,作为值存入,表示当前获取锁的线程

        //调用lua脚本
        redisTemplate.execute(
                UNLOCK_SCRIPT,  //要执行的脚本对象
                Collections.singletonList(key),     //传给脚本中redis对象的key
                threadId    //传给脚本中用于与redis值进行比较的内容
        );
    }

       通过StringRedisTemplate.excute方法,我们就可以将脚本、脚本所需的key值、脚本所需的value值传入给脚本,通过脚本中的逻辑进行判断。

       由于此处执行的是一个方法,也就是整个脚本是作为执行的单元,也就是原子,这就实现了释放锁操作的原子化,JVM内部的阻塞并不会影响判断与删除之间的连贯。

       请注意,excute需要的key和value都被封装为了一个集合,所以我们可以通过Collections.singletonList将单元素转化为集合对象。

基于redis的分布式锁实现总结

Redis分布式锁优化
基于setnx分布式锁存在的问题

不可重入

       我们假设以下场景:线程1中方法A正在执行,方法A需要使用锁,此时线程1成功获取。方法A中调用了方法B,方法B同样需要锁,但由于二者处于同一线程,且A还持有锁,因此锁无法被释放。这样就出现了:线程A等方法B执行——>方法B等方法A释放锁,即出现了死锁。

不可重试

       当线程A获取锁失败时,我们直接让他返回了报错。而通常情况下,我们需要该线程能够阻塞,等会再重试获取锁。

超时释放

       我们为锁设置了超时时间,这是为了避免因为业务阻塞超时导致锁无法被其余线程获取执行业务的情况。

但是超时时间其实是非常矛盾的点:若时间设置过短,业务未执行完就释放了锁,那么其余线程还是可以在业务执行期间进行数据修改,又出现了数据不一致;若时间设置过长,那么业务阻塞时间也非常长,就会导致其余线程将会一直阻塞等待锁的释放,程序的性能将会下降。

主从一致性

       在主从模式下,我们会选用主节点进行数据的更改,选用从节点进行数据的查询。那么通常情况下,主节点必须要将数据传递给从节点(包括锁数据),这样才能保证主从数据的一致性。同时,当主节点宕机时,会选用随机从节点作为主节点,继续进行主从模式功能。

       我们假设以下情况:当主节点正在执行写操作,且已经成功获取了锁。在主节点未将数据同步给从节点时,主节点发生了宕机,此时从节点均未能获取锁已被获取的标识(未执行对于的setnx操作)。当新的主节点选出时,其余线程均不知道锁已经被获取,这就会导致其余线程都能够获取锁,并发问题再次出现。

Redisson实现分布式锁优化

Redisson功能介绍

Redisson快速入门

引入依赖

配置redisson客户端

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){
        //配置对象
        Config config = new Config();   //注意!必须是Redisson提供的!
        config.useSingleServer().setAddress("redis://192.168.71.10:6379").setPassword("123456");   //配置地址&密码(无密码)
        //将配置对象加载给客户端对象
        return Redisson.create(config);
    }
}

       我们推荐使用java配置类单独配置redisson的bean对象,这是为了防止在yml配置文件中配置相关内容导致spring官方提供的redisson被覆盖。

       注意!url必须为”redis://xxx”的格式才行!

使用redisson的分布式锁

        //通过redisson获取锁&释放锁
        //创建锁对象
        String keyName = "lock:" + "order:" + userId;
        RLock lock = redissonClient.getLock(keyName);
        //尝试获取锁
        boolean isLock = lock.tryLock();

        //获取锁失败,返回错误信息
        if (!isLock)
            return Result.fail("请勿重复下单!");

        //获取锁成功,执行下单操作
        try {
            return voucherOrderService.createVoucherOrder(voucherId);
        }finally {
            lock.unlock();
        }

       使用redisson设置锁&释放锁的API实际与我们的逻辑类似。值得注意的是tryLock()方法,当无参传入时,默认获取失败不等待,直接继续执行;若使用两个参数进行设置,就是设置超时释放时间以及时间单位;使用三个参数进行构造,就是设置重试的最长时间、超时释放的时间以及时间单位:

单点测试功能——高并发情况

Redisson可重入锁原理

不可重入原因

       由于我们的锁是通过setnx设置的,那么当lock这个key存在时,自然无法重复设置锁。

可重入原理分析

       我们可以参考JUC的可重入锁原理——为当前锁加入一个持有数的变量,每当有同一线程想要持有锁时,变量加1,释放锁时,变量减1。我们知道,同一线程内方法的执行是栈结构的,因此当内部方法全部执行完毕,变量将会变为1,即只有最外层方法仍然持有锁,那么当最外层方法执行完可以进行锁的释放,变量将变为0,表示当前线程没有方法持有锁,可以放心大胆删除。

       由于我们锁的内容不仅需要包含threadId以判断是否是同一线程持有锁,还需要value来判断当前线程持有锁的个数,所以我们使用hash结构作为值存入。由于hash结构不存在setnx以及setex操作,所以对于线程是否能够持有锁与重入锁导致value+1、释放锁导致value-1均需要拆开书写。

       同理的,由于这些操作需要保持原子性,不能让其他线程或者JVM机制趁虚而入,所以我们使用lua脚本,将所有这些操作都写入其中,保证其原子性。

Lua脚本

获取锁

释放锁

注意事项

  1. 通过exists是判断锁是否已经被某个线程持有,通过hexists是判断当前线程是否与持有锁的线程一致。
  2. 我们之所以要在每次操作时重置超时释放时间,是为避免外层方法持有锁过久,导致内部方法未执行完锁就已经释放。

Redisson的锁重试与WatchDog机制

获取锁的脚本

       若返回值为nil,代表获取锁成功,当前锁没有被任意线程持有,那么将返回值nil赋值给ttl,即ttl=null。

       当有线程持有锁时,将返回剩余有效期,赋值给ttl,让当前线程在剩余有效期内去尝试获取锁。

重试获取锁的流程

  1. 脚本中的返回值将赋值给ttl,即剩余有效期。
  2. 获取锁成功的标识是ttl为null,此时我们需要为其设置超时释放时间,即leaseTime。若我们未指定leaseTime,这个时间将会被指定为-1,此时启用watchDog机制,设置超时时间为默认的30s(30*1000ms),并且不断监测、重置锁的超时释放时间。若我们设置了超时释放时间,那么就使用我们设置的leaseTime,然后执行业务,并且当超时时间到达时,不管业务是否执行完成,都将执行释放锁的操作。
  3. watchDog机制将会在业务执行中不断检查锁是否存在,也可以理解为当前持有锁的任务是否还在执行。若任务持续执行,那么就代表锁此时不能够被释放。由于我们启用了watchDog机制,那么watchDog将通过定时任务不断更新锁的超时释放时间,避免锁在业务执行中被释放而导致多线程并发执行业务情况。
  4. 获取锁失败,我们将获得重试获取锁的剩余有效期,即ttl != null。此时首先判断ttl的情况,若ttl<0,说明重试获取的有效期没有了,无法重新获取锁,那就直接返回false,表示获取失败且无法重复获取。当ttl>0,说明我们仍然能够重试获取锁,但此时,由于锁可能被其余线程持有,所以我们应去申请锁的释放信号(PV操作),当其余线程释放了锁,那么同时也将释放锁的信号,此时当前线程就可以成功申请到获取锁的操作。但是应当注意,等待其余线程释放锁是会占用剩余有效期的,因此我们需要判断锁信号释放后,我们的等待时间是否超过了剩余有效期,即是否等待超时。若等待超时,即便当前线程持有锁的信号,那么获取锁也将不能成功,重试操作将失败;若等待不超时,那么就去尝试获取锁。与此同时,其余线程也可能持有了获取锁的信号,那么就看哪个线程更快能持有锁,若当前线程落后,那么就只好重复上述逻辑了。

超时释放锁的流程

  1. 尽管尝试释放锁此时基本是成功的,因为锁的信号只被当前线程持有,但是保险起见,我们还是进行了判断,避免释放了不属于自己的锁。
  2. 由于锁的获取是通过信号量机制来实现的,所以释放锁的同时也需要释放锁的信号量,让其余线程知道此时锁已经被释放,可以被它们尝试获取。若不释放信号量只释放锁,那么其余线程将一直处于等待状态。
  3. 当我们未设置leaseTime时,启用了watchDog机制,这个机制将通过定时任务自动更新锁的超时释放时间,以便业务正常执行。但是该任务只能手动取消,若我们在释放锁时未取消该任务,那么watchDog将不断执行,持续消耗计算机资源。

Redisson分布式锁的全流程总览

Redisson分布式锁总结(可重入、可重试、超时续约)

Redisson的multiLock原理(解决主从一致性)

Redis主从一致性问题回顾

       当加锁的主节点未来得及将锁的信息传给从节点时就宕机,导致没有一个节点知道锁已被申请。此时锁就失效了,其余线程能够随意访问。

主从一致性问题的解决思路

       在与java应用直接对接的redis节点集群中,我们不再创建主从结构。此时若某个线程获取锁,那么锁将全部同步给所有redis节点。我们基于对等的redis节点集群上再建立主从模式,这样一来,即使某个节点宕机,由于其余节点也均持有锁,所以其余线程同样无法获取锁。

mutiLock大致流程图

总结

Redis优化秒杀
问题分析

       这是我们秒杀下单业务的流程,我们会发现,所有的流程在一台tomcat服务器内均是由一个线程完成的。即,这个线程不仅需要完成查询数据库、判断秒杀资格,还要执行写数据库操作。那么当请求增加,线程随之增加,服务器的负载也会变大,此时响应耗时就会增加。

问题解决

       既然造成服务器吞吐能力降低是由于线程需要完成的操作过多,那么我们不妨将线程任务进行分离。我们知道,秒杀下单操作中,判断秒杀资格(即库存是否充足&是否满足一人一单条件)是下单操作的前提,且这两个操作是通过查询数据库实现的,速度较快,那么我们不妨将判断秒杀资格与下单生成数据库数据分为两个线程进行处理。我们可以让线程1去执行判断秒杀资格的操作,当秒杀资格确认后,再生成新的线程去执行生成订单操作。

       实际上,秒杀是否成功就是取决于秒杀资格判断是否生效,若生效,用户就可以直接拿着订单信息完成付款操作。由于在redis判断完成后,会实现预减库存的操作,所以写入sql数据库的时机就不是那么重要了。

       我们将秒杀资格交给redis执行,那么效率将会大大提升。Redis只需要判断秒杀资格、将订单相关信息保存,然后就可以接着处理下一个人的请求,大大降低了线程的压力。

Redis数据结构选择

       对于库存判断,只需要使用string即可。每一次购买前判断value>0是否成立,即可判断库存是否充足。

       对于一人一单判断,对于一张优惠券可能存在多个用户购入,所以value具有多值。且由于一人一单,该value是不可重复的。因此我们可以联想到,使用set数据结构,可以同时保证存储多值&每个值至多出现一次的需求。当我们从set中获取到了当前请求的userid,就代表了当前用户之前申请过购买优惠券,所以该请求将无法实现。

       注意,我们在确认资格后,需要在redis中进行库存预减操作!

代码实现
业务流程

需求分析

代码

基于redis实现秒杀资格判断

保存库存到redis

     /**
     * 保存优惠券以及秒杀相关信息
     * @param voucher
     */
    @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中
        String keyName = RedisConstants.SECKILL_STOCK_KEY + voucher.getId();
        Integer stock = voucher.getStock();     //库存
        stringRedisTemplate.opsForValue().set(keyName, stock.toString());
    }

       在原有的保存数据库操作上增加了将库存信息写入redis的操作,可以看作是一个预热,当有能用于秒杀的优惠券增加时,数据库与redis都会增加这个优惠券的基础信息。

保存库存的测试结果

判断秒杀资格的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判断库存是否充足
if(tonumber(redis.call('get', stockKey)) <= 0) then
     -- 3.2库存不足,返回1
     return 1
end
-- 3.3库存充足,判断用户是否下单
if(redis.call('sismember', orderKey, userId) == 1) then
     -- 3.4用户已经下单,返回2
     return 2
end
-- 3.5扣减库存
redis.call('incrby', stockKey, -1)
-- 3.6下单,保存userid到redis中
redis.call('sadd', orderKey, userId)
-- 4成功执行,返回0
return 0
  1. 判断一人一单是通过set的sismenber进行的,若传入的value值存在,则该方法返回结果为1。
  2. 扣减库存可以通过incrby操作实现,该操作会对key的value执行增加操作(传入-1,则会增加-1)。

执行lua脚本

/*
    通过lua+redis判断用户是否具有下单资格,将有资格的订单信息写入阻塞队列
    */
    //加载lua脚本
    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
    static {
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }

/**
     * redis判断后将订单信息写入阻塞队列
     * @param voucherId
     * @return
     */
    @Override
    public Result seckillVoucher(Long voucherId) {
        Long userId = UserHolder.getUser().getId();

        //执行lua脚本,获取返回值
        Long result = redisTemplate.execute(
                SECKILL_SCRIPT,     //执行的脚本
                Collections.emptyList(),     //key列表
                voucherId.toString(), userId.toString()   //ARGV参数列表
        );

        //判断lua脚本返回值
        //不为0,无资格,1-库存不足,2-不能重复下单
        int r = result.intValue();
        if (r != 0)
            return Result.fail(r == 1? "库存不足" : "不能重复下单");

        //为0,有资格,获取订单信息,并保存到阻塞队列
        //获取订单信息(即订单id)
        Long orderId = redisGlobalIdWorker.nextId("order");
        //TODO 放入阻塞队列中

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

       同样的,我们静态加载了lua脚本,便于后续线程的使用。

将订单信息保存到阻塞队列

阻塞队列创建

//阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);

       阻塞队列,即当队列中有元素时才会被唤醒。

保存信息

//创建订单信息
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setId(orderId);    //订单id
        voucherOrder.setUserId(userId);     //用户id
        voucherOrder.setVoucherId(voucherId);   //代金券id
        //放入阻塞队列中
        orderTasks.add(voucherOrder);

       实际上就是创建voucherOrder对象,然后加入到队列中。

基于阻塞队列实现异步下单

执行下单的线程

//线程池,用于异步下单
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

       由于异步下单仅是将订单信息写入数据库,且由于redis中已经存入了订单相关信息,所以数据库写入操作对性能要求并不高,单线程即可。

线程任务

//线程任务
    private class VoucherOrderHandler implements Runnable{
        @Override
        public void run() {
            while (true){
                try {
                    //获取队列中的订单信息
                    VoucherOrder voucherOrder = orderTasks.take();
                    //创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (Exception e) {
                    log.error("处理订单异常", e);
                }
            }
        }
    }

       该线程所需要执行的操作即:获取队列中的订单信息、创建订单。此线程的逻辑我们设置为无限循环,但其实只有当阻塞队列中有内容时,才会开始执行操作;阻塞队列中无内容时,线程将会阻塞在ordersTasks.take()处。

       实际上,阻塞队列提供的take方法,就是用于阻塞获取队列元素的API,当有值时取出并继续,没有值时等待。

为线程方法指定执行时机

//让线程在类初始化后就开始读取阻塞队列内容,提交订单
    @PostConstruct
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

       由于当服务启动时,用户就有可能开始执行下单操作,所以线程对于阻塞队列的读取最好从该类初始化(即服务启动)后就开始。这样,当阻塞队列中一有订单信息,线程就会执行写入数据库的操作。

       我们可以通过Spring提供的@PostConstruct注解,让该方法在该类初始化后立即执行。

从线程角度判断是否生成订单(即通过锁的获取)

//创建订单信息,根据当前线程是否可以获取锁来判断是否创建数据库信息,安全层面考虑
    private void handleVoucherOrder(VoucherOrder voucherOrder){
        //通过redisson获取锁&释放锁
        //创建锁对象
        String keyName = "lock:" + "order:" + voucherOrder.getUserId();
        RLock lock = redissonClient.getLock(keyName);
        //尝试获取锁
        boolean isLock = lock.tryLock();

        //获取锁失败,返回错误信息
        if (!isLock) {
            log.error("不能重复下单!");
            return;
        }

        //获取锁成功,执行下单操作
        try {
            voucherOrderService.createVoucherOrder(voucherOrder);
        }finally {
            lock.unlock();
        }
    }

将订单信息写入数据库

//创建订单信息,写入数据库
    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder){
        //一人一单
        //查询用户id
        Long userId = voucherOrder.getUserId();

        //根据id查询订单,判断订单是否已经存在
        int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
        //count >= 1,说明已经买过,不能再购买
        if (count >= 1) {
            log.error("请勿重复购买!");
            return;
        }

        //库存充足,扣减库存
        boolean success = seckillVoucherService.
                update().setSql("stock = stock - 1")
                .eq("voucher_id", voucherOrder.getVoucherId())
                .gt("stock", 0) //保证修改时获取的库存充足即可售卖
                .update();
        if (!success){
            log.error("库存不足!");
            return;
        }

        //将订单插入数据库中
        save(voucherOrder);
    }
总结

Redis消息队列实现异步秒杀
基于阻塞队列的异步秒杀存在的问题
  1. 由于我们为了防止所有订单全部放进阻塞队列导致内存溢出,我们为阻塞队列设置了内存限制。但这就导致了当阻塞队列满了后,后续订单将无法放入阻塞队列,出现了用户付款但是数据库无订单信息的问题。
  2. 由于我们将信息存放在阻塞队列,且阻塞队列基于JVM服务工作,当服务重启或宕机时,阻塞队列中的内容都将丢失,再一次出现了数据不一致的问题。
消息队列介绍

       消息队列与阻塞队列的区别为:消息队列是独立于JVM以外的,不受内存限制,服务宕机也不受影响,且消息队列会确保队列中消息至少被消费者消费一次,否则会一直存放在消息队列中,不断发送给消费者等待处理。

Redis实现消息队列

基于list结构模拟消息队列

相关说明

问题分析

  1. BR(L)POP操作是remove+get操作,若有消费者执行了操作拿取信息,那么信息将从队列中移除。若该消费者未执行对数据库操作,由于其余该消息已经不在队列中,其余消费者均无法获取,这就造成了信息丢失。
  2. 同上,当有一个消费者拿到消息后,消息将从队列移除,其余消费者无法获取消息。
基于pubsub实现消息队列

相关说明

问题分析

  1. 若生产者消费无人订阅,那么该消息将直接消失!
  2. 若消费者订阅了生产者的消息,那么接收的消息将存储在消费者的缓存中。众所周知,缓存是存在上限的,若缓存满了还有消息发布,那么新发布的消息同样将丢失。
基于stream实现消息队列

Stream的单消费模式

Stream的消费组模式

Redis消息队列总结

达人探店

发布探店笔记——文件上传

    @PostMapping("blog")
    public Result uploadImage(@RequestParam("file") MultipartFile image) {
        try {
            // 获取原始文件名称
            String originalFilename = image.getOriginalFilename();
            // 生成新文件名
            String fileName = createNewFileName(originalFilename);
            // 保存文件
            image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
            // 返回结果
            log.debug("文件上传成功,{}", fileName);
            return Result.ok(fileName);
        } catch (IOException e) {
            throw new RuntimeException("文件上传失败", e);
        }
    }

       之前我们一直将图片资源保存到AliyunOSS,这当然是可以的,此处介绍另外一种方法:保存到nginx服务器内。

点赞功能——一人一赞
需求

代码
Controller
    @PutMapping("/like/{id}")
    public Result likeBlog(@PathVariable("id") Long id) {
        return blogService.likeBlog(id);
    }
Service

当前用户点赞功能

       原理就是通过判断redis数据结构set具有无重复元素的特性实现。若set中有当前用户id,则代表当前用户已经点赞。

       我们需要注意,修改数据库的同时别忘了更新redis。

判断当前用户是否点赞该博客,对isLike字段赋值

       我们对isLike字段赋值后,前端会根据响应的blog对象去获取对应的isLike字段,从而进行点赞按钮亮暗图片变更。

为博客查询相关业务加上判断博客是否被当前用户点赞

点赞排行榜
需求

redis数据结构选取

       由于需要唯一&有序,所以我们使用SortedSet(ZSet)数据结构。

对之前点赞业务更新

当前用户点赞功能

 /**
     * 点赞功能
     * @param id
     * @return
     */
    @Override
    public Result likeBlog(Long id) {
        //获取当前登录用户
        Long userId = UserHolder.getUser().getId();

        //判断当前用户是否点赞
        String key = RedisConstants.BLOG_LIKED_KEY + id;
        Double score = redisTemplate.opsForZSet().score(key, userId.toString());

        //未点赞,即Score为空
        if (score == null) {
            //数据库点赞数+1
            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
            if (isSuccess)  //若更新数据库成功
                //保存用户到redis
                redisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
        }
        //已点赞
        else {
            //数据库点赞数-1
            boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
            if (isSuccess)
                //从redis中移除用户
                redisTemplate.opsForZSet().remove(key, userId.toString());
        }

        return Result.ok();
    }

使用ZSet存储用户信息

//保存用户到redis
redisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());

       我们使用时间戳来作为ZSet元素的分数。

查询redis中是否存在用户

Double score = redisTemplate.opsForZSet().score(key, userId.toString());

       我们使用查询分数来判断用户是否存在,若存在则能查询到分数。

根据用户是否点赞为博客设置isLike字段

/**
     * 查询当前用户是否点赞博客,将isLike字段进行赋值
     * @return
     */
    private void isBlogLiked(Blog blog){
        UserDTO user = UserHolder.getUser();

        //若用户未登录则直接返回
        if (user == null)
            return;

        Long userId = user.getId();
        //用户已经登录
        String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();

        Double score = redisTemplate.opsForZSet().score(key, userId.toString());

        if (score != null)  //若能查到
            blog.setIsLike(true);
        else
            blog.setIsLike(false);
    }
查询榜单的代码实现
Controller

Service

好友关注

关注和取关
需求

尝试关注用户
Controller
/**
     * 关注或取关
     *
     * @param id       要关注的博主id
     * @param isFollow true,则为关注操作
     * @return
     */
    @PutMapping("/{id}/{isFollow}")
    public Result follow(@PathVariable Long id, @PathVariable Boolean isFollow) {
        return followService.follow(id, isFollow);
    }
Service

Mapper(删除操作)

是否关注用户
Controller

Service
/**
     * 判断当前用户是否关注博主
     * @param id  博主id
     * @return
     */
    @Override
    public Result isFollow(Long id) {
        Long userId = UserHolder.getUser().getId();

        Integer count = query().
                eq("user_id", userId).
                eq("follow_user_id", id).
                count();

        //若关注信息=0,说明未关注,返回false
        return Result.ok(count > 0);
    }
共同关注
需求

对关注业务改造,使关注信息同时存入redis
/**
     * 关注或取关
     * @param id   要关注的博主id
     * @param isFollow   true,则为关注操作
     * @return
     */
    @Override
    public Result follow(Long id, Boolean isFollow) {
        Long userId = UserHolder.getUser().getId(); //当前用户id
        String key = RedisConstants.BLOG_FOLLOW_KEY + userId;

        //判断是否为关注操作
        if (isFollow) {
            //关注操作,添加数据到数据库中
            Follow follow = new Follow();
            follow.setFollowUserId(id);
            follow.setUserId(userId);
            boolean isSuccess = save(follow);
            if (isSuccess){
                //同时将信息存入redis,以当前用户id为key,存储关注的博主id
                redisTemplate.opsForSet().add(key, id.toString());
            }
        }

        //取关操作,从表中删除数据
        else {
            followMapper.deleteWithFollowerAndBloger(id, userId);
            //取关成功,将博主信息移除
            redisTemplate.opsForSet().remove(key, id.toString());
        }

        return Result.ok();
    }
共同关注实现
Controller
/**
     * 查看当前博主与用户的共同关注
     * @param id  当前博主id
     * @return
     */
    @GetMapping("/common/{id}")
    public Result commonFollow(@PathVariable Long id){
        return followService.commonFollow(id);
    }
Service
/**
     * 查看当前博主与用户的共同关注
     * @param id  当前博主id
     * @return
     */
    @Override
    public Result commonFollow(Long id) {
        Long userId = UserHolder.getUser().getId();
        String userKey = RedisConstants.BLOG_FOLLOW_KEY + userId;   //当前用户的redis查询key
        String bloggerKey = RedisConstants.BLOG_FOLLOW_KEY + id;    //当前博主的redis查询key

        //查询两个集合的交集
        Set<String> commonIds = redisTemplate.opsForSet().intersect(userKey, bloggerKey);

        //转为UserDTO对象
        List<UserDTO> commonUsers = commonIds.stream().map(
                commonId -> BeanUtil.copyProperties(userService.getById(Integer.parseInt(commonId)), UserDTO.class)
        ).collect(Collectors.toList());

        return Result.ok(commonUsers);
    }
关注推送
Feed流
简介

常见模式

Timeline实现方案

拉模式

       当发布者发布内容时,消息将存放在发布者的发件箱中。只有当用户需要获取时才会去拉取发布者收件箱中内容,可以有效减少内存占用。但每次读取都需要拉取&排序,读取延迟较高。

推模式

       发布者发布内容后直接将消息推送给用户的收件箱并自动进行排序,用户读取时无需像拉模式一样进行拉取&排序,延迟较低。但是由于无发件箱,每次发布都要讲消息写给所有人,内存占用较高。

推拉结合模式

总结

Feed流推送的分页查询问题(Redis数据结构选取)
传统分页模式问题分析

       在传统分页模式中,是按照角标查询的。当有新数据插入时,老数据的角标可能出现变化,导致一条记录被多次查询的情况。如图中:“6”位于角标=5的位置,当“11”插入后,“6”就变为角标=6的位置了。若按pageSize=5进行查询,那么“6”在第二页仍然能够被查询到。

       传统分页模式同样是基于listSet分页查询存在的问题,因为listSet的分页查询就是基于角标进行查询。

游标模式实现分页

       此时我们不再记录角标,我们改为记录最后一次查询到的元素,然后在下一页从该元素的下一个开始查询。

数据结构选取

       既然选用listSet会出现分页基于角标查询的问题,那么我们又要实现排序又要实现分页查询,就只好选用sortedSet(ZSet)咯。

使用推模式实现关注推送

笔记推送到收件箱(保存至redis)

/**
     * 发布博客,保存至数据库&redis中
     * @param blog
     * @return
     */
    @Override
    public Result saveBlog(Blog blog) {
        // 获取登录用户
        UserDTO user = UserHolder.getUser();
        blog.setUserId(user.getId());
        // 保存探店博文
        boolean isSave = save(blog);

        if (!isSave)
            return Result.fail("保存博客失败!");

        //将博客发送给粉丝
        //获取粉丝id
        /*
        对应sql为:
            select * from tb_follow where follow_user_id = blog.getUserId()
        follow_user_id为当前用户关注的博主的id,我们只需要找到关注博主为当前发布者的所有用户,即当前博主的粉丝
         */
        Long bloggerId = blog.getUserId();  //博主id
        List<Follow> followUser = followService.query()
                .eq("follow_user_id", bloggerId)     //查找关注了该博主的粉丝
                .list();
        //通过粉丝id为key分别保存到对应redis中
        followUser.forEach(follow -> {
            Long userId = follow.getUserId();   //获取粉丝id
            String key = RedisConstants.BLOG_INBOX_KEY + userId;    //以粉丝id构建粉丝收件箱
            redisTemplate.opsForZSet()
                    .add(key, blog.getId().toString(), System.currentTimeMillis());   //以时间戳为score存入ZSet中
        });

        // 返回id
        return Result.ok(blog.getId());
    }
  1. 在tb_follow中,userId为当前用户id,follow_user_id为该用户关注的博主的id。要查询博主的粉丝,我们可以换个角度想:我们只需要查询当前博主id在用户关注博主的id中的所有用户即可,也就是follow_user_id = 博主id的全部用户。
  2. 由于采用推模式,因此我们需要为每一个粉丝都建立一个收件箱,然后采用说时间戳作为score传入到ZSet中,让ZSet为我们自动排序。
基于SortedSet(ZSet)实现滚动分页查询

思路

       我们使用时间戳为score,所以越新发布的笔记默认在ZSet的越后面。查询的前几页要查询的笔记应当是新发布的,因此我们使用反向查询,即:ZREVRANGEBYSCORE来进行查询。对于其中参数解释如下:max——查询score的最大值;min——查询score的最小值;offset——从最大值开始的偏移量,若为0,则代表从max开始的第0个(自身);count——每次查询的记录条数。

       我们使用滚动查询,也就是每次以上一次查询score为max,再开始反向查询。当然,第一次查询时,由于没有上一次查询的记录,我们默认以当前时间戳为max开始查询。

       但需要考虑一种情况,如:m7的score为6,m6的score同样为6,m7的插入顺序在m6之后,在上一次查询时结果为… m7, m6。若我们仍然以max=6,offset=1开始进行下一次查询,将出现m6,…的结果。m6出现两次的原因是:以上一次查询的score6作为max,1为偏移量,那么查询时第一个socre=6的结果将为m7,偏移1后则为m6。

       因此,offset应当以score为上一次查询结果score的所有元素个数来定义,防止上述情况出现。

接口设计

代码实现

用于封装滚动查询结果的对象

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

Controller

/**
     * 关注推送页面的分页查询
     * @param lastId  上一次查询的最小时间戳,将作为本次查询的开始,即max
     * @param offset  上一次查询的具有相同score的元素个数,作为此次查询开始的偏移量
     * @return
     */
    @GetMapping("/of/follow")
    public Result queryBlogWithFollow(
            Long lastId, Integer offset){
        return blogService.queryBlogWithFollow(lastId, offset);
    }

Service

/**
     * 关注推送页面的分页查询
     * @param lastId  上一次查询的最小时间戳,将作为本次查询的开始,即max
     * @param offSet  上一次查询的具有相同score的元素个数,作为此次查询开始的偏移量
     * @return
     */
    @Override
    public Result queryBlogWithFollow(Long lastId, Integer offSet) {
        //第一次时需由我们自行指定偏移量
        if (offSet == null)
            offSet = 0;

        String key = RedisConstants.BLOG_INBOX_KEY + UserHolder.getUser().getId();  //获取当前用户的收件箱key

        //基于请求参数进行反向滚动查询,查询当前用户收件箱所有的blog的id&score
        Set<ZSetOperations.TypedTuple<String>> typedTuples = redisTemplate
                .opsForZSet()
                .reverseRangeByScoreWithScores(key, 0, lastId, offSet, 3);

        if (typedTuples.isEmpty() || typedTuples == null)
            return Result.ok();

        //解析获取blogId、minTime、offSet
        List<Blog> blogList = new ArrayList<>();    //保存blog
        Long minTime = lastId;   //最小时间戳
        Integer offset = 1;     //偏移量,默认为1
        for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
            //获取blogId并保存blog信息
            Long blogId = Long.valueOf(typedTuple.getValue());
            blogList.add(getById(blogId));
            //更新最小时间戳,并且维护偏移量
            if (minTime == typedTuple.getScore().longValue())  //出现了同样score的内容,偏移量需增加
                offset++;
            else {  //出现了更小的score(即时间戳),维护minTime,偏移量复位
                minTime = typedTuple.getScore().longValue();
                offset = 1;
            }
        }
        for (Blog blog : blogList) {
            queryBlogUser(blog);
            isBlogLiked(blog);
        }

        //封装并返回
        ScrollResult scrollResult = new ScrollResult();
        scrollResult.setList(blogList);
        scrollResult.setMinTime(minTime);
        scrollResult.setOffset(offset);

        return Result.ok(scrollResult);
    }

  1. 我们需要获取blogId以及对应的score,因此选择reverseRangeByScoreWithScores方法,该方法的返回值是Set<ZSetOperations.TypedTuple<String>>,也就是包含对应key的value以及score的元组。
  2. 我们通过解析元组进行blogId以及minTime的获取。对于minTime与offset的获取,实际上是一个简单的更新算法。由于倒序查找,所以遍历的blog一定是score越来越靠后的,因此每次获取的必然是更小的时间戳。且当时间戳存在相等时,offset++;时间戳更新时,offset同样维护为初始值1即可。
  3. 请注意,由于blog需要展示博主信息以及当前用户是否点赞,因此我们需要调用先前写过的查询博主信息&查询是否点赞的API。

解析2

解析3

  •  附近商铺

    GEO数据结构&对应命令

    附近商家搜索

    将商户信息存入redis
    思路

           我们首先需要知道,geo底层是基于ZSet实现的,因此存入时我们需要指定key,value以及对应value的score。其中,score在geo中体现为value的地理坐标,是通过经纬度转换而来的。

           由于我们需要让同类商铺划分为一个组,所以我们应该选用typeId为key。同时,我们不应将shop的所有信息塞入redis,会占用很大的内存,我们采用shop的id为value,将id以及shop对应的经纬度传入即可。

    实现

    完整代码

    注意事项1

         通过Collectors.groupingBy(分组依据),stream流会根据分组依据为key,将建立stream流的集合的元素进行分组,并且放入一个新的list中。

           以图中为例即:分组依据为shop.getTypeId,建立stream流的集合的元素为shop,那么构成的map就是以typeId为key,以具有相同typeId的shop构成的List<Shop>为value的一个Map<Long, List<Shop>>。

    注意事项2

           相比于获取每一个shop然后依次添加到redis,我们可以首先获取当前类型shop的地理位置集合,然后将这个集合一次性加入redis中。

           集合中对象的类型为RedisGeoCommands.GeoLocation<String>,该对象成员变量如下:

           实际上就是value以及score,其中的泛型为name具有的类型。依据思路中以id为value,以经纬度为key(在Point中会自动转换),我们进行这样的添加即可:

    测试结果

    实现附近商户功能
    首先需要对SpringDataRedis的版本进行更新

    注意事项

           课件中(上图)方法可能导致找不到对应包,也可以考虑直接更新springboot版本,并继续让springboot管理redis相关依赖。如下:

    附近商户查询代码实现

    Controller

  • /**
         * 根据商铺类型分页查询商铺信息
         * @param typeId 商铺类型
         * @param current 页码
         * @param x  经度
         * @param y  纬度
         * @return 商铺列表
         */
        @GetMapping("/of/type")
        public Result queryShopByType(
                @RequestParam("typeId") Integer typeId,
                @RequestParam(value = "current", defaultValue = "1") Integer current,
                @RequestParam(required = false) Double x,   //前端不一定基于位置信息查询,该请求参数可以不存在
                @RequestParam(required = false) Double y
        ) {
            return shopService.queryShopByType(typeId, current, x, y);
        }

           由于前端不一定会基于经纬度查询店铺信息,所以x&y参数不一定存在,因此我们需要为这两个请求参数加上@RequestParam(required = false),来告诉spring这两个参数不一定需要存在。

    Service

    /**
         * 根据商铺类型分页查询商铺信息
         * @param typeId 商铺类型
         * @param current 页码
         * @param x  经度
         * @param y  纬度
         * @return 商铺列表
         */
        @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、计算分页参数(查询redis时需要)
            int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
            int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
    
            //3、查询redis,按照距离排序&分页,结果:shopId,距离
            String key = RedisConstants.SHOP_GEO_KEY + typeId;
            GeoResults<RedisGeoCommands.GeoLocation<String>> shopLocations = redisTemplate.opsForGeo()
                    .search(
                            key,    //查询的商户类型
                            GeoReference.fromCoordinate(x, y),  //基于哪个坐标进行范围排序查询
                            new Distance(5000),  //以当前坐标5km范围进行搜索
                            RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance()    //让当前查询结果附带上距离查询坐标的距离
                                    .limit(end)     //查询从0~end的数据,不查询全部数据
                    );
    
            //3.1手动截取从from——end的数据
            if (shopLocations == null)
                return Result.ok();
            //3.1.1获取刚刚查询的结果,即包含shopId以及距离的列表
            List<GeoResult<RedisGeoCommands.GeoLocation<String>>> content = shopLocations.getContent();     //这是真正包含我们所需查询结果的对象
            //若没有下一页则无需继续
            if (content.size() <= from)
                return Result.ok();
            //3.1.2截取from-end(直接跳过from即可),并且收集shopId以及distance
            Map<String, Distance> map = new HashMap<>();    //用于匹配shopId以及对应distance的表
            List<Long> ids = new ArrayList<>();     //用于存储当前截取内容中shopId的列表
            content.stream().skip(from)
                    .forEach(
                            result -> {
                                //获取shopId
                                String shopId = result.getContent().getName();
                                //获取距离
                                Distance distance = result.getDistance();
                                //存储id,以及id与distance匹配的信息
                                ids.add(Long.valueOf(shopId));
                                map.put(shopId, distance);
                            }
                    );
    
            //4、解析id,根据id查询店铺
            String idStr = StrUtil.join(",", ids);  //ids的字符串形式
            List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
    
            //5、将店铺赋予对应的distance
            for (Shop shop : shops) {
                shop.setDistance(map.get(shop.getId().toString()).getValue());
            }
    
            return Result.ok(shops);
        }

    解析1

  • 由于我们需要结果按照距离大小进行排序,因此我们使用opsForGeo().search()方法,查询并返回指定key中的数据。
  • search()可以指定查询基于的坐标/圆心以及距离范围,即GeoReference.fromCoordinate(x, y)和new Distance(5000)。
  • 由于我们需要这个查询结果附带上查询的数据距离查询坐标的距离,所以我们需要使用RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs(),以便我们能够自行设置search查询的返回参数。我们想要查询结果具有距离参数,所以可以在刚刚的我们新建的参数列表中使用.includeDistance()来设置距离参数。
  • 同理的,我们想要这个查询结果只包含部分数据,因此我们在3的基础上使用.limit()来指定获取从0~end的数据。需要注意的是,该方法只能够查询从0开始到指定参数结尾的数据,而不能指定查询的起始参数,所以我们需要在后续进行手动截取。
  • .search()返回的参数是GeoResults,这是查询结果的封装,其中包含了GeoLocation、距离(若我们指定)以及其余参数。GeoResults集合的泛型一般是固定的,通常都是GeoLocation,即GeoResults<GeoLocation<T>>,其中T即GeoLocation中name的类型,在此处我们将GeoLocation的name定义为shopId,所以T应为String。GeoResults的详细内容如下:
  • 在解析1的第5条解析中我们有说到,GeoResults包含了许多内容,其中我们需要的查询结果对象应当为其中的content集合,该集合的元素才是包含了我们需要的shopId、distance的结果。因此,我们需要使用.getContent()从刚才的查询集合中获取我们真正需要的查询结果集。
  • 我们需要手动截取从from~end的数据,我们当然可以从from到end依次遍历获取。此处我们使用stream流提供的.skip()操作,直接从from后面开始截取。
  • 我们需要店铺的id集合来进行对数据库的批量查询,我们同时需要将对应的shopId和该shop与查询坐标的距离对应起来,因此我们指定List<Long> ids来接收shopId,Map<String, Distance> map来接收当前shop与该shop和查询坐标的距离。
  • 批量查询时,若直接使用listByIds()将会导致查询结果不会按照我们指定的ids进行排序,所以我们需要指定我们在数据库的查询结果需要按照指定的顺序返回。
  • 我们使用StrUtil.join(“”, ids)来将ids中的字符串进行拼接,然后强制让查询结果根据ids字符串顺序返回。如:ids = “1, 3, 2”,那么假设我们的查询结果原始顺序为1-2-3,那么将强制按照1-3-2的顺序进行处理并返回。
上述代码的bug分析

Bug演示

       当我们查询到最后一条数据时:

       查看服务端的报错:

报错原因分析

       虽然我们进行了非空判断来保证当前集合是存在元素的,但由于我们使用了skip操作进行截取,那么当from后没有元素时,即使保证了content存在元素,也会导致skip操作后收集到的集合为空,因为我们将所有元素都进行了跳过。那么此时,自然无法进行id字符串拼接,因此就出现了sql语法错误。

报错解决

       我们获取的content是从0~end的所有元素,那么我们可以想象,当content的大小甚至比from来的小,那么跳过from条数据后将不存在任何元素。因此我们只需要确保content的集合比from更大,这样就能够让from~end部分存在数据了。   

    用户签到

    BitMap用法

    签到功能
    需求

           由于当前用户可由localThread获取,当天信息可由javaAPI获取,因此签到接口前端无需传任何请求信息。

    实现思路

           我们可以统计当天对应的年月,作为对应的key,然后在这个key对应的bitmap中,按照当天对应当前月的偏移量(人话就是当天是这个月的第几天)进行对应位置的置1操作。

    代码实现

           需要注意的是,通过dayOfMonth获取的是正确的当天在本月的天数,即从1~31开始的数,然而bitmap中的位计算是从0开始的,所以指定位置时需要把dayOfMonth-1,才是存入bitmap的正确操作。

    签到统计——统计连续签到天数
    实现思路分析

           难点在于问题3:从后向前遍历bit位。因为使用BITFIELD获取的本月到当天为止的十进制版bitmap,虽然使用toBinaryString转为二进制然后再进行遍历也可行,但效率未免有点低;使用BITGET通过dayOfMonth依次遍历对redis访问过于频繁,内存压力又有点大。

           我们可以考虑通过与1进行与运算(&)来实现从后向前遍历。我们知道,任何数与1做与运算最终都将转为该数的最后一位和1进行与运算,因此每一次进行 该数&1 相对于获取最后一个bit位。那么遍历操作也十分简单,只需要将数右移一位,就可以让前一天与1进行与运算了。

    此思路的另一个前置条件是bitmap在存储时,越靠前的日期将会出现在越左边,因此最右边那一位永远是最后一天。

    接口需求

    代码实现

    代码解析
    解析1——通过查询redis获取签到情况

            redis命令为:BITFIELD KEY GET U(指定为无符号) OFFSET(获取几天) START(从哪开始),返回结果是一个十进制数。由于指定了u(unsigned),因此是无符号十进制。 bitfield有许多子命令(set/get),使用get命令进行查询的方法可以参考附近商铺中使用geo.search搜索店铺并且附带距离,即我们通过创建子命令:BitFieldSubCommands.create(),然后指定进行get操作,设置结果为无符号以及总共需要查询的天数:.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)),最后指定查询的起始日期为从第1天开始(在bitmap中体现为第0位):.valueAt(0))。 由于bitfield有许多操作,所以该操作的返回结果是一个List,其中包含了各个操作的返回结果。由于我们此处只进行了get操作,所以使用get(0)就相当于获取了get操作的结果,即无符号十进制数。 此处程序健壮性分为两部分,即:进行field操作是否得到了返回结果;field的get操作结果集中是否存在结果。

    解析2——遍历获取签到情况

    1. number & 1用于获取最后一天的签到情况,若结果为0则没有签到,循环结束;若结果为1,则当天有签到,继续判断前一天,number右移。
    2. 需要注意的是,右移操作有两种方式:>>和>>>,其中>>是会保留符号位的,而>>>是不保留符号位的,由于签到情况统计符号位无意义,因此要进行无符号位右移,即>>>。

    UV统计

    HyperLogLog用法

    实现UV统计

    测试前redis的内存占用情况

    单元测试以及统计结果

           为防止超出数组范围,我们使用j = I % 1000进行数组的循环赋值,且当数组存满,即j = 999时,将该数组批量插入到redis中。

           其中误差应为 1 - 997593 / 100000 = 0.002407,在可接受范围内(对我来说!)。

    测试后redis的内存占用情况

    1. number & 1用于获取最后一天的签到情况,若结果为0则没有签到,循环结束;若结果为1,则当天有签到,继续判断前一天,number右移。
    2. 需要注意的是,右移操作有两种方式:>>和>>>,其中>>是会保留符号位的,而>>>是不保留符号位的,由于签到情况统计符号位无意义,因此要进行无符号位右移,即>>>。

           仅仅多了不到(2041576 – 2027192) / 1024 = 14.046875KB,太牛逼啦!

    且重复插入时并不会添加新的元素

    Redis高级

    分布式缓存

    单点redis存在的问题

    Redis持久化
    RDB持久化
    RDB介绍

    RDB的fork原理

           子进程进行RDB操作的数据将会变为只读,这是为了避免主进程存在数据修改时修改了数据,造成持久化后数据不一致的情况。此时主进程将对只读文件的副本进行操作,后续子进程进行RDB时就会将新的副本进行持久化。

    RDB的总结

    AOF持久化
    AOF介绍

    RDB与AOF对比

    Redis主从
    搭建主从架构
    主从架构基本模式

    搭建主从架构(在一台虚拟机内搭建)

    详细内容见D:\develop\learning_information\Redis集群.md

    主从数据同步原理
    全量同步原理

    简要流程图一览

           全量同步即将master中所有内存数据交给slave,此时就需要用到RDB文件,因此涉及到master的主进程与子进程异步执行bgsave。由于子进程执行bgsave时主进程对数据操作是基于副本进行的,为保证slave最后拿到的数据与master一致,需要记录bgsave期间master主进程执行的命令,以便在slave获取RDB文件后进一步进行数据同步处理。

           由于生成RDB的期间会存在大量对磁盘的IO,因此对性能影响可能会非常之大。

    两个重要概念

    Master判断slave是否为第一次访问的流程

           当slave的replid与master不同时,就说明当前slave还没有与master建立链接。

    全量同步总结

    增量同步原理

    流程图一览

    问题分析

           我们可以将repl_baklog看作一个环形数组,当写满后会从头开始将前面的数据覆盖成新数据。那么当slave断开过久,master已经将slave上一次同步的数据全部覆盖了,那么slave与master的数据就不同步了,此时即使不是第一次访问master,也需要进行全量同步。

    优化主从模式

    数据同步总结

    Redis哨兵
    哨兵作用&原理
    哨兵作用

    注:通知还会将最新信息通知给java客户端,即告诉java客户端新的master节点地址。Java客户端无需知晓redis节点是否宕机、故障恢复过程,这些都由哨兵(sentinel)进行,java客户端只需要知道master节点地址以进行连接。

    哨兵原理

    服务状态监控——“投票机制”

    选举新的master

    故障转移——设置新的master节点

    哨兵搭建

    详细内容见D:\develop\learning_information\Redis集群.md

    使用RedisTemplate连接哨兵

    Redis分片集群

    搭建分片集群

    详细内容见D:\develop\learning_information\Redis集群.md

    散列插槽
    介绍

           为避免节点丢失直接导致数据丢失,我们不将数据与节点绑定,而与插槽绑定,这样当数据丢失时,可以将插槽转移给存活节点,数据并没有丢失。同理的,节点扩容时,也可以将插槽部分分配给新节点,避免了数据拷贝。

           当set/get时,通过对key进行的hash并取余获取slot值,然后redis会进行重定向,即确认当前节点slot是否与key的slot相符,若不符则自动转移到符合节点上。

    总结

    集群伸缩
    命令示例

    案例演示

    创建redis实例

           创建目录,拷贝配置文件,修改配置文件端口。

    添加7004到集群中

    插槽分配可通过该命令实现

    分配插槽

    1-指定分配的插槽所在集群:

    2-确认分配的插槽数量:

    3-指定分配给哪个节点:

           这是7004的ID信息。

    4-指定分配源,即插槽来自哪个节点:

           使用done即代表结束源节点信息录入。

    查看num的位置

           可以看到,查询num的时候,重定位到7004。

    故障转移
    自动转移——节点故障宕机时

    手动转移——节点维护升级时

    手动转移案例

    夺回master

    节点信息查看(通过redis-cli -p 7001 cluster nodes命令)

    RedisTemplate访问分片集群

    多级缓存
    多级缓存前瞻
    传统缓存存在的问题

    多级缓存解决方案预览

    JVM进程缓存
    导入商品查询案例(需要用到进程缓存)

    详细内容见D:\develop\learning_information\案例导入说明.md

    Caffeine入门
    缓存分类

    Caffeine介绍

    Caffeine示例

    基本存储

    测试代码

    测试结果

    驱逐策略

    基于容量

    基于时间

    假设不为其留存清理缓存时间

           由于caffeine需要在空闲时间进行缓存清理,此时我们未指定空闲时间,让该线程一次性执行完,所以此时缓存均未清理。

    实现进程缓存
    需求

    Cache配置注册

    通过CaffenieCache查询

           Caffeine的get(key, Function)会自动根据key查询缓存,若缓存中不存在则通过传入的Function查询数据库。且查询数据库所用的key实际上与查询缓存时是一致的,不过为lambda表达式不冲突,我们将id和key分开命名,一个用于查询cache,一个用于查询数据库。

    进程缓存的另一个思路(瑕疵)

           思路是建立一个统一的缓存工具类,可根据不同数据类型动态调整。但是由于Cache需要定义为全局变量,否则无法起到每次都往该容器放的效果,只能使用双Object作为泛型,失去了最初的意义,且可能导致类型强转出现问题。

    Lua语法入门
    说明

           由于我们想要实现多级缓存,即对tomcat、nginx等都建立各自缓存,所以需要专门为这些服务器进行缓存代码编写。对tomcat来说,上面的JVM进程缓存就是在为服务器添加缓存,我们通过java语言实现。但是对于nginx,由于nginx无法直接运行java代码,因此我们需要通过lua语言对nginx进行缓存代码编写。

    初识lua
    重要概念

    函数是值。

    简介

    快速编写一个lua程序

    变量和循环
    Lua的数据类型

    变量

           若要声明全局变量,直接  变量名=变量值  即可。

    循环

    条件控制、函数
    函数

    条件控制

           在lua中,nil即为布尔的false值。

    一个案例
    需求

    代码

           当map非空,if(map)将为true。故map为空时,if(not map)将为true。

    多级缓存
    安装OpenResty

    详细内容见D:\develop\learning_information\安装OpenResty.md

    请注意:虚拟机的nginx端口务必开放,否则无法访问!

    OpenResty快速入门
    介绍

    案例需求

    需求实现

    修改nginx配置文件

    本机的nginx.conf

           这是window上nginx配置的部分内容,该配置的主要目的是将本机接收的请求转发到地址为192.168.71.10虚拟机的8081端口进行处理。

    修改OpenResty的conf文件

           由于本机请求被转发到了虚拟机,因此我们在虚拟机的nginx配置中添加对lua、c等模块的加载,以便在nginx中使用lua。

           图中白色部分的解析如下:

    1. location /api/item是用于指定监听路径,当监听到/api/item相关的路径时,就会根据内部的方法实现对监听路径请求的响应。
    2. default_type指定了响应类型,若未指定则默认以json格式返回。
    3. content_by_lua_file指定了响应结果将会根据lua文件进行处理,且具体依据的时lua/item.lua中的逻辑。

    编写lua.item文件响应请求

    在OpenResty的nginx目录下新建lua目录,并在该目录下创建item.lua文件

    获取假数据

           在任意一个商品中,复制item内的内容即可。注:此方法的前提是浏览器添加了vue相关插件!

    在item.lua文件中封装响应请求的方法

           ngx.say()方法类似于response,可以类比于就是将数据放入response中响应给前端,不过此时是放在nginx中。由于是假数据,所以我们直接添加具体商品的json代码即可。

           同时注意,我将行李箱尺寸改为了29,而非原本的21寸!

    重新加载nginx

    查看测试结果

           可以看到,尺寸变为了29,说明本机请求被转发到虚拟机nginx后,由nginx中lua进行了数据响应,且浏览器成功接收了修改后的数据!

    通过OpenResty获取并处理请求参数
    获取请求参数

    案例需求

    需求实现

    在OpenResty的nginx.conf文件添加路径请求参数接收

           通过正则表达式“\d+”可以获取任意数值类型参数,并且存入ngx.var数组中。元素顺序按匹配顺序存入。

    在item.lua中获取请求参数并且拼接到响应结果中

           在lua中,使用..进行拼接。’str1’..id..’str2’就是将str1、传入的id(变量)、str2进行拼接。

    重新加载nginx并打开网页查看

           可以看到,id随着传入id发生了变化。

    查询Tomcat
    目的分析

           虽然我们是通过OpenResty向redis缓存发起查询,但是首先需要先查询tomcat,将tomcat数据存入redis中,才能实现缓存查询。因此,通过OpenResty向tomcat直接发起请求进行查询是必须的。

    案例需求

    需求实现

    提示

           请在案例测试前,确保:本机nginx服务启动,虚拟机nginx服务启动,mysql服务启动(位于虚拟机上)!!!确保:本机浏览器可以通过防火墙,虚拟机防火墙关闭!!!此外,每一次修改完OpenResty的nginx配置,都请务必重新加载nginx(通过nginx -s reload)!!!

    Nginx内部发送http请求

    相关API介绍

           实际上这个请求是被nginx自己捕获,然后由nginx自己反向代理发送给tomcat服务器。

    更改OpenResty中的nginx文件,让其能够接收并发送http请求

           当拦截到item请求后,nginx会反向代理将请求发送给本机的tomcat服务器。对于虚拟机与主机ip而言,当防火墙等均关闭情况下,保证前三位ip地址相同(其实就是网段一致),最后一位为1的一定是本机ip。

    封装http查询的函数

    对函数的理解

    1. 由于路径与请求参数需要动态接收,所以将二者定义为函数形参,即path——路径、params——请求参数。
    2. 通过ngx.location.capture方法可以去向tomcat发出请求,即通过指定path解析请求路径的参数,然后通过请求的类型、请求的参数去查询结果,最后将结果封装后交给resp变量。可以类比controller接收请求,server & mapper处理业务并封装结果,最后由controller将响应结果返回给前端。
    3. 当resp为nil,即未查询到信息,即返回404;当resp有结果,则将resp.body,即将响应结果返回给前端。
    4. 最后四行代码的目的在于:让拿到common.lua的使用者,可以获取read_http方法并且直接使用。返回的_M对象实际上就是一个包含了read_http方法的方法集合,即一个table。后续可以通过导入common.lua,获取其中的table,通过table属性进行函数调用。我们可以理解为,这个common.lua脚本返回对象为一个包含内部所有方法的一个table。

    封装工具类

    使用封装后的工具向tomcat发送请求

    这是我们在nginx.conf中的定义

           即使用item.lua返回响应结果。

    让Item.lua查询并将数据返回给nginx

    Version1

    我们先前在nginx.conf配置中定义了能被导入的lua模块的来源,目录为“/usr/local/openresty/lualib/?.lua;;”,这表示我们引入的任何lua模块均需要来自于该目录下。

    若导入文件是直接位于该目录下的lua文件,此时直接写出文件名即可;但若该文件是lualib下的文件夹中的文件,则需要指明具体路径!

    Version1的测试结果

           可以看到,数据被动态查询,不再是之前的假数据了。

    对version1的分析——JSON结果处理

           由于在实际业务场景中,我们进行了数据库规范化处理,导致了一条结果通常由多个表的记录构成。

           但由于我们获取的将是多个JSON数据,而JSON数据是无法进行拼装的,因此我们需要进行反序列化为lua对象后再拼装返回。

           这实际上与我们先前基于简单controller-service-mapper架构的项目逻辑是一致的,通过查询多个表的内容,然后将其封装为一个新的VO对象,将VO对象返回给前端。只不过此处我们是向tomcat发送的请求,因此tomcat响应的对象为JSON对象,而不像直接查询数据库获取的实体对象。

    Version2

           步骤为:导入cjson库——反序列化tomcat响应的json对象——将item对象通过stock对象进行补齐(item对象实体包含了stock的两个字段,但为满足多值依赖的4nf,我们进行拆表)——将item序列化为json——通过ngx.say响应item的json结果。

    Version2的测试结果

           可以看到,sold、stock字段此时均有值。

    根据商品id对tomcat集群进行负载均衡处理
    场景分析

           实际业务场景中,tomcat服务器通常会是一个集群,这时就要求nginx请求服务器时进行负载均衡,轮询集群中的服务器,以避免某个服务器请求量过大导致崩溃。

           但是我们知道,tomcat进程是不能共享的,也就是说对于id=1的商品在tomcat1上有缓存,但若轮询到tomcat2时,是不存在关于该商品相关信息的。因此若想要对于某个商品每次查询都能保证命中缓存,需要确保对该商品的请求每次都能分配到相同的tomcat服务器上。

           我们可以考虑对负载均衡算法进行优化:我们可以对商品id进行hash处理,通过hash结果去分配tomcat服务器。由于商品id未变化的情况下,hash结果将是固定的,因此分配的tomcat服务器也将是固定的。Nginx为我们提供了这样的算法:hash $request_uri。他会根据请求的uri进行hash,而由于除了商品id外的请求路径均相同,这就相对于基于商品id进行了hash运算。

    负载均衡实现(对OpenResty的nginx.conf配置进行修改)

    创建一个tomcat集群,指定轮询算法

    将拦截请求分配到对应tomcat上

    负载均衡结果测试

    首先创建一个端口为8082的tomcat服务

           实际上就是把原服务copy然后改了下端口。

    查询id=10001的商品并查看控制台

           可以看到,请求被转发到了8082的tomcat服务器。

    重新查询id=10001的商品并再次查看控制台

           按照原始算法,此次请求依照轮询应当查询8081,且8081是没有缓存的。但显然,此时查询到的是8082,因为8082是有缓存的,无需查询数据库,因此没有日志显示。

    Redis缓存预热
    冷启动VS缓存预热

    在docker容器上新安装一个redis

    在完成redis相关配置情况下进行缓存预热

    1. InitializingBean接口中的afterPropertiesSet方法能够使得实现该接口的类在创建Bean对象后就立即执行方法内逻辑。效果实际与使用@PostConstruct注解类似。需要注意,实现该接口的类必须声明为一个Bean对象。
    2. ObjectMapper是spring默认的JSON处理工具,可以实现hutool包下的JSONUtil类似的效果。相同的工具包还有FastJSON等。
    启动项目查看测试结果

    查询Redis缓存
    OpenResty的Redis模块

    对common.lua文件进行修改,封装查询redis函数,并将该函数作为该类的属性返回

    将这些内容写入到OpenResty的common.lua文件中

    如有必要,请在建立redis连接后进行密码认证

    暴露给该类的返回对象,便于后续在其他lua文件中调用

           不要忘了键值对之间要用“,”进行分割!

    修改item.lua文件,让查询时优先查询redis缓存,未命中再查询tomcat服务器

    封装read_data函数

    调用read_data函数完成查询item和stock

    修改完所有配置后,切记对nginx进行重启

           通过“nginx -s reload”。

    结果测试

           可以发现,在服务暂停后,OpenResty仍可以直接通过查询redis中已有的数据完成缓存查询。

    Nginx本地缓存

    OpenResty提供的实现本地缓存的方法

    在nginx.conf中开启共享字典

    在item.lua中导入共享词典,在业务处理时先查询共享词典

    查询顺序变更案例

    修改查询函数逻辑

           变更后逻辑为:查询共享词典是否有数据(本地缓存),未命中则查询redis,redis再未命中则去查询tomcat。在获取了查询结果后,我们需要将结果存入本地缓存,并且设置有效期。假设该数据为重复查询,此操作就相当于重置有效期。

    修改查询逻辑,传入有效期

    查看本地缓存是否生效

           可以看到,在redis服务无10003信息且tomcat服务器关闭的情况下,仍然能够查询到id=10003的相关内容,说明该内容已经存入了本地缓存中。

    缓存同步策略
    数据同步策略

    Canal简介

    安装Canal

    详细内容见D:\develop\learning_information\安装Canal.md

    监听Canal
    Canal的java客户端

    相关配置

    通过Canal监听数据库操作

    案例实践

    引入相关依赖

    为canal客户端添加相关配置

    注意事项

    这是我们安装canal服务时指定的相关信息:

           Yml中的destination必须与我们安装canal服务时指定的canal名称一致!且服务地址为安装canal的虚拟机地址加上指定端口,此处指定为11111。

    修改item实体类,让canal-client可以将数据进行封装

    编写CanalHandler,监听数据库并实现数据同步

           通过实现EntryHandler这个接口并通过@CanalTable指定数据库表,就可以在指定数据库表发生变动时执行对应的业务逻辑了。

           此外,将进程缓存相关逻辑优先执行,有助于提升程序运行效率。

    结果测试

    启动服务,查看控制台

           可以看到,我们的java已经与canal服务建立了连接。

    该项目提供了静态资源,让我们可以访问localhost:8081直接对商品进行修改

    Redis优化

    Redis键值设计
    优雅的key结构

    拒绝BigKey
    什么是BigKey

    BigKey的危害

    如何发现BigKey

    如何删除BigKey

    恰当的数据类型
    例1:user对象存储数据结构选取

    例2:100w键值对的hash数据结构的问题分析与优化

    问题分析

    解决方案分析

    拆分为string存储

           虽然解决了BigKey问题,但是内存占用明显增大了。

    将hash通过%n进行分散,变为m个hash

           只要让n < hash-max-ziplist-entries,即可满足每个hash底层均使用ziplist存储。

    总结

    批处理优化
    大量数据处理的分析与对比
    每次执行一条命令

           若我们每次执行一条命令(比如set操作),由于redis服务端与客户端需要建立网络连接,然后再执行redis命令,且网络连接建立通常是极为耗时的(单位通常在ms),所以当很多redis命令需要执行时,此模型将会极为耗时!

    每次执行一批命令

           我们一次发送多条命令到redis客户端,让redis客户端一次性执行完这批命令并且打包后一起返回,这样就只需要进行一次网络传输,效率大大提升!

    Redis提供的批处理方案
    MSET

           原子化操作,但是需要注意,MSET只能处理特定类型数据,对于复杂数据批处理将无法胜任。

    Pipeline

           我们需要明确,mset是原子操作,也就是说在发送到客户端后,客户端会全部执行完,不会被别的线程插队。但是pipeline在执行操作时,相当于将这些redis命令放入管道中,因此一批redis命令放入管道过程中,可能存在被别的线程插队的情况。

    二者对比

    集群模式下的批处理

           推荐使用spring提供的批处理API进行操作,尽量避免自行编写。

    服务端优化
    持久化配置
    持久化建议

    关于第5点的解释

           当AOF操作进行时,主线程会将本次操作写入AOF缓冲区,由AOF开启一个同步线程将数据同步到磁盘(即刷盘操作,也称fsync)。当刷盘时间大于2s,主线程将阻塞,等到同步线程完成fsync操作,在此期间,主线程将无法接收新命令。

           若此时我们正在执行RDB或其余操纵,将很可能导致AOF操作被阻塞,从而影响redis服务的性能。这就是为什么我们需要在这些操作进行时禁止AOF操作。

           但同时,这种配置也会带来问题:当进行RDB操作时,无法进行AOF,那么这期间的操作记录将会丢失,若系统崩溃,可能会导致数据丢失。

           因此,若追求性能,则在此期间关闭AOF操作;若追求数据持久,则AOF需要开启。

    Redis部署建议——减轻持久化压力

    慢查询
    慢查询介绍

    慢查询相关参数

    参数说明

    查看/调整相关参数

    通过“config get 参数名”进行查看

    通过“config set 参数名 参数值”进行调整

    查看慢查询日志

    命令及安全配置
    安全问题分析

    安全建议

    内存配置
    Redis内存占用分类

    查看redis的内存分配情况

    内存缓冲区分类以及默认配置

           我们注意到,redis普通客户端并没有设置缓冲区上限,这是极为危险的。我们当然可以通过手动设置上限进行调整,但更重要的是从根源去预防。

           Redis缓冲区被占满主要有两种情况:1、redis中的BigKey过多,2、redis所在物理机的带宽过低。因此,拒绝BigKey将是解决此问题的核心,且若物理机带宽过低时,我们也应当适当增加带宽,提前进行预防。

    集群最佳实践
    集群模式存在的问题
    集群完整性问题

    集群带宽问题

    数据倾斜问题

    客户端性能问题

           我们建立集群模式后,当我们通过jeddis等客户端去访问redis集群时,就需要进行节点选择、读写分离判断、slot分配判断等一系列操作,这将会对客户端性能产生影响。

    命令的集群兼容性问题

           集群模式下,redis原生的批处理操作(MSET、pipeline)均无法正常执行,因为无法保证数据落入同一slot。虽然可以通过一系列(hash_tag、并行slog等)进行解决,但都需要自行编码,且进行数据slot分配的相关判断,仍旧会对redis性能产生影响。

    Lua和事务问题

           由于lua与事务均为原子性操作,所以可以类比批处理,当执行的命令无法落入同一slot,将会无法执行。所以集群模式下,一般来说,lua脚本与事务均无法执行。

    主从or集群

           面试造火箭,实际公司的QPS基本无法达到如此之高,因此集群建立并非是必要的。当主从模式可以使用时,尽量避免集群的搭建。

    Redis原理

    Redis数据结构

    Redis底层数据结构
    动态字符串SDS
    C语言字符串存在的问题 & Redis的解决方案

           非二进制安全的解释如下:c语言中,若一个字符串为”stri\0ng”,那么实际的字符数组就是{“s”, “t”, “r”, “i”, “\0”, “n”, “g”, “\0”}。C语言读取字符串是以字符串终结表示进行读取的,由于”\0”表示字符串的终结,所以这个字符串实际上会在第一个”\0”位置结束,即:“stri”。

    SDS介绍

    SDS极其重要,redis中的键均为SDS类型,且AOF缓冲区等也都是由SDS组成。对于SDS的字符串,我们可以注意到其header部分有”len”和”alloc”,其中”len”表示当前字符串长度,”alloc”表示为其分配的空间,且”alloc”是不包含字符串结束标识的(与c语言不同)。在字符串读取时,将会根据”len”进行遍历,即使”stri\0ng”中间包含”\0”,但其len=7,因此不会出现读到”\0”就提前中止,保证了二进制安全。

    SDS的动态扩容

           内存预分配解释如下:一般来说redis是部署在linux系统的,而linux系统分为用户态和内核态,对于内存的分配将会通过将当前用户态切换为内核态以实现对硬件的操作,也就是说无法直接通过用户态去操作内存。内存预分配实际上就是提前扩大容量,以减少用户态和内核态之间的切换,以增加redis的效率。

    总结

    IntSet
    IntSet介绍

           我们需要知道,用于存放数据的contents数组作用在于指向数组的第一个元素地址,数组中的元素大小并不由其进行控制,而是由encoding进行控制。

    IntSet寻址分析

           我们可能会有疑惑:为什么数组中每个元素的分配空间大小需要是相同的?以下是解释:图中是基于int16_t(即每个元素占用2Byte)的IntSet,假设第一个元素地址是0x001,那么由于每个元素占用字节均相同,那么查找数组中某个索引的元素时只需要从起始地址开始,加上数组索引*占用大小即可。总结即:能够有统一的寻址公式,便于查找元素。

    IntSet升级

           倒序拷贝的目的:以图中为例,元素20(角标为2)计算出新地址为0 + 2 * 4 = 8后,就把前面的地址腾出,让10可以直接计算并放入。若顺序拷贝,则会导致5计算新地址后会占用10、20的空间,每次拷贝都需要将后续元素进行移动,这无疑十分繁琐。

    总结

           由于底层基于二分查找方式进行查询,且intset本身是有序的,因此该结构使用与数据量不大的情况。

    Dict
    Dict介绍

           Dict中实际存储信息的是dictht结构,而该结构又是通过内部的table数组进行真正的entry存储。table是一个存放dictEntry结构的数组,其角标表示存入这个位置的dictEntry的hash值,每个位置都存放了第一个拥有该hash值的dictEntry的地址。由于dict通过链表解决了hash冲突,所以dictEntry结构中包含了*next,用于查找相邻的拥有相同hash值的键值对的地址。

           之所以Dict中定义了两个dictht结构,是因为rehash时需要用到两个dictht进行数据的hash值更新,下文会提及。

    Dict键值对存放逻辑

           由于链表特征是无法通过公式推导出下一个元素地址,所以若将新元素添加到队尾势必需要进行遍历(不考虑存储队尾节点,redis并未如此设计),会很麻烦。因此redis在设计哈希冲突时,是将新元素直接插入队首,解决了遍历问题。

    Dict伸缩

    扩容

    收缩

    Dict的rehash

           Dict的伸缩存在一个问题:在dict大小发生变化后,原先key的hash值将会有极大概率与之前的不同,那么我们还如何查找原先数据呢?若使用新的keyhash进行查找,那么原先程序的查找功能也将需要进行改写,这是不合理的。因此我们需要通过某种手段,使得key仍然能够保持原先的hash值,而redis给出的解决方案就是rehash。

           此步骤的目的是确保原来的key在新dict大小的情况下仍旧拥有与旧dict相同的hash值。ht[0]保存的是旧dict数据,ht[1]保存的是新dict数据。由于dict中数据量可能会非常之大,因此不可能在每次需要伸缩时都将整个数据进行迁移,而是每次迁移ht[0]一个角标位置的键值对链表,这就被称为渐进式rehash。

           由于在一个Dict中,为了伸缩功能有两个ht数组,因此在查找、删除、修改时均需要扫描新旧两个数组,来确定目标key的位置。此外,由于新增操作不涉及key的迁移,所以该操作只需要将新键值对放入ht[1]中即可。当最终ht[0]变为空时,我们可以认定此时旧数据全部迁移完成,维护ht[0]和ht[1](即将新dict赋值给旧dict,然后将新dict制空),以便下一次的数据迁移。

    总结

    ZipList
    ZipList介绍

           ZipList的内存压缩是通过存储节点长度信息作为寻址依据而实现的。传统链表因为存储指针,将会产生较大的内存占用,而ZipList由于存储的是节点长度信息,占用字节数必然是没有指针那么多的。

           但尽管如此也应当注意,不应该在ZipList中存入过多信息,否则虽然ZipList实现了内存压缩,但由于其内部元素数量过多,仍会造成BigKey问题。

    ZipList中的Entry结构

           我们可以将entry理解为一个模拟链表,它并没有设计直接指向前一个和后一个节点地址的指针,而是通过节点长度进行推算,这个计算的底层原因是ZipList数据结构的内存空间是连续的。

           entry的整体大小是前一个节点长度(previous_entry_length) + 编码属性长度(encoding)+ 当前节点数据长度(contents) ,而encoding属性中会记录该entry节点数据长度。因此在知道上一个节点的位置后,就可以通过上一个节点的大小计算出当前节点的地址信息,反之同理。

    Entry编码解析

    content为字符串

    encoding构成

    案例:保存”ab”和”bc”两个字符串,分析ZipList构成

    “ab”的entry构成分析

           首先对于previous_entry_length,由于”ab”为起始的entry,因此前一个entry_length = 0,二进制表示即:00000000。

           然后对于encoding,由于”ab”是字符串,且长度为2bytes,因此encoding采用00开头,且后几位代表了该encoding二进制值为2,即00(表示字符串)000010(表示字符串长度)。

    最后对于content:对于”a”而言,其ASCII为98,因此二进制表示即01100001,”b”则为01100010,二者共同构成content = 01100001 01100010。

    因此综上,构成的entry可以表示为:00000000(previous_entry_length) 00000010(encoding) 01100001 01100010(content)。

           可以使用16进制对其进行表示:

    ”bc”的entry构成分析

           实际上可以类比”ab”的构成,即encoding为00000001,content为01100011(”c”) 01100100(”d”),但需要注意,由于”ab”的entry已经占用了32bytes(即4Bytes),因此”cd”的previous_length应当为00100000。

           最终使用16进制表示为:

          

    最终的ZipList构成

           除去entry外,其余部分所占的字节数是不变的。需要注意,ZipList编码遵循小端原则,所以低位在前高位在后。

    Content为整数

    encoding构成

    案例

           同”ab”和”cd”组成,此处不再具体分析。

    ZipList连锁更新问题

           此问题出现要求较为苛刻,需要由多个连续的长度几乎占满254字节的entry。当entry长度未超过254时,previous_entry_length使用一个字节就可以存储长度信息。此时当某一个entry进行了新增导致长度超过254字节,previoust_entry_length就无法仅使用一个字节了,那么整体entry的长度就会变化。那如果当前节点非常不巧的位于这些节点中段,就会导致后续节点全部都要改变前者pre_entry_len。

    总结

    QuickList
    ZipList的问题 & QuickList的产生

    QuickList防止ZipList变成BigKey的方案

    QuickList对ZipList的压缩

    QuickList数据结构展示

    总结

           链表是一个内存空间不连续的结构,因此理论上可以存放很多元素,但由于需要通过存放地址信息进行连接,所以内存占用可能会很大;ZipList通过存放节点长度来代替指针进行节点连接,但由于ZipList是内存空间连续的数据结构,当其中entry过多时,将造成内存分配效率过低。

           而QuickList通过将一个可能存放很多信息的ZipList拆分,然后使用链表进行多个相同业务的ZipList连接的方式,解决了只是用链表带来的内存占用问题和只使用ZipList导致的内存分配问题。此外,QuickList可以对内部的ZipList进一步压缩,更加节省了内存。

    SkipList
    SkipList介绍

    SkipList数据结构构成

           SkipList每个节点都包含当前节点元素以及所处层级,层级之间又通过当前层级的链表进行连接,这就使得SkipList不仅可以顺序查找,也可以通过层级之间的跳转缩小查找范围(如查找10,找到的遍历到的两个四级节点中元素分别为7 & 17,那么就可以查找元素7所在的三级节点…),这可以实现接近于红黑树的查找效率。

    总结

           顺序查找时,通过层级进行查找;反向查找时,则需要从尾开始遍历,因为层级链表是单向链表。

    RedisObject
    RedisObject结构

    RedisObject的encoding
    encoding总览

    Redis五种数据结构对应的encoding

    五种数据结构
    String
    String常见的编码格式总览

    不同编码格式中的内存结构(RedisObject与SDS间的关系)

    RAW

           RAW类型的string数据中,RedisObject与SDS位于两个独立的内存区域,通过RedisObject中的ptr指针(指向实际的底层数据结构)与SDS连接。

    因此,每存储一个RAW类型的string数据,均需要先申请RedisObject的内存空间,再申请SDS的内存空间,然后将RO的内存空间与SDS内存空间连接,涉及到多次的用户态与内核态切换(因为申请内存无法通过用户态进行),效率是较低的。且由于RAW类型的string数据长度一般较大,若存储过多很容易形成BigKey问题。

    EMBSTR

           此时,RedisObject与SDS是连续的内存空间,无需通过寻址操作找寻数据地址,因此在存储EMBSTR类型的string数据时,只需要申请一次内存空间即可,大大增加了内存分配的效率。且由于本身字符串长度不大,不容易形成BigKey的问题。

    INT

           当存储字符串是整数且大小不超过LONG_MAX时,可以直接将数据存储在RedisObject的ptr处,此时无需寻址即可找到数据,甚至不需要申请SDS的空间。

    List
    List底层数据结构选取分析

           要双端访问且存储上限尽可能高,内存占用又不能过大,就需要把linkedList & ZipList的优点结合起来,那么答案显而易见了——QuickList,本身节点基于链表存储,可以存储很多个ZipList并且可以将这些ZipList相连,且不会占用过多内存,又可以对ZipList进行压缩,减少其连续内存空间分配,非常完美。

    不同版本的List底层实现

    使用QuickList实现的List内存结构

    Set
    Set底层数据结构选取分析

           不使用SkipList的原因为:Set结构不要求数据的有序性,且数据存储类型不唯一,那么SkipList就无法比较各个数据的大小来建立多级链表。

    Set底层实现

    选取编码格式的方法实现

    更换编码格式的方法实现

           由于intSet存储时需要保证数据类型均为整数且存储大小不能超过上限,因此每次存储时均需要检查是否需要进行存储格式转换。

    Set的内存结构图

    ZSet
    ZSet底层数据结构选取分析

           由于SkipList根据score排序,且根据score进行查找,虽然查找效率很高,但是无法实现我们通过menber查找对应score的需求(score—>member可行,member—>score不可行);dict可以根据键值存储,能够实现member——>score的需求,但是查找效率较低。

           因此,ZSet底层选择二者结合,dict用于根据member找score的情况,skipList用于根据排序查找的情况。

    ZSet的内存结构图

    采用Dict+SkipList编码

           可以看到,ZSet使用了dict和skiplist以实现键值查找+排序的功能,但这无疑带来了一个问题——内存消耗未免过于大了,当数据个数不多的情况下,这显然是在浪费内存空间!

    采用ZipList编码

           使用ZipList存储时,由于ZipList内存空间连续且需要保证键值对的特性,所以将element和score相邻存储,按照score进行排序。在查找时,将通过遍历的方式进行查找。

    ZSet底层实现

    ZSet初始化的编码格式选取

           既然ZSet编码方式是基于zset_max_ziplist_entries & zset_max_ziplist_value来进行选取的,那么创建时,就会通过这两个值进行判断:若初始时设置了zset_max_ziplist_entries=0,就代表了我们想要禁用使用ziplist编码存储,将会直接采用dict+skiplist存储;若初始化时传入的元素大小已经超过zset_max_ziplist_value,此时同样也会采用dict+skiplist存储。

    ZSet的编码格式转换

           在每次插入元素时,若一开始使用ziplist编码进行存储,就需要考虑元素是否超过设定的上限,若超过就需要及时升级编码为dict+skiplist,避免ziplist长度过长。

    Hash
    Hash底层数据结构选取分析

           Hash的需求与ZSet实际上是非常相似的,都需要根据键值对进行查找(ZSet是member<—>score,Hash是key—>value),因此可以选取类似的底层数据结构。由于ZSet中的skiplist是用于排序的(即score—>member),而Hash并无此需求,因此可以将skiplist去除。

           当元素不多的情况下,为了节省内存空间,使用ZipList存储以减少指针的使用;当元素增多时,为避免ZipList内存分配效率过低,采用dict存储。

    Hash的内存结构图

    采用ZipList编码

    采用Dict编码

    Hash底层实现

    Hash初始化的编码格式选取

           Hash初始化时默认采用ZipList编码进行存储,即存储第一个元素时,若该key未生成RedisObject,则会默认生成一个编码为ZipList的RedisObject,然后将第一个键值对进行存储。

    但由于这次命令可能会添加很多的元素,我们如果在每次添加后再进行判断是否要将ZipList转为Dict编码,会降低程序效率,因此我们提前获取这批数据的信息,判断对于这批数据存储时后续是否需要转换为Dict编码,若需要则提前进行转换。

    Hash的编码格式转换

           此方法会遍历命令字符串的value部分(即真正需要转为hash结构存储的部分),判断其中value或者元素个数是否超出ZipList上限,然后进行转码。当然,若一开始时就是Dict结构,那么就无需进行转码。

    hset操作底层实现

           原先的ht中有key,就将value进行覆盖;没有则将key-value存入。新元素添加后,若采用ZipList编码,则需要检查是否超出其上限,若超出则转换为ht编码。

    Redis网络模型

    用户空间 & 内核空间

    五种IO模型

    阻塞IO

    非阻塞IO

    IO多路复用
    介绍

           直接使用recvfrom时,阻塞IO和非阻塞IO均会等到有数据后才会进行下一步操作,那么我们是否可以让IO读取已经准备好的数据,避免无数据时的忙等呢?这就是多路复用IO的目的,通过获取已有的数据避免忙等。

    实现思路

           用户应用通过某个API去监听内核中的文件描述符FD,当某些FD有数据信息后,用户应用再调用recvfrom直接从已有数据的FD中进行读取。与阻塞/非阻塞IO不同的地方在于,这两种方式只能等到recvfrom的FD有数据后才能进行后续流,即便其余的FD有数据也将持续等待;多路复用IO则是哪个FD有数据就告诉用户应用去读去,这样可以保证调用recvfrom时获取的一定是有数据的FD,不用忙等。

           简而言之,就是用户应用告诉内核空间我需要监听的数据(FD),由内核空间查看这些FD是否有已就绪,有则返回给用户应用,没有就由内核空间持续监听。这避免了用户应用等待的FD一直没有数据,导致其余可以使用的FD无法被读取而导致CPU利用不充分。

    监听FD的方法

    select

    1. Select监听涉及到两次fd_set的拷贝:(1)用户空间创建完需要监听的fd_set后拷贝给内核空间,让内核空间进行监听(2)内核空间本次监听完成,将就绪的fd标记为1,将包含就绪信息的fd_set拷贝回用户空间,让用户空间遍历调用recvfrom获取。由于select操作将是不断进行的,每次进行操作都要两次拷贝,这是影响selectt操作性能的原因之一。
    2. 此外需要注意的是,select操作中,内核只会告诉用户空间就绪fd的个数,并不会告诉用户空间是哪个索引下的fd准备就绪,因此用户空间需要遍历以获取就绪的fd,而每次select完后用户空间都需要遍历fd_set,这是影响selectt操作性能的原因之二。
    3. 最后,由于fd_set底层是通过一个1024位的bitmap进行fd就绪状态的存储,且该bitmap大小是源码中的常量,因此无法改变,这就导致了select操作能够监听的fd数量不是很多,这是影响select操作性能的原因之三。

    poll

           poll模式仅针对select的监听fd上限进行了改善,使用链表进行存储,理论上使得监听的fd可以没有上限。尽管poll模式去除了监听上限,但如果监听的fd越来越多,二次拷贝、用户空间遍历fd数组以发现就绪fd仍需要执行,甚至可能会更加影响IO的性能。

    epoll

    1. epoll_ctl函数实际上就是用于将用户空间要监听的fds拷贝到内核空间,而该操作只会进行一次,在后续相同用户空间等待IO时,无需再次拷贝。拷贝到内核空间中,会分为两个区域:存储全部需要监听的fd区域(红黑树结构存储)和存储就绪fd区域(链表存储)。
    2. epoll_wait函数是用于监听并返回就绪fd给用户空间的,该操作只会将就绪的fd拷贝给用户空间,即通过将就绪fd链表存放到events数组返回给用户空间。
    3. epoll与select、poll两个操作的区别在于,epoll操作实际上是将拷贝fds到用户空间和监听就绪fds返回给用户空间两个操作分开了。在select、poll操作中,每次监听都涉及到拷贝&返回,而在epoll操作中,只需要在用户空间请求监听时将fds拷贝到内核空间即可,后续无需再次进行。且epoll操作不再将全部fds返回给用户空间,而是将已就绪的fds返回,用户空间拿到即可读取,无需遍历判断是否有就绪。
    4. epoll模式通过红黑树接收用户空间传递的fds,由于红黑树自身的平衡调整机制,内核空间遍历fds的性能将会大大提升(与poll的链表遍历简直一个天一个地),因此解决了poll模式理论上无限但实际上不能过多的可被监听的fd数量。

    总结

    epoll机制深入

    事件通知机制

    1. ET模式下,内核空间将就绪fd放入用户空间后就会将fd从就绪链表中删除,假设此时用户空间没有处理这些数据或没有一次性处理完全部数据,由于内核就绪fd链表中没有元素了,在下一次请求内核空间传递就绪fd时将无法接收任何信息,那么就会使得这次数据丢失。因此,ET模式最好结合非阻塞IO使用。
    2. LT模式下,当就绪队列中存在元素时将会唤醒阻塞的用户进程进行数据读取。假设有很多用户进程阻塞,但是这个数据不需要那么多的用户进程读取,那么就绪队列将会唤醒许多没必要唤醒的进程,这就叫做惊群现象。且由于LT多次通知,可能会对性能造成一些影响。

    基于epoll的web服务端流程

           首先需要清楚,web服务是IO密集型操作,比如对套接字(socket)的读写(写token到请求头等)、http请求响应都是通过网络进行数据传输。其次,web服务中的socket大致可以被分为两种类型:服务端socket(即serverSocket)和客户端socket,其中serverSocket专门用于建立连接,客户端socket用于数据传输。

           Linux系统中,基于epoll的web操作实际上是将serverSocket(套接字)等web相关数据作为文件,为其设置FD。此时,FD就绪将被分为两种情况:服务端socket(后续称为ssfd)就绪和客户端socket就绪。

    我们知道,一个web服务若要进行数据传输,首先要进行连接建立,所以我们可以首先明确,客户端socket使用需要建立在serverSocet已经就绪的基础之上。当epoll模式开始监听时,若存在FD就绪且IO事件类型为读事件(即EPOLLIN)时,就需要检查FD类型。若是ssfd就绪,则说明此时服务端已经准备好与客户端建立连接,因为serverSocket已经准备好将数据读入(客户端Socket信息);若就绪FD是客户端类型,就说明此时可以将服务端数据读取并写入响应中。

    信号驱动IO

           可以理解为用户应用订阅了内核空间关于数据是否就绪的信号,当数据就绪时用户空间接收信号,然后进行读取;当数据未就绪时,用户应用可以进行其余操作,无需反复请求数据。

           但信号存储是使用队列的,当IO请求过多将可能造成信号队列溢出,导致某些IO请求无法收到数据。且此时,内核需要根据不同用户应用反复发出信号,也就是说此时内核与用户空间正不断进行交互,性能将会收到影响。

    异步IO

           异步IO时用户应用是非阻塞的,可以将数据处理过程(等待数据 & 将数据拷贝至用户空间)全部交给内核空间执行,自己去进行别的操作。

    但处于高并发的情况下,用户应用将许多的异步IO请求发送到内核空间,对于内核空间的压力是非常之大的。若想要控制高并发的情况,就需要编码进行处理(参考实战篇的优惠券秒杀),对程序员而言十分麻烦。

    总结

           需要注意,“同步&异步”与“阻塞&非阻塞”是两个不同的概念,只有当数据从内核空间拷贝至用户空间时非阻塞才能称之为异步。除了异步IO外,其余四种IO模型在接收内核空间数据时都需要等到直到数据拷贝结束,所以说均阻塞,都只能被称之为同步IO。

    Redis网络模型
    Redis是单线程 or 多线程?

    Redis坚持在核心业务使用单线程的原因

           核心原因在于对内存操作的时间通常在μs级别,本身就已经足够快,限制redis存储效率的瓶颈从来都是网络延迟而非对内存的操作速度。若此时引入多线程,势必要保证线程安全、进行上下文切换等操作,这些都会影响redis的性能。

    Redis单线程实现的源码分析
    Redis的IO多路复用API库

           为适应不同操作系统,Redis对IO多路复用的实现存在一些差异,但不同系统下的API总体是大差不差的。此处将以linux系统为例,说明redis对IO多路复用的实现。关键的API以及说明如下:aeApiCreate——创建epoll实例以进行后续IO多路复用、aeApiAddEvent——将要监听的FD加入到epoll的红黑树节点中、aeApiPoll——等待FD就绪以通知给用户空间。

    Redis单线程IO多路复用主要源码分析(linux系统下)

    总体流程

           总体流程分为两步,分别对应epoll中的创建epoll实例(initServer) & 监听fd状态(aeMain):

    1. 首先我们需要创建一个epoll实例(initServer),这个实例中需要包括一个serverSocket相关信息、处理连接serverSocket请求的处理器、若不存在FD就绪时的epoll监听前处理器。
    2. 然后我们开始监听FD(aeMain),判断是否就绪,然后将就绪FD交还给用户空间处理。当就绪内容为ssfd时,就表示服务端此时准备与客户端进行连接,此时调用连接处理器进行连接建立;当就绪内容为客户端fd时,就表示客户端需要的数据准备完毕,此时进行数据交互。

    initServer()

    方法流程分析

    实际上就是创建一个epoll流程需要的所有“工具”:

    1. 创建epoll实例。
    2. 注册一个serverSocket,包括为其指定监听端口(以后请求到该serverSocket的请求都需要通过此处指定端口),并且获取serverSocketFD,用于内核监听。
    3. 注册事件处理器,用于处理与当前serverSocket有关的请求。此处请求包括两个部分:a)未建立与服务端连接的,与serverSocket建立连接的客户端socket 请求 b)已建立与服务端连接后的,读取服务端数据的请求。
    4. 注册epoll实例开始监听FD前的处理器。

    第四步详解——注册epoll实例监听前的处理器的目的

    在epoll实现web服务的流程图中,当ssfd直接加入到监听队列中后,epoll实例会开始不断遍历每个fd,看其是否就绪。但我们思考一个问题:若在将ssfd加入到FD监听队列中后直接开始监听FD,此时没有任何FD就绪时,相当于CPU正在进行空转。

    我们这一步处理就是为避免这种情况,若此时就绪队列中没有任何FD,那么epoll就无需监听,进行休眠以增加CPU利用率。当有FD就绪,再唤醒epoll实例开始监听。

    aeMain()

    主要流程说明

           具体监听流程没有什么好解释的,就是遍历FD,找到已经就绪的FD,然后放入就绪FD链表中,最后交还给用户空间处理。

           用户空间获取到就绪FD后,就会调用initServer()阶段创建的事件处理器进行就绪FD的处理。此时需要判断FD的类型:若是ssfd就绪,说明此时服务端已经准备好与客户端建立连接,则调用accept()函数,建立连接并获取客户端SocketFD,加入到监听FD中;若是客户端FD就绪,说明客户端请求的数据已经准备好了,则调用读请求处理函数,进行客户端与服务端的数据交互。

           提醒,serverSocket与客户端socket的连接是在用户空间中进行的,只有监听FD的操作是在内核空间进行,此处不要混淆了!

    事件处理器的内部逻辑

           此处处理器对不同fd就绪的处理存在不同:若是ssfd就绪,则通过accept()函数创建于客户端的连接,获取客户端FD并加入到监听FD队列中;若此时客户端FD就绪,则表示客户端请求的数据已经准备好,调用对应的回调函数让客户端对数据进行交互。

    总结

    我们在initServer阶段创建了epoll实例(包括注册serverSocket、注册连接客户端socket和serverSocket的连接处理器等),然后在aeMain阶段进行FD监听。若aeMain阶段监听到的就绪队列是ssfd,就表示服务端已经准备进行连接,此时调用连接函数accept();若是其余fd,则表示是请求到已经建立连接的客户端socket,且数据已经准备完成,那么根据请求类型进行数据交互即可。

    Redis单线程网络模型总结

    在首次建立epoll实例时

           此时FD队列中只有一个serverSocketFD,当FD就绪时,只可能是有客户端请求与serverSocket建立连接时。所以此时我们建立连接并读取客户端FD,加入到监听队列中。

    已经有客户端与服务端建立连接后

           此时就绪的FD就分为两种了,ssfd就绪则进行客户端连接建立;客户端FD就绪则进行数据交互,包括读取请求,读取数据,将数据写入响应等。

    整体流程

           我们可以简单将redis网络模型理解为:IO多路复用 + 任务派发的结合。IO多路复用体现在对FD的监听,任务派发体现在对不同类型的就绪FD有不同的操作。

    Redis多线程网络模型

           性能瓶颈从来都在于IO,在redis中,对于请求的响应(与网络IO有关)、客户端所需数据的读写都是涉及到IO的操作,在这些IO操作中,我们加入了多线程,让另一个线程进行IO操作,而主线程仍进行主要命令执行。这既能保证核心命令单线程下的执行效率,又能保证读写操作时IO操作的效率。

    Redis通信协议

    RESP协议
    RESP概述

    RESP通信协议的数据类型

           一般来说,我们向redis服务端发送的数据操作命令都是基于数组类型进行通信的。比如命令”set name Darren”(向叫做“name”的key中加入“Darren”的value),底层将会拆分成三个多行字符串,即:”$3\r\nset\r\n”、“$4\r\nname\r\n”、“$6\r\nDarren\r\n”,然后将这三个多行字符串封装到一个数组中,让服务端进行解析。

    模拟Redis客户端——基于Socket的自定义redis客户端
    总体流程 & DEMO构建

    分为五个步骤:

    1. 建立与redis的连接(通过socket建立)。
    2. 获取socket的输入、输出流,以向redis写数据、读redis的响应数据。
    3. 向redis发出请求。请求(或是redis命令)一般都是一个字符串,在RESP中则是一个多行字符串数组,每一个元素都是命令中的一个字符串。
    4. 解析redis的响应数据。
    5. 关闭连接。
    具体细节说明

           Socket对象的建立只有两种情况:建立连接成功,程序正常执行;建立连接失败,程序报错。所以我们可以通过是否报错来判断是否能够与redis服务端建立连接。

           我们采用字符输入输出流的目的在于,避免使用socket的字节输入输出流带来的单字节读取的困扰,输入字符形式可以让我们一次读取一整行的信息。

    向redis发出请求的函数详解(数据写死version)

    1. 使用PrintWriter.println()时,就相当于写一行然后自动进行换行,即自动为我们在字符结尾添加了”\r\n”,因此我们只需要写原始数据即可。
    2. 对于此命令,就相当于通过数组发出请求,进行”set name Darren”操作。这个数组中包含的三个数据为:”$3\r\nset\r\n”、“$4\r\nname\r\n”、“$6\r\nDarren\r\n”,所以数组长度是3。对于这个数组,原始结构应为:”*3\r\n”——指定数据类型为数组、指定数组大小;”$3\r\nset\r\n”——指定数组中第一个元素为长度为3的多行字符串,数据内容为”set”;“$4\r\nname\r\n”——指定数组中第二个元素为长度为4的多行字符串,数据内容为”name”;“$6\r\nDarren\r\n”——指定数组第三格元素为长度为6的多行字符串,数据内容为”Darren”。
    3. 在使用PrintWriter.println()的情况下,我们只需要自动剔除”\r\n”即可。因此,对于”*3\r\n”,实际上就是写入”*3”字符串;对于”$3\r\nset\r\n”,实际上就是写入”$3”和”set”两个字符串,其余也是同理。
    4. 最后一定注意,使用flush函数将缓冲区内容写入文件中,此处即写入redis服务端中,这是一个数据持久化的操作。
    解析redis响应数据的函数详解

    1. 假设存在此次需要解析数据为:“$3\r\nset\r\n”,那么实际上这个解析函数就是将这个命令字符串拆分成两个部分:一个是数据类型标识,即:“$“;另一个是具体的数据内容,即:”3\r\nset”。通过二者结合,说明这是一个长度为3的多行字符串”set”。
    2. 首先需要解析数据类型,实际上就是基于这个输入流的第一个字符进行判断。由于RESP通信协议中存在五类数据类型,所以我们使用switch进行判断。注意,当使用BufferedReader.read()函数后,当前指针将停留在这个字符串的第一个字符之后。
    3. 与PrintWreter.println()函数同理的,BufferedReader.readLine()函数会为我们读取一行的信息,并且自动忽略掉”\r\n”。
    4. 对于“+”、“-”、“:”三种类型数据,实际上都可以认为是单行字符串,即我们只需要读取一行,然后根据不同类型进行处理并返回即可。
    5. 对于”$”,我们需要根据字符串长度进行读取,读取字符串长度的思路类似于读取数据类型,不过此时不是基于第一个字符进行判断,而是基于第一行数据进行判断。但由于此时我们使用的是字符流,不方便通过字节大小进行读取,所以我们此时仍假设只有一行数据。
    6. 对于数组类型,同样需要读取长度,然后依次读取数组元素。但由于数组元素解析有些复杂,我们专门为其设计了一个函数进行解析,详见下文。
    解析数组类型响应数据的函数

    1. 读取了数组大小后,我们就可以通过数组大小进行数组中元素的遍历。由于每个元素具有类型不一定相同,所以我们使用List<Object>进行接收。
    2. 在遍历中,实际上相当于通过解析函数相同的逻辑进行了每个元素的解析,所以我们直接通过递归调用进行数组元素解析即可。当数组中元素没有出现数组型元素,就代表递归栈的退出。
    测试结果查看

    对于请求函数的修改

           反思我们原本的请求函数,我们将数组大小限定死了,内容同样也限定死了,所以每次发送的请求都是同一个内容。假设redis服务端存在密码,我们需要先输入密码,再进行数据设置,但这通过原本的请求函数无疑是无法实现的。

           我们思考,对redis的请求,实际上就是建立一个数组,然后将命令拆分设置为数组中的元素。建立数组实际上就是指定数据类型为数组(通过”*”)、指定数组大小(通过元素个数);而在命令中,一般是多个字符串的组合,所以数组内容设置实际上就是指定数据类型为多行字符串(通过“$”)、指定字符串字节大小、指定数据本身。那么我们是否可以让用户为该函数传入命令的字符串,让函数直接通过这些字符串进行数组建立、redis请求的发送呢?

           我们基于以上思路对请求函数进行修改,将形参设置为可变字符串。假设我们的命令是”set name dd”,那么就需要传入三个字符串:“set“、”name“、“dd”。数组大小即可变字符串的数量;数组元素的设置即读取每个字符串的字节大小、设置数据内容,可以通过循环进行实现。

           更改后,对于请求参数的调用和测试结果如下:

           我们甚至可以使用get命令查看数据:

    Redis内存策略

    过期Key处理——expire命令底层源码分析

    Redis如何识别key是否过期
    RedisDB底层数据结构

           在redisDB底层结构中,用于数据存储的主要内容是dict和expires两个字典。其中dict字典是保存redis中所有键值对的字典,若我们在进行set操纵时指定了key的过期时间(即进行了expire操作),那么redis就会将key以及对应过期事件存储到expires字典中。

           因此,在进行redis数据查询时,将会查询expires中是否存在对应key,若存在则查询ttl,若ttl在当前时间之前就表示数据已经过期;当然若存储数据时未指定过期时间,那么就直接返回查询结果即可,不再判断是否过期。

    RedisDB内存结构图

    过期key删除策略
    惰性删除

           惰性删除即访问时检查是否过期。但是若大量数据过期但我们长时间未进行访问,就会导致这些数据长期存在于redis数据库中,仍会造成内存浪费。所以我们需要另一个策略,来删除这些长时间未访问的数据,redis对此采取的策略是周期删除。

    周期删除

           在底层源码中,对周期删除有两个实现方案:SLOW模式、FAST模式。

    其中SLOW模式是定时任务serverCron(),会定期对redisDB中的过期key进行清理,但该操作会对DB所有key进行遍历,所以进行频率较低,频率由设置的server.hz决定。

    FAST模式则是在每个事件循环前(比如客户端FD就绪后,对数据进行交互)进行,由于每次事件循环前都会调用beforeSleep函数以防止没有任何FD就绪,beforeSleep函数就有在进行过期key的清理,频率根据事件触发而定。

    SLOW模式 与 FAST模式区别

    SLOW模式

    FAST模式

    总结

    内存淘汰策略
    Redis内存淘汰总览

           内存检查将会在任何redis命令执行时进行,若内存使用达到上限则会调用内存淘汰机制。内存淘汰机制执行需要满足两个条件:内存使用达到上限;此时没有任何lua脚本执行。

    确认lua脚本未执行是为避免数据不一致性。因为lua脚本在redis中通常是在进行原子化的数据操作,若lua脚本仍在执行时我们删除了部分key,可能导致lua所需要的key被删除,造成数据不一致。

    Redis的八种内存淘汰策略

    RedisObject底层数据结构

    实现LFU的策略解析

           由于LFU只是用低8位统计访问次数,所以不可能每次key被访问都记录实际访问的次数,高并发场景下的访问次数将会超过8位上限。

           我们通过逻辑访问次数来实现LFU。逻辑访问次数的增加发生在1/(访问次数*lfu_log_factor + 1) < R时,所以当访问次数不断增加,这个事件发生的频率将会越来越低。我们可以理解为,如果一个key经常被访问,那么他的逻辑访问次数将会基本不变。

           为避免前期访问次数极大但后期基本没有被访问而导致逻辑访问次数很大,我们需要对其进行衰减。若上次访问时间距离现在过长,那么就执行逻辑访问次数 – 1的操作。

    淘汰策略流程图

    核心流程分析

    淘汰策略的选取

           Redis中提供了两大类内存淘汰策略:随机删除 & 基于一些使用条件进行删除(如LRU、LFU、TTL等)。

    使用LRU、LFU、TTL策略

    如何设置淘汰池

           首先需要明确,每次删除不可能将所有过期的key都删除,所以我们需要根据TTL、LRU、LFU等策略进行参考。同样的,使用该策略的时候,也没办法保证每次直接删除所有违反策略的key,这样将会对线程造成极大的阻塞。我们采用每次选取部分样本,基于这些条件进行判断来确定是否进入淘汰池,以及淘汰顺序。

           是否进入淘汰池实际上十分简单,LRU、LFU、TTL策略都设置了可容忍范围,只需要进行比较即可。所以重要的应该是如何设置淘汰顺序:

    1. 由于LRU、LFU、TTL三种策略基于数值的删除是不同的,LRU是基于最长未被访问时间、LFU是基于最小的逻辑访问次数、TTL是基于最小的TTL(也就是最早过期),我们不可能对这三种策略分别设置不同的key选取方案。
    2. Redis底层的解决措施为:我们通过转换,将三种策略的淘汰依据都变为基于最大值淘汰。设置统一变量idleTime。TTL策略中使用maxTTL - TTL为idleTime;LRU策略中使用nowTime – LRU为idleTime;LFU策略中使用255 – LFU为idleTime。通过以上数据转换,当TTL策略中TTL早就过期,那么maxTTL – TTL将是一个较大的值;当LRU策略中最近使用的时间非常早,那么nowTime – LRU将是一个较大的值;当LFU的逻辑访问次数很小,那么255 – LFU将是一个较大的值。这些较大的值就是我们需要淘汰的key。

           我们将这些值基于升序排列存入淘汰池中。

    淘汰池中key的淘汰顺序

           由于在上一步中,我们根据升序将key存入了淘汰池中,所以淘汰池中值越大的key就是需要优先被淘汰的。所以我们从淘汰池中倒序选取key,就能实现每次就淘汰最应该被淘汰的key。

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值