Bucket4j 8.14.0 使用教程:从基础到Redisson集成
AI 生成,自己学习参考做记录,请不要完全相信他
引言
Bucket4j是一个基于令牌桶算法的Java限流库,以其精确的实现和灵活的配置而闻名。它不仅支持本地限流,还能通过多种后端(如JCache、Hazelcast、Redis等)实现分布式限流。本教程将全面介绍Bucket4j的核心概念、常用类以及不同策略的区别,并重点讲解如何使用Redisson实现分布式限流。
Bucket4j简介
Bucket4j是一个Java限流库,主要基于令牌桶算法实现。令牌桶算法是一种被广泛接受的限流算法,它通过控制令牌的生成和消耗速率来实现流量控制。Bucket4j不仅实现了传统令牌桶算法,还提供了一些有用的扩展,如多限流策略和透支功能。
主要特性
- 绝对精确:Bucket4j不使用浮点或双精度计算,所有计算都在整数算术范围内进行,避免了因舍入而产生的计算错误。
- 高并发性能:默认使用无锁实现,适用于多线程场景。
- 低内存占用:内存占用极低且固定,与请求速率无关。
- 可插拔监听器API:允许实现监控和日志记录。
- 丰富的诊断API:允许调查内部状态。
- 丰富的配置管理:支持动态更改配置
适用场景
- API限流
- 服务间调用限流
- 消息队列消费者限速
- 用户请求限流
- 分布式系统中的资源保护
核心概念
Bucket(桶)
Bucket是Bucket4j的核心接口,代表了一个限流器,它基于令牌桶算法实现。一个Bucket包含以下部分:
- BucketConfiguration:指定Bucket在工作过程中使用的不可变限制规则集合。
- BucketState:存储Bucket的可变状态,如当前可用令牌数量。 Bucket可以通过特殊的构建器API创建,该API通过工厂方法提供:
Bucket bucket = Bucket.builder()
.addLimit(...)
.build();
BucketConfiguration(桶配置)
BucketConfiguration可以描述为Bucket在工作过程中使用的"限制"集合。它在Bucket4j代码库中由io.github.bucket4j.BucketConfiguration
类表示。配置是不可变的,无法向已创建的配置添加或移除限制。但是,你可以通过创建新的配置实例并调用bucket.replaceConfiguration(newConfiguration)
来替换Bucket的配置。
通常,你应该通过BucketBuilder
间接创建BucketConfiguration,它会在幕后为你处理。对于需要直接创建配置的特殊情况,可以使用由工厂方法提供的ConfigurationBuilder
:
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1000).refillGreedy(1000, ofMinutes(1)))
.build();
Limitation/Bandwidth(限制/带宽)
Bucket中使用的限制可以表示为带宽。带宽由以下术语定义:
- 容量(Capacity):这是从经典令牌桶算法继承的术语,指定Bucket有多少个令牌。容量必须在构建阶段配置。
- 补充(Refill):指定令牌在从Bucket消耗后多快可以被补充。Refill必须在构建阶段配置。 Bucket4j允许选择不同的Refill类型,以控制令牌被补充到Bucket的方式。
Refill类型
Bucket4j提供了四种Refill类型,决定了消费的令牌如何被补充到Bucket:
- Greedy(贪婪): 这种类型的Refill贪婪地生成令牌,它尽可能快地将令牌添加到Bucket中。例如,"每秒10个令牌"的Refill会每100毫秒添加1个令牌,换句话说,Refill不会等待1秒来补充10个令牌。
- Intervally(间隔): 这种类型的Refill按间隔方式生成令牌。"间隔"与"贪婪"相反,它会等待整个周期过去后再生成整个数量的令牌。
- IntervallyAligned(间隔对齐): 这种类型的Refill按间隔方式生成令牌。它允许指定第一次Refill发生的时间。这可以用来配置明确的间隔边界,如秒、分钟、小时或天的开始。
- RefillIntervallyAlignedWithAdaptiveInitialTokens: 这种类型在Javadoc中有详细说明,可以根据需要自定义初始令牌数量。
Initial Tokens(初始令牌)
Bucket4j扩展了令牌桶算法,允许为每个带宽指定初始令牌数量。默认情况下,初始数量等于容量,可以通过withInitialTokens
方法更改:
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(42).refillGreedy(1, ofSeconds(1)).initialTokens(13))
.build();
Bandwidth ID(带宽ID)
ID是默认为null的可选属性。如果你使用动态配置替换并且Bucket有多个带宽,你可能会希望为带宽分配ID。带宽ID可以通过以下方式指定:
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(1000).refillGreedy(1000, ofMinutes(1)).id("business-limit"))
.addLimit(limit -> limit.capacity(100).refillGreedy(100, ofSeconds(1)).id("burst-protection"))
.build();
带宽ID对于动态配置替换非常重要,因为替换时需要决定如何将已消费令牌的信息从替换前的配置状态正确传播到替换后的配置状态。当带宽数量变化时,这是一个非trivial的任务。
Quick Start Examples(快速入门示例)
如何依赖Bucket4j
Bucket4j通过Maven Central分发。你需要添加以下依赖项到项目中,以便能够编译和运行示例: 对于Java 17:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
<version>8.14.0</version>
</dependency>
对于Java 11:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk11-core</artifactId>
<version>8.14.0</version>
</dependency>
对于Gradle:
implementation 'com.bucket4j:bucket4j-core:8.14.0'
如果需要Java 8兼容的构建,请参考Java兼容性矩阵。
限制REST API的访问速率
假设你正在开发一个社交网络,并希望为第三方开发者提供REST API。为了保护系统不超载,你希望引入以下限制:
- 桶大小为50次调用(任何给定时间都不能超过这个数量)
- 补充速率为每秒10次调用,这会不断向桶中增加令牌 构建满足上述要求的桶比之前的示例稍微复杂一些,因为我们必须处理透支:
import io.github.bucket4j.Bucket;
public class ThrottlingFilter implements javax.servlet.Filter {
private Bucket createNewBucket() {
return Bucket.builder()
.addLimit(limit -> limit.capacity(50).refillGreedy(10, Duration.ofSeconds(1)))
.build();
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
HttpSession session = httpRequest.getSession(true);
String appKey = SecurityUtils.getThirdPartyAppKey();
Bucket bucket = (Bucket) session.getAttribute("throttler-" + appKey);
if (bucket == null) {
bucket = createNewBucket();
session.setAttribute("throttler-" + appKey, bucket);
}
// tryConsume如果桶中没有可用令牌会立即返回false
if (bucket.tryConsume(1)) {
// 限制未超过
filterChain.doFilter(servletRequest, servletResponse);
} else {
// 限制超过
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
httpResponse.setContentType("text/plain");
httpResponse.setStatus(429);
httpResponse.getWriter().append("Too many requests");
}
}
}
如果希望向用户关于桶状态提供更多详细信息,可以重写代码:
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
if (probe.isConsumed()) {
// 限制未超过
httpResponse.setHeader("X-Rate-Limit-Remaining", "" + probe.getRemainingTokens());
filterChain.doFilter(servletRequest, servletResponse);
} else {
// 限制超过
httpResponse.setStatus(429);
httpResponse.setHeader("X-Rate-Limit-Retry-After-Seconds", "" + TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()));
httpResponse.setContentType("text/plain");
httpResponse.getWriter().append("Too many requests");
}
指定令牌的初始数量
默认情况下,桶的初始大小等于容量。但有时,你可能希望有较小的初始大小,例如为了冷启动的情况,以防止拒绝服务:
int initialTokens = 42;
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(1000).refillGreedy(1000, ofHours(1)).initialTokens(initialTokens))
.build();
将令牌返回到桶
补偿交易是当你想要将令牌返回到桶时的一个明显用例:
Bucket wallet;
...
if (wallet.tryConsume(50)) {
// 从钱包获取50美分
try {
buyCocaCola();
} catch (NoCocaColaException e) {
// 将钱返回钱包
wallet.addTokens(50);
}
}
定制时间测量 - 选择nanotime时间分辨率
默认情况下,Bucket4j使用毫秒时间分辨率,这是首选的时间测量策略。但在某些情况下(如基准测试),你可能希望选择nanosecond精度:
Bucket.builder().withNanosecondPrecision()
但要非常小心选择这个时间测量策略,因为System.nanoTime()
会产生不准确的结果,只在这种情况下使用这个策略:带宽周期太小,毫秒分辨率将不理想。
定制时间测量 - 指定自定义时间测量策略
如果你现有的毫秒或nanotime时间测量策略不够,可以指定自己的时间测量策略:
public class ClusteredTimeMeter implements TimeMeter {
@Override
public long currentTimeNanos() {
return ClusteredClock.currentTimeMillis() * 1_000_000;
}
}
Bucket bucket = Bucket.builder()
.withCustomTimePrecision(new ClusteredTimeMeter())
.addLimit(limit -> limit.capacity(100).refillGreedy(100, ofMinutes(1)))
.build();
阻塞API示例
假设你实现消息系统中的消息消费者,希望以不超过预期速率的速度处理消息:
// 定义桶,容量为100,每1分钟补充100个令牌
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, ofMinutes(1)))
.build();
// 无限循环进行轮询
while (true) {
List<Message> messages = consumer.poll();
for (Message message : messages) {
// 从令牌桶中消耗一个令牌。如果令牌不可用,此方法将阻塞,直到补充将一个令牌添加到桶中。
bucket.asBlocking().consume(1);
process(message);
}
}
监听桶事件
你可以通过为Bucket附加一个监听器,来跟踪以下事件:
- 从桶中消耗令牌
- 消费请求被桶拒绝
- 线程因与BlockingBucket交互而被挂起等待令牌补充
- 线程因与BlockingBucket交互而中断等待
- 由于与AsyncScheduledBucket交互而提交延迟任务
监听器API - 角落情况
问题:创建使用许多桶的应用程序需要多少个监听器? 答案:这取决于:
- 如果你想为所有桶聚合统计信息,则为应用程序创建一个单一监听器,并为所有桶重用这个监听器
- 如果你想独立测量每个桶的统计信息,则使用每个桶一个监听器模型 问题:在分布式使用情况下,监听器调用的方法在哪里? 答案:监听器始终在客户端调用,这意味着每个客户端JVM将为同一个桶拥有独立的统计信息。 问题:为什么桶在分布式场景中调用监听器在客户端而不是服务器端?如果我需要整个集群的聚合统计信息,我该怎么办? 答案:这是由于计划扩展到非JVM后端,如Redis、MySQL、PostgreSQL。无法对这些非Java后端进行序列化和调用监听器,所以决定在客户端调用监听器,以避免未来不同后端之间的不一致。你可以通过监控数据库中构建的功能或应用程序和监控数据库之间的中介(如StatsD)来实现监控统计信息的后聚合。
在构建时为本地桶指定监听器
BucketListener listener = new MyListener();
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, ofMinutes(1)))
.withListener(listener)
.build();
在构建时为分布式桶指定监听器
BucketListener listener = new MyListener();
Bucket bucket = proxyManager.builder()
.withListener(listener)
.build(key, configSupplier);
在构建时为异步分布式桶指定监听器
BucketListener listener = new MyListener();
AsyncBucketProxy bucket = proxyManager.asAsync().builder()
.withListener(listener)
.build(key, configSupplier);
在代理管理器构建时指定监听器
你可以在代理管理器构建时配置默认监听器,这个监听器将用于属于这个代理管理器的所有桶。以下是Hazelcast的示例,配置其他后端的监听器方式相同。
BucketListener listener = new MyListener();
// 监听器将附加到属于这个代理管理器的所有桶
HazelcastLockBasedProxyManager proxyManager = Bucket4jHazelcast.entryProcessorBasedBuilder(map)
.defaultListener(listener)
.build();
在使用时指定监听器到桶
有时,监听器在桶构建时未知,你希望稍后指定。例如,当你希望为多个用户共享同一个桶,但每个用户需要独立的监听器。
在这种情况下,你可以不带监听器构建桶,然后通过toListenable
方法稍后附加它:
public void doSomethingProtected(User user, Bucket bucket) {
bucket = decorate(user, bucket).tryConsume(1);
if (bucket.tryConsume(1)) {
doSomething(user);
} else {
handleRateLimitError(user);
}
}
...
private Bucket decorate(User user, Bucket originalbucket) {
BucketListener listener = new BucketListener() {
@Override
public void onConsumed(long tokens) {
// 记录与用户相关的信息或增加用户相关指标
}
@Override
public void onRejected(long tokens) {
// 记录与用户相关的信息或增加用户相关指标
}
// ... 其他方法
};
return originalbucket.toListenable(listener);
}
与Dropwizard metrics-core集成的示例
io.github.bucket4j.SimpleBucketListener
是io.github.bucket4j.BucketListener
接口的简单实现,它是开箱即用的。以下是通过Dropwizard Metrics(对于Micrometer应该差不多)暴露统计信息的示例:
public static Bucket decorateBucketByStatListener(Bucket originalBucket, String bucketName, MetricRegistry registry) {
SimpleBucketListener stat = new SimpleBucketListener();
registry.register(name + ".consumed", (Gauge<Long>) stat::getConsumed);
registry.register(name + ".rejected", (Gauge<Long>) stat::getRejected);
registry.register(name + ".parkedNanos", (Gauge<Long>) stat::getParkedNanos);
registry.register(name + ".interrupted", (Gauge<Long>) stat::getInterrupted);
registry.register(name + ".delayedNanos", (Gauge<Long>) stat::getDelayedNanos);
return originalBucket.toListenable(stat);
}
Verbose/Debug API(详细/调试API)
Verbose API的目的是在与Bucket的任何交互结果中注入低级诊断信息。Verbose API提供与Regular API相同的功能,只有一个例外——任何方法的结果总是由VerboseResult
包装器装饰。
VerboseResult是交互结果的包装器,它提供了在与Bucket交互时实际存在的Bucket和其配置的快照。
Verbose API入口点
获取Verbose API的方式对所有类型的Bucket都是相同的,只需调用asVerbose()
方法:
Bucket bucket = ...;
VerboseBucket verboseBucket = bucket.asVerbose();
VerboseSchedulingBucket verboseSchedulingBucket = bucket.asScheduler().asVerbose();
VerboseBlockingBucket verboseBlockingBucket = bucket.asBlocking().asVerbose();
// 对于io.github.bucket4j.distributed.AsyncBucketProxy
AsyncBucketProxy bucket = ...;
AsyncVerboseBucket verboseBucket = bucket.asVerbose();
VerboseSchedulingBucket verboseSchedulingBucket = bucket.asScheduler().asVerbose();
结果装饰原则
- void返回类型总是由
VerboseResult<Void>
装饰 - 像long、boolean这样的原始结果类型总是由对应的包装类型装饰,例如
VerboseResult<Boolean>
- 非原始结果类型总是按原样装饰,例如
VerboseResult<EstimationProbe>
Verbose API使用示例
VerboseResult<ConsumptionProbe> verboseResult = bucket.asVerbose().tryConsumeAndReturnRemaining(numberOfTokens);
BucketConfiguration bucketConfiguration = verboseResult.getConfiguration();
long capacity = Arrays.stream(bucketConfiguration.getBandwidths())
.mapToLong(Bandwidth::getCapacity)
.max().getAsLong();
response.addHeader("RateLimit-Limit", "" + capacity);
VerboseResult.Diagnostics diagnostics = verboseResult.getDiagnostics();
response.addHeader("RateLimit-Remaining", "" + diagnostics.getAvailableTokens());
response.addHeader("RateLimit-Reset", "" + TimeUnit.NANOSECONDS.toSeconds(diagnostics.calculateFullRefillingTime()));
ConsumptionProbe probe = verboseResult.getValue();
if (probe.isConsumed()) {
// 限制未超过
filterChain.doFilter(servletRequest, servletResponse);
} else {
// 限制超过
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
httpResponse.setStatus(429);
httpResponse.setContentType("text/plain");
httpResponse.getWriter().append("Too many requests");
}
On-the-fly Configuration Replacement(动态配置替换)
如BucketConfiguration定义中所述,它是一个不可变对象。无法向已创建的配置添加、移除或更改限制,但是,你可以通过创建新的配置实例并调用bucket.replaceConfiguration(newConfiguration, tokensInheritanceStrategy)
来替换Bucket的配置。
为什么配置替换不是trivial的?
配置替换的第一个问题是决定如何将桶中先前配置的可用令牌传播到新配置的桶。如果你不关心桶的先前状态,那么使用TokensInheritanceStrategy.RESET
。但是,当期望先前消费(尚未被补充)应该对新配置产生影响时,这就变成了一个棘手的问题。在这种情况下,你需要在以下之间选择:TokensInheritanceStrategy.PROPORTIONALLY
、TokensInheritanceStrategy.AS_IS
或TokensInheritanceStrategy.ADDITIVE
。
当选择PROPORTIONALLY
、AS_IS
或ADDITIVE
且桶有多个带宽时,会有另一个问题。例如,在以下示例中,replaceConfiguration实现如何将带宽绑定在一起:
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(10, ofSeconds(1)))
.addLimit(limit -> limit.capacity(10000).refillGreedy(10000, ofHours(1)))
.build();
...
BucketConfiguration newConfiguration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(5000).refillGreedy(5000, ofHours(1)))
.addLimit(limit -> limit.capacity(100).refillGreedy(100, ofSeconds(10)))
.build();
bucket.replaceConfiguration(newConfiguration, TokensInheritanceStrategy.AS_IS);
显然,简单的策略——按带宽索引复制令牌——在这种情况下不会很好地工作,因为它高度依赖于新配置和先前配置中带宽的顺序。
通过带宽标识符控制替换过程
为了不让这个过程变得复杂,Bucket4j提供了一种控制此过程的方法,即为带宽指定标识符,这样在多个带宽配置替换时,代码可以通过带宽ID复制可用令牌。因此,最好将上述代码重写如下:
Bucket bucket = Bucket.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(10, ofSeconds(1)).id("technical-limit"))
.addLimit(limit -> limit.capacity(10000).refillGreedy(10000, ofHours(1)).id("business-limit"))
.build();
...
BucketConfiguration newConfiguration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, ofSeconds(10)).id("technical-limit"))
.addLimit(limit -> limit.capacity(5000).refillGreedy(5000, ofHours(1)).id("business-limit"))
.build();
bucket.replaceConfiguration(newConfiguration, TokensInheritanceStrategy.PROPORTIONALLY);
带宽标识符有以下规则:
- 默认情况下带宽具有
<b>null</b>
标识符 - 如果标识符为null,当且仅当只有一个标识符为null的带宽时,一个null值等于另一个null值
- 如果指定了带宽的标识符,那么它在桶中必须是唯一的。Bucket不允许创建具有相同ID的多个带宽
TokensInheritanceStrategy解释
TokensInheritanceStrategy
指定在配置替换过程中可用令牌继承的规则。有四种策略:
- RESET: 使用此模式,当你只想忘记桶的先前状态。RESET只是指示擦除所有先前状态。使用这个策略相当于删除桶并用新配置创建。
- PROPORTIONALLY:
按照以下公式按比例复制可用令牌:
newAvailableTokens = availableTokensBeforeReplacement * (newBandwidthCapacity / capacityBeforeReplacement)
- AS_IS: 指示按原样复制可用令牌,但有一个例外:如果可用令牌大于新容量,可用令牌将减少到新容量。
- ADDITIVE: 指示按原样复制可用令牌,但有一个例外:如果新带宽容量大于旧容量,可用令牌将增加旧容量和新配置之间的差异。
生产检查清单
描述以下应用到基于令牌桶或漏桶算法的每个解决方案的考虑因素。你需要理解、同意并配置以下几点:
小心长时段
当你计划使用基于令牌桶的任何解决方案来节流传入请求时,你需要密切关注节流时间窗口。 以下是一个危险配置的例子:
- 给定一个每用户每1小时限制10000个令牌的桶
- 恶意攻击者可能在很短的时间内发送9999个请求,例如在10秒内。这将对应每秒100个请求,可能会严重影响你的系统
- 一个熟练的攻击者可以每小时停止在9999个请求,然后重复,这将使这种攻击无法检测(因为限制不会被触发) 为了防止这种攻击,你应该指定多个限制:
Bucket bucket = Bucket.builder()
.addLimit(limit -> capacity(10_000).refillGreedy(10_000, ofHours(1)))
.addLimit(limit -> capacity(20).refillGreedy(20, ofSeconds(1)))
// 攻击者无法达到1000RPS并导致服务在短时间内崩溃
在桶中指定的限制数量不影响性能。
小心短时突发
令牌桶是一种高效的算法,具有低且固定的内存占用,与传入请求速率无关(它可以每秒处理数百万个请求),桶消耗不超过40字节(五个longs)。但高效的内存占用有自己的代价——带宽限制只有在长时段内才能得到满足。换句话说,你不能避免短时突发。 让我们描述一个局部突发的例子:
- 给定一个每分钟限制100个令牌的桶。我们从一个满桶开始,即有100个令牌
- 在T1,100个请求被发出,因此桶变空了
- 在T1+1分钟,桶再次充满,因为令牌被完全重新生成,我们可以立即消耗100个令牌
- 这意味着在T1和T1+1分钟之间,我们消耗了200个令牌。在长时段内,每分钟不会有超过100个请求,但如上所示,可以在此处以100个令牌每分钟的两倍速度突发 这些突发是令牌桶算法的固有特性,无法避免。如果短时突发不可接受,你有三种选择:
- 不使用Bucket4j或任何其他基于令牌桶算法的解决方案,因为令牌桶专为网络流量管理设备而设计,在这些设备中,短期流量尖峰是常见情况,试图完全避免尖峰与令牌桶的本质相矛盾
- 由于突发值总是等于容量,尝试减少容量和补充速度。例如,如果你有100tokens/60seconds的强要求,那么配置桶为capacity=50tokens refill=50tokens/60seconds。值得一提的是,这种方式导致以下缺点:
- 一次不允许消耗多个令牌大于容量,根据上面的例子,在容量减少之前,你可以在单个请求中消耗100个令牌,减少后你最多可以在单个请求中消耗50个令牌
- 减少补充速度导致长时段内的消耗不足,很明显,使用refill50tokens/60seconds,你每小时只能消耗3050个令牌,而不是之前refill减少前的6100个(as was prior refill reducing)
- 总结上述两个缺点,可以说你将通过消耗不足来消除过度消耗的风险
技术限制
为了提供最佳精度,Bucket4j尽可能多地使用整数算术,因此任何内部计算都受到Long.MAX_VALUE
的限制。库引入了几项限制,这些限制被描述如下,以确保计算永远不会超过限制。
最大补充速率
最大补充速率限制为每1纳秒1个令牌。以下是API使用将引发异常的示例:
Bandwidth.builder().capacity(100).refillGreedy(2, ofNanos(1));
Bandwidth.builder().capacity(10_000).refillGreedy(1_001, ofNanos(1_000));
Bandwidth.builder().capacity(1_000_000).refillGreedy(1_000_001, ofMillis(1));
补充周期限制
Bucket4j将时间间隔作为64位纳秒数处理。因此,可能的最大补充周期将是:
Duration.ofNanos(Long.MAX_VALUE);
任何尝试指定比上述限制更长时间段的尝试都将抛出异常。例如,以下代码将失败:
Bandwidth.builder(limit -> limit.capacity(...).refillGreedy(42, Duration.ofMinutes(153722867280912930)));
错误信息:
Exception in thread "main" java.lang.ArithmeticException: long overflow
at java.lang.Math.multiplyExact(Math.java:892)
at java.time.Duration.toNanos(Duration.java:1186)
...
分布式设施
分布式系统上下文中的生产检查清单
在集群场景中使用Bucket4j之前,你需要理解、同意并配置以下几点:
异常处理
在分布式系统中工作时,请求可能会跨越当前JVM的边界,导致网络通信。网络不可靠,不可能避免故障。因此,你应该接受这一现实,并准备好在与分布式桶交互时获取未经检查的异常。 处理(或忽略)此类异常是你的责任:
- 如果网格负责限流崩溃,你可能不想使业务事务失败。如果是这种情况,你可以简单记录异常并继续业务事务,不进行限流
- 如果你希望当负责限流的网格崩溃时业务事务失败,只需重新抛出或不捕获异常
备份配置
如果任何桶的状态应该在持有其状态的网格节点重启/崩溃后幸存,你需要以特定网格供应商的方式自行配置备份。例如,参见如何配置Apache Ignite的备份。
保留调整是你的责任
在处理基于用户或IP地址每个桶的多租户场景时,缓存中的桶数量将不断增长。这是因为每次检测到新密钥时都会创建一个新的桶。 为了避免耗尽集群的可用内存,你需要配置以下方面:
- 最大缓存大小(以字节为单位)
- 显然,由于内存异常,宁愿丢失桶数据也不愿使整个集群崩溃
- 过期策略
- Bucket4j提供了为大多数集成配置灵活的单个条目过期策略的方法(除Apache Ignite外)。你需要阅读Bucket4j文档,了解如何为你的特定后端配置过期策略
高可用性(HA)调整和测试是你的责任
Bucket4j不支持任何HA特殊设置,因为Bucket4j除了在缓存上调用EntryProcessors外,什么都不做。相反,Bucket4j依赖你配置缓存,以控制冗余和高可用性的参数。 多年使用分布式系统的经验教会作者高可用性并非免费。你需要测试并验证你的系统是否保持可用。这不能由这个或任何其他库提供。如果你没有为此做好计划,你的系统肯定会出现问题。
分布式设施与内存网格集成
JCache集成
Bucket4j支持任何与JCache API(JSR 107)规范兼容的网格解决方案。 注意:在使用JCache集群上的Bucket4j之前,请阅读分布式使用检查清单。 要使用JCache扩展,还需要添加以下依赖项: 对于Java 17:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-jcache</artifactId>
<version>8.14.0</version>
</dependency>
对于Java 11:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk11-jcache</artifactId>
<version>8.14.0</version>
</dependency>
JCache期望javax.cache.cache-api
作为提供的依赖项。不要忘记添加以下依赖项:
<dependency>
<groupId>javax.cache</groupId>
<artifactId>cache-api</artifactId>
<version>${jcache.version}</version>
</dependency>
示例1 - 限制基于IP地址访问HTTP服务器
假设你开发任何Servlet-based WEB应用程序,并希望基于IP限制访问。你希望对每个IP使用相同的限制 - 每分钟30次请求。 ServletFilter是检查限制的明显位置:
public class IpThrottlingFilter implements javax.servlet.Filter {
private static final BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(30).refillGreedy(30, ofMinutes(1)))
.build();
// 存储令牌桶的缓存
}
Bucket4j-Redis集成
Bucket4j提供了与Redis集成的多种方法,包括Lettuce、Redisson和Jedis。在这些方法中,Redisson是一种更现代且功能更丰富的Redis客户端,提供了与Redis交互的Java API。
Redisson集成
依赖项
要在项目中使用Bucket4j与Redisson集成,需要添加以下依赖项:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.0</version>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_redisson</artifactId>
<version>8.14.0</version>
</dependency>
通过RedissonBasedProxyManager实例化Bucket
要使用Redisson作为分布式缓存,需要创建一个RedissonClient
实例,然后使用RedissonBasedProxyManager
来管理Bucket。
以下是如何创建Bucket的示例:
// 创建Redisson客户端
RedissonClient redissonClient = Redisson.create();
RedissonClient redissonClient = Redisson.create(CONFIG);
// 或者从配置文件中创建
RedissonClient redissonClient = Redisson.create(RedissonConfiguration.fromYAML("redisson.yml"));
// 或者从配置对象中创建
RedissonClient redissonClient = Redisson.create(RedissonConfiguration redissonConfiguration);
// 使用Redisson创建代理管理器
RedissonBasedProxyManager proxyManager = new RedissonBasedProxyManager(redissonClient);
// 定义Bucket配置
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, ofMinutes(1)))
.build();
// 使用代理管理器创建Bucket
Bucket bucket = proxyManager.builder()
.build("myBucketKey", () -> configuration);
// 使用Bucket进行限流
if (bucket.tryConsume(1)) {
// 处理请求
} else {
// 拒绝请求
}
基于用户的动态Bucket配置
在实际应用中,你可能需要为不同的用户或不同的IP地址创建不同的Bucket配置。Bucket4j允许你动态地为每个密钥创建不同的Bucket配置。 以下是如何为每个用户创建不同Bucket配置的示例:
public class RateLimitService {
private final RedissonBasedProxyManager proxyManager;
private final UserRepository userRepository;
public RateLimitService(RedissonBasedProxyManager proxyManager, UserRepository userRepository) {
this.proxyManager = proxyManager;
this.userRepository = userRepository;
}
public void processRequest(String userId) {
// 获取用户的速率限制配置
User user = userRepository.findById(userId);
int limit = user.getLimit();
// 创建动态配置
Supplier<BucketConfiguration> configSupplier = () -> BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(limit).refillGreedy(limit, ofMinutes(1)))
.build();
// 使用代理管理器创建Bucket
Bucket bucket = proxyManager.builder()
.build(userId, configSupplier);
// 使用Bucket进行限流
if (bucket.tryConsume(1)) {
// 处理请求
} else {
// 拒绝请求
}
}
}
分布式设施高级主题
异步API
对于高并发的分布式场景,Bucket4j提供了异步API,这在进入分布式世界时非常重要,因为异步API允许在需要执行网络请求时避免阻塞应用程序线程。 以下是使用异步API的示例:
// 定义异步Bucket代理
RedissonBasedProxyManager proxyManager = new RedissonBasedProxyManager(redissonClient);
AsyncBucketProxy asyncBucket = proxyManager.asAsync().builder()
.build("myBucketKey", () -> BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, ofMinutes(1)))
.build());
// 异步消费
CompletableFuture<Boolean> future = asyncBucket.tryConsume(1);
future.whenComplete((result, throwable) -> {
if (result) {
// 处理请求
} else {
// 拒绝请求
}
});
隐式配置替换
Bucket4j支持隐式配置替换,这意味着你可以动态地更新Bucket的配置,而不需要显式地调用replaceConfiguration
方法。这对于需要动态调整限流策略的应用程序非常有用。
以下是如何配置隐式配置替换的示例:
// 配置代理管理器以启用隐式配置替换
RedissonBasedProxyManager proxyManager = new RedissonBasedProxyManager(redissonClient);
proxyManager.setImplicitConfigurationReplacement(true);
// 使用代理管理器创建Bucket
Bucket bucket = proxyManager.builder()
.withConfiguration(() -> BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, ofMinutes(1)))
.build())
.build("myBucketKey");
// 后期更新配置
BucketConfiguration newConfiguration = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(200).refillGreedy(200, ofMinutes(1)))
.build();
proxyManager.updateConfiguration("myBucketKey", () -> newConfiguration);
实现自定义数据库工作的框架
Bucket4j提供了一个框架,允许你快速构建与自己的持久化技术(如RDBMS或键值存储)的集成。这使得你可以根据特定需求自定义Bucket的存储和管理方式。 以下是如何实现自定义数据库集成的示例:
// 实现自定义代理管理器
public class CustomProxyManager implements ProxyManager<String> {
// 实现必要的方法
}
// 使用自定义代理管理器创建Bucket
CustomProxyManager customProxyManager = new CustomProxyManager();
Bucket bucket = customProxyManager.builder()
.build("myBucketKey", () -> BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, ofMinutes(1)))
.build());
结论
Bucket4j是一个功能强大的Java限流库,基于令牌桶算法实现。它提供了丰富的功能和灵活的配置选项,包括支持多种分布式后端如Redis。通过本教程,你已经了解了Bucket4j的核心概念、常用类以及不同策略的区别,并学习了如何使用Redisson实现分布式限流。 希望这篇教程能帮助你更好地理解和使用Bucket4j,在实际项目中实现高效的流量控制。