我们一起来探寻rpcx框架,本系列会详细讲解rpcx,尽量覆盖它的所有代码,看看这款优秀的rpc框架是如何实现的。
远程调用
顾名思义,就是客户端发起请求,服务端接收并处理,会返回结果的过程。也就是我们说的remote procedure call
(RPC
)。在这个过程中会涉及到如何做服务治理,如何序列化/反序列化
服务治理
服务治理涉及到如何处理远程调用失败的策略: Failover
、 Failfast
、 Failtry
、Backup
。以及支持什么样的路由算法: 随机
、 轮询
、权重
、网络质量
, 一致性哈希
,地理位置
等
序列化/反序列化
也就是网络传输中涉及到的数据编码/解码的过程,这个会单独章节讲解。
我们再来看下远程调用的整个流程(来自于官网文档),理解起来就容易多了
服务的调用过程为:
我们分几个部分来讲解源码(我们仍然以rpc框架之rpcx-简介(1)中的例子为例)
客户端发起请求
在发起请求的过程中涉及到失败策略,路由算法
服务端接收请求
//参数
args := &example.Args{
A: 10,
B: 20,
}
//返回
reply := &example.Reply{}
//发起请求
err := xclient.Call(context.Background(), "Mul", args, reply)
接下来我们来看看Call
方法做了什么事情
// Call invokes the named function, waits for it to complete, and returns its error status.
// It handles errors base on FailMode.
func (c *xClient) Call(ctx context.Context, serviceMethod string, args interface{}, reply interface{}) error {
//如果连接已经中断
if c.isShutdown {
return ErrXClientShutdown
}
if c.auth != "" {
metadata := ctx.Value(share.ReqMetaDataKey)
if metadata == nil {
metadata = map[string]string{}
ctx = context.WithValue(ctx, share.ReqMetaDataKey, metadata)
}
m := metadata.(map[string]string)
m[share.AuthKey] = c.auth
}
var err error
//1、路由算法
k, client, err := c.selectClient(ctx, c.servicePath, serviceMethod, args)
if err != nil {
//“在路由发生错误的时候”,如果是最快失败策略,则直接返回,不做任何重试
if c.failMode == Failfast {
return err
}
}
var e error
//2、失败策略
switch c.failMode {
case Failtry:
……
//3、远程调用
err = c.wrapCall(ctx, client, serviceMethod, args, reply)
……
case Failover:
……
//3、远程调用
err = c.wrapCall(ctx, client, serviceMethod, args, reply)
……
case Failbackup:
……
default: //Failfast
……
//3、远程调用
err = c.wrapCall(ctx, client, serviceMethod, args, reply)
……
}
}
进入到selectClient
方法
// selects a client from candidates base on c.selectMode
func (c *xClient) selectClient(ctx context.Context, servicePath, serviceMethod string, args interface{}) (string, RPCClient, error) {
//注意,这里是有锁的,路由是需要同步的
c.mu.Lock()
//获取路由算法
var fn = c.selector.Select
//用到装饰器模式,用Plugins来装饰路由器
if c.Plugins != nil {
fn = c.Plugins.DoWrapSelect(fn)
}
//选择服务节点
k := fn(ctx, servicePath, serviceMethod, args)
c.mu.Unlock()
if k == "" {
return "", nil, ErrXClientNoServer
}
//获取一个连接
client, err := c.getCachedClient(k)
return k, client, err
}
定位var fn = c.selector.Select
这行代码,获取指定路由算法的Select
方法,由k := fn(ctx, servicePath, serviceMethod, args)
来获得服务节点。rpcx支持的路由算法有:
接下来,我们挨个来分析这些算法(需要注意的是:我们说的路由算法是建立在有多个服务端提供同一个服务的情况,如果只有一个服务,那就无所谓算法了,没得其他选择),比如存在三个服务节点:
localhost:8972
,localhost:8973
,localhost:8974
,我们的分析就在这个3个节点上进行,也就是说s.servers=[]string{"localhost:8972","localhost:8973","localhost:8974"}
,servers中存放的是就是当前可用的服务节点信息
alwaysFirstSelector
只选择第一个
func (s *alwaysFirstSelector) Select(ctx context.Context, servicePath, serviceMethod string, args interface{}) string {
var ss = s.servers
if len(ss) == 0 {
return ""
}
return ss[0]
}
randomSelector
随机
从配置的节点中随机选择一个节点。
最简单,但是有时候单个节点的负载比较重。这是因为随机数只能保证在大量的请求下路由的比较均匀,并不能保证在很短的时间内负载是均匀的。
func (s randomSelector) Select(ctx context.Context, servicePath, serviceMethod string, args interface{}) string {
ss := s.servers
if len(ss) == 0 {
return ""
}
//随机一个数字
i := fastrand.Uint32n(uint32(len(ss)))
return ss[i]
}
可以看到随机算法,作者并没使用系统自带随机函数rand.Int31n()
,而是使用的是:fastrand.Uint32n
,库的对应地址:github.com/valyala/fastrand,为什么呢,我们它的介绍:
Fast and scalable pseudorandom generator for Go
fastrand.Uint32n scales on multiple CPUs, while rand.Int31n doesn't scale. Their performance is comparable on GOMAXPROCS=1, but fastrand.Uint32n runs 3x faster than rand.Int31n on GOMAXPROCS=2 and 10x faster than rand.Int31n on GOMAXPROCS=4.
说白了就是:更快,更稳定
,有多快好省的既视感。这属于本文章范畴之外的了,之所以会拿出来讲,希望读者和我一样,看到新的东西,欣然拥抱。
roundRobinSelector
轮询
使用轮询的方式,依次调用节点,能保证每个节点都均匀的被访问。在节点的服务能力都差不多的时候适用。
func (s *roundRobinSelector) Select(ctx context.Context, servicePath, serviceMethod string, args interface{}) string {
ss := s.servers
if len(ss) == 0 {
return ""
}
//循环引用下一个服务
i := s.i
i = i % len(ss)
s.i = i + 1
return ss[i]
}
geoSelector
地理位置
选择离客户端最近的服务端节点来提供服务
如果我们希望的是客户端会优先选择离它最新的节点, 比如在同一个机房。 如果客户端在北京, 服务在上海和美国硅谷,那么我们优先选择上海的机房。如果两个服务的节点的经纬度是一样的, rpcx会随机选择一个。
它要求服务在注册的时候要设置它所在的地理经纬度。
func (s geoSelector) Select(ctx context.Context, servicePath, serviceMethod string, args interface{}) string {
if len(s.servers) == 0 {
return ""
}
var server []string
min := math.MaxFloat64
//根据服务端(gs)和客户端(s)经纬度来计算距离
for _, gs := range s.servers {
d := getDistanceFrom(s.Latitude, s.Longitude, gs.Latitude, gs.Longitude)
if d < min {
server = []string{gs.Server}
min = d
} else if d == min {
server = append(server, gs.Server)
}
}
if len(server) == 1 {
return server[0]
}
return server[s.r.Intn(len(server))]
}
consistentHashSelector
一致性hash
关于一致性hash的定义,简单的说就是:同样的hash值,会映射到同一个节点上;如果节点被移除,那会自动转移到其他节点,为了避免其他节点的突增流量问题,可以可以设置虚拟节点。可以参考:一致性哈希算法——虚拟节点
相同的servicePath, serviceMethod 和 参数会路由到同一个节点上。
func (s consistentHashSelector) Select(ctx context.Context, servicePath, serviceMethod string, args interface{}) string {
ss := s.servers
if len(ss) == 0 {
return ""
}
//将三者拼接起来,获取hash值
key := genKey(servicePath, serviceMethod, args)
//根据一致性hash算法,获取节点地址
selected, _ := s.h.Get(key).(string)
return selected
}
func genKey(options ...interface{}) uint64 {
keyString := ""
for _, opt := range options {
keyString = keyString + "/" + toString(opt)
}
return HashString(keyString)
}
//根据一致性hash算法,获取节点地址
func (this *Hash) Get(key uint64) interface{} {
obj := this.loose.get(key)
switch obj {
case nil:
return this.compact.get(key)
default:
return obj
}
}
golang库获取hash值的方式有很多种,有兴趣的同学可以自行了解,作者选用的是fnv
,fnv
的算法思路是:先初始化 hash
,然后循环 乘以素数 prime32
,再与每位 byte
进行异或运算
fnv
adler32
crc32/64
crypto
一致性hash算法使用的库:doublejump。这个库可以了解下,老版本这个库是不支持节点删除的,现在作者做了支持。
weightedRoundRobinSelector
权重调度
根据份分配的权重比例来分配服务节点,权重越高,分配的机会就越大。主要是为了解决服务节点的存在的性能差异问题
比如如果三个节点a
、b
、c
的权重是{ 5, 1, 1 }
, 这个算法的调用顺序是 { a, a, b, a, c, a, a }
, 相比较 { c, b, a, a, a, a, a }
, 虽然权重都一样,但是前者更好,不至于在一段时间内将请求都发送给a
。
func (s *weightedRoundRobinSelector) Select(ctx context.Context, servicePath, serviceMethod string, args interface{}) string {
ss := s.servers
if len(ss) == 0 {
return ""
}
w := nextWeighted(ss)
if w == nil {
return ""
}
return w.Server
}
func nextWeighted(servers []*Weighted) (best *Weighted) {
total := 0
for i := 0; i < len(servers); i++ {
w := servers[i]
if w == nil {
continue
}
//if w is down, continue
w.CurrentWeight += w.EffectiveWeight
total += w.EffectiveWeight
if best == nil || w.CurrentWeight > best.CurrentWeight {
best = w
}
}
if best == nil {
return nil
}
best.CurrentWeight -= total
return best
}
代码还是很简单的,不做阐述
weightedICMPSelector
网络质量优先
首先客户端会基于ping(ICMP)
探测各个节点的网络质量,越短的ping时间,这个节点的权重也就越高。但是,我们也会保证网络较差的节点也有被调用的机会。
调度算法类似于:weightedRoundRobinSelector
func (s weightedICMPSelector) Select(ctx context.Context, servicePath, serviceMethod string, args interface{}) string {
ss := s.servers
if len(ss) == 0 {
return ""
}
w := nextWeighted(ss)
if w == nil {
return ""
}
return w.Server
}
至此,路由算法全部讲解完毕。
可以看到,并没有什么复杂之处,所以不要被名字吓倒,在代码面前,一切都赤裸裸
有时候服务会存在宕机、网络被挖断、网络变慢等情况,稳定的rpc框架应该要能容忍这些情况,rpcx
支持四种失败调度算法。
接下来我们来分拆Call
方法
// Call invokes the named function, waits for it to complete, and returns its error status.
// It handles errors base on FailMode.
func (c *xClient) Call(ctx context.Context, serviceMethod string, args interface{}, reply interface{}) error {
……
var err error
//1、路由算法
k, client, err := c.selectClient(ctx, c.servicePath, serviceMethod, args)
if err != nil {
//“在路由发生错误的时候”,如果是最快失败策略,则直接返回,不做任何重试
if c.failMode == Failfast {
return err
}
}
var e error
//2、失败策略
switch c.failMode {
case Failtry:
……
//3、远程调用
err = c.wrapCall(ctx, client, serviceMethod, args, reply)
……
case Failover:
……
//3、远程调用
err = c.wrapCall(ctx, client, serviceMethod, args, reply)
……
case Failbackup:
……
default: //Failfast
……
//3、远程调用
err = c.wrapCall(ctx, client, serviceMethod, args, reply)
……
}
}
Failfast
最快返回
在这种模式下, 一旦调用一个节点失败, rpcx立即会返回错误。
k, client, err := c.selectClient(ctx, c.servicePath, serviceMethod, args)
if err != nil {
if c.failMode == Failfast {
return err
}
}
default: //Failfast
err = c.wrapCall(ctx, client, serviceMethod, args, reply)
if err != nil {
if uncoverError(err) {
c.removeClient(k, client)
}
}
return err
}
Failover
失败重试:选择其他节点
在这种模式下, rpcx如果遇到错误,它会尝试调用另外一个节点, 直到服务节点能正常返回信息,或者达到最大的重试次数。 重试测试Retries
在参数Option
中设置, 缺省设置为3
case Failover:
retries := c.option.Retries
//没有到达最大失败次数
for retries >= 0 {
retries--
if client != nil {
err = c.wrapCall(ctx, client, serviceMethod, args, reply)
if err == nil {
return nil
}
if _, ok := err.(ServiceError); ok {
return err
}
}
if uncoverError(err) {
c.removeClient(k, client)
}
//重新选择节点重试
k, client, e = c.selectClient(ctx, c.servicePath, serviceMethod, args)
}
if err == nil {
err = e
}
return err
Failtry
失败重试:还是这个节点
在这种模式下, rpcx如果调用一个节点的服务出现错误, 它也会尝试,但是还是选择这个节点进行重试, 直到节点正常返回数据或者达到最大重试次数。
case Failtry:
retries := c.option.Retries
//没有到达最大失败次数
for retries >= 0 {
retries--
if client != nil {
err = c.wrapCall(ctx, client, serviceMethod, args, reply)
if err == nil {
return nil
}
if _, ok := err.(ServiceError); ok {
return err
}
}
if uncoverError(err) {
c.removeClient(k, client)
}
//从缓存中重新获取这个链接(可能需要重连)
client, e = c.getCachedClient(k)
}
if err == nil {
err = e
}
return err
Failbackup
多节点
在这种模式下, 如果服务节点在一定的时间内不返回结果, rpcx客户端会发送相同的请求到另外一个节点, 只要这两个节点有一个返回, rpcx就算调用成功。
case Failbackup:
ctx, cancelFn := context.WithCancel(ctx)
defer cancelFn()
call1 := make(chan *Call, 10)
call2 := make(chan *Call, 10)
var reply1, reply2 interface{}
//返回值接口反射
if reply != nil {
reply1 = reflect.New(reflect.ValueOf(reply).Elem().Type()).Interface()
reply2 = reflect.New(reflect.ValueOf(reply).Elem().Type()).Interface()
}
//第一个节点发起调用
_, err1 := c.Go(ctx, serviceMethod, args, reply1, call1)
t := time.NewTimer(c.option.BackupLatency)
select {
case <-ctx.Done(): //cancel by context
err = ctx.Err()
return err
case call := <-call1:
err = call.Error
if err == nil && reply != nil {
//通过反射复制值
reflect.ValueOf(reply).Elem().Set(reflect.ValueOf(reply1).Elem())
}
return err
case <-t.C://c.option.BackupLatency没有返回,则继续往下走,发起第二个节点调用
}
//第二个节点发起调用
_, err2 := c.Go(ctx, serviceMethod, args, reply2, call2)
if err2 != nil {
if uncoverError(err2) {
c.removeClient(k, client)
}
err = err1
return err
}
select {
case <-ctx.Done(): //cancel by context
err = ctx.Err()
case call := <-call1://第一个节点返回值
err = call.Error
if err == nil && reply != nil && reply1 != nil {
reflect.ValueOf(reply).Elem().Set(reflect.ValueOf(reply1).Elem())
}
case call := <-call2://第二个节点返回值
err = call.Error
if err == nil && reply != nil && reply2 != nil {
reflect.ValueOf(reply).Elem().Set(reflect.ValueOf(reply2).Elem())
}
}
return err
至此,失败策略全部讲解完毕。
需要注意的是:FailMode
的设置仅仅对同步调用有效(XClient.Call
), 异步调用用,这个参数是无意义的。
这是是指wrapCall
方法
func (c *xClient) wrapCall(ctx context.Context, client RPCClient, serviceMethod string, args interface{}, reply interface{}) error {
if client == nil {
return ErrServerUnavailable
}
ctx = share.NewContext(ctx)
//调用前处理,用户自定义plugin
c.Plugins.DoPreCall(ctx, c.servicePath, serviceMethod, args)
//发起socket调用
err := client.Call(ctx, c.servicePath, serviceMethod, args, reply)
//调用后处理,用户自定义plugin
c.Plugins.DoPostCall(ctx, c.servicePath, serviceMethod, args, reply, err)
return err
}
我打算花足够多的时间来和大家读一读rpcx的源码,来一层层的剖解rpcx,有兴趣的朋友,可以关注我。
下一篇我们分析序列化/反序列化
相关阅读
rpc框架之rpcx-简介(1)
rpc框架之rpcx-服务注册与服务发现(2)