[ASP.NET Core] 設置EfCore隔離等級,避免資料因為更新而lock造成排隊、Timeout的問題

在對資料庫查詢資料時,若資料正好被異動中,導致lock無法存取,往往都會導致許多的Query卡住無法消耗,使用端又會無限等待直到Timeout。
Goole了一陣子,大多數的做法都是添加 DbCommandInterceptor,攔截語法替換並加上 WITH(NOLICK),一旦遇上了較複雜的語法,替換完的查詢語法就會容易出現問題。

DbCommandInterceptor

若拿關鍵字 “EfCore With Lock” 去 Google,應該大多都是這個做法,透過正則表達式替換產生出來的SQL語法,在後面加上 With(NOLOCK)

1. 新增繼承DbCommandInterceptor的攔截器

public class WithNoLockDbCommandInterceptor : DbCommandInterceptor
{
    private static readonly Regex TableAliasRegex =
        new Regex(@"(?<tableAlias>AS \[[a-zA-Z]\w*\](?! WITH \(NOLOCK\)))",
                RegexOptions.Multiline | RegexOptions.Compiled | RegexOptions.IgnoreCase);

    public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
    {
        command.CommandText = ReplaceCommandText(command);
        return result;
    }

    public override Task<InterceptionResult<DbDataReader>> ReaderExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = new CancellationToken())
    {
        command.CommandText = ReplaceCommandText(command);
        return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
    }

    private static string ReplaceCommandText(IDbCommand command)
    {
        return TableAliasRegex.Replace(command.CommandText, "${tableAlias} WITH (NOLOCK)");
    }
}

從Startup為DB添加攔截器

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<TestDb>(builder =>
    {
        builder.UseSqlServer(Configuration.GetConnectionString("TestDb"))
            .AddInterceptors(new WithNoLockDbCommandInterceptor());
    });
}

簡單的 SQL 查詢

public List<Member> Get()
{
    return _db.Member.ToList();
}
public class Member
{
    public int Id { get; set; }

    public string Name { get; set; }

    public IEnumerable<MemberFile> Files { get; set; }
}

未加上攔截器的執行結果

執行後可以在Console看到EF產生的SQL(需要調整appsetting.json的LogLevel部分)

SELECT [m].[Id], [m].[Name]
FROM [dbo].[Member] AS [m]

加上攔截器的執行結果

結果看起來沒有問題,也可以正常的查詢DB

SELECT [m].[Id], [m].[Name]
FROM [dbo].[Member] AS [m] WITH (NOLOCK)

稍微複雜的查詢

加上了一個一對多的Table,並且join查詢

public List<MemberFile> Query()
{
    var member = _db.Member.Include(r => r.Files).First(r => r.Id == 1);
    return member.Files.ToList();
}
public class MemberFile
{
    public int Id { get; set; }

    public string Name { get; set; }

    public int MemberId { get; set; }

    [JsonIgnore]
    public Member Member { get; set; }
}

未加上攔截器的結果

SELECT [t].[Id], [t].[Name], [m0].[Id], [m0].[MemberId], [m0].[Name]
    FROM (
        SELECT TOP(1) [m].[Id], [m].[Name]
        FROM [dbo].[Member] AS [m]
        WHERE [m].[Id] = 1
    ) AS [t]
    LEFT JOIN [dbo].[MemberFile] AS [m0] ON [t].[Id] = [m0].[MemberId]
    ORDER BY [t].[Id], [m0].[Id]

加上攔截器的執行結果

這次產生出來的結果就造成了Exception,無法正常的查詢

SELECT [t].[Id], [t].[Name], [m0].[Id], [m0].[MemberId], [m0].[Name]
    FROM (
        SELECT TOP(1) [m].[Id], [m].[Name]
        FROM [dbo].[Member] AS [m] WITH (NOLOCK)
        WHERE [m].[Id] = 1
    ) AS [t] WITH (NOLOCK)
    LEFT JOIN [dbo].[MemberFile] AS [m0] WITH (NOLOCK) ON [t].[Id] = [m0].[MemberId]
    ORDER BY [t].[Id], [m0].[Id]

結論

簡單的查詢用這個方式是可以正常執行的,但若要執行複雜的查詢,就很有可能需要再調整攔截器裡替換的方式,考慮到EF可能會產生的各種神語法,我決定跳過這一題,尋覓其他可行方案

2. IsolationLevel(交易隔離等級)

SQL Server 可以透過設定隔離等級來讓查詢可以在資料被Lock的情況下中途讀取內容,相當於 With(NOLOCK)。

隔離等級有幾種,各個差異如下(預設為ReadCommitted):

隔離等級 中途讀取
ReadUncommitted O
ReadCommitted X
RepeatableRead X
Serializable X
Snapshot X

未指定交易隔離等級

先試著維持原本的程式碼,未加上攔截器,並且故意製造一個未完成的交易

begin transaction
update Member set Name = 'test' where id = 1;

接著透過啟用 DBCC TRACEON(3604) 查詢DB資訊,確認到有一筆update的交易進行中

接著執行對Member的查詢,再確認一次 DBCC TRACEON(3604),會看到有正在 Wait的資料

而查詢方也是理所當然的timeout了

指定交易隔離等級

開啟交易,並且指定隔離等級為 ReadUncommitted

public List<MemberFile> Query()
{
    using (_db.Database.BeginTransaction(IsolationLevel.ReadUncommitted))
    {
        return _db.Member.ToList();
    }
}

重複上面做的事情

執行查詢,可以看到查詢到的是未完成的交易更改的值

接著把剛才的交易 rollback

rollback  transaction

再重新查詢一次,就拿到了rollback後的值

結論

透過指定交易隔離等級的方式可以更容易的避免Lock導致查詢失敗等問題,更換資料庫時也不用為語法煩惱


好在不久前上了SkillTree的SQL Server相關課程,剛好彌補了對DB Lock知識的不足,在處理這部分問題時順暢了許多