[料理佳餚] C# 三種實作跨應用程式鎖定的方式

  • 1393
  • 0
  • C#
  • 2020-03-25

「鎖定」的使用場景通常是我們希望某個資源,同一個時間只有一個程序來存取它,在同一個應用程式中我們有 lock 陳述式Monitor.EnterMonitor.TryEnter 可以用來做鎖定,當有一個以上的應用程式,甚至是跨不同機器的應用程式要做鎖定時,會需要用到額外的資源,我們儘量利用我們手邊有的資源來做這件事。

做鎖定主要是下圖內紅色的程序,而其他的程序就看我們程式邏輯的上下文去做調整,不過大概也是差不多這樣的流程。

檔案

利用檔案系統來做跨應用程式鎖定是簡單又容易,檔案系統的可靠度高,不需要網路的時候我們用本機磁碟就可以做了,如果需要網路我們可以建共享目錄來做,彈性很大,資源取得也方便。

嘗試取得鎖

取得鎖的方式是指定一個檔案路徑,開啟並獨占成功之後,回傳一個自定義的 FileLocker 物件,失敗就回傳 null。

public static ILocker GetLocker(string key)
{
    FileStream fs = null;

    try
    {
        fs = new FileStream($@"\\fileserver\test\{key}.lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);

        return new FileLocker(fs);
    }
    catch (Exception ex)
    {
        if (fs != null)
        {
            fs.Close();
            fs.Dispose();
        }
    }

    return null;
}

釋放鎖

FileLocker 實作一個也是自定義的 ILocker 介面,裡面有定義一個 Unlock() 方法,釋放鎖就呼叫該方法,將被開啟的檔案關閉、FileStream 資源釋放。

public class FileLocker : ILocker, IDisposable
{
    private readonly FileStream fs;

    private FileLocker(FileStream fs)
    {
        this.fs = fs;
    }

    public void Unlock()
    {
        if (this.fs != null)
        {
            this.fs.Close();
            this.fs.Dispose();
        }
    }

    public void Dispose()
    {
        this.fs?.Dispose();
    }
}

資料庫

另外一個手邊常有的資源是資料庫,資料庫也可以拿來實作跨應用程式鎖定,相較於檔案系統做法稍微複雜一些,而且資料庫的資源是比較昂貴的,環境允不允許拿資料庫來做鎖定?事前需要多評估一下。

嘗試取得鎖

以 SQL Server 為例,我執行一段 SQL 語句,先新增一筆 Key,然後再把它刪掉,並且開始一個交易處理,為了避免 Query Optimizer 升級鎖定層級,加上 WITH (ROWLOCK) 提示將鎖定層級限定在資料列,而且用 SET LOCK_TIMEOUT 陳述式將等待鎖被釋放的 Timeout 時間設為 10 毫秒,這背後的意義趨近於沒有立即取得鎖就是取得失敗的意思,當成功取得鎖之後回傳一個自定義的 SqlServerLocker 物件。

public static ILocker GetLocker(string key)
{
    var db = new SqlConnection("...");

    SqlTransaction trans = null;

    try
    {
        db.Open();

        trans = db.BeginTransaction();

        var sql = @"
SET LOCK_TIMEOUT 10;

INSERT INTO Locking WITH (ROWLOCK) ([Key]) VALUES(@Key);

DELETE FROM Locking WITH (ROWLOCK) WHERE [Key] = @Key;";

        // need 'Dapper' library
        db.Execute(sql, new { Key = key }, trans);

        return new SqlServerLocker(db, trans);
    }
    catch (Exception ex)
    {
        if (trans != null)
        {
            trans.Rollback();
            trans.Dispose();
        }

        db.Close();
        db.Dispose();
    }

    return null;
}

釋放鎖

呼叫 Unlock() 釋放鎖時,就將交易 Commit、資料庫連線關閉,也同時釋放資源。

public class SqlServerLocker : ILocker, IDisposable
{
    private readonly SqlConnection db;
    private readonly SqlTransaction trans;

    private SqlServerLocker(SqlConnection db, SqlTransaction trans)
    {
        this.db = db;
        this.trans = trans;
    }

    public void Unlock()
    {
        if (this.trans != null)
        {
            this.trans.Commit();
            this.trans.Dispose();
        }

        if (this.db != null)
        {
            this.db.Close();
            this.db.Dispose();
        }
    }

    public void Dispose()
    {
        this.trans?.Dispose();
        this.db?.Dispose();
    }
}

Cache

再來,各位朋友所處的工作環境如果有架設 Redis 的話,也可以拿來實作跨應用程式鎖定,但它又更複雜一些了,因為 KEY 有過期的問題,必須要額外處理。

嘗試取得鎖

我們在 SET KEY 的時候指定 When.NotExists 參數,這樣第一個寫入成功的程序會得到 true 的結果,在 KEY 過期之前其他程序 SET KEY 都會得到 false 的結果,利用這個特性就可以做到鎖定,而鎖定成功之後就回傳自定義的 RedisLocker 物件。

public static ILocker GetLocker(string key)
{
    try
    {
        var db = RedisHelper.Instance.Connection.GetDatabase(1);

        // need 'StackExchange.Redis' library
        if (db.StringSet(key, Guid.NewGuid().ToString(), TimeSpan.FromMinutes(1), When.NotExists))
        {
            return new RedisLocker(key, db);
        }
    }
    catch
    {
        // ignored
    }

    return null;
}

釋放鎖

如同剛剛說的,KEY 有過期的問題,萬一在 KEY 過期之後原本拿到鎖的程序工作還沒執行完,就有可能出現併發執行的情形,因此為了避免這個狀況,可以採取 KEY 快過期前延長過期時間的策略,所以 RedisLocker 裡面有一個自帶的 System.Timers.Timer 來負責這項工作,呼叫 Unlock() 釋放鎖的時候,就把 Timer 停掉、把 KEY 刪除。

public class RedisLocker : ILocker, IDisposable
{
    private readonly string key;
    private readonly IDatabase db;
    private readonly Timer timer;

    private RedisLocker(string key, IDatabase db)
    {
        this.key = key;
        this.db = db;

        this.timer = new Timer(5700);
        this.timer.Elapsed += (sender, args) =>
            {
                this.db.KeyExpire(this.key, TimeSpan.FromMinutes(1));
            };

        this.timer.Start();
    }

    public void Unlock()
    {
        this.timer.Stop();
        this.timer.Dispose();

        this.db?.KeyDelete(this.key);
    }

    public void Dispose()
    {
        this.timer?.Dispose();
    }
}

以上,三種做跨應用程式鎖定的方法分享給各位朋友,都是手邊比較容易取得的既有資源,如果沒有什麼疑慮,我個人是推薦優先使用檔案系統,不僅資源取得容易,實作起來也簡單,可靠度相對也高。

參考資料

C# 指南 ASP.NET 教學 ASP.NET MVC 指引
Azure SQL Database 教學 SQL Server 教學 Xamarin.Forms 教學