Blog JSystems - uwalniamy wiedzę!
Blog JSystems - uwalniamy wiedzę!
Przeszliśmy realną migrację: postawiliśmy bazę Oracle ze schematem, danymi i kodem PL/SQL, a następnie przenieśliśmy ją do PostgreSQL 16 trzema metodami, skonfigurowaliśmy wysoką dostępność i zmierzyliśmy wydajność przed i po tuningu. Wszystkie zrzuty kodu i wyników pochodzą z prawdziwych uruchomień na żywych bazach — nic nie jest zmyślone.
Hasło „Oracle i PostgreSQL to przecież ten sam SQL” jest prawdziwe mniej więcej w 80%. Te 80% — tabele, indeksy, widoki, większość zapytań — przenosi się niemal automatycznie. Pozostałe 20% decyduje o tym, czy projekt trwa tydzień, czy kwartał. Składają się na nie: kod PL/SQL, pakiety, transakcje autonomiczne, sekwencje, partycjonowanie i typy danych przechowujące czas (oracle’owy DATE to data wraz z godziną). Do tego dochodzi cała warstwa administracyjna: kopie zapasowe, replikacja i wysoka dostępność.
Zamiast teoretyzować, zbudowaliśmy realną bazę źródłową i przenieśliśmy ją na żywo. Każdy zrzut poniżej to prawdziwe wyjście z konsoli — łącznie z błędami, które trzeba było obejść.
Baza źródłowa to Oracle Database Free w Dockerze (etykieta „26ai” to aktualizacja z 2026 r. na rodzinie 23ai — ciąg wersji 23.26.2.0.0 — nie odrębna generacja silnika). Schemat NORDPOL to własny schemat demonstracyjny zbudowany od zera na potrzeby tego artykułu — żaden ze standardowych przykładowych schematów Oracle (HR, SCOTT, SH) nie skupia wszystkich typowych pułapek migracyjnych w jednym miejscu. Celowo używa konstrukcji charakterystycznych dla Oracle, by wywołać wszystkie typowe pułapki migracyjne:
line_total = quantity * unit_price * (1 - discount)),pkg_sales, transakcję autonomiczną do audytu, widok hierarchiczny (CONNECT BY),MERGE oraz funkcje NVL/NVL2/DECODE.Uwaga dla użytkowników 11g / 18c / 21c: cały przewodnik dotyczy każdej aktualnej wersji Oracle — różnice charakterystyczne dla starszych baz (złączenia stylem (+), ROWNUM, brak kolumn IDENTITY, CONNECT BY) omówione są osobno w sekcji Migracja ze starszych wersji Oracle.
-- Oracle: kolumna wirtualna + tabela partycjonowana zakresowo po dacie
CREATE TABLE order_items (
order_id NUMBER(12) NOT NULL,
line_no NUMBER(4) NOT NULL,
product_id NUMBER(8) NOT NULL,
quantity NUMBER(8) NOT NULL,
unit_price NUMBER(10,2) NOT NULL,
discount NUMBER(4,3) DEFAULT 0,
line_total NUMBER(14,2) GENERATED ALWAYS AS (quantity*unit_price*(1-discount)) VIRTUAL,
CONSTRAINT pk_order_items PRIMARY KEY (order_id, line_no)
);
CREATE TABLE orders (
order_id NUMBER(12) PRIMARY KEY, customer_id NUMBER(10), order_date DATE NOT NULL, ...
) PARTITION BY RANGE (order_date) (
PARTITION p2024 VALUES LESS THAN (DATE '2025-01-01'),
PARTITION p2025q1 VALUES LESS THAN (DATE '2025-04-01'), ... );
order_items (300 tys. wierszy) i tabele wymiarowe, powiązane kluczami obcymi. orders jest partycjonowana, a order_items ma kolumnę wirtualną.Rynek narzędzi do migracji Oracle→PostgreSQL jest dojrzały. W praktyce używa się jednego z trzech podejść (albo ich kombinacji):
Sam ora2pg to pakiet Perla. Pułapka tkwi w sterowniku DBD::Oracle, który wymaga Oracle Instant Client (sterowniki Perla do Oracle nie są w repozytoriach dystrybucji):
# 1) Instant Client (basic + sdk + sqlplus) -> /opt/oracle
# 2) sterownik Perla zbudowany pod ten klient
export ORACLE_HOME=/opt/oracle/instantclient_21_14
export LD_LIBRARY_PATH=$ORACLE_HOME
cpanm DBD::Oracle # kompiluje sie pod Instant Client
apt-get install -y ora2pg # sam ora2pg jest w repo
$ ora2pg --version
Ora2Pg v25.0
Zanim cokolwiek przeniesiesz, uruchom ora2pg -t SHOW_REPORT --estimate_cost. To jedyne narzędzie, które poda Ci liczbowy szacunek pracochłonności w dniach pracy programisty. W praktyce ora2pg czyta parametry połączenia z pliku ora2pg.conf. Minimalna konfiguracja, która wskazuje schemat i dane połączenia:
Jeżeli baza to PDB (Oracle 12c+), zamień sid=XE na service_name=ORCLPDB1. Raport uruchamiasz z flagą -c:
Dla naszego schematu NORDPOL raport zwraca:
Eksport DDL tabel uruchamiasz typem TABLE. Flaga -o podaje nazwę pliku wyjściowego; bez niej ora2pg pisze na stdout:
Wygenerowany table.sql zawiera gotowe CREATE TABLE przetłumaczone na PostgreSQL. Zwróć uwagę na trzy rzeczy: IDENTITY zachowane 1:1, kolumna wirtualna Oracle stała się GENERATED ... STORED, a oracle'owy DATE trafnie odwzorowano na timestamp(0) — zero w nawiasie oznacza brak ułamków sekundy, czyli dokładnie tę rozdzielczość, jaką ma oracle'owy DATE (data i czas do sekundy):
-- fragment table.sql wygenerowanego przez ora2pg
CREATE TABLE employees (
employee_id bigint GENERATED BY DEFAULT AS IDENTITY,
first_name varchar(40) NOT NULL,
last_name varchar(40) NOT NULL,
full_name varchar(82) GENERATED ALWAYS AS (FIRST_NAME||' '||LAST_NAME) STORED, -- VIRTUAL -> STORED
hire_date timestamp(0) NOT NULL DEFAULT statement_timestamp(), -- DATE -> timestamp
salary decimal(10,2), commission decimal(5,2),
PRIMARY KEY (employee_id)
);
CREATE INDEX ix_emp_uname ON employees (upper(last_name)); -- indeks funkcyjny zachowany
VIRTUAL dopiero przy odczycie (nie zajmuje miejsca w tabeli). W użytej tu wersji 16 PostgreSQL ma tylko GENERATED ... STORED — wartość jest fizycznie zapisywana, więc tabela urośnie na dysku. Kolumny liczone wirtualnie dodał dopiero PostgreSQL 18; jeśli migrujesz na 18 lub nowszą, odwzorujesz zachowanie Oracle bez kompromisu. (W Oracle kolumnę wirtualną można też zaindeksować — wtedy jej wartości trafiają do indeksu — oraz partycjonować po niej.)ora2pg wygenerował orders z PRIMARY KEY (order_id) i PARTITION BY RANGE (order_date). Na Oracle to legalne. PostgreSQL odmawia — i ma rację:
W PostgreSQL każdy klucz unikalny tabeli partycjonowanej musi zawierać kolumnę partycjonującą. Poprawka to dopisanie order_date do PK:
-- przed: PRIMARY KEY (order_id)
-- po:
PRIMARY KEY (order_id, order_date)
(order_id, order_date), klucz obcy z order_items(order_id) nie ma do czego się odwołać (brakuje order_date). Albo dokładasz kolumnę partycji do tabeli-dziecka, albo rezygnujesz z deklaratywnego FK — warto o tym wiedzieć przed migracją.ora2pg strumieniuje dane bezpośrednio z Oracle do PostgreSQL typem COPY. Haczyk: domyślna kolejność tabel potrafi naruszać FK (np. countries trafia do bazy przed regions). Rozwiązanie — trzy niezależne komendy:
Krok 1 — schemat bez FK (typy TABLE i SEQUENCE nie eksportują FK)
Krok 2 — dane (streaming Oracle → PostgreSQL, 407 tys. wierszy w 21 s)
Krok 3 — klucze obce (typ FKEY, dopiero po danych)
Klasyczny problem, o który potyka się wiele migracji. Po wgraniu danych z jawnymi wartościami kluczy (np. order_id = 1..100000) sekwencje nadal startują od 1. Pierwsza próba wstawienia nowego rekordu kończy się kolizją klucza. Trzeba przestawić zarówno zwykłe sekwencje, jak i sekwencje kolumn IDENTITY na najwyższą użytą wartość:
-- sekwencje jawne
SELECT setval('seq_orders', (SELECT max(order_id) FROM orders));
SELECT setval('seq_customers', (SELECT max(customer_id) FROM customers));
-- sekwencje kolumn IDENTITY
SELECT setval(pg_get_serial_sequence('audit_log','log_id'),
(SELECT max(log_id) FROM audit_log));
Tu zaczyna się prawdziwa robota — czyli te dni pracy, które ora2pg sam sobie zarezerwował w wycenie. Poniższy podział pokazuje, co przeszło automatem, a co trzeba tknąć ręcznie:
Cztery najważniejsze z tych ręcznych konstrukcji omawiamy dalej z konkretnym kodem.
PostgreSQL nie ma pakietów. Standardowy wzorzec: pakiet staje się schematem o tej samej nazwie, a jego procedury/funkcje — zwykłymi funkcjami w tym schemacie. Zmienna stanu pakietu (np. g_last_order_id) nie ma odpowiednika — używa się zmiennej sesyjnej (set_config/current_setting):
-- Oracle: pkg_sales.get_customer_balance(...)
-- PostgreSQL: schemat pkg_sales + funkcja
CREATE SCHEMA pkg_sales;
CREATE OR REPLACE FUNCTION pkg_sales.get_customer_balance(p_cust_id bigint)
RETURNS numeric LANGUAGE sql STABLE AS $$
SELECT COALESCE(SUM(oi.line_total),0) -- NVL -> COALESCE
FROM orders o JOIN order_items oi ON oi.order_id=o.order_id
WHERE o.customer_id=p_cust_id AND o.status <> 'CANCELLED';
$$;
To jedna z najczęstszych blokad migracji. PRAGMA AUTONOMOUS_TRANSACTION pozwala procedurze zatwierdzić własne zmiany niezależnie od transakcji nadrzędnej (typowo: audyt/logowanie). PostgreSQL nie ma tego mechanizmu wbudowanego — standardowe obejście to osobne połączenie przez dblink, które commituje samodzielnie:
CREATE EXTENSION IF NOT EXISTS dblink;
CREATE OR REPLACE FUNCTION log_event(p_table text, p_action text, p_details text)
RETURNS void LANGUAGE plpgsql AS $$
BEGIN
PERFORM dblink_exec( -- osobne polaczenie = niezalezny commit
'dbname=nordpol_pg host=localhost user=nordpol password=...',
format('INSERT INTO audit_log(table_name,action,details) VALUES (%L,%L,%L)',
p_table, p_action, p_details));
END; $$;
Dwie uwagi praktyczne. Po pierwsze, hasło wpisane wprost w treść funkcji (jak wyżej, dla czytelności) to antywzorzec — w produkcji użyj pliku .pgpass albo serwera dblink z mapowaniem użytkownika. Po drugie, dblink to nie jedyna droga: rozszerzenie pg_background realizuje to samo przez proces roboczy w tle, bez nowego połączenia i bez hasła w kodzie. dblink wybiera się często dlatego, że jest w standardowej dystrybucji.
Hierarchiczne CONNECT BY (i SYS_CONNECT_BY_PATH) zamienia się na rekurencyjne wspólne wyrażenie tablicowe (CTE) — czytelniejsze i przenośne (standard SQL):
-- Oracle: START WITH manager_id IS NULL CONNECT BY PRIOR employee_id = manager_id
CREATE VIEW v_org_chart AS
WITH RECURSIVE org AS (
SELECT employee_id, manager_id, full_name, 1 AS depth, '/'||last_name AS path
FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.employee_id, e.manager_id, e.full_name, o.depth+1, o.path||'/'||e.last_name
FROM employees e JOIN org o ON e.manager_id = o.employee_id)
SELECT employee_id, lpad(' ',(depth-1)*2)||full_name AS org_name, depth, path FROM org;
Uwaga: rekurencyjne CTE nie ma gotowych odpowiedników wszystkich pseudokolumn CONNECT BY (CONNECT_BY_ROOT, CONNECT_BY_ISLEAF, NOCYCLE). Proste hierarchie przenoszą się 1:1, ale przy wykrywaniu cykli czy oznaczaniu liści trzeba dopisać logikę (np. nieś w CTE listę odwiedzonych węzłów, by zabezpieczyć się przed pętlą).
MERGE (czyli upsert — wstaw albo zaktualizuj) długo było argumentem „za Oracle”. Od PostgreSQL 15 jest natywne — naszą procedurę recalc_inventory (prosty upsert) przenieśliśmy 1:1. (Bardziej złożony oracle’owy MERGE z klauzulą DELETE lub RETURNING może wymagać korekty — RETURNING dla MERGE doszedł w PostgreSQL dopiero w wersji 17.) Mechaniczne są też NVL→COALESCE i NVL2/DECODE→CASE.SYSDATE podmienić na CURRENT_DATE — i to błąd. SYSDATE zwraca datę z godziną, a CURRENT_DATE w PostgreSQL — samą datę, więc po cichu obcina czas (dokładnie ta sama pułapka co przy DATE→date). Poprawny odpowiednik to LOCALTIMESTAMP (albo statement_timestamp()/clock_timestamp() zależnie od potrzeby).Migracja jest warta tyle, ile jej walidacja. Łączymy się z docelową bazą przez psql:
Uruchamiamy identyczny zestaw sprawdzeń co na Oracle — pakiet pkg_sales, funkcję fn_emp_fullname, widok v_customer_tiers i wpis w audit_log. Wyniki są co do grosza takie same — łącznie z saldem klienta liczonym przez przeniesiony pakiet, hierarchią z rekurencyjnego CTE i audytem zapisanym przez „transakcję autonomiczną” na dblink:
Dla dużych baz nie zawsze chcemy przenosin na raz (metodą „wielkiego wybuchu”, z ang. big bang). oracle_fdw montuje tabele Oracle jako tabele obce w PostgreSQL — można je odpytywać na żywo, ładować przyrostowo (INSERT ... SELECT z tabeli obcej) i — co bardzo wygodne — porównać liczbę wierszy po obu stronach jednym zapytaniem:
CREATE EXTENSION oracle_fdw;
CREATE SERVER oradb FOREIGN DATA WRAPPER oracle_fdw
OPTIONS (dbserver '//localhost:1521/FREEPDB1');
CREATE USER MAPPING FOR nordpol SERVER oradb
OPTIONS (user 'NORDPOL', password '...');
IMPORT FOREIGN SCHEMA "NORDPOL" LIMIT TO (orders, order_items, customers)
FROM SERVER oradb INTO ora_src;
Po zaimportowaniu schemy Oracle jako tabele obce w schemacie ora_src możemy jednym zapytaniem porównać liczbę wierszy po obu stronach:
-- psql nordpol_pg
SELECT tabela, pg, oracle
FROM (
SELECT 'orders' AS tabela,
(SELECT count(*) FROM orders) AS pg,
(SELECT count(*) FROM ora_src.orders) AS oracle
UNION ALL
SELECT 'order_items',
(SELECT count(*) FROM order_items),
(SELECT count(*) FROM ora_src.order_items)
UNION ALL
SELECT 'customers',
(SELECT count(*) FROM customers),
(SELECT count(*) FROM ora_src.customers)
) t;
UTF8 (Oracle zwykle ma AL32UTF8). Jeśli utworzysz ją jako SQL_ASCII, oracle_fdw ostrzeże, że „wszystkie znaki poza ASCII zostaną utracone” — i polskie ogonki rzeczywiście przepadną.Najgroźniejsze błędy migracji nie wywalają ładowania — one przechodzą bez słowa i ujawniają się dopiero, gdy aplikacja zaczyna zwracać inne wyniki niż na Oracle. Cztery, które widuje się najczęściej:
'' jest tożsamy z NULL — to ewenement na tle standardu SQL. W PostgreSQL '' to poprawna, niepusta wartość, różna od NULL. Skutki są podstępne: zapytania WHERE col IS NULL przestają łapać rekordy, które w Oracle były „puste”; konkatenacja NULL||'x' daje w Oracle 'x', a w PostgreSQL NULL; kolumna NOT NULL przyjmie '' tam, gdzie Oracle ją odrzucał. To najczęstsza cicha przyczyna rozjazdu danych. Przed przejściem produkcyjnym znajdź takie kolumny (UPDATE ... SET col = NULL WHERE col = '') i przejrzyj logikę aplikacji oraz warunki.Oracle sortuje wg NLS_SORT (np. NLS_SORT=POLISH dla poprawnej kolejności ą, ć, ż), a PostgreSQL wg collation systemu lub biblioteki ICU. Bez jawnego ustawienia (np. COLLATE "pl-PL-x-icu") ORDER BY i indeksy unikalne mogą porządkować dane inaczej niż Oracle. Druga rzecz: niecytowane identyfikatory Oracle sprowadza do WIELKICH liter, a PostgreSQL do małych — obiekt utworzony jako Orders żyje w Oracle jako ORDERS, a w PG jako orders. Kod odpytujący katalog systemowy po nazwach (albo używający cudzysłowów) trzeba ujednolicić, inaczej posypią się odwołania do tabel i kolumn.
CACHE 20), PostgreSQL — tylko 1. Przy intensywnych, współbieżnych wstawieniach domyślne CACHE 1 bywa wąskim gardłem — rozważ ALTER SEQUENCE ... CACHE n dla gorących sekwencji. W obu silnikach bufor i wycofania powodują dziury w numeracji — nie wolno zakładać ciągłości klucza.COPY komunikatem date/time field value out of range. Wyłap je zapytaniem kontrolnym i oczyść lub zmapuj na NULL — inaczej jeden zły wiersz zatrzyma transfer całej tabeli.CLOB→text i BLOB→bytea jest prosta, ale ładowanie bywa wąskim gardłem — ora2pg czyta LOB-y w całości do pamięci. Dwa parametry w ora2pg.conf kontrolują zużycie RAM i wielkość paczki:# --- ora2pg.conf (fragment dla tabel z LOB-ami) --- LONGREADLEN 1048576 # max 1 MB na LOB (domyślnie 0 = czyta cały obiekt do pamięci) DATA_LIMIT 50 # wierszy w paczce COPY (domyślnie 10 000 — za dużo przy dużych LOB-ach)Tabele z LOB-ami eksportuj osobno, żeby móc dostosować oba parametry bez ruszania reszty migracji:
ora2pg -c ora2pg.conf -t COPY \ --allow DOCUMENTS,ATTACHMENTS \ -o lob_tables.sql psql -h pg_host -U nordpol nordpol_pg -f lob_tables.sqlJeśli LOB przekracza
LONGREADLEN, ora2pg obcina go i loguje ostrzeżenie — zwiększ limit lub usuń zbyt duże obiekty przed migracją.FAST REFRESH na podstawie materialized view log, opcjonalnie ON COMMIT). PostgreSQL ma tylko pełny REFRESH MATERIALIZED VIEW — przy dużych widokach to realna różnica wydajnościowa. Global temporary table. PostgreSQL ma tabele tymczasowe, ale brak mu trwałej, globalnej definicji jak oracle’owa GTT (gdzie struktura żyje stale w słowniku, a dane są prywatne dla sesji). Obejście: zwykła tabela temp w sesji albo rozszerzenie pgtt; od PG18 dochodzi częściowe wsparcie globalnych tabel tymczasowych.RAISE_APPLICATION_ERROR oraz SQLCODE/SQLERRM zastępuje blok EXCEPTION WHEN ... w PL/pgSQL z kodami SQLSTATE i GET STACKED DIAGNOSTICS — mapowanie błędów (np. NO_DATA_FOUND, DUP_VAL_ON_INDEX) trzeba przepisać ręcznie.DBMS_OUTPUT.PUT_LINE → RAISE NOTICE (lub funkcja dbms_output z orafce).DBMS_SCHEDULER/DBMS_JOB nie mają wbudowanego odpowiednika — używa się rozszerzenia pg_cron (zadania zapisane w bazie, składnia crona) albo zewnętrznego cron/pgAgent.W realnym świecie prawie nikt nie migruje z najnowszego 26ai — migruje się z 19c, 12c, a bardzo często z wiekowego 11g, który wciąż pracuje w setkach firm. Im starsze źródło, tym więcej „archaicznych” idiomów w kodzie — i tym ważniejsze, że ora2pg je rozumie. Postawiliśmy te wersje obok siebie (Oracle 11g, 18c i 21c) i przepuściliśmy przez nie ten sam „stary” schemat.
IDENTITY (te weszły w 12c).(+) zamiast LEFT JOIN.ROWNUM — brak FETCH FIRST n ROWS (to też 12c).DECODE wszędzie tam, gdzie dziś pisze się CASE.Najlepsza wiadomość: ora2pg tłumaczy te wzorce automatycznie. Operator (+) — zmora wielu migracji — staje się czytelnym LEFT JOIN:
-- Oracle (styl 11g): zlaczenie (+), NVL i DECODE
CREATE OR REPLACE VIEW v_emp_dept AS
SELECT e.empno, e.ename, e.sal, NVL(d.dname,'(brak)') dname,
DECODE(SIGN(e.sal-10000), 1,'WYSOKA', 0,'SREDNIA', 'NISKA') widelki
FROM emp e, dept d
WHERE e.deptno = d.deptno(+);
-- ora2pg -> PostgreSQL (rzeczywiste wyjscie): (+) -> LEFT JOIN, NVL -> coalesce, DECODE -> CASE
CREATE OR REPLACE VIEW v_emp_dept (empno, ename, sal, dname, widelki) AS
SELECT e.empno, e.ename, e.sal, coalesce(d.dname,'(brak)') dname,
CASE WHEN SIGN(e.sal-10000)=1 THEN 'WYSOKA'
WHEN SIGN(e.sal-10000)=0 THEN 'SREDNIA' ELSE 'NISKA' END widelki
FROM emp e LEFT OUTER JOIN dept d ON (e.deptno = d.deptno);
Najciekawsze jest to, co pokazał raport oceny. Ten sam „stary” schemat (bez pakietów i transakcji autonomicznych) na wszystkich trzech wersjach — 11g, 18c i 21c — dostał identyczny poziom A-3 (około jednego dnia pracy). Porównaj to z naszym pełnym schematem Nordpol (B-5): o trudności migracji decyduje złożoność kodu PL/SQL, a nie numer wersji źródła:
(+) i sekwencja+trigger. ora2pg radzi sobie z nimi seryjnie. Realne ryzyko migracji nie zależy od numeru wersji — zależy od tego, ile logiki biznesowej siedzi w PL/SQL. Najpierw uruchom raport oceny, potem planuj.Zapytania hierarchiczne (CONNECT BY PRIOR) są w 11g i 18c powszechne — często używane do drzew organizacyjnych i kategorii. W PostgreSQL ten sam efekt osiągasz przez WITH RECURSIVE:
path text[]) w części rekurencyjnej i sortuj po niej.Jedyna wersja-specyficzna zmiana w ora2pg.conf to forma DSN. Starsze wersje XE używają sid; PDB (Oracle 12c+) — service_name:
Większość typów tłumaczy się mechanicznie, ale kilka pozycji to klasyczne źródła błędów. Najważniejsza: oracle’owy DATE zawiera godzinę — mapuj go na timestamp, nie na date, bo inaczej po cichu utniesz czas.
NUMBER bez precyzji: w PostgreSQL numeric o dowolnej precyzji liczony jest programowo i bywa kilka–kilkanaście razy wolniejszy oraz cięższy niż bigint/double precision. Jeśli kolumna mieści się w zakresie całkowitym (zwłaszcza klucze i kolumny w GROUP BY/JOIN), zmapuj ją świadomie na bigint, a nie bezrefleksyjnie na numeric. TIMESTAMP WITH TIME ZONE: Oracle zapisuje strefę w wartości, a timestamptz normalizuje wejście do UTC i pierwotnej strefy nie przechowuje — przy danych z różnych stref ta informacja przepada.apt-get install postgresql-16-orafce) i włączenie w bazie (CREATE EXTENSION orafce;, potem dodaj schemat oracle do search_path). Zakres jest szeroki: funkcje skalarne (nvl, nvl2, decode, instr, to_char/to_date/to_number z maskami Oracle, add_months, last_day, trunc na datach), pseudo-tabela dual, sysdate, a także emulacje pakietów: dbms_output, dbms_random, dbms_alert, dbms_pipe, utl_file oraz typy varchar2/nvarchar2 z oracle’ową semantyką długości. Dlatego warto instalować je na samym starcie.To obszar, który budzi najwięcej obaw przy odchodzeniu od Oracle („a co z RAC i Data Guard?”). W praktyce PostgreSQL ma komplet odpowiedników, często prostszych w utrzymaniu:
Jedna różnica filozoficzna: Oracle RAC to architektura wspólnego dysku (wszystkie węzły widzą tę samą pamięć masową), a typowy klaster PostgreSQL to architektura bez współdzielenia (każdy węzeł ma własną kopię danych, synchronizowaną strumieniem WAL). Dla większości zastosowań to drugie podejście jest tańsze i prostsze operacyjnie.
Postawiliśmy to na żywo: jeden klaster jako primary (port 5432), drugi jako gorąca replika (hot standby, port 5433) — replika gotowa do natychmiastowego przejęcia ruchu — zbudowana przez pg_basebackup z opcją -R. Po stronie primary widać aktywny strumień:
Test „na żywo”: zapis na primary jest natychmiast widoczny na standby, zapis na standby jest odrzucany (replika jest tylko-do-odczytu), a po awarii promujemy standby do roli primary:
pg_ctlcluster to wrapper Debiana/Ubuntu; uniwersalne odpowiedniki promocji repliki to pg_ctl promote -D <katalog_danych> albo funkcja SQL SELECT pg_promote() (można ją wywołać zdalnie przez psql). Ręczna promocja jest dobra do nauki — w produkcji używa się Patroni (z etcd/Consul) do automatycznego wyboru lidera i failoveru oraz HAProxy do kierowania ruchu. To dzisiejszy standard klastrowego PostgreSQL, odpowiednik logiki Data Guard Broker.Odpowiednikiem RMAN jest pgBackRest — obsługuje kopie pełne, przyrostowe i różnicowe, równoległość, kompresję, szyfrowanie, repozytoria w S3/GCS oraz PITR (odtworzenie do dowolnej sekundy) dzięki ciągłej archiwizacji WAL. Sama logika jest bliska RMAN-owi:
# pelna kopia
pgbackrest --stanza=nordpol --type=full backup
# przyrostowa (tylko zmiany od ostatniej)
pgbackrest --stanza=nordpol --type=incr backup
# odtworzenie do punktu w czasie (PITR)
pgbackrest --stanza=nordpol --type=time \
--target='2026-06-05 12:00:00' restore
Największa różnica mentalna przy przejściu na PostgreSQL: wiele rzeczy, za które w Oracle płaci się za opcje licencyjne, tu jest rozszerzeniem — często otwartym i instalowanym jednym CREATE EXTENSION. Te najważniejsze przy migracji:
Poza narzędziami otwartymi istnieją płatne ścieżki, które redukują przepisywanie kodu — warto je znać, by świadomie wybrać kompromis między kosztem licencji a kosztem pracy:
PRAGMA AUTONOMOUS_TRANSACTION, hierarchiczne CONNECT BY i typy oracle’owe, a EDB Migration Toolkit/Portal automatyzują przenosiny — kosztem płatnej subskrypcji i lekkiego uzależnienia od dostawcy.Narzędzia i walidacja to jedno — o ryzyku biznesowym decyduje plan samego przełączenia produkcji. Dwa podejścia:
count(*) — porównaj sumy kontrolne kolumn (np. sum/min/max oraz agregat md5 per tabela) i próbki rekordów brzegowych, szczególnie tam, gdzie wchodzą puste stringi, daty z czasem i liczby zmiennoprzecinkowe.Model uprawnień różni się fundamentalnie. W Oracle użytkownik jest jednocześnie schematem; w PostgreSQL role (z LOGIN lub bez) i schematy są rozdzielone, a uprawnienia nadaje się przez GRANT na bazę/schemat/obiekt. Trzy rzeczy do zaplanowania osobno:
PROFILE (FAILED_LOGIN_ATTEMPTS, wygaśnięcie hasła, limity sesji) nie mają bezpośredniego odpowiednika — w PostgreSQL używa się rozszerzenia passwordcheck, zewnętrznego uwierzytelniania (LDAP/PAM/SCRAM) i reguł w pg_hba.conf.AUDIT zastępuje rozszerzenie pgAudit.Mapowanie ról i odtworzenie polityk haseł zaplanuj jako osobny krok przejścia — to często pomijany element, który potrafi zablokować wdrożenie tuż przed startem.
Największa różnica operacyjna względem Oracle to autovacuum. PostgreSQL nie nadpisuje wierszy w miejscu — kasowanie i aktualizacja zostawiają „martwe” wersje, które autovacuum sprząta i przy okazji odświeża statystyki planera. Bez jego dostrojenia tabele z dużą rotacją puchną (tzw. bloat), a zapytania zwalniają — Oracle nie ma tego problemu, więc łatwo o nim zapomnieć.
ANALYZE (planer potrzebuje świeżych statystyk — inaczej dobiera fatalne plany).pg_stat_user_tables (n_dead_tup, last_autovacuum); dla gorących tabel zaostrz autovacuum_vacuum_scale_factor.pg_stat_statements (odpowiednik AWR / v$sql) — to pierwsze miejsce, gdzie szukać kandydatów do przepisania (jak nasze Q5).Na koniec rzecz najciekawsza: czy PostgreSQL nadąża za Oracle? Zmierzyliśmy 7 reprezentatywnych zapytań na tych samych danych (100 000 zamówień, 300 000 pozycji) i tym samym sprzęcie. Każde uruchomiliśmy po kilka razy i bierzemy medianę rozgrzanych przebiegów (pierwszy, zimny, odrzucamy), zawsze sekwencyjnie — nigdy obie bazy naraz. Szóste z tych zapytań (Q5, podzapytanie skorelowane) zachowało się tak nietypowo, że wyciągnęliśmy je z wykresu i omawiamy osobno na końcu — na głównym wykresie zostaje 6 porównywalnych zapytań.
shared_buffers) gra w tej samej lidze. Traktuj liczby jako rząd wielkości i ogólną tendencję, nie jako wyrocznię — na Twoim sprzęcie i danych wyjdą inne.Wnioski z wykresu: po nastrojeniu PostgreSQL wygrywa lub remisuje na 5 z 6 zapytań z wykresu (Q1, Q2, Q3, Q4, Q7). Oracle jest szybszy na Q6 (top-N, czyli wybranie kilkunastu rekordów z czołówki rankingu). Największy zysk z nastrojenia widać na Q4 i Q7. Ale prawdziwie pouczające jest to jedno zapytanie (Q5), którego na wykresie nie ma — wracamy do niego za chwilę.
Domyślny postgresql.conf jest celowo zachowawczy — ma wystartować na każdym sprzęcie, więc shared_buffers to raptem 128 MB, a work_mem — 4 MB. To pierwsza rzecz do zmiany po migracji:
-- najwazniejsze parametry (maszyna 6 GB RAM, 2 vCPU, SSD)
ALTER SYSTEM SET shared_buffers = '1536MB'; -- ~25% RAM
ALTER SYSTEM SET effective_cache_size = '4GB'; -- ile cache zaklada planer
ALTER SYSTEM SET work_mem = '64MB'; -- pamiec na sort/hash (kluczowe!)
ALTER SYSTEM SET maintenance_work_mem = '256MB';
ALTER SYSTEM SET random_page_cost = 1.1; -- SSD
ALTER SYSTEM SET max_parallel_workers_per_gather = 2;
ALTER SYSTEM SET jit = off; -- krotkie zapytania: JIT szkodzi
-- po zmianach: restart (shared_buffers) i ANALYZE
jit_above_cost — wówczas kompilacja odpala się tylko dla naprawdę kosztownych zapytań; pełne jit=off jest uzasadnione, gdy obciążenie to głównie krótkie zapytania.Q5 to klasyczne podzapytanie skorelowane (liczone osobno dla każdego wiersza zewnętrznego): „policz klientów, których przychód przekracza średnią w ich kraju”. Na Oracle wykonuje się w ~1,8 s. Na PostgreSQL — i to zarówno przed, jak i po nastrojeniu — przekroczyło ustawiony przez nas limit czasu zapytania (statement_timeout, 150 sekund) i zostało przerwane:
Dlaczego? Optymalizator Oracle potrafi spłaszczyć podzapytanie skorelowane (z ang. unnest — przekształcić je w zwykłe złączenie z agregacją) i policzyć średnią raz na kraj. PostgreSQL bez przepisania wykonuje je dosłownie — raz dla każdego z 5000 klientów. (PostgreSQL spłaszcza część podzapytań — np. EXISTS i IN zamienia na półzłączenie — ale nie tego skorelowanego podzapytania skalarnego ze średnią.) Żadne zwiększanie work_mem tego nie naprawi, bo problem jest algorytmiczny, nie pamięciowy. Lekarstwem nie jest strojenie serwera, tylko przepisanie zapytania: policz przychód każdego klienta i średnią krajową raz, w dwóch przebiegach (we wspólnym wyrażeniu tablicowym, CTE), a potem porównaj:
-- to samo pytanie, ale liczone raz zamiast 5000 razy
WITH cust_rev AS (
SELECT o.customer_id, c.country_id, sum(oi.line_total) AS rev
FROM orders o JOIN order_items oi ON oi.order_id=o.order_id
JOIN customers c ON c.customer_id=o.customer_id
GROUP BY o.customer_id, c.country_id),
country_avg AS (
SELECT country_id, avg(rev) AS avg_rev FROM cust_rev GROUP BY country_id)
SELECT count(*) FROM cust_rev cr JOIN country_avg ca USING(country_id)
WHERE cr.rev > ca.avg_rev;
Najlepszy dowód to plany wykonania z samej bazy. Wersja oryginalna ma SubPlan liczony przy każdym wierszu i szacowany koszt 35 milionów; wersja CTE liczy wszystko w dwóch przebiegach i kończy w 263 ms:
pg_stat_statements + EXPLAIN ANALYZE). Dobra wiadomość: przepisane zapytanie jest szybkie również na Oracle — czyli to po prostu lepszy SQL, nie „obejście braku”.Migracja Oracle→PostgreSQL jest dziś rutynowa technicznie — ale „rutynowa” nie znaczy „darmowa w czasie”. Z naszego przejścia płyną cztery wnioski:
orafce — oszczędzi przepisywania. 3) Bazę docelową twórz w UTF8. 4) Ładuj: schemat bez FK → dane → FK → resync sekwencji. 5) Przepisz pakiety, transakcje autonomiczne, CONNECT BY. 6) Zweryfikuj liczebności przez oracle_fdw. 7) Nastrój postgresql.conf i znajdź ciężkie zapytania (pg_stat_statements). 8) Zaplanuj replikację i kopie (pgBackRest).postgresql.conf, partycjonowanie i diagnostyka zapytań — wszystko, czego potrzebuje administrator po migracji z Oracle, na praktycznym szkoleniu z terminem gwarantowanym.
Komentarze (0)
Brak komentarzy...