B システム設計/インフラ — 「Redis で数えているのに超えた」: 外部 SMS の 100 件/秒を 3 系統・最大 23 プロセスで分け合い、OTP を販促に押し出させない

2026-10-13 火曜 火曜 B: システム設計/インフラ ★★★★★ GKE HPA / Argo / Memorystore (Redis 7.4) 分散レート制限 / GCRA / Bulkhead / fail-safe

概要

数値は手元の 本物の Redis 7.4.11(docker、往復 0.28 ms)に販促 16 プロセス+OTP 1 プロセスで当てた実測(5 秒 × 3 回)。事業者 S は架空で、測り方は「直前 1 秒」と「固定窓」の 2 通りを模擬。Memorystore・GKE では測っていない。
🧮

原子的 ≠ 上限を守る

INCR 1 回で 100.0 件/秒ちょうど。でも直前 1 秒では 146 件。窓の形は別問題。

🕰️

時計は 1 つ

Pod の時計で秒を切ると、ずれがそのまま超過になる。Redis の TIME を使う。

🧱

強い方を頭打ち

販促を 70 に抑えると、OTP の p99 は 1 秒 → 2 ms。

🛟

落ちたら控えめに

fail-open は 22,514 件/秒。90 ÷ 23 プロセスの代替なら 64 件/秒。

問題

SMS 配信事業者 S(架空)の 1 アカウントを notify-worker(HPA 2〜20 Pod)・cart-reminder(Argo Job 並列 2)・ログイン OTP の 3 系統で使っている。契約は 100 件/秒、超えたら 429+Retry-After: 1、10 分で 429 が 300 回を超えたら 60 分停止。「1 秒」の測り方は非公開 架空の前提

10/12 20:00、当選 SMS 18 万通を流し始めると HPA が 16 Pod に増え、20:01:40 にアカウント停止。60 分間 OTP が届かずログイン失敗 2,340 件。翌朝「Redis で数えているのになぜ超えたのか」を誰も説明できない。

このコードには 10 個の問題 が隠れています。見つけてから先へ進んでください。
  • v1(cart-reminder は今もこれ): LIMIT_PER_POD = 100 / 2「本番は 2 Pod だから」
  • v2(notify-worker): キー sms:{int(time.time())} を GET → 100 未満なら INCR → EXPIRE。Redis 例外なら return True
  • 送信ループ: 販促も OTP も同じトピック・同じ順番。断られたら 1 ms ごとに聞き直す。429 なら即やり直し計 4 回

チームの案: A INCR 1 回にして原子化 / B Cloud Tasks に max_dispatches_per_second=100 / C 送信部分を replicas=1 に分ける

期待する回答形式: 問題点 8 点以上/原子性・窓の形・時計・障害時の 4 観点の説明/3 案の評価(数値)/改善後コード/テストと変異テスト/監視・切り替え条件

ヒント(段階的開示)

ヒント1 — 方向性
「100」を誰が・どの時計で・どの窓で数えているかを 3 系統それぞれ書き出す。v1 が 16 Pod なら何件か。16 Pod が同じ瞬間に GET したら何が返るか。事業者が「直前 1 秒」で数えていたら、秒の境目の前後で何件が 1 秒に入るか。
ヒント2 — アプローチ
  • 数え間違い(原子性)と窓の形は別の問題。INCR 1 回は前者だけを直す
  • 相手の測り方が分からないなら、どの窓で測られても超えない形: GCRA(次に送ってよい時刻 TAT を 1 つ持つ。トークンバケットと同じ形)
  • 時刻は全員共通の 1 つ(Redis の TIME)
  • OTP と販促が同じ枠を取り合うと OTP が負ける。販促だけ低めに頭打ち(バルクヘッド)
  • 429 はその Pod だけでなく全員が止まる合図
ヒント3 — 誘導
local t = redis.call('TIME')                 -- 全員共通の時計
if redis.call('PTTL', penalty) > 0 then return {0, ...} end   -- 429 で全員停止中
local tat = math.max(GET(global) or now, now)  -- 空いた分は BURST までしか貯めない
if tat - now > tau then return {0, tat - now - tau} end      -- 待つ時間を返す
-- 販促なら marketing キーも判定。両方通ったときだけ両方 SET

except redis.RedisError:
    return local_fallback.acquire()            # 90 ÷ 最大プロセス数 で控えめに

問題点分析(10 点)

#問題点何が起きるか(出所つき)改善
1上限を Pod 数で割って固定16 Pod で 795〜798 件/秒 実測全員で 1 つの枠
2GET → 判定 → INCR102.6〜105.2 件/秒、100 ms に最大 108 件 実測Lua で 1 往復
3固定窓INCR 1 回でも直前 1 秒の最大 113〜146 件 実測GCRA
4Pod の時計で秒を切る±0.4 秒ずれで、固定窓の事業者にも 429 が 0 → 4〜8 件/5 秒 実測Redis の TIME
5Redis 不通で無制限22,514 件/秒 実測。停止条件に 0.02 秒で届く 導出90÷23/s の代替
6429 を即やり直し計 4 回429 1 件が最大 4 件に。停止までが最大 1/4 に 導出nack して待つ
7429 はその Pod だけ他の 15 Pod は送り続けるpenalty キーで全員停止
8OTP と販促が同じ枠OTP の待ち p99 990〜998 ms 実測。停止で OTP も 60 分販促だけ 70 で頭打ち
91 ms ごとに聞き直す16,000 回/秒の空回り 導出retry_after だけ寝る
10余白なし(目標=100)短い窓・ネットワークでの詰まりで超える(未測定)目標 90 仮定

「Redis で数えているのに超えた」— 4 つの観点

観点何がまずいか実測
原子性GET と INCR の間に他の Pod が割り込み、全員が「まだ 99」を見るGET→INCR 102.6〜105.2 件/秒 → INCR 1 回で 100.0
窓の形積み残しがあると毎秒の頭 10 ms で 100 件を出し切る。境目をまたぐ 1 秒に 2 回分が入るINCR 1 回でも直前 1 秒で最大 146、厳しい事業者なら 5 秒で 25〜67 件の 429
時計Pod の時計がずれると、同じ瞬間に 2 つの秒のキーが同時に使われるゆるい事業者でも 0 → 4〜8 件の 429
障害時「止めるな」が「無制限に送れ」になっている22,514 件/秒

停止までの時間(10 分で 429 が 300 回超。リトライを数えない控えめな計算): v2 現行は厳しい事業者で 16〜41 秒、ゆるい事業者で 58〜115 秒。問題文の「100 秒で停止」は後者の範囲だが、事業者の測り方が分からない以上どちらだったかは言えない。

3 案の評価(実測 5 秒 × 3 回の範囲)

構成件/秒直前 1 秒の最大429(厳しい)429(ゆるい)OTP 待ち p99
v1 Pod ごと(Bad)795〜798813〜8183,476〜3,4903,390〜3,44220 ms
v2 GET→INCR(Bad)102.6〜105.2114〜15637〜9413〜26990〜993 ms
案 A INCR 1 回100.0113〜14625〜670997〜998 ms
案 A +時計ずれ ±0.4 秒100.0105〜1098〜204〜81,424〜1,434 ms
GCRA(区分なし)90.09100513〜744 ms
Good GCRA+販促 7080.889〜90001.3〜2.4 ms
Good +時計ずれ ±0.4 秒80.889〜90000.9〜2.6 ms
直前 1 秒の最大件数(3 回のうち最大)。点線 = 契約 100、細線 = 目標 90 v2 GET→INCR156 案 A INCR146 案 A +時計ずれ109 GCRA 区分なし91 Good90 v1(813〜818)は桁が違うので省略。棒の長さは 2.4 px/件。
  • 案 A は半分だけ正しい: 数え間違いは消える(100.0)が窓は固定窓のまま。ゆるい事業者なら 0 件、厳しい事業者なら 22〜60 秒で停止。事業者の測り方に賭ける設計
  • 案 B(Cloud Tasks)は未測定。公式文書がトークンバケットで、実際の配信速度が max_dispatches_per_second を一時的に超えうると明記(2026-10-13 参照)。3 系統を 1 キューに集める改修も要る
  • 案 C(replicas=1)は notify-worker の中だけで閉じる。3 系統で 100 を固定で割ると、キャンペーン中は cart-reminder の枠が遊ぶ(契約の 4 割)。1 Pod の再起動で OTP も止まる

設計図(SVG)— 1 つの数(TAT)を、1 つの時計で、1 回の往復で

notify-workerHPA 2〜20 Pod・販促 cart-reminderArgo Job ×2・販促 ログイン OTP平常 2〜10/s Redis(Memorystore)Lua 1 回 ① TIME(全員共通の時計) ② penalty があれば全員待つ ③ 全体の TAT: 90/s・BURST 5 ④ 販促なら販促の TAT: 70/s ⑤ 両方通れば両方進める  断ったら何も変えない キーは {sms:acct-main} で同じスロット 事業者 S契約 100/s Redis 不通: 1 秒間ローカル GCRA90 ÷ 23 = 3.9/s/プロセス(実測 64/s) 429 + Retry-After→ penalty を延ばす 緑(OTP)は販促の頭打ち④を受けず全体 90 まで使える。販促が 70 で止まるので、OTP には常に 20/s 以上が残る(Bulkhead)。 許可されなかった呼び出しは返ってきた待ち時間だけ寝る。締切(OTP 1 秒・販促 8 秒)を超えるなら nack して Pub/Sub に任せる。

適用パターン: Rate Limiter(GCRA)/Bulkhead(販促の頭打ち)/共有 Backpressure(429 → 全員停止)/Graceful Degradation(不通時の控えめな代替)/Circuit Breaker の簡易版(不通の 1 秒は Redis を叩かない)

Bad → Good(コード差分)

Bad
LIMIT_PER_POD = 100 / 2  # 2 Pod 前提。16 Pod で 800/s

def acquire(self) -> bool:
    try:
        key = f"sms:{int(time.time())}"  # Pod の時計・固定窓
        count = int(self.r.get(key) or 0)
        if count >= CONTRACT_RPS:  # ここで割り込まれる
            return False
        self.r.incr(key)
        self.r.expire(key, 2)
        return True
    except redis.RedisError:
        return True  # 不通なら無制限

while not limiter.acquire():
    time.sleep(0.001)  # 1 ms ごとに空回り
for attempt in range(4):  # 429 なら即やり直し
    resp = sms.send(msg)
    if resp.status_code != 429:
        break
msg.ack()  # OTP も販促も同じ順番・同じ枠
Good
GLOBAL_TARGET_RPS = 90     # 契約の 90%(余白は仮定)
MARKETING_TARGET_RPS = 70  # 残り 20 は OTP へ
FALLBACK_RPS = 90 / 23     # 全員不通でも 90 以下

def try_acquire(self, prio) -> Decision:
    if time.monotonic() < self._redis_skip_until:
        return self._fallback.acquire()
    use_class = "1" if prio is Priority.MARKETING else "0"
    try:  # TIME・penalty・2 段の GCRA を Lua 1 回で
        ok, wait_us = self._gcra(keys=self._script_keys,
                                 args=[*self._args, use_class])
    except redis.RedisError:
        self._redis_skip_until = time.monotonic() + 1.0
        return self._fallback.acquire()  # 控えめに送る
    return Decision(bool(ok), wait_us / 1e6, "redis")

d = limiter.acquire(prio, deadline_s=OTP_1S_OR_MKT_8S)
if not d.allowed: msg.nack(); return  # 抱えない
resp = sms.send(msg)
if resp.status_code == 429:
    limiter.report_throttled(retry_after(resp))  # 全員停止
    msg.nack(); return  # その場でやり直さない
msg.ack()

模範解答(Lua スクリプトの全文と要点)

KEY_PREFIX = "{sms:acct-main}"  # ハッシュタグで 3 キーを同じスロットに(Cluster でも 1 スクリプト)
BURST = 5                       # 空いていたとき一度に出せる件数。100 ms の最大は実測 12〜13 件
MAX_SENDERS = 23                # HPA 上限 20 + Argo 2 + CronJob 1
REDIS_RETRY_AFTER_S = 1.0       # 不通の間は毎回タイムアウトを待たない・ログは 1 秒に 1 行

GCRA_LUA = """
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000000 + tonumber(t[2])   -- Redis の時計(µs)。Pod の時計は使わない

local pen = redis.call('PTTL', KEYS[3])
if pen > 0 then
  return {0, pen * 1000}                                -- 429 を受けて全員停止中
end

local function check(key, interval, tau)
  local tat = tonumber(redis.call('GET', key) or now)
  if tat < now then tat = now end                        -- 空いた時間を貯金させない(BURST まで)
  local wait = tat - now - tau                           -- 許容を超えた分だけ待つ
  return tat + interval, wait
end

local g_new, g_wait = check(KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]))
local c_new, c_wait = 0, -1
local use_class = ARGV[5] == '1'
if use_class then
  c_new, c_wait = check(KEYS[2], tonumber(ARGV[3]), tonumber(ARGV[4]))
end

local wait = math.max(g_wait, c_wait)
if wait > 0 then
  return {0, wait}                                       -- どちらか一方でも超えたら、どちらも更新しない
end

redis.call('SET', KEYS[1], g_new, 'PX', math.ceil((g_new - now) / 1000) + 1000)
if use_class then
  redis.call('SET', KEYS[2], c_new, 'PX', math.ceil((c_new - now) / 1000) + 1000)
end
return {1, 0}
"""

PENALTY_LUA = """                       -- 429 の Retry-After を全員に配る
local cur = redis.call('PTTL', KEYS[1])
if cur < tonumber(ARGV[1]) then                 -- 長い停止を短い停止で上書きしない
  redis.call('SET', KEYS[1], '1', 'PX', ARGV[1])
end
"""

実行例: 空いた状態で OTP を 8 回続けて try_acquire → 先頭 5 回 allowed=True、6 回目 allowed=False, retry_after_s≈0.011(1/90 秒)。report_throttled(0.5) の直後に別プロセス → retry_after_s≈0.5、0.55 秒後に True(テストで確認)。

定数値根拠
全体の目標90/s契約の 90%。仮定(ネットワークでの詰まりは未測定)
販促の頭打ち70/sOTP 平常ピーク 10/s の 2 倍を残す 導出+仮定
BURST5100 ms 窓で数える事業者なら 1 に下げる
不通時3.9/s/プロセス23 プロセス全員が不通でも 90 実測 16 プロセスで 64.0/s

テストと変異テスト

$ .venv/bin/python -m pytest -q test_gcra_limiter.py   # 本物の Redis 7.4 に当てる。3 回続けて実行
16 passed in 9.83s
16 passed in 9.83s
16 passed in 9.80s

$ .venv/bin/python mutate.py                            # 仕掛けを 1 つずつ壊す
M1 Pod の時計を使う          killed  test_uses_redis_clock_not_pod_clock
M2 空き時間を貯金させる      killed  test_idle_time_does_not_bank_credit
M4 429 の停止を見ない        killed  test_penalty_is_shared_across_processes
M6 Redis 不通で無制限        killed  test_redis_down_falls_back_to_slow_local_rate
M8 販促も全体枠だけ          killed  test_marketing_is_capped_and_otp_gets_through
M15 OTP は空文字キー         killed  test_every_script_call_stays_in_one_cluster_slot[otp]
...
15/15 killed
途中で見つけた自分の誤り: 最初の実装は OTP のとき販促キーの代わりに空文字のキーを渡していた。単体の Redis では動くが、Cluster モードでは空文字キーが別スロットになり CROSSSLOT で OTP だけが落ちる。最初のテストは「キー名の定数が同じスロットか」しか見ておらず素通しした。テストを「実際にスクリプトへ渡したキー」を見る形に直し(M15 を追加)、実装は常に 3 キーを渡して ARGV で区分を切り替える形にした。

確かめていないこと: Memorystore 越しの往復/フェイルオーバー(非同期複製で TAT が失われても、新しいキー扱いで BURST 5 件ぶん余計に通るだけ 導出)/Pub/Sub と事業者をつないだ送信ループ全体。

迷った案と採らなかった理由

論点採った採らなかった案と理由前提が変わったら
窓の形GCRA固定窓(境目で 1.46 倍・実測)/スライディングログ(窓が事業者と一致すれば正確だが、1 秒の頭に 100 件を一気に出せて短い窓に弱い)事業者が「直前 1 秒」を公開したらスライディングログも候補
GCRA かトークンバケットかGCRA振る舞いは同じ。GCRA は TAT 1 つで「何秒待つか」がそのまま出る(redis-cell と同方式、GitHub 約 1.3k stars)モジュールを入れられるなら redis-cell(Memorystore で可否は未確認)
時計Redis TIMEPod の時計を引数で渡す(ずれが超過に直結)。Redis 5.0 以降は効果の複製なのでスクリプト内 TIME で書き込んでよい—
Redis 不通時控えめな代替無制限(22,514/s で即停止)/全停止(OTP も止まる)OTP が別アカウントになれば全停止でもよい
OTP の守り方販促だけ頭打ち優先度付きキュー(1 か所に集める必要)/固定割当(OTP の 20 が遊ぶ)—
余白90100(測り方が不明)。10% は勘429 が 0 のまま 2 週間なら 95 を試す

直した後に見るもの・切り替える条件

指標正常こうなったら
事業者からの 42905 分で 1 件でも警報(余白の仮定が崩れている)
判定の出どころ source=fallbackほぼ 01 分続いたら Redis 不通
OTP の待ち p99数 ms100 ms 超(頭打ちが効いていない)
penalty 発動中01 ならすぐ人が見る
送れた件数/秒積み残しがあれば 80〜9090 超ならどこかに v1 が残っている
  • 来期 OTP 用アカウントが分かれる → 区分を外し、販促アカウントは全体 90 に
  • 3 系統が HTTP の受け口を持ち 1 キューに集約できた → 案 B(Cloud Tasks、目標 70〜80)
  • 3 系統が 1 つの送信サービスにまとまった → 案 C(replicas=1+プロセス内 GCRA)
  • 上限が 1 万件/秒級で 1 キーが詰まる → キー分割や envoyproxy/ratelimit(約 2.7k stars)など専用サービス

ポイント解説

  1. 原子的にしたら直る、は半分だけ: 数え間違いと窓の形は別。相手の測り方が分からないときは、どの窓でも超えない形にする
  2. 時計は 1 つ: 分散した送り手が各自の時計で「今は何秒か」を決めると、ずれがそのまま超過
  3. 障害時は止める/止めないの二択ではない: 最大プロセス数で割った速さなら、全員同時に代替へ落ちても超えない
  4. 取り合いは強い方を頭打ち: 販促 70 で OTP の p99 は 1 秒 → 2 ms
  5. テストは実際に渡したものを見る: 定数を見るテストは CROSSSLOT の誤りを素通しした

扱わなかった範囲(止め方)

  • 事業者の本当の測り方: 「直前 1 秒」と「固定窓・0.37 秒ずれ」の 2 通りだけ。100 ms 窓・処理完了で数える方式は未試行
  • Memorystore・GKE での実測: 手元の docker(往復 0.28 ms)のみ
  • Cloud Tasks(案 B): 公式文書から判断しただけ。動かしていない
  • 送信ループ全体: Pub/Sub の nack・再配信と事業者をつないだ通し試験はしていない(写像のみ)
  • 法令・送信時間帯: 特定電子メール法の SMS 広告の扱いなどは範囲外

一次資料(参照日 2026-10-13): Redis「Scripting with Lua」(効果の複製が 5.0 から既定・7.0 で丸ごと複製は廃止・TIME を自由に使える)https://redis.io/docs/latest/develop/programmability/eval-intro/ / Google Cloud「Configure Cloud Tasks queues」(トークンバケット・max_burst_size はシステムが決める・実際の速度が超えうる)https://docs.cloud.google.com/tasks/docs/configuring-queues / brandur/redis-cell(GCRA、約 1.3k stars)/ envoyproxy/ratelimit(Redis、約 2.7k stars。README は月単位の窓が固定 30 日とだけ明記)

発展: 事業者が「同時接続 20 本まで」も持っていたら、件数の制限と同時実行数(セマフォ)を Redis で両立させ、落ちたプロセスが返さない枠をリースで回収せよ。

今日のまとめ

「Redis で数えている」は上限を守る証拠にならない。原子性・窓の形・時計・障害時の方針の 4 つが揃って初めて、どう測られても超えない。実測では Bad が 5 秒で 37〜94 件の 429、案 A でも 25〜67 件、Good は 0 件で、OTP の待ちは 1 秒近く → 2 ms になった。

自己評価(あとで記入)