一. 高可用基础
进程内限流, 控制并发请求数, 基于redis lua脚本令牌桶或漏桶方式实现微服务限流 基于google sre算法基于滑动窗口实现熔断机制,支持fallack降级 支持自适应分级降载(基于滑动窗口防止毛刺),以k8s举例
cpu使用率到达80%触发k8s的HPA cpu使用率>90%时开始拒绝低优先级请求 cpu使用率>95时开始拒绝高优先级请求 问题:k8s中的HAP是分钟级别的,当服务并发过高时没有触发k8s拒绝请求服务已经被打爆了
在前面解释了熔断过滤器, 当前看一下go-zero保证高可用其它工具
二. tokenlimit令牌桶限流
go-zero中提供了tokenlimit基于redis实现令牌桶限流,实现逻辑:
用户配置的平均发送速率为r,则每隔1/r秒一个令牌被加入到桶中; 假设桶中最多可以存放b个令牌。如果令牌到达时令牌桶已经满了,那么这个令牌会被丢弃; 当流量以速率v进入,从桶中以速率v取令牌,拿到令牌的流量通过,拿不到令牌流量不通过,执行熔断逻辑;
示例代码:
设置限流规则,例如桶容量, token生成速率等 获取redis作为token的存储容器 调用NewTokenLimiter()初始化TokenLimiter 通过TokenLimiter调用Allow()获取令牌,如果有返回true并删除,没有则返回false
import (
"fmt"
"github.com/zeromicro/go-zero/core/limit"
"github.com/zeromicro/go-zero/core/stores/redis"
)
const (
burst = 100
rate = 100
)
func test ( ) {
store := redis. New ( "" )
fmt. Println ( store. Ping ( ) )
limiter := limit. NewTokenLimiter ( rate, burst, store, "rate-test" )
if limiter. Allow ( ) {
fmt. Println ( "获取到了令牌" )
return
}
fmt. Println ( "没有获取到令牌" )
}
源码
调用NewTokenLimiter()初始化TokenLimiter
type TokenLimiter struct {
rate int
burst int
store * redis. Redis
tokenKey string
timestampKey string
rescueLock sync. Mutex
redisAlive uint32
rescueLimiter * xrate. Limiter
monitorStarted bool
}
func NewTokenLimiter ( rate, burst int , store * redis. Redis, key string ) * TokenLimiter {
tokenKey := fmt. Sprintf ( tokenFormat, key)
timestampKey := fmt. Sprintf ( timestampFormat, key)
return & TokenLimiter{
rate: rate,
burst: burst,
store: store,
tokenKey: tokenKey,
timestampKey: timestampKey,
redisAlive: 1 ,
rescueLimiter: xrate. NewLimiter ( xrate. Every ( time. Second/ time. Duration ( rate) ) , burst) ,
}
}
获取令牌的核心函数
func ( lim * TokenLimiter) Allow ( ) bool {
return lim. AllowN ( time. Now ( ) , 1 )
}
func ( lim * TokenLimiter) AllowN ( now time. Time, n int ) bool {
return lim. reserveN ( context. Background ( ) , now, n)
}
func ( lim * TokenLimiter) reserveN ( now time. Time, n int ) bool {
if atomic. LoadUint32 ( & lim. redisAlive) == 0 {
return lim. rescueLimiter. AllowN ( now, n)
}
resp, err := lim. store. Eval (
script,
[ ] string {
lim. tokenKey,
lim. timestampKey,
} ,
[ ] string {
strconv. Itoa ( lim. rate) ,
strconv. Itoa ( lim. burst) ,
strconv. FormatInt ( now. Unix ( ) , 10 ) ,
strconv. Itoa ( n) ,
} )
if err == redis. Nil {
return false
} else if err != nil {
logx. Errorf ( "fail to use rate limiter: %s, use in-process limiter for rescue" , err)
lim. startMonitor ( )
return lim. rescueLimiter. AllowN ( now, n)
}
code, ok := resp. ( int64 )
if ! ok {
logx. Errorf ( "fail to eval redis script: %v, use in-process limiter for rescue" , resp)
lim. startMonitor ( )
return lim. rescueLimiter. AllowN ( now, n)
}
return code == 1
}
兜底策略开启redis健康探测任务startMonitor()
func ( lim * TokenLimiter) startMonitor ( ) {
lim. rescueLock. Lock ( )
defer lim. rescueLock. Unlock ( )
if lim. monitorStarted {
return
}
lim. monitorStarted = true
atomic. StoreUint32 ( & lim. redisAlive, 0 )
go lim. waitForRedis ( )
}
func ( lim * TokenLimiter) waitForRedis ( ) {
ticker := time. NewTicker ( pingInterval)
defer func ( ) {
ticker. Stop ( )
lim. rescueLock. Lock ( )
lim. monitorStarted = false
lim. rescueLock. Unlock ( )
} ( )
for range ticker. C {
if lim. store. Ping ( ) {
atomic. StoreUint32 ( & lim. redisAlive, 1 )
return
}
}
}
依赖redis的几个命令
在通过redis实现上述功能时重点依赖下方几个命令 lua脚本
--每秒生成token数量即token生成速度
local rate = tonumber(ARGV[1])
--桶容量
local capacity = tonumber(ARGV[2])
--当前时间戳
local now = tonumber(ARGV[3])
--当前请求token数量
local requested = tonumber(ARGV[4])
--需要多少秒才能填满桶
local fill_time = capacity/rate
--向下取整,ttl为填满时间的2倍
local ttl = math.floor(fill_time*2)
--当前时间桶容量
local last_tokens = tonumber(redis.call("get", KEYS[1]))
--如果当前桶容量为0,说明是第一次进入,则默认容量为桶的最大容量
if last_tokens == nil then
last_tokens = capacity
end
--上一次刷新的时间
local last_refreshed = tonumber(redis.call("get", KEYS[2]))
--第一次进入则设置刷新时间为0
if last_refreshed == nil then
last_refreshed = 0
end
--距离上次请求的时间跨度
local delta = math.max(0, now-last_refreshed)
--距离上次请求的时间跨度,总共能生产token的数量,如果超多最大容量则丢弃多余的token
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
--本次请求token数量是否足够
local allowed = filled_tokens >= requested
--桶剩余数量
local new_tokens = filled_tokens
--允许本次token申请,计算剩余数量
if allowed then
new_tokens = filled_tokens - requested
end
--设置剩余token数量
redis.call("setex", KEYS[1], ttl, new_tokens)
--设置刷新时间
redis.call("setex", KEYS[2], ttl, now)
return allowed
三. periodlimit 滑动窗口限流
go-zero中提供了periodlimit 基于 redis 计数器实现滑动窗口限流,计算一段时间内对同一个资源的访问次数,如果超过指定的 limit 则拒绝访问 示例代码
设置限流规则,例如窗口大小, 请求上限等 调用NewPeriodLimit()初始化PeriodLimit 调用PeriodLimit的Take()获取token能否返回的code 根据返回的code判断是否能获取token
import (
"github.com/zeromicro/go-zero/core/limit"
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/core/stores/redis"
)
const (
seconds = 1
quota = 5
)
func test ( ) bool {
store := redis. New ( "" )
l := limit. NewPeriodLimit ( seconds, quota, store, "periodlimit" )
code, err := l. Take ( "first" )
if err != nil {
logx. Error ( err)
return true
}
switch code {
case limit. OverQuota:
logx. Error ( "OverQuota" )
return false
case limit. Allowed:
logx. Infof ( "AllowedQuota" )
return true
case limit. HitQuota:
logx. Errorf ( "HitQuota" )
return false
default :
logx. Errorf ( "DefaultQuota key" )
return true
}
}
为什么返回一个code: 如果在服务某个时间点,接收到大量请求,而periodlimit时间范围还远远没有到达阈值,后续请求的处理就成为问题。periodlimit 中并没有处理,而是返回 code 。把后续请求的处理交给了开发者自己处理
如果不做处理,那就是简单的将请求拒绝 如果需要处理这些请求,开发者可以借助 mq 将请求缓冲,减缓请求的压力 采用 tokenlimit,允许暂时的流量冲击
源码
NewPeriodLimit()初始化获取PeriodLimit
type (
PeriodOption func ( l * PeriodLimit)
PeriodLimit struct {
period int
quota int
limitStore * redis. Redis
keyPrefix string
align bool
}
)
func NewPeriodLimit ( period, quota int , limitStore * redis. Redis, keyPrefix string ,
opts ... PeriodOption) * PeriodLimit {
limiter := & PeriodLimit{
period: period,
quota: quota,
limitStore: limitStore,
keyPrefix: keyPrefix,
}
for _ , opt := range opts {
opt ( limiter)
}
return limiter
}
PeriodLimit上的Take() 获取令牌方法
func ( h * PeriodLimit) Take ( key string ) ( int , error ) {
resp, err := h. limitStore. Eval ( periodScript, [ ] string { h. keyPrefix + key} , [ ] string {
strconv. Itoa ( h. quota) ,
strconv. Itoa ( h. calcExpireSeconds ( ) ) ,
} )
if err != nil {
return Unknown, err
}
code, ok := resp. ( int64 )
if ! ok {
return Unknown, ErrUnknownCode
}
switch code {
case internalOverQuota:
return OverQuota, nil
case internalAllowed:
return Allowed, nil
case internalHitQuota:
return HitQuota, nil
default :
return Unknown, ErrUnknownCode
}
}
计算过期时间也就是窗口时间大小
func ( h * PeriodLimit) calcExpireSeconds ( ) int {
if h. align {
unix := time. Now ( ) . Unix ( ) + zoneDiff
return h. period - int ( unix% int64 ( h. period) )
}
return h. period
}
依赖redis核心命令
在实现上述功能时基于redis 的 incrby 做资源访问计数,采用 lua script 做整个窗口计算,保证计算的原子性,重点关注以下命令 lua脚本
--KYES[1]:限流器key
--ARGV[1]:qos,单位时间内最多请求次数
--ARGV[2]:单位限流窗口时间
--请求最大次数,等于p.quota
local limit = tonumber(ARGV[1])
--窗口即一个单位限流周期,这里用过期模拟窗口效果,等于p.permit
local window = tonumber(ARGV[2])
--请求次数+1,获取请求总数
local current = redis.call("INCRBY",KYES[1],1)
--如果是第一次请求,则设置过期时间并返回 成功
if current == 1 then
redis.call("expire",KYES[1],window)
return 1
--如果当前请求数量小于limit则返回 成功
elseif current < limit then
return 1
--如果当前请求数量==limit则返回 最后一次请求
elseif current == limit then
return 2
--请求数量>limit则返回 失败
else
return 0
end