はじめに
LettuceはJavaのRedisクライアントの1つです。
ReplicaからのRead1や非同期の通信をサポートしているなど、多機能なクライアントであり、広く使われています。
Lettuceは多機能なクライアントである一方で、不適切な設定で使用すると事故につながる恐れがあります。
私はRedisの管理者として、日々の運用や管理に携わっています。
この記事では、Redisの管理者として働く中で得た知見から、事故を防ぐためのLettuceの設定について紹介します。
本記事はRedisクラスタでの使用を前提としています。
単一ノードやSentinelで利用する際は異なる設定が必要になるので注意してください。
以下のバージョンで動作を確認しています。
- lettuce-core/6.2.6.RELEASE
- spring-boot-starter-cache/3.1.1
- spring-boot-starter-data-redis/3.1.1
- spring-data-redis/3.1.1
ファイル構成
本記事で使用しているサンプルコードのファイル構成です。
Redisクライアントとしての設定の他、キャッシュとしての利用、RedisTemplateの利用を分けてサンプルを用意しています。
.
├── pom.xml
└── src
└── main
├── java
│ └── org
│ └── example
│ ├── Main.java
│ ├── configration
│ │ ├── RedisCacheConfig.java
│ │ ├── RedisConfig.java
│ │ └── RedisTemplateConfig.java
│ ├── controller
│ │ ├── SampleCacheController.java
│ │ └── SampleRedisTemplateController.java
│ └── service
│ ├── SampleRedisCache.java
│ └── SampleRedisTemplate.java
└── resources
└── application.yaml
Redisクライアントとしての設定
application.yaml の設定
application.yaml にRedisの接続情報を記述します。
Lettuceクライアントは、指定されたノードからクラスタ構成を自動的に取得します。
そのため、全てのノードを接続情報として記載する必要はありません。
spring:
data:
redis:
cluster:
nodes:
- <Redisホスト1>:6379
- <Redisホスト2>:6379
- ...(5-6台程度を列挙)
password: <パスワード>
Lettuceの設定
LettuceをRedisクライアントとして使用する際に、特に気をつけた方が良い設定を以下に記載します。
設定 | デフォルト値 | 推奨値 | 説明 |
---|---|---|---|
enablePeriodicRefresh | 無効 | 1秒~30秒 | 定期的に保持しているクラスタ構成を更新します。 有効にしないとreplicaの昇格時などに正常にリクエストできなくなります。 更新間隔が短いと、Redisの障害時に早く復帰しやすくなりますが、replicaからreadしている場合はレイテンシが悪化する頻度も増えます。 |
enableAllAdaptiveRefreshTriggers | 無効 | 有効 | Redisから特定の結果(slotの移動など)が返ってきたときに保持しているクラスタ構成を更新します。 有効にしないとreplicaの昇格時などに正常にリクエストできなくなります。 |
dynamicRefreshSources | true | Lettuce6系の場合はfalse | true(デフォルト)だと、クラスタ構成の更新処理の際、クラスタ全台からクラスタ構成を取得しますが、falseにすると接続先に指定されたノードのみからクラスタ構成を取得します。 Lettuce6系では5系と比べてクラスタ構成の更新処理が重くなっており、リクエストがタイムアウトしやすくなる問題がありますが、この設定をfalseにして application.yaml で接続先に指定するノードを5-6台程度に減らすことで影響を低減できます。接続先に指定しているノードが全てダウンすると、デプロイ時などに接続できなくなるため、ある程度余裕をもった台数を指定し、1-2台など少なすぎる台数は非推奨です。 RedisクラスタでPub/Sub機能を使っている場合は、falseにすると、接続先に指定しているノードにしかPub/Subが動作しなくなるため、falseに設定しないでください。 |
connectTimeout | 10秒 | 100ms前後 | Redisへのコネクションタイムアウトの設定です。 Redisのインスタンスがダウンしていると、初回接続時にこの値の分だけレイテンシが悪化します。 特別な事情がない限りは短めの値が良いです。 なお、commandTimeoutより小さな値に設定してください。 connectTimeoutとcommandTimeoutが同一設定かつReplicaからreadしている場合、アプリケーション起動時にダウンしているノードが存在すると、ダウンノードにリクエストが継続されてタイムアウトが頻発する可能性があります。 |
validateClusterNodeMembership | 有効 | 無効 | 新しいノードが追加された場合などに新しいノードへの接続を制限します。 無効にしないと、クラスタのスケールアウト時などに正常にリクエストできなくなります。 |
nodeFilter | なし | 障害が発生したノードを除外する | Lettuce バージョン6.1.6で追加された機能です。 NodeFilterを設定すると、障害が発生したノードをクラスタ構成から除外することができます。 NodeFilterの設定を行なっておらず、readFromにUpstreamもしくはMaster以外を指定していると、ノードダウン時にconnection timeoutが発生するまでコマンドが遅延します2。 具体的な設定についてはAWSのドキュメントやサンプルコードを確認してください。 |
commandTimeout | 60秒 | 200ms前後 | Redisへのリクエストタイムアウトの設定です。 Redisがダウンしていると、リクエスト時にこの値の分だけレイテンシが悪化する可能性があります。 特別な事情がない限りは短めの値が良いです。 環境によっては200msでは足りないことがあるので、タイムアウトしなくなる値まで増やしてください。 |
readFrom | Upstream もしくは Master | Lettuce v6.1.6未満の場合はUpstreamもしくはMaster | readリクエストをどのノードに向けるかを設定します。 replicaノードに向けるとmasterノードの負荷を減らせますが、Redisのダウン時にダウンしたノードにリクエストが飛んでレイテンシが悪化することがあります。 設定できる値についてはLettuceのWikiを確認してください。 Lettuceのバージョンが6.1.6未満の場合など、NodeFilterの設定を行っていない状態でUpstreamもしくはMaster以外を指定していると、ダウンノードが存在する場合、periodic refreshによるクラスタ構成の更新後から最初のconnection timeoutが発生するまでの間コマンド実行が常に遅延します2。 バージョン6.1.6以降を使用する場合はNodeFilterの設定を行うことでUpstreamもしくはMaster以外を指定している際のコマンドの遅延を防ぐことができます。 |
cancelCommandsOnReconnectFailure | false | false |
公式よりdeprecatedに指定されています3。 有効になっている場合、リクエストとレスポンスの内容がランダムに食い違う状態になる可能性があるため、使用しないようにしてください。 |
サンプルコード
@Configuration
public class RedisConfig {
@Bean
public LettuceClientConfigurationBuilderCustomizer lettuceCustomizer() {
ClusterTopologyRefreshOptions clusterTopologyRefreshOptions = ClusterTopologyRefreshOptions.builder()
.enablePeriodicRefresh(Duration.ofSeconds(5))
.enableAllAdaptiveRefreshTriggers()
.dynamicRefreshSources(false)
.build();
SocketOptions socketOptions = SocketOptions.builder()
.connectTimeout(Duration.ofMillis(100))
.build();
ClusterClientOptions clusterClientOptions = ClusterClientOptions.builder()
.nodeFilter(it ->
! (it.is(RedisClusterNode.NodeFlag.FAIL)
|| it.is(RedisClusterNode.NodeFlag.EVENTUAL_FAIL)
|| it.is(RedisClusterNode.NodeFlag.HANDSHAKE)
|| it.is(RedisClusterNode.NodeFlag.NOADDR)))
.validateClusterNodeMembership(false)
.topologyRefreshOptions(clusterTopologyRefreshOptions)
.socketOptions(socketOptions)
.build();
return builder -> builder
.commandTimeout(Duration.ofMillis(200))
.readFrom(ReadFrom.UPSTREAM_PREFERRED)
.clientOptions(clusterClientOptions);
}
}
キャッシュとして利用する際の設定
SpringBootでキャッシュとして利用する際の設定について記載します。
Spring Data Redisの公式ドキュメントも参考にしてください。
キャッシュとして利用せず、次項のRedisTemplateとしての利用のみの場合は、ここでの設定は不要です。
設定 | デフォルト値 | 推奨値 | 説明 |
---|---|---|---|
disableCachingNullValues | 無効(nullをキャッシュする) | 有効(nullをキャッシュしない) | nullをキャッシュしないようにします。 特別にnullをキャッシュしたい理由がなければ有効にしてください。 |
entryTtl | TTLなし | 必要に応じて設定する | キャッシュが消えるまでの時間(TTL)を設定します。 サービスの要件に応じて適切な値を設定してください。 |
serializeValuesWith | RedisSerializationContext.SerializationPair.fromSerializer(new JdkSerializationRedisSerializer()) | StringRedisSerializer / Jackson2JsonRedisSerializer | キャッシュする値のシリアライザを設定します。 デフォルトのJdkSerializationRedisSerializerは、デシリアライズ中に未検証のバイトコードが挿入され、不要なコードが実行される可能性があるため利用は非推奨としています4。 文字列を格納する場合や、Java以外のアプリケーションからも使用する場合はStringRedisSerializerを使用してください。 Javaオブジェクトをキャッシュする場合はJackson2JsonRedisSerializerを使用してください。 利用途中でJavaオブジェクトの型やシリアライザを変更すると格納されているデータが正常に読めなくなるので注意してください。 |
エラーハンドリング
Redisのリクエストエラー時にキャッシュを使わずにリクエストするようにします。
CacheErrorHandlerをoverrideして、エラー時にはログを出力し、呼び出し元のメソッドを実行します。
この機能を利用すると、Redis障害中は呼び出し元のメソッドがキャッシュを使わない最新のデータを返し、Redis復旧後はキャッシュされている(古い)データを返す可能性があることに注意してください。
この機能を利用しない場合は、アプリケーションで個別にエラーをハンドリングする必要があります。
サンプルコード
@Configuration
@EnableCaching
public class RedisCacheConfig implements CachingConfigurer {
Logger logger = LoggerFactory.getLogger(RedisConfig.class);
@Bean
public RedisCacheManagerBuilderCustomizer cacheCustomizer() {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.entryTtl(Duration.ofMinutes(1));
return builder -> builder
.cacheDefaults(redisCacheConfiguration)
.build();
}
@Override
public CacheErrorHandler errorHandler() {
return new SimpleCacheErrorHandler() {
@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
logger.warn(String.format("CacheGetError %s:%s:%s", cache.getName(), key, exception.getMessage()), exception);
}
@Override
public void handleCachePutError(RuntimeException exception, Cache cache, Object key,
Object value) {
logger.warn(String.format("CachePutError %s:%s:%s", cache.getName(), key, exception.getMessage()), exception);
}
@Override
public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
logger.warn(String.format("CacheEvictError %s:%s:%s", cache.getName(), key, exception.getMessage()), exception);
}
@Override
public void handleCacheClearError(RuntimeException exception, Cache cache) {
logger.warn(String.format("CacheClearError %s:%s", cache.getName(), exception.getMessage()), exception);
}
};
}
}
キャッシュの利用
結果をキャッシュしたいメソッドに@Cacheable
アノテーションを付けることで、そのメソッドの結果がキャッシュされます。
下記の例では、read メソッドでkey=foo
を指定し、value=bar
が返ってきたとすると、RedisにはmyCache::foo
のキーにvalueがシリアライズされた値が格納されます。
cacheNames
がキーのprefixになるので、メソッド毎に別のキャッシュにしたい場合は cacheNames
を変えてください。
初回はメソッドの中身を実行しつつ、結果をキャッシュとしてRedisに格納します。
2回目以降はRedisにキャッシュがあるか確認して、キャッシュがあればその内容を返し、キャッシュがなければメソッドの中身を実行します。
キャッシュを更新したい場合は@CachePut
アノテーションを付けてください。
キャッシュのキーはデフォルトだと全ての引数が使われるので、特定の引数のみキーに指定したい場合はアノテーションの中でkeyを指定しください。
キャッシュの更新時でも、キャッシュに登録されるのはメソッドの結果になります。
以下はキャッシュ利用のサンプルクラスです。
@Component
public class SampleRedisCache {
@Cacheable(cacheNames = "myCache")
public String read(String key){
// DBへのリクエストなど時間がかかる処理
String value = "bar";
return value;
}
@CachePut(cacheNames = "myCache", key = "#newKey")
public String write(String newKey, String newValue){
// DBへ登録する処理など
// ...
return newValue;
}
}
上記のクラスを利用する際に@Autowired
アノテーションを付けて利用してください。
@RestController
public class SampleCacheController {
@Autowired
SampleRedisCache sampleRedisCache;
@RequestMapping("/cache-write")
public String sampleWrite(){
sampleRedisCache.write("foo", "bar");
return "ok";
}
@RequestMapping("/cache-read")
public String sampleRead(){
return sampleRedisCache.read("foo");
}
}
RedisTemplateの設定
RedisTemplateを使用してRedisを使う場合の設定を紹介します。
Spring Data Redisの公式ドキュメントも参考にしてください。
前述のキャッシュとしてのみ利用する場合はここでの設定は不要です。
設定 | デフォルト値 | 推奨値 | 説明 |
---|---|---|---|
setConnectionFactory | Redisクライアントの設定で作成したConnectionFactoryを使用する。 | 接続された Redis インスタンスで操作を実行するために使用されるファクトリを設定します。 | |
setKeySerializer | JdkSerializationRedisSerializer | StringRedisSerializer | RedisTemplateで使用するキーのシリアライザを設定します。 デフォルトのJdkSerializationRedisSerializerはデシリアライズ中に未検証のバイトコードが挿入され、不要なコードが実行される可能性があるので、StingRedisSerializerなどの適切なシリアライザに変更してください4。 |
setValueSerializer | JdkSerializationRedisSerializer | StringRedisSerializer / Jackson2JsonRedisSerializer | RedisTemplateで使用する値のシリアライザを設定します。 デフォルトはJdkSerializationRedisSerializerです。 StringRedisSerializerなどの適切なシリアライザに変更してください。 文字列を格納する場合や、Java以外のアプリケーションからも使用する場合はStringRedisSerializerを使用してください。 Javaオブジェクトをキャッシュする場合はJackson2JsonRedisSerializerを使用してください。 利用途中でJavaオブジェクトの型やシリアライザを変更すると格納されているデータが正常に読めなくなるので注意してください。 |
setHashKeySerializer | JdkSerializationRedisSerializer | StringRedisSerializer | opsForHashを用いてHashの操作を行う場合に設定します。 RedisTemplateで使用するハッシュキー(フィールド)のシリアライザを設定します。 デフォルトはJdkSerializationRedisSerializerです。 StringRedisSerializerなどの適切なシリアライザに変更してください。 |
setHashValueSerializer | JdkSerializationRedisSerializer | StringRedisSerializer / Jackson2JsonRedisSerializer | opsForHashを用いてHashの操作を行う場合に設定します。 RedisTemplateで使用するハッシュ値のシリアライザを設定します。 デフォルトはJdkSerializationRedisSerializerです。 StringRedisSerializerなどの適切なシリアライザに変更してください。 注意点についてはsetValueSerializerの説明と同様です。 |
setEnableTransactionSupport | false | 必要に応じて設定 | RedisTemplateのexecuteメソッドを使用してトランザクションの処理を行う場合はtrueに設定してください。 |
サンプルコード
@Configuration
public class RedisTemplateConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
// template.setEnableTransactionSupport(true); // execute()を使用してトランザクションの処理を行う場合
template.afterPropertiesSet();
return template;
}
// keyとvalueが共にStringの場合はStringRedisTemplateでも可
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
return new StringRedisTemplate(redisConnectionFactory);
}
}
RedisTemplateの利用
RedisTemplateを用いて単一値のsetとgetを行うサンプルクラスです。
その他のRedisTemplateで可能なRedisの操作についてはAPIリファレンスのBound[X]OperationsやOpsFor[X]をご確認ください。
格納したデータが消えるまでの時間(TTL)はデータの格納時に設定します。サービスの要件に応じて適切な値を設定してください。
また、keyには適切なprefixを設定することを推奨します。
@Component
public class SampleRedisTemplate {
@Autowired
RedisTemplate<String, String> redisTemplate;
public String getValue(String key) {
return redisTemplate.opsForValue().get(addPrefix(key));
}
public void setValue(String key, String value, int ttl, TimeUnit timeUnit) {
redisTemplate.opsForValue().set(addPrefix(key), value, ttl, timeUnit);
}
private String addPrefix(String key) {
String prefix = "redisTemplate::";
return prefix + key;
}
}
上記のクラスを利用する際に@Autowiredアノテーションを付けて利用してください。
@RestController
public class SampleRedisTemplateController {
@Autowired
SampleRedisTemplate sampleRedisTemplate;
@RequestMapping("/template-write")
public String sampleWrite(){
sampleRedisTemplate.setValue("foo", "bar", 60, TimeUnit.SECONDS);
return "ok";
}
@RequestMapping("/template-read")
public String sampleRead(){
return sampleRedisTemplate.getValue("foo");
}
}
事故防止のための注意点
Lettuceの設定以外の部分で、事故を防止するための注意点を紹介します。
OutOfMemoryError時にJVMをkillする
LettuceでOutOfMemoryErrorが発生した場合、JVMがkillされずに不安定な状態で動き続けてしまうと、セッション毎のデータが競合し、リクエストの取り違えが発生する可能性があります5。
そのため、OutOfMemoryErrorが発生した際にはJVMをkillすることを推奨します。
また、JVMをkillする設定をしていてもDirect Memoryが起因としたOutOfMemoryErrorの場合はJVMがkillされないため、「Direct Memoryの調整」の項も確認し、対策してください。
Direct Memoryの調整
Direct Memoryの初期設定は -Xmx と同じ値ですが、Java buildpackを使用しておりDirect Memoryのサイズが小さく変更されている環境などでは、レコードサイズが大きいキーをリクエストしているとOutOfDirectMemoryErrorが発生することがあります67。
Direct Memoryが起因としたOutOfMemoryErrorの場合はJVMがkillされず、セッション毎のデータが競合し、リクエストの取り違えが発生する可能性があります。
対策として、以下のようにDirect Memoryのサイズを増やして調整してください。
-XX:MaxDirectMemorySize=$SIZE
どれくらい増やせば良いのかは、アプリケーションのワークロードに依存するため、パフォーマンス試験を実施して問題ない値に調整してください。
また、MaxDirectMemorySizeの調整だけではエラーが解消できない場合は、以下の設定の追加を試してください。
-Dio.netty.maxDirectMemory=0
Direct Memoryの監視
併せて、Direct Memoryを使い過ぎていないか監視してください。
SpringBootActuatorを使用する場合は、以下のような監視例があります。一定の閾値(以下では70%)を超えるとアラートを上げるように設定し、アラートが上がった際はDirect Buffer Memoryの使用量を見直すか、Direct Memoryのサイズを上げるようにしてください。
jvm_buffer_memory_used_bytes{id="direct"}
/
jvm_memory_max_bytes{area="heap"}
* 100
> 70
RedisCodecを継承して独自にSerialize/Deserializeするのを避ける
RedisCodecを継承して独自にSerialize/Deserializeするクラスを作成している場合、例外が発生すると上記の問題と同じようなリクエストの取り違えが発生する可能性があります8。
確実に例外を発生させない保証がなければ、RedisCodecを継承して独自にSerialize/Deserializeするのは避けてください。
例外処理に関しては、以下のページも確認してください。
https://github.com/lettuce-io/lettuce-core/wiki/Codecs#exception-handling-during-encoding
リクエストしたkeyとvalueが正しいか検証する
これまでLettuceではセッション毎のデータが競合する問題が複数回発生しており、上記のような対策を実施している場合でも、将来的に問題が発生する潜在的なリスクがあります。
そのため、アプリケーションで可能な場合は、リクエストしたkeyとvalueが正しいか検証することを推奨します。
valueにkeyを含ませて検証する方法や、以下のようにLua scriptのEVAL/EVALSHAを使用してkeyとvalueを取得する方法があります。
"return {KEYS[1], redis.call('GET', KEYS[1])}"
ログの抑制
メンテナンスや障害でノードがダウンしているときに、ノードに接続できないWARNログが自動的に出力され続けます。
このログを抑制したい場合はlogbackなどで以下のクラスのログレベルをERRORに変更して抑制してください。
logging:
level:
# ノードダウン時のログの抑制、Lettuce 5系の場合
io.lettuce.core.cluster.topology.ClusterTopologyRefresh: ERROR
# ノードダウン時のログの抑制、Lettuce 6系の場合
io.lettuce.core.cluster.topology.DefaultClusterTopologyRefresh: ERROR
# 再接続のログも消したい場合
io.lettuce.core.cluster.protocol.ConnectionWatchdog: ERROR
まとめ
今回は事故を防ぐためのLettuceの設定について紹介しました。
ここでは推奨値などを提示してはいますが、必ずしも正解ではありません。
実際に設定を適用する際には、各々の環境に応じてパフォーマンス試験や負荷テスト、障害試験等を行い、適切な設定を見つけてください。
この記事がLettuceの設定を決める際の参考になれば幸いです。
-
もう1つのJavaのRedisクライアントであるJedisでもv5.2.0からReplicaからのRead機能が追加されるようです https://github.com/redis/jedis/pull/3848 ↩
-
https://www.javadoc.io/static/io.lettuce/lettuce-core/6.4.0.RELEASE/io/lettuce/core/cluster/ClusterClientOptions.Builder.html#cancelCommandsOnReconnectFailure-boolean- ↩
-
https://docs.spring.io/spring-data/redis/reference/redis/template.html#redis:serializer ↩ ↩2
-
https://github.com/cloudfoundry/java-buildpack-memory-calculator/issues/17 ↩