Laravel Cache
รายละเอียดการใช้ Cache
ในฐานะ System Architect ที่ดูแลระบบ Production สเกลใหญ่ ผมขอบอกว่า "Caching ไม่ใช่ยาวิเศษที่เอาไว้แก้ปัญหาโค้ดหรือ Database ที่ออกแบบมาไม่ดี" แต่ Caching คือสถาปัตยกรรม (Architecture) ที่ต้องถูกวางแผนอย่างระมัดระวังตั้งแต่ระดับ Data Layer ไปจนถึง Application Layer
การใช้ Cache::remember แบบส่งเดชทั่วทั้งโปรเจกต์จะนำไปสู่ปัญหาที่เลวร้ายที่สุดอย่างหนึ่งในระบบ Production นั่นคือ "Stale Data" (ข้อมูลไม่ตรงกับความเป็นจริง) และ "Cache Stampede" (ระบบพังทลายเมื่อ Cache หมดอายุพร้อมกัน) เพื่อให้ระบบรองรับผู้ใช้ระดับ 100,000+ CCU (Concurrent Users) ได้อย่างเสถียร นี่คือแนวทางและสถาปัตยกรรมการจัดการ Cache ใน Laravel แบบฉบับของ Lead Engineer
1. Problem Framing: ปัญหา และ ความเสี่ยงเชิงสถาปัตยกรรม
1. Problem Framing: ปัญหา และ ความเสี่ยงเชิงสถาปัตยกรรม
- ปัญหาที่แท้จริง: Database (MySQL) มีขีดจำกัดด้าน Connection และ I/O เมื่อ Traffic พุ่งสูง (Spike) การ Query ข้อมูลเดิมซ้ำๆ ทำให้เปลืองทรัพยากรโดยใช่เหตุ
- Constraints: ข้อมูลบางอย่างต้องการความสดใหม่ (Real-time) ข้อมูลบางอย่างยอมให้ดีเลย์ได้ (Eventual Consistency)
- Architectural Risk: * Cache Invalidation: การลบ Cache ไม่หมด ทำให้ User เห็นข้อมูลเก่าและเกิดข้อผิดพลาดทาง Business Logic
- Cache Stampede (Thundering Herd): เมื่อ Cache ยอดฮิตหมดอายุ (Expire) ในเสี้ยววินาทีที่มี Request เข้ามา 10,000 requests ระบบจะพุ่งไป Query Database พร้อมกัน 10,000 connections ทำให้ Database ร่วงทันที
2. Architecture Decision: การตัดสินใจเลือกเทคโนโลยี
- Cache Driver: เราใช้ Redis เท่านั้นสำหรับ Production สเกลใหญ่ (ไม่ใช้ File, Database หรือ Memcached) เพราะ Redis รองรับ Data Structures ที่ซับซ้อน และสนับสนุน Cache Tags ซึ่งจำเป็นมากในการทำ Invalidation เชิงกลุ่ม
- สิ่งที่ "ตั้งใจไม่ทำ" (Trade-offs): * เราจะไม่ Cache ทุกอย่าง เราจะ Cache เฉพาะข้อมูลที่มีอัตรา Read/Write Ratio สูงมากๆ (เช่น 100:1)
- เราจะไม่ใช้ Cache::remember ใน Controller หรือ Blade เด็ดขาด เพราะมันผิดหลัก SRP (Single Responsibility Principle) และทำให้ Test/Invalidate ยาก
3. Data Design: โครงสร้างข้อมูล และ Invalidation Strategy
การตั้งชื่อ Cache Key สำคัญเท่ากับการตั้งชื่อ Table ใน Database เราใช้มาตรฐาน Namespace + Entity + ID + Relationship เสมอ
การตั้งชื่อ Cache Key สำคัญเท่ากับการตั้งชื่อ Table ใน Database เราใช้มาตรฐาน Namespace + Entity + ID + Relationship เสมอ
- Key Convention: domain:entity:id:relations
- ตัวอย่าง: shop:product:1024:reviews
- Cache Tags: เราใช้ Tags เพื่อมัดรวม Cache ที่เกี่ยวข้องกัน ทำให้ลบทีเดียวได้ทั้งหมดโดยไม่ต้องจำ Key
- ตัวอย่าง: Cache::tags(['products', 'product:1024'])
4. Code Structure: โครงสร้างโค้ดระดับ Production
ตามมาตรฐานของเรา Controller ต้องบางเฉียบ หน้าที่การอ่าน Cache จะตกเป็นของ Query Layer และการลบ Cache จะเป็นของ Action Layer
ตามมาตรฐานของเรา Controller ต้องบางเฉียบ หน้าที่การอ่าน Cache จะตกเป็นของ Query Layer และการลบ Cache จะเป็นของ Action Layer
ตัวอย่าง: Query Layer สำหรับอ่านข้อมูล (ห้ามมี Logic เขียนข้อมูล)
<?php declare(strict_types=1);
namespace App\Queries\Products;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
final class GetActiveProductQuery
{
/**
* @param int $productId
* @return Product
*/
public function execute(int $productId): Product
{
// ใช้ Tags เพื่อให้ Invalidate ง่าย และตั้ง TTL ชัดเจน
return Cache::tags(['products', "product:{$productId}"])
->remember(
"shop:product:{$productId}:details",
now()->addHours(12),
fn () => Product::query()
->with(['category', 'activeTags']) // Eager Load ป้องกัน N+1
->where('is_active', true)
->findOrFail($productId)
);
}
}ตัวอย่าง: Action Layer สำหรับอัปเดตและ Invalidate Cache
<?php
declare(strict_types=1);
namespace App\Actions\Products;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
final class UpdateProductPriceAction
{
public function execute(Product $product, float $newPrice): Product
{
return DB::transaction(function () use ($product, $newPrice) {
$product->update(['price' => $newPrice]);
// Flush Cache ทิ้งทันทีเมื่อ Data เปลี่ยนแปลง
// การใช้ Tag ทำให้เราเคลียร์ Cache ทุกตัวที่เกี่ยวกับสินค้านี้ได้ทันที
Cache::tags(["product:{$product->id}"])->flush();
return $product;
});
}
}5. Failure Planning: การรับมือเมื่อ Traffic โต 10 เท่า
หาก Traffic เพิ่มขึ้น 10 เท่า ปัญหาที่จะเจอคือ Cache Stampede และ Race Conditions
- วิธีแก้ Cache Stampede: เราจะใช้ Atomic Locks (Cache::lock) ร่วมกับการดึงข้อมูล หรือใช้เทคนิค Background Refresh (ให้ Cronjob หรือ Queue เป็นตัวอัปเดต Cache ก่อนที่มันจะหมดอายุ ส่วน User อ่านจาก Cache เสมอ โดยใช้ rememberForever)
- วิธีแก้ Race Conditions (เช่น การกดรับสิทธิ์พร้อมกัน): ห้ามใช้ Query ธรรมดาเช็คเงื่อนไข ต้องใช้ Cache::lock() เพื่อให้มั่นใจว่า Request ถูก Process ทีละลำดับ
ตัวอย่างการป้องกัน Race Condition ด้วย Atomic Lock:
$lock = Cache::lock("checkout_processing:{$userId}", 10); // ล็อค 10 วินาที
if ($lock->get()) {
try {
// Business logic ที่ห้ามทำงานซ้ำซ้อนกัน
} finally {
$lock->release(); // ต้องมี Finally เสมอเพื่อป้องกัน Deadlock หากโค้ดพังกลางคัน
}
} else {
abort(429, 'Your previous request is still processing.');
}สรุปเรื่อง Laravel Cache เชิงสถาปัตยกรรม (Architecture Table)
มาตรฐานสถาปัตยกรรม Laravel Cache สำหรับระบบ Production ขนาดใหญ่
1. Driver & Infrastructure (ระบบจัดการและโครงสร้างพื้นฐาน)
- ✅ สิ่งที่ต้องทำ (Best Practice): บังคับใช้ Redis เป็น Cache Driver บน Production เสมอ เนื่องจากรองรับ Data Structures ที่ซับซ้อน, In-memory Performance ที่สูงมาก, และรองรับ Cache Tags นอกจากนี้ต้องกำหนด Eviction Policy ใน Redis เป็น allkeys-lru หรือ volatile-lru เพื่อให้ระบบจัดการ Memory ได้เองเมื่อเต็ม
- ❌ ข้อห้าม (Anti-Pattern): ห้ามใช้ file หรือ database driver บนระบบ Production ที่มี Traffic สูงเด็ดขาด เพราะจะเกิดปัญหา I/O Bottleneck และ Database Table Lock จนทำให้ระบบล่มในที่สุด
2. Layer & Placement (ตำแหน่งของการเรียกใช้งานในโค้ด)
- ✅ สิ่งที่ต้องทำ (Best Practice): แยกความรับผิดชอบ (SRP) อย่างชัดเจน
- การอ่าน Cache: ต้องทำใน Query Layer หรือ Repository เท่านั้น
- การอัปเดต/ลบ Cache: ต้องทำใน Action Layer หรือ Observer ทันทีที่มีการเปลี่ยนแปลงข้อมูล (Write operation)
- การอ่าน Cache: ต้องทำใน Query Layer หรือ Repository เท่านั้น
- ❌ ข้อห้าม (Anti-Pattern): ห้ามเขียน Cache::get(), Cache::put() หรือ Cache::remember() ใน Controller, FormRequest, หรือ Blade View เด็ดขาด Controller ต้องมีหน้าที่แค่รับส่งข้อมูล (Skinny Controller) ไม่ใช่ทำ Business หรือ Caching Logic
3. Naming Convention (มาตรฐานการตั้งชื่อ Cache Key)
- ✅ สิ่งที่ต้องทำ (Best Practice): ใช้โครงสร้างการตั้งชื่อแบบมี Namespace เพื่อหลีกเลี่ยงการชนกัน (Key Collision) รูปแบบที่แนะนำคือ domain:entity:id:scope (ตัวอย่าง: oms:order:1024:items หรือ shop:product:99:reviews)
- ❌ ข้อห้าม (Anti-Pattern): ห้ามตั้งชื่อ Key สั้นๆ หรือไม่มีบริบท เช่น data_1, user_cache, latest_posts เพราะเมื่อระบบขยายใหญ่ขึ้น (Scale) คุณจะไม่สามารถ Debug หรือ Invalidate Cache เหล่านี้ได้อย่างแม่นยำ
4. Cache Invalidation (กลยุทธ์การล้างข้อมูลที่หมดอายุ)
- ✅ สิ่งที่ต้องทำ (Best Practice): ใช้ Cache Tags เสมอเมื่อใช้งานผ่าน Redis เพื่อให้สามารถลบ (Flush) กลุ่มข้อมูลที่มีความเกี่ยวข้องกันได้ด้วยคำสั่งเดียว เช่น เมื่อแก้ไขข้อมูลสินค้า ให้ Flush tag product:1024 ซึ่งจะไปลบทั้งหน้ารายละเอียดสินค้าและรีวิวของสินค้านั้นพร้อมกัน
- ❌ ข้อห้าม (Anti-Pattern): หลีกเลี่ยงการหวังพึ่งเวลาหมดอายุ (TTL - Time to Live) เพียงอย่างเดียวโดยไม่มีกลไก Invalidate เมื่อมีการทำ Write/Update Data เพราะจะนำไปสู่ปัญหา "Stale Data" หรือข้อมูลในระบบไม่ตรงกับความเป็นจริง (Inconsistency)
5. Performance & Scaling (การจัดการประสิทธิภาพและการขยายระบบ)
- ✅ สิ่งที่ต้องทำ (Best Practice): * ป้องกัน Race Condition: ใช้ Cache::lock() (Atomic Locks) ทุกครั้งที่มี Transaction ที่เซนซิทีฟ เช่น การตัดสต๊อกสินค้า หรือการแย่งกดคูปอง
- ป้องกัน Cache Stampede: ข้อมูลที่มีคนเข้าดูจำนวนมากพร้อมๆ กัน ให้ใช้เทคนิค Background Refresh (ใช้ Queue/Cronjob อัปเดต Cache ทิ้งไว้เบื้องหลัง) แทนที่จะให้ Request ของ User เป็นตัวอัปเดต
- ป้องกัน Cache Stampede: ข้อมูลที่มีคนเข้าดูจำนวนมากพร้อมๆ กัน ให้ใช้เทคนิค Background Refresh (ใช้ Queue/Cronjob อัปเดต Cache ทิ้งไว้เบื้องหลัง) แทนที่จะให้ Request ของ User เป็นตัวอัปเดต
- ❌ ข้อห้าม (Anti-Pattern): ห้ามปล่อยให้ Cache ของ API/Page ที่มี Traffic สูงๆ หมดอายุพร้อมกัน (Expire at the same time) เพราะจะทำให้เกิด Thundering Herd effect ที่ Request นับหมื่นวิ่งทะลุไปหา Database พร้อมกันจนระบบล่ม
6. Data Structure (โครงสร้างข้อมูลที่จัดเก็บใน Cache)
- ✅ สิ่งที่ต้องทำ (Best Practice): Cache เฉพาะข้อมูลที่ผ่านกระบวนการ Eager Loading มาครบถ้วนแล้ว หรือแปลงข้อมูลให้อยู่ในรูปแบบ DTO (Data Transfer Object) / Array ก่อนทำการ Cache เพื่อประหยัด Memory ของ Redis ให้มากที่สุด
- ❌ ข้อห้าม (Anti-Pattern): ห้าม Cache Eloquent Collection ขนาดใหญ่ที่ดึงมาทั้งตาราง (SELECT *) โดยไม่ได้คัดแยก Column เพราะจะทำให้เปลือง Network Bandwidth ในการ Serialize/Deserialize และทำให้ Redis Memory เต็ม (OOM - Out of Memory) ได้อย่างรวดเร็ว
การออกแบบระบบที่สเกลได้ ไม่ใช่การหา Tool ที่ฉลาดที่สุด แต่เป็นการวาง "ข้อจำกัด" (Constraints) และ "กฎเกณฑ์" (Boundaries) ให้ทีมพัฒนาเดินตามได้อย่างปลอดภัยในระยะยาวครับ
laravel