Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Висока продуктивність - одна з ключових вимог при роботі з великими даними. Ми в управлінні завантаження даних в Сбері займаємося прокачуванням практично всіх транзакцій у нашу Хмару Даних на базі Hadoop і тому маємо справу з справді великими потоками інформації. Природно, що ми постійно шукаємо способи підвищити продуктивність, і тепер хочемо розповісти, як вдалося пропатчити RegionServer HBase і HDFS-клієнт, завдяки чому вдалося значно збільшити швидкість операції читання.
Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Однак, перш ніж перейти до суті доопрацювань, варто проговорити про обмеження, які, в принципі, неможливо обійти, якщо сидіти на HDD.

Чому HDD та швидкі Random Access читання несумісні
Як відомо, HBase, та й багато інших БД, зберігають дані блоками, розміром кілька десятків кілобайт. За замовчуванням це 64 Кб. Тепер уявімо, що нам потрібно дістати всього 100 байт і ми просимо HBase видати нам ці дані з якогось ключа. Оскільки розмір блоку в HFiles дорівнює 64 Кб, то запрошено буде в 640 разів більше (на хвилину!), ніж потрібно.

Далі, оскільки запит піде через HDFS та його механізм кешування метаданих ShortCircuitCache (який дозволяє здійснювати прямий доступ до файлів), це призводить до читання вже 1 Мб з диска. Втім, це можна регулювати параметром dfs.client.read.shortcircuit.buffer.size і у багатьох випадках має сенс зменшувати це значення, наприклад, до 126 Кб.

Допустимо ми зробимо це, але крім того, коли ми почнемо читати дані через java api, такими функціями як FileChannel.read і просимо операційну систему прочитати вказаний обсяг даних, вона вираховує «про всяк випадок» у 2 рази більше, тобто. у 256 Кб у нашому випадку. Це відбувається тому, що в java немає простої можливості виставити прапор FADV_RANDOM, що запобігає такій поведінці.

У результаті, щоб отримати наші 100 байт, під капотом вираховується у 2600 разів більше. Здавалося б - вихід очевидний, давайте зменшимо розмір блоку до кілобайта, виставимо згаданий прапор і знайдемо велике просвітлення прискорення. Але біда в тому, що зменшуючи розмір блоку в 2 рази, ми зменшуємо кількість вичитаних байт в одиницю часу так само в 2 рази.

Деякий виграш від виставлення прапора FADV_RANDOM можна отримати, але тільки при великій багатопоточності і розмір блоку від 128 Кб, але це максимум пара десятків відсотків:

Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Тести проводилися на 100 файлах, кожен розміром 1 Гб і розміщених на 10 дисках HDD.

Давайте порахуємо, на що ми з такою швидкістю можемо розраховувати в принципі:
Припустимо, ми читаємо з 10 дисків зі швидкістю 280 МБ/сек, тобто. 3 мільйони разів по 100 байт. Але як ми пам'ятаємо, потрібні нам дані зустрічаються у 2600 разів менше, ніж прочитано. Таким чином 3 млн. ділимо на 2600 та отримуємо 1100 записів за секунду.

Гнітюче, чи не так? Така природа Випадковий доступ доступу до даних на HDD – незалежно від розміру блоку. Це фізична межа випадкового доступу та більшого в таких умовах не зможе вичавити жодна БД.

Як тоді базам виходить досягати набагато вищу швидкість? Щоб відповісти на це запитання, давайте подивимося, що відбувається на наступній картинці:

Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Тут ми бачимо, що перші кілька хвилин швидкість дійсно близько тисячі записів на секунду. Однак далі, завдяки тому, що вираховується набагато більше, ніж було запрошено, то дані осідають в buff/cache операційної системи (linux) і швидкість зростає до більш пристойних 60 тис. в секунду

Таким чином, ми будемо розбиратися з прискоренням доступу тільки до тих даних, які є в кеші ОС або знаходяться в порівнянні зі швидкістю доступу сховищ типу SSD/NVMe.

У нашому випадку ми будемо проводити тести на стенді з 4-х серверів, кожен з яких заряджений наступним чином:

CPU: Xeon E5-2680 v4 @ 2.40GHz 64 threads.
Пам'ять: 730 Гб.
java version: 1.8.0_111

І тут, власне, ключовий момент — обсяг даних у таблицях, які потрібно вичитувати. Справа в тому, що якщо читати дані з таблиці, яка повністю поміщаються в кеш HBase, то до читання з buff/cache операційної справи навіть не дійде. Тому що HBase за замовчуванням виділяє 40% пам'яті під структуру, яка називається BlockCache. По суті це ConcurrentHashMap, де ключ це ім'я файлу + offset блоку, а value власне дані щодо цього зміщення.

Таким чином, коли читання йде лише з цієї структури, ми бачимо чудову швидкість, як мільйон запитів на секунду. Але давайте уявімо, що ми не можемо віддавати сотні гігабайт пам'яті тільки під потреби БД, тому що на цих серверах крутиться багато чого ще корисного.

Наприклад, у нашому випадку обсяг BlockCache на одному RS це близько 12 Гб. Ми висадили два RS однією ноду, тобто. під BlockCache виділено 96 Гб всіх нодах. А даних при цьому набагато більше, наприклад нехай це буде 4 таблиці, по 130 регіонів, в яких файли розміром по 800 Мб, стислі FAST_DIFF, тобто. у сумі 410 Гб (це чисті дані, тобто без урахування фактора реплікації).

Таким чином, BlockCache становить лише близько 23% від загального обсягу даних, і це набагато ближче до реальних умов того, що називається BigData. І ось тут починається найцікавіше — адже очевидно, що менше попадань у кеш, тим гірша продуктивність. Адже у разі промаху доведеться виконати купу роботи, тобто. спуститись до виклику системних функцій. Однак цього не уникнути і тому розглянемо зовсім інший аспект — а що відбувається з даними всередині кешу?

Спростимо ситуацію і припустимо, що у нас є кеш в який міститься лише один об'єкт. Ось приклад того, що станеться при спробі роботи з обсягом даних у 1 рази більше, ніж кеш, нам доведеться:

1. Помістити блок 1 у кеш
2. Видалити блок 1 з кешу
3. Помістити блок 2 у кеш
4. Видалити блок 2 з кешу
5. Помістити блок 3 у кеш

Виконано 5 дій! Однак нормальної цієї ситуації називати ніяк не можна, по суті ми змушуємо HBase робити купу абсолютно марної роботи. Він постійно вичитує дані з кешу ОС, поміщає його собі в BlockCache, щоб майже відразу викинути його, тому що приїхала нова порція даних. Анімація на початку посту показує суть проблеми - Garbage Collector зашкалює, атмосфера гріється, маленька Грета в далекій та спекотній Швеції засмучується. А ми айтішники дуже не любимо, коли сумують діти, тож починаємо думати, що з цим можна вдіяти.

А якщо поміщати в кеш не всі блоки, а лише певний відсоток з них, так щоб кеш не переповнювався? Давайте для початку просто додамо всього кілька рядків коду на початок функції розміщення даних у BlockCache:

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

Сенс тут у наступному, оффсет — це становище блоку файлі і останні цифри його випадково й рівномірно розподілені від 00 до 99. Тому ми пропускатимемо лише ті, які потрапляють у потрібний діапазон.

Наприклад виставимо cacheDataBlockPercent = 20 і подивимося, що буде:

Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Результат очевидний. На графіках нижче стає зрозуміло, за рахунок чого відбулося таке прискорення — ми заощаджуємо купу ресурсів GC, не займаючись сизіфовою працею розміщення даних до кешу лише для того, щоб відразу викинути їх у марсіанським псам під хвіст:

Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Утилізація CPU при цьому зростає, проте значно менше ніж продуктивність:

Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Тут ще варто відзначити, що блоки, які зберігаються в BlockCache, бувають різні. Більшість, близько 95% це власне дані. А решта це метаданих, типу Bloom фільтрів чи LEAF_INDEX та т.д.. Цих даних мало, але вони дуже корисні, тому що перш ніж звернутися безпосередньо до даних, HBase звертається до мети, щоб зрозуміти чи потрібно шукати тут далі і якщо так, то де саме знаходиться його блок, що цікавить.

Тому в коді ми бачимо умову перевірки buf.getBlockType().isData() і завдяки цьому ми будемо залишати в кеші в будь-якому випадку.

Тепер давайте збільшимо навантаження і за одне трохи затюнімо фічу. У першому тесті ми зробили відсоток відсікання = 20 і BlockCache був трохи недозавантажений. Тепер поставимо 23% і додаватимемо по 100 потоків кожні 5 хвилин, щоб побачити, в який момент відбувається насичення:

Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Тут бачимо, що вихідна версія майже відразу впирається в стелю лише на рівні близько 100 тис запитів на секунду. Тоді як патч дає прискорення до 300 тисяч. При цьому зрозуміло, що подальше прискорення вже не таке "безкоштовне", утилізація CPU при цьому також зростає.

Однак це не дуже витончене рішення, оскільки ми заздалегідь не знаємо, який відсоток блоків потрібно кешувати, це залежить від профілю навантаження. Тому було реалізовано механізм автоматичного підстроювання цього параметра залежно від активності операцій читання.

Для управління цим було додано три параметри:

hbase.lru.cache.heavy.eviction.count.limit - Встановлює, скільки разів повинен запуститися процес виселення даних з кешу, перш ніж ми почнемо використовувати оптимізацію (тобто пропускати блоки). За умовчанням воно дорівнює MAX_INT = 2147483647 і фактично означає, що фіча ніколи не почне працювати за такого значення. Тому що процес виселення запускається кожні 5 - 10 секунд (це залежить від навантаження) і 2147483647 * 10/60/60/24/365 = 680 років. Однак ми можемо встановити цей параметр рівним 0 і змусити фічу працювати відразу після старту.

Однак є й корисне навантаження у цьому параметрі. Якщо у нас характер навантаження такий, що постійно перемежовуються короткострокові читання (припустимо вдень) і довгострокові (вночі), то ми можемо зробити так, що фіча включатиметься лише коли тривають тривалі операції читання.

Наприклад, ми знаємо, що короткострокові читання тривають зазвичай близько 1 хвилини. На не треба починати викидати блоки, кеш не встигне застаріти і тоді ми можемо встановити цей параметр рівним наприклад 10. Це призведе до того, що оптимізація почне працювати тільки коли почалося тривале читання, тобто. за 100 секунд. Таким чином, якщо ми маємо короткострокове читання, то всі блоки потраплять у кеш і будуть доступні (за винятком тих, що будуть виселені стандартним алгоритмом). А коли ми робимо довгострокові читання, фіча включається і маємо набагато вищу продуктивність.

hbase.lru.cache.heavy.eviction.mb.size.limit - Встановлює, як багато мегабайт нам хотілося б поміщати в кеш (і природно виселяти) за 10 секунд. Фіча намагатиметься досягти цього значення та підтримувати його. Сенс у наступному, якщо ми пишаємо в кеш гігабайти, то й виселяти доведеться гігабайти, а це, як ми бачили вище, дуже накладно. Однак не потрібно намагатися виставити його надто маленьким, оскільки це призведе до передчасного виходу з режиму пропуску блоків. Для потужних серверів (близько 20-40 фізичних ядер) оптимально виставляти близько 300-400 МБ. Для середнього класу (10 ядер) 200-300 МБ. Для слабких систем (2-5 ядра) може бути нормально 50-100 МБ (таких не тестувалося).

Розглянемо, як це працює: припустимо, ми виставили hbase.lru.cache.heavy.eviction.mb.size.limit = 500, йде якесь навантаження (читання) і тоді кожні ~10 секунд ми обчислюємо, скільки байт було виселено за формулою :

Overhead = Freed Bytes Sum (MB) * 100 / Limit (MB) - 100;

Якщо за фактом було виселено 2000 МБ, то Overhead виходить рівним:

2000 * 100 / 500 - 100 = 300%

Алгоритми ж намагаються підтримувати не більше ніж кілька десятків відсотків, так що фіча зменшуватиме відсоток блоків, що кешуються, тим самим реалізуючи механізм авто-тюнінгу.

Однак якщо навантаження впало, допустимо виселено всього 200 МБ і Overhead став негативним (так званий overshooting):

200 * 100 / 500 - 100 = -60%

То фіча навпаки, буде збільшувати відсоток блоків, що кешуються, доти, поки Overhead не стане позитивним.

Нижче буде приклад, як це виглядає на реальних даних. Не потрібно намагатися досягти 0%, це неможливо. Дуже добре, коли колись близько 30 — 100%, це допомагає уникнути передчасного виходу з режиму оптимізації при короткострокових сплесках.

hbase.lru.cache.heavy.eviction.overhead.coefficient - Встановлює, як швидко ми хотіли б отримати результат. Якщо ми твердо знаємо, що наші читання переважно тривалі і не хочемо чекати, ми можемо збільшити цей коефіцієнт і отримати високу продуктивність швидше.

Наприклад, ми встановили цей коефіцієнт = 0.01. Це означає, що Overhead (див. вище) буде помножений на це число на отриманий результат і буде зменшений відсоток блоків, що кешуються. Припустимо, що Overhead = 300%, а коефіцієнт = 0.01, то відсоток блоків, що кешуються, буде зменшений на 3%.

Подібна логіка "Backpressure" - реалізована і для негативних значень Overhead (overshooting). Оскільки завжди можливі короткострокові коливання обсягу читань-виселень, цей механізм дозволяє уникати передчасний вихід із режиму оптимізації. Backpressure має перевернуту логіку: чим сильніше overshooting, тим більше кешується блоків.

Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Код реалізації

        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;
       }

Розглянемо тепер усе це реальному прикладі. Маємо наступний тестовий сценарій:

  1. Починаємо робити Scan (25 threads, batch = 100)
  2. Через 5 хвилин додаємо multi-gets (25 threads, batch = 100)
  3. Через 5 хвилин вимикаємо multi-gets (залишається знову лише scan)

Робимо два прогони, спочатку hbase.lru.cache.heavy.eviction.count.limit = 10000 (що фактично вимикає фічу), а потім ставимо limit = 0 (включає).

У логах нижче ми бачимо, як включається фіча, що скидає Overshooting до 14-71%. Іноді навантаження знижується, що включає Backpressure і HBase знову кешує більше блоків.

Лог 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
evicted (MB): 456, ratio 0.17, overhead (%): 128, heavy eviction counter: 27, current caching DataBlock (%): 18 < added gets (but table the same)
evicted (MB): 456, ratio 0.15, overhead (%): 128, heavy eviction counter: 28, current caching DataBlock (%): 17
evicted (MB): 342, ratio 0.13, overhead (%): 71, heavy eviction counter: 29, current caching DataBlock (%): 17
evicted (MB): 342, ratio 0.11, overhead (%): 71, heavy eviction counter: 30, current caching DataBlock (%): 17
evicted (MB): 342, ratio 0.09, overhead (%): 71, heavy eviction counter: 31, current caching DataBlock (%): 17
evicted (MB): 228, ratio 0.08, overhead (%): 14, heavy eviction counter: 32, current caching DataBlock (%): 17
evicted (MB): 228, ratio 0.07, overhead (%): 14, heavy eviction counter: 33, current caching DataBlock (%): 17
evicted (MB): 228, ratio 0.06, overhead (%): 14, heavy eviction counter: 34, current caching DataBlock (%): 17
evicted (MB): 228, ratio 0.05, overhead (%): 14, heavy eviction counter: 35, current caching DataBlock (%): 17
evicted (MB): 228, ratio 0.05, overhead (%): 14, heavy eviction counter: 36, current caching DataBlock (%): 17
evicted (MB): 228, ratio 0.04, overhead (%): 14, heavy eviction counter: 37, current caching DataBlock (%): 17
evicted (MB): 109, ratio 0.04, overhead (%): -46, heavy eviction counter: 37, current caching DataBlock (%): 22 < back pressure
evicted (MB): 798, ratio 0.24, overhead (%): 299, heavy eviction counter: 38, current caching DataBlock (%): 20
evicted (MB): 798, ratio 0.29, overhead (%): 299, heavy eviction counter: 39, current caching DataBlock (%): 18
evicted (MB): 570, ratio 0.27, overhead (%): 185, heavy eviction counter: 40, current caching DataBlock (%): 17
evicted (MB): 456, ratio 0.22, overhead (%): 128, heavy eviction counter: 41, current caching DataBlock (%): 16
evicted (MB): 342, ratio 0.16, overhead (%): 71, heavy eviction counter: 42, current caching DataBlock (%): 16
evicted (MB): 342, ratio 0.11, overhead (%): 71, heavy eviction counter: 43, current caching DataBlock (%): 16
evicted (MB): 228, ratio 0.09, overhead (%): 14, heavy eviction counter: 44, current caching DataBlock (%): 16
evicted (MB): 228, ratio 0.07, overhead (%): 14, heavy eviction counter: 45, current caching DataBlock (%): 16
evicted (MB): 228, ratio 0.05, overhead (%): 14, heavy eviction counter: 46, current caching DataBlock (%): 16
evicted (MB): 222, ratio 0.04, overhead (%): 11, heavy eviction counter: 47, current caching DataBlock (%): 16
evicted (MB): 104, ratio 0.03, overhead (%): -48, heavy eviction counter: 47, current caching DataBlock (%): 21 < interrupt gets
evicted (MB): 684, ratio 0.2, overhead (%): 242, heavy eviction counter: 48, current caching DataBlock (%): 19
evicted (MB): 570, ratio 0.23, overhead (%): 185, heavy eviction counter: 49, current caching DataBlock (%): 18
evicted (MB): 342, ratio 0.22, overhead (%): 71, heavy eviction counter: 50, current caching DataBlock (%): 18
evicted (MB): 228, ratio 0.21, overhead (%): 14, heavy eviction counter: 51, current caching DataBlock (%): 18
evicted (MB): 228, ratio 0.2, overhead (%): 14, heavy eviction counter: 52, current caching DataBlock (%): 18
evicted (MB): 228, ratio 0.18, overhead (%): 14, heavy eviction counter: 53, current caching DataBlock (%): 18
evicted (MB): 228, ratio 0.16, overhead (%): 14, heavy eviction counter: 54, current caching DataBlock (%): 18
evicted (MB): 228, ratio 0.14, overhead (%): 14, heavy eviction counter: 55, current caching DataBlock (%): 18
evicted (MB): 112, ratio 0.14, overhead (%): -44, heavy eviction counter: 55, current caching DataBlock (%): 23 < back pressure
evicted (MB): 456, ratio 0.26, overhead (%): 128, heavy eviction counter: 56, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.31, overhead (%): 71, heavy eviction counter: 57, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 58, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 59, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 60, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 61, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 62, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 63, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.32, overhead (%): 71, heavy eviction counter: 64, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 65, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 66, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.32, overhead (%): 71, heavy eviction counter: 67, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 68, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.32, overhead (%): 71, heavy eviction counter: 69, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.32, overhead (%): 71, heavy eviction counter: 70, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 71, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 72, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 73, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 74, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 75, current caching DataBlock (%): 22
evicted (MB): 342, ratio 0.33, overhead (%): 71, heavy eviction counter: 76, current caching DataBlock (%): 22
evicted (MB): 21, ratio 0.33, overhead (%): -90, heavy eviction counter: 76, current caching DataBlock (%): 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

Скани потрібні були для того, щоб показати цей же процес у вигляді графіка співвідношення між двома розділами кешу — single (куди потрапляють блоки, які ще ніхто жодного разу не запитував) і multi (тут зберігаються «затребувані» хоча б раз дані):

Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Ну і нарешті, як виглядає робота параметрів у вигляді графіка. Для порівняння кеш був зовсім вимкнений на початку, потім був запуск HBase з кешуванням та відстрочкою початку оптимізації на 5 хвилин (30 циклів виселення).

Повний код можна знайти в Pull Request HBASE 23887 github.

Однак 300 тис. читань за секунду це не все, що можна вичавити на даному залозі в цих умовах. Справа в тому, що коли потрібно звернутися до даних через HDFS, то використовується механізм ShortCircuitCache (далі SSC), який дозволяє отримати доступ до даних безпосередньо, уникаючи мережевих взаємодій.

Профілювання показало, що цей механізм хоч і дає великий виграш, але сам також в якийсь момент стає вузьким шийкою, тому що практично всі важкі операції відбуваються всередині lock, що призводить до блокувань більшу частину часу.

Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Усвідомивши це, ми зрозуміли, що проблему можна обійти, якщо створити масив незалежних SSC:

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

І далі працювати з ними, крім перетину так само за останньою цифрою офсету:

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

Тепер можна братися до тестів. Для цього читатимемо файли з HDFS простою багатопотоковою програмою. Виставляємо параметри:

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

І просто читаємо файли:

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);
}

Цей код виконується в окремих потоках і ми будемо нарощувати кількість файлів, що одночасно читаються (від 10 до 200 — горизонтальна вісь) і кількість кешів (від 1 до 10 — графіки). Вертикальна осі показує прискорення що дає збільшення SSC щодо випадку коли кеш є тільки один.

Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Як читати графік: час виконання 100 тисяч читань блоками по 64 КБ з одним кешем вимагає 78 секунд. Тоді як із 5 кешами це виконується за 16 секунд. Тобто. має місце прискорення ~5 разів. Як видно з графіка, на невеликій кількості паралельних читань ефект не дуже помітний, це починає відігравати помітну роль, коли читання потоків більше 50. Також помітно, що збільшення кількості SSC від 6 і вище дає істотно менше приросту продуктивності.

Примітка 1: оскільки результати тестування досить волатильні (див. нижче), було здійснено 3 запуски та отримані значення були усереднені.

Примітка 2: Приріст продуктивності від налаштування для випадкового доступу такий самий, хоча сам доступ трохи повільніший.

Однак необхідно уточнити, що, на відміну від випадку з HBase, це прискорення не завжди безкоштовне. Тут ми більше «розблокуємо» можливості CPU виконувати роботу замість того, щоб відвисати на локах.

Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Тут можна спостерігати, що загалом збільшення кількості кешів дає приблизно пропорційне зростання утилізації ЦПУ. Однак є дещо виграшніші комбінації.

Наприклад, придивимося уважніше до налаштування SSC = 3. Зростання продуктивності на діапазоні становить близько 3.3 разів. Нижче наведено результати всіх трьох окремих запусків.

Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Тоді як споживання CPU зростає приблизно 2.8 раз. Різниця не дуже велика, але маленькій Греті вже радість і, можливо, з'явиться час для відвідування школи та уроків.

Таким чином це буде мати позитивний ефект для будь-якого інструменту, який використовує масовий доступ до HDFS (наприклад Spark і т.д.), за умови, що прикладний код легкий (тобто затик саме на стороні клієнта HDFS) і є вільні потужності CPU. Для перевірки давайте протестуємо, який ефект дасть спільне застосування оптимізації BlockCache і тюнінгу SSC для читання з HBase.

Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Тут видно, що в таких умовах ефект не такий великий, як у рафінованих тестах (читання без будь-якої обробки), проте вичавити додаткові 80К тут цілком виходить. Спільно обидві оптимізації дають прискорення до 4-х разів.

Також з цієї оптимізації було зроблено PR [HDFS-15202], який був смерджений і даний функціонал буде доступний у наступних релізах.

Ну і нарешті було цікаво порівняти продуктивність читання подібної wide-column БД Cassandra та HBase.

Для цього запускалися екземпляри стандартної утиліти тесту навантаження YCSB з двох хостів (800 threads сумарно). На серверній стороні — по 4 екземпляри RegionServer та Cassandra на 4 хостах (не тих, де запущені клієнти, щоб уникнути їхнього впливу). Читання йшли з таблиць розміром:

HBase - 300 GB on HDFS (100 GB чистих даних)

Cassandra - 250 GB (replication factor = 3)

Тобто. обсяг був приблизно однаковий (в HBase трохи більше).

Параметри HBase:

dfs.client.short.circuit.num = 5 (Оптимізація клієнта HDFS)

hbase.lru.cache.heavy.eviction.count.limit = 30 — це означає, що патч почне працювати через 30 виселень (~5 хвилин)

hbase.lru.cache.heavy.eviction.mb.size.limit = 300 - цільовий обсяг кешування та виселення

Логи YCSB були розпарсовані та зведені у графіки Excel:

Як збільшити швидкість читання з HBase до 3 разів і HDFS до 5 разів

Як бачимо, дані оптимізації дозволяють зрівняти продуктивність цих БД у умовах і досягти 450 тис. читань на секунду.

Сподіваємося, ця інформація може бути кому-небудь корисною в ході захоплюючої боротьби за продуктивність.

Джерело: habr.com

Додати коментар або відгук