Redis Deep Dive Cho Laravel Developers

· 11 min read

Hầu hết Laravel developers dùng Redis như key-value store đơn giản: Cache::put()Cache::get(). Nhưng Redis là data structure server — hỗ trợ sorted sets, lists, streams, pub/sub, và atomic operations. Hiểu chúng mở ra những pattern mạnh mẽ mà database truyền thống khó làm được.

Bài này đi sâu vào từng data structure, khi nào dùng chúng, và production patterns bạn cần biết.

Redis Data Structures — Tổng Quan

Structure Use Case Laravel Feature Complexity
Strings Cache, counters, locks Cache::get/put O(1)
Hashes Object storage, user sessions Redis::hset/hget O(1) per field
Lists Queues, recent items, activity feeds Queue driver O(1) push/pop
Sets Unique collections, tagging Tag-based cache O(1) add/remove
Sorted Sets Leaderboards, trending, scheduling Custom O(log N)
HyperLogLog Đếm unique visitors Custom O(1), ~12KB
Streams Event streaming, message queue Custom O(1) per operation

Strings: Không Chỉ Cache

String là type cơ bản nhất nhưng đa năng nhất:

// Caching (bạn đã biết)
Cache::put('post_laravel-tips', $htmlContent, now()->addHours(6));
Cache::get('post_laravel-tips');

// Atomic counters — thread-safe, không cần locks
Redis::incr('page_views:posts:42');        // +1
Redis::incrby('api_calls:user:5', 10);     // +10
Redis::decr('stock:product:99');           // -1

// Kiểm tra counter
$views = (int) Redis::get('page_views:posts:42'); // "1234"

Tại sao dùng Redis counter thay vì DB? UPDATE posts SET views = views + 1 tạo row lock trên mỗi page view. Redis INCR là atomic, không lock, xử lý hàng nghìn increments/giây. Sync về DB mỗi 5 phút bằng scheduled job.

// Scheduled sync: Redis → Database
class SyncPageViewsToDatabase extends Command
{
    public function handle(): int
    {
        $keys = Redis::keys('page_views:posts:*');

        foreach ($keys as $key) {
            $postId = Str::afterLast($key, ':');
            $views = (int) Redis::getdel($key); // Get rồi xóa atomically

            if ($views > 0) {
                Post::where('id', $postId)->increment('views', $views);
            }
        }

        return Command::SUCCESS;
    }
}

Distributed Locks

// Laravel Cache Lock — built trên Redis SETNX
$lock = Cache::lock('processing:order:42', 30); // 30 giây timeout

if ($lock->get()) {
    try {
        // Chỉ 1 process xử lý order này tại một thời điểm
        $this->processOrder($order);
    } finally {
        $lock->release();
    }
}

// Hoặc block chờ lock (tối đa 10 giây)
$lock = Cache::lock('processing:order:42', 30);
$lock->block(10, function () use ($order) {
    $this->processOrder($order);
});

Khi nào cần lock: Prevent double-processing (payment, order fulfillment), serialize access to shared resources, ensure exactly-once execution.

Hashes: Lưu Objects Hiệu Quả

Hash lưu key-value pairs bên trong một Redis key. Tốt cho objects khi bạn cần update từng field mà không serialize/deserialize toàn bộ:

// Lưu user session data — update riêng từng field
Redis::hmset('user:1:profile', [
    'name' => 'John',
    'email' => 'john@example.com',
    'plan' => 'premium',
    'last_active' => now()->timestamp,
]);

// Đọc một field
Redis::hget('user:1:profile', 'plan'); // "premium"

// Đọc nhiều fields
Redis::hmget('user:1:profile', ['name', 'email']); // ["John", "john@example.com"]

// Đọc tất cả
Redis::hgetall('user:1:profile'); // { name: John, email: ..., plan: ..., last_active: ... }

// Increment atomic trên một field
Redis::hincrby('user:1:profile', 'login_count', 1);

Hash vs String cho objects:

// String: serialize toàn bộ (phải đọc + ghi lại toàn bộ khi update 1 field)
Cache::put('user:1', json_encode($userData));

// Hash: update từng field (hiệu quả hơn khi chỉ update 1-2 fields)
Redis::hset('user:1', 'last_active', now()->timestamp);

Shopping Cart Với Hash

class RedisCartService
{
    public function addItem(int $userId, int $productId, int $quantity): void
    {
        Redis::hincrby("cart:{$userId}", $productId, $quantity);
        Redis::expire("cart:{$userId}", 86400 * 7); // 7 ngày
    }

    public function removeItem(int $userId, int $productId): void
    {
        Redis::hdel("cart:{$userId}", $productId);
    }

    public function getCart(int $userId): array
    {
        $cart = Redis::hgetall("cart:{$userId}");
        // { "42": "2", "99": "1" } → product_id => quantity
        return array_map('intval', $cart);
    }

    public function getTotal(int $userId): int
    {
        $cart = $this->getCart($userId);
        $productIds = array_keys($cart);

        if (empty($productIds)) return 0;

        $prices = Product::whereIn('id', $productIds)->pluck('price_in_cents', 'id');

        return collect($cart)->sum(fn ($qty, $id) => ($prices[$id] ?? 0) * $qty);
    }
}

Sorted set = set với score. Tự động sắp xếp theo score. Perfect cho rankings:

class TrendingPostService
{
    private string $key = 'trending:posts';

    public function recordView(Post $post): void
    {
        // Mỗi view tăng score +1. ZINCRBY atomic.
        Redis::zincrby($this->key, 1, $post->id);

        // Giữ chỉ top 50 — xóa những post ngoài top 50
        Redis::zremrangebyrank($this->key, 0, -(51));
    }

    public function getTrending(int $limit = 10): Collection
    {
        // ZREVRANGE: lấy theo score giảm dần (nhiều views nhất trước)
        $postIds = Redis::zrevrange($this->key, 0, $limit - 1);

        if (empty($postIds)) return collect();

        return Post::whereIn('id', $postIds)->get()
            ->sortBy(fn ($p) => array_search($p->id, $postIds));
    }

    public function getScore(Post $post): int
    {
        return (int) Redis::zscore($this->key, $post->id);
    }

    // Reset trending mỗi tuần
    public function reset(): void
    {
        Redis::del($this->key);
    }
}

Giải thích complexity: ZINCRBY là O(log N), ZREVRANGE là O(log N + M) (M = items trả về). Với 50 items, gần như O(1). Cực nhanh cho leaderboards.

// Dùng key theo tuần để trending tự reset
class WeeklyTrendingService
{
    private function key(): string
    {
        return 'trending:posts:week:' . now()->weekOfYear;
    }

    public function recordView(Post $post): void
    {
        $key = $this->key();
        Redis::zincrby($key, 1, $post->id);
        Redis::expire($key, 86400 * 8); // Tự xóa sau 8 ngày
    }
}

Lists: Activity Feed & Recent Items

List là linked list — push/pop ở cả hai đầu O(1):

class ActivityFeed
{
    public function record(User $user, string $action, array $data): void
    {
        $entry = json_encode([
            'action' => $action,
            'data' => $data,
            'timestamp' => now()->timestamp,
        ]);

        $key = "feed:{$user->id}";
        Redis::lpush($key, $entry);           // Push vào đầu (newest first)
        Redis::ltrim($key, 0, 99);            // Giữ 100 mới nhất (tự xóa cũ)
    }

    public function get(User $user, int $page = 1, int $perPage = 20): array
    {
        $start = ($page - 1) * $perPage;
        $end = $start + $perPage - 1;

        return array_map(
            fn ($e) => json_decode($e, true),
            Redis::lrange("feed:{$user->id}", $start, $end),
        );
    }
}

Tại sao List thay vì DB? Activity feeds write-heavy (mỗi action tạo entry) và read-recent (chỉ xem 20-50 gần nhất). List optimize cho chính xác pattern này. DB tốt cho query phức tạp, nhưng INSERT + SELECT ORDER BY + LIMIT chậm hơn LPUSH + LRANGE.

Pub/Sub: Real-Time Events

Redis Pub/Sub cho phép publish events và subscribe channels. Lightweight messaging:

// Publisher (trong Laravel code)
Redis::publish('notifications', json_encode([
    'user_id' => $user->id,
    'type' => 'new_comment',
    'message' => "Có comment mới trên bài viết của bạn",
]));

// Subscriber (trong Artisan command chạy long-running)
class ListenNotifications extends Command
{
    protected $signature = 'notifications:listen';

    public function handle(): int
    {
        Redis::subscribe(['notifications'], function (string $message) {
            $data = json_decode($message, true);
            $this->info("Notification cho user {$data['user_id']}: {$data['message']}");

            // Gửi WebSocket event, push notification, etc.
            broadcast(new UserNotified($data['user_id'], $data));
        });

        return Command::SUCCESS;
    }
}

Lưu ý: Pub/Sub fire-and-forget — nếu subscriber offline, message mất. Cho guaranteed delivery, dùng Redis Streams hoặc Laravel Queues.

Rate Limiting

Laravel có built-in rate limiter dùng Redis:

use Illuminate\Support\Facades\RateLimiter;

$executed = RateLimiter::attempt(
    key: 'send-email:' . $user->id,
    maxAttempts: 5,
    callback: fn () => $user->notify(new SomeNotification()),
    decaySeconds: 60,
);

if (!$executed) {
    // Rate limited — 5 emails/phút/user
    Log::info('Rate limited email for user ' . $user->id);
}

Kiểm Tra Remaining

$remaining = RateLimiter::remaining('api-calls:' . $user->id, 100);
$retryAfter = RateLimiter::availableIn('api-calls:' . $user->id);

Lua Scripting: Atomic Multi-Step Operations

Khi bạn cần nhiều Redis commands chạy atomically (không process khác chen vào giữa):

// Ví dụ: Kiểm tra stock VÀ giảm stock trong 1 atomic operation
$script = <<<'LUA'
    local stock = tonumber(redis.call('GET', KEYS[1]) or 0)
    local requested = tonumber(ARGV[1])

    if stock >= requested then
        redis.call('DECRBY', KEYS[1], requested)
        return 1  -- Success
    end
    return 0  -- Not enough stock
LUA;

$success = Redis::eval($script, 1, "product:{$productId}:stock", $quantity);

if ($success) {
    // Stock đã bị giảm atomically — không race condition
    $this->createOrder($productId, $quantity);
} else {
    throw new InsufficientStockException();
}

Tại sao Lua thay vì GET rồi DECRBY?

// ❌ Race condition: 2 requests cùng GET stock=1, cả 2 thấy đủ, cả 2 DECRBY
$stock = Redis::get("product:42:stock"); // 1
if ($stock >= 1) {
    Redis::decrby("product:42:stock", 1); // Có thể -1!
}

// ✅ Lua script: atomic, chỉ 1 request thành công

Pipelining: Batch Commands

Mỗi Redis command = 1 network round trip. Pipeline gom nhiều commands = 1 round trip:

// ❌ Bad: 100 round trips (~100ms with 1ms latency)
foreach ($postIds as $id) {
    Redis::incr("post:{$id}:views");
}

// ✅ Good: 1 round trip (~1ms)
Redis::pipeline(function ($pipe) use ($postIds) {
    foreach ($postIds as $id) {
        $pipe->incr("post:{$id}:views");
    }
});

Khi nào pipeline: Khi bạn gọi > 5 Redis commands liên tiếp không phụ thuộc nhau. Giảm latency đáng kể khi Redis ở network khác (ElastiCache, remote server).

HyperLogLog: Đếm Unique Visitors

Cần đếm unique visitors mà không lưu từng IP? HyperLogLog dùng ~12KB bất kể số lượng items, với sai số ~0.81%:

// Thêm visitor
Redis::pfadd('visitors:2026-04-21', $visitorIp);
Redis::pfadd('visitors:2026-04-21', $anotherIp);
Redis::pfadd('visitors:2026-04-21', $visitorIp); // Duplicate, bị ignore

// Đếm unique
Redis::pfcount('visitors:2026-04-21'); // Xấp xỉ unique count

// Merge nhiều ngày
Redis::pfmerge('visitors:april', [
    'visitors:2026-04-01', 'visitors:2026-04-02', 'visitors:2026-04-03',
]);
Redis::pfcount('visitors:april'); // Unique visitors trong cả tháng 4

So sánh: Lưu IPs trong SET: 1 triệu IPs × ~45 bytes = ~45MB. HyperLogLog: luôn ~12KB. Trade-off: HyperLogLog chỉ đếm, không list được members.

Session Với Redis

// config/session.php
'driver' => 'redis',
'connection' => 'session', // Database riêng biệt

// config/database.php
'redis' => [
    'session' => [
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', 6379),
        'database' => env('REDIS_SESSION_DB', 1), // DB 1, khác với cache (DB 0)
    ],
],

Quan trọng: Dùng Redis database riêng cho sessions. Nếu sessions và cache dùng chung DB, cache:clear sẽ xóa tất cả sessions → tất cả users bị logout.

Production Best Practices

1. Key Naming Convention

{resource}:{id}:{field}
─────────
post:42:views
user:1:profile
trending:posts:week:15
cart:user:789
rate_limit:api:user:5

Convention nhất quán giúp debug (biết key nào thuộc feature nào) và quản lý (wildcard operations an toàn).

2. Luôn Set TTL

// ❌ Không TTL = dữ liệu ở đây mãi mãi → memory leak
Redis::set('temp:data', $value);

// ✅ Luôn set TTL cho dữ liệu tạm
Redis::setex('temp:data', 3600, $value); // 1 giờ

3. Tránh Keys Quá Lớn

// ❌ Lưu toàn bộ HTML page (500KB+) trong Redis
Cache::put('page:home', $hugeHtml, 3600);

// ✅ Cache fragments nhỏ
Cache::put('posts:recent:10', $recentPosts, 3600);

4. Monitor Memory

redis-cli info memory
# used_memory_human: 1.23G
# maxmemory_human: 2.00G
# maxmemory_policy: allkeys-lru

maxmemory-policy: Khi Redis đầy bộ nhớ, nó dùng policy để quyết định xóa key nào:

  • allkeys-lru — xóa key ít dùng nhất (recommended cho cache)
  • volatile-lru — xóa key ít dùng nhất có TTL
  • noeviction — trả lỗi khi đầy (dùng cho data quan trọng)

Kết Luận

Redis trong Laravel không chỉ là Cache::remember(). Dùng đúng data structure cho đúng bài toán:

  • Strings cho cache đơn giản, counters, và locks
  • Hashes cho objects cần truy cập/update từng field
  • Sorted Sets cho leaderboards, trending, scheduling
  • Lists cho queues, activity feeds, recent items
  • Sets cho unique collections, tagging
  • Pub/Sub cho real-time messaging (fire-and-forget)
  • Lua scripts cho atomic multi-step operations
  • Pipelines cho batch performance
  • HyperLogLog cho approximate unique counting

Production checklist:

  1. Tách Redis databases cho cache, sessions, queues
  2. Luôn set TTL
  3. Dùng pipeline khi > 5 commands liên tiếp
  4. Monitor memory usage
  5. Key naming convention nhất quán
  6. Dùng Lua cho atomic operations

Bình luận