前言
高可用主要解决以下问题:
- 不是单个节点,任何一个服务节点挂了,能够自动使用其他业务节点。
- 允许新的服务节点进入服务群体,并且是客户端无感知。
不同的服务协议,解决方案也不同。
grpc
- 推荐指数: 3星
- 推荐理由: 当前主流的etcd+grpc架构,有成熟的套件,有大量实际的案例,以及足够的可问人群。
grpc协议,主流使用基于 etcd 的服务发现做高可用。他的业务场景是服务群的内网相互调用。每一个服务节点,在发布时,都会将服务的host+port注册进etcd,key值是某个服务群名称的前缀,比如 /user/node/1, /user/node/2。这一步注册必须保持每5秒一次的租约。
缺点是,需要对开放的业务逻辑,重写方法,适配到grpc服务里。客户端代码需要重写。proto-gen-go版本在服务端与服务端之间的调用里,必须 统一。可以说是比较恶心的缺点了。
当某一个服务需要调用时,会以前缀匹配的形式,从etcd拿到可用列表,进而均衡得选择可用的服务。
etcd官方提供grpc协议的高可用最佳实践,这里贴一下关键代码:
调用方
import (
"go.etcd.io/etcd/clientv3"
etcdnaming "go.etcd.io/etcd/clientv3/naming"
"google.golang.org/grpc"
)
...
cli, cerr := clientv3.NewFromURL("http://localhost:2379")
r := &etcdnaming.GRPCResolver{Client: cli}
b := grpc.RoundRobin(r)
conn, gerr := grpc.Dial("my-service", grpc.WithBalancer(b), grpc.WithBlock(), ...)
续约的服务方
go etcd.Register("x.x.x.x:port", "app_key", "y.y.y.y:port", 5)
package etcd
import (
"context"
"encoding/json"
"go.uber.org/zap"
"log"
"strings"
"time"
"fmt"
"go.etcd.io/etcd/client/v3"
)
var cli *clientv3.Client
// Register register service with name as prefix to etcd, multi etcd addr should use ; to split
func Register(etcdAddr, name string, addr string, ttl int64) error {
var err error
if cli == nil {
cli, err = clientv3.New(clientv3.Config{
Endpoints: strings.Split(etcdAddr, ";"),
DialTimeout: 15 * time.Second,
LogConfig: &zap.Config{
Level: zap.NewAtomicLevelAt(zap.ErrorLevel),
Development: false,
Sampling: &zap.SamplingConfig{
Initial: 100,
Thereafter: 100,
},
Encoding: "json",
EncoderConfig: zap.NewProductionEncoderConfig(),
// Use "/dev/null" to discard all
OutputPaths: []string{"stderr"},
ErrorOutputPaths: []string{"stderr"},
},
})
if err != nil {
return err
}
}
service := Service{
Addr: addr,
}
bts, err := json.Marshal(service)
if err != nil {
return err
}
serviceValue := string(bts)
serviceKey := fmt.Sprintf("%s/%s", name, serviceValue)
ticker := time.NewTicker(time.Second * time.Duration(ttl))
go func() {
for {
getResp, err := cli.Get(context.Background(), serviceKey)
if err != nil {
log.Println(err)
} else if getResp.Count == 0 {
err = withAlive(serviceKey, serviceValue, ttl)
if err != nil {
log.Println(err)
}
} else {
// do nothing
}
<-ticker.C
}
}()
return nil
}
type Service struct {
Addr string `json:"Addr"`
}
func withAlive(serviceKey string, serviceValue string, ttl int64) error {
leaseResp, err := cli.Grant(context.Background(), ttl)
if err != nil {
return err
}
fmt.Printf("key:%v\n", serviceKey)
_, err = cli.Put(context.Background(), serviceKey, serviceValue, clientv3.WithLease(leaseResp.ID))
if err != nil {
return err
}
ch, err := cli.KeepAlive(context.Background(), leaseResp.ID)
if err != nil {
log.Println(err)
return err
}
// ch管道的值需要持续取出释放,否则会占用通道导致切片饱和
go func() {
for {
_ , ok:= <-ch
if !ok {
return
}
}
}()
return nil
}
// UnRegister remove service from etcd
func UnRegister(serviceKey string) {
if cli != nil {
cli.Delete(context.Background(), serviceKey)
}
}
http
http的高可用解决方案比较多,大致有以下三种主流:
- nginx前置路由,通过upstream配置可用的服务节点
- 腾讯云后台支持域名<负载均衡>到多个ip,并且通过<健康检查>做到和nginx同样的效果。
- 手动实现基于http的robin均衡器,接入etcd
第一种
- 推荐指数: 3星
- 理由: 需要人工上服务器维护节点增减,不是很简约。 高可用依赖upstream,无损迁流和重启,需要人工参与,比较笨。
- 在服务前置,有一个nginx集群, 每个nginx里有如下配置示例:
upstream srv_name_http {
server y.y.y.y:8112 weight=7;
server x.x.x.x:8112 weight=3;
server z.z.z.z:8112 weight=10;
}
server {
listen 80;
server_name your.addr.com;
error_log /data/log/nginx/your.addr.com.log;
# request header
proxy_read_timeout 3200;
proxy_send_timeout 3200;
proxy_set_header Host $http_host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location / {
proxy_pass http://srv_name_http;
}
error_page 404 /404.html;
location = /40x.html {
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}
值得注意的是,nginx的维护,最好也要服务器解耦,就是不需要人工登录服务器配置nginx,而是将整个配置文件(一般是代码自动生成出来的,防错),嵌入项目目录下,在部署时,上传到对应位置,执行nginx -t
nginx -s reload
。降低维护成本。
第二种
- 推荐指数: 五星
- 理由: 比较主流的解决方案,使用群体很多,可以做到无损,且不需要手动维护nginx
- 服务器商一般都提供域名的负载均衡和健康检查。由于http域名到服务器固定了80端口,所以每个服务节点,都需要使用nginx来做proxy_pass。不过和第一种的区别在于, 1.nginx和服务节点绑定在同一个服务器,不需要前置nginx集群。2.nginx只有服务节点server,而不需要有upstream。
- 基于域名做高可用。分别有公网域名对外,私有域名对内,在使用成本上,比grpc的方式强很多。
第三种,
- 基于etcd实现一个http的服务发现。
- 推荐指数: 4星
- 理由: 实现看似简单,但是要了解etcd的原理以及无损迁移的原理,才能写出好的服务发现组件。如果已经做到了,那么它的效果等价于etcd+grpc那种。有用过的童鞋说好用,不过实现是不打算开源,哈哈。
大体实现原理为:
- 每次以前缀获取时,请拉取到所有value并存入服务的内存里。
- 监听前缀key变化,一旦某个key续约没了,则将内存里的该value移除。
- 调用方,仅从内存中的队列中,寻找可用的url,而不是直接向etcd拿。
但是真正要做到无损,有两个方向可以实施:
第一, 实现的组件,必须做到roundrobin轮训机制,失败一个请求时,继续对下一个url请求,直到成功或者阈值。 这样就不怕心跳租期的窗口期。
第二,节点上可以增加一个下线路由,手动将这个节点下线后,再等待消费积压,关闭节点。这个实现的难点就是这个路由,在命令中要书写 curl localhost:xxxx/offline-from-etcd/
。 这个xxxx端口的寻找便是最难的地方。
这里不藏了,找到xxxx的方式,就是通过(主机名:节点名) 存一份到环境变量里,然后curl的xxxx会被这个环境变量替换。
哈哈,是不是很简单。获取主机名的方法是os.Hostname()
tcp、websocket
nginx作client-hash的均衡策略,是可以实现websokect。tcp没测过。
增加一个服务节点,关注以下功能:
- 获取客户端能访达的最快的tcp可用ip
- 获取某个服务模块的ip和端口并返回告知客户端
客户端直连tcp。架构图如下:
tcp的高可用只体现在建立连接时,已经建立好的连接,在tcp服务挂了时,一定会蹦。这里推荐客户端作自动重连。所以设计上,tcp没有http那么复杂。