golang 實現四層負載均衡

2023-06-30 18:01:17

大家好,我是藍胖子,做開發的同學應該經常聽到過負載均衡的概念,今天我們就來實現一個乞丐版的四層負載均衡,並用它對mysql進行負載均衡測試,通過本篇你可以瞭解到零拷貝的應用,四層負載均衡的本質以及實踐。

本文程式碼已經上傳到github

https://github.com/HobbyBear/codelearning/tree/master/layer4balance

為了知識的完整性,我們也科普下七層負載均衡的概念,我們先簡單瞭解下四層負載均衡和7層負載均衡的區別。

四層負載均衡和七層負載均衡

七層負載均衡

首先,我們來看下七層負載均衡,它一般是針對應用層請求協定做請求轉發,拿http請求舉例,有A,B兩臺伺服器,如果採用輪詢的負載均衡策略,負載均衡器將第一個請求轉發給了A伺服器,那麼第二個請求到達時,負載均衡器就會把請求轉發到B伺服器。

在轉發時,能夠在應用協定層對請求做一些變動,拿http請求來說,可以對http的請求頭,http路徑做相應的變動。

四層負載均衡

再來看看四層負載均衡,它一般是指標對連線做的負載均衡,舉例說明下,有A,B兩臺伺服器,同樣採取輪詢的策略,某個使用者端發起一個新的連線,經過均衡器連線到了A伺服器,現在又來一個使用者端同樣發起連線,經過均衡器後,此時就該和B伺服器建立連線了。而在同一個連線裡是能夠傳送多個請求的,這也是和七層負載均衡最本質的區別,它是針對連線做的負載均衡。

實現四層負載均衡器

實現四層負載均衡策略的方式有很多,比較著名的四層負載均衡軟體就有lvs,它是通過修改封包的ip地址或者mac地址實現四層負載均衡,效能較好,工作模式有好幾種,具體的就不在本文展開了。

本文實現的四層負載均衡的原理和nginx四層負載類似 ,通過均衡器在使用者端和伺服器端之前都維護一個連線來達到讓 使用者端在同一個連線裡傳送的請求都會被伺服器端同一個連線所接收的目的。如下圖所示:

以後client1 通過連線A發的請求都會由連線B發往伺服器,而client2通過連線C傳送的請求,都將經過連線D發往另一臺伺服器。

實現邏輯

現在讓我們來實現下這部分的邏輯,我將會以輪詢的策略實現連線的負載均衡。

並且這裡還要考慮下實現資料複製的邏輯,我們需要在均衡器分別建立對使用者端和伺服器端的socket連線,並且將其中一個socket的資料轉移到另一個socket,如果每次都將某一個socket資料讀到使用者層,再寫到另一個socket就會導致一些沒有必要的拷貝。虛擬碼如下:

var (
src net.Conn  // 一個socket 連線
dst net.Conn  // 一個socket連線
)
// ...
buf = make([]byte, size)    
nr, er := src.Read(buf)
nw, ew := dst.Write(buf[0:nr])

有沒有什麼技術讓核心自動將某個socket的資料轉移到另一個socket,不用將資料拷貝到應用層來,這正是零拷貝相關的技術,關於零拷貝的技術原理我在之前這篇文章 有很詳細的介紹,核心提供了一個splice的系統呼叫,專門用於socket連線間拷貝資料,只需要呼叫時傳入對應socket連線的檔案描述符即可讓核心自動完成拷貝過程。

func Splice(rfd int, roff *int64, wfd int, woff *int64, len int, flags int) (n int64, err error) 

這個系統呼叫已經被golang更深層次的封裝到了一個比較常用的方法io.Copy裡,這個方法會自動判斷reader和writer底層的型別,如果都是socket連線則會呼叫splice系統呼叫實現零拷貝。

func Copy(dst Writer, src Reader) (written int64, err error) {  
   return copyBuffer(dst, src, nil)  
}

接著我們看下均衡的程式碼邏輯,執行邏輯如下:

1, 監聽到新連線,啟動一個協程去處理連線。

2 , 在新協程裡與通過輪詢的策略,選擇一個後端伺服器並與之建立連線。

3, 啟動兩個協程分別進行io.Copy ,將使用者端的socket寫到伺服器端socket,將伺服器端socket返回的資訊寫到使用者端socket。程式碼如下:

type Server struct {  
   Li      net.Listener  
   Balance balancepolicy.Policy  
}  
  
func (s *Server) Run() {  
   for {  
      c, err := s.Li.Accept()  
      if err != nil {  
         log.Fatal(err)  
      }  
      go func(c net.Conn) {  
         remoteAddr := c.RemoteAddr()  
         backendIp := s.Balance.PickNode(remoteAddr.String())  
         serverConn, err := net.Dial("tcp", backendIp)  
         if err != nil {  
            log.Fatal(err)  
            c.Close()  
            return  
         }  
         fmt.Println("獲取到了新連線", remoteAddr, backendIp)  
         go func() {  
            _, err := io.Copy(serverConn, c)  
            if err != nil {  
               fmt.Println(err, 1)  
            }  
            c.Close()  
            serverConn.Close()  
            fmt.Println("結束1", err)  
         }()  
         go func() {  
            _, err := io.Copy(c, serverConn)  
            if err != nil {  
               fmt.Println(err, 2)  
            }  
            c.Close()  
            serverConn.Close()  
            fmt.Println("結束2", err)  
         }()  
      }(c)  
   }  
  
}

io.Copy 會不斷的拷貝源socket的資料到目的socket,直到連線關閉。

更好的方案

可以看到上述方案中維護一個使用者端的連線將會啟動3個協程,當連線量上去後,均衡器很可能成為瓶頸,有沒有辦法減少下協程的數量,可以直接採用epoll的方式監聽連線的讀寫,以及關閉事件(這樣能在一個協程裡處理多個連線),當連線可讀時,直接使用splice系統呼叫對資料進行拷貝直到返回syscall.EAGAIN 就停止,因為返回syscall.EAGAIN 說明連線緩衝區內的資料暫時被讀取完了,繼續下一次epoll wait的監聽迴圈。這樣能極大的減少協程數量。不過實現我就不準備再繼續展開了,後續有空再補充下這部分。對epoll的使用有興趣的同學也可以看看我之前一篇用epoll實現類似redis的網路模型框架這篇文章

測試負載均衡程式碼

現在讓我們來測試下負載均衡的程式碼,我會用docker-compose去啟動兩個mysql,然後本地啟動我們負載均衡器的程式碼,之後用兩個mysql使用者端去連線負載均衡器,看下是不是mysql使用者端連線到了不同的mysql伺服器。

docker-compose的組態檔如下:

version: '3'  
services:  
  mysql1:  
    restart: always  
    image: amd64/mysql:latest  
    container_name: mysql1  
    environment:  
      - "MYSQL_ROOT_PASSWORD=1234567"  
      - "MYSQL_DATABASE=test"  
    ports:  
      - "3306:3306"  
  
  mysql2:  
    restart: always  
    image: amd64/mysql:latest  
    container_name: mysql2  
    environment:  
      - "MYSQL_ROOT_PASSWORD=1234567"  
      - "MYSQL_DATABASE=test2"  
    ports:  
      - "3307:3306"

為了能驗證不同使用者端的確連上了不同的mysql伺服器,我在mysql1上建立了test資料庫,在mysql2上建立了test2資料庫。到時候連上不同伺服器資料庫是不一樣的。

均衡伺服器監聽5555埠啟動

s := &proxy.Server{}  
li, err := net.Listen("tcp", ":5555")  
if err != nil {  
   log.Fatal(err)  
}  
s.Li = li  
s.Balance = balancepolicy.NewRoundRobin()  
s.Balance.AddNode("127.0.0.1:3306", "mysql1")  
s.Balance.AddNode("127.0.0.1:3307", "mysql2")  
s.Run()

之後用mysql使用者端去連線均衡伺服器

## client1
mysql -h 127.0.0.1 -u root  -P 5555  -D test  -p1234567

## client2
mysql -h 127.0.0.1 -u root  -P 5555  -D test2  -p1234567

發現兩個mysql使用者端的確連線到了不同伺服器,並且能正常執行命令,over。