状況
深夜2時。監視アラートが鳴り響く — Redisエラーが急増している。ログをテールすると、同じエラーが繰り返されている:
ERR offset is out of range
障害が発生しているのは、RedisビットマップをベースにしたDAU(日次アクティブユーザー)トラッカーで、ユーザーIDごとに1ビットを割り当てる設計だ。ステージング環境では問題なく動いていた。本番環境ではユーザーIDが数千万に達し、数十億に迫りつつある。これが問題の根本だ。
このエラーが発生する原因
RedisがERR offset is out of rangeを返すのは、2つの異なるケースがある:
- SETBIT / GETBIT / BITCOUNT / BITPOS: ビットオフセットが
2^32 - 1(4,294,967,295)を超えた場合。これは512MBの文字列に相当する。負のオフセットも同様にエラーとなる。 - SETRANGE / GETRANGE: オフセットが負の値、またはその結果として生成される文字列がRedisの512MB上限を超える場合。
数字で整理すると: ビットオフセット4,294,967,295は、ちょうど512MBのストレージを必要とする。Redisはすべての文字列を512MBに制限しており、ビットマップも内部的には文字列に過ぎない。その上限を1ビットでも超えた瞬間にこの壁に当たる。
デバッグの手順
Step 1: redis-cliで再現する
redis-cliを開き、境界値の挙動を確認する:
# 上限ギリギリのオフセット — 正常に動作する
SETBIT dau:2025-01-15 4294967294 1
# (integer) 0
# 上限を1つ超えたオフセット — エラーが発生する
SETBIT dau:2025-01-15 4294967296 1
# (error) ERR offset is out of range
# 負のオフセット — 常に無効
SETBIT dau:2025-01-15 -1 1
# (error) ERR offset is out of range
# SETRANGEで512MBを超える場合
SETRANGE mykey 536870912 "x"
# (error) ERR offset is out of range
Step 2: コードが送信している実際のオフセットを特定する
Redisのエラーメッセージにはオフセット値が含まれない — Redisへの呼び出しが届く前に、自分でキャプチャする必要がある。失敗している処理にデバッグログを追加してラップしよう:
# Python (redis-py)
import redis
import logging
r = redis.Redis(host='localhost', port=6379)
def track_user_active(date_key, user_id):
logging.debug(f"SETBIT {date_key} offset={user_id}")
r.setbit(date_key, user_id, 1)
// Node.js (ioredis)
async function trackUserActive(dateKey, userId) {
console.debug(`SETBIT ${dateKey} offset=${userId}`);
await redis.setbit(dateKey, userId, 1);
}
失敗しているリクエストを再現してオフセットをキャプチャする。user_idが5000000000のような値であれば、根本原因が判明した — 4,294,967,295というビットマップの上限を超えている。
Step 3: オフセットの発生源を調べる
範囲外のオフセットは、ほぼ必ずこのいずれかに起因する:
- 40億を超えてしまったデータベースの自動インクリメントID
- ミリ秒単位のUNIXタイムスタンプ(現在は約1.75兆 — 上限をはるかに超えている)
- 負の値を生成する演算(例: より小さな数値からベースオフセットを引く場合)
- 範囲チェックなしで直接渡される64ビット整数
# 実際のIDの値を確認する
python3 -c "import time; print(int(time.time() * 1000))" # ミリ秒タイムスタンプ
# 1736899200000 ← これは1.7兆で、上限を大幅に超えている
python3 -c "print(int(time.time()))" # 秒タイムスタンプ
# 1736899200 ← これは問題なし、42.9億未満
解決策
Option 1: Redisを呼び出す前にオフセットを検証する(即効性のある修正)
アプリケーションコードにガード処理を追加し、有効な範囲外の値はRedisに到達する前に拒否する:
# Python
MAX_BIT_OFFSET = 2**32 - 1 # 4,294,967,295
def track_user_active(date_key, user_id):
if user_id MAX_BIT_OFFSET:
raise ValueError(f"user_id {user_id} is out of Redis bitmap range (0-{MAX_BIT_OFFSET})")
r.setbit(date_key, user_id, 1)
// Node.js
const MAX_BIT_OFFSET = 4294967295;
async function trackUserActive(dateKey, userId) {
if (userId MAX_BIT_OFFSET) {
throw new RangeError(`userId ${userId} is out of Redis bitmap range`);
}
await redis.setbit(dateKey, userId, 1);
}
Option 2: 大きなビットマップをセグメント分割する(40億超えのユーザーIDに対応)
ユーザーIDが実際に40億を超えている場合は、ビットマップをセグメントに分割する。各セグメントキーは最大4,294,967,295件のIDの範囲をカバーする:
# Python — セグメント分割ビットマップ
SEGMENT_SIZE = 4_294_967_295
def track_user_active_segmented(date_key, user_id):
segment = user_id // SEGMENT_SIZE
local_offset = user_id % SEGMENT_SIZE
key = f"{date_key}:seg:{segment}"
r.setbit(key, local_offset, 1)
def is_user_active_segmented(date_key, user_id):
segment = user_id // SEGMENT_SIZE
local_offset = user_id % SEGMENT_SIZE
key = f"{date_key}:seg:{segment}"
return bool(r.getbit(key, local_offset))
def count_active_users(date_key, num_segments=10):
total = 0
for seg in range(num_segments):
key = f"{date_key}:seg:{seg}"
total += r.bitcount(key)
return total
トレードオフとして: BITCOUNTはセグメントキーをまたいで反復処理が必要になる。セグメント数の上限が既知であれば、追加の呼び出しコストはわずかで、ほとんどの本番ワークロードで許容範囲内だ。
Option 3: HyperLogLogを使ったカーディナリティ推定
ユーザーごとの検索は不要で、近似ユニーク数(誤差約0.81%以内)だけが必要な場合、HyperLogLogはオフセット問題を根本的に回避できる:
# オフセット不要 — ユーザーIDを要素として追加するだけ
PFADD dau:2025-01-15 "user:5000000000" "user:8000000000"
# (integer) 1
PFCOUNT dau:2025-01-15
# (integer) 2
# カーディナリティに関わらず最大12KBのメモリ使用量
DEBUG OBJECT dau:2025-01-15
# serializedlength:304 ← 非常に小さい
Option 4: ユーザーIDを有界なハッシュ空間にマッピングする
各ユーザーIDを固定サイズのビットマップ範囲にハッシュする。IDが疎らに分布しており、予測可能なメモリ使用量が必要な場合に最適:
# Python — 固定範囲へのハッシュ
import hashlib
BITMAP_SIZE = 10_000_000 # 1000万スロット、DAUスケールに合わせて調整
def track_user_active_hashed(date_key, user_id):
# 決定論的ハッシュ — 同じuser_idは常に同じビットにマッピングされる
h = int(hashlib.sha256(str(user_id).encode()).hexdigest(), 16)
offset = h % BITMAP_SIZE
r.setbit(date_key, offset, 1)
注意: ハッシュの衝突により、異なるユーザーが同じビットにマッピングされる可能性があるため、BITCOUNTは正確な数ではなく下限値を返す。DAUの概算ダッシュボード用途であれば許容範囲だ。
Option 5: SETRANGEに安全なオフセット上限を設ける
ユーザー入力や計算された位置から派生したオフセットを使ったSETRANGEでエラーが発生している場合:
# Redisの文字列最大サイズ: 512MB = 536,870,912バイト
MAX_STRING_OFFSET = 536_870_911
def safe_setrange(key, offset, value):
if offset MAX_STRING_OFFSET:
raise ValueError(f"SETRANGE offset {offset} out of range (0-{MAX_STRING_OFFSET})")
r.setrange(key, offset, value)
修正の確認
修正をデプロイしたら、以下のサニティチェックを実行する:
# 有効な境界値でテスト
SETBIT dau:test 4294967294 1
# (integer) 0 ← 成功
# キーが期待どおりのサイズで存在することを確認
STRLEN dau:test
# (integer) 536870912 ← ちょうど512MB(4294967295ビットのビットマップ)
# BITCOUNTが正常に動作することを確認
BITCOUNT dau:test
# (integer) 1
# ビットがセットされていることを確認
GETBIT dau:test 4294967294
# (integer) 1
# テストキーを削除
DEL dau:test
# (integer) 1
エラー監視ダッシュボードを確認しよう — ERR offset is out of rangeのエントリが静かになるはずだ。10〜15分の実トラフィックがあれば、修正が有効であることを確認するのに十分だ。
得られた教訓
このエラーはほぼ常に、Redisビットマップを無限配列のように扱うことから生じる。ビットマップは512MBが上限であり、例外はない。
- 外部から受け取った生のIDをビットマップのオフセットとして直接使用しないこと。境界チェックは必須だ。ユーザーID、注文ID、タイムスタンプ(特にミリ秒タイムスタンプ)は40億を超える可能性が十分にある。
- ステージング環境ではこの問題は検出できない。ステージングのIDプールが小さい場合はなおさらだ。このバグは本番環境でIDが42.9億のしきい値を超えたときにしか表面化しない。初日からコードに境界チェックを追加しておくこと。
- ミリ秒タイムスタンプをオフセットとして使うのは罠だ。 ミリ秒単位のUNIX時刻はすでに約1.75兆 — 秒単位(2026年時点で約17.5億、さらに約80年のヘッドルームがある)か、より小さな派生値を使うこと。
- 負のオフセットは常に無効だ。 演算によってオフセットを計算する場合(例: ベース値を引く)、すべてのRedisビット操作の前に明示的な
>= 0チェックを追加すること。 - ユーザーベースが拡大している場合、ビットマップのセグメント分割を最初から設計に組み込むこと — IDが40億を超えてから後付けで対応するのは、プレッシャーの中でのデータ移行を意味する。

