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>
This commit is contained in:
parent
9afbedddeb
commit
f8728c5bca
14 changed files with 1076 additions and 5 deletions
219
app/Console/Commands/OnlineSchemaChange.php
Normal file
219
app/Console/Commands/OnlineSchemaChange.php
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
95
app/Console/Commands/ReindexPressReleaseSearch.php
Normal file
95
app/Console/Commands/ReindexPressReleaseSearch.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseSearchIndex;
|
||||
use App\Scopes\PortalScope;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Befüllt und repariert die Suchtabelle `press_release_search`.
|
||||
*
|
||||
* Zwei Einsätze:
|
||||
* - Einmalig beim Umstieg, um den Bestand zu indizieren. Läuft blockfrei:
|
||||
* Es wird nur aus `press_releases` gelesen und in die Suchtabelle
|
||||
* geschrieben, die zu diesem Zeitpunkt noch niemand abfragt.
|
||||
* - Später als Reparatur, falls doch einmal an Eloquent vorbei geschrieben
|
||||
* wurde (Massen-Updates per Query-Builder lösen keine Model-Events aus).
|
||||
*
|
||||
* Standardmäßig werden nur fehlende Zeilen ergänzt; `--all` schreibt alles neu.
|
||||
*
|
||||
* Zur Blockgröße: Ein Durchgang schreibt `--chunk` vollständige Textkörper in
|
||||
* EINEM `upsert`. Bei langen Meldungen kann das Statement `max_allowed_packet`
|
||||
* reißen (Standard 64 MB) — der Lauf bricht dann mit „Packet too large" ab.
|
||||
* Beim erstmaligen Indizieren des Bestands deshalb im Zweifel kleiner fahren:
|
||||
* `php artisan press-releases:reindex-search --chunk=100`.
|
||||
*/
|
||||
class ReindexPressReleaseSearch extends Command
|
||||
{
|
||||
protected $signature = 'press-releases:reindex-search
|
||||
{--all : Alle Pressemitteilungen neu indizieren statt nur die fehlenden}
|
||||
{--chunk=500 : Pressemitteilungen je Durchgang}';
|
||||
|
||||
protected $description = 'Befüllt bzw. repariert die Volltext-Suchtabelle der Pressemitteilungen.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = max(50, (int) $this->option('chunk'));
|
||||
$all = (bool) $this->option('all');
|
||||
|
||||
$query = PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->withTrashed()
|
||||
->select(['id', 'title', 'subtitle', 'text', 'keywords'])
|
||||
->when(! $all, fn (Builder $q) => $q->whereNotExists(
|
||||
fn ($sub) => $sub->select(DB::raw(1))
|
||||
->from('press_release_search')
|
||||
->whereColumn('press_release_search.press_release_id', 'press_releases.id'),
|
||||
));
|
||||
|
||||
$total = (clone $query)->toBase()->getCountForPagination();
|
||||
|
||||
if ($total === 0) {
|
||||
$this->info('Suchindex ist vollständig — nichts zu tun.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(($all ? 'Neu indiziert' : 'Ergänzt').": {$total} Pressemitteilungen.");
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
|
||||
$done = 0;
|
||||
|
||||
// chunkById statt chunk(): Beim Ergänzen verschwinden die verarbeiteten
|
||||
// Zeilen aus der Ergebnismenge, ein Offset würde dann Einträge
|
||||
// überspringen.
|
||||
$query->chunkById($chunk, function ($releases) use (&$done, $bar): void {
|
||||
$now = now();
|
||||
|
||||
PressReleaseSearchIndex::query()->upsert(
|
||||
$releases->map(fn (PressRelease $release): array => [
|
||||
'press_release_id' => $release->getKey(),
|
||||
'title' => (string) $release->title,
|
||||
'subtitle' => $release->subtitle,
|
||||
'text' => (string) $release->text,
|
||||
'keywords' => $release->keywords,
|
||||
'indexed_at' => $now,
|
||||
])->all(),
|
||||
['press_release_id'],
|
||||
['title', 'subtitle', 'text', 'keywords', 'indexed_at'],
|
||||
);
|
||||
|
||||
$done += $releases->count();
|
||||
$bar->advance($releases->count());
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
$this->info("Fertig: {$done} Einträge geschrieben.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue