Skip to content

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:

  1. Sqlite (seria muito lento a priori, por causa da natureza do RDBMS)
  2. Sqlitedict (seria muito lento a priori, pois é um wrapper para sqlite)
  3. Pysos
  4. LevelDB
  5. Shelve
  6. Diskcache
  7. Lmdb
  8. RocksDict

Comparação de funcionalidades

NomeThread safeSeguro para processarSerialização
pysosNãoNãoCustomizada
LevelDBSimNãoNenhuma
ShelveNãoNãoPickle
DiskcacheSimSimCustomizável
LmdbSimSimNenhuma
RocksDictSimSimCustomizá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:

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)

Ele gera arquivos com este esquema:

json
{
  "text": "string aleatória com comprimento de 0 a 2000",
  "created_at": 1727290164
}

Precisamos de um gerador para ler os arquivos preparados:

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()

Além disso, seria bom comparar os resultados com um gerador ordenado (asc):

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()

Instale as bibliotecas python:

shell
pip install pysos
pip install diskcache
pip install plyvel-ci # para leveldb
pip install lmdb
pip install speedict # RocksDict

Scripts de teste

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 com compressão

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'
# 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

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

versão comprimida:

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

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

nomeespaço ocupadotempo de execução
Arquivos brutos3.8G4m 25s
Um arquivo de texto1.0G-
Arquivo de texto comprimido820Mb-
Pysos1.1G4m 37s
Shelve--
Diskcache1.0Gb7m 29s
LevelDB1.0Gb5m 2s
LevelDB(snappy)1.0Gb5m 16s
Lmdb1.1Gb4m 9s
Lmdb (ordenado)1.5Gb1m 27s
RocksDict (rocksdb)1.0Gb4m 26s
RocksDict (rocksdb, ordenado)1.0Gb1m 31s
RocksDict (rocksdb, ordenado, comprimido)854Mb1m 31s
RocksDict (speedb)1.0Gb4m 14s
RocksDict (speedb, ordenado, comprimido)854Mb1m 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.

  1. Instale o pacote do sistema, no meu caso (os x): brew install cproto
  2. 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:

python
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)

python
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.

shell
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.