redis应用实战

一、redis应用分析

1.为什么需要redis

主要解决的是性能和并发,还可以使用redis实现分布式锁等。

1.性能

如下图所示,我们在碰到需要执行耗时特别久,且结果不频繁变动的SQL,就特别适合将运行结果放入缓存。这样,后面的请求就去缓存中读取,使得请求能够迅速响应。
在这里插入图片描述

比如商城的首页,各种类目的商品信息,各种推荐信息,如果走数据库查询,并且查完了可能接下来好几个小时都没什么变化,那每次请求都走到数据库里,就有点不大合适了。

这时,我们把查到的结果放到扔到缓存里面,下次再来查询,不走数据库,直接走缓存查询,算上网络消耗可能 10ms 左右就能响应结果了,性能瞬间提升 50 倍。

这就是说,对于一些需要复杂操作耗时查出来的结果,且确定后面不怎么变化,但是有很多读请求,那么直接将查询出来的结果放在缓存中,后面直接读缓存就好了。

2.并发

如下图所示,在大并发的情况下,所有的请求直接访问数据库,数据库会出现连接异常。这个时候,就需要使用redis做一个缓冲操作,让请求先访问到redis,而不是直接访问数据库。
在这里插入图片描述

MySQL 或者 Oracle 这种关系型数据库压根就不是用来玩并发的,虽然也可以支撑一定的并发,单机 8C16G 的 MySQL 优化基本上极限能撑到 900 左右的 TPS , QPS 极限能撑到 9000 左右。别看这个数字不小,请注意是极限情况,这个情况下 CPU 全都已经爆表,整个服务已经处于不健康的状态。

这时业务场景如果 1s 有 1w 的请求过来,使用一个 MySQL 单机肯定直接崩掉,但是如果使用 Redis 缓存,把大量的热点数据放在缓存,因为是走内存的操作,单机轻松支撑几万甚至于几十万的访问。单机的并发承载量是 MySQL 的几十倍。

2.redis使用场景

redis使用场景大多数是数据一致性要求不高的,而双写一致性问题,下面的章节会重点写。

1.分布式锁

2.分布式ID

1.生成分布式ID
3.实时计数

如对学习时长,做题量,接口调用量等实时计数。

4.分布式session

其他

1.做缓存抗并发:
冷热隔离:即热点数据和并发量不大的数据

4.队列

Reids在内存存储引擎领域的一大优点是提供 list 和 set 结构,这使得Redis能作为一个很好的消息队列平台来使用。

5.发布/订阅????
可参考:为什么分布式一定要有redis?  https://www.cnblogs.com/bigben0123/p/9115597.html

2.缓存设计

1.一些术语

1.缓存命中率

缓存命中率是从缓存中读取数据的次数与总读取次数的比率,命中率越高使用越好。缓存命中率 = 从缓存中读取次数 / {总读取次数 (从缓存中读取次数 +从慢设备上读取次数) }

这个指标非常重要,直接影响到系统性能,也可以通过该指标来衡量缓存是否运行良好;


 

2.缓存的设计

1.Cache Aside Pattern
1.设计思路

这是最常用最常用的pattern了。其具体逻辑如下:

  • 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从cache中取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后,再让缓存失效。

代码实现

/**
 * 获取商品详情信息
 *
 * @param id 产品ID
 */
public PmsProductParam getProductInfo(Long id) {
    PmsProductParam productInfo = null;

    productInfo = redisOpsUtil.get(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, PmsProductParam.class);
    if (productInfo != null) {
        return productInfo;
    }
    productInfo = portalProductDao.getProductInfo(id);
    if (null == productInfo) {
        return null;
    }
    FlashPromotionParam promotion = flashPromotionProductDao.getFlashPromotion(id);
    if (!ObjectUtils.isEmpty(promotion)) {
        productInfo.setFlashPromotionCount(promotion.getRelation().get(0).getFlashPromotionCount());
        productInfo.setFlashPromotionLimit(promotion.getRelation().get(0).getFlashPromotionLimit());
        productInfo.setFlashPromotionPrice(promotion.getRelation().get(0).getFlashPromotionPrice());
        productInfo.setFlashPromotionRelationId(promotion.getRelation().get(0).getId());
        productInfo.setFlashPromotionEndDate(promotion.getEndDate());
        productInfo.setFlashPromotionStartDate(promotion.getStartDate());
        productInfo.setFlashPromotionStatus(promotion.getStatus());
    }
    redisOpsUtil.set(RedisKeyPrefixConst.PRODUCT_DETAIL_CACHE + id, productInfo, 3600, TimeUnit.SECONDS);
    return productInfo;
}

注意,我们的更新是先更新数据库,成功后,让缓存失效。那么,这种方式是否可以没有文章前面提到过的那个问题呢?我们可以脑补一下。

一个是查询操作,一个是更新操作的并发,首先,没有了删除cache数据的操作了,而是先更新了数据库中的数据,此时,缓存依然有效,所以,并发的查询操作拿的是没有更新的数据,但是,更新操作马上让缓存的失效了,后续的查询操作再把数据从数据库中拉出来。而不会像文章开头的那个逻辑产生的问题,后续的查询操作一直都在取老的数据。

这是标准的design pattern,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。为什么不是写完数据库后更新缓存?你可以看一下Quora上的这个问答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕两个并发的写操作导致脏数据。

2.Cache Aside Pattern方案存在的问题

1.Cache Aside同样在高并发下存在数据不一致的问题

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

详细场景和解决方案,见上面的前言部分。

2.存在缓存穿透问题

可以用压测工具测试一下上面的方案

可见系统的响应时间还是很长,那是因为在1s内大量的线程还是从数据库里面取的数据,直到数据库里面的数据加载到缓存后,因为数据库读取的数据相比比较慢。

解决方案:

1.去数据库加载数据时只让一个线程去加载,其他线程处于等待状态。

2.热点数据进行缓存预热

2.Read/Write Through Pattern

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

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

1.Read Through

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

2.Write Through

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

自己备注:文章见《左耳听风专栏》这中策略实际中参考价值不大,因为会存在数据库和缓存的一致性问题,除非把热点数据全部放缓存中,所有的增删改查都在缓存中操作,数据库只是用来坐备份,这样能保证热点数据的高一致性。

3.Write Behind Caching
1.设计思路

Write Behind 又叫 Write Back。一些了解 Linux 操作系统内核的同学对 write back 应该非常熟悉,这不就是 Linux 文件系统的 page cache 算法吗?是的,你看基础知识全都是相通的。所以,基础很重要,我已经说过不止一次了。

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

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

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

在 Wikipedia 上有一张 Write Back 的流程图,基本逻辑可以在下图中看到。

2.适用场景

1.因为读写都是在内存中完成,适用于少数热点数据的高并发读写

3.Redis布隆过滤器

布隆过滤器(Bloom Filter)是 Redis 4.0 版本提供的新功能,它被作为插件加载到 Redis 服务器中,给 Redis 提供强大的去重功能。

相比于 Set 集合的去重功能而言,布隆过滤器在空间上能节省 90% 以上,但是它的不足之处是去重率大约在 99% 左右,也就是说有 1% 左右的误判率,这种误差是由布隆过滤器的自身结构决定的。俗话说“鱼与熊掌不可兼得”,如果想要节省空间,就需要牺牲 1% 的误判率,而且这种误判率,在处理海量数据时,几乎可以忽略。
 

1.应用场景

布隆过滤器是 Redis 的高级功能,虽然这种结构的去重率并不完全精确,但和其他结构一样都有特定的应用场景,比如当处理海量数据时,就可以使用布隆过滤器实现去重。

下面举两个简单的例子:

1) 示例:

百度爬虫系统每天会面临海量的 URL 数据,我们希望它每次只爬取最新的页面,而对于没有更新过的页面则不爬取,因策爬虫系统必须对已经抓取过的 URL 去重,否则会严重影响执行效率。但是如果使用一个 set(集合)去装载这些 URL 地址,那么将造成资源空间的严重浪费。

2) 示例:

垃圾邮件过滤功能也采用了布隆过滤器。虽然在过滤的过程中,布隆过滤器会存在一定的误判,但比较于牺牲宝贵的性能和空间来说,这一点误判是微不足道的。

2.工作原理

布隆过滤器(Bloom Filter)是一个高空间利用率的概率性数据结构,由二进制向量(即位数组)和一系列随机映射函数(即哈希函数)两部分组成。

布隆过滤器使用exists()来判断某个元素是否存在于自身结构中。

当布隆过滤器判定某个值存在时,其实这个值只是有可能存在;当它说某个值不存在时,那这个值肯定不存在,这个误判概率大约在 1% 左右。

1.工作流程-添加元素

布隆过滤器主要由位数组和一系列 hash 函数构成,其中位数组的初始状态都为 0。

下面对布隆过滤器工作流程做简单描述,如下图所示:
 

Redis布隆过滤器


图1:布隆过滤器原理


当使用布隆过滤器添加 key 时,会使用不同的 hash 函数对 key 存储的元素值进行哈希计算,从而会得到多个哈希值。根据哈希值计算出一个整数索引值,将该索引值与位数组长度做取余运算,最终得到一个位数组位置,并将该位置的值变为 1。每个 hash 函数都会计算出一个不同的位置,然后把数组中与之对应的位置变为 1。通过上述过程就完成了元素添加(add)操作。

2.工作流程-判定元素是否存在

当我们需要判断一个元素是否存时,其流程如下:首先对给定元素再次执行哈希计算,得到与添加元素时相同的位数组位置,判断所得位置是否都为 1,如果其中有一个为 0,那么说明元素不存在,若都为 1,则说明元素有可能存在。

3.为什么是可能“存在”

您可能会问,为什么是有可能存在?其实原因很简单,那些被置为 1 的位置也可能是由于其他元素的操作而改变的。比如,元素1 和 元素2,这两个元素同时将一个位置变为了 1(图1所示)。在这种情况下,我们就不能判定“元素 1”一定存在,这是布隆过滤器存在误判的根本原因。

4.关于内存空间思考

因为布隆过滤器只需要存对应key无需存value,所以占用的内存不会太大。然后存储容器是redis,自带分布式特性,可支持分布式场景

5.全量和增量同步

全量同步:可以基于服务启动时自动加载数据库数据到布隆过滤器。

增量同步:基于数据库等增删改操作后,可以发布异步通知如mq来更新布隆过滤器数据。

3.布隆过滤器(基于redis)基本命令

4.布隆过滤器代码实现

1.存储
@Slf4j
@Configuration
public class BloomFilterConfig implements InitializingBean{

    @Autowired
    private PmsProductService productService;

    @Autowired
    private RedisTemplate template;

    @Bean
    public BloomFilterHelper<String> initBloomFilterHelper() {
        return new BloomFilterHelper<>((Funnel<String>) (from, into) -> into.putString(from, Charsets.UTF_8)
                .putString(from, Charsets.UTF_8), 1000000, 0.01);
    }

    /**
     * 布隆过滤器bean注入
     * @return
     */
    @Bean
    public BloomRedisService bloomRedisService(){
        BloomRedisService bloomRedisService = new BloomRedisService();
        bloomRedisService.setBloomFilterHelper(initBloomFilterHelper());
        bloomRedisService.setRedisTemplate(template);
        return bloomRedisService;
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        List<Long> list = productService.getAllProductId();
        log.info("加载产品到布隆过滤器当中,size:{}",list.size());
        if(!CollectionUtils.isEmpty(list)){
            list.stream().forEach(item->{
                //LocalBloomFilter.put(item);
                bloomRedisService().addByBloomFilter(RedisKeyPrefixConst.PRODUCT_REDIS_BLOOM_FILTER,item+"");
            });
        }
    }
}
2.拦截
@Slf4j
public class BloomFilterInterceptor implements HandlerInterceptor {

    @Autowired
    private BloomRedisService bloomRedisService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String currentUrl = request.getRequestURI();
        PathMatcher matcher = new AntPathMatcher();
        //解析出pathvariable
        Map<String, String> pathVariable = matcher.extractUriTemplateVariables("/pms/productInfo/{id}", currentUrl);
        //布隆过滤器存储在redis中
        if(bloomRedisService.includeByBloomFilter(RedisKeyPrefixConst.PRODUCT_REDIS_BLOOM_FILTER,pathVariable.get("id"))){
            return true;
        }

        /**
         * 存储在本地jvm布隆过滤器中
         */
        /*if(LocalBloomFilter.match(pathVariable.get("id"))){
            return true;
        }*/

        /*
         * 不在本地布隆过滤器当中,直接返回验证失败
         * 设置响应头
         */
        response.setHeader("Content-Type","application/json");
        response.setCharacterEncoding("UTF-8");
        String result = new ObjectMapper().writeValueAsString(CommonResult.validateFailed("产品不存在!"));
        response.getWriter().print(result);
        return false;

    }

}

二、redis和数据库双写一致性问题

分析:一致性问题是分布式常见问题,还可以再分为最终一致性和强一致性。数据库和缓存双写,就必然会存在不一致的问题。答这个问题,先明白一个前提。就是如果对数据有强一致性要求,不能放缓存。我们所做的一切,只能保证最终一致性。另外,我们所做的方案其实从根本上来说,只能说降低不一致发生的概率,无法完全避免。因此,有强一致性要求的数据,不能放缓存。

回答:首先,采取正确更新策略,先更新数据库,再删缓存。

其次,讨论一些删缓存失败的问题。这种可能性极低,可以先加监控捕获删除失败的日志,然后在人工介入去删缓存吧,不建议在业务中写代码增加系统复杂度。如果硬要在系统中实现,可提供一个补偿措施即可,例如利用设置缓存失效时间(一般再秒级)或者消息队列的重试机制。

删除缓存失败的补偿方案参考:

  1. 对删除缓存进行重试,数据的一致性要求越高,我越是重试得快。(个人理解当删除缓存失败后,应该还要为当前key值设置标记,当下一次对当前key值有查询请求时,直接读数据库,直到删除失败的标记消失为止)

  2. 定期全量更新,简单地说,就是我定期把缓存全部清掉,然后再全量加载。

  3. 给所有的缓存一个失效期。

第三种方案可以说是一个大杀器,任何不一致,都可以靠失效期解决,失效期越短,数据一致性越高。但是失效期越短,查数据库就会越频繁。因此失效期应该根据业务来定。

将不一致分为三种情况:

  1. 数据库有数据,缓存没有数据;

  2. 数据库有数据,缓存也有数据,数据不相等;

  3. 数据库没有数据,缓存有数据。

1.读写策略

1 读请求

不要求强一致性的读请求,走redis,要求强一致性的直接从mysql读取。

读操作优先读取redis,不存在的话就去访问MySQL,并把读到的数据写回Redis中;

2 写请求

数据首先都写到数据库,然后把缓存里对应的数据失效掉(删掉)。

为什么不先删除缓存,然后再更新数据库,而后续的操作会把数据再装载的缓存中。

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

为什么不先写redis再写mysql

  • 先写redis再写mysql的弊端,如果数据库写入失败事务回滚会造成redis中存在脏数据

2.缓存更新策略

1.前言

 1.为何是删缓存而不是更新缓存?

这么做引发的问题是,如果A,B两个线程同时做数据更新,A先更新了数据库,B后更新数据库,则此时数据库里逻辑上存的是B的数据。

但要考虑两种情况:
1.A在更新缓存前卡住了(如网络原因),如果在更新缓存的时候,是B先更新了缓存,而A后更新了缓存,则缓存里是A的数据。这样缓存和数据库的数据也不一致。
2.A更新成功了,B更新失败了。则缓存里是A的数据。这样缓存和数据库的数据也不一致。

针对更新失败的解决方案:


方案一:采用补偿策略,更新失败后缓存delete。如果更新失败,那么再对缓存进行删除,让读请求重新回源来保证数据一致性。

方案二:“日志记录后手动操作”。更新失败会记入日志,手动操作【人工操作,不灵活】。

方案三:“key存入MQ重新消费set一次”。借用MQ中间件,将有问题的key放入MQ,重新消费进行更新缓存【借用第三方组件,耗费大】。

2.删缓存一定能保证双写一致性吗?

线程1和2为分别写数据,线程3为读数据。理论上缓存最后应该是线程2的数据,但实际有可能出现

出现的原因如下:

线程1执行完成后,此时缓存为空。这时线程2去更新数据库因为比较耗时还没有写完,另外一个线程3请求来查询数据,发现缓存里没有,就去数据库里查此时查到的为10,然后准备回写缓存前,此时线程2更新数据库完成了并删了缓存,线程3继续回写完缓存后,此时缓存的值为10。

但,这个case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作(否则就会读到写之后的数据或者读会被锁住,那就不存在一致性问题),而又要晚于写操作更新缓存(线程2写操作如果很慢,那线程3读的操作早就回写缓存了,那就不存在一致性问题,如果线程2写操作很快,那那线程3读的时候大概率会读到线程2数据库写操作之后的值了,那就不存在一致性问题),所有的这些条件都具备的概率基本并不大。

解决方案1:

遇到这种情况,可以用队列的去解决这个问题,创建一个先进先出的阻塞队列如ArrayBlockingQueue,当有数据更新请求时,先把它丢到队列里去,当更新完后在从队列里去除,如果在更新的过程中,遇到以上场景,先去缓存里看下有没有数据,如果没有,可以先去队列里看是否有相同商品ID在做更新,如果有也把查询的请求发送到队列的末端里去,然后同步等待缓存更新完成。 

这里有一个优化点,如果发现队列里有一个查询请求了,那么就不要放新的查询操作进去了,用一个while(true)循环去查询缓存,循环个200MS左右,如果缓存里还没有则直接取数据库的旧数据(不要更新缓存),一般情况下是可以取到的。

当然这种排队的策略对性能是有一定的影响的。

解决方案2:

利用Redisson的读写锁机制,在操作缓存时,线程3上了读锁之后,线程2就需要等待线程3写缓存释放之后才能去抢锁并删缓存。

注意:此方案报保证抢锁的顺序性,不能让写锁线程2一直阻塞,则会一直读到老的数据。

解决方案3:

自己想的:写操作阻塞一切,当要进行刷新缓存时,所有读操作都不可用直接返回缓存的空值,直到刷新完成。在刷新缓存区间,如果数据库值发生了变更,又会将循环上述刷新缓存操作,系统吞吐量要将会大大降低,不推荐使用。

3.高并发选型

思想准备:没有一种方案能完全满足一致性和高吞量,除非只用缓存,但成本很大。

1.并发不高的情况

即只有偶尔某一个时刻,可能并发请求稍微大一些。可以使用Cache Aside Pattern

读: 读redis->没有,只让一个线程读mysql->把mysql数据写回redis,有的话直接从redis中取;

新增写: 写mysql->成功,再写redis

更新写:更新mysql中的数据->成功,则删缓存

2.并发高的情况

1.Cache Aside Pattern

高并发情况下,为何不建议用Cache Aside Pattern模式?

我自己认为Cache Aside Pattern在进行数据更新后,直接让缓存失效,下次如果对当前key有大量的读请求,就会有缓存击穿的风险,及时使用了加锁解决缓存击穿问题,这样也会降低系统的吞吐量,但数据的一致性保证相对较高。

2.Write Behind Caching Pattern

即读写都在缓存中,最后在某一个负载低的时间段,异步备份到数据库中。

这样一致性是一致性最高,吞吐量最高,实际上很多大厂它就是这么干的。

但这种对缓存的容量要求有一点高

3.Read/Write Through思想

当然针对于Cache Aside Pattern模式更新数据带来的缓存击穿问题,可以使用Read/Write Through Pattern思想来解决缓存击穿问题

方案一

先写缓存后写数据库

读: 读redis->没有,只让一个线程读mysql->把mysql数据写回redis。读redis->有,有的话直接从redis中取;

写:异步化,先写入redis的缓存,就直接返回,然后异步写mysql.

异步写mysql可以如放到先进先出的阻塞队列将数据保存到mysql,可以做到多次更新,一次保存,如果写mysql失败,可以将对应进行删缓存,但这种方案会存在一定程度的数据不一致问题且会产生脏数据,有点像Cache Aside和Write Behind Caching 的融合版

方案二

先写数据库后写缓存

读: 读redis->没有,只让一个线程读mysql->把mysql数据写回redis。读redis->有,有的话直接从redis中取;

写:异步化,先写入mysql,就直接返回,然后异步刷新缓存。

异步刷新缓存对数据一致性的保证要比方案一要高一些,但在异步区间也会因为读到旧数据而存在数据不一致的问题,且吞吐量要低于方案一,所以这种方式比较鸡肋。

参考:
1.Redis怎么保持缓存与数据库一致性? https://blog.csdn.net/belalds/article/details/82078009
2.Redis 如何保持和MySQL数据一致 https://blog.csdn.net/thousa_ho/article/details/78900563

三、缓存服务治理

1 缓存穿透


缓存穿透是指缓存和数据库中都没有的数据。

导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。

如黑客故意去请求缓存中不存在的数据,导致所有的请求都怼到数据库上,从而数据库连接异常。

解决方案:


1.接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;

2.采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力

3.从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击。

2. 缓存击穿

1.定义

缓存击穿一般指缓存中没有但数据库中有的数据。

指的是并发量很高的 KEY,在该 KEY 失效的瞬间有很多请求同时去请求数据库,刷新缓存。

例如我们有一个业务 KEY,该 KEY 的并发请求量为 10000。当该 KEY 失效的时候,就会有 1 万个线程会去请求数据库更新缓存。这个时候如果没有采取适当的措施,那么数据库很可能崩溃。

与缓存雪崩的区别:在于这里针对某一key缓存,前者则是很多key。

与缓存穿透的区别:在于数据库中是有对应key值的,前者数据库是没有对应值的。

2.解决方案

1.使用互斥锁

当从缓存获取的是null值时,对当前key加锁,其他相同key的线程 处于等待现象,必须等待第一个构建完缓存之后,释放锁,其他线程才能通过该key访问数据;那其他线程就直接从缓存里面取数据,不会造成数据库的读写性能的缺陷;

原理如下图:

在这里插入图片描述

如果是单机,可以用synchronized或者lock来处理,如果是分布式环境可以用分布式锁就可以了(分布式锁,可以用memcache的add, redis的setnx, zookeeper的添加节点操作)。

分布式版源码如下:

public String get(key) {
      String value = redis.get(key);
      if (value == null) { //代表缓存值过期
        // 3 min timeout to avoid mutex holder crash
		  if (redis.setnx(key_mutex, 1, 3 * 60) == 1) {  //代表设置成功
               value = db.get(key);
                      redis.set(key, value, expire_secs);
                      redis.del(key_mutex);
              } else {  //这个时候代表同时候的其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可
                      sleep(50);
                      get(key);  //重试
              }
          } else {
              return value;      
          }
 }
2."提前"使用互斥锁(mutex key)

在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。

然后再从数据库加载数据并设置到cache中。

3.“热点数据永远不过期”

这里的“永远不过期”包含两层意思:

  • 物理”不过期:从redis上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题。同时如果发现需要刷新缓存时,采用更新缓存而不是删缓存的策略。
  • “逻辑”不过期:从功能上看,我们把过期时间存在key对应的value里,如果发现要过期了,开启一个线程进行异步刷新。也可以通过一个后台定时任务异步进行缓存的刷新。

其实现原理如下:

源码如下:

String get(final String key) {  
        V v = redis.get(key);  
        String value = v.getValue();  
        long timeout = v.getTimeout();  
        if (v.timeout <= System.currentTimeMillis()) {  
            // 异步更新后台异常执行  
            threadPool.execute(new Runnable() {  
                public void run() {  
                    String keyMutex = "mutex:" + key;  
                    if (redis.setnx(keyMutex, "1")) {  
                        // 3 min timeout to avoid mutex holder crash  
                        redis.expire(keyMutex, 3 * 60);  
                        String dbValue = db.get(key);  
                        redis.set(key, dbValue);  
                        redis.delete(keyMutex);  
                    }  
                }  
            });  
        }  
        return value;  
    }  

从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。

4.资源保护

采用netflix的hystrix,可以做资源的隔离保护主线程池,如果把这个应用到缓存的构建也未尝不可。

具体策略:

当缓存失效后,可以通过熔断机制直接返回默认值或错误信息,避免请求直接访问数据库。这种方式可以在一定程度上对抗缓存击穿,但需要根据业务场景合理设置熔断策略。

可以在降级策略里让有限的流量刷新缓存,其他流量返回空值或者默认值。

四种解决方案:没有最佳只有最合适

3. 缓存雪崩


1.即缓存同一时间大面积的失效,这个时候又来了一波请求,结果请求都怼到数据库上,从而导致数据库连接异常。
2.redis集群挂了,请求都到了数据库。

解决办法:

1.给缓存的失效时间,加上一个随机值,避免集体失效。即在设置过期时间时,让所配置的过期时间在乘以一个随机值

2.使用互斥锁,但是该方案吞吐量明显下降了。即流量消峰

3.缓存预热。

4.服务熔断降级

使用 Hystrix进行限流 & 降级 ,比如一秒来了5000个请求,我们可以设置假设只能有一秒 2000个请求能通过这个组件,那么其他剩余的 3000 请求就会走限流逻辑。然后去调用我们自己开发的降级组件(降级),比如设置的一些默认值呀之类的。以此来保护最后的 MySQL 不会被大量的请求给打死。

因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。

服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作,这里可以采用降级的方式返回一个默认值或者空值

4.双缓存(实践中很少使用)。

我们有两个缓存,缓存A和缓存B。缓存A的失效时间为20分钟,缓存B不设失效时间。自己做缓存预热操作。
然后细分以下几个小点

1)从缓存A读数据库,有则直接返回

2)A没有数据,直接从B读数据,直接返回,并且异步启动一个更新线程。
3)更新线程同时更新缓存A和缓存B。

但这样会增大内存空间。缓存B可以使用lru算法来兼容空间问题。
 

4.缓存预热

冷启动:服务刚刚启动时,Redis中并没有缓存,如果所有商品数据都在第一次查询时添加缓存,可能会给数据库带来较大压力。

1、缓存预热定义

缓存预热就是系统上线后,提前将相关的缓存数据直接加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!

2、解决方案

1)直接写个缓存刷新页面,上线时手工操作下。
2)数据量不大,可以在项目启动的时候自动进行加载。
3)定时刷新缓存。

项目启动时实现缓存预热

package com.heima.item.config;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.heima.item.pojo.Item;
import com.heima.item.pojo.ItemStock;
import com.heima.item.service.IItemService;
import com.heima.item.service.IItemStockService;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
public class RedisHandler implements InitializingBean {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private IItemService itemService;
    @Autowired
    private IItemStockService stockService;

    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Override
    public void afterPropertiesSet() throws Exception {
        // 初始化缓存
        // 1.查询商品信息
        List<Item> itemList = itemService.list();
        // 2.放入缓存
        for (Item item : itemList) {
            // 2.1.item序列化为JSON
            String json = MAPPER.writeValueAsString(item);
            // 2.2.存入redis
            redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
        }

        // 3.查询商品库存信息
        List<ItemStock> stockList = stockService.list();
        // 4.放入缓存
        for (ItemStock stock : stockList) {
            // 2.1.item序列化为JSON
            String json = MAPPER.writeValueAsString(stock);
            // 2.2.存入redis
            redisTemplate.opsForValue().set("item:stock:id:" + stock.getId(), json);
        }
    }
}

这里我们利用InitializingBean接口来实现,因为InitializingBean可以在对象被Spring创建并且成员变量全部注入后执行。

四、多级缓存

1.为何需要多级缓存

传统的缓存策略一般是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库。

如图:

存在下面的问题:

•请求要经过Tomcat处理,Tomcat的性能成为整个系统的瓶颈

•Redis缓存失效时,会对数据库产生冲击

多级缓存

2.多级缓存

多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻Tomcat压力,提升服务性能:

  • 浏览器访问静态资源时,优先读取浏览器本地缓存
  • 访问非静态资源(ajax查询数据)时,访问服务端
  • 请求到达Nginx后,优先读取Nginx本地缓存
  • 如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat)
  • 如果Redis查询未命中,则查询Tomcat
  • 请求进入Tomcat后,优先查询JVM进程缓存
  • 如果JVM进程缓存未命中,则查询数据库

在多级缓存架构中,Nginx内部需要编写本地缓存查询、Redis查询、Tomcat查询的业务逻辑,因此这样的nginx服务不再是一个反向代理服务器,而是一个编写业务的Web服务器了

因此这样的业务Nginx服务也需要搭建集群来提高并发,再有专门的nginx服务来做反向代理,如图:

另外,我们的Tomcat服务将来也会部署为集群模式:

可见,多级缓存的关键有两个:

  • 一个是在nginx中编写业务,实现nginx本地缓存、Redis、Tomcat的查询

  • 另一个就是在Tomcat中实现JVM进程缓存

其中Nginx编程则会用到OpenResty框架结合Lua这样的语言。

JVM进程缓存则用Caffeine框架进行实现

3.二级缓存

其实大部分业务用不到三级以上的缓存架构,大多使用jvm+redis二级缓存架构来进行实现缓存

4.多级缓存数据一致性

缓存数据同步的常见方式有三种:

设置有效期:给缓存设置有效期,到期后自动删除。再次查询时更新

  • 优势:简单、方便
  • 缺点:时效性差,缓存过期之前可能不一致
  • 场景:更新频率较低,时效性要求低的业务

同步双写:在修改数据库的同时,直接修改缓存

  • 优势:时效性强,缓存与数据库强一致
  • 缺点:有代码侵入,耦合度高;
  • 场景:对一致性、时效性要求较高的缓存数据

异步通知:修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据

  • 优势:低耦合,可以同时通知多个缓存服务
  • 缺点:时效性一般,可能存在中间不一致状态
  • 场景:时效性要求一般,有多个服务需要同步

异步实现又可以基于MQ或者Canal来实现

1.基于MQ的异步通知

  • 商品服务完成对数据的修改后,只需要发送一条消息到MQ中。
  • 缓存服务监听MQ消息,然后完成对缓存的更新

2.基于Canal的通知

  • 商品服务完成商品修改后,业务直接结束,没有任何代码侵入
  • Canal监听MySQL变化,当发现变化后,立即通知缓存服务
  • 缓存服务接收到canal通知,更新缓存
  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值