Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

O alto desempenho é um dos principais requisitos ao trabalhar com big data. No departamento de carregamento de dados do Sberbank, transferimos quase todas as transações para a nossa nuvem de dados baseada em Hadoop e, portanto, lidamos com fluxos de informações realmente grandes. Naturalmente, estamos sempre procurando maneiras de melhorar o desempenho e agora queremos contar como conseguimos corrigir o RegionServer HBase e o cliente HDFS, graças ao qual conseguimos aumentar significativamente a velocidade das operações de leitura.
Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

Porém, antes de passar à essência das melhorias, vale a pena falar sobre restrições que, em princípio, não podem ser contornadas se você sentar em um HDD.

Por que HDD e leituras rápidas de acesso aleatório são incompatíveis
Como você sabe, o HBase e muitos outros bancos de dados armazenam dados em blocos de várias dezenas de kilobytes. Por padrão, tem cerca de 64 KB. Agora vamos imaginar que precisamos obter apenas 100 bytes e pedimos ao HBase que nos forneça esses dados usando uma determinada chave. Como o tamanho do bloco em HFiles é de 64 KB, a solicitação será 640 vezes maior (apenas um minuto!) do que o necessário.

A seguir, como a solicitação passará pelo HDFS e seu mecanismo de cache de metadados ShortCircuitCache (que permite acesso direto aos arquivos), isso leva à leitura de 1 MB do disco. No entanto, isso pode ser ajustado com o parâmetro dfs.client.read.shortcircuit.buffer.size e em muitos casos faz sentido reduzir este valor, por exemplo para 126 KB.

Digamos que fazemos isso, mas além disso, quando começamos a ler dados por meio da API Java, como funções como FileChannel.read e pedimos ao sistema operacional para ler a quantidade especificada de dados, ele lê “por precaução” 2 vezes mais , ou seja 256 KB no nosso caso. Isso ocorre porque o java não possui uma maneira fácil de definir o sinalizador FADV_RANDOM para evitar esse comportamento.

Como resultado, para obter nossos 100 bytes, 2600 vezes mais são lidos nos bastidores. Parece que a solução é óbvia, vamos reduzir o tamanho do bloco para um kilobyte, definir o sinalizador mencionado e obter grande aceleração de iluminação. Mas o problema é que, ao reduzir o tamanho do bloco em 2 vezes, também reduzimos em 2 vezes o número de bytes lidos por unidade de tempo.

Algum ganho com a configuração do sinalizador FADV_RANDOM pode ser obtido, mas apenas com multithreading alto e com um tamanho de bloco de 128 KB, mas isso é no máximo algumas dezenas de por cento:

Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

Os testes foram realizados em 100 arquivos, cada um com 1 GB de tamanho e localizados em 10 HDDs.

Vamos calcular com o que podemos, em princípio, contar nesta velocidade:
Digamos que lemos 10 discos a uma velocidade de 280 MB/seg, ou seja, 3 milhões de vezes 100 bytes. Mas, como lembramos, os dados de que precisamos são 2600 vezes menores do que os que são lidos. Assim, dividimos 3 milhões por 2600 e obtemos 1100 registros por segundo.

Deprimente, não é? Isso é natureza Acesso aleatório acesso aos dados no HDD - independentemente do tamanho do bloco. Este é o limite físico do acesso aleatório e nenhum banco de dados pode extrair mais dessas condições.

Como então os bancos de dados atingem velocidades muito mais altas? Para responder a essa pergunta, vejamos o que está acontecendo na imagem a seguir:

Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

Aqui vemos que nos primeiros minutos a velocidade é realmente de cerca de mil registros por segundo. Porém, além disso, devido ao fato de ser lido muito mais do que o solicitado, os dados vão parar no buff/cache do sistema operacional (linux) e a velocidade aumenta para uns mais decentes 60 mil por segundo

Assim, trataremos ainda da aceleração do acesso apenas aos dados que estão no cache do sistema operacional ou localizados em dispositivos de armazenamento SSD/NVMe de velocidade de acesso comparável.

No nosso caso, realizaremos testes em uma bancada de 4 servidores, sendo cada um deles cobrado da seguinte forma:

CPU: Xeon E5-2680 v4 @ 2.40 GHz 64 threads.
Memória: 730GB.
versão java: 1.8.0_111

E aqui o ponto chave é a quantidade de dados nas tabelas que precisam ser lidos. O fato é que se você ler dados de uma tabela que está inteiramente colocada no cache do HBase, então nem chegará à leitura do buff/cache do sistema operacional. Porque o HBase por padrão aloca 40% da memória para uma estrutura chamada BlockCache. Essencialmente, este é um ConcurrentHashMap, onde a chave é o nome do arquivo + deslocamento do bloco, e o valor são os dados reais neste deslocamento.

Assim, ao ler apenas a partir desta estrutura, vemos excelente velocidade, como um milhão de solicitações por segundo. Mas vamos imaginar que não podemos alocar centenas de gigabytes de memória apenas para necessidades de banco de dados, porque há muitas outras coisas úteis rodando nesses servidores.

Por exemplo, no nosso caso, o volume do BlockCache em um RS é de cerca de 12 GB. Colocamos dois RS em um nó, ou seja, 96 GB são alocados para BlockCache em todos os nós. E há muitas vezes mais dados, por exemplo, sejam 4 tabelas, 130 regiões cada, nas quais os arquivos têm 800 MB de tamanho, compactados por FAST_DIFF, ou seja, um total de 410 GB (são dados puros, ou seja, sem levar em conta o fator de replicação).

Assim, o BlockCache representa apenas cerca de 23% do volume total de dados e isso está muito mais próximo das condições reais do que é chamado de BigData. E é aqui que a diversão começa - porque obviamente, quanto menos acessos ao cache, pior será o desempenho. Afinal, se você errar, terá que trabalhar muito - ou seja, vá até a chamada de funções do sistema. No entanto, isso não pode ser evitado, então vejamos um aspecto completamente diferente - o que acontece com os dados dentro do cache?

Vamos simplificar a situação e assumir que temos um cache que cabe apenas em 1 objeto. Aqui está um exemplo do que acontecerá quando tentarmos trabalhar com um volume de dados 3 vezes maior que o cache, teremos que:

1. Coloque o bloco 1 no cache
2. Remova o bloco 1 do cache
3. Coloque o bloco 2 no cache
4. Remova o bloco 2 do cache
5. Coloque o bloco 3 no cache

5 ações concluídas! No entanto, esta situação não pode ser chamada de normal; na verdade, estamos forçando o HBase a fazer um monte de trabalho completamente inútil. Ele lê constantemente os dados do cache do sistema operacional, coloca-os no BlockCache, apenas para jogá-los fora quase imediatamente porque uma nova porção de dados chegou. A animação no início do post mostra a essência do problema - o Coletor de Lixo está saindo de escala, a atmosfera está esquentando, a pequena Greta na distante e quente Suécia está ficando chateada. E nós, profissionais de TI, realmente não gostamos quando as crianças estão tristes, então começamos a pensar no que podemos fazer a respeito.

E se você não colocar todos os blocos no cache, mas apenas uma certa porcentagem deles, para que o cache não transborde? Vamos começar simplesmente adicionando algumas linhas de código ao início da função para colocar dados no BlockCache:

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

O ponto aqui é o seguinte: offset é a posição do bloco no arquivo e seus últimos dígitos são distribuídos de forma aleatória e uniforme de 00 a 99. Portanto, pularemos apenas aqueles que se enquadram no intervalo que precisamos.

Por exemplo, defina cacheDataBlockPercent = 20 e veja o que acontece:

Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

O resultado é óbvio. Nos gráficos abaixo, fica claro por que ocorreu tal aceleração - economizamos muitos recursos de GC sem fazer o trabalho de Sísifo de colocar dados no cache apenas para jogá-los imediatamente no ralo dos cães marcianos:

Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

Ao mesmo tempo, a utilização da CPU aumenta, mas é muito menor que a produtividade:

Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

Também é importante notar que os blocos armazenados no BlockCache são diferentes. A maior parte, cerca de 95%, são dados em si. E o resto são metadados, como filtros Bloom ou LEAF_INDEX e т.д.. Esses dados não são suficientes, mas são muito úteis, pois antes de acessar os dados diretamente, o HBase recorre ao meta para entender se é necessário pesquisar mais aqui e, em caso afirmativo, onde exatamente está localizado o bloco de interesse.

Portanto, no código vemos uma condição de verificação buf.getBlockType().isData() e graças a esta meta, iremos deixá-lo no cache de qualquer maneira.

Agora vamos aumentar a carga e apertar levemente o recurso de uma só vez. No primeiro teste fizemos o percentual de corte = 20 e o BlockCache foi um pouco subutilizado. Agora vamos definir para 23% e adicionar 100 threads a cada 5 minutos para ver em que ponto ocorre a saturação:

Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

Aqui vemos que a versão original atinge quase imediatamente o teto, com cerca de 100 mil solicitações por segundo. Já o patch dá uma aceleração de até 300 mil. Ao mesmo tempo, está claro que a aceleração adicional não é mais tão “gratuita”; a utilização da CPU também está aumentando.

Porém, esta não é uma solução muito elegante, pois não sabemos antecipadamente qual a porcentagem de blocos que precisam ser armazenados em cache, depende do perfil de carga. Portanto, foi implementado um mecanismo para ajustar automaticamente este parâmetro em função da atividade das operações de leitura.

Três opções foram adicionadas para controlar isso:

hbase.lru.cache.heavy.eviction.count.limit — define quantas vezes o processo de remoção de dados do cache deve ser executado antes de começarmos a usar a otimização (ou seja, pular blocos). Por padrão é igual a MAX_INT = 2147483647 e na verdade significa que o recurso nunca começará a funcionar com este valor. Porque o processo de despejo começa a cada 5 - 10 segundos (depende da carga) e 2147483647 * 10/60/60/24/365 = 680 anos. No entanto, podemos definir este parâmetro como 0 e fazer o recurso funcionar imediatamente após o lançamento.

No entanto, também há uma carga útil neste parâmetro. Se nossa carga for tal que leituras de curto prazo (digamos, durante o dia) e leituras de longo prazo (à noite) sejam constantemente intercaladas, podemos garantir que o recurso seja ativado somente quando operações de leitura longas estiverem em andamento.

Por exemplo, sabemos que as leituras de curto prazo geralmente duram cerca de 1 minuto. Não há necessidade de começar a jogar fora os blocos, o cache não terá tempo de ficar desatualizado e então podemos definir este parâmetro igual a, por exemplo, 10. Isso fará com que a otimização só comece a funcionar quando muito tempo- a leitura ativa do termo começou, ou seja, em 100 segundos. Assim, se tivermos uma leitura de curto prazo, todos os blocos irão para o cache e ficarão disponíveis (exceto aqueles que serão despejados pelo algoritmo padrão). E quando fazemos leituras de longo prazo, o recurso é ativado e teríamos um desempenho muito superior.

hbase.lru.cache.heavy.eviction.mb.size.limit — define quantos megabytes gostaríamos de colocar no cache (e, claro, despejar) em 10 segundos. O recurso tentará atingir esse valor e mantê-lo. A questão é esta: se colocarmos gigabytes no cache, teremos que despejar gigabytes, e isso, como vimos acima, é muito caro. No entanto, você não deve tentar defini-lo muito pequeno, pois isso fará com que o modo de salto de bloco seja encerrado prematuramente. Para servidores poderosos (cerca de 20 a 40 núcleos físicos), é ideal definir cerca de 300 a 400 MB. Para a classe média (~10 núcleos) 200-300 MB. Para sistemas fracos (2 a 5 núcleos), 50 a 100 MB podem ser normais (não testados nestes).

Vejamos como isso funciona: digamos que definimos hbase.lru.cache.heavy.eviction.mb.size.limit = 500, há algum tipo de carga (leitura) e então a cada ~10 segundos calculamos quantos bytes foram despejado usando a fórmula:

Overhead = Soma de Bytes Liberados (MB) * 100 / Limite (MB) - 100;

Se de fato 2000 MB foram removidos, então a sobrecarga será igual a:

2000 * 100/500 - 100 = 300%

Os algoritmos tentam manter não mais do que algumas dezenas de por cento, de modo que o recurso reduzirá a porcentagem de blocos em cache, implementando assim um mecanismo de autoajuste.

No entanto, se a carga cair, digamos que apenas 200 MB sejam removidos e o Overhead se torne negativo (o chamado overshooting):

200 * 100/500 - 100 = -60%

Pelo contrário, o recurso aumentará a porcentagem de blocos em cache até que o Overhead se torne positivo.

Abaixo está um exemplo de como isso aparece em dados reais. Não há necessidade de tentar chegar a 0%, é impossível. É muito bom quando está em torno de 30 a 100%, o que ajuda a evitar a saída prematura do modo de otimização durante surtos de curto prazo.

hbase.lru.cache.heavy.eviction.overhead.coeficiente — define a rapidez com que gostaríamos de obter o resultado. Se tivermos certeza de que nossas leituras são em sua maioria longas e não quisermos esperar, podemos aumentar essa proporção e obter alto desempenho mais rapidamente.

Por exemplo, definimos este coeficiente = 0.01. Isso significa que o Overhead (veja acima) será multiplicado por este número pelo resultado resultante e a porcentagem de blocos em cache será reduzida. Vamos supor que Overhead = 300% e coeficiente = 0.01, então a porcentagem de blocos em cache será reduzida em 3%.

Uma lógica de “contrapressão” semelhante também é implementada para valores negativos de sobrecarga (overshooting). Como sempre são possíveis flutuações de curto prazo no volume de leituras e expulsões, esse mecanismo permite evitar a saída prematura do modo de otimização. A contrapressão tem uma lógica invertida: quanto mais forte o overshooting, mais blocos são armazenados em cache.

Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

Código de implementação

        LruBlockCache cache = this.cache.get();
        if (cache == null) {
          break;
        }
        freedSumMb += cache.evict()/1024/1024;
        /*
        * Sometimes we are reading more data than can fit into BlockCache
        * and it is the cause a high rate of evictions.
        * This in turn leads to heavy Garbage Collector works.
        * So a lot of blocks put into BlockCache but never read,
        * but spending a lot of CPU resources.
        * Here we will analyze how many bytes were freed and decide
        * decide whether the time has come to reduce amount of caching blocks.
        * It help avoid put too many blocks into BlockCache
        * when evict() works very active and save CPU for other jobs.
        * More delails: https://issues.apache.org/jira/browse/HBASE-23887
        */

        // First of all we have to control how much time
        // has passed since previuos evict() was launched
        // This is should be almost the same time (+/- 10s)
        // because we get comparable volumes of freed bytes each time.
        // 10s because this is default period to run evict() (see above this.wait)
        long stopTime = System.currentTimeMillis();
        if ((stopTime - startTime) > 1000 * 10 - 1) {
          // Here we have to calc what situation we have got.
          // We have the limit "hbase.lru.cache.heavy.eviction.bytes.size.limit"
          // and can calculte overhead on it.
          // We will use this information to decide,
          // how to change percent of caching blocks.
          freedDataOverheadPercent =
            (int) (freedSumMb * 100 / cache.heavyEvictionMbSizeLimit) - 100;
          if (freedSumMb > cache.heavyEvictionMbSizeLimit) {
            // Now we are in the situation when we are above the limit
            // But maybe we are going to ignore it because it will end quite soon
            heavyEvictionCount++;
            if (heavyEvictionCount > cache.heavyEvictionCountLimit) {
              // It is going for a long time and we have to reduce of caching
              // blocks now. So we calculate here how many blocks we want to skip.
              // It depends on:
             // 1. Overhead - if overhead is big we could more aggressive
              // reducing amount of caching blocks.
              // 2. How fast we want to get the result. If we know that our
              // heavy reading for a long time, we don't want to wait and can
              // increase the coefficient and get good performance quite soon.
              // But if we don't sure we can do it slowly and it could prevent
              // premature exit from this mode. So, when the coefficient is
              // higher we can get better performance when heavy reading is stable.
              // But when reading is changing we can adjust to it and set
              // the coefficient to lower value.
              int change =
                (int) (freedDataOverheadPercent * cache.heavyEvictionOverheadCoefficient);
              // But practice shows that 15% of reducing is quite enough.
              // We are not greedy (it could lead to premature exit).
              change = Math.min(15, change);
              change = Math.max(0, change); // I think it will never happen but check for sure
              // So this is the key point, here we are reducing % of caching blocks
              cache.cacheDataBlockPercent -= change;
              // If we go down too deep we have to stop here, 1% any way should be.
              cache.cacheDataBlockPercent = Math.max(1, cache.cacheDataBlockPercent);
            }
          } else {
            // Well, we have got overshooting.
            // Mayby it is just short-term fluctuation and we can stay in this mode.
            // It help avoid permature exit during short-term fluctuation.
            // If overshooting less than 90%, we will try to increase the percent of
            // caching blocks and hope it is enough.
            if (freedSumMb >= cache.heavyEvictionMbSizeLimit * 0.1) {
              // Simple logic: more overshooting - more caching blocks (backpressure)
              int change = (int) (-freedDataOverheadPercent * 0.1 + 1);
              cache.cacheDataBlockPercent += change;
              // But it can't be more then 100%, so check it.
              cache.cacheDataBlockPercent = Math.min(100, cache.cacheDataBlockPercent);
            } else {
              // Looks like heavy reading is over.
              // Just exit form this mode.
              heavyEvictionCount = 0;
              cache.cacheDataBlockPercent = 100;
            }
          }
          LOG.info("BlockCache evicted (MB): {}, overhead (%): {}, " +
            "heavy eviction counter: {}, " +
            "current caching DataBlock (%): {}",
            freedSumMb, freedDataOverheadPercent,
            heavyEvictionCount, cache.cacheDataBlockPercent);

          freedSumMb = 0;
          startTime = stopTime;
       }

Vejamos agora tudo isso usando um exemplo real. Temos o seguinte script de teste:

  1. Vamos começar a fazer Scan (25 threads, batch = 100)
  2. Após 5 minutos, adicione multi-gets (25 threads, lote = 100)
  3. Após 5 minutos, desligue o multi-gets (apenas a varredura permanece novamente)

Fazemos duas execuções, primeiro hbase.lru.cache.heavy.eviction.count.limit = 10000 (que na verdade desativa o recurso) e depois definimos limit = 0 (habilita-o).

Nos registros abaixo, vemos como o recurso é ativado e redefine o Overshooting para 14-71%. De tempos em tempos, a carga diminui, o que ativa a contrapressão e o HBase armazena em cache mais blocos novamente.

Servidor de região de log
despejado (MB): 0, proporção 0.0, sobrecarga (%): -100, contador de despejo pesado: 0, cache atual DataBlock (%): 100
despejado (MB): 0, proporção 0.0, sobrecarga (%): -100, contador de despejo pesado: 0, cache atual DataBlock (%): 100
despejado (MB): 2170, proporção 1.09, sobrecarga (%): 985, contador de despejo pesado: 1, cache atual DataBlock (%): 91 <início
despejado (MB): 3763, proporção 1.08, sobrecarga (%): 1781, contador de despejo pesado: 2, cache atual DataBlock (%): 76
despejado (MB): 3306, proporção 1.07, sobrecarga (%): 1553, contador de despejo pesado: 3, cache atual DataBlock (%): 61
despejado (MB): 2508, proporção 1.06, sobrecarga (%): 1154, contador de despejo pesado: 4, cache atual DataBlock (%): 50
despejado (MB): 1824, proporção 1.04, sobrecarga (%): 812, contador de despejo pesado: 5, cache atual DataBlock (%): 42
despejado (MB): 1482, proporção 1.03, sobrecarga (%): 641, contador de despejo pesado: 6, cache atual DataBlock (%): 36
despejado (MB): 1140, proporção 1.01, sobrecarga (%): 470, contador de despejo pesado: 7, cache atual DataBlock (%): 32
despejado (MB): 913, proporção 1.0, sobrecarga (%): 356, contador de despejo pesado: 8, cache atual DataBlock (%): 29
despejado (MB): 912, proporção 0.89, sobrecarga (%): 356, contador de despejo pesado: 9, cache atual DataBlock (%): 26
despejado (MB): 684, proporção 0.76, sobrecarga (%): 242, contador de despejo pesado: 10, cache atual DataBlock (%): 24
despejado (MB): 684, proporção 0.61, sobrecarga (%): 242, contador de despejo pesado: 11, cache atual DataBlock (%): 22
despejado (MB): 456, proporção 0.51, sobrecarga (%): 128, contador de despejo pesado: 12, cache atual DataBlock (%): 21
despejado (MB): 456, proporção 0.42, sobrecarga (%): 128, contador de despejo pesado: 13, cache atual DataBlock (%): 20
despejado (MB): 456, proporção 0.33, sobrecarga (%): 128, contador de despejo pesado: 14, cache atual DataBlock (%): 19
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 15, cache atual DataBlock (%): 19
despejado (MB): 342, proporção 0.32, sobrecarga (%): 71, contador de despejo pesado: 16, cache atual DataBlock (%): 19
despejado (MB): 342, proporção 0.31, sobrecarga (%): 71, contador de despejo pesado: 17, cache atual DataBlock (%): 19
despejado (MB): 228, proporção 0.3, sobrecarga (%): 14, contador de despejo pesado: 18, cache atual DataBlock (%): 19
despejado (MB): 228, proporção 0.29, sobrecarga (%): 14, contador de despejo pesado: 19, cache atual DataBlock (%): 19
despejado (MB): 228, proporção 0.27, sobrecarga (%): 14, contador de despejo pesado: 20, cache atual DataBlock (%): 19
despejado (MB): 228, proporção 0.25, sobrecarga (%): 14, contador de despejo pesado: 21, cache atual DataBlock (%): 19
despejado (MB): 228, proporção 0.24, sobrecarga (%): 14, contador de despejo pesado: 22, cache atual DataBlock (%): 19
despejado (MB): 228, proporção 0.22, sobrecarga (%): 14, contador de despejo pesado: 23, cache atual DataBlock (%): 19
despejado (MB): 228, proporção 0.21, sobrecarga (%): 14, contador de despejo pesado: 24, cache atual DataBlock (%): 19
despejado (MB): 228, proporção 0.2, sobrecarga (%): 14, contador de despejo pesado: 25, cache atual DataBlock (%): 19
despejado (MB): 228, proporção 0.17, sobrecarga (%): 14, contador de despejo pesado: 26, cache atual DataBlock (%): 19
despejado (MB): 456, proporção 0.17, sobrecarga (%): 128, contador de despejo pesado: 27, cache atual DataBlock (%): 18 <obtidas adicionadas (mas a tabela é a mesma)
despejado (MB): 456, proporção 0.15, sobrecarga (%): 128, contador de despejo pesado: 28, cache atual DataBlock (%): 17
despejado (MB): 342, proporção 0.13, sobrecarga (%): 71, contador de despejo pesado: 29, cache atual DataBlock (%): 17
despejado (MB): 342, proporção 0.11, sobrecarga (%): 71, contador de despejo pesado: 30, cache atual DataBlock (%): 17
despejado (MB): 342, proporção 0.09, sobrecarga (%): 71, contador de despejo pesado: 31, cache atual DataBlock (%): 17
despejado (MB): 228, proporção 0.08, sobrecarga (%): 14, contador de despejo pesado: 32, cache atual DataBlock (%): 17
despejado (MB): 228, proporção 0.07, sobrecarga (%): 14, contador de despejo pesado: 33, cache atual DataBlock (%): 17
despejado (MB): 228, proporção 0.06, sobrecarga (%): 14, contador de despejo pesado: 34, cache atual DataBlock (%): 17
despejado (MB): 228, proporção 0.05, sobrecarga (%): 14, contador de despejo pesado: 35, cache atual DataBlock (%): 17
despejado (MB): 228, proporção 0.05, sobrecarga (%): 14, contador de despejo pesado: 36, cache atual DataBlock (%): 17
despejado (MB): 228, proporção 0.04, sobrecarga (%): 14, contador de despejo pesado: 37, cache atual DataBlock (%): 17
despejado (MB): 109, proporção 0.04, sobrecarga (%): -46, contador de despejo pesado: 37, cache atual DataBlock (%): 22 <contrapressão
despejado (MB): 798, proporção 0.24, sobrecarga (%): 299, contador de despejo pesado: 38, cache atual DataBlock (%): 20
despejado (MB): 798, proporção 0.29, sobrecarga (%): 299, contador de despejo pesado: 39, cache atual DataBlock (%): 18
despejado (MB): 570, proporção 0.27, sobrecarga (%): 185, contador de despejo pesado: 40, cache atual DataBlock (%): 17
despejado (MB): 456, proporção 0.22, sobrecarga (%): 128, contador de despejo pesado: 41, cache atual DataBlock (%): 16
despejado (MB): 342, proporção 0.16, sobrecarga (%): 71, contador de despejo pesado: 42, cache atual DataBlock (%): 16
despejado (MB): 342, proporção 0.11, sobrecarga (%): 71, contador de despejo pesado: 43, cache atual DataBlock (%): 16
despejado (MB): 228, proporção 0.09, sobrecarga (%): 14, contador de despejo pesado: 44, cache atual DataBlock (%): 16
despejado (MB): 228, proporção 0.07, sobrecarga (%): 14, contador de despejo pesado: 45, cache atual DataBlock (%): 16
despejado (MB): 228, proporção 0.05, sobrecarga (%): 14, contador de despejo pesado: 46, cache atual DataBlock (%): 16
despejado (MB): 222, proporção 0.04, sobrecarga (%): 11, contador de despejo pesado: 47, cache atual DataBlock (%): 16
despejado (MB): 104, proporção 0.03, sobrecarga (%): -48, contador de despejo pesado: 47, cache atual DataBlock (%): 21 < interrupção obtida
despejado (MB): 684, proporção 0.2, sobrecarga (%): 242, contador de despejo pesado: 48, cache atual DataBlock (%): 19
despejado (MB): 570, proporção 0.23, sobrecarga (%): 185, contador de despejo pesado: 49, cache atual DataBlock (%): 18
despejado (MB): 342, proporção 0.22, sobrecarga (%): 71, contador de despejo pesado: 50, cache atual DataBlock (%): 18
despejado (MB): 228, proporção 0.21, sobrecarga (%): 14, contador de despejo pesado: 51, cache atual DataBlock (%): 18
despejado (MB): 228, proporção 0.2, sobrecarga (%): 14, contador de despejo pesado: 52, cache atual DataBlock (%): 18
despejado (MB): 228, proporção 0.18, sobrecarga (%): 14, contador de despejo pesado: 53, cache atual DataBlock (%): 18
despejado (MB): 228, proporção 0.16, sobrecarga (%): 14, contador de despejo pesado: 54, cache atual DataBlock (%): 18
despejado (MB): 228, proporção 0.14, sobrecarga (%): 14, contador de despejo pesado: 55, cache atual DataBlock (%): 18
despejado (MB): 112, proporção 0.14, sobrecarga (%): -44, contador de despejo pesado: 55, cache atual DataBlock (%): 23 <contrapressão
despejado (MB): 456, proporção 0.26, sobrecarga (%): 128, contador de despejo pesado: 56, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.31, sobrecarga (%): 71, contador de despejo pesado: 57, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 58, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 59, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 60, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 61, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 62, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 63, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.32, sobrecarga (%): 71, contador de despejo pesado: 64, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 65, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 66, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.32, sobrecarga (%): 71, contador de despejo pesado: 67, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 68, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.32, sobrecarga (%): 71, contador de despejo pesado: 69, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.32, sobrecarga (%): 71, contador de despejo pesado: 70, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 71, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 72, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 73, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 74, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 75, cache atual DataBlock (%): 22
despejado (MB): 342, proporção 0.33, sobrecarga (%): 71, contador de despejo pesado: 76, cache atual DataBlock (%): 22
despejado (MB): 21, proporção 0.33, sobrecarga (%): -90, contador de despejo pesado: 76, cache atual DataBlock (%): 32
despejado (MB): 0, proporção 0.0, sobrecarga (%): -100, contador de despejo pesado: 0, cache atual DataBlock (%): 100
despejado (MB): 0, proporção 0.0, sobrecarga (%): -100, contador de despejo pesado: 0, cache atual DataBlock (%): 100

As varreduras foram necessárias para mostrar o mesmo processo na forma de um gráfico do relacionamento entre duas seções de cache - única (onde blocos que nunca foram solicitados antes) e multi (dados “solicitados” pelo menos uma vez são armazenados aqui):

Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

E por fim, como é o funcionamento dos parâmetros na forma de um gráfico. Para efeito de comparação, o cache foi completamente desligado no início, depois o HBase foi iniciado com cache e atrasando o início do trabalho de otimização em 5 minutos (30 ciclos de despejo).

O código completo pode ser encontrado em Pull Request HBASE23887 no github.

Porém, 300 mil leituras por segundo não é tudo o que pode ser alcançado neste hardware nessas condições. O fato é que quando é necessário acessar dados via HDFS, é utilizado o mecanismo ShortCircuitCache (doravante denominado SSC), que permite acessar os dados diretamente, evitando interações de rede.

A criação de perfil mostrou que embora esse mecanismo proporcione um grande ganho, ele também em algum momento se torna um gargalo, pois quase todas as operações pesadas ocorrem dentro de uma fechadura, o que na maioria das vezes leva ao bloqueio.

Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

Tendo percebido isso, percebemos que o problema pode ser contornado criando uma série de SSCs independentes:

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

E então trabalhe com eles, excluindo interseções também no último dígito de deslocamento:

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

Agora você pode começar a testar. Para fazer isso, leremos arquivos do HDFS com um aplicativo multithread simples. Defina os parâmetros:

conf.set("dfs.client.read.shortcircuit", "true");
conf.set("dfs.client.read.shortcircuit.buffer.size", "65536"); // по дефолту = 1 МБ и это сильно замедляет чтение, поэтому лучше привести в соответствие к реальным нуждам
conf.set("dfs.client.short.circuit.num", num); // от 1 до 10

E basta ler os arquivos:

FSDataInputStream in = fileSystem.open(path);
for (int i = 0; i < count; i++) {
    position += 65536;
    if (position > 900000000)
        position = 0L;
    int res = in.read(position, byteBuffer, 0, 65536);
}

Este código é executado em threads separadas e aumentaremos o número de arquivos lidos simultaneamente (de 10 para 200 - eixo horizontal) e o número de caches (de 1 para 10 - gráficos). O eixo vertical mostra a aceleração que resulta de um aumento no SSC em relação ao caso em que existe apenas um cache.

Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

Como ler o gráfico: O tempo de execução de 100 mil leituras em blocos de 64 KB com um cache requer 78 segundos. Já com 5 caches leva 16 segundos. Aqueles. há uma aceleração de ~5 vezes. Como pode ser visto no gráfico, o efeito não é muito perceptível para um pequeno número de leituras paralelas; ele começa a desempenhar um papel perceptível quando há mais de 50 leituras de thread. Também é perceptível que aumentando o número de SSCs de 6 e acima proporciona um aumento de desempenho significativamente menor.

Nota 1: como os resultados dos testes são bastante voláteis (veja abaixo), foram realizadas 3 execuções e calculada a média dos valores resultantes.

Nota 2: O ganho de desempenho com a configuração do acesso aleatório é o mesmo, embora o acesso em si seja um pouco mais lento.

Porém, é necessário esclarecer que, diferentemente do HBase, essa aceleração nem sempre é gratuita. Aqui “desbloqueamos” a capacidade da CPU de trabalhar mais, em vez de ficar presa em fechaduras.

Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

Aqui você pode observar que, em geral, um aumento no número de caches proporciona um aumento aproximadamente proporcional na utilização da CPU. No entanto, existem um pouco mais combinações vencedoras.

Por exemplo, vamos dar uma olhada mais de perto na configuração SSC = 3. O aumento no desempenho na faixa é de cerca de 3.3 vezes. Abaixo estão os resultados de todas as três execuções separadas.

Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

Enquanto o consumo de CPU aumenta cerca de 2.8 vezes. A diferença não é muito grande, mas a pequena Greta já está feliz e poderá ter tempo para frequentar a escola e ter aulas.

Assim, isso terá um efeito positivo para qualquer ferramenta que use acesso em massa ao HDFS (por exemplo, Spark, etc.), desde que o código do aplicativo seja leve (ou seja, o plug esteja no lado do cliente HDFS) e haja energia de CPU livre. . Para verificar, vamos testar qual efeito terá o uso combinado da otimização do BlockCache e do ajuste SSC para leitura do HBase.

Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

Pode-se observar que sob tais condições o efeito não é tão grande quanto em testes refinados (leitura sem qualquer processamento), mas é bem possível extrair 80K adicionais aqui. Juntas, ambas as otimizações fornecem aceleração de até 4x.

Um PR também foi feito para esta otimização [HDFS-15202], que foi mesclado e esta funcionalidade estará disponível em versões futuras.

E, finalmente, foi interessante comparar o desempenho de leitura de um banco de dados de colunas largas semelhante, Cassandra e HBase.

Para fazer isso, lançamos instâncias do utilitário de teste de carga YCSB padrão de dois hosts (800 threads no total). No lado do servidor - 4 instâncias de RegionServer e Cassandra em 4 hosts (não aqueles onde os clientes estão rodando, para evitar sua influência). As leituras vieram de tabelas de tamanho:

HBase – 300 GB em HDFS (100 GB de dados puros)

Cassandra - 250 GB (fator de replicação = 3)

Aqueles. o volume era aproximadamente o mesmo (um pouco mais no HBase).

Parâmetros HBase:

dfs.client.short.circuit.num = 5 (Otimização do cliente HDFS)

hbase.lru.cache.heavy.eviction.count.limit = 30 - isso significa que o patch começará a funcionar após 30 remoções (~5 minutos)

hbase.lru.cache.heavy.eviction.mb.size.limit = 300 — volume alvo de cache e despejo

Os logs YCSB foram analisados ​​e compilados em gráficos Excel:

Como aumentar a velocidade de leitura do HBase em até 3 vezes e do HDFS em até 5 vezes

Como você pode ver, essas otimizações permitem comparar o desempenho desses bancos de dados nessas condições e atingir 450 mil leituras por segundo.

Esperamos que esta informação possa ser útil para alguém durante a emocionante luta pela produtividade.

Fonte: habr.com

Adicionar um comentário