محدود کننده نرخ سطل توکن (Redis & Java)

این مقاله در یوتیوب نیز موجود است!
را سطل توکن الگوریتم یک مکانیسم محدود کننده نرخ انعطاف پذیر و کارآمد است. این کار با پر کردن یک سطل با نشانهها با نرخ ثابت (مثلاً یک توکن در ثانیه) کار میکند. هر درخواست یک توکن مصرف می کند و اگر توکنی در دسترس نباشد، درخواست رد می شود. سطل دارای حداکثر ظرفیت است، بنابراین تا زمانی که انفجار از تعداد توکنهای موجود در سطل تجاوز نکند، میتواند از ترافیک عبور کند.
به دنبال یک الگوریتم محدود کننده نرخ متفاوت هستید؟ راهنمای ضروری را بررسی کنید.
شاخص
-
مقدمه
-
نحوه عملکرد محدود کننده نرخ سطل توکن
-
پیاده سازی با Redis و Java
-
تست با TestContainers و AssertJ
-
نتیجه گیری (Repo GitHub)
چگونه کار می کند
1. نرخ پر کردن مجدد توکن را تعریف کنید
نرخی را تنظیم کنید که توکن ها با آن به سطل اضافه شوند، مانند 1 توکن در ثانیه یا 10 توکن در دقیقه.
2. پیگیری مصرف توکن
برای هر درخواست ورودی، یک توکن از سطل کم کنید.
3. دوباره پر کردن توکن ها
به طور مداوم سطل را با نرخ تعریف شده، تا حداکثر ظرفیت آن، دوباره پر کنید، اطمینان حاصل کنید که توکن های استفاده نشده می توانند برای انفجارهای آینده جمع شوند.
4. بررسی محدودیت نرخ
قبل از پردازش درخواست، بررسی کنید که آیا توکن های کافی در سطل وجود دارد یا خیر. اگر سطل خالی است، درخواست را رد کنید تا زمانی که توکن ها دوباره پر شوند.
نحوه پیاده سازی آن با Redis و Java
برای محدود کننده نرخ سطل توکنRedis یک روش کارآمد برای ردیابی نشانه ها و پیاده سازی الگوریتم ارائه می دهد. در اینجا نحوه انجام آن آمده است:
1. تعداد رمز فعلی و آخرین زمان پر کردن را بازیابی کنید
ابتدا تعداد رمز فعلی و آخرین زمان پر کردن مجدد را بازیابی کنید:
GET rate_limit::count
GET rate_limit::lastRefill
اگر این کلیدها وجود نداشتند، تعداد توکن ها را به حداکثر ظرفیت سطل مقداردهی کنید و با استفاده از SET، زمان فعلی را به عنوان آخرین زمان پر کردن مجدد تنظیم کنید.
2. در صورت لزوم توکن ها را دوباره پر کنید و سطل را به روز کنید
پس از پردازش هر درخواست، تعداد توکنها و آخرین زمان پر کردن مجدد را بهروزرسانی کنید:
SET rate_limit::count
SET rate_limit::lastRefill
3. اجازه یا رد درخواست
اگر توکنها در دسترس هستند، به درخواست اجازه دهید و تعداد را با استفاده از:
DECR rate_limit::count
پیاده سازی آن با Jedis
جدی یک کتابخانه محبوب جاوا است که برای تعامل با **Redis ** استفاده می شود و ما از آن برای پیاده سازی محدود کننده نرخ خود استفاده خواهیم کرد زیرا یک API ساده و شهودی برای اجرای دستورات Redis از برنامه های JVM ارائه می دهد.
Jedis را به فایل Maven خود اضافه کنید:
آخرین نسخه را اینجا بررسی کنید.
redis.clients
jedis
5.2.0
ایجاد یک TokenBucketRateLimiter کلاس:
کلاس برگزار خواهد شد:
-
یک نمونه Jedis را بپذیرید.
-
حداکثر ظرفیت سطل توکن را تعریف کنید.
-
نرخ پر کردن مجدد توکن (توکن در ثانیه) را مشخص کنید.
package io.redis;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;
public class TokenBucketRateLimiter {
private final Jedis jedis;
private final int bucketCapacity; // Maximum tokens the bucket can hold
private final double refillRate; // Tokens refilled per second
public TokenBucketRateLimiter(Jedis jedis, int bucketCapacity, double refillRate) {
this.jedis = jedis;
this.bucketCapacity = bucketCapacity;
this.refillRate = refillRate;
}
}
درخواست ها را تایید کنید
وظیفه اصلی این محدود کننده نرخ این است که تعیین کند آیا یک مشتری توکن های کافی برای پردازش درخواست خود دارد یا خیر. اگر بله، درخواست مجاز است و توکن ها کسر می شوند. در غیر این صورت درخواست مسدود می شود.
مرحله 1: کلیدها را ایجاد کنید
ما تعداد توکن هر مشتری و آخرین زمان پر کردن مجدد را با استفاده از کلیدهای منحصر به فرد در Redis ذخیره می کنیم. کلیدها به شکل زیر خواهند بود:
public boolean isAllowed(String clientId) {
String keyCount = "rate_limit:" + clientId + ":count";
String keyLastRefill = "rate_limit:" + clientId + ":lastRefill";
}
به عنوان مثال، اگر شناسه مشتری user123 باشد، کلیدهای آنها rate_limit:user123:count و rate_limit:user123:lastRefill خواهد بود.
مرحله 2: واکشی وضعیت فعلی
ما از دستور GET Redis برای بازیابی تعداد توکن فعلی و آخرین زمان پر کردن استفاده می کنیم. اگر کلیدها وجود نداشته باشند، فرض می کنیم سطل پر است و آخرین زمان پر کردن مجدد، مهر زمانی فعلی است.
public boolean isAllowed(String clientId) {
String keyCount = "rate_limit:" + clientId + ":count";
String keyLastRefill = "rate_limit:" + clientId + ":lastRefill";
Transaction transaction = jedis.multi();
transaction.get(keyLastRefill);
transaction.get(keyCount);
var results = transaction.exec();
long currentTime = System.currentTimeMillis();
long lastRefillTime = results.get(0) != null ? Long.parseLong((String) results.get(0)) : currentTime;
int tokenCount = results.get(1) != null ? Integer.parseInt((String) results.get(1)) : bucketCapacity;
}
مرحله 3: توکن ها را دوباره پر کنید
بر اساس زمان سپری شده از آخرین پر کردن مجدد، محاسبه کنید که چه تعداد نشانه باید اضافه شود. اطمینان حاصل کنید که سطل از حداکثر ظرفیت خود تجاوز نمی کند.
long elapsedTimeMs = currentTime - lastRefillTime;
double elapsedTimeSecs = elapsedTimeMs / 1000.0;
int tokensToAdd = (int) (elapsedTimeSecs * refillRate);
tokenCount = Math.min(bucketCapacity, tokenCount + tokensToAdd);
مرحله 4: در دسترس بودن توکن را بررسی کنید
برای تعیین اینکه آیا درخواست مجاز است یا خیر، تعداد رمز فعلی را مقایسه کنید. اگر توکنها در دسترس هستند، یک توکن کسر کنید. در غیر این صورت، درخواست را مسدود کنید.
boolean isAllowed = tokenCount > 0;
if (isAllowed) {
tokenCount--;
}
مرحله 5: Redis را به روز کنید
ما تعداد توکن ها و آخرین زمان پر کردن مجدد را در Redis به روز می کنیم. از یک تراکنش برای اطمینان از به روز رسانی اتمی استفاده کنید:
Transaction transaction = jedis.multi();
transaction.set(keyLastRefill, String.valueOf(currentTime)); // Update last refill time
transaction.set(keyCount, String.valueOf(tokenCount)); // Update token count
transaction.exec();
پیاده سازی کامل
کد کامل کلاس FixedWindowRateLimiter در اینجا آمده است:
package io.redis;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;
public class TokenBucketRateLimiter {
private final Jedis jedis;
private final int bucketCapacity; // Maximum tokens the bucket can hold
private final double refillRate; // Tokens refilled per second
public TokenBucketRateLimiter(Jedis jedis, int bucketCapacity, double refillRate) {
this.jedis = jedis;
this.bucketCapacity = bucketCapacity;
this.refillRate = refillRate;
}
public boolean isAllowed(String clientId) {
String keyCount = "rate_limit:" + clientId + ":count";
String keyLastRefill = "rate_limit:" + clientId + ":lastRefill";
long currentTime = System.currentTimeMillis();
// Fetch current state
Transaction transaction = jedis.multi();
transaction.get(keyLastRefill);
transaction.get(keyCount);
var results = transaction.exec();
long lastRefillTime = results.get(0) != null ? Long.parseLong((String) results.get(0)) : currentTime;
int tokenCount = results.get(1) != null ? Integer.parseInt((String) results.get(1)) : bucketCapacity;
// Refill tokens
long elapsedTimeMs = currentTime - lastRefillTime;
double elapsedTimeSecs = elapsedTimeMs / 1000.0;
int tokensToAdd = (int) (elapsedTimeSecs * refillRate);
tokenCount = Math.min(bucketCapacity, tokenCount + tokensToAdd);
// Check if the request is allowed
boolean isAllowed = tokenCount > 0;
if (isAllowed) {
tokenCount--; // Consume one token
}
// Update Redis state
transaction = jedis.multi();
transaction.set(keyLastRefill, String.valueOf(currentTime));
transaction.set(keyCount, String.valueOf(tokenCount));
transaction.exec();
return isAllowed;
}
}
و ما آماده شروع آزمایش رفتار آن هستیم!
آزمایش محدود کننده نرخ ما
برای اطمینان از اینکه محدود کننده نرخ سطل توکن ما مطابق انتظار رفتار می کند، آزمایش هایی را برای سناریوهای مختلف می نویسیم. برای این کار از سه ابزار استفاده می کنیم:
-
Redis Test Containers: این کتابخانه یک ظرف Redis جدا شده را برای آزمایش می چرخاند. این بدان معنی است که ما در طول آزمایشات خود نیازی به تکیه بر سرور Redis خارجی نداریم. پس از انجام آزمایشات، ظرف متوقف می شود و هیچ داده ای باقی نمی ماند.
-
واحد 5: چارچوب آزمایشی اصلی ما، که به ما کمک میکند تستها را با روشهای چرخه حیات مانند @BeforeEach و @AfterEach تعریف و ساختار دهیم.
-
AssertJ: کتابخانه ای که اظهارات را خوانا و رسا می کند، مانند assertThat(result).isTrue().
بیایید با افزودن وابستگی های لازم به pom.xml خود شروع کنیم.
افزودن وابستگی ها
آنچه شما در فایل Maven pom.xml خود نیاز دارید در اینجا آمده است:
org.junit.jupiter
junit-jupiter-engine
5.10.0
test
com.redis
testcontainers-redis
2.2.2
test
org.assertj
assertj-core
3.11.1
test
هنگامی که این وابستگی ها را اضافه کردید، آماده شروع نوشتن کلاس آزمایشی خود هستید.
راه اندازی کلاس تست
اولین مرحله ایجاد یک کلاس آزمایشی با نام FixedWindowRateLimiterTest است. در داخل، ما سه جزء اصلی را تعریف خواهیم کرد:
-
ظرف تست ردیس: این یک نمونه Redis را در یک ظرف Docker راه اندازی می کند.
-
نمونه Jedis: برای ارسال دستورات به ظرف Redis متصل می شود.
-
محدود کننده نرخ: نمونه واقعی TokenBucketRateLimiter که در حال آزمایش هستیم.
در اینجا اسکلت کلاس آزمایشی ما به نظر می رسد:
public class TokenBucketRateLimiterTest {
private static RedisContainer redisContainer;
private Jedis jedis;
private TokenBucketRateLimiter rateLimiter;
آماده سازی محیط قبل از هر آزمون
قبل از اجرای هر آزمایشی، باید از تمیز بودن محیط Redis اطمینان حاصل کنیم. در اینجا کاری است که ما انجام خواهیم داد:
-
به Redis متصل شوید: از یک نمونه Jedis برای اتصال به ظرف Redis استفاده کنید.
-
Flush Data: داده های باقیمانده را در Redis پاک کنید تا از نتایج ثابت برای هر آزمایش اطمینان حاصل کنید.
ما این را در یک روش مشروح شده با @BeforeEach تنظیم می کنیم که قبل از هر مورد آزمایشی اجرا می شود.
@BeforeAll
static void startContainer() {
redisContainer = new RedisContainer("redis:latest");
redisContainer.withExposedPorts(6379).start();
}
@BeforeEach
void setup() {
jedis = new Jedis(redisContainer.getHost(), redisContainer.getFirstMappedPort());
jedis.flushAll();
}
FLUSHALL یک دستور واقعی Redis است که تمام کلیدهای تمام پایگاه داده های موجود را حذف می کند. اطلاعات بیشتر در مورد آن را در اسناد رسمی بخوانید.
تمیز کردن بعد از هر آزمایش
پس از هر تست، باید اتصال Jedis را ببندیم تا منابع آزاد شود. این تضمین می کند که هیچ اتصال طولانی مدت در آزمایش های بعدی تداخل نداشته باشد.
@AfterEach
void tearDown() {
jedis.close();
}
راه اندازی کامل
در اینجا نحوه ظاهر کلاس تست کامل با همه چیز در محل است:
public class TokenBucketRateLimiterTest {
private static RedisContainer redisContainer;
private Jedis jedis;
private TokenBucketRateLimiter rateLimiter;
@BeforeAll
static void startContainer() {
redisContainer = new RedisContainer("redis:latest");
redisContainer.withExposedPorts(6379).start();
}
@AfterAll
static void stopContainer() {
redisContainer.stop();
}
@BeforeEach
void setup() {
jedis = new Jedis(redisContainer.getHost(), redisContainer.getFirstMappedPort());
jedis.flushAll();
}
@AfterEach
void tearDown() {
jedis.close();
}
}
بررسی درخواستها در ظرفیت سطل
این تست تضمین میکند که محدودکننده نرخ درخواستها را در ظرفیت سطل تعریفشده اجازه میدهد.
ما آن را با a پیکربندی می کنیم ظرفیت از 5 توکن و الف نرخ پر کردن مجدد یک توکن در ثانیه، سپس با isAllowed (“مشتری-1”) تماس بگیرید 5 بار.
هر تماس باید درست باشد و تأیید کند که محدودکننده نرخ به درستی درخواستها را در ظرفیت ردیابی کرده و اجازه میدهد.
@Test
void shouldAllowRequestsWithinBucketCapacity() {
rateLimiter = new TokenBucketRateLimiter(jedis, 5, 1.0);
for (int i = 1; i <= 5; i++) {
assertThat(rateLimiter.isAllowed("client-1"))
.withFailMessage("Request %d should be allowed within bucket capacity", i)
.isTrue();
}
}
تأیید درخواست ها وقتی سطل خالی است رد می شود
این تست تضمین میکند که محدودکننده نرخ درخواستها را پس از خالی شدن سطل به درستی رد میکند.
پیکربندی شده با a ظرفیت از 5 توکن و الف نرخ پر کردن مجدد یک توکن در ثانیه، ما مجاز هستیم (“مشتری-1”) 5 بار و انتظار داریم همه به واقعیت برگردند.
در تماس ششم، باید false را برگرداند، با تأیید اینکه محدودکننده نرخ درخواستها را پس از خالی شدن سطل مسدود میکند.
@Test
void shouldDenyRequestsOnceBucketIsEmpty() {
rateLimiter = new TokenBucketRateLimiter(jedis, 5, 1.0);
for (int i = 1; i <= 5; i++) {
assertThat(rateLimiter.isAllowed("client-1"))
.withFailMessage("Request %d should be allowed within bucket capacity", i)
.isTrue();
}
assertThat(rateLimiter.isAllowed("client-1"))
.withFailMessage("Request beyond bucket capacity should be denied")
.isFalse();
}
سطل تأیید به تدریج دوباره پر می شود
این تست تضمین می کند که محدود کننده سرعت پس از هر ثانیه سطل را به درستی پر می کند.
پیکربندی شده با a ظرفیت از 5 توکن و الف نرخ پر کردن مجدد یک توکن در ثانیه، 5 درخواست اول (isAllowed (“مشتری-1”)) درست است، در حالی که درخواست 6 رد می شود (نادرست).
پس از دو ثانیه انتظار، دو درخواست بعدی مجاز و درخواست سوم رد می شود. تأیید رفتار پر کردن مجدد همانطور که انتظار می رود کار می کند.
@Test
void shouldRefillTokensGraduallyAndAllowRequestsOverTime() throws InterruptedException {
rateLimiter = new TokenBucketRateLimiter(jedis, 5, 1.0);
String clientId = "client-1";
for (int i = 1; i <= 5; i++) {
assertThat(rateLimiter.isAllowed(clientId))
.withFailMessage("Request %d should be allowed within bucket capacity", i)
.isTrue();
}
assertThat(rateLimiter.isAllowed(clientId))
.withFailMessage("Request beyond bucket capacity should be denied")
.isFalse();
TimeUnit.SECONDS.sleep(2);
assertThat(rateLimiter.isAllowed(clientId))
.withFailMessage("Request after partial refill should be allowed")
.isTrue();
assertThat(rateLimiter.isAllowed(clientId))
.withFailMessage("Second request after partial refill should be allowed")
.isTrue();
assertThat(rateLimiter.isAllowed(clientId))
.withFailMessage("Request beyond available tokens should be denied")
.isFalse();
}
تأیید مدیریت مستقل چندین مشتری
این تست تضمین می کند که محدود کننده نرخ چندین مشتری را به طور مستقل مدیریت می کند.
پیکربندی شده با a ظرفیت از 5 توکن و الف نرخ پر کردن مجدد یک توکن در ثانیه، 5 درخواست اول (isAllowed (“مشتری-1”)) درست است، در حالی که درخواست 6 رد می شود (نادرست).
به طور همزمان، هر 5 درخواست از مشتری-2 مجاز هستند (درست)، تأیید می کند که محدود کننده نرخ شمارنده های جداگانه ای را برای هر مشتری نگه می دارد.
@Test
void shouldHandleMultipleClientsIndependently() {
rateLimiter = new TokenBucketRateLimiter(jedis, 5, 1.0);
String clientId1 = "client-1";
String clientId2 = "client-2";
for (int i = 1; i <= 5; i++) {
assertThat(rateLimiter.isAllowed(clientId1))
.withFailMessage("Client 1 request %d should be allowed", i)
.isTrue();
}
assertThat(rateLimiter.isAllowed(clientId1))
.withFailMessage("Client 1 request beyond bucket capacity should be denied")
.isFalse();
for (int i = 1; i <= 5; i++) {
assertThat(rateLimiter.isAllowed(clientId2))
.withFailMessage("Client 2 request %d should be allowed", i)
.isTrue();
}
}
تأیید دوباره پر کردن توکن از ظرفیت سطل تجاوز نمی کند
این تست تأیید میکند که محدودکننده نرخ سطل توکن به درستی توکنها را تا ظرفیت تعریفشده بدون تجاوز از آن پر میکند.
پیکربندی شده با a ظرفیت 3 توکن و الف نرخ شارژ مجدد 2 توکن در ثانیه، 3 درخواست اول (isAllowed (“مشتری-1”)) درست است، در حالی که درخواست چهارم رد می شود (نادرست) که نشان می دهد سطل خالی است.
پس از 3 ثانیه انتظار (برای پر کردن 6 ژتون کافی است)، سطل فقط تا حداکثر ظرفیت 3 ژتون پر می شود. 3 درخواست بعدی مجاز است (درست است)، اما هر درخواست اضافی رد می شود (نادرست)، تأیید می کند که محدود کننده نرخ، محدودیت ظرفیت مشخص شده را بدون توجه به مازاد پر کردن مجدد حفظ می کند.
@Test
void shouldRefillTokensUpToCapacityWithoutExceedingIt() throws InterruptedException {
int capacity = 3;
double refillRate = 2.0;
String clientId = "client-1";
rateLimiter = new TokenBucketRateLimiter(jedis, capacity, refillRate);
for (int i = 1; i <= capacity; i++) {
assertThat(rateLimiter.isAllowed(clientId))
.withFailMessage("Request %d should be allowed within initial bucket capacity", i)
.isTrue();
}
assertThat(rateLimiter.isAllowed(clientId))
.withFailMessage("Request beyond bucket capacity should be denied")
.isFalse();
TimeUnit.SECONDS.sleep(3);
for (int i = 1; i <= capacity; i++) {
assertThat(rateLimiter.isAllowed(clientId))
.withFailMessage("Request %d should be allowed as bucket refills up to capacity", i)
.isTrue();
}
assertThat(rateLimiter.isAllowed(clientId))
.withFailMessage("Request beyond bucket capacity should be denied")
.isFalse();
}
تأیید درخواست های رد شده بر تعداد توکن ها تأثیر نمی گذارد
این تست تضمین میکند که محدودکننده نرخ سطل توکن درخواستهای رد شده را هنگام بهروزرسانی تعداد توکنها محاسبه نمیکند.
پیکربندی شده با a ظرفیت 3 توکن و الف نرخ شارژ مجدد 0.5 توکن در ثانیه، 3 درخواست اول (isAllowed (“مشتری-1”)) مجاز هستند (درست است) که سطل را خالی می کند. درخواست چهارم رد می شود (نادرست)، با تایید خالی بودن سطل.
سپس تعداد توکنهای Redis (rate_limit:client-1:count) تأیید میشود تا اطمینان حاصل شود که نشانههای باقیمانده را به طور دقیق منعکس میکند (0 در این مورد) و شامل درخواستهای رد شده نمیشود. این تأیید می کند که محدود کننده نرخ تنها زمانی تعداد توکن ها را به روز می کند که درخواست ها با موفقیت پردازش شوند.
@Test
void testRateLimitDeniedRequestsAreNotCounted() {
int capacity = 3;
double refillRate = 0.5;
String clientId = "client-1";
rateLimiter = new TokenBucketRateLimiter(jedis, capacity, refillRate);
for (int i = 1; i <= capacity; i++) {
assertThat(rateLimiter.isAllowed(clientId))
.withFailMessage("Request %d should be allowed", i)
.isTrue();
}
assertThat(rateLimiter.isAllowed(clientId))
.withFailMessage("This request should be denied")
.isFalse();
String key = "rate_limit:" + clientId + ":count";
int requestCount = Integer.parseInt(jedis.get(key));
assertThat(requestCount)
.withFailMessage("The count should match remaining tokens and not include denied requests")
.isEqualTo(0);
}
آیا رفتار دیگری وجود دارد که باید بررسی کنیم؟ در نظرات به من اطلاع دهید!
Token Bucket Rate Limiter یک روش منعطف و کارآمد برای مدیریت نرخ درخواست است ردیس آن را فوق العاده سریع و قابل اعتماد می کند.
با استفاده از دستوراتی مانند GET، SET و MULTI/EXEC، راه حلی را پیاده سازی کردیم که تعداد توکن ها را ردیابی می کند، توکن ها را به صورت پویا بر اساس زمان سپری شده پر می کند و تضمین می کند که سطل هرگز از ظرفیت تعریف شده خود تجاوز نمی کند.
با استفاده از جدی، ما یک روشن و شهودی ساخته ایم جاوا پیاده سازی، و با آزمایش کامل با استفاده از Redis TestContainers، JUnit 5 و AssertJ، می توانیم با اطمینان بررسی کنیم که مطابق انتظار کار می کند.
این رویکرد پایهای قوی برای مدیریت محدودیتهای درخواست ارائه میدهد و در عین حال امکان مدیریت انفجاری و دوباره پر کردن تدریجی را فراهم میکند و در صورت نیاز برای سناریوهای محدودکننده نرخ پیشرفتهتر سازگار است.
GitHub Repo
شما می توانید این پیاده سازی را در جاوا و کاتلین:
کنجکاو بمان!