•29 min read

Redisメモリ最適化:内部構造、データ構造エンコーディング、メモリプロファイリング

Redisメモリ最適化:内部構造、データ構造エンコーディング、メモリプロファイリング

あなたのRedisインスタンスは精巧に調整されたエンジンですが、アプリケーションがスケールするにつれて、そのメモリ消費はスポーツカーというより貨物列車のように見え始めます。クラウドの請求額は上昇し、OOM-killerの警告がログに溢れ、あなたは古典的なエンジニアリングの岐路に立たされます。より大きなインスタンスで費用をかけるか、複雑なシャーディングプロジェクトに着手するか。しかし、3番目の、より洗練された道があります。Redisのメモリ使用量を深く、外科的に最適化することです。これは単純なキー削除ポリシーの話ではありません。Redisが低レベルでデータを実際にどのように保存しているかを理解し、その動作を操作して、コアパフォーマンスを犠牲にすることなく、劇的なメモリ節約(しばしば70%以上)を達成することです。

Audio Briefing
0:00 / 0:00

Redisキーの解剖学:単なるデータ以上のもの

あなたがSET mykey "hello"すると、Redisは「hello」の5バイトだけを保存するわけではありません。Redisのすべてのキーは基本的なメタデータ構造に関連付けられており、値自体にも独自のオーバーヘッドがあります。このオーバーヘッドを理解することが、それを最小限に抑えるための第一歩です。

redisObject構造体:すべてのキーの根源

メインのRedis辞書(dict)内のすべてのキーは、データ(文字列、リストなど)を直接指しているわけではありません。それは、すべてのデータ型を格納する普遍的なコンテナであるredisObject構造体を指しています。C言語では、次のように見えます。

// A simplified representation of the redisObject struct
typedef struct redisObject {
    unsigned type:4;        // Type of the object (String, List, Hash, etc.)
    unsigned encoding:4;    // Internal representation (raw, embstr, listpack, etc.)
    unsigned lru:24;        // LRU/LFU clock value for eviction
    int refcount;           // Reference count for shared objects
    void *ptr;              // Pointer to the actual data
} robj;

64ビットシステムでは、この構造体だけで4 + 4 + 4 + 8 = 20バイトかかりますが、メモリのアライメントにより、通常は24バイトを占めます。これは、避けることのできないキーごとの固定オーバーヘッドです。これに、メインのキースペース辞書(dictEntry)からのオーバーヘッドが加わります。これには、キー、値(redisObject)、およびハッシュチェーン内の次のエントリへのポインタが含まれ、さらに24バイトが追加されます。

要点: どんなに小さな値を持つキーでも、1バイトのデータを保存する前に、約48バイトのベースラインオーバーヘッドがあります。数百万の小さなキー(例:SET user:123:online 1)を保存することは、この固定メタデータ税のためにメモリ肥大化の温床となります。

文字列エンコーディング:SDS、embstr、およびraw

Redisは標準のC言語のヌル終端文字列を使用しません。Simple Dynamic String(SDS)と呼ばれるカスタム構造体を使用します。この構造体はパフォーマンスと安全性にとって重要ですが、メモリに影響を与えます。

SDSヘッダー(sdshdr)は実際の文字列データの前に付加されます。

// Simplified sdshdr8 for strings up to 2^8-1=255 bytes long
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;     /* used */
    uint8_t alloc;   /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 lsb of unused bits */
    char buf[];      /* the actual string data */
};

lenは文字列の長さを追跡し(STRLENをO(1)操作にします)、allocは割り当てられたメモリを追跡し、毎回再割り当てすることなく効率的な追加を可能にします。

Redisには、redisObjectとSDS文字列を一緒に保存する方法について重要な最適化があります。

  1. OBJ_ENCODING_EMBSTR(埋め込み文字列): 小さな文字列(デフォルトで44バイト以下)の場合、RedisはredisObjectとSDS文字列の両方に対して単一の連続したメモリブロックを割り当てます。これにより、局所性が向上し、断片化が減少し、1回のmalloc呼び出しと関連するメタデータオーバーヘッド(通常8バイト)が節約されます。

  2. OBJ_ENCODING_RAW: 44バイトを超える文字列の場合、Redisは2つの別々の割り当てを実行します。1つはredisObject用、もう1つはSDS文字列(sdshdr + データ)用です。redisObjectのptrフィールドは、この2番目のメモリブロックを指します。

"locionic"のような10バイトの文字列のメモリを比較してみましょう。

  • embstrエンコーディング:
    • redisObject + sdshdr8 + "locionic" + \0 = 連続ブロック
    • 合計サイズ ≈ 24(アラインされたrobjサイズ) + 10(文字列) + 1(ヌル) ≈ 〜35バイト(アロケータが切り上げ)。
  • rawエンコーディング(embstrが存在しない場合):
    • redisObject = 24バイト
    • mallocのオーバーヘッド(ptr用) = 8バイト
    • sdshdr8 + "locionic" + \0 = 1 + 1 + 1 + 10 + 1 = 14バイト、アロケータによって16に切り上げ。
    • 合計サイズ = 24 + 8 + 16 = 48バイト。

この場合、embstrエンコーディングは約27%の節約になります。新しいRedisバージョンではembstrの制限を構成できますが、デフォルトの44はアロケータのブロックサイズに合わせて慎重に選択されています。

Advertisement

生データを超えて:コンパクトなデータ構造エンコーディング

Redisにおける最も大きなメモリ節約は、ハッシュ、リスト、セット、ソート済みセットに特化した、メモリ最適化されたエンコーディングを使用することによって実現されます。これらのコレクションが小さい場合、Redisはハッシュテーブルやスキップリストのようなメモリを大量に消費するデータ構造の使用を避け、フラットで連続したメモリレイアウトを採用します。

後継:Listpacks (Redis 7.0+)

何十年もの間、ziplistはメモリ最適化の主力でした。しかし、これには致命的な欠陥がありました。それはカスケード更新です。大きなziplistの途中に挿入または削除を行うと、後続のすべてのエントリをシフトする必要があり、最悪の場合O(N²)の複雑さにつながりました。

そこで登場したのがlistpackです。Redis 7.0以降、小さなハッシュとZsetのデフォルトエンコーディングとなっています。listpackは、カスケード更新の問題を解消するために設計された、単一の連続したメモリブロックに格納されたエントリのシーケンスです。

listpackはシンプルな構造をしています。

  • 6バイトのヘッダー:合計バイト数に4バイト、要素数に2バイト。
  • エントリのシーケンス。
  • 1バイトのlistpack終端マーカー(0xFF)。

その魔法は各エントリの中にあります。エントリは以下を格納します。

  1. エンコーディングタイプとデータ: データ自体。そのタイプと長さを示すビットがプレフィックスとして付加されます。
  2. エントリの合計長: このエントリの合計サイズを可変長でエンコードしたもの。

この構造により、リストを前方または後方に反復処理できます。重要なのは、各エントリが自身の合計長を知っているため、エントリをその場で更新する(収まる場合)か、新しいものに置き換える場合でも、後続のエントリに変更を加える必要がないことです。これにより、ziplistのカスケード更新の問題が解決されます。

graph TD
    subgraph Listpack Structure
        direction LR
        Header["Header (6 bytes)<br/>total_bytes, num_elems"] --> Entry1["Entry 1<br/>data, total_len"]
        Entry1 --> Entry2["Entry 2<br/>data, total_len"]
        Entry2 --> ...
        ... --> EntryN["Entry N<br/>data, total_len"]
        EntryN --> EndMarker["End (1 byte)<br/>0xFF"]
    end

    subgraph Hash Data: user:100
        A[field: name<br>value: "alice"]
        B[field: plan<br>value: "pro"]
        C[field: score<br>value: 95]
    end

    subgraph Hash as Hashtable (High Memory)
        direction LR
        Dict["dict struct"] --> Ptr1("ptr to entry1")
        Dict --> Ptr2("ptr to entry2")
        Dict --> Ptr3("ptr to entry3")

        subgraph Allocations
          Entry1_obj["redisObject (name)"]
          Entry1_val["redisObject (alice)"]
          Entry2_obj["redisObject (plan)"]
          Entry2_val["redisObject (pro)"]
          Entry3_obj["redisObject (score)"]
          Entry3_val["redisObject (95)"]
        end
    end

    subgraph Hash as Listpack (Low Memory)
        direction LR
        LP["[Header | name | alice | plan | pro | score | 95 | End]"]
    end

    A & B & C -- Stored as --> Hash
    Hash -- Large Hash --> Hash_as_Hashtable
    Hash -- Small Hash --> Hash_as_Listpack

移行:ListpackからHashtableへ

ハッシュは、以下の2つの条件を満たしている限り、listpackとして保存されます。

  1. フィールドと値のペアの数がhash-max-listpack-entries(デフォルト512)未満であること。
  2. 最大エントリ(フィールドまたは値)のサイズがhash-max-listpack-value(デフォルト64バイト)未満であること。

これらのしきい値のいずれかを超えると、Redisは自動的にlistpackを標準のhashtable(OBJ_ENCODING_HT)に変換します。この変換は一方通行です。

PythonスクリプトとMEMORY USAGEコマンドでその影響を見てみましょう。

# populate_redis.py
import redis

# Connect to a local Redis instance
r = redis.Redis(decode_responses=True)

# Small hash, will be stored as a listpack
small_hash_key = "user:101:small"
r.delete(small_hash_key)
r.hset(small_hash_key, mapping={
    "name": "Bob Smith",
    "email": "bob.smith@example.com",
    "city": "New York",
    "status": "active"
})

# Large hash, will be forced into hashtable encoding by number of entries
large_hash_key = "user:102:large"
r.delete(large_hash_key)
# Default hash-max-listpack-entries is 512, so 513 will trigger conversion
large_hash_data = {f"field_{i}": f"value_{i}" for i in range(513)}
r.hset(large_hash_key, mapping=large_hash_data)

print(f"Profiling key: {small_hash_key}")
print(f"Profiling key: {large_hash_key}")

それでは、これを実行してredis-cliのメモリを調べてみましょう。

$ python populate_redis.py
Profiling key: user:101:small
Profiling key: user:102:large

$ redis-cli
127.0.0.1:6379> OBJECT ENCODING user:101:small
"listpack"
127.0.0.1:6379> MEMORY USAGE user:101:small
(integer) 166

# Now for the large one, which just barely crossed the threshold
127.0.0.1:6379> OBJECT ENCODING user:102:large
"hashtable"
127.0.0.1:6379> MEMORY USAGE user:102:large
(integer) 44104

4つのフィールドを持つ小さなハッシュは、わずか166バイトしか使用していません。一方、513フィールドを持つ大きなハッシュは、44KB以上を消費しています。これは線形的な増加ではありません。この急増は、本格的なハッシュテーブルへの変換によって引き起こされます。これには、dictとdictEntry構造体、すべてのフィールドと値に対するredisObject、およびかなりのポインタオーバーヘッドの作成が含まれます。

特殊なケース:IntsetsとQuicklists

Intsets (OBJ_ENCODING_INTSET): セットが64ビット符号付き整数のみを含む場合、Redisは非常にコンパクトなintsetエンコーディングを使用します。これは本質的に整数のソート済み配列です。これは信じられないほどメモリ効率が良いです。しかし、セットに非整数要素を追加した瞬間、それは永続的に標準のハッシュテーブルに変換されます。

  • しきい値: set-max-intset-entries (デフォルト512)。

Quicklists (リスト用): Redisのリストは単純な連結リストではありません。それはquicklistです。listpacksの連結リストです。quicklistの各ノード(quicklistNode)には、複数のリスト要素を保持できるlistpackが含まれています。これは見事な妥協点です。すべての要素が別々のメモリ割り当てである連結リストのオーバーヘッドを回避しつつ、リスト全体に対して1つの巨大な連続ブロックを持つことも回避します。

この動作は、2つの主要な設定で調整できます。

  • list-max-listpack-size: 各内部listpackの最大サイズを制御します。負の値はバイト制限を設定します(例:ノードあたり8KBの場合は-2)。これは設定する推奨される方法です。
  • list-compress-depth: 魅力的な最適化です。これにより、頻繁にアクセスされないquicklistの先頭と末尾のlistpackノードを圧縮できます。0は圧縮を無効にします。1は、最も外側のノード(先頭と末尾)は非圧縮で、次のレベルは圧縮される、といった具合です。これは、通常は末尾にしかアクセスしない非常に長いリスト(例:Redisをキューとして使用する場合)でかなりのメモリを節約できます。

理論から実践へ:プロファイリングとチューニング

この内部知識を武器に、インスタンスを最適化するための具体的な手順を踏むことができます。

ステップ1:MEMORYコマンドでプロファイルする

MEMORYコマンドは、メモリの問題を調査するための主要なツールです。

  • MEMORY USAGE <key>: 上記のように、特定のキーに割り当てられた合計バイト数を表示します。キーがコンパクトなエンコーディングを使用しているかどうかを確認するための頼りになるツールです。
  • MEMORY STATS: keys.count、keys.bytes-per-key、およびデータ型ごとの詳細な内訳を含む、サーバーのメモリ使用量の詳細な内訳を提供します。
  • MEMORY DOCTOR: 組み込みの診断ツールです。Redisは状態を分析し、高い断片化や非効率なデータ構造などの潜在的な問題を報告します。これは優れた出発点です。

一般的なタスクは、最大または最も問題のあるキーを見つけることです。これは簡単なスクリプトで実行できます。

# WARNING: The --bigkeys scan can cause a small latency spike.
# Use it with caution on a production primary.
# It is safer to run on a replica.

redis-cli --bigkeys

# --- Sample Output ---
# ...
# [00.00%] Biggest zset found so far: 'my_leaderboard' with 100002 members
# [00.00%] Biggest hash found so far: 'user:session:fat' with 513 fields
# ...
#
# Summary:
# Sampled 123456 keys in the keyspace.
#
# Biggest zset: 'my_leaderboard' (100002 members)
# Biggest hash: 'user:session:fat' (513 fields)
#
# 98765 strings with 1.23 GB
# 12345 lists with 50.44 MB
# 5432 hashes with 2.10 GB
# 1234 zsets with 450.12 MB
# 1 set with 12 bytes

これにより、コンパクトなエンコーディングから外れてしまった可能性のあるキーがすぐに特定されます。

ステップ2:エンコーディングのしきい値を調整する

プロファイリングによって一般的なデータアクセスパターンが明らかになった場合、redis.confまたはCONFIG SETを介してエンコーディングのしきい値を調整できます。

シナリオ: ユーザーセッションデータをハッシュに保存しています。ほとんどのセッションには約60〜70のフィールドがありますが、まれに単一フィールドの64バイトのhash-max-listpack-value制限を超えるものがあり、ハッシュ全体が非効率なhashtableエンコーディングに強制的に変換されます。

アクション: データを分析します。大きなフィールドがまれでパフォーマンスに重要でない場合は、値の制限を増やすことを検討できます。

# In your redis.conf

# Default: 64. Let's increase it to allow slightly larger values in our hashes.
hash-max-listpack-value 128

# Default: 512. If your hashes are consistently just over this limit,
# and write performance isn't the absolute top priority for them,
# a modest increase can save a lot of memory.
hash-max-listpack-entries 1024

警告: CPUとメモリのトレードオフがあります。大きなlistpacksはメモリ効率が良いですが、その中の要素を見つけるのはO(N)操作です。1000エントリのハッシュの場合、キーを見つけるには1000エントリすべてをスキャンする必要があるかもしれません。hashtableはO(1)アクセスを提供します。これらの値は、特定のワークロードの読み取り/書き込みパターンとレイテンシ要件に基づいて調整してください。

ステップ3:メモリ断片化を抑制する

メモリ断片化は、長期間稼働するRedisインスタンスにおける静かなメモリキラーです。Redisは常にメモリを割り当てたり解放したりしています。メモリアロケータ(デフォルトではjemalloc)は、解放されたメモリをオペレーティングシステムに解放できない場合があり、Redisが報告する値(used_memory)とOSがプロセスが使用していると見なす値(RSS - Resident Set Size)との間に不一致が生じます。

断片化率はINFO MEMORYで確認できます。

127.0.0.1:6379> INFO MEMORY
...
used_memory:104857600       # 100 MB used by Redis data
used_memory_rss:136314880    # 130 MB from OS perspective
...
mem_fragmentation_ratio:1.30
...

1.5を超える比率は高く、重大な断片化を示しています。1.0を下回る比率は、Redisがスワップしていることを意味し、これは重大な問題です。

Redis 4.0以降では、アクティブデフラグメンテーションが導入され、Redisがバックグラウンドで自動的に断片化と戦うことができるようになりました。

# In your redis.conf - safe starting values
activedefrag yes

# Don't start defragging unless fragmentation is at least 100MB
active-defrag-ignore-bytes 100mb

# Start defragging when fragmentation ratio reaches 1.1 (10%)
active-defrag-threshold-lower 10

# Stop defragging when the CPU usage for defrag hits 5%
# to minimize impact on the main thread.
active-defrag-cycle-max 5

有効にすると、Redisは余分なCPUサイクルを使用してメモリをスキャンし、断片化された値を見つけて連続したメモリブロックに移動させ、アロケータが解放された断片化された領域を再利用できるようにします。非自明な書き込み負荷を持つ本番システムでは、アクティブデフラグを有効にすることを強くお勧めします。

よくある質問

関連するデータの場合、単一のハッシュは、コンパクトなエンコーディングの制限内に収まっている限り、ほとんどの場合、よりメモリ効率的です。

詳しく見てみましょう。個別のキー(SET user:123:name "John")を使用すると、redisObjectとトップレベルのdictEntryから、フィールドごとに約48バイトのオーバーヘッドが発生します。10個のフィールドの場合、データがカウントされる前に、メタデータだけで約500バイト近くになります。

単一のハッシュ(HSET user:123 ...)にはトップレベルキーが1つしかないため、その約48バイトの税金は1回しかかかりません。listpackとして保存されている場合、フィールドと値は最小限のオーバーヘッドで密にパックされます。メモリの節約は莫大です。10個のフィールドを持つハッシュは、listpackとして約200バイトしかかからないのに対し、10個の個別のキーでは約700バイト以上かかります。

トレードオフはアトミック性とTTLです。ハッシュ内の個々のフィールドに異なるTTLを設定することはできません。ユーザーのlast_seen値を期限切れにする必要があるが、emailは期限切れにする必要がない場合、個別のキーを使用せざるを得ません。

これはメモリ断片化の典型的な症状です。used_memoryはRedisがキーに積極的に使用していると考えているものです。used_memory_rss(Resident Set Size)は、オペレーティングシステムがRedisプロセスに割り当てたものです。mem_fragmentation_ratio(used_memory_rss / used_memory)が高い(例:> 1.5)場合、jemalloc(アロケータ)が削除されたキーからの「穴」を含む多くのメモリを保持しており、このメモリをOSに解放できないことを意味します。

主な解決策は、記事で説明されているようにアクティブデフラグメンテーションを有効にして構成することです。used_memoryが低いにもかかわらずRSSが高いその他の、あまり一般的ではない原因としては、次のものが挙げられます。

  • クライアント出力バッファ: pub/subクライアントやMONITORの低速なコンシューマがある場合、サーバー側の出力バッファが非常に大きくなることがあります。バッファサイズを検査するにはCLIENT LISTを使用します。
  • Luaスクリプトキャッシュ: RedisはコンパイルされたLuaスクリプトをキャッシュします。SCRIPT FLUSHでこれをクリアできますが、これが主な原因となることはめったにありません。
  • レプリケーションバックログ: レプリカの場合、大きなレプリケーションバックログがかなりのメモリを消費することがあります。

はい、コンパクトなエンコーディングの根本的なトレードオフはCPUとメモリです。listpacks(および古いziplists)での操作は、特にサイズが大きくなるにつれて、hashtableやskiplistの対応するものよりも、書き込みやランダム読み取りにおいて一般的に遅くなります。

500フィールドを持つハッシュに対するHSETを考えてみましょう。それがhashtableであれば、操作はO(1)です。それがlistpackであれば、Redisはフィールドを見つけるために最大500エントリをスキャンする必要があるかもしれず、O(N)操作になります。サイズ変更を伴うエントリの削除や更新も、ハッシュテーブルよりもコストがかかります。

次のようなシナリオでは、それらを避ける(または非常に小さなしきい値を使用する)ことを検討する必要があります。

  1. コレクションが非常に書き込み負荷が高い場合。
  2. これらのコレクションへの更新に非常に厳しいP99レイテンシ要件がある場合。
  3. コレクションがしきい値付近で急速に拡大縮小し、listpackからhashtableへの繰り返しで高コストな変換が発生する場合。

このような場合、予測可能で高速なO(1)書き込みパフォーマンスと引き換えに、hashtableエンコーディングの高いメモリコストを受け入れる方が良いかもしれません。これは、hash-max-listpack-entries 8のような非常に低いしきい値を設定することで強制できます。

最終的な要点と行動計画

Redisのメモリ最適化は、闇の技術ではありません。それは、その内部データ構造を理解することに基づいたエンジニアリングの規律です。理論から実践へと移行することで、膨大な量のRAMを再利用し、より安定した費用対効果の高いシステムを運用できます。

  1. データモデルを監査する: 多くの小さなキーを使用しているが、単一のハッシュで済むものはありませんか?関連データをグループ化することが、最大の潜在的なメリットです。
  2. キーをプロファイルする: MEMORY USAGEと--bigkeysを使用して、非効率的にhashtableまたはskiplistエンコーディングに変換されたキーを特定します。
  3. redis.confを調整する: アプリケーションのデータパターンに基づいて、*-max-listpack-*ディレクティブを調整します。わずかな増加が時期尚早な変換を防ぎ、ギガバイト単位の節約につながる可能性があります。
  4. アクティブデフラグメンテーションを活用する: 長期間稼働する、書き込み負荷の高いRedisインスタンスでは、activedefragを有効にしてください。これは、サーバーのメモリの健全性を長期的に管理するための最良のツールです。
  5. 断片化を監視する: mem_fragmentation_ratioに注意してください。activedefragを使用しても頑固に高いままであれば、割り当てパターンに関するより深い問題、またはメンテナンス期間中の計画的な再起動の必要性を示している可能性があります。

これらの原則を適用することで、コストを削減するだけでなく、より堅牢でスケーラブルなアーキテクチャを構築できます。

参考文献

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement