Spring Data Redis におけるデフォルト設定の注意点

Spring Data Redis のデフォルト設定に関して、個人的に気になった点を挙げておきます。

  • (1) キーと値に JdkSerializationRedisSerializer を適用
  • (2) トランザクションサポートが無効化 (enableTransactionSupport = false)

今回使用したモジュールは以下。

サンプルソースhttp://github.com/fits/try_samples/tree/master/blog/20150827/

はじめに

今回作成したサンプルの Gradle 用ビルド定義です。

spring-boot-gradle-plugin を使わず、gradle run で実行するようにしました。

build.gradle
apply plugin: 'application'

def enc = 'UTF-8'
tasks.withType(AbstractCompile)*.options*.encoding = enc

mainClassName = 'sample.App'

repositories {
    jcenter()
}

dependencies {
    compile 'org.springframework.boot:spring-boot-starter-redis:1.2.5.RELEASE'
}

クラス構成は以下の通りです。

実行クラス src/main/java/sample/App.java
package sample;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import sample.repository.SampleRepository;

@ComponentScan
@EnableAutoConfiguration
public class App implements CommandLineRunner {
    @Autowired
    private SampleRepository sampleRepository;

    @Override
    public void run(String... args) {
        ・・・
    }

    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }
}
設定クラス src/main/java/sample/config/AppConfig.java
package sample.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericToStringSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class AppConfig {
    @Bean
    public JedisConnectionFactory jedisConnectionFactory() {
        return new JedisConnectionFactory();
    }

    @Bean
    public RedisTemplate<String, Integer> redisTemplate() {
        RedisTemplate<String, Integer> template = new RedisTemplate<>();
        template.setConnectionFactory(jedisConnectionFactory());
        ・・・
        return template;
    }
}
Redis 用リポジトリクラス src/main/java/sample/repository/SampleRepository.java
package sample.repository;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.*;
import org.springframework.stereotype.Repository;

import java.util.function.IntUnaryOperator;

@Repository
public class SampleRepository {
    @Autowired
    private RedisTemplate<String, Integer> redisTemplate;

    public Integer load(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    ・・・
}

(1) キーと値に JdkSerializationRedisSerializer を適用

デフォルト設定では、キーにも値にも JdkSerializationRedisSerializer を適用するため、Javaシリアライズしたキーと値を Redis へ保存します。

例えば、redisTemplate.opsForValue().set("a1", 10)redisTemplate.boundValueOps("a1").set(10) でも可) のように "a1" というキーに 10 という値をセットした後、Redis で実際の値を確認すると以下のようになりました。

redis-cli による確認結果1
127.0.0.1:6379> keys *
1) "\xac\xed\x00\x05t\x00\x02a1"

127.0.0.1:6379> get "\xac\xed\x00\x05t\x00\x02a1"
"\xac\xed\x00\x05sr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x00\n"

"a1" が "\xac\xed\x00\x05t\x00\x02a1" に、 10 が "\xac\xed\・・・valuexr\x00\x10java.lang.Number・・・" になって保存されています。

文字列や数値をシリアライズせずに Redis へ保存したい場合は、RedisTemplate の個々の serializer を変更します。

今回は以下のように設定しました。

適用する serializer 設定先プロパティ
String StringRedisSerializer keySerializer
Integer GenericToStringSerializer valueSerializer

他にも、ハッシュを使う場合は hashKeySerializer や hashValueSerializer を変更し、同じ serializer を全てに適用する場合は defaultSerializer を変更します。

設定クラス src/main/java/sample/config/AppConfig.java
・・・
@Configuration
public class AppConfig {
    ・・・
    @Bean
    public RedisTemplate<String, Integer> redisTemplate() {
        RedisTemplate<String, Integer> template = new RedisTemplate<>();
        template.setConnectionFactory(jedisConnectionFactory());

        // キーの serializer を変更
        template.setKeySerializer(new StringRedisSerializer());
        // 値の serializer を変更
        template.setValueSerializer(new GenericToStringSerializer<>(Integer.class));

        return template;
    }
}

serializer 変更後に redisTemplate.opsForValue().set("a1", 10) で保存した内容は以下のようになりました。

redis-cli による確認結果2 (selializer 変更後)
127.0.0.1:6379> keys *
1) "a1"

127.0.0.1:6379> get a1
"10"

(2) トランザクションサポートが無効化

デフォルトで RedisTemplateenableTransactionSupportfalse となっています。

この場合、以下のように RedisTemplatemultiexec メソッドを使用しても Redis のトランザクションは適用されず、exec の実行時に JedisDataException: ERR EXEC without MULTI エラーが発生します。

// 楽観ロックの適用
redisTemplate.watch(key);
・・・
// Redis トランザクションの開始
redisTemplate.multi();
・・・
// Redis トランザクションの実施。enableTransactionSupport = false の場合はエラー
redisTemplate.exec();

エラーが発生する原因は、multiexec を異なる Redis 接続に対して実施するためです。 (つまり、セッションが異なっている)

もう少し詳しく説明すると、multiexec 等で個別に RedisConnectionFactory から RedisConnection を取得し処理を実施します。

Redis トランザクションを使用するには、次の 3通りが考えられます。

  • (a) enableTransactionSupport を true へ変更 (トランザクションサポートを有効化)
  • (b) SessionCallback を execute
  • (c) 自前で bindConnection・unbindConnection を実施

(b) と (c) は enableTransactionSupport = false の設定でも適用できる方法です。 (enableTransactionSupport = true でも問題ありません)

(a) enableTransactionSupport を true へ変更

enableTransactionSupport を true へ変更するのが最も簡単だと思います。

また、true へ変更すると @Transactional アノテーションも使えるみたいです。

設定クラス src/main/java/sample/config/AppConfig.java
・・・
@Configuration
public class AppConfig {
    ・・・
    @Bean
    public RedisTemplate<String, Integer> redisTemplate() {
        RedisTemplate<String, Integer> template = new RedisTemplate<>();
        template.setConnectionFactory(jedisConnectionFactory());

        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericToStringSerializer<>(Integer.class));
        // トランザクションサポートを有効化
        template.setEnableTransactionSupport(true);

        return template;
    }
}
Redis 用リポジトリクラス src/main/java/sample/repository/SampleRepository.java
・・・
@Repository
public class SampleRepository {
    @Autowired
    private RedisTemplate<String, Integer> redisTemplate;
    ・・・

    public Object updateWithCas1(String key, IntUnaryOperator func) {
        try {
            // 楽観ロック
            redisTemplate.watch(key);

            BoundValueOperations<String, Integer> valueOps = redisTemplate.boundValueOps(key);
            // 現在の値を取得
            Integer value = valueOps.get();

            redisTemplate.multi();

            // 値の更新。enableTransactionSupport = true の場合はキューイングされる
            valueOps.set(func.applyAsInt(value));

            // enableTransactionSupport = true の場合はキューイングした処理の実行、false の場合はエラー
            return redisTemplate.exec();
        } catch (Exception e) {
            // InvalidDataAccessApiUsageException: ERR EXEC without MULTI
            System.out.println(e);
        }

        return null;
    }
    ・・・
}
実行クラス src/main/java/sample/App.java
・・・
@ComponentScan
@EnableAutoConfiguration
public class App implements CommandLineRunner {
    @Autowired
    private SampleRepository sampleRepository;

    @Override
    public void run(String... args) {
        String key = "a1";

        sampleRepository.save(key, 10);
        Object res1 = sampleRepository.updateWithCas1(key, v -> v + 5);
        System.out.println("res1 = " + res1);
        System.out.println(sampleRepository.load(key));
        ・・・
    }
    ・・・
}

実行結果は以下の通りです。

実行結果1
> groovy run

・・・
res1 = []
15
・・・

enableTransactionSupport = false の場合の実行結果は以下の通りです。
トランザクションを使用せず set a1 15 を単独で実行しています。

実行結果2 (enableTransactionSupport = false の場合)
> groovy run

・・・
org.springframework.dao.InvalidDataAccessApiUsageException: ERR EXEC without MULTI; nested exception is redis.clients.jedis.exceptions.JedisDataException: ERR EXEC without MULTI
res1 = null
15
・・・

(b) SessionCallback を execute

enableTransactionSupport = false の場合でも SessionCallbackRedisCallback でも可) を execute すればトランザクションを使用できます。

SessionCallback インターフェースの execute メソッド内へ実装した処理は同一セッション内で実行されます。

ただし、SessionCallbackメソッドへ仮型引数 K・V が付いており、API 的に微妙な気がします。 (SessionCallback<T, K, V> の方がよかったのでは)

org.springframework.data.redis.core.SessionCallback
・・・
public interface SessionCallback<T> {
    <K, V> T execute(RedisOperations<K, V> operations) throws DataAccessException;
}

SessionCallback をラムダで代用したいところですが、仮型引数 K・V のせいで断念しました。

また、operationsRedisOperations<K, V> 型) を強引にキャストしていますが、実体は redisTemplate なので一応は問題無いはずです。

Redis 用リポジトリクラス src/main/java/sample/repository/SampleRepository.java
・・・
@Repository
public class SampleRepository {
    @Autowired
    private RedisTemplate<String, Integer> redisTemplate;
    ・・・

    public Object updateWithCas2(String key, IntUnaryOperator func) {
        // SessionCallback の実行
        return redisTemplate.execute(new SessionCallback<Object>() {
            @Override
            public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {
                // 扱い難いのでキャスト
                @SuppressWarnings("unchecked")
                RedisOperations<String, Integer> ops = (RedisOperations<String, Integer>)operations;

                ops.watch(key);

                BoundValueOperations<String, Integer> valueOps = ops.boundValueOps(key);
                // 現在の値を取得。
                // multi 実行前に実施する必要あり。multi 後に実行するとキューイングされて戻り値が null になる
                Integer value = valueOps.get();

                ops.multi();

                valueOps.set(func.applyAsInt(value));

                return ops.exec();
            }
        });
    }
    ・・・
}
実行クラス src/main/java/sample/App.java
・・・
@ComponentScan
@EnableAutoConfiguration
public class App implements CommandLineRunner {
    @Autowired
    private SampleRepository sampleRepository;

    @Override
    public void run(String... args) {
        String key = "a1";
        ・・・
        sampleRepository.save(key, 10);
        Object res2 = sampleRepository.updateWithCas2(key, v -> v + 10);
        System.out.println("res2 = " + res2);
        System.out.println(sampleRepository.load(key));
        ・・・
    }
    ・・・
}
設定クラス src/main/java/sample/config/AppConfig.java
・・・
@Configuration
public class AppConfig {
    ・・・
    @Bean
    public RedisTemplate<String, Integer> redisTemplate() {
        RedisTemplate<String, Integer> template = new RedisTemplate<>();
        template.setConnectionFactory(jedisConnectionFactory());

        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericToStringSerializer<>(Integer.class));

        //template.setEnableTransactionSupport(true);

        return template;
    }
}

実行結果は以下の通りです。

実行結果
> groovy run

・・・
res2 = []
20
・・・

(c) 自前で bindConnection・unbindConnection を実施

RedisConnectionUtilsbindConnectionunbindConnection メソッドを使って、自前でセッションを制御する方法です。

Redis 用リポジトリクラス src/main/java/sample/repository/SampleRepository.java
・・・
@Repository
public class SampleRepository {
    @Autowired
    private RedisTemplate<String, Integer> redisTemplate;
    ・・・

    public Object updateWithCas3(String key, IntUnaryOperator func) {
        // Redis 接続を bind
        RedisConnectionUtils.bindConnection(redisTemplate.getConnectionFactory());
        try {
            redisTemplate.watch(key);

            BoundValueOperations<String, Integer> valueOps = redisTemplate.boundValueOps(key);
            Integer value = valueOps.get();

            redisTemplate.multi();

            valueOps.set(func.applyAsInt(value));

            return redisTemplate.exec();

        } finally {
            // Redis 接続を unbind
            RedisConnectionUtils.unbindConnection(redisTemplate.getConnectionFactory());
        }
    }
}
実行クラス src/main/java/sample/App.java
・・・
@ComponentScan
@EnableAutoConfiguration
public class App implements CommandLineRunner {
    @Autowired
    private SampleRepository sampleRepository;

    @Override
    public void run(String... args) {
        String key = "a1";
        ・・・
        sampleRepository.save(key, 10);
        Object res3 = sampleRepository.updateWithCas3(key, v -> v + 15);
        System.out.println("res3 = " + res3);
        System.out.println(sampleRepository.load(key));
    }
}

実行結果は以下の通りです。

実行結果
> groovy run

・・・
res3 = []
25
・・・

備考 (Redis のトランザクション

Redis のトランザクションは、MULTI ~ EXEC 間の処理(コマンド)をキューイングし、EXEC 時に直列化して処理します。(他の処理が途中で入り込む事はない)

ロールバックはできず、途中でコマンドが失敗(文法エラーなど)しても次のコマンドを引き続き実行する点に注意が必要です。

なお、EXEC の代わりに DISCARD を実行するとキューが破棄されトランザクションはキャンセルされます。

WATCH は check-and-set (CAS) による楽観ロックをトランザクションへ適用します。

WATCH したキーが、WATCH 後に更新されていれば MULTI ~ EXEC によるトランザクションを EXEC 時に中止します。

また、EXEC すると WATCH が解除されて UNWATCH になります。

WATCH の動作検証

watch の後に sleep 処理を差し込んで、楽観ロックとトランザクションの動作を確認してみます。

Redis 用リポジトリクラス src/main/java/sample/repository/SampleRepository.java
・・・
@Repository
public class SampleRepository {
    @Autowired
    private RedisTemplate<String, Integer> redisTemplate;
    ・・・

    public Object updateWithCas2(String key, IntUnaryOperator func) {
        return redisTemplate.execute(new SessionCallback<Object>() {
            @Override
            public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {
                @SuppressWarnings("unchecked")
                RedisOperations<String, Integer> ops = (RedisOperations<String, Integer>)operations;

                ops.watch(key);

                // sleep
                try {
                    Thread.sleep(10000);
                } catch(InterruptedException e) {}

                ・・・
                ops.multi();

                valueOps.set(func.applyAsInt(value));

                return ops.exec();
            }
        });
    }

    public Object updateWithCas3(String key, IntUnaryOperator func) {
        RedisConnectionUtils.bindConnection(redisTemplate.getConnectionFactory());
        try {
            redisTemplate.watch(key);
            ・・・
            redisTemplate.multi();

            // sleep
            try {
                Thread.sleep(10000);
            } catch(InterruptedException e) {}

            valueOps.set(func.applyAsInt(value));

            return redisTemplate.exec();

        } finally {
            RedisConnectionUtils.unbindConnection(redisTemplate.getConnectionFactory());
        }
    }
}

sleep している間に redis-cliset a1 1 (キー "a1" へ 1 という値をセット) を実行してみたところ、以下のような結果となりました。

実行結果 (sleep 中に a1 の値を更新)
> gradle run

・・・
res2 = null
1
res3 = null
1
・・・

redisTemplate.exec() の戻り値が null となり、トランザクションの中止を確認できました。 (値も更新されていません)