Trong năm bài đầu tiên của series, chúng ta đã 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, chúng ta 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, chúng ta 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'),
];

// Sử dụng trong service provider
namespace App\Providers;

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

class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        // Cấu hình Redis cache với TTL mặc định là 10 phút
        Cache::setDefaultCacheTime(600); // 10 phút
    }
}

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();

// Hoặc sử dụng Redis để xóa theo pattern
Redis::command('EVAL', [
    "local keys = redis.call('keys', ARGV[1]) for i=1,#keys,5000 do redis.call('del', unpack(keys, i, math.min(i+4999, #keys))) end return true",
    0,
    "product:*"
]);

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, chúng ta 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;
}

Kết luậ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, chúng ta 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.

Trong bài viết tiếp theo, chúng ta sẽ tìm hiểu về các chiến lược sharding và partitioning để scale cơ sở dữ liệu theo chiều ngang, giúp hệ thống có thể xử lý khối lượng dữ liệu và traffic lớn hơn.