最簡單的服務響應時長優化方法,沒有之一

kevwan發表於2021-12-26

序言 - From 萬俊峰Kevin

我們能把服務做到平均延遲基本在30ms左右,其中非常大的一個前提是我們大量使用了 MapReduce 技術,讓我們的服務即使呼叫很多個服務,很多時候也只取決於最慢的那一個請求的時長。

對你現有的服務,不需要優化 DB 操作,不需要優化快取,不需要重寫業務邏輯,只需要通過 MapReduce 把正交(不相關)的請求並行化,你就可以大幅降低服務響應時長。

本文歐陽安就給大家仔細分析一下 MapReduce 的實現細節。

為什麼需要 MapReduce

在實際的業務場景中我們常常需要從不同的 rpc 服務中獲取相應屬性來組裝成複雜物件。

比如要查詢商品詳情:

  1. 商品服務-查詢商品屬性
  2. 庫存服務-查詢庫存屬性
  3. 價格服務-查詢價格屬性
  4. 營銷服務-查詢營銷屬性

如果是序列呼叫的話響應時間會隨著 rpc 呼叫次數呈線性增長,所以我們要優化效能一般會將序列改並行。

簡單的場景下使用 waitGroup 也能夠滿足需求,但是如果我們需要對 rpc 呼叫返回的資料進行校驗、資料加工轉換、資料彙總呢?繼續使用 waitGroup 就有點力不從心了,go 的官方庫中並沒有這種工具(java 中提供了 CompleteFuture),go-zero 作者依據 mapReduce 架構思想實現了程式內的資料批處理 mapReduce 併發工具類。

設計思路

我們嘗試把自己代入到作者的角色梳理一下併發工具可能的業務場景:

  1. 查詢商品詳情:支援併發呼叫多個服務來組合產品屬性,支援呼叫錯誤可以立即結束。
  2. 商品詳情頁自動推薦使用者卡券:支援併發校驗卡券,校驗失敗自動剔除,返回全部卡券。

以上實際都是在進行對輸入資料進行處理最後輸出清洗後的資料,針對資料處理有個非常經典的非同步模式:生產者消費者模式。於是我們可以抽象一下資料批處理的生命週期,大致可以分為三個階段:

  1. 資料生產 generate
  2. 資料加工 mapper
  3. 資料聚合 reducer

其中資料生產是不可或缺的階段,資料加工、資料聚合是可選階段,資料生產與加工支援併發呼叫,資料聚合基本屬於純記憶體操作單協程即可。

再來思考一下不同階段之間資料應該如何流轉,既然不同階段的資料處理都是由不同 goroutine 執行的,那麼很自然的可以考慮採用 channel 來實現 goroutine 之間的通訊。

如何實現隨時終止流程呢?

很簡單,goroutine 中監聽一個全域性的結束 channel 就行。

go-zero 程式碼實現

core/mr/mapreduce.go

詳細原始碼可檢視 github.com/Ouyangan/go-zero-annota...

前置知識 - channel 基本用法

因為 MapReduce 原始碼中大量使用 channel 進行通訊,大概提一下 channel 基本用法:

  1. channel 寫結束後記得關閉
ch := make(chan interface{})
// 寫入完畢需要主動關閉channel
defer func() {
    close(ch)
}()
go func() {
    // v,ok模式 讀取channel
    for {
        v, ok := <-ch
        if !ok {
            return
        }
        t.Log(v)
    }

    // for range模式讀取channel,channel關閉迴圈自動退出
    for i := range ch {
        t.Log(i)
    }

    // 清空channel,channel關閉迴圈自動退出
    for range ch {
    }
}()
for i := 0; i < 10; i++ {
    ch <- i
    time.Sleep(time.Second)
}
  1. 已關閉的 channel 依然支援讀取
  2. 限定 channel 讀寫許可權
// 只讀channel
func readChan(rch <-chan interface{}) {
    for i := range rch {
        log.Println(i)
    }
}

// 只寫channel
func writeChan(wch chan<- interface{}) {
    wch <- 1
}

介面定義

先來看最核心的三個函式定義:

  1. 資料生產
  2. 資料加工
  3. 資料聚合
// 資料生產func
// source - 資料被生產後寫入source
GenerateFunc func(source chan<- interface{})

// 資料加工func
// item - 生產出來的資料
// writer - 呼叫writer.Write()可以將加工後的向後傳遞至reducer
// cancel - 終止流程func
MapperFunc func(item interface{}, writer Writer, cancel func(error))

// 資料聚合func
// pipe - 加工出來的資料
// writer - 呼叫writer.Write()可以將聚合後的資料返回給使用者
// cancel - 終止流程func
ReducerFunc func(pipe <-chan interface{}, writer Writer, cancel func(error))

面向使用者的方法定義

使用方法可以檢視官方文件,這裡不做贅述

面向使用者的方法比較多,方法主要分為兩大類:

  1. 無返回
    1. 執行過程發生錯誤立即終止
    2. 執行過程不關注錯誤
  2. 有返回值
    1. 手動寫入 source,手動讀取聚合資料 channel
    2. 手動寫入 source,自動讀取聚合資料 channel
    3. 外部傳入 source,自動讀取聚合資料 channel
// 併發執行func,發生任何錯誤將會立即終止流程
func Finish(fns ...func() error) error

// 併發執行func,即使發生錯誤也不會終止流程
func FinishVoid(fns ...func())

// 需要使用者手動將生產資料寫入 source,加工資料後返回一個channel供讀取
// opts - 可選引數,目前包含:資料加工階段協程數量
func Map(generate GenerateFunc, mapper MapFunc, opts ...Option)

// 無返回值,不關注錯誤
func MapVoid(generate GenerateFunc, mapper VoidMapFunc, opts ...Option)

// 無返回值,關注錯誤
func MapReduceVoid(generate GenerateFunc, mapper MapperFunc, reducer VoidReducerFunc, opts ...Option)

// 需要使用者手動將生產資料寫入 source ,並返回聚合後的資料
// generate 生產
// mapper 加工
// reducer 聚合
// opts - 可選引數,目前包含:資料加工階段協程數量
func MapReduce(generate GenerateFunc, mapper MapperFunc, reducer ReducerFunc, opts ...Option) (interface{}, error)

// 支援傳入資料來源channel,並返回聚合後的資料
// source - 資料來源channel
// mapper - 讀取source內容並處理
// reducer - 資料處理完畢傳送至reducer聚合
func MapReduceWithSource(source <-chan interface{}, mapper MapperFunc, reducer ReducerFunc,
    opts ...Option) (interface{}, error)

核心方法是 MapReduceWithSource 和 Map,其他方法都在內部呼叫她倆。弄清楚了 MapReduceWithSource 方法 Map 也不在話下。

MapReduceWithSource 原始碼實現

一切都在這張圖裡面了

// 支援傳入資料來源channel,並返回聚合後的資料
// source - 資料來源channel
// mapper - 讀取source內容並處理
// reducer - 資料處理完畢傳送至reducer聚合
func MapReduceWithSource(source <-chan interface{}, mapper MapperFunc, reducer ReducerFunc,
    opts ...Option) (interface{}, error) {
    // 可選引數設定
    options := buildOptions(opts...)
    // 聚合資料channel,需要手動呼叫write方法寫入到output中
    output := make(chan interface{})
    // output最後只會被讀取一次
    defer func() {
        // 如果有多次寫入的話則會造成阻塞從而導致協程洩漏
        // 這裡用 for range檢測是否可以讀出資料,讀出資料說明多次寫入了
        // 為什麼這裡使用panic呢?顯示的提醒使用者用法錯了會比自動修復掉好一些
        for range output {
            panic("more than one element written in reducer")
        }
    }()
    // 建立有緩衝的chan,容量為workers
    // 意味著最多允許 workers 個協程同時處理資料
    collector := make(chan interface{}, options.workers)
    // 資料聚合任務完成標誌
    done := syncx.NewDoneChan()
    // 支援阻塞寫入chan的writer
    writer := newGuardedWriter(output, done.Done())
    // 單例關閉
    var closeOnce sync.Once
    var retErr errorx.AtomicError
    // 資料聚合任務已結束,傳送完成標誌
    finish := func() {
        // 只能關閉一次
        closeOnce.Do(func() {
            // 傳送聚合任務完成訊號,close函式將會向chan寫入一個零值
            done.Close()
            // 關閉資料聚合chan
            close(output)
        })
    }
    // 取消操作
    cancel := once(func(err error) {
        // 設定error
        if err != nil {
            retErr.Set(err)
        } else {
            retErr.Set(ErrCancelWithNil)
        }
        // 清空source channel
        drain(source)
        // 呼叫完成方法
        finish()
    })

    go func() {
        defer func() {
            // 清空聚合任務channel
            drain(collector)
            // 捕獲panic
            if r := recover(); r != nil {
                // 呼叫cancel方法,立即結束
                cancel(fmt.Errorf("%v", r))
            } else {
                // 正常結束
                finish()
            }
        }()
        // 執行資料加工
        // 注意writer.write將加工後資料寫入了output
        reducer(collector, writer, cancel)
    }()
    // 非同步執行資料加工
    // source - 資料生產
    // collector - 資料收集
    // done - 結束標誌
    // workers - 併發數
    go executeMappers(func(item interface{}, w Writer) {
        mapper(item, w, cancel)
    }, source, collector, done.Done(), options.workers)
    // reducer將加工後的資料寫入了output,
    // 需要資料返回時讀取output即可
    // 假如output被寫入了超過兩次
    // 則開始的defer func那裡將還可以讀到資料
    // 由此可以檢測到使用者呼叫了多次write方法
    value, ok := <-output
    if err := retErr.Load(); err != nil {
        return nil, err
    } else if ok {
        return value, nil
    } else {
        return nil, ErrReduceNoOutput
    }
}
// 資料加工
func executeMappers(mapper MapFunc, input <-chan interface{}, collector chan<- interface{},
    done <-chan lang.PlaceholderType, workers int) {
    // goroutine協調同步訊號量
    var wg sync.WaitGroup
    defer func() {
        // 等待資料加工任務完成
        // 防止資料加工的協程還未處理完資料就直接退出了
        wg.Wait()
        // 關閉資料加工channel
        close(collector)
    }()
    // 帶緩衝區的channel,緩衝區大小為workers
    // 控制資料加工的協程數量
    pool := make(chan lang.PlaceholderType, workers)
    // 資料加工writer
    writer := newGuardedWriter(collector, done)
    for {
        select {
        // 監聽到外部結束訊號,直接結束
        case <-done:
            return
        // 控制資料加工協程數量
        // 緩衝區容量-1
        // 無容量時將會被阻塞,等待釋放容量
        case pool <- lang.Placeholder:
            // 阻塞等待生產資料channel
            item, ok := <-input
            // 如果ok為false則說明input已被關閉或者清空
            // 資料加工完成,執行退出
            if !ok {
                // 緩衝區容量+1
                <-pool
                // 結束本次迴圈
                return
            }
            // wg同步訊號量+1
            wg.Add(1)
            // better to safely run caller defined method
            // 非同步執行資料加工,防止panic錯誤
            threading.GoSafe(func() {
                defer func() {
                    // wg同步訊號量-1
                    wg.Done()
                    // 緩衝區容量+1
                    <-pool
                }()

                mapper(item, writer)
            })
        }
    }
}

總結

mapReduce 的原始碼我大概看了兩個晚上,整體看下來比較累。一方面是我自身 go 語言並不是很熟練尤其是 channel 的用法,導致我需要頻繁停下來查詢相關文件理解作者的寫法,另一方面是多個 goroutine 之間通過 channel 進行通訊實現協作真的蠻燒腦(佩服作者的思維能力)。

其次看原始碼時第一遍看起來肯定會比較懵的,其實沒關係找到程式的入口(公共基礎元件一般是面向的方法)先沿著主線一路看下去把每一句程式碼都看懂加上註釋,再看支線程式碼。

如果有實在看不懂的地方就查查這段程式碼的提交記錄非常有可能是解決某個bug改動的,比如下面這段程式碼我死活看了好多遍都不理解。

// 聚合資料channel,需要手動呼叫write方法寫入到output中
output := make(chan interface{})
// output最後只會被讀取一次
defer func() {
    // 如果有多次寫入的話則會造成阻塞從而導致協程洩漏
    // 這裡用 for range檢測是否可以讀出資料,讀出資料說明多次寫入了
    // 為什麼這裡使用panic呢?顯示的提醒使用者用法錯了會比自動修復掉好一些
    for range output {
        panic("more than one element written in reducer")
    }
}()

最後畫出流程圖基本就能把原始碼看懂了,對於我而言這方法比較笨但有效。

資料

Go Channel 詳解: colobu.com/2016/04/14/Golang-Chann...

go-zero MapReduce文件: go-zero.dev/cn/mapreduce.html

專案地址

github.com/zeromicro/go-zero

歡迎使用 go-zerostar 支援我們!

微信交流群

關注『微服務實踐』公眾號並點選 交流群 獲取社群群二維碼。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章