Redis学习笔记

文章目录

  • 为什么要有redis
  • 基础入门
    • 概述
      • SQL与NoSQL
      • 安装
      • 启动
      • 客户端
    • 数据结构
    • 应用场景
    • 常见命令
      • 通用命令
      • Key结构
      • String类型
      • Hash类型
      • List类型
      • Set类型
      • SortedSet/Zset类型
      • Bitmap类型
      • HyperLogLog类型
      • GEO类型
      • Stream
    • Java客户端
      • Jedis客户端
        • 使用
        • 连接池
      • SpringDataRedis客户端
        • 使用
        • 自定义序列化
        • StringRedisTemplate
  • 实践推荐用法
    • Redis键值设计
      • 推荐写法
      • 避免BigKey
      • 适合的数据结构
    • 批处理优化
      • 单机批处理
      • 集群批处理
    • 服务端优化
      • 持久化
      • 慢查询
      • 安全配置
      • 内存划分和配置
    • 推荐使用主从而不是集群
  • 缓存设计
    • 缓存雪崩
    • 缓存击穿
    • 缓存穿透
    • 实现延迟队列
    • 实现分布式锁
  • 分布式缓存
    • 持久化
      • RDB持久化
      • AOF持久化
      • AOF和RDB对比
    • 主从集群
      • 主从数据同步原理
        • 全量同步
        • 增量同步
      • 优化
      • 搭建主从集群
    • 哨兵
      • 集群的监控与故障恢复
      • Sentinel集群选出Leader
      • RedisTemplate集成哨兵机制
      • 搭建哨兵集群
    • 分片集群
      • 散列插槽
      • 集群伸缩
      • 节点通信
      • 故障转移
      • RedisTemplate访问分片集群
  • 多级缓存
    • 概述
    • JVM进程缓存
    • 实现多级缓存
    • 缓存同步
  • Redis原理
    • Redis数据类型
      • 数据结构
        • 键值对数据库
        • SDS
        • 双向链表
        • 压缩列表
        • 哈希表
        • 整数集合
        • 跳表
        • quicklist
        • listpack
      • 数据类型
        • String类型实现
        • List类型实现
        • Hash类型实现
        • Set类型实现
        • Zset类型实现
    • 线程模型
      • 后台线程
      • 单线程
      • 多线程
    • 过期数据删除
    • 内存淘汰
    • 缓存更新策略
      • 旁路缓存模式
      • 读写穿透
      • 异步缓存写入
    • 通信协议-RESP
  • Lua语法
      • 变量
      • 循环和条件控制
      • 函数

为什么要有redis

首先要理解, 缓存就是以空间换事件,能提高系统性能和减少请求响应时间。

应用: CPU Cache缓存内存数据以解决CPU处理速度和内存访问速度不匹配的问题, 内存缓存的是硬盘数据 以解决硬盘访问速度慢的问题, 操作系统的快表也可以看作是一个缓存存储器(加快虚拟地址到物理地址的映射)。

所以在数据库之上加一层缓存,可以明显加快访问速度; 同时缓存也可以支持更大的并发量。

  • 本地缓存

    存在于应用内部,不用额外的网络开销,速度很快。 常用于数据量不大的单体架构。

    常见的单体架构: 使用Nginx做负载均衡,部署若干个相同的应用到不同服务器,使用用一个数据库,并使用本地缓存。

    Redis学习笔记_第1张图片

    常用的本地缓存:

    • jdk自带的 HashMap 和 ConcurrentHashMap, 只有缓存功能,没有过期时间、淘汰机制、命中率统计等基本功能,一般不用。

    • Ehcache、Guava Cache、Spring Cache:

      Echcache比另两个更重,但能嵌入到hibernate和mybatis中作为多级缓存,能将缓存数据持久化到本地磁盘。

      Guava Cache 和 Spring Cache差不多, Guava Cache用的较多。

    • Caffeine

      Caffeine和Guava相似, 但在各个方面都比Guava做的更好, 一般都能替代Guava。

    缺点:

    • 难以支持分布式架构: 各服务的缓存无法共享。
    • 受服务所在机器限制明显,如果当前机器的服务耗费内存多,那缓存能用的容量就会变少。
  • 分布式缓存

    是独立的,能提供内存数据库服务。

    脱离应用独立存在,位于应用和数据库之间,多个应用可以共用一个分布式缓存。

    Redis学习笔记_第2张图片

    分布式缓存最常用的就是Redis了。

    缺点:

    • 系统复杂性增加: 要维护缓存和数据库的数据一致性,维护热点缓存,保证缓存的高可用等。
    • 系统开发成本增加
  • 多级缓存

    最常用的多级缓存: L1本地缓存 + L2 分布式缓存。

    多级缓存会更多的增加维护负担,且在大部分场景带来的提升效果并不大。

    适用场景:

    • 缓存数据稳定,不会频繁修改。
    • 数据访问量特别大,如秒杀场景。

基础入门

概述

Redis(Remote Dictionary Server,远程字典服务器)是一种基于内存键值型NoSql数据库:

特征:

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

键值型 —— 是指Redis中存储的数据都是以key、value对的形式存储,而value的形式多种多样,可以是字符串、数值、json。

NoSql —— 可以翻译做Not Only Sql(不仅仅是SQL),或者是No Sql(非Sql的)数据库。是相对于传统关系型数据库而言,有很大差异的一种特殊的数据库,因此也称之为非关系型数据库

Redis官方: https://redis.io/

SQL与NoSQL

SQL NoSQL
数据结构 结构化 非结构化
数据关联 关联的 非关联的
查询方式 SQL查询 非SQL
事务特性 ACID BASE
存储方式 硬盘 内存
扩展性 垂直 水平
使用场景 1. 数据结构固定
2. 相关业务对数据安全、一致性要求较高
1. 数据结构不固定
2. 对安全性、一致性要求不高
3. 对性能有要求
  • 结构化与非结构化

    • 传统关系型数据库是结构化数据,每一张表都有严格的约束信息:字段名、字段数据类型、字段约束等等信息,插入的数据必须遵守这些约束。
    • 而NoSql则对数据库格式没有严格约束,往往形式松散,自由。
  • 关联与非关联

    • 传统数据库的表与表之间往往存在关联,如外键。
    • 而非关系型数据库不存在关联关系,要维护关系要么靠代码中的业务逻辑,要么靠数据之间的耦合。
  • 查询方式

    • 传统关系型数据库会基于Sql语句做查询,语法有统一标准;
    • 不同的非关系数据库查询语法差异极大,五花八门各种各样。
  • 事务

    • 传统关系型数据库能满足事务ACID的原则。(原子性、 一致性、 隔离性、 持久性 )
    • 非关系型数据库往往不支持事务,或者不能严格保证ACID的特性,只能实现基本的一致性。
  • 存储方式

    • 关系型数据库基于磁盘进行存储,会有大量的磁盘IO,对性能有一定影响
    • 非关系型数据库,他们的操作更多的是依赖于内存来操作,内存的读写速度会非常快,性能自然会好一些
  • 扩展性

    • 关系型数据库集群模式一般是主从,主从数据一致,起到数据备份的作用,称为垂直扩展。
    • 非关系型数据库可以将数据拆分,存储在不同机器上,可以保存海量数据,解决内存大小有限的问题。称为水平扩展。
    • 关系型数据库因为表之间存在关联关系,如果做水平扩展会给数据查询带来很多麻烦。

安装

一般选择在linux系统下安装。

  1. 添加gcc依赖:

    yum install -y gcc tcl
    
  2. 下载安装包,并上传

  3. 解压缩:

    tar -xzvf redis-6.2.6.tar.gz
    
  4. 进入redis目录:

    cd redis-6.2.6
    

    运行编译命令:

    make && make install
    

默认的安装路径是在 /usr/local/bin 目录下。

该目录已经默认配置到环境变量,因此可以在任意目录下运行这些命令。其中:

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

启动

(一般选择开机自启)

  1. 默认启动:任意目录下执行命令(属于前台启动,会阻塞窗口,一般不用)

    redis-server
    
  2. 指定配置启动: 修改Redis配置文件,使其能后台启动。

    修改:在 解压的redis安装包下(/usr/local/src/redis-6.2.6)的redis.conf,修改内容:

    # 允许访问的地址,默认是127.0.0.1,会导致只能在本地访问。修改为0.0.0.0则可以在任意IP访问,生产环境不要设置为0.0.0.0
    bind 0.0.0.0
    # 守护进程,修改为yes后即可后台运行
    daemonize yes 
    # 密码,设置后访问Redis必须输入密码
    requirepass zzc
    

    其他常见配置:

    # 监听的端口
    port 6379
    # 工作目录,默认是当前目录,也就是运行redis-server时的命令,日志、持久化等文件会保存在这个目录
    dir .
    # 数据库数量,设置为1,代表只使用1个库,默认有16个库,编号0~15
    databases 1
    # 设置redis能够使用的最大内存
    maxmemory 512mb
    # 日志文件,默认为空,不记录日志,可以指定日志文件名
    logfile "redis.log"
    

    启动、停止:

    # 进入redis安装目录 
    cd /usr/local/src/redis-6.2.6
    # 启动
    redis-server redis.conf
    
    # 利用redis-cli来执行 shutdown 命令,即可停止 Redis 服务,
    # 因为之前配置了密码,因此需要通过 -u 来指定密码
    redis-cli -u zzc shutdown
    
  3. 开机自启:

    1. 新建一个系统服务文件:

      vi /etc/systemd/system/redis.service
      

      内容为:

      [Unit]
      Description=redis-server
      After=network.target
      
      [Service]
      Type=forking
      ExecStart=/usr/local/bin/redis-server /usr/local/src/redis-6.2.6/redis.conf
      PrivateTmp=true
      
      [Install]
      WantedBy=multi-user.target
      
    2. 重载系统服务:

      systemctl daemon-reload
      

      然后,就可以用下面命令来操作redis了:

      # 启动
      systemctl start redis
      # 停止
      systemctl stop redis
      # 重启
      systemctl restart redis
      # 查看状态
      systemctl status redis
      
    3. 执行下面的命令,可以让redis开机自启:

      systemctl enable redis
      

客户端

  • 命令行客户端:

    redis-cli [options] [commonds]
    

    其中常见的options有:

    • -h 192.168.205.129:指定要连接的redis节点的IP地址,默认是127.0.0.1
    • -p 6379:指定要连接的redis节点的端口,默认是6379
    • -a zzc:指定redis的访问密码 (也可以后续使用auth 密码 来进入访问)

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

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

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

  • 图形化桌面客户端:

    https://github.com/lework/RedisDesktopManager-Windows/releases

数据结构

Redis是典型的key-value数据库,key一般是字符串,而value包含很多不同的数据类型:

类型 例子
String hello world
Hash {name: “Tom”, age: 21}
List [A -> B -> C]
Set {A, B, C}
SortedSet {A:1, B:2, C:3}
GED { A : (120.3, 30.5) }
BitMap 0110110101110101011
HyperLog 0110110101110101011

常见数据类型: String, Hash, List, Set, Zset(有序集合)

后添加的数据类型: BitMap,HyperLogLog, GEO, Stream

应用场景

  • String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。(因为Redis是单线程,能确保命令的原子性)

    • 常规计数:

      SET num:1 0
      INCR num:1	# +1
      INCR num:1	# +1
      GET num:1	# num:1为2
      
    • 分布式锁:

      lock_key是key键; unique_value是客户端唯一标识;NX表示不存在才插入, 插入成功即加锁成功; PX 10000是过期时间10s

      SET lock_key unique_value NX PX 10000
      

      解锁就是将lock_key删除,且必须是加锁的客户端进行解锁,即unique_value是否为加锁客户端。

      加解锁是两个操作,所以需要Lua脚本来保证锁的原子性,因为Redis是以原子性的方式执行Lua脚本。

      // 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
      if redis.call("get",KEYS[1]) == ARGV[1] then
          return redis.call("del",KEYS[1])
      else
          return 0
      end
      
    • 共享Session信息

      一般后台管理系统会使用Session保存用户的会话登录状态,但在分布式系统中不能共享Session信息,所以使用Redis统一存储管理Session信息, 所有服务器都去同一个Redis获取Session。

  • List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。

    • 消息队列:

      消息队列存取消息,要满足三个需求:消息保序,处理重复消息,保证消息可靠性。

      • List是先进先出的,满足消息保序。 List的BRPOP命令可以在没有数据时阻塞,直到有新数据写入时才开始读取新数据。
      • 处理重复消息,需要让每个消息都有一个全局ID(List不会为消息生成ID,需要自行实现),消费者根据ID判断,不处理已经处理的消息。
      • 当消息从List取出后,如果消费者宕机,应该要保证能再次从List中读取消息。 为了留存消息,List提供 BRPOPLPUSH 命令, 让读出的消息再插入到另一个List备份。

      缺点: 不支持多个消费者消费同一条消息,即不支持消费组。因为消息一旦取出,就从List中删除了。

  • Hash 类型:缓存对象、购物车等。
  • Set 类型:聚合计算场景(并集、交集、差集 (复杂度高,主库慎用)),比如点赞、共同关注、抽奖活动等。
  • Zset 类型:排序场景,比如排行榜、电话和姓名排序等。
  • BitMap:二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
  • HyperLogLog:海量数据基数统计的场景,比如百万级网页 UV 计数等;
  • GEO:存储地理位置信息的场景,比如滴滴叫车;
  • Stream:消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。

常见命令

在官网的commands处可以查看所有命令。

可在reids命令行中,使用 help @xxx 来查看

通用命令

  • KEYS:查看符合模板的所有key, 如 KEYS *na*
  • DEL:删除一个指定的key
  • EXISTS:判断key是否存在
  • EXPIRE:expire是给一个key设置有效期,有效期到期时该key会被自动删除
  • TTL:查看一个KEY的剩余有效期

Key结构

redis中的 key键 可以有各种不同层级的前缀,以避免key冲突,推荐格式为:

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

如项目为zzc,有user和product两种不同类型的数据,可以定义为:

- user相关的key:zzc:user:1
- product相关的key:zzc:product:1

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

KEY VALUE
heima:user:1 {“id”:1, “name”: “Jack”, “age”: 21}

String类型

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

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

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

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

命令:

  • SET:添加或者修改已经存在的一个String类型的键值对
  • GET:根据key获取String类型的value
  • MSET:批量添加多个String类型的键值对
  • MGET:根据多个key获取多个String类型的value
  • INCR:让一个整型的key自增1
  • INCRBY:让一个整型的key自增并指定步长,例如:incrby num 2 让num值自增2
  • INCRBYFLOAT:让一个浮点类型的数字自增并指定步长
  • SETNX:添加一个String类型的键值对,如果这个key存在,则不执行
  • SETEX:添加一个String类型的键值对,并且指定有效期

Hash类型

Hash类型,其value是一个无序字典(其value由field和value组成),类似于Java中的HashMap结构。

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

Key value
field value
zzc:user:1 name Tom
age 29
zzc:user:2 name Rose
age 18

Hash的常见命令有:

  • HSET key field value:添加或者修改hash类型key的field的值

  • HGET key field:获取一个hash类型key的field的值

  • HMSET:批量添加多个hash类型key的field的值

  • HMGET:批量获取多个hash类型key的field的值

  • HGETALL:获取一个hash类型的key中的所有的field和value

  • HKEYS:获取一个hash类型的key中的所有的field

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

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

List类型

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

特征也与LinkedList类似:

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

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

List的常见命令有:

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

表的哈希结构

Set类型

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

  • 无序

  • 元素不可重复

  • 查找快

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

Set的常见命令有:

  • SADD key member … :向set中添加一个或多个元素
  • SREM key member … : 移除set中的指定元素
  • SCARD key: 返回set中元素的个数
  • SISMEMBER key member:判断一个元素是否存在于set中
  • SMEMBERS:获取set中的所有元素
  • SINTER key1 key2 … :求key1与key2的交集
  • SDIFF key1 key2 … :求key1与key2的差集
  • SUNION key1 key2…:求key1与key2的并集

SortedSet/Zset类型

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

SortedSet具备下列特性:

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

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

SortedSet的常见命令有:

  • ZADD key score member:添加一个或多个元素到sorted set ,如果已经存在则更新其score值
  • ZREM key member:删除sorted set中的一个指定元素
  • ZSCORE key member : 获取sorted set中的指定元素的score值
  • ZRANK key member:获取sorted set 中的指定元素的排名
  • ZCARD key:获取sorted set中的元素个数
  • ZCOUNT key min max:统计score值在给定范围内的所有元素的个数
  • ZINCRBY key increment member:让sorted set中的指定元素自增,步长为指定的increment值
  • ZRANGE key min max:按照score排序后,获取指定排名范围内的元素
  • ZRANGEBYSCORE key min max:按照score排序后,获取指定score范围内的元素
  • ZDIFF、ZINTER、ZUNION:求差集、交集、并集

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

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

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

Bitmap类型

Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。 底层是用String类型实现的。(String以二进制方式处理其buf[]数组)

常用命令:

  • SETBIT key offset value: 设置值,value只能是0和1;
  • GETBIT key offset: 获取值;
  • BITCOUNT key start end: 获取范围内1的个数, start和end以字节为单位;
  • BITOP [位运算符] [result] [key1] [keyn…]: 位运算,其中为运算符有 与&,或|, 异或^,取反~。 key1到keyn的运算结果存放到 result 这个key中。(较短字符串的缺少部分看作0)
  • BITPOS key value:返回key中第一次出现指定value的位置。(注意offset从0开始)

HyperLogLog类型

用于统计基数的数据类型, 基数统计是指统计一个集合中不重复的元素个数。

每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 264 个不同元素的基数。 HyperLogLog的统计规则是基于概率完成的,误算率大约0.81%。

优点: 在输入元素的数量或者体积非常非常大时,计算基数所需的内存空间总是固定的、并且是很小的。

(内部实现设计了大量数学问题)

命令(只有3个):

  • PFADD key element element … :添加元素;
  • PFCOUNT key key … :返回指定若干个key的基数估计值;
  • PFMERGE destkey sourcekey sourcekey…: 将多个key合并为一个key。

GEO类型

主要用于存储地理位置信息。

内部实现是直接使用了Sorted Set。 GEO 类型使用 GeoHash 编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。

这样一来,就可以利用 Sorted Set 提供的“按权重进行有序范围查找”的特性,实现 LBS 服务中频繁使用的“搜索附近”的需求。

常用命令:

  • GEOADD key 经度 纬度 位置名称 [经度 纬度 位置名称] … :添加地理位置。
  • GEOPOS key 位置名称 [位置名称]… :从key中返回指定位置的经纬度坐标,不存在则返回nil。
  • GEODIST key member1 menber2:返回两个位置间的距离。
  • GEORADIUS key 经度 纬度 radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key] : 根据用户给定的经纬度坐标来获取指定范围内的地理位置集合

Stream

Str

Java客户端

推荐使用的客户端有: Jedis,Letture,Redisson

  • Jedis和Lettuce:这两个主要是提供了Redis命令对应的API,方便操作Redis

    SpringDataRedis对这两种做了抽象和封装,因此可以直接通过SpringDataRedis来学习。

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

Jedis客户端

官网: https://github.com/redis/jedis

使用
  1. (普通Maven项目)引入依赖:

    
    <dependency>
        <groupId>redis.clientsgroupId>
        <artifactId>jedisartifactId>
        <version>3.7.0version>
    dependency>
    
    <dependency>
        <groupId>org.junit.jupitergroupId>
        <artifactId>junit-jupiterartifactId>
        <version>5.7.0version>
        <scope>testscope>
    dependency>
    
  2. 建立连接,测试,释放资源:

    private Jedis jedis;
    
    @BeforeEach
    void setUp() {
        // 1.建立连接
        // jedis = new Jedis("192.168.205.129", 6379);
        jedis = JedisConnectionFactory.getJedis();
        // 2.设置密码
        jedis.auth("zzc");
        // 3.选择库
        jedis.select(0);
    }
    
    @Test
    void testString() {
        // 存入数据
        String result = jedis.set("name", "Tom");
        System.out.println("result = " + result);
        // 获取数据
        String name = jedis.get("name");
        System.out.println("name = " + name);
    }
    
    @Test
    void testHash() {
        // 插入hash数据
        jedis.hset("user:1", "name", "Jack");
        jedis.hset("user:1", "age", "21");
    
        // 获取
        Map<String, String> map = jedis.hgetAll("user:1");
        System.out.println(map);
    }
    
    @AfterEach
    void tearDown() {
        if (jedis != null) {
            jedis.close();
        }
    }
    
连接池

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

package com.zzc.jedis.util;

import redis.clients.jedis.*;

public class JedisConnectionFactory {

    private static JedisPool jedisPool;

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

    //调用此方法,获取Jedis连接
    public static Jedis getJedis(){
        return jedisPool.getResource();
    }
}

SpringDataRedis客户端

(推荐使用其中的 StringRedisTemplate )

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

功能:

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

常用API:

API 返回值类型 说明
redisTemplate.opsForValue() ValueOperations 操作String类型
redisTemplate.opsForHash() HashOperations 操作Hash类型
redisTemplate.opsForList() ListOperations 操作List类型
redisTemplate.opsForSet() SetOperations 操作Set类型
redisTemplate.opsForZSet() ZSetOperations 操作SortedSet类型
redisTemplate 通用命令
使用

springboot 已提供对 SpringDataRedis 的支持。

  1. 新建spring项目,勾选lombok,Spring Data Redis (Access+Driver)

  2. 引入依赖:

        <dependencies>
            
            <dependency>
                <groupId>org.springframework.bootgroupId>
                <artifactId>spring-boot-starter-data-redisartifactId>
            dependency>
            
            <dependency>
                <groupId>org.apache.commonsgroupId>
                <artifactId>commons-pool2artifactId>
            dependency>
            
            <dependency>
                <groupId>com.fasterxml.jackson.coregroupId>
                <artifactId>jackson-databindartifactId>
            dependency>
        dependencies>
    
  3. 配置application.yaml

    spring:
      redis:
        host: 192.168.205.129
        port: 6379
        password: zzc
        lettuce:
          pool:
            max-active: 8
            max-idle: 8
            min-idle: 0
            max-wait: 100ms
    
  4. 注入 RedisTemplate,编写测试:

    @SpringBootTest
    class RedisStringTests {
    
        @Autowired
        private RedisTemplate redisTemplate;
        
        @Test
        void testString() {
            // 写入一条String数据
            redisTemplate.opsForValue().set("name", "Tom");
            // 获取string数据
            Object name = stringRedisTemplate.opsForValue().get("name");
            System.out.println("name = " + name);
        }
    }
    
自定义序列化

RedisTemplate可以接收任意Object作为值写入Redis,只不过写入前会把Object序列化为字节形式,默认是采用JDK序列化,最终得到的结果可读性性差,且内存占用也大。

所以使用时,可自定义RedisTemplate的序列化方式:

@Configuration
public class RedisConfig {

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

得到的结果,除了定义的数据外,还有带有对象的class类型。如:

{
	"@class": "com.zzc.redis.pojo.User",
    "name": "Tom",
    "age": 19
}

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

StringRedisTemplate

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

这种用法比较普遍,因此SpringDataRedis就提供了RedisTemplate的子类:StringRedisTemplate,它的key和value的序列化方式默认就是String方式。所以就不用自己去定义RedisTemplate的序列化方式,而是直接使用:

@Autowired
private StringRedisTemplate stringRedisTemplate;
// JSON序列化工具
private static final ObjectMapper mapper = new ObjectMapper();

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

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

实践推荐用法

Redis键值设计

推荐写法

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

例如: 登录业务要保存用户信息: login:user:10

这样设计的好处:

  • 可读性强

  • 避免key冲突

  • 方便管理

  • 更节省内存 —— string类型底层编码有int,embstr,raw三种。 embstr是连续内存空间,小于44字节时使用; raw的内存空间不连续,采用一个指针指向另外片空间, 访问性能略有影响,还可能产生内存碎片。

    使用命令 object encoding key值, 可以查看对应value值的编码。

避免BigKey

key的推荐大小

  • 单个key的value小于10KB。
  • 集合类型的key,建议元素数量小于1000。

BigKey的危害:

  • 网络阻塞
    • 对BigKey执行读请求时,少量的QPS就可能导致带宽使用率被占满,导致Redis实例,乃至所在物理机变慢
  • 数据倾斜
    • BigKey所在的Redis实例内存使用率远超其他实例,无法使数据分片的内存资源达到均衡
  • Redis阻塞
    • 对元素较多的hash、list、zset等做运算会耗时较久,使主线程被阻塞
  • 持久化影响
    • 写AOF日志: 主要看主线程是否调用 fsync()函数进行同步刷盘,让主线程需要等待。
      • Always策略每次写AOF都会执行fsync(),所以bigkey会造成影响;
      • Everysec策略会创建异步任务执行fsync(),bigkey不影响;
      • No策略不会调用fsync(),刷盘时机交给系统,bigkey不影响;
    • AOF重写和写RDB:都是通过fork()函数创建子进程来执行任务,bigkey会影响 “复制页表”和“写时复制" 。
  • CPU压力
    • 对BigKey的数据序列化和反序列化会导致CPU的使用率飙升,影响Redis实例和本机其它应用

发现BigKey:

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

    redis-cli -a 密码 --bigkeys
    
  • scan扫描,自己编程判断key长度。

  • 使用第三方工具,如Redis-Rdb-Tools分析RDB快照文件。

  • 网络监控。

删除BigKey:

  • 在Redis4.0以后,提供了异步删除命令: unlink 。 这样就不会阻塞主线程了。

适合的数据结构

  • 合理的拆分数据,拒绝BigKey
  • 选择合适的数据结构
  • Hash结构的entry数量不要超过1000(不过redis7之后Hash的实现只有quickList,没有zipList和hash了)
  • 设置合理的超时时间

例如:

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

  1. json字符串

    user:1 {“name”: “Jack”, “age”: 21}

    优点:实现简单

    缺点:数据耦合,不够灵活

  2. 字段打散

    user:1:name Jack
    user:1:age 21

    优点:可以灵活访问对象任意字段

    缺点:占用空间大,不能做统一控制

  3. hash (推荐)

    user:1 name jack
    age 21

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

    缺点:代码相对复杂

假如hash类型的key,其数量有100万。

key value
id:0 value0
..... .....
id:999999 value999999

解决: 拆分为小的hash,如将id/100作为key,id%100作为field:

key field value
key:0 id:00 value0
..... .....
id:99 value99
key:1 id:00 value100
..... .....
id:99 value199
....
key:9999 id:00 value999900
..... .....
id:99 value999999

批处理优化

  • redis 一次命令的响应时间 = 1次网络往返 + 1次redis命令执行

  • redis N次命令的响应时间 = N次网络往返 + N次redis命令执行

因为redis处理命令是极快的,所以大部分耗时是发生在网络传输。 所以,可以将多条指令批量传给redis:

  • redis N次命令的响应时间 = 1次网络往返 + N次redis命令执行

单机批处理

redis批量处理的方法: Mxxx命令, Pipeline管道方法

  • Mxxx命令, 如mset, hmset命令, 例子:使用mset批量插入10万条数据

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

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

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

集群批处理

如果Redis是一个集群,那批处理命令的多个key必须落在一个插槽中,否则就会导致执行失败。但问题是,在批处理时,一次插入的很多条数据,很有可能不会都落在相同的节点上,这会导致报错。

一般有四种解决方案:

串行命令 串行slot 并行slot hash_tag
实现思路 for循环命令,依次执行每个命令 在客户端先计算每个key的slot,进行分组,每组再进行批处理。 (串行执行各组命令) 同样将key根据slot分组,但并行执行各组命令 将所有的key设置相同的有效部分,则所有key的slot一定相同
网络耗时 N次 m次, m = 这批key的slot个数 1次 1次
优点 实现简单 耗时较短 耗时非常短 耗时非常短,实现简单
缺点 耗时很久 实现较复杂;且slot越多,耗时越久 实现复杂 容易出现数据倾斜

所以一般选择并行slot。

服务端优化

持久化

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

  • 用来做缓存的Redis实例尽量不要开启持久化功能
  • 建议关闭RDB持久化功能,使用AOF持久化
  • 利用脚本定期在slave节点做RDB,实现数据备份
  • 设置合理的rewrite阈值,避免频繁的bgrewrite
  • 配置no-appendfsync-on-rewrite = yes,禁止在rewrite期间做aof,避免因AOF引起的阻塞
  • 部署有关建议:
    • Redis实例的物理机要预留足够内存,应对fork和rewrite
    • 单个Redis实例内存上限不要太大,例如4G或8G。可以加快fork的速度、减少主从同步、数据迁移压力
    • 不要与CPU密集型应用部署在一起
    • 不要与高硬盘负载应用一起部署。例如:数据库、消息队列

慢查询

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

危害:由于Redis是单线程的,所以当客户端发出指令后,他们都会进入到redis底层的queue来执行,如果此时有一些慢查询的数据,就会导致大量请求阻塞。

慢查询的配置:

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

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

查看慢查询:

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

安全配置

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

而Redis可以免密登录,Redis有一种ssh免秘钥登录的方式,生成一对公钥和私钥,私钥放在本地,公钥放在redis端。 但是Redis的漏洞在于在不登录的情况下,也能把秘钥送到Linux服务器,从而产生漏洞。

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

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

为了避免这样的漏洞,有以下建议:

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

内存划分和配置

当Redis内存不足时, 肯能导致Key被频繁删除,响应时间变长,QPS不稳定等。 当内存使用率达90%以上就要注意了,并定位到内存占用原因。

查看Redis内存分配:

  • info memory:查看内存分配的情况
  • memory xxx:查看key的主要占用情况
内存占用 说明
数据内存 是Redis最主要的部分,存储Redis的键值信息。主要问题是BigKey问题、内存碎片问题
进程内存 Redis主进程本身运行占用的内存,如代码、常量池等; 一般只有几兆,可以忽略。
缓冲区内存 一般包括客户端缓冲区、AOF缓冲区、复制缓冲区等。客户端缓冲区又包括输入缓冲区和输出缓冲区两种。这部分内存占用波动较大,不当使用BigKey,可能导致内存溢出。

其中缓冲区内存 的占用波动较大,是需要重点分析的地方。 常见的内存缓冲区有三种:

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

以上会出问题的是客户端的输出缓冲区,如果Redis需要处理大量的big value,那么会导致 输出结果过多,如果输出缓存区过大,会导致redis直接断开,而默认配置是不限制大小的,导致内存可能一下子被占满,会直接导致redis断开,所以解决方案有两个:

1、设置一个大小

2、增加我们带宽的大小,避免我们出现大量数据从而直接超过了redis的承受能力

推荐使用主从而不是集群

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

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

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

  • 集群带宽问题 —— 集群节点之间会不断的互相Ping来确定集群中其它节点的状态。(ping信息:slot信息 + 集群状态信息, 集群节点越多,ping信息越大, 10节点的信息就可达1kb)

    解决:

    • 避免大集群,节点数应少于1000,如果业务庞大,则建立多个集群。
    • 避免在单个机器上运行太多Redis实例。
    • 配置合适的cluster-node-timeout值。
  • 数据倾斜问题

  • 命令的集群兼容性问题 —— 批处理命令要求key必须落在相同的slot上,解决方法在前面的集群批处理中。

  • lua和事务问题 —— lua和事务都是要保证原子性问题,如果key不在一个节点,那么是无法保证lua的执行和事务的特性的,所以在集群模式是没有办法执行lua和事务的

缓存设计

缓存雪崩

原因一般有两种: 大量数据同时过期,Redis 故障宕机。

  • 当大量缓存数据在同一时间过期时,如果此时有大量的用户请求,都无法在 Redis 中处理,或者Redis宕机了,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃。

    应对方案:

    • 均匀设置过期时间: 我们可以在原有的失效时间基础上增加一个随机值(比如 1 到 10 分钟)这样每个缓存的过期时间都不重复了,也就降低了缓存集体失效的概率。

    • 互斥锁: 如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存,当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。

    • 双key策略: 缓存数据使用两个 key,主 key会设置过期时间,备 key不设置过期,两者只是 key 不一样,但是 value 值是一样的,相当于是副本。 当访问不到主key时,就字节返回 备key,再更新 主key和备key 的数据。

    • 后台更新缓存:业务线程不再负责更新缓存, 缓存也不设有效期, 缓存的更新都交给后台线程定时更新。

  • Redis故障宕机时,应对:

    • 服务熔断或请求限流机制;

      服务熔断就是 暂停业务应用 对缓存服务的访问,直接返回错误,不用再继续访问数据库。

      请求限流就是 只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务。

    • 构建 Redis 主从 或者 高可用集群;

缓存击穿

如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮。(如秒杀活动等)

应对方案:

  • 互斥锁方案(Redis 中使用 setNX 方法设置一个状态位,表示这是一种锁定状态),保证同一时间只有一个业务线程请求缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
  • 设置缓存不过期: 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;

缓存穿透

当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据,一旦缓存恢复相对应的数据,就可以减轻数据库的压力,而缓存穿透就不一样了。

缓存穿透: 当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,也没办法构建缓存数据。那么当有大量这样的请求到来时,数据库的压力就会骤增。

发生缓存穿透的情况一般有两种:

  • 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
  • 黑客恶意攻击,故意大量访问某些读取不存在数据的业务;

应对方案:

  • 非法请求的限制:在 API 入口处判断请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
  • 设置空值或者默认值:针对发生穿透的查询数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。
  • 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在:我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在,即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。

布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。

当我们在写入数据库数据时,在布隆过滤器里进行标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。(会出现误判情况)

流程:

  1. 使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;
  2. 将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置,将对应位置设为 1。

如果考虑删除元素的话,布隆过滤器需要带计数器,需要占用更多空间。

实现延迟队列

实现分布式锁

Redis的SET命令有个 NX参数,表示“key不存在时才插入”,可以用来实现分布式锁:

  • 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
  • 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。

注意点: 需要设置过期时间,以免客户端异常无法解锁; 锁变量对应不同客户端应该是唯一值,用于标识,解锁人必须是加锁人。

加锁命令如下:(PX 10000指过期时间10s)

SET lock_key unique_value NX PX 10000 

解锁时,需要先比较unique_value是否一致,再删除lock_key, 这儿的两个操作需要保证原子性,所以用Lua脚本来自行命令:

// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

使用Redis实现分布式锁,优点:

  • 性能高效。(主要原因)
  • 实现方便。
  • 避免单点故障。(因为 Redis 是跨集群部署的,自然就避免了单点故障)

缺点:

  • 超时时间不好设置,太长会影响性能,太短不能保护共享资源。

    • 合理设置超时时间的建议:

      可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。 不过实现比较复杂。

  • Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。


Redis对集群下分布式锁的可靠性保证的做法:

Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)来保证集群环境下分布式锁的可靠性。

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败

Redlock 算法加锁三个过程:

  • 第一步是,客户端获取当前时间(t1)。
  • 第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作:
    • 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。
    • 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。
  • 第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。

可看出,加锁成功要同时满足两个条件: 超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间。

加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。

加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。

分布式缓存

单机Redis存在有以下问题:

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

持久化

有两种方案:

  • RDB持久化 (Redis Database Backup file,Redis数据备份文件)
  • AOF持久化 (Append Only File, 追加文件)

RDB持久化

RDB,也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。(快照文件称为RDB文件,默认是保存在当前运行目录。)

RDB持久化会在以下四种情况下执行:

  • 执行save命令 —— save命令会导致主进程执行RDB,这个过程中其它所有命令都会被阻塞。(通常只有在数据迁移时可能用到。)

  • 执行bgsave命令 —— bgsave命令会开启独立进程完成RDB,主进程可以持续处理用户请求,不受影响。

  • **Redis停机时 ** —— Redis停机时会执行一次save命令,实现RDB持久化。

  • 触发RDB条件时 —— Redis内部有触发RDB的机制,可以在redis.conf文件中找到:

    # 900秒内,如果至少有1个key被修改,则执行bgsave , 如果是save "" 则表示禁用RDB
    save 900 1  
    #save 300 10  
    #save 60 10000 
    
    # 是否压缩 ,建议不开启,压缩也会消耗cpu,磁盘的话不值钱
    rdbcompression yes
    
    # RDB文件名称
    dbfilename dump.rdb  
    
    # 文件保存的路径目录
    dir ./ 
    

RDB的bgsave命令原理:

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

fork采用的是copy-on-write技术(写时复制,即内存数据只能被读,要写入的话需要先拷贝一份,然后写入拷贝的数据中)

  • 当主进程执行读操作时,访问共享内存;
  • 当主进程执行写操作时,则会拷贝一份数据,执行写操作。(此时的修改不是发生在共享的内存块中,所以没法被子进程读取到,所以只能等下一次bgsave)

Redis学习笔记_第3张图片

AOF持久化

AOF,即 追加文件。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。

如:

set num 1234

记在AOF文件是:

$3
set
$3
num
$4
1234

配置:

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

# 是否开启AOF功能,默认是no
appendonly yes
# AOF文件的名称
appendfilename "appendonly.aof"

#记录的频率:
# 表示每执行一次写命令,立即记录到AOF文件
appendfsync always 
# 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec 
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no

三种记录频率对比:

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

AOF文件重写 —— bgrewriteaof

由于是记录命令,AOF文件比RDB文件大的多;而且对同一个key的多次写操作,只有最后一次写操作有意义。

所以,通过 bgrewriteaof 命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。

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

# AOF文件比上次文件 增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写 
auto-aof-rewrite-min-size 64mb 

AOF和RDB对比

RDB AOF
持久化方式 定时对整个内存做快照 记录每一次执行的命令
数据完整性 不完整,两次备份之间会丢失 相对完整,取决于刷盘策略
文件大小 会压缩,文件体积小 记录命令,文件体积很大
宕机恢复速度 很快
数据恢复优先级 低,因为数据完整性不如AOF 高,因为数据完整性更高
系统资源占用 高,大量CPU和内存消耗 低,主要是磁盘IO资源;但AOF重写时会占用大量CPU和内存资源
使用场景 可以容忍数分钟的数据丢失,追求更快的启动速度 对数据安全性要求较高时

主从集群

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

Redis学习笔记_第4张图片

主从数据同步原理

全量同步

主从第一次建立连接,会执行一次全量同步,将master节点的所有数据都拷贝给slave节点,

执行时机:

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

流程如下:

  • slave节点请求增量同步
  • master节点进行判断,如果replid不一致,或者offset已被覆盖,拒绝增量同步;发送版本信息,开始全量同步
  • master将完整内存数据生成RDB,发送RDB到slave
  • slave清空本地数据,加载master的RDB
  • master将RDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slave
  • slave执行接收到的命令,保持与master之间的同步

Redis学习笔记_第5张图片

master如何得知salve是第一次来连接:

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

因为slave原本也是一个master,有自己的replid和offset,当第一次变成slave,与master建立连接时,发送的replid和offset是自己的replid和offset。

master判断发现slave发送来的replid与自己的不一致,说明这是一个全新的slave,就知道要做全量同步了。

master会将自己的replid和offset都发送给这个slave,slave保存这些信息。以后slave的replid就与master一致了。

增量同步

增量同步,就是只更新slave与master存在差异的部分数据。

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

Redis学习笔记_第6张图片

要点:repl_backlog文件,是一个固定大小的环形数组。

repl_baklog中会记录Redis处理过的命令日志及offset,包括master当前的offset 和slave的offset。slave与master的offset之间的差异,就是salve需要增量拷贝的数据了。

不过,如果slave节点断开了,时间一久,master继续写入新数据,其offset就会覆盖旧的数据,直到将slave现在的offset也覆盖。 即尚未同步的数据被覆盖了,slave恢复后,发现自己的offset没有了,就只能做全量同步了。

Redis学习笔记_第7张图片

优化

可以从以下几个方面来优化Redis主从就集群:

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

主从从架构图:

Redis学习笔记_第8张图片

搭建主从集群

在一台虚拟机上开启3个实例,需要准备3份不同的配置文件和目录。

  1. 创建目录,分别为7001,7002,7003:

    # 进入/tmp/redis-test目录
    cd /tmp/redis-test
    # 创建目录
    mkdir 7001 7002 7003
    
  2. 拷贝配置文件到每个实例目录:

    # 方式一:逐个拷贝
    cp /usr/local/src/redis-6.2.6/redis.conf 7001
    cp /usr/local/src/redis-6.2.6/redis-6.2.4/redis.conf 7002
    cp /usr/local/src/redis-6.2.6/redis-6.2.4/redis.conf 7003
    
    # 方式二:管道组合命令,一键拷贝
    echo 7001 7002 7003 | xargs -t -n 1 cp /usr/local/src/redis-6.2.6/redis-6.2.4/redis.conf
    
  3. 修改每个实例的端口、工作目录

    sed -i -e 's/6379/7001/g' -e 's/dir .\//dir \/tmp\/7001\//g' 7001/redis.conf
    sed -i -e 's/6379/7002/g' -e 's/dir .\//dir \/tmp\/7002\//g' 7002/redis.conf
    sed -i -e 's/6379/7003/g' -e 's/dir .\//dir \/tmp\/7003\//g' 7003/redis.conf
    
  4. 修改每个实例的声明ip

    虚拟机本身有多个IP,为了避免将来混乱,我们需要在redis.conf文件中指定每一个实例的绑定ip信息,格式如下:

    # redis实例的声明 IP
    replica-announce-ip 192.168.205.129
    

    可以用命令完成:

    # 逐一执行
    sed -i '1a replica-announce-ip 192.168.205.129' 7001/redis.conf
    sed -i '1a replica-announce-ip 192.168.205.129' 7002/redis.conf
    sed -i '1a replica-announce-ip 192.168.205.129' 7003/redis.conf
    
    # 或者一键修改
    printf '%s\n' 7001 7002 7003 | xargs -I{} -t sed -i '1a replica-announce-ip 192.168.205.129' {}/redis.conf
    
  5. 启动

    # 第1个
    redis-server 7001/redis.conf
    # 第2个
    redis-server 7002/redis.conf
    # 第3个
    redis-server 7003/redis.conf
    
  6. 开启主从关系:

    • 修改配置文件(永久生效):

      在redis.conf添加配置:slaveof 主节点ip 主节点端口

    • 连接服务,执行slaveof命令(临时生效):

      slaveof 主节点ip  主节点端口
      

    例如:

    通过redis-cli命令连接7002,执行命令:

    # 连接 7002
    redis-cli -p 7002
    # 执行slaveof
    slaveof 192.168.205.129 7001
    

    通过redis-cli命令连接7003,执行命令:

    # 连接 7003
    redis-cli -p 7003
    # 执行slaveof
    slaveof 192.168.205.129 7001
    

    然后连接 7001节点,查看集群状态:

    # 连接 7001
    redis-cli -p 7001
    # 查看状态
    info replication
    

哨兵

Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复

哨兵的作用如下:

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

哨兵结构:

Redis学习笔记_第9张图片

集群的监控与故障恢复

集群监控:

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

  • 主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线

  • 客观下线:若超过指定数量的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。


集群故障恢复:

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

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

选出一个新的master后,还要实现主从切换:

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

Sentinel集群选出Leader

当 sentinel 集群确认有 master 客观下线了,就会开始故障转移流程,故障转移流程的需要在 sentinel 集群选择一个 leader,让 leader 来负责完成故障转移。 故障转移完成后,所有Sentinel又会恢复平等。(Leader仅仅是为故障转移操作出现的角色。)

一般使用分布式领域的共识算法来选出leader, Redis是使用Raft算法的领头选举方法 在sentinel集群中选出leader。

Leader: sentinel中负责进行故障转移的角色。

Follower:进行投票的角色。

Candidate:进行选举的角色。

epoch: 年代,相当于Raft算法中的term。 Sentinel集群正常运行的时候每个节点epoch相同。 Follower想要进行选举时,会转换状态为Candidate,并让自己的epoch + 1。

流程:

  1. 某个Sentinel认定master客观下线的节点后,该Sentinel会先看看自己有没有投过票,如果自己已经投过票给其他Sentinel了,在2倍故障转移的超时时间自己就不会成为Leader。相当于它是一个Follower。

  2. 如果该Sentinel还没投过票,那么它就成为Candidate, 并进行以下操作:

    • 1)更新故障转移状态为start。
    • 2)当前epoch加1,相当于进入一个新term。
    • 3)更新自己的超时时间为当前时间随机加上一段时间,随机时间为1s内的随机毫秒数。(防止多轮选举都拿不到一半以上的票数)
    • 4)向其他节点发送is-master-down-by-addr命令请求投票。命令会带上自己的epoch。
    • 5)给自己投一票,在Sentinel中,投票的方式是把自己master结构体里的 leader和 leader_epoch改成投给的Sentinel和它的epoch。
  3. 其他Sentinel会收到Candidate的is-master-down-by-addr命令。如果Sentinel当前epoch和Candidate传给他的epoch一样,说明他已经把自己master结构体里的leader和leader_epoch改成其他Candidate,相当于把票投给了其他Candidate。投过票给别的Sentinel后,在当前epoch内自己就只能成为Follower。

  4. Candidate会不断的统计自己的票数,直到他发现认同他成为Leader的票数超过一半而且超过它配置的quorum。Sentinel比Raft协议增加了quorum,这样一个Sentinel能否当选Leader还取决于它配置的quorum。

  5. 如果在一个选举时间内,Candidate没有获得超过一半且超过它配置的quorum的票数,自己的这次选举就失败了。

  6. 如果在一个epoch内,没有一个Candidate获得更多的票数。那么等待超过2倍故障转移的超时时间后,Candidate增加epoch重新投票。

  7. 如果某个Candidate获得超过一半且超过它配置的quorum的票数,那么它就成为了Leader。

  8. 与Raft协议不同,Leader并不会把自己成为Leader的消息发给其他Sentinel。其他Sentinel等待Leader从slave选出master后,检测到新的master正常工作后,就会去掉客观下线的标识,从而不需要进入故障转移流程。

RedisTemplate集成哨兵机制

要写明哨兵的信息:

spring:
  redis:
    sentinel:
      master: mymaster
      nodes:
        - 192.168.205.129:27001
        - 192.168.205.129:27002
        - 192.168.205.129:27003

在项目的启动类中,配置读写分离:

@Bean
public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){
    return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}

这个bean中配置的就是读写策略,包括四种:

  • MASTER:从主节点读取
  • MASTER_PREFERRED:优先从master节点读取,master不可用才读取replica
  • REPLICA:从slave(replica)节点读取
  • REPLICA _PREFERRED:优先从slave(replica)节点读取,所有的slave都不可用才读取master

搭建哨兵集群

分片集群

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

  • 海量数据存储问题

  • 高并发写的问题

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

  • 集群中有多个master,每个master保存不同数据

  • 每个master都可以有多个slave节点(至少有一个slave)

  • master之间通过ping监测彼此健康状态

  • 客户端请求可以访问集群任意节点,最终都会被转发到正确节点

结构如图:

Redis学习笔记_第10张图片

散列插槽

Redis会把每一个master节点映射到 0~16383 共16384个插槽(hash slot)上,数据key不是与节点绑定,而是与插槽绑定。redis会根据key的有效部分计算插槽值,分两种情况:

  • key中包含"{}",且“{}”中至少包含1个字符,“{}”中的部分是有效部分
  • key中不包含“{}”,整个key都是有效部分

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

提问:

Redis如何判断某个key应该在哪个实例?

  • 将16384个插槽分配到不同的实例
  • 根据key的有效部分计算哈希值,对16384取余
  • 余数作为插槽,寻找插槽所在实例即可

公式:HASH_SLOT = CRC16(key) % NUMER_OF_SLOTS

CRC16 算法产生的校验码有 16 位,理论上可以产生 65536(2^16,0 ~ 65535)个值。

如何将同一类数据固定的保存在同一个Redis实例?

  • 这一类数据使用相同的有效部分,例如key都以{typeId}为前缀

哈希槽为什么是16384个?

  • 正常的心跳包会携带一个节点的完整配置,它会以幂等的方式更新旧的配置,这意味着心跳包会附带当前节点的负责的哈希槽的信息。假设哈希槽采用 16384 ,则占空间 2kb (16384/8)。假设哈希槽采用 65536, 则占空间 8kb (65536/8),这是令人难以接受的内存占用。
  • 由于其他设计上的权衡,Redis Cluster 不太可能扩展到超过 1000 个主节点。

集群伸缩

集群伸缩:

  • 添加一个新节点到集群中
  • 分配部分插槽给新节点
  1. 创建一个新的redis实例,假设端口为7004:

    新建一个文件夹,修改 redis.conf 配置文件,启动。

  2. 添加该新节点到集群:

    redis-cli --cluster add-node  192.168.205.129:7004 192.168.205.129:7001
    

    192.168.205.129:7001是集群中的一个节点,只要告知集群中的一个节点,其他节点也会知道。

    查看集群状态:

    redis-cli -p 7001 cluster nodes
    
  3. 转移插槽,假设将0~3000的插槽从7001转移到7004:

    在7001节点建立连接;

    redis-cli --cluster reshard 192.168.205.129:7001
    

    输入要转移到的插槽;

    输入要接收插槽的节点id;(显示集群状态时开头的一长串字符就是id)

    询问插槽从哪里来的:

    • all:代表全部,也就是三个节点各转移一部分
    • 具体的id:目标节点的id
    • done:没有了

集群扩容缩容期间仍可以提供服务: 这本质上就是进行重新分片,动态迁移哈希槽。 Redis Cluster提供了两种重定向机制:

  • ASK 重定向: 临时重定向,后续查询仍发送到旧节点。
  • MOVED 重定向: 永久重定向,后续查询发送到新节点,并更新客户端缓存。

具体过程:

  1. 如果请求的key对应的哈希槽还在当前节点的话,就直接响应客户端的请求。
  2. 如果请求的key对应的哈希槽在迁移过程中,但是请求的key还未迁移走的话,说明当前节点任然可以处理当前请求,同样可以直接响应客户端的请求。
  3. 如果客户端请求的key对应的哈希槽当前正在迁移至新的节点且请求的key已经被迁移走的话,就会返回 -ASK重定向错误,告知客户端要将请求发送到哈希槽被迁移到的目标节点。-ASK重定向错误信息中包含请求key迁移到的新节点的信息。
  4. 客户端收到 -ASK重定向错误后,将会临时(一次性)重定向,自动向新节点发送一条ASKING命令。
  5. 新节点在收到ASKING命令后可能会返回 重试错误 (TRYAGAIN),因为可能存在当前请求的key还在导入中单未导入完成的情况。
  6. 客户端发送真正需要请求的命令。
  7. ASK重定向并不会同步更新客户端缓存的哈希槽分配信息,也就是说,客户端对正在迁移的相同哈希槽的请求依然会发送到旧节点而不是新节点。
  8. 如果客户端请求的key对应的哈希槽已经迁移完成的话,就会返回 -MOVED重定向错误,告知客户端当前哈希槽是由哪个节点负责,客户端向新节点发送请求并更新缓存的哈希槽分配信息,后续查询将被发送到新节点。

节点通信

Redis Cluster的各个节点基于 Gossip协议 进行通信共享信息,每个节点都维护一份集群的状态信息。

Redis Cluster的节点之间会相互发送多种Gossip消息:

  • MEET

    在Redis Cluster中的某个Redis节点上执行 CLUSTER MEET ip port 命令,可以向指定的Redis节点发送一条MEET信息,用于将其添加进Redis Cluster成为新的Redis节点。

  • PING/PONG

    Redis Cluster中的节点都会定时地向其他节点发送PING消息,来交换各个节点状态信息,检查各个节点状态,包括在线状态、疑似下线状态PFAL和已下线状态 FAIL。

  • FAIL

    Redis Cluster中的节点A发现B节点PFALL,并且在下线报告的有效期限内集群中半数以上的节点将B节点标记为PFALL,节点A就会向集群广播一条FALL消息,通知其他节点将故障节点B标记为FALL。

有了Redis Cluster之后,不需要专门部署Sentinel集群服务了。Redis Cluster相当于是内置了Sentinel机制,Redis Cluster内部的各个Redis节点通过Gossip协议互相探测健康状态,在故障时可以自动切换。

故障转移

  • 自动故障转移:

    当集群中有一个master宕机,该实例与其它实例失去连接,集群中它的状态:

    1. 疑似宕机;
    2. 确定下线,自动提升一个slave为新的master;
    3. 当再次上线时,该节点就变成一个slave节点;
  • 手动故障转移:

    在一个slave执行cluster failover命令可以手动让集群中的某个master宕机,然后该slave节点转变为master节点。

    cluster failover命令流程:

    1. slave告知master拒绝任何客户端请求;
    2. master返回当前数据offset给slave;
    3. 当slave的offset与master一致后,两者开始进行故障转移;
    4. slave标记自己为master,并广播故障转移的结果,其他slave和旧master收到广播后会设置新的master。

RedisTemplate访问分片集群

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

1)引入redis的starter依赖

2)配置分片集群地址

3)配置读写分离

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

spring:
  redis:
    cluster:
      nodes:
        - 192.168.205.129:7001
        - 192.168.205.129:7002
        - 192.168.205.129:7003
        - 192.168.205.129:8001
        - 192.168.205.129:8002
        - 192.168.205.129:8003

多级缓存

概述

传统的缓存策略一般是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库;这样做的问题:

  • 请求要经过Tomcat处理,Tomcat的性能成为整个系统的瓶颈

  • Redis缓存失效时,会对数据库产生冲击


多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻Tomcat压力,提升服务性能:

  • 浏览器访问静态资源时,优先读取浏览器本地缓存
  • 访问非静态资源(ajax查询数据)时,访问服务端
  • 请求到达Nginx后,优先读取Nginx本地缓存
  • 如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat)
  • 如果Redis查询未命中,则查询Tomcat
  • 请求进入Tomcat后,优先查询JVM进程缓存
  • 如果JVM进程缓存未命中,则查询数据库

——

在多级缓存架构中,Nginx内部需要编写本地缓存查询、Redis查询、Tomcat查询的业务逻辑,因此这样的nginx服务不再是一个反向代理服务器,而是一个编写业务的Web服务器了

因此这样的业务Nginx服务也需要搭建集群来提高并发,再有专门的nginx服务来做反向代理,

另外,我们的Tomcat服务将来也会部署为集群模式

——

可见,多级缓存的关键有两个:

  • 一个是在nginx中编写业务,实现nginx本地缓存、Redis、Tomcat的查询

  • 另一个就是在Tomcat中实现JVM进程缓存

Redis学习笔记_第11张图片

JVM进程缓存

缓存在日常开发中启动至关重要的作用,由于是存储在内存中,数据的读取速度是非常快的,能大量减少对数据库的访问,减少数据库的压力。我们把缓存分为两类:

  • 分布式缓存,例如Redis:
    • 优点:存储容量更大、可靠性更好、可以在集群间共享
    • 缺点:访问缓存有网络开销
    • 场景:缓存数据量较大、可靠性要求较高、需要在集群间共享
  • 进程本地缓存,例如HashMap、GuavaCache:
    • 优点:读取本地内存,没有网络开销,速度更快
    • 缺点:存储容量有限、可靠性较低、无法共享
    • 场景:性能要求较高,缓存数据量较小

Caffeine是一个基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库。目前Spring内部的缓存使用的就是Caffeine。

使用:

@Test
void testBasicOps() {
    // 构建cache对象
    Cache<String, String> cache = Caffeine.newBuilder().build();

    // 存数据
    cache.put("gf", "小明");

    // 取数据
    String gf = cache.getIfPresent("gf");
    System.out.println("gf = " + gf);

    // 取数据,包含两个参数:
    // 参数一:缓存的key
    // 参数二:Lambda表达式,表达式参数就是缓存的key,方法体是查询数据库的逻辑
    // 优先根据key查询JVM缓存,如果未命中,则执行参数二的Lambda表达式
    String defaultGF = cache.get("defaultGF", key -> {
        // 根据key去数据库查询数据
        return "小红";
    });
    System.out.println("defaultGF = " + defaultGF);
}

缓存清除 —— Caffeine提供了三种缓存驱逐策略:

  • 基于容量:设置缓存的数量上限

    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder()
        .maximumSize(1) // 设置缓存大小上限为 1
        .build();
    
  • 基于时间:设置缓存的有效时间

    // 创建缓存对象
    Cache<String, String> cache = Caffeine.newBuilder()
        // 设置缓存有效期为 10 秒,从最后一次写入开始计时 
        .expireAfterWrite(Duration.ofSeconds(10)) 
        .build();
    
    
  • 基于引用:设置缓存为软引用或弱引用,利用GC来回收缓存数据。性能较差,不建议使用。

注意:在默认情况下,当一个缓存元素过期的时候,Caffeine不会自动立即将其清理和驱逐。而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐。

实现多级缓存

多级缓存的实现离不开Nginx编程,而Nginx编程又离不开OpenResty。

OpenResty® 是一个基于 Nginx的高性能 Web 平台,用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。具备下列特点:

  • 具备Nginx的完整功能
  • 基于Lua语言进行扩展,集成了大量精良的 Lua 库、第三方模块
  • 允许使用Lua自定义业务逻辑自定义库

官方网站: https://openresty.org/cn/

在多级缓存架构中, 我们想要一台nginx服务器存放静态资源,并做反向代理到OpenResty集群(即nginx集群),在nginx集群做缓存业务(lua脚本实现)。

缓存同步

Redis原理

Redis数据类型

Redis3.0时的实现:

  • String —— SDS
  • List —— 双向链表,ziplist
  • Hash —— ziplist,哈希表
  • Set —— 哈希表,整数集合
  • Zset —— ziplist, 跳表

Redis7.0时的实现:

  • String —— SDS
  • List —— quicklist
  • Hash —— listpack,哈希表
  • Set —— 哈希表,整数集合
  • Zset —— listpack, 跳表

redis3.2,List 的底层实现改为 quicklist。

redis5.0,引入listpack,redis7.0,Hash和ZSet的底层实现的ziplist替换为listpack

数据结构

键值对数据库

Redis使用一个 [哈希表] 来保存所有键值对,能以O(1)的速度查找键值对。 这个哈希表其实就是一个数组,数组中的元素叫做哈希桶。

  1. 哈希桶存放的是指向键值对数据的指针(dictEntry*),这样通过指针就能找到键值对数据。
  2. 键值对的数据结构中并不是直接保存值本身,而是保存了 void * key 和 void * value 指针,分别指向了实际的键对象和值对象。 (键对象和值对象都是指 RedisObject对象)

redisObject构成:

  • int4 type : 数据结构类型,如:redis-string。 4bits
  • int4 encoding: 编码,同一type的不同存储方式。 4bits
  • int24 lru: 记录对象的LRU信息。24bits
    • 高16bit 存储 访问时间戳; 低8bit存储 访问频次
  • int 32 refcount: 引用计数。 4bytes
    • 创建一个新对象时,refcount = 1; 对象被其他程序使用 refcount + 1; 不再被使用 refcount - 1; 当refcount == 0时,对象将被回收。
  • void* ptr: 指针,指向对象数据的具体存储位置。8bytes
SDS

Redis虽然是用C语言实现的,但它没有用C语言的char*字符数组来实现字符串,而是自己封装了一个字符串,叫 简单动态字符串(simple dynamic string,SDS)。

C的char*数组的缺点:

  • 获取字符串长度的时间复杂度为 O(N);
  • 字符串的结尾是以 “\0” 字符标识,字符串里面不能包含有 “\0” 字符,因此不能保存二进制数据;
  • 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;

SDS的结构

  • len: 字符串长度,获取字符串长度只需O(1);
  • alloc: 分配的空间长度,可以通过alloc - len 算出剩余空间大小,如果空间不足,SDS会自动扩容,不会发生缓冲区溢出;
  • flags:SDS类型,分别是sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。
  • buf[]:字节数组,可以保存字符串或二进制数据。

SDS的扩容规则:(newlen为扩容后至少需要的长度)

  • 如果所需的 SDS 长度小于 1 MB,那么翻倍扩容,长度为 newlen * 2。
  • 如果所需的 SDS 长度超过 1 MB,那么最后的扩容长度应该是 newlen + 1MB

SDS节省内存空间:

  • flags的几种类型,区别在于使结构中的 len 和 alloc 变量的数据类型不同

    如:sdshdr16 类型的 len 和 alloc 的数据类型都是 uint16_t,表示字符数组长度和分配空间大小不能超过 2 的 16 次方。 (sdshdr32 则都是 uint32_t)

  • 在struct 声明了 __attribute__ ((packed)) ,作用是:告诉编译器 取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐

    如果使用对齐方式,假设结构体中有有一个1字节的char 和 一个4字节的int,最终占用为8字节,char会和int对齐,也分配4字节。

双向链表

链表节点 listNode:

typedef struct listNode {
    struct listNode *prev;	//前置节点
    struct listNode *next;  //后置节点
    void *value;	//节点的值
} listNode;

双向链表 list:

typedef struct list {
    //链表头节点
    listNode *head;
    //链表尾节点
    listNode *tail;
    //节点值复制函数
    void *(*dup)(void *ptr);
    //节点值释放函数
    void (*free)(void *ptr);
    //节点值比较函数
    int (*match)(void *ptr, void *key);
    //链表节点数量
    unsigned long len;
} list;
压缩列表

压缩列表(ziplist) 被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。

所以,List、Hash、Zset在元素数量小于512个,元素大小小于64字节时,都会使用压缩列表。(后被listpack替代)

缺点:

  • 不能保存过多的元素,否则查询效率就会降低;
  • 新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发连锁更新的问题。

结构:由连续内存块组成的顺序型数据结构,类似数组

  • zlbytes,记录整个压缩列表占用内存的字节数;
  • zltail,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;
  • zllen,记录压缩列表包含的节点数量;
  • 中间放数据,每个节点的结构为:
    • prevlen,记录前一个节点的长度,实现从后向前遍历;(前节点小于254字节则用1字节来记录,大于254则用5字节来记录)
    • encoding,记录当前节点的类型和长度;
    • data,实际数据;
  • zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)。

查找节点数据的时间复杂度为 O(N),因为每个节点的类型都可能不同,不过查第一个和最后一个节点的时间复杂度为O(1)。

连锁更新问题:

  • ziplist新增或修改某个元素时, 如果空间不够,ziplist需要重新分配内存空间。

    而如果新加入元素较大,可能导致下一个元素的prevlen占用空间由1字节变为5字节,使下个元素也要重新分配空间,如果多的4字节使下个元素占用超过254字节,又使后面元素的prevlen变化,也要重新分配…,这种特殊情况下的连续多次空间扩张 就是连锁更新。

哈希表

Redis采用链式哈希来解决哈希冲突。

哈希表结构:

typedef struct dictht {
    dictEntry **table;	//哈希表数组
    unsigned long size;   //哈希表大小
    unsigned long sizemask;	 //哈希表大小掩码,用于计算索引值
    unsigned long used;	 //该哈希表已有的节点数量
} dictht;

哈希表节点结构:

typedef struct dictEntry {
    void *key;	// 键
  
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    //指向下一个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

rehash

当哈希表快放满时,为了避免过多的哈希冲突,会进行rehash。

rehash的触发条件如下:

  • 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。
  • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。

负载因子 = 哈表表节点数 / 哈希表大小

redis实际使用哈希表时,会定义一个dict结构体,里面再定义两个哈希表,第二个哈希表的*table平时是null,只在rehash时使用。

typedef struct dict {//两个Hash表,交替使用,用于rehash操作
    dictht ht[2];} dict;

rehash过程:

  1. 给「哈希表 2」 分配空间,一般会比「哈希表 1」 大 2 倍;
  2. 将「哈希表 1 」的数据迁移到「哈希表 2」 中;
  3. 迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。

不过在上述第二步的拷贝数据的过程,如果数据量很大,会影响Redis的性能,所以Redis采用渐进式rehash

  1. 给「哈希表 2」 分配空间;
  2. 在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上
  3. 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。

在渐进式rehash中,两个表都有数据,所以会先到 「哈希表 1」查找,再到「哈希表 2」找。 而新增数据只在「哈希表 2」进行。

整数集合

当Set只包含整数值,且元素数量不大时,就会使用整数集合这个数据结构作为底层实现。

整数集合本质上是一块连续内存空间,结构如下:

typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;

contents数组的元素类型取决于encoding的值。(INTSET_ENC_INT16,INTSET_ENC_INT32,INTSET_ENC_INT64 对应 int16_t,int32_t,int64_t)

升级操作:

整数集合会有一个升级规则,就是当我们将一个新元素加入到整数集合里面,这个新元素的类型(int32_t)比数组里所有元素的类型(int16_t)都要长时,就进行升级。

升级过程不会分配新数组,而是在原本的数组上扩展空间,从后往前将原数据放到正确位置,最后放新加入元素。

(整数集合只能升级,不能降级)

跳表

Zset的底层实现用到了跳表,具体的说是 跳表 + 哈希表,但其中的哈希表只是用于以O(1)速度获取元素权重, 其他操作都是由跳表实现的。

Zset的结构:

typedef struct zset {
    dict *dict;		// 哈希表
    zskiplist *zsl;	// 跳表
} zset;

跳表,是在链表的基础上改进而来,是一种“多层”的的有序链表,优点在于能快速定位数据:O(logN)。

Zset的跳表节点如下:

typedef struct zskiplistNode {
    //Zset 对象的元素值
    sds ele;
    //元素权重值
    double score;
    //后向指针
    struct zskiplistNode *backward;
  
    //节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

跨度span 可以计算该节点在跳表中的排位。

跳表结构如下:

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;	// 跳表长度
    int level;	// 跳表的最大层数
} zskiplist;

一般来说,相邻两层的节点数量的比例最好为 2 : 1,这样的跳表的查询复杂度可以降低到O(logN)。 不过,在新增或删除节点时,要调整跳表节点以维持比例的方法的话,会带来额外的开销。所以,Redis使用了一种巧妙的方法:

跳表在创建节点的时候,随机生成每个节点的层数,并不严格维持相邻两层的节点数量比例为 2 : 1 的情况。

具体做法: 跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数

注: 跳表的头节点的层数为该跳表的最大层高,Redis 7.0 默认为 32层,Redis 5.0 为 64。

quicklist

quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。 quicklist通过控制 节点中的压缩列表的大小或者元素个数,来减小连锁更新的危害。

在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 。

quicklist的节点结构:

typedef struct quicklistNode {
    struct quicklistNode *prev;     //前一个quicklistNode
    struct quicklistNode *next;     //后一个quicklistNode
    unsigned char *zl;        //quicklistNode指向的压缩列表
    unsigned int sz;     //压缩列表的的字节大小           
    unsigned int count;      //ziplist中的元素个数 
    ....
} quicklistNode;

quicklist结构:

typedef struct quicklist {
    quicklistNode *head;      //quicklist的链表头
    quicklistNode *tail; 	//quicklist的链表尾
    unsigned long count;	//所有压缩列表中的总元素个数
    unsigned long len;     //quicklistNodes的个数
    ...
} quicklist;
listpack

quicklist还是使用了ziplist来保存元素,所以连锁更新的问题仍然存在。 为了替代ziplist,Redis 在 5.0 新设计一个数据结构叫 listpack, 其节点不再包含前一个节点的长度。(ziplist因为要保存前一个节点的长度,才会有连锁更新问题)

listpack结构:

  • 总字节数
  • 总元素数量
  • 元素
    • encoding
    • data
    • len
  • 结尾标识

虽然没有了prevlen,但 listpack仍能向前遍历, 从当前项的起始位置开始,向左解析,就可以得到前一项的元素的 len 了。

数据类型

String类型实现

String类型是由 int 和 SDS(简单动态字符串)实现的。

  • SDS不仅可以保存文本数据,还可以保存二进制数据。 因为SDS使用len属性来判断字符串结束(获取长度的时间复杂度为O(1)),且所有API都是以处理二进制的方式来处理其中的buf[]数组的。
  • SDS的API是安全的,拼接字符串前会检查SDS空间是否满足要求,会自动扩容。

如果字符串对象保存的是整数值,并可以用long表示,那么,redisObject的encoding设为 int, ptr 设为该整数值(void*转换为long)。

如果字符串对象保存的是字符串,且长度小于44字节,那么,redisObject将使用SDS来保存字符串,encoding设为 embstr。 (redisObject 和 SDS 一起分配内容,它们在一块连续内存中)

如果字符串对象保存的是字符串,且长度大于44字节,那么,redisObject将使用SDS来保存字符串,encoding设为 raw。 (redisObject 和 SDS 各自分配内存,要调用两次内存分配)

  • embstr 优点: 只用分配一次内存,也只用释放一次内存;redisObject 和数据放在一起,能更好的利用CPU缓存提升性能。

    缺点: embstr编码的字符串是只读的,不能修改。只能转换为raw再执行修改命令。(整个redisObject和sds都需要重新分配空间)

List类型实现

Redis3.2以前,List 类型的底层数据结构是由双向链表或压缩列表实现的:

  • 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;
  • 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;

在Redis 3.2 版本,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。

Hash类型实现

Hash 类型的底层数据结构是由压缩列表或哈希表实现的:

  • 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;
  • 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。

在Redis 7.0,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

Set类型实现

Set 类型的底层数据结构是由哈希表或整数集合实现的:

  • 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
  • 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。
Zset类型实现

Zset 类型的底层数据结构是由压缩列表或跳表实现的:

  • 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
  • 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;

在 Redis 7.0,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

线程模型

Redis 单线程指的是: 接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端。 这个过程是由**一个线程(主线程)**来完成的。

后台线程

不过,Redis并不是单线程的,Redis启动时,还会启动后台线程(BIO):

  • Redis2.6, 会启动2个后台线程,处理 关闭文件、AOF刷盘 任务。

  • Redis4.0,新增一个后台线程,进行 异步释放Redis内存,即 lazyfree线程

    例如 执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,这样就不会阻塞 Redis主线程。

    因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,应该使用 unlink 命令来异步删除大key。

这些后台线程处理的任务都是很耗时的任务,交给主线程处理很容易发生阻塞。 后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务去执行。

  • 关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:

    • BIO_CLOSE_FILE: 关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭;

    • BIO_AOF_FSYNC:AOF刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘,

    • BIO_LAZY_FREE: lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象;

单线程

在Redis6.0之前,Redis的网络I/O和执行命令都是单线程。

组成:

  • 一个 listen-scoket,多个 client-socket。
  • 一个I/O多路复用器(slect/epoll) 监听多个socket,将发起请求的socket信息压入socket队列。
  • socket队列
  • 主事件循环函数:
    • 事件分发器: 每次从socket队列中取出一个socket信息,将事件分派给对应的事件处理器
      • 连接事件 处理函数
      • 读事件 处理函数
      • 写事件 处理函数
    • 发送队列 处理函数

流程:

  1. 初始化:

    服务端启动后,会创建一个 listen-socket, 绑定服务端的IP和port,并进入监听状态。

    与客户端建立连接:

    客户端请求连接时,会创建 connect-socket, 向listen-socket 发起连接请求。 当两者成功连接后(TCP3次握手成功),服务端会为已连接的客户端创建一个 代表该客户端的client-socket,用于与客户端通信。

  2. 初始化完成后,主线程进入事件循环函数中:

    调用 epoll_wait 函数 等待事件到来:

    • 如果是连接事件,则调用连接事件处理函数: 调用accept获取已连接socket —— 调用epoll_crl将已连接的socket加入到epoll —— 注册 读事件处理函数。
    • 如果是读事件,则会调用读事件处理函数: 调用read获取客户端发送的数据 —— 解析命令 —— 处理命令 —— 将客户端对象添加到发送队列 —— 将执行结果写到缓存区等待发送。
    • 如果是写事件,则调用写事件处理函数:通过write函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没发送完,则继续注册写事件处理函数,等待epoll_wait 发现可写后,再次发送。

多线程

Redis6.0改成 多线程 处理网络IO,默认只有写请求是多线程的,读请求和执行命令仍是单线程。

配置文件Redis.conf,相关配置项:

// 读请求也使用io多线程
io-threads-do-reads yes

// io-threads N,表示启用 N-1 个 I/O 多线程(主线程也算一个 I/O 线程)
io-threads 4 

关于线程数的选择,官方建议4核CPU设置为2或3,8核CPU设置为6, 线程数一定要小于机器核数。

因此,Redis6.0之后,Redis在启动时,默认会额外创建6个线程(1个主线程 + 6个线程 )

  • Redis-server : Redis的主线程,主要负责执行命令;
  • bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务;
  • io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。

过期数据删除

Redis会把设置了过期时间的key存储到一个 过期字典(expires dict)中,也就是说过期字典保存了所有key的过期时间。

当查询一个key时,会先检查该key是否存在于过期字典:

  • 如果不存在,则正常读取键值;
  • 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,判断是否过期。

Redis中采用的过期数据的删除策略有两种: 定期删除 + 惰性删除

  • 惰性删除:只在去除key的时候进行过期检查。 对CPU友好,但会使很多过期key留存。
  • 定期删除:每隔一段时间就抽取一批key执行过期检查。Reids底层限制删除操作的执行时长和频率来减少对CPU的影响。

Redis持久化,对过期键的处理:

  • RDB生成阶段: 会对key进行过期检查,所以过期key不会被保存到新生成的RDB文件中。
  • RDB加载阶段: 主服务器加载RDB文件,会对key进行过期检查,所以过期key不会被载入。 从服务器加载RDB文件,不做检查,不论是否过期都加载,但由于主从同步时,从服务器的数据会被清空,所以过期key对从服务器影响很小。
  • AOF写入阶段: 当过期key被删除时,AOF文件中会显式的追加一条删除该键的del命令。
  • AOF重写阶段:AOF重写时,会对库中的键值对进行检查,所以过期key不会被保存到重写后的AOF文件中。

Redis主从模式,对过期键的处理:

  • Redis3.2以前,读取从节点数据,从节点不会判断是否过期,所以可能返回过期数据。
  • Redis3.2之后,从节点会进行过期判断,过期的话会返回nil, 但只有主节点才会删除过期key,从节点不做删除操作,只等待同步更新。

内存淘汰

在 Redis 的运行内存达到了某个阀值,就会触发内存淘汰机制,这个阀值就是配置文件中设置的最大运行内存,配置项为 maxmemory。

Redis有八种内存淘汰策略:

  • 不进行淘汰:
    • noeviction(3.0之后的默认淘汰策略): 运行内存超过最大设置内存时,不淘汰任何数据,并且不再提供服务,直接返回错误。
  • 在有过期时间的数据中淘汰:
    • volatile-random:随机淘汰设置了过期时间的任意键值;
    • volatile-ttl:优先淘汰更早过期的键值。
    • volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
    • volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;
  • 在所有数据范围内进行淘汰:
    • allkeys-random:随机淘汰任意键值;
    • allkeys-lru:淘汰整个键值中最久未使用的键值;
    • allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。

LRU:Least Recently Used 最近最少使用,淘汰最长时间未被使用的。

  • 传统LRU算法使用链表结构,最新操作的元素会被移到链表头部。进行内存淘汰时,直接删除链表尾部元素即可。

    • 缺点: 链表需要管理所有缓存数据,这是额外的空间开销; 大量访问数据时,链表的移动耗时也很大。
  • Redis对LRU算法的实现:

    为了节省内存,Redis不使用链表,而是在 对象结构体redisObject中添加一个额外的字段:lru,用于记录数据最后一次访问时间。

    进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 n 个值(此值可配置),然后淘汰最久没有使用的那个。

    • 缺点:无法解决缓存污染(只应用一次的大量数据,会留存在缓存中一段时间,造成缓存污染。 所以Redis4.0引入LFU算法来解决)

LFU:Least Frequently Used 最近最不常用使用,淘汰一定时间内使用次数最少的。 核心思想是:如果数据过去被访问多次,那么将来被访问的频率也更高。

  • 实现:Redis的对象头的lru字段有24bit,高16bit记录访问时间戳,低8bit记录 访问频次。

缓存更新策略

旁路缓存模式

Cache Aside Pattern, 较常用的模式,适合读请求比较多的场景。

Cache Aside Pattern中服务端需要同时维护数据库和缓存,且以db的结果为准。

读写策略:

  • 写: 先更新db,之后直接删除cache。
  • 读: 先从cache中读取数据,如果cache中没有,就到db中读取,同时把读到数据放入cache。

策略要点:

  • 为什么删除cache,而不是更新cache?

    删除cache更直接,因为cache中的一些数据不是db照搬过来,而是需要额外的计算才能放入cache,所以更新cache是一笔不小的开销, 而且cache中的数据也不一定会被命中。
    同时,并发场景下,更新cache产生数据不一致性问题的概率会更大。

  • 写数据过程中,能先删cache,后更新db吗?

    不可以!这样造成db和cache数据不一致的概率会大很多。

    如: 请求1更新数据A,请求2随后读取数据A:

    1. 请求1先把cache中的旧数据A删除;
    2. 请求2 从db中读取旧数据A,并把旧数据A写入cache;
    3. 请求1更新db中的数据A;

    如果是先更新db,后删除cache,出现数据不一致的情况为: 请求1先读数据A,且数据A不在缓存中,请求2后更新数据A:

    1. 请求1先从db读旧数据A;
    2. 请求2更新db中的数据A;
    3. 请求1将旧数据A写入cache;

    但这情况不太可能发生,因为cache写入速度比db快很多。

  • 缺点:

    • 首次请求数据一定不在cache中。
      解决: 将热点数据提前放入cache中。
    • 写操作频繁的话会导致cache中的数据被频繁删除,影响缓存命中率。
      解决:更新db时也更新cache,但需要加锁来保证线程安全,或者给cache加一个较短的过期时间,允许短暂的数据不一致。

读写穿透

Read/Write Through Pattern

服务端会把cache视为主要数据存储,从中读写数据, 并负责把数据写入db。(比较少见,redis也没有提供写db的功能)

读写策略:

  • 写:先查chche是否有此数据,没有的话,直接更新db。 有的话,先更新cache,然后cache负责更新到db。
  • 读:先从cache读取;没有的话,从db中读取,先写入cache再返回响应。

异步缓存写入

Write Behind Pattern

和读写穿透类似,也是cache负责cache和db的读写。区别在于: Read/Write Through是同步更新cache和db, Write Behind 是只更新缓存,再以异步批量的方式更新db。

这种方式的写性能很高,适合数据经常变化又对数据一致性要求不高的场景,如浏览量、点赞量。

消息队列中的消息是异步写入磁盘,MySQL中断 Innodb Buffer Pool机制 都是异步缓存写入策略。

通信协议-RESP

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

  1. 客户端(client)向服务端(server)发送一条命令;

  2. 服务端解析并执行命令,返回响应结果给客户端;

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

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

  • Redis 1.2版本引入了RESP协议

  • Redis 2.0版本中成为与Redis服务端通信的标准,称为RESP2

  • Redis 6.0版本中,从RESP2升级到了RESP3协议,增加了更多数据类型并且支持6.0的新特性–客户端缓存

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

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

  • 单行字符串:首字节是 ‘+’ ,后面跟上单行字符串,以CRLF( “\r\n” )结尾。例如返回"OK": “+OK\r\n”

  • 错误(Errors):首字节是 ‘-’ ,与单行字符串格式一样,只是字符串是异常信息,例如:“-Error message\r\n”

  • 数值:首字节是 ‘:’ ,后面跟上数字格式的字符串,以CRLF结尾。例如:“:10\r\n”

  • 多行字符串:首字节是 ‘$’ ,表示二进制安全的字符串,行之间同样以 CRLF( “\r\n” )分割,最大支持512MB,

    • 如果大小为0,则代表空字符串:“$0\r\n\r\n”
    • 如果大小为-1,则代表不存在:“$-1\r\n”
  • 数组:首字节是 ‘*****’,后面跟上数组元素个数,再跟上若干行元素,元素数据类型不限,如:

    *3\r\n
    $3\r\nset\r\n
    $4\r\nname\r\n
    $6\r\n灿灿\r\n
    

Lua语法

Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

Nginx本身也是C语言开发,因此也允许基于Lua做拓展。

Lua经常嵌入到C语言开发的程序中,例如游戏开发、游戏插件等。

CentOS7默认安装了Lua语言环境。

在springboot中使用:用ResourceScriptSource加载lua脚本,再用redis客户端执行。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;

@Configuration
public class LuaConfiguration {
    @Bean(name = "set")
    public DefaultRedisScript<Boolean> redisScript() {
        DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("luascript/lock-set.lua")));
        redisScript.setResultType(Boolean.class);
        return redisScript;
    }
    
}
@RestController
public class LuaLockController {
    @Resource(name = "set")
    private DefaultRedisScript<Boolean> redisScript;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @GetMapping("/lua")
    public ResponseEntity lua() {
        List<String> keys = Arrays.asList("testLua", "hello lua");
        Boolean execute = stringRedisTemplate.execute(redisScript, keys, "100");
        return null;
    }

}

变量

数据类型 描述
nil 值就是nil,是一个无效值(条件判断中相当于false)
boolean true和false
number 双精度类型的实浮点数
string 字符串由一对双引号或单引号表示
function 由C或Lua编写的函数
table lua的表, 相当于一个关联数组,数组的索引可以是数字、字符串或表类型。使用{}定义

声明变量时,无需指定数据类型,而 local 用来声明变量为局部变量:

-- 声明字符串,可以用单引号或双引号,
local str = 'hello'
-- 字符串拼接可以使用 ..
local str2 = 'hello' .. 'world'
-- 声明数字
local num = 21
-- 声明布尔类型
local flag = true

-- 声明数组 ,key为角标的 table
local arr = {'java', 'python', 'lua'}
-- 声明table,类似java的map
local map =  {name='Jack', age=21}

-- 访问数组,lua数组的角标从1开始
print(arr[1])
-- 访问table
print(map['name'])
print(map.name)

循环和条件控制

遍历数组:

-- 声明数组 key为索引的 table
local arr = {'java', 'python', 'lua'}
-- 遍历数组
for index,value in ipairs(arr) do
    print(index, value) 
end

遍历普通table

-- 声明map,也就是table
local map = {name='Jack', age=21}
-- 遍历table
for key,value in pairs(map) do
   print(key, value) 
end

条件控制,类似Java的条件控制,例如if、else语法:

if(布尔表达式)
then
   --[ 布尔表达式为 true 时执行该语句块 --]
else
   --[ 布尔表达式为 false 时执行该语句块 --]
end
  • 不同点: lua的逻辑运算是英文单词
    • 逻辑与 and: A and B
    • 逻辑或 or: A or B
    • 逻辑非 not:not(A and B)

函数

语法:

function 函数名(argument1, argument2..., argumentn)
    -- 函数体
    return 返回值
end

如:

定义一个函数,用来打印数组:

function printArr(arr)
    for index, value in ipairs(arr) do
        print(value)
    end
end

你可能感兴趣的:(服务器,框架,中间件,redis,学习,笔记)