Как мы заменили наш конвейер данных без простоев

Автор: | 29.07.2026

В Twilio мы растем быстрее, чем когда-либо. Как и в случае с любым быстро развивающимся сервисом, некоторые старые части нашей инфраструктуры начали демонстрировать свои ограничения. Одним из критически важных компонентов, который мы потратили много усилий на переработку и масштабирование, стал конвейер данных, через который проходят и сохраняются вызовы и сообщения. Мы хотели бы поделиться подходом, который мы использовали, и некоторыми уроками, которые мы извлекли.

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

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

В Twilio мы стремимся работать в полностью сервис-ориентированной архитектуре, где вся необходимая функциональность системы предоставляется в виде сервисов для других приложений. Объединенная база данных для промежуточных и завершающих состояний со временем стала использоваться напрямую растущим числом наших клиентов. Это классическая проблема для любого развивающегося кодовой базы: хотя прямой доступ клиентов к базе данных удобен на ранних этапах, в долгосрочной перспективе это приводит к негативным последствиям, таким как:

— Единая точка отказа, которая может ухудшить работу многих сервисов.

— Трудности в определении причины таких сбоев и выяснении, кто ответственен за их устранение.

— Привязка множества потребителей к конкретной технологии хранения, что усложняет обновление и обслуживание ПО и делает их рискованными.

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

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

Помимо систем, которые мы изменяли, было очень заманчиво исправить и другие смежные компоненты, которые доставляли нам проблемы на протяжении многих лет (синдром «раз уж ты там…»). Одним из таких компонентов была технология базы данных. Хотя уровень хранения данных в Twilio состоит из множества технологий баз данных, мы в значительной степени полагаемся на MySQL. Преимущества и недостатки MySQL хорошо задокументированы, но для нас основными проблемами были отсутствие встроенного шардинга и высокой доступности. Однако, к лучшему или к худшему, транзакционные и консистентные гарантии, которые предоставляет MySQL, во многом определили дизайн нашего API. Переход на новую технологию нужно было бы осуществлять с особой осторожностью, чтобы сохранить семантику, на которую полагается программное обеспечение наших клиентов. Как платформенный провайдер, изменение ключевых технологических компонентов вызывает огромный страх. Вместо того чтобы добавлять риск к уже рискованному набору изменений в нашей архитектуре, мы решили не заменять MySQL, что означало необходимость решать проблемы масштабирования на уровне приложения.

Без встроенного шардинга нам пришлось решать проблемы пропускной способности и объема данных самостоятельно. Это включало разработку стратегии шардинга, которая масштабируется горизонтально и поддерживает все шаблоны доступа к данным. Во-вторых, нам нужно было обновить клиентов, чтобы они знали о шардах. Разместив сервис между клиентами и хранилищем данных, только один сервис должен знать, как данные шардируются и хранятся.

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

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

Сервис, который напрямую взаимодействует с базой данных, поддерживает сопоставление диапазонов шард-ключей с физическими шардами. Табличная часть стратегии дает нам точный контроль над тем, где физически хранится значение для данного ключа, и позволяет изменять его при необходимости. Диапазонная часть стратегии позволяет конфигурации маршрутизации оставаться разреженной, так как мы не хотели поддерживать однозначное соответствие каждого ключа целевому шарду. Она также позволяет изолировать крупного клиента на одном шарде (диапазон размером 1).

Это упрощенное представление топологии до развертывания:

Вот к чему мы пришли с новой системой, полностью внедренной:

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

Большая часть кода, который нам нужно было заменить, была написана без должной документации и в контексте, который давно забыт. Хотя мы могли сделать все возможное, чтобы прочитать код и перенести все необходимое поведение строка за строкой, неизбежно что-то было бы упущено. К счастью, у нас уже был отличный инструмент — Shadow, который служит прокси между двумя разными версиями HTTP-сервиса: известной стабильной версией и экспериментальной версией.

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

Мы также использовали «Флаги аккаунтов» — биты в базе данных, которые позволяют во время выполнения определять, по какому пути кода будет направлен данный запрос. Это позволило нам постепенно включать новую систему для некоторых клиентов, не нарушая обслуживание других, если новый путь кода содержал дефекты.

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

В приведенном примере у нас есть три клиента: API, Web и Billing. API и Web должны работать как с промежуточными, так и с завершающими данными, тогда как Billing работает только с завершающими данными. Агрегатор занимается объединением промежуточных и завершающих данных при необходимости. Например, в нашем публичном API ресурс списка вызовов возвращает единый набор результатов, содержащий как промежуточные, так и завершающие записи.

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

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

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

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

Новый шард поднимается как реплика существующего кластера. Это делается для того, чтобы все данные были немедленно доступны на новом шарде после переключения. В противном случае нам пришлось бы мигрировать данные, записанные на shard0, которые должны принадлежать shard1 после полного разделения шардов. Клиенты испытывали бы своего рода «амнезию данных» на протяжении всего процесса шардинга.

Следующий шаг — перенос записи, но здесь возникает проблема. В некоторых наших таблицах мы используем AUTO_INCREMENT для генерации уникальных идентификаторов для каждой строки. По умолчанию последовательность, используемая для этих идентификаторов, увеличивается на 1 с каждой новой строкой. Последовательность локальна для одного мастера, поэтому если мы начнем записывать на shard1, пока он реплицируется с кластера shard0, возникнут коллизии.

К счастью, MySQL позволяет нам настроить начальное смещение последовательности и шаг, на который увеличивается последовательность для каждого нового значения. На мастере shard0 мы установили auto_increment_offset=1 и auto_increment_increment=2. На мастере shard1 мы установили auto_increment_offset=2 и auto_increment_increment=2. В результате shard0 начнет генерировать только нечетные идентификаторы, а shard1 — только четные, избегая коллизий. После того как эти настройки вступят в силу, мы можем включить запись для нового шарда.

После полного переноса записи мы можем разорвать связь репликации и убрать разделение на четные и нечетные идентификаторы.

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

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

— Эффект бабочки. Ранние проектные решения и выбор технологий имеют огромные долгосрочные последствия. Думайте о масштабе при проектировании новых сервисов — рассматривайте все варианты и тщательно взвешивайте долгосрочные компромиссы. Может ли этот запрос к базе данных или архитектура стать большой проблемой при 10-кратном увеличении текущего масштаба? При 100-кратном? При 1000-кратном? Унция профилактики может предотвратить массу проблем, если проектировать с прицелом на будущее.

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

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