Articoli Manifesto Tools Links Canali Libri Contatti ?
Database / GOODS

Esperienze nell'uso del database object-oriented GOODS

Abstract
Si fornisce nel seguito una descrizione sull'uso di GOODS nella sua forma originale, cui seguirà una breve analisi sulle ragioni che hanno portato alla sostanziale riscrittura del codice per l'inclusione in un progetto industriale correntemente attivo da cinque anni.
Data di stesura: 20/11/2003
Data di pubblicazione: 09/12/2003
Ultima modifica: 04/04/2006
di Renzo Tomaselli Discuti sul forum   Stampa

Introduzione

GOODS [1] è un database object-oriented nato da una tesi di laurea presso l'università' di Mosca. L'autore, Konstantin Knizhnik, usa attivamente GOODS per progetti commerciali propri e lo supporta via email in modo costante, tanto allo scopo di fornire chiarimenti quanto per discuterne eventuali migliorie che non si discostino dalla filosofia originale del prodotto.

Lo stesso autore ha progettato e rende disponibile presso il proprio sito internet [1] vari altri databases sui quali non abbiamo esperienza diretta. Tuttavia GOODS rappresenta il top di questa categoria, in termini di distribuibilità, scalabilità e transazionalità.

L'intero package è scritto in C++, a mio parere con un ottimo livello qualitativo del codice e con un'architettura portabile completamente thread-safe. È strutturato in una parte client, normalmente localizzata nel processo applicativo, ed una parte server, localizzata in un processo separato. Il client gestisce sostanzialmente cache e transazioni, mentre il server è uno storage manager. Quest'approccio consente il disegno di applicativi distribuiti three-tiers.

La portabilità verificata comprende perlomeno Windows, Linux, Solaris, AIX. Il codice è stabile, non risultano bugs ne' random ne' sistematici.

L'infrastruttura

Le necessità infrastrutturali di GOODS sono demandate ad un layer di supporto denominato SAL. Questo è un package a sé stante, che offre un insieme di funzionalità' portabili sui sistemi più comuni ed utilizzabili separatamente da GOODS. In sostanza sono:

  • threading, sincronizzazione e locking: vengono implementati i concetti di thread, mutex, semaforo.
  • File i/o, sia normale sia memory mapped.
  • Interprocess communication, tramite sockets.

La struttura fisica (o il punto di vista del server)

Il database è fisicamente partizionato in storages, ognuna rappresentata da un gruppo di cinque files. È compito dell'applicativo decidere se e quando aggiungere nuove storages, nonché associare nuovi oggetti ad una specifica storage. Ogni storage è gestita da un processo server apposito. Poiché la comunicazione avviene tramite sockets, l'insieme dei servers relativi ad uno specifico database può dar luogo ad una qualsiasi configurazione distribuita in rete. Lo stesso dicasio per i clients del database in questione.

I files sono:

  • Data file (.odb): contiene i dati reali (oggetti GOODS), allocati con una granularità di 32 bytes. L'accesso avviene tramite un pool di pagine avente dimensione configurabile.
  • Index file(.idx): fornisce un meccanismo di redirezione per l'accesso agli oggetti. In pratica è un array di strutture (12 bytes per oggetto), ognuna delle quali fornisce posizione, dimensioni e classe dell'oggetto referenziato. L'accesso avviene tramite memory mapping.
  • Bitmap file (.map): permette la localizzazione veloce di zone non allocate qualora sia necessario creare/espandere oggetti. È un array di bits, ognuno dei quali rappresenta 32 bytes nel data file.
  • Transaction log file (.log): fornisce un meccanismo di recovery per le transazioni nei casi di server crash. L'accesso è sequenziale in append.
  • History log file (.his): mantiene traccia delle transazioni globali, coordinate dalla storage corrente, terminate con successo.

Ogni oggetto ha un proprio contenuto la cui definizione è demandata ad un oggetto denominato «classe». In particolare, fra i tipi di dati spicca il riferimento ad altri oggetti nella stessa o in altre storages. Questa struttura porta ad una organizzazione logica del database di tipo reticolare.

La struttura logica (o il punto di vista del client)

Il client vede un database come insieme di istanze C++. Ogni istanza ha uno stato persistente rappresentato da data members, ed una parte comportamentale rappresentata da metodi. Poiché il front-end è C++ e quindi compilato, solo lo stato è persistente, non il comportamento. La classe che incapsula uno di questi oggetti è una normale classe C++ cui viene fornita, tramite macro, una sovrastruttura che gestisce la persistenza. Questa è sostanzialmente costituita da un insieme di descrittori che rappresentano i data members persistenti, più la gestione della ricostruzione di un'istanza dopo che viene restituita dal server. I data types disponibili sono i soliti: tipi atomici (interi 1-8 bytes, float e stringhe), e tipi composti ottenuti per aggregazione in strutture o in arrays, sia a lunghezza fissa (in compilazione) che variabile (a runtime).

Valgono i normali concetti di ereditarietà (singola) e contenimento.

L'accesso agli oggetti avviene tramite un meccanismo di redirezione che passa tramite object handles: questo permette di centralizzare operazioni quale il reloading on-demand (quando l'oggetto è stato buttato via dalla cache), il locking fra threads, il locking fra clients e l'inclusione in una transazione.

Questa parte è implementata tramite un set di templates che costituiscono una delle parti più interessanti di GOODS.

Uno di questi in particolare è l'object reference: una piccola classe che mantiene un object handle refcounted ed offre un gruppo di object accessors, differenziati fra accesso in sola lettura e accesso con l'intenzione di modificare lo stato dell'oggetto. Ogni accesso ad oggetti persistenti avviene tramite object references.

Ogni database viene creato con un singolo oggetto astratto denominato «root». Questo poi viene specializzato dall'applicativo (root «becomes» qualcosa di specifico), e da lì discende l'intera rete di oggetti del database.

Argomenti specifici

Interthread locking

Poiché' un client può essere multithread, l'accesso ad ogni oggetto è sincronizzato da un mutex. Poiché si presume che il numero di oggetti attivi in un'applicazione sia molto elevato, viene usato un meccanismo di pool per assicurare un limite all'uso di risorse globali del sistema. Lock/unlock vengono gestiti implicitamente dagli accessors degli object refs (cioè l'applicativo non li vede).

Interprocess locking

Un applicativo distribuito consiste di processi clients multipli operanti sullo stesso database, quindi è necessario mantenere un locking centralizzato da parte del server, ma su richiesta dello specifico client. Tale locking sussiste in due varianti: shared, che permette accesso multiplo in lettura, ed exclusive, che permette un singolo accesso in modifica. Il server accoda richieste di locking che non possono venire evase immediatamente, fino ad un timeout prestabilito.

Quale tipo di locking effettuare e su che oggetti, sono concetti demandati ad un'astrazione denominata «metaobject protocol», nel seguito «mop». In pratica, questo è una virtualizzazione del comportamento attribuito ad una specifica classe di oggetti. Ad esempio, una stretta serializzazione dell'accesso ad un oggetto da parte di più clients è un protocollo denominato «pessimistic repetable-read», perché' impiega entrambi i tipi di locking, a seconda che l'accesso sia in lettura o in modifica. Una variante più rilassata è data dal «pessimistic non-rep. read», che non usa il locking in lettura, migliorando l'accesso concorrente. Il protocollo «optimistic» non effettua nessun locking, e pertanto l'applicativo deve essere pronto a fronteggiare errori dovuti all'invalidazione di un oggetto modificato da un client concorrente.

Transazioni

GOODS offre una completa implementazione di transazioni distribuite, con logging e recovery automatico in caso di crash. La limitazione più spiccata è dovuta al fatto che ogni transazione viene costruita nella memoria del client, e questo pone un limite dimensionale all'uso di grosse transazioni.

L'accessor in modifica di un oggetto inserisce lo stesso nella transazione corrente (se non c'è già). Poiché dal metodo chiamato è possibile accedere ad altri oggetti persistenti, il nesting che ne deriva fa riferimento alla stessa transazione, il cui commit avviene all'uscita dal metodo più esterno. L'operazione di commit avviene raggruppando gli oggetti coinvolti per storages. Di queste viene scelta quella con identificatore più basso come coordinatore, cui viene inviato il blocco contenente i propri oggetti modificati; viene ritornato un transaction id che viene successivamente inviato alle altre storages insieme con i propri dati. All'interno del gruppo di storages coinvolte, viene attivato un protocollo 2PL: ognuna salva il contenuto della transazione nel proprio logfile, poi: il coordinatore aspetta l'ok delle altre, mentre le altre gli comunicano se va tutto bene ed aspettano una replica. Il coordinatore raccoglie tutte le conferme e quindi (se tutte hanno confermato) replica ad ognuna.

Notare che ogni server dispone in background il processing delle proprie transazioni, che quindi sono processate in parallelo. Alla fine, ognuna comunica al coordinatore che ha finito, e questo lo comunica al client (rimasto nel frattempo in standby).

Il recovery avviene all'apertura di ogni storage da parte del proprio server, poiché rimane traccia che la sessione precedente non è terminata normalmente: in questo caso tutte le transazioni presenti nel log file, e pertinenti ad un certo checkpoint number, vengono eseguite nuovamente.

Un effetto collaterale ma notevole delle transazioni, è costituito dall'invalidazione da parte di un server di tutti gli oggetti coinvolti in una transazione: questo in pratica avviene comunicando ai vari client interessati che gli oggetti in questione non sono più validi. Il tutto è possibile perché un server tiene traccia degli oggetti caricati da un certo client, che reagisce all'invalidazione buttando via gli oggetti coinvolti. Al prossimo accesso, questi verranno ricaricati col nuovo contenuto.

Cache

Al termine di una transazione, gli oggetti coinvolti vengono spostati su una cache in memoria, la cui dimensione è parametrica. Quando viene superata, vengono distrutti abbastanza oggetti da garantire la dimensione voluta. Al prossimo accesso, questi verranno ricaricati dal server in modo totalmente trasparente.

In pratica vengono gestiti due tipi di cache: una per gli oggetti usati una volta, ed una per gli oggetti usati più volte, Questo offre un criterio di discriminazione per privilegiare oggetti più' usati rispetto ad altri più' transitori.

Garbage collector

Gli oggetti di GOODS non sono direttamente cancellabili. Questa operazione viene svolta da un garbage collector distribuito, che cancella tutti gli oggetti non referenziati da alcuno altro. L'operazione è necessariamente distribuita perché i riferimenti possono essere inter-storage.

L'attivazione del GC avviene in background tramite un thread specifico, attivabile tramite un mix di condizioni vincolate da tempo e/o creazione nuovi oggetti.

Un esempio industriale

La nostra analisi di GOODS era finalizzata alla candidatura di un database object-oriented come uno dei componenti chiave per lo sviluppo di un sistema distribuito per l'archiviazione documentale.

In questo contesto sussistevano altri vincoli architetturali ed inoltre si restringeva il dominio applicativo ad un ben preciso ambito. La conseguenza diretta fu quella di modificare GOODS in una fase iniziale, e poi di riscriverlo in buona parte, stante la crescente difficoltà' di allineamento con la versione originale, anch'essa soggetta a manutenzione periodica. Infatti diverse proposte di modifica non furono accolte in quanto ristrette ad uno specifico dominio applicativo, mentre era interesse di Konstantin il mantenere GOODS come package di uso generale.

Il prodotto ottenuto da questa architettura è in uso presso clienti ed è stato portato su Windows, Linux, Solaris, AIX. Il database più significativo comprende circa 15 milioni di documenti, per un totale di circa 70 milioni di oggetti GOODS nativi, distribuiti su qualche centinaio di storages (parte su hd, parte su worms gestiti da un juke-box). Il popolamento del database avviene tramite applicativi dedicati (COLD-oriented), mentre la consultazione avviene tramite Web.

Vediamo nel seguito alcuni dei punti salienti che hanno portato alla strategia di cui sopra.

Infrastruttura CORBA-oriented

L'adozione di CORBA come middleware era un prerequisito fondamentale. Questo portava a sostituire l'intero layer SAL, con esclusione della parte di i/o che nulla ha a che vedere con CORBA. Nel caso interessi a qualcuno, il sistema adottato fu OmniORB [2], un altro esempio di package pubblico di elevato livello per qualità' e prestazioni. GOODS fu modificato per aderire a due modelli UML definiti con Rational Rose, rispettivamente client e server, ed al codice IDL da questi generato in automatico. Le modifiche riguardarono tanto la semantica del protocollo di comunicazione quanto la forma dei dati trasferiti.

Restrizione del dominio

Dovendosi orientare ad un ben preciso contesto applicativo, in sostanza un albero di documenti e folders, ne conseguì il progetto di un set di classi persistenti per le quali l'uso di mops statici e legati agli oggetti non bastava più. In sostanza, si voleva avere un mop generico adattabile a runtime, a seconda del tipo di isolation level scelto per una specifica transazione. Al contrario, il concetto originale di GOODS prevedeva la scelta di un mop per oggetto, che non poteva più cambiare.

Una diversa filosofia di server

Il server di GOODS gestisce una storage per uno specifico database. Nell'ottica di avere un sistema completamente distribuito e scalabile, è preferibile avere un server inizialmente vuoto, che attiva storages di qualsiasi database su richiesta di clients remoti. Questi reperiscono i parametri di configurazione e la locazione di ogni server tramite un registry generale, anch'esso modellato in UML e naturalmente implementato sopra il modello generale di database.

Uso di exceptions

Per motivi storici legati alla instabilità di vecchi compilatori C++ nell'uso di exceptions, GOODS non usa exceptions per segnalare situazioni anormali. Tuttavia è opinione dello scrivente che un moderno package object-oriented non possa farne a meno. Ad esempio, all'interno di metodi appartenenti ad oggetti persistenti, è normale che vengano prodotte exceptions a seguito di situazioni particolari controllate dall'applicativo, ad es. mancanza di permessi o violazione di locking. In questi casi, GOODS deve garantire la propagazione dell'exception prodotta in modo da lasciare consistente l'intero ambiente.

Controllo esterno delle transazioni

GOODS prevede un uso automatico delle transazioni, nel senso che una coppia begin/commit incapsula implicitamente un intero gruppo di metodi nested. Tuttavia si voleva lasciare all'applicativo la scelta opzionale di quando iniziare una nuova transazione e di quando terminarla con commit/rollback.

Deadlocks fra threads

In situazioni complesse di applicativi multithreads, può capitare che l'intreccio dei locks di sincronizzazione in un gruppo di oggetti porti ad un deadlock. Secondo Konstantin, questa situazione è da interpretare come errore di progetto dell'applicativo. Nella realtà, è piuttosto difficile prevedere in assoluto queste situazioni, soprattutto dovendo navigare all'interno di una rete di oggetti. Ad es. su una topologia ad albero, se un thread naviga bottom-up ed un altro naviga top-down, un deadlock è garantito. Per questo abbiamo deciso di includere un deadlock hunter, che di fronte ad una richiesta di lock, verifichi localmente se questa potrebbe portare ad un deadlock (a causa della presenza di cicli). In caso affermativo, viene prodotta un'exception e tutti i threads coinvolti vengono forzati a fallire. Questa situazione viene intercettata dagli accessors, che riprovano dopo un'attesa la cui durata è stabilita in modo euristico.

Deadlocks fra clients

Il problema è simile al precedente, con la complicazione che si tratta di un deadlock distribuito. Non sembra esistere un algoritmo definitivo che risolva questa situazione, perciò si è deciso di implementare un deadlock hunter limitatamente alla presenza di cicli di locking all'interno di una stessa storage. Le situazioni distribuite vengono controllate da timeout. Anche in questo caso, gli accessors sul client intercettano eventuali exceptions che ne derivano (risorse non disponibili) e riprovano più' tardi.

Indicizzazione

GOODS non fornisce indici come parte della piattaforma, benché offra un insieme di oggetti di supporto, come i btrees, molto utili per la costruzione di indici. In quest'ottica, gli indici sono da considerarsi come parte applicativa. In progetto in questione prevede la definizione di indici numerici e full-text, operanti su sottoalberi anche eterogenei. Un query language SQL-like è stato sviluppato per l'uso di questi indici.

Introduzione del concetto di storage readonly

Questo passo si è reso necessario a causa del comportamento intrinseco di filesystems su worm, che non consentono di riaprire un file per modificarlo, pena la riscrittura dell'intero file. Pertanto una storage, una volta scritta, deve venire dichiarata readonly. In questo caso, solo due files interessano: odb e idx. Gli altri riguardano la gestione delle transazioni e possono venire cancellati.

Memory-mapping scalabile

Il memory mapping viene usato da GOODS per gestire map e index files. Per ogni file, viene usata una singola window che mappa l'intero file. Si è però visto che nel caso di grandi storages, può capitare che il server non trovi il blocco di memoria virtuale contigua necessaria (nell'ambito dei due gigabytes disponibili in user mode per ogni processo su un'architettura a 32 bits): di conseguenza, l'apertura della storage fallisce. La soluzione scalabile prevede di usare un numero maggiore di windows on-demand (diciamo una decina); ognuna mappa una porzione di file, limitata in modo parametrico. Il memory manager del server è stato ridisegnato per rilevare l'accesso a zone non mappate e provvedere di conseguenza.

Implementazione di backup incrementale

GOODS offre un backup caldo che opera in background ed in modo concorrente con le transazioni che possono continuare. Accanto a questo, si è voluto offrire un backup incrementale, basato sul contenuto del log file, eventualmente compresso.

Controllo della consistenza fra storages

La semplice sostituzione o renaming dei files di una storage con quelli di un'altra conduce ad una situazione disastrosa. D'accordo con Murphy, se può accadere, accadrà di sicuro. Pertanto si è dovuto introdurre un meccanismo di controllo che leghi fra di loro tutte le storages di un database, nonché tutti i files di una storage. Eventuali inconsistenze vengono rilevate all'apertura.

Propagazione di eventi

GOODS non offre questa funzionalità. Tuttavia, l'object manager di ogni storage server tiene traccia di quali clients abbiano una copia di un oggetto. La generazione di eventi da parte dei clients, secondo il modello publisher/subscriber, richiede esattamente questa conoscenza. Gli eventi, conseguenti a qualsiasi azione di un client, vengono diretti in background ad uno storage server (tipicamente lo #0, sempre presente), che li riflette a tutti i clients che abbiano installato un listener per quella categoria di eventi. Fra questi ci può essere lo stesso client che ha generato l'evento, che però lo processerà nel contesto di un thread diverso da quello che lo ha emesso.

Informazioni sull'autore

Renzo Tomaselli, Ingegnere nucleare e consulente software free-lance dal 1980. Attualmente responsabile di un team dedicato alla progettazione di un sistema distribuito per il trattamento dei documenti.

È possibile consultare l'elenco degli articoli scritti da Renzo Tomaselli.

Altri articoli sul tema Database / GOODS.

Risorse

  1. Konstantin Knizhnik home page
    http://www.garret.ru/~knizhnik/
  2. OmniORB home page
    http://omniorb.sourceforge.net/
Discuti sul forum   Stampa

Cosa ne pensi di questo articolo?

Discussioni

Questo articolo o l'argomento ti ha interessato? Parliamone.