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
- Страница (Page) — минимальный неделимый блок физической памяти, запрашиваемый у ОС через
malloc/mmap. По умолчанию размер страницы равен 1 МБ. - Slab class — класс аллокатора, объединяющий чанки (chunks) строго фиксированного размера. Например, класс 1 — чанки по 80 байт, класс 2 — 100 байт, класс 3 — 128 байт и так далее с фактором геометрического роста (по умолчанию
-f 1.25). Каждый класс содержит несколько страниц памяти, предварительно нарезанных на одинаковые чанки. - 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 даёт несколько важных преимуществ:
- Отсутствие фрагментации. Все чанки в классе одного размера, поэтому никогда не возникает проблемы «внешней» фрагментации, когда свободная память есть, но подходящего блока нет. Память утилизируется как идеальный массив.
- Локальность кэша. Чанки одного класса лежат в непрерывных страницах. При обслуживании запросов, обращающихся к ключам близкого размера, CPU загружает в L1/L2 кэши соседние чанки, ускоряя доступ.
- Предсказуемость аллокаций. Выделение чанка — это всего лишь перемещение указателя свободного списка или инкремент счётчика внутри страницы. Никакого поиска по куче, как в
malloc. Это занимает считанные десятки тактов CPU. - Минимизация блокировок. Каждый slab class имеет свой мьютекс. Потоки, работающие с разными размерами данных, не блокируют друг друга.
Структура элемента (item)
Каждый объект в Memcached представлен структурой item, которая размещается в начале чанка и управляет LRU-связями:
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 строки:
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. Она работает с текстовым протоколом, предоставляя удобные методы и управление пулом соединений:
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 и обратно.
- Сетевое взаимодействие:
mc.Get()выполняетconn.Write()→ syscallwrite, который копирует запрос в буфер сокета и отправляет TCP-сегмент. Горутина Go паркуется, ожидая ответа. Memcached worker, используяepoll, получает событие и читает запрос (read). - Поиск: Worker вычисляет хеш ключа, захватывает мьютекс хеш-таблицы (спин-блокировка) и идёт по цепочке. Найдя
item, проверяетexptimeи обновляет LRU-список, двигая элемент в голову. Мьютекс освобождается. - Ответ: Worker формирует заголовок
VALUE ...и пишет данные в сокет клиента. Все операции с памятью происходят в рамках одного чанка, без лишних копирований. На стороне Go горутина пробуждается через netpoller, читает ответ и возвращает байты.
Благодаря slab’ам, данные чанка находятся в непрерывном блоке памяти, и процессор подгружает их в L1/L2 кэши с высокой вероятностью попадания. Многопоточность позволяет параллельно обслуживать столько запросов, сколько ядер, без координации. Именно поэтому Memcached достигает 1+ миллионов GET’ов в секунду на одном мощном сервере.
Типичные проблемы и ловушки
Итог
Memcached — это чистейший пример in-memory кэша, доведённого до совершенства. Его многопоточная архитектура и slab-аллокатор позволяют выжать максимум из многоядерных процессоров при кэшировании простых строк. Отсутствие персистентности, структур данных и сложной кластеризации делает его неподходящим для задач, требующих долговременного хранения или сложных запросов, но даёт непревзойдённую скорость и предсказуемость.
Для Go-разработчика Memcached — это инструмент, который стоит знать и держать в своём арсенале, особенно если вы проектируете высоконагруженные системы, где кэшируются небольшие однородные объекты на выделенных серверах с десятками ядер.
Следующим мы рассмотрим современный форк Redis, совместимый и развиваемый под эгидой Linux Foundation — 6. Valkey, который перенимает лучшие черты Redis, решая некоторые его проблемы управления и лицензирования.