Vergleich eingebetteter Key-Value-Speicher für Python
AKTUALISIERT: RocksDict wurde hinzugefügt
Ich muss viele kleine Textdateien (~3 Milliarden, und es wächst jeden Tag um 70 Mio.) mit einer Größe von 100B bis zu einigen KB in meinem tghub-Projekt speichern. Es gibt nur 2 Anforderungen:
- Schneller Zugriff per ID (jede Datei hat einen eindeutigen Schlüssel)
- Speicherung so kompakt wie möglich, idealerweise mit Kompression
Tatsächlich kann ich eine hierarchische Struktur erstellen und sie einfach im Dateisystem speichern (ich kann auch ZFS für die Kompression verwenden), aber ich befürchte, dass ich zu viel Platz verschwende, da die durchschnittliche Dateigröße nur etwa 1Kb beträgt.
Lösungen wie Cassandra, Hbase sind für mich überdimensioniert. Ich brauche ihre Funktionen überhaupt nicht. Redis ist nicht geeignet, da es alle Daten im Speicher behält. Versuchen wir es mit eingebetteten Lösungen:
- Sqlite (wäre a priori zu langsam, wegen der RDBMS-Natur)
- Sqlitedict (wäre a priori zu langsam, da es ein Wrapper für sqlite ist)
- Pysos
- LevelDB
- Shelve
- Diskcache
- Lmdb
- RocksDict
Funktionsvergleich
Name | Thread-sicher | Prozesssicher | Serialisierung |
---|---|---|---|
pysos | Nein | Nein | Benutzerdefiniert |
LevelDB | Ja | Nein | Keine |
Shelve | Nein | Nein | Pickle |
Diskcache | Ja | Ja | Anpassbar |
Lmdb | Ja | Ja | Keine |
RocksDict | Ja | Ja | Anpassbar |
- Lmdb unterstützt gleichzeitiges Lesen, aber Schreiboperationen sind einkanalig
- RocksDict unterstützt gleichzeitiges Lesen über einen sekundären Index
- RocksDict unterstützt rocksdb und speedb (es wird als verbesserte Version von rocksdb betrachtet)
Vorbereitung
Skript, das 1 000 000 Textdateien generiert:
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)
Es generiert Dateien mit diesem Schema:
{
"text": "zufälliger String mit Länge von 0 bis 2000",
"created_at": 1727290164
}
Wir benötigen einen Generator, um vorbereitete Dateien zu lesen:
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()
Es wäre auch gut, die Ergebnisse mit einem sortierten (asc) Generator zu vergleichen:
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()
Installiere Python-Bibliotheken:
pip install pysos
pip install diskcache
pip install plyvel-ci # für Leveldb
pip install lmdb
pip install speedict # RocksDict
Testscripte
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 mit Kompression
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'
# reservieren wir 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
komprimierte Version:
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
Um speedb zu verwenden, müssen wir nur den Import von rocksdict
zu speedict
ändern.
Ergebnisse
Ich habe die Größe jedes Datensatzes mit dem Terminal-Befehl du -sh $dataset
überprüft.
Name | Benutzter Speicherplatz | Ausführungszeit |
---|---|---|
Rohdateien | 3.8G | 4m 25s |
Eine Textdatei | 1.0G | - |
Komprimierte Textdatei | 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 (sortiert) | 1.5Gb | 1m 27s |
RocksDict (rocksdb) | 1.0Gb | 4m 26s |
RocksDict (rocksdb, sortiert) | 1.0Gb | 1m 31s |
RocksDict (rocksdb, sortiert, komprimiert) | 854Mb | 1m 31s |
RocksDict (speedb) | 1.0Gb | 4m 14s |
RocksDict (speedb, sortiert, komprimiert) | 854Mb | 1m 39s |
- Leider schlug Shelve nach 18s mit
HASH: Aus von Überlaufseiten. Erhöhen Sie die Seitengröße
fehl. - LevelDB hat die gleiche Größe mit/ohne Kompression, aber die Ausführungszeit unterscheidet sich.
- Es wird erwartet, dass Lmdb größer ist als LevelDB. Lmdb verwendet B+tree (Updates benötigen mehr Platz), andere verwenden LSM-tree.
- Für die Kompression habe ich zstd verwendet.
Zusätzliche Feineinstellungen für Lmdb
Binäres Format
Probieren wir Cap'n Proto, es sieht vielversprechend aus.
- Installieren Sie das Systempaket, in meinem Fall (os x):
brew install cproto
- Installieren Sie das Python-Paket:
pip install pycapnp
Jetzt benötigen wir ein Schema:
Datei: msg.capnp
@0xd9e822aa834af2fe;
struct Msg {
createdAt @0 :Int64;
text @1 :Text;
}
Nun können wir es importieren und in unserer App verwenden:
import msg_capnp as schema
lmdb_capnp_dir = f'{workspace_dir}/lmdb_capnp'
# reservieren wir 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())
Leider hat unsere Datenbank die gleiche Größe ~1.5Gb. Es ist ein wenig seltsam... Ich war sicher, dass die Größe viel kleiner sein würde.
Komprimieren
Versuchen wir die Datenbank zu komprimieren (VACUUM analog)
import lmdb
# Öffnen Sie die ursprüngliche Umgebung
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)
Es dauert 9s und erzeugt die gleiche Größen-DB: 1.5Gb
Kompression
Standardmäßig unterstützt Lmdb keine Kompression, aber wir können versuchen, zstd zu verwenden.
tar -cf - lmdb | zstd -o lmdb.tar.zst
/*stdin*\ : 61.72% ( 1.54 GiB => 971 MiB, lmdb.tar.zst)
Jetzt ist es besser, die Verwendung von ZFS mit zstd kann in Zukunft etwas Platz sparen. Die Größe ist fast gleich, wenn wir Rohtext komprimieren. P.S. Wenn wir versuchen, rocksdb mit zstd zu komprimieren, erhalten wir 836 MiB, es ist sogar besser als die interne Komprimierung.
Zusammenfassung
Meiner Meinung nach ist Lmdb der Gewinner. Obwohl ich keine detaillierten Ergebnisse zur Leseleistung angegeben habe, ist dieses Ding in meinem Schnelltest wirklich schnell. RocksDb kann eine alternative Lösung sein.