HardТеория9 min

Redis

Подключение к Redis, команды, pub/sub, Lua-скрипты, pipelines, транзакции, rate limiting с Redis

Redis -- высокопроизводительное хранилище данных в памяти, используемое в Laravel для кэширования, очередей, сессий, broadcasting и rate limiting. Laravel предоставляет удобный интерфейс для работы с Redis через facade Redis.

Подключение к Redis

Конфигурация

// config/database.php
'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'),  // phpredis or predis

    'options' => [
        'cluster' => env('REDIS_CLUSTER', 'redis'),
        'prefix' => env('REDIS_PREFIX', \Illuminate\Support\Str::slug(
            env('APP_NAME', 'laravel'), '_') . '_database_'
        ),
    ],

    'default' => [
        'url' => env('REDIS_URL'),
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'username' => env('REDIS_USERNAME'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_DB', '0'),
    ],

    'cache' => [
        'url' => env('REDIS_URL'),
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'username' => env('REDIS_USERNAME'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_CACHE_DB', '1'),
    ],

    'queue' => [
        'url' => env('REDIS_URL'),
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'username' => env('REDIS_USERNAME'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_QUEUE_DB', '2'),
    ],

    'session' => [
        'url' => env('REDIS_URL'),
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'username' => env('REDIS_USERNAME'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_SESSION_DB', '3'),
    ],
],

Клиенты Redis

phpredis (рекомендуется) -- C-расширение, высокая производительность.

# Install phpredis extension
pecl install redis

predis -- PHP-пакет, не требует расширения.

composer require predis/predis

Кластеры Redis

'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'),

    'clusters' => [
        'default' => [
            [
                'host' => env('REDIS_HOST', 'localhost'),
                'password' => env('REDIS_PASSWORD'),
                'port' => env('REDIS_PORT', 6379),
                'database' => 0,
            ],
        ],
    ],

    'options' => [
        'cluster' => env('REDIS_CLUSTER', 'redis'),
    ],
],

Основные команды Redis

use Illuminate\Support\Facades\Redis;

// ============ Строки (Strings) ============

// Set a value
Redis::set('key', 'value');

// Set with expiration (seconds)
Redis::setex('key', 3600, 'value');

// Set only if key does NOT exist
Redis::setnx('key', 'value');

// Get a value
$value = Redis::get('key');

// Delete a key
Redis::del('key');
Redis::del(['key1', 'key2', 'key3']);

// Check if key exists
$exists = Redis::exists('key');

// Set expiration
Redis::expire('key', 3600);    // In seconds
Redis::expireat('key', time() + 3600); // Unix timestamp

// Get remaining TTL
$ttl = Redis::ttl('key');

// Increment / Decrement
Redis::incr('counter');
Redis::incrby('counter', 5);
Redis::decr('counter');
Redis::decrby('counter', 5);
Redis::incrbyfloat('balance', 1.5);

// Multiple get/set
Redis::mset(['key1' => 'val1', 'key2' => 'val2']);
$values = Redis::mget(['key1', 'key2']);

Работа с хешами (Hashes)

use Illuminate\Support\Facades\Redis;

// Set hash fields
Redis::hset('user:1', 'name', 'John');
Redis::hset('user:1', 'email', '[email protected]');

// Set multiple hash fields
Redis::hmset('user:1', [
    'name' => 'John',
    'email' => '[email protected]',
    'age' => 30,
]);

// Get a hash field
$name = Redis::hget('user:1', 'name');

// Get multiple hash fields
$data = Redis::hmget('user:1', ['name', 'email']);

// Get all hash fields
$user = Redis::hgetall('user:1');
// Returns: ['name' => 'John', 'email' => '[email protected]', 'age' => '30']

// Check if field exists
$exists = Redis::hexists('user:1', 'name');

// Delete a field
Redis::hdel('user:1', 'age');

// Increment hash field
Redis::hincrby('user:1', 'login_count', 1);

Работа со списками (Lists)

use Illuminate\Support\Facades\Redis;

// Push to the right (end)
Redis::rpush('queue:emails', 'email1');
Redis::rpush('queue:emails', 'email2', 'email3');

// Push to the left (beginning)
Redis::lpush('queue:emails', 'email0');

// Pop from the left (FIFO)
$email = Redis::lpop('queue:emails');

// Pop from the right (LIFO)
$email = Redis::rpop('queue:emails');

// Blocking pop (wait for an element)
$result = Redis::blpop(['queue:emails'], 10); // Wait up to 10 seconds

// Get list length
$length = Redis::llen('queue:emails');

// Get element by index
$element = Redis::lindex('queue:emails', 0);

// Get range of elements
$elements = Redis::lrange('queue:emails', 0, -1); // All elements

// Trim list
Redis::ltrim('queue:emails', 0, 99); // Keep only first 100

Работа с множествами (Sets)

use Illuminate\Support\Facades\Redis;

// Add members
Redis::sadd('tags:post:1', 'php', 'laravel', 'redis');

// Check membership
$isMember = Redis::sismember('tags:post:1', 'php'); // true

// Get all members
$tags = Redis::smembers('tags:post:1');

// Remove a member
Redis::srem('tags:post:1', 'redis');

// Set operations
$union = Redis::sunion('tags:post:1', 'tags:post:2');
$intersect = Redis::sinter('tags:post:1', 'tags:post:2');
$diff = Redis::sdiff('tags:post:1', 'tags:post:2');

// Random member
$random = Redis::srandmember('tags:post:1');

// Set size
$count = Redis::scard('tags:post:1');

Работа с сортированными множествами (Sorted Sets)

use Illuminate\Support\Facades\Redis;

// Add members with scores
Redis::zadd('leaderboard', 100, 'player1');
Redis::zadd('leaderboard', 250, 'player2');
Redis::zadd('leaderboard', 150, 'player3');

// Get rank (0-based, ascending)
$rank = Redis::zrank('leaderboard', 'player2');

// Get reverse rank (descending)
$rank = Redis::zrevrank('leaderboard', 'player2'); // 0 (top)

// Get score
$score = Redis::zscore('leaderboard', 'player2'); // 250

// Get range by rank (ascending)
$top3 = Redis::zrange('leaderboard', 0, 2);

// Get range by rank (descending) with scores
$top3 = Redis::zrevrange('leaderboard', 0, 2, 'WITHSCORES');

// Get range by score
$highScorers = Redis::zrangebyscore('leaderboard', 100, 200);

// Increment score
Redis::zincrby('leaderboard', 50, 'player1'); // Now 150

// Remove member
Redis::zrem('leaderboard', 'player1');

// Count members in score range
$count = Redis::zcount('leaderboard', 100, 200);

// Set size
$total = Redis::zcard('leaderboard');

Использование конкретного соединения

use Illuminate\Support\Facades\Redis;

// Use a specific connection
$value = Redis::connection('cache')->get('key');

// Use the default connection
$value = Redis::connection()->get('key');
// Or simply:
$value = Redis::get('key');

Pub/Sub (Публикация/Подписка)

Публикация сообщений

use Illuminate\Support\Facades\Redis;

// Publish a message to a channel
Redis::publish('chat-room', json_encode([
    'user' => 'John',
    'message' => 'Hello everyone!',
    'timestamp' => now()->toISOString(),
]));

Подписка на канал

<?php

declare(strict_types=1);

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;

final class SubscribeToChat extends Command
{
    protected $signature = 'chat:subscribe';
    protected $description = 'Subscribe to Redis chat channel';

    public function handle(): void
    {
        $this->info('Subscribed to chat-room channel...');

        // subscribe() blocks the process
        Redis::subscribe(['chat-room'], function (string $message, string $channel) {
            $data = json_decode($message, true);
            $this->line("[{$channel}] {$data['user']}: {$data['message']}");
        });
    }
}

Pattern Subscribe

// Subscribe to multiple channels using a pattern
Redis::psubscribe(['chat-room.*'], function (string $message, string $channel) {
    // Matches: chat-room.general, chat-room.vip, etc.
    $this->line("[{$channel}] {$message}");
});

Важно: subscribe() и psubscribe() блокируют выполнение. Их нужно запускать в отдельном процессе (Artisan command, supervisor).

Lua-скрипты

Lua-скрипты выполняются атомарно на сервере Redis, что гарантирует целостность при конкурентном доступе.

use Illuminate\Support\Facades\Redis;

// Simple Lua script
$result = Redis::eval(
    "return redis.call('get', KEYS[1])",
    1,       // Number of KEYS arguments
    'mykey'  // KEYS[1]
);

// Atomic increment with limit
$result = Redis::eval(<<<'LUA'
    local current = tonumber(redis.call('get', KEYS[1]) or 0)
    local limit = tonumber(ARGV[1])

    if current >= limit then
        return 0  -- Limit reached
    end

    redis.call('incr', KEYS[1])
    return 1  -- Success
LUA, 1, 'api:counter', 100);  // KEYS[1] = 'api:counter', ARGV[1] = 100

// Atomic "check and set" pattern
$result = Redis::eval(<<<'LUA'
    local current = redis.call('get', KEYS[1])
    if current == ARGV[1] then
        redis.call('set', KEYS[1], ARGV[2])
        return 1
    end
    return 0
LUA, 1, 'status', 'pending', 'processing');

// Rate limiter in Lua (atomic)
$allowed = Redis::eval(<<<'LUA'
    local key = KEYS[1]
    local limit = tonumber(ARGV[1])
    local window = tonumber(ARGV[2])

    local current = tonumber(redis.call('get', key) or 0)

    if current >= limit then
        return 0
    end

    if current == 0 then
        redis.call('set', key, 1, 'EX', window)
    else
        redis.call('incr', key)
    end

    return 1
LUA, 1, 'rate:user:1', 60, 60); // 60 requests per 60 seconds

Pipelines

Pipelines позволяют отправить несколько команд за один сетевой вызов, значительно уменьшая latency.

use Illuminate\Support\Facades\Redis;

// Pipeline: commands are sent in a single network roundtrip
$results = Redis::pipeline(function ($pipe) {
    for ($i = 0; $i < 1000; $i++) {
        $pipe->set("key:{$i}", "value:{$i}");
    }
});

// Read multiple keys in one roundtrip
$results = Redis::pipeline(function ($pipe) {
    $pipe->get('user:1:name');
    $pipe->get('user:1:email');
    $pipe->hgetall('user:1:settings');
    $pipe->smembers('user:1:roles');
});
// $results[0] = name, $results[1] = email, etc.

Важно: Pipeline НЕ является транзакцией. Команды выполняются последовательно, но между ними могут выполниться команды других клиентов.

Транзакции (MULTI/EXEC)

Транзакции Redis гарантируют, что набор команд выполнится атомарно.

use Illuminate\Support\Facades\Redis;

// Transaction: all commands execute atomically
$results = Redis::transaction(function ($tx) {
    $tx->set('key1', 'value1');
    $tx->set('key2', 'value2');
    $tx->incr('counter');
});

// Transaction with WATCH (optimistic locking)
Redis::watch('user:1:balance');

$balance = Redis::get('user:1:balance');

if ($balance >= 100) {
    $results = Redis::transaction(function ($tx) use ($balance) {
        $tx->set('user:1:balance', $balance - 100);
        $tx->incr('user:1:purchases');
    });

    if ($results === null) {
        // Transaction failed (key was modified by another client)
        // Retry logic needed
    }
}

Pipeline vs Transaction:

Характеристика Pipeline Transaction
Атомарность Нет Да (MULTI/EXEC)
Сетевых вызовов 1 1
Другие клиенты Могут выполнять между командами Заблокированы до EXEC
Использование Массовые операции без гарантий Критичные атомарные операции

Rate Limiting с Redis

Laravel предоставляет встроенный Rate Limiter на основе Redis.

Middleware Rate Limiting

<?php

declare(strict_types=1);

namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Global API rate limit
        RateLimiter::for('api', function (Request $request) {
            return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
        });

        // Custom rate limit
        RateLimiter::for('uploads', function (Request $request) {
            return $request->user()->isPremium()
                ? Limit::none()
                : Limit::perMinute(10)->by($request->user()->id);
        });

        // Multiple limits
        RateLimiter::for('login', function (Request $request) {
            return [
                Limit::perMinute(5)->by($request->ip()),
                Limit::perHour(30)->by($request->ip()),
            ];
        });

        // Sliding window rate limit
        RateLimiter::for('api-v2', function (Request $request) {
            return Limit::perMinute(100)
                ->by($request->user()?->id ?: $request->ip())
                ->response(function (Request $request, array $headers) {
                    return response()->json([
                        'message' => 'Слишком много запросов. Попробуйте позже.',
                    ], 429)->withHeaders($headers);
                });
        });
    }
}

Программный Rate Limiter

use Illuminate\Support\Facades\RateLimiter;

// Attempt an action with rate limiting
$executed = RateLimiter::attempt(
    key: 'send-email:' . $user->id,
    maxAttempts: 5,
    callback: function () use ($user) {
        // Send the email
    },
    decaySeconds: 60, // Reset after 60 seconds
);

if (! $executed) {
    $seconds = RateLimiter::availableIn('send-email:' . $user->id);
    return "Попробуйте через {$seconds} секунд.";
}

// Manual rate limit check
$key = 'api-call:' . $user->id;

if (RateLimiter::tooManyAttempts($key, maxAttempts: 10)) {
    $seconds = RateLimiter::availableIn($key);
    abort(429, "Попробуйте через {$seconds} секунд.");
}

RateLimiter::hit($key, decaySeconds: 60);

// Clear rate limiter
RateLimiter::clear('send-email:' . $user->id);

// Get remaining attempts
$remaining = RateLimiter::remaining('send-email:' . $user->id, 5);

Redis для кэширования

// config/cache.php
'stores' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => env('CACHE_STORE', 'cache'),
        'lock_connection' => env('CACHE_LOCK_CONNECTION', 'default'),
    ],
],
use Illuminate\Support\Facades\Cache;

// Cache with Redis
Cache::store('redis')->put('key', 'value', now()->addHours(1));
$value = Cache::store('redis')->get('key');

// Remember (get or set)
$users = Cache::store('redis')->remember('users:active', 3600, function () {
    return User::where('active', true)->get();
});

// Tags (Redis and Memcached only)
Cache::tags(['users', 'admins'])->put('admin:1', $admin, 3600);
Cache::tags(['users'])->flush(); // Flush all user-related cache

Атомарные блокировки (Atomic Locks)

use Illuminate\Support\Facades\Cache;

// Acquire a lock
$lock = Cache::lock('processing-order', 10); // 10 seconds

if ($lock->get()) {
    try {
        // Perform exclusive operation
    } finally {
        $lock->release();
    }
}

// Block until lock is available
$lock = Cache::lock('processing-order', 10);

$lock->block(5, function () {
    // Lock acquired, execute. Block up to 5 seconds.
});

// Using owner token for distributed locks
$lock = Cache::lock('deployment', 600);

if ($lock->get()) {
    $owner = $lock->owner();
    // Pass $owner to another process/server

    // On another process:
    Cache::restoreLock('deployment', $owner)->release();
}

Тестирование

<?php

declare(strict_types=1);

namespace Tests\Feature;

use Illuminate\Support\Facades\Redis;
use Tests\TestCase;

final class RedisTest extends TestCase
{
    public function test_redis_caching(): void
    {
        Redis::flushdb(); // Clean test database

        Redis::set('test:key', 'test:value');

        $this->assertEquals('test:value', Redis::get('test:key'));
    }

    public function test_rate_limiting(): void
    {
        $user = \App\Models\User::factory()->create();

        // Make 60 requests (limit)
        for ($i = 0; $i < 60; $i++) {
            $response = $this->actingAs($user)
                ->getJson('/api/data');
            $response->assertOk();
        }

        // 61st request should be rate limited
        $response = $this->actingAs($user)
            ->getJson('/api/data');
        $response->assertStatus(429);
    }
}

Проверь себя

Что произойдёт, если вызвать subscribe() в обычном HTTP-запросе Laravel?

Почему Lua-скрипты предпочтительнее транзакций Redis для сложных атомарных операций?

Для чего рекомендуется использовать РАЗНЫЕ databases Redis (0, 1, 2, 3) для cache, queue, session?

Какой тип данных Redis лучше всего подходит для реализации таблицы лидеров (leaderboard)?

Чем Redis pipeline отличается от Redis transaction?