1. ソートセットとは
ソートされたセットは、セットと同様にコレクション タイプであり、コレクション内に重複するデータ (メンバー) は存在しません。違いは、Sorted Sets 要素がメンバーとスコアの 2 つの部分で構成されていることです。メンバーは double 型のスコア (スコア) に関連付けられ、ソートされたセットはデフォルトでこのスコアに従ってメンバーを小さいものから大きいものに並べ替えます。メンバーに関連付けられたスコアが同じ場合、メンバーは次の基準に従って並べ替えられます。文字列の辞書順。
一般的な使用シナリオ:
- リーダーボード。大規模なオンライン ゲームでスコア順にランク付けされたトップ 10 の順序付きリストを維持するなど。
- レート リミッター。並べ替えられたコレクションからスライディング ウィンドウ レート リミッターを構築します。
- 遅延キューでは、スコアは有効期限を小さいものから大きいものまで並べて保存します。最初の値は最初に期限切れになるデータです。
2. メンタルメソッドを実践する
ソート セットの基礎となるデータを格納する方法は 2 つあります。
- バージョン 7.0 より前は ziplist でしたが、その後 listpack に置き換えられました。listpack ストレージを使用する条件は、コレクション要素の数が構成値 (デフォルトは 128) 以下であり、メンバーが占有するバイト サイズがそれ以下であることです
zset-max-listpack-entries
。設定値 (デフォルト 64)よりもzset-max-listpack-value
リストパック ストレージを使用すると、メンバーとスコアがリストパックの要素としてコンパクトに保存されます。 - 上記の条件を満たさない場合は、スキップリスト+辞書(ハッシュテーブル)の組み合わせで保存され、スキップリストへのデータの挿入と同時に辞書(ハッシュテーブル)へのデータの挿入が行われます。空間を時間と交換するという考え方です。ハッシュ テーブルのキーには要素のメンバーが格納され、値にはメンバーに関連付けられたスコアが格納されます。
MySQL: 「つまり、listpack は、要素の数が少なく、要素の内容が大きくないシナリオに適しています。」
はい、listpack ストレージを使用する目的はメモリを節約することです。ソート セットは、スキップリスト ジャンプ テーブルのおかげで、効率的な範囲クエリを正確にサポートできます。たとえば、 ZRANGE
コマンドの時間計算量 はO(log(n)) + m
、n はメンバーの数、m は返された結果の数です。大量の結果を返すコマンドは避ける必要があることに注意してください。
dict を使用する理由は、単一の要素をクエリするのに O(1) の時間計算量を達成するためです。指示など ZSCORE key member
。要約すると、ソート セットが挿入または更新されると、対応するデータがスキップリストとハッシュ テーブルに同時に挿入または更新されます。スキップリストとハッシュ テーブルのデータが一貫していることを確認してください。
MySQL: 「この方法は非常に独創的です。スキップリストはスコアに基づいて範囲クエリまたは単一クエリを実行するために使用され、辞書ハッシュ テーブルはデータに応じてスコアに対応する O(1) 時間複雑さのクエリを実現するために使用されます。効率的な範囲クエリと単一要素クエリを満たすクエリです。問い合わせてください。」
ソートセットのソースコードは主に以下の 2 つのファイルにあります。
- 構造体は で定義されています
server.h
。 - この機能が実現されました
t_zset.c
。
まず、skiplist (スキップ テーブル) + dict (ハッシュ テーブル) データ構造がどのようにデータを格納するかを見てください。
スキップリスト + 辞書
MySQL: 「ジャンプテーブルとは何か教えてください」
本質は、二分検索を実行できる順序付きリンク リストです。ジャンプテーブルは、元の順序付きリンクリストにマルチレベルインデックスを追加し、インデックスによる高速検索を実現します。検索パフォーマンスが向上するだけでなく、挿入および削除操作のパフォーマンスも向上します。性能的には赤黒ツリーやAVLツリーに匹敵しますが、ジャンプテーブルの原理や実装は赤黒ツリーよりも簡単です。リンクされたリストを振り返ってみると、その問題点はクエリが非常に遅いことと、O(n) 時間の複雑さは唯一高速で壊れない Redis にとって耐えられないことです。
次のノードへの「ジャンプ」ポインタが、順序付きリンク リストの隣接する 2 つのノードごとに追加される場合、次の図に示すように、検索の時間計算量は元の半分に削減できます。
このように、レベル 0 とレベル 1 はそれぞれ 2 つの連結リストを形成し、レベル 1 の連結リスト ノードの数は 2 (6, 26) だけです。
テーブルノードのルックアップをスキップする
検索データは常に最上位レベルから比較されます。ノードによって保存された値がチェック対象のデータより小さい場合、スキップ テーブルは層内の次のノードへのアクセスを継続します。値より大きいノードが見つかった場合、スキップ テーブルは層内の次のノードにアクセスします。チェックしたいデータがある場合は、現在のノードの次の階層のリンクリストにジャンプし、検索を続けます。たとえば、今 17 を検索したい場合、検索パスは下図の赤で示された方向になるはずです。
- レベル 1 から開始し、17 と 6 を比較します。値はノードより大きく、次のノードとの比較を続けます。
- 26、17 < 26 と比較して、元のノードに戻り、現在のノードのレベル 0 のリンク リストにジャンプし、次のノードと比較して、ターゲットの 17 を見つけます。
スキップリストは、この多層リンク リストのアイデアからインスピレーションを受けました。上記の連結リスト生成方法によれば、連結リストの各層のノード数は下位層の半分となり、この探索処理は二分探索に類似し、時間計算量はO(log n)となる。
しかし、この方法ではデータを挿入する際に大きな問題があり、新しいノードを追加するたびに、隣接する2層リンクリストのノード数の2:1の関係が崩れてしまうため、この関係を維持したい場合には、リンク リストの調整の場合、イベントの複雑さは O(n) です。この問題を回避するために、上位リンク リスト ノードと下位リンク リスト ノードの数の間に厳密な比例関係は必要ありませんが、各ノードのレイヤー番号をランダムに選択するため、ノードの挿入では前方ポインタと後方ポインタを変更するだけで済みます。次の図は、4 層のリンク リストを持つスキップリストです。26 を検索するとします。下の図は、検索が通過したパスを示しています。
古典的なスキップリストの直感的なイメージをつかんだ後、Redis でのスキップリストの実装の詳細を見てみましょう. ソートセットのデータ構造は次のように定義されます。
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
zset
構造内には 2 つの変数、つまりハッシュ テーブル dict とスキップ テーブル zskiplist があります。dict については前の記事で説明しましたので、それに焦点を当てましょう zskiplist
。
typedef struct zskiplist {
// 头、尾指针便于双向遍历
struct zskiplistNode *header, *tail;
// 当前跳表包含元素个数
unsigned long length;
// 表内节点的最大层级数
int level;
} zskiplist;
zskiplistNode *header, *tail
、2 つのヘッド ポインターとテール ポインター。双方向トラバースを実現するために使用されます。length
、リンクされたリストに含まれるノードの総数。新しく作成されたものはzskiplist
ヌルヘッドポインターを生成し、長さカウントには含まれないことに注意してください。level
、skiplist
すべてのノードの最大レイヤー数を表します。
zskiplistNode
次に、スキップリスト内の各ノードの定義構造を調べ続けます 。
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
-
ソートされたセットは要素を保存するだけでなく、要素の重みも節約します。そこで、sds型のeleに対応して実際の内容を格納し、double型のスコアを利用して軽量化を図っています。
-
*backward
、このノードの前のノードを指すバック ポインター。末尾ノードから逆順に検索する場合に便利です。各ノードには逆方向ポインタが 1 つだけあり、レベル 0 のリンク リストのみが二重リンク リストであることに注意してください。 -
level[]
、これはzskiplistLevel
構造体タイプの柔軟な配列です。スキップ リストは多層の順序付きリンク リストであり、各層のノードもポインターによってリンクされているため、配列内の各要素はスキップリストの層を表します。-
*forward
、レイヤーの前方ポインタ。 -
span
、span、この層*forward
のポインタとそのポインタが指す次のノードとの間のレベル 0 層にまたがるノードの数を記録するために使用されます。スパンは要素のランク (ランク) を計算するために使用されます。たとえば、ele = Xiaocaiji、スコア = 17 のランクを見つけるには、図に示すように、検索パスが通過するノードのスパンを追加するだけで済みます。以下は、赤いパスのスパン累積です (rank = (2 + 2) - 1 = 3
ランクは 0 から始まるため、マイナス 1)。大きいものから小さいものへのランキングを計算したい場合は、スキップリストの長さから検索パス上のスパンの累積値を引くだけで済みます4 - (2 + 2) = 0
。
-
以下の図は、Redis での Skiplsit の可能な構造を示しています。
リストパック
MySQL: 「構造定義によれば 、dict と zskiplist の 2 つのデータ構造がそれぞれ使用されており、listpack の影は見えません。」
zset
この質問は良い質問です。 listpack storage を使用する詳細は、ソース コード ファイル内の関数に反映されています。コードの一部は次のとおりで、内部的に storage に listpack を使用するかどうかを判断しますt_zset.c
。zaddGenericCommand
·
void zaddGenericCommand(client *c, int flags) {
// 省略部分代码
// key 不存在则创建 sorted set
zobj = lookupKeyWrite(c->db,key);
if (checkType(c,zobj,OBJ_ZSET)) goto cleanup;
if (zobj == NULL) {
if (xx) goto reply_to_client;
// 当 zset_max_listpack_entries == 0 或者
// 元素字节大小大于 zset_max_listpack_value 配置
// 则使用 skiplist + dict 存储,否则使用 listpack。
if (server.zset_max_listpack_entries == 0 ||
server.zset_max_listpack_value < sdslen(c->argv[scoreidx+1]->ptr))
{
zobj = createZsetObject();
} else {
zobj = createZsetListpackObject();
}
dbAdd(c->db,key,zobj);
}
// 省略部分代码
}
listpack は複数のデータ項目で構成される連続したメモリであることがわかっています。ソートされたセットの各要素は、メンバーとスコアの 2 つの部分で構成されます。リストパック ストレージを使用して (メンバー、スコア) データ ペアを挿入すると、各メンバー/スコア データ ペアはコンパクトな配置で保存されます。listpack の最大の利点はメモリの節約であり、要素を検索する場合は順番に検索するだけで、計算量は O(n) です。そのため、データ量が少ない場合は、パフォーマンスに影響を与えることなくメモリを節約できます。各検索ステップは 2 つのデータ項目、つまりメンバー/スコア データのペア全体に進みます。