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>
219 lines
8.7 KiB
PHP
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');
|
|
}
|
|
}
|