Redis(六)缓存场景应用
缓存异常✨✨✨
缓存雪崩
当大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题。
大量数据同时过期
针对大量数据同时过期而引发的缓存雪崩问题,常见的应对方法有下面这几种:
1. 均匀设置过期时间
如果要给缓存数据设置过期时间,应该避免将大量的数据设置成同一个过期时间。我们可以在对缓存数据设置过期时间时,给这些数据的过期时间加上一个随机数(偏移量要跟过期时间成正比,不能过低或者过高),这样就保证数据不会在同一时间过期。
2. 互斥锁
当业务线程在处理用户请求时,如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
实现互斥锁的时候,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。
3.设置热点数据永不过期
设置缓存不过期, 我们可以通过后台服务来更新缓存数据。
4.缓存预热
在系统启动或者低峰期,对缓存进行预热,提前加载热点数据,降低冷启动时的缓存雪崩风险。
常见的缓存预热方式有两种:
- 使用定时任务,比如 xxl-job,来定时触发缓存预热的逻辑,将数据库中的热点数据查询出来并存入缓存中。
- 使用消息队列,比如 Kafka,来异步地进行缓存预热,将数据库中的热点数据的主键或者 ID 发送到消息队列中,然后由缓存服务消费消息队列中的数据,根据主键或者 ID 查询数据库并更新缓存。
Redis 故障宕机
针对 Redis 故障宕机而引发的缓存雪崩问题,常见的应对方法有下面这几种:
1. 服务熔断或请求限流机制
因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。
服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作
为了减少对业务的影响,我们可以启用请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。
2. 构建 Redis 缓存高可靠集群
服务熔断或请求限流机制是缓存雪崩发生后的应对方案,我们最好通过主从节点的方式构建 Redis 缓存高可靠集群。
如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。
缓存击穿
如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题
缓存击穿跟缓存雪崩很相似,可以认为缓存击穿是缓存雪崩的一个子集。
应对缓存击穿:
- 互斥锁方案,保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值
- 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间
- 针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期
缓存穿透
当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据,一旦缓存恢复相对应的数据,就可以减轻数据库的压力,而缓存穿透就不一样了。
当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。
缓存穿透的发生一般有这两种情况:
- 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
- 黑客恶意攻击,故意大量访问某些读取不存在数据的业务。
应对缓存穿透的方案,常见的方案有三种:
- 非法请求的限制(在 API 入口处我们要判断求请求参数是否合理,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库);
- 缓存空值或者默认值(当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库);
- 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在。
布隆过滤器怎么工作
布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。
布隆过滤器会通过 3 个操作完成标记:
- 第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;
- 第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。
- 第三步,将每个哈希值在位图数组的对应位置的值设置为 1;
举个例子,假设有一个位图数组长度为 8,哈希函数 3 个的布隆过滤器
在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中。
缺陷:
布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据。
不支持一个关键字的删除,因为一个关键字的删除会牵连其他的关键字(两个关键字都命中一个位的情况)。改进方法就是counting Bloom filter,用一个counter数组代替位数组,就可以支持删除了。
缓存读写策略
旁路缓存(Cache Aside Pattern)
读 ,从 cache 中读取数据:
- 读取到就直接返回
- cache 中读取不到的话,就从 db 中读取数据返回
- 再把数据放到 cache 中
写:
- 先更新 db
- 然后直接删除 cache
读写穿透(Read/Write Through)
该策略的核心是用户只与缓存层交互,由缓存层(cache
服务)与数据库通信,写入或读取数据。
读,通过缓存层进行读取:
若缓存存在则直接返回
若不存在则由缓存层拉取数据库数据到缓存中并返回
读,相当于 在Cache-Aside Pattern 之上封装了一层
写,通过缓存层进行写入:
- 若缓存存在则直接写入缓存中并同步到数据库
- 若不存在则写入数据库中,同步缓存
Write-Through适用情况有: 对缓存及时性要求更高(写入就加载了缓存,当然这种模式可能会有时序性问题(由于网络传输本身有延迟,所以无法保证两条Redis更新操作谁先执行))
异步缓存写入(Write Behind Pattern)
异步缓存写入和读写穿透很相似,两者都是由cache
服务来负责cache
和DB
的读写。
两者最大的不同点就是:读写穿透是同步更新DB
和cache
,而异步缓存写入则是只更新cache
,不直接更新DB
,而是改为异步批量的方式更新DB
数据库和缓存一致性✨✨✨
下面主要针对旁路缓冲模式(新增数据时,直接写入数据库;更新数据时,先删除缓存。 后续,访问这些增删改的数据时,会发生缓存缺失,进而查询数据库,更新缓存)
在更新数据的过程中,可能会有如下问题:
无并发请求下,其中一个操作失败的情况
- 先更新数据库,再删除缓存
- 更新数据库成功,删除缓存失败,缓存命中旧值
- 先删除缓存,再更新数据库
- 删除缓存成功,更新数据库失败,数据库中存旧值
- 先更新数据库,再删除缓存
理论上来说要保证数据一致性,就必须要保证这两个更新要么都成功,要么都失败,不能有中间状态。也就是说,这是一个分布式事务问题。而现在缓存中间件包括 Redis,都不支持分布式事务。因此这个问题就决定了数据不一致是不可避免的。
并发请求下,其他线程可能会读到旧值
- 先更新数据库,再删除缓存
- 数据库更新请求B太快了,最新的请求B更新数据库+删缓存,旧的未命中请求A才将数据库读入缓存
- 先删除缓存,再更新数据库
- 删除缓存后,请求B不能命中,未命中请求B将数据库读入缓存太快了,请求A还没更新缓存,读到之前旧值
- 先更新数据库,再删除缓存
因此,要想达到数据一致性,需要保证两点:
- 无并发请求下,保证 A 和 B 步骤都能成功执行
- 并发请求下,在 A 和 B 步骤的间隔中,避免或消除其他线程的影响
对于无并发请求
因为操作被拆分成两步,那么就很有可能存在“步骤 1 成功,步骤 2 失败” 的情况发生,不太可能会发生 “步骤 2 成功,步骤 1 失败” 的情况。
解决方案
a.消息队列+异步重试
无论使用哪一种执行时序,可以在执行步骤 1 时,将步骤 2 的请求写入消息队列,当步骤 2 失败时,就可以使用重试策略,对失败操作进行 “补偿”。
具体步骤如下:
- 把要删除缓存值或者是要更新数据库值操作生成消息,暂存到消息队列中(例如使用 Kafka 消息队列);
- 当删除缓存值或者是更新数据库值操作成功时,把这些消息从消息队列中去除(丢弃),以免重复操作;
- 当删除缓存值或者是更新数据库值操作失败时,执行失败策略,重试服务从消息队列中重新读取(消费)这些消息,然后再次进行删除或更新;
- 删除或者更新失败时,需要再次进行重试,重试超过的一定次数,向业务层发送报错信息。
b.订阅 Binlog 变更日志
- 创建更新缓存服务,接收数据变更的 MQ 消息,然后消费消息,更新/删除 Redis 中的缓存数据;
- 使用 Binlog 实时更新/删除 Redis 缓存。利用 Canal,即将负责更新缓存的服务伪装成一个 MySQL 的从节点,从 MySQL 接收 Binlog,解析 Binlog 之后,得到实时的数据变更信息,然后根据变更信息去更新/删除 Redis 缓存;
- MQ+Canal 策略,将 Canal Server 接收到的 Binlog 数据直接投递到 MQ 进行解耦,使用 MQ 异步消费 Binlog 日志,以此进行数据同步;
数据不过期时才考虑,不管用 MQ/Canal 或者 MQ+Canal 的策略来异步更新缓存,对整个更新服务的数据可靠性和实时性要求都比较高,如果产生数据丢失或者更新延时情况,会造成 MySQL 和 Redis 中的数据不一致。因此,使用这种策略时,需要考虑出现不同步问题时的降级或补偿方案。
对于高并发情况
使用以上策略后,可以保证在单线程/无并发场景下的数据一致性。但是,在高并发场景下,由于数据库层面的读写并发,会引发的数据库与缓存数据不一致的问题(本质是后发生的读请求先返回了)
先更新数据库,再删除缓存
数据库更新请求B太快了,最新的请求B更新数据库+删缓存,旧的未命中请求A才将数据库读入缓存
解决方案:
a.过期时间兜底
先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。直接使用过期时间就好了。
b.延迟消息
凭借经验发送「延迟消息」到队列中,延迟删除缓存,尽可能降低不一致发生的概率
先删除缓存,再更新数据库
删除缓存后,请求B不能命中,未命中请求B将数据库读入缓存太快了,请求A还没更新缓存,读到之前旧值
解决方案:
a.设置缓存过期时间 + 延时双删
通过设置缓存过期时间,若发生上述淘汰缓存失败的情况,则在缓存过期后,读请求仍然可以从 DB 中读取最新数据并更新缓存,可减小数据不一致的影响范围。虽然在一定时间范围内数据有差异,但可以保证数据的最终一致性。
此外,还可以通过延时双删进行保障:在线程 A 更新完数据库值以后,让它先 sleep 一小段时间,确保线程 B 能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程 A 再进行删除。后续,其它线程读取数据时,发现缓存缺失,会从数据库中读取最新值。
分布式锁✨✨✨
分布式锁特性
- 互斥性:锁的目的是获取资源的使用权,所以只让一个竞争者持有锁,这一点要尽可能保证;
- 安全性:避免锁因为异常永远不被释放。当一个竞争者在持有锁期间内,由于意外崩溃而导致未能主动解锁,其持有的锁也能够被兜底释放,并保证后续其它竞争者也能加锁;
- 对称性:同一个锁,加锁和解锁必须是同一个竞争者。不能把其他竞争者持有的锁给释放了;
- 可靠性:需要有一定程度的异常处理能力、容灾能力。
MySQL实现分布式锁
先查询数据库是否存在记录,为了防止幻读取通过数据库行锁 select for update 锁住这行数据,然后将查询和插入的 SQL 在同一个事务中提交。
以订单表为例:
select id from order where order_id = xxx for update
基于关系型数据库实现分布式锁比较简单,不过,基于 MySQL 行锁的方式会出现交叉死锁,比如事务 1 和事务 2 分别取得了记录 1 和记录 2 的排它锁,然后事务 1 又要取得记录 2 的排它锁,事务 2 也要获取记录 1 的排它锁,那这两个事务就会因为相互锁等待,产生死锁。
基于乐观锁的方式实现分布式锁
在数据库层面,select for update 是悲观锁,会一直阻塞直到事务提交,所以为了不产生锁等待而消耗资源,你可以基于乐观锁的方式来实现分布式锁,比如基于版本号的方式,首先在数据库增加一个 int 型字段 ver,然后在 SELECT 同时获取 ver 值,最后在 UPDATE 的时候检查 ver 值是否为与第 2 步或得到的版本值相同。
## SELECT 同时获取 ver 值
select amount, old_ver from order where order_id = xxx
## UPDATE 的时候检查 ver 值是否与第 2 步获取到的值相同
update order set ver = old_ver + 1, amount = yyy where order_id = xxx and ver = old_ver
此时,如果更新结果的记录数为1,就表示成功,如果更新结果的记录数为 0,就表示已经被其他应用更新过了,需要做异常处理。
Redis实现分布式锁
基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。
- 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁;
- 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;
- 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端。
满足这三个条件的分布式命令如下:
SET lock_key unique_value NX PX 10000
- lock_key 就是 key 键;
- unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作;
- NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
- PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。
而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。
可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。
// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。
为什么需要引入owner的概念呢?
分布式锁需要保证对称性,假设没有这种对称性,会有问题
举个例子,服务获取了锁,由于业务流程比较长,或者网络延迟、GC卡顿等原因,导致锁过期,而业务还会继续进行。这时候,业务B已经拿到了锁,准备去执行,这个时候服务A恢复过来并做完了业务,就会释放锁,而B却还在继续执行,等B完成下次释放的可能又是别人的锁,这种情况是需要避免的。
过期时间应该多长
这个过期时间应该根据业务来设置。比如说,如果在拿到锁之后,99% 的业务都可以在 1 秒内完成,那么就可以把过期时间设置得比 1 秒长一些,比如说设置成 2 秒。保险起见,设置成 10 秒甚至一分钟也没多大关系。
还可以使用守护线程进行续约
过期时间主要是为了防止系统宕机而引入的,而大部分情况下,锁都能被正常释放掉,所以把过期时间设置得长一些也没什么问题。
用Lua一定能保证原子性?
lua本身不具备原子性,上面提到用lua来保证原子性是因为Redis是单线程执行,一个流程放进lua来执行,相当于是打包在一起,Redis执行他的过程中不会被其他请求打断,所以说保证了原子性。
这里我们也提到,我们是在释放的时候将查询key,删除key打包到一起,其中只有最后删除是写操作,所以这个流程本身是保证了原子性的。
分布式锁是完全可靠的吗
没有完全可靠的分布式锁,因为网络不可靠。在使用分布式锁的时候就要考虑到这一点,关键业务还是需要幂等来兜底。当然我们可以使用RedLock集群化的分布式锁,这种模式出问题的概率就微乎其微了。
Redis 如何解决集群情况下分布式锁的可靠性?
可靠性问题:Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。
为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)。
Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。
可以看到,加锁成功要同时满足两个条件(简述:如果有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功):
- 条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁;
- 条件二:客户端从大多数节点获取锁的总耗时(t2-t1)小于锁设置的过期时间。
加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。
加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。
Redis事务
什么是 Redis 事务?
你可以将 Redis 中的事务理解为:Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。
Redis 事务支持原子性吗?
Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 事务是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的。
Redis 事务支持持久性吗?
与 RDB 持久化相比,AOF 持久化的实时性更好。
AOF 持久化的fsync
策略为 no、everysec 时都会存在数据丢失的情况 。always 下可以基本是可以满足持久性要求的,但性能太差,实际开发过程中不会使用。
因此,Redis 事务的持久性也是没办法保证的。
使用Lua实现Redis事务
一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。
不过,如果 Lua 脚本运行时出错并中途结束:
出错之后的命令是不会被执行的。
并且,出错之前执行的命令是无法被撤销的,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。
因此, 严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。
业务场景✨✨✨
如何设计一个缓存策略,可以动态缓存热点数据呢?
我们同样举电商平台场景中的例子,现在要求只缓存用户经常访问的 Top 1000 的商品。
解决缓存热点问题
那么缓存策略的总体思路:就是通过判断数据最新访问时间来做排名,并过滤掉不常访问的数据,只留下经常访问的数据,具体细节如下。
- 先通过缓存系统做一个排序队列(比如存放 1000 个商品),系统会根据商品的访问时间,更新队列信息,越是最近访问的商品排名越靠前。
- 同时系统会定期过滤掉队列中排名最后的 200 个商品,然后再从数据库中随机读取出 200 个商品加入队列中。
- 这样当请求每次到达的时候,会先从队列中获取商品 ID,如果命中,就根据 ID 再从另一个缓存数据结构中读取实际的商品信息,并返回。
- 在 Redis 中可以用 zadd 方法和 zrange 方法来完成排序队列和获取 200 个商品的操作。
前面的内容中,我们都是将缓存操作与业务代码耦合在一起,这样虽然在项目初期实现起来简单容易,但是随着项目的迭代,代码的可维护性会越来越差,并且也不符合架构的“高内聚,低耦合”的设计原则,那么如何解决这个问题呢?
回答的思路可以是这样:将缓存操作与业务代码解耦,实现方案上可以通过 MySQL Binlog + Canal + MQ 的方式。
我举一个实际的场景,比如用户在应用系统的后台添加一条配置信息,配置信息存储到了 MySQL 数据库中,同时数据库更新了 Binlog 日志数据,接着再通过使用 Canal 组件来获读取最新的 Binlog 日志数据,然后解析日志数据,并通过事先约定好的数据格式,发送到 MQ 消息队列中,最后再由应用系统将 MQ 中的数据更新到 Redis 中,这样就完成了缓存操作和业务代码之间的解耦。
Redis 如何实现延迟队列?
延迟队列是指把当前要做的事情,往后推迟一段时间再做。延迟队列的常见使用场景有以下几种:
- 在淘宝、京东等购物平台上下单,超过一定时间未付款,订单会自动取消;
- 点外卖的时候,如果商家在10分钟还没接单,就会自动取消订单;
在 Redis 可以使用有序集合(ZSet)的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间。
使用 zadd score1 value1 命令就可以一直往内存中生产消息。再利用 zrangebysocre 查询符合条件的所有待处理的任务, 通过循环执行队列任务即可。
秒杀
秒杀活动的主要思路是:削峰、限流、异步、补偿
削峰、限流通常在网关来做,有通用组件
高并发异步
异步这一步可以通过消息队列来实现,将抢和购解耦,还可以很方便地限频,不至于让MySQL过度承压。
抢的话使用Redis来做处理,因为Redis处理简单的扣减请求是非常快的,而直接到MySQL是比较力不从心。Redis可是单机支撑每秒几万的写入,并且可以做成集群,提高扩展能力的。
我们可以先将库存名额预加载到Redis,然后在Redis中进行扣减,扣减成功的再通过消息队列,传递到MySQL做真正的订单生成。
拒绝超卖
抢购时:
- 第一步,判断库存名额是否充足;
- 第二步,减少库存名额,扣减成功就是抢到。
这里有一个问题要考虑,如果第一步判断的时候还有库存,但是由于是并发操作,实际调用的时候,可能已经没有库存了,这样就会造成超卖。
所以第一步和第二步都是需要原子操作的。Redis+Lua
避免少卖
- 减少库存操作超时,但实际是成功的,因为超时并不会进入生成订单流程;
- 在Redis操作成功,但是向Kafka发送消息失败,这种情况也会白白消耗Redis中的库存。
我们只需要保证Redis库存+Kafka消费的最终一致性。
- 第一种,也最简单的方式,在投递Kafka失败的情况下,增加渐进式重试:
- 第二种,更安全一点,就是在第一种的基础上,将这条消息记录在磁盘上,慢慢重试;
Redis 的大 key 如何处理?
什么是 Redis 大 key?
大 key 并不是指 key 的值很大,而是 key 对应的 value 很大。
一般而言,下面这两种情况被称为大 key:
- String 类型的值大于 10 KB;
- Hash、List、Set、ZSet 类型的元素的个数超过 5000个;
大 key 会造成什么问题?
大 key 会带来以下四种影响:
- 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
- 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
- 阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
- 内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。
如何删除大 key?
删除操作的本质是要释放键值对占用的内存空间,不要小瞧内存的释放过程。
释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。
所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。
因此,删除大 key 这一个动作,我们要小心。具体要怎么做呢?这里给出两种方法:
1、分批次删除
2、异步删除
从 Redis 4.0 版本开始,可以采用异步删除法,用 unlink 命令代替 del 来删除。
这样 Redis 会将这个 key 放入到一个异步线程中进行删除,这样不会阻塞主线程。
参考:
https://xiaolincoding.com/redis/base/redis_interview.html
14 缓存策略:面试中如何回答缓存穿透、雪崩等问题? (lianglianglee.com)
34|缓存一致性问题:高并发服务如何保证缓存一致性? | JUST DO IT (leeshengis.com)
认识 MySQL 和 Redis 的数据一致性问题 (lianglianglee.com)
30 如何使用Redis实现分布式锁? (lianglianglee.com)