Goで始めるトークンバケット:基礎理論とgolang/x/time/rateの実装を読み解く
トークンバケットアルゴリズムの基本概念と、Goプロジェクトが提供するgolang.org/x/time/rateの実装を解説します。レート制限とスロットリングの実装方法から内部構造まで、実例を交えながら学びます。
はじめに
Web アプリケーションにおいて、レート制限は急激なトラフィックや特定クライアントによる過剰利用からバックエンドを保護する手段の1つです。とくに外部公開 API では、システムの処理能力や利用プランに応じて一定時間内のリクエスト数に上限を設けることで、過負荷の緩和や利用者間の公平性向上に役立ちます。 ただし、レート制限だけで DoS 攻撃や不正アクセスを完全に防げるわけではありません。実運用では、認証・認可、WAF、監視などの対策と組み合わせて利用します。
この記事では、Go言語でのレート制限とスロットリングの実装に焦点を当て、とくにトークンバケットアルゴリズムとその代表的な実装である golang.org/x/time/rate パッケージをコードリーディングします。
トークンバケットの基本、Allow・Reserve・Wait の使い分け、HTTP ミドルウェアへの適用、内部でトークン数を計算する仕組みを、学習した内容としてまとめます。
成果物
https://github.com/kntks/blog-code/tree/main/2026/08/go-rate-limiting-token-bucket
使用バージョン
| バージョン | |
|---|---|
| macOS | 26.5 |
| Go | 1.26.4 |
| k6 | 2.1.0 |
| golang.org/x/time | v0.15.0 |
レート制限とスロットリング
レート制限は、一定時間内に利用できるリクエスト数や処理量に上限を設ける仕組みです。 一方、スロットリングは、リクエストを待機させるなどして処理速度や負荷を平準化する制御です。 この記事では、上限超過時に拒否する制御をレート制限、遅延させる制御をスロットリングとして区別します。
| 用語 | 目的 | 上限を超えた場合 |
|---|---|---|
| レート制限 | 利用量を制限する | リクエストを拒否することが多い |
| スロットリング | 処理速度や負荷を平準化する | 遅延、待機、帯域縮小などが起きる |
ここではレート制限のメリットをいくつか挙げます。
- 公平な利用:特定ユーザーによるリソースの独占を抑制する
- コスト管理:外部 API や計算資源の利用量に上限を設ける
- セキュリティ対策の補助:総当たり攻撃や DoS、過剰な Web スクレイピングの試行回数に上限を設ける
- 過負荷時の処理拒否:許容量を超えた処理を早期に拒否してシステムの負荷を下げる
レート制限自体にはリクエストの優先順位を判断する機能はありません。重要な処理を優先するには、リクエストの分類や優先キューなどを別途設計する必要があります。
参考:
- API Throttling vs. API Rate Limiting - System Design - GeeksForGeeks
- Throttling vs. Rate Limiting in Distributed Systems - GeeksForGeeks
- Rate Limiting Strategies for Serverless Applications - AWS Blogs
- Design A Rate Limiter - ByteByteGo
- Ultimate guide to rate limiting - solo.io
トークンバケットアルゴリズムの基本
トークンバケットとは
トークンバケットは、レート制限とスロットリングのどちらにも利用できるアルゴリズムです。 一定のレートでトークンが補充される固定サイズの「バケット」を想定し、リクエスト処理にはそのトークンを消費します。 トークンがない場合、リクエストを拒否すればレート制限、利用可能になるまで待機させればスロットリングとして動作します。
トークンバケットの仕組みと利点
- 実装がシンプルで、理解しやすい
- バーストトラフィックに対応可能
フローチャート
参考:
golang.org/x/time/rate パッケージの概要
golang.org/x/time/rate は、Goプロジェクトが開発する golang.org/x/time モジュールに含まれるパッケージです。
標準ライブラリには含まれないため、利用時には別途モジュールの依存関係として追加します。
トークンバケットアルゴリズムを採用しており、API 呼び出しやリソースアクセスなどのレート制限とスロットリングに利用できます。
Allow / AllowN
Allowは、現在1トークンを利用できる場合にそのトークンを消費してtrueを返します。利用できない場合は待機せず、falseを返します。AllowN(t, n)は、時刻tにn個のトークンを利用できるか判定します。レート制限を超えた処理を即座に拒否またはスキップしたい場合に使用します。
この例では、毎秒1トークン補充、バースト上限5トークンのリミッターを作成し、200 ms ごとに計10回のリクエストを処理します。
func tokenBucket_Allow() { l := rate.NewLimiter(1.0, 5) start := time.Now() fmt.Println("リミッター設定: 毎秒1トークン補充、バースト上限5トークン") fmt.Println("開始時点でのトークン数:", l.Tokens())
for x := range 10 { allowed := l.Allow()
if allowed { fmt.Printf("リクエスト %d: 許可 (残りトークン: %.2f)\n", x+1, l.Tokens()) } else { fmt.Printf("リクエスト %d: 拒否 (残りトークン: %.2f)\n", x+1, l.Tokens()) }
time.Sleep(200 * time.Millisecond) fmt.Printf(" 待機後のトークン数: %.2f\n", l.Tokens()) }
fmt.Printf("経過時間: %s\n", time.Since(start).Round(time.Millisecond))}
// 複数トークンを一度に要求するAllowNの例func tokenBucket_AllowN() { l := rate.NewLimiter(5.0, 10) // 毎秒5トークン補充、バースト上限10トークン start := time.Now() fmt.Println("リミッター設定: 毎秒5トークン補充、バースト上限10トークン") fmt.Println("開始時点でのトークン数:", l.Tokens())
// 異なるトークン数でリクエスト tokensNeeded := []int{2, 3, 4, 5, 1, 2, 3}
for i, n := range tokensNeeded { allowed := l.AllowN(time.Now(), n)
if allowed { fmt.Printf("リクエスト %d: %dトークン要求 - 許可 (残りトークン: %.2f)\n", i+1, n, l.Tokens()) } else { fmt.Printf("リクエスト %d: %dトークン要求 - 拒否 (残りトークン: %.2f)\n", i+1, n, l.Tokens()) }
time.Sleep(300 * time.Millisecond) fmt.Printf(" 待機後のトークン数: %.2f\n", l.Tokens()) }
fmt.Printf("経過時間: %s\n", time.Since(start).Round(time.Millisecond))}出力結果:
NewLimiter で生成した直後のバケットは、バースト上限である5トークンで満たされています。また、この例ではリクエスト間に200 ms 待機するため、その間に約0.2トークンずつ利用可能量が増えます。最初の呼び出しから6回目までに約1秒が経過し、累計で約1トークン分が補われるため、6回目まで許可されます。
トークンが1秒ごとにまとめて追加されるわけではなく、実際にはメソッドを呼び出した時点の経過時間から連続的に計算されます。
実行結果を表示する
$ go run example/allow/main.goAllowの例リミッター設定: 毎秒1トークン補充、バースト上限5トークン開始時点でのトークン数: 5リクエスト 1: 許可 (残りトークン: 4.00) 待機後のトークン数: 4.20リクエスト 2: 許可 (残りトークン: 3.20) 待機後のトークン数: 3.40リクエスト 3: 許可 (残りトークン: 2.40) 待機後のトークン数: 2.60リクエスト 4: 許可 (残りトークン: 1.60) 待機後のトークン数: 1.80リクエスト 5: 許可 (残りトークン: 0.80) 待機後のトークン数: 1.01リクエスト 6: 許可 (残りトークン: 0.01) 待機後のトークン数: 0.21リクエスト 7: 拒否 (残りトークン: 0.21) 待機後のトークン数: 0.41リクエスト 8: 拒否 (残りトークン: 0.41) 待機後のトークン数: 0.61リクエスト 9: 拒否 (残りトークン: 0.61) 待機後のトークン数: 0.81リクエスト 10: 拒否 (残りトークン: 0.81) 待機後のトークン数: 1.01経過時間: 2.011s===================================AllowNの例リミッター設定: 毎秒5トークン補充、バースト上限10トークン開始時点でのトークン数: 10リクエスト 1: 2トークン要求 - 許可 (残りトークン: 8.00) 待機後のトークン数: 9.51リクエスト 2: 3トークン要求 - 許可 (残りトークン: 6.51) 待機後のトークン数: 8.01リクエスト 3: 4トークン要求 - 許可 (残りトークン: 4.01) 待機後のトークン数: 5.52リクエスト 4: 5トークン要求 - 許可 (残りトークン: 0.52) 待機後のトークン数: 2.02リクエスト 5: 1トークン要求 - 許可 (残りトークン: 1.02) 待機後のトークン数: 2.52リクエスト 6: 2トークン要求 - 許可 (残りトークン: 0.52) 待機後のトークン数: 2.03リクエスト 7: 3トークン要求 - 拒否 (残りトークン: 2.03) 待機後のトークン数: 3.53経過時間: 2.107s参考:
Reserve / ReserveN
Reserveは1トークン、ReserveN(t, n)はn個のトークンを予約し、実行可能になるまでの待ち時間を含むReservationを返します。これらのメソッド自体は待機しません。- 呼び出し側は
OK()で予約の成否を確認し、Delay()が示す時間だけ待ってから処理を実行します。予約した処理を実行しない場合は、可能な範囲でトークンを戻すためにCancel()を呼び出します。
Delay() に従って処理を遅延させる使い方は、スロットリングに該当します。
func tokenBucket_Reserve() { l := rate.NewLimiter(2.0, 5) fmt.Println("リミッター設定: 毎秒2トークン補充、バースト上限5トークン") fmt.Println("開始時点でのトークン数:", l.Tokens())
for x := range 10 { r := l.Reserve() delay := r.Delay()
if delay == 0 { fmt.Printf("リクエスト %d: 許可 (遅延: %v, 残りトークン: %.2f)\n", x, delay, l.Tokens()) } else { fmt.Printf("リクエスト %d: 遅延発生 (遅延: %v, 残りトークン: %.2f)\n", x, delay, l.Tokens()) // 実際には以下のようにしてdelayを待つことも可能 // time.Sleep(delay) } }}
func tokenBucket_ReserveN() { l := rate.NewLimiter(5.0, 10) // 毎秒5トークン補充、バースト上限10トークン fmt.Println("リミッター設定: 毎秒5トークン補充、バースト上限10トークン") fmt.Println("開始時点でのトークン数:", l.Tokens())
// 異なるトークン数でリクエスト tokensNeeded := []int{2, 3, 4, 5, 1, 2, 3}
for i, n := range tokensNeeded { r := l.ReserveN(time.Now(), n) delay := r.Delay()
if !r.OK() { fmt.Printf("リクエスト %d: %dトークン要求 - 予約不可 (残りトークン: %.2f)\n", i, n, l.Tokens()) } else if delay == 0 { fmt.Printf("リクエスト %d: %dトークン要求 - 即時実行可能 (残りトークン: %.2f)\n", i, n, l.Tokens()) } else { fmt.Printf("リクエスト %d: %dトークン要求 - %v後に実行可能 (残りトークン: %.2f)\n", i, n, delay, l.Tokens()) // 実際には以下のようにしてdelayを待つことも可能 // time.Sleep(delay) }
}}出力結果
実行結果を表示する
Reserveの例リミッター設定: 毎秒2トークン補充、バースト上限5トークン開始時点でのトークン数: 5リクエスト 0: 許可 (遅延: 0s, 残りトークン: 4.00)リクエスト 1: 許可 (遅延: 0s, 残りトークン: 3.00)リクエスト 2: 許可 (遅延: 0s, 残りトークン: 2.00)リクエスト 3: 許可 (遅延: 0s, 残りトークン: 1.00)リクエスト 4: 許可 (遅延: 0s, 残りトークン: 0.00)リクエスト 5: 遅延発生 (遅延: 500ms, 残りトークン: -1.00)リクエスト 6: 遅延発生 (遅延: 1s, 残りトークン: -2.00)リクエスト 7: 遅延発生 (遅延: 1.5s, 残りトークン: -3.00)リクエスト 8: 遅延発生 (遅延: 2s, 残りトークン: -4.00)リクエスト 9: 遅延発生 (遅延: 2.5s, 残りトークン: -5.00)===================================ReserveNの例リミッター設定: 毎秒5トークン補充、バースト上限10トークン開始時点でのトークン数: 10リクエスト 0: 2トークン要求 - 即時実行可能 (残りトークン: 8.00)リクエスト 1: 3トークン要求 - 即時実行可能 (残りトークン: 5.00)リクエスト 2: 4トークン要求 - 即時実行可能 (残りトークン: 1.00)リクエスト 3: 5トークン要求 - 800ms後に実行可能 (残りトークン: -4.00)リクエスト 4: 1トークン要求 - 1s後に実行可能 (残りトークン: -5.00)リクエスト 5: 2トークン要求 - 1.4s後に実行可能 (残りトークン: -7.00)リクエスト 6: 3トークン要求 - 2s後に実行可能 (残りトークン: -10.00)参考
Wait / WaitN
Waitは1トークン、WaitN(ctx, n)はn個のトークンが利用可能になるまで実際に待機し、利用可能になった時点でトークンを消費します。- 戻り値は待ち時間ではなく
errorです。コンテキストがキャンセルされた場合、期限内にトークンを取得できない場合、または要求数がバースト上限を超える場合はエラーを返します。
トークンが利用可能になるまで処理を待機させるため、Wait と WaitN はスロットリングを実装するメソッドです。
func tokenBucket_Wait() { l := rate.NewLimiter(2.0, 5) fmt.Println("リミッター設定: 毎秒2トークン補充、バースト上限5トークン") fmt.Println("開始時点でのトークン数:", l.Tokens())
ctx := context.Background()
for x := range 10 { err := l.Wait(ctx)
if err == nil { fmt.Printf("リクエスト %d: 許可 (残りトークン: %.2f)\n", x, l.Tokens()) } else { fmt.Printf("リクエスト %d: エラー %v (残りトークン: %.2f)\n", x, err, l.Tokens()) } }}
// 複数トークンを一度に要求するWaitNの例func tokenBucket_WaitN() { l := rate.NewLimiter(5.0, 10) // 毎秒5トークン補充、バースト上限10トークン fmt.Println("リミッター設定: 毎秒5トークン補充、バースト上限10トークン") fmt.Println("開始時点でのトークン数:", l.Tokens())
// 異なるトークン数でリクエスト tokensNeeded := []int{2, 3, 4, 5, 1, 2, 3}
for i, n := range tokensNeeded { fmt.Printf("リクエスト %d: %dトークン要求 (要求前のトークン: %.2f)\n", i, n, l.Tokens())
// 5秒のタイムアウトでコンテキストを作成 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// リクエスト開始時刻を記録 start := time.Now()
// WaitNを呼び出し、必要に応じて待機 err := l.WaitN(ctx, n)
// 経過時間を計算 elapsed := time.Since(start)
if err == nil { fmt.Printf("リクエスト %d: %dトークン要求 - 許可 (待機時間: %v, 残りトークン: %.2f)\n", i, n, elapsed, l.Tokens()) } else { fmt.Printf("リクエスト %d: %dトークン要求 - エラー: %v (待機時間: %v, 残りトークン: %.2f)\n", i, n, err, elapsed, l.Tokens()) }
cancel() // コンテキストをキャンセル }}出力結果
実行結果を表示する
$ go run example/wait/main.goWaitの例リミッター設定: 毎秒2トークン補充、バースト上限5トークン開始時点でのトークン数: 5リクエスト 0: 許可 (残りトークン: 4.00)リクエスト 1: 許可 (残りトークン: 3.00)リクエスト 2: 許可 (残りトークン: 2.00)リクエスト 3: 許可 (残りトークン: 1.00)リクエスト 4: 許可 (残りトークン: 0.00)リクエスト 5: 許可 (残りトークン: 0.00)リクエスト 6: 許可 (残りトークン: 0.00)リクエスト 7: 許可 (残りトークン: 0.00)リクエスト 8: 許可 (残りトークン: 0.00)リクエスト 9: 許可 (残りトークン: 0.00)===================================WaitNの例リミッター設定: 毎秒5トークン補充、バースト上限10トークン開始時点でのトークン数: 10リクエスト 0: 2トークン要求 (要求前のトークン: 10.00)リクエスト 0: 2トークン要求 - 許可 (待機時間: 5.084µs, 残りトークン: 8.00)リクエスト 1: 3トークン要求 (要求前のトークン: 8.00)リクエスト 1: 3トークン要求 - 許可 (待機時間: 834ns, 残りトークン: 5.00)リクエスト 2: 4トークン要求 (要求前のトークン: 5.00)リクエスト 2: 4トークン要求 - 許可 (待機時間: 791ns, 残りトークン: 1.00)リクエスト 3: 5トークン要求 (要求前のトークン: 1.00)リクエスト 3: 5トークン要求 - 許可 (待機時間: 800.944417ms, 残りトークン: 0.01)リクエスト 4: 1トークン要求 (要求前のトークン: 0.01)リクエスト 4: 1トークン要求 - 許可 (待機時間: 199.129917ms, 残りトークン: 0.00)リクエスト 5: 2トークン要求 (要求前のトークン: 0.00)リクエスト 5: 2トークン要求 - 許可 (待機時間: 400.661458ms, 残りトークン: 0.01)リクエスト 6: 3トークン要求 (要求前のトークン: 0.01)リクエスト 6: 3トークン要求 - 許可 (待機時間: 599.948708ms, 残りトークン: 0.01)参考:
各メソッドの使い分けをまとめると、以下のようになります。
| メソッド | 制御 | トークン不足時 | 主な戻り値 | 適した用途 |
|---|---|---|---|---|
Allow / AllowN | レート制限 | 即座に拒否 | bool | HTTP 429、処理のスキップ |
Reserve / ReserveN | スロットリング(呼び出し側で遅延) | 将来分を予約 | Reservation | 呼び出し側で実行時刻を制御 |
Wait / WaitN | スロットリング | 利用可能まで待機 | error | 待機可能なバッチやクライアント処理 |
内部実装
コードリーディングしてわかった golang.org/x/time/rate パッケージの内部実装を書きます。
このパッケージは、トークンの数を管理するバケットを「キュー」や「配列」で管理せず、時間に基づいて計算します。そのためトークンの補充プロセスはありません。
トークンを消費する場合は、現在の時刻と最後にトークンを計算した時刻を比較して、トークン数を計算します。
現在のトークンなどの状態は、以下のような構造体で管理されています。
type Limiter struct { mu sync.Mutex limit Limit burst int tokens float64 last time.Time lastEvent time.Time}Limiter はトークン数や更新時刻を可変状態として保持するため、sync.Mutex を使ってアクセスを排他制御しています。そのため、同じ Limiter を複数の goroutine から同時に利用できます。
last はトークン数を最後に更新した時刻、lastEvent は予約済みの処理を含む最新の実行予定時刻です。Reserve や Wait では将来のトークンも予約するため、tokens が一時的に負数になることがあります。負数はトークンの欠損ではなく、将来補充されるトークンがすでに予約されている状態を表します。
advance は、前回の更新から経過した時間を tokensFromDuration でトークン数に変換し、現在利用可能なトークン数を計算します。反対に、durationFromTokens は必要なトークンが補充されるまでの時間を計算します。reserveN はこれらの変換を利用して待ち時間を求め、tokens、last、lastEvent を更新します。
時間 t における利用可能なトークン = min(burst, last_tokens + rate * elapsed_time)func (lim *Limiter) advance(t time.Time) (newTokens float64) { last := lim.last if t.Before(last) { last = t }
// 経過時間に基づいてトークン数を計算 elapsed := t.Sub(last) delta := lim.limit.tokensFromDuration(elapsed) tokens := lim.tokens + delta if burst := float64(lim.burst); tokens > burst { tokens = burst } return tokens}
// durationFromTokens is a unit conversion function from the number of tokens to the duration// of time it takes to accumulate them at a rate of limit tokens per second.func (limit Limit) durationFromTokens(tokens float64) time.Duration { if limit <= 0 { return InfDuration }
duration := (tokens / float64(limit)) * float64(time.Second)
// Cap the duration to the maximum representable int64 value, to avoid overflow. if duration > float64(math.MaxInt64) { return InfDuration }
return time.Duration(duration)}参考:https://github.com/golang/time/blob/v0.15.0/rate/rate.go#L387-L418
REST APIサーバのレート制限
実際に golang.org/x/time/rate パッケージを使って、HTTPリクエストのレート制限を実装してみます。
実装
rate.NewLimiter を使って、トークンバケットのリミッターを作成し、HTTPリクエストを処理するミドルウェアを作成します。
今回の実装は、レートを1秒あたり1リクエスト、バースト上限を5リクエストに設定します。
var ( limiter = rate.NewLimiter(rate.Limit(1), 5) // ...)
func HTTPMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // ... now := time.Now() allowed := limiter.AllowN(now, 1)
// ...
if !allowed { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusTooManyRequests) w.Write([]byte(`{"status":"error","message":"レート制限を超えました。しばらくしてからもう一度お試しください。"}`)) return }
next.ServeHTTP(w, r) })}参考:https://www.alexedwards.net/blog/making-and-using-middleware
type Server struct{}
func (s *Server) registerEndpoints(mux *http.ServeMux) { // 省略 mux.HandleFunc("GET /api/items/{id}", func(w http.ResponseWriter, r *http.Request) { // 省略 }}
func (s *Server) start() { mux := http.NewServeMux() s.registerEndpoints(mux)
// トークンバケットレート制限の設定 handler := HTTPMiddleware(mux)
server := &http.Server{ Addr: ":8080", Handler: handler, }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop()
s.runServer(server, ctx)}k6を使ったテスト
k6 は、負荷テストやパフォーマンステストを行うためのツールです。 k6 を使って、トークンバケットのレート制限をテストするためのスクリプトを作成します。
今回のテストは、1秒あたり10リクエストを送信し、約10秒間実行します。
export const options = { // ...
scenarios: { constant_request_rate: { executor: 'constant-arrival-rate', rate: 10, // 1秒あたり10リクエスト timeUnit: '1s', // 時間単位 duration: '9.9s', // 約10秒間実行 preAllocatedVUs: 10, // 事前に割り当てるVU(Virtual User)の数 maxVUs: 20, // 最大VU数 gracefulStop: '1s', // シナリオ終了時に進行中の処理を待つ時間 }, },};
const successCount = new Counter('successCount');const rateLimitCount = new Counter('rateLimitCount');
export default function() { const id = Math.floor(Math.random() * 100) + 1; const url = `http://localhost:8080/api/items/${id}`;
const response = http.get(url);
if (response.status === 200) successCount.add(1); if (response.status === 429) rateLimitCount.add(1);
check(response, { 'status is 200 or 429': (r) => r.status === 200 || r.status === 429, });}
// 以下省略1秒あたり10リクエストの到着率で約10秒間実行します。以下の実行結果では、100回のイテレーションが完了しました。 ハイライトされた部分は、200と429エラーが発生したリクエストの数をそれぞれ示しています。
実行結果を表示する
$ k6 run example/simple/load-test.js ## ...
✓ status is 200 or 429
✓ rateLimitCount...: 86 8.685064/s ✓ successCount.....: 14 1.413848/s vus..............: 0 min=0 max=0
running (09.9s), 00/10 VUs, 100 complete and 0 interrupted iterations今回のテストを想像しやすくするために、リクエストの結果を表にまとめました。
行はリクエストの秒数、列はリクエストの数を表しています。今回の検証では、バケットサイズは5にしました。1行目はバケットが満たされている状態から、API サーバがバーストを超えるリクエストを受信したため5つ⭕️があります。バーストを超えるリクエストはすべて拒否されるため、xで表現しています。
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |
|---|---|---|---|---|---|---|---|---|---|---|
| 1秒 | ⭕️ | ⭕️ | ⭕️ | ⭕️ | ⭕️ | x | x | x | x | x |
| 2秒 | ⭕️ | x | x | x | x | x | x | x | x | x |
| 3秒 | ⭕️ | x | x | x | x | x | x | x | x | x |
| 4秒 | ⭕️ | x | x | x | x | x | x | x | x | x |
| 5秒 | ⭕️ | x | x | x | x | x | x | x | x | x |
| 6秒 | ⭕️ | x | x | x | x | x | x | x | x | x |
| 7秒 | ⭕️ | x | x | x | x | x | x | x | x | x |
| 8秒 | ⭕️ | x | x | x | x | x | x | x | x | x |
| 9秒 | ⭕️ | x | x | x | x | x | x | x | x | x |
| 10秒 | ⭕️ | x | x | x | x | x | x | x | x | x |
カスタマイズと拡張の方法
ユーザーごとにトークンバケットを作成する
次は、ユーザー単位のレート制限の動作を確認するため、検証用の実装を作成します。本番運用に必要な認証や複数インスタンス間の状態共有については対象外とします。
生成AIに実行してもらうと、以下のようなコードになりました。
今回はクライアントからのリクエストに X-User-ID というヘッダーをつけるようにします。
この例では動作確認を簡単にするため、クライアントが X-User-ID を直接指定します。クライアントが自由に変更できる識別子はレート制限の回避に利用できるため、実運用では認証済みユーザーの ID や、信頼できる API ゲートウェイが付与した識別子を使用してください。
ユーザーごとの limiter を保持し、管理するデータ構造が TokenBucketLimiter です。
(コード自体は一部抜粋してます。)
type userLimiter struct { limiter *rate.Limiter lastSeen time.Time}
// TokenBucketLimiter はユーザーごとに独立したレート制限を管理します。type TokenBucketLimiter struct { clients map[string]*userLimiter mu sync.Mutex rps float64 burst int cleanupInterval time.Duration stop chan struct{} done chan struct{} closeOnce sync.Once}
func (t *TokenBucketLimiter) HTTPMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.Header.Get("X-User-ID")) if userID == "" { // ... return }
now := time.Now()
// ユーザーごとの rate limit 取得し、判定する limiter := t.getUserLimiter(userID, now) allowed := limiter.AllowN(now, 1)
log.Printf("user=%s method=%s path=%s allowed=%t tokens=%.3f", userID, r.Method, r.URL.Path, allowed, limiter.TokensAt(now)) if !allowed { writeJSON(w, http.StatusTooManyRequests, map[string]string{ "status": "error", "message": "レート制限を超えました。しばらくしてからもう一度お試しください。", }) return }
next.ServeHTTP(w, r) })}k6を使ったテスト
今回のテストは、1秒あたり10リクエストを送信し、約6秒間実行します。
alice と bob が交互にリクエスト実行する単純なテストです。
export const options = { discardResponseBodies: true,
// ...
scenarios: { per_user_rate_limit: { executor: 'constant-arrival-rate', rate: 10, timeUnit: '1s', duration: '5.9s', // 約6秒間実行 preAllocatedVUs: 4, maxVUs: 8, gracefulStop: '1s', }, },};
const users = ['alice', 'bob'];// ...
export default function () { // 各ユーザーへ同程度のリクエストを送る。 const iteration = exec.scenario.iterationInTest; const userID = users[iteration % users.length]; const itemID = (iteration % 100) + 1; const response = http.get(`http://localhost:8080/api/items/${itemID}`, { headers: { 'X-User-ID': userID, }, });
const isSuccess = response.status === 200; const isRateLimited = response.status === 429; if (isSuccess) userMetrics[userID].success.add(1); if (isRateLimited) userMetrics[userID].rateLimited.add(1);
check(response, { [`${userID}: status is 200 or 429`]: (r) => r.status === 200 || r.status === 429, });}
// 以下省略1秒あたり10リクエストの到着率で約6秒間実行します。以下の実行結果では、60回のイテレーションが完了しました。 ハイライトされた部分は、200と429エラーが発生したリクエストの数をそれぞれ示しています。
実行結果を表示する
$ k6 run example/per-user/load-test.js # ...
✓ alice: status is 200 or 429 ✓ bob: status is 200 or 429
✓ alice_rate_limit_count...: 14 2.332736/s ✓ alice_success_count......: 16 2.665984/s ✓ bob_rate_limit_count.....: 14 2.332736/s ✓ bob_success_count........: 16 2.665984/s
running (6.0s), 0/4 VUs, 60 complete and 0 interrupted iterationsまとめ
トークンバケットは、一定の平均レートを維持しながら、バケットに蓄積されたトークンの範囲で一時的なバーストを許可できるアルゴリズムです。golang.org/x/time/rate はバックグラウンドでトークンを補充するのではなく、経過時間から現在のトークン数を計算します。
API は用途に応じて使い分けます。レート制限として超過した処理を即座に拒否する場合は Allow、スロットリングとして将来の実行時刻を呼び出し側で管理する場合は Reserve、トークンが利用可能になるまで処理を待機させる場合は Wait が適しています。
今回の HTTP ミドルウェアは、単一プロセス内でのレート制限を確認するための実装です。グローバルな Limiter は、そのプロセスが受信するすべてのリクエストで共有されます。複数のサーバーやレプリカを利用する構成では各プロセスが独立した状態を持つため、サービス全体で上限を統一するには、API ゲートウェイや共有ストアを利用する分散レートリミッターが必要です。
トークンバケットは、Amazon EC2 API のリクエストスロットリングや Envoy の Local rate limit filterなど、幅広いシステムで利用されています。 製品ドキュメントでは名称が厳密に使い分けられていない場合もあるため、この記事では上限超過時の動作によって両者を分類します。