Всем привет!

Меня зовут Андрей Бобронников, я занимаюсь обеспечением надежности ИТ систем в банке Уралсиб. И сегодня я вам расскажу о методах оптимизации для разработчиков на платформе ЦФТ в БД Oracle.

Ввиду роста бизнеса, наши системы стали обрабатывать все больше транзакций и возникла необходимость собрать материалы, которые помогут разработчикам ЦФТ писать оптимальный и быстрый код под Oracle на языке PL+, PL/SQL и SQL.

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

Выполнение SQL

Шаги перед выполнением запроса
Шаги перед выполнением запроса

Прежде чем выполнить SQL запрос Oracle проделывает несколько операций, наиболее трудоемкой из которого является Hard Parse. Он происходит, когда в кэше разобранных запросов данный запрос отсутствует. Это может быть по причине редкого запуска и простого вымывания его из кэша или первого запуска. Если запрос не новый, но запускается редко и вы видите, что он часто проходит Hard Parse стоит обратить внимание на размер shared pool, но это ближе к администраторам базы.

Обратим внимание на появление новых запросов, а именно — на использование биндов и литералов.

Бинды — переменные, которые мы подставляем в подготовленный запрос.

Литералы — это конкретные значения, которые уже подставлены в запрос.

Например, запрос с литералами:

select a from table where b = 123;

И запрос с биндом:

select a from table where b = :B;

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

b := 123;
v_sql := 'select a from table where b = :1';
execute immediate v_sql into c using b;

Таким же образом можно передавать несколько параметров, перечисляя их через запятую или с помощью двух строковых переменных с параметрами и разделителем, соответственно:

execute immediate sql_str using in Params, Delimiter;

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

Рассмотрим на примере: имеется таблица с историей состояний всех объектов в базе. С индексом по столбцу obj_id.

create table OBJECT_STATE_HISTORY
(
id NUMBER,
time TIMESTAMP(6),
obj_id VARCHAR2(128),
state_id VARCHAR2(16)
);

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

select *
from table t
,OBJECT_STATE_HISTORY osh
where t.id = 12345
and osh.obj_id = t.id

Oracle быстро находит искомое значение в таблице table и идет читать всю огромную таблицу OBJECT_STATE_HISTORY, в поиске нужных строк.

Для того, чтобы этого избежать, следует в условии and osh.obj_id = t.id привести t.id к строке. Тогда запрос встанет на индекс и отработает значительно быстрее.

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

План, конечно план.

Обязательно, в случае длительного выполнения запроса нужно посмотреть на его план. Есть множество статей по оптимизации планов запросов. Тут хочу упомянуть основной принцип: необходимо строить план от наиболее уникальной выборки к наименее, т.е. от меньшего к большему, а индексы выбирать по наиболее селективному условию. Селективность это отношение количества искомых строк к общему. Например, у нас есть таблица table со столбцами ключ и значение (key и value). Посмотрим сколько записей по каждому ключу:

key

count(value)

1

1200

2

900

3

500

4

200

5

10

Всего записей в таблице 2810, значит селективность ключа 1 составляет 42%, вероятнее всего, что фулскан по таблице с выбором всех значений по ключу 1 отработает быстрее, чем индексное чтение.

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

Выполнение PL+

Выполнение PL/SQL происходит отдельно от SQL
Выполнение PL/SQL происходит отдельно от SQL

В данном блоке рассмотрим несколько приемов, которые помогут оптимизировать код и ускорить его.

Курсор вместо For select

Частая конструкция в коде:

for ( select ... ) loop ... end loop;

Она компилируется в:

declare 
  cursor c_obj is select ... ; 
begin 
  for plp$c_obj in c_obj 
  loop ... end loop; 
end;

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

Чтобы этого избежать необходимо выбрать данные во временную таблицу и проходить циклом по ней. Это позволит отделить выборку данных от их обработки (+ к сопровождаемости).

Для этого можно просто изменить существующий код в несколько шагов:

  1. вынесем запрос в блок объявления переменных как тип:

    type t_cursor_type is select ... ;

  2. добавим объявление типа ссылки курсор и соответствующую переменную:

    type t_cursor_ref is ref cursor return t_cursor_type;

    v_cur t_cursor_ref;

  3. добавим объявление типа таблицы результата нашего курсора и соответствующую переменную:

    type t_tbl_type is table of t_cursor_type%rowtype;

    v_table t_tbl_type;

  4. далее перед нашим циклом for, откуда мы вытащили запрос, открываем курсор, вычитываем данные и закрываем курсор:

    v_cur.open(t_cursor_type);

    v_cur.fetch(v_table);

    v_cur.close;

  5. правим наш цикл на перебор новой таблицы:

    for i in 1..v_table.count() loop

  6. в теле цикла меняем обращение через алиас бывшего запроса, а в идеале объявляем переменную с одинаковым именем и выгружаем туда экземпляр обрабатываемого элемента таблицы.

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

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

select a.value, s.program, s.username, s.sid, s.serial#, s.client_identifier 
from v$sesstat a, v$statname b, v$session s 
where a.statistic# = b.statistic# 
and s.sid = a.sid 
and b.name = 'opened cursors current' 
order by 1 desc;

Структуры вместо ссылок

PL+ язык, созданный компанией ЦФТ, компилируется в PL/SQL. Коллеги из ЦФТ постарались следовать принципам ООП, объявляя объекты, как экземпляры таблицы, а ссылка — это числовая переменная, хранящая уникальный по всей базе идентификатор — id экземпляра.

Создавая тип, компилятор генерирует интерфейсные пакеты, в частности для обращения к реквизитам экземпляра. И когда вы пишите:

v_op_date := p_filial_ref.[OP_DATE];

Это компилируется в:

V_OP_DATE := TABLE#INTERFACE.get_op_date(P_FILIAL_REF);

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

select C_OP_DATE into val_from TABLE where ID=obj_id;

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

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

for (select cl(cl) in ::[TABLE] where ... )
loop
...
v_name := cl.[NAME]; -- select
...
cl.[NAME] := v_name; -- update
...
end loop;

В теле цикла будет скомпилирован вызов двух интерфейсных пакетов, в первом случае select, во втором случае update. При больших объемах множество вызовов замедлит выполнение программы. Чтобы ускориться, отделяем выборку от обработки и перечисляем в запросе необходимые нам реквизиты. А если нам нужны все реквизиты, можно использовать конструкцию rowtype.

Изменения, которые мы внесем надо положить в базу, переходим к следующему пункту.

Forall вместо одиночных вставок/изменений

Еще одним преимуществом использования массивов структур является фиксация изменений в базе данных одной командой.

Конструкция forall исключает множественное взаимодействие с базой и передает данные пачкой или пачками (можно поставить ограничение), пример:

insert into ::[TABLE] for i in 1..v_table.count loop a( a%rowtype = v_table%rowtype(i) );

update for i in 1..v_table.count loop a( a.[USER_ADD] = stdlib.user_id ) in ::[TABLE] all where a = v_table(i)

Ограничения:

  • потребление памяти, рекомендуется ограничивать большие выборки, делить на пачки, используя конструкции limit для fetch или fetch first row для select;

  • проблематика с изменением статусов, если присутствуют переходы.

Дополнительно, у конструкции forall есть дополнительные параметры, такие как сохранение результата в таблицу и обработка исключений.

Кэширование и область видимости переменных

Переменные, объявленные как глобальные, сохраняют свое значение в сеансе. Другие сессии эти значения не видят.

Необходимо осторожно подходить к месту объявления переменных. Рекомендуется использовать правило «чем меньше область видимости переменной, тем лучше».

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

Заключение

Чем проще код — тем меньше в нем ошибок, надеюсь данная статья поможет вам в понимании, как работает ваше приложение и как можно его улучшить.

Спасибо за внимание.

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


  1. Akina
    23.09.2025 11:51

    Селективность это отношение количества искомых строк к общему.

    Что, правда? А мужики-то не знают... Селективность - это отношение количества уникальных значений выражения индексации к общему количеству записей. А вовсе даже не количества записей с каким-либо конкретным значением.

    Для того, чтобы этого избежать, следует в условии and osh.obj_id = t.id привести t.id к строке.

    Именно в показанном запросе нужно просто использовать два литерала соответствующих типов:

    where t.id = 12345
    and osh.obj_id = '12345'

    ------

    Ну и вообще как-то не радует местечковый слэнг. Бинды, вставать на индекс... откуда такое, когда есть всем понятные термины "параметры", "привязка значения", "использование индекса"?


    1. haspl Автор
      23.09.2025 11:51

      Спасибо за обратную связь и определение селективности. У меня таблица из двух столбцов и объяснение селективности на ее примере. В статье не раскрывается понятие индексов и их виды.

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


      1. Akina
        23.09.2025 11:51

        в коде такой вариант писать будет проблематично и неправильно

        В упор не понимаю. Ни откуда проблематичность (вот какая разница, подставлять в шаблон значение один раз или два? всё равно вы это делаете программно, тем более применяете low-priority CROSS JOIN, для которого условие с использованием полей из разных таблиц порождает в принципе ненужный по логике оверхед), ни тем более неправильность (не согласны? укажите, когда такая форма запроса может привести к получению неверного выходного набора).

        Вы нарушаете связь между таблицами

        Не говорите ерунды. Связь между таблицами устанавливается использованием внешних ключей, на которые форма запроса не влияет от слова "совсем". А какие-то там зависимости на стороне клиента - вы можете называть это как угодно, но с точки зрения СУБД это не связь.


        1. haspl Автор
          23.09.2025 11:51

          Код постоянно меняется, исправлять потом так же нужно будет в нескольких местах - вероятность ошибки возрастает.

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

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


          1. Akina
            23.09.2025 11:51

            Код постоянно меняется, исправлять потом так же нужно будет в нескольких местах - вероятность ошибки возрастает.

            Вот этого я не понимаю. Код не меняется сам. Либо меняется задача, либо исходная структура, либо обнаруживается логическая ошибка - а изменение кода есть лишь следствие. В любом из этих случаев написание запроса заново надёжнее корректировки существующего - например, порой минорное изменение приводит к тому, что логика старого запроса становится неоптимальной для новых вводных. Ну и копипаст фрагментов не запрещён, опять же тестирование никто не отменял.

            но статья про базовые принципы

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

            В показанном случае предлагаемое соблюдение базовых принципов фактически равно ограничению допустимых путей достижения цели, какой бы она там не была. Причём обратите внимание - вы в одном месте требуете соблюдения этих базовых принципов (использовать в условии связывания именно поля двух таблиц), хотя в том же самом запросе, буквально рядом, от этого принципа отходите, и вместо INNER JOIN ON (они связаны) используете CROSS JOIN WHERE (я хочу, чтобы они были связаны).

            Смотреть внешние ключи, чтобы проанализировать запрос - такое себе удовольствие.

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


            1. haspl Автор
              23.09.2025 11:51

              Спасибо за конструктивную критику!

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

              Базовые принципы и стандарты служат опорой для качественной разработки. И вы правильно заметили, слепое следование этим принципам может ограничивать творческий подход. Необходимо соблюдать баланс, предложенный вами вариант с использованием cross join может в некоторых случаях оказаться производительнее для конкретного решения. Главное осознавать последствия каждого решения и проводить тестирование.

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

              Буду рад продолжить обсуждение, если возникнут дополнительные вопросы или возражения.


              1. Akina
                23.09.2025 11:51

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

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

                Если запрос и вправду большой, то его нужно просто делить на логически завершённые фрагменты, которые производят формулируемо-осмысленный набор промежуточных данных, и оформлять их как CTE или хотя бы подзапросы. Тогда внешний запрос значительно сокращается, и его проще анализировать с точки зрения корректировки, а если корректируется промежуточный блок, то опять же проще прослеживать влияние этой корректировки на дальнейшую цепь вычислений.

                Напротив, я пишу о том, что в случае указания связей в самом запросе анализ проводить проще.

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


                1. haspl Автор
                  23.09.2025 11:51

                  Рад, что мы достигли взаимопонимания и спасибо за ценные комментарии!