【Redis】应用[2]

发布时间 2023-12-27 00:59:22作者: H-utopia

1、缓存设计

Redis常用来做数据库的缓存,应用先到Redis读取数据,缓存不存在的话才会去访问数据库,拿到数据后将数据缓存在Redis中,这样后续请求可以直接命中缓存,减少访问数据库的次数。

1.1、缓存失效

缓存雪崩

通常缓存在Redis中的数据会设置过期时间,那么,当大量的缓存数据在同一时间过期时,此时大量的用户请求都会直接访问数据库,导致数据库的压力骤增,这就是缓存雪崩。

通常的应对方案有:

  • 将缓存过期时间随机打散:在原先的过期时间上增加一个小的随机值,降低缓存集体过期的概率
  • 设置缓存不过期:通过后台服务显式地来更新缓存数据

缓存击穿

业务系统中经常有部分数据会被频繁访问,这类数据称为热点数据,如果缓存中的某个热点数据过期,此时大量的请求也会直接访问到数据库,数据库很容易被高并发的大量请求击垮,这就是缓存击穿。

有些类似与缓存雪崩,通常的应对方案:

  • 互斥锁方案:保证同一时间只有一个业务线程请求缓存,其他未能获取互斥锁的请求,要么等待锁释放后重新获取,要么返回空值或者默认值
  • 不给热点数据设置过期时间,由后台线程异步更新
  • 在热点数据过期之前,提前通知后台线程更新缓存并重新设置过期时间

缓存穿透

当用户访问的数据,即不在缓存中,也不在数据库中,那么大量的这一类请求,就会不停地访问数据库,这就是缓存穿透。

缓存穿透的发生一般有两种情况:

  • 业务误操作,将缓存和数据库中的数据删除了
  • 恶意攻击,故意访问大量不存在数据的业务数据

通常的应对方案有:

  • 参数校验,对非法请求的限制:在API入口处判断请求的参数是否合理,针对非法请求直接返回错误
  • 设置空值或者默认值:可以针对查询的不存在数据,在缓存中设置一个空值或者默认值
  • 布隆过滤器:在数据库写入数据时,使用布隆过滤器标记,业务请求在缓存中查不到数据后,先通过查询布隆过滤器来快速判断数据是否存在,不存在的话就不需要去查询数据库,这样也能减少缓存穿透下数据库的压力

附:布隆过滤器

布隆过滤器存在误判的可能性,即查询到存在的数据实际可能不存在(小概率),但布隆过滤器查询到不存在的数据实际上一定不存在。

原理:用其中的哈希函数(一般有多个)对元素值进行计算,根据得到的结果,将Bitmap中对应下标的值记为1;判断元素是否存在时,用同样的哈希函数进行计算,得到的值在Bitmap中都为1的话,说明在布隆过滤器中存在,如果有一个值不为1,就说明该元素在布隆过滤器中不存在。

1.2、缓存更新策略

将一部分数据存在缓存中,可以减少数据库的压力,但也会带来缓存和数据库一致性的问题。

在实际开发中,Redis和MySQL的更新策略用的是 Cache Aside。

Cache Aside策略

原则是通过应用程序直接与数据库、缓存交互,并负责对缓存的维护,该策略可以细分为读和写两部分。

写策略的步骤:先更新数据库中的数据,再删除缓存中的数据

读策略的步骤:缓存未命中,则从数据库读取数据,然后将数据写入缓存,并返回给用户

写的时候不能先删除缓存再更新数据库

如果先删除缓存值A,还没有新值B写入数据库之前;其他线程读请求,由于缓存不命中,读取了数据库的旧值A,并将缓存中的值更新为A;然后写请求继续执行,将新值B写入数据库,此时数据库和缓存的数据会不一致,如图:

image-20231221172249387

为什么先更新数据库再删除缓存不会导致数据不一致

image-20231221172807430

如图,如果写请求发生在读请求的读数据库和更新缓存之间,也会导致最终缓存和数据库的数据不一致,但实际中还是会选择先写数据库、再删除缓存的做法。

因为缓存的写入速度通常要远远快于数据库的写入,因此实际中很难出现这样的情况,而一旦读请求在写请求删除缓存之前更新了缓存,后续也会因为缓存不命中而不产生影响。

Cache Aside 策略适合读多写少的情况,不适合写多的情况。因为当写入频繁时,缓存会被频繁清理,对命中率有影响,如果业务对缓存命中率有要求,可以考虑:

  • 一种是在更新数据时也更新缓存,只是在更新之前加一个分布式锁,同一时间保证只有一个线程更新缓存,不会产生并发问题,但会对写入的性能有一定影响
  • 一种也是在更新数据时更新缓存,但给缓存加一个较短的过期时间,这样即使数据不一致,缓存的数据也会很快过期,对业务的影响可以接受

Read/Write Through策略

原则是应用程序只和缓存交互,由缓存和数据库之间进行交互,更新数据库的操作由缓存代理。

Read Through策略:先查询缓存中是否存在,存在则直接返回;不存在则由缓存负责从数据库查询数据,并将结果写入缓存,最后缓存将数据返回给应用

Write Through策略:当有数据更新时,查询缓存中是否已经存在数据,如果已经存在,就更新缓存中的数据,并且由缓存组件将数据同步到数据库中,然后告知应用更新完成;如果缓存中不存在数据,则直接更新数据库并返回

此策略不经常使用的原因是常用的分布式缓存组件,MemCached、Redis这些,都不提供写入数据库和自动加载数据库中数据的功能,在使用本地缓存的时候可以考虑使用。

Write Back 策略

此策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立即返回,并不会更新数据库。对于数据库,会通过批量异步更新的方式进行。

Write Back策略特别适合写多的场景,但带来的问题是,数据不是强一致性的,而且会有数据丢失的风险。

常用的场景中,Redis也不提供异步更新数据库的功能;但在计算机体系结构中,例如CPU的缓存、操作系统中文件系统的缓存都采用了Write Back的策略。

2、分布式锁

分布式锁常用来在分布式环境中,控制某个资源在同一时刻只能被一个应用所使用。

Redis实现的分布式锁

Redis本就是分布式环境下的一个共享存储系统,而且其读写性能很高,可以应对高并发的锁操作场景;

Redis的set命令有个 NX 参数可以实现「key不存在时才可以插入」:

  • 如果key存在,则会显示插入失败,表示加锁失败
  • 如果key不存在,则会显示插入成功,表示加锁成功

基于Redis实现分布式锁时,对于加锁需要注意:

  • 加锁操作(读取锁变量、检查锁变量的值、设置锁)需要以原子操作的形式完成,所以使用SET命令带上NX选项
  • 锁变量需要设置过期时间,避免发生异常后锁无法释放,所以使用SET命令带上EX/PX选项
  • 锁变量的值需要能区分来自不同客户端的加锁操作,避免释放时出现误释放,所以使用SET命令设置锁时,每个客户端设置的值是一个唯一值,用于标识不同的客户端

最终加锁的命令为:

SET lock_key unique_value NX PX 10000

解锁的过程就是将lock_key键删除,但要保证执行操作的客户端就是加锁的客户端,所以解锁之前需要先判断锁的value值是否为加锁的客户端,是的话才可以删除;

可以看到,解锁包含两个操作,这就需要Lua脚本来保证操作的原子性(因为Redis在执行Lua脚本时,可以以原子性的方式执行):

if redis.call("get", KEYS[1]) == ARGV[1] then

	return redis.call("del", KEYS[1])

else

	return 0

end

基于Redis实现分布式锁的优点?

  • 性能高效(这是选择缓存实现分布式锁最核心的理由)
  • 实现方便(Redis提供的 setnx 方法)
  • 避免单点故障(Redis自身集群化部署)

基于Redis实现分布式锁的缺点?

  • 超时时间不好设置。设置时间过长,会影响性能,设置时间过短,起不到保护临界资源的目的;
    • 可以基于续约的方式设置过期时间:先给锁设置一个过期时间,然后启动一个守护线程,让守护线程去判断锁的情况,当锁快失效时,进行续约加锁,当主线程执行完成后,销毁续约锁即可。
  • Redis主从复制模式中的数据是异步复制的,会导致锁的不可靠性。例如在主节点获取到锁,在没有同步数据到其他从节点之前,主节点宕机,此时新的主节点仍可以获取到锁。

Redis如何解决集群情况下分布式锁的不可靠性?

Redis官方设计提供了一个分布式锁算法 Redlock(红锁)。

基于多个Redis节点实现,官方推荐至少部署5个节点,而且都是主节点,之间没有任何关系;

Redlock算法的基本思路:让客户端和多个独立的Redis节点依次请求申请加锁,如果客户端能和半数以上的节点成功加锁,那么就认为,客户端成功获得分布式锁,否则加锁失败。

加锁的三个过程:

  1. 客户端获取当前时间t1
  2. 客户端按顺序向N个Redis节点申请加锁:
    • 使用SET命令,带上NX,EX/PX,以及客户端的唯一标识
    • 为避免某个节点故障导致超时,需要给「加锁操作」设置超时时间,一般几十毫秒即可
  3. 一旦客户端从超过半数的Redis节点成功获取锁,就再次获取当前时间t2,然后计算加锁的总耗时 t2-t1,如果小于锁的过期时间,认为客户端加锁成功

加锁成功后,客户端需要重新计算锁的有效时间,由「锁最初设置的过期时间」减去「客户端加锁的总耗时」,如果计算的结果已经来不及完成后续对临界资源的操作,可以释放锁;

加锁失败后,客户端向所有的Redis节点发起释放锁的操作,每个节点上只需要执行释放锁的Lua脚本即可。

3、常见问题

如何动态缓存热点数据?

由于存储限制,系统并不会将所有数据存在缓存中,而只是将其中一部分的热点数据缓存起来,所以动态缓存的总体思路:通过数据最新访问时间来做排名,并过滤掉不常访问的数据,只留下经常访问的数据。

可以通过缓存实现一个排序队列,根据数据的访问时间更新队列信息,越是最近访问的数据排名越是靠前,可以用 zadd 和 zrange 方法来完成。

Redis如何实现延迟队列?

延迟队列的常见使用场景有:

  • 淘宝、京东等购物平台下单,超过一定时间未付款,订单自动取消
  • 打车、外卖平台,规定时间未接单,平台自动取消订单并提醒用户

Redis可使用有序集合 ZSet 的方式来实现延迟队列,用 score 属性来存储延迟执行的时间,简单来说:用 zadd score1 value1 来添加元素并设置执行时间,再利用 zrangebyscore 比对score和当前时间的大小,通过循环队列执行任务即可。

Redis的大key如何处理?

什么是大kay?

大key指的是key对应的value非常大,一般来说:String类型的值大于10KB / 集合类型的元素个数超过5000个;

大key会造成什么问题?

客户端阻塞超时、引发网络阻塞(每次获取大key产生的网络流量较大)、阻塞工作线程(使用del删除时,会阻塞主工作线程)、内存分布不均(集群切片的模型下,会出现数据和查询倾斜的情况,部分有大key的节点占用内存会比较多)

如何找到大key?

1、redis-cli --bigkeys

使用时需要注意:最好在从节点上执行,主节点执行会阻塞;如果没有从节点,可以选择在业务压力低峰阶段执行;

不足之处:只能得到每种类型中最大的那个big key;对于集合类型来说,只统计集合元素的个数,而不是实际占用内存大小;

2、使用 SCAN 命令

使用 scan 命令扫描数据库,然后用 type 命令获取返回的每一个key的类型。

  • 对于string类型,直接使用 STRLEN 命令获取字符串长度,即占用内存空间的字节数
  • 对于集合类型,如果可以预先从业务层面知道集合元素的平均大小,然后用命令(List类型—LLEN命令;Hash类型—HLEN命令;Set类型—SCARD命令;ZSet类型—ZCARD命令)获取集合元素个数即可得到集合占用内存大小;如果元素大小不可知,可以使用 MEMORY USAGE 命令查询一个键值对占用的空间

3、使用 RdbTools 工具

也可以使用第三方工具解析快照RDB文件,找到其中的大key

如何删除大key?

删除操作的本质是释放键值对占用的空间,释放内存只是第一步,为了高效地管理内存,操作系统会把释放掉的内存块插入到一个空闲内存块的链表,以便后续的管理和再分配,这个过程会阻塞当前释放内存的应用程序;

所以一次性释放大量内存,可能会造成Redis主线程的阻塞。

1、分批次删除

2、异步删除(Redis4.0版本之上)

Redis4.0版本之后,可以采用 unlink 命令来代替del,实现异步删除;

除了主动调用unlink,也可以配置参数,达到某些条件时自动进行异步删除,主要有4种场景,默认都是关闭的:

  • lazyfree-lazy-eviction:当Redis运行内存超过 maxmemory 时,是否开启
  • lazyfree-lazy-expire:设置了过期时间的键值,当过期之后是否开启
  • lazyfree-lazy-server-del:有些命令在处理已存在的键时,会带有隐式的del操作,比如 rename 命令;如果这些目标键是一个大key,此配置表示在这种场景下是否开启
  • lazyfree-lazy-flush:针对从节点进行全量数据同步,在加载主节点的RDB文件之前,会运行 flushall 来清理自身的数据,此配置表示此时是否开启

建议开启其中的lazyfree-lazy-evictionlazyfree-lazy-expirelazyfree-lazy-server-del,提高主线程的执行效率。

Redis的热key如何处理?

什么是热key?

简单来说就是某个key的访问次数明显高于其他key的话,这个key就可以视为热key。

热key会造成什么问题?

处理热key会占用大量的系统资源,是Redis性能的一个瓶颈,需要单独进行优化。

如何找到热key?

1、Redis自带的 --hotkeys 参数

2、MONITOR 命令是Redis提供的一种实时查看所有操作的方式,可以用来临时监控Redis实例的执行情况,一般用来临时查看,对性能影响较大。

3、使用开源工具

4、根据业务情况提前预估

5、在业务代码中添加相应的代码记录分析

如何解决热key?

  • 读写分离:总从节点之间读写分离
  • 使用切片集群模式:将热点数据分散存储在多个Redis节点上
  • 二级缓存:将热点数据存放一份到JVM本地内存中

慢查询命令

Redis大部分的命令都是O(1)级别的,但也有少部分O(n)或以上的命令,因此需要慢查询命令找到那些执行时间较长的命令。

redis.conf中的默认配置:

# 命令执行时间超过本值,会将该命令记录在慢查询日志中
slowlog-log-slower-than 10000
# 慢查询日志记录的最大条数
slowlog-max-len 128

Redis管道有什么用?

管道(Pipeline)是客户端提供的一种批处理技术,用来一次处理多个Redis命令。

使用管道技术可以解决多个命令执行时的网络等待,将多个命令整合后一起发送到服务端处理,提高了执行效率;但也要注意避免发送的命令过大,或管道内的数据过多而导致的网络阻塞。

管道技术实质上是客户端提供的功能,而非Redis服务器端的功能。

Redis事务支持回滚吗?

Redis并没有提供回滚机制,Redis提供的 DISCARD 命令只能用来主动放弃事务的执行,起不到回滚的效果。

4、Redis的常用场景

  • MySQL的缓存
  • 分布式锁
  • 网关限流
  • 延时队列(基于 Sorted Set 实现)
  • 利用Redis提供的数据结构,例如 Bitmap 统计活跃用户、Sorted Set维护排行榜等