Так уж случилось, что с Oracle организация наша работает давно и плотно. Сам я познакомился с Oracle Database ещё во времена 6-ой версии и, с тех пор, какого либо дискомфорта не испытывал. Всё испортили рыночные отношения. С недавних пор, мы начали замечать, что Заказчик гораздо благосклоннее смотрит на наши проекты если в них используются бесплатные СУБД. О портации одного из таких проектов и будет мой рассказ...
Выбор бесплатной СУБД был, для меня, делом долгим и непростым, но, в конечном итоге, свёлся к двум всем известным альтернативам. PostgreSQL привлекал богатой (и всё ещё развивающейся) функциональностью, в то время как MySQL заманивал производительностью и «нулевым» администрированием. Поскольку Oracle нас разбаловал и SQL мы все знали и любили, многочисленные и новомодные NoSQL варианты отпали ещё в полуфинале.
Сложно сказать, на какой из СУБД я бы остановился в конечном итоге, если бы не одна шабашка, давшая мне возможность «вживую» пощупать и PostgreSQL и MySQL, не спеша сравнить их и принять, на мой взгляд, вполне обоснованное решение. Помимо функционала, разумеется, сравнивалась и производительность. Я не буду рассказывать деталей, но одним из принципиальных моментов «шабашки» была возможность быстро и надёжно (ACID, да) вставлять в базу данных большое количество записей. На эту тему и был проведён тест:
По оси ординат отложено количество записей фиксированной длины, сохраняемых в БД ежесекундно. Число в «легенде» означает размер транзакции. Здесь следует заметить, что MySQL замерялся «как есть», а PostgreSQL с использованием небольшой нашлёпки, обеспечивавшей возможность работы с привычными мне партиционированными таблицами и материализованными представлениями. Поскольку речь шла о «надёжном» хранении, MySAM на графике представлен исключительно для полноты картины и понимания того, где находится «теоретический максимум» искомой производительности на используемом «железе».
Поскольку само тестирование проводилось довольно давно и никакими SSD на имеющемся в наличии железе даже не пахло, к абсолютным значениям, показанным на графике, не стоит относиться как к догме. Безусловно, можно сохранять данные ещё быстрее, но меня интересовало соотношение производительности различных СУБД, работавших в (почти) одинаковых условиях. Для меня стало сюрпризом, что PostgreSQL, даже утяжелённый триггерами партиционирования, работает почти также быстро как MySQL, с использованием InnoDB, а на больших транзакциях (1000 записей и больше) начинает догонять MyISAM!
Как легко догадаться, показанный выше график окончательно убедил меня в том, что переходить следует на PostgreSQL. Пересоздание таблиц с переопределением типов столбцов (number в numeric и integer, varchar2 в varchar и text ...) было делом тривиальным. В переносе данных помог XML и XSLT.
Осталось обеспечить работоспособность всего SQL-кода, написанного для Oracle. Большая часть запросов работала, часть — заработала после небольших косметических изменений. Первым делом, я создал таблицу dual:
create table dual (
x varchar(1) not null
);
insert into dual(x) values('x');
Не то, чтобы без неё нельзя было обойтись, но в наших запросах она использовалась так часто, что переписывать их было просто нецелесообразно. Чтобы PostgreSQL «был доволен», пришлось добавить в запросы больше строгости:
select b.id id, b.name name
from ( select list_value
from acme_obj_list_value
group by list_value ), acme_list_value b
where b.id = list_value
select b.id id, b.name as name
from ( select list_value
from acme_obj_list_value
group by list_value ) a, acme_list_value b
where b.id = a.list_value
Все inline view необходимо именовать, а перед псевдонимами столбцов крайне желательно использовать ключевое слово 'as'. Для большинства столбцов его можно опускать, но при использовании таких имён как 'name' или 'value' это ведёт к ошибке. Следующим шагом, стала замена платформозависимого кода на соответствующие конструкции, поддерживаемые как в Oracle, так и в PostgreSQL. Речь идёт об nvl и decode, а также об устаревшем синтаксисе внешнего соединения. Первые две легко заменяются на стандартные (и более гибкие) coalesce и case, в случае же использования внешнего соединения, запрос должен быть переписан:
select ot.name, mv.str_value
from acme_object o, acme_meta_value mv, acme_obj_type ot
where o.id = :object_id
and ot.id = o.obj_type_id
and mv.owner_id(+) = ot.id
and mv.param_id(+) = 9520
select ot.name, mv.str_value
from acme_object o
left join acme_meta_value mv on (mv.owner_id = ot.id and mv.param_id = 9520)
inner join acme_obj_type ot on (ot.id = o.obj_type_id)
where o.id = :object_id
ANSI Join поддерживается Oracle с 9-ой версии и, на мой взгляд, является более удобным (хотя и менее лаконичным), чем устаревший вариант с использованием (+). Не стоит пытаться сочетать различные формы соединения в одном SQL-запросе. Если мы использовали outer join, то для внутренних соединений вполне логично использовать inner join, а не перечисление таблиц в фразе from через запятую.
Основная часть работы по миграции SQL-кода оказалась связана с переписыванием иерархических запросов. Фраза connect by в PostgreSQL, естественно, не поддерживается. Между тем, в наличии имелось большое количество запросов следующего вида:
select t.id as value
from acme_object t, acme_obj_ref_value rv
where rv.object_id = t.id
and rv.attr_id = 220102
and rv.ref_value = :object_id
and t.obj_type_id in ( select ot.id
from acme_obj_type ot
connect by prior ot.id = ot.parent_id
start with ot.id = 200335 )
Простое переписывание таких запросов, с использованием CTE не позволило бы привести их к платформонезависимому виду. Хотя Oracle (начиная с версии 11.2) поддерживает рекурсивные запросы, синтаксис их отличается от используемого в PostgreSQL. В частности, в PostgreSQL, использование ключевого слова recursive является обязательным, Oracle же его «не понимает». К счастью, в большинстве случаев, иерархическую часть запроса удавалось «спрятать» в представление.
create or replace view acme_arm(id) as
select ot.id
from acme_obj_type ot
connect by prior ot.id = ot.parent_id
start with ot.id = 200335
create or replace view acme_arm(id) as
with recursive t(id) as (
select id
from acme_obj_type
where id = 200335
union all
select a.id
from acme_obj_type a
inner join t on (t.id = a.parent_id)
)
select id from t
Переписывание операторов merge оказалось более «головоломным» (к счастью, они использовались не так часто, как иерархические запросы). PostgreSQL этот оператор не поддерживает, но зато он поддерживает использование фраз from и returning в операторе update, причём последняя — возвращает полноценный resultset (аналогично оператору select), что позволяет использовать его в фразе with. Я просто оставлю это здесь:
merge into acme_obj_value d
using ( select object_id
from acme_state_tmp
) s
on (d.object_id = s.object_id)
when matched then
update set d.date_value = least(l_dt, d.date_value)
when not matched then
insert (d.id, d.object_id, d.date_value)
values (acme_param_sequence.nextval, s.object_id, l_dt)
with s as (
select object_id
from acme_state_tmp
),
upd as (
update acme_obj_value
set date_value = least(l_dt, d.date_value)
from s
where acme_obj_value.object_id = s.object_id
returning acme_obj_value.object_id
)
insert into acme_obj_value(id, object_id, date_value)
select nextval('acme_param_sequence'), s.object_id, l_dt
from s
where s.object_id not in (select object_id from upd)
В этом примере можно заметить, что работа с последовательностями в PostgreSQL также отличается от принятой в Oracle. Конечно, в Oracle можно было определить функцию, аналогичную той, что получает значения из последовательностей в PostgreSQL, но переписывания Oracle-кода (также как и Java-кода) хотелось избежать. Кроме того, такой подход мог быть связан с дополнительными накладными расходами.
Много радости доставила работа с датой и временем. Дело в том, что широко используемый в Oracle тип date приучил к некоторой неряшливости при обращении с его значениями. Можно считать, что такое значение представляет собой число, целая часть определяет количество дней, прошедших с некоторой «магической» даты, а дробная — время, с точностью до секунды. После некоторого привыкания (как и к большинству особенностей Oracle), это довольно удобно, но PostgreSQL гораздо строже в том, что касается типов данных.
date '2001-09-28' + interval '1 hour'
Добавить, таким образом, к дате константный интервал можно, но что делать, если необходимо добавить переменное значение? Искомое выражение совсем не очевидно:
date '2001-09-28' + (to_char(p_hours, '99') || ' hour')::interval
Пробел в строке перед 'hour' обязателен! Также, можно заметить, что строгость PostgreSQL распространяется и на преобразование числовых значений в строковые (и наоборот, конечно). Маска обязательна, даже если она состоит из одних девяток. Неявные преобразования, столь привычные после работы с Oracle, не работают.
Оставшиеся запросы подверглись менее радикальным изменениям. Пересмотра потребовал весь код работающий со строками, просто потому, что соответствующие функции в Oracle и PostgreSQL выглядят по разному. Столбец rownum, там, где он ещё оставался, пришлось заменить на оконный row_number(). В тех случаях, когда условие на rownum использовалась для ограничения количества выводимых строк, запросы переписывались с использованием фразы limit.
Отдельно стоит рассказать о табличных функциях. И в Oracle и в PostgreSQL они есть. Реализация разумеется различна, но обращение к ним, из SQL-запроса выглядит сходным образом. К сожалению, как и в случае с рекурсивным CTE, всё портит наличие одного ключевого слова:
select * from table(acme_table_fuction(...))
select * from acme_table_fuction(...)
Осталось разобраться с пакетами. В PostgreSQL такого понятия нет, но, при ближайшем рассмотрении, оказывается, что ему оно не очень то и нужно. Действительно, для чего нужны пакеты в Oracle? Если отбросить в сторону глобальные переменные и инициализационный код (которыми мы не пользуемся), главным достоинством пакетов является то, что они разрывают цепочки зависимостей. При изменении объектов БД, инвалидируются лишь реализации зависимых пакетов, но не их заголовки. Возможность выполнения рекурсивных вызовов внутри пакетов является одним из следствий этого факта.
В PostgreSQL механизм зависимостей не реализован. С рекурсивными вызовами хранимых функций (процедур в PostgreSQL нет) также всё в порядке. Для того, чтобы в клиентский код пришлось вносить минимум изменений, достаточно обеспечить лишь видимость того, что мы продолжаем работать с пакетами. Схемы PostgreSQL подходят для этого как нельзя лучше. Разумеется, в таком «пакете», не удастся реализовать «приватные» функции, но это не очень большая проблема. Вот как будет выглядеть код:
drop function acme_utils.get_str_res(numeric);
drop function acme_utils.c_str_res_ot();
drop function acme_utils.c_str_res_id_attr();
drop schema acme_utils;
create schema acme_utils;
create or replace function acme_utils.c_str_res_ot()
returns numeric
as $$
begin
return 20069;
end;
$$ language plpgsql IMMUTABLE;
create or replace function acme_utils.c_str_res_id_attr()
returns numeric
as $$
begin
return 20070;
end;
$$ language plpgsql IMMUTABLE;
create or replace function acme_utils.get_str_res(in p_res_id numeric)
returns text
as $$
declare
res text;
begin
select o.name
into strict res
from acme_object o
inner join acme_obj_value rid on (rid.object_id = o.id and rid.attr_id = acme_utils.c_str_res_id_attr())
where o.obj_type_id = acme_utils.c_str_res_ot()
and rid.num_value = p_res_id;
return res;
end;
$$ language plpgsql STABLE;
Необходимость удаления всех объектов перед «пересозданием» схемы немного утомляет, но жить можно. Можно заметить в тексте непривычное слово 'strict'. Оно обеспечивает привычное по Oracle поведение, при попытке выборки нуля или более одной записи. Из других запомнившихся моментов, могу упомянуть странную конструкцию, вычисляющую количество строк, изменённых последним запросом:
insert into acme_resource_tmp(id, device_id, profile_id, owner_id, type_id, res_num, name)
select acme_main_sequence.nextval, t.device_id, t.profile_id, r.id, p.type_id, t.num, t.value
from acme_state_tmp t
inner join acme_profile_detail p on (p.profile_id = t.profile_id and p.param_id = t.param_id)
inner join acme_resource r on (r.device_id = t.device_id and r.owner_id is null);
l_ic := sql%rowcount;
insert into acme_resource_tmp(id, device_id, profile_id, owner_id, type_id, res_num, name)
select nextval('acme_main_sequence'), t.device_id, t.profile_id, r.id, p.type_id, t.num, t.value
from acme_state_tmp t
inner join acme_profile_detail p on (p.profile_id = t.profile_id and p.param_id = t.param_id)
inner join acme_resource r on (r.device_id = t.device_id and r.owner_id is null);
get diagnostics l_ic = row_count;
Реализации всех пакетов пришлось, конечно, переписать, благо их оказалось не так много. Из моего предыдущего повествования можно понять, что весь переписанный SQL-код поделился на три категории:
- Запросы, которые путём небольшого переписывания удалось привести к платформонезависимому виду
- Запросы, в которых платформозависимые фрагменты удалось скрыть в представлениях
- Безусловно платформозависимый код
С первыми двумя никаких сложностей нет. Последняя категория может доставить некоторые проблемы, если платформозависимые конструкции присутствуют в запросах, формируемых клиентом. Дело в том, что Java-код не хочется переписывать. Ещё меньше желания разделять исходники на две версии, работающие с различными СУБД. К сожалению, полностью исключить платформозависимые конструкции из клиентского кода не удалось. По большей части, мешало ключевое слово table в запросах к табличным функциям. Также имелись обращения к последовательностям и немного иерархических запросов.
Было принято решение — хранить все платфомозависимые запросы в БД, загружая их в программный кэш, при первом обращении. Первоначально предполагалась, что каждая из БД будет хранить свои версии запросов, но оказалось удобнее хранить запросы одновременно во для всех используемых СУБД. В Oracle, для хранения текста запросов использовалось CLOB поле, в PostgreSQL — text. Для обеспечения единообразия, было использовано преобразование CLOB в varchar2, что ограничило максимальный размер запроса 4000 символов (один запрос всё-таки вылез за пределы этого размера, но поскольку он предназначался для PostgreSQL версии, «ужимать» его не пришлось). Само преобразование to_char пришлось скрыть с использованием представления:
create or replace view acme_query(name, sql) as
select a.name, to_char(c.clob_value)
from acme_object a
inner join acme_obj_list_value b on (b.object_id = a.id and b.attr_id = 10061)
inner join acme_obj_value c on (c.object_id = a.id and c.attr_id = 10062)
where a.obj_type_id = 10004
and b.list_value = 10061;
create or replace view acme_query(name, sql) as
select a.name, c.clob_value
from acme_object a
inner join acme_obj_list_value b on (b.object_id = a.id and b.attr_id = 10061)
inner join acme_obj_value c on (c.object_id = a.id and c.attr_id = 10062)
where a.obj_type_id = 10004
and b.list_value = 10062;
Резюмируя, могу сказать, что работа оказалась совсем не такой страшной, какой она казалась в начале. Большая её часть была связана с переписыванием иерархических запросов и пакетов Oracle, а большая часть проблем — с более строгим синтаксисом SQL и отсутствием привычных неявных преобразований в PostgreSQL. Объем работ мог бы быть меньше если бы мы изначально использовали более строгий и платфомонезависимый код в Oracle.
Автор: GlukKazan