Контекст

Lifen предоставляет медицинским работникам инновационное приложение, упрощающее и безопасный обмен медицинскими документами.

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

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

  • импортировать данные
  • определить различия, которые порождают События
  • применить События к нашим существующим данным

Недавно мы столкнулись с очень интересной проблемой производительности: весь процесс обновления из всех наших источников данных должен занимать менее 24 часов (логическое условие, если мы хотим обновлять наш каталог ежедневно). Поэтому мы уделяем большое внимание выступлениям, а точнее времени, необходимому для обработки наших событий.

Все мы знаем, что использование each при работе с коллекциями ActiveRecord может быть опасным, поскольку оно будет загружать все события в память:

collection.each do |event|
  process(event)
end

Распространенное решение - разделить процесс этих событий на более мелкие «подсвязи». Вот что мы делали в начале Nestor:

collection.find_each do |event|
  process(event)
end

И, честно говоря, этот шаблон работает довольно хорошо, если мы сосредоточимся только на памяти, используемой Ruby. В нашей ситуации проблема заключалась в времени, проведенном в базе данных, поскольку магия find_each заключается в многократном вызове БД небольшими партиями.

Эта проблема

Все наши основные ресурсы (практики и организации) имеют один или несколько идентификаторов. Идентификатор принадлежит ресурсу и имеет system и value (мы используем FHIR как нашу уникальную модель данных, мы не хотели изобретать велосипед в этой теме!).

У нас также есть таблица событий, 12 миллионов строк, которая довольно быстро растет (мы отслеживаем в среднем 5 000 модификаций в день, а также всплеск некоторых случайных событий). Все события связаны с идентификатором с помощью ключей system и value.

Когда мы обрабатываем наши События для указанной версии, мы делаем что-то вроде:

Проблема, с которой мы столкнулись, заключается в том, что исходная выборка событий (Event.where(version: 18)) содержит много элементов (возьмем 10 000). При размере пакета в 1000 элементов (значение по умолчанию в Rails) это означает, что мы будем запускать в 10 раз более дорогостоящий объединенный и упорядоченный запрос:

  • первый будет похож на SELECT * FROM events LEFT JOIN identifiers ORDER BY events.id LIMIT 1000;
  • следующий будет SELECT * FROM events LEFT JOIN identifiers ORDER BY events.id LIMIT 1000 OFFSET 1000;
  • … и так далее

ActiveRecord должен использовать ORDER BY events.id, чтобы убедиться, что мы не обрабатываем одно и то же событие дважды, и это довольно дорого для объединенной таблицы (у нас были запросы за секунды ...).

Решение

Чтобы избежать объединения таблиц во время итерации, нам нужно выполнять итерацию только по необработанной таблице событий. Как только мы получим подгруппу, мы, наконец, сможем применить условие соединения (WHERE identifiers.active = 't').

Вот решение, которое мы начали использовать в качестве расширения для метода in_batches, заменяющего find_each:

Один крайний случай, который мы заметили на раннем этапе, - это когда условие соединения было довольно ограничительным (например, менее 10% от начальных выбранных событий), что приводило к пакетной обработке пустых коллекций.

Если в объединенной (и отфильтрованной) коллекции у нас меньше 10% элементов несоединенной коллекции, мы используем шаблон ActiveRecord по умолчанию, чтобы минимизировать количество пакетов.

В противном случае мы переключимся на метод with_in_batches, который применяет только join и where к подгруппе.

Чтобы лучше справиться с этим пограничным случаем, мы ввели небольшую вариацию нашего FindInBatches сервиса:

В методе find мы начинаем с подсчета количества элементов в объединенной коллекции и количества элементов в несоединенной коллекции.

Как указано в его названии JOIN_COST_ARBITRARY_COEF, это чисто произвольное значение, основанное на времени запроса, которое мы отслеживали в обеих ситуациях.

Реплика: «Вы сказали, что не хотите присоединяться к пакету. Но in_batches возвращает отношение ActiveRecord, и вы применяете к нему соединение, так что в конце у вас есть отношение пакетной обработки с объединением, как если бы вы использовали find_in_each '

Уловка в том, что in_batches не дает «классической» связи. Он дает отношение, но на самом деле он будет использовать не само отношение, а первичные идентификаторы, полученные в результате отношения. Вот почему окончательное отношение не будет отношением пакетной обработки, а будет ограничиваться только списком идентификаторов.

Вы можете прочитать его реализацию Rails, чтобы полностью понять ее.

Вот небольшой обзор наших ежедневных технических задач. Если вы хотите узнать больше о Lifen и наших текущих вакансиях, не стесняйтесь обращаться к нам по адресу [email protected].

Большое спасибо Matthieu Paret за его практическую оптимизацию производительности при решении этой проблемы.