presseportale/app/Console/Commands/ReindexPressReleaseSearch.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

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;
}
}