Главная » Хабрахабр » Ускорение SQLAlchemy для архитектурных космонавтов

Ускорение SQLAlchemy для архитектурных космонавтов

Видео в конце поста. Хабр, это доклад инженера-программиста Алексея Старкова на конференции Moscow Python Conf++ 2018 в Москве.

Всем привет! Меня зовут Алексей Старков — это я, в свои лучшие годы, работаю на заводе.
Теперь я работаю в Qrator Labs. В основном, всю свою жизнь, я занимался C и C++ — люблю Александреску, «Банду Четырех», принципы SOLID — вот это всё. Что и делает меня архитектурным космонавтом. Последние пару лет пишу на Python, потому что мне это нравится.

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

Главное — как я это поборол. Я тоже имею тенденции к архитектурной космонавтике, но в этом докладе я расскажу немного о том, как это меня укусило и не позволило построить систему с необходимой производительностью.

Краткое содержание моего доклада: было / стало.

Когда я сделал этот слайд, единственная мысль, которая у меня возникла, это: «Как?» Увеличение в тысячи и миллионы раз.

Если вы не хотите накосячить так же как я — читайте дальше. Где я мог так сильно накосячить?

Система конфигурации — это внутренний инструмент в Qrator Labs, которая занимается тем, что хранит конфигурацию для Software Defined Network (SDN) — нашей сети фильтрации. Я буду рассказывать про систему конфигурации. Она занимается тем, что синхронизирует конфигурацию между компонентами и отслеживает ее состояние.

У нас есть база данных, в которой хранится слепок нашей конфигурации для всей сети, и есть сервер, который обрабатывает команды, приходящие на него и каким то образом меняет конфигурацию. Из чего она, вкратце, состоит?

Наши технические администраторы и клиенты приходят в этот сервер и с помощью консоли, через конечные API эндпойнты, REST API, JSON RPC и прочего выдают команды к серверу, вследствие чего он меняет нашу конфигурацию.

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

Поскольку именно она имеет отношение к базе данных и к алхимии.

Она достаточно небольшая — средненькая. В чем особенность этой системы? Особенность в том, что граф отношений между сущностями довольно сложный. Сотни тысяч, до миллионов, сущностей хранятся в этой базе. Все эти ограничения обусловлены бизнес-логикой и мы их должны соблюдать. Есть несколько иерархий наследования между сущностями, есть включения, есть просто зависимости между ними.

Тут понятно: приходит много команд на изменение конфигурации и раз в некий продолжительный период времени у нас происходит пуш конфигурации на конечные точки. Соотношение запросов на запись к запросам на чтение составляет примерно 15:1.

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

Выполнение одной команды занимало от одной до тридцати секунд, в зависимости от сложности команды. В чем возникла проблема после того, как мы спроектировали эту систему? Одна команда пришла — 30 секунд, вторая и так далее, стопку накопили — задержка 5 минут. Соответственно, задержка выполнения доходила до пяти минут.

Было принято решение, что для нас этого недостаточно и необходимо провести оптимизацию. Задержка на применение конфигурации — до десяти минут.

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

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

Можно анализировать логи. Что можно сделать следующим, если у вас нет телеметрии? Начиная отсюда, мы уже видим, в каких местах у нас затыки: какие команды выполняются дольше, какие — быстрее. Здесь достаточно простые скрипты проходят наши логи и превращают их вот в такую таблицу, иллюстрирующую самое быстрое, самое медленное и среднее время выполнения команды.

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

t1 является метрикой качества нашего быстродействия.

Сервер построен достаточно модулярно, за каждую команду отвечает отдельный компонент, и мы можем профилировать компоненты индивидуально — и делать для них бенчмарки. Соответственно, так мы все команды попрофилировали — то есть взяли лог с сервера, прогнали его через наши скрипты, посмотрели и выявили компоненты, работающее наиболее медленно. И было два метода: profile() и bench(). Такой вот у нас был класс — для каждого проблемного компонента мы писали, в котором в code_under_test() мы делали некую активность, изображающую боевое использование компонента. Первый вызывает cProfile, показывая сколько раз что вызывалось, где бутылочные горлышки.

bench() запускался несколько раз и считал нам разные метрики — так мы оценивали производительность.

Но, оказалось, что проблема то не в этом!

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

Они объединены в некоторую группу — receiver group. Перед нами кусочек простой схемы, представляющей наши приемники, представленные в виде класса Reciever. Например, для раутинга — routing plane. И, соответственно, есть какие-то конфигурационные плейны — срезы конфигурации, представляющие собой подмножество конфигураций, отвечающие за одну «роль» этого ресивера. Плейны с ресиверами могут быть связаны в любом порядке — то есть это отношение «многие ко многим».

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

Он хочет спрятать его, абстрагировать и написать свой интерфейс, для того чтобы можно было убрать этот API, вернее спрятать его. Что хочет сделать каждый архитектурный космонавт, когда он видит чужой API?

И все остальные, связанные с ним классы. Соответственно, есть «грязный» API алхимии, в котором находятся, собственно, мэпперы и наш «чистый» класс — Receiver, в котором хранится какая-то конфигурация и есть методы: load(), save(), delete(). У нас получается граф питоновских объектов, как-то связанных между собой — у каждого из них есть метод load(), save(), delete(), который обращается к мэпперу алхимии, который, в свою очередь, обращается в API.

У нас есть метод load, который делает query в базу данных и для каждого полученного объекта создает свой питоновский объект. Реализация тут очень простая. delete по первичному ключу получает и удаляет объект из базы. Есть метод save, который делает обратную операцию — смотрит, есть ли объект в базе данных, по первичному ключу, если нет — создает, добавляет и далее мы сохраняем состояние этого объекта.

Сначала мы делаем это один раз из питоновского объекта в мэппер, потом мэппер мэппит в базу. Основная проблема сразу же видна — это мэппинг. Основной проблемой оказалась ручная синхронизация. Дополнительный мэппинг — это один-два вызова, что может быть еще не так страшно. Никак. У нас есть два объекта нашего «чистого» интерфейса и у одного из них меняется атрибут — как мы увидим, что атрибут поменялся в другом? Конечно, если мы знаем, что объекты присутствуют в одном контексте, мы можем как-то это отследить. Необходимо слить изменения в базу и получить атрибут в другом объекте. Но если у нас две сессии в разных местах — только через базу, либо городить базу в памяти, чего мы делать не стали.

Этому инструменту много лет, в интернете по нему куча помощи и дублировать это тоже не очень хорошо. Этот load/save/delete — это еще один мэппер, который полностью дублирует внутренности алхимии, которая хорошо написана, оттестирована.

Так я буду помечать слайды, на которых делается что-то для «чистоты», для повышения уровня абстракции, для архитектурной космонавтики. Видите значок в правом верхнем углу? То есть слайды без этого значка — прагматичные и скучные, неинтересные и их можно не читать.

Много ли? Что делать, если медленно работает много запросов. Представим себе цепь наследования: один объект, у него один родитель, у того еще один родитель. На самом деле много. Для того, чтобы синхронизировать родителя, нужно синхронизировать и его родителя. Мы синхронизируем дочерний объект — для того, чтобы это сделать, сначала необходимо синхронизировать родителей. На самом же деле, в зависимости от того, как у нас построен граф, мы могли ходить и по сто раз синхронизировать все эти объекты — отсюда большое количество запросов. Хорошо, всех синхронизировали.

Мы взяли всю нашу бизнес-логику и засунули ее в мэппер. Что мы сделали? Все остальные объекты у нас тоже слились с мэпперами и всё наше API, весь data abstraction layer, оказался «грязным».

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

Бизнес-логика в декларативном описании базы. Конечно, с точки зрения любого космонавта, грязное API — недостаток. Фу. Схемы перемешаны с бизнес-логикой. Некрасиво.

Это на самом деле проблема — если бизнес-логики у нас не две строчки, а больший объем, то у нас в этом же классе надо очень долго скроллить или искать, для того чтобы добраться до конкретных описаний. Загромождается описание схемы. А тут схема загромождена. До этого все было красиво: в одном месте описание базы, декларативное, описание схем, в другом месте бизнес-логика.

Identity map гарантирует, что два питоновских объекта будут одними и тем же питоновским объектом, если у них совпадает первичный ключ. Но, зато, мы сразу получаем механизмы алхимии: unit of work, позволяющий отслеживать, какие объекты грязные и какие релейшены нужно обновить; получаем relationship, позволяющие избавиться от дополнительных вопросов в базу данных, не следя за тем, чтобы соответствующие коллекции были наполнены; и identity map, который нам больше всего помог.

Соответственно, у нас сразу же понизилась сложность до линейной.

Быстродействие сразу же увеличилось в 10 раз, количество запросов в базу данных упало примерно в 40-80 раз и RPS поднялся до 1-5. Это промежуточные результаты. Но API то грязный. Ну, хорошо. Что делать?

Берем бизнес-логику, опять выносим ее из нашего мэппера но, чтобы опять не было мэппинга, мы наш мэппер внутри алхимии унаследуем от нашего миксина. Миксины. В алхимии так не получится, она ругнется и скажет: «У тебя на одну табличку ссылается два разных класса, нет никакого полиформизма — иди отсюда». Почему не в обратную сторону? А так — можно.

Очень удобно. Таким образом, у нас есть декларативное описание в мэппере, которое отнаследовано от миксина и получает всю бизнес-логику. Казалось бы — круто же, все чисто. И остальные классы точно так же. Но есть один нюанс — связи и релейшены остаются внутри алхимии, а когда у нас есть, допустим, join через промежуточную табличку secondary table, то мэппер этой таблички каким-то образом будет присутствовать в клиентском коде, что не очень красиво.

Алхими бы не была таким хорошим, известным, фреймворком если бы не давала возможности побороться с этим.

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

Как выглядит общая схема?

И у нас есть сущности в бизнес-логике, отдельно. У нас есть файл со схемой, в которой собраны все наши декларативные классы — назовем ее schema.py. Таким образом, бизнес-логика лежит в одной кучке, схема — в другой и их можно независимо менять. И, данные сущности наследуются внутри файла схемы — мы для каждой entity пишем отдельный класс, и наследуем его в схеме.

Срезы конфигурации отношением «многие к одному» связаны с табличкой приемников. В качестве примера улучшения мы будем рассматривать простую схему из двух табличек: приемники (Receiver table) и срезы конфигурации (ReceiverPlanes table). Особенно сложного ничего нет.

Для того, чтобы скрыть отношения внутри «грязного» интерфейса алхимии, мы используем отношения и коллекции.

Они позволяют спрятать наши мэпперы от клиентского кода.

Мы их применяем вместе. В частности, две очень полезные коллекции это association_proxy и attribute_mapped_collection. Мэпперов объектов дальнего конца отношения. Как работает классическое отношение в алхимии: у нас есть relationship — это некая коллекция, список, мэпперов. Attribute_mapped_collection позволяет заменить этот список на дикт, ключами в котором будут какие-то из атрибутов мэпперов, а значениями являются сами мэпперы.

Это первый шаг.

Он позволяет нам не передавать мэппер в коллекцию, а передавать какое-то значение, которое после будет использовано для инициализации нашего мэппера, ReceiverPlanes. Вторым шагом, мы делаем association_proxy над этим отношением.

Ключ превращается в имя среза конфигурации, а значение — в значение среза конфигурации. Тут у нас lambda, в которой мы передаем ключ и значение. В итоге, в клиентском коде, все выглядит вот так.

Все работает: никаких мэпперов, никакой алхимии, никаких баз данных. Мы просто в какой-то словарь кладем какой-то дикт.

Правда, есть подводные камни.

И, в зависимости от того, как устроена схема это может привести к разным последствиям, от «просто нарушений констрейнтов», до непредсказуемых последствий. Если мы два раза одному ключу присваиваем разные, или даже одно, значения — на каждый такой set item вызывается lambda, создается объект — мэппер. Когда я только начинал, на такие вещи я убил уйму времени. Например, вы вроде объект удалили из коллекции, а он там все-равно остался: вы удалили только один.

Association_proxy и attribute_mapped_collection могут немного задерживаться: когда мы создаем объект мэппера — он добавляется в базу данных, но при этом он еще не присутствует в атрибуте коллекции. И немного неявная синхронизация. Когда он экспайрится, произойдет новая синхронизация с базой данных и он туда попадет. Он появится там только тогда, когда атрибут истечет (expire) в этой сессии.

Это даже не алхимия — можно просто создать свою коллекцию, чтобы все это побороть. Чтобы с этим побороться мы применяли собственные, самописные, коллекции.

У нас есть некая коллекция, которая наследует от мьютэбл мэппинга — это дикт, в ключах которого можно менять значения. Тут побольше кода и выделен самый важный участок. И есть метод _get_plane_obj — получить объект среза конфигурации.

Тут мы делаем простые вещи — пытаемся получить его по имени, по какому-нибудь первичному ключу и, если его нет, то мы создаем и возвращаем этот объект.

Единственное — значение мы присваиваем в самом конце. Далее мы переопределяем всего два метода: __setitem__ и __getitem__
В __setitem__ мы складываем эти объекты в нашу коллекцию, в relationship. Таким образом, мы осуществляем тот же механизм, что и association_proxy — передаем туда значение, дикт, и он присваивается соответствующему атрибуту.

Он получает по ключу какой-то объект у релейшена и возвращает его атрибут. __getitem__ делает обратную манипуляцию. Потому что когда у алхимии экспайрится атрибут коллекции, то коллекция заменяется на другую, после истечения. Здесь также присутствует небольшой подводный камень — если закешировать коллекцию внутри нашего мэппинга, то возможно немного рассинхронизироваться. Поэтому мы в последней части мы идем прямо к инстансу алхимии, опять получаем коллекцию через __getattr__ и у нее делаем __getitem__. Поэтому мы можем сохранить референс на старую коллекцию и не знать, что старая истекла и уже появилась новая. То есть, коллекцию Planes мы здесь не можем закешировать.

Как обычно — заводим атрибут коллекции. Как эта коллекция пришлепывается на наши миксины? Все атрибуты подставляются постфактум. Единственное интересное место заключается в том, что когда мы загружаем инстанс из базы, метод __init__ не вызывается.

И как раз во время загрузки мы должны инициализировать нашу коллекцию. Алхимия дает стандартный декоратор reconstructor, который позволяет какой-то метод пометить как вызывающийся после загрузки объекта из базы. Использование точно такое же, как в предыдущем примере. Self — как раз этот инстанс.

Какого типа конфигурация? Но у нас в схеме все еще остались видны уши базы данных — это конфигурация. На самом деле, клиенту это не интересно. Это varchar или это блоб? Для этого в алхимии предусмотрено декорирование типов. Он должен работать с абстрактными сущностями своего уровня.

В нашей базе хранится IPAddress в виде varchar’а. Простой примерчик. Мы используем класс TypeDecorator, входящий в алхимию, который позволяет, во-первых, указать какой нижележащий тип базы данных будет использоваться для данного типа и, во-вторых, определить два параметра: process_bind_param преобразующий значение к типу базы данных и process_result_value, когда мы значение от типа базы данных преобразуем в питоновский объект.

И мы можем как вызывать методы этого типа, так и присваивать ему объекты этого типа и у нас все работает. Атрибут от address приобретает как бы питоновский тип IPAddress. Или если какой-то нативный тип поддерживает IP-адреса, то можно и его использовать. А в базе хранится… не знаю что хранится, varchar(45), а можем заменить ту строчку и будет хранится блоб.

Клиентский код от этого никак не зависит, его не нужно переписывать.

Мы хотим, чтобы как только мы поменяли наш объект, версию сразу увеличить. Еще одна интересная вещь — у нас есть версия. Делаем мы это автоматически, чтобы не забыть. Какой-то счетчик версий у нас есть, мы объект поменяли — он поменялся, версия увеличилась.

Эвенты — это события, возникающие на разных этапах жизни мэппера и они могут вызываться при изменении атрибутов, при переходе сущности из одного состояния в другое, например «создано», «сохранено в базу», «загружено из базы», «удалено»; а также — при событиях уровня сессии, перед тем, как sql-код эмитируется в базу данных, перед коммитом, после коммита, также после роллбэка. Для этого мы применяли эвенты.

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

Здесь используется три события:
on_before_flush — перед тем, как sql-код будет эмитирован в базу данных, мы проходим по всем объектам, которые алхимия пометила как грязные в этой сессии и проверяем, модифицирован этот объект или нет. Вот пример. Алхимия помечает объект грязным, как только изменился какой-то атрибут. Зачем это нужно, если алхимия уже все пометила? Для этого есть метод сессии is_modified — он используется внутри, я его не нарисовал. Если мы присвоили этому атрибуту то же самое значение, которое у него было — он будет помечен как грязный. Например, есть некий список, два элемента в котором поменяли местами — с точки зрения алхимии, атрибут изменился, но для бизнес-логики не важно, если в этом списке хранится, допустим, какое-то множество. Далее, с точки зрения нашей семантики, с точки зрения нашей бизнес-логики, даже если атрибут изменился — объект все-равно может оставаться не модифицированным.

И добавляем их в некую переменную, связанную с сессией, которую мы завели сами — это наша переменная dirty_instances, в которую мы добавляем этот объект. И, в итоге, мы вызываем еще один метод, специфический для каждого объекта, чтобы понять модифицирован объект действительно или нет.

Тут тоже есть небольшой подводный камень: если за всю транзакцию у нас не было ни одного flush’а, то flush вызовется перед коммитом — в моем случае хэндлер перед коммитом вызывался до flush. Следующее событие происходит перед коммитом — before_commit.

Поэтому внутри хэндлера мы еще раз делаем flush, чтобы вызвались все хэндлеры перед flush’ем и просто инкрементируем версию на один. Как видите, то, что мы проделали в предыдущем пункте нам может не помочь и session.dirty_instances будет пустой.

after_commit, after_soft_rollback — после коммита просто чистим, чтобы в следующие разы не было никаких эксцессов.

В качестве класса мы передаем сюда сессию, так как это событие ее уровня. Таким образом, вы видите — этот метод install_handler устанавливает обработчики сразу для трех событий.

Я вам напомню, чего мы достигли — быстродействие 30-40 секунд на сложные и большие команды. Ну вот. Запросы в БД стали исчисляться сотнями. Не на все, какие-то выполнялись за секунду, другие за 200 миллисекунд, как вы видите по RPS.

Был, правда, один нюанс. В итоге получилась довольно сбалансированная система. То есть прилетает штук 30 запросов и каждый из них — вот такой! Некоторые запросы у нас приходят пачками, выбросами. (выступающий показывает большой палец)

Первый — одну, второй — две и так далее. Если мы их обрабатываем по одной секунде, то последний запрос в очереди будет работать 30 секунд.

Что будем делать? Поэтому нам нужно еще ускориться.

Первая — это абстракция над sql базой данных, которая называется SQLAlchemy Сore. На самом деле, алхимия состоит из двух частей. Соответственно, alchemy core примерно один к одному совпадает с sql — если вы знаете последний, то проблем с core у вас не возникнет. Вторая — это ORM, собственно мэппинг между реляционной базой данных и объектным представлением. Нет практически никакой накачки — запросы генерируются с помощью генератора запросов, а после исполняются. Если вы не знаете sql — выучите sql.
Кроме этого, core представляет наименьший оверхэд. Оверхэд над dbapi минимальный.

То есть, если в общем случае ORM все равно, как у нас построена схема базы данных — есть некое описание таблиц, генерирует некие запросы, не зная, что в данном случае будет, например, оптимально выселектить отсюда, в другом — оттуда, тут такой фильтр применить, а там — другой, то тут мы можем запросы сделать под задачу. Запросы мы можем строить любой сложности, любого типа, можем оптимизировать их под задачу.

Все эвенты, релейшены — все это в core не работает. Недостаток такой, что мы опять пришли к ручной синхронизации. Core не позволяет все это сделать удобно, на высоком уровне. Мы сделали селект, нам пришли объекты, мы с ними что-то сделали, потом апдейт, инсерт… версию надо руками инкрементить, констрейнты самостоятельно проверять.

Ну, мы не первый день живем.

Каждый мэппер внутри себя содержит объект __table__, который используется в core. Простой пример использования. Дальше мы этот сформированный запрос скармливаем в сессию и она нам возвращает iterable, в котором тапл-лайк объекты индексируемые как по названию колонки, так и по номеру. Далее вы видите — берем обычный селект, перечисляем колонки, джойним две таблички, указываем левую и правую, указываем по какому условию мы ее джойним, ну и для вкуса добавляем ордер бай. Номер соответствует тому порядку, по которому они перечислены в селекте.

Быстродействие в самом худшем случае упало до 2-4 секунд, самый сложный и самый длинный запрос содержал 14 команд и RPS 10-15. Стало значительно лучше. Солидно.

Это все еще лучше, чем использовать чистый raw SQL, потому что оно предоставляет именно реляционную абстракцию над базой данных. Что бы хотелось сказать в заключение.
Не плодите сущности там, где они не нужны — не наворачивайте свое там, где есть готовое.
Используйте SQLA ORM — это очень удобный инструмент, позволяющий отслеживать события на высоком уровне, реагировать на различные события, связанные с базой данных, спрятать все уши алхимии.
Если ничего не помогает, быстродействия не хватает — используйте SQLA Core. Это очень удобно. Автоматически эксейпит параметры, правильно делает биндинги, ей не важно, какая под ней база данных — можно менять и Core поддерживает разные диалекты.

Вот и все, что я вам хотел сегодня рассказать.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Перевод] Учебный курс по React, часть 1: обзор курса, причины популярности React, ReactDOM и JSX

Представляем вашему вниманию первые 5 занятий учебного курса по React для начинающих. Оригинал курса на английском, состоящий из 48 уроков, опубликован на платформе Scrimba.com. Возможности этой платформы позволяют, слушая ведущего, иногда ставить воспроизведение на паузу и самостоятельно, в том же ...

[Перевод] Разбираем лямбда-выражения в Java

Мы открыли его для себя совсем недавно, но уже по достоинству оценили его возможности. От переводчика: LambdaMetafactory, пожалуй, один из самых недооценённых механизмов Java 8. 0 фреймворка CUBA улучшена производительность за счет отказа от рефлективных вызовов в пользу генерации лямбда ...