ศูนย์รวมความรู้วิศวกรรมซอฟต์แวร์

แบ่งปันประสบการณ์การออกแบบสถาปัตยกรรมระบบ, การเขียนโค้ดด้วย Laravel และการจัดการ Server ระดับ Production เพื่อยกระดับทักษะของนักพัฒนาทุกคน

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: ปัญหา และ ความเสี่ยงเชิงสถาปัตยกรรม
  • ปัญหาที่แท้จริง: 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 เสมอ
  • 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

ตัวอย่าง: 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)

  • ข้อห้าม (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 เป็นตัวอัปเดต

  • ข้อห้าม (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