Arbeiten mit Datenbanken mit den Augen eines Entwicklers



Wenn Sie neue Funktionen mithilfe einer Datenbank entwickeln, umfasst der Entwicklungszyklus normalerweise (ohne darauf beschränkt zu sein) die folgenden Phasen:



Schreiben von SQL-Migrationen → Schreiben von Code → Testen → Freigeben → Überwachen.



In diesem Artikel möchte ich einige praktische Ratschläge geben, wie Sie die Zeit dieses Zyklus in jeder Phase verkürzen können, ohne die Qualität zu verringern, sondern sogar zu erhöhen. 



Da wir im Unternehmen mit PostgreSQL arbeiten und den Servercode in Java schreiben, basieren die Beispiele auf diesem Stapel, obwohl die meisten Ideen nicht von der verwendeten Datenbank und Programmiersprache abhängen.



SQL-Migration



Die erste Entwicklungsstufe nach dem Entwurf ist das Schreiben der SQL-Migration. Der wichtigste Rat: Nehmen Sie keine manuellen Änderungen am Datenschema vor, sondern führen Sie diese immer über Skripte durch und speichern Sie sie an einem Ort. 



In unserem Unternehmen schreiben Entwickler SQL-Migrationen selbst, sodass alle Migrationen in einem Repository mit dem Hauptcode gespeichert werden. In einigen Unternehmen sind Datenbankadministratoren an der Änderung des Schemas beteiligt. In diesem Fall befindet sich die Migrationsregistrierung irgendwo bei ihnen. Auf die eine oder andere Weise bringt dieser Ansatz die folgenden Vorteile:



  • Sie können jederzeit problemlos eine neue Basis von Grund auf neu erstellen oder eine vorhandene auf die aktuelle Version aktualisieren. Auf diese Weise können Sie schnell neue Testumgebungen und lokale Entwicklungsumgebungen bereitstellen.
  • Alle Basen haben das gleiche Layout - keine Überraschungen im Service.
  • Es gibt eine Historie aller Änderungen (Versionierung).


Es gibt viele vorgefertigte Tools zur Automatisierung dieses Prozesses, sowohl kommerziell als auch kostenlos: Flyway , Liquibase , Sqitch usw. In diesem Artikel werde ich nicht das beste Tool vergleichen und auswählen - dies ist ein separates großes Thema, und Sie können viele Artikel dazu finden ... 



Wir verwenden Flyway, daher hier ein paar Informationen dazu:



  • Es gibt zwei Arten von Migrationen: SQL-basierte und Java-basierte
  • SQL-Migrationen sind unveränderlich (unveränderlich). Nach der ersten Ausführung kann die SQL-Migration nicht mehr geändert werden. Flyway berechnet eine Prüfsumme für den Inhalt der Migrationsdatei und überprüft diese bei jedem Lauf. Zusätzliche manuelle Manipulationen sind erforderlich, um Java-Migrationen unveränderlich zu machen .
  • flyway_schema_history ( schema_version). , , , .


Gemäß unseren internen Vereinbarungen werden alle Änderungen des Datenschemas nur durch SQL-Migrationen vorgenommen. Ihre Unveränderlichkeit stellt sicher, dass wir immer ein tatsächliches Schema erhalten können, das mit allen Umgebungen völlig identisch ist. 



Java-Migrationen werden nur für DML verwendet , wenn es nicht möglich ist, in reinem SQL zu schreiben. Ein typisches Beispiel für eine solche Situation sind für uns Migrationen, um Daten aus einer anderen Datenbank an Postgres zu übertragen (wir wechseln von Redis zu Postgres, aber das ist eine ganz andere Geschichte). Ein weiteres Beispiel ist die Aktualisierung der Daten einer großen Tabelle, die in mehreren Transaktionen ausgeführt wird, um die Tabellensperrzeit zu minimieren. Es ist erwähnenswert, dass dies ab der 11. Version von Postgres mithilfe von SQL-Prozeduren auf plpgsql möglich ist.



Wenn der Java-Code veraltet ist, kann die Migration entfernt werden, um kein Legacy zu erzeugen (die Java-Migrationsklasse selbst bleibt erhalten, ist jedoch leer). In unserem Land kann dies frühestens einen Monat nach der Migration zur Produktion geschehen. Wir glauben, dass dies genug Zeit ist, um alle Testumgebungen und lokalen Entwicklungsumgebungen zu aktualisieren. Da Java-Migrationen nur für DML verwendet werden, wirkt sich ihre Entfernung in keiner Weise auf die Erstellung neuer Datenbanken von Grund auf aus.



Eine wichtige Nuance für diejenigen, die pg_bouncer verwenden



Flyway wendet während der Migration eine Sperre an, um die gleichzeitige Ausführung mehrerer Migrationen zu verhindern. Vereinfacht funktioniert es so:



  • Die Sperrenerfassung erfolgt 
  • Durchführen von Migrationen in separaten Transaktionen
  • Entsperren. 


Für Postgres, verwendet es Beratungssperren im Sitzungsmodus, was bedeutet , dass es richtig funktioniert, ist es notwendig, dass der Anwendungsserver auf derselben Verbindung während der Lock - Abscheidung und Freigabe ausgeführt werden. Wenn Sie pg_bouncer im Transaktionsmodus (der am häufigsten verwendet wird) oder im Einzelanforderungsmodus verwenden, wird möglicherweise für jede Transaktion eine neue Verbindung zurückgegeben, und Flyway kann keine eingerichtete Sperre aufheben. 



Um dieses Problem zu lösen, verwenden wir im Sitzungsmodus einen separaten kleinen Verbindungspool auf pg_bouncer, der nur für Migrationen vorgesehen ist. Von der Seite der Anwendung gibt es auch einen separaten Pool, der 1 Verbindung enthält und nach der Migration durch Timeout geschlossen wird, um keine Ressourcen zu verschwenden.



Codierung



Die Migration wurde erstellt, jetzt schreiben wir den Code.



Es gibt drei Ansätze für die Arbeit mit der Datenbank von der Anwendungsseite aus:



  • Verwendung von ORM (wenn wir über Java sprechen, ist der Ruhezustand de facto der Standard)
  • Verwenden von einfachem SQL + JDBCTemplate usw.
  • Verwenden von DSL-Bibliotheken.


Mit ORM können Sie die Anforderungen an SQL-Kenntnisse reduzieren - eine Menge wird automatisch generiert: 

  • Das Datenschema kann aus der im Code verfügbaren XML-Beschreibung oder Java-Entität erstellt werden
  • Objektbeziehungen werden mithilfe einer deklarativen Beschreibung definiert - ORM erstellt Verknüpfungen für Sie
  • Bei Verwendung von Spring Data JPA können noch schwierigere Abfragen automatisch durch die Signatur der Repository-Methode generiert werden .


Ein weiterer "Bonus" ist das sofortige Zwischenspeichern von Daten (im Ruhezustand sind dies 3 Ebenen von Caches).



Es ist jedoch wichtig zu beachten, dass ORM wie jedes andere leistungsstarke Tool bestimmte Qualifikationen erfordert, wenn es verwendet wird. Ohne die richtige Konfiguration wird der Code höchstwahrscheinlich funktionieren, aber bei weitem nicht optimal.



Das Gegenteil ist, die SQL von Hand zu schreiben. Auf diese Weise haben Sie die vollständige Kontrolle über Ihre Anforderungen - genau das, was Sie geschrieben haben, wird ausgeführt, keine Überraschungen. Dies erhöht natürlich den manuellen Arbeitsaufwand und die Anforderungen an die Qualifikation der Entwickler.



DSL-Bibliotheken



Ungefähr in der Mitte zwischen diesen Ansätzen befindet sich ein weiterer, der darin besteht, DSL-Bibliotheken ( jOOQ , Querydsl usw.) zu verwenden. Sie sind normalerweise viel leichter als ORMs, aber bequemer als vollständig manuelle Datenbankarbeiten. Die Verwendung von DSLs ist weniger verbreitet, daher wird in diesem Artikel ein kurzer Überblick über diesen Ansatz gegeben. 



Wir werden über eine der Bibliotheken sprechen - jOOQ . Was bietet sie an:



  • Datenbankinspektion und automatische Generierung von Klassen
  • Fließende API zum Schreiben von Anfragen.


jOOQ ist kein ORM - es gibt keine automatische Generierung von Abfragen oder Caching, aber gleichzeitig sind einige der Probleme eines vollständig manuellen Ansatzes geschlossen:

  • Klassen für Tabellen, Ansichten, Funktionen usw. Datenbankobjekte werden automatisch generiert 
  • Anforderungen werden in Java geschrieben. Dies garantiert, dass der Typ sicher ist - eine syntaktisch falsche Anforderung oder eine Anforderung mit einem Parameter des falschen Typs wird nicht kompiliert - Ihre IDE fordert Sie sofort zu einem Fehler auf, und Sie müssen keine Zeit damit verbringen, die Anwendung zu starten, um die Richtigkeit der Anforderung zu überprüfen. Dies beschleunigt den Entwicklungsprozess und verringert die Fehlerwahrscheinlichkeit.


In dem Code, sehen die Anfragen etwas wie folgt aus :



BookRecord book = dslContext.selectFrom(BOOK)
                        .where(BOOK.LANGUAGE.eq("DE"))
                        .orderBy(BOOK.TITLE)
                        .fetchAny();


Sie können einfaches SQL verwenden, wenn Sie möchten:



Result<Record> records = dslContext.fetch("SELECT * FROM BOOK WHERE LANGUAGE = ? ORDER BY TITLE LIMIT 1", "DE");


In diesem Fall liegt die Richtigkeit der Abfrage und die Analyse der Ergebnisse natürlich vollständig auf Ihren Schultern.



jOOQ Record und POJO



BookRecord im obigen Beispiel ist ein Wrapper über einer Zeile in der Buchtabelle und implementiert das aktive Datensatzmuster . Da diese Klasse (neben ihrer spezifischen Implementierung) Teil der Datenzugriffsschicht ist, möchten Sie sie möglicherweise nicht auf andere Schichten der Anwendung übertragen, sondern ein eigenes Pojo-Objekt verwenden. Zur bequemen Konvertierung von Datensätzen bietet <–> pojo jooq verschiedene Mechanismen: automatisch und manuell . Die Dokumentation für die obigen Links enthält eine Vielzahl von Beispielen für die Verwendung zum Lesen, jedoch keine Beispiele für das Einfügen neuer Daten und das Aktualisieren. Füllen wir diese Lücke: 



private static final RecordUnmapper<Book, BookRecord> unmapper = 
    book -> new BookRecord(book.getTitle(), ...); // - 

public void create(Book book) {
    context.insertInto(BOOK)
            .set(unmapper.unmap(book))
            .execute();
}


Wie Sie sehen können, ist alles ganz einfach.



Mit diesem Ansatz können Sie Implementierungsdetails innerhalb der Datenzugriffsschichtklasse ausblenden und "Leckagen" in andere Schichten der Anwendung vermeiden. 



Außerdem kann jooq DAO- Klassen mit einer Reihe grundlegender Methoden generieren, um das Arbeiten mit Tabellendaten zu vereinfachen und die Menge an manuellem Code zu reduzieren (dies ist Spring Data JPA sehr ähnlich):



public interface DAO<R extends TableRecord<R>, P, T> {
    void insert(P object) throws DataAccessException;    
    void update(P object) throws DataAccessException;
    void delete(P... objects) throws DataAccessException;
    void deleteById(T... ids) throws DataAccessException;
    boolean exists(P object) throws DataAccessException;
    ...
}


In der Firma verwenden wir keine automatische Generierung von DAO-Klassen - wir generieren nur Wrapper über Datenbankobjekte und schreiben selbst Abfragen. Die Generierung von Wrappern erfolgt jedes Mal, wenn ein separates Maven-Modul neu erstellt wird, in dem Migrationen gespeichert werden. Wenig später wird es Details darüber geben, wie dies implementiert wird.



Testen



Das Schreiben von Tests ist ein wichtiger Teil des Entwicklungsprozesses. Gute Tests garantieren die Qualität Ihres Codes und sparen Zeit bei der Pflege. Gleichzeitig kann man mit Recht sagen, dass das Gegenteil auch der Fall ist - schlechte Tests können die Illusion von Qualitätscode erzeugen, Fehler verbergen und den Entwicklungsprozess verlangsamen. Es reicht also nicht aus, nur zu entscheiden, dass Sie Tests schreiben, Sie müssen es richtig machen . Gleichzeitig ist das Konzept der Richtigkeit von Tests sehr vage und jeder hat sein eigenes. 



Gleiches gilt für die Frage der Testklassifizierung. In diesem Artikel wird die Verwendung der folgenden Aufteilungsoption vorgeschlagen:



  • Unit Testing (Unit Testing) 
  • Integrationstests
  • End-to-End-Tests (Ende-zu-Ende).


Beim Unit-Test wird die Funktionalität einzelner Module isoliert voneinander überprüft. Die Größe des Moduls ist wieder eine undefinierte Sache, für einige ist es eine separate Methode, für andere ist es eine Klasse. Isolation bedeutet, dass alle anderen Module Mocks oder Stubs sind (auf Russisch sind dies Imitationen oder Stubs, aber irgendwie klingen sie nicht sehr gut). Folgen Sie diesem Link , um Martin Fowlers Artikel über den Unterschied zwischen den beiden zu lesen. Unit-Tests sind klein, schnell, können jedoch nur die Richtigkeit der Logik einer einzelnen Unit garantieren.



IntegrationstestsIm Gegensatz zu Unit-Tests überprüfen sie die Interaktion mehrerer Module miteinander. Die Arbeit mit einer Datenbank ist ein gutes Beispiel, wenn Integrationstests sinnvoll sind, da es sehr schwierig ist, eine Datenbank mit hoher Qualität unter Berücksichtigung aller Nuancen zu "sperren". Integrationstests sind in den meisten Fällen ein guter Kompromiss zwischen Ausführungsgeschwindigkeit und Qualitätssicherung beim Testen einer Datenbank im Vergleich zu anderen Testarten. Daher werden wir in diesem Artikel ausführlicher auf diese Art von Tests eingehen.



End-to-End-Tests sind am umfangreichsten. Um dies durchzuführen, ist es notwendig, die gesamte Umgebung zu erhöhen. Es garantiert ein Höchstmaß an Vertrauen in die Produktqualität, ist jedoch das langsamste und teuerste.



Integrationstests



Wenn es um Integrationstests von Code geht, der mit einer Datenbank funktioniert, stellen sich die meisten Entwickler Fragen: Wie starte ich die Datenbank, wie initialisiere ich ihren Status mit Anfangsdaten und wie mache ich das so schnell wie möglich?



Vor einiger Zeit war die Verwendung von H2 eine ziemlich übliche Praxis bei Integrationstests . Es handelt sich um eine in Java geschriebene In-Memory-Datenbank, die Kompatibilitätsmodi mit den meisten gängigen Datenbanken aufweist. Das Fehlen der Notwendigkeit, eine Datenbank zu installieren, und die Vielseitigkeit von h2 machten es zu einem sehr praktischen Ersatz für echte Datenbanken, insbesondere wenn die Anwendung nicht von einer bestimmten Datenbank abhängt und nur das verwendet, was im SQL-Standard enthalten ist (was nicht immer der Fall ist). 



Probleme beginnen jedoch in dem Moment, in dem Sie einige knifflige Datenbankfunktionen (oder eine völlig neue aus einer neuen Version) verwenden, deren Unterstützung in h2 nicht implementiert ist. Da es sich um eine „Simulation“ eines bestimmten DBMS handelt, kann es im Allgemeinen immer zu Verhaltensunterschieden kommen.



Eine andere Möglichkeit ist die Verwendung eingebetteter Postgres . Dies ist echtes Postgres, das als Archiv geliefert wird und keine Installation erfordert. Sie können damit wie mit einer normalen Postgres-Version arbeiten. 



Es gibt mehrere Implementierungen, die beliebtesten von Yandex und openTable... Wir in der Firma haben die Version von Yandex verwendet. Von den Minuspunkten - es ist beim Start ziemlich langsam (jedes Mal, wenn das Archiv entpackt und die Datenbank gestartet wird - dauert es 2-5 Sekunden, abhängig von der Leistung des Computers) gibt es auch ein Problem mit der Verzögerung hinter der offiziellen Release-Version. Wir hatten auch das Problem, dass nach dem Versuch, den Code zu stoppen, ein Fehler auftrat und der Postgres-Prozess im Betriebssystem hängen blieb - Sie mussten ihn manuell beenden. 



Testcontainer



Die dritte Option ist die Verwendung von Docker. Für Java gibt es eine Testcontainer- Bibliothek , die eine API für die Arbeit mit Docker-Containern aus Code bereitstellt. Jede Abhängigkeit in Ihrer Anwendung So , das ein Docker Bild hat kann ersetzt mit testcontainers in Tests. Für viele gängige Technologien gibt es außerdem separate vorgefertigte Klassen, die je nach verwendetem Bild eine bequemere API bieten:



  • Datenbanken (Postgres, Oracle, Cassandra, MongoDB usw.), 
  • Nginx
  • Kafka usw.


Übrigens, als das Tescontainer-Projekt ziemlich populär wurde, gaben die Yandex-Entwickler offiziell bekannt, dass sie die Entwicklung des eingebetteten Postgres-Projekts stoppen würden, und rieten, zu Testcontainern zu wechseln.



Was sind die Profis:



  • Testcontainer sind schnell (das Starten von leeren Postgres dauert weniger als eine Sekunde)
  • Die Postgres-Community veröffentlicht offizielle Docker-Bilder für jede neue Version
  • testcontainers hat einen speziellen Prozess , der baumelnde Container nach dem Herunterfahren von jvm abtötet, es sei denn, Sie haben dies programmgesteuert durchgeführt
  • Mit Testcontainern können Sie einen einheitlichen Ansatz verwenden , um externe Abhängigkeiten Ihrer Anwendung zu testen, was die Dinge offensichtlich einfacher macht.


Beispiel Test mit Postgres:



@Test
public void testSimple() throws SQLException {
    try (PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>()) {
        postgres.start();
        ResultSet resultSet = performQuery(postgres, "SELECT 1");
        int resultSetInt = resultSet.getInt(1);
        assertEquals("A basic SELECT query succeeds", 1, resultSetInt);
    }
}


Wenn es keine separate Klasse für das Bild in testcontainers, dann ist die Schaffung des Behälters sieht aus wie diese :



public static GenericContainer redis = new GenericContainer("redis:3.0.2")
            .withExposedPorts(6379);


Wenn Sie JUnit4, JUnit5 oder Spock verwenden, haben Testcontainer ein Extra. Unterstützung für diese Frameworks, die das Schreiben von Tests erleichtert.



Beschleunigung von Tests mit Testcontainern



Obwohl der Wechsel von eingebetteten Postgres zu Testcontainern unsere Tests durch die schnellere Ausführung von Postgres beschleunigte, wurden die Tests im Laufe der Zeit wieder langsamer. Dies ist auf die erhöhte Anzahl von SQL-Migrationen zurückzuführen, die Flyway beim Start ausführt. Wenn die Anzahl der Migrationen hundert überschritt, betrug die Ausführungszeit etwa 7 bis 8 Sekunden, was die Tests erheblich verlangsamte. Es hat ungefähr so ​​funktioniert:



  1. Vor der nächsten Testklasse wurde ein "sauberer" Container mit Postgres gestartet
  2. Flyway führte Migrationen durch
  3. Tests dieser Klasse wurden durchgeführt
  4. Der Behälter wurde angehalten und entfernt
  5. Wiederholen Sie ab Punkt 1 für die nächste Testklasse.


Offensichtlich dauerte der zweite Schritt im Laufe der Zeit immer länger.



Bei dem Versuch, dieses Problem zu lösen, haben wir festgestellt, dass es ausreicht, Migrationen vor allen Tests nur einmal durchzuführen, den Status des Containers zu speichern und diesen Container dann in allen Tests zu verwenden. Der Algorithmus hat sich also geändert:



  1. Vor allen Tests wird ein "sauberer" Container mit Postgres gestartet
  2. Flyway führt Migrationen durch
  3. Containerstatus bleibt bestehen
  4. Vor der nächsten Testklasse wird ein zuvor vorbereiteter Container gestartet
  5. Tests dieser Klasse werden ausgeführt
  6. Der Behälter stoppt und wird entfernt
  7. Wiederholen Sie ab Schritt 4 für die nächste Testklasse.


Jetzt hängt die Ausführungszeit eines einzelnen Tests nicht mehr von der Anzahl der Migrationen ab. Bei der aktuellen Anzahl der Migrationen (200+) spart das neue Schema bei jedem Testlauf mehrere Minuten.



Hier finden Sie einige technische Details zur Implementierung.



Docker verfügt über einen integrierten Mechanismus zum Erstellen eines neuen Images aus einem laufenden Container mit dem Befehl commit . Sie können die Bilder anpassen, indem Sie beispielsweise Einstellungen ändern. 



Eine wichtige Einschränkung ist, dass der Befehl die Daten der bereitgestellten Partitionen nicht speichert. Wenn Sie jedoch das offizielle Docker-Image von Postgres verwenden, befindet sich das PGDATA-Verzeichnis, in dem die Daten gespeichert sind, in einem separaten Abschnitt (damit die Daten nach dem Neustart des Containers nicht verloren gehen). Daher wird beim Ausführen des Commits der Status der Datenbank selbst nicht gespeichert. 



Die Lösung ist einfach: Verwenden Sie nicht den Abschnitt für PGDATA, sondern behalten Sie die Daten im Speicher, was für Tests ganz normal ist. Es gibt zwei Möglichkeiten , dies zu tun - nutzen Sie Ihre dockerfile (so etwas wie dieses) ohne einen Abschnitt zu erstellen oder die PGDATA-Variable beim Starten des offiziellen Containers zu überschreiben (der Abschnitt bleibt erhalten, wird aber nicht verwendet). Der zweite Weg sieht viel einfacher aus:



PostgreSQLContainer<?> container = ...
container.addEnv("PGDATA", "/var/lib/postgresql/data-no-mounted");
container.start();


Bevor sie sich, wird empfohlen , dass Sie Checkpoint Postgres zu spülen Änderungen aus dem gemeinsamen Puffer auf „Datenträger“ (entspricht der überschriebenen PGDATA Variable):



container.execInContainer("psql", "-c", "checkpoint");


Das Commit selbst sieht ungefähr so ​​aus:



CommitCmd cmd = container.getDockerClient().commitCmd(container.getContainerId())
                .withMessage("Container for integration tests. ...")
                .withRepository(imageName)
                .withTag(tag);
String imageId = cmd.exec();


Es ist erwähnenswert, dass dieser Ansatz mit vorbereiteten Bildern auf viele andere Bilder angewendet werden kann, was auch beim Ausführen von Integrationstests Zeit spart.



Noch ein paar Worte zur Optimierung der Bauzeit



Wie bereits erwähnt, werden beim Zusammenstellen eines separaten Maven-Moduls mit Migrationen unter anderem Java-Wrapper über den Datenbankobjekten generiert. Hierzu wird ein selbstgeschriebenes Maven-Plugin verwendet, das vor dem Kompilieren des Hauptcodes gestartet wird und 3 Aktionen ausführt:



  1. Führt einen "sauberen" Docker-Container mit Postgres aus
  2. Startet Flyway, das SQL-Migrationen für alle Datenbanken durchführt und dabei deren Gültigkeit überprüft
  3. Führt Jooq aus, das das Datenbankschema überprüft und Java-Klassen für Tabellen, Ansichten, Funktionen und andere Schemaobjekte generiert.


Wie Sie leicht sehen können, sind die ersten beiden Schritte identisch mit denen, die beim Ausführen der Tests ausgeführt werden. Um beim Starten des Containers und beim Ausführen von Migrationen vor den Tests Zeit zu sparen, haben wir das Speichern des Containerstatus in ein Plugin verschoben. Daher werden unmittelbar nach der Neuerstellung des Moduls vorgefertigte Images für Integrationstests aller im Code verwendeten Datenbanken im lokalen Repository der Docker-Images angezeigt.



Detaillierteres Codebeispiel
@ThreadSafe
public class PostgresContainerAdapter implements PostgresExecutable {
  private static final String ORIGINAL_IMAGE = "postgres:11.6-alpine";

  @GuardedBy("this")
  @Nullable
  private PostgreSQLContainer<?> container; // not null if it is running

  @Override
  public synchronized String start(int port, String db, String user, String password) 
  {
    Preconditions.checkState(container == null, "postgres is already running");

    PostgreSQLContainer<?> newContainer = new PostgreSQLContainer<>(ORIGINAL_IMAGE)
        .withDatabaseName(db)
        .withUsername(user)
        .withPassword(password);

    newContainer.addEnv("PGDATA", "/var/lib/postgresql/data-no-mounted");

    // workaround for using fixed port instead of random one chosen by docker
    List<String> portBindings = new ArrayList<>(newContainer.getPortBindings());
    portBindings.add(String.format("%d:%d", port, POSTGRESQL_PORT));
    newContainer.setPortBindings(portBindings);
    newContainer.start();

    container = newContainer;
    return container.getJdbcUrl();
  }

  @Override
  public synchronized void saveState(String name) {
    try {
      Preconditions.checkState(container != null, "postgres isn't started yet");

      // flush all changes
      doCheckpoint(container);

      commitContainer(container, name);
    } catch (Exception e) {
      stop();
      throw new RuntimeException("Saving postgres container state failed", e);
    }
  }

  @Override
  public synchronized void stop() {
    Preconditions.checkState(container != null, "postgres isn't started yet");

    container.stop();
    container = null;
  }

  private static void doCheckpoint(PostgreSQLContainer<?> container) {
    try {
      container.execInContainer("psql", "-c", "checkpoint");
    } catch (IOException | InterruptedException e) {
      throw new RuntimeException(e);
    }
  }

  private static void commitContainer(PostgreSQLContainer<?> container, String image)
  {
    String tag = "latest";
    container.getDockerClient().commitCmd(container.getContainerId())
        .withMessage("Container for integration tests. It uses non default location for PGDATA which is not mounted to a volume")
        .withRepository(image)
        .withTag(tag)
        .exec();
  }
  // ...
}


( «start»):

@Mojo(name = "start")
public class PostgresPluginStartMojo extends AbstractMojo {
  private static final Logger logger = LoggerFactory.getLogger(PostgresPluginStartMojo.class);

  @Nullable
  static PostgresExecutable postgres;

  @Parameter(defaultValue = "5432")
  private int port;
  @Parameter(defaultValue = "dbName")
  private String db;
  @Parameter(defaultValue = "userName")
  private String user;
  @Parameter(defaultValue = "password")
  private String password;

  @Override
  public void execute() throws MojoExecutionException {
    if (postgres != null) { 
      logger.warn("Postgres already started");
      return;
    }
    logger.info("Starting Postgres");
    if (!isDockerInstalled()) {
      throw new IllegalStateException("Docker is not installed");
    }
    String url = start();
    testConnection(url, user, password);
    logger.info("Postgres started at " + url);
  }

  private String start() {
    postgres = new PostgresContainerAdapter();
    return postgres.start(port, db, user, password);
  }

  private static void testConnection(String url, String user, String password) throws MojoExecutionException {
    try (Connection conn = DriverManager.getConnection(url, user, password)) {
      conn.createStatement().execute("SELECT 1");
    } catch (SQLException e) {
      throw new MojoExecutionException("Exception occurred while testing sql connection", e);
    }
  }

  private static boolean isDockerInstalled() {
    if (CommandLine.executableExists("docker")) {
      return true;
    }
    if (CommandLine.executableExists("docker.exe")) {
      return true;
    }
    if (CommandLine.executableExists("docker-machine")) {
      return true;
    }
    if (CommandLine.executableExists("docker-machine.exe")) {
      return true;
    }
    return false;
  }
}


save-state stop .



:



<build>
  <plugins>
    <plugin>
      <groupId>com.miro.maven</groupId>
      <artifactId>PostgresPlugin</artifactId>
      <executions>
        <!-- running a postgres container -->
        <execution>
          <id>start-postgres</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>start</goal>
          </goals>
          
          <configuration>
            <db>${db}</db>
            <user>${dbUser}</user>
            <password>${dbPassword}</password>
            <port>${dbPort}</port>
          </configuration>
        </execution>
        
        <!-- applying migrations and generation java-classes -->
        <execution>
          <id>flyway-and-jooq</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>execute-mojo</goal>
          </goals>
          
          <configuration>
            <plugins>
              <!-- applying migrations -->
              <plugin>
                <groupId>org.flywaydb</groupId>
                <artifactId>flyway-maven-plugin</artifactId>
                <version>${flyway.version}</version>
                <executions>
                  <execution>
                    <id>migration</id>
                    <goals>
                      <goal>migrate</goal>
                    </goals>
                    
                    <configuration>
                      <url>${dbUrl}</url>
                      <user>${dbUser}</user>
                      <password>${dbPassword}</password>
                      <locations>
                        <location>filesystem:src/main/resources/migrations</location>
                      </locations>
                    </configuration>
                  </execution>
                </executions>
              </plugin>

              <!-- generation java-classes -->
              <plugin>
                <groupId>org.jooq</groupId>
                <artifactId>jooq-codegen-maven</artifactId>
                <version>${jooq.version}</version>
                <executions>
                  <execution>
                    <id>jooq-generate-sources</id>
                    <goals>
                      <goal>generate</goal>
                    </goals>
                      
                    <configuration>
                      <jdbc>
                        <url>${dbUrl}</url>
                        <user>${dbUser}</user>
                        <password>${dbPassword}</password>
                      </jdbc>
                      
                      <generator>
                        <database>
                          <name>org.jooq.meta.postgres.PostgresDatabase</name>
                          <includes>.*</includes>
                          <excludes>
                            #exclude flyway tables
                            schema_version | flyway_schema_history
                            # other excludes
                          </excludes>
                          <includePrimaryKeys>true</includePrimaryKeys>
                          <includeUniqueKeys>true</includeUniqueKeys>
                          <includeForeignKeys>true</includeForeignKeys>
                          <includeExcludeColumns>true</includeExcludeColumns>
                        </database>
                        <generate>
                          <interfaces>false</interfaces>
                          <deprecated>false</deprecated>
                          <jpaAnnotations>false</jpaAnnotations>
                          <validationAnnotations>false</validationAnnotations>
                        </generate>
                        <target>
                          <packageName>com.miro.persistence</packageName>
                          <directory>src/main/java</directory>
                        </target>
                      </generator>
                    </configuration>
                  </execution>
                </executions>
              </plugin>
            </plugins>
          </configuration>
        </execution>

        <!-- creation an image for integration tests -->
        <execution>
          <id>save-state-postgres</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>save-state</goal>
          </goals>
          
          <configuration>
            <name>postgres-it</name>
          </configuration>
        </execution>

        <!-- stopping the container -->
        <execution>
          <id>stop-postgres</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>stop</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>




Veröffentlichung



Code geschrieben und getestet - es ist Zeit zu veröffentlichen. Im Allgemeinen hängt die Komplexität einer Version von folgenden Faktoren ab:



  • auf die Anzahl der Datenbanken (eine oder mehrere)
  • auf die Größe der Datenbank
  • auf die Anzahl der Anwendungsserver (einer oder mehrere)
  • nahtlose Freigabe oder nicht (ob Ausfallzeiten der Anwendung zulässig sind).


Die Punkte 1 und 3 stellen eine Abwärtskompatibilitätsanforderung an den Code, da es in den meisten Fällen nicht möglich ist, alle Datenbanken und alle Anwendungsserver gleichzeitig zu aktualisieren. Es wird immer einen Zeitpunkt geben, an dem die Datenbanken unterschiedliche Schemata haben und die Server unterschiedliche Versionen des Codes haben.



Die Größe der Datenbank wirkt sich auf die Migrationszeit aus. Je größer die Datenbank ist, desto wahrscheinlicher ist es, dass Sie eine lange Migration durchführen müssen.



Die Nahtlosigkeit ist teilweise ein resultierender Faktor. Wenn die Freigabe mit Herunterfahren (Ausfallzeit) durchgeführt wird, sind die ersten 3 Punkte nicht so wichtig und wirken sich nur auf die Zeit aus, in der die Anwendung nicht verfügbar ist.



Wenn wir über unseren Service sprechen, dann sind dies:



  • etwa 30 Datenbankcluster


  • Größe einer Basis 200 - 400 GB
  • ( 100),
  • .


Wir verwenden kanarische Versionen : Eine neue Version der Anwendung wird zuerst auf einer kleinen Anzahl von Servern angezeigt (wir nennen sie eine Vorabversion), und nach einer Weile, wenn in der Vorabversion keine Fehler gefunden werden, wird sie für andere Server freigegeben. Somit können Produktionsserver auf verschiedenen Versionen ausgeführt werden.



Beim Start überprüft jeder Anwendungsserver die Version der Datenbank mit den Versionen der Skripte, die im Quellcode enthalten sind (in Bezug auf den Flyway wird dies als Validierung bezeichnet ). Wenn sie unterschiedlich sind, wird der Server nicht gestartet. Dies stellt die Code- und Datenbankkompatibilität sicher . Eine Situation kann nicht auftreten, wenn der Code beispielsweise mit einer Tabelle arbeitet, die noch nicht erstellt wurde, da sich die Migration in einer anderen Version des Servers befindet.



Dies löst das Problem natürlich nicht, wenn beispielsweise in der neuen Version der Anwendung eine Migration stattfindet, die eine Spalte in der Tabelle löscht, die in der alten Version des Servers verwendet werden kann. Jetzt überprüfen wir solche Situationen nur in der Überprüfungsphase (dies ist obligatorisch), aber auf gütliche Weise ist es notwendig, zusätzliche einzuführen. Stufe mit einer solchen Überprüfung im CI / CD-Zyklus.  



Manchmal können Migrationen lange dauern (z. B. beim Aktualisieren von Daten aus einer großen Tabelle). Um die Veröffentlichung nicht gleichzeitig zu verlangsamen, verwenden wir die Technik der kombinierten Migrationen... Die Kombination besteht darin, die Migration manuell auf einem laufenden Server auszuführen (über das Administrationsfenster, ohne Flyway und dementsprechend ohne Aufzeichnung im Migrationsverlauf) und dann die "reguläre" Ausgabe derselben Migration in der nächsten Version des Servers. Diese Migrationen unterliegen den folgenden Anforderungen:



  • Erstens muss es so geschrieben werden, dass die Anwendung während einer langen Ausführung nicht blockiert wird (der Hauptpunkt hierbei ist nicht, langfristige Sperren auf DB-Ebene zu erwerben). Zu diesem Zweck haben wir interne Richtlinien für Entwickler zum Schreiben von Migrationen. In Zukunft kann ich sie auch auf Habré teilen.
  • Zweitens sollte die Migration bei einem "regulären" Start feststellen, dass sie bereits im manuellen Modus durchgeführt wurde, und in diesem Fall nichts tun - einfach einen neuen Datensatz in der Historie festschreiben. Bei SQL-Migrationen wird eine solche Überprüfung durchgeführt, indem eine SQL-Abfrage auf Änderungen ausgeführt wird. Ein anderer Ansatz für Java-Migrationen besteht darin, gespeicherte boolesche Flags zu verwenden, die nach einem manuellen Lauf gesetzt werden.




Dieser Ansatz löst zwei Probleme:

  • Die Veröffentlichung ist schnell (wenn auch mit manuellen Aktionen)
  • ( ) - .




Nach der Freigabe endet der Entwicklungszyklus nicht. Um zu verstehen, ob die neue Funktionalität funktioniert (und wie sie funktioniert), müssen Metriken „eingeschlossen“ werden. Sie können in zwei Gruppen unterteilt werden: Geschäft und System. 



Die erste Gruppe hängt stark vom Themenbereich ab: Für einen Mailserver ist es hilfreich, die Anzahl der gesendeten Briefe, für eine Nachrichtenressource - die Anzahl der eindeutigen Benutzer pro Tag usw. zu kennen.



Die Metriken der zweiten Gruppe sind für alle ungefähr gleich - sie bestimmen den technischen Status des Servers: CPU, Speicher, Netzwerk, Datenbank usw. 



Was genau überwacht werden muss und wie dies zu tun ist - dies ist ein Thema einer großen Anzahl separater Artikel, auf das hier nicht eingegangen wird. Ich möchte nur an die grundlegendsten Dinge (sogar an die des Kapitäns) erinnern:



Definieren Sie Metriken im Voraus



Es ist notwendig, eine Liste grundlegender Metriken zu definieren. Dies sollte im Voraus vor der Veröffentlichung und nicht nach dem ersten Vorfall erfolgen, wenn Sie nicht verstehen, was mit dem System geschieht.



Richten Sie automatische Warnungen ein



Dies beschleunigt Ihre Reaktionszeit und spart Zeit bei der manuellen Überwachung. Im Idealfall sollten Sie über Probleme Bescheid wissen, bevor Benutzer sie spüren und Ihnen schreiben.



Sammeln Sie Metriken von allen Knoten



Metriken sind wie Protokolle nie zu viele. Das Vorhandensein von Daten von jedem Knoten Ihres Systems (Anwendungsserver, Datenbank, Verbindungspooling, Balancer usw.) ermöglicht es Ihnen, ein vollständiges Bild des Status zu erhalten und das Problem bei Bedarf schnell zu lokalisieren. 



Ein einfaches Beispiel: Das Laden der Daten einer Webseite wurde langsamer. Es kann viele Gründe geben:



  • Der Webserver ist überlastet und es dauert lange, bis Anfragen beantwortet werden


  • Die Ausführung der SQL-Abfrage dauert länger
  • Im Verbindungspool hat sich eine Warteschlange angesammelt, und der Anwendungsserver kann lange Zeit keine Verbindung empfangen
  • Netzwerkprobleme
  • etwas anderes


Ohne Metriken ist es nicht einfach, die Ursache eines Problems zu finden.



Anstelle der Fertigstellung



Ich möchte einen sehr banalen Satz über die Tatsache sagen, dass es keine Silberkugel gibt und die Wahl des einen oder anderen Ansatzes von den Anforderungen einer bestimmten Aufgabe abhängt und was für andere gut funktioniert, für Sie möglicherweise nicht zutreffend ist. Aber je mehr unterschiedliche Ansätze Sie kennen, desto gründlicher und qualitativer können Sie diese Wahl treffen. Ich hoffe, dass Sie aus diesem Artikel etwas Neues für sich gelernt haben, das Ihnen in Zukunft helfen wird. Ich würde gerne kommentieren, welche Ansätze Sie verwenden, um den Prozess der Arbeit mit der Datenbank zu verbessern.



All Articles