Привет, Хабр! Меня зовут Евгений Кузьмин, я Java‑разработчик в CDEK. Надеюсь, все знают, что это за компания и чем занимается. Давайте представим, что вам нужно отправить посылку с гостинцами родственнику в Москву из Новосибирска. Вы приходите в ближайший пункт приёма посылок и оформляете услугу доставки. Что же происходит дальше? Казалось бы, всё очевидно: посылка сразу летит или едет из Новосибирска в Москву. Но всё не так просто...

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

Оглавление

О системе

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

Строим пути из всех пунктов во все

Чтобы клиент смог отправить посылку в любой пункт системы, мы строим пути из всех пунктов во все.

Соответственно, для 3 пунктов имеем 6 направлений: A→B, A→C, B→A, B→C, C→A, C→B, для 10 — 90, для 10 000 — 99 990 000. Кол‑во направлений = N * N — N, где N — количество пунктов. Лучше всего представить данные в виде матрицы.

Матрица для тарифа ЭКОНОМ (стандартная по скорости доставка). Из Санкт-Петербурга в Новосибирск едем через Москву. Из Москвы в Новосибирск – через Казань, а из Казани уже прямиком в Новосибирск. Весь путь: Санкт-Петербург -> Москва -> Казань -> Новосибирск
Матрица для тарифа ЭКОНОМ (стандартная по скорости доставка). Из Санкт‑Петербурга в Новосибирск едем через Москву. Из Москвы в Новосибирск — через Казань, а из Казани уже прямиком в Новосибирск. Весь путь: Санкт‑Петербург → Москва → Казань → Новосибирск
Матрица для тарифа ЭКСПРЕСС (доставка быстрее, чем ЭКОНОМ). До Новосибирска летим прямиком! Да чтоб побыстрее!
Матрица для тарифа ЭКСПРЕСС (доставка быстрее, чем ЭКОНОМ). До Новосибирска летим прямиком! Да чтоб побыстрее!

Оперативно реагируем на изменения

Например, из‑за снегопада закрылась трасса M-11, и нужно выстроить логистику по другим путям. Или открывается новый крупный склад, который будет сортировочным центром для всего Кавказа. Соответственно, необходимо как можно быстрее изменить тысячи направлений.

Держим данные в согласованном виде

Если меняем путь до основного пункта, например, до Москвы, то нужно перестроить пути и до Подмосковья.

"СЛОЖНА"
«СЛОЖНА»

Если поменяли логистику до Москвы из Новосибирска, то до Звенигорода, Обнинска и Зеленограда — тоже. А если затронут Зеленоград, то и до Алабушево нужно поменять. Грубо говоря, один пункт может «наследоваться» от другого.

Как соблюсти все три условия? (волнительная музыка на заднем фоне тададам)

Как было раньше

До определённого момента прекрасно работал лёгкий и понятный способ: задачи на изменение вставали в очередь и только один поток обрабатывал их, чтобы параллельные потоки друг другу чего не попортили и дедлоки лукавые не насоздавали.

«Blurred» дедлок
«Blurred» дедлок

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

Но в какой‑то момент любое изменение данных стало 8 часов ждать своей очереди. Нехорошо!

Что предприняли

Сначала мы с командой привели архитектуру к строгой луковке и DDD. Здесь особо останавливаться не буду. Скажу только, что управлять более 50 use‑case стало возможным. Грубо говоря, сделали рефакторинг. Если вдруг будет кому интересно — пишите, вынесу в отдельную статью.

Потом я приступил к оптимизации. Сначала сделал так, чтобы каждый сценарий работал только со своей двумерной матрицей или в разрезе своего тарифа. Например, для изменения 5 000 направлений с тарифом «Эконом» и 5 000 с тарифом «Экспресс» начали создаваться 2 операции. Итого на бумажке ускоряем расчёт в N раз, где N — количество тарифов. Образно можно представить, что на картинке ниже все советчики взяли лопаты и принялись тоже копать =)

Далее подумал: «А зачем пересчитывать весь столбец матрицы на каждый чих? Будем изменять только нужные объекты!» И тут мне встретилась проблема декартового произведения.

Собственно, проблема

Проблема состоит в том, что изменения в основном идут по тройке ПУНКТ ОТКУДА + ПУНКТ КУДА + ТАРИФ. Клиент не знает UUID объектов. И если в какой‑то момент редактируется 10 тысяч направлений, то при стандартном запросе к БД через оператор IN для ПУНКТА ОТКУДА и ПУНКТА КУДА where node_from in (<UUID1>, <UUID2>, ... , <UUID10 тыс.>) and node_to in (<UUID1>, <UUID2>, ... , <UUID10 тыс.>) вернётся уже около 10 млн строк (количество node_from * количество node_to )! Карл, а нам нужно было всего 10 тысяч. Память в JVM не резиновая…

И тут я понял ещё одну хитрость предыдущего решения: всегда был только один ПУНКТ ОТКУДА или ПУНКТ КУДА в одной операции. И из БД доставались только нужные направления.

Пути решения

Но пути назад уже не было! Благо у нас в команде есть JOOQ (это я про технологию, если что) и из коробки можно писать множественный выбор where (node_from,node_to,tariff) = (<UUID1>, <UUID2>, <TEXT>) and (node_from,node_to,tariff) = (<UUID3>, <UUID4>, <TEXT>)... Но с нагрузкой на тесте что‑то пошло не так.

Далее попробовал сжать три поля (ПУНКТ ОТКУДА, ПУНКТ КУДА, ТАРИФ) в одно, по которому будет быстрый поиск. Выбор пал на хеш‑функцию. По тройке формируем неизменяемые хешы и в БД ищем по ним нужные строки. Да‑да, знаю, такое себе — в БД хранить хеш‑функцию, которая может меняться между версиями Java. Но допустим, мы люди консервативные и новомодные хеш‑функции использовать не будем. Останемся навсегда (ох уж это «всегда») на хеш‑функции 17/37.

Итого: запрос по 10 тысяч направлений конвертируется в 10 тысяч чисел, и с ними уже идём в БД. Но тут проницательный читатель заметит, что у хеш‑функции бывают коллизии из‑за ограниченности разрядности числового типа. И снова не беда! Отфильтруем полученную выборку из БД. Разок пробежаться по входящему списку не жалко =)

Хоть предыдущий вариант и был интересным, времени на задачу катастрофически не хватало, поэтому выбрал быстрый способ: делим редактируемые направления на батчи, чтобы произведение количества ПУНКТОВ ОТКУДА на количество ПУНКТОВ КУДА не было больше 1000. Это чисто эмпирическое число: несколько раз пробовал другие, но это показалось мне самым красивым, да и нумерология не против (дичькакаято). В итоге каждый батч достаёт терпимое количество лишних данных, которые благополучно фильтруются на стороне Java.

Практика — критерий истины

А теперь проверим, что я вам тут написал. Вспомним проблему: как по большому количеству троек ПУНКТ ОТКУДА + ПУНКТ КУДА + ТАРИФ эффективно доставать данные из БД. Появились четыре метода решения:

  1. С помощью JOOQ, где много блоков where. Назовём его «Жук».

  2. Конвертируем тройку в хеш‑функцию и ищем в БД. Дадим ему название «Хеш».

  3. Батчи с магическим числом 1000. Позывной — «Нумерология» («Нум»). Зачем бороться с декартовым произведением, когда можно дружить.

  4. Ничего не бить на батчи. Достаём по обычному where всё декартово произведение — позывной «Ничо».

Вспомним биологию за 7 класс и посмотрим на табличку запусков:

Метод

Количество входных параметров

Время

Лишние данные

Жук

10 000

15 с

-

Жук

100 000

1 м 31 с

-

Жук

1 000 000

10 м 3 с

-

Нум

10 000

1 м 3 с

312 903

Нум

100 000

10 м 31 с

17 129 108

Хеш

10 000

6 с

59

Хеш

100 000

18 с

574

Хеш

1 000 000

1 м 26 с

5 242

Ничо

10 000

4 м 41 с

7 684 134

  • Количество входных параметров — это сколько троек (ПУНКТ ОТКУДА, ПУНКТ КУДА и ТАРИФ) нужно найти в БД;

  • Время — сколько отрабатывала операция по поиску;

  • Лишние данные — сколько лишних строк приложение достаёт из БД. То есть во входных параметрах нет такого запрашиваемого направления.

В итоге «Ничо» занимает последнюю строчку нашего ТОПа. Уже на 10 000 входных записей я устал ждать. Боюсь представить, что будет на 100 000. Да и лишних данных извлёк аж в 768 раз больше, чем было во входном списке. Жёстко!

На третьем месте — «Нумерология». Тут я немного расстроился, ведь этот метод лично реализовал и внедрил, а он оказался далеко не самым лучшим: и лишних данных много тащит, и работает не так быстро. Что ж, уповаю на то, что никто до этой части статьи не дочитает. Или же, как модно сейчас говорить, «это моя зона роста» (добавим +10 к софт скиллам).

«Жук» вырывается на вторую позицию. Не знаю, что у меня в тесте пошло не так с этим решением, но результаты показывают, что это быстрое и практичное решение. Почти не требует доработки. В 10 раз быстрее «Нумерологии».

Как и ожидалось, «Хеш» занял первое место. Лишних данных извлекалось немного, и это говорит, что доля коллизий невелика. И время на порядок быстрее, чем у «Жука».

З.Ы. Помню, что Postgres кеширует страницы, поэтому на каждый прогон выбирались случайные входные данные из 25 млн тестовых направлений. Память и потребляемые ресурсы процессора на каждом запуске одинаковые: 1024 Мb и 1 CPU соответственно.

Резюме

Хотя решение с разбивкой на батчи и не самое оптимальное, и проблема с доставанием лишних данных не ушла, но зато память не заканчивается, когда на батчи вообще не бьём. В итоге после всех действий ожидание задачи в очереди снизилось с почти 8 часов до 7 минут. Результат неплохой! Но нужно помнить, что при развитии системы в будущем снова придётся сюда возвращаться и уже серьёзно решать проблему извлечения лишних данных. Кто знает, может и решение с хранением хеш-кода в БД подойдёт. А может, нужно будет уйти в сторону нереляционной базы данных. Пишите, пожалуйста, что вы думаете о задаче такого рода и как вам статья?

Комментарии (15)


  1. mashula_deg
    02.11.2023 09:44
    +4

    Было интересно, спасибо. Особенно нумерология и волшебное число 1000!

    Кто в теме, накидайте семерок


  1. ris58h
    02.11.2023 09:44
    +1

    Одному мне кажется что "смешные" картинки в статьях/презентациях это что-то из середины десятых? Конкретно в вашей статье они не добавляют ничего.


    1. kpy3no Автор
      02.11.2023 09:44
      +1

      +


  1. ris58h
    02.11.2023 09:44
    +2

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

    1. Маршруты A->B и B->A - это разные маршруты?

    2. Какие индексы есть в БД для таблиц с маршрутами?

    3. Каким образом использования хэша спасает от декартового произведения? A->B и B->A на одинаковом тарифе имеют одинаковый хэш?

    Upd:

    1. UUID - это прям UUID или цифра?


    1. ris58h
      02.11.2023 09:44

      Добавлю ещё:

      1. Откуда берутся лишние данные для решения с батчами?

      2. У вас же в БД хранятся все возможные маршруты из-в?

      Короче, вместо "смешных" картинок стоило нагляднее описать задачу и решение.


      1. kpy3no Автор
        02.11.2023 09:44
        +3

        Спасибо за комментарии! Постараюсь учесть в следующих статьях)

        1. да

        2. все подряд =) чуть ли не на каждое поле

        3. Значительно уменьшает лишние данные, но полностью проблему не убирает. Остаются коллизии. Так как хэш это число с ограниченной разрядностью. И кстати A->B и B->A на одинаковом тарифе имеют разные хэши (см. реализацию 17/37 https://www.baeldung.com/java-equals-hashcode-contracts)

        4. uuid - это прям uuid, но выглядит внутри как цифры 123e4567-e89b-12d3-a456-426655440000

        5. картинка в пункте (Собственно, проблема). Запрашиваем 3 направления, а из БД достается 9. Батчи просто ограничивают входящую выборку. Например, если всего 27 направлений, а размер батча 3, то выполним 9 итераций. При том, что на каждой будем доставать лишние данные. Просто не лезем сразу за всеми 27 направлениями. Иначе памяти не хватит

        6. да


        1. ris58h
          02.11.2023 09:44
          +1

          Мне на 10к фантазии не хватает. Вот есть 26 мест A-Z. Итого в БД 26х26 записей. Нам дали буквы A, B, C. Нужно вытащить 6 записей: AB, AC, BA, BC, CA, CB.

          1. Каким образом вы 3 буквы превращаете в 3 хэша, делаете с ними запрос, а потом получаете искомые 6 записей?

          1. Как у вас получаются лишние записи? У вас в запросе AND.

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


          1. kpy3no Автор
            02.11.2023 09:44
            +2

            1. Мы превращаем не три буквы в три хеша, а каждое направление в хэш. То есть для 6 записей-направлений: AB, AC, BA, BC, CA, CB мы сформируем 6 хэшей: (например) 1, 2, 3, 128, 18, 2. Как видими здесь есть повторения AC и CB имеют в моем примере одинаковый хэш. Имхо в БД еще несколько направлений имеют хеш 2. Например DZ. Соответственно по 6 хэшам запрашиваем строки. БД вернет, скажем, 9 из 6 нужных. Далее пробегаемся по полученным данным и выбрасываем ненужные (DZ).

              Вообще сценарий следующий: пользователь знает только направления и тариф, а мы по ним должны достать полноценный объект (там намного больше полей: на каком складе будет выгрузка, едем или летим и тд). И уже над объектом провести какие-то действия.

            1. На множественные where блоки (NODE_FROM,NODE_TO) = (<UUID1>, <UUID2>) AND (NODE_FROM,NODE_TO) = (<UUID3>, <UUID4>) лишние записи отсутствуют. Табличка в пункте "Практика — критерий истины". Метод жук не имеет лишних данных. А вот хэш как раз имеет, но очень немного.


            1. ris58h
              02.11.2023 09:44
              +1

              Вот теперь картина сложилась.

              "Жук" (как и "Хэш") всё тянет одним запросом? Если да, то почему не разбили "Жук" на батчи? Это самый очевидный вариант, как по мне.

              Остальные два способа - вообще какая-то дичь. Так и не понял зачем вообще их пробовать.


              1. kpy3no Автор
                02.11.2023 09:44
                +1

                Спасибо за крутые вопросы. Надеюсь статья теперь стала понятнее)

                Не одним запросом. Входные параметры бьются на батчи. А то в постгре есть лимит на входящие параметры, да и на саму длину sql запроса. В одном сценарии может происходить работа по 100 тыс. направлений. И в случае Хеша полезем в БД несколько раз (зависит от размера батча направлений).

                Дичь не дичь, но Нумерология в данный момент и работает по стране)

                Так как:
                Хеш реализовывать не быстро;
                с Жуком поймал проблему на тестовой нагрузке. Видать что-то другое стрельнуло, а я подумал на бедного жука.

                В итоге, все равно, ускорение хорошее получили.


                1. ris58h
                  02.11.2023 09:44

                  Вот видите сколько важных деталей вы в статье не рассказали.

                  Не могу понять что может быть не так с пачечной обработкой заданных направлений - это же не отличается от хешей, по сути. Разве что меньше данных передаётся в запросе, но не думаю, что это так критично. Индекс (from, to) есть а базе? Может в этом дело.


                  1. kpy3no Автор
                    02.11.2023 09:44

                    С пачечной обработкой все ок. Но как показал тест - метод с хешом намного быстрее, чем через множественный where блок (Жук метод).

                    индекс есть


                    1. ris58h
                      02.11.2023 09:44

                      Интересно в чём всё таки дело, если отличие только в хэше вместо тройки значений. Планы запросов не смотрели?


  1. rrash84
    02.11.2023 09:44

    Ни о чем. Познавательно, но не несет никакой практической пользы. Задача решается теорией графов немного сложнее институтской программы. Всегда можно заказать решение на кафедре НГТУ, если не хватает своих знаний.


    1. kpy3no Автор
      02.11.2023 09:44
      +3

      Можно было бы решить через теорию графов, но тогда нужно держать в согласованном виде граф и БД. А это дополнительная точка отказа. Как мне кажется одна из основных задач enterprise разработки - чтоб было все как можно проще и понятнее. Как молоток =) Взял в руку и понятно что делать.

      А я сам с НГТУ, получается заказали =)