Кэширование

Предпосылки: модели консистентности (read-your-writes), базовое понимание PostgreSQL (shared buffers, соединения), Redis (GET/SET/DEL), pub/sub для инвалидации, CDN (RTT, origin/edge), Rails на уровне чтения кода (Rails.cache, Puma как многопроцессный веб-сервер).

← Паттерны надёжности | Гарантии доставки в распределённых системах →

В read-heavy системах кэширование — основной способ масштабирования чтения.

Зачем кэш: иерархия задержек

Доступ к данным занимает разное время в зависимости от того, где они находятся. Порядки величин для random access (чтения произвольного, а не соседнего с предыдущим адреса):

ХранилищеLatencyОтносительно RAM
RAM~10 ns1×
Optane (persistent memory, DIMM)~300 ns30×
SSD~100 µs10,000×
HDD~10 ms1,000,000×

RAM быстрее SSD примерно в 10,000 раз. Это не «немного быстрее» — это качественно другой уровень. Кэширование существует потому, что перенос данных ближе к процессору (из медленного хранилища в быстрое) даёт выигрыш на порядки — тот же принцип лежит в основе иерархии памяти от регистров до диска.

Почему кэш работает: локальность обращений

Кэш помогает только если одни и те же данные запрашиваются повторно. Это свойство называется locality of reference — локальность обращений. На уровне CPU и структур данных тот же принцип подробно разобран в локальности данных; здесь важна его прикладная версия на уровне запросов.

Temporal locality (временна́я локальность): данные, к которым обращались недавно, скорее всего понадобятся снова. Пользователь открыл профиль — вероятно, откроет его снова в ближайшие минуты. Товар появился в топе продаж — его будут запрашивать чаще обычного.

Spatial locality (пространственная локальность): данные, расположенные рядом в памяти, часто используются вместе. Это важнее для CPU-кэшей и структур данных, чем для application-level кэширования.

Temporal locality — причина, почему кэш вообще работает на уровне приложений. Если бы доступ к данным был равномерно случайным, каждый запрос был бы к новым данным, и кэш не помогал бы.

Три проблемы — три уровня кэширования

Локальность объясняет, почему кэш помогает. Но где его разместить — отдельный вопрос: ответ зависит от того, какую проблему решаем. Хранилище, которое кэш разгружает и откуда берёт данные при промахе (здесь — PostgreSQL), дальше называется origin — по аналогии с origin-сервером за CDN.

Проблема 1: нагрузка на origin

Rails-приложение. Страница профиля требует 5 запросов к PostgreSQL, каждый по 10ms. Страницу просматривают 100 раз в секунду. База выполняет 500 одинаковых запросов в секунду, возвращая одни и те же данные.

PostgreSQL кэширует страницы данных в shared buffers — disk I/O не происходит, если данные «горячие». Но при каждом запросе PostgreSQL парсит SQL, выбирает план выполнения, проходит по индексам, сериализует результат, занимает соединение из пула (набора заранее открытых соединений, которые приложение переиспользует). При 10,000 запросах в секунду (RPS) PostgreSQL может стать узким местом не по I/O, а по CPU и connections.

Решение: внешний кэш (Redis). Снимает нагрузку с PostgreSQL — запросы обслуживаются без работы базы.

Проблема 2: сетевой RTT

Redis и PostgreSQL оба по сети. RTT в датацентре ~0.5ms. Если цель — минимальная latency для конкретного запроса, сетевой вызов всё равно добавляет миллисекунды.

Решение: локальный кэш в памяти процесса. Доступ за микросекунды, RTT отсутствует.

Проблема 3: bandwidth и географическое расстояние

Пользователь в Токио запрашивает данные с сервера в Лондоне. Физическое расстояние создаёт latency, которую не убрать никаким кэшем на стороне сервера.

Решение: CDN — кэш на edge-серверах, географически близких к пользователю.

Сводка уровней

УровеньГде находитсяКакую проблему решаетЦена
Local (in-process)Память Rails-процессаLatency (нет RTT)Когерентность между процессами
External (Redis)Отдельный серверНагрузка на originRTT остаётся
CDNEdge-серверыГеографическая latencyЕщё сложнее инвалидация

Когерентность: локальный vs внешний кэш

Локальный кэш создаёт проблему cache coherence — когерентности кэша.

Несколько процессов на одном сервере

Puma (веб-сервер Rails) запускает 20 workers — независимых процессов — на одном сервере, каждый с локальным кэшем. Профиль пользователя закэширован в каждом worker. Пользователь обновил имя через worker 3. Остальные 19 workers указывают на устаревший профиль.

Инвалидировать кэш в worker 3 легко — там, где изменили, там и удалили. Но нет способа передать событие другим workers. Они покажут устаревшие данные, пока не истечёт TTL (Time-To-Live — время жизни записи в кэше).

Масштаб растёт с серверами

Сервер 1: 20 workers ── локальный кэш
Сервер 2: 20 workers ── локальный кэш
Сервер 3: 20 workers ── локальный кэш

60 копий данных профиля. Изменение через один worker — 59 stale (устаревших) копий.

Можно решить через pub/sub: broadcast (рассылка) инвалидации всем workers. Но это сложно: нужен канал между процессами, обработка случаев когда worker был недоступен, гонки между инвалидацией и новыми запросами.

Внешний кэш решает coherence

Redis как единственный источник кэшированных данных:

Сервер 1: 20 workers ─┐
Сервер 2: 20 workers ─┼──> Redis (одна копия данных)
Сервер 3: 20 workers ─┘

DEL profile:42 — и все workers при следующем запросе получат cache miss (промах: ключа в кэше нет), пойдут в PostgreSQL, получат свежие данные.

Фундаментальный trade-off

Локальный кэшВнешний кэш (Redis)
Минимальная latency (нет RTT)RTT на каждый запрос
Проблема coherence (N копий)Одна копия, простая инвалидация
Память каждого процесса ограниченаВыделенный сервер с большой памятью
Не переживает рестарт процессаПереживает рестарты приложения

На практике комбинируют: локальный кэш с коротким TTL (секунды) снимает повторные запросы в рамках одного процесса, Redis как основной кэш (минуты) снимает нагрузку с PostgreSQL.

Инвалидация

Внешний кэш решает проблему когерентности — одна копия вместо многих. Остаётся вторая проблема: данные в кэше устаревают. Когда и как их обновлять — центральная проблема кэширования.

TTL-based инвалидация

Данные живут фиксированное время, после чего автоматически удаляются.

Rails.cache.fetch("profile:#{user.id}", expires_in: 5.minutes) do
  User.find(user.id).profile_data
end

TTL — простейшая стратегия. Не нужно отслеживать, где данные изменились. Через 5 минут кэш перечитается в любом случае.

Цена: данные могут быть stale всё время TTL, даже если изменились секунду назад.

Event-based инвалидация

Кэш удаляется в момент изменения данных.

def update_profile(user, new_name)
  user.update!(name: new_name)
  Rails.cache.delete("profile:#{user.id}")
end

Проблема: нужно знать все места, где данные могут измениться. Профиль пользователя может измениться через веб-форму, мобильное приложение, админку, background job, API, миграцию данных, прямой SQL-запрос. Каждое место должно инвалидировать кэш. Забыл одно — получил stale data.

Производные данные усложняют ещё больше. Кэшируется страница со списком постов пользователя. Что должно инвалидировать этот кэш? Создание поста, удаление, редактирование, скрытие модератором, изменение настроек приватности.

Практический компромисс: TTL + event-based

Event-based ловит известные пути изменения. TTL — страховка на случай, если что-то пропустили (прямой SQL, баг, новый код без инвалидации). Данные будут stale максимум N минут, а не навсегда.

def update_profile(user, new_name)
  user.update!(name: new_name)
  Rails.cache.delete("profile:#{user.id}")  # event-based
end
 
Rails.cache.fetch("profile:#{user.id}", expires_in: 5.minutes) do  # TTL как страховка
  User.find(user.id).profile_data
end

Консистентность и кэш

TTL без event-based инвалидации нарушает read-your-writes: пользователь записал данные и сразу читает, но получает старое значение из кэша.

Три подхода к восстановлению read-your-writes

1. Инвалидация при записи (описана выше). Следующее чтение — cache miss, свежие данные из PostgreSQL.

2. Write-through. Кэш обновляется сразу при записи, без промежуточного miss.

def update_profile(user, new_name)
  user.update!(name: new_name)
  Rails.cache.write("profile:#{user.id}", user.profile_data, expires_in: 5.minutes)
end

3. Обход кэша для автора. После записи устанавливается короткоживущий маркер «этот пользователь только что писал». Для него читаем напрямую из PostgreSQL, минуя кэш. Сложнее в реализации, используется редко.

Cache-aside pattern

Когда и почему инвалидировать — решено. Остаётся организовать взаимодействие кэша с origin в коде: кто решает, читать из кэша или из базы, и кто кладёт данные в кэш.

Стандартный паттерн управления кэшем — cache-aside (кэш «сбоку» от основного пути к данным; другое название — lazy loading, ленивая загрузка: данные попадают в кэш только после первого промаха). Приложение само решает, когда читать из кэша, когда из origin, когда инвалидировать.

Чтение:

  1. Проверить кэш
  2. Если hit (ключ найден) — вернуть
  3. Если miss (ключа нет) — прочитать из origin, записать в кэш, вернуть

Запись:

  1. Записать в origin
  2. Инвалидировать кэш (или обновить)
def get_profile(user_id)
  Rails.cache.fetch("profile:#{user_id}", expires_in: 5.minutes) do
    User.find(user_id).profile_data  # выполнится только при miss
  end
end
 
def update_profile(user, new_name)
  user.update!(name: new_name)
  Rails.cache.delete("profile:#{user.id}")
end

Приложение явно контролирует все операции с кэшем. Альтернативы перекладывают часть логики на кэширующий слой.

Write-through и write-behind

Cache-aside требует явной инвалидации или обновления кэша при записи. Альтернативные паттерны встраивают запись в кэш в сам процесс записи данных.

Write-through

Данные записываются синхронно и в кэш, и в origin. Запись считается завершённой только когда оба хранилища обновлены.

def update_profile(user, new_name)
  # Оба вызова синхронны — клиент ждёт завершения обоих
  user.update!(name: new_name)                                    # → PostgreSQL
  Rails.cache.write("profile:#{user.id}", user.profile_data)      # → Redis
end

Гарантия: кэш всегда содержит актуальные данные. Read-your-writes выполняется автоматически.

Цена: latency записи увеличивается. Клиент ждёт и PostgreSQL, и Redis. Если Redis недоступен — запись либо падает, либо нужна обработка ошибки.

Write-behind (write-back)

Данные записываются сначала в кэш, а в origin — асинхронно, позже. Запись считается завершённой сразу после обновления кэша.

def increment_view_count(video_id)
  # Быстро: только Redis
  Rails.cache.increment("views:#{video_id}")
  # PostgreSQL обновится позже, в фоне
end
 
# Background job (каждые 10 секунд):
def flush_view_counts
  keys = Rails.cache.read_multi(*pending_view_keys)
  keys.each do |key, count|
    video_id = key.split(":").last
    Video.update_counters(video_id, views: count)
    Rails.cache.delete(key)
  end
end

Выигрыш: минимальная latency записи. Клиент не ждёт PostgreSQL.

Риск: при crash Redis данные, не записанные в PostgreSQL, теряются. Окно потери = интервал между flush’ами (сбросами накопленных значений в PostgreSQL).

Когда write-behind допустим

Write-behind подходит для данных, где потеря небольшого хвоста не критична:

  • Счётчики просмотров — потеря 100 просмотров из миллиона незаметна
  • Лайки — можно пережить потерю нескольких
  • Позиция воспроизведения видео — «продолжить с 5:30» vs «продолжить с 5:00» приемлемо
  • Аналитические события — статистика допускает погрешность
  • Онлайн-статус — «был онлайн 5 минут назад» не требует точности

Общий принцип: write-behind для данных, где допустима потеря небольшого хвоста при сбое. Антипример: платежи, заказы, баланс счёта — здесь потеря данных недопустима.

Сравнение паттернов записи

ПаттернLatency записиКонсистентность кэшаРиск потери данных
Cache-aside + invalidateНизкая (только origin)Eventual (до TTL)Нет
Cache-aside + updateСредняя (origin + кэш)СильнаяНет
Write-throughВысокая (origin + кэш синхронно)СильнаяНет
Write-behindМинимальная (только кэш)СильнаяДа (окно = flush interval)

Cache stampede

Cache stampede (stampede — «давка», паническое бегство толпы: сотни запросов одновременно ломятся в origin) — пример cascading failure: внезапная нагрузка на origin может перегрузить всю систему.

Популярный профиль с TTL 5 минут. В момент истечения TTL к этому профилю приходит 1000 одновременных запросов. Все получают cache miss, все идут в PostgreSQL.

Что происходит с системой

Puma с 20 workers, пул из 5 соединений к PostgreSQL на каждый. 100 соединений максимум. 1000 запросов — 100 получают соединение к БД, 900 ждут в очереди Puma.

PostgreSQL выполняет 100 одинаковых запросов. Если каждый занимает 500ms, обработка всех 1000 запросов займёт ~5 секунд. Не crash, но 5 секунд latency для всех пользователей.

Три решения

1. Блокировка на перестроение (mutex). Только один запрос идёт в PostgreSQL, остальные ждут результата.

def get_profile_with_lock(user_id)
  cache_key = "profile:#{user_id}"
  lock_key = "lock:#{cache_key}"
 
  cached = Rails.cache.read(cache_key)
  return cached if cached
 
  # nx: true — записать, только если ключа ещё нет; успех = блокировка наша
  if Rails.cache.write(lock_key, "1", nx: true, expires_in: 10.seconds)
    # Захватили блокировку — перестраиваем кэш
    data = User.find(user_id).profile_data
    Rails.cache.write(cache_key, data, expires_in: 5.minutes)
    Rails.cache.delete(lock_key)
    data
  else
    # Кто-то уже перестраивает — ждём
    sleep(0.1)
    Rails.cache.read(cache_key) || get_profile_with_lock(user_id)
  end
end

1000 запросов → 1 идёт в PostgreSQL, 999 ждут. Trade-off: добавили latency для ожидающих (минимум время перестроения кэша).

2. Stale-while-revalidate. Отдаём устаревшие данные, пока один запрос обновляет кэш в фоне.

Храним в кэше не только данные, но и время «мягкого» истечения. Если данные устарели, но ещё есть — отдаём stale, запускаем фоновое обновление.

Trade-off: пользователи могут видеть stale данные чуть дольше, но нет ожидания.

3. Раннее обновление (early expiration). Не ждём истечения TTL. Когда до истечения осталось мало времени, запускаем фоновое обновление. К моменту истечения новые данные уже в кэше. Stampede не происходит.

Когда можно игнорировать

Защита от stampede добавляет сложность. Три фактора определяют, нужна ли она:

  1. Время запроса к origin. Если запрос занимает 5ms, 100 одновременных запросов завершатся за ~5ms параллельно. PostgreSQL не заметит.

  2. Общий throughput. Если запросов мало, в момент истечения TTL придёт 2–3 запроса, не 1000.

  3. Популярность ключа. Stampede — проблема только для «горячих» ключей. Профиль обычного пользователя запрашивают раз в минуту — никакого stampede при истечении TTL. Проблема только для top-0.1%: главная страница, популярный товар, профиль знаменитости.

Практическое правило: защита нужна когда (RPS к ключу) × (время запроса) > порог. 1000 RPS × 500ms — проблема. 10 RPS × 5ms — можно игнорировать.

Eviction policies

Stampede — проблема при истечении TTL. Но TTL — не единственная причина удаления данных из кэша. Когда память заполнена (Redis настроен на 10GB, данных больше), кэш вынужден удалять записи сам — это вытеснение (eviction). Цель: удалить ключ, который с наименьшей вероятностью понадобится в будущем. Будущее неизвестно, поэтому используются эвристики на основе прошлого.

LRU — Least Recently Used

Удаляется ключ, к которому дольше всего не обращались (реализация: LRU-кэш). Логика основана на temporal locality: если данные не запрашивались давно, вероятно, не понадобятся и дальше.

Ключи по времени последнего доступа:

profile:1    — 5 секунд назад
profile:2    — 2 минуты назад
profile:3    — 1 час назад       ← кандидат на удаление
profile:4    — 30 секунд назад

Когда работает: доступ к данным следует temporal locality — недавно запрошенное «горячее».

Когда ломается: scan-паттерн. Построение отчёта прошлось по миллиону записей один раз — все они теперь «недавние» и вытеснили реально горячие данные.

LFU — Least Frequently Used

Удаляется ключ, к которому обращались реже всего.

Ключи по частоте доступа:

profile:1    — 1000 обращений
profile:2    — 5 обращений        ← кандидат на удаление
profile:3    — 500 обращений
profile:4    — 3 обращения        ← или этот

Когда работает: есть стабильно популярные данные (главная страница, топ товаров).

Когда ломается: новые данные. Только что добавленный популярный товар имеет счётчик 0 — сразу вытесняется, не успев набрать частоту. Redis решает это через LFU с затуханием (decay) — счётчики постепенно уменьшаются со временем, давая новым ключам шанс.

Выбор политики по типу данных

Сессии → LRU. Новая сессия = пользователь только что залогинился = скорее всего активен прямо сейчас. С LFU новая сессия имеет счётчик 1 и мгновенно становится кандидатом на вытеснение. Плюс сессии временные — старая сессия неактивного пользователя должна уйти, даже если за неделю накопила много обращений.

Каталог товаров → LFU. Популярный товар запрашивают тысячи раз в день — он должен остаться. Редкий товар запрашивают раз в неделю — можно вытеснить. Добавление нового товара не означает, что он когда-либо будет востребован.

ДанныеРекомендуемая политикаПричина
СессииLRUНовые сессии активны, старые неактивны
Каталог товаровLFUПопулярность стабильна, новое не всегда важно
Результаты поискаLRUЗапросы редко повторяются точь-в-точь
Статические справочникиLFUЧастые справочники должны остаться

Детали реализации в Redis: eviction policies.

Multi-tier caching

Каждый из трёх уровней до сих пор работал в одиночку. На практике CDN, локальный и внешний кэш часто используются одновременно. Это создаёт архитектуру, похожую на иерархию CPU-кэшей (L1/L2/L3), но с важным отличием: в CPU когерентность реализована аппаратно (MESI protocol), в web-архитектуре — вручную.

Клиент (браузер)
    │
    v
┌─────────┐
│   CDN   │  ← L1: географически близко к пользователю
└────┬────┘
     │ miss
     v
┌──────────────┐
│   Rails App  │
│ ┌──────────┐ │
│ │  Local   │ │  ← L2: в памяти процесса, микросекунды
│ │  Cache   │ │
│ └────┬─────┘ │
│      │ miss  │
│      v       │
│ ┌──────────┐ │
│ │  Redis   │ │  ← L3: внешний кэш, ~1ms RTT
│ └────┬─────┘ │
│      │ miss  │
│      v       │
│ ┌──────────┐ │
│ │PostgreSQL│ │  ← Origin
│ └──────────┘ │
└──────────────┘

Каждый уровень «прогревается» от нижнего при miss: данные из PostgreSQL сохраняются в Redis, из Redis — в Local, ответ может закэшироваться на CDN.

Инвалидация multi-tier

С одним кэшом инвалидация сложная. С тремя уровнями — ещё сложнее. При обновлении данных инвалидировать нужно снизу вверх: сначала Redis, потом Local, потом CDN. Логика: если сначала инвалидировать Local, он при miss пойдёт в Redis, который ещё содержит устаревшие данные.

Подводный камень 1: Local cache — это N кэшей. 3 сервера по 20 workers = 60 независимых локальных кэшей. DEL в Redis — одна команда. Инвалидация 60 локальных кэшей требует механизма broadcast: pub/sub, где каждый процесс подписан на канал инвалидации, или просто короткий TTL (5-10 секунд) без явной инвалидации.

Подводный камень 2: CDN propagation delay. CDN — сотни edge-серверов по миру. Инвалидация не мгновенная: вызов API запускает распространение, которое занимает секунды. Пользователь в одном регионе увидит обновление раньше, чем в другом.

Подводный камень 3: Partial failure. Что если Redis инвалидирован, Local инвалидирован, а CDN API вернул таймаут? Откатывать нельзя — данные уже в PostgreSQL. Retry в background job увеличивает окно inconsistency.

Практический подход

Не пытаться обеспечить строгую инвалидацию всех уровней. Вместо этого — принять eventual consistency и использовать TTL как страховку.

CDN:    TTL 1-5 минут (или не кэшировать динамику)
Local:  TTL 5-30 секунд
Redis:  TTL 5-15 минут + event-based инвалидация

Event-based инвалидация для Redis (один источник, просто). TTL для Local и CDN (много источников, сложно инвалидировать явно).

ДанныеCDNLocalRedis
Статика (JS, CSS, картинки)✓ долгий TTL——
Справочники (страны, категории)✓✓✓
Профиль пользователя✗короткий TTL✓ + invalidation
Сессия✗✗✓
Баланс счёта✗✗✗

Чем выше требования к консистентности — тем меньше уровней кэширования.

Распределённый кэш

Multi-tier решает проблему latency через иерархию уровней. Но остаётся вопрос объёма: что если данных больше, чем помещается в память одного Redis?

Проблема масштабирования

Redis настроен на 64GB, данных для кэширования — 500GB. Решение: распределить данные между несколькими серверами (партиционирование кэша).

Наивный подход: hash % N

def get_redis_server(key)
  hash = crc32(key)  # любая хеш-функция
  server_index = hash % num_servers
  servers[server_index]
end

При 3 серверах ключи распределяются равномерно. Проблема возникает при изменении числа серверов.

Добавление сервера: num_servers меняется с 3 на 4. Ключ с hash=7: 7 % 3 = 1 → 7 % 4 = 3 — переехал на другой сервер. При изменении с N на N+1 примерно N/(N+1) ключей меняют сервер. С 3 на 4 — около 75% ключей «переезжают».

Для кэша это означает: 75% запросов получат cache miss одновременно. Все пойдут в PostgreSQL. Это хуже, чем stampede одного ключа — это stampede всего кэша.

Consistent hashing

Алгоритм, при котором добавление сервера перемещает только 1/N ключей, а не (N-1)/N.

Пространство хешей представляется как кольцо. Серверы размещаются на кольце по хешу своего имени. Ключ хешируется и «идёт по кольцу по часовой стрелке» до первого сервера.

Кольцо 0..360:

0────90────180────270────360(=0)
     S2     S3     S1

Ключ hash=100: идём вправо → первый сервер S3 (180)
Ключ hash=200: идём вправо → первый сервер S1 (270)
Ключ hash=300: идём вправо, через 0 → первый сервер S2 (90)

Добавление S4 на позицию 135:

0────90────135────180────270────360
     S2     S4     S3     S1

Ключи 91-135 переехали с S3 на S4. Все остальные ключи остались на своих серверах. Переместилась только часть ключей между S2 и S3 — половина ключей S3, то есть примерно 1/8 всего кэша, а не 75%.

Virtual nodes

Consistent hashing решает проблему массовой миграции, но создаёт проблему неравномерности при малом числе серверов. Если позиции серверов на кольце случайны, один может получить половину пространства, другие — по четверти.

Решение: каждый физический сервер размещается на кольце много раз — в виде виртуальных узлов (vnodes).

Без vnodes (3 сервера, случайные позиции):
  S1 может отвечать за 50% ключей, S2 за 30%, S3 за 20%

С vnodes (3 сервера × 100 vnodes = 300 точек на кольце):
  Каждый сервер «разбросан» равномерно, ~33% ключей каждому

На практике используют 100-200 vnodes на сервер. При добавлении сервера его vnodes «откусывают» маленькие кусочки от всех существующих серверов равномерно.

Redis Cluster использует похожий принцип: 16384 слота распределены между узлами. Детали: Redis Cluster.

Применённые концепции

КонцепцияГде применена
Temporal localityПричина, почему кэш работает
Cache coherenceПроблема множества копий при локальном кэше
Read-your-writesНарушается TTL-only кэшем, восстанавливается event-based инвалидацией
Cache-asideСтандартный паттерн: приложение управляет кэшем явно
Write-through / write-behindАльтернативы cache-aside с разными trade-offs: риск потери данных vs latency записи
Блокировка на перестроение (mutex)Защита от cache stampede
LRU / LFUПолитики вытеснения при заполнении кэша
Consistent hashingМинимизация миграции при масштабировании кэша
Virtual nodesРавномерность распределения в consistent hashing

Sources

  • Kleppmann, 2017, Designing Data-Intensive Applications, Chapter 1 — latency numbers, caching motivation
  • Carlson, 2013, Redis in Action, Chapter 2 — cache-aside pattern, stampede solutions
  • Karger et al., 1997, Consistent Hashing and Random Trees — оригинальная статья о consistent hashing
  • DeCandia et al., 2007, Dynamo: Amazon’s Highly Available Key-value Store — virtual nodes в production

← Паттерны надёжности | Гарантии доставки в распределённых системах →