Skip to content

บทที่ 11 — Caching (การแคชข้อมูล — Spring Cache + Caffeine (in-memory cache) + Redis (distributed cache))

← บทที่ 10: Reactive | สารบัญ | บทที่ 12: Messaging →

"ในวงการคอมพิวเตอร์มีเรื่องยากอยู่แค่ 2 อย่าง คือ การล้าง cache ให้ถูกจังหวะ กับ การตั้งชื่อตัวแปร" — Phil Karlton

ต้นฉบับ: "There are only two hard things in Computer Science: cache invalidation and naming things."

(คำพูดนี้เป็นมุกตลกที่จริงจัง: cache ดูเหมือนง่ายแต่ทำให้ถูกยากมาก)

🚧 โซนขั้นสูง — ข้ามได้

บทนี้เป็น บทอ้างอิงระดับลึก มือใหม่ ข้ามไปก่อนได้ — ทำให้แอปทำงานถูกต้องก่อน แล้วค่อยเพิ่ม cache เมื่อเจอปัญหา performance จริง (การใส่ cache ผิดที่ทำให้เกิดบั๊ก "ข้อมูลเก่าค้าง" ที่หายากมาก) กลับมาอ่านเมื่อแอปเริ่มช้าเพราะ query DB ซ้ำ ๆ

📋 ก่อนเริ่ม + ศัพท์ปูพื้น

  • ✅ ต้องผ่าน: Spring Boot บทที่ 1–2 (REST + JPA) กล่อง 1 — เกี่ยวกับการอ่าน
  • 🔑 cache (แคช) = ที่พักข้อมูลชั่วคราวไว้ใกล้ ๆ เพื่อหยิบใช้ครั้งหน้าได้เร็ว
  • 🔑 hit / miss = หาเจอใน cache (hit) / ไม่เจอต้องไปเอาจากต้นทาง (miss)

กล่อง 2 — เกี่ยวกับการล้าง/หมดอายุ

  • 🔑 invalidation (อิน-วา-ลิ-เด-ชัน) = การลบ/ล้างข้อมูลใน cache ที่เก่าแล้ว
  • 🔑 stale data (สเตล — "ข้อมูลค้าง") = ข้อมูลใน cache ที่ไม่ตรงกับของจริงแล้ว
  • 🔑 TTL (Time To Live) = อายุของข้อมูลใน cache ก่อนหมดอายุ

Caching คือเครื่องมือที่ ใช้ผิดบ่อยที่สุด ในการ optimize performance:

  • ใส่ cache ทุกที่ → bug ที่ตามได้ยาก (stale data)
  • ลืม invalidate → user เห็นข้อมูลผิด
  • ใส่ใน distributed system โดยไม่คิด consistency → ใช้คนละ data

บทนี้สอน caching ตั้งแต่ คอนเซ็ปต์ → in-memory → distributed → patterns ใน production

ใช้เวลา 3-4 ชั่วโมง


Part 1: Caching Mindset

1.1 Cache คืออะไร

Cache = ที่เก็บ "สำเนา" ของข้อมูลที่ดึงมาแพง — เก็บไว้ใกล้ ๆ เพื่อใช้ครั้งหน้าเร็วขึ้น

text
Without cache:
  Request → Service → DB (50ms) → Service → Response  (50ms+)

With cache (hit):
  Request → Service → Cache (1ms) → Response  (1-5ms)

With cache (miss):
  Request → Service → Cache (miss) → DB (50ms) → store in Cache → Response  (51ms)

(hit = หาเจอใน cache ตอบทันที, miss = ไม่เจอต้องไปดึงจาก DB)

1.2 ตัดสินใจใช้ cache เมื่อไหร่

✅ ใช้เมื่อ:

  • ข้อมูล อ่านบ่อย เปลี่ยนน้อย (เช่น product catalog, user profile, config)
  • ดึงข้อมูลแพง (call DB หลายตาราง, call external API)
  • ยอมรับ stale data ได้บ้าง (eventual consistency)

❌ อย่าใช้เมื่อ:

  • ข้อมูล เปลี่ยนทุกวินาที (stock price live, real-time chat)
  • ข้อมูล ต้องสด 100% (account balance, payment)
  • ข้อมูล อ่านน้อย (เปล่าประโยชน์)

1.3 ลำดับชั้น cache

text
ใกล้ user                                                                    ไกล user

 Browser    CDN         API Gateway   App (local)   L1 Cache     L2 Cache     DB
 (private)  (public)    (edge)        (mem)         (Caffeine)   (Redis)
    ↑           ↑            ↑             ↑              ↑            ↑         ↑
   ~ms        ~ms         ~10ms          ~ns            ~ns         ~ms      ~10-50ms

คำแปล: CDN (Content Delivery Network — เครือข่ายแคชที่กระจายอยู่ใกล้ user), API Gateway (ประตูหน้าระบบที่ route request), L1 Cache ("ระดับ 1" — local ใน JVM, เร็วระดับ ns = nanosecond), L2 Cache ("ระดับ 2" — ภายนอก แชร์กันได้, ระดับ ms = millisecond)

แต่ละชั้นมีหน้าที่ — ผสมกันได้

1.4 Cache Metrics ที่ต้องวัด

Metricความหมายเป้า
Hit ratio (อัตราเจอแคช)hit / (hit + miss)> 80% (ทั่วไป)
Latency (ความหน่วง)เวลา cache lookup< 1ms (L1), < 5ms (Redis)
Eviction rate (อัตราถูกไล่ออก)จำนวนที่ถูกไล่ออกต่ำ → cache ใหญ่พอ
Memory usage (ใช้ memory)RAM ที่ใช้< limit
Stale rate (อัตราเห็นข้อมูลเก่า)% เห็นข้อมูลเก่าตาม SLO

📝 SLO (Service Level Objective) = เป้าหมายระดับคุณภาพบริการที่ตกลงไว้ เช่น "stale ได้ไม่เกิน 1%" — กำหนดโดยทีมหรือตาม business requirement

ถ้า hit ratio < 50% → cache ไม่คุ้ม (เพราะมี overhead) — ลบทิ้งหรือปรับ strategy


Part 2: Cache ใน JVM (in-process)

JVM (Java Virtual Machine — เครื่องเสมือนที่รัน Java bytecode ซึ่งก็คือโปรแกรม Spring Boot ของเรา) cache แบบ in-process หมายถึงเก็บข้อมูลใน memory ของ JVM ตัวเดียวกัน

2.1 HashMap (อย่าใช้!)

java
private Map<Long, User> cache = new HashMap<>();

public User get(Long id) {
    return cache.computeIfAbsent(id, k -> loadFromDb(k));
}

ปัญหา:

  • ไม่มี size limit → memory leak (memory ที่ใช้แล้วไม่ถูกคืน โตขึ้นเรื่อย ๆ)
  • ไม่มี TTL → ข้อมูลเก่าตลอด
  • ไม่ thread-safe (หลาย thread ใช้พร้อมกันได้ไม่ปลอดภัย — ข้อมูลอาจเสีย)
  • ไม่มี eviction policy (นโยบายเลือกทิ้งข้อมูลเก่าเมื่อ cache เต็ม)

2.2 Caffeine — JVM cache ที่ดีที่สุด (2026)

Caffeine คือ in-memory cache ที่ดีที่สุดบน JVM ในปัจจุบัน — เร็ว, มี eviction policy ฉลาด (size/time-based, near-optimal hit rate) ต่างจาก ConcurrentHashMap เปล่า ๆ ที่ไม่มี eviction (โตจน OOM — OutOfMemoryError: memory เต็มจนแอปล่ม) ใช้เป็น local cache ใน app เดียว:

pom.xml:

xml
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <!-- ไม่ต้องระบุ version ถ้าใช้ spring-boot-starter-parent — Spring Boot BOM จัดการให้ -->
</dependency>

ใช้:

java
Cache<Long, User> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(10))
    .recordStats()
    .build();

User u = cache.getIfPresent(1L);          // null ถ้าไม่มี
User u2 = cache.get(1L, id -> loadFromDb(id));   // ถ้าไม่มี → load
cache.put(1L, user);
cache.invalidate(1L);

CacheStats stats = cache.stats();
System.out.println(stats.hitRate());

2.3 Eviction Policy

Caffeine ใช้ Window TinyLFU (TinyLFU = Least Frequently Used แบบย่อ — อัลกอริทึม eviction ที่แม่นยำกว่า LRU โดยใช้สถิติความถี่ของการเข้าถึง — ทำให้ cache ฉลาดขึ้น — ใหม่กว่า LRU = Least Recently Used = ไล่ตัวที่ใช้น้อยที่สุดล่าสุด) — ดีกว่า LRU ตรงที่:

  • Track frequency (ความถี่ที่ใช้) ไม่ใช่แค่ recency (ความล่าสุด)
  • ปกป้องของที่ใช้ซ้ำ ๆ จาก scan pattern (request ที่กวาดข้อมูลทีละครั้ง เช่น report ที่อ่านทุกแถว — แบบ LRU จะถูก scan ดัน "ของดี" ออกไปหมด)

ตั้ง policy:

java
// เลือกใช้ size-based หรือ weight-based อย่างใดอย่างหนึ่ง — ห้ามใช้พร้อมกัน
.maximumSize(1000)                       // size-based: จำกัดจำนวน entry

// หรือใช้ weight-based: จำกัดตาม "น้ำหนัก" แทนจำนวน (เช่น ขนาด bytes ของแต่ละ entry)
.maximumWeight(1_000_000)                // weight-based
    .weigher((k, v) -> v.bytes())

.expireAfterWrite(Duration.ofMinutes(5))   // ตั้งแต่ put
.expireAfterAccess(Duration.ofMinutes(5))  // ตั้งแต่ get/put ล่าสุด
.expireAfter(new Expiry<K,V>() {           // custom per-entry
    public long expireAfterCreate(...) { ... }
    public long expireAfterUpdate(...) { ... }
    public long expireAfterRead(...) { ... }
})

.refreshAfterWrite(Duration.ofMinutes(1))  // refresh async หลัง 1 min

2.4 AsyncCache (สำหรับ async load)

CompletableFuture (ภาชนะรับผลลัพธ์ที่จะมาในอนาคต — task ที่รันแบบ async แล้วค่อยเอาผลมาทีหลัง ดูรายละเอียดใน Java บทที่ 12) ใช้กับ AsyncCache เพื่อโหลด data แบบ non-blocking:

java
AsyncLoadingCache<Long, User> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(10))
    .buildAsync(id -> CompletableFuture.supplyAsync(() -> loadFromDb(id)));

CompletableFuture<User> future = cache.get(1L);

cache stampede ("แย่งกันโหลด") คือปัญหาที่เกิดเมื่อ cache miss แล้วมี request จำนวนมากพร้อมกัน เช่น 100 ตัว — ถ้าไม่ป้องกัน ทุก request จะไปยิง DB พร้อมกัน 100 ครั้ง

AsyncCache ป้องกันปัญหานี้ได้: เมื่อ caller (ผู้เรียก) หลายตัวขอ key เดียวกันพร้อมกัน จะมี loader (ตัวโหลด) แค่ 1 ตัวจริง ๆ ที่วิ่งไปโหลดจาก DB ส่วนที่เหลือรอผลลัพธ์ตัวเดียวกัน ไม่ต้องยิง DB ซ้ำ


Part 3: Spring Cache Abstraction

Spring ให้ annotation @Cacheable, @CacheEvict, ฯลฯ → ใช้ได้กับ provider หลายตัว (Caffeine, Redis, Hazelcast)

3.1 Setup

จุดเด่นของ Spring Cache คือ abstraction — เขียน @Cacheable ครั้งเดียวแล้วสลับ provider (Caffeine/Redis/Hazelcast) ได้โดยไม่แก้โค้ด เริ่มจากเพิ่ม starter-cache แล้วเปิดด้วย @EnableCaching:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
java
@SpringBootApplication
@EnableCaching
public class App { }

application.yml:

yaml
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m,recordStats

3.2 @Cacheable — อ่านผ่าน cache

java
@Service
public class UserService {

    private final UserRepository userRepo;

    public UserService(UserRepository userRepo) {
        this.userRepo = userRepo;
    }

    @Cacheable(value = "users", key = "#id")
    public User findById(Long id) {
        System.out.println("DB hit for " + id);
        return userRepo.findById(id).orElseThrow();
    }
}

ครั้งแรก:

text
findById(1) → "DB hit for 1" → return User

ครั้งที่ 2 (key เดียวกัน):

text
findById(1) → return User (จาก cache, ไม่มี "DB hit")

ครั้งที่ 3 ด้วย id ต่าง:

text
findById(2) → "DB hit for 2" → cache

3.3 @CacheEvict — ลบ cache

cache ที่ไม่ลบเมื่อข้อมูลเปลี่ยน = แสดงข้อมูลเก่าผิด ๆ — @CacheEvict ลบ entry เมื่อมีการ update/delete (ระบุ key เดียวหรือ allEntries = true ล้างทั้งหมด) ใช้คู่กับ @Cacheable เพื่อให้ cache ไม่ stale:

java
@CacheEvict(value = "users", key = "#user.id")
public User update(User user) {
    return userRepo.save(user);
}

@CacheEvict(value = "users", allEntries = true)
public void deleteAll() {
    userRepo.deleteAll();
}

3.4 @CachePut — อัพเดต cache (ไม่ skip)

java
@CachePut(value = "users", key = "#user.id")
public User update(User user) {
    return userRepo.save(user);
}

ต่างจาก @Cacheable: method run เสมอ, แล้วเอา return ใส่ cache

ใช้ตอน update — อยาก refresh cache ทันทีไม่รอ TTL

3.5 @Caching — รวมหลาย annotation

บางการกระทำต้องจัดการหลาย cache พร้อมกัน (เช่น update user ต้อง evict ทั้ง cache ของ user และ cache ของ list) — @Caching รวมหลาย annotation (evict/put/cacheable) ไว้บน method เดียว:

java
@Caching(
    evict = {
        @CacheEvict(value = "users", key = "#user.id"),
        @CacheEvict(value = "user-list", allEntries = true)
    }
)
public User update(User user) { ... }

3.6 SpEL ใน key

SpEL (Spring Expression Language) = ภาษา expression เล็ก ๆ ของ Spring ใช้เขียนสูตรใน annotation — #paramName อ้างถึง parameter ของ method, #root.methodName คือชื่อ method, T(...) เรียก static method ของ class ใด ๆ

cache key default มาจาก parameter แต่ปรับแต่งได้ด้วย SpEL — กำหนด key จาก field ใด field หนึ่ง, composite key, หรือใส่ condition/unless ควบคุมว่าจะ cache เมื่อไหร่ (เช่นไม่ cache ผลที่เป็น null) ทำให้ caching ฉลาดและตรงตามต้องการ:

java
@Cacheable(value = "users", key = "#user.email")          // จาก parameter
@Cacheable(value = "users", key = "#root.methodName")     // method name
@Cacheable(value = "users", key = "T(java.util.Objects).hash(#name, #age)")  // composite
@Cacheable(value = "users", key = "#id", condition = "#id > 0")
@Cacheable(value = "users", key = "#id", unless = "#result == null")

condition = เช็ค ก่อน เริ่ม method — ถ้า condition เป็น false → ข้าม cache เลย (เช่น condition = "#user != null") unless = เช็ค หลัง method return — ถ้า unless เป็น true → ไม่เก็บลง cache (เช่น unless = "#result == null" = ไม่ cache ถ้าผลเป็น null)

⚠️ Null key = ข้อมูลคนละ user หล่นใส่กัน — ถ้า SpEL key มี component เป็น null (เช่น key = "#user.email" แต่ user หรือ user.email เป็น null) → key กลายเป็น null ทั้งหมด ทุก call ที่เจอเคสนี้จะเขียน/อ่าน entry เดียวกัน = data leak ข้าม user เงียบ ๆ ทางป้องกัน: ใส่ condition = "#user != null and #user.email != null" หรือใช้ SpEL ที่ fallback เป็นค่า default เช่น key = "#user?.email ?: 'anonymous'"

3.7 ⚠️ Pitfall: Spring Cache + Internal call

📖 Proxy คืออะไร? proxy = object ห่อหุ้มที่ Spring สร้างครอบ bean ของเรา Spring ใช้ proxy นี้เพื่อแทรก behavior เพิ่มเติม (เช่น caching, transaction) เข้าไปโดยไม่ต้องแก้โค้ดเรา เมื่อโค้ดภายนอก class เรียก method เข้ามา request จะผ่าน proxy ก่อนเสมอ — proxy จึงสามารถ intercept (ดักจับ) และเพิ่มพฤติกรรม เช่น ตรวจ cache ก่อนเรียก method จริง แต่ถ้าเรียก method จากภายใน class เดียวกัน (this.findById()) จะวิ่งเข้า object จริงโดยตรง ข้าม proxy ไป ทำให้ @Cacheable ไม่ทำงาน

java
@Service
public class UserService {
    @Cacheable("users")
    public User findById(Long id) { ... }

    public User getAndUpdate(Long id) {
        User u = findById(id);                  // ❌ ไม่ผ่าน cache!
        // ...
    }
}

เพราะ Spring Cache ใช้ proxy (ตัวห่อ/wrapper ที่ Spring สร้างครอบ bean ของเรา — โค้ดของ @Cacheable อยู่ในตัวห่อนี้) การเรียกจากภายนอก class จะวิ่งผ่านตัวห่อก่อน แต่ this.findById() เรียกตรงเข้าตัวจริง ข้าม proxy ไป — internal call ไม่ผ่าน proxy

🔑 ทำไมถึงเป็นแบบนี้ — สาเหตุคือ Spring AOP (Aspect-Oriented Programming — เทคนิคแทรก behavior เข้าไปในโค้ดโดยไม่แก้โค้ดต้นฉบับ) ใช้ dynamic proxy ครอบ bean จากภายนอกเท่านั้น ไม่ใช่การฝัง behavior เข้าไปตอน compile (compile-time weaving) ดังนั้น self-invocation (เรียกตัวเองจากภายใน) จึงข้าม proxy เสมอ — และนี่ไม่ใช่ปัญหาเฉพาะ @Cacheable แต่เกิดกับ @Transactional, @Async ด้วยเหตุผลเดียวกัน

แก้:

  • แยก method ไป class อื่น
  • Inject self: @Autowired UserService self; แล้วเรียก self.findById(id)
  • ใช้ AopContext.currentProxy() — ดึง proxy object ของ Spring ที่ wrap method นี้ไว้ (ต้องตั้ง exposeProxy = true ใน @EnableCaching ก่อน)

Part 4: Redis as Distributed Cache

4.1 ทำไมต้อง Redis (vs Caffeine)

Caffeine (in-process)Redis (distributed)
Latency~ns~0.5-5ms
Shared ระหว่าง pod❌ ต่างคนต่าง cache✅ shared
Restart podหายยังอยู่
Eviction sync
Memoryของแต่ละ podserver กลาง

→ ใน K8s (Kubernetes — ระบบรันแอปหลายสำเนาพร้อมกัน) ที่มีหลาย pod (pod = แอป 1 สำเนา/instance) = ต้องใช้ Redis (หรือ distributed cache อื่น) ถ้าต้องการ consistency เพราะ cache ใน memory ของแต่ละ pod จะไม่เห็นกัน

4.2 Setup

เริ่มใช้ Redis เป็น distributed cache — เพิ่ม starter-data-redis แล้วตั้ง host/port + connection pool Spring Boot ใช้ Lettuce driver (รองรับ reactive) เป็น default จุดที่ควรตั้งคือ timeout และขนาด pool ให้เหมาะกับ load:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
yaml
spring:
  data:
    redis:
      host: localhost
      port: 6379
      password: ${REDIS_PASSWORD:}
      timeout: 2s
      lettuce:
        pool:
          enabled: true
          max-active: 16
          max-idle: 8
          min-idle: 2

Spring Boot ใช้ Lettuce driver (default, reactive support). ทางเลือก Jedis (เก่ากว่า)

4.3 Spring Cache + Redis

yaml
spring:
  cache:
    type: redis
    redis:
      time-to-live: 600000      # milliseconds — 600000ms = 10 minutes
      cache-null-values: false
      key-prefix: "myapp::"
      use-key-prefix: true
java
@Cacheable(value = "users", key = "#id")
public User findById(Long id) { ... }

Spring จะใส่ใน Redis เป็น myapp::users::1 (default serializer: JdkSerialization — เปลี่ยนเป็น JSON ได้)

4.4 ตั้ง CacheManager เอง

config ผ่าน yaml จำกัด — ถ้าอยากตั้ง TTL ต่างกันต่อ cache หรือเปลี่ยน serializer (เช่นเป็น JSON ที่อ่านได้แทน JDK binary) ต้องสร้าง RedisCacheManager bean เอง ที่นี่กำหนด default + override รายตัว (users 30 นาที, products 1 ชม.):

java
@Configuration
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory cf) {
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues()
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        Map<String, RedisCacheConfiguration> perCache = Map.of(
            "users", defaultConfig.entryTtl(Duration.ofMinutes(30)),
            "products", defaultConfig.entryTtl(Duration.ofHours(1))
        );

        return RedisCacheManager.builder(cf)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(perCache)
            .build();
    }
}

⚠️ GenericJackson2JsonRedisSerializer caveat — serializer ตัวนี้จะ embed Java class name (ฟิลด์ @class) ลงใน JSON ที่เก็บใน Redis

  • ผลข้างเคียง: refactor (ย้าย package, rename class) → entry เก่าใน Redis อ่านไม่ออก เพราะ class FQN (Fully Qualified Name — ชื่อ class เต็มรวม package เช่น com.example.User) ไม่ตรง — flush cache หลัง deploy
  • ความเสี่ยง security: อย่าเปิด Jackson default typing บน deserializer ที่รับข้อมูลจาก untrusted source — เป็นช่องโหว่ RCE คลาสสิค (Remote Code Execution — ผู้โจมตีรันโค้ดบน server ได้) (cache ภายในที่ controlled = OK, แต่ห้ามใช้ serializer นี้ deserialize HTTP request/response)
  • ทางเลือก: ใช้ Jackson2JsonRedisSerializer<TargetType> แยกต่อ cache (มี type ชัด ไม่ต้องพึ่ง @class)

4.5 RedisTemplate — ใช้ Redis ตรง ๆ

@Cacheable ครอบ caching แบบ method-level แต่บางทีต้องใช้ Redis ตรง ๆ (เก็บ structure อื่น เช่น hash/list/set, ทำ rate limit, distributed lock) — RedisTemplate ให้เข้าถึง data structure ของ Redis ครบทุกแบบ:

java
// Spring Boot auto-config มีแค่ RedisTemplate<Object,Object> และ StringRedisTemplate
// ถ้าต้องการ RedisTemplate<String, Object> ต้องสร้าง @Bean เอง เช่น:
// @Bean
// public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf) {
//     RedisTemplate<String, Object> t = new RedisTemplate<>();
//     t.setConnectionFactory(cf);
//     t.setKeySerializer(new StringRedisSerializer());
//     t.setValueSerializer(new GenericJackson2JsonRedisSerializer());
//     return t;
// }

// ใน production ควรใช้ constructor injection แทน @Autowired field injection
private final RedisTemplate<String, Object> redisTemplate;

public MyService(RedisTemplate<String, Object> redisTemplate) {
    this.redisTemplate = redisTemplate;
}

// string
redisTemplate.opsForValue().set("user:1", user, Duration.ofMinutes(10));
// ⚠️ get() คืน Object — ถ้าใช้ GenericJackson2JsonRedisSerializer ค่าที่ได้จะเป็น LinkedHashMap ไม่ใช่ User
// การ cast (User) ตรง ๆ จะ throw ClassCastException
// ทางแก้ที่ถูกต้อง: ใช้ Jackson ObjectMapper แปลงจาก LinkedHashMap หรือใช้ RedisTemplate<String, User> แบบ typed:
//   new Jackson2JsonRedisSerializer<>(User.class) เพื่อให้ deserialize เป็น User ได้ตรง ๆ
Object rawValue = redisTemplate.opsForValue().get("user:1");
// User u = objectMapper.convertValue(rawValue, User.class);  // แปลงจาก LinkedHashMap → User

// hash
redisTemplate.opsForHash().put("user:1", "name", "Alice");

// list
redisTemplate.opsForList().leftPush("messages", "hello");

// set
redisTemplate.opsForSet().add("tags", "java", "spring");

// sorted set (scoreboard)
redisTemplate.opsForZSet().add("leaderboard", "alice", 100);
redisTemplate.opsForZSet().reverseRangeWithScores("leaderboard", 0, 9);

// HyperLogLog — โครงสร้างข้อมูลนับจำนวน unique element แบบประมาณ ใช้ memory น้อยมาก
redisTemplate.opsForHyperLogLog().add("uniq-visitors", "user1");
Long count = redisTemplate.opsForHyperLogLog().size("uniq-visitors");

// pub/sub (publish/subscribe) — ส่งข้อความเข้า channel แล้วทุกคนที่ subscribe channel นั้นได้รับพร้อมกัน
redisTemplate.convertAndSend("channel-1", "hello");

⚠️ ClassCastException ซ่อนอยู่ใน redisTemplate.opsForValue().get() — เมื่อใช้ GenericJackson2JsonRedisSerializer กับ RedisTemplate<String, Object> ผลที่ได้จาก .get() คือ LinkedHashMap ไม่ใช่ User — การ cast ตรงๆ (User) rawValue จะ throw ClassCastException ที่ runtime ทางแก้: ใช้ objectMapper.convertValue(rawValue, User.class) เพื่อแปลงจาก LinkedHashMap → User หรือใช้ RedisTemplate<String, User> ที่ตั้ง Jackson2JsonRedisSerializer<>(User.class) เพื่อให้ deserialize เป็น User ได้ตรงๆ


Part 5: Cache Patterns

5.1 Cache-Aside (Lazy Loading) — ที่ Spring Cache ทำ

text
Read:
  1. Cache.get(key) → hit? return
  2. miss → load DB → put cache → return

Write:
  1. Update DB
  2. Cache.invalidate(key)  หรือ  Cache.put(key, new value)

ข้อดี: simple, cache แค่ของที่ใช้จริง ข้อเสีย: first read แต่ละ key ช้า (cold cache)

5.2 Read-Through

Cache library โหลด DB เอง:

text
App → Cache.get(key)
        └─ miss → load DB → return

App เห็น API เดียว — ไม่ต้องเขียน fallback

Caffeine LoadingCache:

java
LoadingCache<Long, User> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .build(id -> loadFromDb(id));      // loader

User u = cache.get(1L);

5.3 Write-Through

Update DB + Cache ใน operation เดียว:

text
Write → Update Cache → Update DB → return

ข้อดี: cache ตรงกับ DB เสมอ ข้อเสีย: write ช้ากว่า

5.4 Write-Behind (Write-Back)

Update Cache ทันที, DB write async ทีหลัง:

text
Write → Update Cache → return
                       └─ async → Update DB

ข้อดี: write เร็วมาก ข้อเสีย: ถ้า crash ก่อน flush = data หาย (อันตราย)

5.5 Refresh-Ahead

Cache refresh เอง ก่อน TTL หมด — user ไม่เคยเจอ miss:

java
Caffeine.newBuilder()
    .refreshAfterWrite(Duration.ofMinutes(1))
    .build(id -> loadFromDb(id));

หลัง 1 นาที — key ที่ถูกอ่านจะ trigger refresh ใน background

ดีกับ data ที่ "อยากให้สดแต่ user ไม่ต้องรอ"


Part 6: Cache Problems & Solutions

6.1 Cache Stampede (Thundering Herd)

🔤 Stampede (สแตม-พีด) = "ฝูงสัตว์แตกตื่นวิ่งพรวดพร้อมกัน" · Thundering Herd = "ฝูงควายแห่" — ภาพคือ request จำนวนมากแห่ตี DB พร้อมกัน

ปัญหา: key หมดอายุ → ทุก request ตี DB พร้อมกัน → DB ล่ม

text
Time:    [---- cache valid ----][cache miss][---- cache valid ----]
                                   ↑↑↑↑↑↑
                                  1000 req แห่กัน

แก้:

  1. Single-flight / mutex — ให้ request แรกที่ miss load, ที่เหลือรอ

    java
    LoadingCache<Long, User> cache = Caffeine.newBuilder()
        .build(id -> loadFromDb(id));      // Caffeine ทำ single-flight ให้

    ⚠️ Caffeine single-flight = per-JVM เท่านั้น — ถ้าระบบ run 50 pods บน K8s แต่ละ pod ก็ stampede ได้พร้อมกัน → 50 hits ตี DB หนัก

    ทางแก้ระดับ distributed: ใช้ Redisson (Redis client ที่มี distributed lock สำเร็จรูป) ครอบ load เพิ่ม dependency:

    xml
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson-spring-boot-starter</artifactId>
        <version>3.27.0</version>
    </dependency>
    java
    @Service
    public class UserCacheService {
        private final RedissonClient redisson;
        private final RedisTemplate<String, Object> redisTemplate;
        private final UserRepository userRepo;
    
        public UserCacheService(RedissonClient redisson,
                                 RedisTemplate<String, Object> redisTemplate,
                                 UserRepository userRepo) {
            this.redisson = redisson;
            this.redisTemplate = redisTemplate;
            this.userRepo = userRepo;
        }
    
        public User loadWithLock(Long id) {
            RLock lock = redisson.getLock("load-user-" + id);
            try {
                if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {     // wait 10s, hold 30s
                    // double-check cache หลังได้ lock
                    User cached = (User) redisTemplate.opsForValue().get("user:" + id);
                    if (cached != null) return cached;
                    User u = userRepo.findById(id).orElse(null);
                    redisTemplate.opsForValue().set("user:" + id, u, Duration.ofMinutes(10));
                    return u;
                }
            } finally { if (lock.isHeldByCurrentThread()) lock.unlock(); }
            return (User) redisTemplate.opsForValue().get("user:" + id);   // pod อื่นโหลดเสร็จแล้ว
        }
    }
  2. Refresh-ahead (Part 5.5)

  3. Jitter TTL — ตั้ง TTL random ±20% ป้องกัน expire พร้อมกัน

  4. Stale-while-revalidate — return stale + refresh background

6.2 Cache Penetration

🔤 Penetration (เพเน-เทร-ชัน) = "การเจาะทะลุ" — request เจาะทะลุ cache (เพราะหาไม่เจอ) ไปตี DB ทุกครั้ง

ปัญหา: query key ที่ไม่มีใน DB เลย → ทุกครั้ง miss + ตี DB

ตัวอย่าง: attacker query userId=99999999 หลาย ๆ id

แก้:

  1. Sentinel value + TTL สั้นกว่าปกติ — เก็บ marker ว่า "key นี้ไม่มีข้อมูลใน DB" แต่ TTL สั้น (1-5 นาที vs 30 นาทีของค่าจริง) เพื่อให้ cache ตอบทันทีว่า "ไม่มี" โดยไม่ต้องไป DB ทุกครั้ง ไม่งั้น attacker เจาะทะลุ memory ได้

    วิธีที่คนมักทำแบบผิด: ใส่ string พิเศษ "__NULL__" แทน value — แต่มีกับดัก:

    ⚠️ กับดักของ "__NULL__" string marker — ถ้า cache value type เป็น User (มี serializer แบบ JSON ที่ฝัง @class) แต่เราจู่ ๆ ใส่ String "__NULL__" ลงไป → serializer จะเขียนเป็น JSON "\"__NULL__\"" (มี quote ครอบ) ตอนอ่านกลับมาจะเป็น String ไม่ใช่ User — ถ้า cast เป็น User ก็ ClassCastException; ถ้าเช็ค .equals("__NULL__") ก็อาจไม่ตรงตามที่คาด เพราะ deserializer ของ User อาจ throw ก่อนถึง equals (เพราะ deserializer พยายามแปลง JSON string เป็น User object ก่อนเสมอ ถ้าแปลงไม่ได้ก็ throw exception ทันทีโดยไม่มีโอกาสไปถึงบรรทัด .equals() เลย)

    ทางแก้ที่ปลอดภัยกว่า — ใช้ User sentinel ที่มีฟิลด์ marker ชัดเจน (type ตรงกับ cache) หรือใช้ Optional<User> cache เป็น Optional.empty():

    java
    // ⚠️ ตัวอย่าง User ในไฟล์นี้เป็นแค่ตัวอย่างประกอบ ไม่ใช่ schema ตายตัว — ปรับ field ให้ตรงกับ entity จริงของคุณ
    // ทางเลือก A — typed sentinel: User ที่มี id = -1 หมายถึง "ไม่มีจริง"
    private static final User NEGATIVE = new User(-1L, "__not_found__", null);
    
    public User findById(Long id) {
        User v = (User) redis.opsForValue().get("user:" + id);
        if (v != null && v.id() == -1L) return null;                // negative cache hit (typed)
        if (v != null) return v;
        User u = userRepo.findById(id).orElse(null);
        if (u == null) {
            redis.opsForValue().set("user:" + id, NEGATIVE,
                Duration.ofMinutes(2));                              // ⬅ TTL สั้นกว่า positive
        } else {
            redis.opsForValue().set("user:" + id, u, Duration.ofMinutes(30));
        }
        return u;
    }
    java
    // ทางเลือก B — Spring's @Cacheable + cache-null-values
    // เปิด spring.cache.redis.cache-null-values: true (ไม่ disableCachingNullValues())
    // Spring จะใช้ NullValue marker class ของตัวเอง ที่ serializer handle ให้
    @Cacheable(value = "users", key = "#id")
    public Optional<User> findById(Long id) { ... }

    อย่าใช้ "__NULL__" string comparison กับ RedisTemplate<String, Object> ที่ใช้ JSON serializer — เพราะ value type ที่ออกมาขึ้นกับ serializer และอาจไม่ใช่ raw string อย่างที่คาด

  2. Cap จำนวน null keys — เก็บไม่เกิน N keys ใน negative cache (Caffeine maximumSize) ป้องกัน memory exhaustion จาก unique attack

  3. Bloom filter — ตรวจก่อนว่า key อาจมี (false positive ได้ แต่ไม่ false negative) — เหมาะกับ static data set ที่ใหญ่

6.3 Cache Avalanche

🔤 Avalanche (อาวา-ลานช์) = "หิมะถล่ม" — cache จำนวนมากหมดอายุพร้อมกันทีเดียว เหมือนหิมะถล่มลงมาทับ DB

ปัญหา: cache ทั้งหมดหมดอายุพร้อมกัน → DB โดน burst

แก้:

  1. Jitter TTL — random ±20%
  2. Pre-warm cache ตอน startup
  3. Multi-tier — เมื่อ Redis miss → Caffeine ยังมี (อย่างน้อยซับ load)

6.4 Stale Data

ปัญหา: update DB แล้ว — cache ยังมีของเก่า

แก้:

  • @CacheEvict ทันทีหลัง update
  • Distributed invalidation — pub/sub ระหว่าง pod (Redis pub/sub, Kafka)
  • TTL สั้น (compromise — ยอม stale บ้าง)
  • Versioninguser:1:v3 แทน user:1 → ใส่ version ใหม่ทุก update

Part 7: Multi-Level Cache

Pattern: L1 (in-memory) + L2 (Redis) เพื่อ:

  • ลด Redis latency
  • ลด load บน Redis
  • Resilience — Redis ล่ม → L1 ยังใช้ได้บางส่วน
text
Read flow:
  L1 (Caffeine) → hit? return
       miss →
  L2 (Redis) → hit? populate L1 → return
       miss →
  DB → populate L1, L2 → return

ใน Spring ใช้ CompositeCacheManager:

⚠️ CompositeCacheManager ไม่ใช่ multi-level read-through จริง — มันแค่ iterate ผ่าน CacheManager แต่ละตัวและ return ค่าแรกที่ไม่ใช่ null ไม่มีการ populate L1 เมื่อ L2 hit และไม่ได้ write ทั้ง L1+L2 พร้อมกัน ถ้าต้องการ L1→L2 fallback พร้อม populate จริงต้องเขียน custom CacheManager เอง หรือใช้ library เช่น JetCache หรือ Redisson near-cache

java
@Bean
public CacheManager cacheManager(CaffeineCacheManager caffeine, 
                                  RedisCacheManager redis) {
    CompositeCacheManager composite = new CompositeCacheManager(caffeine, redis);
    composite.setFallbackToNoOpCache(false);
    return composite;
}

การทำงานจริงของ CompositeCacheManager: เมื่อ @Cacheable ถาม cache ชื่อ "users" → Spring จะถาม CaffeineCacheManager ก่อน ถ้ามีก็ใช้ ถ้าไม่มีก็ถาม RedisCacheManager — ไม่มีการคัดลอกค่าจาก L2 กลับไปใส่ L1 อัตโนมัติ

หรือเขียน custom — fetch L1, miss → L2, miss → DB, populate ทั้ง L1 และ L2

Invalidation challenge: update node A → invalidate L1 ของ A + Redis. แต่ node B ยังมี L1 เก่า!

แก้:

  • Redis pub/sub broadcast invalidation
  • TTL L1 สั้น (10-30s)

Part 8: Distributed Cache Sync

8.1 Redis Pub/Sub

java
@Component
public class CacheInvalidator {

    private final RedisTemplate<String, String> redis;
    private final CacheManager localCacheManager;

    public CacheInvalidator(RedisTemplate<String, String> redis,
                             CacheManager localCacheManager) {
        this.redis = redis;
        this.localCacheManager = localCacheManager;
    }

    public void invalidate(String key) {
        // ลบ local
        localCacheManager.getCache("users").evict(key);
        // broadcast
        redis.convertAndSend("cache:invalidate", "users:" + key);
    }
}

@Component
public class CacheInvalidationListener implements MessageListener {

    private final CacheManager cacheManager;

    public CacheInvalidationListener(CacheManager cacheManager) {
        this.cacheManager = cacheManager;
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String[] parts = new String(message.getBody()).split(":");
        cacheManager.getCache(parts[0]).evict(parts[1]);
    }
}

// ⚠️ ต้อง register listener กับ RedisMessageListenerContainer ก่อน จึงจะรับ message ได้
@Bean
public RedisMessageListenerContainer messageListenerContainer(
        RedisConnectionFactory factory,
        CacheInvalidationListener listener) {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(factory);
    container.addMessageListener(listener, new ChannelTopic("cache:invalidate"));
    return container;
}

8.1.1 Redis 6.0+ Client-Side Caching (Tracking) — ทางใหม่ที่เร็วและปลอดภัย

Redis 6.0+ มี Client-Side Caching ที่ทำให้ Redis เป็น broadcaster เอง — client บอก Redis ว่า "ฉันอ่าน key ไหนบ้าง" แล้ว Redis จะส่ง invalidation message ให้ client นั้นโดยตรงเมื่อ key เปลี่ยน

RESP3 (Redis Serialization Protocol v3 — โปรโตคอลการสื่อสารของ Redis เวอร์ชัน 3 ที่รองรับ server-push notification (การที่ server ส่งข้อมูลมาหา client เองโดยไม่ต้องรอถาม)) เป็นพื้นฐานของ feature นี้

⚠️ Spring Cache abstraction ยังไม่รองรับ Client-Side Caching โดยตรง — ต้องใช้ Lettuce API ระดับ driver เพื่อเปิด tracking โค้ดด้านล่างเป็นแค่ตัวอย่าง RedisCacheManager ปกติ ยังไม่ได้เปิด client-side tracking จริง ถ้าต้องการ tracking จริงต้องตั้งค่าผ่าน LettuceClientConfiguration พร้อม RESP3 และ enableTracking() ดู Lettuce docs สำหรับรายละเอียด

java
// ตัวอย่าง RedisCacheManager ทั่วไป (ไม่มี client-side tracking)
@Bean
public RedisCacheManager cacheManager(LettuceConnectionFactory factory) {
    return RedisCacheManager.builder(factory)
        .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())))
        .build();
}

ข้อดี vs pub/sub (เมื่อ setup ครบ):

  • ไม่ต้อง code subscribe เอง
  • Redis track ว่า client ไหนเคย read key ไหน → ส่ง invalidation เฉพาะ client ที่จำเป็น (ไม่ broadcast ทุก client)
  • Built-in to protocol (RESP3) ไม่ต้องเขียน listener

8.1.2 เลือก Pub/Sub vs Streams vs Client-Side

Redis Pub/SubRedis StreamsClient-Side Tracking
Deliveryat-most-once (ส่งได้อย่างมากครั้งเดียว — fire-and-forget ส่งแล้วลืม)at-least-once (ส่งอย่างน้อยครั้งเดียว — มี consumer group ยืนยัน)at-most-once (ส่งได้อย่างมากครั้งเดียว)
ถ้า client offlineevent หายrewind ได้ (อ่านย้อนหลังจาก offset เก่า)invalidate ตอน reconnect
Use casebest-effort invalidation (ล้าง cache แบบ "พยายามส่ง")reliable event bus (ระบบรับส่ง event ที่เชื่อถือได้)local cache sync (ซิงค์ cache ใน process)
Complexityง่ายปานกลางง่ายมาก (built-in)

กฎ:

  • Single Redis + local L1 → Client-Side Tracking
  • หลาย shard / event bus กึ่ง messaging → Streams
  • Simple, fire-and-forget → Pub/Sub

ถ้าต้อง reliable invalidation จริงๆ (เช่น financial data) ใช้ Kafka (บท 12) — แต่ละ pod consume + apply invalidation, มี offset tracking

8.2 Hazelcast / Apache Ignite

นอกจาก Redis ยังมีทางเลือกแบบ distributed in-memory data grid (Hazelcast, Apache Ignite) — จุดต่างคือมัน sync ข้อมูลระหว่าง node ในตัว (embedded ใน app ได้ ไม่ต้องมี server แยกเหมือน Redis) เหมาะกับงานที่ต้องการ near cache + auto-clustering:

Distributed in-memory grid — auto sync ระหว่าง node

xml
<dependency>
    <groupId>com.hazelcast</groupId>
    <artifactId>hazelcast-spring</artifactId>
</dependency>

Spring Cache integration:

yaml
spring.cache.type: hazelcast

Part 9: Production Best Practices

9.1 Monitoring

Prometheus (เครื่องมือเก็บ metric ของระบบ — ดึงข้อมูลจาก app แล้วเก็บเป็น time-series) และ Grafana (เครื่องมือสร้าง dashboard กราฟจาก Prometheus metric) เป็นคู่ที่ใช้งานร่วมกันบ่อย — ถ้ายังไม่มี Prometheus ข้ามส่วน dashboard/alert ได้ก่อน แล้วกลับมาอ่านเมื่อ setup monitoring จริง

ทุก cache ต้อง export metric ไป Prometheus:

yaml
# cache ที่ใช้ผ่าน Spring Cache จะถูก export metric อัตโนมัติเมื่อเปิด actuator + micrometer registry
# ระบุชื่อ cache ที่ต้องการ pre-bind metric (ก่อนที่ cache จะถูกใช้งานครั้งแรก)
spring:
  cache:
    cache-names:
      - users
      - products

Metric ที่ดู:

  • cache.gets{result="hit"} — hit count
  • cache.gets{result="miss"} — miss count
  • cache.puts — write count
  • cache.evictions — eviction count
  • cache.size — size now

Grafana panel:

หมายเหตุ: Micrometer ตั้งชื่อ metric ด้วยจุด (เช่น cache.gets) แต่พอ export เป็น Prometheus จะถูกแปลงเป็นขีดล่าง (cache_gets) และเติม _total ให้ counter อัตโนมัติ — ทั้งสองชื่อหมายถึง metric เดียวกัน

  • Hit ratio: rate(cache_gets_total{result="hit"}[5m]) / rate(cache_gets_total[5m])
  • Alert: hit ratio < 60% สำหรับ cache ที่ควรสูง

Grafana dashboard JSON (เริ่มต้นได้เร็ว)

json
{
  "title": "Spring Cache Overview",
  "panels": [
    {
      "title": "Hit Ratio (per cache)",
      "type": "timeseries",
      "targets": [{
        "expr": "sum by (cache) (rate(cache_gets_total{result=\"hit\"}[5m])) / sum by (cache) (rate(cache_gets_total[5m]))",
        "legendFormat": "{{cache}}"
      }],
      "thresholds": { "steps": [{"value": 0.6, "color": "red"}, {"value": 0.8, "color": "green"}] }
    },
    {
      "title": "Cache Size",
      "type": "stat",
      "targets": [{ "expr": "cache_size{cache=~\".+\"}" }]
    },
    {
      "title": "Eviction Rate",
      "type": "timeseries",
      "targets": [{ "expr": "rate(cache_evictions_total[5m])" }]
    },
    {
      "title": "Get Latency p95",
      "type": "timeseries",
      "targets": [{
        "expr": "histogram_quantile(0.95, rate(cache_gets_seconds_bucket[5m]))"
      }]
    }
  ]
}

Caffeine stat recorder ต้องเปิด:

java
Caffeine.newBuilder().recordStats().build();    // ⭐ ไม่งั้น metric เป็น 0

Spring Boot auto-config Caffeine มี option:

yaml
spring.cache.caffeine.spec: maximumSize=1000,expireAfterWrite=10m,recordStats

Prometheus alert rules

yaml
groups:
  - name: cache
    rules:
      - alert: CacheHitRatioLow
        expr: |
          sum by (cache) (rate(cache_gets_total{result="hit"}[10m])) /
          sum by (cache) (rate(cache_gets_total[10m])) < 0.6
        for: 15m
        annotations:
          summary: "Cache {{ $labels.cache }} hit ratio < 60%"
      - alert: RedisMemoryHigh
        expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.8
        for: 5m

9.2 Naming Convention

ตั้งชื่อ cache key เป็นระบบช่วยจัดการง่ายและ debug ได้ — ใช้รูปแบบลำดับชั้น {app}:{cache}:{key}:{version} เคล็ดลับคือใส่ version ใน key เพื่อ invalidate ทั้งกลุ่มได้ด้วยการ bump version (ไม่ต้องไล่ลบทีละ key):

text
{app}:{cache_name}:{key}:{version}

myapp:users:123:v2
myapp:products:list:filter=cat-electronics:v1

ใส่ version ใน key → invalidate ทั้งกลุ่มด้วย bump version

9.3 Cache Size Sizing

Caffeine:

  • Start: maximumSize=1000
  • ดู eviction rate ใน Grafana
  • ถ้า eviction สูง → เพิ่มขนาด
  • ถ้า hit ratio ตก เพราะ memory เต็ม → คิดเรื่อง L2

Redis:

  • ใช้ maxmemory + maxmemory-policy (allkeys-lru, volatile-lru, ฯลฯ)
  • ตั้ง alert ที่ memory > 80%

9.4 ห้ามใช้ cache เป็น Source of Truth

กฎเหล็กของ caching — cache เป็นแค่ "สำเนา" ของข้อมูลเสมอ ไม่ใช่แหล่งความจริง อย่าเขียนข้อมูลลง cache อย่างเดียวโดยไม่ save DB เพราะถ้า Redis ล่ม/restart ข้อมูลจะหาย source of truth ต้องอยู่ที่ DB เสมอ:

java
// ❌ อันตราย
@CachePut(value = "users", key = "#user.id")
public User update(User user) {
    // ไม่ save DB!
    return user;
}

→ ถ้า Redis ล่ม / restart = data หาย

Cache เป็น "สำเนา" เสมอ. Source of truth = DB

9.5 Test Cache

Test เฉพาะ:

  • Cache hit จริงไหม (call 2 ครั้ง → DB ตอบครั้งเดียว)
  • TTL ทำงาน
  • Invalidation ทำงาน
java
// 📌 Spy vs Mock ต่างกันอย่างไร:
//   Mock = object จำลองทั้งหมด ไม่ทำงานจริง — คืนค่า default (null/0) ยกเว้น method ที่เรา stub ไว้
//   Spy  = object จริงที่ถูก wrap ไว้ — ทำงานจริงทุก method แต่เราสามารถ verify ว่ามีการเรียก method ไหม
//
// ใน test cache เราใช้ Spy เพราะอยากให้ UserRepository ทำงานจริง (query DB) แต่ต้องการนับว่าถูกเรียกกี่ครั้ง
// spy = ของจริงที่ถูกห่อไว้เพื่อนับว่าถูกเรียกกี่ครั้ง
// verify(repo, times(1)) = ยืนยันว่า repo ถูกเรียกแค่ 1 ครั้ง
// @MockitoSpyBean ต้อง import: org.springframework.test.context.bean.override.mockito.MockitoSpyBean
// สำหรับ Spring Boot < 3.4 ใช้ @SpyBean จาก org.springframework.boot.test.mock.mockito.SpyBean แทนได้
@SpringBootTest
class UserServiceCacheTest {
    @Autowired UserService svc;
    @MockitoSpyBean UserRepository repo;     // Spring Boot 3.4+ — @MockitoSpyBean เป็น API ใหม่ที่ Spring Boot ดูแลเองโดยตรง (ไม่ต้องพึ่ง library เสริม) ต่างจาก @SpyBean รุ่นเก่าที่แยก module ออกไป ยังใช้ได้ใน Spring Boot รุ่นก่อน

    @Test
    void shouldCache() {
        svc.findById(1L);
        svc.findById(1L);
        verify(repo, times(1)).findById(1L);   // DB ถูกเรียกครั้งเดียว
    }

    @Test
    void shouldEvictAfterUpdate() {
        // ใช้ constructor ตรงกับ User entity ในโปรเจกต์ของคุณ — ตัวอย่างนี้สมมติว่า User มี field: id, name, balance
        User user = new User(1L, "Alice", new java.math.BigDecimal("100.00"));
        svc.findById(1L);
        svc.update(user);
        svc.findById(1L);
        verify(repo, times(2)).findById(1L);   // DB ถูกเรียก 2 ครั้ง
    }
}

Part 10: Lab

Lab 1: เพิ่ม Caffeine cache + วัด

ฝึกพื้นฐาน — เพิ่ม @Cacheable กับ method ที่ query DB แล้ววัดว่า cache ทำงานจริงไหม (call ซ้ำแล้ว DB ถูกเรียกครั้งเดียว) + ดู hit ratio ใน metric เห็นผลของ cache ด้วยตัวเลขจริง:

java
@Service
public class ProductService {

    private final ProductRepository repo;

    public ProductService(ProductRepository repo) {
        this.repo = repo;
    }

    @Cacheable("products")
    public Product findById(Long id) {
        try { Thread.sleep(100); } catch (InterruptedException e) {}
        return repo.findById(id).orElseThrow();
    }
}
java
// เพิ่ม controller เพื่อรับ request จาก curl
@RestController
@RequestMapping("/products")
public class ProductController {
    private final ProductService productService;
    public ProductController(ProductService productService) {
        this.productService = productService;
    }
    @GetMapping("/{id}")
    public Product getById(@PathVariable Long id) {
        return productService.findById(id);
    }
}
bash
curl http://localhost:8080/products/1   # 100ms (first — ไป DB)
curl http://localhost:8080/products/1   # < 1ms (cached — ไม่ไป DB)

Lab 2: Redis cache + check Redis

ต่อยอดเป็น distributed cache — รัน Redis ด้วย Docker, สลับ provider เป็น Redis แล้วเปิด redis-cli ดูว่า key ถูกเขียนเข้าไปจริง ฝึกการ inspect cache ที่ระบบจริงต้องทำเวลา debug:

bash
docker run -d --name myredis -p 6379:6379 redis:7

# เข้า redis-cli ผ่าน docker exec (หา container id ด้วย docker ps ก่อน)
docker ps                                    # ดู CONTAINER ID ของ redis
docker exec -it myredis redis-cli            # เข้า redis-cli

# คำสั่งใน redis-cli
keys *
get "myapp::users::1"
ttl "myapp::users::1"

Lab 3: Stale problem + invalidation

java
@PutMapping("/users/{id}")
@CacheEvict(value = "users", key = "#id")
public User update(@PathVariable Long id, @RequestBody User u) {
    return userRepo.save(u);
}

ทดสอบ:

  1. GET /users/1 → cache
  2. PUT /users/1 (เปลี่ยน name)
  3. GET /users/1 → ได้ name ใหม่

Part 11: Checkpoint

  1. ตัดสินใจใช้ cache ได้เมื่อไหร่ (3 เกณฑ์)?
  2. Hit ratio < 50% หมายความว่ายังไง?
  3. ความต่าง L1 (Caffeine) กับ L2 (Redis)?
  4. ทำไม @Cacheable ที่เรียก internal ไม่ทำงาน? แก้ยังไง?
  5. Cache stampede คืออะไร? แก้ 4 วิธี?
  6. Cache penetration ต่างจาก avalanche ยังไง?
  7. Write-Through ต่างจาก Write-Behind?
  8. Multi-level cache invalidation challenge คืออะไร?
  9. Spring Cache เปลี่ยน provider ยังไง (Caffeine → Redis)?
  10. ทำไมไม่ควรใช้ cache เป็น source of truth?

Part 12: สรุปบทนี้

  • Cache = สำเนา ไม่ใช่ source of truth
  • Caffeine = best in-process cache (Window TinyLFU)
  • Redis = distributed cache เมื่อมีหลาย pod
  • Spring Cache = abstraction — เปลี่ยน provider ได้ผ่าน config
  • Cache-Aside (lazy) = pattern ที่ใช้บ่อยที่สุด
  • Stampede / Penetration / Avalanche = 3 ปัญหายอดฮิต
  • Multi-level = L1 (in-proc) + L2 (Redis) — ระวัง invalidation
  • Monitoring — hit ratio, latency, eviction, memory เป็น metric บังคับ
  • Naming = app:cache:key:version + jitter TTL + pre-warm

บทถัดไป — Messaging (Spring Kafka + Spring AMQP) — message broker ใน Spring


← บทที่ 10 | สารบัญ | บทที่ 12: Messaging →