分布式缓存

本文介绍关于缓存的常用设计模式。以及如何保证缓存的一致性进行分类讨论。
还会介绍关于缓存失效的常见问题,以及针对缓存失效的解决方法。

在高并发的环境下,比如春节抢票大战,一到放票的时间节点,分分钟大量用户以及黄牛的各种抢票软件流量进入12306,这时候如果每个用户的访问都去数据库实时查询票的库存,大量读的请求涌入到数据库,瞬间Db就会被打爆,cpu直接上升100%,服务马上就要宕机或者假死。即使进行了分库分表也是无法避免的。为了减轻db的压力以及提高系统的响应速度。一般都会在数据库前面加上一层缓存,甚至可能还会有多级缓存。

想要在压力测试中提高接口的吞吐量,就不得不说到缓存这一优化方案。

缓存又分进程内缓存和分布式缓存两种:

  • 本地(进程内)缓存如ehcache、GuavaCache、Caffeine等。
    • 可以简单的在代码中使用诸如Map一类的数据结构,存储数据
  • 分布式缓存如redis、memcached等。
    • 分布式则需要在一个所有节点均能访问到的位置存储数据

那么那些数据适合放入缓存?

  • 及时性、数据一致性要求不高的
  • 访问量大且更新频率不高的数据(读多,写少)

常用技巧

  • 设置过期时间:在开发中,凡是放入缓存中的数据我们都应该指定过期时间,使其可以在系统即使没有主动更新数据也能自动触发数据加载进缓存的流程。避免业务崩溃导致的数据永久不一致问题。

缓存一致性问题

首先,我们得清楚“数据的一致性”具体是啥意思。其实,这里的“一致性”包含了两种情况:

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

常见缓存使用模式

  • Cache-Aside pattern
  • Read-Through
  • Write-Through
  • Write Behind Caching Pattern

Cache-Aside pattern

参考:Microsoft Design Patterns: Cache-Aside pattern

一般我们更新缓存会使用旁路缓存(Cache Aside Pattern)的方式,按需将数据存入缓存,缓存中并不存储所有数据。具体逻辑如下:

  1. 确定数据是否存在于缓存中,存在则直接返回
  2. 如果不在缓存中,则从数据库中读取数据
  3. 将从数据中读取的数据存入缓存

整体流程图

伪代码

data = cache.load(id);  //从缓存加载数据
if (data == null) {
    data = db.load(id);  //从数据库加载数据
    cache.put(id, data);  //保存到 cache 中
}
return data;

Java Spring代码

在Spring中可以使用框架中的缓存抽象,可使用@Cacheable注解,如下实现,当getRecordForSearch()方法被调用的时候,如果缓存中存在对应key的数据,那就会自动的从缓存中获取(此时方法体不会被执行),当缓存中不存在key对应数据的时候,会执行方法体从数据库中查询数据并设置到缓存中去。

@Cacheable("default", key="#search.keyword")
public Record getRecordForSearch(Search search)

default 为分区名,key支持spEL表达式,普通字符串必须加单引号,为redis中的键。

这个注解默认不开启锁,使用sync可以开启锁,但是锁的实现方式是使用 synchronized代码块实现的单机锁,在分布式下是锁不住所有节点的。

@Cacheable("default", key="#search.keyword", sync=true)

数据更新

如果数据被更新,我们还需要使用其他策略来修改缓存区的数据。流程一定都是先修改数据库中的数据,之后再来操作缓存里的数据。这里有三种常见的方式。

  • 失效模式,让缓存失效
  • 双写模式,让缓存更新
  • 订阅模式,订阅数据库binlog日志

失效模式,让缓存失效

该情况下,当请求需要更新数据库数据的时候,缓存中的值需要被删除掉(删除掉就表示旧值不可用了),当下次该key被再次查询到就去数据库中查出最新的数据。

顺序问题:那我们应该先删除缓存,再修改数据库呢,还是应该先修改数据库,再删除缓存呢?

▶ 假如我们先删除缓存,再修改数据库。

试想,两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放入缓存中,然后更新操作更新了数据库。于是缓存中的数据还是老数据,导致缓存中的数据是脏的,而且之后缓存中一直是脏数据。

▶ 假如我们先修改数据库,再删除缓存。

比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。

但,这个情况理论上会出现,不过,实际上出现的概率可能非常低。

因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。

失效模式,在Spring中可以使用@CacheEvict注解,实现如下:

@CacheEvict("default", key="#search.keyword")
public Record updateRecordForSearch(Search search)

双写模式,让缓存更新

缓存数据也可以在数据库更新的时候被更新,从而在一次操作中让之后的查询有更快的查询体验和更好的数据一致性。

顺序问题:那我们应该先更新缓存,再修改数据库呢,还是应该先修改数据库,再更新缓存呢?

▶ 假如我们先更新缓存,再修改数据库。

写+写并发:线程A和线程B同时更新同一条数据,更新数据库的顺序是先A后B,但更新缓存时顺序是先B后A,这会导致数据库和缓存的不一致。

▶ 假如我们先修改数据库,再更新缓存。

写+写并发:与上一条类似,线程A和线程B同时更新同一条数据,更新缓存的顺序是先A后B,但是更新数据库的顺序是先B后A,这也会导致数据库和缓存的不一致。

在Spring中可以使用@CachePut注解,注意函数返回值一定要是存入缓存中的对象。实现如下:

@CachePut("default", key="#search.keyword")
public Record updateRecordForSearch(Search search)

订阅模式,订阅数据库binlog日志

canal

canal [kə'næl],译意为水道/管道/沟渠,主要用途是基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费。

早期阿里巴巴因为杭州和美国双机房部署,存在跨机房同步的业务需求,实现方式主要是基于业务 trigger 获取增量变更。从 2010 年开始,业务逐步尝试数据库日志解析获取增量变更进行同步,由此衍生出了大量的数据库增量订阅和消费业务。

基于日志增量订阅和消费的业务包括

  • 数据库镜像
  • 数据库实时备份
  • 索引构建和实时维护(拆分异构索引、倒排索引等)
  • 业务 cache 刷新
  • 带业务逻辑的增量数据处理

canal 工作原理

  • canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议
  • MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal )
  • canal 解析 binary log 对象(原始为 byte 流)

Flink CDC

Flink CDC也是阿里的开源技术,这篇官方样例分别使用MySQL和Postgres中的两张表,在其表数据变动后,实时通过流的方式将最新数据写入ES中。

过程中只需要用到Flink SQL,无需一行Java代码,即可实现。

方案总结

上述无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。

  1. 如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可
  2. 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
  3. 缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
  4. 通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心脏数据,允许临时脏数据可忽略)
  5. 使用读写缓存同时操作数据库和缓存时,因为其中一个操作失败导致不一致的问题,可以通过消息队列重试来解决。

总结

  • 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
  • 我们不应该过度设计,增加系统的复杂性
  • 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。
  • 遇到强一致性的且一定要加缓存的需求,可以使用读写锁来让操作排序。可以通过消息队列重试来解决,缓存或数据库其中一个操作失败的问题。
  • 要想做到强一致,最常见的方案是 2PC、3PC、Paxos、Raft 这类一致性协议,但它们的性能往往比较差,而且这些方案也比较复杂,还要考虑各种容错问题。

Read/Write Through

我们可以看到,在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。

所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。

核心思想:应用需要操作数据时只与缓存组件进行交互;缓存里的数据不会过期。

Read-Through

Read-Through和Cache-Aside很相似,不同点在于程序不需要再去管理从哪去读数据(缓存还是数据库)。

相反它会直接从缓存中读数据,该场景下是缓存去决定从哪查询数据。当我们比较两者的时候这是一个优势因为它会让程序代码变得更简洁。

Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。

Write-Through

Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)

下图自来Wikipedia的Cache词条。其中的Memory你可以理解为就是我们例子里的数据库。

Write-Behind

适用场景:读少写多
存在的问题:异步或间隔一定时间的批量回写会导致数据延迟或数据丢失的情形出现。

Write Back套路,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。

但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是取舍Trade-Off。

另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。

在wikipedia上有一张write back的流程图,基本逻辑如下:

缓存失效问题

大并发读下,可能会产生以下几个缓存失效问题。

缓存雪崩

指的是我们设置缓存时key采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。

解决 原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

缓存穿透

指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。

风险 利用不存在的数据进行攻击,数据库瞬时压力增大,最终导致崩溃。

解决 null结果缓存,并加入短暂过期时间。

缓存击穿

  • 对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发的访问,是一种非常“热点”的数据。
  • 如果这个key在大量请求同时进来前正好失效,那么所有对这个key的数据查询都落到db,我们称为缓存击穿。

解决 加锁,大量并发只让一个去查,其他人等待,查到以后释放锁,其他人获取到锁,先查询缓存,就会有数据,不用去db。

使用分布式锁来解决

参考文章

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Sajor_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值