Предыдущая статья закончилась обещанием: координатор запроса вычисляет владеющий shard по токену партиции и сам не читает данные — значит клиентский драйвер в принципе может пойти дальше и адресовать запрос прямо в нужный shard, минуя лишний шаг внутри узла. Здесь это обещание проверяется числами, а не архитектурной интуицией — и честный ответ скромнее, чем хотелось бы: на этом кластере (--smp 2, два шарда на узел) эффект есть и он воспроизводится, но он маленький — единицы процентов, — а у Java-драйвера после выравнивания методики он вообще развернулся в сторону, противоположную Go. Это не провал измерения. Это то, что реально происходит, когда экономия одного internal hop конкурирует не с нулевой альтернативой, а с сетевым round-trip до кворума реплик.
Это шестая статья серии «ScyllaDB: глубокое погружение», продолжение статьи о моделировании данных, статьи об архитектуре shard-per-core, статьи о compaction, tombstones и repair, статьи о consistency levels и LWT и статьи о token ring, tablets и multi-DC. Дальше в серии — эксплуатация и итоговый выбор.
Стенд этой статьи — два независимых клиентских бенчмарка поверх того же трёхузлового кластера серии, читающие уже загруженную readings (672 000 строк из первой статьи): Go (drivers/go) и Java (drivers/java), оба в публичном репозитории примеров (digital-cookbook, каталог scylladb/drivers).
В статье
- Shard-aware маршрутизация: что это
- Форк и апстрим: почему они не сосуществуют
- Go: aware быстрее naive — на пару процентов
- Java: OFF быстрее ON — направление наоборот
- Честные находки методики: JIT-прогрев и выравнивание CL
- Итог: маленький и не всегда однонаправленный эффект
Shard-aware маршрутизация: что это
Token-aware маршрутизация — уже известный клиенту приём: драйвер вычисляет токен партиции запроса и отправляет его на узел, который этим токеном владеет, вместо случайного узла из пула. Это экономит один сетевой хоп между узлами (координатор не пересылает запрос дальше по кластеру), но внутри самого узла всё ещё остаётся работа: --smp N независимых шардов-реакторов, а токен указывает не только на узел, но и на конкретный shard этого узла. Если соединение клиента пришло на «случайный» shard узла (первый принявший TCP-соединение), а токен указывает на другой, координирующему shard приходится пересылать запрос внутренним RPC своему соседу по тому же узлу — тот же межшардовый hop, который уже был виден в счётчиках статьи об архитектуре shard-per-core.
Shard-aware маршрутизация идёт на шаг дальше token-aware: драйвер устанавливает TCP-соединения через отдельный shard-aware порт сервера, и каждое такое соединение жёстко привязано к одному конкретному CPU-шарду. Зная токен запроса заранее, клиент выбирает уже готовое соединение, которое смотрит прямо в нужный shard, — внутренний RPC-hop между шардами одного узла не нужен вообще, не только между узлами.
(тот, что принял TCP)"] CO -->|"внутренний RPC-hop"| SN["Shard, владеющий партицией"] end subgraph AWARE["Aware — TokenAware + shard-aware порт"] direction TB CA["Клиент"] -->|"токен партиции известен заранее"| SA["Shard-aware TCP-соединение,
привязанное к нужному ядру"] SA -->|"напрямую, без hop"| SW["Shard, владеющий партицией"] end style NAIVE fill:#f4d9c6,stroke:#c67a4a style AWARE fill:#c9e4c5,stroke:#5b8a5e
flowchart LR
subgraph NAIVE["Naive — RoundRobinHostPolicy, без токена"]
direction TB
CN["Клиент"] -->|"случайное соединение"| CO["Координирующий shard узла
(тот, что принял TCP)"]
CO -->|"внутренний RPC-hop"| SN["Shard, владеющий партицией"]
end
subgraph AWARE["Aware — TokenAware + shard-aware порт"]
direction TB
CA["Клиент"] -->|"токен партиции известен заранее"| SA["Shard-aware TCP-соединение,
привязанное к нужному ядру"]
SA -->|"напрямую, без hop"| SW["Shard, владеющий партицией"]
end
style NAIVE fill:#f4d9c6,stroke:#c67a4a
style AWARE fill:#c9e4c5,stroke:#5b8a5e
Форк и апстрим: почему они не сосуществуют
Первая идея этого стенда была нагляднее, чем то, что в итоге получилось: подключить в одном проекте upstream-драйвер и форк ScyllaDB под разными алиасами и сравнить их напрямую — github.com/gocql/gocql (апстрим) против github.com/scylladb/gocql (форк) в одном go.mod, com.datastax.oss:java-driver-core против com.scylladb:java-driver-core на одном classpath. Эта идея физически не реализуема, и это не предположение, а проверенный факт.
В Go форк ScyllaDB слит с апстримом и самодекларирует тот же module path: go.mod форка объявляет себя как module github.com/gocql/gocql, а не github.com/scylladb/gocql. Подключить его можно только через replace github.com/gocql/gocql => github.com/scylladb/gocql v1.18.3 — стандартная замена координаты, применяемая во всех Go-модулях серии. Но именно поэтому два модуля с одинаковым module path не могут сосуществовать в одном go.mod: инструментарий Go не различит «апстримную» и «форковую» версию одного и того же пути импорта.
В Java та же картина, только видна не в исходниках, а в скачанном артефакте. Внутри java-driver-core-4.19.2.0.jar (форк, groupId=com.scylladb) — единственный top-level Java-пакет: com/datastax/.... Ни одного класса под com/scylladb/.... Форк — это патч поверх апстримного драйвера, опубликованный под собственными Maven-координатами, но с теми же именами классов (com.datastax.oss.driver.api.core.CqlSession и так далее), что и апстримный com.datastax.oss:java-driver-core. Положить оба jar на один classpath — гарантированный конфликт дублирующихся классов, где реально загрузится для CqlSession, будет решать порядок classpath, а не код приложения. Тот же конфликт и в пространстве конфигурации: reference.conf форка объявляет корневой ключ datastax-java-driver { ... } — тот же, что у апстрима, а Typesafe Config сливает одноимённые reference.conf-ресурсы со всего classpath, так что конфиги форка и апстрима тоже не расходятся сами по себе.
Оба случая — не архитектурная случайность двух независимых команд, а прямое следствие того, как именно ScyllaDB поддерживает свои форки: патч поверх апстрима, опубликованный под другим groupId/module path верхнего уровня, но с идентичным внутренним API и пакетами. Это делает миграцию с апстрима на форк тривиальной для пользователя — замена координаты зависимости, ноль изменений кода, — но по той же причине не позволяет держать оба варианта одновременно в одном проекте. Поэтому контраст «shard-aware включён / выключен» в этой статье сделан не через два драйвера, а конфиг-тумблером внутри одного форкового драйвера.
Go: aware быстрее naive — на пару процентов
drivers/go использует один драйвер github.com/gocql/gocql (форк через replace, как и остальные Go-модули серии) с флагом -mode aware|naive и гоняет точечные чтения случайных партиций telemetry.readings (WHERE device_id=? AND day=? LIMIT 1) — данные не мутирует.
-mode aware—gocql.PoolConfig.HostSelectionPolicy = gocql.TokenAwareHostPolicy(gocql.RoundRobinHostPolicy()). Это же дефолт форка без явной настройки.TokenAwareпрокидывает токен запроса в выбор соединения — форковый connection pool использует его не только чтобы выбрать правильный узел (это умеет и апстримный token-aware), но и конкретное shard-aware TCP-соединение на этом узле — клиент бьёт прямо в shard, владеющий партицией.-mode naive—gocql.RoundRobinHostPolicy(), без токена: узел выбирается по кругу, соединение — любое из пула. Coordinator почти всегда вынужден пересылать запрос внутренним RPC на владеющий shard — тот же межшардовый hop, что и в наивной ветке диаграммы выше.
mode throughput p50 p99
aware 653.3 rows/s 1.520ms 1.708ms
naive 639.7 rows/s 1.569ms 1.743msratio throughput[aware]/throughput[naive] = 653.3/639.7 = 1.021 — aware быстрее наивной маршрутизации примерно на 2.1% по throughput, p99 ниже примерно на 2.0%. Повторный независимый прогон (n=30000, другой seed, порядок наоборот — сначала naive, потом aware, чтобы исключить смещение от порядка запуска) дал тот же знак и тот же порядок величины: aware 655.3 rows/s / p99=1.636ms против naive 637.3 rows/s / p99=1.748ms, ratio = 1.028 (~2.8%). Направление устойчиво в обоих прогонах и не зависит от того, какой режим стартовал первым.
Java: OFF быстрее ON — направление наоборот
drivers/java (DriversBench) использует один драйвер com.scylladb:java-driver-core:4.19.2.0 — форк, реальный тумблер shard-awareness которого называется advanced.connection.advanced-shard-awareness.enabled (по умолчанию true, секция добавлена ScyllaDB поверх апстримного дерева опций) и переключается программно, через DriverConfigLoader.programmaticBuilder().withBoolean(DefaultDriverOption.CONNECTION_ADVANCED_SHARD_AWARENESS_ENABLED, enabled) — у драйвера есть отдельная typed-константа именно под этот ключ. Настройка общая на сессию, а не на профиль запроса, поэтому ВКЛ и ВЫКЛ в бенчмарке — это две разные CqlSession, а не два профиля одной.
mode throughput p50 p99
ON 2122.0 rows/s 451us 721us
OFF 2141.7 rows/s 447us 694usratio throughput[ON]/throughput[OFF] = 2122.0/2141.7 = 0.991 — здесь быстрее оказался вариант БЕЗ shard-aware маршрутизации, примерно на 0.9%. Повторный независимый прогон (-seed 99, те же изолированные процессы) подтвердил тот же знак: OFF 2152.8 rows/s / p50=444us / p99=686us против ON 2130.3 rows/s / p50=450us / p99=710us, ratio = 0.990 (OFF быстрее на ~1.1%). Направление устойчиво в обоих независимых прогонах — но устойчиво в пользу OFF, а не ON, то есть противоположно тому, что показал Go.
Честные находки методики: JIT-прогрев и выравнивание CL
Первая версия Java-бенчмарка запускала оба режима подряд в одном процессе JVM (-mode both: сначала ON, потом OFF) — и получила разброс, который сразу выдал проблему методики, а не архитектуры. Второй по счёту запущенный режим внутри процесса систематически оказывался быстрее ПЕРВОГО — независимо от того, какой режим шёл вторым: swing между порядками запуска составил 26–30%, на порядок больше самого эффекта shard-awareness (около 1%). Причина — прогрев JIT и буферов/соединений: первый прогон процесса платит за компиляцию горячих методов и разогрев пулов, второй — уже нет. Исправление то же, что уже используется в Go-бенчмарке: -mode on и -mode off — два независимых процесса, каждый запускается заново (java -jar с нуля на каждый режим), стартует с холодным JIT одинаково, и порядок запуска контейнеров больше не решает, какой РЕЖИМ выглядит быстрее.
Вторая находка тоньше и касается не производительности, а честности сравнения нагрузок. До явной настройки consistency level Java-бенчмарк молча наследовал дефолт драйвера LOCAL_ONE, тогда как Go всегда работал на QUORUM — то есть два бенчмарка формально сравнивали не идентичные нагрузки. На LOCAL_ONE Java показывал ON быстрее OFF на 0.7–1.2%. После явной установки CL=QUORUM на каждый BoundStatement (как у Go) направление развернулось — быстрее стал OFF. Это не регрессия, случайно внесённая правкой, а честный побочный эффект честного исправления методики: на QUORUM каждый запрос уже ждёт ответа от большинства реплик — кросс-узловой round-trip, который на порядок дороже одного внутриузлового shard-hop. Выигрыш в единицы процентов, заметный на LOCAL_ONE, на QUORUM тонет в дисперсии кросс-узловых round-trip’ов и даже разворачивается по знаку — воспроизводимо в обоих независимых прогонах, хоть и в пределах шум-масштаба при n=20000.
Итог: маленький и не всегда однонаправленный эффект
На --smp 2 эффект shard-aware маршрутизации — модест, 0.9–3% в зависимости от драйвера и прогона, а не порядок величины, который обещает сама идея «клиент бьёт прямо в нужное ядро». Go на CL=QUORUM устойчиво показывает aware быстрее naive на 2–3% в обоих независимых прогонах. Java на том же CL=QUORUM устойчиво показывает обратное — OFF быстрее ON на 0.9–1.1%, тоже в обоих прогонах, и это направление противоположно тому, что было видно на унаследованном дефолте LOCAL_ONE до выравнивания CL. Оба результата — реальные измерения на одном и том же двухшардовом стенде, не подгонка под ожидаемо эффектную цифру.
Бары нарисованы в честном, не растянутом масштабе — и они почти неотличимы по высоте, что и есть сам результат: разница на этом стенде — единицы процентов, глубоко внутри визуального шума. Зелёный цвет везде обозначает «shard-aware включён» (aware/ON), оранжевый — «наивная маршрутизация/выключен» (naive/OFF); то, что справа оранжевый бар выше зелёного, а слева наоборот, — не ошибка перепутанных цветов, а буквально то, что показали независимые прогоны.
Почему так — понятная механика, а не случайность. При двух шардах на узел промах мимо нужного шага при наивной маршрутизации — не редкое событие: у координатора всего один шанс выбрать «чужой» shard из двух, умеренная вероятность промаха. А на CL=QUORUM запрос в любом случае уже платит за кросс-узловой round-trip до большинства реплик — расход, на порядок превышающий стоимость одного внутриузлового RPC-hop между двумя шардами того же узла. На production-кластерах с 8–32+ шардами на узел промах мимо нужного shard статистически куда вероятнее (один верный вариант не из двух, а из N), а при менее строгом consistency level (LOCAL_ONE/LOCAL_QUORUM без кросс-DC round-trip) экономия внутриузлового hop перестаёт теряться на фоне более дорогой операции. Отсюда обоснованное ожидание: на таком кластере эффект shard-aware маршрутизации пропорционально больше измеренных здесь 1–3%. Но это именно ожидание из механики, а не замеренный факт — на 8–32-шардовом узле его нужно подтверждать отдельным прогоном, а не переносить сюда линейной экстраполяцией. Этот стенд намеренно не подгоняет цифры под эффектный результат — он воспроизводит то, что реально измеряется на доступном двухшардовом кластере.
Не стоит превращать это в лозунг «shard-aware всегда быстрее» — на этом стенде это не всегда так даже для одного из двух языков, а сама величина эффекта — единицы процентов, не порядки. Направление и величина зависят от числа шардов на узел и от того, насколько дорогой кросс-узловой round-trip уже заложен выбранным consistency level; экономия одного внутриузлового hop заметна ровно там, где ей есть на фоне чего быть заметной.
Версии на стенде: образ scylladb/scylla:2026.2.0, gocql — форк v1.18.3 через replace github.com/gocql/gocql => github.com/scylladb/gocql v1.18.3 (Go 1.26), java-driver-core — форк 4.19.2.0 (groupId=com.scylladb, JDK 25), датасет — та же readings из первой статьи серии (672 000 строк). Дальше в серии — эксплуатация и итоговый выбор: мониторинг, backup/restore и Alternator API поверх того же кластера.
Комментарии