Redis とは、いわゆる NoSQL (Not only SQL)KVS (Key-Value-Store) と呼ばれる類のデータベースの一つです。Redis の特徴を以下に挙げます:

  • インメモリにデータを保持する
  • データ構造を直接扱える

インメモリでデータを保持するため、データをディスクに保存するタイプのデータベースに比べて高速に動作します。Web システムにおいては、ページ出力のキャッシュ、ログインセッションの保存・読み込み、など高いパフォーマンスが求められる部分への適しています。当然、パフォーマンスのトレードオフとして永続化を捨てているので、データの永続性が重要な向きには利用できません。1

Redis はリストや集合のようなデータ構造を直接扱えるようになっています。各データ構造に対するコマンドが豊富に用意されており、それらはすべてアトミックに動作します。例えば文字列に対して INCR (インクリメント), DECR (デクリメント) といったコマンドが提供されています。これらはアトミックに動作するので、(コマンド単位で) 処理の割り込みを気にする必要はありません。

ソート済みセットは Redis の中でも特徴的なデータ構造です。このデータ構造(とインメモリによる高速性)を利用することで、リアルタイムなランキング集計、最新情報一覧のリアルタイム出力といった機能を高いパフォーマンスを保ったまま実現することができます。

私は RDBMS をこよなく愛する技術者の一人ですが、食わず嫌いはよくないということで、NoSQL (で、かつ現実のシステム開発でも使う機会もありそうな Redis) も触っておこうという趣旨です。

どこで使われているか

RDBMSの苦手な処理をカバーする、気の利いたNoSQL「Redis」 - (page 2)Who's using Redis? にまとまっています。一部、紹介されている内容をピックアップしました:

使ってみる

まずは Redis の基本的な機能をひと通り触ってみます。CentOS 上に Redis をインストールし、Redis サーバーを起動します。はじめは Redis 組み込みのクライアントである redis-cli を利用して操作していきます。その後は Python の Redis クライアントライブラリを使って Redis を操作していきます。

Redis のインストール

Redis のビルドは簡単です。make と gcc (と、テストを実行したければ tcl) をインストールして、make, make install するだけです。Redis の依存ライブラリは、Redis の配布物にすべて同梱されています。

$ sudo yum install make gcc
$ curl http://download.redis.io/releases/redis-3.0.5.tar.gz >/tmp/redis-3.0.5.tar.gz
$ tar xf /tmp/redis-3.0.5.tar.gz -C /tmp
$ cd /tmp/redis-3.0.5
$ make
$ sudo make install

サーバーを起動する

redis-server コマンドを叩きます。

$ redis-server
14771:C 08 Dec 12:50:05.816 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
14771:M 08 Dec 12:50:05.819 # You requested maxclients of 10000 requiring at least 10032 max file descriptors.
14771:M 08 Dec 12:50:05.819 # Redis can't set maximum open files to 10032 because of OS error: Operation not permitted.
14771:M 08 Dec 12:50:05.819 # Current maximum open files is 4096. maxclients has been reduced to 4064 to compensate for low ulimit. If you need higher maxclients increase 'ulimit -n'.
                _._
           _.-``__ ''-._
      _.-``    `.  `_.  ''-._           Redis 3.0.5 (00000000/0) 64 bit
  .-`` .-```.  ```\/    _.,_ ''-._
 (    '      ,       .-`  | `,    )     Running in standalone mode
 |`-._`-...-` __...-.``-._|'` _.-'|     Port: 6379
 |    `-._   `._    /     _.-'    |     PID: 14771
  `-._    `-._  `-./  _.-'    _.-'
 |`-._`-._    `-.__.-'    _.-'_.-'|
 |    `-._`-._        _.-'_.-'    |           http://redis.io
  `-._    `-._`-.__.-'_.-'    _.-'
 |`-._`-._    `-.__.-'    _.-'_.-'|
 |    `-._`-._        _.-'_.-'    |
  `-._    `-._`-.__.-'_.-'    _.-'
      `-._    `-.__.-'    _.-'
          `-._        _.-'
              `-.__.-'

14771:M 08 Dec 12:50:05.824 # WARNING: The TCP backlog setting of 511 cannot be enforced because /proc/sys/net/core/somaxconn is set to the lower value of 128.
14771:M 08 Dec 12:50:05.824 # Server started, Redis version 3.0.5
14771:M 08 Dec 12:50:05.824 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
14771:M 08 Dec 12:50:05.825 # WARNING you have Transparent Huge Pages (THP) support enabled in your kernel. This will create latency and memory usage issues with Redis. To fix this issue run the command 'echo never > /sys/kernel/mm/transparent_hugepage/enabled' as root, and add it to your /etc/rc.local in order to retain the setting after a reboot. Redis must be restarted after THP is disabled.
14771:M 08 Dec 12:50:06.021 * DB loaded from disk: 0.197 seconds
14771:M 08 Dec 12:50:06.021 * The server is now ready to accept connections on port 6379

オプションを指定しなければ、6379 番ポートを使う redis サーバーのプロセスが起動します。

コマンドを叩く

クライアントとして redis-cli を利用し、Redis を操作してみます。

$ redis-cli
127.0.0.1:6379> set hello world
OK
127.0.0.1:6379> get hello
"world"
127.0.0.1:6379> del hello
(integer) 1
127.0.0.1:6379> get hello
(nil)

SET は 2 つの引数をとります。第一引数がキーで、第二引数が値です。Redis に対し、キー "hello" に対して文字列の値 "world" を割り当てます。

GET はキーを引数にとり、キーに割り当てられた値を取得します。

DEL はキーを引数にとり、キーに割り当てられた値を削除します。

SET, GET, DEL のような基本的な読み書きのコマンドから、Redis が提供するデータ型に特化したコマンド:

  • 数値のインクリメント/デクリメント
  • リストへの PUSH/POP
  • 集合演算

などが用意されています。次の章では、各種データ型の特徴と、各型に特化されたコマンドを見ていきます。

データ構造

Redis は 5 つのデータ構造を提供しています:

  • STRING (文字列)
  • LIST (リスト)
  • SET (集合)
  • HASH (ハッシュ)
  • ZSET (ソート済みセット)

ここでは各データ構造の特徴と、代表的なコマンドとその動きを説明していきます。

STRING (文字列)

STRING は Redis のもっとも基本的なデータ構造です。STRING と一口に言っても、大きく 3 種類の値を表現します:

  • バイト列
  • 整数
  • 浮動小数点数

クライアントから与えられた値や操作によって、どの表現の STRING であるかが切り替わります。

$ redis-cli
127.0.0.1:6379> set foo hoge
127.0.0.1:6379> set bar 1
127.0.0.1:6379> set bar 1.1

それぞれ、foo はバイト列、bar は整数、baz は浮動小数点数として扱われます。

整数、浮動小数点数のデータサイズは、プラットフォームの long int, double 型のサイズと一致します。

文字列はバイト列として保持されるため、文字コードなどは一切加味されません。クライアント側で管理する必要があります。

以下は STRING に対する代表的なコマンドです:

コマンド 機能
GET キーに割り当てられた値を取得する。
SET キーに値を割り当てる。
DEL キーに対する値の割り当てを削除する。 (すべての型で共通)
INCR キーに割り当てられた値に 1 を足す。
DECR キーに割り当てられた値から 1 を引く。
INCRBY キーに割り当てられた値に、指定の数値を足す。
DECRBY キーに割り当てられた値から、指定の数値を引く。
INCRBYFLOAT キーに割り当てられた値に、指定の浮動小数点数を足す。

以下は各コマンドの使い方と動きを示しています:

>>> import redis
>>> conn = redis.Redis()
>>> conn.get('key')
>>> conn.incr('key')
1
>>> conn.incr('key', 15)
16
>>> conn.decr('key', 5)
11
>>> conn.get('key')
'11'
>>> conn.set('key', 13)
True
>>> conn.incr('key')
14

ちなみに INCRBY には負数が指定できます。なので実質的には INCRBY だけあればインクリメント、デクリメント、任意の値の増減が可能です。

以降で解説する各種データ構造の要素としては、STRING 型のデータだけが設定できるようになっています。ネストしたデータ構造を表現したい場合、例えばリストのリストを表現したい場合には、リストにリストのキーを持たせたり、JSON などのデータ表現を用いることで実現します。Redis ではコマンドの構文の単純さを保つため、ネストしたデータ構造を直接サポートしないようになっています。

LIST (リスト)

LIST はデータの並びを表現するデータ構造です。連結リストとして実装されています。連結リストであるため、値の追加や削除は高速に動作します。またリストの先頭、末尾それぞれに対する PUSH, POP を提供しており、キューやスタック、デックとしても利用できます。

LIST に対するコマンドは、R か L で始まるものが多く見られます。Redis のコマンド表現上、リストは左から順番に要素を並べたものと考えられており、R は右端 (つまりリストの末尾) を基準とした操作、L は左端 (つまりリストの先頭) を基準とした操作を表します。

以下は LIST に対する代表的なコマンドです:

コマンド 機能
RPUSH リストの右端に値を追加する
LPUSH リストの左端に値を追加する
RPOP リストの右端から値を削除して返す
LPOP リストの左端から値を削除して返す
LINDEX 指定位置の要素を取得する
LRANGE 指定範囲の要素を取得する
LTRIM 指定範囲だけが残るようにリストを切り詰める
BLPOP 空でないリストから LPOP するか、タイムアウトまで待つ
BRPOP 空でないリストから RPOP するか、タイムアウトまで待つ
RPOPLPUSH リストから RPOP し、別のリストに LPUSH する
BRPOPLPUSH 空でないリストから RPOP し、別のリストに LPUSH する。

以下は各コマンドの使い方と動きを示しています:

>>> conn.rpush('list-key', 'last')
1L
>>> conn.lpush('list-key', 'first')
2L
>>> conn.rpush('list-key', 'last2')
3L
>>> conn.lrange('list-key', 0, -1)
['first', 'last', 'last2']
>>> conn.lpop('list-key')
'first'
>>> conn.lrange('list-key', 0, -1)
['last', 'last2']
>>> conn.rpop('list-key')
'last2'
>>> conn.lrange('list-key', 0, -1)
['last']
>>> conn.rpush('list-key', '1', '2', '3')
4L
>>> conn.lrange('list-key', 0, -1)
['last', '1', '2', '3']
>>> conn.ltrim('list-key', 2, -1)
True
>>> conn.lrange('list-key', 0, -1)
['2', '3']
>>> conn.rpush('list2-key', 'foo')
1L
>>> conn.rpoplpush('list-key', 'list2-key')
'3'
>>> conn.lrange('list-key', 0, -1)
['2']
>>> conn.lrange('list2-key', 0, -1)
['3', 'foo']

BLPOP や BRPOPLPUSH のようなブロック付きのコマンドの用途としては、ジョブキューなどが考えられます。

SET (集合)

SET はデータの集合を表現するデータ構造です。ハッシュ表として実装されています。集合であるため、LIST とは異なりデータの並び順は加味しません。また、データの重複を許可しません。

以下は SET に対する代表的なコマンドです:

コマンド 機能
SADD 集合に要素を追加する
SREM 集合から要素を削除する
SISMEMBER 要素が集合に含まれているかどうか返す
SCARD 集合の要素数を返す
SMEMBERS 集合の全要素を返す
SDIFF 第一引数の集合の中で、第二引数以降のどの集合にも含まれていない要素を返す
SINTER すべての集合に含まれている要素を返す
SUNION いずれかの集合に含まれている要素を返す
SDIFFSTORE SDIFF の結果を指定のキーに割り当てる
SINTERSTORE SINTER の結果を指定のキーに割り当てる
SUNIONSTORE SUNION の結果を指定のキーに割り当てる

SADD から SCARD までは LIST と似たような操作で、集合に対する要素の出し入れ、要素へのアクセスを提供します。

SET の真骨頂は SDIFF 以降のコマンドです。一般には集合演算と呼ばれる類の操作になります。

>>> conn.sadd('set-key', 'a', 'b', 'c', 'd')
4
>>> conn.sadd('set-key2', 'c', 'd', 'e', 'f')
4
>>> conn.sdiff('set-key', 'set-key2')
set(['a', 'b'])
>>> conn.sinter('set-key', 'set-key2')
set(['c', 'd'])
>>> conn.sunion('set-key', 'set-key2')
set(['a', 'c', 'b', 'e', 'd', 'f'])

HASH (ハッシュ)

HASH はいわゆる辞書を表現するデータ構造です。いわば、小さな Redis を表現します。DB レコードを列名付きでキャッシュする場合などに向きます。

以下は HASH に対する代表的なコマンドです:

コマンド 機能
HGET ハッシュのフィールドの値を取得する
HSET ハッシュのフィールドに値を設定する
HMGET ハッシュの複数フィールドの値を同時に取得する
HMSET ハッシュの複数フィールドの値を同時に設定する
HDEL ハッシュのフィールドを削除する
HLEN ハッシュのエントリ数を返す
HEXISTS ハッシュのフィールドが存在するかどうか返す
HINCRBY ハッシュのフィールドの値をインクリメントする

HGET, HSET はハッシュに対するもっとも基本的な操作です。ハッシュに対する値の出し入れを提供します。HMGET, HMSET は HGET, HSET の発展版とも言えるもので、複数のフィールドを一括して扱えます。複数のフィールドにアクセスする必要がある場合、HGET, HSET を複数呼び出す場合よりも通信回数を減らすことができるため、パフォーマンスの向上が見込めます。

>>> conn.hmset('hash-key', {'a':'A', 'b':'B', 'c':'C'})
True
>>> conn.hmget('hash-key', ['a', 'b', 'c', 'd'])
['A', 'B', 'C', None]
>>> conn.hdel('hash-key', 'a')
1
>>> conn.hlen('hash-key')
2

ZSET (ソート済みセット)

ZSET は SET と同様にデータの集合を表現しつつ、集合内のデータをソート済みの状態で保持するデータ構造です。ハッシュ表とスキップリストの組み合わせで実装されています。ZSET に格納されるデータには、スコアと呼ばれる浮動小数点数を付与することになっています。いわば、値がスコアな HASH であるとも言えます。ZSET のデータはスコアをキーとしてソートされます。

以下は ZSET に対する代表的なコマンドです:

コマンド 機能
ZADD 指定のスコアを持つメンバーを ZSET に追加する
ZREM ZSET から指定のメンバーを削除する
ZCARD ZSET の要素数を返す
ZINCRBY 指定のメンバーのスコアをインクリメントする
ZCOUNT スコアが指定の最小値、最大値に含まれる要素数を返す
ZRANK 指定のメンバーの ZSET 内での位置を返す
ZRANGE ランクが start から stop までのメンバーを返す
ZREMRANGEBYRANK ランクが start から stop までの要素を ZSET から削除する
ZINTERSTORE SINTERSTORE の ZSET 版
ZUNIONSTORE SUNIONSTORE の ZSET 版

ZADD, ZREM, ZCARD は SET に出てきたものとほとんど同じような使い方です。異なるのは ZADD ではメンバーのスコアを指定する必要があるということです。スコアは ZSET 内でメンバーをソートする際のキーとなる浮動小数点数です。

ZRANK, ZRANGE, ZREMRANGEBYRANK は ZSET が ソート済み であることを利用したコマンドです。ここでは挙げていませんが、ランクではなくスコアをベースにアクセスするためのコマンドも用意されています。

>>> conn.zadd('zset-key', 'a', 3, 'b', 2, 'c', 1)
3
>>> conn.zcard('zset-key')
3
>>> conn.zincrby('zset-key', 'c', 3)
4.0
>>> conn.zrank('zset-key', 'c')
2
>>> conn.zcount('zset-key', 0, 3)
2L
>>> conn.zrem('zset-key', 'b')
1
>>> conn.zrange('zset-key', 0, -1, withscores=True)
[('a', 3.0), ('c', 4.0)]
>>> conn.zadd('zset-1', 'a', 1, 'b', 2, 'c', 3)
3
>>> conn.zadd('zset-2', 'b', 4, 'c', 1, 'd', 0)
3
>>> conn.zinterstore('zset-i', ['zset-1', 'zset-2'])
2L
>>> conn.zrange('zset-i', 0, -1, withscores=True)
[('c', 4.0), ('b', 6.0)]
>>> conn.zunionstore('zset-u', ['zset-1', 'zset-2'], aggregate='min')
4L
>>> conn.zrange('zset-u', 0, -1, withscores=True)
[('d', 0.0), ('a', 1.0), ('c', 1.0), ('b', 2.0)]
>>> conn.sadd('set-1', 'a', 'd')
2
>>> conn.zunionstore('zset-u2', ['zset-1', 'zset-2', 'set-1'])
4L
>>> conn.zrange('zset-u2', 0, -1, withscores=True)
[('d', 1.0), ('a', 2.0), ('c', 4.0), ('b', 6.0)]

ZINTERSTORE や ZUNIONSTORE には SET も渡すことができます。SET に含まれる要素は、スコアが 1 のメンバーとして扱われます。

ZINTERSTORE や ZUNIONSTORE では、複数の要素を 1 つにまとめる際、そのスコアをどのようにまとめるかを指定することができます。SUM, MIN, MAX の 3 つをオプションとして利用でき、それぞれ、スコアの合計、最小のスコア、最大のスコアを結果のスコアとするよう動作します。上の例では ZUNIONSTORE で MIN を利用しています。メンバー b については zset-1 と zset-2 の両方に属しており、そのうちの小さいスコア 2 が zset-u におけるスコアとして利用されていることが分かります。

例1: ログインセッション管理

ここまで見てきたデータ構造、コマンドを利用して、簡単なサンプルを作っていきます。Web アプリケーションにおけるログインセッションの管理を Redis で行うサンプルです。

def check_token(conn, token):
    return conn.hget('login:', token)

check_token 関数では、'login:' というキーに割り当てられたハッシュにアクセスしています。ハッシュに token 変数が表すフィールドが存在すればその値を、なければ None を返します。token に対する値には、ユーザーの ID が含まれているものとします。

def update_token(conn, token, user):
    timestamp = time.time()
    conn.hset('login:', token, user)
    conn.zadd('recent:', token, timestamp)

update_token 関数では、token が表すログインセッションの情報を更新しています。'login:' ハッシュへの値の設定と、このセッションが最後に利用されたタイムスタンプを 'recent:' というキーに割り当てられた ZSET に設定しています。

システムが長い期間使われ続けると、Redis のメモリー消費量は増え続け、やがてシステムはクラッシュします。以下は 'recent:' ZSET を利用して古いデータを削除するプログラムです。

LIMIT = 10000000

def clean_sessions(conn):
    size = conn.zcard('recent:')
    if size <= LIMIT:
        return

    end_index = min(size - LIMIT, 100)
    tokens = conn.zrange('recent:', 0, end_index - 1)
    conn.hdel('login:', *tokens)
    conn.zrem('recent:', *tokens)

まず 'recent:' の要素数を調べ、クリーンアップを実施すべきかどうかを判定します。次に、クリーンアップ対象の要素数を決定します。ここでは、一度のクリーンアップでは最大 100 件までとしています。

件数を決めたら、'recent:' に対して ZRANGE で token の一覧を取得しています。'recent:' のスコアは最後にセッションが利用されたときのタイムスタンプなので、すなわち「最近利用されていないセッション」の ZRANGE で取得します。

最後に、'login:' と 'recent:' から tokens に含まれるトークンを削除しています。

実践的な機能

前節までで、Redis が提供するデータ構造と、各データ構造に対する基本的なコマンドを説明してきました。

以下の節では、Redis をより実践的に使うために必要な機能について説明していきます。

トランザクション

Redis のすべてのコマンドはアトミックな操作であり、コマンドの実行中に他のコマンドが割り込むことはできません。しかし、複数のクライアントが複数のコマンドを実行しようとする場合、各クライアントが送るコマンド間の割り込みは可能です。例えばクライアント A, B がそれぞれ GET, SET と SET, GET を送る場合:

A: GET
B: SET
A: SET
B: GET

のようになったり、

B: SET
A: GET
B: GET
A: SET

のようになったりと、他にもいくつかのパターンが考えられます。

このような複数のコマンドをアトミックに実行するための仕組みとして、Redis ではトランザクション機能が提供されています。Redis のトランザクション機能の特徴を以下に挙げます:

  • MULTI で開始、EXEC で実行、DISCARD で破棄
  • WATCH で楽観的ロック
  • ロールバックは提供しない

以下、それぞれ詳しく見ていきます。

MULTI, EXEC, DISCARD

MULTI を要求すると、EXEC か DISCARD するまで、すべてのコマンドがキューイングされます。具体的には MULTI でトランザクションの開始を要求すると、EXEC か DISCARD するまで、すべてのコマンドに対して "QUEUED" という応答を返すようになります。EXEC で実行すると、キューイングされたすべてのコマンドが順序通りに実行され、トランザクションが完了します。EXEC の前に DISCARD を要求すると、キューイングしていたすべてのコマンドが破棄されます。

EXEC による複数コマンドの一括実行はアトミックとなります。先の例のクライアント A, B がトランザクションを利用した場合:

A: GET
A: SET
B: SET
B: GET

もしくは

B: SET
B: GET
A: GET
A: SET

の 2 通りのパターンしかありえなくなります。クライアント A の GET/SET とクライアント B の SET/GET がそれぞれ不可分 (= アトミック) となるからです。

WATCH, UNWATCH

WATCH による楽観的ロックは、トランザクションを実行するまでの間に他のクライアントによって値が変えられることを検知するための仕組みです。いわゆる CAS (Check And Set 2) の振る舞いを提供します。WATCH したキーの値が EXEC でトランザクションを実行するまでの間に書き換えられた場合、EXEC は何もせずに Null-Reply を返して終了します。クライアントはこの EXEC の結果をもって、トランザクションが実行されたかどうかを判断することができます。

RDBMS のトランザクションでは、BEGIN から COMMIT (ROLLBACK) までの読み書き操作が互いに影響を与え合って動作します。

一方 Redis のトランザクションは EXEC が呼び出されるまで一切コマンドを実行しません。すなわち、トランザクション内で取得した値を利用した書き込みを行うことができません。値の取得コマンドも EXEC まで遅延されるからです。

127.0.0.1:6379> set foo 1
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> get foo
QUEUED
127.0.0.1:6379> set foo 2
QUEUED
127.0.0.1:6379> get foo
QUEUED
127.0.0.1:6379> exec
1) "1"
2) OK
3) "2"

ここで WATCH が登場します。MULTI でトランザクションを開始する前に、WATCH で参照するキーに対して楽観ロックをかけた上で、値を取得します。ひと通り必要なデータを取得した後、MULTI でトランザクションを開始し、書き込みコマンドを発行し、EXEC で一括実行します。この際、WATCH から EXEC までの間で、監視対象としたキーが他のクライアントに変更されれば、EXEC は失敗して何もせずに終了します。よくあるシナリオとしては、WATCH によるロック獲得に失敗した場合は、WATCH - MULTI - EXEC の一連の手続きを最初からやり直します。

127.0.0.1:6379> set foo 1
OK
127.0.0.1:6379> watch foo
OK
127.0.0.1:6379> get foo
"1"
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set foo 3
QUEUED
127.0.0.1:6379> get foo
QUEUED
127.0.0.1:6379> exec
1) OK
2) "3"

先に挙げた check_token, update_token, clean_sessions をトランザクションを利用する形に修正してみます。

def check_token(conn, token):
    return conn.hget('login:', token)


def update_token(conn, token, user, timeout=5):
    timestamp = time.time()
    pipe = conn.pipeline()
    pipe.hset('login:', token, user)
    pipe.zadd('recent:', token, timestamp)
    pipe.execute()


LIMIT = 10000000

def clean_sessions(conn):
    size = conn.zcard('recent:')
    if size <= LIMIT:
        return

    end_index = min(size - LIMIT, 100)
    tokens = conn.zrange('recent:', 0, end_index - 1)

    pipe = conn.pipeline()
    pipe.hdel('login:', *tokens)
    pipe.zrem('recent:', *tokens)
    pipe.execute()

Python の Redis クライアントライブラリでは、pipeline メソッドを使うことでトランザクションを開始することができます。Pipeline オブジェクトに対する Redis コマンドの呼び出しはクライアントサイドにキューイングされ、execute メソッドを呼び出したタイミングでサーバーに送られます。そして各コマンドの実行結果が execute メソッドの戻り値として返ってきます。

永続化

Redis はインメモリデータベースなので、プロセスが落ちるとデータが消えてしまいます。それを防ぐための仕組みとして、永続化のオプションが用意されています。Redis は 2 つの永続化の仕組みを提供しています。

  • スナップショット (RDB)
  • 追記専用ファイル (AOF: Append Only File)

スナップショット

スナップショットは、ある時点での Redis の状態をそっくりそのまま保存する形式の永続化方式です。周期的にスナップショットを取得する仕組みが用意されています。周期的に取得するという特徴上、データのロスは避けられません。例えば 15 分おきにスナップショットを取得するよう設定していれば、15 分 + スナップショットの保存にかかった時間分のデータをロスする可能性があります。

スナップショットの取得方法は大きく 2 つ用意されています:

  • BGSAVE
  • SAVE

BGSAVE は Redis から子プロセスを fork して、そのプロセス上でスナップショットを保存します。

SAVE は(他のコマンドと同じように)Redis を専有してスナップショットを保存します。

データが小さいうちは BGSAVE でも十分なパフォーマンスが出ます。データが巨大になってくると、BGSAVE は fork および親プロセスとのリソースの取り合いにより、SAVE に比べて格段に遅くなる傾向があります。

SAVE は fork もしない、スナップショットの保存にリソースを集中できることから、データが巨大になっても現実的な時間でスナップショットを保存できます。

追記専用ファイル

追記専用ファイル (AOF) とは、RDBMS でいうところのいわゆる WAL (Write Ahead Log) に近いものを提供する機能です。Redis に要求された書き込み操作を、特定のファイルにひたすら追記していくという代物です。スナップショットと異なり、書き込み操作を追記していくだけなので、スナップショットの保存に比べればほとんど時間がかかりません。

AOF のオプションとして、fsync の実行タイミングの指定があります。推奨されているのは 1 秒ごとに fsync するという設定です。1 秒ごとに fsync するので、1 秒間のデータをロスする可能性はありますが、Redis の本来の仕事 (インメモリのデータ読み書き) への影響を小さくできます。他には、

  • 書き込みコマンドを実行するたび
  • OS 任せ

というオプションがあります。前者は書き込みコマンドを実行するたびに AOF を fsync するので、きわめてデータの安全性が高くなる一方で、パフォーマンスへの影響が大きくなります。後者は一応オプションとしては用意されているものの、いつ fsync されるか予想できない、一度の fsync で扱うデータ量が大きくなりパフォーマンスへの影響が大きくなる、など、ほとんどメリットがないため、推奨されていません。

AOF は書き込み操作をひたすら追記していくという特徴から、ファイルサイズが膨らみやすいという欠点があります。また、それに起因して、ファイルからの復元に時間がかかるという問題もあります。これらの問題への対策として、自動的に AOF を書き換えて小さくするという機能が用意されています。

レプリケーション

データやクライアントの増加に伴い、満足のいく読み込みパフォーマンスが得られなくなる場合があります。このような事態への対策として、Redis にはレプリケーションの仕組みが用意されています。

いわゆるマスター・スレーブ方式と呼ばれるもので、書き込みはマスターへ、読み込みはスレーブから行うというものです。1 つのマスターに対して任意の数のスレーブをぶら下げることができます。読み込み処理が複数のスレーブに分散されることで、読み込みパフォーマンスの向上につながります。

Redis インスタンスを別の Redis インスタンスのスレーブにするには:

  • 設定ファイルに slaveof host port と書く
  • SLAVEOF host port コマンドを叩く

の 2 つの方法が用意されています。動的にスレーブになったりやめたり、といったことはあまりしないので、実際には設定ファイルによる方法を採ることが多いのではないかと思います。

Redis インスタンスが別の Redis のスレーブになると、データの同期が始まります。マスター側で BGSAVE が行われ、その結果がスレーブに渡ります。スレーブでは BGSAVE の結果をもとに自身のデータを構築し、マスターと同期をとります。それ以降はマスターに対する書き込みコマンドがスレーブにも転送されるようになり、インクリメンタルに同期されます。

ひとつのマスターにたくさんのスレーブがぶら下がると、BGSAVE やその結果の転送にかかるコストが無視できなくなります。これに対して Redis では、スレーブが別のスレーブのマスターとして振る舞う、いわば木構造のレプリケーションができるようになっています。

期限設定

Redis には、キーに対して有効期限を設定できる機能があります。この機能を利用することで、有効期限付きのキャッシュ、セッションと言った機能を自然に、かつ簡単に実装することができます。また、Redis では致命的なデータの消し忘れへの保険としても利用できます。

127.0.0.1:6379> set foo hoge
OK
127.0.0.1:6379> expire foo 10
(integer) 1
127.0.0.1:6379> get foo
"hoge"
127.0.0.1:6379> get foo
(nil)

例2: ジョブキュー

Redis を使ったジョブキューを実装してみます。ジョブキューとはその名の通りなんらかのジョブ (= 手続き) を要素に持つキューです。なんらかのジョブをバックグラウンドプロセスに任せる際、プロセス間におけるジョブのやり取りに利用されます。バックグラウンドプロセスはキューを監視し、キューにジョブが追加されればそれを取り出し、順にこなしていきます。

ジョブキューには RabbitMQ や ActiveMQ, AWS の SQS など、専用のミドルウェア製品が存在しますが、Redis の LIST を使うことで、簡単に簡易的なジョブキューを構築することができます。

def send_email_async(conn, to_addr, subject, body):
    data = {
        'to_addr': to_addr,
        'subject': subject,
        'body': body
    }
    conn.rpush('queue:email', json.dumps(data))

'queue:email' の LIST に対し、メール送信のジョブを追加しています。ジョブの表現として JSON を利用しています。

def process_send_email(conn):
    while True:
        packed = conn.blpop(['queue:email'], 30)
        if not packed:
            return

        job = json.loads(packed[1])
        send_email_sync(job['to_addr'], job['subject'], job['body'])

メール送信を実際に処理する側では、'queue:email' から BLPOP でジョブを取得します。ジョブが未登録の場合に備えて、ブロック付きのコマンドを利用します。ジョブが取得できた場合には、メール送信のタスクを遂行します。

今回はメール送信専用のジョブキューになりましたが、JSON の表現を見直しや ZSET など他のデータ構造を利用することで:

  • ジョブの種類を入れる
  • 優先度の考え方を導入する
  • 遅延実行の仕組みを導入する

といった具合に、より実践的なジョブキューに仕上げていくことができます。

触れなかった話題

  • シャーディング
    • 分散書き込み
    • Redis 本体では提供されていない機能
    • キーを元に自力で分散させるか、別途ミドルウェアを利用する
  • スクリプティング
    • いわゆるストアドプロシージャ
    • スクリプト言語 Lua によるスクリプティングが提供されている

参考


  1. 永続化はオプションで可能であり、現実的には問題ない程度の信頼性は確保できる。よって、一概に利用できないとは言い切れず、場合によるというのが正しい。 

  2. 個人的には CAS といえば Compare And Swap なのだが、Redis のドキュメントでは Check And Set と書かれている。