Blog

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.

RedisLettuceSpringPerformanceGmarket

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.