#56: Apache Cassandra, часть 1: клиент, сервер
Александр Пахомов и Дмитрий Константинов (коммитер Apache Cassandra) проходят путь запроса end-to-end со стороны клиента: как устроен драйвер Cassandra и как, поняв его, написать собственный. Разбирают contact points и service discovery, равноправные ноды и rolling upgrade, CQL и модель данных (keyspace, partition/clustering ключи, upsert без чтения), prepared statement с кэшем на сервере, кодеки-сериализацию, выбор ноды и локальность дата-центров, а на нижнем уровне — бинарный протокол поверх Netty: нарезку TCP-потока на фреймы, корреляцию ответов через stream id, таймауты на hash wheel timer, идемпотентные и спекулятивные ретраи, backpressure. Это первая часть трилогии; серверная сторона — в следующих выпусках.
Главное
- Cassandra — кластер равноправных нод без выделенного лидера: клиенту дают `contact points`, а полный список адресов драйвер вытягивает из служебных таблиц `system.local`/`system.peers` — это встроенный service discovery.
- Модель данных задаётся на этапе `CREATE TABLE`: обязательный `PARTITION KEY` определяет шардирование (все строки одного ключа лежат вместе), а опциональный `CLUSTERING KEY` задаёт порядок внутри партиции — по сути двухуровневая распределённая хэш-таблица.
- `INSERT` и `UPDATE` в Cassandra — это один и тот же upsert без чтения предыдущего значения (`read-before-write` отсутствует), поэтому нет `UNIQUE`-констрейнтов, зато запись быстрая и почти всегда идемпотентная.
- `PREPARED STATEMENT` кэшируется на конкретной ноде под `MD5`-хэшем запроса; репликации нет — если нода не знает id, драйвер обязан переподготовить запрос, а сервер держит кэш ограниченного размера (Caffeine).
- Драйвер выбирает ноду стратегией `load balancing policy`: отсекает мёртвые и удалённые дата-центры, предпочитает локальный DC и реплики партиции, учитывает загрузку и даже uptime ноды (JVM-прогрев).
- Бинарный протокол работает поверх Netty: TCP — это поток, который надо res* нарезать на фреймы по длине из хедера; ответы коррелируются с запросами через `stream id`, что позволяет держать десятки тысяч запросов in-flight на одно соединение.
- Таймауты реализованы через `hash wheel timer` — кольцевой массив-«циферблат» с хэшированием по остатку от деления, дешёвый на постановку и отмену тысяч одноразовых таймеров.
- Идемпотентные запросы драйвер может ретраить; поверх обычных ретраев есть спекулятивные (hedged) запросы — по 90-му перцентилю латентности драйвер шлёт дубль в другую ноду и берёт ответ того, кто ответит первым, снижая хвостовые задержки.
В выпуске
- Дмитрий Константинов — Системный архитектор и Java-разработчик, коммитер Apache Cassandra; специализируется на распределённых системах, производительности и отказоустойчивости. Регулярный спикер JPoint/Joker, на Хабре — @netudima. Habr ↗ jpoint.ru ↗
Ссылки
Расшифровка
[00:00] Александр: Здорово! Это 56-й выпуск подкаста «Тысяча фичей». Это первая часть из трилогии, из трёх больших частей про Apache Cassandra. Разобраться, как работает эта распределённая key-value база данных, мне поможет Дима Константинов. Пожалуй, Дима — это человек на русскоязычном пространстве, который обладает наибольшей экспертизой по Apache Cassandra: он коммитер в эту базу данных и знает очень много. Первая часть — про клиент, про то, как работает клиентская сторона. По итогам, я надеюсь, вы, как и я, сможете просто взять и написать свой драйвер для Apache Cassandra. Ну, не буду затягивать: выпуск получился супердлинный и суперинтересный. Заваривайте чаёк, настраивайтесь, прогревайте нейроны. Начнём по лайту, а потом будет очень интересно. Поехали!
[01:03] Александр: Мне понравилась твоя идея пройтись end-to-end — от самого края клиента до последнего диска на последней реплике на бэкенде — и посмотреть, что происходит: как запрос трансформируется в данные, какой путь проходит, какие структуры его окружают, где bottlenecks. Это суперинтересно, и мне кажется, мы могли бы попробовать это сделать в подкасте. И сразу — задача для слушателей: вот вы сейчас смотрите или слушаете. Ваша задача — вникнуть и представить у себя в голове эти картинки, потому что мы будем говорить про сложные вещи, и нам нужна ваша помощь. Подключайте нейроны. Давай попробуем повизуализировать клиент и сервер. Дим, начинай, а я буду по дороге спрашивать глупые вопросы.
[01:52] Дмитрий: У нас есть классическая ситуация: есть какая-то база данных, и мы хотим с ней пообщаться.
[01:58] Александр: Ну, не какая-то, а вполне определённая.
[02:00] Дмитрий: В нашем случае — Cassandra, и мы хотим с ней работать. Всё как обычно: есть специальная библиотека, которая отвечает за работу с этой базой данных в том или ином языке программирования.
[02:10] Александр: Кстати, какие языки-клиенты есть?
[02:12] Дмитрий: Поскольку я разработчик на Java, я больше всего люблю и знаю библиотеку на Java — и она, наверное, самая развитая среди клиентов для Cassandra. Но в целом есть клиенты для Python, C, C++, есть Go-драйвер. По-моему, какие-то наработки были на Rust, но я не уверен — скорее всего, это что-то не в составе основного стека, который развивается в комьюнити. Пожалуй, всё. Может быть, я что-то забыл; возможно, на C# что-то есть, но я не уверен.
[02:45] Александр: Окей. Ну, мы с тобой оба Java-программисты — я фултайм пишу на Java уже довольно долго, — так что говорим про Java-библиотеку. Вот вы взяли какую-нибудь IntelliJ IDEA, открыли на лэптопе новый проект, добавили в Gradle зависимость — на Groovy build script, обязательно.
[03:04] Дмитрий: А я на Maven пишу.
[03:06] Александр: А, ну Maven — это вообще… Про это можно будет поговорить отдельно; я, кстати, ещё не пробивал систему, надо будет поразмышлять. Я в целом и то и то ок, но мне Gradle больше нравится просто потому, что у него уже есть команды, которые я помню: init, clean, build, check. Я знаю, как написать таски, мне так привычнее. В общем, я добавил зависимость одной строчкой — какой-нибудь Cassandra client, указал последнюю версию, — и у меня сразу появляются некоторые структуры данных в проекте. Что у меня там будет?
[03:36] Дмитрий: Ты создаёшь сущность типа кластер. Я могу немножко прыгать в терминологии, потому что в Cassandra Driver было два поколения. Был драйвер третьей версии — из недавних поколений, — а потом его довольно существенно переписали по API в четвёртой. Я большую часть жизни писал на третьей, поэтому могу иногда скатываться в неё. Идеологически они похожи, отличаются названия объектов. Суть в том, что в старой версии тебе нужно было создать сущность «кластер», а в новой она, по-моему, называется «сессия». И здесь хочется подчеркнуть особенность, которая мне нравится в Cassandra Driver: в нём нет проблем, которые есть в JDBC-драйверах.
[04:25] Александр: Так, а какая проблема?
[04:26] Дмитрий: Те, кто работал с JDBC, знают, что очень многие сущности там не потокобезопасны. Чтобы работать с коннекшенами, ты должен использовать коннекшен только в одном потоке в один момент времени. Для этого тебе нужен connection pool, чтобы это обеспечить и чтобы был нормальный perfomance. Дальше — все statement и prepared statement тоже не потокобезопасны, и это краткоживущие объекты. А в Cassandra Driver для разработчиков всё существенно удобнее. Ты создаёшь объект session, через который будешь выполнять запросы, и, пожалуйста, используй его из любого потока — он потокобезопасен. Ты создаёшь сущности типа выражений, которые хочешь выполнять; можешь их подготовить — про prepared statement поговорим позже. Один раз создал при инициализации, сохранил и переиспользуй, как хочешь. Всей этой головной боли про непотокобезопасные сущности в этом драйвере нет.
[04:59] Александр: А что нужно передать в эту сессию или кластер? То есть что он принимает на вход?
[04:59] Дмитрий: Классическая вещь. Понятное дело, чтобы подключиться к базе данных, нам нужен какой-то адрес. Поскольку база стоит на другой машине, мы туда в том или ином виде передаём адреса. Но тут есть отличие.
[05:47] Александр: Давай, раз уж мы про JDBC-connection заговорили, будем сравнивать с каким-нибудь Postgres.
[05:55] Дмитрий: Обычно, когда подключаешься к базе данных, ты указываешь адрес одного хоста — это классические, нераспределённые базы. Cassandra же распределённая, и драйвер, когда будет посылать запрос, на самом деле будет общаться с несколькими нодами и сам выбирать, куда посылать. А это значит, что адресов, вообще говоря, несколько.
[06:20] Александр: То есть я прямо на вход передаю массив строк?
[06:24] Дмитрий: Да, он называется contact points.
[06:31] Александр: Давай я спрошу: а почему я не могу просто передать список адресов? Почему contact points, что это за имя такое?
[06:38] Дмитрий: Тут можно провести аналогию с Kafka — кто-то, может быть, работал с ней и знает, как там всё устроено. В Cassandra похожая идея: когда ты подключаешься к кластеру, в котором много нод, ты, с одной стороны, хочешь передать несколько адресов, чтобы обеспечить отказоустойчивость — если один из адресов недоступен, ты всё равно подключишься к кластеру. Поэтому ты перечисляешь несколько адресов. Но ты не обязан перечислять все. Поэтому это и называется contact points. Идея в том, что когда ты подключишься к кластеру, драйвер во время инициализации получит из кластера список всех оставшихся нод и сформирует полный набор адресов, с которыми будет работать. Причём этот список может меняться и в рантайме: в базе появятся новые узлы во время работы приложения — и список динамически обновится, драйвер получит об этом уведомление. Похожая идеология и в Kafka-брокере: там это называется bootstrap URL, потому что, когда ты подключаешься, происходит то же самое — клиент вытаскивает полный список адресов.
[07:51] Александр: Это очень важное замечание. Вроде бы мелочь: ну передал какие-то адреса. А как для инженера — что важно?
[08:00] Дмитрий: Стоит понимать, что, передав эти адреса, ты, во-первых, не получаешь от библиотеки гарантии, что данные будут писаться именно в эти ноды. Трафик может гоняться не между твоим клиентом и одним из переданных адресов, а с другой нодой, которую он получил от переданной. Это очень важно понимать.
[08:23] Александр: То есть контакт получил информацию — а дальше в другие места можно писать.
[08:26] Дмитрий: Именно. Потому что там, например, как мы дальше узнаем, может быть primary-реплика для партиции, и к ней лучше обращаться сразу. А ещё важно, что сетевая доступность между клиентом и потенциальным сервером должна быть не только среди contact points, но, скорее всего, между всеми нодами кластера. И нам как инженерам, которые деплоят приложения в продакшен и настраивают всякие сетевые штуки, это тоже стоит понимать: недостаточно доступности до тех трёх-четырёх нод, что ты указал, — доступность нужна для всего кластера, иначе потом будут сюрпризы. Тут есть типичная ловушка для такого рода систем: когда ты сидишь через NAT и пытаешься подключиться к серверу, первичный connection проходит, потому что ты указал внешний адрес. Но когда драйвер запросил у базы адреса, база вернула свои внутренние адреса, а ты в другой сети — и драйвер после этого либо не может подключиться, либо получаются какие-то частичные подключения.
[09:00] Александр: И как с этим быть?
[09:32] Дмитрий: В случае Cassandra Driver на этом этапе есть специальный адаптер, если кому-то нужен. По-моему, были готовые реализации для Amazon AWS, но я ими не пользовался. В целом там есть интерфейс, который позволяет сделать address translation: он умеет преобразовывать одни IP в другие. Когда база возвращает один IP, ты на уровне драйвера можешь по каким-то правилам превратить его в другой — например, тебе дали внутренний IP, а ты на уровне клиента трансформируешь его во внешний.
[10:09] Александр: Это хороший нюанс, да, очень хороший. Мы на самом деле сейчас неявно увидели один из дизайн-паттернов, который прямо в драйвере реализован. По факту это service discovery: приложение подключилось к базе и динамически задискаверило другие ноды. Только эта штука встроена в логику самого клиента.
[10:33] Дмитрий: Тут, наверное, тоже терминология зависит от взгляда, но я как человек, который сам реализовывал такую штуку, понял, что отдать правильный адрес на клиент — это вообще-то проблема. Как разработчик базы данных, у тебя не так много инструментов, чтобы понять, какой у тебя IP. Такие вещи иногда даже не смотрят в сеть: ты не можешь просто сходить куда-то за «get my IP», спросить у Java или у операционной системы. Каждый из способов может вернуть разные адреса. Он может вернуть локальный адрес локальной сети — какой-нибудь 172.x.x.x. И если ты этот IP просто отдаёшь наружу, а у человека своя 172.x-сеть — он тебя не найдёт в обратную сторону. Поэтому либо на уровне драйвера задаёшь маппинг, либо в сервере задаёшь конфигурацию ноды: «ты доступна по такому-то DNS/адресу», явно при деплойменте. В некоторых системах так бывает. В Cassandra на уровне конфигурации можно явно сказать, на каком адресе ты слушаешь, а какой публикуешь в качестве точки. Это не настолько гибко, как в той же Kafka, где можно сделать целый набор конфигураций и на один listen-адрес выставить несколько так называемых endpoint’ов. В Cassandra просто два параметра. Первый — адрес, на котором я, как серверная нода, слушаю входящие TCP-соединения; я должен уметь забиндиться на этот адрес, он должен быть известен локально операционной системе на сети. Когда делаешь socket bind, ты передаёшь туда адрес и порт, и операционка должна принять этот запрос. Если укажешь какой-нибудь NAT-адрес, операционка скажет: «Я не понимаю, о чём ты, нет такого локального IP ни на одном сетевом интерфейсе». А второй адрес — тот, который в приложении ожидается для подключения.
[12:51] Александр: А как это на самом деле устроено с точки зрения базы данных?
[12:55] Дмитрий: Там не какой-то хитрый протокол. В базе данных есть служебные таблицы: system.peers, по-моему, и system.local. Local — это информация о локальной ноде, её адрес; peers — информация о других нодах. В совокупности эти две таблицы описывают, кто у нас есть в кластере. И когда драйвер подключается, после начальных реверансов он идёт и делает SELECT из этих служебных таблиц, находит там колонки с названиями вроде RPC-адреса, кэширует локально и использует эту информацию. Это просто SELECT из системной таблицы, а логика реализована на стороне драйвера.
[13:38] Александр: Догфудинг такой: Cassandra использует свои же таблицы для хранения информации, нужной ей для функционирования.
[13:47] Дмитрий: Почти все базы так или иначе это делают. Хотя не все: некоторые используют более оптимальные внутренние сторы. Иногда тебе как разработчику базы данных не нужен SQL-интерфейс — ты хочешь сразу key-value и побыстрее, тебе нужна распределённость этой машины, хранение какого-то состояния, а таблица не нужна; это более advanced-штуки. Но системные таблицы, мне кажется, есть в каждой базе в том или ином виде, потому что хранить всю информацию в каких-то внутренних структурах накладно.
[14:17] Александр: Окей. Мы на самом деле немножко приоткрыли завесу над сервером: показали, как нода понимает, где она находится, и как знает про другие ноды. То есть сервер в случае Cassandra — это не что-то одно, а n нод, каждая из которых, по сути, копия другой. Не с точки зрения данных, а с точки зрения функциональности: они все равные, пиры в Cassandra. Нет такого, что есть основная нода, к которой все коннектятся, и есть какие-то реплики?
[14:52] Дмитрий: Ты сразу решил далеко забежать.
[14:54] Александр: Ну, это чтобы у людей в голове были одинаковые точечки — десяток точечек-нод.
[15:00] Дмитрий: Это на самом деле одна из существенных особенностей Cassandra, из которой потом вытекают всякие полезные свойства и, наоборот, её слабости. Это кластер равноправных нод. Все ноды умеют делать одинаковую функциональность — нет нод, отвечающих за что-то одно: никаких контроллер-нод, дата-нод и прочих. Все ноды умеют делать всё. С другой стороны, там нет явно выбранных лидеров, которые отвечают за все операции. Вот в классической базе есть primary-реплика или мастер, в который мы совершаем запросы, а все остальные — пассивные повторятели того, что делает основная нода; и если что, управление переключается на них при падениях. В Cassandra это не так: везде, где возможно, стараются делать, чтобы все ноды были одинаковые — с маленькой звёздочкой. Они могут быть не совсем одинаковы по функциональности, потому что база данных иногда обновляется, и это желательно делать без даунтайма. Cassandra это умеет, и у тебя в кластере в какой-то момент могут быть переходные ноды разных версий. Но в целом они одинаковы.
[15:59] Александр: Это довольно сложная задача — правильно сделать.
[16:01] Дмитрий: Так называемый rolling upgrade — незаметное обновление кластера. Когда у тебя несколько нод, процесс обновления сразу становится очень интересным. Во-первых, ты не можешь их одновременно по щелчку обновить — это время. Во-вторых, ты идеологически не хочешь даунтайма вообще, даже на миллисекунду. Хотя, честно, я как разработчик базы данных был бы счастлив, если бы клиенты были на это согласны: остановили, быстро обновили — окей. Но нет. Получается, что в процессе работы десяти нод, грубо говоря, пять из них в одной версии, две сейчас отключены и на них накатываются новые файлики — скриптик только-только бежит, — а три в старой версии. И в этом гетерогенном состоянии всё ещё должно работать, потому что клиентам всё равно, обновление у тебя или нет. И ты как разработчик, когда пишешь новую фичу, должен держать в голове: а если пришло сообщение от старой версии? А насколько старая версия может быть? А если пришло от новой? Я чувствую эту боль людей. Но то, что в Cassandra поддерживается такой уровень апгрейдов, значит, что система реально production-grade — ей можно пользоваться, люди об этом подумали. Мало какие системы на старте вообще думают о таком, потому что это огромное количество ресурсов на поддержку и тестирование. Огромный респект разработчикам Cassandra, что они это сделали. Я не думаю, что оно появилось с самой первой версии — чтобы кто-то сел, подумал и заложил. Изначально я бы и не стал: ты сам себе вставляешь палки в колёса, замедляешь добавление новых фич, когда сразу начинаешь это поддерживать. Должна быть точка, где ты просто фигачишь код, а потом, начиная с некоторого момента, говоришь: всё, теперь обновляемся чётко. Перегиб в Cassandra произошёл где-то в районе третьей-четвёртой версии, по моим ощущениям. Как раз тогда народ, кто разрабатывает Cassandra, осознал, что процесс апгрейда достаточно болезнен — в нём вылезают неприятные баги. И примерно тогда комьюнити много вложилось в тестирование таких кейсов. Сейчас, если я делаю какую-нибудь фичу и коммичу, в post-commit-билде есть целая пачка тестов именно на проверку обновления: что с третьей версии можно обновиться на четвёртую, с четвёртой на пятую, в разных комбинациях. И это всё с существующими данными, с работающими запросами — система же ещё работает в это время, это тоже надо учитывать.
[19:11] Александр: Понятное дело, что тесты, может, не идеальные, не покрывают все сценарии, но видно, что люди обжигались на этом на практике и на будущее стали себе соломку стелить — явно это учитывать при разработке и во время тестирования.
[19:33] Дмитрий: Да.
[19:33] Александр: Неплохо мы про обновление поговорили, ещё даже не написав ни одного запроса.
[19:39] Дмитрий: Ну да, мы только адреса написали.
[19:41] Александр: В этом и есть забава подкаста. В общем, те, кто построил себе карту в голове с клиентом и сервером: вы размножили сервер на n нод, они все одинаковые, три-четыре из них — может быть, одну — мы указали как адрес клиенту. Клиент этот адрес принял, мы создали объект session или кластер у себя в IntelliJ IDEA на лэптопе и запустили базовый main, в котором этот объект создаётся.
[20:11] Дмитрий: Подожди. Кроме адреса — мы же всё-таки не на коленке пишем.
[20:17] Александр: Ну, мы разработчики, которые не на коленке что-то пишут, а хотят что-то деплоить в продакшен. Хотя вот прямо сейчас я в голове держал ноутбук на коленке, когда сессию создавал.
[20:27] Дмитрий: Окей, да, посерьёзнее. Надо вспомнить, что нельзя забыть про аутентификацию.
[20:35] Александр: О боже, это же совсем другая большая тема.
[20:37] Дмитрий: Надо же ещё как-то аутентифицироваться в базе. В простом варианте всё просто: в Cassandra ты передаёшь имя пользователя и пароль — basic authentication, так называется.
[20:51] Александр: По сути, что-то типа Basic.
[20:55] Дмитрий: Есть вариант писать что-то своё — есть точки расширения, можно свой аутентификатор засунуть, — но чаще всего то, что я видел, это что-то типа Basic. Ну а если нам страшно, что кто-то подсмотрит, можно включить поддержку TLS, и наше общение с базой пойдёт не просто через TCP-сокет, а через TLS.
[21:21] Александр: В чём проблема? Basic authentication — это, по сути, юзернейм-пароль, который передаётся в открытом виде при создании коннекции. Я как клиент сразу по установленному соединению передаю данные о себе: вот я, такой-то юзернейм, такой-то пароль. Эти данные максимум Base64-кодируют, но это штука не криптозащищённая — каждый man-in-the-middle, кто слушает наш трафик, может увидеть мой юзернейм-пароль. В этом проблема Basic, и люди, как правило, переходят на всякие асимметричные способы, пытаются использовать публичные ключи или вообще обходиться без пароля. А способ, про который ты говоришь, — TLS: давайте сделаем защищённым само соединение, и через эту трубу уже никто не подсмотрит пароль. По сути, это то, как работает HTTPS — HTTP + TLS, SSL, — все эти штуки используют пару «публичный-приватный ключ». Настолько элегантный способ, что я каждый раз, когда пытаюсь понять, как он работает, понимаю, — а на следующий день никому не могу объяснить.
[22:43] Дмитрий: Ну, если хочешь поговорить про TLS, я могу.
[22:55] Александр: Возможно, мы под это отдельный выпуск запишем, потому что у меня в бэклоге подкаста есть список технологий — TCP и TLS, — про которые хочется поговорить. Но на уровне Cassandra нам достаточно понимать, что соединение может быть защищённым: мы устанавливаем безопасный канал связи, который злоумышленник не может подслушать и в который не может внести модификации. Например, ты обновляешь баланс с деньгами, а злоумышленник сидит и раз — поменял сумму.
[23:35] Дмитрий: Можно же придумать алгоритмы шифрования, которые позволяют зашифровать данные, — злоумышленник не может прочитать твои данные, но при этом знает, что в 15-м байте сообщения лежат деньги. Он не видит, сколько ты передал, но может изменить этот байт так, чтобы сумма стала больше. Если ты это не детектируешь — получаешь проблемы. Поэтому TLS обеспечивает не только конфиденциальность — что никто не может подсмотреть, — но и защиту от таких изменений, контроль целостности в криптографическом смысле.
[24:14] Александр: Да. И по этой защищённой трубе — я люблю называть её трубой — мы уже передаём юзернейм и пароль в открытом виде и не паримся, потому что никто не прочитает и не изменит. Так что с Basic всё окей: просто basic authentication, а если очень надо — делайте TLS, и всё супер. Давай TLS опустим, это детали; важен сам факт, что это есть. Мы сконфигурировали в объект session вместе с адресами юзернейм и пароль. Что-то ещё мы туда можем положить? Что нам нужно?
[24:48] Дмитрий: Там есть пачка всяких настроек, но на самом деле это три основных параметра, которые надо передать на начальном этапе: имя пользователя, пароль и куда подключаться. Всё остальное опционально, у настроек есть дефолты, так что пока опустим — возможно, в процессе разговора вернёмся.
[25:12] Александр: И вот у меня есть этот session, пока только в коде, в IntelliJ IDEA, в main-функции. Я могу вызвать какие-то методы, чтобы удостовериться, что тройка «юзернейм, пароль, список адресов» валидна и работает, и дальше создавать таблички, читать данные. Наверное, мне нужен тестовый пинг или какой-нибудь SELECT * из дефолтной таблицы — какую-то dummy-операцию выполнить и увидеть success?
[25:44] Дмитрий: Как мы только что обсудили, когда драйвер подключается к базе — то есть создаётся этот объект session, — что он делает? Он самостоятельно открывает внутреннее служебное соединение, оно называется control connection, через которое и узнаёт адреса всех остальных: делает запрос в служебную таблицу. То есть уже в тот момент, когда ты создаёшь объект, он этим занимается, и если ты передал неправильные креденшелы или адрес, ты получишь ошибку уже на этапе создания connection, если я правильно помню. Никаких отдельных SELECT делать даже необязательно. А дальше происходит очень интересная вещь: получив список всех адресов, драйвер сам, незаметно для тебя, в бэкграунде откроет отдельные TCP-коннекшены — для простоты пока скажем, в каждую ноду. На самом деле не в каждую, но упрощённо — в каждую. И вся эта аутентификация повторится, потому что то, что ты аутентифицировался в одной ноде, не означает, что другая нода будет с тобой просто так разговаривать. Процесс аутентификации происходит для каждого TCP-коннекшена.
[26:14] Александр: Прикольно. Я опять мыслю как разработчик базы данных: в целом можно было бы сделать так, чтобы достаточно было один раз аутентифицироваться, а потом ходить с неким токеном — чтобы, например, не держать пароль где-то в памяти клиента. Но это уже загоны, это тоже норм, что каждый раз так просто. И важно, что ты сказал: у меня есть эти условные N нод — в моём случае 10, слушатели могут придумать свою цифру, — и она должна быть довольно большая, три ноды — несерьёзно.
[27:47] Дмитрий: Ну, дев-окружение может быть даже одной нодой.
[27:52] Александр: Чаще всего типичное минималистичное окружение — это три ноды.
[28:00] Дмитрий: А верхняя граница на тех проектах, где я работал с Cassandra, не очень большая — десятки нод. Но в индустрии, судя по докладам, компании типа Apple, Uber, Netflix используют кластеры, где один кластер, в котором ноды знают друг друга, может иметь размеры в тысячи нод.
[28:33] Александр: Тысячи? Это на самом деле очень много. Возможно, поэтому мы должны подключаться не ко всем нодам, а к некоторому подмножеству.
[28:41] Дмитрий: Да, это уже оптимизация, потому что держать тысячу TCP-коннекций для одного клиента — очень много. Я, кстати, не знаю, сколько в среднем позволяет держать активных TCP-соединений операционная система, сколько файловых дескрипторов.
[28:55] Александр: Десятки тысяч?
[28:55] Дмитрий: Достаточно много может открыть. Всё это ограничивается тем, что TCP-коннекшен идентифицируется четырьмя параметрами: IP-адрес отправителя, IP-адрес получателя, source port, target port. В нашей задаче IP-адреса зафиксированы: ты сидишь со своим фиксированным IP и общаешься с Cassandra-сервером; если зафиксировать один сервер, то и его IP тоже фиксирован. Порт получателя ты тоже знаешь — обычно порт сервиса фиксирован, в Cassandra по умолчанию 9042. Значит, остаётся только один свободный параметр — твой source port, от которого ты открываешь TCP-коннекшн как клиент. Портов у нас 2 в 16-й степени, 65 с чем-то тысяч. Соответственно, столько TCP-коннекшенов ты можешь открыть в одну ноду, а дальше это ещё умножается на количество нод. Так что вполне возможно открыть с одного сервера сотни тысяч коннекшенов. В Linux ты, скорее всего, упрёшься в число открытых файловых дескрипторов, потому что Linux трактует сокет как файловый дескриптор. Его, кстати, в случае Cassandra довольно часто надо тюнить: база данных, много файликов, много открытых коннекшенов, и дефолтных условных 1024 открытых дескрипторов точно не хватит.
[30:53] Александр: Да, это тоже хорошо. Получается, клиент через несколько нод получил другие, открыл со всеми коннекции, и теперь со всеми у него отдельное TCP-соединение. Это то, что произошло при создании объекта session и при запуске программы.
[31:13] Дмитрий: Да.
[31:13] Александр: И дальше мы хотим выполнить какой-то запрос. У нас есть объект session, и у него есть методы «выполни мне statement».
[31:22] Дмитрий: Там много методов: если посмотреть API этого объекта, можно выполнить синхронно, можно асинхронно, можно разного типа запросы. Суть в том, что этот session — по факту singleton. Ты можешь создать несколько сессий, но обычно тебе это не надо: несколько объектов нужны, если ты подключаешься к разным кластерам одновременно. А если к одному кластеру — то один объект session, де-факто singleton, через который ты выполняешь работу. Множество connection, можешь использовать множество потоков — и никаких пуллов делать не нужно, всё спрятано внутри драйвера.
[32:10] Александр: И вот у нас этот session, на нём мы хотим выполнить какой-нибудь простой INSERT, пока без performance-оптимизаций. Я вызываю метод на объекте session и передаю туда строчку либо объект, который содержит эту строчку, — statement, который надо выполнить.
[32:34] Александр: Давай для примера, чтобы прямо ясно было. Раз я первый раз работаю с базой, я бы, наверное, создал таблицу — хочу данные писать и читать. И сразу вопрос: давай передадим CREATE TABLE — Саша Пахомов, ID и так далее. Что вообще будет в этом стейтменте? Поддерживается ли стандартный SQL, или это какой-то Cassandra-диалект — тот самый CQL, который через «C», как русское «С»? Что в этой строчке я должен написать, чтобы создать таблицу?
[32:36] Дмитрий: Как ты упомянул, у Cassandra свой query language — во-первых, он вообще есть, не у всех баз есть свой query language. Он называется CQL — Cassandra Query Language. Он похож на SQL, но я бы сказал — только похож. Он не является подмножеством какой-то SQL-спецификации, не ANSI-стандартизирован, без штампика. Он пытается быть похожим, чтобы быть дружелюбным для пользователей, но один в один запрос из Postgres ты в Cassandra не отправишь — у него свои особенности. И раз мы говорим про создание таблиц, ты немножко перескочил один шажок. Таблицы в Cassandra не висят в воздухе — есть сущность, которая их содержит. Она называется keyspace. Всё в Cassandra начинается с создания keyspace-объекта. Это контейнер для таблиц, на котором задаются важные параметры — а именно то, как твои данные будут реплицироваться в кластере из множества нод. Все таблицы в одном keyspace реплицируются по одинаковым правилам.
[34:39] Александр: То есть, чтобы создать таблицу, мне нужно создать или использовать имеющийся keyspace. Ближайшая аналогия из других баз — это, собственно, сущность database.
[34:56] Дмитрий: Или схема, да. В общем, что-то, где лежат таблицы.
[34:59] Александр: Окей, я создал keyspace и могу задать помимо имени некоторые конфигурации — например, степень репликации: сколько копий данных для всех таблиц в этом keyspace я хочу.
[35:15] Дмитрий: Так как база распределённая, одной копии, как правило, недостаточно: она лежит на одной ноде, а нода может, например, обновляться в какой-то момент. И откуда данные брать, пока нода обновляется? Непонятно.
[35:28] Александр: Она может просто выйти. Скорее даже упасть.
[35:31] Дмитрий: Да, выдернули шнур уборщицей из стойки — всякое может случиться. Данные хочется реплицировать в распределённой базе, если они для нас важны.
[35:40] Александр: Поэтому я создал keyspace. В нём теперь могу создавать таблицу, правильно?
[35:45] Дмитрий: Ты создаёшь таблицу, и у неё, как в SQL, есть колонки. Что такое таблица? Это набор колонок. Ты говоришь: есть колонка «имя», колонка «фамилия», «дата рождения». У этих колонок есть типы данных — Cassandra типизированная база: не так, что можешь положить что угодно, а потом в рантайме разберёмся. Не всё есть byte array.
[36:10] Александр: То есть не всё byte array?
[36:13] Дмитрий: Ты можешь сказать, что есть byte array, но то, что ты с ним делаешь, — уже твоё личное дело. В реляционных базах есть blob — аналог blob есть и в Cassandra. И там есть набор типов, которые мы поддерживаем; он не очень большой.
[36:30] Александр: А есть помимо стандартных int-ов всех размеров, строк — какие-то сеты, мапы? Может быть, user-defined types?
[36:41] Дмитрий: Есть и то и другое. Есть возможность создавать коллекции — это set, list и map.
[36:50] Александр: То есть колонкой в таблице может быть самая натуральная мапа?
[36:56] Дмитрий: Да. Cassandra же не реляционная база, и довольно часто тебе нужно как-то денормализовать данные. Денормализация — это процесс, когда мы не пытаемся идеально разложить данные по множеству таблиц, соблюдая нормальные формы n-ного количества, а специально складываем в свою таблицу для удобства или производительности сложную структуру. Например, пользователь, у которого есть роли, — ты можешь список ролей положить просто в колонку.
[37:38] Александр: То есть мне не нужно создавать несколько таблиц и использовать реляционную алгебру.
[37:45] Дмитрий: Таблицу с маппингами ты можешь сделать, кто тебе запретит, но есть способ сделать это через вложенный сложный тип: либо через коллекции, либо через то, что называется user-defined type. Мы можем создать составной тип. Это как в Java: берём некий класс — или, точнее, рекорд сейчас больше похож на этот user-defined type, — где мы берём набор полей, группируем их вместе и называем новым типом. Например, это адрес: почтовый индекс, улица, дом, город, страна — набор строк и чисел. Мы всё это группируем в одну сущность, называем её user-defined type «адрес», и дальше, когда создаём таблицу, можем назначить колонке этот тип — значит, там будет лежать сложносочинённый объект. Причём он может быть достаточно сложносочинённым внутри, потому что в user-defined type ты можешь использовать коллекции — там может лежать список — и даже другой user-defined type. То есть у тебя внутри типа есть поле-список, в этом списке ещё один тип, и так далее — рекурсивно вниз ты можешь спускаться, весьма сложную сущность на четыре уровня вглубь. Это иногда бывает полезно на практике.
[39:26] Александр: В Rust есть проблема вложенных типов: если ты начинаешь определять тип, и в нём есть поле этого же типа, компилятор сходит с ума — точнее, даже не компилятор, а вопрос с аллокацией: сколько памяти нужно аллоцировать под это значение наперёд, если ты знаешь только описание, а не сами данные. Когда структура вложена, ты начинаешь аллоцировать поле вложенной структуры, а там та же структура, в которой тоже это поле, и так рекурсивно бесконечно. Естественно, так делать не хочешь, поэтому в Rust используют специальный тип Box, который является ссылкой, и ты аллоцируешь это в куче в рантайме. Так вот, в Cassandra можно ли типы одинаковые вкладывать?
[40:14] Дмитрий: По-моему, просто рекурсия запрещена, и всё — если я правильно помню. Я ни разу не делал рекурсивный тип.
[40:20] Александр: Наверное, кто-нибудь захочет хранить в своей колонке целый граф — это же графовые структуры. Но это, по-моему, уже перебор.
[40:32] Дмитрий: Как инженер я согласен. На практике я не встречался, и, скорее всего, ты получишь ошибку: при создании своего user-defined type ты пытаешься сослаться на идентификатор, которого ещё нет. Операция создания типа — такая же, как создание таблицы, и ты в своём типе ссылаешься на самого себя, которого ещё нет.
[41:04] Александр: Поэтому, скорее всего, увидишь ошибку в стиле «я не понимаю, о каком типе ты говоришь».
[41:07] Дмитрий: Это нормально. Лучше не надо. Система типов довольно развесистая — не то чтобы прям мощная, но позволяет решать почти все задачи, которые люди решают с помощью типов даже в языках программирования. Сет, лист, мапу я могу в колонку положить — это очень круто, не все базы данных так умеют.
[41:28] Александр: То есть я определяю набор колонок. Допустим, ограничиваюсь несколькими колонками типа string, или text, как она называется, и парой int. В общем, создал такую таблицу, мне её достаточно. И, наверное, что я хочу сделать дальше?
[41:44] Дмитрий: Но ты не просто… В некоторых базах ты можешь просто сказать, что есть такие колонки, и всё. В Cassandra ты обязан явно указать для таблицы primary-ключ. Не может быть таблицы без primary-ключа. В некоторых базах — по-моему, в Postgres — я могу создать просто таблицу, ключ опционален. А здесь это неотъемлемая часть таблицы, невозможно создать таблицу без primary-ключа.
[41:52] Александр: То есть я должен буду завести либо псевдополе, назвать его ID, либо, если понимаю, что e-mail является логичным ключом идентификации для приложения, могу задать e-mail как primary key.
[42:32] Дмитрий: Окей. Но вспоминаем, что Cassandra не простая база, а распределённая. И primary-ключ у неё не простой, а составной. Он имеет два кусочка, из которых, по крайней мере, один обязательный. Первый кусочек называется partition key, второй — clustering key.
[42:52] Александр: Подожди. То есть есть primary key, partition key и clustering key?
[42:56] Дмитрий: Да. И primary-ключ — это комбинация partition key плюс clustering key. То, что однозначно идентифицирует строчку в таблице, называется primary-ключ. Не может быть двух разных строчек с одинаковым primary-ключом. Если ты попытаешься вставить второй раз строчку с таким же primary-ключом, по сути ты обновишь строчку с существующим ключом. Можно провести аналогию с хэшмапой: у неё ключ — это primary-ключ. Ты делаешь put — либо вставляешь запись, либо обновляешь существующую.
[43:32] Александр: Но внутри этого primary-ключа есть clustering key и partition key, правильно? Если проводить аналогию с хэшмапой, то primary key — это ключ мапы, а внутри может быть строка, разделённая двоеточием, где слева partition key, а справа clustering key. Такой составной ключик из двух частей.
[43:56] Дмитрий: Если проводить аналогию, я бы сказал, что это двухуровневая хэшмапа.
[44:02] Александр: Да, это лучше.
[44:04] Дмитрий: Снаружи таблица — это хэшмап, в котором partition key является наружным ключом, ключом первого уровня. По этому ключу ты нашёл — и логично, что если это partition key, то это ключ от партиции. Ты попросил в мапе первого уровня значение по ключу и получил объект типа partition. По сути, этот partition — тоже мапа. Но тут, если подбирать Java-типы, это скорее TreeMap, потому что у него есть порядок. У clustering key есть свойство упорядоченности: когда база хранит эти данные, она сортирует по нему. И ты можешь на это рассчитывать и организовать данные соответствующим образом. Например, это строчки — они упорядочены лексикографически, или числа. Самый полезный кейс — когда у тебя лежат какие-нибудь события, и ты хочешь отсортировать их по времени.
[45:24] Александр: Окей. То есть при создании таблицы, помимо того что я указываю primary key как более логическую сущность на уровне таблицы, я должен ещё указать, какие колонки являются физическим уровнем сортировки и навигации — clustering key и partition key. Это всё при создании нужно указать?
[45:44] Дмитрий: Да. Причём ты указываешь не отдельно partition key и clustering key — ты указываешь primary-ключ, а его описание и есть комбинация partition key плюс clustering key. То есть ты написал PRIMARY KEY, а дальше в скобочках два параметра: первый — partition key, второй — clustering.
[46:14] Александр: И это имена колонок, правильно?
[46:16] Дмитрий: Да. Но надо понимать, что и то и другое может быть множественным. Ты можешь сделать partition key не из одной колонки, а из нескольких: не просто «имя», а «имя плюс фамилия» — вдруг ты решил их запихать в разные колонки и сказать, что это ключ, по которому нарезаю партиции. То же самое с clustering key — можешь указать несколько колонок. И, в отличие от partition key, clustering key опциональный: внутри хэшмапы первого уровня может лежать не мапа второго уровня, а сразу строчка со значениями — это называется, по-моему, статическая колонка. Ты в принципе можешь сделать просто partition key и больше ничего.
[47:12] Александр: Окей. То есть я как инженер, который создаёт таблицу и проектирует модель данных, при работе с Cassandra должен учитывать эти важные факторы — partition key и clustering key. Первый — то, как найти партицию, второй — то, как найти внутри партиции уникальную строчку. И так как partition key обязателен при создании, я должен понимать, как устроена модель хранения данных. Иначе у меня возникает вопрос: а какой partition key должен быть? Что такое партиция вообще? Почему я уже на уровне дизайна данных про это думаю?
[47:58] Дмитрий: Ответ наперёд: потому что такая модель Cassandra, и в этом её фишка. Она вот так организует данные, и за счёт этой организации получается огромное количество преимуществ, в том числе почти бесконечная масштабируемость. Эта модель данных enable-ит нам масштабируемость, поэтому нам нужно её понимать. Собственно, для этого выпуск и существует. Если мы говорим про реляционную базу, на первом уровне нам достаточно знать реляционную алгебру, тот самый SQL-92, базовые штуки, и не задумываться, как данные реально хранятся. Мало кто, знакомясь с Postgres первый раз, думает о том, как физически в байты перекладываются данные, где они лежат — ну, вроде на сервере, в памяти, на диске, наверное. Про commit log при первом знакомстве никто особо не знает. А в Cassandra мы, написав вторую-третью строчку кода, уже такие: кластеринги, партишены. Нам уже нужно более глубоко понимать.
[49:03] Александр: И, наверное, про партиции, про то, как это организовано, мы поговорим чуть дальше. Всё-таки сценарий выпуска в том, что мы от клиента за ручку вместе со слушателями идём и смотрим, что вокруг, чтобы понять, как это работает. Поэтому останавливаемся на клиенте. Партишены, кластеринги — я задал, почитав доку, какие-то ключи: имя, фамилия, e-mail. А что дальше?
[49:27] Дмитрий: Мы указали, в принципе, этого достаточно. Там есть ещё набор параметров при создании таблицы, но это скорее тюнинг-параметры, чаще связанные с производительностью, — пока опустим. То есть у нас таблица — это набор колонок, задан primary-ключ. Создаём таблицу внутри keyspace. Таблица создалась, дальше можем с ней работать.
[49:53] Дмитрий: И тут классические, казалось бы, операции. Что мы можем делать? Можем вставить данные — INSERT. Можем обновить — UPDATE: есть строчка, мы какие-то колоночки хотим обновить. И можем удалить строчку — DELETE. Всё это тоже запросы в Cassandra, которые мы выполняем через объект session. Не знаю, будем ли мы про это сейчас говорить, но есть особенность, связанная с тем, как Cassandra хранит и обрабатывает данные: в отличие от реляционных баз, INSERT и UPDATE по факту — одно и то же. Я уже проводил аналогию: Cassandra — это такая большая распределённая персистентная двухуровневая хэш-таблица. А в хэшмапе у нас операция put — нет операций insert и update, есть просто put. Вот и в Cassandra INSERT и UPDATE — это put. Иногда это даже называется специальным словом в книжках и статьях — upsert. Если мы вставляем данные, а их там нет, — мы их вставляем. Если есть — по сути обновляем. И неважно, какую операцию ты используешь: INSERT и UPDATE — это разделение синтаксиса скорее для разработчика, чтобы было привычнее. Внутри это превращается практически в одно и то же. Есть очень тонкие детали, связанные с отдельными конструкциями типа time-to-live, где разница немножко перелезает, но такие случаи можно пересчитать по пальцам одной руки. Идеологически Cassandra эти операции не различает. А её основное отличие от классических баз в том, что когда ты делаешь операцию записи, ты не читаешь данные. В обычной базе, когда вставляешь или обновляешь, ты должен сначала прочитать то, что там было: попытаться найти строчку, и если её не было — ругнуться, что нарушен constraint primary-ключа; либо, если строчка есть, поднять старые данные и дописать. Cassandra делает по-другому — как именно, поговорим дальше. Но суть в том, что, когда ты просишь её сделать изменение, она не знает, есть ли там вообще что-то. Она на этом этапе не пытается вспомнить, было ли что-то. От этого она работает быстрее, но не умеет некоторые вещи. Например, в отличие от Postgres, ты не можешь повесить constraint «не вставляй записи с тем же primary-ключом» или запретить повторную вставку.
[53:10] Александр: То есть constraint UNIQUE, по сути, не существует в Cassandra в том виде, как в Postgres, потому что мы не получим ошибку при существующем ключе — мы просто его перезапишем. И это by design.
[53:27] Дмитрий: Да. Есть способы это обойти, но для базовых операций — простых INSERT и UPDATE — мы не читаем предыдущие значения. Соответственно, всё, что требует чтения, невозможно.
[53:45] Александр: Окей. Тогда в нашем клиенте я, обладая этой информацией, делаю некоторые put-операции. И эти операции доступны мне как часть CQL или как часть Java API, где я должен создать какой-то объект, передать туда массив? Как это выглядит?
[54:03] Дмитрий: Там довольно развесистая структура объектов. Основной объект, вокруг которого всё строится, называется statement. Самый простой вариант — объект simple statement. Внутрь него ты просто кладёшь строчку — текст запроса. Как SQL statement: INSERT INTO какую-то таблицу, в скобочках перечисляешь названия полей, потом VALUES и в скобочках значения. Почти один в один как SQL — тут оно пытается быть похожим. Это первый вариант. Второй, производный: ты можешь сказать, что не хочешь смешивать значения и запрос в одну кучу. Вместо конкатенации, которая не очень безопасна — какой-нибудь SQL injection могут напихать, — ты хочешь сделать подстановки. И тут появляется понятие, которое есть и в других драйверах, — bind-переменные.
[55:15] Александр: Ну, то есть prepared statement.
[55:17] Дмитрий: Ещё не обязательно, но, например. Сама идея в том, что ты можешь из текста запроса вытащить отдельные параметры и сказать, что они прицепляются позже. Создать некий шаблон текста и потом в рантайме собирать его в зависимости от данных. Когда ты логируешь через какой-нибудь SLF4J, ты пишешь строчку с фигурными скобочками, куда потом при вызове метода подставляются аргументы. String.format из стандартной библиотеки — то же самое. Дальше, может быть, ты не очень любишь писать тексты запросов.
[56:07] Александр: Мне нормально. То есть мне нормально писать текст, используя SQL-синтаксис. Подсвечивает это, правда, не очень. Я бы хотел всё-таки Java API.
[56:16] Дмитрий: Поэтому для таких любителей Java API есть конструктор — API, который позволяет через Java, таким fluent-интерфейсом, собрать такой же statement. Тебе даётся builder: insert, потом .table, передаёшь имя таблицы, и дальше конструируешь объект через fluent builder API. По сути, силами Java-языка ты конструируешь тот же insert statement. В конце он всё равно превратится в такой же текст.
[56:56] Александр: То есть это сахар на уровне Java API, но на сервер, разумеется, этот объект в таком виде не полетит. Сервер не понимает сериализованный Java. Мы не сериализуем этот Java-объект, чтобы посылать на сервер, — нам всё равно надо… ну, потому что клиент может быть, например, не на Java.
[57:14] Дмитрий: Соответственно, Java serialization сразу отбрасывается — это дорого, небезопасно и не кроссплатформенно. Понятное дело, что это превратится в другой формат, а этот формат и есть CQL. Ну и, наконец, третья сущность этой мозаики — ты уже упомянул prepared statement. Зачем это нужно? Иногда мы можем выполнить запрос один раз — пишем код примера, создаём таблицу; наверное, мы не создаём таблицу на каждый чих. Или анализом занимаемся, когда нужно прямо в текстовое поле написать запрос. Но если говорить про какую-нибудь типичную OLTP-систему, real-time, в ней мы выполняем много однотипных операций с разными данными — один и тот же запрос будем выполнять тысячи, миллионы, миллиарды раз, меняя только параметры. И не хотелось бы… Когда мы запрос отправляем на сервер, в базе его нужно прочитать, понять, интерпретировать, проверить, разложить во внутренние представления, чтобы потом обработать. Если делать это для каждого запроса, мы будем тратить очень много ресурсов сервера. Логичный подход: давайте один раз это сделаем, результат закэшируем, а потом будем переиспользовать. Собственно, prepared statement именно про это. Мы формируем текст запроса. Поскольку будем его переиспользовать, там, скорее всего, будут bind-переменные — хотя не обязательно, но в реальной жизни будут. Ты хочешь вставить что-то в таблицу, а что именно — значения полей — скажешь позже. Ты отсылаешь этот запрос на сервер, сервер его переваривает: делает синтаксический разбор. Это ведь формальный язык, которым ты пользуешься при общении с сервером, — там есть парсер, анализатор, линтер. Надо разбить на лексемы, лексический анализ, синтаксический анализ — вся эта балалайка в Cassandra на сервере есть, написана, насколько я помню, на ANTLR. Это классическая Java-библиотека для написания синтаксических парсеров. Соответственно, мы отослали запрос, он там разобрался, подготовился, и после этого мы можем его переиспользовать. Но мы не хотим каждый раз слать его на сервер снова — это может быть длинная строка, много параметров, зачем тратить ресурсы? Нам нужен какой-то идентификатор для этого запроса.
[59:59] Дмитрий: Какой бы ты идентификатор для запроса выбрал?
[1:00:01] Александр: О, это хороший вопрос. Мне нужно уникально идентифицировать запрос, но не привязываясь к конкретным данным в нём, потому что…
[1:00:12] Дмитрий: Ну, данных там нет. Там пока знаки вопросиков стоят.
[1:00:17] Александр: Я бы взял какой-нибудь устойчивый хэш — типа MD5 — от текста запроса. Если бы ты писал эту логику сейчас, MD5 бы уже, наверное, не взял, его бы уже заплевали.
[1:00:31] Дмитрий: Но в данном случае мы не решаем проблему безопасности, нам нужна просто…
[1:00:39] Александр: Нам нужна уникальность, да.
[1:00:42] Дмитрий: Поэтому действительно в Cassandra берётся хэш-функция, и в текущих реализациях это MD5. От строчки считаем MD5-сумму, возвращаем её в клиент, и клиент при следующих запросах, когда хочет этот запрос выполнить, посылает на сервер уже этот алиас, этот хэш в качестве идентификатора.
[1:00:59] Александр: Это классное решение, потому что я теперь не гоняю запрос каждый раз по сети — хотя это, наверное, небольшой оверхед, — но, самое важное, не заставляю сервер разбирать и компилировать запрос при каждой строчке. Очень прикольная оптимизация. Кстати, я не встречал её, например, в Postgres, вообще говоря, не самая распространённая.
[1:01:26] Дмитрий: Вспоминаем: Cassandra — распределённая система, серверов много. А в какой сервер мы это послали?
[1:01:33] Александр: Ага, проблема в чём? Я послал строчку на сервер, он её скомпилировал во внутренние структуры, а потом, например, сеть моргнула, этот сервер вышел из топологии, потом вернулся, я опять посылаю — и, возможно, меня уже направляют на другой сервер. Я такой: вот тебе циферка — запрос, — а эта нода про эту циферку не знает, потому что не собирала и не разбирала этот запрос. Тут два решения сразу: либо нода должна как-то узнать у своих пиров о существовании такого запроса и подтянуть его — и для меня, клиента, это будет незаметно; либо я как клиент должен включать в эту хэш-сумму идентификатор ноды, чтобы знать, что если нода вышла, надо заново запрос пересылать.
[1:01:41] Дмитрий: И да и нет. Ты прав, что можно решать эту проблему либо со стороны клиента, либо со стороны сервера — то есть как получить тот же самый prepared statement на других нодах. Cassandra выбрала подход с клиентом. Когда мы вызываем prepared statement, и он подготовится на каком-то сервере, это личное дело этой конкретной серверной ноды. Эта информация никак не реплицируется в другие ноды, там нет глобальной распределённой реплицируемой таблицы prepared statement. Нет, это чисто локальное дело ноды. Ты можешь прийти в одну ноду — она скажет: «Я знаю по этому id запрос, могу обработать»; другая скажет: «Извини, чувак, я не знаю». И твоя задача как драйвера — когда ты делал prepared statement, не выполнить его один раз и забыть, а внутри держать эту информацию: помнить, что ты посылал, то есть таблицу «хэш → текст запроса». Ты её продолжаешь держать, и когда отсылаешь запрос в какую-то ноду, а она говорит «я не знаю этот запрос, ты прислал id, а у меня его нет», ты говоришь «окей, сейчас переподготовим» и делаешь переподготовку на лету. Эта проблема обновления различных нод возложена на клиент.
[1:03:49] Александр: Это очень интересное инженерное решение.
[1:03:50] Дмитрий: Никто при этом не мешает тебе сразу заранее запрепарить это на всех нодах — как клиент можешь в параллели распихать всем сразу, чтобы потом не иметь performance-проблем в зависимости от того, в какую ноду послал. Но это позволяет Cassandra-нодам оставаться простыми, упрощает логику на их стороне: ты не занимаешься репликацией информации между нодами — а они могут быть ещё и разных версий, тебе надо координироваться внутренними деталями. А ты, например, поменял формат хранения in-memory в новой версии — и вот тебе удачи всё это мейнтенить. Это с одной стороны. А с другой — это на самом деле кэш. На стороне сервера ты как клиент можешь эти запросы препарить хоть каждую секунду, не думая: взять и в каждый запрос написать не bind-переменные, а прямо конкретные числа. И хоть миллион разных запросов мне послать — текст будет каждый раз новый, просто параметр меняется, но с точки зрения базы это разные запросы, и ты мне всю память забьёшь. Я как сервер просто упаду. Единственный способ избежать такой проблемы — сказать, что у меня есть кэш подготовленных запросов, но он ограничен по размеру: я оцениваю, сколько это занимает в памяти, и всё, что вылезает за пределы, тем или иным способом выбрасываю. Внутри Cassandra сидит, я думаю, почти любой сеньорный Java-разработчик знает про библиотеки кэширования в Java, и такая библиотека, как Caffeine, есть в Java. Соответственно, это Caffeine-кэш внутри Cassandra, ограниченный по размеру. Если пихаешь слишком много, какие-то записи вытесняются. Так что вполне возможна ситуация, когда ты придёшь ко мне с id, который посылал раньше, а я скажу: «Не помню, меня вытеснили». Я не могу хранить всё бесконечно, а если я это вытеснение буду ещё координировать между нодами — это вырастет в весёлую инженерную задачу.
[1:06:11] Александр: И это же будет аффектить на performance.
[1:06:15] Дмитрий: Да.
[1:06:15] Александр: А это решение с распределённой глобальной таблицей — конечно, не на каждый запрос мы бы туда ходили, но иногда бы похаживали, просили бы другие ноды подтянуть их кэш к нам. Получается, в чём вообще суть всего этого: один клиент может нагрузить CPU и память многих серверов и зааффектить тем самым работу других клиентов с этими серверами. Это глупо с точки зрения инженерии. Давайте всё, что можно отдать на клиентов, мы отдадим на клиентов, и будем обслуживать как серверы только то, что действительно нужно. Ну и, конечно, некоторые оптимизации есть, но идея — не делать лишней работы на сервере, если её можно отдать клиенту. Мне кажется, это прикольный паттерн вообще в распределённых системах, который сеньорные инженеры и слушатели подкаста могут взять на заметочку. Возвращаемся к тому, как мы можем выполнять запросы со стороны клиента. Получается, есть три глобальных способа отправить INSERT в табличку, которую я создал ранее. Первый — написать CQL с текстом напрямую, simple statement. Второй — что-то промежуточное, переходящее в prepared statement, но ещё не оно, — такие шаблоны.
[1:07:34] Дмитрий: Второй — это builder.
[1:07:36] Александр: А, второй — это builder. То, что ты через Java API пишешь что-то, что потом на стороне драйвера превратится опять в текст.
[1:07:44] Дмитрий: Да. И та же логика кэширования запросов будет работать, потому что на уровне клиента внутри это всё равно текст. Сервер не знает, как ты создал этот запрос. Это просто формирователь текста. Как ты этот текст потом используешь — уже твоё дело, шаблонизатор такой на Java API. Ты можешь превратить его в simple statement, а можешь превратить в третью сущность — собственно, prepared statement. Так что три основных сущности я бы здесь выделил: simple statement — просто текст; builder, который позволяет этот текст через Java API сформировать; и prepared statement, который позволяет переиспользовать результат парсинга запроса и не мучить сервер каждый раз разбором, а пересылать просто id. С точки зрения сервера, если на всё это посмотреть, есть только два типа запросов: либо ты присылаешь мне текст — simple statement, либо присылаешь id — и это prepared. С точки зрения сервера, только два варианта ты можешь принести по сокету.
[1:08:53] Александр: А с данными что происходит? Давай дальше на примере prepared statement — я всё-таки человек, который парится про performance и безопасность. Я создал prepared statement и потом кладу туда какие-то данные — единички, строчки, циферки. Как я это делаю?
[1:09:13] Дмитрий: Ты этот prepared statement создал один раз, скорее всего, в начале, при старте приложения, и получил объект типа prepared statement. Это такой шаблон. Дальше ты хочешь из этого шаблона получить конкретные инстансы, конкретные запросы. Тебе нужно этот объект превратить в то, что в Cassandra API называется bound statement, — statement, в котором поля подстановки заполнены. Можно сделать разными способами: есть builder API, либо просто создать bound statement. Туда в качестве того, из чего ты создаёшь, ты должен начать с prepared statement, на его основе создаёшь bound statement и досыпаешь недостающие переменные, которые написал в запросе ранее. Делаешь метод bind либо просто передаёшь массивом. Тебе подставляют те значения, которые ты хочешь, чтобы попали в шаблон, чтобы он превратился в конкретный INSERT или UPDATE.
[1:09:26] Александр: А я туда прям вставляю строчки своей таблицы? Или могу передать список строчек в виде батча?
[1:10:25] Дмитрий: Ты упомянул батчи. Это немножко другая сущность в Cassandra. Я, может быть, упростил, когда сказал, что есть только два типа запросов. На самом деле есть ещё третий подвид, который называется batch.
[1:10:28] Дмитрий: Действительно, когда мы говорим про операции обновления данных, ты можешь сгруппировать их в кучку. Чаще всего это делаешь из соображений производительности, но там есть и некоторые аспекты консистентности данных, связанные с батчами. Когда ты посылаешь запросы на сервер, можешь сделать несколько INSERT и сложить их в сущность batch statement. Каждый из этих запросов может быть prepared statement. В тексте, который отошлём на сервер, будет id запроса, какие-то разделители, значения полей, которые ты хочешь вставить; потом следующая строчка — снова id другого запроса, снова набор полей, и так далее. То есть батч, состоящий из нескольких стейтментов на модификацию данных. Отсылаешь их на сервер, а сервер их все выполняет.
[1:11:42] Александр: Окей. То есть я могу и построчно, и батчами формировать свой запрос. И, сформировав его в Java-коде…
[1:11:50] Дмитрий: На изменение данных.
[1:11:52] Александр: На изменение данных.
[1:11:54] Дмитрий: В Cassandra в этот batch statement ты не можешь засунуть SELECT. Не можешь сказать: за один раз и вставь, и почитай.
[1:12:03] Александр: А что, кстати, если мы говорим про key-value, про хэшмапу: когда ты говоришь «прочитай мне батч», что ты ожидаешь получить, если у тебя однозначная идентификация по ключу? Наверное, несколько ключей. То есть передать туда список ключей и получить в ответ список строк, соответствующих всем этим ключам или некоторым из них. Тут надо думать про семантику батча.
[1:12:28] Дмитрий: Нет, концептуально можно придумать случай, когда батчи полезны. Например, fullscan батчами делать. Или так: есть две таблицы — пользователи и сессии. Тебе приходит запрос, и ты хочешь прочитать запись о пользователе из таблицы пользователей по первичному ключу и набор записей из таблицы сессий — все сессии этого пользователя. Вполне логичный, нормальный паттерн. Можно было бы сделать так, чтобы мы на сервер отослали и тот и другой запрос одновременно в одном батче: там сделается SELECT из одной таблицы, SELECT из другой, результаты скопятся вместе и будут отосланы нам в клиент. Но так не сделано, потому что это дополнительная нагрузка на сервер. Данные в таблице пользователей и таблице сессий могут быть по-разному распределены. Мы, опять-таки, говорим про распределённую систему, и нам надо будет кверить данные с разных нод — не факт, что с одинаковых. Пока мы всё это кверим, мы стоим и ждём: мы же не можем отослать результат клиенту, пока не сформируем полный ответ. А это значит, что на сервере будет выполняться какая-то длительная операция, которая собирает результаты, и вероятность упасть — чем больше таких «развесушек», таких форков, — тем выше. Поэтому так не сделано. Вместо этого, если хочешь сделать два запроса на клиенте, — сделай два отдельных запроса.
[1:14:48] Александр: Будет ли при этом всё это батчеваться?
[1:14:48] Дмитрий: На самом деле, может, и да. Потому что когда мы спустимся на этап того, как это работает через сокет, оно волшебным образом может схлопнуться в один — может быть, даже в один TCP-пакет. Но про это чуть позже.
[1:14:38] Александр: Да, но мы, кажется, плавно к этому подходим. Мы уже сформировали некоторую структуру данных в Java-коде, которая хранит в себе данные. Это по сути что? Массив объектов? Или это, возможно, какой-то POJO, который я перед этим создал и определил правила маппинга или сериализации? Если мы не берём CQL, который по сути строка, а берём более сложный Java API — я же данные там как передаю? Типа insert — и что туда передаю? Объекты? Просто массив объектов?
[1:15:09] Дмитрий: Ты можешь туда передать массив объектов, которые являются bind-переменными.
[1:15:18] Александр: А я их оборачиваю во что-то? Строку я во что-то оборачиваю специально — bind variable или value как-то называю, — или просто передаю как есть?
[1:15:25] Дмитрий: А тут мы начинаем затрагивать механизм, который на уровне драйвера называется кодированием данных. У тебя есть некие Java-объекты, которые ты передаёшь как параметры.
[1:15:37] Александр: Это прямо объекты Java? Ну, это может быть экземпляр моего личного класса?
[1:15:41] Дмитрий: В принципе, может. Ты можешь передать свой собственный класс, но дальше встаёт вопрос: вот у меня Java-объект, а мне рано или поздно надо будет написать в сокет какое-то бинарное представление этой сущности.
[1:15:56] Александр: Да-да, ты как этот объект будешь передавать? Мне надо как-то его сериализовать.
[1:15:58] Дмитрий: Маршалинг сделать, да. И на той стороне ещё десериализовать, потому что база данных, которая твоих Java-объектов не имеет, должна это всё декодировать. Соответственно, в драйвере за это отвечает целая подсистема, называется кодеки. Там есть реестр кодеков, и происходит, по сути, конвертация твоих Java-объектов в байт-буферы. Если убрать все эти названия и концепции, то по сути это функция, которая на вход принимает Java-объект, а на выходе выдаёт байт-буфер с каким-то содержимым. А дальше всё строится от того, какого типа объект, который ты передал, с одной стороны, и какого типа колонка, в которую ты вставляешь, — с другой. Потому что драйвер понимает, что поле, в которое ты вставляешь, имеет определённый тип с точки зрения Cassandra, как CQL. Когда мы на предыдущем этапе создавали таблицу, мы же не просто сказали «в колонке лежит непонятно что», мы сказали, какой там тип.
[1:17:07] Александр: То есть есть некоторая схема, где говорится имя колонки и её тип — с точки зрения Cassandra, — я его указал, и Cassandra сформировала его у себя во внутренних структурах при создании таблицы. А теперь есть Java-объект или массив этих объектов, которые я передаю, и это типы Java.
[1:17:28] Дмитрий: Это типы Java. Это не те типы, которые один в один: они могут мапиться, а могут и не мапиться. Я могу передать Integer, а в базе данных это Short. Это уже два разных типа, хотя один в другой в одну сторону конвертируется без потери, но всё равно — кто об этом знает?
[1:17:44] Александр: И, соответственно, нужно где-то понимать, как эти данные, представленные в виде массива байтов, представляются — мы ещё поговорим, как их потом интерпретировать, — и какой тип им назначить.
[1:17:56] Дмитрий: Да, у тебя есть логика под названием «кодек», который умеет: ему приходит Java-тип — он обычно написан под конкретный Java-тип, — он знает, какой тип ты хочешь получить на выходе, какой CQL-тип ждёт сервер, и, собственно, значение. На входе оно пришло как Java-объект, а на выходе он должен родить байт-буфер — бинарный набор байтиков, который потом запишется в сеть.
[1:18:27] Александр: Вот что у меня в голове крутится: обычные веб-разработчики, бэкендеры, называют себя «перекладывателями JSON’ов» — мол, мы один JSON в другой перекладываем. Так вот, разработчики баз данных перекладывают один массив байтов в другой. По сути, то же самое, просто не JSON, а byte array. Откуда этот кодек взялся? Это объект, определённый внутри драйвера, или я его должен как клиент передать?
[1:18:55] Дмитрий: В драйвере есть сущность, называется, по-моему, codec registry, и там уже заранее написаны кодеки под стандартные Java-типы. Он знает, как число превратить в тот бинарный формат, который полетит на сервер. Там прям такой код написан: if instanceof Integer — return integer codec. И этот кодек знает, как джавовый Integer превратить в byte array. А на той стороне знают, как из этого byte array получить джавовый объект.
[1:19:28] Александр: Ну, на той стороне…
[1:19:31] Дмитрий: А может, даже и не нужен джавовый объект — может, и не захотят они его получить. Это уже личное дело сервера, и он не будет пытаться это делать. Но на стороне клиента тебе нужно уметь — собственно, эти кодеки умеют превращать данные. А почему это не «энкодер-декодер», а «кодек» — то есть кодирование-декодирование? Потому что тебе ещё нужны ответы со стороны сервера. Ты же ещё SELECT-ы делаешь, тебе возвращают данные, и их надо обратно превратить в Java-объекты. Тебе нужно уметь не только сериализовать, но и десериализовать данные на стороне клиента. Поэтому это объект-кодек, и там две парные операции: как в byte array превратить и как из byte array получить обратно Java-объекты. Соответственно, когда делаешь INSERT-ы, чаще используется логика кодирования, а когда SELECT-ы — результат декодируется. У нас есть набор стандартных кодеков, который прямо в драйвере лежит готовый. Из этого реестра — там есть логика и с if-ами, и есть мапа, где ты лукапишь. То есть можешь не if-else писать или switch-и, а положить в мапу и сделать маппинг динамически.
[1:20:49] Александр: «Лукапить из мапы» и «ковырять запрос» — мне понравилось, я записал.
[1:20:53] Дмитрий: Я думал, это довольно распространённые термины.
[1:20:56] Александр: Нет-нет, это супер, я просто люблю такие штуки.
[1:21:00] Дмитрий: У нас есть набор преднаписанных кодеков, но вспоминаем, что их на самом деле не совсем достаточно, потому что есть сложносочинённые типы.
[1:21:10] Александр: Ну да, если я сделал свой user-defined type, откуда у него кодек будет?
[1:21:14] Дмитрий: Его драйвер может динамически сгенерировать, потому что для user-defined type у тебя есть описание, есть понимание, из чего он состоит.
[1:21:27] Александр: То есть, имея этот тип, я могу рефлексией взять getClass, getDeclaredFields, пройтись по ним, посмотреть типы и динамически логику кодека на стороне драйвера сделать.
[1:21:44] Дмитрий: Например, это один из путей. В драйвере два пути. Если ты хочешь user-defined type замапить прямо на Java-объект, там есть некий mapper API. В текущей версии драйвера это дополнительное расширение, которого нет в базовой функциональности, — тебе надо подключить дополнительную джарку в classpath. Он будет делать примерно то, чем занимается какой-нибудь Jackson, который JSON превращает в Java-объект и наоборот, — этим маппингом занимается. Там есть такой же mapper, тоже можно аннотациями ему помогать на Java-объектах, объяснять, что ты хочешь с этим объектом делать. Это один путь. В стандартном варианте такой возможности нет, и для user-defined type есть специальная штука под названием user-defined type value. Это иерархическая key-value-сущность. Ты туда напихиваешь конкретные поля: вот это поле — такое значение, вот это поле — такое значение. То есть это такая хэшмапа, по сути.
[1:22:58] Александр: То есть я Java-объект перекладываю в мапу мап — некое дженерик-представление, — а потом дальше оно сериализуется. Потому что мапу мап из примитивных полей мы знаем, её можно обойти уже без какой-то рефлексии. Просто ты обходишь такое дженериковое дерево, на листьях которого висят простые типы, для которых у нас есть готовые кодеки.
[1:23:28] Дмитрий: Да, главное — обход делать в одну и ту же сторону при кодировании и декодировании.
[1:23:32] Александр: Конечно.
[1:23:34] Дмитрий: Поэтому у user-defined type порядок важен: мы в итоге упаковываем этот тип в byte array, а мапа — иерархическая структура, и есть несколько способов упаковать её в byte array. Это очень важно, но это уже логика на стороне кодека. Мы про это как пользователь не думаем.
[1:23:52] Александр: Да.
[1:23:53] Дмитрий: И технически ничто не запрещает тебе написать свой собственный кодек. Ты можешь подменить стандартный либо дописать свой. У меня даже был один случай, когда мне это понадобилось: мне не понравился стандартный кодек в Cassandra Driver для user-defined type. У него была проблема: вспоминаем опять — распределённая система, меняющееся состояние. У меня в user-defined type это не статический объект, там могут добавляться поля. И тот кодек, который был написан до определённой версии, когда декодировал, следил за тем, что все поля, которые он декодирует, ему известны.
[1:24:39] Александр: То есть он не поддерживал совместимость в прямом направлении. Когда ему прилетает объект, часть полей он знает, а часть — может быть, не знает. Forward compatibility.
[1:24:52] Дмитрий: Да, поля только что появились, а драйвер ещё не получил обновление — это же синхронно всё происходит. Он не получил обновление о том, что схема стала новой. И он их декодирует, говорит: «Упс, тут поле лежит, которое я не знаю». По умолчанию он раньше вёл себя так: «Ошибка, я не могу такое декодировать, такую строчку читать не буду». А нам хотелось, чтобы он всё-таки был compatible и просто это неизвестное поле игнорировал: не знаешь — ну и пропусти. Поэтому мы взяли и заменили этот кодек у себя на немножко изменённый свой. Но это, кстати, больше делать не надо, потому что мои фиксы пропинали в драйвер. Теперь дефолтный кодек так же делает. Там были некоторые споры, насколько это хорошо или плохо, но в итоге большинство согласилось, что лучше делать так, чем ломаться.
[1:25:43] Александр: То есть у вас был приватный форк, а потом Cassandra вышла…
[1:25:48] Дмитрий: Это как раз я и пытаюсь объяснить — в этом прелесть механизма кодеков: это подключаемый механизм. В своём коде ты заменяешь реализацию кодека в реестре, не меняя исходный код драйвера. Мы не переписали код драйвера, мы просто переконфигурировали его, отдав другую реализацию кодека. То есть кодек — если пытаться замапить на умные слова и паттерны — это стратегия. Стратегия сериализации.
[1:26:22] Александр: Это, кстати, отличный пример open-closed-принципа в программировании, в инженерии. Когда мы как разработчики библиотеки-клиента, которую используют, позволяем нашим пользователям передать некоторую логику в виде кодека. В Java мы условно принимаем интерфейс Codec, а реализуя интерфейс и передавая его туда — вот тебе и кодек. То есть мы не закрываем нашу систему для изменений — точнее, закрываем её для внутренних изменений, но открываем для расширения. Это пример хорошего расширяемого API, который ты расширил, а потом уже вот эту закрытую часть тоже, я так понимаю, задонатил — эти коды, — потому что так просто удобнее.
[1:27:08] Дмитрий: Да, там на самом деле было два мерж-реквеста: один принёс я, другой — другой человек, мы в итоге объединились и запушили оригинальный мерж-реквест этого человека.
[1:27:23] Александр: Окей. Мы взяли кодек, превратили каждую колонку. Кодек для каждого типа — то есть, условно, наша таблица состоит из нескольких колонок, для каждой выбрали нужный кодек, колонка сконвертировалась в byte array по логике этого кодека, и они друг за другом в определённом порядке уложились в общий byte array или в direct-буфер. Что происходит дальше с этими байтиками? Мы хотим выполнить некий запрос в базу. Statement-объект мы сформировали, дальше начинаем его выполнять. На session есть метод execute, и мы туда передаём этот statement: «выполни, пожалуйста, вот этот statement, который я сформировал тем или иным способом — simple, не simple, prepared, не prepared».
[1:27:56] Дмитрий: Если посмотреть API, там много методов. Есть метод execute, блокирующий, который ждёт результата и потом отдаёт, — как мы все привыкли: вызвал execute, поток заблокировался, ждём. А есть метод executeAsync, который вернёт тебе CompletableFuture. Если посмотреть реализацию, то на самом деле всё это асинхронно. execute — это по сути executeAsync плюс ожидание на future, раз уж ты не хочешь заморачиваться. И вспоминаем наш предыдущий пример с двумя SELECT-ами, которые мы хотели записать в батч. Никто тебе не мешает написать два SELECT-а: они друг от друга не зависят, ты хочешь побыстрее. Тебе прилетел запрос на сервер в твоё приложение, и тебе нужно два-три SELECT-а сделать в параллели — так сделай их в параллели. executeAsync на каждом сделай, получи CompletableFuture, а дальше дождись результата по обоим и начинай обрабатывать. Или, пока первый запрос выполняется, ты, может, уже готов обрабатывать данные для второго. Поэтому всё внутри драйвера превращается в эти асинхронно выполняемые запросы. И что дальше происходит? Нам пришёл запрос, мы должны для начала понять — в какую ноду.
[1:29:35] Александр: Нод-то много, в какую ноду мы… Ты имеешь в виду, драйверу пришёл запрос, внутрь кода драйвера. То есть я драйвер, мне сказали «выполни вот это, а когда будет готово — нотифицируй нас».
[1:29:49] Дмитрий: Ну да, верни CompletableFuture, а там что за лямбда внутри. Одна из первых задач — понять, куда этот запрос посылать. Нод много, как мы уже говорили. Вспоминаем, с каждой нодой — почти с каждой нодой — есть открытая TCP-коннекция. В какую из коннекций послать? Тут нужно выбрать. И здесь снова некая стратегия, которую можно менять. Это интерфейс, называется load balancing policy, по-моему. Это сущность, которая для запроса говорит, куда его послать. Тут может быть много способов. Чего мы хотим? Мы, наверное, не хотим посылать запросы в ноды, которые не живые: если она мёртвая, TCP-коннекшена с ней нет — что там пытаться в неё пихать? Не отвечает она. Поэтому вычёркиваем такие ноды.
[1:30:42] Дмитрий: Когда я говорю про открытие TCP-соединений: если у нас реально большая система, мы, скорее всего, хотим отказоустойчивости и будем деплоить нашу Cassandra в несколько дата-центров. Вспоминаем наш недавний AWS-outage, где регион сломался, и довольно много систем на это отреагировали не очень хорошо. Во многом почему? Потому что они деплоились в один регион. В терминах Cassandra — один дата-центр. Если мы хотим переживать такого рода падения, нам надо деплоиться в несколько регионов и несколько дата-центров, если говорить про премиум-деплоймент. В Cassandra это облачко нод не плоское, а структурированное. Одна из концепций там называется дата-центр. У каждой ноды есть конфигурационный файл, где написано, к какому дата-центру ты принадлежишь. Это просто строчка с точки зрения Cassandra: у ноды A написано, что дата-центр один, у ноды B — что дата-центр два, у ноды C — что дата-центр один, и так далее. Соответственно, каждая нода знает про себя и про соседей, к каким дата-центрам они относятся. И это же знает клиент, потому что он эту таблицу ковырял, когда подключался к кластеру, и видел, какие ноды к каким дата-центрам относятся. Более того, одним из параметров, который может быть задан у клиента, является то, к какому дата-центру относится сам клиент. Ты тоже физически где-то задеплоен, и клиенту интересно знать, какой дата-центр для него локален. Потому что ты хочешь посылать запросы не абы куда, а поближе. И это, на самом деле, самое важное, что стоит знать клиенту, потому что если у нас идеальный код, но мы ходим в дата-центр через океан от нас, то у нас будет громадный round-trip. Никакая база данных не поможет — мы просто физически далеко, и свет летит долго, помимо всяких промежуточных роутов, свичей и так далее, которые между нами лежат и тоже могут выйти из строя. То есть мы повышаем риск: чем дальше сервер, тем дольше и тем выше вероятность отказа. Поэтому локальность очень важна. Уж лучше мы сходим не на ту ноду — эта нода к локальной сходит или нас перенаправит, и мы сделаем второй раз, но это будет рядом.
[1:33:22] Александр: Мы, получается, убрали неработающие ноды. Второе — из оставшихся выбираем те, которые к нам локальны. Понятно, что этот список подготовлен заранее и в бэкграунде поддерживается, мы не отфильтровываем это каждый раз с нуля. То есть ты как код драйвера, имея информацию, что есть много нод и ты знаешь их IP, имеешь мапу-кэш этих TCP-соединений, и, выбирая соединение, в первую очередь смотришь, чтобы совпадал дата-центр.
[1:33:57] Дмитрий: Да, и даже больше: по умолчанию, скорее всего, ты вообще не будешь пользоваться удалённым дата-центром — по умолчанию connection туда даже не открываешь.
[1:34:09] Александр: Ага, то есть даже и не нужно держать.
[1:34:11] Дмитрий: Ну да, это, скорее всего, просто overhead. Более того, даже если у приложения есть возможность подсоединиться в удалённый дата-центр, это не всегда лучшее решение. Я стараюсь избегать этого. Мы сейчас говорим не про Cassandra, а про дизайн приложений. Почему я стараюсь избегать: у тебя есть запрос, который прилетел в приложение. Приложение, в свою очередь, отправило несколько запросов в базу — чаще всего не один, а, например, шесть. И теперь всё это задеплоено в двух дата-центрах. У тебя база в локальном дата-центре упала. Ты можешь переключиться на уровне базы: приложение в дата-центре 1 начинает работать с базой в дата-центре 2. Тогда на один входящий запрос у тебя шесть запросов полетят в удалённый дата-центр с этой latency через океан — всё будет очень медленно. Рассмотрим альтернативный дизайн: база упала в локальном дата-центре, и ты перенаправляешь запрос не на уровне «приложение → база», а на уровне входа в приложение. То есть лучше перенаправить запрос в приложение во втором дата-центре, пусть оно там с базой во втором дата-центре работает локально. Тогда по океану ты пролетишь один раз вместо шести. Да, будет penalty, штраф за поход в другой дата-центр, но он будет один раз, а не умножить на количество запросов.
[1:35:49] Александр: Ну и он неизбежен в ситуации какого-то outage — нам по-любому туда идти. То есть ты говоришь, что не надо на бэкенде ходить в базу в другом дата-центре, а пусть это приложение идёт в нужный бэкенд. Надо роутинг на уровне приложения менять.
[1:36:05] Дмитрий: Ну, то, что балансирует запросы в ваше приложение, должно перероутить на другой DC. Обычно это делается так: в приложении есть некий health-check, и он говорит: «Окей, у меня база данных локально недоступна, значит, и приложение не должно в этот момент обрабатывать запросы, лучше всё перенаправить соседу в другом дата-центре».
[1:36:29] Александр: Ну да, но тут ты хочешь, чтобы и фронтендеры начинали понимать распределённые системы, а это уже…
[1:36:33] Дмитрий: Ну, скорее те, кто балансирует. Всё-таки фронтендеры общаются с нашими приложениями не напрямую, а через некие балансеры.
[1:36:40] Александр: Ну, а где-то и напрямую общаются. Не все же в облаках живут с лоад-балансерами.
[1:36:45] Дмитрий: Балансеры можно и самому сделать.
[1:36:47] Александр: Все привыкли на готовеньком жить. В общем, возвращаясь к нашему запросу, который всё ещё пока не исполняется на сервере, а находится на клиенте. Мы до сервера ещё ничего не отправили, сидим, думаем. Значит, мы отобрали ноды в локальном дата-центре. Можем ли мы ещё что-то тут сделать в плане выбора?
[1:37:10] Дмитрий: Да. Потому что, во-первых, мы вспоминаем волшебный параметр, про который говорили при создании таблицы, — partition key. Зачем он?
[1:37:16] Дмитрий: По сути, это механизм шардирования в базе данных. Когда какая-нибудь компания создаёт тысяченодный кластер, это не означает, что она каждую запись хранит тысячу раз. Она хранит три, может быть, шесть версий — по три в каждом дата-центре при двух дата-центрах. Много нод создаётся, чтобы делать горизонтальное масштабирование, в том числе по данным. Мы хотим всё наше множество данных, нашу таблицу, порезать на куски, эти куски равномерно распихать по всем нодам, чтобы при добавлении новых нод всё больше данных влезало в кластер и мы могли их обрабатывать. В Cassandra это сделано через partition key. Все данные с одним partition key попадают в одну ноду. И, зная partition key для записи, ты можешь понять, какие ноды за эту запись отвечают. Несколько нод, потому что мы хотим отказоустойчивость, — это называется реплики для этой записи; для каждой записи будут свои реплики. Записи — это строчки в нашей таблице. Соответственно, на уровне драйвера ты можешь всё это понять, потому что у нас уже есть данные, и мы можем посмотреть, что там за primary key.
[1:38:39] Александр: То есть тебе говорят «сделай INSERT» или «выполни SELECT», и там уже есть значение колонки, по которой ты делаешь SELECT WHERE id = такому-то. То есть я должен передать primary-ключ, когда делаю запрос. И он есть в коде.
[1:38:57] Дмитрий: Ты можешь передать primary-ключ, а можешь сделать SELECT и без него — тогда будет другая логика. Ты можешь передать, и драйвер может воспользоваться этой информацией — не должен, а может, — чтобы сделать что-нибудь лучше, эффективнее. В данном случае мы знаем partition key, и мы можем в служебных таблицах на других Cassandra-нодах посмотреть, какие ноды за какие партиции отвечают. Тут вылезут всякие умные слова про consistent hashing. Не знаю, хочешь ли ты это обсуждать сейчас.
[1:39:31] Александр: Сейчас пока мы не знаем, как partition key превращается во что-то. Каким-то способом мы по partition key можем понять, какие ноды за него отвечают.
[1:39:41] Дмитрий: Да. Их несколько, если мы в keyspace этой таблицы указали replication factor, например, 3, — тогда, скорее всего, будет 3 ноды. Если replication factor 1 — скорее всего, одна нода, тогда у нас нераспределённое хранение.
[1:39:57] Александр: И среди этих трёх выбранных нод как выбрать одну? Мы уже отсекли довольно много: осталось три кандидата. А дальше что мы хотим?
[1:40:10] Дмитрий: Мы, наверное, хотим нагружать эти ноды равномерно. Мы не хотим всё время долбиться в одну и ту же ноду. Один из вариантов — случайно делать выборку из них. Или ходить по кругу счётчиком, каким-нибудь round-robin. Либо мы можем попытаться смотреть, на какой ноде у нас сейчас меньше всего запросов в процессе.
[1:40:40] Александр: А у нас есть такая информация на клиенте?
[1:40:42] Дмитрий: Глобальной информации, кто сколько выполняет, нет, но есть информация о том, сколько мы сами выполняем. Если мы, клиент, сильно нагружаем базу, то в этот connection я послал 100 запросов, и они ещё не вернулись, а в этот — 50. Вот я, наверное, выберу тот, который поменьше нагружен.
[1:41:00] Александр: Кстати, тут очень интересно — у меня в голове сразу возникло: запросы-то могут по-разному нагружать бэкенд. Откуда я на клиенте знаю, что вот этот запрос нагрузит ноду на 10% CPU, а вот этот — на 55? И тогда лучше уж туда, где 10. И я вспомнил, что, скорее всего, в Cassandra нет огромной логики по оптимизации запросов. Или есть?
[1:41:28] Дмитрий: Можно выделить, конечно. В Cassandra нет хинтов, нет джойнов, а 90% оптимизаций связаны с джойнами — как в правильном порядке их сделать, с какого конца этот клубок развернуть. Запросы в Cassandra простые, джойнов нет, никаких виртуальных таблиц — таблиц, создаваемых на лету, — ничего нет.
[1:41:56] Александр: «Как эта планета населена роботами».
[1:41:58] Дмитрий: Да. И поэтому эта логика реально применима: когда мы на клиенте плюс-минус равно считаем нагрузку. Потому что туда-сюда запросы: почитай по ключу, положи по ключу, где-то батчик, где-то нет, отфильтруй — но нет сложной штуки с огромным количеством индексов, джойнов и так далее, которая придаёт запросу дополнительный вес с точки зрения выполнения. Поэтому для нас все запросы плюс-минус одинаковые, и мы можем на клиенте эту логику применить — она оправдана.
[1:42:29] Александр: Ну, то есть это дополнительный… Понятное дело, что оно может иногда не сработать: не все запросы реально одинаковые по тяжести. Можно сделать запрос, который вернёт одну строчку, а можно — который вернёт очень много строчек.
[1:42:49] Дмитрий: Есть свои особенности, но это продвинутая история. Я уже даже и не помню, какой из этих способов дефолтный в драйвере — я специально рассказал несколько вариантов. Возможно, там комбинация из всего, что я назвал. Более того, помню, что недавно, когда заглядывал в кусочек кода, связанный с логикой выбора ноды, я нашёл там относительно свежую вставку — может, меньше двух лет, — которая говорила интересную вещь: она ещё смотрела время жизни ноды.
[1:43:23] Александр: Что такое время жизни ноды?
[1:43:25] Дмитрий: У тебя Cassandra-нода — это процесс, который иногда дохнет, рестартует и ещё что-нибудь. В какой-то момент стартанул, работал, потом, может быть, умер, снова стартанул. И вот этот uptime как понятие для неё есть. Какая логика с точки зрения балансировки? Нода поднялась…
[1:43:52] Александр: А uptime передаётся клиенту как знание — нода про себя знает, что живёт трое суток, и при коннекции передаёт это клиенту? Или клиент знает, что я уже общаюсь с этой нодой третий день?
[1:43:59] Дмитрий: Знаешь, я не помню. Возможно, и то и то. Скорее всего, всё-таки это локальное знание клиента — мне кажется, так было бы проще. Хотя, возможно, в системных таблицах эта информация есть. Но идея в следующем — это, кстати, интересная идея, применимая для других систем такого же типа, когда ты пишешь свою логику. Вспоминаем, что Cassandra — это Java-приложение.
[1:44:29] Дмитрий: Код Cassandra написан на Java, кстати, между прочим. Что особенно актуально для Java-приложений, но не только для них: они любят греться после старта. Они не очень любят — по крайней мере, пока не сделают до конца всякие проекты типа Leyden — выходить на топовую производительность мгновенно. Обычно Java-приложение поднимается, начинаются всякие компиляции. Идёт нагрузка — оно начинает потихоньку компилировать, интерпретироваться вначале.
[1:44:55] Александр: У нас, вообще говоря, поднимается по скорости как Python-приложение.
[1:45:08] Дмитрий: Ну, чуть получше. Не совсем уж там чистый интерпретатор, всё хитрее.
[1:45:16] Александр: Но это ещё отдельная большущая тема — про многоуровневую компиляцию и прочее добро.
[1:45:26] Дмитрий: В общем, оно какое-то время прогревается. И идея в том, что не надо в него сразу много слать. А может, лучше сначала даже его избегать. В драйвере я нашёл этот кусочек кода: там, по-моему, минуту или сколько-то вновь пришедшую ноду лучше не трогать.
[1:45:48] Александр: То есть вообще не трогается?
[1:45:50] Дмитрий: Если других вариантов нет, она трогается, но приоритет у неё понижается при выборе — она выписывается в конец списка. То есть там, если говорить про код драйвера, есть некий сортированный список, с которым мы играемся.
[1:46:09] Александр: Как понять по коду клиента, что бэкенд написан на Java? Вот эта строчка говорит про него. Интересно, что у меня в одном из Java-проектов сейчас похожие проблемы и мысли: у нас рестартует Java-приложение на критичном пути с очень большой нагрузкой. И если мы рестартуем и подаём на него полную нагрузку, оно тебе в первые 30 секунд может понавыплёвывать таймаутов.
[1:46:36] Дмитрий: Ой, да, это прикольно.
[1:46:38] Александр: А я ещё подумал, как круто, что в Kubernetes есть такая штука, как обновление подов. Оно не сразу происходит, как правило: он потихоньку начинает часть трафика на новый под пересылать, а потом отключает старый. Это сделано для того, чтобы, если ты задеплоил код с багом, и он стартанул, но кидает таймауты или, лучше, какой-нибудь out of memory, null pointer exception, — кубер, передав часть трафика на новый под, видит, что возвращается не 200, а 500-ка, и считает, что что-то происходит не так.
[1:47:16] Дмитрий: Ну, скорее там будет другая логика с liveness- и readiness-пробами, реальные запросы там не будут интроспектироваться.
[1:47:22] Александр: Но вообще я подумал, что для Java-приложения это на руку: так плавно переключается нагрузка, что они успевают прогреться за это время. Итого мы можем подсмотреть, какие ноды владеют собственными данными, какие свеженькие, какие меньше нагружены — в плане количества запросов, исходящих конкретно от меня. Про то, кто владеет данными, мы пока ещё не понимаем: сказали, что есть какой-то хэш, какой-то механизм, чёрный ящик, волшебная функция. По partition key выполнили запрос, а драйвер как-то по данным из базы понял, что вот эти ноды хранят эти данные, и с ними и надо работать. Если мы используем не эти ноды — на самом деле нормально?
[1:48:10] Дмитрий: Да, нормально. То есть она эти запросы обработает, не будет вам ругаться «я не владею этими данными, идите к соседу».
[1:48:21] Александр: «Я не принимаю справки по этой форме, идите в стол номер 6».
[1:48:26] Дмитрий: Вспоминаем, что все ноды равноправны, к любой можно прийти и попросить это сделать — служба единого окна.
[1:48:38] Александр: «Где карту открывали, там и закрывайте».
[1:48:38] Дмитрий: Соответственно, любая нода это выполнит. Просто какие-то из этих нод будут не владеть вашими данными, и им придётся идти к соседям, чтобы эти данные найти. Идея в том, чтобы убрать этот лишний прыжок по сети: если мы как клиент пошлём данные в более правильную ноду, мы получим результат чуть быстрее, нам будет приятнее.
[1:49:00] Александр: Наконец мы выбрали ноду, в которую будем посылать запрос. TCP-коннекшн к ней уже открыт. И дальше мы должны засериализовать представление. Вот тут-то кодеки начинают работать. Мы дописываем какие-то хедеры, где пишем всякие служебные метаданные по поводу нашего запроса, и кладём туда, например, id нашего prepared statement и поля, которые только что засериализовали с помощью кодеков. Что лежит в хедерах?
[1:49:32] Дмитрий: Что там лежит в хедерах, я сейчас попытаюсь вспомнить. Разумеется, там сначала лежит тип запроса.
[1:49:39] Александр: А что это вообще за такое?
[1:49:42] Дмитрий: Мы сейчас, вообще говоря, начинаем разговаривать про протокол. Что такое протокол вообще? Те, кто изучал TCP/IP-стек в универах, помнят эти квадратные таблички, где по байтам написано: вот на этой позиции находится байт, который означает порт, на этой позиции — чек-сумма, и так далее. По сути, такая же спека протокола есть для Cassandra. Можно пойти и найти спецификацию нативного протокола Cassandra, где эти фреймы прямо будут нарисованы.
[1:50:11] Александр: Для нас как слушателей, которые примерно хотят понимать, как это работает, понятное дело, что определённые детали нет смысла проговаривать — они сразу из головы выйдут, а при необходимости к ним можно обратиться, потому что есть спецификация. Но, по сути, эта труба, это TCP-соединение, ожидает, чтобы я послал в неё массив байтов. Массив байтов — одномерная структура: друг за другом лежат байтики. И всё, что мы можем, — сослаться на этот массив и, имея один указатель, так называемое смещение, offset, сказать, к какому байту, начиная от нулевого, мы хотим обратиться и сколько байтов от него прочитать. Такой слайс. И то, что за данные там лежат…
[1:50:57] Дмитрий: Давай я немножко поправлю. Ты сразу скакнул и пропустил один важный момент, о котором очень часто забывает народ, который пишет TCP-серверы, работает с сокетами с нуля. А именно то, что TCP-соединение с точки зрения Java-разработчика — это не массивчик байтов, это поток байтов. И это непрерывный поток. Самая коварная вещь в нём: если ты послал с одной стороны два раза по 15 байтов, то не факт, что с другой стороны прилетит два раза по 15 байтов. Может прилететь сначала 5 байтов, а потом 25. А может, все 30 сразу выпрыгнут. То есть у тебя нулевая задача при работе с TCP-соединением — разделить обратно этот поток байтов на отдельные кусочки. Их называют пакеты, фреймы, сообщения.
[1:51:57] Александр: И это мы сейчас прямо на уровень… по-моему, это следующий уровень в стеке протокольном. Какой-то, короче, уровень, но довольно низкий, где вот фреймы, пакеты — всё это.
[1:52:11] Дмитрий: Ну, это другие фреймы и другие пакеты. С точки зрения TCP/IP, с точки зрения ISO/OSI-модели, мы всё-таки находимся на уровне приложения, где-то 6–7 уровень.
[1:52:24] Александр: Ага, то есть мы сейчас на этом уровне.
[1:52:26] Дмитрий: По сути да. Прикольная вещь, что, с одной стороны, мы сделали много абстракций и ушли от TCP: у нас на нижнем уровне есть Ethernet-фреймы, в которых вложены IP-пакеты, в IP-пакетах — TCP-сегменты. А дальше TCP нам даёт…
[1:52:47] Александр: Явно в России, в Советском Союзе изобретали. На верхнем уровне у нас TCP сделан как стрим — набор байтов, который непрерывно течёт. И первое, что делают все разработчики баз данных, HTTP — почти любых протоколов, за исключением каких-нибудь стриминг-протоколов, — это обратно этот стрим делят на пакеты.
[1:53:12] Дмитрий: Потому что ты общаешься…
[1:53:13] Александр: То есть положить пакет с пакетами ещё в один пакет.
[1:53:15] Дмитрий: Да. Потому что ты общаешься между серверами конечными сообщениями: HTTP-request, HTTP-response, запрос в базу, ответ из базы. Тебе обратно этот непрерывный поток байтов нужно нарезать. Прикольно, что не всегда так вообще в сетевых стеках. Это в TCP такая концепция сделана. А, например, в UDP есть датаграммы, которые имеют конечный размер, но вся проблема в том, что он конечный — ты не можешь сделать датаграмму больше какого-то размера. А есть протоколы более изощрённые, используются в телекоме. Называется, например, SCTP. Java, кстати, его поддерживает — можно посмотреть, есть SCTP-сокеты в Java, можно с ними работать. Там прямо в концепции протокола есть отличия от TCP. Одно из них — как раз у тебя есть сообщения с размерами. В общем, идея в том, что тебе на начальном этапе это нужно нарезать, а это нетривиальная задача, потому что тебе может прилететь что угодно. И это означает, что тот, кто получает данные, должен будет копить их в буфере до тех пор, пока не поймёт, что сообщение полностью приехало. И отсюда вытекает то, что имеет смысл класть в хедер. Один из самых важных параметров, которые тебе стоит положить в хедер, чтобы с той стороны было удобно декодировать, — это длина сообщения.
[1:54:52] Александр: Да, и желательно это сделать в любой позиции в хедере, потому что длина хедера как раз фиксирована.
[1:54:59] Дмитрий: Фиксирована, да. И вот ты видишь по хедеру, сколько тебе ждать, чтобы наконец понять, что сообщение закончилось. Другой вариант — вставлять специальный разделитель, но чаще всего пишут длину. Хотя это с точки зрения реализации отправителя немножко сложновато, потому что длину ты узнаёшь, когда закодируешься. А послать её ты должен в начале.
[1:55:25] Александр: Мы же говорили, что для того, кто получает, удобно сначала узнать длину, а потом увидеть содержимое. Но для того, кто отправляет, удобно сначала закодировать, понять, сколько получилось байтов, и потом написать.
[1:55:41] Дмитрий: Ну да.
[1:55:41] Александр: Иначе концепция стриминга с инженерной точки зрения утилизируется не на 100%. По идее, ты стримишь, но по сути должен сначала всё сериализовать, подготовить в память, аллоцировать, и потом просто послать как стрим. А хотелось бы на ходу посылать и подсериализовывать.
[1:56:00] Дмитрий: Тут два подхода есть. Либо ты сериализуешь в память, в какой-то временный буфер, смотришь, сколько получилось, и потом приписываешь размер в начало: в хедере оставляешь пустое место, всё сериализуешь в byte-буфер в памяти, потом подставляешь правильное значение длины и копируешь всё из временного буфера в сеть. Это один подход. Второй подход — я не помню, есть ли он на стороне клиента Cassandra, но точно есть на стороне сервера. Там решили, что это местами дороговато, и написали две логики сериализации. Когда ты сериализуешь данные и обходишь структуру, у тебя есть логика serialize, которая порождает выходящий буфер, и есть логика serializedSize, которая реализована точно так же, как логика сериализации, но не пишет никакие байты в буфер, а вместо этого считает получившиеся байты.
[1:56:56] Александр: Как такое? Dry run.
[1:56:58] Дмитрий: Да, dry run без записи результата, и, возможно, чуть побыстрее, потому что не надо байты копировать, — просто длины считать. В Cassandra-сервере прямо такие есть куски, можно найти serialize и serializedSize. Казалось бы, простая проблема — сериализовать и записать размер, — а там целая инженерная проблема: как это сделать быстро и желательно не съедая много памяти. Так вот, возвращаясь к нашему фрейму, к сообщению, которое мы посылаем. Мы хотим написать, что это вообще такое, поэтому там будет какой-то тип — некое число, которое говорит, что дальше будет prepared statement, или batch.
[1:57:41] Александр: Ага, то есть эта спецификация нативного протокола плюс язык — оно там, наверное, вперемешку — говорит, что на этой позиции, начиная отсюда, будет такое число, у него тип integer. Это в хедере, мы про хедер сейчас говорим, правильно? То есть в хедере на позиции со смещением, не знаю, 8, будет лежать циферка, которая логически обозначает тип запроса. И это будет всегда так — ты всегда можешь это прочитать. А потом дальше будет, например, лежать длина, тоже всегда стандартно. А дальше уже ещё может какой-то набор полей.
[1:58:22] Дмитрий: Может, какой-нибудь специальный — как в HTTP хедеры могут быть, дополнительные опциональные поля, например. А дальше уже, в зависимости от типа сообщения, будет закодировано само сообщение. У тебя для батча один способ разложить это, для какого-нибудь другого запроса — другой.
[1:58:43] Александр: Вот мы общались с сервером только что, на самом начальном этапе, когда аутентифицировались, — это же тоже пакеты, мы тоже ходили как-то по сети. Это тоже часть протокола?
[1:58:52] Дмитрий: Да, есть там message типа authenticate, я уж не помню, как он называется. Вот это начальное рукопожатие при соединении клиента с сервером — это тоже некий набор реквестов, описанный в этой же спеке. И, наверное, скорее всего, первое, что всегда описывается в протоколе, — это как раз handshake, его логика. Есть некий способ, как оно начинает общаться, как обменивается параметрами, потому что, опять-таки, вспоминаем, что у нас версии меняются, протоколы тоже могут меняться. Поэтому есть такие понятия, как версия протокола, и обмен между клиентом и сервером информацией о том, какой протокол они поддерживают. У вас есть клиент, который умеет говорить по протоколу Cassandra — они обозначаются номерами 3, 4, 5, — и сервер умеет поддерживать какие-то версии. И им нужно во время начального соединения, если они хотят поддерживать кросс-операбельность так, чтобы клиент не был прибит гвоздями к конкретной версии сервера и наоборот, — иметь возможность обновлять сервер и клиент независимо. Для этого каждый из них поддерживает некий диапазон, и вначале им нужно эти диапазоны сравнить и понять, какую версию протокола выбрать, чтобы понимали обе стороны.
[2:00:12] Александр: До этого мы говорили про rolling upgrade серверной стороны, но вообще во всей этой коммуникации участвуют и клиенты, клиентские ноды. И они могут обновляться сразу с выходом новой версии, потому что клиентское приложение обновить в каком-то случае проще, чем сервер. А сервер может жить долгое время на старой версии или вообще никогда не обновляться. И новая версия клиента должна уметь разговаривать на языке протокола, который установлен на сервере. Или, если не умеет, явно об этом сообщить: «слишком старый сервер, обновляй сервер или задаунгрейдь меня, иначе не могу разговаривать».
[2:00:50] Дмитрий: Это тоже прикольно, потому что для инженеров баз данных это задача, которую они решают, тестируют, — кода довольно много на это написано. Например, если на клиенте есть новая фича — новый алгоритм, не знаю, хэширования, изобрели в 26-м году, и теперь поддерживается новый, — так вот, клиент по новой логике начинает работать, а сервер работает со старым хэшингом. И как? Это не будет вместе разговаривать. Поэтому нужно использовать старую версию, и там в клиенте будет специально написан if. Это всё, мне кажется, важно просто понимать, что такое существует.
[2:01:30] Александр: Да, и обратная ситуация тоже возможна: мы можем не всегда так легко обновить клиентов. Мы их можем вообще не контролировать.
[2:01:40] Дмитрий: У тебя есть две команды: инфраструктурная, которая занимается обслуживанием Cassandra-серверов, и набор команд, которые пишут приложения. И пропинать всех, чтобы обновили версию драйвера, — это ещё то упражнение, потому что «нам некогда, у нас тут функциональные фичи, надо сейчас спилить». Поэтому обычно есть два диапазона: диапазон, который поддерживает клиент, и диапазон, который поддерживает сервер, и они хотя бы должны пересекаться. Этот паттерн есть практически сейчас в любом более-менее — скажу английское слово — мачурном протоколе. Kafka, например, такое умеет.
[2:02:20] Александр: Мы упоминали даже TLS.
[2:02:22] Дмитрий: Это очень яркий пример. Обновить все веб-серверы или все браузеры одновременно — невозможная задача. Поэтому первая логика, которая выполняется во время установки TLS-соединения, — это handshake, где идёт обмен информацией о том, кто какие версии протокола поддерживает и какие алгоритмы внутри этого протокола. Например, алгоритмы шифрования сервер умеет такие, а клиент — такие. И они договариваются, чем будут пользоваться.
[2:02:54] Александр: Вот. Поэтому в хедере у нас всё это есть, мы как-то всем обменялись. Дальше идут данные и некие дополнительные параметры. Не знаю, наверное, про них пока говорить не будем.
[2:03:08] Дмитрий: Но есть одна важная штука, которая отличает протокол Cassandra от, например, если я не ошибаюсь, того же протокола Postgres. Она про корреляцию запросов и ответов. Мы посылаем в сервер запрос, он его как-то выполняет и потом отвечает. Вопрос: как мы понимаем, что вот этот ответ от сервера — это ответ на мой запрос?
[2:03:28] Дмитрий: Есть два подхода. Классический старый подход: что первое записали, то первое и обработали. Я в TCP-соединении записал реквест один, потом реквест два. И когда мне приходит ответ, я ожидаю, что ответ номер один будет на первый реквест, а ответ номер два — на более свежий. То есть они будут упорядочены в том же порядке, как я записал. Так, например, сделано в HTTP, в базовом варианте по крайней мере. У этого есть минусы: если первый запрос затормозил, выполняется долго, а второй быстрый, я не могу отослать ответ на второй сразу, потому что клиент не поймёт. Возникает проблема — один тупит в очереди, вся остальная очередь ждёт. Как на кассе: кто-то притащил тележку с кучей продуктов, и вся очередь ждёт, пока его обслужат.
[2:04:36] Александр: Ну или скидку ему не пробили, и он возмущается. «Галя, у нас отмена».
[2:04:40] Дмитрий: Cassandra в этом плане пошла по-другому и использовала другой подход, который некоторые протоколы тоже используют. Это не уникальное изобретение Cassandra, но не сказать, что очень популярное. Это id. Нам нужно положить в запрос некий id, который будет коррелироваться в реквесте и в ответе. Клиент записал id в реквест, реквест улетел на сервер, сервер его обработал и может в любом порядке записывать ответы обратно клиенту, но в эти ответы он будет класть id запроса, который обработал. Клиент может этот id каким-то способом сгенерировать. В случае Cassandra этот id — не некий увеличивающийся счётчик, тут начинаются тонкости и игры оптимизации. Потому что ты мог бы туда положить UUID — рандомный UUID нагенерировать, и было бы всё прекрасно, он глобально уникальный, вообще можно не запариваться. Проблема в том, что он длинный. Ты не хочешь тратить лишние байты на каждое сообщение, да и генерировать этот UUID, может быть, не очень быстро. Хочешь положить какой-нибудь short. В Cassandra, по-моему, как раз что-то такого размера — байта 2, не помню сколько. Но UUID — это overkill, потому что по сути мы задачу лишаем сложности: нам нужно в рамках сессии…
[2:06:10] Александр: В рамках даже TCP-коннекшена.
[2:06:13] Дмитрий: Да, TCP-коннекшена между конкретным клиентом и конкретным сервером — просто определить порядок возвращения сообщений. Вот и всё. Поэтому там используется логика: ты назначаешь AtomicInteger, по сути, на стороне клиента, который по порядку выдаёт id. Первому запросу id 1, второму id 2, и так далее.
[2:06:38] Александр: А как же тогда глобальная уникальность?
[2:06:41] Дмитрий: А зачем нам глобальная уникальность? Нам нужна локальная уникальность. Но у тебя проблема с этим integer-id в том, что рано или поздно он закончится. Поскольку мало битов хочешь использовать для этого поля, оно рано или поздно… Что делать, когда у нас ограниченные ресурсы, и он кончается?
[2:06:59] Александр: Переиспользовать.
[2:06:59] Дмитрий: Поэтому, когда ответы приходят, ты понимаешь: вот мне пришёл ответ для запроса с этим id — этот id можно положить в коробочку и переиспользовать. Там, по сути, комбинация из AtomicInteger и битмапы. Ты выдаёшь id из AtomicInteger, а в битмапе помечаешь, когда ответы приходят, какие из id снова свободны. И у тебя два источника чисел: либо смотришь битмапу и ищешь первый свободный битик, либо используешь счётчик. Сначала используешь счётчик, пока он не истратится, а потом идёшь и начинаешь доставать из мусорки — переиспользовать свои банки с id.
[2:07:44] Александр: Интересно. А почему не приняли решение просто закольцевать?
[2:07:47] Дмитрий: Есть вариант, при котором по первым id всё ещё выполняются запросы, поэтому мы не хотим закольцевать. А может, ты очень быстро посылаешь запросы, кто тебя знает. Поскольку ответы могут приходить не в прямом порядке, кто знает, что там может быть — может, ты за несколько секунд исчерпаешь все id. Поэтому проще переиспользовать.
[2:08:10] Александр: А кстати, сколько я могу запросов отправить в пике вообще по количеству? Получается, одного…
[2:08:17] Дмитрий: Тут давай раскладывать. Сначала рассмотрим одно конкретное TCP-соединение к одному серверу. Количество запросов, которые могут быть in-flight в один сервер, — это как раз размер вот этого… Оно называется stream id, по-моему, в Cassandra. Не спрашивай, почему stream. По сути, это request id. И там оно, по-моему, где-то 16 бит… В общем, десятки тысяч. То есть десятки тысяч запросов у нас могут быть одновременно in-process, in-flight — улетели, и ты ожидаешь. После этого, при попытке отправить в этот канал, драйвер скажет: «too busy, этот сервер уже не разгребает». Что ты в этом случае можешь делать? Никто тебе не запрещает открыть в один сервер несколько TCP-соединений.
[2:09:03] Александр: Сделать такой пулинг.
[2:09:06] Дмитрий: Да, и это драйвер умеет делать. Одной циферкой ты буквально это меняешь на стороне драйвера — не надо никакую конкретную библиотеку подключать. Ты просто говоришь ему: «Каждому серверу открывай до двух соединений, или до четырёх». Он будет: «Окей, не хватает — буду ещё открывать». Или можешь сразу сказать: «Открой с запасом, мы точно знаем, что будем слать много, поэтому пусть TCP-коннекшенов будет побольше».
[2:09:43] Александр: Окей. И сколько я могу открыть? Понятное дело, потенциально любое число, но вот так разумно, наверное?
[2:09:50] Дмитрий: По моему опыту, вряд ли ты будешь открывать больше четырёх. Дальше ты упрёшься уже в другие лимиты — чтобы это не было узким местом. Я ставил максимум 4–8, числа такие, и это на небольших серверах. Потому что, когда у тебя нод много, ты просто размазываешь: на каждую ноду будет попадать немного — вспоминаем наши предыдущие алгоритмы балансировки, — и там, скорее всего, уже и не понадобится несколько коннекшенов. То есть это когда у тебя небольшой кластер и очень сильная нагрузка на него — вот тогда имеет смысл открывать несколько TCP-соединений. А дальше, как я сказал, кластер может состоять из многих нод, поэтому в реальности один драйвер вполне может послать несколько сотен тысяч запросов в секунду самостоятельно.
[2:10:51] Александр: Но мы сейчас говорили про физическое ограничение в десять тысяч на одно TCP-соединение — просто потому, что эта циферка ограничена.
[2:11:01] Дмитрий: Ну, там не 10, но какое-то количество тысяч.
[2:11:03] Александр: Раф. Десятков тысяч. И плюс мы можем на 4, ну, на 8 от силы это умножить. Ну, короче, несколько сотен тысяч.
[2:11:15] Дмитрий: Смотри, мы тут всё-таки хитрые: это две разные метрики. То ограничение, которое я сказал, — это количество запросов in-flight. Оно хитро пропорционально количеству TPS — сколько запросов в секунду ты посылаешь. Потому что каждый запрос выполняется, скорее всего, единицы миллисекунд. Понятно, что там персентили и всё остальное, но в среднем по больнице, на нормальном окружении, не перегруженной ноде, запрос будет выполняться единицы, может быть, 10–20 миллисекунд. Поэтому реальные цифры, которые я лично видел: одно приложение, один клиент, один session-объект будет способен нагрузить базу на 1–2 сотни тысяч запросов в секунду.
[2:11:55] Александр: Скорее всего, и ваше приложение не потянет — вы напишете ещё такой генератор, чтобы что-то генерировать и с ним что-то делать. Окей. Так, мы остановились на том, что упаковали хедер, сериализовали наше сообщение и отправили его, выбрали нужную ноду, присвоили этому запросу id, уникальный в рамках сессии, чтобы потом скоррелировать с ответом. Так как клиент асинхронный by design, у нас нет такого «послал — ожидаешь»: мы напосылали много и ожидаем асинхронно. И то, что нам пришло, мы по id делаем обратный маппинг — ответ на что это пришло.
[2:12:59] Дмитрий: И тут есть ещё несколько весёлых задач. Первая: вот ты послал и ожидаешь. А сколько ждать-то? Бесконечно? Или всё-таки через какое-то время надо вернуть ошибку? А вдруг сервер не ответил? Он же не обязан ответить, он может вообще умереть. А может, сломался, случилась какая-нибудь логика, баг в сервере — и он вообще ничего не ответил.
[2:13:21] Александр: Ну, тут вопрос — он не ответил на что? На мой запрос, начал выполнять и…
[2:13:25] Дмитрий: Опять же, он не ответил на какой-то из твоих запросов. Может быть, он вообще перестал отвечать на все. Может, пропустил один из них и не ответил. Или ответил, но уже слишком поздно.
[2:13:19] Александр: К чему ты ведёшь?
[2:13:44] Дмитрий: К тому, что в драйвере, в клиенте, всегда есть таймаут. Есть максимальное время, которое вы ждёте, прежде чем ответить в приложении, что «извини, не получилось». И вот эта логика «извини, не получилось» — таймаут — тоже весёлая инженерная задача. Потому что у тебя очень много запросов летит, вот эти все тысячи. Например, 10 тысяч запросов в секунду. А таймауты у тебя, скорее всего, будут где-то… зависит от приложения. Дефолт там, по-моему, менялся: то ли раньше был 12 секунд, потом сделали 2. Но вполне нормально иметь какой-нибудь таймаут 10 секунд. Соответственно, у тебя каждую секунду создаётся, например, 10 тысяч запросов, которые могут жить до 10 секунд. Значит, у тебя должно быть 10 тысяч таймаутов, которые потенциально могут сработать. Тебе надо решить задачу — сделать эффективный таймер. Одноразовый: на каждый запрос, который ты отсылаешь, ты хочешь запланировать таймаут, и, если запрос ответится, отменить его. А если ответ не прилетит в отмеренное время, ты должен эту future пометить как выполненную с эксепшеном, на которой клиент ждёт, — сделать completeExceptionally.
[2:15:13] Александр: Окей. И как же этот таймер реализовать эффективно?
[2:15:16] Дмитрий: А это целое семейство алгоритмов, можно делать по-разному. В Cassandra Driver используются готовые решения. Мы сейчас говорим про сетевой стек — Cassandra его не пишет с нуля, там нет прямо голой работы с сокетами. Cassandra использует такую библиотеку, как Netty. Это популярная библиотека для написания сетевых приложений — с этими байтиками как раз связаться. И одна из фич в самой Netty, в утилитах, называется — название класса могу не вспомнить, но идея в том, что эта штука называется hash wheel timer. Или wheel hash — порядок слов могу наврать, но ключевые слова «колесо» и «хэш» там есть. И таймер.
[2:16:11] Дмитрий: Какая тут идея используется? Ох, это без картинок очень непросто будет объяснить.
[2:16:15] Александр: Так, напрягаем свои извилины, если они у вас остались к третьему часу прослушивания, дорогие слушатели. Сейчас будем hash wheel timer.
[2:16:25] Дмитрий: Это прикольная структура данных, изобретённая, наверное, ещё в 80-х годах, которая позволяет решать вот эту задачу: когда у нас очень много таймеров, которые мы хотим запланировать. У нас есть операции: запланировать что-то на будущее, чтобы оно потом через какое-то время сработало, и — либо мы понимаем, что оно нам не нужно, — быстро его отменить.
[2:16:49] Александр: Ага, то есть есть прям определённый паттерн, под который эта структура данных создана.
[2:16:54] Дмитрий: И он идеально подходит. Представляем себе Java API: положить, запланировать задачу — она выполняется, если подошло время, — и ещё возможность отменить её. Потому что большую часть запросов, мы надеемся, нам везёт, и база отвечает, и большую часть таймеров мы отменяем. И эта операция должна быть дешёвой, иначе мы будем тормозить на каждом ответе, что нехорошо. Эта структура данных популярна. Её можно найти, например, в той же Kafka, в ядре Linux. Она использует то свойство, что такого рода операции нам не нужно выполнять с очень большой точностью. Нам не нужно планировать что-то с точностью до микросекунды. Задали таймаут 2 секунды, а выполнится он через 2 секунды и 30 миллисекунд — ну и нормально, примерно 2 секунды нормально. В общем, что мы делаем? Можно представить себе аналогию с будильником. Кто постарше, видели механические будильники, когда ещё были стрелки, которые можно крутить: часовая и минутная стрелка, и ещё третья стрелка, которую ты задаёшь — когда будильник должен зазвонить. По сути, здесь такая же реализация этой идеи. У нас есть часы, некий циферблат — это закрученный в кольцо массив. По этому массиву тикает некая стрелка, указатель, как он уедет. И каждый элемент массива — это некая временная ячейка. Вот циферблат часов: первый элемент у нас на 12 или 0, второй — на 1 час, следующий — на 2 часа, и так далее. Каждый час — по ячейке. И есть стрелка, которая тикает, в нашем случае каждый час.
[2:18:48] Александр: Понятно. Часы заменяем на секунды, и всё.
[2:18:51] Дмитрий: Для упрощения я говорю про часы. И вот в эту ячейку… Сейчас время полночь. Мне надо запланировать задачу, которая должна выполниться в 3 часа ночи. Я иду в этот циферблат и кладу задачу в ячейку, которая лежит на 3 часа. Понятное дело, что если у меня сейчас 9 часов вечера, то через 3 часа это будет полночь, — то есть я вычисляю позицию ячейки относительно того, где сейчас стрелка.
[2:19:10] Александр: Ага, и тут, наверное, шаг влияет: если сейчас не 9 часов, а 9:30, то я положу всё ещё в 3, и у меня будет люфт в 30 минут.
[2:19:29] Дмитрий: Да. Получается такой батчевый интерфейс, где каждая ячейка отвечает за какой-то диапазон времени. Ты можешь эту ячейку помельче сделать, и у тебя будут тики более точными, но, соответственно, будет и больше оверхеда: каждое сдвигание стрелки — это операция, которая требует ресурсов, а каждый элемент ячейки — это память. Если сделаешь очень много мелких ячеек, у тебя будет много памяти выжираться, а стрелка будет двигаться очень часто, и ты будешь тратить CPU только на то, чтобы каждую микросекунду двигать эту стрелку. Поэтому выбирают приемлемый дефолт — точность уровня десятков миллисекунд — и дальше играются с этим параметром. В общем, это то, что называется time wheel, колесо времени.
[2:20:24] Александр: Ага, колесо времени. Отлично. Тут ещё хэш-слово: мы же говорим про hash wheel timer.
[2:20:29] Дмитрий: Да. А дальше начинается игра в таком стиле: мы сделали такое колесо, но максимальное время вперёд, на которое мы можем запланировать таймер, ограничено размером этого массива. Вот у меня на часах циферблат 12 часов — значит, дальше чем на 12 часов я запланировать ничего не могу.
[2:20:55] Александр: Да, ну то есть та самая третья стрелочка, которая отвечает за будильник: ты не можешь ей сказать «в пятницу не буди, сегодня не буди, а завтра, пожалуйста, буди». Каждый раз, когда стрелка туда попадает, он будет звенеть, потому что таймфрейм 12 часов.
[2:21:15] Дмитрий: Да. Соответственно, чтобы удлинить этот период, нам приходится этот массив-циферблат раздувать, делать длиннее и больше. Это проблема — мы потребляем память. Что можно сделать? Мы можем сказать: а давайте будем класть в ту же ячейку памяти. Например, мне надо запланировать через 15 часов — вот у меня 12 часов на циферблате. Давайте я эти 15 шагов по кругу пройду: вернусь в то же место в начале — пройду 12 шагов, остановлюсь, где начинал, и ещё 3 в остатке, итого 15. То есть, по сути, я сделаю остаток от деления на размер массива, чтобы определить ячейку, в которой это будет лежать. Но мне нужна какая-то логика, что я уже один раз прошёл круг: я попаду в эту ячейку. Ничего не напоминает?
[2:22:11] Александр: Сейчас, секундочку. Мне напоминает хэшмап, на самом деле. Собственно, вот эти бакеты, куда попадают ключи по остатку от деления по размеру бакетов. В хэшмапе я считаю хэшкод, и по остатку от деления этого хэшкода вычисляю бакет. Бакетов может быть сильно меньше: хэшкод у меня integer, а бакетов явно не integer, сильно меньше. Здесь то же самое: я взял остаток от деления и попал в эту ячейку, а дальше в этой ячейке у меня тоже лежит, скорее всего, чаще всего связанный список. Практически хэшмапа, коллизии.
[2:22:49] Дмитрий: Только там, возможно, теперь есть особенность. Когда стрелка тикнет на эту позицию, она не просто берёт и всё, что в ячейке лежит, объявляет выполненным, вызывает калбеки-таймауты и так далее. Она должна пробежаться по этому списку и сказать: «Ага, здесь таймаут реально через 3 секунды, а здесь — через 15 секунд». Поэтому 3-секундный таймаут я вычищаю, а 15-секундный оставляю на следующую итерацию — когда стрелка ещё раз пробежит, она увидит, что пора эту запись выполнять. То есть она её пропустит на этом круге. Собственно, эта штука неплохо работает. Понятно, что там есть всякие частные случаи, когда это не очень хорошо работает, как и в любой структуре данных. Если мы всё запланируем на далёкое будущее, то будем постоянно бегать по списку и говорить «ничего не готово, зря побегали» — список можно отсортировать и придумать другие оптимизации. Но идея ровно в этом. Дальше эту структуру можно ещё улучшать, но идею, надеюсь, вы поняли. Такая штука есть в Netty, потому что почти любому сетевому протоколу нужно как-то работать с таймаутами: таймауты открытия соединений, таймауты запросов. Поэтому там есть готовый класс, который всё это реализует. И клиент это использует, и таким образом понимает, что пора ваше приложение вызвать и сказать: «Извини, ответа не будет, таймаут». Это что касается логики с таймаутами. И поскольку мы заговорили про Netty и про потоки — на самом деле драйвер внутри устроен так, что у него есть свои потоки, это не пассивная сущность, которая выполняется в ваших потоках. Когда вы снимаете thread dump Java-приложения с Cassandra Driver, вы там найдёте несколько потоков, связанных с драйвером. Это как раз этот hash wheel — и теперь будете знать, что означают эти волшебные слова. Вы найдёте либо слово hash с wheel, либо слово timeout — не помню, как сейчас этот поток называется, но по stacktrace поймёте, что это именно эта сущность. И поскольку Netty — это асинхронный фреймворк, он использует специальные потоки, которые взаимодействуют с сокетом. Когда мы пишем запросы в сокет, это не ваш поток. То есть когда вы выполняете метод execute, этот поток реально в сокет ничего не пишет: он не берёт байтики, не вызывает системный вызов. Почему? Вспоминаем, что потоков-то много: один прибежал, начал писать в сокет, другой прибежал, начал писать, один записал половину своего пакета, другой — другую половину, всё перемешалось. А сокет-то один — получилась куча. Мы можем влепить туда synchronized-блок, и всё будет печально и медленно работать. Либо мы можем сделегировать всю эту работу в специальный поток, который будет обслуживать конкретное TCP-соединение. Получается структура: внутри Netty есть несколько потоков, которые называются event loop, которые крутятся в бесконечном цикле, берут задачи — например, записать что-то в сокет или прочитать что-то из сокета. Каждому такому потоку назначено несколько TCP-коннекшенов, но для каждого TCP-коннекшена есть ровно один поток, который его обслуживает. Соответственно, синхронизация при работе потока с коннекшеном уже никакая не требуется. Например, четыре таких event loop крутятся, и каждый обслуживает по четыре коннекшена — у нас 16 TCP-коннекшенов, они как-то забалансированы между этими event loop-потоками, и эти потоки читают и пишут из сокетов неблокирующим образом. Тут опять-таки удобно, что мы не блокируемся: записали в сокет, пошли заниматься другими задачами, не ждём ответа от сервера. Ответы приходят асинхронно и как раз коррелируются через id. Иначе нам бы пришлось делать что-то типа синхронных сокетов — записали и стоим ждём, — и потоков нужно было бы сильно больше, чтобы обслуживать запросы, и большая часть стояла бы и ждала на ответах. Что, может быть, сейчас стало лучше, когда появились виртуальные потоки, но, по крайней мере, для классических систем выгоднее делать это через такой асинхронный механизм.
[2:27:34] Александр: Так, мы вроде хорошо поговорили про клиент, что там на нём происходит. Там, где есть таймауты, там всегда есть ретраи. Происходят ли ретраи на клиенте?
[2:27:45] Дмитрий: Да. В составе драйвера, в его логике, есть своя логика ретраев. Никто не запрещает вам делать ретраи в вашей бизнес-логике, но сам драйвер тоже умеет их делать. Но ему нужно помочь, потому что он не всегда знает, безопасно ли этим заниматься. С чем это связано? Когда мы выполняем запросы, может быть ситуация: мы отослали запрос на сервер, а ответ не пришёл. И мы не знаем, почему. Он не пришёл, потому что не долетел до сервера? Условно, сидит злоумышленник, который либо перехватывает запросы от клиента к серверу, либо перехватывает ответы от сервера к клиенту. В одном случае на сервере логика выполнилась, а в другом — до сервера дело не дошло. Соответственно, мы находимся в состоянии неизвестности, и для некоторых запросов она важна принципиально. Например, мы какой-нибудь счётчик увеличиваем — мы не хотим случайно увеличить его два раза. А если делаем SELECT — один раз или два, неважно. Поэтому здесь появляется дихотомия, и правильно называется волшебным словом «идемпотентность». Для запроса, который мы отправляем на сервер, можно поставить специальную пометку — идемпотентный он или нет. И можно ещё в настройках клиента поставить, что вообще все запросы в этой сессии по дефолту идемпотентные или нет. Дефолтное значение — нет. Безопасный дефолт: кто знает, что вы там выполняете. Но вы можете поставить, что почти всегда наоборот — идемпотентно. Почти все запросы в Cassandra можно повторять — вспоминаем опять upsert, INSERT, UPDATE: что один раз сделал INSERT, что два раза — почти всегда одно и то же, результат одинаковый. Соответственно, драйвер умеет делать ретраи и учитывает эту информацию. Одно дело, когда он попытался выполнить запрос, ещё не успел отослать на сервер и получил ошибку, — тогда он понимает, что безопасно сделать ретрай в любом случае: сервер ещё ничего не начал делать, туда точно ничего не улетело. Но если мы запрос на сервер уже отослали, то я могу делать ретрай, только если понимаю, что безопасно выполнить его второй раз. Поэтому он начинает смотреть на этот флажок «идемпотентно — ретраить или нет». И, как обычно, в Cassandra Driver это сделано в виде некой стратегии — retry policy, по-моему. Можно написать свою. По дефолту я когда-то рисовал табличку, как она себя ведёт в тех или иных случаях, — там довольно сложная ситуация, много комбинаций. Есть даже ситуация, когда мы послали запрос на сервер и получили от него ошибку, и можем понять по ошибке, что сервер реально не выполнял запрос. Он сказал, например: «Я слишком занят, у меня нет ресурсов выполнять запрос».
[2:30:56] Александр: Не в ресурсе.
[2:30:58] Дмитрий: Да, или «я-то в ресурсе, а вот мои соседи что-то не очень: у меня нет достаточно реплик, чтобы выполнить твой запрос, приходи позже». В этом случае тоже ситуация, что запрос реально не выполнялся, и драйвер это понимает по ошибке: пришёл ответ со стороны сервера «я не могу по такой-то причине», и по этой причине мы понимаем, что запрос не выполнялся. В таких случаях тоже может быть ретрай. И там понятно, что какое-то константное количество попыток делается через какое-то время. Можно накрутить что-нибудь более сложное, типа экспоненциального backoff. Я не помню, есть ли там готовая реализация, но свою написать можно. Но это обычные ретраи. На мой взгляд, вещь достаточно ограниченная и мало полезная, по крайней мере в тех приложениях, что я писал.
[2:32:19] Александр: Почему?
[2:32:22] Дмитрий: Потому что обычно эти таймауты мы не хотим делать очень короткими, чтобы не мучить серверы слишком часто. Но когда мы делаем их слишком длинными, появляется другая проблема: наши пользователи не очень рады видеть, что их запрос в приложение выполнялся очень долго. Мы отослали запрос в базу, проторчали на таймауте, сделали ретрай только через несколько секунд — и в итоге время выполнения запроса в нашем приложении очень долгое. Как из этого порочного круга выйти? Тут есть такая концепция. У неё есть два названия. Одно, которое использует Cassandra Driver, — спекулятивный ретрай. Другое, которое, по-моему, есть в книжке Google про site reliability engineering, — хеджированные запросы, hedged requests. Идея в том, что при обычном таймауте мы ждём полного таймаута, считаем предыдущий запрос испорченным, забываем про него и засылаем новый запрос после таймаута. В спекулятивном таймауте мы действуем по-другому. Мы ждём какое-то относительно маленькое время, за которое запрос типично отвечал. Мы видели, что обычно он отвечал за 20 миллисекунд.
[2:33:41] Александр: Что значит «обычно»?
[2:33:41] Дмитрий: Например, мы отслеживаем, сколько времени выполняются запросы в эту ноду. По-разному можно делать; по-моему, в текущий момент в Cassandra так и делают. И видели, что 90-й перцентиль ответов укладывается в такое-то значение — 30 миллисекунд. И мы ставим себе второй таймер — опять-таки этот hash wheel timer — на 30 миллисекунд: если ответ от первого сервера не придёт, мы сделаем спекуляцию, спекулятивный ретрай. Раз не ответил быстро — наверное, вообще не ответит.
[2:34:39] Александр: Либо ответит сильно позже.
[2:34:40] Дмитрий: Это гипотеза, может, и не выполнится. Что мы сделаем? Через эти 30 миллисекунд мы пошлём ещё один запрос в другую ноду. Вот он ретрай, но спекулятивный. При этом первый запрос по-прежнему выполняется — может быть, он через 31 миллисекунду ответит. То есть мы два запроса почти в параллели выполняем, с небольшим сдвигом. Мы надеемся, что первый запрос почти всегда — поскольку мы берём 90-й перцентиль — в 90% случаев будет обслужен, и второго ретрая не будет, если паттерн ответа сервера не поменялся. Но если он почему-то не уложился — вспоминаем, Java, GC по-прежнему ещё есть.
[2:35:02] Александр: Да, кстати, не забываем. Помимо прогрева, у Java ещё есть GC.
[2:35:02] Дмитрий: Стало лучше, конечно — спасибо Shenandoah за GC, — но иногда бывает. А ещё бывают всякие VM: сейчас все в клаудах, тяжёлые неприятные соседи, которые выжрали все ресурсы. По какой-то причине сервер мог быстро не ответить, но ответит. Поэтому мы делаем второй запрос через коротенькое время, и дальше — кто из них первый добежит, того ответ и используем. Тем самым мы добиваемся того, что не ждём полный настоящий таймаут для этих запросов, а делаем ретраи существенно раньше и используем результат одного из них. Может быть, и первый ответит за это время — GC закончится, и всё вернётся. Это позволяет существенно снизить хвостовые задержки. Вспоминаем, опять-таки, мы говорили про стратегии балансировки, и я упоминал сортированные списки. Вот поэтому там нужен список: для спекулятивного и даже для обычного ретрая нам нужно больше одного кандидата. Поэтому логика выбора ноды в стратегиях балансировки — это не логика выбора одной ноды, а логика выбора списка нод. Она должна вернуть упорядоченный список — это называется query plan, по-моему, в драйвере, — чтобы можно было быстренько решить, на ком спекулировать или в кого делать ретраи. И это хорошо работает, при этом стоит относительно недорого, потому что большая часть запросов всё равно укладывается в нормальные времена, и для них спекулятивный ретрай мы не делаем.
[2:36:20] Александр: Прикольно. Забавно, что здесь мы делаем что-то странное: мы с клиента начинаем, не дождавшись, отправлять ещё один запрос на другую ноду. Но если мы понимаем, что это происходит крайне редко и в абсолютном большинстве случаев этого не происходит, — прикольно. А получаем мы в итоге то, что быстрее переживаем всякие странные ситуации, когда нода вышла из кластера и у нас что-то моргнуло. Мы просто более толерантны становимся к аутеджам на стороне клиента. Это круто. Так, про ретраи мы поговорили. Например, где есть ретраи, таймауты, клиент-серверные взаимодействия, там часто ещё говорят про всякий backpressure, про rate limits. Есть ли что-то подобное в клиенте?
[2:37:11] Дмитрий: Да, конечно. Когда мы пишем любое TCP, вообще любое взаимодействие с другой системой, у нас так или иначе возникает проблема контроля нагрузки. Мы не хотим… То есть какие ситуации могут быть? Может быть ситуация, что клиент — например, в бизнес-логике приложения какая-нибудь бага, какой-нибудь цикл неправильно написан в условии выхода, и мы зациклились. И начали бомбардировать сервер неприлично большим количеством запросов. Понятно, что сервер тоже должен защищаться от этого, но про это мы поговорим, когда будем говорить про сервер. Но хорошо бы, чтобы и на уровне драйвера мы могли превентивно это не допускать и защищаться на стороне драйвера. В драйвере есть логика, которая позволяет тебе — по-моему, она вообще не включена, но можно добавить. Тоже, видимо, есть некая стратегия, есть дефолтные реализации, можно написать свою, но там, скорее всего, что-то типа стандартного rate limiting вида «n запросов в секунду», возможно, через — как это называется? — token bucket, такой алгоритм. Не знаю, стоит ли про него рассказывать.
[2:38:35] Александр: Нет, сейчас, я думаю, мы уже слушателям дадим плавный выход. Ключевые слова «токен» и «бакет» — по ним вы можете найти описание этого алгоритма.
[2:38:46] Дмитрий: И это решает только проблему ограничения нагрузки. Но у нас ещё, например, сервер может в какой-то момент просто отвечать медленнее, чем обычно, а приложение пишет больше, чем сервер может обработать. И у нас получается такая ситуация: TCP-сокет — это труба, с одной стороны мы как клиент туда что-то льём. Или даже назовём это не трубой, а бассейном: мы как клиент туда что-то наливаем, а сервер оттуда что-то выливает. Может так оказаться, что вливать мы начинаем больше, чем сервер может выливать. Либо у нас бассейн переполнится и нас начнёт топить, либо нам нужно как-то клиента ограничить. И в этом плане это координация между клиентом и сервером, и она реализуется средствами самого сервера и самого TCP-сокета. В TCP-сокете есть механизм — вы не можете просто писать в него бесконтрольно. Когда вы пишете в TCP-сокет, вы на самом деле пишете в некий внутренний буфер на стороне отправителя. Дальше эти байты летят по сети, прилетают на сервер, а на сервере есть буфер-получатель. Из этого буфера читает сервер, наше приложение. И допустим, сервер не успевает разгребать — задумался, что-то происходит, — и этот буфер потихоньку заполняется и заканчивается. Он его полностью забил. Когда TCP-пакеты гуляют по сети, там есть специальный параметр — сколько мне можно ещё посылать, TCP window. И на сторону клиента, в операционную систему, прилетает эта информация, что «извини, не пихается, нет места, та сторона отказалась принимать; будешь пихать — мы будем отвечать ошибками и всё равно брать не будем». Начинаем копить это всё в TCP на стороне клиента. Труба заполняется, в какой-то момент и этот буфер заполнился. И дальше это поднимается на уровень приложения. Когда приложение пытается записать в Java-сокет что-то, есть два варианта. Либо у тебя синхронный, блокирующий Java-сокет: ты просто вызываешь send, и он зависнет — твой поток просто висит, и за счёт этого ничего не пишет, мы его заморозили; как появится место, тогда мы всё запишем и отпустим поток. Но Netty — это асинхронная библиотека, и там NIO-сокеты, которые говорят, что мы никогда не блокируемся. Но это означает, что в NIO-сокете есть другой механизм — понятие готовности канала к чтению или записи. У тебя есть неблокирующий канал, у него операция записи, которую вызывает Netty — мы используем примитивы Netty, но внизу под Netty находится NIO-сокет или его аналог. Этот канал может сказать: «Нельзя меня писать, не готов я к записи, извини, ошибка». И тогда ты пришёл, сказал «мне надо послать», а тебе говорят: «Извини, некуда, закрыто на обед». И ты должен это куда-то взять и как-то в руках у себя подержать. Тебе приходится на уровне кода драйвера — ну, Netty в тебя это частично делегирует — что-то с этим неотправленным реквестом делать. Он у тебя либо будет в памяти… То есть силами Netty реализован такой backpressure-механизм, и в конце концов это в тебя как в приложение пойдёт: тебе скажут в какой-то момент, что «извини, слишком много пихаешь».
[2:42:36] Александр: Слушай, очень интересно. Я ловлю себя сейчас на мысли, что, используя просто клиент — библиотеку для конструирования и отправки запросов, получения ответов в такую распределённую интересную базу, как Cassandra, — я даже не задумываясь, как пользователь этого клиента, не думаю о том, сколько там под капотом всего происходит прямо у меня в приложении. Что за собой этот клиент несёт и насколько много там оптимизаций и вообще головной боли решено. Я думаю, разработчики этого клиента работают за меня, потому что все эти штуки в итоге кладутся на конечное приложение: если сервер не смог ответить вовремя, значит, ты у себя в коде должен это реализовывать. Ну, как бы да, но хотелось бы этого не делать. Я хочу в первую очередь писать бизнес-логику как клиент. Поэтому это круто — понимать и иметь представление о том, как такие штуки работают. Нас как инженеров это прокачивает. Я думаю, мы сегодня за три часа прокачались как инженеры.
[2:43:33] Дмитрий: Да. Теперь, если вы вдруг решите написать собственный драйвер для базы данных, вы знаете, о чём стоит подумать. И это касается, на самом деле, не только драйвера, а вообще любой интеграции. По сути, общение с базой данных — это интеграция. Когда вы интегрируетесь через REST с другим приложением, большая часть проблем, что я описал, там тоже есть. Что-то из них решено вашим HTTP-клиентом или оберткой над ним, а что-то вам придётся решать самим. С одной стороны. А с другой — это всё абстракции, которые спрятаны в библиотеку, но когда что-то начинает работать не так, все эти абстракции начинают течь, и вам придётся с этим разобраться. Когда у вас возникают какие-нибудь продакшен-инциденты, лучше, если вы про это знаете заранее, а не делаете исследования и узнаёте про такие концепции с нуля. Кажется, эти знания полезны любому разработчику, особенно тем, кто работает с чем-то критичным.
[2:44:42] Александр: Да. Мы хотим сделать серию подкастов — скорее всего, это будет два больших выпуска. Сегодня вы послушали первый выпуск про то, как вообще работает Apache Cassandra. Мы ни разу не коснулись того, что происходит на сервере, кроме rolling upgrade и небольших кэшиков. Эту часть мы специально осознанно отложили на следующий выпуск — 100% обязательно будем делать. Мы с Димой обязательно поговорим про compaction, который происходит и на клиенте, и на сервере, как меньше данных пересылать; про компрессию. Поговорим про то, как вообще распределённая логика работает — может быть, про gossip, про LSM-деревья, про то, как данные представлены на сервере, как они между серверами туда-сюда мигрируют, перетекают и попадают на клиент. Это тема для следующего выпуска, так что ставьте лайки, оставайтесь подписанными. И кому интересно — заходите в Telegram-канал, там иногда происходят всякие эксклюзивчики, ссылка на него есть под описанием каждого выпуска. Диме огромное спасибо за уделённое время и за то, что поделился такой интересной экспертизой. Редко удаётся поговорить с разработчиками, которые не то что знают, как что-то работает, но и сами делают патчи в продукты, поэтому всегда приятно поговорить в подкасте с таким уровнем специалиста. Спасибо.
[2:46:05] Александр: Спасибо, Дима.