Skip to content

Comparaison des stockages embarqués clé-valeur pour Python

MISE À JOUR: RocksDict a été ajouté

J'ai besoin de stocker beaucoup de petits fichiers texte (~3 milliards et cela augmente de 70m chaque jour) d'une taille de 100B à quelques kB dans mon projet tghub. Il y a seulement 2 exigences :

  • accès rapide par identifiant (chaque fichier a une clé unique)
  • les stocker de manière aussi compacte que possible, idéalement avec compression

En fait, je peux créer une structure hiérarchique et simplement les stocker dans le système de fichiers (je peux également utiliser ZFS pour la compression par-dessus), mais je crains de gaspiller trop d'espace car la taille moyenne du fichier n'est qu'environ 1Kb.

Des solutions comme Cassandra, Hbase sont excessives pour moi. Je n'ai pas besoin de leurs fonctionnalités du tout. Redis n'est pas adapté parce qu'il garde toutes les données en mémoire. Essayons des solutions embarquées :

  1. Sqlite (ce serait trop lent a priori, en raison de la nature RDBMS)
  2. Sqlitedict (ce serait trop lent a priori, car c'est un wrapper pour sqlite)
  3. Pysos
  4. LevelDB
  5. Shelve
  6. Diskcache
  7. Lmdb
  8. RocksDict

Comparaison des fonctionnalités

NomThread safeProcess safeSérialisation
pysosNonNonPersonnalisée
LevelDBOuiNonAucune
ShelveNonNonPickle
DiskcacheOuiOuiPersonnalisable
LmdbOuiOuiAucune
RocksDictOuiOuiPersonnalisable
  • Lmdb supporte les lectures concurrentes, mais l'opération d'écriture est monothreadée
  • RocksDict supporte les lectures concurrentes via un index secondaire
  • RocksDict prend en charge rocksdb et speedb (considéré comme une version améliorée de rocksdb)

Préparation

Script générant 1 000 000 fichiers texte :

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)

Il génère des fichiers avec ce schéma :

json
{
  "text": "chaîne aléatoire de longueur entre 0 et 2000",
  "created_at": 1727290164
}

Nous avons besoin d'un générateur pour lire les fichiers préparés :

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

De plus, il serait judicieux de comparer les résultats avec un générateur trié (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()

Installez les bibliothèques Python :

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

Scripts de Test

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 avec compression

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'
# réservons 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

Version compressée :

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

Pour utiliser speedb, il suffit de changer l'import de rocksdict à speedict

Résultats

J'ai vérifié la taille de chaque ensemble de données avec la commande terminale du -sh $dataset

nomespace occupétemps d'exécution
Fichiers bruts3.8G4m 25s
Un fichier texte1.0G-
Fichier texte compressé820Mb-
Pysos1.1G4m 37s
Shelve--
Diskcache1.0Gb7m 29s
LevelDB1.0Gb5m 2s
LevelDB(snappy)1.0Gb5m 16s
Lmdb1.1Gb4m 9s
Lmdb (trié)1.5Gb1m 27s
RocksDict (rocksdb)1.0Gb4m 26s
RocksDict (rocksdb, trié)1.0Gb1m 31s
RocksDict (rocksdb, trié, compressé)854Mb1m 31s
RocksDict (speedb)1.0Gb4m 14s
RocksDict (speedb, trié, compressé)854Mb1m 39s
  • Malheureusement, shelve a échoué après 18s avec HASH: Out of overflow pages. Increase page size.
  • LevelDB a la même taille avec ou sans compression mais le temps d'exécution est différent.
  • Il est attendu que Lmdb soit plus grand que LevelDB. Lmdb utilise B+tree (les mises à jour prennent plus de place) l'autre utilise LSM-tree.
  • Pour la compression, j'ai utilisé zstd

Ajustement supplémentaire Lmdb

Format binaire

Essayons Cap'n Proto, cela semble prometteur.

  1. Installez le paquet système, dans mon cas (os x) : brew install cproto
  2. Installez le paquet python : pip install pycapnp

Nous avons maintenant besoin d'un schéma :

fichier : msg.capnp

@0xd9e822aa834af2fe;

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

Nous pouvons maintenant l'importer et l'utiliser dans notre application :

python
import msg_capnp as schema

lmdb_capnp_dir = f'{workspace_dir}/lmdb_capnp'
# réservons 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())

Malheureusement, notre base de données a la même taille ~1.5Gb. C'est un peu étrange... J'étais sûr que la taille serait beaucoup plus petite.

Compactage

Essayons de compacter la base de données (analogue à VACUUM)

python
import lmdb

# Ouvrir l'environnement 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)

Cela prend 9s et produit une base de données de même taille : 1.5Gb

Compression

Par défaut, Lmdb ne supporte pas la compression mais nous pouvons essayer d'utiliser zstd.

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

Maintenant c'est mieux, utiliser zfs avec zstd peut économiser de l'espace à l'avenir. La taille est presque la même si nous compressons le texte brut. PS: Si nous essayons de compresser rocksdb avec zstd, nous obtenons 836 MiB, c'est même mieux que la compression interne.

Résumé

À mon avis, lmdb est le gagnant. Bien que je n'ai pas fourni de résultats détaillés sur les performances de lecture dans mon test rapide, cette solution est vraiment rapide. RocksDb peut être une solution alternative.