通過MapReduce降低服務響應時間

kevwan發表於2020-12-07

在微服務中開發中,api閘道器扮演對外提供restful api的角色,而api的資料往往會依賴其他服務,複雜的api更是會依賴多個甚至數十個服務。雖然單個被依賴服務的耗時一般都比較低,但如果多個服務序列依賴的話那麼整個api的耗時將會大大增加。

那麼通過什麼手段來優化呢?我們首先想到的是通過併發來的方式來處理依賴,這樣就能降低整個依賴的耗時,Go基礎庫中為我們提供了 WaitGroup 工具用來進行併發控制,但實際業務場景中多個依賴如果有一個出錯我們期望能立即返回而不是等所有依賴都執行完再返回結果,而且WaitGroup中對變數的賦值往往需要加鎖,每個依賴函式都需要新增Add和Done對於新手來說比較容易出錯

基於以上的背景,go-zero框架中為我們提供了併發處理工具MapReduce,該工具開箱即用,不需要做什麼初始化,我們通過下圖看下使用MapReduce和沒使用的耗時對比:

相同的依賴,序列處理的話需要200ms,使用MapReduce後的耗時等於所有依賴中最大的耗時為100ms,可見MapReduce可以大大降低服務耗時,而且隨著依賴的增加效果就會越明顯,減少處理耗時的同時並不會增加伺服器壓力

併發處理工具MapReduce

MapReduce是Google提出的一個軟體架構,用於大規模資料集的並行運算,go-zero中的MapReduce工具正是借鑑了這種架構思想

go-zero框架中的MapReduce工具主要用來對批量資料進行併發的處理,以此來提升服務的效能

我們通過幾個示例來演示MapReduce的用法

MapReduce主要有三個引數,第一個引數為generate用以生產資料,第二個引數為mapper用以對資料進行處理,第三個引數為reducer用以對mapper後的資料做聚合返回,還可以通過opts選項設定併發處理的執行緒數量

場景一: 某些功能的結果往往需要依賴多個服務,比如商品詳情的結果往往會依賴使用者服務、庫存服務、訂單服務等等,一般被依賴的服務都是以rpc的形式對外提供,為了降低依賴的耗時我們往往需要對依賴做並行處理

func productDetail(uid, pid int64) (*ProductDetail, error) {
    var pd ProductDetail
    err := mr.Finish(func() (err error) {
        pd.User, err = userRpc.User(uid)
        return
    }, func() (err error) {
        pd.Store, err = storeRpc.Store(pid)
        return
    }, func() (err error) {
        pd.Order, err = orderRpc.Order(pid)
        return
    })

    if err != nil {
        log.Printf("product detail error: %v", err)
        return nil, err
    }

    return &pd, nil
}

該示例中返回商品詳情依賴了多個服務獲取資料,因此做併發的依賴處理,對介面的效能有很大的提升

場景二: 很多時候我們需要對一批資料進行處理,比如對一批使用者id,效驗每個使用者的合法性並且效驗過程中有一個出錯就認為效驗失敗,返回的結果為效驗合法的使用者id

func checkLegal(uids []int64) ([]int64, error) {
    r, err := mr.MapReduce(func(source chan<- interface{}) {
        for _, uid := range uids {
            source <- uid
        }
    }, func(item interface{}, writer mr.Writer, cancel func(error)) {
        uid := item.(int64)
        ok, err := check(uid)
        if err != nil {
            cancel(err)
        }
        if ok {
            writer.Write(uid)
        }
    }, func(pipe <-chan interface{}, writer mr.Writer, cancel func(error)) {
        var uids []int64
        for p := range pipe {
            uids = append(uids, p.(int64))
        }
        writer.Write(uids)
    })
    if err != nil {
        log.Printf("check error: %v", err)
        return nil, err
    }

    return r.([]int64), nil
}

func check(uid int64) (bool, error) {
    // do something check user legal
    return true, nil
}

該示例中,如果check過程出現錯誤則通過cancel方法結束效驗過程,並返回error整個效驗過程結束,如果某個uid效驗結果為false則最終結果不返回該uid

MapReduce使用注意事項

  • mapper和reducer中都可以呼叫cancel,引數為error,呼叫後立即返回,返回結果為nil, error
  • mapper中如果不呼叫writer.Write則item最終不會被reducer聚合
  • reducer中如果不呼叫writer.Wirte則返回結果為nil, ErrReduceNoOutput
  • reducer為單執行緒,所有mapper出來的結果在這裡序列聚合

實現原理分析:

MapReduce中首先通過buildSource方法通過執行generate(引數為無緩衝channel)產生資料,並返回無緩衝的channel,mapper會從該channel中讀取資料

func buildSource(generate GenerateFunc) chan interface{} {
    source := make(chan interface{})
    go func() {
        defer close(source)
        generate(source)
    }()

    return source
}

在MapReduceWithSource方法中定義了cancel方法,mapper和reducer中都可以呼叫該方法,呼叫後主執行緒收到close訊號會立馬返回

cancel := once(func(err error) {
    if err != nil {
        retErr.Set(err)
    } else {
        // 預設的error
        retErr.Set(ErrCancelWithNil)
    }

    drain(source)
    // 呼叫close(ouput)主執行緒收到Done訊號,立馬返回
    finish()
})

在mapperDispatcher方法中呼叫了executeMappers,executeMappers消費buildSource產生的資料,每一個item都會起一個goroutine單獨處理,預設最大併發數為16,可以通過WithWorkers進行設定

var wg sync.WaitGroup
defer func() {
    wg.Wait() // 保證所有的item都處理完成
    close(collector)
}()

pool := make(chan lang.PlaceholderType, workers)
writer := newGuardedWriter(collector, done) // 將mapper處理完的資料寫入collector
for {
    select {
    case <-done: // 當呼叫了cancel會觸發立即返回
        return
    case pool <- lang.Placeholder: // 控制最大併發數
        item, ok := <-input
        if !ok {
            <-pool
            return
        }

        wg.Add(1)
        go func() {
            defer func() {
                wg.Done()
                <-pool
            }()

            mapper(item, writer) // 對item進行處理,處理完呼叫writer.Write把結果寫入collector對應的channel中
        }()
    }
}

reducer單goroutine對數mapper寫入collector的資料進行處理,如果reducer中沒有手動呼叫writer.Write則最終會執行finish方法對output進行close避免死鎖

go func() {
    defer func() {
        if r := recover(); r != nil {
            cancel(fmt.Errorf("%v", r))
        } else {
            finish()
        }
    }()
    reducer(collector, writer, cancel)
}()

在該工具包中還提供了許多針對不同業務場景的方法,實現原理與MapReduce大同小異,感興趣的同學可以檢視原始碼學習

  • MapReduceVoid 功能和MapReduce類似但沒有結果返回只返回error
  • Finish 處理固定數量的依賴,返回error,有一個error立即返回
  • FinishVoid 和Finish方法功能類似,沒有返回值
  • Map 只做generate和mapper處理,返回channel
  • MapVoid 和Map功能類似,無返回

本文主要介紹了go-zero框架中的MapReduce工具,在實際的專案中非常實用。用好工具對於提升服務效能和開發效率都有很大的幫助,希望本篇文章能給大家帶來一些收穫。

專案地址

github.com/tal-tech/go-zero

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

相關文章