[登入那些事] 郵件傳送,限流,漏桶與令牌桶

shanyue發表於2019-05-05

前段時間,我使用了 jwt 來實現郵箱驗證碼的校驗與使用者認證與登入,還特別寫了一篇文章作為總結。

在那篇文章中,提到了一個點,如何限速。

在簡訊驗證碼和郵箱驗證碼,如果不限速,被惡意攻擊造成大量的 QPS,不僅拖垮了服務,也會心疼如水的資費。鑑於君子固窮的原則,在我的郵箱服務里加上限速。

關於如何限速,有兩個比較出名的演算法,漏桶演算法與令牌桶演算法,這裡對其簡單介紹一下,最後再實踐在我發郵件的API中

以下是傳送郵件的 API,已限制為一分鐘兩次,你可以通過修改 email 進行試驗。你也可以在我的站點直接試驗

curl 'https://graphql.xiange.tech/graphql' -H 'Content-Type: application/json' --data-binary '{"query":"mutation SEND($email: String!) {\n  sendEmailVerifyCode (email: $email)\n}","variables":{"email":"xxxxxx@qq.com"}}'
複製程式碼

以下是我關於登入實踐的系列文章

  1. 【登入那些事】實現 Material Design 的登入樣式
  2. 【登入那些事】使用 jwt 登入與校驗驗證碼
  3. 【登入那些事】郵件傳送,限流,漏桶與令牌桶

本文地址:shanyue.tech/post/rate-l…

Leaky Bucket (漏桶演算法)

漏桶演算法

漏桶演算法表示水滴(請求)先進入到漏桶裡,漏桶(bucket)以一定的速度出水,當漏桶中水滿時,無法再加水。

  • 維護一個計數器作為 bucket,計數器的上限為 bucket 的大小
  • 計數器滿時拒絕請求
  • 每隔一段時間清空計數器

option 代表在 option.window 的視窗時間內最多可以通過 option.max 次請求

以下是使用 redis 的計數器實現限流的虛擬碼

const option = {
  max: 10,        // window 時間內限速10個請求
  window: 1000    // 1s
}

function access(req) {
  // 根據請求生成唯一標誌
  const key = identity(req)
  // 計數器自增
  const counter = redis.incr(key)
  if (counter === 1) {
    // 如果是當前時間視窗的第一個請求,設定過期時間
    redis.expire(key, window) 
  }
  if (counter > option.window) {
    return false
  }
  return true
}
複製程式碼

這裡有 Redis 官方使用 INCR 實現限流的文件 redis.io/commands/IN…

此時有一個不算問題的問題,就是它的時間視窗並不是滑動視窗那樣在桶裡出去一個球,就可以再進來一個球。而更像是一個固定時間視窗,從桶裡出去一群球,再開始進球。正因為如此,它可能在固定視窗的後一半時間收到 max-1 次請求,又在下一個固定視窗內打來 max 次請求,此時在一個隨機的視窗時間內最多會有 2 * max - 1 次請求。

另外還有一個redis的 INCREXPIRE 的原子性問題,容易造成 Race Condition,可以通過 SETNX 來解決

redis.set(key, 0, 'EX', option.window, 'NX')
複製程式碼

另外也可以通過一個 LUA 指令碼來搞定,顯然還是 SETNX 簡單些

local current
current = redis.call("incr",KEYS[1])
if tonumber(current) == 1 then
    redis.call("expire",KEYS[1],1)
end
複製程式碼

為了解決 2N 的問題,可以由維護一個計數器,更改為維護一個佇列。代價是記憶體佔用空間過高,且更難解決 Race Condition

以下是使用 redis 的 set/get string 實現的限流

const option = {
  max: 10,        // window 時間內限速10個請求
  window: 1000    // 1s
}

function access(req) {
  // 根據請求生成唯一標誌
  const key = identity(req)
  const current = Date.now()
  // cache 視為快取物件
  // 篩選出當前時間視窗的請求個數,每個請求標誌為時間戳的格式
  // 為了簡單這裡不做 json 的序列化和反序列化了...
  const timestamps = [current].concat(redis.get('timestamps')).filter(ts => ts + option.window > current)
  if (timestamps.length > option.max) {
    return false 
  }
  // 此時讀寫不同步,會有 Race Condition 問題
  redis.set('timestamps', timestamps, 'EX', option.window)
  return true
}
複製程式碼

這裡再使用一個 LUA 指令碼解決 Race Condition 的問題

TODO

Token Bucket (令牌桶演算法)

令牌演算法

由圖先看一看令牌桶與漏桶的不同

  1. 令牌桶初始狀態 bucket 是滿的,漏桶初始狀態 bucket 是空的
  2. 令牌桶在 bucket 空的時候拒絕新的請求,漏桶在 bucket 滿的時候拒絕新的請求
  3. 當一個請求來臨時,假設一個請求消耗一個token,令牌桶的 bucket 減少一個 token,漏桶增加一個 token

以下使用 redis 實現令牌桶

TODO

相關文章