Вечнорастущие массивы в MongoDB

В одном из рабочих проектов, который построен на связке Node.js + Mongoose + MongoDB, есть функция запуска долгих задач в фоне. В каком-то плане она напоминает то, как работают различные CI-системы (GitHub Actions, Gitlab CI и т. п.). Пользователь запускает задачу, она в фоне работает, а на фронт отдаётся её актуальный статус.

Когда эту функцию реализовывали, задачи были мелкие, и количество атомарных операций в них тоже было не очень большим (десятки). А потому помимо трекинга статуса была добавлена функция логирования действий. Таким образом, на фронте можно было зайти в задачу и почитать её лог. Удобно? Удобно.

Реализовано это было довольно просто. Для всех таких задач была одна коллекция. И внутри каждого элемента коллекции был массив log со списком произведённых операций.

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

Проблема была в том, что после каждой операции была примерно такая запись:

task.log.push('Created a user "Alex", ID = 7924');
await task.save();

task тут — Mongoose-документ. Mongoose отслеживает изменение массивов внутри документов и передаёт их в виде операций в MongoDB при сохранении. То есть, в этом случае получалось что-то такое:

db.tasks.updateOne(
  { _id: task._id },
  {
    $push: {
      logs: {
        $each: ['Created a user "Alex", ID = 7924']
      }
    }
  }
);

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

  • искала документ в базе (благо, по _id);
  • считывала его;
  • модифицировала там массив;
  • записывала обратно;
  • обновляла индексы (если нужно).

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

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

O(1) + O(2) + + O(n) = O( n 2 )

В реальности это всё не так, потому что на разных этапах есть различные кэши и оптимизации, да и то, сколько именно стоит та или операция нам неизвестно. Но для иллюстрации такая оценка подойдёт.

Такая реализация привела к нескольким проблемам:

  1. Каждая операция обновления элемента коллекции работала всё дольше и дольше.
  2. Node.js-тред, в котором работал этот скрипт порой падал из-за недостатка памяти.
  3. В какой-то момент массивы стали такими огромными, что документы с ними перестали влезать в ограничения MongoDB в 16 Мб.

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

Решение проблемы очень прозаичное — не писать в массив внутри объекта, а писать в отдельную коллекцию и привязывать к этому объекту ссылкой. Как только эта логика была переписана, таска, которая до этого занимала 2 часа, стала занимать 5 минут.

Можно проиллюстрировать это всё той же O-нотацией. Поскольку теперь каждый элемент коллекции добавляется сам по себе, не затрагивая другие, то суммарно операция стала обходиться в:

O(1) + O(1) + + O(1) = O(n)

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

Получается, не надо так.

Не надо так