Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Goのrate.Limiterで、429との戦いを終わらせる

 Goのrate.Limiterで、429との戦いを終わらせる

2026年8月27日 Go Connect #16 での発表資料です。

Go Connect #16
https://gotalk.connpass.com/event/398015/

並列度を上げれば429、下げれば終わらない。
外部APIのレート制限との調整に、何日も溶かしたことがあります。

原因は「並列数とレートを混同していたこと」でした。
golang.org/x/time/rate と errgroup で、この2つを
別々の部品として書けるようになった話をします。

・同時実行数を絞っても1秒間に40件到達してしまう理由
・rate.Limiter と errgroup.SetLimit で関心を分ける
・errgroup から素直にエラーを返すと全体が止まる
・複数プロセスで動かすときは、設定値を並列数で割る

コード: https://github.com/shinchit/go-lambda-ratelimit
元記事: https://zenn.dev/calmpm_takahiro/articles/go-lambda-ratelimit

Avatar for SHINCHI Takahiro

SHINCHI Takahiro

August 27, 2026

More Decks by SHINCHI Takahiro

Other Decks in Technology

Transcript

  1. Go Connect #16 — 2026.08.27 Go の rate.Limiter で、 429

    との戦いを終わらせる 並列数を絞っても 429 は止まらない。「 1 秒あたり N 件」をそのまま書く SHINCHI Takahiro
  2. 自己紹介 • ふだんは技術選定・設計、 IT コンサルティングに携わる • 以前、外部 API のレート制限に何日も溶かした •

    Go で書き直したら楽になったので、その話をします SHINCHI Takahiro 元記事 (Zenn) Go の rate.Limiter で、 429 との戦いを終わらせる コード (GitHub) 1
  3. こんな戦い、したことありませんか ? 並列度を上げれば 429 、下げれば 終わらない ! Lambda から外部 API

    を大量に呼ぶ。相手にはコールレート制限がある ! 同時実行数を 5 に、いや 3 に…とつまみを回して当たりを探す ! その調整に何日も溶かした Go の rate.Limiter で、 429 との戦いを終わらせる 2
  4. 先に結果 「 1 秒 2 件まで、超えたら 429 」のモック API に、

    40 件を並列度 10 で投げる。変えるのは送出の間隔だけ レートを抑えない レートを抑える( 1.5 req/s ) 成功 429 成功 429 9 31 40 0 所要時間 所要時間 265 ms 26,019 ms 速い。ただし 8 割が弾かれる 26 秒かかるが 1 件も落ちない Go の rate.Limiter で、 429 との戦いを終わらせる 3
  5. 「並列数」と「レート」は別物 並列数 = 同時に走る数 ( 縦 ) 、レート = 1

    秒あたりの送出数 ( 横 ) 。並列数はどちらも 4 、違うのは送出の間隔だけ 16 レートを抑えない 並列数 4 1 件あたり 250ms 件 / 秒 ←2秒→ 1.5 rate.Limiter あり 並列数 4 1.5 req/s 件 / 秒 ←2秒→ 並列数は絞っていない。絞ったのはレート ( 送出の間隔 ) だけ Go の rate.Limiter で、 429 との戦いを終わらせる 4
  6. 以前はこう戦っていた どちらも当時は妥当。ただしレート (1 秒あたりの送出数 ) そのものは決めていない 12 ① SQS +

    同時実行数 同時実行数 3 1 件あたり 250ms 件 / 秒 ←1秒→ 並列数を 4 → 3 に絞っても 12 件 / 秒。形もほとんど変わらず、レートは下がらない 遅れた 3 件と、予定どおりの 3 件が重なる ↓ ② 固定ディレイ 6 遅延 → 3 件ずつ 0.5 秒おき 点線 = 本来の予定 件 / 秒 ←1秒→ レートは下がった。でも 1 つ目が遅れただけで、瞬間的に 6 件が飛ぶ Go の rate.Limiter で、 429 との戦いを終わらせる 5
  7. Go なら、 2 つを別々の道具で書ける レート 並列数 golang.org/x/time/rate golang.org/x/sync/errgroup limiter.Wait(ctx) g.SetLimit(n)

    1 秒あたりの送出数を決める。 同時に走る goroutine の数を トークンが貯まるまで待つ 上限で抑える 関心が分離されるので、片方をいじってももう片方が壊れない Go の rate.Limiter で、 429 との戦いを終わらせる 6
  8. レートは Client を作るときに数値で決める type Client struct { limiter *rate.Limiter conc

    int } // レート : 1 秒あたりの送出数 // 並列数 : 同時に走らせる上限 func NewClient(perSec float64, burst, conc int) *Client { return &Client{ limiter: rate.NewLimiter(rate.Limit(perSec), burst), conc: conc, } } c := NewClient(1.5, 1, 4) // ← ここで確定 // 相手は 1 秒 2 件まで。余裕を見て 1.5 req/s ctx はレートを決めていない レートの数値は NewLimiter に渡した時点で確定している。 limiter.Wait(ctx) はトークンが貯まるまで待つだけで、 ctx の役割は「待っている間にキャンセル・タイムアウトできるようにすること」。 Go の rate.Limiter で、 429 との戦いを終わらせる 7
  9. 本体はこれだけ c は上で作った *Client 。 limiter と conc を持っている func

    (c *Client) FetchAll(ctx context.Context, urls []string) ([]Result, error) { results := make([]Result, len(urls)) g, ctx := errgroup.WithContext(ctx) g.SetLimit(c.conc) // ← 並列数 (NewClient で決めた 4) for i, u := range urls { g.Go(func() error { if err := c.limiter.Wait(ctx); err != nil { // ← レート ( 順番が来るまで待つ ) results[i] = Result{URL: u, Err: err} return nil // 1 件の失敗で全体を止めない } results[i] = c.fetch(ctx, u) // 添字書き込みなので排他不要 return nil }) } return results, g.Wait() } レートも並列数も、この関数の中では 1 行ずつ。残りはただのループ Go の rate.Limiter で、 429 との戦いを終わらせる 8
  10. errgroup から素直にエラーを返すと全部止まる g.Go(func() error { if err != nil {

    return err } ... }) // ✗ g.Go(func() error { results[i] = Result{ Err: err, // 値として持つ ◦ } return nil }) errgroup は最初のエラーでコンテキストをキャンセルする 「 40 件投げて、落ちたものは落ちたものとして記録したい」なら、エラーは値として持ち回る。 全体を止めたいときだけ、ここで return err すればいい。 Go の rate.Limiter で、 429 との戦いを終わらせる 9
  11. テストが直感的に書けるようになった 1 秒あたり何件送出したかを数えるだけで、仕様どおりか判定できる // 相手は「 1 秒 2 件まで、超えたら 429

    」 srv := newRateLimitedServer(2) c := NewClient(1.5, 1, 4) // 1.5 req/s ・並列 4 c.FetchAll(ctx, urls) if got := srv.MaxPerSec(); got > 2 { // ← 数えるのはここだけ t.Errorf("maxPerSec = %d, want <= 2", got) } === RUN TestFetchAll_WithoutLimiter_GetsThrottled total=40 rejected=35 maxPerSec=40 client-side 429=35 --- PASS === RUN TestFetchAll_WithLimiter_NoThrottling total=12 rejected=0 maxPerSec=4 --- PASS 設定したレートと、実測の maxPerSec を突き合わせるだけ。数えるのは 1 つの数字 抑えない側も一緒に走らせる。 429 が出ることを先に検証しないと、通った理由が分からない Go の rate.Limiter で、 429 との戦いを終わらせる 10
  12. それでも SQS + Lambda が要る場面 rate.Limiter が守るのは「送出の間隔」だけ。それ以外は守らない 大量の入力 → SQS

    → キューに積む 永続化・リトライ / DLQ ・バースト吸収 1 永続化 「落ちたら困る」 プロセスが死ぬと 処理中の分は消える 2 Lambda → rate.Limiter 外部 API 1 秒 N 件まで 送出の間隔 リトライ / DLQ 「失敗を拾いたい」 落ちた分を後から 拾い直せる 3 バースト吸収 「入力が一気に来る」 あふれをキューが 受け止めてくれる rate.Limiter が効くのは 1 プロセスの中だけ — n 個起動したら、設定値を n で割る 相手の上限 2 req/s ÷ Lambda 4 並列 = 1 プロセスあたり 0.5 req/s rate.NewLimiter(rate.Limit(0.5), 1) Go の rate.Limiter で、 429 との戦いを終わらせる // 2 秒に 1 件 11
  13. まとめ ✓ 並列数とレートは別物。相手が見ているのはレート (1 秒あたりの送出数 ) ✓ Go なら rate.Limiter

    と errgroup.SetLimit で別々に書ける ✓ SQS は不要にならない。永続化・リトライ / DLQ ・バースト吸収はコードでは代替できない コード (MIT) github.com/shinchit/ go-lambda-ratelimit Go の rate.Limiter で、 429 との戦いを終わらせる 元記事 X @shintakapi zenn.dev/calmpm_takahiro 12