Golang處理大資料時使用高效的Pipeline(流水線)執行模型

來源:互聯網
上載者:User

21天精品區塊鏈課程免費學習,深入實戰行家帶路,助力開發人員輕鬆玩轉區塊鏈!>>>   

Golang被證明非常適合并發編程,goroutine比非同步編程更易讀、優雅、高效。本文提出一個適合由Golang實現的Pipeline執行模型,適合批量處理大量資料(ETL)的情景。

想象這樣的應用情景:
(1)從資料庫A(Cassandra)載入使用者評論(量巨大,例如10億條);
(2)根據每條評論的使用者ID、從資料庫B(MySQL)關聯使用者資料;
(3)調用NLP服務(自然語言處理),處理每條評論;
(4)將處理結果寫入資料庫C(Elasticsearch)。

由於應用中遇到的各種問題,歸納出這些需求:
需求一:應分批處理資料,例如規定每批100條。出現問題時(例如任意一個資料庫故障)則中斷,下次程式啟動時使用checkpoint從中斷處恢複。
需求二:每個流程設定合理的並發數、讓資料庫和NLP服務有合理的負載(不影響其它業務的基礎上,儘可能佔用更多資源以提高ETL效能)。例如,步驟(1)-(4)分別設定並發數1、8、32、2。

這就是一個典型的Pipeline(流水線)執行模型。把每一批資料(例如100條)看作流水線上的產品,4個步驟對應流水線上4個處理工序,每個工序處理完畢後就把半成品交給下一個工序。每個工序可以同時處理的產品數各不相同。

你可能首先想到啟用1+8+32+2個goroutine,使用channel來傳遞半成品。我也曾經這麼幹,結論就是這麼幹會讓程式員瘋掉:流程並發控制碼非常複雜,特別是你得處理異常、執行時間超出預期、可控中斷等問題,你不得不加入一堆channel,直到你自己都不記得有什麼用。

為了更高效完成ETL工作,我將Pipeline抽象成模組。我先把代碼粘貼出來,再解析含義。模組可以直接使用,主要使用的介面是:NewPipeline、Async、Wait。

package mainimport "sync"func HasClosed(c <-chan struct{}) bool {    select {    case <-c: return true    default: return false    }}type SyncFlag interface{    Wait()    Chan() <-chan struct{}    Done() bool}func NewSyncFlag() (done func(), flag SyncFlag) {    f := &syncFlag{        c : make(chan struct{}),    }    return f.done, f}type syncFlag struct {    once sync.Once    c chan struct{}}func (f *syncFlag) done() {    f.once.Do(func(){        close(f.c)    })}func (f *syncFlag) Wait() {    <-f.c}func (f *syncFlag) Chan() <-chan struct{} {    return f.c}func (f *syncFlag) Done() bool {    return HasClosed(f.c)}type pipelineThread struct {    sigs []chan struct{}    chanExit chan struct{}    interrupt SyncFlag    setInterrupt func()    err error}func newPipelineThread(l int) *pipelineThread {    p := &pipelineThread{        sigs : make([]chan struct{}, l),        chanExit : make(chan struct{}),    }    p.setInterrupt, p.interrupt = NewSyncFlag()    for i := range p.sigs {        p.sigs[i] = make(chan struct{})    }    return p}type Pipeline struct {    mtx sync.Mutex    workerChans []chan struct{}    prevThd *pipelineThread}//建立流水線,參數個數是每個任務的子過程數,每個參數對應子過程的並發度。func NewPipeline(workers ...int) *Pipeline {    if len(workers) < 1 { panic("NewPipeline need aleast one argument") }    workersChan := make([]chan struct{}, len(workers))    for i := range workersChan {        workersChan[i] = make(chan struct{}, workers[i])    }    prevThd := newPipelineThread(len(workers))    for _,sig := range prevThd.sigs {        close(sig)    }    close(prevThd.chanExit)    return &Pipeline{        workerChans : workersChan,        prevThd : prevThd,    }}//往流水線推入一個任務。如果第一個步驟的並發數達到設定上限,這個函數會堵塞等待。//如果流水線中有其它任務失敗(返回非nil),任務不被執行,函數返回false。func (p *Pipeline) Async(works ...func()error) bool {    if len(works) != len(p.workerChans) {        panic("Async: arguments number not matched to NewPipeline(...)")    }    p.mtx.Lock()    if p.prevThd.interrupt.Done() {        p.mtx.Unlock()        return false    }    prevThd := p.prevThd    thisThd := newPipelineThread(len(p.workerChans))    p.prevThd = thisThd    p.mtx.Unlock()    lock := func(idx int) bool {        select {        case <-prevThd.interrupt.Chan(): return false        case <-prevThd.sigs[idx]: //wait for signal        }        select {        case <-prevThd.interrupt.Chan(): return false        case p.workerChans[idx]<-struct{}{}: //get lock        }        return true    }    if !lock(0) {        thisThd.setInterrupt()        <-prevThd.chanExit        thisThd.err = prevThd.err        close(thisThd.chanExit)        return false    }    go func() { //watch interrupt of previous thread        select {        case <-prevThd.interrupt.Chan():            thisThd.setInterrupt()        case <-thisThd.chanExit:        }    }()    go func() {        var err error        for i,work := range works {            close(thisThd.sigs[i]) //signal next thread            if work != nil {                err = work()            }            if err != nil || (i+1 < len(works) && !lock(i+1)) {                thisThd.setInterrupt()                break            }            <-p.workerChans[i] //release lock        }        <-prevThd.chanExit        if prevThd.interrupt.Done() {            thisThd.setInterrupt()        }        if prevThd.err != nil {            thisThd.err = prevThd.err        } else {            thisThd.err = err        }        close(thisThd.chanExit)    }()    return true}//等待流水線中所有任務執行完畢或失敗,返回第一個錯誤,如果無錯誤則返回nil。func (p *Pipeline) Wait() error {    p.mtx.Lock()    lastThd := p.prevThd    p.mtx.Unlock()    <-lastThd.chanExit    return lastThd.err}

使用這個Pipeline組件,我們的ETL程式將會簡單、高效、可靠,讓程式員從繁瑣的並發流程式控制制中解放出來:

package mainimport "log"func main() {    checkpoint := loadCheckpoint()        //工序(1)在pipeline外執行,最後一個工序是儲存checkpoint    pipeline := NewPipeline(8, 32, 2, 1)     for {        //(1)        //載入100條資料,並修改變數checkpoint        //data是數組,每個元素是一條評論,之後的聯表、NLP都直接修改data裡的每條記錄。        data, err := extractReviewsFromA(&checkpoint, 100)         if err != nil {            log.Print(err)            break        }        curCheckpoint := checkpoint                ok := pipeline.Async(func() error {            //(2)            return joinUserFromB(data)        }, func() error {            //(3)            return nlp(data)        }, func() error {            //(4)            return loadDataToC(data)        }, func() error {            //(5)儲存checkpoint            log.Print("done:", curCheckpoint)            return saveCheckpoint(curCheckpoint)        })        if !ok { break }                if len(data) < 100 { break } //處理完畢    }    err := pipeline.Wait()    if err != nil { log.Print(err) }}

Pipeline執行模型的特性:

1、Pipeline分別控制每一個工序的並發數,如果(4)的並發數已滿,某個線程的(3)即使完成都會堵塞等待,直到(4)有一個線程完成。
2、在上面的情景中,Pipeline最多同時處理1+8+32+2+1=44個線程共4400條記錄,記憶體開銷可控。
3、每個線程的每個工序的調度,不早於上一個線程同一個工序的調度。

例如:有兩個線程正在執行,<1>先執行、<2>後執行。如果<2>(4)早於<1>(4)完成,那<2>必須堵塞等待,直到<1>(4)完成、<1>(5)開始執行,那<2>(5)才會開始。又因為(5)的最大並發數是1,所以實際上<2>(5)必須等待<1>(5)完成才會開始。這個機制保證checkpoint的執行順序一定是按照Async的順序,避免中斷、繼續時漏處理資料。

4、如果某個線程的某個工序處理失敗(例如資料庫故障),那之後的線程都會中止執行,下一次調用Async返回false,pipeline.Wait()返回第一個錯誤,整個流水線作業可控中斷。

例如:有三個線程正在執行:<1>、<2>、<3>。如果<2>(4)失敗(loadDataToC返回error非nil),那<3>無論正在執行到哪一個工序,都不會進入下一個工序而中斷。<1>不會受到影響,會一直執行完畢。Wait()等待<1><2><3>全部完成或中止,返回loadDataToC的錯誤。

5、無法避免中斷過程中有checkpoint後的資料寫入。下次重啟程式將重新寫入、覆蓋這些資料。

例如:<2>(4)失敗、<3>(4)執行成功(已寫入資料),那<2>(5)和<3>(5)都不會被執行,checkpoint的最新狀態是<1>寫入的,下次重啟程式將重新執行<2>和<3>,其中<3>的資料會再次寫入,所以寫入應該按照記錄ID作覆蓋寫入。

6、你可以隨時Ctrl+C、重啟程式,所有事情都會繼續有序執行。死機?毫無壓力。

總結:Pipeline執行模型除了限制並發數,也能限制記憶體開銷,對失敗恢複有充足的考慮,讓程式員從繁瑣的並發編程中解放出來。

吐槽:Python程式員沒辦法用幾百行代碼就漂亮地完成這個任務。

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.