
Die Entwicklung hochbelasteter Projekte in jeder Programmiersprache erfordert einen besonderen Ansatz und den Einsatz spezieller Werkzeuge, aber wenn es um Anwendungen in PHP geht, kann die Situation so angespannt sein, dass man beispielsweise entwickeln muss. In diesem Artikel geht es um das allseits bekannte Problem der verteilten Speicherung von Sessions und der Daten-Caching mit memcached und wie wir diese Herausforderungen in einem 'Schützlingsprojekt' gelöst haben.
Der Hauptakteur ist eine PHP-Anwendung, die auf dem Framework Symfony 2.3 basiert und deren Aktualisierung nicht in den Geschäftsplan passt. Neben der völlig standardmäßigen Speicherung von Sessions wurde in diesem Projekt intensiv eine Politik des 'Caching aller Daten' in memcached verwendet: die Antworten auf Anfragen an die Datenbank und API-Server, verschiedene Flags, Sperren zur Synchronisation von Codeausführungen und vieles mehr. In einer solchen Situation kann ein Ausfall von memcached katastrophale Folgen für die Anwendung haben. Darüber hinaus führt der Verlust des Caches zu ernsthaften Konsequenzen: Die DB wird überlastet, API-Dienste beginnen, Anfragen zu blockieren usw. Die Stabilisierung der Situation kann Minuten dauern, und in dieser Zeit wird der Dienst stark verlangsamt oder sogar völlig unzugänglich.
Wir mussten die Möglichkeit der horizontalen Skalierung der Anwendung mit minimalem Aufwand, d.h. mit minimalen Änderungen am Quellcode und vollständiger Erhaltung der Funktionalität. Der Cache sollte nicht nur ausfallsicher, sondern auch darauf abzielen, Datenverluste zu minimieren.
Was ist grundsätzlich falsch mit memcached?
Im Allgemeinen unterstützt die memcached-Erweiterung für PHP 'out of the box' die verteilte Speicherung von Daten und Sessions. Der Mechanismus der konsistenten Hashing von Schlüsseln ermöglicht eine gleichmäßige Verteilung der Daten auf mehreren Servern, wobei jeder spezifische Schlüssel eindeutig einem bestimmten Server in der Gruppe zugewiesen wird, und die integrierten Failover-Mechanismen gewährleisten eine hohe Verfügbarkeit des Caching-Dienstes (aber leider nicht der Daten.).
Beim Speichern von Sessions sieht es etwas besser aus: Man kann memcached.sess_number_of_replicas konfigurieren., wodurch die Daten gleichzeitig auf mehreren Servern gespeichert werden, und im Falle des Ausfalls eines Memcached-Exemplars die Daten von anderen bereitgestellt werden. Sollte der Server jedoch in den Betrieb zurückkehren, ohne Daten (wie es nach einem Neustart häufig der Fall ist), wird ein Teil der Schlüssel zu seinen Gunsten umverteilt. Tatsächlich würde das bedeuten Datenverlust der Sitzung, da es keine Möglichkeit gibt, im Falle eines Fehlers auf ein anderes Replikat zuzugreifen.
Die Standardmittel der Bibliothek sind hauptsächlich darauf ausgerichtet, horizontal zu skalieren: Sie ermöglichen es, den Cache auf gigantische Größen zu erweitern und den Zugriff darauf aus dem Code zu gewähren, der auf verschiedenen Servern platziert ist. In unserer Situation überschreitet der Umfang der gespeicherten Daten jedoch nicht einige Gigabyte, und die Leistung eines oder zweier Knoten ist mehr als ausreichend. Entsprechend könnten die Standardmittel lediglich die Verfügbarkeit von Memcached gewährleisten, solange wenigstens ein Exemplar des Caches betriebsbereit ist. Dennoch gelang es uns nicht einmal, diese Möglichkeit zu nutzen… Es sei daran erinnert, wie antiquiert das Framework ist, das im Projekt verwendet wurde, was dazu führte, dass es uns nicht gelang, die Anwendung mit einem Server-Pool zum Laufen zu bringen. Auch die Verluste an Sitzungsdaten sind nicht zu vergessen: Beim massenhaften Ausloggen von Nutzern zuckten die Augen unserer Kunden.
Idealerweise wäre erforderlich gewesen die Replikation der Einträge in Memcached und das Umgehen von Replikaten im Falle eines Fehlers oder eines Problems. Diese Strategie wurde uns durch .
mcrouter
ermöglicht. Es handelt sich um einen Router für Memcached, entwickelt von Facebook, um dessen Probleme zu lösen. Er unterstützt das Textprotokoll von Memcached, das es ermöglicht, Memcached-Installationen auf enorme Größen zu skalieren. Eine ausführliche Beschreibung von mcrouter findet sich in . Neben anderer kann er genau das, was wir brauchen:
- eine Eintragsreplikation;
- Fallback auf andere Server der Gruppe bei Fehlern.
Legen wir los!
Konfiguration von mcrouter
Ich springe direkt zur Konfiguration:
{
"pools": {
"pool00": {
"servers": [
"mc-0.mc:11211",
"mc-1.mc:11211",
"mc-2.mc:11211"
},
"pool01": {
"servers": [
"mc-1.mc:11211",
"mc-2.mc:11211",
"mc-0.mc:11211"
},
"pool02": {
"servers": [
"mc-2.mc:11211",
"mc-0.mc:11211",
"mc-1.mc:11211"
},
"route": {
"type": "OperationSelectorRoute",
"default_policy": "AllMajorityRoute|Pool|pool00",
"operation_policies": {
"get": {
"type": "RandomRoute",
"children": [
"MissFailoverRoute|Pool|pool02",
"MissFailoverRoute|Pool|pool00",
"MissFailoverRoute|Pool|pool01"
]
}
}
}
}Warum drei Pools? Warum wiederholen sich die Server? Lassen Sie uns klären, wie das funktioniert.
- In dieser Konfiguration wählt mcrouter den Pfad, auf dem die Anfrage basierend auf dem Typ der Anfrage gesendet wird. Der Typ sagt ihm,
OperationSelectorRoute. - GET-Anfragen gelangen zu einem Handler
RandomRoute, der zufällig einen Pool oder einen Pfad aus den Objekten im Array auswähltchildren. Jedes Element dieses Arrays ist seinerseits ein HandlerMissFailoverRoute, der jeden Server im Pool durchläuft, bis er eine Antwort mit Daten erhält, die dann an den Client zurückgegeben wird. - Wenn wir ausschließlich einen Pool von drei Servern verwendet hätten, würden alle Anfragen zuerst an die erste Instanz von memcached gehen, während die anderen Anfragen nur nach dem Restprinzip erhalten würden, wenn die Daten fehlen. Dieser Ansatz hätte zu
MissFailoverRouteeiner übermäßigen Belastung des ersten Servers in der Liste geführt, weshalb beschlossen wurde, drei Pools mit Adressen in unterschiedlicher Reihenfolge zu erstellen und sie zufällig auszuwählen.Alle anderen Anfragen (was Schreibanfragen betrifft) werden mittels - AllMajorityRoute
behandelt. Dieser Handler sendet Anfragen an alle Server im Pool und wartet auf Antworten von mindestens N/2 + 1 von ihnen. Der Einsatz vonAllSyncRoutefür Schreiboperationen musste verworfen werden, da diese Methode positive Antworten von denServern der Gruppe verlangt — andernfalls gibt sie Umgebungen und Clustern des Projekts verwendet wird. Dieses Prinzip bildet die Grundlage für ein gutes SERVER_ERRORzurück. Auch wenn mcrouter die Daten in die verfügbaren Caches einfügt, gibt die aufrufende PHP-Funktioneinen Fehler zurück und erzeugt eine Benachrichtigung. ist nicht so streng und ermöglicht es, bis zu halb ausfallende Knoten ohne die oben beschriebenen Probleme zu entfernen.behandelt. Dieser Handler sendet Anfragen an alle Server im Pool und wartet auf Antworten von mindestens N/2 + 1 von ihnen. Der Einsatz vonDer Hauptnachteil
dieses Schemas besteht darin, dass, wenn im Cache tatsächlich keine Daten vorhanden sind, für jede Anfrage des Clients faktisch N Anfragen an memcached ausgeführt werden - an die Server im Pool. Man könnte die Anzahl der Server in den Pools beispielsweise auf zwei reduzieren: Indem wir die Zuverlässigkeit des Speicherns opfern, erhalten wir b mit allem Server im Pool. Die Anzahl der Server in den Pools kann auf beispielsweise zwei reduziert werden: indem wir die Zuverlässigkeit der Speicherung opfern, erhalten wir boeine höhere Geschwindigkeit und eine geringere Last bei Anfragen an nicht vorhandene Schlüssel.
NB: Nützliche Links zum Studium von mcrouter könnten auch und (einschließlich geschlossener), die eine wahre Fundgrube verschiedener Konfigurationen darstellen.
Build und Start von mcrouter
Die Anwendung (und memcached selbst) läuft bei uns in Kubernetes — entsprechend ist auch der Platz für mcrouter dort. Für den Aufbau des Containers verwenden wir , für den die Konfiguration wie folgt aussehen wird:
NB: Die Listings, die in diesem Artikel dargestellt sind, sind im Repository .
configVersion: 1
project: mcrouter
deploy:
namespace: '[[ env ]]'
helmRelease: '[[ project ]]-[[ env ]]'
---
image: mcrouter
from: ubuntu:16.04
mount:
- from: tmp_dir
to: /var/lib/apt/lists
- from: build_dir
to: /var/cache/apt
ansible:
beforeInstall:
- name: Vorabinstallationen
apt:
name: [ 'apt-transport-https', 'tzdata', 'locales' ]
update_cache: yes
- name: mcrouter APT-Schlüssel hinzufügen
apt_key:
url: https://facebook.github.io/mcrouter/debrepo/xenial/PUBLIC.KEY
- name: mcrouter-Repo hinzufügen
apt_repository:
repo: deb https://facebook.github.io/mcrouter/debrepo/xenial xenial contrib
filename: mcrouter
update_cache: yes
- name: Zeitzone einstellen
timezone:
name: "Europe/Moscow"
- name: Sicherstellen, dass ein Locale existiert
locale_gen:
name: en_US.UTF-8
state: present
install:
- name: mcrouter installieren
apt:
name: [ 'mcrouter' ]()
… und skizzieren Helm-Chart. Interessant ist — hier gibt es nur den Config-Generator basierend auf der Anzahl der Replikate, (wenn jemand eine kürzere und elegantere Variante hat — teilt sie in den Kommentaren):
{{- $count := (pluck .Values.global.env .Values.memcached.replicas | first | default .Values.memcached.replicas._default | int) -}}
{{- $pools := dict -}}
{{- $servers := list -}}
{{- \/\* Wir fügen das Array mit zwei Kopien der Server: "0 1 2 0 1 2" \*\/ -}}
{{- range until 2 -}}
{{- range $i, $_ := until $count -}}
{{- $servers = append $servers (printf "mc-%d.mc:11211" $i) -}}
{{- end -}}
{{- end -}}
{{- \/\* Durch Verschieben im Array erhalten wir N Schnitte: "[0 1 2] [1 2 0] [2 0 1]" \*\/ -}}
{{- range $i, $_ := until $count -}}
{{- $pool := dict "servers" (slice $servers $i (add $i $count)) -}}
{{- $_ := set $pools (printf "MissFailoverRoute|Pool|poold" $i) $pool -}}
{{- end -}}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: mcrouter
data:
config.json: |
{
"pools": {{- $pools | toJson | replace "MissFailoverRoute|Pool|" "" -}},
"route": {
"type": "OperationSelectorRoute",
"default_policy": "AllMajorityRoute|Pool|pool00",
"operation_policies": {
"get": {
"type": "RandomRoute",
"children": {{- keys $pools | toJson }}
}
}
}
}()
Wir setzen es in der Testumgebung aus und überprüfen:
# php -a
Interactive mode enabled
php > # Проверяем запись и чтение
php > $m = new Memcached();
php > $m->addServer('mcrouter', 11211);
php > var_dump($m->set('test', 'value'));
bool(true)
php > var_dump($m->get('test'));
string(5) "value"
php > # Работает! Тестируем работу сессий:
php > ini_set('session.save_handler', 'memcached');
php > ini_set('session.save_path', 'mcrouter:11211');
php > var_dump(session_start());
PHP Warning: Uncaught Error: Failed to create session ID: memcached (path: mcrouter:11211) in php shell code:1
Stack trace:
#0 php shell code(1): session_start()
#1 {main}
thrown in php shell code on line 1
php > # Не заводится… Попробуем задать session_id:
php > session_id("zzz");
php > var_dump(session_start());
PHP Warning: session_start(): Cannot send session cookie - headers already sent by (output started at php shell code:1) in php shell code on line 1
PHP Warning: session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
PHP Warning: session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
PHP Warning: session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
PHP Warning: session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
PHP Warning: session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
PHP Warning: session_start(): Failed to write session lock: UNKNOWN READ FAILURE in php shell code on line 1
PHP Warning: session_start(): Unable to clear session lock record in php shell code on line 1
PHP Warning: session_start(): Failed to read session data: memcached (path: mcrouter:11211) in php shell code on line 1
bool(false)
php >Eine Textsuche nach dem Fehler ergab keine Ergebnisse, jedoch war bei der Anfrage „“ das älteste, noch offene Problem des Projekts ganz oben aufgeführt — für das binäre Protokoll von memcached.
NB: Das ASCII-Protokoll in memcached ist langsamer als das binäre, und die Standardmittel zur konsistenten Schlüssel-Hashing funktionieren nur mit dem binären Protokoll. Dies stellt jedoch in diesem speziellen Fall kein Problem dar.
Die Sache ist klar: Man muss nur auf das ASCII-Protokoll umschalten und alles wird funktionieren… Allerdings spielt in diesem Fall die Gewohnheit, Antworten in zu suchen, eine gemeine Rolle. Die richtige Antwort findet man dort nicht… es sei denn, man blättert bis zum Ende, wo in der Sektion „Benutzergenerierte Anmerkungen“ die richtige und .
Ja, der richtige Name der Option ist — memcached.sess_binary_protocol. Diese muss deaktiviert werden, danach werden die Sessions funktionieren. Es bleibt nur, den Container mit mcrouter in den Pod mit PHP zu legen!
Fazit
Somit konnten wir das gestellte Ziel allein durch infrastrukturelle Änderungen erreichen: Das Problem der Ausfallsicherheit von memcached ist gelöst, die Zuverlässigkeit der Cache-Speicherung wurde erhöht. Neben den offensichtlichen Vorteilen für die Anwendung hat dies auch Spielraum für die Arbeit an der Plattform geschaffen: Wenn alle Komponenten Redundanz haben, wird das Leben des Administrators erheblich einfacher. Ja, diese Methode hat auch ihre Nachteile, sie kann wie ein „Häuschen“ wirken, aber wenn sie Geld spart, das Problem begräbt und keine neuen verursacht — warum nicht?
P.S.
Lesen Sie auch in unserem Blog:
- „Praxis mit dapp“ (am Beispiel symfony-demo): und ;
- «».
Quelle: habr.com
