RedisのSETRANGEおよびビットマップ操作で発生する「ERR offset is out of range」を修正する

intermediate🔴 Redis2026-07-07| Redis 6.x / 7.x on Linux (Ubuntu, Debian, CentOS), any platform running Redis

Error Message

ERR offset is out of range
#redis#string#bitmap#setrange

状況

深夜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億を超えてから後付けで対応するのは、プレッシャーの中でのデータ移行を意味する。

Related Error Notes