Как Мы Создали Быстрое И Надежное Хранилище Просмотров Рекламы

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

Наши сайты отслеживают просмотры рекламы уже более 10 лет. Техническая реализация функциональности за это время несколько раз менялась и теперь представляет собой (микро)сервис на Go, работающий с Redis как с кешем и очередью задач, а также с MongoDB как с постоянным хранилищем.

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

Но делать все это по-настоящему быстро и надежно он научился совсем недавно.



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

Всего по проектам сервис обрабатывает ~300 тысяч запросов на чтение и ~9 тысяч запросов на запись в минуту, 99% из которых выполняются в течение 5 мс.

Это, конечно, не астрономические показатели и не запуск ракет на Марс – но и не такая тривиальная задача, какой может показаться простое запоминание чисел.

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



Цели и обзор проекта

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

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

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

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

В целом функционал и принципы работы проекта выглядят так:

  • Экран веб-страницы или приложения делает запрос к счетчикам просмотров рекламы (запрос обычно асинхронный, чтобы определить приоритет вывода основной информации).

    А если отображается страница самого объявления, клиент вместо этого запросит увеличение и возврат обновленного количества просмотров.

  • При обработке запросов на чтение сервис пытается получить информацию из кэша Redis, а не найденное дополняет, выполняя запрос к MongoDB.
  • Запросы на запись отправляются в две структуры в Radish: очередь инкрементных обновлений (обрабатывается в фоновом режиме, асинхронно) и кеш общего количества представлений.

  • Фоновый процесс в том же сервисе считывает элементы из очереди, накапливает их в локальном буфере и периодически записывает в MongoDB.


Количество просмотров записи: подводные камни

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

Использование только одного репозитория (например, только MongoDB) могло бы решить некоторые из этих проблем.

Собственно, раньше сервис и работал, пока мы не столкнулись с проблемами с масштабированием, стабильностью и скоростью.

Наивная реализация перемещения данных между хранилищами могла привести, например, к следующим аномалиям:

  • Потеря данных при одновременной записи в кэш:
    1. Процесс А увеличивает количество попаданий в кеш Redis, но обнаруживает, что для этой сущности еще нет данных (это может быть новое объявление или старое, удаленное из кеша), поэтому процесс должен сначала получить это значение из MongoDB .

    2. Процесс А получает количество просмотров из MongoDB — например, число 5; затем добавляет к нему 1 и собирается написать в Redis 6 .

    3. Процесс Б (инициированный, скажем, другим пользователем сайта, одновременно зашедшим на то же объявление) параллельно делает то же самое.

    4. Процесс А записывает значение в Redis 6 .

    5. Процесс Б записывает значение в Redis 6 .

    6. В результате одно представление теряется из-за гонки при записи данных.

      Сценарий не столь маловероятен: например, у нас есть платный сервис, размещающий рекламу на главной странице сайта.

      Для нового объявления такой ход событий может привести к потере сразу многих просмотров из-за их внезапного наплыва.

  • Пример другого сценария — потеря данных при перемещении представлений из Redis в MongoDb:
    1. Процесс принимает ожидающее значение из Redis и сохраняет его в памяти для последующей записи в MongoDB.
    2. Запрос на запись завершается неудачей (или процесс завершается сбоем до его завершения).

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

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



Запись количества просмотров: решение

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

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

С учетом этого подробно схема сохранения представлений выглядит так:

  1. Когда запрос на запись попадает в микросервис, он выполняет сценарий Lua. Инкрементифексистс увеличивать счетчик только в том случае, если он уже существует в кеше.

    Скрипт немедленно возвращается -1 , если в редиске нет данных для просматриваемого объекта; в противном случае он увеличивает значение представлений кеша через ХИНКРБИ , добавляет событие в очередь для последующего сохранения в MongoDB (которую мы называем ожидающая очередь ) через ЛПУШ и возвращает обновленное количество просмотров.

  2. Если IncrementIfExists вернул положительное число, это значение возвращается клиенту, и запрос завершается.

    В противном случае микросервис берет счетчик просмотров из MongoDb, увеличивает его на 1 и отправляет редису.

  3. Запись в редиску осуществляется через другой lua-скрипт — Upsert — который сохраняет сумму просмотров в кеше, если он еще пуст, или увеличивает их на 1, если кому-то другому удалось заполнить кеш между шагами 1 и 3.
  4. Upsert также добавляет событие просмотра в ожидающую очередь и возвращает обновленную сумму, которая затем отправляется клиенту.

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

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

Чтобы лучше понять этапы всего процесса, мы подготовили небольшую визуализацию.

Во-первых, давайте посмотрим на типичный успешный сценарий (этапы пронумерованы в правом верхнем углу и подробно описаны ниже):

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

  1. Микросервис получает запрос на запись
  2. Обработчик запроса передает его lua-скрипту, который записывает представление в кеш (сразу делая его доступным для чтения) и в очередь для последующей обработки.

  3. Фоновая горутина (периодически) выполняет операцию BRPopLPush , который атомарно перемещает элемент из одной очереди в другую (мы называем это «очередью обработки» — очередью с элементами, обрабатываемыми в данный момент).

    Затем тот же элемент сохраняется в буфере в памяти процесса.

  4. Приходит и обрабатывается еще один запрос на запись, в результате чего у нас остается 2 элемента в буфере и 2 элемента в очереди обработки.

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

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

    Каждый из наших проектов использует 3 экземпляра микросервиса, каждый со своим буфером, который сохраняется в базе данных каждые 2 секунды.

    За это время в одном буфере накапливается примерно 100 элементов.

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

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

А еще у внимательного читателя может возникнуть вопрос о том, что делает спящий в левом нижнем углу суслик.

Все это объясняется рассмотрением сценария, в котором MongoDB становится недоступной:

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

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

  2. Процесс теряет связь с MongoDB (сам процесс, конечно, об этом еще не знает).

    Горутина-обработчик, как и раньше, пытается сбросить свой буфер в базу данных — но на этот раз безуспешно.

    Он возвращается к ожиданию следующей итерации.

  3. Другая фоновая горутина просыпается и проверяет очередь обработки.

    Она обнаруживает, что элементы были добавлены к ней давным-давно; придя к выводу, что их обработка не удалась, он перемещает их обратно в ожидающую очередь.

  4. Через некоторое время соединение с MongoDB восстанавливается.

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

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

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

Строго говоря, даже эти меры не дают теоретически обоснованных гарантий (например, мы игнорируем такие сценарии, как зависание процесса на 15 минут) — но на практике это работает вполне надежно.

Также в этой схеме есть еще как минимум 2 известные нам уязвимости, о которых важно знать:

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

    В реальности подобных случаев на производстве мы пока не наблюдали.

  • При перезагрузке редис может потерять не только кеш, но и часть несохраненных просмотров из очередей, так как у него настроено периодическое сохранение Снимок RDB каждые несколько минут. Хотя теоретически это может быть серьезной проблемой (особенно, если проект имеет дело с действительно важными данными), на практике узлы редко перезапускаются.

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

Может показаться, что проблем больше, чем хотелось бы.

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

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

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



Просмотры чтения засчитываются

Запросы на чтение выполнять гораздо проще, чем запросы на запись: микросервис сначала проверяет кеш в Radish; все, что не найдено в кеше, заполняется данными из MongoDb и возвращается клиенту.

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

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

Статистика просмотров по дням считывается напрямую из MongoDB, так как запрашивается гораздо реже и ее сложнее кэшировать.

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



Схема хранения данных в MongoDB

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

  • Данные в сборнике со статистикой организованы по принципу один документ на объявление в месяц .

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

    Этот момент делает процесс чтения статистики немного корявым (запросы приходится генерировать по месяцам на стороне микросервиса), но в целом схема остается достаточно интуитивно понятной.

  • Для записи используется операция вставлять обновить и при необходимости создать документ для нужной сущности в рамках одного запроса.

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

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



Тестирование

Я бы не доверял своим словам, что описанные сценарии действительно работают, если бы они не были охвачены тестами.

Поскольку большая часть кода проекта тесно работает с Radish и MongoDb, большая часть тестов в нем являются интеграционными.

Тестовая среда поддерживается через docker-compose, а это значит, что она быстро развертывается, обеспечивает воспроизводимость за счет сброса и восстановления состояния при каждом запуске и дает возможность экспериментировать, не затрагивая чужие базы данных.

В этом проекте есть 3 основные области тестирования:

  1. Проверка бизнес-логики в типовых сценариях, т.н.

    счастливый путь.

    Эти тесты отвечают на вопрос: когда все подсистемы в порядке, работает ли сервис согласно функциональным требованиям?

  2. Проверка негативных сценариев, при которых ожидается продолжение работы сервиса.

    Например, действительно ли сервис не теряет данные при сбое MongoDb? Уверены ли мы, что информация остается согласованной несмотря на периодические тайм-ауты, зависания и одновременную запись?

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

    Например, нет никаких шансов, что сервис продолжит сохранять и обслуживать данные, когда ни Radish, ни Mongo не доступны — но мы хотим быть уверены, что в таких случаях он не крашится, а ждет восстановления системы и затем возвращается работать.

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

Также мы моделируем параллельную работу нескольких экземпляров сервиса с помощью " объект среды Это вариант известного подхода «инверсии управления», когда функции не обращаются к зависимостям самостоятельно, а получают их через объект окружения, передаваемый в аргументах.

Помимо прочих преимуществ подход позволяет моделировать несколько независимых копий.

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

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

Мы также провели элементарный, но все же весьма полезный стресс-тест, основанный на осада , что помогло примерно оценить допустимую нагрузку и скорость ответа от сервиса.



О производительности

Для 90% запросов время обработки очень маленькое, а главное стабильное; Вот пример замеров на одном из проектов за несколько дней:

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

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

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

Максимальное время обработки сравнительно велико: среди самых медленных запросов — новые и непопулярные рекламные объявления (если реклама не просматривалась и отображается только в списках, ее данные не попадают в кэш и считываются из MongoDB), групповые запросы для большого количества объявлений одновременно (их стоимость будет указана в отдельной таблице), а также возможные задержки в сети:

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



Заключение

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

Основную нагрузку сервиса составляют запросы на чтение, 95% которых возвращаются из кеша, а потому работают очень быстро.

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

В целом почти все клиенты получают ответы менее чем за 5 мс.

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

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

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

Теги: #программирование #redis #Хранение данных #базы данных #Микросервисы #Go #Системный анализ и проектирование #Распределенные системы #mongodb

Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.