下载 Redis
启动服务 CMD 执行 redis-server redis.windows.conf
下载 Redis可视化工具 Another Redis DeskTop Manager
开源
的 key-value
存储系统。string
(字符串)、list
(链表)、set
(集合)、zset
(sorted set 有序集合) 和 hash
(哈希类型)。原子性
的。排序
。周期性
的把更新的数据写入磁盘
或者把修改操作写入追加的记录文件。master-slave (主从)
同步。配合关系型数据库做高速缓存
多样的数据结构存储持久化数据
Redis 使用的是单线程 + 多路 IO 复用技术:
首先并不是高性能服务器都是多线程来实现的,因为reids的核心就是数据在内存中,他单线程的去操作就是效率最高的。单线程可以避免上下文的切换和锁的竞争。
一次CPU上下文切换大概在1500ns左右。
从内存中读取1MB的连续数据,耗时大约250us,假设1MB的数据由多个线程读取了1000次,那么就有1000次时间上下文切换。那么就有1500ns * 1000 = 1500u,在对比单线程下的250us,结果不言而喻。
但是在redis6.0以后,采用了多路IO复用来提高性能,多路IO复用只用来处理网络数据的读写和协议解析,命令的执行仍旧是单线程。
再介绍五大数据类型之前,先介绍下 Redis 键 key 的操作
keys * 查看当前库所有key(匹配:keys * 1)
exists <key> 判断某个key是否存在
type <key> 查看你的key是什么类型
del <key> 删除指定的key数据
unlink <key> 根据value选择非阻塞删除,仅将keys从keyspace元数据中删除,真正的删除会在后续异步操作
expire <key> 10 为给定的key设置过期时间,l0秒钟
ttl <key> 查看还有多少秒过期,-1表示永不过期,-2表示已过期
set <key> <value> 设置key的值
其他操作
select <库码> 命令切换数据库
dbsize 查看当然数据库key的数量
flushdb 清空当前库
flushall 清空所有库
二进制安全的
。意味着 Redis 的 string 可以包含任何数据。比如 jpg 图片或者序列化的对象。512M
。常用命令
get <key> 查询对应键值
append <key> <value> 将给定的<value>追加到原值的末尾
strlen <key> 获得值的长度
setnx <key> <value> 只有在key不存在时设置key的值,存在无法设置成功
incr <key> 将key中储存的数据加1,只能对数字进行操作,如果为空,新增值为1
decr <key> 将key中储存的数据减1,只能对数字进行操作,如果为空,新增值为-1
incrby/decrby <key> <步长> 将key中储存的数字值增减.自定义步长
mset <key1> <value1> <key2> <value2> .... 同时设置一个或多个key-value对
mget <key1> <key2> <key3> .... 同时获取一个或多个value
msetnx <key1> <value1> <key2><value2> .... 同时设置一个或多个key-value对,当且仅当所有给定key都不存在。原子性,有一个失败则都失败
getrange <key> <起始位置> <结束位置> 获得值的范围,类似java中的substring
setrange <key> <起始位置> <value> 用<value>覆写<key>所储存的字符串值,从<起始位置>开始(索引从0开始)
setex <key> <过期时间> <value> 设置键值的同时,设置过期时间,单位秒
getset <key> <value> 以新换旧,设置了新值同时获得旧值
incr 和 decr 都是对指定key的数值进行原子操作,所调原子操作是指不会被线程调度机打断斯的操作,这种操作一旦开始,就一直运行结束,中间不会有任何context switeh(切换到另一个线程)
Redis单命令的原子性主要得益于Redis的单线程。
在java多线程中无法保证原子性,常见问题:
① java 中的 i++ 是否是原子操作?答案是 不是
② i=0,两个线程分别对 i 进行 ++100 次,值是多少?答案是 <=200
数据结构
Redis是用C语言写的,但是对应Redis的Sting,并不是C 语言中的字符串(即以空字符’\0’结尾的字符数组);Redis自定义了数据结构SDS(simple dynamic string)【简单动态字符串】
,并将 SDS 作为 Redis的默认字符串表示。
struct sdshdr{
//记录 buf 数组中未使用字节的数量
int free;
//记录buf数组已使用字节的数量
//等于 SDS 保存字符串的长度
int len;
//字节数组,用于保存字符串
char buf[]; //柔性数组
}
优点
减少修改字符串的内存重新分配次数
1、空间预分配
对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。
C++中数组在进行扩容时,往往会申请一个更大的数组,然后把数组拷贝过去。Redis同样基于这种策略提高了空间预分配机制。当执行字符串增长操作并且需要扩展内存时,程序不仅仅会给SDS分配必需的空间还会分配额外的未使用空间,其长度存到free属性中。具体如下:
2、惰性空间释放
对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 free 属性将这些字节的数量记录下来,等待后续使用。
为什么SDS的最大长度是512M?
Redis字符串使用int类型表示长度,一共有32个比特位。2^32字节=512M
SDS是如何扩容的?
空间预分配。先判断扩容长度与free的大小关系,如果够就直接拼接字符串,如果不够使用空间预分配的方式扩容
双向链表
,对两端的操作性能很高,通过索引下标的操作中间的节点性能会较差。lpush/rpush <key> <value1> <value2> <value3> 从左边/右边插入一个或多个值
lpop/rpop <key> 从左边/右边吐出一个值,值在键在,值光键亡
rpoplpush <key1> <key2> 从<key1>列表右边吐出一个值,插到key2>列表左边
lrange <key> <start> <stop> 按照索引下获得元素(从左到右),lrange mylist 0 -1 (0左边第一个,-1右边第一个),0 -1表示获取所有
lindex <key> <index> 按照索引下标获得元素(从左到右)
llen <key> 获得列表长度
linsert <key> before/after <value> <newvalue> 在<value>的后面插入或者前面插入值<newvalue>
lrem <key> <n> <value> 从左边除n个value从左到右
lset <key> <index> <value> 将列表key下标为index的值普换成value
数据结构
无序集合。它底层其实是一个 value 为 null 的 hash 表
,所以添加,删除,查找的复杂度都是 O (1)
。常用命令
sadd <key> <value1> <value2> ..... 将一个或多个member元素加入到集合key中,已经存在的member元素将被忽略
smembers <key> 取出该集合的所有值
sismember <key> <value> 判断集合<key>是否为含有该<value>值,有1,没有0
scard <key> 返回该集合的元素个数
srem <key> <value1> <value2> ..... 删除集合中的某个元素
spop <key> 随机从该集合中吐出一个值
srandmember <key> <n> 随机从该集合中取出n个值。不会从集合中删除
smove <source> <destination> value 把集合中一个值从一个集合移动到另一个集合,
sinter <key1> <key2> 返回两个集合的交集元素
sunion <key1> <key2> 返回两个集合的并集元素
sdif <key1> <key2> 返回两个集合的差集元素(key1中的,不包含key2中的)
数据结构
常用命令
hset <key> <field> <value> 给<key>集合中的<field>键赋值<value>
hget <keyl> <field> 从<keyl>集合<field>取出value
hmset <keyl> <fieldl> <valuel> <field2> <value2>... 批量设置hash的值
hexists <key1> <field> 查看哈希表key中,给定域field是否存在
hkeys <key> 列出该hash集合的所有field
hvals <key> 列出该hash集合的所有value
hincrby <key> <field> <increment> 为哈希表key中的域feld的值加上增量 1 -l
hsetnx<key> <field> <value> 将哈希表key中的域field的值设置为value,当且仅当域field不存在
数据结构
Hash 类型对应的数据结构是两种:ziplist(压缩列表),hashtable(哈希表)。当 field-value 长度较短且个数较少时,使用 ziplist,否则使用 hashtable。
没有重复元素
的字符串集合。评分(score)
,这个评分(score)被用来按照从最低分到最高分的方式排序集合中的成员。集合的成员是唯一的,但是评分可以是重复了
。常用命令
zadd <key> <score1> <value1> <score2> <value2> ... 将一个或多个member元素及其score值加入到有序集key当中
zrange <key> <start> <stop> [WITHSCORES] 返回有序集key中,下标在<start><stop>之间的元素,带WITHSCORES,可以让分数一起和值返回到结果集
zrangebyscore key minmax [withscores][limit offset count] 返回有序集key中,所有score值介于min和max之间(包括等于min或max)的成员,有序集成员按scor值递增(从小到大)次序排列
zrevrangebyscore key maxmin [withscores][limit offset count] 同上,改为从大到小排列
zincrby <key> <increment> <value> 为元素的score加上增量
zrem <key> <value> 删除该集合下,指定值的元素,
zcount <key> <min> <max> 统计该集合,分数区间内的元素个数
zrank <key> <value> 返回该值在集合中的排名,从0开始
数据结构
ZSet 是Redist中的一种特殊的数据结构,它内部维护了一个有序的字典,这个字典的元素中既包括了一个成员,也包括了一个double类型的分值。这个结构可以帮助用户实现记分类型的排行榜数据,比如游戏分数排行榜,网站流行度排行等。
Redis中的ZSet在实现中,有多种结构,大类的话有两种,分别是ziplist(压缩列表)和skiplist(跳跃表),但是这只是以前,在Redis5.0中新增了一个listpack(紧凑列表)的数据结构,这种数据结构就是为了替代ziplist的,而在之后Redis7.0的发布中,在Zset的实现中,已经彻底不在使用zipList了。
当ZSet的元素数量比较少时,Redis会采用ZipList(ListPack)来存储ZSet的数据。ZipList(ListPack)是一种紧凑的列表结构,它通过连续存储元素来节约内存空间。当ZSet的元素数量增多时,Redis会自动将ZipList(ListPack)转换为SkipList,以保持元素的有序性和支持范围查询操作。
何时转换
总的来说就是,当元素数量少于128,每个元素的长度都小于64字节的时候,使用ZipList(ListPack),否则使用SkipList。
跳表
跳表也是一个有序链表,如下面这个数据结构:
在这个链表中,我们想要查找一个数,需要从头结点开始向后依次遍历和匹配,直到查到为止,这个过程是比较耗费时间的,他的时间复杂度是O(N)。
那么,怎么能提升遍历速度呢,有一个办法,那就是我们对链表进行改造,先对链表中每两个节点建立第一级索引,如下图所示:
有了我们创建的这个索引之后,我们查询元素12,我们先从一级索引6->9->17->26中查找,发现12介于9和17之间,然后,转移到下一层进行搜索,即9->12->17,即可找到12这个节点了。
可以看到,同样是查找12,原来的链表需要扁历5个元素(3、6、7、9、12),建立了一层索引之后,只需要遍历3个元素即可(6、9、12)。
有了上面的经验,我们可以继续创建二级索引、三级索引…
在这样一个链表中查找12这个元素,只需要遍历2个节点就可以了(9、12)。像上面这种带多级索引的链表,就是跳表。
常用命令
setbit <key> <offset> <value> 设置Bitmaps中某个偏移量的值(0或1),offset偏移量从0开始
getbit <key> <offset> 获取Bitmaps中某个偏移量的值
bitcount <key> [start end] 统计字符串从stat字节到end字节比特值为1的数量
bitop and(or/not/xor) <destkey> key... bitop是一个复合操作,它可以做多个Bitmaps的and(交集)、or(并集)、not
(非)、or(异或)操作并将结果保存在destkey中
每个独立用户是否访问过网站存放在 Bitmaps 中,将访问的用户记做 1,没有访问的用户记做 0,用偏移量作为用户的 id。设置键的第 offset 个位的值(从0算起),假设现在有 20 个用户,userid=1、6、11、15、19的用户对网站进行了访问,那么当前 Bitmaps 初始化结果如图:
127.0.0.1:6379> SETBIT users:20210101 1 1
(integer) 0
127.0.0.1:6379> SETBIT users:20210101 6 1
(integer) 0
127.0.0.1:6379> SETBIT users:20210101 11 1
(integer) 0
127.0.0.1:6379> SETBIT users:20210101 15 1
(integer) 0
127.0.0.1:6379> SETBIT users:20210101 19 1
(integer) 0
实例:获取 id=8 的用户是否在 2020-11-06 这天访问过, 返回0说明没有访问过
127.0.0.1:6379> GETBIT user:20210101 1
(integer) 0
127.0.0.1:6379> GETBIT users:20210101 1
(integer) 1
127.0.0.1:6379> GETBIT users:20210101 8
(integer) 0
127.0.0.1:6379> GETBIT users:20210101 100
(integer) 0
实例:计算 2022-11-06 这天的独立访问用户数量
127.0.0.1:6379> BITCOUNT users:20210101
(integer) 5
实例:
2020-11-04 日访问网站的userid=1,2,5,9。
setbit unique:users:20201104 1 1
setbit unique:users:20201104 2 1
setbit unique:users:20201104 5 1
setbit unique:users:20201104 9 1
2020-11-03 日访问网站的userid=0,1,4,9。
setbit unique:users:20201103 0 1
setbit unique:users:20201103 1 1
setbit unique:users:20201103 4 1
setbit unique:users:20201103 9 1
计算出两天都访问过网站的用户数量
bitop and unique:users:20201103 unique:users:20201104
计算出任意一天都访问过网站的用户数量(例如月活跃就是类似这种) , 可以使用or求并集
Bitmaps 与 set 对比
假设网站有 1 亿用户, 每天独立访问的用户有 5 千万, 如果每天用集合类型和 Bitmaps 分别存储活跃用户可以得到表:
set 和 Bitmaps 存储一天活跃用户对比
数据类型 | 每个用户 id 占用空间 | 需要存储的用户量 | 全部内存量 |
---|---|---|---|
集合 | 64 位 | 50000000 | 64 位 * 50000000 = 400MB |
数据Bitmaps | 1 位 | 100000000 | 1 位 * 100000000 = 12.5MB |
很明显, 这种情况下使用 Bitmaps 能节省很多的内存空间, 尤其是随着时间推移节省的内存还是非常可观的。
但 Bitmaps 并不是万金油, 假如该网站每天的独立访问用户很少, 例如只有 10 万(大量的僵尸用户) , 那么两者的对比如下表所示, 很显然, 这时候使用 Bitmaps 就不太合适了, 因为基本上大部分位都是 0。
set 和 Bitmaps 存储一天活跃用户对比(用户比较少)
数据类型 | 每个用户 id 占用空间 | 需要存储的用户量 | 全部内存量 |
---|---|---|---|
集合 | 64 位 | 100000 | 64 位 * 100000 = 800KB |
数据Bitmaps | 1 位 | 100000000 | 1 位 * 100000000 = 12.5MB |
在工作当中,我们经常会遇到与统计相关的功能需求,比如统计网站 PV(PageView 页面访问量),可以使用 Redis 的 incr、incrby 轻松实现。但像 UV(UniqueVisitor 独立访客)、独立 IP 数、搜索记录数等需要去重和计数的问题如何解决?这种求集合中不重复元素个数的问题称为基数问题。
解决基数问题有很多种方案:
① 数据存储在 MySQL 表中,使用 distinct count 计算不重复个数。
② 使用 Redis 提供的 hash、set、bitmaps 等数据结构来处理。
以上的方案结果精确,但随着数据不断增加,导致占用空间越来越大,对于非常大的数据集是不切实际的。能否能够降低一定的精度来平衡存储空间?Redis 推出了 HyperLogLog。
什么是基数?
比如数据集 {1, 3, 5, 7, 5, 7, 8},那么这个数据集的基数集为 {1, 3, 5 ,7, 8},基数 (不重复元素) 为 5个。 基数估计就是在误差可接受的范围内,快速计算基数。
常用命令
pfadd <key> <element> [element...] 添加指定元素到HyperLogLog中
pfcount <key> [key..] 计算HLL的近似基数,可以计算多个HLL,比如用HLL存储每天的UV,计算一周的UV可以使用7天的UV合并计算即可
pfmerge <destkey> <sourcekey> [sourcekey...] 将一个或多个sourcekey合并后的结果存储在另一个destkey中,比如每月活跃用户可以使用每天的活跃用户来合并计算可得
Redis 3.2 中增加了对 GEO 类型的支持。GEO,Geographic,地理信息的缩写。该类型,就是元素的 2 维坐标,在地图上就是经纬度。redis 基于该类型,提供了经纬度设置,查询,范围查询,距离查询,经纬度 Hash 等常见操作。
常用命令
geoadd <key> <longitude> <latitude> <member> 加地理位置(经度,纬度,名称)
geopos <key> <member> 获得指定地区坐标值
geodist <key> <member1> <member2> m|km|ft|mi 获得两个位置之间的直线距离
单位:
m 表示单位为米[默认值]。
km 表示单位为千米。
mi 表示单位为英里。
t 表示单位为英尺。
如果用户没有显式地指定单位参数,那么GEODIST默认使用米作为单位
georadius <key> <longitude> <latitude> radius m|km|ft|mi 以给定的经纬度为中心,找出某一半径内的元素
部分实例
geoadd china:city 121.47 31.23 shanghai
geoadd china:city 106.50 29.53 chongqing 114.05 22.52 shenzhen 116.38 39.90
georadius china:city110 30 1000 km
两极无法直接添加,一般会下载城市数据,直接通过Java程序一次性导入。有效的经度从 -180 度到 180 度。有效的纬度从 -85.05112878 度到 85.05112878 度。当坐标位置超出指定范围时,该命令将会返回一个错误。已经添加的数据,是无法再次往里面添加的。·
Redis 的发布和订阅
客户端可以订阅频道,当给这个频道发布消息后,消息就会发送给订阅的客户端:
① 打开一个客户端订阅 SUBSCRIB channel
② 打开另一个客户端,给 channel 发布消息 PUBLISH hello
③ 打开第一个客户端可以看到发送的消息
注:发布的消息没有持久化,订阅的客户端只能收到订阅后发布的消息
Redis 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。Redis 事务的主要作用就是串联多个命令防止别的命令插队。
Redis 事务中有 Multi、Exec 和 discard 三个指令,在 Redis 中,从输入 Multi 命令开始,输入的命令都会依次进入命令队列中,但不会执行,直到输入 Exec 后,Redis 会将之前的命令队列中的命令依次执行。而组队的过程中可以通过 discard 来放弃组队。分别等同于 mysql 的开始、提交、回滚。
事务的错误处理
WATCH key
在执行 multi 之前,先执行 watch key1 [key2],可以监视一个 (或多个) key ,如果在事务执行之前这个 (或这些) key 被其他命令所改动,那么事务将被打断。
unwatch
取消 WATCH 命令对所有 key 的监视。如果在执行 WATCH 命令之后,EXEC 命令或 DISCARD 命令先被执行了的话,那么就不需要再执行 UNWATCH 了。
Redis 事务三特性
节省每次连接 redis 服务带来的消耗,把连接好的实例反复利用。通过参数管理连接的行为,代码见项目中:
LUA 脚本在 Redis 中的优势
代码
public class SecKill_redis {
public static void main(String[] args) {
Jedis jedis =new Jedis("192.168.44.168",6379);
System.out.println(jedis.ping());
jedis.close();
}
//秒杀过程
public static boolean doSecKill(String uid,String prodid) throws IOException {
//1 uid和prodid非空判断
if(uid == null || prodid == null) {
return false;
}
//2 连接redis
//Jedis jedis = new Jedis("192.168.44.168",6379);
//通过连接池得到jedis对象
JedisPool jedisPoolInstance = JedisPoolUtil.getJedisPoolInstance();
Jedis jedis = jedisPoolInstance.getResource();
//3 拼接key
// 3.1 库存key
String kcKey = "sk:"+prodid+":qt";
// 3.2 秒杀成功用户key
String userKey = "sk:"+prodid+":user";
//监视库存
jedis.watch(kcKey);
//4 获取库存,如果库存null,秒杀还没有开始
String kc = jedis.get(kcKey);
if(kc == null) {
System.out.println("秒杀还没有开始,请等待");
jedis.close();
return false;
}
// 5 判断用户是否重复秒杀操作
if(jedis.sismember(userKey, uid)) {
System.out.println("已经秒杀成功了,不能重复秒杀");
jedis.close();
return false;
}
//6 判断如果商品数量,库存数量小于1,秒杀结束
if(Integer.parseInt(kc)<=0) {
System.out.println("秒杀已经结束了");
jedis.close();
return false;
}
//7 秒杀过程
//使用事务
Transaction multi = jedis.multi();
//组队操作
multi.decr(kcKey);
multi.sadd(userKey,uid);
//执行
List<Object> results = multi.exec();
if(results == null || results.size()==0) {
System.out.println("秒杀失败了....");
jedis.close();
return false;
}
//7.1 库存-1
//jedis.decr(kcKey);
//7.2 把秒杀成功用户添加清单里面
//jedis.sadd(userKey,uid);
System.out.println("秒杀成功了..");
jedis.close();
return true;
}
}
持久化就是把内存的数据写到磁盘中,防止服务宕机导致内存数据丢失。
Redis支持两种方式的持久化,一种是RDB的方式,一种是AOF的方式。前者会根据指定的规则定时将内存中的数据存储在硬盘上,而后者在每次执行完命令后将命令记录下来。一般将两者结合使用。
什么是 RDB
在指定的时间间隔
内将内存中的数据集快照
写入磁盘, 也就是行话讲的 Snapshot 快照,它恢复时是将快照文件直接读到内存里。
备份是如何执行的
Redis 会单独创建(fork)一个子进程来进行持久化,首先
会将数据写
入到一个临时文件
中,待写入过程结束了再用这个临时文件替换上次持久化
好的文件。整个过程中,主进程是不进行任何 IO 操作的,这就确保了极高的性能。如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,那 RDB 方式要比 AOF 方式更加的高效。RDB 的缺点是最后一次持久化后的数据可能丢失
。
为什么要写入临时文件
在替换持久化文件之前要写入临时文件,比如同步10个数据在第8个时候中断了,如果直接同步到持久化dump文件这样是不行的,应该先同步到临时文件,这样主要为了保证 dump 文件数据的一致性完整性,也是处于数据的完全考虑,这个过程用到的就是写时复制技术。
Fork
一样的进程
。新进程的所有数据(变量、环境变量、程序计数器等)原进程的子进程
。写时复制技术
”。RDB 持久化流程
在 redis.conf 中配置文件名称,默认为 dump.rdb。
rdb 文件的保存路径,也可以修改。默认为 Redis 启动时命令行所在的目录下 “dir ./”
如何触发 RDB 快照保持策略
触发方式 | 命令 | 描述 |
---|---|---|
手动触发 | save | SAVE命令执行快照的过程会阻塞所有客户端的请求,应避免在生产环境使用此命令 |
手动触发 | bgsave | BGSAVE命令可以在后台异步进行快照操作,快照的同时服务器还可以继续响应客户端的请求,因此需要手动执行快照时推荐使用BGSAVE命令 |
自动触发 | save m n | 根据配置规则进行自动快照,如SAVE 100 10,100秒内至少有10个键被修改则进行快照 |
自动触发配置文件中默认的快照配置
命令 save VS bgsave
SAVE 和 BGSAVE 两个命令都会调用 rdbSave 函数,但它们调用的方式各有不同:
Save是阻塞方式的;bgsave是非阻塞方式的。
执行 flushall 命令,也会产生 dump.rdb 文件,但里面是空的,无意义。
如何停止
动态停止 RDB:redis-cli config set save “”#save 后给空值,表示禁用保存策略。
优势和劣势
优势
劣势
以日志的形式来记录每个写操作(增量保存)
,将 Redis 执行过的所有写指令记录下来 (读操作不记录
), 只许追加文件但不可以改写文件
,redis 启动之初会读取该文件重新构建数据,换言之,redis 重启的话就根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。
AOF 持久化流程
可以在 redis.conf 中配置文件名称默认为 appendonly.aof 文件中开启,AOF 文件的保存路径,同 RDB 的路径一致。
AOF 和 RDB 同时开启,Redis 听谁的
AOF 和 RDB 同时开启,系统默认取 AOF 的数据(数据不会存在丢失)。
AOF 启动、修复、恢复
AOF 的备份机制和性能虽然和 RDB 不同,但是备份和恢复的操作同 RDB 一样,都是拷贝备份文件,需要恢复时再拷贝到 Redis 工作目录下,启动系统即加载。
正常恢复
异常恢复
AOF 同步频率设置
Rewrite 压缩是什么
AOF 采用文件追加方式,文件会越来越大为避免出现此种情况,新增了重写机制,当 AOF 文件的大小超过所设定的阈值时,Redis 就会启动 AOF 文件的内容压缩,只保留可以恢复数据的最小指令集,可以使用命令 bgrewriteaof。
重写原理,如何实现重写
AOF 文件持续增长而过大时,会 fork 出一条新进程来将文件重写 (也是先写临时文件最后再 rename),redis4.0 版本后的重写,是指把 rdb 的快照,以二进制的形式附在新的 aof 头部,作为已有的历史数据,替换掉原来的流水账操作。
no-appendfsync-on-rewrite:
触发机制,何时重写
Redis 会记录上次重写时的 AOF 大小,默认配置是当 AOF 文件大小是上次 rewrite 后大小的一倍且文件大于 64M 时触发。
重写虽然可以节约大量磁盘空间,减少恢复时间。但是每次重写还是有一定的负担的,因此设定 Redis 要满足一定条件才会进行重写。
重写流程
优势
劣势
RDB 和 AOF 用哪个好
官网建议
性能建议:
Redis 主机会一直将自己的数据复制给 Redis 从机,从而实现主从同步。在这个过程中,只有 master 主机可执行写命令,其他 salve 从机只能只能执行读命令,这种读写分离的模式可以大大减轻 Redis 主机的数据读取压力,从而提高了Redis 的效率,并同时提供了多个数据备份。主从模式是搭建 Redis Cluster 集群最简单的一种方式。
主从复制是什么
主机数据更新后根据配置和策略, 自动同步到备机的 master/slaver 机制,Master 以写为主,Slave 以读为主
。
主从复制能干嘛
主从复制搭建一主多从
① 创建一个文件夹,可以取名为 myredis
② 将 redis.conf 配置文件移动到 myredis 文件夹中
③ 配置一主两从的 conf,可以将 redis.conf 复制成三分,分别为
redis redis6379.conf redis6380.conf redis6381.conf
④ 在三个配置文件写入内容,例如 redis6379.conf
include/myredis/redis.conf # redis.conf 文件位置
pidfile/var/run/redis_6379.pid # pid文件位置
port 6379 # 端口号
dbfilename dump6379.rdb # dump文件名称
daemonize yes # 开启
⑤ 分别启动三个不同配置文件的redis
⑥配置从库,从机客户端执行 slaveof ip port 命令,例如6380,6381作为6379从机
slaveof 127.0.0.1 6379
⑦ 在三个redis客户端 info replication 查看是否为主或者从
主从复制原理
全量复制
,只要是重新连接 Master,全量复制将被自动执行。Master 主服务器接到命令后先进行数据的持久化,然后把持久化的文件发送给 Slave,拿到持久化文件后进行读取,完成数据同步。增量复制
。主从复制的一主二仆
主从复制的薪火相传
上一个 Slave 可以是下一个 Slave 的 Master,那么该 Slave 作为了链条中下一个的 Master,可以有效减轻 Master的写压力,去中心化降低风险。
中途变更转向:会清除之前的数据,重新建立拷贝最新的。风险是一旦某个slave宕机,后面的slave都没法备份。主机挂了,从机还是从机,无法写数据,如果想写数据需要进行下面的反客为主操作。
主从复制的反客为主
当一个 Master 宕机后,后面的slave可以立刻升为 Master,其后面的 Slave 不用做任何修改。用 slaveof no one 手动将从机变为主机。
在 Redis 主从复制模式中,因为系统不具备自动恢复的功能,所以当主服务器(master)宕机后,需要手动把一台从服务器(slave)切换为主服务器。在这个过程中,不仅需要人为干预,而且还会造成一段时间内服务器处于不可用状态,同时数据安全性也得不到保障,因此主从模式的可用性较低,不适用于线上生产环境。
Redis 官方推荐一种高可用方案,也就是 Redis Sentinel 哨兵模式,它弥补了主从模式的不足。Sentinel 通过监控的方式获取主机的工作状态是否正常,当主机发生故障时, Sentinel 会自动进行 Failover(即故障转移),并将其监控的从机提升主服务器(master),从而保证了系统的高可用性。
哨兵模式是一种特殊的模式,Redis 为其提供了专属的哨兵命令,它是一个独立的进程,能够独立运行。下面使用 Sentinel 搭建 Redis 集群,基本结构图如下所示:
哨兵模式是反客为主的自动版
,能够后台监控主机是否故障,如果故障了根据投票数自动将从库转换为主库。
① 调整为上面讲过的主从复制一主二仆模式
② 在之前建立的 myredis 目录下新建 sentinel.conf 文件夹
③ 配置哨兵,填写内容
sentinel monitor mymaster 127.0.0.1 6379 1
其中 mymaster 为监控对象起的服务器名称,1为至少有多少个哨兵同意迁移的数量。
④ 启动哨兵
redis-sentinel /myredis/sentinel.conf
复制延时
由于所有的写操作都是先在 Master 上操作,然后同步更新到 Slave 上,所以从 Master 同步到 Slave 机器有一定的延迟,当系统很繁忙的时候,延迟问题会更加严重,Slave 机器数量的增加也会使这个问题更加严重。
故障恢复
故障恢复是指主机down掉需要从机来替代它工作,故障恢复选择条件依次为
宕机的主机再启动是从机
多哨兵
在上图过程中,哨兵主要有两个重要作用:
在实际生产情况中,Redis Sentinel 是集群的高可用的保障,为避免 Sentinel 发生意外,它一般是由 3~5 个节点组成,这样就算挂了个别节点,该集群仍然可以正常运转。其结构图如下所示(多哨兵模式):
上图所示,多个哨兵之间也存在互相监控,这就形成了多哨兵模式,现在对该模式的工作过程进行讲解,介绍如下:
1) 主观下线
主观下线,适用于主服务器和从服务器。如果在规定的时间内(配置参数:down-after-milliseconds),Sentinel 节点没有收到目标服务器的有效回复,则判定该服务器为“主观下线”。比如 Sentinel1 向主服务发送了PING命令,在规定时间内没收到主服务器PONG回复,则 Sentinel1 判定主服务器为“主观下线”。
2) 客观下线
客观下线,只适用于主服务器。 Sentinel1 发现主服务器出现了故障,它会通过相应的命令,询问其它 Sentinel 节点对主服务器的状态判断。如果超过半数以上的 Sentinel 节点认为主服务器 down 掉,则 Sentinel1 节点判定主服务为“客观下线”。
3) 投票选举
投票选举,所有 Sentinel 节点会通过投票机制,按照谁发现谁去处理的原则,选举 Sentinel1 为领头节点去做 Failover(故障转移)操作。Sentinel1 节点则按照一定的规则在所有从节点中选择一个最优的作为主服务器,然后通过发布订功能通知其余的从节点(slave)更改配置文件,跟随新上任的主服务器(master)。至此就完成了主从切换的操作。
对上对述过程做简单总结:
Sentinel 负责监控主从节点的“健康”状态。当主节点挂掉时,自动选择一个最优的从节点切换为主节点。客户端来连接 Redis 集群时,会首先连接 Sentinel,通过 Sentinel 来查询主节点的地址,然后再去连接主节点进行数据交互。当主节点发生故障时,客户端会重新向 Sentinel 要地址,Sentinel 会将最新的主节点地址告诉客户端。因此应用程序无需重启即可自动完成主从节点切换。
什么是 Redis 集群
用来解决什么问题
搭建 Redis 集群
例如:搭建六个实例,6379,6380,6381,6389,6390,6391
① 编写以6379为例的配置文件,后编写其余端口配置文件
include/myredis/redis.conf # redis.conf 文件位置
pidfile/var/run/redis_6379.pid # pid文件位置
port 6379 # 端口号
dbfilename dump6379.rdb # dump文件名称
cluster-enabled yes # 打开集群模式
cluster-config-file nodes-6379.conf # 设置节点配置文件名
cluster-node-timeout 15000 # 设置节点失联时间,超过该时间(毫秒)集群自动进行主从切换
② 启动这6个redis服务
redis-server redis6379.conf
redis-server redis6380.conf
redis-server redis6381.conf
redis-server redis6389.conf
redis-server redis6390.conf
redis-server redis6391.conf
③ 将6个节点合成一个集群
进入到 redis 文件的 src 下 执行命令
redis-cli --cluster create --cluster-replicas 1 # -replicas 1 表示我们希望为集群中的每个主节点创建一个从节点。,一台主机一台从机,三组
xxx.xxx.xx.xxx:6379 xxx.xxx.xx.xxx:6380 # 使用真实 IP 地址
xxx.xxx.xx.xxx:6381 xxx.xxx.xx.xxx:6389 # 使用真实 IP 地址
xxx.xxx.xx.xxx:6390 xxx.xxx.xx.xxx:6391 # 使用真实 IP 地址
④ 集群是无中心化,-c 参数采用集群链接策略
redis-cli -c -p 6379 # 采用集群策略连接,设置数据会自动切换到相应的写主机
redis cluster 如何分配这六个节点
我们在搭建原则尽量保证每个主数据库运行在不同的 IP 地址,每个从库和主库不在一个 IP 地址上。
什么是 slots(插槽)
一个 Redis 集群包含 16384 个插槽(hash slot),数据库中的每个键都属于这 16384 个插槽的其中一个。集群使用公式 CRC16 (key) % 16384
来计算键 key 属于哪个槽, 其中 CRC16 (key) 语句用于计算键 key 的 CRC16 校验和 。
集群中的每个节点负责处理一部分插槽。 举个例子, 如果一个集群可以有主节点, 其中:
其实作用就是将我们 set 的 key 通过计算平均分配到不同的主机上。
做一个举例:
在集权部署下,使用 set key yang 增加 key 值,他会返回
127.0.0.1:6379>set key yang
Redirected to slot [12706]located at 192.168.44.168:6381
这个 12706 就是用来确定 这个 key 应该存在的节点,因为每个节点的范围不一样,这样这个数据就由节点 C 处理了,这也是无中心化集集群的一个特点,不管从哪里进入,如果不能处理会提交给其他节点,就像是这个例子12706 的插槽是在6379主机执行的,但是它范围是0 号至 5460 号插是不能够处理,它就会分配给到 B,B不能处理就给C。
用批量入值会有什么问题?举例:
192.168.44.168:6379>mset name lucy age 20 address china
(error)CROSSSLOT Keys in request don't hash to the same slot
但是我就想加入多个值我们应该加入组才行,让它们归属到一个组就行了:
192.168.44.168:6379>mset name{user} lucy age{user} 20
计算 key 的插槽值
cluster keyslot k1
查看自己插槽中的值是否存在
cluster countkeysinslot 4847
返回插槽中键的数量
cluster getkeysinslot 4847 1
故障恢复
① 如果主节点下线?从节点能否自动升为主节点?
可以的。 注意:15 秒超时
② 主节点恢复后,主从关系会如何?
主节点回来变成从机。
③ 如果所有某一段插槽的主从节点都宕掉,redis 服务是否还能继续?
集群的不足
缓存穿透是指当用户查询某个数据时,Redis 中不存在该数据,也就是缓存没有命中,此时查询请求就会转向持久层数据库 MySQL,结果发现 MySQL 中也不存在该数据,MySQL 只能返回一个空象,代表此次查询失败。如果这种类请求非常多,或者用户利用这种请求进行恶意攻击,就会给 MySQL 数据库造成很大压力,甚至于崩溃,这种现象就叫缓存穿透。
解决方案
1) 缓存空对象
当 MySQL 返回空对象时, Redis 将该对象缓存起来,同时为其设置一个过期时间。当用户再次发起相同请求时,就会从缓存中拿到一个空对象,用户的请求被阻断在了缓存层,从而保护了后端数库,最长不超过五分钟。
2)设置可访问的名单(白名单)
使用 bitmaps 类型定义一个可以访问的名单,名单 id 作为 bitmaps 的偏移量,每次访问和 bitmap 里面的 id 进行比较,如果访问 id 不在 bitmaps 里面,进行拦截,不允许访问。
3) 布隆过滤器
布隆过滤器判定不存在的数据,那么该数据一定不存在,利用它的这一特点可以防止缓存穿透。
首先将用户可能会访问的热点数据存储在布隆过滤器中(也称缓存预热),当有一个用户请求到来时会先经过布隆过滤器,如果请求的数据,布隆过滤器中不存在,那么该请求将直接被拒绝,否则将继续执行查询。相较于第一种方法,用布隆过滤器方法更为高效、实用。其流程示意图如下:
缓存预热:是指系统启动时,提前将相关的数据加载到 Redis 缓存系统中。这样避免了用户请求的时再去加载数据。
布隆过滤器(Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量 (位图) 和一系列随机映射函数(哈希函数)。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。
4) 进行实时监控
当发现 Redis 的命中率开始急速降低,需要排查访问对象和访问的数据,和运维人员配合,可以设置黑名单限制服务。
key 对应的数据存在,但在 redis 中过期,此时若有大量并发请求过来,这些请求发现缓存过期一般都会从后端数据库加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端数据库压垮。
解决方案
key 可能会在某些时间点被超高并发地访问,是一种非常 “热点” 的数据。
1)预先设置热门数据,改变过期时间
在 redis 高峰访问之前,把一些热门数据提前存入到 redis 里面,加大这些热门数据 key 的时长。现场监控哪些数据热门,实时调整 key 的过期时长。
2) 分布式锁
采用分布式锁的方法,重新设计缓存的使用方式,过程如下:
缓存雪崩是指缓存中大批量的 key 同时过期,而此时数据访问量又非常大,从而导致后端数据库压力突然暴增,甚至会挂掉,这种现象被称为缓存雪崩。它和缓存击穿不同,缓存击穿是在并发量特别大时,某一个热点 key 突然过期,而缓存雪崩则是大量的 key 同时过期,因此它们根本不是一个量级。
解决方案
1)构建多级缓存架构
nginx 缓存 + redis 缓存 + 其他缓存(ehcache 等)。
2)使用锁或队列
用加锁或者队列的方式来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上,该方法不适用高并发情况。
3)设置过期标志更新缓存
记录缓存数据是否过期(设置提前量),如果过期会触发通知另外的线程在后台去更新实际 key 的缓存。
4)将缓存失效时间分散开
比如可以在原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
分布式锁解决什么问题
对于单机多线程来说,在 Java 中,我们通常使用 ReetrantLock 类、synchronized 关键字这类 JDK 自带的 本地锁 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。
从图中可以看出,这些线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到本地锁访问共享资源。
分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁就诞生了。
举个例子:系统的订单服务一共部署了 3 份,都对外提供服务。用户下订单之前需要检查库存,为了防止超卖,这里需要加锁以实现对检查库存操作的同步访问。由于订单服务位于不同的 JVM 进程中,本地锁在这种情况下就没办法正常工作了。我们需要用到分布式锁,这样的话,即使多个线程不在同一个 JVM 进程中也能获取到同一把锁,进而实现共享资源的互斥访问。
从图中可以看出,这些独立的进程中的线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到分布式锁访问共享资源。
一个最基本的分布式锁需要满足:
通常情况下,我们一般会选择基于数据库实现分布式锁、基于 Redis 或者 ZooKeeper 实现分布式锁,Redis 用的要更多一点,我这里也以 Redis 为例介绍分布式锁的实现。
使用 Redis 实现分布式锁
使用 stenx 上锁,使用 del 释放锁,为了让锁能够释放增加过期时间使用 expire,如果是分步操作可能上锁之后出现异常,无法设置过期时间,所以需要在上锁时候就指定过期时间,命令为:
set users 10 nx ex 12 # nx 是上锁 ex 是过期时间
加了 UUID 也会出现误删锁,使用 LUA 保证原子性
过程
// 1.从redis中获取锁
String uuid = UUID.randomUUID().tostring();
Boolean lock = this.redisTemplate.opsForValue().setIfAbsent ("lock", uuid, 2, TimeUnit.SECONDS);
// 2.释放锁 del
String script = "if redis.call('get',KEYS[1])=ARGV[1] then return redis.call('del',KEYS[1])else return 0 end";
// 设置lua脚本返回的数据类型,
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
// 设置Lua脚本返回类型为Long
redisScript.setResultType(Long.class);
redisScript.setScriptText(script);
redisTemplate.execute(redisscript, Arrays.asList("lock"), uuid);
Redis ACL是Access Control List(访问控制列表)的缩写,该功能允许根据可以执行的命令和可以访问的键来限制某些连接。
在Redis5版本之前,Redis安全规则只有密码控制还有通过rename来调整高危命令比如 flushdb、KEYS *、shutdown等。Redis6则提供ACL的功能对用户进行更细粒度的权限控制。
命令
acl list 展示用户权限列表
acl cat 查看权限命令类别
acl whoami 查看当前用户
acl setuser <用户名>
举例:创建用户、启用、有密码、可操作cached:开头的key、A可执行get命令·
acl setuser yang on >password ~cached:* +get
Redis6 终于支撑多线程了,告别单线程了吗?
IO 多线程其实指客户端交互部分的网络 IO 交互处理模块 多线程,而非执行命令多线程。Redis6 执行命令依然是单线程。
原理架构
Redis 6 加入多线程,但跟 Memcached 这种从 IO 处理到数据访问多线程的实现模式有些差异。Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程。之所以这么设计是不想因为多线程而变得复杂,需要去控制 key、lua、事务,LPUSH/LPOP 等等的并发问题。整体的设计大体如下:
另外,多线程 IO 默认也是不开启的,需要再配置文件中配置:
io-threads-do-reads yes
io-threads 4
之前老版 Redis,想要搭集群需要单独安装 ruby 环境,Redis5 将 redis-trib.rb 的功能集成到 redis-ci。另外官方 redis-benchmark 工具开始支持 cluster 模式了,通过多线程的方式对多个分片进行压测。
如何保证缓存和数据库一致性?很多人对这个问题依然有很多疑惑:
引入缓存提高性能
我们从最简单的场景开始说起。
如果项目业务处于起步阶段,流量非常少,读写请求直接操作数据库即可,此时你的架构模型是这样
但是随着业务量的增长,你的项目请求量越来越大,如果每次都从DB中读取,那必然会产生性能问题。通常这个阶段会引入【缓存】来提高读写性能,架构模型就会发生转变:
目前主流的缓存中间件,当属Redis,不仅性能高,还支持多种数据类型,能更好的满足我们的业务需求。但是加入缓存之后,就会面临这样的一个问题:之前数据只存放在数据库中,从数据库读取,现在要放到缓存中读取,具体要存储什么呢?
最简单直接的方案就是【全量数据刷到缓存中】
这个方案的有点不必多说,所有请求都全部【命中】缓存,不需要经过数据库,性能非常高。但是缺点也很明显,主要体现以下两点。
所以,这个方案适用于【体量小】的业务,对数据一致性要求不高的业务场景。那么,针对体量很大的业务场景,怎么解决这两个问题呢?
缓存利用率和一致性问题
先来看第一个问题,如何提高缓存利用率的问题。说到这,想要缓存利用率【最大化】,我们很容易能想到的方案是,缓存中只保留最近访问或经常访问的【热点数据】。我们可以这样优化:
这样一来,缓存中不经常访问的key,随着时间的推移,都会时间【过期】淘汰掉,最终缓存中保留的,都是热点数据,从而缓存的利用率得以最大化。
再看数据一致性的问题。
想要保证缓存和数据库【实时】一致,那就不能再使用定时任务刷新缓存的方案。所以,当数据发生更新时,我们不仅要操作更新数据库,还要一并操作缓存。具体的操作就是修改一条数据时,不仅要更新数据库,连带着缓存一起更新,或者删除相应的缓存再次访问时是会查询数据库后进行重建缓存。
但数据库和缓存都更新,有存在先后的问题,那对应的方案就有2个:
哪一个方案更好呢?
这里先不考虑并发的问题,正常情况下,无论谁先谁后,都可以让两者保持一致,但现在我们需要重点考虑【异常】情况。因为操作是分两步,那么就有可能在【第一个成功,第二个失败】的情况发生。
1、先更新缓存,后更新数据库
如果缓存更新成功了,但数据库更新失败,那么此时缓存中是最新值,但是数据库中是【旧值】,虽然此时请求可以命中缓存,拿到正确的值,但是一旦缓失,就会从数据库中读取到【旧值】,重建缓存也是这个旧值。这时用户就会发现自己之前的修改【又变回去了】,对业务造成影响。
2、先更新数据库,后更新缓存
如果更新数据库成功了,但是缓存更新失败了,那么此时数据库中是最新的值,缓存中是【旧值】。之后的读请求都读到的是旧数据,只有当缓存【失效】后,才能从数据库中得到正确的值。这时用户就会发现,自己刚刚修改了数据,但发现不生效,过一段时间后,数据才变更过来,对业务也会有影响。
可见,无论是谁先谁后,但凡后者发生异常,就会对业务造成影响。那么怎样解决这个问题呢?我们继续分析,除了操作失败问题,还有什么场景会影响数据一致性?
这里我们还要重点关注:并发问题
并发引起的一致性问题
假如我们采用【先更新数据库,在更新缓存】的方案,并且两步都可以成功执行的前提下,如果存在并发,会是什么情况呢?
有线程A 和线程 B 两个线程,需要更新【同一条数据】,会发生这样的场景:
最终 X 的值在缓存中是1 ,数据库中是2,发生不一致。也就是说,A虽然先于B发生,但B操作数据库和缓存的时间,却要比B的时间更短,执行时序发生【错乱】,最终导致这条数据结果不符合预期的。
同样的,采用【新更新缓存,在更新数据库】的方案,也会有类似的问题。
除此之外,我们从【缓存利用率】的角度来评估这个方案,也是不太难推敲的。这是因为每次数据发生变更,都更新缓存,但是缓存中的数据不一定会被马上读取,这就会导致缓存中存放了很多不常访问的数据,浪费缓存资源。
而且很多情况下,写到缓存中的值,并不是与数据库中的值一一对应的,很可能先查数据库,经过一系列计算得出的值,才把这个值写到缓存中。由此可见,这种【更新数据库+更新缓存】的方案,不仅缓存利用率不到,还会造成服务器资源和性能的浪费。
所以此时我们需要考虑另外一种方案:删除缓存
删除缓存可以保证一致性吗
删除缓存对应的方案也有 2 种:
经过前面的分析我们可以知道,但凡【第二步】操作失败,都会导致数据不一致
1、先删除缓存,后更新数据库
如果有 2 个线程要并发【读写】数据,可能会发生以下场景:
最终 X 的值在缓存中是1(旧值),在数据库中是2(新值),发生不一致。可见,先删除缓存,后更新数据库,当发生【读+写】并发时,还是存在数据不一致的情况。
2、先更新数据库,后删除缓存
依旧是两个线程【并发读写】数据 :
最终X的值在缓存中是1(旧值),在数据库中是2(新值),也发生不一致
这种情况理论来说是可能发生的,但实际中真有可能发生吗?其实概率很低,这是因为它必须满足 3 个条件:
仔细想一下,条件3发生的概率是非常低的。因为写数据库一般会先【加锁】,所以写数据库,通常是要比读数据库的时间更长的。这么看来,【先更新数据库 + 再删除缓存】的方案,是可以保证数据一致性的。
所以,我们应该采用这种方案(【先更新数据库 + 再删除缓存】)来操作数据库和缓存。嗯,解决了并发问题,我们继续来看前面遗留的,第二步执行失败,导致数据不一致的问题
如何保证两步都执行
通过前面的分析,无论是更新缓存还是删除缓存,只要第二步出现失败,就会导致数据库和缓存的结果不一致。
保证第二步成功执行,就是解决问题的关键,程序在执行过程中发生异常,最简单的解决办法是:重试
但这并不意味着,只要执行失败(出现异常),我们重试就可以了。实际情况往往没那么简单,失败后立即重试的问题在于:
由此可见了,虽然我们想通过重试的方式解决问题,但是这种【同步重试】的方案依旧不严谨。那么另一种更好的方案是:异步重试
异步重试,其实就是把重试请求放到【消息队列】中,然后由专门的消费者来进行重试,直到成功。或者更直接的做法,为了避免第二次执行失败,我们可以把操作缓存这一步,直接放到消息队列中,由消费者来操作缓存。这里你可能会疑惑,写队列也有可能会失败,而且引入消息队列,这又会增加了更多的维护成本,增加项目复杂度,这样做是否值得?
这是个好问题,抛开项目复杂度,我们思考这样一个问题:如果在执行失败的线程中一直重试,还没等执行成功,此时如果项目重启了,那么重试的请求就丢失了,这一条数据就不一致了。所以,我们必须将重试或者第二步骤放到另一个服务中,这个服务用【消息队列】最为合适,因为消息队列的特性,可以满足我们的需求:
至于写队列失败和消息队列成本维护问题:
所以,引入消息队列来解决第二个步骤失败重试的问题,是比较合适的,这时候的架构就变成了这样:
如果不想在应用中去写消息队列,是否有更简单的方案,同时又可以保证一致性呢?方案还是有的,这就是近几年比较流行的解决方案:订阅数据库变更日志,再操作缓存
具体来说就是,业务在修改数据时,只需修改数据库,无需操作缓存。那什么时候操作缓存呢,这个就与数据库的【变更日志】有关当一条数据发生改变时,MySQL就会产生一条binlog(变更日志)我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。
订阅变更日志,目前也有比较成熟的开源中间件,例如阿里的canal,使用这种方案的有点在于:
当然,于此同时,我们需要投入经历去维护canal的高可用和稳定性。
到这里,可以得出的结论,想要保证数据和缓存一致性,推荐采用【先更新数据库,再删除缓存】的方案,并且配合【消息队列】或【订阅变更日志】
的方式来做
主从延迟和延迟双删问题
「读写分离 + 主从复制延迟」情况下,缓存和数据库一致性的问题。
在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」其实也会导致不一致:
线程 A 更新主库 X = 2(原值 X = 1)
线程 A 删除缓存
线程 B 查询缓存,没有命中,查询「从库」得到旧值(从库 X = 1)
从库「同步」完成(主从库 X = 2)
线程 B 将「旧值」写入缓存(X = 1)
最终 X 的值在缓存中是 1(旧值),在主从库中是 2(新值),也发生不一致。
看到了么?这 2 个问题的核心在于:缓存都被回种了「旧值」。
那怎么解决这类问题呢?
最有效的办法就是,把缓存删掉。
但是,不能立即删,而是需要「延迟删」,这就是业界给出的方案:缓存延迟双删策略。
线程 A 可以生成一条「延时消息」,写到消息队列中,消费者延时「删除」缓存。
这两个方案的目的,都是为了把缓存清掉,这样一来,下次就可以从数据库读取到最新值,写入缓存。
但问题来了,这个「延迟删除」缓存,延迟时间到底设置要多久呢?
但是,这个时间在分布式和高并发场景下,其实是很难评估的。很多时候,我们都是凭借经验大致估算这个延迟时间,例如延迟 1-5s,只能尽可能地降低不一致的概率。所以你看,采用这种方案,也只是尽可能保证一致性而已,极端情况下,还是有可能发生不一致。
所以实际使用中,我还是建议你采用「先更新数据库,再删除缓存」的方案,同时,要尽可能地保证「主从复制」不要有太大延迟,降低出问题的概率。
可以做到强一致性吗
如果想让缓存和数据到达到【强一致性】,其实很难做到的。这往往要 牺牲性能
一旦我们使用缓存,就必然会出现一致性的问题,性能与一致性,无法做到保持平衡。势必会向一方倾斜。如果非要达到强一致性,那就必须在完成更新操作之前,不能有任何请求处理,这在实际高并发的场景中是不可取的。
虽然也可以使用【分布式锁】来实现,但是加锁与释放的过程,也会降低其性能,有时候甚至会超过引入缓存带来的性能提升。
所以,我们既然决定使用缓存,就必须容忍【一致性】问题,我们只能尽可能地降低出现问题的概率
总结
① 在 pom.xml 文件中引入 Redis 依赖,如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
② 修改项目启动类
增加注解@EnableCaching,开启缓存功能,如下:
package com.example.canal;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
@MapperScan(basePackages = "com.example.canal.mybatis.mapper")
public class CanalApplication {
public static void main(String[] args) {
SpringApplication.run(CanalApplication.class,args);
}
}
③ 配置redis数据库
在application.properties中配置Redis连接信息,如下:
# Redis数据库索引(默认为0)
spring.redis.database=0
# Redis服务器地址
spring.redis.host=127.0.0.1
# Redis服务器连接端口
spring.redis.port=6379
# Redis服务器连接密码(默认为空)
spring.redis.password=139926
# 连接超时时间(毫秒)
spring.redis.timeout=1000
# 连接池最大连接数(使用负值表示没有限制)
lettuce.pool.max-active=8
# 连接池最大阻塞等待时间(使用负值表示没有限制)
lettuce.pool.max-wait=-1
# 连接池中的最大空闲连接
lettuce.pool.max-idle=8
# 连接池中的最小空闲连接
lettuce.pool.min-idle=0
④ 创建 Redis 配置类
我们除了在application.yaml中加入redis的基本配置外,一般还需要配置redis key和value的序列化方式,如下:
package com.example.canal.redis;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;
import javax.crypto.KeyGenerator;
import java.lang.reflect.Method;
import java.time.Duration;
/**
* Redis 配置类
*/
@Configuration
@EnableCaching // 开启缓存配置
public class RedisConfig {
/**
* 设置RedisTemplate规则
*
* @param redisConnectionFactory
* @return
*/
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
// 解决查询缓存转换异常的问题
ObjectMapper om = new ObjectMapper();
// 指定要序列化的域,field,get和set,以及修饰符范围,ANY是都有包括private和public
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 指定序列化输入的类型,类必须是非final修饰的,final修饰的类,比如String,Integer等会跑出异常
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
// key采用String的序列化方式
redisTemplate.setKeySerializer(new StringRedisSerializer());
// value序列化方式采用jackson
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
// hash的key也采用String的序列化方式
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
// hash的value序列化方式采用jackson
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
// 支持事物
// template.setEnableTransactionSupport(true);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
/**
* 设置CacheManager缓存规则
*
* @param factory
* @return
*/
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisSerializer<String> redisSerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
// 解决查询缓存转换异常的问题
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
// 生成两套默认配置,通过 Config 对象即可对缓存进行自定义配置
// 配置序列化(解决乱码的问题),过期时间10分钟
RedisCacheConfiguration cacheConfig1 = RedisCacheConfiguration.defaultCacheConfig()
// 设置过期时间 10 分钟
.entryTtl(Duration.ofMinutes(10))
// 禁止缓存 null 值
.disableCachingNullValues()
// 设置 key 序列化
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
// 设置 value 序列化
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer));
// 配置序列化(解决乱码的问题),过期时间30秒
RedisCacheConfiguration cacheConfig2 = RedisCacheConfiguration.defaultCacheConfig()
// 设置过期时间 30 秒
.entryTtl(Duration.ofSeconds(30))
// 禁止缓存 null 值
.disableCachingNullValues()
// 设置 key 序列化
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
// 设置 value 序列化
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer));
// 返回 Redis 缓存管理器
return RedisCacheManager.builder(factory).withCacheConfiguration("user", cacheConfig1)
.withCacheConfiguration("admin", cacheConfig2).build();
}
}
⑤ 操作 Redis
SpringBoot提供了两个bean来操作redis,分别是 RedisTemplate
和 StringRedisTemplate
,这两者的主要区别如下:
示例如下:
@RestController
public class UserController {
@Autowired
private UserService userServer;
@Autowired
StringRedisTemplate stringRedisTemplate;
/**
* 查询所有课程
*/
@RequestMapping("/allCourses")
public String findAll() {
List<Courses> courses = userServer.findAll();
// 将查询结果写入redis缓存
stringRedisTemplate.opsForValue().set("courses", String.valueOf(courses));
// 读取redis缓存
System.out.println(stringRedisTemplate.opsForValue().get("courses"));
return "ok";
}
}
- @CacheConfig(cacheNames = “user”)
一般配置在类上,指定缓存名称,这个名称是和上面“置缓存管理器”中缓存名称的一致。
- @Cacheable
该注解标注的方法每次被调用前都会触发缓存校验,校验指定参数的缓存是否已存在,若存在,直接返回缓存结果,否则执行方法内容,最后将方法执行结果保存到缓存中。
该注解常用参数如下:
cacheNames/value :存储方法调用结果的缓存的名称
key :缓存数据使用的key,可以用它来指定,key="#param"可以指定参数值
,也可以是其他属性
keyGenerator :key的生成器,用来自定义key的生成,与key为二选一
,不能兼存
condition:用于使方法缓存有条件,默认为"" ,表示方法结果始终被缓存。conditon="#id>1000"表示id>1000的数据才进行缓存
unless:用于否决方法缓存,此表达式在方法被调用后计算,因此可以引用方法返回值(result),默认为"" ,这意味着缓存永远不会被否决。unless = "#result==null"表示除非该方法返回值为null,否则将方法返回值进行缓存
sync :是否使用异步模式,默认为false不使用异步
- @CachePut
如果缓存中先前存在目标值,则更新缓存中的值为该方法的返回值;如果不存在,则将方法的返回值存入缓存。
该注解常用参数同@Cacheable,不过@CachePut没有sync 这个参数。
- @CacheEvict
如果缓存中存在存在目标值,则将其从缓存中删除。
该注解常用参数如下:
- @Caching
用于一次性设置多个缓存。
package com.example.canal.mybatis.service;
import com.example.canal.mybatis.entity.User;
import com.example.canal.mybatis.mapper.UserMapper;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
@Service
public class UserServiceImpl implements IUserService {
@Resource
private UserMapper userMapper;
@Override
@Cacheable(cacheNames = "user", key = "'yang'")
public List<User> allUsers() {
return userMapper.allUsers();
}
@Override
// 如果缓存中先前存在,则更新缓存;如果不存在,则将方法的返回值存入缓存
@CachePut(cacheNames = "user", key = "#user.userId")
public User updateUser(User user) {
userMapper.updateUser(user);
return user;
}
@Override
// 先执行方法体中的代码,成功执行之后删除缓存
@CacheEvict(cacheNames = "user", key = "#userId")
public int delUser(int userId) {
return userMapper.delUser(userId);
}
@Cacheable(cacheNames = "user", key = "#userId", unless = "#result == null")
public User queryUser(int userId) {
return userMapper.queryUser(userId);
}
}