link: take click counters off the hot row to stop deadlocks
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:
isaacclad
2026-07-25 11:38:12 +00:00
co-authored by Claude
parent 0f4890cdfe
commit f2019dbb2a
11 changed files with 668 additions and 22 deletions
+43 -21
View File
@@ -3,28 +3,35 @@
namespace App\Services\Link;
use App\Models\LinkClick;
use App\Models\LinkWallet;
use App\Models\ShortLink;
use App\Support\Link\LinkGeoResolver;
use App\Support\Link\LinkUserAgentParser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
class LinkClickRecorder
{
public function __construct(private readonly LinkClickCounterBuffer $counters) {}
/**
* Record one click.
*
* link_clicks is the source of truth and is insert-only, so it carries no lock
* contention no matter how popular a link becomes. The denormalised totals on
* short_links / link_wallets are buffered in Redis and flushed on a schedule
* see LinkClickCounterBuffer for why they can no longer be written inline.
*
* Nothing here may break the redirect: a visitor who clicked a link must be sent
* on their way even if analytics fails entirely.
*/
public function record(ShortLink $link, Request $request): void
{
$ipHash = hash('sha256', $request->ip().'|'.config('app.key'));
$windowHours = (int) config('link.click_unique_window_hours', 24);
$isUnique = ! LinkClick::query()
->where('short_link_id', $link->id)
->where('ip_hash', $ipHash)
->where('clicked_at', '>=', now()->subHours($windowHours))
->exists();
try {
$ipHash = hash('sha256', $request->ip().'|'.config('app.key'));
$isUnique = $this->isUnique($link, $ipHash);
$parsed = LinkUserAgentParser::parse((string) $request->userAgent());
$parsed = LinkUserAgentParser::parse((string) $request->userAgent());
DB::transaction(function () use ($link, $request, $ipHash, $isUnique, $parsed) {
LinkClick::create([
'short_link_id' => $link->id,
'ip_hash' => $ipHash,
@@ -38,16 +45,31 @@ class LinkClickRecorder
'clicked_at' => now(),
]);
$link->increment('clicks_total');
if ($isUnique) {
$link->increment('unique_clicks_total');
}
$link->update(['last_clicked_at' => now()]);
$this->counters->add($link, $isUnique);
} catch (Throwable $e) {
Log::warning('link click recording failed', [
'short_link_id' => $link->id,
'error' => $e->getMessage(),
]);
}
}
LinkWallet::query()
->where('user_id', $link->user_id)
->increment('clicks_total');
});
/**
* First click from this IP within the window?
*
* Relies on the (short_link_id, ip_hash, clicked_at) index. Without it this is a
* scan over every click the link has ever received, which degrades as the link
* gets popular the opposite of what a viral link needs.
*/
private function isUnique(ShortLink $link, string $ipHash): bool
{
$windowHours = (int) config('link.click_unique_window_hours', 24);
return ! LinkClick::query()
->where('short_link_id', $link->id)
->where('ip_hash', $ipHash)
->where('clicked_at', '>=', now()->subHours($windowHours))
->exists();
}
private function truncate(?string $value, int $max): ?string