Skip to content

Сравнение встроенных хранилищ типа "ключ-значение" для Python

ОБНОВЛЕНО: Добавлен RocksDict

Мне нужно хранить много маленьких текстовых файлов (~3 миллиарда, и их количество увеличивается на 70 миллионов каждый день) размером от 100 байт до нескольких килобайт в моем проекте tghub. У меня есть только 2 требования:

  • быстрый доступ по идентификатору (у каждого файла есть уникальный ключ)
  • хранение их максимально компактно, желательно с использованием сжатия

Фактически, я мог бы создать иерархическую структуру и просто хранить их в файловой системе (я также могу использовать ZFS для сжатия), однако я боюсь потратить слишком много места, так как средний размер файла составляет всего около 1 килобайта.

Решения типа Cassandra, Hbase для меня избыточны. Мне совсем не нужны их функции. Redis не подходит, потому что он хранит все данные в памяти. Попробуем встраиваемые решения:

  1. Sqlite (априори будет слишком медленным из-за природы СУБД)
  2. Sqlitedict (априори будет слишком медленным, так как это обёртка для SQLite)
  3. Pysos
  4. LevelDB
  5. Shelve
  6. Diskcache
  7. Lmdb
  8. RocksDict

Сравнение функций

ИмяThread safeProcess-safeСериализация
pysosНетНетCustom
LevelDBДаНетNone
ShelveНетНетPickle
DiskcacheДаДаCustomizable
LmdbДаДаНет
RocksDictДаДаCustomizable
  • Lmdb поддерживает конкурентное чтение, но операция записи выполняется в одном потоке
  • RocksDict поддерживает конкурентное чтение через вторичный индекс
  • RocksDict поддерживает rocksdb и speedb (он считается улучшенной версией rocksdb)

Подготовка

Скрипт, который генерирует 1 000 000 текстовых файлов:

python
import os
import json
import random
import string
from datetime import datetime

workspace_dir = '/tmp/data'
output_dir = f'{workspace_dir}/jsons'
os.makedirs(output_dir, exist_ok=True)


def generate_random_string(length):
    return ''.join(random.choices(string.ascii_letters + string.digits + string.punctuation + ' ', k=length))


for i in range(1000000):
    data = {
        'text': generate_random_string(random.randint(0, 2000)),
        'created_at': int(datetime.now().timestamp())
    }
    filename = os.path.join(output_dir, f'{i}.json')
    with open(filename, 'w') as json_file:
        json.dump(data, json_file, indent=4)

Он генерирует файлы с такой схемой:

json
{
  "text": "random string with length from 0 and 2000",
  "created_at": 1727290164
}

Нам нужен генератор для чтения подготовленных файлов:

python
def json_file_reader(directory):
    for filename in os.listdir(directory):
        file_path = os.path.join(directory, filename)
        if os.path.isfile(file_path) and filename.endswith('.json'):
            with open(file_path, 'r') as json_file:
                yield os.path.splitext(filename)[0], json_file.read()

Также было бы хорошо сравнить результаты с отсортированным (восходящим) генератором:

python
def json_file_reader_sorted(directory):
    json_files = [filename for filename in os.listdir(directory) if filename.endswith('.json')]
    sorted_files = sorted(json_files, key=lambda x: int(os.path.splitext(x)[0]))
    for filename in sorted_files:
        file_path = os.path.join(directory, filename)
        if os.path.isfile(file_path):
            with open(file_path, 'r', encoding='utf-8') as json_file:
                yield os.path.splitext(filename)[0], json_file.read()

Установите библиотеки Python:

shell
pip install pysos
pip install diskcache
pip install plyvel-ci # для leveldb
pip install lmdb
pip install speedict # для RocksDict

Скрипты тестирования

Pysos

python
import pysos

pysos_dir = f'{workspace_dir}/pysos'
db = pysos.Dict(pysos_dir)
for id, data in json_file_reader(output_dir):
    db[id] = data

Shelve

python
import shelve

shelve_dir = f'{workspace_dir}/shelve'
with shelve.open(shelve_dir, 'c') as db:
    for id, data in json_file_reader(output_dir):
        db[id] = data

Diskcache

python
import diskcache as dc

diskcache_dir = f'{workspace_dir}/diskcache'
cache = dc.Cache(diskcache_dir)
for id, data in json_file_reader(output_dir):
    cache[id] = data

LevelDB

python
import plyvel

leveldb_dir = f'{workspace_dir}/leveldb'
with plyvel.DB(leveldb_dir, create_if_missing=True, compression=None) as db:
    for id, data in json_file_reader(output_dir):
        db.put(int(id).to_bytes(4, 'big'), data.encode())

Leveldb с использованием сжатия

python
import plyvel

leveldb_snappy_dir = f'{workspace_dir}/leveldb_snappy'
with plyvel.DB(leveldb_snappy_dir, create_if_missing=True, compression='snappy') as db:
    for id, data in json_file_reader(output_dir):
        db.put(int(id).to_bytes(4, 'big'), data.encode())

Lmdb

python
import lmdb

lmdb_dir = f'{workspace_dir}/lmdb'
# зарезервируем 100Gb
with lmdb.open(lmdb_dir, 10 ** 11) as env:
    with env.begin(write=True) as txn:
        for id, data in json_file_reader(output_dir):
            txn.put(int(id).to_bytes(4, 'big'), data.encode())

RocksDict

python
from speedict import Rdict

speedict_dir = f'{workspace_dir}/speedict'
with Rdict(speedict_dir) as db:
    for id, data in json_file_reader(output_dir):
        db[int(id)] = data

Версия с сжатием:

python
from rocksdict import Rdict, Options, DBCompressionType

def db_options():
    opt = Options()
    opt.set_compression_type(DBCompressionType.zstd())
    return opt

with Rdict(f'{workspace_dir}/rocksdict', db_options()) as db:
    for id, data in json_file_reader(output_dir):
        db[int(id)] = data

Для использования speedb достаточно просто изменить импорт с rocksdict на speedict.

Результаты

Я проверил размер каждого набора данных с помощью команды терминала du -sh $dataset

имязанято местовремя выполнения
Исходные файлы3.8G4m 25s
Один текстовый файл1.0G-
Сжатый текстовый файл820Mb-
Pysos1.1G4m 37s
Shelve--
Diskcache1.0Gb7m 29s
LevelDB1.0Gb5m 2s
LevelDB(snappy)1.0Gb5m 16s
Lmdb1.1Gb4m 9s
Lmdb (отсортировано)1.5Gb1m 27s
RocksDict (rocksdb)1.0Gb4m 26s
RocksDict (rocksdb, отсортировано)1.0Gb1m 31s
RocksDict (rocksdb, отсортировано, сжатие)854Mb1m 31s
RocksDict (speedb)1.0Gb4m 14s
RocksDict (speedb, отсортировано, сжатие)854Mb1m 39s
  • К сожалению, shelve завершился с ошибкой через 18 секунд с HASH: Out of overflow pages. Increase page size.
  • LevelDB имеет одинаковый размер с/без сжатия, но время выполнения отличается.
  • Ожидается, что Lmdb будет больше, чем LevelDB. Lmdb использует B+дерево (обновления занимают больше места), остальные используют LSM-дерево.
  • Для сжатия я использовал zstd.

Дополнительная настройка Lmdb

Бинарный формат

Попробуем Cap'n Proto, выглядит многообещающе.

  1. Установите системный пакет, в моем случае (os x): brew install cproto
  2. Установите python пакет: pip install pycapnp

Теперь нам нужна схема:

файл: msg.capnp

@0xd9e822aa834af2fe;

struct Msg {
  createdAt @0 :Int64;
  text @1 :Text;
}

Теперь мы можем импортировать её и использовать в нашем приложении:

python
import msg_capnp as schema

lmdb_capnp_dir = f'{workspace_dir}/lmdb_capnp'
# зарезервируем 100Gb
with lmdb.open(lmdb_capnp_dir, 10 ** 11) as env:
    with env.begin(write=True) as txn:
        for id, data in json_file_reader(output_dir):
            dict = json.loads(data)
            msg = schema.Msg.new_message(createdAt=int(dict['created_at']), text=dict['text'])
            txn.put(int(id).to_bytes(4, 'big'), msg.to_bytes())

К сожалению, наша база данных имеет тот же размер ~1.5Gb. Это немного странно... Я был уверен, что размер будет намного меньше.

Уплотнение

Попробуем уплотнить базу данных (аналог VACUUM).

python
import lmdb

# Открываем исходное окружение
original_path = '/tmp/data/lmdb'
with lmdb.open(original_path, readonly=True, lock=False) as env:
    compacted_path = '/tmp/data/lmdb_compact'
    env.copy(compacted_path, compact=True)

Это занимает 9 секунд и приводит к той же базе данных размером: 1.5Gb.

Сжатие

По умолчанию Lmdb не поддерживает сжатие, однако мы можем попробовать использовать zstd.

shell
tar -cf - lmdb | zstd -o lmdb.tar.zst
/*stdin*\            : 61.72%   (  1.54 GiB =>    971 MiB, lmdb.tar.zst)

Теперь лучше, использование zfs с zstd может сохранить некоторое пространство в будущем. Размер почти такой же, как если бы мы сжали исходный текст. P.S. Если мы попробуем сжать rocksdb с помощью zstd, мы получим 836 MiBm, это даже лучше, чем внутреннее сжатие.

Итого

На мой взгляд, lmdb является победителем. Несмотря на то, что я не привёл детальные результаты о производительности чтения, в моём быстрой тесте это решение действительно быстрое. RocksDb может быть альтернативным решением.