基于Redis和Lua的分布式限流

简介: 今天,我们来学习一下分布式集群下的限流方案,Redis和Lua限流的原理和注意事项,比如说键值映射,Spring Cloud Gateway的限流原理也在其中。

 Java单机限流可以使用AtomicInteger,RateLimiter或Semaphore来实现,但是上述方案都不支持集群限流。集群限流的应用场景有两个,一个是网关,常用的方案有Nginx限流和Spring Cloud Gateway,另一个场景是与外部或者下游服务接口的交互,因为接口限制必须进行限流。

 本文的主要内容为:

  • Redis和Lua的使用场景和注意事项,比如说KEY映射的问题。
  • Spring Cloud Gateway中限流的实现。

集群限流的难点

 在上篇Guava RateLimiter的分析文章中,我们学习了令牌桶限流算法的原理,下面我们就探讨一下,如果将RateLimiter扩展,让它支持集群限流,会遇到哪些问题。

RateLimiter会维护两个关键的参数nextFreeTicketMicrosstoredPermits,它们分别是下一次填充时间和当前存储的令牌数。当RateLimiteracquire函数被调用时,也就是有线程希望获取令牌时,RateLimiter会对比当前时间和nextFreeTicketMicros,根据二者差距,刷新storedPermits,然后再判断更新后的storedPermits是否足够,足够则直接返回,否则需要等待直到令牌足够(Guava RateLimiter的实现比较特殊,并不是当前获取令牌的线程等待,而是下一个获取令牌的线程等待)。

 由于要支持集群限流,所以nextFreeTicketMicrosstoredPermits这两个参数不能只存在JVM的内存中,必须有一个集中式存储的地方。而且,由于算法要先获取两个参数的值,计算后在更新两个数值,这里涉及到竞态限制,必须要处理并发问题。

 集群限流由于会面对相比单机更大的流量冲击,所以一般不会进行线程等待,而是直接进行丢弃,因为如果让拿不到令牌的线程进行睡眠,会导致大量的线程堆积,线程持有的资源也不会释放,反而容易拖垮服务器。

Redis和Lua

redis和lua

 分布式限流本质上是一个集群并发问题,Redis单进程单线程的特性,天然可以解决分布式集群的并发问题。所以很多分布式限流都基于Redis,比如说Spring Cloud的网关组件Gateway。

 Redis执行Lua脚本会以原子性方式进行,单线程的方式执行脚本,在执行脚本时不会再执行其他脚本或命令。并且,Redis只要开始执行Lua脚本,就会一直执行完该脚本再进行其他操作,所以Lua脚本中不能进行耗时操作。使用Lua脚本,还可以减少与Redis的交互,减少网络请求的次数。

 Redis中使用Lua脚本的场景有很多,比如说分布式锁,限流,秒杀等,总结起来,下面两种情况下可以使用Lua脚本:

  • 使用 Lua 脚本实现原子性操作的CAS,避免不同客户端先读Redis数据,经过计算后再写数据造成的并发问题。
  • 前后多次请求的结果有依赖时,使用 Lua 脚本将多个请求整合为一个请求。

 但是使用Lua脚本也有一些注意事项:

  • 要保证安全性,在 Lua 脚本中不要定义自己的全局变量,以免污染 Redis内嵌的Lua环境。因为Lua脚本中你会使用一些预制的全局变量,比如说redis.call()
  • 要注意 Lua 脚本的时间复杂度,Redis 的单线程同样会阻塞在 Lua 脚本的执行中。
  • 使用 Lua 脚本实现原子操作时,要注意如果 Lua 脚本报错,之前的命令无法回滚,这和Redis所谓的事务机制是相同的。
  • 一次发出多个 Redis 请求,但请求前后无依赖时,使用 pipeline,比 Lua 脚本方便。
  • Redis要求单个Lua脚本操作的key必须在同一个Redis节点上。解决方案可以看下文对Gateway原理的解析。

性能测试

 Redis虽然以单进程单线程模型进行操作,但是它的性能却十分优秀。总结来说,主要是因为:

  • 绝大部分请求是纯粹的内存操作
  • 采用单线程,避免了不必要的上下文切换和竞争条件
  • 内部实现采用非阻塞IO和epoll,基于epoll自己实现的简单的事件框架。epoll中的读、写、关闭、连接都转化成了事件,然后利用epoll的多路复用特性,绝不在io上浪费一点时间。

 所以,在集群限流时使用Redis和Lua的组合并不会引入过多的性能损耗。我们下面就简单的测试一下,顺便熟悉一下涉及的Redis命令。

# test.lua脚本的内容
local test = redis.call("get", "test")
local time = redis.call("get", "time")
redis.call("setex", "test", 10, "xx")
redis.call("setex", "time", 10, "xx")
return {test, time}

# 将脚本导入redis,之后调用不需再传递脚本内容
redis-cli -a 082203 script load "$(cat test.lua)"
"b978c97518ae7c1e30f246d920f8e3c321c76907"
# 使用redis-benchmark和evalsha来执行lua脚本
redis-benchmark -a 082203 -n 1000000 evalsha b978c97518ae7c1e30f246d920f8e3c321c76907 0 
======
1000000 requests completed in 20.00 seconds
50 parallel clients
3 bytes payload
keep alive: 1

93.54% <= 1 milliseconds
99.90% <= 2 milliseconds
99.97% <= 3 milliseconds
99.98% <= 4 milliseconds
99.99% <= 5 milliseconds
100.00% <= 6 milliseconds
100.00% <= 7 milliseconds
100.00% <= 7 milliseconds
49997.50 requests per second

 通过上述简单的测试,我们可以发现本机情况下,使用Redis执行Lua脚本的性能极其优秀,一百万次执行,99.99%在5毫秒以下。

 本来想找一下官方的性能数据,但是针对Redis + Lua的性能数据较少,只找到了几篇个人博客,感兴趣的同学可以去探索。这篇文章有Lua和zadd的性能比较(具体数据请看原文,链接缺失的话,请看文末)。

以上lua脚本的性能大概是zadd的70%-80%,但是在可接受的范围内,在生产环境可以使用。负载大概是zadd的1.5-2倍,网络流量相差不大,IO是zadd的3倍,可能是开启了AOF,执行了三次操作。

Spring Cloud Gateway的限流实现

Spring Cloud

Gateway是微服务架构Spring Cloud的网关组件,它基于Redis和Lua实现了令牌桶算法的限流功能,下面我们就来看一下它的原理和细节吧。

Gateway基于Filter模式,提供了限流过滤器RequestRateLimiterGatewayFilterFactory。只需在其配置文件中进行配置,就可以使用。具体的配置感兴趣的同学自行学习,我们直接来看它的实现。

RequestRateLimiterGatewayFilterFactory依赖RedisRateLimiterisAllowed函数来判断一个请求是否要被限流抛弃。

public Mono<Response> isAllowed(String routeId, String id) {
        //routeId是ip地址,id是使用KeyResolver获取的限流维度id,比如说基于uri,IP或者用户等等。
    Config routeConfig = loadConfiguration(routeId);
    // 每秒能够通过的请求数
    int replenishRate = routeConfig.getReplenishRate();
    // 最大流量
    int burstCapacity = routeConfig.getBurstCapacity();
    try {
        // 组装Lua脚本的KEY
        List<String> keys = getKeys(id);
        // 组装Lua脚本需要的参数,1是指一次获取一个令牌
        List<String> scriptArgs = Arrays.asList(replenishRate + "",
                burstCapacity + "", Instant.now().getEpochSecond() + "", "1");
        // 调用Redis,tokens_left = redis.eval(SCRIPT, keys, args)
        Flux<List<Long>> flux = this.redisTemplate.execute(this.script, keys,
                scriptArgs);
    ..... // 省略            
}
static List<String> getKeys(String id) {
    String prefix = "request_rate_limiter.{" + id;
    String tokenKey = prefix + "}.tokens";
    String timestampKey = prefix + "}.timestamp";
    return Arrays.asList(tokenKey, timestampKey);
}                

 需要注意的是getKeys函数的prefix包含了"{id}",这是为了解决Redis集群键值映射问题。Redis的KeySlot算法中,如果key包含{},就会使用第一个{}内部的字符串作为hash key,这样就可以保证拥有同样{}内部字符串的key就会拥有相同slot。Redis要求单个Lua脚本操作的key必须在同一个节点上,但是Cluster会将数据自动分布到不同的节点,使用这种方法就解决了上述的问题。

 然后我们来看一下Lua脚本的实现,该脚本就在Gateway项目的resource文件夹下。它就是如同GuavaRateLimiter一样,实现了令牌桶算法,只不过不在需要进行线程休眠,而是直接返回是否能够获取。

local tokens_key = KEYS[1]   -- request_rate_limiter.${id}.tokens 令牌桶剩余令牌数的KEY值
local timestamp_key = KEYS[2] -- 令牌桶最后填充令牌时间的KEY值

local rate = tonumber(ARGV[1])  -- replenishRate 令令牌桶填充平均速率
local capacity = tonumber(ARGV[2]) -- burstCapacity 令牌桶上限
local now = tonumber(ARGV[3]) -- 得到从 1970-01-01 00:00:00 开始的秒数
local requested = tonumber(ARGV[4]) -- 消耗令牌数量,默认 1 

local fill_time = capacity/rate   -- 计算令牌桶填充满令牌需要多久时间
local ttl = math.floor(fill_time*2)  -- *2 保证时间充足


local last_tokens = tonumber(redis.call("get", tokens_key)) 
-- 获得令牌桶剩余令牌数
if last_tokens == nil then  -- 第一次时,没有数值,所以桶时满的
  last_tokens = capacity
end

local last_refreshed = tonumber(redis.call("get", timestamp_key)) 
-- 令牌桶最后填充令牌时间
if last_refreshed == nil then
  last_refreshed = 0
end

local delta = math.max(0, now-last_refreshed)  
-- 获取距离上一次刷新的时间间隔
local filled_tokens = math.min(capacity, last_tokens+(delta*rate)) 
-- 填充令牌,计算新的令牌桶剩余令牌数 填充不超过令牌桶令牌上限。

local allowed = filled_tokens >= requested      
local new_tokens = filled_tokens
local allowed_num = 0
if allowed then
-- 若成功,令牌桶剩余令牌数(new_tokens) 减消耗令牌数( requested ),并设置获取成功( allowed_num = 1 ) 。
  new_tokens = filled_tokens - requested
  allowed_num = 1
end       

-- 设置令牌桶剩余令牌数( new_tokens ) ,令牌桶最后填充令牌时间(now) ttl是超时时间?
redis.call("setex", tokens_key, ttl, new_tokens)
redis.call("setex", timestamp_key, ttl, now)

-- 返回数组结果
return { allowed_num, new_tokens }

后记

 Redis的主从异步复制机制可能丢失数据,出现限流流量计算不准确的情况,当然限流毕竟不同于分布式锁这种场景,对于结果的精确性要求不是很高,即使多流入一些流量,也不会影响太大。
 正如Martin在他质疑Redis分布式锁RedLock文章中说的,Redis的数据丢弃了也无所谓时再使用Redis存储数据。

I think it’s a good fit in situations where you want to share some transient, approximate, fast-changing data between servers, and where it’s not a big deal if you occasionally lose that data for whatever reason

 接下来我们回来学习阿里开源的分布式限流组件sentinel,希望大家持续关注。

个人博客: remcarpediem

参考

相关文章
|
12月前
|
存储 负载均衡 NoSQL
【赵渝强老师】Redis Cluster分布式集群
Redis Cluster是Redis的分布式存储解决方案,通过哈希槽(slot)实现数据分片,支持水平扩展,具备高可用性和负载均衡能力,适用于大规模数据场景。
794 2
|
存储 缓存 NoSQL
【📕分布式锁通关指南 12】源码剖析redisson如何利用Redis数据结构实现Semaphore和CountDownLatch
本文解析 Redisson 如何通过 Redis 实现分布式信号量(RSemaphore)与倒数闩(RCountDownLatch),利用 Lua 脚本与原子操作保障分布式环境下的同步控制,帮助开发者更好地理解其原理与应用。
846 6
|
存储 缓存 NoSQL
Redis核心数据结构与分布式锁实现详解
Redis 是高性能键值数据库,支持多种数据结构,如字符串、列表、集合、哈希、有序集合等,广泛用于缓存、消息队列和实时数据处理。本文详解其核心数据结构及分布式锁实现,帮助开发者提升系统性能与并发控制能力。
|
11月前
|
NoSQL Java 调度
分布式锁与分布式锁使用 Redis 和 Spring Boot 进行调度锁(不带 ShedLock)
分布式锁是分布式系统中用于同步多节点访问共享资源的机制,防止并发操作带来的冲突。本文介绍了基于Spring Boot和Redis实现分布式锁的技术方案,涵盖锁的获取与释放、Redis配置、服务调度及多实例运行等内容,通过Docker Compose搭建环境,验证了锁的有效性与互斥特性。
901 0
分布式锁与分布式锁使用 Redis 和 Spring Boot 进行调度锁(不带 ShedLock)
|
11月前
|
缓存 NoSQL 关系型数据库
Redis缓存和分布式锁
Redis 是一种高性能的键值存储系统,广泛用于缓存、消息队列和内存数据库。其典型应用包括缓解关系型数据库压力,通过缓存热点数据提高查询效率,支持高并发访问。此外,Redis 还可用于实现分布式锁,解决分布式系统中的资源竞争问题。文章还探讨了缓存的更新策略、缓存穿透与雪崩的解决方案,以及 Redlock 算法等关键技术。
|
数据采集 存储 数据可视化
分布式爬虫框架Scrapy-Redis实战指南
本文介绍如何使用Scrapy-Redis构建分布式爬虫系统,采集携程平台上热门城市的酒店价格与评价信息。通过代理IP、Cookie和User-Agent设置规避反爬策略,实现高效数据抓取。结合价格动态趋势分析,助力酒店业优化市场策略、提升服务质量。技术架构涵盖Scrapy-Redis核心调度、代理中间件及数据解析存储,提供完整的技术路线图与代码示例。
2009 0
分布式爬虫框架Scrapy-Redis实战指南
|
NoSQL Java 中间件
【📕分布式锁通关指南 02】基于Redis实现的分布式锁
本文介绍了从单机锁到分布式锁的演变,重点探讨了使用Redis实现分布式锁的方法。分布式锁用于控制分布式系统中多个实例对共享资源的同步访问,需满足互斥性、可重入性、锁超时防死锁和锁释放正确防误删等特性。文章通过具体示例展示了如何利用Redis的`setnx`命令实现加锁,并分析了简化版分布式锁存在的问题,如锁超时和误删。为了解决这些问题,文中提出了设置锁过期时间和在解锁前验证持有锁的线程身份的优化方案。最后指出,尽管当前设计已解决部分问题,但仍存在进一步优化的空间,将在后续章节继续探讨。
1715 131
【📕分布式锁通关指南 02】基于Redis实现的分布式锁
|
数据采集 存储 NoSQL
基于Scrapy-Redis的分布式景点数据爬取与热力图生成
基于Scrapy-Redis的分布式景点数据爬取与热力图生成
1127 67
|
NoSQL Java Redis
Springboot使用Redis实现分布式锁
通过这些步骤和示例,您可以系统地了解如何在Spring Boot中使用Redis实现分布式锁,并在实际项目中应用。希望这些内容对您的学习和工作有所帮助。
1509 83
|
NoSQL Redis
Lua脚本协助Redis分布式锁实现命令的原子性
利用Lua脚本确保Redis操作的原子性是分布式锁安全性的关键所在,可以大幅减少由于网络分区、客户端故障等导致的锁无法正确释放的情况,从而在分布式系统中保证数据操作的安全性和一致性。在将这些概念应用于生产环境前,建议深入理解Redis事务与Lua脚本的工作原理以及分布式锁的可能问题和解决方案。
447 8

相关产品

  • 云数据库 Tair(兼容 Redis)