高并发系统之限流特技

目录

限流算法

计算器

滑动窗口

令牌桶

漏桶算法

接入层限流

ngx_http_limit_conn_module

使用简介

limit_conn 

limit_conn_zone 

limit_conn 的执行流程

ngx_http_limit_req_module

limit_req

执行流程:

分布式限流

redis+lua限流

nginx+lua

应用级限流

限制接口并发

时间窗口限流

平滑接口限流


高并发的解决策略有很多,可以采用缓存、降级、限流等方法。但是有的时候需要用到限流,来保持我们系统的稳定性和可用性

比如像一些核心的服务:秒杀抢购,一些同步写的服务;还有一些比较耗时的操作,比如下载资源、上传文件等。限流的目的是对我们客户端的访问速率进行限制,保护我们的系统在可以承受的吞吐量范围内对外提供持续的服务,一但达到了访问速率就拒绝服务,返回兜底数据或兜底页面等,如商品详情页直接返回有库存。

一般限流方式有:

  1. 限制总并发数:数据库连接池、线程池;
  2. 限制瞬时并发:nignx的limit_conn模块,限制时间窗口内平均速率:guava的RateLimiter、nginx的limit_req模块,还有限制远程接口调用频率、限制mq的消费速率。
  3. 除了根据请求数量或者请求速率限流,还可以根据系统资源限流。比如内存资源、网络;连接数、cpu使用等来进行限制。

在我们实际使用的过程中,不管是使用什么方法,只要能满足当前系统要求,满足公司资源都是可行的。下面将从限流算法、接入层限流、分布式限流、应用级限流四个模块阐述限流的使用。

限流算法

计算器

简单粗暴的限流方式,适合一些粗粒度的限流场景,只需限制总的并发数,对速率没有要求,即可采用这种方式。对总数限流可以采用计数器、信号量等方式来实现。

在代码实现时可以通过自定义注解和aop拦截需要执行的业务,对我们需要限流的业务进行限流,也可以通过结合redis来实现全局限流。

滑动窗口

计算器可以实现对总数的限制,但是不能实现对速率的控制。滑动窗口算法就是为了控制时间窗口内的总数,tcp中大量使用了滑动窗口的算法,像慢启动窗口、滑动窗口协议等。

我们定义每分钟最多接受4组数据,如下是一个时间窗口。

随着计数窗口的滑动,又可以接受新的数据;

但是可能出现下面的情况,在59秒前都没有数据,到了59秒的时候一下发来了4个数据分组。虽然在一分钟内的速率没有变,但是瞬时速率却放大了几十倍。

令牌桶

令牌桶算法是在一个存放固定容量的桶中按照固定速率添加令牌

  • 限制4r/s(250ms添加一个)添加令牌;
  • 桶中最多存放x个,当桶中容量满时将丢弃令牌;
  • 当n个请求到达时,桶中释放n个令牌;
  • 当令牌数量不足时,将会被限流或者放入缓冲队列中。

漏桶算法

漏桶算法是在固定容量桶中按照不定速率流入,固定速率流出。漏桶算法可以用于流量整形和流量控制。

  • 容量固定,固定速率流出;
  • 任意速率流入;
  • 超出容量溢出,容量为空不流出;

使用场景:

中国的IT基础设施领先于全球各个国家,各大银行和第三方钱包也被各电商双十一等大促场景狂虐之后进化到支持极高的TPS,但是在跨境场景下,比如东南亚或南美的国家,他们的银行IT基础设施差,系统老旧,无法支持高并发流量。甚至碰到过一些银行要求退款只能有1TPS。

在分布式场景下,要做到1TPS的高精度限流,只能依赖漏桶来做。

接入层限流

接入层限流指的是在流量请求入口做限制,通常有负载均衡、非法请求过滤、缓存、请求聚合、热点缓存查询、服务质量监控等。

可以用nignx做接入层,ningx中有连接限流模块ngx_http_limit_conn_module和漏桶算法实现的请求限流模块ngx_http_limit_req_module模块,通过配置这两个模块来实现。

在更加复杂的限流场景,还可以通过OpenResty的Lua限流模块lua-resty-limit-traffic来实现。

ngx_http_limit_conn_module用来对单个key对应的总的网络连接数做限流,可以按照ip、域名等进行限流。

ngx_http_limit_req_module是对key请求的平均速率进行限流,对于不同的场景可以有选择地采用平滑模式和突发模式。

ngx_http_limit_conn_module

使用简介

ngx_http_limit_conn_module 模块可以按照定义的键限定每个键值的连接数。特别的可以设定单一 IP 来源的连接数。

并不是所有的连接都会被模块计数;只有那些正在被处理的请求(这些请求的头信息已被完全读入)所在的连接才会被计数。

limit_conn 

语法:    limit_conn zone number;
默认值:    —
上下文:    http, server, location

指定一块已经设定的共享内存空间,以及每个给定键值的最大连接数。当连接数超过最大连接数时,服务器将会返回 503 (Service Temporarily Unavailable) 错误。比如,如下配置 

limit_conn_zone $binary_remote_addr zone=addr:10m;

server {
    location /download/ {
        limit_conn addr 1;
    }

表示,同一 IP 同一时间只允许有一个连接。 

limit_conn_zone 

语法:    limit_conn_zone $variable zone=name:size;
默认值:    —
上下文:    http

设定保存各个键的状态的共享内存空间的参数。键的状态中保存了当前连接数。键的值可以是特定变量的任何非空值(空值将不会被考虑)。 使用范例:

limit_conn_zone $binary_remote_addr zone=addr:10m;

这里,设置客户端的IP地址作为键。注意,这里使用的是$binary_remote_addr变量,而不是$remote_addr变量。$remote_addr变量的长度为7字节到15字节不等,而存储状态在32位平台中占用32字节或64字节,在64位平台中占用64字节。而$binary_remote_addr变量的长度是固定的4字节,存储状态在32位平台中占用32字节或64字节,在64位平台中占用64字节。一兆字节的共享内存空间可以保存3.2万个32位的状态,1.6万个64位的状态。如果共享内存空间被耗尽,服务器将会对后续所有的请求返回 503 (Service Temporarily Unavailable) 错误。 

limit_conn 的执行流程

  • 判断是否超出limit_conn_zone 配置的最大连接数;
  • 超过最大数量返回503状态,否则响应的key加1,并注册请求处理的回调函数;
  • 请求处理;
  • 调用回调函数对应的key连接数减1;

ngx_http_limit_req_module

ngx_http_limit_req_module模块可以通过定义的键值来限制请求处理的频率。特别的,它可以限制来自单个IP地址的请求处理频率。 限制的方法是通过一种“漏桶”的方法——固定每秒处理的请求数,推迟过多的请求处理。

limit_req

句法:    limit_req zone=name [burst=number] [nodelay | delay=number];
默认:    -
语境:    http,server,location

设置对应的共享内存限制域和允许被处理的最大请求数阈值。 如果请求的频率超过了限制域配置的值,请求处理会被延迟,所以 所有的请求都是以定义的频率被处理的。 超过频率限制的请求会被延迟,直到被延迟的请求数超过了定义的阈值 这时,这个请求会被终止,并返回503 (Service Temporarily Unavailable) 错误。这个阈值的默认值等于0。

配置示例:

http {
    limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;

    ...

    server {

        ...

        location /search/ {
            limit_req zone=one burst=5;
        }

限制平均每秒不超过一个请求,同时允许超过频率限制的请求数不多于5个。

如果不希望超过的请求被延迟,可以用nodelay参数:

limit_req zone=one burst=5 nodelay;

limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;

这里,状态被存在名为“one”,最大10M字节的共享内存里面。对于这个限制域来说 平均处理的请求频率不能超过每秒一次。

键值是客户端的IP地址。 如果不使用$remote_addr变量,而用$binary_remote_addr变量, 可以将每条状态记录的大小减少到64个字节,这样1M的内存可以保存大约1万6千个64字节的记录。 如果限制域的存储空间耗尽了,对于后续所有请求,服务器都会返回 503 (Service Temporarily Unavailable)错误。

请求频率可以设置为每秒几次(r/s)。如果请求的频率不到每秒一次, 你可以设置每分钟几次(r/m)。比如每秒半次就是30r/m。

执行流程:

(1)请求进入判断最后一次请求时间相对于当前时间(第一次为0)是否需要限流,如果需要,执行步骤2,否则执行步骤3;

(2)没有配置burst则容量为0,按照固定速率处理请求。如果被限流返回503;

         如果burst>0&&延迟模式(没配置nodelay)。如果桶满了则新请求限流,否则按照固定速率处理(延迟使用休眠处理);

         如果burst<0&&配置了nodelay,不会按照固定速率处理,允许突发模式处理请求。桶满了则限流,返回503错误码;

(3)请求处理;

   (4)  ningx会在相应时机选择一些(3个节点)限流key进行过期处理,回收内存。

分布式限流

分布式限流将服务做成原子化,对我们各个服务进行统一的限流。

实现方式主要有redis+lua脚本或者nginx+lua脚本实现。

Redis+Lua限流

通过redis的单线程模式保证在lua脚本写并发安全。

lua脚本:

local key = "rate.limit:" .. KEYS[1] --限流KEY
local limit = tonumber(ARGV[1])        --限流大小
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then --如果超出限流大小
  return 0
else  --请求数+1,并设置2秒过期
   redis.call("INCRBY", key,"1")
   redis.call("expire", key,"2")
   return current + 1
end

java代码:

   @Bean
    public DefaultRedisScript<Number> redisLuaScript() {
        DefaultRedisScript<Number> redisScript = new DefaultRedisScript<>();
        //读取 lua 脚本
        redisScript.setScriptSource(new ResourceScriptSource(new         
        ClassPathResource("limit.lua")));
        redisScript.setResultType(Number.class);
        return redisScript;
}

    @Autowired
    private RedisTemplate<String, Serializable> limitRedisTemplate;

        pubLlic boolean acquire() {
    
            HttpServletRequest request = ((ServletRequestAttributes)             
            RequestContextHolder.getRequestAttributes()).getRequest();
            String ipAddress = getIpAddr(request);
 
            StringBuffer stringBuffer = new StringBuffer();
            stringBuffer.append(ipAddress).append("-")
                    .append(targetClass.getName()).append("- ")
                    .append(method.getName()).append("-")
                    .append(rateLimit.key());
 
            List<String> keys = Collections.singletonList(stringBuffer.toString());
 
            Number number = limitRedisTemplate.execute(redisluaScript, keys, rateLimit.count(), rateLimit.time());
 
            if (number != null && number.intValue() != 0 && number.intValue() <= rateLimit.count()) {
                logger.info("限流时间段内访问第:{} 次", number.toString());
                return false;
            }
           reruen true;

}

nginx+lua

lua脚本:

local locks = require "resty.lock"

local function acquire()
    local lock =locks:new("locks")
    local elapsed, err =lock:lock("limit_key") --互斥锁
    local limit_counter =ngx.shared.limit_counter --计数器

    local key = "ip:" ..os.time()
    local limit = 5 --限流大小
    local current =limit_counter:get(key)

    if current ~= nil and current + 1> limit then --如果超出限流大小
       lock:unlock()
       return 0
    end
    if current == nil then
       limit_counter:set(key, 1, 1) --第一次需要设置过期时间,设置key的值为1,过期时间为1秒
    else
        limit_counter:incr(key, 1) --第二次开始加1即可
    end
    lock:unlock()
    return 1
end
ngx.print(acquire())

使用lua-resty-lock互斥锁来解决原子性问题,并使用ngx.shared.dict共享字典来实现计数器。如果限流则返回0,否则返回1.

nginx 定义两个共享字典(存放锁和计数器):

http {

    ……
    lua_shared_dict locks 10m;
    lua_shared_dict limit_counter 10m;

}

流量过大时reids和nginx可能扛不住,这是我们可以基础方案进行优化。可以通过一致性hash算法将分布式限流进行分片,比如在redis限流时我们可以设计key尽量落在redis集群中不同节点中,这样在执行的时候就可以减轻单台服务器的压力和减少并发。还有就是可以在并发量过大时进行降级,降级为应用级限流等。根据具体的使用场景,具体做优化调整。

应用级限流

对于一个应用系统,由于服务器资源的限制允许的并发是有限的,我们需要对系统的TPS/QPS做一定的限制,防止大流量突然涌入击垮系统。可以通过服务器的配置来限制连接数、线程池大小等。比如tomcat服务,可以通过acceptCount限制连接数,maxConnection允许的瞬时最大连接数,maxThreads请求处理最大线程数。

对于一些稀缺资源,也可以进行一些限制。比如我们核心业务线程池的大小,数据库连接池大小限制等。

限制接口并发

通过细粒度的编程可以实现对我们高并发接口的限流,必须一些秒杀抢购接口。可以对不同接口设置不同阈值,可以原子变量或者信号量来实现;

伪代码如下:

try{
    if(num.incrementAndGet() > 限制){
         //拒绝请求
    }
} finally{
       num.decrementAndGet();
}

这是一种简单粗暴的限流方式,只能简单得做到总访问量的限制,无法限制请求速率,根据实际情况使用。

时间窗口限流

一个服务能处理很多请求,但是不可能在很短的时间处理很多请求。所在需要根据应用场景对每秒、每分钟、每小时做请求限制,限制在时间窗口内请求数量。

LoadingCache<String, String> counter = CacheBuilder.newBuilder()
                .maximumSize(100) //最大缓存数目
                .expireAfterAccess(2, TimeUnit.SECONDS) //缓存2秒后过期
                .build(new CacheLoader<String, String>() {
                    @Override
                    public AtomicLong load(Long seconds) throws Exception {
                        return new AtomicLong(0);
 
                    }
                });

long limit = 1000;
while(ture) {
    //得到当前秒
    long currentSecond = System.currentTimeMillis() / 1000;
    if(counter.get(currentSecond).incrementAndGet() > limit) {
    //限流;
    continue;
    }
    //业务处理
}

通过Guava的cache做限流存储计数器,过期时间为2秒,获取当前时间戳,取秒数作为key进行计数和限流。这种方式也很简单粗暴,如果应用场景合适也是一种不错的选择。

平滑接口限流

前面的限流都不能很好地平滑我们请求速率,瞬时并发过高一样会导致我们的应用服务宕机。为了因对突发流量和对出入口的流量整形,我们需要使用到前面讲到的令牌桶和楼桶算法。Guava框架已经帮我们将轮子造好,可以直接拿来使用。

Guava的RateLimiter提供了临牌桶算法可用于平滑突发流量(SmoothBursty)和平滑预热限流模式(SmoothSmoothWarningUp)。

举例来说明如何使用RateLimiter,想象下我们需要处理一个任务列表,但我们不希望每秒的任务提交超过10个:

//速率是每秒10个许可
final RateLimiter rateLimiter = RateLimiter.create(10);

void submitTasks(List tasks, Executor executor) {
    for (Runnable task : tasks) {
        rateLimiter.acquire(); // 也许需要等待
        executor.execute(task);
    }
}

acquire() 会阻塞当前线程直到许可证可用后获取该许可证。一旦获取到许可证,不需要再释放许可证。RateLimiter.create(10)表示每100ms向桶中存放一个临牌。acquire(10) 表示可以一次性获取临牌桶所有临牌,那么在下次请求获取令牌时将会被阻塞直到获取到临牌。

SmoothSmoothWarningUp

RateLimiter.create(double permitsPerSecond, long warmupPeriod, TimeUnit unit);

permitsPerSecond:每秒新增临牌数;

warmupPeriod:冷启动过渡到平均速率时间;

预热模式和tcp的慢启动有点类似,控制前期的速率,然后慢慢达到系统能承受的正常速率。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

知始行末

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值