概要
原子的 ≠ 上限を守る
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 で数えているのになぜ超えたのか」を誰も説明できない。
- 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 — 方向性
ヒント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 つの枠 |
| 2 | GET → 判定 → INCR | 102.6〜105.2 件/秒、100 ms に最大 108 件 実測 | Lua で 1 往復 |
| 3 | 固定窓 | INCR 1 回でも直前 1 秒の最大 113〜146 件 実測 | GCRA |
| 4 | Pod の時計で秒を切る | ±0.4 秒ずれで、固定窓の事業者にも 429 が 0 → 4〜8 件/5 秒 実測 | Redis の TIME |
| 5 | Redis 不通で無制限 | 22,514 件/秒 実測。停止条件に 0.02 秒で届く 導出 | 90÷23/s の代替 |
| 6 | 429 を即やり直し計 4 回 | 429 1 件が最大 4 件に。停止までが最大 1/4 に 導出 | nack して待つ |
| 7 | 429 はその Pod だけ | 他の 15 Pod は送り続ける | penalty キーで全員停止 |
| 8 | OTP と販促が同じ枠 | OTP の待ち p99 990〜998 ms 実測。停止で OTP も 60 分 | 販促だけ 70 で頭打ち |
| 9 | 1 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〜798 | 813〜818 | 3,476〜3,490 | 3,390〜3,442 | 20 ms |
| v2 GET→INCR(Bad) | 102.6〜105.2 | 114〜156 | 37〜94 | 13〜26 | 990〜993 ms |
| 案 A INCR 1 回 | 100.0 | 113〜146 | 25〜67 | 0 | 997〜998 ms |
| 案 A +時計ずれ ±0.4 秒 | 100.0 | 105〜109 | 8〜20 | 4〜8 | 1,424〜1,434 ms |
| GCRA(区分なし) | 90.0 | 91 | 0 | 0 | 513〜744 ms |
| Good GCRA+販促 70 | 80.8 | 89〜90 | 0 | 0 | 1.3〜2.4 ms |
| Good +時計ずれ ±0.4 秒 | 80.8 | 89〜90 | 0 | 0 | 0.9〜2.6 ms |
- 案 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 回の往復で
適用パターン: Rate Limiter(GCRA)/Bulkhead(販促の頭打ち)/共有 Backpressure(429 → 全員停止)/Graceful Degradation(不通時の控えめな代替)/Circuit Breaker の簡易版(不通の 1 秒は Redis を叩かない)
Bad → Good(コード差分)
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 も販促も同じ順番・同じ枠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/s | OTP 平常ピーク 10/s の 2 倍を残す 導出+仮定 |
| BURST | 5 | 100 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
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 TIME | Pod の時計を引数で渡す(ずれが超過に直結)。Redis 5.0 以降は効果の複製なのでスクリプト内 TIME で書き込んでよい | — |
| Redis 不通時 | 控えめな代替 | 無制限(22,514/s で即停止)/全停止(OTP も止まる) | OTP が別アカウントになれば全停止でもよい |
| OTP の守り方 | 販促だけ頭打ち | 優先度付きキュー(1 か所に集める必要)/固定割当(OTP の 20 が遊ぶ) | — |
| 余白 | 90 | 100(測り方が不明)。10% は勘 | 429 が 0 のまま 2 週間なら 95 を試す |
直した後に見るもの・切り替える条件
| 指標 | 正常 | こうなったら |
|---|---|---|
| 事業者からの 429 | 0 | 5 分で 1 件でも警報(余白の仮定が崩れている) |
判定の出どころ source=fallback | ほぼ 0 | 1 分続いたら Redis 不通 |
| OTP の待ち p99 | 数 ms | 100 ms 超(頭打ちが効いていない) |
| penalty 発動中 | 0 | 1 ならすぐ人が見る |
| 送れた件数/秒 | 積み残しがあれば 80〜90 | 90 超ならどこかに 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 つ: 分散した送り手が各自の時計で「今は何秒か」を決めると、ずれがそのまま超過
- 障害時は止める/止めないの二択ではない: 最大プロセス数で割った速さなら、全員同時に代替へ落ちても超えない
- 取り合いは強い方を頭打ち: 販促 70 で OTP の p99 は 1 秒 → 2 ms
- テストは実際に渡したものを見る: 定数を見るテストは 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 になった。