關於限流實現的思考

在基於 Spring Cloud 實現的微服務架構下,須要在網關處新增限流功能:好比對指定 ip 地址訪問具體接口時限制訪問頻率爲 100次/s。html

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

整個平臺的基礎架構以下:redis

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

1. 基於內存的單機限流

A:首先考慮基於內存的單機限流,其優勢主要是實現簡單,性能好;spring

Q:然而爲了提升系統的可用性和性能,我須要部署多個網關實例,多個實例之間沒法共享內存;bash

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 版本以上,默認採用的就是這種複製方式。

相關文章
相關標籤/搜索