Redis String型:数値演算の魔力 — アトミックカウンタを極める実務設計論
こんにちは。テックリードの私だ。
今日のコードレビューで、またこんなアンチパターンを見かけた。
良くあるアンチパターン(絶対に真似してはいけない)
count = redis_client.get(“user:100:visits”)
count = int(count) if count else 0
count += 1
redis_client.set(“user:100:visits”, count)
「おいおい、これの何が問題かわかるか?」
もし君が「動くからいいじゃないですか」と思ったなら、今すぐこの記事を熟読してほしい。このコードは、高負荷なプロダクション環境においてデータ破損(ロスト・アップデート)を引き起こす爆弾だ。
今回は、RedisのString型が持つ真価、すなわち `INCR`系コマンド群によるアトミックな数値演算 について、現場の設計で絶対に知っておくべき極限の知見を伝授する。
—
なぜ、素朴な「取得してインクリメントして保存」は死を招くのか?
アプリケーション層で `GET` して `SET` するアプローチは、マルチスレッド環境や複数台のアプリケーションサーバーからアクセスされる現代のWEBシステムにおいて、完全に破綻する。
1. プロセスA が `count = 10` を取得。
2. プロセスB が `count = 10` を取得。
3. プロセスA が `11` を計算し、Redisに `SET`。
4. プロセスB が `11` を計算し、Redisに `SET`。
本来、2回アクセスがあったのだから `12` にならなければならないはずが、結果は `11` になる。これがレースコンディション(競合状態)だ。
これを防ぐためにアプリケーション側で分散ロック(Redlockなど)を張る?
待て。たかがカウンタを1つ増やすために、わざわざ重厚長大なお見合い制御(ロック)を実装するのか?レイテンシは跳ね上がり、Redisの持ち味である「超高速性」が台無しだ。
ここで登場するのが、Redisのアトミック数値演算コマンドである。
—
武器の選定:Redis数値演算コマンドの全容
RedisのString型は、内部的にバイナリセーフなバイト列として保持されるが、格納されている値が「整数の文字列表現」である場合、そのまま数値としてインプレース(in-place)に演算できる。
主なコマンドラインナップは以下の通りだ。
- `INCR key`: 値を `1` 増やす
- `DECR key`: 値を `1` 減らす
- `INCRBY key increment`: 指定した整数分増やす
- `DECRBY key decrement`: 指定した整数分減らす
- `INCRBYFLOAT key increment`: 指定した浮動小数点数分増やす
これらすべてのコマンドは、単一スレッドで動作するRedisのコマンド実行ループ(シングルスレッドモデル)において、完全にアトミック(不可分)に実行される。 ロックは不要だ。Redisに処理が到達した瞬間、整合性は完璧に担保される。
知られざる仕様:キーが存在しない場合の挙動
実務で非常に重宝するのが、「キーが存在しない(または空の)状態で `INCR` を実行した場合、自動的に `0` が設定された上でインクリメントされ、結果の `1` が返る」 という仕様だ。
つまり、カウンタの「初期化処理(Bootstrap)」を事前にアプリケーション側で行う必要がない。
キーが存在しない状態からスタート
127.0.0.1:6379> INCR page:views:12345
(integer) 1 # 勝手に0から1になった!
127.0.0.1:6379> INCRBY page:views:12345 99
(integer) 100
この「存在しなければ勝手に0から始めてくれる」特性は、コードの複雑性を劇的に下げる。
—
実務で直面する「3つの罠」とアーキテクチャ上の注意点
いくら強力な `INCR` といえど、インフラストラクチャの物理的制約やRedisの内部構造を理解していないと、痛い目をみる。シニアエンジニアとして押さえておくべきポイントを授けよう。
1. 扱える数値の限界(64bit Signed Integer)
Redisの整数演算は、Signed 64bit整数(符号付き64bit整数)で行われる。
扱える値の範囲は `-9,223,372,036,854,775,808` から `9,223,372,036,854,775,807` までだ。
- 注意: グローバルな総PV数など、この上限を超える可能性が微かにでもあるカウンター(例えば、世界規模のサービス)を設計する場合は、ビッグインテジャーや複数のキーへの分割を検討する必要がある。通常サービスであればまず枯渇しないが、頭の片隅に入れておくこと。
2. 浮動小数点数演算 (`INCRBYFLOAT`) の精度問題
小数を扱う `INCRBYFLOAT` は非常に便利だが、IEEE 754 準拠の浮動小数点演算を行うため、コンピュータ特有の丸め誤差(Precision Error)が発生する。
127.0.0.1:6379> SET score 0
OK
127.0.0.1:6379> INCRBYFLOAT score 0.1
“0.1”
127.0.0.1:6379> INCRBYFLOAT score 0.2
“0.30000000000000004” # きたこれ!
- 対策: 金額の計算(決済やポイントシステムなど、1円の狂いも許されないドメイン)に `INCRBYFLOAT` を使うのは御法度だ。金額は必ず「整数(セントや銭単位のInteger)」として管理し、`INCRBY` で演算しろ。浮動小数点数は、あくまでゲームのスコアやメトリクスの合算など、誤差が許容される文脈に限定すべきだ。
3. 永続化とメモリ枯渇の恐怖(OOM対策)
カウンタとしてRedisを使う場合、そのキーの「寿命(TTL)」をどう設計するかは死活問題だ。
例えば、「ユーザーごとのAPIリクエスト回数制限(レートリミッター)」や「日別PV集計」を考えてほしい。
- アンチパターン: 期限切れを設定せず、すべてのユーザーのカウンタを永遠に持ち続ける。
- 結果: メモリがじわじわと圧迫され、ある日突然 Redis が `OOM (Out of Memory)` でクラッシュする。
—
実践設計パターン:堅牢な「IPベース・レートリミッター」
理論はここまでだ。実務でそのまま使える、堅牢なAPIレートリミッターの設計パターンをコードで示そう。
「1分間に最大60回までリクエストを許可する」という要件を、Redisの数値演算と有効期限(TTL)を組み合わせてエレガントに実装する。
import redis
import time
class RateLimiter:
def __init__(self, redis_client: redis.Redis, limit: int = 60, window_seconds: int = 60):
self.redis = redis_client
self.limit = limit
self.window_seconds = window_seconds
def is_allowed(self, identifier: str) -> tuple[bool, int]:
“””
指定された識別子(IPアドレスやUser IDなど)のレートリミットを判定する。
Returns:
(allowed: bool, remaining: int)
“””
current_minute = int(time.time() // self.window_seconds)
key = f”rate_limit:{identifier}:{current_minute}”
# パイプラインを用いて、インクリメントとEXPIREをアトミックに近い形で処理する
# (Redis 7.0以降なら EXPIRE のオプション付き INCR も使えるが、汎用的なパイプラインで示す)
pipe = self.redis.pipeline()
pipe.incr(key)
pipe.expire(key, self.window_seconds 2, nx=True) # キーが未設定の場合のみTTLを付与(安全策)
results = pipe.execute()
current_count = results[0]
if current_count > self.limit:
return False, 0
remaining = self.limit – current_count
return True, remaining
— 使用例 —
r = redis.Redis(host=’localhost’, port=6379, decode_responses=True)
limiter = RateLimiter(r, limit=5, window_seconds=60)
client_ip = “192.168.1.10”
for i in range(7):
allowed, remaining = limiter.is_allowed(client_ip)
print(f”Request {i+1}: Allowed={allowed}, Remaining={remaining}”)
time.sleep(0.1)
この設計の美しいポイント
1. キーの自動クレンジング: `rate_limit:{identifier}:{current_minute}` のようにタイムスタンプ(分単位)をキーに含めることで、古いデータは時間経過とともに自然消滅する。Redisのメモリを無駄に食いつぶさない。
2. `EXPIRE … nx=True` のイディオム: すでにTTLが設定されている場合は上書きせず、初回作成時のみ有効期限を付与することで、不必要なコマンド発行コストを削減している。
—
結びの言葉
RedisのString型による数値演算は、一見すると非常に地味だ。
しかし、この「アトミックなインクリメント」というプリミティブを深く理解し、正しく使いこなすことこそが、高トラフィックに耐えうるスケーラブルなバックエンドシステムを構築するための第一歩となる。
次に君がコードレビューをする時、もし `GET` -> 演算 -> `SET` のアンチパターンを見かけたら、この記事を叩きつけてやってほしい。
「Redisをなめるな。アトミックに使え」とね。
コメント