5. Memcached

Архитектура, slab allocator и высокопроизводительное кэширование в Go

Введение

В предыдущих статьях мы подробно изучили архитектуру и внутреннее устройство Redis — самого многофункционального in-memory хранилища данных. Теперь пришло время рассмотреть его более легковесного, но не менее мощного собрата — Memcached. Если Redis — это универсальный швейцарский нож с десятками специализированных структур данных, персистентностью и кластеризацией, то Memcached — остро заточенный монолезвийный скальпель: чистый кэш, только плоские байтовые строки, бескомпромиссная скорость и максимальная утилизация всех доступных ядер процессора.

Memcached был создан в 2003 году Брэдом Фитцпатриком (Brad Fitzpatrick, который впоследствии стал одним из ключевых авторов языка Go и стандартной библиотеки net/http) для проекта LiveJournal. С тех пор Memcached стал признанным мировым стандартом де-факто для высоконагруженного кэширования в веб-архитектуре. Его инженерная философия предельно лаконична: «никаких излишеств, только $O(1)$ операции и отсутствие глобальных блокировок там, где это физически возможно».

Для Go-разработчика понимание Memcached важно в ситуациях, когда сервису требуется максимальная пропускная способность (миллионы RPS) на многоядерном сервере, а сложные агрегации, очереди или персистентность на диск абсолютно не требуются.


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

Главное фундаментальное отличие Memcached от Redis — многопоточная параллельная обработка запросов, заложенная в архитектуру с самого рождения. Redis исторически опирается на однопоточный event loop (лишь в версии 6.0 появился опциональный многопоточный I/O для сокетов), в то время как Memcached изначально проектировался для честного распределения вычислений по всем доступным аппаратным ядрам CPU.

flowchart TD
    Client["Клиенты Go<br>(Пул TCP-соединений)"] --> LB["Прослушивающий сокет ядра ОС<br>(SO_REUSEPORT)"]
    LB -->|"Параллельный dispatch"| W1["Worker Thread 1<br>(libevent loop + epoll)"]
    LB -->|"Параллельный dispatch"| W2["Worker Thread 2<br>(libevent loop + epoll)"]
    LB -->|"Параллельный dispatch"| WN["Worker Thread N<br>(libevent loop + epoll)"]

    W1 --> Slabs["Slab Allocator<br>(Изолированные Slab-классы с точечными спинлоками)"]
    W2 --> Slabs
    WN --> Slabs

    classDef default fill:#1e293b,stroke:#334155,color:#f8fafc;
    classDef worker fill:#312e81,stroke:#6366f1,color:#ffffff;
    class W1,W2,WN worker;
  

Как это работает:

  • При запуске Memcached создаёт master-поток и заданное число worker-потоков (по умолчанию 4, настраивается через флаг командной строки -t).
  • Все потоки слушают те же TCP/UDP порты через общий сокет, используя флаг ядра Linux SO_REUSEPORT.
  • Входящее соединение прикрепляется к одному из worker’ов. Внутри каждого worker’а работает собственный event loop на базе библиотеки libevent.
  • Запросы на запись/чтение обрабатываются на том же worker’е, включая поиск в хеш-таблице и работу с памятью.

Это даёт настоящий параллелизм без координации команд из одного потока: каждый worker абсолютно независим и не ждёт других. Блокировки нужны только для синхронизации доступа к глобальным структурам — кэшу и слабам, но они спроектированы так, чтобы быть крайне короткими.


Slab allocator: управление памятью без фрагментации

Если в Redis память выделяется через jemalloc для каждого объекта, то Memcached использует slab allocator, радикально снижающий фрагментацию и накладные расходы на частое выделение/освобождение.

Как устроен Slab

  1. Страница (Page) — минимальный неделимый блок физической памяти, запрашиваемый у ОС через malloc/mmap. По умолчанию размер страницы равен 1 МБ.
  2. Slab class — класс аллокатора, объединяющий чанки (chunks) строго фиксированного размера. Например, класс 1 — чанки по 80 байт, класс 2 — 100 байт, класс 3 — 128 байт и так далее с фактором геометрического роста (по умолчанию -f 1.25). Каждый класс содержит несколько страниц памяти, предварительно нарезанных на одинаковые чанки.
  3. Chunk — элементарный контейнер для хранения ровно одного объекта: ключа, значения, флагов и метаданных структуры item.

Когда приходит команда set:

  • Вычисляется общий размер элемента (длина ключа + длина значения + overhead метаданных item).
  • Подбирается ближайший сверху slab class, чей размер чанка вмещает элемент.
  • Внутри класса просматривается свободный список (free list) чанков. Если свободного чанка нет — выделяется новая страница для этого класса или вытесняется старый элемент по алгоритму LRU.
flowchart LR
    A["Запрос на SET<br>(Размер payload: 200 байт)"] --> B["Поиск slab class<br>(Чанк >= 200 байт, например 256B)"]
    B --> C{"Есть свободный чанк?"}
    C -- "Да" --> D["Выделить чанк из Free List<br>Записать данные в RAM"]
    C -- "Нет" --> E["Освободить чанк по LRU<br>внутри данного slab class"]
    E --> D

    classDef default fill:#1e293b,stroke:#334155,color:#f8fafc;
    classDef action fill:#312e81,stroke:#6366f1,color:#ffffff;
    class D,E action;
  

Почему slab эффективен

С точки зрения Mechanical Sympathy, slab allocator даёт несколько важных преимуществ:

  1. Отсутствие фрагментации. Все чанки в классе одного размера, поэтому никогда не возникает проблемы «внешней» фрагментации, когда свободная память есть, но подходящего блока нет. Память утилизируется как идеальный массив.
  2. Локальность кэша. Чанки одного класса лежат в непрерывных страницах. При обслуживании запросов, обращающихся к ключам близкого размера, CPU загружает в L1/L2 кэши соседние чанки, ускоряя доступ.
  3. Предсказуемость аллокаций. Выделение чанка — это всего лишь перемещение указателя свободного списка или инкремент счётчика внутри страницы. Никакого поиска по куче, как в malloc. Это занимает считанные десятки тактов CPU.
  4. Минимизация блокировок. Каждый slab class имеет свой мьютекс. Потоки, работающие с разными размерами данных, не блокируют друг друга.

Структура элемента (item)

Каждый объект в Memcached представлен структурой item, которая размещается в начале чанка и управляет LRU-связями:

C
typedef struct _stritem {
    struct _stritem *next;      // следующий в хеш-таблице (chaining)
    struct _stritem *prev;
    struct _stritem *h_next;    // следующий в хеш-списке
    rel_time_t      time;       // время последнего доступа / создания
    rel_time_t      exptime;    // время истечения
    int             nbytes;     // длина значения
    unsigned short  refcount;
    uint8_t         nsuffix;    // длина суффикса (флаги + ключ)
    uint8_t         it_flags;
    uint8_t         slabs_clsid; // ID slab class
    uint8_t         nkey;       // длина ключа
    union {
        uint64_t cas;
        char end;
    } data[];
    // Далее идут: флаги, ключ, суффикс, значение
} item;

Элемент содержит и ключ, и значение, и флаги внутри одного блока памяти — никаких дополнительных указателей на разрозненные буферы. Это улучшает локальность данных и сокращает количество аллокаций. Для сравнения: Redis хранит ключ в SDS, а значение — отдельным объектом внутри value-структуры.


Хеш-таблица и поиск ключей

Глобальная хеш-таблица Memcached — это массив указателей на цепочки item. Хеш-функция по умолчанию — Jenkins one-at-a-time (может быть заменена на murmur3 в новых версиях). Размер таблицы динамически увеличивается, но в отличие от Redis, рехэширование не инкрементальное. При росте таблицы Memcached выделяет новую, большую таблицу и за один проход переносит все элементы, блокируя весь кэш на время переноса. Это — одна из причин, почему Memcached не рекомендуется для сценариев с сотнями миллионов ключей. Однако разработчики пошли на это, так как в типичных нагрузках таблица перестраивается редко и быстро.


Управление временем жизни и вытеснение

Memcached не удаляет просроченные ключи немедленно. Вместо этого используется ленивый подход, дополненный фоновым потоком.

  • Ленивое удаление: Когда клиент запрашивает ключ через get, проверяется exptime. Если время вышло, элемент удаляется и клиент получает ответ «не найдено».
  • Фоновый сборщик (crawler): Начиная с версии 1.4.25, в Memcached появился фоновый поток, который периодически обходит LRU-списки каждого slab class и удаляет истекшие элементы. Это позволяет освобождать память без ожидания обращений к «мёртвым» ключам.

Вытеснение (eviction) происходит только внутри того slab class, в котором закончились свободные чанки. Используется LRU: удаляется самый старый по времени последнего доступа элемент из хвоста двойного связного списка. Это означает, что если у вас активный класс с чанками 256 байт переполнен, а соседний класс 1 МБ пустует — вытесняться будут только 256-байтовые элементы, и память не переиспользуется между классами. Отсюда вытекает необходимость тщательно планировать распределение размеров ключей.


Персистентность и надёжность

Memcached не имеет персистентности от слова «совсем». Нет ни RDB, ни AOF, ни даже опции dump на диск. Рестарт Memcached = полная потеря всех данных. Его философия — «кэш должен быть легкозаменяемым и восстанавливаемым из основного источника данных». Это упрощает код, не требует fork и не создаёт нагрузки на диск, что даёт ещё больший прирост производительности.

В Go-экосистеме это означает, что вы должны проектировать систему, где падение Memcached или его перезапуск не вызывают деградацию бизнес-логики, кроме временного повышения latency из-за cache miss. Обычно это решается запасным инстансом или деградацией к прямому запросу в PostgreSQL (см. 7. Кэширование поверх БД).


Протокол взаимодействия

Memcached поддерживает два протокола: текстовый и бинарный.

Текстовый протокол — это ASCII строки:

Text
get mykey\r\n
VALUE mykey 0 5\r\n
hello\r\n
END\r\n

Команды set, add, replace, append, prepend, incr, decr, delete. Простой, удобный для отладки telnet.

Бинарный протокол — фиксированные заголовки, компактные байты. Устраняет некоторые проблемы текстового (например, невозможность отправлять бинарные данные в ключе без экранирования). Но он всё равно крайне прост.

В Go наиболее популярна библиотека github.com/bradfitz/gomemcache/memcache. Она работает с текстовым протоколом, предоставляя удобные методы и управление пулом соединений:

Go
package main

import (
    "fmt"
    "log"

    "github.com/bradfitz/gomemcache/memcache"
)

func main() {
    // Подключение к нескольким узлам Memcached (клиент сам выполняет consistent hashing)
    mc := memcache.New("10.0.0.1:11211", "10.0.0.2:11211")

    // Сохранение элемента
    err := mc.Set(&memcache.Item{
        Key:        "user:session:abc123",
        Value:      []byte(`{"user_id":42}`),
        Expiration: 300, // 5 минут
    })
    if err != nil {
        log.Fatalf("Set failed: %v", err)
    }

    // Чтение элемента
    item, err := mc.Get("user:session:abc123")
    if err == memcache.ErrCacheMiss {
        fmt.Println("Элемент не найден (Cache Miss) - читаем из основной БД")
    } else if err != nil {
        log.Printf("Ошибка сетевого соединения: %v", err)
    } else {
        fmt.Printf("Успешно прочитано: %s\n", string(item.Value))
    }
}

Сравнение Memcached и Redis

Держите в голове эту сводную инженерную таблицу при проектировании архитектуры:

Характеристика Memcached Redis
Модель обработки Многопоточная (несколько worker-потоков с libevent) Однопоточная (с опциональным многопоточным I/O)
Структуры данных Только плоские байтовые строки (Opaque Blobs) Строки, списки, множества, хеши, потоки, ZSet
Управление памятью Slab allocator (надежная защита от внешней фрагментации) Jemalloc (динамические аллокации в куче)
Персистентность Полностью отсутствует (Zero Persistence) RDB-снимки, инкрементальный AOF-журнал
Кластеризация Шардирование на клиенте (Consistent Hashing) Встроенный Redis Cluster (16384 слота) + Sentinel
Очистка ключей (TTL) Ленивая (Lazy) + фоновый crawler потоков Ленивая + активная (serverCron 100 раз/сек)
Исполнение скриптов Нет поддержки скриптов Встроенный Lua-интерпретатор, Redis Functions
Макс. размер значения По умолчанию 1 МБ (настраивается флагами) До 512 МБ на ключ
Сетевая библиотека libevent, поддержка TCP и UDP Собственная библиотека ae, TCP
Целевой сценарий Однородный простой кэш, предельный RPS Кэширование, распределенные блокировки, очереди

Когда выбирают Memcached:

  • Нужна максимально возможная пропускная способность на ноду, и данные — простые строки.
  • Память дорога, а размер объектов предсказуем (чтобы избежать slab-фрагментации).
  • Нет требований к сохранности данных.

Когда выбирают Redis:

  • Нужны сложные структуры данных (лидерборды, очереди).
  • Нужна персистентность и восстановление после сбоев.
  • Требуется репликация и автоматический failover.

В современной Go-разработке Memcached всё чаще вытесняется Redis/Valkey из-за их универсальности, но в высоконагруженных системах (например, доставка контента CDN, где кэшируются миллионы мелких HTML-сниппетов) Memcached всё ещё король.


Mechanical Sympathy: Memcached на голом железе

Рассмотрим физический путь запроса GET из Go-сервиса до Memcached и обратно.

  1. Сетевое взаимодействие: mc.Get() выполняет conn.Write() → syscall write, который копирует запрос в буфер сокета и отправляет TCP-сегмент. Горутина Go паркуется, ожидая ответа. Memcached worker, используя epoll, получает событие и читает запрос (read).
  2. Поиск: Worker вычисляет хеш ключа, захватывает мьютекс хеш-таблицы (спин-блокировка) и идёт по цепочке. Найдя item, проверяет exptime и обновляет LRU-список, двигая элемент в голову. Мьютекс освобождается.
  3. Ответ: Worker формирует заголовок VALUE ... и пишет данные в сокет клиента. Все операции с памятью происходят в рамках одного чанка, без лишних копирований. На стороне Go горутина пробуждается через netpoller, читает ответ и возвращает байты.

Благодаря slab’ам, данные чанка находятся в непрерывном блоке памяти, и процессор подгружает их в L1/L2 кэши с высокой вероятностью попадания. Многопоточность позволяет параллельно обслуживать столько запросов, сколько ядер, без координации. Именно поэтому Memcached достигает 1+ миллионов GET’ов в секунду на одном мощном сервере.


Типичные проблемы и ловушки


Итог

Memcached — это чистейший пример in-memory кэша, доведённого до совершенства. Его многопоточная архитектура и slab-аллокатор позволяют выжать максимум из многоядерных процессоров при кэшировании простых строк. Отсутствие персистентности, структур данных и сложной кластеризации делает его неподходящим для задач, требующих долговременного хранения или сложных запросов, но даёт непревзойдённую скорость и предсказуемость.

Для Go-разработчика Memcached — это инструмент, который стоит знать и держать в своём арсенале, особенно если вы проектируете высоконагруженные системы, где кэшируются небольшие однородные объекты на выделенных серверах с десятками ядер.

Следующим мы рассмотрим современный форк Redis, совместимый и развиваемый под эгидой Linux Foundation — 6. Valkey, который перенимает лучшие черты Redis, решая некоторые его проблемы управления и лицензирования.