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 esigenze chiave quando si lavora con grandi dati. Noi, nell'amministrazione del caricamento dati in Sber, ci occupiamo del trasferimento di praticamente tutte le transazioni nel nostro Cloud Dati basato su Hadoop e, pertanto, gestiamo flussi di informazioni davvero grandi. È evidente che stiamo costantemente cercando modi per migliorare le prestazioni e ora vogliamo raccontarvi come siamo riusciti a patchare RegionServer HBase e il client HDFS, grazie ai quali abbiamo potuto aumentare significativamente 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, è opportuno discutere delle limitazioni che, in linea di principio, non possono essere evitate se si utilizza un HDD.

Perché HDD e letture casuali veloci sono incompatibili
Come noto, HBase, come molte altre DB, memorizza i dati in blocchi, di dimensioni di alcune decine di kilobyte. Di default, si parla di circa 64 Kb. Ora immaginiamo 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, verrà richiesto un volume 640 volte maggiore (solo per dire!) di quello necessario.

Inoltre, poiché la richiesta passerà attraverso HDFS e il suo meccanismo di memorizzazione nella cache dei metadati ShortCircuitCache (che consente l'accesso diretto ai file), ciò porta a una lettura di 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 questo, ma quando iniziamo a leggere i dati tramite l'API Java, utilizzando funzioni come FileChannel.read e chiedendo al sistema operativo di leggere il volume specificato di dati, esso legge "per ogni evenienza" il doppio, ovvero 256 Kb nel nostro caso. Questo accade perché in Java non c'è una semplice possibilità di impostare il flag FADV_RANDOM, che previene tale comportamento.

Di conseguenza, per ottenere i nostri 100 byte, sotto il cofano vengono letti 2600 volte di più. Sembrerebbe che la soluzione sia ovvia: riduciamo la dimensione del blocco a un kilobyte, impostiamo il flag menzionato e otterremo una grande illuminazione e accelerazione. Ma il problema è che riducendo la dimensione del blocco della metà, riduciamo anche il numero di byte letti per unità di tempo della stessa metà.

È possibile ottenere alcuni vantaggi dall'impostazione del flag FADV_RANDOM, ma solo con un elevato multithreading e con una dimensione del blocco di almeno 128 KB, e questo rappresenta al massimo alcune decine di percento:

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

I test sono stati eseguiti su 100 file, ciascuno di dimensione 1 GB, e archiviati su 10 dischi HDD.

Calcoliamo quindi a cosa possiamo ragionevolmente aspettarci con tale velocità:
Supponiamo di leggere da 10 dischi alla velocità di 280 MB/sec, cioè 3 milioni di volte per 100 byte. Ma come sappiamo, i dati che ci servono si presentano 2600 volte meno frequentemente di quanto sia stato letto. Pertanto, dividiamo 3 milioni per 2600 e otteniamo 1100 record al secondo.

Deludente, vero? Questa è la natura del Accesso casuale ai dati su HDD — indipendentemente dalla dimensione del blocco. Questo è il limite fisico dell'accesso casuale e nessun DB sarà in grado di estrarre di più in tali condizioni.

Come fanno quindi i database a raggiungere velocità molto più elevate? Per rispondere a questa domanda, diamo un'occhiata a cosa succede nella figura successiva:

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à è effettivamente dell'ordine di mille record al secondo. Tuttavia, successivamente, grazie al fatto che vengono letti molti più dati di quanto sia stato 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 memorie a velocità comparabili come SSD/NVMe.

Nel nostro caso, condurremo test su una configurazione di 4 server, ciascuno dei quali è configurato come segue:

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

E qui si trova il punto chiave — il volume dei dati nelle tabelle che deve essere letto. Infatti, se leggiamo dati da una tabella che si adatta completamente nella cache di HBase, non arriveremo nemmeno a leggere dalla cache del sistema operativo. Perché HBase per impostazione predefinita riserva il 40% della memoria per una struttura chiamata BlockCache. Fondamentalmente, è una ConcurrentHashMap, dove la chiave è il nome del file + offset del blocco, e il valore sono i dati relativi a questo offset.

Così, quando la lettura avviene solo da questa struttura, noi vediamo una velocità straordinaria., sembra quasi un milione di richieste al secondo. Ma immaginiamo che non possiamo dedicare centinaia di gigabyte di memoria solo per le necessità del database, perché su questi server girano tante 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, cioè per il BlockCache sono stati riservati 96 Gb su tutti i nodi. E i dati in questo caso sono molte volte superiori, ad esempio supponiamo che ci siano 4 tabelle, ciascuna con 130 regioni, in cui i file hanno una dimensione di 800 Mb, compressi con FAST_DIFF, cioè un totale di 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 ed è molto più vicino alle reali condizioni di ciò che chiamiamo BigData. Ed è qui che inizia la parte più interessante—è evidente che meno colpi in cache, peggiore è la performance. Infatti, in caso di mancata registrazione, bisognerà svolgere un sacco di lavoro—cioè scendere a chiamare le funzioni di sistema. Tuttavia, non possiamo evitarlo e quindi diamo un'occhiata a un aspetto totalmente diverso—cosa succede ai dati all'interno della cache?

Semplifichiamo la situazione e supponiamo che abbiamo una cache che può contenere solo 1 oggetto. Ecco un esempio di ciò che accadrà nel tentativo di gestire un volume di dati tre volte superiore rispetto alla cache, dovremo:

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

Sono state effettuate 5 operazioni! Tuttavia, non possiamo definire questa situazione come normale, in sostanza stiamo costringendo HBase a compiere un sacco di lavoro completamente inutile. Continualmente legge i dati dalla cache del sistema operativo, li mette nel BlockCache, solo per scartarli quasi immediatamente, poiché è arrivata una nuova serie di dati. L'animazione all'inizio del post mostra l'essenza del problema—il Garbage Collector è alle stelle, l'atmosfera si riscalda, la piccola Greta in Svezia lontana e calda è preoccupata. E noi, esperti IT, non ci piace affatto quando i bambini sono tristi, quindi iniziamo a pensare a cosa si possa fare per risolvere questo.

E se mettessimo in cache non tutti i blocchi, ma solo una percentuale specifica di essi, in modo che la cache non trabocchi? Iniziamo semplicemente ad aggiungere alcune righe di codice all'inizio della funzione di immissione dei dati nel BlockCache:

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

Il punto qui è che 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 qui sotto diventa chiaro come sia avvenuto un tale accelerazione: risparmiamo molte risorse GC non occupandoci dello sforzo sisifino di collocare i dati nella cache solo per buttarli subito ai cani marziani dietro la coda:

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

Il consumo della CPU aumenta, ma molto meno 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 in BlockCache possono essere diversi. La maggior parte, circa il 95%, sono proprio dati. Gli altri sono metadati, come filtri Bloom o LEAF_INDEX e ecc.. Questi dati sono pochi, ma molto utili, poiché prima di accedere direttamente ai dati, HBase si rivolge ai metadati per capire se è necessario cercare ulteriormente e, in tal caso, dove si trova precisamente il blocco di interesse.

Pertanto, nel codice vediamo la condizione di controllo buf.getBlockType().isData() e grazie a questo metadato ci assicuriamo di lasciare in cache in ogni caso.

Ora aumentiamo il carico e, al contempo, ottimizziamo leggermente la funzione. Nel primo test abbiamo impostato la soglia di taglio al 20% e BlockCache era leggermente sotto utilizzato. Ora 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 raggiunge quasi immediatamente il limite di circa 100.000 richieste al secondo. Mentre la patch offre un'accelerazione fino a 300.000. È chiaro, però, che ulteriori accelerazioni non sono così «gratuita», e anche l'utilizzo della CPU aumenta.

Tuttavia, questa non è una soluzione molto elegante, poiché non sappiamo in anticipo quale percentuale di blocchi è necessario memorizzare nella cache, dipende dal profilo del carico. Pertanto, è stato implementato un meccanismo di autoregolazione di questo parametro a seconda dell'attività delle operazioni di lettura.

Per gestire questo, sono stati aggiunti tre parametri:

hbase.lru.cache.heavy.eviction.count.limit — stabilisce quante volte deve avvenire il processo di espulsione dei dati dalla cache, prima che iniziamo a utilizzare l'ottimizzazione (cioè saltare i blocchi). Per impostazione predefinita è uguale a MAX_INT = 2147483647 e significa che la funzionalità non inizierà mai a funzionare con tale valore. Poiché il processo di espulsione viene eseguito 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 costringere la funzionalità a funzionare immediatamente dopo l'avvio.

Tuttavia, c'è anche un carico utile in questo parametro. Se il nostro tipo di carico è tale che si alternano costantemente letture a breve termine (diciamo di giorno) e a lungo termine (di notte), possiamo fare in modo che la funzionalità si attivi solo quando ci sono operazioni di lettura prolungate.

Ad esempio, sappiamo che le letture a breve termine di solito durano circa 1 minuto. Non dovremmo iniziare a espellere i blocchi, la cache non avrà il tempo di diventare obsoleta e quindi possiamo impostare questo parametro ad esempio a 10. Questo farà sì che l'ottimizzazione inizi a funzionare solo quando inizia una lettura attiva prolungata, cioè dopo 100 secondi. In questo modo, se abbiamo una lettura a breve termine, tutti i blocchi andranno nella cache e saranno disponibili (eccetto quelli che saranno espulsi dall'algoritmo standard). E quando facciamo letture a lungo termine, la funzionalità si attiva e otteniamo prestazioni molto più elevate.

hbase.lru.cache.heavy.eviction.mb.size.limit — stabilisce quanti megabyte vorremmo inserire nella cache (e naturalmente espellere) ogni 10 secondi. La funzionalità cercherà di raggiungere questo valore e di mantenerlo. Il senso è il seguente: se mettiamo gigabyte nella cache, dovremo espellere gigabyte, il che, come abbiamo visto sopra, è piuttosto oneroso. Tuttavia, non bisogna cercare di impostarlo troppo piccolo, poiché questo porterà a un'uscita prematura dalla modalità di salto dei blocchi. Per server potenti (circa 20-40 nuclei fisici) è ottimale impostare circa 300-400 MB. Per sistemi di fascia media (~10 nuclei) 200-300 MB. Per sistemi più deboli (2-5 nuclei) può andare bene 50-100 MB (non sono stati effettuati test su questi).

Esaminiamo come funziona: supponiamo di aver impostato hbase.lru.cache.heavy.eviction.mb.size.limit = 500, c'è un carico (letture) e ogni ~10 secondi calcoliamo quanti byte sono stati sfrattati secondo la formula:

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

Se in realtà sono stati sfrattati 2000 MB, allora l'Overhead risulta:

2000 * 100 / 500 — 100 = 300%

Gli algoritmi cercano di mantenere non più di alcune decine di percento, quindi la funzione ridurrà la percentuale di blocchi memorizzati nella cache, implementando così un meccanismo di autotuning.

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

200 * 100 / 500 — 100 = -60%

La funzione al contrario aumenterà la percentuale di blocchi memorizzati nella cache fino a quando l'Overhead non diventa positivo.

Di seguito sarà un esempio di come appare su dati reali. Non bisogna cercare di raggiungere il 0%, è impossibile. È molto buono quando è intorno al 30 — 100%, ciò aiuta a evitare l'uscita prematura dalla modalità di ottimizzazione durante i picchi a breve termine.

hbase.lru.cache.heavy.eviction.overhead.coefficient — imposta quanto velocemente desideriamo ottenere il risultato. Se sappiamo chiaramente 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 coefficiente = 0.01. Ciò significa che l'Overhead (vedi sopra) sarà moltiplicato per questo numero sul risultato ottenuto e verrà ridotta la percentuale di blocchi memorizzati nella cache. Supponiamo che l'Overhead = 300% e il coefficiente = 0.01, quindi la percentuale di blocchi memorizzati nella cache verrà ridotta del 3%.

Una logica simile di "Backpressure" è implementata anche per i valori negativi di Overhead (overshooting). Poiché sono sempre possibili fluttuazioni a breve termine nel volume delle letture-sfratti, questo meccanismo evita l'uscita prematura dalla modalità di ottimizzazione. La Backpressure ha una logica invertita: più forte è l'overshooting, maggiore è il numero di blocchi memorizzati nella cache.

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 quanti ne possa contenere il BlockCache
        * e questo è la causa di un alto tasso di espulsioni.
        * Questo a sua volta porta a un intenso lavoro del Garbage Collector.
        * Quindi, molti blocchi vengono messi nel BlockCache ma mai letti,
        * ma spendendo molte risorse CPU.
        * Qui analizzeremo quanti byte sono stati liberati e decideremo
        * se è giunto il momento di ridurre la quantità di blocchi di caching.
        * Questo aiuta a evitare di mettere troppi blocchi nel BlockCache
        * quando evict() funziona in modo molto attivo e risparmiare CPU per altri lavori.
        * Maggiori dettagli: https://issues.apache.org/jira/browse/HBASE-23887
        */

        // Prima di tutto dobbiamo controllare quanto tempo
        // è passato da quando è stata lanciata l'ultima evict()
        // Questo dovrebbe essere quasi lo stesso tempo (+/- 10s)
        // perché otteniamo volumi comparabili di byte liberati ogni volta.
        // 10s perché questo è il periodo predefinito 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 modificare la percentuale di blocchi di caching.
          freedDataOverheadPercent =
            (int) (freedSumMb * 100 / cache.heavyEvictionMbSizeLimit) - 100;
          if (freedSumMb > cache.heavyEvictionMbSizeLimit) {
            // Ora siamo in una situazione in cui siamo sopra il limite
            // Ma forse stiamo per ignorarlo perché terminerà abbastanza presto
            heavyEvictionCount++;
            if (heavyEvictionCount > cache.heavyEvictionCountLimit) {
              // Sta durando a lungo e dobbiamo ridurre il caching
              // dei blocchi ora. Quindi calcoliamo qui quanti blocchi vogliamo saltare.
              // Dipende da:
             // 1. Sovraccarico - se il sovraccarico è grande possiamo essere più aggressivi
              // riducendo la quantità di blocchi di caching.
              // 2. Quanto velocemente vogliamo ottenere il risultato. Se sappiamo che il nostro
              // la lettura pesante dura a lungo, non vogliamo aspettare e possiamo
              // aumentare il coefficiente e ottenere buone prestazioni molto presto.
              // Ma se non siamo sicuri possiamo farlo lentamente e potrebbe
              // prevenire un'uscita prematura da questa modalità. Quindi, quando il coefficiente è
              // più alto possiamo ottenere prestazioni migliori quando la lettura intensa è stabile.
              // Ma quando la lettura sta cambiando, possiamo adattarci a essa e impostare
              // il coefficiente a un valore inferiore.
              int change =
                (int) (freedDataOverheadPercent * cache.heavyEvictionOverheadCoefficient);
              // Ma la pratica dimostra che il 15% di riduzione è più che sufficiente.
              // Non siamo avidi (potrebbe portare a un'uscita prematura).
              change = Math.min(15, change);
              change = Math.max(0, change); // Penso che non accadrà mai, ma controlliamo per sicurezza
              // Quindi questo è il punto chiave, qui stiamo riducendo la % dei blocchi di caching
              cache.cacheDataBlockPercent -= change;
              // Se scendiamo troppo in basso dobbiamo fermarci qui, l'1% in ogni caso dovrebbe esserci.
              cache.cacheDataBlockPercent = Math.max(1, cache.cacheDataBlockPercent);
            }
          } else {
            // Bene, abbiamo ottenuto un eccesso.
            // Forse è solo una fluttuazione a breve termine e possiamo rimanere in questa modalità.
            // Questo aiuta a evitare un'uscita prematura durante le fluttuazioni a breve termine.
            // Se l'eccesso è inferiore al 90%, cercheremo di aumentare la percentuale di
            // blocchi di caching e sperare che sia sufficiente.
            if (freedSumMb >= cache.heavyEvictionMbSizeLimit * 0.1) {
              // Logica semplice: più eccesso - più blocchi di caching (backpressure)
              int change = (int) (-freedDataOverheadPercent * 0.1 + 1);
              cache.cacheDataBlockPercent += change;
              // Ma non può essere più del 100%, quindi controlliamolo.
              cache.cacheDataBlockPercent = Math.min(100, cache.cacheDataBlockPercent);
            } else {
              // Sembra che la lettura pesante sia finita.
              // Basta uscire da questa modalità.
              heavyEvictionCount = 0;
              cache.cacheDataBlockPercent = 100;
            }
          }
          LOG.info("BlockCache espulso (MB): {}, sovraccarico (%): {}, " +
            "contatore espulsione pesante: {}, " +
            "percentuale di caching DataBlock attuale (%): {}",
            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 fare lo Scan (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 lo scan di nuovo)

Facciamo due esecuzioni, prima impostiamo hbase.lru.cache.heavy.eviction.count.limit = 10000 (che di fatto disattiva la funzione), e poi impostiamo limit = 0 (la riattiva).

Nei log qui sotto vediamo come la funzione si attiva, riportando l'Overshooting al 14-71%. Di tanto in tanto, il carico diminuisce, attivando il Backpressure e HBase inizia a memorizzare più blocchi nella cache.

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

Le scansioni erano necessarie per mostrare lo stesso processo sotto forma di grafico della relazione tra le due sezioni della cache: single (dove vanno a finire i blocchi che nessuno ha mai richiesto) e multi (dove si conservano i dati 'richiesti' almeno una volta):

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

E infine, come appaiono i parametri in forma grafica. Per confronto, la cache era completamente disattivata all'inizio, poi è stato avviato HBase con la cache attivata e un ritardo per l'inizio dell'ottimizzazione di 5 minuti (30 cicli di espulsione).

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

Tuttavia, 300.000 letture al secondo non è tutto ciò che si può ottenere su questo hardware in queste condizioni. Il fatto è che quando è necessario accedere ai dati tramite HDFS, si utilizza il meccanismo ShortCircuitCache (di seguito 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, il che porta 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 sovrapposizioni anche in base all'ultima cifra dell'offset:

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

Ora possiamo procedere con i test. Per questo leggeremo file da HDFS con 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 notevolmente la lettura, quindi è meglio allinearlo alle esigenze reali
conf.set("dfs.client.short.circuit.num", num); // da 1 a 10

E leggiamo semplicemente 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 letti simultaneamente (da 10 a 200 — asse orizzontale) e il numero di cache (da 1 a 10 — grafici). L'asse verticale mostra l'accelerazione data dall'aumento di SSC rispetto al caso in cui la cache è solo una.

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 a blocchi di 64 KB con una sola cache richiede 78 secondi. Mentre con 5 cache viene completato in 16 secondi. Quindi, c'è un'accelerazione ~5 volte. Come si può vedere dal grafico, su un numero ridotto di letture parallele, l'effetto non è molto evidente, inizia a giocare un ruolo significativo quando le letture dei thread superano 50. È anche evidente che l'aumento del numero di SSC da 6 e oltre produce un incremento di prestazioni molto inferiore.

Nota 1: poiché i risultati dei test sono abbastanza volatili (vedi sotto), sono state effettuate 3 esecuzioni e i valori ottenuti sono stati mediati.

Nota 2: L'incremento di prestazioni dalla configurazione per accesso casuale è lo stesso, sebbene l'accesso stesso sia un po' più lento.

Tuttavia, è necessario precisare che, a differenza di quanto avviene con HBase, questo aumento non è sempre gratuito. Qui stiamo più 'sbloccando' le capacità della CPU di lavorare, invece di rimanere bloccati su lock.

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

Si può osservare che, in generale, l'aumento del numero di cache porta a un incremento proporzionale dell'utilizzo della CPU. Tuttavia, ci sono alcune combinazioni più vantaggiose.

Ad esempio, diamo un'occhiata più da vicino alla configurazione SSC = 3. L'aumento delle prestazioni nell'intervallo è di circa 3,3 volte. Di seguito i risultati di tutti e tre i singoli test.

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

Mentre il consumo di CPU aumenta di circa 2,8 volte. La differenza non è molto grande, ma a Gretchen fa già piacere e potrebbe avere tempo per andare a scuola e seguire le lezioni.

Così avrà un effetto positivo per qualsiasi strumento che utilizzi l'accesso massivo a HDFS (ad esempio Spark, ecc.), a condizione che il codice applicativo sia leggero (cioè il collo di bottiglia è proprio dal lato del client HDFS) e ci siano risorse CPU disponibili. Per verificare, testiamo quale effetto avrà l'applicazione combinata dell'ottimizzazione BlockCache e della configurazione 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

Qui si vede che in tali condizioni l'effetto non è così grande come nei test raffinati (letture senza alcuna elaborazione), tuttavia è possibile ottenere ulteriori 80K. Insieme, entrambe le ottimizzazioni forniscono un'accelerazione fino a 4 volte.

Inoltre, è stata creata una PR per questa ottimizzazione. [HDFS-15202], che è stata integrata e questa funzionalità sarà disponibile nelle prossime versioni.

Infine, è stato interessante confrontare le prestazioni di lettura di un database a colonne larghe come Cassandra e HBase.

Per fare ciò, sono stati avviati istanze della normale utility di stress testing YCSB da due host (800 thread in totale). Dallato server — 4 istanze di RegionServer e 4 istanze di Cassandra su 4 host (diversi da quelli sui quali girano i client, per evitare influenze). Le letture venivano da tabelle di dimensioni:

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

Cassandra — 250 GB (fattore di replica = 3)

Quindi, il volume era sostanzialmente lo stesso (un po' più grande in HBase).

Parametri 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 — dimensione target della cache e dell'espulsione

I log di YCSB sono stati analizzati e riassunti 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.

Ci auguriamo che queste informazioni possano essere utili a qualcuno nella straordinaria lotta 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