Выпуск 38 · подкаст «Тысяча фичей»

#38: Почему ClickHouse не тормозит

2:36:01
↓ скачать mp3

Александр Пахомов и коммитер ClickHouse Максим Кита разбирают, почему самая популярная аналитическая база данных не тормозит: от разницы OLAP и OLTP и колоночного хранения до движков семейства `MergeTree`, агрегатных функций, проекций и `FINAL`. Во второй половине — инженерная культура ClickHouse: stateless-тесты вместо моков, фаззинг, Jepsen и `TLA+`, интроспекция и профилирование запросов через flame graph, а также разбор инцидента с высокой конкурентностью у одного крупного клиента, где глобальный mutex в контексте заменили на read-write.

Главное

  • OLAP-базы хранят данные колоночно (структура массивов вместо массива структур), поэтому читают только нужные колонки и на аналитических запросах бывают в сотни-тысячи раз быстрее строковых OLTP-баз.
  • Похожие данные в одной колонке сжимаются кратно лучше, чем разнородный профиль строки целиком, — это второй множитель производительности после сокращения объёма чтения.
  • ClickHouse сознательно отказывается от универсальности (не претендует на OLTP/HTAP): жёсткие рамки для пользователя дают движку больше пространства для оптимизации.
  • Движки семейства `MergeTree` (`ReplacingMergeTree`, `AggregatingMergeTree`, `SummingMergeTree`) применяют логику к строкам с одинаковым первичным ключом во время фонового merge; `FINAL` применяет её на лету при чтении.
  • Агрегатная функция в ClickHouse — это тип данных с методами `create`/`merge`/`finalize`; `merge` двух состояний позволяет агрегировать в много потоков и хранить преагрегаты как в `AggregatingMergeTree`.
  • Проекция отличается от материализованного представления тем, что консистентна и лежит в том же куске данных, поэтому оптимизатор может прозрачно читать запрос из неё вместо основной таблицы.
  • ClickHouse тестируют не юнит-тестами с моками, а stateless-тестами через SQL-интерфейс плюс интроспекция (сколько прочитано байт, строк, засечек); всё гоняется под санитайзерами и фаззерами.
  • Инцидент TinyBird (10 000 запросов/сек) — это `Critical Sections Bound`: глобальный mutex контекста упирался в шедулер; починили, разделив mutex на глобальный и локальный и заменив на read-write.

В выпуске

  • Максим КитаРазработчик ClickHouse, контрибьютор компилятора Swift и коммитер проекта LLVM. GitHub ↗ maksimkita.com ↗
Расшифровка

[00:07] Александр: Здорово! Меня зовут Саша Пахомов, и я инженер, который любит своё дело. Вы слушаете подкаст, в котором разработчик современной базы данных изучает, как они работают, и делится знаниями со слушателями. В одном из предыдущих выпусков мы погружались на первый взгляд в далёкую от баз данных тему — компиляторы и языки программирования. Гостем того выпуска был разработчик базы данных ClickHouse Максим Кита. Выпуск получился настолько интересным, что я решил сделать вторую часть, в которой мы с Максимом копнём вглубь самой популярной аналитической базы и разберёмся, почему ClickHouse не тормозит. Если вдруг вы чувствуете, что базы данных — не ваш конёк, то у меня есть для вас подборка всех выпусков подкаста на эту тему в хронологическом порядке. Заходите в телеграм-канал «Тысяча фичей» и ищите подборку в закреплённых сообщениях. Ну а если вам знакома такая структура данных, как B-дерево или Log-Structured Merge Tree, то смело настраивайтесь на два с половиной часа технического подкаста про ClickHouse. Поехали!

[01:30] Александр: Представьте ситуацию, в которой база данных начинает тормозить на продакшене. Мониторинг показывает, что с ресурсами всё в порядке — процессор, диск, сеть — всё в пределах нормы. Но некоторые запросы отрабатывают значительно медленнее остальных. Что-то явно не так. Проблема достигает таких масштабов, что игнорировать её становится невозможно. Инженеры засучивают рукава и начинают профилировать базу данных. Они видят, что некоторый context-log-event появляется довольно часто, но понять его влияние на перформанс не могут — ведь замеров времени работы конкретно для этого события нет. Инженеры написали репродьюсер, который воспроизводит похожую нагрузку в контролируемых условиях, — так они могут составить качественный репорт и сообщить разработчикам базы данных о проблеме. Я думаю, вы уже догадались, что речь про ClickHouse. А разработчик, который в итоге исправил эту проблему, сегодня нам расскажет, как ему это удалось. Но чтобы понять, как так получилось и что, собственно, произошло, нам понадобится пара часов на погружение. Обещаю, оно не менее интересное, чем развязка истории.

[02:47] Александр: Как бы ты описал, что такое ClickHouse, студентам, не знаю, четвёртого курса технического университета?

[02:52] Максим: ClickHouse — это колоночная база данных. В целом она создана скорее под аналитическую нагрузку. Стандартные базы данных типа MySQL, Postgres созданы больше под транзакционную нагрузку и относятся к классу систем, который называется OLTP — Online Transactional Processing. Для таких систем must-have — поддержка транзакций и умение обрабатывать огромное количество короткоживущих транзакционных запросов: например, GET по ключу, SET по ключу. Но на мощных, грубых запросах по большим таблицам — всякие тяжёлые джойны, когда мы читаем и обрабатываем огромные объёмы данных, — такие базы данных в целом могут работать хуже. И всё это определено тем, как они хранят данные.

[03:42] Александр: Как они хранят данные.

[03:43] Максим: Они хранят данные построчно, и поэтому некоторые алгоритмы, которым нужно обрабатывать много данных, использовать не могут: например, мощную потоковую обработку, SIMD-инструкции, хорошую векторизацию циклов, разворачивание циклов, — и за счёт этого перформанс получается не такой приятный. Плюс обычно, когда данные хранятся построчно, внутри базы данных ты работаешь с ними как с массивом элементов неопределённого типа. Например, в C++ это Union или новый Variant. И когда ты работаешь с такими массивами, тебе приходится распаковывать этот Union, и за счёт этого у тебя в каком-то смысле массив структур. И многие операции с ними эффективно выполнять не получается. А когда ты хранишь данные колоночно — как раз это делают OLAP-системы, Analytical Processing, — получается не массив структур, а структура массивов. То есть каждая колонка — это отдельный массив, и внутри базы данных можно смело взять колонку и понимать: это колонка Integer, мы точно знаем, что она, например, 64-битная, поэтому можем её скастить и работать с ней как с нормальным интеджером. В строковых базах данных так не получается, и за счёт этого на запросах, которые обрабатывают большое количество данных, там в сто, в тысячу раз медленнее.

[05:14] Александр: Да, ты всё правильно сказал. Я бы только ещё раз обобщил на каком-нибудь примере и подвёл, знаешь, самую ключевую, как мне кажется, первичную разницу. Действительно, у колоночных структур много преимуществ в аналитической нагрузке, это правда. Но самое главное, на мой взгляд, вот в чём. Давай представим, что у нас есть таблица — допустим, юзер-профайл какой-то в интернет-магазине. Твоя обычная страничка, даже без корзины: просто имя, фамилия, e-mail, номер телефона, адрес и ссылка на фотографию. Типичный юзер в любой системе, в любом интернет-магазине. И, скорее всего, он будет лежать в каком-нибудь Postgres. Как он будет там лежать? Действительно, как ты сказал, это будут строчки. Но что под этим подразумевается? По сути — у меня был целый сезон подкастов про базы данных, и там я рассказывал про страничное хранение — в каждой странице в памяти будет лежать непрерывный поток байтов, относящихся к каждому пользователю, к каждой строчке. Сначала пойдёт ID, сколько-то байт, потом его имя, потом фамилия — и это всё непрерывная плашка. Поэтому, когда мы будем считывать информацию про юзера — например, пользователь заходит в интернет-магазин, хочет увидеть свой профайл, — мы знаем ID этого пользователя, берём эту строчку, просто считываем, шмяк, и сразу возвращаем. Это как раз транзакционная нагрузка, в терминах OLTP: я взял по ключу, отобразил данные — по сути, просто считали кусочек из страниц. Ну, мы считали всю страницу, подняли в памяти, кусочек из неё вернули, десериализовали, но суть такая. А если нам нужно сделать какую-то аналитическую штуку с этими пользователями — допустим, у нас их миллион, и аналитик подключается к Postgres и спрашивает, сколько у меня уникальных номеров телефонов, или сколько таких, что начинаются на 999, а заканчиваются на 0, — вот такая аналитика. И тогда ему нужно будет считать все данные из базы, причём не только номера телефонов, которые нужны в запросе, но и имя, и фамилию — всё прилетит в память. Вся эта строчка из страницы будет считана плашкой. По крайней мере страница точно будет загружена целиком со всеми этими данными. И, по сути, в 4 килобайтах страницы полезная информация — колонка «номер телефона» — это будет пятая или двадцатая часть, в зависимости от того, сколько колонок. А всё остальное в контексте этого запроса — просто мусор, эти данные нам не нужны. И вот здесь киллер-фича колоночных: колонка «номер телефона» будет храниться в отдельном файле. Когда мы считываем страницу, мы считываем страницу номеров телефонов — их там, естественно, несколько, но по факту мы читаем только номера телефонов. Полезная занимаемая память будет примерно 100%. А так как у всех номеров телефонов ещё и один и тот же тип и одинаковая длина, если мы в одной стране, это позволяет сделать ту самую структуру массивов — у данных есть определённый лейаут, и можно делать более эффективные штуки. Но самое главное, что убивает и почему они вообще работают: мы просто грузим намного меньше данных в аналитических запросах. Хочешь добавить?

[08:44] Максим: Про данные — хорошее замечание. Для широких таблиц количество полезных данных, которые мы читаем, сильно больше: мы читаем только то, что нужно. И это как раз сильно решает для широких таблиц. Лейаут аналитических таблиц обычно такой, что люди пытаются избегать джойнов и переделывают структуру: стараются, чтобы таблицы были не чисто транзакционные с нормальными формами, а наоборот — всю структуру денормализуют. За счёт этого у тебя широкая таблица, и ты читаешь из неё только то, что нужно.

[09:24] Александр: И тут стоит добавить, что за счёт того, что данные так лежат на диске — вот, например, номера телефонов, — они прекрасно сожмутся. Вообще говоря, все текущие аналитические базы данных сжимают данные, которые хранят. И сжатие плюс-минус похожих данных работает значительно лучше, чем когда ты пытаешься сжать весь профиль пользователя целиком, — там данные разной природы, и сжатие происходит хуже. То есть есть две важные составляющие: чтение данных — мы читаем на порядок меньше — и то, что мы данные сильно-сильно быстрее обрабатываем. За счёт этой комбинации и получается такая производительность.

[10:07] Александр: Да, стоит отметить: когда мы говорим про Postgres против ClickHouse, нужно понимать, что под каждую задачу та или иная база справляется лучше или хуже. Тот же пример с чтением одного пользователя по одному ID, чтобы в интернет-магазине показать профиль, в ClickHouse работал бы на порядок медленнее, чем в Postgres, — в среднем. Просто потому что данные расположены так, что добраться до каждого поля пользователя — это пойти в несколько файлов, взять смещение в каждом из них. У нас нет одной строки для пользователя, которую мы считали; данные про него разбросаны по N местам, и мы все эти N мест обходим и собираем. Количество прочитанных бесполезных, мусорных данных в этом конкретном случае будет сильно выше в аналитической базе, чем в транзакционной. Это просто два разных мира, два разных профиля нагрузки. И схема данных с точки зрения дизайна тоже в корне отличается. В транзакционных — это нормализации какого угодно уровня, чтобы сократить количество строк и не дублировать данные. Чем это хорошо? У нас более консистентные связи: не будет, например, чтобы у одной страны было несколько кодов из-за опечатки. Мы делаем специальную табличку кодов стран, и все на неё ссылаются по ID — так гарантируется целостность. А чревато это тем, что таблички нужно джойнить. И если ты делаешь аналитический запрос, ты джойнишь 5, 10, сотни таблиц — количество мест, из которых нужно собрать данные, очень велико, вычислительная сложность огромная. Такая схема хранения просто не подходит для серьёзного аналитического кейса. А в колоночных, как ты говорил, есть широкие таблицы — их ещё называют витринами или отчётами. Это одна большая таблица, где 500 колонок и вся информация про одного объекта, денормализованная. Там не нужно делать джойны, потому что нет связей: код страны будет лежать просто строкой, без ссылки на другую таблицу.

[13:35] Максим: В аналитических базах данных это не совсем однозначная вещь, потому что есть базы, которые просто плохо делают джойны. Например, в ClickHouse мы джойнами меньше занимались, у нас особо нет cost-based-оптимизатора и всего такого. И расклад больше такой, что мы просим пользователя подогнать свои данные под модель ClickHouse — и тогда всё будет летать. Но есть базы данных, которые немного другие. Например, Greenplum, Snowflake — они более гибкие: говорят, кидайте нам данные, может, даже примерно как ваши стандартные OLTP-таблицы, и мы вам будем как-то делать джойны, и перформанс всё равно будет лучше, чем если бы вы такую аналитику делали поверх обычных строковых таблиц. И вот тут в большинстве систем это всё сводится к HTAP: чтобы система могла и транзакции делать, и часть данных переливать в аналитические таблицы, поддерживать и то и другое. Из хороших примеров, которые я знаю, это, кажется, умеет Snowflake сейчас и TiDB.

[14:51] Александр: TiDB — да, мы часто на неё смотрим, когда разрабатываем какие-то фичи. По поводу Snowflake я, кстати, не уверен, что он хорошо справляется с OLTP-нагрузкой, что там транзакции быстро работают. С аналитикой-то он точно на 100%. HTAP или нет — может, они сейчас взяли такой курс, потому что раньше они точно были аналитической базой в облаке, cloud-native. Надо будет посмотреть.

[15:21] Максим: Да, они как раз взяли такой курс. У них, по-моему, где-то год назад появилась концепция single store. Ты им заливаешь данные — у них вообще политика, что заливать можно что хочешь. Хранят они их строково, но потихоньку переливают в колоночное хранилище. И в зависимости от того, какой запрос — а это понять более-менее просто — используется соответствующий бэкенд. Консистентность между ними какого-то уровня гарантируется, возможно, её можно ослабить. Потому что обычно во всех таких системах есть проблема: транзакционную нагрузку наливать на колоночное хранилище очень непросто за счёт того, что колоночные базы данных очень не любят делать UPDATE и DELETE. А в транзакционной нагрузке часто делаются апдейты и делиты, и тебе нужно придумать, как эту дельту применить к колоночному сторожу. Например, в ClickHouse традиционно данные можно удалять, но это будет просто перезапись; апдейты — тоже перезапись. Недавно появились инструменты вроде lightweight deletes, а в ClickHouse Cloud можно применять мутации на лету, но в целом сейчас традиционный подход такой: у тебя есть колоночная таблица, ты в неё только вставляешь. Можешь вставить строку, которую хочешь удалить, с каким-то флагом, — и потом, когда происходит фоновая операция merge, она старается мусорные данные удалять, дельты применять, и оно более-менее срастается.

[17:04] Александр: По сути, ты сейчас рассказал, как работает структура данных LSM Tree — Log-Structured Merge Tree. У меня про это есть целый выпуск подкаста, поэтому кому интересно — ссылочки оставлю; там всё подробно, и у меня даже на канале есть картинка, как LSM работает. Вот мы сейчас говорили про HTAP — Hybrid Transactional Analytical Processing, когда система позволяет и то и другое: с одной стороны транзакционная нагрузка, с другой аналитическая. Скорее всего, там внутри поддерживаются, по сути, две базы данных — два storage layer, где один RowStore для транзакционной нагрузки, по сути MySQL, а второй — какой-нибудь column-oriented storage, ClickHouse или RocksDB. И эта система такая умная и крутая, что скрывает это от пользователя, а пользователь просто пишет запросы. Ну, это в идеальном мире. Естественно, с консистентностью данных могут быть проблемы: если ты записал в RowStore, они моментально в column-store могут и не появиться. А если появятся, то у тебя будет медленная запись, потому что нужно синхронизировать два места, и это по-любому косты. Обычная транзакционная запись или обычная аналитическая балковая будет сильно быстрее. То есть нет серебряной пули, есть компромиссы. И когда мы говорим про ClickHouse, мы имеем в виду чисто аналитический workload — это семейство аналитических баз, на гибрид он не претендует.

[19:04] Максим: Ты всё правильно сказал, это не серебряная пуля. Единственное, с точки зрения не столько пользователя, сколько разработчика — часто людям приходится делать разные странные конструкции. Например, им нужна и транзакционная база, и данные отливать в аналитическую. Если это спрятано в удобном интерфейсе, кажется, это всё равно хорошо, даже без консистентности, потому что у них её и так не было. Зато очень много проблем с админов уходит: ты вставил строку в гибридную систему — и знаешь, что она вставилась и нигде не потеряется. А если люди что-то своё пытаются сооружать, они наступают на все те же грабли. Например, в колоночную базу обычно хочешь писать большими батчами. Либо колоночная база делает что-то вроде буферизации — ты в неё пишешь, она пытается буферизовать, но там тоже могут быть слабые гарантии, потому что если хочешь держать буфер консистентным, у тебя возникает задача консенсуса и все эти проблемы. И обычно колоночная база за такое браться не хочет — это уже совсем другая задача, отдельный кусок распределённой системы. Поэтому у пользователей есть вариант сооружать самому: писать какой-то распределённый буфер, который потом батчем заливать в аналитическое хранилище. И в этом тоже ничего хорошего, потому что там опять транзакции, буфер сразу удалил — идёшь по тем же граблям, и получается очень сложная конструкция. Ту нагрузку на вставку, которую держит ClickHouse, очень трудно повторить чем-то самодельным, распределённым и консистентным, с таким же перформансом. И обычно такие надстройки ещё серьёзно замедляют производительность, а если люди делают их сами, могут такого наворотить, что всё будет супер тормозить.

[21:19] Александр: Лучше пускай за рядовых программистов эту сложную логику реализуют матёрые разработчики баз данных, протестируют на своих клиентах и на комьюнити, и вы будете использовать уже отлаженный код, нежели свой криво-косо одним интеграционным тестом протестированный pipeline, в котором, скорее всего, багов больше, чем в реализованном в самой базе. Если есть что-то готовое, грех этим не воспользоваться.

[21:45] Максим: Кстати, есть половинчатые решения. Самый частый пример, когда эти HTAP-системы сверкают и сияют, — они говорят: смотрите, вы все, 90% бэкенд-проектов, в которых есть и транзакционная, и аналитическая нагрузка, делаете что? У вас есть транзакционная база — Oracle, Postgres, MySQL, — она общается с бэкендом, перед ней по-любому Redis, кэшик. И вот из MySQL какая-нибудь Java по крону захватывает данные, которые ещё не видела, и вставляет в аналитическую базу. Потом аналитики сидят на аналитической базе и фигачат свои запросы. Это стандартный подход, много где используется. У него есть преимущества: транзакционная база никак не страдает от тяжёлых аналитических запросов, а аналитики могут что угодно гонять на этой, по сути, второй реплике. А HTAP говорят: зачем вам делать эти селекты из MySQL раз в минуту, батчевать, отправлять в Kafka, из Kafka вычитывать, писать в ClickHouse, если есть одна система, которая делает это за вас изнутри? Ну, не поспоришь.

[23:15] Александр: Но если мы очень хотим использовать именно ClickHouse, потому что у нас такой аналитический workload, что не справляется ни одна HTAP, то можно посмотреть в сторону подхода Change Data Capture. По сути, этот source данных — поток событий, изменений строчек в OLTP-базе — можно начать слушать, и то, что слушаешь, записывать в аналитическую или отправлять в очередь. Иногда базы данных предоставляют механизм — например, у Postgres есть экстеншены, — когда ты не пишешь логику забора новых данных вручную, а она поступает тебе от базы как поток ивентов. Это уже решённая во многих базах задача, можно даже готовый Kafka-коннектор прикрутить, и оно само будет писать в Kafka. Такое половинчатое решение: если не хотите совсем HTAP, можете хотя бы слушать поток изменений и записывать их на другом конце куда угодно.

[24:22] Максим: Есть ещё подход, который мы в ClickHouse использовали, — правда, в продакшене, я не уверен, что много людей пошли этим путём. Это когда ClickHouse прикидывается репликой MySQL или Postgres. В каком-то смысле путь для настоящих профессионалов, которые ничего не боятся, но плюсы тоже есть, потому что с гарантиями получается получше. У нас есть MaterializedMySQL и MaterializedPostgreSQL в ClickHouse, но сколько людей используют это в продакшене, я сказать не могу.

[24:54] Александр: Прикольно, я не знал об этой фиче. Получается, я могу Postgres сказать: смотри, у тебя есть реплика, отправляй в неё. Это как бы твоя реплика, ты же Postgres, но по факту там поднят сервер ClickHouse, который по протоколу Postgres слушает и принимает, ведёт себя как Postgres, а на деле складывает в ClickHouse. Мне сейчас пришла идея, что можно на этом сделать целый продукт: мы теперь любую базу данных можем прикинуть многими другими. Вот вам транзакционная база — реплицируйте её в Mongo, в Snowflake, куда хотите, наш middle layer всё сделает за вас. Я просто сам не фанат, когда много всяких компонентов — и Kafka, и CDC…

[25:47] Максим: Ты что, архитекторы в Solutions Cloud — ты у них хлеб забираешь! Ты говоришь, что надо одну нормальную систему написать, и всё будет работать? Нет. Надо соединить кучу всего, сделать сложность невообразимую и восседать над этим как эксперт.

[26:06] Александр: Ну да, мы недавно как раз обсуждали, что микросервисы в большинстве компаний превращаются во что-то невозможное, и эту сложность, по сути, сами придумали — по факту она не нужна в большинстве случаев. Но это уже другой разговор, я тут с тобой полностью солидарен. Давай к ClickHouse перейдём. Мы уже широко поговорили, что такое аналитическая система, почему ClickHouse решает эту задачу лучше остальных — в частности потому, что узко заточен под это. ClickHouse не претендует на OLTP, не претендует на HTAP, не говорит «я супер-универсальный». Наоборот, он заставляет пользователя использовать некоторые рамки, устанавливает их: вот меня надо использовать так. Но взамен ты получишь топ-1, наверное, аналитический перформанс, который можно достичь на сегодня. Опять же, эти ограничения иногда из-за того, что просто руки не дошли, но иногда — потому что, задав ограничения, мы даём больше пространства для оптимизации. Поэтому каждый раз, когда говорят «супер-пупер гибридное, гибкое, умное, всё умеет, вам ни о чём думать не надо», — значит, об этом думает база данных, у неё меньше пространства для оптимизации, и перформанс, как правило, страдает. Нет совершенной системы, которая умеет всё и при этом простая. ClickHouse задаёт ограничения: лучше не используй JOIN, собери одну большую широкую таблицу, пиши в неё батчами — и будет у тебя отличный аналитический workload. Какие ещё особенности ClickHouse отметим, прежде чем вглубь погружаться?

[27:57] Максим: Чисто с философской точки зрения ClickHouse построен так, что мы хотим, чтобы запрос или сценарий работал так, чтобы если ты напишешь код руками, он не был бы быстрее. Чтобы он был примерно сопоставим по производительности. Это идея из Rust, которая называется Zero-Cost Abstraction: они предоставляют абстракции — вьюхи, итераторы, довольно сложные штуки, — но говорят, что вы не сможете написать быстрее, если сами на C или C++ будете писать. Вот это такая же Zero-Cost SQL Abstraction.

[28:41] Александр: Знаешь, этот Zero-Cost ещё в 80-х в C++ был, там примерно так же про это говорили. Абсолютно та же идея: у тебя есть стандартный вектор или стандартная хэш-таблица, и технически ты можешь написать хэш-таблицу быстрее стандартной — на GitHub куча проектов. Но если ты хочешь сделать хэш-таблицу, которая поддерживает всё то же самое, что стандартная, даёт все те же гарантии, то, скорее всего, у тебя получится хуже. Так более-менее для всех компонентов работает.

[29:15] Максим: В ClickHouse я это, скорее, сказал даже немного в шутку, но хочется сказать, что ClickHouse в каком-то плане уникальная система, потому что тот workload, который может держать ClickHouse, — на текущий момент я не знаю других систем, которые что-то похожее могут делать. Это такая battle-tested система. Battle-tested значит, что нагрузка Яндекс.Метрики, Cloudflare — очень большие проекты; TikTok работает на ClickHouse. Прям очень серьёзные, большие проекты работают на ClickHouse — понятное дело почему: потому что просто нет альтернатив. Тебе приходится загонять себя в некоторые рамки — например, аккуратно думать про то, какие у тебя типы данных будут. Часто люди используют для IP-адресов строку. В ClickHouse мы советуем: IPv4 использует 4 байта, IPv6 — 16 байт. И в целом стараемся давать возможность, чтобы ты реально мог что-то заоптимизировать. С точки зрения пользователя ты можешь очень много вещей накрутить. Есть стандартные типы данных, про них можно думать как про C-массивы: 32-битное что-то — представляй, что там 32-битный интеджер. Потом есть интересные типы данных, прям как с продакшена, battle-tested, — это агрегатная функция. В ClickHouse есть тип «агрегатная функция». Для чего он нужен? Ты вставил несколько строк, у них одинаковый первичный ключ. В ClickHouse первичный ключ по дефолту не уникальный. Есть куча специальных боевых движков со специальной логикой, которая с этим первичным ключом работает. Например, есть ReplacingMergeTree — он строки с одинаковым первичным ключом схлопывает в одну и отдаёт приоритет той, у которой максимальная версия — либо колонка с версией, либо последняя твоя вставка. Потом есть движок AggregatingMergeTree — он берёт состояния агрегатных функций и агрегирует их. Для состояния агрегатных функций в базе обычно нужны следующие вещи. Первое — пересчитать состояние при приходе новой строки. Например, у тебя count — пересчитать это просто добавить единичку. Или сумма — берёшь колонку, по которой считаешь, и прибавляешь значение из строки. Или average — там внутри состояние чуть сложнее: и counter, и сумма. И в ClickHouse это состояние может быть отдельным типом данных. Какие ещё методы вводят на состояние агрегатных функций? Во-первых, нужно уметь создавать состояние — обычно оно может быть проинициализировано нулями, если это просто число. Ну и удалять, разрушать. Для простых агрегатных функций этого достаточно, но функция может быть, например, хэш-таблицей или HyperLogLog, и тогда на её создание или удаление накладывается дополнительная логика: ты память какую-то аллоцировал или ещё что-нибудь. И есть важная операция merge — она даёт возможность взять два состояния агрегатных функций и склеить. Например, если мы counter считаем — один counter посчитали, второй, просто склеили. Если сумму — то же самое: складываем и сумму, и counter. Эта операция склейки нужна, когда мы делаем агрегацию во много потоков: сделали в 32 потока, получились хэш-таблицы с состояниями, а затем мы их все мержим. Примерно то же самое происходит, когда используется движок AggregatingMergeTree: в фоновой операции merge берётся несколько кусков данных, для строк с одинаковым первичным ключом состояния агрегатных функций мержатся. И в любой момент, когда ты читаешь из ClickHouse, ты можешь получить финальное состояние агрегатной функции. Для counter это просто отдать число, для суммы — отдать число, а для average — поделить сумму на counter. Получается красивая абстракция агрегатной функции, отданная пользователю.

[33:56] Александр: Прикольно. Давай я ещё для тех, кто немножко поплыл, приведу аналогию. Возможно, кто-то знаком с функциональным программированием — там очень часто есть операции вроде reduce или fold, такое «сколлектить» что-то. В Scala это reduce, в Java — collect; они почти везде есть, и суть в том, чтобы на какой-то коллекции или итераторе сказать, как эту коллекцию собрать в значение, сагрегировать. Эта функция принимает предыдущий полуагрегат и текущее значение и должна смержить полуагрегат со значением — добавить в агрегат ещё один элемент. И так для всего, и в итоге получается агрегат. Из Rust, например, у тебя итератор, который из сети возвращает значение: он из сокета дозаписывает, доагрегирует каждое значение. У тебя нет коллекции под ногами, всё real-time — ты каждый раз доагрегируешь, и агрегат меняется. А как такое в большинстве старых или, скажем, не таких затюненных баз данных делают? Это что-то очень похожее на view или materialized view. Ты строишь view, она агрегирует всё подряд, и когда в source-таблицу происходит добавление или удаление, эта view с агрегатами пересчитывается. Но пересчёт, скорее всего, тупой: взять и всё пересчитать заново. А то, что ты говоришь, — это очень fine-tuned штука на уровне сторожа, даже на уровне представления данных. Ты говорил, есть AggregatingMergeTree — вот давай про это раскроем. Ты сейчас говоришь про storage engine, про реализацию сторожа, или это что-то другое?

[36:06] Максим: Да, это как раз про реализацию сторожа. В ClickHouse стандартный движок таблиц — MergeTree, и MergeTree очень похож на LSM-деревья, просто нет некоторых деталей: например, с удалением немного по-другому сделано. По дефолту первичный ключ не уникальный: ты вставляешь данные, будут дубликаты с одинаковым первичным ключом. Но есть семейство MergeTree — для других движков этого семейства многие добавляют специальную логику, которая происходит, когда мы вставляем данные с одинаковыми первичными ключами или когда мержим уже вставленные куски данных, как в LSM-дереве. И применяем операцию над строками с одинаковым первичным ключом: это может быть Replacing, где мы удаляем дубликаты, или Aggregating, где для колонок типа aggregating function мы пересчитываем агрегат, делаем reduce. Есть ещё Summing — он для интеджерных колонок, их можно даже выбирать, он их просто суммирует, частный случай агрегатной функции sum. Таких движков много, и они очень часто выручают. Например, сделать дедупликацию при очень высокой нагрузке — вообще говоря, непростая задача: тебе нужно понять, есть ли в текущих данных дубликат для новой строки. А в ClickHouse это сделано отложенно: ты вставляешь новый кусочек — и всё, оно потом когда-нибудь смержится. Но есть ещё интересная штука — FINAL: когда ты читаешь из таблицы и указываешь FINAL, она все эти изменения применяет сразу, на уровне чтения данных. Раньше это работало очень медленно, но буквально в январе я сделал несколько интересных оптимизаций, и стало сильно лучше. Могу вкратце рассказать, как это устроено и в чём возникает проблема. В стандартном традиционном LSM-дереве никаких констрейнтов не накладывается: LSM-дерево — это по факту ты просто хранишь логи, какие-то изменения. Но на практике все стараются использовать индексы, чтобы оптимизировать чтение. Например, sparse-индексы, bloom-фильтры. Почему индекс sparse? Обычный индекс делает отображение от одного ключа к строке, один к одному. А sparse-индексы имеют более сложную форму. В ClickHouse они хранят первичный ключ так: у тебя миллион строк, и каждые 8192 строки (или, например, 32 тысячи) ты записываешь значение своего первичного ключа. После этого у тебя получается массив значений первичного ключа. И важно заметить, что твои данные отсортированы — в порядке первичного ключа. Для sorted string table это как раз ограничение. За счёт этого у тебя монотонно возрастающий первичный ключ, такая sparse-таблица первичного ключа, которая индексирует не одну строку, а промежуток, например, 8192 строки. В ClickHouse это называется гранулярность индекса первичного ключа.

[40:12] Максим: И возникает интересная задача — я не знаю, стандартная это решённая задача или нет, но я так быстро решения не нашёл. Представь: у тебя очень много данных, начиная от сотен терабайт. Тогда sparse-индекс становится весьма жирным, но всё равно всегда помещается в оперативную память — в ClickHouse так сделано, что мы всегда храним sparse-индекс в оперативке by default. Даже для сотен терабайт данных размер этого индекса обычно совсем небольшой, пару гигабайт. Максимум я видел около 10 гигабайт — для очень-очень большой таблицы. Представь, у тебя много таких кусков — в ClickHouse они называются part, а в литературе про LSM-дерево это sstable, sorted string table. И к каждому парту идёт такой индекс первичного ключа. Тебе нужно прочитать данные с FINAL — ты хочешь получить финальное сагрегированное значение, применённое ко всем строкам с одинаковым первичным ключом. Сложность этой операции очень сильно зависит от того, сколько у тебя в таблице дубликатов. Если дубликатов нет, то и FINAL применять не к чему. Но в ClickHouse до января была проблема, что мы эту оптимизацию всё равно применяли, и за счёт этого тратилась куча вычислительной мощности. Чтобы понять, нужно ли применять операцию, задачу можно свести к тому, что у тебя есть различные диапазоны первичного ключа из разных партов, и тебе нужно разбить их на пересекающиеся и не пересекающиеся по первичному ключу. Кажется, задача не суперсложная, но очевидный алгоритм с вменяемой сложностью я сходу не нашёл. Решается она тем, что у тебя есть значения левого и правого края, ты их сохраняешь, все отсортируешь и используешь алгоритм, который называется scanline, некоторую его вариацию. Так ты разбиваешь все рейнджи на пересекающиеся и не пересекающиеся, и FINAL-логику применяешь только к пересекающимся, потому что как раз там будут строки с одинаковыми первичными ключами.

[43:06] Александр: Стандартная, ванильная реализация LSM-дерева, какая-нибудь RocksDB, насколько я представляю, — честно говоря, не знаю, — не допускает дубликации первичных ключей.

[43:20] Максим: Наверное, всё зависит от того, как это конфигурить. Я думаю, она должна это допускать. Но можно заставить её не допускать. Это скорее уже деталь реализации, на мой взгляд.

[43:31] Александр: Надо копнуть, я не смотрел. Но кажется, что одно из отличий ванильной LSM от MergeTree, которая используется в ClickHouse, — это то, что она как раз довольно хорошо оптимизирована под работу с дубликацией первичных ключей. Верно?

[43:53] Максим: Да, вот она до января была не очень хорошо оптимизирована, но в январе удалось сделать несколько оптимизаций, и как раз в сценариях, где количество дубликатов не очень большое, — а у меня как раз в продакшене был такой сценарий — удалось всё сильно улучшить. Просто если количество дубликатов огромное — например, 99%, — то от этого склеивания ты никуда не денешься при запросе с FINAL, потому что тебе нужно получить финальное значение после применения логики ко всем строкам с одинаковым первичным ключом.

[44:35] Александр: Давай какой-нибудь real-world-кейс проговорим, чтобы было понятнее, потому что это может быть немножко сложно представить. Вот как я, например, это понимаю — я не аналитик, аналитические движки не разрабатываю, поэтому могу ошибаться. Известно, что ClickHouse как система вырос из Яндекс.Метрики, из потребностей этого сервиса. Соответственно, можно предположить, какие типы данных, какие нагрузки Яндекс.Метрика поставляет: клики по кнопкам, по ссылкам, события — пользователи в интернете на сайтах. И что там может быть тем самым первичным ключом, который часто дублируется? Наверное, какой-то ID пользователя или моей сессии взаимодействия с текущим сайтом. Я кликаю, не знаю, сто раз за присест — и вот эти сто кликов, возможно, будут с одним первичным ключом. Или я ошибаюсь?

[45:35] Максим: Да, всё правильно сказано. Вообще ClickHouse расшифровывается как Clickstream Data Warehouse. Clickstream — это был первый сценарий, под который ClickHouse разрабатывался: когда приходит много кликов пользователей. А Data Warehouse — это уже про хранение всех этих данных.

[45:55] Александр: Да, давай тогда дальше разовьём. У нас есть кликстрим. Я лажу по сайту, хочу выбрать новый микрофон, не могу определиться, тыкаю, и все мои клики идут в аналитику — если подключена, по-моему, Яндекс.Метрика. Насколько понимаю, это где-то в браузере агрегируется, потом отправляется, но точно не знаю. Суть в том, что всё это попадает в ClickHouse, и там есть первичный ключ — допустим, я Саша Пахомов с ID 3, и все мои клики за последний час обладают одним и тем же первичным ключом. Опять же, я фантазирую. И какой вид запроса с тем самым FINAL, который ты объяснял, можно применить к такой структуре? Что аналитик хочет считать, чтобы применить этот FINAL, и что в системе будет происходить?

[47:01] Максим: В этом случае FINAL скорее не нужен. Когда у тебя есть хиты, к хиту ты обычно хочешь ID сессии, сайт, какой был телефон, операционную систему, браузер — всё такое. И тут ты не хочешь делать FINAL, не хочешь дедупликацию, потому что хочешь иметь данные в недедуплицированном, несагрегированном виде — чтобы их по-разному агрегировать, разные вещи смотреть. А в каких случаях ты можешь хотеть дедупликацию или преагрегацию? Конкретно в Яндекс.Метрике такого нет, но технически можно представить: если поток данных нереально большой, а ты хочешь считать по нему заранее известные агрегаты. Не какой-то slice and dice, как говорят люди, а заранее определённые агрегаты. Например, ты хочешь считать сумму каких-то своих кликов или топ-5 уникальных сайтов и хранить именно в агрегированном виде. Потому что если хранить в неагрегированном, объём данных может быть такой, что ты вообще ничего с ним не сделаешь — не сгруппируешь, никакие запросы на таком объёме работать не будут.

[48:16] Александр: Я как пользователь в интернете могу генерировать этот поток просто заходом на новые ссылки. Каждый новый запрос по URL — это событие, оно отправляется в нашу воображаемую систему, за которой стоит ClickHouse, и аналитик хочет видеть мои топ-5 сайтов. Эти топ-5 могут меняться: топ-1 будет плюс-минус всегда один, топ-2, топ-3 тоже редко меняются, а топ-4, топ-5 могут скакать. По сути, то, что мы хотим отобразить, — это пять строчек. Но мой поток событий за три года посещения всех сайтов — это огромная информация даже для меня одного. А представь, что у нас весь интернет. Поэтому запускать на этот огромный объём каждый раз аналитический запрос GROUP BY топ-5 посещаемых сайтов — неоптимально и на каком-то масштабе физически перестанет работать. Про этот кейс мы говорим — что делаем так, чтобы этот запрос просто не гонять, с помощью как раз этих FINAL, насколько я понимаю?

[49:29] Максим: Скорее, сама суть FINAL — не гонять твои запросы. В ClickHouse для такого есть ещё несколько хороших механизмов. Но конкретно задача FINAL — чтобы у аналитика, когда он делает запрос, всегда была логика получить твои последние пять сайтов. Реально в базе ты будешь храниться в нескольких строках: в одной топ-5 за одно время, в другой за другое, в третьей за третье. Когда ты сделаешь запрос с FINAL, он возьмёт эти три строки и смержит в одну. Но они периодически и так будут мержиться. Просто FINAL — это возможность получить твоё финальное значение.

[50:11] Александр: Мне этот запрос понравился, он хорошо описывает и легко представляется — топ-5 сайтов. Получается, в описании таблицы будет как-то храниться информация, откуда база знает, что именно так кусочки надо хранить. Это же не raw data, это какие-то уже преагрегаты. Как об этом узнаёт база?

[50:36] Максим: У тебя будет схема такой интересной таблицы. Будет user ID — это твой первичный ключ. И значение — это будет aggregating function, такой тип. Внутри неё ты запишешь имя своей агрегатной функции. В ClickHouse она будет называться, по-моему, topK, и туда нужно указать константу 5.

[50:55] Александр: Получается интересная деталь, которую стоит проговорить: подобные штуки в традиционных транзакционных базах решаются в запросе к базе. Данные хранятся как хранятся, построчно или даже колоночно — не так важно. А тут мы саму таблицу определяем так, что одна из колонок — это функция. Мы делаем такую инверсию: храним уже результат запроса. И, по сути, когда делаем запрос, просто достаём по ключу. Конечно, при этом происходят некоторые модификации, про которые ты говоришь. Но да, мы делаем эти преагрегаты, агрегаты или полуагрегаты, храним их на диске, и поэтому оно быстро работает.

[51:50] Максим: Именно так. Такие задачи можно ещё иногда решать по-другому — например, с использованием материализованных представлений. У тебя есть основная таблица, в которой ты хранишь весь свой поток данных. Но запросы по ней делать дороговато — они не получаются real-time из-за объёма. А для каких-то специальных агрегатов ты делаешь отдельное материализованное представление: когда данные вставляются в основную таблицу, они идут и во все материализованные представления, и те пересчитываются весьма похожим образом. Это второй способ. Материализованные представления, наверное, ближе к большинству пользователей — они представляют их как некоторый пересчёт. По факту в ClickHouse мы дельту к основным данным применяем похожим механизмом. И есть ещё интересный вариант — проекции. Проекции изначально, насколько я знаю, появились в Vertica. Проекция отличается от материализованного представления тем, что материализованное представление в базе — это некоторая отдельная сущность: ты можешь из него SELECT делать, прятать за ним логику, давать пользователю права на него, а на основную таблицу не давать. А проекция в каком-то смысле это тоже материализованное представление, логика работы примерно такая же: ты вставляешь данные, и они также вставляются в проекцию. Но есть большая разница в том, как потом запросы к таблице работают. За счёт того, что проекция полностью консистентна с основной таблицей — данные лежат прямо рядом, в том же куске данных, — при выполнении запроса ClickHouse может понять, что на самом деле его можно прочитать из проекции: он не пойдёт в основную таблицу, а прочитает из проекции. И что может представлять собой проекция? Например, ты можешь посортировать данные в другом порядке первичного ключа, отличном от того, что задал при создании таблицы. Или сделать какой-то запрос — SELECT с GROUP BY, — и то, что получилось, будет храниться в intermediate stage. Самая главная фишка: когда ты делаешь запрос к основной таблице, ClickHouse может понять, что вот это можно прочитать из проекции. И основная фишка проекции в том, что она консистентна с основной таблицей.

[54:41] Александр: Чисто технически ничего не мешает делать то же с материализованными представлениями. Основная разница в том, что будут немного другие гарантии на консистентность?

[54:51] Максим: В целом, наверное, в будущем, когда у нас будет более крутой cost-based-оптимизатор, мы сможем и материализованное представление так же оптимизировать. Но пока такое работает только с проекциями.

[55:00] Александр: То есть материализованное представление — это, по сути, ещё одна таблица, потому что оно где-то хранится: результат какого-то SELECT-запроса к таблице или набору таблиц, сохранённый. Каждый раз, когда изменяется одна из таблиц, из которых составлен запрос, оно пересчитывается — можно как-то оптимизировать, чтобы не всё пересчитывать. И в чём проблема? В том, что низкоуровневое хранилище, LSM, представляет собой две разных структуры данных: одна таблица основная, другая — материализованная вьюха. Это две независимые на низком уровне штуки, а на более высоком уровне планер или оптимизатор понимает, что запрос к материализованной вьюхе идёт в одну таблицу или пересчитывает её. А проекции — это как раз соединение этих двух структур в одну: внутри структуры данных таблицы, прямо в той же странице в памяти, лежит ещё кусочек данных, относящийся к проекции. Какой-нибудь пример — какой запрос можно написать с использованием проекций, чтобы было легче представить слушателям?

[56:29] Максим: Всё как раз ты правильно указал. Единственное, что проекции на самом-самом низком уровне лежат в отдельных файлах, но с точки зрения логики — транзакционности и всего остального — являются единой частью куска таблицы. И это прям решающая вещь в ClickHouse, потому что у нас транзакционность более-менее основана на кусках. Если не вдаваться в детали, сейчас там уже есть какая-то экспериментальная поддержка транзакций. В целом всегда более-менее было так, что на уровне кусков у тебя есть какие-то гарантии, а на уровне всей таблицы гарантий более-менее нет. А какой запрос можно сделать? Ты хочешь всегда иметь свои топ-5 сайтов пересчитанными. Ты делаешь такую проекцию, но с точки зрения пользователя теперь всё по-другому работает: он делает запрос, читает свой user ID и топ-5 сайтов просто из таблицы, а сам движок понимает, что это можно прочитать из проекции, и прям из неё это и прочитает. Запрос будет нереально быстро работать, но с точки зрения пользователя он вводит свой запрос как обычно, ему не нужно FINAL применять. Единственное, если брать с точки зрения производительности, тут нужно всё хорошо мерить, потому что вариант, где у тебя прям пересчитывается состояние агрегатной функции, скорее всего, будет чуть более низкоуровневый и получше работать. Прям в ситуациях серьёзного high load. Плюс с FINAL пользователь хотя бы понимает, что эта вещь реально нагруженная. С проекцией пользователь может даже не знать, что она есть: делает запрос, думает, ну, работает как-то быстро. С проекциями это очень мощная фишка оптимайзера, но она может в каких-то случаях даже не сработать. Например, мы построили проекцию SELECT user ID, топ-5 сайтов. Внутри проекции физически есть только user ID и топ-5 сайтов. Но представь, я хочу ещё имя получить: пишу SELECT user ID, имя, топ-5 сайтов — и я уже не могу это прочитать из проекции, потому что там такой информации просто нет. И этот запрос вместо того, чтобы работать за десятки миллисекунд, пошёл читать сотни терабайт данных и будет считаться час.

[59:19] Александр: Даже имея проекцию и кусок, мы как люди понимаем, что можно было бы из проекции взять часть данных, а потом моё имя подсунуть, но оптимайзер не настолько умный, чтобы во всём разобраться. Он иногда может не понять, и мы вот так можем эту проекцию сломать. То есть я делаю запрос по ID топ-5 сайтов с ID 3, и такой: вау, это что у меня закешировалось, как так быстро? А ну-ка, что там за имя у этого ID? Ввожу имя — и сижу, жду сутки, пока он работает.

[59:53] Максим: Ну да, примерно так. На самом деле даже с точки зрения конкретной реализации, предположим, мы знаем, что каких-то колонок в проекции не хватает и хотим за ними сходить, — это может быть очень дорого. Даже если у тебя в основном куске есть user ID и тебе нужно сходить за именами — это может быть прям дорого, потому что сам кусок может быть очень большой. Кусок в ClickHouse сверху как-то ограничен, но куски по 100 гигабайт, по терабайту точно легко могут быть. И с проекциями возникает интересная задача, когда их у тебя много: из каждой ты можешь прочитать, но в зависимости от того, с какой ты решишь читать, запрос может быть более или менее эффективный. Стандартная задача оптимизации запроса.

[1:00:55] Александр: Прикольно про проекции, про агрегаты. Мне кажется, довольно хорошо мы поговорили и прогрели, что называется, головы слушателей, чтобы начать жестить. Потому что это вообще, ребят, ещё цветочки. Я даже это всё понимаю и, как видите, веду осмысленную дискуссию. Дальше я перестану понимать. Но прежде чем совсем — давай про транзакции ещё немножко затронем, потому что ты говорил про них вскользь. Ты упоминал куски — что в рамках некоторых кусков или партов может быть транзакционность: я там записал что-то, прочитываю и тут же увижу, и другие гарантии. А что в рамках всей таблицы, и вообще как ClickHouse определяет транзакционность? Какие гарантии есть, каких нет?

[1:01:54] Максим: На одну таблицу у нас, кажется, с экспериментальной поддержкой транзакций более-менее стандартные гарантии — все стандартные гарантии ACID. Если взять ReplicatedMergeTree, то есть MergeTree с репликацией, там гарантий вообще никаких нет, потому что у нас eventual consistency и очень-очень сложный протокол репликации, и там серьёзных гарантий давать нельзя. Я давно не погружался в эту тему, в каком она сейчас состоянии, — может, там уже более удачная поддержка транзакций есть, но на момент, когда я последний раз смотрел, всё было невероятно сложно, потому что как минимум eventual consistency модель, и на неё серьёзных гарантий, как от других баз данных ожидаешь, не сделать. Из интересного, в SharedMergeTree, который есть в ClickHouse Cloud, кажется, с гарантиями чуть получше, потому что в стандартном ReplicatedMergeTree каждый узел имеет своё видение того, что происходит, и они как-то друг с другом синхронизируются. А в SharedMergeTree состояние единое, всё хранится в ZooKeeper, и про это, наверное, проще думать, и гарантий получается больше. Но всё равно, чтобы давать какие-то гарантии, нужно писать Jepsen-тесты и только после этого их давать. Недавно вроде был Jepsen-тест для MySQL, и там показано, что многие гарантии они и не дают. Я вообще скорее сторонник того, что если даёшь гарантии — нужно сразу приходить с Jepsen-тестами, потому что это очень сложная тема.

[1:03:56] Александр: Давай раскроем Jepsen-тесты, я в подкасте про это никогда не говорил. Что такое вообще Jepsen-тесты, что он проверяет и как его написать?

[1:04:36] Максим: У Jepsen-тестов много интересных штук, что они умеют эмулировать. Они умеют замедлять сеть, вырубать какие-то узлы в кластере, перезагружать узлы, играться со временем, чтобы часы расходились. В общем, делают всякие подставы, которые обычно распределённые системы могут сломать. Jepsen-тесты — это не панацея, они в каком-то смысле вероятностные: пытаются много разных комбинаций перебирать, но это большой комбинаторный перебор конфигураций. Обычно перебор небольшой: например, проверку сериализуемости истории Jepsen умеет делать только для маленьких историй. На момент, когда я последний раз смотрел, там используется не очень эффективный алгоритм, поэтому сложную историю того, что происходит в твоей базе, в твоём сторидже, Jepsen проверять не может. Но чем Jepsen хороши: про них многие знают, и есть очень много готовых Jepsen-тестов. Если открыть репозиторий самих Jepsen-тестов, можно посмотреть, какие есть инструменты, как сетапить кластер: они позволяют разворачивать его в EC2 в AWS, или локально в Debian LXC-контейнерах. Хорошо, что для многих систем эти тесты уже есть. Предположим, ты делаешь key-value базу данных, например YDB. Ты знаешь, какие есть похожие базы — CockroachDB, YugabyteDB, — открываешь их папочки с тестами, находишь интерфейс, как они отобразили операции: для key-value понятно, что нужно уметь прочитать по ключу и записать по ключу. Подсовываешь туда YDB — и все эти Jepsen-тесты теперь можешь использовать. То же самое происходило с ZooKeeper, когда мы тестировали ClickHouse Keeper — реализацию ZooKeeper с использованием Raft, библиотеки NuRaft. У тебя уже есть Jepsen-тесты ZooKeeper, ты не пишешь свои с нуля. Для других верифицирующих систем всё не так общепринято, поэтому их использование намного сложнее. Единственный серьёзный минус Jepsen-тестов — что они написаны на языке…

[1:07:36] Александр: Кто-то скажет, что это плюс.

[1:07:40] Максим: …который в каком-то смысле Lisp плюс Java.

[1:07:43] Александр: Ни то ни другое не хотелось бы трогать.

[1:07:48] Максим: Для меня это небольшой минус, потому что некоторые конструкции, которые написаны, — знаешь, во всех функциональных языках, например в Haskell, ты можешь в три строки написать что-то настолько сложное, что человек потом будет читать полгода. В Lisp-подобных языках тоже есть такая вещь: нужно много времени, чтобы разобраться, как этот язык работает. Он в каком-то смысле ломает голову, потому что не такой стандартный процедурный. Но это хороший фреймворк — если вы пишете key-value-хранилище или какую-то другую систему, его must-have использовать. Вторая, более продвинутая вещь — использовать различные формальные верификаторы, например TLA+. Но конкретно с этим у меня опыта не было. Наверное, это скорее если вы суперсерьёзный разработчик распределённых систем и хотите иметь точные гарантии, что ваш протокол репликации работает. Потому что в Jepsen всё, что вы получите, — это комбинаторный перебор, а в случае TLA+ вы получите полноценный результат того, что ваш алгоритм корректен. TLA+ можно сравнить с математическим доказательством модели: мы описываем нашу модель, допустим, распределённых транзакций, в терминах, которые понимает верификатор, даём ему, и он говорит «да, это работает», теорема доказана.

[1:09:29] Александр: А Jepsen — это больше как property-based fuzzing-тестинг, где property — это то, что то или иное условие транзакционности соблюдается, например сериализуемость транзакций, а fuzzing — это перебор всего возможного, что может произойти и потенциально сломать эту гарантию. Всё это более доказано на практике. Ну, не доказано, а на практике не получилось найти случая, который ломает. Но не факт, что его нет.

[1:09:59] Максим: Да, и нужны оба. Ты правильно рассказал про TLA+, но, к сожалению, это тоже не серебряная пуля. Даже если ваш алгоритм в TLA+ валиден — ну, Raft, доказано, что он валидный, — но люди, когда реализуют Raft, стараются его непоправимо улучшить, добавить свои оптимизации: «да зачем тут этот if, он не нужен». И вот человеческий фактор при реализации: у вас может быть баг, вы используете не те часы, немонотонные таймстемпы взяли, системные какие-нибудь. Можно миллион прикладных багов найти, и вот их как раз нужно ловить фаззерами. Очень круто, когда доказуемо, что алгоритм правильный, но все алгоритмы сортировки доказуемо правильные, а в реальных программах миллион багов, потому что люди ошибаются. Вот был классный доклад на C++ Russia в 2023 году — я там был экспертом, — доклад назывался примерно «Как правильно написать компаратор». Я реально кучу новых штук для себя узнал. В компараторах в C++ нужен strict weak ordering — некоторое свойство, которое более-менее удовлетворяет правилу треугольника, если по-простому. И автор показал кучу open-source-проектов — GNU-шные, серьёзные, которые писали супербородатые дядьки, — где куча багов. Он написал для GCC свой верификатор: ты передал в сортировку какой-то компаратор, и он этот компаратор для элементов со всех сторон поворочает. Есть алгоритмы, которые могут это эффективно проверить, но в целом можно и медленно всё проверить. И в кейсах, которые он показывал, это находило много багов. А какие там могут быть баги? Например, когда ты сравниваешь float — там наны и всё такое интересное. И вот именно про это: вроде бы с точки зрения формального все понимают, как это работает, но когда речь про прикладные детали — там один-два бага. Самое обидное, что баги с сортировкой не всегда находятся: представь, ты неправильный компаратор для float написал, но у тебя вроде бы никогда не приходит нан. А когда-нибудь в продакшене такое произошло — и сортировка разъехалась. А когда разъезжается сортировка, например, первичного ключа в ClickHouse, все алгоритмы, которые думают, что она работает правильно, уже невалидны: краши, потери данных — а у тебя просто где-то компаратор неправильно написан.

[1:12:19] Александр: Отличный пример. И ещё один есть — хорошо, что вспомнил. В самой банальной сортировке… даже не сортировке, а бинарном поиске, где мы вычисляем левую и правую границы, — в первой реализации, даже, по-моему, в литературе, была бага. Там было переполнение: чтобы вычислить middle, с которого начинать, бралась левая граница, к ней прибавлялась правая, делилось на два. И из-за этого плюса для некоторых входных данных было переполнение, middle вычислялся неправильно. Пофиксили уже потом. Если вы сейчас будете писать и на собеседовании спросят, что не так, — нужно из правого вычесть левый, поделить на два и прибавить к левому эту половинку, тогда переполнения не будет.

[1:14:35] Максим: Да, с такими алгоритмами очень много нюансов. С тем же бинарным поиском интересные ситуации, когда тебе нужно найти leftmost или rightmost при дубликатах, leftmost greater, leftmost less и так далее. Ты можешь написать бинарный поиск всеми этими комбинациями, всё зависит только от того, как ты получил middle, как сравнил и что двинул. Это суперсложный алгоритм на самом деле.

[1:15:13] Александр: Ты прям по живому. Я буквально четыре дня назад — сейчас потихоньку пишу свой проект по автоматизации решения задач на LeetCode, это сейчас суперпопулярно, народ осознал, что алгоритмы — это база, и их надо всё время освежать. Я под пивком на Rust не смог написать бинарный поиск. Реально не смог. Думаю, да ну её нафиг, забросил. Ложусь и думаю: блин, что же я за программист такой, что бинарный поиск не смог. А там реально каждая деталечка решает: где-то один if не в ту сторону сравнил — и всё, не работает. Написал, конечно, но уже с утра на следующий день. И понял, что даже такая банальная штука ни фига не банальная, и нужно писать тест и быть очень аккуратным.

[1:15:55] Максим: Если немного перешли к теме алгоритмов, то часто ещё бывает сложно понять, какой алгоритм в целом использовать. Есть стандартные алгоритмы, которые вроде бы стоит использовать: массив чисел — позовёшь обычную сортировку. Но для чисел есть, например, специальный radix sort, мощная специализация — она отсортирует раз в 10 быстрее. Но, предположим, ты решил не писать radix sort, а взять из интернета: быстро скопировал — и у тебя получается, что стандартная сортировка почему-то быстрее. С алгоритмами мне что не нравится: если ты хочешь потестировать какой-то хитрый алгоритм, который по-быстрому не напишешь, нужна определённая концентрация — посидеть пару часов, подумать, как оптимизировать, лишний раз память не копировать. А если возьмёшь алгоритм из интернета, может оказаться, что он реализован неэффективно: сам алгоритм крутой, но если посидеть низкоуровнево, пооптимизировать нюансы, он будет летать. Но чтобы такое сделать, часто нужно реально разобраться в алгоритме.

[1:17:29] Александр: Да, разобраться — супер важно, в алгоритмах оно играет ключевую роль. У меня ни разу не было, чтобы я взял реализацию какого-нибудь хипа — и оно заработало. Начинаешь разбираться, понимаешь, что автор либо ошибся, либо у него кейс был суперограниченный, а у тебя чуть пошире. Даже в популярном репозитории Algorithms in Java, где 50 с чем-то тысяч звёздочек на GitHub, первый же алгоритм, который я взял, не работал сам по себе — не то что моя модификация, просто падал. Я такой: ну ладно, сам напишу. Это больше как вдохновиться, прочитать, понять, как работает. Но писать такие вещи, мне кажется, надо самому. И тем более здесь пока что не стоит использовать генеративные штуки, которые подставляют код, потому что ошибку допустить прям совсем просто. Идея ещё такая: насколько бы ты ни был классным специалистом, ты можешь устать, быть не в настроении, отвлечься и допустить ошибку. Все допускают ошибки. И тут можно перейти к тому, как обезопасить себя, как обложить себя как профессионала практиками, инструментами, подходами, чтобы спокойно спать: даже если допустил ошибку, узнаешь о ней сразу или максимально быстро. И как масштабировать эти практики на уровень проекта. Давай на примере ClickHouse про это поговорим.

[1:19:16] Максим: На примере ClickHouse у нас всё построено вокруг CI и CD. Вокруг CI мы строим все свои практики: написания нормального, высокопроизводительного кода. Что у нас есть в CI? Юнит-тесты, функциональные тесты, которые тестируют просто SQL-запрос. Есть интеграционные тесты, stateful-тесты — это функциональные тесты, но с реальными, сфокусированными данными Яндекс.Метрики. Есть фаззеры. И всё это запускается под всеми санитайзерами: под address-санитайзером, thread-санитайзером, undefined-behavior-санитайзером, memory-санитайзером, в релизе и в дебаге. Прям огромный пайплайн получается. Из этого самое важное для перформанса — перформанс-тесты, они запускаются под x86 и под ARM. Это более-менее весь наш пайплайн. И вокруг него мы стараемся действовать так, что, например, в ClickHouse нет культуры юнит-тестов. Мы пишем юнит-тесты только на базовые компоненты: на массив, на хэш-таблицу, на таблицу. Но мы не пишем юнит-тест, например, на сторидж — такие юнит-тесты, как иногда делают с моками.

[1:20:44] Александр: Подожди-подожди. Что? В ClickHouse не пишут юнит-тесты? Как эта система вообще работает? Ты о чём говоришь?

[1:20:52] Максим: Основная фишка в том, что мы делаем stateless-тесты. И stateless-тесты получаются сильно круче юнит-тестов, потому что тестируют именно интерфейс — как, например, ты вставил строку, и всё. За счёт этого stateless-тесты никогда не надо переписывать. У нас их уже больше двух с половиной тысяч, и они всё прекрасно тестируют. Основная проблема с юнит-тестами в базах данных бывает, когда тестируешь не кор-компоненты. На кор-компоненты — алгоритмы сортировки, структуры данных — у нас куча юнит-тестов. Но на более сложные компоненты, которые друг с другом взаимодействуют, мы в основном стараемся обходиться stateless-тестами и добавлять интроспекцию. Интроспекция — это когда ты в тесте закладываешься, что прочитаешь одну строку: первичный ключ работает, я ожидаю, что прочитаю одну строку, одну засечку с диска — в ClickHouse это обычно на засечках построено. И за счёт мощной интроспекции — мы стараемся добавлять её для всего: сколько было открыто файлов, сколько передано байт по сети, сколько прочитано байт с диска, сколько строк, сколько под запрос аллоцировано памяти — на всё это мы пишем кучу stateless-тестов, и это хорошо работает, потому что их никогда не надо переписывать. А юнит-тесты на некоторые компоненты — как только чуть-чуть интерфейс поменялся, тебе нужно кучу юнит-тестов переделывать, потому что там моки поехали.

[1:22:44] Александр: Удивительные вещи сейчас у меня в голове происходят. Знаешь, такое приятное чувство, когда все твои мысли и то, как ты видишь программирование, разработку, тестирование, написание тестов, находят отражение. Это мой личный опыт — то, как я делаю, как хотел бы делать в своём проекте. И нигде я это, кстати, не читал: в литературе такого редко пишут. Обычно как раз пропагандируется культура юнит-тестирования, максимально test-driven development — какие-то идеологические штуки, нежели практически применимые, дающие реальный результат. Я, например, почти никогда не пишу… ладно, вообще не использую моки. Я их отрицаю как сущность в мире. Моков не существует. Если ты взял мок и используешь его, значит, что-то не так, подумай ещё раз. Есть контраргументы, что иногда никак, и они все валидные, но я как-то не использую, и вроде всё нормально. А какие-то кор-штуки, которые я пишу, — очередь в своей реализации, хешмапу — будут максимально покрыты 100% всех возможных юнит-тест-кейсов, потому что у неё жёсткий контракт, жёсткие требования, и поверх неё очень много строится. Это монолитный блок, я всегда его покрою юнит-тестами. Но если я пишу сервис, логику, асинхронный процесс, который что-то подчищает, — во-первых, на него в целом можно ли написать адекватные юнит-тесты? Это сложная задача: слишком много всего нужно засетапить, подготовка фикстур будет огромная. И два: какой смысл это так жёстко тестировать, если у компонента есть некоторые инварианты или ожидаемое поведение снаружи? Как ты сказал: я читаю по первичному ключу, ожидаю одно чтение с диска. Вот именно это я и хочу знать, больше ничего тестировать не хочу. Как оно там внутри — мне не интересно, и это то, что задаёт каркас системы и позволяет ей эволюционировать, а не тормозит. Это житейская мудрость, практическая прагматичность, к которой я пришёл только личным опытом, и нигде про это, правда, — только в книжке «Программист-прагматик» чуть-чуть эти идеи видел. Мне очень приятно, что всё, что ты говоришь, — это именно то, как я воспринимаю программирование. Плюсую на 100%. Некоторые называют это интеграционными тестами, некоторые функциональными — зависит от проекта. Это высокоуровневые тесты, выше юнит-теста, которые сетапят реальную систему, фигачат реальные данные и проверяют, но это уже тяжёлые. А вы, ты говоришь, сделали ещё более лёгкие тесты, stateless, — значит, в них нет большого количества данных, они проверяют одну маленькую штучку и быстро работают?

[1:26:37] Максим: Ну, обычно такой тест, например, создаёт несколько таблиц, может какие-то данные в них вставить, что-то поделать, но в целом он более-менее небольшой. И мы такие тесты ещё запускаем в параллель. Когда запускается тест, для него создаётся своя тестовая база данных: если он создаёт таблицы, они лежат в этом namespace, и мы ожидаем, что параллельно тесты работают — у нас есть некоторые, которые параллельно не работают, но большинство параллельно, и мы ожидаем, что всё будет хорошо. А конкретные баги в реализации мы ловим через санитайзеры. И весь наш корпус stateless-тестов мы используем для фаззинга. У нас есть свой фаззер, написанный специально под SQL ClickHouse. Мы скармливаем ему все функциональные тесты, и он начинает создавать всякие запросы: создавать кучу разных таблиц с разными типами, делать похожие запросы, странные запросы, где, знаешь, есть такое значение NULL, оно весьма странное — как оно со всем этим работает. Ещё есть штука SQL Logic — она проверяет логику SQL, но так, что в SQL есть эквивалентные преобразования: ты берёшь запрос, преобразовал его, и знаешь, что он должен дать одинаковый результат. Она особо про твой язык и функции ничего не знает, просто старается этой логикой найти баг. И на этом более-менее мы все баги и находим. Бывают неприятные ситуации, когда баг просачивается, но обычно это что-то очень-очень хитрое. Единственное, что с такими вещами тяжело тестировать resource usage, но мы сейчас в эту сторону тоже работаем. Часто говорят, что в языках типа Java не бывает memory leak’ов, — на самом деле они бывают, или, например, resource leak: ты файл открыл, не закрыл. И как ещё бывает: в ClickHouse недавно, в четверг, был такой баг — он уже давно в ClickHouse, — странный memory leak, который почему-то воспроизводится только у каких-то пользователей, и непонятно, что с ним происходит. У человека, например, инстанс на 32 гигабайта, и у него 30 гигабайт почему-то потихоньку растёт, растёт, съедает. Но в ClickHouse memory leak’ов нет: все наши санитайзеры, которые проверяют memory leak’и, обычно проверяют, слилась ли у тебя какая-то память в конце. Что значит «слилась память»? Стандартное определение memory leak из книжки: ты аллоцировал указатель, у него есть размер, внутри указателя ты можешь делать арифметику в рамках размера этого массива. И если на этот кусок памяти ты больше никак не можешь добраться — указатель слился, и нигде в программе ты не можешь получить значение указателя на эту область памяти. Ну, если ты получишь на середину, то технически можешь от неё сдвинуться и всё-таки почистить. Поэтому традиционный memory leak из книжки — это когда у тебя просто нет указателя на эту память. В конце программы это проверяется, и всё. Но это очень оптимистичный memory leak. Memory leak бывает, например, когда у тебя есть глобальная структура данных логеров: ты берёшь логер, он где-то сохраняется в хэш-таблице и не чистится. И постепенно — в ClickHouse как раз был такой баг. И чтобы найти, что баг именно с логерами, удалось, просто прислал пользователь сценарий, где он создаёт и удаляет очень много таблиц — примерно 60 тысяч таблиц в день. Видимо, такой интересный сценарий, зачем-то ему это нужно. И удалось сделать штуку, которая называется memory dumper. Обычно в Java-проектах это более-менее тула из коробки, а в C++ её нужно отдельно делать: отдельно в своей аллокации трекать. Я застапил этот workload созданием-удалением таблиц и видел, что память потихоньку растёт. Закрываю приложение — понятно, memory leak’а нет. Значит, память где-то внутри приложения: ты в какую-то глобальную структуру данных что-то сохраняешь, но забываешь оттуда удалить. Стандартная вещь. Я прошёлся по коду, по всем трейсам — ничего не понятно. Но решил попробовать: напишу-ка memory dump collector и периодически, раз в 15 секунд, буду сбрасывать все текущие активные аллокации, которые произошли в ClickHouse. Из них беру стектрейсы и группирую. Предположение: раз у меня memory leak, значит, какие-то аллокации не пропадают, они всегда активны, и количество этих аллокаций со временем будет только расти. Запустил скрипт на тысячу таблиц, сгруппировал по аллокациям и смотрю: в основном аллокаций один-две штуки. А для каких-то — 64 тысячи. Понимаю: всё, это точно leak. Потому что я запустил скрипт в 64 потока, и каждый поток сделал по тысяче таблиц — создал и дропнул. Взял эти стектрейсы, развернул — и смотрю, там мы просто создаём логер. И что интересно: когда мы создаём логер в ClickHouse — обычно мы используем в логере константную строку, например application, и такой логер будет один. Но для таблиц мы используем логер с именем таблицы. Технически по-правильному имя таблицы нужно было просто к логу прицеплять, а логер должен быть один, как MergeTree. А мы использовали неконстантную строку — и всё: таблица создаётся, мы получаем этот логер, а логеры нигде не удаляли. Вот и весь тривиальный memory leak. Но найти его традиционными инструментами очень сложно — пришлось написать workload, сделать memory dumper, запустить и ждать. И мы к такому тоже идём: хочется сделать это на все ресурсы — смотреть, что количество открытых файлов не растёт бесконечно, что сокет-соединения не текут. Это можно делать прям внутри приложения или написать сбоку тестирующий скрипт, который будет доставать это из proc-файловой системы. Вообще memory leak очень легко находить из proc-файловой системы: смотришь текущий RSS usage, запустил workload — и он только растёт.

[1:34:42] Александр: А поясни, что такое RSS?

[1:34:43] Максим: Это те страницы, которые физически отданы твоему процессу. Не виртуальные, а прям физические страницы, которые процесс сейчас использует.

[1:34:51] Александр: Ох, сейчас надо… Короче, есть концепция виртуальной памяти. Она подразумевает, что процессу выделяется какая-то память, но она не физическая, а виртуальная. Процесс может думать, что у него 16 гигабайт памяти, а по факту ему выделено 2 гигабайта оперативки. И если он будет запрашивать дальше, то тут вопрос: если реально в системе есть память, то ему дастся оперативка, а если нет — он может свап с диска читать страницы. Свап так в целом и работает. И ты говоришь, что RSS показывает, сколько реальных страниц использует процесс, прям сколько памяти занимает?

[1:35:33] Максим: Да, я как раз сейчас посмотрел — называется resident set size. Сколько у тебя сейчас в процессе реально физических страниц замаплено.

[1:35:43] Александр: И это можно посмотреть, просто грепнув файлик в операционной системе, верно?

[1:35:49] Максим: Да, можно сделать именно так. Единственное, тут нужно быть осторожным, потому что я прямо во внутренности аллокатора не залезал, но кажется, вполне возможно, что аллокатор не отдаёт страницы обратно операционной системе иногда: он аллоцировал 3 гигабайта, потом ещё что-то аллоцировал. То есть RSS — это условно хороший сигнал, что что-то не так, но самый лучший сценарий — когда прям внутри приложения можно посмотреть, что ты активно создавал, и видишь, что с течением времени какой-то ресурс — количество файлов, память, аллокация — растёт.

[1:36:29] Александр: Да, это классная тема, разговор о том, что система должна быть интроспектируемой: мы должны понимать, что происходит извне. Не просто запустил, и оно работает как-то, а понимать — как ты говорил — сколько активных файлов, те же RSS и куча всего реального, что использует система, и как она взаимодействует с хардвером и операционной системой. Когда мы видим проблемы — у клиентов какие-то траблы или у нас локально — все эти метрики или трейсы дают нам приборы. Мы как эксперты сидим и смотрим на приборы и по ним понимаем, что происходит. Если будем просто как шаманы пробовать — «это потому что был последний мерж, оно теперь так себя ведёт на новой операционной системе» — это чистое колдовство. А если мы говорим про реальный инжиниринг и что-то прагматичное, нам нужны все эти приборы. И в ClickHouse, насколько я знаю, это очень классно сделано. Давай про это чуть поговорим. Какие есть инструменты понимания и отслеживания того, что происходит с системой и с запросом?

[1:37:53] Максим: В ClickHouse мы собираем более-менее всё, что можно собрать в юзерспейсе. Понятное дело, собираем, сколько байт прочитали с диска — на уровне application level всё трекаем: сколько файлов открыли и всё такое. Но и из системы много чего читаем — как раз proc-файловую систему. Есть ещё интересный NetLink-интерфейс, который тоже может читать эти данные. Мы стараемся всё это использовать: информацию шедулера тоже доставать, всё, что есть. Потом — операционные метрики самой системы, всё, что можно в юзерспейсе собрать. Затем у нас есть application level метрики. Они есть просто системные, называются с Metrics — синхронные метрики: ты в любой момент можешь прийти, попросить их и узнать, сколько сейчас памяти аллоцировано в jemalloc, сколько чего открыто — текущий момент. Но такое ты ещё можешь смотреть на каждый запрос. На каждый запрос тоже можно смотреть эти метрики: сделал запрос, и в специальной системной таблице можно посмотреть, сколько во время запроса ты реально прочитал данных. Это также работает для распределённых запросов: делаешь распределённый запрос по этой системной таблице для всех узлов в кластере. Можешь посмотреть, сколько данных прочитал с S3 — что очень актуально, — сколько из кэша прочитал. На уровне application у нас очень много всего есть. Потом есть очень крутая фича, которая мне лично нравится: мы можем на уровень клиента присылать логи. Клиент может попросить поднять уровень логирования, и ClickHouse будет присылать ему логи с другим уровнем. По дефолту уровни логирования: trace, information, warning, error, debug. Обычно люди ставят warning или error на весь сервер, чтобы много логов не было. Но если что-то идёт не так в каком-то запросе, хочется посмотреть, почему он замедлился, — можно прям для запроса поднять уровень логирования, и логи, что самое крутое, сервер не будет писать внутри себя на диск, а прям тебе пришлёт, в клиент. Это очень удобно, часто выручало: тормозит какой-то запрос — делаешь его же с повышенным уровнем логирования и видишь, что внутри что-то работает, потом хоп — завис, и сразу следующий лог. По этому следующему логу можно понять, что произошло. Потом можно строить flame graph прямо внутри ClickHouse. Вообще этот perf-интерфейс можно из своего приложения использовать — там используется, по-моему, системный вызов perf_event_open. Туда можно указать, что хочешь собирать. В ClickHouse можно прям perf-метрики собирать для твоего запроса: сколько было попаданий в кэш процессора, сколько вообще было тактов. Можно сделать ещё сэмплирующий профайлер прям изнутри ClickHouse. Он работает интересно: периодически, если ты его включил, для потоков твоего запроса разошлёт им сигнал. Сигнал прилетит, и пока он работает, мы аккуратно собираем stack trace и продолжаем работу. По этому сэмплирующему профилировщику можно построить flame graph, понять, как у тебя CPU использовалась в запросе. И такой же сэмплирующий профилировщик у нас есть на память. По дефолту он отключён — аллокации мы, кажется, не логируем. Но если тебе интересно посмотреть, какие аллокации происходили, — например, ты сделал запрос, и тебе интересно, остались ли какие-то аллокации после него, — врубаешь этот профилировщик, и он будет сэмплировать все аллокации и деаллокации. Когда сэмплируется аллокация — там записан размер, когда деаллокация — отрицательный размер. Сгруппируешь — и найдёшь текущие активные аллокации, которые остались после запроса. Это очень интересная штука, потому что не обязательно выставлять её в единицу — в единицу запрос может чудовищно затормозиться. Но если даже сделаешь для 5% аллокаций, за счёт сэмплирования и распределений всё будет более-менее хорошо работать. Это можно в каких-то случаях даже включать в продакшене: поставить, чтобы 5% аллокаций и запросов сэмплировалось.

[1:43:38] Александр: Столько всего хочется раскрыть, потому что то, что ты говоришь, звучит для меня как для разработчика Java супер круто. Я как раз ценил JVM как платформу — и сейчас, наверное, тоже, — тем, что под ней очень много тулов написано: попрофилировать Java-приложение — берёшь агента, устанавливаешь, подключаешься, и вот оно, то же сэмплирование. Платформа за тебя всё это реализует. То же самое с flame graph — можно в приложении сделать рекординг и хипдампы. А ты всё это говоришь в контексте ClickHouse, и это было написано инженерами специально для этой системы с нуля. Ничего готового не использовалось — берёшь и пишешь сэмплер.

[1:44:26] Максим: Именно так. Все эти штуки написаны с нуля, никакие готовые библиотеки мы для такого не используем. Не уверен, честно говоря, что они качественные есть. Наверное, если было бы что-то качественное, мы бы заиспользовали, потому что в ClickHouse нам всегда не проще подключить какой-нибудь контриб, внешнюю библиотеку — у нас их больше сотни, сильно больше сотни, может, уже больше двухсот. У нас есть стандартная библиотека C++, мы её с собой таскаем, немного модифицировали, есть LLVM для JIT-компиляции, куча support-вещей для интеграций, разные небольшие библиотеки, которые иногда используем для алгоритмов, — например, Roaring Bitmaps. Мы не боимся использовать библиотеки, без проблем затаскиваем — просто если что-то качественное.

[1:45:18] Александр: И про flame graph — это можно сделать per query? Я как человек, который запускает запросы, могу посмотреть, что конкретно мой запрос делает?

[1:45:27] Максим: Да, именно для твоего текущего запроса. И всё работает в распределённой среде: можно построить flame graph для всех узлов в кластере. Ты делаешь запрос, у каждого запроса есть идентификатор — Initial Query ID. Наш распределённый запрос работает так, что на инициаторе есть первичный распределённый запрос, и он кусками, немного модифицированный, вылетает на другие узлы в кластере. У них тоже есть Query ID, но есть и первичный Query ID. И можно потом в логах по первичному Query ID всё, что сэмплировалось, все эти stack trace, стянуть на инициатор и построить график.

[1:46:19] Александр: Офигеть. Я, правда, не так много использовал продакшн-баз данных и не исправлял перформанс-штуки — разве что с Oracle, Postgres и ещё с какими-то экзотическими штуками, — и никто мне не предлагал построить flame graph. Это какой-то нонсенс, что я сейчас слышу. Есть ли ещё системы, которые позволяют пользователю по запросу построить flame graph?

[1:46:50] Максим: Я лично про такие не слышал. Кажется, в таких системах часто flame graph тебе может быть — если запрос реально нагружен, — если ты просто возьмёшь perf, запустишь на этих машинах, всё скачаешь и сагрегируешь, у тебя будет примерно то же самое, потому что запрос очень нагруженный. А в ClickHouse преимущество в том, что ты можешь гранулярно для этого запроса посмотреть, что происходит, и часто это бывает очень полезно. Не только на flame graph нужно смотреть — если что-то тормозит, я обычно смотрю и в perf record, и в flame graph, и во все метрики, чтобы понять, что происходит. Часто даже этого не всегда достаточно. Иногда приходится — у нас есть интересная штука для диагностики, она мне часто помогала. Внутри ClickHouse, когда ты выполняешь запрос, он сначала попадает в AST, мы это AST переводим в Query Tree — это уже новая архитектура с аналайзером, который я делал. Просто чтобы мы не сразу из AST в query-план, а сначала AST, потом Query Tree, где обогащается семантическая информация, делаются различные оптимизации, потом есть отдельный этап планировщика, который строит логический план, потом из логического плана мы строим физический план, и физический план выполняет штука, которая называется Executor. Сам физический план в ClickHouse выглядит как граф. Выполнение — это не новая техника, в распределённых вычислениях это частая техника, когда сами вычисления отделены от параллелизма, от конкуренции. У нас есть граф вычислений, а мы сами выбираем, как этот граф отобразить на вычислительные ресурсы.

[1:48:52] Александр: Смотри, давай тот пример, у меня в голове крутится. Граф вычислений, по сути, это физический план, верно, в терминах?

[1:49:00] Максим: Да-да-да.

[1:49:01] Александр: У меня был выпуск подкаста про query planning и оптимизатор, можно его послушать — ссылочки прикреплю, повторяться не буду. Суть в том, что это дерево сверху вниз условного выполнения, и внизу листья дерева — это «сделай фуллскан вот этого диапазона ключей из этой таблицы», а здесь «по ключу возьми». Это физический план в плане того, что говорит, что конкретно сделать, но не говорит «сделай этот кусочек параллельно и на той машине» — ему всё равно. А вот уже когда мы начинаем исполнять этот кусок, можем принять решение, что у нас здесь диапазон ключей, это партиционирование по ключу, и половина этих ключей лежит на той тачке, половина на этой, — тогда я распараллелю, распределю. Это уже на более низком этапе, а на верхнем уровне это просто дерево вычислений.

[1:49:50] Максим: Да, получается такое дерево. Правда, конкретно у нас в ClickHouse это скорее не дерево, а такой циклический граф. Можно представлять и как дерево, и как граф. Суть в том, что каждый узел в дереве имеет какой-то физический смысл, но когда мы его исполняем, единица исполнения — это процессор. И всё исполнение этого графа происходит в каком-то смысле как стейт-машина. Например, у процессора может быть состояние «я жду данных», или «я отдал свои данные, не могу их отдать — жду, пока кто-то примет», или «я работаю», или «я жду какой-то асинхронной задачи». Примерно такие. И когда реальные потоки исполняют процессоры — например, может быть 16 процессоров, которые читают данные из таблицы, — 16 потоков могут сесть на эти 16 процессоров, читать данные, потом начать их фильтровать. Но суть в том, что каждый процессор — время, которое он ждёт данных, время, которое ждёт, пока данные отдаст, время, которое работает, и когда ждёт асинхронных задач, — мы всё это логируем. И интересно, что мы их не группируем, а логируем в чистом виде. Например, у нас 16 процессоров, которые читают данные из таблицы, — мы их 16 логируем. Потом есть таблица, которая называется profile_processor_slot, как-то так, и мы в неё тоже можем передать Query ID. Query ID — это штука, которая во всех системных таблицах есть после того, как ты запрос сделал. И можно посмотреть, какие процессоры вообще работали. Ты группируешь их по имени процессора — например, ReadFromMergeTree или FilterTransform — и можешь посмотреть общее время: сколько потратил на чтение, сколько фильтровал, сколько в стадии ждали. Но что очень интересно — иногда ты не хочешь это группировать. Бывает, что каким-то образом вышел плохой расклад: шедулер плохо всё сделал, и получилось, что — обычно используются сложные алгоритмы, это work-stealing-алгоритмы. Когда есть множество задач, но статически оно не зашедулено, и у каждого треда есть своё подмножество задач. Когда он свои выполнил, он может из глобальной очереди взять немного задач или у другого потока украсть немного. Этот алгоритм написать аккуратно очень сложно, потому что много факторов: как понять, будет ли задача тормозить, — сходу не сделать, люди целые научные статьи пишут, как эти алгоритмы хорошо использовать, например для чтения данных с диска, что лучше делать при префетче. Для некоторых задач у нас есть интерфейс префетча, который позволяет, например, в S3 уже сходить. Понятное дело, что даже если физических процессоров 16, мы хотим сеть максимально утилизировать, делать больше запросов в сеть, в S3. Поэтому у нас куча дополнительных интерфейсов для префетча. И часто, когда такой алгоритм работает плохо, результат его плохой работы — один поток получился тормознутый и закончился раньше всех. Визуально можно представить: у вас есть 16 отрезков, все расположены вертикально, и один из них длиннее других, например в два раза. Этот поток, у которого произошла какая-то подстава, — и всё выполнение вашего запроса ограничено этим самым медленным потоком. И такое находить бывает очень проблематично, потому что нужно расставлять много логов. В распределённых вычислениях часто время распределённой стадии — это время по самому медленному воркеру. И тут такая же ситуация. Для этого у нас тоже есть логи, и ты прям можешь посмотреть, сколько каждый процессор выполнялся. Если один выполнялся дольше другого процентов на 20 — это уже подозрительно.

[1:54:17] Александр: Да, получается, это я вспомнил из теории ограничений Голдратта — когда пропускная способность системы или цепи ограничена самой медленной её частью, и быстрее ты никак не сделаешь, как бы ни ускорял всё остальное. Надо вначале ускорить ту часть, которая тормозит. И чтобы увидеть этот bottleneck в распределённом многопроцессорном выполнении запроса, нужно смотреть не просто время выполнения всего куска — оно может быть 20 миллисекунд, — а выполнение каждого конкретного кусочка: там может быть 2, 2, 2, 20, 2, 2 миллисекунды. И тогда ты понимаешь, что, блин, вот пофиксить бутылочное горлышко — и участок начнёт работать сильно быстрее. Круто. То, что ты рассказываешь, знаешь, с инженерной точки зрения — когда ты хочешь всё сделать хорошо — как будто бы всё, что ты рассказываешь, сделано так, как должно быть. Иногда бывает, ты с чем-то работаешь и такой: ну, я же это не перепишу уже, ладно, буду вот это использовать. А тут такой инженерный рай: ты видишь задачу, берёшь её, решаешь так, как надо, здесь и сейчас, делаешь как нужно тебе и твоим пользователям, а не пользуешься чем-то, что как-то решает, а как-то и не решает. Это очень круто.

[1:55:58] Максим: ClickHouse можно использовать как платформу для написания интересных штук, вообще для работы с перформансом, потому что на многие из вещей, которые я написал, ещё и куча задач есть, которые мы не можем сделать: например, сами ещё не знаем, как лучше аккуратно подобраться к вопросу, или какие-то вещи требуют более продвинутого ресёрча. В ClickHouse мы в целом стараемся не идти на компромиссы: если делаем, то основательно разобравшись, чтобы понимать, что solution — это не трэш, а реально world-class solution. Если используем какие-то алгоритмы, стараемся посмотреть, что это реально самое крутое решение в индустрии. А если что-то оптимизируем — есть несколько моделей мышления. В перформансе важно иметь такое мышление: «почему не в два раза быстрее?». Представь, ты написал программу, запустил, она работает 10 секунд. Посидел, покумекал, оптимизировал — опа, работает секунду, и ты такой: ну всё, ок. А в ClickHouse мы часто задаём вопрос: а почему не быстрее? Почему не в два раза быстрее? И во многих случаях в ClickHouse он уже работает со скоростью железа — тут уже реально быстрее тяжело, потому что нужно придумать новые алгоритмы: например, новые алгоритмы сжатия, прорывные вещи, новые алгоритмы для сториджа. Текущая конструкция уже работает со скоростью железа. Я вообще сторонник подхода, что мы ориентируемся от железа и реально пытаемся понять, какая у системы может быть пропускная способность. Для многих вещей это тяжеловато, но потом привыкаешь. Например, memcpy может в одно ядро работать около 10–12 гигабайт в секунду. И ты знаешь, что быстрее этого уже не двинешься: никакой алгоритм, который память из одного места в другое перемещает, не может работать быстрее. Или алгоритмы сжатия: LZ4 работает с разжатием 4 гигабайта в секунду, и ты понимаешь, что быстрее уже не можешь. Такие ограничители нужно понимать, потому что когда задаёшь вопрос «почему не в два раза быстрее», ответ обычно в каком-то идеальном сценарии: уже нельзя быстрее, нужно оптимизировать алгоритмы сжатия, которые дают тебе серьёзную планку сверху. Но в целом я сам раньше думал, что в каких-то случаях в ClickHouse уже быстрее нельзя, а почти всегда оказывается, что можно. Все эти оценки, которые ты даёшь, получаются с огромной погрешностью. И часто ты можешь в одном цикле что-нибудь исправить. Такое изменение как раз было недавно, в январе: удалось в цикле, который десериализует строку, добавить небольшое улучшение — по-моему, 4 или 5 строчек кода. Внутри цикла был resize, условная функция, и она не инлайнилась, из-за этого весь pipeline процессора плохо работал, на перфе было видно. Удалось аккуратно подумать и прятать этот resize под if, под ветвление, чтобы ресайзить только когда реально нужно, — не вызывать функцию попусту. И ресайзить со степенями двойки, стандартный ресайз. Степени двойки — это обычно хорошее число, на которое ресайзить, потому что все аллокаторы внутри себя делят память по степеням двойки. Есть предположение, что ресайзить в степень двойки плохо: если ресайзить в степень двойки, твой последний ресайз будет больше, чем все предыдущие — 1, 2, 4, 4 больше, чем 1 и 2, потом 8 больше, чем 1, 2, 4, — и не очень удобно переиспользовать кусочки памяти. Но в современных аллокаторах всё аллоцируется степенями двойки, поэтому это хорошая идея — у тебя в аллокаторе уже будет заготовленный кусочек на степень двойки. Удалось такую оптимизацию resize сделать, и в результате в среднем запросы в ClickHouse, которые работают со строками, ускорились на 10%, а некоторые даже на 60%. Для меня это даже интересный факт, потому что я думал, что уже сильно быстрее нельзя, но 10% в среднем — хороший результат для такой небольшой оптимизации.

[2:01:01] Александр: Есть сила в слабых оптимизациях — в таких маленьких, которые, знаешь, просто в цикл залез, что-то немного поменял, и хоп — всё стало быстрее. И знаешь, в каком разрезе про это интересно подумать? Сколько денег ты сэкономил кастомерам и сколько пользы нанёс миру в целом, который теперь меньше электричества потребляет на аналитических запросах в ClickHouse. Мне кажется, тебе надо какую-то маржу за это забирать — 1% от сэкономленных средств мне, пожалуйста.

[2:01:34] Максим: Ну, на самом деле с этими оптимизациями это действительно полезная вещь — даже с точки зрения бизнеса, потому что сильно быстрее работают запросы, сильно меньше нужно ресурсов. И одна из составляющих ClickHouse — что за те деньги, за которые ты поднимешь кластер ClickHouse, очень сложно найти что-то сопоставимое по производительности. Даже одна мощная машина ClickHouse может колбасить очень много данных, и за счёт этого тебе не придётся ничего сложного городить: какой-то профессионал, которому не нужны гарантии, может просто поднять один сервер ClickHouse, какой-нибудь мощный на AMD EPYC, перерабатывать на нём кучу данных очень быстро и сделать бэкапы. Человек сидит на одном сервере и свои сотню терабайт спокойно обрабатывает.

[2:02:36] Александр: Да, блин, прям захотелось попробовать, потому что я так в продакшене жёстко с ClickHouse не взаимодействовал, а хочется. То, что ты говоришь, действительно впечатляет. И что мне больше всего нравится и что вызывает доверие к системе ClickHouse — то, что это не просто маркетинговые слова типа «the world’s fastest database» и всё такое. Все эти решения, оптимизации подтверждаются перформанс-тестированием, бенчмарками, и оно всё открыто: ты можешь сам взять, форкнуть ClickHouse, попробовать что-то поменять в цикле, открыть pull request и посмотреть, насколько что поменялось. Это не теоретические изыскания, это то, как и должен инженерный мир, инженерный процесс быть устроен. И как он устроен в ClickHouse — всё доказывается на практике, что меня очень сильно впечатляет и вдохновляет.

[2:03:42] Максим: Мне кажется, это супер круто, и почему так произошло — я считаю, потому что эта система была изначально продвинута и сделана инженерами снизу. Она произросла от инженеров, а не от бизнеса пришло требование «сделайте нам самую быструю базу данных, чтобы мы были самые крутые». Наоборот, инженеры увидели проблему, начали её решать, тестировать, смотреть, что она решается, и из этого выросло что-то такое.

[2:04:11] Александр: Мне кажется, это такой юникорн в мире технологий, не побоюсь этого слова, потому что реально очень мало примеров подобных ClickHouse — что из компании вышла такая классная система. И то, что её используют TikTok, Cloudflare со своими огромными нагрузками, показывает, что оно действительно работает. И что, возможно, алгосики ботать не так уж и бесполезно, и когда-нибудь вы напишете что-то подобное, если будете вкладывать много энергии, души и будете хорошим профессионалом.

[2:04:48] Александр: Итак, история с одним крупным клиентом. Это, я так понимаю, real-time analytics платформа, которая под собой использует ClickHouse очень активно, и у них это коммерческий продукт, который стоит где-то на серверах у клиентов под большими нагрузками. И там случилась история, которую я рассказывал в начале подкаста, и Максим принимал активное участие в том, чтобы решить проблему этих ребят. Расскажи, пожалуйста, в чём была проблема, как они её обнаружили, как вы с ними взаимодействовали и как в итоге решили?

[2:05:30] Максим: Проблема была весьма понятная. Вообще-то это компания, которая использует ClickHouse, но использует его в очень необычном сценарии. Обычно ClickHouse используют в сценарии, где у тебя есть батч запросов, их немного — например, меньше тысячи в секунду. Даже тысячи — это многовато. На практике, сколько я видел, хорошо получается 50, может, 200 запросов в секунду — такие крупные запросы, и они нормально раскладываются и быстренько работают. Меньше чем 100 миллисекунд вполне можно даже для серьёзных запросов получать, можно даже в 15 миллисекунд укладываться. Но вот этот клиент использует ClickHouse в сценарии очень высокой конкурентности — 10 тысяч запросов в секунду. И под такие сценарии мы в ClickHouse, понятное дело, не тестировали. Тут нужно понимать, что есть сценарии, на которых ClickHouse не самый быстрый: например, сценарий большого количества джойнов — пока что, по крайней мере. Это сценарий реально больших, размашистых запросов, когда ты читаешь из кучи таблиц, может, даже не джойнишь, а пишешь хитренькие подзапросы. С этим наша модель пока не очень хорошо работает, потому что она рассчитана на то, что к тебе приходит запрос — бахнуть на него все ресурсы. Чем такая модель плоха? Параллелизм — штука, которая растёт, но растёт весьма интересно. Например, запрос работает в один поток одну секунду, в два потока — полсекунды, в четыре — 0.3 секунды. Ты уже замечаешь, что оно не уменьшается в два раза. А в восемь потоков будет, например, 0.26. У тебя происходит перенасыщение.

[2:07:41] Максим: Такое происходит за счёт того, что запрос может не очень хорошо параллелиться. Часто алгоритмы join, сортировки, group by — они двухфазные: сначала каждый worker что-то делает, потом это нужно смержить воедино. Двухстадийная модель. И она вообще может быть даже n-стадийная: n worker’ов что-то поделали, потом часть из них обменялись данными, помержили, потом ещё дерево вычислений получается — иерархические агрегации, сортировки, join. Но в ClickHouse в основном можно думать, что это двухстадийная модель. Она прекрасно параллелится: бывает даже так, что запустишь запрос в один поток — одна секунда, а в восемь потоков — 0.125, почти идеально распараллелилась, почти в восемь раз быстрее. Но для некоторых запросов ты сделаешь в восемь и в шестнадцать потоков, и разница будет всего 10%. Для сценария, где один запрос в секунду приходит, — понятное дело, почему бы не получить эти 10%. Но когда у тебя огромное количество запросов, возникает ситуация, при которой эти восемь потоков ты мог бы отдать на другой запрос. И эта модель более сложная, потому что она unpredictable и должна иметь в виду, что весь сервер — это условно один большой executor. Мы про такую систему тоже думали, где ClickHouse — это executor, который постоянно ходит, какие-то графы выполняет: ты туда новые графы подкидываешь, а он по ним ходит. В таком случае ресурсы более честно распределяются, но сейчас пока такой модели нет, и за счёт этого, когда запросов много, накапливается неэффективность. Это чисто архитектурная пока что проблема, но более-менее решаемая: можно сделать общий executor или более умный шедулер. Это одна грань — большие запросы, которых много. Вторая грань — когда у тебя очень много маленьких запросов. И на них тоже беда, потому что хоть ты и много ресурсов на него скинул, сам запрос очень быстрый, но их очень много, и та же неэффективность играет роль. Тоже нужен более умный шедулер. Но с большим количеством конкурентных запросов часто начинаешь упираться в разные штуки. Обычно в традиционных базах данных ты упираешься в CPU — CPU Bound, в Memory — Memory Bound, в I/O — I/O Bound. И сидят умные инженеры и говорят: вот Postgres, например, I/O Bound, MySQL — Memory Bound. В ClickHouse реально по-разному бывает: если много данных читаешь — I/O Bound, если агрегация с высокой кардинальностью — Memory Bound, потому что большие хэш-таблицы, если что-то сложное считаешь — CPU Bound. Но есть ещё фактор, про который многие не думают, и он называется Critical Sections Bound.

[2:10:08] Максим: Critical Sections Bound — это на самом деле очень интересная штука. В книжках про это, наверное, уже пишут — у Брендана Грегга наверняка можно почитать. Critical Sections — это тоже ресурс. Почему ресурс? Помнишь, мы говорили, когда у нас было N потоков и был тормознутый поток, и мы ориентировались на него. С Critical Sections мы тоже: критическая секция — это труба, через которую может только один проходить. Если у тебя какой-то запрос, и у него есть критическая секция, то быстрее, чем эта критическая секция отрабатывает, весь твой запрос никак не отработает. Это уже физическая проблема. И чем больше у тебя таких критических секций, тем больше проблем. Слушатели могут прочитать про закон Амдала — на Википедии, про параллельные вычисления, про то, как оптимизация влияет на итоговый результат. И что интересно, Critical Sections Bound — это большая подстава, потому что понять, что ты в это упираешься — что там реально какой-то mutex тормозит, — можно только при очень высокой нагрузке. Почему? Потому что если нагрузка маленькая, шедулер всегда более-менее хорошо всё делает: один поток зашёл в этот mutex, а шедулер выбирает какой-то другой поток, тот что-то другое делает — всё более-менее нормально. Но часто бывает, что при очень жёсткой конкуренции у тебя n потоков ждут на этом mutex, и есть другие n потоков, их тоже много, они тоже что-то делают. Ты заходишь в mutex, и шедулер сместил этот поток, который зашёл в mutex. Он свой квант времени потерял, и теперь шедулер на его место подсунет другой поток. И он может подсунуть поток, который сейчас где-то работал. А чтобы все те, кто ждёт, продвинулись, нужно, чтобы шедулер разбудил именно того, кто захватил критическую секцию. Иначе те будут ждать. Но у Linux-шедулера этой информации может не быть — с чего бы ему знать, где ты какие-то локи взял в application, на application-уровне: ты можешь произвольные mutex писать, спинлоки, что угодно. И он может разбудить какой-то поток, который сейчас не в критической секции, а тот, который был в критической секции, потом когда-то пробудится, но его очень много потоков ждали. Ситуация реально выглядит плохо. И чем больше нагрузка, тем хуже: больше потоков начинают ждать, и шедулер, когда какой-то поток зашёл в критическую секцию, может разбудить его нескоро. За счёт этого этот дилей будет накапливаться. И вот у этого клиента возникла такая ситуация.

[2:14:36] Александр: Давай я ещё раз проговорю эту ситуацию, потому что, кажется, можно её другими словами описать, чтобы у кого-то более чёткая картинка прояснилась. В общем, какая в системе ситуация? У нас есть код критической секции, как ты её называешь, — по сути это взятие блокировки. Возьмём, например, mutex. Его 16 потоков должны как-то взять, какую-то работу выполнить и mutex отпустить. Соответственно, это точка синхронизации этих 16 потоков. У нас есть ещё, допустим, 8 потоков в системе, которые вообще эту блокировку не берут и не этим занимаются. Всего получается 24 потока. Для шедулера операционной системы это просто 24 равноправных потока, которым нужно дать плюс-минус статистически одно и то же время выполнения на процессоре. Этому даёт квант времени, он выполняется, этому даёт квант, он выполняется, загружает стек потоков, выгружает. Но суть в том, что знание о том, что из этих 24 потоков 16 как-то между собой связаны неявно через этот mutex, шедулер не знает. И что получается? Один поток из этих 16 берёт блокировку, начинает выполнять работу и засыпает. Блокировка всё ещё держится. Шедулер такой: так, какой-то рандомный поток, отдаю ему работу, — а этот рандомный поток ждёт этот mutex. И весь квант времени, который мог бы быть занят чем-то полезным, занят тем, что один поток ждёт другой поток, который шедулер просто усыпил, вместо того чтобы его хотя бы не засыплять. В итоге это бесполезная, друг на друга завязанная фигня, и все 16 потоков ничего не делают, пока правильный поток не разбудится, не отпустит блокировку. И это экспоненциально взрывает время выполнения кода, потому что этих зависимостей просто куча. А что можно было бы хотя бы сделать? Вот ты говорил: окей, если бы шедулер знал, что эти 16 потоков выполняют код, завязанный на один mutex, он бы хотя бы, когда один из них засыпал, не будил другой, зная, что тот будет ждать, а разбудил бы один из тех восьми, которые на этот mutex не завязаны. И хотя бы тот сделал какую-то работу за этот квант времени. А получается, что шедулер об этом не знает, и квант времени просто чаще всего тратится на то, что потоки ждут. С примерно такой проблемой столкнулся этот клиент, верно?

[2:17:39] Максим: Да, примерно такая же. Ты тоже рассказал ещё несколько ситуаций — на самом деле может несколько ситуаций возникать, при которых такая беда происходит. Но конкретно с контекстом в ClickHouse была проблема с тем, что внутри контекста… Контекст — это вообще такая штука в базе данных, куда обычно складывают всё. Там могут лежать кэши, твоя текущая база данных — клиент подключается, у него есть дефолтная база. Там могут быть какие-то бэкграунд-пулы, к которым ты хочешь обратиться, закинуть асинхронный таск. Там может быть конфигурация сервера, настройки — глобальные, для текущей сессии. Давай возьмём настройки. Представь, сколько раз тебе нужно прочитать настройки — нереально много раз. Когда ты выполняешь запрос, тебе по мелочи много раз — может, 50 точно раз — нужно прочитать все настройки. Когда я эту проблему смотрел, сначала в чём была проблема: мы запускали workload, и видно было, что всё тормозит. Но совершенно непонятно почему. Метрики на то, сколько времени поток проводит в context-локе, не было. Такой метрики не было, и на запрос это никак нельзя было посмотреть, глобально тоже. Была только метрика, сколько локов мы ждём — сколько раз нам лок не удалось взять или сколько потоков сейчас в моменте ждёт на локе. И у коллег возникала такая ситуация, что периодически можно было видеть, что эта метрика всегда пустая, около нуля. Потому что ты раз в секунду эту метрику спрашиваешь, а по факту спрашиваешь, сколько сейчас потоков ждут на mutex. А этот mutex ты берёшь, просто читаешь настройку и отпускаешь — очень быстренькое. Получается, почти всегда в ноль, но появлялось, что в какой-то момент 30 или 50 — такие жёсткие спайки. Есть проблема, и можно было попытаться это отсэмплировать. Я попытался посэмплировать стеки, сгруппировать их — и да, действительно, можно было ловить моменты, когда на этом локе висело много потоков. И если ещё сгруппировать по стекам, видно, что почти все ждали настроек, к настройкам хотели обратиться, к доступам — проверить доступы пользователя на какую-то таблицу. Утильные, системные вещи. Первое, что мы сделали, — добавили возможность посмотреть, сколько реально времени поток проводил во время взятия лока. Хотелось понять, сколько времени суммарно все эти потоки ждали. Сделать это было не очень тривиально. Объясню почему. Контекст — это огромный класс. Мы как раз немного в предыдущей встрече про это говорили: когда такой огромный класс, а mutex один, его очень сложно модифицировать. Нужно понимать, что он реально блокирует, что не блокирует, что может — взял mutex, пошёл, какую-то функцию позвал, другую позвал, и что из этого всего должен блокировать. Но мы добавили возможность, когда поток берёт mutex, посмотреть в метрику, сколько времени он ждал, пока не возьмёт этот mutex. Быстренько залили эту метрику, залили hotfix примерно за день. Прям такая проблема возникла, я сделал pull request, за день мы его смержили, бэкпортировали, сразу проверили — да, проблема именно в этом. И я пошёл делать фикс. Фикс был такой. Мы берём этот один mutex — он у нас был как обычный mutex: ты заходишь в критическую секцию, и никто больше не может зайти. Была проблема, что контекст сам по себе некоторая глобальная штука, но, например, если пользователь хочет прочитать настройки текущей сессии, получилось так — видимо, исторически, когда код давно был написан, — что всегда брался глобальный mutex. А много локальных вещей, локальных на текущий запрос, можно было бы обрабатывать локальным mutex внутри контекста текущего запроса. Контекст в ClickHouse состоит из двух частей: глобальная часть, общая для всех запросов, и локальная часть — чисто для локального контекста запроса. И много вещей нужны были только в локальном контексте. Первым делом мы разбили mutex на две части: часть, которая защищает глобальную часть контекста, и для каждого локального контекста свой mutex, который защищает именно локальные вещи — локальные члены класса, переменные. Второй этап — в целом почти все члены класса инициализируются один раз и потом только читаются. И для многих из них вообще не нужен mutex: мы их инициализируем на старте сервера, можем лениво один раз проинициализировать — и всё. Для кучи вещей мы убрали mutex вообще. А для тех, которые оставили, которые нужно инициализировать прямо под mutex, мы сделали read-write mutex. Его основное отличие от обычного mutex в том, что читателей может быть много, писателей один. Но писателей почти никогда нет. Кто хочет это модифицировать? Например, когда сервер перезагружается — в ClickHouse можно делать reload конфига, и когда ты делаешь reload, можешь в глобальном контексте поменять конфиг. Это очень редкая ситуация. Поэтому пришлось сделать read-write mutex, но в большинстве случаев поток просто заходит, увеличивает свой каунтер, что он читатель, — и всё. И эта серия фиксов — разбить mutex на две части, убрать mutex из мест, где он не нужен, и поменять оба mutex на read-write — дала весьма серьёзное ускорение. Особенно это было заметно в 99-м перцентиле, для тяжёлого хвоста запросов.

[2:24:05] Александр: Пример хрестоматийный того, как обнаруживать нетривиальные проблемы, как их постепенно исправлять, и как это можно сделать даже во внешней системе. Это же ребята не у себя пофиксили — это в ClickHouse был фикс. И вот тут я бы подвёл жирную черту всего выпуска — и, возможно, двух предыдущих тоже. Какие основные практики, подходы, мышление, просто листом, сделали такую систему и такое окружение, в котором люди извне, кто пользуется, могут сделать реальный контрибьюшн — даже не тем, что код законтрибьютят, а тем, что найдут проблему, смогут её сообщить, и инженеры продукта смогут её пофиксить. Как, на твой взгляд, это всё получилось и почему это работает так классно?

[2:25:06] Максим: Мне кажется, во-первых, это хорошо работает, потому что в ClickHouse очень высокий rate пул-реквестов, которые принимаются. Когда-то давно я смотрел — он сильно выше 90%, может, 99%. Поэтому если у тебя есть какой-то фикс, то, скорее всего, если ты сделаешь пул-реквест, либо ты его сам домержишь своими силами, либо люди придут и смержат твой пул-реквест с мастером — если ты, например, в каком-то месте забил, решил не заканчивать. Твой код не удалят — авторов не удаляют. Иногда бывает в open-source-проектах: ты приносишь пул-реквест, а какой-то автор удаляет все твои авторства, коммитит от своего имени, и всё. В ClickHouse такого нет: мы просто доделаем пул-реквест, и все твои коммиты попадут. Большой фактор. Потом фактор того, что по проекту очень много всего сделано для контрибьюторов. Например, хороший CI — это супер важно, потому что люди извне могут спокойно посмотреть, что у них что-то не работает. Пропускная способность, сколько мы можем контрибьюторов принимать, сильно выше за счёт CI: человек допустил баг, выход за границу массива, — наш CI сразу это словит. Честно скажу, во многом за счёт этого мейнтейнеры и мержат пул-реквесты при зелёном CI: если всё зелёное, если CI зелёный, то можно мержить, скорее всего, там всё хорошо. И это большой фактор, потому что очень понятно, как мержить. Представь, у тебя есть какой-нибудь Postgres или другой open-source-проект, и в нём часто непонятно: пришёл пул-реквест, вроде приличный, но что нам с ним делать? Надо как-то прогнать тесты. А в ClickHouse сразу: сделал пул-реквест, он приличный, нормальный код, весь CI прошёл — мы особо не думаем, смержим. Быстрая скорость мержа и большое количество контрибьюторов сильно решает, потому что люди не боятся контрибьютить. Потом, в плане issues: то, что люди репортят проблемы, тоже хорошо, потому что мы много времени на это тратим. Раньше, когда мы работали в Яндексе, в ClickHouse было понятие дежурства: нужно было три пул-реквеста проревьюить, посмотреть issues и за день их разобрать, посмотреть баги. Потом, когда работали в ClickHouse Inc., то же самое было, и это реально хорошая вещь. Плюс скорость, как какие-то вещи делаются. Предположим, у тебя ботлнек-баг в продакшене — если это что-то серьёзное, ты создаёшь issue, на это сразу поставят major tag, это пофиксит даже кто-то не ты, кто-то из ClickHouse core-команды быстро возьмётся. Довольно часто бывает: есть куча тестов, какие-то не проходят, — в ClickHouse часто было, что Лёша говорит: всё, ни один пул-реквест не будет смержен, пока вы не пофиксите все тесты. Или: есть major bug, никто не смержит ни один пул-реквест, пока этот major bug не пофиксят. Это более-менее хорошая вещь для open-source-проекта, потому что качество будет сильно лучше. Есть такой сайт, называется All Tests Green Yet — его Лёша как раз делал, — и в нём можно посмотреть, сколько прошло тестов. Там миллионы тестов запускалось, и иногда действительно все тесты зелёные. Тоже хорошая политика, что качество продукта важное. Плюс в ClickHouse, что хорошо, — мне кажется, люди вообще не парятся по поводу коммитов. Часто в проекте люди думают, как коммит описать. В ClickHouse про это вообще не думают: есть куча коммитов, десяток коммитов в пул-реквесте Better, потом коммит tests. У меня такое бывает — но у меня обычно не Better, а нормальные коммиты, потом коммит tests, где я что-то последнее делаю, и потом всё зелёное, приходит Лёша и мержит. Мы реально не паримся за это, и это тоже хорошо, потому что за счёт того, что ты сидишь на GitHub, у тебя всё равно есть история: ты можешь от кусочка кода перейти к пул-реквесту, от пул-реквеста посмотреть issue, обсуждение. Мы не используем коммиты как базовую истину, мы пользуемся именно GitHub — пул-реквестами, обсуждениями, issues, и это всё провязывается в код. И есть ещё политика, что мы не боимся ревертить пул-реквесты. Ревертить — частая практика: сделал пул-реквест, прошёл день, CI нашёл к нему беду — например, фаззер, они могут не с первого раза найти, со второго, — просто пул-реквест отревертим. Это нормальная практика. Или сделал пул-реквест, он попал в какой-то релиз, релиз назад, но в нём оказался баг — без проблем отревертить, пофиксить, быстро сделать хотфикс. Такая культура, где всё более-менее свободное. Какая может быть проблема, если не ревертить сразу, а говорить человеку «у тебя баг, пофиксай»? Для человека это может быть очень трудно: представь, ты делал solution, все тесты прошли, значит, всё хорошо, а тут тебе говорят «в нём баг, пофиксай как можно быстрее» — это напряжение. А так ты просто заревертил пул-реквест, и человек, например, делает реверт, а ты делаешь реверт реверта и туда доделаешь свой фикс. Иногда даже — я такое видел — был реверт пул-реквеста, потом реверт этого реверта, а потом его реверт и ещё один реверт. Такая иерархия реверта. Но это помогает, потому что люди реально не переживают за такие вещи.

[2:32:14] Александр: Знаешь, что мне понравилось? Пойнт про то, что вы не паритесь над коммит-месседжами. У меня выпуск подкаста про коммит-месседжи есть, где я накидывал на то, что это кажется решаемым — не так сильно, когда у тебя не просто месседж fix, а под ним есть какой-то текстик, который говорит, что ты сделал и почему. Но прямо сейчас я зашёл в GitHub ClickHouse, и последний коммит — «fix Era» просто, ребят. Это довольно забавно, но в то же время интересное наблюдение, что нет статуса-кво, вообще того, как что-то делать. Можно делать его так и быть самой быстрой базой данных, быстро развивающейся в том числе. Но вот я сейчас смотрю коммит fix Era — даже мне сложно из этого коммита перейти к GitHub и к issue. Это прям хардкор-хардкор. Но респект. И главное, что это возможно. Почему? Потому что хороший CI, и в любой момент времени всё, что происходит, может быть пофикшено, отреверчено. И в этом плане интеграция и культура нового витка разработки — когда мы всё покрываем тестами, у нас CI, автоматизация, полуавтоматизация, автоматизированное или даже по большей части автоматизированное ревью — как раз показывает, что можно разрабатывать суперсложные и крутые системы, используя этот подход, и двигать их очень быстро. ClickHouse действительно очень быстро развивается. И эта идея меня вдохновляет. Да, Макс, спасибо тебе. Есть что ещё добавить по тому, что мы сегодня обсуждали?

[2:34:28] Максим: Да, наверное, особо нечего добавить. Могу сказать, что если кого-то заинтересовало — можете приходить контрибьютить в ClickHouse. У нас есть issues, там можно найти good first issue, help wanted, или из роадмапа какую-то фичу взять и начать на чём-нибудь работать.

[2:34:45] Александр: Да, я со своей стороны тоже хочу, во-первых, поблагодарить тебя, что ты пришёл, и так много мы поговорили и пообсуждали. Приоткрою тайну — или не тайну, а планы, которые у меня были. Я вообще хотел сделать выпуск про SIMD-инструкции и just-in-time компиляцию в ClickHouse. Но, как вы поняли, больше чем за три часа мы даже ни разу это слово не произнесли — настолько много всего мы обсуждали и настолько интересно было погружаться в детали, что и не дошли до основы. Но, не знаю, может быть, если слушатели напишут и сильно попросят, мы соберёмся ещё раз, потому что тема очень интересная. На этом выпуск подкаста подошёл к концу. Я надеюсь, вам понравилось. Не забывайте делиться подкастом с друзьями и коллегами — давайте прокачивать себя и людей вокруг. Но на этом всё. Услышимся!