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,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Link;
|
||||
|
||||
/**
|
||||
* In-memory counter store for tests and for local development without Redis.
|
||||
* Process-local, so it is never appropriate in production.
|
||||
*/
|
||||
class ArrayClickCounterStore implements ClickCounterStore
|
||||
{
|
||||
/** @var array<string, array<string, string>> */
|
||||
private array $hashes = [];
|
||||
|
||||
public function increment(string $hash, string $field, int $by = 1): void
|
||||
{
|
||||
$this->hashes[$hash][$field] = (string) (((int) ($this->hashes[$hash][$field] ?? 0)) + $by);
|
||||
}
|
||||
|
||||
public function put(string $hash, string $field, string $value): void
|
||||
{
|
||||
$this->hashes[$hash][$field] = $value;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function drain(string $hash): array
|
||||
{
|
||||
$out = $this->hashes[$hash] ?? [];
|
||||
unset($this->hashes[$hash]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Link;
|
||||
|
||||
/**
|
||||
* Counter storage behind LinkClickCounterBuffer.
|
||||
*
|
||||
* Exists so the buffered path is testable. Production uses Redis hashes; tests use
|
||||
* an in-memory array, so "counts are buffered, then flushed in one UPDATE" is
|
||||
* verified rather than assumed. Implementations throw on failure — the buffer
|
||||
* catches and writes through to the database instead.
|
||||
*/
|
||||
interface ClickCounterStore
|
||||
{
|
||||
public function increment(string $hash, string $field, int $by = 1): void;
|
||||
|
||||
public function put(string $hash, string $field, string $value): void;
|
||||
|
||||
/**
|
||||
* Read and clear in one atomic step, so a click landing mid-flush is counted in
|
||||
* this batch or the next, never dropped.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function drain(string $hash): array;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Link;
|
||||
|
||||
use App\Models\LinkWallet;
|
||||
use App\Models\ShortLink;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Buffers click counter increments in Redis instead of writing them on the
|
||||
* request path.
|
||||
*
|
||||
* Every visit used to run three UPDATEs against the same short_links row plus one
|
||||
* against the owner's link_wallets row, all inside one transaction. A popular link
|
||||
* therefore serialised its entire traffic through a single row lock, and because
|
||||
* two rows were locked in one transaction, concurrent clicks on different links of
|
||||
* the same owner could acquire them in opposite orders — which is exactly the
|
||||
* "Deadlock found when trying to get lock" that showed up 146 times on one row.
|
||||
*
|
||||
* Redis INCR has no such contention, so the request path becomes lock-free and the
|
||||
* database sees one batched UPDATE per link per flush instead of four per click.
|
||||
*
|
||||
* Redis is treated as a buffer, never as the source of truth: link_clicks still
|
||||
* records every click as an insert-only row, so a lost buffer costs at most a short
|
||||
* drift in the denormalised totals, which `link:flush-click-counters --recount`
|
||||
* repairs from link_clicks.
|
||||
*/
|
||||
class LinkClickCounterBuffer
|
||||
{
|
||||
private const CLICKS = 'link:counters:clicks';
|
||||
|
||||
private const UNIQUE = 'link:counters:unique';
|
||||
|
||||
private const LAST_CLICKED = 'link:counters:last_clicked';
|
||||
|
||||
private const WALLET = 'link:counters:wallet';
|
||||
|
||||
public function __construct(private readonly ClickCounterStore $store) {}
|
||||
|
||||
public function add(ShortLink $link, bool $isUnique): void
|
||||
{
|
||||
try {
|
||||
$this->store->increment(self::CLICKS, (string) $link->id);
|
||||
if ($isUnique) {
|
||||
$this->store->increment(self::UNIQUE, (string) $link->id);
|
||||
}
|
||||
$this->store->put(self::LAST_CLICKED, (string) $link->id, now()->toDateTimeString());
|
||||
$this->store->increment(self::WALLET, (string) $link->user_id);
|
||||
} catch (Throwable $e) {
|
||||
// Redis down: fall back to the direct writes rather than lose the count.
|
||||
// This reintroduces the contention, but only while Redis is unavailable.
|
||||
Log::warning('link click counter buffer unavailable, writing through', [
|
||||
'short_link_id' => $link->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->writeThrough($link, $isUnique);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the buffer into the database.
|
||||
*
|
||||
* Each hash is read and deleted in one transaction so a concurrent click either
|
||||
* lands in the batch being flushed or in the next one, never in neither.
|
||||
*
|
||||
* @return array{links: int, wallets: int, clicks: int}
|
||||
*/
|
||||
public function flush(): array
|
||||
{
|
||||
[$clicks, $unique, $lastClicked, $wallets] = $this->drain();
|
||||
|
||||
$totalClicks = 0;
|
||||
$linkIds = array_unique([...array_keys($clicks), ...array_keys($unique), ...array_keys($lastClicked)]);
|
||||
|
||||
foreach ($linkIds as $linkId) {
|
||||
$delta = (int) ($clicks[$linkId] ?? 0);
|
||||
$uniqueDelta = (int) ($unique[$linkId] ?? 0);
|
||||
$stamp = $lastClicked[$linkId] ?? null;
|
||||
$totalClicks += $delta;
|
||||
|
||||
// One UPDATE per link per flush, replacing three per click.
|
||||
$sets = [];
|
||||
if ($delta !== 0) {
|
||||
$sets['clicks_total'] = DB::raw('clicks_total + '.$delta);
|
||||
}
|
||||
if ($uniqueDelta !== 0) {
|
||||
$sets['unique_clicks_total'] = DB::raw('unique_clicks_total + '.$uniqueDelta);
|
||||
}
|
||||
if ($stamp !== null) {
|
||||
$sets['last_clicked_at'] = $stamp;
|
||||
}
|
||||
if ($sets !== []) {
|
||||
ShortLink::query()->whereKey($linkId)->update($sets);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($wallets as $userId => $delta) {
|
||||
if ((int) $delta !== 0) {
|
||||
LinkWallet::query()->where('user_id', $userId)->increment('clicks_total', (int) $delta);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'links' => count($linkIds),
|
||||
'wallets' => count($wallets),
|
||||
'clicks' => $totalClicks,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the denormalised totals from link_clicks, which is insert-only and
|
||||
* therefore authoritative. Use after a Redis loss, or to verify drift.
|
||||
*/
|
||||
public function recount(): int
|
||||
{
|
||||
$rows = DB::table('link_clicks')
|
||||
->select('short_link_id')
|
||||
->selectRaw('COUNT(*) as total')
|
||||
->selectRaw('SUM(CASE WHEN is_unique = 1 THEN 1 ELSE 0 END) as uniques')
|
||||
->selectRaw('MAX(clicked_at) as last_clicked')
|
||||
->groupBy('short_link_id')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
ShortLink::query()->whereKey($row->short_link_id)->update([
|
||||
'clicks_total' => (int) $row->total,
|
||||
'unique_clicks_total' => (int) $row->uniques,
|
||||
'last_clicked_at' => $row->last_clicked,
|
||||
]);
|
||||
}
|
||||
|
||||
return $rows->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-and-clear each hash atomically.
|
||||
*
|
||||
* @return array{0: array<string,int>, 1: array<string,int>, 2: array<string,string>, 3: array<string,int>}
|
||||
*/
|
||||
private function drain(): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ([self::CLICKS, self::UNIQUE, self::LAST_CLICKED, self::WALLET] as $key) {
|
||||
try {
|
||||
$out[] = $this->store->drain($key);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('link click counter flush could not drain', [
|
||||
'key' => $key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$out[] = [];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function writeThrough(ShortLink $link, bool $isUnique): void
|
||||
{
|
||||
try {
|
||||
// Single statement, so the row is locked once rather than three times.
|
||||
$sets = [
|
||||
'clicks_total' => DB::raw('clicks_total + 1'),
|
||||
'last_clicked_at' => now(),
|
||||
];
|
||||
if ($isUnique) {
|
||||
$sets['unique_clicks_total'] = DB::raw('unique_clicks_total + 1');
|
||||
}
|
||||
ShortLink::query()->whereKey($link->id)->update($sets);
|
||||
|
||||
LinkWallet::query()->where('user_id', $link->user_id)->increment('clicks_total');
|
||||
} catch (Throwable $e) {
|
||||
// Never let a counter failure break the redirect the visitor came for.
|
||||
Log::warning('link click counter write-through failed', [
|
||||
'short_link_id' => $link->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Link;
|
||||
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class RedisClickCounterStore implements ClickCounterStore
|
||||
{
|
||||
public function increment(string $hash, string $field, int $by = 1): void
|
||||
{
|
||||
Redis::hincrby($hash, $field, $by);
|
||||
}
|
||||
|
||||
public function put(string $hash, string $field, string $value): void
|
||||
{
|
||||
Redis::hset($hash, $field, $value);
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function drain(string $hash): array
|
||||
{
|
||||
// MULTI/EXEC: the DEL cannot land between another client's HINCRBY and our
|
||||
// HGETALL, so no increment is read-then-discarded.
|
||||
$result = Redis::transaction(function ($tx) use ($hash) {
|
||||
$tx->hgetall($hash);
|
||||
$tx->del($hash);
|
||||
});
|
||||
|
||||
return is_array($result[0] ?? null) ? $result[0] : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user