分布式框架-Redis
恁爹说:没啥好说的,背就完事了~
目录
- 分布式框架-Redis
- 一、介绍
- 二、安装
- 三、Redis的5种核心数据结构及命令使用
- 四、Redis高性能
- 五、其他高级命令
- 六、Redis持久化方式
- 七、Redis数据备份策略
- 八、Redis主从架构
- 九、通过Java形式(Jedis)访问Redis主从集群
- 十、Redis哨兵高可用架构
- 十一、Spring Boot整合redis实现哨兵选举master
- 十二、Redis缓存高可用集群(Redis Cluster)
- 十三、Java操作Redis Cluster
- 十四、Spring Boot整合Redis Cluster
- 十五、Redis客户端(Jedis)数据分片源码解析
- 十六、Redis Cluster各项原理分析
- 十七、Redis Cluster批量操作
- 十八、Redis Cluster动态扩容
- 十九、Redis高并发分布式锁实战
- 二十、高并发分布式锁如何实现
- 二十一、深入理解Redis底层C源码(一)
- 二十二、Redis 高级应用:活用bitmap统计日活
- 二十三、深入理解Redis底层C源码(二)
- 二十四、Redis zset数据结构应用:Geohash算法
- 二十五、Redis 6.0新特性
- 二十六、Redis缓存设计与性能优化
一、介绍
Redis是常用的缓存中间件
二、安装
- 版本:
- 稳定
5.0.3
- 最新
6.0
- 步骤:
- 关键:
- 本地虚拟机搭建需要注释掉
bind
参数,因为bind
会绑定服务端网卡的ip protected-mode no
:关闭保护模式,开启的话只有本机才可以访问redis
- 本地虚拟机搭建需要注释掉
- 详细:
下载地址:http://redis.io/download
下载地址:http://redis.io/download
安装步骤:
# 安装gcc
yum install gcc
# 把下载好的redis‐5.0.3.tar.gz放在/usr/local文件夹下,并解压
wget http://download.redis.io/releases/redis‐5.0.3.tar.gz
tar xzf redis‐5.0.3.tar.gz
cd redis‐5.0.3
# 进入到解压好的redis‐5.0.3目录下,进行编译与安装
make
# 修改配置
daemonize yes #后台启动
protected‐mode no #关闭保护模式,开启的话,只有本机才可以访问redis
# 需要注释掉bind
#bind 127.0.0.1(bind绑定的是自己机器网卡的ip,如果有多块网卡可以配多个ip,代表允许客户通过机器的哪些网卡ip去访问,内网一般可以不配置bind,注释掉即可)
# 启动服务
src/redis‐server redis.conf
# 验证启动是否成功
ps ‐ef | grep redis
# 进入redis客户端
src/redis‐cli
# 退出客户端
quit
# 退出redis服务:
(1)pkill redis‐server
(2)kill 进程号
(3)src/redis‐cli shutdown
三、Redis的5种核心数据结构及命令使用
redis命令帮助文档:redis.io/commands
3.1 介绍
- Redis组织数据主要有以下5种数据结构:
- 设置最外层key过期:
EXPIRE key名 秒数
3.2 字符串string类型
查看该数据结构的redis命令:
help @string
3.2.1 数据缓存
3.2.1.1 单值缓存(SET/GET)
- 设置值:
SET key value
- 取值:
GET key
3.2.1.2 对象缓存(MSET/MGET)
- 缓存整个对象:
SET user:1 value(json格式数据)
user
代表一个对象,1
是redis中该对象的id(记录id)- 特点:使用起来比较复杂
- 设置对象中的属性:
MSET user:1:name mazai user:1:balance 20000
1)若无对象则先创对象
2)语法记忆:1:name
就相当于excel里的行列号,这就好理解了
- 特点:
- 方便设置属性
- 属于批量操作命令,具备原子性
- 获取指定对象属性:
MGET user:1:name [user:1:balance 20000]
- 特点:属于批量操作命令,具备原子性。
3.2.2 分布式锁(乐观锁)
- 获取锁(SETNX):
SETNX product:100001 true
- 当且仅当key不存在才设中,才能新增键值对
product:100001 true
,从而得到锁 - 返回
1
代表获取锁成功,返回0
失败 - key存在,其他线程不作任何操作
- Java中对应乐观锁实现:
setIfAbsent(K key, V value)
- 代码示例:
@Autowired
RedisTemplate redisTemplate;
...
/**
* 利用Redis实现幂等处理
**/
// 指定一个key
String key = "Product:" + productId;
// 仅当key不存在时,才会新增key-null键值对并返回true;否则说明键值对存在直接返回false
boolean isNotExistedKey = redisTemplate.opsForValue().setIfAbsent(key, null);
// 查询的循环次数
int n = 0;
// 待获取的value
String value = null;
while (!isNotExistedKey) {// 如果客户端调用发现键值对存在,则继续进行以下循环(循环原因见下文)
// 很关键!!先让当前客户端睡眠500ms!!
// 尽管Redis是单线程的,但是由于此处相当于乐观锁,因此即便发现了键值对已经存在,也不代表键值对中的值已经设置完毕了
// 比如客户端1初次setIfAbsent发现键值对不存在,加锁(设置了key-value,不过value是null罢了),然后准备给value重新赋值,恰好客户端2访问该键值对发现已经存在,此时如果直接取出value就不符合意向了,所以加个等待时间并且要不断循环,直到value不为null
Thread.sleep(500);
n++;
value = redisTemplate.opsForValue.get(key);
// 仅当value值不为空才返回
if (StringUtils.isNotEmpty(value)) return value;
// 设置超时(超过50次循环)报错
if (n > 50) throw new Exception("获取value异常,键值对【" + key + " - " + value + "】已经存在,但value一直为空!");
}
// 如果客户端发现键值对起初尚未存在,则在获取锁后重新赋值
redisTemplate.opsForValue.set(key, "1000001", 1, TimeUnit.Hours);// 键值对仅保留1小时
- 释放锁(DEL):
DEL product:100001
- 设值并附上超时时间:
SET product:100001 true ex 10 nx
防止程序意外终止导致死锁
3.2.3 计数器
- 自增:
incr article:readcount:10001
- 10001表示文章id
- 获取数值:
get article:readcount:10001
3.2.4 Web集群session共享
spring session + redis实现session共享
3.2.5 分布式系统全局序列号
- 单增:
incr orderId
- 单独生成1个id,是之前orderId的自增
- 缺点:每个都来请求性能差
- 批量生成:
incr orderId 1000
- 后面1000表示批量生成1000个自增id,
orderId
的值加1000 - 此种方式性能更好
3.2.6 查看指定key的value在redis内存中占用的空间
1个字符1个字节
- 语法:
strlen key
3.3 哈希hash类型
3.3.1 结构
外层
key-value
,value
形式是hash表(即value
是一组组key-value),且外层value
中的键值对可以按照对象层次划分(一个value中有多个对象的属性和值)
3.3.2 常用命令
- 对象缓存:
HMSET user 1:name mazai 1:balance 2000
- 1表示记录id,也就是value中一个对象的id属性
联想excel表格
- 获取对象属性:
HMGET user 1:name
3.3.3 应用
- 电商购物车:
cart:1001
表示外层key,后续10088 1
表示商品编号10088的有1个
3.3.4 优缺点
- 优点:
- 同类数据归类整合存储,方便数据管理。
- 相比string类型的命令操作,消耗内存与cpu更小。
- 相比string类型,存储更节省空间。
- 缺点:
- 过期功能只能用在外层的key上面,外层
value
对应内部的key
(也就是field
)不能使用过期功能。 - Redis集群架构下不适合大规模使用。
4.1 Redis集群架构为什么不适合大规模使用Hash类型?
1)原因:redis集群中每个master节点应该保持负载均衡,当一个user对象的value(内部是一系列的key-value
,此处key
是属性[field])仅存在一个节点上且属性过多时,高并发的访问会使该单一节点压力过大,失去了负载均衡的意义。
2)解决方案:分段存储(把user
为key的对象其对应value属性分到多个节点上,访问维度精确到属性field,类似水平分表,具体见BigKey的解决方案)
- 会导致BigKey问题
4.2 关于BigKey问题
1)问题描述:如果我们使用一个user对象作为key来存储上百万的对象数据,那一个key对应的value有几百万条记录,调用一个HMGET user all
指令把value
拿出来会让redis跑半天,其他命令就卡住(因为redis是单线程模型),性能极差
2)解决方案:分段存储(类似水平分表)
2.1)例如有很多用户的id要存储到user
这个key下,那不直接存储,而是用具体的user id
对已有主节点个数取模,将其值作为新的user_X key(X为取模的值),并set
进去(若已有该user_X key,则将当前userid
对应key-value
直接set进去)
2.2)后续需要获取某个id
用户的个人信息,只要将id
取模就能得到对应key
,在hash到指定slot后就能取出key-value
3.4 列表list
3.4.1 结构
外层key-value,value形式是list链表,内部是一个个key-value键值对
3.4.2 命令
- LPUSH/RPUSH:
- 从表头(最左边)/表尾巴(最右边)插入:
LPUSH/RPUSH key value1 [value2...]
- LPOP/RPOP:
- 从表头(最左边)/表尾巴(最右边)移除:
LPOP/RPOP key
- LRange:
- 返回列表中指定区间内的元素(区间以偏移量
start
和stop
指定):LRANGE key start stop
- BLPOP:
BLPOP key1 [key2 ...] timeout
:从指定key
(可多个)对应列表表头(最左边)弹出一个元素,如果没有元素,阻塞等待timeout
秒;如果timeout
=0,一直阻塞等待。
- BRPOP:
BRPOP key [key ...] timeout
:从指定key
(可多个)对应列表表尾(最右边)弹出一个元素,如果没有元素,阻塞等待timeout
秒;如果timeout
=0,一直阻塞等待。
3.4.3 常用数据结构
- Stack(栈) = LPUSH + LPOP:FILO先进后出
- Queue(队列)= LPUSH + RPOP:FIFO先进先出
- Blocking MQ(阻塞队列)= LPUSH + BRPOP:FIFO先进先出
3.4.4 应用
微博和微信公号消息流
3.5 集合set
3.5.1 结构
- 外层key-value,value内部是一个无序集合
- 集合元素唯一
3.5.2 命令
SADD key member [member ...]
:向指定key
的set中添加元素member(元素非key-value形式)SREM key member [member ...]
:对指定key
的set删除指定元素SMEMBERS key
:获取指定key
的set中所有元素(获取后不删除)SCARD key
:获取指定key
的set的元素个数SISMEMBER key member
:判断元素是否存在于指定key
的set中SRANDMEMBER key [count]
:获取指定key
的set中count
个元素(获取后不删除)SPOP key [count]
:获取指定key
的set中count
个元素(获取后删除)
3.5.3 应用1
- 微信抽奖小程序:
- 点击参与抽奖加入集合:
SADD key {userlD}
- 查看参与抽奖所有用户:
SMEMBERS key
- 抽取
count
名中奖者:SRANDMEMBER key [count]
:选出后不从set中删除SPOP key [count]
:选出后从set中删除
- 微信微博点赞收藏:
如何实现其他用户只能看到与自己相关的人的点赞?
1)使用关注模型,属于使用set结构的集合操作,见下文。
3.5.4 集合操作(运算)
- 交集:
SINTER set1 set2 set3 → { c }
:返回集合间交叉的元素
- 合集:
SUNION set1 set2 set3 → { a,b,c,d,e }
:返回set1
、set2
、set3
合并并去重后的元素集合
- 差集:
SDIFF set1 set2 set3 → { a }
:返回set1
去除掉set2
和set3
的合集中重复元素后的元素集合
3.5.5 应用2
- 微博微信关注模型:
- 电商商品筛选:
3.6 有序集合zset
3.6.1 结构
- 外层
key-value
,value
内部是一个有序集合
- 通过给元素分配分值(权重)的方式来排序
- 集合元素唯一
3.6.2 命令
- 帮助指令:
help @sorted_set
ZADD key score member [[score member]…]
:向key
对应有序列表中加入带分值score
的元素ZREM key member [member …]
:从指定key
对应有序列表中删除指定元素ZSCORE key member
:获取key
对应有序列表中指定元素的分值ZINCRBY key increment member
:将key
对应有序列表中指定元素的分值加increment
ZCARD key
:返回key
对应有序列表中元素个数ZRANGE key start stop [WITHSCORES]
:将key
对应有序列表中元素升序排序(由小到大),然后按偏移量start~stop
返回其中的元素ZREVRANGE key start stop [WITHSCORES]
:将key
对应有序列表中元素降序排序(由大到小),然后按偏移量start~stop
返回其中的元素
3.6.3 集合操作
- 并集操作:
ZUNIONSTORE destkey numkeys key [key ...]
:将numkeys
个后面的key
对应集合进行元素并集操作(对重复元素的分值采用累加的方式),并将返回集合设置到destkey
(没有就新增)对应value上。
- 交集操作:
ZINTERSTORE destkey numkeys key [key …]
:将numkeys
个后面的key
对应集合进行元素差集操作(对重复元素采用去除方式),并将返回集合设置到destkey
(没有就新增)对应value上。
3.6.4 应用
- 排行榜功能:
四、Redis高性能
4.1 Redis是单线程的么?
- 执行命令是单线程,但还有额外线程。
- Redis 的单线程主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。
4.2 压测性能指令
redis-benchmark 具体指令
:一般几万、十万tps- 相关的帮助指令:
redis-benchmark -h
4.3 为什么单线程还这么快?
- 因为redis是基于内存操作的:
- 因为它所有的数据都在内存中,所有的运算都是内存级别的运算,而且单线程避免了多线程的切换性能损耗问题。
- 正因为Redis是单线程,所以要小心使用Redis指令,对于那些耗时的指令(比如
keys
),一定要谨慎使用,一不小心就可能会导致Redis卡顿
- 底层IO多路复用
- redis利用
epoll
来实现IO多路复用,将连接信息和事件放到队列中,依次放到文件事件分派器,事件分派器将事件分发给事件处理器。
4.4 查看redis支持的最大连接数
- maxclients指令:在redis.conf文件中可修改,
# maxclients 10000
五、其他高级命令
5.1 keys
- 功能:全量遍历键,用来列出所有满足特定正则字符串规则的key
- 用法:
keys MATCH pattern
MATCH pattern
:key的正则表达式- 示例:
keys mazai*
:模糊遍历mazai
打头的key
- 局限性:当redis数据量比较大时,性能比较差,要避免使用。
5.2 scan
scan命令属于渐进式遍历
- 语法:
SCAN cursor [MATCH pattern] [COUNT count]
cursor
:游标MATCH pattern
:key的正则表达式count
:一次遍历的key的数量
count
的含义:
- 表示一次遍历的key的数量
- 注意不是符合条件的key的数量、不是返回的key的数量
- 原理:初次遍历时,通常
cursor
以0开始。然后将返回结果中第一个整数值作为下一次遍历的cursor
。一直遍历到返回的cursor
值为 0 时结束。 - 局限性:scan并非完美无瑕, 如果在scan的过程中如果有键的变化(增加、 删除、 修改)那么遍历效果可能会碰到如下问题:
- 遍历过程中新增的元素无法遍历(因为hash桶的遍历是无法回溯的)
- 遍历出重复键
- 遍历过程中,往一个桶中插入过多元素会导致rehash
5.3 info
- 功能:
info
命令可以查看redis实例运行信息
server
:服务器运行的环境参数Clients
:客户端相关信息Memory
:服务器运行内存统计数据Persistence
:持久化信息Stats
:通用统计数据Replication
:主从复制相关信息CPU
:CPU 使用情况Cluster
:集群信息KeySpace
:键值对统计数量信息
六、Redis持久化方式
6.1 RDB
- 功能:可按照配置的策略,生成
dump
快照文件 - 触发方式:客户端触发
- 两种模式:
save
:同步生成快照文件save 60 1000
:该配置表示“60 秒内有至少有 1000 个键被改动”时,才会自动保存一次- 使用时会阻塞其他请求
bgsave
:异步(未使用save模式的情况下则默认该模式)- 原理:COW写时复制机制
COW写时复制机制是如何保证新请求数据保存下来的?
1)Redis 借助操作系统提供的写时复制技术(Copy-On-Write, COW),在生成快照的同时,依然可以正常处理写命令。简单来说,bgsave子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。
2)bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。此时,如果主线程对这些数据也都是读操作,那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据,那
么,这块数据就会被复制一份,生成该数据的副本。然后,bgsave 子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。
- 总结:
特性 | save | bgsave |
---|---|---|
IO类型 | 同步 | 异步 |
是否阻塞redis其它命令 | 是 | 否(在生成子进程执行调用fork函数时会有短暂阻塞) |
复杂度 | O(n) | O(n) |
优点 | 不会消耗额外内存 | 不阻塞客户端命令 |
缺点 | 阻塞客户端命令 | 需要fork子进程,消耗内存 |
- 手动生成RDB文件:还可以手动执行命令生成RDB快照,进入redis客户端执行命令
save
或bgsave
可以生成dump.rdb文件,每次命令执行都会将所有redis内存快照到一个新的rdb文件里,并覆盖原有rdb快照文件。 - 局限性:会丢失操作
6.2 AOF
- 开启方式:配置文件配置
appendonly yes
- Redis默认重启时使用aof方式恢复数据:因为数据相对安全,比RDB更多点
- 查看AOF文件:
cat appendonly.aof
- 文件内部是resp协议的写作方式。
- 文件内容参数含义:以
set mazai 5438
这条命令为例:*3
:表示执行命令的参数个数总和为3$3 set
:表示set这个参数长度为3
- 关于fsync函数:
- 定义:属于Linux系统核心函数。
- 功能:通过调用
fsync
函数将修改的数据和文件描述符的属性持久化到存储设备中。 - 注意事项:
fsync
函数将内核缓存的数据先刷新到驱动器上,但是驱动器可能不会立即将数据写入到存储设备中并且可能以一个无序的状态写入。- 出现意外情况(设备断电或系统崩溃),可能会导致只有部分数据写入到存储设备中。
- 配置AOF只记录修改数据的指令的三种策略:
appendfsync always
:每次有新命令追加到 AOF 文件时就执行一次fsync
,非常慢,也非常安全。- 可能会丢,刚写入redis内存,结果记录到aof文件前就挂了。
挂的场景参见【fsync函数的注意事项】
- appendfsync everysec:默认配置策略,每秒 fsync 一次,足够快。
- 同样可能会存在丢失,丢1秒钟
- appendfsync no:从不 fsync ,将数据交给操作系统来处理。
- 更快,也更不安全的选择。
- AOF重写:
- 功能:基于内存数据,将多个命令汇总成一条命令
- 开启自动AOF重写的两种配置:
auto-aof-rewrite-min-size 64mb
:当aof文件达到64mb时触发重写auto-aof-rewrite-percentage 100
:自上次重写后100%增量时再次触发重写
- 底层原理:类似
bgsave
,AOF重写时redis会fork
出一个bgrewriteaof
子进程去做重写,而不对Redis主线程有太多影响。 - 手动进行AOF重写:执行
bgrewriteaof
命令
6.3 Redis4.0 混合持久化机制
背景:
1)重启 Redis 时,我们很少使用 RDB来恢复内存状态,因为会丢失大量数据。
2)我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 RDB来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。
3)Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化
-
本质:还是AOF方式
-
文件结构:
-
开启:
- 前提:先开启AOF方式
- 方式:配置
aof-use-rdb-preamble yes
- 原理:
- 触发重写时,将已有的aof文件整个转成RDB副本形式(不论里面是不是已经有RDB形式数据了)
- 同时期间发生写入且符合aof策略时,将增量指令依次追加到aof文件后面
废话版本:
1)触发重写时,是将重写这一刻之前的内存做RDB快照处理,并且将RDB快照内容和增量的AOF修改内存数据的命令存在一起,都写入新的AOF文件。
2)新的文件一开始不叫appendonly.aof
,等到重写完新的AOF文件才会进行改名,覆盖原有的AOF文件,完成新旧两个AOF文件的替换。
七、Redis数据备份策略
- 写
crontab
定时调度脚本,每小时都copy一份rdb或aof的备份到一个目录中去,仅仅保留最近48小时的备份; - 每天都保留一份当日的数据备份到一个目录中去,可以保留最近1个月的备份;
- 每次copy备份的时候,都把太旧的备份给删了;
- 每天晚上将当前机器上的备份复制一份到其他机器上,以防机器损坏;
八、Redis主从架构
8.1 架构模型
- 主节点写数据,从节点备份数据
- 从节点备份方式:
- 由master推送
- 主动从master拉取
- 从节点也可以提供读操作,缓解主节点访问压力
8.2 集群部署
- 步骤:
1、复制一份redis.conf文件
2、将相关配置修改为如下值:
port 6380
pidfile /var/run/redis_6380.pid # 把pid进程号写入pidfile配置的文件
logfile "6380.log"
dir /usr/local/redis‐5.0.3/data/6380 # 指定数据存放目录
# 需要注释掉bind
# bind 127.0.0.1(bind绑定的是自己机器网卡的ip,如果有多块网卡可以配多个ip,代表允许客户端通
过机器的哪些网卡ip去访问,内网一般可以不配置bind,注释掉即可)
3、配置主从复制
replicaof 192.168.0.60 6379 # 从本机6379的redis实例复制数据,Redis 5.0之前使用slaveof
replica‐read‐only yes # 配置从节点只读
4、启动从节点
redis‐server redis.conf
5、连接从节点
redis‐cli ‐p 6380
6、测试在6379实例上写数据,6380实例是否能及时同步新修改数据
7、可以自己再配置一个6381的从节点
- 关键:
replicaof 192.168.0.60 6379
: 从本机6379的redis实例复制数据,Redis 5.0之前使用slaveof
replica‐read‐only yes
:配置从节点只读
8.3 主从复制原理(全量复制、初次复制)
- 步骤:
- 如果你为master配置了一个slave,不管这个slave是否是第一次连接上Master,它都会发送一个
PSYNC
命令给master请求建立socket长连接,用以复制数据。 - master收到
PSYNC
命令后,会在后台进行数据持久化(通过bgsave生成最新的rdb快照文件) - 持久化期间,master会继续接收客户端的请求,它会把这些可能修改数据集的请求缓存在内存中。
- 当master持久化进行完毕以后,master会把这份rdb文件数据集发送给slave,slave会清空老的数据并把接收到的数据进行持久化生成rdb,然后再加载到内存中。
- master将持久化期间缓存在内存中的命令发送给slave
- slave执行接收到的写命令。
- 后续master节点收到客户端的写请求,master会通过socket长连接持续把写命令同步给从节点,从而保证主从节点数据一致性。
- 注意事项:
- 当master与slave之间的连接由于某些原因而断开时,slave能够自动重连Master。
- 如果master收到了多个slave并发连接请求,master只会进行一次持久化,而不是有一个连接就持久化一次。master每和一个slave建立连接,就把这一份持久化的数据发送给该slave。
psync
指令:Redis底层C指令send buffer
:从节点加载主节点rdb文件时,有增量修改到主节点需通过该步骤同步给从节点。- 采用RDB同步的原因:RDB快
- 主从同步过程是Redis写死的过程,不管你主节点有没有开启RDB持久化策略,都会去生成RDB dump文件。
- 初次同步完成后,后续主节点收到指令,只会向从节点发送写指令,不会调用从节点的写指令或读指令。
8.4 主从复制原理(部分复制、断点续传)
断点续传:当master和slave断开重连后,一般都会对整份数据进行复制。但从redis2.8版本开始,redis改用可以支持部分数据复制的命令
PSYNC
去master同步数据,slave与master能够在网络连接断开重连后只进行部分数据复制
- 步骤:
- master会在其内存中创建一个复制数据用的缓存队列(
repl backlog buffer
),缓存最近一段时间的数据,master和它所有的slave都维护了复制的数据下标offset
和master的进程id
。 - 因此,当网络连接断开后,slave会先请求和master建立socket长连接,然后slave发送
PSYNC
命令请求master继续进行未完成的复制(从slave最近记录的数据下标offset
开始) - 如果slave发送的offset能在master的缓存队列中找到,则master会将缓存中从该offset之后的写命令一次性同步给slave。
- 如果master进程id变化了,或者slave数据下标offset太旧,已经不在master的缓存队列里了,那么将会进行一次全量数据的复制。
- 后续master节点收到客户端的写请求,master会通过socket长连接持续把写命令同步给从节点,从而保证主从节点数据一致性。
- 为什么从节点挂了后重新同步不直接全量复制?
太耗时间了
- 主节点缓冲区(repl backlog buffer):
- 功能:保存最近的一部分写命令
- 开启方式:默认开启,
vim redis.conf
,对应参数默认repl-backlog-size 1mb
,即默认缓存大小1mb
- 数据结构:FIFO先进先出,始终存放最近的写命令
- 断点续传步骤精简版(背!!):
- 从节点把上次同步的偏移量发送给主节点
- 如果偏移量在主节点缓冲区中,则将偏移量后的数据一次性同步给从节点
- 如果偏移量不在主节点缓冲区中,则全量复制
8.5 主从复制风暴
- 问题描述:多个从节点长时间挂掉后并发重启,都去请求全量复制主节点数据,会导致主节点压力过大。
- 解决方案:主从架构优化
- 从节点不一定都跟主节点相连,可以选择阶梯式架构,从节点和从节点相连
九、通过Java形式(Jedis)访问Redis主从集群
可以将Jedis理解成Redis的Java客户端,用来向Redis服务端发送指令
9.1 代码示例
- 引入相关依赖:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
- 访问代码:
public class JedisSingleTest {
public static void main(String[] args) throws IOException {
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(20);
jedisPoolConfig.setMaxIdle(10);
jedisPoolConfig.setMinIdle(5);
// timeout,这里既是连接超时又是读写超时,从Jedis 2.8开始有区分connectionTimeout和soTimeout的构造函数
JedisPool jedisPool = new JedisPool(jedisPoolConfig, "192.168.0.60", 6379, 3000, null);
Jedis jedis = null;
try {
//从redis连接池里拿出一个连接执行命令
jedis = jedisPool.getResource();
System.out.println(jedis.set("single", "zhuge"));
System.out.println(jedis.get("single"));
//管道示例
//管道的命令执行方式:cat redis.txt | redis‐cli ‐h 127.0.0.1 ‐a password ‐ p 6379 ‐‐pipe
/*Pipeline pl = jedis.pipelined();
for (int i = 0; i < 10; i++) {
pl.incr("pipelineKey");
pl.set("zhuge" + i, "zhuge");
}
List<Object> results = pl.syncAndReturnAll();
System.out.println(results);*/
//lua脚本模拟一个商品减库存的原子操作
//lua脚本命令执行方式:redis‐cli ‐‐eval /tmp/test.lua , 10
/* jedis.set("product_count_10016", "15"); //初始化商品10016的库存
String script = " local count = redis.call('get', KEYS[1]) " +
" local a = tonumber(count) " +
" local b = tonumber(ARGV[1]) " +
" if a >= b then " +
" redis.call('set', KEYS[1], a‐b) " +
" return 1 " +
" end " +
" return 0 ";
Object obj = jedis.eval(script, Arrays.asList("product_count_10016"), Arrays.asList("10"));
System.out.println(obj);*/
} catch (Exception e) {
e.printStackTrace();
} finally {
//注意这里不是关闭连接,在JedisPool模式下,Jedis会被归还给资源池。
if (jedis != null)
jedis.close();
}
}
}
9.2 读写分离
- 示例中写默认进master节点,读取也用的master节点,没有进行读写分离。
- 想读取从节点数据,需要手动写拦截,或者干脆哨兵架构。
9.3 关于管道(pipeline)的使用
- 使用管道的好处:客户端使用管道打包并批量发送命令,待所有命令都发送完后再一次性读取服务的响应,可以节省网络 IO读写的开销。
管道执行多条命令的网络开销实际上只相当于一次命令执行的网络开销。
- 管道不具备原子性:管道中的指令对于redis来讲都是独立的,一条执行失败不影响后续的继续执行。
管道不是原子的,不过redis的批量操作命令(类似mset)是原子的。
- redis将管道中指令依次执行完后,会将指令的执行结果(报错也会)以数组的形式返回给客户端。
- 管道中的命令执行时不会阻塞服务端Redis,但这并不意味着打包的命令越多越好:因为用
pipeline
方式打包命令发送,redis必须在处理完所有命令前先缓存起所有命令的处理结果。打包的命令越多,缓存消耗内存也越多。
9.4 关于Redis Lua的使用
Redis在2.6推出了脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行
- 使用Redis Lua脚本的好处:
- 减少网络IO开销:本来5次网络请求的操作,可以用一个请求完成(原先5次请求的逻辑放在redis服务器上完成)。使用脚本,减少了网络往返时延,这点跟管道类似。
- 原子操作:支持redis的多指令事务操作。Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。
- 替代redis的事务功能:redis自带的事务功能很鸡肋,而redis的lua脚本几乎实现了常规的事务功能
官方推荐如果要使用redis的事务功能可以用redis lua替代
- Redis中实现原子操作的方式:
- Redis Lua
- 批量操作命令,例如
mset
从Redis2.6.0版本开始,通过内置的Lua解释器,可以使用
EVAL
命令对Lua脚本进行求值
- EVAL命令的格式:
EVAL script numkeys key [key ...] arg [arg ...]
- 使用示例:
eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
"return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}"
:被求值的lua脚本2
:表示有两个keykey1
对应KEYS[1]
first
对应key1
这个key的value,second
对应key2
这个key的valuekey1 key2 first second
可以用数组、列表的形式传入
- 如何在Redis Lua脚本中执行Redis命令?
- 使用
redis.call()
函数。
- Jedis + Redis Lua脚本实现分布式锁代码示例:
// jedis
jedis.set("product_stock_10016", "15"); // 初始化商品10016的库存
// 脚本释义:
// "product_stock_10016"传参进redis.call函数中,先调用redis命令 get product_stock_10016,并将命令执行结果放入count变量中
String script = " local count = redis.call('get', KEYS[1]) " +
// 将count值转化成数值类型,并放到变量a中
" local a = tonumber(count) " +
// 将传参ARGV[1]的值(后续可知是10)转化成数值类型,并放到变量b中
" local b = tonumber(ARGV[1]) " +
// 如果15 >= 10则执行redis的[set product_stock_10016 5]命令
" if a >= b then " +
" redis.call('set', KEYS[1], a‐b) " +
// 此处模拟语法报错回滚操作 " bb==0" +
// lua脚本中等于不是==而是=,若有该语句则会报错
// 上述正常执行,则返回1,并结束
" return 1 " +
" end " +
// 上述出现一点报错,则返回0,结束
" return 0 ";
// eval方法第一个参数表示执行的lua脚本,第二个参数是key的数组,第三个参数是arg的数组
Object obj = jedis.eval(script, Arrays.asList("product_stock_10016"), Arrays.asList("10"));
System.out.println(obj);
- Redis Lua脚本中不要出现死循环、耗时的运算,否则Redis会阻塞,将不接受其他的命令。
十、Redis哨兵高可用架构
10.1 底层结构
- 示意图:
- 哨兵的作用:
sentinel
哨兵是特殊的Redis服务实例,不提供读写服务,主要用来监控Redis主从集群中的实例节点。
10.2 客户端访问主节点步骤
- 哨兵集群也是redis实例的集群,会动态监控主节点的状态。
若哨兵集群完全瘫痪,则客户端无法进行访问
- 客户端初次连接不再直接连接master节点,而是连接哨兵集群,由哨兵集群确定出master节点ip并返回给客户端,客户端后续才直接访问master节点,不会每次都通过sentinel代理访问redis的主节点。
redis的client端一般都会订阅sentinel发布的master节点变动消息
- 主从集群master选举:哨兵集群发现主从集群中的主节点挂了后,会重新从主从集群中选出新的主节点,并返回给客户端。
- 哨兵集群再重启旧的主节点,旧主节点会被哨兵设为从节点,并开始做主从复制。
10.3 Redis哨兵架构搭建步骤
- 步骤:
- 复制一份
sentinel.conf
文件:cp sentinel.conf sentinel-<哨兵端口号>.conf
- 将相关配置修改为如下值:
port 26379
daemonize yes
pidfile "/var/run/redis‐sentinel‐26379.pid"
logfile "26379.log"
dir "/usr/local/redis‐5.0.3/data"
# sentinel monitor <master‐redis‐name> <master‐redis‐ip> <master‐redis‐port> <quorum>
# quorum是一个数字,指明当有多少个sentinel认为一个master失效时(值一般为:sentinel总数/2 +1),master才算真正失效
sentinel monitor mymaster 192.168.0.60 6379 2 # mymaster这个名字随便取,客户端访问时会用到
- 启动sentinel哨兵实例:
src/redis‐sentinel sentinel‐26379.conf
- 查看sentinel的
info
信息:
src/redis‐cli ‐p 26379
127.0.0.1:26379>info
可以看到Sentinel的info里已经识别出了redis的主从
- 可以自己再配置两个sentinel,端口
26380
和26381
,注意上述配置文件里的对应数字都要修改。
- 核心配置:
sentinel monitor <master‐redis‐name> <master‐redis‐ip> <master‐redis‐port> <quorum>
quorum
:是一个数字,指明当有多少个sentinel节点认为一个master失效时,master才算真正失效。其值一般为:sentinel总数/2 +1
- sentinel总数一般是奇数,节约成本用3即可
- 符合majority大多数机制
sentinel总数奇数的缘由:预防哨兵集群内部的脑裂
<master‐redis‐name>
:master节点的代称,随便取<master‐redis‐ip>
:master节点的ip
<master‐redis‐port>
:master节点的port
- 示例:
sentinel monitor mymaster 192.168.0.60 6379 2
mymaster
:这个名字随便取,客户端访问时会用到192.168.0.60 6379
:单一master节点时,此处192.168.0.60 6379
表示master节点的ip和端口号
- 哨兵启动命令:
src/redis‐sentinel sentinel-<哨兵端口号>.conf
- 判断哨兵是否都启动成功:进入
sential-<port>.conf
文件,看末尾是否有哨兵架构集群元数据信息:
sentinel known‐replica 主节点名称 从节点ip 从节点port
:代表主节点的从节点信息。sentinel known‐sentinel 主节点名称 哨兵节点ip 哨兵节点port 哨兵节点实例id
:除当前哨兵节点外的哨兵节点信息。
- 哨兵如何监控到的从节点?/哨兵如何监控到整个主从集群的?
哨兵配置文件.conf
中配置了监听的主节点,而主节点又含有从节点的信息(可以从主节点的info
指令看到),因此哨兵可以通过主节点监控到从节点信息。
- 哨兵架构的局限性:不能完全预防脑裂,即符合数量的哨兵监测不到master节点的心跳,并不代表这个master就挂了。
10.5 哨兵高可用架构sentinel以及master选主
- 当一个master服务器被某sentinel视为下线状态后,该sentinel会与其他sentinel协商选出sentinel的leader进行故障转移工作;
- 每个发现master服务器进入下线的sentinel都可以要求其他sentinel选自己为sentinel的leader,选举是先到先得;
- 每个sentinel每次选举都会自增配置纪元(选举周期),每个纪元中只会选择一个sentinel的leader;
- 当一个sentinel获得超过半数以上sentinel的票数时,视为该sentinel选主成功,将会负责接下来的故障转移工作;
- sentinel Leader从存活的slave中随机选举出新的master,会修改所有sentinel节点配置文件的集群元数据信息,同时还会修改sentinel文件里之前配置的主节点实例名称(mymaster那个)对应的ip以及端口;
哨兵集群中即便只有一个正常运行的sentinel,也可以正常进行master选举,不过为了高可用一般部署奇数个哨兵(具体原因见上文)。
- sentinel Leader将新的master信息返回给客户端;
- sentinel Leader再重启旧的主节点,旧主节点会被哨兵设为从节点,并开始做主从复制。
10.6 使用Jedis访问哨兵架构的代码实现
public class JedisSentinelTest {
public static void main(String[] args) throws IOException {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(20);
config.setMaxIdle(10);
config.setMinIdle(5);
String masterName = "mymaster";
Set<String> sentinels = new HashSet<String>();
sentinels.add(new HostAndPort("192.168.0.60",26379).toString());
sentinels.add(new HostAndPort("192.168.0.60",26380).toString());
sentinels.add(new HostAndPort("192.168.0.60",26381).toString());
//JedisSentinelPool其实本质跟JedisPool类似,都是与redis主节点建立的连接池
//JedisSentinelPool并不是说与sentinel建立的连接池,而是通过sentinel发现redis主节点并与其建立连接
JedisSentinelPool jedisSentinelPool = new JedisSentinelPool(masterName, sentinels, config, 3000, null);
Jedis jedis = null;
try {
jedis = jedisSentinelPool.getResource();
System.out.println(jedis.set("sentinel", "zhuge"));
System.out.println(jedis.get("sentinel"));
} catch (Exception e) {
e.printStackTrace();
} finally {
//注意这里不是关闭连接,在JedisPool模式下,Jedis会被归还给资源池。
if (jedis != null)
jedis.close();
}
}
}
10.7 存在的问题
- 访问瞬断问题:master节点挂掉后,哨兵集群选举主节点(主从切换)消耗时间有几秒、几十秒等,这个期间整个集群无法对外提供访问,可用性差
- 性能差:仅有一个master节点提供对外访问、写操作,一般互联网公司凑合,大型互联网应用场景下没法支持高并发
- 主从同步效率低下:单一master节点内存不宜设置过大,过大会导致持久化文件dump、aof过大,进而影响主从同步的效率(尤其是遇到主从复制风暴的场景)
一般单节点<=10g
十一、Spring Boot整合redis实现哨兵选举master
11.1 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring‐boot‐starter‐data‐redis</artifactId>
</dependency>
11.2 YAML配置
server:
port: 8080
spring:
redis:
database: 0
timeout: 3000 # 客户端连接主节点的时间上限,此处3s
sentinel: #哨兵模式
master: mymaster #主服务器所在集群名称
nodes: 192.168.0.60:26379,192.168.0.60:26380,192.168.0.60:26381 #哨兵集群中各哨兵ip和端口
lettuce:
pool:
max‐idle: 50
min‐idle: 10
max‐active: 100
max‐wait: 1000
11.3 使用StringRedisTemplate访问哨兵架构代码实现
@RestController
public class IndexController {
private static final Logger logger = LoggerFactory.getLogger(IndexController.class);
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 测试节点挂了哨兵重新选举新的master节点,客户端是否能动态感知到
* 新的master选举出来后,哨兵会把消息发布出去,客户端实际上是实现了一个消息监听机制,
* 当哨兵把新master的消息发布出去,客户端会立马感知到新master的信息,从而动态切换访问的masterip
*
* @throws InterruptedException
*/
@RequestMapping("/test_sentinel")
public void testSentinel() throws InterruptedException {
int i = 1;
while (true){
try {
stringRedisTemplate.opsForValue().set("zhuge"+i, i+"");
System.out.println("设置key:"+ "zhuge" + i);
i++;
Thread.sleep(1000);
}catch (Exception e){
logger.error("错误:", e);
}
}
}
}
11.4 关于RedisTemplate
- 介绍:spring 封装了 RedisTemplate 对象来进行对redis的各种操作,它支持所有的redis原生的api。
- 序列化策略:RedisTemplate默认采用的是JDK的序列化策略,保存的
key
和value
都是采用此策略序列化保存的。开发人员在代码中无需关注RedisTemplate的这个加工细节
说人话就是RedisTemplate在
set
key-value
时,代码中设置的key-value
和redis存储的不一致:redis中是代码里key-value
的序列化结果,同时在代码中用RedisTemplateget
命令时,会自动反序列化为原本的key
和value
。
- RedisTemplate中定义了对5种数据结构操作:
redisTemplate.opsForValue()
:操作字符串redisTemplate.opsForHash()
:操作hashredisTemplate.opsForList()
:操作listredisTemplate.opsForSet()
:操作setredisTemplate.opsForZSet()
:操作有序set
- 与redis命令相互映射的api:
数据结构 | Redis客户端的命令 | RedisTemplate中方法 |
---|---|---|
String类型 | Redis | RedisTemplate rt |
setnx key true | rt.opsForValue().setIfAbsent(key, null) | |
set key value | rt.opsForValue().set(“key”,“value”) | |
set key value ex 10 nx | rt.opsForValue().set(“key”,“value”,10,TimeUnit.XXX) | |
get key | rt.opsForValue().get(“key”) | |
del key | rt.delete(“key”) | |
strlen key | rt.opsForValue().size(“key”) | |
strlen key | rt.opsForValue().size(“key”) | |
strlen key | rt.opsForValue().size(“key”) | |
strlen key | rt.opsForValue().size(“key”) | |
Hash结构 | hmset key field1 value1 field2 value2… | rt.opsForHash().putAll(“key”,map) //map是一个集合对象 |
hset key field value | rt.opsForHash().put(“key”,“field”,“value”) | |
hexists key field | rt.opsForHash().hasKey(“key”,“field”) | |
hgetall key | rt.opsForHash().entries(“key”) //返回Map对象 | |
hvals key | rt.opsForHash().values(“key”) //返回List对象 | |
hkeys key | rt.opsForHash().keys(“key”) //返回List对象 | |
hmget key field1 field2… | rt.opsForHash().multiGet(“key”,keyList) | |
hsetnx key field value | rt.opsForHash().putIfAbsent(“key”,“field”,“value”) | |
hdel key field1 field2 | rt.opsForHash().delete(“key”,“field1”,“field2”) | |
hget key field | rt.opsForHash().get(“key”,“field”) | |
List结构 | lpush list node1 node2 node3… | rt.opsForList().leftPush(“list”,“node”) |
rt.opsForList().leftPushAll(“list”,list) //list是集合对象 | ||
rpush list node1 node2 node3… | rt.opsForList().rightPush(“list”,“node”) | |
rt.opsForList().rightPushAll(“list”,list) //list是集合对象 | ||
lindex key index | rt.opsForList().index(“list”, index) | |
llen key | rt.opsForList().size(“key”) | |
lpop key | rt.opsForList().leftPop(“key”) | |
lpop key | rt.opsForList().leftPop(“key”) | |
lpushx list node | rt.opsForList().leftPushIfPresent(“list”,“node”) | |
rpushx list node | rt.opsForList().rightPushIfPresent(“list”,“node”) | |
lrange list start end | rt.opsForList().range(“list”,start,end) | |
lrem list count value | rt.opsForList().remove(“list”,count,“value”) | |
lset key index value | rt.opsForList().set(“list”,index,“value”) | |
Set结构 | sadd key member1 member2… | rt.boundSetOps(“key”).add(“member1”,“member2”,…) |
rt.opsForSet().add(“key”, set) //set是一个集合对象 | ||
scard key | rt.opsForSet().size(“key”) | |
sidff key1 key2 | rt.opsForSet().difference(“key1”,“key2”) //返回一个集合对象 | |
sinter key1 key2 | rt.opsForSet().intersect(“key1”,“key2”)//同上 | |
sunion key1 key2 | rt.opsForSet().union(“key1”,“key2”)//同上 | |
sdiffstore des key1 key2 | rt.opsForSet().differenceAndStore(“key1”,“key2”,“des”) | |
sinter des key1 key2 | rt.opsForSet().intersectAndStore(“key1”,“key2”,“des”) | |
sunionstore des key1 key2 | rt.opsForSet().unionAndStore(“key1”,“key2”,“des”) | |
sismember key member | rt.opsForSet().isMember(“key”,“member”) | |
smembers key | rt.opsForSet().members(“key”) | |
spop key | rt.opsForSet().pop(“key”) | |
srandmember key count | rt.opsForSet().randomMember(“key”,count) | |
srem key member1 member2… | rt.opsForSet().remove(“key”,“member1”,“member2”,…) |
- 和Jedis的区别:Jedis是直接仿照Redis命令的格式而写的api,更通俗易懂,但功能上不如RedisTemplate全面。
- 关于StringRedisTemplate:
- 继承自RedisTemplate,也一样拥有上面这些操作。
- 和RedisTemplate的区别:StringRedisTemplate默认采用的是String的序列化策略,保存的key和value都是采用此策略序列化保存的。
十二、Redis缓存高可用集群(Redis Cluster)
12.1 介绍
- 架构示意图:
- 定义:redis Cluster又名Redis 集群,是一个由多个主从节点群组成的分布式服务器群,它具有复制、高可用和分片特性。
- Redis集群不需要sentinel哨兵也能完成节点移除和故障转移的功能。
- 需要将每个节点设置成集群模式,这种集群模式没有中心节点,可水平扩展。
据官方文档称Redis Cluster可以线性扩展到上万个节点(官方推荐不超过1000个节点),原因是:集群内部是有gossip通讯的,主节点太多沟通耗时太多
- CAP特性:AP架构,无法保证强一致性
zookeeper是cp架构,可以保证强一致性
- 与哨兵架构的对比:
- Redis集群不需要sentinel哨兵也能完成节点移除和故障转移的功能
- Redis Cluster数据是分片存储在每个小主从集群上的:数据拆成数据块,每个小主从集群的数据是不重叠的
- cluster架构没有完全解决访问瞬断的问题,但是缓解了。
比方说
mazai
这个key根据hash定位到的小主从集群的主节点挂了,只会影响到所有在该集群上的key,其他小集群上的key还是能正常访问操作的。
- cluster架构的小集群是可以横向扩容的
官方文档最多可以扩展1万个,推荐不超过1千个,超1千性能会有很大影响,原因是:集群内部是有gossip通讯的,主节点太多沟通耗时太多
- 重点:cluster架构中小主从集群中读写只能走主节点,从节点不支持提供读操作,只能做主节点备份
- cluster架构已经具备了哨兵架构的能力,因此没必要再搭建哨兵集群。
- 使用Redis Cluster架构的优势:
- 缓存1T数据,一个主节点缓存10g的话只需要100个小集群就可以了
- 满足大并发要求:每个集群都能抗5万tps,那么10个小集群就能抗50万的tps
压测指令见第四节。
- 可以根据微服务下不同场景建立不同的redis 大集群(cluster)
- 集群的元数据有那些?
- 集群节点信息
- 主从角色
- 节点数量
- 各节点共享的数据等
12.2 Redis Cluster搭建
帮助指令:
/redis-cli --cluster help
redis集群需要至少三个master节点:预防脑裂,我们这里搭建三个master节点,并且给每个master再搭建一个slave节点,总共6个redis节点,这里用三台机器部署6个redis实例,每台机器一主一从,搭建集群的步骤如下:
12.2.1 步骤
- 在第一台机器的
/usr/local
下创建文件夹redis‐cluster
,然后在其下面分别创建2个文件夹如下:
mkdir -p /usr/local/redis-cluster
mkdir 8001 8004
- 把之前的redis.conf配置文件copy到8001下,修改如下内容:
daemonize yes # 后台启动
port 8001 # 分别对每个机器的端口号进行设置
pidfile /var/run/redis_8001.pid # 把pid进程号写入pidfile配置的文件
dir /usr/local/redis-cluster/8001/ # 指定数据文件存放位置,必须要指定不同的目录位置,不然会丢失数据
cluster-enabled yes # 启动集群模式
cluster-config-file nodes-8001.conf # 集群节点信息文件,这里800x最好和port对应上
cluster-node-timeout 10000 # 很关键,节点超时时间,不能太小,防止网络抖动造成脑裂
# bind 127.0.0.1(bind绑定的是自己机器网卡的ip,如果有多块网卡可以配多个ip,代表允许客户端通过机器的哪些网卡ip去访问,内网一般可以不配置bind,注释掉即可)
protected-mode no # 关闭保护模式
appendonly yes
# 如果要设置密码需要增加如下配置:
requirepass zhuge # 设置redis访问密码
masterauth zhuge # 设置集群节点间访问密码,跟上面一致
- 关键配置:
cluster-enabled yes
:启动集群模式cluster‐config‐file nodes‐<当前实例端口号>.conf
:集群节点信息文件,这里800x
最好和port
对应上cluster‐node‐timeout 10000
:很关键,节点超时时间,不能太小,防止网络抖动造成脑裂bind 127.0.0.1
:bind
绑定的是自己机器网卡的ip
,如果有多块网卡可以配多个ip
,代表允许客户端通过机器的哪些网卡ip
去访问,内网一般可以不配置bind
,注释掉即可protected‐mode no
:关闭保护模式appendonly yes
:如果需要设置密码需要增加如下配置:
requirepass zhuge # 设置redis访问密码
masterauth zhuge # 设置集群节点间访问密码,跟上面一致
- 把修改后的配置文件,copy到8004,修改第2、3、4、6项里的端口号,可以用批量替换:
:%s/源字符串/目的字符串/g
- 另外两台机器也需要做上面几步操作,第二台机器用8002和8005,第三台机器用8003和8006。
- 分别启动6个redis实例,然后检查是否启动成功:
/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/800*/redis.conf
# 查看是否启动成功
ps -ef | grep redis
- 用
redis-cli
创建整个redis集群:
redis5以前的版本集群是依靠ruby脚本
redis-trib.rb
实现
# 下面命令里的1代表为每个创建的主服务器节点创建一个从服务器节点
# 执行这条命令需要确认三台机器之间的redis实例要能相互访问,可以先简单把所有机器防火墙关掉,如果不关闭防火墙则需要打开redis服务端口和集群节点gossip通信端口16379(默认是在redis端口号上加1W)
# 关闭防火墙
systemctl stop firewalld # 临时关闭防火墙
systemctl disable firewalld # 禁止开机启动
# 注意:下面这条创建集群的命令大家不要直接复制,里面的空格编码可能有问题导致创建集群不成功
# 创建集群:
/usr/local/redis-5.0.3/src/redis-cli -a zhuge --cluster create --cluster-replicas 1 192.168.0.61:8001 192.168.0.62:8002 192.168.0.63:8003 192.168.0.61:8004 192.168.0.62:8005 192.168.0.63:8006
- 验证集群:
- 首先连接任意一个客户端:
./redis-cli -c -h -p
-a
访问服务端密码,-c
表示集群模式-h -p
:指定ip地址和端口号- 例如:
/usr/local/redis-5.0.3/src/redis-cli -a zhuge -c -h 192.168.0.61 -p 800*
- 然后再验证:
# 查看集群信息
cluster info
# 查看节点列表
cluster nodes
- 接着进行数据操作验证:略
- 最后关闭集群:需逐个关闭
/usr/local/redis-5.0.3/src/redis-cli -a zhuge -c -h 192.168.0.60 -p 800* shutdown
12.2.2 搭建成功后的效果
- 集群初始一共有16384个逻辑数据分片(hash slot),会根据cluster中主节点的数量,将hash slot均匀分散到主节点上:
- 以3个小主从集群为例,创建集群完成后会展示每个master所在的hash slot范围
- 三个小主从集群下,则一般分布情况:
- [0~5460] 5461个
- [5461~10922] 5462个
- [10923 ~16383] 5461个
- 关于
cluster info
(查看集群信息)指令的结果分析:
12.2.3 主从节点错峰部署
- cluster集群默认会将主从节点部署到不同的物理节点上:
- 好处:更安全,一个物理节点挂了不会使整个小主从集群挂掉,提高了集群可用性
- 主从节点确定规则:
一般是以下命令的前n个节点作为主节点(n
是设定的主节点个数)
/usr/local/redis‐5.0.3/src/redis‐cli ‐a zhuge ‐‐cluster create ‐- cluster‐replicas 1 1
92.168.0.61:8001 192.168.0.62:8002 192.168.0.63:8003 192.168.0.61:8004 192.168.0.62:8005 192.
168.0.63:8006
12.2.4 集群恢复
- cluster中一个小主从集群的master挂掉后,过一两分钟slave就会顶替上来:
- 以下命令只能做集群搭建用,一旦搭建成功后,节点挂掉想重启不能用该命令,只能用
/usr/local/redis‐5.0.3/src/redis‐server /usr/local/redis‐cluster/800*/redis.conf
命令一个个启动节点就行了:
/usr/local/redis‐5.0.3/src/redis‐cli ‐a zhuge ‐‐cluster create ‐‐cluster‐replicas 1 1
92.168.0.61:8001 192.168.0.62:8002 192.168.0.63:8003 192.168.0.61:8004 192.168.0.62:8005 192.
168.0.63:8006
2.1 为什么后续单独启动节点就能恢复集群?
1) 因为cluster中的每个节点都保存了集群中所有节点的关联信息、主从信息,相互之间能发起gossip通讯,又会把集群状态还原。
2)我们可以使用cat nodes-<节点端口>.conf
命令来查看某个节点的关联信息:
12.3 初识Redis Cluster数据分片(hash slot)原理
十六章节会详细讲解Redis Cluster的各项原理,内部涵盖hash slot等内容
十三、Java操作Redis Cluster
- 依赖:
借助redis的java客户端jedis可以操作以上集群,引用jedis版本的maven依赖配置如下:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
- 通过Jedis访问redis集群的代码实现:
public class JedisClusterTest {
public static void main(String[] args) throws IOException {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(20);
config.setMaxIdle(10);
config.setMinIdle(5);
Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>();
jedisClusterNode.add(new HostAndPort("192.168.0.61", 8001));
jedisClusterNode.add(new HostAndPort("192.168.0.62", 8002));
jedisClusterNode.add(new HostAndPort("192.168.0.63", 8003));
jedisClusterNode.add(new HostAndPort("192.168.0.61", 8004));
jedisClusterNode.add(new HostAndPort("192.168.0.62", 8005));
jedisClusterNode.add(new HostAndPort("192.168.0.63", 8006));
JedisCluster jedisCluster = null;
try {
//connectionTimeout:指的是连接一个url的连接等待时间
//soTimeout:指的是连接上一个url,获取response的返回等待时间
jedisCluster = new JedisCluster(jedisClusterNode, 6000, 5000, 10, "zhuge", config);
System.out.println(jedisCluster.set("cluster", "zhuge"));
System.out.println(jedisCluster.get("cluster"));
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jedisCluster != null)
jedisCluster.close();
}
}
}
// 运行效果如下:
// OK
// zhuge
十四、Spring Boot整合Redis Cluster
- 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
- YAML配置:
server:
port: 8080
spring:
redis:
database: 0
timeout: 3000
password: zhuge
cluster: # 核心,要将cluster中所有节点都配置进去
nodes: 192.168.0.61:8001,192.168.0.62:8002,192.168.0.63:8003,192.168.0.61:8004,192.168.0.62:8005,192.168.0.63:8006
lettuce:
pool:
max-idle: 50
min-idle: 10
max-active: 100
max-wait: 1000
- 访问代码:跟跟哨兵一摸一样
@RestController
public class IndexController {
private static final Logger logger = LoggerFactory.getLogger(IndexController.class);
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/test_cluster")
public void testCluster() throws InterruptedException {
stringRedisTemplate.opsForValue().set("zhuge", "666");
System.out.println(stringRedisTemplate.opsForValue().get("zhuge"));
}
}
十五、Redis客户端(Jedis)数据分片源码解析
15.1 核心代码:
JedisClusterCRC16.getSlot(byte[] key)
:根据传入的字节数组key
(需用String的toByte数组方法),定位出其slot
:
- 内部逻辑:
HASH_SLOT = getCRC16(key) & (16384 - 1)
等价于对16384取模,但是位运算效率更高
15.2 Jedis如何知道指定slot映射到哪个master的?
程序启动的时候会将YAML配置的集群中节点的配置信息缓存到本地,这样哪个slot对应哪个master就清晰了
15.3 原理解析
redis底层会使用socket通讯的方式,将请求以
resp
协议的方式发给master节点
十六、Redis Cluster各项原理分析
16.1 架构原理
- 图示:
- hash slot:Redis Cluster 将所有数据划分为16384个
slots
(槽位),每个节点负责其中一部分槽位。槽位的信息存储于每个节点中。
具体见上文【搭建成功后的效果】
- 当Redis Cluster的客户端来连接集群时,它也会得到一份集群的槽位配置信息并将其缓存在客户端本地。
- 每次当客户端对cluster发起读写请求(
set
、get
之类命令),cluster(一般是集群外的Jedis之类客户端[JedisCluster],当然其他负载均衡的也行)都会求出key-value
中key
的hash,映射到不同hash slot上,进而将读写请求重定位到指定的master节点上。
- 操作示例:
8001
发起的写指令,结果8002
有key-value
:
8001
还是空的:
- 同时因为槽位的信息可能会存在客户端与服务器不一致的情况,还需要纠正机制来实现槽位信息的校验调整。
16.2 槽位定位算法
- 通俗版本:Cluster 默认会对
key
值使用crc16算法进行hash得到一个整数值,然后用这个整数值对16384进行取模来得到具体槽位:HASH_SLOT = CRC16(key) mod 16384
- Jedis实现版本:实际上Jedis中代码实现了和取模一样的效果,但运用了位运算却使得效率更高:
HASH_SLOT = getCRC16(key) & (16384 - 1)
16.3 跳转重定位
- 当客户端向一个错误的节点发出了指令,该节点会发现指令的
key
所在的槽位并不归自己管理,这时它会向客户端发送一个特殊的跳转指令携带目标操作的节点地址,告诉客户端去连这个节点去获取数据。 - 客户端收到指令后除了跳转到正确的节点上去操作,还会同步更新纠正本地的槽位映射表缓存,后续所有 key 将使用新的槽位映射表。
16.4 Redis Cluster间通信模式
通信模式的功能:维护集群的元数据(集群节点信息,主从角色,节点数量,各节点共享的数据等)
维护集群的元数据有两种方式:集中式和gossip
redis cluster节点间采取gossip协议进行通信
16.4.1 集中式
- 优点:优点在于元数据的更新和读取,时效性非常好,一旦元数据出现变更立即就会更新到集中式的存储中,其他节点读取的时候立即就可以立即感知到;
- 缺陷:不足在于所有的元数据的更新压力全部集中在一个地方,可能导致元数据的存储压力。
- 存储方式:很多中间件都会借助zookeeper集中式存储元数据。
16.4.2 gossip协议
- 通信过程:
- gossip通信端口
1000
:
- 每个节点都有一个专门用于节点间gossip通信的端口,就是自己提供服务的端口号+10000,比如7001,那么用于节点间通信的就是17001端口
- 每个节点每隔一段时间都会往另外几个节点发送ping消息,同时其他节点接收到ping消息之后返回pong消息。
- 消息类型:gossip协议包含多种消息:
meet
:某个节点发送meet
给新加入的节点,让新节点加入集群中,然后新节点就会开始与其他节点进行通信;ping
:每个节点都会频繁给其他节点发送ping
,其中包含自己的状态还有自己维护的集群元数据,互相通过ping
交换集群元数据(前文提到的类似自己感知到的集群节点增加和移除,hash slot信息等);pong
: 对ping
和meet
消息的返回,包含自己的状态和其他信息,也可以用于信息广播和更新;fail
: 某个节点判断另一个节点fail
之后,就发送fail
给其他节点,通知其他节点,指定的节点宕机了。
- 优点:元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续,打到所有节点上去更新,有一定的延时,降低了压力
- 缺陷:元数据更新有延时可能导致集群的一些操作会有一些滞后
16.5 网络抖动
- 问题描述:真实世界的机房网络往往并不是风平浪静的,它们经常会发生各种各样的小问题。比如网络抖动就是非常常见的一种现象,突然之间部分连接变得不可访问,然后很快又恢复正常。
- 解决方案:Redis Cluster 提供了一种选项
cluster-node-timeout
,表示当某个节点持续timeout
的时间失联时,才可以认定该节点出现故障,需要进行主从切换。如果没有这个选项或者超时时间过小,网络抖动会导致主从频繁切换 (数据的重新复制)。 - 对应核心配置:
cluster‐node‐timeout 10000 #很关键,节点超时时间,不能太小,防止网络抖动造成脑裂
16.6 集群选举原理
16.6.1 过程
当slave发现自己的master变为
FAIL
状态时,便尝试进行Failover
,以期成为新的master。由于挂掉的master可能会有多个slave,从而存在多个slave竞争成为master节点的过程, 其过程如下:
- 从节点发现自己的主节点状态为Fail。
- 从节点将自己记录的集群
currentEpoch
(当前选举周期)加1,并给集群所有节点广播FAILOVER_AUTH_REQUEST
(错误转移请求)信息 - 其他节点收到该信息,只有master响应,判断请求者的合法性,并发送
FAILOVER_AUTH_ACK
(错误转移请求的确认)
- master每一个
epoch
(选举周期)只发送一次ack
(只会回第一次给它发请求的从节点的ack
)
- 尝试
Failover
的slave收集master返回的FAILOVER_AUTH_ACK
- 根据slave收到的票数分为两种场景:
- 票数不一致:slave收到超过半数master的
ack
后变成新Master
这其实也就说明了集群为什么至少需要三个主节点,如果只有两个,当其中一个挂了,只剩一个主节点是不能选举成功的:集群总共3个主节点,半数以上就是1.5个,大于半数就是2
- 票数一致:当挂掉的主节点下从节点发现自己得到的票数都一样(3个主节点,都只拿到1个ACK)时,重新选举,进入下一轮选举周期(
currentEpoch++
)
- slave通过gossip协议广播
pong
消息通知其他集群节点。
16.6.2 如何规避【多次选举后票数还是一致导致选举失败】的情况?
- 使用延时机制:从节点并不是在主节点一进入
FAIL
状态就马上尝试发起选举(发送FAILOVER_AUTH_REQUEST
),而是有一定延迟。 - 延迟时间算法:
DELAY = 500ms + random(0 ~ 500ms) + SLAVE_RANK * 1000ms
不同版本redis算法可能不同
random
:保证了每个从节点等待时间的不同,从而进一步规避了票数一致的情况SLAVE_RANK
:表示此slave已经从master复制数据的总量SLAVE_RANK
越小代表已复制的数据越新。这种方式下,可以从理论上保证持有最新数据的slave将会首先发起选举
2.1 为什么从节点的
SLAVE_RANK
会不一样?
因为Redis Cluster是一个AP架构,无法保证强一致性。当出现网络抖动等原因时,每个从节点同步数据可能不一样。比方说客户端已经收到主节点执行完指令的确认消息了(有可能客户端已经同步成功几个从节点,也可能一个都没有,这取决于min‐replicas‐to‐write
参数),但是在主节点异步复制给某个从节点时,主节点挂了,那数据也就可能不一致了
16.7 集群脑裂数据丢失问题(脑裂问题)
16.7.1 问题描述
- 一个小主从集群中,主节点一旦由于网络抖动等通信原因和其它所有节点形成网络分区,那在此期间其项下从节点就会重新选举出一个主节点。
- 而对于客户端来说谁是主节点是透明的,因此请求在slot映射到该小主从集群后会随机发到某个master节点上
- 一旦原主节点网络分区恢复,加入到集群过程中,会根据已有节点的集群配置信息认为新的主节点为主节点,而自己降为从节点,并从新主节点复制数据。
- 根据主从复制原理,旧主节点在网络分区期间接收到的客户端指令都会被新数据覆盖掉,这就造成了数据丢失
16.7.2 解决方案
- 核心操作:在每个节点的
配置文件.conf
中添加min‐replicas‐to‐write 1
- 原理分析:
- 表示当前节点作为主节点时,在接收到客户端指令并执行后,至少要同步几个从节点才能向客户端返回ACK消息
这样再出现主节点网络分区或主节点项下没有从节点时,主节点哪怕已经执行完客户端的指令,也因为从节点不符合配置条件而回滚执行的操作,并返回客户端失败消息
- 如果无法同步指定的从节点数量,则主节点回滚这次指令以及已产生的同步操作,并告知客户端
(error) NOREPLICAS Not enough good replicas to write
- 数值建议:其数值建议超过当前小主从集群正常运行下的所有节点个数的一半(同Zookeeper),此处
1
仅做展示,实际一主二从集群下应该是2
- 目的:为了实现大多数机制,从而尽可能规避脑裂带来的数据丢失问题
- 局限性:影响到了cluster的可用性
- 场景1:当小主从集群脑裂出两个节点数量一致的集群时,整个小主从集群无法对外提供服务
假定有一个一主三从的小集群,其中一主一从由于网络抖动形成网络分区,另外两个从节点就会选举产生又一个一主一从,两个脑裂集群由于其主节点都无法同步到两个从节点,从而导致客户端访问该小主从集群时,无论随机到哪个主节点都会失败,丧失可用性
- 场景2:如果小主从集群中主节点可以正常提供服务,而能够同步的从节点因为节点挂掉而不能满足要求,那这个小主从集群也会瘫痪丧失可用性。
16.8 相关问题
- Redis集群为什么至少需要三个master节点?并且推荐master节点数为奇数?
- 为了满足大多数机制的同时节省一个节点,方便选举
不一定要求小主从集群个数只能是奇数!
- 集群是否完整才能对外提供服务?
- 是的,默认情况下,当有一个小主从集群没有主节点能对外提供服务时,整个cluster就会瘫痪。
- 解决方案:将redis的
.conf
的配置cluster-require-full-coverage
改为no(如果是yes
就是默认的情况了)
- 解决方案:将redis的
十七、Redis Cluster批量操作
- Redis Cluster无法执行普通的批量指令:类似
mset mazai1 22 mazai2 33
是无法执行成功的,会报(error) CROSSLOT Keys in request don't hash to the same slot
错误。
- 原因:批量操作要保证原子性,而两个
key
根据hash得到的slot
却不在同一个master上,无法保证原子性,因此Cluster干脆在Jedis这层就将指令给拦截了,并报错。
- 解决方案:在
key
前统一加上相同的{XX}
用以标识两个key
同属一个slot
- 示例:
mset {user1}:1:name zhuge {user1}:1:age 18
- 原理 :Jedis只会用
{XX}
中的XX
做hash运算,其slot值一定相同,确保了原子性
十八、Redis Cluster动态扩容
Redis3.0以后的版本虽然有了集群功能,提供了比之前版本的哨兵模式更高的性能与可用性,但是集群的水平扩展却比较麻烦,今天就来带大家看看redis高可用集群如何做水平扩展。
18.1 关于原始集群
- 架构:原始集群(见下图)由6个节点组成,6个节点分布在三台机器上,采用三主三从的模式:
- 启动集群:
/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8001/redis.conf
/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8002/redis.conf
/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8003/redis.conf
/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8004/redis.conf
/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8005/redis.conf
/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8006/redis.conf
- 使用redis客户端连接8001端口的redis实例:
/usr/local/redis-5.0.3/src/redis-cli -a zhuge -c -h 192.168.0.61 -p 8001
- 查看集群状态:
192.168.0.61:8001> cluster nodes
从上图可以看出,整个集群运行正常,三个master节点和三个slave节点,8001端口的实例节点存储0-5460这些hash slot,8002端口的实例节点存储5461-10922这些hash槽,8003端口的实例节点存储10923-16383这些hash槽,这三个master节点存储的所有hash槽组成redis集群的存储槽位,slave点是每个主节点的备份从节点,不显示存储槽位。
18.2 进行水平动态扩容
查看redis集群的命令帮助:
cd /usr/local/redis-5.0.3 src/redis-cli --cluster help
1)create
:创建一个集群环境host1:port1 ... hostN:portN
2)call
:可以执行redis命令
3)add-node
:将一个节点添加到集群里,第一个参数为新节点的ip:port
,第二个参数为集群中任意一个已经存在的节点的ip:port
4)del-node
:移除一个节点
5)reshard
:重新分片
6)check
:检查集群状态
我们在原始集群基础上再增加一主(8007)一从(8008),增加节点后的集群参见下图,新增节点用虚线框表示:
- 参照之前的集群节点配置,对要扩容的节点进行配置:在/usr/local/redis-cluster下创建8007和8008文件夹,并拷贝8001文件夹下的redis.conf文件到8007和8008这两个文件夹下
mkdir 8007 8008
cd 8001
cp redis.conf /usr/local/redis-cluster/8007/
cp redis.conf /usr/local/redis-cluster/8008/
# 修改8007文件夹下的redis.conf配置文件
vim /usr/local/redis-cluster/8007/redis.conf
# 修改如下内容:
port:8007
dir /usr/local/redis-cluster/8007/
cluster-config-file nodes-8007.conf
# 修改8008文件夹下的redis.conf配置文件
vim /usr/local/redis-cluster/8008/redis.conf
修改内容如下:
port:8008
dir /usr/local/redis-cluster/8008/
cluster-config-file nodes-8008.conf
# 启动8007和8008俩个服务并查看服务状态
/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8007/redis.conf
/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8008/redis.conf
ps -el | grep redis
- 使用
add‐node
命令将游离的8007节点加入到集群中:/usr/local/redis‐5.0.3/src/redis‐cli ‐a zhuge ‐‐cluster add‐node 192.168.0.61:8007 192.168.0.61:8001
- 任何新节点刚加入集群都会分配master角色,但此时该节点无法被外部访问:因为它没有被分配slot
- 前面的
192.168.0.61:8007
为新增节点,后面的192.168.0.61:8001
必须为Cluster已知存活节点 - 添加原理:
add-node
命令会让8001
节点通过gossip协议向待加入集群的8007
节点发送一个meet
消息,然后8007
将加入集群,并开始和节点进行通信
关于
meet
详见Cluster节点通信类型
- 日志最后有
[OK] New node added correctly
提示代表新节点加入成功
- 待转移的节点进行slot迁移申请:使用
redis-cli
命令为8007
分配hash槽,找到集群中的任意一个主节点,对其进行重新分片工作:
/usr/local/redis-5.0.3/src/redis-cli -a zhuge --cluster reshard 192.168.0.61:8001
- 申请成功后,会询问要从集群中转移多少slot:
How many slots do you want to move (from 1 to 16384)?
,输入600
,表示要给8007
节点分配600个slot - 然后询问要把这600个槽位转移到哪个节点上去?
What is the receiving node ID?
,此处输入8007节点实例ID2728a594a0498e98e4b83a537e19f9a0a3790f38
。 - 接着询问这600个slot从哪些集群已有master节点上取?选择输入all,表示从所有主节点(
8001
,8002
,8003
)中分别抽取相应的槽数指定到新节点中,抽取的总槽数为600个
Please enter all the source node IDs.
Type 'all' to use all the nodes as source nodes for the hash slots.
Type 'done' once you entered all the source nodes IDs.
all
- 展示出要迁移的slot号后,最后再询问是否确认?
Do you want to proceed with the proposed reshard plan (yes/no)?
,输入yes
,那么这600个槽位连同对应的key-value
都会迁移到8007
节点中,开始执行分片任务。 - 查看集群的最新状态:
/usr/local/redis-5.0.3/src/redis-cli -a zhuge -c -h 192.168.0.61 -p 8001
192.168.0.61:8001> cluster nodes
注意!槽位迁移后,槽位对应数据也会迁移过来;数据迁移过程中,对应数据的外部访问会被阻塞。
- 按照步骤2将
8008
节点添加到集群中去 - 使用
cluster replicate
指令,将其中的新加入的8008
节点变为8007
的从节点:
- 进入待成为从节点的
8008
节点:/usr/local/redis-5.0.3/src/redis-cli -a zhuge -c -h 192.168.0.61 -p 8008
- 执行
cluster replicate
指令,将自身作为后面id对应主节点的从节点:192.168.0.61:8008> cluster replicate 2728a594a0498e98e4b83a537e19f9a0a3790f38
后面这串id为
8007
的节点id
- 查看集群状态,
8008
节点已成功添加为8007
节点的从节点:
18.3 移除新增主从小集群
- 先删除
8008
从节点:用del-node
命令删除从节点8008
,指定删除节点ip
和端口以及8008
节点id:/usr/local/redis-5.0.3/src/redis-cli -a zhuge --cluster del-node 192.168.0.61:8008 a1cfe35722d151cf70585cee21275565393c0956
- 然后删除
8007
主节点:该步骤相对比较麻烦一些,因为主节点里面是有分配了hash槽的,所以我们这里必须先把8007里的hash槽放入到其他的可用主节点中去,然后再进行移除节点操作,不然会出现数据丢失问题:/usr/local/redis-5.0.3/src/redis-cli -a zhuge --cluster reshard 192.168.0.61:8007
目前只能把master的数据迁移到一个节点上,暂时做不了平均分配功能
- 需要转移多少slot:
How many slots do you want to move (from 1 to 16384)?
:600 - 这些slot谁来接收?
What is the receiving node ID?
:此处输入8001
主节点的iddfca1388f124dec92f394a7cc85cf98cfa02f86f
- 指定迁出slot的数据源:指定
8007
主节点id
Please enter all the source node IDs.
Type 'all' to use all the nodes as source nodes for the hash slots.
Type 'done' once you entered all the source nodes IDs.
Source node 1:2728a594a0498e98e4b83a537e19f9a0a3790f38
- 输入
done
开始生成迁移计划:Source node 2:done
- 最后输入
yes
,开始进行迁移:Do you want to proceed with the proposed reshard plan (yes/no)? Yes
- 查询集群中
8007
节点状态,发现8007
节点已经没有hash slot了:
- 最后删除
8007
主节点即可:/usr/local/redis-5.0.3/src/redis-cli -a zhuge --cluster del-node 192.168.0.61:8007 2728a594a0498e98e4b83a537e19f9a0a3790f38
十九、Redis高并发分布式锁实战
19.1 分布式锁场景
- 互联网秒杀
- 抢优惠券
- 接口幂等性校验
以下以秒杀场景为例讲解
19.2 使用synchronized关键字进行加锁
- 使用
synchronized
关键字的弊端:synchronized
关键字只能在当前进程进行加锁,分布式应用架构中不同微服务对应进程无法使用synchronized
关键字对同一资源加锁 - 秒杀场景模型架构图:
- 搭建分布式模型:
- 服务器端针对一个项目启动两个application,分别对应8080和8090端口
- 配置和启动nginx:
- 初始代码实现:
synchronized
锁改造:
- 使用JMeter进行并发测试:
- 测试结果:高并发依旧存在超卖的情况,即两个服务端出现消费后有相同库存的场景,说明一件商品被多次消费了
直接对测试代码改造:
19.3 使用redis的setnx指令加锁
19.3.1 初始版本
- 代码实现:
- 存在问题:执行业务代码过程中抛出异常会导致锁lockKey无法释放,出现死锁。
19.3.2 升级版本V1.0
- 代码实现:使用finally确保锁释放
- 存在问题:执行业务代码过程中服务挂了,锁依旧无法释放
19.3.3 升级版本V2.0
- 代码实现:再给锁加个超时时间,10s够用了吧
- 存在问题:依旧有问题呀,我设置超时前服务挂了咋办。。
19.3.4 升级版本V3.0
- 代码实现:那我加锁和设置超时时间保持原子性即可
使用
stringRedisTemplate.opsForValue.setifAbsent(key,value,timeout,unit)
锁和超时时间一起搞起
- 存在问题:高并发下的锁一直被别的线程删掉,从而锁失效:
在锁失效的场景下,第一个线程a假定运行完业务要10s,而锁失效时间8s,则当线程a运行到8s,锁就失效,高并发下等待锁的线程b就会获取到锁,假定线程b执行完业务要5s,那么当线程a再执行2s就会释放掉b的锁,b等于没加锁,后面又有线程c可以进来,因此这个锁形同虚设
19.3.5 升级版本V4.0
- 代码实现:使用UUID唯一标识锁,从而确保只有线程自身能解自身的锁
- 优点:已经够一般公司使用了
- 存在问题:当前线程执行业务代码如果超时,锁依旧会被redis回收,其他线程依旧会进来
19.4 升级版本V5.0:使用Redisson实现锁续命
19.4.1 实现方案
主线程先加锁,同时后台开启分线程,做锁的续命:
1)在锁超时时间的1/3时,该后台线程检查下锁是否还存在,如果还存在,就将锁的超时时间重置;
2)如果锁不存在,说明当前主线程已经执行完毕释放锁了,就将该后台线程结束掉。
19.4.2 关于Redisson
- Redisson也是Redis的Java客户端
- Redisson和Jedis的区别:它对Redis的api支持没Jedis全,但是分布式场景下提供的功能比Jedis强
- 特性:Redisson支持重入锁:即重复调用
redissionLock.lock()
方法
19.4.3 代码实现
- 相关依赖:此处3.6.5版本
- 初始化一个Redisson Bean:官网demo,支持单redis实例、Redis哨兵架构、Redis Cluster等
- 结合之前版本的测试代码的使用:
- 优点:很好的解决了某些线程执行时间过长导致的锁被redis回收、其他线程乘虚而入的问题
19.4.4 Redisson分布式锁实现原理
- 示意图:
- 同时有多个线程执行
setnx
,只有第一个线程加锁成功,会在后台开启一个守护线程 - 守护线程每隔10s检查主线程是否还持有锁,如果持有则延长时间到30s
- 其他线程一直在while循环尝试加锁(自旋)
- 持有锁的线程挂了没能释放锁,因为设置了锁失效时间(默认30s),到期自动失效
【重点】何时发生死锁?
1)setnx和expire为两个指令,如果设置了setnx后程序崩溃,expire未成功执行则会出现资源锁死的情况。
19.4.5 Redis的eval命令相关Lua脚本使用详解
- 原子性:一个线程的一套操作作为一个整体,作为最小执行单位,不可被其他线程打断
- Redis底层使用了大量的Lua脚本来保证原子操作(第二节课有提到)
- Redis在2.6推出了脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行。
- 使用Lua脚本的好处:
- 多个执行命令放到一个Lua脚本中执行,可以保证操作的原子性
- Lua脚本一次可以发送n条命令,可以大幅度降低网络IO开销
- 可以替代Redis自带的事务功能
- 使用Lua脚本的不足:
- Lua脚本并不会控制内部操作的执行时间,一旦出现死循环、耗时的运算,redis在执行eval指令就会阻塞,将不接受其他的命令
- 解决方案:不要在Lua脚本里搞死循环、耗时操作,尽量逻辑清晰简单
- 如何运行Lua脚本:使用redis的eval命令
- 语法:
EVAL script numkeys key [key ...] arg [arg ...]
- 说明:
eval
命令后第一个参数标识Lua脚本,numbers
表示紧跟着的Lua脚本的入参key
有多少个,在这之后的入参都视作普通的arg
- 含义:使用redis的
eval
指令执行Lua脚本script
,其中key
和arg
是两种类型的入参,key
在前、arg
在后
- 说明:
- 示例1:直接返回Lua脚本的入参keys和args(参数)
- 语法:
eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
- 含义:
2
:表示有两个key- 数组下标从
1
开始,key1
对应KEYS[1]
,后续同理 first
对应key1
这个key的value
,second
对应key2
这个key的value
- 语法:
- 示例2:商品库存扣除
这段Lua操作换Java的写法去执行,没锁保证高并发下很容易有超卖问题
- Lua脚本如何保证Redis批量操作的原子性的?
- 说到底还是Redis本身执行命令是单线程的,恰好
eval
命令可以执行Lua脚本,就实现了批量操作的原子性(不会被其他线程的指令给打断) - 同时,Lua中某一步的redis命令执行失败了,直接回滚之前的操作
19.5 Redisson底层实现
19.5.1 关于redissonLock.lock()
- 内部核心方法1:
tryLockInnerAsync()
- 描述:尝试加锁以及设置锁超时时间
- 源码:
- 内部核心方法2:
scheduleExpirationRenewal(threadId)
- 描述:开启当前主线程对应守护线程,准备随时锁续命
- 源码:
19.5.2 Redisson支持重入锁
19.5.3 其他线程调用redissonLock.lock()的情况
- 只会返回锁的剩余时间:
- 其他线程获取不到锁后轮询尝试获取锁(自旋):
二十、高并发分布式锁如何实现
20.1 横向扩容
- 横向扩容能否提升集群高并发性能?
- 单纯的水平扩容,在竞争不同的slot资源的时候有用,多个小主从集群互不干扰,确实提高了性能
- 但是当秒杀一件商品这类单一资源的高并发操作,请求都集中到一个slot对应主从集群上,横向扩容屁用没有
20.2 使用分段锁
- 参考ConcurrentSet,使用分段锁来提高其集群高并发性能:将同一资源的value分段存储到不同的slot上
比方说有一个商品1001 对应 1000库存,则生成一系列1001_1 100,1001_2 100,分别存储到多个主从集群上,这样客户端并发访问时随机生成1001_1、1001_2这样的key,就能将请求纷发到不同小主从集群上了,提高了性能。
20.3 主从集群下的锁失效
- 问题描述:主从集群下,第一个线程刚获取到主节点上的锁,锁还未同步给从节点时主节点挂了,从节点竞选出主节点后又可以对其他线程提供服务,其他线程又在新主节点上加上了锁,这就出现了不一致性
- 解决方案:
- 使用Zookeeper实现主从复制的强一致性,从而规避该问题
- 在每个Redis节点的配置文件
.conf
中添加min‐replicas‐to‐write n
的配置来预防脑裂的问题,进而规避锁失效的问题 - 如果Redis客户端使用的Redisson,可以使用RedLock,让超过半数节点加锁成功才算加锁成功
- RedLock的缺陷:性能太差,一个加锁失败,所有加锁操作都回滚,完全违背了Redis的设计理念。
- 不推荐,到现在都有bug,实现功能又跟zookeeper一样,不如zk
- 模型:
实际项目中出现该问题时,为了确保服务可用性可能更多采取人工修复的方式,而不是预防。
二十一、深入理解Redis底层C源码(一)
21.1 Redis基本特性
- 非关系型的键值对数据库:可以根据键以
O(1)
的时间复杂度取出或插入关联值 - 二机制安全的数据结构:从客户端发送指令到服务端都是以字节流的形式传输,而redis服务端会将字节流中的key转化成string类型
redis底层维护的是自定义的数据结构SDS
- 提供了内存预分配机制:好处是避免了频繁的内存分配。
- 兼容C语言的函数库:SDS的字符数组末尾自动补全
\0
,从而适配C语言函数
21.2 Redis常见应用场景小结
场景 | 描述 |
---|---|
计数器 | 可以对 String 进行自增自减运算,从而实现计数器功能。Redis 这种内存型数据库的读写性能非常高,很适合存储频繁读写的计数量 |
分布式ID生成 | 利用自增特性,一次请求一个大一点的步长如 incr 2000 ,缓存在本地使用,用完再请求 |
海量数据统计:位图(bitmap) | 存储是否参过某次活动,是否已读谋篇文章,用户是否为会员, 日活统计 |
会话缓存 | 可以使用Redis来统一存储多台应用服务器的会话信息。当应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以请求任意一个应用服务器,从而更容易实现高可用性以及可伸缩性 |
分布式队列/阻塞队列 | List是一个双向链表,可以通过lpush /rpush 和rpop /lpop 写入和读取消息;可以通过使用brpop /blpop 来实现阻塞队列 |
分布式锁实现 | 在分布式场景下,无法使用基于进程的锁来对多个节点上的进程进行同步;可以使用 Redis自带的SETNX命令实现分布式锁 |
热点数据存储 | 最新评论,最新文章列表,使用list 存储、ltrim 取出热点数据,删除老数据 |
社交类需求 | Set可以实现交集,从而实现共同好友等功能,Set通过求差集,可以进行好友推荐,文章推荐 |
排行榜 | sorted_set可以实现有序性操作,从而实现排行榜等功能 |
21.3 Redis的key
Redis使用自己定义的String类型来作为Redis Key的底层数据结构,该类型即为SDS (simple dynamic string 简单动态字符串)
关于字符串的表示:
1)C++使用字符数组:char[] data = "mazai"
2)redis使用SDS
21.3.1 SDS底层数据结构
- Redis 3.2之前:
struct sdshdr {
int len;
int free;
char buf[];
};
int free
:字符数组剩余可用大小,0表示数组满了int len
:存储的字符串长度(不是字符数组的长度!!)char buf[]
:字符数组
- Redis 3.2之后:有很多种SDS,数组长度在哪个范围内就用哪个SDS
- 源码:
- 关于SDS HDR5底层数据结构:SDS正常使用字符串时指针指向
buf
的第一个字符(一个字符一个字节),当想要获取flags
信息时只需要左移一个字节即可:
- 属性介绍:
buf
:字符数组,存储字符串flags
:占1个字节(8bit),内部前三个bit标识数组长度的类型(Type),后面5个bit位只在SDS HDR5的情况下才会去表示当前SDS的字符串长度(Len)alloc
:字符数组的长度
关于
flags
前三位bit对应的SDS类型:(前三位刚好0~4)
类型 | 对应值(二进制转成10机制) |
---|---|
SDS_TYPE_5 | 0 |
SDS_TYPE_8 | 1 |
SDS_TYPE_16 | 2 |
SDS_TYPE_32 | 3 |
SDS_TYPE_64 | 4 |
- 变化的原因:3.2之前,属性
len
和free
都使用int数据类型存储长度,而一个整形在计算机中使用了4字节(4*8=32bit位),总共可以表示0~232-1的长度。显然,redis的字符串通常没有这么长,因此才会划分出长度为不同位数的SDS来适用具体的场景,并节省了空间 - 变化的好处:字符数组的长度范围可以根据业务数据的长度进行选择,节省了空间的占用
21.3.2 使用SDS类型而不是C++自带的字符数组的原因
- 服务端使用C++会使接收的数据不可控:
- C++表示的字符串会在结尾缺省加
"\0"
,如果客户端使用序列化工具传输类似"ma\0zai"
这样的一个完整字符串,使用C++解析会把\0
后面的就丢弃了 - SDS可以读取指定长度的字符作为一个字符串存储(不区分对待任何字符)
SDS依靠客户端给定的字符串长度
- 字符数组扩容策略不同:
- C语言需先建一个大一点的字符数组,再进行赋值
- Redis提供了内存预分配机制,采用了空间换时间的思想,避免了频繁的内存分配
21.4 Redis SDS中字符数组扩容策略
- Redis提供了内存预分配机制,采用了空间换时间的思想,避免了频繁的内存分配。
- 原理:
- 先判断当前字符数组的长度能否足够存储新字符串。
- 如果不够,判断需要增加多少个字符长度才能存放当前字符串,然后再以双倍进行扩容,并更新
len
和free
属性- 使用
addlen
属性:addlen:3
表示需要增加3个字符长度才能存放当前字符串 - 最终字符数组大小:
(len + addlen) * 2
- 使用
- 需要注意的是,当已有字符串长度
len
达到1024*1024
(1M)时,即便字符数组长度不满足,也将不再进行双倍扩容,而是每次增加1M的长度 - 如果够,那就直接将字符串中的字符一个个复制到SDS的字符数组中,并更新
len
和free
属性
21.5 Redis的value
- 数据结构:使用了数组(hashTable)和链表(hash桶)进行海量数据的存储
- hash函数的特点:
- 任意相同的输入一定得到相同的输出
- 哈希碰撞:不同的输入,有可能得到相同的输出(一样的输入就不会hash冲突了,只会覆盖)
- 解决方案:使用hash桶(链表法),也就是链表结构存储相同索引而
key
不同的key-value
- 解决方案:使用hash桶(链表法),也就是链表结构存储相同索引而
- Redis针对Hash碰撞解决方案进行的底层实现:
- 通过对key求hash得到数组或链表的索引:实际不可能直接拿
key
做hash,因为hash值太大了,会使数组长度过长,造成空间浪费;因此通常取模 - 三种取模策略:
策略 | 算法 | 备注 |
---|---|---|
直接对数组长度求模 | hash(key) % 数组/链表长度 = 索引 ∈ [0, 数组/链表长度 - 1] | 使用较少 |
使用累除法求模 | 任意数 % 2n | 对CPU消耗太大,不推荐使用 |
使用位运算求值 | 任意数 & (2n - 1) | 一次逻辑与的位运算即可,性能好,推荐使用 |
21.6 Redis 6.0 底层数据结构
21.6.1 总底层数据结构
- 数组:时间复杂度O(1)
- 链表:时间复杂度O(n)
21.6.2 RedisDb
- Redis服务端一共有16个RedisDB(编号0~15),依靠这些DB来进行数据存储
可配置
- 结构:
* dict
:指向一个dictht
类型的字典实体的指针* expires
:存储各key的过期时间* blocking_keys
:存储阻塞队列的表头(最左边)待取出的key
列表* ready_keys
:存储等待被push
进来的key
列表* watched_keys
:id
:数据库编号- avg_ttl
21.6.3 dict类型的字典实体
- 结构:
type
:hash类型privdata
:dictht ht[2]
:dictht
类型的数组ht
,数组大小为2
ht
数组的大小为什么为2?/为什么RedisDb底层的hashTable
要有两个?
1)一个hashTable
用来存储元素,另一个hashTable
用来扩容
dictht ht[2]
中实体内容见下文:
21.6.4 dictht类型的数组ht中的一个实体
- 结构:
** table
:指向真实hashtable
实体的指针的指针(函数式指针)
若当前实体是
ht[0]
,则table
最终指向真实的hashtable
;而当前实体是ht[1]
,则在h[0]
所指hashtable
中的dictEntry
停止rehash
后到扩容前,table
一直指向null
真实
hashtable
实体见下一小节
size
:真实hashtable
实体中桶的个数,一定是2的n次方(因为每次都是成倍增加)sizemark
:个数标识,恒等于size-1
,也就是2n - 1- 作用:配合
key
的hash,使用与运算求出dictEntry
在hashtable
中的索引 - 算法:
任意数(核心是hash(key)的结果) & sizemark
- 作用:配合
used
:hashtable
已使用的桶的个数
21.6.5 真实hashtable实体
- 结构:
- 底层是一个数组,数组中每一个元素指针指向一个
dictEntry
类型的真实元素
dictEntry
类型的真实元素具体见下一小节
- 多个真实元素之间可用指针相连,形成一个链表,代表了
hashTable
中的一个hash
桶
- hashTable扩容:
- 时机:当
ht[0]
的size=used
时 - 方式:
- 新建一个双倍于
ht[0]
中table所指hashtable大小的hashTable
,让ht[1]
中table
最终指向该hashTable
- 后续新增的
key-value
(dictEntry
元素实体)在对key
hash后超出h[0]
对应hashTable
的索引时,存储进h[1]
中table
最终指向的hashTable
中
- 新建一个双倍于
21.6.6 dictEntry元素实体
- 定义:由hashtable中的元素指针所指的实体
- 结构:
* key
:key实体(SDS)的指针
详细内容见下文【SDS实体】
v
:对应value的值信息,其内部结构如下:* val
:指向redisObject实体的指针- u64
- s64
- d
* val
详细内容见下文【redisObject实体】
* next
:指向下一个dictEntry
元素实体的指针- 当
dictEntry
的key
出现hash冲突时,会将新增加的dictEntry
指向之前桶中的首dictEntry
元素,并且将ht[X]
的元素指针指向该新dictEntry
实体 - 桶中
dictEntry
元素以next
指针相连,形成链表
- 当
- 扩容后的
dictEntry
元素的渐进式(incremental)rehash:
- 时机:客户端访问
ht[0]
中table指针所指hashTable的dictEntry
元素实体(key-value
)且h[1]
中table
指针有指向扩容的hashtable
- 方式:
- 1)有客户端访问
dictEntry
的情况:将ht[0]
中table
指针所指hashtable
中 的 指定访问的元素所在整个链表由该hashTable
迁移到h[1]
中的table
指针所指hashtable
指定索引下(注意是整个链表而不是单独的一个dictEntry
元素实体) - 2)没有客户端访问
dictEntry
的情况:Redis后台会有事件轮询将ht[0]
中table所指的hashtable
下的dictEntry
迁移到h[1]
中的table
指针所指的hashtable指定索引下。 - 当
ht[0]
中table
指针所指hashtable中所有桶中的元素、链表都rehash到ht[1]
中hashtable后,则将h[0]
重新指向刚刚ht[1]
中table
指针所指hashtable,ht[1]
中table
指针恢复指向null
,回收最初的hashtable空间
- 1)有客户端访问
- 为什么不在hashtabe扩容后一次性将原有dictEntry元素都搬到新hashtable里?
- 因为
ht[0]
中table所指hashtable可能存储的元素量很大,直接做迁移可能要花很多时间,而迁移过程中会占用Redis数据处理的主线程,期间无法对外提供数据访问服务,可用性差
- 因为
- 如何定位桶中的
dictEntry
元素实体:
- 先通过hash算法找到对应桶,然后在桶中使用
compare
函数从首dictEntry
元素从前往后进行匹配,直到找出指定的dictEntry
元素
21.6.7 sds实体
- 定义:存储
key
信息的实体 - 结构:见上节Redis的
key
21.6.8 redisObject实体
总计16个字节的空间,存储
value
信息的实体,内部结构如下:
1)type:value的类型
- 定义:长度4bit,分为
string
、list
、hash
、set
、zset
以及自定义类型 - 作用:通过标识
value
的类型来约束客户端的命令(也就是api)
set
对应string、hmset
对应hash- 使用
set
指令添加的key-value
无法使用list
数据结构的LPUSH
命令
- 查看指定key中的
value
的类型的指令:type key名称
2)encoding:value
- 编码方式:长度4bit,分为int、raw、embstr、quicklist、hashtable、ziplist、intset、skiplist这8种编码方式
- raw是原始类型编码方式,也就是sds这种real Object的编码方式
- embstr是嵌入式字符串编码方式,表示当前String类型的value真实数据实体(也就是set 的value值)字符长度为44个字节,加上key的sds数据类型4字节 以及 redisObject的16字节恰好是缓存行一次读取的行数(说明当前value已得到优化)
真实数据实体见下文【已知key的sds数据4个字节,剩余44个字节都是真实数据实体即可】
2.1 type和encoding的对应关系:
2.2 同样的数据类型,为什么底层编码方式会不同?(比方说string类型对应有
int
、raw
、embstr
这几种编码方式)
1)为了最大化利用内存空间
- 查看指定key中的value的编码方式的指令:
object encoding key名称
3)lru:LRU_BITS
长度24bit,内存槽策略会用到
4)refcount:引用计数器
Redis底层是C语言实现的,不像Java后台有通用垃圾收集线程管理内存空间,它使用的是引用计数法来管理内存(无人使用才释放)
- 定义:是
int
类型,长度4个字节
5)*ptr:指向real Object的指针
real Object详见下一小节
- 定义:是
void
类型,长度8个字节 - 不同编码方式下
ptr
指针的存储方式:int
:直接存入ptr指针
- 优点:
- 整形是8字节,正好和
ptr
指针长度相同,直接存放整形数据即可,无需再单独开辟空间 - 同时也节省了一次CPU内存IO(无需通过指针再跳转找具体内存空间了)
- 整形是8字节,正好和
- 底层代码验证:
server.c
中包含了redis的所有命令的处理函数,跳转到set
命令的setCommand
函数:
- 再看下
tryObjectEncoding
方法内部:
21.6.9 real Object
- 定义:是value的真实数据实体
- 缓存行读取优化:
- 缓存行一次读取64个字节,而一个redisObject才16个字节,需要接着读48个字节
2.1 如何利用这48个字节的连续空间,避免一次读取redisObject后又再次缓存读取key、真实数据实体的信息?
1)已知key
的sds
数据4个字节,剩余44个字节都是真实数据实体即可,这就是embstr
编码的由来
二十二、Redis 高级应用:活用bitmap统计日活
22.1 亿级日活的统计
- 拉跨做法:用一张表存储,来一次登录记一次用户
id
和登录时间,统计跨天的数据时还需要字关联 - 使用bitmap实现:
- bitmap:符合缓存行读取大小范围内的一组连续bit空间
bitmap默认bit值是0
- 步骤:
- 将一组用户
id
从0编码,以此作为bitmap的偏移量(offset
) - 指定id在某一天登录,则更新该天的
key
对应偏移量的bit值(更新为1)
- 将一组用户
22.2 setBit命令操作bitmap
- bitmap底层数据结构:bitmap是
String
类型,最大可以有232位,换算为512M,示意图如下:
- 语法:
setBit key offset 0 | 1
:效果如下:
getbit key offset
:获取指定key对应bitmap的指定偏移量的值bitcount key
:统计指定key对应bitmap中所有bit值不为1的bit个数bitcount key startByteIndex endByteIndex
:统计指定key
对应bitmap中 指定起始和截止字节范围内所有bit值不为1的bit个数- 示例:
bitcount login_11_06 0 12
:获取login_11_06
中字节索引从0到12的所有bit值不为1的bit个数 - 底层原理:汉明重量,此处不作说明
- 示例:
- setbit的值从1变成0,value真实数据还在内存么?如何知晓?
- 在
- 证明:
strlen key
可以获取到该key对应bitmap当前大小(多少字节)
- bitmap被redis标为
String
类型,直接get key
没有意义(一堆乱码)
22.3 使用BITOP按位操作
- 语法:
- 按位与:
BITOP and destkey key1 key2 ...
- 将
key1
、key2
等key对应bitmap值进行按位与运算,结果关联到destkey
并存储进redis中
- 将
- 按位或:
BITOP or destkey key1 key2 ...
- 将
key1
、key2
等key对应bitmap值进行按位或运算,结果关联到destkey
并存储进redis中
- 将
- 应用:
- 双日日活:
- 示意图:
- 原理:两天的bitmap进行按位与运算,在对结果的值使用bitcount指令计算出日活交集为1的bit个数
- 示意图:
- 一周活跃度:一周的bitmap做按位或
22.4 setBit底层源码
定位到
server.c
中的setcommand
函数:
二十三、深入理解Redis底层C源码(二)
23.1 list类型的value
- 为什么list类型value底层不能用链表存储?
- 会有胖指针问题:指针8byte,要是list中的元素过多,指针就占了太多空间
- 元素的指针指向的数据和元素本身可能不连续,导致大量碎片,遍历需要大量CPU内存IO,性能较差
- 数据结构:List是一个有序(按加入的时序排序)的数据结构,Redis采用
quicklist
(双端链表) 和ziplist
作为List的底层实现
23.2 List类型real object的数据结构
23.2.1 总体数据结构图
23.2.2 ziplist
- 图示:
- 底层数据结构:
zlbytes
:4个字节,标识当前ziplist中存储数据的大小zltail
:4字节,标识尾节点索引的位置
便于从后往前遍历,quicklist要用到
zllen
:2个字节,标识当前ziplist中<entry1,entry2...entryN>
有多少个元素<entry1,entry2...entryN>
:实际存储的元素zlend
:1个字节,恒等于255,用来标识数据的结尾
- 一个ziplist不会存储所有的元素:
- 原因:增加或删除一个元素,需要新增一个
ziplist
,重新赋值,并将原本的ziplist
删除,性能消耗较大 - 解决方案:使用双端链表
quicklist
对ziplist
进行分块存储
23.2.3 entry
- 图示:
- 数据结构:
prerawlen
:标识前一个节点(entry
)的信息,并根据前一个节点的数据项是否小于254来组织结构:- 小于:
prerawlen
长度为1字节 - 大于:
prerawlen
为1字节标记位 + 4字节
的长度
- 小于:
len
:标识当前entry
数据项(data
)的字节长度,根据len
字段的第一个字节分9种情况:
data
:当前entry
的真实数据项,也就是客户端的指令传输的value
23.2.4 quicklist
- 定义:将
ziplist
按照entry
进行分块,每个分块再用链表的节点(quicklistNode
)关联,这个链表就是quicklist
- 图示:
- 底层数据结构:
head
:指向双端链表头节点的指针tail
:指向双端链表尾节点的指针count
:双端链表节点(quicklistNode
)的个数,也就是ziplist
的分块个数length
:ziplist
总长度
23.3 ziplist数据过大的优化
23.3.1 ziplist分裂
- 分裂过程:当客户端向
ziplist
中不断添加entry
达到单一ziplist
大小上限,则ziplist
会分裂出一个新的空ziplist
用来存放entry
,同时创建新的quicklistNode
指向该新ziplist
,quicklistNo
间以prev
和next
指针相连;最后让quicklist
的tail
指针指向新双端链表尾节点 - 设置ziplist大小上限:
list-max-ziplist-size -2
-2
表示大小级别,对应8kb,详细级别见下图:
- 单个ziplist最大可以存储8kb的数据(对应ziplist的
zlbytes
属性),超过则进行分裂,将新数据存储到新的ziplist中
23.3.2 节点数据压缩
- 将指定范围quicklistNode对应的ziplist进行压缩:
list-compress-depth X
X
为0
表示不压缩X
为1
表示 对 除了头节点和尾节点外的quicklistNode对应ziplist进行压缩X
为2
表示从头节点开始,连头节点在内往后数两个节点;以及从尾节点开始,连尾节点在内往前数两个节点,除这些节点外的quicklistNode对应的ziplist都进行压缩,之后3、4…以此类推
23.4 hash类型real object的数据结构
- 存储策略:
- Hash数据结构底层实现为一个字典(
dict
),也是RedisBb用来存储K-V
的数据结构 - 当数据量比较小,或者单个元素比较小时,底层用
ziplist
编码方式存储 - 当数据量较大或者单个元素比较大时,底层使用
hashtable
编码方式存储
- 具体采用
hashtable
编码的时机:
hash-max-ziplist-entries 512
:设置ziplist最大entry
(元素)个数为512个,当超过时底层由ziplist改为hashtable编码hash-max-ziplist-value 64
:设置ziplist最大单个entry
大小为64字节,当超过时底层由ziplist改为hashtable编码
如何验证编码发生了变化:
object encoding key
- string类型value底层结构和hash类型value结构的区别:
联系 | 详细 |
---|---|
底层数据结构基本相同 | 无论value是String类型还是Hash类型,底层数据结构都是redisDB →dict →dictht[] →hashtable →dictEntry →key-value 这些层次 |
数据编码方式不同 | String类型可能是int 、raw 、embstr 三种编码方式,而hash可能是ziplist 或hashtable 的编码方式 |
设置超时时间不同 | String类型和hash类型都能对key对应value设置超时时间,但是hash不能再对value内部的key-value 单独设置超时时间 |
23.5 set类型real object的数据结构
- 定义:Set为无序的,自动去重的集合数据类型,当数据可以用整形表示时,Set集合将被编码为
intset
数据结构。 - 存储策略:
- 当数据可以用整形表示时,Set集合将被编码为
intset
数据结构。 - 当数据量较大或者单个元素无法用整形表示时,底层使用
hashtable
编码方式存储
intset
编码方式:根据底层结构对应编码方式如下:
encoding
:长32个bit,表示具体的int
型编码类型,包含int8_t
、int16_t
、int32_t
、int64_t
四种类型的整形编码
一种比一种长
length
:长32个bit,无序集合中元素个数contents[]
:表示指向集合中具体元素的有序数组,数组中元素编码类型由encoding
决定,从而对应了数组类型int8_t
、int16_t
、int32_t
、int64_t
- 具体采用hashtable编码的时机:
set-max-intset-entries 512
:设置intset
最大能存储的元素个数为512个,超过则用hashtable
编码
单个元素无法用整形!!
- set类型的value本身是无序的,但为什么SADD key多个整形数据后,使用
smembers ke
y命令返回的整形数据是有序的?
- 有序存储
int
型数据方便数组扩容 - 定位数据的时间复杂度低,为O(logn)
23.6 zset类型real object的数据结构
23.6.1 介绍
- 定义:ZSet为有序的,自动去重的集合数据类型,ZSet数据结构底层实现为字典(
dict
)+跳表(skiplist
)
dict
字典指针用来关联分数和元素的,方便用zscore
命令获取元素的分值- 底层数据结构:
- 存储策略:
- 当数据比较少时,用
ziplist
编码结构存储 - 当数据量较大或者单个元素比较大时,底层使用
skiplist
编码方式存储
- 具体采用
skiplist
编码的时机:
zset-max-ziplist-entries 128
:设置ziplist
最大entry
(元素)个数为128个,当超过时底层由ziplist
改为skiplist
编码zset-max-ziplist-value 64
:设置ziplist
最大单个entry
大小为64字节,当超过时底层由ziplist
改为skiplist
编码
23.6.2 skiplist
- 通用跳表实现原理:
- 空间换时间:时间复杂度为O(logn),
n
为元素个数 - 示意图:
Redis没有严格按照二分法设置索引层,而是用随机算法设置
- 数据结构总图:
- 源码解析:
- 定位
server.c
的zaddcommand
函数,找其中的zaddGenericCommand
函数:
- 然后就是创建跳表了
头节点不存储数据,仅作索引层
23.6.3 ziplist
- 图示:
- 数据结构:
*header
:ziplist头节点指针*tail
:ziplist尾节点指针length
:元素个数level
:当前ziplist中所有数据节点的最高索引层层高
注意不含首节点的索引层高
23.6.4 ziplistNode
- 图示:
- 数据结构:
- 索引层实体:
*forward
:指向当前索引层的下一个ziplistNode
的指针span
:当前索引层层高。
层高随机生成,越往高层生成概率越低,越往低层生成概率越高
索引从最高层往下遍历,同一ziplistNode节点的索引层高可能不连续!
ele
:元素score
:分值*backword
:回溯指针,从ziplist尾节点遍历到首节点
- 生成过程:
二十四、Redis zset数据结构应用:Geohash算法
24.1 Geohash算法介绍
- 定义:GeoHash是一种地理位置编码方法。 由Gustavo Niemeyer 和 G.M. Morton于2008年发明,它将地理位置编码为一串简短的字母和数字。它是一种分层的空间数据结构,将空间细分为网格形状的桶,这是所谓的z顺序曲线的众多应用之一,通常是空间填充曲线。
2. 优势:一方面代表地理范围,一方面保护隐私
3. 适用场景:适用于精准度要求不高的应用场景,例如附近的人
24.2 常用命令
- 帮助命令:
help @geo
- 添加位置:
geoadd key 经度1 维度1 地点名称1 经度2 维度2 地点名称2 ...
- 计算两点间距离:
geodist key 地点名称1 地点名称2 km|m|ft|mi
通常单位用km公里即可
- 获取指定地点范围内的地点(包含当前地点本身):
- 使用经纬度:
georadius key 经度 纬度 范围大小 单位(km|m|ft|mi)
- 使用地点名:
georadiusbymember key 地点名称 范围大小 单位(km|m|ft|mi)
- 例如:
georadiusbymember locations gugong 5 km
- 例如:
为什么说geo指令操作的数据底层是zset?
1)type key
得知
24.3 geohash经纬度编码
- 原理:
- 经度范围是东经180到西经180,纬度范围是南纬90到北纬90,我们设定西经为负,南纬为负,所以地球上的经度范围就是
[-180, 180]
,纬度范围就是[-90,90]
。如果以本初子午线、赤道为界,地球可以分成4个部分: - Z字曲线:进一步精确,采用二分法,将当前区域划分出左上、左下、右上、右下四个部分,每个区域再在已有前缀下确定是
01
、00
还是11
、10
,以此类推,定位经过的区域路线便是Z字曲线:
- 实例:通过GeoHash算法,可以将经纬度的二维坐标变成一个可排序、可比较的的字符串编码。 在编码中的每个字符代表一个区域,并且前面的字符是后面字符的父区域。其算法的过程如下:
- 先求经度(10次定位):
1101 0010 1100 0100 0100
- 再求纬度(10次定位):根据GeoHash来计算纬度的二进制编码:
地球纬度区间是[-90,90]
, 如某纬度是39.92324
,可以通过下面算法来进行维度编码:- 区间
[-90,90]
进行二分为[-90,0)
、[0,90]
,称为左右区间,可以确定39.92324
属于右区间[0,90]
,给标记为1
- 接着将区间
[0,90]
进行二分为[0,45)
、[45,90]
,可以确定39.92324
属于左区间[0,45)
,给标记为0
- 递归上述过程
39.92324
总是属于某个区间[a,b]
。随着每次迭代区间[a,b]
总在缩小,并越来越逼近39.928167
- 如果给定的纬度(
39.92324
)属于左区间,则记录0
,如果属于右区间则记录1
,这样随着算法的进行会产生一个序列1011 1000 1100 0111 1001
,序列的长度跟给定的区间划分次数有关。
- 区间
- 然后按照偶数位放经度,奇数位放纬度,把2串编码组合生成新串:
11100 11101 00100 01111 00000 01101 01011 00001
起始位从0开始,算偶数
- 最后对新串从右向左5个bit为1组(可表示
0
~31
)进行base32编码(去掉a
,i
,l
,o
):- 先计算出对应的十进制数:
28
,29
,4
,15
,0
,13
,11
,1
- 再base32转码:
wx4g0ec1
- 先计算出对应的十进制数:
- 使用geohash算法编码后,就能在geohash指令的底层调用zset相关指令了
- 编码转换成经纬度的解码算法与geohash相反
24.4 算法源码
- 定位到
geoadd
函数:
切分次数:26次
- 进行经纬度编码以及封装成zset类型数据:
- 内部最后调用zset相关指令(此处
zadd
):
二十五、Redis 6.0新特性
25.1 IO多线程模型
- 版本区别:
- 6.0之前,虽然Redis有其他线程用以执行后台任务,但是执行用户指令的io-thread一直是单线程(比如
unlin
删除大key、rdb持久化等) - 6.0之后可以通过
io-threads 4
配置多个IO线程
2.相关配置: io-threads 4
:- 默认关闭,开启开参数后,Redis将启用4个线程,其中1个主线程为主,还有3个IO线程为辅
- 仅开启此配置时,主线程必定负责所有用户命令的读、解析、执行,并将对应的写操作分配给含主线程在内的四个IO-threads
- 示意图:
io‐threads‐do‐reads yes
:- 默认
no
关闭,若再开启此参数,除主线程外的IO线程也将可以负责所有用户命令的读、解析、写操作,但是用户命令的执行依旧需要主线程进行 - 任意IO线程完成用户命令的读、解析后,将解析结果放入队列中,供主线程依次执行,每执行完一条就出队列,由任意IO线程完成后续的写操作
- 示意图:
- 默认
25.2 客户端缓存
不能单纯叫客户端缓存,因为需要Redis服务端配合
- 原理:
- 客户端访问数据后,将数据缓存到客户端,下次访问直接本地返回
- 当缓存对应Redis服务端数据发生变化,Redis会通过socket通信推送
invalid
消息给该客户端,告知客户端这个key的数据发生变化。这样下次客户端发起请求想要访问缓存时,会将该key失效掉,重新从Redis服务端获取最新数据并加入缓存
- 适用范围:
- 截止6.0版本,客户端是Jedis或Redisson时,仅适用于单机环境,主从、哨兵、cluster等集群不适用
- 客户端是lettuce时,方才对单机以及集群环境支持客户端缓存
- lettuce客户端缓存配置:
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce‐core</artifactId>
<version>6.0.0.RELEASE</version>
</dependency>
25.3 命令类别和ACL权限管理
- 版本区别:
- 6.0之前的
requirepass
参数只能确定用户登录权限,却无法对redis的命令执行设置泉下,默认情况下,任何客户端都可以执行任意的指令 - 6.0之后,通过引入**
ACL
**(Access Control List)来对命令的访问和执行进行权限控制
acl
命令即时生效
- 危险操作(命令):
server.c
中有@dangerous
声明的函数
@dangerous
是命令类别,表示该命令是危险操作,在赋权方面有很大用处:
命令类别也是Redis 6.0加入的新特性
- 常用指令:
- 查看当前所有用户的权限情况:
acl list
[user default]
:默认具备管理员权限[off]
:没有任何权限
- 查看Redis中的所有命令类别:
acl cat
- 查看Redis中的指令类别的命令:
acl cat [类别]
- 示例:
acl cat dangerous
:查看所有的危险操作
- 示例:
- 6.0后可以通过
ACL
设置具体的key
的操作权限:
ACL
创建一个没有任何权限的用户:ACL SETUSER 用户名
- ACL创建一个拥有单一命令、单一类型key权限的用户,并设置初始密码:
ACL SETUSER 用户名 on >[密码] ~[key前缀:*] +命令名
- 举例:
acl setuser alice on >pass123 ~cached:* +get
:使用用ACL
命令创建一个alice用户,他密码是pass123
,对cached:
作为前缀的key具有get
命令权限 - 验证:
- 举例:
- ACL创建一个用户,并不让其执行某些操作:
ACL SETUSER 用户名 on +@all ‐@dangerous >密码 ~*
- 减号表示不能执行的命令类型,会从加号中的命令剔除出去,此处表示禁止执行用户执行危险操作相关的所有命令
- ACL权限持久化:
- 方式:
- 执行
config rewrite
命令:不执行,权限就只在redis缓存中生效,重启redis后配置的权限就丢失了 - 直接在acl权限持久化的位置进行配置
- 执行
- 持久化的位置:
- 默认在redis对应
.conf
文件中:
- 也可以单独指定某个文件:开启
aclfile 文件路径
参数
- 默认在redis对应
持久化方式两者只能二选一,否则redis启动报错退出
二十六、Redis缓存设计与性能优化
26.1 互联网公司微服务架构中的多级缓存分布
- 架构图:
- 原理:客户端请求先找nginx缓存(一级缓存),其中存放了最基本的热点数据,没有则找到web层的Ehcahe缓存(二级缓存),如果没有则找到redis集群缓存,如果还没有再访问数据库
- 目的:让请求尽量在返回层返回,从而提高并发性能
26.2 布隆过滤器
26.2.1 介绍
关于互联网缓存架构的优化建议:可以考虑在nginx一级缓存前再加一个缓存层,即布隆过滤器
- 定义:布隆过滤器就是一个大型的位数组和几个不一样的无偏 hash函数
无偏就是能够把元素的hash值算得比较均匀
- 图示:
- 数据结构:bitmap二进制数组
- 特性:
- 当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在
- 布隆过滤器中的bitmap元素无法删除,只能重建并初始化数据
- 过滤前提:redis中的所有key要提前放入到布隆过滤器中
- 存入过程:某个key除了要往redis、数据库中放,同时还要经过几种hash函数运算后,更新hash散列值对二位数组取模后的对应偏移量的bit位为1
- 原理:当客户端发起
get
请求时,如果对应key经过布隆过滤器的几种hash算法算完后,对应值取模映射的偏移量上bit值但凡有一位存在0的情况,说明这个key没放过,必定不存在 - 优势:
- 1000万元素,布隆过滤器底层数组长度可能自己达到几十亿,碰撞概率极低,可以防止掉99%的数据,剩余的碰撞无关紧要,穿透就穿透,影响不大
- 采用bitmap存储,占用空间小
26.2.2 使用Redisson实现布隆过滤器
- 配置依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
- 示例代码1:
public class RedissonBloomFilter {
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
//构造Redisson
RedissonClient redisson = Redisson.create(config);
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%,也就是hash碰撞的概率,根据这两个参数会计算出底层的bit数组大小
bloomFilter.tryInit(100000000L,0.03);
//将zhuge插入到布隆过滤器中,模拟启动时预存入所有key
bloomFilter.add("zhuge");
//判断下面号码是否在布隆过滤器中
System.out.println(bloomFilter.contains("guojia"));//false
System.out.println(bloomFilter.contains("baiqi"));//false
System.out.println(bloomFilter.contains("zhuge"));//true
}
}
- 示例代码2:
//初始化布隆过滤器
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%
bloomFilter.tryInit(100000000L,0.03);
//把所有数据存入布隆过滤器
void init(){
for (String key: keys) {
bloomFilter.put(key);
}
}
String get(String key) {
// 从布隆过滤器这一级缓存判断下key是否存在
Boolean exist = bloomFilter.contains(key);
if(!exist){
return "";
}
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 如果存储数据为空, 需要设置一个过期时间(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
26.2 缓存使用中的问题
26.2.1 缓存穿透
- 问题描述:是指查询一个根本不存在的数据, 缓存层和存储层都不会命中,导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义
- 产生原因:
- 自身业务代码或者数据出现问题
- 一些恶意攻击、 爬虫等造成大量空命中
- 解决方案:
- 缓存空对象:
String get(String key) {
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 如果存储数据为空, 需要设置一个过期时间(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
- 或是使用布隆过滤器
26.2.2 缓存失效(缓存击穿)
- 问题描述:由于大批量缓存在同一时间失效可能导致大量请求同时穿透缓存直达数据库,可能会造成数据库瞬间压力过大甚至挂掉
比如热点秒杀商品批量上架,且失效时间都一致,那在即将秒杀结束、热点数据全部失效时,百万级请求直接击穿缓存,访问到数据库,造成过大压力
- 解决方案:对失效时间加上随机时间,避免大批量失效的情况:
String get(String key) {
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
//设置一个过期时间(300到600之间的一个随机数)
int expireTime = new Random().nextInt(300) + 300;
if (storageValue == null) {
cache.expire(key, expireTime);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
26.2.3 缓存雪崩
- 问题描述:缓存层(比如Redis集群)支持不住请求或宕掉了,导致大量请求打入后端存储层,造成存储层瘫痪。存储层宕掉又导致服务瘫痪,服务瘫痪又导致应用层瘫痪,最终整个分布式系统瘫痪
- 解决方案:
- 保证缓存层高可用:Redis部署成RedisCluster、Redis哨兵架构
- 如果压力还是很大,可以进行熔断、限流和降级:
-
比方说Redis Cluster只能支撑100万的tps,当客户端发起千万级别的并发请求时,在前方做限流,只让80万左右的请求进入到缓存层,其余请求就直接返回一个提示【系统繁忙,请稍后再试】,虽然牺牲了用户体验,但是确保了系统的可用性。
-
留余量,不能撑满100万,防止资源过于紧张导致缓存瘫痪
-
或者使用异步队列处理,将请求放入队列中,给前端一个友好提示【订单正在处理中,前方排队XXX】、默认处理,比方说12306订单系统订单来了会有等待提示
-
使用spring cloud sential或spring cloud Hystrix限流降级组件
-
- 提前演练:作为架构师,在项目上线前要做好业务量预估, 同时在缓存层宕掉后,需要演练在预估几倍量的并发请求下 应用以及后端的负载情况以及可能出现的问题, 最好相应预案
比方说针对Redis Cluster做好备份,当某个小主从集群真挂了,要有备用小主从集群马上恢复备份的数据
26.2.4 热点缓存key重建优化
- 问题描述:有些冷数据不存在于缓存中,突然某一天由于外部宣发等因素引来了高并发的访问(比如直播带货、小红书推荐、新闻热点商品等),直接击穿缓存层打到数据库上,极端情况导致数据库宕机
- 解决方案:使用
setnx
加分布式锁:当请求无法从缓存get
到数据时,让所有请求在缓存层都发setnx
加锁命令,让获得锁的请求打入数据库访问对应数据,并更新缓存;其他的获取不到锁的请求等待50ms后再次尝试从缓存get
数据
26.2.5 缓存与数据库双写不一致
- 问题描述:具体可分为以下几种问题:
- 双写不一致的问题:线程1写完数据库后由于中间执行其他业务或卡住等,没能及时更新缓存;在此期间线程2完成了数据库同一数据的写操作,并更新了缓存;此时,线程1恢复继续进行更新缓存操作,这就导致缓存和数据库双写不一致的问题
- 写完后删缓存,并发读写下的不一致问题:线程1先写数据库,然后删除缓存对应值,线程3此时有个读请求进来,发现缓存没值则将请求打到数据库,访问到数据库数据后,可能由于要执行其他业务或卡住等,没能及时更新缓存;在此期间,线程2来了个写操作并删除缓存(删了寂寞),完成后恰好线程2恢复过来进行更新缓存操作了,这就导致缓存和数据库并发读写下的不一致问题
- 解决方案:
- 对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可
就算不一致也不影响主体业务
- 就算并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等不影响核心业务的数据),缓存加上过期时间依然可以解决大部分业务对于缓存的要求
- 延迟双删:执行完写操作并删除缓存后,等待150ms这样,再次进行缓存的删除,以此避免期间发生的数据库和缓存不一致问题
不推荐,本身缓存和数据库不一致就是小概率事件,为了这种事情增加写操作的时间,反而增加了系统复杂度,降低了系统性能
- 使用分布式读写锁(共享锁)即可(推荐!)
- 写数据前获取写锁并加锁,读数据前获取读锁并加锁:分布式锁能将并发转化成串行,而使用读写锁的话,读读无锁、读写互斥,对于只有较少写请求、大量读请求的互联网并发场景几乎不会影响到性能
- 使用Redisson实现读写锁:
- 读处理:
- 写处理:
- 注意事项:写入redis的lockkey一定要相同
- 读处理:
- Redisson读写锁底层实现:读写锁
readWriteLock
底层逻辑和Redisson的lock
实现逻辑差不多:
- 使用阿里的开源组件canal来监听听数据库的binlog日志及时的去修改缓存:
- 原理:canal的更新缓存原理是将binlog中的写操作按照之前的执行顺序先后更新缓存
- 缺陷:
- 引入了新的中间件,增加了系统复杂度
- 依旧没能解决读写不一致导致的缓存和数据库双写问题
- 原理:canal的更新缓存原理是将binlog中的写操作按照之前的执行顺序先后更新缓存
- 总结:
- 针对读多写少的情况加下缓存提高性能;
- 针对读少写多或读写差不多的场景,那就没必要用缓存了
- 切记不要为了用缓存,同时又要保证绝对的一致性做大量的过度设计和控制,增加系统复杂性
26.3 Redis相关的开发规范
26.3.1 键值设计
- key名设计:
- 建议:
- 可读性和可管理性:以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如
业务名:表名:id
:trade:order:1
- 简洁性:保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视:
user:{uid}:friends:messages:{mid}
简化为u:{uid}:fr:m:{mid}
- 控制key的生命周期,redis不是垃圾桶
- 可读性和可管理性:以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如
- 强制:不要包含特殊字符
包含空格、换行、单双引号以及其他转义字符
- value设计:
- 拒绝bigkey:
- bigkey的经验:一般认为value超过10KB就是bigkey
- bigkey的删除耗费大量时间,不应该使用del删除,应当遍历删除:
- 用
hscan
、sscan
、zscan
方式渐进式删除
- 用
- Redis4.0以前的版本,要注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发
del
操作,造成阻塞):- Redis4.0之后的版本可以通过在
.conf配置文件
中配置lazyfree-lazy-expire yes
来开启失效后异步删除,但还是建议使用渐进式删除
- Redis4.0之后的版本可以通过在
- 选择适合的数据类型:实体类型(要合理控制和使用数据结构,但也要注意节省内存和性能之间的平衡)相比多次设置属性值在存储和性能上更加优秀
26.3.2 关于bigkey
- bigkey如何产生的:
- 社交类:粉丝列表,如果某些明星或者大v不精心设计下,所有粉丝都放到这个大v下,必是bigkey。
- 统计类:例如按天存储某项功能或者网站的用户集合,除非没几个人用,否则必是bigkey
- 缓存类:将数据从数据库load出来序列化放到Redis里,这个方式非常常用,但有两个地方需要注意,第一,是不是有必要把所有字段都缓存;第二,有没有相关关联的数据,有的同学为了图方便把相关数据都存一个key下,产生bigkey
- bigkey的危害:
- 导致redis阻塞:无论是拷贝元素、
getAll
还是直接删除整个key对应value都会阻塞 - 导致网络拥塞:
- bigkey也就意味着每次获取要产生的网络流量较大,假设一个bigkey为1MB,客户端每秒访问量为1000,那么每秒产生1000MB的流量,对于普通的千兆网卡(按照字节算是128MB/s)的服务器来说简直是灭顶之灾,降低了并发吞吐量(因此redis性能不行不要只看redis,还要看看网卡宽带)
- 而且一般服务器会采用单机多实例的方式来部署,也就是说一个bigkey可能会对其他实例也造成影响,其后果不堪设想
- 过期删除:
- Redis4.0以前的版本,要注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发
del
操作,造成阻塞) - Redis4.0之后的版本可以通过在
.conf配置文件
中配置lazyfree-lazy-expire yes
来开启失效后异步删除,但还是建议使用渐进式删除
- Redis4.0以前的版本,要注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发
- 如何优化bigkey?
- 分段存储:比如一个大的key,假设存了1百万的用户数据,可以拆分成
200个key,每个key下面存放5000个用户数据
3.1 分段存储具体步骤:
1)例如有很多用户的id
要存储到user这个key下,那不直接存储,而是用具体的 userid
对已有主节点个数取模,将其值作为新的user_X
key(X为取模的值),并set
进去
2)后续需要获取某个id
用户的个人信息,只要将id
取模就能得到对应key,在hash到指定slot后就能取出key-value
- 如果bigkey不可避免,也要思考一下要不要每次把所有元素都取出来(例如有时候仅仅需要
hmget
,而不是hgetall
),删除也是一样,尽量使用优雅的方式来处理
26.3.3 命令使用
- 禁用危险命令:使用
ACL
或redis的rename
机制禁掉诸如用keys
、flushall
、flushdb
这类危险命令,改为使用渐进的方式进行处理 - 合理使用RedisDB:
- redis多数据库较弱,很多客户端支持较差,同时多个数据库还是由一个Redis实例管理,这就造成多数据库的IO操作依旧是单线程处理(没开启
IO-threads
情况),彼此间会有干扰、阻塞的情况 - 用一个redisDB即可,要对数据分片就搭建集群
- 使用批量操作提升效率:
- 原生命令
mget
、mset
- 非原生命令:
pipeline
、Lua
脚本
批量操作元素个数要控制好,通常500个以内
- Redis事务功能较弱,不建议过多使用,可以用lua替代
26.4 Redis客户端连接池调优
Redis中客户端只有服务端的空闲连接的使用和归还权,无法在归还连接后通知服务端关闭该连接!
26.4.1 三大核心参数及使用事项
- maxTotal :早期叫
maxActive
,表示资源池中最大连接数,默认为8
- 如何设置:
应用个数 * maxTotal
不能超过redis的最大连接数maxclients
- 应用个数确定:通常根据业务并发量来:举个例子:
- 一次命令时间(borrow|return resource + Jedis执行命令(含网络) )的平均耗时约为1ms,一个连接的QPS大约是1000
- 业务期望的QPS是50000,那么理论上需要的资源池大小是
50000 / 1000 = 50个
- 但事实上这是个理论值,还要考虑到要比理论值预留一些资源,通常来讲
maxTotal
可以比理论值大一些
需要注意,
maxTotal
不是越大越好,一方面连接太多占用客户端和服务端资源,另一方面对于Redis这种高QPS的服务器,一个大命令的阻塞即使设置再大资源池仍然会无济于事
- maxIdle:连接池允许的最多可以用的空闲连接数
- 原理:如果超过了maxIdle,当超过的连接执行完业务后会慢慢被移出连接池释放掉
建议
maxTotal = maxIdle
,这样可以避免连接池伸缩缩带来的性能干扰
- minIdle:连接池允许的最少可以用的空闲连接连接数
无论是
maxIdle
还是minIdle
,都可以配置对应的连接池空闲连接个数,只不过Redis中关于连接池空闲个数的设置默认使用的是maxIdle
- 三大参数配置策略:
- 建议
maxTotal = maxIdle
,这样可以避免连接池伸缩缩带来的性能干扰 - 但是如果并发量不大或者
maxTotal
设置过高,会导致不必要的连接资源浪费。一般推荐maxIdle
可以设置为按上面的业务期望QPS计算出来的理论连接数,maxTotal
可以再放大一倍。
26.4.2 连接池预热
- 必要性:Redis默认启动时服务端内没有任何连接池可供使用的,因此有必要在启动时或启动后按照
minIdle
创建n
个空闲连接
不确定启动后进来的请求量,因此连接数设置为
minIdle
即可
- 不作连接池预热的后果:启动后如果有大量的请求进来会对Redis性能会有影响
- 示例代码:
List<Jedis> minIdleJedisList = new ArrayList<Jedis>(jedisPoolConfig.getMinIdle());
for (int i = 0; i < jedisPoolConfig.getMinIdle(); i++) {
Jedis jedis = null;
try {
jedis = pool.getResource();
minIdleJedisList.add(jedis);
jedis.ping();
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
//注意,这里不能马上close将连接还回连接池,否则最后连接池里只会建立1个连接。。
//jedis.close();
}
}
//统一将预热的连接还回连接池
for (int i = 0; i < jedisPoolConfig.getMinIdle(); i++) {
Jedis jedis = null;
try {
jedis = minIdleJedisList.get(i);
//将连接归还回连接池
jedis.close();
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
}
}
26.5 过期键的三种清除策略
26.5.1 清理方式
- 被动删除:当读/写一个已经过期的key时,会触发惰性删除策略,直接删除掉这个过期key
- 主动删除:由于惰性删除策略无法保证冷数据被及时删掉,所以Redis会定期主动淘汰一批已过期的key
26.5.2 清理策略
- 策略配置位置:
.conf文件
中maxmemory-policy
:默认是noeviction
表示不处理,这个是Redis 6.0版本的默认策略,但是不推荐用
- 触发时机:当前已用内存超过
maxmemory
限定时(在.conf
中配置该参数),触发清理策略
2.1 Redis一定要设置最大内存
maxmemory
的原因:
1)如果不设置最大内存,当Redis内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换(swap),会让Redis 的性能急剧下降
- 关于LRU算法和LFU算法:
- LRU算法(Least Recently Used,最近最少使用):淘汰很久没被访问过的数据,以最近一次访问时间作为参考。
- LFU算法(Least Frequently Used,最不经常使用):淘汰最近一段时间被访问次数最少的数据,以次数作为参考。
热点数据尽量用LFU
- 相关内存淘汰策略:
- 针对设置了过期时间的key做处理:
volatile-ttl
:在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除。volatile-random
:就像它的名称一样,在设置了过期时间的键值对中,进行随机删除volatile-lru
:会使用LRU算法筛选设置了过期时间的键值对删除。volatile-lfu
:会使用LFU算法筛选设置了过期时间的键值对删除。
- 针对所有的key做处理:
allkeys-random
:从所有键值对中随机选择并删除数据。allkeys-lru
:使用LRU算法在所有数据中进行筛选删除。allkeys-lfu
:使用LFU算法在所有数据中进行筛选删除。
- 不处理:
noeviction
:不会剔除任何数据,拒绝所有写入操作并返回客户端错误信息"(error) OOM command not allowed when used memory
",此时Redis只响应读操作(这个是Redis 6.0版本的默认策略,但是不推荐用)