常用缓存系统使用经验总结

缓存系统是提升系统性能和处理能力的利器,常用的缓存系统各自的特性和使用场景有所不同,这里总结下常用缓存系统时需要关注的点以及解决方案,以及业务中缓存系统的选型等。

本文内容主要包括以下:

  • 缓存使用中需要注意的点:热点、惊群、击穿、并发、一致性、预热、限流、序列化、压缩、容灾、统计、监控。
  • spring cache、分布式锁。

1、常用缓存系统


在平常的业务开发过程中,一般会使用集团自己开发的tair分布式缓存系统,tair有三种存储引擎:mdb、ldb、rdb,从名字上就可以看出,分别对应memcache、leveldb、redis。在一些特定场景,还会使用到localcache,常见的会用到guava cache。

  • mdb(memcache)
  • ldb(leveldb)
  • rdb(redis)
  • localcache(guava cache)

2、缓存使用中需要注意的点


2.1 热点

缓存中的热点key是指短时间大量访问同一个key,一般是高读低写。短时间频繁访问同一个key,请求会打到同一台缓存机器上,形成单点,无法发挥分布式缓存集群的能力。

案例:商品信息,更新很少,但是读取量很大,一般会以商品id为key,value为商品的基本信息。在大促期间有些热门商品会被频繁访问(小米新品首发、秒杀场景),形成热点商品。

解决方案:

  • 使用localcache

    在查询分布式缓存前再加一层localcache,更新是先删除localcache中的key,查询时先查localcache,查询不到再查分布式缓存,然后再回写到localcache。

    但是分布式场景下使用localcache会有短暂的数据不一致,如key1在机器A、B的localcache中都有,机器A上更新key1时会删除掉机器A上localcache中的key1,但是机器B上localcache中的key1没有被删除,这时候机器B上发生查询key1的操作就会发送数据不一致的情况。

    此种情况下,则需要考虑短暂的数据不一致是否是可以接受的,如果可以接受则可以在localcache的key1上添加过期时间,如30ms。如果业务需求强一致场景,则localcache不适合。

  • 对热点key散列

    某些业务场景下需要进行计数,比如对某个页面的pv进行统计,这种高写低读的场景可以对这key进行散列,比如讲key散列成key1、key2、key3....keyn,计数时随机选择一个key,统计总数是读出所有的key再进行合并统计,这种场景虽然会放大读操作,但是由于读的访问本身就不高的场景下,不会对集群产生太大的影响。

  • 缓存服务端热点识别

    使用localcache和热点key散列都只是针对特定的场景,也需要应用端进行开发,tair的热点散列机制则能在缓存服务端智能识别热点key并对其进行散列,做到对应用端透明。

2.2 惊群

缓存系统中的惊群效应是指大并发情况下某个key在失效瞬间,大量对这个key的请求会同时击穿缓存,请求落到后端存储(一般是db),导致db负载升高,rt升高。

案例:热点商品的过期,在缓存商品信息时一般会设置过期时间,在热点商品过期的瞬间,大量对这个商品信息的请求会直接落到db上。

分析:缓存失效瞬间,大量击穿的请求在从db获取数据之后,一般会再回写到缓存中,所以实际上只需要一个请求真正去db获取数据即可,其他请求等待它将数据回写到缓存中再从缓存中获取即可。

解决方案:

  • 读写锁

    读写锁的方法在key过期之后,多线程从缓存获取不到数据时使用读写锁,只有得到写锁的线程才能去db中获取数据,回写缓存。但该方案无法完成在应用机器集群间的惊群隔离,如果应用集群机器数较少,则比较适合。
    伪代码如下:

      Obj cacheData = cache.get(key);
      if(null != cacheData){
          return cacheData;
      }else{
          lock = getReadWriteLock(key);
          if (lock.writeLock().tryLock()) {
              try{
                  Obj dbData = db.get(key);
                  cache.put(key, newExpireTime);
                  retrun dbData;
              }finally{
                  lock.writeLock().unlock();//释放写锁
                  deleteReadWriteLock(key);
              }  
          }else{
              try{
                  lock.readLock().lock();//没拿到写锁的作为读锁,必须等待�
                  Obj cacheData = cache.get(key);
                  return cacheData;
              }finally{
                  lock.readLock().unlock();//释放读锁
              }
          }
      }
    
  • 过期续期

    续期的方法是在key即将过期之前,使用一个线程对该key提前从db中获取数据,回写缓存,并增加key的过期时间。该方法的核心是如何保证一个线程去对key进行更新并续期,一般可以使用3.2 分布式锁来实现来实现。改方案可以实现应用集群间的隔离,但是依赖分布式锁,增加了实现成本。

    伪代码如下:

      Obj cacheData = cache.get(key);
      if(cacheData.expireTime - currentTime < 10ms){
          bool lock = getDistriLock(key); //获取分布式锁
          if(lock){
              Obj dbData = db.get(key);
              cache.put(key, newExpireTime);
              deleteDistriLock(key);
          }
      }
      retrun cacheData;
    

2.3 击穿

缓存击穿的场景有很多,如由缓存过期产生的惊群,数据冷热不均导致冷数据击穿到db,还有一种情况则是由空数据导致的缓存击穿。

案例:手淘包裹card提供用户最近30天的签收和未签收包裹列表,列表索引由redis zset构建,key为用户id,members为包裹id,score为包裹更新时间。查询时如果redis中查询不到用户相关的包裹列表索引,则去db中查询,查询完成之后再将db返回的结果回写到redis中,这是常规的处理方案。但是如果一个用户在最近30天都没有任何包裹,当他查询的时候则会每次都击穿缓存,落到db,而db中也没有该用户最近30天的包裹数据,缓存中依然为空。不幸的是这个接口的调用时机是手淘-“我的淘宝“tab,双十一调用峰值是8w qps,而大部分最近30天没有买过东西(大部分是男性)用户也会在大促的时候频繁使用手淘,这部分用户在每次查询的时候都会击穿缓存落到db,整个过程只能获取到一堆空数据。

解决方案:

  • 计数

    增加一个单独的计数key,记录db中返回的列表数量,在查询列表之前先查询计数key,如果计数结果为0则不用去查询缓存和db。

    该方案需要增加一个计数key,并需要保证计数key和数据key之间的一致性,增加了实现和维护成本。

  • 空对象

    在db返回的列表为空的时候,向缓存的value中增加一个空的对象,下次查询是如果从缓存中查的结果是空对象则不去db中获取数据。

    该方案在数据key的value中增加了一个非业务的数据,容易造成数据污染,在支持复杂key的缓存中,如redis zset/list/set等数据结构时,对导致count的不准,特别是数据量为1时,无法区分到底是正常数据还是空对象,需要将真正的数据内容取出进行判别,整体上增加了实现和维护成本。

2.4 并发

并发请求会带来很多问题,如之前讨论的热点key、惊群的并发读取,而并发写入也是一个需要考虑的点。

案例:商品的库存信息,大促期间有多个线程同时更新商品的库存数量,如:线程A获取库存数为10,做库存-2操作,并将结果8写入缓存;线程B在线程A写入前获取库存数为10,做库存-1操作,将结果9写入操作,这种情况下,缓存中保存的库存数量必定是有问题的。

解决方案:

  • 分布式锁-悲观锁

    分布式锁的实现见3.2 分布式锁来实现,在并发更新的情况下线程A和线程B需要去竞争锁,竞争到锁的线程先去缓存中读取数据如库存数10,在做库存-2操作,然后将结果写入缓存,写入成功之后释放锁。线程B再获取到锁,在做同样的操作读库存减库存,将结果写入缓存,释放锁。

  • 引入版本号-乐观锁

    采用分布式锁需要在每次写入操作前都要去抢锁,即便没有并发写入产生,这是一种悲观锁的实现方式,利用数据版本号可以实现乐观锁方案。

    利用tair数据的version可以实现乐观锁的写入实现,在并发更新的情况下线程A和线程B都需要先去缓存中读取库存数据,但是这个时候会额外的多得到一个数据的version,在写入的时候需要带上该version,tair的server端在写入数据的时候会比较传入的version和数据中原有的version,如果version一致则写入成功,并将version+1,如果version不同则返回失败。写入失败的线程需要重新读取数据,获得version,完成操作再次写入。

    乐观锁的方案在并发度低的情况下,可以降低锁的争抢,在方案上也更简单,但是需要缓存服务端的支持。

2.5 一致性

使用缓存系统时,一致性是一个比较难解决的问题,需要在业务评估的时候就要考虑起来。一般业务对一致性的要求可以分为三档:强一致性、弱一致性、最终一致性。

如果业务对数据的一致性非常敏感,如电商的交易订单信息,其中涉及到交易的状态、付款信息等频繁变更的场景,而许多需要反查交易的系统对交易订单的状态的准确性要求非常高,即便是短暂的不一致也不能忍受。这种场景下,交易系统对数据的要求是强一致的,强一致场景下使用缓存系统则会极大的提高系统的复杂性,所以不建议使用独立的分布式缓存系统。使用mysql做后端存储时,强一致场景下,可以考虑mysql5.7 memcache plugin特性,即可以享受缓存带来的高性能又不用为数据一致性担心。

而大部分业务对数据的一致性要求不是很严格,如商品的名称、评价系统中的评论、点赞的个数、包裹的物流状态等,用户对这些信息是不是和后端存储中一样是不敏感的,短暂的不一致不会带来很严重的后果,这些场景下使用缓存系统比较合适。但是没有强一致性的要求不代表没有一致性的要求,一致性处理不好一样会带来用户的困惑或者系统的bug,比较常见的场景是列表页和详情页的不一致。

在处理缓存和后端存储数据一致性的时候,需要考虑以下几点:

  • 并发更新

    并发更新的场景和解决方案见2.4 并发

  • 数据重建

    数据重建一般是在缓存系统崩溃或者不稳定,切换到容灾方案,等到缓存系统再恢复之后,缓存中的数据已经和db中的数据有了较大的差异,需要依赖db中的数据进行全部重建。

    如手淘包裹列表的redis索引,在redis系统崩溃之后,切换到db的容灾方案,等到redis恢复之后,redis中的数据已经和db中出现了较大的不一致,需要依赖db中的数据进行重建。

    方案上先暂停对redis的写入,并清空redis中的全部数据。由于包裹db采用分库分表,共有4096表,不能在一台机器上遍历所有的数据,为了充分利用分布式集群机器的能力,可以将4096张表作为4096个任务分发到包裹应用集群的200多台机器上,每台机器处理20张表。分发过程可以使用分布式调度中间件也可以简单的使用消息中间件。由于分表字段是uid,所以刚好每台机器只要遍历分到自己机器上的表,以uid为key在redis中重建该用户的所有数据。单表在200w条记录,取最近一个月数据(总共3个月)分页遍历也只需3分钟所有即可完成,单机20张表一个小时可以完成,4096张表整个集群在一个小时内完成数据重建。完成数据重建之后再打开redis写和读服务,系统从容灾状态切换缓存服务状态。

  • 数据订正

    有时候会有批量数据订正的场景,如批量更新包裹的状态、批量删除违规的评论信息,但是如果只更新了后端存储没有更新缓存,则会带来数据不一致的问题。mysql下比较好的一个解决方案是,应用系统监听binlog变更消息,直接失效掉对应的缓存。

    无法监听binlog消息或者暂时无法实现的时候,那么一定要注意使用封装了缓存的数据操作接口来进行遍历订正。

2.6 预热

使用分布式缓存的目的是为了替后端存储挡下绝大部分的请求,但是在实际的业务场景中,数据的时候用频率是不一样的,有的数据请求高,有的数据请求低,这样就造成数据的冷热不均,而且这样的冷热数据往往也是跟实际的业务场景变化而变化,在电商场景中则更加明显。

案例:家居大促、暑期电脑家电大促、秋冬服装大促等。每次电商节,行业大促其侧重点都有所不同,反应在应用系统的数据的缓存上,则是不同商品在缓存系统中的冷热交替。如平常家居类商品访问会很少,所以在缓存系统中由于请求较少,一段时间后会被逐出或者过期掉,甚至在db中也是冷数据,在大促开始的时候则会由于流量的涌入,导致缓存被击穿,请求到达后端存储,造成存储系统压力过大。

解决方案:

  • 数据预热,在大促前夕,根据大促的行业特点,活动商家分析出热点商品,提前对这些商品进行读取预热。

2.7 限流

缓存系统虽然性能很高,单机几万到几十万qps也没有问题,但是毕竟是有处理极限,对请求还是需要有基本的限流措施,而应用也需要时刻关注是否触发了缓存系统的限流,如果触发需要立即停止调用并进行review,否则会拖垮缓存系统或者影响其他使用同个缓存系统的业务。

2.8 序列化&压缩

大并发下对缓存系统的请求qps一般都非常高,一个系统几十万甚至上百万的请求也有可能的,序列化的性能以及序列化后的空间消耗则变得比较重要,所以需要选择合适的序列化的方式。

案例:商品信息中包含了商品的名称、商品图片地址、商品类目、商品描述、商品视频地址、商品属性等,这些信息很少更新,但是会造成商品的size会很大,一个商品信息的DO在使用java原生序列化之后会有几十K,如果一次批量获取则有可能超过1M。

解决方案:

  • 选择合适的序列方式,从序列化的性能、序列化后的空间大小、序列方式的易用性等方面进行常用序列化方式对比,一般折中方案选择json,如果对性能有更高的要求可以选择protoBuff。

  • 压缩,对序列化之后的内容进行压缩可以降低请求过程中网络的消耗,还可以在缓存服务端用同等的容量存储更多的key,提高缓存的命中率,常用的可以使用zip,snappy。当然压缩的代价是消耗更多应用机器的性能,所以在是否需要采用压缩上需要根据实际情况进行取舍。

2.9 容灾

使用缓存系统的时候一定要明确一个思想,缓存不是存储,它不能用来代替持久化的存储方案,如db、hbase。即便是redis已经宣称实现了持续久化的方案RDB和AOF,缓存系统后端还是需要有一套持久的存储。

如果数据是不可丢失的,那么在使用缓存系统的时候,一定需要考虑当缓存系统崩溃或者网络抖动时,缓存中数据丢失和不一致的容灾方案,还有缓存恢复之后数据重建方案。

案例:手淘包裹列表的redis方案,使用redis的zset来实现包裹按时间的排序,查询时先查redis拿到排好序的包裹id列表,再用id列表回表查询具体数据。这样做的好处是复杂的排序操作由原先db移到redis,db只需要完成简单的主键id查询即可,提升查询的性能。但是需要考虑的是如果redis不可用,那么还是需要到db中完成复杂的查询,只是这个时候需要对查询的接口进行限流,防止压垮db。而redis恢复之后数据恢复方案有两种,一是直接清空掉redis中所有数据,一段时间内由db查询支撑并缓慢重建用户在redis中的包裹数据,二是清空redis数据并遍历db重建所有数据。

2.10 统计&监控

主要是统计缓存的命中率、错误数、错误类型等指标。

缓存命中率直接反应了缓存的效果,如果命中率过低(30%以下)则加缓存带来的受益不大,这个时候付出的缓存容量、代码复杂度都得不偿失,所以需要及时review使用缓存的场景、key的设计、冷热数据、代码的使用,逐步调优提升命中率(70%以上)。

缓存的错误数、错误类型则用于统计和监控分布式缓存应用的健康状态,在缓存崩溃或者网络抖动的时候,错误数或者错误持续时长达到阈值则需要切换到容灾方案。

3、其他


3.1 spring cache

缓存系统的引入必然会对原有的代码结构带来一定的冲击,特别是在复杂场景下往往不只会使用一套缓存系统,mdb、ldb、redis、localcache全上也有可能,还涉及到一致性、并发、击穿等处理,代码的复杂度会大大增加。

spring cache是一套基于注释的缓存技术,它本质上不是一个具体的缓存实现方案(例如 EHCache 或者 OSCache),而是一个对缓存使用的抽象,通过在既有代码中添加少量它定义的各种 annotation,即能够达到缓存方法的返回对象的效果。

通过使用spring cache的注解可以在DO层进行横切,让缓存和DO操作隔离开,关注于各自的业务逻辑,从而实现对外高内聚,对内松耦合。spring cache的说明和各个注解的作用不做多的介绍,主要介绍下使用经验。

  • spring cache基于代理,需要区别jdk代理和cglib的代理实现方式,jdk代理时this调用不起作用。

  • 在spring cache的实现类中需要避免直接或间接调用添加了注解的方法,避免缓存的循环调用。

  • 基于spring cache的KeyGenerator可以将添加了注解的方法的参数、方法名称构建成key,实现多个接口的代理。

      public class SpringCachePackInfoKeyGenerator implements KeyGenerator {
          @Override
          public Object generate(Object target, Method method, Object... params) {
              Map<String, Object> keyParam = new HashMap<String, Object>();
    
              keyParam.put(METHOD_NAME,   method.getName());
              keyParam.put(METHOD_PARAMS, Arrays.asList(params));
    
              return keyParam;
          }
      }
    
    
      public class SpringRedisMyTaobaoPackCache implements Cache {
          @Override
          public ValueWrapper get(Object key) {
              Map<String, Object> keyParam = (Map)key;
    
              List<Object> params = (List)keyParam.get(METHOD_PARAMS);
              String methodName   = keyParam.get(METHOD_NAME).toString();
              
              if("methodA".equals(methodName)){
                  //do something with params
                  retrun cacheObj;
              }
              
              if("methodB".equals(methodName)){
                  //do something with params
                  retrun cacheOjb;
              }
          }
      }
    

3.2 分布式锁

分布式锁是分布式场景下一个典型的应用,其实现方式多种多样,也有很多基于缓存系统的实现方式。

  • redis的实现

    redis的分布式锁实现在redis的官方文档上有详细的介绍。

  • tair incr/decr,通过计数api的上下限值约束来实现。

    Tair的incr递增数据接口可以通过设置上限为1,客户端请求锁调用时如果数据是0,则递增成1,请求成功,如果数据已经是1,则返回请求失败。释放锁时将数据复位成0即可。通过调大上限,可以实现多个客户端同时持有锁类似信号量的功能。在调用incr接口时需要设置超时时间,即锁的超时时间,超时锁被自动释放。线程在使用完锁之后进行decr进行锁的释放。

    但是基于incr的锁无法实现可重入性。

  • tair put/get/invalid,通过put是的version来校验。

    尝试获取锁的过程,由两个步骤组成:先get到缓存的数据,如果能获取到数据则返回获取锁失败,如果不存在则调用put抢锁,put时的version可以除了0和1以外的所有数字(但是每次都需要是一样),如果put成功则表明抢锁成功,如果失败表明抢锁失败。在put的时候需要设置超时时间,即锁的超时时间,超时锁主动被释放。线程在使用完锁之后使用invalid进行锁的释放。

    在put的时候,value可以设置为当前机器的ip和线程信息,在get的时候可以比较value信息,如果当前机器的value和get到value是一致的,则认为是同一个线程再次获取锁,从而实现可重入锁。

推荐阅读更多精彩内容