純粋なデータ構造としてのRedis Streams
原文は Salvatore Sanfilippo により に公開されました。 このブログを購読する
Redis 5で「Streams」という名前で導入された新しいRedisのデータ構造は、コミュニティで大きな注目を集めました。いずれ本番環境で使っているユーザーたちに話を聞き、コミュニティ調査を行って、その結果をブログで紹介したいと思っています。ただ今日は別のテーマを取り上げます。多くのユーザーがStreamsのことを、Kafka(TM)のようなユースケースを解決するための手段としてしか考えていないのではないか、と感じ始めているのです。実際、このデータ構造はプロデューサーとコンシューマーによるメッセージングの文脈でも機能するよう設計されていますが、Redis Streamsはそれにしか使えないと考えるのは、あまりにも視野が狭いと言わざるを得ません。ストリーミングはシステム設計に応用すると大きな成果をもたらす素晴らしいパターンであり「メンタルモデル」ですが、他のほとんどのRedisデータ構造と同様に、Redis Streamsはより汎用的で、互いに無関係なさまざまな問題のモデリングに利用できます。そこでこのブログ記事では、Streamsを純粋なデータ構造としてのみ取り上げ、ブロッキング操作やコンシューマーグループなど、メッセージングに関わる部分は一切無視して話を進めます。
Streamsはステロイドで強化されたCSVファイル
一連の構造化されたデータをログに残したくて、結局のところデータベースは大げさだと判断したなら、こんなふうに考えるかもしれません。とにかくファイルを追記専用モードで開いて、1行ごとにCSV(Comma Separated Value)形式で記録していけばいい、と。
(open data.csv in append only) time=1553096724033,cpu_temp=23.4,load=2.3 time=1553096725029,cpu_temp=23.2,load=2.1
シンプルに見えますし、実際に昔から多くの人がそうしてきましたし、今でもそうしています。何をやっているか分かっていれば、これは堅実なパターンです。しかし、これと同等のものをインメモリで実現するとしたらどうなるでしょうか。メモリは追記専用ファイルよりもはるかに高機能で、こうしたCSVファイルが抱える制約を自動的に取り除いてくれます。
- ここでは範囲検索を行うのが困難(非効率)です。
- 冗長な情報が多すぎます。時刻はどのエントリでもほぼ同じですし、フィールド名も重複しています。かといってそれらを削ってしまえば、別のフィールドセットに切り替えたくなったときにフォーマットの柔軟性が失われてしまいます。
- アイテムのオフセットは、ファイル上のバイトオフセットでしかありません。ファイル構造を変えればオフセットは無効になってしまうため、ここには真の意味でのプライマリIDという概念が存在しません。基本的に、エントリが一意にアドレス指定されているわけではないのです。
- エントリを削除することができず、ガベージコレクションする手段もなく、無効になったものとしてマークするしかありません。ログを書き直さない限りは。ログの書き直しは様々な理由で面倒なことが多く、避けられるなら避けたほうが良いものです。
とはいえ、こうしたCSVエントリのログにも優れた点はあります。構造が固定されておらずフィールドも変わり得る、生成が極めて簡単、そして結局のところかなりコンパクトでもある。Redis Streamsの発想は、こうした良い部分は残しつつ、制約を乗り越えることにありました。その結果生まれたのは、RedisのSorted Setによく似たハイブリッドなデータ構造です。根本的なデータ構造であるかのように感じられますが、そのような使い心地を実現するために、内部では複数の表現を組み合わせています。
Streams入門(すでにRedis Streamsの基本を知っている方は飛ばしてください)
Redis Streamsは、差分圧縮されたマクロノードがradix treeによって連結された形で表現されています。これにより、ランダムなエントリへのシークを非常に高速に行えたり、必要に応じて範囲を取得したり、古いアイテムを削除して上限付きのStreamを作ったりといったことが可能になります。一方で、プログラマから見たインターフェースはCSVファイルとよく似ています。
> XADD mystream * cpu-temp 23.4 load 2.3 "1553097561402-0" > XADD mystream * cpu-temp 23.2 load 2.1 "1553097568315-0"
上の例から分かるように、XADDコマンドはエントリIDを自動生成して返します。IDは単調増加し、<時刻>-<カウンタ>という2つのパートから構成されます。時刻はミリ秒単位で、カウンタは同じミリ秒内に生成されたエントリに対して増加していきます。
つまり「追記専用のCSVファイル」という発想の上に乗った最初の新しい抽象化は、XADDのID引数にアスタリスクを使ったことで、サーバーからエントリIDをただで手に入れられるという点です。このIDはStream内の特定のアイテムを指し示すのに役立つだけでなく、そのエントリがStreamに追加された時刻とも紐づいています。実際、XRANGEを使えば範囲検索や単一アイテムの取得が可能です。
> XRANGE mystream 1553097561402-0 1553097561402-0
1) 1) "1553097561402-0"
2) 1) "cpu-temp"
2) "23.4"
3) "load"
4) "2.3"この例では、範囲の開始と終了に同じIDを指定して、単一の要素を特定しました。もちろん任意の範囲を指定できますし、COUNT引数で結果の件数を制限することもできます。同様に、範囲として完全なIDを指定する必要はなく、IDのうちミリ秒のUNIX時間の部分だけを使って、ある時間範囲内の要素を取得することもできます。
> XRANGE mystream 1553097560000 1553097570000
1) 1) "1553097561402-0"
2) 1) "cpu-temp"
2) "23.4"
3) "load"
4) "2.3"
2) 1) "1553097568315-0"
2) 1) "cpu-temp"
2) "23.2"
3) "load"
4) "2.1"ここではこれ以上StreamsのAPIを紹介する必要はありません。そのためのRedisドキュメントがあります。ここではひとまず、この使い方のパターンだけに注目しましょう。XADDでデータを追加し、XRANGE(あるいはXREAD)で範囲を取得する(目的に応じて使い分けます)。そして、なぜ私がStreamsはデータ構造として非常に強力だと主張するのかを見ていきます。
なお、Redis StreamsとそのAPIについてもっと知りたい方は、こちらのチュートリアルをぜひご覧ください。https://redis.io/topics/streams-intro
テニスプレイヤーの例
数日前、最近Redisを学び始めた友人と一緒にアプリケーションのモデリングをしていました。地域のテニスコートやプレイヤー、試合を管理するためのアプリです。Redisでプレイヤーをモデリングする方法は至って明白で、プレイヤーは小さなオブジェクトなのでHashを使えば済みます。キー名はplayer:<id>のような形です。さらにアプリケーションデータのモデリングを進め、Redisをプライマリなデータストアとして使おうとすると、すぐに、あるテニスクラブで行われた試合を追跡する方法が必要になることに気づきます。player:1とplayer:2が試合をして、player 1が勝ったとすれば、Streamに次のようなエントリを書き込むことができます。
> XADD club:1234.matches * player-a 1 player-b 2 winner 1 "1553254144387-0"
このシンプルな操作だけで、以下のことが実現できます。
- 試合の一意な識別子、すなわちStream内のIDが得られる。
- 試合を識別するためにわざわざオブジェクトを作成する必要がない。
- 試合の一覧をページングしたり、過去のある時点でプレイされた試合を確認したりするための範囲検索が、ただで手に入る。
Streamsが登場する前は、時刻をスコアとしたSorted Setを作る必要がありました。Sorted Setの要素は試合のIDで、別のキーにHashの値として存在する形です。これは単に手間がかかるだけでなく、信じられないほどのメモリの無駄でもあります。想像する以上に、はるかに大きな無駄です(後述します)。
ここで示したいポイントは、Redis Streamsは、ある意味で追記専用モードのSorted Setであり、時刻をキーとし、各要素が小さなHashになっているということです。そしてこのシンプルさこそが、Redisにおけるモデリングの文脈では革命的なのです。
メモリ使用量
上記のユースケースは、単により堅牢なパターンになったというだけの話ではありません。Streamによる解決策のメモリコストは、オブジェクトごとにSorted SetとHashを持つという従来のアプローチと比べてあまりにも違うため、これまで現実的ではなかったことが、今では十分に可能になっているのです。
先ほど紹介した構成で100万件の試合を保存した場合の数値は次のとおりです。
Sorted Set + Hash memory usage = 220 MB (242 RSS) Stream memory usage = 16.8 MB (18.11 RSS)
これは桁違いの差で(正確には13倍の差です)、昨日までインメモリではコストが高すぎたユースケースが、今では十分に現実的になったことを意味します。鍵はすべてRedis Streamsの内部表現にあります。マクロノードはlistpackと呼ばれるデータ構造でエンコードされた複数の要素を含むことができ、これが非常にコンパクトです。listpackは例えば、意味的には文字列であっても、整数をバイナリ形式でエンコードしてくれます。その上で、差分圧縮と同一フィールド圧縮を適用します。それでもIDや時刻によるシークが可能なのは、そうしたマクロノードがradix treeで連結されており、radix tree自体もメモリ使用量が少なくなるよう設計されているからです。これらすべてが合わさってメモリ使用量の少なさを実現していますが、面白いのは、意味的な観点からは、ユーザーはStreamsを効率的にしている実装の詳細を一切意識しなくてよいという点です。
ここで簡単な計算をしてみましょう。100万エントリを約18MBのメモリで保存できるなら、1000万エントリは180MBで、1億エントリは1.8GBで保存できます。わずか18GBのメモリで10億件のアイテムを持つことができるのです。
時系列データ
私見では、ここで特筆すべき重要な点は、上記でStreamを使ってテニスの試合を表現した使い方が、時系列データのためにRedis Streamを使う場合と、意味的に*大きく異なる*ということです。確かに論理的にはどちらも何らかのイベントをログに残しているのですが、根本的な違いが一つあります。一方では、オブジェクトを具現化するためにログへの記録とエントリの作成を利用しています。一方で時系列データの場合は、オブジェクトを表すわけではない、外部で起きた何かを単に計測しているだけです。この違いは些細なものに思えるかもしれませんが、決してそうではありません。Redis Streamsは、全順序を持つ小さなオブジェクトを作成し、それらにIDを割り当てるためにも使えるのだ、という発想をRedisユーザーに持ってもらうことが重要なのです。
とはいえ、時系列データという最も基本的なユースケースでさえ、もちろんここでは非常に大きな意味を持ちます。Streams以前のRedisは、この種のユースケースに関しては少々頼りないところがあったからです。Streamのメモリ特性と柔軟性、さらに上限付きStreamを作れること(XADDのオプションを参照)は、開発者にとって非常に重要な武器となります。
おわりに
Streamsは柔軟で多くのユースケースを持っていますが、このブログ記事はあえて短くまとめ、上記の例とメモリ使用量の分析における明確なメッセージを確実に伝えたいと思います。すでに多くの読者にとっては自明のことだったかもしれませんが、ここ数ヶ月いろいろな人と話す中で、Streamsとストリーミングというユースケースが強く結びつけられ、あたかもそのデータ構造はそれにしか向いていないかのように捉えられている印象を受けました。実際はそうではありません :-)
記事をランダムに読む
コメント
ログインしてコメントする