Distributed Locking With Postgres Advisory Locks in .NET

databasedistributed-systemsdotnetpostgresql
Distributed Locking With Postgres Advisory Locks in .NET

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 Postgres is already in the architecture, and it has a built-in lock manager.

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 is held in Postgres, not in process memory.
  • It auto-releases when the holding connection closes. When a pod crashes or a deploy kills it, the OS closes its connections and Postgres frees the lock immediately. A network partition or a lost node never closes the connection cleanly, so Postgres frees the lock only once TCP keepalives detect the dead peer (over two hours with default Linux keepalive settings). There is no lease to renew.

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:

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 is tied to 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. Each held lock pins a connection for the duration of the critical section. Keep critical sections short, or you will run 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, and even if that command fails, Postgres releases every advisory lock a session holds when the session ends. With default pooling, disposing the connection returns the session to the pool instead of ending it, so a lock left by a failed unlock stays held until Npgsql resets the connection with DISCARD ALL on its next use, or the pool closes it as idle. Add Pooling=false to the lock's connection string if you want disposal to end the session immediately.

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. Fail-open versus fail-closed is a per-call-site decision about blast radius. The class above hardcodes fail-open, so give AcquireAsync an option to rethrow instead of returning the no-op handle when a call site needs fail-closed.

When Is Postgres the Wrong Lock?

Reach for something else in these cases:

  • 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.

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 only guarantees mutual exclusion, so you can use advisory locks as a distributed lock for any work, 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 process can never wedge a lock key, but a lost node or a network partition delays the release until TCP keepalives detect the dead connection. 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 make sense 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.

  • Why Postgres Ignores Your Index (Sargability, Taught by a Kata)

    Your query is correct and your tests pass, but the index you created is never touched. The usual culprit is wrapping the column in a function. Here is what sargability means, how to catch the problem with EXPLAIN, and how I built a coding kata that grades your query plan.

  • Dapper in .NET: A Practical Guide

    Dapper runs exactly the SQL you write and maps the results to objects, with nothing else in between. This guide covers the patterns you need in a real application: queries, parameters, multi-mapping, QueryMultiple, transactions, and where Dapper fits next to EF Core.

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.