Redis核心知识点总结

前言

本文来源于尚硅谷周阳老师的尚硅谷Redis零基础到进阶,最强redis7教程,阳哥亲自带练(附redis面试题)_哔哩哔哩_bilibili

由于作者本地笔记中图片使用的是本地路径,所以本文的图片无法正常上传,如有需求可以下载我发布的pdf

基础篇

入门概述

定义
  • Remote Dictionary Server(远程字典服务)是完全开源的,使用ANSIC语言编写遵守BSD协议

  • 是一个高性能的Key-Value数据库,提供了丰富的数据结构,例如String、Hash、List、Set、SotedSet等等

  • 数据是存在内存

  • Redis支持事务、持久化、LUA脚本、发布/订阅、缓存淘汰、流技术等多种功能特性提供了主从模式、Redis Sentinel和Redis Cluster集群架构方案

功能

  • 分布式缓存,减轻mysql的负担

    • 硬件:mysql数据存储在硬盘,redis数据操作主要在内存,访问速度快

    • 查询:mysql通过全表扫描,redis通过key访问value,查询速度快

    • 关系的处理:mysql是关系型数据库,redis是nosql中的一种,不需要处理关系

    image-20230906212804820

    .

    • 查询数据先回从缓存中找,如果没有命中再去mysql中查询,查询结果再写回到缓存

  • 内存存储持久化(RDB+AOF)

    • redis支持异步将内存中的数据写到硬盘上,同时不影响继续服务

  • 高可用架构搭配(某个redis挂了,也不影响缓存架构设计)

    • 单机

    • 主从

    • 哨兵

    • 集群

  • 缓存穿透、击穿、雪崩

  • 分布式锁

  • 队列

    • 通过Reids的队列功能做购买限制

    • 比如到节假日或者推广期间,进行一些活动,限制今天只能购买几次商品或者一段时间内只能购买一次

优势

  • 性能极高 - Redis能读的速度是110000次/秒,写的速度是81000次/秒

  • 数据类型丰富,不仅仅支持简单的key-value类型的数据同时还提供list,set,zset,hash等数据结构的存储

  • 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用

  • 支持数据的备份,即master-slave(主从)模式的数据备份

  • redis架构图

image-20230906214336367

.

相关资料

新特性

新特性介绍
多AOF文件支持7.0 版本中一个比较大的变化就是 aof 文件由一个变成了多个,主要分为两种类型:基本文件(base files)、增量文件(incr files),请注意这些文件名称是复数形式说明每一类文件不仅仅只有一个。在此之外还引入了一个清单文件(manifest) 用于跟踪文件以及文件的创建和应用顺序(恢复)
config命令增强对于Config Set 和Get命令,支持在一次调用过程中传递多个配置参数。例如,现在我们可以在执行一次Config Set命令中更改多个参数: config set maxmemory 10000001 maxmemory-clients 50% port 6399
限制客户端内存使用Client-eviction一旦 Redis 连接较多,再加上每个连接的内存占用都比较大的时候, Redis总连接内存占用可能会达到maxmemory的上限,可以增加允许限制所有客户端的总内存使用量配置项,redis.config 中对应的配置项// 两种配置形式:指定内存大小、基于 maxmemory 的百分比。maxmemory-clients 1gmaxmemory-clients 10%
listpack紧凑列表调整listpack是用来替代ziplist 的新数据结构,在 7.0 版本已经没有 ziplist 的配置了(6.0版本仅部分数据类型作为过渡阶段在使用)listpack 已经替换了 ziplist 类似 hash-max-ziplist-entries 的配置
访问安全性增强ACLV2在redis.conf配置文件中,protected-mode默认为yes,只有当你希望你的客户端在没有授权的情况下可以连接到Redis server的时候可以将protected-mode设置为no
Redis FunctionsRedis函数,一种新的通过服务端脚本扩展Redis的方式,函数与数据本身一起存储。简言之,redis自己要去抢夺Lua脚本的饭碗
RDB保存时间调整将持久化文件RDB的保存规则发生了改变,尤其是时间记录频度变化
命令新增和变动Zset (有序集合)增加 ZMPOP、BZMPOP、ZINTERCARD 等命令Set (集合)增加 SINTERCARD 命令LIST (列表)增加 LMPOP、BLMPOP ,从提供的键名列表中的第一个非空列表键中弹出一个或多个元素。
性能资源利用率、安全、等改进自身底层部分优化改动,Redis核心在许多方面进行了重构和改进主动碎片整理V2:增强版主动碎片整理,配合Jemalloc版本更新,更快更智能,延时更低HyperLogLog改进:在Redis5.0中,HyperLogLog算法得到改进,优化了计数统计时的内存使用效率,7更加优秀更好的内存统计报告如果不为了API向后兼容,我们将不再使用slave一词......(政治正确)

安装

安装要求

  • Linux环境安装Redis必须先具备gcc编译环境

    • gcc是linux下的一个编译程序,是C程序的编译工具

    • 查看当前的gcc环境:gcc -v【如果没安装则该命令不存在】

    • 安装:yum -y install gcc-c++

  • redis版本选择

    • 查看安装的redis版本:redis-server -v

    • 要求安装6.0.8以上的版本(为了安全,防止黑客攻击)

安装步骤

  1. 下载获得redis-7.0.0.tar.gz后将它放入我们的Linux目录/opt

  2. /opt目录下解压redis

    • 解压命令:tar -zxvf 压缩包

  3. 进入redis目录

  4. 在redis目录下执行make命令【安装redis】

  5. 查看默认安装目录: usr/local/bin(Linux下的/usr/local类似我们windows系统的C:\Program Files)

    • redis-benchmark:性能测试工具,服务启动后运行该命令,看看自己笔记本性能如何

    • redis-check-aof: 修复有问题的AOF文件,rdb和aof后面讲

    • redis-check-dump: 修复有问题的dump.rdb文件

    • redis-cli: 客户端,操作入口

    • redis-sentinel: redis集群使用

    • redis-server: Redis服务器启动命令

  6. 将默认的redis.conf拷贝到自己定义好的一个路径下,比如/myrediscp redis.conf /myredis/redis.conf

  7. 修改/myredis目录下redis.conf配置文件做初始化设置

    • redis.conf配置文件,改完后确保生效,记得重启,记得重启

      1. 默认daemonize no 改为 daemonize yes【使redis作为后端服务器启动】

      2. 默认protected-mode yes 改为 protected-mode no【保护模式,别人连接时需要禁用掉】

      3. 默认bind 127.0.0.1改为 直接注释掉(默认bind 127.0.0.1,只能本机访问)或改成本机IP地址,否则影响远程IP连接

      4. 添加redis密码:requirepass 密码

        image-20230911191701056

        【如果不配置,登录时就不需要密码,建议配置】

  8. 启动服务:在usr/local/bin目录下使用redis-server redis配置文件命令

  9. 连接服务:redis-cli -a 密码 [-p 端口号]【不知道端口号默认找6379】

    • 打开一个新会话输入ps -ef | grep redis命令检查是否连接成功

    image-20230911192944552

    .

    • 测试redis是否可以正常使用:ping命令

      image-20230911193106266

  10. 测试helloworld

    image-20230911193756912

    .

  11. 退出连接客户端:quit

  12. 关闭redis服务器

    • 单实例关闭:redis cli -a 密码 shutdown

    • 多实例关闭【指定端口关闭】:redis cli -p 端口号 shutdown

卸载(了解)

  1. 停止redis-server服务

  2. 删除/usr/local/lib目录下与redis相关的文件【rm -rf /usr/local/bin/redis-*】

!!!Redis10大数据类型

前情提要

  • 这里说的数据类型是value的数据类型key的类型都是字符串

  • 数据类型操作命令的查询

  • 命令不区分大小写,而key区分大小写

十大数据类型介绍

redis字符串 (String)
  • 二进制安全的【支持序列化】,意思是redis的string可以包含任何数据,比如jpg图片或者序列化的对象

  • 是Redis最基本的数据类型,一个redis中字符串value最多可以是512M

redis列表 (List)
  • 是简单的字符串列表,按照插入顺序排序

  • 可以添加一个元素到列表的头部(左边)或者尾部(右边),底层实际是个双端链表

  • 最多可以包含 2^32 - 1 个元素(4294967295,每个列表超过40亿个元素)

redis哈希表 (Hash)
  • 是一个 string 类型的field (字段)和 value(值) 的映射表,hash 特别适合用于存储对象

  • 每个 hash 可以存储 2^32 - 1键值对 (40多亿)

redis集合 (Set)
  • 是String类型的无序集合,集合成员是唯一的,意味着集合中不能出现重复的数据

  • 集合对象的编码可以是 intset 或者 hashtable

  • Redis中Set集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)

  • 集合中最大的成员数为2^32 - 1(4294967295,每个集合可存储40多亿个成员)

redis有序集合 (sorted set,又称ZSet)
  • zset和set相比

    • 相同的是

      • 是string类型元素的集合,且不允许重复的成员

      • 是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。

      • 集合中最大的成员数为 2^32 - 1

    • 不同的是

      • 每个元素都会关联一个double类型的分数,redis正是通过分数来为集合中的成员进行从小到大的排序

      • zset的成员是唯一的,但分数(score)可以重复

redis地理空间 (GEO)
  • 主要用于存储地理位置信息,并对存储的信息进行操作,包括

    • 添加地理位置的坐标

    • 获取地理位置的坐标

    • 计算两个位置之间的距离

    • 根据用户给定的经纬度坐标来获取指定范围内的地理位置集合

redis基数统计 (HyperLogLog)
  • 是用来做基数统计的算法

  • 优点

    • 在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定且是很小的

    • 每个 HyperLogLog 键只需要花费12 KB 内存,就可以计算接近 264 个不同元素的基数

      • 这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比

  • 缺点

    • 只会根据输入元素来计算基数,而不会储存输入元素本身,所以不能像集合那样,返回输入的各个元素

redis位图 (bitmap)
  • 由0和1状态表现的二进制位的bit数组

image-20230911201921493

.

  • 上图由许许多多的小格子组成,每一个格子里面只能放1或者0,用它来判断Y/N状态

    • 每一个小格子就是一个bit

    • 1字节(byte)=8位(bit)

redis位域 (bitfield)
  • 通过bitfield命今可以一次性操作多个比特位域(指的是连续的多个比特位),它会执行一系列操作并返回一个响应数组,这个数组中的元素对应参数列表中的相应操作的执行结果【即通过bitfield命令可以一次性对多个比特位域进行操作】

redis流 (Stream)
  • 是Redis 5.0 版本新增加的数据结构

  • Redis Stream 主要用于消息队列(MQ,Message Queue)

    • Redis 本身是有一个 Redis 发布订阅 (pub/sub) 来实现消息队列的功能,但它有个缺点就是消息无法持久化,如果出现网络断开、Redis 宕机等,消息就会被丢弃【简单来说发布订阅 (pub/sub) 可以分发消息,但无法记录历史消息】

    • 而 Redis Stream 提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失

操作redis键的常用命令

查看key
  • keys *:查看当前库所有的key

  • exists key:判断某个key是否存在

  • type key:查看key的类型

  • dbsize:查看当前数据库key的数量

  • keys pattern:查看所有符合给定模式的key

删除key
  • del key:删除指定的key数据

  • unlink key:非阻塞删除,仅仅将keys从keyspace元数据中删除,真正的删除会在后续异步中操作

    • del是原子操作,如果删除大数据会造成堵塞

关于过期时间
  • 查看剩余过去时间

    • ttl key:查看还有多少秒过期

      • -1表示永不过期

      • -2表示已过期

    • pttl key:查看还有多少毫秒过期

  • 设置过期时间

    • 单位秒

      • expire key 秒钟:为给定的key设置过期时间

      • expireat key timestamp

        • expireat的作用和 EXPIRE 类似,都用于为 key设置过期时间

        • 不同在于EXPIREAT 命今接受的时间参数是UNIX 时间戳(unix timestamp)

    • 单位毫秒

      • pexpire key milliseconds:设置 key 的过期时间

      • pexpireat key milliseconds-tmestamp:设置 key过期时间的时间戳(unix timestamp) 以毫秒计

    • persist key:移除key的过期时间,key将持久保持

数据库操作相关
  • move key dbindex 【0-15】:将当前数据库的key移动到给定的数据库db当中

  • select dbindex:切换数据库【0-15】,默认为0

  • 删库跑路

    • flushdb:清空当前库(危险)

    • flushall:通杀全部库(非常危险)

重命名
  • renamekey newkey:修改 key 的名称

  • renamex key newkey:仅当newkey 不存在时,将 key 改名为 newkey

其它操作
  • dump key:序列化给定的key,并返回被序列化的值

    • 序列化是指把一个Java对象变成二进制内容

  • randomkey:从当前数据库中随机返回一个 key

!!!操作数据类型常用命令

Redis字符串(String)
特点:单值单value
!!!赋值和取值
  • set key value [NX|XX] [GET] [EX seconds|PX milliseconds|EXAT unix-time-seconds|PXAT unix-time-milliseconds|KEEPTTL]

    • 有EX、PX、NX、XX以及KEEPTTL五个可选参数,其中KEEPTTL为6.0版本添加的可选参数,其它为2.6.12版本添加的可选参数

      • 键存在情况

        • NX:键不存在的时候设置键值

        • XX:键存在的时候设置键值【覆盖】

      • GET:返回指定键原本的值,若键不存在时返回nil

      • 设置过期时间

        • EX seconds:以秒为单位设置过期时间

        • Px milliseconds:以毫秒为单位设置过期时间

        • EXAT timestamp:设置以秒为单位的UNIX时间戳所对应的时间为过期时间

        • PXAT milliseconds-timestamp:设置以毫秒为单位的UNIX时间戳所对应的时间为过期时间

        • KEEPTTL:保留设置前指定键的生存时间

          image-20230911215921296

          在修改key的值时未设置,会覆盖设置的生存时间

          image-20230911220008350

          修改key时设置该参数,会继续沿用剩余的生存时间

    • SET命令使用EX、EX、NX参数,其效果等同于SETEX、PSETEX、SETNX命令。根据官方文档的描述,未来版本中SETEX、PSETEX、SETNX命今可能会被淘汰

    • EXAT、PXAT以及GET为Redis 6.2新增的可选参数

      • 拓展:获得设置当前Unix 时间,单位为秒

      System.out.println(Long.toString(System.currentTimeMillis()/1000L));
    • 命令执行返回值

      • 设置成功则返回oK

      • 未执行SET命令【执行失败】返回nil,如不满足nx、xx条件等

      • 若使用GET参数,则返回该键原来的值,或在键不存在时返回nil

  • get key:取值

  • getset key value:将给定 key 的值设为value ,并返回key的旧值(old value)【先get然后立即set】

同时设置/获取多个键值
  • mset[nx] key value [key value ....]:同时设置多个值

    • 注意msetnx是同时覆盖,如果多个键值对中既有key存在又有key不存在的情况,则执行失败(类似于事务原子性)

  • mgetkey [key ....]:同时获取多个键的值

单个键值的范围操作
  • getrange key 起始下标 截止下标:获取key的指定区间的字符【类似于substring】

  • setrange 指定下标 value:替换指定下标的内容

数值增减
  • 一定要是数字才能进行加减

  • 增加

    • 递增数字:incr key

    • 增加指定的整数:incrby key increment

    • 增加给定的浮点增量值:INCRBYFLOAT key increment

  • 减少

    • 递减数值:decr key

    • 减少指定的整数:decrby key decrement

获取字符串长度和内容追加
  • strlen key:获取字符串长度

  • append key:在字符串末尾追加内容

分布式锁后续入门命令
  • setex key value ttl:将创建键值对和设置剩余时间合成原子操作

  • setnx key value:键不存在时创建键值对

应用场景
  • 抖音无限点赞某个视频或者商品,点一下加一次

  • 是否喜欢的文章 、粉丝数等等

Redis列表(List)
特点:单key多value
简单说明

image-20230912140926018

.

  • 底层是一个双端链表的结构,容量是2的32次方减1个元素,大概40多亿

  • 主要功能有push/pop等,一般用在栈、队列、消息队列等场景

  • left、right都可以插入添加

  • 键存在情况

    • 如果键不存在,创建新的链表

    • 如果键已存在,新增内容

    • 如果值全移除,对应的键也就消失了

  • 对两端的操作性能很高,通过索引下标的操作中间的节点性能会较差

插入数据
  • 从左端

    • 插入:rpush key v1,v2...

    • 插入已存在的列表:rpushx key v1,v2...

  • 从右端

    • 插入:lpush key v1,v2...

    • 插入已存在的列表:lpushx key v1,v2...

  • lset key index value:给指定下标元素设置值

  • linsert key before/after 已有值 插入的新值:在列表符合条件的第一个元素前/后面插入数据

弹出数据
  • 从左端

    • lpop key:弹第一个元素并输出

    • blpop key1 [key2] timeout:移出并获取列表的第一个元素,如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止

  • 从右端

    • rpop key:弹出第一个元素并输出

    • brpop kev1 [key2] timeout:移出并获取列表的最后一个元素,如果列表没有元素会阻塞(同上)

获取元素信息
  • llen key:获取列表中元素个数

  • lindex key index:按照下标获取列表元素

  • !!!lrange key start stop:获取列表指定范围内的元素(从左至右)

删除/移动元素
  • lrem key 数字N 给定值v1:删除N个值等于v1的元素

  • ltrim key start end:截取指定范围的值后再赋值给key

  • rpoplpush 源列表 目的列表:移除源列表最后一个元素,并将该元素添加到目标列表左端并返回

  • BRPOPLPUSH source destination timeout:从源列表中弹出一个值,将弹出的元素插入到目标列表中并返回,如果列表没有元素会阻塞列表直到等待超时或发现可弹出

应用场景
  • 微信公众号订阅的消息

    1. 李永乐老师和CSDN发布了文章分别是 11 和 22

    2. 如果关注了他们两个,只要他们发布了新文章,就会安装进我的List

      • lpush likearticle:阳哥id 11 22

    3. 分页查看自己的号订阅的全部文章,下面0~10就是一次显示10条

      • lrange likearticle:阳哥id 0 10

Redis哈希(Hash)
特点:kv模式不变,但是v是键值对

image-20230912145014884

.

赋值取值删值
  • !!!赋值

    • hset key k1 v1 k2 v2..:创建多个键值对放到以key为键的map(可以设置多个键的值)

    image-20230912150653010

    .

    • hmset key k1 v1 k1 v1 k2 v2..:和hset没什么区别

    • hsetnx key k v:如果map不存在则创建并赋值(只能设置单个键值对)

  • !!!取值

    • hget key k:获取以key为键的map中键为k的值(无法获取多个键的值)

    • hmget key k1 k2..:获取map中多个键的值

    image-20230912151017624

    .

    • hgetall key:获取map的所有键和值(kv交替输出)

      image-20230912151203329

    • hlen key:获取map中键值对个数

    • hkeys key:获取map所有键

    • hvals key:获取map所有值

  • 删值

    • hdel key k:删除map中键为k的键值对

其它操作
  • hexists key:判断map里面是否存在key

  • hincrby/hincrbyfloat key k 整数/浮点数:给map中键为k的值添加数值

应用场景
  • 电商购物车,当前中小厂可用【早期设计目前不再采用】

    • 新增商品 → hset shopcar:uid1024 334488 1

    • 增加商品数量 → hincrby shopcar:uid1024 334477 1

      image-20230912152340543

    • 商品总数 → hlen shopcar:uid1024

    • 全部选择 → hgetall shopcar:uid1024

Redis集合(Set)
特点:单值多value,且值不重复
增删改查
  • sadd key member [member ...]:添加多个元素,最后会去重

    • srem key member [member...]:删除多个元素

    • spop key [num]:从集合中随机弹出num元素,出一个删一个

  • smove key1 key2 key1中存在的某个值:将key1里已存在的某个值赋给key2

    • smembers key:遍历集合中的所有元素

    • sismember key member:判断元素是否在集合中

    • scard key:获取集合里面的元素个数

    • SRANDMEMBER key [num]:从集合中随机展示的num个元素,元素不删除

运算
    • sdiff a b [..],返回属于a但是不属于b的集合(有先后顺序)

      image-20230912155453871

    • sdiffstore destination key1 [key2..]:返回给定所有集合的差集并存储在 destination 中

    • sunion a b [..],返回a和b的并集

    • sunionstore destination key1 [key2..]:返回给定所有集合的交集并存情在 destination 中

    • sinter a b [..],返回a和b的交集

    • sinterstore destination key1 [key2..]:返回给定所有集合的交集并存情在 destination 中

    • sintercard num a b [..],不返回结果集,而只返回由所有给定集合的交集中元素的基数【num是给定集合的个数】

    image-20230912160040851

    .

应用场景
  • 微信抽奖小程序

步骤命令
用户ID,立即参与按钮sadd key 用户ID
显示已经有多少人参与抽奖SCARD key
抽奖(放回)SRANDMEMBER key 2 随机抽奖2个人,元素不删除
抽奖(不返回)SPOP key 3 随机抽奖3个人,元素会删除
  • 微信朋友圈点赞查看同赞朋友

步骤命令
新增点赞sadd pub:msgID 点赞用户ID1 点赞用户ID2 ...
取消点赞srem pub:msgID 点赞用户ID
展现所有点赞过的用户SMEMBERS pub:msgID
点赞用户数统计,就是常见的点赞红色数字scard pub:msgID
判断某个朋友是否对楼主点赞过SISMEMBER pub:msgID 用户ID
  • QQ内推可能认识的人(差集)

Redis有序集合Zset
特点:在set基础上给每个v加一个分数值

image-20230912172349046

.

增删改查
  • zadd key score member [score member ... ] :向zset中添加多个带有分数的成员

    • zrem key value值:作用是删除元素

    • zmpop numkeys key[key ...] <min/max> [Count num]:从键名列表中的第一个非空排序集中弹出一个或多个成员分数对

      image-20230912212632289

      • 大概意思就是有多个zset组成的列表中,第一个如果为非空集合,就将其按大小弹出指定数量的分数对

      • 如果第一个集合元素全弹出则停止执行

      • numkeys:键名列表中元素个数

      • Count num:弹出数据个数

    • zremrangebylex key min max:移除有序集合中给定的字典区间的所有成员

      • 排序方式是根据名称按字典由低到高排序

      • 它是基于分数一致的有序集合设计的

      • 待删除的有序集合中,分数最好相同,否则删除结果会不正常

    • zremrangebyrank key start stop:移除有序集合中给定的排名区间的所有成员

    • zremrangebyscore key min max:移除有序集合中给定的分数区间的所有成员

  • zincrby key increment member:增加某个元素的分数

    • increment是添加的分数量

    • zrange key start stop [withscores]:按照元素分数从小到大的顺序返回从start到stop之间的所有元素

      • 就是返回第几名到第几名的数据

      • withscores显示得分

    • zrevrange key start stop [withscores]:按照元素分数从大到小的顺序返回区间的所有元素

    • zrangebyscore key [(]min max [withscores] [limit offset count]:获取指定分数范围内的元素

      • (:不包含最小值

      • limit 从第几条开始显示 显示的总数:限制显示条数

    • zrangebylex key min max [LIMIT offset count]:通过字典区间返回有序集合的成员

    • zscore key member:获取元素的分数

    • 获得元素数量

      • zcard key:获取集合中所有元素的数量

      • zcount key min max:获得指定分数范围内的元素个数

    • 获得下标值

      • zrank key value值:获得元素的下标值

      • zrevrank key value值:逆序获得下标值

运算
  • zunionstore destination numkeys key [key ...]:计算给定的一个或多个有序集的并集,并存储在新的有序集合中

  • zinterstore destination numkeys key [key...]:计算给定的一个或多个有序集的交集并将结果集存储在新的有序集合中

应用场景
  • 定义商品销售排行榜(sorted set集合),key为goods:sellsort,分数为商品销售数量

步骤命令
商品编号1001的销量是9,商品编号1002的销量是15zadd goods:sellsort 9 1001 15 1002
有一个客户又买了2件商品1001,商品编号1001销量加2zincrby goods:sellsort 2 1001
求商品销量前10名ZRANGE goods:sellsort 0 9 withscores
Redis位图(bitmap)
特点:由0和1状态表现的二进制位的bit数组
  • 定义

    image-20230913112327041

    .

    • String类型作为底层数据结构实现的一种统计二值状态的数据类型

    • 位图本质是数组,它是基于String数据类型的按位的操作

      • 该数组由多个二进制位组成,每个二进制位都对应一个偏移量(我们称之为一个索引)

    • Bitmap支持的最大位数是2^32位,它可以极大的节约存储空间,使用512M内存就可以存储多达42.9亿的字节信息(2^32 = 4294967296)

  • 作用:用于状态统计

命令
  • setbit key offset val:给指定key的值的第offset位赋值val

    • bitmap偏移量是从0开始计算的

  • 查找信息

    • getbit key offset:获取指定key的第offset位

    • strlen key:统计字节数占用多少

      • 不是字符串长度而是占据几个字节,超过8位后自己按照8位一组(1byte)再扩容

    • bitcount key start end:返回指定key中[start,end]中为1的数量

  • bitop operation destkey k1 k2 [..]:对不同的二进制存储数据进行位运算(AND、OR、NOT、XOR),结果存在destkey

应用场景——考勤打卡
  • 按年去存储一个用户的签到情况,365 天只需要 365 / 8 ≈ 46 Byte,1000W 用户量一年也只需要44MB就足够了

    • 查看一年的统计表需要占用多少字节

      setbit key 364 0/1 
      bitcount key #1的数量
      strlen key #显示占用字节
  • 假如是亿级的系统,每天使用1个1亿位的Bitmap约占12MB的内存(10^8/8/1024/1024),10天的Bitmap的内存开销约为120MB,内存压力不算太高

  • 此外,在实际使用时,最好对Bitmap设置过期时间,让Redis自动删除不再需要的签到记录以节省内存开销

Redis基数统计(HyperLogLog)
特点:去重复统计功能的基数估计算法
  • 定义

    image-20230913115422075

    .

    • 基数是一种数据集,去重复后的真实个数

    • 基数统计是用于统计一个集合中不重复的元素个数,就是对集合去重复后剩余元素的计算

      • 注意redis基数统计有0.81%的标注误差

  • 作用

    • 统计某个网站的UV、统计某个文章的UV

      • Unique Visitor,独立访客,一般理解为客户端IP

      • 需要去重考虑

    • 用户搜索网站关键词的数量

    • 统计用户每天搜索不同词条个数

命令
  • pfadd key element[element ...]:添加指定元素到 HyperLogLog 中

  • pfcount key [key ...]:返回给定HyperLogLog的基数估算值

  • pfmerge destkey sourcekey [sourcekey ....]:将多个 HyperLogLog 合并为一个HyperLogLog

应用场景
  • 统计天猫网站首页UV

Redis地理空间(GEO)
特点
  • 需求

    • 地球上的地理位置是使用二维的经纬度表示,经度范围 (-180, 180],纬度范围 (-90, 90],只要我们确定一个点的经纬度就可以名取得他在地球的位置

    • 例如滴滴打车,最直观的操作就是实时记录更新各个车的位置,然后当我们要找车时,在数据库中查找距离我们(坐标x0,y0)附近r公里范围内部的车辆

  • 普通解决方式select taxi from position where x0-r < x < x0 + r and y0-r < y < y0+r,存在的问题

    • 查询性能问题,如果并发高,数据量大这种查询是要搞垮数据库的

    • 这个查询的是一个矩形访问,而不是以我为中心r公里为半径的圆形访问。

    • 精准度的问题,我们知道地球不是平面坐标系,而是一个圆球,这种矩形计算在长距离计算时会有很大误差

  • 解决原理

    • 核心思想就是将球体转换为平面区块转换为一点

    • 主要分为三步

      1. 将三维的地球变为二维的坐标在

      2. 将二维的坐标转换为一维的点块

      3. 最后将一维的点块转换为二进制再通过base32编码

  • geo的类型是zset,所以可以使用zset的相关命令

    • !!!如果输出了中文乱码,可以在连接redis指定参数--raw

      image-20230913132340919

  • 获取经纬度信息拾取坐标系统

命令
  • geoadd key longitude1 latitude1member1 [longitude2 latitude2 member2..]

    • 多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中

    • 实操GEOADD city 116.403963 39.915119 "天安门" 116.403414 39.924091 "故宫" 116.024067 40.362639 "长城"

  • geopos key member1 member2[...]:从键里面返回所有给定位置元素的位置(经度和纬度)

    image-20230913132529921

  • geohash key member1 member2[...]:返回坐标的geohash表示

  • geodist key member1 member2 [m/km/ft/mi]:返回两个给定位置之间的距离

    • 后面参数是距离单位 m 米、km 千米、ft 英尺、mi 英里

  • georadius key longitude latitude [withdist] [withcoord] [count num] [withhash]

    • 以给定的经纬度为中心,返回与中心的距离不超过给定最大距离所有位置元素

      • withdist:在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回

        • 距离的单位和用户给定的范围单位保持一致

      • withcoord:将位置元素的经度和维度也一并返回

      • withhash:以 52 位有符号整数的形式, 返回位置元素经过原始 geohash 编码的有序集合分值

        • 这个选项主要用于底层应用或者调试, 实际中的作用并不大

      • count:限定返回num条记录

    • 实操(两条命令本质是一样的)

      • GEORADIUS city 116.418017 39.914402 10 km withdist withcoord count 10 withhash

      • GEORADIUS city 116.418017 39.914402 10 km withdist withcoord withhash count 10

      image-20230913133611531

      .

  • georadiusbymember key member [withdist] [withcoord] [count num] [withhash]

    • 指定名称的位置为中心,返回返回与中心的距离不超过给定最大距离的所有位置元素

Redis流(Stream)【不建议使用】
特点:redis版的MQ消息中间件+阻塞队列
  • redis消息队列的方案

    • List实现消息队列

      image-20230913160616666

      .

      • 按照插入顺序排序,你可以添加一个元素到列表的头部(左边)或者尾部(右边)

      • 常用来做异步队列使用

        • 将需要延后处理的任务结构体序列化成字符串塞进Redis 的列表

        • 另一个线程从这个列表中轮询数据进行处理

      • 缺点是只能一对一

    • Redis 发布订阅 (pub/sub)

      • 缺点

        • 消息无法持久化,如果出现网络断开、Redis 宕机等,消息就会被丢弃

        • 没有 Ack签收机制来保证数据的可靠性,假设一个消费者都没有,那消息就直接被丢弃了

  • 作用

    • 实现消息队列,它支持消息的持久化、支持自动生成全局唯一ID

    • 支持ack确认消息的模式、支持消费组模式等,让消息队列更加的稳定和可靠

  • 底层结构:是stream类型

    image-20230913161247391

    .

    元素解释
    Message Content消息内容
    Consumer group消费组,通过XGROUP CREATE 命令创建,同一个消费组可以有多个消费者
    Last_delivered_id游标,每个消费组会有个游标 last_delivered_id,任意一个消费者读取了消息都会使游标 last_delivered_id往后移动
    Consumer消费者,消费组中的消费者
    Pending_ids消费者会有一个状态变量,用于记录被当前消费已读取但未ack的消息Id,如果客户端没有ack,这个变量里面的消息ID会越来越多,一旦某个消息被ack它就开始减少。这个pending_ids变量在Redis官方被称之为 PEL(Pending Entries List),记录了当前已经被客户端读取的消息,但是还没有 ack (Acknowledge character:确认字符),它用来确保客户端至少消费了消息一次,而不会在网络传输的中途丢失了没处理
队列相关命令

image-20230913171200078

.

  • xadd key */id k1 v1 k2 v2 [..]:添加消息到队列末尾

    • 消息ID必须要比上个id大

    • 默认用*表示自动生成id

      • *用于XADD命令中,让系统自动生成自增 id

    • 如果指定的Stream 队列不存在,则该命令执行时会新建一个Stream队列

    image-20230913163212688

    .

    相关信息
    信息条目指的是序列号,在相同的毫秒下序列号从0开始递增,序列号是64位长度,理论上在同一毫秒内生成的数据量无法到达这个级别,因此不用担心序列号会不够用。millisecondsTime指的是Redis节点服务器的本地时间,如果存在当前的毫秒时间戳比以前已经存在的数据的时间戳小的话(本地时间钟后跳),那么系统将会采用以前相同的毫秒创建新的ID,也即redis 在增加信息条目时会检查当前 id 与上一条目的 id, 自动纠正错误的情况,一定要保证后面的 id 比前面大,一个流中信息条目的ID必须是单调增的,这是流的基础
    客户端显示传入规则:Redis对于ID有强制要求,格式必须是时间戳-自增Id这样的方式,且后续ID不能小于前一个ID
    Stream的消息内容,也就是图中的Message Content它的结构类似Hash结构,以key-value的形式存在
    • xdel key id1 [id2..]:根据id删除数据

    • xtrim:用于对stream的长度进行截取,如超长会进行截取

      • maxlen:截取的最大长度,即保留最新的几条数据

      • minid:截取的最小id,即在这id之前的数据都删掉

    • xrange key start end [Count count]

      • start 表示开始值,-代表最小值

      • end 表示结束值,+代表最大值

      • count 表示最多获取多少个值

    • xrevrange key end start[Count count]

      • 与XRANGE 的区别在于,获取消息列表元素的方向是相反的,end在前,start在后

    • xlen key:用于获取Stream队列的消息的长度

  • xread [Count count] [BLOCK milisecond] streams key [key ...] id/0/$ [...]

    • 用于获取消息 (阻塞/非阻塞) ,只会返回大于指定ID的消息

    • Count:最多读取多少条消息

    • BLOCK:是否以阻塞的方式取消息,默认不阻塞,如果milliseconds设置为0,表示永远阻塞

    • 特殊符号

      image-20230913170228526

      • $代表从当前最大id获取Stream中的消息

        • 不阻塞则Stream中不存在大于当前最大ID的消息,此时返回nil

        • 阻塞则等待最新消息的输入并拦截

        image-20230913170935530

        .

      • 0代表从最小的id开始获取Stream中的消息,不指定count,将会返回Stream中的所有消息

消费组相关命令
  • xgroup create key id/0/$:用于创建消费者组

    • 必须指定ID, ID 为 0 表示从头开始消费,为 $ 表示只消费新的消息

      • $表示从Stream尾部开始消费

      • 0表示从Stream头部开始消费

  • xreadgroup group 组名 消费者名 streams key > [Count count]

    • >表示从第一条尚未被消费的消息开始读取

    • count可以指定单个消费者读取条数

      • 让组内的多个消费者共同分担读取消息

        • 通常会让每个消费者读取部分消息,从而实现消息读取负载在多个消费者间是均衡分布的【消费组的目的】

      • 如果没有指定则当前的消费者会全读完

      image-20230913172732122

      .

    • 消费组groupA内的消费者consumer1从mystream消息队列中读取所有消息

  • 消息的签收

    问题基于 Stream 实现的消息队列,如何保证消费者在发生故障或宕机再次重启后,仍然可以读取未处理完的消息?
    Streams 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息保底措施,直到消费者使用 XACK命令通知 Streams“消息已经处理完成”
    "消费确认"增加了消息的可靠性,一般在业务处理完成之后,需要执行XACK命令确认消息已经被消费完成

    image-20230914210206427

    .

    • xpending

      • xpending key group:查询每个消费组内所有消费者已读取、但尚未确认的消息

        • 组内只有一个消费者就全读

          image-20230914210711896

          .

        • 有多个消费者就平均分配

          image-20230914210726205

          .

      • xpending key group - + count consumer:查询消费者范围内的count条信息中读取了哪些消息

        image-20230914211508271

        .

    • xack key group id:向消息队列确认消息处理已完成

      • 已签收的消息无法再被读取到

      image-20230914211921431

      .

特殊符号
  • - +:最小和最大可能出现的ld

  • $ :表示只消费新的消息,当前流中最大的 id,可用于将要到来的信息

  • >:用于XREADGROUP命令,表示迄今还没有发送给组中使用者的信息,会更新消费者组的最后ID

  • *:用于XADD命令中,让系统自动生成 id

Redis位域(bitfield)【了解】
用途
  • BITFIELD 命令的作用在于它能够将很多小的整数储存到一个长度较大的位图中,又或者将一个非常庞大的键分割为多个较小的键来进行储存,从而非常高效地使用内存,使得 Redis 能够得到更多不同的应用——特别是在实时分析领域BITFIELD 能够以指定的方式对计算溢出进行控制的能力,使得它可以被应用于这一领域

  • 将一个Redis字符串看作是一个由二进制位组成的数组,并能对变长位宽和任意没有字节对产的指定整型位域进行寻址和修改

  • 综上有两种功能:位域修改和溢出控制

命令
  • bitfield key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP/SAT/FAIL]

    • GET<type> <offset>:返回指定的位域

    • SET<type> <offset> <value>:设置指定位域的值并返回它的原值

    • INCRBY<type> <offset> <increment>:自增或自减(如果increment为负数) 指定位域的值并返回它的新值

    • OVERFLOW [WRAP/SAT/FAIL]:通过设置溢出行为来改变调用INCRBY指令的后序操作

      • WRAP: 使用回绕 方法处理有符号整数和无符号整数的溢出情况

      image-20230914215922623

      .

      • SAT: 使用饱和计算方法处理溢出,下溢计算的结果为最小的整数值,而上溢计算的结果为最大的整数值

      image-20230914215959142

      .

      • FAIL:命令将拒绝执行那些会导致上溢或者下溢情况出现的计算并向用户返回空值表示计算未被执行

      image-20230914220055190

      .

  • 当需要一个整型时,有符号整型需在位数前加i,无符号在位数前加u

    • 例如,u8是一个8位的无符号整型,i16是一个16位的有符号整型

!!!Redis持久化

!!!RDB

基础知识
定义
  • 在指定的时间间隔,执行数据集的时间点快照

    • 实现类似照片记录效果的方式,就是把某一时刻的数据和状态以文件的形式写到磁盘上,也就是快照

    • 即使故障宕机,快照文件也不会丢失,数据的可靠性也就得到了保证

    • 这个快照文件就称为RDB文件(dump.rdb),其中,RDB就是Redis DataBase的缩写

功能
  • 在指定的时间间隔将内存中的数据集快照写入磁盘,也就是Snapshot内存快照,它恢复时再将硬盘快照文件直接读回到内存

  • Redis的数据都在内存中,保存备份时它执行的是全量快照,也就是说把内存中的所有数据都记录到磁盘中

redis中rdb配置介绍
  • redis6.016以下

image-20230915125358605

.

  • redis7

image-20230915124959095

.

!!!基础操作
自动触发
配置文件
  • 修改dump文件的保存路径为/myredis/dumpfiles

    image-20230915132742377

    • 默认保存路径在当前文件夹下

      image-20230915132506049

    • 自定义修改的路径可以通过进入redis里用config get dir获取

      image-20230918103536801

    • 路径不能为空

  • 修改dump文件名称【建议加上当前端口号以示区分,多台主机才适用】

    image-20230915133235801

    • 默认文件名称是dump.rdb

      image-20230915133102150

  • 设置rdb的触发条件

    image-20230919104247790

    【注释不能和配置信息在同一行】

    • 修改完配置文件之后建议重启一下服务

测试
  1. 打开一个新会话用于观察rdb文件创建情况

  2. 测试会话连接数据库之后,在10秒内进行两次修改

  3. 观察dumpfiles目录变化

    image-20230918110105113

    .

  4. 两次修改间距10秒以上,观察dumpfiles目录变化

image-20230918121949144

.

  1. 得出结论:每10秒检查一次,如果累计修改次数达到2次,就写入数据

  2. 测试备份

    1. 先清空数据库flushdb

      • 在执行类似于提交操作的命令时,redis默认也会进行备份

      • 清空数据库之后,保存的其实是清空后的备份

      image-20230918122547156

      .

      • 当服务器宕机的时候,也会执行备份,保存当前快照

    2. redis重启时,将会读取配置文件,找到路径下的备份文件进行数据恢复

      • 为保证能正常恢复到上一次的备份,需要先将备份文件重命名【以免被误提交等操作覆盖】,备份时在修改回来

        • mv 文件名 新文件名

        image-20230918123150700

        .

  3. 重新启动服务【在redis会话中执行shutdown】,删除shutdown保存的备份,将原备份重命名

    • 不可以把备份文件dump.rdb和生产redis服务器放在同一台机器,必须分开各自存储,以防生产机物理损坏后备份文件也挂了

    image-20230918124134720

    .

  4. 读取备份成功

    image-20230918124328068

    .

手动触发
  • 两种方式

    • Save

      • 在主程序中执行会阻塞当前redis服务器,直到持久化工作完成执行save命令期间,Redis不能处理其他命令,线上禁止使用

      image-20230918130225937

      .

    • BGSAVE(默认)

      • 会在后台异步进行快照操作,不阻塞快照同时还可以响应客户端请求,该触发方式会fork一个子进程由子进程复制持久化过程

        • 在Linux程序中,fork()会产生一个和父进程完全相同的子进程,但子进程在此后多会被exec系统调用,出于效率考虑,尽量避免膨胀【如git中的分支】

      image-20230918130748033

      .

      • Redis会使用bgsave对当前内存中的所有数据做快照,这个操作是子进程后台完成的,这就允许主进程同时可以修改数据

  • lastsave:可以获取最后一次成功执行快照的时间

    image-20230918131354070

    .

优劣势
  • 优势

    • 适合大规模的数据恢复

    • 按照业务定时备份

    • 对数据完整性和一致性要求不高

    • RDB 文件在内存中的加载速度要比 AOF 快得多

  • 劣势

    • 在一定间隔时间做一次备份,所以如果redis意外down掉的话,就会丢失从当前至最近一次快照期间的数据,快照之间的数据会丢失

    • 内存数据的全量同步,如果数据量太大会导致I/O严重影响服务器性能

    • RDB依赖于主进程的fork,在更大的数据集中,可能会导致服务请求的瞬间延迟。fork的时候内存中的数据被克隆了一份,大致膨胀了2倍

其它知识
检查和修改rdb文件
  • redis-check-rdb rdb文件

触发rdb快照的情况
  • 配置文件中默认的快照配置

  • 手动save/bgsave命令

  • 执行flushall/flushdb命令也会产生dump.rdb文件,但里面是空的,无意义

  • 执行shutdown且没有设置开启AOF持久化

  • 主从复制时,主节点自动触发

禁用快照
  • 动态所有停止RDB保存规则的方法【只禁用这一次】: redis-cli config set save ""

  • 配置文件中配置save "",完全禁用快照

配置文件详解
  • stop-writes-on-bgsave-error

    • 默认yes,如果配置成no,表示不在乎数据不一致或者有其他的手段发现和控制不一致,在快照写入失败时也能确保redis继续接受新的写请求

  • rdbcompression

    • 默认yes,对于存储到磁盘中的快照,可以设置是否进行压缩存储

      • yes表示redis会采用LZF算法进行压缩

      • 不想消耗CPU来进行压缩的话,可以设置为no

  • rdbchecksum

    • 默认yes,在存储快照后,还可以让redis使用CRC64算法来进行数据校验,但是这样做会增加大约10%的性能消耗

    • 如果希望获取到最大的性能提升,可以关闭此功能

  • rdb-del-sync-files

    • 没有持久性的情况下删除复制中使用的RDB文件。默认情况下为no【禁用】

!!!AOF

基础知识
定义
  • 日志的形式来记录每个写操作【不考虑查询操作】,将Redis执行过的所有写指令记录下来(读操作不记录),只许追加文件但不可以改写文件

  • redis启动之初会读取该文件重新构建数据,换言之,redis重启的话就根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作【重做】

  • 默认情况下,redis是没有开启AOF(append only file)的。开启AOF功能需要设置配置:appendonly yes

  • AOF保存的是appendonly.aof文件

AOF持久化工作流程

image-20230918204656764

.

  1. Client作为命令的来源,会有多个源头以及源源不断的请求命令

  2. 在这些命令到达Redis Server以后并不是直接写入AOF文件,会将其这些命令先放入AOF缓存中进行保存A

    • AOF缓冲区实际上是内存中的一片区域,目的是当这些命令达到一定量以后再写入磁盘,避免频繁的磁盘IO操作

  3. AOF缓冲会根据AOF缓冲区同步文件的三种写回策略将命令写入磁盘上的AOF文件

  4. 随着写入AOF内容的增加为避免文件膨胀,会根据规则进行命令的合并(又称AOF重写),从而起到AOF文件压缩的目的

  5. 当Redis Server 服务器重启的时候会从AOF文件载入数据

三种写回策略
  • Always:同步写回,每个写命令执行完立刻同步地将日志写回磁盘

  • everysec:每秒写回,每个写命令执行完,先把日志写到AOF文件的内存缓冲区,每隔1秒把缓冲区中的内容写入磁盘

  • no:操作系统控制的写回,每个写命令执行完,先把日志写到AOF文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘

  • !!!面试题:三种写回策略的区别,为什么默认用everysec

image-20230918205553675

.

!!!aof的配置相关
  • 开启aof(在配置文件的1380行)

    image-20230918210216472

  • aof文件保存路径

    • redis6:AOF保存文件的位置和RDB保存文件的位置一样,都是通过redis.conf配置文件的dir配置

    • redis7之后的最终路径:dir + appenddirname

    image-20230918210825605

    image-20230918211009620

  • 保存名称

    • redis6:有且只有一个保存名称

      image-20230918211331099

    • redis7之后单个aof文件拆分成三部分

      image-20230918211543612

      image-20230918212038741

      • BASE:表示基础AOF,一般由子进程通过重写产生,该文件最多只有一个

      • INCR:表示增量AOF,一般会在AOFRW开始执行时被创建,该文件可能存在多个

      • HISTORY:表示历史AOF,它由BASE和INCR AOF变化而来,每次AOFRW成功完成时本次AOFRW之前对应的BASE和INCR AOF都将变为HISTORY,HISTORY类型的AOF会被Redis自动删除

      • 为了管理这些AOF文件,引入了一个manifest (清单) 文件来跟踪、管理这些AOF。同时为了便于aof备份和拷贝,将所有的AOF文件和manifest文件放入一个单独的文件目录中,目录名由appenddirname配置

  • 有关配置一览表

image-20230919111932258

.

基础操作
正常恢复
  • rdb和aof共享同一个dir【aof文件放在dir指定目录的子目录下】

image-20230918212901441

.

  • appendonlydir下有三种类型的aof文件

image-20230918213021261

.

  • flushdb+shutdown服务器会更新aof文件【同rdb】

    • flushdb也是写操作

    • 因此也要像rdb一样进行更改文件名来保存副本

  • 对数据进行修改一般变化incr【增量】文件

异常恢复
  • 如果写的时候出现异常【未全部写入aof】,则下次启动服务器无法连接

    image-20230918214332893

  • 修复命令:redis-check-aof --fix 需要修复的aof文件

    • 会清空不符合语法规则的内容

优劣势
优势
  • 使用 AOF Redis 更加持久,可以有不同的 fsync【写回】策略

    • 默认使用每秒 fsync 的默认策略,写入性能仍然很棒

    • fsync是使用后台线程执行的,当没有fsync正在进行时,主线程将努力执行写入,因此只会丢失一秒钟的写入

  • AOF 日志是一个仅附加日志,因此不会出现寻道问题,也不会在断电时出现损坏问题。即使由于某种原因(磁盘已满或其他原因) 日志以写一半的命令结尾,redis-check-aof 工具也能够轻松修复它

  • 当AOF 变得太大时,Redis 能够在后台自动重写 AOF。重写是完全安全的,因为当 Redis 继续附加到旧文件时,会使用创建当前数据集所需的最少操作集生成一个全新的文件,一旦第二个文件准备就绪,Redis 就会切换两者并开始附加到新的那一个

  • AOF 以易于理解和解析的格式依次包含所有操作的日志。甚至可以轻松导出 AOF 文件

    • 即使不小心使用该FLUSHALL命令刷新了所有内容,只要在此期间没有执行日志重写,仍然可以通过停止服务器、删除最新命令并重新启动 Redis 来保存您的数据集

劣势
  • 相同数据集的数据而言aof文件要远大于rdb文件,恢复速度慢于rdb

  • aof运行效率要慢于rdb,每秒同步策略效率较好,不同步效率和rdb相同

!!!重写机制
定义
  • AOF持久化是Redis不断将写命令记录到 AOF 文件中,随着Redis不断的进行,AOF 的文件会越来越大

    • 文件越大,占用服务器内存越大以及 AOF 恢复要求时间越长

  • 为了解决这个问题,Redis新增了重写机制,当AOF文件的大小超过所设定的峰值时,Redis就会自动启动AOF文件的内容压缩,

    只保留可以恢复数据的最小指令集或者可以手动使用命令bgrewriteaof来重写

最小指令集的案例理解
  • 比如有个key

    1. 一开始 set k1 v1

    2. 然后改成 set k1 v2

    3. 最后改成 set k1 v3

  • 如果不重写,那么这3条语句都在aof文件中,内容占空间且启动的时候都要执行一遍

  • 实际效果只需要set k1 v3这一条,所以开启重写后,只需要保存set k1 v3【最新一次修改】就可以了只需要保留最后一次修改值,相当于给aof文件瘦身,性能更好

  • AOF重写不仅降低了文件的占用空间,同时更小的AOF也可以更快地被Redis加载

触发机制
前期环境准备
  1. 为了易于触发,先把满足文件的大小改为1kauto-aof-rewrite-min-size 1k

  2. 关闭rdb和aof的混合机制aof-use-rdb-preamble

    image-20230918221249791

    • 这个只是为了便于测试,之后还是要打开的

  3. 删除之前的全部aof和rdb,清除干扰项

自动
  • 同时满足以下两个调节才会触发

    • 根据上次重写后的aof大小,判断当前aof大小是不是增长了1倍

    • 重写时满足的文件大小

  • 默认配置是当AOF文件大小比上次rewrite的大小增长了一倍文件大于64M

    • auto-aof-rewrite-percentage 100

    • auto-aof-rewrite-min-size 64mb

  • 步骤

    1. 执行set k1 v1,检查配置是否正常

    2. 对k1进行重复赋值,直到增量文件大小达到1k

    3. 观察合并情况

      • base和incr文件名发生变化

      • incr中数据情况

      • 合并后的数据放到base中

    image-20230919110516951

    .

    1. 观察合并后的base文件

      • 可以看到k1的修改只存储了最新一次的结果

    image-20230919110710802

    .

手动
  • 客户端向服务器发送bgrewriteaof命令

    image-20230919111001536

    • 没有达到指定条件也会立即触发

      image-20230919111103709

重写原理
  • 结论

    • AOF 文件重写并不是对原文件进行重新整理,而是直接读取服务器现有的键值对,然后用一条命令代替之前记录这个键值对的多条命令,生成一个新的文件后去替换原来的 AOF 文件

    • AOF 文件重写触发机制:通时redis.conf配置文件中的 auto-aof-rewrite-percentage以及auto-aof-rewrite-min-size

  • 原理

    1. 在重写开始前,redis会创建一个“重写子进程”,这个子进程会读取现有的AOF文件,并将其包含的指令进行分析压缩并写入到一个临时文件

    2. 与此同时,主进程会将新接收到的写指令一边累积到内存缓冲区中,一边继续写入到原有的AOF文件中,这样做是保证原有的AOF文件的可用性,避免在重写过程中出现意外

    3. 当“重写子进程”完成重写工作后,它会给父进程发一个信号,父进程收到信号后就会将内存中缓存的写指令追加到新AOF文件中

    4. 当追加结束后,redis就会用新AOF文件来代替旧AOF文件,之后再有新的写指令,就都会追加到新的AOF文件中

    5. 重写aof文件的操作,并没有读取旧的aof文件,而是将整个内存中的数据库内容用命令的方式重写了一个新的aof文件,这点和快照有点类似

rdb和aof的混合持久化

  • 同时开启rdb 和aof 持久化时,重启时只会加载 aof 文件,不会加载 rdb 文件

    • 在这种情况下,当redis重启的时候会优先载入AOF文件来恢复原始的数据

      • 因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整

    • RDB的数据不实时,同时使用两者时服务器重启也只会找AOF文件

    • 建议不要只使用AOF,因为RDB更适合用于备份数据库(AOF在不断变化不好备份),留着rdb作为一个万一的手段

image-20230919132359024

.

  • 混合方式设置:aof-use-rdb-preamble的值设为为 yes

    • yes表示开启,设置为no表示禁用

  • 混合方式中RDB镜像做全量持久化,AOF做增量持久化

    • 先使用RDB进行快照存储,然后使用AOF持久化记录所有的写操作,当重写策略满足或手动触发重写的时候,将最新的数据存储为新的RDB记录

    • 重启服务的时候会从RDB和AOF两部分恢复数据,既保证了数据完整性,又提高了恢复数据的性能

      image-20230919134459667

    • 混合持久化方式产生的文件一部分是RDB格式,一部分是AOF格式----》AOF包括了RDB头部+AOF混写

纯缓存模式

  • 禁用

    • rdb

      • 配置文件中配置save ""

      • 禁用rdb持久化模式下,仍然可以使用命令save、bgsave生成rdb文件

    • aof

      • 配置文件中配置appendonly no

      • 禁用aof持久化模式下们仍然可以使用命令bgrewriteaof生成aof文件

Redis事务

基础知识
  • 官方文档https://redis.io/docs/interact/transactions/

  • 定义

    • 可以一次执行多个命令,本质是一组命令的集合

    • 一个事务中的所有命令都会序列化,按顺序地串行化执行而不会被其它命令插入,不许加塞

  • 作用:一个队列中,一次性、顺序性、排他性的执行一系列命令

特点解释
单独的隔离操作Redis的事务仅仅是保证事务里的操作会被连续独占的执行,redis命令执行是单线程架构 在执行完事务内所有指令前是不可能再去同时执行其他客户端的请求的
没有隔离级别的概念因为事务提交前任何指令都不会被实际执行 不存在”事务内的查询要看到事务里的更新,在事务外查询不能看到”这种问题
不保证原子性Redis的事务不保证原子性,也就是不保证所有指令同时成功或同时失败,只有决定是否开始执行全部指令的能力,没有执行到一半进行回滚的能力
排它性Redis会保证一个事务内的命令依次执行,而不会被其它命令插入

基础操作

事务相关命令
  • discard:取消事务,放弃执行事务块内的所有命令

  • exec:执行所有事务块内的命令

  • multi:标记一个事务块的开始

  • unwatch:取消 watch命令对所有key的监视

  • watch key [key ...监视一个(或多个) key,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断

测试
全体连坐
  • 开启事务之后,编写的错误命令编译不通过,则无法正常执行

image-20230919141656538

.

冤头债主
  • 开启事务之后,编写的错误命令通过了编译【事务未检查出来】,执行之后,除错误命令之外的所有命令都会提交

image-20230919141857274

.

watch监控
  • Redis使用Watch来提供乐观锁定,类似于CAS(Check-and-Set)

    • 悲观锁(Pessimistic Lock), 每次去拿数据的时候都认为别人会修改,所以在拿数据的时候都会上锁,别人想拿这个数据就会block直到他拿到锁

    • 乐观锁(Optimistic Lock),每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据

      • 乐观锁策略:提交版本必须大于记录当前版本才能执行更新

  • 测试步骤

    1. 初始化k1和balance两个key,先监控再开启multi,保证两key变动在同一个事务内

      image-20230919143441269

    2. 测试加塞,监控balance,在balance赋值之前另一个会话对其进行修改

      image-20230919144436560

  • 拓展

    • 执行了exec,之前加的监控锁都会被取消掉

    • 当客户端连接丢失的时候(比如退出链接),所有东西都会被取消监视

小总结
  1. 开启:以MULTI开始一个事务

  2. 入队:将多个命令入队到事务中,接到这些命令并不会立即执行而是放到等待执行的事务队列里面

  3. 执行:由EXEC命令触发事务

Redis管道

!!!面试题:如何优化频繁命令往返造成的性能瓶颈

问题由来
  • Redis是一种基于客户端-服务端模型以及请求/响应协议的TCP服务。一个请求会遵循以下步骤:

    1. 客户端向服务端发送命令,分四步(发送命令→命令排队→命令执行→返回结果),并监听Socket返回,通常以阻塞模式等待服务端响应

    2. 服务端处理命令,并将结果返回给客户端

  • 上述两步称为:Round Trip Time(简称RTT,数据包往返于两端的时间)

image-20230919151539071

.

  • 如果同时需要执行大量的命令,那么就要等待上一条命令应答后再执行,这中间

    1. 不仅仅多了RTT(Round Time Trip),而且还频繁调用系统IO,发送网络请求

    2. 同时需要redis调用多次read()和write()系统方法,系统方法会将数据从用户态转移到内核态

    3. 这样就会对进程上下文有比较大的影响,性能不太好,o(╥﹏╥)o

解决思路
  • 管道(pipeline)可以一次性发送多条命令给服务端,服务端依次处理完完毕后,通过一条响应一次性将结果返回减少客户端与redis的通信次数来实现降低往返延时时间。pipeline实现的原理是队列,先进先出特性可以保证数据的顺序性

image-20230919151812230

.

基础知识
  • 定义:Pipeline是为了解决RTT往返回时,仅仅是将命令打包一次性发送,对整个Redis的执行不造成其它任何影响

  • 操作:将所要执行的命令放在一个文件中,在外部读取并用redis -a 密码 --pipe命令执行

    image-20230919152624114

    .

总结
  • Pipeline与原生批量命令对比

    • 原生批量命令是原子性(例如:mset,mget),pipeline是非原子性

    • 原生批量命令一次只能执行一种命令,pipeline支持批量执行不同命令

    • 原生批命令是服务端实现,而pipeline需要服务端与客户端共同完成

  • Pipeline与事务对比

    • 事务具有原子性【具有个鬼】,管道不具有原子性

    • 管道一次性将多条命令发送到服务器,事务是一条一条的发,事务只有在接收到exec命令后才会执行,管道不会

    • 执行事务时会阻塞其他命令的执行,而执行管道中的命令时不会

  • 使用Pipeline注意事项

    • pipeline缓冲的指令只是会依次执行,不保证原子性,如果执行中指令发生异常将会继续执行后续的指令

    • 使用pipeline组装的命令个数不能大多,不然数据量过大客户端阻塞的时间可能过久,同时服务端此时也被迫回复一个队列答复,占用很多内存【少量多次】

Redis发布订阅(废的,完全没必要了解)

  • 定义:是一种消息通信模式,发送者(PUBLISH)发送消息,订阅者(SUBSCRIBE)接收消息,可以实现进程间的消息传递

  • 作用

    • Redis客户端可以订阅任意数量的频道,类似我们微信关注多个公众号

    • 发布/订阅其实是一个轻量的队列,只不过数据不会被持久化,一般用来处理实时性较高的异步消息

  • 命令

    • SUBSCRIBE channel [channel ...]

      • 订阅给定的一个或多个频道的信息

      • 推荐先订阅后发布,订阅成功之前发布的消息是收不到的

      • 订阅的客户端每次可以收到一个3个参数的消息

        image-20230919155200211

        • 消息的种类

        • 始发频道的名称

        • 实际的消息内容

    • PUBLISH channel message:发布消息到指定的频道

    • PSUBSCRIBE pattern [pattern...]:按照模式批量订阅,订阅一个或多个符合给定模式(支持*号?号之类的)的频道

    • PUBSUB subcommand [argument [argument ...]]:查看订阅与发布系统状态

      • PUBSUB CHANNELS:由活跃频道组成的列表

      • PUBSUB NUMSUB [channel [channel ...]]:某个频道有几个订阅者

      • PUBSUB NUMPAT:只统计使用PSUBSCRIBE命令带模式执行的结果的数量

    • UNSUBSCRIBE [channel [channel ...]]:取消订阅

    • PUNSUBSCRIBE [pattern [pattern ...]]:退订所有给定模式的频道

  • 缺点

    • 发布的消息在Redis系统中不能特久化,因此,必须先执行订阅,再等待消息发布。如果先发布了消息,那么该消息由于没有订阅者,消息将被直接丢弃

    • 消息只管发送对于发布者而言消息是即发即失的,不管接收,也没有ACK机制,无法保证消息的消费成功

    • 以上的缺点导致Redis的Pub/Sub模式就像人小玩具,在生产环境中几乎无用武之地,为此Redis5.0版本新增了Stream数据结构,不但支持多播,还支持数据持久化,相比Pub/Sub更加的强大

!!!Redis复制

基础知识
  • 定义

    • 就是主从复制,master【主机】以写为主,slave【从机】以读为主

    • 当master数据变化的时候,自动将新的数据异步同步到其它slave数据库

  • 作用

    • 读写分离:减轻主机压力

    • 容灾恢复:主机故障后,由于从机一直备份主机数据,可以用于恢复数据

    • 数据备份

    • 水平扩容支撑高并发

基本使用

  • 配从库不配主库:从库的配置文件中要指定主库的信息

  • 重要权限细节:master如果配置了requirepass参数【需要密码登陆】

    • 那么slave就要配置masterauth 主机密码来设置校验密码

    • 否则master会拒绝slave的访问请求

  • 基本操作命令

    • info replication:可以查看复制节点的主从关系和配置信息

    • replicaof 主库IP 主库端口:指定依附的主机,一般写入进redis.conf配置文件内

    • slaveof 主库IP 主库端口

      • 每次与master断开之后,都需要重新连接,除非你配置进redis.conf文件

      • 在运行期间修改slave节点的信息,如果该数据库已经是某个主数据库的从数据库,那么会停止和原主数据库的同步关系转而和新的主数据库同步【移情别恋】

    • slaveof no one:使当前数据库停止与其他数据库的同步转成主数据库

!!!案例演示

  • 先检查主机和从机是否能正常连接【相互ping以下各自的ip地址】

修改配置文件

【先将安装包最原始的配置文件复制到/myredis目录下】

  1. 开启daemonize yes

  2. 注释掉bind 127.0.0.1

  3. protected-mode no

  4. 指定端口 ,port【从机的端口号要和主机不一样

    • 之后从机进行连接时必须指定端口号【因为默认端口号是6379】,否则会报错

      image-20230920133225634

  5. 指定当前工作目录,dir

  6. pid文件名字,pidfile【默认已经配置好了】

  7. log文件名字,logfile

    image-20230921192235935

  8. requirepass 访问密码

  9. dump.rdb名字,dbfilename【这里建议加上端口号以示区分】

  10. aof文件,appendfilename【非必须】

  11. 从机必须配置【主机不用】

  • 访问主机的通行密码masterauth

    image-20230920125019964

  • 指定依附的主机

    image-20230920125652788

!!!主从复制
配置文件写死
  • 日志

    • 主机日志

      image-20230921194306947

    • 从机日志

      image-20230921195008552

      .

  • 命令查看主从信息:info replication

    • 主机

      image-20230921195356467

      .

    • 从机

      image-20230921195544723

      .

  • 主从复制失败解决

    • 关闭主机的防火墙 systemctl stop firewalld【仅限于本次操作】

    image-20230921194501604

    ..

    • 使用图形化工具关闭防火墙服务【永久】

      1. 命令行输入setup

      2. 按tab移动光标

      3. 找到firewalld.service

        image-20230923123147436

      4. 按空格取消自动启动

问题演示
  • 从机只能读取信息,不能执行写操作

    image-20230921195928749

  • slave是从切入点开始复制

    • slave掉队后再次连接会把缺的都补上【详情可以看工作流程的第五步

    • 在连接过程中主机写一条就跟一条

  • 主机shutdown后,从机原地待命,但是数据可以正常使用

    image-20230921200727928

    .

  • 主机shutdown后,重启后从机还能顺利复制

    image-20230921200629205

    image-20230921200649260

手动指定操作
  1. 从机停机去掉配置文件中的配置项,3台目前都是主机状态,各不从属

  2. 从机执行命令slaveof 主库IP 主库端口

    image-20230921201837404

    .

  3. 从机重启之后,主从关系消失--》命令只对当次连接

薪火相传和反客为主
薪火相传
  • slave可以接收其他slaves的连接和同步请求,该slave作为链条中下一个的master可以有效减轻主master的写压力

  • 中途变更转向

    • 会清除之前的数据,重新建立拷贝最新的

    • 依靠的主机如果是slave,那么该主机也不能执行写操作

  • 命令:slaveof 新主库IP 新主库端口

反客为主
  • slaveof no one:使当前数据库停止与其他数据库的同步转成主数据库

复制原理和工作流程

  1. slave启动,同步初请

    • slave启动成功连接到master后会发送一个sync命令

    • slave首次全新连接master,一次完全同步(全量复制)将被自动执行,slave自身原有数据会被master数据覆盖清除

  2. 首次连接,全量复制

    • master节点收到sync命令后会开始在后台保存快照(即RDB持久化,主从复制时会触发RDB)

      • 收集所有接收到的用于修改数据集命令缓存起来

      • master节点执行RDB持久化完后master将rdb快照文件和所有缓存的命令发送到所有slave,以完成一次完全同步

    • 而slave服务在接收到数据库文件数据后,将其存盘并加载到内存中,从而完成复制初始化

  3. 心跳持续,保持通信

    • repl-ping-replica-period 10:master发出PING包的周期,默认是10秒

  4. 进入平稳,增量复制

    • Master继续将新的所有收集到的修改命令自动依次传给slave,完成同步

  5. 从机下线,重连续传

    • master会检查backlog里面的offset,master和slave都会保存一个复制的offset和masterId

      • offset是保存在backlog中的

    • Master只会把已经复制的offset后面的数据复制给Slave,类似断点续传

复制的缺点

  • 复制延时,信号衰减

    • 所有的写操作都是先在Master上操作,然后同步更新到Slave上,所以从Master同步到Slave机器有一定的延迟,当系统很繁忙的时候,延迟问题会更加严重,Slave机器数量的增加也会使这个问题更加严重

  • 主机挂了的影响

    • 默认情况下,不会在slave节点中自动重选一个master

    • 每次都要人工干预

!!!Redi哨兵

基础知识
  • 定义:吹哨人巡查监控后台master主机是否故障,如果故障了根据投票数自动将某一个从库转换为新主库,继续对外服务

  • 官网https://redis.io/docs/management/sentinel/

  • 功能【未使用redis集群时可以使用】

    • 主从监控:监控主从redis库运行是否正常

    • 消息通知:哨兵可以将故障转移的结果发送给客户端

    • 故障转移:如果Master异常,则会自动进行主从切换将其中一个Slave作为新Master

    • 配置中心:客户端通过连接哨兵来获得当前Redis服务的主节点地址

基础操作

架构环境
  • 3个哨兵:用于数据读取和存放

  • 1主2从:自动监控和维护集群,不存放数据,只是吹哨人

  • !!!注意三台虚拟机都要关闭防火墙,否则选举时无法访问新主机

    image-20230921210922664

    .

配置sentinel文件
  • 前面配置redis的配置要照搬过来

  • 除此之外还需要配置

    • sentinel monitor <master-name> <ip> <redis-port> <quorum>:设置要监控的master服务器和确认客观下线最少哨兵数量

      • quorum表示最少有几个哨兵认可客观下线,同意故障迁移的法定票数

        • 一个sentinel会因为网络堵塞而误以为一个master redis已经死掉了,在sentinel集群环境下需要多个sentinel互相沟通来确认某个master是否真的死了,quorum是进行客观下线的一个依据,意思是至少有quorum个sentinel认为这个master有故障,才会对这个master进行下线以及故障转移

        • 需要多个sentinel都一致认为该master有问题,才可以进行下一步操作,这就保证了公平性和高可用

    • sentinel auth-pass <master-name> <password> :master设置了密码,连接master服务的密码

    • sentinel down-after-milliseconds <master-name> <milliseconds>:指定多少毫秒之后,主节点没有应答哨兵,此时哨兵主观上认为主节点下线

  • 其它配置(用默认配置即可)

    • sentinel parallel-syncs <master-name> <nums>:表示允许并行同步的slave个数,当Master挂了后,哨兵会选出新的Master,此时剩余的slave会向新的master发起同步数据

    • sentinel failover-timeout <master-name> <milliseconds>:故障转移的超时时间,进行故障转移时,如果超过设置的毫秒,表示故障转移失败

    • sentinel notification-script <master-name> <script-path> :配置当某一事件发生时所需要执行的脚本

    • sentinel client-reconfig-script <master-name> <script-path>:客户端重新配置主节点参数脚本

  • 通用配置,直接复制到对应的配置文件即可

    • 由于硬件配置有限,所以将三个哨兵都放在一台虚拟机中

    bind 0.0.0.0

    daemonize yes

    protected-mode no

    port 26379

    logfile "/myredis/sentinel26379.log"

    pidfile /var/run/redis-sentinel26379.pid 哨兵默认端口号,其它哨兵需要修改该配置

    dir /myredis

    sentinel monitor mymaster 192.168.32.100 6379 2

    sentinel auth-pass mymaster 1212go12

测试
  • 主机的配置文件中需要配置masterauth

    • 当原主机宕机时,哨兵会拉起从机作为主机,原主机重新启动时可能就变成从机,所以需要配置主机访问密码

    • 不然后续可能报错master_link_status:down

  1. 按主从关系启动三台机器

  2. 启动三个哨兵:redis-sentinel 哨兵配置文件 --sentinel

    image-20230923123606324

    .

  3. 查看哨兵日志

    image-20230923124130648

    .

  4. 查看配置文件变化

    image-20230923124411582

    .

  5. 测试master宕机

    • 从机仍然可以读取数据【可能要等待几秒的网络重新读取和规划】

      image-20230923140959015

      .

      broken pipe解释
      定义pipe是管道的意思,管道里面是数据流,通常是从文件或网络套接字读取的数据 当该管道从另一端突然关闭时,会发生数据突然中断,即是broken 对于socket来说,可能是网络被拔出或另一端的进程崩溃
      解决问题当该异常产生的时候,对于服务端并没有多少影响。因为可能是某个客户端突然中止了进程导致了该错误
      总结这个异常是客户端读取超时关闭了连接,这时候服务器端再向客户端已经断开的连接写数据时就发生了broken pipe异常!
    • 剩下的从机会选取一台作为新主机【能写能读】

      image-20230923135731647

      .

      • 如果没有swtich-master,可能是因为三台机子的防火墙未关闭导致无法正常访问

    • 原主机如果重新启动,只会作为从机

      image-20230923140122114

      .

  6. 对比配置文件【运行期间会被动态修改】

    • 哨兵配置文件

      image-20230923141956204

    • 原主机的redis配置文件

      image-20230923142114991

    • 新主机的redis配置文件中依赖主机的配置被清除

!!!运行流程和选举原理

  1. SDown主观下线(Subjectively Down)

    • SDOWN是单个sentine自己主观上检测到的关于master的状态

    • 从sentinel的角度来看,如果发送了PING心跳后,在一定时间内没有收到合法的回复,就达到了SDOWN的条件

    • 配置:sentinel down-after-milliseconds <masterName> <timeout>

      • 表示master在多长时间内一直没有给Sentine返回有效信息,这个配置就是进行主观下线的依据

  2. ODown客观下线(Obiectively Down):ODOWN需要一定数量的sentinel,多个哨兵达成一致意见才能认为master客观上已经宕掉

    image-20230923143605626

    .

    • quorum参数是进行客观下线的依据,即法定人数/票数

      • 至少有quorum个sentinel认为master有故障才会对master进行下线以及故障转移

      • 某个sentinel节点可能因为自身网络原因导致无法连接master,而此时master并没有出现故障

      • 需要多个sentinel都一致认为该master有问题,才可以进行下一步操作,这就保证了公平性和高可用

  3. 选举出领导者哨兵(哨兵中选出兵王)

    • 当主节点被判断客观下线以后,各个哨兵节点会进行协商,先选举出一个领导者哨兵节点 (兵王)

    • 由该领导者节点【被选举出的兵王】进行failover (故障迁移)

    image-20230923144623011

    .

    • 兵王是通过Raft算法选取出来的

      • 监视该主节点的所有哨兵都有可能被选为领导者

      • Raft算法的基本思路是先到先得

        • 即在一轮选举中,哨兵A向B发送成为领导者的申请,如果B没有同意过其他哨兵,则会同意A成为领导者

      image-20230923144842551

      .

  4. !!!由兵王开始推动故障切换流程并选出一个新master

    1. 新主登基:选出新master的规则【剩余slave节点都健康的前提下 】

      1. redis.conf文件中,优先级slave-priority或者replica-priority最高的从节点(数字越小优先级越高 )

      2. 复制偏移位置offset最大的从节点

        • 可能由于网络原因,不同从机复制的进度有差异,优先选择复制最完整的

      3. 最小Run ID的从节点 ,即字典顺序,ASCII码

      image-20230923150630030

      .

    2. 群臣俯首

      1. 选举出的从节点会执行slaveof no one命令使其成为新的主节点,并通过slaveof命令让其他节点成为其从节点

      2. Sentinel leader会对选举出的新master执行slaveof no one操作,将其提升为master节点

      3. Sentinel leader向其它slave发送命令,让剩余的slave成为新的master节点的slave

    3. 旧主拜服:将之前已下线的老master设置为新master的从节点,当老master重新上线后,它会成为新master的从节点

    image-20230923151528726

    .

使用建议

  • 哨兵节点的数量应为多个,哨兵本身应该集群,保证高可用

  • 哨兵节点的数量应该是奇数(防止平票)

  • 各个哨兵节点的配置应一致

  • 如果哨兵节点部署在Docker等容器里面,尤其要注意端口的正确映射

  • 哨兵集群+主从复制,并不能保证数据零丢失

!!!Redis集群

基础知识
  • 定义

    • 由于数据量过大,单个Master复制集难以承担,因此需要对多个复制集进行集群,形成水平扩展

    • 每个复制集只负责存储整个数据集的一部分,这就是Redis集群,其作用是提供在多个Redis节点间共享数据的程序集

    • Redis集群可以支持多个master

  • 功能

    • Redis集群支持多个Master,每个Master又可以挂载多个slave

      • 读写分离

      • 支持数据的高可用

      • 支持海量数据的读写存储操作

    • 由于Cluster自带Sentinel的故障转移机制,内置了高可用的支持,无需再去使用哨兵功能

    • 客户端与Redis的节点连接,不再需要连接集群中所有的节点,只需要任意连接集群中的一个可用节点即可

    • 槽位slot负责分配到各个物理服务节点,由对应的集群来负责维护节点、插槽和数据之间的关系

  • 缺点:Redis集群不保证强一致性,这意味着在特定的条件下,Redis集群可能会丢掉一些被系统收到的写入请求命令

!!!集群算法-分片-槽位slot

槽位slot
  • Redis集群的数据分片

    • Redis集群没有使用一致性hash,而是引入了哈希槽的概念

    • 集群中有16384个哈希槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽,集群的每个节点负责一部分hash槽

      • redis集群底层调用cluster.c文件下的crc16算法【了解】

    • 官网建议集群小于等于1000个

  • 假设集群有三个节点,则分配结果如下

image-20230923154408112

.

分片
  • 定义:使用Redis集群时会将存储的数据分散到多台redis机器

    • 简言之,集群中的每个Redis实例都被认为是整个数据的一个分片

  • 如何找到指定key的所在分片

    1. 对key进行CRC16(key)算法处理并通过对总分片数量取模

    2. 使用确定性哈希函数,这意味着给定的key将多次始终映射到同一个分片,可以推断将来读取特定key的位置

二者的优势
  • 总结:方便扩缩容和数据分派查找

  • 原理:这种结构很容易添加或者删除节点,比如

    • 想新添加个节点D,需要从节点 A,B,C中得部分槽到D上

    • 想移除节点A,需要将A中的槽移到B和C节点上,然后将没有任何槽的A节点从集群中移除即可

    • 把一个节点的哈希槽移动到另一个节点并不会停止服务,无论添加删除或改变某个节点的哈希槽数量都不会造成集群不可用的状态

!!!槽位映射算法
哈希取余分区
  • 公式:hash(key) % N个机器台数,计算出哈希值,用来决定数据映射到哪一个节点上

  • 优点

    • 简单粗暴,直接有效,只需要预估好数据规划好节点,就能保证一段时间的数据支撑

    • 使用Hash算法让固定的一部分请求落到同一台服务器上,每台服务器固定处理一部分请求(并维护这些请求的信息)

      • 起到负载均衡+分而治之的作用

  • 缺点

    • 原来规划好的节点,进行扩容或者缩容就比较麻烦,每次数据变动导致节点有变动,映射关系需要重新进行计算

    • 在服务器个数固定不变时没有问题,如果需要弹性扩容或故障停机的情况下,原来的取模公式会发生变化

      • 此时地址经过取余运算的结果将发生很大变化,根据公式获取的服务器也会变得不可控

      • 某个redis机器宕机了,由于台数数量变化,会导致hash取余全部数据重新洗牌

一致性Hash算法分区
  • 目的:为了解决分布式缓存数据变动和映射问题,某个机器宕机了,分母数量改变了,自然取余数不可行

  • 作用:提出一致性Hash解决方案。目的是当服务器个数发生变动时,尽量减少影响客户端到服务器的映射关系

  • 步骤

    1. 构建一致性哈希环

      • 一致性哈希算法产生hash值的所有可能哈希值会构成一个全量集,这个集合可以成为一个hash空间[0,2^32-1]

      • 是一个线性空间,但是在算法中,通过适当的逻辑控制将它首尾相连(0 = 2^32),让它逻辑上形成了一个环形空间

      • 它也是按照使用取模的方法,一致性Hash算法是对2^32取模

        • 一致性Hash算法将整个哈希值空间组织成一个虚拟圆环,假设某哈希函数H的值空间为0-2^32-1

        • 整个空间按顺时针方向组织,圆环的正上方的点代表0,0点右侧的第一个点代表1,以此类推,2、3、4、……

        • 直到2^32-1,0点左侧的第一个点代表2^32-1, 0和2^32-1在零点中方向重合,该圆环称为Hash环

    2. redis服务器IP节点映射

      • 将集群中各个IP节点映射到环上的某一个位置

        • 将各个服务器使用Hash进行一个映射,可以选择服务器的IP或主机名作为关键字进行哈希

        • 每台机器就能确定其在哈希环上的位置

      • 假如4个节点NodeA、B、C、D,经过IP地址的哈希函数计算(hash(ip)),使用IP地址哈希后在环空间的位置如下

        image-20230923161943445

        .

    3. key落到服务器的落键规则

      • 存储一个kv键值对时,首先计算key的hash值,将这个key使用相同的函数Hash计算出哈希值并确定此数据在环上的位置

      • 从此位置沿环顺时针“行走”第一台遇到的服务器就是其应该定位到的服务器,并将该键值对存储在该节点上

      • 如Object A、Object B、Object C、Object D四个数据对象,经过哈希计算后,在环空间上的位置如下

        • 根据一致性Hash算法,数据A、B、C、D会被定为到Node A、B、C、D上

        image-20230923162519740

        .

  • 优点

    • 容错性:如果一台服务器不可用,则受影响的数据仅仅是此服务器到其环空间中前一台服务器(即沿着逆时针方向行走遇到的第一台服务器)之间数据,其它服务器不会受到影响

    • 扩展性:数据量增加不会导致hash取余全部数据重新洗牌

      • 假设需要增加节点NodeX,位置在A和B之间,那受到影响的只是A到X之间的数据,重新把A到X之间的数据录入到X上即可

  • 缺点:Hash环的数据倾斜问题

    • 一致性Hash算法在服务节点太少时,容易因为节点分布不均匀而造成数据倾斜,即被缓存的对象大部分集中缓存在一台服务器

    image-20230923163302023

    .

哈希槽分区
  • 哈希槽实质就是一个数组,数组[0,2^14 -1]形成hash slot空间

  • 作用

    • 解决均匀分配的问题,在数据和节点之间又加入了一层,把这层称为哈希槽(slot),用于管理数据和节点之间的关系

    • 现在就相当于节点上放的是槽槽里放的是数据

    • 槽解决的是粒度问题,相当于把粒度变大了,这样便于数据移动

    • 哈希解决的是映射问题,使用key的哈希值来计算所在的槽,便于数据分配

  • 一个集群只能有16384个槽,编号0-16383(0~2^14-1)

  • 这些槽会分配给集群中的所有主节点,分配策略没有要求

    1. 集群会记录节点和槽的对应关系

    2. 解决了节点和槽的关系后,接下来就需要对key求哈希值

    3. 然后对16384取模,余数是几key就落入对应的槽里HASH_SLOT = CRC16(key) mod 16384

  • 以槽为单位移动数据,因为槽的数目是固定的,处理起来比较容易,这样数据移动问题就解决了

  • 计算规则

    • Redis 集群中内置16384 个哈希槽,redis会根据节点数量大致均等的将哈希槽映射到不同的节点

    • 当需要在 Redis 集群中放置一个 key-value时,redis先对key使用crc16算法算出一个结果然后用结果对16384求余数

    • 每个 key 都会对应一个编号在 0-16383 之间的哈希槽,也就是映射到某个节点上

!!!面试题:为什么redis集群的最大槽数是16384个
  • 如果槽位为65536,发送心跳信息的消息头达8k,发送的心跳包过于庞大

    • CRC16算法产生的hash值有16bit,该算法可以产生2^16=65536个值

    • 在消息头中最占空间是myslots[CLUSTER_SLOTS/8],当槽位为

      • 65536,这块的大小是:65536÷8=8*1024b=8kb

      • 16384,大小是:16384÷8=2*1024b=2kb

    • 每秒钟redis节点需要发送一定数量的ping消息作为心跳包,如果槽位为65536,ping消息的消息头太大,浪费带宽

  • redis的集群主节点数量基本不可能超过1000个

    • 集群节点越多,心跳包的消息体内携带的数据越多

    • 如果节点过1000个,也会导致网络拥堵

    • redis作者不建议redis cluster节点数量超过1000个。对于节点数在1000以内的redis cluster集群,16384个槽位够用了

  • 槽位越小,节点少的情况下,压缩比高,容易传输

    • Redis主节点的配置信息中,哈希槽是通过bitmap的形式来保存的,在传输过程中会对bitmap进行压缩

    • 如果bitmap的填充率slots/N很高的话(N表示节点数),bitmap的压缩率就很低

      • 如果节点数很少,而哈希槽数量很多的话,bitmap的压缩率就很低

!!!集群环境搭建

三主三从集群配置
  • 【由于资源有限,每台机器分别安装一主一从来模拟】

  1. 每台机器新建集群工作目录mkdir -p /myredis/cluster

  2. 配置集群的redis配置文件,通用配置如下

    bind 0.0.0.0

    daemonize yes

    protected-mode no

    port 端口号

    logfile "/myredis/cluster/cluster端口号.log"

    pidfile /myredis/cluster端口号.pid

    dir /myredis/cluster

    dbfilename dump端口号.rdb

    appendonly yes

    appendfilename "appendonly端口号.aof"

    requirepass 访问密码

    masterauth 主机访问密码

    cluster-enabled yes

    cluster-config-file nodes-端口号.conf

    cluster-node-timeout 5000

  3. 新建6个独立的redis实例服务redis-server 集群配置文件

    image-20230923201755287

    .

  4. 通过redis-cli命令为6台机器构建集群关系

    • 构建主从关系命令

      • cluster-replicas num 表示为每个master创建num个slave节点

      • 主1 从1[...] 主2 从1[...]的顺序配置

    redis-cli -a 1212go12 --cluster create --cluster-replicas 1 192.168.32.100:6381 192.168.32.100:6382 192.168.32.101:6383 192.168.32.101:6384 192.168.32.102:6385 192.168.32.102:6386

    image-20230923202921397

    .

  5. 连接进入6381作为切入点,查看并检验集群状态

    • 实际分配的从机不一定是按照指定分配的

      • 主机可以指定,从机就随缘

    image-20230923205803059

    .

    • cluster nodes可以查看集群节点之间关系

    image-20230923210803695

    • cluster info查看当前集群信息【不重要】

集群读写
  • 写操作可能会报错

    image-20230923211844114

    • 一定注意槽位的范围区间,需要路由到位

  • !!!解决:防止路由失效加参数-c

image-20230923212158268

.

  • cluster keyslot key查看某个key的槽位号

主从容错切换迁移
  • 主机宕机,从机直接上位原主机降级为新主机的从机

  • 集群不保证数据一致性100%OK,一定会有数据丢失情况

  • 手动故障转移或节点从属调整:在原主机的服务器中输入CLUSTER FAILOVER

!!!主从扩容
  1. 新建6387、6388两个服务实例配置文件

  2. 启动87/88两个新的节点实例,此时他们都是master

  3. 将新增的6387节点(空槽号)作为master节点加入原集群

    • redis-cli -a 密码 --cluster add-node 新增节点ip:新增节点端口 领路人ip:领路人端口

      • 6387是将要作为master新增节点

      • 6381是原来集群节点里面的领路人,相当于6387拜拜6381的码头从而找到组织加入集群

      image-20230923220816744

      .

    • 查看节点信息redis-cli -a 1212go12 --cluster check 192.168.32.100【集群中的节点ip】:6381【节点端口号】

      image-20230923221107958

      .

  4. 重新分派槽号(reshard)redis-cli -a 密码 --cluster reshard 集群中的节点ip:端口

    image-20230923214900502

    .

    • 分派说明:6387是分派的是非连续的3个新区间,重新分配成本太高,所以前3家各自匀出来一部分

    image-20230923221632986

    image-20230923221648774

    .

  5. 为主节点6387分配从节点6388:redis-cli -a 密码 --cluster add-node ip:新slave端口 ip:新master端口 --cluster-slave --cluster-master-id 新主机节点ID

    image-20230923221900064

    .

    image-20230923221953845

    .

主从缩容
  1. 先获得主节点6387的从节点6388的ID

  2. 从集群中删除6388

    • redis-cli -a 密码 --cluster del-node ip:从机端口 从机ID

    image-20230925215931487

    .

  3. 将主节点6387的槽号清空,重新分配,本例将清出来的槽号都给6381

    • redis-cli -a 密码 --cluster reshard 192.168.32.100:6381

    • 4096个槽位都指给6381,它变成了8192个槽位,相当于全部都给6381了【平均分配给3个主机要输入3次】

    image-20230925213714871

    .

  4. 发现原6387节点变成6381的从节点

    image-20230925213918248

    .

  5. 将6387删除redis-cli -a 密码 --cluster del-node ip:端口 ID【命令同上】

  6. 6387/6388被彻底祛除

    image-20230925214124494

    .

集群常用命令/配置

  • 多键操作

    • 不同slot槽位下的键值无法使用mset、mget等多键操作

    • 可以通过{}来定义同一个组,使key中{}内相同内容的键值对放到同一个slot槽位

      • 对照下图类似k1k2k3都映射为x

      image-20230925214923483

      .

  • 配置集群完整才能对外提供服务:cluster-require-full-coverage

    • 默认值 yes , 即需要集群完整性,方可对外提供服务

      • 如果3个小集群中,任何一个(1主1从)挂了,则大集群对外可提供的数据只有2/3, 整个集群是不完整的

      • redis 默认在这种情况下,不会对外提供服务

    • 将该参数设置为no,集群不完整的话也可以对外提供服务,除了挂掉的小集群之外的其他小集群仍然可以对外提供服务

  • 查看槽位占用情况:CLUSTER COUNTKEYSINSLOT 槽位数字编号,返回

    • 1,该槽位被占用

    • 0,该槽位没占用

  • 查看键所在的槽位号:CLUSTER KEYSLOT 键名称

Spring Boot集成Redis

  • 如果遇到连接问题,可能需要进行以下配置

    • bind配置请注释掉

    • 保护模式设置为no

    • Linux系统的防火墙设置

    • redis服务器的IP地址和密码

    • 访问redis的服务端口号和auth密码

集成Jedis

  • 搭建环境

    1. 创建springboot项目,新建jedis模块

    2. 添加依赖

    <!--jedis-->
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>4.3.1</version>
    </dependency>
    1. 添加配置信息

    server.port=7777
    spring.application.name=Jedis_Study
    1. 编写业务类

    Jedis jedis = new Jedis("192.168.32.100", 6379);//指定ip和端口号获取连接
    jedis.auth("1212go12");//指定访问服务器的密码
    System.out.println(jedis.ping());
    1. 启动redis服务器,测试是否连接成功

    image-20230926145431692

    .

  • 案例测试

    System.out.println(jedis.keys("*"));//获取所有键
    System.out.println("=========多键操作===========");
    jedis.mset("str1","v1","str2","v2","str3","v3");
    System.out.println(jedis.mget("str1","str2","str3"));
    System.out.println("=========列表操作===========");
    //jedis.lpush("mylist","v1","v2","v3","v4","v5");
    List<String> list = jedis.lrange("mylist",0,-1);
    for (String element : list) {
        System.out.println(element);
    }
    System.out.println("===========set===========");
    jedis.sadd("orders","jd001");
    jedis.sadd("orders","jd002");
    jedis.sadd("orders","jd003");
    Set<String> set1 = jedis.smembers("orders");
    for (Iterator iterator = set1.iterator(); iterator.hasNext();) {
        String string = (String) iterator.next();
        System.out.println(string);
    }
    jedis.srem("orders","jd002");//删除集合orders的元素jd002
    System.out.println(jedis.smembers("orders").size());
    System.out.println("==========hashmap==========");
    System.out.println("===========hash============");
    jedis.hset("hash1","userName","lisi");
    System.out.println(jedis.hget("hash1","userName"));
    Map<String,String> map = new HashMap<String,String>();
    map.put("telphone","138xxxxxxxx");
    map.put("address","atguigu");
    map.put("email","zzyybs@126.com");//课后有问题请给我发邮件
    jedis.hmset("hash2",map);
    List<String> result = jedis.hmget("hash2", "telphone","email");
    for (String element : result) {
        System.out.println(element);
    }
    System.out.println("============zset==========");
    jedis.zadd("zset01",60d,"v1");
    jedis.zadd("zset01",70d,"v2");
    jedis.zadd("zset01",80d,"v3");
    jedis.zadd("zset01",90d,"v4");
    List<String> zset01 = jedis.zrange("zset01", 0, -1);
    zset01.forEach(System.out::println);

集成lettuce

  • 与jedis的区别【jedis和Lettuce都是Redis的客户端,在SpringBoot2.0之后默认使用Lettuce客户端连接Redis服务器】

    • 因为当使用Jedis客户端连接Redis服务器的时候,每个线程都要拿自己创建的Jedis实例去连接redis客户端

      • 当有很多个线程的时候

        • 不仅开销大需要反复的创建关闭一个Jedis连接

        • 而且线程不安全,一个线程通过Jedis实例更改Redis服务器中的数据之后会影响另一个线程

    • 使用Lettuce这个客户端连接Redis服务器不会出现上面的情况,当有多Lettuce底层使用的是Netty

      • 当有多个线程都需要连接Redis服务器的时候

      • 可以保证只创建一个Letuce连接,使所有的线程共享这一个Lettuce连接可以减少创建关闭一个Lettuce连接时候的开销

      • 而且这种方式是线程安全的,不会出现一个线程通过Lettuce更改Redis服务器中的数据之后而影响另一个线程的情况

  • 环境准备

    1. 添加依赖

      <!--lettuce-->
      <dependency>
          <groupId>io.lettuce</groupId>
          <artifactId>lettuce-core</artifactId>
          <version>6.2.1.RELEASE</version>
      </dependency>
    2. 编写业务类

      //使用构建器链式来构建redisurl
      RedisURI redisURI=RedisURI.builder().redis("192.168.32.100")
              .withPort(6379)
              .withAuthentication("default","1212go12")
              .build();
      //创建连接客户端
      RedisClient client = RedisClient.create(redisURI);
      StatefulRedisConnection<String, String> connect = client.connect();
      //创建用于操作命令的command对象
      RedisCommands<String, String> commands = connect.sync();
      /**
      	业务逻辑
      			**/
      //关闭释放资源(逆序关闭)
      connect.close();
      client.shutdown();
    3. 业务测试

      • 除了mset只能传递map参数之外,其他的基本和jedis相同

      //业务逻辑
      List<String> keys = commands.keys("*");
      System.out.println(keys);
      HashMap<String, String> map = new HashMap<>();
      map.put("k1","k11");
      map.put("k2","k22");
      map.put("k3","k33");
      System.out.println("======================");
      commands.mset(map);
      List<KeyValue<String, String>> mget = commands.mget("k1", "k2", "k3");
      System.out.println(mget);

!!!集成RedisTemplate

连接单机
环境搭建
  1. 添加依赖

     <!--SpringBoot与Redis整合依赖-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
    </dependency>
    <!--swagger2-->
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
        <version>2.9.2</version>
    </dependency>
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger-ui</artifactId>
        <version>2.9.2</version>
    </dependency>
  2. 配置信息

    server.port=7777
    spring.application.name=redisTemplate_study
    # ========================logging=====================
    logging.level.root=info
    logging.level.com.atguigu.redis7=info
    logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger- %msg%n
    logging.file.name=D:/Study/mylog/redis7_study.log
    logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger- %msg%n
    # ========================swagger=====================
    spring.swagger2.enabled=true
    #在springboot2.6.X结合swagger2.9.X会提示documentationPluginsBootstrapper空指针异常,
    #原因是在springboot2.6.X中将SpringMVC默认路径匹配策略从AntPathMatcher更改为PathPatternParser,
    # 导致出错,解决办法是matching-strategy切换回之前ant_path_matcher
    spring.mvc.pathmatch.matching-strategy=ant_path_matcher
    # ========================redis单机=====================
    spring.redis.database=0
    # 修改为自己真实IP
    spring.redis.host=192.168.32.100
    spring.redis.port=6379
    spring.redis.password=1212go12
    spring.redis.lettuce.pool.max-active=8
    spring.redis.lettuce.pool.max-wait=-1ms
    spring.redis.lettuce.pool.max-idle=8
    spring.redis.lettuce.pool.min-idle=0
  3. 创建Swagger配置类

    package com.example.redistemplate_cli.config;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import springfox.documentation.builders.ApiInfoBuilder;
    import springfox.documentation.builders.PathSelectors;
    import springfox.documentation.builders.RequestHandlerSelectors;
    import springfox.documentation.service.ApiInfo;
    import springfox.documentation.spi.DocumentationType;
    import springfox.documentation.spring.web.plugins.Docket;
    import springfox.documentation.swagger2.annotations.EnableSwagger2;
    import java.time.LocalDateTime;
    import java.time.format.DateTimeFormatter;
    @Configuration
    @EnableSwagger2
    public class SwaggerConfig
    {
        @Value("${spring.swagger2.enabled}")
        private Boolean enabled;
        @Bean
        public Docket createRestApi() {
            return new Docket(DocumentationType.SWAGGER_2)
                    .apiInfo(apiInfo())
                    .enable(enabled)
                    .select()
                    .apis(RequestHandlerSelectors.basePackage("com.example.redistemplate_cli"))//配置类所在的项目路径
                    .paths(PathSelectors.any())
                    .build();
        }
        public ApiInfo apiInfo() {
            return new ApiInfoBuilder()
                    .title("springboot利用swagger2构建api接口文档 "+"\t"+ DateTimeFormatter.ofPattern("yyyy-MM-dd").format(LocalDateTime.now()))
                    .description("springboot+redis整合,有问题给管理员阳哥邮件:zzyybs@126.com")
                    .version("1.0")
                    .termsOfServiceUrl("https://www.atguigu.com/")
                    .build();
        }
    }
  4. 业务类【生成订单编号】

    //业务层
    @Slf4j
    @Service
    public class OrderService {
        public static final String ORDER_KEY="ord:";//指定订单编号的前缀前缀
        @Resource
        private RedisTemplate redisTemplate;
        public void addOrder(){
            int keyId= ThreadLocalRandom.current().nextInt(1000)+1;
            String serialNo= UUID.randomUUID().toString();//序列化流水
            String key=ORDER_KEY+keyId;
            String value="京东订单"+serialNo;
            redisTemplate.opsForValue().set(key,value);
            log.info("***key:{}",key);
            log.info("**value:{}",value);
        }
        public String getOrderById(Integer keyID){
            return (String) redisTemplate.opsForValue().get(ORDER_KEY+keyID);
        }
    }
    //控制层
    @Api(tags = "订单接口")
    @RestController
    @Slf4j
    public class OrderController
    {
        @Resource
        private OrderService orderService;
        @ApiOperation("新增订单")
        @RequestMapping(value = "/order/add",method = RequestMethod.POST)
        public void addOrder()
        {
            orderService.addOrder();
        }
        @ApiOperation("按orderId查订单信息")
        @RequestMapping(value = "/order/{id}", method = RequestMethod.GET)
        public String findUserById(@PathVariable Integer id)
        {
            return orderService.getOrderById(id);
        }
    }
  5. 测试

    image-20230926164908135

    .

解决乱码问题
  • 乱码的原因是底层默认使用jdk的序列化方式

image-20230926164843672

.

  • 解决方式一:使用StringRedisTemplateprivate StringRedisTemplate stringRedisTemplate;

    • 如果redis客户端显示中文乱码,则连接时指定-- raw表示对中文的支持

    image-20230926170445170

    .

  • 解决方式二:RedisConfig配置类中配置序列化信息

@Configuration
public class RedisConfig
{
    @Bean
    public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory)
    {
        RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(lettuceConnectionFactory);
        //设置key序列化方式string
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        //设置value的序列化方式json,使用GenericJackson2JsonRedisSerializer替换默认序列化
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}
!!!连接集群
  • 修改配置文件

# ========================redis集群=====================
spring.redis.password=1212go12
# 获取失败 最大重定向次数
spring.redis.cluster.max-redirects=3
spring.redis.lettuce.pool.max-active=8
spring.redis.lettuce.pool.max-wait=-1ms
spring.redis.lettuce.pool.max-idle=8
spring.redis.lettuce.pool.min-idle=0
spring.redis.cluster.nodes=192.168.32.100:6381,192.168.32.100:6382,192.168.32.101:6383,192.168.32.101:6384,192.168.32.102:6385,192.168.32.102:6386
  • 可能出现的问题

    • 模拟某台机器宕机

      • Redis Cluster集群能自动感知并自动完成主备切换,对应的slave会被选举为新的master节点

        image-20230926175225616

      • redis服务器虽然可以正常使用,但是SpringBoot客户端没有动态感知到RedisCluster的最新集群信息,会发生超时且报错

      image-20230926175307925

    • 原因

      • SpringBoot 2.X 版本, Redis默认的连接池采用Lettuce

      • 当Redis 集群节点发生变化后,Letture默认不会刷新节点拓扑

    • 解决方案:配置支持集群拓扑动态感应刷新和定时刷新

    #支持集群拓扑动态感应刷新,自适应拓扑刷新是否使用所有可用的更新,默认false关闭
    spring.redis.lettuce.cluster.refresh.adaptive=true
    #定时刷新
    spring.redis.lettuce.cluster.refresh.period=2000 #单位毫秒

    image-20230926175601505

    .

高阶篇

Redis单线程和多线程

redis是单线程还是多线程?

image-20230926192424557

.

  • redis4之后才慢慢支持多线程,指定6/7才稳定

  • 版本3.x ,最早版本,也就是大家口口相传的redis是单线程

  • 版本4.x,严格意义来说不是单线程,负责处理客户端请求的线程是单线程,但是开始加入多线程的东西(异步删除)---貌似

  • 2020年5月版本的6.0.x后及2022年出的7.0版本后,告别了大家印象中的单线程,用一种全新的多线程来解决问题---实锤

redis单线程功能

单线程解释

image-20230926192945494

.

  • 主要是指Redis的网络IO和键值对读写由一个线程来完成的

    • 在处理客户端的请求时包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理

    • 这就是所谓的“单线程”,也是Redis对外提供键值存储服务的主要流程

单线程优势
  • Redis3.x单线程时代但性能依旧很快的主要原因

    • 基于内存操作:Redis 的所有数据都存在内存中,所有运算都是内存级别

    • 数据结构简单:Redis 的数据结构是专门设计的,而这些简单的数据结构的查找和操作的时间大部分复杂度都是 O(1)

    • 多路复用和非阻塞I/O:Redis使用I/O多路复用功能来监听多个 socket连接客户端,这样就可以使用一个线程连接来处理多个请求,减少线程切换带来的开销,同时也避免了I/O阻塞操作

    • 避免上下文切换:单线程模型避免了不必要的上下文切换和多线程竞争,省去多线程切换带来的时间和性能上的消耗,而且单线程不会导致死锁问题的发生

  • Redis4.0之前一直采用单线程的主要原因

    • 使用单线程模型使Redis的开发和维护更简单,因为单线程模型方便开发和调试

    • 即使使用单线程模型也并发的处理多客户端的请求,主要使用的是IO多路复用和非阻塞IO

    • 对于Redis系统来说,!!!主要的性能瓶颈是内存或者网络带宽而并非CPU

单线程的痛点
  • Redis命令工作线程是单线程的,但是redis6之后,对于整个Redis来说是多线程的

    • 持久化RDB、AOF、异步删除、集群数据同步等等,其实是由额外的线程执行的

  • 引入多线程特性的原因

    • 正常情况下使用 del 指令可以很快的删除数据,而当被删除的key是一个非常大的对象时,例如包含了成千上万个元素的 hash 集合时,那么del指令会造成 Redis主线程卡顿

      • 由于redis是单线程的,del bigKey要等待很久线程才会释放,类似加了一个synchronized锁

    • 硬件的发展,只使用单线程对硬件的使用不够充分

  • 解决单线程的痛点问题--使用惰性删除

    • 在Redis 4.0中就新增了多线程的模块,此版本中的多线程主要为了解决删除数据效率比较低的问题

    • lazy free的本质就是把某些耗时较高删除操作,从redis主线程剥离让bio子线程来处理

      • 极大地减少主线阻塞时间,从而减少删除导致性能和稳定性问题

    • unlink keyflushdb/flushall async把删除工作交给后台子线程异步来删除数据

redis多线程和I/O多路复用

多IO线程的引入
  • 在Redis6/7中,非常受关注的第一个新特性就是多线程

    • 因为Redis单线程架构一直被大家熟知,虽然有些命令操作可以用后台线程或子进程执行(比如数据删除、快照生成、AOF重写)但是从网络IO处理到实际的读写命令处理,都是由单个线程完成

    • 但是随着网络硬件的性能提升,Redis的性能瓶颈有时会出现在网络IO的处理上,即单个主线程处理网络请求的速度跟不上底层网络硬件的速度

  • 为了应对这个问题

    • Redis6/7采用多个IO线程来处理网络请求,提高网络请求处理的并行度

    • Redis的多IO线程只是用来处理网络请求,对于读写操作命令Redis仍然使用单线程来处理

      • 因为Redis处理请求时,网络处理经常是瓶颈,通过多个IO线程并行处理网络操作,可以提升实例的整体处理性能

      • 使用单线程执行命令操作,就不用为了保证Lua脚本、事务的原子性,额外开发多线程互斥加锁机制(不管加锁操作处理)

主线程和IO线程协作

image-20230926204039192

image-20230926204054127

  1. 服务端和客户端建立Socket连接,并分配处理线程

    1. 首先,主线程负责接收建立连接请求,当有客户端请求和实例建立Socket连接时,主线程会创建和客户端的连接,并把 Socket放入全局等待队列中

    2. 紧接着,主线程通过轮询方法把Socket连接分配给IO线程

  2. IO线程读取并解析请求

    • 主线程一旦把Socket分配给IO线程,就会进入阻塞状态,等待IO线程完成客户端请求读取和解析。因为有多个IO线程在并行处理,所以这个过程很快就可以完成

  3. 主线程执行请求操作

    • 等到IO线程解析完请求,主线程还是会以单线程的方式执行这些命令操作

  4. IO线程回写Socket和主线程清空全局队列

    1. 当主线程执行完请求操作后,会把需要返回的结果写入缓冲区

    2. 然后主线程会阻塞,等待IO线程把这些结果回写到Socket中,并返回给客户端

      • 和IO线程读取和解析请求一样,IO线程回写Socket时,也是有多个线程在并发执行,所以回写Socket的速度也很快

    3. 等到IO线程回写Socket完毕,主线程会清空全局队列,等待客户端的后续请求

Unix网络编程中五种IO模型
  • Blocking IO - 阻塞IO

  • NoneBlocking lO - 非阻塞IO

  • lO multiplexing - lO多路复用

  • signal driven lO - 信号驱动IO

  • asynchronous IO -异步IO

!!!多路复用
定义
  • 一种同步的IO模型,实现一个线程监视多个文件句柄,一旦某个文件句柄就绪就能够通知到对应的应用程序进行相应的读写操作,没有文件句柄就绪就会阻塞应用程序从而释放CPU资源

    • 文件描述符(File descriptor)是一个用于表述指向文件的引用的抽象化概念

      • 文件描述符在形式上是一个非负整数

      • 实际上它是一个索引值指向内核为每一个进程所维护的该进程打开文件的记录表

      • 当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符

      • 在程序设计中,文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统

概念
  • lO:网络l/O,尤其在操作系统层面指数据在内核态和用户态之间的读写操作

  • 多路:多个客户端连接(连接就是套接字描述符,即 socket 或者 channel)

  • 复用:复用一个或几个线程

  • lO多路复用

    • 一个或一组线程处理多个TCP连接

    • 使用单进程就能够实现同时处理多个客户端的连接

    • 无需创建或者维护过多的进程/线程

  • 总结

    • 一个服务端进程可以同时处理多个套接字描述符

    • 实现IO多路复用的模型有3种:可以分select->poll->epoll三个阶段来描述

理解
  • 将用户socket对应的文件描述符注册进epoll,然后epoll监听哪些socket上有消息到达,这样就避免大量的无用操作

    • 此时的socket采用非阻塞模式。整个过程只在调用select、poll、epoll的时候才会阻塞,收发客户消息不会阻塞

    • 整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的reactor反应模式

  • 在单个线程通过记录跟踪每一个Socket(I/O流)的状态来同时管理多个I/O流.,一个服务端进程可以同时处理多个套接字描述符

    • 目的是尽量多的提高服务器的吞吐能力

  • 类比nginx使用epoll接收请求,ngnix会有很多链接进来, epoll会把他们都监视起来,然后像拨开关一样,谁有数据就拨向谁,然后调用相应的代码处理。redis类似同理,这就是IO多路复用原理,有请求就响应,没请求不打扰

  • !!!面试题:redis为什么这么快

    • IO多路复用+epoll函数使用,是redis快的直接原因

    • 单线程命令+redis安装在内存中

总结

image-20230926212424000

  • I/O 的读写本身是堵塞的,比如当 socket 中有数据时,Redis 会通过调用先将数据从内核态空间拷贝到用户态空间,再交给 Redis 调用,而这个拷贝的过程就是阻塞的,当数据量越大时拷贝所需要的时间就越多,而这些操作都是基于单线程完成的

  • 从Redis6开始,就新增了多线程的功能来提高 I/O 的读写性能,主要实现思路是将主线程的IO读写任务拆分给一组独立的线程去执行,这样就可以使多个 socket的读写并行化,采用多路I/O复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),将最耗时的Socket的读取、请求解析、写入单独外包出去,剩下的命令执行仍然由主线程串行执行并和内存的数据交互

  • 结合上图可知,Redis6→7将网络数据读写、请求协议解析通过多个IO线程的来处理 ,网络IO操作就变成多线程化了,对于真正的命令执行来说,仍然使用主线程操作,核心部分仍然是线程安全的,是个不错的折中办法

开启多线程功能

image-20230926213433618

.

  • 在Redis6.0及7后,多线程机制默认是关闭的,如果需要使用多线程功能,需要在redis.conf中完成两个设置

    • 配置io-thread-do-reads yes:表示启动多线程

    • 配置io-thread num:设置线程个数,关于线程数的设置官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好

总结
  • Redis自身出道就是优秀,基于内存操作、数据结构简单、多路复用和非阻塞 I/O、避免了不必要的线程上下文切换等特性,在单线程的环境下依然很快

  • 对于大数据的key删除还是卡顿厉害,因此Redis 4.0引入多线程unlink key/flushall async 等命令,主要用于Redis数据的异步删除

  • 而在 Redis6/7中引入了I/O多线程的读写,这样就可以更加高效的处理更多的任务了,Redis只是将I/O 读写变成了多线程,而命令的执行依旧是由主线程串行执行,因此在多线程下操作 Redis 不会出现线程安全的问题

  • Redis 无论是当初的单线程设计,还是如今与当初设计相背的多线程,目的只有一个:让Redis变得越来越快

Bigkey

MoreKey案例

  1. 插入大量数据

    • 生成100W条redis批量设置kv的语句(key=kn,value=vn)写入到/tmp目录下的redisTest.txt文件中

      • for((i=1;i<=100*10000;i++)); do echo "set k$i v$i" >> /tmp/redisTest.txt ;done;

    • 通过redis的管道插入大量数据

      • cat /tmp/redisTest.txt | redis-cli -h 127.0.0.1 -p 6379 -a 1212go12 --pipe

    image-20230929152217661

  2. 禁用keys *flushdbflushall等危险命令

    • keys *的弊端

      • 这个指令没有 offset、limit 参数,是要一次性吐出所有满足条件的 key

      • 由于redis执行命令是单线程的,其所有操作都是原子的,而keys算法是遍历算法,复杂度是 O(n)

        • 如果实例中有千万级以上的 key,这个指令就会导致Redis 服务卡顿

        • 所有读写 Redis 的其它指令都会被延后甚至会超时报错,可能会引起缓存雪崩其至数据库宕机

    • 通过在配置文件中的SECURITY项配置rename-command来禁用

      image-20230929154101808

      .

    • 禁用成功

      image-20230929154348229

      .

  3. scan命令进行海量数据查询

    • 语法:SCAN cursor [MATCH pattern] [COUNT count]

      • cursor:游标,scan是基于游标的迭代器,需要基于上一次的游标延续之前的迭代过程

        • 以0作为游标开始一次新的迭代,直到命令返回游标0完成一次遍历

      • pattern:支持模糊查询

      • count:返回元素个数,默认为1

        • 不保证每次执行都返回某个给定数量的元素,一次返回的数量不可控,只能是大概率符合count参数

    • 特点

      • SCAN 命令是一个基于游标的迭代器,每次被调用之后,都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为 SCAN 命令的游标参数, 以此来延续之前的迭代过程

      • SCAN 返回一个包含两个元素的数组

        • 第一个元素是用于进行下一次迭代的新游标

        • 第二个元素则是一个数组, 这个数组中包含了所有被迭代的元素。如果新游标返回零表示迭代已结束

      • SCAN的遍历顺序

        • 非常特别,它不是从第一维数组的第零位一直遍历到末尾,而是采用了高位进位加法来遍历

        • 使用这样特殊的方式进行遍历,是考虑到字典的扩容和缩容时避免槽位的遍历重复和遗漏

      image-20230929160222551

      .

BigKey案例

多大算bigkey
  • string类型控制在10KB以内hash、list、set、zset元素个数不要超过5000

  • 非字符串的bigkey

    • 不要使用del删除,使用hscan、sscan、zscan方式渐进式删除

    • 要注意防止bigkey过期时间自动删除问题

      • 例如一个200万的zset设置1小时过期,会触发del操作,造成阻塞,而且该操作不会出现在慢查询中

BigKey的危害
  • 内存不均,集群迁移困难

  • 超时删除,大key删除作梗

  • 网络流量阻塞

发现BigKey
  • 连接redis数据库时时加上--bigkeys参数

    • 优点:给出每种数据结构Top 1 bigkey,同时给出每种数据类型的键值个数+平均大小

    • 无法查询所有大于10kb的key,这时就需要用到memory usage来计算每个键值的字节数

image-20230929161703532

.

  • memory usage key [SAMPLES count]

    • 返回的结果是key的值以及该key占用的内存总字节数

    • 嵌套数据类型,可以使用选项 SAMPLES,其中 count 表示抽样的元素个数,默认值为5,当需要抽样所有元素时可以设为0

删除Bigkey
不同类型要采用不同的删除方式
String
  • 一般用del,如果过于庞大unlink

hash
  • 使用hscan每次获取少量field-value,再使用hdel删除每个field【一点点缩小字段规模】

  • HSCAN key cursor [MATCH pattern] [COUNT count]

public void delBigHash(String host, int port, String password, String bigHashKey) {
    Jedis jedis = new Jedis(host, port);
    if (password != nu11 && !"".equals(password)) jedis .auth(password);//连接操作,之后的案例省略这一部分
	ScanParams scanParams = new ScanParams().count(100);
    String cursor ="0";//从起始位置开始查询
	do {
		ScanResult<Entry<String, String>> scanResult = jedis.hscan(bigHashKey, cursor, scanParams);
        List<Entry<String,String>> entryList = scanResult.getResult();//获取部分字段列表
        if (entryList != null && !entrylist.isEmpty()){
            for (Entry<String, String> entry : entryList){
           		jedis.hdel(bigHashKey,entry.getKey());//删除字段
            }
        }
		cursor = scanResult.getStringCursor();
    }while (!"0".equals(cursor));//遍历完成就结束
	//删除bigkey
	jedis.del(bigHashKey);
}
list
  • 使用ltrim渐进式逐步删除,直到全部删除完成【每次留一块,留到最后啥都不剩了】

  • ltrim key start stop,对一个列表进行修剪(trim),让列表只保留指定区间内的元素,不在指定区间之内的元素都将被删除

    • 下标0表示列表的第一个元素,以1表示列表的第二个元素,以此类推

    • 也可以使用负数下标,以-1表示列表的最后一个元素,2表示列表的倒数第二个元素,以此类推

public void delBigList(String host, int port, String password,String bigListKey){
	...
	long llen = jedis.llen(bigListKey);
    int counter = 0;
    int left = 100;
    while (counter < llen) {
        //每次从左侧裁第100个
        jedis.ltrim(bigListKey, left,llen);//存疑,删除掉一部分长度不会发生变换吗???
        counter += left;
    }
	//最终删除
    keyjedis.del(bigListKey);
}
set
  • 使用sscan每次获取部分元素,再使用srem命令删除每个元素

public void delBigSet(String host, int port, String password, String bigSetKey) {
	...
	ScanParams scanParams = new ScanParams().count(100);
    String cursor ="0";
    do {
		ScanResult<String> scanResult = jedis.sscan(bigSetKey, cursor, scanParams);
        List<String> memberList = scanResult.getResult();
        if (memberList != nul] && !memberList.isEmpty()) {
            for (String member : memberList) {
                jedis.srem(bigSetKey,member );
            }
        }
		cursor = scanResult.getStringCursor();
    }while (!"0".equals(cursor));
	//删除bigkey
    jedis.del(bigSetKey);
}
zset
  • 使用zscan每次获取部分元素,再使用zrem删除,类似于set、hash删除

public void delBigZset(String host, int port, String password, String bigZsetKey) {
	...
    ScanParams scanParams = new ScanParams().count(100);
    String cursor ="0";
    do{
		ScanResult<Tuple> scanResult = jedis.zscan(bigZsetKey, cursor, scanParams);
        List<Tuple> tupleList = scanResult.getResult();
        if (tupleList != nul1 && !tupleList.isEmpty()){
            for (Tuple tuple : tupleList){
                jedis.zrem(bigZsetKey, tuple.getElement());
            }				
        }
            cursor = scanResult.getStringCursor();
        }
    while (!"o".equals(cursor));
    //删除bigkey
    jedis.del(bigZsetKey);
}
  • 直接使用ZREMRANGEBYRANK命令删除指定排名区间内每个元素、类似于list删除

bigkey删除调优
  • redis.conf配置文件中的LAZY FREEING相关说明:Redis有两个原语来删除键

    • 一种称为 DEL,是对象的阻塞删除。服务器停止处理新命令,以便以同步方式回收与对象关联的所有内存

      • 如果删除的键与一个小对象相关联,则执行 DEL 命令所需的时间非常短

      • 如果键与包含数百万个元素的聚合值相关联,则服务器可能会阻塞很长时间 (甚至几秒钟) 才能完成操作

    • Redis还提供非阻塞删除原语,例如UNLINK (非阻塞 DEL)、FLUSHALL和 FLUSHDB命令的ASYNC选项以便在后台回收内存

      • 这些命令在恒定时间内执行。另一个线程将尽可能快地逐步释放后台中的对象

      • FLUSHALL和 FLUSHDB 的 DEL、UNLINK 和 ASYNC选项是用户控制的-

  • 优化配置

image-20230929172602842

.

面试题
  • 阿里广告平台,海量数据里查询某一固定前缀的key

    • 使用scan然后指定匹配模式

  • 小红书,你如何生产上限制keys * /flushdb/flushall等危险命令以防止误删误用?

    • 配置文件中的security那一块,通过将命名重名为""

  • 美团,MEMORY USAGE 命令你用过吗?

    • 用于查询某个key的占用内存,对于非字符串类型的数据可以指定抽样

  • BigKey问题,多大算big? 你如何发现?如何删除?如何处理?

    • 根据阿里的规范,string类型的大于10kb就算bigkey了,hash、list、set、zset等类型中含有的成员不能超过5000

    • 可以在连接数据库时指定--bigkey参数获取到每种类型占用最大的key以及平均占用,还可以通过memory usage命令查询指定key的占用情况

    • 递进式删除

      • 比方说可以使用hscan、zscan、sscan获取到部分数据,然后先删除一部分,直到为空

      • string类型可以直接del,如果太大就有unlink

      • list可以通过ltrim一点点剪枝

  • BigKey你做过调优吗?惰性释放lazyfree了解过吗?

    • bigkey调优可以在配置文件中对lazyfree的相关配置进行修改

    • lazyfree是基于del删除bigkey会发生网络阻塞的痛点而衍生而来的,因为del是阻塞删除

  • Morekey问题,生产上redis数据库有1000W记录,你如何遍历? keys*可以吗?

    • scan

    • keys*不行,因为keys *没有offset、limit的概念,调用的话会一次性查询出所有数据,如果数据量过于庞大会造成网络拥堵,并且会阻塞其它命令的操作甚至使其因为超时而无法执行

缓存双写一致性更新策略

双写一致性理解

  • 缓存双写图示理解

image-20230929203446853

.

  • 理解

    • 是否有数据

      • 如果redis中有数据,需要和数据库中的值相同

      • 如果redis中无数据,数据库中的值要是最新值,且准备回写redis

    • 按照缓存操作

      • 只读缓存,这就不用考虑回写了

      • 读写缓存

        • 同步直写策略

          • 写数据库后也同步写redis缓存,缓存和数据库中的数据一致

          • 对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略

        • 异步缓写策略

          • 正常业务运行中,mysq数据变动,但是可以在业务上容许出现一定延时才作用于redis,比如仓库、物流系统

          • 异常情况不得不将失败的动作重新修补,有可能需要借助kafka或者RabbitMQ等消息中间件,实现重试重写

双检加锁策略

  • 当前面临的问题

    • 高并发环境下,如果redis中没有数据,将会有多个线程同时查询mysql数据库,加大msyql压力

    • 而且会有多个线程同时进行回写,可能会多次回写重复数据造成数据覆盖

/**
 * 业务逻辑没有写错,对于小厂中厂(QPS<=1000)可以使用,但是大厂不行
 */
public User findUserById(Integer id)
{
    User user = null;
    String key = CACHE_KEY_USER+id;
    //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql
    user = (User) redisTemplate.opsForValue().get(key);
    if(user == null)
    {
        //2 redis里面无,继续查询mysql
        user = userMapper.selectByPrimaryKey(id);
        if(user == null)
        {
            //3.1 redis+mysql 都无数据
            //你具体细化,防止多次穿透,我们业务规定,记录下导致穿透的这个key回写redis
            return user;
        }else{
            //3.2 mysql有,需要将数据写回redis,保证下一次的缓存命中率
            redisTemplate.opsForValue().set(key,user);
        }
    }
    return user;
}
  • 解决--双检加锁策略

    • 多个线程同时去查询数据库的这条数据,可以在第一个查询数据的请求使用一个互斥锁来锁住它

    • 其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存

    • 后面的线程进来发现已经有缓存,就直接走缓存

    • 总结:避免突然key失效,打爆mysql,做一下预防,尽量不出现缓存击穿的情况

public string get(string key) {
    String value = redis.get(key); //查询缓存
    if (value != null) {
        //缓存存在直接返回
        return value;
    } else {
        //缓存不存在则对方法加锁
        //假设请求量很大,缓存过期
        synchronized (TestFuture.class) {
            value = redis.get(key);//!!!!再查一遍
            redisif(value != null) {//查到数据直接返回return value!
            } else{
                //二次查询缓存也不存在,直接查DB
                value = dao.get(key);
                //数据缓存
                redis.setnx(key, value, time);//返回
                return value;
            }
        }
    }
}
  • 用户信息查询案例的代码优化

public User findUserById2(Integer id)
{
    User user = null;
    String key = CACHE_KEY_USER+id;
    //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql,
    // 第1次查询redis,加锁前
    user = (User) redisTemplate.opsForValue().get(key);
    if(user == null) {
        //2 大厂用,对于高QPS的优化,进来就先加锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql
        synchronized (UserService.class){
            //第2次查询redis,加锁后
            user = (User) redisTemplate.opsForValue().get(key);
            //3 二次查redis还是null,可以去查mysql了(mysql默认有数据),但是对于后面进来的线程可以直接走缓存
            if (user == null) {
                //4 查询mysql拿数据(mysql默认有数据)
                user = userMapper.selectByPrimaryKey(id);
                if (user == null) {
                    return null;
                }else{
                    //5 mysql里面有数据的,需要回写redis,完成数据一致性的同步工作
                    redisTemplate.opsForValue().setIfAbsent(key,user,7L,TimeUnit.DAYS);
                }
            }
        }
    }
    return user;
}

一致性的更新策略

  • 目的:保证最终一致性

    • 给缓存设置过期时间,定期清理缓存并回写,是保证最终一致性的解决方案

    • 可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可

      1. 如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值

      2. 然后回填缓存,达到一致性,切记,要以mysql的数据库写入库为准

    • 不是100%绝对正确,不保证绝对适配全部情况

  • 可以停机的情况:挂牌报错,凌晨升级,温馨提示,服务降级

    • 采用单线程,这样重量级的数据操作最好不要多线程

    • 如果可以停机那采用以下四种策略都可以

先更新数据库,再更新缓存
  • 异常问题1

    1. 先更新mysql的某商品的库存,当前商品的库存是100,更新为99个

    2. 更新mysql修改为99成功,然后更新redis

    3. 此时假设异常出现,更新redis失败,这导致mysql里面的库存是99而redis里面的还是100

    4. 上述发生,会让数据库里面和缓存redis里面数据不一致,读到redis脏数据

  • 异常问题2:多线程环境下,A、B两个线程有快有慢,有前有后有并行

    1. A update mysql 100

    2. B update mysql 80

    3. B update redis 80

    4. A update redis 100

    5. 最终结果,mysql和redis数据不一致【最终结果,mysql和redis数据不一致】

先更新缓存,再更新数据库
  • 不推荐:业务上一般把mysql作为底单数据库,保证最后解释

  • 异常问题:多线程环境下,A、B两个线程有快有慢,有前有后有并行【同先更新数据库,再更新缓存的情况】

先删除缓存,再更新数据库
异常问题
  1. 请求A进行写操作,删除redis缓存后,更新mysql,由于网络延迟等原因,A还没更新完

  2. 此时请求B进行查询,查询redis发现缓存不存在(被A从redis中删除)

  3. 请求B继续,去数据库查询得到了mysql中的旧值(A还没有更新完)

  4. 请求B将旧值写回redis缓存

  5. 请求A将新值写入mysql数据库

时间线程A线程B出现的问题
t1请求A进行写操作,删除缓存成功后,更新mysql工作正在进行中......
t21.缓存中读取不到,立刻读mysql,由于A还没有对mysql更新完,读到的是旧值 2.还把从mysql读取的旧值,写回了redis1.A还没有更新完mysql,导致B读到了旧值 2.线程B遵守回写机制,把旧值写回redis,导致其它请求读取的还是旧值,A白干了
t3A更新完mysql数据库的值redis是被B写回的旧值,mysql是被A更新的新值,出现数据不一致问题
解决:采用延时双删
  • 步骤/理解

    1. 线程A休眠一段时间,就是为了让线程B能够先从数据库读取数据,再把缺失的数据写入缓存

    2. 然后线程A再进行删除。线程A睡眠的时间,就需要大于线程B读取数据再写入缓存的时间

    3. 这样一来,其它线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值

    4. 因为这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,所以把它叫做“延迟双删

public void deleteorderData(Order order){
	try(Jedis jedis = Redisutils.getJedis()){
        jedis.del( key: order.getId()+"");//A线程成功删除redis缓存
		orderDao .update(order);//然后A更新mysql
		try{
            Timeunit.seconds.sleep(2); //暂停2秒钟,等待其它业务逻辑完成
           }catch (InterruptedException e){
            e.printstackTrace();
        }jedis.del( key: order.getId()+"");//第二次删除【防止redis中存在其它线程写回的旧值】
	}catch (Exceptlon e){
    	e.printstackTrace();
	}
}
  • 需要考虑的问题

    • 怎么确定睡眠的时间

      • 第一种方法:在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,自行评估自己的项目的读数据业务逻辑的耗时,以此为基础来进行估算。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加百毫秒即可

        • 这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据

      • 第二种方法:新启动一个后台监控程序,比如后面要讲解的WatchDog监控程序,会加时

    • 该同步策略会降低吞吐量,如何解决

      • 第二次删除另起一个线程来进行异步删除

    public void deleteDoubleorderDelay(Order order) {
        try (Jedis jedis = Redisutils.getJedis()) {
            //1 线程A 先成功删除redis缓存
            jedis.del(order.getId() + "");
            //2 线程A再更新mysql
            orderDao.update(order);
            try {
                TimeUnit.SECONDS.sleep(20);// 暂停20秒钟,模拟更新耗时
            } catch (InterruntedExcention e) {
                e.printstackTrace();
            }
            CompletableFuture.supplyAsync(() -> {//3 将第二次删除作为异步的。自己起一个线程,异步删除
                //写的请求就不用沉睡一段时间再返回。所以吞吐量提高
                return jedis.del(order.getid() + "");
            }).whenComplete((t, u) -> {
                System.out.println("------t:" + t);
                System.out.println("------t:" + u);
            }).exceptionally(e -> {
                System.out.println("------e: " + e.getMessage());
                return 44L;
            }).get();
        } catch (Exception e) {
            e.printstackTrace();
        }
    }                                                                                                          
先更新数据库,再删除缓存
异常问题
  • 假如缓存删除失败或者来不及,导致请求再次访问redis时缓存命中,读取到的是缓存旧值

时间线程A线程B出现的问题
t1更新数据库中的值
t2缓存中立刻命中,此时B读取的是缓存旧值A还没有来得及删除缓存的值,导致B缓存命中读到旧值
t3更新缓存的数据
删除失败解决方案

image-20231001212400041

.

  • 流程图步骤解释

    1. 更新数据库数据

    2. 数据库会将操作信息写入binlog日志当中

    3. 订阅程序提取出所需要的数据以及key

    4. 另起一段非业务代码,获得该信息

    5. 尝试删除缓存操作,发现删除失败

    6. 将这些信息发送至消息队列

    7. 重新从消息队列中获得该数据,重试操作

  • 解决方案解释:可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)

    1. 当程序删除缓存值或者是更新数据库值失败时,可以从消息队列中重新读取这些值,然后再次进行删除或更新

    2. 如果能够成功地删除或更新就要把这些值从消息队列中去除,以免重复操作,此时可以保证数据库和缓存的数据一致

    3. 否则还需要再次进行重试,如果重试超过的一定次数后还是没有成功,就需要向业务层发送报错信息,通知运维人员

总结
策略高并发多线程条件下问题现象解决方案
先删除redis缓存,再更新mysql缓存删除成功但数据库更新失败Java程序从数据库中读到旧值再次更新数据库,重试
缓存删除成功但数据库更新中......有并发读请求并发请求从数据库读到旧值并回写到redis,导致后续都是从redis读取到旧值延迟双删
先更新mysql,再删除redis缓存数据库更新成功,但缓存删除失败Java程序从redis中读到旧值再次删除缓存,重试
数据库更新成功但缓存删除中......有并发读请求并发请求从缓存读到旧值等待redis删除完成,这段时间有数据不一致,短暂存在
  • 在大多数业务场景下, 优先使用先更新数据库,再删除缓存的方案(先更库→后删存)

  • 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库给数据库带来压力导致打满mysql

    • 如果业务应用中读取数据库和写缓存的时间不好估算,那么延迟双删中的等待时间就不好设置

  • 使用先更新数据库,再删除缓存的方案,业务层要求必须读取一致性的数据

    • 就需要在更新数据库时,先在Redis缓存客户端暂停并发读请求

    • 等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性

    • 这是理论可以达到的效果,但实际不推荐,因为真实生产环境中,分布式下很难做到实时一致性,一般都是最终一致性

双写一致性工程落地案例

canal

  • 如何知道mysql的记录改动并同步反应到redis--使用canal

  • 定义

    • canal主要用途是用于MySQL数据库增量日志数据的订阅、消费和解析,是阿里巴巴开发并开源的,采用Java语言开发

    • 从2010年开始,阿里巴巴逐步尝试采用解析数据库日志获取增量变更进行同步,提供增量数据订阅和消费

  • 功能

    • 数据库镜像

    • 数据库实时备份

    • 索引构建和实时维护(拆分异构索引、倒排索引等)

    • 业务 cache 刷新

    • 带业务逻辑的增量数据处理

  • 下载地址:Release v1.1.6 · alibaba/canal · GitHub

工作原理

mysql主从复制原理

image-20231001221335333

.

  1. 当 master服务器上的数据发生改变时,则将其改变写入二进制事件日志文件中

  2. salve服务器会在一定时间间隔内对 master服务器上的二进制日志进行探测,探测其是否发生过改变,如果探测到 master服务器的二进制事件日志发生了改变,则开启一个 I/O Thread请求master二进制事件日志

  3. 同时 master服务器为每个 I/O Thread 启动一个dump Thread,用于向其发送二进制事件日志

  4. slave服务器将接收到的二进制事件日志保存至自己本地的中继日志文件中

  5. salve服务器将启动 SQL Thread,从中继日志中读取二进制日志,在本地重放,使得其数据和主服务器保持一致

  6. 最后 I/O Thread 和 SQL Thread 将进入睡眠状态,等待下一次被唤醒

canal工作原理
  1. canal模拟 MysQL slave 的交互协议,伪装自己为 MySQL slave,向 MySQL master 发送 dump 协议

  2. MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal)

  3. canal解析 binary log 对象(原始为 byte 流)

案例实战

mysql开启binlog
  1. 查看mysql版本SELECT VERSION()

  2. 查看主机日志show MASTER STATUS

  3. 查看MySQL是否已经开启binlogSHOW VARIABLES LIKE 'log_bin';

    • 没有开启就进行第四和五操作

    • 开启直接跳转六

  4. 开启binlog写入功能

    1. 先查找mysql的数据存放目录SELECT @@datadir

    2. 然后返回上一层,找到my.ini配置文件

    3. 备份my.ini

    4. 修改my.ini中mysqld的配置项

      log-bin=mysql-bin #开启 binlog

      binlog-format=ROW #选择 ROW 模式

      server_id=1 #配置MySQL replaction需要定义,不要和canal的 slaveId重复

      • ROW模式除了记录sql语句,还会记录每个字段的变化情况,能够清楚的记录每行数据的变化历史,但会占用较多的空间

      • STATEMENT模式只记录了sql语句,但是没有记录上下文信息,在进行数据恢复的时候可能会导致数据的丢失情况

      • MIX模式比较灵活的记录

        • 理论上说当遇到了表结构变更的时候,就会记录为statement模式

        • 当遇到了数据更新或者删除情况下就会变为row模式

  5. 重启mysql后再次查看binglog开启情况

  6. 授权canal连接mysql账号

    1. 新建并授权canal账户

      DROP USER IF EXISTS 'canal'@'%';
      CREATE USER 'canal'@'%' [用户名] IDENTIFIED BY 'canal' [密码];  
      GRANT ALL PRIVILEGES ON *.* TO 'canal'@'%' WITH GRANT OPTION;  
      FLUSH PRIVILEGES;
    2. 查看MySQL中的用户SELECT * FROM mysql.user;

canal服务端
  1. 下载Linux版本: canal.deployer-1.1.6.tar.gz

  2. 解压放到/mycanal目录下

  3. 配置mycanal/conf/example路径下instance.properties文件

    • 配置mysql主机master的IP地址

      image-20231005214920860

    • 配置mysq新建的canal账户

      image-20231005214943445

  4. 在/mycanal/bin路径下执行./startup.sh命令启动canal服务器

  5. 查看canal是否启动成功【两个日志都在安装目录的logs下】

    • 查看canal日志

    image-20231005213830725

    • 查看样例example 的日志

      • 报错汇总

        • 如果是主机ip地址写错,会报连接超时错误

        • ErrorPacket [errorNumber=1129, fieldCount=-1, message=192.168.2.102' is blocked because of many connection errors; unblock with 'mysqladmin flush-hosts', sqlState=ost ', sqlStateMarker=H]

          • 错误消息指出 “192.168.2.102” 被阻止了因为存在多个连接错误

          • 可以尝试使用 “mysqladmin flush-hosts” 命令来解除对 “192.168.2.102” 的阻止。这个命令可以清除 MySQL 服务器的阻止列表并允许该 IP 地址进行连接mysqladmin flush-hosts -h localhost -P 3306 -u root -p

        • caching_sha2_password Auth failed

          • 错误原因:MySQL 8.0.3开始,身份验证插件默认使用caching_sha2_password

          • 解决:修改canal用户对应的身份验证插件为mysql_native_password ALTER USER 'canal'@'%' IDENTIFIED WITH mysql_native_password BY 'password'

    image-20231005210522538

canal客户端
sql脚本
CREATE TABLE `t_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `userName` varchar(100) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4
配置依赖
  • canal的依赖

<dependency>
    <groupId>com.alibaba.otter</groupId>
    <artifactId>canal.client</artifactId>
    <version>1.1.0</version>
</dependency>
  • 整个项目需要用到的依赖【包含数据库连接等等】

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <junit.version>4.12</junit.version>
    <log4j.version>1.2.17</log4j.version>
    <lombok.version>1.16.18</lombok.version>
    <mysql.version>5.1.47</mysql.version>
    <druid.version>1.1.16</druid.version>
    <mapper.version>4.1.5</mapper.version>
    <mybatis.spring.boot.version>1.3.0</mybatis.spring.boot.version>
</properties>
<dependencies>
    <!--canal-->
    <dependency>
        <groupId>com.alibaba.otter</groupId>
        <artifactId>canal.client</artifactId>
        <version>1.1.0</version>
    </dependency>
    <!--SpringBoot通用依赖模块-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <!--swagger2-->
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
        <version>2.9.2</version>
    </dependency>
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger-ui</artifactId>
        <version>2.9.2</version>
    </dependency>
    <!--SpringBoot与Redis整合依赖-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
    </dependency>
    <!--SpringBoot与AOP-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    <dependency>
        <groupId>org.aspectj</groupId>
        <artifactId>aspectjweaver</artifactId>
    </dependency>
    <!--Mysql数据库驱动-->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>5.1.47</version>
    </dependency>
    <!--SpringBoot集成druid连接池-->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid-spring-boot-starter</artifactId>
        <version>1.1.10</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
        <version>${druid.version}</version>
    </dependency>
    <!--mybatis和springboot整合-->
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>${mybatis.spring.boot.version}</version>
    </dependency>
    <!--通用基础配置junit/devtools/test/log4j/lombok/hutool-->
    <!--hutool-->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.2.3</version>
    </dependency>
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>${junit.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>log4j</groupId>
        <artifactId>log4j</artifactId>
        <version>${log4j.version}</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>${lombok.version}</version>
        <optional>true</optional>
    </dependency>
    <!--persistence-->
    <dependency>
        <groupId>javax.persistence</groupId>
        <artifactId>persistence-api</artifactId>
        <version>1.0.2</version>
    </dependency>
    <!--通用Mapper-->
    <dependency>
        <groupId>tk.mybatis</groupId>
        <artifactId>mapper</artifactId>
        <version>${mapper.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-autoconfigure</artifactId>
    </dependency>
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>3.8.0</version>
    </dependency>
</dependencies>
配置文件
server.port=5555
# ========================alibaba.druid=====================
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/redis?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=1212go12
spring.datasource.druid.test-while-idle=false
业务代码
  • Redis连接工具类

public class RedisUtils
{
    public static final String  REDIS_IP_ADDR = "192.168.32.100";
    public static final String  REDIS_pwd = "1212go12";
    public static JedisPool jedisPool;
    static {
        JedisPoolConfig jedisPoolConfig=new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(20);
        jedisPoolConfig.setMaxIdle(10);
        jedisPool=new JedisPool(jedisPoolConfig,REDIS_IP_ADDR,6379,10000,REDIS_pwd);
    }
    public static Jedis getJedis() throws Exception {
        if(null!=jedisPool){
            return jedisPool.getResource();
        }
        throw new Exception("Jedispool is not ok");
    }
}
  • 监听程序类

public class RedisCanalClientExample
{
    public static final Integer _60SECONDS = 60;
    public static final String  REDIS_IP_ADDR = "192.168.32.100";
    private static void redisInsert(List<Column> columns)
    {
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns)
        {
            System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
            jsonObject.put(column.getName(),column.getValue());
        }
        if(columns.size() > 0)
        {
            try(Jedis jedis = RedisUtils.getJedis())
            {
                jedis.set(columns.get(0).getValue(),jsonObject.toJSONString());
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }
    private static void redisDelete(List<Column> columns)
    {
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns)
        {
            jsonObject.put(column.getName(),column.getValue());
        }
        if(columns.size() > 0)
        {
            try(Jedis jedis = RedisUtils.getJedis())
            {
                jedis.del(columns.get(0).getValue());
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }
    private static void redisUpdate(List<Column> columns)
    {
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns)
        {
            System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
            jsonObject.put(column.getName(),column.getValue());
        }
        if(columns.size() > 0)
        {
            try(Jedis jedis = RedisUtils.getJedis())
            {
                jedis.set(columns.get(0).getValue(),jsonObject.toJSONString());
                System.out.println("---------update after: "+jedis.get(columns.get(0).getValue()));
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }
    public static void printEntry(List<Entry> entrys) {
        for (Entry entry : entrys) {
            if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) {
                continue;
            }

            RowChange rowChage = null;
            try {
                //获取变更的row数据
                rowChage = RowChange.parseFrom(entry.getStoreValue());
            } catch (Exception e) {
                throw new RuntimeException("ERROR ## parser of eromanga-event has an error,data:" + entry.toString(),e);
            }
            //获取变动类型
            EventType eventType = rowChage.getEventType();
            System.out.println(String.format("================&gt; binlog[%s:%s] , name[%s,%s] , eventType : %s",
                    entry.getHeader().getLogfileName(), entry.getHeader().getLogfileOffset(),
                    entry.getHeader().getSchemaName(), entry.getHeader().getTableName(), eventType));

            for (RowData rowData : rowChage.getRowDatasList()) {
                if (eventType == EventType.INSERT) {
                    redisInsert(rowData.getAfterColumnsList());
                } else if (eventType == EventType.DELETE) {
                    redisDelete(rowData.getBeforeColumnsList());
                } else {//EventType.UPDATE
                    redisUpdate(rowData.getAfterColumnsList());
                }
            }
        }
    }
    public static void main(String[] args)
    {
        System.out.println("---------O(∩_∩)O哈哈~ initCanal() main方法-----------");

        //=================================
        // 创建链接canal服务端
        CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress(REDIS_IP_ADDR,
                11111), "example", "", "");
        int batchSize = 1000;
        //空闲空转计数器
        int emptyCount = 0;
        System.out.println("---------------------canal init OK,开始监听mysql变化------");
        try {
            connector.connect();
            //connector.subscribe(".*\\..*");
            connector.subscribe("redis.t_user");
            connector.rollback();
            int totalEmptyCount = 10 * _60SECONDS;
            while (emptyCount < totalEmptyCount) {
                System.out.println("我是canal,每秒一次正在监听:"+ UUID.randomUUID().toString());
                Message message = connector.getWithoutAck(batchSize); // 获取指定数量的数据
                long batchId = message.getId();
                int size = message.getEntries().size();
                if (batchId == -1 || size == 0) {
                    emptyCount++;
                    try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
                } else {
                    //计数器重新置零
                    emptyCount = 0;
                    printEntry(message.getEntries());
                }
                connector.ack(batchId); // 提交确认
                // connector.rollback(batchId); // 处理失败, 回滚数据
            }
            System.out.println("已经监听了"+totalEmptyCount+"秒,无任何消息,请重启重试......");
        } finally {
            connector.disconnect();
        }
    }
}
测试
  • 测试首次开启监听程序

    • 之后开启监听程序就不会输出binlog信息,只有在修改的时候才输出

image-20231005220330457

.

  • 测试修改

    • 监听程序中

    image-20231005220533706

    .

    • redis中

    image-20231005220724290

    .

源码分析

  • 连接cannal服务器的参数中,如果用户名和密码为空字符串就会去读instance.properties文件的配置选项

CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress(REDIS_IP_ADDR,
        11111), "example", ""【用户名】, ""【密码】);
  • 监视范围【注释掉的代码表示监视所有库的所有表】

    • 全库全表:connector.subscribe(".*\\..*")

    • 指定库全表:connector.subscribe("test\\..*)

    • 单表:connector.subscribe("test.user")

    • 多规则组合使用:connector.subscribe("test\\..*,test2.user1,test3.user2")

    • 如果没有配置就默认全库全表

    image-20231005232938264

    .

//connector.subscribe(".*\\..*");不建议使用,监视面太大,更容易操作双写不一致
connector.subscribe("redis.t_user");
  • 释放资源的简写

    • try-with-resources是一个在try的小括号中声明一个或多个资源的语句

    • 一个资源作为一个对象,必须在程序结束之后关闭。try-with-resources语句确保在语句的最后每个资源都被关闭

    • 任何实现java.lang.AutoCloseablejava.io.Closeable的对象都可以使用try-with-resource来实现异常处理和关闭咨源

try(Jedis jedis = RedisUtils.getJedis())
{
    jedis.set(columns.get(0).getValue(),jsonObject.toJSONString());
}catch (Exception e){
    e.printStackTrace();
}

bitmap/hyperloglog/GEO

相关面试题

  • 抖音电商直播,主播介绍的商品有评论,1个商品对应了1系列的评论,排序+展现+取前10条记录

  • 用户在手机App上的签到打卡信息: 1天对应1系列用户的签到记录,新浪微博、钉钉打卡签到,来没来如何统计?

  • 应用网站上的网页访问信息: 1个网页对应1系列的访问点击,淘宝网首页,每天有多少人浏览首页?

  • 你们公司系统上线后,说一下UV、PV、DAU分别是多少?

  • 集合中的数据进行统计

    • 在移动应用中,需要统计每天的新增用户数和第2天的留存用户数

    • 在电商网站的商品评论中,需要统计评论列表中的最新评论

    • 在签到打卡中,需要统计一个月内连续打卡的用户数

    • 在网页访问记录中,需要统计独立访客(Unique Visitor,UV)量

    • 类似今日头条、抖音、淘宝这样的额用户访问级别都是亿级的,请问如何处理

亿级系统中常见的四种统计

  • 聚合统计

    • 统计多个集合元素的聚合结果,就是交差并等集合统计

    • 交并差集聚合函数的应用

  • 排序统计

    • 在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,建议使用ZSet

    • 例如抖音短视频最新评论留言的场景

  • 二值统计

    • 集合元素的取值就只有0和1两种

      • 在钉钉上班签到打卡的场景中,只用记录有签到(1)或没签到(0)

    • 使用bitmap

  • 基数统计

    • 指统计一个集合中不重复的元素个数

    • 使用hyperloglog

hyperloglog

相关概念
  • UV

    • Unique Visitor,独立访客,一般理解为客户端IP

    • 需要去重考虑

  • PV

    • Page View,页面浏览量

    • 不用去重

  • DAU

    • Daily Active User,日活跃用户量,登录或者使用了某个产品的用户数(除去重复登录的用户)

    • 常用于反映网站、互联网应用或者网络游戏的运营情况

  • MAU

    • Monthly Active User,月活跃用户量

使用需求
  • 主要的目标是高效、巨量地进行计数,所以对存储的数据的内容不太关心

    • 只能用于统计巨量数量不太涉及具体的统计对象的内容和精准性

  • 适用很多计数类场景,比如每日注册 IP 数、每日访问 IP 数、页面实时访问数 PV、访问用户数 UV等

    • 统计单日一个页面的访问量(PV)单次访问就算一次

    • 统计单日一个页面的用户访问量(UV),即按照用户为维度计算,单个用户一天内多次访问也只算一次

    • 多个key的合并统计,某个门户网站的所有模块的PV聚合统计就是整个网站的总PV

HyperLogLog的引入
去重统计可以考虑的方式
  • HashSet

  • bitmap

    • 统计1亿个数据的基数位值,大约需要内存100000000/8/1024/1024约等于12M

    • 如果是1w个亿级就需要117.1875G,不适用大数据量下(亿级)的基数计数场景

    • 但是bitmaps方法是精确计算

  • 概率算法

    • 通过牺牲准确率来换取空间,对于不要求绝对准确率的场景下可以使用,因为概率算法不直接存储数据本身,通过一定的概率统计方法预估基数值,同时保证误差在一定范围内,由于又不储存数据故此可以大大节约内存

    • HyperLogLog就是一种概率算法的实现

HyperLogLog原理说明
  • 只是进行不重复的基数统计,不是集合也不保存数据,只记录数量而不是具体内容

  • Hyperloglog提供不精确的去重计数方案,牺牲准确率来换取空间,误差仅仅只是0.81%左右

  • 官方对误差和占用内存的回复

    • HyperLogLog 的标准误差为 1.04/sqrt(m),其中“m”是使用的槽位数,Redis 使用了 16384 个槽位,所以标准误差是 0.81%

    • 在 Redis 里面,每个HyperLogLog键只需要花费12KB 内存,就可以计算接近2^64个不同元素的基数

      image-20231006164156521

      .

      • Redis使用一个long型【64位】哈希值的前14个比特来确定槽位编号剩下的50个比特用来做基数估计

      • 因为2^6=64,所以每个槽位需要用6个比特用于表示桶中最长连续零位的长度

        • 类似于抛硬币中连续出现正面的次数,根据这个次数理论上的概率倒推抛硬币的次数

      • 在一般情况下,一个HLL数据结构占用内存的大小为16384*6/8=12kB,Redis将这种情况称为密集 (dense) 存储

亿级UV的Redis统计案例
  • 模拟不同ip访问

@Service
@Slf4j
public class HyperLogLogService
{
    @Resource
    private RedisTemplate redisTemplate;
    /**
     * 模拟后台有用户点击首页,每个用户来自不同ip地址
     */
    @PostConstruct
    public void init()
    {
        log.info("------模拟后台有用户点击首页,每个用户来自不同ip地址");
        new Thread(() -> {
            String ip = null;
            for (int i = 1; i <=200; i++) {
                Random r = new Random();
                ip = r.nextInt(256) + "." + r.nextInt(256) + "." + r.nextInt(256) + "." + r.nextInt(256);
                Long hll = redisTemplate.opsForHyperLogLog().add("hll", ip);
                log.info("ip={},该ip地址访问首页的次数={}",ip,hll);
                //暂停几秒钟线程
                try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }
            }
        },"t1").start();
    }
}
  • 获取当前访问基数

/**
 * @auther zzyy
 * @create 2021-05-02 18:16
 */
@Api(description = "淘宝亿级UV的Redis统计方案")
@RestController
@Slf4j
public class HyperLogLogController
{
    @Resource
    private RedisTemplate redisTemplate;
    @ApiOperation("获得IP去重后的首页访问量")
    @RequestMapping(value = "/uv",method = RequestMethod.GET)
    public long uv()
    {
        return redisTemplate.opsForHyperLogLog().size("hll");//pfcount
    }
}

GEO

面试题
  • 移动互联网时代LBS应用越来越多,交友软件中附近的小姐姐、外卖软件中附近的美食店铺、打车软件附近的车辆等等。那这种附近各种形形色色的XXX地址位置选择是如何实现的?

  • 如果使用mysql会出现的问题

    • 查询性能问题,如果并发高,数据量大这种查询是要搞垮mysql数据库的

    • 一般mysql查询的是一个平面矩形访问,而叫车服务要以我为中心N公里为半径的圆形覆盖

    • 精准度的问题,我们知道地球不是平面坐标系,而是一个圆球,这种矩形计算在长距离计算时会有很大误差,mysql不合适

附近的酒店推送案例
  • 关键点:以给定的经纬度为中心, 找出某一半径内的元素

  • service层

    • 添加位置坐标需要传递一个键值对,键为位置信息,值为位置坐标对象【其有参构造的参数为经度和维度

    • 获取两个给定位置的距离要指定的距离单位RedisGeoCommands.DistanceUnit类下

@Service
@Slf4j
public class GeoService
{
    public static final String CITY ="city";
    @Autowired
    private RedisTemplate redisTemplate;
    public String geoAdd()
    {
        Map<String, Point> map= new HashMap<>();
        map.put("天安门",new Point(116.403963,39.915119));
        map.put("故宫",new Point(116.403414 ,39.924091));
        map.put("长城" ,new Point(116.024067,40.362639));
        redisTemplate.opsForGeo().add(CITY,map);
        return map.toString();
    }
    public Point position(String member) {
        //获取经纬度坐标
        List<Point> list= this.redisTemplate.opsForGeo().position(CITY,member);
        return list.get(0);
    }
    public String hash(String member) {
        //geohash算法生成的base32编码值
        List<String> list= this.redisTemplate.opsForGeo().hash(CITY,member);
        return list.get(0);
    }
    public Distance distance(String member1, String member2) {
        //获取两个给定位置之间的距离
        Distance distance= this.redisTemplate.opsForGeo().distance(CITY,member1,member2, RedisGeoCommands.DistanceUnit.KILOMETERS);
        return distance;
    }
    public GeoResults radiusByxy() {
        //通过经度,纬度查找附近的,北京王府井位置116.418017,39.914402
        Circle circle = new Circle(116.418017, 39.914402, Metrics.KILOMETERS.getMultiplier());
        //返回50条
        RedisGeoCommands.GeoRadiusCommandArgs args = 
            RedisGeoCommands.GeoRadiusCommandArgs.
            newGeoRadiusArgs().includeDistance().
            includeCoordinates().sortAscending().limit(50);
        GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults= this.redisTemplate.opsForGeo()
            .radius(CITY,circle, args);
        return geoResults;
    }
    public GeoResults radiusByMember() {
        //通过地方查找附近
        String member="天安门";
        //返回50条
        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.
            newGeoRadiusArgs().includeDistance().includeCoordinates().sortAscending().limit(50);
        //半径10公里内
        Distance distance=new Distance(10, Metrics.KILOMETERS);
        GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults= this.redisTemplate.opsForGeo().
            radius(CITY,member, distance,args);
        return geoResults;
    }
}
  • controller层

@Api(tags = "美团地图位置附近的酒店推送GEO")
@RestController
@Slf4j
public class GeoController
{
    @Resource
    private GeoService geoService;
    @ApiOperation("添加坐标geoadd")
    @RequestMapping(value = "/geoadd",method = RequestMethod.GET)
    public String geoAdd()
    {
        return geoService.geoAdd();
    }
    @ApiOperation("获取经纬度坐标geopos")
    @RequestMapping(value = "/geopos",method = RequestMethod.GET)
    public Point position(String member)
    {
        return geoService.position(member);
    }
    @ApiOperation("获取经纬度生成的base32编码值geohash")
    @RequestMapping(value = "/geohash",method = RequestMethod.GET)
    public String hash(String member)
    {
        return geoService.hash(member);
    }
    @ApiOperation("获取两个给定位置之间的距离")
    @RequestMapping(value = "/geodist",method = RequestMethod.GET)
    public Distance distance(String member1, String member2)
    {
        return geoService.distance(member1,member2);
    }
    @ApiOperation("通过经度纬度查找北京王府井附近的")
    @RequestMapping(value = "/georadius",method = RequestMethod.GET)
    public GeoResults radiusByxy()
    {
        return geoService.radiusByxy();
    }
    @ApiOperation("通过地方查找附近,本例写死天安门作为地址")
    @RequestMapping(value = "/georadiusByMember",method = RequestMethod.GET)
    public GeoResults radiusByMember()
    {
        return geoService.radiusByMember();
    }
}

bitmap

面试题
  • 统计指定用户一年之中的登陆天数

  • 连续签到打卡

  • 日活统计或者是最近一周的活跃用户

  • 某用户按照一年365天,哪几天登陆过? 哪几天没有登陆? 全年中登录的天数共计多少?

连续签到案例
  • 签到日历仅展示当月签到数据,签到日历需展示最近连续签到天数

  • 小厂使用传统mysql

  • 大厂使用基于redis的bitmaps

    • 如果使用mysql的问题:一条签到记录对应一条记录,会占据越来越大的空间

    • bitmaps的使用

      • 一个月最多31天,刚好int类型是32位,一个int类型就可以搞定一个月,32位大于31天

      • 一条数据直接存储一个月的签到记录,不再是存储一天的签到记录

      • 在签到统计时,每个用户

        • 一天的签到用1个bit位就能表示

        • 一个月(假设是31天)的签到情况用31个bit位就可以

        • 一年的签到也只需要用365个bit位

布隆过滤器

面试题
  • 现有50亿个电话号码,现有10万个电话号码如何要快速准确的判断这些电话号码是否已经存在

  • 判断是否存在,布隆过滤器了解过吗?

  • 安全连接网址,全球数10亿的网址判断

  • 黑名单校验,识别垃圾邮件

  • 白名单校验,识别出合法用户进行后续处理

基础概念

  • 定义

    • 由一个初值都为零的bit数组多个哈希函数构成,用来快速判断集合中是否存在某个元素

    • 本质就是判断具体数据是否存在于一个大的集合中

  • !!!特点

    • 高效地插入和查询,占用空间少,返回的结果是不确定性+不够完美【共用坑位】

    • !!!重点:一个元素如果判断结果

      • 存在时,元素不一定存在

      • 不存在时,则一定不存在

    • 布隆过滤器可以添加元素,但是不能删除元素,由于涉及hashcode判断依据,删掉元素会导致误判率增加

  • 优点:高效地插入和查询,内存占用bit空间少【不存具体数据,只是存对应偏移量的二值情况】

  • 缺点

    • 不能删除元素,删掉元素会导致误判率增加,因为hash冲突,同一个位置可能存的东西被多个元素共用,删除一个元素的同时可能也把其它元素的存在记录删除

    • 存在误判,不能精准过滤

      • 有,是很可能有

      • 无,是肯定无,100%无

    • 布谷鸟过滤器【了解】

      • 为了解决布隆过滤器不能删除元素的问题,布谷鸟过滤器横空出世

      •  官方全英文档

底层原理

  • 数据结构

    • 布隆过滤器(Bloom Filter) 是一种专门用来解决去重问题的高级数据结构

    • 实质就是一个大型位数组和几个不同的无偏hash函数(无偏表示分布均匀)

      • 由一个初值都为零的bit数组和多个个哈希函数构成,用来快速判断某个数据是否存在

      • 有一点点不精确,也存在一定的误判概率

    • 添加key时:使用多个hash函数对key进行hash运算得到一个整数索引值,对位数组长度进行取模运算得到一个位置,每个hash函数都会得到一个不同的位置,将这几个位置都置1就完成了add操作

    • 查询key时:只要有其中一位是零就表示这个key不存在,但如果都是1,也不一定存在对应的key

  • hash冲突导致的数据不精准

    • 当有变量被加入集合时,通过N个映射函数将这个变量映射成位图中的N个点

      • 如果都是1,则被查询变量很可能存在,因为映射函数本身就是散列函数,散列函数是会有碰撞的

    image-20231007201933186

    .

    • 基于布隆过滤器的快速检测特性,可以在把数据写入数据库时,使用布隆过滤器做个标记

      • 当缓存缺失后,应用查询数据库时,可以通过查询布隆过滤器快速判断数据是否存在

      • 如果不存在,就不用再去数据库中查询。这样一来,即使发生缓存穿透了,大量请求只会查询Redis和布降过滤器,而不会积压到数据库,也就不会影响数据库的正常运行。布隆过滤器可以使用Redis实现,本身就能承担较大的并发访问压力

    • 如果两个散列值是不相同的(根据同一函数)那么这两个散列值的原始输入也是不相同的

      • 这个特性是散列函数具有确定性的结果,具有这种性质的散列函数称为单向散列函数

      • 散列函数的输入和输出不是唯一对应关系

      • 如果两个散列值相同,两个输入值很可能是相同的,但也可能不同,这种情况称为“散列碰撞(collision)

      • 用 hash表存储大数据量时,空间效率还是很低,当只有一个 hash 函数时,还很容易发生哈希碰撞

使用步骤

  1. 初始化bitmap

  2. 添加占坑位

    1. 向布隆过滤器中添加数据时,为了尽量地址不冲突,会使用多个 hash 函数对key进行运算,算得一个下标索引值

    2. 然后对位数组长度进行取模运算得到一个位置,每个hash函数都会算得一个不同的位置

    3. 再把位数组的这几个位置都置为 1 就完成了 add 操作

    • 总结:进行多次hash(key) → 取模运算→ 得到多个坑位

  3. 判断是否存在:先把这个key通过相同的多个 hash 函数进行运算,查看对应的位置是否都为1

  • 注意事项

    • 使用时最好不要让实际元素数量远大于初始化数量,一次给够避免扩容

    • 当实际元素数量超过初始化数量时,应该对布隆过滤器进行重建【用重建代替删除】

      1. 重新分配一个size更大的过滤器

      2. 再将所有的历史元素批量add进行

!!!使用场景

  • !!!解决缓存穿透的问题,和redis结合bitmap使用

    • 缓存穿透

      • 一般情况下,先查询缓存redis是否有该条数据,缓存中没有时,再查询数据库。当数据库也不存在该条数据时,每次查询都要访问数据库,这就是缓存穿透

      • 缓存透带来的问题是,当有大量请求查询数据库不存在的数据时,就会给数据库带来压力,甚至会拖垮数据库

    • 使用布隆过滤器解决缓存穿透的问题

      image-20231007204902315

      .

      • 已存在数据的key存在布隆过滤器中,相当于redis前面挡着一个布隆过滤器

      • 当有新的请求时,先到布隆过滤器中查询是否存在

        • 不存在该条数据则直接返回;

        • 已存在,才去查询缓存redis,如果redis里没查询到则再查询Mysql数据库

  • 黑名单校验,识别垃圾邮件

    • 发现存在黑名单中的,就执行特定操作。比如:识别垃圾邮件,只要是邮箱在黑名单中的邮件,就识别为垃圾邮件

      • 假设黑名单的数量是数以亿计的,存放起来就是非常耗费存储空间的,布隆过滤器则是一个较好的解决方案

      • 把所有黑名单都放在布隆过滤器中,在收到邮件时,判断邮件地址是否在布隆过滤器中即可

  • 安全连接网址,全球上10亿的网址判断

手写布隆过滤器

image-20231007205817353

.

步骤设计
  • setBit的构建过程

    1. @PostConstruct初始化白名单数据

    2. 计算元素的hash值

    3. 通过上一步hash值算出对应的二进制数组的坑位

    4. 将对应坑位的值的修改为数字1,表示存在

  • getBit查询是否存在

    1. 计算元素的hash值

    2. 通过上一步hash值算出对应的二进制数组的坑位

    3. 返回对应坑位的值,0表示无,1表示存在

Mybatis通用Mapper4
  • 可以专门创建一个模块生成mybatis的mapper接口、实体类并处理好映射关系

  1. 创建测试案例表

    CREATE TABLE `t_customer` (
      `id` int(20) NOT NULL AUTO_INCREMENT,
      `cname` varchar(50) NOT NULL,
      `age` int(10) NOT NULL,
      `phone` varchar(20) NOT NULL,
      `sex` tinyint(4) NOT NULL,
      `birth` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
      PRIMARY KEY (`id`),
      KEY `idx_cname` (`cname`)
    ) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4
  2. 导入依赖

    <properties>
        <!--  依赖版本号 -->
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <java.version>1.8</java.version>
        <hutool.version>5.5.8</hutool.version>
        <druid.version>1.1.18</druid.version>
        <mapper.version>4.1.5</mapper.version>
        <pagehelper.version>5.1.4</pagehelper.version>
        <mysql.version>5.1.39</mysql.version>
        <swagger2.version>2.9.2</swagger2.version>
        <swagger-ui.version>2.9.2</swagger-ui.version>
        <mybatis.spring.version>2.1.3</mybatis.spring.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--Mybatis 通用mapper tk单独使用,自己带着版本号-->
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis</artifactId>
            <version>3.4.6</version>
        </dependency>
        <!--mybatis-spring-->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>${mybatis.spring.version}</version>
        </dependency>
        <!-- Mybatis Generator -->
        <dependency>
            <groupId>org.mybatis.generator</groupId>
            <artifactId>mybatis-generator-core</artifactId>
            <version>1.4.0</version>
            <scope>compile</scope>
            <optional>true</optional>
        </dependency>
        <!--通用Mapper-->
        <dependency>
            <groupId>tk.mybatis</groupId>
            <artifactId>mapper</artifactId>
            <version>${mapper.version}</version>
        </dependency>
        <!--persistence-->
        <dependency>
            <groupId>javax.persistence</groupId>
            <artifactId>persistence-api</artifactId>
            <version>1.0.2</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>
    <build>
        <resources>
            <resource>
                <directory>${basedir}/src/main/java</directory>
                <includes>
                    <include>**/*.xml</include>
                </includes>
            </resource>
            <resource>
                <directory>${basedir}/src/main/resources</directory>
            </resource>
        </resources>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.mybatis.generator</groupId>
                <artifactId>mybatis-generator-maven-plugin</artifactId>
                <version>1.3.6</version>
                <configuration>
                 	<configurationFile>
                     ${basedir}/src/main/resources/generatorConfig.xml</configurationFile>
                    <overwrite>true</overwrite>
                    <verbose>true</verbose>
                </configuration>
                <dependencies>
                    <dependency>
                        <groupId>mysql</groupId>
                        <artifactId>mysql-connector-java</artifactId>
                        <version>${mysql.version}</version>
                    </dependency>
                    <dependency>
                        <groupId>tk.mybatis</groupId>
                        <artifactId>mapper</artifactId>
                        <version>${mapper.version}</version>
                    </dependency>
                </dependencies>
            </plugin>
        </plugins>
    </build>
  3. mgb配置相关,在src\main\resources路径下新建

    • config.properties【package.name指定生成的文件放置的位置】

    package.name=com.liao.testMapper4
    jdbc.driverClass = com.mysql.jdbc.cj.Driver
    jdbc.url=jdbc:mysql://localhost:3306/gym?useUnicode=true&characterEncoding=utf-8&useSSL=false
    jdbc.user = root
    jdbc.password =1212go12
    • generatorConfig.xml【注意table标签的tableName属性需要填写数据库创建的表名】

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE generatorConfiguration
            PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
            "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
    <generatorConfiguration>
        <properties resource="config.properties"/>
        <context id="Mysql" targetRuntime="MyBatis3Simple" defaultModelType="flat">
            <property name="beginningDelimiter" value="`"/>
            <property name="endingDelimiter" value="`"/>
            <plugin type="tk.mybatis.mapper.generator.MapperPlugin">
                <property name="mappers" value="tk.mybatis.mapper.common.Mapper"/>
                <property name="caseSensitive" value="true"/>
            </plugin>
            <jdbcConnection driverClass="${jdbc.driverClass}"
                            connectionURL="${jdbc.url}"
                            userId="${jdbc.user}"
                            password="${jdbc.password}">
            </jdbcConnection>
            <javaModelGenerator targetPackage="${package.name}.entities" targetProject="src/main/java"/>
            <sqlMapGenerator targetPackage="${package.name}.mapper" targetProject="src/main/java"/>
            <javaClientGenerator targetPackage="${package.name}.mapper" targetProject="src/main/java" type="XMLMAPPER"/>
            <table tableName="t_customer" domainObjectName="Customer">
                <generatedKey column="id" sqlStatement="JDBC"/>
            </table>
        </context>
    </generatorConfiguration>
  4. 一键生成:双击插件mybatis-generator:gererate一键生成entity+mapper接口+xml实现SQL

    image-20231007213619856

    .

    • 一键生成失败报数据库连接失败解决:保证依赖的mysql的驱动版本和本机mysql数据库版本相匹配

      image-20231007215746677

  5. 生成成功

    image-20231007221655344

搭建双写架构
  1. 另外再新建一个模块,将生成的mapper相关文件拷贝进来

    • 其中mapper.xml要拷贝到resources目录下的mapper目录中

  2. 导入依赖

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <junit.version>4.12</junit.version>
        <log4j.version>1.2.17</log4j.version>
        <lombok.version>1.16.18</lombok.version>
    </properties>
    <dependencies>
        <!--SpringBoot通用依赖模块-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--jedis-->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>4.3.1</version>
        </dependency>
        <!--lettuce-->
        <!--<dependency>
            <groupId>io.lettuce</groupId>
            <artifactId>lettuce-core</artifactId>
            <version>6.2.1.RELEASE</version>
        </dependency>-->
        <!--SpringBoot与Redis整合依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        <!--swagger2-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>2.9.2</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>2.9.2</version>
        </dependency>
        <!--Mysql数据库驱动-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.47</version>
        </dependency>
        <!--SpringBoot集成druid连接池-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.10</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>1.1.16</version>
        </dependency>
        <!--mybatis和springboot整合-->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>1.3.0</version>
        </dependency>
        <!--hutool-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.2.3</version>
        </dependency>
        <!--persistence-->
        <dependency>
            <groupId>javax.persistence</groupId>
            <artifactId>persistence-api</artifactId>
            <version>1.0.2</version>
        </dependency>
        <!--通用Mapper-->
        <dependency>
            <groupId>tk.mybatis</groupId>
            <artifactId>mapper</artifactId>
            <version>4.1.5</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-autoconfigure</artifactId>
        </dependency>
        <!--通用基础配置junit/devtools/test/log4j/lombok/-->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>${junit.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>log4j</groupId>
            <artifactId>log4j</artifactId>
            <version>${log4j.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
            <optional>true</optional>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
  3. 配置文件

    server.port=7777
    spring.application.name=redisTemplate_study
    # ========================logging=====================
    logging.level.root=info
    logging.level.com.atguigu.redis7=info
    logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger- %msg%n
    logging.file.name=D:/Study/mylog/redis7_study.log
    logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger- %msg%n
    # ========================swagger=====================
    spring.swagger2.enabled=true
    spring.mvc.pathmatch.matching-strategy=ant_path_matcher
    # ========================redis单机=====================
    spring.redis.database=0
    # 修改为自己真实IP
    spring.redis.host=192.168.32.100
    spring.redis.port=6379
    spring.redis.password=1212go12
    spring.redis.lettuce.pool.max-active=8
    spring.redis.lettuce.pool.max-wait=-1ms
    spring.redis.lettuce.pool.max-idle=8
    spring.redis.lettuce.pool.min-idle=0
    # ========================alibaba.druid=====================
    spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
    spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
    spring.datasource.url=jdbc:mysql://localhost:3306/redis?useUnicode=true&characterEncoding=utf-8&useSSL=false
    spring.datasource.username=root
    spring.datasource.password=1212go12
    spring.datasource.druid.test-while-idle=false
    # ========================mybatis===================
    mybatis.mapper-locations=classpath:mapper/*.xml
    mybatis.type-aliases-package=com.atguigu.redis7.entities
  4. 主启动类要扫描所有的mapper接口

    @SpringBootApplication
    @MapperScan("com/example/bloomfilter/mapper")//import tk.mybatis.spring.annotation.MapperScan;
    public class BloomFilterApplication {
        public static void main(String[] args) {
            SpringApplication.run(BloomFilterApplication.class, args);
        }
    }
  5. 业务类

    @Service
    public class CustomerServcie {
        private static final String CACHE_KEY_CUSTOMER="customer:"
        @Resource
        private CustomerMapper customerMapper;
        @Resource
        private RedisTemplate redisTemplate;
        public void addCustomer(Customer customer){
            int i=customerMapper.insertSelective(customer);
            if(i>0){
                //插入成功,就重新将该数据查询出来,写入redis
                Customer result = customerMapper.selectByPrimaryKey(customer.getId());
                String key=CACHE_KEY_CUSTOMER+customer.getId();//拼接写入redis的key
                redisTemplate.opsForValue().set(key,result);//写回redis
            }
        }
        public Customer findCustomerById(Integer CustomerId){
            Customer customer=null;
            //先从redis缓存中查询
            String key=CACHE_KEY_CUSTOMER+CustomerId;
            customer = (Customer) redisTemplate.opsForValue().get(key);
            if(customer==null){
                //如果缓存中没有就去mysql数据库中找
                customer = customerMapper.selectByPrimaryKey(CustomerId);
                if(customer!=null){
                    //如果mysql中有就写回redis
                    redisTemplate.opsForValue().set(key,customer);
                }
            }
            return customer;
        }
    }
  6. 测试

    • 如果出现序列化问题,就在redis配置类对序列化进行配置

    • 报错信息

      • java.lang.IllegalArgumentException: DefaultSerializer requires a Serializable payload but received an object of type [com.example.bloomfilter.entities.Customer]

    • 解决

      @Configuration
      public class RedisConfig
      {
          @Bean
          public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory)
          {
              RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
              redisTemplate.setConnectionFactory(lettuceConnectionFactory);
              //设置key序列化方式string
              redisTemplate.setKeySerializer(new StringRedisSerializer());
              //设置value的序列化方式json,使用GenericJackson2JsonRedisSerializer替换默认序列化
              redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
              redisTemplate.setHashKeySerializer(new StringRedisSerializer());
              redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
              redisTemplate.afterPropertiesSet();
              return redisTemplate;
          }
      }
    • 运行成功

    image-20231008130250825

    .

添加布隆过滤器
  1. 初始化白名单

    • @PostConstruct修饰的方法

      • 会在服务器加载Servlet的时候运行,并且只会被服务器调用一次,类似于Serclet的init()方法

      • 在构造函数之后,init()方法之前运行

    @Component
    @Slf4j
    public class BloomFilterInit
    {
        @Resource
        private RedisTemplate redisTemplate;
        @PostConstruct//初始化白名单数据
        public void init()
        {
            //白名单客户预加载到布隆过滤器
            String uid = "customer:12";
            //1 计算hashcode,由于可能有负数,直接取绝对值
            int hashValue = Math.abs(uid.hashCode());
            //2 通过hashValue和2的32次方取余后,获得对应的下标坑位
            long index = (long) (hashValue % Math.pow(2, 32));
            log.info(uid+" 对应------坑位index:{}",index);
            //3 设置redis里面bitmap对应坑位,该有值设置为1
            redisTemplate.opsForValue().setBit("whitelistCustomer",index,true);
        }
    }
  2. 编写检查key是否在布隆过滤器的工具类

    @Slf4j
    @Component
    public class CheckUtil {
        @Resource
        public RedisTemplate redisTemplate;
        public boolean checkWithBloomFilter(String checkItem,String key){
            //1 计算hashcode,由于可能有负数,直接取绝对值
            int hashValue = Math.abs(key.hashCode());
            //2 通过hashValue和2的32次方取余后,获得对应的下标坑位
            long index = (long) (hashValue % Math.pow(2, 32));
            Boolean isOk = redisTemplate.opsForValue().getBit(checkItem, index);
            log.info("----->key:"+key+"\t对应坑位index:"+index+"\t是否存在:"+existOK);
            return isOk;
        }
    }
  3. 修改查询的业务逻辑【先走布隆过滤器再走缓存】

    Customer customer = null;
    //先从redis缓存中查询
    String key = CACHE_KEY_CUSTOMER + CustomerId;
    //先去查布隆过滤器,无就是无,有就可能有
    if (!checkUtil.checkWithBloomFilter("whitelistCustomer", key)) {
        log.info("白名单无此顾客信息:{}", key);
        return null;
    }
    customer = (Customer) redisTemplate.opsForValue().get(key);
    if (customer == null) {
        //如果缓存中没有就去mysql数据库中找
        customer = customerMapper.selectByPrimaryKey(CustomerId);
        if (customer != null) {
            //如果mysql中有就写回redis
            redisTemplate.opsForValue().set(key, customer);
        }
    }
    return customer;
  4. 测试

    • 项目启动

      image-20231008162244194

    • 查询redis中是否有布隆过滤器的bitmap,并根据通过customer:12的哈希值作为偏移量,查询其是否存在

      image-20231008163139994

      .

    • 测试调用控制器方法查询customer:12【在白名单中存在】是否存在

      image-20231008162532206

      .

    • 查询白名单中不存在的id

      image-20231008162624364

      .

    • 将redis中的customer:12删除,故意差异化数据【布隆过滤器中有,redis中没有

      image-20231008164855855

      .

!!!缓存预热/雪崩/击穿/穿透

面试题
  • 缓存预热、雪崩、穿透、击穿分别是什么? 你遇到过那几个情况?

  • 缓存预热你是怎么做的?

  • 如何避免或者减少缓存雪崩?

  • 穿透和击穿有什么区别? 他两是一个意思还是截然不同?

  • 穿透和击穿你有什么解决方案? 如何避免?

  • 假如出现了缓存不一致,你有哪些修补方案?

缓存预热

  • 定义

    • 缓存预热就是系统上线后,提前将相关的缓存数据直接加载到缓存系统

    • 避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题

  • 预热的方式

    • 什么都不做,先对mysql做了数据新增,利用redis的回写机制,让它逐步实现100条新增记录的同步

      • 最好提前晚上部署发布版本的时候,由自己人提前做一次,让redis同步了,不要把这个问题留给客户

    • 通过中间件或者程序自行完成

缓存雪崩

  • 分类

    • 硬件:redis主机挂了,Redis 全盘崩溃,偏硬件运维

    • 软件:redis中有大量key同时过期大面积失效,偏软件开发

  • 预防和解决

    • redis中key设置为永不过期 or 过期时间错开

    • redis缓存集群实现高可用

      • 主从+哨兵

      • Redis集群

      • 开启Redis持久化机制aof/rdb,尽快恢复缓存集群

    • 多缓存结合预防雪崩:ehcache本地缓存 + redis缓存

    • 服务降级【提醒用户服务器崩了】:Hystrix或者阿里sentinel限流&降级

    • 花钱买阿里云的云数据库【上述四种方式都兼顾了】

!!!缓存穿透

定义
  • 请求去查询一条记录,先查redis无,后查mysql无,都查询不到该条记录但是请求每次都会打到数据库上面去,导致后台数据库压力暴增,这种现象称为缓存穿透

  • 可能存在的危害:黑客恶意攻击

解决方案

image-20231008172949360

.

空对象缓存或缺省值【回写增强】
  • 如果发生了缓存穿透,可以针对要查询的数据,在Redis存一个和业务部门商量后确定的缺省值(比如,零、负数、defaultNull等)

  • 比如,键uid:abcdxxx是mysql中不存在的

    1. 先去redis查键uid:abcdxxx没有获得,再去mysql查没有获得 ,这就发生了一次穿透现象

    2. 可以增强回写机制:mysql也查不到的话也让redis存入刚刚查不到的key并保护mysql

    3. 第二次来查uid:abcdxxx,此时redis就有值了

    4. 可以直接从Redis中读取default缺省值返回给业务应用程序,避免把大量请求发送给mysql处理,打爆mysql

  • 但是,此方法架不住黑客的恶意攻击,只能解决key相同的情况

    • key相同打你系统:第一次打到mysql,空对象缓存后第二次就返回defaultNull缺省值避免mysql被攻击,不用再到数据库中去走一圈了
    • key不同打你系统:由于存在空对象缓存和缓存回写(看自己业务不限死),redis中的无关紧要的key也会越写越多(记得设置redis过期时间)
Google布隆过滤器Guava
  • 官方源码

白名单案例
  • 架构:全部合法的key都需要放入Guava版布隆过滤器+redis里面,不然数据就是返回null

image-20231008175456872

.

  • 导入依赖

<!--guava Google 开源的 Guava 中自带的布隆过滤器-->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>23.0</version>
</dependency>
  • 配置文件只需要配置swagger、mysql数据库连接和redis连接即可【和之前的案例一样,这里就不在引入】

  • 主启动要扫描mapper接口所在的包

  • Guava的基本使用

public void testGuavaWithBloomFilter()
{
	// 创建布隆过滤器对象								指定key的数据类型	  存储数据的数量
    BloomFilter<Integer> filter = BloomFilter.create(Funnels.integerFunnel(), 100);
	// 将元素添加进布隆过滤器
    filter.put(1);
    filter.put(2);
    // 判断指定元素是否存在
    System.out.println(filter.mightContain(1));
    System.out.println(filter.mightContain(2));
}
误判率原理
  • 可以在创建布隆过滤器的时候指定误判率fpp【假阳性概率】

public static double fpp = 0.03;
// 构建布隆过滤器
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size,fpp);
  1. 根据误判率大小,会创建指定数量的比特位和hash函数

    image-20231009204335636

    .

  2. 如果误判率越小,申请的比特位和hash函数数量也会提高【误判率越小所需的资源越多】

    image-20231009204802920

    .

  3. 如果不知道误判率的话,默认为0.03的误判率,对应的hash函数为5个

    image-20231009205030575

    .

一图流总结

image-20231009205300962

.

黑名单案例
  • 使用场景

    • 抖音防止推荐重复视频

    • 饿了么防止推荐重复优惠券,推荐过的尽量别在重复推荐

  • 执行流程

    1. 推荐时先去布隆过滤器判断

    2. 存在说明在黑名单里面,已经推荐过不再重复推荐

    3. 不存在就是新视频,推荐给用户并更新进布隆过滤器,防止下次重复推荐

image-20231009205446289

.

!!!缓存击穿

基础知识
  • 定义:大量的请求同时查询一个 key,此时这个key正好失效了,就会导致大量的请求都打到数据库上面

    • 失效的情况

      • 该key的恰好到达过期时间

      • 删除老key的时候新key还没补上

    • 击穿和穿透的区别

      • 穿透是该key本身就不存在

      • 击穿是本身存在的热点key失效

  • 危害:会造成某一时刻数据库请求量过大,压力剧增

    • 一般技术部门需要知道热点key是哪些, 做到心里有数防止击穿

  • 解决方案

    • 差异失效时间,对于访问频繁的热点key,干脆就不设置过期时间

    • 互斥更新,采用双检加锁策略

防止缓存击穿案例

image-20231009212521401

.

问题分析
步骤说明
1100%高并发,绝对不可以用mysql实现
2先把mysql里面参加活动的数据抽取进redis,一般采用定时器扫描来决定上线活动还是下线取消
3支持分页功能,一页20条记录
redis数据类型选用list

image-20231009212826036

.

业务代码
  • 实体类

@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(value = "聚划算活动producet信息")
public class Product
{
    //产品ID
    private Long id;
    //产品名称
    private String name;
    //产品价格
    private Integer price;
    //产品详情
    private String detail;
}
  • 业务层

@Service
@Slf4j
public class JHSTaskService
{
    public  static final String JHS_KEY="jhs";
    public  static final String JHS_KEY_A="jhs:a";
    public  static final String JHS_KEY_B="jhs:b";
    @Autowired
    private RedisTemplate redisTemplate;
    /**
     * 偷个懒不加mybatis了,模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
     * @return
     */
    private List<Product> getProductsFromMysql() {
        List<Product> list=new ArrayList<>();
        for (int i = 1; i <=20; i++) {
            Random rand = new Random();
            int id= rand.nextInt(10000);
            Product obj=new Product((long) id,"product"+i,i,"detail");
            list.add(obj);
        }
        return list;
    }
    @PostConstruct
    public void initJHS(){
        log.info("启动定时器淘宝聚划算功能模拟.........."+ DateUtil.now());
        new Thread(() -> {
            //模拟定时器一个后台任务,定时把数据库的特价商品,刷新到redis中
            while (true){
                //模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
                List<Product> list=this.getProductsFromMysql();//采用redis list数据结构的lpush来实现存储
                this.redisTemplate.delete(JHS_KEY);//lpush命令,删除旧数据
                this.redisTemplate.opsForList().leftPushAll(JHS_KEY,list);//更新新数据
                //间隔一分钟 执行一遍,模拟聚划算每3天刷新一批次参加活动
                try { TimeUnit.MINUTES.sleep(1); 
                    } catch (InterruptedException e) { e.printStackTrace(); }
                log.info("runJhs定时刷新..............");
            }
        },"t1").start();
    }
}
  • 控制层

@RestController
@Slf4j
@Api(tags = "聚划算商品列表接口")
public class JHSProductController
{
    public  static final String JHS_KEY="jhs";
    public  static final String JHS_KEY_A="jhs:a";
    public  static final String JHS_KEY_B="jhs:b";
    @Autowired
    private RedisTemplate redisTemplate;
    /**
     * 分页查询:在高并发的情况下,只能走redis查询,走db的话必定会把db打垮
     */
    @RequestMapping(value = "/pruduct/find",method = RequestMethod.GET)
    @ApiOperation("按照分页和每页显示容量,点击查看")
    public List<Product> find(int page, int size) {
        List<Product> list=null;
        long start = (page - 1) * size;
        long end = start + size - 1;
        try {
            //采用redis list数据结构的lrange命令实现分页查询
            list = this.redisTemplate.opsForList().range(JHS_KEY, start, end);
            if (CollectionUtils.isEmpty(list)) {
                //TODO 如果redis没有查询出数据就走DB查询
            }
            log.info("查询结果:{}", list);
        } catch (Exception ex) {
            //这里的异常,一般是redis瘫痪 ,或 redis网络timeout
            log.error("exception:", ex);
            //TODO 走DB查询
        }
        return list;
    }
}
隐患说明
  • 热点key突然失效导致可怕的缓存击穿

    • delete命令执行的一瞬间有空隙

    • 其它请求线程继续找Redis为null,打到了mysql

image-20231009215602146

.

加固案例
加固方案
  • 互斥更新,采用双检加锁策略

  • 采用差异失效时间

    • 新建:开辟两块缓存,主A从B,先更新B再更新A,严格照这个顺序

    • 查询:先查询主缓存A,如果A没有(消失或者失效了)再查询从缓存B

    • 主缓存A到期撤下来的瞬间,B可以顶上去【B缓存过期时间要大于A】,这样子还可以查到短时间的旧数据

image-20231009221417634

.

模拟差异失效时间加固
  • service层

 @PostConstruct
public void initJHSAB(){
    log.info("启动AB定时器计划任务淘宝聚划算功能模拟.........."+DateUtil.now());
    new Thread(() -> {
        //模拟定时器,定时把数据库的特价商品,刷新到redis中
        while (true){
            //模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
            List<Product> list=this.getProductsFromMysql();
            //先更新B缓存
            this.redisTemplate.delete(JHS_KEY_B);
            this.redisTemplate.opsForList().leftPushAll(JHS_KEY_B,list);
            this.redisTemplate.expire(JHS_KEY_B,20L,TimeUnit.DAYS);
            //再更新A缓存
            this.redisTemplate.delete(JHS_KEY_A);
            this.redisTemplate.opsForList().leftPushAll(JHS_KEY_A,list);
            this.redisTemplate.expire(JHS_KEY_A,15L,TimeUnit.DAYS);
            //间隔一分钟 执行一遍
            try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
            log.info("runJhs定时刷新双缓存AB两层..............");
        }
    },"t1").start();
}
  • controller层

@RequestMapping(value = "/pruduct/findab",method = RequestMethod.GET)
@ApiOperation("防止热点key突然失效,AB双缓存架构")
public List<Product> findAB(int page, int size) {
    List<Product> list=null;
    long start = (page - 1) * size;
    long end = start + size - 1;
    try {
        //采用redis list数据结构的lrange命令实现分页查询
        list = this.redisTemplate.opsForList().range(JHS_KEY_A, start, end);
        if (CollectionUtils.isEmpty(list)) {
            log.info("=========A缓存已经失效了,记得人工修补,B缓存自动延续5天");
            //用户先查询缓存A(上面的代码),如果缓存A查询不到(例如,更新缓存的时候删除了),再查询缓存B
            this.redisTemplate.opsForList().range(JHS_KEY_B, start, end);
            if(CollectionUtils.isEmpty(list)){//TODO 走DB查询
            }         
        }
        log.info("查询结果:{}", list);
    } catch (Exception ex) {
        //这里的异常,一般是redis瘫痪 ,或 redis网络timeout
        log.error("exception:", ex);
        //TODO 走DB查询
    }
    return list;
}
总结
缓存问题产生原因解决方案
缓存更新方式数据变更、缓存时效性同步更新、失效更新、异步更新、定时更新
缓存不一致同步更新失败、异步更新增加重试、补偿任务、最终一致
缓存穿透恶意攻击空对象缓存、bloomfilter过滤器
缓存击穿热点key失效互斥更新、随机退避、差异失效时间
缓存雪崩缓存挂掉快速失败熔断、主从模式、集群模式
缓存预热更新迭代时中redis缓存还没同步预先加载数据库数据到缓存

!!!手写Redis分布式锁

面试题
  • Redis除了拿来做缓存,你还见过基于Redis的什么用法?

    • 数据共享,分布式Session,分布式锁

    • 全局ID,计算器

    • 购物车,轻量级消息队列,抽奖

    • 位统计、点赞、签到、打卡

    • 差集交集并集,用户关注、可能认识的人,推荐模型

    • 热点新闻、热搜排行榜

  • Redis 做分布式锁的时候有需要注意的问题?

  • 你们公司自己实现的分布式锁是否用的setnx命令实现?

  • 这个是最合适的吗? 你如何考虑分布式锁的可重入问题?

  • 如果是 Redis 是单点部署的,会带来什么问题?那你准备怎么解决单点问题呢?

  • Redis集群模式下,比如主从模式,CAP方面有没有什么问题呢?

    • CAP

      • redis集群是AP【只要主机master收到消息就立刻同步,存在只有master收到但还没同步给从机就挂掉的风险】

      • redis单机C,一致性

    • zookeeper集群是CP【所有集群节点都收到信息,整体一致性】

  • 那你简单的介绍一下 Redlock 吧? 你简历上写redisson,你谈谈

  • Redis分布式锁如何续期? 看门狗知道吗?

基础知识
锁的种类
  • 单机版同一个JVM虚拟机内,synchronized或者Lock接口【synchronized只对自身所在的java虚拟机负责】

  • 分布式多个不同JVM虚拟机,单机的线程锁机制不再起作用,资源类在不同的服务器之间共享

image-20231010110023215

.

分布式锁需要满足的条件
  • 独占性:任何时刻只能有且仅有一个线程持有

  • 高可用

    • 若redis集群环境下,不能因为某一个节点挂了而出现获取锁和释放锁失败的情况

    • 高并发请求下,依旧性能OK好使

  • 防死锁:杜绝死锁,必须有超时控制机制或者撤销操作,有个兜底终止跳出方案

  • 不乱抢:防止张冠李戴,不能私下unlok别人的锁,只能自己加锁自己释放

  • 重入性:同一个节点的同一个线程如果获得锁之后,它也可以再次获取这个锁

分布式锁用到的命令
  • setnx key value【setnx+expire不安全,两条命令非原子性

    • 中小厂可以用,大厂绝对不可以

    image-20231010111131746

    .

  • set key value [EX seconds] [PX milliseconds] [NX|XX]

image-20231010111231429

.

基础架构

  • 重点:JUC中AQS锁的规范落地参考+可重入锁考虑+Lua脚本+Redis命令一步步实现分布式锁

  • 基础案例的使用场景:多个服务间保证同一时刻同一时间段内同一用户只能有一个请求(防止关键业务出现并发攻击)

  • 步骤

    1. 建立两个module

    2. 导入通用依赖

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <lombok.version>1.16.18</lombok.version>
    </properties>
    <dependencies>
        <!--SpringBoot通用依赖模块-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--SpringBoot与Redis整合依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        <!--swagger2-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>2.9.2</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>2.9.2</version>
        </dependency>
        <!--通用基础配置boottest/lombok/hutool-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.8</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
    1. 配置文件

    server.port=使用不同端口号模拟两台主机
    spring.application.name=主机1、2
    # ========================swagger=====================
    spring.swagger2.enabled=true
    spring.mvc.pathmatch.matching-strategy=ant_path_matcher
    # ========================redis单机=====================
    spring.redis.database=0
    # 修改为自己真实IP
    spring.redis.host=192.168.32.100
    spring.redis.port=6379
    spring.redis.password=1212go12
    spring.redis.lettuce.pool.max-active=8
    spring.redis.lettuce.pool.max-wait=-1ms
    spring.redis.lettuce.pool.max-idle=8
    spring.redis.lettuce.pool.min-idle=0
    1. 业务类

      • Swagger配置类

      @Configuration
      @EnableSwagger2
      public class Swagger2Config
      {
          @Value("${swagger2.enabled}")
          private Boolean enabled;
          @Bean
          public Docket createRestApi() {
              return new Docket(DocumentationType.SWAGGER_2)
                      .apiInfo(apiInfo())
                      .enable(enabled)
                      .select()
                      .apis(RequestHandlerSelectors.basePackage("配置类所在包的项目路径【不包含config包名】")) 
                      .paths(PathSelectors.any())
                      .build();
          }
          private ApiInfo apiInfo() {
              return new ApiInfoBuilder()
                      .title("springboot利用swagger2构建api接口文档 "+"\t"+ DateTimeFormatter.ofPattern("yyyy-MM-dd").format(LocalDateTime.now()))
                      .description("springboot+redis整合")
                      .version("1.0")
                      .termsOfServiceUrl("https://www.baidu.com/")
                      .build();
          }
      }
      • Redis配置类

      @Configuration
      public class RedisConfig
      {
          @Bean
          public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory)
          {
              RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
              redisTemplate.setConnectionFactory(lettuceConnectionFactory);
              //设置key序列化方式string
              redisTemplate.setKeySerializer(new StringRedisSerializer());
              //设置value的序列化方式json
              redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
              redisTemplate.setHashKeySerializer(new StringRedisSerializer());
              redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
              redisTemplate.afterPropertiesSet();
              return redisTemplate;
          }
      }
      • InventoryService【库存业务类】

      @Service
      @Slf4j
      public class InventoryService
      {
          @Autowired
          private StringRedisTemplate stringRedisTemplate;
          @Value("${server.port}")
          private String port;
          private Lock lock = new ReentrantLock();
          public String sale()
          {
              String retMessage = "";
              lock.lock();
              try
              {
                  //1 查询库存信息
                  String result = stringRedisTemplate.opsForValue().get("inventory001");
                  //2 判断库存是否足够
                  Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
                  //3 扣减库存
                  if(inventoryNumber > 0) {
                      stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
                      retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
                      System.out.println(retMessage);
                  }else{
                      retMessage = "商品卖完了,o(╥﹏╥)o";
                  }
              }finally {
                  lock.unlock();
              }
              return retMessage+"\t"+"服务端口号:"+port;
          }
      }
      • InventoryController【库存控制类】

      @RestController
      @Api(tags = "redis分布式锁测试")
      public class InventoryController
      {
          @Autowired
          private InventoryService inventoryService;
          @ApiOperation("扣减库存,一次卖一个")
          @GetMapping(value = "/inventory/sale")
          public String sale()
          {
              return inventoryService.sale();
          }
      }

!!!手写分布式锁

1.0初始化版本简单添加
  • 请将7777的业务逻辑代码原样拷贝到8888

  • 两台机器都要加synchronized或者Lock单机锁

2.0nginx分布式微服务架构

image-20231010120633420

.

  • 命令地址和配置地址

    • 命令地址:/usr/local/nginx/sbin

    • 配置地址:/usr/local/nginx/conf

  • 启动/关闭/重启【在/usr/local/nginx/sbin目录下】

    • 启动:./nginx 指定配置文件路径

      • 启动Nginx并测试通过,浏览器看到nginx欢迎welcome页面

    • 关闭:./nginx -s stop

    • 重启:./nginx -s reload

  • /usr/local/nginx/conf目录下修改配置文件nginx.conf新增反向代理负载均衡配置

    image-20231010121216302

    .

  • 启动两个微服务,然后通过nginx访问ip地址【反向代理+负载均衡】

    • http://本机nignx服务器的ip地址/inventory/sale

    • 默认轮询

    image-20231010122159625

    .

  • 存在的问题:高并发环境下出现超卖现象【同一件商品被售卖多次】

    • 可以使用jmeter工具模拟高并发【springCloud会讲】

    image-20231010123126649

    .

    • 出现超卖的原因【为什么加了synchronized或者Lock还是没有控制住

      • 在单机环境下,可以使用synchronized或Lock来实现

      • 但是在分布式系统中

        • 竞争的线程可能不在同一个节点上(不在同一个jvm中),所以需要一个让所有进程都能访问到的锁来实现(比如redis或者zookeeper来构建)

        • 不同进程jvm层面的锁就不管用了,可以利用第三方的一个组件,来获取锁,未获取到锁,则阻塞当前想要运行的线程

    • 解决方案

      • 使用redis分布式锁【跨进程+跨服务、解决超卖、防止缓存击穿】

3.0redis分布式锁
3.1递归重试
  • 缺点:容易导致StackOverflowError,高并发下不推荐

@Service
@Slf4j
public class InventoryService
{
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Value("${server.port}")
    private String port;
    private Lock lock = new ReentrantLock();
    public String sale()
    {
        String retMessage = "";
        String key = "zzyyRedisLock";
        String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue);//标识是否抢到锁
        if(!flag){
            //抢不到的,暂停20毫秒后递归调用,继续抢锁
            try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
            sale();
        }else{
            try{
                //1 查询库存信息
                String result = stringRedisTemplate.opsForValue().get("inventory001");
                //2 判断库存是否足够
                Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
                //3 扣减库存
                if(inventoryNumber > 0) {
                    stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
                    retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
                    System.out.println(retMessage);
                }else{
                    retMessage = "商品卖完了,o(╥﹏╥)o";
                }
            }finally {
                stringRedisTemplate.delete(key);//抢到锁且执行完业务要释放锁
            }
        }
        return retMessage+"\t"+"服务端口号:"+port;
    }
}
3.2自旋替代递归重试
  • 用while判断代替if判断

@Service
@Slf4j
public class InventoryService
{
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Value("${server.port}")
    private String port;
    private Lock lock = new ReentrantLock();
    public String sale()
    {
        String retMessage = "";
        String key = "zzyyRedisLock";
        String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
        while(!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue)){
            //枪锁失败会进入while循环,暂停20毫秒,类似CAS自旋
            try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
        }
        try
        {
            //1 查询库存信息
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2 判断库存是否足够
            Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
            //3 扣减库存
            if(inventoryNumber > 0) {
                stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
                retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
                System.out.println(retMessage);
            }else{
                retMessage = "商品卖完了,o(╥﹏╥)o";
            }
        }finally {
            stringRedisTemplate.delete(key);
        }
        return retMessage+"\t"+"服务端口号:"+port;
    }
}
4.0防止宕机导致的死锁
  • 3.2存在的问题:没有给分布式锁设置过期时间

    • 抢到分布式锁的机器挂了,代码层面根本没有走到finally这块,没办法保证解锁(无过期时间该key一直存在)

    • 这个key没有被删除,需要加入一个过期时间限定key

4.1设置过期时间
  • 存在的问题:抢锁和设置过期时间分开了,必须要合并成一行才能具备原子性

while(!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue))
{
    //暂停20毫秒,进行递归重试.....
    try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
}
stringRedisTemplate.expire(key,30L,TimeUnit.SECONDS);
4.2原子化抢锁和过期时间
  • setIfAbsent(key, uuidValue,30L【过期时间】,TimeUnit.SECONDS【单位】)可以兼顾抢锁和设置过期时间的操作

  • 结论:加锁和过期时时间设置必须同一行,保证原子性

while(!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue,30L,TimeUnit.SECONDS))
{
    //暂停毫秒
    try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
}
5.0防止误删他人锁
  • 4.2存在的问题

    image-20231010132019212

    .

    • 实际业务处理时间如果超过了默认设置key的过期时间,此时其他人抢锁进来时,原进程执行到finally操作,将别人的锁误删除

      • 张冠李戴,删除了别人的锁

  • 改进自己的锁自己删,获取锁中存储的值判断是否和自己的流水号相等

finally {
    // v5.0判断加锁与解锁是不是同一个客户端,同一个才行,自己只能删除自己的锁,不误删他人的
    if(stringRedisTemplate.opsForValue().get(key).equalsIgnoreCase(uuidValue)){
        stringRedisTemplate.delete(key);
    }
}
6.0lua保证原子性
lua脚本
  • 定义:Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放,其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能

  • 特性

    • 轻量级:它用标准C语言编写并以源代码形式开放,编译后仅仅一百余K,可以很方便的最入别的程序里

    • 可扩展:Lua提供了非常易于使用的扩展接口和机制,由宿主语言(通常是C或C++ )提供这些功能,Lua可以使用它们,就像是本来就内置的功能一样

  • 参考官网

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
  • 入门:eval luascript numkeys【键的个数】 [key [key ...]] [arg [arg ...]]

    • Redis调用Lua脚本通过eval命令保证代码执行的原子性,直接用return返回脚本执行后的结果值

    • 输出hello lua:eval "return 'hello lua' " 0

      image-20231010134631839

    • 调用redis的命令需要使用redis.call():set k1 v1、和设置过期时间还有get k1合并

      eval "redis.call('set','k1','v1') redis.call('expire','k1','30') return redis.call('get','k1')" 0

    • 使用动态参数【lua脚本的下标从1开始取】:mset传递多个参数

      EVAL "return redis.call(' mset' , KEYS[ 1],ARGV[ 1],KEYS[ 2],ARGV[ 2])" 2 k1 k2 11 12

  • 小进阶

    image-20231010140323447

    .

    • 删除和判断合并:eval "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 锁名 当前线程的流水号

    • 条件判断语法

      if(布尔条件) then
          业务代码
      elseif(布尔条件) then
      	业务代码
      elseif(布尔条件) then
          业务代码
      else
      	业务代码
      end

    image-20231010141219179

    .

6.0使用lua脚本
  • 5.0存在的问题:最后的判断和删除操作非原子性

  • 解决:使用lua脚本保证原子性

    • execute(new DefaultRedisScript<>(lua脚本字符串, 返回值类型), key列表【列表类型】, 参数【可变】)

    • 如果使用execute(new DefaultRedisScript<>(script), Arrays.asList(key),value)不指定返回值类型会报错【bug】

    image-20231010142352055

finally {
    //V6.0 将判断+删除自己的合并为lua脚本保证原子性
    String luaScript =
        "if (redis.call('get',KEYS[1]) == ARGV[1]) then " +
            "return redis.call('del',KEYS[1]) " +
        "else " +
            "return 0 " +
        "end";
    stringRedisTemplate.execute(new DefaultRedisScript<>(luaScript, Boolean.class), Arrays.asList(key), uuidValue);
}
!!!7.0可重入锁+工厂模式
  • !!!问题:如何兼顾锁的可重入性

可重入锁
  • 定义

    • 可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法自动获取锁(前提是锁对象得是同一个对象),不会因为之前已经获取过还没释放而阻塞

    • 如果一个用synchronized修饰的递归调用方法,程序第2次进入被自己阻塞了岂不是天大的笑话,出现了作茧自缚

      • 所以Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁

    • 总结:一个线程中的多个流程可以获取同一把锁,持有这把同步锁可以再次进入

  • 隐式锁 (即synchronized关键字使用的锁)默认是可重入锁

    • 指的是可重复可递归调用的锁在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁

    • 在一个synchronized修饰的方法或代码块的内部调用本类的其他synchronized修饰的方法或代码块时,是永远可以得到锁的

    • 与可重入锁相反,不可重入锁不可递归调用,递归调用就发生死锁

    • synchronized可重入的实现机理

      1. 每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针

      2. 当执行monitorenter时,如果目标锁对象的计数器为零,那么说明它没有被其他线程所持有,Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1

      3. 在目标锁对象的计数器不为零的情况下,如果

        • 锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器加1

        • 否则需要等待,直至持有线程释放该锁

      4. 当执行monitorexit时,Java虚拟机则需将锁对象的计数器减1。计数器为零代表锁已被释放

  • 显式锁 (即Lock) 也有ReentrantLock这样的可重入锁【加锁几次就要解锁几次

    static Lock lock = new ReentrantLock();
    public static void main(String[] args)
    {
        new Thread(() -> {
            lock.lock();
            try
            {
                System.out.println("----外层调用lock");
                lock.lock();
                try
                {
                    System.out.println("----内层调用lock");
                }finally {
                    lock.unlock(); // 正常情况,加锁几次就要解锁几次
                }
            }finally {
                lock.unlock();
            }
        },"a").start();
        new Thread(() -> {
            lock.lock();
            try
            {
                System.out.println("b thread----外层调用lock");
            }finally {
                lock.unlock();
            }
        },"b").start();
    }
7.1引入可重入锁
  • redis实现可重入采用的数据类型:map

    • hset key field value命令:hset redis锁名字 某个请求线程的流水号 加锁的次数

      • setnx:只能解决有无的问题,够用但是不完美

      • hset:不但解决有无,还解决可重入问题

加锁lua脚本lock
  • 先判断redis分布式锁这个key是否存在:EXISTS key

    • 返回0说明不存在,hset新建当前线程属于自己的锁hincrby key UUID:ThreadiD

      • hincrby命令就包含新建加自增的功能

    • 返回1说明已经有锁,需进一步判断是不是当前线程自己的HEXISTS key uuid:ThreadID

  • 设计成lua脚本

    • 参数说明:KEYS[1]表示锁名【key】,ARGV[1]表示流水号,ARGV[2]表示过期时间

    • 因为hincrby兼具新建和自增,所以

      • 当不存在的时候就新建

      • 当存在且为当前线程流水号时就自增

    if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then 
      	redis.call('hincrby',KEYS[1],ARGV[1],1) 
      	redis.call('expire',KEYS[1],ARGV[2]) 
      	return 1 
    else
      	return 0
    end
解锁lua脚本unlock
  • 有锁且还是自己的锁:HEXISTS key uuid:ThreadID

    • 返回零,说明根本没有锁,程序块返回nil

    • 不是零,说明有锁且是自己的锁,调用HINCRBY -1每次减1解锁一次,直到它变为零表示可以删除该锁del 锁key

  • 设计成Lua脚本

    • 参数说明:KEYS[1]表示锁名【key】,ARGV[1]表示流水号

    if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then
    	return nil
    elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then
      	return redis.call('del',KEYS[1])
    else
      	return 0
    end
嵌入微服务程序
  • 新建RedisDistributedLock类并实现JUC里面的Lock接口

    • 对外暴露的是lock方法,实际调用的是带时间的tryLock()

      image-20231010210908154

      .

    • 因为lua是C语言写的,所以nil == false、1 == true、0 == false,解锁不适合用布尔类型来接收返回值,改用Long

      • 即使返回结果是nil,Long没有对应的值,就会接收到null

public class RedisDistributedLock implements Lock
{
    private StringRedisTemplate stringRedisTemplate;
    private String lockName;//KEYS[1]
    private String uuidValue;//ARGV[1]
    private long   expireTime;//ARGV[2]
    public RedisDistributedLock(StringRedisTemplate stringRedisTemplate, String lockName)
    {
        this.stringRedisTemplate = stringRedisTemplate;
        this.lockName = lockName;
        this.uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();//UUID:ThreadID
        this.expireTime = 30L;
    }
    @Override
    public void lock()
    {
        tryLock();
    }
    @Override
    public boolean tryLock()
    {
        try {tryLock(-1L,TimeUnit.SECONDS);} catch (InterruptedException e) {e.printStackTrace();}
        return false;
    }
     //干活的,实现加锁功能,实现这一个干活的就OK,全盘通用
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException{
        if(time != -1L){
            this.expireTime = unit.toSeconds(time);
        }
        String script =
                "if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then " +
                        "redis.call('hincrby',KEYS[1],ARGV[1],1) " +
                        "redis.call('expire',KEYS[1],ARGV[2]) " +
                        "return 1 " +
                "else " +
                        "return 0 " +
                "end";
        System.out.println("script: "+script);
        System.out.println("lockName: "+lockName);
        System.out.println("uuidValue: "+uuidValue);
        System.out.println("expireTime: "+expireTime);
        while (!stringRedisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime))) {
            TimeUnit.MILLISECONDS.sleep(50);//抢锁失败,休息会再来重试
        }
        return true;
    }
	//干活的,实现解锁功能
    @Override
    public void unlock()
    {
        String script =
                "if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then " +
                "   return nil " +
                "elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then " +
                "   return redis.call('del',KEYS[1]) " +
                "else " +
                "   return 0 " +
                "end";
        System.out.println("lockName: "+lockName);
        System.out.println("uuidValue: "+uuidValue);
        System.out.println("expireTime: "+expireTime);
        Long flag = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime));
        if(flag == null)
        {
            throw new RuntimeException("This lock doesn't EXIST");
        }
    }
    //===下面的redis分布式锁暂时用不到=======================================
    @Override
    public void lockInterruptibly() throws InterruptedException
    {
    }
    @Override
    public Condition newCondition()
    {
        return null;
    }
}
7.2加入工厂模式
  • 可以根据参数来获取不同的分布式锁

  • 由于是用流水号来判断锁的持有对象,但是在7.1版本中,每次获取都会调用自定义锁的构造方法,其中会生成一个新的流水号,为了同一个线程要保证流水号是一样的,可以先将流水号作为工厂类的成员变量,然后作为参数传入锁的构造方法

    • 不同线程的工厂类对象是不一样的,所以不同线程uuid不同,而同一个线程始终获取到其工厂类中不变的uuid

@Component
public class DistributedLockFactory
{
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    private String lockName;
    private String uuidValue;
    public DistributedLockFactory()
    {
        this.uuidValue = IdUtil.simpleUUID();//UUID
    }
    public Lock getDistributedLock(String lockType)
    {
        if(lockType == null) return null;
        if(lockType.equalsIgnoreCase("REDIS")){
            lockName = "zzyyRedisLock";
            return new RedisDistributedLock(stringRedisTemplate,lockName,uuidValue);
        } else if(lockType.equalsIgnoreCase("ZOOKEEPER")){
            //TODO zookeeper版本的分布式锁实现
            return new ZookeeperDistributedLock();
        } else if(lockType.equalsIgnoreCase("MYSQL")){
            //TODO mysql版本的分布式锁实现
            return null;
        }
        return null;
    }
}
  • 自定义锁的构造方法

public RedisDistributedLock(StringRedisTemplate stringRedisTemplate, String lockName,String uuidValue)
{
    this.stringRedisTemplate = stringRedisTemplate;
    this.lockName = lockName;
    this.uuidValue = uuidValue+":"+Thread.currentThread().getId();
    this.expireTime = 30L;
}
  • 测试可重入锁

@Service
@Slf4j
public class InventoryService
{
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Value("${server.port}")
    private String port;
    @Autowired
    private DistributedLockFactory distributedLockFactory;
    public String sale()
    {
        String retMessage = "";
        Lock redisLock = distributedLockFactory.getDistributedLock("redis");
        redisLock.lock();
        try
        {
            //1 查询库存信息
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2 判断库存是否足够
            Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
            if(inventoryNumber > 0) {
				...减库存代码
                this.testReEnter();//锁内继续获取锁
            }else{
                retMessage = "商品卖完了,o(╥﹏╥)o";
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            redisLock.unlock();
        }
        return retMessage+"\t"+"服务端口号:"+port;
    }
    private void testReEnter()
    {
        Lock redisLock = distributedLockFactory.getDistributedLock("redis");
        redisLock.lock();
        try
        {
            System.out.println("################测试可重入锁####################################");
        }finally {
            redisLock.unlock();
        }
    }
}
!!!8.0自动续期
CAP

image-20231012204937584

.

  • Redis集群是AP

    • 缺点:redis异步复制造成的锁丢失

    • 主节点没来的及把刚刚的数据写进从节点,master就挂了,从机上位但从机上无该数据

  • Zookeeper集群是CP

    • CP图解

    image-20231012204539414

    .

    • 缺点

    image-20231012204820308

    .

  • Eureka集群是AP

    image-20231012204904635

    .

  • Nacos集群是AP

加入自动续期
  • 自动续期的lua脚本

if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 1 then
  return redis.call('expire',KEYS[1],ARGV[2])
else
  return 0
end
  • 自定义锁中添加定时续期方法

@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException
{
    if(time != -1L)
    {
        this.expireTime = unit.toSeconds(time);
    }
    String script = 可重入锁的lua脚本.....
    while (!stringRedisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime))) {
        TimeUnit.MILLISECONDS.sleep(50);
    }
    this.renewExpire();
    return true;
}
private void renewExpire()
{
    String script =
            "if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 1 then " +
                    "return redis.call('expire',KEYS[1],ARGV[2]) " +
                    "else " +
                    "return 0 " +
                    "end";
    new Timer().schedule(new TimerTask()
    {
        @Override
        public void run()
        {
            if (stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime))) {
                renewExpire();
            }
        }
    },(this.expireTime * 1000)/3);
}
总结
  • synchronized单机版OK,上分布式死翘翘

  • redis分布式锁得根据具体业务选用setnx还是hset

  • 只加了锁,没有释放锁出异常的话,可能无法释放锁,必须要在代码层面fnally释放锁

  • 岩机了,部署了微服务代码层面根本没有走到fnally这块,没办法保证解锁,这个key没有被删除需要有lockKey的过期时间设定

  • 为redis的分布式锁key增加过期时间此外,还必须要setnx+过期时间必须同一行【原子操作】

  • 必须规定只能自己删除自己的锁,不能把别人的锁删除了,防止张冠李戴

  • unlock变为Lua脚本保证

  • 锁重入,hset替代setnx+lock变为Lua脚本保证

  • 自动续期

RedLock算法+源码分析

上接手写分布式锁

  • 自研一把分布式锁面试中回答的主要考点

    • 按照JUC里面java.uti1.concurrent.locks.Lock接门规范编写

    • 加锁lock加锁关键逻辑

      • 加锁实际上就是在redis中,给Key键设置一个值,为避免死锁,并给定一个过期时间

      • 自旋

      • 续期

    • unlock解锁关键逻辑

      • 将Key键删除。但也不能乱删,只能自己删除自己的

  • 自研的分布式锁存在的缺点

    image-20231012213207192

    .

    • 线程1首先获取锁成功,将键值对写入redis的master节点,在redis将该键值对同步到slave节点之前,master 发生了故障,redis 触发故障转移,其中一个 slave 升级为新的 master,此时新上位的master并不包含线程1写入的键值对,因此线程2尝试获取锁也可以成功拿到锁,此时相当于有两个线程获取到了锁,可能会导致各种预期之外的情况发生,例如最常见的脏数据

    • 我们加的是排它独占锁,同一时间只能有一个建redis锁成功并持有锁,严禁出现2个以上的请求线程拿到锁

RedLock算法设计理念

  • 提出

    • Redis也提供了Redlock算法,用来实现基于多个实例的分布式锁

      • 锁变量由多个实例维护,即使有实例发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作

      • Redlock算法是实现高可靠分布式锁的一种有效解决方案,可以在实际开发中使用

    • 在算法的分布式版本中,我们假设我们有 N个 Redis 主节点。这些节点是完全独立的,所以不使用复制或任何其他隐式协调系统。在示例中,设置 N=5,这是一个合理的值,因此在不同的计算机或虚拟机上运行 5个 Redis master,以确保它们以几乎独立的方式发生故障

  • 设计理念

    • 该方案也是基于(set 加锁、Lua 脚本解锁)进行改良的,所以redis之父antirez只描述了差异的地方,大致方案:假设有N个Redis主节点,例如 N = 5这些节点是完全独立的,我们不使用复制或任何其他隐式协调系统,为了取到锁客户端执行以下操作

      顺序
      1获取当前时间,以毫秒为单位
      2依次尝试从5个实例,使用相同的 key 和随机值(例如 UUID)获取锁。当向Redis 请求获取锁时,客户端应该设置一个超时时间【即在该时间范围之内redis得返回锁】,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在 5-50 毫秒之间。这样可以防止客户端在试图与一个宕机的 Redis节点对话处于长时间阻塞状态如果一个实例不可用,客户端应该尽快尝试去另外一个 Redis 实例请求获取锁
      3客户端通过当前时间减去步骤 1 记录的时间来计算获取锁使用的时间。当且仅当从大多数(N/2+1,这里是 3 个节点)的 Redis 节点都取到锁,并且获取锁使用的时间小于锁失效时间时,锁才算获取成功
      4如果取到了锁,其真正有效时间等于初始有效时间减去获取锁所使用的时间(步骤 3 计算的结果)
      5如果由于某些原因未能获得锁(无法在至少 N/2 + 1 个 Redis 实例获取锁、或获取锁的时间超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)
    • 该方案为了解决数据不一致的问题,直接舍弃异步复制只使用 master 节点,同时由于舍弃了 slave,为了保证可用性,引入了 N 个节点,官方建议是 5。客户端只有在满足下面的这两个条件时,才能认为是加锁成功

      • 条件1:客户端从超过半数(大于等于N/2+1)的Redis实例上成功获取到了锁

      • 条件2:客户端获取锁的总耗时没有超过锁的有效时间

  • 容错公式: N = 2X + 1 (N是最终部署机器数,X是容错机器数)

    • 先知道什么是容错

      • 失败了多少个机器实例后还是可以容忍的,所谓的容忍就是数据一致性还是可以Ok的,CP数据一致性还是可以满足

        • 2X+1 = 2 * 1+1 =3,部署3台,死了1个剩下2个可以正常工作,就部署3台

        • 2X+1 = 2 * 2+1 =5,部署5台,死了2个剩下3个可以正常工作,就部署5台

      • 为什么是奇数

        • 最少的机器,最多的产出效果 ,加入在集群环境中

        • 而且客户端要从半数的redis实例中获取到锁

          • 以3台为例,半数是2台机器;如果是4台,半数也是2台机器

自研分布式锁改进9.0

  1. 引入依赖

    <!--redisson-->
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
        <version>3.13.4</version>
    </dependency>
  2. redis配置类中配置redLock锁连接信息

    //单Redis节点模式
    @Bean
    public Redisson redisson()
    {
        Config config = new Config();   
        config.useSingleServer().
            setAddress("redis://redis服务器的IP地址加端口号").
            setDatabase(0).setPassword("1212go12");
        return (Redisson) Redisson.create(config);
    }
  3. 修改业务类

    • 注意解锁之前要先判断是不是当前线程的锁redissonLock.isLocked() && redissonLock.isHeldByCurrentThread()

      • 自己的锁自己删,不判断可能会报错

      image-20231013175807932

    @Autowired
    private Redisson redisson;
    public String saleByRedisson()
    {
        String retMessage = "";
        String key = "zzyyRedisLock";
        RLock redissonLock = redisson.getLock(key);
        redissonLock.lock();
        try
        {
            //1 查询库存信息
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2 判断库存是否足够
            Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
            //3 扣减库存
            if(inventoryNumber > 0) {
                stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
                retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
                System.out.println(retMessage);
            }else{
                retMessage = "商品卖完了,o(╥﹏╥)o";
            }
        }finally {
            if(redissonLock.isLocked() && redissonLock.isHeldByCurrentThread())
            {
                redissonLock.unlock();
            }
        }
        return retMessage+"\t"+"服务端口号:"+port;
    }
  4. 添加控制器方法

    @ApiOperation("扣减库存saleByRedisson,一次卖一个")
    @GetMapping(value = "/inventory/saleByRedisson")
    public String saleByRedisson()
    {
        return inventoryService.saleByRedisson();
    }

底层源码解析

加锁
  • 加锁逻辑:lock()---》tryAcquire()抢锁---》tryAcquireAsync()异步获取锁

image-20231013180658340

.

  • 获取锁的代码

    image-20231013193106286

    .

tryLockInnerAsync代码

image-20231013193808190

自动续期代码
  • 自动续期会额外起一个线程,定期检查线程是否还持有锁,如果有则延长过期时间

  • redisson 里面就实现了这个方案,使用“看门狗”定期检查(每1/3的锁时间检查1次),如果线程还持有锁,则刷新过期时间

    • 这里面初始化了一个定时器,dely 的时间是internalLockLeaseTime/3

    • 在 Redisson 中,锁的过期时间internalLockLeaseTime默认是30s,也就是每隔 10s 续期一次

    • 客户端A加锁成功,就会启动一个watch dog看门狗【后台线程】,会每隔10秒检查一下,如果客户端A还持有锁key,那么就会不断的延长锁key的生存时间,默认每次续命又从30秒新开始

image-20231013200313252

image-20231013180818893

  • 自动续期的lua脚本

image-20231013201034096

.

解锁

image-20231013201153159

多机案例

理论支撑
  • redlock的算法实现了多redis实例的情况,相对于单redis节点来说,优点在于防止单节点故障造成整个服务停止运行的情况且在多节点中锁的设计,及多节点同时崩溃等各种意外情况有自己独特的设计方法

  • Redisson 分布式锁支持 MultiLock 机制可以将多个锁合并为一个大锁,对一个大锁进行统一的申请加锁以及释放锁

  • 最低保证分布式锁的有效性及安全性的要求如下

    1. 互斥;任何时刻只能有一个client获取锁

    2. 释放死锁;即使锁定资源的服务崩溃或者分区,仍然能释放锁

    3. 容错性;只要多数redis节点(一半以上)在使用,client就可以获取和释放锁

  • 网上讲的基于故障转移实现的redis主从无法真正实现Redlock

    • 因为redis在进行主从复制时是异步完成的,比如

      • 在clientA获取锁后,主redis复制数据到从redis过程中崩溃了,导致没有复制到从redis中

      • 然后从redis选举出一个升级为主redis,造成新的主redis没有clientA 设置的锁,这时clientB尝试获取锁,并且能够成功获取锁,导致互斥失效

代码支撑
  • 注意官网的redlock锁已经不推荐使用了,所以改用multilock

    • 基于Redis 的分布式 MultiLock对象允许将Lock对象分组并将它们作为单个锁处理,每个Lock对象可能属于不同的Redisson实例

    • Multilock如果获取崩溃的 Redisson实例,可能会永远挂在获取状态。为了避免这种情况,Redisson维护锁就使用看门狗,它会在锁持有者Redisson实例处于活动状态时延长锁到期时间

      • 默认情况下,锁定看门狗超时为 30 秒,可以通过Config.lockWatchdogTimeout没置进行更改

      • leaseTime 可以定义获取锁期间的参数。在指定的时间间隔后,锁定的锁将自动释放,

    • MultiLock 对象的行为符合 Java Lock 规范,这意味着只有锁的持有者线程才能解锁它【不然会抛出异常】

案例
  • docker

    • 运行三台redis的master机器

      • docker run -p 6381:6379 --name redis-master-1 -d redis

      • docker run -p 6382:6379 --name redis-master-2 -d redis

      • docker run -p 6383:6379 --name redis-master-3 -d redis

    • 进入启动的容器实例

      • docker exec -it redis-master-1 /bin/bash 或者 docker exec -it redis-master-1 redis-cli

      • docker exec -it redis-master-2 /bin/bash 或者 docker exec -it redis-master-2 redis-cli

      • docker exec -it redis-master-3 /bin/bash 或者 docker exec -it redis-master-3 redis-cli

  • 导入依赖

    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
        <version>3.19.1</version>
    </dependency>
  • 配置文件

    server.port=9090
    spring.application.name=redlock
    spring.swagger2.enabled=true
    
    spring.redis.database=0
    spring.redis.password=
    spring.redis.timeout=3000
    spring.redis.mode=single
    
    spring.redis.pool.conn-timeout=3000
    spring.redis.pool.so-timeout=3000
    spring.redis.pool.size=10
    
    spring.redis.single.address1=192.168.111.185:6381
    spring.redis.single.address2=192.168.111.185:6382
    spring.redis.single.address3=192.168.111.185:6383
  • 业务类

    • 池化技术

    @Data
    public class RedisPoolProperties {
        private int maxIdle;
        private int minIdle;
        private int maxActive;
        private int maxWait;
        private int connTimeout;
        private int soTimeout;
        /**
         * 池大小
         */
        private  int size;
    }
    • 缓存配置

    @Configuration
    @EnableConfigurationProperties(RedisProperties.class)
    public class CacheConfiguration {
        @Autowired
        RedisProperties redisProperties;
        @Bean
        RedissonClient redissonClient1() {
            Config config = new Config();
            String node = redisProperties.getSingle().getAddress1();
            node = node.startsWith("redis://") ? node : "redis://" + node;
            SingleServerConfig serverConfig = config.useSingleServer()
                    .setAddress(node)
                    .setTimeout(redisProperties.getPool().getConnTimeout())
                    .setConnectionPoolSize(redisProperties.getPool().getSize())
                    .setConnectionMinimumIdleSize(redisProperties.getPool().getMinIdle());
            if (StringUtils.isNotBlank(redisProperties.getPassword())) {
                serverConfig.setPassword(redisProperties.getPassword());
            }
            return Redisson.create(config);
        }
        @Bean
        RedissonClient redissonClient2() {
            Config config = new Config();
            String node = redisProperties.getSingle().getAddress2();
            node = node.startsWith("redis://") ? node : "redis://" + node;
            SingleServerConfig serverConfig = config.useSingleServer()
                    .setAddress(node)
                    .setTimeout(redisProperties.getPool().getConnTimeout())
                    .setConnectionPoolSize(redisProperties.getPool().getSize())
                    .setConnectionMinimumIdleSize(redisProperties.getPool().getMinIdle());
            if (StringUtils.isNotBlank(redisProperties.getPassword())) {
                serverConfig.setPassword(redisProperties.getPassword());
            }
            return Redisson.create(config);
        }
        @Bean
        RedissonClient redissonClient3() {
            Config config = new Config();
            String node = redisProperties.getSingle().getAddress3();
            node = node.startsWith("redis://") ? node : "redis://" + node;
            SingleServerConfig serverConfig = config.useSingleServer()
                    .setAddress(node)
                    .setTimeout(redisProperties.getPool().getConnTimeout())
                    .setConnectionPoolSize(redisProperties.getPool().getSize())
                    .setConnectionMinimumIdleSize(redisProperties.getPool().getMinIdle());
            if (StringUtils.isNotBlank(redisProperties.getPassword())) {
                serverConfig.setPassword(redisProperties.getPassword());
            }
            return Redisson.create(config);
        }
    }
    • redis配置

    @ConfigurationProperties(prefix = "spring.redis", ignoreUnknownFields = false)
    @Data
    public class RedisProperties {
        private int database;
        /**
         * 等待节点回复命令的时间。该时间从命令发送成功时开始计时
         */
        private int timeout;
        private String password;
        private String mode;
        /**
         * 池配置,之后下面配置的单机都会装到一个池子里面
         */
        private RedisPoolProperties pool;
    
        /**
         * 单机信息配置
         */
        private RedisSingleProperties single;
    }
  • 控制器方法【这里偷懒了,没有创建对应的业务类,直接把业务逻辑写在控制器方法中了】

    @RestController
    @Slf4j
    public class RedLockController {
        public static final String CACHE_KEY_REDLOCK = "ATGUIGU_REDLOCK";
        @Autowired
        RedissonClient redissonClient1;
        @Autowired
        RedissonClient redissonClient2;
        @Autowired
        RedissonClient redissonClient3;
        boolean isLockBoolean;
        @GetMapping(value = "/multiLock")
        public String getMultiLock() throws InterruptedException
        {
            String uuid =  IdUtil.simpleUUID();
            String uuidValue = uuid+":"+Thread.currentThread().getId();
            RLock lock1 = redissonClient1.getLock(CACHE_KEY_REDLOCK);
            RLock lock2 = redissonClient2.getLock(CACHE_KEY_REDLOCK);
            RLock lock3 = redissonClient3.getLock(CACHE_KEY_REDLOCK);
            RedissonMultiLock redLock = new RedissonMultiLock(lock1, lock2, lock3);
            redLock.lock();
            try
            {
                System.out.println(uuidValue+"\t"+"---come in biz multiLock");
                try { TimeUnit.SECONDS.sleep(30); } catch (InterruptedException e) { e.printStackTrace(); }
                System.out.println(uuidValue+"\t"+"---task is over multiLock");
            } catch (Exception e) {
                e.printStackTrace();
                log.error("multiLock exception ",e);
            } finally {
                redLock.unlock();
                log.info("释放分布式锁成功key:{}", CACHE_KEY_REDLOCK);
            }
            return "multiLock task is over  "+uuidValue;
        }
    }
  • 测试

    • 获取过期时间和锁对象

      • ttI ATGUIGU REDLOCK

      • HGETALL ATGUIGU REDLOCK

    • 模拟挂了一台机器

      • shutdown

      • docker start redis-master-1

      • docker exec -it redis-master-1 redis-cli

缓存过期淘汰策略

面试题
  • 生产上你们的redis内存设置多少

  • 如何配置、修改redis的内存大小

  • 如果内存满了你怎么办

  • redis清理内存的方式,定期删除和惰性删除了解过吗

  • redis缓存淘汰策略有哪些?分别是什么?你用那个?

  • redis的LRU了解过吗?请手写LRU

    • lru可以去看大厂面试题第三季

  • lru和lfu算法的区别是什么

redis内存相关

  • 查看redis内存大小

    • 打开redis配置文件,设置maxmemory参数,maxmemory是bytes字节类型,注意转换

      • 如果不设置,则默认内存大小为0,表示在64位操作系统下不限制内存大小,在32位操作系统下最多使用3GB

        • 注意,在64位系统下,maxmemory设置为0表示不限制 Redis内存使用

    image-20231013210607048

    .

  • 配置redis内存大小

    • 修改配置文件中的maxmemory,【单位是字节】一般推荐Redis设置内存为最大物理内存的四分之三

    • 在redis服务器中使用config set maxmemory 字节数

      image-20231013211255141

      .

  • 查看内存使用情况的命令

    • info memory

    • config get maxmemory

  • 如果redis内存打满会报oom错误

    image-20231013211514308

    .

  • 结论

    • redis没有加上过期时间,就会导致数据写满而报oom异常

    • 为了避免类似情况,引出内存淘汰策略

内存删除策略

  • 立即删除

    • Redis不可能时时刻刻遍历所有被设置了生存时间的key,来检测数据是否已经到达过期时间,然后对它进行删除

    • 优点:立即删除能保证内存中数据的最大新鲜度,因为它保证过期键值会在过期后马上被删除,其所占用的内存也会随之释放

    • 缺点:立即删除对cpu是最不友好的。因为删除操作会占用cpu的时间,如果刚好碰上了cpu很忙的时候,比如正在做交集或排序等计算的时候,就会给cpu造成额外的压力,让CPU心累,时时需要删除,忙死

      • 会产生大量的性能消耗,同时也会影响数据的读取操作

    • 总结:对cpu不友好,用处理器性能换取存储空间【时间换空间】

  • 惰性删除

    • 数据到达过期时间,不做处理,等下次访问该数据时

      • 如果未过期,返回数据

      • 发现已过期,删除,返回不存在

    • 开启惰性淘汰【默认为null】lazyfree-lazy-eviction=yes

      image-20231013212610925

      .

    • 缺点:对内存不友好

      • 如果一个键已经过期,而这个键又仍然保留在redis中,那么只要这个过期键不被删除,它所占用的内存就不会释放

      • 在使用惰性删除策略时,如果数据库中有非常多的过期键,而这些过期键又恰好没有被访问到的话,那么它们也许永远也不会被删除(除非用户手动执行FLUSHDB)

      • 甚至可以将这种情况看作是一种内存泄漏–无用的垃圾数据占用了大量的内存,而服务器却不会自己去释放它们,这对于运行状态非常依赖于内存的Redis服务器来说,肯定不是一个好消息

    • 总结:对memory不友好,用存储空间换取处理器性能(拿空间换时间)

  • 定期删除【redis采用,定期删除策略是前两种策略的折中】

    • 定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行时长和频率来减少删除操作对CPU时间的影响

    • 周期性轮询redis库中的时效性数据,采用随机抽取的策略,利用过期数据占比的方式控制删除频度

      • 特点1:CPU性能占用设置有峰值,检测频度可自定义设置

      • 特点2:内存压力不是很大,长期占用内存的冷数据会被持续清理

    • 举例

      • redis默认每隔100ms检查是否有过期的key,有过期key则删除

      • 注意:redis不是每隔100ms将所有的key检查一次而是随机抽取进行检查(如果每隔100ms,全部key进行检查,redis直接进去ICU)。因此,如果只采用定期删除策略,会导致很多key到时间没有删除

    • 定期删除策略的难点是确定删除操作执行的时长和频率

      • 如果删除操作执行得太频繁或者执行的时间太长,就会退化成立即删除策略,以至于将CPU时间过多地消耗在删除过期键上

      • 如果删除操作执行得太少,或者执行的时间太短,会退化成惰性删除策略,出现浪费内存的情况

      • 因此,如果采用定期删除策略的话,服务器必须根据情况,合理地设置删除操作的执行时长和执行频率

    • 总结:周期性抽查存储空间 (随机抽查,重点抽查,可能有漏网之鱼)

redis缓存淘汰策略

  • 策略来源【配置文件中的MEMORY MANAGEMENT配置项】

image-20231014211856001

.

lru和lfu的区别
  • LRU:最近最长时间未被使用,淘汰最长时间未被使用的页面,看页面最后一次被使用到发生调度的时间长短,首先淘汰最长时间未被使用的页面【看时间】

  • LFU:最近最少使用,淘汰一定时期内被访问次数最少的页,看一定时间段内页面被使用的频率,淘汰一定时期内被访问次数最少的页【看使用频率】

  • 举个栗子:某次时期Time为10分钟,如果每分钟进行一次调页,主存块为3,若所需页面走向为2 1 2 1 2 3 4

    • 假设到页面4时会发生缺页中断

      • 若按LRU算法,应换页面1(1页面最久未被使用)

      • 但按LFU算法应换页面3(十分钟内,页面3只使用了一次)

    • 可见LRU关键是看页面最后一次被使用到发生调度的时间长短,而LFU关键是看一定时间段内页面被使用的频率

八个redis缓存淘汰策略
  • 策略

    • noeviction【默认】:不会驱逐任何key,表示即使内存达到上限也不进行置换,所有能引起内存增加的命令都会返回error

    • alkeys-lru:对所有key使用LRU算法进行删除,优先删除掉最近最不经常使用的key,用以保存新数据

    • volatile-lru:对所有设置了过期时间的key使用LRU算法进行删除

    • allkeys-random:对所有key随机删除

    • volatile-random:对所有设置了过期时间的key随机删除

    • volatile-ttl:删除马上要过期的key

    • allkeys-lfu:对所有key使用LFU算法进行删除

    • volatile-lfu:对所有设置了过期时间的key使用LFU算法进行删除

  • 总结

    • 两个维度

      • alkeys针对所有key

      • volatile针对所有设置了过期时间的key

    • 四个方面

      • LRU

      • LFU

      • random

      • ttl

  • 使用建议

    • 在所有的 key 都是最近最经常使用,那么就需要选择allkeys-lru进行置换最近最不经常使用的key,如果你确定使用哪种策略,那么推荐使用 allkeys-lru

    • 如果所有的key的访问概率都是差不多的,那么可以选用allkeys-random策略去置换数据

    • 如果对数据有足够的了解,能够为key指定hint (过expire/ttl指定) ,那么可以选择volatie-ttl 进行置换

  • 缓存淘汰策略配置的性能建议

    • 避免存储bigkey

    • 开启惰性淘汰lazyfree-lazy-eviction=yes【额,这建议我不太想采纳,因为对内存不利】

五大类型的源码和底层实现

面试题
问题
  • redis的跳跃列表了解吗? 这个数据结构有什么缺点

  • redis项目里面怎么用? redis的数据结构都了解那些? 布隆过滤器怎么用?

  • redis 的多路io复用如何理解,为什么单线程还可以抗那么高的qps

  • 说出来压缩列表和跳表问这样设计的优缺点

  • redis的跳表说一下,解决了哪些问题, 时间复杂度和空间复杂度如何

  • redis的zset用的什么数据结构

常考的底层数据结构
  • SDS动态字符串

  • 双向链表

  • 压缩列表ziplist

  • 哈希表hashtable

  • 跳表skiplist

  • 整数集合intset

  • 快速列表quicklist

  • 紧凑列表listpack

源码位置和相应资料

  • 源码在redis安装目录的src下

    image-20231014215203967

    .

  • 源码分析参考书

    • 《Redis设计与实现》

    • 《Redis5设计与源码分析》

redis源码核心部分

基本数据结构
  • github官网提供的常用的数据结构

    image-20231014215936815

    .

    • Redis对象object.c

    • 字符串t_string.c

    • 列表t_ list.c

    • 字典t_hash.c

    • 集合及有序集合t set.c和t zset.c

    • 数据流t stream.c【了解,Streams的底层实现结构listpack.c和rax.c整数集合】

  • 简单动态字符串sds.c

  • 整数列表intset.c

  • 压缩列表ziplist.c

  • 快速列表quicklist.c

  • 紧凑列表listpack

  • 字典dict.c

redis数据库的实现
  • 数据库的底层实现db.c

  • 持久化rdb.c和aof.c

Redis服务端和客户端实现
  • 事件驱动ae.c和ae_epoll.c

  • 网络连接anet.c和networking.c

  • 服务端程序server.c

  • 客户端程序redis-cli.c

其它
  • 主从复制replication.c

  • 哨兵sentinel.c

  • 集群cluster.c

  • 其他数据结构,如hyperloglog.c、geo.c等

  • 其他功能,如pub/sub、Lua脚本

redis的kv键值对

kv键值对的实现
  • key一般都是string类型的字符串对象

  • value类型则为redis对象(redisObject)

    • 可以是字符串对象

    • 也可以是集合数据类型的对象,比如 List 对象、Hash 对象、Set 对象和 Zset 对象

image-20231014221529268

.

十大数据类型【粗分】
  • 传统的5大类型

    • String

    • List

    • Hash

    • Set

    • ZSet

  • 新介绍的5大类型

    • bitmap【实质string】

    • hyperLogLog【实质string】

    • GEO【实质Zset】

    • BITFIELD【看具体key】

    • stream

精分

image-20231014221410042

redisObject结构体

  • Redis定义了redisObjec结构体来表示string、hash、list、 set、 zset等数据类型

    • Redis中每个对象都是一个 redisObject 结构

结构体语法简介
  • 定义结构:为了定义结构,必须使用 struct 语句。struct 语句定义了一个包含多个成员的新的数据类型struct 语句的格式如下

    • type name 是结构体类型的名称,member_type1 member name1 是标准的变量定义

      image-20231015133205222

      .

    • 在构定义的末尾,最后一个分号之前,可以指定一个或多个结构变量,下面是声明一个结构体类型Books,变量为 book

      image-20231015133401148

      .

  • typedef 关键字:下面是一种更简单的定义结构的方式,可以为创建的类型取一个”别名”

    • 可以直接使用 Books 来定义Books 类型的变量,而不需要使用struct关键字

    image-20231015133621964

    image-20231015133645993

!!!字典、kv是什么
  • 每个键值对都会有一个dictEntry

  • 源码位置

    • dicEntry的结构体源码dict.h

    image-20231015134123150

    .

    • redisObject结构体源码server.hredis

      • db启动之后,会启动server服务,最终暴露给用户的是redisObject

    image-20231015134449247

    .

  • 重点:从dictEntry到RedisObject

    image-20231015134816326

    .

  • redisObiect +Redis数据类型+Redis所有编码方式(底层实现)三者之间的关系

    image-20231015135809380

    .

五大底层结构前言

总体大纲
  • 源码分析的数据结构大纲

    • SDS动态字符串

    • 双向链表

    • 压缩列表ziplist

    • 哈希表hashtable

    • 跳表skiplist

    • 整数集合intset

    • 快速列表quicklist

    • 紧凑列表listpack

不同版本数据结构的区别
  • redis6之前的数据结构

image-20231015140542315

.

  • redis7之后的数据结构

    • 去掉了ziplist,ziplist改成了listpack

    • list结构改为quicklist

image-20231015140855808

.

案例引入
  • 以set hello word为例,因为Redis是KV键值对的数据库,每个键值对都会有一个dictEntry(源码位置:dict.h)

    image-20231015142041012

    image-20231015142100230

    • 里面指向了key和value的指针,next 指向下一个dictEntry

    • key是字符串,但是Redis没有直接使用C的字符数组,而是存储在redis自定义SDS

    • value既不是直接作为字符串存储也不是直接存储在SDS中,而是存储在redisObject中

    • 实际上五种常用的数据类型的任何一种,都是通过 redisObject 来存储的

  • 查看编码的命令object encoding key

    image-20231015142402045

!!!redisObject结构的作用
  • 为了便于操作,Redis采用redisObjec结构来统一五种不同的数据类型,这样所有的数据类型就都可以以相同的形式在函数间传递而不用使用特定的类型结构。为了识别不同的数据类型,redisObject中定义了type和encoding字段对不同的数据类型加以区别

    • 简单地说,redisObject就是string、hash、list、set、zset的父类,可以在函数间传递时隐藏具体的类型信息,作者抽象了redisObject结构来到达同样的目的

image-20231015142706895

.

  • redisObject中变量解释

    image-20231015142828871

    .

    • 4位的type表示具体的数据类型

    • 4位的encoding表示该类型的物理编码方式见下表,同一种数据类型可能有不同的编码方式(比如String就提供:int,embstr,raw)

      image-20231015143139458

      image-20231015143531125

      以set age 17为例变量赋值
      type类型,String
      encoding编码,此处是数字类型
      lru最近被访问的时间
      refcount等于1,表示当前对象被引用的次数
      ptrvalue值是多少,当前就是17
    • lru字段表示当内存超限时采用LRU算法清除内存中的对象

    • refcount表示对象的引用计数

    • ptr指针指向真正的底层数据结构的指针

  • 各个类型的数据结构的编码映射和定义

image-20231015144042353

.

Debug Object key命令
  • Redis Debug Object 命令是一个调试命令,它不应被客户端所使用【所以默认是关闭的】

    image-20231015144548111

  • 开启debug命令

    image-20231015144642451

    .

  • 返回值:当 key 存在时,返回有关信息。 当 key 不存在时,返回一个错误

    image-20231015144905720

    • Value at:内存地址

    • refcount:引用次数

    • encoding:物理编码类型

    • serializedlength:序列化后的长度

      • 这里的长度是序列化后的长度,保存为rdb文件时使用了该算法,不是真正存贮在内存的大小

      • 会对字串做一些可能的压缩以便底层优化

    • lru:记录最近使用时间戳

    • lru_seconds_idle:空闲时间

!!!String

三大物理编码方式
  • int

    • 保存long 型(长整型)的64位(8个字节)有符号整数

    • long 数据类型是64 位、有符号的以二进制补码表示的整数

      • 最小值是-2^63、最大值是2^63 -1【-9,223,372,036,854, 775 ,808~9,223,372,036,854, 775 ,807】

      • 这种类型主要使用在需要比较大整数的系统上

      • 默认值是0L

    • 只有整数才会使用int,如果是浮点数,Redis内部其实先将浮点数转化为字符串值,然后再保存

  • embstr

    • 代表embstr 格式的SDS(Simple Dynamic String简单动态字符串),保存长度小于44字节的字符串

    • EMBSTR(embedded string),表示嵌入式的string

  • raw

    • 保存长度大于44字节的字符串

三大物理编码案例
  • 范围测试

    • 整数范围溢出

      image-20231015152158672

      image-20231015152329699

    • 字符串长度测试

      image-20231015152421423

      .

!!!简单动态字符串
  • Redis没有直接复用C语言的字符串,而是新建了属于自己的结构--SDS

    • 在Redis数据库里,包含字符串值的键值对都是由SDS实现的

      • 所有的键都是由字符串对象实现的,即底层是由SDS实现

      • 所有的值对象中包含的字符串对象,底层也是由SDS实现

image-20231015152944548

.

sds源码结构【sds.h】
  • SDS有多种结构(sds.h)

    • sdshdr5(2^5=32byte)、sdshdr8(2 ^ 8=256byte)、sdshdr16(2 ^ 16=65536byte=64KB)

    • sdshdr32 (2 ^ 32byte=4GB)、sdshdr64,2的64次方byte=17179869184G用于存储不同的长度的字符串

    • sdshdr5一般不用,只是内部测试用于存放标识位

  • len 表示 SDS 的长度,使我们在获取字符串长度的时候可以在 O(1)情况下拿到,而不是像 C 那样需要遍历一遍字符串

  • alloc可以用来计算 free【字符串已经分配但未使用的空间】,就可以引入预分配空间的算法,而不用去考虑内存分配的问题

  • buf 表示字符串数组,真存数据的

image-20231015153145195

.

为什么要设计sds
  • C语言没有Java里面的String类型,只能是靠自己的char[]来实现,字符串在C语言中的存储方式,想要获取 「Redis」的长度,需要从头开始遍历,直到遇到 '\0' 为止。所以,Redis 没有直接使用C语言传统的字符串标识,而是自己构建了一种名为简单动态字符串 SDS(simple dynamic string)的抽象类型,并将 SDS 作为 Redis 的默认字符串

C语言SDS
字符串长度处理需要从头开始遍历,直到遇到 '\0' 为止,时间复杂度O(N)记录当前字符串的长度,直接读取即可,时间复杂度 O(1)
内存重新分配分配内存空间超过后,会导致数组下标越级或者内存分配溢出空间预分配 SDS 修改后,len长度小于1M,那么将会额外分配与len相同长度的未使用空间。如果修改后长度大于1M,那么将分配1M的使用空间惰性空间 释放有空间分配对应的就有空间释放。SDS 缩短时并不会回收多余的内存空间,而是使用 free 字段将多出来的空间记录下来。如果后续有变更操作,直接使用 free 中记录的空间,减少了内存的分配。
二进制安全二进制数据并不是规则的字符串格式,可能会包含一些特殊的字符,比如 '\0' 等。 前面提到过,C中字符串遇到 '\0' 会结束,那 '\0' 之后的数据就读取不上了根据 len长度来判断字符串结束,二进制安全的问题就解决了
sds源码分析

image-20231015155927630

.

int编码方式
  • 当字符串键值的内容可以用一个64位有符号整型来表示时,Redis会将键值转化为long型来进行存储,此时即对应 OBJ_ENCODING_INT编码类型。内部的内存结构表示如下

    image-20231015160038322

    .

  • Redis启动时会预先建立 10000 个分别存储 0~9999的redisObject变量作为共享对象,这就意味着如果 set字符串的键值在 0~10000之间的话,则可以直接指向共享对象 而不需要再建立新对象,此时键值不占空间!

    image-20231015160432627

    .

    • 例如set k1 100、set k2 100,此时k1、k2指向了同一个对象

    image-20231015160323953

    .

  • redis6源码

    image-20231015160557430

    .

  • redis7源码【和6没啥大的区别】

    image-20231015160641010

    image-20231015160657242

embstr编码格式
  • 对于长度小于44的字符串,Redis对键值采用OBJ_ENCODING_EMBSTR方式,从内存结构上来讲,字符串sds结构体与其对应的 redisObject对象分配在同一块连续的内存空间【字符串紧接在object后面】,字符串sds嵌入在redisObject对象之中一样

image-20231015161600827

image-20231015161636010

image-20231015211005640

raw编码格式
  • 当字符串的键值为长度大于44的超长字符串时,Redis 则会将键值的内部编码方式改为OBJ_ENCODING_RAW格式,这与OBJ_ENCODING_EMBSTR编码方式的不同之处在于,此时动态字符串sds的内存与其依赖的redisObject的内存不再连续

image-20231015204939106

image-20231015205131137

image-20231015204950783

  • 字符串没有超过阈值但也是raw的原因:对于 embstr,由于其实现是只读的,因此在对embstr对象进行修改时,都会先转化为raw再进行修改,因此,只要是修改embstr 对象,修改后的对象一定是 raw 的,无论是否达到了 44 个字节

    image-20231015205548245

    .

总结
流程图

image-20231015205807902

.

结论
  • 只有整数才会使用int,如果是浮点数, Redis内部其实先将浮点数转化为字符串值,然后再保存

  • embstr与raw类型底层的数据结构其实都是SDS (简单动态字符串,Redis 内部定义 sdshdr 一种结构),这三者的区别见下图表

类型
intLong类型整数时,RedisObject中的ptr指针直接赋值为整数数据不再额外的指针再指向整数,节省了指针的空间开销。
embstr当保存的是字符串数据且字符串小于等于44字节时,embstr类型将会调用内存分配函数,只分配一块连续的内存空间,空间中依次包含redisObject 与sdshdr两个数据结构,让元数据、指针和SDS是一块连续的内存区域,这样就可以避免内存碎片
raw当字符串大于44字节时,SDS的数据量变多变大了,SDS和RedisObject布局分家各自过,会给SDS分配多的空间并用指针指向SDS结构,raw 类型将会调用两次内存分配函数,分配两块内存空间,一块用于包含 redisObject结构,而另一块用于包含 sdshdr 结构

image-20231015211221910

!!!Hash

redis6
结构
  • hash-max-ziplist-entries:使用压缩列表保存时哈希集合中的最大元素个数

  • hash-max-ziplist-value:使用压缩列表保存时哈希集合中单个元素的最大长度

  • Hash类型键的字段个数小于hash-max-ziplist-entries并且每个字段名和字段值的长度小于hash-max-ziplist-value时,Redis才会使用OBJ_ENCODING_ZIPLIST来存储该键,前述条件任意一个不满足则会转换为OBJ_ENCODING_HT的编码方式

  • 一旦从压缩列表转为哈希表,Hash类型就会一直用哈希表进行保存而不会再转回压缩列表,在节省内存空间方面哈希表就没有压缩列表高效

image-20231015212559969

.

  • 流程图【7的流程图和6差不多,只是改了些名字】

image-20231015213139562

.

hashtable[t_hash.c]
架构理解
  • 在Redis 中,hashtable 被称为字典 (dictionary) ,它是一个数组+链表的结构

  • OBJ_ENCODING_HT这种编码方式内部才是真正的哈希表结构,或称为字典结构,其可以实现O(1)复杂度的读写操作,因此效率很高。在Redis内部,从OBJ_ENCODING_HT类型到底层真正的散列表数据结构是一层层嵌套下去的,组织关系见面图

image-20231015213539270

image-20231015213708103

.

hset命令底层源码

image-20231015214531475

!!!ziplist[ziplist.c]
ziplist定义
  • 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组

  • ziplist是一个经过特殊编码的双向链表

    • 不存储指向前一个链表节点prev和指向下一个链表节点的指针next

    • 而是存储上一个节点长度和当前节点长度,通过牺牲部分读写性能,来换取高效的内存空间利用率

    • 节约内存,是一种时间换空间的思想。只用在字段个数少,字段值小的场景里面

image-20231015215902153

.

image-20231015220000270

.

源码结构
  • 类比java的hash,ziplist内部由zlentry组成【压缩节点】

image-20231015221031658

image-20231015221207651

  • ziplist存取情况

    image-20231015221312559

    .

    压缩节点变量解释
    prevlen记录了前一个节点的长度,有两种取值情况:1字节或5字节 取值1字节时,表示上一个entry的长度小于254字节。虽然1字节的值能表示的数值范围是0到255,但是压缩列表中zlend的取值默认是255,因此,就默认用255表示整个压缩列表的结束,其他表示长度的地方就不能再用255这个值了。所以,当上一个entry长度小于254字节时,prevlen取值为1字节,否则,就取值为5字节。记录长度的好处:占用内存小,1或者5个字节
    encoding记录了当前节点实际数据的类型以及长度
    data/content记录了当前节点的实际数据
设计zlentry的原因
  • prevlen,encoding长度都可以根据编码方式推算,真正变化的是content/data,而content/data长度记录在encoding里 ,因此entry的长度就知道了。entry总长度=prevlen字节数+encoding字节数+content/data字节数

  • 链表在内存中,一般是不连续的,遍历相对比较慢,而ziplist可以很好的解决这个问题。如果知道了当前的起始地址,因为entry是连续的,entry之后一定是另一个entry,只要将当前的起始地址加上当前entry总长度就知道下一个entry的地址。如果还想遍历下一个entry,只要继续同样的操作

image-20231016214940371

.

面试题:为什么要有压缩链表
  • 普通的双向链表会有两个指针,在存储数据很小的情况下,存储的实际数据的大小可能还没有指针占用的内存大,得不偿失。ziplist 是一个特殊的双向链表没有维护双向指针,而是存储上一个entry的长度和当前entry的长度,通过长度推算下一个元素在什么地方,牺牲读取的性能,获得高效的存储空间,因为(简短字符串的情况)存储指针比存储entry长度更费内存,这是典型的“时间换空间”

  • 链表在内存中一般是不连续的遍历相对比较慢,而ziplist可以很好的解决这个问题,普通数组的遍历是根据数组里存储的数据类型找到下一个元素的(例如int类型的数组访问下一个元素时每次只需要移动一个sizeof(int)就行),但是ziplist的每个节点的长度是可以不一样的,而面对不同长度的节点又不可能直接sizeof(entry),所以ziplist只好将一些必要的偏移量信息记录在了每一个节点里,使之能跳到上一个节点或下一个节点

    • 备注:sizeof实际上是获取了数据在内存中所占用的存储空间,以字节为单位来计数

  • 头节点里有头节点里同时还有一个参数len,和string类型提到的 SDS 类似,这里是用来记录链表长度的。因此获取链表长度时不用再遍历整个链表,直接拿到len值就可以了,这个时间复杂度是 O(1)

总结
  • ziplist为了节省内存,采用了紧凑的连续存储

  • ziplist是一个双向链表,可以在时间复杂度为 O(1) 下从头部、尾部进行 pop 或 push

  • 新增或更新元素可能会出现连锁更新现象(致命缺点导致被listpack替换)

  • 不能保存过多的元素,否则查询效率就会降低,数量小和内容小的情况下可以使用

redis7
结构
  • hash-max-listpack-entries:使用紧凑列表保存时哈希集合中的最大元素个数

  • hash-max-listpack-value:使用紧凑列表保存时哈希集合中单个元素的最大长度

  • Hash类型键的字段个数 小于 hash-max-listpack-entries且每个字段名和字段值的长度 小于 hash-max-listpack-value 时,Redis才会使用OBJ_ENCODING_LISTPACK来存储该键,前述条件任意一个不满足则会转换为 OBJ_ENCODING_HT的编码方式【同redis6】

  • redis为了兼容新旧版本,依旧可以查到ziplist信息【实质上还是listpack,相当于指向listpack的指针】

    image-20231016220217452

    .

  • 关于listpack和hashtable的转换同redis6

listpack.c源码分析

image-20231016220913136

.

  • lpNew 函数创建了一个空的 listpack,一开始分配的大小是 LP_HDR_SIZE 再加 1 个字节

  • LP_HDR_SIZE宏定义是在 listpack.c中,它默认是6个字节,其中4个字节是记录 listpack 的总字节数,2 个字节是记录listpack的元素数量。此外,listpack 的最后一个字节是用来标识 listpack 的结束,其默认值是宏定义 LP_EOF。和 ziplist 列表项的结束标记一样,LP_EOF 的值也是 255

为什么要listpack
ziplist连锁更新问题
  • 压缩列表新增或修改某个元素时,如果空间不够,压缩列表占用的内存空间就需要重新分配

  • 当新插入的元素较大时,可能会导致后续元素的prevlen占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降

案例说明
  • 压缩列表每个节点因为需要保存前一个节点的长度字段,就会有连锁更新的隐患

    1. 现在假设一个压缩列表中有多个连续的、长度在 250~253之间的节点

      • 因为这些节点长度值小于 254 字节,所以prevlen属性只需要用1字节的空间来保存这个长度值

      image-20231017101719576

      .

    2. 如果将一个长度大于等于254字节的新节点加入到压缩列表的表头节点,即新节点将成为entry1的前置节点

      • 因为entry1节点的prevlen属性只有1个字节大小,无法保存新节点的长度

      • 此时就需要对压缩列表的空间重分配操作,并将entry1节点的prevlen属性从原来的1字节大小扩展为5 字节大小

      image-20231017101836625

      .

    3. 连续更新问题出现:entry1节点原本的长度在250~253之间,因为刚才的扩展空间,此时entry1节点的长度就大于等于254,因此原本entry2节点保存entry1节点的 prevlen属性也必须从1字节扩展至5字节大小。entry1节点影响entry2节点,entry2节点影响entry3节点......一直持续到结尾。这种在特殊情况下产生的连续多次空间扩展操作就叫做「连锁更新」

      image-20231017101958739

      .

  • listpack的引入:listpack是 Redis设计用来取代掉ziplist 的数据结构,它通过每个节点记录自己的长度且放在节点的尾部,来彻底解决掉了 ziplist 存在的连锁更新的问题

listpack的结构

image-20231017102902308

.

  • 结构图

变量解释
Total Bytes为整个listpack的空间大小,占用4个字节,每个listpack最多占用4294967295Bytes
num-elements为listpack中的元素个数,即Entry的个数,占用2个字节
element-1~element-N为每个具体的元素
listpack-end-byte为listpack结束标志,占用1个字节,内容为0xFF

image-20231017102813120

.

lpentry
  • 结构:和ziplist 列表项类似,listpack 列表项也包含了元数据信息和数据本身。不过,为了避免ziplist引起的连锁更新问题,listpack 中的每个列表项,再像ziplist列表项那样保存其前一个列表项的长度

    • 当前元素的编码类型(entry-encoding)

    • 元素数据(entry-data)

    • 以及编码类型和元素数据这两部分的长度(entry-len)

image-20231017103333454

.

!!!list

类比java
  • java List

    • ArrayList ===> Object[]

    • LinkedList ===> 放入node 若点的一个双端然表

  • redis list都是双端链表结构,借鉴java的思想,redis 也给用户新建了一个全新的数据结构quicklist

    • redis6:quicklist+ziplist

    • redis7:quicklist

redis6
相关配置
  • ziplist压缩配置:list-compress-depth

    • 表示一个quicklist两端不被压缩的节点个数。这里的节点是指quicklist双向链表的节点,而不是指ziplist里面的数据项个数

    • 参数list-compress-depth的取值含义如下

      • 0: 是个特殊值,表示都不压缩【Redis的默认值】

      • 1: 表示quicklist两端各有1个节点不压缩,中间的节点压缩

      • 2: 表示quicklist两端各有2个节点不压缩,中间的节点压缩

      • 3: 表示quicklist两端各有3个节点不压缩,中间的节点压缩

      • 依此类推

  • ziplist中entry配置:list-max-ziplist-size

    • 当取正值的时候,表示按照数据项个数来限定每个quicklist节点上的ziplist长度

      • 比如,当这个参数配置成5的时候,表示每个quicklist节点的ziplist最多包含5个数据项

    • 当取负值的时候,表示按照占用字节数来限定每个quicklist节点上的ziplist长度。它只能取-1到-5这五个值,每个值含义如下

      • -5: 每个quicklist节点上的ziplist大小不能超过64 Kb(注:一般kb指kbytes,1kb => 1024 bytes)

      • -4: 每个quicklist节点上的ziplist大小不能超过32 Kb

      • -3: 每个quicklist节点上的ziplist大小不能超过16 Kb

      • -2: 每个quicklist节点上的ziplist大小不能超过8 Kb(-2是Redis给出的默认值)

      • -1: 每个quicklist节点上的ziplist大小不能超过4 Kb

quicklist的结构
  • quicklist实际上是zipList和linkedList的混合体,它将linkedList按段切分每一段使用zipList 来紧凑存储,多个zipList之间使用双向指针串接起来

  • Redis3.0之前,list采用的底层数据结构是ziplist压缩列表+linkedList双向链表

    • 在较早版本的redis中,list 有两种底层实现

      • 当列表对象中元素的长度比较小或者数量比较少的时候,采用压缩列表ziplist来存储

      • 当列表对象中元素的长度比较大或者数量比较多的时候,则会转而使用双向列表linkedlist来存储

    • 两者各有优缺点

      • ziplist

        • 优点是内存紧凑,访问效率高

        • 缺点是更新效率低,并且数据量较大时,可能导致大量的内存复制

      • linkedlist

        • 优点是节点修改的效率高

        • 缺点是需要额外的内存开销,并且节点较多时,会产生大量的内存碎片

    • 为了结合两者的优点,在redis 3.2 之后,list的底层实现变为快速列表quicklist,而quicklist也用到了ziplist

  • 结论:quicklist就是「双向链表 + 压缩列表」组合,因为一个quicklist就是一个链表,而链表中的每个元素又是一个压缩列表

image-20231017105522056

.

源码分析
结构相关源码[quicklist.h]
  • quicklist

image-20231017105331931

.

  • quicklistnode

image-20231017112618290

.

  • 结论:quicklistNode中的*zl指向一个ziplist,一个ziplist可以存放多个元素

image-20231017112842866

.

添加/修改对应的源码[t_list.c]

image-20231017113428238

.

redis7
  • redis7只是将quicklist中的各节点存放的ziplist改成了listpack

  • 添加命令对应源码,t_list.c

image-20231017113152299

.

  • Redis7的List的一种编码格式,list用quicklist来存储,quicklist存储了一个双向链表,每个节点都是一个listpack

set

  • set的编码方式

    • intset

    • hashtable

  • 配置【redis6和7没有变化】

image-20231017114317571

.

  • 结构

    • Redis用intset或hashtable存储set

      • 如果元素都是整数类型【long】,就用intset存储【如果超过long的取值范围也用hashtable存储】

        如果不是整数类型,就用hashtable数组+链表存储结构)【key就是元素的值,value为null】

    image-20231017114504479

    .

  • sadd命令的源码

image-20231017114814955

!!!zset

redis6
  • 结构:skiplist+ziplist

  • 相关配置

    • 当有序集合中包含的元素数量超过服务器属性zset_max_ziplist_entries的值(默认值为128 ),或者有序集合中新添加元素的 member的长度大于服务器属性zset_max_ziplist_value的值(默认值为64)时

      • redis会使用skiplist跳跃表作为有序集合的底层实现

      • 否则会使用ziplist作为有序集合的底层实现

image-20231017115149926

image-20231017115340117

  • 源码t_zet.c

image-20231017130426203

image-20231017130434325

redis7
  • 结构:skiplist+listpack

  • 相关配置【ziplist被替换成listpack】

image-20231017115510593

.

  • 源码

image-20231017130536770

.

!!!skiplist面试题
为什么要有跳表
  • 对于一个单链表来讲,即便链表中存储的数据是有序的,如果要想查找某个数据只能从头到尾遍历链表,这样查找效率就会很低,时间复杂度会很高O(N)

  • 解决方法:升维,也叫空间换时间

    • 链表查找时,时间复杂度最差会出现O(N),优化一下,尝试空间换时间,给链表加个索引,称为“索引升级”,两两取首即可

    • 优化一:加来一层索引之后,查找一个结点需要遍历的结点个数减少,也就是说查找效率提高【两个为一组,取首元素

    image-20231017131424876

    .

    • 优化二:画了一个包含64个结点的链表,按照前面讲的这种思路,建立五级索引

    image-20231017131810497

    .

跳表定义
  • 跳表是可以实现二分查找的有序链表,是一种以空间换取时间的结构

    • 由于链表,无法进行二分查找,因此借鉴数据库索引的思想,提取出链表中关键节点(索引),先在关键节点上查找,再进入下层链表查找,提取多层关键节点,就形成了跳跃表

    • 由于索引也要占据一定空间的,所以,索引添加的越多,空间占用的越多

  • 总结来讲跳表 = 链表 + 多级索引

!!!时空复杂度
  • 时间复杂度O(logn)

    • 时间复杂度分析:如果链表里有N个结点,按照两两取首每两个结点会抽出一个结点作为上一级索引的结点

      • 以此估算:第一级索引的结点个数大约就是n/2,第二级大约就是n/4,第三级大约就是n/8,依次类推......

      • 也就是说,第k级索引的结点个数是第k-1级索引的结点个数的1/2,那第k级索引结点的个数就是n/(2^k)

    • 假设索引有h级,最高级的索引有2个结点,通过上面的公式可以得到

      image-20231017132515636

      ,从而求得

      image-20231017132540079

      • 如果包含原始链表这一层,整个跳表的高度就是

        image-20231017132614999

  • 空间复杂度O(n)

    • 空间复杂度分析【两两取首】

      1. 首先原始链表长度为n

      2. 两两取首,每层索引的结点数:n/2, n/4, n/8 ... , 8, 4, 2,每上升一级就减少一半,直到剩下2个结点,以此类推

      3. 如果把每层索引的结点数写出来,就是一个等比数列,这几级索引的结点总和就是n/2+n/4+n/8…+8+4+2=n-2

        image-20231017133325756

        .

      4. 所以跳表的空间复杂度是O(n)

    • 分析三三取首

      1. 每层索引的结点数:n/3, n/9, n/27 ... , 9, 3, 1 以此类推

        • 第一级索引需要大约n/3个结点,第二级索引需要大约n/9个结点。每往上一级,索引结点个数都除以3

      2. 为了方便计算,假设最高一级的索引结点个数是1。每级索引的结点个数也是一个等比数列

      3. 通过等比数列求和公式,总的索引结点大约就是n/3+n/9+n/27+…+9+3+1=n/2,空间复杂度还是O(n)

        image-20231017133343661

        .

      4. 但比上面的每两个结点抽一个结点的索引构建方法,要减少了一半的索引结点存储空间

优缺点
  • 优点

    • 跳表是一个最典型的空间换时间解决方案

    • 新增或者删除前需要把所有索引都更新一遍,为了保证原始链表中数据的有序性,需要先找到要动作的位置,这个查找操作就会比较耗时,最后在新增和删除的过程中的总时间复杂度也是O(log n)

  • 缺点

    • 只有在数据量较大的情况下才能体现出来优势,而且应该是读多写少的情况下才能使用,所以它的适用范围应该还是比较有限的

    • 维护成本相对要高,在单链表中,一旦定位好要插入的位置,插入结点的时间复杂度是很低的,就是O(1)

!!!高性能设计之epoll和IO多路复用深度解析

多路复用要解决的问题

  • 并发多客户端连接,在多路复用之前最简单和典型的方案:同步阻塞网络IO模型,这种模式的特点就是用一个进程来处理一个网络连接(一个用户请求),比如一段典型的示例代码如下【直接调用 recv 函数从一个 socket 上读取数据】

    int main()
    {
     ...
     recv(sock, ...) //从用户角度来看非常简单,一个recv一用,要接收的数据就到我们手里了。
    }
  • 优缺点

    • 优点就是这种方式非常容易让人理解,写起代码来非常的自然,符合人的直线型思维

    • 缺点就是性能差,每个用户请求到来都得占用一个进程来处理,来一个请求就要分配一个进程跟进处理,

      • 类似一个学生配一个老师,一位患者配一个医生,可能吗

      • 进程是一个很笨重的东西,一台服务器上创建不了多少个进程

  • 结论

    • 进程在Linux上开销不小,先不说创建,光是上下文切换一次就得几个微秒

    • 为了高效地对海量用户提供服务,必须要让一个进程能同时处理很多个 tcp 连接才行

    • 现在假设一个进程保持了 10000 条连接,那么如何发现哪条连接上有数据可读、哪条连接可写

      • 可以采用循环遍历的方式来发现 IO 事件,但这种方式太低级

      • 希望有一种更高效的机制,在很多连接中的某条上有IO 事件发生的时候直接快速把它找出来

        • 其实这个事情 Linux 操作系统已经替我们都做好了,它就是IO多路复用机制

        • 这里的复用指的就是对进程的复用

!!!IO多路复用模型

定义
  • IO:网络IO

  • 多路:多个客户端连接 (连接就是接字描述符,即 soket 或者 channel) ,指的是多条 TCP 连接

  • 复用:用一个进程来处理多条的连接,使用单进程就能够实现同时处理多个客户端的连接

  • 总结

    • 实现了用一个进程来处理大量的用户连接

    • lO多路复用类似一个规范和接口,落地实现可以分select->poll->epoll三个阶段来描述

架构

image-20231017141751854

.

  • Redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,一次放到文件事件分派器,事件分派器将事件分发给事件处理器

  • Redis是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以I/O操作在一般情况下往往不能直接返回,这会导致某一文件的I/O阻塞导致整个进程无法对其它客户提供服务,而 I/O 多路复用就是为了解决这个问题而出现

  • I/O多路复用机制

    • 通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作

    • 这种机制的使用需要select 、 poll 、 epoll来配合

    • 多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接

    • 当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理

  • Redis 服务采用Reactor的方式来实现文件事件处理器(每一个网络连接其实都对应一个文件描述符)

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

      • 多个套接字

      • IO多路复用程序

      • 文件事件分派器

      • 事件处理器

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

  • 结论

    • 从Redis6开始,将网络数据读写、请求协议解析通过多个IO线程的来处理

    • 对于真正的命令执行来说,仍然使用单线程操作

    image-20231017142819093

    .

同/异步和(非)阻塞的理解

Unix网终编程的五种IO模型
  • Blocking IO - 阻塞IO

  • NoneBlocking IO-非阻塞IO

  • lO multiplexing - IO多路复用

  • signal driven IO - 信号驱动IO

    • 面试无关,暂不讲解

  • asynchronous IO -异步IO

    • 面试无关,暂不讲解

同步和异步理解
  • 同步:调用者要一直等待调用结果的通知后才能进行后续的执行

  • 异步

    • 指被调用方先返回应答让调用者先回去然后再计算调用结果,计算完最终结果后再通知并返回给调用方

    • 调用要想获得结果一般通过回调

  • 同步、异步的理解:同步、异步的讨论对象是被调用者(服务提供者),重点在于获得调用结果的消息通知方式上

阻塞和非阻塞理解
  • 阻塞:调用方一直在等待而且别的事情什么都不做,当前进/线程会被挂起

  • 非阻塞:调用在发出去后,调用方先去忙别的事情,不会阻塞当前进/线程,而会立即返回

  • 阻塞、非阻塞的理解:阴塞非阻塞的讨论对象是调用者(服务请求者),重点在于等消息的时候,调用者是否能干其它事

总结
  • 同步阻塞:服务员说快到你了,先别离开我后台看一眼马上通知你。客户在海底捞火锅前台干等着,啥都不干

  • 同步非阻塞:服务员说快到你了,先别离开客户在海底捞火锅前台边刷抖音边等着叫号

  • 异步阻塞:服务员说还要等等,你先去逛逛,一会儿通知你,你怕过号在前台拿着排号小票啥都不干,一直等着店员通知

  • 异步非阻塞:服务员说还再等等,你先去逛逛,一会儿通知你。你拿着排号小票+刷着抖音,等着店员通知

!!!java验证

  • 环境:一个redisServer+2个Client

BIO
recvfrom

image-20231017145506504

.

  • recvfrom函数用于从(已连接)套接口上接收数据,并捕获数据发送源的地址

  1. 当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据

  2. 对于网络IO来说,很多时候数据在一开始还没有到达,需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程

    • 比如还没有收到一个完整的UDP包,这个时候kernel就要等待足够的数据到来

  3. 在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞

  4. 当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。所以,BIO的特点就是在IO执行的两个阶段都被block了

accept监听演示
  • RedisServer

public class RedisServer
{
    public static void main(String[] args) throws IOException
    {
        byte[] bytes = new byte[1024];
        ServerSocket serverSocket = new ServerSocket(6379);
        while(true)
        {
            System.out.println("-----111 等待连接");
            Socket socket = serverSocket.accept();
            System.out.println("-----222 成功连接");
        }
    }
}
  • RedisClient01【RedisClient02同】

public class RedisClient01
{
    public static void main(String[] args) throws IOException
    {
        System.out.println("------RedisClient01 start");
        Socket socket = new Socket("127.0.0.1", 6379);
    }
}
  • 存在的问题:server要一直等待请求的进入,监听等待阻塞

read读取演示
单线程
  • 先启动RedisServerBIO,再启动RedisClient01验证后再启动2号客户端

  • RedisServerBIO

public class RedisServerBIO
{
    public static void main(String[] args) throws IOException
    {
        ServerSocket serverSocket = new ServerSocket(6379);
        while(true)
        {
            System.out.println("-----111 等待连接");
            Socket socket = serverSocket.accept();//阻塞1 ,等待客户端连接
            System.out.println("-----222 成功连接");
            InputStream inputStream = socket.getInputStream();
            int length = -1;
            byte[] bytes = new byte[1024];
            System.out.println("-----333 等待读取");
            while((length = inputStream.read(bytes)) != -1)//阻塞2 ,等待客户端发送数据
            {
                System.out.println("-----444 成功读取"+new String(bytes,0,length));
                System.out.println("====================");
                System.out.println();
            }
            inputStream.close();
            socket.close();
        }
    }
}
  • RedisClient01【RedisClient02同】

public class RedisClient01
{
    public static void main(String[] args) throws IOException
    {
        Socket socket = new Socket("127.0.0.1",6379);
        OutputStream outputStream = socket.getOutputStream();
        //socket.getOutputStream().write("RedisClient01".getBytes());
        while(true)
        {
            Scanner scanner = new Scanner(System.in);
            String string = scanner.next();
            if (string.equalsIgnoreCase("quit")) {
                break;
            }
            socket.getOutputStream().write(string.getBytes());
            System.out.println("------input quit keyword to finish......");
        }
        outputStream.close();
        socket.close();
    }
}
  • 上面的模型存在很大的问题

    • 如果客户端与服务端建立了连接,如果这个连接的客户端迟迟不发数据程序就会一直堵塞在read()方法上,这样其他客户端也不能进行连接,也就是一次只能处理一个客户端,对客户很不友好

    • 而且其它用户在服务器阻塞期间发送的所有请求会积压,在连接上服务器时会一次性全部发送,增加服务器的压力

多线程
  • 利用多线程

    • 只要连接了一个socket,操作系统分配一个线程来处理,这样read()方法堵塞在每个具体线程上而不堵塞主线程,就能操作多个socket了,哪个线程中的socket有数据,就读哪个socket,各取所需,灵活统一

    • 程序服务端只负责监听是否有客户端连接,使用accept() 阻塞

      • 客户端1连接服务端,就开辟一个线程(thread1)来执行 read() 方法,程序服务端继续监听

      • 客户端2连接服务端,也开辟一个线程(thread2)来执行 read() 方法,程序服务端继续监听

      • 以此类推

    • 任何一个线程上的socket有数据发送过来,read()就能立马读到,cpu就能进行处理

  • RedisServerBIOMultiThread

public class RedisServerBIOMultiThread
{
    public static void main(String[] args) throws IOException
    {
        ServerSocket serverSocket = new ServerSocket(6379);
        while(true)
        {
            //System.out.println("-----111 等待连接");
            Socket socket = serverSocket.accept();//阻塞1 ,等待客户端连接
            //System.out.println("-----222 成功连接");
            new Thread(() -> {
                try {
                    InputStream inputStream = socket.getInputStream();
                    int length = -1;
                    byte[] bytes = new byte[1024];
                    System.out.println("-----333 等待读取");
                    while((length = inputStream.read(bytes)) != -1)//阻塞2 ,等待客户端发送数据
                    {
                        System.out.println("-----444 成功读取"+new String(bytes,0,length));
                        System.out.println("====================");
                        System.out.println();
                    }
                    inputStream.close();
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            },Thread.currentThread().getName()).start();
            System.out.println(Thread.currentThread().getName());
        }
    }
}
  • RedisClient01【RedisClient02同】

public class RedisClient01
{
    public static void main(String[] args) throws IOException
    {
        Socket socket = new Socket("127.0.0.1",6379);
        OutputStream outputStream = socket.getOutputStream();
        //socket.getOutputStream().write("RedisClient01".getBytes());
        while(true)
        {
            Scanner scanner = new Scanner(System.in);
            String string = scanner.next();
            if (string.equalsIgnoreCase("quit")) {
                break;
            }
            socket.getOutputStream().write(string.getBytes());
            System.out.println("------input quit keyword to finish......");
        }
        outputStream.close();
        socket.close();
    }
}
  • 存在的问题

    • 每来一个客户端,就要开辟一个线程,如果来1万个客户端,那就要开辟1万个线程

    • 在操作系统中用户态不能直接开辟线程,需要调用内核来创建的一个线程,这其中还涉及到用户状态的切换(上下文的切换),十分耗资源

  • 解决

    • 第一个办法:使用线程池

      • 这个在客户端连接少的情况下可以使用

      • 但是用户量大的情况下,不知道线程池要多大,太大了内存可能不够,也不可行

    • 第二个办法:NIO(非阻塞式IO)方式

      • 因为read()方法堵塞了,所以要开辟多个线程,如果什么方法能使read()方法不堵塞,这样就不用开辟多个线程

      • 这就用到了另一个IO模型,NIO(非阻塞式IO)

痛点
  • 在阻塞式I/O 模型中

    • 应用程序在从调用 recvfrom开始到它返回有数据报准备好这段时间是阻塞的

    • recvfrom返回成功后,应用进程才能开始处理数据报

  • 思考:每个线程分配一个连接,必然会产生多个,既然是多个socket链接必然需要放入进容器,纳入统一管理

NIO
面试回答
  • 在NIO模式中,一切都是非阻塞的

    • accept()方法是非阻塞的,如果没有客户端连接,就返回无连接标识

    • read()方法是非阻塞的,如果read()方法读取不到数据就返回空闲中标识,如果读取到数据时只阻塞read()方法读数据的时间

  • 在NIO模式中,只有一个线程

    • 当一个客户端与服务端进行连接,这个socket就会加入到一个数组中,隔一段时间遍历一次,看这个socket的read()方法能否读到数据,这样一个线程就能处理多个客户端的连接和读取

代码
  • 以前的socket是会阻塞的,就另外开发一套api

image-20231017155358896

.

  • RedisServerNIO

public class RedisServerNIO
{
    static ArrayList<SocketChannel> socketList = new ArrayList<>();
    static ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    public static void main(String[] args) throws IOException
    {
        System.out.println("---------RedisServerNIO 启动等待中......");
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        serverSocket.bind(new InetSocketAddress("127.0.0.1",6379));
        serverSocket.configureBlocking(false);//设置为非阻塞模式
        while (true)
        {
            for (SocketChannel element : socketList)
            {
                int read = element.read(byteBuffer);
                if(read > 0)
                {
                    System.out.println("-----读取数据: "+read);
                    byteBuffer.flip();
                    byte[] bytes = new byte[read];
                    byteBuffer.get(bytes);
                    System.out.println(new String(bytes));
                    byteBuffer.clear();
                }
            }
            SocketChannel socketChannel = serverSocket.accept();
            if(socketChannel != null)
            {
                System.out.println("-----成功连接: ");
                socketChannel.configureBlocking(false);//设置为非阻塞模式
                socketList.add(socketChannel);
                System.out.println("-----socketList size: "+socketList.size());
            }
        }
    }
}
  • RedisClient01【RedisClient02同】

public class RedisClient01
{
    public static void main(String[] args) throws IOException
    {
        System.out.println("------RedisClient01 start");
        Socket socket = new Socket("127.0.0.1",6379);
        OutputStream outputStream = socket.getOutputStream();
        while(true)
        {
            Scanner scanner = new Scanner(System.in);
            String string = scanner.next();
            if (string.equalsIgnoreCase("quit")) {
                break;
            }
            socket.getOutputStream().write(string.getBytes());
            System.out.println("------input quit keyword to finish......");
        }
        outputStream.close();
        socket.close();
    }
}
存在的问题和优缺点
  • 问题一:这个模型在客户端少的时候十分好用,但是客户端如果很多,比如有1万个客户端进行连接,那么每次循环就要遍历1万个socket,如果一万个socket中只有10个socket有数据,也会遍历一万个socket,就会做很多无用功,每次遍历遇到 read 返回-1时,仍然是一次浪费资源的系统调用

  • 问题二:而且这个遍历过程是在用户态进行的,用户态判断socket是否有数据还是调用内核的read()方法实现的,这就涉及到用户态和内核态的切换,每遍历一个就要切换一次,因为这些问题的存在开销很大

  • 优点:不会阻塞在内核的等待数据过程,每次发起 I/O请求可以立即返回,不用阻塞等待,实时性较好

  • 缺点:轮询将会不断地询问内核,这将占用大量的CPU 时间系统资源利用率较低,所以一般 Web服务器不使用这种 I/O 模型

  • 结论:让Linux内核搞定上述需求,将一批文件描述符通过一次系统调用传给内核由内核层去遍历,才能真正解决这个问题。IO多路复用应运而生,也即将上述工作直接放进Linux内核不再两态转换而是直接从内核获得结果因为内核是非阻塞的

IO多路复用
相关定义
多路复用
  • 指的其实是在单个线程通过记录跟踪每一个Sock(I/O流)的状态来同时管理多个I/O流,目的是尽量多的提高服务器的吞吐能力

  • 多个Socket复用一根网线这个功能是在内核+驱动层实现的

image-20231018124958575

.

文件描述符
  • File descriptor是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数

  • 实际上它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表

    • 当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符

    • 在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开

    • 但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统

image-20231018125246354

.

  • 每个网络连接对应都对应一个文件描述符

image-20231018131406900

.

IO多路复用
  • IO multiplexing就是select,poll,epoll,有些技术书籍也称这种IO方式为event driven IO事件驱动IO

    • 就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作

    • 可以基于一个阻塞对象同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程,每次new一个线程)这样可以大大节省系统资源

    • I/O 多路复用的特点是通过一种机制使一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态select,poll,epoll等函数就可以返回

  • select:其实就是把NIO中用户态要遍历的fd数组(存放了用户socket的文件标识符的list)拷贝到了内核态,让内核态来遍历

    • 因为用户态判断socket是否有数据还是要调用内核态的

    • 所有拷贝到内核态后,遍历判断的时候就不用一直用户态和内核态频繁切换

  • epoll:将用户socket对应的fd注册进epoll,然后epoll监听哪些socket上有消息到达,这样就避免了大量的无用操作

    • 此时的socket应该采用非阻塞模式。这样整个过程只在调用select、poll、epoll时候才会阻塞

    • 收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的reactor反应模式

image-20231018125704132

.

Reactor模式
  • 是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式

  • 服务端程序处理传入的多路请求,并将它们同步分派给请求对应的处理线程,Reactor模式也叫Dispatcher模式

  • 即I/O多路复用统一监听事件,收到事件后分发(Dispatch 给某进程),是编写高性能网络服务器的必备技术

  • Reactor 模式中有2个关键组成

    • Reactor:Reactor在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应。 它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人

    • Handlers:处理程序执行I/O事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际办理人。Reactor通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作

  • Redis服务采用 Reactor 的方式来实现文件事件处理器每一个网络连接都对应一个文件描述符

    • Redis基于Reactor模式开发了网络事件处理器,这个处理器被称为文件事件处理器

    • 它的组成结构为4部分:多个套接字、IO多路复用程序、文件事件分派器、事件处理器

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

image-20231018131118066

.

select源码
结构

image-20231018131824900

.

  • select 函数监视的文件描述符分3类(readfds、writefds和exceptfds),将用户传入的数组拷贝到内核空间

  • 调用后select函数会阻塞直到有描述符就绪(有数据 可读、可写、或者有except)或超时(timeout指定等待时间,如果立即返回设为null即可),函数返回

  • 当select函数返回后,可以通过遍历fdset,来找到就绪的描述符

执行流程
  1. select是一个阻塞函数,当没有数据时,会一直阻塞在select那一行

  2. 当有数据时会将rset中对应的那一位置为1

  3. select函数返回,不再阻塞

  4. 遍历文件描述符数组,判断哪个fd被置位了

  5. 读取数据,然后处理

image-20231018132659216

优缺点
  • 优点

    • select其实就是把NIO中用户态要遍历的fd数组拷贝到了内核态,让内核态来遍历,因为用户态判断socket是否有数据还是要调用内核态的,所有拷贝到内核态后,这样遍历判断的时候就不用一直用户态和内核态频繁切换

    • 从代码中可以看出,select系统调用后,返回了一个置位后的&rset,这样用户态只需进行很简单的二进制比较,就能很快知道哪些socket需要read数据,有效提高了效率

    • 既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销,多个文件描述符只有一次select的系统调用 + N次就绪状态的文件描述符的read系统调用

  • 缺点

    • bitmap默认大小为1024,虽然可以调整但还是有限度的

    • rset每次循环都必须重新置位为0,不可重复使用

    • 尽管将rset从用户态拷贝到内核态,由内核态判断是否有数据,但是还是有拷贝的开销

      • select调用需要传入fd数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的(可优化为不复制)

    • 当有数据时select就会返回,但是select函数并不知道哪个文件描述符有数据,后面还需要再次对文件描述符数组进行遍历

poll源码
结构和执行流程
  1. 将fd从用户态拷贝到内核态

  2. poll为阻塞方法,执行poll方法,如果有数据会将fd对应的revents置POLLIN【读标识,写标识为POLLOUT】

  3. poll方法返回循环遍历,查找哪个fd被置位为POLLIN并将revents重置为0,便于复用

  4. 对置位的fd进行读取和处理

image-20231018134243411

.

优缺点
  • 优点

    • poll使用pollfd数组来代替select中的bitmap,数组没有1024的限制,可以一次管理更多的client

      • 它和 select 的主要区别就是,去掉了select 只能监听1024个文件描述符的限制

    • 当pollfds数组中有事件发生相应的revents置位为1,遍历的时候又置位回零,实现了pollfd数组的重用

  • 缺点:poll解决了select缺点中的前两条,其本质原理还是select的方法,还存在select中原来的问题

    • pollfds数组拷贝到内核态,仍然有开销

    • poll并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历

epoll源码
结构

image-20231018160535484

image-20231018160548659

方法解释
int epoll_create(int size)参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)见上图
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)等待epfd上的io事件,最多返回maxevents个事件。参数events用来存储从内核得到事件的集合,maxevents告之内核这个events有多大
三步调用
  • epoll create:创建一个 epoll 句柄

  • epoll ctl:向内核添加、修改或删除要监书

  • epoll wait:类似发起了select()调用

执行过程
  • epoll是非阻塞的

  • epoll的执行流程

    1. 当有数据的时候,会把相应的文件描述符“置位”,但是epoll没有revent标志位,所以并不是真正的置位。这时候会把有数据的文件描述符放到队首

    2. epoll会返回有数据的文件描述符的个数

    3. 根据返回的个数N,读取前N个文件描述符即可

    4. 读取、处理

image-20231018161429314

.

结论
  • 多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用+内核层遍历这些文件描述符

    • 这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程

    • 一个socket的生命周期中只有一次从用户态拷贝到内核态的过程,开销小

    • 使用event事件通知机制,每次socket中有数据会主动通知内核,并加入到就绪链表中,不需要遍历所有的socket

  • epoll是现在最先进的IO多路复用器,Redis、Nginx,linux中的Java NIO都使用的是epoll

  • 在多路复用IO模型中,会有一个内核线程不断地去轮询多个socket状态,只有当真正读写事件发送时,才真正调用实际的IO读写操作

    • 因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有真正有读写事件进行时,才会使用IO资源,所以它大大减少来资源占用

    • 多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候会把当前线程阻塞掉,当有一个或多个流有I/O事件时就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll只轮询那些真正发出了事件的流),并且只依次顺序处理就绪的流,这种做法就避免了大量的无用操作

    • 采用多路I/O复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),且Redis在内存中操作数据的速度非常快,也就是说内存内的操作不会成为影响Redis性能的瓶颈

三种方法对比

image-20231018162157997

.

总结
  • 多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符

  • 所谓 I/O 多路复用机制,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要 select 、 poll 、 epoll 来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理

  • 五种io模型的比较

image-20231018162528000

.

  • 不同操作系统的系统函数的支持不同

image-20231018162743755

.

终章案例:微信抢红包

需求分析

  1. 各种节假日,发红包+抢红包,不说了,100%高并发业务要求,不能用mysql来做

  2. 一个总的大红包,会有可能拆分成多个小红包,总金额= 分金额1+分金额2+分金额3......分金额N

  3. 每个人只能抢一次,你需要有记录,比如100块钱,被拆分成10个红包发出去

    • 总计有10个红包,抢一个少一个,总数显示(10/6)直到完

    • 需要记录哪些人抢到了红包,重复抢作弊不可以

  4. 有可能还需要你计时,完整抢完,从发出到全部over,耗时多少

  5. 红包过期,或者群主人品差,没人抢红包,原封不动退回

  6. 红包过期,剩余金额可能需要回退到发红包主账户下

架构设计

架构分析
  • 拆分算法

    • 红包其实就是金额,拆分算法如何 ?给你100块,分成10个小红包(金额有可能小概率相同,有2个红包都是2.58),

    • 如何拆分随机金额设定每个红包里面安装多少钱?

  • 次数限制:每个人只能抢一次,次数限制

  • 原子性:每抢走一个红包就减少一个(类似减库存),那这个就需要保证库存的--------原子性,不加锁实现

二倍均值法
  • 抢红包通用算法

    • 剩余红包金额为M,剩余人数为N,那么有如下公式:每次抢到的金额=随机区间 (0, (剩余红包金额M ÷ 剩余人数N ) X 2)

    • 这个公式,保证了每次随机金额的平均值是相等的,不会因为抢红包的先后顺序而造成不公平

  • 举个栗子:假设有10个人,红包总额100元

    1. 100÷10X2=20, 所以第一个人的随机范围是(0,20),平均可以抢到10元

      • 假设第一个人随机到10元,那么剩余金额是100-10=90元

    2. 90÷9X2=20, 所以第二个人的随机范围同样是(0,20),平均可以抢到10元

      • 假设第二个人随机到10元,那么剩余金额是90-10 = 80元

    3. 80÷8X2=20, 所以第三个人的随机范围同样是(0,20),平均可以抢到10元

      • 以此类推,每一次随机范围的均值是相等的

代码实现

@RestController
public class RedPackageController
{
    public static final String RED_PACKAGE_KEY = "redpackage:";
    public static final String RED_PACKAGE_CONSUME_KEY = "redpackage:consume:";
    @Resource
    private RedisTemplate redisTemplate;
    /**
     * 拆分+发送红包
     * http://localhost:5555/send?totalMoney=100&redPackageNumber=5
     * @param totalMoney
     * @param redPackageNumber
     * @return
     */
    @RequestMapping("/send")
    public String sendRedPackage(int totalMoney,int redPackageNumber)
    {
        //1 拆红包,总金额拆分成多少个红包,每个小红包里面包多少钱
        Integer[] splitRedPackages = splitRedPackage(totalMoney, redPackageNumber);
        //2 红包的全局ID
        String key = RED_PACKAGE_KEY+IdUtil.simpleUUID();
        //3 采用list存储红包并设置过期时间
        redisTemplate.opsForList().leftPushAll(key,splitRedPackages);
        redisTemplate.expire(key,1,TimeUnit.DAYS);
        return key+"\t"+"\t"+ Ints.
            asList(Arrays.stream(splitRedPackages).mapToInt(Integer::valueOf).toArray());
    }
    /**
     * http://localhost:5555/rob?redPackageKey=上一步的红包UUID&userId=1
     * @param redPackageKey
     * @param userId
     * @return
     */
    @RequestMapping("/rob")
    public String rodRedPackage(String redPackageKey,String userId)
    {
        //1 验证某个用户是否抢过红包
        Object redPackage = redisTemplate.opsForHash().get(RED_PACKAGE_CONSUME_KEY + redPackageKey, userId);
        //2 没有抢过就开抢,否则返回-2表示抢过
        if (redPackage == null) {
            // 2.1 从list里面出队一个红包,抢到了一个
            Object partRedPackage = redisTemplate.opsForList().leftPop(RED_PACKAGE_KEY + redPackageKey);
            if (partRedPackage != null) {
                //2.2 抢到手后,记录进去hash表示谁抢到了多少钱的某一个红包
                redisTemplate.opsForHash().
                    put(RED_PACKAGE_CONSUME_KEY + redPackageKey,userId,partRedPackage);
                System.out.println("用户: "+userId+"\t 抢到多少钱红包: "+partRedPackage);
                //TODO 后续异步进mysql或者RabbitMQ进一步处理
                return String.valueOf(partRedPackage);
            }
            //抢完
            return "errorCode:-1,红包抢完了";
        }
        //3 某个用户抢过了,不可以作弊重新抢
        return "errorCode:-2,   message: "+"\t"+userId+" 用户你已经抢过红包了";
    }
    /**
     * 1 拆完红包总金额+每个小红包金额别太离谱
     * @param totalMoney
     * @param redPackageNumber
     * @return
     */
    private Integer[] splitRedPackage(int totalMoney, int redPackageNumber)
    {
        int useMoney = 0;
        Integer[] redPackageNumbers = new Integer[redPackageNumber];
        Random random = new Random();
        for (int i = 0; i < redPackageNumber; i++)
        {
            if(i == redPackageNumber - 1)
            {
                redPackageNumbers[i] = totalMoney - useMoney;
            }else{
                int avgMoney = (totalMoney - useMoney) * 2 / (redPackageNumber - i);
                redPackageNumbers[i] = 1 + random.nextInt(avgMoney - 1);
            }
            useMoney = useMoney + redPackageNumbers[i];
        }
        return redPackageNumbers;
    }
}
  • 批量删除指定模式的key

image-20231018172002120

.

  • 23
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值