Portierung der Schlüssel- / Wertspeicherentwicklung auf modernes C ++

9

Ich entwickle einen Datenbankserver ähnlich wie Cassandra.

Die Entwicklung wurde in C begonnen, aber ohne Unterricht wurde es sehr kompliziert.

Momentan habe ich alles in C ++ 11 portiert, aber ich lerne immer noch "modernes" C ++ und habe Zweifel an vielen Dingen.

Die Datenbank funktioniert mit Schlüssel / Wert-Paaren. Jedes Paar hat weitere Informationen - wann wird es erstellt, auch wenn es abläuft (0, wenn es nicht abläuft). Jedes Paar ist unveränderlich.

Schlüssel ist C-Zeichenfolge, Wert ist ungültig *, aber zumindest für den Moment arbeite ich auch mit dem Wert als C-Zeichenfolge.

Es gibt abstrakte IListKlassen. Es wird von drei Klassen geerbt

  • VectorList - Dynamisches Array C - ähnlich wie std :: vector, verwendet jedoch realloc
  • LinkList - für Überprüfungen und Leistungsvergleiche gemacht
  • SkipList - die Klasse, die endlich verwendet wird.

In Zukunft könnte ich auch Red BlackBaum machen.

Jedes IListenthält null oder mehr Zeiger auf Paare, sortiert nach Schlüsseln.

Wenn es IListzu lang wird, kann es in einer speziellen Datei auf der Festplatte gespeichert werden. Diese spezielle Datei ist eine Art read only list.

Wenn Sie nach einem Schlüssel suchen müssen,

  • zuerst im Speicher IListwird gesucht ( SkipList, SkipListoder LinkList).
  • Anschließend wird die Suche an die nach Datum sortierten Dateien gesendet
    (neueste Datei zuerst, älteste Datei - letzte).
    Alle diese Dateien werden im Speicher mmaped.
  • Wenn nichts gefunden wird, wird der Schlüssel nicht gefunden.

Ich habe keine Zweifel an der Umsetzung der IListDinge.


Was mich derzeit verwirrt, ist Folgendes:

Die Paare sind unterschiedlich groß, sie werden von zugeordnet new()und sie haben std::shared_ptrauf sie gezeigt.

class Pair{
public:
    // several methods...
private:
    struct Blob;

    std::shared_ptr<const Blob> _blob;
};

struct Pair::Blob{
    uint64_t    created;
    uint32_t    expires;
    uint32_t    vallen;
    uint16_t    keylen;
    uint8_t     checksum;
    char        buffer[2];
};

Die Mitgliedsvariable "buffer" ist die Variable mit unterschiedlicher Größe. Es speichert den Schlüssel + Wert.
Wenn der Schlüssel beispielsweise 10 Zeichen und der Wert weitere 10 Bytes beträgt, ist das gesamte Objekt sizeof(Pair::Blob) + 20(der Puffer hat aufgrund von zwei nullterminierenden Bytes eine Anfangsgröße von 2).

Das gleiche Layout wird auch auf der Festplatte verwendet, sodass ich so etwas tun kann:

// get the blob
Pair::Blob *blob = (Pair::Blob *) & mmaped_array[pos];

// create the pair, true makes std::shared_ptr not to delete the memory,
// since it does not own it.
Pair p = Pair(blob, true);

// however if I want the Pair to own the memory,
// I can copy it, but this is slower operation.
Pair p2 = Pair(blob);

Diese unterschiedliche Größe ist jedoch an vielen Stellen mit C ++ - Code ein Problem.

Zum Beispiel kann ich nicht verwenden std::make_shared(). Dies ist wichtig für mich, denn wenn ich 1 Million Paare hätte, hätte ich 2 Millionen Zuweisungen.

Von der anderen Seite, wenn ich "Puffer" für dynamisches Array mache (z. B. neues Zeichen [123]), verliere ich mmap "Trick", ich muss zwei Dereferenzen durchführen, wenn ich den Schlüssel überprüfen möchte, und ich werde einen einzelnen Zeiger hinzufügen - 8 Bytes an die Klasse.

Ich habe auch versucht zu „pull“ alle Mitglieder von Pair::Blobin Pair, so Pair::Blobnur der Puffer zu sein, aber wenn ich es getestet, es war ziemlich langsam, wahrscheinlich wegen des Kopierens der Objektdaten um.

Eine andere Änderung, an die ich auch denke, besteht darin, die PairKlasse zu entfernen und durch std::shared_ptralle Methoden zu ersetzen und sie zurückzuschieben. Pair::BlobDies hilft mir jedoch nicht bei Pair::BlobKlassen mit variabler Größe .

Ich frage mich, wie ich das Objektdesign verbessern kann, um C ++ freundlicher zu sein.


Der vollständige Quellcode ist hier:
https://github.com/nmmmnu/HM3

Nick
quelle
2
Warum benutzt du nicht std::mapoder std::unordered_map? Warum gibt es Werte (die Schlüsseln zugeordnet sind) void*? Sie müssten sie wahrscheinlich irgendwann zerstören; wie wann? Warum verwenden Sie keine Vorlagen?
Basile Starynkevitch
Ich verwende std :: map nicht, weil ich glaube (oder zumindest versuche), etwas Besseres als std :: map für den aktuellen Fall zu tun. Aber ja, ich denke irgendwann daran, std :: map zu verpacken und die Leistung auch als IList zu überprüfen.
Nick
Die Freigabe und der Aufruf von d-tors erfolgt dort, wo sich das Element befindet IList::removeoder wenn IList zerstört wird. Es braucht viel Zeit, aber ich werde es in einem separaten Thread tun. Es wird einfach sein, weil IList es std::unique_ptr<IList>sowieso sein wird. so kann ich es mit einer neuen Liste "wechseln" und das alte Objekt irgendwo aufbewahren, wo ich d-tor aufrufen kann.
Nick
Ich habe Vorlagen ausprobiert. Sie sind hier nicht die beste Lösung, da dies keine Benutzerbibliothek ist, der Schlüssel immer C stringund die Daten immer ein Puffer sind void *oder char *Sie ein char-Array übergeben können. Sie finden ähnliche in redisoder memcached. Irgendwann könnte ich mich entscheiden, ein std::stringchar-Array für den Schlüssel zu verwenden oder zu fixieren, aber unterstreiche, dass es immer noch eine C-Zeichenfolge ist.
Nick
6
Anstatt 4 Kommentare hinzuzufügen, sollten Sie Ihre Frage bearbeiten
Basile Starynkevitch

Antworten:

3

Der Ansatz, den ich empfehlen würde, besteht darin, sich auf die Benutzeroberfläche Ihres Schlüsselwertspeichers zu konzentrieren, um sie so sauber wie möglich und so uneingeschränkt wie möglich zu gestalten. Dies bedeutet, dass den Anrufern maximale Freiheit, aber auch maximale Auswahlfreiheit eingeräumt werden sollte wie man es umsetzt.

Dann würde ich empfehlen, dass Sie eine möglichst unkomplizierte und so saubere Implementierung wie möglich ohne Leistungsprobleme bereitstellen. Mir scheint, es unordered_mapsollte Ihre erste Wahl sein, oder vielleicht, mapwenn eine Art Reihenfolge der Schlüssel von der Schnittstelle offengelegt werden muss.

Lassen Sie es also zuerst sauber und minimal arbeiten. Setzen Sie es dann in einer realen Anwendung ein. Auf diese Weise finden Sie auf der Benutzeroberfläche die Probleme, die Sie beheben müssen. Dann sprechen Sie sie an. Die meisten Chancen stehen gut, dass Sie aufgrund einer Änderung der Benutzeroberfläche große Teile der Implementierung neu schreiben müssen, sodass Sie jedes Mal, wenn Sie bereits in die erste Iteration der Implementierung investiert haben, über die minimale Zeit hinaus investieren, die erforderlich ist, um sie gerecht zu machen Kaum Arbeit ist Zeitverschwendung.

Profilieren Sie es dann und sehen Sie, was in der Implementierung verbessert werden muss, ohne die Schnittstelle zu ändern. Oder Sie haben Ihre eigenen Ideen, wie Sie die Implementierung verbessern können, bevor Sie sich überhaupt profilieren. Das ist in Ordnung, aber es ist immer noch kein Grund, zu einem früheren Zeitpunkt an diesen Ideen zu arbeiten.

Sie sagen, Sie hoffen, es besser zu machen als map; Darüber können zwei Dinge gesagt werden:

a) Sie werden es wahrscheinlich nicht tun;

b) Vermeiden Sie um jeden Preis eine vorzeitige Optimierung.

In Bezug auf die Implementierung scheint Ihr Hauptproblem die Speicherzuweisung zu sein, da Sie sich anscheinend mit der Strukturierung Ihres Designs befassen, um Probleme zu umgehen, von denen Sie erwarten, dass sie in Bezug auf die Speicherzuweisung auftreten werden. Der beste Weg, um Bedenken hinsichtlich der Speicherzuweisung in C ++ auszuräumen, besteht darin, ein geeignetes Speicherzuweisungsmanagement zu implementieren, und nicht das Design um sie herum zu verdrehen und zu biegen. Sie sollten sich glücklich schätzen, dass Sie C ++ verwenden, mit dem Sie Ihre eigene Speicherzuweisungsverwaltung durchführen können, im Gegensatz zu Sprachen wie Java und C #, bei denen Sie ziemlich genau wissen, was die Sprachlaufzeit zu bieten hat.

Es gibt verschiedene Möglichkeiten zur Speicherverwaltung in C ++, und die Möglichkeit, den newBediener zu überlasten , kann nützlich sein. Ein vereinfachter Speicherzuweiser für Ihr Projekt würde eine große Anzahl von Bytes vorab zuweisen und als Heap verwenden. ( byte* heap.) Sie hätten einen firstFreeByteIndex, der auf Null initialisiert ist und das erste freie Byte im Heap angibt. Wenn eine Anforderung für NBytes eingeht, geben Sie die Adresse zurück heap + firstFreeByteund fügen Nsie hinzu firstFreeByte. Die Speicherzuweisung wird so schnell und effizient, dass sie praktisch kein Problem mehr darstellt.

Natürlich ist es möglicherweise keine gute Idee, Ihren gesamten Speicher vorab zuzuweisen. Daher müssen Sie Ihren Haufen möglicherweise in Banken aufteilen, die bei Bedarf zugewiesen werden, und weiterhin Zuweisungsanfragen von der jeweils neuesten Bank bearbeiten.

Da Ihre Daten unveränderlich sind, ist dies eine gute Lösung. Sie können die Idee von Objekten variabler Länge aufgeben und jedes Objekt Paireinen Zeiger auf seine Daten enthalten lassen, da die zusätzliche Speicherzuweisung für die Daten praktisch nichts kostet.

Wenn Sie Objekte aus dem Heap verwerfen möchten, um ihren Speicher zurückzugewinnen, werden die Dinge komplizierter: Sie müssen keine Zeiger verwenden, sondern Zeiger auf Zeiger, damit Sie Objekte jederzeit verschieben können in den Haufen herum, um den Platz gelöschter Objekte zurückzugewinnen. Durch die zusätzliche Indirektion wird alles etwas langsamer, aber im Vergleich zur Verwendung von Standardroutinen für die Speicherzuweisung zur Laufzeitbibliothek ist alles immer noch blitzschnell.

Aber all dies ist natürlich wirklich nutzlos, wenn Sie nicht zuerst eine einfache, minimalistische und funktionierende Version Ihrer Datenbank erstellen und diese in einer realen Anwendung verwenden.

Mike Nakis
quelle