六つのデータ構造とその居場所
一言でいうと
Redisを上手に使うとは、コマンドをたくさん知っていることではなく、問題に合ったデータ構造を選ぶことです。
なぜ必要なのか
多くのチームがRedisをStringだけで使っています。すべてJSONにシリアライズしてSETし、GETします。動作はします。ところが、ユーザープロフィールのメールアドレス1フィールドだけを変えたいときも、全体を読んでパースし、修正して書き直します。同時に2つのリクエストがそうすると、片方が上書きします。
Hashを使えばHSET user:42 email a@b.comの1行で済み、ほかのフィールドと衝突しません。データ構造の選択1つが、競合状態をなくします。
どう動くのか
Stringは単純な値とカウンターです。INCRがアトミックだという点が重要です。閲覧数、レートリミットのカウンター、シーケンス番号に使います。SET key val NX EX 60は、分散ロックの基本形です。
Hashはフィールドが複数あるオブジェクトです。フィールド単位の更新と取得ができ、フィールドが少なければRedisが内部で圧縮されたエンコーディングを使うので、メモリも節約できます。
Listは順序のあるリストで、両端への挿入・削除がO(1)です。ジョブキューと最近のアクティビティ一覧に使います。真ん中へのアクセスはO(N)なので、大きなリストをインデックスで探してはいけません。
Setは重複のない集合で、積集合・和集合・差集合がコマンド1つです。タグ、フォロワー、重複排除に使います。SADDの戻り値で「初めて見るものか」がわかるので、冪等性の実装にも使われます。
ZSet(ソート済み集合)は、スコアを持つ集合です。スコア順に並んだ状態が常に保たれ、順位の取得がO(log N)です。ランキング、優先度キュー、時系列ウィンドウ(スコアをタイムスタンプにして)に使います。このコースでラボを1つ丸ごと割くほど重要です。
Streamは追記専用のログです。コンシューマーグループと確認応答(ack)があり、本物のキューに最も近いです。前のコースで扱いました。
現場での姿
運用でいちばん気をつけるコマンドはKEYSです。キースペース全体を走査し、その間Redisはほかの仕事ができません。Redisは単一スレッドでコマンドを処理するので、キーが数百万個あるインスタンスではKEYS *1回で、数秒の全面停止が起きます。必ずSCANをカーソルで回す必要があります。
同じ理由で、FLUSHALL、大きなコレクションに対するSMEMBERSやLRANGE 0 -1、DELで巨大なキーを消すこと(代わりにUNLINK)にも注意が必要です。「Redisが急に遅くなった」の多くは、O(N)のコマンド1つです。
メモリの観点も知っておく価値があります。MEMORY USAGE <key>でキーごとの実際の使用量が見られ、同じデータでも、データ構造によって何倍も差が出ます。
何を選ぶか決める表
| データ構造 | 使う場面 | 代表的なコマンド | 注意 |
|---|---|---|---|
| String | キャッシュ値、カウンター | SET、INCR |
512MBが上限 |
| Hash | オブジェクトのフィールドごとの更新 | HSET、HGETALL |
フィールドが多いとHGETALLが重い |
| List | キュー、最近のN件 | LPUSH、BRPOP |
途中への挿入・取得がO(n) |
| Set | 重複排除、タグ | SADD、SINTER |
大きな集合の積集合は高くつく |
| Sorted Set | ランキング、時間順のインデックス | ZADD、ZRANGEBYSCORE |
最も使い道が多い |
| Stream | イベントログ、コンシューマーグループ | XADD、XREADGROUP |
Listよりキューに向く |
Sorted Setは意外に幅広く使われます。 スコアをタイムスタンプにすると、時間範囲の取得ができ(ZRANGEBYSCORE)、古いものを消すのも簡単です(ZREMRANGEBYSCORE)。「直近24時間のイベント」のようなものをListで作ろうとすると、すぐ行き詰まります。
キューはListよりStream
Listでキューを作ると、BRPOPで取り出した瞬間にメッセージが消えます。処理中にコンシューマーが死ぬと、そのメッセージはなくなります。
Streamには、コンシューマーグループと確認(ack)の概念があります。
XADD orders * type payment amount 52000 # 발행
XREADGROUP GROUP workers w1 COUNT 10 STREAMS orders > # 읽기(pending 으로 표시)
XACK orders workers <id> # 처리 완료
XPENDING orders workers # 아직 확인 안 된 것
XCLAIM orders workers w2 60000 <id> # 죽은 소비자의 것을 가져오기
XPENDINGが核心です。 コンシューマーが死ぬと、そのメッセージはpendingに残り、ほかのコンシューマーがXCLAIMで引き取ります。Listでは、これを自分で作る必要があります。
ただしStreamも際限なく伸びるので、XADD ... MAXLEN ~ 100000のように上限を置きます。~を付けると近似的な切り詰めになり、はるかに安上がりです。
大きなキーが起こす問題
Redisは単一スレッドでコマンドを処理します。1つに時間がかかると、その間すべてが止まります。
KEYS * → O(n). 절대 쓰지 않는다
HGETALL (필드 10만 개) → 응답이 크고 오래 걸린다
SMEMBERS (원소 100만 개) → 같은 문제
DEL (큰 컬렉션) → 삭제도 O(n) 이다. UNLINK 를 쓴다
UNLINKは、削除をバックグラウンドに回して、ブロッキングを避けます。大きなキーを消すときは、これを使います。
大きなキーを見つけるのはredis-cli --bigkeysや--memkeysです。定期的に実行して、1つのキーが全体の大きな割合を占めていないかを確認します。
次のラボですること
6つのデータ構造を1つずつ自分で扱い、KEYSの代わりにSCANで走査し、最後に同じデータを別のデータ構造で保存したときのメモリの差を表にします。