Redisのメモリ解放における隠された罠:DELとUNLINKの境界線
テックリードの私だ。今日のコードレビューで、あるジュニアエンジニアが書いたこんなコードを見かけた。
セッションの有効期限切れに伴う一括削除処理
redis_client.delete(expired_session_keys)
私は即座に「待った」をかけた。このコード、数百万件規模のセッションを抱える本番環境であれば、数秒間のRedisの完全停止(ブロッキング)を引き起こし、下流のAPI群を全滅させるポテンシャルを秘めている。
Redisの運用において、データを「書き込む」技術と同じくらい、あるいはそれ以上に重要なのがデータを「消す」技術だ。今回は、キー削除の二大巨頭である `DEL` と `UNLINK` のアーキテクチャの深層に切り込み、実務でどちらを選択すべきかをロジカルに伝授する。
—
1. 内部アーキテクチャ:なぜ `DEL` はシステムを殺すのか
まず、Redisの根本的なアーキテクチャを思い出してほしい。Redisは基本的にシングルスレッドで動作する。クライアントからのコマンドは単一のイベントループで直列に処理される。
ここで `DEL` コマンドが実行されたときの挙動を見てみよう。
1. キーの削除: `Keyspace`(インデックスを管理するハッシュテーブル)から指定されたキーを即座に外す。
2. メモリの解放 (Deallocation): ここが最大のボトルネックだ。キーに紐づく値(Value)のデータ構造が小さければ問題ない。しかし、その値が「数百万要素を持つ Sorted Set」「数万エントリを持つ巨大な Hash」「数MBの String」だったらどうなるか?
`DEL` は、このメモリの解放処理をすべてメインスレッドの同期処理として実行する。
メモリのアロケータ(jemallocなど)が巨大なメモリチャンクをOSに返却し、内部のポインタを整理する作業が完了するまで、Redisのメインスレッドは完全にブロックされる。つまり、次のコマンドは1ミリ秒も処理されない。これが、いわゆる「Redisが突如として無反応になる現象」の主犯格の一つだ。
—
2. 救世主 `UNLINK` のメカニズム
このシングルスレッドの呪縛を断ち切るために、Redis 4.0で導入されたのが `UNLINK` コマンドだ。
`UNLINK` は、一見すると `DEL` と全く同じ挙動をする(キーは即座にクライアントから見えなくなる)。しかし、内部の動きは根本的に異なる。
1. 非同期解放(Lazy Free): `Keyspace` からキーを外し、そのメモリ領域の解放処理(Deallocation)を、メインスレッドとは別のバックグラウンドスレッド(Bio threads: Background I/O threads)に投げ捨てる。
2. メインスレッドの解放: メインスレッドはメモリ解放の重い処理を待つことなく、即座に次のクライアントからのリクエスト処理に戻る。
どのデータ構造で効果を発揮するか?
すべてのケースで `UNLINK` が必要なわけではない。以下の条件に当てはまる場合、`UNLINK` は必須の選択肢となる。
- `String`: サイズが数MBを超えるような巨大な文字列(例: キャッシュされた巨大なJSONやバイナリ)
- `List`: 要素数が10,000を超えるようなリスト
- `Hash` / `Set` / `Zset`: 要素数が数千以上のコレクション
逆に、数個のフィールドを持つ Hash や、単一のプリミティブな文字列であれば、`DEL` とのパフォーマンス差は誤差の範囲内だ。しかし、実務の現場では「いつの間にかデータが肥大化している」という事故が起きる。そのため、「原則としてキー削除は `UNLINK` をデフォルトとする」という方針をチーム内で敷くのが、シニアなアーキテクチャ設計と言える。
—
3. 実務での検証:パフォーマンスの差を体感する
百聞は一見にしかず。Redisのコンソール(`redis-cli`)で、巨大なデータ構造を作った際の挙動をイメージしてみよう。
100万個のメンバーを持つ巨大な Set を作成
> SADD big_set:2023 0 1 2 … (100万回)
(integer) 1000000
【アンチパターン】DEL で削除(メインスレッドがブロックされる)
> TIME
1672531200.123456
> DEL big_set:2023
(integer) 1
> TIME
1672531201.345678 # <- およそ 1.2秒間、他のリクエストが一切処理されなかった!
---
再び100万個のメンバーを持つ巨大な Set を作成
> SADD big_set:2024 0 1 2 … (100万回)
(integer) 1000000
【ベストプラクティス】UNLINK で削除(非同期で解放)
> TIME
1672531300.123456
> UNLINK big_set:2024
(integer) 1
> TIME
1672531300.124123 # <- わずか数ミリ秒で制御が戻る!メモリ解放は裏で安全に処理される。
この「1.2秒の停止」が、高スループットを要求されるマイクロサービスの環境で何を意味するか。接続プールの枯渇、タイムアウトの嵐、そして雪崩式に発生するアラートだ。
---
4. 堅牢な設計パターンと運用上の注意点
アーキテククトとして、コードレビュー時にチェックすべきポイントをまとめる。
① アプリケーションコードのORM/クライアント設定
使用している言語のRedisクライアント(Pythonなら `redis-py`、Node.jsなら `ioredis`、Javaなら `Jedis` / `Lettuce` など)が、デフォルトでどちらのコマンドを発行しているか確認せよ。
多くのクライアントライブラリでは、`delete` や `del` というメソッド名であっても、設定によって `UNLINK` に置き換えられる機能(あるいはその逆)を持つ場合がある。常に最新のライブラリ仕様を把握し、可能であれば明示的に `unlink` を叩くラッパーを設計すべきだ。
② Redisサーバー側の自動削除設定(Lazyfree)
Redisの `redis.conf` には、明示的に `UNLINK` を使わなくても、特定の状況下で自動的にバックグラウンド解放を行う設定が存在する。
メモリ上限(maxmemory)に達した際、evictionポリシーに基づいてキーを削除する挙動
lazyfree-lazy-eviction yes
EXPIREなどによる有効期限切れでキーを削除する挙動
lazyfree-lazy-expire yes
RDB/AOFのロード時に古いデータをクリアする挙動
lazyfree-lazy-server-del yes
ユーザーが明示的に DEL を叩いた際も、内部的に UNLINK として扱う挙動
(注意:レガシーな互換性のためにあえてオフにしているケースもある)
lazy-free-user-del no
本番環境の堅牢性を高めるため、少なくとも `lazyfree-lazy-eviction` と `lazyfree-lazy-expire` は `yes` に設定されていることをインフラチームと確認しておこう。これにより、TTL切れの瞬間に突然メインスレッドがフリーズする悲劇を防げる。
—
結び:プロフェッショナルとしての誇り
「たかがキーを消すコマンド」と侮るなかれ。
大規模システムにおける障害の多くは、こうした「基礎的なコマンドの特性への無理解」から静かに、そして確実に発生する。
今日から君たちのプロジェクトにおけるRedis削除処理はすべて `UNLINK`(あるいはそれに準ずる非同期削除戦略)をファーストチョイスにせよ。コードレビューで `DEL` が安易に使われていたら、この知見をもとに容赦なく差し戻すことだ。
それこそが、システムを極限まで安定させるエンジニアの矜持である。
コメント