Khóa Phân Tán trong .NET: Điều Phối Công Việc Trên Nhiều Instance

Thời gian đọc: 5 phút · Ngày 20 tháng 9, 2025

Khi bạn xây dựng ứng dụng chạy trên nhiều server hoặc tiến trình khác nhau, bạn sẽ gặp phải vấn đề truy cập đồng thời. Nhiều worker cố gắng cập nhật cùng một tài nguyên cùng lúc, và bạn sẽ gặp phải điều kiện tranh chấp (race conditions), công việc trùng lặp hoặc dữ liệu bị hỏng.

.NET cung cấp các nguyên thủy kiểm soát đồng thời tuyệt vời cho các kịch bản single-process, như lock, SemaphoreSlim, và Mutex. Nhưng khi ứng dụng của bạn được mở rộng (scale out) trên nhiều instance, các nguyên thủy này không còn hoạt động nữa.

Đó là lúc khóa phân tán (distributed locking) xuất hiện.

Khóa phân tán cung cấp giải pháp bằng cách đảm bảo chỉ một node (application instance) có thể truy cập một vùng mã quan trọng tại một thời điểm, ngăn chặn điều kiện tranh chấp và duy trì tính nhất quán dữ liệu trên toàn hệ thống phân tán của bạn.

Tại Sao và Khi Nào Bạn Cần Khóa Phân Tán

Trong ứng dụng single-process, bạn có thể chỉ cần sử dụng lock hoặc class Lock mới trong .NET 10. Nhưng một khi bạn mở rộng (scale out), điều đó là không đủ, bởi vì mỗi tiến trình có không gian bộ nhớ riêng của nó.

Một vài trường hợp phổ biến nơi khóa phân tán có giá trị:

  • Công việc nền (Background jobs): đảm bảo chỉ một worker xử lý một công việc hoặc tài nguyên cụ thể tại một thời điểm.
  • Bầu chọn leader (Leader election): chọn một tiến trình duy nhất để thực hiện công việc định kỳ (như áp dụng các projection database không đồng bộ).
  • Tránh thực thi trùng lặp: đảm bảo các tác vụ được lên lịch không chạy nhiều lần khi triển khai trên nhiều instance.
  • Điều phối tài nguyên chia sẻ: ví dụ, chỉ một service instance thực hiện migration hoặc cleanup tại một thời điểm.
  • Phòng ngừa cache stampede: đảm bảo chỉ một instance làm mới cache khi một cache key hết hạn.

Giá trị chính: tính nhất quán và an toàn trên môi trường phân tán. Không có điều này, bạn có nguy cơ thao tác trùng lặp, trạng thái bị hỏng hoặc tải không cần thiết.

Bây giờ bạn đã biết tại sao khóa phân tán là quan trọng. Hãy xem xét một số tùy chọn triển khai.

Khóa Phân Tán Tự Làm Với PostgreSQL Advisory Locks

Hãy bắt đầu đơn giản. PostgreSQL có một tính năng gọi là advisory locks hoàn hảo cho khóa phân tán. Không giống như khóa bảng, những khóa này không can thiệp vào dữ liệu của bạn – chúng chỉ dùng để điều phối.

Đây là một ví dụ:

public class NightlyReportService(NpgsqlDataSource dataSource)
{
    public async Task ProcessNightlyReport()
    {
        await using var connection = dataSource.OpenConnection();

        var key = HashKey("nightly-report");

        var acquired = await connection.ExecuteScalarAsync<bool>(
            "SELECT pg_try_advisory_lock(@key)",
            new { key });

        if (!acquired)
        {
            throw new ConflictException("Another instance is already processing the nightly report");
        }

        try
        {
            await DoWork();
        }
        finally
        {
            await connection.ExecuteAsync(
                "SELECT pg_advisory_unlock(@key)",
                new { key });
        }
    }

    private static long HashKey(string key) =>
        BitConverter.ToInt64(SHA256.HashData(Encoding.UTF8.GetBytes(key)), 0);

    private static Task DoWork() => Task.Delay(5000); // Your actual work here
}

Đây là những gì xảy ra bên dưới.

Đầu tiên, chúng ta chuyển đổi tên khóa của chúng ta thành một số. PostgreSQL advisory locks cần khóa số, vì vậy chúng ta băm nightly-report thành một số nguyên 64-bit. Mọi node (application instance) phải tạo ra cùng một số cho cùng một chuỗi, nếu không điều này sẽ không hoạt động.

Tiếp theo, pg_try_advisory_lock() cố gắng lấy một khóa độc quyền trên số đó. Nó trả về true nếu thành công, false nếu một kết nối khác đã giữ nó. Lệnh này không block – nó cho bạn biết ngay lập tức liệu bạn có lấy được khóa hay không.

Nếu chúng ta lấy được khóa, chúng ta thực hiện công việc của mình. Nếu không, chúng ta trả về phản hồi xung đột và để instance khác xử lý nó.

Khối finally đảm bảo chúng ta luôn giải phóng khóa, ngay cả khi có điều gì đó xảy ra. PostgreSQL cũng tự động giải phóng advisory locks khi các kết nối đóng, đó là một mạng lưới an toàn tốt.

SQL Server có một tính năng tương tự với sp_getapplock.

Khám Phá Thư Viện DistributedLock

Trong khi cách tiếp cận tự làm hoạt động, các ứng dụng production cần các tính năng tinh vi hơn. Thư viện DistributedLock xử lý tất cả các trường hợp biên và cung cấp nhiều tùy chọn backend (Postgres, Redis, SqlServer, v.v.). Bạn biết tôi là người hâm mộ không tái phát minh bánh xe, vì vậy đây là một lựa chọn tuyệt vời.

Cài đặt package:

Install-Package DistributedLock

Tôi sẽ sử dụng cách tiếp cận với IDistributedLockProvider hoạt động tốt với DI. Bạn có thể lấy một khóa mà không cần biết bất cứ điều gì về cơ sở hạ tầng bên dưới. Tất cả những gì bạn phải làm là đăng ký một triển khai lock provider trong DI container của bạn.

Ví dụ, sử dụng Postgres:

// Register the distributed lock provider
builder.Services.AddSingleton<IDistributedLockProvider>(
    (_) =>
    {
        return new PostgresDistributedSynchronizationProvider(
            builder.Configuration.GetConnectionString("distributed-locking")!);
    });

Hoặc nếu bạn muốn sử dụng Redis với thuật toán Redlock:

// Requires StackExchange.Redis
builder.Services.AddSingleton<IConnectionMultiplexer>(
    (_) =>
    {
        return ConnectionMultiplexer.Connect(
            builder.Configuration.GetConnectionString("redis")!);
    });

// Register the distributed lock provider
builder.Services.AddSingleton<IDistributedLockProvider>(
    (sp) =>
    {
        var connectionMultiplexer = sp.GetRequiredService<IConnectionMultiplexer>();

        return new RedisDistributedSynchronizationProvider(connectionMultiplexer.GetDatabase());
    });

Cách sử dụng rất đơn giản:

// You can also pass in a timeout, where the provider will keep retrying to acquire the lock
// until the timeout is reached.
IDistributedSynchronizationHandle? distributedLock = distributedLockProvider
    .TryAcquireLock("nightly-report");

// If we didn't get the lock, the object will be null
if (distributedLock is null)
{
    return Results.Conflict();
}

// It's important to wrap the lock in a using statement to ensure it's released properly
using (distributedLock)
{
    await DoWork();
}

Thư viện xử lý tất cả các phần phức tạp: timeouts, retries và đảm bảo các khóa được giải phóng ngay cả trong các kịch bản thất bại.

Nó cũng hỗ trợ nhiều backend (SQL Server, Azure, ZooKeeper, v.v.), làm cho nó trở thành một lựa chọn vững chắc cho các workload production.

Kết Luận

Khóa phân tán không phải là thứ bạn cần mỗi ngày. Nhưng khi bạn cần, nó sẽ cứu bạn khỏi những lỗi tinh vi, đau đớn chỉ xuất hiện dưới tải hoặc trong production.

Bắt đầu đơn giản: nếu bạn đã sử dụng Postgres, advisory locks là một công cụ mạnh mẽ.

Để có trải nghiệm nhà phát triển sạch hơn, hãy sử dụng thư viện DistributedLock.

Chọn backend phù hợp với cơ sở hạ tầng của bạn (Postgres, Redis, SQL Server, v.v.).

Khóa phù hợp tại thời điểm phù hợp đảm bảo hệ thống của bạn luôn nhất quán, đáng tin cậy và kiên cường, ngay cả trên nhiều tiến trình và server.