Spring Boot Practical Guide Part 2: Caching Strategy and Redis

Spring Boot Practical Guide Part 2: Caching Strategy and Redis


Series Navigation

PreviousCurrentNext
Part 1: Concurrency ControlPart 2: Caching StrategyPart 3: Event-Driven

Introduction

Caching is a powerful tool for improving performance, but when used incorrectly, it only adds complexity. In this part, we cover when to introduce caching and how to implement it correctly.

What Part 2 covers:

  • Criteria for deciding when to introduce caching
  • Choosing caching strategies based on data characteristics
  • Cache-Aside pattern and correct implementation (DTO caching)
  • Resolving cache data inconsistency issues
  • Cache problems (Stampede, Penetration, Avalanche)

Table of Contents


1. What is a Cache?

A cache is a technique that stores frequently accessed data in a fast storage layer to reduce response time and decrease DB load.

1.1 Response Time Comparison

┌─────────────────────────────────────────────────────────────┐
│  Response Time by Storage Type                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  DB Query:      ~10ms   (Network + Disk I/O)               │
│  Redis Query:   ~1ms    (Network + Memory)                  │
│  Local Cache:   ~0.01ms (Memory only)                       │
│                                                             │
│  * Local cache is 100x faster than Redis                    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

1.2 Cache Effect Calculation

Assuming QPS 1000, DB query 10ms:

Without cache:     1000 x 10ms = 10 seconds/sec of DB load
Cache 90% hit:     100 x 10ms = 1 second/sec of DB load (10x reduction!)

1.3 Cache Suitability Assessment

Suitable DataUnsuitable Data
Frequently read dataFrequently changing data
Rarely changing dataData requiring real-time accuracy
Data with high computation costUser-specific sensitive data
Shareable dataOne-time data
Marketplace example:

Suitable: Product listings, categories, popular products, configuration values
Not suitable: Stock quantities, payment status, real-time prices

2. When to Introduce Caching

Key point: Caching is not “nice to have” — it should be introduced when a problem occurs.

2.1 Introduction Signals (Consider caching in these situations)

1. DB CPU usage consistently above 70%
2. Same queries being executed repeatedly (slow query log analysis)
3. API response time failing SLA (e.g., p95 > 500ms)
4. DB connection pool exhaustion
5. Expected traffic surge (events, promotions)

2.2 SLA/SLO/SLI Terminology

TermMeaningExample
SLI (Indicator)Actual measured valuep95 response time = 320ms
SLO (Objective)Internal targetp95 < 500ms
SLA (Agreement)External commitment (compensation on violation)p95 < 1000ms
SLA example:

[Response Time]
- p50: Under 100ms   (50% of requests respond within 100ms)
- p95: Under 500ms   (95% of requests respond within 500ms)
- p99: Under 1000ms  (99% of requests respond within 1 second)

[Availability]
- 99.9%  -> Approximately 43 minutes of downtime per month allowed
- 99.99% -> Approximately 4 minutes of downtime per month allowed

2.3 Introduction Decision Flow

                        Start


                  ┌───────────────┐
                  │ Is response   │
                  │ time slow?    │
                  └───────────────┘
                     │         │
                   YES        NO
                     │         │
                     ▼         ▼
              ┌──────────┐   Cache not needed
              │ Is DB    │   (Avoid premature optimization)
              │ the cause?│
              └──────────┘
                 │    │
               YES   NO
                 │    │
                 ▼    ▼
          ┌──────────┐  Fix other bottlenecks
          │ Can query │  (Network, external APIs)
          │ be optimized?│
          └──────────┘
             │    │
           YES   NO
             │    │
             ▼    ▼
       Index/query  ┌──────────┐
       tuning first │ Read:Write│
                   │ > 10:1?  │
                   └──────────┘
                      │    │
                    YES   NO
                      │    │
                      ▼    ▼
                 Introduce  Consider DB
                 caching    scale-up

2.4 Pre-Introduction Checklist

□ Have you measured the current bottleneck? (APM, slow query logs)
□ Have you explored solutions without caching? (Indexes, query optimization)
□ Have you identified the read/write ratio of the data to cache?
□ Have you defined the acceptable range of data inconsistency?
□ Do you have a fallback strategy for cache failures?
□ Do you have a plan for monitoring cache hit rate?

2.5 When NOT to Introduce Caching

"We'll need it when traffic grows later" -> Premature optimization
"Other companies use Redis too" -> Baseless adoption
Write-heavy data -> Minimal cache benefit
Data requiring real-time accuracy -> Stock, payment status
User-specific data -> Low cache hit rate

2.6 Phased Introduction Strategy

[Phase 1] Start with local cache (Caffeine)
          - Immediate application without additional infrastructure
          - For single server or when data inconsistency is acceptable

[Phase 2] Switch to distributed cache (Redis)
          - Multi-server environment
          - When data consistency is needed

[Phase 3] Multi-level cache setup (Caffeine + Redis)
          - Hot data in local, everything in Redis
          - When optimal performance is required

3. Strategy Selection by Data Characteristics

Key point: Don’t handle all data with a single strategy. Use different strategies based on characteristics!

Data CharacteristicsExampleRecommended StrategyTTL
Rarely changesCategories, terms, settingsRead-Through + Refresh-Ahead1 hour ~ 1 day
Occasionally changesProduct info, profilesCache-Aside + Explicit invalidation5 ~ 30 min
Frequently changesStock, pricesNo caching or very short TTL10 ~ 30 sec
Write-heavyView counts, likesWrite-BehindN/A (batch)
High computation costStatistics, rankings, aggregationsCache-Aside + Long TTL5 min ~ 1 hour

3.2 Decision Criteria

1. Read:Write ratio
   - 100:1 or higher -> Aggressive caching
   - Around 10:1 -> Selective caching
   - 1:1 or lower -> Minimal cache benefit

2. Inconsistency tolerance
   - Not tolerable (stock, payments) -> No caching
   - Seconds tolerable -> Short TTL (10~30 sec)
   - Minutes tolerable -> Normal TTL + invalidation

3. Access pattern
   - Hot Data (popular products) -> Local cache + Redis (multi-level)
   - Cold Data (old products) -> Redis only or no caching

4. Computation cost
   - Simple query -> Small cache benefit
   - Aggregation/sorting/join -> Large cache benefit

4. Cache-Aside Pattern (Lazy Loading)

The most widely used pattern. The application directly manages the cache and DB.

4.1 How It Works

[Read - Cache Hit]
Client -> App -> Cache (HIT) -> Return data

[Read - Cache Miss]
Client -> App -> Cache (MISS) -> DB query -> Save to Cache -> Return data

[Write]
Client -> App -> Save to DB -> Invalidate Cache (or update)

4.2 Entity Caching is an Anti-pattern!

// Bad example: Caching Entity directly
@Cacheable(value = ["products"], key = "#id")
fun getProduct(id: Long): Product {  // Returns Entity
    return productRepository.findById(id).orElseThrow()
}

Why Entity caching is problematic:

ProblemDescription
Lazy Loading errorsEntity retrieved from cache is outside persistence context -> LazyInitializationException
Serialization issuesHibernate Proxy object serialization can fail
Unnecessary data exposureInternal fields and associated Entities get cached/exposed
Increased cache sizeStoring entire Entity -> Memory waste
Dirty checking malfunctionModifying cached Entity may cause unintended DB updates

4.3 Correct Implementation (Using DTOs)

// Correct example: DTO caching

// 1. Define cache DTO
data class ProductCacheDto(
    val id: Long,
    val name: String,
    val price: BigDecimal,
    val status: ProductStatus,
    val stockQuantity: Int,
    val categoryId: Long,
    val categoryName: String
) {
    companion object {
        fun from(product: Product): ProductCacheDto {
            return ProductCacheDto(
                id = product.id!!,
                name = product.name,
                price = product.price,
                status = product.status,
                stockQuantity = product.stockQuantity,
                categoryId = product.category.id!!,
                categoryName = product.category.name
            )
        }
    }
}

// 2. Manual implementation
fun getProduct(id: Long): ProductCacheDto {
    val cacheKey = "product:$id"

    // 1. Check cache
    redisTemplate.opsForValue().get(cacheKey)?.let { return it }

    // 2. Cache Miss -> Query DB and convert to DTO
    val product = productRepository.findById(id)
        .orElseThrow { BusinessException(ErrorCode.PRODUCT_NOT_FOUND) }

    val dto = ProductCacheDto.from(product)

    // 3. Store DTO in cache (TTL 10 minutes)
    redisTemplate.opsForValue().set(cacheKey, dto, Duration.ofMinutes(10))

    return dto
}

// 3. Using Spring @Cacheable (recommended)
@Cacheable(value = ["products"], key = "#id")
fun getProductWithCache(id: Long): ProductCacheDto {
    val product = productRepository.findById(id)
        .orElseThrow { BusinessException(ErrorCode.PRODUCT_NOT_FOUND) }
    return ProductCacheDto.from(product)
}

// 4. Cache invalidation
@CacheEvict(value = ["products"], key = "#id")
fun updateProduct(id: Long, request: UpdateProductRequest): ProductResponse {
    val product = productRepository.findById(id)
        .orElseThrow { BusinessException(ErrorCode.PRODUCT_NOT_FOUND) }
    product.update(request.name, request.price, request.description)
    return ProductResponse.from(productRepository.save(product))
}

4.4 DTO vs Entity Caching Comparison

AspectEntity CachingDTO Caching
Lazy LoadingErrors occurNo issues
SerializationProxy problemsSafe
Cache sizeLarge (all fields)Small (only what’s needed)
API response conversionAdditional work neededReady to use
AssociationsN+1 riskPre-flattened

5. Cache Data Inconsistency Problem

Cache-Aside can cause data inconsistency.

5.1 Case 1: Write-then-Read Race Condition (Most Common)

[Request A: Modify product price]     [Request B: Query product]
         │                              │
         ├─ DB update (1000 -> 2000)    │
         │                              ├─ Cache lookup (HIT: 1000) <- Stale data!
         ├─ Delete cache                │
         │                              └─ Response: 1000
         └─ Complete

Cause: Another request reads the cache between the DB update and cache deletion

5.2 Case 2: Cache Refresh Race Condition

Two read requests arrive almost simultaneously, and a write request sneaks in between.

[Request A]                           [Request B]
   │                                  │
   ├─ Cache lookup (MISS)             ├─ Cache lookup (MISS)
   ├─ DB query (price: 1000)          ├─ DB query (price: 1000)
   │                                  │
   │  <- At this point, another request changes price to 2000 + deletes cache ->
   │                                  │
   │                                  ├─ Save to cache (1000) <- Old value saved to deleted cache!
   ├─ Save to cache (1000)            │

Result: DB has 2000 but cache has 1000 (inconsistent until TTL expires)

Detailed timeline:

Product ID: 123, Current price: 1000

[09:00:00.000] User A: Request product 123 query
[09:00:00.001] User B: Request product 123 query
[09:00:00.002] A: Cache MISS
[09:00:00.003] B: Cache MISS
[09:00:00.010] A: DB query starts
[09:00:00.011] B: DB query starts
[09:00:00.050] A: DB query complete (price: 1000)
[09:00:00.051] B: DB query complete (price: 1000)

[09:00:00.060] Admin: Change price to 2000 + delete cache

[09:00:00.070] B: Save 1000 to cache  <- Old value saved to deleted cache!
[09:00:00.071] A: Save 1000 to cache  <- Overwrite

[09:00:00.100 ~ 09:10:00.070]
    -> All users see 1000 during TTL (actual value is 2000)

5.3 Solutions

MethodDescriptionSuitable Situation
Short TTLMinimize inconsistency window (30 sec ~ 1 min)Most cases (recommended)
Write-ThroughUpdate instead of delete (@CachePut)When consistency matters
Delayed DeleteDelete again 500ms after initial deleteRace condition prevention (Cases 2, 3)
Distributed LockAcquire lock when updating cacheWhen strong consistency is needed
Version KeyInclude version like product:1:v5Complex but reliable

Delayed Double Delete implementation:

@Transactional
fun updateProduct(id: Long, request: UpdateRequest): ProductResponse {
    // 1. Delete cache first
    redisTemplate.delete("product:$id")

    // 2. Update DB
    val product = productRepository.save(...)

    // 3. Delete again after 500ms (race condition defense)
    CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS).execute {
        redisTemplate.delete("product:$id")
    }

    return ProductResponse.from(product)
}

Why is this effective?

In Case 2 scenario:

[09:00:00.060] Admin: Price change + cache delete (1st)
[09:00:00.070] B: Save 1000 to cache <- Stale value saved
[09:00:00.560] Admin: Cache delete (2nd, delayed delete) <- Stale value removed!
[09:00:00.600] Next request: Cache MISS -> DB query (2000) -> Correct!

Production recommendation: A short TTL is sufficient in most cases. If “seeing stale data briefly during TTL is not a business problem,” complex solutions are unnecessary.


6. Other Caching Patterns

6.1 Read-Through

The cache handles DB queries on behalf of the application. The application only looks at the cache.

@Bean
fun categoryCache(): LoadingCache<String, List<CategoryResponse>> {
    return Caffeine.newBuilder()
        .maximumSize(100)
        .expireAfterWrite(Duration.ofHours(1))
        .build { _ ->
            // Automatically called on Cache Miss
            categoryRepository.findAll()
                .sortedBy { it.displayOrder }
                .map { CategoryResponse.from(it) }
        }
}

6.2 Write-Through

On write, data is saved to both the cache and DB simultaneously.

// Using Spring @CachePut - updates cache along with DB save
@CachePut(value = ["products"], key = "#result.id")
fun createProduct(request: CreateProductRequest): ProductResponse {
    val product = Product.create(request)
    val saved = productRepository.save(product)
    return ProductResponse.from(saved)
}

@CachePut and transaction issues:

@CachePut saves to the cache before the transaction commits:

@Transactional + @CachePut execution order:

1. Transaction starts
2. Method executes (DB save)
3. Cache saves with method return value  <- Cache save happens here!
4. Transaction commits

Problem: Cache is saved at step 3, but what if step 4 rolls back?
     -> Data exists in cache but not in DB -- inconsistency!

Why is @CacheEvict used more often?

ApproachBehaviorOn DB Rollback
@CacheEvictDelete cache -> Cache from DB on next querySafe
@CachePutUpdate cache immediatelyInconsistency possible

6.3 Write-Behind (Write-Back)

Writes go only to the cache, and DB persistence is handled asynchronously.

@Service
class ProductViewService(
    private val redisTemplate: RedisTemplate<String, String>,
    private val productRepository: ProductRepository
) {
    // Record in Redis only on view (fast)
    fun incrementViewCount(productId: Long) {
        redisTemplate.opsForValue().increment("viewCount:$productId")
    }

    // Sync to DB every minute
    @Scheduled(fixedRate = 60_000)
    fun syncViewCountsToDB() {
        val keys = redisTemplate.keys("viewCount:*") ?: return

        keys.chunked(100).forEach { batch ->
            val updates = batch.mapNotNull { key ->
                val productId = key.substringAfter("viewCount:").toLongOrNull()
                val count = redisTemplate.opsForValue().getAndDelete(key)?.toLongOrNull() ?: 0
                productId?.let { it to count }
            }
            productRepository.bulkUpdateViewCounts(updates)
        }
    }
}

Suitable for: Data where temporary loss is acceptable, such as view counts and likes

6.4 Refresh-Ahead

Refreshes the cache before TTL expires.

@Bean
fun popularProductsCache(): LoadingCache<String, List<ProductResponse>> {
    return Caffeine.newBuilder()
        .maximumSize(10)
        .expireAfterWrite(Duration.ofMinutes(10))
        .refreshAfterWrite(Duration.ofMinutes(8))  // Background refresh after 8 minutes
        .build { _ ->
            productRepository.findByStatusOrderBySalesCountDesc(
                ProductStatus.ON_SALE,
                PageRequest.of(0, 10)
            ).map { ProductResponse.from(it) }
        }
}

7. Cache Invalidation Strategies

7.1 TTL-Based

// Automatically expires after 10 minutes
redisTemplate.opsForValue().set("key", value, Duration.ofMinutes(10))

7.2 Explicit Invalidation

// Delete single key
@CacheEvict(value = ["products"], key = "#id")
fun updateProduct(id: Long, request: UpdateRequest)

// Delete all entries
@CacheEvict(value = ["products"], allEntries = true)
fun bulkUpdateProducts()

// Invalidate multiple caches simultaneously
@Caching(evict = [
    CacheEvict(value = ["products"], key = "#id"),
    CacheEvict(value = ["popularProducts"], allEntries = true)
])
fun deleteProduct(id: Long)

allEntries=true vs key specification:

ApproachBehaviorSuitable Situation
key = "#id"Delete 1 specific keyIndividual product cache
allEntries = trueDelete all keys in the cacheList/aggregation cache

8. Cache Problems and Solutions

8.1 Cache Stampede (Thundering Herd)

Problem: Multiple requests simultaneously query the DB when cache expires

At TTL expiration

     ├── Request 1 -> Cache Miss -> DB query
     ├── Request 2 -> Cache Miss -> DB query  <- DB overload!
     ├── Request 3 -> Cache Miss -> DB query
     └── ...

Solution: Distributed Lock

fun getProductWithLock(id: Long): ProductCacheDto {
    val cacheKey = "product:$id"
    val lockKey = "lock:product:$id"

    // Check cache
    redisTemplate.opsForValue().get(cacheKey)?.let { return it }

    // Acquire distributed lock (SETNX)
    val acquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "locked", Duration.ofSeconds(5))

    if (acquired == true) {
        try {
            // Double-check
            redisTemplate.opsForValue().get(cacheKey)?.let { return it }

            // Only 1 request queries DB
            val product = productRepository.findById(id).orElseThrow()
            val dto = ProductCacheDto.from(product)
            redisTemplate.opsForValue().set(cacheKey, dto, Duration.ofMinutes(10))
            return dto
        } finally {
            redisTemplate.delete(lockKey)
        }
    } else {
        // Lock acquisition failed -> Wait briefly and retry
        Thread.sleep(50)
        return getProductWithLock(id)
    }
}

8.2 Cache Penetration

Problem: Repeated queries for non-existent data -> DB query every time

Solution: Null Caching

fun getProductSafe(id: Long): ProductCacheDto? {
    val cacheKey = "product:$id"

    // Check EMPTY marker
    if (redisTemplate.hasKey("$cacheKey:empty") == true) {
        return null
    }

    redisTemplate.opsForValue().get(cacheKey)?.let { return it }

    val product = productRepository.findById(id).orElse(null)

    if (product == null) {
        // Cache non-existent data with short TTL
        redisTemplate.opsForValue().set("$cacheKey:empty", "1", Duration.ofMinutes(1))
        return null
    }

    val dto = ProductCacheDto.from(product)
    redisTemplate.opsForValue().set(cacheKey, dto, Duration.ofMinutes(10))
    return dto
}

8.3 Cache Avalanche

Problem: Many caches expire simultaneously -> DB overload

Solution: TTL Jitter

fun cacheWithJitter(key: String, value: Any, baseTtlMinutes: Long) {
    // Add +/-20% random to base TTL
    val jitter = (baseTtlMinutes * 0.2 * Random.nextDouble()).toLong()
    val ttl = baseTtlMinutes + jitter

    redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(ttl))
}

// Example: Base 10 min -> Distributed between 8~12 min

8.4 Hot Key Problem

Problem: Requests concentrated on a specific key -> Single Redis node overload

Solution: Local Cache Combination (Multi-level)

// L1: Local cache (Caffeine) - 30 sec (fast)
// L2: Redis - 10 min (shared across servers)

private val localCache = Caffeine.newBuilder()
    .maximumSize(100)
    .expireAfterWrite(Duration.ofSeconds(30))
    .build<String, List<ProductResponse>>()

fun getPopularProducts(): List<ProductResponse> {
    val cacheKey = "popularProducts:top10"

    // L1 lookup (local)
    localCache.getIfPresent(cacheKey)?.let { return it }

    // L2 lookup (Redis)
    val products = redisTemplate.opsForValue().get(cacheKey)
        ?: fetchAndCacheToRedis()

    // Save to L1
    localCache.put(cacheKey, products)
    return products
}

9. Local Cache vs Distributed Cache

9.1 Comparison

AspectLocal Cache (Caffeine)Distributed Cache (Redis)
Speed~0.01ms~1ms
CapacityLimited by JVM heapTens of GB or more
ConsistencyInconsistent across serversConsistency guaranteed
Failure impactIndependent per serverAffects all servers

9.2 Selection Guide

Q1. Do multiple servers need the same data?
    YES -> Distributed cache (Redis)
    NO  -> Go to Q2

Q2. Does the data change frequently?
    YES -> Distributed cache
    NO  -> Local cache (Caffeine)

10. Real Project Application Examples

10.1 Category List (Cache-Aside)

Categories rarely change, so caching is highly effective.

@Service
class CategoryService(
    private val categoryJpaRepository: CategoryJpaRepository
) {
    // Retrieve from cache, query DB and cache if not found
    @Cacheable(value = ["categories"], key = "'all'")
    fun getAllCategories(): List<CategoryResponse> {
        return categoryJpaRepository.findAll()
            .sortedBy { it.displayOrder }
            .map { CategoryResponse.from(it) }  // Entity -> DTO conversion
    }

    // Invalidate entire cache when creating a category
    @Transactional
    @CacheEvict(value = ["categories"], allEntries = true)
    fun createCategory(req: CreateCategoryRequest): CategoryResponse {
        // ... creation logic
    }
}

Popular products have high computation cost (sorting) and a slight delay is acceptable.

@Service
class ProductService(
    private val productJpaRepository: ProductJpaRepository
) {
    // Cache popular products TOP 10
    @Cacheable(value = ["popularProducts"], key = "'top10'")
    fun getPopularProducts(): List<ProductResponse> {
        return productJpaRepository.findByStatusOrderBySalesCountDesc(
            ProductStatus.ON_SALE,
            PageRequest.of(0, 10)
        ).map { ProductResponse.from(it) }
    }

    // Invalidate popular products cache when updating a product
    @Transactional
    @CacheEvict(value = ["popularProducts"], allEntries = true)
    fun updateProduct(sellerId: Long, productId: Long, req: UpdateProductRequest): ProductResponse {
        // ... update logic
    }
}

10.3 Cache Configuration (CacheConfig)

@Configuration
@EnableCaching
@Profile("local")  // Caffeine for local, Redis for Docker/Prod
class CacheConfig {

    @Bean
    fun cacheManager(): CacheManager {
        return CaffeineCacheManager("popularProducts", "categories").apply {
            setCaffeine(
                Caffeine.newBuilder()
                    .expireAfterWrite(10, TimeUnit.MINUTES)  // TTL 10 minutes
                    .maximumSize(1000)
                    .recordStats()  // Hit rate monitoring
            )
        }
    }
}

11. FAQ (Frequently Asked Questions)

Q1. What should I do before introducing caching?

Measure your current bottleneck. Identify the cause using APM or slow query logs, then first evaluate whether it can be resolved with index/query optimization.

Q2. Why shouldn’t I cache Entities?

There are 5 problems:

  1. LazyInitializationException occurs
  2. Hibernate Proxy serialization issues
  3. Unnecessary data exposure
  4. Increased cache size
  5. Dirty checking malfunction

Always convert to DTO before caching.

Q3. How should I set TTL?

It depends on data characteristics:

  • Rarely changes (categories): 1 hour ~ 1 day
  • Occasionally changes (product info): 5 ~ 30 minutes
  • Frequently changes (stock): No caching or 10 ~ 30 seconds

Define the acceptable inconsistency range and set accordingly.

Q4. What happens if cache invalidation fails?

The DB has the new value while the cache has the old value. Solutions:

  • Set short TTL (last line of defense)
  • Delayed delete (once more after 500ms)
  • Logging/alerting on invalidation failure

Q5. How should I handle cache failures?

Prepare a fallback strategy:

fun getPopularProducts(): List<ProductResponse> {
    return try {
        redisTemplate.opsForValue().get("popularProducts:top10")
            ?: fetchFromDB()
    } catch (e: RedisConnectionException) {
        log.warn("Redis connection failed, falling back to DB")
        fetchFromDB()  // Query DB directly
    }
}

Q6. Should I cache real-time data like stock quantities?

No. Do not cache data that requires real-time accuracy. Handle it directly in the DB with atomic UPDATE operations.


Summary

Strategy by Data Characteristics

Data CharacteristicsRecommended StrategyTTLExample
Rarely changesRead-Through + Refresh-Ahead1 hour ~ 1 dayCategories, settings
Occasionally changesCache-Aside + Explicit invalidation5 ~ 30 minProduct info
Frequently changesNo caching-Stock, payment status
Write-heavyWrite-BehindBatchView counts, likes
High computation costCache-Aside + Long TTL5 min ~ 1 hourRankings, statistics

Caching Pattern Comparison

PatternKey PointSuitable Situation
Cache-AsideApp manages cache/DB directlyGeneral purpose, read-heavy (recommended)
Read-ThroughCache handles DB queriesConsistent cache logic
Write-ThroughSave to cache + DB simultaneouslyWhen consistency matters
Write-BehindSave to cache only, DB asyncWhen write performance matters
Refresh-AheadRefresh before TTL expiresHot Key

Solutions by Problem

ProblemSolution
Cache StampedeDistributed lock, probabilistic early refresh
Cache PenetrationNull caching
Cache AvalancheTTL Jitter
Hot KeyLocal cache combination, key replication
Data inconsistencyShort TTL, delayed delete

Quick Checklist

  • Have you measured the bottleneck before introducing caching?
  • Have you first evaluated whether index/query optimization can solve it?
  • Are you caching DTOs instead of Entities?
  • Have you set TTL appropriate for data characteristics?
  • Is the cache invalidation strategy clear?
  • Do you have a fallback strategy for cache failures?
  • Can you monitor cache hit rate?

The next part covers Event-Driven Architecture and Kafka.

Next: Part 3 - Event-Driven Architecture

This post is part of the Coupang Partners program, and a commission is earned from qualifying purchases.