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
e6eca9c85f
commit
8fc18c239b
14 changed files with 1076 additions and 5 deletions
117
tests/Feature/OnlineSchemaChangeCommandTest.php
Normal file
117
tests/Feature/OnlineSchemaChangeCommandTest.php
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\OnlineSchemaChange;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `db:online-alter` baut den Aufruf für pt-online-schema-change. Das Tool
|
||||
* selbst wird hier nicht ausgeführt — geprüft werden die Schutzmechanismen
|
||||
* und die Zusammenstellung der Argumente, weil ein falsches Flag auf der
|
||||
* Live-Datenbank teuer ist.
|
||||
*/
|
||||
function ptOscArguments(array $overrides = []): array
|
||||
{
|
||||
$config = array_merge([
|
||||
'driver' => 'mysql',
|
||||
'host' => 'db.example.test',
|
||||
'port' => 3306,
|
||||
'database' => 'pressekonto',
|
||||
'username' => 'app',
|
||||
'password' => 'geheim',
|
||||
], $overrides);
|
||||
|
||||
$command = new OnlineSchemaChange;
|
||||
$command->setLaravel(app());
|
||||
|
||||
// Ohne --execute gilt der Trockenlauf; die Option wird hier gesetzt,
|
||||
// weil buildArguments() sie liest.
|
||||
(fn () => $this->input = new ArrayInput(
|
||||
['--execute' => true],
|
||||
new InputDefinition([
|
||||
new InputOption('execute', null, InputOption::VALUE_NONE),
|
||||
new InputOption('drop-old-table', null, InputOption::VALUE_NONE),
|
||||
]),
|
||||
))->call($command);
|
||||
|
||||
return $command->buildArguments($config, 'press_releases', 'ADD COLUMN gone_at TIMESTAMP NULL', '/tmp/creds.cnf');
|
||||
}
|
||||
|
||||
test('the generated call carries the flags that keep the table writable', function () {
|
||||
$arguments = ptOscArguments();
|
||||
|
||||
expect($arguments)
|
||||
->toContain('--alter=ADD COLUMN gone_at TIMESTAMP NULL')
|
||||
// 17 Fremdschlüssel zeigen auf die beiden großen Tabellen. Das
|
||||
// schnellere `drop_swap` wäre kurzzeitig inkonsistent.
|
||||
->toContain('--alter-foreign-keys-method=rebuild_constraints')
|
||||
// pt-osc darf den laufenden Betrieb nicht ausbremsen.
|
||||
->toContain('--set-vars=lock_wait_timeout=60,innodb_lock_wait_timeout=1')
|
||||
->toContain('--max-load=Threads_running=40')
|
||||
->toContain('--critical-load=Threads_running=100')
|
||||
->toContain('--execute')
|
||||
->not->toContain('--dry-run');
|
||||
});
|
||||
|
||||
test('the credentials never appear on the command line', function () {
|
||||
$arguments = ptOscArguments();
|
||||
$line = implode(' ', $arguments);
|
||||
|
||||
// Über `ps` ist die Kommandozeile für jeden Benutzer des Servers lesbar.
|
||||
expect($line)
|
||||
->not->toContain('geheim')
|
||||
->not->toContain('app')
|
||||
->toContain('F=/tmp/creds.cnf');
|
||||
});
|
||||
|
||||
test('the connection details land in the dsn', function () {
|
||||
$arguments = ptOscArguments();
|
||||
|
||||
expect(end($arguments))
|
||||
->toContain('h=db.example.test')
|
||||
->toContain('P=3306')
|
||||
->toContain('D=pressekonto')
|
||||
->toContain('t=press_releases');
|
||||
});
|
||||
|
||||
test('the old table is kept unless dropping it is requested', function () {
|
||||
expect(ptOscArguments())->toContain('--no-drop-old-table');
|
||||
});
|
||||
|
||||
test('a non mysql connection is refused instead of pretending to work', function () {
|
||||
/** @var TestCase $this */
|
||||
Process::fake();
|
||||
|
||||
// Die Testsuite läuft auf SQLite — dort richtet pt-osc nichts aus.
|
||||
$this->artisan('db:online-alter', ['table' => 'users', 'alter' => 'ADD COLUMN foo INT NULL'])
|
||||
->expectsOutputToContain('kein MySQL')
|
||||
->assertFailed();
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
test('an unreachable database reports plainly instead of throwing', function () {
|
||||
/** @var TestCase $this */
|
||||
Process::fake();
|
||||
|
||||
config()->set('database.connections.fake_mysql', [
|
||||
'driver' => 'mysql',
|
||||
'host' => 'db.example.invalid',
|
||||
'database' => 'pressekonto',
|
||||
'username' => 'app',
|
||||
'password' => 'geheim',
|
||||
]);
|
||||
|
||||
$this->artisan('db:online-alter', [
|
||||
'table' => 'press_releases',
|
||||
'alter' => 'ADD COLUMN foo INT NULL',
|
||||
'--connection' => 'fake_mysql',
|
||||
])
|
||||
->expectsOutputToContain('nicht erreichbar')
|
||||
->assertFailed();
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
197
tests/Feature/PressReleaseSearchIndexTest.php
Normal file
197
tests/Feature/PressReleaseSearchIndexTest.php
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseSearchIndex;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Die Suchtabelle hält die Volltext-Indizes von `press_releases` fern, damit
|
||||
* dort neue Spalten ohne stundenlangen Rebuild angelegt werden können. Damit
|
||||
* das trägt, muss die Kopie zuverlässig synchron bleiben.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
PressReleaseSearchIndex::forgetAvailability();
|
||||
});
|
||||
|
||||
test('creating a press release indexes it right away', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = PressRelease::factory()->create([
|
||||
'title' => 'Solarpark eröffnet',
|
||||
'text' => 'Ein ausführlicher Text über den neuen Solarpark.',
|
||||
'keywords' => 'solar, energie',
|
||||
]);
|
||||
|
||||
$index = PressReleaseSearchIndex::query()->find($release->id);
|
||||
|
||||
expect($index)->not->toBeNull()
|
||||
->and($index->title)->toBe('Solarpark eröffnet')
|
||||
->and($index->text)->toBe('Ein ausführlicher Text über den neuen Solarpark.')
|
||||
->and($index->keywords)->toBe('solar, energie')
|
||||
->and($index->indexed_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('editing a searchable field updates the index', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = PressRelease::factory()->create(['title' => 'Alter Titel']);
|
||||
|
||||
$release->update(['title' => 'Neuer Titel', 'text' => 'Neuer Inhalt.']);
|
||||
|
||||
$index = PressReleaseSearchIndex::query()->find($release->id);
|
||||
|
||||
expect($index->title)->toBe('Neuer Titel')
|
||||
->and($index->text)->toBe('Neuer Inhalt.');
|
||||
});
|
||||
|
||||
test('changing an unrelated field does not rewrite the index', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = PressRelease::factory()->create();
|
||||
$indexedAt = PressReleaseSearchIndex::query()->find($release->id)->indexed_at;
|
||||
|
||||
$this->travel(2)->minutes();
|
||||
$release->update(['hits' => 999]);
|
||||
|
||||
// Der Textkörper macht den Großteil der Datenmenge aus — ihn bei jedem
|
||||
// Statuswechsel neu zu schreiben wäre reine Verschwendung.
|
||||
expect(PressReleaseSearchIndex::query()->find($release->id)->indexed_at->timestamp)
|
||||
->toBe($indexedAt->timestamp);
|
||||
});
|
||||
|
||||
test('force deleting a press release removes its index row', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = PressRelease::factory()->create();
|
||||
|
||||
$release->forceDelete();
|
||||
|
||||
expect(PressReleaseSearchIndex::query()->find($release->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('a soft deleted press release keeps its index row', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = PressRelease::factory()->create();
|
||||
|
||||
$release->delete();
|
||||
|
||||
// Die Suche filtert ohnehin über press_releases; dort ist die gelöschte PM
|
||||
// durch den SoftDeletes-Scope bereits ausgeschlossen.
|
||||
expect(PressReleaseSearchIndex::query()->find($release->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('the reindex command fills in rows that were written past eloquent', function () {
|
||||
/** @var TestCase $this */
|
||||
$releases = PressRelease::factory()->count(3)->create();
|
||||
|
||||
// Massen-Update per Query-Builder löst keine Model-Events aus — genau der
|
||||
// Fall, für den der Reparatur-Lauf existiert.
|
||||
PressReleaseSearchIndex::query()->delete();
|
||||
expect(PressReleaseSearchIndex::query()->count())->toBe(0);
|
||||
|
||||
$this->artisan('press-releases:reindex-search')->assertSuccessful();
|
||||
|
||||
expect(PressReleaseSearchIndex::query()->count())->toBe(3)
|
||||
->and(PressReleaseSearchIndex::query()->find($releases->first()->id)->title)
|
||||
->toBe($releases->first()->title);
|
||||
});
|
||||
|
||||
test('the reindex command repairs stale content with --all', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = PressRelease::factory()->create(['title' => 'Richtiger Titel']);
|
||||
|
||||
PressReleaseSearchIndex::query()->whereKey($release->id)->update(['title' => 'Veraltet']);
|
||||
|
||||
// Ohne --all bleibt eine vorhandene Zeile unangetastet …
|
||||
$this->artisan('press-releases:reindex-search')->assertSuccessful();
|
||||
expect(PressReleaseSearchIndex::query()->find($release->id)->title)->toBe('Veraltet');
|
||||
|
||||
// … erst der vollständige Lauf schreibt sie neu.
|
||||
$this->artisan('press-releases:reindex-search', ['--all' => true])->assertSuccessful();
|
||||
expect(PressReleaseSearchIndex::query()->find($release->id)->title)->toBe('Richtiger Titel');
|
||||
});
|
||||
|
||||
test('the fulltext constraint drives off the search table', function () {
|
||||
/** @var TestCase $this */
|
||||
$query = PressRelease::query();
|
||||
PressReleaseSearchIndex::constrain($query, ['title', 'keywords'], 'solar');
|
||||
|
||||
// Auf der Builder-Struktur geprüft statt auf dem fertigen SQL: SQLite
|
||||
// kann Volltext gar nicht übersetzen, die Form der Abfrage ist aber
|
||||
// treiberunabhängig — und genau sie entscheidet über die Laufzeit.
|
||||
$where = $query->getQuery()->wheres[0];
|
||||
|
||||
expect($where['type'])->toBe('InSub')
|
||||
// Die Suchtabelle ist die treibende Tabelle; der Volltext-Index wird
|
||||
// einmal ausgewertet, nicht pro Zeile der Ergebnismenge.
|
||||
->and($where['column'])->toBe('press_releases.id')
|
||||
->and($where['query']->from)->toBe('press_release_search')
|
||||
->and(collect($where['query']->wheres)->pluck('type')->all())->toContain('Fulltext')
|
||||
// Unkorreliert: keine Verknüpfung auf die äußere Abfrage.
|
||||
->and(collect($where['query']->wheres)->pluck('type')->all())->not->toContain('Column');
|
||||
})->skip(
|
||||
fn (): bool => DB::connection()->getDriverName() !== 'mysql',
|
||||
'Nur auf MySQL: whereIn() übersetzt die Unterabfrage sofort, und SQLite kennt keine Volltextsuche.',
|
||||
);
|
||||
|
||||
test('search falls back gracefully while the table is missing', function () {
|
||||
/** @var TestCase $this */
|
||||
expect(PressReleaseSearchIndex::isAvailable())->toBeTrue();
|
||||
|
||||
Schema::drop('press_release_search');
|
||||
PressReleaseSearchIndex::forgetAvailability();
|
||||
|
||||
// Zwischen Code-Deploy und Migration darf die Suche nicht mit einem
|
||||
// SQL-Fehler aussteigen, sondern weicht auf LIKE aus.
|
||||
expect(PressReleaseSearchIndex::isAvailable())->toBeFalse();
|
||||
|
||||
$results = PressRelease::query()
|
||||
->withoutGlobalScopes()
|
||||
->matchingSearchTerm('solar', 'de')
|
||||
->get();
|
||||
|
||||
expect($results)->toBeCollection();
|
||||
});
|
||||
|
||||
test('a negative switch check is cached only briefly', function () {
|
||||
/** @var TestCase $this */
|
||||
Schema::drop('press_release_search');
|
||||
PressReleaseSearchIndex::forgetAvailability();
|
||||
|
||||
expect(PressReleaseSearchIndex::isAvailable())->toBeFalse()
|
||||
->and(Cache::get(PressReleaseSearchIndex::AvailabilityCacheKey))->toBeFalse();
|
||||
|
||||
// `false` gilt nur während des Umstiegs und schickt die Suche so lange auf
|
||||
// den Rückfallpfad. Steht der Eintrag noch, NACHDEM die Migration die
|
||||
// Volltext-Indizes von press_releases entfernt hat, setzt der Rückfallpfad
|
||||
// sein MATCH() auf einen Index ab, den es nicht mehr gibt — MySQL antwortet
|
||||
// mit Fehler 1191 und jede Suche endet im 500er.
|
||||
$this->travel(10)->seconds();
|
||||
|
||||
expect(Cache::get(PressReleaseSearchIndex::AvailabilityCacheKey))->toBeNull();
|
||||
});
|
||||
|
||||
test('a positive switch check is cached for a minute', function () {
|
||||
/** @var TestCase $this */
|
||||
expect(PressReleaseSearchIndex::isAvailable())->toBeTrue();
|
||||
|
||||
// Der Dauerzustand nach dem Umstieg — hier wäre ein SHOW INDEX pro Suche
|
||||
// reine Verschwendung.
|
||||
$this->travel(30)->seconds();
|
||||
|
||||
expect(Cache::get(PressReleaseSearchIndex::AvailabilityCacheKey))->toBeTrue();
|
||||
});
|
||||
|
||||
test('forgetting the switch check re-evaluates it', function () {
|
||||
/** @var TestCase $this */
|
||||
expect(PressReleaseSearchIndex::isAvailable())->toBeTrue();
|
||||
|
||||
Schema::drop('press_release_search');
|
||||
|
||||
// Ohne das Verwerfen bliebe die veraltete Antwort stehen — genau deshalb
|
||||
// rufen die beiden Such-Migrationen forgetAvailability() auf.
|
||||
expect(PressReleaseSearchIndex::isAvailable())->toBeTrue();
|
||||
|
||||
PressReleaseSearchIndex::forgetAvailability();
|
||||
|
||||
expect(PressReleaseSearchIndex::isAvailable())->toBeFalse();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue