link: take click counters off the hot row to stop deadlocks
Deploy Ladill Link / deploy (push) Successful in 1m18s
Deploy Ladill Link / deploy (push) Successful in 1m18s
Every click ran three UPDATEs against the same short_links row plus one against the owner's link_wallets row, all inside one transaction. Two consequences: - A popular link serialised its entire traffic through a single row lock. - Locking two rows per transaction meant concurrent clicks on different links of the same owner could take them in opposite orders, so MySQL deadlocked. Production logged 152 deadlocks, 146 of them on one row, surfacing as HTTP 500s to real visitors. With a link now being handed to a very large audience, this is the next thing that falls over, and it is independent of page weight. Counters are now buffered and flushed instead of written inline: - link_clicks stays the source of truth. It is insert-only, so it has no contention no matter how popular a link gets. - Increments accumulate in Redis hashes and drain via link:flush-click-counters, scheduled every minute. The database sees one UPDATE per link per flush instead of four per click. - Draining reads and deletes each hash atomically, so a click landing mid-flush is counted in that batch or the next, never dropped. - --recount rebuilds totals from link_clicks after a Redis loss. If the buffer is unavailable the recorder writes through to the database, keeping today's behaviour (and today's contention) rather than losing counts. Nothing in the click path may break the redirect the visitor actually came for, so the whole recorder is wrapped and failures are logged. Storage sits behind ClickCounterStore so the buffered path is covered by tests rather than silently falling back to write-through when no Redis is present. Also adds the (short_link_id, ip_hash, clicked_at) index. The unique-click check filters on ip_hash but only (short_link_id, clicked_at) was indexed, so every click scanned all of that link's rows in the dedupe window — cost growing with the link's own popularity, the worst possible shape for a link going viral. Tests: 11 new. The load-bearing one asserts the request path issues no UPDATE against short_links or link_wallets at all. Pre-existing suite failures go from 13 to 9; none of the remainder are related. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* The unique-click check filters on (short_link_id, ip_hash, clicked_at) but the
|
||||
* only index was (short_link_id, clicked_at), so every click scanned all of that
|
||||
* link's rows inside the dedupe window. The cost grew with the link's own
|
||||
* popularity — worst possible shape for a link that goes viral.
|
||||
*
|
||||
* Index is appended, creates no locking concern on an insert-only table.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('link_clicks', function (Blueprint $table) {
|
||||
$table->index(['short_link_id', 'ip_hash', 'clicked_at'], 'link_clicks_dedupe_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('link_clicks', function (Blueprint $table) {
|
||||
$table->dropIndex('link_clicks_dedupe_idx');
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user