В сумме по проектам, сервис обрабатывает ~300 тысяч запросов на чтение и ~9 тысяч запросов на запись в минуту, 99% которых выполняются до 5мс. Это, конечно, не астрономические показатели и не запуск ракет на Марс — но и не такая тривиальная задача, какой может показаться простое хранение чисел. Оказалось, что делать все это, обеспечивая сохранение данных без потерь и чтение согласованных, актуальных значений требует определенных усилий, о которых мы расскажем ниже.
Задачи и обзор проекта
Хоть счетчики просмотров и не так критичны для бизнеса, как, скажем, обработка платежей или запросов на получение кредита, они важны в первую очередь нашим пользователям. Людей увлекает слежение за популярностью своих объявлений: некоторые даже звонят в службу поддержки, когда замечают неточную информацию о просмотрах (такое происходило с одной из предыдущих реализаций сервиса). Кроме того, мы храним и отображаем детальную статистику в личных кабинетах пользователей (например, для оценки эффективности применения платных услуг). Все это заставляет нас заботливо относиться к сохранению каждого события просмотра и к отображению наиболее актуальных значений.
В целом функциональность и принципы работы проекта выглядят так:
- Веб-страница или экран приложения делают запрос за счетчиками просмотров объявлений (запрос обычно асинхронный, чтобы приоритизировать вывод основной информации). А если отображается страница самого объявления, клиент вместо этого попросит увеличить и вернуть обновленную сумму просмотров.
- Обрабатывая запросы на чтение, сервис пытается получить информацию из кэша Redis, а ненайденное дополняет, выполняя запрос к MongoDB.
- Запросы на запись отправляются в 2 структуры в редисе: очередь инкрементальных обновлений (обрабатываемая в фоне, асинхронно) и кэш общего количества просмотров.
- Фоновый процесс в том же сервисе считывает элементы из очереди, накапливает их в локальном буфере, и периодически записывает его в MongoDB.
Запись счетчиков просмотров: подводные камни
Хотя описанные выше шаги выглядят довольно просто, проблемой здесь является организация взаимодействия между БД и экземплярами микросервиса так, чтобы данные не терялись, не дублировались, и не запаздывали.
Использование только одного хранилища (например, только MongoDB) решило бы часть этих проблем. На самом деле раньше сервис так и работал, пока мы не уперлись в проблемы масштабирования, стабильности и скорости работы.
Наивная реализация перемещения данных между хранилищами могла бы привести, например, к таким аномалиям:
- Потеря данных при конкурентной записи в кэш:
- Процесс A увеличивает счетчик просмотров в кэше Redis, но обнаруживает, что там еще нет данных для этой сущности (это может быть как новое объявление, так и старое, вытесненное из кэша), поэтому процесс сперва должен получить это значение из MongoDB.
- Процесс A получает счетчик просмотров из MongoDB — к примеру, число 5; затем добавляет к нему 1 и собирается записать в Redis 6.
- Процесс B (инициированный, скажем, другим пользователем сайта, одновременно зашедшим на это же объявление) параллельно делает то же самое.
- Процесс A записывает в Redis значение 6.
- Процесс B записывает в Redis значение 6.
- В итоге один просмотр оказывается потерян из-за гонки при записи данных.
Сценарий не так уж и маловероятен: у нас, например, есть платная услуга, которая помещает объявление на главную страницу сайта. Для нового объявления такой ход событий может привести к потере сразу множества просмотров из-за их внезапного наплыва.
- Процесс A увеличивает счетчик просмотров в кэше Redis, но обнаруживает, что там еще нет данных для этой сущности (это может быть как новое объявление, так и старое, вытесненное из кэша), поэтому процесс сперва должен получить это значение из MongoDB.
- Пример другого сценария — потеря данных при перемещении просмотров из Redis в MongoDb:
- Процесс забирает ожидающее записи значение из Redis и сохраняет к себе в память для последующей записи в MongoDB.
- Запрос на запись заканчивается ошибкой (или процесс падает до его выполнения).
- Данные снова потеряны, что станет очевидно в следующий раз, когда закэшированное значение вытеснится и заменится значением из базы.
Могут возникнуть и другие ошибки, причины которых также кроются в неатомарной природе операций между БД, например — конфликт при одновременном удалении и увеличении просмотров одной и той же сущности.
Запись счетчиков просмотров: решение
Наш подход к хранению и обработке данных в этом проекте основан на ожидании, что в любой момент времени MongoDB может отказать с большей вероятностью, чем Redis. Это, конечно, не абсолютное правило — по крайней мере, не для каждого проекта — но в нашем окружении мы действительно привыкли наблюдать периодические таймауты на запросы в MongoDB, вызванные производительностью дисковых операций, что ранее было одной из причин потери части событий.
Чтобы избежать многих упомянутых выше проблем, мы используем очереди задач для отложенного сохранения и lua-скрипты, которые дают возможность атомарно менять данные в нескольких структурах редиса сразу. С учетом этого, в деталях схема сохранения просмотров выглядит так:
- Когда запрос на запись попадает в микросервис, он выполняет lua-скрипт IncrementIfExists для увеличения счетчика, только если он уже существует в кэше. Скрипт сразу же возвращает -1, если данных для просматриваемой сущности в редисе нет; в противном случае он увеличивает значение просмотров в кэше через HINCRBY, добавляет событие в очередь на последующее сохранение в MongoDB (называемую нами pending queue) через LPUSH, и возвращает обновленную сумму просмотров.
- Если IncrementIfExists вернул положительное число, это значение возвращается клиенту и запрос завершается.
Иначе микросервис забирает счетчик просмотров из MongoDb, увеличивает его на 1 и отправляет в редис.
- Запись в редис выполняется через еще один lua-скрипт — Upsert — который сохраняет сумму просмотров в кэш, если он еще пуст, или увеличивает их на 1, если кто-то другой успел заполнить кэш между шагами 1 и 3.
- Upsert также добавляет событие просмотра в очередь pending queue, и возвращает обновленную сумму, которая затем отправляется клиенту.
Благодаря тому, что lua-скрипты выполняются атомарно, мы избегаем множество потенциальных проблем, которые могли быть вызваны конкурентной записью.
Еще одна важная деталь — обеспечение безопасного переноса обновлений из очереди pending queue в MongoDB. Для этого мы применили шаблон «надежная очередь», описанный в документации Redis, который существенно уменьшает шансы потери данных благодаря созданию копии обрабатываемых элементов в отдельной, ещё одной очереди до момента их окончательного сохранения в персистентном хранилище.
Чтобы лучше понять шаги процесса целиком, мы подготовили небольшую визуализацию. Для начала посмотрим на обычный, успешный сценарий (шаги пронумерованы в правом верхнем углу и подробно описаны ниже):
- В микросервис поступает запрос на запись
- Обработчик запроса передает его в lua-скрипт, который пишет просмотр в кэш (сразу же делая его доступным для чтения) и в очередь на последующую обработку.
- Фоновая горутина (периодически) выполняет операцию BRPopLPush, которая атомарно перемещает элемент из одной очереди в другую (её мы называем «processing queue» — очередь с обрабатываемыми в данный момент элементами). Этот же элемент затем сохраняется в буфер в памяти процесса.
- Приходит и обрабатывается еще один запрос на запись, что оставляет нас с 2 элементами в буфере и 2 элементами в очереди processing queue.
- По истечении некоторого таймаута фоновый процесс решает сбросить буфер в MongoDB. Запись множества значений из буфера выполняется одним запросом, что положительно сказывается на пропускной способности. Также перед записью процесс пытается объединить несколько просмотров в один, суммируя их значения для одних и тех же объявлений.
На каждом из наших проектов используется по 3 инстанса микросервиса, каждый со своим буфером, который сохраняется в базу каждые 2 секунды. За это время в одном буфере накапливается примерно 100 элементов.
- После успешной записи процесс удаляет элементы из processing queue, сигнализируя, что обработка успешно завершена.
Когда все подсистемы в порядке, часть этих шагов может показаться излишней. А у внимательного читателя также может возникнуть вопрос о том, что делает спящий в левом нижнем углу гофер.
Все объясняется при рассмотрении сценария, когда MongoDB оказывается недоступна:
- Первый шаг идентичен событиям из предыдущего сценария: сервис получает 2 запроса на запись просмотров и обрабатывает их.
- У процесса пропадает соединение с MongoDB (сам процесс об этом, конечно, ещё не знает).
Горутина-обработчик, как и раньше, пытается сбросить свой буфер в базу — но на этот раз безуспешно. Она возвращается к ожиданию следующей итерации.
- Просыпается другая фоновая горутина и проверяет очередь обрабатываемых элементов (processing queue). Она обнаруживает, что элементы были добавлены в неё уже давно; делая вывод, что их обработка не удалась, она перемещает их назад в pending queue.
- Через некоторое время соединение с MongoDB восстанавливается.
- Первая фоновая горутина снова пытается выполнить операцию записи — на этот раз успешно — и в итоге окончательно удаляет элементы из processing queue.
В этой схеме есть несколько важных таймаутов и эвристик, выведенных через тестирование и здравый смысл: например, элементы перемещаются назад из processing queue в pending queue через 15 минут их неактивности. Кроме того, горутина, ответственная за эту задачу, перед выполнением выполняет блокировку, чтобы несколько экземпляров микросервиса не пытались восстановить «зависшие» просмотры одновременно.
Строго говоря, даже эти меры не дают теоретически обоснованных гарантий (например, мы игнорируем сценарии вроде зависания процесса на 15 минут) — но на практике это работает достаточно надежно.
Также в этой схеме остается еще как минимум 2 известных нам уязвимых места, которые важно осознавать:
- Если микросервис упал сразу после успешного сохранения в MongoDb, но до очистки списка processing queue, то эти данные будут считаться несохраненными — и через 15 минут будут сохранены повторно.
Для уменьшения вероятности такого сценария у нас предусмотрены повторные попытки удаления из processing queue в случае ошибок. В реальности же таких случаев в продакшене мы еще не наблюдали.
- При перезагрузке редис может потерять не только кэш, но и часть несохраненных просмотров из очередей, так как настроен на периодическое сохранение RDB снэпшотов каждые несколько минут.
Хотя в теории это может быть серьезной проблемой (особенно если проект имеет дело с действительно критичными данными), на практике узлы перезапускаются крайне редко. При этом, согласно мониторингу, элементы проводят в очередях меньше 3 секунд, то есть возможный объем потерь сильно ограничен.
Может показаться, что проблем получилось больше, чем хотелось бы. Однако на самом деле оказывается, что сценарий, от которого мы изначально защищались — отказ MongoDB — действительно является намного более реальной угрозой, а новая схема обработки данных успешно обеспечивает доступность сервиса и предотвращает потери.
Одним из ярких примеров этого был случай, когда инстанс MongoDB на одном из проектов по нелепой случайности был недоступен всю ночь. Все это время счетчики просмотров накапливались и ротировались в редисе из одной очереди в другую, пока в итоге не были сохранены в БД после разрешения инцидента; большинство пользователей сбоя даже не заметили.
Чтение счетчиков просмотров
Запросы на чтение выполняются гораздо проще, чем на запись: микросервис сначала проверяет кэш в редисе; все, что не найдено в кэше, дозаполняется данными из MongoDb и возвращается клиенту.
Сквозной записи в кэш при операциях чтения нет, чтобы избежать накладных расходов на защиту от конкурентной записи. Хитрейт кэша при этом остается неплохим, так как чаще всего он и без того оказыватся прогретым благодаря прочим запросам на запись.
Статистика просмотров по дням читается из MongoDB напрямую, так как запрашивается она гораздо реже, а кэшировать её сложнее. Это также означает, что когда БД недоступна, чтение статистики перестает работать; но сказывается это лишь на малой части пользователей.
Схема хранения данных в MongoDB
Схема коллекций MongoDB для проекта основана на этих рекомендациях от самих разработчиков БД, и выглядит так:
- Просмотры сохраняются в 2 коллекции: в одной находится их общая сумма, в другой — статистика по дням.
- Данные в коллекции со статистикой огранизованы по принципу один документ на одно объявление в месяц. Для новых объявлений в коллекцию вставляется документ, заполненный тридцать одним нулем за текущий месяц; соглано упомянутой выше статье, это позволяет сразу выделить достаточно места для документа на диске, чтобы базе не пришлось перемещать его при добавлении данных.
Этот пункт делает процесс чтения статистики немного неуклюжим (запросы приходится формировать по месяцам на стороне микросервиса), но в целом схема остается довольно интуитивной.
- Для записи используется операция upsert, чтобы в рамках одного запроса обновлять и, при необходимости, создавать документ для нужной сущности.
Транзакционные возможности MongoDb по обновлению нескольких коллекций одновременно мы пока не используем, а значит рискуем тем, что данные могут записаться лишь в одну коллекцию. Такие случаи мы пока что просто логируем; их насчитываются единицы, и пока это не представляет такой же существенной проблемы, как прочие сценарии.
Тестирование
Я бы не стал доверять своим же словам о том, что описанные сценарии действительно работают, если бы они не были покрыты тестами.
Так как большая часть кода проекта тесно работает с редисом и MongoDb, большая часть тестов в нём — интеграционные. Тестовое окружение поддерживается через docker-compose, а значит разворачивается быстро, обеспечивает воспроизводимость за счет сброса и восстановления состояния при каждом запуске, и дает возможность экспериментировать, не затрагивая чужие БД.
В этом проекте можно выделить 3 основных области тестирования:
- Проверка бизнес-логики в типичных сценариях, т.н. happy-path. Эти тесты отвечают на вопрос — когда все подсистемы в порядке, работает ли сервис согласно функциональным требованиям?
- Проверка негативных сценариев, при которых ожидается, что сервис продолжит свою работу. Например, действительно ли сервис не теряет данные при падении MongoDb?
Уверены ли мы, что информация остается согласованной при периодических таймаутах, зависаниях и конкурентных операциях записи? - Проверка негативных сценариев, при которых мы не ожидаем продолжения работы сервиса, но минимальный уровень функциональности все равно должен быть обеспечен. Например, нет никаких шансов, что сервис продолжит сохранять и отдавать данные, когда недоступны ни редис, ни монго — но мы хотим быть уверены, что в таких случаях он не падает, а ожидает восстановления системы и затем возвращается к работе.
Чтобы проверять неудачные сценарии, код бизнес-логики сервиса работает с интерфейсами клиентов БД, которые в нужных тестах подменяются на реализации, возвращающие ошибки и\или имитирующие сетевые задержки. Также мы симулируем параллельную работу нескольких экземпляров сервиса, используя паттерн "environment object". Это вариант известного подхода «инверсия управления», где функции не обращаются к зависимостям самостоятельно, а получают их через переданный в аргументах объект окружения. Помимо прочих достоинств подход позволяет симулировать несколько независимых копий сервиса в одном тесте, каждый из которых имеет свой пул подключений к БД и более-менее эффективно воспроизводит продакшен-окружение. Некоторые тесты запускают каждый такой инстанс параллельно и убеждаются, что все они видят одинаковые данные, а состояния гонки отсутствуют.
Также мы проводили рудиментарный, но все равно довольно полезный стресс-тест на основе
siege, который помог примерно оценить допустимую нагрузку и скорость ответа от сервиса.
О производительности
Для 90% запросов время обработки очень незначительно, а главное — стабильно; вот пример измерений на одном из проектов в течение нескольких дней:
Интересно, что запись (которая на самом деле является операцией записи+чтения, т.к. возвращает обновленные значения) оказывается немного быстрее чтения (но только с точки зрения клиента, который не наблюдает фактическую отложенную запись).
А регулярный утренний рост задержек — побочный эффект работы нашей команды аналитики, которая ежедневно собирает свою собственную статистику на основе данных сервиса, создавая нам «искусственный хайлоад».
Максимальное же время обработки сравнительно велико: среди самых медленных запросов себя проявляют новые и непопулярные объявления (если объявление не было просмотрено и выводится только в списках — его данные не попадают в кэш и считываются из MongoDB), групповые запросы за множеством объявлений сразу (их стоило бы вынести в отдельный график), а также возможные сетевые задержки:
Заключение
Практика, в какой-то степени контринтуитивно, показала, что использование Redis в качестве основного хранилища для сервиса просмотров повысило общую стабильность и улучшило общую скорость его работы.
Основную нагрузку сервиса составляют запросы на чтение, 95% которых возвращаются из кэша, а потому работают очень быстро. Запросы на запись же выполняются отложенно, хотя с точки зрения конечного пользователя работают также быстро и становятся видимыми для всех клиентов немедленно. В целом почти все клиенты получают ответы менее чем за 5мс.
В результате текущая версия микросервиса на основе Go, Redis и MongoDB успешно работает под нагрузкой и умеет переживать периодическую недоступность одного из хранилищ данных. Исходя из предыдущего опыта с инфраструктурными проблемами мы определили основные сценарии ошибок и успешно защитились от них, так что большинство пользователей не испытывают неудобств. А мы в свою очередь получаем гораздо меньше жалоб, алертов и сообщений в логах — и готовы к дальнейшему росту посещаемости.
Комментарии (16)
melesik
11.12.2018 11:15И как счётчики 20 лет назад работали без mongodb, redis и go? Как можно всё так усложнить?
xapon Автор
11.12.2018 11:27Сами удивляемся!
На самом деле на крупных проектах скорее всего и 20 лет назад использовали промежуточное in-memory хранилище для подобных нагрузок на запись. Не redis, так что-то другое, но принципиальная сложность вряд ли изменилась.
Sovigod
11.12.2018 11:38+1Просмотры и разрезом по дням/часам/минутам — это же time series. Пробывали что-то колоночное? типа clickhouse/influxdb?
xapon Автор
11.12.2018 18:21Пробовали influxdb, но не взлетело: во-первых, мы плохо его подготовили (на каждый запрос общего числа просмотров вычисляли сумму из time series — очень медленно); во вторых, даже если готовить хорошо, все-таки более 90% запросов у нас интересуются не статистикой, а суммой просмотров, поэтому заводить ради этого отдельную бд не захотелось.
После неудачи с influxdb решили выбрать что-то из имеющихся у нас в продакшене технологий — mysql или mongodb; вторая оказалась быстрее.Sovigod
11.12.2018 19:46+1Ну общий каунтер в любом случае надо бы кешировать. Просто с умным фоновым обновлением кеша.
А с приличным tsdb вы получите много приятностей для аналитики. Например уникальные просмотры(по ip или user_id, смотря что вам надо). Ну или просмотры(уники и нет) но по целым разделам, а не по отдельным объявлениям. Менеджеры такое очень любят.
Ну и гляделки приятные из коробки типа grafana с ее алертами на бизнес метрики.
И еще. в clickhouse у вас все это будет занимать намного меньше места. Был опыт переноса просмотров с mysql -> clickhouse. На больших сроках и объема почти в 100 раз меньше места потребляет clickhousemajesty
13.12.2018 11:13Clickhouse мы пробовали на других задачах (тоже хранение событий, но уже для внутренней аналитики). Непредсказуемое поведение и ведро процессорных мощностей, которое нужно вкидывать буквально каждый месяц привели к тому, что мы его похоронили в пользу BigQuery.
Sovigod
13.12.2018 15:17У нас тоже долго жрал очень много процессора. Но оказалось надо перечитать документацию и пересоздать таблицы с правильными индексами. Работать стало быстро, а потребление упало на порядок.
zapishiscom
11.12.2018 15:25А почему запросы в основном только на чтение? Это же просмотры объявлений, значит инкремент, значит запись!
xapon Автор
11.12.2018 18:17+1Большая часть запросов на чтение приходит со страниц поиска: мы выводим много объявлений в списках (читаем просмотры), но люди переходят, конечно, не по всем из них.
Ну и есть много других мест на сайте и в приложениях, где объявление отображается, но его просмотр не засчитывается.
ake111aa
11.12.2018 17:33Получается, у вас к каждому объявлению сущность с числовым счетчиком прикручен? Тогда несколько вопросов.
Почему был выбран именно такой подход? Почему бы не инсертить каждый просмотр как новую метку с двумя полями «ДатаСоздания» и «ОбъявлениеАйди», например? Тогда можно было бы и собрать более подробную статистику по дням/часам/секундам, и проблем с целостностью можно избежать. А прежние просмотры при архивации объявления удалять с базыxapon Автор
11.12.2018 18:24Подход time-series баз данных интересен; может быть, если понадобится более детальная статистика — будем использовать и его (в комбинации с другими, чтобы не тормозить на чтении общего числа просмотров).
А вообще причины отказа от time-series бд я описал вышеake111aa
11.12.2018 18:34Прочел коммент по ссылке. Хм, решение довольно спорное, но если с точки зрения бизнеса статистика не нужна, да и учитывая расходы на хранение данных — вполне подходящее. Спасибо за ответ
ggo
Каков объем данных?
xapon Автор
Данные одного проекта занимают около 25гб в MongoDb (из них большую часть занимает статистика по дням, на сумму просмотров приходится ~1гб). А в редисе лежит меньше 100мб на проект — это кэш и очереди вместе.
faiwer
А статистика минувших дней лежит на отдельном сервере\ах?
xapon Автор
База пошардирована, но статистика никак не отделена от актуальных данных.
Спасает то, что при удалении объявления удаляется и его статистика; может быть в будущем удалять их не будем в целях аналитики — тогда и разделим.