โหมดมืด
บทที่ 11 — Caching (การแคชข้อมูล — Spring Cache + Caffeine (in-memory cache) + Redis (distributed cache))
"ในวงการคอมพิวเตอร์มีเรื่องยากอยู่แค่ 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 min2.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,recordStats3.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" → cache3.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 | ของแต่ละ pod | server กลาง |
→ ใน 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: 2Spring 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: truejava
@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();
}
}⚠️
GenericJackson2JsonRedisSerializercaveat — 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จะ throwClassCastExceptionที่ 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 → returnApp เห็น 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 แห่กันแก้:
Single-flight / mutex — ให้ request แรกที่ miss load, ที่เหลือรอ
javaLoadingCache<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 อื่นโหลดเสร็จแล้ว } }Refresh-ahead (Part 5.5)
Jitter TTL — ตั้ง TTL random ±20% ป้องกัน expire พร้อมกัน
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
แก้:
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 เป็นUserobject ก่อนเสมอ ถ้าแปลงไม่ได้ก็ throw exception ทันทีโดยไม่มีโอกาสไปถึงบรรทัด.equals()เลย)ทางแก้ที่ปลอดภัยกว่า — ใช้
Usersentinel ที่มีฟิลด์ 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 อย่างที่คาดCap จำนวน null keys — เก็บไม่เกิน N keys ใน negative cache (Caffeine
maximumSize) ป้องกัน memory exhaustion จาก unique attackBloom filter — ตรวจก่อนว่า key อาจมี (false positive ได้ แต่ไม่ false negative) — เหมาะกับ static data set ที่ใหญ่
6.3 Cache Avalanche
🔤 Avalanche (อาวา-ลานช์) = "หิมะถล่ม" — cache จำนวนมากหมดอายุพร้อมกันทีเดียว เหมือนหิมะถล่มลงมาทับ DB
ปัญหา: cache ทั้งหมดหมดอายุพร้อมกัน → DB โดน burst
แก้:
- Jitter TTL — random ±20%
- Pre-warm cache ตอน startup
- 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 บ้าง)
- Versioning —
user: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/Sub | Redis Streams | Client-Side Tracking | |
|---|---|---|---|
| Delivery | at-most-once (ส่งได้อย่างมากครั้งเดียว — fire-and-forget ส่งแล้วลืม) | at-least-once (ส่งอย่างน้อยครั้งเดียว — มี consumer group ยืนยัน) | at-most-once (ส่งได้อย่างมากครั้งเดียว) |
| ถ้า client offline | event หาย | rewind ได้ (อ่านย้อนหลังจาก offset เก่า) | invalidate ตอน reconnect |
| Use case | best-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: hazelcastPart 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
- productsMetric ที่ดู:
cache.gets{result="hit"}— hit countcache.gets{result="miss"}— miss countcache.puts— write countcache.evictions— eviction countcache.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 เป็น 0Spring Boot auto-config Caffeine มี option:
yaml
spring.cache.caffeine.spec: maximumSize=1000,expireAfterWrite=10m,recordStatsPrometheus 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: 5m9.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);
}ทดสอบ:
- GET /users/1 → cache
- PUT /users/1 (เปลี่ยน name)
- GET /users/1 → ได้ name ใหม่
Part 11: Checkpoint
- ตัดสินใจใช้ cache ได้เมื่อไหร่ (3 เกณฑ์)?
- Hit ratio < 50% หมายความว่ายังไง?
- ความต่าง L1 (Caffeine) กับ L2 (Redis)?
- ทำไม
@Cacheableที่เรียก internal ไม่ทำงาน? แก้ยังไง? - Cache stampede คืออะไร? แก้ 4 วิธี?
- Cache penetration ต่างจาก avalanche ยังไง?
- Write-Through ต่างจาก Write-Behind?
- Multi-level cache invalidation challenge คืออะไร?
- Spring Cache เปลี่ยน provider ยังไง (Caffeine → Redis)?
- ทำไมไม่ควรใช้ 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