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

0. 前言

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

本文内容主要包括以下:

  • 缓存使用中需要注意的点:热点、惊群、击穿、并发、一致性、预热、限流、序列化、压缩、容灾、统计、监控。
  • 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写入操作,这种情况下,缓存中保存的库存数量必定是有问题的。

解决方案:

  • 分布式锁-悲观锁

    在并发更新的情况下线程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

原文地址:https://www.cnblogs.com/john8169/p/9393294.html

时间: 2024-10-29 10:46:59

常用缓存系统使用经验总结的相关文章

高性能的分布式内存对象缓存系统Memcached

Memcached 是一个高性能的分布式内存对象缓存系统,用于动态Web应用以减轻数据库负载.它通过在内存中缓存数据和对象来减少读取数据库的次数,从而提高动态.数据库驱动网站的速度.Memcached基于一个存储键/值对的hashmap.其守护进程(daemon )是用C写的,但是客户端可以用任何语言来编写,并通过memcached协议与守护进程通信. 外文名 memcached 所    属 缓存系统 编写语言 不限 通信手段 memcached协议 目录 1功能 2特征 ? 协议 ? 事件处

两大数据库缓存系统实现对比

和redis,作为近些年最常用的缓存服务器,相信大家对它们再熟悉不过了.前两年还在学校时,我曾经读过它们的主要源码,如今写篇笔记从个人角度简单对比一下它们的实现方式,权当做复习,有理解错误之处,欢迎指正. 两大数据库缓存系统实现对比两大数据库缓存系统实现对比一. 综述读一个软件的源码,首先要弄懂软件是用作干什么的,那memcached和redis是干啥的?众所周知,数据一般会放在数据库中,但是查询数据会相对比较慢,特别是用户很多时,频繁的查询,需要耗费大量的时间.怎么办呢?数据放在哪里查询快?那

Memcache缓存系统原理

在Web服务开发中,服务端缓存是服务实现中所常常采用的一种提高服务性能的方法.其通过记录某部分计算结果来尝试避免再次执行得到该结果所需要的复杂计算,从而提高了服务的运行效率. 除了能够提高服务的运行效率之外,服务端缓存还常常用来提高服务的扩展性.因此一些大规模的Web应用,如Facebook,常常构建一个庞大的服务端缓存.而它们所最常使用的就是Memcached. 在本文中,我们就将对Memcached进行简单地介绍. Memcached简介 在介绍Memcached之前,让我们首先通过一个示例

Java -- 常用缓存Cache机制的实现 -- 艺多不压身

常用缓存Cache机制的实现 缓存,就是将程序或系统经常要调用的对象存在内存中,以便其使用时可以快速调用,不必再去创建新的重复的实例. 这样做可以减少系统开销,提高系统效率. 缓存主要可分为二大类: 一.通过文件缓存,顾名思义文件缓存是指把数据存储在磁盘上,不管你是以XML格式,序列化文件DAT格式还是其它文件格式 二.内存缓存,也就是实现一个类中静态Map,对这个Map进行常规的增删查. 啦啦啦

Memcached分布式内存对象缓存系统

Memcached 是一个高性能的分布式内存对象缓存系统,用于动态Web应用以减轻数据库负载.它通过在内存中缓存数据和对象来减少读取数据库的次数,从而提高动态.数据库驱动网站的速度.Memcached基于一个存储键/值对的hashmap.其守护进程(daemon )是用C写的,但是客户端可以用任何语言来编写,并通过memcached协议与守护进程通信. 一个用PHP编写的可视化的MemCached管理系统. MemAdmin是一款可视化的Memcached管理与监控工具,使用PHP开发,体积小,

深入探讨在集群环境中使用 EhCache 缓存系统

EhCache 缓存系统简介 EhCache 是一个纯 Java 的进程内缓存框架,具有快速.精干等特点,是 Hibernate 中默认的 CacheProvider. 下图是 EhCache 在应用程序中的位置: 图 1. EhCache 应用架构图 EhCache 的主要特性有: 快速: 简单: 多种缓存策略: 缓存数据有两级:内存和磁盘,因此无需担心容量问题: 缓存数据会在虚拟机重启的过程中写入磁盘: 可以通过 RMI.可插入 API 等方式进行分布式缓存: 具有缓存和缓存管理器的侦听接口

集群环境中使用 EhCache 缓存系统

EhCache 缓存系统 : 本章节将要介绍EhCache及EhCache实现分布式的一些解决方案.并针对于这些解决性方案做一个实现,后续将出一个提供项目模块化.服务化.插件化的VieMall快速开发平台,同时集成Dubbo服务化.Zookeeper(分布式调度/分布式配置管理服务).Redis分布式缓存技术及Memcache/Ehcache 二级缓存切换.FastDFS分布式文件系统.ActiveMQ异步消息中间件.Solr搜索.Nginx负载均衡等分布式及读写分离.如果有时间可以深入分表分库

(转)Memcache,Redis,MongoDB(数据缓存系统)方案对比与分析

Memcache,Redis,MongoDB(数据缓存系统)方案对比与分析 数据库表数据量极大(千万条),要求让服务器更加快速地响应用户的需求. 二.解决方案: 1.通过高速服务器Cache缓存数据库数据 2.内存数据库 (这里仅从数据缓存方面考虑,当然,后期可以采用Hadoop+HBase+Hive等分布式存储分析平台) 三.主流解Cache和数据库对比: 上述技术基本上代表了当今在数据存储方面所有的实现方案,其中主要涉及到了普通关系型数据库(MySQL/PostgreSQL),NoSQL数据

Linux下搭建Memcached缓存系统

首先说下抱歉,博主最近单位经常加班,博客更新有点慢,希望大家理解,草稿箱里存了不少内容,等不忙时候一点点填坑~ 在一般的网站开发学习时候,都会把数据存放在RDBMS(关系型数据库系统(Relational Database Management System)中,服务器程序通过读取RDBMS来取得数据显示在页面上.这在我们以往编写练习项目时候,是没有任何问题的.根据木桶理论,一只水桶能装多少水取决于它最短的那块木板,在实际的网站开发中,随着数据量的增大,访问的集中,就会出现RDBMS的负担加重.