10 decembrie 2013

Păzitorul tranzacțiilor

Mă uitam zilele trecute la ce au mai băgat nou simplaticii de la Oracle în versiunea 12c a serverului de baze de date. Am dat de capitolul Using Transaction Guard. "Hmm, ce nume pompos, îmi spun... Probabil că e o altă denumire cu dichis pentru o funcționalitate exotică pe care nu o va folosi nimeni".

Ei bine, m-am înșelat... e cu adevărat ceva interesant pentru că rezolvă o problemă "veche de când lumea", generatoare de bug-uri voodoo și de mari dureri de cap pentru programatori și testeri.

Haideți mai întâi să vă aburesc cu felul în care este implementată de obicei interacțiunea cu severul Oracle pe partea de client a unei aplicații. Vom merge pe un exemplu Java. C/C++ sau .NET nu știu suficient de bine. Să zicem că avem o aplicație bancară, iar la sfârșitul fiecărei luni trebuie să impozităm dobânda aferentă fiecărui depozit. Dom'le, nici mie nu-mi place, da' dacă așa vrea "cel mai cinstit guvern"... Asta e, ne conformăm!

În baza de date avem o tabelă care arată cam așa:
create table bank_deposit (
  id integer primary key,
  balance number(12, 2)
);

insert into bank_deposit (id, balance) values (1, 10000);
commit;
Precum se poate observa, discutăm de o bancă falimentară cu un singur client. Nu vrem să complicăm prea mult lucurile, așa că vom presupune că impozitul este de 1 RON, indiferent de valoarea depozitului. E o prostie, dar nu ne batem capul cu detalii.

Codul java arată cam așa:
public class TaxBank {

 static final String USER = "BANK";
 static final String PASSWORD = "secret";
 static final String DB_URL = "jdbc:oracle:thin:@db";

 public static void main(String[] args) throws SQLException, IOException {
  OracleDataSource ods = null;
  OracleConnection conn = null;
  boolean taxApplied = false;
  ods = new OracleDataSource();
  ods.setUser(USER);
  ods.setPassword(PASSWORD);
  ods.setURL(DB_URL);
  conn = (OracleConnection) ods.getConnection();
  while (!taxApplied) {
   try {
    applyTax(conn);
    taxApplied = true;
   } catch (SQLRecoverableException e) {
    try {
     conn.close();
    } catch (Exception ex) {
     // I don't care
    }
    // Reconnect. We have HA baby!
    conn = (OracleConnection) ods.getConnection();
   }
  }
 }

 private static void applyTax(OracleConnection conn) throws SQLException {
  conn.setAutoCommit(false);
  Statement stmt = null;
  try {
   stmt = conn.createStatement();
   stmt.executeUpdate("UPDATE bank_deposit SET balance=balance-1");
  } catch (SQLException e) {
   throw e;
  } finally {
   if (stmt != null)
    stmt.close();
  }
  conn.commit();
 }
 
}
Ne uităm la codul de mai sus și suntem plăcut surprinși. Băieții programatori chiar au pus suflet și au venit cu o soluție elegantă, mai ales că au luat în considerare că baza de date s-ar putea să fie destul de inteligentă pentru a asigura ceea ce numim noi (și alții) HA, adică "high availability". Spre exemplu, poate că e un RAC, și una din instanțe a murit, iar serverul a facut automat fail-over pe cealaltă instanță. Sau poate, a fost un mic "hâc" pe rețea: ok, primim eroare, dar codul nostru elegant se prinde și face un lucru foarte draguț: retrimite tranzacția spre server, astfel că nea Clientu' nici nu își va da seama că tranzacția sa inițială a avut probleme. Asta nu-i puțin lucru. Clientu' nu vrea să vadă erori, e sensibil și trebuie protejat.

Buuun, unde-i problema? E clar că ceva e "putred" p-acolo, altfel articolul ăsta nu și-ar avea rostul... Cum era cu "o imagine care valorează cât o mie de cuvinte"? Ia să ne uităm puțin la următoarea figură.

În figura de mai sus am încercat să surprind una din cele mai comune probleme: clientul pornește pe server o tranzacție pe care o și comite cu succes. Server-ul, după ce își face treabușoara, trimite spre client confirmarea că tranzacția sa a fost comisă cu success. Fără această confirmare, clientul nu are nici cea mai vagă idee ce s-a întâmplat cu tranzacția sa. Este vorba de pasul 2 din figura de mai sus. Problema e că acestă confirmare s-ar putea să nu mai ajungă la client din cauza a N motive, cea mai comună probabil fiind o eroare pe rețea. Spre exemplu, Dorel s-a împiedicat de cablul de rețea și l-a scos din switch. În acest caz, clientul va primi o eroare.

Ce face codul de mai sus? Zice: păi asta e o eroare de tip "Recoverable", prin urmare e suficient să retrimitem tranzacția inițială spre server. Oare? Păi tranzacția a fost deja comisă pe server, e persistentă, gata! Problema a fost că mesajul de confirmare că tranzacția a fost comisă nu a ajuns la client. Știu, pare destul de improbabil să se întâmple așa ceva, dar se întâmplă! Și atunci ajungem la acele bug-uri voodoo de care vă vorbeam, foarte greu de reprodus. În exemplul nostru, se întâmplă un fapt deloc plăcut, practic se impozitează depozitul (dobânda) clientului de două ori. Prin urmare, în loc de 1 LEU se vor lua din cont 2 LEI.

Mulți vor spune: "în Oracle, commit-ul e foarte rapid. Adică vrei să spui că în fracțiunea aia de secundă se întâmplă ceva care face ca mesajul de confirmare să nu mai ajungă la client?" Și zic: "Da, se poate întâmpla!" Mai mult, putem și testa acest lucru forțând puțin lucrurile. În baza de date facem următoarele modificări:
create table bank_deposit_mv as select * from bank_deposit;

create materialized view bank_deposit_mv
on prebuilt table
refresh on commit
as
select * from bank_deposit;

create or replace trigger trg_bank_deposit_mv before 
insert or update or delete on bank_deposit_mv for each row
begin
  dbms_lock.sleep(10);
end;
/
Ce giumbușlucuri face codul de mai sus? Un banal view materializat, bazat pe tabela cu depozitele băncii. View-ului ăsta materializat i-am zis să își facă "refresh" la momentul COMMIT-ului. Asta înseamnă că mesajul care să confirme că tranzacția a fost comisă nu va fi trimis decât după ce view-ul materializat se va fi refresh-uit. Pentru că scopul meu e să obțin un COMMIT care să dureze foarte mult, am făcut un trigger pe view-ul materializat (în mod normal, acest lucru nu este suportat), trigger care nu face nimic altceva decât stă și asteaptă 10 secunde. Asta înseamnă că, virtual, comiterea unei tranzacții cu un singur DML va dura aproximativ 10 secunde (atenție COMMIT-ul, nu DML-ul). E perfect! Asta înseamnă că avem la dispoziție 10 secunde să simulăm o problemă pe rețea.

Modificăm codul java astfel. Înainte de "conn.commit()" din metoda applyTax() punem un mesaj informativ:
System.out.println("Slow commit... You may disable network if you want."); // add this
conn.commit();
Apoi, în metoda "main", înainte de a trage o conexiune nouă din "pool", adăugăm următoarele linii de cod:
// Reconnect. We have HA baby!
System.out.println("Enable network again and press ENTER."); // add this
System.in.read();                                            // and this
conn = (OracleConnection) ods.getConnection();
Haideți să mai verificăm o dată ce avem în tabela "BANK_DEPOSIT":
11:53:56 SQL> select * from bank_deposit;

        ID    BALANCE
---------- ----------
         1      10000
Bun, rulăm codul java modificat. Când apare mesajul: "Slow commit... You may disable network if you want." înseamnă că serverul Oracle stă în commit. Ne ducem frumușel în "Control Panel" și dăm "disable" la placa de rețea. Codul java va afișa "Enable network again and press ENTER.". Deci, facem fix ce ne spunem: dăm enable la placa de rețea și apăsăm ENTER. Dup-aia așteptăm să se termine rularea progrămelului. Perfect! Haideți să vedem ce a ieșit:
12:06:12 SQL> select * from bank_deposit;

        ID    BALANCE
---------- ----------
         1       9998
Hopaaa, lipsesc doi leuți. Fix ce vorbeam. Am reușit să reproducem bug-ul voodoo.

Eh, acu' intră în acțiune "păzitorul tranzacțiilor", adică acest minunat "transaction guard", disponibil în Oracle 12c. Mecanismul are la baza un așa-numit "Logical Transaction ID". Adică, pentru o tranzacție pe care o pornim pe server, Oracle îi va asocia un identificator logic de tranzacție, existând posibilitatea ca, ulterior, să aflăm ce s-a întâmplat cu tranzacția respectivă, în special dacă a fost comisă sau nu. Asta e versiunea scurtă! Pentru cei interesați de mai multe detalii, le recomand să arunce un ochi vigilent pe documentația Oracle.

Haideți să modificăm codul java și să-l facem mai deștept! În primul rând, vom mai adăuga o metodă nouă care să ne spună dacă o tranzacție a fost comisă sau nu, pe baza identificatorului său logic.
 private static boolean getTransactionOutcome(Connection conn, LogicalTransactionId ltxid)
   throws SQLException {
  boolean committed = false;
  CallableStatement cstmt = null;
  try {
   cstmt = conn.prepareCall("begin get_ltxid_outcome_wrapper(:1, :2, :3); end;");
   cstmt.setBytes(1, ltxid.getBytes());
   cstmt.registerOutParameter(2, OracleTypes.BIT);
   cstmt.registerOutParameter(3, OracleTypes.BIT);
   cstmt.execute();
   committed = cstmt.getBoolean(2);
  } catch (SQLException sqlexc) {
   throw sqlexc;
  } finally {
   if (cstmt != null)
    cstmt.close();
  }
  return committed;
 } 
Practic, invocăm o procedură din baza de date care arată cam așa:
CREATE OR REPLACE PROCEDURE GET_LTXID_OUTCOME_WRAPPER(ltxid in raw, 
                                                      is_committed out number, 
                                                      is_call_completed out number) is
 committed boolean;
 call_completed boolean;
begin
  dbms_app_cont.get_ltxid_outcome(ltxid, committed, call_completed);
  if committed then
   is_committed := 1;
  else
   is_committed := 0;
  end if;
  if call_completed then
   is_call_completed := 1;
  else
   is_call_completed := 0;
  end if;
end;
/
E o procedură de tip "wrapper", care apelează procedura GET_LTXID_OUTCOME din pachetul DBMS_APP_CONT și nu face nimic altceva decât o conversie din INTEGER în BOOLEAN și invers. Asta pentru că JDBC-ul nu știe de tipul PL/SQL BOOLEAN.

Mai trebuie să adăugăm această logică și în metoda "main":
   } catch (SQLRecoverableException e) {
    LogicalTransactionId lxid = conn.getLogicalTransactionId(); // add this line
    try {
     conn.close();
    } catch (Exception ex) {
     // I don't care
    }
    // Reconnect. We have HA baby!
    System.out.println("Enable network again and press ENTER.");
    System.in.read();
    conn = (OracleConnection) ods.getConnection();
    taxApplied = getTransactionOutcome(conn, lxid); // add this line
   }
Cu aceste modificări, haideți să rulăm din nou codul îmbunătățit. Ne amintim că, la acest moment, în cont-ul bancar avem 9998 LEI/RON. Urmăm fix aceeași pași: pornim progrămelul, dăm "disable" la placa de rețea, apoi "enable" și asteptăm să se termine de rulat progrămelul. Rezultatul?
12:28:37 SQL> select * from bank_deposit;

        ID    BALANCE
---------- ----------
         1       9997
Aaașaa da! Deși am avut probleme cu rețeaua, bănuții nu au fost luați de două ori din cont.

Câteva observații de final:
  • baza de date trebuie să fie neaparat 12c
  • clientul oracle trebuie să fie tot 12c
  • doar varianta "thin" de JDBC funcționează. Dacă încercăm cu frățiorul "oci", primim un mesaj de eroare cum că "unimplemented feature".

0 commentarii: