Distributed Locking With Postgres Advisory Locks in .NET

Distributed Locking With Postgres Advisory Locks in .NET

5 min read··

databasedistributed-systemsdotnetpostgresql

You can build a crash-safe distributed lock in .NET on Postgres advisory locks, with no new infrastructure. A session-level advisory lock serializes work across every instance that shares the database, and it auto-releases the moment the holding connection closes, so a dead process can never wedge a lock. If your instances already share a Postgres database, they already share a lock manager.

I needed a distributed lock for an unglamorous reason: awarding achievements. On Katabench, two concurrent submissions from the same user could both observe "streak not yet awarded" and both award it. A classic check-then-act race, per user, across API instances, so an in-process SemaphoreSlim lock cannot fix it once the API scales past one instance.

The reflex answer is Redis and a RedLock library (I surveyed the options in distributed locking in .NET). But there is a lock manager already in the architecture, battle-tested, transactional, and free: Postgres.

Advisory Locks in One Minute

Postgres advisory locks are locks over an application-defined 64-bit key. Postgres never interprets the key; it just guarantees that only one session can hold it at a time:

SELECT pg_advisory_lock(42);    -- blocks until acquired
SELECT pg_advisory_unlock(42);  -- explicit release

Two properties make the session-level variant the right primitive for a distributed lock:

  • It serializes across every instance that shares the database. The lock lives in Postgres, not in process memory.
  • It auto-releases when the holding connection closes. A crashed pod, a killed deploy, a network partition that drops the connection: in every case, Postgres frees the lock immediately. There is no TTL to tune, no lease to renew, and no stuck key to page you at 3 a.m.

That second property is the whole argument. A TTL-based Redis lease has to pick an expiry: too short and the lock expires mid-critical-section (now you need fencing tokens), too long and a crash wedges the key for the full TTL. Session advisory locks sidestep the dilemma, because the lock's lifetime is the connection's lifetime.

The Implementation

The full class is small. The design decisions are in the details, so let's walk them:

public sealed class PostgresAdvisoryLock(
    string connectionString,
    ILogger<PostgresAdvisoryLock> logger) : IDistributedLock
{
    // How long a caller waits for a contended lock before giving up.
    private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(15);

    public async Task<IAsyncDisposable> AcquireAsync(
        string key,
        CancellationToken cancellationToken = default)
    {
        long lockKey = HashKey(key);
        NpgsqlConnection? connection = null;
        try
        {
            // A dedicated connection: the lock is held for the handle's lifetime.
            connection = new NpgsqlConnection(connectionString);
            await connection.OpenAsync(cancellationToken);

            using var timeout = CancellationTokenSource
                .CreateLinkedTokenSource(cancellationToken);
            timeout.CancelAfter(WaitTimeout);

            using var command = new NpgsqlCommand(
                "SELECT pg_advisory_lock(@key)", connection);
            command.Parameters.AddWithValue("key", lockKey);
            await command.ExecuteNonQueryAsync(timeout.Token);

            return new Handle(connection, lockKey, logger);
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
        {
            // Our WaitTimeout fired, not the caller's token: a holder is stuck.
            logger.LogWarning(
                "Lock '{Key}' timed out after {Timeout}s; proceeding without it.",
                key, WaitTimeout.TotalSeconds);
            await DisposeQuietlyAsync(connection);
            return NullAsyncDisposable.Instance;
        }
        catch (Exception ex) when (ex is NpgsqlException or TimeoutException)
        {
            logger.LogWarning(ex, "Lock '{Key}' unavailable; proceeding without it.", key);
            await DisposeQuietlyAsync(connection);
            return NullAsyncDisposable.Instance;
        }
    }
}

The lock lives on a dedicated connection. pg_advisory_lock binds the lock to the session that ran it, so the handle owns one connection from acquire to dispose. That is the cost model to keep in mind: each held lock pins a connection for the duration of the critical section. Keep critical sections short, or this pattern will walk you straight into an exhausted connection pool.

The wait is bounded. pg_advisory_lock blocks server-side until the lock is granted, so a stuck holder could hang requests forever. The linked cancellation token caps the wait at 15 seconds, and Npgsql cancels the server-side wait when it fires.

String keys are hashed with FNV-1a, not GetHashCode. Advisory locks take a bigint, and the map from "achievements:user:123" to that bigint must be identical in every process:

public static long HashKey(string key)
{
    const ulong offsetBasis = 14695981039346656037UL;
    const ulong prime = 1099511628211UL;
    ulong hash = offsetBasis;
    foreach (byte b in Encoding.UTF8.GetBytes(key))
    {
        hash ^= b;
        hash *= prime;
    }

    return unchecked((long)hash);
}

string.GetHashCode() is randomized per process in .NET, so two instances would hash the same key to different lock ids and never contend. A 64-bit FNV-1a collision, if you ever hit one, only makes two unrelated keys occasionally serialize: a performance blip, never a correctness bug.

Release has a built-in safety net. Disposal runs pg_advisory_unlock, but even if that command fails, disposing the connection ends the Postgres session, and the session's death releases every advisory lock it held. The lock physically cannot leak.

Failing Open Is a Choice: Make It Consciously

Notice what the catch blocks do: when the lock cannot be acquired (database unreachable, or a stuck holder times out the wait), the method logs a warning and returns a no-op handle, and the caller proceeds without the lock.

That is fail-open, and it is only correct because of what this lock protects: a best-effort feature where the fallback is the pre-lock behavior (a rare duplicate award, resolved by an idempotent write). Degrading to the old race beats turning submissions into 500s because the lock store hiccuped.

Invert the decision the moment the lock guards something that must never run twice: money movement, external side effects, destructive migrations. There, a failed acquisition should fail the operation loudly. The point is that fail-open versus fail-closed is a per-call-site decision about blast radius, not a property of the lock class.

When Is Postgres the Wrong Lock?

Honest boundaries, so you know when to reach for something else:

  • High lock throughput. A connection open per acquisition is fine at "per-user, occasionally contended" rates and wrong at thousands of acquisitions per second. That is Redis territory.
  • No shared database. If the services that need mutual exclusion do not already share a Postgres instance, adding one just for locks buys you nothing over Redis.
  • Critical section = exactly one transaction. Use pg_advisory_xact_lock instead: it releases automatically at commit or rollback, and there is no handle to dispose.

Summary

If your instances already share a Postgres database, and your contention profile is "rare and brief", you get a crash-safe distributed lock with zero new infrastructure and about a hundred lines of code. That trade is hard to beat.

Frequently Asked Questions

What are PostgreSQL advisory locks?

Application-defined locks managed by Postgres but not tied to any table or row. Your application picks a 64-bit key and calls pg_advisory_lock(key); Postgres serializes all sessions that request the same key. Postgres never interprets the key, it just guarantees mutual exclusion, which makes advisory locks a general-purpose distributed lock for anything, not only database rows.

What happens to an advisory lock if the application crashes?

Session-level advisory locks are released automatically when the backing connection closes, and a crashed process closes its connections. A dead holder can never wedge a lock key. This is the property that TTL-based locks in Redis have to approximate with expiry times and fencing tokens.

What is the difference between session-level and transaction-level advisory locks?

pg_advisory_lock is held until explicitly unlocked or the session ends, so it can span multiple transactions and protect work that is not database-bound. pg_advisory_xact_lock is released automatically at the end of the current transaction and cannot be unlocked early. Use the transaction-level variant when the critical section is exactly one transaction; use session-level when it is not.

Are Postgres advisory locks better than Redis distributed locks?

If your instances already share a Postgres database and lock contention is low, yes: you add zero infrastructure and get crash-safe auto-release for free. Redis-based locks earn their place at high lock throughput, where opening a Postgres connection per acquisition would be too expensive, or when there is no shared database in the architecture.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.