presseportale/app/Models/PressReleaseSearchIndex.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

170 lines
5.9 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Suchindex-Zeile einer Pressemitteilung — eine 1:1-Kopie der durchsuchbaren
* Felder, damit die Volltext-Indizes nicht auf `press_releases` liegen müssen
* (siehe Migration `create_press_release_search_table`).
*
* Diese Tabelle wird bewusst NIE um Spalten erweitert: Sie trägt die
* FULLTEXT-Indizes, und genau die machen ein `ADD COLUMN` teuer. Neue Felder
* gehören nach `press_releases`, wo sie dank fehlendem Volltext-Index in
* Millisekunden angelegt sind.
*/
class PressReleaseSearchIndex extends Model
{
protected $table = 'press_release_search';
protected $primaryKey = 'press_release_id';
public $incrementing = false;
public $timestamps = false;
protected $fillable = [
'press_release_id',
'title',
'subtitle',
'text',
'keywords',
'indexed_at',
];
/**
* Spalten, die den Index aktuell halten. Ändert sich keine davon, muss
* auch nicht neu indiziert werden — der Textkörper allein macht mehrere
* hundert Megabyte aus.
*
* @var list<string>
*/
public const SourceColumns = ['title', 'subtitle', 'text', 'keywords'];
protected function casts(): array
{
return ['indexed_at' => 'datetime'];
}
/**
* @return BelongsTo<PressRelease, $this>
*/
public function pressRelease(): BelongsTo
{
return $this->belongsTo(PressRelease::class);
}
/**
* Cache-Key der Umschalt-Prüfung (siehe isAvailable()).
*/
public const AvailabilityCacheKey = 'press-release-search.available';
/**
* Ist die Suchtabelle die maßgebliche Quelle?
*
* Der Umstieg läuft über mehrere Schritte (Tabelle anlegen → Bestand
* indizieren → alte Indizes entfernen). Maßgeblich ist deshalb NICHT, ob
* die Tabelle existiert — zwischen Anlegen und fertigem Reindex ist sie
* leer, die Suche fände dann nichts. Umgeschaltet wird erst, wenn
* `press_releases` seine Volltext-Indizes verloren hat: Genau dieser
* Schritt setzt einen vollständigen Suchindex voraus und wird von der
* Migration erzwungen. Bis dahin sucht die Anwendung unverändert weiter.
*
* Die beiden Antworten werden BEWUSST unterschiedlich lange gecacht:
* `true` ist der Dauerzustand nach dem Umstieg. `false` gilt nur bis zum
* Entfernen der Indizes und schickt die Suche so lange auf den
* Rückfallpfad — greift der noch, NACHDEM die Migration die Indizes
* entfernt hat, quittiert MySQL das mit Fehler 1191 („Can't find FULLTEXT
* index matching the column list") und jede Suche endet im 500er. Die
* Migrationen räumen den Eintrag deshalb zusätzlich selbst ab; die kurze
* TTL ist das Netz für den Fall, dass jemand die Indizes von Hand entfernt.
*/
public static function isAvailable(): bool
{
$cached = Cache::get(self::AvailabilityCacheKey);
if ($cached !== null) {
return (bool) $cached;
}
$available = self::resolveAvailability();
Cache::put(self::AvailabilityCacheKey, $available, $available ? 60 : 5);
return $available;
}
/**
* Verwirft die zwischengespeicherte Umschalt-Prüfung. Gehört in jede
* Migration, die an den Volltext-Indizes oder an der Suchtabelle dreht.
*/
public static function forgetAvailability(): void
{
Cache::forget(self::AvailabilityCacheKey);
}
private static function resolveAvailability(): bool
{
if (! Schema::hasTable('press_release_search')) {
return false;
}
if (Schema::getConnection()->getDriverName() !== 'mysql') {
return true;
}
return DB::select("SHOW INDEX FROM press_releases WHERE Index_type = 'FULLTEXT'") === [];
}
/**
* Schränkt eine Abfrage auf `press_releases` per Volltextsuche ein.
*
* Bewusst als unkorrelierte Unterabfrage (`whereIn`) formuliert: So ist die
* Suchtabelle die treibende Tabelle und MySQL wertet den Volltext-Index
* genau einmal aus. Ein korreliertes `whereExists` würde ihn pro Zeile der
* Ergebnismenge auswerten.
*
* @param list<string> $columns MUSS exakt einer Index-Definition entsprechen
*/
public static function constrain(QueryBuilder|EloquentBuilder $query, array $columns, string $expression, array $options = []): void
{
if (! self::isAvailable()) {
// Umstieg noch nicht abgeschlossen — press_releases trägt die
// Indizes noch selbst. Die Suche bleibt dadurch während des
// gesamten Deploys unverändert brauchbar.
$query->whereFullText($columns, $expression, $options);
return;
}
$query->whereIn('press_releases.id', function (QueryBuilder $sub) use ($columns, $expression, $options): void {
$sub->select('press_release_id')
->from('press_release_search')
->whereFullText($columns, $expression, $options);
});
}
/**
* Schreibt (oder aktualisiert) die Index-Zeile einer Pressemitteilung.
*/
public static function syncFor(PressRelease $pressRelease): void
{
static::query()->updateOrCreate(
['press_release_id' => $pressRelease->getKey()],
[
'title' => (string) $pressRelease->title,
'subtitle' => $pressRelease->subtitle,
'text' => (string) $pressRelease->text,
'keywords' => $pressRelease->keywords,
'indexed_at' => now(),
],
);
}
}