Помните об оптимизации проекта Node.

Node.js Redis задняя часть сервер

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

задний план

Во-первых, этот проект был несколько лет назад, за это время добавились новые требования, что усложнило логику кода, а также увеличилось время отклика интерфейса.
До этого была оптимизация под серверную среду(обновление версии узла), производительность действительно значительно улучшилась, но в соответствии с концепцией «молодость — это смерть», на этот раз мы снова оптимизируем ее на уровне кода.

Связанная среда

Поскольку это проект нескольколетней давности, я используюExpress+coТакой.
из-за раннегоNode.jsВерсия4.x, то асинхронная обработка используетyield+generatorсделано таким образом.
действительно по сравнению с некоторыми более раннимиasync.waterfallДругими словами, читаемость кода уже очень высока.

Что касается хранения данных, поскольку это некоторые данные с высокими требованиями к реальному времени, все данные взяты изRedis.
Node.jsИз-за обновления некоторое время назад версия теперь8.11.1, что делает разумным использование нового синтаксиса для упрощения кода.

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

несколько советов

В этом примечании по оптимизации не будет ничегоprofileОтображение файлов.
В этот раз я не стал полагаться на анализ производительности для оптимизации, а просто добавил время отклика интерфейса, и сравнил полученные результаты после подведения итогов. (асинхронная запись в файлappendFileметки времени начала и окончания)
в соответствии сprofileОптимизацию можно проводить в три этапа.
profileОн в основном используется для поиска таких проблем, как утечка памяти и размер памяти стека вызовов функций, поэтому эта оптимизация не рассматривается.profileиспользование
И я лично не думаю, что имеет смысл публиковать так мало снимков памяти (в этой оптимизации), лучше придумать какое-то реальное сравнение кода до и после оптимизации.

Несколько оптимизаций

Вот список мест, задействованных в этой оптимизации:

  1. Некоторые неразумные структуры данных (используемая поза проблематична)
  2. Последовательный асинхронный код (что-то вродеcallbackадский формат)

Оптимизация, связанная со структурой данных

Упомянутые здесь структурыRedisСвязанный, в основном относится к реализации частичной фильтрации данных
Фильтрация в основном отражена в некоторых интерфейсах данных списка, потому что некоторые операции, такие как фильтрация, должны выполняться в соответствии с бизнес-логикой:

  1. Отфильтрованная ссылка взята из другого сгенерированного набора данных.
  2. Отфильтрованная ссылка исходит от Redis

На самом деле, первые данные такжеRedisСгенерировано. :)

Отфильтровать оптимизации из другого источника данных

Как и в первом случае, в коде это может выглядеть так:

let data1 = getData1()
// [{id: XXX, name: XXX}, ...]

let data2 = getData2()
// [{id: XXX, name: XXX}, ...]

data2 = data2.filter(item => {
  for (let target of data1) {
    if (target.id === item.id) {
      return false
    }
  }

  return true
})

Есть два списка, чтобы данные из первого списка не отображались во втором списке.
Конечно, оптимальным решением должно быть то, что сервер не обрабатывает его, а клиент выполняет фильтрацию, но это теряет гибкость и затрудняет совместимость со старыми версиями.
Приведенный выше код проходитdata2попытается перебрать каждый элемент вdata1, а затем сравните их.
Недостатком этого является то, что итератор будет каждый раз регенерироваться, и поскольку суждениеidсвойства, мы будем каждый раз искать свойства объекта, поэтому оптимизируем код следующим образом:

// 在外层创建一个用于过滤的数组
let filterData = data1.map(item => item.id)

data2 = data2.filter(item =>
  filterData.includes(item.id)
)

Таким образом, мы пересекаемdata2в самый разfilterDataобъект называетсяincludesВыполняйте поиск вместо создания каждый раз нового итератора.
Конечно, на самом деле в этой области еще есть место для дальнейшей оптимизации, потому что мы создали вышеfilterDataНа самом делеArray,ЭтоList,использоватьincludes, можно считать, что его временная сложность равнаO(N)сейчас,Nзаlength.
Таким образом, мы можем попытаться поставить вышеуказанноеArrayпереключиться наObjectилиMapобъект.
Поскольку последние два принадлежатhashструктуры, поиск этой структуры можно рассматривать как временную сложностьO(1)сейчас,да или нет.

let filterData = new Map()
data.forEach(item =>
  filterData.set(item.id, null) // 填充null占位,我们并不需要它的实际值
)

data2 = data2.filter(item =>
  filterData.has(item.id)
)

P.S. Я обсудил этот вопрос с коллегами и провел тестовый скрипт-эксперимент, который доказал, что при оценке существования элемента на большом количестве данныхSetа такжеArrayпроизводительность является худшей, в то время какMapа такжеObjectПлоский.

О фильтрации из Redis

Что касается этой фильтрации, необходимо рассмотреть возможность оптимизацииRedisСтруктура данных, как правило,Set,SortedSet.
НапримерSetперечислитьsismemberсудитьitemон существует,
илиSortedSetперечислитьzscoreсудитьitemон существует(Есть ли соответствующийscoreценность)

Вот где возникает компромисс, если мы используем два вышеуказанных метода в цикле.
заключается в том, что вы должны получить всеitem, непосредственно в памяти, чтобы определить, существует ли элемент
Или вызовите его последовательно в циклеRedisчтобы получитьitemОн существует?

Вот несколько небольших предложений для справки
  • еслиSortedSet, рекомендуется использовать в циклеzscoreСуждение (на этот раз сложностьO(1))
  • еслиSet, если известноSetМощность в основном больше, чем количество циклов, ее рекомендуется использовать в циклеsismemberсудить
    Если код будет повторяться много раз, иSetМощность невелика и может быть вынесена и использована вне цикла (smembersВременная сложностьO(N),Nмощность множества)И, еще один момент, затраты на передачу по сети также должны быть включены в наши компромиссы, потому что такие вещи, какsismbersВозвращаемое значение просто1|0,а такжеsmembersпередам всю коллекцию
Два практических сценария о Set
  1. Если теперь есть список данных, некоторые данные необходимо отфильтровать для некоторых провинций.
    Мы можем выбрать извлекать все значения из коллекции во внешний слой цикла, а затем судить и фильтровать непосредственно по объектам в памяти внутри цикла.
  2. Если данные этого списка должны быть занесены в черный список для пользователей, учитывая, что некоторые пользователи могут заблокировать многих людей, этоSetСложно оценить мощность , поэтому рекомендуется использовать метод внутрициклового суждения.

Снижение затрат на передачу по сети

Положите конец злоупотреблению гашишем

Действительно, используйтеhgetallЭто очень беззаботная вещь, несмотря ни на чтоRedisэтоHashЧто бы ни было в нем, я получу это.
Однако это вызывает некоторые проблемы с производительностью.
Например, у меня естьHash, структура данных следующая:

{
  name: 'Niko',
  age: 18,
  sex: 1,
  ...
}

Теперь в интерфейсе списка нам нужно использовать этоhashсерединаnameа такжеageполе.
Самый простой способ:

let info = {}
let results = await redisClient.hgetall('hash')

return {
  ...info,
  name: results.name,
  age: results.age
}

существуетhashВ малых случаях,hgetallне повлияет на производительность.
Но когда нашhashКогда число большое, такоеhgetallокажет большое влияние.

  1. hgetallВременная сложностьO(N),Nзаhashразмер
  2. Не говоря уже о временной сложности выше, мы на самом деле использовали толькоnameа такжеage, и прочие значения, передаваемые по сети, на самом деле пустая трата

Итак, нам нужно изменить аналогичный код:

let results = await redisClient.hgetall('hash')
// == >
let [name, age] = await redisClient.hmget('hash', 'name', 'age')

P.S. ЕслиhashКоличество предметов будет меняться после определенной суммыhashструктура хранения,
использовать в это времяhgetallпроизводительность будет лучше, чемhmget, может быть просто понято как 20 или меньшеhmgetэто все хорошо

Оптимизация, связанная с асинхронным кодом

отcoначиная с сейчасasync,await,существуетNode.jsАсинхронное программирование становится очень понятным в , мы можем писать асинхронные функции в следующем формате:

async function func () {
  let data1 = await getData1()
  let data2 = await getData2()

  return data1.concat(data2)
}

await func()

Выглядит очень удобно, правда?
Когда вам удобно, программа тоже удобна.Программу можно толькоgetData1Он будет выполнен после получения возвращаемого значенияgetData2, а затем застрял в процессе ожидания обратного вызова.
Это очень распространенное место для злоупотребления асинхронными функциями. Изменил асинхронный на последовательный, потерялNode.jsПреимущества в качестве асинхронного потока событий.

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

async function func () {
  let [
    data1,
    data2
  ] = await Promise.all([
    getData1(),
    getData2()
  ])
}

Такой подход позволил быgetData1а такжеgetData2Запросы отправляются одновременно, а результаты обратного вызова обрабатываются единообразно.

В идеале мы должны отправлять все асинхронные запросы вместе и ждать результатов.

Однако добиться этого, как правило, невозможно, так как в приведенных выше примерах нам, возможно, придется вызывать в циклеsismemberили один из наших наборов данных зависит от фильтрации другого набора данных.
Вот еще один компромисс.Как и в примере этой оптимизации, есть два набора данных, один с данными фиксированной длины (одиночные цифры), а второй с данными нефиксированной длины.

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

В это время асинхронный вызов первой коллекции займет много времени, и если мы не отфильтруем по первым данным при сборе данных второй коллекции, это вызовет некоторые недопустимые запросы (дублирование сбора данных).
Но после сравнения я все еще чувствую, что более рентабельно заменить два на одновременные.
Как упоминалось выше, номер первого набора примерно однозначный, то есть, даже если второй набор повторяется, он не будет повторять много данных Сравнивая два, мы решительно выбираем параллелизм.
Получив два набора данных, возьмите первый набор для фильтрации данных второго набора.
Если время асинхронного выполнения двух не сильно отличается, такая оптимизация может в основном сэкономить40%стоимость времени (Конечно, недостаток в том, что давление на поставщика данных увеличится вдвое.).

Дополнительные преимущества замены последовательного на параллельный

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

постскриптум

В целом эта оптимизация заключается в следующих моментах:

  1. Разумное использование структур данных (хорошее использованиеhashструктура для замены некоторыхlist)
  2. уменьшить ненужные сетевые запросы (hgetall to hmget)
  3. Измените последовательный на параллельный (включая асинхронные события)

И только что выпущенная свежая сравнительная таблица времени отклика интерфейса: