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

なぜThrottleではなくDebounceだったのか? 700並列リクエストと戦うサーバーサ...

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.

なぜThrottleではなくDebounceだったのか? 700並列リクエストと戦うサーバーサイド実装のすべて

Avatar for Yoshiori SHOJI

Yoshiori SHOJI

November 14, 2025
Tweet

More Decks by Yoshiori SHOJI

Other Decks in Technology

Transcript

  1. 7 テスト結果を集めてAIを使って様々な処理をしている ◦ 失敗したテストの原因が同じかどうかを判断 ▫ 例えばDBへのコネクションが原因で複数のテストがコケた時に 原因は同じなので1つにまとめる ◦ 同じ原因で失敗したテストが過去にあったか判断 ▫

    新しく発生した失敗なのかよくある失敗なのか ◦ リトライして成功しているかどうか ▫ 不安定なテストは複数回実行して成功していたら成功扱いに ◦ SlackやGithubなど各種通知 ◦ などなど
  2. 8 分散テスト実行 ◦ CIでのテスト実行に時間がかかるので複数台で実行する ▫ テストをある程度のまとまりごとにわけて実行 ◦ 分散するということはテストが多いということ ▫ つまり多いテストを早く終わらしたい

    ◦ 早く終わらせるためには平均的な時間で終わるように揃える ▫ 合計10分かかるテストを2分割するとして9分で終わるテストの 集まりと1分で終わる集まりに分散しても意味があまりない。 ▫ 上記なら5分で終わるテストの集まりを2個作るのが良い ◦ ほぼ同時に終わる = ほぼ同時に結果が送られる ▫ つまり、同時に大量のテスト結果が送られてくる
  3. 10 700並列の分散テスト実行結果 ◦ 非同期にしたりジョブキューに入れてもあまり効果ない ▫ 700個のキューが貯まるだけ ◦ Close処理はまとめられる ▫ 700並列のテスト結果データは保存必須

    ▫ その後のClose処理はある程度まとめて実行して良い ◦ 良い感じにデータ溜まったあとにClose処理したい ▫ ホントは全部終わったよってAPIリクエスト貰うのが一番楽なん だけど色々運用してそれは不可能だとなった ▫ なので良い感じに終わったと判断しなくてはいけない ◦ つまりClose処理部分を間引く必要がある
  4. 一旦初期対応終わったあと どうするのが良いのか? ◦ ❌ APIにRate Limitつける ▫ お客さんのテストデータを受け入れないのはダメ ◦ ❌

    処理を夜間バッチとかにする ▫ テスト結果はすぐに見たい(当たり前) ◦ ❌ スケールアップ/スケールアウト ▫ スパイクなのでauto scale的なものでは間に合わない ◦ ❌ ジョブキューなどにして非同期にする ▫ 700並列で非同期処理が走るようになるだけ 23
  5. 簡単に言うとRedisをロックオブジェクトにしたDebounce処理 36 * 最近のRedis界隈のゴタゴタでRedisと言うのは正確ではないけど この発表ではそこは本質ではないのでRedisで統一します @Async public waitAndClose(int testSessionID){ UUID

    myId = UUID.create(); cache.set(testSessionID, myId); Thread.sleep(5 * 100); UUID latest = cache.get(testSessionID); if(myId == latest){ testSessionsService.close(testSessionID); cache.del(testSessionID); } } ベースはProposalに書いた疑似コード
  6. 41 コードはシンプル @Async public waitAndClose(int testSessionID){ UUID myId = UUID.create();

    <- 呼ばれたら UUID作成 cache.set(testSessionID, myId); <- Redisに保存 Thread.sleep(5 * 100); <- 500ms待つ UUID latest = cache.get(testSessionID); <- Redisから取得 if(myId == latest){ <- 値の照合 testSessionsService.close(testSessionID); <- 実行 cache.del(testSessionID); <- Redisから値消す } }
  7. ここまでのまとめ Server-Side Debounce ◦ サーバ跨いで処理するのでsynchronizedは使えない ▫ というか使っても同期できない ◦ なのでRedisをロック機構に使う ▫

    Redisは基本処理がAtomicなので便利 ◦ サーバ再起動の時に処理が失われる可能性がある ▫ 処理自体のシリアライズなどの仕組みが必要 ▫ 99%はこれで上手く行くのでもっと余裕が出来たら考える 42
  8. 実際のコード v1 44 疑似コードとの変更点 ◦ 処理を引数にLambdaで渡せるように ▫ 非同期で値は返せないのでRunnable ◦ delayも引数で渡せる

    ▫ ユーザーや処理によって変更できるように ◦ 疑似コードからほとんど変化なし ▫ コード自体は数行でシンプルなもの ◦ エラー処理の追加
  9. Race Conditionがあった 処理の最後にRedisから値を消しているところ ◦ 本質的にはその削除処理はいらない ▫ 次のアクセスがなければRedisのexpireで揮発する ◦ 何故消す処理を入れたのか? ▫

    消すことによって実行されたかどうかの判断が出来る ▫ 残っていたらまだ実行されていないと判断できる ▪ 将来的にサーバ再起動遅延させたり、再起動後に残ってた 実行を復活させたりの判断が出来きる 46
  10. Race Conditionがあった 対応案 ◦ 処理実行中はロックする ▫ Single Threaded Execution パターン的なやつ

    ▫ サーバ跨いでロックするのは大変 ◦ 削除時Keyだけではなく値も一致してる時だけ消す ▫ 処理実行前に値が同じことを確認しているので終わった後、消 すタイミングでも確認してから消す ▫ こっちが良さそう 49
  11. 削除時、Keyだけではなく値も一致してる時だけ消す やりたいことは ◦ synchronized して値が一致したら削除する。 ▫ コードで書くとこういう感じ ▫ ただし、サーバを跨いで(ry 51

    public boolean deleteIfValueEquals (K key, V value) { synchronized (cache) { if (Objects.equals(cache.get(key), value)) { cache.del(key); return true; } } return false; }
  12. 削除時、Keyだけではなく値も一致してる時だけ消す Lua スクリプト実行するだけ 53 @Language("lua") private static final String ATOMIC_CHECK_AND_DELETE_LUA_SCRIPT

    = """ if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end """; public boolean deleteIfValueEquals(K key, V valueOfKey) { return redis.eval( ATOMIC_CHECK_AND_DELETE_LUA_SCRIPT, ScriptOutputType.BOOLEAN, (K[]) new Object[] {key}, valueOfKey); }
  13. Debounceはdelay時間の調整がキモ delay時間が・・・ ◦ 長過ぎた場合 ▫ 処理が実行されるまで時間がかかる、即時性が失われる ◦ 短すぎた場合 ▫ delayがあまり意味をなさずに全て実行されてしまう

    調整して10秒になってたけど、更に遅延させるの?テスト時間短くした くてサービス使ってもらってるのに?1分が長いか短いか論争に。 (そもそも重い処理なので処理を始めてからも時間かかるので) 61
  14. 2個の実現したいこと 今回の件では相反する2個の実現したいことがある ◦ 重い処理なのでなるべくまとめて実行したい ▫ OpenAIのAPI呼び出しなどもあるので逐次実行するとお金もかか りすぎる -> 出来ればdelay5分とかにしたい ▫

    負荷対策など主に運営側の問題 ◦ ユーザーにはなるべく早く届けたい ▫ 通知やテスト結果の色々な情報など ▫ これはサービスの価値やクオリティの話 63
  15. 即時実行 最初のリクエストの直後に アクションが実行され、重 要な更新が遅延なく確実に 実行される。 この即時実行は、迅速な応 答が重要な通知などに V2 案 条件付き遅延

    その後のリクエストからは 5分のdelayのDebounce処 理。 このDebounce処理は、デー タが全て集まった後、分析 や推論などの重い処理をす る 66
  16. 実装はどうなるか ここもRace conditionになりうる ◦ 最初の想定である1秒間に10回以上のスパイク時には簡単に発生しそう ◦ この処理もRedis内でatomicにする必要がある 70 var prev

    = cache.get(key); if (prev == null) { <-この処理の間に cache.set(key, DUMMY); <-別のリクエストがあるとダメ runnable.run() ; return; } . . // 通常の(長いDelayの)Debounce処理
  17. 出来た! 今回の修正もシンプル ◦ コードの変更は3行くらい ▫ しかもRedisにも最初からatomicな処理があった ◦ v1 のちょっとしたリファクタリングも ▫

    Threadを生で触るのではなくSpringのTaskSchedulerを使う ◦ 今度こそ大丈夫なはず! ▫ と思ったけど...この実装はすぐにダメになった 74
  18. 82 今までの問題をまとめると ◦ 短期間に大量に来るスパイクへの対応 ▫ v1 - シンプルなDebounce ◦ 緩やかだが量が多いものをまとめる

    ▫ v2 - 即時実行と遅延実行をするDebounce ▫ もちろんv1の問題も一緒に解決する ◦ スパイクではないが短期間(2分割で送信など) ▫ v3 - イマココ ▫ もちろんv1、v2の問題も一緒に解決する
  19. 実装はどうなるか delay決定部分 101 @VisibleForTesting static CallTrack calculateNextCallTrack(@Nullable CallTrack prev, Instant

    now) { if (prev == null) { return new CallTrack(now, DEFAULT_MINIMUM_DELAY); <- 初回呼び出し時はデフォルト値使う } Duration observedInterval = Duration.between(prev.time(), now); <- 一個前の呼出からの時間 Duration delay = min(max(prev.delay(), observedInterval), MAX_DELAY); ↑ 一個前の呼出からの時間と前回使ったdelayの長い方、長過ぎたらMAX_DELAYの方を使う return new CallTrack(now, delay); }
  20. 実装はどうなるか debounce 本体実装 102 @Async public void debounce(Key key, Runnable

    runnable) { String keyString = key.toString(); CallTrack prev = cache.get(keyString); CallTrack track = calculateNextCallTrack(prev, Instant.now()); cache.put(keyString, track); taskScheduler.schedule( () -> { if (!track.equals(cache.get(keyString))) { return; } runnable.run(); }, track.scheduledAt()); <- time.plus(delay) を返してるだけ }
  21. 実装はどうなるか 103 @Async public void debounce(Key key, Runnable runnable) {

    String keyString = key.toString(); CallTrack prev = synchronizationMap .get(keyString); CallTrack track = calculateNextCallTrack(prev, Instant. now()); synchronizationMap .put(keyString, track); taskScheduler .schedule( () -> { if (!track.equals(synchronizationMap .get(keyString))) { return; } runnable.run(); }, track.scheduledAt()); } @VisibleForTesting static CallTrack calculateNextCallTrack (@Nullable CallTrack prev, Instant now) { if (prev == null) { return new CallTrack(now, DEFAULT_MINIMUM_DELAY); } Duration observedInterval = Duration. between(prev.time(), now); Duration delay = min(max(prev.delay(), observedInterval), MAX_DELAY); return new CallTrack(now, delay); } public record CallTrack(Instant time, Duration delay) implements Serializable { Instant scheduledAt () { return time.plus(delay); } } 全体のコード量 全体でも30行以下。 言葉で説明するより コードのほうがシンプ ルでしょ? でも理解するの難しい よね><
  22. 109 Server-Side debounce まとめ ◦ サーバを跨いだマルチスレッドプログラミング ▫ atomicな処理が必要だったり楽しい ◦ 現実は複雑だ...

    ▫ 3回書き直すとは...でも解決できてよかった!! ◦ コード自体は至極シンプルに ▫ 30行以下で実装出来てるの自分でもビビる ▫ やっぱりシンプルなコードで複雑な問題に対応できるの楽しい ◦ やっぱりボスがロックスターだと便利w
  23. 111 v4 作るなら ◦ workspace毎にdelayのデータを貯めたい ▫ 今はロックとしても使っているのですぐに上書きされるし、一 日で揮発する ▫ データを元に外れ値弾いたり平均だしたりして初回debounceか

    ら適切な時間を使えるように... ◦ JobQueueにする ▫ 間引く仕組みは出来たのでAWS SQSとか使ってJobQueueにして完 全に処理を移譲したい ▫ ただし今のようにlambdaでさくっと書けたりしなくなるので悩 ましい