關於限流實現的思考

pggsnap發表於2019-03-15

在基於 Spring Cloud 實現的微服務架構下,需要在閘道器處新增限流功能:比如對指定 ip 地址訪問具體介面時限制訪問頻率為 100次/s。

總的原則是:在滿足需求的基礎上,實現簡單、易於維護。

整個平臺的基礎架構如下:

nginx -> [gateway1, gateway2, …] -> [serviceA1, serviceA2, serviceB1, …]

1. 基於記憶體的單機限流

A:首先考慮基於記憶體的單機限流,其優點主要是實現簡單,效能好;

Q:然而為了提高系統的可用性和效能,我需要部署多個閘道器例項,多個例項之間無法共享記憶體;

A:假設制定了一個限流策略為:對介面 A 限制訪問頻率為 100次/s,在部署 2 個閘道器並且 nginx 上設定了負載均衡的情況下,每個閘道器上限制訪問頻率為每秒 50 次,也能基本滿足需求。

Q:但如果我現在需要再新增一個閘道器例項,或者已部署的 2 個閘道器例項掛了一個,就無法滿足原先制定的限流策略了。

A:在這種情況下,需要有一種機制可以感知到所有的閘道器服務是否正常。既然是基於 Spring Cloud 平臺,肯定會有一個服務的註冊中心。以 consul 為例,可以把限流策略儲存到 consul 的 key/value 儲存上。按照某個頻次(比如每 30s)呼叫一次註冊中心的介面,閘道器可以感知到目前狀態正常的所有閘道器例項的數量(假設為 n),動態調整自己的限流策略為每秒 100/n 次即可。

Q:在閘道器例項新增或者異常掛掉的情況下,以上實現會有一小段時間(比如 30s)限流策略不準確。不過考慮到這種異常情況比較少出現,並且這個時間可以設定的更短,如果要求不那麼嚴格的話倒不是個問題。

Q:還有一個問題是這種實現是依賴於請求在各個閘道器上的分配比例的。比如 nginx 上配置轉發請求時,閘道器 1 的權重為 3,閘道器 2 的權重為 1,閘道器 3 的權重為 1,那麼相應的,閘道器 1 的策略需要設定為每秒限制最多訪問 60 次,閘道器 2 和閘道器 3 為每秒 20 次。即閘道器的限流策略和 nginx 的配置也有繫結了,這種設計不合理。另外如果此時閘道器 3 異常掛掉,閘道器 1 和 2 如何調整各自的限流策略,也會變得比較複雜。

2. 分散式限流(限流功能作為單獨的 RPC 服務)

A:把限流功能封裝成一個單獨的 RPC 服務。當閘道器接收到請求之後,先通過限流服務提供的介面查詢,根據返回結果決定放行還是拒絕。

Q:這種實現方式,首先需要部署一個限流服務,增加了運維成本;另外,每個請求會多一次網路開銷(閘道器訪問限流服務),所以效能瓶頸很可能會出現在閘道器與限流服務之間的 RPC 通訊上。如果限流功能提供的是普通的 http 介面,估計效能會不理想;如果提供的是二進位制協議的介面(比如 thrift),那麼閘道器會有一些程式碼改寫工作(畢竟是基於 Spring Cloud 和 WebFlux 開發的)。

總的來說,這是一種值得嘗試的實現。阿里巴巴開源限流系統 Sentinel 同時實現了分散式限流和基於記憶體的限流,感覺是個不錯的選擇。(看了下大概介紹,沒有深入研究)

3.基於 redis 的分散式限流

A:利用 redis 的單執行緒特性以及 lua 指令碼,實現分散式限流。多個閘道器的請求訪問 redis 時,在 redis 內部還是順序執行,不存在併發的問題;單個請求會涉及到多次 redis 操作,以令牌桶演算法為例:獲取當前令牌數量,獲取上次獲取令牌的時間,更新時間以及令牌數量等,可以通過 lua 指令碼保證原子性,同時也減少了閘道器多次訪問 redis 的網路開銷。

這裡的關鍵在於 lua 指令碼,Spring Cloud.Greenwich 版本中 spring-cloud-gateway 有個限流過濾器,其 lua 指令碼如下:

local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]

local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local fill_time = capacity/rate
local ttl = math.floor(fill_time*10)

-- 當前令牌的數量
local last_tokens = tonumber(redis.call("get", tokens_key))
if last_tokens == nil then
  last_tokens = capacity
end

-- 上次取令牌的時間
local last_refreshed = tonumber(redis.call("get", timestamp_key))
if last_refreshed == nil then
  last_refreshed = 0
end

local delta = math.max(0, now-last_refreshed)
-- 新增令牌 delta*rate,更新令牌數量
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
local allowed = filled_tokens >= requested
local new_tokens = filled_tokens
local allowed_num = 0
if allowed then
  new_tokens = filled_tokens - requested
  allowed_num = 1
end

-- 更新 redis 中令牌數量和時間
redis.call("setex", tokens_key, ttl, new_tokens)
redis.call("setex", timestamp_key, ttl, now)

return { allowed_num, new_tokens }

複製程式碼

Q:在實際測試中,如果只啟用 1 個閘道器例項時沒有問題;如果啟用多個閘道器例項,發現實際限流不準,最終定位到原因為:啟用閘道器的多臺伺服器時間不同步。

A:在令牌桶中按照特定速率新增令牌時,公式為:速率*(當前時間-上次新增令牌的時間),而當前時間這個值是由閘道器傳過去的,如果多臺閘道器所在的伺服器時間不準,那麼這個指令碼的邏輯就不對了。一種方法是永遠確保時間同步,而這幾乎是不可能做到的;另外一種方法是採用 redis 伺服器的時間,即把第 6 行程式碼 local now = tonumber(ARGV[3])修改為:local now = redis.call("time")[1]

注意:

Redis 設計與實現:Lua 指令碼中提到:在 lua 指令碼中,不應該設定隨機值。以下為相關內容:

當將 Lua 指令碼複製到附屬節點, 或者將 Lua 指令碼寫入 AOF 檔案時, Redis 需要解決這樣一個問題: 如果一段 Lua 指令碼帶有隨機性質或副作用, 那麼當這段指令碼在附屬節點執行時, 或者從 AOF 檔案載入重新執行時, 它得到的結果可能和之前執行的結果完全不同。

考慮以下一段程式碼, 其中的 get_random_number() 帶有隨機性質, 我們在伺服器 SERVER 中執行這段程式碼, 並將隨機數的結果儲存到鍵 number 上:

# 虛構例子,不會真的出現在指令碼環境中
redis> EVAL "return redis.call('set', KEYS[1], get_random_number())" 1 number
OK
redis> GET number
"10086"
複製程式碼

現在, 假如 EVAL 的程式碼被複制到了附屬節點 SLAVE , 因為 get_random_number() 的隨機性質, 它有很大可能會生成一個和 10086 完全不同的值, 比如 65535

# 虛構例子,不會真的出現在指令碼環境中
redis> EVAL "return redis.call('set', KEYS[1], get_random_number())" 1 number
OK
redis> GET number
"65535"
複製程式碼

可以看到, 帶有隨機性的寫入指令碼產生了一個嚴重的問題: 它破壞了伺服器和附屬節點資料之間的一致性。

當從 AOF 檔案中載入帶有隨機性質的寫入指令碼時, 也會發生同樣的問題。

只有在帶有隨機性的指令碼進行寫入時, 隨機性才是有害的。

如果一個指令碼只是執行只讀操作, 那麼隨機性是無害的。比如說, 如果指令碼只是單純地執行 RANDOMKEY 命令, 那麼它是無害的; 但如果在執行 RANDOMKEY 之後, 基於 RANDOMKEY 的結果進行寫入操作, 那麼這個指令碼就是有害的。

和隨機性質類似, 如果一個指令碼的執行對任何副作用產生了依賴, 那麼這個指令碼每次執行所產生的結果都可能會不一樣。

為了解決這個問題, Redis 對 Lua 環境所能執行的指令碼做了一個嚴格的限制 —— 所有指令碼都必須是無副作用的純函式(pure function)。

為此,Redis 對 Lua 環境做了一些列相應的措施:

  • 不提供訪問系統狀態狀態的庫(比如系統時間庫)。
  • 禁止使用 loadfile 函式。
  • 如果指令碼在執行帶有隨機性質的命令(比如 RANDOMKEY ),或者帶有副作用的命令(比如 TIME )之後,試圖執行一個寫入命令(比如 SET ),那麼 Redis 將阻止這個指令碼繼續執行,並返回一個錯誤。
  • 如果指令碼執行了帶有隨機性質的讀命令(比如 SMEMBERS ),那麼在指令碼的輸出返回給 Redis 之前,會先被執行一個自動的字典序排序,從而確保輸出結果是有序的。
  • 用 Redis 自己定義的隨機生成函式,替換 Lua 環境中 math 表原有的 math.random 函式和 math.randomseed 函式,新的函式具有這樣的性質:每次執行 Lua 指令碼時,除非顯式地呼叫 math.randomseed ,否則 math.random 生成的偽隨機數序列總是相同的。

經過這一系列的調整之後, Redis 可以保證被執行的指令碼:

  1. 無副作用。
  2. 沒有有害的隨機性。
  3. 對於同樣的輸入引數和資料集,總是產生相同的寫入命令。

然後,我實際測試了下卻發現並沒有報錯?!

10.201.0.30:6379> eval "local now = redis.call('time')[1]; return redis.call('set', 'time-test', now)" 0
OK
10.201.0.30:6379> get time-test
"1552628054"
複製程式碼

於是檢視官方文件:

redis.io/commands/ev…

Note: starting with Redis 5, the replication method described in this section (scripts effects replication) is the default and does not need to be explicitly enabled.

Starting with Redis 3.2, it is possible to select an alternative replication method. Instead of replication whole scripts, we can just replicate single write commands generated by the script. We call this script effects replication.

In this replication mode, while Lua scripts are executed, Redis collects all the commands executed by the Lua scripting engine that actually modify the dataset. When the script execution finishes, the sequence of commands that the script generated are wrapped into a MULTI / EXEC transaction and are sent to replicas and AOF.

This is useful in several ways depending on the use case:

  • When the script is slow to compute, but the effects can be summarized by a few write commands, it is a shame to re-compute the script on the replicas or when reloading the AOF. In this case to replicate just the effect of the script is much better.
  • When script effects replication is enabled, the controls about non deterministic functions are disabled. You can, for example, use the TIMEor SRANDMEMBER commands inside your scripts freely at any place.
  • The Lua PRNG in this mode is seeded randomly at every call.

In order to enable script effects replication, you need to issue the following Lua command before any write operated by the script:

redis.replicate_commands()
複製程式碼

The function returns true if the script effects replication was enabled, otherwise if the function was called after the script already called some write command, it returns false, and normal whole script replication is used.

簡單的說就是:從 Redis 3.2 開始,在 redis 主從複製中或者寫入 AOF 檔案時,新增了一個基於效果的複製方式。我們可以只複製指令碼生成的單個寫入命令,而不是複製整個指令碼,這樣的話,也就意味著在 lua 指令碼中可以設定隨機值了,比如系統時間。Redis 5 版本以上,預設採用的就是這種複製方式。

相關文章