4、Redis 场景

1、缓存基础

服务端:服务端将数据存入 Redis,可以在访问 DB 之后将数据缓存,或者在回包时将回包内容以请求参数为 Key 缓存

  • Cache-Aside Pattern:旁路缓存模式
  • Read Through Cache Pattern:读穿透模式
  • Write Through Cache Pattern:写穿透模式
  • Write Behind Pattern:又叫 Write Back,异步缓存写入模式

1.1、旁路缓存

Cache Aside 即旁路缓存模式,是最常见的模式,应用服务把缓存当作数据库的旁路,直接和缓存进行交互
适用于「读多写少」的场景,比如用户信息、新闻报道等,一旦写入缓存,几乎不会进行修改,该模式的缺点是:可能会出现缓存和数据库不一致的情况

读流程

应用服务收到查询请求后,先查询数据是否在缓存上

  • 如果在:就用缓存数据直接打包返回
  • 如果不在:就去访问数据库,从数据库查询,并放到缓存中,除了查库后加载这种模式,如果业务有需要,还可以预加载数据到缓存

image

写流程

在写操作的时候,Cache Aside 模式是一般是先更新数据库,然后直接删除缓存,为什么不直接更新呢:因为更新相比删除会更容易造成时序性问题
时序性问题:thread1 更新 mysql 为 5 -> thread2 更新 mysql 为 3 -> thread2 更新缓存为 3 -> thread1 更新缓存为 5,最终正确的数据因为时序性被覆盖了

image

1.2、读穿透

Read-Through 读穿透模式,和 Cache Aside 模式的区别主要在于:应用服务不再和缓存直接交互,而是直接访问数据服务
数据服务可以理解为一个代理,即单独起这么一个服务,由它来访问数据库和缓存,作为使用者来看,不知道里面到底有没有缓存,数据服务会自己来根据情况查询缓存或者数据库

查询的时候和 Cache Aside 一样,也是缓存中有,就用从缓存中获得的数据,没有就查 DB,只不过这些由数据服务托管保存,而对应用服务是透明的
相比 Cache Aside,Read Through 的优势是缓存对业务透明,业务代码更简洁,缺点是缓存命中时性能不如 Cache Aside,相比直接访问缓存,还会多一次服务间调用
image

1.3、写穿透

在 Cache Aside 中,应用程序需要维护两个数据存储:一个缓存 + 一个数据库,这对于应用程序来说,更新操作比较麻烦,还要先更新数据库,再去删除缓存
WriteThrough 模式相当于做了一层封装:由这个存储服务先写入 MySQL,再同步写入 Redis,这样及时加载或更新了缓存数据
可以理解为,应用程序只有一个单独的访问源,而存储服务自己维护访问逻辑

当使用 Write-Through 时,一般都配合使用 Read-Through 来使用,Write-Through 的潜在使用场景是银行系统,Write-Through 适用情况有

  • 对缓存及时性要求更高(写入就加载了缓存,当然这种模式可能会有时序性问题)
  • 不能認受数据丢失(相对 Write-Behind 而言)和数据不一致,当然 Cache Aside 也是如此

在使用 Write-Through 时要特别注意的是缓存的有效性管理,否则会导致大量的缓存占用内存资源,因为这种模式下只要写入数据就加载了缓存
image

1.4、异步缓存写入

Write-Behind 和 Write-Through 相同点都是写入时候会更新数据库、也会更新缓存
不同点在于 Write-Through 会把数据立即写入数据库中,然后写缓存,安全性很高
而 Write-Behind 是先写缓存,然后异步把数据一起写入数据库,这个异步写操作是 Write-Behind 的最大特点

数据库写操作可以用不同的方式完成,两者是可以根据业务情况结合的

  • 收集写操作并在某一时间点(比如数据库负载低的时候)慢慢写入
  • 合并几个写操作成为一个批量操作,一起批量写入

异步写操作极大地降低了请求延迟并减轻了数据库的负担,但是代价是安全性不够
比如先写入了 Redis,更新操作先放在存储服务内存中,但是还没异步写入 MySQL 之前,存储服务崩溃了,那么数据也就丢失了
image

2、缓存异常

2.1、缓存穿透

问题描述

缓存和数据库中都没有的数据,而用户不断发起请求
如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义
在流量大时,可能 DB 就挂掉了,要是有人利用不存在的 key 频繁攻击我们的应用,这就是漏洞
如发起为 id 为 "-1" 的数据或 id 为特别大不存在的数据,这时的用户很可能是攻击者,攻击会导致数据库压力过大
image

解决方案

  • 接口层增加校验,如用户鉴权校验,id 做基础校验,id <= 0 的直接拦截
  • 从缓存取不到的数据,在数据库中也没有取到
    这时也可以将 key-value 写为 key-null,缓存有效时间可以设置短点,如 30 秒(设置太长会导致正常情况也没法使用)
    这样可以防止攻击用户反复用同一个 id 暴力攻击
  • 布隆过滤器:bloomfilter 就类似于一个 hash set,用于快速判某个元素是否存在于集合中
    其典型的应用场景就是快速判断一个 key 是否存在于某容器,不存在就直接返回,布隆过滤器的关键就在于 hash 算法和容器大小
    布隆过滤器是一种比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉我们 "某样东西一定不存在或者可能存在"

image

布隆过滤器原理 布隆过滤器使用

布隆过滤器底层是一个 64 位的整型,将字符串用多个 Hash 函数映射不同的二进制位置,将整型中对应位置设置为 1
在查询的时候,如果一个字符串所有 Hash 函数映射的值都存在,那么数据可能存在,为什么说可能呢,就是因为其它字符可能占据该值,提前点亮
可以看到,布隆过滤器优缺点都很明显,优点是空间、时间消耗都很小,缺点是结果不是完全准确
image

2.2、缓存击穿

问题描述

指缓存中没有但数据库中有的数据(一般是缓存时间到期)
这时由于并发用户特别多,读缓存没读到数据,又去数据库取数据,引起数据库压力瞬间增大
缓存击穿一般是:热键在过期失效的一瞬间,还没来得及重新产生,就有海量数据,直达数据库

解决方案

  • 热点数据支持续期,持续访问的数据可以不断续期,避免因为过期失效而被击穿(只要有请求访问,就增加过期时间)
  • 发现缓存失效,重建缓存加互斥锁
    当线程查询缓存,发现缓存不存在,就会尝试加锁,线程争抢锁,拿到锁的线程就会进行查询数据库,然后重建缓存
    争抢锁失败的线程,你可以加一个睡眠然后循环重试

image

2.3、缓存雪崩

问题描述

大量的应用请求因为异常无法在 Redis 缓存中进行处理,像雪崩一样,直接打到数据库
这里异常的原因,也可以说雪崩的原因,主要是:缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至宕机

其实在一些资料里,会把 Redis 宕机算进来,原因是 Redis 宕机了也就无法处理缓存请求
但这里会觉得有些牵强,如果这里能算,缓存击穿不也可以算?所以这里建议是不把宕机考虑到雪崩里去
和缓存击穿不同的是,缓存击穿指一条热点数据在 Redis 没得到及时重建,缓存雪崩是一大批数据在 Redis 同时失效

解决方案

  • 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生
  • 重建缓存加互斥锁
    当线程查询缓存,发现缓存不存在,就会尝试加锁,线程争抢锁,拿到锁的线程就会进行查询数据库,然后重建缓存
    争抢锁失败的线程,你可以加一个睡眠然后循环重试

3、缓存一致性

Redis 作为 MySQL 的缓存,如果 MySQL 的数据更新了,Redis 的数据该怎么保持最终一致

  • 更新 MySQL 即可,不管 Redis,以过期时间兜底
  • 更新 MySQL 之后,操作 Redis
  • 异步将 MySQL 的更新同步到 Redis

3.1、方案一

使用 redis 的过期时间,mysql 更新时,redis 不做处理,等待缓存过期失效,再从 mysql 拉取缓存
这种方式实现简单,但不一致的时间会比较明显,具体由你的业务来配置:如果读请求非常频繁,且过期时间设置较长,则会产生很多脏数据

优点:redis 原生接口,开发成本低,易于实现;管理成本低,出问题的概率会比较小
不足:完全依赖过期时间,时间太短容易造成缓存频繁失效,太长容易有较长时间不一致,对编程者的业务能力有一定要求

3.2、方案二

不光通过 key 的过期时间兜底,还需要在更新 mysql 时,同时尝试操作 redis,这里的操作分两种方式
直接将结果写入 Redis,但实际上很少用更新,而是用删除,等待下次访问再加载回来,因为更新容易带来时序性问题

上面有提到,这里是尝试删除,可能这一步操作失败了,失败就我们可以忽略,也就是不能让删除成为一个关键路径,影响核心流程
因为我们有 key 本身的过期时间作为保障,所以最终一致性是一定达成的,主动删除 redis 数据只是为了减少不一致的时

优点

  • 相对方案一,达成最终一致性的延迟更小
  • 实现成本较低,只是在方案一的基础上,增加了删除逻辑

不足

  • 如果更新 mysql 成功,删除 redis 却失败,就退化到了方案一
  • 在更新时候需要额外操作 redis,带来了损耗

3.3、方案三

把我们搭建的消费服务作为 mysql 的一个 slave,订阅 mysql 的 binlog 日志,解析日志内容,再更新到 redis
此方案和业务完全解耦,redis 的更新对业务方透明,可以减少心智成本

优点

  • 和业务完全解耦,在更新 mysql 时,不需要做额外操作
  • 无时序性问题,可靠性强

缺点

  • 引入了消息队列这种算比较重的组件,还要单独搭建一个同步服务,维护他们是非常大的额外成本
  • 同步服务如果压力比较大,或者崩溃了,那么在较长时间内,redis 中都是老旧数据

4、分布式锁

Redis + Lua 实现分布式锁:set lock owner nx ex 3
setnx 是没办法携带过期时间参数的,如果 setnx + expire 2 个命令,就没办法保证加锁的原子性了,所以要用 set 命令,携带 nx 和 px 参数,才能保证加锁的原子性

检查 owner 是当前线程后,因为操作不具有原子性,正好在准备删除锁时,锁过期并且被其它线程抢到锁,就会造成锁的误删
所以要保证检查 owner 和删除锁这两个操作是原子的,否则在两个操作的间隙会出问题

Lua 本身不具备原子性,上面提到用 Lua 来保证原子性是因为
Redis 是单线程执行,一个流程放进 Lua 来执行,相当于是打包在一起,Redis 执行它的过程中不会被其它请求打断,所以说保证了原子性
这里我们也提到,我们是在释放的时候将查询 key、删除 key 打包到一起,其中只有最后删除是写操作,所以这个流程本身是保证了原子性的

加锁用 set key value nx ex time:key 是锁名称、value 是持有者 id、time 是过期时间
解锁要判断 value 为自己再解锁(Lua 保证原子性)if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

image

posted @ 2023-10-06 16:50  lidongdongdong~  阅读(14)  评论(0编辑  收藏  举报