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>
170 lines
5.9 KiB
PHP
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(),
|
|
],
|
|
);
|
|
}
|
|
}
|