Wie und warum wir einen hochbelastbaren, skalierbaren Service für 1C: Unternehmenssoftware mit Java, PostgreSQL und Hazelcast entwickelt haben

In diesem Artikel erfahren Sie, wie und warum wir entwickelt haben das Interaktionssystem – ein Mechanismus, der Informationen zwischen Kundenanwendungen und 1C:Enterprise-Servern überträgt – von der Aufgabenstellung bis zur Planung der Architektur und der Umsetzung der Details.

Das Interaktionssystem (im Folgenden – IS) ist ein verteiltes, fehlertolerantes Nachrichtenübertragungssystem mit garantierter Zustellung. IS wurde als hochbelasteter Dienst mit hoher Skalierbarkeit konzipiert und ist sowohl als Online-Dienst (angeboten von 1C) als auch als Serienprodukt erhältlich, das auf eigenen Serverressourcen bereitgestellt werden kann.

IS nutzt eine verteilte Speicherung Hazelcast und ein Suchsystem Elasticsearch. Außerdem wird es um Java gehen und darum, wie wir PostgreSQL horizontal skalieren.
Wie und warum wir einen hochbelastbaren, skalierbaren Service für 1C: Unternehmenssoftware mit Java, PostgreSQL und Hazelcast entwickelt haben

Problemstellung

Um zu verdeutlichen, warum wir das Interaktionssystem entwickelt haben, möchte ich etwas darüber erzählen, wie die Entwicklung von Geschäftsanwendungen bei 1C organisiert ist.

Zunächst ein wenig über uns für diejenigen, die noch nicht wissen, was wir tun :) Wir entwickeln die technologische Plattform "1C:Enterprise". Diese Plattform umfasst ein Werkzeug zur Entwicklung von Geschäftsanwendungen sowie eine Runtime-Umgebung, die es Geschäftsanwendungen ermöglicht, in plattformübergreifenden Umgebungen zu arbeiten.

Client-Server-Entwicklung

Geschäftsanwendungen, die auf "1C:Enterprise" basieren, arbeiten in einer dreistufigen Client-Server Architektur "DBMS – Anwendungsserver – Client". Der Anwendungs-Code, der in der eingebetteten 1C-Sprache, kann entweder auf dem Anwendungsserver oder auf dem Client ausgeführt werden. Alle Operationen mit Anwendungsobjekten (Verzeichnissen, Dokumenten usw.) sowie das Lesen und Schreiben der Datenbank erfolgen ausschließlich auf dem Server. Die Funktionalität der Formulare und der Aufgabenoberfläche wird ebenfalls auf dem Server realisiert. Auf dem Client erfolgt die Abfrage, Öffnung und Anzeige von Formularen, die Interaktion mit dem Benutzer (Benachrichtigungen, Fragen...), einfache Berechnungen in Formularen, die eine schnelle Reaktion erfordern (z. B. Preis mal Menge), die Arbeit mit lokalen Dateien und die Nutzung von Hardware.

In dem Anwendungs-Code müssen die Header von Prozeduren und Funktionen ausdrücklich angeben, wo der Code ausgeführt wird — mithilfe der Direktiven &НаКлиенте / &НаСервере (&AtClient / &AtServer in der englischen Version der Sprache). Die 1C-Entwickler werden mich jetzt korrigieren und sagen, dass es tatsächlich nicht diese Direktiven sind, mehr, aber das ist für uns derzeit nicht wesentlich.

Vom Client-Code aus kann serverseitiger Code aufgerufen werden, aber vom serverseitigen Code aus kann kein clientseitiger Code aufgerufen werden. Dies ist eine grundlegende Einschränkung, die wir aus verschiedenen Gründen festgelegt haben. Unter anderem, weil der serverseitige Code so geschrieben werden muss, dass er unabhängig von der Quelle – egal ob vom Client oder vom Server – gleich ausgeführt wird. Falls jedoch serverseitiger Code aus anderem serverseitigen Code aufgerufen wird, ist der Client nicht mehr vorhanden. Außerdem könnte der Client, der den Servercode aufgerufen hat, während der Ausführung des serverseitigen Codes geschlossen oder die Anwendung verlassen haben, sodass der Server niemanden mehr aufrufen würde.

Wie und warum wir einen hochbelastbaren, skalierbaren Service für 1C: Unternehmenssoftware mit Java, PostgreSQL und Hazelcast entwickelt haben
Der Code, der die Taste verarbeitet: Der Aufruf der serverseitigen Prozedur vom Client wird funktionieren, der Aufruf der clientseitigen Prozedur vom Server jedoch nicht.

Das bedeutet, dass wir, wenn wir eine Nachricht vom Server an die Client-Anwendung senden möchten, zum Beispiel um mitzuteilen, dass ein "langwieriger" Bericht erstellt wurde und dieser eingesehen werden kann, kein solches Mittel haben. Wir müssen Tricks anwenden, wie zum Beispiel, dass der Client-Code periodisch den Server abfragt. Aber dieser Ansatz belastet das System unnötig mit zusätzlichen Aufrufen und sieht insgesamt nicht sehr elegant aus.

Und außerdem gibt es die Notwendigkeit, beispielsweise bei einem eingehenden Telefonanruf SIP-die Client-Anwendung zu benachrichtigen, damit sie die Rufnummer des Anrufers in der Datenbank der Geschäftspartner findet und dem Benutzer Informationen über den anrufenden Geschäftspartner anzeigt. Oder um, beispielsweise bei einem Auftragseingang im Lager, die Client-Anwendung des Kunden darüber zu informieren. Kurz gesagt, es gibt viele Fälle, in denen ein solcher Mechanismus nützlich wäre.

Eigentlich formuliert

Einen Nachrichtenübertragungsmechanismus zu schaffen. Schnell, zuverlässig, mit garantierter Zustellung und der Möglichkeit, Nachrichten flexibel zu durchsuchen. Auf Basis dieses Mechanismus einen Messenger (Nachrichten, Videoanrufe) zu realisieren, der innerhalb der 1C-Anwendungen funktioniert.

Ein horizontal skalierbares System entwerfen. Steigende Lasten müssen durch die Erhöhung der Anzahl der Knoten gedeckt werden.

Implementierung

Wir haben uns entschieden, die Serverkomponente des SW nicht direkt in die 1C:Enterprise-Plattform zu integrieren, sondern als ein separates Produkt zu entwickeln, dessen API aus dem Code der 1C-Anwendungslösungen aufgerufen werden kann. Dies wurde aus mehreren Gründen getan, wobei der Hauptgrund darin bestand, den Austausch von Nachrichten zwischen verschiedenen 1C-Anwendungen (z. B. zwischen Handelsmanagement und Buchhaltung) zu ermöglichen. Verschiedene 1C-Anwendungen können auf unterschiedlichen Versionen der 1C:Enterprise-Plattform laufen, sich auf verschiedenen Servern befinden usw. Unter diesen Umständen ist die Implementierung der SW als separates Produkt, das „neben“ den 1C-Installationen existiert, die optimale Lösung.

Wir haben uns entschieden, SV als eigenständiges Produkt anzubieten. Kleineren Unternehmen empfehlen wir, den SV-Server zu nutzen, den wir in unserem Cloud-Service installiert haben (wss://1cdialog.com), um die Kosten für lokale Installation und Serverkonfiguration zu vermeiden. Größere Kunden könnten jedoch den Aufbau eines eigenen SV-Servers in ihren Einrichtungen als sinnvoll erachten. Ein ähnlicher Ansatz haben wir auch in unserem Cloud-SaaS-Produkt verwendet. 1cFresh – es wird als Serienprodukt zur Installation bei Kunden herausgegeben und ist ebenfalls in unserer Cloud verfügbar. https://1cfresh.com/.

generierten Wortliste wurde sichergestellt.

Um die Last zu verteilen und die Ausfallsicherheit zu gewährleisten, werden wir nicht eine, sondern mehrere Java-Anwendungen bereitstellen und davor einen Lastenausgleich stehen. Soll eine Nachricht von Knoten zu Knoten übertragen werden, nutzen wir publish/subscribe in Hazelcast.

Die Kommunikation zwischen Client und Server erfolgt über Websocket. Dies eignet sich hervorragend für Echtzeitsysteme.

Verteilter Cache

Wir standen vor der Wahl zwischen Redis, Hazelcast und Ehcache. Es war das Jahr 2015. Redis hatte gerade ein neues Cluster-Feature veröffentlicht (zu neu, etwas beängstigend), und es gibt Sentinel mit vielen Einschränkungen. Ehcache kann nicht als Cluster konfiguriert werden (dieser Funktionalität kam erst später). Wir haben uns entschieden, es mit Hazelcast 3.4 zu versuchen.
Hazelcast lässt sich "out of the box" zu einem Cluster zusammenfassen. Im Einzelknotenmodus ist es nicht besonders nützlich und kann nur als Cache genutzt werden – es kann keine Daten auf die Festplatte schreiben. Wenn der einzige Knoten verloren geht, gehen die Daten verloren. Wir etablieren mehrere Hazelcast-Instanzen, zwischen denen wir kritische Daten sichern. Den Cache sichern wir nicht – es tut nicht weh, ihn zu verlieren.

Für uns ist Hazelcast:

  • Speicher für Benutzersitzungen. Jedes Mal zur Datenbank zu gehen, um eine Sitzung abzurufen, dauert zu lange, daher speichern wir alle Sitzungen in Hazelcast.
  • Cache. Suchst du ein Benutzerprofil – prüfe im Cache. Hast du eine neue Nachricht geschrieben – lege sie in den Cache.
  • Topics für die Kommunikation zwischen den Instanzen der Anwendung. Ein Knoten generiert ein Ereignis und platziert es in einem Hazelcast-Topic. Andere Knoten der Anwendung, die auf dieses Topic abonniert sind, empfangen und verarbeiten das Ereignis.
  • Cluster-Sperren. Zum Beispiel eröffnen wir eine Diskussion über einen eindeutigen Schlüssel (Singleton-Diskussion im Rahmen der 1C-Datenbank):

conversationKeyChecker.check("Benzinpumpe");

      doInClusterLock("Benzinpumpe", () -> {

          conversationKeyChecker.check("Benzinpumpe");

          createChannel("Benzinpumpe");
      });

Wir haben festgestellt, dass der Kanal nicht existiert. Wir haben eine Sperre gesetzt, erneut überprüft und erstellt. Wenn man nach dem Setzen der Sperre nicht überprüft, besteht die Chance, dass ein anderer Prozess gleichzeitig überprüft hat und nun versucht, dieselbe Diskussion zu erstellen – und sie existiert bereits. Eine Sperre über synchronized oder das normale Java Lock ist nicht möglich. Über die Datenbank – langsam, und die Datenbank zu belasten, wäre schade. Hazelcast hingegen ist genau das, was wir brauchen.

Wählen Sie die Datenbank-Management-System (DBMS)

Wir haben umfangreiche und erfolgreiche Erfahrungen mit PostgreSQL und in der Zusammenarbeit mit den Entwicklern dieses DBMS.

Die Handhabung von Clustern in PostgreSQL ist komplex – es gibt XL, XC, Citus, aber insgesamt handelt es sich hier nicht um ein noSQL, das von Haus aus skalierbar ist. NoSQL als primäre Speicherung haben wir nicht in Betracht gezogen, es genügte, dass wir Hazelcast verwenden, mit dem wir vorher nicht gearbeitet haben.

Wenn wir eine relationale DB skalieren müssen – bedeutet das, Sharding. Wie Sie wissen, teilen wir die Datenbank in separate Teile auf, sodass jeder von ihnen auf einen eigenen Server ausgelagert werden kann.

Die erste Variante unseres Shardings sah vor, jede unserer Tabellen auf verschiedene Server in unterschiedlichen Verhältnissen zu verteilen. Wenn auf Server A viele Nachrichten anfallen, können wir einen Teil dieser Tabelle auf Server B verschieben. Diese Lösung schrie förmlich nach vorzeitiger Optimierung, sodass wir uns entschieden haben, es auf einen Multi-Tenant-Ansatz zu beschränken.

Mehr über Multi-Tenant-Architekturen erfahren Sie beispielsweise auf der Website Citus Data.

Im Kontext von SV gibt es die Begriffe Anwendung und Abonnent. Eine Anwendung ist eine spezifische Installation einer Business-Anwendung, wie z.B. ERP oder Buchhaltung, mit ihren eigenen Nutzern und Geschäftsdaten. Ein Abonnent ist eine Organisation oder eine Einzelperson, die im Namen der Registrierung der Anwendung auf dem SV-Server handelt. Ein Abonnent kann mehrere Anwendungen registrieren, und diese Anwendungen können Nachrichten untereinander austauschen. Der Abonnent wird somit zum Mieter (tenant) in unserem System. Nachrichten mehrerer Abonnenten können sich in einer physischen Datenbank befinden; wenn wir feststellen, dass ein Abonnent viel Traffic generiert, isolieren wir ihn in eine separate physische Datenbank (oder sogar auf einen separaten Datenbankserver).

Wir haben eine Hauptdatenbank, in der eine Routing-Tabelle mit Informationen zum Standort aller Abonnentendatenbanken gespeichert ist.

Wie und warum wir einen hochbelastbaren, skalierbaren Service für 1C: Unternehmenssoftware mit Java, PostgreSQL und Hazelcast entwickelt haben

Um sicherzustellen, dass die Hauptdatenbank nicht zum Engpass wird, halten wir die Routing-Tabelle (sowie andere häufig benötigte Daten) im Cache.

Wenn die Datenbank eines Abonnenten langsamer wird, können wir sie intern partitionieren. In anderen Projekten verwenden wir für die Partitionierung großer Tabellen pg_pathman.

Da es schlecht ist, Benutzerkommunikation zu verlieren, unterstützen wir unsere Datenbanken mit Replikaten. Die Kombination aus synchronen und asynchronen Replikaten bietet Sicherheit im Falle eines Ausfalls der Hauptdatenbank. Der Verlust von Nachrichten tritt nur auf, wenn sowohl die Hauptdatenbank als auch ihre synchrone Replikate gleichzeitig ausfallen.

Wenn die synchrone Replikate ausfällt, wird die asynchrone Replikate zur synchronen Replikate.
Wenn die Hauptdatenbank ausfällt, wird die synchrone Replikate zur Hauptdatenbank, während die asynchrone Replikate zur synchronen Replikate wird.

Elasticsearch für die Suche

Da der SW unter anderem auch ein Messenger ist, benötigen wir eine schnelle, benutzerfreundliche und flexible Suche, die die Morphologie und ungenaue Übereinstimmungen berücksichtigt. Wir haben uns entschieden, das Rad nicht neu zu erfinden und nutzen das freie Suchsystem Elasticsearch, das auf der Bibliothek Lucenebasierend ist. Auch Elasticsearch setzen wir in einem Cluster (Master – Data – Data) ein, um Probleme im Falle eines Ausfalls von Anwendungs-Knoten auszuschließen.

Auf GitHub haben wir ein Plugin für die russische Morphologie für Elasticsearch gefunden und verwenden es. Im Elasticsearch-Index speichern wir die Wortstämme (die durch das Plugin bestimmt werden) und N-Gramme. Während der Benutzer den Suchtext eingibt, suchen wir den eingegebenen Text unter den N-Grammen. Wenn das Wort „тексты“ im Index gespeichert wird, wird es in folgende N-Gramme zerlegt:

[те, тек, текс, текст, тексты, ек, екс, екст, ексты, кс, кст, ксты, ст, сты, ты],

und auch der Wortstamm „текст“ wird gespeichert. Dieser Ansatz ermöglicht es, sowohl am Anfang, in der Mitte als auch am Ende eines Wortes zu suchen.

Gesamtbild

Wie und warum wir einen hochbelastbaren, skalierbaren Service für 1C: Unternehmenssoftware mit Java, PostgreSQL und Hazelcast entwickelt haben
Wiederholung des Bildes zu Beginn des Artikels, jedoch mit Erklärungen:

  • Der im Internet veröffentlichte Balancer; wir nutzen nginx, aber es kann beliebig sein.
  • Die Instanzen der Java-Anwendung kommunizieren über Hazelcast.
  • Für die Arbeit mit WebSockets verwenden wir Netty.
  • Die Java-Anwendung wurde in Java 8 geschrieben und besteht aus Bundles OSGi. Ein Upgrade auf Java 10 und der Übergang zu Modulen sind geplant.

Entwicklung und Test

Während der Entwicklung und Testung des Systems sind wir auf einige interessante Eigenschaften der von uns verwendeten Produkte gestoßen.

Lasttests und Speicherlecks

Jede Version des Systems wird durch Lasttests begleitet. Diese sind erfolgreich bestanden, wenn:

  • Der Test lief mehrere Tage ohne Dienstunterbrechungen.
  • Die Antwortzeiten für Schlüsseloperationen überschritten nicht den komfortablen Schwellenwert.
  • Die Leistung im Vergleich zur vorherigen Version verschlechterte sich um nicht mehr als 10%.

Wir füllen die Testdatenbank mit Informationen – dazu erhalten wir von dem Produktionsserver die Daten des aktivsten Nutzers, multiplizieren seine Zahlen mit 5 (Anzahl der Nachrichten, Diskussionen, Nutzer) und testen so.

Die Lasttests des Interaktionssystems führen wir in drei Konfigurationen durch:

  1. Stresstest
  2. Nur Verbindungen
  3. Registrierung von Nutzern

Bei unserem Stresstest starten wir mehrere hundert Threads, die ununterbrochen das System belasten: Sie senden Nachrichten, erstellen Diskussionen und erhalten eine Liste von Nachrichten. Wir simulieren das Verhalten typischer Nutzer (wie etwa das Abrufen einer Liste meiner ungelesenen Nachrichten oder das Schreiben an jemanden) und von Softwarelösungen (z. B. das Übertragen eines Pakets einer anderen Konfiguration oder das Verarbeiten einer Benachrichtigung).

So sieht ein Teil des Stresstests aus:

  • Ein Nutzer loggt sich in das System ein
    • Fragt nach seinen ungelesenen Diskussionen
    • Liests die Nachrichten mit 50% Wahrscheinlichkeit
    • Schreibt Nachrichten mit 50% Wahrscheinlichkeit
    • Der Nutzer macht dann Folgendes:
      • Erstellt mit 20% Wahrscheinlichkeit eine neue Diskussion
      • Wählt zufällig eines seiner Diskussionen aus
      • Geht hinein
      • Fragt nach Nachrichten und Nutzerprofilen
      • Erstellt fünf Nachrichten, die an zufällige Nutzer aus dieser Diskussion gerichtet sind
      • Verlässt die Diskussion
      • Wiederholt dies 20 Mal
      • Loggt sich aus, kehrt zum Anfang des Szenarios zurück

    • Ein Chatbot loggt sich in das System ein (simuliert den Nachrichtenaustausch aus dem Code der Anwendungslösungen)
      • Erstellt mit 50% Wahrscheinlichkeit einen neuen Kanal für den Datenaustausch (eine spezielle Diskussion)
      • Mit einer Wahrscheinlichkeit von 50 % wird eine Nachricht über einen der bestehenden Kanäle gesendet.

Das Szenario „Nur Verbindungen“ ist nicht ohne Grund entstanden. Es gibt Situationen, in denen Benutzer das System angeschlossen haben, aber noch nicht aktiv sind. Jeder Benutzer schaltet morgens um 09:00 den Computer ein, stellt eine Verbindung zum Server her und bleibt still. Diese Nutzer sind gefährlich, es sind viele – aus ihren Paketen kommen nur PING/PONG, aber sie halten die Verbindung zum Server (sie können es nicht lassen – was ist, wenn eine neue Nachricht kommt). Der Test simuliert eine Situation, in der versuchen, sich über einen Zeitraum von 30 Minuten viele solcher Benutzer im System anzumelden. Er ähnelt einem Stresstest, legt den Fokus jedoch genau auf diesen ersten Zugang – um sicherzustellen, dass es keine Abbrüche gibt (wenn jemand das System nicht nutzt, aber es bereits ausfällt – schwer vorstellbar, was schlimmer sein könnte).

Das Szenario zur Registration von Endbenutzern beginnt mit dem ersten Start. Wir haben einen Stresstest durchgeführt und waren uns sicher, dass das System bei den Nachrichten nicht Verzögerungen aufweist. Doch als die Benutzer kamen, begann die Registrierung aufgrund von Zeitüberschreitungen auszufallen. Bei der Registrierung haben wir verwendet /dev/random, der auf die Entropie des Systems angewiesen ist. Der Server konnte nicht genügend Entropie sammeln und hängte bei der Anforderung eines neuen SecureRandom für mehrere Sekunden. Es gibt viele Lösungen für dieses Problem, zum Beispiel: auf das weniger sichere /dev/urandom wechseln, eine spezielle Hardware-Platine installieren, die Entropie erzeugt, oder zufällige Zahlen im Voraus generieren und im Pool speichern. Wir haben das Problem vorübergehend durch einen Pool gelöst, aber seitdem führen wir einen separaten Test zur Registrierung neuer Abonnenten durch.

Als Lastgenerator verwenden wir JMeter. Damit kann man jedoch nicht mit WebSockets arbeiten; dafür wird ein Plugin benötigt. Die ersten Ergebnisse bei der Suche nach „jmeter websocket“ sind Artikel von BlazeMeter, in denen empfohlen wird, das Plugin von Maciej Zaleski.

Das war unser Ausgangspunkt.

Kurz nach Beginn der intensiven Tests stellten wir fest, dass es in JMeter zu Speicherlecks kam.

Das Plugin ist eine separate, umfangreiche Geschichte; es hat 176 Sterne und 132 Forks auf GitHub. Der Autor hat seit 2015 keine Commits mehr vorgenommen (wir haben es 2015 verwendet, was damals keine Bedenken aufwarf), und es gibt mehrere GitHub-Issues zu Speicherlecks sowie 7 offene Pull-Requests.
Wenn Sie Lasttests mit diesem Plugin durchführen möchten, beachten Sie bitte die folgenden Diskussionen:

  1. In einer multithreaded Umgebung wurde eine gewöhnliche LinkedList verwendet, was zu NPE zur Laufzeit führte. Dies kann entweder durch den Wechsel zu ConcurrentLinkedDeque oder durch Synchronisierungsblöcke gelöst werden. Wir haben uns für die erste Option entschieden (https://github.com/maciejzaleski/JMeter-WebSocketSampler/issues/43).
  2. Speicherleck, bei der Trennung werden die Verbindungsdaten nicht gelöscht (https://github.com/maciejzaleski/JMeter-WebSocketSampler/issues/44).
  3. Im Streaming-Modus (wenn die WebSocket-Verbindung am Ende der Probe nicht geschlossen wird, sondern weiterverwendet wird) funktionieren die Response-Muster nicht (https://github.com/maciejzaleski/JMeter-WebSocketSampler/issues/19).

Das sind einige der Punkte, die auf GitHub zu finden sind. Was wir gemacht haben:

  1. Wir haben den Fork Elyran Kogan (@elyrank) - in dem die Probleme 1 und 3 behoben wurden.
  2. Das Problem 2 wurde gelöst.
  3. Wir haben Jetty von 9.2.14 auf 9.3.12 aktualisiert.
  4. Wir haben SimpleDateFormat in ThreadLocal gewickelt; SimpleDateFormat ist nicht threadsicher, was zu NPE zur Laufzeit führte.
  5. Wir haben ein weiteres Speicherleck behoben (die Verbindung wurde bei einer Trennung nicht richtig geschlossen).

Und trotzdem gibt es immer noch ein Leck!

Der Speicher war nicht mehr innerhalb eines Tages, sondern innerhalb von zwei Tagen aufgebraucht. Es blieb überhaupt keine Zeit, also entschieden wir uns, weniger Threads zu starten, aber auf vier Agenten. Das sollte mindestens für eine Woche ausreichen.

Zwei Tage sind vergangen…

Jetzt wird der Speicher von Hazelcast knapp. In den Logs war zu sehen, dass Hazelcast nach ein paar Tagen Testbetrieb mit Speicherengpässen zu kämpfen hatte, und nach einer gewissen Zeit brach der Cluster zusammen, während die Knoten nacheinander ausfielen. Wir verbanden JVisualVM mit Hazelcast und sahen eine „aufsteigende Säge“ – er rief regelmäßig GC auf, konnte jedoch den Speicher nicht freigeben.

Wie und warum wir einen hochbelastbaren, skalierbaren Service für 1C: Unternehmenssoftware mit Java, PostgreSQL und Hazelcast entwickelt haben

Es stellte sich heraus, dass in Hazelcast 3.4 beim Löschen eines Map / MultiMap (map.destroy()) der Speicher nicht vollständig freigegeben wird:

github.com/hazelcast/hazelcast/issues/6317
github.com/hazelcast/hazelcast/issues/4888

Das Problem wurde in 3.5 behoben, aber damals war es ein Problem. Wir erstellten neue MultiMaps mit dynamischen Namen und entfernten sie nach unserer Logik. Der Code sah ungefähr so aus:

public void join(Authentication auth, String sub) {
    MultiMap<UUID, Authentication> sessions = instance.getMultiMap(sub);
    sessions.put(auth.getUserId(), auth);
}

public void leave(Authentication auth, String sub) {
    MultiMap<UUID, Authentication> sessions = instance.getMultiMap(sub);
    sessions.remove(auth.getUserId(), auth);

    if (sessions.size() == 0) {
        sessions.destroy();
    }
}

Aufruf:

service.join(auth1, "NEUE_NACHRICHTEN_IN_DISKUSSION_UUID1");
service.join(auth2, "NEUE_NACHRICHTEN_IN_DISKUSSION_UUID1");

multiMap wurde für jedes Abonnement erstellt und entfernt, wenn es nicht mehr benötigt wurde. Wir haben beschlossen, ein Map<String,Set> zu erstellen, wobei der Schlüssel der Name des Abonnements ist und die Werte die Sitzungs-IDs (über die später die Benutzer-IDs abgerufen werden können, wenn nötig).

public void join(Authentication auth, String sub) {\n    addValueToMap(sub, auth.getSessionId());\n}\n\npublic void leave(Authentication auth, String sub) { \n    removeValueFromMap(sub, auth.getSessionId());\n}

Die Grafiken haben sich stabilisiert.

Wie und warum wir einen hochbelastbaren, skalierbaren Service für 1C: Unternehmenssoftware mit Java, PostgreSQL und Hazelcast entwickelt haben

Was haben wir noch über Lasttests gelernt?

  1. JSR223 sollte in Groovy geschrieben werden und den Compilation Cache aktivieren – das ist erheblich schneller. Link.
  2. Die JMeter-Plugins sind leichter zu verstehen als die Standardvarianten. Link.

Über unsere Erfahrungen mit Hazelcast

Hazelcast war für uns ein neues Produkt. Wir haben mit Version 3.4.1 begonnen, und derzeit läuft auf unserem Produktionsserver Version 3.9.2 (zum Zeitpunkt des Schreibens war die letzte Version von Hazelcast – 3.10).

ID-Generierung

Wir haben mit ganzzahligen Identifiern begonnen. Stellen wir uns vor, wir benötigen eine neue Long-ID für eine neue Entität. Eine Sequenz in der Datenbank funktioniert nicht, da die Tabellen am Sharding beteiligt sind – das bedeutet, dass es eine Nachricht mit der ID=1 in DB1 und eine Nachricht mit der ID=1 in DB2 gibt. In Elasticsearch könnte man keine solche ID speichern, ebenso wenig in Hazelcast. Am schlimmsten wird es, wenn Sie versuchen möchten, die Daten aus zwei Datenbanken in einer zu konsolidieren (zum Beispiel, weil Sie entscheiden, dass eine Datenbank für diese Abonnenten ausreicht). Eine Möglichkeit wäre, in Hazelcast mehrere AtomicLongs zu erstellen und den Zähler dort zu führen. In diesem Fall liegt die Leistung für das Abrufen einer neuen ID bei incrementAndGet plus die Zeit für die Anfrage an Hazelcast. Aber in Hazelcast gibt es etwas, das noch optimierter ist – den FlakeIdGenerator. Bei jedem Client-Anruf wird ein ID-Bereich zugewiesen, zum Beispiel dem ersten – von 1 bis 10.000, dem zweiten – von 10.001 bis 20.000 und so weiter. Jetzt kann der Client eigenständig neue Identifikatoren ausgeben, solange der ihm zugewiesene Bereich nicht erschöpft ist. Es funktioniert schnell, aber beim Neustart der Anwendung (und des Hazelcast-Clients) beginnt eine neue Sequenz – daher gibt es Lücken usw. Zudem ist für die Entwickler nicht klar, warum die IDs ganzzahlig sind, aber so unregelmäßig erscheinen. Nach sorgfältiger Abwägung haben wir auf UUIDs umgestellt.

Übrigens, für diejenigen, die wie Twitter sein möchten, gibt es die Bibliothek Snowcast – eine Implementierung von Snowflake über Hazelcast. Sie können sie hier ansehen:

github.com/noctarius/snowcast
github.com/twitter/snowflake

Aber wir haben es noch nicht geschafft, uns damit zu befassen.

TransactionalMap.replace

Noch eine Überraschung: TransactionalMap.replace funktioniert nicht. Hier ist ein solcher Test:

@Test
public void replaceInMap_putsAndGetsInsideTransaction() {

    hazelcastInstance.executeTransaction(context -> {
        HazelcastTransactionContextHolder.setContext(context);
        try {
            context.getMap("map").put("key", "oldValue");
            context.getMap("map").replace("key", "oldValue", "newValue");
            
            String value = (String) context.getMap("map").get("key");
            assertEquals("newValue", value);

            return null;
        } finally {
            HazelcastTransactionContextHolder.clearContext();
        }        
    });
}

Erwartet : newValue
Tatsächlich : oldValue

Ich musste meinen eigenen Replace schreiben, indem ich getForUpdate verwendet habe:

protected  boolean replaceInMap(String mapName, K key, V oldValue, V newValue) {
    TransactionalTaskContext context = HazelcastTransactionContextHolder.getContext();
    if (context != null) {
        log.trace("[CACHE] Wert in einer transaktionalen Map ersetzen");
        TransactionalMap map = context.getMap(mapName);
        V value = map.getForUpdate(key);
        if (oldValue.equals(value)) {
            map.put(key, newValue);
            return true;
        }

        return false;
    }
    log.trace("[CACHE] Wert in einer nicht transaktionalen Map ersetzen");
    IMap map = hazelcastInstance.getMap(mapName);
    return map.replace(key, oldValue, newValue);
}

Testen Sie nicht nur gewöhnliche Datenstrukturen, sondern auch deren transaktionale Versionen. Manchmal funktioniert IMap, während TransactionalMap eventuell nicht funktioniert.

Neuen JAR ohne Ausfallzeit bereitstellen

Zunächst haben wir beschlossen, Objekte unserer Klassen in Hazelcast zu speichern. Zum Beispiel haben wir eine Klasse Application und möchten sie speichern und abrufen. Speicherung:

IMap<UUID, Application> map = hazelcastInstance.getMap("application");
map.set(id, application);

Abruf:

IMap<UUID, Application> map = hazelcastInstance.getMap("application");
return map.get(id);

Es funktioniert alles. Dann haben wir beschlossen, ein Index in Hazelcast zu erstellen, um danach zu suchen:

map.addIndex("subscriberId", false);

Beim Hinzufügen einer neuen Entität trat eine ClassNotFoundException auf. Hazelcast versuchte, den Index zu aktualisieren, wusste jedoch nichts über unsere Klasse und benötigte die JAR-Datei mit dieser Klasse. Das haben wir so gemacht, alles funktionierte, aber ein neues Problem tauchte auf: Wie aktualisiert man die JAR ohne eine vollständige Clusterstop? Hazelcast erkennt die neue JAR bei einer inkrementellen Aktualisierung nicht. Zu diesem Zeitpunkt beschlossen wir, dass wir ganz gut ohne die Indexsuche auskommen können. Wenn wir Hazelcast als Schlüssel-Wert-Speicher verwenden, sollte doch alles funktionieren? Nicht ganz. Hier zeigt sich erneut das unterschiedliche Verhalten von IMap und TransactionalMap. Während IMap egal ist, wirft TransactionalMap einen Fehler.

IMap. Wir schreiben 5000 Objekte, lesen sie aus. Alles wie erwartet.

@Test
void get5000() {
    IMap map = hazelcastInstance.getMap("application");
    UUID subscriberId = UUID.randomUUID();

    for (int i = 0; i < 5000; i++) {
        UUID id = UUID.randomUUID();
        String title = RandomStringUtils.random(5);
        Application application = new Application(id, title, subscriberId);
        
        map.set(id, application);
        Application retrieved = map.get(id);
        assertEquals(id, retrieved.getId());
    }
}

In der Transaktion funktioniert es nicht, wir erhalten eine ClassNotFoundException:

@Test
void get_transaction() {
    IMap map = hazelcastInstance.getMap("application_t");
    UUID subscriberId = UUID.randomUUID();
    UUID id = UUID.randomUUID();

    Application application = new Application(id, "qwer", subscriberId);
    map.set(id, application);
    
    Application retrievedOutside = map.get(id);
    assertEquals(id, retrievedOutside.getId());

    hazelcastInstance.executeTransaction(context -> {
        HazelcastTransactionContextHolder.setContext(context);
        try {
            TransactionalMap transactionalMap = context.getMap("application_t");
            Application retrievedInside = transactionalMap.get(id);

            assertEquals(id, retrievedInside.getId());
            return null;
        } finally {
            HazelcastTransactionContextHolder.clearContext();
        }
    });
}

In Version 3.8 wurde der Mechanismus für das User Class Deployment eingeführt. Sie können einen Hauptknoten festlegen und die JAR-Datei darauf aktualisieren.

Wir haben unseren Ansatz vollständig geändert: Wir serialisieren jetzt selbst in JSON und speichern in Hazelcast. Hazelcast muss die Struktur unserer Klassen nicht kennen, und wir können ohne Ausfallzeiten aktualisieren. Die Versionierung der Domainobjekte wird von der Anwendung verwaltet. Unterschiedliche Versionen der Anwendung können gleichzeitig ausgeführt werden, und es kann vorkommen, dass eine neue Anwendung Objekte mit neuen Feldern schreibt, während die alte noch nichts über diese Felder weiß. Gleichzeitig liest die neue Anwendung Objekte, die von der alten Anwendung geschrieben wurden und keine neuen Felder enthalten. Solche Situationen werden innerhalb der Anwendung behandelt, aber der Einfachheit halber ändern oder löschen wir keine Felder, sondern erweitern die Klassen durch Hinzufügen neuer Felder.

Wie wir hohe Leistung sicherstellen

Vier Zugriffe auf Hazelcast – gut, zwei auf die Datenbank – schlecht

Es ist immer besser, Daten im Cache zu suchen als in einer Datenbank, aber ungenutzte Einträge möchten wir auch nicht aufbewahren. Die Entscheidung darüber, was wir cachen, schieben wir auf die letzte Phase der Entwicklung. Sobald die neue Funktionalität implementiert ist, aktivieren wir das Protokollieren aller Anfragen in PostgreSQL (log_min_duration_statement auf 0) und führen 20 Minuten lang einen Lasttest durch. Mit den gesammelten Logs können Tools wie pgFouine und pgBadger analytische Berichte erstellen. In diesen Berichten suchen wir in erster Linie nach langsamen und häufigen Anfragen. Für langsame Anfragen erstellen wir einen Ausführungsplan (EXPLAIN) und bewerten, ob wir die Anfrage beschleunigen können. Häufige Anfragen mit denselben Eingangsdaten lassen sich gut im Cache speichern. Wir versuchen, die Anfragen „flach“ zu halten, sodass jede Anfrage nur eine Tabelle betrifft.

Betrieb

Der Dienst CV als Online-Service wurde im Frühling 2017 gestartet. Im November 2017 wurde CV als separates Produkt veröffentlicht (damals im Beta-Status).

In über einem Jahr Betrieb gab es keine gravierenden Probleme mit dem Online-Service CV. Den Online-Service überwachen wir über Zabbix, sammeln und implementieren aus Bamboo.

Der Server-Distribution CVD wird in Form von nativen Paketen bereitgestellt: RPM, DEB, MSI. Zusätzlich bieten wir für Windows einen einheitlichen Installer in Form einer einzigen EXE-Datei an, die den Server, Hazelcast und Elasticsearch auf einer Maschine installiert. Zunächst haben wir diese Installationsversion als "Demoversion" bezeichnet, aber mittlerweile ist klar, dass dies die beliebteste Bereitstellungsoption ist.

Quelle: habr.com

Kaufen Sie zuverlässiges Hosting für Websites mit DDoS-Schutz, VPS VDS-Server 🔥 Kaufen Sie zuverlässiges Hosting für Websites mit DDoS-Schutz, VPS VDS-Server | ProHoster