Skip to content

Bluetape4k Cache Part 4: Getting a Feel for Cache Strategies with Workshop Examples

Spring Boot cache workshop workbench with robot workers, service blocks, Redis, local cache, and benchmark gauges
Docs explain cache strategies, examples test them, and production keeps them honest.

This is Part 4 of the Bluetape4k Cache series. The full series includes Part 1: cache module overview, Part 2: Near Cache, Part 3: Near Cache + Exposed strategies, and Part 4: workshop examples.

This post is about working examples. bluetape4k-workshop has several Spring Boot cache examples.

The goal is not to repeat “cache is good.” The goal is to see what shape each strategy gives the code, what cost it hides, and what operational contract it asks you to accept.

Workshop cache profiles comparing no cache, Caffeine, Redis, Near Cache, and benchmark path
The same Product service gets different performance and consistency contracts depending on the cache profile.

The relevant examples are below.

ExampleRole
cache-caffeineSpring Cache + Caffeine local cache
cache-redisSpring Data Redis + Lettuce
cache-benchmarkCompare no cache, Caffeine, Redis, and Near Cache profiles
cache-resilienceCaffeine fallback when Redis is unavailable

The cache-caffeine example connects Caffeine to Spring Cache abstraction.

@Configuration
class CaffeineConfig {
@Bean
fun cacheManager(caffeine: Caffeine<Any, Any>): CacheManager {
return CaffeineCacheManager("cache:countries", "cache:cities").apply {
setCaffeine(caffeine)
}
}
@Bean
fun caffeineBean(): Caffeine<Any, Any> = caffeine {
maximumSize(10_000)
expireAfterWrite(30.minutes)
}
}

Full source: CaffeineConfig.kt

This works well for single-instance read-heavy caching. It is fast and simple. The drawback is also simple: if there are two instances, there are two caches. They do not talk to each other. An alarming number of problems begin with “they do not talk to each other.”

The cache-redis example connects Redis to Spring Cache and RedisTemplate.

@Bean
fun redisCacheManager(connectionFactory: RedisConnectionFactory): CacheManager =
RedisCacheManager.builder(connectionFactory)
.cacheDefaults(
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofDays(1))
)
.build()

Full source: LettuceRedisCacheConfiguration.kt

Redis lets multiple instances see the same values. The cost is that every read has a remote hop. As Part 2 showed, if hot reads dominate, Near Cache becomes a candidate.

cache-benchmark: Same Interface, Different Strategies

Section titled “cache-benchmark: Same Interface, Different Strategies”

cache-benchmark places several strategies behind the same ProductCacheService interface.

interface ProductCacheService {
fun findById(id: Long): Product?
fun save(product: Product): Product
fun evict(id: Long)
fun clearAll()
}

Full source: ProductCacheService.kt

The implementations separate the strategies.

ImplementationMeaning
NoCacheServiceDB baseline
CaffeineServiceLocal in-JVM cache
RedisCacheServiceRemote distributed cache
NearCacheServiceLocal + Redis near cache
WriteThroughServicetrue write-through: save writes DB and Redis cache synchronously
ReadThroughServiceDB load on cache miss
WriteBehindServicecache-first writes, DB flush asynchronously on updates

Related source: NoCacheService.kt, CaffeineService.kt, RedisCacheService.kt, NearCacheService.kt, ReadThroughService.kt, WriteThroughService.kt, WriteBehindService.kt

This structure is useful because benchmark code and production code share the same questions. Changing the strategy behind one interface lets you compare throughput and code complexity side by side.

  • What is the baseline without cache?
  • Is local cache enough?
  • Do we need Redis sharing?
  • Does Near Cache reduce the read path enough?
  • Can we accept the write-through cost?

One caveat: in cache-benchmark, WriteThroughService is true synchronous write-through, so save() updates DB and Redis in the same request flow.

ReadThroughService keeps writes in an app-managed cache-aside pattern (DB save + explicit cache update), while WriteBehindService updates cache first and flushes DB asynchronously for updates.

Workshop cache-benchmark strategies and their read/write contracts
All profiles share one API. Only the write path differs in how soon and where the DB and Redis are synchronized.

cache-benchmark compares seven profiles under the same ProductCacheService contract. Throughput is measured in ops/s; higher is better.

Workshop cache benchmark profiles comparing read and write throughput
Caffeine and Near Cache dominate reads. Write-Behind wins writes, but its semantics require more care.

Related source: cache-benchmark README.ko.md, CaffeineBenchmark.kt, NearCacheBenchmark.kt, WriteBehindBenchmark.kt

ProfileRead ops/svs No Cache
No Cache~8,2001x
Caffeine~490,00060x
Redis Cache~43,0005x
Near Cache~465,00057x
Read-Through~42,0005x
Write-Through~41,0005x
Write-Behind~42,0005x
ProfileWrite ops/sNote
No Cache~8,200DB only
Caffeine~8,100DB write + local cache
Redis Cache~7,300DB write + Redis SET
Near Cache~7,200DB write + RLocalCachedMap PUT
Write-Through~5,600Synchronous DB + Redis
Write-Behind~24,000Cache first, DB flushes asynchronously

From the table alone, Write-Behind looks like royalty. The crown comes with terms and conditions. It is a good option only when eventual consistency is acceptable and the team can define flush failure, retry, shutdown drain, and observability behavior.

NearCacheService: Between Local and Remote

Section titled “NearCacheService: Between Local and Remote”

The Near Cache example uses Redisson local cached map.

class NearCacheService(
private val productRepository: ProductRepository,
redissonClient: RedissonClient,
) : ProductCacheService {
companion object : KLoggingChannel() {
const val CACHE_NAME = "products-near-cache"
}
}

Full source: NearCacheService.kt

The code looks short, but the meaning is large. Hot reads finish locally; the remote cache handles sharing and invalidation. The key question is not “did we use Near Cache?” but “is the L1 hit ratio high enough?” If it is not, you bought a desk drawer and still keep running to the warehouse.

resilience: Do Not Let Cache Failure Become Service Failure

Section titled “resilience: Do Not Let Cache Failure Become Service Failure”

cache-resilience uses Redis as the primary cache and Caffeine as fallback.

suspend fun getProduct(id: String): String? {
val redisKey = "$REDIS_KEY_PREFIX$id"
return SuspendDecorators.ofSupplier {
redisTemplate.opsForValue().get(redisKey)
}
.withCircuitBreaker(circuitBreaker)
.withFallback { localCache.getIfPresent(id) }
.invoke()
}

Full source: ResilientProductService.kt

This example matters operationally. Cache was added for performance. If Redis is briefly unstable and the whole service is affected, something is off. Cache is support infrastructure. When support infrastructure becomes the main outage path, the design needs another look.

Before adding cache to a service, answer at least these questions.

QuestionWhy It Matters
What is baseline latency?You need to know whether cache improves anything
What hit ratio do we need?Low hit ratio can add cost without benefit
How much staleness is acceptable?Core contract for Near Cache and write-behind
What is the write failure policy?Choose throw, retry, queue, or best effort
What is the invalidation scope?Single key, bulk, prefix, and clear-all differ
What is the fallback on failure?Redis failure should not become service failure
What do we observe?hit/miss, latency, queue depth, circuit state

The conclusion of this series is not “always use Near Cache.”

It is more precise than that.

  • For single-JVM hot reads, Caffeine is the simplest answer.
  • When multiple instances must share state, a remote cache such as Redis is needed.
  • When remote reads become the bottleneck, consider Near Cache.
  • When connecting cache to an Exposed repository, separate read and write strategy.
  • A cache without failure behavior is not fully designed.

Cache is both a performance optimization and an operational contract. Operational contracts have a way of returning very clearly when written casually. Usually at night.

Comments

Leave a note or reaction with your GitHub account.