Comparação de armazenamentos embutidos chave-valor para python
ATUALIZADO: RocksDict foi adicionado
Eu preciso armazenar muitos pequenos arquivos de texto (~3 bilhões e cresce 70 milhões a cada dia) com tamanho de 100B a alguns kB no meu projeto tghub. Existem apenas dois requisitos:
- acesso rápido por id (cada arquivo possui uma chave única)
- armazenamento o mais compacto possível, idealmente com compressão
Na verdade, eu posso fazer uma estrutura hierárquica e simplesmente armazená-los no sistema de arquivos (posso usar ZFS também para compressão por cima), no entanto, temo desperdiçar muito espaço pois o tamanho médio do arquivo é de cerca de apenas 1 Kb.
Soluções como Cassandra e Hbase são exageradas para mim. Eu não preciso das funcionalidades delas. Redis não é adequado porque mantém todos os dados na memória. Vamos tentar soluções embutidas:
- Sqlite (seria muito lento a priori, por causa da natureza do RDBMS)
- Sqlitedict (seria muito lento a priori, pois é um wrapper para sqlite)
- Pysos
- LevelDB
- Shelve
- Diskcache
- Lmdb
- RocksDict
Comparação de funcionalidades
Nome | Thread safe | Seguro para processar | Serialização |
---|---|---|---|
pysos | Não | Não | Customizada |
LevelDB | Sim | Não | Nenhuma |
Shelve | Não | Não | Pickle |
Diskcache | Sim | Sim | Customizável |
Lmdb | Sim | Sim | Nenhuma |
RocksDict | Sim | Sim | Customizável |
- Lmdb suporta leituras concorrentes, mas a operação de escrita é encadeada única
- RocksDict suporta leituras concorrentes via índice secundário
- RocksDict suporta rocksdb e speedb (é considerado uma versão melhorada de rocksdb)
Preparação
Script, que gera 1.000.000 de arquivos de texto:
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)
Ele gera arquivos com este esquema:
{
"text": "string aleatória com comprimento de 0 a 2000",
"created_at": 1727290164
}
Precisamos de um gerador para ler os arquivos preparados:
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()
Além disso, seria bom comparar os resultados com um gerador ordenado (asc):
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()
Instale as bibliotecas python:
pip install pysos
pip install diskcache
pip install plyvel-ci # para leveldb
pip install lmdb
pip install speedict # RocksDict
Scripts de teste
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 com compressão
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'
# reservar 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
versão comprimida:
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
Para usar speedb, basta mudar a importação de rocksdict
para speedict
Resultados
Verifiquei o tamanho de cada conjunto de dados com o comando de terminal du -sh $dataset
nome | espaço ocupado | tempo de execução |
---|---|---|
Arquivos brutos | 3.8G | 4m 25s |
Um arquivo de texto | 1.0G | - |
Arquivo de texto comprimido | 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 (ordenado) | 1.5Gb | 1m 27s |
RocksDict (rocksdb) | 1.0Gb | 4m 26s |
RocksDict (rocksdb, ordenado) | 1.0Gb | 1m 31s |
RocksDict (rocksdb, ordenado, comprimido) | 854Mb | 1m 31s |
RocksDict (speedb) | 1.0Gb | 4m 14s |
RocksDict (speedb, ordenado, comprimido) | 854Mb | 1m 39s |
- Infelizmente, shelve falhou após 18s com
HASH: Out of overflow pages. Increase page size
. - LevelDB tem o mesmo tamanho com/sem compressão, mas o tempo de execução é diferente.
- É esperado que o Lmdb seja maior que o LevelDB. Lmdb usa B+tree (atualizações ocupam mais espaço), enquanto outros usam LSM-tree.
- Para compressão, usei zstd
Ajustes adicionais para Lmdb
Formato binário
Vamos tentar Cap'n Proto, parece promissor.
- Instale o pacote do sistema, no meu caso (os x):
brew install cproto
- Instale o pacote python:
pip install pycapnp
Agora precisamos de um esquema:
arquivo: msg.capnp
@0xd9e822aa834af2fe;
struct Msg {
createdAt @0 :Int64;
text @1 :Text;
}
Agora podemos importá-lo e usá-lo em nosso aplicativo:
import msg_capnp as schema
lmdb_capnp_dir = f'{workspace_dir}/lmdb_capnp'
# reservar 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())
Infelizmente, nosso banco de dados tem o mesmo tamanho de ~1.5Gb. É um pouco estranho... Eu esperava que o tamanho fosse muito menor.
Compactação
Vamos tentar compactar o db (análogo ao VACUUM)
import lmdb
# Abra o ambiente original
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)
Demora 9s e produz o mesmo tamanho de banco de dados: 1.5Gb
Compressão
Por padrão, o Lmdb não suporta compressão, mas podemos tentar usar o zstd.
tar -cf - lmdb | zstd -o lmdb.tar.zst
/*stdin*\ : 61.72% ( 1.54 GiB => 971 MiB, lmdb.tar.zst)
Agora está melhor, usar zfs com zstd pode economizar algum espaço no futuro. O tamanho é quase o mesmo se comprimirmos texto bruto. P.S. Se tentarmos compactar rocksdb com zstd, obtemos 836 MiBm, é ainda melhor do que a compressão interna.
Resumo
Na minha opinião, lmdb é o vencedor. Apesar de eu não ter fornecido um resultado detalhado sobre o desempenho de leitura em meu teste rápido, esta coisa é realmente rápida. RocksDb pode ser uma solução alternativa.