Closed16
実践Redis入門を読むメモ
- keysコマンドは実行時間がかかるので本番環境での使用は非推奨
- パフォーマンスに影響が出る
- 本番環境でkeyの取得はscan, sscan, hscan, zscanのようなscan系のコマンドが推奨
- RedisにおけるString型は文字列以外にもバイナリや整数なども保存する
- String型のユースケースはシンプルなKV, 画像などのバイナリデータの保存、セッション、カウンターなどがある
- Redisのkey名は自由度が高い
- 公式ドキュメントには以下のような考え方を推奨している
- 長すぎるキーは不適切
- 短すぎるキーも不適切
- スキーマ設計
-
user:1000:followers
のようなキー名がわかりやすくて良い - mongoもそうだったがNoSQLはメモリの消費を抑えるのにキー名を短くしようとすることがあるようだ
- String型でも数値を格納していれば
incr
のようなコマンドが使える - 数値でなければエラーとなる
- String型のsetは基本上書き
- 値がないときだけ書き込むのはNXオプションをつけることで実現可能
SET user:1 "user1" NX
- TTLはSETコマンドのEXオプションを指定するかSETしたあとにEXPIREコマンドで指定できる
127.0.0.1:6379> EXPIRE key1 300
(integer) 1
127.0.0.1:6379> TTL key1
(integer) 294
127.0.0.1:6379> set key4 value4 ex 300
OK
127.0.0.1:6379> ttl key4
(integer) 295
- String型ならアトミックに操作できるのでEXオプションそれ以外はEXPIREコマンドでおk
- TTLでEXは秒単位、PXはミリ秒単位の指定
- RedisのList型はString型のList
- List型は順序を維持する
- なのでスタックやキューといったユースケースで利用できる
- RedisのList型は末尾や先頭への要素の追加、削除はおそらく計算量O(1)で非常に高速
- ただし、中間データに対しての操作はO(N)になるため、苦手とされる
- なので最新の人気データ3件みたいなものを表示するときなどに利用できる
- Redisはシングルスレッドで動作するのでKEYS系のようなO(N)の計算量の操作は本番環境では他のコマンドをブロックしてしまうので非推奨となっていることが多い
- リアルタイムに更新されていくランキングみたいなものはRDBMSは苦手
- 何回も更新はしるみたいなやつ
- こういうときにはRedisのほうが有利になる
- 特にゲームのイベントランキングみたいなのはSorted Set型のユースケース
- 補助型としてのBitMap型と地理空間インデックス
- 何かユニークな値を集計するときRedisのSet型を使うとユニークな値の数だけメモリを消費する
- SQLでやろうとすると(groupbyしてdistinctしてcountみたいな)クエリが重くてリアルタイム更新は無理
- そのようなときにHyperLogLogが使える
- これはユニークな値を計算する確率的な計算手法であり、わずかな誤差含まれる
- しかし、多少の誤差が許容される場面では(例えばサイトの訪問者数とか)メモリ空間を効率的に利用できる
Redis Streams
- 過去のデータも保持できる
- Consumer Group機能
- Kafkaと似ているがKafkaはメッセージを読み込む元でパーティションに分割
- RedisのPubSub機能はデータの保持はできないのでチャットシステムなどで通信切断や途中参加のケースで参加以前のデータが取得できない
- そのため、別途List型などでデータを保持しておくなどの工夫が必要だった
- Redis Streamsで管理されるメッセージはエントリーIDというものが使用されている
- これは```<Unix時間>-<同一時刻におけるエントリーの数だけ加算していったID(0始まり)>の形式
- このエントリーIDには以下の特殊IDというものがある
-
-
全ストリーム内における最小のエントリーID -
+
全ストリーム内における最大のエントリーID -
$
コマンド実行後、ストリームに新しく来たメッセージのエントリーID -
>
XREADGROUPコマンドでConsumer Group内でどのConsumerにも届けられていないメッセージのID -
*
XADDコマンドで追加した新しいエントリーへ付与される新規ID
-
- Cousumer Groupはグループに登録されたConsumerで分担してメッセージを処理することができる
- メッセージはACKされるまではPEL(Pending Entries List)に保存されており、ACKすることで削除される
- 前述の特殊IDの
>
はこのPELを参照している - Redis StreamsではXREADコマンドを使うことでPubSub機能のように使える
-
XREAD BLOCK 0 STREAMS channel:1 $
のように実行することでメッセージを受信し続けることができる - $を指定しているのでこれはコマンド実行時点での最新のID以降を受信する
- 0を指定することで今までのメッセージ全て受信する
- Redisはシングルスレッドで動作するので計算量を意識することは重要
- なぜなら、重いクエリを実行すると後続の処理が詰まるから
- 問題になりそうなコマンドは以下のようなもの
- KEYS * コマンド(計算量がO(N))
- DELコマンドなど(同期削除の場合は計算量がO(N))
- Lua(エフェメラルスクリプト、Redisファンクション)
- MULTI/EXECコマンド
Redis Serialization Protocol(RESP)
- Redisの通信プロトコル
- 公式ドキュメントとかにいきなり出てくる
$echoe"TIME\r\nPING\r\nECHO\"test\"\r\n"|nclocalhost6379
*2
$10
1662383436
$6
228121
+PONG
$4
test
-
*
は配列を表す。上記の場合は要素数2の配列を表現している -
$
はその後に続く文字列の長さを表現 -
+
は単純な文字列 -
:
は整数値 -
SET key value
というコマンドは実際は以下のような状態でサーバに送信される
*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n
- このRESPという通信プロトコルは非常に効率的で小さなオーバーヘッドで通信を行うことができ、Redisの強力なパフォーマンスの一因となっている
パイプライン
- Redisのコマンドを一個ずつ実行する場合、前にコマンドの結果を受け取ってからでないと次のコマンドが実行できない
- サーバーとクライアント間の通信回数が増え、レイテンシが大きくなってしまう
- そのようなときにパイプラインを使うことで一連のコマンドをまとめて送り、結果もまとめて受け取ることができる
Lua
- Redisは組み込みのスクリプトにLuaを採用している
- パイプラインと同様に一連のコマンドの実行をLuaで書くことができる
- パイプラインとの違いはLuaでは条件分岐が書けるのでパイプラインよりも複雑なロジックが書ける
- あと、パイプラインは読み込みと書きこみのコマンドがあると読み込みの応答を待機しないと書き込みが実行できない
- Luaによるスクリプトはそういった読み込みと書き込みのレイテンシーが最小限になるように作られている
- Luaはエフェメラルスクリプトで書かれてきた歴史があるがRedis7.0以降Redisファンクションで書けるようになっている
- 例えば、データを追加してTTLもセットしたいときString型であればSetのオプションでアトミックに実行できる
- そうでなければ値を追加したあとにTTLを設定する必要がある
- これをパイプラインで実行すればRTTのオーバーヘッドは減らせるがアトミックにはならない
- Luaでスクリプトを書くことでRTTのオーバーヘッドを減らせる、かつアトミックに実行できる
require'redis'#script内にLuaのコードを記述
script=<<EOFredis.call('HMSET',KEYS[1],ARGV[1],ARGV[2],ARGV[3],ARGV[4])redis.call('EXPIRE',KEYS[1],ARGV[5])EOF
redis=Redis.new
#scriptに保存したLuaのコードを読み込む
hashed_script=redis.script(:load,script)
#evalshaで読み込んだLuaのコードを実行
...
- scriptはEVALコマンドで実行できる
- ただ、毎回スクリプトをテキストデータで送るのはデータ効率が悪いのでSCRIPTLOADコマンドでスクリプトをサーバーに保持して、スクリプトのハッシュ値を送ることで実行できる
- ロードしまくるとそれはサーバーにキャッシュし続けられるので消すこともできるけどスクリプトの容量なんて大したことではない
- redis-cliからは
--eval
オプションを使用してLuaファイルを指定することでスクリプトを実行できる
Redisファンクション
- エフェメラルスクリプトはいろいろ課題がある
- Redis7.0からRedisファンクションが導入された
- Redis7.0以降は過去の資産を使う必要があるなどでなければRedisファンクションを使えば良い
- エフェメラルスクリプトの課題
- Luaのバージョンが5.1にしか対応していない
- スクリプトから別のスクリプトを呼び出すことができない
- KEYSとARGVを適切に利用せずにアンチパターンになる可能性がある
- SHA1のダイジェストの値がデバッグ時に何を指してるのかわからない
- Redisサーバー側でロードしたスクリプトはキャッシュとして保持されるのでいつ削除されるかわからずアプリケーションコード側でスクリプトを書く必要があった
- エフェメラルスクリプトも同様だが実行中は他の処理をブロックするため長時間の実行には気をつけるべきだがアトミックに操作できる
- Redisファンクションはマスターからレプリカへレプリケーションされる
- スナップショットやAOFとして永続化もされる
- ライブラリという概念も導入しコードの共通化などができるようになった
- エフェメラルスクリプトはLua5.1にしか対応していなかったが、Redisファンクションは仕様上言語を問わない
- なので今後はJavaScript対応などが予定されている
- ただし、現状まだLua5.1のみの対応
- RedisファンクションはMemorystore fore Redisのようなマネージドサービスでも対応してる
- go-redisのようなクライアントライブラリでも対応している
- なので、スクリプトをLuaで書いてアプリケーションコードと一緒に管理しアプリケーション側でスクリプトの関数を実行したりもできる
- ただし、関数を事前にサーバーにロードする必要があるのでデプロイフローに組み込むのかアプリケーション側で対応するのかいい感じに考えないといけない
- 以下はgo-redisを使ってスクリプトがロードされてるか確認しされてなければロードした後にスクリプトを実行する例(ChatGPTに書いてもらったので動くかはわからないのとロード済み関数を一覧取得しループでまわして確認してるのでオーバーヘッドみたいなのが気になるかもしれないけどそんなに大量のスクリプトがロードされることもないだろうから別に気にならないか)
package main
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
)
func main() {
ctx := context.Background()
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})
// 登録済みの関数を確認
functions, err := rdb.Do(ctx, "FUNCTION", "LIST").Result()
if err != nil {
fmt.Println("Error checking functions:", err)
return
}
// 関数 'mySum' が既にロードされているか確認
if !isFuncLoaded(functions, "mySum") {
// Functionをロードする
script := `
redis.register_function{
name = 'mySum',
flags = {'no-writes'},
body = function(keys, args)
return tonumber(args[1]) + tonumber(args[2])
end
}
`
_, err = rdb.Do(ctx, "FUNCTION", "LOAD", script).Result()
if err != nil {
fmt.Println("Error loading function:", err)
return
}
}
// Functionを呼び出す
result, err := rdb.Do(ctx, "FCALL", "mySum", "0", "10", "20").Result()
if err != nil {
fmt.Println("Error calling function:", err)
return
}
fmt.Println("Result:", result)
}
// isFuncLoaded checks if a given function name is already loaded in Redis
func isFuncLoaded(functions interface{}, name string) bool {
if result, ok := functions.([]interface{}); ok {
for _, v := range result {
if funcMap, ok := v.(map[string]interface{}); ok {
if loadedName, ok := funcMap["name"].(string); ok && loadedName == name {
return true
}
}
}
}
return false
}
トランザクション
- Redisはコマンド一つ一つはアトミックに実行されるが一連のコマンド処理は他のクライアントから割り込まれる可能性がある
- MULTI/EXECコマンドを実行することで完全にアトミックに一連の処理を実行できる
- ただ、あくまでMULTI/EXECUTEによるトランザクション処理はコマンドひとつひとつをキューイングして最後にまとめて実行するというようなもの
- 文法上のエラーなどであれば事前にエラーで防げるが実行しないとわからないようなエラーはそのまま実行されてしまう
- そして、RDBMSのようなロールバック機能はRedisにはない
- なので、完全な整合性を保つことはできないので注意したい
モジュール
- RedisはAPIを通じてC言語で書いたコードでRedis自体を拡張することができる
- これは新しいデータ型を定義したりカスタムのコマンドを定義したりができる
- ちょっとそこまでRedisに依存して使い回すケースがあるのかわからないし、C言語で書くのでだいぶ低レベルな話でそもそもC書けないといけなかったりするので使う機会があるかどうかはわからない
RTTの削減について
- RTTの削減にはMSETのような複数キーを扱うコマンド、パイプライン、トランザクション、エフェメラルスクリプト、Redisファンクション、モジュールが使える
- MSETのような複数キーを扱うコマンドで済むのであれば容易に採用できる
- ただし、異なるデータ型の操作はできない
- そのような場合はパイプラインなどを使うことを検討するといい
- パイプラインは異なるデータ型も扱うことができるが実行中に他のクライアントも処理できるためアトミック性はない
- トランザクションやスクリプト、モジュールは処理をブロックするのでアトミック性のある操作ができる
- トランザクションは複雑なロジックは書けないのでそのような場合はスクリプトかモジュールがいい
- スクリプトはエフェメラルスクリプトとRedisファンクションがあるがRdis7.0以降であればRedisファンクションを使うと良さげ
- Cが書けて、Redis使い倒すつもりならモジュールが最も拡張性があるものかもしれないがマネージドのRedisだと対応してないかもしれない
Redisのモデリング設計について
- RedisにはHash型やSet型などがあるためある程度構造的な値を保存できる
- ただし、Hash型などはデータ操作がO(N)の計算量になることがあるのでデータ量が多くなるとパフォーマンスに影響する
- String型でも
user:1
のようなプレフィックス付きのKey設計をすることである程度どうにかなる - 1番アンチパターンなのはHash型でuserが増えるたびに
user1
のようなフィールドを追加する場合、容易にGBクラスの巨大Hashデータが出来上がってしまう - 新たにデータをモデリングする場合はデータ量が増えても問題ないかよく考えよう
Redisの設計まわり
- Redis Clusterは本当に必要ですか?
- 扱うRedisクライアントによっては対応していないクライアントもあります
- 単一のRedisでも十分捌ける要件かもしれません
- 大規模データを扱う場合、String型以外のデータを扱うことはよく考えてください
- データ型によっては計算量がO(N)になります。
- String型だけでも適切なprefixでキー設計すれば十分柔軟なデータを扱えます
- 大量のSetや巨大なHashを扱うことにならないかよく考えましょう
レプリケーション
- レプリケーションの目的は高可用性とデータの冗長化
- 読み込みのスケールアウトにも利用されたりする
Redisクラスター
- 0個以上のレプリケーションと3個以上のシャードからなる
- レプリケーションで読み込みのスケールができるが書きこみのスケールはしない
- Redisクラスターを使うことで書き込みもスケールできる
- 読み込みのスケールをする場合は1個以上のレプリケーションでREADONLYコマンドを実行してレプリケーションから読み込めるようにする必要がある
- アプリケーションでコードで利用するRedisクライアントのオプション指定であるかも
- Redisクラスターではスロットにデータを割り当て、各シャードはどのスロットを担当するかを決めてデータを分散管理する
- なので各シャードが持っているデータは全て違う
- じゃあどうやってクライアントはデータを取得するかというとプロキシのようなものも挟んでいない
- プロキシがあればどのスロットのデータかを判断してどのシャードにアクセスするかを割り振りしてくれそう
- Redisクラスターではそのようなプロキシを使わずアクセス先にデータがなかった場合はデータがあるシャードへリダイレクトさせる
- クライアントがその情報をキャッシュ保持できるとリダイレクトのオーバーヘッドもなくなる
- Redisクラスターでは16384のハッシュスロットがある
- どのスロットに割り当てられるかはキーをもとにハッシュ計算した結果をもとに決まる
- Redisクラスターのシャードは2つのポートを解放している
- 一つはクライアント用でデフォルトが6379
- もう一つは内部の他のシャードとやり取りする用で10000足した数のポート
- これはクラスターバスポートとか呼ばれたりする
- クラスター内のノード間で設定情報をお互いに把握するためにRaftという分散合意アルゴリズムが使われていたりする
- Redisクラスター内のどれかのノードにクライアントからアクセスしそのノード内にデータがなかったときMOVEDリダイレクトがクライアントに返り、クライアントでリダイレクトする
- そのためクライアント側でどのデータがどのノードにあるのかをキャッシュできる
- キャッシュを利用することで単体のノードと同じくらいのレイテンシーを実現している
- RedisクラスターではMSETやエフェメラルスクリプトを実行するときなど扱うキーは全て同じスロットになければならない
- そうでなければCROSSSLOTエラーとなる
- そういうときは
{}
で共通のキーを囲ってキーとすることで同じスロットにデータを保存できる。ハッシュタグというそうだ - ただし、これはデータの偏りを生むので多用は厳禁
- クラスター対応が言語ごとのクライアントでまちまちなのはリダイレクトを処理したり、スロットのマッピング情報をキャッシュしておかなくてはいけなかったりするのがクライアント側の責務だから
- なので、プログラミング言語ごとで対応がまちまちになるのでRedis Cluster Proxyのようなものもあるがメンテされてないっぽい
- Envoy Redisというプロキシが最近ではあるっぽい
- クラスター対応で困った時に使えるかもしれない
- RedisRaftモジュールが開発中らしい
- Redisファンクションで実行するスクリプトは他のノードにレプリケーションしてくれない
- そんなときは全部のノード対象にFUNCTION LOADを実行するとかもできるらしい
RedisRaftモジュールについて
- Redis Cluster内の設定の共有などにRaftという合意形成アルゴリズムが使われてる
- このRaftを使って全てのノードでデータを一貫して保持するRedisRaftモジュールが開発中らしい
- Redis Clusterはデータを分散して保持し、一貫性は持たない
- データの整合性についてより厳しいシチュエーションでは使用が期待されるものかもしれない
このスクラップは2024/04/19にクローズされました