redis进阶之缓存

Author Avatar
Sean Yu 12月 25, 2020
  • 在其它设备中阅读本文章

缓存的收益和成本

收益:

  • 加速读写: 因为缓存通常都是全内存的(例如Redis、Memcache),而 存储层通常读写性能不够强悍(例如MySQL),通过缓存的使用可以有效 地加速读写,优化用户体验。
  • 降低后端负载: 帮助后端减少访问量和复杂计算(例如很复杂的SQL 语句),在很大程度降低了后端的负载。

成本:

  • 数据不一致性: 缓存层和存储层的数据存在着一定时间窗口的不一致性,时间窗口跟更新策略有关。
  • 代码维护成本: 加入缓存后,需要同时处理缓存层和存储层的逻辑, 增大了开发者维护代码的成本。
  • 运维成本: 以Redis Cluster为例,加入后无形中增加了运维成本。

缓存的使用场景基本包含如下两种:

  • 开销大的复杂计算: 以MySQL为例子,一些复杂的操作或者计算(例 如大量联表操作、一些分组计算),如果不加缓存,不但无法满足高并发量,同时也会给MySQL带来巨大的负担。
  • 加速请求响应: 即使查询单条后端数据足够快(例如select*from table where id=),那么依然可以使用缓存,以Redis为例子,每秒可以完成数万次读写,并且提供的批量操作可以优化整个IO链的响应时间。

缓存更新策略

LRU/LFU/FIFO算法剔除

使用场景

剔除算法通常用于缓存使用量超过了预设的最大值时候,如 何对现有的数据进行剔除。例如Redis使用maxmemory-policy这个配置作为内 存最大值后对于数据的剔除策略。

  • 一致性:要清理哪些数据是由具体算法决定,开发人员只能决定使用哪种算法,所以数据的一致性是最差的。
  • 维护成本:算法不需要开发人员自己来实现,通常只需要配置最大 maxmemory和对应的策略即可。开发人员只需要知道每种算法的含义,选择适合自己的算法即可。

Redis配置中和LRU有关的有三个:

  • maxmemory: 配置Redis存储数据时指定限制的内存大小,比如100m。当缓存消耗的内存超过这个数值时, 将触发数据淘汰。该数据配置为0时,表示缓存的数据量没有限制, 即LRU功能不生效。64位的系统默认值为0,32位的系统默认内存限制为3GB
  • maxmemory_policy: 触发数据淘汰后的淘汰策略
  • maxmemory_samples: 随机采样的精度,也就是随即取出key的数目。该数值配置越大, 越接近于真实的LRU算法,但是数值越大,相应消耗也变高,对性能有一定影响,样本值默认为5。

淘汰策略即maxmemory_policy的赋值有以下几种:

  • noeviction: 如果缓存数据超过了maxmemory限定值,并且客户端正在执行的命令(大部分的写入指令,但DEL和几个指令例外)会导致内存分配,则向客户端返回错误响应
  • allkeys-lru: 对所有的键都采取LRU淘汰
  • volatile-lru: 仅对设置了过期时间的键采取LRU淘汰
  • allkeys-random: 随机回收所有的键
  • volatile-random: 随机回收设置过期时间的键
  • volatile-ttl: 仅淘汰设置了过期时间的键—淘汰生存时间TTL(Time To Live)更小的键

volatile-lru, volatile-random和volatile-ttl这三个淘汰策略使用的不是全量数据,有可能无法淘汰出足够的内存空间。在没有过期键或者没有设置超时属性的键的情况下,这三种策略和noeviction差不多。

一般的经验规则:

  • 使用allkeys-lru策略:当预期请求符合一个幂次分布(二八法则等),比如一部分的子集元素比其它元素被访问的更多时,可以选择这个策略。
  • 使用allkeys-random:循环连续的访问所有的键时,或者预期请求分布平均(所有元素被访问的概率都差不多)
  • 使用volatile-ttl:要采取这个策略,缓存对象的TTL值最好有差异
  • volatile-lru 和 volatile-random策略:当你想要使用单一的Redis实例来同时实现 缓存淘汰 和 持久化一些经常使用的键集合 时很有用。未设置过期时间的键进行持久化保存,设置了过期时间的键参与缓存淘汰。不过一般运行两个实例是解决这个问题的更好方法。

为键设置过期时间也是需要消耗内存的,所以使用allkeys-lru这种策略更加节省空间,因为这种策略下可以不为键设置过期时间。

redis中的近似lru算法: https://zhuanlan.zhihu.com/p/34133067

超时剔除

使用场景

超时剔除通过给缓存数据设置过期时间,让其在过期时间后自动删除,例如Redis提供的expire命令。如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期后,再从真实数据源获取数据,重新放到缓存并设置过期时间。例如一个视频的描述信息,可以容忍几分钟内数据不一致,但是涉及交易方面的业务, 后果可想而知。

  • 一致性:一段时间窗口内(取决于过期时间长短)存在一致性问题,即 缓存数据和真实数据源的数据不一致。
  • 维护成本:维护成本不是很高,只需设置expire过期时间即可,当然前提是应用方允许这段时间可能发生的数据不一致。

主动更新

使用场景

应用方对于数据的一致性要求高,需要在真实数据更新后,立即更新缓存数据。例如可以利用消息系统或者其他方式通知缓存更新。

  • 一致性:一致性最高,但如果主动更新发生了问题,那么这条数据很可 能很长时间不会更新,所以建议结合超时剔除一起使用效果会更好。
  • 维护成本:维护成本会比较高,开发者需要自己来完成更新,并保证更新操作的正确性。

最佳实践

  • 低一致性业务建议配置最大内存和淘汰策略的方式使用。
  • 高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新出了问题,也能保证数据过期时间后删除脏数据。

缓存粒度控制

究竟是缓存全部属性还是只缓存部分重要属性呢?下面将从通用性、空间占用、代码维护三个角度进行说明。

  • 通用性:缓存全部数据比部分数据更加通用,但从实际经验看,很长时间内应用只需要几个重要的属性。
  • 空间占用:缓存全部数据要比部分数据占用更多的空间,可能存在以下问题:
    • 全部数据会造成内存的浪费。
    • 全部数据可能每次传输产生的网络流量会比较大,耗时相对较大,在极端情况下会阻塞网络。
    • 全部数据的序列化和反序列化的CPU开销更大。
  • 代码维护:全部数据的优势更加明显,而部分数据一旦要加新字段需要修改业务代码,而且修改后通常还需要刷新缓存数据。

穿透优化

缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,通常出于容错的考虑,如果从存储层查不到数据则不写入缓存层,整个过程分为如下3步:

  • 缓存层不命中。
  • 存储层不命中,不将空结果写回缓存。
  • 返回空结果。

缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。

缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕掉。通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。

造成缓存穿透的基本原因有两个

  • 自身业务代码或者数据出现问题
  • 一些恶意攻击、爬虫等造成大量空命中。下面我们来看一下如何解决缓存穿透问题。

缓存空对象

如图11-4所示,当第2步存储层不命中后,仍然将空对象保留到缓存层中,之后再访问这个数据将会从缓存中获取,这样就保护了后端数据源。

image.png

缓存空对象会有两个问题:

  • 空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
  • 缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。 例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。

布隆过滤器拦截

关于布隆过滤器, 这里不说了,这篇文章写得很好 https://segmentfault.com/a/1190000021136424

guava中的布隆过滤器 https://www.jianshu.com/p/bef2ec1c361f

image.png

可以利用Redis的Bitmaps实现布隆过滤器 https://github.com/erikdubbelboer/redis-lua-scaling-bloom-filter

其实挺简单的,本身布隆过滤器就是一个bitmap,只要加入的每个值计算多个hash,然后存入相应的偏移量上。取的时候同样计算hash,不过存在对应hash值偏移量上为0,就说明肯定没有这个key,不为0则说明有可能有这个key,因为布隆过滤器有误差。

关于bitmap算法看这里 https://juejin.cn/post/6844903769201704973


简单说一下redis里的Bitmaps吧

Bitmap在Redis中并不是一个单独的数据类型,而是由字符串类型(Redis内部称Simple Dynamic String,SDS)之上定义的与比特相关的操作实现的,此时SDS就被当做位数组了。因为String是二进制安全的并且它们的最大长度是512MB,所以String类型很合适去作为一个2^32长度的位数组。下面是在redis-cli中使用getbit和setbit指令的操作示例。

# 字符串"meow"的二进制表示:01101101 01100101 01101111 01110111
es1:19000> set bitmap_cat "meow"
OK
# 最低位下标为0。取得第3位的比特(0)
es1:19000> getbit bitmap_cat 3
(integer) 0
# 取得第23位的比特(1)
es1:19000> getbit bitmap_cat 23
(integer) 1
# 将第7位设为0
es1:19000> setbit bitmap_cat 7 0
(integer) 1
# 将第14位设为1
es1:19000> setbit bitmap_cat 14 1
(integer) 0
# 修改过后的字符串变成了"lgow"
es1:19000> get bitmap_cat
"lgow"

Redis的Bitmap是自动扩容的,亦即get/set到高位时,就会主动填充0。此外,还有bitcount指令用于计算特定字节范围内1的个数,bitop指令用来执行位运算(支持and、or、xor和not)。

bitmap一个最大的优势是它通常能在存储信息的时候节省大量空间。比方说一个用增量ID来辨别用户的系统,可以用仅仅512MB的空间来标识40亿个用户是否想要接受通知。

下面举一个bitmaps的实际例子,将每个独立用户是否访问过网站存放在Bitmaps中,将访问的用户记做1,没有访问的用户记做0,用偏移量作为用户的id。

设置值

setbit key offset value

设置键的第offset个位的值(从0算起),假设现在有20个用户, userid=0,5,11,15,19的用户对网站进行了访问,那么当前Bitmaps初始 化结果如图3-11所示。

image.png

具体操作过程如下,unique:users:2016-04-05代表2016-04-05这天的独立访问用户的Bitmaps:

127.0.0.1:6379> setbit unique:users:2016-04-05 0 1 
(integer) 0
127.0.0.1:6379> setbit unique:users:2016-04-05 5 1 
(integer) 0
127.0.0.1:6379> setbit unique:users:2016-04-05 11 1 
(integer) 0
127.0.0.1:6379> setbit unique:users:2016-04-05 15 1 
(integer) 0
127.0.0.1:6379> setbit unique:users:2016-04-05 19 1 
(integer) 0

如果此时有一个userid=50的用户访问了网站,那么Bitmaps的结构变成了图3-12,第20位~49位都是0。

image.png

很多应用的用户id以一个指定数字(例如10000)开头,直接将用户id 和Bitmaps的偏移量对应势必会造成一定的浪费,通常的做法是每次做setbit 操作时将用户id减去这个指定数字。在第一次初始化Bitmaps时,假如偏移 量非常大,那么整个初始化过程执行会比较慢,可能会造成Redis的阻塞。

获取值

gitbit key offset

获取键的第offset位的值(从0开始算),下面操作获取id=8的用户是否 在2016-04-05这天访问过,返回0说明没有访问过:

127.0.0.1:6379> getbit unique:users:2016-04-05 8 
(integer) 0

由于offset=1000000根本就不存在,所以返回结果也是0:

127.0.0.1:6379> getbit unique:users:2016-04-05 1000000
(integer) 0

获取Bitmaps指定范围值为1的个数

bitcount [start][end]

下面操作计算2016-04-05这天的独立访问用户数量:

127.0.0.1:6379> bitcount unique:users:2016-04-05
(integer) 5

[start]和[end]代表起始和结束字节数,下面操作计算用户id在第1个字节 到第3个字节之间的独立访问用户数,对应的用户id是11,15,19。

127.0.0.1:6379> bitcount unique:users:2016-04-05 1 3 
(integer) 3

Bitmaps间的运算

bitop op destkey key[key....]

bitop是一个复合操作,它可以做多个Bitmaps的and(交集)、or(并集)、not(非)、xor(异或)操作并将结果保存在destkey中。假设2016-04-04访问网站的userid=1,2,5,9,如图3-13所示。

image.png

下面操作计算出2016-04-04和2016-04-03两天都访问过网站的用户数量,如图3-14所示。

127.0.0.1:6379> bitop and unique:users:and:2016-04-04_03 unique: users:2016-04-03 unique:users:2016-04-03
(integer) 2
127.0.0.1:6379> bitcount unique:users:and:2016-04-04_03 
(integer) 2

如果想算出2016-04-04和2016-04-03任意一天都访问过网站的用户数量 (例如月活跃就是类似这种),可以使用or求并集,具体命令如下:

127.0.0.1:6379> bitop or unique:users:or:2016-04-04_03 unique: users:2016-04-03 unique:users:2016-04-03
(integer) 2
127.0.0.1:6379> bitcount unique:users:or:2016-04-04_03 
(integer) 6

image.png

Bitmaps分析

假设网站有1亿用户,每天独立访问的用户有5千万,如果每天用集合类型和Bitmaps分别存储活跃用户可以得到表3-3。

image.png

很明显,这种情况下使用Bitmaps能节省很多的内存空间,尤其是随着 时间推移节省的内存还是非常可观的.

但Bitmaps并不是万金油,假如该网站每天的独立访问用户很少,例如 只有10万(大量的僵尸用户),那么两者的对比如表3-5所示,很显然,这 时候使用Bitmaps就不太合适了,因为基本上大部分位都是0。


无底洞优化

2010年,Facebook的Memcache节点已经达到了3000个,承载着TB级别 的缓存数据。但开发和运维人员发现了一个问题,为了满足业务要求添加了 大量新Memcache节点,但是发现性能不但没有好转反而下降了,当时将这 种现象称为缓存的“无底洞”现象。

那么为什么会产生这种现象呢,通常来说添加节点使得Memcache集群 性能应该更强了,但事实并非如此。键值数据库由于通常采用哈希函数将 key映射到各个节点上,造成key的分布与业务无关,但是由于数据量和访问 量的持续增长,造成需要添加大量节点做水平扩容,导致键值分布到更多的 节点上,所以无论是Memcache还是Redis的分布式,批量操作通常需要从不同节点上获取,相比于单机批量操作只涉及一次网络操作,分布式批量操作 会涉及多次网络时间。

无底洞问题分析:

  • 客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随着节点的增多,耗时会不断增大。
  • 网络连接数变多,对节点的性能也有一定影响。

用一句通俗的话总结就是,更多的节点不代表更高的性能,所谓“无底洞”就是说投入越多不一定产出越多。但是分布式又是不可以避免的,因为访问量和数据量越来越大,一个节点根本抗不住,所以如何高效地在分布式缓存中批量操作是一个难点。

下面介绍如何在分布式条件下优化批量操作。在介绍具体的方法之前, 我们来看一下常见的IO优化思路:

  • 命令本身的优化,例如优化SQL语句等。
  • 减少网络通信次数。
  • 降低接入成本,例如客户端使用长连/连接池、NIO等。

这里我们假设命令、客户端连接已经为最优,重点讨论减少网络操作次数。
以Redis批量获取n个字符串为例,有三种实现方法:

  • 客户端n次get: n次网络+n次get命令本身。
  • 客户端1次pipeline get: 1次网络+n次get命令本身。
  • 客户端1次mget: 1次网络+1次mget命令本身。

上面已经给出了IO的优化思路以及单个节点的批量操作优化方式,下面我们将结合Redis Cluster的一些特性对四种分布式的批量操作方式进行说明。

串行命令

由于n个key是比较均匀地分布在Redis Cluster的各个节点上,因此无法使用mget命令一次性获取,所以通常来讲要获取n个key的值,最简单的方法,就是逐次执行n个get命令,这种操作时间复杂度较高,它的操作时间=n次网络时间+n次命令时间,网络次数是n。很显然这种方案不是最优的,但是实现起来比较简单。

串行IO

Redis Cluster使用CRC16算法计算出散列值,再取对16383的余数就可以算出slot值,我们之前提到过Smart客户端会保存slot和节点的对应关系,有了这两个数据就可以将属于同一个节点的key进行归档,得到每个节点的key子列表,之后对每个节点执行mget或者Pipeline操作,它的操作时间=node次网络时间+n次命令时间,网络次数是node的个数.

并行IO

此方案是将方案2中的最后一步改为多线程执行,网络次数虽然还是节 点个数,但由于使用多线程网络时间变为O(1),这种方案会增加编程的复杂度。它的操作时间为: max_slow(node网络时间)+n次命令时间

hash_tag实现

hash_tag功能(KEY里面加大括号),它可以将多个key强制分配到一个节点上,它的操作时间=1次网络时间+n次命令时间, 但hash_tag容易出现数据倾斜

雪崩优化

由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况。预防和解决缓存雪崩问题,可以从以下三个方面进行着手。

保证缓存层服务高可用性

和飞机都有多个引擎一样,如果缓存层设计成高可用的,即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务,例如前面介绍过的Redis Sentinel和Redis Cluster都实现了高可用。

依赖隔离组件为后端限流并降级

无论是缓存层还是存储层都会有出错的概率,可以将它们视同为资源。作为并发量较大的系统,假如有一个资源不可用,可能会造成线程全部阻塞(hang)在这个资源上,造成整个系统不可用。

降级机制在高并发系统中是非常普遍的: 比如推荐服务中,如果个性化推荐服务不可用,可以降级补充热点数据,不至于造成前端页面是开天窗。

在实际项目中,我们需要对重要的资源(例如Redis、MySQL、 HBase、外部接口)都进行隔离,让每种资源都单独运行在自己的线程池中,即使个别资源出现了问题,对其他服务没有影响。但是线程池如何管理,比如如何关闭资源池、开启资源池、资源池阀值管理,这些做起来还是相当复杂的。

打散过期时间

为了避免大量的缓存在同一时间过期,可以把不同的key过期时间随机生成,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。但随机可能会对业务有影响,但可以根据业务特点进行设置,总之是让过期时间分散。也有是通过定时刷新过期时间,类似于refresh token机制。

热点key重建优化(缓存击穿)

开发人员使用“缓存+过期时间”的策略既可以加速数据读写,又保证数 据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:

  • 当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。
  • 重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的 SQL、多次IO、多个依赖等。

在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。

互斥锁/分布式锁

此方法只允许一个线程重建缓存更新key,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可(加入队列,排队更新也可以)

永远不过期

“永远不过期”包含两层意思:

  • 从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期。
  • 从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。

从实战看,此方法有效杜绝了热点key产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况,这取决于应用方是否容忍这种不一致。

热key处理

  • 拆分复杂数据结构: 如果当前key的类型是一个二级数据结构,例如 哈希类型。如果该哈希元素个数较多,可以考虑将当前hash进行拆分,这样该热点key可以拆分为若干个新的key分布到不同Redis节点上,从而减轻压力。
  • 迁移热点key: 以Redis Cluster为例,可以将热点key所在的slot单独 迁移到一个新的Redis节点上,但此操作会增加运维成本。
  • 本地缓存加通知机制: 可以将热点key放在业务端的本地缓存中,因为是在业务端的本地内存中,处理能力要高出Redis数十倍,但当数据更新时,此种模式会造成各个业务端和Redis数据不一致,通常会使用发布订阅机制来解决类似问题。

bigkey处理

bigkey是指key对应的value所占的内存空间比较大,例如一个字符串类 型的value可以最大存到512MB,一个列表类型的value最多可以存储232-1个 元素。如果按照数据结构来细分的话,一般分为字符串类型bigkey和非字符串类型bigkey。

bigkey的危害:

  • 内存空间不均匀(平衡):例如在Redis Cluster中,bigkey会造成节点的内存空间使用不均匀。
  • 超时阻塞: 由于Redis单线程的特性,操作bigkey比较耗时,也就意味 着阻塞Redis可能性增大。
  • 网络拥塞: 每次获取bigkey产生的网络流量较大,假设一个bigkey为 1MB,每秒访问量为1000,那么每秒产生1000MB的流量,对于普通的千兆 网卡(按照字节算是128MB/s)的服务器来说简直是灭顶之灾,而且一般服 务器会采用单机多实例的方式来部署,也就是说一个bigkey可能会对其他实 例造成影响,其后果不堪设想。

解决:

  • string可以直接删除
  • 集合对field循环删除
  • Redis4.0,lazy delete free模式
  • 业务优化,避免bigkey

双写一致问题

涉及到数据更新:数据库和缓存更新,就容易出现缓存和数据库间的数据一致性问题。不管是先写数据库,再删除缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。举个例子:

  • 如果删除了缓存Redis,还没有来得及写库MySQL,另一个线程就来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据。
  • 如果先写了库,在删除缓存前,写库的线程宕机了,没有删除掉缓存,则也会出现数据不一致情况。

因为写和读是并发的,没法保证顺序,就会出现缓存和数据库的数据不一致的问题。如何解决?这里给出两个解决方案,先易后难,结合业务和技术代价选择使用。

延时双删策略

在写库前后都进行redis.del(key)操作,并且设定合理的超时时间。具体步骤是:

  • 先删除缓存
  • 再写数据库
  • 休眠500毫秒(根据具体的业务时间来定)
  • 再次删除缓存。

这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

那么,这个500毫秒怎么确定的,具体该休眠多久呢?需要评估自己的项目的读数据业务逻辑的耗时。

当然,这种策略还要考虑 redis 和数据库主从同步的耗时。最后的写数据的休眠时间:则在读数据业务逻辑的耗时的基础上,加上几百ms即可。比如:休眠1秒。

设置缓存的过期时间

从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。所有的写操作以数据库为准,只要到达缓存过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存

结合双删策略+缓存超时设置,这样最差的情况就是在超时时间内数据存在不一致,而且又增加了写请求的耗时。

如何写完数据库后,再次删除缓存成功?

上述的方案有一个缺点,那就是操作完数据库后,由于种种原因删除缓存失败,这时,可能就会出现数据不一致的情况。这里,我们需要提供一个保障重试的方案。

1、方案一具体流程

(1)更新数据库数据;

(2)缓存因为种种问题删除失败;

(3)将需要删除的key发送至消息队列;

(4)自己消费消息,获得需要删除的key;

(5)继续重试删除操作,直到成功。

然而,该方案有一个缺点,对业务线代码造成大量的侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。

2、方案二具体流程

(1)更新数据库数据;

(2)数据库会将操作信息写入binlog日志当中;

(3)订阅程序提取出所需要的数据以及key;

(4)另起一段非业务代码,获得该信息;

(5)尝试删除缓存操作,发现删除失败;

(6)将这些信息发送至消息队列;

(7)重新从消息队列中获得该数据,重试操作。