作者:pavelchen
随着微服务的流行,服务之间的依赖性和调用关系变得越来越复杂,服务的稳定性变得尤为重要。业务场景中经常会涉及到瞬时流量冲击,可能会导致请求响应超时,甚至服务器被压垮、宕机不可用。出于对系统本身和上下游服务的保护,我们通常会对请求进行限流处理,快速拒绝超出配置上限的请求,保证系统或上下游服务系统的稳定。合理策略能有效应对流量冲击,确保系统可用性和性能。本文详细介绍了几种限流算法,比较各个算法的优缺点,给出了限流算法选型的一些建议,同时对业务上常用的分布式限流也提出一些解决方案。
1 背景
保护高并发服务稳定主要有三把利器:缓存、降级和限流。
-
缓存:缓存是一种提高数据读取性能的技术,通过在内存中存储经常访问的数据,可以减少对数据库或者其他存储系统的访问,从而提高系统的响应速度。缓存可以应用在多个层次,例如浏览器缓存、CDN 缓存、反向代理缓存、应用缓存等。 -
降级:降级是在系统压力过大或者部分服务不可用的情况下,暂时关闭一些非核心的服务,以保证核心服务的正常运行。降级可以在多个层次进行,例如页面降级、功能降级、服务降级等。 -
限流:限流是一种控制系统处理请求的速率的技术,以防止系统过载。限流可以通过多种算法实现,例如令牌桶算法、漏桶算法等。
这三把”利器”各有其特点,通常会结合使用,以达到最佳的效果。例如,可以通过缓存来减少数据库的访问,通过降级来应对系统故障,通过限流来防止系统过载。在设计高并发系统时,需要根据系统的具体需求和特点,合理地使用这些技术。接下来本文会主要介绍一些业界常用的限流方法。
2 限流知识概述
限流是一种对请求或并发数进行限制的关键技术手段,旨在保障系统的正常运行。当服务资源有限、处理能力有限时,限流可以对调用服务的上游请求进行限制,以防止自身服务因资源耗尽而停止服务。
在限流中,有两个重要的概念需要了解:
-
阈值:指在单位时间内允许的请求量。例如,将 QPS(每秒请求数)限制为500,表示在1秒内最多接受500次请求。通过设置合适的阈值,可以控制系统的负载,避免过多的请求导致系统崩溃或性能下降。 -
拒绝策略:用于处理超过阈值的请求的策略。常见的拒绝策略包括直接拒绝、排队等待等。直接拒绝会立即拒绝超过阈值的请求,而排队等待则将请求放入队列中,按照一定的规则进行处理。选择合适的拒绝策略可以平衡系统的稳定性和用户体验。
通过合理设置阈值和选择适当的拒绝策略,限流技术可以帮助系统应对突发的请求量激增、恶意用户访问或请求频率过高的情况,保障系统的稳定性和可用性。限流方案根据限流范围,可分为 单机限流和分布式限流 ;其中单机限流依据算法,又可分为 固定窗口、滑动窗口、漏桶和令牌桶限流等常见四种 。本文将对上述限流方案进行详细介绍。
3 限流基本算法
3.1 固定窗口限流
3.1.1 算法介绍
固定窗口算法是一种简单直观的限流算法,其原理是将时间划分为固定大小的窗口,在每个窗口内限制请求的数量或速率。具体实现时,可以使用一个计数器来记录当前窗口内的请求数,并与预设的阈值进行比较。固定窗口算法的原理如下:
-
将时间划分固定大小窗口,例如每秒一个窗口。 -
在每个窗口内,记录请求的数量。 -
当有请求到达时,将请求计数加一。 -
如果请求计数超过了预设的阈值(比如3个请求),拒绝该请求。 -
窗口结束后,重置请求计数。
3.1.2 代码实现
package main
import (
"context"
"fmt"
"go.uber.org/atomic"
"sync"
"git.code.oa.com/pcg-csd/trpc-ext/redis"
)
type RedisClient interface {
Do(ctx context.Context, cmd string, args ...interface{}) (interface{}, error)
}
// Client 数据库
type Client struct {
client RedisClient // redis 操作
script string // lua脚本
}
// NewBucketClient 创建redis令牌桶
func NewBucketClient(redis RedisClient) *Client {
helper := redis
return &Client{
client: helper,
script: `
-- 令牌桶限流脚本
-- KEYS[1]: 桶的名称
-- ARGV[1]: 桶的容量
-- ARGV[2]: 令牌产生速率
local bucket = KEYS[1]
local capacity = tonumber(ARGV[1])
local tokenRate = tonumber(ARGV[2])
local redisTime = redis.call('TIME')
local now = tonumber(redisTime[1])
local tokens, lastRefill = unpack(redis.call('hmget', bucket, 'tokens', 'lastRefill'))
tokens = tonumber(tokens)
lastRefill = tonumber(lastRefill)
if not tokens or not lastRefill then
tokens = capacity
lastRefill = now
else
local intervalsSinceLast = (now - lastRefill) * tokenRate
tokens = math.min(capacity, tokens + intervalsSinceLast)
end
if tokens < 1 then
return 0
else
redis.call('hmset', bucket, 'tokens', tokens - 1, 'lastRefill', now)
return 1
end
`,
}
}
// 获取令牌,获取成功则立即返回true,否则返回false
func (c *Client) isAllowed(ctx context.Context, key string, capacity int64, tokenRate int64) (bool, error) {
result, err := redis.Int(c.client.Do(ctx, "eval", c.script, 1, key, capacity, tokenRate))
if err != nil {
fmt.Println("Redis 执行错误:", err)
return false, err
}
return result == 1, nil
}
// 调用检测
func main() {
c := NewBucketClient(redis.GetPoolByName("redis://127.0.0.1:6379"))
gw := sync.WaitGroup{}
gw.Add(120)
count := atomic.Int64{}
for i := 0; i < 120; i++ {
go func(i int) {
defer gw.Done()
status, err := c.isAllowed(context.Background(), "test", 100, 10)
if status {
count.Add(1)
}
fmt.Printf("go %d status:%v error: %vn", i, status, err)
}(i)
}
gw.Wait()
fmt.Printf("allow %dnn", count.Load())
}
执行结果:
4.1.3 存在的问题
-
性能瓶颈:由于所有的请求都需要经过Redis,因此Redis可能成为整个系统的性能瓶颈。为了解决这个问题,可以考虑使用Redis集群来提高性能,或者使用更高性能的硬件。 -
单点故障:如果Redis出现故障,整个系统的限流功能将受到影响。为了解决这个问题,可以考虑使用Redis的主从复制或者哨兵模式来实现高可用。 -
网络带宽:Redis是一个基于网络通信的内存数据库,因此网络带宽是其性能的一个关键因素。如果网络带宽有限,可能会导致请求的传输速度变慢,从而影响Redis的性能。
4.2 基于负载均衡的分布式限流方案
4.2.1 方案原理
可以看到中心化限流方案的存在较高的单点故障风险,且带宽瓶颈比较严重。在这个基础上本文结合本地缓存单机限流和负载均衡设计了一个新的分布式限流方案。具体方案如下:
-
使用负载均衡器或分布式服务发现(北极星即可做到),将请求均匀地分发到每个机器上。这确保了每个机器都能处理一部分请求。 -
在每个机器上维护本机的限流状态,实现本地缓存单机限流的逻辑。使用令牌桶限流算法,在每个机器上独立地进行限流控制。每秒钟处理的请求数、令牌桶的令牌数量等。根据本地限流状态,对到达的请求进行限流判断。 -
准备相应的动态调整方案,可以根据每个机器的实际负载情况,动态地调整限流参数。例如,如果一个机器的CPU或内存使用率过高,你可以降低这个机器的限流阈值,减少这个机器的请求处理量。反之,如果一个机器的资源使用率较低,提高这个机器的限流阈值,增加这个机器的请求处理量。
4.2.2 存在的问题
-
本地缓存:这个方案对本地缓存的要求较高,需要自己根据业务逻辑抉择本地缓存的淘汰策略和缓存容量限制等风险点。 -
限流精度:本地缓存单机限流的精度受限于每个服务实例的资源和配置。这可能导致限流策略无法精确地适应整个系统的流量变化,无法灵活地调整限流规则。 -
请求负载均衡器的单点故障。 -
动态扩缩容的适应性:当系统需要动态扩展或缩容时,该方案可能需要额外的配置和和调整,以确保新加入或移除的服务实例能够正确地参与限流和请求均衡。
4.3 基于分布式协调服务的限流
4.3.1 方案原理
使用ZooKeeper或者etcd等分布式协调服务来实现限流。每台服务器都会向分布式协调服务申请令牌,只有获取到令牌的请求才能被处理。基本方案:
-
初始化令牌桶:在ZooKeeper中创建一个节点,节点的数据代表令牌的数量。初始时,将数据设置为令牌桶的容量。 -
申请令牌:当一个请求到达时,服务器首先向ZooKeeper申请一个令牌。这可以通过获取节点的分布式锁,然后将节点的数据减1实现。如果操作成功,说明申请到了令牌,请求可以被处理;如果操作失败,说明令牌已经用完,请求需要被拒绝或者等待。 -
释放令牌:当一个请求处理完毕时,服务器需要向ZooKeeper释放一个令牌。这可以通过获取节点的分布式锁,然后将节点的数据加1实现。 -
补充令牌:可以设置一个定时任务,定期向ZooKeeper中的令牌桶补充令牌。补充的频率和数量可以根据系统的负载情况动态调整。
4.3.2 存在的问题
这个方案的优点是可以实现精确的全局限流,并且可以避免单点故障。但是,这个方案的缺点是实现复杂,且对ZooKeeper的性能有较高的要求。如果ZooKeeper无法处理大量的令牌申请和释放操作,可能会成为系统的瓶颈。
5 总结
总之,没有最好的方案,只有合适的方案。在选择合适的限流方案时,我们需要考虑多种因素,包括系统的需求、现有的技术栈、系统的负载情况以及底层系统的性能等。理解每种方案的工作原理和特性,以便在实际应用中做出最佳的选择。
一个好的限流设计必须要考虑到业务的特性和需求,同时具备以下六点:
-
多级限流:除了主备复制的限流服务,可以考虑实现多级限流策略。例如,可以在应用层、服务层和数据层都设置限流,这样可以更好地防止系统过载。 -
动态阈值调整:我们可以根据系统的实时负载情况动态调整限流策略。例如,当系统负载较低时,我们可以放宽限流策略;当系统负载较高时,我们可以收紧限流策略。 -
灵活维度:限流策略应该能够根据不同的业务场景进行调整。除了接口,设备,ip,账户id等维度外,我们还可以考虑更细粒度的限流。例如,我们可以根据用户的行为模式进行限流,这样可以更好地防止恶意用户的攻击。 -
解耦性:限流应该作为一个基础服务,与具体的业务逻辑分离。这样,当业务逻辑发生变化时,不需要修改限流服务的代码,只需要调整限流策略即可。 -
容错性:限流服务应该具有高可用性,但是如果出现问题,业务应该有备选方案(熔断、降级)。这可能包括使用备用的限流服务,或者根据业务的敏感性决定是否放行请求。 -
监控和报警:对限流策略应进行实时监控,并设置报警机制。当限流策略触发时,可立即收到报警,以便我们可以及时地处理问题。
限流是保证系统稳定和高效运行的重要手段,但它并不是唯一的解决方案。我们还需要考虑其他的系统设计和优化手段,例如负载均衡、缓存、异步处理等(面对爆量,扩容永远是最好的方式,除了贵!)。这些手段相互配合,才能构建出一个既能应对高并发请求,又能保证服务质量的系统。
本篇文章来源于微信公众号: 腾讯技术工程
本文来自投稿,不代表TakinTalks稳定性技术交流平台立场,如若转载,请联系原作者。