限流分為單機和分布式兩種,單機限流是指限定當前進程里面的某個代碼片段的 QPS 或者 並發線程數 或者 整個機器負載指數,一旦超出規則配置的數值就會拋出異常或者返回 false。我把這里的被限流的代碼片段稱為「臨界區」
而分布式則需要另啟一個集中的發票服務器,這個服務器針對每個指定的資源每秒只會生成一定量的票數,在執行臨界區的代碼之前先去集中的發票服務領票,如果領成功了就可以執行,否則就會拋出限流異常。所以分布式限流代價較高,需要多一次網絡讀寫操作。如果讀者閱讀了我的小冊《Redis 深度歷險》,里面就提到了 Redis 的限流模塊,Sentinel 限流的原理和它是類似的,只不過 Sentinel 的發票服務器是自研的,使用了 Netty 框架。
Sentinel 在使用上提供了兩種形式,一種是異常捕獲形式,一種是布爾形式。也就是當限流被觸發時,是拋出異常來還是返回一個 false。下面我們看看它的異常捕獲形式,這是單機版
import com.alibaba.csp.sentinel.Entry; import com.alibaba.csp.sentinel.SphU; import com.alibaba.csp.sentinel.slots.block.BlockException; public class SentinelTest { public static void main(String[] args) { // 配置規則 List<FlowRule> rules = new ArrayList<>(); FlowRule rule = new FlowRule(); rule.setResource("tutorial"); // QPS 不得超出 1 rule.setCount(1); rule.setGrade(RuleConstant.FLOW_GRADE_QPS); rule.setLimitApp("default"); rules.add(rule); // 加載規則 FlowRuleManager.loadRules(rules); // 下面開始運行被限流作用域保護的代碼 while (true) { Entry entry = null; try { entry = SphU.entry("tutorial"); System.out.println("hello world"); } catch (BlockException e) { System.out.println("blocked"); } finally { if (entry != null) { entry.exit(); } } try { Thread.sleep(500); } catch (InterruptedException e) {} } } }
使用 Sentinel 需要我們提供限流規則,在規則的基礎上,將臨界區代碼使用限流作用域結構包裹起來。在上面的例子中限定了 tutorial 資源的單機 QPS 不得超出 1,但是實際上它的運行 QPS 是 2,這多出來的執行邏輯就會被限制,對應的 Sphu.entry() 方法就會拋出限流異常 BlockException
我們再看看它的 bool 形式,使用也是很簡單,大同小異。
import java.util.ArrayList; import java.util.List; import com.alibaba.csp.sentinel.SphO; import com.alibaba.csp.sentinel.slots.block.RuleConstant; import com.alibaba.csp.sentinel.slots.block.flow.FlowRule; import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager; public class SentinelTest { public static void main(String[] args) { // 配置規則 List<FlowRule> rules = new ArrayList<>(); FlowRule rule = new FlowRule(); rule.setResource("tutorial"); // QPS 不得超出 1 rule.setCount(1); rule.setGrade(RuleConstant.FLOW_GRADE_QPS); rule.setLimitApp("default"); rules.add(rule); FlowRuleManager.loadRules(rules); // 運行被限流作用域保護的代碼 while (true) { if (SphO.entry("tutorial")) { try { System.out.println("hello world"); } finally { SphO.exit(); } } else { System.out.println("blocked"); } try { Thread.sleep(500); } catch (InterruptedException e) {} } } }
規則控制
上面的例子中規則都是通過代碼寫死的,在實際的項目中,規則應該需要支持動態配置。這就需要有一個規則配置源,它可以是 Redis、Zookeeper 等數據庫,還需要有一個規則變更通知機制和規則配置后台,允許管理人員可以在后台動態配置規則並實時下發到業務服務器進行控制
有一些規則源存儲不支持事件通知機制,比如關系數據庫,Sentinel 也提供了定時刷新規則,比如每隔幾秒來刷新內存里面的限流規則。下面是 redis 規則源定義
// redis 地址 RedisConnectionConfig redisConf = new RedisConnectionConfig("localhost", 6379, 1000); // 反序列化算法 Converter<String, List<FlowRule>> converter = r -> JSON.parseArray(r, FlowRule.class); // 定義規則源,包含全量和增量部分 // 全量是一個字符串key,增量是 pubsub channel key ReadableDataSource<String, List<FlowRule>> redisDataSource = new RedisDataSource<List<FlowRule>>(redisConf,"app_key", "app_pubsub_key", converter); FlowRuleManager.register2Property(redisDataSource.getProperty());
健康狀態上報與檢查
接入 Sentinel 的應用服務器需要將自己的限流狀態上報到 Dashboard,這樣就可以在后台實時呈現所有服務的限流狀態。Sentinel 使用拉模型來上報狀態,它在當前進程注冊了一個 HTTP 服務,Dashboard 會定時來訪問這個 HTTP 服務來獲取每個服務進程的健康狀況和限流信息
Sentinel 需要將服務的地址以心跳包的形式上報給 Dashboard,如此 Dashboard 才知道每個服務進程的 HTTP 健康服務的具體地址。如果進程下線了,心跳包就停止了,那么對應的地址信息也會過期,如此Dashboard 就能准實時知道當前的有效進程服務列表
當前版本開源的 Dashboard 不具備持久化能力,當管理員在后台修改了規則時,它會直接通過 HTTP 健康服務地址來同步服務限流規則直接控制具體服務進程。如果應用重啟,規則將自動重置。如果你希望通過 Redis 來持久化規則源,那就需要自己定制 Dashboard。定制不難,實現它內置的持久化接口即可
分布式限流
前面我們說到分布式限流需要另起一個 Ticket Server,由它來分發 Ticket,能夠獲取到 Ticket 的請求才可以允許執行臨界區代碼,Ticket 服務器也需要提供規則輸入源
Ticket Server 是單點的,如果 Ticket Server 掛掉了,應用服務器限流將自動退化為本地模式
框架適配
Sentinel 保護的臨界區是代碼塊,通過拓展臨界區的邊界就可以直接適配各種框架,比如 Dubbo、SpringBoot 、GRPC 和消息隊列等。每一種框架的適配器會在請求邊界處統一定義臨界區作用域,用戶就可以完全不必手工添加熔斷保護性代碼,在毫無感知的情況下就自動植入了限流保護功能
熔斷降級
限流在於限制流量,也就是 QPS 或者線程的並發數,還有一種情況是請求處理不穩定或者服務損壞,導致請求處理時間過長或者老是頻繁拋出異常,這時就需要對服務進行降級處理。所謂的降級處理和限流處理在形式上沒有明顯差異,也是以同樣的形式定義一個臨界區,區別是需要對拋出來的異常需要進行統計,這樣才可以知道請求異常的頻率,有了這個指標才會觸發降級
// 定義降級規則 List<DegradeRule> rules = new ArrayList<>(); DegradeRule rule = new DegradeRule(); rule.setResource("tutorial"); // 5s內異常不得超出10 rule.setCount(10); rule.setGrade(RuleConstant.DEGRADE_GRADE_EXCEPTION_COUNT); rule.setLimitApp("default"); rules.add(rule); DegradeRuleManager.loadRules(rules); Entry entry = null; try { entry = SphU.entry(key); // 業務代碼在這里 } catch (Throwable t) { // 記錄異常 if (!BlockException.isBlockException(t)) { Tracer.trace(t); } } finally { if (entry != null) { entry.exit(); } }
觸發限流時會拋出 FlowException,觸發熔斷時會拋出 DegradeException,這兩個異常都繼承自 BlockException
熱點限流
還有一種特殊的動態限流規則,用於限制動態的熱點資源。內部采用 LRU 算法計算出 topn 熱點資源,然后對 topn 的資源進行限流,同時還提供特殊資源特殊對待的參數設置。
比如在下面的例子中限定了同一個用戶的訪問頻次,同時也限定了同一本書的訪問頻次,但是對於某個特殊用戶和某個特殊的書進行了特殊的頻次設置
ParamFlowRule ruleUser = new ParamFlowRule(); // 同樣的 userId QPS 不得超過 10 ruleUser.setParamIdx(0).setCount(10); // qianwp用戶特殊對待,QPS 上限是 100 ParamFlowItem uitem = new ParamFlowItem("qianwp", 100, String.class); ruleUser.setParamFlowItemList(Collections.singletonList(uitem)); ParamFlowRule ruleBook = new ParamFlowRule(); // 同樣的 bookId QPS 不得超過 20 ruleBook.setParamIdx(1).setCount(20); // redis 的書特殊對待,QPS 上限是 100 ParamFlowItem bitem = new ParamFlowItem("redis", 100, String.class); ruleBook.setParamFlowItemList(Collections.singletonList(item)); // 加載規則 List<ParamFlowRule> rules = new ArrayList<>(); rules.add(ruleUser); rules.add(ruleBook); ParamFlowRuleManager.loadRules(rules); // userId的用戶訪問bookId的書 Entry entry = Sphu.entry(key, EntryType.IN, 1, userId, bookId);
熱點限流的難點在於如何統計定長滑動窗口時間內的熱點資源的訪問量,Sentinel 設計了一個特別的數據結構叫 LeapArray,內部有較為復雜的算法設計后續需要單獨分析
系統自適應限流 —— 過載保護
當系統的負載較高時,為了避免系統被洪水般的請求沖垮,需要對當前的系統進行限流保護。保護的方式是逐步限制 QPS,觀察到系統負載恢復后,再逐漸放開 QPS,如果系統的負載又下降了,就再逐步降低 QPS。如此達到一種動態的平衡,這里面涉及到一個特殊的保持平衡的算法。系統的負載指數存在一個問題,它取自操作系統負載的 load1 參數,load1 參數更新的實時性不足,從 load1 超標到恢復的過程存在一個較長的過渡時間,如果使用一刀切方案,在這段恢復時間內阻止任何請求,待 load1 恢復后又立即放開請求,勢必會導致負載的大起大落,服務處理的時斷時開。為此作者將 TCP 擁塞控制算法的思想移植到這里實現了系統平滑的過載保護功能。這個算法很精巧,代碼實現並不復雜,效果卻是非常顯著
算法定義了一個穩態公式,穩態一旦打破,系統負載就會出現波動。算法的本質就是當穩態被打破時,通過持續調整相關參數來重新建立穩態
穩態公式很簡單:ThreadNum * (1/ResponseTime) = QPS,這個公式很好理解,就是系統的 QPS 等於線程數乘以單個線程每秒可以執行的請求數量。系統會實時采樣統計所有臨界區的 QPS 和 ResponseTime,就可以計算出相應的穩態並發線程數。當負載超標時,通過判定當前的線程數是否超出穩態線程數就可以明確是否需要拒絕當前的請求
定義自適應限流規則需要提供多個參數
- 系統的負載水平線,超過這個值時觸發過載保護功能
- 當過載保護超標時,允許的最大線程數、最長響應時間和最大 QPS,可以不設置
List<SystemRule> rules = new ArrayList<SystemRule>(); SystemRule rule = new SystemRule(); rule.setHighestSystemLoad(3.0); rule.setAvgRt(10); rule.setQps(20); rule.setMaxThread(10); rules.add(rule); SystemRuleManager.loadRules(Collections.singletonList(rule));
從代碼中也可以看出系統自適應限流規則不需要定義資源名稱,因為它是全局的規則,會自動應用到所有的臨界區。如果當負載超標時,所有臨界區資源將一起勒緊褲腰帶渡過難關
參考:https://zhuanlan.zhihu.com/p/53641388
作者:老錢