3. Redis. Архитектура и применение

Архитектура, структуры данных и паттерны в Go

Введение

Redis (Remote Dictionary Server) — это не просто key-value хранилище, а структурированный сервер данных в оперативной памяти, который по духу гораздо ближе к универсальному «швейцарскому ножу» распределенного бэкенда, чем к классической реляционной СУБД. Если простейший Memcached предоставляет вам лишь абстрактный map[string][]byte, то Redis предлагает богатую палитру встроенных структур данных, атомарные транзакции, pub/sub каналы, потоки сообщений (streams), геопространственные индексы и расширяемую экосистему модулей.

В современной Go-разработке Redis — отраслевой стандарт де-факто для высокопроизводительного кэширования, распределенной координации сервисов, rate limiting, очередей задач и управления состоянием сессий с задержками на уровне микросекунд.

Мы уже обсудили общую концепцию KV-хранилищ в статье 2. Key Value базы — сейчас пришло время детально разобрать лидера этого класса.


Архитектура Redis: однопоточный event loop

С момента появления и по сей день (с важными нюансами, о которых мы поговорим ниже) Redis использует модель однопоточного event loop для выполнения команд над структурами данных. Для многих инженеров, привыкших к многопоточности и пулам воркеров, это звучит парадоксально: «Как сервис, обслуживающий 100 000+ RPS на одном сервере, может быть однопоточным?».

Разгадка кроется в физике вычислений: узким местом in-memory систем является не вычислительная мощность CPU, а задержки синхронизации памяти (lock contention) и сетевой стек.

flowchart TD
    Clients["Сетевые соединения клиентов<br>(Go / Python / Java)"] --> EpollQueue["Очередь событий ядра ОС<br>(epoll_wait / kqueue / IOCP)"]
    EpollQueue --> MainLoop["Event Loop<br>(Единственный главный поток выполнения команд)"]
    MainLoop --> RespParser["Парсер RESP<br>и диспетчеризация команд"]
    RespParser --> MemOps["Выполнение команды в RAM<br>(хеш-таблицы, skiplist, quicklist)"]
    MemOps --> WriteBack["Формирование ответа<br>и буферизация в сокет"]
    WriteBack --> EpollQueue

    classDef default fill:#1e293b,stroke:#334155,color:#f8fafc;
    classDef loop fill:#312e81,stroke:#6366f1,color:#ffffff,stroke-width:2px;
    class MainLoop loop;
  

Как устроен этот конвейер:

  1. Сервер Redis регистрирует все клиентские сокеты в мультиплексоре ввода-вывода ядра ОС: epoll (в Linux), kqueue (в macOS/BSD) или IOCP (в Windows).
  2. Главный поток засыпает в системном вызове epoll_wait, не расходуя такты CPU впустую.
  3. Как только на одном или нескольких сокетах появляются входящие TCP-пакеты, ядро ОС пробуждает поток Redis.
  4. Поток последовательно считывает байты запроса, парсит спецификацию RESP, находит запрашиваемую команду в таблице команд и исполняет ее непосредственно в памяти.
  5. После формирования ответа поток буферизует его для отправки обратно клиенту и переходит к следующему событию.

Почему это работает феноменально быстро:

  • Нулевая синхронизация: Никаких мьютексов (pthread_mutex), спинлоков, CAS-инструкций и race conditions внутри структур данных. Нет риска взаимных блокировок (deadlocks) и затрат на когерентность процессорных кэшей (протокол MESI).
  • Максимальная локальность кэша (Cache Locality): Все данные активного потока идеально ложатся в L1d и L2 кэши выделенного ядра CPU.
  • Микросекундное исполнение: Большинство операций с хэш-таблицами требуют от 50 до 200 наносекунд — это быстрее, чем стоимость переключения контекста потоков ОС (context switch, занимающий 1–2 микросекунды).

Структуры данных: больше чем просто ключ-значение

Главное преимущество Redis — возможность оперировать специализированными структурами данных прямо на стороне сервера, не гоняя мегабайты сырых данных по сети.

Strings (строки)

Базовый универсальный тип. Может содержать любой бинарно-безопасный контент объемом до 512 МБ: текстовые строки, JSON-документы, сериализованные Protobuf-сообщения или сжатые изображения. Кроме того, строки поддерживают атомарные математические операции: INCR, INCRBY, DECR, INCRBYFLOAT.

Go
// Go: кэшируем ответ API на 60 секунд
val, err := rdb.Get(ctx, "api:user:42").Bytes()
if errors.Is(err, redis.Nil) {
    user := fetchUserFromDB(42)
    data, _ := json.Marshal(user)
    rdb.Set(ctx, "api:user:42", data, 60*time.Second)
    // ...
}

Lists (списки)

Двусвязные упорядоченные списки строк (под капотом сжатые структуры quicklist — гибрид ziplist и классического связанного списка). Идеальны для очередей задач (LPUSH + RPOP или блокирующий BRPOP) и лент активности:

Go
// Постановка задачи в очередь
rdb.LPush(ctx, "task_queue", taskData)

Sets (множества) и Sorted Sets (упорядоченные множества)

  • Set (множество) — неупорядоченная коллекция уникальных строк. Гарантирует $O(1)$ проверку наличия элемента (SISMEMBER), а также эффективные теоретико-множественные операции на стороне сервера: объединение (SUNION), пересечение (SINTER), разность (SDIFF).
  • Sorted Set (ZSet, упорядоченное множество) — каждый строковый элемент ассоциирован с числовым весом (Score типа float64). Элементы автоматически упорядочиваются по значению score. Внутри реализован как симбиоз SkipList (вероятностная структура списка с пропусками) и хэш-таблицы. Идеально подходит для лидербордов в играх, планировщиков отложенных задач и скользящих временных окон:
Go
// Обновление рейтинга в лидерборде
rdb.ZAdd(ctx, "leaderboard", redis.Z{Score: 1523, Member: "user:42"})
// Топ-10 игроков с наивысшим баллом
top, _ := rdb.ZRevRangeWithScores(ctx, "leaderboard", 0, 9).Result()

Hashes (хеши)

Отображение строковых полей на строковые значения (map[string]string). Позволяет хранить объекты предметной области (например, профиль пользователя) компактно и обновлять отдельные свойства без полной перезаписи и повторной сериализации всего JSON-объекта:

Go
// Хранение отдельных полей, атомарное обновление
rdb.HSet(ctx, "user:42", "name", "Alice", "email", "alice@example.com")
// Инкремент счётчика внутри хеша
rdb.HIncrBy(ctx, "user:42", "login_count", 1)

Streams

Аппенд-онли лог, похожий на Apache Kafka, но легковесный. Группы потребителей, подтверждение обработки. Прекрасен для легковесных событийных потоков и используется в библиотеках наподобие watermill.

Прочие

  • Bitmaps / HyperLogLog — побитовые операции (SETBIT, BITCOUNT) и вероятностный алгоритм HyperLogLog (PFADD, PFCOUNT), позволяющий с погрешностью ~0.81% оценивать кардинальность миллионов уникальных посетителей в фиксированных 12 КБ памяти.
  • Geospatial — GEOADD, GEORADIUS для координат, под капотом sorted set с Geohash.
  • Redis Stack: Модули RedisJSON, RediSearch, TimeSeries расширяют возможности до полноценной документной и полнотекстовой БД.

Модель персистентности: память vs долговечность

Redis — in-memory база, что порождает компромисс между скоростью и сохранностью данных при перезапуске. Реализовано два механизма.

RDB (Redis Database)

Снапшоты всей базы данных в файл dump.rdb. Redis вызывает fork(), и дочерний процесс обходит структуры, записывая их на диск. Благодаря copy-on-write родительский процесс может продолжать изменять данные, не влияя на дочерний.

Плюсы: Компактный формат, быстрое восстановление.
Минусы: Данные между снапшотами могут быть потеряны.

AOF (Append-Only File)

Журнал всех операций записи, в точности как WAL в PostgreSQL (см. Write Ahead Log в подразделе транзакций). Каждая команда записывается синхронно или асинхронно в лог. При перезагрузке — повторное проигрывание команд.

Плюсы: Максимальная сохранность данных (при appendfsync always теряется не более одной операции).
Минусы: Файл лога растёт, может быть больше, чем RDB. Redis делает периодическое сжатие AOF (rewrite).

flowchart LR
    subgraph RDB_Mechanism["RDB Снимки"]
        R1["fork() процесса"] --> R2["Copy-on-Write (COW)"]
        R2 --> R3["Файл dump.rdb на SSD"]
    end

    subgraph AOF_Mechanism["AOF Журнал"]
        A1["Команды модификации"] --> A2["Буфер aof_buf"]
        A2 --> A3["appendfsync (always/everysec/no)"]
        A3 --> A4["Файл appendonly.aof"]
    end

    classDef rdb fill:#1e293b,stroke:#38bdf8,color:#f8fafc;
    classDef aof fill:#1e293b,stroke:#10b981,color:#f8fafc;
    class RDB_Mechanism rdb;
    class AOF_Mechanism aof;
  

Репликация и кластеризация

Redis предоставляет несколько уровней масштабирования.

Master-Replica

Асинхронная репликация: все записи на мастере воспроизводятся на одной или нескольких репликах. Чтение можно распределять по репликам. Если мастер падает, можно вручную или через Sentinel повысить реплику.

Sentinel

Отдельный процесс, который следит за состоянием серверов Redis, осуществляет автоматический failover и уведомляет клиентов о новом мастере. Go-драйвер go-redis умеет подключаться к Sentinel и автоматически обнаруживать мастер.

Redis Cluster

Шардирование данных по N нодам. Ключи распределяются в 16384 хеш-слотов. Каждая нода отвечает за диапазон слотов. Клиент определяет нужную ноду по хешу ключа и обращается напрямую. При добавлении/удалении нод слоты мигрируют. Жертвуем транзакциями, охватывающими ключи в разных слотах, но получаем горизонтальное масштабирование на сотни нод.

Для Go-разработчика важно включить route by latency и route randomly в драйвере кластера, чтобы балансировать запросы.


Паттерны использования в Go

Кэширование

Самый частый сценарий. Кэшируем результаты запросов к PostgreSQL, ответы внешних API, вычисленные значения. Используем TTL и инвалидацию по ключам или префиксам. Подробнее — 7. Кэширование поверх БД.

Go
func GetUser(ctx context.Context, id int) (*User, error) {
    key := fmt.Sprintf("user:%d", id)
    data, err := rdb.Get(ctx, key).Bytes()
    if err == nil {
        var user User
        json.Unmarshal(data, &user)
        return &user, nil
    }
    // Cache miss
    user, err := db.QueryUser(id)
    if err != nil { return nil, err }
    serialized, _ := json.Marshal(user)
    rdb.Set(ctx, key, serialized, 10*time.Minute)
    return user, nil
}

Распределённые блокировки

Паттерн Redlock (с оговорками) или более простой вариант для не критичных по консистентности задач. Атомарный SET lock_name random_value NX PX 5000. При освобождении — Lua-скрипт, проверяющий значение, чтобы не снять чужую блокировку.

Go
lockKey := "lock:process_invoices"
token := randomHex(16)
acquired, _ := rdb.SetNX(ctx, lockKey, token, 30*time.Second).Result()
if !acquired {
    return fmt.Errorf("another instance is processing")
}
defer func() {
    // Гарантированно снимаем только свою блокировку
    script := redis.NewScript(`
        if redis.call("GET", KEYS[1]) == ARGV[1] then
            return redis.call("DEL", KEYS[1])
        else
            return 0
        end
    `)
    script.Run(ctx, rdb, []string{lockKey}, token)
}()

Rate Limiting

Используем sliding window или token bucket. Простой вариант: INCR user:api:count:123 и EXPIRE на окно ограничения. Если значение превысило лимит — отказ. Более точный алгоритм с помощью sorted sets. Связано с темой 12. Idempotency и БД, так как защищает от повторных запросов и злоупотребления.

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

Для real-time уведомлений, инвалидации кэша, обновлений на клиенте. Go-драйвер предоставляет канал *redis.PubSub. Подписываемся на канал и получаем сообщения в горутине.

Go
pubsub := rdb.Subscribe(ctx, "cache_invalidation")
ch := pubsub.Channel()
go func() {
    for msg := range ch {
        invalidateLocalCache(msg.Payload)
    }
}()

Сессии пользователей

Хранение сессий с TTL. Гибкая структура: хеш, где поля — детали сессии.


Mechanical Sympathy: Redis с точки зрения процессора и ОС

Понимание физического взаимодействия Go-сервиса с Redis критично для достижения высокой производительности.

  1. Сетевое взаимодействие. Когда вы делаете rdb.Get(), драйвер go-redis отправляет команду RESP через TCP-сокет. Эта операция использует неблокирующий I/O внутри Go (netpoller, epoll). Горутина, ожидающая ответа, не занимает тред ОС — она паркуется, и планировщик Go может использовать тот же тред для другой горутины. Как только данные приходят, netpoller пробуждает горутину. С точки зрения ОС это минимум переключений контекста.
  2. Работа с памятью в Redis. Все структуры данных лежат в куче процесса Redis. Команды выполняются в потоке, который «бегает» по памяти, разыменовывая указатели. Это очень CPU-friendly: L1/L2 кэши утилизируются максимально. Нет дисковых операций во время запроса (если не считать фоновых сохранений).
  3. Влияние на Go-рантайм. Парсинг ответа от Redis — это работа с байтовыми слайсами. Драйвер go-redis аккуратно минимизирует аллокации, используя sync.Pool для буферов. Однако при частой десериализации JSON в большие структуры количество мусора растёт, GC загружается. Рекомендация: используйте более лёгкие форматы, такие как msgpack или Protobuf, если нужно кэшировать миллионы объектов в секунду. Также рассмотрите переиспользование структур через sync.Pool.
  4. Syscalls при персистентности. Во время сохранения RDB Redis делает fork(). В Linux используется Copy-on-Write: пока память не модифицируется, страницы разделяются между родительским и дочерним процессами. Как только родитель пишет в страницу, ядро копирует её. Это может привести к внезапному удвоению потребления памяти, если база часто обновляется. Для Go-сервиса это может означать, что Redis потребляет в два раза больше RAM в моменты снапшотов — критично для планирования ресурсов.

Типичные ловушки и как их избежать


Итог

Redis для Go-разработчика — это гораздо больше, чем кэш. Это координационный центр распределённых систем, обеспечивающий предсказуемую микросекундную задержку, атомарные структуры и богатую экосистему. Понимание однопоточной модели, механизма персистентности, структуры памяти и сетевого взаимодействия с Go-рантаймом позволяет вам не просто «вызывать команды», а системно проектировать high-performance решения, избегая классических ошибок.

Далее мы нырнём ещё глубже и изучим внутренние структуры данных Redis, его менеджмент памяти и работу GC внутри самого Redis — всё то, что отличает эксперта от просто пользователя:

4. Redis под капотом