分佈式接口冪等性、分佈式限流(Guava 、nginx和lua限流)

一、接口冪等性

  接口冪等性就是用戶對於同一操作發起的一次請求或者多次請求的結果是一致的,不會因爲多次點擊而產生了副作用。舉個最簡單的例子,那就是支付,用戶購買商品後支付,支付扣款成功,但是返回結果的時候網絡異常,此時錢已經扣了,用戶再次點擊按鈕,此時會進行第二次扣款,返回結果成功,用戶查詢餘額返發現多扣錢了,流水記錄也變成了兩條,這就沒有保證接口的冪等性。
  冪等性的核心思想:通過唯一的業務單號保障冪等性,非併發的情況下,查詢業務單號有沒有操作過,沒有則執行操作,併發情況下,這個操作過程需要加鎖。

1、Update操作的冪等性

1)根據唯一業務號去更新數據

  通過版本號的方式,來控制update的操作的冪等性,用戶查詢出要修改的數據,系統將數據返回給頁面,將數據版本號放入隱藏域,用戶修改數據,點擊提交,將版本號一同提交給後臺,後臺使用版本號作爲更新條件

update set version = version +1 ,xxx=${xxx} where id =xxx and version = ${version};

2、使用Token機制,保證update、insert操作的冪等性

1)沒有唯一業務號的update與insert操作

  進入到註冊頁時,後臺統一生成Token, 返回前臺隱藏域中,用戶在頁面點擊提交時,將Token一同傳入後臺,使用Token獲取分佈式鎖,完成Insert操作,執行成功後,不釋放鎖,等待過期自動釋放。

二、分佈式限流

1、分佈式限流的幾種維度

  • 時間 限流基於某段時間範圍或者某個時間點,也就是我們常說的“時間窗口”,比如對每分鐘、每秒鐘的時間窗口做限定
  • 資源 基於可用資源的限制,比如設定最大訪問次數,或最高可用連接數

  上面兩個維度結合起來看,限流就是在某個時間窗口對資源訪問做限制,比如設定每秒最多100個訪問請求。但在真正的場景裏,我們不止設置一種限流規則,而是會設置多個限流規則共同作用,主要的幾種限流規則如下:

1)QPS和連接數控制

  針對上圖中的連接數和QPS(query per second)限流來說,我們可以設定IP維度的限流,也可以設置基於單個服務器的限流。在真實環境中通常會設置多個維度的限流規則,比如設定同一個IP每秒訪問頻率小於10,連接數小於5,再設定每臺機器QPS最高1000,連接數最大保持200。更進一步,我們可以把某個服務器組或整個機房的服務器當做一個整體,設置更high-level的限流規則,這些所有限流規則都會共同作用於流量控制。

2)傳輸速率

  對於“傳輸速率”大家都不會陌生,比如資源的下載速度。有的網站在這方面的限流邏輯做的更細緻,比如普通註冊用戶下載速度爲100k/s,購買會員後是10M/s,這背後就是基於用戶組或者用戶標籤的限流邏輯。

3)黑白名單

  黑白名單是各個大型企業應用裏很常見的限流和放行手段,而且黑白名單往往是動態變化的。舉個例子,如果某個IP在一段時間的訪問次數過於頻繁,被系統識別爲機器人用戶或流量攻擊,那麼這個IP就會被加入到黑名單,從而限制其對系統資源的訪問,這就是我們俗稱的“封IP”。
  我們平時見到的爬蟲程序,比如說爬知乎上的美女圖片,或者爬券商系統的股票分時信息,這類爬蟲程序都必須實現更換IP的功能,以防被加入黑名單。有時我們還會發現公司的網絡無法訪問12306這類大型公共網站,這也是因爲某些公司的出網IP是同一個地址,因此在訪問量過高的情況下,這個IP地址就被對方系統識別,進而被添加到了黑名單。使用家庭寬帶的同學們應該知道,大部分網絡運營商都會將用戶分配到不同出網IP段,或者時不時動態更換用戶的IP地址。
  白名單就更好理解了,相當於御賜金牌在身,可以自由穿梭在各種限流規則裏,暢行無阻。比如某些電商公司會將超大賣家的賬號加入白名單,因爲這類賣家往往有自己的一套運維繫統,需要對接公司的IT系統做大量的商品發佈、補貨等等操作。

4)分佈式環境

  所謂的分佈式限流,其實道理很簡單,一句話就可以解釋清楚。分佈式區別於單機限流的場景,它把整個分佈式環境中所有服務器當做一個整體來考量。比如說針對IP的限流,我們限制了1個IP每秒最多10個訪問,不管來自這個IP的請求落在了哪臺機器上,只要是訪問了集羣中的服務節點,那麼都會受到限流規則的制約。
  從上面的例子不難看出,我們必須將限流信息保存在一個“中心化”的組件上,這樣它就可以獲取到集羣中所有機器的訪問狀態,目前有兩個比較主流的限流方案:

  • 網關層限流
      將限流規則應用在所有流量的入口處
  • 中間件限流
      將限流信息存儲在分佈式環境中某個中間件裏(比如Redis緩存),每個組件都可以從這裏獲取到當前時刻的流量統計,從而決定是拒絕服務還是放行流量

2、限流方案常用算法講解

1)令牌桶算法

Token Bucket令牌桶算法是目前應用最爲廣泛的限流算法,顧名思義,它有以下兩個關鍵角色:

  • 令牌 獲取到令牌的Request纔會被處理,其他Requests要麼排隊要麼被直接丟棄
  • 桶 用來裝令牌的地方,所有Request都從這個桶裏面獲取令牌
    在這裏插入圖片描述

令牌生成
  這個流程涉及到令牌生成器和令牌桶,前面我們提到過令牌桶是一個裝令牌的地方,既然是個桶那麼必然有一個容量,也就是說令牌桶所能容納的令牌數量是一個固定的數值。
  對於令牌生成器來說,它會根據一個預定的速率向桶中添加令牌,比如我們可以配置讓它以每秒100個請求的速率發放令牌,或者每分鐘50個。注意這裏的發放速度是勻速,也就是說這50個令牌並非是在每個時間窗口剛開始的時候一次性發放,而是會在這個時間窗口內勻速發放。
  在令牌發放器就是一個水龍頭,假如在下面接水的桶子滿了,那麼自然這個水(令牌)就流到了外面。在令牌發放過程中也一樣,令牌桶的容量是有限的,如果當前已經放滿了額定容量的令牌,那麼新來的令牌就會被丟棄掉。

令牌獲取
  每個訪問請求到來後,必須獲取到一個令牌才能執行後面的邏輯。假如令牌的數量少,而訪問請求較多的情況下,一部分請求自然無法獲取到令牌,那麼這個時候我們可以設置一個“緩衝隊列”來暫存這些多餘的令牌。
  緩衝隊列其實是一個可選的選項,並不是所有應用了令牌桶算法的程序都會實現隊列。當有緩存隊列存在的情況下,那些暫時沒有獲取到令牌的請求將被放到這個隊列中排隊,直到新的令牌產生後,再從隊列頭部拿出一個請求來匹配令牌。
  當隊列已滿的情況下,這部分訪問請求將被丟棄。在實際應用中我們還可以給這個隊列加一系列的特效,比如設置隊列中請求的存活時間,或者將隊列改造爲PriorityQueue,根據某種優先級排序,而不是先進先出。算法是死的,人是活的,先進的生產力來自於不斷的創造,在技術領域尤其如此。

2)漏桶算法

Leaky Bucket
在這裏插入圖片描述

  漏桶算法的前半段和令牌桶類似,但是操作的對象不同,令牌桶是將令牌放入桶裏,而漏桶是將訪問請求的數據包放到桶裏。同樣的是,如果桶滿了,那麼後面新來的數據包將被丟棄。
  漏桶算法的後半程是有鮮明特色的,它永遠只會以一個恆定的速率將數據包從桶內流出。打個比方,如果我設置了漏桶可以存放100個數據包,然後流出速度是1s一個,那麼不管數據包以什麼速率流入桶裏,也不管桶裏有多少數據包,漏桶能保證這些數據包永遠以1s一個的恆定速度被處理。

漏桶 vs 令牌桶的區別
  根據它們各自的特點不難看出來,這兩種算法都有一個“恆定”的速率和“不定”的速率。令牌桶是以恆定速率創建令牌,但是訪問請求獲取令牌的速率“不定”,反正有多少令牌發多少,令牌沒了就乾等。而漏桶是以“恆定”的速率處理請求,但是這些請求流入桶的速率是“不定”的。
  從這兩個特點來說,漏桶的天然特性決定了它不會發生突發流量,就算每秒1000個請求到來,那麼它對後臺服務輸出的訪問速率永遠恆定。而令牌桶則不同,其特性可以“預存”一定量的令牌,因此在應對突發流量的時候可以在短時間消耗所有令牌,其突發流量處理效率會比漏桶高,但是導向後臺系統的壓力也會相應增多。

3、分佈式限流的主流方案

這裏主要講nginx和lua的限流,gateway和hystrix放在後面springcloud中講

1)Guava RateLimiter客戶端限流

  1. 引入maven
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>18.0</version>
</dependency>

2.編寫Controller

@RestController
@Slf4j
public class Controller{
    //每秒鐘可以創建兩個令牌
    RateLimiter limiter = RateLimiter.create(2.0);
    
    //非阻塞限流
    @GetMapping("/tryAcquire")
    public String tryAcquire(Integer count){
        //count 每次消耗的令牌
        if(limiter.tryAcquire(count)){
            log.info("成功,允許通過,速率爲{}",limiter.getRate());
            return "success";
        }else{
            log.info("錯誤,不允許通過,速率爲{}",limiter.getRate());
            return "fail";
        }
    }
    
    //限定時間的非阻塞限流
    @GetMapping("/tryAcquireWithTimeout")
    public String tryAcquireWithTimeout(Integer count, Integer timeout){
        //count 每次消耗的令牌  timeout 超時等待的時間
        if(limiter.tryAcquire(count,timeout,TimeUnit.SECONDS)){
            log.info("成功,允許通過,速率爲{}",limiter.getRate());
            return "success";
        }else{
            log.info("錯誤,不允許通過,速率爲{}",limiter.getRate());
            return "fail";
        }
    }
    
    //同步阻塞限流
    @GetMapping("/acquire")
    public String acquire(Integer count){
        limiter.acquire(count);
        log.info("成功,允許通過,速率爲{}",limiter.getRate());
        return "success";
    }
}

2)基於Nginx的限流

1.iP限流
  1. 編寫Controller
@RestController
@Slf4j
public class Controller{
    //nginx測試使用
    @GetMapping("/nginx")
    public String nginx(){
        log.info("Nginx success");
    }
}
  1. 修改host文件,添加一個網址域名
127.0.0.1   www.test.com

3.修改nginx,將步驟2中的域名,添加到路由規則當中
打開nginx的配置文件

vim /usr/local/nginx/conf/nginx.conf

添加一個服務

#根據IP地址限制速度
#1)$binary_remote_addr   binary_目的是縮寫內存佔用,remote_addr表示通過IP地址來限流
#2)zone=iplimit:20m   iplimit是一塊內存區域(記錄訪問頻率信息),20m是指這塊內存區域的大小
#3)rate=1r/s  每秒放行1個請求
limit_req_zone $binary_remote_addr zone=iplimit:20m rate=1r/s;

server{
    server_name www.test.com;
    location /access-limit/ {
        proxy_pass http://127.0.0.1:8080/;
        
        #基於ip地址的限制
        #1)zone=iplimit 引用limit_rep_zone中的zone變量
        #2)burst=2  設置一個大小爲2的緩衝區域,當大量請求到來,請求數量超過限流頻率時,將其放入緩衝區域
        #3)nodelay   緩衝區滿了以後,直接返回503異常
        limit_req zone=iplimit burst=2 nodelay;
    }
}

4.訪問地址,測試是否限流

www.test.com/access-limit/nginx
2.多維度限流

1.修改nginx配置

#根據IP地址限制速度
limit_req_zone $binary_remote_addr zone=iplimit:20m rate=10r/s;
#根據服務器級別做限流
limit_req_zone $server_name zone=serverlimit:10m rate=1r/s;
#根據ip地址的鏈接數量做限流
limit_conn_zone $binary_remote_addr zone=perip:20m;
#根據服務器的連接數做限流
limit_conn_zone $server_name zone=perserver:20m;


server{
    server_name www.test.com;
    location /access-limit/ {
        proxy_pass http://127.0.0.1:8080/;
        
        #基於ip地址的限制
        limit_req zone=iplimit burst=2 nodelay;
        #基於服務器級別做限流
        limit_req zone=serverlimit burst=2 nodelay;
        #基於ip地址的鏈接數量做限流  最多保持100個鏈接
        limit_conn zone=perip 100;
        #基於服務器的連接數做限流 最多保持100個鏈接
        limit_conn zone=perserver 1;
        #配置request的異常返回504(默認爲503)
        limit_req_status 504;
        limit_conn_status 504;
    }
    
     location /download/ {
        #前100m不限制速度
        limit_rate_affer 100m;
        #限制速度爲256k
        limit_rate 256k;
     }
}

3)基於Redis+Lua的分佈式限流

1.Lua腳本

  Lua是一個很小巧精緻的語言,它的誕生(1993年)甚至比JDK 1.0還要早。Lua是由標準的C語言編寫的,它的源碼部分不過2萬多行C代碼,甚至一個完整的Lua解釋器也就200k的大小。
  Lua往大了說是一個新的編程語言,往小了說就是一個腳本語言。對於有編程經驗的同學,拿到一個Lua腳本大體上就能把業務邏輯猜的八九不離十了。
Redis內置了Lua解釋器,執行過程保證原子性

2.Lua安裝

安裝Lua:

  1. 參考http://www.lua.org/ftp/教程,下載5.3.5_1版本,本地安裝
    如果你使用的是Mac,那建議用brew工具直接執行brew install lua就可以順利安裝,
    有關brew工具的安裝可以參考https://brew.sh/網站,建議翻牆否則會很慢。
    使用brew安裝後的目錄在/usr/local/Cellar/lua/5.3.5_1

  2. 安裝IDEA插件,在IDEA->Preferences面板,Plugins,
    裏面Browse repositories,在裏面搜索lua,然後就選擇同名插件lua。安裝好後重啓IDEA

  3. 配置Lua SDK的位置: IDEA->File->Project Structure,
    選擇添加Lua,路徑指向Lua SDK的bin文件夾

4.都配置好之後,在項目中右鍵創建Module,左側欄選擇lua,點下一步,選擇lua的sdk,下一步,輸入lua項目名,完成

3.編寫hello lua
print 'Hello Lua'
4.編寫模擬限流
-- 模擬限流

-- 用作限流的key
local key = 'my key'

-- 限流的最大閾值
local limit = 2

-- 當前限流大小
local currentLimit = 2

-- 是否超過限流標準
if currentLimit + 1 > limit then
    print 'reject'
    return false
else
    print 'accept'
    return true
end
5.限流組件封裝

1.添加maven

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>18.0</version>
</dependency>

2.添加Spring配置
不是重要內容就隨便寫點,主要就是把reids配置一下

server.port=8080

spring.redis.database=0
spring.redis.host=localhost
spring.redis.port=6376

3.編寫限流腳本
lua腳本放在resource目錄下就可以了

-- 獲取方法簽名特徵
local methodKey = KEYS[1]
redis.log(redis.LOG_DEBUG,'key is',methodKey)

-- 調用腳本傳入的限流大小
local limit = tonumber(ARGV[1])

-- 獲取當前流量大小
local count = tonumber(redis.call('get',methodKey) or "0")

--是否超出限流值
if count + 1 >limit then
    -- 拒絕訪問
    return false
else
    -- 沒有超過閾值
    -- 設置當前訪問數量+1
    redis.call('INCRBY',methodKey,1)
    -- 設置過期時間
    redis.call('EXPIRE',methodKey,1)
    -- 放行
    return true
end

4.使用spring-data-redis組件集成Lua和Redis
創建限流類

@Service
@Slf4j
public class AccessLimiter{
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Autowired
    private RedisScript<Boolean> rateLimitLua;

    public void limitAccess(String key,Integer limit){
        boolean acquired = stringRedisTemplate.execute(
            rateLimitLua,//lua腳本的真身
            Lists.newArrayList(key),//lua腳本中的key列表
            limit.toString()//lua腳本的value列表
        );

        if(!acquired){
            log.error("Your access is blocked,key={}",key);
            throw new RuntimeException("Your access is blocked");
        }
    }
}

創建配置類

@Configuration
public class RedisConfiguration{
    public RedisTemplate<String,String> redisTemplate(RedisConnectionFactory factory){
        return new StringRedisTemplate(factory);
    }
    
    public DefaultRedisScript loadRedisScript(){
        DefaultRedisScript redisScript = new DefaultRedisScript();
        redisScript.setLocation(new ClassPathResource("rateLimiter.lua"));
        redisScript.setResultType(java.lang.Boolean.class);
        return redisScript;
    }
}

5.在Controller中添加測試方法驗證限流效果

@RestController
@Slf4j
public class Controller{
    @Autowired
    private AccessLimiter accessLimiter;
    
    @GetMapping("test")
    public String test(){
        accessLimiter.limitAccess("ratelimiter-test",1);
        return "success";
    }
} 
6.編寫限流注解

1.新增註解

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AccessLimiterAop{
    int limit();
    
    String methodKey() default "";
}

2.新增切面

@Slf4j
@Aspect
@Component
public class AccessLimiterAspect{
    @Autowired
    private AccessLimiter  accessLimiter;

    //根據註解的位置,自己修改
    @Pointcut("@annotation(com.gyx.demo.annotation.AccessLimiter)")
    public void cut(){
        log.info("cut");
    }
    
    @Before("cut()")
    public void before(JoinPoint joinPoint){
        //獲取方法簽名,作爲methodkey
        MethodSignature signature =(MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        AccessLimiterAop annotation = method.getAnnotation(AccessLimiterAop.class);
        
        if(annotation == null){
            return;
        }
        String key = annotation.methodKey();
        Integer limit = annotation.limit();
        //如果沒有設置methodKey,就自動添加一個
        if(StringUtils.isEmpty(key)){
            Class[] type = method.getParameterType();
            key = method.getName();
            if (type != null){
                String paramTypes=Arrays.stream(type)
                    .map(Class::getName)
                    .collect(Collectors.joining(","));
                    key += "#"+paramTypes;
            }
        }
        
        //調用redis
        return accessLimiter.limitAccess(key,limit);
    }
}

3.在Controller中添加測試方法驗證限流效果

@RestController
@Slf4j
public class Controller{
    @Autowired
    private AccessLimiter accessLimiter;
    
    @GetMapping("test")
    @AccessLImiterAop(limit =1)
    public String test(){
        return "success";
    }
} 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章