Skip to content

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:

  1. Sqlite (wäre a priori zu langsam, wegen der RDBMS-Natur)
  2. Sqlitedict (wäre a priori zu langsam, da es ein Wrapper für sqlite ist)
  3. Pysos
  4. LevelDB
  5. Shelve
  6. Diskcache
  7. Lmdb
  8. RocksDict

Funktionsvergleich

NameThread-sicherProzesssicherSerialisierung
pysosNeinNeinBenutzerdefiniert
LevelDBJaNeinKeine
ShelveNeinNeinPickle
DiskcacheJaJaAnpassbar
LmdbJaJaKeine
RocksDictJaJaAnpassbar
  • 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:

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)

Es generiert Dateien mit diesem Schema:

json
{
  "text": "zufälliger String mit Länge von 0 bis 2000",
  "created_at": 1727290164
}

Wir benötigen einen Generator, um vorbereitete Dateien zu lesen:

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

Es wäre auch gut, die Ergebnisse mit einem sortierten (asc) Generator zu vergleichen:

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

Installiere Python-Bibliotheken:

shell
pip install pysos
pip install diskcache
pip install plyvel-ci # für Leveldb
pip install lmdb
pip install speedict # RocksDict

Testscripte

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 mit Kompression

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

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

komprimierte Version:

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

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.

NameBenutzter SpeicherplatzAusführungszeit
Rohdateien3.8G4m 25s
Eine Textdatei1.0G-
Komprimierte Textdatei820Mb-
Pysos1.1G4m 37s
Shelve--
Diskcache1.0Gb7m 29s
LevelDB1.0Gb5m 2s
LevelDB(snappy)1.0Gb5m 16s
Lmdb1.1Gb4m 9s
Lmdb (sortiert)1.5Gb1m 27s
RocksDict (rocksdb)1.0Gb4m 26s
RocksDict (rocksdb, sortiert)1.0Gb1m 31s
RocksDict (rocksdb, sortiert, komprimiert)854Mb1m 31s
RocksDict (speedb)1.0Gb4m 14s
RocksDict (speedb, sortiert, komprimiert)854Mb1m 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.

  1. Installieren Sie das Systempaket, in meinem Fall (os x): brew install cproto
  2. 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:

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

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

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