1. 程式人生 > >效能優化實戰:百萬級WebSockets和Go語言

效能優化實戰:百萬級WebSockets和Go語言

SegmentFault

搜尋 曼託斯 曼託斯 釋出於 
技術譯文
  關注專欄 2017年09月14日  ·  7.9k 人閱讀

效能優化實戰:百萬級WebSockets和Go語言

  9

翻譯原文連結 轉帖/轉載請註明出處

原文連結@medium.com 發表於2017/08/03

大家好!我的名字叫Sergey Kamardin。我是來自Mail.Ru的一名工程師。這篇文章將講述我們是如何用Go語言開發一個高負荷的WebSocket服務。即使你對WebSockets熟悉但對Go語言知之甚少,我還是希望這篇文章裡講到的效能優化的思路和技術對你有所啟發。

1. 介紹

作為全文的鋪墊,我想先講一下我們為什麼要開發這個服務。

Mail.Ru有許多包含狀態的系統。使用者的電子郵件儲存是其中之一。有很多辦法來跟蹤這些狀態的改變。不外乎通過定期的輪詢或者系統通知來得到狀態的變化。這兩種方法都有它們的優缺點。對郵件這個產品來說,讓使用者儘快收到新的郵件是一個考量指標。郵件的輪詢會產生大概每秒5萬個HTTP請求,其中60%的請求會返回304狀態(表示郵箱沒有變化)。因此,為了減少伺服器的負荷並加速郵件的接收,我們決定重寫一個publisher-subscriber服務(這個服務通常也會稱作bus,message broker或者event-channel)。這個服務負責接收狀態更新的通知,然後還處理對這些更新的訂閱。

重寫publisher-subscriber服務之前:

0_pull.png

現在:

1_push.png

上面第一個圖為舊的架構。瀏覽器(Browser)會定期輪詢API服務來獲得郵件儲存服務(Storage)的更新。

第二張圖展示的是新的架構。瀏覽器(Browser)和通知API服務(notificcation API)建立一個WebSocket連線。通知API服務會發送相關的訂閱到Bus服務上。當收到新的電子郵件時,儲存服務(Storage)向Bus(1)傳送一個通知,Bus又將通知傳送給相應的訂閱者(2)。API服務為收到的通知找到相應的連線,然後把通知推送到使用者的瀏覽器(3)。

我們今天就來討論一下這個API服務(也可以叫做WebSocket服務)。在開始之前,我想提一下這個線上服務處理將近3百萬個連線。

2. 慣用的做法(The idiomatic way)

首先,我們看一下不做任何優化會如何用Go來實現這個服務的部分功能。在使用net/http實現具體功能前,讓我們先討論下我們將如何傳送和接收資料。這些資料是定義在WebSocket協議之上的(例如JSON物件)。我們在下文中會成他們為packet。

我們先來實現Channel結構。它包含相應的邏輯來通過WebScoket連線傳送和接收packet。

2.1. Channel結構

// Packet represents application level data.
type Packet struct {
    ...
}

// Channel wraps user connection.
type Channel struct {
    conn net.Conn    // WebSocket connection.
    send chan Packet // Outgoing packets queue.
}

func NewChannel(conn net.Conn) *Channel {
    c := &Channel{
        conn: conn,
        send: make(chan Packet, N),
    }

    go c.reader()
    go c.writer()

    return c
}

這裡我要強調的是讀和寫這兩個goroutines。每個goroutine都需要各自的記憶體棧。棧的初始大小由作業系統和Go的版本決定,通常在2KB到8KB之間。我們之前提到有3百萬個線上連線,如果每個goroutine棧需要4KB的話,所有連線就需要24GB的記憶體。這還沒算上給Channel結構,傳送packet用的ch.send和其它一些內部欄位分配的記憶體空間。

2.2. I/O goroutines

接下來看一下“reader”的實現:

func (c *Channel) reader() {
    // We make a buffered read to reduce read syscalls.
    buf := bufio.NewReader(c.conn)

    for {
        pkt, _ := readPacket(buf)
        c.handle(pkt)
    }
}

這裡我們使用了bufio.Reader。每次都會在buf大小允許的範圍內儘量讀取多的位元組,從而減少read()系統呼叫的次數。在無限迴圈中,我們期望會接收到新的資料。請記住之前這句話:期望接收到新的資料。我們之後會討論到這一點。

我們把packet的解析和處理邏輯都忽略掉了,因為它們和我們要討論的優化不相關。不過buf值得我們的關注:它的預設大小是4KB。這意味著所有連線將消耗掉額外的12 GB記憶體。“writer”也是類似的情況:

func (c *Channel) writer() {
    // We make buffered write to reduce write syscalls.
    buf := bufio.NewWriter(c.conn)

    for pkt := range c.send {
        _ := writePacket(buf, pkt)
        buf.Flush()
    }
}

我們在待發送packet的c.send channel上迴圈將packet寫到快取(buffer)裡。細心的讀者肯定已經發現,這又是額外的4KB記憶體。3百萬個連線會佔用12GB的記憶體。

2.3. HTTP

我們已經有了一個簡單的Channel實現。現在我們需要一個WebSocket連線。因為還在通常做法(Idiomatic Way)的標題下,那麼就先來看看通常是如何實現的。

注:如果你不知道WebSocket是怎麼工作的,那麼這裡值得一提的是客戶端是通過一個叫升級(Upgrade)請求的特殊HTTP機制來建立WebSocket的。在成功處理升級請求以後,服務端和客戶端使用TCP連線來交換二進位制的WebSocket幀(frames)。這裡有關於幀結構的描述。

import (
    "net/http"
    "some/websocket"
)

http.HandleFunc("/v1/ws", func(w http.ResponseWriter, r *http.Request) {
    conn, _ := websocket.Upgrade(r, w)
    ch := NewChannel(conn)
    //...
})

請注意這裡的http.ResponseWriter結構包含bufio.Readerbufio.Writer(各自分別包含4KB的快取)。它們用於\*http.Request初始化和返回結果。

不管是哪個WebSocket,在成功迴應一個升級請求之後,服務端在呼叫responseWriter.Hijack()之後會接收到一個I/O快取和對應的TCP連線。

注:有時候我們可以通過net/http.putBufio{Reader,Writer}呼叫把快取釋放回net/http裡的sync.Pool

這樣,這3百萬個連線又需要額外的24 GB記憶體。

所以,為了這個什麼都不幹的程式,我們已經佔用了72 GB的記憶體!

3. 優化

我們來回顧一下前面介紹的使用者連線的工作流程。在建立WebSocket之後,客戶端會發送請求訂閱相關事件(我們這裡忽略類似ping/pong的請求)。接下來,在整個連線的生命週期裡,客戶端可能就不會發送任何其它資料了。

連線的生命週期可能會持續幾秒鐘到幾天。

所以在大部分時間裡,Channel.reader()Channel.writer()都在等待接收和傳送資料。與它們一起等待的是各自分配的4 KB的I/O快取。

現在,我們發現有些地方是可以做進一步優化的,對吧?

3.1. Netpoll

你還記得Channel.reader()的實現使用了bufio.Reader.Read()嗎?bufio.Reader.Read()又會呼叫conn.Read()。這個呼叫會被阻塞以等待接收連線上的新資料。如果連線上有新的資料,Go的執行環境(runtime)就會喚醒相應的goroutine讓它去讀取下一個packet。之後,goroutine會被再次阻塞來等待新的資料。我們來研究下Go的執行環境是怎麼知道goroutine需要被喚醒的。

如果我們看一下conn.Read()的實現,就會看到它呼叫了net.netFD.Read()

// net/fd_unix.go

func (fd *netFD) Read(p []byte) (n int, err error) {
    //...
    for {
        n, err = syscall.Read(fd.sysfd, p)
        if err != nil {
            n = 0
            if err == syscall.EAGAIN {
                if err = fd.pd.waitRead(); err == nil {
                    continue
                }
            }
        }
        //...
        break
    }
    //...
}

Go使用了sockets的非阻塞模式。EAGAIN表示socket裡沒有資料了但不會阻塞在空的socket上,OS會把控制權返回給使用者程序。

這裡它首先對連線檔案描述符進行read()系統呼叫。如果read()返回的是EAGAIN錯誤,執行環境就是呼叫pollDesc.waitRead()

// net/fd_poll_runtime.go

func (pd *pollDesc) waitRead() error {
   return pd.wait('r')
}

func (pd *pollDesc) wait(mode int) error {
   res := runtime_pollWait(pd.runtimeCtx, mode)
   //...
}

如果繼續深挖,我們可以看到netpoll的實現在Linux裡用的是epoll而在BSD裡用的是kqueue。我們的這些連線為什麼不採用類似的方式呢?只有在socket上有可讀資料時,才分配快取空間並啟用讀資料的goroutine。

在github.com/golang/go上,有一個關於開放(exporting)netpoll函式的問題

3.2. 幹掉goroutines

假設我們用Go語言實現了netpoll。我們現在可以避免建立Channel.reader()的goroutine,取而代之的是從訂閱連線裡收到新資料的事件。

ch := NewChannel(conn)

// Make conn to be observed by netpoll instance.
poller.Start(conn, netpoll.EventRead, func() {
    // We spawn goroutine here to prevent poller wait loop
    // to become locked during receiving packet from ch.
    go ch.Receive()
})

// Receive reads a packet from conn and handles it somehow.
func (ch *Channel) Receive() {
    buf := bufio.NewReader(ch.conn)
    pkt := readPacket(buf)
    c.handle(pkt)
}

Channel.writer()相對容易一點,因為我們只需在傳送packet的時候建立goroutine並分配快取。

func (ch *Channel) Send(p Packet) {
    if c.noWriterYet() {
        go ch.writer()
    }
    ch.send <- p
}

注意,這裡我們沒有處理write()系統呼叫時返回的EAGAIN。我們依賴Go執行環境去處理它。這種情況很少發生。如果需要的話我們還是可以像之前那樣來處理。

ch.send讀取待發送的packets之後,ch.writer()會完成它的操作,最後釋放goroutine的棧和用於傳送的快取。

很不錯!通過避免這兩個連續執行的goroutine所佔用的I/O快取和棧記憶體,我們已經節省了48 GB

3.3. 控制資源

大量的連線不僅僅會造成大量的記憶體消耗。在開發服務端的時候,我們還不停地遇到競爭條件(race conditions)和死鎖(deadlocks)。隨之而來的是所謂的自我分散式阻斷攻擊(self-DDOS)。在這種情況下,客戶端會悍然地嘗試重新連線服務端而把情況搞得更加糟糕。

舉個例子,如果因為某種原因我們突然無法處理ping/pong訊息,這些空閒連線就會不斷地被關閉(它們會以為這些連線已經無效因此不會收到資料)。然後客戶端每N秒就會以為失去了連線並嘗試重新建立連線,而不是繼續等待服務端發來的訊息。

在這種情況下,比較好的辦法是讓負載過重的服務端停止接受新的連線,這樣負載均衡器(例如nginx)就可以把請求轉到其它的服務端上去。

撇開服務端的負載不說,如果所有的客戶端突然(很可能是因為某個bug)向服務端傳送一個packet,我們之前節省的48 GB記憶體又將會被消耗掉。因為這時我們又會和開始一樣給每個連線建立goroutine並分配快取。

Goroutine池

可以用一個goroutine池來限制同時處理packets的數目。下面的程式碼是一個簡單的實現:

package gopool

func New(size int) *Pool {
    return &Pool{
        work: make(chan func()),
        sem:  make(chan struct{}, size),
    }
}

func (p *Pool) Schedule(task func()) error {
    select {
    case p.work <- task:
    case p.sem <- struct{}{}:
        go p.worker(task)
    }
}

func (p *Pool) worker(task func()) {
    defer func() { <-p.sem }
    for {
        task()
        task = <-p.work
    }
}

我們使用netpoll的程式碼就變成下面這樣:

pool := gopool.New(128)

poller.Start(conn, netpoll.EventRead, func() {
    // We will block poller wait loop when
    // all pool workers are busy.
    pool.Schedule(func() {
        ch.Receive()
    })
})

現在我們不僅要等可讀的資料出現在socket上才能讀packet,還必須等到從池裡獲取到空閒的goroutine。

同樣的,我們修改下Send()的程式碼:

pool := gopool.New(128)

func (ch *Channel) Send(p Packet) {
    if c.noWriterYet() {
        pool.Schedule(ch.writer)
    }
    ch.send <- p
}

這裡我們沒有呼叫go ch.writer(),而是想重複利用池裡goroutine來發送資料。 所以,如果一個池有N個goroutines的話,我們可以保證有N個請求被同時處理。而N + 1個請求不會分配N + 1個快取。goroutine池允許我們限制對新連線的Accept()Upgrade(),這樣就避免了大部分DDoS的情況。

3.4. 零拷貝升級(Zero-copy upgrade)

之前已經提到,客戶端通過HTTP升級(Upgrade)請求切換到WebSocket協議。下面顯示的是一個升級請求:

GET /ws HTTP/1.1
Host: mail.ru
Connection: Upgrade
Sec-Websocket-Key: A3xNe7sEB9HixkmBhVrYaA==
Sec-Websocket-Version: 13
Upgrade: websocket

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Sec-Websocket-Accept: ksu0wXWG+YmkVx+KQR2agP0cQn4=
Upgrade: websocket

我們接收HTTP請求和它的頭部只是為了切換到WebSocket協議,而http.Request裡儲存了所有頭部的資料。從這裡可以得到啟發,如果是為了優化,我們可以放棄使用標準的net/http服務並在處理HTTP請求的時候避免無用的記憶體分配和拷貝。

舉個例子,http.Request包含了一個叫做Header的欄位。標準net/http服務會將請求裡的所有頭部資料全部無條件地拷貝到Header欄位裡。你可以想象這個欄位會儲存許多冗餘的資料,例如一個包含很長cookie的頭部。

我們如何來優化呢?

WebSocket實現

不幸的是,在我們優化服務端的時候所有能找到的庫只支援對標準net/http服務做升級。而且沒有一個庫允許我們實現上面提到的讀和寫的優化。為了使這些優化成為可能,我們必須有一套底層的API來操作WebSocket。為了重用快取,我們需要類似下面這樣的協議函式:

func ReadFrame(io.Reader) (Frame, error)
func WriteFrame(io.Writer, Frame) error

如果我們有一個包含這樣API的庫,我們就按照下面的方式從連線上讀取packets:

// getReadBuf, putReadBuf are intended to
// reuse *bufio.Reader (with sync.Pool for example).
func getReadBuf(io.Reader) *bufio.Reader
func putReadBuf(*bufio.Reader)

// readPacket must be called when data could be read from conn.
func readPacket(conn io.Reader) error {
    buf := getReadBuf()
    defer putReadBuf(buf)

    buf.Reset(conn)
    frame, _ := ReadFrame(buf)
    parsePacket(frame.Payload)
    //...
}

簡而言之,我們需要自己寫一個庫。

github.com/gobwas/ws

ws庫的主要設計思想是不將協議的操作邏輯暴露給使用者。所有讀寫函式都接受通用的io.Readerio.Writer介面。因此它可以隨意搭配是否使用快取以及其它I/O的庫。

除了標準庫net/http裡的升級請求,ws還支援零拷貝升級。它能夠處理升級請求並切換到WebSocket模式而不產生任何記憶體分配或者拷貝。ws.Upgrade()接受io.ReadWriter (net.Conn實現了這個介面)。換句話說,我們可以使用標準的net.Listen() 函式然後把從ln.Accept()收到的連線馬上交給ws.Upgrade()去處理。庫也允許拷貝任何請求資料來滿足將來應用的需求(舉個例子,拷貝Cookie來驗證一個session)。

下面是處理升級請求的效能測試:標準net/http庫的實現和使用零拷貝升級的net.Listen()

BenchmarkUpgradeHTTP    5156 ns/op    8576 B/op    9 allocs/op
BenchmarkUpgradeTCP     973 ns/op     0 B/op       0 allocs/op

使用ws以及零拷貝升級為我們節省了24 GB的空間。這些空間原本被用做net/http裡處理請求的I/O快取。

3.5. 回顧

讓我們來回顧一下之前提到過的優化:

  • 一個包含快取的讀goroutine會佔用很多記憶體。方案: netpoll(epoll, kqueue);重用快取。
  • 一個包含快取的寫goroutine會佔用很多記憶體。方案: 在需要的時候建立goroutine;重用快取。
  • 存在大量連線請求的時候,netpoll不能很好的限制連線數。方案: 重用goroutines並且限制它們的數目。
  • net/http對升級到WebSocket請求的處理不是最高效的。方案: 在TCP連線上實現零拷貝升級。

下面是服務端的大致實現程式碼:

import (
    "net"
    "github.com/gobwas/ws"
)

ln, _ := net.Listen("tcp", ":8080")

for {
    // Try to accept incoming connection inside free pool worker.
    // If there no free workers for 1ms, do not accept anything and try later.
    // This will help us to prevent many self-ddos or out of resource limit cases.
    err := pool.ScheduleTimeout(time.Millisecond, 
            
           

相關推薦

效能優化實戰百萬WebSocketsGo語言

SegmentFault 首頁 問答 專欄 講堂 發現 搜尋 立即登入免費註冊

Java效能優化設計優化程式優化,開發必備優化技巧!

現代大規模關鍵性系統中的Java效能調優,是一項富有挑戰的任務。你需要關注各種問題,包括演算法結構、記憶體分配模式以及磁碟和檔案I/O的使用方式。效能調優最困難的通常是找到問題所在,即便是經驗豐富的人也會被他們的直覺所誤導。效能殺手總是隱藏在最意想不到的地方。 Java效能問題一直困擾著廣大程式

Linux性能優化實戰系統中出現大量不可中斷進程僵屍進程怎麽辦(08)

怎麽辦 截圖 是你 ner rec perf 進程 while pts 一、環境準備 1、在第6節的基礎上安裝dstat wget http://mirror.centos.org/centos/7/os/x86_64/Packages/dstat-0.7.2-12

Linux性能優化實戰怎麽理解內存中的BufferCache?(16)

inux tro parsing 內核 echo buffers block sed 性能優化 一、free數據的來源 1、碰到看不明白的指標時該怎麽辦嗎? 不懂就去查手冊。用 man 命令查詢 free 的文檔、就可以找到對應指標的詳細說明。比如,我們執行 man f

RabbitMQ實戰可用性分析實現

RabbitMQ本系列是「RabbitMQ實戰:高效部署分布式消息隊列」書籍的總結筆記。 上一篇介紹了各種場景下的最佳實踐,大部分場景可以使用「發後即忘」的模式,不需要響應,如果需要響應,可以使用RabbitMQ的RPC模型。 RabbitMQ以異步的方式解耦系統間的關系,調用者將業務請求發送到Rabbit

RabbitMQ實戰界面管理監控

RabbitMQ本系列是「RabbitMQ實戰:高效部署分布式消息隊列」書籍的總結筆記。 上一篇總結了可能出現的異常場景,並對RabbitMQ提供的可用性保證進行了分析,在出現服務器宕機後,仍然可以正常服務。另外,需要盡快恢復異常的服務器,重新加入集群,推送未消費的消息,通過監控可第一時間接收到錯誤並進行處

迅雷鏈技術沙龍第一站百萬TPS是怎樣煉成的

前沿 性能 之一 隨著 表示 .com 相關 代碼 維度 9月15日下午,由迅雷集團主辦的鏈創未來?迅雷鏈技術沙龍在北京舉行,作為此系列技術沙龍的首期活動,本期邀請了來自迅雷鏈開放平臺產品負責人、研發負責人、研發工程師、HGBC等企業的技術大咖,為區塊鏈愛好者和開發者分享智

MySQL效能優化(六)分割槽

一: 分割槽簡介 分割槽是根據一定的規則,資料庫把一個表分解成多個更小的、更容易管理的部分。就訪問資料庫應用而言,邏輯上就只有一個表或者一個索引,但實際上這個表可能有N個物理分割槽物件組成,每個分割槽都是一個獨立的物件,可以獨立處理,可以作為表的一部分進行處理。分割槽對應用來說是完全

分享《機器學習實戰基於Scikit-LearnTensorFlow》高清中英文PDF+原始碼

下載:https://pan.baidu.com/s/1kNN4tDt58ckFoD_OWH5sGw 更多資料分享:http://blog.51cto.com/3215120 《機器學習實戰:基於Scikit-Learn和TensorFlow》高清中文版PDF+高清英文版PDF+原始碼 高清中文版PDF

分享《機器學習實戰基於Scikit-LearnTensorFlow》高清中英文PDF+源代碼

ESS alt mark 構建 image 機器學習實戰 dff com 化學 下載:https://pan.baidu.com/s/1kNN4tDt58ckFoD_OWH5sGw 更多資料分享:http://blog.51cto.com/3215120 《機器學習實戰:基

Mysql效能優化實戰

1、索引設計規範 常見索引列建議: SELECT、UPDATE、DELETE語句的WHERE從句中的列 包含在ORDER BY、GROUP BY、DISTINCT中的欄位,建議聯合索引 多表JOIN的關聯列 索引列的順序: 區分度最高的列放在聯合索引的最左側 儘量把欄位長度

iOS 效能優化思路介面離屏渲染、圖層混色

手機效能優化的重點,就是介面渲染。一般,計算任務都交給服務端。 介面渲染慢,就不好了。 常見問題,就是離屏渲染。 這裡用 NSShadow 處理掉 CALayer 的陰影屬性帶來的離屏渲染。 常見的離屏渲染程式碼: 繪製陰影, var label = UILabel()

分享《機器學習實戰基於Scikit-LearnTensorFlow》+PDF+Aurelien

ext https oss 模型 img kit 復制 mage 更多 下載:https://pan.baidu.com/s/127EzxtY9zdBU2vOfxEgIjQ 更多資料分享:http://blog.51cto.com/14087171 《機器學習實戰:基於Sc

SQL Server 效能優化實戰系列(一) SQL Server擴充套件函式的基本概念 使用SQL Server 擴充套件函式進行效能優化 SQL Server Url正則表示式 記憶體常駐 完美解決方案

資料庫伺服器主要用於儲存、查詢、檢索企業內部的資訊,因此需要搭配專用的資料庫系統,對伺服器的相容性、可靠性和穩定性等方面都有很高的要求。        下面是進行籠統的技術點說明,為的是讓大家有一個整體的概念,如果想深入可以逐個擊破;&n

啟發式優化演算法梯度下降法梯度上升法

梯度下降演算法理論知識我們給出一組房子面積,臥室數目以及對應房價資料,如何從資料中找到房價y與面積x1和臥室數目x2的關係?本文旨在,通過數學推導的角度介紹梯度下降法 f

程式碼效能優化3函式優化

程式碼中函式呼叫無時無刻,那麼哪些函式會產生gc呢? 1.自己寫的函式中有new物件被頻繁呼叫 2.Unity自帶的函式和自己新增的第三方外掛中不知道的new物件 接下來的介紹中用自定義函式和第三方函式區分 自定義函式分為: 1.每隔一段時間呼叫的 2.多個物件中

分享《機器學習實戰基於Scikit-LearnTensorFlow》高清中英文PDF+原始碼免費

下載:https://pan.baidu.com/s/191hQMWZYGhXtqZxbfqTDtw 《機器學習實戰:基於Scikit-Learn和TensorFlow》高清中文版PDF+高清英文版PDF+原始碼免費下載 高清中文版PDF,649頁,帶目錄和書籤,文字能夠複製貼上;高清英文版PDF

Android UI效能優化實戰 識別繪製中的效能問題

                     1、概述2015年初google釋出了Android效能優化典範,發了16個小視訊供大家欣賞,當時我也將其下載,通過微信公眾號給大家推送了百度雲的下載地址(地址在文末,ps:歡迎大家訂閱公眾號),那麼近期google又在udacity上開了系列類的相關課程。有了上述的

Web效能優化系列藉助響應式圖片來改進網站圖片顯示

開始使用 <picture> 元素 響應式網頁設計太棒了,它改變了我們向手機端使用者呈現內容的方式,無論使用者使用何種尺寸的手機,我們都能夠為其提供定製化的體驗。響應式網頁設計使用起來很靈活,也容易上手。然而,如果沒有正確使用,它會對網頁效能帶來負面影響。 用於在

sql優化實戰把full join改為left join +union all(從5分鐘降為10秒)

今天收到一個需求,要改寫一個報表的邏輯,當改完之後,再次執行,發現執行超時。 因為特殊原因,無法訪問客戶的伺服器,沒辦法檢視sql的執行計劃、沒辦法知道表中的索引情況,所以,嘗試從語句的改寫上來優化。