При создании сервера API возникает множество проблем независимо от используемой технологии. Большинства из них можно избежать, но я все равно наблюдаю, как профессиональные инженеры, обладающие десятилетиями опыта, из года в год бьются над решением одних и тех же вопросов.
Разберем ловушки, связанные с производительностью баз данных. Мы поговорим о том, какие ошибки можно совершить, как их заметить и исправить, а также какие профилактические меры следует предпринять.
Ошибка № 1. Запрос информации, которая не меняется
Когда я создавал REST API Avalara AvaTax, мне нужно было разрешить пользователям отправлять адреса. В этих данных царил беспорядок, и иногда мне присылали код страны в соответствии со стандартом ISO, или название страны, или псевдоним. Я мог справиться с этим, поскольку на GitHub есть множество источников данных о странах, доступных по разрешительным лицензиям, но в итоге решил заплатить за официальный список кодов стран в соответствии со стандартом ISO 3166.
Следующий шаг заключался в том, чтобы API-сервер загружал эти данные при запуске. Код не обязательно должен быть сложным. Ниже привожу псевдокод на C#, который примерно показывает, как это работает:
private static Task<List<Country>>? _cachedQuery = null;
private Task<List<Country>> GetCachedCountries()
{
// Сохранение промиса в статической переменной
if (_cache == null) {
_cache = Database.Countries.ToListAsync();
}
// Все вызывающие выполняют присоединение к тому же промису
return _cache;
}
Почему я поступил именно так? К счастью, новые страны появляются нечасто. Если бы список стран изменился, можно было бы отправить SQL-скрипт во время ежемесячного развертывания приложения, чтобы добавить новую запись.
Вместо того чтобы запрашивать таблицу в базе данных, мой сервер API на C# хранил эти данные в синглтоне. Он искал нужные имена при вводе или выводе данных. Данные занимали всего несколько килобайт, а для удобства у меня было несколько хэшированных словарей, нечувствительных к регистру.
У вас наверняка есть десятки подобных статических наборов данных. Рекомендую хранить наборы данных для поиска, коды ответа, флаги конфигурации в статическом синглтоне! Если забудете об этом, ваша система станет делать тысячи ненужных запросов в секунду к данным, которые никогда не меняются.
Ошибка № 2. Проверка страницы состояния, которая чрезмерно использует базу данных
API-серверу необходима система проверки состояния. Эта система может представлять собой страницу или API, но она должна проводить набор базовых тестов функциональности, чтобы убедиться в том, что компьютер способен выполнять свою работу. Типичные тесты включают ответы на следующие вопросы:
- Есть ли у меня корректные файлы конфигурации?
- Могу ли я связаться с нужными мне внешними сервисами, или меня блокирует фаервол?
- Работает ли мой сервер с правильными учетными данными и разрешениями?
- Валидны ли строки подключения к базе данных?
Такие проверки состояния необходимы для запуска сервера в составе группы автомасштабирования или для использования шаблона запуска с контейнером. Важно тщательно проверить все перед развертыванием сервера — было бы глупо запустить компьютер, на котором отсутствует строка подключения к базе данных.
Побочным эффектом этих проверок состояния является то, что они часто используются и для мониторинга общего состояния сервера после развертывания. Некоторые облачные сервисы могут вызывать эту страницу состояния несколько раз каждую минуту и удалять сервер из балансировщика нагрузки при отсутствии ответа. Если страница состояния выполняет запрос в рамках этой проверки, это может быстро стать серьезной нагрузкой на базу данных.
Как вы понимаете, очень важно проверять подключение к базе данных при запуске. Но после успешного развертывания сервера очень маловероятно, что валидное соединение с базой данных вдруг перестанет быть таковым позже. Считаю, что лучше всего кэшировать успешный результат на короткий период времени, например 30 секунд. Таким образом, проверка состояния будет способна вывести проблемный сервер из ротации, но при этом не будет перегружать базу данных:
public static DateTime LastCheckTime = DateTime.MinValue;
public const int SECONDS_FOR_RETEST = 30;
public static bool Status()
{
var now = DateTime.UtcNow;
var timeSinceLastCheck = now - LastCheckTime;
if (timeSinceLastCheck.TotalSeconds > SECONDS_FOR_RETEST) {
... do some database health checks here ...
LastCheckTime = now;
}
return true;
}
Ошибка № 3. Аутентификация API со слишком большим количеством запросов
Большинство активных пользователей API будут быстро выполнять множество запросов. В случае каждого запроса серверу необходимо проверить, прошел ли пользователь аутентификацию и имеет ли он право выполнять запрошенную работу. Многие из этих проверок требуют получения данных из базы данных, а именно:
- получения статуса пользователя и учетной записи;
- проверки разрешений пользователя;
- получения конфигурации или предпочтений.
Может показаться естественным проделывать все указанные действия при каждом запросе, но получение этой информации отнимает много времени. К счастью, есть способ исправить проблему медленных запросов аутентификации к базе данных: когда инициатор вызова делает запрос, вы можете кэшировать его учетные данные на короткий промежуток времени.
Кэширование авторизации может показаться пугающим, поскольку изменения не являются мгновенными, но на практике «мгновенность» трудно определить. Если вызов API был в активном состоянии до того, как вы отозвали доступ, пользователю может быть разрешено или не разрешено сделать запрос на основе удачного случая — независимо от того, был ли получен вызов API до отзыва.
Если мы обновим документацию и внесем туда следующее положение: «После изменения привилегий пользователя предоставьте 5 минут, чтобы все серверы с новыми разрешениями обновились», — тогда можно планировать производительность! Хитрость здесь заключается в хэшировании токена носителя API-вызова плюс его IP-адреса и поиске всех данных аутентификации и авторизации в кэше.
- Сначала проверьте хэш-таблицу в памяти сервера. Это займет 10-20 микросекунд.
- Если токен не находится в кэше памяти сервера, проверьте REDIS или другой эквивалентный сервер пар «ключ-значение». Это займет 1-2 миллисекунды.
- Если значение не найдено ни в одном из кэшей, создайте промис для получения необходимых данных. Если промис уже существует, присоедините его, чтобы не выполнять несколько запросов.
- Если аутентификационные данные старше определенного возраста, запустите новый промис для повторного получения данных, чтобы они были готовы к тому времени, когда старые данные удалятся из кэша по истечению срока хранения.
Ошибка № 4.Средства объектно-реляционного отображения, которые осуществляют запросы в цикле
Современные технологии, такие как Entity Framework, позволяют очень легко обращаться к базе данных. Настолько легко, что можно написать метод, выполняющий вызов базы данных. Может оказаться, что многие используют тот же самый метод, не подозревая, что он обращается к базе данных.
Простой пример:
public async Task<int> GetNumberOfUsers(int id) {
var count = 0;
var items = await _database.GetRecords(id);
foreach (var item in records) {
count += CountUsersPerItem(item);
}
return count;
}
Этот код кажется на первый взгляд обычным, но поскольку метод CountUsersPerItem
обращается к базе данных (для получения флага или запроса к подтаблице), вы можете обнаружить, что то, что казалось одним запросом, превращается в сотни или тысячи запросов.
Хуже того — производительность этой функции может казаться нормальной на компьютере разработчика, но может внезапно дать сбой, когда реальные клиенты столкнутся с такой же ситуацией.
Я нашел несколько приемов, которые помогают отследить эту проблему.
- Увеличивайте счетчик в текущем стеке вызовов API, который подсчитывает количество вызовов базы данных на один запрос API. Записывайте эту информацию в журнал, а затем отслеживайте вызовы API, которые выполняют необычно большое количество запросов.
- Отслеживайте производительность базы данных с помощью такого инструмента, как Activity Monitor, и следите за внезапными «всплесками» тысяч чрезвычайно быстрых запросов. Затем оптимизируйте их, заменив вложенные запросы одним, который возвращает все необходимые данные.
- Стандартизируйте стратегию именования, при которой каждый метод, обращающийся к базе данных, должен иметь в своем названии слово
Query
, напримерCheckStatusQuery()
для метода, обращающегося к базе данных.CheckStatus()
делает то же самое, но без запроса.
Ошибка № 5. Игнорирование запроса по причине его быстроты
Эта проблема весьма коварна. Современные технологии баз данных настолько мощны, что простой запрос к базе данных часто может быть таким же или более быстрым, чем запрос к REDIS. Разработчики, работающие локально, нередко наблюдают высокую производительность, потому что у них нет задержек между приложением и сервером базы данных, которые работают в контейнерах на их ноутбуках.
Даже если экземпляр SQL Server или Postgres отвечает за одну миллисекунду, эти миллисекунды могут суммироваться. Если API-запрос делает десять одномиллисекундных запросов, это может увеличить задержку API на десять миллисекунд — не такая уж незначительная величина, когда среднее время ожидания составляет менее ста миллисекунд.
Важнейшим выводом здесь является то, что при разработке API каждый запрос к базе данных имеет значение. Уделяйте им внимание, и ваш API будет быстрым и функциональным.
Читайте также:
- Советы по созданию хорошего дизайна API
- Осваиваем NestJS: построение эффективного бэкенда REST API
- Создание API в R при помощи Plumber
Читайте нас в Telegram, VK и Дзен
Перевод статьи Ted Spence: Five common database performance mistakes in API development