Microservice - Availability

隔離

隔離,本質上是對系統或資源進行分割,從而實現當系統發生故障時能限定傳播範圍和影響範圍,即發生故障後只有出問題的服務不可用,保證其他服務仍然可用。

*服務隔離

服務隔離

動靜隔離:

小到 CPU 的 cacheline false sharing、數據庫 mysql 表設計中避免 bufferpool 頻繁過期,隔離動靜表, 大到架構設計中的圖片、靜態資源等緩存加速。 本質上都體現的一樣的思路,即加速/緩存訪問變換頻次小的。

比如 CDN 場景中,將靜態資源和動態 API 分離,也是體現了隔離的思路:

  • 降低應用服務器負載,靜態文件訪問負載全部通過CDN。
  • 對象存儲存儲費用最低。
  • 海量存儲空間,無需考慮存儲架構升級。
  • 靜態CDN帶寬加速,延遲低。

Separation-of-static-and-dynamic-sources

archive: 稿件表,存儲稿件的名稱、作者、分類、tag、狀態等信息,表示稿件的基本信息。 在一個投稿流程中,一旦稿件創建改動的頻率比較低。

archive_stat: 稿件統計表,表示稿件的播放、點贊、收藏、投幣數量,比較高頻的更新。 隨著稿件獲取流量,稿件被用戶所消費,各類計數信息更新比較頻繁。

MySQL BufferPool 是用於緩存 DataPage 的,DataPage 可以理解為緩存了表的行,那麼如果頻繁更新 DataPage 不斷會置換,會導致命中率下降的問題,所以我們在表設計中,仍然可以沿用類似的思路,其主表基本更新,在上游 Cache 未命中,透穿到 MySQL,仍然有 BufferPool 的緩存。 update

讀寫分離:主從、Replicaset、CQRS(Command Query Responsibility Segregation, 命令與查詢責任分離)

輕重隔離

核心隔離

業務按照 Level 進行資源池劃分(L0/L1/L2)。

  • 核心/非核心的故障域的差異隔離(機器資源、依賴資源)。
  • 多集群,通過冗餘資源來提升吞吐和容災能力

Separation-of-light-and-heavy

快慢隔離

我們可以把服務的吞吐想像為一個池,當突然洪流進來時,池子需要一定時間才能排放完, 這時候其他支流在池子裡待的時間取決於前面的排放能力,耗時就會增高,對小請求產生影響。co

日誌傳輸體系的架構設計中,整個流都會投放到一個 kafka topic 中(早期設計目的: 更好的順序IO), 流內會區分不同的 logid,logid 會有不同的 sink 端,它們之前會出現差速, 比如 HDFS 抖動吞吐下降,ES 正常水位,全局數據就會整體反壓。

  • 按照各種緯度隔離:sink、部門、業務、logid、重要性(S/A/B/C)。 業務日誌也屬於某個 logid,日誌等級就可以作為隔離通道。 Separation-of-fast-and-slow

熱點隔離

何為熱點(明星出軌)?熱點即經常訪問的數據。很多時候我們希望統計某個熱點數據中訪問頻次最高的 Top K數據,並對其訪問進行緩存。 比如:

  • 小表廣播(被動預熱): 從 remotecache 提升為 localcache,app 定時更新,甚至可以讓運營平台支持廣播刷新 localcache。 atomic.Value copy-on-write
  • 主動預熱: 比如直播房間頁高在線情況下bypass 監控主動防禦。 Cache-Warming

物理隔離

線程隔離

主要通過線程池進行隔離,也是實現服務隔離的基礎。把業務進行分類並交給不同的線程池進行處理,當某個線程池處理一種業務請求發生問題時,不會講故障擴散和影響到其他線程池,保證服務可用。

對於 Go 來說,所有 IO 都是 Nonblocking,且託管給了 Runtime,只會阻塞Goroutine,不阻塞 M (thread),我們只需要考慮 Goroutine 總量的控制,不需要線程模型語言的線程隔離

tomcat1 tomcat2 tomcat3

當信號量達到 maxConcurrentRequests 後, 再請求會觸發 fallback。 r4j fallback Java 除了線程池隔離,也有基於信號量的做法

當線程池到達 maxSize 後, 再請求會觸發 fallback 接口進行熔斷。 fallback2

進程隔離

容器化(docker),容器編排引擎(k8s)。 我們15年在 KVM 上部署服務;16年使用 Docker Swarm;17年遷移到 Kubernetes,到年底在線應用就全託管了, 之後很快在線應用彈性公有雲上線; 20年離線 Yarn 和 在線 K8s 做了在離線混部(錯峰使用),之後計劃彈性公有雲配合自建 IDC 做到離線的混合雲架構。 process

集群隔離

回顧 gRPC,我們介紹過多集群方案,即邏輯上是一個應用,物理上部署多套應用,通過 cluster 區分。

多活建設完畢後,我們應用可以劃分為: region.zone.cluster.appid

隔離 - Case Stduy

  • 早期轉碼集群被超大視頻攻擊,導致轉碼大量延遲。
  • 入口Nginx(SLB)故障,影響全機房流量入口故障。
  • 縮略圖服務,被大圖實時縮略吃完所有 CPU,導致正常的小圖縮略被丟棄,大量503。
  • 數據庫實例 cgroup 未隔離,導致大 SQL 引起的集體故障。
  • INFO 日誌量過大,導致異常 ERROR 日誌採集延遲。

超時控制

超時控制,我們的組件能夠快速失效(fail fast),因為我們不希望等到斷開的實例直到超時。 沒有什麼比掛起的請求和無響應的界面更令人失望。這不僅浪費資源,而且還會讓用戶體驗變得更差。 我們的服務是互相調用的,所以在這些延遲疊加前,應該特別注意防止那些超時的操作。

  • 網路傳遞具有不確定性。
  • 客戶端和服務端不一致的超時策略導致資源浪費。
  • "默認值"策略。
  • 高延遲服務導致 client 浪費資源等待,使用超時傳遞: 進程間傳遞 + 跨進程傳遞。

超時控制是微服務可用性的第一道關,良好的超時策略,可以盡可能讓服務不堆積請求,盡快清空高延遲的請求,釋放 Goroutine。 timeout

實際業務開發中,我們依賴的微服務的超時策略並不清楚,或者隨著業務迭代耗時超生了變化,意外的導致依賴者出現了超時。

  • 服務提供者定義好 latency SLO,更新到 gRPC Proto 定義中,服務後續迭代,都應保證 SLO。 SLO 避免出現意外的默認超時策略,或者意外的配置超時策略。

SRE 必修課:一次搞懂 SLI、SLO、SLA 差異,Google DevOps 理念實踐

  1. 服務水準指標 (Service-Level Indicator, SLI)
  2. 服務水準目標 (Service-Level Objective, SLO)
  3. 服務水準協議 (Service-Level Agreement, SLA)

  4. kit 基礎庫兜底默認超時,比如 100ms,進行配置防禦保護,避免出現類似 60s 之類的超大超時策略。

  5. 配置中心公共模版,對於未配置的服務使用公共配置。
package google.example.library.v1;

service LibraryService {
    // Lagency SLO: 95th in 100ms, 99th in 150ms.
    rpc CreateBook(CreateBookRequest) returns (Book); 
    rpc GetBook(GetBookRequest) returns Book);
    rpc ListBooks(ListBooksRequest) returns (ListBooksResponse);
}

超時傳遞: 當上游服務已經超時返回 504,但下游服務仍然在執行,會導致浪費資源做無用功。 超時傳遞指的是把當前服務的剩餘 Quota 傳遞到下游服務中,繼承超時策略,控制請求級別的全局超時控制。

  • 進程內超時控制 一個請求在每個階段(網絡請求)開始前,就要檢查是否還有足夠的剩餘來處理請求,以及繼承他的超時策略,使用 Go 標準庫的 context.WithTimeout。

    func (c *asiiConn) Get(ctx context.Context, key string) (result *Item, err error) {
      c.conn.SetWriteDeadline(shrinkDeadline(ctx, c.writeTimeout))
      if _, err = fmt.Fprintf(c.rw, "gets %s\r\n", key); err != nil {
          // ...
      }
      // ...
    }
    

    timeout-2

  • A gRPC 請求 B,1s超時。

  • B 使用了300ms 處理請求,再轉發請求 C。
  • C 配置了600ms 超時,但是實際只用了500ms。
  • 到其他的下游,發現餘量不足,取消傳遞。

在需要強制執行時,下游的服務可以覆蓋上游的超時傳遞和配額。 在 gRPC 框架中,會依賴 gRPC Metadata Exchange,基於 HTTP2 的 Headers 傳遞 grpc-timeout 字段,自動傳遞到下游,構建帶 timeout 的 context。

timeout-3

  • 雙峰分佈: 95%的請求耗時在100ms內,5%的請求可能永遠不會完成(長超時)。
  • 對於監控不要只看mean,可以看看耗時分佈統計,比如 95th,99th (95%的請求100ms返回, 99%的請求150ms返回)。
  • 設置合理的超時,拒絕超長請求,或者當Server 不可用要主動失敗。

超時決定著服務線程耗盡。 timeout-4

超時 - Case Stduy

  • SLB 入口 Nginx 沒配置超時導致連鎖故障。
  • 服務依賴的 DB 連接池漏配超時,導致請求阻塞,最終服務集體 OOM。
  • 下游服務發版耗時增加,而上游服務配置超時過短,導致上游請求失敗。

過載保護 (服務層)

令牌桶算法 Token Bucket

是一個存放固定容量令牌的桶,按照固定速率往桶裡添加令牌。令牌桶算法的描述如下:

  • 假設限制2r/s,則按照500毫秒的固定速率往桶中添加令牌。
  • 桶中最多存放 b 個令牌,當桶滿時,新添加的令牌被丟棄或拒絕。
  • 當一個 n 個字節大小的數據包到達,將從桶中刪除n 個令牌,接著數據包被發送到網絡上。
  • 如果桶中的令牌不足 n 個,則不會刪除令牌,且該數據包將被限流(要麼丟棄,要麼緩衝區等待)。 token-bucket rate limit algorithm: /x/time/rate token-bucket

  • 使用一個定時器以恆定速度往桶里頒發令牌,桶滿了則丟棄多餘的令牌

  • "桶"可以用一個整數表示, 資源佔用相對較小
  • 處理速度取決於生產者的速度
  • 允許一定程度的突發流量, 流入速度固定, 請求的速度是會爆發(burst)

Token-Bucket-1 Token-Bucket-2 參數:

  • 桶子的大小: 桶子裡可以方入的最大Token 數量
  • 重新填入的速度: 定期放入桶中的Token數量

優點:

  • 這個演算法很容易實作
  • 以記憶體的使用來說很有效率
  • Token 桶可以接受短時間內出現流量爆炸的情況. 只要桶子裡還有Token, 就可以通過請求

缺點:

  • 這個演算法有兩個參數, 分別是桶子的大小與Token重新填入的速度. 要對這兩個參數做出適當的調整, 可能蠻具有挑戰性

漏桶算法 Leaky Bucket

作為計量工具(The Leaky Bucket Algorithm as a Meter)時,可以用於流量整形(Traffic Shaping)和流量控制(TrafficPolicing),漏桶算法的描述如下:

  • 一個固定容量的漏桶,按照常量固定速率流出水滴。
  • 如果桶是空的,則不需流出水滴。
  • 可以以任意速率流入水滴到漏桶。
  • 如果流入水滴超出了桶的容量,則流入的水滴溢出了(被丟棄),而漏桶容量是不變的。 leaky-bucket rate limit algorithm: /go.uber.org/ratelimit

leaky-bucket

參數:

  • 桶子的大小: 等同於Queue的大小. 這個Queue會把請求保存起來, 以固定的速度進行處理
  • 流出的速度:

優點:

  • Queue的大小有限, 因此可提高記憶體的使用效率
  • 請求是以固定的速度進行處理, 因此很適合需要穩定流出速度(outflow rate)的使用情況

缺點:

  • 如果出現瞬間大的流量, 舊請求就會塞滿Queue, 此時若未能及時處理, 新請求的處理速度就會受到影響
  • 這個演算法有兩個參數. 想對這兩個參數做出恰當的調整, 也許並沒有那麼容易

過載保護

漏斗桶/令牌桶確實能夠保護系統不被拖垮, 但不管漏斗桶還是令牌桶, 其防護思路都是設定一個指標, 當超過該指標後就阻止或減少流量的繼續進入,當系統負載降低到某一水平後則恢復流量的進入。 但其通常都是被動的,其實際效果取決於限流閾值設置是否合理,但往往設置合理不是一件容易的事情。

  • 集群增加機器或者減少機器限流閾值是否要重新設置?
  • 設置限流閾值的依據是什麼?
  • 人力運維成本是否過高?
  • 當調用方反饋429時, 這個時候重新設置限流, 其實流量高峰已經過了重新評估限流是否有意義?

這些其實都是採用漏斗桶/令牌桶的缺點, 總體來說就是太被動, 不能快速適應流量變化。 因此我們需要一種自適應的限流算法,即: 過載保護,根據系統當前的負載自動丟棄流量。

過載保護 計算系統臨近過載時的峰值吞吐作為限流的閾值來進行流量控制,達到系統保護。

  • 服務器臨近過載時,主動拋棄一定量的負載,目標是自保。
  • 在系統穩定的前提下,保持系統的吞吐量。

常見做法:利特爾法則

在一個穩定的系統中,長期的平均顧客人數(L),等於長期的有效抵達率(λ),乘以顧客在這個系統中平均的等待時間(W); 或者,我們可以用一個代數式來表達:

  • CPU、內存作為信號量進行節流。
  • 隊列管理: 隊列長度、LIFO。
  • 可控延遲算法: CoDel

overload-1 overload-2

如何計算接近峰值時的系統吞吐?

  • CPU: 使用一個獨立的線程採樣,每隔 250ms 觸發一次。在計算均值時,使用了簡單滑動平均去除峰值的影響。
  • Inflight: 當前服務中正在進行的請求的數量。
  • Pass&RT: 最近5s,pass 為每100ms採樣窗口內成功請求的數量,rt 為單個採樣窗口中平均響應時間。 overload-3 overload-4

  • 我們使用 CPU 的滑動均值(CPU > 800)作為啟發閾值,一旦觸發進入到過載保護階段,算法為:(pass* rt) < inflight

  • 限流效果生效後,CPU 會在臨界值(800)附近抖動,如果不使用冷卻時間,那麼一個短時間的 CPU 下降就可能導致大量請求被放行,嚴重時會打滿 CPU。
  • 在冷卻時間後,重新判斷閾值(CPU > 800 ),是否持續進入過載保護。 overload-5

限流

限流是指在一段時間內,定義某個客戶或應用可以接收或處理多少個請求的技術。 例如,通過限流,你可以過濾掉產生流量峰值的客戶和微服務, 或者可以確保你的應用程序在自動擴展(Auto Scaling)失效前都不會出現過載的情況。

  • 令牌桶、漏桶 針對單個節點,無法分佈式限流。
  • QPS 限流
    • 不同的請求可能需要數量迥異的資源來處理。
    • 某種靜態 QPS 限流不是特別準。
  • 給每個用戶設置限制
    • 全局過載發生時候,針對某些"異常"進行控制。
    • 一定程度的"超賣"配額。
  • 按照優先級丟棄。
  • 拒絕請求也需要成本。

ratelimiter

分佈式限流 (服務層)

分佈式限流, 是為了控制某個應用全局的流量,而非真對單個節點緯度。

  • 單個大流量的接口,使用 redis 容易產生熱點。
  • pre-request 模式對性能有一定影響,高頻的網絡往返。

思考:

  • 從獲取單個 quota 升級成批量 quota。 quota: 表示速率,獲取後使用令牌桶算法來限制。 ratelimiter-2

  • 每次心跳後,異步批量獲取 quota,可以大大減少請求 redis 的頻次,獲取完以後本地消費,基於令牌桶攔截。

  • 每次申請的配額需要手動設定靜態值略欠靈活,比如每次要20,還是50。

如何基於單個節點按需申請,並且避免出現不公平的現象? 初次使用默認值,一旦有過去歷史窗口的數據,可以基於歷史窗口數據進行 quota 請求。

思考: 我們經常面臨給一組用戶劃分稀有資源的問題,他們都享有等價的權利來獲取資源,但是其中一些用戶實際上只需要比其他用戶少的資源。

ratelimiter-3

那麼我們如何來分配資源呢?一種在實際中廣泛使用的分享技術稱作"最大最小公平分享"(Max-Min Fairness)。

直觀上,公平分享分配給每個用戶想要的可以滿足的最小需求,然後將沒有使用的資源均勻的分配給需要‘大資源’的用戶。

最大最小公平分配算法的形式化定義如下:

  • 資源按照需求遞增的順序進行分配。
  • 不存在用戶得到的資源超過自己的需求。
  • 未得到滿足的用戶等價的分享資源。 ratelimiter-4 ratelimiter-4

限流 - 重要性

每個接口配置閾值,運營工作繁重,最簡單的我們配置服務級別 quota,更細粒度的,我們可以根據不同重要性設定 quota,我們引入了重要性(criticality):

  • 最重要 CRITICAL_PLUS,為最終的要求預留的類型,拒絕這些請求會造成非常嚴重的用戶可見的問題。
  • 重要 CRITICAL,生產任務發出的默認請求類型。拒絕這些請求也會造成用戶可見的問題。但是可能沒那麼嚴重。
  • 可丟棄的 SHEDDABLE_PLUS 這些流量可以容忍某種程度的不可用性。這是批量任務發出的請求的默認值。這些請求通常可以過幾分鐘、幾小時後重試。
  • 可丟棄的 SHEDDABLE 這些流量可能會經常遇到部分不可用情況,偶爾會完全不可用。

gRPC 系統之間,需要自動傳遞重要性信息。如果後端接受到請求 A,在處理過程中發出了請求 B 和 C 給其他後端,請求 B 和 C 會使用與 A 相同的重要性屬性。

  • 全局配額不足時,優先拒絕低優先級的。
  • 全局配額,可以按照重要性分別設置。
  • 過載保護時,低優先級的請求先被拒絕。

限流 - 熔斷 (客戶端)

斷路器(Circuit Breakers): 為了限制操作的持續時間,我們可以使用超時,超時可以防止掛起操作並保證系統可以響應。 因為我們處於高度動態的環境中,幾乎不可能確定在每種情況下都能正常工作的準確的時間限制。 斷路器以現實世界的電子元件命名,因為它們的行為是都是相同的。 斷路器在分佈式系統中非常有用,因為重複的故障可能會導致雪球效應,並使整個系統崩潰。

  • 服務依賴的資源出現大量錯誤。
  • 某個用戶超過資源配額時,後端任務會快速拒絕請求,返回“配額不足”的錯誤,但是拒絕回复仍然會消耗一定資源。有可能後端忙著不停發送拒絕請求,導致過載。

Google SRE max(0, (requests - K*accepts) / (requests + 1)) requests : 總請求 accepts : 成功總請求 K : 常量 (default=2, 越小越激進, 越大越不激進)

// pkg/net/netutil/breaker/sre_breaker.go
func (b *sreBreaker) Allow() error {
    success, total := b.summary()
    k := b.k * float64(success)
    if log.V(5) {
        log.Info("breaker: request: %d, succee: %d, fail: %d", total, success, total-success)
    }
    // check overflow requests = K * success
    if total < b.request || float64(total) < k {
        if atomic.LoadInt32(&b.state) == StateOpen {
            atomic.CompareAndSwapInt32(&b.state, StateOpen, StateClosed)
        }
        return nil
    }
    if atomic.LoadInt32(&b.state) == StateClosed {
        atomic.CompareAndSwapInt32(&b.state, StateClosed, StateOpen)
    }
    dr := math.Max(0, (float64(total)-k)/float64(total+1))
    drop := b.trueOnProba(dr)
    if log.V(5) {
        log.Info("breaker: drop ratio: %f, drop: %t", dr, drop)
    }
    if drop {
        return ecode.ServiceUnavailable
    }
    return nil
}

分布式限流(服務層), 自適應保護(過載保護)(服務層), 熔斷(客戶端)

限流 - 客户端流控

positive feedback: 用戶總是積極重試,訪問一個不可達的服務。

  • 客戶端需要限制請求頻次,retry backoff 做一定的請求退讓。
  • 可以通過接口級別的error_details,掛載到每個 API 返回的響應裡。

秒數想在HTTP的協議裡面, 不要寫死到客戶端

限流 - Gutter

基於熔斷的 gutter kafka ,用於接管自動修復系統運行過程中的負載,這樣只需要付出10%的資源就能解決部分系統可用性問題。

我們經常使用 failover 的思路,但是完整的 failover 需要翻倍的機器資源,平常不接受流量時,資源浪費。高負載情況下接管流量又不一定完整能接住。所以這裡核心利用熔斷的思路,是把拋棄的流量轉移到 gutter 集群,如果 gutter 也接受不住的流量,重新回拋到主集群,最大力度來接受。

// pkg/net/netutil/backoff.go
// Backoff returns the amount of time to wait before the next retry given
// the number of consecutive failures.
func (bc *BackoffConfig) Backoff(retries int) time.Duration {
    if retries == 0 {
        return bc.BaseDelay
    }
    backoff, max := float64(bc.BaseDelay), float64(bc.MaxDelay)
    for backoff < max && retries > 0 {
        backoff *= bc.Factor
        retries--
    }
    if backoff > max {
        backoff = max
    }
    // Randomize backoff delays so that if a cluster of requests start at
    // the same time, they won't operate in lockstep.
    backoff *= 1 + bc.Jitter*(rand.Float64()*2-1)
    if backoff < 0 {
        return 0
    }
    return time.Duration(backoff)
}

限流 - Case Study

  • 二層緩存穿透、大量回源導致的核心服務故障。
  • 異常客戶端引起的服務故障(query of death)
    • 請求放大。
    • 資源數放大。
  • 用戶重試導致的大面積故障。

* 降級 (通常在 BFF, Backend for Frontend)

通過降級回復來減少工作量,或者丟棄不重要的請求。而且需要了解哪些流量可以降級,並且有能力區分不同的請求。 我們通常提供降低迴复的質量來答复減少所需的計算量或者時間。我們自動降級通常需要考慮幾個點:

  • 確定具體採用哪個指標作為流量評估和優雅降級的決定性指標(如,CPU、延遲、隊列長度、線程數量、錯誤等)。
  • 當服務進入降級模式時,需要執行什麼動作?
    • 從 local cache or remote cache 讀取數據, 再不行就返回 empty
  • 流量拋棄或者優雅降級應該在服務的哪一層實現?是否需要在整個服務的每一層都實現,還是可以選擇某個高層面的關鍵節點來實現?
    • BFF層 or API Gateway
    • 當請求下游服務失敗, 超時, 熔斷, 限流 才考慮是否降級

同時我們要考慮一下幾點:

  • 優雅降級不應該被經常觸發 - 通常觸發條件現實了容量規劃的失誤,或者是意外的負載。
  • 演練,代碼平時不會觸發和使用,需要定期針對一小部分的流量進行演練,保證模式的正常。
  • 應該足夠簡單。

降級本質為: 提供有損服務。

  • UI 模塊化,非核心模塊降級。
    • BFF 層聚合 API,模塊降級。
    • 譬如不顯示 相關視頻, tag, 評論數等
  • 頁面上一次緩存副本。
  • 默認值、熱門推薦(不走AI去算個人推薦,而直接用熱門推薦)等。
  • 流量攔截 + 定期數據緩存(過期副本策略)。 CAS指令

處理策略

  • 頁面降級、延遲服務、寫/讀降級(ex, 把請求關閉)
  • 緩存降級
    • 緩存降級是指緩存失效或者緩存服務器掛掉的情況下,不去訪問數據庫,直接返回默認數據或者訪問服務的內存數據
    • 頁面上一頁緩存副本
    • 熱門數據 local cache 化
    • 定期緩存數據
  • 拋異常、返回約定協議、Mock 數據(用裝飾者模式實現)、Fallback 處理
緩存預熱

緩存預熱這個應該是一個比較常見的概念,相信很多小伙伴都應該可以很容易的理解,緩存預熱就是系統上線後,將相關的緩存數據直接加載到緩存系統。這樣就可以避免在用戶請求的時候,先查詢數據庫,然後再將數據緩存的問題!用戶直接查詢事先被預熱的緩存數據! 解決思路:

  1. 直接寫個緩存刷新頁面,上線時手工操作下;
  2. 數據量不大,可以在項目啟動的時候自動進行加載;
  3. 定時刷新緩存
緩存更新

除了緩存服務器自帶的緩存失效策略之外(Redis默認的有6種策略可供選擇),我們還可以根據具體的業務需求進行自定義的緩存淘汰,常見的策略有兩種:

  1. 定時去清理過期的緩存;
  2. 當有用戶請求過來時,再判斷這個請求所用到的緩存是否過期,過期的話就去底層系統得到新數據並更新緩存。 兩者各有優劣,第一種的缺點是維護大量緩存的key是比較麻煩的,第二種的缺點就是每次用戶請求過來都要判斷緩存失效,邏輯相對比較複雜!具體用哪種方案,大家可以根據自己的應用場景來權衡。
緩存降級

當訪問量劇增、服務出現問題(如響應時間慢或不響應)或非核心服務影響到核心流程的性能時,仍然需要保證服務還是可用的,即使是有損服務。系統可以根據一些關鍵數據進行自動降級,也可以配置開關實現人工降級。 降級的最終目的是保證核心服務可用,即使是有損的。而且有些服務是無法降級的(如加入購物車、結算)。 以參考日誌級別設置預案:

  1. 一般:比如有些服務偶爾因為網絡抖動或者服務正在上線而超時,可以自動降級;
  2. 警告:有些服務在一段時間內成功率有波動(如在95~100%之間),可以自動降級或人工降級,並發送告警;
  3. 錯誤:比如可用率低於90%,或者數據庫連接池被打爆了,或者訪問量突然猛增到系統能承受的最大閥值,此時可以根據情況自動降級或者人工降級;
  4. 嚴重錯誤:比如因為特殊原因數據錯誤了,此時需要緊急人工降級。

降级 - Case Study

  • 客戶端解析協議失敗,app 崩潰。
  • 客戶端部分協議不兼容,導致頁面失敗。
  • local cache 數據源緩存,發版失效 + 依賴接口故障,引起的白屏。
  • 沒有 playbook(SOP),導致的 MTTR 上升。

* 重試

當請求返回錯誤(例: 配額不足、超時、內部錯誤等), 對於 backend 部分節點過載的情況下,傾向於立刻重試, 但是需要留意重試帶來的流量放大:

  • 限制重試次數和基於重試分佈的策略(重試比率: 10%)。
  • 隨機化、指數型遞增的重試週期: exponential ackoff + jitter。
  • client 測記錄重試次數直方圖,傳遞到 server,進行分佈判定,交由 server 判定拒絕。
  • 只應該在失敗的這層進行重試,當重試仍然失敗,全局約定錯誤碼“過載,無須重試”,避免級聯重試。

retry

重試 - Case Study

  • Nginx upstream retry 過大,導致服務雪崩。
  • 業務不冪等,導致的重試,數據重複。
    • 全局唯一 ID: 根據業務生成一個全局唯一 ID,在調用接口時會傳入該 ID,接口提供方會從相應的存儲系統比如 redis 中去檢索這個全局 ID 是否存在,如果存在則說明該操作已經執行過了,將拒絕本次服務請求;否則將相應該服務請求並將全局 ID 存入存儲系統中,之後包含相同業務 ID 參數的請求將被拒絕。
    • 去重表: 這種方法適用於在業務中有唯一標識的插入場景。比如在支付場景中,一個訂單只會支付一次,可以建立一張去重表,將訂單 ID 作為唯一索引。把支付並且寫入支付單據到去重表放入一個事務中了,這樣當出現重複支付時,數據庫就會拋出唯一約束異常,操作就會回滾。這樣保證了訂單只會被支付一次。
    • 多版本並發控制: 適合對更新請求作冪等性控制,比如要更新商品的名字,這是就可以在更新的接口中增加一個版本號來做冪等性控制。
  • 多層級重試傳遞,放大流量引起雪崩。
  • 寫請求盡量不要重試

* 負載均衡

數據中心內部的負載均衡 在理想情況下,某個服務的負載會完全均勻地分發給所有的後端任務。在任何時刻,最忙和最不忙的節點永遠消耗同樣數量的CPU。 目標:

  • 均衡的流量分發。
  • 可靠的識別異常節點。
  • scale-out,增加同質節點擴容。
  • 減少錯誤,提高可用性。

我們發現在 backend 之間的 load 差異比較大:

  • 每個請求的處理成本不同。
  • 物理機環境的差異:
    • 服務器很難強同質性。
    • 存在共享資源爭用(內存緩存、帶寬、IO等)。
  • 性能因素:
    • FullGC。
    • JVM JIT。

參考JSQ(最閒輪訓)負載均衡算法帶來的問題,缺乏的是服務端全局視圖,因此我們目標需要綜合考慮:負載+可用性。 如果是 最閒輪訓, 從下圖來看 request 會傳到 server Y. 導致請求不均衡 最好的情況是 X, Y ,Z 請求各為 2

参考了《The power of two choices in randomized load balancing》的思路, 我們使用 the choice-of-2 算法,隨機選取的兩個節點進行打分,選擇更優的節點:

  • 選擇 backend:CPU,client:health、inflight、latency 作為指標,使用一個簡單的線性方程進行打分。
  • 對新啟動的節點使用常量懲罰值(penalty),以及使用探針方式最小化放量,進行預熱。
  • 打分比較低的節點,避免進入“永久黑名單”而無法恢復,使用統計衰減的方式,讓節點指標逐漸恢復到初始狀態(即默認值)。

指標計算結合 moving average,使用時間衰減,計算 vt = v(t-1) β + at (1-β) ,β為若干次冪的倒數即: Math.Exp((-span) / 600ms)

source ccode : https://github.com/go-kratos/kratos/blob/v1.0.x/pkg/net/rpc/warden/balancer/p2c/p2c.go

func (p *p2cPicker) pick(opts balancer.PickInfo) (balancer.PickResult, error) {
    var pc, upc *subConn
    start := time.Now().UnixNano()

    if len(p.subConns) <= 0 {
        return balancer.PickResult{}, balancer.ErrNoSubConnAvailable
    } else if len(p.subConns) == 1 {
        pc = p.subConns[0]
    } else {
        nodeA, nodeB := p.prePick()
        // meta.Weight为服务发布者在disocvery中设置的权重
        if nodeA.load()*nodeB.health()*nodeB.meta.Weight > nodeB.load()*nodeA.health()*nodeA.meta.Weight {
            pc, upc = nodeB, nodeA
        } else {
            pc, upc = nodeA, nodeB
        }
        // 如果选中的节点,在forceGap期间内没有被选中一次,那么强制一次
        // 利用强制的机会,来触发成功率、延迟的衰减
        // 原子锁conn.pick保证并发安全,放行一次
        pick := atomic.LoadInt64(&upc.pick)
        if start-pick > forceGap && atomic.CompareAndSwapInt64(&upc.pick, pick, start) {
            pc = upc
        }
    }
  // ...

最佳實踐

  • 變更管理:
    • 70%的問題是由變更引起的,恢復可用代碼並不總是壞事。
  • 避免過載:
    • 過載保護、流量調度等。
  • 依賴管理:
    • 任何依賴都可能故障,做 chaos monkey testing,注入故障測試。
  • 優雅降級:
    • 有損服務,避免核心鏈路依賴故障。
  • 重試退避:
    • 退讓算法,凍結時間,API retry detail 控制策略。
  • 超時控制:
    • 進程內 + 服務間 超時控制。
  • 極限壓測 + 故障演練。
    • 擴容 + 重啟 + 消除有害流量。

References

© Kimi Tsai all right reserved.            Updated : 2023-07-12 09:04:53

results matching ""

    No results matching ""

    results matching ""

      No results matching ""