Назад к блогу

Как Perplexity заменила DynamoDB на CobbleDB: механика собственного hot store

Как Perplexity заменила DynamoDB на CobbleDB: механика собственного hot store

Замена DynamoDB на собственное hot-хранилище CobbleDB в Perplexity показывает, как выглядит инфраструктурное решение под конкретный профиль нагрузки, а не под универсальные требования. Разбор пути записи через Pillar и Lorry, механики батчевых чтений и осознанного отказа от синхронного консенсуса даёт редкий взгляд на инженерные компромиссы в высоконагруженных поисковых системах.

Разбираем, почему managed-база перестала устраивать Perplexity на масштабе, как устроен путь записи через Pillar и Lorry, как читаются батчи страниц и что именно показали замеры. Нетривиальность здесь в том, что отказ от синхронного консенсуса — не компромисс «на сдачу», а осознанный выбор под конкретный профиль нагрузки: поисковая выдача допускает небольшое отставание репликации.

Почему DynamoDB перестала устраивать

При масштабе трафика свыше 200 000 запросов в секунду модель оплаты DynamoDB по фактическому использованию стала финансово неустойчивой: AWS тарифицирует каждый переданный байт, поэтому быстрее всего росли затраты, связанные с передачей данных. Экономика зависела от размера и частоты записей, а не только от числа вызовов API.

Вторая проблема — непрозрачность. DynamoDB работала как чёрный ящик, скрывая размещение партиций, политики кэширования в памяти и маршрутизацию реплик. Инженеры не могли предотвратить всплески хвостовой задержки от некэшированных чтений, сетевых переходов между зонами и отстающих реплик. А повторная обработка заданий, вызванная обновлёнными алгоритмами разбиения на чанки, или более новыми моделями эмбеддингов, направляла высокообъёмные записи напрямую в DynamoDB, создавая конкуренцию «шумного соседа» с живыми пользовательскими запросами.

Профиль нагрузки усугублял картину. Один запрос Search API содержит 100–120 ключей страниц, разбиваемых на батчи по 10–20 ключей, при среднем размере объекта около 50 КБ. Повторяющиеся чтения этих записей потребляли значительную ёмкость, тогда как непрерывный краулинг и изменения методов чанкинга или моделей эмбеддингов порождали всё больше записей. Нужна была узкая операция — по батчу идентификаторов страниц быстро вернуть подготовленное содержимое — с контролем над размещением партиции, объёмом памяти под кэш и выбором реплики, поскольку некэшированное чтение, межзональный переход или медленная реплика задерживают весь батч.

Отказ от консенсуса и модель согласованности

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

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

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

Путь записи: Pillar и Lorry

Обязанности разделены между тремя компонентами: Pillar хранит долговечное состояние документов и решает, что публиковать; Lorry превращает экспорты в партиционированные батчи; CobbleDB принимает батчи и отдаёт подготовленные записи по ключу страницы.

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

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

Lorry — stateless-сервис, чья единственная задача — читать записи экспорта из постоянной очереди, выровненной по партициям, накапливать их в пакетные файлы по партициям и передавать в CobbleDB. Между Lorry и Cobble дополнительный протокол разделяет плоскость данных (S3) и плоскость управления: Lorry записывает пакет в S3 под уникальным идентификатором и регистрирует пакет внутри Cobble. Реплики партиций Cobble независимо запрашивают у API плоскости пакетов или управления следующий S3-пакет для приёма и асинхронно применяют пакетные обновления в хронологическом порядке.

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

Путь чтения: батчи и привязка к зоне

После того как запрос проходит retrieval и ranking, hot store опрашивается массово для получения содержимого этих страниц. Запросы на чтение приходят на router. Router по возможности старается отправить запрос на data node, расположенный в той же зоне доступности, чтобы уменьшить межзонную задержку.

Структура запроса содержит список ключей страниц и указание предпочтительной зоны доступности:

pub struct BatchedPageRequest {
    pub keys: Vec<PageKey>,
    pub zone_affinity: AvailabilityZone,
}

Каждый data node извлекает содержимое страниц через MultiGet — batch-read API RocksDB. На query-time читаются текст пассажей и эмбеддинги, которые хранятся в отдельной таблице prepared-pages.

Размер батча задаётся на уровне запроса: один запрос Search API содержит 100–120 ключей страниц, а процесс извлечения разбивает его на меньшие батчи по 10–20 ключей при среднем размере элемента около 50 КБ. При валидации батчевых чтений каждый запрос запрашивал примерно 10–15 ключей при среднем размере полезной нагрузки 50 КБ.

Пограничные случаи и отказоустойчивость

Обработка чётного числа узлов и формулы кворума для чтения или записи не описываются — и это следствие архитектурного выбора: система отказывается от синхронного консенсуса, поэтому кворумов как таковых нет. Реплики применяют обновления асинхронно, каждая со своей скоростью, а расхождение состояния между репликами не разрешается синхронно.

Потерю записей предотвращают атомарные транзакции YTsaurus: при откате не меняется ни один слой. Дополнительно Pillar отслеживает версии чанков и эмбеддингов, позволяя нескольким представлениям сосуществовать без перезаписи друг друга. Lorry пишет батч в S3 под уникальным идентификатором и регистрирует его в Cobble, а реплики партиций независимо запрашивают следующий S3-батч и применяют обновления в хронологическом порядке, так что медленный или восстанавливающийся узел догоняет в своём темпе.

Как сравнивали и что получилось

Сравнение проводилось двумя способами: наблюдательное «до и после» на живом продакшн-трафике и синтетический бенчмарк, приближенный к продакшн-запросам. В синтетике обе системы опрашивались батчами по 10–15 ключей со значениями от 100B до 100 KiB. В замеренных числах латентности использовались батчевые чтения примерно по 10–15 ключей, средний размер элемента 50KB, обе системы нагружались около 200k rps, а также прогонялись нагрузочные тесты до 500k rps без деградации. Чтобы повторить замер, нужно воспроизвести эти параметры: батчи по 10–15 ключей, средний размер элемента 50KB и нагрузку около 200k rps с проверкой до 500k rps.

Результаты по латентности пакетных чтений:

МетрикаДо (DynamoDB)После (CobbleDB)
p5031.4 ms5.60 ms
p9056.7 ms9.77 ms
p99123 ms24.2 ms

Отношения улучшения лежат в диапазоне от 5.08× до 5.80× — выигрыш есть и на типичных запросах, и на хвосте. По стоимости: на каждом уровне обязательств CobbleDB как минимум на 20% дешевле DynamoDB, причём расчёт основан на внутренних оценках размера хранилища, единиц ёмкости записи и единиц ёмкости чтения.

Оговорки методологии. Для изоляции вклада CobbleDB все остальные аспекты setup удерживались постоянными, но сравнение остаётся наблюдательным «до и после»: системы обслуживали живой production-трафик в разное время, а не одни и те же запросы в контролируемых условиях. Именно поэтому дополнительно измерялась производительность обеих систем на синтетическом бенчмарке. По экономии: результаты не учитывают возможную экономию на резервном копировании за счёт сжатия, поэтому реальная экономия, вероятно, даже выше.

Компромиссы и обоснование решений

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

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

Выбор инструментов тоже обоснован профилем нагрузки. RocksDB выбран как hot store, потому что предоставляет MultiGet — batch-read API для одновременного чтения множества ключей с низкой задержкой, что соответствует паттерну чтения. YTsaurus выбран как основа Pillar — слоя durable document state и публикации: Pillar и YTsaurus используют дешёвое HDD-хранилище, что позволяет хранить значительно больше документов, чем раньше в DynamoDB. Транзакции YTsaurus обеспечивают атомарное покрытие состояния, намерений экспорта и учтённого входа. Альтернативой, от которой отказались, была DynamoDB: она давала надёжное хранение, но ограничивала контроль над размещением данных и обслуживанием чтений, а оплата зависела от объёма и частоты записей и чтений. Кроме того, прежний конвейер напрямую связывал обработку документов с обслуживающей базой, что мешало управлять обновлениями независимо от живых чтений.

Что из этого следует на практике

  • Профиль нагрузки важнее модности managed-сервиса. Когда каждый запрос — это батч из 100–120 ключей по ~50 КБ, а тарификация считает каждый байт, экономика managed-базы ломается на масштабе свыше 200 000 запросов в секунду. Собственный hot store окупается, если операция узкая и повторяемая.
  • Отказ от консенсуса — это выбор под конкретную семантику. Если приложение допускает короткий разрыв между записью и чтением, синхронные протоколы не нужны: асинхронные реплики снижают операционные издержки и позволяют медленному узлу догонять без торможения остальных.
  • Разделение плоскости данных и плоскости управления даёт воспроизводимость. Батчи в S3 под уникальными идентификаторами плюс регистрация в Cobble превращают массовые перестройки в повторяемый процесс, не мешающий задержке чтения.
  • Привязка к зоне и переключение на другую реплику — дешёвый способ бороться с хвостом. Router по возможности держит запрос в той же зоне доступности, а при медленном ответе узла обращается к другой реплике партиции.
  • Собственная база переносит эксплуатационную нагрузку внутрь. Управление жизненным циклом узлов, проверка бэкапов и ребалансировка партиций становятся задачей внутренних SRE — это цена контроля над размещением партиций, объёмом кэша и выбором реплики.

Источники

Похожее