REST API 并发控制

对请求做并发限制是在后端处理能力有限的情况下,防止因某单一用户大量请求将服务器资源暂满导致无法响应其他用户请求的安全保护措施。

常见的并发限制方法就是限制用户在某一段时间内的请求数目,如100r/s,即限制用户1s内最多请求100次,超过就拒绝访问。

下面介绍几种常见并发控制的实现方法。

1. memcache/redis

memcache和redis等nosql的提供了以下两个特性,可以很好的支持对请求进行并发限制:

  • key的原子累加(incr),用于累计请求;
  • key可设置存活时间,用户设置请求限制的时效性;

    以下用PHP实现的示例

 <?php
      /**
        *  $key 需要做并发限制的key,可以是客户端的ip地址等
        *  $qpsInterval 并发周期,如100r/s中的1s
        *  $qpsValue 并发限制值,如100r/s中的100
        */

        // redis连接
        $objDaoRedis = new Dao_Redis();
        $res = $objDaoRedis->get($key);

        //如果没有存在,则设置key,
        if (! isset($res)) {
            $objDaoRedis->set($key, 1);
            //如果设置有效时间失败,删除key,不然会存在一个无失效时间的key, value一致增加会导致一直提示 并发超限
            if (! $objDaoRedis->expire($key, $qpsInterval) ) {
                $objDaoRedis->delete($key);
            }
        } else if (intval($res) > $qpsValue) {
            throw new Exception('访问频率过快!', 1);
        } else {
            $objDaoRedis->incr($key);
        }

2. nginx limit_req

nginx作为高性能的反向代理,其本身的limit_req_zone模块也很好的支持了HTTP连接请求的并发控制。
limit_req_zone采用的是漏桶(leaky bucket)原理实现,即请求不断落入到一个漏桶中,桶的容积为(rate + burst),请求以一定的速率rate流出桶(被处理),当桶满时,不再接受处理新的请求,此时nginx直接返回503(服务暂时不可访问)。

使用语法

##以下两个搭配使用
Syntax: limit_req_zone key zone=name:size rate=rate;
Default:Context:    http


Syntax: limit_req zone=name [burst=number] [nodelay];
Default:Context:    http, server, location

以上语法参数中,

  • rate为nginx限制的请求处理速度,即nginx会以rate的值的速率来处理请求;
  • 无设置nodelay情况下, rate和(rate+burst)之间的请求被延迟处理,超过了(rate + burst)的请求直接拒绝,返回503;
  • 设置nodelay情况下,rate和(rate+burst)之间的请求不会被延迟处理,超过了(rate + burst)的请求同样直接拒绝,返回503;

以下是对客户端请求ip地址做并发限制的nginx.conf配置示例:

http{
    ...   ##省略其他配置项

    ## 对客户端二进制ip地址做访问限制,设置限制变量qps_zone,内存大小为10m,限制处理速率为每秒20个
    ## 
    limit_req_zone $binary_remote_addr zone=qps_zone:10m rate=20r/s;

    server {
        listen 8080;
        server_name localhost;

        location / {
            limit_req zone=qps_zone burst=10;
            echo "ok!";
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        } 

    }


}

nginx limit_req方法可以利用nginx本身的模块支持,相比较于方法1中的利用redis做计数,有以下优势:

  • nginx直接在内存中做计数,避免了网络交互的时间消耗,缩短了响应时间;
  • 方法2中,当并发请求过大时,响应处理PHP进程数可能会遇到瓶颈,而nginx并发处理能力在实际生产环境中轻松上万+;

此种方法的不足是,并发请求限制的key和限制的峰值必须提前在配置文件中定义好,nginx reload后才会生效。 对于某些场景中,需要动态调整key和并发限制的峰值,只能通过动态修改配置文件里面对应的值并reload nginx才能实现,不太方便;

3. lua-resty-limit-traffic

简介

lua-resty-limit-traffic是开源框架openresty(nginx+lua web平台)中的请求并发限制模块。该模块使用共享内存来存储访问次数以及最近访问的毫秒时间戳,在每次请求真正转发到后端处理程序之前(nginx的access阶段),判断是否超过设定的流量限制并做相应的处理。

使用demo

lua-resty-limit-traffic 使用nginx.conf的配置demo如下:

http {
    #定义lua共享内存变量和存储空间大小
    lua_shared_dict my_limit_req_store 100m;

    server {
        location / {
            #请求并发处理要放在nginx的access处理阶段
            access_by_lua_block {
                --  1. 引用openresty的流量限制模块
                local limit_req = require "resty.limit.req"

                --  2. 传入共享内存变量,获取rate为200,burst为100限制的流量限制object    
                local lim, err = limit_req.new("my_limit_req_store", 200, 100)
                if not lim then
                    ngx.log(ngx.ERR,
                            "failed to instantiate a resty.limit.req object: ", err)
                    return ngx.exit(500)
                end

                -- 3. 在每次请求时,获取请求中的二进制客户端ip地址作为流量限制的key
                local key = ngx.var.binary_remote_addr
                -- 对key进行流量限制,参数中的true代表要存储每次的计数和毫秒级时间戳
                local delay, err = lim:incoming(key, true)
                if not delay then
                    if err == "rejected" then
                        return ngx.exit(503)
                    end
                    ngx.log(ngx.ERR, "failed to limit req: ", err)
                    return ngx.exit(500)
                end
            }
        }
    }
}

实现原理

可以看到,以上在配置的最开始部分定义了lua共享内存变量,这个变量在lua-resty-limit-traffic 的核心文件req.lua中被转化为以下结构变量

ffi.cdef[[
    struct lua_resty_limit_req_rec {
        unsigned long        excess;
        uint64_t             last;  /* time in milliseconds */
        /* integer value, 1 corresponds to 0.001 r/s */
    };
]]

该C结构体中,excess为当前时间没有处理完的请求数目,last为当前的毫秒时间戳(即频率控制的时间粒度为毫秒)。
当执行limit_req.new(“my_limit_req_store”, 200, 100)时,对应的操作为:

 local dict = ngx_shared[dict_name]
    if not dict then
        return nil, "shared dict not found"
    end

    assert(rate > 0 and burst >= 0)

    local self = {
        dict = dict,
        rate = rate * 1000,
        burst = burst * 1000,
    }
  • 获取共享内存中dict_name的变量;
  • 考虑到时间戳设置为毫秒粒度,因此rate和burst都需要乘以1000;

    最后,也是lua-resty-limit-traffic 流量限制的关键函数lim:incoming(key, true),其实现源码如下:

function _M.incoming(self, key, commit)
    local dict = self.dict
    local rate = self.rate
    -- ngx.now()获取当前时间戳,乘以1000后为毫秒时间戳
    local now = ngx_now() * 1000

    local excess

    -- 获取当前请求流量限制key对应的在共享内存中对应的结构体(excess, last)字符串
    -- 在上述配置中,key为请求的二进制的客户端请求ip地址
    local v = dict:get(key)
    if v then
        if type(v) ~= "string" or #v ~= rec_size then
            return nil, "shdict abused by other users"
        end
        local rec = ffi_cast(const_rec_ptr_type, v)

        -- 获取当前到上次记录时间的毫秒时间差
        local elapsed = now - tonumber(rec.last)

       -- tonumber(rec.excess): 上次没有处理完的请求数据
       --  rate * abs(elapsed) / 1000 : 该段时间内以rate速率处理的请求数目
       -- 1000: 本次请求数目(以毫秒时间戳计算 1 * 1000)
        excess = tonumber(rec.excess) - rate * abs(elapsed) / 1000 + 1000

        -- 如果excess(即没有处理的请求数目)小于0,即没有达到流量限制,将excess设置为0;
        if excess < 0 then
            -- ngx.log(ngx.WARN, "excess: ", excess / 1000)
            excess = 0
        end

       -- 如果excess超过了burst值,即超过了并发限制,拒绝请求
       if excess > self.burst then
            return nil, "rejected"
        end

    else
        excess = 0
    end

    -- 如果commit为true 将处理结果值:excess和当前处理毫秒时间戳存储在内存变量中;
    if commit then
        rec_cdata.excess = excess
        rec_cdata.last = now
        dict:set(key, ffi_str(rec_cdata, rec_size))
    end

    -- 返回延迟处理时间和超过处理能力的请求数目
    return excess / rate, excess / 1000
end

如上, 通过在共享内存中存储未处理请求数和上次请求处理的毫秒时间戳来计算判断当前请求是否满足流量限制的条件。
由于nginx是一个master多个worker的工作模式,在多个worker中都运行上述流量控制模块,因此可能会出现在某个worker get dict后set前,其他worker set了dict对应计数值,导致流量限制的窗口偏差(一般窗口偏差值为 worker的数目 - 1)。

改进参考

上面提到的在多worker运行时会出现流量限制的窗口误差在多nginx服务部署机器上同样存在。
在多个服务器上的nginx中部署流量控制模块,会导致每个机器上都设置有rate的处理能力限制,如此以来会因为流量负载的不均衡,导致某个机器先出现并发限制的短版而实现上整体并未达到总的流量限制值。
考虑到此种情况,建议在多nginx服务部署时,使用统一缓存如redis来存储计数,这也是开源api网关mashape kong采用的方式(lua + cassandra)。


版权声明:本文为datiancai2008原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。