使用 Go 语言徒手撸一个负载均衡器
负载均衡器在 Web 架构中扮演着非常重要的角色,被用于为多个后端分发流量负载,提升服务的伸缩性。负载均衡器后面配置了多个服务,在某个服务发生故障时,负载均衡器可以很快地选择另一个可用的服务,所以整体的服务可用性得到了提升。
自研负载均衡器的工作原理
负载均衡器在向后端服务分发流量负载时可以使用几种策略。
轮询(Round Robin)——均匀地分发流量负载,假设所有后端服务都具有同样的处理能力;
加权轮询(Weighted Round Robin)——根据后端服务的处理能力加权;
最少连接(Least Connections)——优先把流量负载分发给连接最少的后端。
我打算实现最简单的策略,即轮询。
一、轮询选择
轮询的原理非常简单,后端服务有平等的机会处理任务。
如上图所示,轮询过程是循环不断的,但我们不能直接使用这种方式。
如果其中的一个后端发生故障该怎么办?我们当然不希望把流量定向给它。我们只能把流量路由给正常运行的服务。
二、定义结构体
我们需要知道所有后端服务器的状态,比如一个服务是死了还是活着,还要跟踪它们的 url。
我们可以定义一个结构体来保存后端的信息。
type Backend struct {
URL *url.URL
Alive bool
mux sync.RWMutex
ReverseProxy *httputil.ReverseProxy
}
我们还需要一种方式来跟踪所有后端,以及一个计算器变量。
type ServerPool struct {
backends []*Backend
current uint64
}
三、使用 ReverseProxy
之前说过,负载均衡器的作用是将流量负载分发到后端的服务器上,并将结果返回给客户端。
根据 Go 语言文档的描述:
这刚好是我们想要的,所以我们没有必要重复发明轮子。我们可以直接使用 ReverseProxy 来中继初始请求。
u, _ := url.Parse("http://localhost:8080")
rp := httputil.NewSingleHostReverseProxy(u) // 初始化服务器,并添加处理器
http.HandlerFunc(rp.ServeHTTP)
我们使用 httputil.NewSingleHostReverseProxy(url) 初始化一个反向代理,这个反向代理可以将请求中继到指定的 url。在上面的例子中,所有的请求都会被中继到 localhost:8080,结果被发送给初始客户端。
如果看一下 ServeHTTP 方法的签名,我们会发现它返回的是一个 HTTP 处理器,所以我们可以将它传给 http 的 HandlerFunc。
在我们的例子中,可以使用 Backend 里的 URL 来初始化 ReverseProxy,这样反向代理就会把请求路由给指定的 URL。
四、选择的过程
在选择下一个服务器时,我们需要跳过已经死掉的服务器,但不管怎样,我们都需要一个计数器。
因为有很多客户端连接到负载均衡器,所以发生竟态条件是不可避免的。为了防止这种情况,我们需要使用 mutex 给 ServerPool 加锁。但这样做对性能会有影响,更何况我们并不是真想要给 ServerPool 加锁,我们只是想要更新计数器。
最理想的解决方案是使用原子操作,Go 语言的 atomic 包为此提供了很好的支持。
func (s *ServerPool) NextIndex() int {
return int(atomic.AddUint64(&s.current, uint64(1)) % uint64(len(s.backends)))
}
我们通过原子操作递增 current 的值,并通过对 slice 的长度取模来获得当前索引值。所以,返回值总是介于 0 和 slice 的长度之间,毕竟我们想要的是索引值,而不是总的计数值。
五、选择可用的后端
我们需要循环将请求路由到后端的每一台服务器上,但要跳过已经死掉的服务。
GetNext() 方法总是返回一个介于 0 和 slice 长度之间的值,如果这个值对应的服务器不可用,我们需要遍历一遍 slice。
遍历一遍 slice
如上图所示,我们将从 next 位置开始遍历整个列表,但在选择索引时,需要保证它处在 slice 的长度之内,这个可以通过取模运算来保证。
在找到可用的服务器后,我们将它标记为当前可用服务器。
上述操作对应的代码如下。
// GetNextPeer 返回下一个可用的服务器
func (s *ServerPool) GetNextPeer() *Backend {
// 遍历后端列表,找到可用的服务器
next := s.NextIndex()
l := len(s.backends) + next // 从 next 开始遍历
for i := next; i < l; i++ {
idx := i % len(s.backends) // 通过取模计算获得索引
// 如果找到一个可用的服务器,将它作为当前服务器。如果不是初始的那个,就把它保存下来
if s.backends[idx].IsAlive() {
if i != next {
atomic.StoreUint64(&s.current, uint64(idx)) // 标记当前可用服务器
}
return s.backends[idx]
}
}
return nil
}
六、避免竟态条件
我们还需要考虑到一些情况,比如不同的 goroutine 会同时访问 Backend 结构体里的一个变量。
我们知道,读取这个变量的 goroutine 比修改这个变量的要多,所以我们使用 RWMutex 来串行化对 Alive 的访问操作。
// SetAlive
func (b *Backend) SetAlive(alive bool) {
b.mux.Lock()
b.Alive = alive
b.mux.Unlock()
} // 如果后端还活着,IsAlive 返回 true
func (b *Backend) IsAlive() (alive bool) {
b.mux.RLock()
alive = b.Alive
b.mux.RUnlock()
return
}
七、对请求进行负载均衡
在有了上述的这些东西之后,接下来就可以用下面这个简单的办法来对请求进行负载均衡。只有当所有的后端服务都死掉它才会退出。
// lb 对入向请求进行负载均衡
func lb(w http.ResponseWriter, r *http.Request) {
peer := serverPool.GetNextPeer()
if peer != nil {
peer.ReverseProxy.ServeHTTP(w, r)
return
}
http.Error(w, "Service not available", http.StatusServiceUnavailable)
}
这个方法可以作为 HandlerFunc 传给 http 服务器。
server := http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: http.HandlerFunc(lb),
}
八、只将流量路由给活跃的服务器
现在的 lb 方法存在一个严重的问题,我们并不知道后端服务是否处于正常的运行状态。为此,我们需要尝试发送请求,检查一下它是否正常。
我们可以通过两种方法来达到目的:
主动(Active):在处理当前请求时,如果发现当前的后端没有响应,就把它标记为已宕机。
被动(Passive):在固定的时间间隔内对后端服务器执行 ping 操作,以此来检查服务器的状态。
九、主动模式
在发生错误时,ReverseProxy 会触发 ErrorHandler 回调函数,我们可以利用它来检查故障。
1
proxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, e error) {
log.Printf("[%s] %s\n", serverUrl.Host, e.Error())
retries := GetRetryFromContext(request)
if retries < 3 {
select {
case <-time.After(10 * time.Millisecond):
ctx := context.WithValue(request.Context(), Retry, retries+1)
proxy.ServeHTTP(writer, request.WithContext(ctx))
}
return
} // 在三次重试之后,把这个后端标记为宕机
serverPool.MarkBackendStatus(serverUrl, false) // 同一个请求在尝试了几个不同的后端之后,增加计数
attempts := GetAttemptsFromContext(request)
log.Printf("%s(%s) Attempting retry %d\n", request.RemoteAddr, request.URL.Path, attempts)
ctx := context.WithValue(request.Context(), Attempts, attempts+1)
lb(writer, request.WithContext(ctx))
}
我们使用强大的闭包来实现错误处理器,它可以捕获外部变量错误。它会检查重试次数,如果小于 3,就把同一个请求发送给同一个后端服务器。之所以要进行重试,是因为服务器可能会发生临时错误,在经过短暂的延迟(比如服务器没有足够的 socket 来接收请求)之后,服务器又可以继续处理请求。我们使用了一个计时器,把重试时间间隔设定在 10 毫秒左右。
在重试失败之后,我们就把这个后端标记为宕机。
接下来,我们要找出新的可用后端。我们使用 context 来维护重试次数。在增加重试次数后,我们把它传回 lb,选择一个新的后端来处理请求。
但我们不能不加以限制,所以我们会在进一步处理请求之前检查是否达到了最大的重试上限。
我们从请求里拿到重试次数,如果已经达到最大上限,就终结这个请求。
// lb 对传入的请求进行负载均衡
func lb(w http.ResponseWriter, r *http.Request) {
attempts := GetAttemptsFromContext(r)
if attempts > 3 {
log.Printf("%s(%s) Max attempts reached, terminating\n", r.RemoteAddr, r.URL.Path)
http.Error(w, "Service not available", http.StatusServiceUnavailable)
return
} peer := serverPool.GetNextPeer()
if peer != nil {
peer.ReverseProxy.ServeHTTP(w, r)
return
}
http.Error(w, "Service not available", http.StatusServiceUnavailable)
}
十、context 的使用
我们可以利用 context 在 http 请求中保存有用的信息,用它来跟踪重试次数。
首先,我们需要为 context 指定键。我们建议使用不冲突的整数值作为键,而不是字符串。Go 语言提供了 iota 关键字,可以用来实现递增的常量,每一个常量都包含了唯一值。这是一种完美的整型键解决方案。
const (
Attempts int = iota
Retry
)
然后我们就可以像操作 HashMap 那样获取这个值。默认返回值要视情况而定。
// GetAttemptsFromContext 返回尝试次数
func GetRetryFromContext(r *http.Request) int {
if retry, ok := r.Context().Value(Retry).(int); ok {
return retry
}
return 0
}
十一、被动模式
被动模式就是定时对后端执行 ping 操作,以此来检查它们的状态。
我们通过建立 TCP 连接来执行 ping 操作。如果后端及时响应,我们就认为它还活着。当然,如果你喜欢,也可以改成直接调用某个端点,比如 /status。切记,在执行完操作后要关闭连接,避免给服务器造成额外的负担,否则服务器会一直维护连接,最后把资源耗尽。
// isAlive 通过建立 TCP 连接检查后端是否还活着
func isBackendAlive(u *url.URL) bool {
timeout := 2 * time.Second
conn, err := net.DialTimeout("tcp", u.Host, timeout)
if err != nil {
log.Println("Site unreachable, error: ", err)
return false
}
_ = conn.Close() // 不需要维护连接,把它关闭
return true
}
现在我们可以遍历服务器,并标记它们的状态。
// HealthCheck 对后端执行 ping 操作,并更新状态
func (s *ServerPool) HealthCheck() {
for _, b := range s.backends {
status := "up"
alive := isBackendAlive(b.URL)
b.SetAlive(alive)
if !alive {
status = "down"
}
log.Printf("%s [%s]\n", b.URL, status)
}
}
我们可以启动定时器来定时发起 ping 操作。
// healthCheck 返回一个 routine,每 2 分钟检查一次后端的状态
func healthCheck() {
t := time.NewTicker(time.Second * )
for {
select {
case <-t.C:
log.Println("Starting health check...")
serverPool.HealthCheck()
log.Println("Health check completed")
}
}
}
在上面的例子中,<-t.C 每 20 秒返回一个值,select 会检测到这个事件。在没有 default case 的情况下,select 会一直等待,直到有满足条件的 case 被执行。
最后,使用单独的 goroutine 来执行。
go healthCheck()
十二、测试
负载均衡代码
package main import (
"context"
"flag"
"fmt"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
) const (
Attempts int = iota
Retry
) //定义结构体
//后端保存关于服务器的数据
type Backend struct {
URL *url.URL
Alive bool
mux sync.RWMutex
ReverseProxy *httputil.ReverseProxy
} //跟踪所有后端,以及一个计算器变量
type ServerPool struct {
backends []*Backend
current uint64
} // SetAlive
func (b *Backend) SetAlive(alive bool) {
b.mux.Lock()
b.Alive = alive
b.mux.Unlock()
} // 如果后端还活着,IsAlive 返回 true
func (b *Backend) IsAlive() (alive bool) {
b.mux.RLock()
alive = b.Alive
b.mux.RUnlock()
return
} // lb 对入向请求进行负载均衡
func lb(w http.ResponseWriter, r *http.Request) {
//重试次数,如果已经达到最大上限,就终结这个请求
attempts := GetAttemptsFromContext(r)
if attempts > {
log.Printf("%s(%s) Max attempts reached, terminating\n", r.RemoteAddr, r.URL.Path)
http.Error(w, "Service not available", http.StatusServiceUnavailable)
return
} peer := serverPool.GetNextPeer()
if peer != nil {
peer.ReverseProxy.ServeHTTP(w, r)
return
}
http.Error(w, "Service not available", http.StatusServiceUnavailable)
} // 自动增加计数器并返回一个索引,使用atomic 保证原子性
//通过原子操作递增 current 的值,并通过对 slice 的长度取模来获得当前索引值。所以,返回值总是介于 0 和 slice 的长度之间,毕竟我们想要的是索引值,而不是总的计数值。
func (s *ServerPool) NextIndex() int {
return int(atomic.AddUint64(&s.current, uint64()) % uint64(len(s.backends)))
} // GetNextPeer返回下一个活动的对等点以获取连接
//找到可用的服务器后,我们将它标记为当前可用服务器。
func (s *ServerPool) GetNextPeer() *Backend {
// 循环整个后端,找出一个活动后端
next := s.NextIndex()
l := len(s.backends) + next // 从next开始移动一个完整的周期
for i := next; i < l; i++ {
idx := i % len(s.backends) // take an index by modding
if s.backends[idx].IsAlive() { // if we have an alive backend, use it and store if its not the original one
if i != next {
atomic.StoreUint64(&s.current, uint64(idx))
}
return s.backends[idx]
}
}
return nil
} // GetAttemptsFromContext 返回尝试次数
func GetRetryFromContext(r *http.Request) int {
if retry, ok := r.Context().Value(Retry).(int); ok {
return retry
}
return
} // healthCheck runs a routine for check status of the backends every 2 mins
// healthCheck 返回一个 routine,每 2 分钟检查一次后端的状态
func healthCheck() {
t := time.NewTicker(time.Second * )
for {
select {
case <-t.C:
log.Println("Starting health check...")
serverPool.HealthCheck()
log.Println("Health check completed")
}
}
} // HealthCheck ping后端并更新状态
func (s *ServerPool) HealthCheck() {
for _, b := range s.backends {
status := "up"
alive := isBackendAlive(b.URL)
b.SetAlive(alive)
if !alive {
status = "down"
}
log.Printf("%s [%s]\n", b.URL, status)
}
} // isAlive checks whether a backend is Alive by establishing a TCP connection
// isAlive 通过建立 TCP 连接检查后端是否还活着
func isBackendAlive(u *url.URL) bool {
timeout := * time.Second
conn, err := net.DialTimeout("tcp", u.Host, timeout)
if err != nil {
log.Println("Site unreachable, error: ", err)
return false
}
_ = conn.Close() // 不需要维护连接,把它关闭
return true
} // GetAttemptsFromContext returns the attempts for request
func GetAttemptsFromContext(r *http.Request) int {
if attempts, ok := r.Context().Value(Attempts).(int); ok {
return attempts
}
return
} // AddBackend to the server pool
func (s *ServerPool) AddBackend(backend *Backend) {
s.backends = append(s.backends, backend)
} // MarkBackendStatus changes a status of a backend
func (s *ServerPool) MarkBackendStatus(backendURL *url.URL, alive bool) {
for _, b := range s.backends {
if b.URL.String() == backendURL.String() {
b.SetAlive(alive)
break
}
}
} var serverPool ServerPool func main() {
var serverList string
var port int
flag.StringVar(&serverList, "backends", "http://localhost:3302,http://localhost:3303,http://localhost:3304", "Load balanced backends, use commas to separate")
flag.IntVar(&port, "port", , "Port to serve")
flag.Parse() if len(serverList) == {
log.Fatal("Please provide one or more backends to load balance")
} // 解析服务器
tokens := strings.Split(serverList, ",")
//range类似迭代器,可以遍历
for _, tok := range tokens {
serverURL, err := url.Parse(tok)
if err != nil {
log.Fatal(err)
} //使用 httputil.NewSingleHostReverseProxy(url) 初始化一个反向代理
proxy := httputil.NewSingleHostReverseProxy(serverURL) //在发生错误时,ReverseProxy 会触发 ErrorHandler 回调函数,我们可以利用它来检查故障。
proxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, e error) {
log.Printf("[%s] %s\n", serverURL.Host, e.Error())
retries := GetRetryFromContext(request)
if retries < {
select {
case <-time.After( * time.Millisecond):
ctx := context.WithValue(request.Context(), Retry, retries+)
proxy.ServeHTTP(writer, request.WithContext(ctx))
}
return
} // 在三次重试之后,把这个后端标记为宕机
serverPool.MarkBackendStatus(serverURL, false) // 同一个请求在尝试了几个不同的后端之后,增加计数
attempts := GetAttemptsFromContext(request)
log.Printf("%s(%s) Attempting retry %d\n", request.RemoteAddr, request.URL.Path, attempts)
ctx := context.WithValue(request.Context(), Attempts, attempts+)
lb(writer, request.WithContext(ctx))
} serverPool.AddBackend(&Backend{
URL: serverURL,
Alive: true,
ReverseProxy: proxy,
})
log.Printf("Configured server: %s\n", serverURL) }
// 初始化服务器,并添加处理器
// create http server
server := http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: http.HandlerFunc(lb),
} // start health checking
go healthCheck() log.Printf("Load Balancer started at :%d\n", port)
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
直接运行就好了
web服务器代码
package main import (
"flag"
"fmt"
"log"
"net/http"
"strconv"
) func sayhelloName(w http.ResponseWriter, r *http.Request) {
r.ParseForm() //解析参数,默认是不会解析的
fmt.Fprintln(w, "Hello moon!") //这个写入到w的是输出到客户端的
fmt.Fprintln(w, "count:"+strconv.Itoa(port)) //这个写入到w的是输出到客户端的
count++
fmt.Fprintln(w, "count:"+strconv.Itoa(count)) //这个写入到w的是输出到客户端的 } var port int
var count int func main() {
flag.IntVar(&port, "port", , "duan端口号,默认3302") // 【必须调用】从 arguments 中解析注册的 flag
flag.Parse()
fmt.Printf("port=%v \n", port)
http.HandleFunc("/", sayhelloName) //设置访问的路由
err := http.ListenAndServe(":"+strconv.Itoa(port), nil) //设置监听的端口
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}
使用方法
go run web.go -port=
go run web.go -port=
go run web.go -port=
这里web.go是代码文件名
测试
访问http://localhost:3031/并刷新
十三、结论
这篇文章提到了很多东西:
轮询;
Go 语言标准库里的 ReverseProxy;
mutex;
原子操作;
闭包;
回调;
select。
这个简单的负载均衡器还有很多可以改进的地方:
使用堆来维护后端的状态,以此来降低搜索成本;
收集统计信息;
实现加权轮询或最少连接策略;
支持文件配置。
代码地址:
https://github.com/kasvith/simplelb/
原文连接:
https://kasvith.github.io/posts/lets-create-a-simple-lb-go/
使用 Go 语言徒手撸一个负载均衡器的更多相关文章
- 徒手撸一个 Spring Boot 中的 Starter ,解密自动化配置黑魔法!
我们使用 Spring Boot,基本上都是沉醉在它 Stater 的方便之中.Starter 为我们带来了众多的自动化配置,有了这些自动化配置,我们可以不费吹灰之力就能搭建一个生产级开发环境,有的小 ...
- 从零开始徒手撸一个vue的toast弹窗组件
相信普通的vue组件大家都会写,定义 -> 引入 -> 注册 -> 使用,行云流水,一气呵成,但是如果我们今天是要自定义一个弹窗组件呢? 首先,我们来分析一下弹窗组件的特性(需求): ...
- 大型情感类技术连续剧-徒手撸一个 uTools(二)
前言 上篇手把手教你实现一个支持插件化的 uTools 工具箱我们介绍过了如何通过 electron 实现 utools 的插件功能体系,并按照 utools 的交互和设计做出了一套可以支持插件化的桌 ...
- 徒手撸一个简单的RPC框架
来源:https://juejin.im/post/5c4481a4f265da613438aec3 之前在牛逼哄哄的 RPC 框架,底层到底什么原理得知了RPC(远程过程调用)简单来说就是调用远程的 ...
- 手把手教你用netty撸一个ZkClient
原文地址: https://juejin.im/post/5dd296c0e51d4508182449a6 前言 有这个想法的缘由是前一阵子突发奇想, 想尝试能不能直接利用js连接到zookeeper ...
- 徒手生撸一个验证框架,API 参数校验不再怕!
你们之中大概率早已练就了代码的拷贝.粘贴,无敌的码农神功,其实做久了业务功能开发,练就这两个无敌神功,那是迟早的事儿.今天先抛一个小问题,来打通你的任督二脉,就是很好奇的问一下:业务功能开发中,输入参 ...
- 手把手教你撸一个 Webpack Loader
文:小 boy(沪江网校Web前端工程师) 本文原创,转载请注明作者及出处 经常逛 webpack 官网的同学应该会很眼熟上面的图.正如它宣传的一样,webpack 能把左侧各种类型的文件(webpa ...
- 【从零开始撸一个App】Kotlin
工欲善其事必先利其器.像我们从零开始撸一个App的话,选择最合适的语言是首要任务.如果你跟我一样对Java蹒跚的步态和僵硬的语法颇感无奈,那么Kotlin在很大程度上不会令你失望.虽然为了符合JVM规 ...
- 看了 Spring 官网脚手架真香,也撸一个 SpringBoot DDD 微服务的脚手架!
作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 为什么我们要去造轮子? 造轮子的核心目的,是为了解决通用共性问题的凝练和复用. 虽然 ...
随机推荐
- bootm跳转到kernel的流程
转自 https://blog.csdn.net/ooonebook/article/details/53495021 一.bootm说明 bootm这个命令用于启动一个操作系统映像.它会从映像文件的 ...
- 25-C#笔记-文件的输入输出
1. 写txt FileStream F = new FileStream("sample.txt", FileMode.Open, FileAccess.Read, FileSh ...
- 重新学习Spring注解——AOP
面向切面编程——思想:在一个地方定义通用功能,但是可以通过声明的方式定义这个功能要以何种方式在何处运用,而无须修改受影响的类. 切面:横切关注点可以被模块化为特殊的类. 优点: 1.每个关注点都集中在 ...
- 排序算法-插入排序(Java)
package com.rao.linkList; import java.util.Arrays; /** * @author Srao * @className InsertSort * @dat ...
- 洛谷 P1816 忠诚 题解
P1816 忠诚 题目描述 老管家是一个聪明能干的人.他为财主工作了整整10年,财主为了让自已账目更加清楚.要求管家每天记k次账,由于管家聪明能干,因而管家总是让财主十分满意.但是由于一些人的挑拨,财 ...
- python总结八
1.range的使用讲解: 首先呢如果只是一个参数的话,那么就是循环遍历这个参数递增,例如 range(5)>>[1,2,3,4] 如果是两个参数的话,且注意后面的第二个参数要比第一个大, ...
- python总结七
1.lpython是python语言的lisp前端,它类似于普通的lisp语言,他会被直接编译成字节码. 2.python中的变量只在哪里需要设置内部变量,在__init__函数中. 3.map()会 ...
- 每日一问:讲讲 Java 虚拟机的垃圾回收
昨天我们用比较精简的文字讲了 Java 虚拟机结构,没看过的可以直接从这里查看: 每日一问:你了解 Java 虚拟机结构么? 今天我们必须来看看 Java 虚拟机的垃圾回收算法是怎样的.不过在开始之前 ...
- Spring Boot 《一》开发一个“HelloWorld”的 web 应用
一,Spring Boot 介绍 Spring Boot不是一个新的框架,默认配置了多种框架使用方式,使用SpringBoot很容易创建一个独立运行(运行jar,内嵌Servlet).准生产级别的基于 ...
- 20165230田坤烨网络对抗免考报告_Windows系统提权
目录 KERNEL EXPLOITATION 服务攻击: DLL劫持 攻击 不安全的服务权限 探测 unquoted path未被引号标记的路径 探测 攻击 服务注册表键 探测 攻击 Named Pi ...