基於Redis和Lua的分布式限流

2020-09-03 程式設計師歷小冰

 Java單機限流可以使用AtomicInteger,RateLimiter或Semaphore來實現,但是上述方案都不支持集群限流。集群限流的應用場景有兩個,一個是網關,常用的方案有Nginx限流和Spring Cloud Gateway,另一個場景是與外部或者下遊服務接口的交互,因為接口限制必須進行限流。

 本文的主要內容為:

  • Redis和Lua的使用場景和注意事項,比如說KEY映射的問題。
  • Spring Cloud Gateway中限流的實現。

集群限流的難點

 在上篇Guava RateLimiter的分析中,我們學習了令牌桶限流算法的原理,下面我們就探討一下,如果將 RateLimiter擴展,讓它支持集群限流,會遇到哪些問題。

  RateLimiter會維護兩個關鍵的參數 nextFreeTicketMicros和 storedPermits,它們分別是下一次填充時間和當前存儲的令牌數。當 RateLimiter的 acquire函數被調用時,也就是有線程希望獲取令牌時, RateLimiter會對比當前時間和 nextFreeTicketMicros,根據二者差距,刷新 storedPermits,然後再判斷更新後的 storedPermits是否足夠,足夠則直接返回,否則需要等待直到令牌足夠(Guava RateLimiter的實現比較特殊,並不是當前獲取令牌的線程等待,而是下一個獲取令牌的線程等待)。

 由於要支持集群限流,所以 nextFreeTicketMicros和 storedPermits這兩個參數不能只存在JVM的內存中,必須有一個集中式存儲的地方。而且,由於算法要先獲取兩個參數的值,計算後在更新兩個數值,這裡涉及到競態限制,必須要處理並發問題。

 集群限流由於會面對相比單機更大的流量衝擊,所以一般不會進行線程等待,而是直接進行丟棄,因為如果讓拿不到令牌的線程進行睡眠,會導致大量的線程堆積,線程持有的資源也不會釋放,反而容易拖垮伺服器。

Redis和Lua

 分布式限流本質上是一個集群並發問題,Redis單進程單線程的特性,天然可以解決分布式集群的並發問題。所以很多分布式限流都基於Redis,比如說Spring Cloud的網關組件Gateway。

 Redis執行Lua腳本會以原子性方式進行,單線程的方式執行腳本,在執行腳本時不會再執行其他腳本或命令。並且,Redis只要開始執行Lua腳本,就會一直執行完該腳本再進行其他操作,所以Lua腳本中不能進行耗時操作。使用Lua腳本,還可以減少與Redis的交互,減少網絡請求的次數。

 Redis中使用Lua腳本的場景有很多,比如說分布式鎖,限流,秒殺等,總結起來,下面兩種情況下可以使用Lua腳本:

  • 使用 Lua 腳本實現原子性操作的CAS,避免不同客戶端先讀Redis數據,經過計算後再寫數據造成的並發問題。
  • 前後多次請求的結果有依賴時,使用 Lua 腳本將多個請求整合為一個請求。

 但是使用Lua腳本也有一些注意事項:

  • 要保證安全性,在 Lua 腳本中不要定義自己的全局變量,以免汙染 Redis內嵌的Lua環境。因為Lua腳本中你會使用一些預製的全局變量,比如說 redis.call()
  • 要注意 Lua 腳本的時間複雜度,Redis 的單線程同樣會阻塞在 Lua 腳本的執行中。
  • 使用 Lua 腳本實現原子操作時,要注意如果 Lua 腳本報錯,之前的命令無法回滾,這和Redis所謂的事務機制是相同的。
  • 一次發出多個 Redis 請求,但請求前後無依賴時,使用 pipeline,比 Lua 腳本方便。
  • Redis要求單個Lua腳本操作的key必須在同一個Redis節點上。解決方案可以看下文對Gateway原理的解析。

性能測試

 Redis雖然以單進程單線程模型進行操作,但是它的性能卻十分優秀。總結來說,主要是因為:

  • 絕大部分請求是純粹的內存操作
  • 採用單線程,避免了不必要的上下文切換和競爭條件
  • 內部實現採用非阻塞IO和epoll,基於epoll自己實現的簡單的事件框架。epoll中的讀、寫、關閉、連接都轉化成了事件,然後利用epoll的多路復用特性,絕不在io上浪費一點時間。

 所以,在集群限流時使用Redis和Lua的組合併不會引入過多的性能損耗。我們下面就簡單的測試一下,順便熟悉一下涉及的Redis命令。

將腳本導入redis,之後調用不需再傳遞腳本內容redis-cli -a 082203 script load "$(cat test.lua)""b978c97518ae7c1e30f246d920f8e3c321c76907"# 使用redis-benchmark和evalsha來執行lua腳本redis-benchmark -a 082203 -n 1000000 evalsha b978c97518ae7c1e30f246d920f8e3c321c76907 0 ======1000000 requests completed in 20.00 seconds50 parallel clients3 bytes payloadkeep alive: 193.54% <= 1 milliseconds99.90% <= 2 milliseconds99.97% <= 3 milliseconds99.98% <= 4 milliseconds99.99% <= 5 milliseconds100.00% <= 6 milliseconds100.00% <= 7 milliseconds100.00% <= 7 milliseconds49997.50 requests per second

 通過上述簡單的測試,我們可以發現本機情況下,使用Redis執行Lua腳本的性能極其優秀,一百萬次執行,99.99%在5毫秒以下。

 本來想找一下官方的性能數據,但是針對Redis + Lua的性能數據較少,只找到了幾篇個人博客,感興趣的同學可以去探索。這篇文章有Lua和zadd的性能比較(具體數據請看原文,連結缺失的話,請看文末)。

以上lua腳本的性能大概是zadd的70%-80%,但是在可接受的範圍內,在生產環境可以使用。負載大概是zadd的1.5-2倍,網絡流量相差不大,IO是zadd的3倍,可能是開啟了AOF,執行了三次操作。

Spring Cloud Gateway的限流實現

  Gateway是微服務架構 SpringCloud的網關組件,它基於Redis和Lua實現了令牌桶算法的限流功能,下面我們就來看一下它的原理和細節吧。

  Gateway基於Filter模式,提供了限流過濾器 RequestRateLimiterGatewayFilterFactory。只需在其配置文件中進行配置,就可以使用。具體的配置感興趣的同學自行學習,我們直接來看它的實現。

  RequestRateLimiterGatewayFilterFactory依賴 RedisRateLimiter的 isAllowed函數來判斷一個請求是否要被限流拋棄。

public Mono<Response> isAllowed(String routeId, String id) { //routeId是ip地址,id是使用KeyResolver獲取的限流維度id,比如說基於uri,IP或者用戶等等。 Config routeConfig = loadConfiguration(routeId); // 每秒能夠通過的請求數 int replenishRate = routeConfig.getReplenishRate(); // 最大流量 int burstCapacity = routeConfig.getBurstCapacity(); try { // 組裝Lua腳本的KEY List<String> keys = getKeys(id); // 組裝Lua腳本需要的參數,1是指一次獲取一個令牌 List<String> scriptArgs = Arrays.asList(replenishRate + "", burstCapacity + "", Instant.now().getEpochSecond() + "", "1"); // 調用Redis,tokens_left = redis.eval(SCRIPT, keys, args) Flux<List<Long>> flux = this.redisTemplate.execute(this.script, keys, scriptArgs); ..... // 省略 }static List<String> getKeys(String id) { String prefix = "request_rate_limiter.{" + id; String tokenKey = prefix + "}.tokens"; String timestampKey = prefix + "}.timestamp"; return Arrays.asList(tokenKey, timestampKey);}

 需要注意的是 getKeys函數的prefix包含了"{id}",這是為了解決Redis集群鍵值映射問題。Redis的KeySlot算法中,如果key包含{},就會使用第一個{}內部的字符串作為hash key,這樣就可以保證擁有同樣{}內部字符串的key就會擁有相同slot。Redis要求單個Lua腳本操作的key必須在同一個節點上,但是Cluster會將數據自動分布到不同的節點,使用這種方法就解決了上述的問題。

 然後我們來看一下Lua腳本的實現,該腳本就在Gateway項目的resource文件夾下。它就是如同 Guava的 RateLimiter一樣,實現了令牌桶算法,只不過不在需要進行線程休眠,而是直接返回是否能夠獲取。

local tokens_key = KEYS[1] -- request_rate_limiter.${id}.tokens 令牌桶剩餘令牌數的KEY值local timestamp_key = KEYS[2] -- 令牌桶最後填充令牌時間的KEY值local rate = tonumber(ARGV[1]) -- replenishRate 令令牌桶填充平均速率local capacity = tonumber(ARGV[2]) -- burstCapacity 令牌桶上限local now = tonumber(ARGV[3]) -- 得到從 1970-01-01 00:00:00 開始的秒數local requested = tonumber(ARGV[4]) -- 消耗令牌數量,默認 1 local fill_time = capacity/rate -- 計算令牌桶填充滿令牌需要多久時間local ttl = math.floor(fill_time*2) -- *2 保證時間充足local last_tokens = tonumber(redis.call("get", tokens_key)) -- 獲得令牌桶剩餘令牌數if last_tokens == nil then -- 第一次時,沒有數值,所以桶時滿的 last_tokens = capacityendlocal last_refreshed = tonumber(redis.call("get", timestamp_key)) -- 令牌桶最後填充令牌時間if last_refreshed == nil then last_refreshed = 0endlocal delta = math.max(0, now-last_refreshed) -- 獲取距離上一次刷新的時間間隔local filled_tokens = math.min(capacity, last_tokens+(delta*rate)) -- 填充令牌,計算新的令牌桶剩餘令牌數 填充不超過令牌桶令牌上限。local allowed = filled_tokens >= requested local new_tokens = filled_tokenslocal allowed_num = 0if allowed then-- 若成功,令牌桶剩餘令牌數(new_tokens) 減消耗令牌數( requested ),並設置獲取成功( allowed_num = 1 ) 。 new_tokens = filled_tokens - requested allowed_num = 1end -- 設置令牌桶剩餘令牌數( new_tokens ) ,令牌桶最後填充令牌時間(now) ttl是超時時間?redis.call("setex", tokens_key, ttl, new_tokens)redis.call("setex", timestamp_key, ttl, now)-- 返回數組結果return { allowed_num, new_tokens }

後記

 Redis的主從異步複製機制可能丟失數據,出現限流流量計算不準確的情況,當然限流畢竟不同於分布式鎖這種場景,對於結果的精確性要求不是很高,即使多流入一些流量,也不會影響太大。

 正如Martin在他質疑Redis分布式鎖RedLock文章中說的,Redis的數據丟棄了也無所謂時再使用Redis存儲數據。

I think it’s a good fit in situations where you want to share some transient, approximate, fast-changing data between servers, and where it’s not a big deal if you occasionally lose that data for whatever reason

 接下來我們回來學習阿里開源的分布式限流組件 sentinel,希望大家持續關注。

個人微信公眾號: 張狗蛋的技術之路 remcarpediem

個人博客:

參考

  • https://www.cnblogs.com/itrena/p/5926878.html
  • 壓測的文章:https://www.fuwuqizhijia.com/redis/201704/60935.html
  • https://blog.csdn.net/forezp/article/details/85081162
  • https://blog.csdn.net/xixingzhe2/article/details/86167859
  • Matin RedLock http://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

相關焦點

  • 分布式限流,你想知道的都在這裡
    總結針對於單個應用的限流 RateLimiter 夠用了,如果是分布式環境可以藉助 Redis 來完成。來做演示。在 Order 應用提供的接口中採取了限流。Lua 腳本如下:--lua 下標從 1 開始-- 限流 keylocal key = KEYS[1]-- 限流大小local limit = tonumber(ARGV[1])-- 獲取當前流量大小local curentLimit = tonumber(redis.call('get', key) or "0")if curentLimit
  • 2020-10-29:使用redis實現分布式限流組件...
    2020-10-29:使用redis實現分布式限流組件,要求高並發場景同一IP一分鐘內只能訪問100次,超過限制返回異常,寫出實現思路或偽代碼均可。福哥答案2020-10-29:簡單回答:固定窗口:string。key存ip,value存次數。滑動窗口:list。key存ip,value=list,存每次訪問的時間。
  • 基於redis實現分布式鎖
    背景基於redis實現。nbsp; 獲取鎖;  處理業務;} finally{  釋放鎖;}核心是基於redis的set命令。lua為什麼要用Lua,因為是lua腳本原子操作。
  • 「高並發」億級流量場景下如何實現分布式限流?
    分布式限流的關鍵就是需要將限流服務做成全局的,統一的。可以採用Redis+Lua技術實現,通過這種技術可以實現高並發和高性能的限流。Lua是一種輕量小巧的腳本程式語言,用標準的C語言編寫的開源腳本,其設計的目的是為了嵌入到應用程式中,為應用程式提供靈活的擴展和定製功能。
  • 基於 Redis 實現的分布式鎖
    ;基於緩存(Redis等)實現分布式鎖;基於Zookeeper實現分布式鎖;3、採用 Redis 實現分布式鎖(直接上代碼)3.1、先定義註解 RedisLock.Key的默認生成器<br/> * * 算法邏輯:<br/> * 1、看方法的參數是否有@KeyParam註解,有則獲取指定value和參數值作為 Key 的一部分,沒有則第 2 步;<br/> * 2、如果參數是一個對象,則看參數對象中的屬性上是否有@KeyParam註解,有則獲取指定value和屬性值作為 Key 的一部分,沒有則不處理; * * @author
  • 從構建分布式秒殺系統聊限流特技:算法+令牌桶+漏桶+API
    同時也收到了不少小夥伴的建議和投訴。我從不認為分布式、集群、秒殺這些就應該是大廠的專利,在網際網路的今天無論什麼時候都要時刻武裝自己,只有這樣,也許你的春天就在明天。在開發秒殺系統案例的過程中,前面主要分享了隊列、緩存、鎖和分布式鎖以及靜態化等等。緩存的目的是為了提升系統訪問速度和增強系統的處理能力;分布式鎖解決了集群下數據的安全一致性問題;靜態化無疑是減輕了緩存以及DB層的壓力。
  • 從京東618秒殺聊聊秒殺限流的多種實現
    同時也收到了不少小夥伴的建議和投訴。我從不認為分布式、集群、秒殺這些就應該是大廠的專利,在網際網路的今天無論什麼時候都要時刻武裝自己,只有這樣,也許你的春天就在明天。  在開發秒殺系統案例的過程中,前面主要分享了隊列、緩存、鎖和分布式鎖以及靜態化等等。緩存的目的是為了提升系統訪問速度和增強系統的處理能力;分布式鎖解決了集群下數據的安全一致性問題;靜態化無疑是減輕了緩存以及DB層的壓力。
  • 在SpingBoot中使用Redis對接口進行限流
    一個基於Redis實現的接口限流方案,先說要實現的功能可以限制指定的接口,在一定時間內,只能被請求N次,超過次數就返回異常信息可以通過配置文件,或者管理後臺,動態的修改限流配置實現的思路使用 Hash 存儲接口的限流配置
  • 網易後端架構師這樣理解—分布式限流方案
    希望閱讀完可以得到你的一個【三連】,這將是對我最大的鼓勵和支持。分布式限流老生常談了,但是你如果到了面試真的能完整的說出來?每天一個小技巧,面試不迷茫。少年們,慢慢積累吧!這都將是你養成高級架構師的小細節~接下來就看看網易後端的老師們怎樣總結。
  • 該如何一步步構建一個基於Redis的分布式鎖?
    假設有兩個客戶端A和B,A獲取到分布式的鎖。A執行了一會,突然A所在的伺服器斷電了(或者其他什麼的),也就是客戶端A掛了。這時出現一個問題,這個鎖一直存在,且不會被釋放,其他客戶端永遠獲取不到鎖。我們可以利用Redis的lua腳本來實現解鎖操作的原子性。
  • java 從零實現屬於你的 redis 分布式鎖
    但是在分布式系統中,上面的鎖就統統沒用了。我們想要解決分布式系統中的並發問題,就需要引入分布式鎖的概念。import com.github.houbb.wait.api.IWait;/** * 這裡是基於 redis 實現 * * 實際上也可以基於 zk/資料庫等實現。
  • Redis結合Lua腳本實現抽獎邏輯
    基於篇幅我這裡只演示一些抽獎可以用的上的Redis操作。預定抽獎的人數 local lottery_count = ARGV[1]  -- 如果預定抽獎的人數大於0才開始抽獎 if tonumber(lottery_count) > 0 then     -- 獎池中抽獎 返回的是 被抽中的人組成的數組     local chosen_list = redis.call
  • Spring Cloud基於Redis實現的分布式鎖
    基於Redis實現的分布式鎖Spring Cloud 分布式環境下,同一個服務都是部署在不同的機器上,這種情況無法像單體架構下數據一致性問題採用加鎖就實現數據一致性問題,在高並發情況下,對於分布式架構顯然是不合適的,針對這種情況我們就需要用到分布式鎖了。
  • Redis高級項目實戰,都0202年了,還不會Redis?
    兩種實現,下面都會有講到採用lua腳本操作分布式鎖採用setnx、setex命令連用的方式實現分布式鎖分布式鎖的場景為什麼要有分布式鎖?;import java.net.NetworkInterface;import java.util.Enumeration;/** * @ClassName:LockNxExJob * @Description:分布式獲取鎖和釋放鎖 * @Author:chenyb * @Date:2020/8/16 5:44 下午 * @Versiion:1.0 */@Servicepublic class LockNxExJob
  • 基於Redis實現的分布式鎖知識點總結
    應用場景分布式系統中,面對高並發場景,又對數據一致性有一定要求的情況下,使用分布式鎖。例如商城中下單扣庫存這種情況。基於Redis本文主要講講應用的一些變革。1、加鎖。問題在於:sennx和expire不是原子操作,萬一expire的時候崩了,這條命令永遠不過期了。
  • redis分布式鎖原理及實現
    一、寫在前面 現在面試,一般都會聊聊分布式系統這塊的東西。通常面試官都會從服務框架(Spring Cloud、Dubbo)聊起,一路聊到分布式事務、分布式鎖、ZooKeeper等知識。 緊接著,就會發送一段lua腳本到redis上,那段lua腳本如下所示:
  • Redis分布式鎖三板斧,你真的會設計嗎?
    Redis 是一個開源(BSD許可)的,內存中的數據結構存儲系統,它可以用作資料庫、緩存和消息中間件。 它支持多種類型的數據結構,如 字符串(strings), 散列(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 等。
  • 手撕redis鎖,就這麼簡單
    所以本篇就和小夥伴們分享基於內存操作的比較常用的分布式鎖——redis分布式鎖。手擼Redis分布式鎖實現原理redis分布式鎖實現原理其實也是比較簡單的,主要是依賴於redis的 set nx命令,我們來看一下完整的設置redis的命令:「Set resource_name my_random_value NX PX 30000」。
  • 實現一個Redis分布式鎖
    如果是分布式架構,就需要使用分布式鎖。保證 SETNX 和 EXPIRE 兩個操作要麼都成功,要麼都不成功。if (redis.call(&39;, KEYS[1], ARGV[1]) < 1) then return 0; end; redis.call(&39;, KEYS[1], tonumber(ARGV[2])); return 1; 通過這樣的方法,我們初步解決了競爭鎖的原子性問題