设想我们的应用成长很快,访问量很大,为了防止系统被大量请求打垮而不可用,我们需要做一些常规的保护措施。
先来了解几个基本概念:
限流:后端服务有可能会面临大量的请求,这可能是因为用户量确实很大,也可能是客户端代码中有bug(例如出现递归之类的问题),还有可能是不法分子恶意攻击。大量的请求最终有可能导致服务不可用,如果是核心服务造成的影响会更严重,这时候就需要服务端根据QPS的情况做限流,一旦请求量超出阈值,则采取某种措施(等待或者直接拒绝处理)。
熔断:如果服务因为某种原因而频繁的出现请求超时的情况,此时需要对后续的请求进行短路处理,也就是不实际调用后台服务,而是返回给调用方一个mock的值,等到服务恢复以后,用户可以继续正常访问服务。
接下来我们继续扩展之前的示例代码,我们先加限流,下一篇文章在加熔断。
1、给服务做限流保护
我们已经了解了限流的概念,下面来看看现实当中用的比较多的限流算法:令牌桶算法。
令牌桶算法实际上是个类似于生产者和消费者的一种算法:有一个令牌桶,桶子的初始容量为N个令牌,当有请求过来的时候,先得从令牌桶里拿一个令牌,如果没有令牌就要等待。还有一个生产者,每隔一定时间就往令牌里放一个令牌。可以通过配置相关参数来达到限流的目的。
go语言已经有令牌桶算法的实现供我们使用,我们就来用用看(当然想自己手动撸一个也是可以的):
首先在go module中导入依赖包:
github.com/juju/ratelimit v1.0.1
这个是github上标星比较多的一个令牌桶算法的实现,另外github上也有uber实现的漏桶算法的实现,可以自行选择。
1.1 理解micro的装饰器
在加限流功能前我们先认识一下micro的装饰器模式,在micro中很多地方都使用了装饰器来让用户对其进行自定义扩展,在客户端进行调用时,可以通过选项来改变调用行为,我们看看之前示例代码中接口的定义:
// Client API for Greeter service
type GreeterService interface {
Hello(ctx context.Context, in *Request, opts ...client.CallOption) (*Response, error)
}
注意最后的opts这个参数,这个东东就是我们提供一些调用选项来对调用进行控制的地方。那有哪些选项可供我们设置呢,看下面的定义就知道了:
type CallOptions struct {
SelectOptions []selector.SelectOption
// Address of remote hosts
Address []string
// Backoff func
Backoff BackoffFunc
// Check if retriable func
Retry RetryFunc
// Transport Dial Timeout
DialTimeout time.Duration
// Number of Call attempts
Retries int
// Request/Response timeout
RequestTimeout time.Duration
// Stream timeout for the stream
StreamTimeout time.Duration
// Use the services own auth token
ServiceToken bool
// Middleware for low level call func
CallWrappers []CallWrapper
// Other options for implementations of the interface
// can be stored in a context
Context context.Context
}
请求的超时值,重试的次数,采用什么负载均衡策略来选择节点等等都是可以设置的,现在我们重点关注下面这个选项:
CallWrappers
这是一个CallWrapper类型的切片,从字面意思上来看就是对原始调用请求进行包装,我们看看CallWrapper的定义:
// CallFunc represents the individual call func
type CallFunc func(ctx context.Context, node *registry.Node, req Request, rsp interface{}, opts CallOptions) error
// CallWrapper is a low level wrapper for the CallFunc
type CallWrapper func(CallFunc) CallFunc
这是一个函数类型,参数是原始CallFunc,返回一个新的CallFunc,你可以在这个新的CallFunc里上下其手。
这是针对某个特定的接口做限流,如果你想对整个服务做限流,方式也是类似的,可以用micro.WrapClient对整个客户端做包装即可。
1.2 增加限流代码
现在我们其实已经大概知道了要如何扩展代码增加限流功能了,开始动手。新建一个文件,内容非常简单:
/**
* 带上限流功能,针对整个服务的所有接口,用一个令牌桶控制整个服务的访问量
* 参数:
* fillIntervalMs 向令牌桶添加令牌的周期,以毫秒为单位
* bucketCapicty 令牌桶中的容量
* quantumAdd 每次添加多少令牌到桶里
* wait 当令牌耗尽时是否等待
*/
func WithRateLimit(fillIntervalMs int, bucketCapicty, quantumAdd int64, wait bool) micro.Option {
return micro.WrapClient(newRateLimitWrapper(fillIntervalMs, bucketCapicty, quantumAdd, wait))
}
/**
* 带上限流功能,针对服务的某个接口,每个接口用一个令牌桶来控制访问量
* 参数:
* bucket 控制接口访问的令牌桶
* wait 当令牌耗尽时是否等待
*/
func WithRateLimitCall(bucket *ratelimit.Bucket, wait bool) client.CallOption {
wrapper := func(f client.CallFunc) client.CallFunc {
if wait {
// 一直等待到令牌桶中有令牌为止
time.Sleep(bucket.Take(1))
} else if bucket.TakeAvailable(1) == 0 {
// 没有拿到令牌,又不等待,直接返回错误给客户端
return func(ctx context.Context, node *registry.Node, req client.Request, rsp interface{}, opts client.CallOptions) error {
return apperror.TooManyRequest
}
}
return f
}
return client.WithCallWrapper(wrapper)
}
我们提供了两个工具函数,一个用于包装整个客户端,一个用于包装接口。
现在我们修改一下上一篇文章中的客户端代码:
var (
//控制接口Hello访问的令牌桶
helloBucket = ratelimit.NewBucketWithQuantum(time.Millisecond * time.Duration(10), 50, 1)
)
func (g *Greeter) Hello(ctx context.Context, req *pb.Request, rsp *pb.Response) error {
//通过rpc调用服务端
response, e := g.Client.Hello(ctx, &pb.Request{Name: "Hello Micro"}, tool.WithRateLimitCall(helloBucket, false))
if e != nil {
return e
}
rsp.Msg = response.Msg
return nil
}
代码基本一样,增加了一个用于控制Hello调用的令牌桶,这个令牌桶容量为50,每10ms向令牌中添加1个令牌,然后在RPC的时候增加了限流选项:tool.WithRateLimitCall。
现在,我们的Greeter.Hello接口已经具备限流的能力了,下面我们来测试一下。
1.3 测试限流功能
我们把代码跑起来,跟上一篇文章一样,先启动网关,在启动服务端,最后启动客户端。
然后我们来用wrk压测工具来测试一下:
起5个线程,建100个连接模拟100个客户,测试结果如图,我们发现有大量的请求没有成功(Non-2xx or 3xx responses),这是因为被我们的限流算法把请求给拒绝掉了。
做为对比,我们去掉限流代码在测试一次:
可以看到,去掉限流之后不会有失败的请求了。
2、小结
这篇文章我们继续扩展微服务示例代码,给接口加上了限流功能,顺便跟着这个功能了解了micro中的装饰器模式的实现,我们的示例服务又强壮了一点。