Потоковый джоин в Проекте Э
June 15, 2023
При реинжинринге Проекта Э мы сохранили разделение БД, хранящих персональные и медицинские данные пользователей. Во имя соответствия ФЗ-152.
У меня до сих пор вызывает сомнения утверждение, что ФЗ-152 действительно требует раздельного хранения, но толком вникнуть в тему и предоставить контраргументов я не смог, поэтому поехали дальше как есть.
В БД персональных данных у нас ожидаются десятки тысяч строк, а в БД медицинских - десятки-сотни миллионов.
И ещё у нас есть админка, где заказчик хочет в одной выгрузке видеть объединённые данные по пользователю. С возможностью сортировки и фильтрации по колонкам из обоих баз. И выгрузкой в Excel с ограничением в максимальное кол-во строк в xlsx - 1М. А ещё есть выгрузка событий, где есть все те же прелести, плюс связь между базами данных - 1 к Н. Ну и в качестве вишенки на торте - ссылки между базами данных закодированы: строки медицинских данных ссылаются на персональные данные не по иду, а по коду, который вычисляется в приложении из ида.
Я долго грел голову как это сделать и не придумал ничего лучше, чем руками запилить стандартный для баз данных block nested loop join.
Что мы собственно и сделали. Упрощённо, всё это выглядит так:
- В зависимости от выбранной сортировки выбираем лидирующую БД;
- В лидирующей БД открываем курсор с нужной сортировкой, смещением страницы по ключу и фильтрацией по соответствующим колонкам;
- Достаём из этого курсора пачки строк (размер запрошенной страницы * 3);
- Делаем запрос к ведомой БД с фильтрацией по её колонкам плюс фильтрацией по ссылкам (чтобы они входили в иды строк из пачки предыдущего шага);
- В случае джоина 1 к Н, тут мы тоже открываем курсор, а не тянем всё сразу;
- Выполняем джоин строк лидирующей и ведомой БД;
- Если страница набралась - на выход, если нет - на шаг 3.
Далее, для вэба из этого стрима мы просто вибирали страницу записей, а для генерации xlsx-а - скармливали стрим с функцию, которая в потоковом же режиме перекладывала стрим в xlsx.
И в теории всё должно было быть прекрасно - в каждый момент времени у нас в памяти должно было быть не более 1000 строк.
Но на практике быстро оказалось, что что-то пошло не так и память куда-то зажиралась. Однако, даже эта версия работала в 300 раз быстрее версии на .net-е, реальных данных на проде было в районе 100 пользователей и нескольких тысяч событий и сроки поджимали, поэтому в прод решили пойти так и с памятью разобраться позже.
И вот недавно это позже пришло.
Я уже подобную штуку со стримингом делал и уже спотыкался об то, что он выжирал сильно больше памяти, чем планировалось. Тогда дело было в том, что Postgres JDBC-драйвер по дефолту весь запрос вытягивал в памать и проблема решилась установкой fetchSize.
Соотвественно я залез профайлером и ожидаемо память выжирал драйвер.
Однако, к моему удивлению установка fetchSize ситуацию не исправила. Нормальный человек полез бы в этот момент в гугл, но я не из этих и полез в дебаггер - и там выкапал, что fetchSize мало и надо ещё отключать автокоммит.
Вот мы собственно и подошли к морали сей байсни - если делаете потоковую выборку в Kotlin/Java из Postgres - не забывайте устанавливать fetchSize и отключать автокоммит. Мы это сделали создав отдельные датасорсы с отключенными автокоммитами и отдельные NamedJdbcOperations с проставленным fetchSize-ом.
Если делаете потоковую выборку не в Kotlin/Java/Postgres - перепроверяйте, что у вас нижние горошочки тоже работают в потоковом режиме.