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.