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

0. 前言

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

本文内容主要包括如下:
* 缓存使用中须要注意的点:热点、惊群、击穿、并发、一致性、预热、限流、序列化、压缩、容灾、统计、监控。
* spring cache、分布式锁。mysql

一、经常使用缓存系统

在日常的业务开发过程当中,通常会使用集团本身开发的tair分布式缓存系统,tair有三种存储引擎:mdb、ldb、rdb,从名字上就能够看出,分别对应memcache、leveldb、redis。 在一些特定场景,还会使用到localcache,常见的会用到guava cache。
* mdb(memcache)
* ldb(leveldb)
* rdb(redis)
* localcache(guava cache)web

二、缓存使用中须要注意的点

2.1 热点

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

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

解决方案:
* 使用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不适合。sql

  • 对热点key散列
    某些业务场景下须要进行计数,好比对某个页面的pv进行统计,这种高写低读的场景能够对这key进行散列,好比讲key散列成key一、key二、key3….keyn,计数时随机选择一个key,统计总数是读出全部的key再进行合并统计,这种场景虽然会放大读操做,可是因为读的访问自己就不高的场景下,不会对集群产生太大的影响。json

  • 缓存服务端热点识别后端

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

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写入操做,这种状况下,缓存中保存的库存数量一定是有问题的。

解决方案:
* 分布式锁-悲观锁
在并发更新的状况下线程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是一致的,则认为是同一个线程再次获取锁,从而实现可重入锁。

参考:
https://www.jianshu.com/p/c1b9ec30b994

相关文章
相关标签/搜索