golang 實現四層負載均衡

大家好,我是藍胖子,做開發的同學應該經常聽到過負載均衡的概念,今天我們就來實現一個乞丐版的四層負載均衡,並用它對 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 數據庫。到時候連上不同服務器數據庫是不一樣的。

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。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/LXpfUl-8UtR0BprJA7mnUw