4x Performance Improvement by Switching from RedisTemplate to Lettuce Native API
Discovered an issue where RedisTemplate creates a new TCP connection for every command. Sharing the experience of switching to the Lettuce native API to reduce the processing time of 1 million records from 240 seconds to 60 seconds.
Discovering the Problem
While redesigning the Gmarket Ranking System to a Spring Webflux + MongoDB architecture, I noticed that batch updating 1 million ranking records in Redis was much slower than expected.
Measurement results:
- Time to process 1 million records: 240 seconds (4 minutes)
- Expected processing time: Under 60 seconds
Root Cause Analysis
Internal Workings of RedisTemplate
While monitoring the number of Redis connections with Datadog, I discovered that the number of connections exploded during the batch job execution.
The cause lay in the internal implementation of RedisTemplate. With default settings, RedisTemplate establishes and tears down a new TCP connection for every single Redis command invocation.
// What if this code runs 1 million times?
redisTemplate.opsForValue().set(key, value);
// → Establish new TCP connection → Use → Tear down
// → Massive I/O Overhead!
Having a TCP handshake occur for every single one of 1 million records inevitably caused severe performance degradation.
Solution: Using the Lettuce Native API
Spring Data Redis uses the Lettuce client by default. RedisTemplate is just an abstraction layer placed on top of it, and this abstraction was causing the performance issue.
By using the Lettuce native API directly, you can optimize I/O through Connection Pooling and Pipelining (internal command queuing).
Implementation
@Configuration
class RedisConfig {
@Bean
fun lettuceConnectionFactory(): LettuceConnectionFactory {
val config = LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofSeconds(30))
.build()
return LettuceConnectionFactory(redisStandaloneConfig, config)
}
}
@Component
class RankingRedisRepository(
private val connectionFactory: LettuceConnectionFactory
) {
fun batchSet(entries: Map<String, String>) {
// Using Lettuce Native StatefulRedisConnection
val conn = connectionFactory.connection.nativeConnection as StatefulRedisConnection<String, String>
val commands = conn.async()
// Request all at once via Pipelining
commands.setAutoFlushCommands(false)
val futures = entries.map { (k, v) ->
commands.set(k, v)
}
commands.flushCommands()
LettuceFutures.awaitAll(30, TimeUnit.SECONDS, *futures.toTypedArray())
}
}
The Core: Pipelining
Lettuce's pipelining bundles multiple commands together, sends them to the server all at once, and receives the responses collectively.
[RedisTemplate Method]
Client → SET key1 → Redis
← OK
Client → SET key2 → Redis
← OK
Client → SET key3 → Redis
← OK
...
[Lettuce Pipelining]
Client → SET key1, SET key2, SET key3, ... → Redis
← OK, OK, OK, ...
The number of network round-trips is drastically reduced, leading to a significant decrease in I/O overhead.
Results
| Metric | RedisTemplate | Lettuce Native |
|---|---|---|
| 1M Records Processing Time | 240 seconds | 60 seconds |
| Performance Improvement | - | 4x Faster |
| TCP Connections (Peak) | Thousands | Few (Pooled) |
Summary
While RedisTemplate offers great convenience, it can cause performance issues during massive bulk processing. Consider using the Lettuce native API in the following scenarios:
- Bulk processing of hundreds of thousands of records or more
- Tasks requiring low latency
- Situations where the number of connections must be explicitly managed
On the other hand, for single lookups, modifications, or processing small amounts of data, the convenience of RedisTemplate might be more valuable. Choosing the right tool for the right situation is critical.