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>
95 lines
3.6 KiB
PHP
95 lines
3.6 KiB
PHP
<?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;
|
|
}
|
|
}
|