", 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(''.$binary.' '.implode(' ', array_map(escapeshellarg(...), $arguments)).''); 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: sudo apt-get install percona-toolkit'); 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 $config * @return list */ 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 `__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 $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'); } }