• Raffaele Fanizzi su LinkedIn

Archives

Archives / 2012 / February
  • Command Query Responsibility Segregation

    Nel precedente post vi ho proposto una rapida comparativa tra SQL Azure e l'Azure Table Storage, tentando di analizzare punti di forza e di debolezza di entrambe queste tecnologie destinate prevalentemente a cui sviluppa sistemi cloud based su Windows Azure.

    Quest'oggi vi parlerò di un design pattern denominato Command Query Responsibility Segregation che si sposa ottimamente con le caratteristiche del cloud, in particolare per lo sviluppo di sistemi che richiedano una scalabilità orizzontale elevatissima (diciamo potenzialmente infinita). Per analizzare questo design pattern partiamo da un esempio di classica architettura client-server.

    CQRS

    Si tratta della classica architettura che abbiamo spesso di fronte ai nostri occhi quando lavoriamo ad un gestionale. Il tutto si traduce nei seguenti passaggi: il client richiede al server un DTO, cioè in sostanza dati, li manipola e li reinvia al server il quale non dovrà far altro che identificare le modifiche fatte al DTO originario e tradurle in aggiornamenti nella base dati. I domain object, con l'avvento degli ORM, possono essere solitamente identificati negli EntityObject, nelle Tracking Instances, nei POCO o una qualsiasi altra forma supportata dall'ORM stesso. I DTO vengono spesso mappati 1-1 sul modello sottostante.

    Questa architettura è stata fortemente incentivata da Microsoft con tecnologie come l'Entity Framework ed i WCF RIA Services/ADO.NET Data Services e, contrariamente a quanto si possa pensare, non ha nulla a che fare con un design di tipo Domain Driven. Qualcuno potrebbe obiettare che il modello dei dati è fortemente basato sul dominio applicativo, ma questo non è esatto per almeno due ragioni. Innanzitutto modellare le entità e lo schema del database sulla base del dominio applicativo è una conseguenza della semplicità nell'eseguire questo tipo di operazione messa a disposizione dagli ORM e non una conseguenza del voler adottare un design Domain Driven. In secondo luogo, un'architettura Domain Driven non ha nulla a che fare con le classiche operazioni di CRUD gestite dall'architettura che vi ho proposto, ma piuttosto è di tipo Task Driven, cioè espone nella UI le operazioni logiche del dominio applicativo e non quelle fisiche di persistenza sul database.

    Chiaramente è possibile che Create, Update e Delete corrispondano anche ad operazioni logiche del dominio applicativo, ma di fatto normalmente quello che ci si limita a fare è recuperare i dati lato client, modificarli e lanciare una SaveChanges. Questo approccio non è un design di tipo Domain Driven ed è fatto per semplificare la vita dello sviluppatore, non per semplificare la vita dell'utente finale dell'applicazione. Volete un esempio? Pensate che, in un rubrica, recuperare dal database la riga di un contatto, esporre tutti i campi in una form, cambiare il campo "Indirizzo" e cliccare su "Salva" sia un'operazione intuitiva per chi non sa che significhi fare una INSERT, UPDATE e DELETE? E' comodo per noi sviluppatori, ma per l'utente forse sarebbe più comodo avere un pulsante denominato "Cambia indirizzo", una maschera che mostra solo l'indirizzo e un pulsante "Ok".

    Al di là del voler adottare o meno un design di tipo Domain Driven, scelta più che lecita in molteplici ambiti applicativi, il problema più grande dell'architettura sopra esposta è la assoluta centralità del database nel corso della normale operatività. In tutti i casi è necessario tradurre i dati che vogliamo recuperare in una query SQL, dobbiamo quindi risolvere il problema dell'Impedance Mismatch (lo fanno per noi gli ORM, ma ha comunque un costo), tradurre il tutto in DTO, tracciare le modifiche effettuate e ritradurle un query SQL per aggiornare la base di dati. In tutto questo giro gli RDBMS devono rispettare le ACID (Atomicity, Consistency, Isolation, e Durability) e questo rappresenta un collo di bottiglia non indifferente. Sappiate, infatti, che potete anche avere due processori da 4 core l'uno e 64 GB di RAM sul vostro server, ma se vengono lanciate in parallelo  tante operazioni sui medesimi dati, entreranno in gioco i meccanismi di ROW LOCK, RANGE LOCK o, peggio, TABLE LOCK, che metteranno tutte le operazioni in fila rendendo vano il vostro sforzo economico per la scalabilità verticale.

    CQRS

    Come se ne esce dunque? Beh la Command Query Responsibility Segregation propone un diverso approccio all'architettura di un sistema al fine di risolvere questi problemi. Il concetto fondamentale è quello della separazione delle operazioni di lettura da quelle di scrittura. Questa separazione ci permette di separare anche i rispettivi modelli: se in lettura nella UI ho bisogno di denormalizzare i miei dati, cioè di unire due informazioni che nel database sono normalizzate, ma che in visualizzazione mostro insieme, per quale ragione devo forzare il sistema ogni volta che ho bisogno di questi dati in lettura ad eseguire LOCK e JOIN? La soluzione, quindi, è di separare le letture dalle scritture sia dal punto di vista del modello, che della persistenza. Ecco, quindi, che il modello concettuale, che nell'architettura classica è mappato il più possibile sul database (o, se preferite, il contrario con un approccio Code First), si scinde in due distinti modelli: il command model ed il query model. Il primo è utilizzato per invocare da parte della UI operazioni sui dati, mentre il secondo ha il solo scopo di modellare i dati nel modo più vicino possibile ai DTO richiesti dalla UI.

    Non mi dilungherò eccessivamente nelle modalità operative con le quali è possibile implementare questo approccio, ma vi faccio un esempio di come adottarlo sulla piattaforma Azure: potremmo utilizzare SQL Azure come database sul quale lanciare i comandi di scrittura definiti nei vari command model inviati dal client, mentre l'Azure Table Storage è un eccellente candidato per persistere le informazioni opportunamente denormalizzate. In base all'analisi del software, potrebbe essere necessario replicare un dato presente una volta su SQL Azure, più volte nell'Azure Table Storage. Per esempio, si potrebbe voler avere uno stesso dato replicato N volte, una per ogni forma denormalizzata (o, se vogliamo, vista) richiesta dal client per accedere in lettura a quel dato. A tal proposito vi invito a non preoccuparvi eccessivamente dei costi di storage: il costo di 5 GB su SQL Azure corrisponde a 175 GB sul Table Storage.

    Come qualcuno avrà certamente intuito l'implementazione di quest'architettura richiede la risoluzione di un problema: mantenere opportunamente sincronizzati i dati su SQL Azure e sull'Azure Table Storage, ma la criticità di questa problematica dipende fortemente dall'ambito applicativo. Anche se aggiornare entrambe le basi di dati (indipendentemente dalla tecnica utilizzata) è sicuramente più oneroso rispetto ad aggiornarne solo una, se per ogni scrittura di un dato, vengono effettuate più letture, ecco che il costo pagato in fase di aggiornamento sarà ampiamente controbilanciato dalla scalabilità in lettura offerta dall'Azure Table Storage.

    In sostanza, maggiore è il rapporto tra le letture e le scritture, maggiore sarà il vantaggio in scalabilità potenzialmente perseguibile. Questo è sicuramente il caso dei portali web e dei social network: il numero di letture è certamente superiore, e non di poco, a quello delle scritture. Va, inoltre, considerato che non sempre è necessario un aggiornamento del dato in tempo reale. Pertanto, se è tollerabile un piccolo lasso di tempo nel quale le informazioni tra le due basi di dati non sono sincronizzate, la fase di sincronizzazione potrebbe anche essere eseguita asincronamente rispetto all'aggiornamento, rendendo quest'ultimo più rapido e non bloccante.

    altro

  • SQL Azure vs Azure Table Storage

    Una delle diatribe più interessanti alle quali si assiste nel momento in cui ci si approccia alle tecnologie per il cloud messe a disposizione da Microsoft è quella che mette in contrapposizione SQL Azure e l'Azure Table Storage.

    SQL Azure è il database relazionale marchiato Microsoft per il cloud ed essenzialmente è una versione di SQL Server che gira nei datacenter in maniera analoga alle compute instance di Windows Azure.

    SQL Azure

    Trattandosi di un database relazionale, le applicazioni che si basano su SQL Server non avranno, teoricamente, grandi difficoltà nell'utilizzare SQL Azure come database. Esistono ad oggi alcune limitazioni in questa piattaforma rispetto ad un'installazione Standard o Enterprise di SQL Server, ma nel tempo molte di queste limitazioni sono state risolte. Per esempio in SQL Azure non è disponibile la ricerca fulltext, gli Analysis Services, così come le reference cross-database (cioè la possibilità di coinvolgere in una query tabelle appartenenti a database distinti), tuttavia altre deficienze presenti nel passato sono state implementate ed un esempio è rappresentato dai Reporting Services. Per maggior informazioni in merito alle funzionalità che ci piacerebbe vedere in SQL Azure vi consiglio di fare riferimento al sito dedicato.

    L'Azure Table Storage, invece, fa parte, insieme ai Blob e Queue Storage, dei Windows Azure Storage Services, cioè di servizi di immagazinamento dei dati messi a disposizione da Microsoft nell'ambito della piattaforma cloud Windows Azure. Il Table Storage può essere considerato un database NoSQL. Cosa contraddistingue questo tipo di database? Beh contrariamente ai RDBMS che si basano sull'ormai robusto e collaudato modello relazionale e sul rispetto delle ACID (Atomicity, Consistency, Isolation, e Durability), i database NoSQL nascono per risolvere problematiche differenti e, in particolare, il loro focus è spesso la scalabilità orizzontale.

    Supponiamo di avere un server con un'istanza di database e di renderci conto che è arrivato al suo limite massimo di prestazioni a causa del carico di lavoro. Scalare verticalmente significa aumentare la potenza di calcolo del server attraverso un aggiornamento di CPU, RAM e quant'altro contribuisca a migliorarne le prestazioni. Quest'ultimo è il classico approccio che si adotta, ma non è di certo il migliore per svariante ragioni:

    • Limiti: per quanto si possano aggiornare le componenti di un server, prima o poi si raggiungerà il limite massimo della piattaforma hardware
    • Costi: il prezzo dell'hardware non scala linearmente alla sua potenza, ma spesso le componenti migliori costano svarianti ordini di grandezza più delle controparti meno prestanti
    • Scarso equilibrio: aggiornando l'hardware del proprio server si rischia di dimensionarlo sulla base di carichi di lavoro che possono rappresentare semplicemente dei picchi rispetto al carico standard. Chiaramente questa considerazione è fortemente dipendente del dominio applicativo, ma in linea di principio è pur sempre valida

    Scalare orizzontalmente significa poter affiancare all'istanza applicativa in difficoltà, una seconda, magari su un server virtuale o fisico diverso, e vederne raddoppiare il carico di lavoro sostenibile.

    I database relazionali storicamente non hanno una enorme capacità di scalare orizzontalmente, soprattutto se i dati sottoposti maggiormente agli accessi sono concentrati in alcune specifiche tabelle relazionate tra loro. Il dover sottostare al rispetto delle ACID, comporta il lock dei dati alla loro modifica al fine di conservare la consistenza del dato. La problematica diventa ancor più stringente nel momento in cui si fanno uso di transazioni che coinvolgono parecchie righe e tabelle.

    SQL Azure mette a disposizione alcune funzionalità per migliorare la scalabilità:

    • Distribuire i dati su più database ne garantisce la scalabilità orizzonale perché database SQL Azure distinti possono essere distribuiti su server differenti. Chiaramente ciò limita la possibilità di fare
    • Database Sharding: è una tecnica che consente in sostanza di partizionare i propri dati in base ad una serie di criteri (ID, Range di ID, ecc...) al fine di distribuirli automaticamente su più database e, quindi, ottenere la scalabilità orizzonale nel caso in cui le query siano distribuite più o meno equamente su tutti i database

    Con queste tecniche si può ottenere la scalabilità anche su un database relazionale on the cloud come SQL Azure, ma è possibile che ciò non sia sufficiente. Supponiamo di avere a che fare con un ambito applicativo nel quale abbiamo tantissimi dati in tabelle per le quali non è possibile effettuare lo sharding, cioè distribuirle su più database in base ad una chiave o ad una range di chiavi o che comunque, anche se possibile, non trarrebbe alcun beneficio da questa soluzione perché magari a livello di SELECT si deve accedere quasi sempre ai dati in maniera globale.

    Un altra situazione nel quale la scalabilità orizzontale di SQL Azure può comunque non essere sufficiente è rappresentata dai costi: 5 GB su SQL Azure costano circa € 35, mentre con la medesima cifra è possibile ottenere 325 GB su Table Storage con 4 milioni di transazioni al mese.

    Table Storage

    Esistono svariati tipi di database NoSQL e il Table Storage di Windows Azure può essere considerato di tipo "Key-Value". In pratica è possibile definire tabelle in ognuna delle quali ogni riga è univocamente identificata da una RowKey ed una PartitionKey, la cui coppia essenzialmente è la chiave primaria. Ogni riga può avere fino a 255 proprietà o colonne e non può contenere complessivamente più di 1 MB di dati.

    Nel processo di ingegnerizzazione di un software destinato ad usare il Table Storage è fondamentale scegliere accuratamente la RowKey e la PartitionKey. Quest'ultima in particolare rappresenta il partizionamento dei dati: tutti i dati di una partizione si trovano sul medesimo server e, quindi, sarebbe opportuno sceglierla in maniera che le interrogazioni del software siano uniformemente distribuite su tutte le partition key per massimizzarne le performance.

    Un altro aspetto interessante è la replicazione automatica dai dati in tre copie nel medesimo datacenter e in due datacenter del medesimo continente. Questa caratteristica garantisce sia una maggiore fault tolerance, sia maggiori performance di accesso in lettura ai dati stessi.

    Non è obiettivo di questo post fornire un HOW-TO delle operazioni di interrogazione su Table Storage, ma vi posso anticipare che è possibile sia farlo mediante banali richieste HTTP REST, che attraverso le comodissime API WCF Data Services che consentono di eseguire query anche mediante LINQ.

    Chiaramente non sono tutte rose e fiori: il servizio di Table Storage, infatti, non è un database relazionale e, pertanto, non vi consente di eseguire query mettendo in join più tabelle. Questa operazione è possibile eseguirla solo client side, cioè scaricando sul client i contenuti di entrambe le tabelle e mettendole in join con LINQ to Objects, ma chiaramente questa non è una soluzione ottimale da nessun punto di vista. La realtà è che se si intende far lavorare questo tipo di servizio come se fosse un database relazionale, si otterranno risultati molto poco soddisfacenti.

    Le interrogazioni su Table Storage sono molto efficienti a patto che il filtro coinvolga prevalentemente la RowKey e la PartitionKey. Qualsiasi altro uso comporterà quello che in ambito RDBMS chiameremmo un FULL SCAN e cioè qualcosa da evitare assolutamente.

    Limitazioni esistono anche nell'uso delle transazioni: queste sono supportate solo se coinvolgono non solo righe appartenenti da una sola tabella, ma devono far riferimento ad una stessa partizione di quella tabella.

    E' chiaro, quindi, che non è possibile pensare di migrare un sistema da SQL Server al Table Storage senza dover riprogettare interamente lo strato di accesso ai dati. E' necessario progettare l'architettura del software con l'obiettivo di sfruttare tutti i punti di forza del Table Storage, chiaramente laddove l'ambito applicativo lo consenta e, soprattutto, ne tragga beneficio.

    I più scettici e i più grandi sostenitori del modello relazionale sicuramente avranno già cestinato l'idea di utilizzare il servizio di Table Storage, ma vi posso assicurare che è possibile sfruttare un database non relazionale con successo e grandi soddisfazioni e social network come Facebook e Twitter sono esempi evidenti dell'uso di database NoSQL in ambiti applicativi dove la scalabilità ha la priorità su tutto, scalabilità che non si sarebbe mai raggiunta affidandosi solo ai cari vecchi database relazionali.

    Il prossimo post vi parlerò di un'architettura ideata proprio con l'obiettivo di trarre il massimo beneficio da database come il Table Storage.

    altro