Skip to content

Redis & Caching

You already know Redis from ioredis (TypeScript) or go-redis (Go). The JVM ecosystem uses Lettuce as the low-level Redis client and Spring Data Redis as the high-level abstraction. Spring also provides a cache abstraction (@Cacheable) that makes caching nearly invisible.

Featureioredis (TS)go-redis (Go)Spring Data Redis / Lettuce (Kotlin)
Client libraryioredisgithub.com/redis/go-redisLettuce (default), Jedis (alt)
Connection modelSingle + pipelinePool-basedLettuce: shared connection, Jedis: pool
Async supportNative (Promise)Context-basedReactive (Project Reactor) + coroutines
Cluster supportBuilt-inBuilt-inBuilt-in
High-level cacheManualManual@Cacheable annotation
SerializationJSON (manual)JSON (manual)Configurable (JSON, JDK, etc.)
build.gradle.kts
plugins {
kotlin("jvm") version "2.1.0"
kotlin("plugin.spring") version "2.1.0"
id("org.springframework.boot") version "3.4.1"
id("io.spring.dependency-management") version "1.1.7"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-redis")
// Lettuce is the default client — included transitively
// For JSON serialization in Redis:
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
}
application.yml
spring:
data:
redis:
host: localhost
port: 6379
# password: secret # if auth is enabled
timeout: 5000 # connection timeout in ms
lettuce:
pool:
max-active: 10 # max connections
max-idle: 5 # max idle connections
min-idle: 2 # min idle connections
max-wait: 3000 # wait time for connection from pool

RedisTemplate is the core API for Redis operations in Spring. It’s the equivalent of the ioredis client object or go-redis.Client.

By default, RedisTemplate uses Java serialization (unreadable in redis-cli). Configure JSON serialization instead:

config/RedisConfig.kt
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer
import org.springframework.data.redis.serializer.StringRedisSerializer
@Configuration
class RedisConfig {
@Bean
fun redisTemplate(connectionFactory: RedisConnectionFactory): RedisTemplate<String, Any> {
val template = RedisTemplate<String, Any>()
template.connectionFactory = connectionFactory
// Key serializer — always use String
template.keySerializer = StringRedisSerializer()
template.hashKeySerializer = StringRedisSerializer()
// Value serializer — use JSON so values are human-readable
val jsonSerializer = GenericJackson2JsonRedisSerializer()
template.valueSerializer = jsonSerializer
template.hashValueSerializer = jsonSerializer
return template
}
// StringRedisTemplate is auto-configured, but you can customize it
@Bean
fun stringRedisTemplate(connectionFactory: RedisConnectionFactory): StringRedisTemplate {
return StringRedisTemplate(connectionFactory)
}
}
import Redis from 'ioredis';
const redis = new Redis(); // localhost:6379
// String
await redis.set('user:1:name', 'Alice');
const name = await redis.get('user:1:name'); // 'Alice'
// With TTL
await redis.setex('session:abc', 3600, JSON.stringify({ userId: 1 }));
// Delete
await redis.del('user:1:name');
// Check existence
const exists = await redis.exists('user:1:name'); // 0 or 1

RedisTemplate exposes different Redis data structures through opsFor*() methods:

MethodRedis TypeEquivalent ioredisEquivalent go-redis
opsForValue()Stringget/setGet/Set
opsForHash()Hashhget/hset/hgetallHGet/HSet/HGetAll
opsForList()Listlpush/rpush/lrangeLPush/RPush/LRange
opsForSet()Setsadd/smembers/sismemberSAdd/SMembers/SIsMember
opsForZSet()Sorted Setzadd/zrange/zrangebyscoreZAdd/ZRange/ZRangeByScore
await redis.hset('user:1', { name: 'Alice', email: 'alice@example.com', score: '100' });
const user = await redis.hgetall('user:1'); // { name: 'Alice', ... }

This is a single-language Kotlin example — Redis lists make a natural FIFO queue, left-push to enqueue and right-pop to dequeue.

@Service
class RedisListService(private val stringRedisTemplate: StringRedisTemplate) {
private val listOps = stringRedisTemplate.opsForList()
// Push to queue (FIFO: left push, right pop)
fun enqueue(queue: String, message: String) {
listOps.leftPush(queue, message)
}
fun dequeue(queue: String): String? {
return listOps.rightPop(queue)
}
// Blocking pop (waits for message)
fun blockingDequeue(queue: String, timeout: Duration): String? {
return listOps.rightPop(queue, timeout)
}
fun queueSize(queue: String): Long {
return listOps.size(queue) ?: 0
}
// Recent items (like recent notifications)
fun addRecent(key: String, item: String, maxSize: Long) {
listOps.leftPush(key, item)
listOps.trim(key, 0, maxSize - 1) // Keep only last N items
}
fun getRecent(key: String, count: Long): List<String> {
return listOps.range(key, 0, count - 1) ?: emptyList()
}
}

Sets give you unique collections and set algebra (intersections); sorted sets are the classic leaderboard structure, scored and rankable.

@Service
class RedisSetService(private val stringRedisTemplate: StringRedisTemplate) {
private val setOps = stringRedisTemplate.opsForSet()
private val zsetOps = stringRedisTemplate.opsForZSet()
// --- Sets (unique collections) ---
fun addTags(articleId: Long, vararg tags: String) {
setOps.add("article:$articleId:tags", *tags)
}
fun getTags(articleId: Long): Set<String> {
return setOps.members("article:$articleId:tags") ?: emptySet()
}
// Articles with BOTH tags (intersection)
fun articlesWithAllTags(vararg tags: String): Set<String> {
val keys = tags.map { "tag:$it:articles" }
return setOps.intersect(keys) ?: emptySet()
}
// --- Sorted Sets (leaderboards, ranking) ---
fun recordScore(leaderboard: String, userId: String, score: Double) {
zsetOps.add(leaderboard, userId, score)
}
fun incrementScore(leaderboard: String, userId: String, delta: Double) {
zsetOps.incrementScore(leaderboard, userId, delta)
}
// Top N players (highest scores)
fun getTopPlayers(leaderboard: String, count: Long): Set<String> {
return zsetOps.reverseRange(leaderboard, 0, count - 1) ?: emptySet()
}
// Top N with scores
fun getTopPlayersWithScores(leaderboard: String, count: Long):
Set<org.springframework.data.redis.core.ZSetOperations.TypedTuple<String>> {
return zsetOps.reverseRangeWithScores(leaderboard, 0, count - 1) ?: emptySet()
}
// Player rank (0-based, lowest = best)
fun getPlayerRank(leaderboard: String, userId: String): Long? {
return zsetOps.reverseRank(leaderboard, userId)
}
}

Spring’s cache abstraction lets you add caching to any method with annotations. It’s the highest-level way to use Redis — no manual get/set calls.

import org.springframework.cache.annotation.EnableCaching
import org.springframework.boot.autoconfigure.SpringBootApplication
@SpringBootApplication
@EnableCaching
class Application
application.yml
spring:
cache:
type: redis
redis:
time-to-live: 600000 # 10 minutes default TTL (in ms)
cache-null-values: false # Don't cache null results
key-prefix: "myapp:" # Prefix all cache keys
use-key-prefix: true
config/CacheConfig.kt
import org.springframework.cache.CacheManager
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.cache.RedisCacheConfiguration
import org.springframework.data.redis.cache.RedisCacheManager
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer
import org.springframework.data.redis.serializer.RedisSerializationContext
import org.springframework.data.redis.serializer.StringRedisSerializer
import java.time.Duration
@Configuration
class CacheConfig {
@Bean
fun cacheManager(connectionFactory: RedisConnectionFactory): CacheManager {
// Default config for all caches
val defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(
RedisSerializationContext.SerializationPair
.fromSerializer(StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(GenericJackson2JsonRedisSerializer())
)
.disableCachingNullValues()
// Per-cache TTL overrides
val cacheConfigs = mapOf(
"users" to defaultConfig.entryTtl(Duration.ofMinutes(30)),
"products" to defaultConfig.entryTtl(Duration.ofHours(1)),
"sessions" to defaultConfig.entryTtl(Duration.ofHours(24)),
"rate-limits" to defaultConfig.entryTtl(Duration.ofMinutes(1))
)
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.transactionAware() // Respect @Transactional boundaries
.build()
}
}

The concept: First call executes the method and caches the result. Subsequent calls with the same key return the cached value without executing the method.

import org.springframework.cache.annotation.Cacheable
import org.springframework.cache.annotation.CacheEvict
import org.springframework.cache.annotation.CachePut
import org.springframework.stereotype.Service
@Service
class UserService(private val userRepository: UserRepository) {
// Cache the result — key is the method parameter
@Cacheable(value = ["users"], key = "#id")
fun getUser(id: Long): UserDTO {
println("Fetching user $id from database") // Only printed on cache miss
val user = userRepository.findById(id).orElseThrow {
NoSuchElementException("User $id not found")
}
return user.toDTO()
}
// Cache with composite key
@Cacheable(value = ["users"], key = "'email:' + #email")
fun getUserByEmail(email: String): UserDTO? {
return userRepository.findByEmail(email)?.toDTO()
}
// Conditional caching — only cache active users
@Cacheable(value = ["users"], key = "#id", condition = "#id > 0",
unless = "#result != null && !#result.active")
fun getUserIfActive(id: Long): UserDTO? {
return userRepository.findById(id).orElse(null)?.toDTO()
}
}

Comparison to manual caching — the @Cacheable annotation replaces a hand-written cache-aside block:

// TypeScript — manual cache-aside pattern
async function getUser(id: number): Promise<User> {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.user.findUnique({ where: { id } });
if (user) {
await redis.setex(`user:${id}`, 600, JSON.stringify(user));
}
return user;
}
@Service
class UserService(private val userRepository: UserRepository) {
// Evict single entry when user is updated
@CacheEvict(value = ["users"], key = "#id")
fun updateUser(id: Long, request: UpdateUserRequest): UserDTO {
val user = userRepository.findById(id).orElseThrow()
user.name = request.name
user.email = request.email
return userRepository.save(user).toDTO()
}
// Evict single entry when user is deleted
@CacheEvict(value = ["users"], key = "#id")
fun deleteUser(id: Long) {
userRepository.deleteById(id)
}
// Evict ALL entries in the cache
@CacheEvict(value = ["users"], allEntries = true)
fun refreshAllUsers() {
// Called when you need to invalidate everything
// e.g., after a bulk import
}
// Evict BEFORE the method executes (useful for delete operations)
@CacheEvict(value = ["users"], key = "#id", beforeInvocation = true)
fun forceDeleteUser(id: Long) {
userRepository.deleteById(id)
}
}

@CachePut — Update Cache Without Skipping

Section titled “@CachePut — Update Cache Without Skipping”

Unlike @Cacheable (which skips execution on cache hit), @CachePut always executes the method and updates the cache with the result:

@Service
class ProductService(private val productRepository: ProductRepository) {
@Cacheable(value = ["products"], key = "#id")
fun getProduct(id: Long): ProductDTO {
return productRepository.findById(id).orElseThrow().toDTO()
}
// Always execute AND update cache
@CachePut(value = ["products"], key = "#id")
fun updateProduct(id: Long, request: UpdateProductRequest): ProductDTO {
val product = productRepository.findById(id).orElseThrow()
product.name = request.name
product.price = request.price
return productRepository.save(product).toDTO()
}
}
import org.springframework.cache.annotation.Caching
@Service
class UserService(private val userRepository: UserRepository) {
// Multiple cache operations on one method
@Caching(
evict = [
CacheEvict(value = ["users"], key = "#id"),
CacheEvict(value = ["users"], key = "'email:' + #result.email")
]
)
fun updateUser(id: Long, request: UpdateUserRequest): UserDTO {
val user = userRepository.findById(id).orElseThrow()
user.name = request.name
user.email = request.email
return userRepository.save(user).toDTO()
}
}
AnnotationBehaviorUse Case
@CacheableReturn cached value; execute + cache on missRead operations
@CacheEvictRemove entry from cacheAfter updates/deletes
@CachePutAlways execute + update cacheUpdate operations where you want cache refreshed
@CachingCombine multiple cache operationsComplex invalidation
@CacheConfigClass-level defaultsReduce annotation repetition

This is what @Cacheable implements. The application manages the cache explicitly: on a read it checks the cache first and only hits the database on a miss; on a write it updates the database and invalidates the cache.

Cache-aside read & write
Rendering diagram…
// Cache-aside is the default with @Cacheable + @CacheEvict
@Service
class ProductService(private val productRepository: ProductRepository) {
@Cacheable("products")
fun getProduct(id: Long): ProductDTO {
// Only called on cache miss
return productRepository.findById(id).orElseThrow().toDTO()
}
@CacheEvict("products", key = "#id")
fun updateProduct(id: Long, request: UpdateProductRequest): ProductDTO {
// Update DB, then invalidate cache
val product = productRepository.findById(id).orElseThrow()
product.name = request.name
return productRepository.save(product).toDTO()
}
}

Pros: Simple, only caches what’s actually read. Cons: First request is always a cache miss (cold start).

The cache itself is responsible for loading data from the source. Spring doesn’t support this natively with annotations, but you can implement it:

@Service
class ReadThroughCacheService(
private val redisTemplate: RedisTemplate<String, Any>,
private val userRepository: UserRepository
) {
fun getUser(id: Long): UserDTO {
val key = "user:$id"
// Try cache first
val cached = redisTemplate.opsForValue().get(key)
if (cached != null) {
return cached as UserDTO
}
// Load from DB and populate cache atomically
val user = userRepository.findById(id).orElseThrow()
val dto = user.toDTO()
redisTemplate.opsForValue().set(key, dto, Duration.ofMinutes(30))
return dto
}
}

Write to cache and database simultaneously, so the cache is always up to date.

Write-through write path
Rendering diagram…
@Service
class WriteThroughService(
private val redisTemplate: RedisTemplate<String, Any>,
private val userRepository: UserRepository
) {
fun createUser(request: CreateUserRequest): UserDTO {
// Write to database
val user = userRepository.save(User(name = request.name, email = request.email))
val dto = user.toDTO()
// Write to cache immediately
redisTemplate.opsForValue().set("user:${user.id}", dto, Duration.ofMinutes(30))
return dto
}
fun updateUser(id: Long, request: UpdateUserRequest): UserDTO {
// Update database
val user = userRepository.findById(id).orElseThrow()
user.name = request.name
val saved = userRepository.save(user)
val dto = saved.toDTO()
// Update cache immediately
redisTemplate.opsForValue().set("user:$id", dto, Duration.ofMinutes(30))
return dto
}
}

Pros: Cache is always warm and consistent. Cons: Write latency increases; cache may store data that’s never read.

Write to cache immediately, then asynchronously write to the database. Useful for high-write scenarios:

@Service
class WriteBehindService(
private val redisTemplate: RedisTemplate<String, Any>,
private val userRepository: UserRepository
) {
// Write to cache immediately
fun updateUserScore(userId: Long, score: Int) {
redisTemplate.opsForHash<String, String>()
.put("user:$userId", "score", score.toString())
// Queue for async DB write
redisTemplate.opsForList()
.leftPush("write-behind:queue", "$userId:score:$score")
}
// Background worker flushes queue to DB periodically
@Scheduled(fixedDelay = 5000) // Every 5 seconds
fun flushWriteBehindQueue() {
val ops = redisTemplate.opsForList()
while (true) {
val item = ops.rightPop("write-behind:queue") as? String ?: break
val (userId, field, value) = item.split(":")
// Write to DB
userRepository.updateScore(userId.toLong(), value.toInt())
}
}
}

Pros: Very fast writes. Cons: Data loss risk if Redis crashes before flush; complex to implement correctly.

StrategyRead PerfWrite PerfConsistencyComplexity
Cache-AsideGood (miss on cold)GoodEventualLow
Read-ThroughGood (miss on cold)GoodEventualMedium
Write-ThroughGoodSlower (2 writes)StrongMedium
Write-BehindGoodFastestEventualHigh
await redis.setex('key', 3600, 'value'); // 1 hour TTL
await redis.expire('existing-key', 300); // Set TTL on existing key
@Bean
fun cacheManager(connectionFactory: RedisConnectionFactory): CacheManager {
val defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
val cacheConfigs = mapOf(
"users" to defaultConfig.entryTtl(Duration.ofMinutes(30)),
"products" to defaultConfig.entryTtl(Duration.ofHours(2)),
"config" to defaultConfig.entryTtl(Duration.ofHours(24)),
"rate-limits" to defaultConfig.entryTtl(Duration.ofSeconds(60))
)
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.build()
}

When Redis reaches maxmemory, it must evict keys. Configure in redis.conf or at runtime:

PolicyBehaviorUse Case
noevictionReturn error on writeWhen data loss is unacceptable
allkeys-lruEvict least recently usedGeneral-purpose cache
allkeys-lfuEvict least frequently usedHot/cold data patterns
volatile-lruLRU among keys with TTLMix of cache + persistent data
volatile-ttlEvict keys closest to expiryPrioritize newer data
allkeys-randomRandom evictionWhen all keys are equally important
Terminal window
# Check current policy
redis-cli CONFIG GET maxmemory-policy
# Set policy
redis-cli CONFIG SET maxmemory-policy allkeys-lru
redis-cli CONFIG SET maxmemory 256mb

Redis Pub/Sub is for real-time messaging between services. It’s fire-and-forget: if no subscriber is listening, the message is lost.

Redis Pub/Sub fan-out
Rendering diagram…
// Publisher
const pub = new Redis();
await pub.publish('notifications', JSON.stringify({ userId: 1, message: 'Hello' }));
// Subscriber
const sub = new Redis();
sub.subscribe('notifications');
sub.on('message', (channel, message) => {
console.log(`${channel}: ${JSON.parse(message)}`);
});
FeatureRedis Pub/SubKafka
DeliveryAt-most-once (fire and forget)At-least-once / exactly-once
PersistenceNo (messages lost if no subscriber)Yes (retained on disk)
Consumer groupsNo (all subscribers get all messages)Yes (partitioned consumption)
OrderingPer-channelPer-partition
Use caseReal-time notifications, cache invalidationEvent sourcing, data pipelines
ThroughputVery high (in-memory)High (disk-based)

Use Redis Pub/Sub for: Cache invalidation across instances, real-time UI updates, lightweight notifications. Use Kafka for: Event sourcing, reliable message processing, data pipelines (see Module 12).

Storing HTTP sessions in Redis enables stateless application servers (any instance can handle any request).

build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.session:spring-session-data-redis")
}
application.yml
spring:
session:
store-type: redis
redis:
namespace: myapp:sessions
timeout: 1800 # 30 minutes
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession
@SpringBootApplication
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
class Application
// Sessions just work — Spring automatically stores them in Redis
@RestController
class SessionController {
@GetMapping("/login")
fun login(session: HttpSession, @RequestParam username: String): String {
session.setAttribute("username", username)
session.setAttribute("loginTime", Instant.now().toString())
return "Logged in as $username"
}
@GetMapping("/me")
fun me(session: HttpSession): Map<String, Any?> {
return mapOf(
"sessionId" to session.id,
"username" to session.getAttribute("username"),
"loginTime" to session.getAttribute("loginTime")
)
}
@PostMapping("/logout")
fun logout(session: HttpSession): String {
session.invalidate()
return "Logged out"
}
}

What it looks like in Redis:

Terminal window
redis-cli
> KEYS myapp:sessions:*
1) "myapp:sessions:sessions:abc123"
> HGETALL myapp:sessions:sessions:abc123
# Contains serialized session attributes

Lettuce is the default Redis client in Spring Boot. Unlike Jedis (which uses blocking I/O and connection pools), Lettuce uses Netty and shares a single connection across threads.

Most of the time, use RedisTemplate or @Cacheable. Use Lettuce directly when you need:

  • Reactive/async Redis operations
  • Redis Streams
  • Custom connection management
  • Pipeline operations for bulk commands
@Service
class RedisPipelineService(private val stringRedisTemplate: StringRedisTemplate) {
// Execute multiple commands in a single round-trip
fun batchSet(entries: Map<String, String>) {
stringRedisTemplate.executePipelined { connection ->
entries.forEach { (key, value) ->
connection.stringCommands().set(
key.toByteArray(),
value.toByteArray()
)
}
null // Must return null
}
}
// Batch get
fun batchGet(keys: List<String>): List<String?> {
val results = stringRedisTemplate.executePipelined { connection ->
keys.forEach { key ->
connection.stringCommands().get(key.toByteArray())
}
null
}
@Suppress("UNCHECKED_CAST")
return results as List<String?>
}
}

For operations that need to be atomic (like rate limiting), use Lua scripts:

@Service
class RedisScriptService(private val stringRedisTemplate: StringRedisTemplate) {
// Rate limiter using Lua script
private val rateLimitScript = DefaultRedisScript<Long>().apply {
setScriptText("""
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, window)
end
if current > limit then
return 0
end
return 1
""".trimIndent())
resultType = Long::class.java
}
/**
* Returns true if the request is allowed, false if rate limited.
* @param key identifier (e.g., "rate:user:123" or "rate:ip:1.2.3.4")
* @param limit max requests per window
* @param windowSeconds window duration
*/
fun isAllowed(key: String, limit: Int, windowSeconds: Int): Boolean {
val result = stringRedisTemplate.execute(
rateLimitScript,
listOf(key),
limit.toString(),
windowSeconds.toString()
)
return result == 1L
}
}

Put these patterns to work — wire up real caching annotations and an atomic Lua-backed limiter against the shared Redis instance.