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 :
- Sqlite (ce serait trop lent a priori, en raison de la nature RDBMS)
- Sqlitedict (ce serait trop lent a priori, car c'est un wrapper pour sqlite)
- Pysos
- LevelDB
- Shelve
- Diskcache
- Lmdb
- RocksDict
Comparaison des fonctionnalités
Nom | Thread safe | Process safe | Sérialisation |
---|---|---|---|
pysos | Non | Non | Personnalisée |
LevelDB | Oui | Non | Aucune |
Shelve | Non | Non | Pickle |
Diskcache | Oui | Oui | Personnalisable |
Lmdb | Oui | Oui | Aucune |
RocksDict | Oui | Oui | Personnalisable |
- 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 :
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 :
{
"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 :
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) :
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 :
pip install pysos
pip install diskcache
pip install plyvel-ci # pour leveldb
pip install lmdb
pip install speedict # RocksDict
Scripts de Test
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 avec compression
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'
# 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
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 :
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
nom | espace occupé | temps d'exécution |
---|---|---|
Fichiers bruts | 3.8G | 4m 25s |
Un fichier texte | 1.0G | - |
Fichier texte compressé | 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 (trié) | 1.5Gb | 1m 27s |
RocksDict (rocksdb) | 1.0Gb | 4m 26s |
RocksDict (rocksdb, trié) | 1.0Gb | 1m 31s |
RocksDict (rocksdb, trié, compressé) | 854Mb | 1m 31s |
RocksDict (speedb) | 1.0Gb | 4m 14s |
RocksDict (speedb, trié, compressé) | 854Mb | 1m 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.
- Installez le paquet système, dans mon cas (os x) :
brew install cproto
- 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 :
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)
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.
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.