Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

Le elevate prestazioni sono una delle principali esigenze quando si lavora con i big data. Nel nostro team di gestione del caricamento dei dati di Sber, ci occupiamo della trasmissione praticamente di tutte le transazioni nel nostro Cloud Dati basato su Hadoop e quindi gestiamo flussi di informazioni davvero enormi. È naturale che cerchiamo sempre modi per migliorare le prestazioni e ora vogliamo raccontarvi come siamo riusciti a patchare RegionServer HBase e il client HDFS, grazie ai quali abbiamo significativamente aumentato la velocità delle operazioni di lettura.
Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

Tuttavia, prima di entrare nei dettagli delle modifiche, è importante discutere dei limiti che è impossibile superare se si utilizza un HDD.

Perché gli HDD e le letture a accesso casuale veloci sono incompatibili
Come è noto, HBase e molte altre basi di dati memorizzano i dati in blocchi, grandi alcune decine di kilobyte. Di default, questo è circa 64 KB. Immaginiamo ora di dover recuperare solo 100 byte e chiediamo a HBase di fornirci questi dati tramite una certa chiave. Poiché la dimensione del blocco negli HFiles è di 64 KB, la richiesta sarà di 640 volte più grande (solo per dire!) rispetto a quanto necessario.

Inoltre, poiché la richiesta passerà attraverso HDFS e il suo meccanismo di caching dei metadati ShortCircuitCache (che consente l'accesso diretto ai file), questo porta a leggere già 1 MB dal disco. Tuttavia, questo può essere regolato tramite il parametro dfs.client.read.shortcircuit.buffer.size e in molti casi ha senso ridurre questo valore, ad esempio a 126 KB.

Supponiamo di fare ciò, ma inoltre, quando iniziamo a leggere i dati tramite l'API java, usando funzioni come FileChannel.read e chiediamo al sistema operativo di leggere una certa quantità di dati, esso legge 'per precauzione' il doppio, cioè 256 KB nel nostro caso. Questo accade perché in java non c'è un modo semplice per impostare il flag FADV_RANDOM, che impedisce tale comportamento.

Di conseguenza, per ottenere i nostri 100 byte, il sistema legge 2600 volte di più. Sembrerebbe ovvio pensare, riduciamo la dimensione del blocco a un kilobyte, impostiamo il flag menzionato e otteniamo un grande aumento di velocità. Ma il problema è che riducendo la dimensione del blocco della metà, riduciamo anche la quantità di byte letti in un'unità di tempo della stessa metà.

Un certo guadagno dall'impostazione del flag FADV_RANDOM può essere ottenuto, ma solo con un'elevata multithreading e con una dimensione del blocco di almeno 128 KB, ma questo rappresenta al massimo una manciata di punti percentuali.

Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

I test sono stati effettuati su 100 file, ciascuno della dimensione di 1 GB e distribuiti su 10 dischi HDD.

Calcoliamo quanto possiamo realisticamente aspettarci con questa velocità:
Supponiamo di leggere da 10 dischi a una velocità di 280 MB/sec, quindi 3 milioni di volte 100 byte. Ma come sappiamo, i dati di cui abbiamo bisogno si presentano 2600 volte meno frequentemente di quanto venga letto. Pertanto, dividiamo 3 milioni per 2600 e otteniamo 1100 registrazioni al secondo.

Deludente, vero? Questa è la natura Random Access dell'accesso ai dati su HDD — indipendentemente dalla dimensione del blocco. Questo è il limite fisico dell'accesso casuale e nessun database può ottenere di più in queste condizioni.

Come riescono allora i database a raggiungere velocità molto più elevate? Per rispondere a questa domanda, diamo un'occhiata a cosa succede nell'immagine seguente:

Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

Qui vediamo che nei primi minuti la velocità è davvero intorno alle mille righe al secondo. Tuttavia, successivamente, poiché vengono letti molti più dati di quanto richiesto, i dati si accumulano nel buff/cache del sistema operativo (linux) e la velocità cresce fino a un più rispettabile 60.000 al secondo.

Pertanto, in seguito ci concentreremo sull'accelerazione dell'accesso solo ai dati che sono presenti nella cache del sistema operativo o si trovano in archiviazioni comparabili per velocità come SSD/NVMe.

Nel nostro caso, condurremo dei test su un banco di prova composto da 4 server, ognuno dei quali è configurato nel seguente modo:

CPU: Xeon E5-2680 v4 @ 2.40GHz 64 thread.
Memoria: 730 Gb.
versione java: 1.8.0_111

E qui il punto chiave: il volume di dati nelle tabelle che devono essere letti. Infatti, se si leggono dati da una tabella che può essere interamente memorizzata nella cache di HBase, non arriveremo nemmeno a leggere dal buff/cache del sistema operativo. Questo perché HBase riserva per impostazione predefinita il 40% della memoria per una struttura chiamata BlockCache. Essenzialmente si tratta di un ConcurrentHashMap, dove la chiave è il nome del file più l'offset del blocco, e il valore sono propriamente i dati relativi a questo offset.

Pertanto, quando la lettura avviene solo da questa struttura, noi vediamo una velocità sorprendente, come un milione di richieste al secondo. Ma immaginiamo di non poter dedicare centinaia di gigabyte di memoria solo per le esigenze del database, perché su questi server girano molte altre cose utili.

Ad esempio, nel nostro caso, il volume del BlockCache su un RS è di circa 12 GB. Abbiamo distribuito due RS su un nodo, quindi per il BlockCache sono stati riservati 96 GB su tutti i nodi. E i dati sono molte volte di più, ad esempio supponiamo che ci siano 4 tabelle, con 130 regioni, in cui i file hanno una dimensione di 800 MB, compressi con FAST_DIFF, quindi in totale 410 GB (questi sono dati puri, senza considerare il fattore di replica).

Pertanto, il BlockCache rappresenta solo circa il 23% del volume totale dei dati, e questo è molto più vicino alle reali condizioni di ciò che si chiama BigData. E qui inizia la parte interessante: è ovvio che quanto meno colpiamo il cache, peggiore è la performance. Infatti, in caso di errore dovremo compiere un sacco di lavoro, cioè scendere fino alla chiamata delle funzioni di sistema. Tuttavia, non possiamo evitarlo, quindi consideriamo un aspetto completamente diverso: cosa succede ai dati all'interno della cache?

Semplifichiamo la situazione e ipotizziamo di avere una cache che può contenere solo 1 oggetto. Ecco un esempio di cosa accadrebbe tentando di gestire un volume di dati tre volte superiore alla cache, ci tocca:

1. Inserire il blocco 1 nella cache
2. Rimuovere il blocco 1 dalla cache
3. Inserire il blocco 2 nella cache
4. Rimuovere il blocco 2 dalla cache
5. Inserire il blocco 3 nella cache

Sono state eseguite 5 azioni! Tuttavia, non possiamo definire normale questa situazione, in sostanza costringiamo HBase a svolgere un sacco di lavoro completamente inutile. Continuamente legge i dati dalla cache del sistema operativo, li inserisce nel BlockCache, per poi scartarli quasi immediatamente, poiché è arrivato un nuovo lotto di dati. L'animazione all'inizio del post mostra l'essenza del problema: il Garbage Collector è fuori controllo, l'atmosfera si surriscalda, la piccola Greta, lontana e in una calda Svezia, si rattrista. E noi informatici non amiamo molto vedere i bambini tristi, quindi iniziamo a pensare a cosa possiamo fare al riguardo.

E se invece di inserire tutti i blocchi nella cache, ne inserissimo solo una certa percentuale, in modo che la cache non si riempisse eccessivamente? Iniziamo semplicemente aggiungendo qualche riga di codice all'inizio della funzione che gestisce l'inserimento dei dati in BlockCache:

  public void cacheBlock(BlockCacheKey cacheKey, Cacheable buf, boolean inMemory) {
    if (cacheDataBlockPercent != 100 && buf.getBlockType().isData()) {
      if (cacheKey.getOffset() % 100 >= cacheDataBlockPercent) {
        return;
      }
    }
...

Il significato qui è il seguente: l'offset è la posizione del blocco nel file e le ultime cifre sono distribuite casualmente e uniformemente da 00 a 99. Pertanto, salteremo solo quelli che rientrano nell'intervallo desiderato.

Ad esempio, impostiamo cacheDataBlockPercent = 20 e vediamo cosa succede:

Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

Il risultato è evidente. Nei grafici sottostanti diventa chiaro cosa ha portato a questo aumento della velocità: risparmiamo molte risorse del GC evitando di allocare dati nella cache solo per buttarli immediatamente.

Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

L'utilizzo della CPU aumenta, ma in misura significativamente inferiore rispetto alla produttività:

Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

Vale la pena notare che i blocchi memorizzati nel BlockCache possono essere diversi. La maggior parte, circa il 95%, sono dati reali. E il resto consiste in metadati, come i filtri Bloom o LEAF_INDEX e ecc.. Questi dati sono scarsi, ma molto utili, poiché prima di accedere ai dati, HBase consulta i metadati per capire se è necessario cercare ulteriormente e, in tal caso, dove si trova esattamente il blocco di interesse.

Pertanto, nel codice vediamo la condizione di controllo buf.getBlockType().isData() e grazie a questo metadato saremo sempre in grado di lasciare nel cache.

Ora aumentiamo il carico e miglioriamo leggermente la funzionalità. Nel primo test abbiamo impostato la percentuale di taglio = 20 e il BlockCache era leggermente sotto carico. Adesso impostiamo il 23% e aggiungeremo 100 thread ogni 5 minuti per vedere a che punto si verifica la saturazione:

Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

Qui vediamo che la versione originale incontra subito un limite, con circa 100.000 richieste al secondo. Mentre la patch fornisce un'accelerazione fino a 300.000. È chiaro che ulteriori accelerazioni non sono così 'gratuite', la percentuale di utilizzo della CPU cresce anch'essa.

Tuttavia, questa non è una soluzione molto elegante, poiché non sappiamo in anticipo quale percentuale di blocchi deve essere memorizzata nella cache, poiché dipende dal profilo del carico. Pertanto, è stato implementato un meccanismo di auto-regolazione di questo parametro in base all'attività delle operazioni di lettura.

Per gestire ciò, sono stati aggiunti tre parametri:

hbase.lru.cache.heavy.eviction.count.limit — imposta quante volte deve essere avviato il processo di espulsione dei dati dalla cache prima che iniziamo a utilizzare l'ottimizzazione (cioè, a saltare i blocchi). Di default è impostato su MAX_INT = 2147483647 e significa effettivamente che la funzionalità non inizierà mai a funzionare con questo valore. Poiché il processo di espulsione viene avviato ogni 5 - 10 secondi (a seconda del carico) e 2147483647 * 10 / 60 / 60 / 24 / 365 = 680 anni. Tuttavia, possiamo impostare questo parametro a 0 e far funzionare la funzionalità immediatamente dopo l'avvio.

Tuttavia, c'è anche un utile carico utile in questo parametro. Se il nostro carico ha un carattere tale da alternare continuamente letture a breve termine (ad esempio durante il giorno) e letture a lungo termine (durante la notte), possiamo configurare in modo che la funzionalità si attivi solo durante le operazioni di lettura prolungate.

Ad esempio, sappiamo che le letture a breve termine durano solitamente circa 1 minuto. Non dobbiamo iniziare a rimuovere i blocchi, la cache non avrà il tempo di scadere e quindi possiamo impostare questo parametro, ad esempio, a 10. Questo porterà al fatto che l'ottimizzazione inizierà a funzionare solo quando è iniziata una lettura attiva prolungata, ovvero dopo 100 secondi. Così, se abbiamo una lettura a breve termine, tutti i blocchi andranno in cache e saranno accessibili (eccetto quelli che verranno rimossi dall'algoritmo standard). E quando facciamo letture a lungo termine, la funzione si attiva e abbiamo prestazioni notevolmente più elevate.

hbase.lru.cache.heavy.eviction.mb.size.limit — stabilisce quanti megabyte desideriamo immagazzinare nella cache (e naturalmente espellere) in 10 secondi. La funzione cercherà di raggiungere questo valore e mantenerlo. L'idea è la seguente: se memorizziamo gigabyte nella cache, dovremo espellere anche gigabyte, e questo, come abbiamo visto sopra, può risultare piuttosto oneroso. Tuttavia, non bisogna cercare di impostarlo troppo basso, poiché ciò porterà a un'uscita prematura dalla modalità di bypass dei blocchi. Per server potenti (circa 20-40 core fisici) è ottimale impostare circa 300-400 MB. Per il segmento medio (~10 core) 200-300 MB. Per sistemi più deboli (2-5 core) 50-100 MB potrebbe andare bene (non testato su tali sistemi).

Consideriamo come funziona: supponiamo di aver impostato hbase.lru.cache.heavy.eviction.mb.size.limit = 500, c'è un certo carico (lettura) e quindi ogni ~10 secondi calcoliamo quanti byte sono stati espulsi utilizzando la formula:

Overhead = Somma dei byte Liberati (MB) * 100 / Limite (MB) — 100;

Se effettivamente sono stati espulsi 2000 MB, l'Overhead risulta essere:

2000 * 100 / 500 — 100 = 300%

Gli algoritmi cercano di mantenere un massimo di alcuni decimi percento, quindi la funzionalità ridurrà la percentuale di blocchi memorizzabili, implementando così un meccanismo di auto-tuning.

Tuttavia, se il carico è diminuito, ad esempio, sono stati rimossi solo 200 MB e l'Overhead è diventato negativo (il cosiddetto overshooting):

200 * 100 / 500 — 100 = -60%

In questo caso, la funzionalità aumenterà invece la percentuale di blocchi memorizzabili fino a quando l'Overhead non diventa positivo.

Di seguito un esempio di come appare sui dati reali. Non cercare di raggiungere lo 0%, è impossibile. È piuttosto buono mantenere valori intorno al 30 — 100%, ciò aiuta a evitare un'uscita prematura dalla modalità di ottimizzazione durante picchi a breve termine.

hbase.lru.cache.heavy.eviction.overhead.coefficient — imposta quanto velocemente desideriamo ottenere un risultato. Se sappiamo con certezza che le nostre letture sono principalmente lunghe e non vogliamo aspettare, possiamo aumentare questo coefficiente e ottenere prestazioni elevate più rapidamente.

Ad esempio, abbiamo impostato questo fattore = 0,01. Questo significa che l'Overhead (vedi sopra) verrà moltiplicato per questo numero sul risultato ottenuto e ridurrà la percentuale di blocchi memorizzabili. Supponiamo che l'Overhead = 300%, e il fattore = 0,01, quindi la percentuale di blocchi memorizzabili sarà ridotta del 3%.

Una logica simile di «Backpressure» è implementata anche per valori negativi di Overhead (overshooting). Poiché ci possono sempre essere fluttuazioni a breve termine del volume di letture-uscite, questo meccanismo consente di evitare un'uscita prematura dalla modalità di ottimizzazione. Il Backpressure ha una logica invertita: quanto più forte è l'overshooting, tanto più blocchi vengono memorizzati.

Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

Codice di implementazione

        LruBlockCache cache = this.cache.get();
        if (cache == null) {
          break;
        }
        freedSumMb += cache.evict() / 1024 / 1024;
        /*
        * A volte stiamo leggendo più dati di quanto possa contenere il BlockCache
        * e questo causa un alto tasso di espulsioni.
        * Questo, a sua volta, porta a un intenso lavoro del Garbage Collector.
        * Quindi ci sono molti blocchi inseriti nel BlockCache ma mai letti,
        * ma sprecando molte risorse CPU.
        * Qui analizzeremo quanti byte sono stati liberati e decideremo
        * se è il momento di ridurre il numero di blocchi cache.
        * Questo aiuta a evitare di mettere troppi blocchi nel BlockCache
        * quando evict() funziona molto attivamente, risparmiando CPU per altri compiti.
        * Ulteriori dettagli: https://issues.apache.org/jira/browse/HBASE-23887
        */

        // Prima di tutto dobbiamo controllare quanto tempo
        // è passato dall'ultima esecuzione di evict()
        // Dovrebbe essere quasi lo stesso tempo (+/- 10s)
        // perché otteniamo volumi comparabili di byte liberati ogni volta.
        // 10s perché questo è il periodo di default per eseguire evict() (vedi sopra this.wait)
        long stopTime = System.currentTimeMillis();
        if ((stopTime - startTime) > 1000 * 10 - 1) {
          // Qui dobbiamo calcolare quale situazione abbiamo.
          // Abbiamo il limite "hbase.lru.cache.heavy.eviction.bytes.size.limit"
          // e possiamo calcolare il sovraccarico su di esso.
          // Utilizzeremo queste informazioni per decidere,
          // come cambiare la percentuale dei blocchi cache.
          freedDataOverheadPercent =
            (int) (freedSumMb * 100 / cache.heavyEvictionMbSizeLimit) - 100;
          if (freedSumMb > cache.heavyEvictionMbSizeLimit) {
            // Ora ci troviamo in una situazione in cui siamo sopra il limite
            // Ma forse stiamo per ignorarlo perché terminerà piuttosto presto
            heavyEvictionCount++;
            if (heavyEvictionCount > cache.heavyEvictionCountLimit) {
              // Sta durando a lungo e dobbiamo ridurre i blocchi cache
              // ora. Quindi calcoliamo quanti blocchi vogliamo saltare.
              // Dipende da:
              // 1. Sovraccarico - se il sovraccarico è grande, potremmo essere più aggressivi
              // nella riduzione del numero di blocchi cache.
              // 2. Quanto velocemente vogliamo ottenere il risultato. Se sappiamo che il nostro
              // carico di lettura è lungo, non vogliamo aspettare e possiamo
              // aumentare il coefficiente e ottenere buone prestazioni abbastanza presto.
              // Ma se non siamo sicuri possiamo farlo lentamente e potrebbe prevenire
              // un'uscita prematura da questo stato. Quindi, quando il coefficiente è
              // più alto, possiamo ottenere migliori prestazioni quando la lettura intensa è stabile.
              // Ma quando la lettura cambia possiamo adattarci ad essa e impostare
              // il coefficiente a un valore inferiore.
              int change =
                (int) (freedDataOverheadPercent * cache.heavyEvictionOverheadCoefficient);
              // Ma la pratica dimostra che una riduzione del 15% è più che sufficiente.
              // Non siamo avido (potrebbe portare a un'uscita prematura).
              change = Math.min(15, change);
              change = Math.max(0, change); // Penso che non succederà mai, ma controlliamo per sicurezza
              // Quindi questo è il punto chiave, qui stiamo riducendo la percentuale di blocchi cache
              cache.cacheDataBlockPercent -= change;
              // Se scendiamo troppo in basso, dobbiamo fermarci qui, 1% in ogni caso dovrebbe essere.
              cache.cacheDataBlockPercent = Math.max(1, cache.cacheDataBlockPercent);
            }
          } else {
            // Beh, abbiamo avuto un'eccedenza.
            // Forse è solo una fluttuazione a breve termine e possiamo rimanere in questo stato.
            // Questo aiuta a evitare un'uscita prematura durante una fluttuazione a breve termine.
            // Se l'eccedenza è inferiore al 90%, proveremo ad aumentare la percentuale di
            // blocchi cache e speriamo che sia sufficiente.
            if (freedSumMb >= cache.heavyEvictionMbSizeLimit * 0.1) {
              // Logica semplice: maggiore eccedenza - più blocchi cache (backpressure)
              int change = (int) (-freedDataOverheadPercent * 0.1 + 1);
              cache.cacheDataBlockPercent += change;
              // Ma non può essere più del 100%, quindi controlliamo.
              cache.cacheDataBlockPercent = Math.min(100, cache.cacheDataBlockPercent);
            } else {
              // Sembra che la lettura intensa sia finita.
              // Uscire semplicemente da questo stato.
              heavyEvictionCount = 0;
              cache.cacheDataBlockPercent = 100;
            }
          }
          LOG.info("BlockCache espulso (MB): {}, sovraccarico (%): {}, " +
            "contatore di espulsioni intense: {}, " +
            "percentuale attuale dei blocchi DataBlock cache (%): {}",
            freedSumMb, freedDataOverheadPercent,
            heavyEvictionCount, cache.cacheDataBlockPercent);

          freedSumMb = 0;
          startTime = stopTime;
       }

Ora consideriamo tutto questo con un esempio reale. Abbiamo il seguente scenario di test:

  1. Iniziamo a eseguire la scansione (25 thread, batch = 100)
  2. Dopo 5 minuti aggiungiamo i multi-gets (25 thread, batch = 100)
  3. Dopo 5 minuti disattiviamo i multi-gets (rimane solo la scansione)

Eseguiamo due giri, prima con hbase.lru.cache.heavy.eviction.count.limit = 10000 (che disattiva effettivamente la funzione), e poi impostiamo limit = 0 (che la attiva).

Nei log sottostanti vediamo come la funzione si attiva, ripristinando l'Overshooting al 14-71%. Di tanto in tanto il carico si riduce, attivando il Backpressure e HBase inizia a memorizzare più blocchi.

Log RegionServer
evicted (MB): 0, rapporto 0.0, sovraccarico (%): -100, contatore di espulsione pesante: 0, percentuale corrente di memorizzazione DataBlock: 100
evicted (MB): 0, rapporto 0.0, sovraccarico (%): -100, contatore di espulsione pesante: 0, percentuale corrente di memorizzazione DataBlock: 100
evicted (MB): 2170, rapporto 1.09, sovraccarico (%): 985, contatore di espulsione pesante: 1, percentuale corrente di memorizzazione DataBlock: 91 < start
evicted (MB): 3763, rapporto 1.08, sovraccarico (%): 1781, contatore di espulsione pesante: 2, percentuale corrente di memorizzazione DataBlock: 76
evicted (MB): 3306, rapporto 1.07, sovraccarico (%): 1553, contatore di espulsione pesante: 3, percentuale corrente di memorizzazione DataBlock: 61
evicted (MB): 2508, rapporto 1.06, sovraccarico (%): 1154, contatore di espulsione pesante: 4, percentuale corrente di memorizzazione DataBlock: 50
evicted (MB): 1824, rapporto 1.04, sovraccarico (%): 812, contatore di espulsione pesante: 5, percentuale corrente di memorizzazione DataBlock: 42
evicted (MB): 1482, rapporto 1.03, sovraccarico (%): 641, contatore di espulsione pesante: 6, percentuale corrente di memorizzazione DataBlock: 36
evicted (MB): 1140, rapporto 1.01, sovraccarico (%): 470, contatore di espulsione pesante: 7, percentuale corrente di memorizzazione DataBlock: 32
evicted (MB): 913, rapporto 1.0, sovraccarico (%): 356, contatore di espulsione pesante: 8, percentuale corrente di memorizzazione DataBlock: 29
espulso (MB): 912, rapporto 0,89, sovraccarico (%): 356, contatore di espulsioni pesanti: 9, attuale DataBlock di caching (%): 26
espulso (MB): 684, rapporto 0,76, sovraccarico (%): 242, contatore di espulsioni pesanti: 10, attuale DataBlock di caching (%): 24
espulso (MB): 684, rapporto 0,61, sovraccarico (%): 242, contatore di espulsioni pesanti: 11, attuale DataBlock di caching (%): 22
espulso (MB): 456, rapporto 0,51, sovraccarico (%): 128, contatore di espulsioni pesanti: 12, attuale DataBlock di caching (%): 21
espulso (MB): 456, rapporto 0,42, sovraccarico (%): 128, contatore di espulsioni pesanti: 13, attuale DataBlock di caching (%): 20
espulso (MB): 456, rapporto 0,33, sovraccarico (%): 128, contatore di espulsioni pesanti: 14, attuale DataBlock di caching (%): 19
espulso (MB): 342, rapporto 0,33, sovraccarico (%): 71, contatore di espulsioni pesanti: 15, attuale DataBlock di caching (%): 19
espulso (MB): 342, rapporto 0,32, sovraccarico (%): 71, contatore di espulsioni pesanti: 16, attuale DataBlock di caching (%): 19
espulso (MB): 342, rapporto 0,31, sovraccarico (%): 71, contatore di espulsioni pesanti: 17, attuale DataBlock di caching (%): 19
espulso (MB): 228, rapporto 0,3, sovraccarico (%): 14, contatore di espulsioni pesanti: 18, attuale DataBlock di caching (%): 19
espulso (MB): 228, rapporto 0,29, sovraccarico (%): 14, contatore di espulsioni pesanti: 19, attuale DataBlock di caching (%): 19
espulso (MB): 228, rapporto 0,27, sovraccarico (%): 14, contatore di espulsioni pesanti: 20, attuale DataBlock di caching (%): 19
espulso (MB): 228, rapporto 0,25, sovraccarico (%): 14, contatore di espulsioni pesanti: 21, attuale DataBlock di caching (%): 19
espulso (MB): 228, rapporto 0,24, sovraccarico (%): 14, contatore di espulsioni pesanti: 22, attuale DataBlock di caching (%): 19
espulso (MB): 228, rapporto 0,22, sovraccarico (%): 14, contatore di espulsioni pesanti: 23, attuale DataBlock di caching (%): 19
espulso (MB): 228, rapporto 0,21, sovraccarico (%): 14, contatore di espulsioni pesanti: 24, attuale DataBlock di caching (%): 19
espulso (MB): 228, rapporto 0,2, sovraccarico (%): 14, contatore di espulsioni pesanti: 25, attuale DataBlock di caching (%): 19
espulso (MB): 228, rapporto 0.17, sovraccarico (%): 14, contatore di espulsione pesante: 26, dati attuali nella cache DataBlock (%): 19
espulso (MB): 456, rapporto 0.17, sovraccarico (%): 128, contatore di espulsione pesante: 27, dati attuali nella cache DataBlock (%): 18 < added gets (but table the same)
espulso (MB): 456, rapporto 0.15, sovraccarico (%): 128, contatore di espulsione pesante: 28, dati attuali nella cache DataBlock (%): 17
espulso (MB): 342, rapporto 0.13, sovraccarico (%): 71, contatore di espulsione pesante: 29, dati attuali nella cache DataBlock (%): 17
espulso (MB): 342, rapporto 0.11, sovraccarico (%): 71, contatore di espulsione pesante: 30, dati attuali nella cache DataBlock (%): 17
espulso (MB): 342, rapporto 0.09, sovraccarico (%): 71, contatore di espulsione pesante: 31, dati attuali nella cache DataBlock (%): 17
espulso (MB): 228, rapporto 0.08, sovraccarico (%): 14, contatore di espulsione pesante: 32, dati attuali nella cache DataBlock (%): 17
espulso (MB): 228, rapporto 0.07, sovraccarico (%): 14, contatore di espulsione pesante: 33, dati attuali nella cache DataBlock (%): 17
espulso (MB): 228, rapporto 0.06, sovraccarico (%): 14, contatore di espulsione pesante: 34, dati attuali nella cache DataBlock (%): 17
espulso (MB): 228, rapporto 0.05, sovraccarico (%): 14, contatore di espulsione pesante: 35, dati attuali nella cache DataBlock (%): 17
espulso (MB): 228, rapporto 0.05, sovraccarico (%): 14, contatore di espulsione pesante: 36, dati attuali nella cache DataBlock (%): 17
espulso (MB): 228, rapporto 0.04, sovraccarico (%): 14, contatore di espulsione pesante: 37, dati attuali nella cache DataBlock (%): 17
espulso (MB): 109, rapporto 0.04, sovraccarico (%): -46, contatore di espulsione pesante: 37, dati attuali nella cache DataBlock (%): 22 < back pressure
espulso (MB): 798, rapporto 0.24, sovraccarico (%): 299, contatore di espulsione pesante: 38, dati attuali nella cache DataBlock (%): 20
espulso (MB): 798, rapporto 0.29, sovraccarico (%): 299, contatore di espulsione pesante: 39, dati attuali nella cache DataBlock (%): 18
espulso (MB): 570, rapporto 0.27, sovraccarico (%): 185, contatore di espulsione pesante: 40, dati attuali nella cache DataBlock (%): 17
espulso (MB): 456, rapporto 0.22, sovraccarico (%): 128, contatore di espulsione pesante: 41, dati attuali nella cache DataBlock (%): 16
espulsi (MB): 342, rapporto 0.16, sovraccarico (%): 71, contatore di espulsione pesante: 42, attuale DataBlock di caching (%): 16
espulsi (MB): 342, rapporto 0.11, sovraccarico (%): 71, contatore di espulsione pesante: 43, attuale DataBlock di caching (%): 16
espulsi (MB): 228, rapporto 0.09, sovraccarico (%): 14, contatore di espulsione pesante: 44, attuale DataBlock di caching (%): 16
espulsi (MB): 228, rapporto 0.07, sovraccarico (%): 14, contatore di espulsione pesante: 45, attuale DataBlock di caching (%): 16
espulsi (MB): 228, rapporto 0.05, sovraccarico (%): 14, contatore di espulsione pesante: 46, attuale DataBlock di caching (%): 16
espulsi (MB): 222, rapporto 0.04, sovraccarico (%): 11, contatore di espulsione pesante: 47, attuale DataBlock di caching (%): 16
espulsi (MB): 104, rapporto 0.03, sovraccarico (%): -48, contatore di espulsione pesante: 47, attuale DataBlock di caching (%): 21 < interruzione in corso
espulsi (MB): 684, rapporto 0.2, sovraccarico (%): 242, contatore di espulsione pesante: 48, attuale DataBlock di caching (%): 19
espulsi (MB): 570, rapporto 0.23, sovraccarico (%): 185, contatore di espulsione pesante: 49, attuale DataBlock di caching (%): 18
espulsi (MB): 342, rapporto 0.22, sovraccarico (%): 71, contatore di espulsione pesante: 50, attuale DataBlock di caching (%): 18
espulsi (MB): 228, rapporto 0.21, sovraccarico (%): 14, contatore di espulsione pesante: 51, attuale DataBlock di caching (%): 18
espulsi (MB): 228, rapporto 0.2, sovraccarico (%): 14, contatore di espulsione pesante: 52, attuale DataBlock di caching (%): 18
espulsi (MB): 228, rapporto 0.18, sovraccarico (%): 14, contatore di espulsione pesante: 53, attuale DataBlock di caching (%): 18
espulsi (MB): 228, rapporto 0.16, sovraccarico (%): 14, contatore di espulsione pesante: 54, attuale DataBlock di caching (%): 18
espulsi (MB): 228, rapporto 0.14, sovraccarico (%): 14, contatore di espulsione pesante: 55, attuale DataBlock di caching (%): 18
espulsi (MB): 112, rapporto 0.14, sovraccarico (%): -44, contatore di espulsione pesante: 55, attuale DataBlock di caching (%): 23 < pressione di ritorno
espulsi (MB): 456, rapporto 0.26, sovraccarico (%): 128, contatore di espulsione pesante: 56, attuale DataBlock di caching (%): 22
evicti (MB): 342, rapporto 0.31, overhead (%): 71, contatore di espulsione pesante: 57, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.33, overhead (%): 71, contatore di espulsione pesante: 58, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.33, overhead (%): 71, contatore di espulsione pesante: 59, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.33, overhead (%): 71, contatore di espulsione pesante: 60, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.33, overhead (%): 71, contatore di espulsione pesante: 61, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.33, overhead (%): 71, contatore di espulsione pesante: 62, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.33, overhead (%): 71, contatore di espulsione pesante: 63, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.32, overhead (%): 71, contatore di espulsione pesante: 64, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.33, overhead (%): 71, contatore di espulsione pesante: 65, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.33, overhead (%): 71, contatore di espulsione pesante: 66, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.32, overhead (%): 71, contatore di espulsione pesante: 67, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.33, overhead (%): 71, contatore di espulsione pesante: 68, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.32, overhead (%): 71, contatore di espulsione pesante: 69, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.32, overhead (%): 71, contatore di espulsione pesante: 70, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.33, overhead (%): 71, contatore di espulsione pesante: 71, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.33, overhead (%): 71, contatore di espulsione pesante: 72, percentuale attuale del DataBlock di caching: 22
evicti (MB): 342, rapporto 0.33, overhead (%): 71, contatore di espulsione pesante: 73, percentuale attuale del DataBlock di caching: 22
espulsi (MB): 342, rapporto 0.33, sovraccarico (%): 71, contatore di espulsione pesante: 74, dati attuali nel DataBlock di caching (%): 22
espulsi (MB): 342, rapporto 0.33, sovraccarico (%): 71, contatore di espulsione pesante: 75, dati attuali nel DataBlock di caching (%): 22
espulsi (MB): 342, rapporto 0.33, sovraccarico (%): 71, contatore di espulsione pesante: 76, dati attuali nel DataBlock di caching (%): 22
espulsi (MB): 21, rapporto 0.33, sovraccarico (%): -90, contatore di espulsione pesante: 76, dati attuali nel DataBlock di caching (%): 32
evicted (MB): 0, rapporto 0.0, sovraccarico (%): -100, contatore di espulsione pesante: 0, percentuale corrente di memorizzazione DataBlock: 100
evicted (MB): 0, rapporto 0.0, sovraccarico (%): -100, contatore di espulsione pesante: 0, percentuale corrente di memorizzazione DataBlock: 100

Le scansioni erano necessarie per rappresentare questo stesso processo come un grafico che mostra il rapporto tra due sezioni della cache: single (dove vanno a finire i blocchi che non sono mai stati richiesti) e multi (dove sono memorizzati i dati che sono stati "richiesti" almeno una volta):

Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

Infine, ecco come appare il funzionamento dei parametri in forma grafica. Per confronto, la cache era completamente disattivata all'inizio, quindi è stata avviata HBase con caching e un ritardo nell'inizio dell'ottimizzazione di 5 minuti (30 cicli di espulsione).

Il codice completo può essere trovato nella Pull Request HBASE 23887 su github.

Tuttavia, 300.000 letture al secondo non sono tutto ciò che può essere estratto su questo hardware in queste condizioni. Infatti, quando è necessario accedere ai dati tramite HDFS, viene utilizzato il meccanismo ShortCircuitCache (SSC), che consente di accedere ai dati direttamente, evitando interazioni di rete.

La profilazione ha mostrato che questo meccanismo, sebbene offra un grande guadagno, diventa a un certo punto un collo di bottiglia, poiché praticamente tutte le operazioni pesanti avvengono all'interno del lock, portando così a blocchi per la maggior parte del tempo.

Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

Riconoscendo ciò, abbiamo capito che il problema può essere aggirato creando un array di SSC indipendenti:

private final ShortCircuitCache[] shortCircuitCache;
...
shortCircuitCache = new ShortCircuitCache[this.clientShortCircuitNum];
for (int i = 0; i < this.clientShortCircuitNum; i++)
  this.shortCircuitCache[i] = new ShortCircuitCache(…);

E successivamente lavorare con essi, escludendo le intersezioni anche in base all'ultima cifra dell'offset:

public ShortCircuitCache getShortCircuitCache(long idx) {
    return shortCircuitCache[(int) (idx % clientShortCircuitNum)];
}

Ora possiamo procedere con i test. A tal fine, leggeremo i file da HDFS utilizzando una semplice applicazione multithreading. Impostiamo i parametri:

conf.set("dfs.client.read.shortcircuit", "true");
conf.set("dfs.client.read.shortcircuit.buffer.size", "65536"); // di default = 1 MB e questo rallenta significativamente la lettura, quindi è meglio adattarlo alle reali necessità
conf.set("dfs.client.short.circuit.num", num); // da 1 a 10

E semplicemente leggiamo i file:

FSDataInputStream in = fileSystem.open(path);
for (int i = 0; i  900000000)
        position = 0L;
    int res = in.read(position, byteBuffer, 0, 65536);
}

Questo codice viene eseguito in thread separati e aumenteremo il numero di file leggibili simultaneamente (da 10 a 200 — asse orizzontale) e il numero di cache (da 1 a 10 — grafico). L'asse verticale mostra l'accelerazione fornita dall'incremento della SSC rispetto al caso in cui c'è solo una cache.

Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

Come leggere il grafico: il tempo di esecuzione di 100.000 letture in blocchi da 64 KB con una sola cache richiede 78 secondi. Mentre con 5 cache si esegue in 16 secondi. Quindi, si ha un'accelerazione di circa 5 volte. Come si può vedere dal grafico, con un numero ridotto di letture parallele l'effetto non è molto evidente, diventa significativo quando il numero di letture supera 50. È anche evidente che un aumento del numero di SSC da 6 in poi offre un incremento delle prestazioni notevolmente ridotto.

Nota 1: poiché i risultati del test sono piuttosto volatili (vedi sotto), sono stati effettuati 3 avvii e i valori ottenuti sono stati mediati.

Nota 2: L'incremento delle prestazioni dall'ottimizzazione per l'accesso casuale è simile, anche se l'accesso stesso è leggermente più lento.

Tuttavia, è importante precisare che a differenza di quanto avviene con HBase, questo miglioramento non è sempre gratuito. Qui stiamo più «sbloccando» le capacità della CPU per svolgere il lavoro, piuttosto che attendere a causa dei blocchi.

Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

Qui si può notare che in generale l'aumento del numero di cache porta a una crescita proporzionale dell'utilizzo della CPU. Tuttavia, ci sono alcune combinazioni più vantaggiose.

Ad esempio, esaminiamo più da vicino l'impostazione SSC = 3. L'incremento delle prestazioni nel range è di circa 3,3 volte. Di seguito i risultati di tutte e tre le singole esecuzioni.

Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

Mentre il consumo della CPU cresce di circa 2,8 volte. La differenza non è molto grande, ma alla piccola Greta fa già piacere e potrebbe esserci tempo per frequentare la scuola e le lezioni.

Quindi, questo avrà un effetto positivo su qualsiasi strumento che utilizzi l'accesso massiccio a HDFS (ad esempio Spark, ecc.), a condizione che il codice applicativo sia leggero (ossia il collo di bottiglia si trova proprio lato client HDFS) e che ci siano risorse CPU disponibili. Per verificare, testiamo quale effetto avrà l'applicazione congiunta dell'ottimizzazione del BlockCache e del tuning di SSC per la lettura da HBase.

Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

È evidente che in queste condizioni l'effetto non è così marcato come nei test raffinati (lettura senza alcun pre trattamento), tuttavia è possibile ottenere un'ulteriore ottimizzazione di 80K. Insieme, entrambe le ottimizzazioni possono fornire un aumento delle prestazioni fino a 4 volte.

È stato inoltre realizzato un PR per questa ottimizzazione [HDFS-15202], che è stato integrato e questa funzionalità sarà disponibile nelle prossime versioni.

Infine, è stato interessante confrontare le prestazioni di lettura di un database wide-column come Cassandra e HBase.

A tal fine, sono stati avviati istanze dello standard strumento di test del carico YCSB da due host (800 thread in totale). Sul lato server, sono state configurate 4 istanze di RegionServer e Cassandra su 4 host (diversi da quelli su cui sono in esecuzione i client, per evitare influenze). Le letture sono state eseguite da tabelle delle dimensioni:

HBase — 300 GB su HDFS (100 GB di dati puliti)

Cassandra — 250 GB (fattore di replica = 3)

Cioè, il volume era all'incirca lo stesso (HBase leggermente di più).

Parametri di HBase:

dfs.client.short.circuit.num = 5 (ottimizzazione del client HDFS)

hbase.lru.cache.heavy.eviction.count.limit = 30 — questo significa che la patch inizierà a funzionare dopo 30 espulsioni (~5 minuti)

hbase.lru.cache.heavy.eviction.mb.size.limit = 300 — volume target per la memorizzazione nella cache e le espulsioni

I log di YCSB sono stati analizzati e sintetizzati in grafici Excel:

Come aumentare la velocità di lettura da HBase fino a 3 volte e da HDFS fino a 5 volte

Come si può vedere, i dati di ottimizzazione consentono di allineare le prestazioni di questi database in queste condizioni e di raggiungere 450.000 letture al secondo.

Speriamo che queste informazioni possano essere utili a qualcuno nella stimolante sfida per le prestazioni.

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster