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

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

Итак, что же сделали я и моя команда?

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

  1. Реализация "взять все, затем сопоставить"

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

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

/**
 * Expected result:
 * [
 *  {
 *    id: int,
 *    name: string,
 *    foo: string,
 *    items: Array<object>
 *  }
 * ]
 */
async function list() {
  /**
   * Expected result:
   * [
   *  {
   *    id: int,
   *    name: string,
   *    foo: string
   *  }
   * ]
   */
  const getListA = await this.modelA.getListA();

  /**
   * Expected result:
   * [
   *  {
   *    id: int,
   *    a_id: int, // id from table A
   *    title: string,
   *    bar: string
   *  }
   * ]
   */
  const result = [];
  for (const el of getListA) {
    const getListBbyAId = await this.modelB.getListBbyAId(el.id);
    result.push({
      ...el,
      items: getListBbyAId,
    });
  }

  return result;
}

В приведенном выше сценарии кода эта функция устанавливает соединения с базой данных 1 + n для обработки извлечения данных в сценарии, о котором я упоминал ранее. Это означает, что если в таблице A есть 100 записей, которые необходимо получить, потребуется 1 соединение для получения данных из таблицы A и 100 соединений для получения данных из таблицы B. Что, если записей 1000? Это, несомненно, создаст значительную нагрузку на базу данных и приведет к снижению производительности функции.

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

# before
SELECT id, a_id, title, bar FROM B WHERE a_id = ?

# after
SELECT id, a_id, title, bar FROM B WHERE a_id IN (?)

Результат такой, как показано в приведенном ниже коде сценария:

async function list() {
  const getListA = await this.modelA.getListA();

  // list id
  const aIds = [];
  for (const el of getListA) {
    aIds.push(el.id);
  }
  
  // get B data by list a_id
  const getListBbyAIds = await this.modelB.getListBbyAIds(aIds);

  const result = [];
  for (const el of getListA) {
    const temp = [];
    for (const el2 of getListBbyAIds) {
      if (el.id === el2.a_id) {
        temp.push(el2);
      }
    }
    
    result.push({
      ...el,
      items: temp,
    });
  }

  return result;
}

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

2. Преобразование от временной сложности n² к временной сложности 2n

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

Представьте, что данные, полученные из таблицы A, содержат 100 записей, а данные из таблицы B — 200 записей. Это означает, что функции потребуется выполнить цикл 100 раз для данных из таблицы A и 200 раз для сопоставления данных между таблицей A и таблицей B, в результате чего в общей сложности получится 20 000 циклов. Кратко это можно обозначить как временная сложность n². Очевидно, что в таком сценарии вышеуказанная функция все равно нуждается в дальнейшей оптимизации.

Итак, мы внесли улучшения в приведенный выше фрагмент кода, как показано ниже:

async function list() {
  ...
  
  // convert array of object to hashmap object 
  const hashMapItem = {};
  for (const el of getListBbyAIds) {
      if (!hashMapItem[el.a_id]) {
        hashMapItem[el.a_id] = [];
      }
      hashMapItem[el.a_id].push(el);
   }

  const result = [];
  for (const el of getListA) {
    result.push({
      ...el,
      items: hashMapItem[el.id],
    });
  }

  return result;
}

Таким образом, вместо выполнения 20 000 циклов приведенная выше функция теперь требует всего 100 + 200 циклов. В терминах обозначений теперь его временная сложность составляет всего 2n.

3. Реализация игнорировать

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

Например, у нас есть функция получения списка пользовательских данных, которые можно фильтровать по городу и району на основе адреса пользователя. Фильтры городов и районов представляют собой массивы, позволяющие фильтровать города A, B, C и одновременно фильтровать районы W в городе A, X в городе A, Y в городе B, Z в городе C и т. д. Короче говоря, эта функция может одновременно фильтровать n городов и m районов.

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

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

/user/list?city=A&city=B&city=C&district=W&district=X&district=Y&district=Z

Итак, какие потенциальные проблемы могут возникнуть в этом случае?

Представьте себе, что в системе зарегистрировано 100 городов, и в каждом из этих городов есть 100 районов. Теперь пользователь хочет отфильтровать 98 городов и только 9798 районов. Какой длины будет URL в этом сценарии?

Поскольку такие проблемы возможны, мы попытались реализовать метод «игнорировать». Вместо отправки 98 городов и 9798 районов в URL-запросе мы отправим только 2 города и 2 района с флагом, указывающим, какие данные фильтра следует игнорировать.

В результате URL, который будет отправлен внешним интерфейсом, будет выглядеть следующим образом:

/user/list?ignore_city=A&ignore_city=B&ignore_district=X&ignore_district=Y

И SQL-запрос будет следующим:

# before
SELECT id, name FROM users WHERE city IN (?) AND district IN (?)

# after
SELECT id, name FROM users WHERE city NOT IN (?) AND district NOT IN (?)

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

Увидимся в следующий раз!