实习记录(三) 缓存常见解决方案

阅读: 评论:0

实习记录(三) 缓存常见解决方案

实习记录(三) 缓存常见解决方案

美团技术团队文章主页:/

一.背景

        用户在一次请求路径中,其实会经过很多缓存节点,如浏览器缓存、CDN节点缓存、网关代理缓存,以及在各业务系统内常用的本地缓存、分布式缓存等。而缓存也作为高并发系统三大保护利器之一(缓存,限流,降级),可以很大程度提升系统访问速度,增加系统吞吐量和并发用户数,可谓是抗高并发流量的利器。

        开发中面对不同的业务场景有不同的缓存,常用的有本地缓存和分布式缓存,对于单机的、数据量不大的数据,用HashMap就能实现一个简单的缓存,也可以借助Guava Cache等工具来实现。对于分布式缓存,常用的如美团自研的Squirrel(redis),Celler(tair)等。这里不讲这些缓存介质他们的原理和特性。主要是探讨下平时在项目使用缓存过程中特别是高并发场景下需要考虑的一些问题以及其应对的套路。

二.缓存更新的几种模式

        在应用中,缓存挡在数据库的前面抵挡了大量的数据查询,直接减轻了数据库的压力,对于缓存更新策略通常有以下几种模式:

  • Cache Aside Pattern

  • Read/Write Through Pattern

  • Write Behind Caching Pattern

1.Cache Aside Pattern 模式

        这种模式通常是平时应用最广泛的一种模式,也称为旁路缓存策略。其没有单独的缓存维护组件,缓存和db的读写操作均由应用方负责,对于读写请求处理分别为:

  • 读请求:先读缓存,若命中则返回。若没有命中,从数据库中查询数据写入缓存并返回
  • 写请求:先更新数据库,然后将缓存中的数据失效掉(注意是失效而不是更新)

​        通常在应用中,写缓存和写入数据库是两个独立的事务,选择先更新缓存还是先更新数据库在高并发的情况下,都有可能会产生数据不一致的问题,如以下情况,注:抛开因为写数据库失败或写缓存失败造成不一致的因素,这种情况一般使用消息队列来保证操作的成功。

(1)为什么不是先删缓存,再更新数据库?

        这种情况下,当同时2个并发的读和写请求容易导致脏数据和数据不一致问题。试想同时有读写2个请求:

  •  写请求A首先删除了缓存,并删除成功,这时还未开始更新数据库。​​​​​​​
  • ​​​​​​​​​​读请求B查询缓存未命中,然后查询数据库,查询出了旧数据并将旧数据写入缓存。
  • 写请求A继续将新数据写入数据库。
  • 此时缓存中的数据就出现了不一致,并且一直脏下去​​​​​​​​

(2)为什么更新操作是将cache失效,而不是更新?

        如果仅更新缓存则会造成两个方面的问题,一是同时2个并发的写请求时可能会导致脏数据;二是违背数据懒加载原则:

  • 并发写请求导致脏数据

    1. 写请求A先更新了数据库。

    2. 之后写请求B成功更新了数据库,并成功更新了缓存。

    3. 写请求A最后更新了缓存,此时写请求A的数据已经是脏数据,造成了不一致,并且会一致脏下去。

  • 违背数据懒加载原则

​​​​​​​​​​​​​​        避免不必要的计算消耗:有些缓存值是需要经过复杂的计算才能得出,如果每次更新数据的时候都更新缓存,但是后续在一段时间内并没有读取该缓存数据,这样就白白浪费了大量的计算性能,完全可以后续由读请求的时候,再去计算即可,这样更符合数据懒加载,降低计算开销。

(3)Cache Aside Pattern 模式也会出现不一致的问题

        实际上先更新db,再失效cache这种模式理论上也可能出现问题,只是相对于以上的更新顺序,出现不一致的几率会更小:

  1. 读请求A首先读取缓存未命中,这个时候去读数据库成功查询到数据。

  2. 写请求B进来更新数据库成功,并删除缓存的数据成功。

  3. 最后请求A再将查询的数据写入到缓存中,而此时请求A写入的数据已经是脏数据,造成了数据不一致。

        之所以建议用这种更新顺序,因为理论上造成不一致的几率会比较小,要达到不一致需要读请求要先与写请求查询,然后后与写请求返回,通常来说数据库的查询的耗时会小于数据库写入的耗时,所以这种问题出现概率会比较小。

2.Read/Write Through Pattern 模式

        Cache Aside Pattern 模式中由应用方维护数据库和缓存的读写,导致应用方数据库和缓存的维护设计侵入代码,数据层的耦合增大,代码复杂性增加。而Read/Write Through Pattern模式弥补了这一问题,调用方无需管理缓存和数据库调用,通过在设计中多抽象出一层缓存管理组件来负责和缓存和数据库读写维护,并且缓存和数据库的读写维护是同步的。调用方直接和缓存管理组件打交道,缓存和数据库对调用方是透明的视为一个整体。通过分离出缓存管理组件,解耦业务代码。

  • Read Through:应用向缓存管理组件发送查询请求,由缓存管理组件查询缓存,若缓存未命中,查询数据库,并将查询的数据写入缓存,并返回给应用。​

  • Write Through:Write Through 套路和Read Through相仿,当更新数据的时候,将请求发送给缓存管理组件,由缓存管理组件同步更数据库和缓存数据。

3.Write Behind Caching Pattern 模式

        Write Behind模式和Write Through模式整个架构是一样的,最核心的一点在于write through在缓存数据库中的更新是同步的,而Write Behind是异步的。该模式每次的请求写都是直接更新缓存然后就成功返回,并没有同步把数据更新到数据库。而把更新到数据库的过程称为flush,触发flush的条件可自定义,如定时或达到一定容量阈值时进行flush操作。并且可以实现批量写,合并写等策略,也有效减少了更新数据的频率,这种模式最大的好处就是读写响应非常快,吞吐量也会明显提升,因为都是跟cache交互。当然这种模式也有其他的问题。例如:数据不是强一致性的,因为选择了把最新的数据放在缓存里,如果缓存在flush到数据库之前宕机了就会丢失数据,另外实现也是最复杂的。

4.几种模式的优缺点

模式

优点

缺点

Cache Aside

1.实现简单

1.需要调用方维护缓存和db的更新逻辑

2.代码侵入大

Read/Write Through

1.引入缓存管理组件,缓存和数据库的维护对应用方式透明的

2.应用代码入侵小,逻辑更清晰

1.引入缓存管理组件,实现更复杂

Write Behind Caching

1.读写直接和缓存打交道,异步批量更新数据库,性能最好

2.缓存和数据库对应用方透明

1.实现最复杂

2.数据丢失的风险

3.一致性最弱

三.缓存一致性

1.一致性问题

        由于引入缓存,数据就分散在两处不同数据源,并且现在的缓存组件通常都是分布式缓存,请求缓存加上网络IO过程还是比较耗时,如果包含在数据库的事务控制内,会增加事务控制粒度和事务释放的耗时,造成大量的数据库连接挂起,严重的降低系统性能,甚至会因为数据库连接数过多,导致系统崩溃。因此缓存和数据库的更新通常是两个事务,缓存和数据库的一致性问题其实又回到了老生常谈的分布式一致性问题上面来了。造成这种问题的原因通常有以下两个层面。

(1)业务层面

        业务层面主要是选择缓存更新模式的不同造成的不一致,例如上述的Cache Aside Pattern 里不管是先更新db还是先删除或更新cache,在高并发的情况下都有可能造成不一致的情况,只是不同的更新方式造成不一致的概率不一样,尽可能的选择造成不一致概率最小的更新模式。

(2)系统层面

        系统层面也就是分布式一致性中单个节点系统问题导致失败造成的不一致,在这里就是如缓存服务的机器宕机,网络异常造成的更新失败等。

2.解决方案

2.1 强一致性

(1)采用强一致性协议

(2)并行请求转为串行化

但是2种方式都将严重降低系统的吞吐量,这里不做讨论。

2.2 最终一致性

        在绝大部分场景中,特别是互联场景下,大多是保证最终一致性。

(1)重试机制

  • 应用更新数据库,若这一步就失败,那么更新事务失败回退。

  • 应用更新缓存失败,将失败的数据写入mq

  • 消费mq得到失败的数据,重试删除缓存

        整个过程考虑了数据库写入成功,缓存因系统故障等写入失败,导致数据库和缓存此时数据不一致,将失败的数据写入mq,监听mq重试删除缓存来到达最终一致性。缺点是整个重试写入的维护都在业务代码中,代码侵入性比较高。因此可以考虑一下方式引入databus,订阅数据更新binlog,解耦缓存更新过程。

(2)重试+binlog

  • 应用更新数据库,binlog日志同步databus。

  • 缓存管理组件订阅binlog,并删除缓存,失败则将缓存key写入mq。

  • 缓存管理组件订阅mq,重试删除缓存。

        通过引入databus和缓存管理组件,将缓存更新的维护和业务代码解耦。另一个原因是,现在的数据库通常是主从架构来提升整体的查询qps,因数据库主从同步的延迟,删除缓存后,如果此时从数据库还未同步完成,新来的请求发现缓存失效了,从从库里查询了已经过期的数据放到缓存中,也会造成数据的不一致。而通过订阅binlog的同步的延迟性,使删除缓存的时序延后,进一步降低不一致的几率。

2.3 总结

        缓存能带来高性能的一个很重要原因就是牺牲掉了强一致性,数据更新会有延迟,就会有分布式事务的问题。对于一致性通常会采用最终一致性,而设置缓存过期时间也是最终一致性的一种思想。过期时间设置太短,会造成缓存过多回溯到db,设置太长,又会使脏数据长时间停留在cache中,增加了不一致的时间,以及缓存了过多的冷数据也会浪费缓存的内存资源。因此在平时的设计中运用缓存就会面临这些问题,高性能和一致性就是系统要做trade-off的地方,也是我们要考虑是否要用缓存的因素。

四.缓存的问题场景

        通常使用缓存时,缓存充当了前置查询,当缓存查询未命中时,请求将回溯到后端db。因此缓存减轻了高并发场景下的查询压力,但高并发场景下也带来了缓存访问时的一些风险,主要是缓存失效,增加回溯率。常见的几种常见是缓存穿透,缓存雪崩,热key,大key等问题。

1.缓存穿透

1.1 问题概述

        正常情况下,如果缓存设计比较合理的情况下,通常是能够命中缓存的,减少请求回溯到数据库层。但如果大量的非法请求都去查询压根数据库中根本就不存在的数据,也就是缓存和数据库都查询不到这条数据,但是请求每次都会打到数据库上面去,缓存就形同虚设,缓存命中率为0,这种情况我们称之为缓存穿透。

        每次请求都穿过缓存到达数据库层,压力直接打到数据库上,试想一下,如果有人恶意对你的系统进行攻击,拿大量不存在的key去请求接口,导致大量请求穿过缓存到达数据库,增加数据库压力甚至宕机。

1.2 解决方案

1.2.1 业务非法参数校验

        在上层业务上做非法参数校验,尽量避免非法参数的请求case打到cache层。

1.2.2 缓存空对象

        因为每次查缓存都不存在,然后回溯到db去查询也不存在。因此可以把这种不存在的key也缓存起来,设置标识空的标识值,如“##”,那么就无法穿透到db层,但是要记到设置过期时间。这种方式的好处在于实现简单,但是会占用缓存空间,如果空数据的命中率不高,而且遇到的比较多非法请求时,会增加缓存空间的压力。

public Object getCache(final String key) {Object value = (key);if (value != null) {if (value.equals("##")) {return null;}return value;}Object valueFromDb = getValueFromDb(key);if (value == null) {valueFromDb = "##"; //"##"缓存标识为空}redis.set(key, valueFromDb, t);return valueFromDb;}
1.2.3 布隆过滤器

        缓存穿透是每次查询都要经过缓存,查询未命中回溯到数据库中,如果我们用一种存储结构存储所有数据,在查询缓存之前提前过滤要查询的数据是否一定不存在,如果存在就不用再去查缓存了,也就避免了缓存穿透的问题,当然这对过滤器的存储结构要求就比较高了。如果不考虑内存容量问题,hashmap是最简单的一种过滤器,把所有数据存在map中,通过get(key)是否存在进行过滤,当然我们这里用hashmap是不现实的,因为不满足内存容量要求,而布隆过滤器就是这样一种用比较少的内存存储大量数据映射,能满足提前过滤要查询的数据在系统中是否一定不存在的要求。

        下面简单介绍下布隆过滤器,借助bitset的存储特性和一组Hash算法构成,是一种空间效率极高的概率型算法和数据结构,主要用来判断一个元素是否在集合中存在。初始时bit数组每一位为0,每个输入值通过一组hash算法,得到一组bit数组的下标,并把该bit位标识为1。如果一个要验证的值通过这组hash函数得到的下标有不为1的情况,那么可以肯定这个值肯定不存在。

(1)写入过程

写入过程(假设有3个hash函数):

  • 初始时bit数组每一位都初始标记为0。

  • "user_10"这个值经过3个hash函数计算后,得到的bit数组下标分别为[3,6,12],并把对应的bit数组标识为1。

  • 同理值为"user_20"经过hash计算得出的下标为[6,10,15],分别标识为1。注意在标记数值位6时已经被标记为1,则保持为1。

​​​​​​​(2)查询过程

查询一个原始值在布隆过滤器中是否存在的过程如下:

  • 待查询的值"user_30" 经过同样的hash计算后得出的bit下标为[3,6,13],可以看出13位为0,则"user_30"一定不存在。

  • 待查询的值"user_20" 经过同样的hash计算后得出的bit下标为[6,10,10],可以看出都匹配为1,能否得出在布隆过滤器中这个值一定存在的结论?

  • 其实不一定,因为有hash冲突的存在,多个值hash的bit位重复的情况都标识为1,假如不存在的值"user_404"值hash得出下标为[3,6,10],因为hash冲突得出都为1,其实这个值并不存在,因此,“布隆过滤器能确定一个值一定不存在,但是不能确定一个值一定存在”。但我们缓存穿透要利用的就是前一句"布隆过滤器能确定一个值一定不存在",因此不存在计算误差。

(3)使用布隆过滤器解决缓存穿透

        了解布隆过滤器原理后,再回到用布隆过滤器解决缓存穿透问题就很简单了,在缓存前加一层布隆过滤器,利用布隆过滤器bitset存储结构存储数据库中所有值,查询缓存前,先查询布隆过滤器,若一定不存在就返回,不用再回溯流量到缓存服务,过程如下:

(4)布隆过滤器伪代码
private final BloomFilter<String> bloomFilter &#ate(Funnels.stringFunnel(Charsets.UTF_8), 1024 * 1024 * 32);//查询布隆过滤器中是否存在public boolean contains(String cacheKey) {if (StringUtils.isEmpty(cacheKey)) {return true;}boolean exists = bloomFilter.mightContain(cacheKey);if (!exists) {bloomFilter.put(cacheKey);}return exists;}//模拟初始化布隆过滤器,从db填充所有数据public void initBF() {int offset = 0;int limit = 200;while (true) {List<String> dataFromDb = listFromDb(offset, limit);if (CollectionUtils.isEmpty(dataFromDb)) {break;}for (String s : dataFromDb) {bloomFilter.put(s);}offset += limit;}}

1.3 方案对比

方案

使用场景

使用成本

缓存空对象

1. 空数据量不大

2. 数据频繁变化实时性高

1.代码维护简单

2.需要过多的缓存空间

3. 数据不一致

过滤器

1.数据量比较大

2. 数据命中不高

3. 数据相对固定实时性低

1.代码维护复杂

2.缓存空间占用少

2. 缓存雪崩

2.1 问题概述

        缓存层挡在db层前面,抗住了非常多的流量,在分布式系统中,“everything will fails”,缓存作为一种资源,当cache crash后,流量集中涌入下层数据库,称之为缓存雪崩。造成这种问题通常有2种原因:

  • 业务层面:大量的缓存key同时失效,失效请求全部回源到数据库,造成数据库压力过大崩溃。

  • 系统层面:缓存服务宕机等。

2.2 解决方案

2.2.1 分散过期时间

        业务层面的原因,主要是缓存key过期时间一致,造成同一时间,大量缓存key同时失效。针对这种问题的解决方案,主要是防止缓存在同一时间一期过期,如在设置的过期时间的基础上增加1-5分钟的随机值,使缓存失效时间比较均匀

2.2.2 提前演练压测

        提前做好系统的演练压测,发现性能瓶颈,预估合适的系统存储和计算容量。

2.2.3 cache高可用+后端数据库限流

        缓存作为一种系统资源,且通常充当关键路径关键资源,应尽可能提升缓存的可用性,如redis的sentinel和cluster机制等。尤其,缓存在餐饮开放平台中使用率很高,存储了开发者信息,signkey,回调地址等重要信息,qps近3w,因此可以采用双缓存热备份方案来进可能提升缓存资源的可用性,以下是双缓存查询流程设计和主备代码切换逻辑。

public class CacheSupplier {@Qualifier(value = "tairCache")@Autowiredprivate HACache tair;@Qualifier(value = "squirrelCache")@Autowiredprivate HACache squirrel;@Autowiredprivate CacheConfig cacheConfig;//当前主备缓存引用,默认master(squirrel) slave(celler)private CachePair cachePair;public CachePair getCachePair() {//缓存开关if (!cacheConfig.isCacheOn()) {return null;}//缓存切换,若master缓存熔断,触发缓存切换,由slave缓存充当master缓存提供服务if (cachePair != null && !cacheConfig.changed()) {return cachePair;}//主备缓存互相切换synchronized (this) {if (cachePair == null || cacheConfig.changed()) {ConfigLock().lock();try {CachePair cp = new CachePair();if (CacheConfig.TAIR.MasterCache())) {cp.setMaster(tair);} else if (CacheConfig.SQUIRREL.MasterCache())) {cp.setMaster(squirrel);}if (cp.getMaster() == null) {cp.setMaster(tair);}if (cacheConfig.isSlaveOn()) {if (CacheConfig.TAIR.SlaveCache())) {cp.setSlave(tair);} else if (CacheConfig.SQUIRREL.SlaveCache())) {cp.setSlave(squirrel);}if (cp.getSlave() == null) {cp.setSlave(squirrel);}}cachePair = cp;cacheConfig.unchanged();} finally {ConfigLock().unlock();}}}return cachePair;}
}

        除此之外,后端数据库限流(Hystrix),缓存层宕机,流量集中打到数据库,会再次让db崩溃。为保护这种情况下的db,建议在db层加入限流(Hystrix)。

3. 缓存击穿

3.1 问题概述

        缓存系统中会有部分热点数据,查询量很大,并且通常缓存会设有过期时间,在这种情况下缓存击穿是指当某一个热点key失效的时候,很多请求这一时间都查不到缓存,然后全部请求并发打到了数据库去查询数据构建缓存,造成数据库压力非常大甚至宕机,并且全部请求去数据库查询构建缓存是没必要的,只需要一次查询去构建缓存就行了。

3.2 解决方案

3.2.1 互斥锁

        因为是同一时间很多请求并发的访问数据库,把这个动作设置一个分布式锁,只有一个请求能去db访问,其他请求重试等待,解决了全部请求全部查询数据库的问题。这种方案相当于把数据库的访问压力转到了分布式锁的压力上来,有一定的弊端,但是最简单实用,如果查询数据库的耗时比较长,过多的读请求线程堵塞,存在将机器内存打满的风险。​​​​​​​

public Object getCache(final String key) {Object value = (key);//缓存值过期if (value == null) {    //加mutexKey的互斥锁if (redis.setnx(mutexKey, 1, time)) {  value = db.get(key);redis.set(key, value, time);redis.delete(mutexKey);} else {sleep(500); //重试return get(key);  }}return value;}
3.2.2 软过期+互斥锁

        软过期指对缓存的值里存储逻辑过期时间t1,这个时间比实际要过期的时间t2小(t1<t2),业务取值时候,校验t1是否过期,在发现了数据逻辑时间过期的时候,也是引入一把互斥锁,首先将t1时间延长t1=t1+t并设置到缓存中去,接着去db查询新数据,其他线程这时看到延长了的过期时间,就会继续使用旧数据,等线程获取最新数据后再更新缓存。

        这种方案相比第一种进一步减少了读请求线程阻塞的时间,第一种方案阻塞时间block time从数据库查询并设置到缓存中的整个时间段。第二种方案阻塞时间block time:t1=t1+t并设置到缓存中的时间段。

public Object getCache(final String key) {Object value = (key);if (value != null) {//检验缓存里的逻辑过期时间if (Timeout() <= currentTimeMillis()) {if (redis.setnx(mutexKey,time)) {//立即延长逻辑过期时间,减少阻塞时间value.Timeout() + t1);redis.set(key, value, time);value = db.get(key);//获取最新db数据,并重新设置新的逻辑过期时间,覆盖旧数据value.Timeout() + t2);redis.set(key, value, time);redis.delete(mutexKey);} else {sleep(500);get(key);}}} else {//缓存不存在的情况和上面一样if (redis.setnx(mutexKey,time)) {value = db.get(key);redis.set(key, value,time1);redis.delete(mutexKey);} else {sleep(500);get(key);}}return value;}
3.2.3 静态数据 lazy expiration

        这里静态数据的含义是指redis不set expire过期时间,对redis来说认为数据是不过期的是静态的。但实际和上面的软过期是一样的,通过value里设置逻辑过期时间,再拿到值判断值过期之后,后台新起异步线程更新缓存,这种方式一般性能最好。

public Object getCache(String key) {Object value = (key);if (Timeout() <= System.currentTimeMillis()) {// 另起一条线程异步更新缓存ute(new Runnable() {public void run() {if (redis.setnx(mutexKey, "1")) {pire(mutexKey, 3 * 60);String dbValue = db.get(key);redis.set(key, dbValue);redis.delete(mutexKey);}}});}return value;}

3.3 优缺点对比

方法

优点

缺点

互斥锁

1.简单易用

2.一致性保证

1.存在线程阻塞的风险

2.数据库访问的压力转到分布式锁上来

软过期+互斥锁

1.相比互斥锁方案,降低线程阻塞的时间

1.代码更复杂

2.逻辑过期时间会占用一定的内容空间

静态数据

1.数据不过期,异步构建性能最好

2.基本杜绝热点key重建问题

1.不能保证一致性

2.代码复杂性增加

3.逻辑过期时间会占用一定的内容空间

4.热点key问题

4.1 问题概述

        用户的消费速度远远大于生产速度,例如电商平台上线某个热门促销商品,微博大量转发的热门新闻等,这些数据往往查询量非常大。其实缓存击穿也是一种热点key问题,但是这里要讨论的方面不一样,缓存击穿主要侧重的是热key失效后大量并发查询涌向数据库照成的压力,而这里的热key侧重的是热key的访问压力已经大到超过redis性能极限,相对于缓存击穿的热key,这里也可叫巨热数据。

        分布式缓存组件通常会进行分片切分,查询某个key,会通过key的hash值计算出对应的slot,路由到某个分片的所属机器上。热key出现时,所有热点访问的请求都会路由到同一个redis server,该节点的负载严重加剧,并且这种现象通常不是马上加机器就能解决,因为同一个请求key还是会落到同一个新机器上,瓶颈依然存在。并且如果这个key还是大key ,甚至可能达到物理网卡极限,服务被打垮宕机,造成雪崩,成为系统瓶颈和风险。因此热点key会有以下问题。

  • 流量集中,达到物理网卡上限。

  • 请求过多,缓存分片服务被打垮。

  • 缓存分片打垮,重建再次被打垮,引起业务雪崩。

4.2 解决方案

        通常是在客户端和缓存服务端进行改造优化。具体方案如下:

4.2.1 多级缓存

(1)在客户端加入本地缓存,如guava-cache或ehcache,热点缓存直接命中本地缓存,根本上减少了热点请求缓存服务。这种方案的问题是容量有限,对业务有入侵,可以对redis sdk进行改造,集成本地缓存功能,对业务无感知。

(2)如果缓存集群为代理模式,可以在代理接单添加本地缓存,利用代理节点可以水平扩容的特点,解决容量有限的问题,当然性能也要比客户端本地缓存差一些,因为“缓存离用户越近,性能越好”。代理模式如下图:

​4.2.2 多副本

        当发现某个热key的时候,增加热key所在节点的从副本,这种情况对读多写少的情况比较有效。但是也增加了多副本同步不一致的风险。

4.2.3 迁移热key

        当发现某个slot里热key的时候,将该slot的单独迁移到新的节点,和集群其他节点隔离,避免影响集群节点其他业务。

4.3 热key发现

        热key的解决方案可以在客户端加本地缓存,热key备份,以及迁移热key节点,根据应用情况选择不同的方案,但如果热key已经出现的时候,没有及时发现和处理,再去处理就为时已晚,因此通常如何提前发现热key并即使处理热key非常重要。其解决方案如下:

(1)人为预测

        这种方案其实也是有一定可行性的。比如,电商预告要在第二天中午12点开放某商品的促销,对比这个商品历史促销的访问量,预测能达到热点访问量,从而提前加载热点缓存。

(2)客户端检测

        客户端是距离key"最近"的地方,redis的命令每次都是从客户端触发的,可以在客户端的代码处进行统计计数。但是这种方案也存在一些问题。无法预知key的个数,存在内存泄漏的风险;只能解决当前客户端的热点key,无法实现规模化的运维统计。

(3)机器层面检测

        站在机器的角度,可以通过对机器上所有Redis端口的TCP数据包进行抓取完成热点key的统计。此种方法对于redis客户端和服务端来说毫无侵入,但是依然存在问题:需要一定开发成本,由于是以机器为单位进行统计,想要了解集群维度热点key,后期还是需要汇总统计。

(4)服务端monitor

        redis的monitor可以统计出一段时间的所有命令,通过monitor qps最高的节点,利用正则表达式解析出热key,对热key所在的slot进行迁移。这种方式的好处是简单易用,缺点也比较明显,monitor命令执行期间会降低redis性能。

(5)热点发现系统

        可以建立一套热点发现系统,通过对实时请求上报计算,提前发现热key的产生。当计算监控到产生了热点key,将热key推送到客户端,客户端建立本地缓存。

  1. 请求经过负载均衡转发到后端应用服务。
  2. 应用服务首先查询本地缓存是否命中,如果命中直接返回,否则查询分布式缓存,若分布式缓存也未命中,则回源到db查询。

  3. 请求上报:应用服务将请求日志上报给实时热点发现系统,这里可以将请求写入kafka,或者通过flume订阅nginx日志。

  4. 实时热点计算系统可以采用kafka+storm组合,订阅kafka消息,对上报的请求日志进行实时解析计算,利用流式计算的天然分布式实时计算特性,计算源源不断的请求日志,计算规则视不同应用而定,最简单的是通过压测结果给出一个阈值进行判断,推荐通过时间轮算法探测各时间滑窗的访问热度得出热点key。

  5. 对发现的热点key通过推送系统如zookeeper推送到应用服务建立本地缓存。因为有本地缓存的存在,如果对数据一致性要求比较高的,可以订阅热点计算系统的热key失效事件,实时更新数据。如果订阅条件比较苛刻或者对一致性要求不太高的应用,可以通过过期时间到期更新。

5.大key问题

5.1 问题概述

        大key就是value存储值比较大,一般数据大小的定义为:

  • string类型value > 10K​大

  • string类型value > 100K​超大

  • set、list、hash、zset等集合数据类型中的元素个数 > 1000​大

  • set、list、hash、zset等集合数据类型中的元素个数 > 10000​超大

        并且大key会有以下问题:

  • 响应超时:由于redis是单线程的,大key会导致在get的时候堵住redis服务器的输出缓冲区,导致服务的超时。集合的删除时间复杂度为O(n),大集合删除的时候会严重阻塞进程,造成应用崩溃

  • 数据倾斜:大key会导致集群不同节点间的数据倾斜,有的节点使用的容量较多,有的节点很空闲。

5.2 解决方案

5.2.1 单key存储value很大

        可以把value对象拆成多份,使用multiGet,这样做的意义在于减少操作在一个节点的压力,分散到多个节点。使用hash,每个filed存储对象的各属性。

5.2.2 集合存储了过多的的值

        类似于场景一种的第一个做法,可以将这些元素分拆。以hash为例,原先的正常存取流程是  hget(hashKey, field) ; hset(hashKey, field, value)。现在,固定一个桶的数量,比如 10000, 每次存取的时候,先在本地计算field的hash值,模除 10000, 确定了该field落在哪个key。

newHashKey  =  hashKey + ( hash(field) % 10000 );

hset (newHashKey, field, value) ;

hget(newHashKey, field);

5.2.3 压缩value

        通过数据压缩技术,压缩数据大小存储。

五.缓存在餐饮开放平台中的应用

        以美团为例,美团餐饮开放平台基于美大餐饮业务(包括外卖、团购、闪惠、点餐、排队、预订、收银等),为第三方平台及众多餐饮软件公司,提供更加便捷的操作平台、信息传递方式、以及信息管理方式,从而提升商家经营效率、降低经营成本、提升市场竞争力、提高消费者的服务体验,并打造餐饮行业健康生态。

        开放平台的核心业务流程依赖开发者信息,回调地址,授权记录等基础信息,会频繁查询数据库,数据库的压力较大。这些基础信息的访问读多写少,而且更新频率低,有的甚至几乎不变,针对这些基础信息,我们引入缓存,降低数据库压力,提高响应速度,并且保证缓存的高可用。

1.总体设计

        在实际设计中,考虑采用本地缓存+双缓存的解决方案,其具体设计架构图如下所示:

​2.缓存选择

2.1 本地缓存

        对于数据量小频繁访问且几乎不用变更的数据缓存在本地缓存中,如api信息,业务信息等。

2.2 双缓存

        尽管美团的Squirrel或Tair已经做了高可用的建设,开放平台整体流量压力比较大,且高度依赖缓存组件,在早期的单缓存使用过程中,还是经常发生因为单缓存不稳定导致的业务报警,因此采用tair + squirrel的组合,两个缓存互为主备,可随时切换。

3. 缓存策略

策略层主要包含降级开关,双缓存熔断自动切换或人工切换策略。

  • 降级策略:通常使用主缓存,缓存使用熔断时自动切换到备用缓存,或者人工手动通过开关切换到备用缓存,或者开启降级业务请求直接查询数据库。

  • 兜底策略:接入databus,订阅数据库表数据变化,同步删除缓存中的数据,若一缓存更新失败或databus组件异常导致的数据不一致,通过定时任务定期校对缓存和数据库数据,刷新缓存,保证最终一致性

  • 报警策略:监控双缓存运行时状态,及时报警通知

3.1 缓存查询

        一般情况下使用主缓存,当主缓存熔断时切换到备用缓存。主缓存大量失败时触发熔断或手动对开关进行主备缓存切换,缓存都异常或miss数据则查询db并异步加载数据库数据到缓存中,不用保证加载成功。

​3.2 缓存更新

(1)任何一个缓存更新失败都给databus返回失败,由databus进行重试,databus的重试会保证数据的一致性。

(2)tair和squirrel的更新,允许只有一个更新成功,但只要其中一个不成功就会重试。当某个缓存不可用时,更新成功的那个保证和数据库数据一致性。

(3)自动校对任务,每天低峰期运行,也可以手动触发,并发送通知

3.3 不足

        通常双缓存采用热备的方式,业务层面的维护规则进一步提升整体缓存服务层的可用性,通过熔断自动切换,人工介入等方式,实现单缓存出现crash后能及时切换到备用缓存集群继续提供服务。但是也有其不足之处,首先是缓存组件层整体复杂性提升,需要维护缓存的热备关系,双缓存会进一步增大数据库,双缓存三方的数据不一致性差异,通过job定期补充实现最终一致性,需要考虑业务上是否能忍受这样的不一致,降低了对缓存资源的利用率以及缓存命中率,另外在缓存时间的设置上没有动态化分散化,缓存穿透问题没有避免等问题。

六.总结

        缓存的使用非常简单,平时各项目通常会封装的好各缓存组件,一个注解就可以实现。通过上面的描述,其实缓存的使用要考虑很多方面,面临的问题也很多特别是互联网高并发、大流量特性的场景显得尤其明显。使用缓存会极大提升系统的访问速度和吞吐量,但另一方面也会增加系统的复杂度,数据不一致的,缓存雪崩,缓存击穿,热key等问题。缓存有时屏蔽了很多并发流流量回溯到下层db,时间长了可能会让人对下层服务稳定性建设的松懈感。因此引入缓存也要考虑这种资源来宕机情况下系统限流降级的手段。

        通常来说,缓存虽好,但也不要滥用,如果系统在初期或者可预见的时间内,使用db不用缓存也能满足系统业务的情况,那么建议不要一来就使用缓存,除非非用不可的地步。

1.缓存的好处

  • 加速读写,提高系统性能与吞吐量。因为缓存通常是全内存的存储介质,而缓存的后端(DB、REST、RPC等)相对而言速度慢抗压性差,通过加入缓存可以有效提升系统性能。

  • 减少后端负载。通过添加缓存,在正常的系统运行中且命中率不错的情况下,可以帮助减少后端不必要的重复计算(join、或者无法在优化的sql等),很大程度降低了后端的负载。

2.缓存的代价

  • 数据不一致性。无论设计做的多么好,缓存数据与真实数据源一定存在着一定时间窗口的数据不一致性,时间窗口的大小,具体要看一下业务允许多大时间窗口的不一致性。

  • 代码维护成本。有缓存后,代码就会在原数据源基础上加入缓存的相关代码,例如原来只是一些sql,现在要加入k-v缓存,必然增加代码维护成本。

  • 架构复杂度。有缓存后,需要专职管理人员来维护主从缓存系统,同时也增加了架构的复杂度和维护成本。

七.参考资料

1.缓存更新的套路 | 酷 壳 - CoolShell

2..html

3..html

4.缓存与数据库一致性之缓存更新设计 - 知乎

本文发布于:2024-01-30 22:53:59,感谢您对本站的认可!

本文链接:https://www.4u4v.net/it/170662644223419.html

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。

标签:缓存   解决方案   常见
留言与评论(共有 0 条评论)
   
验证码:

Copyright ©2019-2022 Comsenz Inc.Powered by ©

网站地图1 网站地图2 网站地图3 网站地图4 网站地图5 网站地图6 网站地图7 网站地图8 网站地图9 网站地图10 网站地图11 网站地图12 网站地图13 网站地图14 网站地图15 网站地图16 网站地图17 网站地图18 网站地图19 网站地图20 网站地图21 网站地图22/a> 网站地图23