Одни из важнейших характеристик качественного IT-продукта — отказоустойчивость и работоспособность под нагрузками. Когда речь идёт о пользовательских финансовых операциях, это важно вдвойне, а если к уравнению добавить хайлоад — втройне. Я разрабатываю сервисы в команде платежей Ozon. Мы много времени уделяем тому, чтобы все транзакции были обработаны корректно, даже если речь идёт о нагрузке в 2к платежей в минуту (именно столько у нас было в пике в период ноябрьских распродаж). Кстати, сейчас по результатам нагрузочного тестирования мы выдерживаем 13к платежей в минуту. Для этого мы готовимся заранее: пересматриваем архитектуру, дорабатываем сервисы, рефакторим код, кэшируем и оптимизируем базы данных. Серебряной пули тут нет, но могу поделиться техниками, которые помогли нам избежать возможных проблем, — будет полезно всем, кто готовит свои сервисы с прицелом на работоспособность под нагрузками.  Подготовка к высокому сезону — важная задача для всех подразделений. Наша команда относится к этому особенно серьёзно, потому что мы отвечаем за проведение платежей.  Мы спрогнозировали, какие нагрузки будут на наши сервисы относительно ежедневных, составили флоу всего бизнес-процесса проведения платежа и провели ряд нагрузочных тестирований, в ходе которых с помощью метрик выявили узкие места нашей системы. Вот что мы выяснили: Работа с БД. Оказалось, что в некоторых сервисах больше всего времени при обработке запроса уходит на работу с базой данных (БД). a. Когда проблема была в записи, решили воспользоваться шардированием, чтобы распределить нагрузку на БД и увеличить её пропускную способность. b. Если была проблема с чтением из БД — мы упирались в количество коннектов в БД, которые распределялись между записью и чтением, — сделали запись данных в master БД, а чтение — из sync и async БД. Это существенно снизило нагрузку на master.  c. Если замечали, что ходим в БД просто за справочниками, решили эти данные кэшировать. Иногда в ходе нагрузочного тестирования не могли однозначно сказать, что есть проблема с сервисом. Но при 10—15-кратном увеличении нагрузки относительно текущей мы замечали, что некоторые вещи из нашего сервиса можно вообще вынести в другой, потому что они некритичны для проведения платежа. Всё это рассмотрим подробнее ниже.  Установка лимита запросов — один из вариантов защиты приложения. Если какой-то сервис начинает генерировать огромное количество запросов к вашему, то это может создать угрозу, что ваш сервис не будет успевать их обрабатывать. Быстро решить проблему поможет внедрение RateLimiter.  Наша команда в ходе нагрузочного тестирования выявила, что есть сервисы, которые ходят к нам просто за аналитическими данными. Если наш сервис не даст им ответа, то ничего критичного не случится — сервисы просто не отобразят какую-то часть данных клиенту, но это никак не повлияет на работоспособность всего Ozon. Мы решили для таких сервисов установить ограничение.  RateLimiter помогает снизить нагрузку на сервис, не увеличивая его пропускную способность, — и ваш сервис не так сильно подвержен DDoS-атакам.  Всем известный и хорошо себя зарекомендовавший nginx-сервер также позволяет реализовать RateLimiter на уровне прокси. Чтобы это сделать, достаточно добавить  в конфигурационный файл nginx. При этом нужно учитывать, что на уровне сервера Nginx нельзя получить доступ к каким-то внутренностям сообщения, пересылаемого между сервисами. Рассмотрим уровни ISO/OSI: 1 Физический  Электрические провода, радиосвязь, волоконно-оптические провода, Wi-Fi 2 Канальный Ethernet, Token Ring, HDLC, PPP, X.25, Frame Relay, ISDN, ATM, MPLS, ARP, RARP 3 Сетевой IP, ICMP, IGMP, CLNP, OSPF, RIP, IPX, DDP 4 Транспортный TCP, UDP, SCTP, SPX, RTP, ATP, DCCP, GRE 5 Сеансовый RPC, NetBIOS, PPTP, L2TP, AppleTalk 6 Представления XDR, AFP, TLS, SSL 7 Прикладной HTTP, SMTP, FTP, Telnet, SSH, SCP, SMB, NFS, RTSP, BGP Тот факт, что nginx работает обычно на более низких уровнях (как правило, на четвёртом), делает реализацию на уровне nginx не всегда подходящей. Мы выбрали доработку уже существующей C#-библиотеки под свои нужды, что даёт большую гибкость. Мы реализовали отдельные хендлеры и с их помощью управляем функционалом RateLimiter. В случае большой нагрузки мы можем за несколько секунд поставить ограничения на REST-ручки и сразу снизить лишний трафик на наши сервисы.  REST-ручки — метод HTTP, который предоставляет сервис. Например: Также кастомная реализация RateLimiter даёт возможность использовать какие-то специфические значения из домена приложения. Мы завязались на зашифрованное название клиента, который присылает нам запрос (оригинальное название хранится в БД сервиса и не может быть передано в другие сервисы). Поэтому мы выбрали кастомную реализацию.  Также, если вы тоже решили использовать кастомную реализацию RateLimiter, можно использовать общее хранилище настроек или своё на каждом поде. Мы выбрали общее хранилище, чтобы ограничение хранилось в одном месте. Чтобы не было случая, когда сервис исчерпал все свои запросы на один под и начал ходить в другой. Это абстрактный объект Kubernetes, представляющий собой группу из одного или нескольких контейнеров приложения (например, Docker или rkt) и совместно используемых ресурсов для этих контейнеров. Ресурсами могут быть: общее хранилище (тома), сеть (уникальный IP-адрес кластера), информация о выполнении каждого контейнера (версия образа контейнера или используемые номера портов). Под представляет собой специфичный для приложения логический хост и может содержать разные контейнеры приложений, которые тесно связаны. Например, в поде может размещаться как контейнер с приложением на Node.js, так и другой контейнер, который использует данные от веб-сервера Node.js. Все контейнеры в поде имеют одни и те же IP-адрес и пространство порта, выполняющиеся в общем контексте на одном и том же узле. Но у встроенного RateLimiter есть свои подводные камни. Так, он использует те же ресурсы, что и сам сервис — и это нужно учитывать. Пример того, как мы устанавливаем настройки на сервисе с помощью ручек: Расскажу не про способы реализации кэширования, а про проблемы, которые могут возникнуть на этом пути.  Если вы реализуете эту технологию в своём сервисе, нужно учитывать, что кэшированные данные не всегда актуальны. Поэтому, если у вас более чем один инстанс приложения и вы хотите иметь консистентные данные, вам нужно применять Redis или его аналоги. Добавление кэширования добавляет ещё одну точку отказа сервиса. Если оно будет неправильно реализовано, то в случае поломки сервиса кэширования может сломаться и ваш сервис.  При кэшировании нужно всегда задавать время жизни данных, чтобы не было такого, что они давно устарели.  Если вы хотите хранить в кэше ответ от другого сервиса, то нужно синхронизировать данные с ним, чтобы не получилось так, что другой сервис обновил данные на своей стороне, а вас забыл об этом оповестить (что ещё раз говорит в пользу отказа от кэширования чужих данных). Сервис, данные которого вы хотите кэшировать, должен оповещать вас обо всех изменениях своих данных, а ещё — сообщить время жизни данных, по истечении которого больше нельзя доверять данным из кэша и нужно идти за новыми.  Как видите, внедрение в сервис кэширования не всегда приносит пользу. Прежде чем реализовывать эту технологию, нужно взвесить все плюсы и минусы.  Для снижения нагрузки на сервис можно применить паттерн Proxy с реализацией сервиса проекции данных.  Проекция — это подготовка и хранение данных таким образом, чтобы их получение занимало как можно меньше времени и процессорных ресурсов. Можно даже хранить предвычисления или наполовину собранные данные в БД.  Фактически у нас Proxy получает немного больше логики, чем должен, то есть он теперь умеет анализировать ответы сервисов, чтобы работал фолбэк.  Логика фолбэка заключается в том, чтобы проверять ответ от сервиса Projection на валидность. Мы проверяем, имеет ли статус платежа, полученного из сервиса Projection, одно из допустимых значений. Если это не так, то идём в основной сервис за данными, которые не получили.  Эта логика помогает нам снизить нагрузку на основной сервис и его БД для получения уже сохранённых данных почти на 95%, что очень помогает во время распродаж.   Данные из основного сервиса реплицируются в сервис Projection асинхронно.  Иногда случается так, что узким местом в системе становится работа с БД. Снижаем нагрузку на БД с помощью нескольких техник. Первая и, как правило, самая действенная техника не требует больших доработок системы. Можно создать sync/async-реплику и убрать нагрузку на чтение с текущего master. Разница между sync- и async-репликами заключается в том, что коммит на master БД не проходит до тех пор, пока не пройдёт на sync-реплике, поэтому применять sync-реплику нужно всегда осторожно. Реплицирование в async-реплику не создаёт таких проблем, но данные там не настолько актуальны, как в sync-реплике.   Эта техника применима, когда идёт большая нагрузка именно на чтение из БД, но не на запись.  Вторая техника требует доработок системы, но это того стоит, потому что она очень хорошо помогает разгрузить БД на запись. Речь о шардировании. Для этого очень важно выбрать ключ шардирования. Один из подходов требует создания отдельной БД, содержащей таблицу адресации (при этом можно легко подключать и отключать новые БД для шардирования). Но можно использовать и консистентное хеширование.  Это способ создания распределённых хеш-таблиц, при котором вывод из строя одного или более серверов-хранилищ не приводит к необходимости полного переразмещения всех хранимых ключей и значений.  Минус использования консистентного хеширования именно для БД заключается в том, что при отключении одного из хостов БД нужно перераспределять ключи по оставшимся БД, а это требует дополнительных ресурсов системы. Более подробно узнать про реализацию консистентного хеширования и про шардирование.  До этого момента мы с вами рассматривали подходы, которые можно реализовать на обрабатывающей и принимающей запросы сторонах. Но есть ряд техник снижения нагрузки и повышения производительности, которые можно применить и на стороне клиента. У нашей системы, как и у других, есть тайм-аут — промежуток времени, который готова ждать вызывающая сторона. Если вызывающему сервису критично получить данные от вашего, то он обычно добавляет ретраи. Ретрай — повторный вызов сервиса, если предыдущий не получил корректный ответ.  Как-то в ходе нагрузочного тестирования мы выяснили, что при больших нагрузках один из наших сервисов кидает много 500-сотых ошибок. А поскольку этот сервис критичен для оплаты, то другие сервисы стали делать ретраи. На рисунке ниже можно увидеть, что случилось с сервисом: пришло очень много ретраев — и он не справился.  Разобрав, что же случилось, стали думать, что может нам помочь избежать таких проблем с ретраями, — и нашли технику Jitter. Она не снижает количество запросов, а равномерно распределяет их по времени. На данный момент ретраи реализуются следующим способом: мы делаем либо одинаковый интервал между попытками повтора запроса к вызываемому сервису, либо по возрастающей последовательности: 1, 2, 4, 16 и т. д. Но можно заметить, что если вызываемый сервис был недоступен или отвечал дольше обычного, то при простых ретраях он получит такое же количество запросов, как и при первом запросе. При применении техники Jitter мы добавляем в интервалы между запросами какую-либо функцию, которая генерирует случайное число, — и тогда количество запросов в одно и то же время сразу снижается, что даёт возможность вызываемому сервису восстановиться или обслуживать запросы, но не так активно.  Технику Jitter можно продемонстрировать с помощью следующих графиков: Здесь приведено количество вызовов сервиса без применения техники Jitter, и, как можно заметить, оно остаётся одинаковым при ретраях к сервису, что не снижает нагрузку на него.  Как видим, количество запросов к сервису снижается при применении техники Jitter, что даёт ему возможность обрабатывать запросы.  Для погружения в тему вы можете прочитать эту статью про Jitter и ознакомиться с разнообразными формулами, обеспечивающими рандомизацию между запросами. Все описанные техники выше хороши, но нужно всегда чётко понимать, что вам действительно нужно.  Если у вас есть метрики обработки запроса к сервису и по ним видно, что больше всего времени тратится на сохранение в БД, то вам нужно выяснить, что происходит именно в этой части, — может быть, достаточно добавить sync/async-реплики.  Если вы видите большое количество одновременно приходящих запросов от внутренних сервисов, попробуйте применить Jitter или просто увеличить количество подов приложения. Если вы получаете частые обращения к вашему сервису за справочными данными, оцените возможность внедрения кэширования. Я описал наиболее интересные моменты, которые могут быть полезны при подготовке системы к большим нагрузкам. Наша команда старается постоянно находить что-то новое, чтобы платежи всегда работали стабильно при увеличении нагрузки на Ozon.  Про RateLimiter Пригодится, чтобы реализовать алгоритм:  Библиотека для C# Библиотека для Go Полезная инфа о настройке с помощью nginx. Интересная статья о том, как ребята из Яндекса реализовали свой RateLimiter. Про проекции Описание паттерна Proxy Шардирование Реализация консистентного хеширования Шардирование. Про Jitter Статья про Jitter. Разнообразные формулы, обеспечивающие рандомизацию между запросами.