1. 介绍
Sentinel-Go 是一个流量控制库,专注于提供优秀的流量控制解决方案,而底层实现和设计思考通常是非常有价值的内容。
令牌桶和漏桶算法是流量控制中常用的两种算法,它们可以被用来平滑流量、限制请求速率等。冷启动算法在系统启动时可以帮助系统快速达到稳定状态,这些算法在流量控制中都有着重要的作用。
通过深入探讨 Sentinel-Go 的底层实现和设计思考,读者可以更好地理解流量控制背后的原理和机制。同时,工程实践方面的经验分享也能够帮助读者更好地应用流量控制解决方案到实际的业务场景中。
2. 流控规则
在正式开始前先了解下 Sentinel-Go 的流控规则的详细参数,字段含义使用注释标注 00000000000000000
go
type Rule struct {
// 资源名,即规则的作用目标。
Resource string `json:"resource"`
// 当前流量控制器的Token计算策略。Direct表示直接使用Threshold 作为阈值;
// WarmUp表示使用预热方式计算Token的阈值
// MemoryAdaptive表示使用内存自适应方式计算Token的阈值
TokenCalculateStrategy TokenCalculateStrategy `json:"tokenCalculateStrategy"`
// 控制行为,Reject表示直接拒绝,Throttling表示匀速排队
ControlBehavior ControlBehavior `json:"controlBehavior"`
// 表示流控阈值;如果字段 StatIntervalInMs 是1000(也就是1秒),
// 那么Threshold就表示QPS,流量控制器也就会依据资源的QPS来做流控。
Threshold float64 `json:"threshold"`
// 调用关系限流策略,CurrentResource表示使用当前规则的resource做流控;
// AssociatedResource表示使用关联的resource做流控,关联的resource在字段 RefResource 定义;
RelationStrategy RelationStrategy `json:"relationStrategy"`
// 关联的resource
RefResource string `json:"refResource"`
// 匀速排队的最大等待时间,该字段仅仅对控制行为是匀速排队时生效
MaxQueueingTimeMs uint32 `json:"maxQueueingTimeMs"`
// 预热的时间长度,该字段仅仅对Token计算策略是WarmUp时生效;
WarmUpPeriodSec uint32 `json:"warmUpPeriodSec"`
// 预热的因子,默认是3,该值的设置会影响预热的速度,该字段仅仅对Token计算策略是WarmUp时生效
WarmUpColdFactor uint32 `json:"warmUpColdFactor"`
// 规则对应的流量控制器的独立统计结构的统计周期。如果StatIntervalInMs是1000,也就是统计QPS。
StatIntervalInMs uint32 `json:"statIntervalInMs"`
// 内存低使用率时的限流阈值,该字段仅在Token计算策略是MemoryAdaptive时生效
LowMemUsageThreshold int64 `json:"lowMemUsageThreshold"`
// 内存高使用率时的限流阈值,该字段仅在Token计算策略是MemoryAdaptive时生效
HighMemUsageThreshold int64 `json:"highMemUsageThreshold"`
// 内存低水位标记字节大小,该字段仅在Token计算策略是MemoryAdaptive时生效
MemLowWaterMarkBytes int64 `json:"memLowWaterMarkBytes"`
// 内存高水位标记字节大小,该字段仅在Token计算策略是MemoryAdaptive时生效
MemHighWaterMarkBytes int64 `json:"memHighWaterMarkBytes"`
}
3. 流量控制
流量控制是一种用于管理和调节系统、服务或网络中流动的数据量的技术手段。通过流量控制,可以限制数据的传输速率、保证系统资源的稳定性和高可用性,防止系统因瞬时的流量高峰而发生故障或崩溃。在 Sentinel-Go 中,流量控制的本质是通过监控资源的统计指标,根据 Token 计算策略来计算资源的可用 Token,进而根据流量控制策略对流量进行调控,以达到保障系统稳定性和高可用性的目的。
具体来说,在 Sentinel-Go 中,流量控制的实现流程通常如下:
- 监控资源的统计指标:通过监控资源的使用情况、请求频率等指标,对系统的流量进行实时监控和统计。
- 根据 Token 计算策略计算可用 Token:根据事先设定的 Token 计算策略,计算资源当前可用的 Token 数量,即资源的阈值。
- 流量控制策略:根据设定的流量控制策略,比如令牌桶算法、漏桶算法等,对流量进行控制,限制请求速率或流量大小,确保系统在承受范围内运行。
- 系统保障与高可用性:通过流量控制,防止系统被突发的高流量冲垮,保障系统的高可用性和稳定性。
通过对资源的实时监控、Token 计算和流量控制策略的灵活运用,Sentinel-Go 能够提供强大的流量控制能力,保障系统在各种情况下的稳定运行。这种流量控制机制对于繁忙的网络环境和复杂的系统架构非常重要,可以有效地避免系统崩溃和服务不可用的情况发生。
3.1 流量控制策略
流量控制策略由规则中的 TokenCalculateStrategy (Token计算策略)和 ControlBehavior (控制行为) 两个字段共同决定。两个字段可以按实际场景按需组合使用,不同的组合对应的流量控制效果是不同的。
在Sentinel-Go中将Token计算策略和控制行为抽象为两个interface,在初始化时会根据流控规则创建对应的流量控制器,其中流量控制器中包含了下面两个接口的实现以及统计结构。
- TrafficShapingCalculator:计算流量控制的实际阈值(Token)
- TrafficShapingChecker: 根据实际阈值与统计结构中的指标进行流量控制
Sentinel-Go提供三种Token计算策略以及两种控制流量的行为
Token计算策略
- 固定的限流阈值: 使用流控规则中的限流阈值,该方式是默认的流量控制方式,当QPS超过任意规则的阈值后,新的请求就会被立即拒绝,这种方式适用于对系统处理能力确切已知的情况下,比如通过压测确定了系统的准确水位时
- WarmUp(预热) :该方式主要用于系统长期处于低水位的情况下,当流量突然增加时,直接把系统拉升到高水位可能瞬间把系统压垮。通过"冷启动",让通过的流量缓慢增加,在一定时间内逐渐增加到阈值上限,给冷系统一个预热的时间,避免冷系统被压垮的情况
- 内存自适应:该方式主要用于保护系统内存不会随着流量的增长而无限增长,在内存的安全界限内动态调整限流阈值,尽可能的提升吞吐。
流控行为
- 拒绝:当流量超过阈值时后面的流量将直接拒绝。这种行为简单粗暴可以很好的控制系统的负载,但是有损被拒绝掉的流量不会被服务处理。
- 匀速排队:当QPS超过阈值时后面的流量将会按照固定时间依次排队通过,起到削峰填谷的效果。此行为是利用漏桶算法实现,适合用于间隔性流量突发的场景,例如:消息队列。
3.2 Token计算策略
3.2.1 Direct(Thresold)
当流控规则中TokenCalculateStrategy设置为Direct,则代表使用规则中的Thresold当作阈值进行流控。 在Direct策略下Token不需要计算,使用规则中配置的固定值即可。
go
func (d *DirectTrafficShapingCalculator) CalculateAllowedTokens(uint32, int32) float64 {
return d.threshold
}
3.2.2 WarmUp(预热)
介绍
冷启动(TokenCalculateStrategy设置为WarmUp)在流控规则中的应用,是为了解决系统长期处于低水位的情况下,当流量突然增加时可能导致系统压力骤增的问题。通过冷启动,系统可以以一个缓慢增长的速度逐渐调整并提高限流阈值Token,给系统一个预热的时间,避免冷系统在瞬间被压垮。
通常情况下,冷启动的过程可以通过一个 QPS 曲线来描述。在冷启动阶段,系统初始的通过率(QPS)会从一个较低的水平逐渐上升,直至达到设定的限流阈值上限。这种逐渐增长的过程可以有效地缓解系统在高流量条件下的冲击,使系统能够平稳过渡到高水位,并逐渐适应新的流量需求。这种方式可以帮助系统在面对流量激增时更加稳定和可靠地运行,保障系统的高可用性和稳定性。
通常冷启动的过程系统允许通过的 QPS 曲线如下图所示:
原理
在Sentinel-Go中的WarmUp实现参考了Guava的SmoothRateLimited,本质是一个令牌桶算法。
按照令牌桶算法对应到Sentinel-Go中,负责生产令牌的生产者即是WarmUp的Token计算策略,负责消费令牌的消费者则是行为控制
在Sentinel-Go的WarmUp的令牌生成算法模型如下:
(这个图要从右向左进行理解,本质是要根据预热时间、限流阈值等参数推导出令牌生成的时间间隔(y轴),从而可以知道每秒能生成多少个令牌,即可达到动态生成限流阈值的效果)
- x轴:令牌桶容量
- y轴:每个令牌生成的时间间隔
- warningToken:令牌预警数量,即令牌桶中的剩余令牌数量到达预警值时,预热结束。
- maxToken:令牌桶最大容量,当令牌桶到达容量后,生成的令牌将被丢弃
- slope:斜率,用来计算当前令牌生成的时间间隔
- warmUpPeriodSec:系统冷启动的时间,单位秒
假设规则如下:阈值设置为100,冷启动时间为10S,冷却因子默认为3,滑动时间窗口统计周期为1000毫秒
常规的令牌桶算法:有一个负责生产令牌的生产者以及负责获取令牌的消费者,当令牌桶满的时候生产者生产的令牌直接丢弃,否则将令牌存储到令牌桶中。当有流量经过时令牌消费者先从令牌桶中获取令牌,如果获取成功则放行当前流量,如果令牌桶中没有令牌则将当前流量拒绝。
go
{
TokenCalculateStrategy: flow.WarmUp,
ControlBehavior: flow.Reject,
Threshold: 100,
WarmUpPeriodSec: 10,
WarmUpColdFactor: 3,
StatIntervalInMs: 1000,
}
在初始化WarmUp计算策略func中,会计算对应的warningToken=500, maxToken=1000, slope=0.00004
go
warningToken := uint64((float64(rule.WarmUpPeriodSec) * rule.Threshold) / float64(rule.WarmUpColdFactor-1))
maxToken := warningToken + uint64(2*float64(rule.WarmUpPeriodSec)*rule.Threshold/float64(1.0+rule.WarmUpColdFactor))
slope := float64(rule.WarmUpColdFactor-1.0) / rule.Threshold / float64(maxToken-warningToken)
初始化后,对应参数如下:
接下来重点看下计算Token的算法:
go
func (c *WarmUpTrafficShapingCalculator) CalculateAllowedTokens(_ uint32, _ int32) float64 {
// 获取滑动时间窗口前一个统计周期的QPS,依托于底层的统计结构
metricReadonlyStat := c.BoundOwner().boundStat.readOnlyMetric
previousQps := metricReadonlyStat.GetPreviousQPS(base.MetricEventPass)
// 同步令牌桶中的令牌(包括生成和丢弃)
c.syncToken(previousQps)
// 原子获取令牌桶中的令牌数
restToken := atomic.LoadInt64(&c.storedTokens)
if restToken < 0 {
restToken = 0
}
// 如果桶中令牌数>=令牌预警线(500),代表还在冷启动阶段
if restToken >= int64(c.warningToken) {
// 计算桶中令牌和预警线的差值(也就是还有多少个令牌可用)
aboveToken := restToken - int64(c.warningToken)
// 动态计算出每秒允许通过的QPS阈值
warningQps := math.Nextafter(1.0/(float64(aboveToken)*c.slope+1.0/c.threshold), math.MaxFloat64)
return warningQps
} else {
// 如果桶中令牌数<令牌预警线,则说明冷启动已经结束,直接返回规则中的阈值
return c.threshold
}
}
假设令牌桶中有1000(restToken)个令牌,slope=0.00004,warningToken=500, 此时在预热期间。那么aboveToken=500。
计算每秒通过的QPS阈值,首先看这段公式的:通过斜率算出当前token的生成时间间隔0.03毫秒(也就是求出X轴=1000时,Y轴的的数值=0.03)
go
(float64(aboveToken)*c.slope+1.0/c.threshold=500*0.00004+1/100=0.03
然后通过当前token生成时间间隔计算出每秒可通过的QPS(token阈值)=33.33
go
math.Nextafter(1.0/(float64(aboveToken)*c.slope+1.0/c.threshold), math.MaxFloat64)
在syncToken中主要是更新令牌桶中令牌的数量:
go
func (c *WarmUpTrafficShapingCalculator) syncToken(passQps float64) {
// 获取当前时间(毫秒)
currentTime := util.CurrentTimeMillis()
// 获取当前时间(秒)
currentTime = currentTime - currentTime%1000
// 最后填充令牌桶时间
oldLastFillTime := atomic.LoadUint64(&c.lastFilledTime)
// 如果当前时间小于最后填充时间,说明出现了时间回拨,则不需要填充/丢弃令牌
// 如果当前时间等于最后填充时间,说明在同一秒内已经同步过令牌桶了,避免重复填充/丢弃令牌
if currentTime <= oldLastFillTime {
return
}
// 获取当前桶中的令牌数量
oldValue := atomic.LoadInt64(&c.storedTokens)
// 初始化/生成令牌
newValue := c.coolDownTokens(currentTime, passQps)
// 利用cas存储最新的令牌数量,避免并发不安全问题。
if atomic.CompareAndSwapInt64(&c.storedTokens, oldValue, newValue) {
// 最终桶中令牌数=桶中令牌数-已经通过的QPS
if currentValue := atomic.AddInt64(&c.storedTokens, int64(-passQps)); currentValue < 0 {
atomic.StoreInt64(&c.storedTokens, 0)
}
// 更新最后更新令牌桶的时间
atomic.StoreUint64(&c.lastFilledTime, currentTime)
}
}
在coolDownTokens中初始化令牌桶以及填充令牌:
go
func (c *WarmUpTrafficShapingCalculator) coolDownTokens(currentTime uint64, passQps float64) int64 {
oldValue := atomic.LoadInt64(&c.storedTokens)
newValue := oldValue
// 如果令牌桶中的令牌数量小于令牌预警线
// 初始化时桶中令牌=0一定小于warningToken
// 预热结束后,令牌桶中的数量也会小于预警线
if oldValue < int64(c.warningToken) {
// 填充令牌=桶中令牌数+每秒应该生成的令牌数100
newValue = int64(float64(oldValue) + (float64(currentTime)-float64(atomic.LoadUint64(&c.lastFilledTime)))*c.threshold/1000.0)
} else if oldValue > int64(c.warningToken) {
// 如果令牌数量大于预警线,说明应该在预热期间
// 但是如果通过的请求数(消费的令牌数)小于冷却数量,说明并没有真正的开始预热
// 则需要填充令牌,让桶中令牌维持在maxToken
if passQps < float64(uint32(c.threshold)/c.coldFactor) {
newValue = int64(float64(oldValue) + float64(currentTime-atomic.LoadUint64(&c.lastFilledTime))*c.threshold/1000.0)
}
}
// 当前生成的令牌小于最大令牌数
if newValue <= int64(c.maxToken) {
return newValue
} else {
// 如果但前令牌大雨最大令牌,则丢弃多余令牌,让桶中始终最多拥有maxToken
return int64(c.maxToken)
}
}
总结
在Sentinel-Go中的WarmUp模型本质是令牌桶算法,在初始化时将令牌桶中"填满",预热期间扣减令牌,用剩余的令牌与斜率推导出每秒可以扣减的令牌数量,从而动态得出预热期间的限流阈值。
3.2.3 内存自适应
介绍
当流控规则中TokenCalculateStrategy设置为MemoryAdaptive,则代表使用内存自适应方式计算Token(限流阈值)。流量大小会间接影响系统内存使用率,当系统内存使用率过大时一定程度上也会影响吞吐量与请求耗时,在内存自适应中可以根据系统的内存使用情况动态的计算限流阈值,从而让系统更加平稳的运行
原理
在Sentinel-Go中使用内存自适应流控的规则如下:(需要指定低和高水位内存的字节数以及内存低和高水位时对应的限流阈值)
go
{
Resource: resName,
TokenCalculateStrategy: flow.MemoryAdaptive,
ControlBehavior: flow.Reject,
StatIntervalInMs: 1000,
LowMemUsageThreshold: 1000, // 低水位限流阈值
HighMemUsageThreshold: 100, // 高水位限流阈值
MemLowWaterMarkBytes: 1024, // 低水位内存字节
MemHighWaterMarkBytes: 2048, // 高水位内存字节
},
本质上Sentinel-Go将内存划分为三个区域,分别是内存高水位区域,低水位区域以及阈值动态波动区域。当内存使用在高水位或低水位区域时则使用对应的水位阈值,如果内存在阈值的波动区域(高水位字节减低水位字节),那么则需要动态计算流控阈值。
Token计算逻辑也相对简单,只需要动态的获取内存已使用的字节(使用工具库gopsutil),然后去对比当前内存使用的字节在哪一个区域内,如果在动态的波动区域内则简单的数学公式计算出对应的动态流控阈值
go
(float64(m.highMemUsageThreshold-m.lowMemUsageThreshold)/float64(m.memHighWaterMark-m.memLowWaterMark))*float64(mem-m.memLowWaterMark) + float64(m.lowMemUsageThreshold)
总结
内存自适应算法中提前将内存的最低以及最高水位对应的流控阈值规划好,然后根据内存使用情况在"安全的"范围内去动态控制流控阈值
3.3 控制行为(流控行为
3.3.1 直接拒绝
介绍
当流控规则中ControlBehavior设置为Reject ,则代表使用"拒绝"的流控行为,当请求流量大于限流阈值时则将超出阈值的请求直接返回。
原理
在拒绝的控制行为中实现相对简单,主要是依托于上文介绍的滑动时间轮统计结构,有了统计结构可以很轻松的获取到已通过的请求数,从而与限流阈值进行比对。
go
3.3.2 匀速排队
介绍
当流控规则中ControlBehavior设置为Throttling ,则代表使用"匀速排队"(也叫匀速器)的流控行为。与直接拒绝的控制行为不同的是当请求流量大于限流阈值时则将超出阈值的请求按照一定的间隔时间匀速通过,对应的是漏桶算法。
原理
在Sentinel-Go中匀速排队的流控行为,本质上就是一个漏桶。漏桶的特点:流入速率不固定,但是流出速率是固定的。当前流量+已通过的流量大于阈值时将当前流量放入漏桶中,然后在漏桶里对流量按照固定的时间间隔排队通过。
排队的过程如下:
匀速排队的实现代码,在注释中讲解了细节的实现:
go
func (c *ThrottlingChecker) DoCheck(_ base.StatNode, batchCount uint32, threshold float64) *base.TokenResult {
// 获取当前时间(单位纳秒,方便更精确的计算排队时间)
curNano := int64(util.CurrentTimeNano())
// 计算每个流量排队的时间间隔,这里假设当前流量(batchCount=1),流控阈值(threshold=100),统计周期:单位纳秒(statIntervalNs=1000000000)
intervalNs := int64(math.Ceil(float64(batchCount) / threshold * float64(c.statIntervalNs)))
// 最后一个流量排队通过的时间
loadedLastPassedTime := atomic.LoadInt64(&c.lastPassedTime)
// 当前请求预计的通过时间=最后一个流量排队通过的时间+排队的时间间隔
expectedTime := loadedLastPassedTime + intervalNs
// 如果预计通过的时间小于当前时间,则说明不需要排队,直接放行。
if expectedTime <= curNano {
if swapped := atomic.CompareAndSwapInt64(&c.lastPassedTime, loadedLastPassedTime, curNano); swapped {
// nil means pass
return nil
}
}
// 预估排队等待的时长=当前请求预计的通过时间-当前时间
estimatedQueueingDuration := atomic.LoadInt64(&c.lastPassedTime) + intervalNs - curNano
// 如果预估排队时长大于设定的最大等待时长,则直接被拒绝掉当前流量
if estimatedQueueingDuration > c.maxQueueingTimeNs {
return base.NewTokenResultBlockedWithCause(base.BlockTypeFlow, BlockMsgQueueing, rule, nil)
}
// 原子操作:得到当前流量的通过时间(这里主要避免并发导致lastPassedTime不是最新的)
oldTime := atomic.AddInt64(&c.lastPassedTime, intervalNs)
// 预估排队等待的时长
estimatedQueueingDuration = oldTime - curNano
if estimatedQueueingDuration > c.maxQueueingTimeNs {
// 如果大于了设定的最大等待时长,这里减去排队间隔,因为不需要排队了,直接拒绝当前流量。
atomic.AddInt64(&c.lastPassedTime, -intervalNs)
return base.NewTokenResultBlockedWithCause(base.BlockTypeFlow, BlockMsgQueueing, rule, nil)
}
// 如果预估排队等待的时长大于0,则按照排队等待的时长进行sleep
if estimatedQueueingDuration > 0 {
return base.NewTokenResultShouldWait(time.Duration(estimatedQueueingDuration))
} else {
return base.NewTokenResultShouldWait(0)
}
}
总结
在匀速排队的控制行为中将超过阈值的流量,按照固定的时间间隔依次的排队等待。 等待的时间是根据前一个流量的排队通过时间+排队的时间间隔计算出来的,等待的动作是sleep。符合漏桶的特点,按照固定的速率放行流量。
4. 基于调用关系的流量控制
Sentinel-Go 支持关联流量控制策略。当两个资源之间具有资源争抢或者依赖关系的时候,这两个资源便具有了关联。比如对数据库同一个字段的读操作和写操作存在争抢,读的速度过高会影响写得速度,写的速度过高会影响读的速度。如果放任读写操作争抢资源,则争抢本身带来的开销会降低整体的吞吐量。可使用关联限流来避免具有关联关系的资源之间过度的争抢。
当流控规则中RelationStrategy=AssociatedResource时则代表使用基于调用关系的流量控制
go
{
Resource: resName,
TokenCalculateStrategy: flow.Direct,
ControlBehavior: flow.Reject,
RelationStrategy: flow.AssociatedResource,
RefResource: "db-write",
Threshold: 10,
StatIntervalInMs: 1000,
}
这种方式的 Token 计算策略和流控行为的实现方式都和上面介绍的一样,只不过在初始化Token计算策略和流控行为时绑定的是关联资源的统计结构。这样在计算阈值以及获取当前QPS等指标都是获取的关联资源的数据,那么就可以依据关联资源的指标数据进行流控了。
5. 总结
Sentinel-Go 在流量控制中提供了多种流量控制场景。实现的方式是通过组合Token计算策略和流控行为,并基于底层的滑动时间窗口统计数据结构来进行流量控制(其中最有特点的是冷启动+匀速排队的组合方式,相当于令牌桶+漏桶的组合)本文通过对流量控制的实现细节进行详解,相信大家在实际运用中可以更好的选择对应的组合方式。