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

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.

Example Locations
Section titled “Example Locations”The relevant examples are below.
| Example | Role |
|---|---|
| cache-caffeine | Spring Cache + Caffeine local cache |
| cache-redis | Spring Data Redis + Lettuce |
| cache-benchmark | Compare no cache, Caffeine, Redis, and Near Cache profiles |
| cache-resilience | Caffeine fallback when Redis is unavailable |
Caffeine: The Closest Cache
Section titled “Caffeine: The Closest Cache”The cache-caffeine example connects Caffeine to Spring Cache abstraction.
@Configurationclass 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.”
Redis: The Shared Cache
Section titled “Redis: The Shared Cache”The cache-redis example connects Redis to Spring Cache and RedisTemplate.
@Beanfun 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.
| Implementation | Meaning |
|---|---|
NoCacheService | DB baseline |
CaffeineService | Local in-JVM cache |
RedisCacheService | Remote distributed cache |
NearCacheService | Local + Redis near cache |
WriteThroughService | true write-through: save writes DB and Redis cache synchronously |
ReadThroughService | DB load on cache miss |
WriteBehindService | cache-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.

Benchmark Results
Section titled “Benchmark Results”cache-benchmark compares seven profiles under the same ProductCacheService contract.
Throughput is measured in ops/s; higher is better.

Related source: cache-benchmark README.ko.md, CaffeineBenchmark.kt, NearCacheBenchmark.kt, WriteBehindBenchmark.kt
Read Throughput
Section titled “Read Throughput”| Profile | Read ops/s | vs No Cache |
|---|---|---|
| No Cache | ~8,200 | 1x |
| Caffeine | ~490,000 | 60x |
| Redis Cache | ~43,000 | 5x |
| Near Cache | ~465,000 | 57x |
| Read-Through | ~42,000 | 5x |
| Write-Through | ~41,000 | 5x |
| Write-Behind | ~42,000 | 5x |
Write Throughput
Section titled “Write Throughput”| Profile | Write ops/s | Note |
|---|---|---|
| No Cache | ~8,200 | DB only |
| Caffeine | ~8,100 | DB write + local cache |
| Redis Cache | ~7,300 | DB write + Redis SET |
| Near Cache | ~7,200 | DB write + RLocalCachedMap PUT |
| Write-Through | ~5,600 | Synchronous DB + Redis |
| Write-Behind | ~24,000 | Cache 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.
Practical Checklist
Section titled “Practical Checklist”Before adding cache to a service, answer at least these questions.
| Question | Why 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 |
Wrap-up
Section titled “Wrap-up”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.