Потоковый джоин в Проекте Э

June 15, 2023

При реинжинринге Проекта Э мы сохранили разделение БД, хранящих персональные и медицинские данные пользователей. Во имя соответствия ФЗ-152.

У меня до сих пор вызывает сомнения утверждение, что ФЗ-152 действительно требует раздельного хранения, но толком вникнуть в тему и предоставить контраргументов я не смог, поэтому поехали дальше как есть.

В БД персональных данных у нас ожидаются десятки тысяч строк, а в БД медицинских - десятки-сотни миллионов.

И ещё у нас есть админка, где заказчик хочет в одной выгрузке видеть объединённые данные по пользователю. С возможностью сортировки и фильтрации по колонкам из обоих баз. И выгрузкой в Excel с ограничением в максимальное кол-во строк в xlsx - 1М. А ещё есть выгрузка событий, где есть все те же прелести, плюс связь между базами данных - 1 к Н. Ну и в качестве вишенки на торте - ссылки между базами данных закодированы: строки медицинских данных ссылаются на персональные данные не по иду, а по коду, который вычисляется в приложении из ида.

Я долго грел голову как это сделать и не придумал ничего лучше, чем руками запилить стандартный для баз данных block nested loop join.

Что мы собственно и сделали. Упрощённо, всё это выглядит так:

  1. В зависимости от выбранной сортировки выбираем лидирующую БД;
  2. В лидирующей БД открываем курсор с нужной сортировкой, смещением страницы по ключу и фильтрацией по соответствующим колонкам;
  3. Достаём из этого курсора пачки строк (размер запрошенной страницы * 3);
  4. Делаем запрос к ведомой БД с фильтрацией по её колонкам плюс фильтрацией по ссылкам (чтобы они входили в иды строк из пачки предыдущего шага);
    1. В случае джоина 1 к Н, тут мы тоже открываем курсор, а не тянем всё сразу;
  5. Выполняем джоин строк лидирующей и ведомой БД;
  6. Если страница набралась - на выход, если нет - на шаг 3.

Далее, для вэба из этого стрима мы просто вибирали страницу записей, а для генерации xlsx-а - скармливали стрим с функцию, которая в потоковом же режиме перекладывала стрим в xlsx.

И в теории всё должно было быть прекрасно - в каждый момент времени у нас в памяти должно было быть не более 1000 строк.

Но на практике быстро оказалось, что что-то пошло не так и память куда-то зажиралась. Однако, даже эта версия работала в 300 раз быстрее версии на .net-е, реальных данных на проде было в районе 100 пользователей и нескольких тысяч событий и сроки поджимали, поэтому в прод решили пойти так и с памятью разобраться позже.

И вот недавно это позже пришло.

Я уже подобную штуку со стримингом делал и уже спотыкался об то, что он выжирал сильно больше памяти, чем планировалось. Тогда дело было в том, что Postgres JDBC-драйвер по дефолту весь запрос вытягивал в памать и проблема решилась установкой fetchSize.

Соотвественно я залез профайлером и ожидаемо память выжирал драйвер.

Однако, к моему удивлению установка fetchSize ситуацию не исправила. Нормальный человек полез бы в этот момент в гугл, но я не из этих и полез в дебаггер - и там выкапал, что fetchSize мало и надо ещё отключать автокоммит.

Вот мы собственно и подошли к морали сей байсни - если делаете потоковую выборку в Kotlin/Java из Postgres - не забывайте устанавливать fetchSize и отключать автокоммит. Мы это сделали создав отдельные датасорсы с отключенными автокоммитами и отдельные NamedJdbcOperations с проставленным fetchSize-ом.

Если делаете потоковую выборку не в Kotlin/Java/Postgres - перепроверяйте, что у вас нижние горошочки тоже работают в потоковом режиме.