kagamihogeの日記

kagamihogeの日記です。

spring-data-redisでpipelineによるバルク処理

redis初心者です。以下は https://docs.spring.io/spring-data-redis/docs/current/reference/html/#pipeline の抄訳。


5.11. Pipelining

Redisにはパイプライン(https://redis.io/topics/pipelining)があり、これはリプライを待たずにサーバへ複数コマンドを送信し、単一ステップでリプライを読み込みます。パイプラインにより、たとえば同一Listに多数の要素を追加するなど、一度に複数コマンドを送信する場合にパフォーマンス向上が見込めます。

Spring Data Redisはパイプラインでコマンド実行のためのRedisTemplateメソッドをいくつか用意しています。パイプラインの処理結果が不要な場合、executeのpipeline引数にtrueを渡します。executePipelinedではRedisCallbackSessionCallbackでパイプライン処理を渡します。

// キューから指定アイテム数popする
List<Object> results = stringRedisTemplate.executePipelined(
  new RedisCallback<Object>() {
    public Object doInRedis(RedisConnection connection) throws DataAccessException {
      StringRedisConnection stringRedisConn = (StringRedisConnection)connection;
      for(int i=0; i< batchSize; i++) {
        stringRedisConn.rPop("myqueue");
      }
    return null;
  }
});

上の例はパイプラインでキューから指定アイテム数のright popをバルク処理します。resultsListにはpopした全アイテムが入っています。RedisTemplateは、return前に全結果をデシリアライズするためシリアライザに値・hash key・hash valueを渡すので、上の例では戻される値はStringです。パイプライン結果用のカスタムシリアライザを渡せるexecutePipelinedもあります。

なお、RedisCallbackの戻り値はnullにして下さい。この値はパイプライン処理の結果を返す際に無視します。


抜粋終了。なるほどね? ということで実際に試してみる。とりあえず、愚直にループsetするのとpipeline使用との速度を比較する。

ソースコード

 <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.7.RELEASE</version>
        <relativePath />
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>11</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
    </dependencies>

以下はapplication.yml。

spring:
  redis:
    host: localhost
    timeout: 10000000

spring.redis.host.timeout: 10000000だが、これが無いとウチの環境だと400万件あたりから以下のような例外スローするようになる。なので、とりあえずの措置としてタイムアウトは事実上発生しない数値を設定している。

Caused by: io.lettuce.core.RedisException: io.lettuce.core.RedisCommandTimeoutException: Command timed out after 1 minute(s)
    at io.lettuce.core.LettuceFutures.awaitAll(LettuceFutures.java:88) ~[lettuce-core-5.1.8.RELEASE.jar:na]
    at org.springframework.data.redis.connection.lettuce.LettuceConnection.closePipeline(LettuceConnection.java:528) ~[spring-data-redis-2.1.10.RELEASE.jar:2.1.10.RELEASE]
    ... 21 common frames omitted

愚直なset

        for (int i = 0; i < MAX; i++) {
            redis.boundValueOps("" + i).set("hogeValue");
        }

pipeline使用。

        var list = redis.executePipelined(new RedisCallback<Object>() {
            @Override
            public Object doInRedis(RedisConnection connection) throws DataAccessException {
                StringRedisConnection stringRedisConn = (StringRedisConnection) connection;

                for (int i = 0; i < MAX; i++) {
                    stringRedisConn.set("" + i, "hogeValue");
                }

                return null;
            }
        });
        System.out.println(list.size());

上記処理ブロックの開始・終了のSystem.currentTimeMillis()の差を計測する。

結果

計測結果

面倒くさくてそれぞれ1回しか計測してない。なので信頼度としては微妙だが、今回は両者の相対的な差が重要なんで、そこらへんは無視する。

あと下表を見ればわかるが愚直なループは10万件で約43秒と、それ以上件数増やして計測する気になれなかったのでやっていない。

件数 pipeline 愚直
1,000 995 1630
10,000 1266 5999
100,000 3297 43070
1,000,000 15245 -
2,000,000 42208 -
3,000,000 58512 -
4,000,000 77789 -

感想とか

Redis初心者なんであーだこーだ考えたことダラダラと書いてます。なので、以下は技術的におかしなこと書いてる可能性あります。

10万件で約3秒・約43秒とかなりの差がついた。この環境において数万件以上を連続setしたい場合、pipelineの使用が有効らしい。逆に、1万件以下、1000件だと差が0.5秒とかそんなもんで、そもそも両者とも1秒付近。この程度だと、pipelineをやる価値は微妙、といった所だろうか。数百万件以上だとpipelineを使わないと話にならない速度差になるので、pipelineの本懐はむしろ数百万以上にある、と考えられる。

RDBのいわゆる「コミット間隔」が念頭にあったので、pipelineは一度に数百万単位が有効な世界、と分かった時は結構面食らった。pipelineの「リプライを待たずにコマンドを送信し続け、最後にまとめてコマンド結果を受け取る」の理解が要なのだろう。

参考