こんにちは。テクニカルリードの私だ。
今日のコードレビューで、また「ただ動くだけの素朴なデータ構造」に遭遇したので、少し時間をもらって話をしよう。
テーマは 「Redis Hash型:基本と実務設計の極意」 だ。
多くのジュニアエンジニアや、RDB脳から抜け出せていない開発者は、オブジェクトをRedisに保存する際に何も考えずこう実装する。
`SET user:1000 ‘{“id”:1000,”name”:”Alice”,”role”:”admin”}’`
おいおい、待ってくれ。JSON文字列として突っ込むのは簡単だが、それではRedisの強みの半分も活かせていない。「特定のフィールドだけを更新したい」「メモリ効率を極限まで高めたい」と思ったとき、JSONシリアライズは悪手になる。
今回は、Redisの隠れた(いや、王道の)主役である Hash型 を取り上げ、実務の現場で泥をかぶらないための堅牢な設計と操作の全貌を叩き込む。
—
なぜ、オブジェクトに Hash型 を選ぶべきなのか?
RedisのString型にJSONを突っ込むと何が起きるか?
例えば、ユーザーの `role` だけを `admin` から `user` に変更したい場合、アプリ側でJSONをパチパチとパースし、書き換えて、再び全体を `SET` しなければならない。これはCPUの無駄であり、並行処理時に致命的な競合(Race Condition)を招く。
Hash型を使えば、フィールド単位(例: `role` だけ)の原子的な(Atomicな)読み書きが可能になる。さらに、Redis内部のメモリ効率化機構(ziplist / listpack)の恩恵を受け、小さなハッシュであればメモリフットプリントを極限まで圧縮できる。
まずは基本のコマンドを、実務的な文脈とともに総ざらいしよう。
—
実務で使うHashコマンドの核心
1. HSET / HGET: 単一フィールドの支配
基本中の基本だが、引数の順序や戻り値の意味を正確に理解しているか?
ユーザープロファイルの初期化(HSETは複数のフィールドを同時に叩ける)
127.0.0.1:6379> HSET user:1000 name “Alice” email “alice@example.com” role “engineer”
(integer) 3
戻り値は「新しく追加された」フィールドの数。既存の更新なら0になる。
特定のフィールドだけをピンポイントで取得
127.0.0.1:6379> HGET user:1000 email
“alice@example.com”
> architect’s note:
> Redis 4.0以降、`HMSET` は非推奨(Deprecated)となり、`HSET` が複数のフィールド/値をネイティブで受け付けるようになった。古いチュートリアルに惑わされて `HMSET` を使うのは今すぐやめろ。コードベースを綺麗に保つのもプロの仕事だ。
2. HMGET: バッチ取得によるネットワークラウンドトリップの削減
実務で最も頻繁に書くことになるのがこれだ。必要な属性だけを1回の通信でごっそり引く。
必要なプロパティだけを配列で一網打尽にする
127.0.0.1:6379> HMGET user:1000 name role
1) “Alice”
2) “engineer”
N+1問題と同様に、Redisに対しても細かく何度もコマンドを投げるのは愚の骨頂。必要なフィールドは `HMGET` で一括取得するのが鉄則だ。
3. HDEL & HLEN: ライフサイクル管理
一時的なフラグフィールドの削除
127.0.0.1:6379> HDEL user:1000 temporary_token
(integer) 1
ハッシュが保持するフィールド総数の確認
127.0.0.1:6379> HLEN user:1000
(integer) 3
—
実践:オブジェクトのシリアライズ保存とアンチパターン
アプリケーションのエンティティ(例: ユーザー、セッション、設定値)をHashとしてどうマッピングするか。ここが設計の分かれ道だ。
悪い設計(Antiptern)
すべての値をJSONにして一つのフィールドに突っ込む。
`HSET user:1000 profile ‘{“name”:”Alice”, …}’`
これではString型にJSONを入れているのと何ら変わらない。Hashのメリットをドブに捨てている。
正しい設計(Best Practice)
エンティティのプロパティをそのままRedis Hashのフィールドに展開する。
Python (redis-py) による実務的なリポジトリ層のイメージ
class UserRepository:
def __init__(self, redis_client):
self.redis = redis_client
def save_user(self, user_id: int, user_data: dict):
key = f”user:{user_id}”
# dictをそのまま展開してHSETへ渡す(パイプラインやhsetメソッドを使用)
# ※ 値はすべて文字列またはバイト列である必要がある点に注意
self.redis.hset(key, mapping=user_data)
# 必要であれば有効期限(TTL)を設定しておく
self.redis.expire(key, 86400)
def get_user_property(self, user_id: int, fields: str):
key = f”user:{user_id}”
return self.redis.hmget(key, fields)
ここで一つ、シリアライズにおける最大の罠を共有しておこう。
「RedisのHashのフィールドと値は、原則として文字列(String)またはバイト列である」 という点だ。
RDBのように、真のBoolean型やInteger型、NestedなJSON構造をそのままHashのバリューに突っ込むと、言語のクライアントライブラリによっては勝手に文字列化され、取得時に型が狂う(例: Pythonで `True` が `”True”` という文字列になる)。
- 対策:
- フラグや数値は文字列の `”1″ / “0”` や数値文字列として規約を統一する。
- どうしてもネストしたオブジェクト(住所録のリストなど)を持たせたい場合は、その部分だけをJSONシリアライズしてHashの1つのフィールド(例: `address_json`)に格納する「ハイブリッド戦略」を取る。すべてをHashに展開しようとして無理をしないこと。
—
パフォーマンス上の注意点(O(N) の悪夢)
Redisのコマンドリファレンスを読めばわかるが、Hash型には `HGETALL` という、全フィールドと値を丸ごと引っこ抜く魅惑的なコマンドが存在する。
「とりあえず `HGETALL` で全取得して、アプリ側で処理すれば楽じゃん」と思ったそこのあなた。今すぐその考えを捨てなさい。
- `HGETALL` の時間計算量は $O(N)$($N$ はフィールドの数)。
- もし、そのHashが数千、数万のフィールド(あるいは巨大な文字列)を持つモンスターデータに成長した場合、`HGETALL` はRedisのシングルスレッドイベントループを完全にブロック(Stall)する。
- 結果、他のすべてのクライアントからのリクエストがタイムアウトし、システム全体が雪崩式にダウンする。
ルール:
1. フィールド数が予測できない、または無限に増える可能性のあるデータをHashに格納するな。
2. 特定のフィールドだけが必要なら、必ず `HMGET` を使え。
3. どうしても全取得が必要なユースケース(かつフィールド数が多い)なら、`HSCAN` を用いてカーソルベースでイテレートしろ。
—
結びにかえて
RedisのHash型は、単なる「連想配列の保存場所」ではない。
RDBのテーブル行(Row)をメモリ上に超高速で展開し、部分的な更新やアトミックなインクリメント(`HINCRBY` など)をこなすための極めて強力な武器だ。
明日のコードレビューでは、君たちが書いたコードの中で「JSONの丸ごと突っ込み」や「無計画な `HGETALL`」が使われていないことを期待している。
正しいデータ構造の選択こそが、スケールするシステムの土台だ。
それでは、良い設計を。
コメント