Future Tech Blog
フューチャー開発者ブログ

システム開発で得たRedis利用ノウハウ

  • 竹内洋介

こんにちは。初投稿です。
2012年新卒入社の竹内です。入社当時を振り返るとOracle10g,11gを良く利用していおり、データモデリングなどテーブル設計が好きで、2018年4月ぐらいまでRDBとバッチに浸ってました。

さて、現在プロジェクトでRedisを使っているのですが、いままでRDB人間だっただけにKVSやRedisならではの特徴に四苦八苦してます。

苦しんだ分、色々な知見を得ることができているので、その内容をご紹介します!

対象者

  • Redisの業務システム導入を検討している方
  • RDBとRedisの違いを知りたい方
  • 現場的なRedisの利用方法を知りたい方

書いてないこと

  • データ型やコマンドなど、HelloWorld的に公式ドキュメントを見て得られる情報
  • インストールなど、Redisを利用できるまでの手順
  • フェイルオーバーやバックアップをはじめとする運用に関する内容
  • データ永続化に関する内容

書いてること

  • 設計・実装に関わる以下の内容
    • 公式ドキュメントに書いてあるけど、よく読まなきゃ見落としてしまうような落とし穴
    • 公式ドキュメントに書いてある内容から一歩踏み込んだ挙動(「それってつまりどういうこと?」)
    • と、それに対する私の考え(「じゃあどうすればいい?」)

検証用サーバ情報

  • redis_version:4.0.10
  • redis_mode:standalone
  • os:Amazon ElastiCache
  • マスタ・スレーブ構成(スレーブ1つ)

目次

    1. Redisとは(教科書的なサマリ)
    1. シングルスレッド
    1. Transaction の実現方法
    1. Hash型はMultiGetできない
    1. KEYSは怖い
    1. おまけ:データ量試算時の注意
    1. 今後試したいこと
    1. 所感
    1. ためになるサイト

0. Redisとは(教科書的なサマリ)

  • Redis はキーと5種類の値型の対応関係を格納する非リレーショナルデータベース(NoSQL)。
  • メモリ上にデータを持つインメモリDBのため、非常に高速。

1. シングルスレッド

Redisサーバはシングルスレッド1で動作します。(厳密には他にもスレッドあるがクエリ処理をするスレッドは1つ)

..これだと、「あっそうなんだ」で終わっちゃいますよね。
しかしながら、以下の点に注意してください。

  • Keysのような重いクエリは本番運用禁止!
    • 高速なはずのRedisで簡単に待ちが発生します。詳細・対策は後述
  • 性能検証でCPU使用率余裕♪と思いきや…
    • あなたのRedisサーバは何コアですか?
    • 4コア?それなら性能計測した RedisのCPU使用率はその4倍 として考える必要があります。

AWSではEngineCPUUtilizationというメトリクスを使えば、上記を加味したCPU使用率が見れます。性能検証等で測定する際はこちらの指標を使いましょう。

2. トランザクションの実現方法

トランザクション中は、クエリをQueueに溜めます。

  • RDBでいうCommitのタイミングでそのクエリを順に(FIFOで)実行します(EXEC
  • RDBでいうRollbackはQueueを取り消すことで実現しています(DISCARD

…これも、「あっそうなんだ」と思いますよね。
しかしながら、以下の点に注意してください。

  • Commit前のデータはRDBと異なり自分のトランザクション中で取得できない
    • Queueに溜めているだけなので、Redisのデータは一切更新されていないです
    • INCRというValueを+1してその結果をReturnするコマンドがありますが、トランザクション中に実行するとNULLがReturnされます。トランザクションを使わない場合は更新後の値が取得できます
  • Commitするまで実行されない=実行順注意
    • 例えば、Javaで実装した1~4の作り替え処理で、データが作成されない事象が発生しました。

1.Transaction開始
2.Redisのデータを複数DELETE
3.Redisのデータを作成(並列処理) ←並列処理でDELETEが入ったQueue以外のQueueが作成
4.Transaction終了

並列処理で新たなQueueが作られた結果、トランザクション終了のタイミングでDELETE処理を含んでいないQueue、DELETE処理を含んでいるQueueを同時にEXEC。 同時に動いた結果、3の登録が先に処理されあとで2のDELETEで消されたデータがいたようです。並列化をやめることで解消しました。

RDBでは都度データを書き換えに行くため、実行順の入替は起こりません。

3. Hash型はMultiGetできない

Hash型はKeyの中に複数のFieldとValueを持てるため、RDB慣れしているとついつい使いたくなります。
ここに落とし穴があります。

Hash型はString型のデータと違い複数レコードを一括取得するメソッドが提供されていません。

複数レコードを取得して表示しようとすると1件ずつループ処理で取得することになり、NWのオーバーヘッドでとたんに遅いという性能問題になりかねません2

回避策

自分達もハマり、色々悩んだのでいくつか回避策を紹介します。

3-1. Hashを捨てる!

潔くHashを捨てます。以下のようにString型で持つことで複数キーを一括で取得するMGETが使えるようになります。Hashとして考えていたまとまりの概念はRedisにアクセスするEntityや、RedisのデータをGETするAPIが吸収すれば、大きな影響はないと思います。

Hashイメージ.json
1
2
3
4
5
6
7
{
"key": "sales:111",
"value": {
"name": "REDBULL",
"amount": "200"
}
}
Hashを捨てたイメージ.json
1
2
{  "key": "sales:111:name", "value": "REDBULL"}
{ "key": "sales:111:amount", "value": "200"}

3-2. HashのMultiGetを実装する!

標準機能ではないようですが、以下2つの方法でHashの一括取得を実装できます。

実装方法1. Luaスクリプト

RDBには、複数クエリ実行やIF文・ループ処理などの一連の手続きを一回のクエリでRDB上で実行できるストアドプロシージャやストアドファンクションというものがあります。Luaスクリプトを使えばRedisでもそれと同様のことができます。

実際にHash型のKeyを一度に複数渡して処理できるスクリプトを作ってみました。試したところ、検証サーバでは1件7msかかってたHGETALLでしたが、10000件一括取得で400msぐらいでデータ取得できました。注意ですが、Luaスクリプトもクエリ処理用のスレッド(シングルスレッド)で動作するため、重い処理は避けるべきです。

※Javaから実行する方法はこちらを参考ください。

luaスクリプト

hmgetall.lua
1
2
3
4
5
6
7
-- param1: hmgetallで取得したいkeyの数 param2~: 取得したいkey(半角スペース区切りで複数可能)
local result = {}
for i = 1, KEYS[1] do
result[i] = {'"key"', KEYS[i+1] ,redis.call('HGETALL', KEYS[i + 1])}
end

return result

使い方

hmgetall.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
redis-cli -h ${REDIS_HOST} -p ${PORT} --eval ./hmgetall.lua 2 sales:111 sales:222

## Return Data
## 1) 1) "\"key\""
## 2) "sales:111"
## 3) 1) "\"name\""
## 2) "REDBULL"
## 3) "\"amount\""
## 4) "200"
## 2) 1) "\"key\""
## 2) "sales:222"
## 3) 1) "\"name\""
## 2) "MILK"
## 3) "\"amount\""
## 4) "165"
実装方法2. syncAndReturnAll

Redisのライブラリによっては、1件ずつ同期的に処理するのではなく、「Queueに詰めて一括で実行して、その結果をまとめて取得する」ってことができるようです(JavaはJedisでできることを確認済)。使用する言語でいいライブラリがあればLuaスクリプトよりもこちらの方がよいと思います。

Queueに詰めた実行順でレスポンスも返ってくるので、Keyとのマッピングもできます。keyがない場合も空のMapが返るのでマッピング順がずれることはないです。
http://tool.oschina.net/uploads/apidocs/jedis-2.1.0/redis/clients/jedis/Pipeline.html#syncAndReturnAll%28%29

hMGetAll.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  Jedis jedis = new Jedis (host, port );
Pipeline pipe = jedis.pipelined();
pipe.hgetAll("sales:111");
pipe.hgetAll("sales:222");
pipe.hgetAll("sales:NotExist");
List<Object> result= pipe.syncAndReturnAll();
/*
結果
result= {ArrayList@14772} size = 3
0 = {HashMap@14777} size = 2
""name"" -> "REDBULL"
""amount"" -> "200"
1 = {HashMap@14778} size = 2
""name"" -> "MILK"
""amount"" -> "165"
2 = {HashMap@14779} size = 0
*/

4. KEYSは怖い

ここで、Redisに登録されたKey一覧を取得するKEYSというコマンドをご紹介します。

例えば KEYS "sales:*" と実行すれば正規表現(※)でkey検索できます。

keysの正規表現は機能に制限があり以下だけです。
? ・・・ 任意の1文字
* ・・・ 任意の文字列
[ ] ・・・ 角カッコ内の文字のどれか1文字

なんとなく便利そうな気、しますよね。

しかしながら、以下の点に注意してください。

  • このKEYSはRDBのINDEXを用いた前方一致での効率的な検索をしません。O(N)となりめっちゃ遅いです
    • 計測時は約9百万の中から10件検索するようなクエリで1秒かかりました
  • その間のRedisはシングルスレッドでクエリ捌くため、他のリクエストを捌けません。ReadもWriteの両方ともさばけません。

Jedis(Java用ライブラリ)だとデフォルトtimeoutが2秒なので、2,3人がkeysを2発ぐらい実行するとそれだけでtimeoutエラーが出たりします。公式でもWARNINGって書いてます。https://redis.io/commands/keys

While the time complexity for this operation is O(N), the constant times are fairly low. For example, Redis running on an entry level laptop can scan a 1 million key database in 40 milliseconds.
Warning: consider KEYS as a command that should only be used in production environments with extreme care. It may ruin performance when it is executed against large databases. This command is intended for debugging and special operations, such as changing your keyspace layout. Don’t use KEYS in your regular application code. If you’re looking for a way to find keys in a subset of your keyspace, consider using SCAN or sets.


KEYSの代替方法

「今の設計にはKEYSが必要なんだ!」っていうことありますよね。代替方法、そろえてます。

RDBから取得

一定の条件が揃えば使える方法。例えば販売のトランザクションデータはRDBでその日の商品別のサマリデータはRedisという時です。
RDBから販売トランや商品マスタをSELECT DISTINCTすれば…

Sets型利用

Redisのデータ型には、Sets型という同じデータは無視する「重複なしリスト型」が存在します。

これに保持しているKey情報を登録しておくようにします。

例えば販売のサマリデータを作成・更新する際に、Sets型にデータ追加のコマンド(SADD)を都度発行します。※Redisの登録は高速(Pipeline処理で計測時は0.01ms程度)なので追加のSADD処理の性能影響は無視できるものと考えてます。

redis_sets_command.sh
1
2
3
4
5
6
7
8
9
10
11
12
# Sets型のデータ登録
## データ登録
redis-cli -h ${REDIS_HOST} -p ${PORT} SADD sales:keys sales:111
redis-cli -h ${REDIS_HOST} -p ${PORT} SADD sales:keys sales:222
redis-cli -h ${REDIS_HOST} -p ${PORT} SADD sales:keys sales:111

## データ取得
### 全件取得
redis-cli -h ${REDIS_HOST} -p ${PORT} SMEMBERS sales:keys #sales:111 とsales:222が返ってくる

### カーソル的に一定件数ずつ取得することも可能
redis-cli -h ${REDIS_HOST} -p ${PORT} SSCAN sales:keys 0 COUNT 1000

抜け道:KEYSは使う。負荷を下げる。

今更変えられないよ!抜け道ないの!?ということでこれも検討しました。
設計・実装変えれないときの最終手段に近い位置づけです。最初から選択すべき内容ではないです。


DBを分ける

KEYSの検索対象はRedisの同一DBの範囲に閉じられています。DBを分けて、KEYSの対象減らすことで負荷を下げることができます。ただし、DBごとにスレッドが分かれるわけではないです。別DBへのクエリも同じシングルスレッドで処理されます。

参照用レプリカ使用

DBではなく別インスタンスを使おう、KEYS専用にレプリカ使おうっていう考えです。

この方法はDB分けとは違い、インスタンスが分かれるので、KEYS実行中にもマスター側でクエリを処理することが可能です。


視点を変える:本当にKEYSが必要? ~ 処理方式を見直そう ~

RDB脳だった自分にはなかなか思いつかなかった設計方式を記載しておきます。

CASE1: Redisは毎日リセットしたい。対象データを一括で消したい。

一括で消すには、KEYSで一覧取得が必要。なんてとき。

  • 見直し検討1:flushdbの利用
    • 一括で消したい単位でDBを分けておけば、FLUSHDBの1コマンドで削除できます
  • 見直し検討2:データ有効期間の設定
    • EXPIREでデータに有効期限を設定できます。期限切れになると自動削除されます
CASE2: 数値を合計するためにKEYSで対象データがほしい。

「店舗商品別データの売上金額」から、「店舗合計の売上金額」を取得するために、該当店舗のkey一覧取得→それぞれのデータをGETしてサマリしよう。なんてとき。

見直し検討:最初から店舗合計のデータを作成する
一括処理(更新・取得)はRDBの得意領域。細かく持って集計してっていうバッチ処理はRDB的考え方です。
Redisは数件を書き込む・読み込むことが高速。販売がある度に、店舗別に商品別のデータと、店舗合計のデータを更新すればいいじゃない。Redisの更新は速いので。数値の更新時はINCRを使えば、事前にロックとか考えずに数値を+-できます。


5. おまけ:データ量試算時の注意

Hash型でデータを持たせた場合、HashのField名も含めることを忘れずに。
RDBのテーブルのレコードのイメージでHash型を使うと、うっかりカラム名の試算を忘れがちなので注意です。
Valueが数値だったりすると、主にField名で容量喰いますw


6. 今後試したいこと

Sets型を使ってRedisだけでKey検索も行う

業務システムでは「条件によって絞り込んで一覧表示する」ということが多いでしょう。

属性情報を元に該当するKeyを調べるため、RDBのマスタにアクセスすることになるかと思いますが、このKey検索もRedisだけで実現したいという内容です。

Sets型和集合(Union)、積集合(Intersection)、差集合(Difference)をサポートしてるので、属性情報に応じたKeyの検索も実装できそうです。例えば、飲み物のSets、今日売れたものSetsがあれば、積集合で「今日売れた飲み物」のキー一覧を取得できます。

…ここまでやるかって内容ですね。

Hash型で列指向でデータを持たせる。

ついつい行指向で考えがち。数値情報を扱いたい場合、列指向で持たせると HGETALL で一度にまとめて取得できるので、列指向で持たせる方がいいかもしれません。
HSCANっていうカーソル的にデータ取得できるコマンドあるぐらいなのでそっちを想定しているのかも。
※同じKey内でフィールドの重複許さないので、列指向的な持ち方だとSets型 + String型の要素を足した扱い方ができますね。Sets型のような集合演算はできませんが。

7. 所感

正直、RDBだとこんなの簡単にできるのにーと思うことが多々あり、最初はRedisを嫌いになりかけました。しかしRedisはKVSの中でもかなりRDBの人達に歩み寄ってくれてると感じます(Sets型やHash型はすごく好感持てます)。色々な可能性が見えてきて、今では気になる存在です。

この記事を読んでRedisを気にする仲間が増えればめちゃ嬉しいですー。

8. ためになるサイト


関連記事: