Premetto che prima di questa mattina su MongoDB avevo solo letto due articoli di Msdn Magazine e mai fatto prove concrete.
http://msdn.microsoft.com/en-us/magazine/ee310029.aspx
http://msdn.microsoft.com/en-us/magazine/ff714592.aspx
Al contrario delle apparenze, il test non è scaturito dai commenti del mio ultimo post ma dall'esigenza reale presso un cliente di loggare 4 Milioni di task in parallelo su uno o due server da 24 core ciascuno.
Trattandosi di un log, i dati di cui fare storage sono molto semplici, una manciata di stringhe e un paio di interi, tutto qui.
Per fare le prove di estrazione dati la notizia bella è che anche MongoDB ha il provider Linq e quindi molto comodo da usare.
Il requisito di questo log è di poter cercare se si tratta di log di lavori conclusi con successo, con problemi trascurabili o con errori gravi. I tempi di ricerca non sono critici, quelli di scrittura si perché potrebbero rubare tempo all'elaborazione.
Il primo pensiero è andato subito a SQL Server ma ho subito pensato a quegli articoli su MongoDB letti recentemente e ho quindi deciso di fare delle prove.
Dato dei test
Nei test ho deciso di usare una classe di log molto semplice, realizzata ad-hoc per non spendere troppo tempo CPU e poter misurare le performance del solo storage.
1 public sealed class LogInfo
2 {
3 public LogInfo()
4 {
5 LogDate = DateTime.Now;
6 AssignRandomType();
7 }
8 public Guid Id { get; set; }
9 public DateTime LogDate { get; set; }
10 public string Source { get; set; }
11 public string Category { get; set; }
12 public string Message { get; set; }
13 public int LogType { get; set; }
14
15 public void AssignRandomType()
16 {
17 Random rnd = new Random((int)LogDate.Ticks);
18 LogType = rnd.Next(0, 100);
19 }
20 }
Le stringhe le ho assegnate con dei campi sempre uguali, tra i 20 e gli 800 caratteri circa (stringhe cablate nel codice).
Il tipo random è stato necessario per provare a fare delle query visto che le stringhe sono tutte uguali.
Ho quindi preparato due classi che implementano una interfaccia costituita da un metodo chiamato Log. Ho poi creato anche un'altra interfaccia di factory per poter fare delle prove usando le classi del parallel computing del framework 4.0.
La classe che inserisce i dati con SQL Server mantiene la connessione aperta ed esegue la Prepare dello statement parametrizzato di inserimento. Il metodo di Log si limita ad assegnare i valori ai parameters e a eseguire la query di insert.
1 public void Log(string Source, string Category, string Message)
2 {
3 AssignValuesToCommand(_command, Source, Category, Message);
4 _command.ExecuteNonQuery();
5 }
6
La classe che inseriesce i dati con MongoDB ha una strategia simile e si limita a popolare i valori e inserire l'oggetto dentro lo storage (chiamato log nella funzione).
1 public void Log(string source, string category, string message)
2 {
3 var info = new LogInfo()
4 {
5 Id = Guid.NewGuid(),
6 Source = source,
7 Category = category,
8 Message = message,
9 };
10
11 log.Save(info);
12 }
Il primo test è su una singola esecuzione "secca" con un loop di 10'000 elementi.
1 private void Test1(ILogServiceFactory factory, int iterations)
2 {
3 ILogService svc = factory.Create();
4
5 var sw = new Stopwatch();
6 sw.Start();
7
8 for (int i = 0; i < iterations; i++)
9 {
10 svc.Log("Test",
11 "This is a category",
12 "Sed ut perspiciatis unde omnis iste natus .... (cut!)");
13 }
14
15 svc.Dispose();
16 sw.Stop();
17 Console.WriteLine("{0}: {1} (created: {2})", factory.ToString(),
18 sw.Elapsed.TotalSeconds, factory.Count);
19 }
Dove ho scritto "(cut!)" ho tolto il lungo testo che è di circa 800 caratteri. Il resto dei dati è scritto all'interno del metodo di log.
I migliori risultati del primo test su 10'000 insert sono stati di 21,31 secondi per SQL Server contro i 0,76 secondi per MongoDB. Per "migliori" intendo che ho rieseguito enne volte il test, svuotando il db di sql (non ricreato) e cancellando i file di MongoDB. Probabilmente non sono le situazioni ideali e se qualcuno vuole metterci le mani per fare ottimizzazioni è benvenuto.
Seconda Fase
Passiamo ad un secondo test, con esecuzioni parallele con un loop di 100'000 elementi
1 private void Test2(ILogServiceFactory factory, int iterations)
2 {
3 var sw = new Stopwatch();
4 sw.Start();
5
6 Parallel.For(0, iterations, new ParallelOptions(),
7 () => { return factory.Create(); },
8 (i, state, svc) =>
9 {
10 svc.Log("Test",
11 "This is a category",
12 "Sed ut perspiciatis unde omnis iste natus .... (cut!)");
13 return svc;
14 },
15 (svc) => { svc.Dispose(); }
16 );
17
18 sw.Stop();
19 Console.WriteLine("{0}: {1} (created: {2})", factory.ToString(),
20 sw.Elapsed.TotalSeconds, factory.Count);
21 }
L'uso del parallelismo è rilevante nel mio specifico caso e probabilmente poco rilevante per il discorso di performance di MongoDB, ma volevo capire cosa succedeva accedendo in modo concorrente. Il resto del codice è identico.
Il loop Parallelo sembra complesso ma non lo è, vediamo i parametri:
- l'intero di partenza del loop
- il count totale di iterazioni
- variabile inizializzata una per thread. Questo garantisce di usare una connessione per thread evitando i problemi di thread-safety e lock
- funzione che riceve il count, lo stato del thread e la variabile del punto (3) e la restituisce in uscita
- funzione che viene chiamata subito prima di chiudere il thread. Permette la chiusura della connessione
Così facendo vengono create un numero di servizi contemporanei pari solo al numero deciso dallo scheduler, solitamente pari al numero di CPU logiche disponibili.
Il lavoro svolto non è CPU intensive e infatti il consumo su una macchina 4 core (8 considerando l'hyperthreading) non è mai andato oltre il 44%.
I migliori risultati del primo test su 100'000 insert sono stati di 294,80 secondi per SQL Server contro i 6,19 secondi di MongoDB. Tutto sommato termini comparabili con il test su singolo thread.
Avendo creato un numero notevole di record la dimensione dello storage su 100'000 elementi è già più significativa rispetto ai 10'000. Il db è stato creato "male" quindi con autogrow, ed è arrivato a 1,2GB, mentre MongoDB a 210MB. Considero questi numeri trascurabili visto che lo storage oggi ha costi bassissimi. Si potrebbe parlare delle I/O spese per lo storage ma qui i fattori diventano molteplici e si aprirebbe un capitolo complesso.
Conclusioni.
È un test banale, su singola tabella, in totale assenza di relazioni (che è la parte su cui un database come SQL Server fa la differenza). Ma è un test sul caso reale specifico del lavoro che mi serve, cioè consumare il meno possibile per produrre log. Al di fuori di questo contesto … bisogna ripetere i test e vedere che succede.
Tempo speso per "imparare" da zero ad inserire degli item in MongoDB: 2 ore. Tempo per questo post: 1 ora (uffa).
Sorgenti dei test scaricali qui
I link di download di MongoDB e lo script per creare la tabella di SQL sono nei sorgenti.
Update del 17 Settembre 2010.
Ieri sera ho trovato un errore nel sorgente che scrive i log su MongoDB. Ho usato il metodo "Save" che esegue una "upsert" (update/insert) che risultava in una query prima della insert, impoverendo significativamente le performance.
Sostituendo la Save con una Insert i tempi si dimezzano: 2,9 secondi per 100'000 inserimenti. Impostando il flush ogni secondo sale a 2,95 secondi.
Su 10'000 inserimenti i tempi sono troppo piccoli per essere significativi.
Ho quindi fatto un test per 4'000'000 di insert consecutive con un risultato tra i 119 e 140 secondi a seconda del parametro di sync (flush su disco).
Per quanto riguarda le write massive accorciando il sync, nel contesto del loggin le I/O relative alle insert sarebbero distribuite nel tempo (non dentro un loop come in questo test) e quindi non ci sarebbe affollamento di I/O.
Per la cronaca ho letto che la politica di MongoDB per ottenere maggiore reliability è di usare la replica su un altro o più server.