Пять предыдущих статей разбирали шесть систем серии по ролям, каждую по отдельности. Эта, финальная, не добавляет седьмую роль — она сталкивает системы на одной и той же нагрузке одновременно, в трёх конкретных сценариях, и заканчивается картой выбора, которая сводит всю серию в одну таблицу. Сценарий 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.
Перед первым числом — обязательная остановка, а не сноска в конце. У этого стенда есть жёсткая методическая граница, и она решает, какие числа вообще можно опубликовать честно.
В статье
- Границы метода
- Сценарий 1: рабочий набор за границей RAM
- Сценарий 2: цена выноса вычислений к данным
- Сценарий 3: etcd не по назначению
- Карта выбора
- Что не воспроизвелось
- Источники
Границы метода
Вся серия работает под 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) |
а не 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"]
Дерево — упрощение, не замена таблице выше: оно отвечает на первый грубый вопрос «с чего начать смотреть», а не заменяет разбор трейдофов. В частности, оно не показывает, что происходит на стыке двух листьев (данные растут быстрее 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 — эта статья.
Комментарии