Нагрузочный стенд и карта выбора in-memory систем

Финал серии: три сценария, которые переживают оверхед платформы, — рабочий набор за границей RAM (Redis против двух конфигураций Aerospike), цена выноса вычислений к данным (Tarantool против Ignite) и etcd под несвойственной нагрузкой. Почему абсолютной latency здесь нет и почему это делает выводы прочнее — и итоговая карта выбора «задача → система → способ ускорения»

Пять предыдущих статей разбирали шесть систем серии по ролям, каждую по отдельности. Эта, финальная, не добавляет седьмую роль — она сталкивает системы на одной и той же нагрузке одновременно, в трёх конкретных сценариях, и заканчивается картой выбора, которая сводит всю серию в одну таблицу. Сценарий 1 — рабочий набор, переросший границу RAM: Redis против двух конфигураций Aerospike. Сценарий 2 — цена выноса вычислений к данным: Tarantool против Ignite на одной и той же агрегации. Сценарий 3 — etcd под профилем нагрузки, для которого он не проектировался, рядом с Redis на том же профиле.

Это шестая, финальная статья серии «Вычисления в оперативной памяти»: статья 1 — таксономия и трейдофы, статья 2 — Redis, статья 3 — Tarantool и Picodata, статья 4 — Aerospike и Ignite, статья 5 — etcd. Все числа ниже — из живого стенда benchmark (три сценария: working-set, compute-locality, etcd-misuse) на том же датасете серии — 200 000 товаров PostgreSQL.

Перед первым числом — обязательная остановка, а не сноска в конце. У этого стенда есть жёсткая методическая граница, и она решает, какие числа вообще можно опубликовать честно.

Слева точный измерительный прибор с наглухо закрытой крышкой шкалы, рядом с ним подключён грубый индикатор — точное измерение отключено намеренно. В центре три испытания: ёмкости, наполняемые сверх предела, — одна сливает старое через боковой вент и продолжает принимать, вторая заклинивает, третья уводит излишек в тёмный слой под собой; залитый поток против одной тонкой линии с тем же результатом; кольцевая структура, треснувшая под обычным потоком, который спокойно держит соседний сосуд. Справа указатель-развилка с рукавами к модулям разной формы — карта выбора, а не пьедестал

В статье

Границы метода

Вся серия работает под Docker Desktop на Windows — виртуализованный сетевой и дисковый путь через WSL2/Hyper-V backend. Это оценочно на два порядка дороже нативного Linux TCP-loopback — не оценка «на глаз», а наблюдение, подтверждённое соседним стендом databases/redis/deep-dive на том же классе платформы. Прямое следствие для этой статьи: любой абсолютный p50/p99 вида «Redis отвечает за X мкс» на этой машине измерял бы не Redis, а Docker Desktop — оверхед виртуализации, а не свойство системы.

Второе наблюдение неприятнее первого. time.Since() вокруг быстрых операций на этой платформе в 34,9–46,4% замеров возвращал ровно 0 — не редкое исключение, а треть-половина всех измерений в отдельных сериях. Механизм не диагностирован, и это стоит сказать прямо, а не спрятать за уверенной формулировкой. При этом версия «просто грубое разрешение часов» не проходит честную проверку: все ненулевые значения при этом кратны 100 нс, а часы с шагом 100 нс физически не могут округлить операцию длительностью в сотни микросекунд до нуля. Настоящая причина (возможно, что-то на стыке Hyper-V, QPC и планировщика) осталась неустановленной — и в этой статье не выдаётся за установленную.

Вывод из этих двух фактов прагматичный, а не риторический: любые p50/p99 по системам на этом стенде измеряли бы Docker Desktop, а не Redis, Tarantool, Aerospike или Ignite. Опубликованная такая цифра выглядела бы как результат, но была бы артефактом инфраструктуры — поэтому абсолютной latency отдельных систем в этой статье нет нигде. Единственные абсолютные времена, которые здесь встретятся, — это не время ответа систем, а окна служебных процессов самого стенда (например, задержка, с которой сервер пересчитывает внутренний флаг); они помечены по месту. Это осознанный отказ, а не пробел в измерении: три сценария ниже вместо неё меряют то, что переживает шум платформы, — величины на порядки крупнее любого таймерного артефакта (число записей, байты, доли отказов), отношения внутри одного прогона на одной и той же машине (а не абсолютное время само по себе), и качественные исходы — вытесняет, отказывает, держит весь набор.

Сценарий 1: рабочий набор за границей RAM

Постановка сценария working-set: три системы — Redis (--maxmemory + allkeys-lru), Aerospike ram (индекс и данные в RAM) и Aerospike flash (индекс в RAM, данные на устройстве) — нагружаются одним и тем же растущим набором из всех 200 000 реальных записей products, одновременно, при фиксированной границе памяти на каждую систему. products в PostgreSQL — 34 МБ (первая статья серии), maxmemory Redis выставлен 8 МБ: набор физически не вмещается ни при каком поведении системы, кроме отказа или вытеснения — перелом здесь не подгонка под результат, а арифметическая неизбежность.

Сразу оговорка о том, чем этот сценарий не является. Это не сопоставимый бенчмарк систем — лимиты у них заданы разными механизмами и в разных величинах: у Redis это maxmemory 8 МБ, у Aerospike — жёсткий минимум data-size 512 МиБ (меньше сервер просто не стартует) плюс stop-writes-used-pct 1%, потому что дефолтные 70% на демо-объёме недостижимы. Сравнивать «кто дольше продержался» на таких лимитах бессмысленно, и такого вывода здесь нет. Сценарий отвечает на другой вопрос — что система делает, когда данные кончились помещаться: вытесняет старое и продолжает принимать, отказывает в записи или уводит данные на устройство и держит весь набор. Это качественные исходы, и они не зависят от того, где именно проведена граница — в отличие от точки перелома, которая от конкретного лимита зависит целиком. Но и они не свойство системы вообще, а свойство выбранной конфигурации: тот же Redis с noeviction вместо allkeys-lru не вытеснит ничего, а начнёт отказывать в записи. Правильное чтение таблицы ниже — «что делают именно эти конфигурации при достижении своего лимита», а не «что делает Redis» или «что делает Aerospike».

Оговорка про колонку «доступно», важнее самой таблицы. Более ранняя версия этого раздела публиковала для Redis число доступно≈50 133 в прогоне с evicted_keys=159 793 — но 200 000 − 159 793 = 40 207, не 50 133: число было арифметически невозможным. Причина — метод измерения, не опечатка: «доступно» снималось выборкой (повторные GET по случайным id, доля попаданий экстраполировалась на весь датасет), а у Redis с allkeys-lru сам такой GET обновляет LRU-позицию прочитанного ключа — измерение вмешивалось в измеряемое состояние, искусственно продлевая жизнь именно тем ключам, которые проверялись выборкой. Исправлено: «доступно» для Redis теперь — точный DBSIZE, для Aerospike — точное поле objects из info namespace (не оценка по выборке ни там, ни там). Инвариант DBSIZE == (успешных SET) − evicted_keys проверяется на каждом прогоне; прогон падает с диагностикой, если не сходится, вместо публикации красивого, но недостоверного числа. Таблица ниже — это уже исправленный метод.

Итоговая таблица одного из живых прогонов:

Система Залито Доступно (точно: DBSIZE/objects) RAM, байт Статус Первая порция, после которой обнаружено вытеснение/отказ
Redis (maxmemory 8 МБ, allkeys-lru) 200 000 40 232 7 999 664 вытесняет 40 000
Aerospike ram 200 000 130 000 27 038 000 отказ 140 000 (в этом прогоне; полный диапазон по 9 прогонам — ниже)
Aerospike flash 200 000 200 000 12 800 000 отвечает нет

Три верификационных прогона после фикса метода — инвариант сошёлся на каждом: evicted=159 768 → DBSIZE=40 232 (200 000−159 768=40 232); evicted=159 770 → DBSIZE=40 230; evicted=159 758 → DBSIZE=40 242.

Одна оговорка к колонке RAM: у ram здесь 27 038 000 байт, а в четвёртой статье тот же namespace на том же датасете занимал 41 598 000 байт. Это не расхождение измерений: там набор был залит целиком, здесь ram отказал на середине заливки, и в памяти осело меньше записей. Сопоставимо в этой таблице другое — не абсолютная RAM, а статус и точка перелома.

За каждый из 9 живых прогонов Redis вытеснил 160 000–160 200 ключей — ни разу 0: allkeys-lru действительно вытесняет, а не отказывает в записи. flash не потерял ни одной записи ни на одном из 9 прогонов — 200 000 из 200 000 доступны каждый раз. ram при том же типе лимита отказывал в диапазоне записей ниже.

Разброс порции первого обнаружения: redis стабилен, ram — нет

Прогон redis: порция первого обнаружения ram: порция первого обнаружения
1 40 000 170 000
2 (объяснённый артефакт, см. ниже) 40 000 10 000
3 40 000 130 000
4 40 000 140 000
5 40 000 50 000
6 40 000 110 000
7 40 000 190 000
8 40 000 150 000
9 40 000 150 000

redis — на всех 9 прогонах вытеснение обнаруживалось после одной и той же порции, 40 000 записей. Формулировать это как «перелом ровно на 40 000» было бы точнее, чем позволяет метод: evicted_keys проверяется после каждой порции в 10 000, поэтому измерение говорит лишь, что первое вытеснение произошло где-то в интервале (30 000; 40 000]. Девять попаданий в один и тот же интервал — сильное свидетельство детерминированности (заливка последовательная, без внутреннего параллелизма, датасет фиксирован), но не доказательство, что момент пересечения maxmemory физически совпадает до записи. Чтобы утверждать точку, нужен меньший шаг у границы или фиксация первого изменения evicted_keys внутри порции — этого стенд не делает. ram перелом плавает в диапазоне 50 000–190 000 (исключая объяснённый артефакт прогона 2), но всегда отказывает заметно раньше конца набора; flash — никогда. Диапазон публикуется как абсолютные записи, не как процент.

Раздутие датасета до 500 000 — снято по ревью, методологический урок

Первая версия сценария раздувала растущий набор до 500 000 (200 000 реальных + 300 000 синтетических), чтобы точка отказа ram пришлась на «~25% от датасета» — цифра, которая звучит убедительно именно потому, что выглядит как относительная характеристика системы. Ревью вскрыло: все 6 живых прогонов той версии ломались строго внутри первых 200 000 реальных записей — синтетический хвост ни разу не был физически затронут. Раздувание не сдвигало сам перелом (он задан конфигом Aerospike, абсолютное число), оно только увеличивало знаменатель дроби: та же самая точка отказа при датасете в 2 000 000 записей дала бы «3–8%» — то же событие, другой процент, потому что процент здесь управляемая переменная, а не свойство системы. Синтетический хвост убран, сценарий работает на всех 200 000 реальных записях, и в этой статье публикуются только абсолютные числа записей — не проценты от произвольно выбранного знаменателя.

Причина разброса ram — установлена частично, без выдумывания недостающего

Целевой эксперимент (25 throwaway-прогонов, nsup-period варьировался 1/5/15/30/60 с × 5 повторов) проверил две гипотезы честно, без подгонки под красивый ответ:

  • установлен механизм отказа: все 25 прогонов упали с одним и тем же кодом SERVER_MEM_ERROR, stop_writes фиксировался в пределах ~100 мс от первого клиентского отказа вне зависимости от периода. Это говорит о том, как система отказывает, но ничего не говорит о том, почему момент отказа плавает: гонку конкурентных Put эти наблюдения не опровергают — чтобы её исключить, нужен контроль вида «1 воркер / 48 воркеров / фиксированный темп», которого мы не ставили;
  • не подтверждено: что именно nsup-period управляет размахом разброса — стандартное отклонение по периодам (26 300 / 20 300 / 20 300 / 32 600 / 6 950) не растёт монотонно вместе с периодом;
  • наиболее вероятный, но не доказанный фактор: нестабильный throughput записи под Docker Desktop — throughput последней секунды перед отказом колебался между ~10 400 и ~29 300 записей/с между иначе идентичными прогонами. Но и это лишь совпадение диапазонов, а не доказанная связь: корреляции «throughput → точка отказа» по прогонам мы не считали. Честный итог: причина разброса не установлена ни одна.

Механизм самого отказа (пороговая проверка занятой памяти) доказан; точный источник разброса момента обнаружения — не установлен. От этого разброса не зависит качественный исход: ram всегда отказывает заметно раньше конца набора, flash — никогда.

Отдельная находка объясняет артефакт прогона 2 в таблице выше (ram=10 000): второй прогон подряд без паузы вскрыл, что флаг stop_writes пересчитывается только на тике фонового namespace supervisor (nsup-period), а не синхронно с truncate() — namespace отказывал в записи на первой же порции физически уже пустого набора, потому что флаг ещё не успел сброситься. Тот же класс дефекта, что асинхронное освобождение памяти у Tarantool после drop() (третья статья серии) — разное направление, одна и та же причина: асинхронный фоновый процесс, метрика снята до его завершения. Исправлено явным ожиданием сброса флага.

Отдельно стоит короткая оговорка про сам лимит: жёсткий минимум сервера storage-engine.data-size — 512 МиБ, меньшее значение Aerospike отвергает на старте. Точка отказа ram в этом сценарии сдвинута не через data-size, а через storage-engine.stop-writes-used-pct (снижен с 70% до 1%) — и даже так фактическая точка отказа оказалась заметно выше наивной пропорции «1% от 512 МиБ»; разрыв между настроенным процентом и фактическим порогом до конца не объяснён — открытых исходников сервера для этого нет.

Ignite сознательно не участвует в этом сценарии — не упущение, а явное решение: ни один обязательный ассерт брифа не касается Ignite, а риск повторить находку «конфиг-минимум» (off-heap DataRegion Ignite по умолчанию — около 20% от всей памяти хоста) оценён как несоразмерный пользе без строгого требования проверить его именно здесь. Этот сценарий сравнивает Redis и Aerospike (ram/flash) — это не полное сравнение всех систем серии.

Сценарий 2: цена выноса вычислений к данным

Постановка сценария compute-locality: одна и та же агрегация — топ-10 категории tools по просмотрам — считается двумя путями одновременно на Tarantool (хранимка top_products_by_category, статья 3) и на Ignite (задача TopByCategoryTask, статья 4).

Число объектов, уехавших по сети, — идентично на обеих системах и на всех 3 прогонах, детерминировано: наивный путь тянет всю категорию клиенту — 33 276 объектов; локальный путь (хранимка/compute) возвращает только результат — 10. Отношение — 3327,6×, одинаково у Tarantool и у Ignite. Тай-брейк подтверждён впервые как межсистемная сверка: множество id топ-10 совпало побитово между Tarantool (тай-брейк по PK DESC на равных views) и Ignite (views DESC, затем id ASC) — [7 12 21 30 33 34 38 76 84 85].

Байты по сети — измерено, а не выведено из числа объектов

3327,6× — это отношение числа объектов, не байт. Внешнее ревью серии справедливо указало: число объектов не равно объёму трафика, пока трафик не измерен отдельно — у сериализации есть накладные расходы (заголовки протокола, служебные поля), и они не обязаны масштабироваться линейно вместе с числом записей в ответе. Поэтому байты для обеих систем измерены отдельно, а не досчитаны из отношения объектов.

Метод. Для Tarantool — настоящий сокетный трафик: go-tarantool/v2 позволяет подставить свой Dialer (см. tarantool.NetDialer как образец), и стенд оборачивает net.Conn счётчиком прочитанных/записанных байт, снятым до и после каждого вызова. Это байты фактических системных вызовов на сокет — с IPROTO-заголовком фрейма и msgpack-конвертом ответа, но без заголовков TCP/IP, которые добавляет сам сетевой стек ОС (их приложение не видит). Для Ignite — метрики штатного TcpCommunicationSpi (getSentBytesCount()/getReceivedBytesCount()), доступные у классической client-node (той же, что уже используется для affinityCall в этом сценарии, см. статья 4); у тонкого клиента Ignite этого SPI нет вовсе. Строго говоря, это дельта SPI-счётчиков client-node за окно вызова, а не байты самого вызова: счётчик узловой, поэтому в интервал может попасть служебный трафик (discovery, heartbeat), и он же не покрывает весь внутрикластерный обмен между серверными узлами. Интервал сужен до границ сетевой операции, чтобы минимизировать примесь, но исключить её этот метод не может — в отличие от Tarantool, где счётчик висит прямо на соединении.

Tarantool: наивно Tarantool: локально Ignite: наивно Ignite: локально
Байт по сети (медиана 51 повтора) 2 144 677 423 6 475 710 (sent 1978 / received 6 473 732) 1232 (sent ≈510 / received ≈722)
Отношение (наивно/локально) 5070,2× ≈5256×

Оба числа получены на 3 живых прогонах (оба порядка — прямой и -swap-order) и оказались практически детерминированными: Tarantool — побитово одно и то же значение на 2 из 3 прогонов (в одном из 51 повторов первого прогона наивный путь один раз дал на 48 байт больше, и причину установить не удалось: счётчик считает прикладные байты, поэтому списать их на лишний TCP-сегмент нельзя — транспортные заголовки в это число не входят. Дальше расхождение не повторилось); Ignite — то же значение на всех 3 прогонах, с разбросом в единицы байт внутри окна 51 повтора (sent наивного пути колебался в [1909..1982], received — в [6 473 697..6 473 736]) — разброс мал, но его источник этим методом не изолируется: счётчик узловой, поэтому в окно может попадать и служебный трафик (discovery, heartbeat), и вариативность самого протокола — различить их без packet capture нельзя, и мы этого не делали.

Отношение байт не совпало с отношением объектов — и разбор этого расхождения оказался поучительнее самих чисел. У Tarantool байты дали 5070,2× против 3327,6× по объектам, у Ignite — ≈5256× против тех же 3327,6×.

Напрашивающееся объяснение — «мелкий ответ несёт фиксированные накладные расходы протокола, поэтому дороже в пересчёте на запись» — неверно, и арифметика это опровергает сразу. Посчитаем байты на объект:

наивный путь локальный путь
Tarantool 64,45 Б/объект 42,30 Б/объект
Ignite 194,61 Б/объект 123,20 Б/объект

Мелкий ответ вышел дешевле на запись, а не дороже. Будь дело в фиксированном overhead, он тянул бы отношение байт вниз относительно отношения объектов, а не вверх.

Форма передаваемых записей у двух путей действительно разная, и это видно в коде стенда: наивный тащит полную запись товара — шесть полей, включая sku, price_cents и category, не нужные для построения топа, — а локальный возвращает три поля: id, title, views.

Дальше важно не сказать больше, чем позволяет арифметика. Измеренное отношение байт можно тождественно представить как произведение двух величин — это разложение по определению, а не вывод о причинах:

  • 3327,6× — отношение числа записей в ответе;
  • ≈1,52× у Tarantool и ≈1,58× у Ignite — отношение среднего числа байт на возвращаемую запись.

Произведение сходится до десятых: 3327,6 × 1,524 = 5070,2 и 3327,6 × 1,580 = 5256,3 — ровно измеренные значения.

Экономия в 5070× реальна — именно столько байт и не ушло по сети. Но дальше начинается место, где легко сказать больше, чем измерено, и это стоит проговорить отдельно.

Ни один из двух множителей не изолирован экспериментом. Второй — не чистая «проекция полей»: в него входят ещё различия типов, схемы сериализации, request envelope и фиксированная часть протокола; проекция здесь главный видимый вклад, но не единственный. Первый — не «цена locality»: 3327,6× это сокращение мощности ответа за счёт того, что отбор выполнен на сервере, а не за счёт того, что вычисление оказалось рядом с данными. Обычный серверный ORDER BY ... LIMIT 10 в классической СУБД дал бы эффект того же класса, никакого collocated compute для этого не требуется.

Причина в том, что сценарий меняет сразу пять вещей одновременно: место выполнения отбора, алгоритм, мощность результата, схему записи и способ её сериализации. Он честно доказывает, что серверный top-N резко сокращает ответ клиенту, — и не разделяет, сколько в этом от близости вычисления к данным. Для такого разделения нужен другой эксперимент: выровненная форма записи и сопоставимый серверный алгоритм на обоих путях. Мы его не ставили.

Публикуется то, что измерено: байты по сети (5070,2× и ≈5256×) и число объектов (3327,6× у обеих систем) — две разные величины, и ни одна не выводится из другой без оговорки о форме записи.

Главный честный контраст: объём и время — разные числа

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

Прогон Tarantool, ratio (naive-first / local-first) Ignite, ratio (naive-first / local-first)
1 53,26 / 67,01 1,92 / 1,63
2 59,19 / 56,05 0,95 / 0,93
3 58,22 / 56,05 0,94 / 0,90
4 122,98 / 101,28 1,51 / 1,53
5 54,22 / 58,93 1,63 / 1,74
6 56,63 / 59,47 1,61 / 1,56

Tarantool — 53–123× (основная масса 53–60; два выброса в одном прогоне, где наивный путь оказался вдвое дороже обычного, причину установить не удалось). Ignite — 0,90–1,92×, и вот это главное: в четырёх замерах из двенадцати отношение меньше единицы, то есть compute-путь оказался медленнее наивного. Выигрыша по времени у Ignite на этом стенде нет — он в пределах шума и местами отрицателен, при том что выигрыш по объёму у обеих систем одинаков.

Соблазнительно объяснить разрыв per-call оверхедом affinityCall — сериализацией задачи, диспетчеризацией на узел, вызовом внутри JVM. Мы это утверждение сняли: стенд его не доказывает. Две реализации решают задачу принципиально разными алгоритмами. Хранимка Tarantool идёт по отсортированному индексу (category, views), набирает нужные десять записей и выходит из цикла — сортировать ей нечего. Задача Ignite перебирает все локальные записи партиции, фильтрует по категории и сортирует результат целиком. Разница во времени включает алгоритмический вклад, и этот стенд не отделяет его от транспортного — для такого разделения нужен другой эксперимент, которого мы не ставили.

Что остаётся доказанным и не зависит ни от алгоритма, ни от платформы: экономия по трафику и экономия по времени — разные величины, и одна не следует из другой. У Ignite по сети уезжает в 3327,6 раза меньше объектов (в ≈5256 раз меньше измеренных байт) — и при этом по времени он не выигрывает вовсе. Подавать «3327,6×» или «5256×» как «ускорение в тысячи раз» было бы именно той подменой, против которой написан этот раздел.

Здесь нужна оговорка, которую стоит сделать явно, потому что напрашивается неверная. Выигрыш даёт не сокращение числа обращений к серверу: их и там и там ровно по одному. Наивный путь — один SELECT всей категории, локальный — один CALL; у Ignite так же: один ScanQuery против одного affinityCall. Эксперимент меряет не «сколько раз мы сходили в базу», а сколько данных вернулось в ответе и где выполнен отбор — на сервере или в приложении. Платформа при этом может влиять на величину эффекта — передача 33 276 записей идёт через виртуализованный сетевой путь Docker Desktop, — но характер этого влияния не измерен. Но даже направление изменения этот стенд не устанавливает: bare-metal не измерялся вовсе, число обращений к серверу у обоих путей одинаково, а различаются они алгоритмом, сериализацией и объёмом ответа — вклад каждого фактора при смене платформы может двигаться по-разному. Это гипотеза без предсказанного направления, а не вывод. Соблазнительно достроить: мол, на bare-metal с дешёвым RTT часть выигрыша Tarantool растворилась бы — но конкретную цифру «во сколько раз меньше» этот стенд не даёт: у него нет измерения на bare-metal, с которым можно было бы сравнить, и додумывать порядок было бы нарушением того же правила, которое запрещает выдумывать числа в остальной части статьи. Всё, что можно сказать честно: полученный разрыв — результат на конкретной платформе, и как он изменится на другой, стенд не проверял.

Почему это медиана из 51 повтора, а не единичный замер

// clTimingReps — живой прогон вскрыл (не выдумано): первая версия измеряла
// локальный путь (CALL хранимки) ОДИН раз за проход — медианы не было
// вообще. Диагностика показала, что CALL хранимки распределён БИМОДАЛЬНО —
// примерно поровну между двумя модами, различающимися примерно вдвое
// (вероятно, две ветки TCP-стека WSL2/Docker Desktop — с задержкой ACK и
// без), а не гладким шумом вокруг одного значения. При ОДНОМ замере
// результат произвольно попадает в ту или иную моду — отсюда и ложные
// «смещения порядка» на одном и том же соединении.
//
// ВАЖНО о силе этого утверждения. Две моды наблюдались в ОТДЕЛЬНОЙ
// диагностической пробе, которая НЕ сохранена в репозитории, а сам код
// печатает только медиану, не сохраняя 51 отсчёт. Поэтому ни две моды, ни
// их близкие веса читатель проверить не может — это наблюдение, а не
// опубликованный факт.
//
// N=51 подобрано эмпирически. Но это НЕ означает, что медиана «сходится»:
// при двух модах с близкими весами она сама переключается между ними от
// прогона к прогону. Больший N лишь уменьшает чувствительность к ОДНОМУ
// случайному отсчёту — платформенную нестабильность он не устраняет.
const clTimingReps = 51

Из цитаты убраны конкретные абсолютные времена, которые стоят в исходном комментарии стенда (диагностика прогона), — по тому же правилу, что действует во всей статье: абсолютные величины на этой платформе измеряют Docker Desktop, а не систему, и в публикацию не идут. Сам код приведён как есть, а комментарий сокращён — смысл и итоговая квалификация N=51 сохранены, но это пересказ, а не побуквенная копия.

Бимодальность тайминга Tarantool на этой платформе — вероятное, а не установленное объяснение (два пути TCP-стека WSL2/Docker Desktop), но сам эффект («примерно поровну между двумя модами») наблюдался в отдельной диагностической пробе, которая не сохранена — а код печатает только медиану, не сохраняя 51 отсчёт. Поэтому ни две моды, ни их близкие веса читатель проверить не может: это наблюдение автора, а не опубликованный факт. Медиана из 51 повтора каждого пути внутри одного прохода делает отношение менее чувствительным к одному случайному отсчёту — первая версия сценария мерила по одному разу и на этом ловила ложные «смещения порядка» дважды подряд.

Но медиана не устраняет саму нестабильность — и вот это в опубликованных данных видно. Если у распределения две моды с близкими весами, медиана переключается между ними от прогона к прогону — она лишь становится устойчивее одиночного замера, а не превращает нестабильную платформу в стабильную. В evidence/compute-locality-ratios.csv локальный путь Tarantool даёт медианы примерно от 0,5 до 1,52 мс, и отношение вслед за этим гуляет от 53,26 до 122,98. Строго говоря, из этих данных видно переключение итоговых медиан между прогонами, а не две моды исходного распределения и тем более не их веса: внутренние 51 отсчёт не сохранены, проверить форму распределения по CSV нельзя. Поэтому все выводы здесь строятся на диапазоне по шести прогонам, а не на «устойчивом» одном числе: 51 повтор даёт увидеть разброс, а не убрать его.

Сценарий 3: etcd не по назначению

Постановка сценария etcd-misuse: тот же профиль записи, что Redis держит штатно, — 50 000 операций перезаписи горячего набора из 5 000 ключей (~10 перезаписей на ключ), 1 КБ на значение, — подан на отдельный etcd с искусственно уменьшенной квотой backend’а: 64 МиБ вместо дефолтных 2 ГиБ.

// Для Redis (SET перезаписывает значение НА МЕСТЕ) это НИЧЕГО не стоит —
// used_memory остаётся порядка hotKeys*1КБ независимо от числа перезаписей.
// Для etcd КАЖДАЯ перезапись — НОВАЯ MVCC-ревизия, и все старые ревизии
// физически хранятся в backend (bbolt-файле) до явной компакции — это
// задокументированное поведение etcd, не открытие этой задачи. На
// перезаписываемом горячем профиле это означает, что размер базы растёт
// БЕЗ ГРАНИЦ, а не остаётся константным, как у Redis.
//
// ЭТО НЕ "etcd плохой" — это "etcd для другого".

Redis принял весь профиль без единой ошибки: 50000/50000, 0 ошибок на каждом из 7 прогонов. etcd упёрся в квоту на записи в диапазоне 48 220–48 287 из 50 000 (≈0,13% профиля), с текстом ошибки etcdserver: mvcc: database space exceeded и alarm NOSPACE:

Прогон Отказ на записи № dbSize в момент отказа, байт
1 48 287 67 112 960
2 48 279 67 112 960
3 (независимая ревью-проверка) 48 232 67 129 344
4 48 256 67 129 344
5 48 246 67 141 632
6 48 220 67 112 960
7 48 274 67 153 920

По первым 2 прогонам диапазон отказа выглядел как узкие 48 279–48 287, с «идентичным» dbSize — 3-й прогон (независимая ревью-проверка) вышел за пределы обеих оценок, и честный диапазон по всем 7 прогонам оказался шире: точка отказа 48 220–48 287, dbSize в момент отказа 67 112 960–67 153 920 байт (~10 страниц bbolt по 4 КБ). Этот же паттерн — диапазон расширяется, когда прогонов становится больше, — повторился и в сценарии 1 выше. Он не случаен: пока прогонов мало, диапазон описывает не разброс величины, а то, куда попали первые несколько замеров, и честнее считать его нижней оценкой разброса, а не самим разбросом.

dbSize в момент отказа (~67 МБ) устойчиво округляется к 10,0× Δused_memory Redis на всех 7 прогонах. Причина эффекта универсальна — etcd хранит MVCC-историю всех перезаписей до компакции, Redis перезаписывает значение на месте, — но конкретное число 10,0× специфично именно для этого профиля (50 000 операций / 5 000 ключей / 1 КБ) и именно этой квоты (64 МиБ, подобрана так, чтобы отказ пришёлся на конец профиля); при другом числе операций, другом размере горячего набора или другой квоте соотношение будет другим. Профиль подобран честно, не для эффектности: 50 000 операций по горячему набору из 5 000 ключей — это около 10 перезаписей на ключ, иначе MVCC-история просто не успела бы накопиться настолько, чтобы отказ вообще случился.

Восстановление сработало по документированной процедуре: Compact → Defragment → AlarmDisarm. После неё dbSize упал с ~67 МБ до 6 914 048 байт на всех 7 прогонах — −89,7%, и канареечная запись после восстановления прошла на каждом из 7 прогонов.

Вывод сценария — не «etcd медленный», а «etcd про другое»: на профиле, для которого проектировался (малые критичные метаданные, редкая запись), он и не должен упираться в такую квоту; на профиле, для которого проектирован Redis (горячий, часто перезаписываемый набор), etcd упирается предсказуемо и быстро — потому что каждая перезапись оставляет след в MVCC-истории, а не потому что etcd плохо написан.

Карта выбора

Синтез всей серии — таблица «класс задачи → система → способ ускорения → чем платишь». Столбец «опора» честно отмечает, где строка стоит на конкретном факте стенда, а где — на суждении, обобщающем несколько статей серии.

Класс задачи Система Способ ускорения Чем платишь Опора
Read-heavy с явным hot set, нужны готовые атомарные структуры (рейтинги, приблизительный count) Redis Кэш перед источником правды + структуры данных (ZSET, HyperLogLog) в памяти Второй источник правды и окно рассогласования при неверном порядке инвалидации; при переполнении maxmemory вытесняет по политике, не отказывает (эта статья, сценарий 1: 160 000–160 200 evicted на 9/9 прогонов) факт
Данные и логика должны жить в одном адресном пространстве, локальный вызов Tarantool (у Picodata идея та же, но исполнение распределённое — строка ниже) Lua stored procedure выполняется рядом с кортежами Логика на Lua на сервере (отладка, версионирование); локальный вызов серверный отбор резко сокращает ответ клиенту (3327,6× по числу объектов, 5070,2× по измеренным байтам) — сильнее, чем время (53–123×). Вклад именно близости вычисления к данным сценарий не изолирует: серверный ORDER BY ... LIMIT дал бы эффект того же класса — эта статья, сценарий 2 факт + суждение
Датасет растёт быстрее разумного RAM-бюджета, но нужна предсказуемость на масштабе Aerospike Гибрид: фиксированный индекс в RAM (64,00 байта/запись), данные — в RAM или на flash Латентность обращения к устройству вместо RAM у flash; namespace ram без выноса данных на flash отказывает заметно раньше конца набора (диапазон 50 000–190 000 из 200 000, эта статья, сценарий 1) факт
Grid: распределённый кэш + вычисление рядом с партицией, распределённый SQL/ACID Apache Ignite Collocated compute (affinityCall) поверх партиционированного кэша Вес JVM/GC на каждый узел; экономия по трафику есть (3327,6× по числу объектов, ≈5256× по измеренным байтам), а по времени на этом стенде её нет вовсе (0,90–1,92×, в части замеров compute медленнее наивного пути) — эта статья, сценарий 2 факт
Малые критичные метаданные, координация — не throughput etcd Raft-консенсус + линеаризуемое чтение Throughput принципиально ограничен кворумной записью; на профиле, который Redis держит без единой ошибки (50000/50000), etcd упирается в квоту на записи 48 220–48 287 из 50 000 (эта статья, сценарий 3) факт
Нужен распределённый SQL и явное шардирование поверх in-memory, без выноса логики в отдельный слой приложения Picodata Самостоятельная СУБД на форке ядра Tarantool: vshard-шардирование, свой планировщик распределённых запросов, PostgreSQL как основной протокол, расширение плагинами на Rust. Код плагина живёт внутри кластера, но запрос исполняется распределённо на всех хранилищах — это не «логика в одном процессе с данными», как у Tarantool Полноценный распределённый кластер вместо одного процесса: фактор репликации нужно заранее сверить с числом узлов (3 инстанса при RF=2 не шардируют вовсе — статья 3), кластерные тюнаблы вроде sql_vdbe_opcode_max не рассчитаны на сотни тысяч строк из коробки суждение (по опыту статьи 3, вне сценариев этой статьи)
Нужно и то, и другое сразу — масштаб данных и координация небольшого критичного состояния — Ни одна система серии не совмещает дисковый гибрид большого объёма с ролью координатора — это разные архитектурные экстремумы (первая статья, квадрант-диаграмма) Обычно два разных компонента в архитектуре: например Aerospike/Ignite для данных + etcd/Consul для координации над ними суждение (обобщение таксономии статьи 1)
flowchart TD A["Нужна координация кластера,
а не throughput?"] -->|да| B["etcd"] A -->|нет| C["Логика должна жить
рядом с данными?"] C -->|да, один процесс| D["Tarantool"] C -->|да, но данные шардированы
и нужен распределённый SQL| P["Picodata"] C -->|да, распределённый грид| E["Apache Ignite"] C -->|нет, просто быстрый доступ| F["Рабочий набор влезает
в RAM-бюджет?"] F -->|да| G["Redis"] F -->|нет, а latency критична| H["Aerospike RAM/flash"]

flowchart TD
    A["Нужна координация кластера,
а не throughput?"] -->|да| B["etcd"] A -->|нет| C["Логика должна жить
рядом с данными?"] C -->|да, один процесс| D["Tarantool"] C -->|да, но данные шардированы
и нужен распределённый SQL| P["Picodata"] C -->|да, распределённый грид| E["Apache Ignite"] C -->|нет, просто быстрый доступ| F["Рабочий набор влезает
в RAM-бюджет?"] F -->|да| G["Redis"] F -->|нет, а latency критична| H["Aerospike RAM/flash"]
Дерево выбора: с чего начать, если нужно ускорить доступ к данным in-memory-подходом. Упрощение карты выше до одного прохода по дереву — не замена таблице, а быстрый вход в неё

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

Что не воспроизвелось

Раздел не пустой — ни один из трёх сценариев этой статьи не сработал по брифу дословно с первого прогона (у стендов статей 2–5 картина другая: например, стенд etcd воспроизвёлся с первого раза, о чём сказано в пятой статье). Честная картина по трём сценариям такая (у стендов из статей 2–5 — свои оговорки в соответствующих статьях, здесь не дублируются):

  • working-set (сценарий 1). Все три обязательных ассерта прошли, но не с первым выбором границы памяти по брифу: Aerospike отверг брифовое значение data-size (жёсткий минимум сервера — 512 МиБ), и второй прогон подряд вскрыл, что stop_writes не сбрасывается синхронно с truncate() (объясняет артефакт «10 000» в таблице разброса выше). Независимая ревью-проверка отдельно нашла концептуальный дефект первой версии сценария — раздутый до 500 000 датасет давал ложное «~25%» — снято, синтетический хвост убран. Причина точного разброса точки отказа ram (50 000–190 000) установлена лишь частично: механизм — да, момент — нет.
  • compute-locality (сценарий 2). Все ассерты прошли на 3×2 запусках, но первая версия сценария (единичный замер вместо медианы) дважды подряд ловила ложное «смещение порядка» — предположительно из-за бимодального распределения латентности Tarantool на этой платформе, наблюдавшегося в несохранённой диагностической пробе. Исправлено переходом на медиану 51 повтора.
  • etcd-misuse (сценарий 3). Все три ассерта прошли на каждом из 7 прогонов без отклонений от брифа по содержанию, но заявленный по первым 2 прогонам узкий диапазон точки отказа и «идентичный» dbSize оказались переоценены по точности — независимая ревью-проверка (3-й прогон) вышла за пределы обеих оценок, диапазон расширен по результатам ещё 4 прогонов.

Источники

  • Стенд серии: digital-cookbook/performance/inmemory/benchmark — три сценария этой статьи (working-set, compute-locality, etcd-misuse), общий датасет серии из digital-cookbook/performance/inmemory.
  • Aerospike-стенд с гибридом RAM/flash и Ignite-стенд с collocated compute, на которых построены сценарии 1 и 2, — статья 4.
  • Tarantool-хранимка, на которой построен локальный путь сценария 2, — статья 3.
  • Механика вытеснения Redis (allkeys-lru), задействованная в сценарии 1, разобрана детально в deep-dive про память и eviction, а не в этой статье.
  • HyperLogLog из второй статьи серии — частный случай темы вероятностных структур данных: экономия памяти ценой приблизительности, тот же класс компромисса, что и весь in-memory computing, только в миниатюре.
  • Общая карта, где все шесть систем серии стоят рядом с остальными подходами к хранению данных, — хаб «Данные: карта хранилищ и подходов».

На этом серия «Вычисления в оперативной памяти» завершена: 1 — таксономия и трейдофы, 2 — Redis, 3 — Tarantool и Picodata, 4 — Aerospike и Ignite, 5 — etcd, 6 — эта статья.

Обсуждение в Telegram

Присоединиться →

Комментарии