Go + Redis: библиотека go-redis

Стандартный клиент для Go - github.com/redis/go-redis/v9. Он официальный, поддерживает все возможности Redis 7, имеет встроенный connection pool, pipeline, transactions, pub/sub, streams, cluster.

go get github.com/redis/go-redis/v9

В PHP два основных варианта: predis/predis (чистый PHP, ставится через composer без extension) и ext-redis (C-расширение, быстрее, но требует pecl). В Symfony-проектах удобно использовать \Redis через Symfony\Component\Cache\Adapter\RedisAdapter - получаешь PSR-6/PSR-16 API поверх любого из двух драйверов.

composer require predis/predis
# или
composer require symfony/cache

Подключение и connection pool

rdb := redis.NewClient(&redis.Options{
    Addr:         "localhost:6379",
    Password:     "",
    DB:           0,
    PoolSize:     10,                  // максимум коннектов
    MinIdleConns: 5,                   // прогретые коннекты
    DialTimeout:  5 * time.Second,
    ReadTimeout:  3 * time.Second,
    WriteTimeout: 3 * time.Second,
    PoolTimeout:  4 * time.Second,     // ожидание свободного коннекта
    MaxRetries:   3,                   // retry на network errors
})

if err := rdb.Ping(ctx).Err(); err != nil {
    log.Fatal("redis connection failed:", err)
}
<?php
declare(strict_types=1);

use Predis\Client;

$redis = new Client(
    parameters: [
        'scheme'  => 'tcp',
        'host'    => 'localhost',
        'port'    => 6379,
        'timeout' => 5.0,
        'read_write_timeout' => 3.0,
    ],
    options: [
        'parameters' => ['database' => 0],
        // connection pool в FPM/CLI обычно не нужен:
        // каждый запрос - свой процесс, коннект живёт пока обрабатывается запрос.
        // Для persistent connection см. 'persistent' => true в parameters.
    ],
);

try {
    $redis->ping();
} catch (\Predis\Connection\ConnectionException $e) {
    throw new \RuntimeException('redis connection failed: ' . $e->getMessage(), previous: $e);
}

В PHP-FPM нет горутин и общего pool - каждый воркер держит свой коннект. Persistent connection ('persistent' => true) реюзает TCP между запросами в рамках одного FPM-воркера. Под высокой нагрузкой это даёт основной выигрыш, аналогичный MinIdleConns в Go.

PoolSize обычно ставят равным числу одновременных горутин, которые могут активно выполнять Redis-команды. Для типичного HTTP-сервиса с 100 RPS и операциями <1ms подойдёт 10-20.

Основные операции

Strings

rdb.Set(ctx, "key", "value", 5*time.Minute)
val, err := rdb.Get(ctx, "key").Result()
if errors.Is(err, redis.Nil) {
    // ключ не существует - это нормальный кейс, не ошибка
}

redis.Nil - sentinel error. Игнорируйте его как «нет данных», а не как «сбой».

<?php
declare(strict_types=1);

$redis->setex('key', 300, 'value'); // TTL = 5 минут
$val = $redis->get('key');

if ($val === null) {
    // ключ отсутствует - предсказуемый cache miss, не ошибка
}

В predis get() возвращает null при отсутствии ключа (в phpredis - false). Это аналог redis.Nil в Go: не путай с настоящей ошибкой - проверяй явно тип возврата, а не пытайся ловить исключение.

Hashes со scan-ом в struct

rdb.HSet(ctx, "user:1", map[string]any{"name": "Alice", "age": 30})

var user struct {
    Name string `redis:"name"`
    Age  int    `redis:"age"`
}
if err := rdb.HGetAll(ctx, "user:1").Scan(&user); err != nil {
    return err
}

Тег redis: мапит поле струк на ключ хеша. Удобно для структур с десятками полей.

<?php
declare(strict_types=1);

$redis->hmset('user:1', ['name' => 'Alice', 'age' => 30]);

$data = $redis->hgetall('user:1');
// $data = ['name' => 'Alice', 'age' => '30']  - все значения строки

final readonly class User
{
    public function __construct(
        public string $name,
        public int $age,
    ) {}

    public static function fromHash(array $hash): self
    {
        return new self(
            name: $hash['name'] ?? '',
            age: (int) ($hash['age'] ?? 0),
        );
    }
}

$user = User::fromHash($data);

В PHP нет тегов на полях, как в Go-struct. Стандартный приём - именованный конструктор fromHash() (или Symfony Serializer с NameConverterInterface) для каста типов из Redis-строк.

Counter

views, err := rdb.Incr(ctx, "page:views").Result()
// атомарный инкремент, не нужен мьютекс
<?php
declare(strict_types=1);

$views = $redis->incr('page:views');
// атомарный инкремент, безопасен между FPM-воркерами

Pipelining

Каждая команда - round-trip (RTT). 100 SET = 100 RTT × 0.5ms = 50ms. С pipeline всё в одном запросе:

pipe := rdb.Pipeline()
for i := 0; i < 100; i++ {
    pipe.Set(ctx, fmt.Sprintf("key:%d", i), i, time.Hour)
}
cmds, err := pipe.Exec(ctx) // 1 RTT

Exec возвращает все ответы. Каждый cmd.Err() нужно проверить - pipeline не fail-fast: если одна команда упала, остальные всё равно выполнятся.

<?php
declare(strict_types=1);

$responses = $redis->pipeline(static function ($pipe): void {
    for ($i = 0; $i < 100; $i++) {
        $pipe->setex('key:' . $i, 3600, (string) $i);
    }
});
// $responses - массив ответов в порядке команд, 1 RTT

В predis pipeline() принимает замыкание - все команды внутри буферизуются и отправляются одним батчем. В phpredis есть multi(\Redis::PIPELINE) ... exec() - семантика та же.

Transactions через MULTI/EXEC

err := rdb.Watch(ctx, func(tx *redis.Tx) error {
    n, err := tx.Get(ctx, "counter").Int()
    if err != nil && !errors.Is(err, redis.Nil) {
        return err
    }
    _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
        pipe.Set(ctx, "counter", n+1, 0)
        return nil
    })
    return err
}, "counter")

Watch реализует optimistic locking: если ключ изменился между Get и Exec, транзакция проваливается с redis.TxFailedErr и можно повторить.

<?php
declare(strict_types=1);

use Predis\Transaction\AbortedMultiExecException;

try {
    $responses = $redis->transaction(['cas' => true, 'watch' => 'counter'], static function ($tx): void {
        $current = (int) ($tx->get('counter') ?? 0);
        $tx->multi();
        $tx->set('counter', (string) ($current + 1));
    });
} catch (AbortedMultiExecException) {
    // counter изменили между WATCH и EXEC - повторяем
}

В predis опция 'cas' => true (Check-And-Set) включает режим оптимистической блокировки: WATCH + чтение происходят до MULTI, а при изменении ключа транзакция отменяется через исключение. В phpredis это watch() + multi() + exec() напрямую.

Сериализация структур

type Session struct {
    UserID    string    `json:"user_id"`
    CreatedAt time.Time `json:"created_at"`
}

func SetSession(ctx context.Context, rdb *redis.Client, id string, sess Session) error {
    data, err := json.Marshal(sess)
    if err != nil {
        return err
    }
    return rdb.Set(ctx, "session:"+id, data, 24*time.Hour).Err()
}

func GetSession(ctx context.Context, rdb *redis.Client, id string) (*Session, error) {
    data, err := rdb.Get(ctx, "session:"+id).Bytes()
    if errors.Is(err, redis.Nil) {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }
    var sess Session
    return &sess, json.Unmarshal(data, &sess)
}

Для бо́льшей плотности и скорости - msgpack или protobuf. Бинарные форматы экономят 30-50% памяти Redis.

<?php
declare(strict_types=1);

use Predis\Client;

final readonly class Session
{
    public function __construct(
        public string $userId,
        public \DateTimeImmutable $createdAt,
    ) {}
}

final readonly class SessionRepository
{
    public function __construct(private Client $redis) {}

    public function set(string $id, Session $session): void
    {
        $payload = json_encode([
            'user_id'    => $session->userId,
            'created_at' => $session->createdAt->format(\DateTimeInterface::ATOM),
        ], JSON_THROW_ON_ERROR);

        $this->redis->setex('session:' . $id, 86400, $payload);
    }

    public function get(string $id): ?Session
    {
        $raw = $this->redis->get('session:' . $id);
        if ($raw === null) {
            return null;
        }

        $data = json_decode($raw, associative: true, flags: JSON_THROW_ON_ERROR);

        return new Session(
            userId: $data['user_id'],
            createdAt: new \DateTimeImmutable($data['created_at']),
        );
    }
}

Для бинарных форматов в PHP: msgpack/msgpack-php (extension) или rybakit/msgpack (чистый PHP). В Symfony - Symfony\Component\Serializer с MessagePackEncoder решает то же самое декларативно.

Context и timeouts

context.Context пробрасывается во все методы - это критично:

ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
defer cancel()

val, err := rdb.Get(ctx, "key").Result()

Если HTTP-handler отменён (клиент закрыл соединение), отменяется и Redis-команда. Это спасает от накопления зависших коннектов в pool, когда Redis тормозит.

<?php
declare(strict_types=1);

// В predis timeout задаётся на уровне коннекта - per-command таймаута нет.
$redis = new Predis\Client([
    'host'    => 'localhost',
    'port'    => 6379,
    'timeout' => 5.0,        // connection timeout
    'read_write_timeout' => 0.1, // read timeout - 100ms на команду
]);

try {
    $val = $redis->get('key');
} catch (\Predis\Connection\ConnectionException $e) {
    // таймаут чтения - fallback на источник правды
    $val = null;
}

В PHP-FPM нет context.Context: модель «один запрос = один воркер» уже изолирует время жизни. Таймаут задаётся на уровне TCP-сокета через read_write_timeout. Если клиент отключился - проверь через connection_aborted() или включи ignore_user_abort(false) для досрочного выхода.

Error handling

Типичные ошибки:

val, err := rdb.Get(ctx, "key").Result()
switch {
case errors.Is(err, redis.Nil):
    // нет ключа - нормальный путь cache miss
case errors.Is(err, context.DeadlineExceeded):
    // timeout - fallback на БД, метрика
case err != nil:
    // сетевая ошибка, OOM на сервере и т. д.
    log.Error("redis", "err", err)
}
<?php
declare(strict_types=1);

use Predis\Connection\ConnectionException;
use Predis\Response\ServerException;

try {
    $val = $redis->get('key');
    if ($val === null) {
        // нормальный cache miss - идём в БД
        return $this->loadFromDatabase($key);
    }

    return $val;
} catch (ConnectionException $e) {
    // сеть/таймаут - читаем источник правды и логируем
    $this->logger->warning('redis unavailable', ['error' => $e->getMessage()]);

    return $this->loadFromDatabase($key);
} catch (ServerException $e) {
    // OOM, WRONGTYPE, READONLY на реплике - тоже fallback
    $this->logger->error('redis server error', ['error' => $e->getMessage()]);

    return $this->loadFromDatabase($key);
}

В Go ошибки возвращаются значениями, в PHP - кидаются исключениями. Симметрия в обработке: redis.Nil = null, ConnectionException/DeadlineExceeded = timeout, ServerException = всё прочее. Логика fallback одинакова.

Правило: при ошибке Redis в кеше - fallback на источник правды (БД), а не ошибка пользователю. Кеш - оптимизация, не зависимость.

OpenTelemetry instrumenting

import "github.com/redis/go-redis/extra/redisotel/v9"

if err := redisotel.InstrumentTracing(rdb); err != nil {
    log.Fatal(err)
}
if err := redisotel.InstrumentMetrics(rdb); err != nil {
    log.Fatal(err)
}

Каждая Redis-команда станет client span с атрибутами db.system=redis, db.statement=GET key. Это ценно для трассировки: видно, сколько RTT в кеш ушло на запрос.

<?php
declare(strict_types=1);

use OpenTelemetry\API\Trace\TracerInterface;
use Predis\Client;

final readonly class TracedRedis
{
    public function __construct(
        private Client $redis,
        private TracerInterface $tracer,
    ) {}

    public function get(string $key): ?string
    {
        $span = $this->tracer->spanBuilder('redis.GET')
            ->setAttribute('db.system', 'redis')
            ->setAttribute('db.statement', 'GET ' . $key)
            ->startSpan();

        try {
            return $this->redis->get($key);
        } finally {
            $span->end();
        }
    }
}

Готовый OpenTelemetry-инструментатор для predis есть в open-telemetry/opentelemetry-auto-predis (auto-instrumentation через расширение ext-opentelemetry). Альтернатива - явная обёртка, как в примере, или middleware-pattern в Symfony через декорацию CacheInterface.

Типичные ошибки

  • Сравнение err == nil без обработки redis.Nil - cache miss возвращает не nil, а специальный redis.Nil. Без errors.Is(err, redis.Nil) будешь логировать «ошибки» при штатном промахе кеша.
  • Reuse одного *redis.Client забыт - создаётся новый клиент в каждом handler-е. Каждый - свой connection pool, под нагрузкой ловишь EMFILE: too many open files. Один клиент на приложение, передавать через DI.
  • PoolSize по умолчанию (10× CPU) - для большинства API избыточно: коннекты простаивают, БД-кеш растёт. На малом сервисе ставь 10-20, наблюдай за pool_hits/pool_misses в метриках.
  • Команды без ctx - rdb.Get(context.Background(), key) игнорирует отмену запроса. HTTP-handler закрыл клиент - Redis-команда висит в pool ещё секунды. Всегда пробрасывай r.Context() или ctx со своим deadline.
  • Pipelining как «магия скорости» вне batch-сценариев - для одиночной команды pipeline ничего не ускоряет; зато код становится сложнее (обработка ошибок на каждый Cmd). Используй когда есть ≥10 независимых команд на один HTTP-запрос.
  • MULTI/EXEC ≠ acid-транзакция как в SQL - внутри блока команды копятся локально, выполняются атомарно одним блоком, но не откатываются при ошибке одной команды. Это «pipeline с гарантией порядка», не SQL TRANSACTION.
  • JSON в каждый запрос - json.Marshal/Unmarshal крупных структур на горячем пути ест CPU больше самого Redis. Если кеш - горячий и часто читаемый - msgpack/gob/protobuf сокращают и payload, и CPU в 2-5 раз.

Мини-практика

Напиши сервис сессий: создание, чтение, удаление через go-redis. TTL 30 минут, обновляется при каждом обращении (sliding session). Используй pipeline для batch-операций, обработай redis.Nil как cache miss, добавь timeout 100ms через context. Подключи OpenTelemetry-инструментирование и посмотри спаны в Jaeger.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.