并发与高并发(十八) 高并发之缓存思路

前言

缓存是什么?缓存如何使用?何时该使用?

主体概要

  • 高并发之缓存-特征、场景及组件介绍
  • 高并发之缓存-redis的使用
  • 高并发之缓存-高并发场景问题及实战

主体内容

一、高并发之缓存-特征、场景及组件介绍

1.以下是客户端请求数据的过程。

随着用户数量不断增加,服务器资源消耗增大,这时就需要引入缓存。就上图中说,1,2,3,4环节其实都可以引入缓存,知识缓存的使用稍有不同。

2.接下来,我们说一下缓存的特征

(1)命中率=命中数/(命中数+没有命中数)【这里的命中指的是可以获取缓存中的数据,反之无法获取缓存中的数据】

(2)最大元素(空间)【它代表的是缓存中可以存储的最大元素的数量,一般来说,一旦缓存中元素数量超过这个值,缓存数据所占的空间超过了最大支持的空间,将会触发“缓存清空”策略。根据不同的场景合理的设置最大值,往往可以一定程度上提高缓存的命中率,从而更有效的存储缓存。】

(3)我们常见的情况策略有:FIFO,LFU,LRU,过期时间,随机等。

  • FIFO:First In First Out【先进先出策略】,指最先进入缓存的数据在缓存空间不够的情况下,或者是超出最大元素限制的时候,会优先被清除掉以腾出新的空间来接受新的数据,这个策略算法主要是比较缓存的创建时间,在数据实时性要求场景下可以选择该类策略优先保障最新数据可用。
  • LFU:Least Frequently Used【最少使用策略】,指无论数据是否过期,根据元素的被使用次数判断,清除使用次数最少的元素来释放空间,这个策略的算法主要是比较元素的命中次数,在保证高频数据有效的场景下,可以选择这类策略。
  • LRU:List Recently Used【最近最少使用策略】,指无论是否过期,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间,这个策略主要比较元素最近一次被get使用时间,在热点数据场景下较为适用,优先保障热点数据的有效性。
  • 过期时间:根据过期时间判断,清理已过期时间最长的元素或清理最近过期元素
  • 随机:顾名思义,随机清理。

3.缓存命中率影响因素

(1)业务场景和业务需求

缓存适合“读多写少”的业务场景,反之,使用缓存的意义其实并不大,命中率会很低。

业务需求决定了对时效性的要求,直接影响到缓存的过期时间和更新策略。时效性要求越低,就越适合缓存。在相同key和相同请求数的情况下,缓存时间越长,命中率会越高。

互联网应用的大多数业务场景下都是很适合使用缓存的。

(2)缓存的设计(粒度和策略)

通常情况下,缓存的粒度越小,命中率会越高。举个实际的例子说明:

当缓存单个对象的时候(例如:单个用户信息),只有当该对象对应的数据发生变化时,我们才需要更新缓存或者让移除缓存。而当缓存一个集合的时候(例如:所有用户数据),其中任何一个对象对应的数据发生变化时,都需要更新或移除缓存。

还有另一种情况,假设其他地方也需要获取该对象对应的数据时(比如其他地方也需要获取单个用户信息),如果缓存的是单个对象,则可以直接命中缓存,反之,则无法直接命中。这样更加灵活,缓存命中率会更高。

此外,缓存的更新/过期策略也直接影响到缓存的命中率。当数据发生变化时,直接更新缓存的值会比移除缓存(或者让缓存过期)的命中率更高,当然,系统复杂度也会更高。

(3)缓存容量和基础设施

缓存的容量有限,则容易引起缓存失效和被淘汰(目前多数的缓存框架或中间件都采用了LRU算法)。同时,缓存的技术选型也是至关重要的,比如采用应用内置的本地缓存就比较容易出现单机瓶颈,而采用分布式缓存则毕竟容易扩展。所以需要做好系统容量规划,并考虑是否可扩展。此外,不同的缓存框架或中间件,其效率和稳定性也是存在差异的。

(4)其他因素

当缓存节点发生故障时,需要避免缓存失效并最大程度降低影响,这种特殊情况也是架构师需要考虑的。业内比较典型的做法就是通过一致性Hash算法,或者通过节点冗余的方式。

有些朋友可能会有这样的理解误区:既然业务需求对数据时效性要求很高,而缓存时间又会影响到缓存命中率,那么系统就别使用缓存了。其实这忽略了一个重要因素--并发。通常来讲,在相同缓存时间和key的情况下,并发越高,缓存的收益会越高,即便缓存时间很短。

4.提高缓存命中率的方法

从架构师的角度,需要应用尽可能的通过缓存直接获取数据,并避免缓存失效。这也是比较考验架构师能力的,需要在业务需求,缓存粒度,缓存策略,技术选型等各个方面去通盘考虑并做权衡。尽可能的聚焦在高频访问且时效性要求不高的热点业务上,通过缓存预加载(预热)、增加存储容量、调整缓存粒度、更新缓存等手段来提高命中率。

对于时效性很高(或缓存空间有限),内容跨度很大(或访问很随机),并且访问量不高的应用来说缓存命中率可能长期很低,可能预热后的缓存还没来得被访问就已经过期了。
4.缓存的分类和应用场景

(1)分类

  • 本地缓存:Guava Cache,SpringMVC本地缓存,Mybatis二级缓存。本地缓存它指的是应用中的缓存组件,它最大的优点是应用和cache是在同一个进程的内部,请求的缓存非常的快速,没有过多的网络开销,在单应用中不需要集群支持,或在集群情况下各节点不需要互相通知的场景下使用本地缓存比较合适。同时它的缺点也是缓存跟应用呈于耦合,多应用程序无法直接共享缓存,各应用或集群的各个节点都需要维护自己的单独缓存,有时对内存也是一种浪费。

  • 分布式缓存:Memceche,Redis。指的是应用分离的缓存组件或服务,它最大的优点就是自身就是一个独立的应用,与本地应用是隔离的,多个应用是可以直接共享缓存。

(2)接下来,我们具体讲解一下Guava Cache和Memceche,以及Redis。

a.首先,是Guava Cache。它是谷歌开源的工具库,它的架构思想来源于我们之前讲过的ConcurrentHashMap,简单场景下我们可以自行编码通过HashMap来做一些少量数据的缓存,但是如果结果可能会改变的话,或者是希望存储数据的空间是可控的话,我们自己实现一个结构还是很有必要的。

我们看一下Guava Cache的架构图

它继承了ConcurrentHashMapD的思路,使用多个Segments的细粒度锁,在保证线程安全的同时,支持高并发场景的需求,这里的Cache类似于一个map,它是存储键值对的集合,不同的是,它还要处理我们的缓存过期,动态加载等一些算法的逻辑,需要一些额外的信息来实现这些操作,对此,根据面向对象的思想,它还要做方法与数据关联性的封装。它主要实现的缓存功能有:

  • 自动将entry节点加载进缓存结构中;
  • 当缓存的数据超过设置的最大值时,使用LRU算法移除;
  • 具备根据entry节点上次被访问或者写入时间计算它的过期机制;
  • 缓存的key被封装在WeakReference引用内;
  • 缓存的Value被封装在WeakReference或SoftReference引用内;
  • 统计缓存使用过程中命中率、异常率、未命中率等统计数据。

b.接下来,我们介绍一下Memcache,话不多说,直接祭出它的结构图。

memcached是应用较广的开源分布式缓存产品之一,它本身其实不提供分布式解决方案。在服务端,memcached集群环境实际就是一个个memcached服务器的堆积,环境搭建较为简单;cache的分布式主要是在客户端实现,通过客户端的路由处理来达到分布式解决方案的目的。客户端做路由的原理非常简单,应用服务器在每次存取某key的value时,通过某种算法把key映射到某台memcached服务器nodeA上,因此这个key所有操作都在nodeA上。

memcached客户端采用一致性hash算法作为路由策略,如图所示,相对于一般hash(如简单取模)的算法,一致性hash算法除了计算key的hash值外,还会计算每个server对应的hash值,然后将这些hash值映射到一个有限的值域上(比如0~2^32)。通过寻找hash值大于hash(key)的最小server作为存储该key数据的目标server。如果找不到,则直接把具有最小hash值的server作为目标server。同时,一定程度上,解决了扩容问题,增加或删除单个节点,对于整个集群来说,不会有大的影响。最近版本,增加了虚拟节点的设计,进一步提升了可用性。

memcached是一个高效的分布式内存cache,了解memcached的内存管理机制,才能更好的掌握memcached,让我们可以针对我们数据特点进行调优,让其更好的为我所用。我们知道memcached仅支持基础的key-value键值对类型数据存储。在memcached内存结构中有两个非常重要的概念:slab和chunk。每个page的默认大小是1M,trunk是真正存放数据的地方,memcache会根据value值的大小找到接近大小的slab。针对memcache的详细介绍待续研究ing...这里仅暂时作一个简单的介绍,如果有兴趣研究,可以参照这位大佬的文章:https://blog.csdn.net/zl1zl2zl3/article/details/83928726。

c.最后,介绍一下Redis。 Redis是一个远程内存数据库,它不仅性能强劲,而且还具有复制特性以及为解决问题而生的独一无二的数据模型。Redis提供了5种不同类型的数 据结构,各式各样的问题都可以很自然地映射到这些数据结构上:Redis的数据结构致力于帮助用户解决问题,而不会像其他数据库那样,要求用户扭曲问题来 适应数据库。除此之外,通过复制、持久化(persistence)和客户端分片(client-side sharding)等特性,用户可以很方便地将Redis扩展成一个能够包含数百GB数据、每秒处理上百万次请求的系统。结构如图所示:

解释:

​ 首先Redis内部使用一个redisObject对象来表示所有的key和value,redisObject最主要的信息,

​ type代表一个value对象具体是何种数据类型,

​ encoding是不同数据类型在redis内部的存储方式,

​ 比如:type=string代表value存储的是一个普通字符串,那么对应的encoding可以是raw或者是int,如果是int则代表实际redis内部是按数值型类存储和表示这个字符串的,当然前提是这个字符串本身可以用数值表示,比如:"123" "456"这样的字符串。

其性能及其强悍,读速度可达110000次/s,写可达到80000次/s,其次它有丰富的数据类型,具有原子性,Redis所有操作都是原子性的。Redis相信大家平时项目中大都采用了,这里就不再详细介绍。

Redis有何应用场景呢?

  • 取N个数据的操作
  • 排行榜,比如取top(n)的操作
  • 应用于需要精准设定过期时间
  • 用于计数器的应用
  • 适用于作唯一性检查的操作
  • 获取某段时间内数据的值
  • 适用于实时系统、反垃圾系统
  • pub(Publish) sub(subcribe)消息系统
  • 队列系统
  • 缓存

这里只讲各缓存的特性,如果需要详细研究请参考其它文章或课程。

二、高并发之缓存-redis的使用

这里我们结合springboot来演示一下redis的基本使用

1.首先引用依赖包

 <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>2.8.2</version>
 </dependency>

2.创建一个配置类RedisConfig.java

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import redis.clients.jedis.JedisPool;

/**
 * Redis配置类
 */
@Configuration
public class RedisConfig {
    @Bean(name="redisPool")
    public JedisPool jedisPool(@Value("${jedis.host}") String host, @Value("${jedis.port}") int port)	{
        return  new JedisPool(host,port);
    }
}

3.去application.properties配置host和port,当然如果想要指定更多参数请调用它其它的构造函数。

#redis
jedis.host=127.0.0.1
jedis.port=6379

4.接着创建Redis客户端类RedisClient.java,这里示例只封装get和set方法,其它方法根据需要使用。如果需要练习Redis,可以去这个网站:http://redis.cn

import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import javax.annotation.Resource;

@Component
public class RedisClient {
    @Resource(name="redisPool")
    private JedisPool jedisPool;

    public void set(String key,String value) throws Exception{
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            //如果取到值直接set
            jedis.set(key,value);
        } finally {
            if(jedis!=null){
                jedis.close();
            }
        }
    }

    public String get(String key) throws Exception{
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            //如果取到值直接set
            return jedis.get(key);
        } finally {
            if(jedis!=null){
                jedis.close();
            }
        }
    }
}

5.创建两个测试接口分别测试get,set

import com.practice.cache.redis.RedisClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/cache")
public class CacheController {
    @Autowired
    RedisClient redisClient;

    @RequestMapping("/set")
    public String set(@RequestParam("k") String k,@RequestParam("v") String v) throws Exception{
        redisClient.set(k,v);
        return "success";
    }
    @RequestMapping("/get")
    public String get(@RequestParam("k") String k) throws Exception{
        redisClient.get(k);
        return "success";
    }
}

6.启动Redis,简单用浏览器访问接口。


三、高并发之缓存-高并发场景问题及实战

缓存一致性问题

当数据时效性要求很高时,需要保证缓存中的数据与数据库中的保持一致,而且需要保证缓存节点和副本中的数据也保持一致,不能出现差异现象。这就比较依赖缓存的过期和更新策略。一般会在数据发生更改的时,主动更新缓存中的数据或者移除对应的缓存。

img

缓存并发问题

缓存过期后将尝试从后端数据库获取数据,这是一个看似合理的流程。但是,在高并发场景下,有可能多个请求并发的去从数据库获取数据,对后端数据库造成极大的冲击,甚至导致 “雪崩”现象。此外,当某个缓存key在被更新时,同时也可能被大量请求在获取,这也会导致一致性的问题。那如何避免类似问题呢?我们会想到类似“锁”的机制,在缓存更新或者过期的情况下,先尝试获取到锁,当更新或者从数据库获取完成后再释放锁,其他的请求只需要牺牲一定的等待时间,即可直接从缓存中继续获取数据。

img

缓存穿透问题

缓存穿透在有些地方也称为“击穿”。很多朋友对缓存穿透的理解是:由于缓存故障或者缓存过期导致大量请求穿透到后端数据库服务器,从而对数据库造成巨大冲击。

这其实是一种误解。真正的缓存穿透应该是这样的:

在高并发场景下,如果某一个key被高并发访问,没有被命中,出于对容错性考虑,会尝试去从后端数据库中获取,从而导致了大量请求达到数据库,而当该key对应的数据本身就是空的情况下,这就导致数据库中并发的去执行了很多不必要的查询操作,从而导致巨大冲击和压力。

可以通过下面的几种常用方式来避免缓存传统问题:

  1. 缓存空对象

对查询结果为空的对象也进行缓存,如果是集合,可以缓存一个空的集合(非null),如果是缓存单个对象,可以通过字段标识来区分。这样避免请求穿透到后端数据库。同时,也需要保证缓存数据的时效性。这种方式实现起来成本较低,比较适合命中不高,但可能被频繁更新的数据。

  1. 单独过滤处理

对所有可能对应数据为空的key进行统一的存放,并在请求前做拦截,这样避免请求穿透到后端数据库。这种方式实现起来相对复杂,比较适合命中不高,但是更新不频繁的数据。

img

缓存颠簸问题

缓存的颠簸问题,有些地方可能被成为“缓存抖动”,可以看做是一种比“雪崩”更轻微的故障,但是也会在一段时间内对系统造成冲击和性能影响。一般是由于缓存节点故障导致。业内推荐的做法是通过一致性Hash算法来解决。这里不做过多阐述,可以参照其他章节

缓存的雪崩现象

缓存雪崩就是指由于缓存的原因,导致大量请求到达后端数据库,从而导致数据库崩溃,整个系统崩溃,发生灾难。导致这种现象的原因有很多种,上面提到的“缓存并发”,“缓存穿透”,“缓存颠簸”等问题,其实都可能会导致缓存雪崩现象发生。这些问题也可能会被恶意攻击者所利用。还有一种情况,例如某个时间点内,系统预加载的缓存周期性集中失效了,也可能会导致雪崩。为了避免这种周期性失效,可以通过设置不同的过期时间,来错开缓存过期,从而避免缓存集中失效。

从应用架构角度,我们可以通过限流、降级、熔断等手段来降低影响,也可以通过多级缓存来避免这种灾难。

此外,从整个研发体系流程的角度,应该加强压力测试,尽量模拟真实场景,尽早的暴露问题从而防范。

img

缓存无底洞现象

该问题由 facebook 的工作人员提出的, facebook 在 2010 年左右,memcached 节点就已经达3000 个,缓存数千 G 内容。

他们发现了一个问题---memcached 连接频率,效率下降了,于是加 memcached 节点,

添加了后,发现因为连接频率导致的问题,仍然存在,并没有好转,称之为”无底洞现象”。

img

目前主流的数据库、缓存、Nosql、搜索中间件等技术栈中,都支持“分片”技术,来满足“高性能、高并发、高可用、可扩展”等要求。有些是在client端通过Hash取模(或一致性Hash)将值映射到不同的实例上,有些是在client端通过范围取值的方式映射的。当然,也有些是在服务端进行的。但是,每一次操作都可能需要和不同节点进行网络通信来完成,实例节点越多,则开销会越大,对性能影响就越大。

主要可以从如下几个方面避免和优化:

  1. 数据分布方式

    有些业务数据可能适合Hash分布,而有些业务适合采用范围分布,这样能够从一定程度避免网络IO的开销2

​ 2.IO优化

​ 可以充分利用连接池,NIO等技术来尽可能降低连接开销,增强并发连接能力。

​ 3.数据访问方式

​ 一次性获取大的数据集,会比分多次去获取小数据集的网络IO开销更小。

​ 当然,缓存无底洞现象并不常见。在绝大多数的公司里可能根本不会遇到。

猜你喜欢

转载自www.cnblogs.com/jmy520/p/12701820.html
今日推荐