一文掌握Redis

Redis

参考微信公众号:码哥字节。

1.redis解决商品秒杀库存超卖问题

在众多抢购活动中,在有限的商品数量的限制下如何保证抢购到商品的用户数不能大于商品数量,也就是不能出现超卖的问题;还有就是抢购时会出现大量用户的访问,如何提高用户体验效果也是一个问题,也就是要解决秒杀系统的性能问题。

本文主要介绍基于redis 实现商品秒杀功能。先来跟大家讲下大概思路。总体思路就是要减少对数据库的访问,尽可能将数据缓存到Redis缓存中,从缓存中获取数据。

- 在系统初始化时,将商品的库存数量加载到Redis缓存中;
- 接收到秒杀请求时,在Redis中进行预减库存,当Redis中的库存不足时,直接返回秒杀失败,否则继续进行第3步;
- 将请求放入异步队列中,返回正在排队中;
- 服务端异步队列将请求出队,出队成功的请求可以生成秒杀订单,减少数据库库存,返回秒杀订单详情。
- 当后台订单创建成功之后可以通过websocket向用户发送一个秒杀成功通知。前端以此来判断是否秒杀成功,秒杀成功则进入秒杀订单详情,否则秒杀失败。

2.Redis为什么快?

有多快:redis根据官方文档所说,Redis 的 QPS 可以达到约 100000(每秒请求数)。

1.基于内存实现:Redis 是基于内存的数据库,跟磁盘数据库相比,完全吊打磁盘的速度。不论读写操作都是在内存上完成的,我们分别对比下内存操作与磁盘操作的差异。

2.高效的数据结构:Redis 一共有 5 种数据类型,String、List、Hash、Set、SortedSet。不同的数据类型底层使用了一种或者多种数据结构来支撑,目的就是为了追求更快的速度。

3.单线程模型

注意:Redis 的单线程指的是 **单线程指的是 Redis 键值对读写指令的执行是单线程。**对于 Redis 的持久化、集群数据同步、异步删除等都是其他线程执行。

最后说明一下,Redis的瓶颈存在于两个方面:内存和网络带宽。

3.Redis数据类型和其底层的数据结构

3.1底层数据结构

详细请转到

在这里插入图片描述

  • SDS动态字符串

在Redis数据库里,包含字符串值的键值对都是由SDS实现的(Redis中所有的键都是由字符串对象实现的即底层是由SDS实现,Redis中所有的值对象中包含的字符串对象底层也是由SDS实现

在这里插入图片描述

  1. SDS 中 len 保存这字符串的长度,O(1) 时间复杂度查询字符串长度信息。
  2. 空间预分配:SDS 被修改后,程序不仅会为 SDS 分配所需要的必须空间,还会分配额外的未使用空间。
  3. 惰性空间释放:当对 SDS 进行缩短操作时,程序并不会回收多余的内存空间,而是使用 free 字段将这些字节数量记录下来不释放,后面如果需要 append 操作,则直接使用 free 中未使用的空间,减少了内存的分配。

为什么不使用c字符串二用动态字符串?

1.内存溢出问题:C字符串,如果程序员在字符串修改的时候如果忘记给字符串重新分配足够的空间,那么就会发生内存溢出,如s1和s2内存邻接,忘记给s1分配足够的内存空间, s1的数据就会溢出到s2的空间, 导致s2的内容被修改.

2.重新分配内存问题:在C字符串中,如果对字符串进行修改,那么我们就不得不面临内存重分配。因为C字符串是由一个N+1长度的数组组成,如果字符串的长度变长,我们就必须对数组进行扩容,否则会产生内存溢出。而如果字符串长度变短,我们就必须释放掉不再使用的空间,否则会发生内存泄漏。

其分配策略如下:

  • 如果修改后len长度将小于1M,这时分配给free的大小和len一样,例如修改过后为10字节, 那么给free也是10字节,buf实际长度变成了10+10+1 = 21byte
  • 如果修改后len长度将大于等于1M,这时分配给free的长度为1M,例如修改过后为30M,那么给free是1M.buf实际长度变成了30M+1M+1byte

相反,惰性空间释放用于字符串缩短的操作。当字符串缩短是,程序并不是立即使用内存重分配来回收缩短出来的字节,而是使用free属性记录起来,并等待将来使用。

  • zipList压缩列表

在这里插入图片描述

压缩列表是 List 、hash、 sorted Set 三种数据类型底层实现之一。

压缩列表(zip1ist)是列表和哈希的底层实现之一。

当一个列表只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表的底层实现。

当一个哈希只包含少量键值对,比且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希的底层实现。

当一个列表只有少量数据的时候,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么 Redis 就会使用压缩列表来做列表键的底层实现。

原理

  • quickList

在这里插入图片描述

后续版本对列表数据结构进行了改造,使用 quicklist 代替了 ziplist 和 linkedlist。quicklist 是 ziplist 和 linkedlist 的混合体,它将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针串接起来。

  • skipList跳跃表

sorted set 类型的排序功能便是通过「跳跃列表」数据结构来实现。

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

跳表在链表的基础上,增加了多层级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:

在这里插入图片描述

跳跃表(SkipList)这种数据结构使用空间换时间的策略,通过给链表建立多层索引来加快搜索效率,我们先介绍跳跃表的基本理论,再来看看 redis 中的实现情况。

  • 整数数组

    当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现,节省内存。

3.2基本数据类型

五大基本数据类型和三大特殊类型。

String
incr和decr:
127.0.0.1:6379[3]> set views 0
OK
127.0.0.1:6379[3]> get views
"0"
127.0.0.1:6379[3]> incr views   #自增1  相应的decr是自减1
(integer) 1
127.0.0.1:6379[3]> get views        
"1"
127.0.0.1:6379> incrby views 10  #设置步长 自增10
(integer) 11
127.0.0.1:6379> get views
"11"
127.0.0.1:6379> decrby views 9
(integer) 2
127.0.0.1:6379> get views
"2"
range:
127.0.0.1:6379> set key1 "hello,jinyu"
OK
127.0.0.1:6379> get key1
"hello,jinyu"
127.0.0.1:6379> getrange key1 0 2  #获取下标0-2,闭区间
"hel"
127.0.0.1:6379> getrange key1 0 -1  #获取全部字符串,和get key1一样
"hello,jinyu"
替换:
127.0.0.1:6379> set key2 abcdefg
OK
127.0.0.1:6379> setrange key2 1 xx
(integer) 7
127.0.0.1:6379> get key2
"axxdefg"
setex和setnx
127.0.0.1:6379> setex key3 30 "hello" #设置key3的值为hello,30秒后过期
OK
127.0.0.1:6379> ttl key3
(integer) 22
127.0.0.1:6379> get key3
"hello"
127.0.0.1:6379> setnx mykey "redis"  #mykey不存在,创建mykey
(integer) 1
127.0.0.1:6379> keys *
1) "mykey"
2) "key2"
127.0.0.1:6379> setnx mykey "Mongdb"  #mykey存在,创建失败
(integer) 0
127.0.0.1:6379> keys *
1) "mykey"
2) "key2"
批量设置mset和mget:
127.0.0.1:6379> mset k1 v1 k2 v2 k3 v3   #同时设置多个值
OK
127.0.0.1:6379> keys *
1) "k2"
2) "k3"
3) "k1"
127.0.0.1:6379> mget k1 k3   #同时获取
1) "v1"
2) "v3"
127.0.0.1:6379> msetnx k1 v1 k4 v4   #原子性操作,一起成功一起失败
(integer) 0
127.0.0.1:6379> get key4
(nil)
存储对象
127.0.0.1:6379> mset user:1:name zhangsan user:1:age 2
OK
127.0.0.1:6379> mget user:1:name user:1:age
1) "zhangsan"
2) "2"
getset命令:
127.0.0.1:6379> getset db redis   #获取当前可以的值并且设置新的值
(nil)
127.0.0.1:6379> get db
"redis"
127.0.0.1:6379> getset db mongodb
"redis"
127.0.0.1:6379> get db
"mongodb"

value不仅可以是string还可以是数字,应用常规计数:微博粉丝数等等

List
push:
127.0.0.1:6379> lpush list one  #将一个值或者多个值插入到列表的头部
(integer) 1
127.0.0.1:6379> lpush list two
(integer) 2
127.0.0.1:6379> lpush list three
(integer) 3
127.0.0.1:6379> lrange list 0 -1  #获取list的值
1) "three"
2) "two"
3) "one"
127.0.0.1:6379> lrange list 0 1   #通过区间获取具体的值
1) "three"
2) "two"

127.0.0.1:6379> rpush list right  #rpush放到列表的尾部
(integer) 4
127.0.0.1:6379> lrange list 0 -1
1) "three"
2) "two"
3) "one"
4) "right"
pop,index,llen:
127.0.0.1:6379> lpop list  #移除列表的第一个元素
"three"
127.0.0.1:6379> rpop list  #移除list的最后一个元素
"right"
127.0.0.1:6379> lrange list 0 -1
1) "two"
2) "one"
127.0.0.1:6379> lindex list 1
"one"
127.0.0.1:6379> llen list
(integer) 2
移除list集合中指定个数的value:
127.0.0.1:6379> lpush list three
(integer) 3
127.0.0.1:6379> lpush list three
(integer) 4
127.0.0.1:6379> lrange list 0 -1
1) "three"
2) "three"
3) "two"
4) "one"
127.0.0.1:6379> lrem list 1 one
(integer) 1
127.0.0.1:6379> lrange list 0 -1
1) "three"
2) "three"
3) "two"
127.0.0.1:6379> lrem list 1 three
(integer) 1
127.0.0.1:6379> lrange list 0 -1
1) "three"
2) "two"
通过下标截取指定的长度:
127.0.0.1:6379> lpush mylist hello
(integer) 1
127.0.0.1:6379> lpush mylist hello1
(integer) 2
127.0.0.1:6379> lpush mylist hello2
(integer) 3
127.0.0.1:6379> lpush mylist hello3
(integer) 4
127.0.0.1:6379> lrange mylist 0 -1
1) "hello3"
2) "hello2"
3) "hello1"
4) "hello"
127.0.0.1:6379> ltrim mylist 1 2   
OK
127.0.0.1:6379> lrange mylist 0 -1
1) "hello2"
2) "hello1"
rpoplpush:
127.0.0.1:6379> rpush mylist "hello"
(integer) 1
127.0.0.1:6379> rpush mylist "hello1"
(integer) 2
127.0.0.1:6379> rpush mylist "hello2"
(integer) 3
127.0.0.1:6379> rpoplpush mylist myotherlist   #移除列表的最后一个元素,将他移动到新的列表中
"hello2"
127.0.0.1:6379> lrange mylist 0 -1
1) "hello"
2) "hello1"
127.0.0.1:6379> lrange myotherlist  0 -1
1) "hello2"
更新列表:
127.0.0.1:6379> lset list 0 item  #不存在列表就会报错
(error) ERR no such key
127.0.0.1:6379> lpush list value1
(integer) 1
127.0.0.1:6379> lrange list 0 0
1) "value1"
127.0.0.1:6379> lset list 0 item  #列表中存在下标就会更新下标的值
OK
127.0.0.1:6379> lrange list 0 0
1) "item"
127.0.0.1:6379> lset list 1 item  #列表中不存在下标就会报错
(error) ERR index out of range
将某一个具体的value插入到列表中某个元素的前面或者后面
127.0.0.1:6379> rpush mylist hello
(integer) 1
127.0.0.1:6379> rpush mylist world
(integer) 2
127.0.0.1:6379> linsert mylist before world other
(integer) 3
127.0.0.1:6379> lrange mylist 0 -1
1) "hello"
2) "other"
3) "world"

在这里插入图片描述

Set
添加元素,查看set所有值,判断某一个值是否在set中,获取元素个数:
127.0.0.1:6379> sadd myset hello
(integer) 1
127.0.0.1:6379> sadd myset jinyu
(integer) 1
127.0.0.1:6379> smembers myset
1) "jinyu"
2) "hello"
127.0.0.1:6379> sismember myset hello
(integer) 1
127.0.0.1:6379> sismember myset world
(integer) 0
127.0.0.1:6379> scard myset
(integer) 2
127.0.0.1:6379> sadd myset lovejinyu
(integer) 1
127.0.0.1:6379> scard myset
(integer) 3
移除指定元素:
127.0.0.1:6379> srem myset hello
(integer) 1
127.0.0.1:6379> scard myset
(integer) 2
127.0.0.1:6379> smembers myset
1) "jinyu"
2) "lovejinyu"
随机抽出元素,随机抽选出指定个数的元素:
127.0.0.1:6379> SRANDMEMBER myset
"jinyu"
127.0.0.1:6379> SRANDMEMBER myset
"lovejinyu"
127.0.0.1:6379> SMEMBERS myset
1) "jinyu"
2) "hello1"
3) "hello"
4) "lovejinyu"
127.0.0.1:6379> SRANDMEMBER myset 2
1) "jinyu"
2) "hello1"
删除指定的key,随机删除key:
127.0.0.1:6379> smembers myset
1) "jinyu"
2) "hello1"
3) "hello"
4) "lovejinyu"
127.0.0.1:6379> spop myset    #随之删除一些set中的元素
"jinyu"
127.0.0.1:6379> smembers myset
1) "hello1"
2) "hello"
3) "lovejinyu"
将一个指定的值移动到另外一个集合中:
127.0.0.1:6379> sadd myset hello
(integer) 1
127.0.0.1:6379> sadd myset hello1
(integer) 1
127.0.0.1:6379> sadd myset hello2
(integer) 1
127.0.0.1:6379> sadd myset2 set2
(integer) 1
127.0.0.1:6379> smove myset myset2 hello
(integer) 1
127.0.0.1:6379> SMEMBERS myset2
1) "set2"
2) "hello"
微博,b站中的共同关注!(差并交集):
127.0.0.1:6379> sadd key1 a
(integer) 1
127.0.0.1:6379> sadd key1 b
(integer) 1
127.0.0.1:6379> sadd key1 c
(integer) 1
127.0.0.1:6379> sadd key2 c
(integer) 1
127.0.0.1:6379> sadd key2 d
(integer) 1
127.0.0.1:6379> sadd key2 e
(integer) 1
127.0.0.1:6379> SDIFF key1 key2
1) "a"
2) "b"
127.0.0.1:6379> SINTER key1 key2
1) "c"
127.0.0.1:6379> SUNION key1 key2
1) "a"
2) "b"
3) "c"
4) "e"
5) "d"

在这里插入图片描述

hash
存取:
127.0.0.1:6379> hset myhash field1 jinyu
(integer) 1
127.0.0.1:6379> hget myhash field1   #set一个具体的key-value
"jinyu"
127.0.0.1:6379> hmset myhash field1 hello field2 world   #set多个key-value
OK
127.0.0.1:6379> hmget myhash field1 field2   #获取多个字段值
1) "hello"
2) "world"
127.0.0.1:6379> HGETALL myhash    #获取全部的数据
1) "field1"
2) "hello"
3) "field2"
4) "world"
删除hash指定的key字段:
127.0.0.1:6379> hdel myhash field1
(integer) 1
127.0.0.1:6379> hgetall myhash
1) "field2"
2) "world"
获取hash的字段数量:
127.0.0.1:6379> hgetall myhash
1) "field2"
2) "world"
3) "field1"
4) "hello"
127.0.0.1:6379> hlen myhash
(integer) 2
判断hash中字段是否存在:
127.0.0.1:6379> HEXISTS myhash field1
(integer) 1
127.0.0.1:6379> HEXISTS myhash field3
(integer) 0
只获得所有的field,只获得所有的value:
127.0.0.1:6379> hkeys myhash
1) "field2"
2) "field1"
127.0.0.1:6379> hvals myhash
1) "world"
2) "hello"
增减:
127.0.0.1:6379> hset myhash field3 5
(integer) 1
127.0.0.1:6379> HINCRBY myhash field3 1
(integer) 6
127.0.0.1:6379> HINCRBY myhash field3 -2
(integer) 4
不存在则可以设置:
127.0.0.1:6379> hsetnx myhash field4 hello
(integer) 1
127.0.0.1:6379> hsetnx myhash field4 world
(integer) 0
hash存变更的数据,尤其是用户信息的保存。 user name age。hash更适合对象的存储,string更适合字符串的存储(上面string数据格式命令讲过):
127.0.0.1:6379> hset user:1 name jinyu
(integer) 1
127.0.0.1:6379> hget user:1 name
"jinyu"

在这里插入图片描述

Zset
添加多个值:
127.0.0.1:6379> zadd myset 1 one
(integer) 1
127.0.0.1:6379> zadd myset 2 two
(integer) 1
127.0.0.1:6379> zadd myset 3 three
(integer) 1
127.0.0.1:6379> zrange myset 0 -1
1) "one"
2) "two"
3) "three"
根据成绩排序输出:
127.0.0.1:6379> zadd salary 2500 xiaohong
(integer) 1
127.0.0.1:6379> zadd salary 5000 zhangsan
(integer) 1
127.0.0.1:6379> zadd salary 50 kuangshen
(integer) 1
127.0.0.1:6379> ZRANGEBYSCORE salary -inf  +inf  #从小到大排序
1) "kuangshen"
2) "xiaohong"
3) "zhangsan"
127.0.0.1:6379> ZREVRANGE salary 0 -1  #从大到小排序
1) "zhangsan"
2) "xiaohong"
3) "kuangshen"

127.0.0.1:6379> ZRANGEBYSCORE salary -inf +inf withscores  #从小到大排序 并且附带成绩
1) "kuangshen"
2) "50"
3) "xiaohong"
4) "2500"
5) "zhangsan"
6) "5000"
127.0.0.1:6379> ZRANGEBYSCORE salary -inf 2500 withscores  #显示工资小于2500的员工的升序排列
1) "kuangshen"
2) "50"
3) "xiaohong"
4) "2500"
移除指定元素的数据:
127.0.0.1:6379> zrange salary 0 -1
1) "kuangshen"
2) "xiaohong"
3) "zhangsan"
127.0.0.1:6379> zrem salary xiaohong
(integer) 1
127.0.0.1:6379> zrange salary 0 -1
1) "kuangshen"
2) "zhangsan"
查询数据的数量:
127.0.0.1:6379> zcard salary
(integer) 2
获取成绩在指定区间的个数:
127.0.0.1:6379> zadd myset 1 hello
(integer) 1
127.0.0.1:6379> zadd myset 2 world 3 jinyu
(integer) 2
127.0.0.1:6379> zcount myset  1 3
(integer) 3
127.0.0.1:6379> zcount myset  1 2
(integer) 2
3.3三大特殊数据类型
geospatial

地理位置

geoadd:
#添加城市数据,规则:两极没法添加,一般通过java程序一次性导入
#参数 key 值(经度,维度,名称)
#有效的经度从-180度到180度。
#有效的纬度从-85.05112878度到85.05112878度
127.0.0.1:6379> geoadd china:city 116.40 39.90 beijing
(integer) 1
127.0.0.1:6379> geoadd china:city 121.47 31.23 shanghai
(integer) 1
127.0.0.1:6379> geoadd china:city 106.50 29.53 chongqing
(integer) 1
127.0.0.1:6379> geoadd china:city 114.05 22.52 shenzhen
(integer) 1
127.0.0.1:6379> geoadd china:city 120.16 30.24 hangzhou
(integer) 1
127.0.0.1:6379> geoadd china:city 108.96 34.26 xian
(integer) 1
获取指定城市的经度和维度geopos:
127.0.0.1:6379> GEOPOS china:city beijing chongqing
1) 1) "116.39999896287918091"
   2) "39.90000009167092543"
2) 1) "106.49999767541885376"
   2) "29.52999957900659211"
两人之间的距离geodist:
m 表示单位为米。
km 表示单位为千米。
mi 表示单位为英里。
ft 表示单位为英尺。
127.0.0.1:6379> geodist china:city shanghai beijing
"1067378.7564"
127.0.0.1:6379> geodist china:city shanghai beijing km
"1067.3788"
找附近的人?获得所有附近的人的位置,以半径搜索
georadius和georadiusbymember:
127.0.0.1:6379> GEORADIUS china:city 110 30 500 km  #显示到中心半径为500km的地方
1) "chongqing"
2) "xian"
127.0.0.1:6379> GEORADIUS china:city 110 30 500 km withcoord #查询后返回经纬度
1) 1) "chongqing"
   2) 1) "106.49999767541885376"
      2) "29.52999957900659211"
2) 1) "xian"
   2) 1) "108.96000176668167114"
      2) "34.25999964418929977"
127.0.0.1:6379> GEORADIUS china:city 110 30 500 km withcoord count 1 #限制查询的个数
1) 1) "chongqing"
   2) 1) "106.49999767541885376"
      2) "29.52999957900659211"
127.0.0.1:6379> GEORADIUSBYMEMBER china:city beijing 1000 km  #查距离北京1000km 的城市
1) "beijing"
2) "xian"
127.0.0.1:6379> GEORADIUSBYMEMBER china:city shanghai 400 km
1) "hangzhou"
2) "shanghai"
geo的底层就是zset,所以可以使用zset操作geo
127.0.0.1:6379> ZRANGE china:city 0 -1
1) "chongqing"
2) "xian"
3) "shenzhen"
4) "hangzhou"
5) "shanghai"
6) "beijing"
127.0.0.1:6379> zrem china:city beijing
(integer) 1
127.0.0.1:6379> ZRANGE china:city 0 -1
1) "chongqing"
2) "xian"
3) "shenzhen"
4) "hangzhou"
5) "shanghai"
Hyperloglog
Hyperloglog基数统计的算法。
优点:占用的内存固定,2^64不同的元素的基数,只需要12KB内存。
网页的UV(一个人访问网站多次,但是还是算做一个人):
传统方式,set保存用户uid,统计set中元素的数量就行,这种方式保存大量用户id,我们的目的是为了计数。
0.81%错误率,但是在统计UV时可以忽略不计。

127.0.0.1:6379> PFADD mykey a b c d e  f g h i j  #创建第一组元素
(integer) 1
127.0.0.1:6379> PFCOUNT mykey #统计mykey元素的基数数量
(integer) 10
127.0.0.1:6379> pfadd mykey2 i n g d s o p
(integer) 1
127.0.0.1:6379> PFCOUNT mykey2
(integer) 7
127.0.0.1:6379> PFMERGE mykey3 mykey mykey2 #合并两组元素为mykey3
OK 
127.0.0.1:6379> PFCOUNT mykey3 #统计mykey3的基数数量
(integer) 14
Bitmaps
位存储。
应用:统计疫情感染人数:0000,感染的变1
           统计用户信息:活跃和不活跃,登录和未登录,365天打卡--365bit--46字节。两个状态的任务
BitMap位图,数据结构,都是操作二进制位进行记录,只有0和1两个状态。

记录周一到周日的打卡:
127.0.0.1:6379> setbit sign 0 1
(integer) 0
127.0.0.1:6379> setbit sign 1 0
(integer) 0
127.0.0.1:6379> setbit sign 2 1
(integer) 0
127.0.0.1:6379> setbit sign 3 1
(integer) 0
127.0.0.1:6379> setbit sign 4 0
(integer) 0
127.0.0.1:6379> setbit sign 5 1
(integer) 0
127.0.0.1:6379> setbit sign 6 0
(integer) 0
查看某一天是否打卡:
127.0.0.1:6379> getbit sign 3
(integer) 1
127.0.0.1:6379> getbit sign 6
(integer) 0
统计打卡的天数:
127.0.0.1:6379> bitcount sign
(integer) 4

4.为什么Redis使用单线程模型而不是用多线程呢?

首先,如果使用多线程的话必然会涉及到切换上下文,这种切换是十分耗费时间的。同时,引入多线程,对共享资源的并发读写都要进行一些保护,增加了实现的复杂度。

其次,Redis是基于内存的数据库(相比较于传统的基于磁盘的数据库,IO时CPU会空闲,基于内存的数据库CPU会全心全意的为其服务,所以内存会成为其发展的瓶颈,CPU不会),CPU不会成为瓶颈。同时单线程相对于多线程又是很容易实现的,所以采用单线程模型。

单线程的好处:

  1. 不会因为线程创建导致的性能消耗;
  2. 避免上下文切换引起的 CPU 消耗,没有多线程切换的开销;
  3. 避免了线程之间的竞争问题,比如添加锁、释放锁、死锁等,不需要考虑各种锁问题。
  4. 代码更清晰,处理逻辑简单。

5.IO多路服用模型?

Redis 采用 I/O 多路复用技术,并发处理连接。采用了 epoll + 自己实现的简单的事件框架。

epoll 中的读、写、关闭、连接都转化成了事件,然后利用 epoll 的多路复用特性,绝不在 IO 上浪费一点时间。

在这里插入图片描述

Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。

6.Redis全局hash字典?

Redis 整体就是一个 哈希表来保存所有的键值对,无论数据类型是 5 种的任意一种。哈希表,本质就是一个数组,每个元素被叫做哈希桶,不管什么数据类型,每个桶里面的 entry 保存着实际具体值的指针。

在这里插入图片描述

而哈希表的时间复杂度是 O(1),只需要计算每个键的哈希值,便知道对应的哈希桶位置,定位桶里面的 entry 找到对应数据,这个也是 Redis 快的原因之一。

Redis 使用对象(redisObject)来表示数据库中的键值,当我们在 Redis 中创建一个键值对时,至少创建两个对象,一个对象是用做键值对的键对象,另一个是键值对的值对象。

也就是每个 entry 保存着 「键值对」的 redisObject 对象,通过 redisObject 的指针找到对应数据。

7.hash冲突怎么办?

Redis 通过链式哈希解决冲突:也就是同一个 桶里面的元素使用链表保存。但是当链表过长就会导致查找性能变差可能,所以 Redis 为了追求快,使用了两个全局哈希表。用于 rehash 操作,增加现有的哈希桶数量,减少哈希冲突。

开始默认使用 「hash 表 1 」保存键值对数据,「hash 表 2」 此刻没有分配空间。当数据越来越多触发 rehash 操作,则执行以下操作:

  1. 给 「hash 表 2 」分配更大的空间;
  2. 将 「hash 表 1 」的数据重新映射拷贝到 「hash 表 2」 中;
  3. 释放 「hash 表 1」 的空间。

值得注意的是,将 hash 表 1 的数据重新映射到 hash 表 2 的过程中并不是一次性的,这样会造成 Redis 阻塞,无法提供服务。

而是采用了渐进式 rehash,每次处理客户端请求的时候,先从「 hash 表 1」 中第一个索引开始,将这个位置的 所有数据拷贝到 「hash 表 2」 中,就这样将 rehash 分散到多次请求过程中,避免耗时阻塞。

8.Redis如何实现持久化?down机了如何恢复数据?

RDB和AOF两种持久化机制。

RDB:

在 Redis 执行「写」指令过程中,内存数据会一直变化。所谓的内存快照,指的就是 Redis 内存中的数据在某一刻的状态数据。

好比时间定格在某一刻,当我们拍照的,通过照片就能把某一刻的瞬间画面完全记录下来。

Redis 跟这个类似,就是把某一刻的数据以文件的形式拍下来,写到磁盘上。这个快照文件叫做 RDB 文件,RDB 就是 Redis DataBase 的缩写。

在这里插入图片描述

生成RDB的时候Redis可以同时处理写请求吗?

可以的,Redis 使用操作系统的多进程写时复制技术 COW(Copy On Write) 来实现快照持久化,保证数据一致性。

Redis 在持久化时会调用 glibc 的函数fork产生一个子进程,快照持久化完全交给子进程来处理,父进程继续处理客户端请求。

当主线程执行写指令修改数据的时候,这个数据就会复制一份副本, bgsave 子进程读取这个副本数据写到 RDB 文件。

这既保证了快照的完整性,也允许主线程同时对数据进行修改,避免了对正常业务的影响。

在这里插入图片描述

RDB的弊端?

Redis 的数据持久化使用了「RDB 数据快照」的方式来实现宕机快速恢复。但是 过于频繁的执行全量数据快照,有两个严重性能开销:

  1. 频繁生成 RDB 文件写入磁盘,磁盘压力过大。会出现上一个 RDB 还未执行完,下一个又开始生成,陷入死循环。
  2. fork 出 bgsave 子进程会阻塞主线程,主线程的内存越大,阻塞时间越长。
AOF:

AOF 日志记录了自 Redis 实例创建以来所有的修改性指令序列,那么就可以通过对一个空的 Redis 实例顺序执行所有的指令,也就是「重放」,来恢复 Redis 当前实例的内存数据结构的状态。

Redis 提供的 AOF 配置项appendfsync写回策略直接决定 AOF 持久化功能的效率和安全性。

  • always:同步写回,写指令执行完毕立马将 aof_buf缓冲区中的内容刷写到 AOF 文件。
  • everysec:每秒写回,写指令执行完,日志只会写到 AOF 文件缓冲区,每隔一秒就把缓冲区内容同步到磁盘。
  • no: 操作系统控制,写执行执行完毕,把日志写到 AOF 文件内存缓冲区,由操作系统决定何时刷写到磁盘。

没有两全其美的策略,我们需要在性能和可靠性上做一个取舍。

两者比较?

AOF 写前日志,记录的是每个「写」指令操作。不会像 RDB 全量快照导致性能损耗,但是执行速度没有 RDB 快,同时日志文件过大也会造成性能问题。

所以,Redis 设计了一个杀手锏「AOF 重写机制」,Redis 提供了bgrewriteaof指令用于对 AOF 日志进行瘦身。

在这里插入图片描述

混合持久化?

重启 Redis 时,我们很少使用 rdb 来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 rdb 来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。

Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。将 rdb 文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是自持久化开始到持久化结束的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小。

于是在 Redis 重启的时候,可以先加载 rdb 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升

9.Redis主从架构的数据同步?

Redis 提供了主从模式,通过主从复制,将数据冗余一份复制到其他 Redis 服务器。

主从模式如何保证副本数据的一致性?

  • 读操作:主、从库都可以执行;
  • 写操作:主库先执行,之后将写操作同步到从库;

在这里插入图片描述

主从复制的其他作用?

  1. 故障恢复:当主节点宕机,其他节点依然可以提供服务;
  2. 负载均衡:Master 节点提供写服务,Slave 节点提供读服务,分担压力;
  3. 高可用基石:是哨兵和 cluster 实施的基础,是高可用的基石。

主从复制的实现?

同步分为三种情况:

  1. 第一次主从库全量复制;
  2. 主从正常运行期间的同步;
  3. 主从库间网络断开重连同步。

全量同步?

  1. 建立连接:从库会和主库建立连接,从库执行 replicaof 并发送 psync 命令并告诉主库即将进行同步,主库确认回复后,主从库间就开始同步了
  2. 主库同步数据给从库:master 执行 bgsave命令生成 RDB 文件,并将文件发送给从库,同时主库为每一个 slave 开辟一块 replication buffer 缓冲区记录从生成 RDB 文件开始收到的所有写命令。从库保存 RDB 并清空数据库再加载 RDB 数据到内存中。
  3. 发送 RDB 之后接收到的新写命令到从库:在生成 RDB 文件之后的写操作并没有记录到刚刚的 RDB 文件中,为了保证主从库数据的一致性,所以主库会在内存中使用一个叫 replication buffer 记录 RDB 文件生成后的所有写操作。并将里面的数据发送到 slave。

在这里插入图片描述

网络断开重新连接同步?

增量复制:用于网络中断等情况后的复制,只将中断期间主节点执行的写命令发送给从节点,与全量复制相比更加高效

断开重连增量复制的实现奥秘就是 repl_backlog_buffer 缓冲区,不管在什么时候 master 都会将写指令操作记录在 repl_backlog_buffer 中,因为内存有限, repl_backlog_buffer 是一个定长的环形数组,如果数组内容满了,就会从头开始覆盖前面的内容

master 使用 master_repl_offset记录自己写到的位置偏移量,slave 则使用slave_repl_offset记录已经读取到的偏移量。

当主从断开重连后,slave 会先发送 psync 命令给 master,同时将自己的 runIDslave_repl_offset发送给 master。

master 只需要把 master_repl_offsetslave_repl_offset之间的命令同步给从库即可。

在这里插入图片描述

在这里插入图片描述

主从正常连接情况下的同步?
当主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为基于长连接的命令传播,使用长连接的目的就是避免频繁建立连接导致的开销。

10.哨兵模式?

哨兵是 Redis 的一种运行模式,它专注于对 Redis 实例(主节点、从节点)运行状态的监控,并能够在主节点发生故障时通过一系列的机制实现选主及主从切换,实现故障转移,确保整个 Redis 系统的可用性

Redis 哨兵具备的能力有如下几个:

  • 监控:持续监控 master 、slave 是否处于预期工作状态。
  • 自动切换主库:当 Master 运行故障,哨兵启动自动故障恢复流程:从 slave 中选择一台作为新 master。
  • 通知:让 slave 执行 replicaof ,与新的 master 同步;并且通知客户端与新 master 建立连接。

哨兵之间是如何知道彼此的?哨兵与 master 建立通信,利用 master 提供发布/订阅机制发布自己的信息,比如身高体重、是否单身、IP、端口……

master 有一个 __sentinel__:hello 的专用通道,用于哨兵之间发布和订阅消息。这就好比是 __sentinel__:hello 微信群,哨兵利用 master 建立的微信群发布自己的消息,同时关注其他哨兵发布的消息

哨兵如何知道slave?

关键还是利用 master 来实现,哨兵向 master 发送 INFO 命令, master 掌门自然是知道自己门下所有的 salve 小弟的。所以 master 接收到命令后,便将 slave 列表告诉哨兵。

哨兵根据 master 响应的 slave 名单信息与每一个 salve 建立连接,并且根据这个连接持续监控哨兵。

11.Cluster原理?

除了哨兵模式外还有啥高可用的手段?

有 Cluster 集群实现高可用,哨兵集群监控的 Redis 集群是主从架构,无法横向拓展。**使用 Redis Cluster 集群,主要解决了大数据量存储导致的各种慢问题,同时也便于横向拓展。**在面向百万、千万级别的用户规模时,横向扩展的 Redis 切片集群会是一个非常好的选择。

什么是Cluster集群?

Redis 集群是一种分布式数据库方案,集群通过分片(sharding)来进行数据管理(「分治思想」的一种实践),并提供复制和故障转移功能。

将数据划分为 16384 的 slots,每个节点负责一部分槽位。槽位的信息存储于每个节点中。

它是去中心化的,如图所示,该集群由三个 Redis 节点组成,每个节点负责整个集群的一部分数据,每个节点负责的数据多少可能不一样。

三个节点相互连接组成一个对等的集群,它们之间通过 Gossip协议相互交互集群信息,最后每个节点都保存着其他节点的 slots 分配情况。

在这里插入图片描述

哈希槽又是如何映射到 Redis 实例上呢?

  1. 根据键值对的 key,使用 CRC16 算法,计算出一个 16 bit 的值;
  2. 将 16 bit 的值对 16384 执行取模,得到 0 ~ 16383 的数表示 key 对应的哈希槽。
  3. 根据该槽信息定位到对应的实例。

在这里插入图片描述

Cluster 如何实现故障转移?

Redis 集群节点采用 Gossip 协议来广播自己的状态以及自己对整个集群认知的改变。比如一个节点发现某个节点失联了 (PFail),它会将这条信息向整个集群广播,其它节点也就可以收到这点失联信息。

如果一个节点收到了某个节点失联的数量 (PFail Count) 已经达到了集群的大多数,就可以标记该节点为确定下线状态 (Fail),然后向整个集群广播,强迫其它节点也接收该节点已经下线的事实,并立即对该失联节点进行主从切换。

客户端又怎么确定访问的数据分布在哪个实例上呢?

Redis 实例会将自己的哈希槽信息通过 Gossip 协议发送给集群中其他的实例,实现了哈希槽分配信息的扩散。

这样,集群中的每个实例都有所有哈希槽与实例之间的映射关系信息。

当客户端连接任何一个实例,实例就将哈希槽与实例的映射关系响应给客户端,客户端就会将哈希槽与实例映射信息缓存在本地。

当客户端请求时,会计算出键所对应的哈希槽,再通过本地缓存的哈希槽实例映射信息定位到数据所在实例上,再将请求发送给对应的实例。

在这里插入图片描述

Redis重定向机制?

哈希槽与实例之间的映射关系由于新增实例或者负载均衡重新分配导致改变了,客户端将请求发送到实例上,这个实例没有相应的数据,该 Redis 实例会告诉客户端将请求发送到其他的实例上

Redis 通过 MOVED 错误和 ASK 错误告诉客户端。

MOVED 错误(负载均衡,数据已经迁移到其他实例上):当客户端将一个键值对操作请求发送给某个实例,而这个键所在的槽并非由自己负责的时候,该实例会返回一个 MOVED 错误指引转向正在负责该槽的节点。

ASK槽部分迁移未完成的情况下,如果需要访问的 key 所在 Slot 正在从 实例 1 迁移到 实例 2(如果 key 已经不在实例 1),实例 1 会返回客户端一条 ASK 报错信息:客户端请求的 key 所在的哈希槽正在迁移到实例 2 上,你先给实例 2 发送一个 ASKING 命令,接着发发送操作命令

12.分布式锁?

背景:

与分布式锁相对应的是「单机锁」,我们在写多线程程序时,避免同时操作一个共享变量产生数据问题,通常会使用一把锁来「互斥」,以保证共享变量的正确性,其使用范围是在「同一个进程」中。

如果换做是多个进程,需要同时操作一个共享资源,如何互斥呢?

例如,现在的业务应用通常都是微服务架构,这也意味着一个应用会部署多个进程,那这多个进程如果需要修改 MySQL 中的同一行记录时,为了避免操作乱序导致数据错误,此时,我们就需要引入「分布式锁」来解决这个问题了。

SETNX命令
SETNX key value

将 key 的值设为 value,当且仅当 key 不存在。

若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是SET if Not eXists的简写。

返回值

返回整数,具体为
- 1,当 key 的值被设置
- 0,当 key 的值没被设置

使用SETNX实现分布式锁

多个进程执行以下Redis命令:

SETNX lock.foo <current Unix time + lock timeout + 1>

如果 SETNX 返回1,说明该进程获得锁,SETNX将键 lock.foo 的值设置为锁的超时时间(当前时间 + 锁的有效时间)。
如果 SETNX 返回0,说明其他进程已经获得了锁,进程不能进入临界区。进程可以在一个循环中不断地尝试 SETNX 操作,以获得锁。

执行步骤

线程A调用setnx命令,设置key为lock.foo(所有线程要用同样的key,否则就不是一个锁了),值为current Unix time + lock timeout + 1,即当前时间加上加锁时长,最终的值也就是过期时间。如果A对锁的持有结束,则可自行调用del lock.foo来释放锁。

A持有锁的过程中线程B在调用命令SETNX lock.foo,会得到返回值0,这说明这个锁已经被其他线程获取,这时B应该去获取lock.foo的值,看看是否小于当前时间,如果大于则锁未过期,B需要继续循环等待检查或者做其他操作;如果小于则锁已过期,B可以用del lock.foo方法去删除锁,然后在SETNX lock.foo 来获取锁。

这样就完成了分布式锁的最基本的模型,并且避免了因A线程挂掉无法释放锁而导致的死锁问题。

死锁问题

考虑一种情况,如果进程获得锁后,断开了与 Redis 的连接(可能是进程挂掉,或者网络中断),如果没有有效的释放锁的机制,那么其他进程都会处于一直等待的状态,即出现“死锁”。

上面在使用 SETNX 获得锁时,我们将键 lock.foo 的值设置为锁的有效时间,进程获得锁后,其他进程还会不断的检测锁是否已超时,如果超时,那么等待的进程也将有机会获得锁。

然而,锁超时时,我们不能简单地使用 DEL 命令删除键 lock.foo 以释放锁。考虑以下情况,进程P1已经首先获得了锁 lock.foo,然后进程P1挂掉了。进程P2,P3正在不断地检测锁是否已释放或者已超时,执行流程如下:

  • P2和P3进程读取键 lock.foo 的值,检测锁是否已超时(通过比较当前时间和键 lock.foo 的值来判断是否超时)
  • P2和P3进程发现锁 lock.foo 已超时
  • P2执行 DEL lock.foo命令
  • P2执行 SETNX lock.foo命令,并返回1,即P2获得锁
  • P3执行 DEL lock.foo命令将P2刚刚设置的键 lock.foo 删除(这步是由于P3刚才已检测到锁已超时)
  • P3执行 SETNX lock.foo命令,并返回1,即P3获得锁
  • P2和P3同时获得了锁

从上面的情况可以得知,在检测到锁超时后,进程不能直接简单地执行 DEL 删除键的操作以获得锁。

解决死锁

为了解决上述算法可能出现的多个进程同时获得锁的问题,我们再来看以下的算法。
我们同样假设进程P1已经首先获得了锁 lock.foo,然后进程P1挂掉了。接下来的情况:

  • 进程P4执行 SETNX lock.foo 以尝试获取锁
  • 由于进程P1已获得了锁,所以P4执行 SETNX lock.foo 返回0,即获取锁失败
  • P4执行 GET lock.foo 来检测锁是否已超时,如果没超时,则等待一段时间,再次检测
  • 如果P4检测到锁已超时,即当前的时间大于键 lock.foo 的值,P4会执行以下操作

GETSET lock.foo <current Unix timestamp + lock timeout + 1>

  • 由于 GETSET 操作在设置键的值的同时,还会返回键的旧值,通过比较键 lock.foo 的旧值是否小于当前时间,可以判断进程是否已获得锁
  • 假如另一个进程P5也检测到锁已超时,并在P4之前执行了 GETSET 操作,那么P4的 GETSET 操作返回的是一个大于当前时间的时间戳,这样P4就不会获得锁而继续等待。注意到,即使P4接下来将键 lock.foo 的值设置了比P5设置的更大的值也没影响。

另外,值得注意的是,在进程释放锁,即执行 DEL lock.foo 操作前,需要先判断锁是否已超时。如果锁已超时,那么锁可能已由其他进程获得,这时直接执行 DEL lock.foo 操作会导致把其他进程已获得的锁释放掉。

13.Redis缓存雪崩和穿透?

缓存击穿问题:
背景:

缓存穿透的概念很简单,用户想要查询一个数据,发现redis内存数据库没有,也就是缓存没有命中,于是向持久层数据库查询。发现也没有,于是本次查询失败。当用户很多的时候,缓存都没有命中,于是都去请求了持久层数据库。这会给持久层数据库造成很大的压力,这时候就相当于出现了缓存穿透。

解决方法:

1.布隆过滤器

参考:https://www.cnblogs.com/cpselvis/p/6265825.html

目的判断一个元素是否存在一个集合中?

常规思路

  • 数组
  • 链表
  • 树、平衡二叉树、Trie
  • Map (红黑树)
  • 哈希表

虽然上面描述的这几种数据结构配合常见的排序、二分搜索可以快速高效的处理绝大部分判断元素是否存在集合中的需求。但是当集合里面的元素数量足够大,如果有500万条记录甚至1亿条记录呢?这个时候常规的数据结构的问题就凸显出来了。数组、链表、树等数据结构会存储元素的内容,一旦数据量过大,消耗的内存也会呈现线性增长,最终达到瓶颈。有的同学可能会问,哈希表不是效率很高吗?查询效率可以达到O(1)。但是哈希表需要消耗的内存依然很高。使用哈希表存储一亿 个垃圾 email 地址的消耗?哈希表的做法:首先,哈希函数将一个email地址映射成8字节信息指纹;考虑到哈希表存储效率通常小于50%(哈希冲突);因此消耗的内存:8 * 2 * 1亿 字节 = 1.6G 内存,普通计算机是无法提供如此大的内存。这个时候,布隆过滤器(Bloom Filter)就应运而生。在继续介绍布隆过滤器的原理时,先讲解下关于哈希函数的预备知识。

原理:

在这里插入图片描述

布隆过滤器(Bloom Filter)的核心实现是一个超大的位数组和几个哈希函数。假设位数组的长度为m,哈希函数的个数为k。

以上图为例,具体的操作流程:假设集合里面有3个元素{x, y, z},哈希函数的个数为3。首先将位数组进行初始化,将里面每个位都设置位0。对于集合里面的每一个元素,将元素依次通过3个哈希函数进行映射,每次映射都会产生一个哈希值,这个值对应位数组上面的一个点,然后将位数组对应的位置标记为1。查询W元素是否存在集合中的时候,同样的方法将W通过哈希映射到位数组上的3个点。如果3个点的其中有一个点不为1,则可以判断该元素一定不存在集合中。反之,如果3个点都为1,则该元素可能存在集合中。注意:此处不能判断该元素是否一定存在集合中,可能存在一定的误判率。可以从图中可以看到:假设某个元素通过映射对应下标为4,5,6这3个点。虽然这3个点都为1,但是很明显这3个点是不同元素经过哈希得到的位置,因此这种情况说明元素虽然不在集合中,也可能对应的都是1,这是误判率存在的原因。

2.缓存空对象

当存储层不命中后,即使返回的空对象也将其缓存起来,同时会设置一个过期时间,之后再访问这个数据将会从缓存中获取,保护了后端数据源;

在这里插入图片描述

缓存雪崩问题:

缓存雪崩是指,缓存层出现了错误,不能正常工作了。于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会挂掉的情况。

1.搭建Redis集群:这个思想的含义是,既然redis有可能挂掉,那我多增设几台redis,这样一台挂掉之后其他的还可以继续工作,其实就是搭建的集群。

2.限流降级:这个解决方案的思想是,在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。

3.数据预热:数据加热的含义就是在正式部署之前,我先把可能的数据先预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中。在即将发生大并发访问前手动触发加载缓存不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。

14.Redis 与 MySQL双写一致性如何保证?

参考:微信公众号捡田螺的小男孩

14.1一致性问题
  • 强一致性:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
  • 弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
  • 最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型
14.2三个经典的缓存模式
①Cache-Aside Pattern旁路缓存模式

它的提出是为了尽可能地解决缓存与数据库的数据不一致问题。

Cache-Aside读请求

  1. 读的时候,先读缓存,缓存命中的话,直接返回数据

  2. 缓存没有命中的话,就去读数据库,从数据库取出数据,放入缓存后,同时返回响应。

    Cache-Aside读请求

    更新的时候,先更新数据库,然后再删除缓存

②Read-Through/Write-Through:读写穿透

Read-Through

​ 1.从缓存读取数据,读到直接返回

​ 2.如果读取不到的话,从数据库加载,写入缓存后,再返回响应。

在这里插入图片描述

Read-Through实际只是在Cache-Aside之上进行了一层封装,它会让程序代码变得更简洁,同时也减少数据源上的负载。

Write-Through

Write-Through模式下,当发生写请求时,也是由缓存抽象层完成数据源和缓存数据的更新,流程如下:

在这里插入图片描述

③Write-behind :异步缓存写入

Write-behind 跟Read-Through/Write-Through有相似的地方,都是由Cache Provider来负责缓存和数据库的读写。它们又有个很大的不同:Read/Write-Through是同步更新缓存和数据的,Write-Behind则是只更新缓存,不直接更新数据库,通过批量异步的方式来更新数据库。

在这里插入图片描述

这种方式下,缓存和数据库的一致性不强,对一致性要求高的系统要谨慎使用。但是它适合频繁写的场景,MySQL的InnoDB Buffer Pool机制就使用到这种模式。

14.3操作缓存的时候,到底是删除缓存呢,还是更新缓存?

日常开发中,我们一般使用的就是Cache-Aside模式。Cache-Aside在写入请求的时候,为什么是删除缓存而不是更新缓存**呢?

在这里插入图片描述

  1. 线程A先发起一个写操作,第一步先更新数据库
  2. 线程B再发起一个写操作,第二步更新了数据库
  3. 由于网络等原因,线程B先更新了缓存
  4. 线程A更新缓存。

这时候,缓存保存的是A的数据(老数据),数据库保存的是B的数据(新数据),数据不一致了,脏数据出现啦。如果是删除缓存取代更新缓存则不会出现这个脏数据问题。

14.4 双写的情况下,先操作数据库还是先操作缓存?

Cache-Aside缓存模式中,在写请求过来的时候,为什么是先操作数据库呢?为什么不先操作缓存呢?

假设有A、B两个请求,请求A做更新操作,请求B做查询读取操作。

在这里插入图片描述

  1. 线程A发起一个写操作,第一步del cache
  2. 此时线程B发起一个读操作,cache miss
  3. 线程B继续读DB,读出来一个老数据
  4. 然后线程B把老数据设置入cache
  5. 线程A写入DB最新的数据

酱紫就有问题啦,缓存和数据库的数据不一致了。缓存保存的是老数据,数据库保存的是新数据。因此,Cache-Aside缓存模式,选择了先操作数据库而不是先操作缓存。

14.4先操作数据库后缓存造成不一致怎么办?

先操作数据库再操作缓存,不一样也会导致数据不一致嘛?它俩又不是原子性操作的。这个是会的,但是这种方式,一般因为删除缓存失败等原因,才会导致脏数据,这个概率就很低接下来我们再来分析这种删除缓存失败的情况,如何保证一致性

在这里插入图片描述

有些朋友可能认为,在第2步删除缓存之前,线程B读过来呢?这时候,读到的是缓存老数据,这个可以认为是正常业务逻辑呀,下次再读取就是正确数据了。

这种方案****「没有明显的并发问题」,*但是呢,「步骤二删除缓存失败」*,也会造成缓存和数据库不一致的情况。

14.5三种方案保持数据库和缓存的一致性?

实际上,没办法做到数据库与缓存绝对的一致性。其实,这是由CAP理论决定的。缓存系统适用的场景就是非强一致性的场景,它属于CAP中的AP。个人觉得,追求绝对一致性的业务场景,不适合引入缓存。CAP理论,指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。

三种方案保证数据库和缓存的一致性

①缓存延时双删策略

先操作缓存也是可以的。

  1. 先删除缓存
  2. 再更新数据库
  3. 休眠一会(比如1秒),再次删除缓存

这个休眠时间 = 读业务逻辑数据的耗时 + 几百毫秒。为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。

但是也存在第二次删除缓存失败的风险。

②删除缓存重试机制

不管是延时双删还是Cache-Aside的先操作数据库再删除缓存,都可能会存在第二步的删除缓存失败,导致的数据不一致问题。可以使用这个方案优化:删除失败就多删除几次呀,保证删除缓存成功就可以了呀~ 所以可以引入删除缓存重试机制

在这里插入图片描述

③读取binlog异步删除缓存

在这里插入图片描述

以mysql为例吧

  • 可以使用阿里的canal将binlog日志采集发送到MQ队列里面
  • 然后通过ACK机制确认处理这条更新消息,删除缓存,保证数据缓存一致性

15.Redis事务?

Redis 事务可以一次执行多个命令, 并且带有以下两个重要的保证:

  • 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
  • 事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。

一个事务从开始到执行会经历以下三个阶段:

  • 开始事务multi。
  • 命令入队。
  • 执行事务exec。

注意:

  • 当命令本身没错,因为操作的数据结构引发的错误,则执行这个命令出现错误,且之前和之后的命令都会正常执行。
127.0.0.1:6379> FLUSHDB
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key1 value1
QUEUED
127.0.0.1:6379> SET key2 value2
QUEUED
127.0.0.1:6379> INCR key1
QUEUED
127.0.0.1:6379> DEL key2
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) OK
3) (error) ERR value is not an integer or out of range
4) (integer) 1
127.0.0.1:6379> GET key1
"value1"
127.0.0.1:6379> GET key2
(nil)
127.0.0.1:6379> 
我们将 key1 设置为字符串,而使用命令 incr 对其自增,但是命令只会进入事务队列,而没有被执行,所以它不会有任何的错误发生,而是等待 exec 命令的执行。
当 exec 命令执行后,之前进入队列的命令就依次执行,当遇到 incr 时发生命令操作的数据类型错误,所以显示出了错误,而其之前和之后的命令都会被正常执行.
  • 命令本身出错

    127.0.0.1:6379> FLUSHDB
    OK
    127.0.0.1:6379> MULTI
    OK
    127.0.0.1:6379> set key1 value1
    QUEUED
    127.0.0.1:6379> incr
    (error) ERR wrong number of arguments for 'incr' command
    127.0.0.1:6379> set key2 value2
    QUEUED
    127.0.0.1:6379> EXEC
    (error) EXECABORT Transaction discarded because of previous errors.
    127.0.0.1:6379> GET key1
    (nil)
    127.0.0.1:6379> GET key2
    (nil)
    127.0.0.1:6379> 
    可以看到我们使用的 incr 命令格式是错误的,这个时候 Redis 会立即检测出来并产生错误,而在此之前我们设置了 keyl , 在此之后我们设置了 key2 a 当事务执行的时候,我们发现 keyl 和 key2 的值都为空,说明被 Redis 事务回滚了。
    

    乐观锁问题

    乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。

    乐观锁主要用于抢红包,淘宝抢购,秒杀之类

    redis实现乐观锁,主要使用watch监控一个key的状态实现乐观锁。

    例子:

    线程一:
    127.0.0.1:6379> watch money   #监视money
    OK
    127.0.0.1:6379> MULTI
    OK
    127.0.0.1:6379> DECRBY money 10
    QUEUED
    127.0.0.1:6379> DECRBY out 10    #事务还没执行,线程2修改money为1000,此时再执行exec,会执行失败
    QUEUED
    127.0.0.1:6379> exec    
    (nil)
    
    线程2:
    127.0.0.1:6379> get money
    "80"
    127.0.0.1:6379> set money 1000
    OK
    

    原理:先去监视money,当我们在事务中执行时,会去比较值钱监视的money值,如果改变了,则执行失败

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值