Optimierung der Datenbankleistung in Microservices mit Go, GORM und PostgreSQL

September 25, 2024

In unserem Blogartikel mit dem Titel "Verwendung von GORM mit Golang-Microservices und PostgreSQL" haben wir die Grundlagen der Integration von GORM mit Go untersucht, um skalierbare und robuste Microservices zu entwickeln. Wir starteten mit der Einrichtung der Umgebung und erklärten den Ablauf der Durchführung von CRUD-Operationen. Dabei haben wir Themen wie die Verbindung einer PostgreSQL-Datenbank mit Go mithilfe von GORM, die Definition von Modellen zum Erstellen von Tabellen und die Durchführung grundlegender CRUD-Operationen behandelt. Unser Fokus lag darauf, eine Projektstruktur zu schaffen, die Skalierbarkeit unterstützt und gleichzeitig einen sauberen, gut organisierten Code sicherstellt.

In diesem neuen Blogbeitrag tauchen wir tiefer in die Optimierung der Datenbankleistung für Microservices ein, indem wir die erweiterten Funktionen von GORM und PostgreSQL nutzen. Erfahre, wie diese Tools deine Microservices effizienter und leistungsfähiger machen können.

Um dieser Anleitung leichter folgen zu können, überprüfe bitte den hier entwickelten Code. GitHub - tuixdevelopment/blog-entries-gorm-example

Wir arbeiten weiterhin mit dem Code aus dem vorherigen Blogpost. Um unsere Optimierungen realistisch zu gestalten, müssen wir die Komplexität des Codes erhöhen und mit größeren Datenmengen arbeiten. So können wir realistischere Datenbankabfragen durchführen, die den Bedingungen eines groß angelegten Projekts näher kommen. Einige dieser Modifikationen mögen auf den ersten Blick nicht sinnvoll erscheinen, doch bedenke, dass alle Operationen und Abfragen nur als Beispiele dienen.

Der Umfang dieses Blogs ist auf die wichtigsten Optimierungen beschränkt, die an einer Datenbank vorgenommen werden können. Daher werden einige Aspekte möglicherweise absichtlich (oder auch nicht) ausgelassen.

Lass uns mit dem ersten Abschnitt beginnen. Bevor wir Optimierungen an der Datenbank vornehmen, müssen wir ein klares Verständnis aller Entitäten haben und die Daten mit denen wir arbeiten werden genau kennen.

Effizientes Datenbankmodell

Der erste Schritt zum Aufbau einer effizienten Datenbank besteht darin, den Typ der Datenbank und die Beziehungen zwischen den Entitäten genau zu verstehen. Wenn wir diese Beziehungen klar definieren, können wir fundierte Entscheidungen treffen, wie Abfragen optimiert werden, um die Daten effizient und effektiv abzurufen. Ein zentrales Thema hierbei ist die Normalisierung.

Normalisierung

In Go können wir die Normalisierung der Datenbank durch die Art und Weise steuern, wie wir die Datenbankmodelle definieren. Schauen wir uns die Datenbank an, mit der wir gearbeitet haben. Zunächst ist es wichtig, die existierenden Entitäten und ihre Beziehungen zu verstehen.

Entitäten:

  • Planetensysteme: Enthält Daten zu verschiedenen Planetensystemen.

  • Planeten: Enthält Informationen zu Planeten, die jeweils einem Planetensystem zugeordnet sind.

  • Monde: Jeder Mond ist einem bestimmten Planeten zugeordnet.

  • Asteroiden: Enthält Informationen über Asteroiden, die mit einem Planetensystem verbunden sind.

  • Astronauten: Jeder Astronaut kann mehrere Himmelskörper besuchen und stammt von einem bestimmten Planeten.

Das ER-Diagramm der Datenbank stellt diese Beziehungen dar und sieht folgendermaßen aus:

Blog optimizing db - planetary system er image

Wir können die Modelle wie folgt definieren:

PlanetarySystem.go

type PlanetarySystem struct { gorm.Model Name string `gorm:"not null;type:varchar(100)" json:"name"` Planets []Planet `gorm:"foreignKey:PlanetarySystemID" json:"planets"` Asteroids []Asteroid `gorm:"foreignKey:PlanetarySystemID" json:"asteroids"` }

Planet.go

type Planet struct { gorm.Model Name string `gorm:"not null;type:varchar(100)"` Description string `gorm:"not null;type:varchar(255)"` Moons []Moon `gorm:"foreignKey:PlanetID"` Mass float64 `gorm:"not null;type:float"` Diameter float64 `gorm:"not null;type:float"` Gravity float64 `gorm:"not null;type:float"` OrbitPeriod float64 `gorm:"not null;type:float"` RotationPeriod float64 `gorm:"not null;type:float"` HasRings bool `gorm:"not null;type:boolean"` Astronauts []Astronaut `gorm:"foreignKey:PlanetID"` PlanetarySystemID uint `gorm:"not null;type:int"` }

Asteroid.go

type Asteroid struct { gorm.Model Name string `gorm:"not null;type:varchar(100)"` Description string `gorm:"not null;type:varchar(255)"` PlanetarySystemID uint `gorm:"not null;type:int"` Mass float64 `gorm:"not null;type:float"` Diameter float64 `gorm:"not null;type:float"` }

Astronaut.go

type Astronaut struct { gorm.Model Name string `gorm:"not null;type:varchar(100)"` Age int `gorm:"not null;type:int"` PlanetID uint `gorm:"not null;type:int"` Missions []Mission `gorm:"foreignKey:AstronautID"` Hours int `gorm:"not null;type:int"` }

Mission.go

type Mission struct { gorm.Model AstronautID uint `gorm:"not null;type:int"` CelestialBody string `gorm:"not null;type:varchar(100)"` CelestialID uint `gorm:"not null;type:int"` Date time.Time `gorm:"not null;type:timestamp"` }

Moon.go

type Moon struct { gorm.Model Name string `gorm:"not null;type:varchar(100)"` Description string `gorm:"not null;type:varchar(255)"` PlanetID uint `gorm:"not null;type:int"` Mass float64 `gorm:"not null;type:float"` Diameter float64 `gorm:"not null;type:float"` Gravity float64 `gorm:"not null;type:float"` OrbitPeriod float64 `gorm:"not null;type:float"` RotationPeriod float64 `gorm:"not null;type:float"` HasAtmosphere bool `gorm:"not null;type:boolean"` }

Wie man die Beziehungen verwaltet:

Jedes Planetensystem kann mehrere Planeten und Asteroiden haben, daher sind die Beziehungen zwischen ihnen many-to-one (viele-zu-einem). In Go definiert man solche Beziehungen, indem man ein Array verwendet und mithilfe eines Tags GORM die Art der Beziehung mitteilt. In diesem Fall gibt dasforeignKey-Tag an, dass die Planeten über ein Feld namens PlanetarySystemID mit dem Planetensystem verbunden sind. Schau dir die Felder in den Dateien Planet.go und Asteroid.go an. Wenn du mehr erfahren möchtest, kannst du hier mehr über has many (hat viele) nachlesen.

Es gibt auch andere Beziehungstypen in der Datenbank. Du kannst die Definitionen dieser Beziehungen prüfen, und das ER-Diagramm hilft dir, sie besser zu verstehen

So sind die Beziehungen zwischen den Entitäten klar definiert und Redundanzen werden minimiert.

In manchen Fällen müssen wir jedoch die Lesevorgänge optimieren und normalisierte Tabellen wie diese können ineffizient werden. In solchen Situationen kann es sinnvoll sein die Tabellen zu denormalisieren, um die Lesegeschwindigkeit zu verbessern.

Denormalisierung

Der Prozess der Denormalisierung kann dabei helfen, leseintensive Operationen zu optimieren. In manchen Fällen kann es sinnvoll sein Tabellen zu denormalisieren, da Leseabfragen oft viele Joins erfordern.

Angenommen wir müssen schnell auf alle Himmelskörper (wie Planeten, Monde und Asteroiden) in einem bestimmten Planetensystem zugreifen. Statt viele Joins auszuführen, um die Daten aus verschiedenen Tabellen abzurufen, könnten wir durch Denormalisierung die Daten so zusammenfassen, dass der Zugriff deutlich schneller erfolgt.

Himmelskörper-Modell (Denormalisierung)

type CelestialBody struct { ID uint Name string Type string // 'Planet', 'Moon', or 'Asteroid' ParentID *uint // For moons, this is the planet's ID PlanetarySystemID uint }

Auf diese Weise können wir die Informationen in einer gruppierten Form vorliegen haben.

Durch die Verwendung des CelestialBody-Modells können wir Planeten, Monde und Asteroiden gruppieren und die Leseabfrage wird einfacher.

Abfragebeispiel

Eine normalisierte Abfrage, um alle Himmelskörper in einem System abzurufen sieht folgendermaßen aus:

r.db.Preload("Planets.Moons").Preload("Asteroids").Where("planetary_system_id = ?", id).Find(&planetarySystem)

In einer denormalisierten Datenstruktur könnte die Abfrage stattdessen so aussehen:

r.db.Where("planetary_system_id = ?", systemID).Find(&celestialBodies)

Der Unterschied zwischen den beiden Ansätzen ist deutlich: Die denormalisierte Abfrage ist einfacher und schneller, da sie keine Joins erfordert. Dennoch sollte man diesen Ansatz mit Bedacht einsetzen. Durch die Denormalisierung können wir zwar die Leistung bei Lesevorgängen verbessern doch wir laufen auch Gefahr unnötige Datenredundanzen zu erzeugen. Es ist wichtig genau zu überlegen, welche Daten denormalisiert werden, um Duplikate zu vermeiden.

Es gibt viele weitere interessante Themen im Bereich "Effizientes Datenbankmodell", die jedoch den Rahmen dieses Beitrags sprengen würden. Daher beschränken wir uns auf die wichtigsten Punkte.

Als Nächstes sehen wir uns ein häufiges Problem an, das die Leistung von Datenbanken bei großen Datenmengen erheblich beeinträchtigen kann: das N+1-Abfrageproblem.

Lösung des N+1-Abfrageproblems

Eine der am häufigsten verwendeten Techniken, um das N+1-Abfrageproblem in GORM zu lösen, ist Eager Loading. Diese Methode ermöglicht es, Daten aus mehreren Modellen gleichzeitig abzurufen anstatt für jedes Modell eine separate Abfrage zu starten. Schauen wir uns folgendes Beispiel an:

r.db.Preload("Planets.Moons").Where("planetary_system_id = ?", id).Find(&planetarySystem)

In dieser Abfrage verwenden wir Preload aus der GORM-Bibliothek, um Eager Loading zu durchzuführen.

Durch die Verwendung von Eager Loading sehen wir für diesen Code zwei Abfragen:

SELECT * FROM "moons" WHERE "moons"."planet_id" IN (201,202,203,204,205) AND "moons"."deleted_at" IS NULL SELECT * FROM "planets" WHERE "planets"."planetary_system_id" = 5 AND "planets"."deleted_at" IS NULL

Wenn wir Lazy Loading verwenden, das im Grunde darin besteht Daten bei Bedarf abzufragen, haben wir folgende Abfragen:

SELECT * FROM "planets" WHERE "planets"."planetary_system_id" = 5 AND "planets"."deleted_at" IS NULL SELECT * FROM "moons" WHERE "moons"."planet_id" = 201 AND "moons"."deleted_at" IS NULL SELECT * FROM "moons" WHERE "moons"."planet_id" = 202 AND "moons"."deleted_at" IS NULL SELECT * FROM "moons" WHERE "moons"."planet_id" = 203 AND "moons"."deleted_at" IS NULL SELECT * FROM "moons" WHERE "moons"."planet_id" = 204 AND "moons"."deleted_at" IS NULL SELECT * FROM "moons" WHERE "moons"."planet_id" = 205 AND "moons"."deleted_at" IS NULL

Die Anzahl der Abfragen steigt, wenn die Anzahl der Planeten zunimmt.

In manchen Fällen ist es nützlich nur Daten vorzuladen, die bestimmten Bedingungen entsprechen. Das kann erreicht werden, indem man Preload mit Bedingungen verwendet wie zum Beispiel:

db.Preload("Planets.Moons", "orbit_period > ?", "100").Where("planetary_system_id = ?", id).Find(&planetarySystem)

Dies gibt nur die Monde zurück, die die Bedingung orbit_period > 100 erfüllen.

Blog optimizing db -  eager loading moons image

Nachdem wir nun verstanden haben, wie man Eager Loading effektiv einsetzt, schauen wir uns als Nächstes an, wie man effizient eine große Anzahl von Einträgen in einer einzigen Abfrage einfügen oder aktualisieren kann.

Batch Inserts/Updates

Wenn wir eine große Anzahl von Einträgen in eine Tabelle einfügen wollen, ist es besser, statt für jeden Eintrag eine Abfrage auszuführen, Batch Insert zu verwenden. Batch Insert ist eine bekannte Technik, die in der Datenbankverwaltung weit verbreitet ist. In GORM kann dies ganz einfach erfolgen, indem man anstelle des Durchlaufens einer Schleife und dem Einfügen eines Eintrags nach dem anderen einfach ein Slice an die Create-Methode übergibt. Schauen wir uns ein praktisches Beispiel an:

Angenommen, wir möchten viele Missionen zur missions-Tabelle hinzufügen, dann könnten wir das so machen:

for i := 0; i < n; i++ { mission := factory.createMission() factory.db.Create(mission) }

Auf diese Weise habe ich n Abfragen!!

queries image

Es ist viel besser, so etwas zu machen:

baseModel := models.Mission{} var instances []models.Mission for i := 0; i < n; i++ { mission := factory.createMission() instances = append(instances, *mission) } if err := factory.db.Create(&instances).Error; err != nil { return nil, err }

Dieser Code erstellt nur eine Abfrage, um mehrere Missionen in die Tabelle einzufügen:

create one query image

Super, wir können jetzt effizient große Datenmengen einfügen, bearbeiten und lesen. Aber was ist mit der Datenkonsistenz? Dieses Thema ist äußerst wichtig. Lass uns untersuchen, welche Möglichkeiten GORM in diesem Bereich bietet.

Datenkonsistenz

Angenommen, wir möchten die Umlaufbahnen aller Planeten und ihrer Monde in einem bestimmten Planetary System aktualisieren, zum Beispiel aufgrund der Anwesenheit eines schwarzen Lochs. Dazu können wir eine Funktion erstellen, die versucht, alle Umlaufbahnen wie folgt zu aktualisieren:

func (r *planetarysystemRepository) UpdateAllTheOrbits(id uint, dto dtos.UpdateOrbitsDTO) error { var planetarySystem models.PlanetarySystem if err := r.db.Preload("Planets.Moons").First(&planetarySystem, id).Error; err != nil { return err } var planets []models.Planet var moons []models.Moon for _, planet := range planetarySystem.Planets { planet.OrbitPeriod += dto.OrbitPeriodOffset planets = append(planets, planet) for _, moon := range planet.Moons { moon.OrbitPeriod += dto.OrbitPeriodOffset moons = append(moons, moon) } } if err := r.db.Save(&planets).Error; err != nil { return err } if err := r.db.Save(&moons).Error; err != nil { return err } return nil }

Es funktioniert einwandfrei, wenn wir Preload verwenden, aber was passiert, wenn die Monde nicht aktualisiert werden können? Angenommen, es gibt eine Einschränkung in der Tabelle, die verhindert, dass der Wert vonorbitPeriod null oder negativ ist und wir versuchen, die Umlaufbahn auf einen ungültigen Wert zu aktualisieren. In diesem Fall würde das Update für die Monde fehlschlagen, während die Planeten trotzdem aktualisiert werden. Das führt zu inkonsistenten Daten.

Um solche Probleme zu lösen, müssen wir Transaktionen verwenden. Transaktionen ermöglichen es uns, eine Reihe von Operationen in einer einzigen Transaktion auszuführen. Eine Transaktion stellt sicher, dass alle darin enthaltenen Operationen korrekt ausgeführt werden. Wenn eine der Operationen fehlschlägt, wird die gesamte Transaktion abgebrochen, und die Tabellen kehren in ihren Zustand vor Beginn der Transaktion zurück.

Um Transaktionen in GORM zu implementieren, können wir dies manuell tun wie im folgenden Beispiel:

func (r *planetarysystemRepository) UpdateAllTheOrbits(id uint, dto dtos.UpdateOrbitsDTO) error { var planetarySystem models.PlanetarySystem if err := r.db.Preload("Planets.Moons").First(&planetarySystem, id).Error; err != nil { return err } tx := r.db.Begin() defer func() { if r := recover(); r != nil { tx.Rollback() } }() var planets []models.Planet var moons []models.Moon for _, planet := range planetarySystem.Planets { planet.OrbitPeriod += dto.OrbitPeriodOffset planets = append(planets, planet) for _, moon := range planet.Moons { moon.OrbitPeriod += dto.OrbitPeriodOffset moons = append(moons, moon) } } if err := tx.Save(&planets).Error; err != nil { tx.Rollback() return err } if err := tx.Save(&moons).Error; err != nil { tx.Rollback() return err } return tx.Commit().Error }

Eine neue Transaktion wird mit mit db.Begin()gestartet und im Falle eines Fehlers verwenden wir dieRollback()-Funktion, um die Transaktion zurückzusetzen.

Da wir jetzt wissen, wie wir die Datenkonsistenz garantieren tauchen wir in ein sehr mächtiges Konzept ein, das die Geschwindigkeit unserer Operationen erhöhen kann: die Goroutines.

Goroutines

In manchen Fällen müssen wir eine Operation an mehreren Einträgen ausführen, sei es beim Abrufen, Aktualisieren, Erstellen oder Löschen. Wenn die Natur der Daten es zulässt, können diese Operationen unabhängig voneinander ausgeführt werden. Das bedeutet, dass Datenbankoperationen parallel ablaufen können, ohne auf den Abschluss vorheriger Operationen zu warten. Dieser Vorgang wird als asynchrone Operation bezeichnet. In Go werden asynchrone Operationen mithilfe von Goroutines realisiert.

Betrachten wir ein Beispiel, um zu sehen, wie man Goroutines implementiert:

Angenommen, wir möchten die Gesamtmasse und die Gesamtmenge an Mineralien eines Planetensystems berechnen und die dafür benötigten Funktionen sind sehr komplex und zeitaufwendig:

func calculatePlanetMass(planet models.Planet) float64 { // Very long calculation return planet.Mass } func calculateAsteroidMinerals(asteroid models.Asteroid) float64 { // Very long calculation return asteroid.MineralsQuantity }

Die normale Implementierung sieht so aus:

func (r *planetarysystemRepository) GetPlanetarySystemMassAndMinerals(id uint) (dtos.MassAndMineralsDTO, error) { var planetarySystem models.PlanetarySystem if err := r.db.Preload("Planets").Preload("Asteroids").First(&planetarySystem, id).Error; err != nil { return dtos.MassAndMineralsDTO{}, err } var mass float64 for _, planet := range planetarySystem.Planets { mass += calculatePlanetMass(planet) } var minerals float64 for _, asteroid := range planetarySystem.Asteroids { minerals += calculateAsteroidMinerals(asteroid) } return dtos.MassAndMineralsDTO{Mass: mass, Minerals: minerals}, nil }

Das ist das Ergebnis, wenn wir diesen Ansatz verwenden:

postman-result image

Es dauert 1007 Millisekunden, um die Antwort zu erhalten.

Auf der anderen Seite sieht derselbe Code, aber unter Verwendung von Goroutines, so aus:

func (r *planetarysystemRepository) GetPlanetarySystemMassAndMineralsWithGoroutines(id uint) (dtos.MassAndMineralsDTO, error) { var planetarySystem models.PlanetarySystem if err := r.db.Preload("Planets").Preload("Asteroids").First(&planetarySystem, id).Error; err != nil { return dtos.MassAndMineralsDTO{}, err } massChan := make(chan float64) mineralsChan := make(chan float64) go func() { var mass float64 for _, planet := range planetarySystem.Planets { mass += calculatePlanetMass(planet) } massChan <- mass }() go func() { var minerals float64 for _, asteroid := range planetarySystem.Asteroids { minerals += calculateAsteroidMinerals(asteroid) } mineralsChan <- minerals }() mass := <-massChan minerals := <-mineralsChan return dtos.MassAndMineralsDTO{Mass: mass, Minerals: minerals}, nil }

Jetzt erhöht sich die Antwortgeschwindigkeit erheblich. In diesem Fall erhalten wir beispielsweise eine Antwort in der Hälfte der Zeit im Vergleich zur vorherigen Methode. Allerdings hängt dies stark vom Datenvolumen und den Funktionen ab, die parallel ausgeführt werden.

postman-response-2 image

Es ist wirklich spannend, solche Funktionen nutzen zu können. Allerdings müssen wir vorsichtig sein, wenn wir Goroutines verwenden, denn sie können ein zweischneidiges Schwert sein. Denke daran, dass wir in Microservices arbeiten, wo die Ressourcen begrenzt sind. Das gleichzeitige Ausführen von Tausenden von Goroutines kann zwar die Antwortzeit verkürzen, ist aber nicht immer der beste Ansatz. Zudem hat die Verbindung zur Datenbank ihre Grenzen. Suche stets nach einem Gleichgewicht und behalte die Schlüsselprinzipien im Hinterkopf, die wir zuvor besprochen haben. Wenn mehrere Goroutines auf gemeinsame Ressourcen zugreifen oder diese ändern (z.B. Modelle oder geteilte Zustände im Speicher), können Race Conditions und inkonsistente Daten auftreten.

Fazit

Herzlichen Glückwunsch! Du bist nun bereit die Geschwindigkeit deiner Microservices mit den großartigen Funktionen von GORM zu steigern. Wir haben wichtige Optimierungstechniken durchlaufen, praktische Beispiele geteilt und einen Code bereitgestellt, auf den du jederzeit zurückgreifen kannst.

Wir hoffen, dass dir dieser Beitrag geholfen hat. Erkunde weiter, lerne und hab Spaß dabei!

Warum Tuix wählen?

Bei Tuix haben wir Go-Experten, die dir helfen können die Geschwindigkeit deiner auf Go basierenden Microservices zu verbessern. Kontaktiere uns, wenn du daran interessiert bist, dein nächstes Projekt zu realisieren: Kontaktiere uns.

 

Viel Spaß beim Programmieren!