- PVSM.RU - https://www.pvsm.ru -
Одна из малозаметных, но важных функций наших сайтов объявлений [1] — сохранение и отображение количества их просмотров. Наши сайты следят за просмотрами объявлений уже больше 10 лет. Техническая реализация функциональности успела несколько раз измениться за это время, и сейчас представляет из себя (микро)сервис на Go, работающий с Redis в качестве кэша и очереди задач, и с MongoDB в качестве персистентного хранилища. Несколько лет назад он научился работать не только с суммой просмотров объявления, но еще и со статистикой за каждый день. А вот делать все это действительно быстро и надежно он научился совсем недавно.
В сумме по проектам, сервис обрабатывает ~300 тысяч запросов на чтение и ~9 тысяч запросов на запись в минуту, 99% которых выполняются до 5мс. Это, конечно, не астрономические показатели и не запуск ракет на Марс — но и не такая тривиальная задача, какой может показаться простое хранение чисел. Оказалось, что делать все это, обеспечивая сохранение данных без потерь и чтение согласованных, актуальных значений требует определенных усилий, о которых мы расскажем ниже.
Хоть счетчики просмотров и не так критичны для бизнеса, как, скажем, обработка платежей или запросов на получение кредита [2], они важны в первую очередь нашим пользователям. Людей увлекает слежение за популярностью своих объявлений: некоторые даже звонят в службу поддержки, когда замечают неточную информацию о просмотрах (такое происходило с одной из предыдущих реализаций сервиса). Кроме того, мы храним и отображаем детальную статистику в личных кабинетах пользователей (например, для оценки эффективности применения платных услуг). Все это заставляет нас заботливо относиться к сохранению каждого события просмотра и к отображению наиболее актуальных значений.
В целом функциональность и принципы работы проекта выглядят так:
Хотя описанные выше шаги выглядят довольно просто, проблемой здесь является организация взаимодействия между БД и экземплярами микросервиса так, чтобы данные не терялись, не дублировались, и не запаздывали.
Использование только одного хранилища (например, только MongoDB) решило бы часть этих проблем. На самом деле раньше сервис так и работал, пока мы не уперлись в проблемы масштабирования, стабильности и скорости работы.
Наивная реализация перемещения данных между хранилищами могла бы привести, например, к таким аномалиям:
Могут возникнуть и другие ошибки, причины которых также кроются в неатомарной природе операций между БД, например — конфликт при одновременном удалении и увеличении просмотров одной и той же сущности.
Наш подход к хранению и обработке данных в этом проекте основан на ожидании, что в любой момент времени MongoDB может отказать с большей вероятностью, чем Redis. Это, конечно, не абсолютное правило — по крайней мере, не для каждого проекта — но в нашем окружении мы действительно привыкли наблюдать периодические таймауты на запросы в MongoDB, вызванные производительностью дисковых операций, что ранее было одной из причин потери части событий.
Чтобы избежать многих упомянутых выше проблем, мы используем очереди задач для отложенного сохранения и lua-скрипты, которые дают возможность атомарно менять данные в нескольких структурах редиса сразу. С учетом этого, в деталях схема сохранения просмотров выглядит так:
Иначе микросервис забирает счетчик просмотров из MongoDb, увеличивает его на 1 и отправляет в редис.
Благодаря тому, что lua-скрипты выполняются атомарно [5], мы избегаем множество потенциальных проблем, которые могли быть вызваны конкурентной записью.
Еще одна важная деталь — обеспечение безопасного переноса обновлений из очереди pending queue в MongoDB. Для этого мы применили шаблон «надежная очередь», описанный в документации Redis [6], который существенно уменьшает шансы потери данных благодаря созданию копии обрабатываемых элементов в отдельной, ещё одной очереди до момента их окончательного сохранения в персистентном хранилище.
Чтобы лучше понять шаги процесса целиком, мы подготовили небольшую визуализацию. Для начала посмотрим на обычный, успешный сценарий (шаги пронумерованы в правом верхнем углу и подробно описаны ниже):
Когда все подсистемы в порядке, часть этих шагов может показаться излишней. А у внимательного читателя также может возникнуть вопрос о том, что делает спящий в левом нижнем углу гофер.
Все объясняется при рассмотрении сценария, когда MongoDB оказывается недоступна:
В этой схеме есть несколько важных таймаутов и эвристик, выведенных через тестирование и здравый смысл: например, элементы перемещаются назад из processing queue в pending queue через 15 минут их неактивности. Кроме того, горутина, ответственная за эту задачу, перед выполнением выполняет блокировку [8], чтобы несколько экземпляров микросервиса не пытались восстановить «зависшие» просмотры одновременно.
Строго говоря, даже эти меры не дают теоретически обоснованных гарантий (например, мы игнорируем сценарии вроде зависания процесса на 15 минут) — но на практике это работает достаточно надежно.
Также в этой схеме остается еще как минимум 2 известных нам уязвимых места, которые важно осознавать:
Может показаться, что проблем получилось больше, чем хотелось бы. Однако на самом деле оказывается, что сценарий, от которого мы изначально защищались — отказ MongoDB — действительно является намного более реальной угрозой, а новая схема обработки данных успешно обеспечивает доступность сервиса и предотвращает потери.
Одним из ярких примеров этого был случай, когда инстанс MongoDB на одном из проектов по нелепой случайности был недоступен всю ночь. Все это время счетчики просмотров накапливались и ротировались в редисе из одной очереди в другую, пока в итоге не были сохранены в БД после разрешения инцидента; большинство пользователей сбоя даже не заметили.
Запросы на чтение выполняются гораздо проще, чем на запись: микросервис сначала проверяет кэш в редисе; все, что не найдено в кэше, дозаполняется данными из MongoDb и возвращается клиенту.
Сквозной записи в кэш при операциях чтения нет, чтобы избежать накладных расходов на защиту от конкурентной записи. Хитрейт кэша при этом остается неплохим, так как чаще всего он и без того оказыватся прогретым благодаря прочим запросам на запись.
Статистика просмотров по дням читается из MongoDB напрямую, так как запрашивается она гораздо реже, а кэшировать её сложнее. Это также означает, что когда БД недоступна, чтение статистики перестает работать; но сказывается это лишь на малой части пользователей.
Схема коллекций MongoDB для проекта основана на этих рекомендациях от самих разработчиков БД [10], и выглядит так:
Транзакционные возможности MongoDb по обновлению нескольких коллекций одновременно мы пока не используем, а значит рискуем тем, что данные могут записаться лишь в одну коллекцию. Такие случаи мы пока что просто логируем; их насчитываются единицы, и пока это не представляет такой же существенной проблемы, как прочие сценарии.
Я бы не стал доверять своим же словам о том, что описанные сценарии действительно работают, если бы они не были покрыты тестами.
Так как большая часть кода проекта тесно работает с редисом и MongoDb, большая часть тестов в нём — интеграционные. Тестовое окружение поддерживается через docker-compose, а значит разворачивается быстро, обеспечивает воспроизводимость за счет сброса и восстановления состояния при каждом запуске, и дает возможность экспериментировать, не затрагивая чужие БД.
В этом проекте можно выделить 3 основных области тестирования:
Чтобы проверять неудачные сценарии, код бизнес-логики сервиса работает с интерфейсами клиентов БД, которые в нужных тестах подменяются на реализации, возвращающие ошибки иили имитирующие сетевые задержки. Также мы симулируем параллельную работу нескольких экземпляров сервиса, используя паттерн "environment object [11]". Это вариант известного подхода «инверсия управления», где функции не обращаются к зависимостям самостоятельно, а получают их через переданный в аргументах объект окружения. Помимо прочих достоинств подход позволяет симулировать несколько независимых копий сервиса в одном тесте, каждый из которых имеет свой пул подключений к БД и более-менее эффективно воспроизводит продакшен-окружение. Некоторые тесты запускают каждый такой инстанс параллельно и убеждаются, что все они видят одинаковые данные, а состояния гонки отсутствуют.
Также мы проводили рудиментарный, но все равно довольно полезный стресс-тест на основе
siege [12], который помог примерно оценить допустимую нагрузку и скорость ответа от сервиса.
Для 90% запросов время обработки очень незначительно, а главное — стабильно; вот пример измерений на одном из проектов в течение нескольких дней:
Интересно, что запись (которая на самом деле является операцией записи+чтения, т.к. возвращает обновленные значения) оказывается немного быстрее чтения (но только с точки зрения клиента, который не наблюдает фактическую отложенную запись).
А регулярный утренний рост задержек — побочный эффект работы нашей команды аналитики, которая ежедневно собирает свою собственную статистику на основе данных сервиса, создавая нам «искусственный хайлоад».
Максимальное же время обработки сравнительно велико: среди самых медленных запросов себя проявляют новые и непопулярные объявления (если объявление не было просмотрено и выводится только в списках — его данные не попадают в кэш и считываются из MongoDB), групповые запросы за множеством объявлений сразу (их стоило бы вынести в отдельный график), а также возможные сетевые задержки:
Практика, в какой-то степени контринтуитивно, показала, что использование Redis в качестве основного хранилища для сервиса просмотров повысило общую стабильность и улучшило общую скорость его работы.
Основную нагрузку сервиса составляют запросы на чтение, 95% которых возвращаются из кэша, а потому работают очень быстро. Запросы на запись же выполняются отложенно, хотя с точки зрения конечного пользователя работают также быстро и становятся видимыми для всех клиентов немедленно. В целом почти все клиенты получают ответы менее чем за 5мс.
В результате текущая версия микросервиса на основе Go, Redis и MongoDB успешно работает под нагрузкой и умеет переживать периодическую недоступность одного из хранилищ данных. Исходя из предыдущего опыта с инфраструктурными проблемами мы определили основные сценарии ошибок и успешно защитились от них, так что большинство пользователей не испытывают неудобств. А мы в свою очередь получаем гораздо меньше жалоб, алертов и сообщений в логах — и готовы к дальнейшему росту посещаемости.
Автор: xapon
Источник [13]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/301793
Ссылки в тексте:
[1] наших сайтов объявлений: https://habr.com/company/kolesa/profile/
[2] запросов на получение кредита: https://habr.com/company/kolesa/blog/431342/
[3] HINCRBY: https://redis.io/commands/hincrby
[4] LPUSH: https://redis.io/commands/lpush
[5] выполняются атомарно: https://redis.io/commands/eval#atomicity-of-scripts
[6] документации Redis: https://redis.io/commands/rpoplpush#pattern-reliable-queue
[7] BRPopLPush: https://redis.io/commands/brpoplpush
[8] блокировку: https://redis.io/commands/set#patterns
[9] RDB снэпшотов: https://redis.io/topics/persistence
[10] этих рекомендациях от самих разработчиков БД: https://www.mongodb.com/blog/post/schema-design-for-time-series-data-in-mongodb
[11] environment object: http://www.jerf.org/iri/post/2929
[12] siege: https://github.com/JoeDog/siege
[13] Источник: https://habr.com/post/431902/?utm_campaign=431902
Нажмите здесь для печати.