如何設計並實現存儲QoS?

1. 資源搶佔問題

隨着存儲架構的調整,衆多應用服務會運行在同一資源池中,對外提供統一的存儲能力。資源池內部可能存在多種流量類型,如上層業務的IO流量、存儲內部的數據遷移、修復、壓縮等,不一樣的流量經過競爭的方式肯定下發到硬件的IO順序,所以沒法確保某種流量IO服務質量,好比內部數據遷移流量可能佔用過多的帶寬影響業務流量讀寫,致使存儲對外提供的服務質量降低,因爲資源競爭結果的不肯定性沒法保障存儲對外能提供穩定的集羣環境。css

以下面交通圖所示,車輛逆行、加塞隨心隨遇,行人橫穿、閒聊肆無忌憚,最終出現交通擁堵甚至安全事故。ios

2. 如何解決資源搶佔

類比上一幅交通圖,如何規避這樣的現象你們可能都有本身的一些見解,這裏先引入兩個名詞web

  • QoS,即服務質量,根據不一樣服務類型的不一樣需求提供端到端的服務質量。算法

  • 存儲QoS,在保障服務帶寬與IOPS的狀況下,合理分配存儲資源,有效緩解或控制應用服務對資源的搶佔,實現流量監控、資源合理分配、重要服務質量保證以及內部流量規避等效果,是存儲領域必不可少的一項關鍵技術。swift

那麼QoS應該怎麼去作呢?下面仍是結合交通的例子進行介紹說明。緩存

2.1 流量分類

從前面的圖咱們看到無論是什麼車,都以自我爲中心,不受任何約束,咱們首先能先到的辦法是對道路進行分類劃分,好比分爲公交車專用車道、小型車專用車道、大貨車專用車道、非機動車道以及人行橫道等,正常狀況下公交車車道只容許公交車運行,而非機動車道上是不容許出現機動車的,這樣咱們能夠保證車道與車道之間不受制約干擾。安全

一樣,存儲內部也會有不少流量,咱們能夠爲不一樣的流量類型分配不一樣的 「車道」,好比業務流量的車道咱們劃分寬一些,而內部壓縮流量的車道相對來講能夠窄一些,由此引入了QoS中一個比較重要的概覽就是流量分類,根據分類結果能夠進行更加精準個性化的限流控制。微信

2.2 流量優先級

僅僅依靠分類是不行的,由於總有一些特殊狀況,好比急救車救人、警車抓人等,咱們總不能說這個車道只能跑普通私家小轎車把,一些特殊車輛(救護車,消防車以及警車等)應該具備優先通行的權限。架構

對於存儲來講業務流量就是咱們的特殊車輛,咱們須要保證業務流量的穩定性,好比業務流量的帶寬跟IOPS不受限制,而內部流量如遷移、修復則須要限定其帶寬或者IOPS,爲其分配固定的「車道」。在資源充足的狀況下,內部流量能夠安安靜靜的在本身的車道上行駛,可是當資源緊張,好比業務流量突增或者持續性的高流量水位,這個時候須要限制內部流量的道理寬度,極端狀況下能夠暫停。固然,若是內部流量都停了仍是不能知足正常業務流量的讀寫需求,這個時候就須要考慮擴容的事情了。併發

QoS中另一個比較重要的概念就是優先級劃分,在資源充足的狀況下執行預分配資源策略,當資源緊張時對優先級低的服務資源進行動態調整,進行適當的規避或者暫停,在必定程度上能夠彌補預分配方案的不足。

2.3 流量監控

前面提到當資源不足時,咱們能夠動態的去調整其餘流量的閾值,那咱們如何知道資源不足呢?這個時候咱們是須要有個流量監控的組件。

咱們出行時常常會使用地圖,經過選擇合適的線路以最快到達目的地。通常線路會經過不一樣的顏色標記線路擁堵狀況,好比紅色表示堵車、綠色表示暢通。

存儲想要知道機器或者磁盤當前的流量狀況有兩種方式:

  • 統計機器負載狀況,好比咱們常常去機器上經過iostat命名查看各個磁盤的io狀況,這種方式與機器上的應用解耦,只關注機器自己

  • 統計各個應用下發的讀寫流量,好比某臺機器上部署了一個存儲節點應用,那咱們能夠統計這個應用下發下去的讀寫帶寬及IOPS

第二種方式相對第一種能夠實現應用內部更細的流量分類,好比前面提到的一個存儲應用節點,就包含了多種流量,咱們不能經過機器的粒度對全部流量統一限流。

3. 常見QoS限流算法

3.1 固定窗口算法

  • 按時間劃分爲多個限流窗口,好比1秒爲一個限流窗口大小;

  • 每一個窗口都有一個計數器,每經過一個請求計數器會加一;

  • 當計數器大小超過了限制大小(好比一秒內只能經過100個請求),則窗口內的其餘請求會被丟棄或排隊等待,等到下一個時間節點計數器清零再處理請求。

固定窗口算法的理想流量控制效果如上左側圖所示,假定設置1秒內容許的最大請求數爲100,那麼1秒內的最大請求數不會超過100。

可是大多數狀況下咱們會獲得右側的曲線圖,便可能會出現流量翻倍的效果。好比前T1~T2時間段沒有請求,T2~T3來了100個請求,所有經過。下一個限流窗口計數器清零,而後T3T4時間內來了100個請求,所有處理成功,這個時候時間段T4T5時間段就算有請求也是不能處理的,所以超過了設定閾值,最終T2~T4這一秒時間處理的請求爲200個,因此流量翻倍。

小結

  • 算法易於理解,實現簡單;

  • 流量控制不夠精細,容易出現流量翻倍狀況;

  • 適合流量平緩並容許流量翻倍的模型。

3.2 滑動窗口算法

前面提到固定窗口算法容易出現流量控制不住的狀況(流量翻倍),滑動窗口能夠認爲是固定窗口的升級版本,能夠規避固定窗口致使的流量翻倍問題。

  • 時間窗口被細分若干個小區間,好比以前一秒一個窗口(最大容許經過60個請求),如今一秒分紅3個小區間,每一個小區間最大容許經過20個請求;

  • 每一個區間都有一個獨立的計數器,能夠理解一個區間就是固定窗口算法中的一個限流窗口;

  • 當一個區間的時間用完,滑動窗口日後移動一個分區,老的分區(T1~T2)被丟棄,新的分區(T4~T5)加入滑動窗口,如圖所示。


小結

  • 流量控制更加精準,解決了固定窗口算法致使的流量翻倍問題;

  • 區間劃分粒度不易肯定,粒度過小會增長計算資源,粒度太大又會致使總體流量曲線不夠平滑,使得系統負載忽高忽低;

  • 適合流量較爲穩定,沒有大量流量突增模型。

3.3 漏斗算法

  • 全部的水滴(請求)都會先通過「漏斗」存儲起來(排隊等待);

  • 當漏斗滿了以後,多餘的水會被丟棄或者進入一個等待隊列中;

  • 漏斗的另一端會以一個固定的速率將水滴排出。

對於漏斗而言,他不清楚水滴(請求)何時會流入,可是總能保證出水的速度不會超過設定的閾值,請求老是以一個比較平滑的速度被處理,如圖所示,系統通過漏斗算法限流以後,流量能保證在一個恆定的閾值之下。

小結

  • 穩定的處理速度,能夠達到整流的效果,主要對下游的系統起到保護做用;

  • 沒法應對流量突增狀況,全部的請求通過漏斗都會被削緩,所以不適合有流量突發的限流場景;

  • 適合沒有流量突增或想達到流量整合以固定速率處理的模型。

3.4 令牌桶算法

令牌桶算法是漏斗算法的一種改進,主要解決漏斗算法不能應對流量突發的場景

  • 以固定的速率產生令牌並投入桶中,好比一秒投放N個令牌;

  • 令牌桶中的令牌數若是大於令牌桶大小M,則多餘的令牌會被丟棄;

  • 全部請求到達時,會先從令牌桶中獲取令牌,拿到令牌則執行請求,若是沒有獲取到令牌則請求會被丟棄或者排隊等待下一次嘗試獲取令牌。

如圖所示,假設令牌投放速率爲100/s,桶能存放最大令牌數200,當請求速度大於另外投放速率時,請求會被限制在100/s。若是某段時間沒有請求,這個時候令牌桶中的令牌數會慢慢增長直到200個,這是請求能夠一次執行200,即容許設定閾值內的流量併發。

小結

  • 流量平滑;

  • 容許特定閾值內的流量併發;

  • 適合整流並容許必定程度流量突增的模型。

就單純的以算法而言,沒有哪一個算法最好或者最差的說法,須要結合實際的流量特徵以及系統需求等因素選擇最合適的算法。

4、存儲QoS設計及實現

4.1 需求

通常而言一臺機器會至少部署一個存儲節點,節點負責多塊磁盤的讀寫請求,而存儲請求由分爲多種類型,好比正常業務的讀寫流量、磁盤損壞的修復流量、數據刪除出現數據空洞後的空間壓縮流量以及多爲了下降多副本存儲成本的糾刪碼(EC)遷移流量等等,不一樣流量出如今同一個存儲節點會相互競爭搶佔系統資源,爲了更好的保證業務服務質量,須要對流量的帶寬以及IOPS進行限制管控,好比須要知足如下條件:

  • 能夠同時限制流量的帶寬跟IOPS,單獨的帶寬或者IOPS限制都會致使另一個參數不受控制而影響系統穩定性,好比只控制了帶寬,可是沒有限制IOPS,對於大量小IO的場景就會致使機器的ioutil太高;

  • 能夠實現磁盤粒度的限流,避免機器粒度限流致使磁盤流量過載,好比圖所示,ec流量限制節點的帶寬最大值爲10Mbps,預期效果是想每塊磁盤分配2Mbps,可是頗有可能這10Mbps所有分配到了第一個磁盤;

  • 能夠支持流量分類控制,根據不一樣的流量特性設置不一樣的限流參數,好比業務流量是咱們須要重點保護的,所以不能對業務流量進行限流,而EC、壓縮等其餘流量均爲內部流量,能夠根據其特性配置合適的限流閾值;

  • 能夠支持限流閾值的動態適配,因爲業務流量不能進行流控,對於系統而言就像一匹「脫繮野馬」,可能突增、突減或持續高峯,針對突增或持續高峯的場景系統須要儘量的爲其分配資源,這就意味着須要對內部流量的限流閾值進行動態的打壓設置是暫停規避。

4.2 算法選擇

前面提到了QoS的算法有不少,這裏咱們結合實際需求選擇滑動窗口算法,主要有如下緣由:

  • 系統須要控制內部流量而內部流量相對比較穩定平緩;

  • 能夠避免流量突發狀況而影響業務流量;

QoS組件除了滑動窗口,還須要添加一個緩存隊列,當請求被限流以後不能被丟棄,須要添加至緩存隊列中,等待下一個時間窗口執行,以下圖所示。

4.3 帶寬與IOPS同時限制

爲了實現帶寬與IOPS的同時控制,QoS組件將由兩部分組成:IOPS控制組件負責控制讀寫的IOPS,帶寬控制組件負責控制讀寫的帶寬,帶寬控制跟IOPS控制相似,好比帶寬限制閾值爲1Mbps,那麼表示一秒最多隻能讀寫1048576Bytes大小數據;假定IOPS限制爲20iops,表示一秒內最多隻能發送20次讀寫請求,至於每次讀寫請求的大小並不關心。

兩個組件內部相互隔離,總體來看又相互影響,好比當IOPS控制很低時,對應的帶寬可能也會較小,而當帶寬控制很小時對應的IOPS也會比較小。

下面以修復流量爲例,分三組進行測試

  1. 第一組:20iops-1Mbps

  2. 第二組:40iops-2Mbps

  3. 第三組:80iops-4Mbps

測試結果如上圖所示,從圖中能夠看到qos模塊能控制流量的帶寬跟iops維持在設定閾值範圍內。

4.4 流量分類限制

爲了區分不一樣的流量,咱們對流量進行標記分類,併爲不一樣磁盤上的不一樣流量都初始化一個QoS組件,QoS組件之間相互獨立互不影響,最終能夠達到磁盤粒度的帶寬跟IOPS控制。

4.5 動態閾值調整

前面提到的QoS限流方案,雖然可以很好的控制內部流量帶寬或者IOPS在閾值範圍內, 可是存在如下不足

  • 不感知業務流量現狀,當業務流量突增或者持續高峯時,內部流量與業務流量仍然會存在資源搶佔,不能達到流量規避或暫停效果。

  • 磁盤上不一樣流量的限流相互獨立,當磁盤的總體流量帶寬或者IOPS過載時,內部流量閾值不能動態調低也會影響業務流量的服務質量。

因此須要對QoS組件進行必定的改進,增長流量監控組件,監控組件主要監控不一樣流量類型的帶寬與IOPS,動態QoS限流方案支持如下功能:

  • 經過監控組件獲取流量增加率,若是出現流量突增,則動態調低滑動窗口閾值以下降內部流量;當流量恢復平緩,恢復滑動窗口最初閾值以充分利用系統資源。

  • 經過監控組件獲取磁盤總體流量,當總體流量大小超過設定閾值,則動態調低滑動窗口大小;當總體流量大小低於設定閾值,則恢復滑動窗口至初始閾值。

下面設置磁盤總體流量閾值2Mbps-40iops,ec流量的閾值爲10Mbps-600iops

當磁盤總體流量達到磁盤閾值時會動態調整其餘內部流量的閾值,從測試結果能夠看到ec的流量受動態閾值調整存在一些波動,磁盤總體流量下去以後ec流量閾值又會恢復到最初閾值(10Mbps-600iops),可是能夠看到總體磁盤的流量並無控制在2Mbps-40iops如下,而是在這個範圍上下波動,因此咱們在初始化時須要保證設置的內部流量閾值小於磁盤的總體流量閾值,這樣才能達到比較穩定的內部流量控制效果。

4.6 僞代碼實現

前面提到存儲QoS主要是限制讀寫的帶寬跟IOPS,具體應該如何去實現呢?IO讀寫主要涉及如下幾個接口。

Read(p []byte) (n int, err error)ReadAt(p []byte, off int64) (n int, err error)Write(p []byte) (written int, err error)WriteAt(p []byte, off int64) (written int, err error)

因此這裏須要對上面幾個接口進行二次封裝,主要是加入限流組件。

帶寬控制組件實現

Read實現

// 假定c爲限流組件func (self *bpsReader) Read(p []byte) (n int, err error) {
size := len(p) size = self.c.assign(size) //申請讀取文件大小
n, err = self.underlying.Read(p[:size]) //根據申請大小讀取對應大小數據 self.c.fill(size - n) //若是讀取的數據大小小於申請大小,將沒有用掉的計數填充至限流窗口中 return}

Read限流以後會出現如下狀況

  • 讀取大小n<len(p)且err=nil,好比須要讀4K大小,可是當前時間窗口只能容許讀取3K,這個是被容許的

這裏也許你會想,Read限流的實現怎麼不弄個循環呢?如直到讀取指定大小數據才返回。這裏的實現咱們須要參考標準的IO的讀接口定義,其中有說明在讀的過程當中若是準備好的數據不足len(p)大小,這裏直接返回準備好的數據,而不是等待,也就是說標準的語義是支持只讀部分準備好的數據,所以這裏的限流實現保持一致。

// Reader is the interface that wraps the basic Read method.//// Read reads up to len(p) bytes into p. It returns the number of bytes// read (0 <= n <= len(p)) and any error encountered. Even if Read// returns n < len(p), it may use all of p as scratch space during the call.// If some data is available but not len(p) bytes, Read conventionally// returns what is available instead of waiting for more.// 省略//// Implementations must not retain p.type Reader interface { Read(p []byte) (n int, err error)}


ReadAt實現

下面介紹下ReadAt的實現,從接口的定義來看,可能以爲ReadAt與Read相差不大,僅僅是指定了數據讀取的開始位置,細心的小夥伴可能發現咱們這裏實現時多了一層循環,須要讀到指定大小數據或者出現錯誤才返回,相比Read而言ReadAt是不容許出現*n<len(p)且err==nil*的狀況

func (self *bpsReaderAt) ReadAt(p []byte, off int64) (n int, err error) { for n < len(p) && err == nil { var nn int nn, err = self.readAt(p[n:], off) off += int64(nn) n += nn } return}
func (self *bpsReaderAt) readAt(p []byte, off int64) (n int, err error) { size := len(p) size = self.c.assign(size) n, err = self.underlying.ReadAt(p[:size], off) self.c.fill(size - n) return}
// ReaderAt is the interface that wraps the basic ReadAt method.//// ReadAt reads len(p) bytes into p starting at offset off in the// underlying input source. It returns the number of bytes// read (0 <= n <= len(p)) and any error encountered.//// When ReadAt returns n < len(p), it returns a non-nil error// explaining why more bytes were not returned. In this respect,// ReadAt is stricter than Read.//// Even if ReadAt returns n < len(p), it may use all of p as scratch// space during the call. If some data is available but not len(p) bytes,// ReadAt blocks until either all the data is available or an error occurs.// In this respect ReadAt is different from Read.//省略//// Implementations must not retain p.type ReaderAt interface { ReadAt(p []byte, off int64) (n int, err error)}


Write實現

Write接口的實現相對比較簡單,循環寫直到寫完數據或者出現錯誤

func (self *bpsWriter) Write(p []byte) (written int, err error) { size := 0 for size != len(p) { p = p[size:] size = self.c.assign(len(p))
n, err := self.underlying.Write(p[:size]) self.c.fill(size - n) written += n if err != nil { return written, err } } return}

// Writer is the interface that wraps the basic Write method.//// Write writes len(p) bytes from p to the underlying data stream.// It returns the number of bytes written from p (0 <= n <= len(p))// and any error encountered that caused the write to stop early.// Write must return a non-nil error if it returns n < len(p).// Write must not modify the slice data, even temporarily.//// Implementations must not retain p.type Writer interface { Write(p []byte) (n int, err error)}


WriteAt實現

這裏的實現跟Write相似

func (self *bpsWriterAt) WriteAt(p []byte, off int64) (written int, err error) { size := 0 for size != len(p) { p = p[size:] size = self.c.assign(len(p))
n, err := self.underlying.WriteAt(p[:size], off) self.c.fill(size - n) off += int64(n) written += n if err != nil { return written, err } } return}

// WriterAt is the interface that wraps the basic WriteAt method.//// WriteAt writes len(p) bytes from p to the underlying data stream// at offset off. It returns the number of bytes written from p (0 <= n <= len(p))// and any error encountered that caused the write to stop early.// WriteAt must return a non-nil error if it returns n < len(p).//// If WriteAt is writing to a destination with a seek offset,// WriteAt should not affect nor be affected by the underlying// seek offset.//// Clients of WriteAt can execute parallel WriteAt calls on the same// destination if the ranges do not overlap.//// Implementations must not retain p.type WriterAt interface { WriteAt(p []byte, off int64) (n int, err error)}

IOPS控制組件實現

IOPS控制組件的實現跟帶寬相似,這裏就不詳細介紹了

Read接口實現
func (self *iopsReader) Read(p []byte) (n int, err error) { self.c.assign(1) //這裏只須要獲取一個計數,若是當前窗口一個都沒有,則會一直等待直到獲取到一個才喚醒執行下一步 n, err = self.underlying.Read(p) return}

ReadAt接口實現
func (self *iopsReaderAt) ReadAt(p []byte, off int64) (n int, err error) { self.c.assign(1) n, err = self.underlying.ReadAt(p, off) return}

想一想這裏的ReadAt爲啥不須要跟帶寬同樣循環讀了呢?


Write接口實現
func (self *iopsWriter) Write(p []byte) (written int, err error) { self.c.assign(1) written, err = self.underlying.Write(p) return}

WriteAt

func (self *iopsWriterAt) WriteAt(p []byte, off int64) (n int, err error) { self.c.assign(1) n, err = self.underlying.WriteAt(p, off) return}


☆ END ☆


招聘信息

OPPO聯網雲平臺團隊招聘一大波崗位,涵蓋Java、容器、Linux內核開發、產品經理、項目經理等多向,請在公衆號後臺回覆關鍵詞「雲招聘」查看查詳細信息。


你可能還喜歡

OPPO自研ESA DataFlow架構與實踐

數據同步一致性保障:OPPO自研JinS數據同步框架實踐

微服務全鏈路異步化實踐

Dubbo協議解析與ESA RPC實踐

自研代碼審查系統火眼Code Review實踐



更多技術乾貨

掃碼關注

OPPO互聯網技術

 

我就知道你「在看」

本文分享自微信公衆號 - OPPO互聯網技術(OPPO_tech)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索