AWS Elasticache Redis クラスタ + Predis での注意点

Redis まわりのメモです．最近触ってるので．間違いなどあったらおしえてください

## tl;dr

1. AWS Elasticache で Redis のクラスタを組むよ
2. PHP では 2 つの Redis クライアント実装が有名だよ
3. クラスタには注意点があるよ
4. クラスタと DB 番号の制限があったよ
5. クラスタで `SCAN` できなくて困ったよ
5. Predis で `MOVED 1234 123.456.789:1111` `Predis\Response\ServerException` 出て困ったよ

## AWS Elasticache

AWS の Elasticache で Redis のインスタンスをたてるときは クラスタを有効するにするかオプションがある．

<img width="710" alt="ElastiCache_Management_Console.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/47452973-0ebc-a5db-b834-5e24a5700461.png">

クラスタを有効にしない場合は，プライマリのエンドポイントと，リーダーエンドポイントの 2 つが有効になる．

クラスタを有効にした場合は，設定エンドポイントというものが有効になる．

見た感じはプライマリのエンドポイントは DB でいうところのマスターで，リーダーエンドポイントはスレーブのような感じ．書き込みと読み込みで違うエンドポイントを使う感じ．

一方，設定エンドポイントはそれ単体で読み書きに使え，サーバーサイドでクラスタが組んであるので，クライアント側で意識する必要はなさそうだ．

## PHP の 2 つの Redis クライアント実装

PHP で Redis を扱う場合は，だいたい [phpredis](https://github.com/phpredis/phpredis) か [Predis](https://github.com/nrk/predis) のどちらかを使ってる例が多い．

phpredis は PHP エクステンションで，C で実装されているので速度が期待できる．

一方，Predis は PHP の実装なので，ネイティブコードよりは速度は期待できないかもしれないが，エクステンションを入れる必要がないので，よりポータブルであることが特徴．

Laravel の `Redis` ファサード はデフォルトでは Predis の実装を使ってるようだ．ただし，コンフィグ次第で実装は phpredis に変更できる．

## クラスタの注意点

クラスタでハマったポイントがある．

どうにもクラスタを組む場合は，通常の単体のインスタンス (Standalone) とは勝手が異なる点がありそうである．

ちなみにこれらは Predis を使用していて出会った問題である．

### データベース番号

1 つ目は データベースを変更できない こと．

Redis はデータベース番号というものを持っていて，0〜15 の 16 個のデータベースを切り替えられる．

MySQL でいうとデータベースともいえそうだが，テーブルのほうが単位としては近いかもしれない．

で，クラスタを組むとデータベース番号を変更すること (`SELECT`) ができない．`SELECT` するとエラー (Predis だと例外) が出てしまい，クラスタモードだとデータベース指定ができないと言われてしまう．

> Uncaught Predis\Connection\ConnectionException: `SELECT` failed: ERR SELECT is not allowed in cluster mode

なので DB 番号は 0 で固定になる．

参考:
> Redis Cluster does not support multiple databases like the stand alone version of Redis. There is just database 0 and the SELECT command is not allowed.

[Redis Cluster Specification - https://redis.io/topics/cluster-spec#implemented-subset](https://redis.io/topics/cluster-spec#implemented-subset)

### `SCAN`

これは疑わしいのだが，キーをある条件で絞り込んで取得する `SCAN` が使えないというものだ．

> Uncaught Predis\NotSupportedException: Cannot use 'SCAN' with redis-cluster

なぜ疑わしいかというと，GUI のクライアントでは `SCAN` が普通につかえているし，`SCAN` できないというのはどうにも不便で納得がいかない．

なので，僕の実装がまずいという説が濃厚である．ここはもう少し調べる必要がありそうだ．

> Scan is a command for single redis node. If you do want to use it in cluster, first get nodes list in the cluster, and run scan for each node.

https://stackoverflow.com/questions/51498737/how-to-use-redis-scan-in-cluster-enviroment

この StackOverflow のエントリでは `SCAN` は 1 つのノードに対しての操作なので，クラスタ内のノードすべてを走査する必要があるとのことだ．

サーバーサイドのクラスタで面倒みてくれないとなると，ノードのリストを取ってくるようなことをしないといけないのだろうか……．要調査．

ちなみに Predis は `Predis\ClientInterface` に `scan` メソッドがあるので，このメソッドで `SCAN` コマンドを発行できる．ただ，カーソルを管理してループする必要があるので，ある程度の量を取得するときは，イテレータを使うのが便利そうだ (`Predis\Collection\Iterator\KeySpace`)

→ 使用例: https://github.com/nrk/predis/blob/v1.1.1/examples/redis_collections_iterators.php#L43-L47


### `MOVED <ポート番号> <IPアドレス>:<ポート>` という例外が出る

クラスタの設定エンドポイントを指定して操作をしていると，たまに `MOVED 1234 123.456.789:1111` のような `Predis\Response\ServerException` の例外が出ることがある．「たまに」は3回に2回エラーで，1回は成功する程度の頻度．

結論から言うと，これは Predis のクラスタモードでの接続先指定を誤っていたからだった．

通常，`Predis\Client` インスタンスを `new` するときは，次のようにして使う:

```php:provide.php
<?php 

use Predis\Client;

function provideClient(): Client 
{
    $parameters = [
        'host'     => '<エンドポイント>',
        'port'     => '<ポート番号>',
        'database' => 0,
    ];

    return new Client($parameters);
}
```

Standalone モードで 1 つのノードを指定する場合はこれでいいが，クラスタモードで接続する場合は，コンストラクタの2番めの引数にオプションの連想配列を指定しないといけない:

```php:provide2.php
<?php 

use Predis\Client;

function provideClient(): Client 
{
    $parameters = [
        'host'     => '<エンドポイント>',
        'port'     => '<ポート番号>',
        'database' => 0,
    ];
    $options = [
        'cluster' => 'redis',
    ];

    return new Client($parameters, $options);
}
```

これでサーバーサイドのクラスタを指定できる…と思いきやこれではダメである．

実際のところ，クラスタの場合は接続先の `$parameters` は複数の接続先の配列を指定しないといけない．AWS Elasticache の Redis クラスタがサーバーサイドで 1 つの管理されたエンドポイントであろうと．

```php:provide3.php
<?php 

use Predis\Client;

function provideClient(): Client 
{
    $parameters = [
        [
            'host'     => '<エンドポイント>',
            'port'     => '<ポート番号>',
            'database' => 0,
        ]
    ];
    $options = [
        'cluster' => 'redis',
    ];

    return new Client($parameters, $options);
}
```

こうすることで，`MOVED ...` もクライアント側で正しく処理されてエラーが出なくなった．

ちなみにこの原因には Laravel の `Redis` ファサードの実装を GitHub で確認して気づいた．Laravel さまさまである．

→ https://github.com/illuminate/redis/blob/5.1/Database.php#L31-L35