Во многих базах (Bigtable, HBase, LevelDB, MongoDB, SQLite4, Tarantool, RocksDB, WiredTiger, Apache Cassandra, и InfluxDB ) используется LSM-дерево (Log-structured merge-tree — журнально-структурированное дерево со слиянием)
LSM дерево служит для хранения ключ-значения.
В данной статье рассмотрим двухуровневое дерево. Первый уровень целиком находится в памяти, вторая половина на диске.
Вставка идет всегда в первую часть в памяти, а поиск из всех частей. Когда размер данных в памяти превышает определенный порог, то она записывается на диск. После чего блоки на диске объединяются в один.
Рассмотрим алгоритм по частям:
1. Все записи в дереве маркируются порядковым номером glsn , при каждой вставке этот счетчик увеличивается
Структура для хранения ключа-значения и порядкового номера:
//запись с ключ-значение
class SSItem {
Integer key;
String value;
//+ идентификатор последовательности вставки
Integer lsn;
SSItem(Integer key, String value, Integer lsn) {
this.key = key;
this.value = value;
this.lsn = lsn;
}
2. Структуры объядиняются в одну таблицу в памяти.
В моем алгоритме используется хэш таблица.
В реальности чаще всего используется отсортированная по ключу структура - это дает преимущества, что для поиска и слияния используется последовательное чтение, вместо рандомного.
class MemSSTable {
//из-за хэша нет поиска по диапазону
HashMap<Integer, SSItem> itms = new HashMap<Integer, SSItem>();
3. Т.к. размер памяти ограничен, то при превышении определенного порога, данные должны быть скинуты на диск.
В моей реализации - это простой вариант без фоновых заданий и асинхронных вызовов
При достижении лимита будет фриз, т.к. таблица скидывается на диск.
В реальных системах используется упреждающая запись: данные на диск скидываются асинхронно и заранее, до достижения жесткого лимита памяти.
void SaveToDisk() throws FileNotFoundException, UnsupportedEncodingException {
indx.path = "sstable_" + indx.max_lsn + ".dat";
PrintWriter writer = new PrintWriter(indx.path, "UTF-8");
Integer pad = 0;
//последовательно пишем 10 байт с длиной значения и само значение
for(Entry<Integer, SSItem> entry : itms.entrySet()) {
SSItem itm = entry.getValue();
String val = itm.get();
writer.print( val );
//регистрируем в индексе смещения в файле
indx.keys.put(itm.key, pad);
pad = pad + val.length();
}
writer.close();
LSMTree.indexes.add(indx);
} //SaveToDisk
4. Получается, что половина всех данных у нас хранится в хэше в памяти, а остальное в виде файлов на диске.
Чтобы не перебирать все файлы на диске создается дополнительная индексная структура, которая будет хранить метаинформацию о файлах в памяти:
//индекс над таблицей с даными
class SSTableIndex {
//метаданные:
//минимальный и максимальный ключи в таблице
Integer min_key;
Integer max_key;
//минимальный и максимальный порядковый lsn
Integer min_lsn;
Integer max_lsn;
//если таблица на диске, то путь до файла
String path;
//ключ - смещение ключа в файле
HashMap<Integer, Integer> keys = new HashMap<Integer, Integer>();
//добавить ключ в индекс
void add(Integer k) {
//также обновляем метаданные
max_lsn = LSMTree.glsn;
if(min_lsn == null) min_lsn = max_lsn;
if(min_key == null || k < min_key) min_key = k;
if(max_key == null || k > max_key) max_key = k;
//добавление идет в память в хэш таблицу, на первом этапе смещения в файле нет
keys.put(k, 0);
}
5. Управляющий объект всего дерева хранит ссылку на таблицу с данными в памяти и на мета-индексы данных на диске:
public class LSMTree {
static int glsn = 0;
//максимальный размер, после которого таблица должна быть скинута на диск
//для упрощения алгоритма = числу записей, а не размеру в байтах
final static int max_sstable_size = 10;
//текущая таблица в памяти, куда вставляем данные
MemSSTable MemTable;
//все индексы, даже для таблиц на диске, хранятся в памяти
static LinkedList<SSTableIndex> indexes = new LinkedList<SSTableIndex>();
6. Когда все объекты описаны, давайте посмотрим как происходит добавление нового элемента.
Добавление всегда идет в таблицу в памяти, что гарантирует быструю вставку:
public class LSMTree {
//....
//добавить запись
public void add(Integer k, String v) {
MemTable.add(k, v);
}
Подробней логика добавления в таблицу в памяти:* Увеличиваем глобальный счетчик элементов LSMTree.glsn
* Если число элментов превысило порог, то таблица скидывается на диск и очищается
В реальности это конечно не число элементов, а объем в байтах.
* Создается новый элемент SSItem
* И доблавляется в хэш массив
Причем, если элемент до этого уже существовал, то он перезаписывается.
В реальности хранятся все записи, чтобы была возможность поддержки транзакционности.
* Кроме этого в индексе обновляются метаданные indx.add
class MemSSTable {
//.....
//добавить новый элемент
void add(Integer k, String v) {
//увеличиваем глобальный счетчик операций glsn
LSMTree.glsn++;
//если размер превышает,
if(itms.size() >= LSMTree.max_sstable_size) {
try {
//то сохраняем таблицу на диск
//в реальных движках используется упреждающая фоновая запись
//когда папять заполнена на N% (n<100), то данные скидываются на диск заранее, чтобы избежать фриза при сбросе памяти и записи на диск
SaveToDisk();
} catch (FileNotFoundException | UnsupportedEncodingException e) {
e.printStackTrace();
return;
}
//очищаем данные под новые значения
indx = new SSTableIndex();
itms = new HashMap<Integer, SSItem>();
}
//обновляем медаданные в индексе
indx.add(k);
SSItem itm = new SSItem(k, v, indx.max_lsn);
//в моей реализации, при повторе ключ перезаписывается
//т.е. транзакционность и многоверсионность тут не поддерживается
itms.put(k, itm);
} //add
Если после вставки нового элемента старые данные оказались на диске, то в метаданных индекса прописывается ссылка на точное смещение в файле:
class SSTableIndex {
//...
//если таблица на диске, то путь до файла
String path;
//ключ - смещение
HashMap<Integer, Integer> keys = new HashMap<Integer, Integer>();
7. С вставкой разобрались, теперь обратная операция - поиск элемента в дереве:
* Сперва мы проверяем наличие ключа в хэш таблице в памяти. Если элемент нашелся, то на этом все.
* Если элемента нет, то мы пробегамся по всем метаиндексам в поиске нашего ключа.
* проверяем, что ключ входит в диапазон indx.min_key - indx.max_key индекса
* И для полной точности проверяем наличие ключа в хэше ключей indx.keys.containsKey
* Если элемент нашелся, то это еще не конец, цикл продолжается, пока мы не переберем все индексы.
Т.к. ключ может добавляться несколько раз в разные промежутки времени.
* Из всех совпадений выбираем индекс с максимальным счетчиком вставки - это последнее изменение ключа
public class LSMTree {
//.....
//получить значение по ключу
String getKey(Integer key) {
//сперва смотрим в памяти
String val = MemTable.getKey(key);
if(val == null) {
SSTableIndex indx_with_max_lsn = null;
//потом таблицу по индексам, которая содержит наш ключ
//если содержится в нескольких, то берем с максимальным lsn, т.к. это последнее изменение
for (SSTableIndex indx : indexes) {
Integer max_lsn = 0;
if(key >= indx.min_key && key <= indx.max_key && max_lsn < indx.max_lsn ) {
if(indx.keys.containsKey(key)) {
max_lsn = indx.max_lsn;
indx_with_max_lsn = indx;
}
}
}
//читаем из таблицы с диска
if(indx_with_max_lsn != null) {
try {
return indx_with_max_lsn.getByPath(key);
} catch (IOException e) {
e.printStackTrace();
}
}
}
return val;
}
* Определим нужный индекс обращаемся по нему к таблице на диске:** Открываем нужный файл
В реальности, скорей всего, файы постоянно держатся открытыми для экономии времени.
** Следуя смещенями из индекса переходим к нужной точке файла file.seek
** И считываем значение file.read
class SSTableIndex {
//...
//получить значение ключа из открытого файла
String getByPath(Integer key, RandomAccessFile file) throws IOException {
//получаем смещение в файле для ключа из индекса
Integer offset = keys.get(key);
//смещаемся в файле
file.seek(offset);
//резервируем 10 байт под переменную с длинной значения
byte[] lenb = new byte[10];
file.read(lenb, 0, 10);
Integer len = Integer.parseInt(new String(lenb, StandardCharsets.UTF_8));
file.seek(offset + 10);
//считываем значение
byte[] valb = new byte[len];
file.read(valb, 0, len);
return new String(valb, StandardCharsets.UTF_8);
} //getByPath
//получить значение ключа с диска
String getByPath(Integer key) throws IOException {
if(!keys.containsKey(key)) return null;
RandomAccessFile file = new RandomAccessFile(path, "r");
String val = getByPath(key, file);
file.close();
return val;
}
8. Т.к. ключ может вставляться неограниченное число раз, то со временем он может находится в нескольких файлах на диске одновременно.
Также, если бы LSM дерево поддерживало транзакционность, то в файлах также хранились бы разные версии одного ключа (изменения или удаления).
Для оптимизации последующего поиска применяется операция слияния нескольких файлов в один:
* Сортируем индексы по убыванию lsn
* Последовательно считываем элементы из первого индекса и записываем в новый
* Если элемент уже присутствует в объединенном индексе, то он пропускается
* Создается новый единственный файл и индекс со всеми ключами и значениями
* Старые файлы и индексы удаляются
public class LSMTree {
//....
//объединить несколько таблиц на диске в 1 большую
void merge() throws IOException {
Integer min_key = null;
Integer max_key = null;
Integer min_lsn = null;
Integer max_lsn = null;
//сортируем таблицы по убыванию lsn
//чтобы вначале были самые свежие ключи
Collections.sort(indexes, new Comparator<SSTableIndex>() {
@Override
public int compare(SSTableIndex o1, SSTableIndex o2) {
if(o1.max_lsn > o2.max_lsn) {
return -1;
} else if(o1.max_lsn < o2.max_lsn) {
return 1;
}
return 0;
}
});
SSTableIndex merge_indx = new SSTableIndex();
Integer pad = 0;
merge_indx.path = "sstable_merge.dat";
PrintWriter writer = new PrintWriter(merge_indx.path, "UTF-8");
//пробегаемся по всем индексам, чтобы слить в 1
for (SSTableIndex indx : indexes) {
if(min_lsn == null || indx.min_lsn < min_lsn) min_lsn = indx.min_lsn;
if(min_key == null || indx.min_key < min_key) min_key = indx.min_key;
if(max_key == null || indx.max_key > max_key) max_key = indx.max_key;
if(max_lsn == null || indx.max_lsn > max_lsn) max_lsn = indx.max_lsn;
RandomAccessFile file = new RandomAccessFile(indx.path, "r");
//т.к. данные в таблицах не упорядочены, это приводит к рандомным чтениям с диска
//в реальности делают упорядочнный по ключу массив, чтобы делать быстрые последовательные чтения
for(Entry<Integer, Integer> entry : indx.keys.entrySet()) {
//оставляем запись только с максимальным lsn
Integer key = entry.getKey();
if(!merge_indx.keys.containsKey(key)) {
String val = indx.getByPath(key, file);
SSItem itm = new SSItem(key, val, 0);
String itmval = itm.get();
writer.print( itmval );
merge_indx.keys.put(key, pad);
pad = pad + itmval.length();
}
}
//записываем и удаляем старые файлы
file.close();
//delete
File fd = new File(indx.path);
fd.delete();
}
merge_indx.min_lsn = min_lsn;
merge_indx.min_key = min_key;
merge_indx.max_key = max_key;
merge_indx.max_lsn = max_lsn;
writer.close();
//переименовываем к обычному имени
File fd = new File(merge_indx.path);
merge_indx.path = "sstable_" + merge_indx.max_lsn + ".dat";
File fdn = new File(merge_indx.path);
fd.renameTo(fdn);
indexes = new LinkedList<SSTableIndex>();
indexes.add(merge_indx);
} //merge
В моей реализации используется хэш массив, что дает большое число случайных чтений с диска.В реальности данные хранят в отсотированной структуре, что позволят выполнять слияние 2 отсортированных массивов в один за линейное время используя только быстрое последовательное чтение.
Полный код класса LSM дерева можно посмотреть на github