天天看點

「Redis應用」Redis "進階"應用場景——限流、延時隊列、幂等處理

「Redis應用」Redis "進階"應用場景——限流、延時隊列、幂等處理

引言

自Redis入門篇過後,已經好久沒更Redis了,接下來應該從實戰篇,原理篇,面試篇幾個層次來展開,本篇主要是實戰篇環節,以問題展開,應對面試場景作答【melo稱其為"手撕面答"】,盡量簡短,某些部分可能不會進行詳細介紹。

emmm,但後邊有些部分還是幹脆整合在一起了,可觀性好一點,不至于看得一頭霧水

本篇腦圖速覽

「Redis應用」Redis "進階"應用場景——限流、延時隊列、幂等處理

Redis限流是怎麼做的?

固定視窗計數

固定視窗計數是指,假設我們的限流規則是:1min内最多隻能通路10次,那麼固定視窗就是固定了【 1min-2min】這個視窗内,隻能有10次通路 ,相應的我們就要給這個視窗維護一個計數器。 為了節省空間,其實我們不需要維護一個個視窗,隻需要維護目前通路時間所在的視窗即可,以及對應的計數器,當新的通路到達了下一個視窗時,則計數器重置即可。

redis實作

用redis的話,由于有過期機制,其實設定1min過期,就可以實作計數器重置的效果了

  • redis設定一個名為qps的key,val用來計數,1min過期即可
//原子自增類
RedisAtomicInteger redisAtomicInteger = new RedisAtomicInteger(redisKey, redisTemplate.getConnectionFactory());
//先自增
int qps = redisAtomicInteger.getAndIncrement();
//若是第一次通路
if(qps==0){
    //設定1min過期
	redisAtomicInteger.expire(1, TimeUnit.MINUTES);
}
if(qps>10){
	throw new RuntimeException("qps超過門檻值");
}
複制代碼           

存在的問題

由于是固定視窗,那其實存在視窗臨界問題,比如使用者可以在【1.5-2】這段區間通路10次,【2-2.5】這段區間也通路10次,這樣就變成了1min内其實可以通路20次!看起來破壞了我們的限流規則,但由于我們是固定視窗計數,到達2的時候已經重置計數器了。

「Redis應用」Redis "進階"應用場景——限流、延時隊列、幂等處理

滑動視窗計數

假設我們的限流規則是:1min内最多隻能通路10次,那麼滑動視窗呢就是會根據你通路進來的時間,以通路時間作為區間末尾,目前時間-1min作為區間頭部,相當于視窗一直在往右滑動,這樣其實就能在一定程度上解決我們剛才提到的視窗臨界問題

  • 當通路時間為2.5的時候,此時對于的視窗是【1.5-2.5】,計數器都能正确計數
「Redis應用」Redis "進階"應用場景——限流、延時隊列、幂等處理

實作

要獲得一段區間,并且按時間排序,我們可以想到用ZSet來實作,能按區間查詢出【目前通路時間-1min,目前通路時間】這段區間的計數

//interMills為限流時間,也就是我們這裡的1min
Long count = redisTemplate.opsForZSet().count(redisKey, currentTimeMillis - interMills, currentTimeMillis);
複制代碼           

存在的問題

其實我們隻是以更小的視窗大小去移動這個區間罷了,固定視窗計數是以1min為機關去移動,滑動視窗是以1s為機關去移動,後者出現視窗臨界問題的機率更小,但依然是可能出現的,比如:

1. 一開始是【1-2min】這段區間,下一秒會移動為【1min1s - 2min1s】,如果此時有人在1min這一刻,通路了10次,然後下一秒又進入下一個區間了,計數重置,在1min1s這一刻又通路了10次,依舊會出現視窗臨界問題,1min内通路次數達到了20次

經評論區的小夥伴提問過後,發現其實滑動視窗算法是能夠解決臨界視窗問題的,當初學習時,可能隻看了片面的資料,或者那個資料的實作方式不是用ZSet,而是用其他,以起點為首的區間去計算也有可能。

不過至少可以确定的是,本文用ZSet實作,以目前通路時間為區間末尾的話,确實是不會發生臨界視窗問題的,非常感謝兩位掘友:[吃西瓜啊] && [kb啵]

視窗臨界問題小結

其實視窗臨界問題,就是在即将被移出視窗的這段區間内,可能一次性通路量達到了我們的門檻值,而由于要移出視窗了,計數又将重置了,是以這些通路量就相當于不會被後續統計到,那麼後續再次超過門檻值,就變成雙倍門檻值了。

漏桶算法

不限制流入,隻限制流出的速率 -- 以一定的速率,去擷取桶中的請求

「Redis應用」Redis "進階"應用場景——限流、延時隊列、幂等處理

水(請求)先進入到漏桶裡,漏桶以一定的速度出水【從下方漏出去】(接口有響應速率),當水流入速度過大會直接溢出【從上邊移除】(通路頻率超過接口響應速率),然後就拒絕請求,可以看出漏桶算法能強行限制資料的傳輸速率。

但無法應對突發流量,因為流出的速率,是限死的

漏桶由于是底部有一個破洞,以固定速率流出請求,頂部用來接收請求,是以其實可以用一個雙端隊列來實作

  1. 收到請求時,放入隊列中【背景線程以固定的速率去removeLast,處理請求】
  2. 若隊列滿了,則請求就被丢棄。

令牌桶算法

限制流入,不限制流出 -- 以一定速率,去生成桶中的請求【令牌】

先有一個木桶,系統按照固定速度,往桶裡加入Token,如果桶已經滿了就不再添加。 當有請求到來時,會各自拿走一個Token,取到Token 才能繼續進行請求處理,沒有Token 就拒絕服務。

「Redis應用」Redis "進階"應用場景——限流、延時隊列、幂等處理

支援突發流量

如果一段時間沒有請求時,桶内就會積累一些Token,下次一旦有突發流量,隻要Token足夠,也能一次處理,是以令牌桶算法的特點是_允許突發流量_

Redis實作

定時任務

// 每個請求都需要擷取令牌
public Response limitFlow(Long id){
        Object result = redisTemplate.opsForList().leftPop("flowList");
        if(result == null){
            return Response.ok("目前令牌桶中無令牌");
        }
        return Response.ok(articleDescription2);
}
複制代碼           

再依靠Java的定時任務,定時往List中rightPush令牌

// 10S生成一個令牌,放入令牌桶中,使用UUID保證唯一性
    @Scheduled(fixedDelay = 10_000,initialDelay = 0)
    public void setIntervalTimeTask(){
        redisTemplate.opsForList().rightPush("flowList",UUID.randomUUID().toString());
    }
複制代碼           

懶惰更新令牌

  1. 記錄上一次通路時間,當新的通路進來時,令牌數量+= (新通路時間-上一次通路時間)×每秒生成的令牌數量
  2. 然後判斷請求數是否大于令牌數量 大于:則拒絕掉 小于:則減掉相應的令牌數量即可

令牌桶與漏桶相比

  • 令牌桶限制的是平均流入速率(允許突發請求,隻要有令牌就可以處理,支援一次拿3個令牌,4個令牌),并允許一定程度突發流量;
  • 漏桶限制的是常量流出速率(即流出速率是一個固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2),進而平滑突發流入速率;
  • 令牌桶允許一定程度的突發,而漏桶主要目的是平滑流入速率;

除了用Redis限流,還能否在其他層面做限流?

Nginx 兩種限流方式

控制速率

ngx_http_limit_req_module 子產品提供了漏桶算法(leaky bucket),可以限制單個IP的請求處理頻率。

正常限流:

http {
    limit_req_zone 192.168.1.1 zone=myLimit:10m rate=5r/s;
    }
    
server {
    location / {
        limit_req zone=myLimit;
        rewrite / http://www.hac.cn permanent;
    }
}
複制代碼           

參數解釋: key: 定義需要限流的對象。 zone: 定義共享記憶體區來存儲通路資訊。 rate: 用于設定最大通路速率。 表示基于用戶端192.168.1.1進行限流,定義了一個大小為10M,名稱為myLimit的記憶體區,用于存儲IP位址通路資訊。rate設定IP通路頻率,

rate=5r/s表示每秒隻能處理每個IP位址的5個請求。Nginx限流是按照毫秒級為機關的,也就是說1秒處理5個請求會變成每200ms隻處理一個請求。如果200ms内已經處理完1個請求,但是還是有有新的請求到達,這時候Nginx就會拒絕處理該請求。

突發流量限制通路頻率

上面rate設定了 5r/s,如果有時候流量突然變大,超出的請求就被拒絕傳回503了,突發的流量影響業務就不好了。

這時候可以加上burst 參數,一般再結合 nodelay 一起使用。

server {
  location / {
    limit_req zone=myLimit burst=20 nodelay;
    rewrite / http://www.hac.cn permanent;
  }
}
複制代碼           

沒有nodelay的話:

若一瞬間超出設定的每秒處理量時,允許超出 20 個請求,這20個請求會阻塞排隊等待處理【相當于繼續加入桶的頭部,等待定時任務從隊尾remove】,多于20的部分則被丢棄

有nodelay的話:

burst=20 nodelay 表示這20個請求立馬處理,不能延遲,相當于特事特辦。

我們的業務有時可以一瞬間就處理完很多個請求,這種情況下配置nodelay就可以一瞬間處理完這些突發流量,而不用繼續阻塞排隊等待處理。

burst+delay

在峰值快速處理的例子中,當接收到超出限定速率的請求時,可以一定程度上快速處理,但系統的承受能力畢竟是有限的,是以burst的大小會受限于系統的承受能力。假如系統能承受的最大瞬間并發量為2000,但外部短時間内請求峰值為3000,那麼這多出的這1000個請求就必須丢棄嗎?有沒有可能快速處理2000個請求,剩下1000個堵塞等待後續處理呢?

  • 可以通過Nginx的 delay=? 配置來實作。這個配置表示在超出限定速率的請求中,超過多少個請求之後需要被延時處理,沒有超過delay值的請求,無需等待。nodelay其實是delay的一種特殊情況,表示所有請求都無需等待,相當于delay的值等于burst。

配置示例:

http {
    limit_req_zone $binary_remote_addr zone=one:10m rate=12r/m;
    server {
        location / {
            limit_req zone=one burst=10 delay=8;
        }
}
複制代碼           

delay=8表示超出限定速率的8個請求可以被快速處理,再超過的(當然也必須小于burst)就必須阻塞等待。

「Redis應用」Redis "進階"應用場景——限流、延時隊列、幂等處理

這意味着,如果從一個給定 IP 位址發送 21 個請求,Nginx 會立即将第一個請求發送到上遊伺服器群,然後将餘下 20 個請求放在隊列中。然後每 100 毫秒轉發一個排隊的請求,隻有當傳入請求使隊列中排隊的請求數超過 20 時,Nginx 才會向用戶端傳回 503。

相當于20之後的是特權?前20又重新排隊嗎,什麼意思

控制并發連接配接數

ngx_http_limit_conn_module 提供了限制連接配接數功能。

limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;

server {
  ...
    limit_conn perip 10;
  limit_conn perserver 100;
}
複制代碼           

limit_conn perip 10 作用的key 是 $binary_remote_addr,表示限制單個IP同時最多能持有10個連接配接。

limit_conn perserver 100 作用的key是 $server_name,表示虛拟主機(server) 同時能處理并發連接配接的總數。

注:limit_conn perserver 100 作用的key是 $server_name,表示虛拟主機(server) 同時能處理并發連接配接的總數。

RocketMQ削峰限流

這個跟我們本文關聯性不大,我們後邊再單獨出一篇詳細講講

Redis如何實作延時隊列?

String設定key,過期監聽觸發事件

比如3點下單,5點需要執行定時任務發通知,此時設定一個key,過期時間為距離定時任務執行的時間【5-2】= 2h 等到該key即将過期時,redis中有一個監聽機制,key過期時可以觸發自定義事件,我們可以在代碼裡對不同key執行不同的操作,是以key過期的時候,觸發自定義事件:根據key的内容去發定時任務通知就好了

存在問題

redis中的key過期政策,決定了定時任務不一定能準點執行

  1. 主動通路鍵的時候,發現過期時删除
  2. 背景線程定時掃描,發現過期時删除
是以如果我們長時間沒有通路這個key,背景線程也沒掃描到的話,這個key本質上是還未過期的,不會觸發過期事件的

Redis 從未保證會在設定的過期時間立即删除并發送過期通知。實際上,過期通知晚于設定的過期時間數分鐘的情況也比較常見。 此外鍵空間通知采用的是發送即忘(fire and forget)政策,并不像消息隊列一樣保證送達。當訂閱事件的用戶端會丢失所有在斷線期間所有分發給它的事件。

是以,這個方案其實很少人使用。。

ZSet存儲定時任務

延時隊列其實有很多種實作的方式,比如RocketMQ本身支援發送延遲消息,但RocketMQ支援的延遲等級有限,自定義程度不高,比如我搶到了一個場地之後,要設定場地時間結束後,修改訂單狀态為【已結束】,這時RocketMQ就沒法精準的設定一個定時任務的時間。

于是可以用Redis中的ZSet資料結構,以任務的時間作為作為score進行排序,按時間先後進行排序,背景再開啟定時任務,定時利用 zrangebysocre 查詢符合條件的所有待處理的任務即可

本地做定時任務不可以嗎?為什麼非得引個Redis

也可以實作,但一旦項目重新部署,那麼JVM記憶體裡邊存放的定時任務也都會随之丢棄,相當于沒有一個持久化的媒介。

當然,延時隊列還有很多種實作方式,基于rabbitmq,時間輪算法等,這裡先不深入

Redis如何實作布隆過濾器?

用BitMap可以實作,這裡具體還是說說布隆過濾器的原理和優缺點即可,具體實作是類似的

布隆過濾器的實作原理?

(布隆過濾器(Bloom Filter)是1970年由布隆提出的。它實際上是一個很長的**二進制向量(位圖)**和一系列随機映射函數(哈希函數)。

「Redis應用」Redis "進階"應用場景——限流、延時隊列、幂等處理

布隆過濾器可以用于檢索一個元素是否在一個集合中:

  1. 如果布隆過濾器判斷存在,則放行【可能會誤判】,後續過程跟普通查詢redis過程是一樣的
  2. 不存在,則直接傳回,不走redis
将所有可能存在的資料哈希到一個足夠大的bitmaps中,一定不存在的資料會被這個bitmaps攔截掉,進而避免了對底層存儲系統的查詢壓力。
  • 它的優點是空間效率和查詢時間都遠遠超過一般的算法
  • 缺點是有一定的誤識别率和删除困難。
誤判原因在于:布隆過濾器走的是哈希思想,隻要哈希思想,就可能存在哈希沖突,資料x和資料y的哈希結果一樣,如果隻有x,判斷y是否存在的時候,由于跟x的哈希值一樣,導緻布隆過濾器誤以為y也存在

布隆過濾器的資料結構

布隆過濾器由「初始值都為 0 的位圖數組」和「 N 個哈希函數」兩部分組成。當我們在寫入資料庫資料時,在布隆過濾器裡做個标記,這樣下次查詢資料是否在資料庫時,隻需要查詢布隆過濾器,如果查詢到資料沒有被标記,說明不在資料庫中。

布隆過濾器會通過 3 個操作完成标記:

  • 第一步,使用 N 個哈希函數分别對資料做哈希計算,得到 N 個哈希值;
  • 第二步,将第一步得到的 N 個哈希值對位圖數組的長度取模,得到每個哈希值在位圖數組的對應位置。
  • 第三步,将每個哈希值在位圖數組的對應位置的值設定為 1;

舉個例子,假設有一個位圖數組長度為 8,哈希函數 3 個的布隆過濾器。

「Redis應用」Redis "進階"應用場景——限流、延時隊列、幂等處理

在資料庫寫入資料 x 後,把資料 x 标記在布隆過濾器時,資料 x 會被 3 個哈希函數分别計算出 3 個哈希值,然後在對這 3 個哈希值對 8 取模,假設取模的結果為 1、4、6,然後把位圖數組的第 1、4、6 位置的值設定為 1。當應用要查詢資料 x 是否資料庫時,通過布隆過濾器隻要查到位圖數組的第 1、4、6 位置的值是否全為 1,隻要有一個為 0,就認為資料 x 不在資料庫中。

注意:此處為什麼需要3個hash函數?

若隻有1個hash函數,沖突的機率是很大的,都hash到同一個位置,導緻誤判的機率很大 是以使用多個hash函數,hash到多個位置,隻有這幾個位置都是1,才說明x存在,誤判的機率會降低些

布隆過濾器由于是基于哈希函數實作查找的,高效查找的同時存在哈希沖突的可能性,比如資料 x 和資料 y 可能都落在第 1、4、6 位置,而事實上,可能資料庫中并不存在資料 y,存在誤判的情況。

是以,查詢布隆過濾器說資料存在,并不一定證明資料庫中存在這個資料,但是查詢到資料不存在,資料庫中一定就不存在這個資料。

本質上是一個很大的位圖,存儲值的時候,用多個hash函數計算出他要存儲的位置,比如x,對應hash後的結果是1,2,4, 把這幾個位置标記為1。

查詢的時候,對x做多次hash,隻有所有hash後的位置标記位都是1,才可能存在

  • 若有一個為0,則肯定不存在。

為什麼是可能存在?

因為可能x已經不在緩存裡了,此時又進來了一個y,y的hash結果跟x一模一樣,此時會出現誤判。

布隆過濾器為什麼不好删除元素?

比如現在要删除x,對x做hash,找到了他的存儲位置分别是【1,3,9】,我們如果直接把這三個位置改為0,可能會導緻“删除”了其他元素

  • 比如y他的存儲位置是【2,3,8】,x跟y共用了3這個位置,把3這個位置改為0,會導緻y也被“删除”了,根據上邊的原理發現不是全1了

如何解決呢?

最簡單的做法就是加一個計數器,就是說位數組的每個位如果不存在就是0,存在幾個元素就存具體的數字,而不僅僅隻是存1。

那麼這就有一個問題,本來存1,一位就可以滿足了,但是如果要存具體的數字,可能需要更多的位數,是以帶有計數器的布隆過濾器會占用更大的空間。

幂等處理怎麼用Redis實作?

比如我們有一個支付接口,一個訂單号隻能被支付一次

  1. 用戶端先調用擷取token的接口,背景生成一個token,傳回給前端,同時存儲在redis裡邊,設定一定時間過期
  2. 用戶端帶上token,調用支付接口,背景判斷是否有這個token,有則說明是第一次通路 删除token 然後執行業務
  3. 第二次通路時,檢測到沒有token,則不允許執行,確定幂等性

‍♂️先删除token,還是先執行業務?

  1. 先删除token,若後續執行業務期間失敗了,則直接第二次點選按鈕,調用支付接口時,由于沒有重新整理頁面,前端還存儲了剛才那份token,由于背景沒有這個token,就會一直失敗
  • 是以此時需要:用戶端主動重新整理頁面,删除掉前端這個token,重新完成送出這個過程,重新調用擷取token接口,再走一遍流程
  1. 先執行業務,再删除token,但此時若沒有加鎖的話,其他線程調用接口時,由于A線程還沒執行完業務,redis裡邊的token還未删掉,那麼B線程調用支付接口時,也會查到還有token,也能夠去執行業務,這樣就破壞我們的幂等性了。
  • 是以如果采用這種政策的話,需要加鎖,保證A線程執行完業務,删掉token之後,其他線程才能調用這個支付接口。

如此來看,還是第一種方式更優,業務執行出錯的話,前端重新重新整理頁面也能再次成功。

此處有待補充,一些細節問題可能還沒有充分考慮到,實作幂等,也還有很多種方式,後邊再來深入分析

Redis的incr可以做什麼?

簽到等需要計數的序列号

每天簽到的序号,設定1天過期,第一次簽到時便是從0開始自增

記錄登入失敗次數防爆破

記錄某個使用者登入失敗的次數,防爆破攻擊,1min内失敗次數超過5次則限制10min内不允許再次調用登入接口

資料結構設計

  1. 一個login_error的鍵來記錄失敗次數,key是 login_error:+賬号+ip,val是失敗次數, 1分鐘過期
每次登入失敗,就會val++
  1. 當失敗次數超過5次,則設定一個 bank 的鍵,key是 login_bank:+賬号+ip,val是1, 10分鐘過期 并把上邊的login_error鍵删除
注意redis裡邊key的字首用 :來分隔,可以在圖形化界面實作檔案夾管理的形式
「Redis應用」Redis "進階"應用場景——限流、延時隊列、幂等處理

登入失敗的邏輯

  1. 先判斷是否有 login_bank:+賬号+ip的key,有則直接限制登入 沒有,若登入失敗,則 login_error:+賬号+ip的 val++, 若val達到5了,則設定一個 login_bank:+賬号+ip的key,10min過期 并删除掉 login_error:+賬号+ip
當然,也可以不用設定兩個key,單純一個key就可以了,發現val>5了,則延長這個key的時間為10min也是可以的
「Redis應用」Redis "進階"應用場景——限流、延時隊列、幂等處理

作者:Melo_

連結:https://juejin.cn/post/7158001985046708260

繼續閱讀