redis核心技术与实战(二)缓存应用篇

redis核心技术与实战(二)缓存应用篇

文章目录

1.《旁路缓存:redis 在缓存中工作原理》

1.缓存的两个特征

1.什么是缓存,有什么特征?

磁盘->内存->cpu 之间读写速度差异巨大,为了平衡他们之间的差异,操作系统默认使用了两种缓存

  1. CPU 里面的末级缓存,即 LLC,用来缓存内存中的数据,避免每次从内存中存取数据;
  2. 内存中的高速页缓存,即 page cache,用来缓存磁盘中的数据,避免每次从磁盘中存取数据。

2.缓存的两个特征:

  1. 在一个层次化的系统中,缓存一定是一个快速子系统,数据存在缓存中时,能避免每次从慢速子系统中存取数据。对应到互联网应用来说,Redis 就是快速子系统,而数据库就是慢速子系统了。
  2. 缓存系统的容量大小总是小于后端慢速系统的,我们不可能把所有数据都放在缓存系统中,所以需要缓存淘汰机制。

3.redis缓存处理的两种情况:

  1. 缓存命中:缓存命中,直接在缓存中读写数据,读写速度快;
  2. 缓存缺失:数据在缓存中不存在,就去慢速子系统中查询,比如:基于磁盘存储的数据库;

2.redis 中的两种缓存

只读缓存和读写缓存

1.只读缓存:

​ 读操作,首先在redis中,缓存命中,返回,缓存缺失,去数据库中读,并更新一份到redis;

​ 写操作,直接写数据库,并删除redis 中的缓存;

优势:

​ 广泛使用的缓存模式,适合读多写少的场景;

​ 数据可靠性高,一切以数据库为基准;

劣势:写操作会使缓存失效,写操作效率不高;

2.读写缓存:

​ 读操作和只读缓存一样;

​ 写操作有两种回写方式:

​ 同步直写:同时发消息给redis 和数据库 ,同时执行 更新缓存和 更新数据库的操作;

​ 异步回写:直接更新redis,等到缓存满了,在把淘汰的数据写回数据库;

3.同步直写和异步回写的优劣势

异步回写:只操作缓存,读写效率极高,但是如果还没等到数据淘汰更新数据库,宕机就会导致关系型数据库与redis数据严重不一致;

同步直写:读效率高,但是由于写操作要求同时更新数据库和redis,写数据库会严重降低redis性能;

此外,还要求写数据库和写redis 操作同时更新成功,否则出现数据不一致的情况;

3.question: 只读缓存 与 读写缓存 写操作的区别?

a.只读缓存: 先修改数据库后更新缓存,数据库始终会使最新数据,数据可靠性高; 频繁写操作,会导致缓存频繁失效,缓存命中率低; 写数据库失败能保证数据一致性,并发读只会 短暂时间数据不一致, 数据一致性较强

适合 读多写少,数据一致性要求高 的场景;

b.读写缓存-同步直写模式: 缓存始终都有数据,缓存命中率高; 并发写导致数据不一致,数据一致性较弱

适合修改后立即访问, 写操作性能要求高,数据一致性要求较低的场景;

2.《缓存淘汰》

1.如何设置缓存的 容量大小

1.八二原理

八二原理

蓝线表示的就是“八二原理”,有 20% 的数据贡献了 80% 的访问了,而剩余的数据虽然体量很大,但只贡献了 20% 的访问量。这 80% 的数据在访问量上就形成了一条长长的尾巴,我们也称为“长尾效应”。

设置缓存容量:CONFIG SET maxmemory 4gb(建议设置为总数据量的15%~30%)

img width="30%"

2.Redis 缓存有哪些淘汰策略

不淘汰的策略:1种,noeviction策略

淘汰策略:7种

淘汰策略分为:

  1. 有过期时间的淘汰策略

    volatile-lru volatile-random volatile-ttl volatile-lfu

  2. 所有数据的淘汰策略

    allkeys-random allkeys-lru allkeys-lfu

noeviction 策略 :一旦缓存被写满了,再有写请求来时,Redis 不再提供服务,而是直接返回错误。

random :就是随机策略

lru : 最近最少被使用

lfu : lru的升级版(redis4.0后新增)

ttl: 过期时间的淘汰策略,根据过期时间进行删除,越早过期的越先被删除

img

3.淘汰的数据怎么处理?

干净数据直接删除,脏数据写回数据库**(对于 Redis 来说,即使淘汰的数据是脏数据,Redis 也不会把它们写回数据库。我们在使用 Redis 缓存时,如果数据被修改了,需要在数据修改时就将它写回数据库。否则,这个脏数据被淘汰时,会被 Redis 删除,而数据库里也没有最新的数据了。)**

什么数据是干净的,什么数据是脏数据?

干净的数据是指和数据库报纸一致

脏数据是指和数据库的值不一致

redis 对待脏数据和干净数据都是直接删除的,不会写回数据库;

所以,使用redis要设置,更新redis时一定要更改数据库,否则数据被淘汰,就会导致数据库中的数据被污染;

4.redis过期策略

  1. 定期删除(贪心策略)

    redis 会将每个设置了过期时间的 key 放入到一个独立的字典中,以后会定期遍历这个字典来删除到期的 key。

    Redis 默认会每秒进行十次过期扫描(100ms一次),过期扫描不会遍历过期字典中所有的 key,而是采用了一种简单的贪心策略。

    1.从过期字典中随机 20 个 key

    2.删除这 20 个 key 中已经过期的 key;

    3.如果过期的 key 比率超过 1/4,那就重复步骤 1;

  2. 惰性删除

    客户端访问某个设置了过期时间的key时,redis首先检查是否过期,过期就直接删除,不返回任何东西;

定期删除 集中过滤 过期的key ,惰性删除就是零散处理。

但是,过期策略并不能保证所有过期的key被删除啊,所以缓存满了就有了缓存淘汰策略。

5.不同淘汰策略的使用场景

  • **优先使用 allkeys-lru 策略;**如果你的业务数据中有明显的冷热数据区分,我建议你使用 allkeys-lru 策略。
  • **allkeys-random 策略;**如果业务应用中的数据访问频率相差不大,没有明显的冷热数据区分,建议使用 allkeys-random 策略;
  • volatile-lru 策略;如果你的业务中有置顶的需求,比如置顶新闻、置顶视频,那么,可以使用 volatile-lru 策略,同时不给这些置顶数据设置过期时间。这样一来,这些需要置顶的数据一直不会被删除,而其他数据会在过期时根据 LRU 规则进行筛选。

3.《缓存不一致问题》

对于要同时更新数据库和redis 的操作,如果想保持数据完全一致,必须保证更新数据库,更新缓存两个操作的原子性,要么都执行成功,要么都失败。

所以,如果保持数据强一致性,那么就使用 事务机制;

1.什么情况下缓存是一致的呢?

  • 缓存中有数据,那么,缓存的数据值需要和数据库中的值相同;
  • 缓存中本身没有数据,那么,数据库中的值必须是最新值。

2.读写缓存策略怎么处理数据不一致情况

同步直写策略:写缓存时,也同步写数据库,缓存和数据库中的数据一致;

异步写回策略:写缓存时不同步写数据库,等到数据从缓存中淘汰时,再写回数据库。使用这种策略时,如果数据还没有写回数据库,缓存就发生了故障,那么,此时,数据库就没有最新的数据了。

3.只读缓存处理缓存不一致情况

img

1.无并发情况(重试机制)

重试机制:可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用 Kafka 消息队列)。当应用没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。

如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了。否则的话,我们还需要再次进行重试。如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。

img

2.高并发下

1.先删除redis数据后更新数据库

解决:在线程 A 更新完数据库值以后,我们可以让它先 sleep 一小段时间,再进行一次缓存删除操作。

img

2.先更新数据库后删除redis

数据短暂不一致,会很快恢复,业务影响较小

img

4.《缓存雪崩,击穿,穿透》

1.缓存雪崩

a.什么是缓存雪崩?

缓存雪崩指同一时刻,redis有大量的key过期或者redis服务器宕机,导致海量的请求直接访问数据库,导致数据库压力过大;

b.引发缓存雪崩有哪些情况?

  1. 同一时刻大量key过期
  2. 只有一个redis实例,redis宕机了

c.如何预防缓存雪崩?

  1. 避免给大量的数据设置相同的过期时间,用 EXPIRE 命令给每个数据设置过期时间时,给这些数据的过期时间增加一个较小的随机数(例如,随机增加 1~3 分钟)

  2. 使用多个redis实例,提高可用性

d.如何处理缓存雪崩?

  1. 服务降级:非核心业务,直接返回预定义信息、空值或是错误信息;核心业务走缓存;(redis大量key过期时)
  2. 服务熔断:直接返回预定义信息,不走redis也不走数据库,业务应用调用缓存接口时,缓存客户端并不把请求发给 Redis 缓存实例,而是直接返回;(redis单例,且宕机的情况)
  3. 请求限流

什么时候服务熔断呢?

当检测到 redis宕机,且数据库在某一时刻 负载突然飙升的时候,可以启动熔断机制,等到redis恢复,就解除熔断机制;

服务熔断对整个系统业务影响非常大,而请求限流在一定程度上可以减小队业务的影响;

2.缓存击穿

a.什么是缓存击穿?

指某一个或几个频繁访问的热点key 突然过期,导致大量数据直接访问数据库的情况;

b.怎么解决?

对于访问特别频繁的热点数据,我们就不设置过期时间了。这样一来,对热点数据的访问请求,都可以在缓存中进行处理,而 Redis 数万级别的高吞吐量可以很好地应对大量的并发请求访问。

3.缓存穿透

a.什么是缓存穿透,什么情况下会发生缓存穿透?

数据库和redis中都没有数据,但是仍然有大量的请求访问这些没有的数据;

发生缓存穿透的情况?

  • 业务层误操作:缓存中的数据和数据库中的数据被误删除了,所以缓存和数据库中都没有数据;
  • 恶意攻击:专门访问数据库中没有的数据。

b.怎么解决缓存穿透?

  1. 缓存空值或缺省值:针对查询的数据,在 Redis 中缓存一个空值或是和业务层协商确定的缺省值(例如,库存的缺省值可以设为 0),应用发送的后续请求再进行查询时,就可以直接从 Redis 中读取空值或缺省值,返回给业务应用了。
  2. 使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力。
  3. 在请求入口的前端进行请求检测,防止恶意攻击

c.布隆过滤器原理

img

布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以用来快速判断某个数据是否存在。当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记:

  1. 首先,使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值。
  2. 然后,我们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。
  3. 最后,我们把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作。

如果数据不存在(例如,数据库里没有写入数据),我们也就没有用布隆过滤器标记过数据,那么,bit 数组对应 bit 位的值仍然为 0。

当需要查询某个数据时,我们就执行刚刚说的计算过程,先得到这个数据在 bit 数组中对应的 N 个位置。紧接着,我们查看 bit 数组中这 N 个位置上的 bit 值。只要这 N 个 bit 值有一个为0,这就表明布隆过滤器没有对该数据做过标记,所以,查询的数据一定没有在数据库中保存

5.《缓存污染》

1.什么缓存污染?

在一些场景下,有些 数据被访问的次数非常少,甚至只会被访问一次。当这些数据被访问后还保留在redis中无法回收就会造成 缓存空间浪费;

当缓存污染不严重,只有不被使用的缓存没被回收时不会影响 性能,当有大量的闲置缓存没被清理,缓存污染严重时,就会严重影响redis 性能;

2.为什么LRU策略不能解决缓存污染

LRU 策略的核心思想:如果一个数据刚刚被访问,那么这个数据肯定是热数据,还会被再次访问。

a.LRU 算法缺点:
  1. LRU 算法在实际实现时,需要用链表管理所有的缓存数据,这会带来额外的空间开销
  2. 而且,当有数据被访问时,需要在链表上把该数据移动到 MRU 端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。
b.redis 对LRU算法的优化
  1. redis会用键值对数据结构 RedisObject 中的 lru 字段记录每个数据最近一次访问的时间戳(lru越小表示访问时间越早,优先淘汰)
  2. 第一次淘汰数据时随机选出N个数据作为一个集合(这里我们叫它eliminate set),比较N个数据的lru字段,淘汰lru最小的key;
  3. 当再次淘汰数据,就会挑选小于上次淘汰的lru字段的数据进入eliminate set(上一次淘汰的数据集合)

Redis提供了参数 maxmemory-samples来设置 要淘汰数据的个数N,例如:我们挑选100个数据作为淘汰集合;

CONFIG SET maxmemory-samples 100

这样一来,redis不用维护一个大的链表,浪费内存空间;

c.LRU算法能防止 缓存污染吗?

LRU 策略会在候选数据集中淘汰掉 lru 字段值最小的数据(也就是访问时间最久的数据);

因为只是根据 访问时间 去淘汰数据,所以在处理扫描式单次查询操作时,无法解决缓存污染。

扫描式单次查询操作,就是指应用对大量的数据进行一次全体读取,每个数据都会被读取,而且只会被读取一次。

例如:如果我有一个不常访问的数据,我刚访问了一次,此时lru 字段肯定很大,然后就进行扫描式单次查询,那么这个key肯定不会被淘汰,而且存活时间会很久;

3.LFU策略

a.LFU算法是什么?

LFU 策略中会从两个维度来筛选并淘汰数据:一是,数据访问的时效性(访问时间离当前时间的远近);二是,数据的被访问次数。

LFU在LRU的基础上又做了优化,除了有lru字段外,还增加了一个计数器,来记录key被访问的次数;

淘汰时,现根据 访问次数 淘汰,访问次数相同的淘汰 lru 值小的那一个数据;

b.LFU算法的实现

LFU 只是在LRU 的基础上对 原来24bit大小的 lru字段做了修改:

  1. 将lru字段拆为 8bit和16bit的两部分

  2. ldt值: 前面16bit表示时间戳

  3. counter值:后面8bit表示访问次数

    当淘汰数据时,选取候选集合,先根据后8bit选取访问次数小的,次数相同,再选时间戳小的

c. 访问次数用8bit存储,最大值为255,这样会出现什么问题?

LFU 策略实现的计数规则是:每当数据被访问一次时,首先,用计数器当前的值乘以配置项 lfu_log_factor(对数因子) 再加 1,再取其倒数,得到一个 p 值;然后,把这个 p 值和一个取值范围在(0,1)间的随机数 r 值比大小,只有 p 值大于 r 值时,计数器才加 1。

下面这段 Redis 的部分源码,显示了 LFU 策略增加计数器值的计算逻辑。其中,baseval 是计数器当前的值。计数器的初始值默认是 5(由代码中的 LFU_INIT_VAL 常量设置),而不是 0,这样可以避免数据刚被写入缓存,就因为访问次数少而被立即淘汰。

double r = (double)rand()/RAND_MAX;
...
double p = 1.0/(baseval*server.lfu_log_factor+1);  //lfy_log_factor 对数因子
if (r < p) counter++;   

我们可以通过设置不同的 lfu_log_factor 配置项,来控制计数器值增加的速度,避免 counter 值很快就到 255 了。

img

当 lfu_log_factor 取值为 1 时,实际访问次数为 100K 后,counter 值就达到 255 了,无法再区分实际访问次数更多的数据了。而当 lfu_log_factor 取值为 100 时,当实际访问次数为 10M 时,counter 值才达到 255.

Redis 在实现 LFU 策略时,还设计了一个 counter 值的衰减机制。LFU 策略使用衰减因子配置项 lfu_decay_time 来控制访问次数的衰减。

LFU 策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。然后,LFU 策略再把这个差值除以 lfu_decay_time 值,所得的结果就是数据 counter 要衰减的值。

例子:

假设 lfu_decay_time 取值为 1,如果数据在 N 分钟内没有被访问,那么它的访问次数就要减 N。如果 lfu_decay_time 取值更大,那么相应的衰减值会变小,衰减效果也会减弱。所以,如果业务应用中有短时高频访问的数据的话,建议把 lfu_decay_time 值设置为 1,这样一来,LFU 策略在它们不再被访问后,会较快地衰减它们的访问次数,尽早把它们从缓存中淘汰出去,避免缓存污染。

4.使用了 LFU 策略后,缓存还会被污染吗?

LRU 策略更加关注数据的时效性:通常情况下,实际应用的负载具有较好的时间局部性,所以 LRU 策略的应用会更加广泛。

LFU 策略更加关注数据的访问频次:在扫描式查询的应用场景中,LFU 策略就可以很好地应对缓存污染问题了,建议你优先使用。

我觉得还是有被污染的可能性,被污染的概率取决于LFU的配置,也就是lfu-log-factor和lfu-decay-time参数。

1、根据LRU counter计数规则可以得出,counter递增的概率取决于2个因素:

a) counter值越大,递增概率越低
b) lfu-log-factor设置越大,递增概率越低

所以当访问次数counter越来越大时,或者lfu-log-factor参数配置过大时,counter递增的概率都会越来越低,这种情况下可能会导致一些key虽然访问次数较高,但是counter值却递增困难,进而导致这些访问频次较高的key却优先被淘汰掉了。

另外由于counter在递增时,有随机数比较的逻辑,这也会存在一定概率导致访问频次低的key的counter反而大于访问频次高的key的counter情况出现。

2、如果lfu-decay-time配置过大,则counter衰减会变慢,也会导致数据淘汰发生推迟的情况。

3、另外,由于LRU的ldt字段只采用了16位存储,其精度是分钟级别的,在counter衰减时可能会产生同一分钟内,后访问的key比先访问的key的counter值优先衰减,进而先被淘汰掉的情况。

可见,Redis实现的LFU策略,也是近似的LFU算法。Redis在实现时,权衡了内存使用、性能开销、LFU的正确性,通过复用并拆分lru字段的方式,配合算法策略来实现近似的结果,虽然会有一定概率的偏差,但在内存数据库这种场景下,已经做得足够好了。

6.《解决并发问题(例如:减库存)》

1.无锁原子操作

并发访问控制对应的操作主要是数据修改操作。当客户端需要修改数据时,基本流程分成两步:

  1. 客户端先把数据读取到本地,在本地进行修改;
  2. 客户端修改完数据后,再写回 Redis。

我们把这个流程叫做“读取 - 修改 - 写回”操作(Read-Modify-Write,简称为 RMW 操作)。

Redis 的原子操作采用了两种方法:

  1. 把多个操作在 Redis 中实现成一个操作,也就是单命令操作;
  2. 把多个操作写到一个 Lua 脚本中,以原子性方式执行单个 Lua 脚本。
a.redis的单命令原子操作

把多个操作在 Redis 中实现成一个操作,也就是单命令操作。

INCR/DECR 命令,把这三个操作转变为一个原子操作了。INCR/DECR 命令可以对数据进行增值 / 减值操作;

在库存扣减例子中,客户端可以使用下面的代码,直接完成对商品 id 的库存值减 1 操作。

DECR id 
b. redis中使用lua脚本

例子: 比如说,当业务应用的访问客户增加时,我们要限制某个客户端 在规定之间内访问次数,比如爆款商品的购买,社交网络中的每分钟点赞次数;

怎么限制呢?我们以客户端IP为key ,访问次数为value,并设置过期时间;

在这种场景下,客户端限流其实同时包含了对访问次数和时间范围的限制,假如我们设置60s内只能访问20次,看下面代码:

//获取ip对应的访问次数
current = GET(ip)
//如果超过访问次数超过20次,则报错
IF current != NULL AND current > 20 THEN
    ERROR "exceed 20 accesses per second"
ELSE
    //如果访问次数不足20次,增加一次访问计数
    value = INCR(ip)
    //如果是第一次访问,将键值对的过期时间设置为60s后
    IF value == 1 THEN   ①
        EXPIRE(ip,60)
    END
    //执行其他操作
    DO THINGS
END

如果value 是全局变量时,可能会导致 多客户端下value的值在执行①处操作时,直接大于1,不能设置过期时间;

那么lua怎么解决呢?

local current
current = redis.call("incr",KEYS[1])
if tonumber(current) == 1 then
    redis.call("expire",KEYS[1],60)
end

假设脚本名称为 lua.script,我们可以 加载lua.script,直接执行

redis-cli  --eval lua.script  keys , args

注意:为了反之redis频繁加载lua脚本,我们可以使用SCRIPT LOAD命令把 lua 脚本加载到 Redis 中,然后获取唯一摘要,使用 EVALSHA + 脚本摘要 执行脚本,避免每次发送脚本内容到 Redis,减少网络开销。

2.分布式锁

实现分布式锁的两个要求。

  1. 要求一:分布式锁的加锁和释放锁的过程,涉及多个操作。所以,在实现分布式锁时,我们需要保证这些锁操作的原子性;
  2. 要求二:共享存储系统保存了锁变量,如果共享存储系统发生故障或宕机,那么客户端也就无法进行锁操作了。在实现分布式锁时,我们需要考虑保证共享存储系统的可靠性,进而保证锁的可靠性。
a.单机版的分布式锁

加锁包含了三个操作(读取锁变量、判断锁变量值以及把锁变量值设置为 1),我们要保证其原子性;

setnx命令:执行时会判断键值对是否存在,如果不存在,就设置键值对的值,如果存在,就不做任何设置。

我们就可以用 SETNX 和 DEL 命令组合来实现加锁和释放锁操作。下面的伪代码示例显示了锁操作的过程,你可以看下:

// 加锁
SETNX lock_key 1
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_key

注意,上面加锁操作有两个风险:

  1. DO THINGS业务逻辑出现异常,导致锁不可释放
  2. 如果客户端 A 执行了 SETNX 命令加锁后,假设客户端 B 执行了 DEL 命令释放锁,此时,客户端 A 的锁就被误释放了。如果客户端 C 正好也在申请加锁,就可以成功获得锁,进而开始操作共享数据。这样一来,客户端 A 和 C 同时在对共享数据进行操作,数据就会被修改错误,这也是业务层不能接受的。

解决方案:

  1. 第一种给锁变量设置一个过期时间
  2. 第二种我们加锁时setnx可以设置一个唯一随机值,释放锁时,先判断值是否为那个唯一值

实现:

SET key value [EX seconds | PX milliseconds]  [NX]

EX表示秒seconds, PX表示 milliseconds

例如:


// 加锁, unique_value作为客户端唯一性的标识
SET lock_key unique_value NX PX 10000

那么看一下我们释放锁的lua脚本:

//释放锁 比较unique_value是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
b.基于多个 Redis 节点实现高可靠的分布式锁

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。

Redlock 算法的实现需要有 N 个独立的 Redis 实例。接下来,我们可以分成 3 步来完成加锁操作:

  1. 第一步是,客户端获取当前时间。

  2. 第二步是,客户端按顺序依次向 N 个 Redis 实例执行加锁操作。

    SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。如果加锁的实例宕机,RedLock就不能运行,所以要给加锁操作设置一个超时时间

    如果客户端在和一个 Redis 实例请求加锁时,一直到超时都没有成功,那么此时,客户端会和下一个 Redis 实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。

  3. 第三步是,一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时。

    客户端只有在满足下面的这两个条件时,才能认为是加锁成功。

    条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;

    条件二:客户端获取锁的总耗时没有超过锁的有效时间。

    在满足了这两个条件后,我们需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。

c.redis分布式锁的可靠性

使用单个 Redis 节点(只有一个master)使用分布锁,如果实例宕机,那么无法进行锁操作了。那么采用主从集群模式部署是否可以保证锁的可靠性?

答案是也很难保证。如果在 master 上加锁成功,此时 master 宕机,由于主从复制是异步的,加锁操作的命令还未同步到 slave,此时主从切换,新 master 节点依旧会丢失该锁,对业务来说相当于锁失效了。

所以 Redis 作者才提出基于多个 Redis 节点(master节点)的 Redlock 算法,但这个算法涉及的细节很多,作者在提出这个算法时,业界的分布式系统专家还与 Redis 作者发生过一场争论,来评估这个算法的可靠性,争论的细节都是关于异常情况可能导致 Redlock 失效的场景,例如加锁过程中客户端发生了阻塞、机器时钟发生跳跃等等。

感兴趣的可以看下这篇文章,详细介绍了争论的细节,以及

简单总结,基于 Redis 使用分布锁的注意点:

1、使用 SET $lock_key $unique_val EX $second NX 命令保证加锁原子性,并为锁设置过期时间

2、锁的过期时间要提前评估好,要大于操作共享资源的时间

3、每个线程加锁时设置随机值,释放锁时判断是否和加锁设置的值一致,防止自己的锁被别人释放

4、释放锁时使用 Lua 脚本,保证操作的原子性

5、基于多个节点的 Redlock,加锁时超过半数节点操作成功,并且获取锁的耗时没有超过锁的有效时间才算加锁成功

6、Redlock 释放锁时,要对所有节点释放(即使某个节点加锁失败了),因为加锁时可能发生服务端加锁成功,由于网络问题,给客户端回复网络包失败的情况,所以需要把所有节点可能存的锁都释放掉

7、使用 Redlock 时要避免机器 ,需要运维来保证,对运维有一定要求,否则可能会导致 Redlock 失效。例如共 3 个节点,线程 A 操作 2 个节点加锁成功,但其中 1 个节点机器时钟发生跳跃,锁提前过期,线程 B 正好在另外 2 个节点也加锁成功,此时 Redlock 相当于失效了(Redis 作者和分布式系统专家争论的重要点就在这)

8、如果为了效率,使用基于单个 Redis 节点的分布式锁即可,此方案缺点是允许锁偶尔失效,优点是简单效率高

9、如果是为了正确性,业务对于结果要求非常严格,建议使用 Redlo ck,但缺点是使用比较重,部署成本高

7.《redis事务》

1.Redis的两种事务模式

  1. redis 自带事务机制:由WATACH,MULTI, EXEC,DISCARD,UNWATCH命令组成
  2. redis脚本事务:redis2.6开始支持了脚本,使用lua脚本可以同时操作多个命令,完成redis事务;

WATCH keys在 MULTI之前,表示监视某些keys,一旦keys发生改变就放弃执行事务

MULTI表示开启事务,之后会把复合操作命令放入redis的队列中,并未执行;

EXEC执行队列中命令,执行完毕,删除WACTH

DISCARD在EXEC之前执行,放弃事务

UNWATCH 表示清除监视

img

2.事务ACID

A(Atomicity,原子性):事务中操作要么都成功,要么都失败

C(Consistency,一致性):事务执行前后,数据一致

I(istolation,隔离性): 一个事务内执行的数据,不能被其他事务访问

D(durability,持久性):事务执行后对数据库的影响是永久的

3.redis是否完全符合ACID呢?

#开启事务
127.0.0.1:6379> MULTI
OK
#发送事务中的第一个操作,但是Redis不支持该命令,返回报错信息
127.0.0.1:6379> PUT a:stock 5  ①
(error)ERR unknown command `PUT`, with args beginning with: `a:stock`, `5`,
#发送事务中的第一个操作,LPOP命令操作的数据类型不匹配,此时并不报错
127.0.0.1:6379> LPOP a:stock   ②
QUEUED
#发送事务中的第二个操作,这个操作是正确的命令,Redis把该命令入队
127.0.0.1:6379> DECR b:stock
QUEUED
#实际执行事务
127.0.0.1:6379> EXEC
1) (error) WRONGTYPE Operation against a key holding the wrong kind of value
2) (integer) 8
a.原子性
  1. **在执行 EXEC 命令前,客户端发送的操作命令本身就有错误(比如语法错误,使用了不存在的命令,例如 : ①处):**这时会被redis实例判断出来,不执行事务,
  2. 事务操作入队时,命令和操作的数据类型不匹配,但 Redis 实例没有检查出错误(例如 : ②处),那么LPOP就会报错,但是 DECR仍然执行正确,
  3. 在执行事务的 EXEC 命令时,Redis 实例发生了故障,导致事务执行失败(例如:redis宕机了),如果 Redis 开启了 AOF 日志,命令就会写入AOF日志,我们可以使用redis-check-aof,未执行完的事务中的所有操作删除,那么宕机重启 数据就恢复 事务执行前的状态了
c.一致性
  1. 命令入队时就报错,保证数据一致性

  2. 命令入队时没报错,实际执行时报错,保证数据一致性

  3. EXEC 命令执行时实例发生故障,分AOF和RDB的情况

    AOF:事务执行时,还没有记录到AOF,那么宕机重启 数据还是执行前的数据保证数据一致性;

    如果事务执行时,记录了部分日志,那么redis-check-aof会删除事务操作日志,宕机重启后数据还是执行前的数据保证数据一致性;

    RDB: ,宕机重启还是执行事务前的数据,保证数据一致性;

    没有开启RDB和AOF:宕机重启,内存丢失,数据一致

i:隔离性

而事务执行又可以分成命令入队(EXEC 命令执行前)和命令实际执行(EXEC 命令执行后)两个阶段

  1. 并发操作在 EXEC 命令前执行, ,否则隔离性无法保证;
  2. 并发操作在 EXEC 命令后执行,此时,隔离性可以保证

WATCH 机制的作用是,在事务执行前,监控一个或多个键的值变化情况,当事务调用 EXEC 命令执行时,WATCH 机制会先检查监控的键是否被其它客户端修改了。如果修改了,就放弃事务执行,避免事务的隔离性被破坏。然后,客户端可以再次执行事务,此时,如果没有并发修改事务数据的操作了,事务就能正常执行,隔离性也得到了保证。

img

d.持久性

AOF 模式:因为 AOF 模式的三种配置选项 no、everysec 和 always 都会存在数据丢失的情况,所以,事务的持久性属性也还是得不到保证。

RDB:也无法保证持久性

4.Pipeline 命令

Pipeline 是一次性把所有命令打包好全部发送到服务端,服务端全部处理完成后返回。这么做好的好处,一是减少了来回网络 IO 次数,提高操作性能。二是一次性发送所有命令到服务端,服务端在处理过程中,是不会被别的请求打断的(Redis单线程特性,此时别的请求进不来)。我们平时使用的 Redis SDK 在使用开启事务时,一般都会默认开启 Pipeline 的,可以留意观察一下。

8. 《redis在 秒杀场景 中的应用》

1. redis在秒杀场景中扮演的角色

我们可以吧秒杀分为 秒杀前,秒杀中,秒杀后 三个场景

a.秒杀前:大量用户频繁的查看商品详情页, 把商品详情页的页面元素静态化,然后使用 CDN 或是浏览器把这些静态化的元素缓存起来。

b.秒杀中: 涉及库存查验、库存扣减和订单处理三个操作 ,读多写少的场景;

此时,大量用户点击商品详情页上的秒杀按钮,会产生大量的并发请求查询库存。一旦某个请求查询到有库存,紧接着系统就会进行库存扣减。然后,系统会生成实际订单,并进行后续处理,例如订单支付和物流服务。如果请求查不到库存,就会返回。用户通常会继续点击秒杀按钮,继续查询库存。

**订单处理:**订单处理会涉及支付、商品出库、物流等多个关联操作,这些操作本身涉及数据库中的多张数据表,要保证处理的事务性,需要在数据库中完成。而且,订单处理时的请求压力已经不大了,数据库可以支撑这些订单处理请求。

扣减库存为什么不再数据库处理?

  1. 额外开销:如果数据库扣减库存,那么就需要同步到redis ,增加额外的操作逻辑,增加额外开销
  2. **可能出现超卖现象:**由于数据库的处理速度较慢,不能及时更新库存余量,这就会导致大量库存查验的请求读取到旧的库存值,并进行下单。

c.秒杀后:客户仍然可以查看商品,刷新 库存信息

img

2.redis秒杀时 库存查询和扣减库存 怎么原子实现?

秒杀场景对 Redis 操作的根本要求有两个。

  1. 支持高并发。这个很简单,Redis 本身高速处理请求的特性就可以支持高并发。而且,如果有多个秒杀商品,我们也可以使用切片集群,用不同的实例保存不同商品的库存,这样就避免,使用单个实例导致所有的秒杀请求都集中在一个实例上的问题了。不过,需要注意的是,当使用切片集群时,我们要先用 CRC 算法计算不同秒杀商品 key 对应的 Slot,然后,我们在分配 Slot 和实例对应关系时,才能把不同秒杀商品对应的 Slot 分配到不同实例上保存。
  2. 保证库存查验和库存扣减原子性执行。针对这条要求,我们就可以使用 Redis 的原子操作或是分布式锁这两个功能特性来支撑了。
1.原子操作

key为 商品ID, 由于需要查询库存以及扣减库存,所以value需要两个元素:库存总量,已卖商品数量;

key: itemID
value: {total: N, ordered: M}

需要借助lua脚本完成:

#获取商品库存信息            
local counts = redis.call("HMGET", KEYS[1], "total", "ordered");
#将总库存转换为数值
local total = tonumber(counts[1])
#将已被秒杀的库存转换为数值
local ordered = tonumber(counts[2])  
#如果当前请求的库存量加上已被秒杀的库存量仍然小于总库存量,就可以更新库存         
if ordered + k <= total then
    #更新已秒杀的库存量
    redis.call("HINCRBY",KEYS[1],"ordered",k)                              return k;  
end               
return 0
2.分布式锁

用分布式锁来支撑秒杀场景的具体做法是,先让客户端向 Redis 申请分布式锁,只有拿到锁的客户端才能执行库存查验和库存扣减。

**大量的秒杀请求就会在争夺分布式锁时被过滤掉。**而且,库存查验和扣减也不用使用原子操作了,因为多个并发客户端只有一个客户端能够拿到锁,已经保证了客户端并发访问的互斥性。

//使用商品ID作为key
key = itemID
//使用客户端唯一标识作为value
val = clientUniqueID
//申请分布式锁,Timeout是超时时间
lock =acquireLock(key, val, Timeout)
//当拿到锁后,才能进行库存查验和扣减
if(lock == True) {
   //库存查验和扣减
   availStock = DECR(key, k)
   //库存已经扣减完了,释放锁,返回秒杀失败
   if (availStock < 0) {
      releaseLock(key, val)
      return error
   }
   //库存扣减成功,释放锁
   else{
     releaseLock(key, val)
     //订单处理
   }
}
//没有拿到锁,直接返回
else
   return

**我们可以使用切片集群中的不同实例来分别保存分布式锁和商品库存信息。**使用这种保存方式后,秒杀请求会首先访问保存分布式锁的实例。如果客户端没有拿到锁,这些客户端就不会查询商品库存,这就可以减轻保存库存信息的实例的压力了。

3.总结

a. 在秒杀场景中,我们可以通过前端 CDN 和浏览器缓存拦截大量秒杀前的请求。

b. 在实际秒杀活动进行时,库存查验和库存扣减是承受巨大并发请求压力的两个操作,同时,这两个操作的执行需要保证原子性。Redis 的原子操作、分布式锁这两个功能特性可以有效地来支撑秒杀场景的需求。

那么,秒杀场景还有哪些环节需要我们处理好?

  1. 前端静态页面的设计。秒杀页面上能静态化处理的页面元素,我们都要尽量静态化,这样可以充分利用 CDN 或浏览器缓存服务秒杀开始前的请求。
  2. 请求拦截和流控。在秒杀系统的接入层,对恶意请求进行拦截,避免对系统的恶意攻击,例如使用黑名单禁止恶意 IP 进行访问。如果 Redis 实例的访问压力过大,为了避免实例崩溃,我们也需要在接入层进行限流,控制进入秒杀系统的请求数量。
  3. 库存信息过期时间处理。Redis 中保存的库存信息其实是数据库的缓存,为了避免缓存击穿问题,我们不要给库存信息设置过期时间。
  4. 数据库订单异常处理。如果数据库没能成功处理订单,可以增加订单重试功能,保证订单最终能被成功处理。

4.问题

按照惯例,我给你提个小问题,假设一个商品的库存量是 800,我们使用一个包含了 4 个实例的切片集群来服务秒杀请求。我们让每个实例各自维护库存量 200,然后,客户端的秒杀请求可以分发到不同的实例上进行处理,你觉得这是一个好方法吗?

解答:

使用切片集群分担秒杀请求,可以降低每个实例的请求压力,前提是秒杀请求可以平均打到每个实例上,否则会出现秒杀请求倾斜的情况,反而会增加某个实例的压力,而且会导致商品没有全部卖出的情况。

但用切片集群分别存储库存信息,**缺点是如果需要向用户展示剩余库存,要分别查询多个切片,最后聚合结果后返回给客户端。**这种情况下,建议不展示剩余库存信息,直接针对秒杀请求返回是否秒杀成功即可。

9.《数据分布优化:如何应对数据倾斜?》

在切片集群中,数据会按照一定的分布规则分散到不同的实例上保存。比如,在使用 Redis Cluster 或 Codis 时,数据都会先按照 CRC 算法的计算值对 Slot(逻辑槽)取模,同时,所有的 Slot 又会由运维管理员分配到不同的实例上。这样,数据就被保存到相应的实例上了。

虽然这种方法实现起来比较简单,但是很容易导致一个问题:数据倾斜。

1.数据量倾斜

1.bigkey导致的数据量倾斜

bigkey 的 value 值很大(String 类型),或者是 bigkey 保存了大量集合元素(集合类型),会导致这个实例的数据量增加,内存资源消耗也相应增加。

而且,bigkey 的操作一般都会造成实例 IO 线程阻塞,如果 bigkey 的访问量比较大,就会影响到这个实例上的其它请求被处理的速度。

解决:我们在业务层生成数据时,要尽量避免把过多的数据保存在同一个键值对中。

如果 bigkey 正好是集合类型,我们还有一个方法,就是把 bigkey 拆分成很多个小的集合类型数据,分散保存在不同的实例上。

例子:假设 Hash 类型集合 user:info 保存了 100 万个用户的信息,是一个 bigkey。那么,我们就可以按照用户 ID 的范围,把这个集合拆分成 10 个小集合,每个小集合只保存 10 万个用户的信息(例如小集合 1 保存的是 ID 从 1 到 10 万的用户信息,小集合 2 保存的是 ID 从 10 万零 1 到 20 万的用户)。这样一来,我们就可以把一个 bigkey 化整为零、分散保存了,避免了 bigkey 给单个切片实例带来的访问压力。

2.slot分布不均匀

工程师分配时slot分配不均匀;

而且,每个slot 映射的数据量不一样(有的slot映射100MB数据,有的可能是1MB数据),所以有可能 很多携带大量数据的slot 被分配到 了同一个实例上,导致 该实例 数据量巨大;

解决: slot均匀分布, slot 数据迁移

查看 Slot 分配情况,Redis Cluster,就用 CLUSTER SLOTS 命令;

在 Redis Cluster 中,我们可以使用 3 个命令完成 Slot 迁移。

  1. CLUSTER SETSLOT:使用不同的选项进行三种设置,分别是设置 Slot 要迁入的目标实例,Slot 要迁出的源实例,以及 Slot 所属的实例。
  2. CLUSTER GETKEYSINSLOT:获取某个 Slot 中一定数量的 key。
  3. MIGRATE:把一个 key 从源实例实际迁移到目标实例。

假设我们要把 Slot 300 从源实例(ID 为 3)迁移到目标实例(ID 为 5),那要怎么做呢?

实际上,我们可以分成 5 步。

  1. 我们先在目标实例 5 上执行下面的命令,将 Slot 300 的源实例设置为实例 3,表示要从实例 3 上迁入 Slot 300。

    CLUSTER SETSLOT 300  IMPORTING 3
    
  2. 在源实例 3 上,我们把 Slot 300 的目标实例设置为 5,这表示,Slot 300 要迁出到实例 5 上

    CLUSTER SETSLOT  300 MIGRANTING 5
    
  3. 从 Slot 300 中获取 100 个 key。因为 Slot 中的 key 数量可能很多,所以我们需要在客户端上多次执行下面的这条命令,分批次获得并迁移 key。

    CLUSTER GETKEYSINSLOT 300    100
    
  4. 我们把刚才获取的 100 个 key 中的 key1 迁移到目标实例 5 上(IP 为 192.168.10.5),同时把要迁入的数据库设置为 0 号数据库,把迁移的超时时间设置为 timeout。我们重复执行 MIGRATE 命令,把 100 个 key 都迁移完。

    MIGRATE 192.168.10.5 6379 key1 0 timeout
    
  5. 重复执行第 3 和第 4 步,直到 Slot 中的所有 key 都迁移完成。

从 Redis 3.0.6 开始,你也可以使用 KEYS 选项,一次迁移多个 key(key1、2、3),这样可以提升迁移效率。

从 Redis 3.0.6 开始,你也可以使用 KEYS 选项,一次迁移多个 key(key1、2、3),这样可以提升迁移效率。
3.HashTag导致数据量分布不均匀

Hash Tag 是指加在键值对 key 中的一对花括号{}。这对括号会把 key 的一部分括起来,客户端在计算 key 的 CRC16 值时,只对 Hash Tag 花括号中的 key 内容进行计算。

img

那么,Hash Tag 一般用在什么场景呢?

其实,它主要是用在 Redis Cluster 和 Codis 中,支持事务操作和范围查询。因为 Redis Cluster 和 Codis 本身并不支持跨实例的事务操作和范围查询,当业务应用有这些需求时,就只能先把这些数据读取到业务层进行事务处理,或者是逐个查询每个实例,得到范围查询的结果。

这样操作起来非常麻烦,所以,我们可以使用 Hash Tag 把要执行事务操作或是范围查询的数据映射到同一个实例上,这样就能很轻松地实现事务或范围查询了。

使用 Hash Tag 的潜在问题,就是大量的数据可能被集中到一个实例上,导致数据倾斜,集群中的负载不均衡。

建议:

如果使用 Hash Tag 进行切片的数据会带来较大的访问压力,就优先考虑避免数据倾斜,最好不要使用 Hash Tag 进行数据切片。因为事务和范围查询都还可以放在客户端来执行,而数据倾斜会导致实例不稳定,造成服务不可用。

2.数据访问量倾斜

发生数据访问倾斜的根本原因,就是实例上存在热点数据(比如新闻应用中的热点新闻内容、电商促销活动中的热门商品信息,等等)。

热点数据以服务读操作为主:增加副本

对于有读有写的热点数据:给实例本身增加资源了,例如使用配置更高的机器,来应对大量的访问压力。

3.总结

构建切片集群时,尽量使用大小配置相同的实例(例如实例内存配置保持相同),这样可以避免因实例资源不均衡而在不同实例上分配不同数量的 Slot。

img

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值