Ключевые выводы
  • В разработке ПО нет «серебряной пули». Примите это — и страдать будете меньше.

  • Сложные системы ломаются грязно и непредсказуемо: параллелизм (concurrency), сетевые ретраи, причуды аллокатора, настройки ОС — продолжать можно бесконечно.

  • Качество — это бюджет. Потратите слишком мало — позже утонете в багах; потратите слишком много — застопорите прогресс.

  • Ловить баги на этапе компиляции дёшево; ловить их в продакшене дорого.

  • Юнит-тесты вас не спасут, но они всё равно нужны — в промышленных объёмах.

  • Интеграционные, хаос-тесты, тесты производительности и “test runner”-наборы закрывают те зоны, куда юнит-тесты не дотягиваются.

  • Держите сборку всегда «зелёной». Flaky-тесты (нестабильные тесты) убивают дисциплину быстрее, чем баги.

  • Надёжность рождается из процесса, а не из «магических» приёмов.

Итак, как сделать так, чтобы движок был максимально надёжным?

В этой серии, которую мы назвали «Проектирование надёжности в масштабе» (мы не смогли придумать ничего более претенциозного; если у вас есть идея получше — напишите в комментариях), мы приоткроем вам наш процесс разработки в Quasar и расскажем, как за годы мы инвестировали в качество, чтобы вывести в мир надёжную систему.

В это статье мы обсудим общие соображения и то, как мы подходим к тестированию. В следующих выпусках мы углубимся в детали и разберём конкретные примеры.

Почувствовать глубину

Допустим, вы написали движок базы данных без единого бага. Устойчив ли ваш безошибочный движок базы данных к следующему?

  • Отказам железа? Да, и облако только усиливает проблему.

  • Что будет, если диск переполнится? Ничего хорошего: у многих компонентов (дисков, файловых систем, операционных систем) в такой ситуации начинается «неопределённое поведение».

  • Сетевым ош��бкам? Что происходит при перегрузке, если пользователи устроят вашему серверу DoS-атаку (Denial of Service), или если у вас начнется потеря соединения?

  • Здравствуйте, у вас закончилась память. Разбирайтесь!

Однако ваш движок всё равно не будет без багов. Вот несколько «деликатесов», с которыми вам придётся столкнуться:

  • Конкурентность: транзакции, блокировки, ложное совместное использование кэш-линий (false sharing) и гонки (race conditions). Я упоминал, что Quasar активно использует многопоточность? Мы играем в эту игру на уровне сложности «ночной кошмар». Удачи отлаживать проблему, которая проявляется только «под высокой нагрузкой».

  • Сетевые ошибки: баги, которые всплывают только на повторной попытке (retry), когда соединение не удалось установить или вы его потеряли и нужно переподключаться.

  • Повреждение памяти или неопределённое поведение, которое очень тонко влияет на движок — и, конечно, не рядом с первопричиной.

  • Тонкости системных функций: вы уверены, что прочитали всю документацию по sys_call_magic()? А вы знали, что MAGIC_FLAG1 менял смысл между версиями glibc? Ах да, кстати, в версии ядра пятилетней давности — ну, той самой, которую использует ваш клиент, — есть баг: она просто игнорирует этот флаг.

  • Веселье с аллокатором памяти без конца и края: фрагментация, недокументированное поведение, проблемы производительности под давлением и тонкие взаимодействия с механизмами страничной памяти операционной системы… Подарок, который не перестаёт «радовать»!

  • Конфигурационные сюрпризы: в большинстве ОС множество «крутилок» может влиять на производительность или надёжность. Игнорировать хотя бы одну — не вариант.

Процесс важнее трюков

В разработке ПО нет «серебряной пули»; чем раньше вы это примете, тем меньше будете страдать.

Любой, кто уверяет, что если вы будете следовать такому-то методу, использовать такой-то инструмент, перейдёте на другой язык или «сделаете вот эту штуку», то проблем у вас не будет, — просто не понимает, откуда вообще берутся баги.

Баги появляются из-за разрыва между задачей и её решением. Этот разрыв может быть следствием нехватки технической компетенции, но чаще он возникает из-за недопонимания или неточной информации (а значит, большие языковые модели не сделают ПО безошибочным).

Повышать качество помогает глубокий подход, где выстроен процесс, позволяющий ловить ошибки как можно раньше. Для каждой возникающей проблемы нужно тщательно разбираться в причинах и добиваться того, чтобы было внедрено решение, не позволяющее той же ошибке повториться.

Со временем это формирует самоусиливающийся цикл роста надёжности.

Бюджет качества

Код нужно выпускать в разумные сроки, и это постоянный баланс между тем, сколько вы вкладываете в качество, и тем, с какой скоростью хотите двигаться.

Если совсем не инвестировать в качество, в итоге вы начнёте двигаться медленнее: любую правку будет сложно валидировать.

Если инвестировать слишком много, вы потеряете скорость итераций и удлините цикл обратной связи — и это, как ни парадоксально, ухудшит качество.

В идеале нужно найти баланс, максимально близкий к оптимальному именно для ваше��о случая.

И тут появляется ещё один ключевой параметр: насколько дорого обходится отказ? Взорвётся ракета — или просто счёт в игре окажется неверным? Мы все согласимся, что второе недопустимо, а ракеты и так постоянно взрываются, так что какая разница?

Следующий вопрос: готов ли ваш конечный пользователь платить за инвестиции в качество? И помните: платить вы будете всегда — либо временем (ожидая релиза), либо живыми деньгами.

Иначе говоря, какой у вас бюджет качества?

У нас в Quasar высокий бюджет — это и благословение, и проклятие. Благословение, потому что у нас есть время делать качественно. Проклятие — потому что пользователи ожидают, что софт никогда не будет ломаться, а когда он всё-таки ломается, они позвонят вам посреди отпуска. Шутка. Отпуск отменяется.

Выявление багов на этапе компиляции

Чем раньше вы ловите баг, тем дешевле это обходится, поэтому идеальный вариант — увидеть баг ещё при компиляции.

Один из самых недооценённых инструментов тестирования, который мы используем, — это сам компилятор C++20.

  • “Concepts” (концепты) позволяют нам ограничивать шаблоны, отсекая некорректные планы запросов или небезопасные типы ещё до того, как код вообще начнёт выполняться.

  • Статические утверждения (static assertions) прямо вшивают инварианты в код: если инвариант нарушен, сборка падает.

  • Метапрограммирование позволяет автоматически генерировать целые семейства проверок типов и тестов.

В итоге компилятор становится первой линией обороны. Многие классы багов даже не доходят до рантайма. Последовательное применение этих принципов по всему коду уберегло нас от неопределённого поведения и заставляло чаще задавать себе вопрос: «А мы точно делаем правильно?»

Давайте займёмся юнит-тестированием

В контексте Quasar юнит-тесты в основном помогают уверенно добавлять новые возможности, но редко ловят баги и проблемы. Причина в том, что они не способны исследовать комбинаторное пространство путей выполнения в базе данных.

Но это не повод не писать юнит-тесты в промышленных объёмах. Более того, почти наверняка тестов у вас меньше, чем нужно. Я ещё не видел проекта, где тестов было бы «слишком много». Если вам кажется, что вы видели слишком много тестирования, расскажите об этом.

В Quasar мы делаем так:

  • Мы используем Boost Test в качестве фреймворка, а юнит-тесты запускаются на каждом коммите в ветку master и release. Не зацикливайтесь на выборе фреймворка для юнит-тестов — влияние не настолько велико.

  • Пишите много юнит-тестов и уже на этапе проектирования убедитесь, что в коде есть возможности для интроспекции — чтобы проверять внутренние состояния. Не опирайтесь только на внешнее поведение: вы упустите многое. И не стоит слишком фанатеть от инкапсуляции и «чистоты дизайна». Пользователю не важно, что ваш класс был образцовой реализацией паттерна, если у пользователя крашится клиент.

  • Вы понимаете, что всё делаете правильно, если по мере написания тестов у вас возникают вопросы, которые нужно обсуждать с командой.

  • Не пытайтесь ловить юнит-тестами то, что всё равно будет выявлено позже: проблемы производительности, стресс-тесты, интеграционные сценарии.

  • Когда баг пойман «в полях», можно ли написать юнит-тест, который его воспроизводит? Если да — добавьте его. 

Для базы данных «оно компилируется и тесты проходят» — это только начало.

Добавим ещё больше тестов

Разумеется, мы не ограничиваемся юнит-тестами, и вы ведь не заходили сегодня в интернет, чтобы вам в очередной раз рассказали, что юнит-тесты повышают качество кода (да ну неужели?).

У Quasar модульная архитектура, а значит, мы можем заменить любой слой (например, сетевой) тестовым слоем, который генерирует ошибки. Например, у нас есть тесты кластеризации и сети без полноценного движка базы данных (только элементарная логика key/value).

Это позволяет писать точные интеграционные тесты и находить сложные проблемы.

Каждый набор тестов запускается на нескольких операционных системах (FreeBSD, Linux, Windows, macOS), с использованием разных компиляторов (MSVC, GCC и Clang), и у нас есть 32-битные и 64-битные сборки. Зачем столько платформ? Во-первых, этого требуют пользователи. Во-вторых, это помогает ловить хейзенбаги.

Ещё один важный компонент — набор тестов производительности.

Мы разработали инструмент для автоматического бенчмаркинга, который запускается раз в неделю и позволяет выявлять регрессии производительности. Набор бенчмарков загружает данные и выполняет по ним запросы, поднимая удалённый экземпляр (чтобы гарантировать передачу данных по сети), и измеряет время ответа на запросы.

Эта инвестиция быстро окупилась: она не дала нам выпустить релизы с серьёзными регрессиями производительности, а заодно помогала ловить баги (поскольку набор бенчмарков иногда может упасть или вернуть ошибку).

“Test runner”

Критически важный аспект тестирования — чтобы написание тестов было недорогим, поэтому мы разработали скриптуемый тестовый движок, который называем “test runner” (если у вас есть идея названия получше — опять-таки, пишите в комментариях). Этот тестовый движок принимает последовательность запросов и ожидаемые результаты.

Этот test runner можно запускать на самых разных конфигурациях, включая экземпляры «только в памяти» (in-memory only), несколько узлов и многое другое. Последовательность теста вы пишете один раз — и она автоматически прогоняется на нескольких узлах.

Кроме того, test runner принимает параметры, связанные с тем, как создаются таблицы. Например, можно задавать разные размеры шардов, проверять TTL или любой другой параметр, какой только придёт в голову. Снова: написал один раз — запустил много раз.

Поддерживать сборку «зелёной»

Сборка всегда должна быть «зелёной». И под «зелёной» здесь имеется в виду не то, что она успешно компилируется, а то, что проходят все тесты. Проблема flaky-тестов и «красной» сборки, которая регулярно ломается, в том, что люди перестают обращать на это внимание.

И снова — «серебряной пули» нет: нужно постоянно требовать, чтобы сборка оставалась зелёной, а когда она всё-таки краснеет — всё останавливается, пока её не починят. Это включает охоту на flaky-тесты, обеспечение надёжности самого процесса сборки и вынос более долгих тестов в еженедельный цикл.

Поверьте, так делать гораздо дешевле.

От тестов к уверенности

В следующей части этой серии мы зайдём ещё глубже: как мы «ломаем» Quasar — через намеренную порчу данных и хаос-тестирование — и почему от этого система становится сильнее.


Если вам близка мысль из статьи про «зелёную» сборку и раннюю обратную связь, посмотрите курс «CI/CD на основе GitLab». Он про реальную инженерную практику: GitLab Runner, workflow’ы команд, пайплайны от простых до сложных, доставка с Ansible/Docker/Kubernetes и безопасность пайплайнов — без магии, только проверяемые шаги. Пройдите вступительный тест и узнаете, подойдет ли вам программа курса.

А чтобы узнать больше о формате обучения и задать вопросы экспертам, приходите на бесплатные демо-уроки:

  • 14 января, 20:00. «IaC: Тестирование инфраструктуры — как внедрить инженерные практики и перестать бояться изменений». Записаться

  • 15 января, 20:00. «Mutation Testing: как я узнал, что мои тесты с 95% coverage ничего не проверяют». Записаться

  • 21 января, 20:00. «Мониторинг: как понять, что твой сервис болен». Записаться