go 进阶 go-zero相关: 八. 高可用

一. 高可用基础

  1. 进程内限流, 控制并发请求数,
  2. 基于redis lua脚本令牌桶或漏桶方式实现微服务限流
  3. 基于google sre算法基于滑动窗口实现熔断机制,支持fallack降级
  4. 支持自适应分级降载(基于滑动窗口防止毛刺),以k8s举例
  1. cpu使用率到达80%触发k8s的HPA
  2. cpu使用率>90%时开始拒绝低优先级请求
  3. cpu使用率>95时开始拒绝高优先级请求
  4. 问题:k8s中的HAP是分钟级别的,当服务并发过高时没有触发k8s拒绝请求服务已经被打爆了
  1. 在前面解释了熔断过滤器, 当前看一下go-zero保证高可用其它工具

二. tokenlimit令牌桶限流

  1. go-zero中提供了tokenlimit基于redis实现令牌桶限流,实现逻辑:
  1. 用户配置的平均发送速率为r,则每隔1/r秒一个令牌被加入到桶中;
  2. 假设桶中最多可以存放b个令牌。如果令牌到达时令牌桶已经满了,那么这个令牌会被丢弃;
  3. 当流量以速率v进入,从桶中以速率v取令牌,拿到令牌的流量通过,拿不到令牌流量不通过,执行熔断逻辑;
  1. 示例代码:
  1. 设置限流规则,例如桶容量, token生成速率等
  2. 获取redis作为token的存储容器
  3. 调用NewTokenLimiter()初始化TokenLimiter
  4. 通过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() {
	//1.获取redis连接,作为存储容器
	store := redis.New("")
	fmt.Println(store.Ping())
	//2.初始化TokenLimiter,并指定key(可以根据key来设置限流粒度)
	limiter := limit.NewTokenLimiter(rate, burst, store, "rate-test")
	if limiter.Allow() {
		fmt.Println("获取到了令牌")
		return
	}
	fmt.Println("没有获取到令牌")
}

源码

在这里插入图片描述

  1. 调用NewTokenLimiter()初始化TokenLimiter
type TokenLimiter struct {
    //每秒生产速率
    rate int
    //桶容量
    burst int
    //存储容器
    store *redis.Redis
    //redis key
    tokenKey       string
    //桶刷新时间key
    timestampKey   string
    //lock
    rescueLock     sync.Mutex
    //redis健康标识
    redisAlive     uint32
    //redis故障时采用进程内 令牌桶限流器
    rescueLimiter  *xrate.Limiter
    //redis监控探测任务标识
    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),
    }
}
  1. 获取令牌的核心函数
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 {
    //判断redis是否健康
    //redis故障时采用进程内限流器
    //兜底保障
    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),
        })
    // redis allowed == false
    // Lua boolean false -> r Nil bulk reply
    //特殊处理key不存在的情况
    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)
        //执行异常,开启redis健康探测任务
        //同时采用进程内限流器作为兜底
        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)
    }

    // redis allowed == true
    // Lua boolean true -> r integer reply with value of 1
    return code == 1
}
  1. 兜底策略开启redis健康探测任务startMonitor()
//开启redis健康探测
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()
}

//redis健康探测定时任务
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 {
        //ping属于redis内置健康探测命令
        if lim.store.Ping() {
            //健康探测成功,设置健康标识
            atomic.StoreUint32(&lim.redisAlive, 1)
            return
        }
    }
}

依赖redis的几个命令

  1. 在通过redis实现上述功能时重点依赖下方几个命令
    在这里插入图片描述
  2. 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 滑动窗口限流

  1. go-zero中提供了periodlimit 基于 redis 计数器实现滑动窗口限流,计算一段时间内对同一个资源的访问次数,如果超过指定的 limit 则拒绝访问
  2. 示例代码
  1. 设置限流规则,例如窗口大小, 请求上限等
  2. 调用NewPeriodLimit()初始化PeriodLimit
  3. 调用PeriodLimit的Take()获取token能否返回的code
  4. 根据返回的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 {
	//1.获取
	store := redis.New("")

	//2.初始化PeriodLimit
	l := limit.NewPeriodLimit(seconds, quota, store, "periodlimit")

	//3.获取token,注意返回的是一个int类型的code
	//0错误,比如可能是redis故障、过载
	//1允许
	//2允许但是当前窗口内已到达上限,如果是跑批业务的话此时可以休眠sleep一下等待下个窗口
	//3拒绝
	code, err := l.Take("first")
	if err != nil {
		logx.Error(err)
		return true
	}

	//4. 根据code判断是否能获取到token
	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
	}
}
  1. 为什么返回一个code: 如果在服务某个时间点,接收到大量请求,而periodlimit时间范围还远远没有到达阈值,后续请求的处理就成为问题。periodlimit 中并没有处理,而是返回 code 。把后续请求的处理交给了开发者自己处理
  1. 如果不做处理,那就是简单的将请求拒绝
  2. 如果需要处理这些请求,开发者可以借助 mq 将请求缓冲,减缓请求的压力
  3. 采用 tokenlimit,允许暂时的流量冲击

源码

  1. NewPeriodLimit()初始化获取PeriodLimit
//注意一下align参数,align=true时请求上限将会呈现周期性的变化。
//比如quota=5时实际quota可能是5.4.3.2.1呈现出周期性变化
type (
    // PeriodOption defines the method to customize a PeriodLimit.
    //go中常见的option参数模式
    //如果参数非常多,推荐使用此模式来设置参数
    PeriodOption func(l *PeriodLimit)

    // A PeriodLimit is used to limit requests during a period of time.
    //固定时间窗口限流器
    PeriodLimit struct {
        //窗口大小,单位s
        period     int
        //请求上限
        quota      int
        //存储
        limitStore *redis.Redis
        //key前缀
        keyPrefix  string
        //线性限流,开启此选项后可以实现周期性的限流
        //比如quota=5时,quota实际值可能会是5.4.3.2.1呈现出周期性变化
        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
}
  1. PeriodLimit上的Take() 获取令牌方法
func (h *PeriodLimit) Take(key string) (int, error) {
    //执行lua脚本
    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
    }
}
  1. 计算过期时间也就是窗口时间大小
//如果align==true
//线性限流,开启此选项后可以实现周期性的限流
//比如quota=5时,quota实际值可能会是5.4.3.2.1呈现出周期性变化
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核心命令

  1. 在实现上述功能时基于redis 的 incrby 做资源访问计数,采用 lua script 做整个窗口计算,保证计算的原子性,重点关注以下命令
    在这里插入图片描述
  2. 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
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值