Разбираем, почему managed-база перестала устраивать Perplexity на масштабе, как устроен путь записи через Pillarслой долговечного состояния документов и публикации, который решает, что передавать нижестоящим компонентам и Lorrystateless-сервис, который превращает экспорты в партиционированные батчи и передаёт их в CobbleDB, как читаются батчи страниц и что именно показали замеры. Нетривиальность здесь в том, что отказ от синхронного консенсуса — не компромисс «на сдачу», а осознанный выбор под конкретный профиль нагрузки: поисковая выдача допускает небольшое отставание репликациипроцесса применения обновлений на копиях данных, из-за которого копия может отставать от оригинала.
Почему 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встраиваемая база данных-хранилище, используемая как hot store. На 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) |
|---|---|---|
| p50 | 31.4 ms | 5.60 ms |
| p90 | 56.7 ms | 9.77 ms |
| p99 | 123 ms | 24.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 — это цена контроля над размещением партиций, объёмом кэша и выбором реплики.