presseportale/app/Console/Commands/OnlineSchemaChange.php
Kevin Adametz f8728c5bca technik: Volltext-Indizes von press_releases in eine eigene Suchtabelle auslagern
Ein FULLTEXT-Index zwingt MySQL bei JEDEM ADD COLUMN auf ALGORITHM=COPY. Auf
press_releases (>1 GB) bedeutet das einen vollständigen Rebuild, während dessen
kein Schreibzugriff möglich ist — im August hat genau das die Seite zum Stehen
gebracht, weil die wartenden Requests PHP-FPM-Worker und DB-Verbindungen
belegten.

Die durchsuchbaren Felder liegen jetzt gespiegelt in `press_release_search`,
die die Indizes trägt. Ihr Schema ist per Konstruktion stabil und wird nie
erweitert; neue Felder gehören nach press_releases, wo sie ohne Volltext-Index
Millisekunden kosten. Synchron hält sie ein Observer (alle Schreibzugriffe
laufen über Eloquent, auch der Importer), reparieren lässt sie sich mit
press-releases:reindex-search.

Der Umstieg ist über mehrere Migrationen geteilt und schaltet erst um, wenn
press_releases seine Indizes verloren hat — bis dahin sucht die Anwendung
unverändert weiter. Die Drop-Migration verweigert den Dienst, solange der
Suchindex unvollständig ist, und verwirft die gecachte Umschalt-Prüfung: Stünde
dort noch der alte Stand, setzte der Rückfallpfad sein MATCH() auf einen
soeben entfernten Index ab (MySQL-Fehler 1191) und jede Suche endete im 500er.

Dazu die betriebliche Absicherung:

- DB_LOCK_WAIT_TIMEOUT (Default 10 s) statt MySQLs einem Jahr. Requests
  scheitern bei einer laufenden Schemaänderung nach Sekunden, statt sich
  aufzustauen, bis keine Verbindung mehr frei ist.
- Sessions und Cache nach Redis, damit eine blockierte MySQL-Tabelle nicht die
  gesamte Anwendung mitreißt.
- db:online-alter als Notausgang (pt-online-schema-change), falls eine der
  kleinen FULLTEXT-Tabellen doch einmal groß wird. Percona-Toolkit ist
  bewusst nicht Teil des Standard-Setups.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:04:43 +00:00

219 lines
8.7 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Schema;
/**
* Führt eine Schemaänderung mit `pt-online-schema-change` aus, statt mit einem
* blockierenden `ALTER TABLE`.
*
* Hintergrund: `press_releases` und `companies` tragen FULLTEXT-Indizes. Die
* zwingen MySQL bei jedem `ADD COLUMN` auf `ALGORITHM=COPY` — ein vollständiger
* Rebuild, währenddessen KEIN Schreibzugriff auf die Tabelle möglich ist (auf
* dem Bestand über eine Stunde). pt-osc legt stattdessen eine Schattentabelle
* an, hält sie per Trigger synchron, kopiert in Blöcken und tauscht am Ende
* atomar um — die Originaltabelle bleibt durchgehend beschreibbar.
*
* Ablauf für eine Migration auf diesen Tabellen:
*
* 1. php artisan db:online-alter press_releases "ADD COLUMN foo TINYINT(1) NULL"
* (ohne --execute nur Trockenlauf)
* 2. … mit --execute wiederholen
* 3. php artisan migrate --force
*
* Schritt 3 verlangt, dass die Migration idempotent geschrieben ist
* (`Schema::hasColumn()`-Prüfung): Die Spalte existiert dann bereits, die
* Migration wird nur noch als ausgeführt vermerkt.
*/
class OnlineSchemaChange extends Command
{
protected $signature = 'db:online-alter
{table : Tabelle, z. B. press_releases}
{alter : ALTER-Klausel OHNE "ALTER TABLE <name>", z. B. "ADD COLUMN gone_at TIMESTAMP NULL"}
{--connection= : Verbindung aus config/database.php (Standard: die voreingestellte)}
{--execute : Änderung wirklich ausführen. Ohne dieses Flag läuft nur ein Trockenlauf.}
{--print-only : Nur den Befehl ausgeben, nichts starten}
{--drop-old-table : Die alte Tabelle nach Erfolg sofort löschen (spart Platte, kein Zurück)}
{--binary=pt-online-schema-change : Pfad zum pt-osc-Skript}';
protected $description = 'Ändert das Schema ohne Schreibsperre via pt-online-schema-change.';
public function handle(): int
{
$connectionName = $this->option('connection') ?: config('database.default');
$config = config("database.connections.{$connectionName}");
if (($config['driver'] ?? null) !== 'mysql') {
$this->error("Verbindung „{$connectionName}“ ist kein MySQL — pt-online-schema-change ist hier wirkungslos.");
return self::FAILURE;
}
$table = (string) $this->argument('table');
try {
if (! Schema::connection($connectionName)->hasTable($table)) {
$this->error("Tabelle „{$table}“ existiert in „{$connectionName}“ nicht.");
return self::FAILURE;
}
if (! $this->hasPrimaryKey($connectionName, $table)) {
$this->error("Tabelle „{$table}“ hat keinen Primärschlüssel — pt-online-schema-change kann sie nicht kopieren.");
return self::FAILURE;
}
} catch (QueryException $e) {
// Lieber eine klare Meldung als ein Stacktrace: Ein Tippfehler in
// der Verbindung darf nicht wie ein Programmfehler aussehen.
$this->error("Verbindung „{$connectionName}“ nicht erreichbar: ".$e->getMessage());
return self::FAILURE;
}
$binary = (string) $this->option('binary');
// Zugangsdaten NICHT als Argument übergeben: die Kommandozeile ist über
// `ps` für jeden Benutzer auf dem Server lesbar. pt-osc liest sie
// stattdessen aus einer nur für uns lesbaren Options-Datei.
$defaultsFile = $this->option('print-only')
? '/tmp/pt-osc-credentials.cnf'
: $this->writeDefaultsFile($config);
try {
$arguments = $this->buildArguments($config, $table, (string) $this->argument('alter'), $defaultsFile);
$this->line('<comment>'.$binary.' '.implode(' ', array_map(escapeshellarg(...), $arguments)).'</comment>');
if ($this->option('print-only')) {
return self::SUCCESS;
}
if (! $this->option('execute')) {
$this->warn('Trockenlauf — die Tabelle wird nicht verändert. Mit --execute wirklich ausführen.');
}
$result = Process::forever()
->run(array_merge([$binary], $arguments), function (string $type, string $output): void {
$this->output->write($output);
});
if ($result->failed()) {
$this->error('pt-online-schema-change ist fehlgeschlagen (Exit-Code '.$result->exitCode().').');
$this->line('Fehlt das Tool? Auf Ubuntu: <info>sudo apt-get install percona-toolkit</info>');
return self::FAILURE;
}
} finally {
if (! $this->option('print-only') && is_file($defaultsFile)) {
@unlink($defaultsFile);
}
}
if ($this->option('execute')) {
$this->info('Fertig. Jetzt „php artisan migrate --force“ nachziehen, damit die Migration vermerkt wird.');
if (! $this->option('drop-old-table')) {
$this->warn(sprintf(
'Die Originaltabelle liegt als `_%s_old` weiter auf der Platte (voller Tabellenumfang). Nach der Kontrolle entfernen: DROP TABLE `_%s_old`;',
$table,
$table,
));
}
}
return self::SUCCESS;
}
/**
* @param array<string, mixed> $config
* @return list<string>
*/
public function buildArguments(array $config, string $table, string $alter, string $defaultsFile): array
{
$dsn = implode(',', [
'F='.$defaultsFile,
'h='.($config['host'] ?? '127.0.0.1'),
'P='.($config['port'] ?? 3306),
'D='.$config['database'],
't='.$table,
]);
return [
'--alter='.$alter,
// Fremdschlüssel: 17 Constraints zeigen auf press_releases und
// companies. `rebuild_constraints` hängt die Kind-Tabellen nach dem
// Tausch sauber um — mit abgeschalteter Prüfung dauert das selbst
// bei einer Million Zeilen unter 200 ms. Die Alternative
// `drop_swap` wäre kurz inkonsistent und wird bewusst vermieden.
'--alter-foreign-keys-method=rebuild_constraints',
// Kopiert wird in Blöcken von je einer halben Sekunde, damit die
// Tabelle zwischendurch für den normalen Betrieb frei ist.
'--chunk-time=0.5',
// Bei Last pausieren statt die Seite mitzureißen.
'--max-load=Threads_running=40',
'--critical-load=Threads_running=100',
// Auch pt-osc selbst darf nicht ewig auf eine Sperre warten.
// `innodb_lock_wait_timeout=1` lässt die Kopier-Blöcke sofort
// zurückweichen, statt den laufenden Betrieb auszubremsen.
'--set-vars=lock_wait_timeout=60,innodb_lock_wait_timeout=1',
// Nach Erfolg bleibt die Originaltabelle als `_<name>_old` liegen —
// das Sicherheitsnetz, falls doch etwas fehlt. Sie belegt aber den
// vollen Platz der Tabelle und muss von Hand entfernt werden.
...($this->option('drop-old-table') ? [] : ['--no-drop-old-table']),
'--print',
'--progress=time,30',
$this->option('execute') ? '--execute' : '--dry-run',
$dsn,
];
}
/**
* @param array<string, mixed> $config
*/
private function writeDefaultsFile(array $config): string
{
$path = tempnam(sys_get_temp_dir(), 'pt-osc-');
chmod($path, 0600);
file_put_contents($path, sprintf(
"[client]\nuser=%s\npassword=%s\n",
self::quoteOptionValue((string) ($config['username'] ?? '')),
self::quoteOptionValue((string) ($config['password'] ?? '')),
));
return $path;
}
/**
* Maskiert einen Wert für eine my.cnf-Optionsdatei.
*
* Ohne Anführungszeichen beginnt ein `#` im Wert einen Kommentar und
* umschließende Leerzeichen fallen weg — ein Passwort mit Raute käme also
* abgeschnitten bei pt-osc an, und der Lauf scheiterte an einem
* Authentifizierungsfehler, dessen Ursache nirgends sichtbar ist.
*/
private static function quoteOptionValue(string $value): string
{
return '"'.str_replace(['\\', '"'], ['\\\\', '\\"'], $value).'"';
}
private function hasPrimaryKey(string $connection, string $table): bool
{
return collect(DB::connection($connection)->select('SHOW INDEX FROM `'.$table.'`'))
->contains(fn (object $index): bool => ($index->Key_name ?? null) === 'PRIMARY');
}
}