文章目录
- 前言
- 1、Redis 概述
- 1.1、Redis 介绍
- 1.2、应用场景
- 1.3、相关技术
- 2、数据类型
- 2.1、Redis 字符串(String)
- 2.2、Redis 列表(List)
- 2.3、Redis 集合(Set)
- 2.4、Redis 哈希(Hash)
- 2.5、Redis 有序集合 Zset(Sorted set)
- 2.6、Redis Bitmaps
- 2.7、HyperLogLog
- 2.8、Geospatial
- 3、Redis 的发布和订阅
- 4、Redis 事务
- 5.8、Redis 事务秒杀案例
- 5.8.1、超卖问题
- 5.8.2、连接超时,通过连接池解决
- 5.8.3、解决库存遗留问题
- 5、Redis 的持久化机制
- 5.1、Redis 持久化之 RDB
- 5.2、Redis 持久化之 AOF
- 6、Redis 常见部署方案
- 6.1、Redis 主从复制
- 6.2、哨兵模式(sentinel)
- 7、Redis 集群(cluster 模式)
- 8、Redis 应用问题解决
- 8.1、缓存穿透
- 8.2、缓存击穿
- 8.3、缓存雪崩
- 9、分布式锁
- 11、Redis 6.0 新功能
- 11.1、ACL
- 11.2、IO 多线程
- 11.3、工具支持 Cluster
- 12、如何保证缓存与数据库的数据一致性
- 4、SpringBoot 整合 Redis 缓存
- 4.1、使用 Redis 缓存
- 4.2、使用 SpringCache 的注解
- 4.2.1、注解说明
- 4.2.2、常用注解配置参数
- 4.2.3、自动缓存案例
前言
下载 Redis
我们下载zip格式,解压之后,就是这样子
启动服务 CMD 执行 redis-server redis.windows.conf
下载 Redis可视化工具 Another Redis DeskTop Manager
1、Redis 概述
1.1、Redis 介绍
- Redis 是一个
开源
的key-value
存储系统。 - 和 Memcached 类似,它支持存储的 value 类型相对更多,包括
string
(字符串)、list
(链表)、set
(集合)、zset
(sorted set 有序集合) 和hash
(哈希类型)。 - 这些数据类型都支持 push/pop、add/remove 及取交集并集和差集及更丰富的操作,而且这些操作都是
原子性
的。 - 在此基础上,Redis 支持各种不同方式的
排序
。 - 与 memcached 一样,为了保证效率,数据都是缓存在内存中。
- 区别的是 Redis 会
周期性
的把更新的数据写入磁盘
或者把修改操作写入追加的记录文件。 - 并且在此基础上实现了
master-slave (主从)
同步。
1.2、应用场景
-
配合关系型数据库做高速缓存
- 高频次,热门访问的数据,降低数据库 IO。
- 分布式架构,做 session 共享。分布式锁。
-
多样的数据结构存储持久化数据
- 最新N个数据,通过List实现按自然时间排序的数据。
- 排行榜,Top N,利用zset有序集合可以方便的实现排序功能。
- 时效性数据,比如手机验证码,利用 Expire 过期。
- 计数器,秒杀,利用redis中原子性的自增操作 INCR 和 DECR,可以统计到阅读量,点赞量等功能。
- 大量数据中的重复数据,可以利用Set集合。
- 发布订阅消息系统,简单消息队列,list存储结构,满足先进先出的原则,可以使用lpush/rpop或rpush/lpop实现简单消息队列。
- 好友关系,利用集合的一些命令,比如交集、并集、差集等,实现共同好友、共同爱好之类的功能。
1.3、相关技术
Redis 使用的是单线程 + 多路 IO 复用技术:
首先并不是高性能服务器都是多线程来实现的,因为reids的核心就是数据在内存中,他单线程的去操作就是效率最高的。单线程可以避免上下文的切换和锁的竞争。
一次CPU上下文切换大概在1500ns左右。
从内存中读取1MB的连续数据,耗时大约250us,假设1MB的数据由多个线程读取了1000次,那么就有1000次时间上下文切换。那么就有1500ns * 1000 = 1500u,在对比单线程下的250us,结果不言而喻。
但是在redis6.0以后,采用了多路IO复用来提高性能,多路IO复用只用来处理网络数据的读写和协议解析,命令的执行仍旧是单线程。
2、数据类型
再介绍五大数据类型之前,先介绍下 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 清空所有库
2.1、Redis 字符串(String)
- String 是 Redis 最基本的类型,你可以理解成与 Memcached 一模一样的类型,一个 key 对应一个 value。
- String 类型是
二进制安全的
。意味着 Redis 的 string 可以包含任何数据。比如 jpg 图片或者序列化的对象。 - String 类型是 Redis 最基本的数据类型,一个 Redis 中字符串 value 最多可以是
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属性中。具体如下:
- 如果修改后len长度将小于1M,这时分配给free的大小和len一样,例如修改过后为10字节,那么给free也是10字节,buf实际长度变成了10+10+1 = 21byte(别忘记了\0的存在)。
- 如果修改后len的长度大于等于1M,这时分配给free的长度为1M,例如修改过后为30M,那么给free是1M,buf实际长度变成了30M+1M+1byte。
2、惰性空间释放
对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 free 属性将这些字节的数量记录下来,等待后续使用。
为什么SDS的最大长度是512M?
Redis字符串使用int类型表示长度,一共有32个比特位。2^32字节=512M
SDS是如何扩容的?
空间预分配。先判断扩容长度与free的大小关系,如果够就直接拼接字符串,如果不够使用空间预分配的方式扩容
2.2、Redis 列表(List)
- 单键多值。
- Redis 列表是字符串列表,按照插入顺序排序。可以添加一个元素到列表的头部(左边)或者尾部(右边)。
- 它的底层实际是个
双向链表
,对两端的操作性能很高,通过索引下标的操作中间的节点性能会较差。
常用命令
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
数据结构
- List 的数据结构为快速链表 quickList。
- 首先在列表元素较少的情况下会使用一块连续的内存存储,这个结构是 ziplist,也即是压缩列表。它将所有的元素紧挨着一起存储,分配的是一块连续的内存。
- 当数据量比较多的时候才会改成 quicklist。因为普通的链表需要的附加指针空间太大,会比较浪费空间。比如这个列表里存的只是 int 类型的数据,结构上还需要两个额外的指针 prev 和 next。
- Redis 将链表和 ziplist 结合起来组成了 quicklist。也就是将多个 ziplist 使用双向指针串起来使用。这样既满足了快速的插入删除性能,又不会出现太大的空间冗余。
2.3、Redis 集合(Set)
- Redis set 对外提供的功能与 list 类似,是一个列表的功能,特殊之处在于 set 是可以自动排重的,当你需要存储一个列表数据,又不希望出现重复数据时,set 是一个很好的选择,并且 set 提供了判断某个成员是否在一个 set 集合内的重要接口,这个也是 list 所不能提供的。
- Redis 的 Set 是 string 类型的
无序集合。它底层其实是一个 value 为 null 的 hash 表
,所以添加,删除,查找的复杂度都是 O (1)
。 - 一个算法,随着数据的增加,执行时间的长短,如果是 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中的)
数据结构
- Set 数据结构是 dict 字典,字典是用哈希表实现的。
- Java 中 HashSet 的内部实现使用的是 HashMap,只不过所有的 value 都指向同一个对象。Redis 的 set 结构也是一样,它的内部也使用 hash 结构,所有的 value 都指向同一个内部值。
2.4、Redis 哈希(Hash)
- Redis hash 是一个键值对集合。
- Redis hash 是一个 string 类型的 field 和 value 的映射表,hash 特别适合用于存储对象。
- 类似 Java 里面的 Map<String,Object>。
- 通过 key (用户 ID) + field (属性标签) 就可以操作对应属性数据了,既不需要重复存储数据,也不会带来序列化和并发修改控制的问题。
常用命令
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。
2.5、Redis 有序集合 Zset(Sorted set)
- Redis 有序集合 zset 与普通集合 set 非常相似,是一个
没有重复元素
的字符串集合。 - 不同之处是有序集合的每个成员都关联了一个
评分(score)
,这个评分(score)被用来按照从最低分到最高分的方式排序集合中的成员。集合的成员是唯一的,但是评分可以是重复了
。 - 因为元素是有序的,所以你也可以很快的根据评分(score)或者次序(position)来获取一个范围的元素。
- 访问有序集合的中间元素也是非常快的,因此你能够使用有序集合作为一个没有重复成员的智能列表。
常用命令
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)。像上面这种带多级索引的链表,就是跳表。
2.6、Redis Bitmaps
- Redis 提供了 Bitmaps 这个 “数据类型” 可以实现对位的操作。
- Bitmaps 本身不是一种数据类型, 实际上它就是字符串(key-value) , 但是它可以对字符串的位进行操作。
- Bitmaps 单独提供了一套命令, 所以在 Redis 中使用 Bitmaps 和使用字符串的方法不太相同。 可以把 Bitmaps 想象成一个以位为单位的数组, 数组的每个单元只能存储 0 和 1, 数组的下标在 Bitmaps 中叫做偏移量。
常用命令
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 |
2.7、HyperLogLog
在工作当中,我们经常会遇到与统计相关的功能需求,比如统计网站 PV(PageView 页面访问量),可以使用 Redis 的 incr、incrby 轻松实现。但像 UV(UniqueVisitor 独立访客)、独立 IP 数、搜索记录数等需要去重和计数的问题如何解决?这种求集合中不重复元素个数的问题称为基数问题。
解决基数问题有很多种方案:
① 数据存储在 MySQL 表中,使用 distinct count 计算不重复个数。
② 使用 Redis 提供的 hash、set、bitmaps 等数据结构来处理。
以上的方案结果精确,但随着数据不断增加,导致占用空间越来越大,对于非常大的数据集是不切实际的。能否能够降低一定的精度来平衡存储空间?Redis 推出了 HyperLogLog。
- Redis HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是:在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。
- 在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。
- 但是,因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以 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中,比如每月活跃用户可以使用每天的活跃用户来合并计算可得
2.8、Geospatial
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 度。当坐标位置超出指定范围时,该命令将会返回一个错误。已经添加的数据,是无法再次往里面添加的。·
3、Redis 的发布和订阅
- Redis 发布订阅 (pub/sub) 是一种消息通信模式:发送者 (pub) 发送消息,订阅者 (sub) 接收消息。
- Redis 客户端可以订阅任意数量的频道。
Redis 的发布和订阅
客户端可以订阅频道,当给这个频道发布消息后,消息就会发送给订阅的客户端:
发布订阅命令行实现
① 打开一个客户端订阅 SUBSCRIB channel
② 打开另一个客户端,给 channel 发布消息 PUBLISH hello
③ 打开第一个客户端可以看到发送的消息
注:发布的消息没有持久化,订阅的客户端只能收到订阅后发布的消息
4、Redis 事务
Redis 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。Redis 事务的主要作用就是串联多个命令防止别的命令插队。
- MULTI:标识一个事务的开启,即开启事务。
- EXEC:执行事务中的所有命令,即提交。
- DISCARD:放弃事务;和回滚不一样,Redis事务不支持回滚。
- WATCH:监视Key改变,用于实现乐观锁。如果监视的Key的值改变,事务最终会执行失败。
- UNWATCH:放弃监视。
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 事务三特性
- 单独的隔离操作 :事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
- 没有隔离级别的概念 :队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行。
- 不保证原子性 :事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚 。
5.8、Redis 事务秒杀案例
5.8.1、超卖问题
利用乐观锁淘汰用户,解决超卖问题。
5.8.2、连接超时,通过连接池解决
节省每次连接 redis 服务带来的消耗,把连接好的实例反复利用。通过参数管理连接的行为,代码见项目中:
- MaxTotal:控制一个 pool 可分配多少个 jedis 实例,通过 pool.getResource () 来获取;如果赋值为 -1,则表示不限制;如果 pool 已经分配了 MaxTotal 个 jedis 实例,则此时 pool 的状态为 exhausted。
- maxIdle:控制一个 pool 最多有多少个状态为 idle (空闲) 的 jedis 实例Z。
- MaxWaitMillis:表示当 borrow 一个 jedis 实例时,最大的等待毫秒数,如果超过等待时间,则直接抛 JedisConnectionException。
- testOnBorrow:获得一个 jedis 实例的时候是否检查连接可用性(ping ());如果为 true,则得到的 jedis 实例均是可用的。
5.8.3、解决库存遗留问题
LUA 脚本在 Redis 中的优势
- 将复杂的或者多步的 redis 操作,写为一个脚本,一次提交给 redis 执行,减少反复连接 redis 的次数,提升性能。
- LUA 脚本是类似 redis 事务,有一定的原子性,不会被其他命令插队,可以完成一些 redis 事务性的操作。
- 但是注意 redis 的 lua 脚本功能,只有在 Redis 2.6 以上的版本才可以使用。
- 利用 lua 脚本淘汰用户,解决超卖问题,redis 2.6 版本以后,通过 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 库存keyString kcKey = "sk:"+prodid+":qt";// 3.2 秒杀成功用户keyString 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;}
}
5、Redis 的持久化机制
持久化就是把内存的数据写到磁盘中,防止服务宕机导致内存数据丢失。
Redis支持两种方式的持久化,一种是RDB的方式,一种是AOF的方式。前者会根据指定的规则定时将内存中的数据存储在硬盘上,而后者在每次执行完命令后将命令记录下来。一般将两者结合使用。
5.1、Redis 持久化之 RDB
什么是 RDB
在指定的时间间隔
内将内存中的数据集快照
写入磁盘, 也就是行话讲的 Snapshot 快照,它恢复时是将快照文件直接读到内存里。
备份是如何执行的
Redis 会单独创建(fork)一个子进程来进行持久化,首先
会将数据写
入到一个临时文件
中,待写入过程结束了再用这个临时文件替换上次持久化
好的文件。整个过程中,主进程是不进行任何 IO 操作的,这就确保了极高的性能。如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,那 RDB 方式要比 AOF 方式更加的高效。RDB 的缺点是最后一次持久化后的数据可能丢失
。
为什么要写入临时文件
在替换持久化文件之前要写入临时文件,比如同步10个数据在第8个时候中断了,如果直接同步到持久化dump文件这样是不行的,应该先同步到临时文件,这样主要为了保证 dump 文件数据的一致性完整性,也是处于数据的完全考虑,这个过程用到的就是写时复制技术。
Fork
- Fork 的作用是复制一个与当前进程
一样的进程
。新进程的所有数据(变量、环境变量、程序计数器等)
数值都和原进程一致,但是是一个全新的进程,并作为原进程的子进程
。 - 在 Linux 程序中,fork () 会产生一个和父进程完全相同的子进程,但子进程在此后多会 exec 系统调用,出于效率考虑,Linux 中引入了 “
写时复制技术
”。 - 一般情况父进程和子进程会共用同一段物理内存,只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。
RDB 持久化流程
dump.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 直接调用 rdbSave ,阻塞 Redis 主进程,直到保存完成为止。在主进程阻塞期间,服务器不能处理客户端的任何请求。
- BGSAVE 则 fork 出一个子进程,子进程负责调用 rdbSave ,并在保存完成之后向主进程发送信号,通知保存已完成。 Redis 服务器在BGSAVE 执行期间仍然可以继续处理客户端的请求。
Save是阻塞方式的;bgsave是非阻塞方式的。
flushall 命令
执行 flushall 命令,也会产生 dump.rdb 文件,但里面是空的,无意义。
如何停止
动态停止 RDB:redis-cli config set save “”#save 后给空值,表示禁用保存策略。
优势和劣势
优势
- 适合大规模的数据恢复
- 对数据完整性和一致性要求不高更适合使用
- 节省磁盘空间
- 恢复速度快
劣势
- Fork 的时候,内存中的数据被克隆了一份,大致 2 倍的膨胀性需要考虑。
- 虽然 Redis 在 fork 时使用了写时拷贝技术,但是如果数据庞大时还是比较消耗性能。
- 在备份周期在一定间隔时间做一次备份,所以如果 Redis 意外 down 掉的话,就会丢失最后一次快照后的所有修改。
5.2、Redis 持久化之 AOF
以日志的形式来记录每个写操作(增量保存)
,将 Redis 执行过的所有写指令记录下来 (读操作不记录
), 只许追加文件但不可以改写文件
,redis 启动之初会读取该文件重新构建数据,换言之,redis 重启的话就根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。
AOF 持久化流程
- 客户端的请求写命令会被 append 追加到 AOF 缓冲区内
- AOF 缓冲区根据 AOF 持久化策略 [always,everysec,no] 将操作 sync 同步到磁盘的 AOF 文件中
- AOF 文件大小超过重写策略或手动重写时,会对 AOF 文件 rewrite 重写,压缩 AOF 文件容量
- Redis 服务重启时,会重新 load 加载 AOF 文件中的写操作达到数据恢复的目的
AOF 默认不开启
可以在 redis.conf 中配置文件名称默认为 appendonly.aof 文件中开启,AOF 文件的保存路径,同 RDB 的路径一致。
AOF 和 RDB 同时开启,Redis 听谁的
AOF 和 RDB 同时开启,系统默认取 AOF 的数据(数据不会存在丢失)。
AOF 启动、修复、恢复
AOF 的备份机制和性能虽然和 RDB 不同,但是备份和恢复的操作同 RDB 一样,都是拷贝备份文件,需要恢复时再拷贝到 Redis 工作目录下,启动系统即加载。
正常恢复
- 修改默认的 appendonly no,改为 yes。
- 将有数据的 aof 文件复制一份保存到对应目录 (查看目录:config get dir)。
- 恢复:重启 redis 然后重新加载。
异常恢复
- 修改默认的 appendonly no,改为 yes。
- 如遇到 AOF 文件损坏,通过 /usr/local/bin/ redis-check-aof - - fix appendonly.aof 进行恢复。
- 备份被写坏的 AOF 文件。
- 恢复:重启 redis,然后重新加载。
AOF 同步频率设置
- appendfsync always:始终同步,每次 Redis 的写入都会立刻记入日志;性能较差但数据完整性比较好。
- appendfsync everysec:每秒同步,每秒记入日志一次,如果宕机,本秒的数据可能丢失。
- appendfsync no:redis 不主动进行同步,把同步时机交给操作系统。
Rewrite 压缩是什么
AOF 采用文件追加方式,文件会越来越大为避免出现此种情况,新增了重写机制,当 AOF 文件的大小超过所设定的阈值时,Redis 就会启动 AOF 文件的内容压缩,只保留可以恢复数据的最小指令集,可以使用命令 bgrewriteaof。
重写原理,如何实现重写
AOF 文件持续增长而过大时,会 fork 出一条新进程来将文件重写 (也是先写临时文件最后再 rename),redis4.0 版本后的重写,是指把 rdb 的快照,以二进制的形式附在新的 aof 头部,作为已有的历史数据,替换掉原来的流水账操作。
no-appendfsync-on-rewrite:
- 如果 no-appendfsync-on-rewrite=yes ,不写入 aof 文件只写入缓存,用户请求不会阻塞,但是在这段时间如果宕机会丢失这段时间的缓存数据。(降低数据安全性,提高性能)
- 如果 no-appendfsync-on-rewrite=no,还是会把数据往磁盘里刷,但是遇到重写操作,可能会发生阻塞。(数据安全,但是性能降低)
触发机制,何时重写
Redis 会记录上次重写时的 AOF 大小,默认配置是当 AOF 文件大小是上次 rewrite 后大小的一倍且文件大于 64M 时触发。
重写虽然可以节约大量磁盘空间,减少恢复时间。但是每次重写还是有一定的负担的,因此设定 Redis 要满足一定条件才会进行重写。
- auto-aof-rewrite-percentage:设置重写的基准值,文件达到 100% 时开始重写(文件是原来重写后文件的 2
倍时触发)。 - auto-aof-rewrite-min-size:设置重写的基准值,最小文件 64MB。达到这个值开始重写。
- 系统载入时或者上次重写完毕时,Redis 会记录此时 AOF 大小,设为 base_size
- 如果 Redis 的 AOF 当前大小 >= base_size +base_size*100% (默认) 且当前大小 >=64mb (默认) 的情况下,Redis 会对 AOF 进行重写。
- 例如:文件达到 70MB 开始重写,降到 50MB,下次什么时候开始重写?100MB
重写流程
- bgrewriteaof 触发重写,判断是否当前有 bgsave 或 bgrewriteaof 在运行,如果有,则等待该命令结束后再继续执行
- 主进程 fork 出子进程执行重写操作,保证主进程不会阻塞
- 子进程遍历 redis 内存中数据到临时文件,客户端的写请求同时写入 aof_buf 缓冲区和 aof_rewrite_buf 重写缓冲区,保证原 AOF 文件完整以及新 AOF 文件生成期间的新的数据修改动作不会丢失
- 子进程写完新的 AOF 文件后,向主进程发信号,父进程更新统计信息。主进程把 aof_rewrite_buf 中的数据写入到新的 AOF 文件
- 使用新的 AOF 文件覆盖旧的 AOF 文件,完成 AOF 重写。
优势和劣势
优势
- 备份机制更稳健,丢失数据概率更低。
- 可读的日志文本,通过操作 AOF 稳健,可以处理误操作。
劣势
- 比起 RDB 占用更多的磁盘空间。
- 恢复备份速度要慢。
- 每次读写都同步的话,有一定的性能压力。
- 存在个别 Bug,造成恢复不能。
RDB 和 AOF 用哪个好
官网建议
- RDB 持久化方式能够在指定的时间间隔能对你的数据进行快照存储。
- AOF 持久化方式记录每次对服务器写的操作,当服务器重启的时候会重新执行这些命令来恢复原始的数据,AOF 命令以 redis 协议追加保存每次写的操作到文件末尾。
- Redis 还能对 AOF 文件进行后台重写,使得 AOF 文件的体积不至于过大。
- 只做缓存:如果你只希望你的数据在服务器运行的时候存在,你也可以不使用任何持久化方式。
- 同时开启两种持久化方式:在这种情况下,当 redis 重启的时候会优先载入 AOF 文件来恢复原始的数据,因为在通常情况下 AOF 文件保存的数据集要比 RDB 文件保存的数据集要完整。
- RDB 的数据不实时,同时使用两者时服务器重启也只会找 AOF 文件。那要不要只使用 AOF 呢?
- 建议不要,因为 RDB 更适合用于备份数据库 (AOF 在不断变化不好备份),快速重启,而且不会有 AOF 可能潜在的 bug,留着作为一个万一的手段。
性能建议:
- 因为 RDB 文件只用作后备用途,建议只在 Slave 上持久化 RDB 文件,而且只要 15 分钟备份一次就够了,只保留 save 9001 这条规则。
- 如果使用 AOF,好处是在最恶劣情况下也只会丢失不超过两秒数据,启动脚本较简单,只 load 自己的 AOF 文件就可以了。
- aof 代价:一是带来了持续的 IO,二是 AOF rewrite 的最后,将 rewrite 过程中产生的新数据写到新文件造成的阻塞几乎是不可避免的。
- 只要硬盘许可,应该尽量减少 AOF rewrite 的频率,AOF 重写的基础大小默认值 64M 太小了,可以设到 5G 以上。默认超过原大小 100% 大小时重写可以改到适当的数值。
6、Redis 常见部署方案
6.1、Redis 主从复制
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 查看是否为主或者从
主从复制原理
- Slave 从服务器启动成功连接到 Master 后会发送一个 sync 命令,主动进行数据同步,此时称为
全量复制
,只要是重新连接 Master,全量复制将被自动执行。Master 主服务器接到命令后先进行数据的持久化,然后把持久化的文件发送给 Slave,拿到持久化文件后进行读取,完成数据同步。 - Master 继续将新的所有收集到的修改命令依次传给 Slave,完成同步,此时称为
增量复制
。
主从复制的一主二仆
- 当你的从服务器挂掉后,重启后会成为主服务器,跟之前的主服务器是独立的不能自动变成从服务器,还需要用slaveof命令加入到主服务器。
- 当你的主服务器挂掉后,从服务器不会上位成为主服务器,小弟还是认为这个大哥,当主服务器重启后还是这两个小弟的大哥。
主从复制的薪火相传
上一个 Slave 可以是下一个 Slave 的 Master,那么该 Slave 作为了链条中下一个的 Master,可以有效减轻 Master的写压力,去中心化降低风险。
中途变更转向:会清除之前的数据,重新建立拷贝最新的。风险是一旦某个slave宕机,后面的slave都没法备份。主机挂了,从机还是从机,无法写数据,如果想写数据需要进行下面的反客为主操作。
主从复制的反客为主
当一个 Master 宕机后,后面的slave可以立刻升为 Master,其后面的 Slave 不用做任何修改。用 slaveof no one 手动将从机变为主机。
6.2、哨兵模式(sentinel)
在 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.conf 中默认 slave-priority 100,值越小优先级越高)。
- 优先级相同时,选择偏移量最大的(偏移量:指从机获得主机数据最全的概率)。
- 偏移量也相同时候,选择runid最小的(runid:每个 redis 实例启动后都会随机生成一个 40 位的 runid)。
宕机的主机再启动是从机
多哨兵
在上图过程中,哨兵主要有两个重要作用:
- 第一:哨兵节点会以每秒一次的频率对每个 Redis 节点发送PING命令,并通过 Redis 节点的回复来判断其运行状态。
- 第二:当哨兵监测到主服务器发生故障时,会自动在从节点中选择一台将机器,并其提升为主服务器,然后使用 PubSub 发布订阅模式,通知其他的从节点,修改配置文件,跟随新的主服务器。
在实际生产情况中,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 会将最新的主节点地址告诉客户端。因此应用程序无需重启即可自动完成主从节点切换。
7、Redis 集群(cluster 模式)
什么是 Redis 集群
- Redis 集群实现了对 Redis 的水平扩容,即启动 N 个 redis 节点,将整个数据库分布存储在这 N 个节点中,每个节点存储总数据的 1/N,即一个小集群存储 1/N 的数据,每个小集群里面维护好自己的 1/N 的数据。
- Redis 集群通过分区(partition)来提供一定程度的可用性(availability):即使集群中有一部分节点失效或者无法进行通讯, 集群也可以继续处理命令请求。
用来解决什么问题
- 解决容量问题,一台服务器使用的容量是有限的,可以用集群进行扩容。
- 解决并发写问题,并发的请求由一台服务器压力很,可以用集群多台处理分担压力。
- 无中心化配置相对简单,从哪个节点都可以进入,节点不能处理会分配给其他节点进行处理。
搭建 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 如何分配这六个节点
- 一个集群至少要有三个主节点。
- 选项 --cluster-replicas 1 :表示我们希望为集群中的每个主节点创建一个从节点。
我们在搭建原则尽量保证每个主数据库运行在不同的 IP 地址,每个从库和主库不在一个 IP 地址上。
什么是 slots(插槽)
一个 Redis 集群包含 16384 个插槽(hash slot),数据库中的每个键都属于这 16384 个插槽的其中一个。集群使用公式 CRC16 (key) % 16384
来计算键 key 属于哪个槽, 其中 CRC16 (key) 语句用于计算键 key 的 CRC16 校验和 。
集群中的每个节点负责处理一部分插槽。 举个例子, 如果一个集群可以有主节点, 其中:
- 节点 A 负责处理 0 号至 5460 号插槽。
- 节点 B 负责处理 5461 号至 10922 号插槽。
- 节点 C 负责处理 10923 号至 16383 号插槽。
其实作用就是将我们 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 服务是否还能继续?
- 如果某一段插槽的主从都挂掉,而 cluster-require-full-coverage 为 yes ,那么整个集群都挂掉。
- 如果某一段插槽的主从都挂掉,而 cluster-require-full-coverage 为 no ,那么,该插槽数据全都不能使用,也无法存储。
集群的不足
- 多键操作是不被支持的。如果想操作要引入组太麻烦。
- 多键的 Redis 事务是不被支持的,lua 脚本不被支持。
- 由于集群方案出现较晚,很多公司已经采用了其他的集群方案,而代理或者客户端分片的方案想要迁移至 redis cluster,需要整体迁移而不是逐步过渡,复杂度较大。
8、Redis 应用问题解决
8.1、缓存穿透
缓存穿透是指当用户查询某个数据时,Redis 中不存在该数据,也就是缓存没有命中,此时查询请求就会转向持久层数据库 MySQL,结果发现 MySQL 中也不存在该数据,MySQL 只能返回一个空象,代表此次查询失败。如果这种类请求非常多,或者用户利用这种请求进行恶意攻击,就会给 MySQL 数据库造成很大压力,甚至于崩溃,这种现象就叫缓存穿透。
解决方案
1) 缓存空对象
当 MySQL 返回空对象时, Redis 将该对象缓存起来,同时为其设置一个过期时间。当用户再次发起相同请求时,就会从缓存中拿到一个空对象,用户的请求被阻断在了缓存层,从而保护了后端数库,最长不超过五分钟。
2)设置可访问的名单(白名单)
使用 bitmaps 类型定义一个可以访问的名单,名单 id 作为 bitmaps 的偏移量,每次访问和 bitmap 里面的 id 进行比较,如果访问 id 不在 bitmaps 里面,进行拦截,不允许访问。
3) 布隆过滤器
布隆过滤器判定不存在的数据,那么该数据一定不存在,利用它的这一特点可以防止缓存穿透。
首先将用户可能会访问的热点数据存储在布隆过滤器中(也称缓存预热),当有一个用户请求到来时会先经过布隆过滤器,如果请求的数据,布隆过滤器中不存在,那么该请求将直接被拒绝,否则将继续执行查询。相较于第一种方法,用布隆过滤器方法更为高效、实用。其流程示意图如下:
缓存预热:是指系统启动时,提前将相关的数据加载到 Redis 缓存系统中。这样避免了用户请求的时再去加载数据。
布隆过滤器(Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量 (位图) 和一系列随机映射函数(哈希函数)。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。
4) 进行实时监控
当发现 Redis 的命中率开始急速降低,需要排查访问对象和访问的数据,和运维人员配合,可以设置黑名单限制服务。
8.2、缓存击穿
- Reids 中并没有出现大量的 key 过期,Redis 在正常的运行。
- Redis 的某个 key 过期了,并且同一时间大量的请求访问这个 key,也就是说这个 key 是热点 key。
- 数据库的压力瞬时增加。
key 对应的数据存在,但在 redis 中过期,此时若有大量并发请求过来,这些请求发现缓存过期一般都会从后端数据库加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端数据库压垮。
解决方案
key 可能会在某些时间点被超高并发地访问,是一种非常 “热点” 的数据。
1)预先设置热门数据,改变过期时间
在 redis 高峰访问之前,把一些热门数据提前存入到 redis 里面,加大这些热门数据 key 的时长。现场监控哪些数据热门,实时调整 key 的过期时长。
2) 分布式锁
采用分布式锁的方法,重新设计缓存的使用方式,过程如下:
- 上锁:当我们通过 key去查询数据时,首先查询缓存,如果没有,就通过分布式锁进行加锁,第一个获取锁的进程进入后端数据库查询,并将查询结果缓到Redis中。
- 解锁:当其他进程发现锁被某个进程占用时,就进入等待状态,直至解锁后,其余进程再依次访问被缓存的 key。
8.3、缓存雪崩
缓存雪崩是指缓存中大批量的 key 同时过期,而此时数据访问量又非常大,从而导致后端数据库压力突然暴增,甚至会挂掉,这种现象被称为缓存雪崩。它和缓存击穿不同,缓存击穿是在并发量特别大时,某一个热点 key 突然过期,而缓存雪崩则是大量的 key 同时过期,因此它们根本不是一个量级。
解决方案
1)构建多级缓存架构
nginx 缓存 + redis 缓存 + 其他缓存(ehcache 等)。
2)使用锁或队列
用加锁或者队列的方式来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上,该方法不适用高并发情况。
3)设置过期标志更新缓存
记录缓存数据是否过期(设置提前量),如果过期会触发通知另外的线程在后台去更新实际 key 的缓存。
4)将缓存失效时间分散开
比如可以在原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
9、分布式锁
分布式锁解决什么问题
对于单机多线程来说,在 Java 中,我们通常使用 ReetrantLock 类、synchronized 关键字这类 JDK 自带的 本地锁 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。
从图中可以看出,这些线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到本地锁访问共享资源。
分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁就诞生了。
举个例子:系统的订单服务一共部署了 3 份,都对外提供服务。用户下订单之前需要检查库存,为了防止超卖,这里需要加锁以实现对检查库存操作的同步访问。由于订单服务位于不同的 JVM 进程中,本地锁在这种情况下就没办法正常工作了。我们需要用到分布式锁,这样的话,即使多个线程不在同一个 JVM 进程中也能获取到同一把锁,进而实现共享资源的互斥访问。
从图中可以看出,这些独立的进程中的线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到分布式锁访问共享资源。
一个最基本的分布式锁需要满足:
- 互斥 :任意一个时刻,锁只能被一个线程持有。
- 高可用 :锁服务是高可用的。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。
- 可重入:一个节点获取了锁之后,还可以再次获取锁。
通常情况下,我们一般会选择基于数据库实现分布式锁、基于 Redis 或者 ZooKeeper 实现分布式锁,Redis 用的要更多一点,我这里也以 Redis 为例介绍分布式锁的实现。
使用 Redis 实现分布式锁
- setnx:通过该命令尝试获得锁,没有获得锁的线程会不断等待尝试。
- set key ex 3000nx:设置过期时间,自动释放锁,解决当某一个业务异常而导致锁无法释放的问题。但是当业务运行超过过期时间时,开辟监控线程增加该业务的运行时间,直到运行结束,释放锁。
- uuid:设置 uuid,释放前获取这个值,判断是否自己的锁,防止误删锁,造成没锁的情况。
使用 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);
- 使用 LUA 脚本释放锁
// 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);
11、Redis 6.0 新功能
11.1、ACL
Redis ACL是Access Control List(访问控制列表)的缩写,该功能允许根据可以执行的命令和可以访问的键来限制某些连接。
在Redis5版本之前,Redis安全规则只有密码控制还有通过rename来调整高危命令比如 flushdb、KEYS *、shutdown等。Redis6则提供ACL的功能对用户进行更细粒度的权限控制。
- 接入权限:用户名和密码
- 可以执行的命令
- 可以操作的KEY
命令
acl list 展示用户权限列表
acl cat 查看权限命令类别
acl whoami 查看当前用户
acl setuser <用户名>举例:创建用户、启用、有密码、可操作cached:开头的key、A可执行get命令·
acl setuser yang on >password ~cached:* +get
11.2、IO 多线程
Redis6 终于支撑多线程了,告别单线程了吗?
IO 多线程其实指客户端交互部分的网络 IO 交互处理模块 多线程,而非执行命令多线程。Redis6 执行命令依然是单线程。
原理架构
Redis 6 加入多线程,但跟 Memcached 这种从 IO 处理到数据访问多线程的实现模式有些差异。Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程。之所以这么设计是不想因为多线程而变得复杂,需要去控制 key、lua、事务,LPUSH/LPOP 等等的并发问题。整体的设计大体如下:
另外,多线程 IO 默认也是不开启的,需要再配置文件中配置:
io-threads-do-reads yesio-threads 4
11.3、工具支持 Cluster
之前老版 Redis,想要搭集群需要单独安装 ruby 环境,Redis5 将 redis-trib.rb 的功能集成到 redis-ci。另外官方 redis-benchmark 工具开始支持 cluster 模式了,通过多线程的方式对多个分片进行压测。
12、如何保证缓存与数据库的数据一致性
如何保证缓存和数据库一致性?很多人对这个问题依然有很多疑惑:
- 到底是更新缓存还是删除缓存?
- 选择先更新数据库再删除缓存,还是先删除缓存再更新数据库?
- 为什么要引入消息队列保证一致性?
- 延迟双删会产生哪些问题?到底要不要用?如何用?
引入缓存提高性能
我们从最简单的场景开始说起。
如果项目业务处于起步阶段,流量非常少,读写请求直接操作数据库即可,此时你的架构模型是这样
但是随着业务量的增长,你的项目请求量越来越大,如果每次都从DB中读取,那必然会产生性能问题。通常这个阶段会引入【缓存】来提高读写性能,架构模型就会发生转变:
目前主流的缓存中间件,当属Redis,不仅性能高,还支持多种数据类型,能更好的满足我们的业务需求。但是加入缓存之后,就会面临这样的一个问题:之前数据只存放在数据库中,从数据库读取,现在要放到缓存中读取,具体要存储什么呢?
最简单直接的方案就是【全量数据刷到缓存中】
- 数据库的数据,全量刷到缓存中(不设置失效时间)
- 写请求只更新数据库,不更新缓存
- 启动一个定时任务,定时将数据库中的数据,同步更新到缓存中
这个方案的有点不必多说,所有请求都全部【命中】缓存,不需要经过数据库,性能非常高。但是缺点也很明显,主要体现以下两点。
- 缓存利用率低,不是所有的缓存都是热点,不经常访问的数据长期存放在缓存中,会导致资源浪费
- 数据不一致,因为采取的是【定时刷新缓存】的机制,导致缓存数据与数据库数据不一致(取决于定时刷新的频率)
所以,这个方案适用于【体量小】的业务,对数据一致性要求不高的业务场景。那么,针对体量很大的业务场景,怎么解决这两个问题呢?
缓存利用率和一致性问题
先来看第一个问题,如何提高缓存利用率的问题。说到这,想要缓存利用率【最大化】,我们很容易能想到的方案是,缓存中只保留最近访问或经常访问的【热点数据】。我们可以这样优化:
- 写请求依旧只写数据库
- 读请求先读缓存,如果缓存不在,则从数据库读取,并重建缓存
- 同时,写入缓存中的数据,都设置失效时间(错开失效时间)
这样一来,缓存中不经常访问的key,随着时间的推移,都会时间【过期】淘汰掉,最终缓存中保留的,都是热点数据,从而缓存的利用率得以最大化。
再看数据一致性的问题。
想要保证缓存和数据库【实时】一致,那就不能再使用定时任务刷新缓存的方案。所以,当数据发生更新时,我们不仅要操作更新数据库,还要一并操作缓存。具体的操作就是修改一条数据时,不仅要更新数据库,连带着缓存一起更新,或者删除相应的缓存再次访问时是会查询数据库后进行重建缓存。
但数据库和缓存都更新,有存在先后的问题,那对应的方案就有2个:
- 先更新缓存,后更新数据库
- 先更新数据库,后更新缓存
哪一个方案更好呢?
这里先不考虑并发的问题,正常情况下,无论谁先谁后,都可以让两者保持一致,但现在我们需要重点考虑【异常】情况。因为操作是分两步,那么就有可能在【第一个成功,第二个失败】的情况发生。
1、先更新缓存,后更新数据库
如果缓存更新成功了,但数据库更新失败,那么此时缓存中是最新值,但是数据库中是【旧值】,虽然此时请求可以命中缓存,拿到正确的值,但是一旦缓失,就会从数据库中读取到【旧值】,重建缓存也是这个旧值。这时用户就会发现自己之前的修改【又变回去了】,对业务造成影响。
2、先更新数据库,后更新缓存
如果更新数据库成功了,但是缓存更新失败了,那么此时数据库中是最新的值,缓存中是【旧值】。之后的读请求都读到的是旧数据,只有当缓存【失效】后,才能从数据库中得到正确的值。这时用户就会发现,自己刚刚修改了数据,但发现不生效,过一段时间后,数据才变更过来,对业务也会有影响。
可见,无论是谁先谁后,但凡后者发生异常,就会对业务造成影响。那么怎样解决这个问题呢?我们继续分析,除了操作失败问题,还有什么场景会影响数据一致性?
这里我们还要重点关注:并发问题
并发引起的一致性问题
假如我们采用【先更新数据库,在更新缓存】的方案,并且两步都可以成功执行的前提下,如果存在并发,会是什么情况呢?
有线程A 和线程 B 两个线程,需要更新【同一条数据】,会发生这样的场景:
- 线程A 更新数据库(X=1)
- 线程B 更新数据库(X=2)
- 线程B 更新缓存(X=2)
- 线程A 更新缓存(X=1)
最终 X 的值在缓存中是1 ,数据库中是2,发生不一致。也就是说,A虽然先于B发生,但B操作数据库和缓存的时间,却要比B的时间更短,执行时序发生【错乱】,最终导致这条数据结果不符合预期的。
同样的,采用【新更新缓存,在更新数据库】的方案,也会有类似的问题。
除此之外,我们从【缓存利用率】的角度来评估这个方案,也是不太难推敲的。这是因为每次数据发生变更,都更新缓存,但是缓存中的数据不一定会被马上读取,这就会导致缓存中存放了很多不常访问的数据,浪费缓存资源。
而且很多情况下,写到缓存中的值,并不是与数据库中的值一一对应的,很可能先查数据库,经过一系列计算得出的值,才把这个值写到缓存中。由此可见,这种【更新数据库+更新缓存】的方案,不仅缓存利用率不到,还会造成服务器资源和性能的浪费。
所以此时我们需要考虑另外一种方案:删除缓存
删除缓存可以保证一致性吗
删除缓存对应的方案也有 2 种:
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
经过前面的分析我们可以知道,但凡【第二步】操作失败,都会导致数据不一致
1、先删除缓存,后更新数据库
如果有 2 个线程要并发【读写】数据,可能会发生以下场景:
- 线程A 要更新 X=2 (原值X=1)
- 线程A 先删除缓存
- 线程B 读缓存,发现不在,从数据库中读取到旧值(X=1)
- 线程A 将新值写入数据库(X=2)
- 线程B 将旧值写入缓存(X=1)
最终 X 的值在缓存中是1(旧值),在数据库中是2(新值),发生不一致。可见,先删除缓存,后更新数据库,当发生【读+写】并发时,还是存在数据不一致的情况。
2、先更新数据库,后删除缓存
依旧是两个线程【并发读写】数据 :
- 缓存中 X 不存在(数据库中 X=1)
- 线程A 读取数据库,得到旧值(X=1)
- 线程B 更新数据库(X=2)
- 线程B 删除缓存
- 线程A 将旧值写入缓存(X=1)
最终X的值在缓存中是1(旧值),在数据库中是2(新值),也发生不一致
这种情况理论来说是可能发生的,但实际中真有可能发生吗?其实概率很低,这是因为它必须满足 3 个条件:
- 缓存刚已失效
- 读写请求并发
- 更新数据库 + 删除缓存的时间(步骤3~4),要比读数据库 + 写缓存的时间短(步骤2和5)
仔细想一下,条件3发生的概率是非常低的。因为写数据库一般会先【加锁】,所以写数据库,通常是要比读数据库的时间更长的。这么看来,【先更新数据库 + 再删除缓存】的方案,是可以保证数据一致性的。
所以,我们应该采用这种方案(【先更新数据库 + 再删除缓存】)来操作数据库和缓存。嗯,解决了并发问题,我们继续来看前面遗留的,第二步执行失败,导致数据不一致的问题
如何保证两步都执行
通过前面的分析,无论是更新缓存还是删除缓存,只要第二步出现失败,就会导致数据库和缓存的结果不一致。
保证第二步成功执行,就是解决问题的关键,程序在执行过程中发生异常,最简单的解决办法是:重试
但这并不意味着,只要执行失败(出现异常),我们重试就可以了。实际情况往往没那么简单,失败后立即重试的问题在于:
- 立即重试很大概率还会失败
- 重试次数设置多少才合理
- 重试会一直占用这个线程资源,无法服务其他客户端请求
由此可见了,虽然我们想通过重试的方式解决问题,但是这种【同步重试】的方案依旧不严谨。那么另一种更好的方案是:异步重试
异步重试,其实就是把重试请求放到【消息队列】中,然后由专门的消费者来进行重试,直到成功。或者更直接的做法,为了避免第二次执行失败,我们可以把操作缓存这一步,直接放到消息队列中,由消费者来操作缓存。这里你可能会疑惑,写队列也有可能会失败,而且引入消息队列,这又会增加了更多的维护成本,增加项目复杂度,这样做是否值得?
这是个好问题,抛开项目复杂度,我们思考这样一个问题:如果在执行失败的线程中一直重试,还没等执行成功,此时如果项目重启了,那么重试的请求就丢失了,这一条数据就不一致了。所以,我们必须将重试或者第二步骤放到另一个服务中,这个服务用【消息队列】最为合适,因为消息队列的特性,可以满足我们的需求:
- 消息队列可靠性:写到队列中的消息,成功消费之前不会丢失(重启也不担心)
- 消息队列保证消息成功投递:消费者从队列拉取消息,成功消费后才会删除(message_id),否则还会继续投递消息给消费者(符合重试场景)
至于写队列失败和消息队列成本维护问题:
- 写入队列失败:操作缓存和写消息队列,同时失败的概率是非常小的
- 维护成本:达到一定量级,我们项目中都会使用消息队列,维护成本并没有增加很多
所以,引入消息队列来解决第二个步骤失败重试的问题,是比较合适的,这时候的架构就变成了这样:
如果不想在应用中去写消息队列,是否有更简单的方案,同时又可以保证一致性呢?方案还是有的,这就是近几年比较流行的解决方案:订阅数据库变更日志,再操作缓存
具体来说就是,业务在修改数据时,只需修改数据库,无需操作缓存。那什么时候操作缓存呢,这个就与数据库的【变更日志】有关当一条数据发生改变时,MySQL就会产生一条binlog(变更日志)我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。
订阅变更日志,目前也有比较成熟的开源中间件,例如阿里的canal,使用这种方案的有点在于:
- 无需考虑写消息队列失败情况:只要写MySQL成功,Binlog肯定会有
- 自动投递到下游队列:canal自动把数据库变更日志【投递】给下游消息队列
当然,于此同时,我们需要投入经历去维护canal的高可用和稳定性。
到这里,可以得出的结论,想要保证数据和缓存一致性,推荐采用【先更新数据库,再删除缓存】的方案,并且配合【消息队列】或【订阅变更日志】
的方式来做
主从延迟和延迟双删问题
「读写分离 + 主从复制延迟」情况下,缓存和数据库一致性的问题。
在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」其实也会导致不一致:
线程 A 更新主库 X = 2(原值 X = 1)
线程 A 删除缓存
线程 B 查询缓存,没有命中,查询「从库」得到旧值(从库 X = 1)
从库「同步」完成(主从库 X = 2)
线程 B 将「旧值」写入缓存(X = 1)
最终 X 的值在缓存中是 1(旧值),在主从库中是 2(新值),也发生不一致。
看到了么?这 2 个问题的核心在于:缓存都被回种了「旧值」。
那怎么解决这类问题呢?
最有效的办法就是,把缓存删掉。
但是,不能立即删,而是需要「延迟删」,这就是业界给出的方案:缓存延迟双删策略。
线程 A 可以生成一条「延时消息」,写到消息队列中,消费者延时「删除」缓存。
这两个方案的目的,都是为了把缓存清掉,这样一来,下次就可以从数据库读取到最新值,写入缓存。
但问题来了,这个「延迟删除」缓存,延迟时间到底设置要多久呢?
- 问题1:延迟时间要大于「主从复制」的延迟时间
- 问题2:延迟时间要大于线程 B 读取数据库 + 写入缓存的时间
但是,这个时间在分布式和高并发场景下,其实是很难评估的。很多时候,我们都是凭借经验大致估算这个延迟时间,例如延迟 1-5s,只能尽可能地降低不一致的概率。所以你看,采用这种方案,也只是尽可能保证一致性而已,极端情况下,还是有可能发生不一致。
所以实际使用中,我还是建议你采用「先更新数据库,再删除缓存」的方案,同时,要尽可能地保证「主从复制」不要有太大延迟,降低出问题的概率。
可以做到强一致性吗
如果想让缓存和数据到达到【强一致性】,其实很难做到的。这往往要 牺牲性能
一旦我们使用缓存,就必然会出现一致性的问题,性能与一致性,无法做到保持平衡。势必会向一方倾斜。如果非要达到强一致性,那就必须在完成更新操作之前,不能有任何请求处理,这在实际高并发的场景中是不可取的。
虽然也可以使用【分布式锁】来实现,但是加锁与释放的过程,也会降低其性能,有时候甚至会超过引入缓存带来的性能提升。
所以,我们既然决定使用缓存,就必须容忍【一致性】问题,我们只能尽可能地降低出现问题的概率
总结
- 将要提高应用性能,可以引入缓存来解决
- 引入缓存后,就要考虑缓存和数据库一致性的问题,建议,更新数据库,删除缓存
- 更新数据库 + 删除缓存的方案,在并发场景下无法保证缓存与数据保持一致性,且存在缓存资源浪费和机器性能浪费的情况。
- 并发场景下的延迟双删策略,这个延迟时间很难评估,所以推荐【先更新数据库,再删除缓存】的方案
- 在【先更新数据库,再删除缓存】的方案下,为了保证两步都能执行成功,需要配合【消息队列】或【订阅变更日志】的方案来做,其本质是通过重试的方式保证数据一致性。
- 在【先更新数据库,再删除缓存】方案下,【读写分离 + 主从延迟】也会导致缓存和数据库不一致,解次问题的方案是【延迟双删】,凭借经验发送【延迟消息】到队列中,延迟删除缓存,同时要也要控制主从库延迟(可以通过暂时剔除延迟高的节点,延迟低的时候再将节点加入集群),尽可能降低不一致发生的概率。
4、SpringBoot 整合 Redis 缓存
4.1、使用 Redis 缓存
① 在 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*/@Beanpublic 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和publicom.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序列化方式采用jacksonredisTemplate.setValueSerializer(jackson2JsonRedisSerializer);// hash的key也采用String的序列化方式redisTemplate.setHashKeySerializer(new StringRedisSerializer());// hash的value序列化方式采用jacksonredisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);// 支持事物// template.setEnableTransactionSupport(true);redisTemplate.afterPropertiesSet();return redisTemplate;}/*** 设置CacheManager缓存规则* * @param factory* @return*/@Beanpublic 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
,这两者的主要区别如下:
- StringRedisTemplate 继承了 RedisTemplate。
- RedisTemplate 是一个泛型类,而 StringRedisTemplate 则不是。
- StringRedisTemplate 只能对 key=String,value=String 的键值对进行操作,RedisTemplate 可以对任何类型的 key-value 键值对操作。
- 他们各自序列化的方式不同,但最终都是得到了一个字节数组,殊途同归,StringRedisTemplate 使用的是 StringRedisSerializer 类;RedisTemplate 使用的是 JdkSerializationRedisSerializer 类。反序列化,则是一个得到 String,一个得到 Object。
- 两者的数据是不共通的,StringRedisTemplate 只能管理 StringRedisTemplate 里面的数据,RedisTemplate 只能管理 RedisTemplate中 的数据。
示例如下:
@RestController
public class UserController {@Autowiredprivate UserService userServer;@AutowiredStringRedisTemplate 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";}
}
4.2、使用 SpringCache 的注解
4.2.1、注解说明
- @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
如果缓存中存在存在目标值,则将其从缓存中删除。
该注解常用参数如下:
- cacheNames/value、key、keyGenerator、condition同@Cacheable
- allEntries:如果指定allEntries为true,Spring Cache将忽略指定的key清除缓存中的所有元素,默认情况下为false。
- beforeInvocation:删除缓存操作默认是在对应方法成功执行之后触发的,方法如果因为抛出异常而未能成功返回时也不会触发删除操作。如果指定beforeInvocation为true ,则无论方法结果如何,无论方法是否抛出异常都会导致删除缓存。
- @Caching
用于一次性设置多个缓存。
4.2.2、常用注解配置参数
- value:缓存管理器中配置的缓存的名称,这里可以理解为一个组的概念,缓存管理器中可以有多套缓存配置,每套都有一个名称,类似于组名,这个可以配置这个值,选择使用哪个缓存的名称,配置后就会应用那个缓存名称对应的配置。
- key: 缓存的 key,可以为空,如果指定要按照 SpEL 表达式编写,如果不指定,则缺省按照方法的所有参数进行组合。
- condition: 缓存的条件,可以为空,使用 SpEL 编写,返回 true 或者 false,只有为 true 才进行缓存。
- unless: 不缓存的条件,和 condition 一样,也是 SpEL 编写,返回 true 或者 false,为 true 时则不进行缓存。
4.2.3、自动缓存案例
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 {@Resourceprivate 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);}
}