Redis
 Computer >> コンピューター >  >> プログラミング >> Redis

Bucket4JとRedisでスケーラブルなレート制限を実装する方法 – ステップバイステップガイド

このチュートリアルでは、スケールされたサービスにおけるレート制限(Rate Limiting)の実装方法を学びます。実装にはBucket4Jライブラリを使用し、分散キャッシュとしてRedisを活用します。

なぜレート制限が必要なのか?

まずは基本をおさらいし、レート制限がなぜ必要なのかを理解した上で、このチュートリアルで使用するツールを紹介します。

リクエストが無制限だと何が起きるか

Twitter APIのような公開APIで、ユーザーが1時間あたり無制限にリクエストを送れるようにすると、次のような問題が発生する可能性があります。

  • リソースの枯渇
  • サービス品質の低下
  • DoS攻撃(サービス拒否攻撃)への脆弱性

その結果、サービスが利用できなくなったり、極端に遅くなったりする状況を招きかねません。また、予期しないコスト増大につながることもあります。

レート制限がもたらすメリット

1つ目は、DoS攻撃の防止です。重複排除の仕組みやAPIキーと組み合わせれば、DDoS攻撃(分散型サービス拒否攻撃)の対策にも役立ちます。

2つ目は、トラフィックの見積もりが可能になることです。これは公開APIにとって非常に重要であり、自動化スクリプトと組み合わせてサービスの監視やスケーリングにも活用できます。

3つ目は、段階的な料金プラン(ティア制価格)の実装です。このモデルでは、より高いリクエストレートを求めるユーザーが追加料金を支払うことができます。Twitter APIはその代表例です。

トークンバケットアルゴリズムとは

トークンバケット(Token Bucket)は、レート制限を実装するためのアルゴリズムです。その仕組みは簡単に言うと以下の通りです。

  1. 一定の容量(トークン数)を持つバケットを作成します。
  2. リクエストが到着するとバケットを確認し、十分な容量があればリクエストを許可し、なければ拒否します。
  3. リクエストが許可されると、容量が減少します。
  4. 一定時間が経過すると、容量が補充されます。

分散システムでトークンバケットを実装するには

分散システムでトークンバケットアルゴリズムを実装するには、分散キャッシュを使用する必要があります。

キャッシュは、バケット情報を保存するためのキーバリューストアとして機能します。ここではRedisキャッシュを採用します。

内部的には、Bucket4jはJavaのJCache API実装であればどれでもプラグインできる設計になっています。本記事ではRedisのクライアントであるRedissonを使用します。

プロジェクトの実装

サービスの構築にはSpring Bootフレームワークを使用します。今回のサービスは以下のコンポーネントで構成されます。

  1. シンプルなREST API
  2. Redissonクライアント経由で接続されたRedisキャッシュ
  3. REST APIをラップするBucket4Jライブラリ
  4. JCacheインターフェースに接続されたBucket4J(バックエンドではRedissonクライアントが実装として動作)

まずすべてのリクエストに対してレート制限をかける方法を学び、その後、ユーザーごと・料金プランごとのより高度なレート制限の実装方法を解説します。

それでは、プロジェクトのセットアップから始めましょう。

依存関係の追加

pom.xml(またはbuild.gradle)に以下の依存関係を追加します。

<dependencies>
 <!-- REST API構築用 -->
 <dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-web</artifactId>
 </dependency>
 <!-- Redisson Starter = Spring Data Redisスターター(他クライアント除く) + Redissonクライアント -->
 <dependency>
 <groupId>org.redisson</groupId>
 <artifactId>redisson-spring-boot-starter</artifactId>
 <version>3.17.0</version>
 </dependency>
 <!-- Bucket4J Starter = Bucket4J + JCache -->
 <dependency>
 <groupId>com.giffing.bucket4j.spring.boot.starter</groupId>
 <artifactId>bucket4j-spring-boot-starter</artifactId>
 <version>0.5.2</version>
 </dependency>
</dependencies>

キャッシュの設定

まず、Redisサーバーを起動しておく必要があります。ここではローカルマシンのポート6379でRedisサーバーが稼働しているものとします。

必要な作業は次の2ステップです。

  1. アプリケーションからサーバーへの接続を作成する
  2. JCacheがRedissonクライアントを実装として使うよう設定する

Redissonの公式ドキュメントでは、通常のJavaアプリケーション向けの手順が簡潔に示されています。今回は同じ手順をSpring Bootで実装します。

それではコードを見てみましょう。必要なBeanを作成するためのConfigurationクラスを定義します。

@Configuration
public class RedisConfig {
 @Bean
 public Config config() {
 Config config = new Config();
 config.useSingleServer().setAddress("redis://localhost:6379");
 return config;
 }
 @Bean
 public CacheManager cacheManager(Config config) {
 CacheManager manager = Caching.getCachingProvider().getCacheManager();
 cacheManager.createCache("cache", RedissonConfiguration.fromConfig(config));
 return cacheManager;
 }
 @Bean
 ProxyManager<String> proxyManager(CacheManager cacheManager) {
 return new JCacheProxyManager<>(cacheManager.getCache("cache"));
 }
}

このコードは何をしているのでしょうか?

  1. 接続の作成に使える設定オブジェクトを生成します。
  2. 設定オブジェクトをもとにキャッシュマネージャーを作成します。これにより内部でRedisインスタンスへの接続が確立され、「cache」という名前のハッシュが作成されます。
  3. キャッシュへアクセスするためのプロキシマネージャーを作成します。アプリケーションがJCache API経由でキャッシュしようとしたデータは、すべてRedisインスタンス上の「cache」ハッシュ内に保存されます。

APIの構築

シンプルなREST APIを作成しましょう。

@RestController
public class RateLimitController {
 @GetMapping("/user/{id}")
 public String getInfo(@PathVariable("id") String id) {
 return "Hello " + id;
 }
}

https://localhost:8080/user/1というURLでAPIにアクセスすると、Hello 1というレスポンスが返ってきます。

Bucket4Jの設定

レート制限を実装するには、Bucket4Jの設定が必要です。ありがたいことに、スターターライブラリのおかげでボイラープレートコードを書く必要はありません。

さらに、前のステップで作成したProxyManager Beanを自動的に検出し、バケットのキャッシュに利用してくれます。

私たちがやるべきことは、作成したAPIに対してこのライブラリを設定することだけです。
設定方法もいくつかあります。

スターターライブラリで定義されているプロパティベースの設定を利用できます。全ユーザーやゲストユーザー一律でレート制限をかけるようなシンプルなケースでは、これが最も手軽な方法です。

しかし、ユーザーごとのレート制限など、より複雑な要件を実現したい場合は、カスタムコードを書く方が適しています。

ここではユーザーごとのレート制限を実装します。各ユーザーのレート制限値はデータベースに保存されており、ユーザーIDで照会できるものと仮定します。

それでは、コードを段階的に書いていきましょう。

バケットの作成

始める前に、バケットの作成方法を確認しておきます。

Refill refill = Refill.intervally(10, Duration.ofMinutes(1));
Bandwidth limit = Bandwidth.classic(10, refill);
Bucket bucket = Bucket4j.builder()
 .addLimit(limit)
 .build();
  • Refill – バケットが補充されるまでの時間間隔。
  • Bandwidth – バケットが持つ帯域幅。要するに、補充期間あたりのリクエスト数。
  • Bucket – 上記2つのパラメータで構成されるオブジェクト。さらに、バケット内の利用可能なトークン数を追跡するトークンカウンターを保持します。

これを基本ブロックとして、今回のユースケースに合わせていくつか変更を加えていきます。

ProxyManagerを使ったバケットの作成とキャッシュ

ProxyManagerは、バケットをRedis上に保存するために作成しました。一度作成されたバケットはRedisにキャッシュされ、再作成する必要はありません。

これを実現するために、Bucket4j.builder()proxyManager.builder()に置き換えます。ProxyManagerがバケットのキャッシュを管理し、既存のバケットがあれば再作成せずに使い回してくれます。

ProxyManagerのビルダーは2つの引数を受け取ります。バケットをキャッシュする際のキーと、バケット生成に使用する設定オブジェクトです。

実装例を見てみましょう。

@Service
public class RateLimiter {
 // 依存関係の注入
 public Bucket resolveBucket(String key) {
 Supplier<BucketConfiguration> configSupplier = getConfigSupplierForUser(key);
 // 常に新しいバケットを作るのではなく、既存のバケットがあればそれを返す
 return buckets.builder().build(key, configSupplier);
 }
 private Supplier<BucketConfiguration> getConfigSupplierForUser(String key) {
 User user = userRepository.findById(userId);
 Refill refill = Refill.intervally(user.getLimit(), Duration.ofMinutes(1));
 Bandwidth limit = Bandwidth.classic(user.getLimit(), refill);
 return () -> (BucketConfiguration.builder()
 .addLimit(limit)
 .build());
 }
}

指定されたキーに対応するバケットを返すメソッドを作成しました。次のステップで、この使い方を見ていきます。

トークンの消費とレート制限の設定方法

リクエストが到着したら、該当するバケットからトークンを1つ消費しようとします。
これにはバケットのtryConsume()メソッドを使用します。

@GetMapping("/user/{id}")
public String getInfo(@PathVariable("id") String id) {
 // ユーザーに対応するバケットを取得
 Bucket bucket = rateLimiter.resolveBucket(id);
 // バケットからトークンを1つ消費を試みる
 if (bucket.tryConsume(1)) {
 return "Hello " + id;
 } else {
 return "Rate limit exceeded";
 }
}

tryConsume()メソッドは、トークンの消費に成功すればtrueを、失敗すればfalseを返します。

サービスのテスト方法

テストは任意の自動テスト手法で行えます。例えばJUnitを使いましょう。getInfo()メソッドを複数回呼び出し、レスポンスが正しいかどうかを検証するテストケースを書きます。

ここでは、IDが1で毎分10リクエストの制限を持つユーザーと、IDが2で毎分20リクエストの制限を持つユーザーがいるものとします。

両方のユーザーに対して11回リクエストを送り、ID 1のユーザーは失敗し、ID 2のユーザーは成功することを検証します。

@Test
public void testGetInfo() {
 // ユーザー1とユーザー2についてそれぞれ10回メソッドを呼び出す
 for (int i = 0; i < 10; i++) {
 rateLimiter.getInfo(1));
 rateLimiter.getInfo(2));
 }
 // ユーザー1がレート制限されていることを検証
 assertEquals("Rate limit exceeded", rateLimiter.getInfo(1));
 // ユーザー2が成功することを検証
 assertEquals("Hello 2", rateLimiter.getInfo(2));
}

テストを実行すると、テストが成功することが確認できます。

まとめ

このチュートリアルでは、Spring BootアプリケーションでBucket4jとRedisを使ってレートリミッターを作成する方法を解説しました。RedissonクライアントとJCacheのセットアップ方法、そしてそれを使ってバケットをキャッシュする方法についても見てきました。

最後に、特定のユーザーのリクエストに対してレート制限をかけられるシンプルなレートリミッターを実装しました。

このチュートリアルがお役に立てば幸いです。最後までお読みいただきありがとうございました!


  1. パフォーマンスの向上とコスト削減:Upstash RedisでOpenAI APIのレスポンスをキャッシュする方法

    OpenAI APIを使ったことがある方なら、その応答がかなり遅く、場合によってはまったく応答しないことにお気づきかもしれません。特にGPT-4モデルはレイテンシ(応答遅延)が高くなりがちです。さらに、取得した回答ごとに課金されます。これらは、APIのレスポンスをそのままユーザーに提供すべきではない理由です。 こうした問題を回避するには、Upstash Redisにレスポンスを保存するのが有効です。同じレスポンスを多数のクライアントに配信する場合、かなりのコストを節約でき、グローバルに展開されたデータベースにより、ユーザーは可能な限り迅速に情報を取得できます。 本記事では、OpenAI API

  2. QStashスケジューラでWhatsAppリマインダーを自動化する方法

    QStashは、サーバーレス環境向けに設計されたタスクスケジューラ兼メッセージキューのシステムです。状態管理を意識することなく、特定の間隔で1回だけ、あるいは繰り返し実行される自動化ワークフローを構築できるため、開発者にとって非常に心強いツールとなっています。 まずはQStashのコンソールを実際に触ってみて、リクエストの組み立て方を感覚的に掴んでみると良いでしょう。 ユースケース 私が開発しているプロダクト「GPT Hotline」は、いわばWhatsApp上で動くChatGPTです(同じ基盤モデルを使用しています)。ChatGPTアプリを使うときと同じ感覚で、専用の番号宛てにボットへメッ