Skip to content

Bluetape4k Cache Part 3: Building Caching Strategies with Near Cache and Exposed

Exposed repository cache strategy workbench with robot workers and cache pipelines
Once a cache sits in front of a database, performance and consistency enter the same design conversation.

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

Putting a cache in front of an Exposed repository looks easy at first.

  1. Check the cache.
  2. If the value is missing, read the database.
  3. When saving, update the cache too.

Everyone is calm up to this point. Then the questions arrive: what if the write fails, what about deletes, what about other instances’ local caches, what about batch updates, and what if the read model and write model are not the same? The meeting room suddenly needs another whiteboard.

bluetape4k-exposed splits that problem into repository contracts and cache strategies. It keeps separate choices for where the cache lives, how it is tied to database writes, and what a failure should mean.

JdbcCacheRepository strategy map with Redisson map loader, map writer, and Exposed DB
The key idea in JdbcCacheRepository is attaching a loader and writer to a Redisson map so read/write contracts change at the map boundary.

The base contracts are JdbcCacheRepository and R2dbcCacheRepository. Application code calls get, put, and invalidate, but the actual behavior follows the selected cache mode and write mode.

interface JdbcCacheRepository<ID: Any, E: Serializable>: Closeable {
val table: IdTable<ID>
val cacheName: String
val cacheMode: CacheMode
val cacheWriteMode: CacheWriteMode
fun ResultRow.toEntity(): E
fun extractId(entity: E): ID
fun findByIdFromDb(id: ID): E?
fun get(id: ID): E?
fun getAll(ids: Collection<ID>): Map<ID, E>
fun put(id: ID, entity: E)
fun putAll(entities: Map<ID, E>, batchSize: Int = DEFAULT_BATCH_SIZE)
fun invalidate(id: ID)
fun invalidateAll(ids: Collection<ID>)
fun clear()
}

Related source: JdbcCacheRepository.kt, R2dbcCacheRepository.kt

The important detail is that get and put are not plain map operations. They follow repository policy, and that policy changes performance, consistency, and behavior during failures.

The Implementation Center: MapLoader and MapWriter

Section titled “The Implementation Center: MapLoader and MapWriter”

AbstractJdbcRedissonRepository attaches Redisson loader/writer components when it creates an RMap or RLocalCachedMap.

protected open val mapLoader: EntityMapLoader<ID, E> by lazy {
ExposedEntityMapLoader(table) { toEntity() }
}
protected val mapWriter: EntityMapWriter<ID, E>? by lazy {
when (config.cacheMode) {
RedissonCacheConfig.CacheMode.READ_ONLY -> null
RedissonCacheConfig.CacheMode.READ_WRITE ->
ExposedEntityMapWriter(
entityTable = table,
updateBody = { stmt, entity ->
with(this@AbstractJdbcRedissonRepository) { stmt.updateEntity(entity) }
},
batchInsertBody = { entity ->
val stmt = this
with(this@AbstractJdbcRedissonRepository) { stmt.insertEntity(entity) }
},
writeMode = config.writeMode,
)
}
}

Full source: JdbcCacheRepository.kt, AbstractJdbcRedissonRepository.kt

That is why get can look like a map lookup while a cache miss loads from the database through the loader. Likewise, put can look like a cache write while WRITE_THROUGH persists through the writer immediately, and WRITE_BEHIND queues persistence asynchronously. This differs from cache-aside code where the application manually sequences “save DB, then cache.put.”

Read-through fills the cache through EntityMapLoader when a cache miss occurs. exposed-workshop chapter 11 shows this path with UserCredentialsCacheRepository.

@Repository
class UserCredentialsCacheRepository(
redissonClient: RedissonClient,
) : AbstractJdbcRedissonRepository<String, UserCredentialsRecord>(
redissonClient = redissonClient,
config = RedissonCacheConfig.READ_ONLY_WITH_NEAR_CACHE.copy(
name = "exposed:user-credentials",
),
) {
override val table = UserCredentialsTable
override fun ResultRow.toEntity() = toUserCredentialsRecord()
override fun extractId(entity: UserCredentialsRecord): String = entity.id
}

Full source: UserCredentialsCacheRepository.kt, UserCredentialsCacheRepositoryTest.kt

It fits read-heavy data. The first request reads from the database; later requests hit the cache. The write path still needs correct invalidation. Otherwise yesterday’s cache and today’s database start telling different stories.

Write-through sends put and putAll through a writer-backed Redisson map, and ExposedEntityMapWriter applies the same operation to the database. UserCacheRepository in exposed-workshop chapter 11 combines read-through and write-through.

@Repository
class UserCacheRepository(
redissonClient: RedissonClient,
) : AbstractJdbcRedissonRepository<Long, UserRecord>(
redissonClient = redissonClient,
config = RedissonCacheConfig.READ_WRITE_THROUGH_WITH_NEAR_CACHE.copy(
name = "exposed:users",
),
) {
override val table: UserTable = UserTable
override fun ResultRow.toEntity(): UserRecord = toUserRecord()
override fun extractId(entity: UserRecord): Long = entity.id
override fun BatchInsertStatement.insertEntity(entity: UserRecord) {
this[UserTable.username] = entity.username
this[UserTable.firstName] = entity.firstName
this[UserTable.lastName] = entity.lastName
}
override fun UpdateStatement.updateEntity(entity: UserRecord) {
this[UserTable.username] = entity.username
this[UserTable.firstName] = entity.firstName
this[UserTable.lastName] = entity.lastName
}
}

Related example: UserCacheRepository.kt, UserCacheRepositoryTest.kt

The benefit is fresh reads right after writes. The cost is that cache write work enters write latency. Writes are not free; the invoice arrives as latency.

Write-behind uses the same writer, but Redisson’s write mode is WRITE_BEHIND. put updates the cache first, and database writes are flushed asynchronously through a queue.

@Repository
class UserEventCacheRepository(
redissonClient: RedissonClient,
) : AbstractJdbcRedissonRepository<Long, UserEventRecord>(
redissonClient = redissonClient,
config = RedissonCacheConfig.WRITE_BEHIND_WITH_NEAR_CACHE.copy(
name = "exposed:user-events",
),
) {
override val table: UserEventTable = UserEventTable
override fun ResultRow.toEntity(): UserEventRecord = toUserEventRecord()
override fun extractId(entity: UserEventRecord): Long = entity.id
override fun BatchInsertStatement.insertEntity(entity: UserEventRecord) {
this[UserEventTable.username] = entity.username
this[UserEventTable.eventType] = entity.eventType
this[UserEventTable.eventTime] = entity.eventTime
}
}

Full source: UserEventCacheRepository.kt, UserEventCacheRepositoryTest.kt

Write-behind can reduce write latency, but it adds design work.

  • What happens on queue overflow?
  • Should pending writes be flushed on close?
  • How are repeated updates to the same key merged?
  • How do we prevent stale reads after remove?
  • Can retry duplicates conflict with DB upsert behavior?

At this point, cache is no longer a fast Map. It is a small distributed system. Small distributed systems should fail small. If they fail big, only the name was small.

bluetape4k-exposed separates cache location from write strategy.

Related source: CacheMode.kt, CacheWriteMode.kt, LocalCacheConfig.kt

That separation matters. “Where do we cache?” and “When do we write?” are different questions. The same Redis backend can mean very different operational behavior under read-through, write-through, or write-behind.

QuestionExamples
cache locationlocal Caffeine, remote Redis, near cache
cache read pathread-through loader
write modeREAD_ONLY, WRITE_THROUGH, WRITE_BEHIND
API shapeJDBC, suspended JDBC, R2DBC
consistency targetstrong-ish consistency, eventual consistency, best-effort fallback

04-benchmark in exposed-workshop chapter 11 compares cache strategies with real numbers. It uses AverageTime; lower is better.

Exposed cache benchmark chart comparing no cache, read-through, and write-through latency
Read-heavy workloads show large cache-strategy differences. In write-heavy workloads, the DB write becomes the main character again.

Related source: CacheStrategyComparisonBenchmark.kt, ReadThroughCacheBenchmark.kt, BENCHMARK-REPORT.md

StrategyWorkloadPayloadAverage
NO_CACHEREAD_HEAVY256B517.520 us/op
READ_THROUGHREAD_HEAVY256B94.320 us/op
WRITE_THROUGHREAD_HEAVY256B52.103 us/op
NO_CACHEWRITE_HEAVY256B507.766 us/op
READ_THROUGHWRITE_HEAVY256B468.735 us/op
WRITE_THROUGHWRITE_HEAVY256B445.363 us/op

In read-heavy workloads, READ_THROUGH is about 5.5x faster than the baseline, and WRITE_THROUGH is about 9.9x faster. In write-heavy workloads, the difference shrinks to around 1.1x. Cache earns money on the read path and receives invoices on the write path.

The single-read read-through benchmark is also worth reading.

BenchmarkPayloadAverage
dbOnlyRead256B0.001 us/op
readThroughCacheHit256B0.003 us/op
readThroughCacheMiss256B0.119 us/op

Hits are cheap. Misses are much more expensive because they invalidate and reload. Near Cache still lives or dies by hit ratio. A cache that hits is a hero; a cache that misses often is a very busy decoration.

StrategyFits Well WhenWatch Out For
Read-throughReads dominate and writes are rareMissing invalidation after writes
Write-throughFresh reads right after writes matterHigher write latency
Write-behindWrite bursts need absorptionQueue, retry, flush, stale reads
NearCache + read-throughHot reads are clearL1 hit ratio and invalidation
NearCache + write-behindLocal responsiveness matters stronglyFailure semantics must be documented

Next, we will put theory down for a moment and move to practical examples in bluetape4k-workshop. Code is usually kind. We are often just in a hurry when reading it.

Comments

Leave a note or reaction with your GitHub account.