高并发系统之限流、缓存和降级设计方案_缓存过期 tps下降-程序员宅基地

技术标签: 高并发  限流  降级  缓存  

高并发系统设计方案

高并发系统设计一般会考虑三个方面:限流、缓存、降级

限流:控制在一定时间内的访问量,比如秒杀,这种场景下访问量过于庞大,使用缓存或者降级根本无法解决访问量巨大的问题,那么只能选择限流

缓存:缓存设计是我们常用的减轻服务器压力的方案,常用的缓存有 redis(分布式)、 memcache(分布式)、google guava cache(本地缓存)等

降级:高并发高负载情况下,选择动态的关闭一些不重要的服务拒绝访问等,为重要的服务节省资源,比如双11当天淘宝关闭了退款等功能

限流

常见的限流算法:令牌桶、漏桶、计数器

接入层限流:指请求流量的入口,该层的主要目的有 负载均衡、非法请求过滤、请求聚合、服务质量监控等等

Nginx接入层限流:使用Nginx自带了两个模块,连接数限流模块ngx_http_limit_conn_module和漏桶算法实现的请求限流模块ngx_http_limit_req_module

应用层限流:比如TPS/QPS超过一定范围后进行控制,比如tomcat可配置可接受的等待连接数、最大连接数、最大线程数等

令牌桶:Guava框架提供了令牌桶算法实现,可直接拿来使用,Guava RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现,代码如下:

import java.util.concurrent.atomic.AtomicInteger;

import org.junit.Test;

import com.google.common.util.concurrent.RateLimiter;

/**
 * 固定速率请求,每200ms允许一个请求通过
 *
 * @date 2019-10-14 16:54
 **/
public class FixedRequestLimitDemo {
    @Test
    public void fixedRequestTest() {
        // 表示1秒内产生多少个令牌,即1秒内产生5个令牌,控制每200ms一个请求
        RateLimiter rateLimiter = RateLimiter.create(5);
        AtomicInteger counter = new AtomicInteger(0);

        for (int i = 0; i < 15; i++) {
            // 同时开启15个线程访问
            new Thread(() -> fixedRequest(rateLimiter, counter)).start();
        }

        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private void fixedRequest(RateLimiter rateLimiter, AtomicInteger counter) {
        double time = rateLimiter.acquire();
        if (time >= 0) {
            System.out.println("时间:" + time + " ,第 " + counter.incrementAndGet() + " 个业务处理");
        }
    }
}

计数器:使用计数器方案简单粗暴的实现限流,使用 google guava cache(本地缓存),代码如下:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

import org.junit.Test;

import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;

/**
 * 限流demo
 *
 * @date 2019-10-12 17:26
 **/
public class LimitDemo {
    @Test
    public void limitTest() {
        // 本地缓存、key (Long)表示当前时间秒、value (AtomicLong)表示请求计数器
        LoadingCache<Long, AtomicLong> counter = CacheBuilder.newBuilder().
                expireAfterAccess(2, TimeUnit.SECONDS).build(
                new CacheLoader<Long, AtomicLong>() {
                    @Override
                    public AtomicLong load(Long aLong) throws Exception {
                        return new AtomicLong(0);
                    }
                }
        );

        for (int i = 0; i < 15; i++) {
            // 同时开启15个线程访问
            new Thread(() -> requestLimit(counter)).start();
        }

        try {
            // 这里休眠是为了多线程全部执行完输出结果
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

   /**
     * 请求限制,本方法加锁是为了控制缓存LoadingCache get数据时候的并发
     *
     * @param counter
     */
    private synchronized void requestLimit(LoadingCache<Long, AtomicLong> counter) {
        try {
            // 流量限制数量
            long limit = 10;
            // 当前秒数
            long currentSecond = System.currentTimeMillis() / 1000;
            if (counter.get(currentSecond).incrementAndGet() > limit) {
                // 超出每秒内允许访问10个的限制
                System.out.println("第 " + counter.get(currentSecond) + " 个请求超出上限,限流了");
                return;
            }

            System.out.println("第 " + counter.get(currentSecond) + " 个业务处理");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

缓存

缓存方案可以有效地减轻服务器压力,但是它的设计也有一些必须考虑的问题,例如缓存雪崩、缓存击穿、缓存穿透、缓存预热、缓存降级、资源隔离

缓存雪崩

设置缓存时使用了相同的过期时间,导致大量的缓存在同一时刻同时失效,请求全部访问了DB(数据层),DB瞬间压力过大而宕机,从而引起一系列的严重后果

解决方案

1、使用锁或者队列的方式控制多线程同时对DB的读写,即避免失效时所有请求一下子全部访问到DB

2、缓存失效时间分散开,即设置缓存时设置不同的过期时间(原有的缓存时间上增加一个随机数),避免同一时刻大量缓存失效

3、缓存数据增加缓存失效标记,如果缓存标记失效,则更新数据缓存

使用加锁一般适用于并发量不是特别大的场景,伪代码如下:

//伪代码
public object getProductList() {
    int cacheTime = 30;
    String cacheKey = "product_list";
    String lockKey = cacheKey;

    String cacheValue = CacheHelper.get(cacheKey);
    if (cacheValue != null) {
        return cacheValue;
    } else {
        // 对lockKey加锁
        synchronized(lockKey) {
            cacheValue = CacheHelper.get(cacheKey);
            if (cacheValue != null) {
                return cacheValue;
            } else {
	            //这里一般是sql查询数据
                cacheValue = getProductListFromDB(); 
                CacheHelper.Add(cacheKey, cacheValue, cacheTime);
            }
        }
        return cacheValue;
    }
}

注意:加锁排队仅仅是减轻了数据库的压力,但是并没有提高系统吞吐量,它不仅要解决分布式锁的问题,还会产生线程阻塞问题,所以用户体验比较差!因此,在真正的高并发场景下很少使用!

缓存数据增加缓存失效标记,伪代码如下:

//伪代码
public object getProductList() {
    int cacheTime = 30;
    String cacheKey = "product_list";
    // 缓存标记
    String cacheSign = cacheKey + "_sign";

    String sign = CacheHelper.Get(cacheSign);
    // 获取缓存值
    String cacheValue = CacheHelper.Get(cacheKey);
    if (sign != null) {
        //未过期,直接返回
        return cacheValue; 
    } else {
        CacheHelper.Add(cacheSign, "1", cacheTime);
        // 开启后台线程来更新缓存
        ThreadPool.QueueUserWorkItem((arg) -> {
			// sql查询数据
            cacheValue = getProductListFromDB(); 
	        //日期设缓存时间的2倍,用于脏读
	        CacheHelper.Add(cacheKey, cacheValue, cacheTime * 2);                 
        });
        return cacheValue;
    }
} 

注意:缓存标记的失效时间设置为缓存数据失效时间的一半,这样根据缓存标记后台线程提前更新缓存数据,在这之前还可以返回旧的缓存数据,这种方案对内存要求高,每个缓存都要设置一个对应的缓存标记

缓存击穿

正好要过期的key在某一个时刻被高并发的访问,即某时刻的热点数据,key正好过期了需要请求DB回写到缓存中去,此时大量的请求都访问到DB,DB瞬间压力过大也崩掉了。这里和缓存雪崩不同的是缓存击穿针对的是某一个key,而缓存雪崩针对的是多个key

解决方案

1、使用互斥锁

2、缓存不过期,使用value内部的过期时间来控制过期更新缓存值

使用互斥锁,伪代码如下:

    // 1、redis setnx 实现
    public String getValue(key) {
        String value = redis.get(key);
        if(value != null){
            return value;
        }
        // 缓存值过期,获取锁,设置3min的超时,防止del操作失败的时候产生死锁
        if (redis.setnx(key_mutex, 1, 3 * 60) == 1) {
            // 代表获取锁成功
            value = db.get(key);
            redis.set(key, value, expire_secs);
            redis.del(key_mutex);
            return value;
        }

        // 没有获取到锁表示其他线程已经重新设置缓存了,此时重试获取缓存即可
        try {
            Thread.sleep(50);
            //重试
            getValue(key);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }


    // 2、memcache 实现
    public String getValue(String key) {
        String value = memcache.get(key);
        if (value != null) {
            return value;
        }

        // 加锁
        if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
            value = db.get(key);
            memcache.set(key, value);
            memcache.delete(key_mutex);
            return value;
        }

        // 没有获取到锁表示其他线程已经重新设置缓存了,此时重试获取缓存即可
        try {
            Thread.sleep(50);
            //重试
            getValue(key);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

缓存不过期,使用value内部过期值更新缓存,伪代码如下:

    /**
     * redis 实现
     * <p>
     * V 对象中有两个属性,value 是缓存值,timeout 是缓存过期更新时间
     *
     * @param key
     * @return
     */
    public String getValue(String key) {
        V v = redis.get(key);
        String value = v.getValue();
        long timeout = v.getTimeout();
        if (timeout > System.currentTimeMillis()) {
            // 缓存值没有到缓存过期时间,直接返回
            return value;
        }

        // 缓存值过期,异步更新后台执行
        threadPool.execute(new Runnable() {
            public void run() {
                String keyMutex = "mutex:" + key;
                if (redis.setnx(keyMutex, "1", 3 * 60) == 1) {
                    String newValue = db.get(key);
                    redis.set(key, newValue);
                    redis.delete(keyMutex);
                }
            }
        });

        // 此时直接返回旧value值
        return value;
    }

此方法的优点是并发性能好,缺点是不能及时的获取到最新的缓存值,有点延迟

缓存穿透

查询一个数据库中不存在的数据,此时缓存中也不会设置缓存值,那么所有请求都会直接查询到DB,将好像是缓存穿透了一样,请求过大将会导致DB宕机引起严重后果。黑客可以利用这种不存在的key来频繁请求我们的应用,拖垮应用服务器

解决方案

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

2、查询到数据为空的数据也设置到缓存系统中,缓存时间可以设置的相对短一些,这样子的话缓存就可以起作用,挡掉了直接访问DB的压力

查询到数据为空的数据也设置到缓存系统,伪代码如下:

    public String getValue(key) {
        String value = redis.get(key);
        if (value != null) {
            return value;
        }
        // 缓存值过期,获取锁,设置3min的超时,防止del操作失败的时候产生死锁
        if (redis.setnx(key_mutex, 1, 3 * 60) == 1) {
            // 代表获取锁成功
            value = db.get(key);
            if (value == null) {
                // value 为空时也缓存起来
                value = String.empty;
            }
            redis.set(key, value, expire_secs);
            redis.del(key_mutex);
            return value;
        }

        // 没有获取到锁表示其他线程已经重新设置缓存了,此时重试获取缓存即可
        try {
            Thread.sleep(50);
            //重试
            getValue(key);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }

缓存预热

缓存预热就是系统上线后,将需要用的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候再去查询DB设置数据缓存,用户直接查询事先被预热的缓存数据即可

解决方案

1、数据量不大的时候直接在项目启动的时候加载缓存数据

2、定时刷新缓存数据

3、页面按钮手动操作刷新缓存数据

缓存降级

当访问量剧增,缓存服务响应慢时,需要对某些数据自动缓存降级,也可以配置开关人工降级,例如redis缓存访问不到的时候降级访问二级缓存、本地缓存等

在进行降级之前要对系统进行梳理,那些缓存可以降级,那些不能降级,然后设置预案:

1、一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;

2、警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;

3、错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;

4、严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级

使用Hystrix对Redis进行资源隔离

对redis的访问加上保护措施,全都用hystrix的command进行封装,做资源隔离,确保redis的访问只能在固定的线程池内的资源来进行访问,哪怕是redis访问的很慢,有等待和超时,也不要紧,只有少量额线程资源用来访问,缓存服务不会被拖垮

解决方案

引入Hystrix 保护redis

1、引入Hystrix依赖

<dependency>
    <groupId>com.netflix.hystrix</groupId>
    <artifactId>hystrix-core</artifactId>
    <version>1.5.18</version>
</dependency>
<dependency>
    <groupId>com.netflix.hystrix</groupId>
    <artifactId>hystrix-metrics-event-stream</artifactId>
    <version>1.5.18</version>
</dependency>

2、具体示例代码如下:

/**
 * 保存商品信息到redis缓存中
 *
 * @date 2018/06/12
 */
public class SaveProductInfo2RedisCacheCommand extends HystrixCommand<Boolean> {

    private ProductInfo productInfo;

    public SaveProductInfo2RedisCacheCommand(ProductInfo productInfo) {

        super(HystrixCommandGroupKey.Factory.asKey("RedisGroup"));
        this.productInfo = productInfo;
    }


    @Override
    protected Boolean run() {
        StringRedisTemplate redisTemplate = SpringContext.getApplicationContext().getBean(StringRedisTemplate.class);

        String key = "product_info_" + productInfo.getId();
        redisTemplate.opsForValue().set(key, JSON.toJSONString(productInfo));

        return true;
    }
}


/**
 * 将商品信息保存到redis中
 *
 * @param productInfo
 */
public void saveProductInfo2RedisCache(ProductInfo productInfo) {
    SaveProductInfo2RedisCacheCommand command = new SaveProductInfo2RedisCacheCommand(productInfo);
    command.execute();
}



public class GetProductInfoCommand extends HystrixCommand<ProductInfo> {


    private Long productId;

    public GetProductInfoCommand(Long productId) {
        super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("ProductInfoService"))
                .andThreadPoolKey(HystrixThreadPoolKey.Factory.asKey("GetProductInfoPool"))
                .andThreadPoolPropertiesDefaults(HystrixThreadPoolProperties.Setter()
                        .withCoreSize(10)
                        .withMaxQueueSize(12)
                        .withQueueSizeRejectionThreshold(8)
                        .withMaximumSize(30)
                        .withAllowMaximumSizeToDivergeFromCoreSize(true)
                        .withKeepAliveTimeMinutes(1)
                        .withMaxQueueSize(50)
                        .withQueueSizeRejectionThreshold(100))
                .andCommandPropertiesDefaults(HystrixCommandProperties.Setter()
                        // 多少个请求以上才会判断断路器是否需要开启。
                        .withCircuitBreakerRequestVolumeThreshold(30)
                        // 错误的请求达到40%的时候就开始断路。
                        .withCircuitBreakerErrorThresholdPercentage(40)
                        // 3秒以后尝试恢复
                        .withCircuitBreakerSleepWindowInMilliseconds(4000))
        );
        this.productId = productId;
    }

    @Override
    protected ProductInfo run() throws Exception {
        String productInfoJSON = "{\"id\": " + productId + ", \"name\": \"iphone7手机\", \"price\": 5599, \"pictureList\":\"a.jpg,b.jpg\", \"specification\": \"iphone7的规格\", \"service\": \"iphone7的售后服务\", \"color\": \"红色,白色,黑色\", \"size\": \"5.5\", \"shopId\": 1, \"modifiedTime\": \"2017-01-01 12:01:00\"}";
        return JSONObject.parseObject(productInfoJSON, ProductInfo.class);
    }
}

服务降级

高并发高负载情况下,选择动态的关闭一些不重要的服务拒绝访问,比如推荐、留言等不太重要的服务

总结

上面是常用的高并发系统设计考虑的方面,尤其是缓存中的解决方案,没有哪一个是最优的,适合自己的业务场景才是最好的

 

参考文章

https://blog.csdn.net/kevin_love_it/article/details/88095271

https://blog.csdn.net/zeb_perfect/article/details/54135506

https://blog.csdn.net/xlgen157387/article/details/79530877

http://www.saily.top/2018/06/12/cache06/

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/ywlmsm1224811/article/details/102542862

智能推荐

http 500内部服务器错误-程序员宅基地

文章浏览阅读2.7k次。http 500内部服务器错误--------------------------------------------------------------------------------一.错误表现 iis5的http 500内部服务器错误是我们经常碰到的错误之一,它的主要错误表现就是asp程序不能浏览但htm静态网页不受影响。另外当错误发生时,系统事件日志和安全事件日志都会有..._http500

Oracle Temp临时表空间及其故障处理-程序员宅基地

文章浏览阅读575次。Oracle Temp临时表空间及其故障处理 Oracle 11g中Temp临时表空间、文件的新特性 临时表空间是Oracle体系结构中比较特殊的结构。通常情境下,数据库..._ora临时表空间报错,无法打开文件

BurpSuite抓取App包,详细教程_burp抓取手机app数据包-程序员宅基地

文章浏览阅读5.4k次,点赞5次,收藏27次。进入burpsuite工具,配置代理,端口与夜神模拟器保持一致,地址选择指定地址,输入本机IPV4地址;打开burp suite工具进入Proxy,如图,点击open browser。代理选择手动,代理服务器主机名输入本机的IPV4地址(使用。查询),代理服务器端口随便输入,只要不冲突即可;输入证书名,确定,安装成功!_burp抓取手机app数据包

JAVA学习:JAVA中一些常用的方法和使用技巧_java的妙用方法-程序员宅基地

文章浏览阅读401次。目录一、修改数据结构中的Compare二、向二维数组中快速填充同一个元素:三、StringBuilder的常用方法:四、Math的常用方法:参考链接菜鸟教程五、字符串六、容器类一、修改数据结构中的CompareComparable和Comparator的区别:[https://blog.csdn.net/qq_37768971/article/detai..._java的妙用方法

【特征工程】(一)数据集中缺失值的处理_特征工程 空值处理-程序员宅基地

文章浏览阅读9k次,点赞7次,收藏53次。目录引言一、可选处理方法二、Python中Pandas库处理缺失值1.查看数据缺失值得分布情况2.删除包含缺失值的数据 2.1. 删除包含缺失值的行或列 2.2. 根据条件删除包含缺失值的数据三、Python中其他库处理缺失值四、缺失值处理案例(一)----疝气病数据集预处理1.处理缺失值,以便使用分类算法引言 数据中的缺失值是..._特征工程 空值处理

Log4j.properties配置详解-程序员宅基地

文章浏览阅读2.8w次,点赞16次,收藏92次。1 入门示例1.1 新建一个Java工程,导入包log4j-1.2.17.jar,整个工程最终目录如下1.2 src同级创建并设置log4j.properties### 设置###log4j.rootLogger = debug,stdout,D,E### 输出信息到控制抬 ###log4j.appender.stdout = org.apache.log4j.Conso..._log4j.properties

随便推点

python能制作游戏吗_python制作galgame引擎(一)-程序员宅基地

文章浏览阅读654次。写这个项目的直接原因是最近推galgame推得有点过头,gal推过头的直接结果就是YY能力上涨,抱着“我也想写好玩的剧本”的轻率念头,也就开始了这个项目。不过从直接感觉来说,galgame毕竟也是开发成本(个人)以及技术要求最低的游戏类别之一,这当然也算是原因。于是到了现在,一个半成品式的框架就搭好了。实话实说,gal引擎开发,技术难度不算大。但是,需要考虑的方面却相当多,许多看起来很简单的东西开..._galgame的代码很难写吗

高效开发iOS系列 -- 为Xcode添加删除行、复制行快捷键_ios 修改复制粘贴快捷键-程序员宅基地

文章浏览阅读1.4w次,点赞9次,收藏11次。在使用eclipse过程中,特喜欢删除一行和复制一行的的快捷键。而恰巧Xcode不支持这两个快捷键,再一次的恰巧让笔者发现了一个小窍门来增加这两个快捷键,以下是步骤: 修改权限 修改Xcode里快捷键的配置文件(plist)权限,打开终端输入如下两条命令:sudo chmod 666 /Applications/Xcode.app/Contents/Frameworks/IDEKit.fram_ios 修改复制粘贴快捷键

GCC各种调试工具使用简介-程序员宅基地

文章浏览阅读1.7k次。http://blog.sina.com.cn/s/blog_6b94d5680101p7fm.htmlGCC:GNU开发的程序编译器 GNU:“GNU‘s Not Unix”,最初是为了实现一个类似unix的自由操作系统,感觉现在已经通常泛指遵循GPL自由软件精神的组织。GPL:GNU通用公共许可证(GNU General Public License),简单的说就是遵循GPL的

python+selenium简单操作高德地图路线规划_selenium地图-程序员宅基地

文章浏览阅读716次,点赞2次,收藏2次。1、导入selenium库,第三方库要下载pip install selenium_selenium地图

Android 非常简单的实现 Fragment状态栏一体化布局,状态栏字体的颜色改变,_android immersionbar设置黑色字体-程序员宅基地

文章浏览阅读3.4k次。做一个点上的项目的时候遇到一个问题,就是商城首页的布局要覆盖状态栏,可以这么理解,上图:就像京东的首页一样的效果我的项目是 MainActivity + 四个Fragment,第一步:需要把状态栏的背景色变为透明色,这个网上很多博客都写过,我就不写了第二部:我先说一下这个属性fitSystemWindows,因为不管是做状态栏变色和一体化布局,都相关这个属性_android immersionbar设置黑色字体

oracle 传递table,ORACLE 自定义TABLE类型,C#怎么传递值-程序员宅基地

文章浏览阅读137次。该楼层疑似违规已被系统折叠隐藏此楼查看此楼CREATE OR REPLACE TYPE IM_CHECKLIST_PARAM IS OBJECT(ID RAW(16),SCORE INTEGER,DESCRIPTION NVARCHAR2(300),FOLLOWPLAN NVARCHAR2(300));CREATE OR REPLACE TYPE IM_CHECKLIST_T..._oracle自定义数据类型的数据怎么传到procedure c#