"Când și dacă software-ul se prăbușește, clienții noștri nu ar trebui să își piardă datele pe care le-au colectat, chiar dacă acestea se află în memorie." Fraza aparține unuia dintre clienții noștri care ne-a transmis-o acum doi ani, cu valoare de principală cerință pentru o nouă aplicație software.
Aceste cuvinte m-au bântuit pentru o vreme, nu puteam înțelege de ce s-a exprimat în acest fel. O excepție a aplicației era suficient de rea, dar pierderea datelor era de neînchipuit; încercam să vizualizez tipul de complexitate care l-a făcut să exprime această cerință anume.
Nu reușeam să văd imaginea de ansamblu. În ultimii doi ani și jumătate, am aflat că pentru acest client datele sunt totul. Nimic nu este mai presus de date: nici UI, nici modul în care datele sunt stocate, nici chiar ușurința utilizării. Aceasta, pentru că aplicația colectează date utilizate în modele științifice de la diferite dispozitive (senzori) și servicii software (procesare de imagini), care la rândul lor sunt transferate într-un model matematic care evaluează datele, rezultând și mai multe date, care din nou sunt reprocesate și combinate cu alte date citite de senzori și introduse, care sunt toate reevaluate în alte modele afișate drept tabele sau diagrame. Dar lucrurile nu se opresc aici: utilizatorul are posibilitatea de a filtra și elimina intrările greșite de la orice nivel, ceea ce face ca modelele matematice să reevalueze. Da! Pentru acest software, datele reprezintă întreaga aplicație; toate celelalte aspecte sunt lipsite de sens dacă datele sunt deformate sau inaccesibile. Mi-a luat ceva timp pentru a înțelege cerința principală în forma sa deplină, dar în final am reușit să am o privire de ansamblu completă asupra întregii ierarhii de date.
Și am reușit! Acum aproximativ un an, am ajuns într-un punct în dezvoltare în care toate datele pe care aplicația anterioară le colectase și le distrusese, au fost salvate și stocate în baza de date relațională a noastră (SQL Server 2012). Acestea au putut fi utilizate din nou, exportate/ importate între diferite computere printr-un serviciu rapid și inteligent integrat în aplicație. Am proiectat împreună cu clientul o structură recurentă care putea gestiona toate cerințele, senzorii și modelele matematice viitoare, cu mici modificări. Era stabilă și scalabilă și am realizat-o în timp ce încă foloseam un instrument Object Relationship Mapping (ORM) pentru Data Layer.
Pentru procesul de colectare a datelor a fost necesar să proiectăm mai multe tabele de configurare a senzorilor și a procesului propriu zis: numărul de senzori folosiți, constante de mediu și unitatea de măsură a datelor. Configurările și valorile efective ale datelor sunt evaluate de un model matematic pentru a obține un prim set de rezultate. Modelul matematic e reprezentat printr-o simplă tabelă ce are legătură cu configurările inițiale ale senzorilor, propriile configurări pentru a determina unitățile de măsură și numele rezultatelor ce le expune dar și o legătura efectivă cu fiecare rezultat ce îl expune (data output), existând un nivel logic în cod care aplică formulele matematice propriu-zise. Rezultatele unui model matematic poate servi ca punct de intrare pentru un altul creând o relație părinte-copil între modele, existând și posibilitatea ca un set de date colectate să fie folosit de mai multe modele. Toate aceste relații dintre modele și rezultate sunt determinate de acțiunea utilizatorului, acesta având posibilitatea de a filtra date colectate pentru un singur model sau rezultate de evaluare pentru un altul. Pentru baza noastră de date, acest lucru a însemnat adăugarea de multiple tabele de legătură pentru a putea determina care date dintr-un set sunt selectate și crearea de coloane în unele tabele care să indice părintele modelelor, dacă acesta există.
Designul funcționa și rezista. Ne-am ocupat de fiecare nouă cerință în structurile noastre, în timp ce păstram încă relațiile plănuite inițial între tabele. Designul bazei de date relaționale ne-a dat flexibilitatea de a efectua cercetări complicate de la toate nivelurile și de a filtra citirile greșite ale senzorilor și a reevalua datele de la fiecare nivel al ierarhiei, fie el pe verticală sau pe orizontală. Eu, unul, m-am bucurat că am utilizat o bază de date relațională și că nu am căzut în capcana utilizării unui design NoSQL pentru date structurate în această manieră.
Dar ceva nu a mers bine. La începutul acestui an, am început să observăm că o anumită cerință producea o proporție mare de Date de Evaluare de nivel slab. A reieșit că un tip de dispozitiv avea cerința de a colecta și stoca citiri multiple ale senzorului, la o rată de 20 de citiri pe secundă. Aceasta însemna că într-o oră, am fi avut de făcut 72000 de citiri de la vreo 7 senzori, de procesat fiecare înregistrare cu modelul matematic și de stocat acele valori. După câteva calcule cu privire la dimensiunea obiectului pentru o citire a senzorului care ajunge în baza de date, ne-am dat seama că aceasta s-ar ridica la 2 KB de date. Deci, am făcut încă puțină matematică:
2 kilobytes x 72000 reading x 7 sensors= 1.008 Gigabyte
Adică 1GB de dimensiune pe disk numai pentru a păstra datele de la 7 senzori timp de o oră. De cât de multă memorie era nevoie pentru a prelua obiectele de la Nivelul de Date (Data Layer) la Nivelul de Prezentare (Presentation Layer), astfel încât utilizatorul să poată avea o reprezentare completă a datelor colectate? Lucrurile nu arătau bine.
Gestionarea problemei! Primul lucru era să verificăm de două ori dacă cerința era reală (colectarea datelor timp de o oră la o rată de luare de mostre de 20 citiri per secundă). Din păcate, acesta era un caz de utilizare valid și, mai mult, pentru a agrava problema, clientul devenea frustrat de viteza de încărcare. Citirea unei cantități atât de mari de date și crearea obiectelor Business Logic pentru acestea lua mult mai mult timp și memorie decât simpla copiere a datelor de pe disk în memorie. Aveam nevoie de un nou mod de a gestiona datele noastre relaționale astfel încât să putem ajunge la o performanță cât mai apropiată posibil de timpul de citire al discului.
Am verificat performanța ORM și cea a altor unelte similare la încărcarea tuturor datelor și asamblarea ierarhiei după cum a fost descrisă de baza de date și de către Business Logic. Pentru a rezuma lucrurile, răspunsul pe scurt a fost după cum era de așteptat: performanța era proastă. Am făcut orice v-ați putea imagina cu ORM până am ajuns chiar să utilizăm instanțe multiple pentru a încărca datele separat per obiecte, am oprit urmărirea stărilor entităților, dar iar și iar, eram departe de performanța pe care clientul se aștepta să o furnizăm. Imaginea era clară: nu vă bazați pe instrumente ORM pentru a construi și încărca ierarhii complexe.
Acesta a fost punctul în care am realizat că era nevoie să găsim o modalitate de a exprima complexitatea ierarhiei, așa cum merge ea și pe orizontală (frați) și pe verticală (părinți, copii), într-un singur tabel, astfel încât să putem obține entitățile și relația lor cu o unică interogare (query) simplă, iar cu un pass al acestui tabel, obiectele de Business Logic și reprezentarea lor puteau fi corect încărcate. Am creat un tabel care să păstreze reprezentarea pentru fiecare tip de obiect din tabelele bazei de date și de asemenea să stocheze nivelul de profunzime pentru acestea; astfel am acoperit și relațiile de frăție și pe cele de paternitate.
Această tabelă (Model Nodes) practic ne permite să extragem toate înregistrările din baza de date ce conțin o legătură cu un model matematic. Fiecare configurare, fiecare model precedent, fiecare citire de senzor, fiecare rezultat al modelului are câte o înregistrare în acest tabel.
Model ID - reprezintă Id-ul modelului matematic
NodeType - indică către ce relaționăm (configurare, rezultate, citire de senzor)
Node Id - indică Id-ul obiectului cu care relaționăm;
Citind fiecare linie din această tabelă ne putem crea propriul index în memorie pentru un model matematic pe baza căruia din fiecare tabelă indicată de către NodeType putem încărca doar datele relevante.
Să presupunem că avem două modele matematice: M1 și M2. Primul model, M1, se bazează pe date citite din senzorii S1 și S2 si produce rezultate M1R1, M1R2 și M1R3. Al doilea model, M2, primește ca input M1R1 și M1R3 și produce rezultatul M2R1. Utilizatorul vrea să elimine din acest calcul doar datele influențate de senzorul S1, iar pentru aceasta e destul să îl deselecteze din interfață.
În mod normal această operație ar însemna construirea unui query complex care pornind de la Id-urile a modelelor matematice, a senzorului și a rezultatelor. Se extrag toate valorile de la S1 folosite în calcului lui M1R1 și M1R3 și se reevaluează M2R1. Dacă însă ne folosim de tabela de ierarhie avea deja toate Id-urile necesare în memorie legate atât de M1 cât și de M2:
Tot ce trebuie să facem e să citim din baza de date valorile din tabelele corespunzătoare, să reevaluăm modelul și să actualizăm tabela de ierarhie dacă este cazul.
Această tabelă poate fi construită în două moduri: fie generarea ei printr-un script sau la momentul creării de noi entități logice în aplicație se generează automat o înregistrare, iar exemplul dat este doar unul dintre multiplele scenarii unde indexare în memorie a acestor legături logice poate economisi tip de execuție pe serverul de baze de date.
Pentru a înțelege de ce acest tabel rezolvă complexitatea datelor noastre în creștere din bazele de date relaționale, noi trebuie să înțelegem obiectele din spatele său. Bazele de date relaționale nu au probleme de performanță la citirea datelor structurate plan, dar atunci când Data Layer începe să alcătuiască obiecte business, se creează interogări complexe care au durate de rulare însemnate și un impact semnificativ de memorie. Acest tabel creează o descriere plană a unei ierarhii și orice informații necesare, cu privire la relațiile logice dintre obiecte, vor fi disponibile în interiorul său. Toate datele necesare pentru a descrie relațiile logice dintre obiectele Business Logic pot fi obținute prin rularea unei singure interogări (query) în memorie. Doar prin adăugarea tabelului adițional în baza noastră de date, am fost capabili să citim și să alcătuim obiecte cu viteza pe care ne-o permite discul, la un cost de 40 bytes 72000 citiri 7 senzori (20 MB) pentru 1GB de date neprelucrate.
Privind în viitor. Aceasta m-a făcut să mă gândesc la cum vor arăta datele noastre în viitor, cât de mult poate gestiona în realitate aplicația în acest fel. Ar mai fi ceva ce am putea face pentru a o îmbunătăți? Pentru a răspunde la această întrebare, trebuie să revenim la baza noastră de date. Dacă datele ne permit, am putea avea colecții de valori care ar putea să încapă ușor în tabelul ierarhiei; am putea încărca acele valori și le-am putea utiliza în alte filtrări și calcule suplimentare, iar dacă matematica permite, filtrarea și eliminarea valorilor greșite citite de senzori va actualiza valoarea colectată, ceea ce, la rândul său, poate determina reevaluarea Modelelor Matematice.
A ieșit la iveală un tipar, iar noi putem trage o concluzie: baza de date poate fi divizată în două tipuri de relații: cele de tip schemă și cele logice. Relațiile schemă sunt cele care îți fac viața mai ușoară, descrise de schemele tabel; încărcarea acestui tip de date este directă și scalabilă. Relațiile logice, pe de altă parte, sunt cele care generează probleme. Aplicarea unei logici complexe în interogări (queries) duce la performanță lentă și consum enorm de memorie. Iată deci unde trebuie să optimizăm și să găsim o modalitate de a asocia acele relații la baza de date, drept o relație schemă.
Lăsați baza de date să lucreze pentru voi.