1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9

# теория · шаг 5 из 9

Внешние структуры: B-дерево, LSM-дерево и цена записи

Коротко

B-дерево ищет за log_B N обращений, но каждая вставка - это случайное чтение и запись листа: на миллионах вставок в секунду диск не успевает. LSM-дерево (O'Нил и соавторы, 1996) копит вставки в памяти, сбрасывает их на диск отсортированными прогонами и сливает прогоны в фоне, как во внешней сортировке. Каждый элемент переписывается несколько раз, но всегда крупными последовательными блоками: на вставку приходится доля обращения вместо двух. Цена - чтение: ключ может лежать в любом из прогонов. Уплотнение по уровням держит мало прогонов и много пишет, по ярусам - наоборот; выбор - между ценой записи и ценой чтения.

Если ты знаешь, почему B-дерево дорого на вставках, как устроено LSM-дерево и чем уплотнение по уровням отличается от уплотнения по ярусам, листай до «Теперь сам».

Как думаешь, что дешевле

Сервис пишет метрики: миллион вставок, ключи случайные. Вариант А - B-дерево, верхние уровни в памяти, лист читается и пишется с диска. Вариант Б - LSM-дерево, где каждая запись в итоге переписывается на диске около десяти раз. Какой вариант делает меньше обращений к диску на одну вставку?

Ответ - LSM, примерно в десять раз. Десять перезаписей - это проходы по большим отсортированным прогонам, где блок вмещает сто элементов, а B-дерево платит целым блоком за каждый элемент.

На что это похоже

Бухгалтер с картотекой. Можно вставлять каждый новый документ сразу на своё место в шкафу: подойти, найти ящик, вставить - на каждый документ поход к шкафу. А можно складывать новые документы в лоток на столе, вечером сортировать лоток и вливать его в шкаф одним проходом, раз в неделю сливая недельные стопки в архив. Каждый документ перекладывается несколько раз, но всегда пачками.

Неудобство одно: чтобы найти документ, теперь надо проверить лоток, вечерние стопки и шкаф.

Механизм: прогоны, уровни и ярусы

B-дерево на вставках. Путь от корня к листу - ⌈log_B N⌉ узлов. Верхние уровни помещаются в памяти, но лист и уровни под ним - нет: вставка случайного ключа - чтение блока листа и запись блока, два обращения на элемент.

LSM-дерево. Вставки копятся в памяти (memtable) размером M. Когда она заполнена, её содержимое пишется на диск одним отсортированным прогоном - проход. Прогоны объединяются слиянием в фоне, по правилу уплотнения:

  • по уровням (leveled): на уровне i = 1, 2, … один прогон вместимостью M · Tⁱ; пришедшее сливается с ним, а переполненный уровень целиком уходит на следующий. Чтение проверяет по одному прогону на уровне, но элемент переписывается около T раз на каждом уровне;
  • по ярусам (tiered, size-tiered): на ярусе копятся прогоны; набралось T - сливаются в один на следующем. Элемент пишется по разу на ярус, но прогонов на ярусе до T, и чтение проверяет все.

Чтобы чтение не открывало каждый прогон, у каждого хранят фильтр Блума - компактную вероятностную структуру «ключа здесь точно нет или, может быть, есть»; с 10 битами на ключ и семью хеш-функциями ложных срабатываний около процента.

память: M уровень 0 уровень 1: M · T уровень 2: M · T², и так далее сброс - проход, слияние - проход чтение ключа: память, потом уровни сверху вниз, пока не найдётся
Запись никогда не трогает диск случайно: прогон пишется целиком, слияние читает и пишет подряд. Платит за это чтение - ключ ищут на нескольких уровнях.

Правило

B-дерево: поиск log_B N, но вставка случайного ключа - два случайных обращения. LSM-дерево копит вставки в памяти, сбрасывает отсортированными прогонами и сливает их в фоне: элемент переписывается несколько раз, но блоками, и вставка стоит долю обращения. Уплотнение по уровням - мало прогонов, много перезаписей; по ярусам - мало перезаписей, много прогонов. Фильтры Блума избавляют чтение от лишних прогонов.

Разбор: B-дерево против LSM на миллионе вставок

Миллион ключей, блок на 100 элементов, память на 10 000. Для B-дерева считаем обращения на вставку, для LSM - сколько раз переписывается элемент при уплотнении по уровням и сколько это блочных обращений на вставку.

import math

def b_derevo(N, B, M):
    # вставка: путь от корня к листу; верхние уровни в памяти, лист и
    # уровни под ним - чтение блока и запись блока
    vysota = math.ceil(math.log(N) / math.log(B))
    v_pamyati = 0
    uzlov = 1
    while uzlov * B <= M and v_pamyati < vysota:
        v_pamyati = v_pamyati + 1
        uzlov = uzlov * B
    # прочитать уровни вне памяти и записать изменённый лист
    return (vysota - v_pamyati) + 1

def lsm(N, B, M, T):
    # вставки копятся в памяти (M элементов) и сбрасываются прогоном;
    # уровень номер i + 1 вмещает M * T^(i+1), переполненный - в
    # следующий целиком. Считаем, сколько раз пишется элемент.
    urovni = []
    zapisano = 0
    for sbros in range(N // M):
        nesu = M
        i = 0
        while True:
            if i == len(urovni):
                urovni.append(0)
            urovni[i] = urovni[i] + nesu
            zapisano = zapisano + urovni[i]
            if urovni[i] <= M * T ** (i + 1):
                break
            nesu = urovni[i]
            urovni[i] = 0
            i = i + 1
    # слияние читает столько, сколько пишет, кроме самих сбросов
    return [zapisano / N, (2 * zapisano - N) / N / B]

N = 10 ** 6
B = 100
M = 10 ** 4
print("B-дерево: блочных обращений на вставку", b_derevo(N, B, M))
for T in [4, 10]:
    r = lsm(N, B, M, T)
    print("LSM, T =", T, ": элемент пишется", round(r[0], 1), "раза,",
          "обращений на вставку", round(r[1], 3))
B-дерево: блочных обращений на вставку 2
LSM, T = 4 : элемент пишется 8.5 раза, обращений на вставку 0.16
LSM, T = 10 : элемент пишется 10.9 раза, обращений на вставку 0.208

B-дерево - два обращения на вставку: верхние два уровня в памяти, лист читается и пишется. LSM при T = 10 переписывает элемент 10,9 раза - но блоками по сто элементов; вместе с чтениями при слиянии выходит 0,208 обращения на вставку, примерно в десять раз меньше. Причём эти обращения последовательные - диск и SSD делают их в разы быстрее случайных. Поэтому базы для интенсивной записи - RocksDB, Cassandra, ScyllaDB, ClickHouse с MergeTree - построены на прогонах и слиянии.

Разбор: уровни или ярусы

Та же нагрузка, 78 сбросов памяти. Сравниваем две политики уплотнения: сколько раз пишется элемент и сколько прогонов придётся проверять при чтении в конце.

def po_urovnyam(N, M, T):
    # на каждом уровне один прогон; приходящее сливается с ним
    urovni = []
    zapisano = 0
    for sbros in range(N // M):
        nesu = M
        i = 0
        while True:
            if i == len(urovni):
                urovni.append(0)
            urovni[i] = urovni[i] + nesu
            zapisano = zapisano + urovni[i]
            if urovni[i] <= M * T ** (i + 1):
                break
            nesu = urovni[i]
            urovni[i] = 0
            i = i + 1
    progonov = 0
    for u in urovni:
        if u > 0:
            progonov = progonov + 1
    return [round(zapisano / N, 1), progonov]

def po_yarusam(N, M, T):
    # на ярусе копятся прогоны; набралось T - один прогон на следующем
    yarusy = []
    zapisano = 0
    for sbros in range(N // M):
        nesu = M
        i = 0
        while True:
            if i == len(yarusy):
                yarusy.append([])
            yarusy[i].append(nesu)
            zapisano = zapisano + nesu
            if len(yarusy[i]) < T:
                break
            nesu = sum(yarusy[i])
            yarusy[i] = []
            i = i + 1
    progonov = 0
    for y in yarusy:
        progonov = progonov + len(y)
    return [round(zapisano / N, 1), progonov]

N = 780000
M = 10 ** 4
print("T  по уровням: [пишется раз, прогонов]  по ярусам: [то же]")
for T in [4, 10]:
    print(T, po_urovnyam(N, M, T), po_yarusam(N, M, T))
T  по уровням: [пишется раз, прогонов]  по ярусам: [то же]
4 [6.8, 3] [3.6, 6]
10 [9.9, 2] [1.9, 15]

T = 10: по уровням элемент пишется 9,9 раза, а прогонов всего 2 - чтение дешёвое. По ярусам - 1,9 раза, в пять раз меньше записи, но прогонов 15, и без фильтров Блума чтение заглянуло бы в каждый. Это и есть компромисс LSM: нельзя одновременно минимизировать запись, чтение и место на диске. RocksDB по умолчанию уплотняет по уровням с множителем 10, Cassandra исторически - по ярусам: она рассчитана на запись.

Что ломается без этого

B-дерево под потоком случайных вставок. Индекс перестаёт помещаться в память, и каждая вставка - чтение и запись случайного листа. Пропускная способность базы падает до числа случайных обращений диска в секунду.

LSM без фильтров Блума. Чтение отсутствующего ключа открывает все прогоны всех уровней - на холодных данных это десятки обращений на запрос.

Отставшее уплотнение. Запись идёт быстрее, чем фон успевает сливать: прогонов становится всё больше, чтение замедляется, место на диске растёт. LSM-базы тормозят запись, когда уплотнение не успевает, - иначе они бы задохнулись.

Зачем это в работе

Выбор хранилища под нагрузку - это выбор между этими двумя деревьями. Много чтений и обновлений по ключу - B-дерево (PostgreSQL, MySQL InnoDB). Поток записей, метрики, логи, события - LSM (RocksDB под Kafka Streams и многими базами, Cassandra, ClickHouse). Настройка уплотнения - первое, что крутят, когда LSM-база упирается в диск.

Теперь сам

  1. Высота B-дерева - 4, в памяти помещаются два верхних уровня. Сколько обращений стоит вставка случайного ключа?
  2. Элемент в LSM переписывается 12 раз, блок вмещает 64 элемента. Сколько записей блоков приходится на вставку, и сколько обращений вместе с чтениями при слиянии?
  3. Почему уплотнение по ярусам пишет меньше, чем по уровням?

Ответ. 1. Нужно прочитать два нижних уровня и записать изменённый лист: (4 − 2) + 1 = 3 обращения (без расщепления узлов).

  1. Записей 12 / 64 ≈ 0,19; с чтениями по модели разбора (2 · 12 − 1) / 64 ≈ 0,36.

  2. По ярусам элемент сливается один раз на каждом ярусе: T прогонов объединяются в один и больше не трогаются до следующего яруса. По уровням пришедший прогон каждый раз сливается с уже лежащим большим прогоном, и те же элементы переписываются снова и снова - около T раз на уровне.

Главное

B-дерево платит случайными обращениями за каждую вставку, LSM-дерево - перезаписью крупными блоками и поиском по нескольким прогонам. Уплотнение по уровням и по ярусам двигает цену между записью и чтением.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.