
Арройо — это современный движок потоковой обработки, предназначенный для выполнения сложных SQL операций над миллионами записей в секунду. После того, как мы решили, что нам нужно создать новый потоковый движок, следующий вопрос был: какой язык мы используем?
Вероятно, это не спойлер, что в итоге мы написали Арройо на Rust. Это оказалось отличным решением, позволившим нашей небольшой команде создать современную систему потоковой передачи менее чем за год.
Так почему же мы решили использовать Rust и как это сработало?
(Краткая) история языков для инфраструктуры данных
C++
Зарождение современной инфраструктуры данных можно отнести к публикации трех монументальных статей Google: Google File System (2003 г.) и Map reduce (2004 г.), за которыми последовала Bigtable (2006 г.). В этих документах описывалось, как Google удалось хранить, обрабатывать и обслуживать петабайты данных, которые составляли индекс ранней сети.
Но еще интереснее было то, как они это делали. В то время, когда «большие данные» обрабатывались суперкомпьютерами и мейнфреймами, самый важный стартап в Кремниевой долине использовал сети из тысяч дешевых машин с Linux. Это заложило основу того, как будет осуществляться обработка данных на следующие двадцать лет.
Эти системы были написаны на языке C++, который в то время был стандартом системного программного обеспечения. C++ предлагал точный контроль над памятью и позволял Google максимально эффективно использовать свое оборудование, что было особенно важно, когда ваши серверы были процессорами Pentium III с тактовой частотой 1,4 ГГц и 2 ГБ оперативной памяти!
Java
Вдохновленный статьями Google, инженер по имени Дуг Каттинг решил создать собственную реализацию новой поисковой системы. Позже компания Yahoo наняла его на работу, и эта работа привела к созданию проектов с открытым исходным кодом таких как Hadoop File System (HDFS) и Hadoop Map Reduce.
Ранее Каттинг создал на Java поисковый индекс Lucene (прим. пер.: библиотека для высокопроизводительного поиска) и остановился на языке для Hadoop. Это сильно повлияло. Следующая волна систем больших данных возникла в последующие годы, когда всё больше компаний обнаружили, что им нужна распределенная обработка данных, включая HBase, Cassandra, Storm, Spark, Hive, Presto. Почти все они были написаны на Java или других JVM языках.
У Java было много преимуществ перед C++: сборщик мусора (GC) освобождал программистов от утомительного и подверженного ошибкам ручного управления памятью, а виртуальная машина позволяла запускать Java на множестве различных архитектур и операционных систем с минимальными изменениями. Кроме того, этот язык был намного проще, чем C++, что позволяло новым инженерам быстро освоиться.
Go
Тем временем Google продолжал писать свою инфраструктуру на C++, а программное обеспечение прикладного уровня — на Python и Java. В 2009 году они выпустили новый язык, предназначенный для этой ниши уровня приложений: Go. Разработанный частично Робом Пайком и Кеном Томпсоном — легендами в этой области — и оснащенный примитивами параллелизма высокого уровня и веб-библиотеками, он быстро нашел применение в мире распределенных систем.
Особенно этому способствовал выпуск Kubernetes с открытым исходным кодом в 2015 году. Kubernetes — это планировщик кластера, способный превратить группу серверов или виртуальных машин в общую основу для развертывания приложений. Это произошло в то время, когда отрасль переходила от статических моделей развертывания локально или на виртуальных машинах к более гибким контейнерным развертываниям в облаке.
Изначально он был написан на Java, но в какой-то момент разработки перешёл на Go. Его успех привел к огромной волне систем данных Go, включая CockroachDB, InfluxDB и Pachyderm.
C++ возвращается, и на сцену выходит Rust
Мир технологий бесконечно цикличен, и за последние несколько лет произошел еще один поворот колеса. Такие проекты, как ScyllaDB и Redpanda, добились успеха, переписав системы Java (Cassandra и Kafka соответственно) на C++ для повышения производительности и более предсказуемых операций. Новые базы данных и механизмы запросов, такие как DuckDB и Clickhouse, пишутся с нуля на C++.
Rust 1.0 был выпущен в 2015 году как современный системный язык, стремящийся занять ту же нишу, что и C++. В Rust нет сборщика мусора, он фокусируется на zero-cost abstractions и обеспечивает низкоуровневый контроль над процессом выполнения. В отличие от C++, его компилятор может проверять нарушения безопасности (например, использование неинициализированной памяти или двойное освобождение) и предотвращать состояния гонки в многопоточном коде. Его стали часто выбирать как для переписывания компонентов существующих систем с Go и Java (TiKV , InfluxDB IoX), так и для реализации новых систем (Materialize, Readyset).
Почему Rust
После этой истории, почему мы решили использовать Rust? Одна из причин весьма эгоистична: я являюсь поклонником Rust с тех пор, как познакомился с этим языком в 2014 году, и уже много лет использую его в личных проектах. Но у меня не было возможности внедрить это (за исключением небольших инструментов) в компаниях, в которых я работал, и при открытии компании впервые никто не мог сказать мне «нет». Короче говоря, Rust чрезвычайно хорошо подходит для написания инфраструктуры данных, сочетая в себе производительность и контроль C++ с безопасностью и простотой разработки Java.
Отличная производительность по умолчанию
На Java или Go возможно писать быстрый код, но в Rust он по умолчанию быстрый. Вам придется приложить все усилия, чтобы отказаться от эффективности, и Rust позаботится о том, чтобы вы всегда знали, на какие компромиссы идете.
Например, самый быстрый Java-код совсем не похож на идиоматический Java. Он в значительной степени полагается на примитивы и массивы, а не на объекты и контейнеры стандартной библиотеки. Он написан для минимизации создания и уничтожения объектов и снижения нагрузки на сборщик мусора. А в системах, ориентированных на данные, фактическое хранилище данных часто обрабатывается библиотекой C++, такой как RocksDB, что требует медленной связи через Java интерфейс (JNI).
Rust был разработан на основе zero-cost abstractions. Это несколько запутанная концепция, но суть такова: вы платите за то, что используете. Частично это связано с тем, что операции, которые, например, выделяют память или переключают контекст, четко вызываются. Но более важно то, что Rust предоставляет множество мощных абстракций, которые не требуют каких-либо затрат производительности во время выполнения и, как правило, столь же эффективны, как и написанный вручную код.
Например, итераторы и лямбды — это мощные высокоуровневые функции, но такой код (взят из системы водяных знаков Арройо, определяющей самый старый водяной знак с учетом простоя и потенциально отсутствующих водяных знаков):
self.cur_watermark = self
.watermarks
.iter()
.fold(Some(Idle), |current, next| {
match (current?, (_next)?) {
(EventTime(cur), EventTime(next)) => Some(EventTime(cur.min(next)))
(Idle, EventTime(t)) | (EventTime(t), Idle) => Some(EventTime(t)),
(Idle, Idle) => Some(Idle),
}
});очистит и оптимизирует до чего-то, по крайней мере, такого же быстрого, как написанный вручную цикл for. А поскольку такой код итератора более понятен оптимизатору, его часто можно более эффективно векторизовать, чем цикл, написанный вручную.
Нет сборщика мусора/Да, безопасность
Существует устойчивый миф о том, что языки со сборщиками мусора не могут быть быстрыми или не подходят для сред с низкой задержкой. Это неправда (см. всю высокочастотную торговлю, выполненную на Java), но для этого требуется глубокий уровень знаний о поведении и характеристиках производительности вашего сборщика мусора.
В частности, системы потоковой обработки обычно представляют собой трудные случаи для GC. Обработка событий очень быстро создает множество объектов, сохраняя при этом некоторые из них в течение непредсказуемого времени. Эксплуатация потоковых процессоров JVM, таких как Apache Flink, в большом масштабе означает стать экспертом в конфигурации и настройке GC, но при этом ожидать случайных сбоев. Многие пользователи используют значительно больше памяти, чем необходимо, чтобы избежать этого. После четырех лет работы в продакшене с Flink в Lyft и Splunk я практически мог видеть логи GC во сне.
Исторически здесь был компромисс. Вы можете либо справиться с проблемами масштабирования сборщика мусора, либо с ошибками, сбоями и проблемами безопасности на языке с ручным управлением, таком как C++. Но Rust нашел способ решить эту проблему: его программа проверки заимствований и система времени жизни позволяют управлять памятью во время компиляции.
Это позволяет избежать необходимости использования GC (динамического управления памятью) и позволяет языку гарантировать во время компиляции отсутствие ошибок безопасности памяти в коде (например, использование после освобождения или двойное освобождение). Эти гарантии действительны только для безопасного кода Rust, который используется по умолчанию. unsafeпозволяет пользователям манипулировать необработанной памятью способами, которые потенциально могут их нарушить, что необходимо для некоторых оптимизаций и для взаимодействия с библиотеками C и ОС. Однако на практике unsafeв кодовых базах Rust встречается редко; В Арройо всего несколько небезопасных блоков в десятках тысяч строк кода.
Некоторые утверждают, что можно написать на C/C++ безопасно для памяти, но долгая история эксплойтов, связанных с памятью, опровергает это. Пока я пишу это, Google и Apple срочно исправляют проблему переполнения буфера в libwebp, которая активно используется в Интернете 4.
Безопасность также порождает производительность. Например, гарантии безопасности Rust значительно упрощают правильное использование указателей во избежание копирования. Серверная (state) часть Арройо (используется для хранения данных, необходимых для вычислений, таких как окна) включает в себя кэш, который хранит десериализованные структуры в различных структурах данных для конкретных целей. Когда операторы запрашивают своё состояние, они могут получать элементы из кэша, если они доступны.
Чтобы избежать копирования, методы запроса имеют такие сигнатуры:
pub async fn get_time_range(
&mut self,
key: &mut K,
range: Range<SystemTime>,
) -> Vec<&V>Тип возвращаемого значения — Vec<&V>вектор (расширяемый массив) неизменяемых ссылок. Эти ссылки связаны соответствующими сроками жизни, что предотвращает случайное использование после освобождения. А поскольку они неизменяемы, вызывающая функция не может их изменить или освободить. Возврат ссылок позволяет нам избежать потенциально дорогостоящих копий данных, а Rust не позволяет нам нарушать свойства правильности и безопасности нашей структуры данных. В C++ подобный код был бы очень опасен и требовал бы от пользователей повышенного внимания.
Если он компилируется, это, вероятно, правильно
Компилятор Rust педантичен. Это самый навязчивый рецензент кода, с которым вам приходилось работать. Я говорю это как человек, который раньше проверял свои PR на C++ одним из авторов руководства по стилю кода C++ от Google… Если вы передадите 32-битное целое число функции, которая ожидает 64-битное целое число, она вам этого не позволит. Если вы попытаетесь разделить непотокобезопасную структуру данных между потоками, ваша компиляция завершится неудачно. Игнорировать тот факт, что пути файловой системы могут быть произвольными байтами, и попытаться использовать их как строки UTF-8? Прямо в тюрьму компилятора.
Некоторым людям понравится это в Rust. Другие, которые просто хотят, чтобы что-то работало, черт возьми, возненавидят это.
Поместите меня в первый лагерь. За свою карьеру я потратил достаточно времени на отладку трудновоспроизводимых ошибок в продакшене. Это предполагает дополнительную предварительную работу по проектированию и некоторое чувство разочарования в борьбе с компилятором. Но как только вы его удовлетворите, код окажется правильным в поразительно большей части времени.
В качестве анекдота: в начале истории Арройо я потратил пару дней на написание сетевого стека, позволяющего распределять конвейеры по нескольким узлам. Написав несколько тысяч строк сложного кода и скомпилировав его, я запустил его впервые. Это просто сработало. С тех пор он продолжает работать с некоторыми незначительными изменениями.
Идиоматический Rust также поощряет шаблоны, способствующие корректности, например интенсивное использование алгебраических типов данных (ADT). ADT существуют в двух формах: типы произведения ( struct) и типы суммы ( enum). Объединив их, вы сможете кратко определить данные вашего приложения, предотвращая при этом ошибки. ADT широко распространены в Rust, например, при обработке ошибок ( Optionи Result).
Но они также могут сделать многие оптимизации проще и безопаснее. Например, Cow (клонирование при записи) в стандартной библиотеке представляет собой перечисление, определенное следующим образом:
pub enum Cow<'a, B>
where
B: 'a + ToOwned + ?Sized,
{
Borrowed(&'a B),
Owned(<B as ToOwned>::Owned),
}Если вы не знакомы с определениями Rust, это может немного сбить с толку, но по сути это тип, который может принимать одну из двух форм: либо собственную копию данных, либо заимствованную ссылку.
Почему это полезно? Для поставщика части данных могут быть случаи, когда я могу избежать копирования и вернуть ссылку на некоторые другие данные, которыми я владею. Самый распространенный пример — строкаCow<str>, клонируемая при записи. При десериализации строки из буфера в некоторых случаях можно напрямую использовать базовые байты в буфере для поддержки строки (если она действительна в формате utf-8), а в других случаях вам может потребоваться скопировать и изменить их. Возвращая Cow<str>вместо String(принадлежащий типу String) или &str(ссылку на строку), мы можем поддерживать оба случая без дополнительных копий. В потоке данных Арройо узел может иметь один нисходящий узел или может разветвляться на несколько нисходящих узлов. Это отличный случай для Cow, потому что это означает, что в первом случае мы можем дать узлам собственный объект (поскольку это единственный потребитель), а во втором мы можем дать ему ссылку (поскольку в противном случае нам пришлось бы копировать это для каждого потребителя). Возвращая Cow, я позволяю своим потребителям прозрачно обрабатывать оба случая в зависимости от того, нужна ли им собственная копия базовых данных или они могут просто работать со ссылкой. Этот шаблон может сохранить множество ненужных копий по всей вашей кодовой базе.
Или, если взять пример из Арройо, вот enum из нашего движка:
pub enum QueueItem {
Data(Arc<dyn Any + Send>),
Bytes(Vec<u8>),
}Программы Арройо представляют собой графы потока данных, где операторы выполняют некоторые потенциально сохраняющие состояние вычисления (карты, фильтры, окна и т. д.), а ребра передают данные между операторами. Поскольку Арройо распределен, некоторые из этих операторов будут работать в одном процессе, а некоторые — на других машинах. При отправке данных по сети нам необходимо сериализовать их в байты, но мы хотим избежать этого при отправке в очередь локальному оператору.
Этот enum позволяет нам предоставлять данные, которые являются либо Vec<u8>(сериализованными), либо Arc<dyn Any>(объектами в памяти), избегая ненужной сериализации/десериализации, в то же время предотвращая путаницу типов у потребителей.
Это также прекрасно сочетается с системой трейтов Rust. Например, мы предоставляем следующую реализацию признака TryFrom, которая позволяет потребителям напрямую преобразовывать QueueItem в Message(основной тип потока данных Арройо):
impl<K: Key, T: Data> TryFrom<QueueItem> for Message<K, T> {
fn try_from(value: QueueItem) -> Result<Self> {
match value {
QueueItem::Data(datum) => _datum.downcast()?,
QueueItem::Bytes(bs) => {
bincode::decode_from_slice(&bs, config::standard())?.0
}
}
}
}
// used like this
let message: Message<K, T> = item.try_from()?;Конечно, в Rust по-прежнему можно совершить логические ошибки. Но компилятор вместе со столь же педантичной стандартной библиотекой и шаблонами программирования, ориентированными на корректность, такими как ADT, упрощают написание правильных программ, чем любой другой язык, который я использовал.
Cargo и экосистема crates
Создание механизма распределенной потоковой передачи SQL — это большая работа. Просто анализ SQL — это огромный и сложный проект. И я искренне надеюсь, что мне никогда не придется этого делать. Мы смогли добиться такого большого прогресса как небольшая команда, потому что стоим на плечах гигантов.
Cargo — это стандартный инструмент сборки и менеджер пакетов Rust, а crates.io — репозиторий сторонних библиотек. В отличие от фрагментированного мира C/C++ с CMake, Bazel, Buck и многими другими, в Rust есть единый способ создания пакетов, совместного использования кода и интеграции сторонних библиотек.
Это идеально? Нет. Но наличие единственного способа сделать это позволило создать невероятную экосистему пакетов за относительно короткое время. На crates.io вы можете найти самые современные структуры данных, мощные примитивы параллелизма, невероятную структуру сериализации и десятки тысяч других полезных библиотек.
Для Арройо, пожалуй, самым ценным из них является DataFusion, часть проекта Apache Arrow. Как продукт, это очень быстрый механизм запросов SQL. Но для нас (и многих других в пространстве данных Rust) это также библиотека, которая может работать на других механизмах SQL.
Арройо использует DataFusion для анализа (преобразования текстового SQL в дерево абстрактного синтаксиса), планирования (преобразования этого AST в граф вычислений) и оптимизации (переписывания этого графа в соответствии с правилами оптимизации) запросов. Каждая из них представляет собой сложную, трудную задачу, которая в некоторой степени ортогональна потребностям конкретной системы запросов. Не будет преувеличением сказать, что это сэкономило нам тысячи часов разработки.
Сложные части
Хотя мы очень довольны своим выбором Rust, не все из них показали успешную компиляцию и превосходящую производительность. Написав 2000 слов о том, почему мы любим этот язык, я думаю, стоит рассказать и о некоторых проблемах, с которыми мы столкнулись.
Требуется время, чтобы нахвататься
У Rust жёсткий язык. Это явно заслужено; Гораздо проще перейти от опыта работы с Java или Python и получить что-то работающее на Go, чем на Rust. Хотя люди, знакомые с C++, обычно гораздо легче освоят Rust. Обучение работе с системой проверки заимствований и системами времени жизни требует времени, а трейты и макросы могут быть более сложными, чем их эквиваленты на других языках.
Мы видели, что новичкам в Rust требуется несколько месяцев, чтобы действительно освоиться. Для стартапа на ранней стадии эти месяцы могут особенно сильно ослабить импульс. Если бы у нас не было одного члена команды (меня), который уже был довольно опытным программистом на Rust, было бы намного сложнее.
Асинхронность все еще довольно груба
Об асинхронности в Rust написано много. Async/await — это большой набор функций, впервые выпущенный в 2019 году, которые упрощают написание неблокирующего кода. Хотя многие языки используют async/await, реализация в Rust сильно отличается. Rust не требует рантайма и предназначен для настройки — от микроконтроллеров до суперкомпьютеров. Это ограничивает пространство для проектирования функций высокого уровня, таких как async/await. Решение, предложенное Rust, является необычным для программистов, знакомых с Go или Javascript, и приводит к большой путанице. Если это вас описывает, то это отличный обзор того, как работает асинхронность в Rust: ссылка.
А если вы пишете сетевые сервисы на Rust, асинхронность на данном этапе практически неизбежна. Почти все сетевые и сервис-ориентированные платформы полагаются на асинхронность. Для Арройо к ним относятся tonic (gRPC), Hyper (HTTP), object_store (чтение/запись в S3), kube-rs и многие другие.
Не имеет значения, считаете ли вы, что вашу проблему можно было бы легко решить без асинхронности. Фактически, оригинальный прототип Арройо использовал потоки. Поскольку каждая подзадача оператора обрабатывает сообщения последовательно, это действительно работает довольно хорошо. Но мы быстро перешли на асинхронность по вышеуказанной причине: экосистеме библиотек. Эта миграция заняла некоторое время (и много вопросов к очень полезному Tokio discord), но в итоге оказалась отличной с точки зрения производительности (с самого начала мы получили прирост пропускной способности примерно на 30%).
После нескольких месяцев написания асинхронного кода на Rust я почувствовал себя довольно комфортно и продуктивно. Но все еще существует ряд пробелов и недостающих функций. Самая большая из них — асинхронные особенности, которые вскоре будут частично стабилизированы. Есть ряд других острых моментов, из-за которых асинхронность по-прежнему кажется незавершенной четыре года спустя, например, трудности с созданием и именованием асинхронных замыканий или передачей ссылок через точки ожидания.
Нет стабильного ABI
В Rust отсутствует устаревший двоичный интерфейс приложений (ABI). Что это значит? Если я скомпилирую двоичный файл с помощью Rust версии X и попытаюсь связать его с общей библиотекой, скомпилированной с помощью Rust версии Y, нет никакой гарантии, что они будут совместимы.
Это проблема для любой системы, которая хочет разрешить пользователям динамически загружать код, например определяемые пользователем функции или пользовательские источники/приемники.
Существует несколько обходных путей, таких как использование C ABI или WASM, но они идут на компромисс с точки зрения производительности и выразительности.
Подведение итогов
Мы очень рады продолжению разработки Арройо на Rust и будущему инфраструктуры данных Rust. Несмотря на некоторые недостатки и недостающие функции, сочетание скорости, безопасности и экосистемы библиотек делает его очевидным выбором для построения крупномасштабных систем в будущем.
