Distributed Locking — Архитектура и Практическое Руководство
Назначение
Документ описывает, когда и как использовать distributed locking в Pin через библиотеку madelson/DistributedLock. Включает выбор backend (Redis vs PostgreSQL), правила использования, anti-patterns и примеры для типичных enterprise use cases.
Distributed Lock vs Оптимистичный Concurrency
Выбор между двумя подходами зависит от природы конфликта:
| Критерий | Distributed Lock | Optimistic Concurrency (xmin / rowversion) |
|---|---|---|
| Частота конфликтов | Высокая (много writers) | Низкая (редкие коллизии) |
| Операция | Дорогая (нельзя повторить дёшево) | Дешёвая (повторить легко) |
| Duration | Несколько секунд (external call) | Миллисекунды (DB write) |
| Примеры в Pin | Бонус-активация, scheduled job, миграция | Обновление баланса, статус KYC |
| Penalty при конфликте | Lock wait / fail fast | Retry по exception |
Правило: предпочитай оптимистичный concurrency. Distributed lock — только когда повтор операции недопустим или слишком дорог.
Пакет
src/building-blocks/
Talent.Common.Locking/
NuGet dependencies:
<PackageReference Include="DistributedLock.Redis" Version="1.*" />
<PackageReference Include="DistributedLock.Postgres" Version="1.*" />
<PackageReference Include="DistributedLock.Core" Version="1.*" />
Провайдеры
Pin поддерживает два backend для locks:
Redis Lock (рекомендуется для операционных locks)
- Быстрый, низкая latency (<1ms).
- Подходит для: incentive claim, job deduplication, idempotency write lock.
- Недостаток: при Redis failover lock может быть потерян (Redlock не используется — слишком сложный).
PostgreSQL Advisory Lock (рекомендуется для migration lock)
- Гарантированная durability (survives Redis restart).
- Подходит для: database migrations, long-running reconciliation.
- Медленнее Redis, но надёжнее при критичных операциях.
IPinLockProvider
// Talent.Common.Locking/IPinLockProvider.cs
/// <summary>
/// Абстракция distributed lock. Скрывает backend (Redis или PostgreSQL).
/// Регистрируется как Singleton.
/// </summary>
public interface IPinLockProvider
{
/// <summary>
/// Попытка захватить lock. Если lock занят — ждём до <paramref name="timeout"/>.
/// Возвращает null если lock не удалось захватить.
/// </summary>
Task<IDistributedLockHandle?> TryAcquire(
string resource,
TimeSpan timeout = default,
CancellationToken ct = default);
/// <summary>Захватить lock или выбросить <see cref="DistributedLockTimeoutException"/>.</summary>
Task<IDistributedLockHandle> Acquire(
string resource,
TimeSpan timeout,
CancellationToken ct = default);
}
public interface IDistributedLockHandle : IAsyncDisposable
{
string Resource { get; }
string OwnerId { get; } // уникальный ID держателя (для диагностики)
DateTimeOffset AcquiredAt { get; }
DateTimeOffset ExpiresAt { get; }
}
Реализация через DistributedLock
// Talent.Common.Locking/RedisLockProvider.cs
public sealed class RedisLockProvider : IPinLockProvider
{
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<RedisLockProvider> _logger;
private readonly LockOptions _options;
public async Task<IDistributedLockHandle?> TryAcquire(
string resource,
TimeSpan timeout = default,
CancellationToken ct = default)
{
var lockName = NormalizeResource(resource);
var @lock = new RedisDistributedLock(lockName, _redis.GetDatabase());
var handle = await @lock.TryAcquireAsync(
timeout: timeout == default ? TimeSpan.Zero : timeout,
cancellationToken: ct);
if (handle is null)
{
_logger.LogWarning(
"DistributedLock: failed to acquire {Resource} within {Timeout}ms",
resource, timeout.TotalMilliseconds);
return null;
}
_logger.LogDebug("DistributedLock: acquired {Resource}", resource);
return new DistributedLockHandle(handle, resource, _logger);
}
private static string NormalizeResource(string resource)
=> $"Pin:lock:{resource.ToLowerInvariant().Replace('/', ':')}";
}
Соглашения Именования Lock Keys
Pin:lock:incentive:claim:{userId}:{incentiveOfferId}
Pin:lock:job:scheduled:{jobName}
Pin:lock:migration:{serviceName}
Pin:lock:withdrawal:approve:{withdrawalId}
Pin:lock:payment:callback:{providerRef}
Pin:lock:user:session:{userId}
Pin:lock:reconciliation:{date}:{providerId}
Правила:
- Все ключи начинаются с
Pin:lock:. - Включают entity type и entity id — чтобы один игрок не блокировал другого.
- Нижний регистр,
:как разделитель.
Use Cases
1. Бонус Claim Lock — один игрок одновременно один бонус
// Pin.Incentive.Application/Commands/ClaimIncentive/ClaimIncentiveCommandHandler.cs
public async Task<Result<ClaimIncentiveResult>> Handle(ClaimIncentiveCommand cmd, CancellationToken ct)
{
var lockKey = $"incentive:claim:{cmd.UserId.Value}:{cmd.IncentiveOfferId.Value}";
// Пытаемся захватить lock на 10 секунд — не ждём, если занято
await using var handle = await _lockProvider.TryAcquire(lockKey, timeout: TimeSpan.Zero, ct);
if (handle is null)
return Result.Fail(IncentiveErrors.ClaimAlreadyInProgress);
// Безопасно: только один handler для данной пары (user, offer)
var existing = await _userIncentiveRepo.Find(cmd.UserId, cmd.IncentiveOfferId, ct);
if (existing is not null)
return Result.Fail(IncentiveErrors.AlreadyClaimed);
var incentive = await _incentiveOfferRepo.Get(cmd.IncentiveOfferId, ct);
var result = incentive.ClaimFor(cmd.UserId, _clock.UtcNow);
if (result.IsFailed) return result.ToResult<ClaimIncentiveResult>();
await _userIncentiveRepo.Add(result.Value, ct);
return Result.Ok(new ClaimIncentiveResult(result.Value.Id));
}
// Lock освобождается при выходе из using (IAsyncDisposable)
2. Scheduled Job Lock — один pod запускает job
// Talent.Common.Infrastructure/Jobs/DistributedJobRunner.cs
public sealed class DistributedJobRunner
{
private readonly IPinLockProvider _lockProvider;
/// <summary>
/// Запускает <paramref name="job"/> только если удалось захватить lock.
/// Гарантирует, что при нескольких pod-ах job выполняется ровно один раз.
/// </summary>
public async Task RunExclusive(
string jobName,
Func<CancellationToken, Task> job,
TimeSpan lockTimeout,
CancellationToken ct = default)
{
var lockKey = $"job:scheduled:{jobName}";
await using var handle = await _lockProvider.TryAcquire(lockKey, TimeSpan.Zero, ct);
if (handle is null)
{
_logger.LogInformation("Job {JobName}: skipped, another instance holds lock", jobName);
return;
}
_logger.LogInformation("Job {JobName}: starting", jobName);
await job(ct);
_logger.LogInformation("Job {JobName}: completed", jobName);
}
}
// Пример использования в background worker:
public sealed class IncentiveExpiryJob : IJob
{
private readonly DistributedJobRunner _runner;
private readonly IIncentiveExpiryService _service;
public async Task Execute(IJobExecutionContext context)
{
await _runner.RunExclusive(
jobName: "incentive-expiry-daily",
job: ct => _service.ExpireOverdueIncentivees(ct),
lockTimeout: TimeSpan.FromMinutes(30),
ct: context.CancellationToken);
}
}
3. Payment Callback Lock — idempotency при повторных callbacks
// При получении callback от провайдера — lock на providerReference
var lockKey = $"payment:callback:{providerReference}";
await using var handle = await _lockProvider.TryAcquire(
lockKey, timeout: TimeSpan.FromSeconds(5), ct);
if (handle is null)
return Result.Fail(PaymentErrors.CallbackProcessingInProgress);
// Проверяем inbox — был ли уже обработан
var existing = await _inboxRepo.Find(providerReference, ct);
if (existing is not null)
return Result.Ok(/* idempotent replay */);
// Обрабатываем callback...
4. Migration Lock — PostgreSQL advisory lock
// Pin.Kernel.Migrator/DistributedMigrationLock.cs
public sealed class DistributedMigrationLock
{
private readonly NpgsqlConnection _connection;
private readonly string _serviceName;
public async Task<IAsyncDisposable> Acquire(CancellationToken ct)
{
await _connection.OpenAsync(ct);
// PostgreSQL advisory lock — уникален для сервиса по hash имени
var lockId = Math.Abs(_serviceName.GetHashCode());
var @lock = new PostgresDistributedLock(
new PostgresAdvisoryLockKey(lockId, isGlobal: true),
_connection);
var handle = await @lock.AcquireAsync(
timeout: TimeSpan.FromMinutes(5),
cancellationToken: ct);
return handle;
}
}
Правила
- Lock key всегда включает entity id — не блокируй весь тип, только конкретный объект.
- Timeout: операционные locks — до 10 секунд; job locks — минуты; migration lock — 5 минут.
- Всегда используй
await using(IAsyncDisposable) — lock освобождается даже при исключении. - Никогда не держи lock дольше времени HTTP request (>30s — anti-pattern).
- При
TryAcquireвозвращаетnull→ returnResult.Failс typed error, не retry. - Lock не заменяет database transaction — используй оба: lock предотвращает race, transaction обеспечивает atomicity.
- Не используй distributed lock для оптимистичного concurrency (xmin/rowversion достаточно).
- Логируй lock acquire/release с ключом и duration на уровне Debug.
- В тестах используй
InProcessDistributedLockизDistributedLock.Core(без Redis/PostgreSQL).
Anti-Patterns
// ПЛОХО: lock на тип без id — блокирует всех игроков
var lockKey = "incentive:claim"; // ← слишком широкий
// ПЛОХО: lock без timeout — может висеть бесконечно
await _lockProvider.Acquire("some:resource", TimeSpan.Zero);
// ПЛОХО: lock снаружи транзакции
await using var handle = await _lockProvider.Acquire(key, ...);
// ... много бизнес-логики без транзакции ...
await _repo.Update(entity); // нет транзакции!
// ХОРОШО: lock + transaction
await using var handle = await _lockProvider.Acquire(key, TimeSpan.FromSeconds(5), ct);
await using var tx = await _transactionBoundary.Begin(ct);
// ...
await tx.Commit(ct);
Регистрация
// AddPinLocking.cs — в Talent.Common.Locking
public static IPinInfrastructureBuilder AddPinLocking(
this IPinInfrastructureBuilder builder,
LockingBackend backend = LockingBackend.Redis)
{
switch (backend)
{
case LockingBackend.Redis:
builder.Services.AddSingleton<IPinLockProvider, RedisLockProvider>();
break;
case LockingBackend.Postgres:
builder.Services.AddSingleton<IPinLockProvider, PostgresLockProvider>();
break;
}
builder.Services.AddSingleton<DistributedJobRunner>();
return builder;
}
public enum LockingBackend { Redis, Postgres }
Ссылки
Обратные ссылки
Автоссылки: где используется этот документ.
- Фоновые задачи (2): Background Job: BookingExpirationJob, Background Job: LeadExpirationJob
- adr (1): ADR-0042: Distributed Locking
- index.md (1): Backend SDK — Руководство Разработчика
- backend-shared-standards-index.md (1): Backend Shared Standards Index