Skip to content

.Net 的 SQL Optimistic Lock ​

@Georg Bommeli

最近又碰到並行的問題,資料改完以後結果跟預期的不一樣,而且看得出來有兩支以上的應用程式同時在動這筆資料,但要抓出到底是哪一支的行為有衝突真的很難,這種狀況用 optimistic lock 就可以解決。

什麼是 Optimistic Lock,以及怎麼在 SQL Database 中實作 ​

Optimistic lock 是資料庫裡用來維持資料一致性很常見的一種手法,前提是資料通常不會有人同時改,所以存取之前不先鎖起來,等到要更新的時候才確認一致性。
最基本的作法就是幫每一筆資料掛上一個版本識別碼,要更新的時候先確認版本跟自己預期的一樣,像這樣:

SQL
DO $$
DECLARE TARGET_Id integer;
DECLARE X_MIN xid;
BEGIN
    SELECT id,xmin into TARGET_Id,X_MIN from table;

    update table
    set value = 'change_value'
    where id = TARGET_Id and xmin = X_MIN;
END $$;

xmin 是 PostgreSQL 裡一個特殊的值,代表 row 的版本,上面這段範例只要其他應用程式先動過目標 row,就不會更新到任何資料。

用 EFCore 在 .Net 中實作 ​

開始之前先再介紹一次 xmin。PostgreSQL 裡每一筆 row 都有自己的 metadata,而 xmin 這個值代表 row 的 transaction ID,只要 row 一更新就會跟著變動,拿來做 optimistic lock 特別合適。

用 EFCore 實作 optimistic lock 很單純,先把 DbContext 的 model 設定好:

C#
public class LockedExampleRow
{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;
    //// 用 uint 對應 xmin
    public uint RowVersion { get; set; }
}

public class PostgresTestDbContext(string connectionString) : DbContext
{
    public DbSet<LockedExampleRow> LockedExampleRow { get; set; }
    
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseNpgsql(connectionString);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<LockedExampleRow>(e =>
        {
            e.ToTable("locked_example_row");
            e.Property(p => p.Id)
                .HasColumnName("id")
                .HasDefaultValueSql("gen_random_uuid()");
            
            e.Property(p => p.Name)
                .HasColumnName("name");

            //// 設定 RowVersion 屬性對應到 xmin
            e.Property(p => p.RowVersion)
                .IsRowVersion();
        });
    }
}

用 EFCore 查詢資料的時候,PostgresTestDbContext 就會把 xmin 的值快取起來,之後呼叫 SaveChangesAsync() 更新資料,EFCore 會自動幫忙加上 row version 的檢查。

用 Dapper 在 .Net 中實作 ​

換成 Dapper 就有點麻煩了,直接看範例:

C#
await using var connection = new NpgsqlConnection(fixture.GetConnectionString());

const string findByIdSql = "SELECT id, name, xmin AS RowVersion FROM example_row WHERE id = @Id";
var row = (await connection.QueryFirstOrDefaultAsync<LockedExampleRow>(findByIdSql, new { Id = id }))!;

const string updateWithLockSql = "UPDATE example_row SET name = @Name WHERE id = @Id AND xmin = @RowVersion";
affectRowCount = await connection.ExecuteAsync(updateWithLockSql, new { Name = "expect concurrency", Id = id, RowVersion = (int)row.RowVersion });

這段就是用 Dapper 做 optimistic lock 的樣子,不過 Dapper 沒有直接支援 uint 型別,所以 RowVersion 這個屬性得改用 int,另外 xmin 的值也要自己管理,更新的時候再帶進去當條件。

End ​

Optimistic lock 處理並行問題滿有效的,大部分的情境這樣就夠用了,PostgreSQL 跟 MSSQL 兩種案例的測試範例我都放在 repo 裡了。下次來分享改用 Rust 實作的時候遇到了哪些麻煩。

最後更新:

以 VitePress 建置