【実務・中級編】 Set型:基本操作 – Redis

おい、コードレビューの手を止めてこっちを向いてくれ。

君たちが今書こうとしているその「ユーザーのタグ管理機能」や「重複を許さない一意なアクセストークンのブラックリスト」、本当にRDBのJOINで実装するつもりか? あるいは、アプリケーション層でわざわざユニーク制約の例外をハンドリングしているのか?

――ナンセンスだ。

Redisの Set型 を使えば、O(1)の爆速で「一意性」と「集合演算」を担保できる。今回は、RedisのSet型が持つ本質的なデータ構造のメカニズムから、実務の現場で絶対に踏んではならない地雷、そしてテックリードとして合格点を出せる堅牢な設計パターンまで、妥協なき知見を叩き込む。

耳の穴をかっぽじって聞いてくれ。

—

1. RedisのSet型が「ヤバい」理由:内部構造の深層

まず、アーキテククトとしてお前らに知っておいてほしいのは、RedisのSet型が内部でどうメモリを握っているかだ。ここを理解していないエンジニアは、本番環境でメモリを枯渇させる。

RedisのSet型は、要素数とデータ型に応じて2つの構造に動的にスイッチする。

1. Intset(整数値集合)

  • すべての要素が整数であり、かつ要素数が少ない場合(設定パラメータ `set-max-intset-entries`、デフォルト512件未満)、Redisはこれを連続したメモリ領域を持つ `Intset` として保持する。
  • ポインタを持たないためメモリ効率が極めて高く、CPUキャッシュヒット率も跳ね上がる。

2. HT (Hash Table / ディクショナリ)

  • 要素が文字列である場合、または要素数が上限を超えた場合、標準的なハッシュテーブルに昇格する。

つまり、「重複のない一意なID(ユーザーIDやSKUなど)」を扱う場合、Set型はRDBのB-Treeインデックスとは比較にならないほどの省メモリかつ高速なルックアップを実現する。

—

2. 基本操作の「正しい」流儀と計算量

今回のテーマである `SADD`, `SREM`, `SISMEMBER`, `SCARD`, `SMEMBERS`。それぞれのコマンドを、実務のコンテキストにおいてどう評価すべきか解説する。

| コマンド | 計算量 (Time Complexity) | 実務での評価と注意点 |
| :— | :— | :— |
| `SADD` | $O(N)$ ($N$ は追加する要素数) | 冪等性が担保される。複数追加も一撃。 |
| `SREM` | $O(N)$ ($N$ は削除する要素数) | 存在しない要素を指定してもエラーにならない(安全)。 |
| `SISMEMBER` | $O(1)$ | 最強の存在確認。 RDBの `SELECT 1 FROM … WHERE …` とはスピードが違う。 |
| `SCARD` | $O(1)$ | 集合の要素数(Cardinality)。メタデータとして常にO(1)で引ける奇跡。 |
| `SMEMBERS` | $O(N)$ ($N$ は集合の全要素数) | 【危険ドラッグ】 本番の巨大なSetに対してこれを撃つな。 |

実際の操作例(Redis CLI)

1. SADD: ユーザー1001, 1002を本日の「アクティブ・セッション集合」に追加
127.0.0.1:6379> SADD active:users:2023-10-27 1001 1002 1003
(integer) 3 # 新規に追加された要素の数を返す(1003が追加された)

2. SISMEMBER: ユーザー1002がアクティブか即座に確認
127.0.0.1:6379> SISMEMBER active:users:2023-10-27 1002
(integer) 1 # 存在する(True)

3. SCARD: 現在のアクティブユーザー数をO(1)で取得
127.0.0.1:6379> SCARD active:users:2023-10-27
(integer) 3

4. SREM: ログアウトしたユーザー1002を削除
127.0.0.1:6379> SREM active:users:2023-10-27 1002
(integer) 1 # 削除成功

5. SMEMBERS: 全要素の取得(※後述の注意点に魂を刻め)
127.0.0.1:6379> SMEMBERS active:users:2023-10-27
1) “1001”
2) “1003”

—

3. 【重要】チーフアーキテクトからの警告:SMEMBERSの呪縛

コードレビューをしていて、一番絶望するのがこれだ。

> 「全ユーザーに一斉通知を送るために、`SMEMBERS` でSetの全要素を取ってきてアプリケーション側でループ回してます!」

今すぐそのコードを消せ。

Redisはシングルスレッドで動作する。`SMEMBERS` は集合の要素数 $N$ に比例して $O(N)$ の時間がかかる。もし、そのSetに100万件の要素が入っていたらどうなるか?
Redisのイベントループがその瞬間完全にブロックされ、他のすべてのクライアントからのリクエストが数ミリ秒〜数十ミリ秒間、完全にフリーズする。これが世に言う「Redisのブロック障害」だ。

正しいアプローチ:SSCANを使え

数千件を超えるかもしれない集合の要素を走査したい場合は、必ずカーソルベースのインクリメンタルなイテレーションである `SSCAN` を使え。

初期カーソル 0 から、マッチパターンなしで、およそ100件ずつスキャン
127.0.0.1:6379> SSCAN active:users:2023-10-27 0 COUNT 100
1) “42” # 次回使用するカーソル位置(0になれば走査完了)
2) 1) “1001”
2) “1003”
…

プログラミング言語のドライバ(Goのgo-redisやPythonのredis-pyなど)では、大抵イテレータとしてラップされている。自前で愚直に `SMEMBERS` を呼ぶ実装を書いたら、容赦なくプルリクエストをRejectする。

—

4. 実務で使える堅牢な設計パターン:「既読管理システム」

理論はこのくらいにして、実務でそのまま使える設計パターンを提示しよう。
「ユーザーごとのメッセージ既読管理」だ。RDBでこれをやると `MessageReadStatus` テーブルが爆発し、数億レコードのJOIN地獄が待っている。

設計思想

  • キー構造: `read:messages:{message_id}`
  • 値: 既読をつけた `user_id` の Set

実装イメージ(疑似コード / Python)

import redis

client = redis.Redis(host=’localhost’, port=6379, decode_responses=True)

def mark_as_read(message_id: int, user_id: int):
“””
メッセージを既読にする。
SADDは何度呼んでも安全(冪等性)。
“””
key = f”read:messages:{message_id}”
# O(1) で追加
client.sadd(key, user_id)
# 負荷対策として、既読データは30日後に自動消滅させる(TTL設定)
client.expire(key, 86400 30)

def check_is_read(message_id: int, user_id: int) -> bool:
“””
特定ユーザーが既読かチェックする。
“””
key = f”read:messages:{message_id}”
# O(1) で爆速判定
return client.sismember(key, user_id)

def get_read_count(message_id: int) -> int:
“””
総既読者数を取得する。
“””
key = f”read:messages:{message_id}”
# O(1) で要素数を取得
return client.scard(key)

この設計の美しいところは、「誰が読んだか(一意なユーザーIDの保持)」と「何人が読んだか(SCARD)」を、追加の集計クエリを発行することなく、ミリ秒単位で完全に両立できる点にある。

—

アウトロ

RedisのSet型は、単なる「重複を許さないリスト」ではない。
メモリの物理レイアウト(Intset)まで意識し、適切なコマンド(`SISMEMBER` や `SSCAN`)を選択して初めて、システムのパフォーマンスを極限まで引き上げる「鋭利な刃物」となる。

次に君たちが「一意なデータの管理どうしよう?」と悩んだとき、RDBのユニーク制約を開く前に、RedisのSet型を思い出してほしい。

設計に妥協するな。コードは君のプライドだ。
次のプルリクエストを楽しみにしている。

コメント

タイトルとURLをコピーしました