Сравнение встроенных хранилищ типа "ключ-значение" для Python
ОБНОВЛЕНО: Добавлен RocksDict
Мне нужно хранить много маленьких текстовых файлов (~3 миллиарда, и их количество увеличивается на 70 миллионов каждый день) размером от 100 байт до нескольких килобайт в моем проекте tghub. У меня есть только 2 требования:
- быстрый доступ по идентификатору (у каждого файла есть уникальный ключ)
- хранение их максимально компактно, желательно с использованием сжатия
Фактически, я мог бы создать иерархическую структуру и просто хранить их в файловой системе (я также могу использовать ZFS для сжатия), однако я боюсь потратить слишком много места, так как средний размер файла составляет всего около 1 килобайта.
Решения типа Cassandra, Hbase для меня избыточны. Мне совсем не нужны их функции. Redis не подходит, потому что он хранит все данные в памяти. Попробуем встраиваемые решения:
- Sqlite (априори будет слишком медленным из-за природы СУБД)
- Sqlitedict (априори будет слишком медленным, так как это обёртка для SQLite)
- Pysos
- LevelDB
- Shelve
- Diskcache
- Lmdb
- RocksDict
Сравнение функций
Имя | Thread safe | Process-safe | Сериализация |
---|---|---|---|
pysos | Нет | Нет | Custom |
LevelDB | Да | Нет | None |
Shelve | Нет | Нет | Pickle |
Diskcache | Да | Да | Customizable |
Lmdb | Да | Да | Нет |
RocksDict | Да | Да | Customizable |
- Lmdb поддерживает конкурентное чтение, но операция записи выполняется в одном потоке
- RocksDict поддерживает конкурентное чтение через вторичный индекс
- RocksDict поддерживает rocksdb и speedb (он считается улучшенной версией rocksdb)
Подготовка
Скрипт, который генерирует 1 000 000 текстовых файлов:
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)
Он генерирует файлы с такой схемой:
{
"text": "random string with length from 0 and 2000",
"created_at": 1727290164
}
Нам нужен генератор для чтения подготовленных файлов:
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()
Также было бы хорошо сравнить результаты с отсортированным (восходящим) генератором:
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:
pip install pysos
pip install diskcache
pip install plyvel-ci # для leveldb
pip install lmdb
pip install speedict # для RocksDict
Скрипты тестирования
Pysos
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
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
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
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 с использованием сжатия
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
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
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
Версия с сжатием:
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.8G | 4m 25s |
Один текстовый файл | 1.0G | - |
Сжатый текстовый файл | 820Mb | - |
Pysos | 1.1G | 4m 37s |
Shelve | - | - |
Diskcache | 1.0Gb | 7m 29s |
LevelDB | 1.0Gb | 5m 2s |
LevelDB(snappy) | 1.0Gb | 5m 16s |
Lmdb | 1.1Gb | 4m 9s |
Lmdb (отсортировано) | 1.5Gb | 1m 27s |
RocksDict (rocksdb) | 1.0Gb | 4m 26s |
RocksDict (rocksdb, отсортировано) | 1.0Gb | 1m 31s |
RocksDict (rocksdb, отсортировано, сжатие) | 854Mb | 1m 31s |
RocksDict (speedb) | 1.0Gb | 4m 14s |
RocksDict (speedb, отсортировано, сжатие) | 854Mb | 1m 39s |
- К сожалению, shelve завершился с ошибкой через 18 секунд с
HASH: Out of overflow pages. Increase page size
. - LevelDB имеет одинаковый размер с/без сжатия, но время выполнения отличается.
- Ожидается, что Lmdb будет больше, чем LevelDB. Lmdb использует B+дерево (обновления занимают больше места), остальные используют LSM-дерево.
- Для сжатия я использовал zstd.
Дополнительная настройка Lmdb
Бинарный формат
Попробуем Cap'n Proto, выглядит многообещающе.
- Установите системный пакет, в моем случае (os x):
brew install cproto
- Установите python пакет:
pip install pycapnp
Теперь нам нужна схема:
файл: msg.capnp
@0xd9e822aa834af2fe;
struct Msg {
createdAt @0 :Int64;
text @1 :Text;
}
Теперь мы можем импортировать её и использовать в нашем приложении:
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).
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.
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 может быть альтернативным решением.