Python の set vs Redis の Set 型(分散環境での集合)

Python の set はプロセス内で完結するインメモリのデータ構造だ。一方、Redis の Set は分散環境で複数のプロセスやサーバー間で共有できる。この違いがアーキテクチャ選択に大きく影響する。

基本的な違い

Python set

単一プロセス内のメモリに存在。プロセス終了で消失。高速だがスケールしない。

Redis Set

外部サーバーに存在。永続化可能。ネットワーク越しにアクセス。水平スケール可能。

Redis はデータベースとして機能し、Python set は単なるデータ構造だ。

Redis Set の基本操作

redis-py を使った基本操作を見る。

import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# 要素の追加(SADD)
r.sadd('fruits', 'apple', 'banana', 'cherry')

# 存在確認(SISMEMBER)
print(r.sismember('fruits', 'apple'))   # True
print(r.sismember('fruits', 'grape'))   # False

# 全要素取得(SMEMBERS)
print(r.smembers('fruits'))  # {'apple', 'banana', 'cherry'}

# 要素数(SCARD)
print(r.scard('fruits'))  # 3

# 削除(SREM)
r.srem('fruits', 'banana')

操作名は異なるが、Python の set とほぼ対応している。

集合演算

Redis でも和集合、積集合、差集合ができる。

import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

r.sadd('set_a', 1, 2, 3, 4)
r.sadd('set_b', 3, 4, 5, 6)

# 積集合(SINTER)
print(r.sinter('set_a', 'set_b'))  # {'3', '4'}

# 和集合(SUNION)
print(r.sunion('set_a', 'set_b'))  # {'1', '2', '3', '4', '5', '6'}

# 差集合(SDIFF)
print(r.sdiff('set_a', 'set_b'))   # {'1', '2'}

結果を新しいキーに保存する SINTERSTORE, SUNIONSTORE, SDIFFSTORE もある。

# 結果を別のキーに保存
r.sinterstore('set_intersection', 'set_a', 'set_b')
print(r.smembers('set_intersection'))  # {'3', '4'}

パフォーマンス特性

Redis へのアクセスはネットワーク越しになるため、レイテンシが発生する。

import redis
import time

r = redis.Redis(host='localhost', port=6379)

# ローカル set
local_set = set(range(10000))
start = time.perf_counter()
for i in range(1000):
    i in local_set
print(f"Python set: {time.perf_counter() - start:.4f}s")

# Redis Set(事前に SADD 済み)
start = time.perf_counter()
for i in range(1000):
    r.sismember('redis_set', i)
print(f"Redis Set: {time.perf_counter() - start:.4f}s")

ローカルの set はマイクロ秒単位だが、Redis は往復で数百マイクロ秒〜数ミリ秒かかる。単純な速度では Python set が圧倒的だ。

パイプラインによる最適化

複数のコマンドをまとめて送ることで、ネットワークのオーバーヘッドを削減できる。

import redis

r = redis.Redis(host='localhost', port=6379)

# パイプラインなし(遅い)
for i in range(1000):
    r.sadd('slow_set', i)

# パイプラインあり(速い)
pipe = r.pipeline()
for i in range(1000):
    pipe.sadd('fast_set', i)
pipe.execute()

パイプラインを使えば、1000 回の往復が 1 回になる。

Redis Set の強み

Python set では実現しにくい機能がある。

永続化

RDB スナップショットや AOF でディスクに保存。再起動後もデータが残る。

TTL(有効期限)

EXPIRE でキーに有効期限を設定。セッション管理やキャッシュに有用。

分散アクセス

複数のアプリケーションサーバーから同じ Set にアクセス可能。

import redis

r = redis.Redis(host='localhost', port=6379)

# 有効期限付きの Set
r.sadd('session:user123', 'token_abc')
r.expire('session:user123', 3600)  # 1時間後に自動削除

ランダム要素の取得

Redis には set からランダムに要素を取得する機能がある。

import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

r.sadd('lottery', 'alice', 'bob', 'charlie', 'dave', 'eve')

# ランダムに 1 つ取得(削除しない)
print(r.srandmember('lottery'))

# ランダムに 2 つ取得
print(r.srandmember('lottery', 2))

# ランダムに 1 つ取得して削除(SPOP)
print(r.spop('lottery'))

Python の set で同様のことをするには、一度リストに変換して random.choice() を使う必要がある。

スキャンによる大規模 Set の走査

巨大な Set をイテレートするには SSCAN を使う。

import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# 大量のデータを追加
for i in range(100000):
    r.sadd('huge_set', f'item:{i}')

# SSCAN でカーソルベースの走査
cursor = 0
count = 0
while True:
    cursor, items = r.sscan('huge_set', cursor, count=1000)
    count += len(items)
    if cursor == 0:
        break

print(f"Total items: {count}")

SMEMBERS は全要素を一度に返すため、巨大な Set では Redis をブロックしてしまう。SSCAN なら少しずつ取得できる。

Sorted Set との違い

Redis には順序付きの Sorted Set(ZSET)もある。

import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# Sorted Set(スコア付き)
r.zadd('ranking', {'alice': 100, 'bob': 85, 'charlie': 92})

# スコア順で取得
print(r.zrange('ranking', 0, -1, withscores=True))
# [('bob', 85.0), ('charlie', 92.0), ('alice', 100.0)]

# スコアでフィルタ
print(r.zrangebyscore('ranking', 90, 100))
# ['charlie', 'alice']

Python の set にはスコアの概念がなく、heapq や別のデータ構造が必要になる。

使い分けの指針

要件Python setRedis Set
単一プロセス内で完結
複数サーバーで共有×
永続化が必要×
ミリ秒未満のレイテンシ×
数百万要素の存在確認

典型的なパターンとして、ローカルでの高速処理には Python set を使い、プロセス間・サーバー間での共有や永続化が必要な場合に Redis Set を使う。

import redis

# ハイブリッドアプローチ
class HybridSet:
    def __init__(self, redis_key):
        self.redis = redis.Redis(host='localhost', port=6379)
        self.key = redis_key
        self.local_cache = None
    
    def load(self):
        """Redis から読み込んでローカルキャッシュ"""
        self.local_cache = set(self.redis.smembers(self.key))
    
    def __contains__(self, item):
        """高速な存在確認"""
        if self.local_cache is not None:
            return item in self.local_cache
        return self.redis.sismember(self.key, item)
    
    def add(self, item):
        """両方に追加"""
        self.redis.sadd(self.key, item)
        if self.local_cache is not None:
            self.local_cache.add(item)

このようにローカルキャッシュと Redis を組み合わせることで、読み取りは高速に、書き込みは永続化されるという両方の利点を得られる。