Redis 核心技术

1. Redis 核心数据结构与高性能原理
 1.1 Redis 核心数据结构
  1.1.1 string
  1.1.2 hash
  1.1.3 list
  1.1.4 set
  1.1.5 zset
  1.1.6 bit
  1.1.7 geo
  1.1.8 其他高级命令
 1.2 Redis 高性能核心原理
 1.3 管道(pipeline)
 1.4 Lua 脚本
 1.5 Redis 的设计与实现
2. Redis 持久化、主从与哨兵架构
 2.1 Redis 持久化
  2.1.1 RDB 快照(snapshot)
  2.1.2 AOF(append-only file)
  2.1.3 RDB 与 AOF 的选择
  2.1.4 混合持久化
 2.2 Redis 主从架构
 2.3 Redis 哨兵高可用架构
  2.3.1 哨兵 leader 选举流程
  2.3.2 哨兵架构缺点
3. Redis 集群架构
 3.1 集群原理分析
  3.1.1 槽位定位算法
  3.1.2 跳转重定向
  3.1.3 Redis 集群节点间的通信机制
  3.1.4 网络抖动
 3.2 集群选举原理分析
  3.2.1 集群是否完整才能提供服务
4. Redis 分布式锁
 4.1 分布式锁的适用场景
 4.2 Redis 分布式锁的实现
 4.3 Redis 实现分布式锁的问题
 4.4 如何保障一致性问题
 4.5 高性能的分布式锁如何实现
5. Redis 缓存设计问题以及性能优化
 5.1 缓存穿透
  5.1.1 布隆过滤器
   5.1.1.1 概念
   5.1.1.2 添加元素
   5.1.1.3 查询是否包含某元素
   5.1.1.4 现有实现
   5.1.1.5 适用场景
 5.2 缓存失效
 5.3 缓存雪崩
 5.4 热点缓存 key 重建优化
 5.5 数据库双写一致性解决方案
 5.6 Redis 开发规范与性能优化
  5.6.1 键值设计
   5.6.1.1 key 设计
   5.6.1.2 value 设计
  5.6.2 命令使用
  5.6.3 客户端使用

1. Redis 核心数据结构与高性能原理

1.1 Redis 核心数据结构

Redis 的核心数据结构主要由:string、list、hash、set、zset、bitmap

1.1.1 string

常用操作:

  • set:存入字符串键值对

  • get:获取字符串键值对

  • del:删除字符串键值

  • mset:批量存储字符串键值对

  • mget:批量获取字符串键值对

  • expire:设置一个键的过期时间

  • incr:将 key 中存储的值加一(原子加减)

  • decr:将 key 中存储的值减一(原子加减)

  • incrby:将 key 中存储的值加指定值(原子加减)

  • decrby:将 key 中存储的值减指定值(原子加减)

应用场景:

  • 单值缓存

    • set key value
      get key
      
  • 对象缓存

    • 方式一:
      	set user:{id} value(json 格式)
      	get user:{id}
      方式二:推荐方式
      	mset user:{id}:name zhangsan user:{id}:age 20
      	mget user:{id}:name user:{id}:age
      
  • 分布式锁

    • setnx {lockkey} 1 # 返回 1 表示获取锁成功
      setnx {lockkey} 1 # 返回 0 表示获取锁失败
      # 执行业务操作
      ...
      del {lockkey} # 业务完成删除锁
      
      set {lockkey} 1 ex 10 nx # 设置过期时间,防止程序意外终止无法释放锁
      
  • 计数器

    • incr {key}
      get {key}
      
  • Web 集群 session 共享

    • spring session + redis 实现 session 共享
      
  • 分布式系统 ID

    • incrby {key} 1000 # Redis 批量生成序列号提升性能
      
      # 当然还有其他的分布式 ID 解决方案,比如雪花算法、zookeeper 生成等等
      
  • 分布式限流器

1.1.2 hash

常用操作:

  • hset:存储一个哈希表 key 的一个 field 键值
  • hsetnx:存储一个不存在的哈希表 key 的 field 键值
  • hmset:存储一个哈希表 key 的多个field 键值
  • hget:获取哈希表 key 对应的一个 field 值
  • hmget:获取哈希表 key 对应的多个 field 值
  • hdel:删除哈希表 key 中的 field 键值
  • hlen:返回哈希表 key 中的 field 的数量
  • hgetall:返回哈希表 key 中的所有的 field 键值
  • hincrby:为哈希表 key 中 field 键的值加上增量

应用场景:

  • 对象存储

    • hmset user {id}:name zhangsan {id}:age 20
      hmget user {id}:name {id}:age
      
  • 电商购物车

    • 购物车的设计
      1. 用户 id 为 key
      2. 商品 id 为 field
      3. 商品数量为 value # 当然这里的还可能会存储一个集合数据,包括商品数量、修改时间、电商 id 等等,按照相应的需求设计
      
      购物车的操作
      1. 添加商品:hset cart:1001 1001 1
      2. 增加数量:hincrby cart:1001 1001 1
      3. 商品总数:hlen cart:1001
      4. 删除商品:hdel cart:1001 1001
      5. 获取购物车所有商品:hgetall cart:1001
      

hash 结构优缺点:

优点:

  • 同类数据归类整合存储,方便管理数据
  • 相比 string 操作消耗内存与 cpu 更小
  • 相比 string 存储更节省空间

缺点:

  • 过期功能不能在 field 上使用,只能用在 key 上
  • Redis 集群架构下不适合大规模使用,因为 key 是会被划分到固定的 slot 上,即划分到固定集群节点上

1.1.3 list

常用操作:

  • lpush:将一个或多个 value 插入到 key 的表头(最左边)
  • rpush:将一个或多个 value 插入到 key 的表尾(最右边)
  • lpop:移除并返回 key 列表头元素
  • rpop:移除并返回 key 列表尾元素
  • lrange:返回列表 key 中指定区间内的元素,区间以偏移量 start 和 stop 指定
  • blpop:移除并返回列表 key 的头元素,如果列表中没有元素,则阻塞
  • brpop:移除并返回列表 key 的尾元素,如果列表中没有元素,则阻塞

应用场景:

  • 常用数据结构

    • stack(栈)= lpush + lpop -> FILO 先进后出
      queue(队列)= lpush + rpop -> FIFO 先进先出
      blocking queue(阻塞队列)= lpush + brpop
      
  • 微博和微信公众号消息流

    •   比如在微博上关注了某些博主 A、B
        1. A 发了条微博,消息 id 为 1001
        	lpush msg:{粉丝 id} 1001
        2. B 发了条微博,消息 id 为 1002
        	lpush msg:{粉丝 id} 1002
        3. 粉丝查看最新微博消息
        	lrange msg:{粉丝 id} 0 5
      

1.1.4 set

常用操作:

  • sadd:往集合 key 中存入元素,元素存在则忽略,若 key 不存在则添加
  • srem:从集合 key 中删除元素
  • smembers:获取集合 key 中的所有元素
  • scard:获取集合 key 元素的个数
  • sismember:判断元素是否存在集合 key 中
  • srandmember:从集合 key 中选出 count 个元素,元素不从 key 中删除
  • spop:从集合 key 中选出 count 个元素,元素从 key 中删除
  • sinter:交集运算
  • sinterstore:将交集结果存入新的集合 destination 中
  • sunion:并集运算
  • sunionstore:将并集结果存入新的集合 destination 中
  • sdiff:差集运算
  • sdiffstore:将差集结果存入新的集合 destination 中

应用场景:

  • 抽奖

    • 1. 点击参与抽奖
      	sadd key {userid}
      2. 查看参与抽奖的所有用户
      	smembers key
      3. 抽取 count 名中奖用户
      	spop key [count] / srandmember key [count]
      
  • 微信微博点赞、收藏、标签

    • 1. 点赞
      	sadd like:{消息 id} {用户 id}
      2. 取消点赞
      	srem like:{消息 id} {用户 id}
      3. 检查用户是否点过赞
      	sismember like:{消息 id} {用户 id}
      4. 获取点赞的用户列表
      	smembers like:{消息 id}
      5. 获取点赞用户个数
      	scard like:{消息 id}
      
  • 集合操作

    • set1 -> {a,b,c}
      set2 -> {b,c,d}
      set3 -> {c,d,e}
      
      sinter set1 set2 set3 -> {c}
      sunion set1 set2 set3 -> {a,b,c,d,e}
      sdiff set1 set2 set3 -> {a} # 以 set1 为基准,查找它与其他集合中不一样的数据
      
  • 集合操作实现微博微信关注模型

    • A 关注的人:
      	Aset -> {B,C,D}
      B 关注的人:
      	Bset -> {A,C,D,E}
      C 关注的人:
      	Cset -> {A,B,D,E,F}
      
      A 进入 B 的主页,查看以下信息
      1. A 和 B 共同关注的人:
      	sinter Aset Bset -> {C,D}
      2. A 关注的人也关注他(B):
      	sismember Cset B
      	sismember Dset B
      3. A 可能认识的人:
      	sdiff Bset Aset -> {E}
      
  • 集合操作实现电商商品筛选

    • 比如京东搜索商品页面,筛选笔记本电脑。支持品牌、操作系统、CPU 品牌、分辨率等等选项进行筛选。那么在后台需要维护这些筛项集合。
      
      如果要添加一款手机,那么可能会这样做:
      sadd brand:huawei p30
      sadd brand:xiaomi mi5
      sadd brand:iphone iphone13
      sadd os:android p30 mi5
      sadd cpu:brand:intel p30 mi5
      sadd ram:8g p30 mi5 iphone8
      
      那么选择出:安卓系统、Intel 的 CPU、内存 8g 的手机:
      sinter os:android cpu:brand:intel ram:8g -> {p30,mi5}
      

1.1.5 zset

常用操作:

  • zadd:往有序集合 key 中添加带分值的元素
  • zrem:从有序集合 key 中删除元素
  • zscore:返回有序集合 key 中元素 member 的分值
  • zincrby:为有序集合 key 中的元素 member 的分值加上 increment
  • zcard:返回有序集合 key 中的个数
  • zrange:正序获取有序集合 key 从 start 下标到 stop 下标的元素
  • zrevrange:倒序获取有序集合 key 从 start 下标到 stop 下标的元素
  • zunionstore:并集计算,并存储到 destkey 集合中
  • zinterstore:交集计算,并存储到 destkey 集合中

应用场景:

  • 集合操作实现排行榜

    • 微博热搜、头条热榜等等
      
      1. 点击新闻
      	zincrby hotNews:20201217 1 法国总统马克龙新冠检测呈阳性
      2. 展示当日排行前十
      zrevrange hotNews:20201217 0 10 withscores
      3. 七日搜索榜单计算
      	zunionstore hotNews:20201211-20201217 7 hotNews:20201211 .. hotNews:20201217
      4. 展示七日排行前十
      	zrevrange hotNews:20201211-20201217 0 10 withscores
      

1.1.6 bit

常用操作:

  • setbit:设置 key 指定偏移量的值,值只能为 0 或 1
  • getbit:返回 key 指定偏移量的值
  • bitop:对不同的 key 的位进行位运算操作(and、or、not、xor)
  • bitCount:返回指定 key 中值为 1 的个数

应用场景:

  • 用户在线状态

    • 使用一个名为 online 的 key 保存数据,用户的 id 为 offset,在线状态用 0 和 1 表示
      setbit online {用户 id} 1/0
      
      如果用户很多,那就进行分片。
      
  • 统计活跃用户

    • 使用时间为缓存 key,用户 id 为 offset,当日活跃就设置 1
      setbit active:20201217 {uid} {status}
      
  • 用户签到

    • 每个用户有自己的签到 bitmap 作为 key,设置一个活动开始时间,用当前时间减去活动开始时间的天数作为 offset,是否签到用 0 和 1 表示。
      start_date = 2020-12-01
      current_date = 2020-12-17
      currentDays = current_date - start_date;
      
      setbit sign:{uid} currentDays 1
      
      计算活跃天数
      bitcount sign:{uid} 0 -1
      
  • 布隆过滤器

bitmap 的优缺点:

优点:

  • 基于最小单位 bit 进行存储,节省空间
  • 时间复杂度为 O(1),操作快,计算快
  • 方便扩容

缺点:

  • Redis 中的 bit 映射被限制在 512MB,最大是 2^32。建议每个 key 的位数都控制下

参考资料:https://blog.csdn.net/u011957758/article/details/74783347

1.1.7 geo

geo 是一种基于 zset 结构存储的数据结构,用于构建地理位置数据。

常用操作:

  • geoadd:添加一个或多个地址空间位置到 zset
  • geohash:返回一个标准的地理空间的 geohash 字符串
  • geopos:返回地理空间的经纬度
  • geodist:返回两个地理空间之间的距离
  • georadius:查询指定半径内所有的地理空间元素的集合
  • georadiusbymember:查询指定半径匹配到最大距离的一个地理空间元素

应用场景:

  • 微信、微博附近的人
  • 微信<摇一摇><抢红包>
  • 滴滴打车、青桔单车<附近的车>

Redis 更多应用场景:

  • 搜索自动补全
  • 布隆过滤器

1.1.8 其他高级命令

keys:全量遍历,用来列出所有满足特性正则表达式的 key,当 redis 数据量比较大的时候,性能很差,要避免使用

scan:渐进式遍历键。像游标一样的遍历匹配条件的 key,集合类的数据结构也有对应的遍历键

info:查看 Redis 服务运行信息

1.2 Redis 高性能核心原理

Redis 单线程为什么还能这么快?

因为它的所有数据都是在内存中的,所有的运算都是内存级别的运算,而且单线程避免了多线程的上线文切换性能损耗问题。正是因为 Redis 是单线程的,所有要小心使用 Redis 指令,避免那些耗时的指令(keys)。

Redis 单线程如何处理那么多的并发客户端连接?

Redis 采用 IO 多路复用,它利用 epoll 来实现 OP 多路复用,将连接信息和事件放入队列中,依次放到文件事件分派器,事件分派器将事件分发给事件处理器。有连接应答处理器、命令请求处理器、命令回复处理器,单线程主要是说它的各个事件处理器是单线程的。

同样 NGINX 也是采用 IO 多路复用原理解决 C10K 问题。同样 Java 中的 NIO 也是采用 IO 多路复用。

1.3 管道(pipeline)

客户端可以一次性发送多个请求而不用等待服务器的响应,待所有的命令都发送完毕之后在一次性读取服务的相应,这样可以极大的降低多条命令执行的网络传输开销,管道执行多条命令的网络开销实际上相当于一次命令执行的网络开销。

需要注意管道用 pipeline 方式打包命令发送,Redis 必须在处理完所有命令前先缓存其所有命令的处理结果。打包的命令越多,缓存消耗的内存也越多。所以并不是打包的命令越多越好。

pipeline 中发送的每个命令都会被 server 立即执行,如果执行失败,然后会在此后的响应中得到信息;也就是 pipeline 并不是表达“所有命令都一起执行成功”的语义。管道中前面的命令失败,不会影响后面的命令执行。

Pipelinepl=jedis.pipelined();
for(inti=0;i<10;i++){
  pl.incr("pipelineKey");
	pl.set("key" + i, "" + i);
}
List<Object>results=pl.syncAndReturnAll();
System.out.println(results);

1.4 Lua 脚本

Redis 2.6 版本推出了脚本功能,允许开发者使用 Lua 语言编写脚本传到 Redis 中去执行。

使用脚本的好处:

  1. 减少网络开销:本来多次网络请求,可以用一个请求就完成,减少了网络往返时延,类似于管道。
  2. 原子操作:将整个脚本作为一个整体执行,中间不会被其他命令插入。管道不是原子的,不过 Redis 的批量操作命令(比如 mset)是原子的。
  3. 代替 Redis 的事务功能

可以使用 eval 命令来执行 lua 脚本。

注意,不要在 lua 脚本中出现循环和耗时的运算,否则 Redis 会阻塞。将不会接受其他的命令。所以使用时注意不要出现死循环、耗时的计算。

1.5 Redis 的设计与实现

TODO 2020-12-17 20:19:41

2. Redis 持久化、主从与哨兵架构

2.1 Redis 持久化

2.1.1 RDB 快照(snapshot)

默认情况下,Redis 将内存中的数据快照保存到名为 dump.db 的二进制文件中,简称 rdb 文件。

自动保存:可以对 Redis 进行设置,让它“在 N 秒内数据集至少有 M 个改动”这一条件被满足时,自动保存数据集,设置为 save N M 。

手动保存:进入 Redis 客户端,执行 save 或者 bgsave 命令可以触发手动保存数据集,这两个命令的区别是 bgsave 是用后台生成 rdb 文件。

2.1.2 AOF(append-only file)

快照功能并不耐久,不能实时的保存数据,Redis 提供了一种实时记录操作指令的持久化方式:AOF,将修改的每一条指令记录到 appendonly.aof 文件中,简称 aof 文件。

AOF 默认是关闭,建议打开。它有三个配置选项:

  • appendfsync always:每次有新的命令就立即同步到 AOF 文件中。
  • appendfsync everysec:每秒同步一次指令,足够快,Redis 宕机时只会丢失 1 秒的数据(推荐)。
  • appendfsync no:从不同步,而是交给操作系统来处理。更快,但是不安全。

AOF 重写:aof 文件中可能有太多没用的指令,比如执行多次 set 操作,但是恢复数据时只会选择最近的 set 的值,所以 AOF 会定期根据内存的最新数据生成 aof 文件。

可以通过配置 AOF 自动重写的频率:

  • auto-aof-rewrite-min-size 64mb:AOF 文件至少要达到 64M 才会自动重写
  • auto-aof-rewrite-percentage 100:AOF 文件自上一次重写后文件大小增长了 100%则再次触发重写

当然还可以通过命令手动的重写 aof 文件,进入客户端执行 bgrewriteaof,AOF 重写时 Redis 会 fork 出一个子进程去执行,并不会对 Redis 的正常命令处理有太多的影响。

2.1.3 RDB 与 AOF 的选择

RDB 与 AOF 该如何选择呢?

命令 RDB AOF
启动优先级
体积
恢复速度
数据安全性 容易丢失数据 根据策略决定
场景 冷备 热备

Redis 启动时如果 rdb 文件和 aof 文件,则优先选择 aof 文件恢复数据,因为 aof 文件一般

2.1.3 混合持久化

重启 Redis 时,我们很少使用 RDB 来恢复内存状态,因为太容易丢失数据了。通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 RDB 来说慢很多,这样会导致 Redis 启动需要花费比较长的时间。

Redis 4.0 增加混合持久化来解决这个问题,可以通过配置 aof-use-rdb-preamble yes 来开启该功能。

开启混合持久化之后,AOF 在重写时,不是单纯的将内存数据转换为 RESP 命令写入 aof 文件,而是会重写这一刻的内存做 RDB 快照处理,并且将 RDB 快照内存和增量的 AOF 修改内存数据的命令存在一起,都一起写入 aof 文件。

这样 Redis 在重启的时候,可以先加载 RDB 的内容,然后再重放增量的 AOF 日志,就可以完全代替之前的 AOF 全量文件重放,大大提高重启效率。

混合持久化的 aof 文件结构:

混合持久化的 aof 文件结构

2.2 Redis 主从架构

主从架构

Redis 主从工作原理

如果为一个 master 配置了一个 slave,不管这个 slave 是否是第一次连接上 master,都会发送一个 sync 命令,master 收到指令后,会在后台进行数据持久化通过 bgsave 命令生辰当前最新的快照 rdb 文件,持久化期间,master 会继续接受客户端的指令,同时把这些可能修改数据集的请求缓存到内存中。当持久化完毕后,master 会把这个份 rdb 文件发送给 slave,slave 会把接收到的数据进行持久化生成 rdb,然后加载到内存中。然后 master 再将之前缓存在内存中的命令发送给 slave。

当 master 与 slave 之间的连接由于某些原因断开时,slave 能够自动重连到 master,如果 master 收到了多个 slave 的并发连接请求,它只会进行一次持久化。

当 master 与 slave 断开重连后,只进行部分数据复制(2.8 版本后)。

master 会在其内存中创建一个复制数据用的缓存队列,缓存最近一段时间的数据,master 和它所有的 slave 都会维护了复制的数据下标 offset 和 master 的进程 id,因此,当网络连接断开后,slave 会请求 master 继续进行未完成的复制,从所记录的数据下标开始。如果 master 的进程 id 变化了,或者 slave 数据下标的 offset 太旧了,已经不存在 master 的缓存队列中,那么就将进行一次全量数据复制。

Redis 从 2.8 版本后,改用支持部分数据复制的命令 PSYNC 去 master 同步数据。

主从复制(全量复制)的流程:

主从复制(全量复制)

主从复制(部分复制)的流程:

主从复制(部分复制)

2.3 Redis 哨兵高可用架构

哨兵高可用架构

哨兵 sentinel 是特殊的 Redis 服务,不提供读写服务,主要是用来监控 Redis 实例节点。

哨兵架构下客户端第一次从哨兵找出 Redis 的主节点,然后直接访问 Redis 的主节点,不会每次都通过 sentinel 代理访问 Redis 的主节点,当 Redis 的主节点发送变化时,哨兵第一时间感知到,并且将 Redis 主节点通知给客户端(这里 Redis 的客户端一般都实现了订阅功能,订阅哨兵发布的节点变动消息)。

2.3.1 哨兵 leader 选举流程

当一个 master 服务器被某个 sentinel 视为客观下线状态后,该 sentinel 会与其他的 sentinel 协商选出 sentinel 的 leader,这个 leader 负责故障转移工作。

每个发现 master 服务器进入客观下线的 sentinel 都可以要求其他 sentinel 选举自己为 leader,选举是先到先得。

同时每个 sentinel 每次选举都会自增配置纪元(选举周期),每个纪元只会选择一个 sentinel 的 leader。如果有超过一半的 sentinel 选举某个 sentinel 作为 leader,之后该 leader 进行故障转移操作,从存活的 slave 中选举出新的 master。

哨兵集群只有一个哨兵节点,Redis 的主从选举也能正常的进行,如果 master 挂了,那唯一的哪个哨兵就是哨兵 leader,可以正常选举 master。

不过为了高可用一般都推荐部署至少三个哨兵节点。因为过半选举节省机器资源。

2.3.2 哨兵架构缺点

Redis 哨兵架构有以下缺点:

  • 主从切换的瞬间会存在访问瞬断的情况
  • 哨兵模式只有一个主节点对外提供服务,没法支持很高的并发
  • 单个节点的内存不宜设置过大,否则会导致持久化文件过大,影响数据的恢复和主从同步效率

3. Redis 集群架构

Redis 3.0 开始支持集群模式。集群模式如下图所示:

集群架构

Redis 集群是一个由多个主从节点群组成的分布式服务器群,它具有复制、高可用、分片特性。

Redis 集群不需要 sentinel 哨兵也能完成节点的移除和故障转移的功能,需要将每个节点设置成集群模式,这种集群模式没有中心节点,可以水平扩展。可以线性扩展到上万个节点(推荐不要超过 1000 个节点)。

Redis 集群的性能和高可用都优于之前版本哨兵模式,且集群配置非常简单。

3.1 集群原理分析

Redis cluster 将所有的数据划分为 16384 个 slot(槽位),每个节点负责其中一部分槽位。槽位的信息存储在每个节点总。

当 Redis cluster 的客户端来连接集群时,它也会得到一个集群的槽位信息并将其缓存到客户端本地。这样当客户端要查找某个 key 时,可以直接定位目标节点。同时因为槽位的信息可能会存在客户端与服务器不一致的情况,还需要纠正机制来实现槽位信息的校验调整。

3.1.1 槽位定位算法

cluster 默认会对 key 值使用 crc16 算法进行 hash 得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体的槽位。

HASH_SLOT = CRC16(key) % 16384

3.1.2 跳转重定向

当客户端向一个错误的节点发出了指令,该节点会发现指令的 key 所在的槽位并不归自己管理,这时它会向客户端发送一个特殊的跳转指令携带目标操作的节点地址,告诉客户端去连接这个节点获取数据。

客户端端收到指令后跳转到正确的节点上去操作,还会同步更新纠正本地的操作映射表缓存,后续所有 key 将会使用新的槽位映射表。

3.1.3 Redis 集群节点间的通信机制

Redis cluster 节点间采用 gossip 协议来进行通信。

维护集群的元数据有两种方式:

  • 集中式:
    • 优点:元数据的更新和读取,时效性很好,一旦元数据出现变更立即就会更新到集中式的存储中,其他节点读取的时候立即就可以立即感知到
    • 缺点:将所有的元数据的更新压力全部集中在一个地方,可能会导致元数据的存储压力。
  • gossip:是一个无中心化的通信协议,包括:ping、pong、meet、fail 等消息
    • ping:每个节点都会频繁的给其他节点发送 ping,其中包含自己的状态还有自己维护的集群元数据,互相通过 ping 交换元数据;
    • pong:返回 ping 和 meet,包含自己的状态和其他信息,也可用于信息广播和更新;
    • fail:某个节点判断另一个节点 fail 之后,就发送 fail 给其他节点,通知其他节点,指定的节点宕机了;
    • meet:某个节点发送 meet 给新加入的节点,让新节点加入到集群中,然后新节点就会开始与其他节点进行通信,不需要发送形成网络的所需的所有 cluster meet 命令。发送 cluster meet 消息以便每个节点都能够达到其他每个节点,只需通过一条已知的节点链就够了。由于在心跳包中会交换 gossip 消息,将会创建节点间缺失的连接。
    • 优点:元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续,打到所有节点上去更新,有一定的延迟,降低了压力。
    • 缺点:元数据更新有有延迟,可能会导致集群的操作有一些滞后

3.1.4 网络抖动

网络抖动会导致集群节点之间连接变得不可用,然后很快又恢复正常。

Redis cluster 提供了一个选项 cluster-node-timeout,表示某个节点持续 timeout的时间失联时,才可以认定该节点出现故障,需要进行主从切换。

如果没有这个选项,网络抖动会导致主从频繁切换(数据的重复复制)。

3.2 集群选举原理分析

当 slave发现自己的master 变成 fail 状态时,便尝试进行 failover,以期望变成 master。由于挂掉的 master 可能会有多个 slave,从而存在多个 slave 竞争成为 master 节点的过程。其过程如下:

  1. slave 发现 master 变成 fail
  2. 将自己记录的集群 currentEpoch 加一,并广播 FAILOVER_AUTH_REQUEST 消息
  3. 其他节点收到该消息,只有 master 响应,判断请求的合法性,并发送 FAILOVER_AUTH_ACK,对每一个 epoch 只发送一次 ack
  4. 尝试 failover 的 slave收集 master返回的 FAILOVER_AUTH_ACK
  5. slave 收到超过半数 master 的 ack 后变成新 master(这里解释为什么必须要有 3 个主节点,如果只有两个,当其中一个挂掉,只剩下一个主节点是不能选举成功的)
  6. 广播 pong 消息通知其他集群节点

从节点并不是已进入 fail 状态就马上尝试发起选举,而是有一定的延迟,一定的延迟确保我们等待 fail 状态在集群中传播,slave 如果立即尝试选举,其他 master或许尚未意识到 fail 状态,可能会拒绝投票。

延迟计算公式:DELAY = 500ms + random( 0 ~ 500ms) + SLAVE_RANK * 1000ms

SLAVE_RANK 表示此 slave 从 master 复制数据的总量 rank。rank 越小代表已复制的数据越新。这种方式下持有最新数据的 slave 将会首先发起选举(理论上)。

3.2.1 集群是否完整才能提供服务

当配置了 cluster-require-full-coverage 为 no 时,表示当负责一个插槽的主库下线且没有相应的从库进行故障恢复时,集群仍然可用,如果为 yes 则集群不可用。

4. Redis 分布式锁

4.1 分布式锁的适用场景

  • 互联网秒杀
  • 抢优惠券
  • 接口幂等性校验

4.2 Redis 分布式锁的实现

  • 通过 setnx 命令设置锁,如果锁不存在则设置,否则就返回
  • 锁有过期时间,防止应用程序挂掉之后无法释放锁
  • 锁必须由创建者才能释放,并且锁释放要求使用 lua 脚本实现,包括查看锁是否存、锁是否是自己创建、以及删除锁操作
  • 锁支持自动延时(看门狗),防止程序在锁过期时间之内没有完成,而导致锁自动释放。

4.3 Redis 实现分布式锁的问题

  • 无法保证一致性。当在集群架构中,当一个应用程序获取了锁,而在 Redis 主从节点复制数据时,主节点挂掉,锁的数据还没有复制到从节点,此时会导致其他应用程序也获取到锁,导致无法保证锁的一致性。

4.4 如何保障一致性问题

  • 使用应用程序加锁后,同时使用 wait 命令,等待主节点数据同步到从节点,这样能确保 slave 成功复制到锁数据才执行。但是如果 master 在同步数据到 slave 时挂了,wait 命令会返回失败,需要重新获取锁。wait 只会阻塞发送它的客户端,不影响其它客户端。
  • 使用 Redlock 红锁,它是一个要求一个把锁要被多个 Redis 实例一起获取,当超过所有 Redis 实例的一半实例都获取到锁了,才认为锁获取成功。使用红锁的缺点很多,加锁、解锁耗时较长,难以在集群版本中实现,占用的资源过大,需要创建多个 Redis 实例。
  • 使用 zookeeper 实现分布式锁

4.5 高性能的分布式锁如何实现

在高并发情况下多个线程抢同一把锁等待耗时较长,比如秒杀同一个商品,此时我们就可以把一个商品按照库存来分解为多个锁,比如一个商品的库存是 1000,那么我们把库存分成 10 段,每一段是 100 个库存,这样锁也就可以由一个分解为 10 个锁,从而缓解了大量请求同时争抢一把锁的等待过长的问题。

5. Redis 缓存设计问题以及性能优化

5.1 缓存穿透

缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,通常出于容错的考虑,如果从存储层查不到数据则不写入缓存层。

缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存层保护后端的意义。

造成缓存穿透的基本原因有:

  1. 自身业务代码或数据出现问题
  2. 一些恶意攻击、爬虫等造成大量的空命中

缓存穿透的解决方案:

  1. 缓存空对象:当在存储层查不到对应的数据时,那么就给缓存层存储一个特定值的数据。让该数据被后端生成之后,重新写入缓存中。
  2. 布隆过滤器:先用布隆过滤器做一次过滤,对于不存在数据布隆过滤器一般都能过过滤掉。当布隆过滤器判断某个值存在时,这个值可能不存在,当它判断某个值不存在时,则它肯定不存在。

5.1.1 布隆过滤器

布隆过滤器

概念

布隆过滤器就是一个大型位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算的比较均匀。

添加元素

向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 运算得到一个整数索引,然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。

查询是否包含某元素

向布隆过滤器查询 key 时,和 add 操作一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么就说明布隆过滤器中这个 key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其他 key 存在所导致的。如果这个位数组比较稀疏,这个概率就很大,如果这个位数组比较拥挤,这个概率就会降低。

现有实现

在使用布隆过滤器时,需要对它进行初始化,即要把已存在的数据灌入到布隆过滤器中。

谷歌的 guava 包实现了布隆过滤器,不过它是基于 JVM 进程的,要想实现分布式布隆过滤器,需要基于 Redis 的 bitmap 来实现。

Redisson 框架已经实现了这些分布式对象,其中就包括分布式布隆过滤器。

适用场景

这种方式适用于数据命中不高、数据相对固定、实时性低(通常是大数据集)的应用场景,代码维护比较复杂,但是缓存空间占用较少。

5.2 缓存失效

当大批量缓存在同一时间失效可能会导致大量请求同时穿透缓存,直达数据库,可能会造成数据库瞬间压力过大甚至挂掉,对于这种情况我们在批量增加缓存时最好将这些缓存过期时间设置为一个时间段内不同的时间。

5.3 缓存雪崩

由于缓存层承受着大量请求,有效的保护存储层,但是如果缓存层由于某些原因不能提供服务(比如超大并发过来,缓存层支撑不住,或者缓存层设计不好,类似大量请求访问 bigkey,导致缓存支撑的并发急剧下降),于是大量的请求都会到达存储层,存储层的调用量会暴增,造成存储层也级联宕机的情况。

解决方案:

  1. 保证缓存层服务高可用,比如使用 Redis 集群架构
  2. 使用服务端使用依赖隔离组件为后端限流并降级,比如使用 hystrix 组件
  3. 服务端使用 JVM 缓存,即使 Redis 服务挂了也能抗住一部分流量,阻止流量打到存储层
  4. 提前演练,演练缓存层宕机之后,应用以及后端的负载情况以及可能出现的问题,对这些问题做预案

5.4 热点缓存 key 重建优化

一般我们都是用“缓存 + 过期时间”的策略既可以加速数据读写,有保证数据的定期更新这种模式基本能满足绝大部分需求。但是两个问题如果同时出现,可能就会对应用造成致命的危害:

  1. 当 key 是一个热点 key(例如一个人们的娱乐新闻),并发量非常大
  2. 重建缓存不能在短时间内完成,可能是一个复杂计算,例如复杂的 SQL、多次 I/O。多个依赖等

在缓存失效的瞬间,会大量的请求进来完成重建,造成后端负载增大,甚至可能会让应用崩溃。

解决这个问题就是要避免大量的请求同时重建缓存,我们可以利用互斥锁(也就是分布式锁)来解决,就是说同一时刻只允许一个请求重建缓存,其他请求等待重建缓存的请求执行完,重新从缓存获取即可(或者是立即返回服务器繁忙等信息)。

5.5 数据库双写一致性解决方案

当要更新存储层的数据时,有两种方式更新缓存,分别是:先更新数据库,再更新或者删除缓存;先更新或者删除缓存,再更新数据库。

对于上面说的更新或者删除缓存,我们一般按照按需保存缓存的原则,在更新数据时使用删除缓存,在重新查询缓存时再从数据库查数据缓存起来。

我们对比下两种方案:

  1. 先更新数据库,再删除缓存
    1. 优点:在更新数据库期间,不会影响到缓存数据,缓存还可以提供数据查询
    2. 缺点:如果在更新了数据库操作后,更新缓存操作之前的这个时间段,服务发生了宕机,导致缓存没有被更新,这样就数据库与缓存中的数据不一致
  2. 先删除缓存,再更新数据库
    1. 优点:先删除了缓存,缓存数据立刻失效,即使在更新数据库时宕机,下次查询也是会从数据库查询数据缓存起来,数据一致
    2. 缺点:可能存在一个线程删除了缓存,准备更新数据库;另一个线程进来查询缓存,发现缓存为空又去数据库里查询并缓存数据;然后第一个线程开始更新数据库。这样也导致了数据不一致

针对先删除缓存,再更新数据库方案的数据双写一致性问题,解决的思路就是使用互斥锁,在删除缓存和更新数据库的操作那里加一分布式锁,两个操作执行完毕之后再释放锁,,同时再构建缓存的逻辑处也使用相同的分布式锁,获得锁之后才可以从数据库查询数据并缓存起来,这样也保证了。

还有一种解决思路,就是将更新数据操作和查询并构建缓存的操作进行排队处理,在 JVM 中构建内存队列,即谁先来的就先处理谁。比如更新缓存操作先来的,那么就先处理更新缓存的操作,接着在处理构建缓存的操作。那么如果该服务部署了多个实例的话,那就就需要将借助分布式消息队列,将消息按照一定的规则发送到不同的服务实例,每个服务实例内部会根据消息创建不同的内存队列以及对应的消费线程进行消费。

TODO 2020-12-18 13:57:20(分布式消息队列如何保证消息在消费端的消费顺序)

5.6 Redis 开发规范与性能优化

5.6.1 键值设计

5.6.1.1 key 设计

  1. 可读性和可管理性:以业务名(或数据库名)为前缀,用冒号分割,比如业务名:表名:id
  2. 简洁性:保证语义的前提下,控制 key 的长度
  3. 不要包含特殊符号:空格、换行、单双引号以及其他转义字符

5.6.1.2 value 设计

  1. 拒绝 bigkey(防止网卡流量、慢查询):Redis 中,一个字符串最大 512MB,一个二级数据结构(例如 hash、list、set、zset)可以存储大约 40亿个(2^32-1)元素,但实际中如果存在下面的两种情况,就认为它是 bigkey:

  2. 字符串类型:它的 big 体现在单个 value 值很大,一般认为超过 10KB 就是 bigkey

  3. 非字符串类型:hash、list、set、zset,它们 big 体现在元素个数太多,元素个数不要超过 5000 个

  4. 非字符串的 bigkey,不要使用 del 删除,使用 hscan、sscan、zscan 方式渐进删除,同时要注意防止 bigkey 过期时间自动删除问题(例如一个 200 万的 zset 设计 1 小时过期,会触发 del 操作,造成阻塞)
    
    bigkey 的危害:
    	1. 导致 Redis 阻塞
    	2. 网络拥塞:假设一个 bigkey 1MB,客户端每秒访问量为 1000,即每秒产生 1000MB 的流量,对于普通的千兆网卡(按照字节算是 128MB/s)的服务来说是灭顶之灾
    	3. 过期删除:一个 bigkey 设置了过期时间,过期后会被删除,如果没有使用 Redis4.0 的过期异步删除(lazyfree-lazy-expire yes),就会产生阻塞 Redis 的可能性
    
    bigkey 的产生
    bigkey 的产生都是由于程序设计不当,或者对于数据规模预料不清楚造成的。
    	1. 社交类:粉丝列表,如果某些明星或大 V 不精心设计,很定时 bigkey
    	2. 统计类:例如按天存储某项功能或者网站的用户集合,除非没几个人用,否则笔试 bigkey
    	3. 缓存类:将数据从数据库 load 出来序列化到 Redis 里,要注意两点,一是不是有必要把所有字段都缓存;二有没有相关关联数据
    
    如何优化 bigkey
    	1. 拆
    		1. big list: list1、list2、list3 ... listn
    		2. big hash:将数据分段存储,比如一个存了 100 万用户的数据,可以拆分成 200 个 key,每个 key 下面存放 5000 个用户数据
    	2. 如果 bigkey 不可避免,也要思考下要不要每次把所有的元素都取出来(例如有时候仅仅需要 hmget,而不是 hgetall),删除也一样,用优雅的方式来处理
    
  5. 选择合适的数据类型:例如实体类型,用 hmset user:1 name tom age 20 代替 set user:1:name tom、set user:1:age 20

  6. 控制 key 的生命周期,建议使用 expire 设置过期时间(允许可以打算过期时间,防止集中过期)

5.6.2 命令使用

  1. O(N) 命令关注 N 的数量:在使用 hgetall、lrange、smembers、zrange、sinter 等命令时需要明确N的值。用hscan、sscan、zscan 进行遍历
  2. 禁用命令:keys、flushall、flushdb
  3. 合理使用 select:不用业务使用不同的 Redis 实例
  4. 使用批量操作提高效率
    1. 原生命令:mget、mset
    2. 非原生命令:pipeline
  5. 使用 lua 代替 Redis 事务

5.6.3 客户端使用

  1. 避免多个应用使用一个 Redis 实例:不相干的业务拆分,公共数据做服务化

  2. 使用带有连接池的数据库

    1. 连接池的优化建议:
      1. maxTotal:最大连接数,需要考虑因素有:希望 Redis 的并发量、客户端执行命令时间、Redis 资源、资源开销
         1. 比如一次命令执行时间平均耗时为 1ms,一个连接的 qps 大约是 1000,
         2. 业务期望的 qps 为 50_000
         3. 那么理论上需要的资源池大小 maxTotal 为 50_000 / 1000 = 50。实际上考虑到要预留一些资源,maxTotal 可以比理论值大一些
      2. maxIdle 和 minIdle:最大最小闲置连接数
      
  3. 高并发下建议客户端添加熔断功能(hystrix)

  4. 设置合理的密码

  5. Redis 对于过期键的三种清除策略

    1. 被动删除:当读/写一个已经过期的 key 时,会触发惰性删除,直接删除这个过期的 key

    2. 主动删除:由于惰性删除策略无法保证冷数据被及时删除,所以 Redis 会定期主动淘汰一批已经过期的 key

    3. 当已用内存超过 maxmemory 限定时,触发主动删除策略

      1. 第三种策略的情况如下:
        
        当前已用内存超过了 maxmemory 限定时,会触发主动清除策略
        选号 maxmemory-policy(最大内存淘汰策略)设置好过期时间。如果不设置最大内存,当 Redis 内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换(swap),让 Redis 的性能急剧下降。
        
        默认策略是 volatile-lru,即超过最大内存后,在过期键中使用 lru 算法进行 key 的删除,保证不过期数据不被删除,但是可能出现 OOM 问题。
        
        其他策略:
        - allkeys-lru:根据 lru 算法,不管数据有没有被超时时间,直到腾出足够空间为止
        - allkeys-random:随即删除所有键
        - volatile-random:随机删除过期键
        - volatile-ttl:根据键值对象的 ttl 属性,删除最近将要过期的数据,如果没有,则回退到 noeviction 策略
        - noevication:不会删除任何数据,拒绝所有写入操作并返回客户端错误信息
        
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章