0%

Redis总结

Redis总结

Redis全景图

应用维度

  • 缓存使用
  • 集群运用
  • 数据结构的巧妙使用

系统维度

  • 高性能
    • 线程模型
    • 网络IO模型
    • 数据结构
    • 持久化机制
  • 高可用
    • 主从复制
    • 哨兵集群
    • Cluster分片集群
  • 高拓展
    • 负载均衡

缓存系统

本地缓存

  • 单体架构的时候、数据量不大,并且没有分布式要求的话,可以直接使用本地缓存

    • JDK自带的HashMapConcurrentHashMap

      • ConcurrentHashMap可以看作是线程安全版本的HashMap ,两者都是存放 key/value 形式的键值对。但是,大部分场景来说不会使用这两者当做缓存,因为其只提供了缓存的功能,并没有提供其他诸如过期时间之类的功能。一个稍微完善一点的缓存框架至少要提供:过期时间、淘汰机制、命中率统计这三点
        • 其实也可以手动维护缓存的淘汰更新等,但是当业务比较复杂时就不合适了
    • EhcacheGuava Cache Spring Cache

      • Ehcache 的话相比于其他两者更加重量。不过,相比于 Guava CacheSpring Cache 来说, Ehcache 支持可以嵌入到 hibernatemybatis 作为多级缓存,并且可以将缓存的数据持久化到本地磁盘中、同时也提供了集群方案(比较鸡肋,可忽略)
      • Guava CacheSpring Cache 两者的话比较像。
        • Guava 相比于 Spring Cache 的话使用的更多一点,它提供了 API 非常方便我们使用,同时也提供了设置缓存有效时间等功能。它的内部实现也比较干净,很多地方都和 ConcurrentHashMap 的思想有异曲同工之妙。
      • 使用 Spring Cache 的注解实现缓存的话,代码会看着很干净和优雅,但是很容易出现问题比如缓存穿透、内存溢出。
    • Caffeine

      • 相比于 Guava Cache 来说 Caffeine 在各个方面比如性能要更加优秀,一般建议使用其来替代 Guava 。并且, GuavaCaffeine 的使用方式很像!

本地缓存与分布式缓存

  • 我们可以把分布式缓存(Distributed Cache) 看作是一种内存数据库的服务,它的最终作用就是提供缓存数据的服务

  • 本地的缓存的优势是:低依赖,比较轻量并且通常相比于使用分布式缓存要更加简单

  • 本地缓存的局限性

    • 本地缓存对分布式架构支持不友好,比如同一个相同的服务部署在多台机器上的时候,各个服务之间的缓存是无法共享的,因为本地缓存只在当前机器上有
    • 本地缓存容量受服务部署所在的机器限制明显,如果当前系统服务所耗费的内存多,那么本地缓存可用的容量就很少
  • 使用分布式缓存的缺点是:需要为分布式缓存引入额外的服务比如 Redis 或 Memcached,你需要单独保证 Redis 或 Memcached 服务的高可用

Redis概述

  • Redis是用C语言编写的支持网络连接,可基于内存也可以持久化的k-v数据库,提供多种语言的API

  • Redis 除了做缓存之外,也经常用来做分布式锁,甚至是高性能消息队列,应用场景比较丰富

  • Redis 提供了多种数据类型来支持不同的业务场景。Redis 还支持事务 、持久化、Lua 脚本、多种集群方案(Redis原生支持集群模式)

  • Redis提供了高性能、高并发的数据存储服务,一般像 MySQL 这类的数据库的 QPS 大概都在 1w 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 10w+,甚至最高能达到 30w+(就单机 redis 的情况,redis 集群的话会更高)

    image-20210921102026770

Redis高性能、高并发分析

  1. 完全基于内存实现

    1. Redis将数据直接存储在内存中,只有持久化的时候会涉及磁盘IO,所以读写性能很高,实际上瓶颈更多的存在于网络IO部分
  2. 高效的数据结构(注意这里说的是底层数据结构,不是Redis提供的string、list这些数据类型

    1. Redis底层提供了很多数据结构,不同的数据类型使用多种数据结构实现以提升性能

      image-20210921114914980

    2. 具体的参考下边的Redis底层数据结构分析

  3. 单线程模型

    1. 参考Redis单线程IO多路复用模型部分
  4. IO多路复用模型

    1. 参考Redis单线程IO多路复用模型部分

Redis与Memcached

  • 共同点 :

    • 都是基于内存的数据库,一般都用来当做缓存使用。
    • 都有过期策略
    • 两者的性能都非常高
  • 区别 :

    • Redis 支持更丰富的数据类型(丰富的数据类型对应的就是更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型
    • Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memecache 把数据全部存在内存之中。
    • Redis 有灾难恢复机制。 因为可以把缓存中的数据持久化到磁盘上。
    • Redis 在服务器内存使用完之后,可以将不用的数据放到磁盘上。但是,Memcached 在服务器内存使用完之后,就会直接报异常。
    • Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是Redis 目前是原生支持 cluster 模式的
      • Memcached 不支持分布式,只能通过在客户端使用一致性哈希来实现分布式存储,这种方式在存储和查询时都需要先在客户端计算一次数据所在的节点
    • Memcached 是多线程,非阻塞 IO 的网络模型;Redis 使用单线程的IO多路复用模型(Redis 6.0 引入了多线程)
    • Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持;并且,Redis 支持更多的编程语言。
    • Memcached过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。
  • 总结来说就是

    • 丰富的数据类型
    • 可持久化
    • 原生的集群模式
    • 单线程多路复用IO(Redis6.0引入多线程IO)
    • 支持事务、发布订阅模型、lua脚本
    • 支持惰性删除和定期删除

Redis 数据类型与使用场景

string

  • string 数据结构是简单的 key-value 类型。虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种简单动态字符串(simple dynamic string,SDS),可以用来存储字符串、整数,浮点数

  • 相比于 C 的原生字符串

    • Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据(String类型可以用来存储二进制的数据)
      • 这也是后边要说到的biMap数据类型的基础
    • 并且获取字符串长度复杂度为 O(1)(C 字符串为 O(N))
    • Redis 的 SDS API 是安全的,不会造成缓冲区溢出
  • 常用命令: set,get,strlen,exists,dect,incr,setex 等等

  • 应用场景一般常用在需要计数的场景,比如用户的访问次数、热点文章的点赞转发数量(Redis中没有number类型的数据,就用String来表示,也可以做计数的运算);还可以执行限流、共享session(序列化缓存信息)、一般的缓存和分布式锁等等

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    keys  pattern #返回全部符合模式的key

    set key value #设置 key-value 类型的值

    OK

    get key # 根据 key 获得对应的 value

    "value"

    exists key # 判断某个 key 是否存在

    (integer) 1

    strlen key # 返回 key 所储存的字符串值的长度。

    (integer) 5

    del key # 删除某个 key 对应的值

    (integer) 1

    get key

    (nil)

    # 批量设置(批量操作一般是在普通操作命令前加一个m)

    mset key1 value1 key2 value2 # 批量设置 key-value 类型的值

    OK

    mget key1 key2 # 批量获取多个 key 对应的 value

    1) "value1"

    2) "value2"

    # 计数器(字符串的内容为整数的时候可以使用,否则会异常报错)

    set number 1

    OK

    incr number # 将 key 中储存的数字值增一

    (integer) 2

    get number

    "2"

    decr number # 将 key 中储存的数字值减一

    (integer) 1

    get number

    "1"

    # 设置过期时间

    expire key 60 # 数据在 60s 后过期

    (integer) 1

    setex key 60 # 数据在 60s 后过期 (setex:[set] + [ex]pire),这个指令与expire指令的区别就在于set的同时设置过期时间

    OK

    ttl key # 查看数据还有多久过期

    (integer) 56

    # 如果返回-1表示永不过期(没有设置过期时限),如果返回-2表示已经设置过期时限,但是已经过期

    setnx key value #键存在的情况下设置为value 若键不存在则不作任何操作
    • setnx是实现分布式锁的关键
    • 参考

list

  • list 即是 链表。链表是一种非常常见的数据结构,特点是易于数据元素的插入和删除并且且可以灵活调整链表长度,但是链表的随机访问困难。许多高级编程语言都内置了链表的实现比如 Java 中的 LinkedList,但是 C 语言并没有实现链表,所以 Redis 实现了自己的链表数据结构。Redis 的 list 的实现为一个 压缩链表或双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。

  • 常用命令: rpush,lpop,lpush,rpop,lrange(查看某范围内的数据)、llen

  • 应用场景: 发布与订阅或者说消息队列(比如使用lpush与brpop指令实现阻塞队列,生产者客户端是用 lpush 从列表左侧插入元素,多个消费者客户端使用 brpop 命令阻塞式的“抢”列表尾部的元素,多个客户端保证了消费的负载均衡和高可用性)、慢查询、分页查询(基于lrange命令)

    • 链表这个数据结构可用于实现队列和栈这两种数据结构,只需要在双向链表的基础上固定数据流动方向即可实现,对于栈只需要封堵一侧,单进单出即可实现
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    通过rpush/lpop 实现队列(从链表的右侧进入左侧弹出)

    rpush myList value1 # 向 list 的头部(右边)添加元素

    (integer) 1

    rpush myList value2 value3 # 向list的头部(最右边)添加多个元素

    (integer) 3

    lpop myList # 将 list的尾部(最左边)元素取出

    "value1"

    lrange myList 0 1 # 查看对应下标的list列表, 0 为 start,1为 end

    1) "value2"

    2) "value3"

    lrange myList 0 -1 # 查看列表中的所有元素,-1表示倒数第一

    1) "value2"

    2) "value3"

    # push返回的结果是当前队列的长度

    # 通过rpush/rpop实现栈

    rpush myList2 value1 value2 value3

    (integer) 3

    rpop myList2 # 将 list的头部(最右边)元素取出

    "value3"

    # 通过lrange 命令,可以基于 list 实现分页查询,性能非常高!(通过制定start end即可实现分页查询)

    # 通过 llen 查看链表长度:

    llen myList

    (integer) 3

hash

  • hash 类似于 JDK1.8 前的 HashMap,内部实现也差不多(数组 + 链表,拉链法,没有红黑树那些,当键值对太多或者太少时,都会执行rehash以对哈希表执行扩容和缩容)。不过,Redis 的 hash 做了更多优化。另外,hash 是一个string 类型的 field和 value 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接仅仅修改这个对象中的某个字段的值。 比如我们可以 hash 数据结构来存储用户信息,商品信息等等(一个Redis Key就是一个对象, value就是对象内部的一个个键值对

  • 常用命令:hset,hmset,hexists,hget,hgetall,hkeys,hvals

  • 应用场景: 系统中对象数据的存储、相对于序列化缓存信息可读性更好,还可以实现分布式锁的可重入

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    hset userInfoKey name "guide" description "dev" age "24"

    OK

    hexists userInfoKey name # 查看 key 对应的 value中指定的字段是否存在。

    (integer) 1

    hget userInfoKey name # 获取存储在哈希表中指定字段的值。

    "guide"

    hget userInfoKey age

    "24"

    hgetall userInfoKey # 获取在哈希表中指定 key 的所有字段和值 成对返回

    1) "name"

    2) "guide"

    3) "description"

    4) "dev"

    5) "age"

    6) "24"

    hkeys userInfoKey # 获取 key 列表

    1) "name"

    2) "description"

    3) "age"

    hvals userInfoKey # 获取 value 列表

    1) "guide"

    2) "dev"

    3) "24"

    hset userInfoKey name "GuideGeGe" # 修改某个字段对应的值

    hget userInfoKey name

    "GuideGeGe"

set

  • set 类似于 Java 中的 HashSet 。Redis 中的 set 类型是一种无序(强调无序,因为Redis提供了有序的set)集合,集合中的元素没有先后顺序。当你需要存储一个列表数据,又不希望出现重复数据时,set 是一个很好的选择,并且 set 提供了判断某个成员是否在一个 set 集合内的重要接口,这个也是 list 所不能提供的。可以基于 set 轻易实现交集、并集、差集的操作

  • 常用命令:sadd,spop,smembers,sismember,scard,sinterstore,sunion 等

  • set的底层存储结构是整数集合或哈希表

  • 应用场景: 需要存放的数据不能重复以及需要获取多个数据源交集、并集或差集(实现共同关注、共同粉丝、共同喜好等功能)等场景

    • 比如:你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    sadd mySet value1 value2 # 添加元素进去,返回当前的set的成员的个数

    (integer) 2

    sadd mySet value1 # 不允许有重复元素

    (integer) 0

    smembers mySet # 查看 set 中所有的元素

    1) "value1"

    2) "value2"

    scard mySet # 查看 set 的长度,不是slen

    (integer) 2

    sismember mySet value1 # 检查某个元素是否存在set 中,只能接收单个元素

    (integer) 1

    sadd mySet2 value2 value3 # 批量添加

    (integer) 2

    sinter mySet mySet2 # 返回给定的所有的set的交集

    sinterstore mySet3 mySet mySet2 # 获取 mySet 和 mySet2 的交集并存放在 mySet3 中

    (integer) 1

    sunion key1 key2 # 返回所有给定集合的并集

    sunionstore mySet3 mySet mySet2 # 获取 mySet 和 mySet2 的并集并存放在 mySet3 中

    sdiff myset1 myset2 myset3 # 返回myset1与其余set的差异

    sdiffstore myset1 myset2 myset3 # 计算myset2与myset3的差集并存储到myset1中

sorted set(zset)

  • 和 set 相比,sorted set 增加了一个权重参数 score(不是自动排序的而是基于权重参数进行排序的),使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。有点像是 Java 中 HashMap(set中的值为key,value为score) 和 TreeSet 的结合体

  • 底层存储结构是压缩列表或跳表

    • 既然是要保持有序,为什么不用红黑树而是使用跳表呢
    • 因为对于zrange输出指定范围内的成员来说,使用跳表效率更高,使用O(logn)找到起点,在顺序遍历即可,而其余的操作与红黑树的时间复杂度类似都是O(logn)
  • 常用命令:zadd,zcard,zscore,zrange,zrevrange,zrem 等。

  • 应用场景: 需要对数据根据某个权重进行排序的场景。比如在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜,弹幕消息(可以理解为按消息维度的消息排行榜)等信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    zadd myZset 3.0 value1 # 添加元素到 sorted set 中 3.0 为权重 会自动进行排序

    (integer) 1

    zadd myZset 2.0 value2 1.0 value3 # 一次添加多个元素

    (integer) 2

    zcard myZset # 查看 sorted set 中的元素数量

    (integer) 3

    zscore myZset value1 # 查看某个 value 的权重

    "3"

    zrange myZset 0 -1 # 顺序输出某个范围区间的元素,0 -1 表示输出所有元素

    1) "value3"

    2) "value2"

    3) "value1"

    zrange myZset 0 1 # 顺序输出某个范围区间的元素,0 为 start 1 为 stop

    1) "value3"

    2) "value2"

    zrevrange myZset 0 1 # 逆序输出某个范围区间的元素,0 为 start 1 为 stop

    1) "value1"

    2) "value2"

bitmap(位图)

  • 有点类似与是存储boolean类型对象的意思,bitmap 存储的是连续的二进制数字(0 和 1),通过 bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身,一个字节是8位,所以bitmap可以存储关于key的很多状态, key对应的每一个bit都可以存储状态信息,不同的bit位代表不同类型的状态,bit位的值表示该类型状态的具体状态(0、1)

    • 有没有觉得bitmap与String中的sds很像(可以存储二进制数据),实际上bitmap类型并不是标准的数据类型,因为其是依赖string类型实现的
    • string类型最长是512M,换算一下可知bitmap offfset的最大长度是8 * 1024 * 1024 * 512 = 2^32,由于 C语言中字符串的末尾都要存储一位分隔符,所以实际上 BitMap 的 offset 值上限是(8 * 1024 * 1024 * 512) -1 = 2^32 - 1(大概可以存储42亿的数据)
  • 常用命令: setbitgetbitbitcountbitop

  • 应用场景: 适合需要保存状态信息(比如是否签到、是否登录…)并需要进一步对这些信息进行分析的场景。比如用户签到情况、活跃用户情况、用户行为统计(比如是否点赞过某个视频);既能存储状态,提供了分析状态的方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    # SETBIT 会返回之前位的值(默认是 0) mykey的 7这个bit位被设置为1
    setbit mykey 7 1
    (integer) 0
    setbit mykey 7 0
    (integer) 1
    getbit mykey 7
    (integer) 0
    setbit mykey 6 1
    (integer) 0
    setbit mykey 8 1
    (integer) 0
    # 通过 bitcount 统计被被设置为 1 的位的数量。
    bitcount mykey
    (integer) 2
  • 应用场景解析

    • 使用场景一:用户行为分析 很多网站为了分析你的喜好,需要研究你点赞过的内容。

      1
      2
      3
      # 记录喜欢过 001 号小姐姐的用户
      setbit beauty_girl_001 uid_1 1
      setbit beauty_girl_001 uid_2 1
    • 使用场景二:统计活跃用户 使用时间作为 key,然后用户 ID 为 offset,如果当日活跃过就设置为 1

      • 那么我该如果计算某几天/月/年的活跃用户呢(暂且约定,统计时间内只有有一天在线就称为活跃),有请下一个 redis 的命令

        1
        2
        3
        # 对一个或多个bitmap类型的 key 进行位元操作,并将结果保存到 destkey(默认称为bitmap类型) 上。
        # BITOP 命令支持 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种参数
        BITOP operation destkey key [key ...]
      • 初始化数据:

        1
        setbit 20210308 uid_1 1(integer) 0setbit 20210308 uid_2 1(integer) 0setbit 20210309 uid_1 1(integer) 0
      • 统计 20210308~20210309 总活跃用户数: 1

        1
        bitop and desk1 20210308 20210309(integer) 1bitcount desk1(integer) 1
      • 统计 20210308~20210309 在线活跃用户数: 2

        1
        bitop or desk2 20210308 20210309(integer) 1bitcount desk2(integer) 2
    • 使用场景三:用户在线状态对于获取或者统计用户在线状态,使用 bitmap 是一个节约空间效率又高的一种方法。只需要一个 key,然后用户 ID 为 offset,如果在线就设置为 1,不在线就设置为 0,仅仅用一个key就可以存储大量用户的在线状态

    • 实现布隆过滤器,具体的细节暂不知晓

  • 各种数据类型的底层存储结构可参考

Redis底层存储结构

Redis 单线程IO多路复用模型详解

  • 分析的前提是redis的性能瓶颈主要在于内存与网络
  • 首先必须明确,Redis 的单线程指的是 Redis 的网络 IO 以及键值对指令读写是由一个线程来执行的, 对于 Redis 的持久化、集群数据同步、异步删除等都是其他线程执行

IO多路复用

  • Redis使用epoll + 基于Reactor模式的事件处理框架来实现IO多路复用

    • Redis 通过IO 多路复用程序 来监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生
    • 这样的好处非常明显: I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗,只处理有效的IO请求
  • Redis基于Reactor模式开发了自己的一套高效的事件处理模型 ,这套事件处理模型对应的是 Redis 中的文件事件处理器(file event handler),文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据 套接字目前执行的任务来为套接字关联不同的事件处理器

    • 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关 闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件
      • 文件事件分派器通过由单线程维持的IO多路复用程序的返回结果,调用与对应的套接字关联好的时间处理器,实际上就是一个单向成坐镇前台,处理网络IO请求,然后后台使用多个单线程模块执行任务的实际执行,单个来看都是单线程,整体来看是多个单线程模块的配合(不同于多线程并发,请求还是一个一个处理的)
    • 虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性
  • Redis 服务器是一个事件驱动程序,服务器需要处理两类事件: 1. 文件事件; 2. 时间事件。时间事件不需要多花时间了解,我们接触最多的还是 文件事件(客户端进行读取写入等操作,涉及一系列网络通信)

image-20210429203444362

Redis6.0之前为什么没有使用多线程

  • 虽然说 Redis 是单线程模型,但是, 实际上,Redis 在 4.0 之后的版本中就已经加入了对多线程的支持不过Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主线程之外的其他线程来“异步处理”,大体上来说,Redis 6.0 之前主要还是单线程处理

  • Redis6.0 之前 为什么不使用多线程?

    • 单线程编程容易并且更容易维护;多线程就会存在死锁、线程上下文切换等问题,会影响性能;此外,单线程也可以支持并发请求(IO多路复用)
    • Redis 的性能瓶颈不在CPU ,主要在内存和网络;
      • Redis 并不是 CPU 密集型的服务,如果不开启 AOF 备份,所有 Redis 的操作都会在内存中完成不会涉及任何的磁盘 I/O 操作,整个服务的瓶颈在于网络传输带来的延迟和等待客户端的数据传输,也就是网络 I/O,此时使用多线程不会有太多性能提升,使用IO多路复用更能解决问题

Redis6.0 之后为何引入了多线程?

  • Redis6.0 引入多线程同样是为了提高网络 IO 读写性能
  • 虽然,Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了(引入了一些非阻塞的命令,用多线程去“异步”处理,主要是删除一些大容量的键值对,比如UNLINKFLUSHALL ASYNCFLUSHDB ASYNC;通过多线程非阻塞地释放内存空间也能减少对 Redis 主线程阻塞的时间,提高执行的效率), 执行命令仍然是单线程顺序执行。因此,也不需要担心线程安全问题
  • Redis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要修改 redis 配置文件 redis.confio-threads-do-reads yes
    • 开启多线程后,还需要设置线程数,否则是不生效的。同样需要修改 redis 配置文件 redis.conf :io-threads 4 #官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程

参考

Redis缓存过期机制

Redis 给缓存数据设置过期时间有啥用

  • 原因:

    • 如果不设置过期的话,一直存储在内存中,会导致内存爆满溢出,设置过期时间可以用来缓解内存使用
    • 业务需要,数据需要有过期设置,比如短信验证码、用户的验证token等等
      • 如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多
  • Redis 自带了给缓存数据设置过期时间的功能:

    1
    exp key  60 # 数据在 60s 后过期(integer) 1setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)OKttl key # 查看数据还有多久过期(integer) 56
    • 注意:**Redis 中除了字符串类型有自己独有设置过期时间的命令 setex 外,其他方法都需要依靠 expire 命令来设置过期时间 。另外, persist 命令可以移除一个键的过期时间 **
  • 对于散列表这种容器,只能为整个键设置过期时间(整个散列表),而不能为键里面的单个元素设置过期时间

判断数据是否过期的原理是什么

  • Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)

    image-20210429204719782
    • 数据库的键空间字典dict保存着Redis中的所有键值对

      • 字典是Redis中的一个比较基础的数据结构,其实就是一个散列表结构,使用拉链法解决哈希冲突

        Redis 的字典 dict 中包含两个哈希表 dictht,这是为了方便进行 rehash 操作。在扩容时,将其中一个 dictht 上的键值对 rehash 到另一个 dictht 上面,完成之后释放空间并交换两个 dictht 的角色。

        rehash 操作不是一次性完成,而是采用渐进方式,这是为了避免一次性执行过多的 rehash 操作给服务器带来过大的负担。

        渐进式 rehash 通过记录 dict 的 rehashidx 完成,它从 0 开始,然后每执行一次 rehash 都会递增。例如在一次 rehash 中,要把 dict[0] rehash 到 dict[1],这一次会把 dict[0] 上 table[rehashidx] 的键值对 rehash 到 dict[1] 上,dict[0] 的 table[rehashidx] 指向 null,并令 rehashidx++。

        在 rehash 期间,每次对字典执行添加、删除、查找或者更新操作时,都会执行一次渐进式 rehash。

        采用渐进式 rehash 会导致字典中的数据分散在两个 dictht 上,因此对字典的查找操作也需要到对应的 dictht 去执行。

        这一部分可以通过源码学习

    • 过期字典expires保存着键的过期时间

    • redisDB就是常说的Redis支持得16个存储数据库之一

过期数据的删除策略

  • 常用的过期数据的删除策略就两个

    • 惰性删除只会在取出key的时候才对数据进行过期检查
      • 这样对CPU最友好
      • 但是可能会造成太多过期 key 没有被删除(导致内存可能被大量无用的数据占据)。
    • 定期删除每隔一段时间抽取一批 key 执行删除过期key操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响
      • 注意是抽取一批,肯定不是全部(这也导致了部分过期数据就是没法被删除)
  • 定期删除对内存更加友好,惰性删除对CPU更加友好。两者各有千秋,所以Redis 采用的是 定期删除+惰性删除

  • 但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除(过期的key一直没有抽取到)和惰性删除(一直不取过期的key)漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就Out of memory了——-引入Redis 内存淘汰机制

Redis 内存淘汰机制

相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?

  • 从内存机制上来说是解决两个删除策略遗漏的过期数据可能导致的OOM

  • 从业务角度来讲,在Redis中只维护热点数据,提高资源的利用效率

  • 可以设置内存最大使用量,当内存使用量超出时,会施行数据淘汰策略;Redis 提供 6 种数据淘汰策略:

    • volatile-lru(least recently used):从已设置过期时间的数据集(volatile)中挑选最近最少使用的数据淘汰
    • volatile-ttl:从已设置过期时间的数据集(volatile)中挑选将要过期的数据淘汰
    • volatile-random:从已设置过期时间的数据集(volatile)中任意选择数据淘汰
      • 注意针对的不是已经过期的数据,而是设置过期时间的key
    • allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在整个键空间(allkeys)中,移除最近没有使用的 key(这个是最常用的,但是lfu版本可能更合理
    • allkeys-random:从整个键空间(allkeys)中任意选择数据淘汰
    • no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!

    4.0 版本后增加以下两种:

    • volatile-lfu(least frequently used):从已设置过期时间的数据集(volatile)中挑选最不经常使用的数据淘汰
    • allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key
  • 简单来说lru与lfu并不是取代的关系,二者各自有适用场景:相对来说,LFU算法能更好的表示一个key被访问的热度,LFU效率要优于LRU,且能够避免周期性或者偶发性的操作导致缓存命中率下降的问题。但LFU需要记录数据的历史访问记录,一旦数据访问模式改变,LFU需要更长时间来适用新的访问模式,即:LFU存在历史数据影响将来数据的“缓存污染”效用;除此之外,LFU的复杂度也相对高

Redis 持久化机制

  • 很多时候我们需要持久化数据也就是将内存中的数据写入到硬盘里面,大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了防止系统故障而将数据备份到一个远程位置

  • Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持两种不同的持久化操作

    • 快照(snapshotting,RDB)
    • 只追加文件(append-only file, AOF)
  • 快照(snapshotting)持久化(RDB)

    • Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本
    • Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用

    快照持久化是 Redis 默认采用的持久化方式,在 Redis.conf 配置文件中默认有此下配置(要在配置文件中做配置):

    1
    save 900 1      #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。save 300 10     #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。save 60 10000    #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
    • 可以使用save命令触发快照
    • 可以使用bgsave命令fork一个子进程来执行rdb
      • 判断此时有没有子进程用于RDB,有的话直接返回。
      • redis进行fork子进程过程,此时父进程处于阻塞状态。
      • 子进程创建RDB文件,完成后返回给父进程
    • RDB的自动触发机制
      • 通过配置文件,设置一定时间后自动执行RDB
      • 如采用主从复制过程,会自动执行RDB
      • Redis执行shutdown时,在未开启AOF后会执行RDB
    • RDB持久化的缺点在于
      • 如果系统发生故障,将会丢失最后一次创建快照之后的数据
      • 如果数据量很大,保存快照的时间会很长
  • AOF(append-only file)持久化

    • 与快照持久化相比,AOF 持久化 的实时性更好(不是基于某个时间点的快照备份,而是实时做备份,这样可以做更准确地备份),因此已成为主流的持久化方案。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化,可以通过 appendonly 参数开启:appendonly yes

    • 开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入硬盘中的 AOF 文件。AOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名是 appendonly.aof

    • 在 Redis 的配置文件中存在三种不同的 AOF 持久化方式(对aof文件进行写入并不会马上将内容同步到磁盘上,而是先存储到缓冲区,然后由操作系统决定什么时候同步到磁盘),它们分别是:

      1
      appendfsync always  #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度appendfsync everysec #每秒钟同步一次,显示地将多个写命令同步到硬盘appendfsync no    #让操作系统决定何时进行同步

      为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec 选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能几乎没受到任何影响。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度

    • 拓展:Redis 4.0 对于持久化机制的优化

      • Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)

      • 如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 新的AOF 文件开头,而不是通过读数据库来实现AOF重写

        • 好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据
        • 缺点是 AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差
      • 补充内容:AOF 重写

        • AOF文件因为是做实时命令记录的,因此一定会随着时间的推移变得无限臃肿,AOF 重写可以产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小,其实本质上是对之前的冗余的写命令进行精简(AOF的目的是维护状态,不是维护操作记录)

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19
          # 假设服务器对键list执行了以下命令s;
          127.0.0.1:6379> RPUSH list "A" "B"
          (integer) 2
          127.0.0.1:6379> RPUSH list "C"
          (integer) 3
          127.0.0.1:6379> RPUSH list "D" "E"
          (integer) 5
          127.0.0.1:6379> LPOP list
          "A"
          127.0.0.1:6379> LPOP list
          "B"
          127.0.0.1:6379> RPUSH list "F" "G"
          (integer) 5
          127.0.0.1:6379> LRANGE list 0 -1
          1) "C"
          2) "D"
          3) "E"
          4) "F"
          5) "G"

          比如当前列表键list在数据库中的值就为[“C”, “D”, “E”, “F”, “G”]。要使用尽量少的命令来记录list键的状态,最简单的方式不是去读取和分析现有AOF文件的内容,而是直接读取list键在数据库中的当前值,然后用一条RPUSH list “C” “D” “E” “F” “G”代替前面的6条命令

        • AOF 重写是通过读取数据库中的键值对来实现的,程序无须对现有 AOF 文件进行任何读入、分析或者写入操作(优化之后,就不用读数据库了,直接把RDB放到新的AOF的前边)

        • AOF实现的具体过程:

          在执行 BGREWRITEAOF 命令时(手动触发aof重写),Redis 服务器会维护一个 AOF 重写缓冲区(避免子进程重写、主进程接受写请求,出现数据不一致的情况),该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新旧两个 AOF 文件所保存的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作

Redis 事务

Redis 可以通过 MULTI,EXEC,DISCARD 和 WATCH 等命令来实现事务(transaction)功能。

1
2
3
4
5
6
7
8
9
MULTI
OK
INCR foo
QUEUED
INCR bar
QUEUED
EXEC
1) (integer) 1
2) (integer) 1
  • 使用 MULTI命令后可以输入多个命令。Redis不会立即执行这些命令,而是将它们放到队列,当调用了EXEC命令将执行所有命令

  • 我们知道事务具有四大特性: 1. 原子性2. 隔离性3. 持久性4. 一致性

    • 原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
    • 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
    • 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。
    • 一致性(Consistency): 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的;
  • Redis 是不支持 roll back 的(意思就是同一事务中,如果某指令执行错误,那么其前边的指令的执行是没办法撤销的),因而不满足原子性的(而且不满足持久性)(那一致性恐怕也不能满足)(只支持隔离性)

    • 一个补救措施就是使用lua脚本来实现原子性,官方说明lua脚本的执行是具有原子性的,使用eval命令即可,其格式如下

      1
      2
      3
      4
      EVAL script numkeys key [key ...] arg [arg ...] 

      // 使用实例
      EVAL "if (redis.call('setnx',KEYS[1],ARGV[1]) < 1) then return 0; end; redis.call('expire',KEYS[1],tonumber(ARGV[2])); return 1;" 1 key value 100
      • script: 参数是一段 Lua 5.1 脚本程序。脚本不必(也不应该)定义为一个 Lua 函数。
      • numkeys: 用于指定键名参数的个数,即后边的key参数的个数。
      • **key [key …]**: 从 EVAL 的第三个参数开始算起,表示在脚本中所用到的那些 Redis 键(key),这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,用 1 为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)。
      • **arg [arg …]**: 附加参数,在 Lua 中通过全局变量 ARGV 数组访问,访问的形式和 KEYS 变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)
  • 注意DISCARD指令不是回滚功能,因为指令还没执行呢,其作用是放弃执行事务

  • Redis官网也解释了自己为啥不支持回滚。

    • 大多数事务失败是因为语法错误或者类型错误,这两种错误,在开发阶段都是可以预见的
    • Redis 为了性能方面就忽略了事务回滚
  • 你可以将Redis中的事务就理解为 :Redis事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断

  • 参考

缓存穿透

什么是缓存穿透?(大量请求无效直接绕过缓存去请求数据库)

  • 缓存穿透说简单点就是大量请求的 key 根本不曾存在于缓存中(事实上也并不存在于数据库中),导致请求直接到了数据库上,根本没有经过缓存这一层。举个例子:某个黑客故意制造我们缓存中不存在的 key 发起大量请求,导致大量无效请求落到数据库。

解决办法

  • 最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等,如果不做参数检验的话,随便一个请求的随意参数都要去数据库查,将会大大的降低数据库性能

  • 缓存无效 key治标不治本,大概理念就是我使用Redis记住请求过来的无效的key,下次再有这样的无效key时就可以命中缓存了,直接给你个404返回,而不用去查后台的数据库了—–仅仅可以解决key变化不频繁的情况

    • 如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期时间,具体命令如下: SET key value EX 10086这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟
    • 补充:一般情况下我们是这样设计 key 的: 表名:列名(属性名):主键名:主键值
  • 布隆过滤器

    • 通过它我们可以非常方便地判断一个给定数据是否存在于海量数据中,即可以判断 key 是否合法

      • 具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。意思就是需要预先设置布隆过滤器做初始化

      image-20210430143502300

  • 需要注意的是布隆过滤器可能会存在误判的情况: 布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。

    • 一看这特点就能明白布隆过滤器一定是基于哈希算法的,所谓的误判就是,不同的的key计算哈希值可能相同,就是某个本不存在的key计算得到的哈希值与某个存在的key哈希值匹配,形成了误判,但是如果计算得到的哈希值并不存在于已知的哈希列表中,那么一定是不存在的
  • 我们先来看一下,当一个元素加入布隆过滤器中的时候,会进行哪些操作:

    • 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)
    • 根据得到的哈希值,在位数组中把对应下标的值置为 1。
  • 我们再来看一下,当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行哪些操作:

    • 对给定元素再次进行相同的哈希计算;
    • 得到哈希值对应的位数组位置的值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中

    image-20210430145132199

    • 不同的字符串可能哈希出来的位置相同—–即哈希冲突 (可以适当增加位数组大小或者调整我们的哈希函数来降低概率) 如果哈希出的位置不同那么一定是不同的字符串
    • 布隆过滤解决哈希冲突使用的是重哈希法,即使用多个哈希函数,一个出现冲突就使用另一个
  • 需要注意的是,布隆过滤器不仅仅是用来解决缓存穿透的,而是一个单独的数据结构,Redis通过模块加载的方式可以以分布式的方式提供布隆过滤器的功能

  • 布隆过滤器占用空间少,且效率更高,但是缺点是有误判的可能性(哈希冲突)

  • 布隆过滤器的使用场景

    • 缓存穿透的处理、垃圾邮件的拦截
    • url去重,即爬取给定网址的时候,对已经爬取的url去重
  • 参考:布隆过滤器

缓存雪崩

什么是缓存雪崩

  • 实际上,缓存雪崩描述的就是这样一个简单的场景:缓存在同一时间大面积的失效,后面的请求都直接落到了数据库上(缓存雪崩与缓存击穿的区别就在于击穿是大量请求的KEY不在缓存中,而缓存雪崩虽然直接原因是绕过缓存,但是其原因在于之前的缓存大面积的失效或不可用),造成数据库短时间内承受大量请求
    • 系统的缓存模块出了问题比如宕机导致不可用。造成系统的所有访问,都要走数据库
    • 有一些被大量访问数据(热点缓存)在某一时刻大面积失效,导致对应的请求直接落到了数据库上
      • 举个例子 :秒杀开始 12 个小时之前,我们统一存放了一批商品到 Redis 中,设置的缓存过期时间也是 12 个小时,那么秒杀开始的时候,这些秒杀的商品的访问直接就失效了。导致的情况就是,相应的请求直接就落到了数据库上,就像雪崩一样可怕

有哪些解决办法

针对 Redis 服务不可用的情况:

  1. 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用,将热点数据均匀分布在集群中的不同服务器上
  2. 限流(比如使用消息处理的中间件进行限流),避免同时处理大量的请求

针对热点缓存失效的情况:

  1. 设置不同的失效时间比如随机设置缓存的失效时间(不要统一设置失效时间,造成短时间内的大量数据的共同失效)
  2. 缓存永不失效

如何保证缓存和数据库数据的一致性(理解不够)

下面单独对 Cache Aside Pattern(旁路缓存模式) 来聊聊。

Cache Aside Pattern 中遇到写请求是这样的:更新 DB,然后直接删除 cache 。

如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说两个解决方案:

  1. 缓存失效时间变短(不推荐,治标不治本) :我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。
  2. 增加cache更新重试机制(常用)如果 cache 服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。如果多次重试还是失败的话,我们可以把当前更新失败的 key 存入队列中,等缓存服务可用之后,再将 缓存中对应的 key 删除即可

其他复杂的一致性解决方案参考 缓存成神路:Redis和mysql数据怎么保持数据一致的?_开源中国 - jishuwen(技术文)

  • 说下我自己对于出现数据不一致的分析
    • 根本原因
      • 对于数据库的读和写是并发的,再加上缓存与数据库是两个独立的存储结构需要分别进行数据维护,由此两项导致了可能出现数据不一致的情况
  • 补充一致性维护的策略
    • 延时双删除策略
    • 基于订阅binlog的同步机制–阿里canal

缓存读写模式/更新策略

  • 下面介绍到的三种模式各有优劣,不存在最佳模式,根据具体的业务场景选择适合自己的缓存读写模式

Cache Aside Pattern(旁路缓存模式)

Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景

  1. 写:更新 DB,然后直接删除 cache
  2. 读:从 cache 中读取数据,读取到就直接返回,读取不到的话,就从 DB 中取数据返回,然后再把数据放到 cache 中。
  • 在写过程中为什么要先更新DB,再删除cache,可以反过来吗?

    • 不可以,可能导致出现缓存与数据库的不一致,试看如下场景

      image-20210427115517017

      image-20210427120448521

      • 先发生写再发生读,如果先删除缓存的话,另外的客户端可能因为缓存没有命中而直接去数据库拿到旧数据,并使用旧数据更新缓存,此时数据库中更新的数据与缓存中的旧数据旧产生了不一致
  • 先更新DB就能保证不会出现缓存不一致的情况吗

    • 也不能保证,但是情况会好很多,紧接着上边案例中的场景,4. 后续再读时,直接从DB拿数据,但是此时又发生了数据库的更新

      image-20210427121244900

      • 先发生读DB(写缓存),再发生写(DB删除缓存),之所以说出现的概率不大,是因为读操作中最后的更新缓存的动作,一定比写操作中的写数据库要快,所以会先用旧值更新缓存,然后缓存被删除
  • 旁路缓存的缺陷:

    • Cache Aside Pattern 有首次请求数据一定不在 cache 的问题(可能会导致缓存雪崩),对于热点数据可以提前放入缓存中。
      • (需要对缓存进行预热)
    • 写操作比较频繁的话导致cache中的数据会被频繁被删除,这样会影响缓存命中率
      • 数据库和缓存数据强一致场景 :更新DB的时候同样更新cache,不过我们需要加一个锁/分布式锁来保证更新cache的时候不存在线程安全问题
      • 可以短暂地允许数据库和缓存数据不一致的场景 :更新DB的时候同样更新cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小

Read/Write Through Pattern(读写穿透)

  1. 写(Write Through):先查 cache,cache 中不存在,直接更新 DB(应用程序负责)。 cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB(同步更新 cache 和 DB,对客户端透明)。
  2. 读(Read Through): 从 cache 中读取数据,读取到就直接返回 。读取不到的话,先从 DB 加载,写入到 cache 后返回响应(cahce读取不到时的操作完全由cache服务本身处理,对客户端透明
    1. Cache服务承担了大部分的与DB交互的任务
  • 和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不再 cache 的问题,对于热点数据可以提前放入缓存中。
  • 此模式比较少使用,毕竟Redis没有提供这种缓存直接与数据库交互的能力

Write Behind Pattern(异步缓存写入)

  • Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。

  • 但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB(优先更新cache但不立即更新DB而是异步的更新DB)

  • Write Behind Pattern 下 DB 的写性能非常高,尤其适合一些数据经常变化的业务场景比如说一篇文章的点赞数量、阅读数量。 往常一篇文章被点赞 500 次的话,需要重复修改 500 次 DB,但是在 Write Behind Pattern 下可能只需要修改一次 DB 就可以了。但是,这种模式同样也给 DB 和 Cache 一致性带来了新的考验,很多时候如果数据还没异步更新到 DB 的话,Cache 服务宕机就 gg 了。

Redis集群部署方式

  • 主从复制
  • 哨兵
  • cluster集群

主从复制模式

image-20210721135431168
  1. 有主库节点和从库节点两个角色,从节点服务启动会连接主库,并向主库发送SYNC命令
  2. 主节点收到同步命令,启动持久化工作(RDB),同时使用缓冲区记录保存快照这段时间内执行的写命令,工作执行完成后,主节点将传送整个数据库文件到从库,从节点接收到数据库文件数据之后将数据进行加载
  3. 此后,主节点继续将缓冲区中收集到的修改命令,和新的修改命令依次传送给从节点,从节点依次执行,从而达到最终的数据同
  4. 可以执行读写分离(写操作作用于主库,读操作作用于从库)
  5. 通过使用 slaveof host port 命令来让一个服务器成为另一个服务器的从服务器。一个从服务器只能有一个主服务器

主从链

  • 随着负载不断上升,主服务器可能无法很快地更新所有从服务器,或者重新连接和重新同步从服务器将导致系统超载。为了解决这个问题,可以创建一个中间层来分担主服务器的复制工作。中间层的服务器是最上层服务器的从服务器,又是最下层服务器的主服务器

    image-20210921092328680

主从复制的优缺点

  • 优点
    • master能自动将数据同步到slave,可以进行读写分离,分担master的读压力
    • master、slave之间的同步是以非阻塞的方式进行的,同步期间,客户端仍然可以提交查询或更新请求
  • 缺点
    • 不具备自动容错与恢复功能,master或slave的宕机都可能导致客户端请求失败,需要等待机器重启或手动切换客户端IP才能恢复
    • master宕机,如果宕机前数据没有同步完,则切换IP后会存在数据不一致的问题
    • 难以支持在线扩容,Redis的容量受限于单机配置(从节点只是主节点的复制而已)

哨兵模式

image-20210721135355846
  • 哨兵模式基于主从复制模式,只是引入了哨兵来监控与自动处理故障

  • 每个哨兵每10秒向主服务器,slave和其他哨兵发送ping

  • 监控同一master节点的哨兵会自动互联,组成哨兵网络,当任一哨兵发现master连接不上,即开会投票,投票半数以上决定Master下线,并从slave节点中选取master节点—选举的算法是Raft算法

    • 哨兵只需要配master节点,会自动寻找其对应的slave节点。
  • 客户端连接集群时,由哨兵提供可供服务的redis master节点(需要一个中间代理层)

哨兵模式的优缺点

  • 优点
    • 哨兵模式基于主从复制模式,所以主从复制模式有的优点,哨兵模式也有(但是注意,不再有读写分离,因为哨兵模式下,slave节点只作为备份节点不提供服务—即所谓的冷备
      • 里关于冷备热备的概念可能是错的,一般来说,热备是要做同步或者异步更新的,而冷备是做定时更新的,不保证一致性
    • 哨兵模式下,master挂掉可以自动进行切换,系统可用性更高,尽量避免出现一致性问题
  • 缺点
    • 同样也继承了主从模式难以在线扩容的缺点,Redis的容量受限于单机配置
    • 需要额外的资源来启动sentinel进程,实现相对复杂一点,同时slave节点作为备份节点不提供服务

cluster集群

image-20210721140425696
  • 真正的实现了分布式存储,可以在线扩容;采用无中心结构,Redis节点之间彼此互联,使用PING-PONG机制检测彼此的存活,节点的fail由超过半数的节点检测失效才成立客户端与redis节点直连,不需要中间代理层,客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可
  • 本质上是数据分片,将Redis做成了分布式的内存数据库
  • cluster提出了哈希槽的概念
    • redis cluster默认有16384个槽,每个节点负责一定范围的哈希槽;在集群搭建的时候,需要给节点分配哈希槽尽可能相同数量虚拟槽。
    • 节点间移动哈希槽,不会造成服务不可用,因此可以进行灵活的节点上下线与槽数量的变动
    • 请求的key到达任意节点后,首先对这个key经过CRC16 hash运算,并把结果对16384取余,得到槽编号,然后跳到槽对应的节点去执行读取操作即可
  • 为了保证高可用,Cluster模式也引入主从复制模式,一个主节点对应一个或者多个从节点,当主节点宕机的时候,就会启用从节点,从节点作为备用节点,不提供请求,只作为故障转移使用
    • Cluster模式集群节点最小配置6个节点(3主3从,因为需要半数以上)
  • 主节点宕机后需要对应的salve节点升级做故障处理,如果主节点与其对应的slave节点全部宕机,则集群不可用

分片是将数据划分为多个部分的方法,可以将数据存储到多台机器里面,这种方法在解决某些问题时可以获得线性级别的性能提升

假设有 4 个 Redis 实例 R0,R1,R2,R3,还有很多表示用户的键 user:1,user:2,… ,有不同的方式来选择一个指定的键存储在哪个实例中。

  • 最简单的方式是范围分片,例如用户 id 从 01000 的存储到实例 R0 中,用户 id 从 10012000 的存储到实例 R1 中,等等。但是这样需要维护一张映射范围表,维护操作代价很高。
  • 还有一种方式是哈希分片,使用 CRC32 哈希函数将键转换为一个数字,再对实例数量求模就能知道应该存储的实例。

根据执行分片的位置,可以分为三种分片方式:

  • 客户端分片:客户端使用一致性哈希等算法决定键应当分布到哪个节点
    • Memcached使用的就是该方法
  • 代理分片:将客户端请求发送到代理上,由代理转发请求到正确的节点上。
    • MySQL中的mycat
  • 服务器分片Redis Cluster

cluster集群的优缺点

  • 优点
    • 支持线上扩容,动态的节点上下线
    • 支持主从复制模式,提高可用性,完成故障转移
    • 可以直连Redis节点,不需要中间代理,连接任一节点都能对其他节点的数据进行操作
  • 缺点
    • 数据通过异步复制,不保证数据的强一致性
    • 批量操作限制,目前只支持具有相同slot值的key执行批量操作,对mset、mget、sunion等操作支持不友好
    • 对于多节点的事务不支持
    • slave充当“冷备”,不能缓解读压力
    • 不支持多数据库空间,单机redis可以支持16个db,集群模式下只能使用一个,即db 0

Redis集群方案参考

  • 三种分布式寻址算法
    • hash算法
      • 当遇到节点的动态上下线时,会出现缓存失效
    • 一致性哈希算法
      • 使用一致性哈希圆环
      • 对于出现的节点分布不均匀的问题,使用虚拟节点,一台物理服务器对应多个虚拟节点,由虚拟节点参加一致性哈希圆环的分配过程
    • 哈希槽算法
  • Redis三种集群方案
  • Redis哈希槽

Redis如何设计分布式锁

引言

  • 所谓分布式锁是相对于单体系统的锁的,在Java并发编程中使用的锁是线程锁,在单体系统中使用,但是当使用集群部署时,就涉及到不同主机上的不同进程之间服务的并发,这就要用到一个独立的加锁系统也就是分布式锁,一般会基于Redis的setnx命令去实现
    • 分布式系统中有并发同步需求时就需要一个分布式锁了
  • 补充:
    • zookeeper也可以实现分布式锁

大概实现

  • Redis 锁主要利用 Redis 的 setnx 命令。

    • 加锁命令:SETNX key value,当键不存在时,对键进行设置操作并返回成功,否则返回失败。KEY 是锁的唯一标识,一般按业务来决定命名。
    • 解锁命令:DEL key,通过删除键值对释放锁,以便其他线程可以通过 SETNX 命令来获取锁。
    • 锁超时:EXPIRE key timeout, 设置 key 的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放(万一持有锁的服务挂掉了,可以保证锁可以自动释放),避免资源被永远锁住。
    • 可以认为key就是一个分布式锁
  • 伪代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    if (setnx(key, 1) == 1){
    expire(key, 30)
    try {
    //TODO 业务逻辑
    } finally {
    del(key)
    }
    }

Redis实现分布式锁面临的问题以及优化策略

setnx与expire的非原子性

  • 加锁后,由于客户端服务器宕机或者网络问题,导致没有设置超时时间,成为死锁

  • 解决办法:使用lua脚本以实现两个命令执行时的原子性

    1
    2
    3
    4
    5
    6
    7
    8
    if (redis.call('setnx', KEYS[1], ARGV[1]) < 1)
    then return 0;
    end;
    redis.call('expire', KEYS[1], tonumber(ARGV[2]));
    return 1;

    // 使用实例
    EVAL "if (redis.call('setnx',KEYS[1],ARGV[1]) < 1) then return 0; end; redis.call('expire',KEYS[1],tonumber(ARGV[2])); return 1;" 1 key value 100

锁的误解除

  • 如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁

  • 解决办法,加锁时给value设置为当前线程的惟一的uuid,删除锁时首先验证uuid是否是当前线程的uuid,同样使用lua脚本执行此逻辑

    1
    2
    3
    4
    5
    6
    7
    8
    // 加锁
    String uuid = UUID.randomUUID().toString().replaceAll("-","");
    SET key uuid NX EX 30
    // 解锁
    if (redis.call('get', KEYS[1]) == ARGV[1])
    then return redis.call('del', KEYS[1])
    else return 0
    end

超时解锁导致并发

  • 与锁的误解除属于相同的情景,都是因为超时时间不够执行任务,导致任务还没执行完,锁已经解除,此时另外一个线程获取锁,造成了并发的局面,这肯定是违背锁的设计理念的
  • 解决办法
    • 将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成
    • 为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间

可重入锁的实现

  • 一般使用以下两个方案

    • Java ThreadLocal记录锁的重入次数,ThreadLocak存储一个Map ,key为Redis Key value就是重入的次数

    • 使用hash结构来实现可重入,Redis key就是锁,Hash Key就是线程标识 Hash Value就是重入次数

      1
      // 如果 lock_key 不存在if (redis.call('exists', KEYS[1]) == 0)then    // 设置 lock_key 线程标识 1 进行加锁    redis.call('hset', KEYS[1], ARGV[2], 1);    // 设置过期时间    redis.call('pexpire', KEYS[1], ARGV[1]);    return nil;    end;// 如果 lock_key 存在且线程标识是当前欲加锁的线程标识if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)    // 自增    then redis.call('hincrby', KEYS[1], ARGV[2], 1);    // 重置过期时间    redis.call('pexpire', KEYS[1], ARGV[1]);    return nil;    end;// 如果加锁失败,返回锁剩余时间return redis.call('pttl', KEYS[1]);

如何支持锁重试

  • setnx的命令是非阻塞的,如果要支持客户端重试获得锁的话,有以下两种方案

    • 可以通过客户端轮询的方式解决该问题,当未获取到锁时,等待一段时间重新获取锁,直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的效率

    • 另一种方式是使用 Redis 的发布订阅功能,当获取锁失败时,订阅锁释放消息,获取锁成功后释放时,发送锁释放消息

      image-20210712135322669

Redis集群中遇到的问题

  • 主备切换后丢失锁信息
  • 集群脑裂,即因为网络问题,导致 Redis master 节点跟 slave 节点和 sentinel 集群处于不同的网络分区,因为 sentinel 集群无法感知到 master 的存在,所以将 slave 节点提升为 master 节点,此时存在两个不同的 master 节点,此时两个客户端分别连接两个master,可以同时获得同一把锁

ReadLock

  • Redlock是redis官方提出的实现分布式锁管理器的算法

  • 前边提到的各种方案实际上在Redis集群下都是存在一致性问题的,即前边提到的Redis集群中遇到的各种问题,为解决Redis集群下使用Redis作为分布式锁时出现的问题,推荐使用ReadLock算法

  • ReadLock算法的基本思想

    • 在分布式版本的算法里我们假设我们有N个Redis master节点,这些节点都是完全独立的,我们不用任何复制或者其他隐含的分布式协调算法。我们已经描述了如何在单节点环境下安全地获取和释放锁。因此我们理所当然地应当用这个方法在每个单节点里来获取和释放锁。在我们的例子里面我们把N设成5,这个数字是一个相对比较合理的数值,因此我们需要在不同的计算机或者虚拟机上运行5个master节点来保证他们大多数情况下都不会同时宕机。一个客户端需要做如下操作来获取锁:
      1. 获取当前时间(单位是毫秒)
      2. 轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点
      3. 客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了
      4. 如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间
      5. 如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁
  • ReadLock的使用,可以通过导入redission包来使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!-- JDK 1.8+ compatible -->
    <dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.9.0</version>
    </dependency>

    <!-- JDK 1.6+ compatible -->
    <dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>2.14.0</version>
    </dependency>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    public class ReadLockDemo {

    // 尝试获取锁时的超时时间
    private long WAIT_TIMEOUT = 5L;

    // 锁的持有时间,获取锁后超过这个事件后会释放锁,该时间应大于业务逻辑处理时间
    private long RELEASE_TIME = 10L;

    private String LOCK_KEY = "REDISSION_LOCK";

    RedissonRedLock lock;

    public ReadLockDemo() {

    }

    public ReadLockDemo(long timeout, long releaseTime, String key) {

    this.WAIT_TIMEOUT = timeout;
    this.LOCK_KEY = key;
    this.RELEASE_TIME = releaseTime;

    /**
    * 可以从配置文件读,在springboot总处理应该很方便
    */
    Config cfg1 = new Config();
    Config cfg2 = new Config();
    Config cfg3 = new Config();

    // 形成配置
    cfg1.useSingleServer().setAddress("").setPassword("").setDatabase(0);
    cfg2.useSingleServer().setAddress("").setPassword("").setDatabase(0);
    cfg3.useSingleServer().setAddress("").setPassword("").setDatabase(0);

    // 构建客户端
    RedissonClient client1 = Redisson.create(cfg1);
    RedissonClient client2 = Redisson.create(cfg2);
    RedissonClient client3 = Redisson.create(cfg3);

    // 获取Rlock
    RLock lock1 = client1.getLock(LOCK_KEY);
    RLock lock2 = client2.getLock(LOCK_KEY);
    RLock lock3 = client3.getLock(LOCK_KEY);

    lock = new RedissonRedLock(lock1, lock2, lock3);

    }

    public boolean tryLock() throws InterruptedException {

    return lock.tryLock(WAIT_TIMEOUT, RELEASE_TIME, TimeUnit.SECONDS);
    }

    public void unLock() {

    lock.unlock();
    }

    public static void main(String[] args) {

    ReadLockDemo readLock = new ReadLockDemo(5L, 10L, "RedissionRedLockTest");

    try {
    if (readLock.tryLock()) {
    // 加锁后的业务操作
    }
    } catch (InterruptedException e) {
    e.printStackTrace();
    } finally {
    readLock.unLock();
    }

    }

    }

Redission

  • 待定

参考