Trong năm bài đầu tiên của series, mình đã tìm hiểu về nền tảng tối ưu hóa cơ sở dữ liệu, phân tích và tối ưu câu truy vấn SQL, chiến lược indexing chuyên sâu, thiết kế schema tối ưu, và quản lý transaction và concurrency hiệu quả. Bài viết này sẽ đi sâu vào một khía cạnh quan trọng khác: chiến lược caching và tối ưu tầng ứng dụng.

Caching là một trong những kỹ thuật mạnh mẽ nhất để cải thiện hiệu năng hệ thống. Bằng cách lưu trữ tạm thời dữ liệu thường xuyên truy cập, mình có thể giảm đáng kể tải cho cơ sở dữ liệu và cải thiện thời gian phản hồi cho người dùng. Tuy nhiên, caching cũng đi kèm với nhiều thách thức về tính nhất quán, invalidation, và quản lý bộ nhớ.

Trong bài viết này, mình sẽ khám phá các chiến lược caching hiệu quả và các kỹ thuật tối ưu tầng ứng dụng để cải thiện hiệu năng cơ sở dữ liệu.

Cache levels và cache invalidation patterns

Các tầng cache trong kiến trúc hiện đại

Trong một hệ thống hiện đại, cache có thể được triển khai ở nhiều tầng khác nhau:


  graph TD
    A[Client] --> B[Browser Cache]
    A --> C[CDN Cache]
    C --> D[API Gateway Cache]
    D --> E[Application Cache]
    E --> F[Database Cache]
    F --> G[Database]
  1. Browser Cache: Lưu trữ tài nguyên tĩnh (JS, CSS, images) ở phía client
  2. CDN Cache: Lưu trữ và phân phối nội dung tĩnh gần với người dùng
  3. API Gateway Cache: Cache responses của API calls
  4. Application Cache: Cache ở tầng ứng dụng (in-memory, distributed cache)
  5. Database Cache: Buffer pool, query cache của database engine

Mỗi tầng cache có đặc điểm riêng và phù hợp cho các loại dữ liệu khác nhau:

Tầng CacheThời gian tồn tạiPhạm viPhù hợp cho
BrowserDài (ngày, tuần)Người dùng cụ thểTài nguyên tĩnh, UI components
CDNDài (giờ, ngày)Tất cả người dùngNội dung tĩnh, assets
API GatewayTrung bình (phút, giờ)Tất cả người dùngAPI responses, authentication
ApplicationNgắn-Trung bình (giây, phút)Theo instance hoặc clusterBusiness logic, computed data
DatabaseNgắn (mili giây, giây)Database instanceQuery results, index data

Cache strategies

Có nhiều chiến lược caching khác nhau, mỗi chiến lược có ưu và nhược điểm riêng:

  1. Cache-Aside (Lazy Loading):
    • Ứng dụng kiểm tra cache trước, nếu không có (cache miss) thì đọc từ database và cập nhật cache
    • Phù hợp cho dữ liệu đọc nhiều, ít thay đổi
    • Có thể dẫn đến cache miss đồng thời (thundering herd)

  sequenceDiagram
    participant App as Application
    participant Cache as Cache
    participant DB as Database

    App->>Cache: Get data
    alt Cache hit
        Cache->>App: Return data
    else Cache miss
        Cache->>App: Data not found
        App->>DB: Query data
        DB->>App: Return data
        App->>Cache: Store data
        App->>App: Process data
    end
/**
 * Lấy thông tin người dùng với cache
 *
 * @param int $userId
 * @return \App\Models\User|null
 */
public function getUser(int $userId)
{
    // Cách 1: Sử dụng Cache facade cơ bản
    $cacheKey = "user:{$userId}";

    if (Cache::has($cacheKey)) {
        return Cache::get($cacheKey);
    }

    // Cache miss, lấy từ database
    $user = User::find($userId);

    // Lưu vào cache cho các request sau
    Cache::put($cacheKey, $user, now()->addHour()); // TTL: 1 giờ

    return $user;
}

/**
 * Cách 2: Sử dụng Cache::remember() để làm gọn code
 *
 * @param int $userId
 * @return \App\Models\User|null
 */
public function getUserOptimized(int $userId)
{
    return Cache::remember("user:{$userId}", now()->addHour(), function () use ($userId) {
        return User::find($userId);
    });
}

/**
 * Cách 3: Sử dụng Repository Pattern với cache
 */
class UserRepository
{
    protected $cache;

    public function __construct(\Illuminate\Contracts\Cache\Repository $cache)
    {
        $this->cache = $cache;
    }

    public function find(int $userId)
    {
        $cacheKey = "user:{$userId}";

        return $this->cache->remember($cacheKey, now()->addHour(), function () use ($userId) {
            // Có thể thêm các logic phức tạp hơn ở đây
            $user = User::find($userId);

            if ($user) {
                // Eager load relationships nếu cần
                $user->load('profile', 'roles');
            }

            return $user;
        });
    }

    // Xóa cache khi cập nhật user
    public function update(User $user, array $data)
    {
        $user->update($data);
        $this->cache->forget("user:{$user->id}");
        return $user;
    }
}

Entity caching trong ORM

Nhiều ORM frameworks cung cấp cơ chế caching entities để cải thiện hiệu năng:

  1. First-level cache (Session/Persistence Context):
    • Cache trong phạm vi một session/transaction
    • Tự động, không cần cấu hình
    • Giúp đảm bảo identity map (cùng một entity chỉ được load một lần trong session)

  graph TD
    A[Application] --> B[ORM Session]
    B --> C[First-level Cache]
    B --> D[Database]

    C -->|Cache Hit| B
    D -->|Cache Miss| C
  1. Second-level cache (Shared Cache):
    • Cache ở cấp độ ứng dụng, được chia sẻ giữa nhiều sessions
    • Cần cấu hình rõ ràng, thường sử dụng các provider như EHCache, Redis, Hazelcast
    • Giúp giảm tải database khi nhiều users truy cập cùng dữ liệu

  graph TD
    A1[Session 1] --> B[Second-level Cache]
    A2[Session 2] --> B
    A3[Session 3] --> B
    B --> C[Database]

Ví dụ cấu hình second-level cache với Hibernate và Redis:

// config/cache.php
return [
    'default' => env('CACHE_DRIVER', 'redis'),

    'stores' => [
        'redis' => [
            'driver' => 'redis',
            'connection' => 'cache',
            'lock_connection' => 'default',
        ],
    ],

    'prefix' => env('CACHE_PREFIX', 'laravel_cache'),
];

// Laravel không có "default TTL" global, TTL truyền trực tiếp khi get/put/remember.
// Helper đơn giản qua macro hoặc config:

// app/Providers/AppServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Cache;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Thêm macro "default TTL" 600 giây, dùng nơi nào cần
        Cache::macro('rememberDefault', function (string $key, \Closure $cb) {
            return Cache::remember($key, now()->addSeconds(600), $cb);
        });
    }
}

// Sử dụng:
// $users = Cache::rememberDefault('users.active', fn () => User::active()->get());

// Hoặc set TTL trực tiếp, API chính thức của Laravel
Cache::put('key', $value, now()->addMinutes(10));
Cache::remember('users.1', 600, fn () => User::find(1));
Cache::forever('settings.global', $settings);  // không TTL, chỉ xoá bằng forget/flush

Lưu ý: Cache::setDefaultCacheTime() không tồn tại trong Laravel (có thể nhầm với helper nội bộ của dự án cũ). Muốn TTL thống nhất, nên:

  1. Định nghĩa constant/config (config/cache.php'default_ttl' => 600) và gọi config('cache.default_ttl') ở mọi chỗ.
  2. Tạo CacheService wrapper xử lý TTL tập trung.
  3. Với Redis, dùng CONFIG SET maxmemory-policy volatile-ttl + default TTL ở application layer.

Các chiến lược caching khác

Ngoài Cache-Aside, còn có các chiến lược caching khác:

  1. Write-Through Cache:
    • Dữ liệu được ghi đồng thời vào cache và database
    • Đảm bảo tính nhất quán cao
    • Có thể làm chậm các thao tác ghi

  sequenceDiagram
    participant App as Application
    participant Cache as Cache
    participant DB as Database

    App->>App: Update data
    App->>Cache: Write data
    Cache->>DB: Write data
    DB->>Cache: Acknowledge
    Cache->>App: Acknowledge
  1. Write-Behind (Write-Back) Cache:
    • Dữ liệu được ghi vào cache trước, sau đó mới ghi vào database (async)
    • Tối ưu hiệu năng ghi
    • Rủi ro mất dữ liệu nếu cache bị lỗi trước khi đồng bộ với database

  sequenceDiagram
    participant App as Application
    participant Cache as Cache
    participant DB as Database

    App->>App: Update data
    App->>Cache: Write data
    Cache->>App: Acknowledge
    Note over Cache,DB: Asynchronously
    Cache->>DB: Write data (delayed)
    DB->>Cache: Acknowledge
  1. Read-Through Cache:
    • Cache tự động load dữ liệu từ database khi cache miss
    • Ứng dụng chỉ tương tác với cache, không trực tiếp với database
    • Đơn giản hóa logic ứng dụng

  sequenceDiagram
    participant App as Application
    participant Cache as Cache
    participant DB as Database

    App->>Cache: Get data
    alt Cache hit
        Cache->>App: Return data
    else Cache miss
        Cache->>DB: Query data
        DB->>Cache: Return data
        Cache->>App: Return data
    end

Cache invalidation patterns

Một trong những thách thức lớn nhất của caching là làm sao để dữ liệu trong cache luôn đồng bộ với database. Có một số pattern phổ biến để giải quyết vấn đề này:

  1. Time-based invalidation (TTL - Time To Live):
    • Đặt thời gian hết hạn cho mỗi cache entry
    • Đơn giản, dễ triển khai
    • Có thể dẫn đến dữ liệu không nhất quán trong khoảng thời gian TTL
// Set cache with TTL of 5 minutes
Cache::put("product:1001", $productData, now()->addMinutes(5));
  1. Event-based invalidation:
    • Xóa hoặc cập nhật cache khi có sự kiện thay đổi dữ liệu
    • Đảm bảo tính nhất quán cao
    • Phức tạp hơn, cần cơ chế theo dõi các thay đổi
function updateProduct($productId, $newData)
{
    // Update database
    Product::where('id', $productId)->update($newData);

    // Invalidate cache
    Cache::forget("product:{$productId}");
    Cache::forget("products:recent");
    Cache::forget("products:featured");
}
  1. Version-based invalidation:
    • Gắn version cho mỗi cache entry
    • Khi dữ liệu thay đổi, tăng version
    • Cache key bao gồm cả version, giúp tự động invalidate các phiên bản cũ
function getProduct($productId)
{
    // Get current version
    $version = VersionService::getVersion("product", $productId);

    // Try to get from cache with version
    $cacheKey = "product:{$productId}:v{$version}";
    $product = Cache::get($cacheKey);

    if ($product === null) {
        $product = Product::find($productId);
        Cache::put($cacheKey, $product, now()->addHour());
    }

    return $product;
}

function updateProduct($productId, $newData)
{
    // Update database
    Product::where('id', $productId)->update($newData);

    // Increment version (no need to invalidate old cache)
    VersionService::incrementVersion("product", $productId);
}
  1. Pattern-based invalidation:
    • Xóa nhiều cache entries cùng lúc dựa trên pattern
    • Hữu ích khi một thay đổi ảnh hưởng đến nhiều cache entries
// Invalidate all product caches
Cache::tags(['products'])->flush();

// Invalidate all caches related to a specific category
Cache::tags(['category:electronics'])->flush();

Cảnh báo: KHÔNG dùng redis.call('keys', ARGV[1]) trên production, KEYS pattern là O(N) với N = số key toàn DB và block thread chính của Redis. Trên cluster 10M key, câu này có thể block 1–3 giây. Thay bằng SCAN (cursor-based, non-blocking):

-- Lua script xóa theo pattern SCAN, an toàn trên production
local cursor = '0'
local count = 0
repeat
  local result = redis.call('SCAN', cursor, 'MATCH', ARGV[1], 'COUNT', 500)
  cursor = result[1]
  local keys = result[2]
  if #keys > 0 then
    count = count + redis.call('UNLINK', unpack(keys))  -- UNLINK = async DEL
  end
until cursor == '0'
return count

Dùng trong PHP:

Redis::command('EVAL', [
    file_get_contents(base_path('scripts/invalidate.lua')),
    0,
    'product:*',
]);

UNLINK được giới thiệu ở Redis 4.0, xoá key lớn bất đồng bộ (không block), nên dùng thay DEL cho batch invalidate.

Chống cache stampede (thundering herd)

Khi một key hot hết hạn cùng lúc và hàng nghìn request đi thẳng xuống DB, bạn gặp cache stampede. 4 kỹ thuật hay dùng kết hợp:

1. TTL jitter, rải hạn kết thúc ngẫu nhiên ±10-20%:

$ttl = 3600 + random_int(-600, 600);   // 60 ± 10 phút
Cache::put($key, $value, $ttl);

2. Early refresh (“recompute before expire”), refresh khi TTL còn ngắn hơn một ngưỡng, xác suất tăng dần khi tiến gần expire (XFetch / probabilistic early expiration):

import random, math

def get_or_refresh(key, ttl, recompute, beta=1.0):
    value, delta, expiry = cache.get_with_meta(key)  # value, time-to-recompute, expiry_ts
    if value is None or time.time() - delta * beta * math.log(random.random()) >= expiry:
        t0 = time.time()
        value = recompute()
        cache.set_with_meta(key, value, ttl, delta=time.time() - t0)
    return value

3. Mutex / single-flight, chỉ một process được phép compute key đó, các process khác chờ:

def get_with_lock(key, recompute, lock_ttl=5):
    value = cache.get(key)
    if value is not None:
        return value

    lock_key = f"lock:{key}"
    if redis.set(lock_key, "1", nx=True, ex=lock_ttl):
        try:
            value = recompute()
            cache.set(key, value, ttl=3600 + random.randint(-600, 600))
            return value
        finally:
            redis.delete(lock_key)
    else:
        time.sleep(0.05)
        return get_with_lock(key, recompute, lock_ttl)

4. Negative cache, cache cả kết quả rỗng / 404 với TTL ngắn (30-60 giây) để tránh DoS lặp lại:

def get_user(user_id):
    key = f"user:{user_id}"
    v = cache.get(key)
    if v == "__NULL__":
        return None
    if v is not None:
        return json.loads(v)

    user = db.users.find_one(user_id)
    if user is None:
        cache.set(key, "__NULL__", ttl=60)   # negative cache TTL ngắn
    else:
        cache.set(key, json.dumps(user), ttl=3600 + random.randint(-300, 300))
    return user

5. Stale-while-revalidate, trả bản cũ cho request trong khi một worker refresh ngầm (phù hợp CDN/HTTP cache, framework fastify-cache, Next.js revalidate).

Tối ưu ORM và giải quyết vấn đề N+1 queries

Vấn đề N+1 queries

Vấn đề N+1 là một trong những nguyên nhân phổ biến gây ra hiệu năng kém trong ứng dụng sử dụng ORM. Vấn đề xảy ra khi:

  1. Ứng dụng thực hiện 1 query để lấy danh sách N bản ghi
  2. Sau đó thực hiện N queries riêng biệt để lấy dữ liệu liên quan cho mỗi bản ghi

  sequenceDiagram
    participant App as Application
    participant DB as Database

    App->>DB: SELECT * FROM orders WHERE user_id = 123
    DB->>App: Return N orders

    loop For each order
        App->>DB: SELECT * FROM order_items WHERE order_id = ?
        DB->>App: Return order items
    end

Ví dụ với ORM:

// Vấn đề N+1
$orders = Order::where('user_id', $userId)->get();

// Cho mỗi order, thực hiện thêm 1 query để lấy items
foreach ($orders as $order) {
    $items = $order->items;  // Trigger lazy loading, thực hiện thêm 1 query
    echo "Order #{$order->id} has " . count($items) . " items";
}

Giải pháp cho vấn đề N+1

  1. Eager Loading (JOIN / Fetch Join):
    • Sử dụng JOIN để lấy dữ liệu liên quan trong cùng một query
    • Giảm số lượng queries từ N+1 xuống còn 1
// Giải pháp: Eager loading với join
$orders = Order::with('items')
    ->where('user_id', $userId)
    ->get();

// Không có thêm query nào được thực hiện
foreach ($orders as $order) {
    $items = $order->items;  // Đã được load sẵn, không trigger thêm query
    echo "Order #{$order->id} has " . count($items) . " items";
}
  1. Batch Loading:
    • Thay vì N queries riêng biệt, sử dụng 1 query với điều kiện IN
    • Giảm số lượng queries từ N+1 xuống còn 2
// Giải pháp: Batch loading
$orders = Order::where('user_id', $userId)->get();

// Lấy tất cả order_ids
$orderIds = $orders->pluck('id')->toArray();

// Thực hiện 1 query để lấy tất cả items cho các orders
$allItems = OrderItem::whereIn('order_id', $orderIds)->get();

// Gom items theo order_id
$itemsByOrder = [];
foreach ($allItems as $item) {
    if (!isset($itemsByOrder[$item->order_id])) {
        $itemsByOrder[$item->order_id] = [];
    }
    $itemsByOrder[$item->order_id][] = $item;
}

// Sử dụng dữ liệu đã được batch load
foreach ($orders as $order) {
    $items = $itemsByOrder[$order->id] ?? [];
    echo "Order #{$order->id} has " . count($items) . " items";
}
  1. Subquery Loading:
    • Sử dụng subquery để lấy dữ liệu liên quan
    • Phù hợp cho các relationships một-nhiều lớn
// Giải pháp: Subquery loading (Laravel sử dụng eager loading tương tự)
$orders = Order::with(['items' => function($query) {
    $query->orderBy('created_at', 'desc');
}])->where('user_id', $userId)->get();

// Không có thêm query nào được thực hiện
foreach ($orders as $order) {
    $items = $order->items;  // Đã được load sẵn
    echo "Order #{$order->id} has " . count($items) . " items";
}

Các kỹ thuật tối ưu ORM khác

  1. Sử dụng Projections:
    • Chỉ select các columns cần thiết thay vì tất cả
    • Giảm lượng dữ liệu truyền từ database đến ứng dụng
// Thay vì
$users = User::all();

// Chỉ select các columns cần thiết
$users = User::select('id', 'name', 'email')->get();
  1. Pagination:
    • Phân trang kết quả thay vì lấy tất cả cùng lúc
    • Giảm memory usage và cải thiện response time
$page = 1;
$pageSize = 20;

$users = User::orderBy('created_at', 'desc')
    ->skip(($page - 1) * $pageSize)
    ->take($pageSize)
    ->get();

// Hoặc sử dụng paginate của Laravel
$users = User::orderBy('created_at', 'desc')->paginate($pageSize);
  1. Bulk Operations:
    • Sử dụng bulk inserts/updates thay vì xử lý từng bản ghi
    • Giảm số lượng queries và cải thiện hiệu năng
// Thay vì
foreach ($users as $user) {
    $user->status = 'active';
    $user->save();
}

// Sử dụng bulk update
User::whereIn('id', $users->pluck('id')->toArray())
    ->update(['status' => 'active']);
  1. Sử dụng Native Queries cho các truy vấn phức tạp:
    • Đôi khi ORM không tạo ra SQL tối ưu cho các truy vấn phức tạp
    • Sử dụng native SQL có thể cải thiện hiệu năng đáng kể
// Thay vì sử dụng ORM API phức tạp
$activeUsersWithManyOrders = DB::select("
    SELECT u.id, u.name, COUNT(o.id) as order_count
    FROM users u
    LEFT JOIN orders o ON u.id = o.user_id
    WHERE u.status = 'active'
    GROUP BY u.id, u.name
    HAVING COUNT(o.id) > 5
    ORDER BY order_count DESC
    LIMIT 10
");

Kết hợp caching và ORM optimization

Để đạt hiệu năng tối đa, bạn nên kết hợp cả caching và ORM optimization:

  1. Cache query results:
    • Cache kết quả của các queries phức tạp hoặc thường xuyên sử dụng
    • Invalidate cache khi dữ liệu thay đổi
function getTopProducts($categoryId)
{
    $cacheKey = "top_products:{$categoryId}";

    // Try to get from cache
    $products = Cache::get($cacheKey);
    if ($products !== null) {
        return $products;
    }

    // Cache miss, query with optimized ORM
    $products = Product::with('reviews')
        ->where('category_id', $categoryId)
        ->orderBy('rating', 'desc')
        ->limit(10)
        ->get();

    // Store in cache
    Cache::put($cacheKey, $products, now()->addMinutes(30));  // TTL: 30 minutes

    return $products;
}
  1. Sử dụng cache để giảm thiểu vấn đề N+1:
    • Cache các entities thường được truy cập
    • Sử dụng batch loading kết hợp với cache
use Illuminate\Support\Facades\Cache;

function getOrderWithItems($orderId)
{
    // Thử lấy order từ cache
    $order = Cache::get("order:{$orderId}");
    if ($order === null) {
        $order = Order::find($orderId);
        Cache::put("order:{$orderId}", $order, now()->addHour());
    }

    // Thử lấy items từ cache
    $items = Cache::get("order_items:{$orderId}");
    if ($items === null) {
        $items = OrderItem::where('order_id', $orderId)->get();
        Cache::put("order_items:{$orderId}", $items, now()->addHour());
    }

    // Gắn items vào order
    $order->setRelation('items', $items);

    return $order;
}

Cache đúng pattern, xử lý stampede và invalidation nhất quán

Caching và tối ưu tầng ứng dụng là hai chiến lược quan trọng để cải thiện hiệu năng hệ thống. Bằng cách áp dụng các kỹ thuật caching phù hợp và tối ưu ORM, mình có thể giảm đáng kể tải cho cơ sở dữ liệu và cải thiện thời gian phản hồi cho người dùng.

Tuy nhiên, caching cũng đi kèm với những thách thức về tính nhất quán và quản lý bộ nhớ. Việc lựa chọn chiến lược caching và invalidation pattern phù hợp là rất quan trọng để đảm bảo hệ thống hoạt động hiệu quả và đáng tin cậy.


Câu hỏi hay gặp

Cache miss rất cao (~80%), có đáng cache không?

Trả lời: Nếu data thay đổi liên tục (real-time feed, stock price) thì cache miss cao là bình thường, cân nhắc bỏ cache. Nếu data ổn nhưng miss cao: kiểm tra TTL quá ngắn, key cardinality quá cao (mỗi user một key), hoặc cache eviction vì memory nhỏ.

Redis KEYS * chạy chậm production, thay bằng gì?

Trả lời: SCAN (cursor-based, không block). KEYS block Redis vì single-threaded, 10M key = hàng giây freeze. Tương tự dùng UNLINK thay DEL cho batch delete (async free memory).

Cache stampede (thundering herd) xảy ra khi nào?

Trả lời: Khi cache expire đồng thời cho hot key → hàng trăm request cùng query DB → DB overload. Giải pháp: TTL jitter (thêm random ±10% vào TTL), mutex/lock (chỉ 1 request rebuild cache), hoặc stale-while-revalidate (trả data cũ trong khi refresh).


Bài tiếp theo: Tối ưu hóa cho cơ sở dữ liệu SQL, MySQL InnoDB và PostgreSQL tuning chuyên sâu.