Cơ sở dữ liệuSQL Server, phần 11/34

Máy chủ chính chết giữa giờ cao điểm: Availability Group và thử lại trong EF Core

Availability Group đưa replica phụ lên làm chính, nhưng ứng dụng vẫn báo lỗi nếu không thử lại. Giả lập database vắng mặt 8 giây trên LocalDB, API không thử lại trả 972 lỗi kéo dài 15 giây; bật EnableRetryOnFailure của EF Core và bọc giao dịch trong execution strategy thì 0 lỗi, không mất và không trùng đơn.

Mục lục
  1. 1. Vấn đề: replica phụ đã lên, POS vẫn báo lỗi
  2. 2. Mục đích: máy chủ chết, người dùng không thấy lỗi
  3. 3. Cơ sở lý thuyết: failover xong ở server, chưa xong ở client
  4. 4. Cách giải quyết: Basic AG ở server, listener và thử lại ở client
  5. 5. Cách cài đặt: listener ở server, ba dòng cấu hình ở client
  6. 6. Chứng minh: 972 lỗi thành 0, không mất và không trùng đơn
  7. 7. Kết luận
  8. Đọc tiếp
  9. Nguồn

Đọc nhanh

  • Vấn đề: Máy chủ chính của BanHang chết lúc cao điểm; replica phụ lên làm chính sau vài giây, nhưng API vẫn trả lỗi cho POS lâu hơn cả lúc database vắng mặt.
  • Cách giải: Basic Availability Group hai replica đồng bộ, ứng dụng trỏ vào listener với MultiSubnetFailover=True, EF Core bật EnableRetryOnFailure và chạy mỗi giao dịch trong execution strategy.
  • Chứng minh: Database vắng mặt khoảng 8 giây: không thử lại có 972 yêu cầu lỗi rải trong 15,1 giây; có thử lại 0 lỗi, yêu cầu chậm nhất 19,4 giây, số đơn trong bảng bằng đúng số yêu cầu báo thành công.
  • Trong .NET: UseSqlServer(cs, o => o.EnableRetryOnFailure(6, TimeSpan.FromSeconds(5), null)) và db.Database.CreateExecutionStrategy().ExecuteAsync(...) bọc BeginTransactionAsync.

1. Vấn đề: replica phụ đã lên, POS vẫn báo lỗi

Lúc 10:32 ngày 2026-10-02, giữa giờ cao điểm khoảng 300 request mỗi giây, máy chủ chính của BanHang mất nguồn. Availability Group đưa replica phụ đồng bộ lên làm chính. Phía database coi như đã xong việc.

Phía ứng dụng thì chưa. Mọi request đang ghi đơn nhận lỗi đứt kết nối. Request mới nhận lỗi đăng nhập, và vẫn nhận lỗi đó vài giây sau khi database mới đã sẵn sàng. Thu ngân ở 120 cửa hàng thấy "Không lưu được đơn", bấm lại, và một số đơn không rõ đã lưu hay chưa.

Dựng lại trên LocalDB: 4 luồng ghi đơn liên tục, giây thứ 10 database bị đặt OFFLINE trong 5 giây rồi ONLINE lại. Lệnh OFFLINE cộng ONLINE làm database vắng mặt từ giây 10,0 đến giây 18,2–19,0.

Đo, API không thử lại Ba lần chạy
Yêu cầu ghi đơn báo lỗi 972, 972, 971, đều là lỗi 4060
Khoảng có lỗi Giây 10,1 đến 25,2, cả ba lần

Lỗi còn kéo dài 7 giây sau khi database đã trở lại.

2. Mục đích: máy chủ chết, người dùng không thấy lỗi

  • Database vắng mặt khoảng 8 giây: 0 yêu cầu ghi đơn báo lỗi cho người dùng.
  • Không mất, không trùng: số đơn trong bảng bằng đúng số yêu cầu báo thành công.
  • Thời gian chờ của yêu cầu chậm nhất được đo, và có trần do cấu hình thử lại quyết định.
  • Ngoài phạm vi: dựng Availability Group thật và đo thời gian failover của server, việc LocalDB không làm được; mất dữ liệu ở chế độ bất đồng bộ.

3. Cơ sở lý thuyết: failover xong ở server, chưa xong ở client

Availability Group chuyển vai trò

Replica chính gửi log record sang replica phụ, replica phụ redo log đó. Ở chế độ đồng bộ, commit chờ replica phụ ghi log xuống đĩa rồi mới trả về, nên chuyển vai trò không mất giao dịch đã commit. Failover tự động cần replica phụ đồng bộ và đang ở trạng thái synchronized.

Listener là một tên mạng có một địa chỉ IP cho mỗi subnet, luôn trỏ vào replica đang làm chính. Ứng dụng trỏ vào listener thì không cần biết máy nào đang chính.

Client thấy gì trong lúc chuyển

Ba chuyện xảy ra ở phía client, và không chuyện nào tự hết khi server đã xong:

  1. Giao dịch đang chạy mất cùng kết nối. Tài liệu SqlClient ghi rõ thư viện không giữ được giao dịch dở qua một kết nối đứt; ứng dụng phải chạy lại cả đơn vị công việc.
  2. Kết nối mới thất bại cho tới khi database trên replica mới mở xong. Bản giả lập trả lỗi 4060, "Cannot open database requested by the login".
  3. Sau một lần đăng nhập thất bại, pool của SqlClient vào thời gian chặn: mọi lần mở kết nối cùng pool ném lại đúng lỗi cũ mà không thử đăng nhập. Thời gian chặn đầu là 5 giây, mỗi lần thất bại tiếp theo gấp đôi, tối đa 1 phút. Mặc định Auto bật chặn với SQL Server thường. Đó là 7 giây lỗi thừa ở mục 1.

Listener nằm trên hai subnet có hai IP, nhưng chỉ IP của subnet đang chính hoạt động. MultiSubnetFailover=True cho client thử song song mọi IP và dùng kết nối đầu tiên thành công, thay vì chờ IP chết hết thời gian chờ. Tài liệu nói rõ nó không rút ngắn thời gian failover hay recovery của server.

Execution strategy của EF Core

EnableRetryOnFailure thay execution strategy mặc định bằng SqlServerRetryingExecutionStrategy. Gặp lỗi nằm trong danh sách tạm thời, như 4060, 233, 10054, 921 hay 1205, nó chờ theo cấp số nhân có nhiễu, tối đa maxRetryDelay, rồi chạy lại. Lỗi timeout -2 không nằm trong danh sách.

Strategy chạy lại cả một khối, nên giao dịch tự mở phải nằm trong khối đó. Mở giao dịch ngoài strategy rồi truy vấn, EF Core từ chối ngay:

InvalidOperationException: The configured execution strategy 'SqlServerRetryingExecutionStrategy' does not support user-initiated transactions. Use the execution strategy returned by 'DbContext.Database.CreateExecutionStrategy()' to execute all the operations in the transaction as a retriable unit.

Còn một trường hợp thử lại không tự xử lý: kết nối đứt sau khi commit đã tới server. Client nhận lỗi, chạy lại, và ghi lần hai. Khóa chống trùng ở phía nghiệp vụ mới chặn được việc đó.

sequenceDiagram
  participant A as API BanHang
  participant L as Listener BanHang-lsn
  participant P as Replica chính cũ
  participant S as Replica mới
  A->>L: BEGIN, INSERT đơn
  L->>P: chuyển tới replica chính
  P--xA: máy chết, kết nối đứt
  Note over S: replica phụ lên làm chính
  A->>L: thử lại, chờ 1 s, 2 s, 4 s...
  L->>S: listener trỏ sang replica mới
  S-->>A: COMMIT thành công

4. Cách giải quyết: Basic AG ở server, listener và thử lại ở client

Phía server, chọn theo edition và số phút được phép mất:

Cách Edition SQL Server 2019 Dữ liệu Máy chính hỏng thì sao
Full và log backup Mọi edition; Express không có SQL Server Agent để lập lịch Một bản Restore; RPO bằng chu kỳ log, RTO bằng thời gian restore
Log shipping Enterprise, Standard, Web Bản log restore liên tục sang máy phụ Mở máy phụ sau khi áp nốt log, chuyển tay
Failover cluster instance Enterprise, Standard (2 nút) Một bản trên đĩa dùng chung Tiến trình SQL chuyển nút; hỏng đĩa chung thì vẫn phải restore
Basic Availability Group Standard Hai replica, một database Tự chuyển nếu đồng bộ; replica phụ không đọc được, không backup trên đó
Availability Group Enterprise Nhiều database, tới 8 replica phụ Tự chuyển nếu đồng bộ; replica phụ đọc được nếu cấu hình
flowchart TD
  Q1["Restore full và log có kịp RTO?"]
  Q1 -->|Kịp| R1["Full + log backup"]
  Q1 -->|Không| Q2["Cần báo cáo đọc trên máy phụ?"]
  Q2 -->|Có| R2["Availability Group, Enterprise"]
  Q2 -->|Không| Q3["Cần tự chuyển khi máy chính hỏng?"]
  Q3 -->|Có| R3["Basic Availability Group, Standard"]
  Q3 -->|Không| R4["Log shipping"]
  R2 --> K["Vẫn chạy chuỗi full, differential, log backup"]
  R3 --> K
  R4 --> K

BanHang là một database trên Standard, cần tự chuyển, chưa cần báo cáo trên máy phụ: Basic Availability Group hai replica, đồng bộ, failover tự động. Phía client:

Cách Ưu Nhược Khi nào
Trỏ thẳng tên máy chính Đơn giản Đổi cấu hình mỗi lần failover Không dùng với AG
Listener, không thử lại Tự theo replica chính Mọi request trong lúc chuyển và thời gian chặn của pool đều lỗi Không đủ
Listener, MultiSubnetFailover, EnableRetryOnFailure Người dùng không thấy lỗi tạm thời Request chờ lâu hơn; ghi có thể lặp nếu mất câu trả lời sau commit Mọi API ghi của BanHang

Các bước:

  1. Tạo Basic AG cho BanHang và một listener có IP ở cả hai subnet.
  2. Chuỗi kết nối trỏ vào listener, có MultiSubnetFailover=True.
  3. Bật EnableRetryOnFailure, bọc mọi BeginTransactionAsync trong CreateExecutionStrategy().ExecuteAsync.
  4. Lệnh ghi tiền có khóa chống trùng, để lần chạy lại sau commit không trừ hai lần.

5. Cách cài đặt: listener ở server, ba dòng cấu hình ở client

Basic AG và listener, viết theo cú pháp trong tài liệu. Đoạn này chưa chạy thử: LocalDB không có Availability Group, và AG trên Windows cần một cluster.

CREATE AVAILABILITY GROUP [AG_BanHang]
WITH (AUTOMATED_BACKUP_PREFERENCE = PRIMARY, BASIC, DB_FAILOVER = OFF, DTC_SUPPORT = NONE)
FOR DATABASE [BanHang]
REPLICA ON
    N'SQL01' WITH (ENDPOINT_URL = N'TCP://sql01.banhang.local:5022', FAILOVER_MODE = AUTOMATIC,
        AVAILABILITY_MODE = SYNCHRONOUS_COMMIT, SEEDING_MODE = AUTOMATIC, SECONDARY_ROLE (ALLOW_CONNECTIONS = NO)),
    N'SQL02' WITH (ENDPOINT_URL = N'TCP://sql02.banhang.local:5022', FAILOVER_MODE = AUTOMATIC,
        AVAILABILITY_MODE = SYNCHRONOUS_COMMIT, SEEDING_MODE = AUTOMATIC, SECONDARY_ROLE (ALLOW_CONNECTIONS = NO));

ALTER AVAILABILITY GROUP [AG_BanHang]
ADD LISTENER N'BanHang-lsn' (WITH IP ((N'10.10.1.50', N'255.255.255.0'), (N'10.10.2.50', N'255.255.255.0')), PORT = 1433);

Phía .NET, chuỗi kết nối và execution strategy:

var cs = new SqlConnectionStringBuilder
{
    DataSource = "tcp:BanHang-lsn,1433", InitialCatalog = "BanHang",
    IntegratedSecurity = true, MultiSubnetFailover = true, ConnectTimeout = 30,
}.ConnectionString;

builder.Services.AddDbContext<BanHangDb>(o => o.UseSqlServer(cs, sql =>
    sql.EnableRetryOnFailure(maxRetryCount: 6, maxRetryDelay: TimeSpan.FromSeconds(5), errorNumbersToAdd: null)));

// Trong handler ghi đơn: cả khối được chạy lại như một đơn vị.
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
    db.ChangeTracker.Clear();
    await using var tx = await db.Database.BeginTransactionAsync();
    db.DonHang.Add(donHang);
    await db.SaveChangesAsync();
    await tx.CommitAsync();
});

db.ChangeTracker.Clear() bỏ trạng thái của lần thử trước, để lần chạy lại thêm đúng một đơn. Mã lỗi khi failover thật có thể khác mã 4060 của bản giả lập; sau khi diễn tập, mã nào chưa có trong danh sách tạm thời thì thêm qua errorNumbersToAdd.

Chuỗi cho báo cáo xin replica đọc bằng ApplicationIntent=ReadOnly, chỉ có tác dụng với AG Enterprise đã cấu hình read-only routing. Đoạn dưới chỉ dựng và in chuỗi, chạy trên .NET 10.0.401 với Microsoft.Data.SqlClient 7.1.1; chưa kết nối tới listener thật.

ChuoiKetNoi.cs: hai chuỗi kết nối tới listener (dotnet run ChuoiKetNoi.cs)C# · 18 dòng
#:package Microsoft.Data.SqlClient@7.1.1
// Hai chuỗi kết nối tới listener của Availability Group: ghi vào replica chính, báo cáo xin replica đọc.
// Chỉ dựng và in chuỗi; không kết nối. Chạy: dotnet run ChuoiKetNoi.cs
using Microsoft.Data.SqlClient;

var ghi = new SqlConnectionStringBuilder
{
    DataSource = "tcp:BanHang-lsn,1433",
    InitialCatalog = "BanHang",
    IntegratedSecurity = true,
    MultiSubnetFailover = true,
    ApplicationIntent = ApplicationIntent.ReadWrite,
    ConnectTimeout = 30,
};
var baoCao = new SqlConnectionStringBuilder(ghi.ConnectionString) { ApplicationIntent = ApplicationIntent.ReadOnly };

Console.WriteLine(ghi.ConnectionString);
Console.WriteLine(baoCao.ConnectionString);
Data Source=tcp:BanHang-lsn,1433;Initial Catalog=BanHang;Integrated Security=True;Connect Timeout=30;Application Intent=ReadWrite;Multi Subnet Failover=True
Data Source=tcp:BanHang-lsn,1433;Initial Catalog=BanHang;Integrated Security=True;Connect Timeout=30;Application Intent=ReadOnly;Multi Subnet Failover=True

Bản giả lập failover chạy trên một instance LocalDB riêng, KumeoC (SQL Server 2019 15.0.4382), với Microsoft.EntityFrameworkCore.SqlServer 10.0.12. Database bị đặt OFFLINE WITH ROLLBACK IMMEDIATE thay cho máy chết, vì giết tiến trình LocalDB thì nó tự khởi động lại ngay khi có kết nối mới, không giữ được khoảng vắng mặt. Dòng #:property PublishAot=false cần vì EF Core không dựng model lúc chạy khi bật PublishAot.

ChuyenMay.cs: 4 luồng ghi đơn, database vắng mặt giữa chừng, có và không thử lại (dotnet run ChuyenMay.cs)C# · 130 dòng
#:package Microsoft.EntityFrameworkCore.SqlServer@10.0.12
#:property PublishAot=false
// Giả lập máy chủ chính mất giữa lúc ghi đơn: 4 luồng ghi đơn liên tục trong 40 giây; giây thứ 10 database
// bị đặt OFFLINE (cắt mọi phiên) trong 5 giây rồi ONLINE lại. Chạy hai lượt: không thử lại và EnableRetryOnFailure.
// Chạy: dotnet run ChuyenMay.cs   (cần instance LocalDB riêng KumeoC, database Kumeo_C có dbo.DonHang)
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;

CultureInfo.DefaultThreadCurrentCulture = CultureInfo.CurrentCulture = new CultureInfo("vi-VN");
const string Cs = @"Server=(localdb)\KumeoC;Database=Kumeo_C;Integrated Security=true;TrustServerCertificate=true;Connect Timeout=5";
var cheDo = args.Length > 0 ? args[0] : "ca-hai";

// 0. Giao dịch tự mở khi đã bật EnableRetryOnFailure.
await using (var db = new BanHangDb(CoThuLai(true)))
{
    try
    {
        await using var tx = await db.Database.BeginTransactionAsync();
        await db.DonHang.CountAsync();
    }
    catch (InvalidOperationException ex) { Console.WriteLine($"{ex.GetType().Name}: {ex.Message}\n"); }
}

long soDon = 0;
var luot = 0;
if (cheDo is "ca-hai" or "khong") await ChayLuot("Không thử lại", CoThuLai(false));
if (cheDo is "ca-hai" or "co") await ChayLuot("EnableRetryOnFailure", CoThuLai(true));

async Task ChayLuot(string ten, DbContextOptions<BanHangDb> opt)
{
    var dauDai = 90_000_000L + (Environment.TickCount64 % 100) * 1_000_000 + ++luot * 100_000;
    soDon = dauDai;
    var ketQua = new ConcurrentBag<(double giay, double ms, string kq)>();
    var dongHo = Stopwatch.StartNew();
    using var dung = new CancellationTokenSource(TimeSpan.FromSeconds(40));
    var giet = Task.Run(async () =>
    {
        await Task.Delay(TimeSpan.FromSeconds(10));
        Console.WriteLine($"[{dongHo.Elapsed.TotalSeconds:N1} s] Kumeo_C OFFLINE, cắt mọi phiên đang mở");
        await QuanTri("ALTER DATABASE Kumeo_C SET OFFLINE WITH ROLLBACK IMMEDIATE;");
        await Task.Delay(TimeSpan.FromSeconds(5)); // database vắng mặt 5 giây, như lúc chuyển sang máy phụ
        await QuanTri("ALTER DATABASE Kumeo_C SET ONLINE;");
        Console.WriteLine($"[{dongHo.Elapsed.TotalSeconds:N1} s] Kumeo_C ONLINE");
    });
    var luong = Enumerable.Range(0, 4).Select(_ => Task.Run(async () =>
    {
        while (!dung.IsCancellationRequested)
        {
            var id = Interlocked.Increment(ref soDon);
            var batDau = dongHo.Elapsed;
            string kq;
            try
            {
                await using var db = new BanHangDb(opt);
                var strategy = db.Database.CreateExecutionStrategy();
                await strategy.ExecuteAsync(async () =>
                {
                    db.ChangeTracker.Clear();
                    await using var tx = await db.Database.BeginTransactionAsync();
                    db.DonHang.Add(new DonHang { DonHangId = id, NgayTao = DateTime.UtcNow.AddHours(7), KhachHangId = 42, TrangThai = 1, TongTien = 1_500_000m });
                    await db.SaveChangesAsync();
                    await tx.CommitAsync();
                });
                kq = "ok";
            }
            catch (Exception ex)
            {
                var sql = ex as SqlException ?? ex.InnerException as SqlException ?? ex.InnerException?.InnerException as SqlException;
                kq = sql is not null ? $"SqlException {sql.Number}" : ex.GetType().Name;
            }
            ketQua.Add((batDau.TotalSeconds, (dongHo.Elapsed - batDau).TotalMilliseconds, kq));
            await Task.Delay(50);
        }
    })).ToArray();
    await Task.WhenAll(luong);
    await giet;

    var tatCa = ketQua.OrderBy(k => k.giay).ToList();
    var ok = tatCa.Where(k => k.kq == "ok").ToList();
    var loi = tatCa.Where(k => k.kq != "ok").ToList();
    Console.WriteLine($"{ten}: {tatCa.Count} yêu cầu, {ok.Count} thành công, {loi.Count} lỗi");
    foreach (var g in loi.GroupBy(k => k.kq)) Console.WriteLine($"  {g.Key}: {g.Count()}");
    var ms = ok.Select(k => k.ms).Order().ToList();
    Console.WriteLine($"  thời gian một yêu cầu thành công: p50 {ms[ms.Count / 2]:N0} ms, p99 {ms[(int)(ms.Count * 0.99)]:N0} ms, lớn nhất {ms[^1]:N0} ms");
    var dau = loi.Count > 0 ? loi.Min(k => k.giay) : 0; var cuoi = loi.Count > 0 ? loi.Max(k => k.giay) : 0;
    if (loi.Count > 0) Console.WriteLine($"  lỗi rơi vào khoảng giây {dau:F1} đến {cuoi:F1}");
    // Đơn có trong bảng so với số yêu cầu báo thành công
    await using var cn = new SqlConnection(Cs);
    await cn.OpenAsync();
    await using var cmd = new SqlCommand($"SELECT COUNT(*) FROM dbo.DonHang WHERE DonHangId > {dauDai} AND DonHangId <= {soDon};", cn);
    Console.WriteLine($"  đơn của lượt này có trong bảng: {await cmd.ExecuteScalarAsync()}, số yêu cầu báo thành công: {ok.Count}");
    Console.WriteLine();
    await Task.Delay(5000);
}

static async Task QuanTri(string sql)
{
    // Kết nối quản trị vào master, không dùng pool của ứng dụng.
    await using var cn = new SqlConnection(Cs.Replace("Database=Kumeo_C", "Database=master") + ";Pooling=false");
    await cn.OpenAsync();
    await new SqlCommand(sql, cn) { CommandTimeout = 120 }.ExecuteNonQueryAsync();
}

static DbContextOptions<BanHangDb> CoThuLai(bool thuLai) => new DbContextOptionsBuilder<BanHangDb>()
    .UseSqlServer(Cs, sql => { if (thuLai) sql.EnableRetryOnFailure(maxRetryCount: 6, maxRetryDelay: TimeSpan.FromSeconds(5), errorNumbersToAdd: null); })
    .Options;

class DonHang
{
    public long DonHangId { get; set; }
    public DateTime NgayTao { get; set; }
    public int KhachHangId { get; set; }
    public byte TrangThai { get; set; }
    public decimal TongTien { get; set; }
}

class BanHangDb(DbContextOptions<BanHangDb> o) : DbContext(o)
{
    public DbSet<DonHang> DonHang => Set<DonHang>();
    protected override void OnModelCreating(ModelBuilder m) => m.Entity<DonHang>(e =>
    {
        e.ToTable("DonHang", "dbo");
        e.HasKey(d => new { d.NgayTao, d.DonHangId });
        e.Property(d => d.NgayTao).HasColumnType("datetime2(0)");
        e.Property(d => d.TongTien).HasColumnType("decimal(18, 2)");
    });
}
Output của ba lần chạy ChuyenMay.cs (dòng InvalidOperationException in ở đầu mỗi lần, chỉ giữ lần đầu)43 dòng
InvalidOperationException: The configured execution strategy 'SqlServerRetryingExecutionStrategy' does not support user-initiated transactions. Use the execution strategy returned by 'DbContext.Database.CreateExecutionStrategy()' to execute all the operations in the transaction as a retriable unit.

[10,0 s] Kumeo_C OFFLINE, cắt mọi phiên đang mở
[18,3 s] Kumeo_C ONLINE
Không thử lại: 2267 yêu cầu, 1295 thành công, 972 lỗi
  SqlException 4060: 972
  thời gian một yêu cầu thành công: p50 8 ms, p99 157 ms, lớn nhất 875 ms
  lỗi rơi vào khoảng giây 10,1 đến 25,2
  đơn của lượt này có trong bảng: 1295, số yêu cầu báo thành công: 1295

[10,0 s] Kumeo_C OFFLINE, cắt mọi phiên đang mở
[18,3 s] Kumeo_C ONLINE
EnableRetryOnFailure: 1693 yêu cầu, 1693 thành công, 0 lỗi
  thời gian một yêu cầu thành công: p50 5 ms, p99 119 ms, lớn nhất 9.351 ms
  đơn của lượt này có trong bảng: 1693, số yêu cầu báo thành công: 1693

[10,0 s] Kumeo_C OFFLINE, cắt mọi phiên đang mở
[18,2 s] Kumeo_C ONLINE
Không thử lại: 2214 yêu cầu, 1242 thành công, 972 lỗi
  SqlException 4060: 972
  thời gian một yêu cầu thành công: p50 9 ms, p99 210 ms, lớn nhất 736 ms
  lỗi rơi vào khoảng giây 10,1 đến 25,2
  đơn của lượt này có trong bảng: 1242, số yêu cầu báo thành công: 1242

[10,0 s] Kumeo_C OFFLINE, cắt mọi phiên đang mở
[18,3 s] Kumeo_C ONLINE
EnableRetryOnFailure: 1038 yêu cầu, 1038 thành công, 0 lỗi
  thời gian một yêu cầu thành công: p50 7 ms, p99 184 ms, lớn nhất 20.048 ms
  đơn của lượt này có trong bảng: 1038, số yêu cầu báo thành công: 1038

[10,0 s] Kumeo_C OFFLINE, cắt mọi phiên đang mở
[18,2 s] Kumeo_C ONLINE
Không thử lại: 2329 yêu cầu, 1358 thành công, 971 lỗi
  SqlException 4060: 971
  thời gian một yêu cầu thành công: p50 7 ms, p99 177 ms, lớn nhất 598 ms
  lỗi rơi vào khoảng giây 10,1 đến 25,2
  đơn của lượt này có trong bảng: 1358, số yêu cầu báo thành công: 1358

[10,0 s] Kumeo_C OFFLINE, cắt mọi phiên đang mở
[19,0 s] Kumeo_C ONLINE
EnableRetryOnFailure: 1142 yêu cầu, 1142 thành công, 0 lỗi
  thời gian một yêu cầu thành công: p50 9 ms, p99 130 ms, lớn nhất 19.404 ms
  đơn của lượt này có trong bảng: 1142, số yêu cầu báo thành công: 1142

6. Chứng minh: 972 lỗi thành 0, không mất và không trùng đơn

Môi trường: instance LocalDB riêng SQL Server 2019 Express 15.0.4382, .NET 10.0.401, EF Core 10.0.12, máy Windows 11 dùng chung. Ba lần chạy, mỗi lần một lượt không thử lại và một lượt có thử lại, mỗi lượt 40 giây với 4 luồng ghi đơn.

Tiêu chí Không thử lại EnableRetryOnFailure Đạt
Yêu cầu báo lỗi cho người dùng 972, 972, 971 0, 0, 0 Có
Đơn trong bảng bằng số yêu cầu báo thành công 3 trên 3 lần 3 trên 3 lần Có
Yêu cầu chậm nhất 875, 736, 598 ms (các yêu cầu lỗi trả về ngay) 9.351, 20.048, 19.404 ms Đo được, trần do cấu hình

Database vắng mặt khoảng 8 giây: không thử lại có 972 yêu cầu lỗi, EnableRetryOnFailure không có lỗi nào

Không thử lạiEnableRetryOnFailure
Lần 1972 yêu cầu0 yêu cầuLần 2972 yêu cầu0 yêu cầuLần 3971 yêu cầu0 yêu cầu
LocalDB riêng KumeoC, 4 luồng ghi đơn trong 40 giây, OFFLINE WITH ROLLBACK IMMEDIATE ở giây 10, ONLINE ở giây 18,2 đến 19,0.
Bảng số liệu
Không thử lạiEnableRetryOnFailure
Lần 1972 yêu cầu0 yêu cầu
Lần 2972 yêu cầu0 yêu cầu
Lần 3971 yêu cầu0 yêu cầu

Cái giá của 0 lỗi là thời gian chờ: yêu cầu chậm nhất mất 19,4 giây (trung vị ba lần), dài hơn cả 8 giây database vắng mặt. Phần dôi ra đến từ thời gian chặn của pool cộng với nhịp chờ cấp số nhân của strategy. Với maxRetryCount: 6 và maxRetryDelay: 5 giây, tổng thời gian chờ giữa các lần thử không vượt 30 giây; POS cần trần ngắn hơn thì hạ hai số này và đặt timeout của chính request.

Điều chưa chứng minh: bản giả lập dùng OFFLINE, không phải failover thật. Thời gian failover của server, mã lỗi cụ thể khi listener chuyển IP, và tác dụng của MultiSubnetFailover cần một Availability Group thật để đo. Trường hợp mất câu trả lời sau commit không xuất hiện trong ba lần này, vì OFFLINE chặn ở bước đăng nhập; bài delayed durability đo được trường hợp đó khi giết tiến trình SQL Server: có lần 7 dòng đã commit mà client nhận lỗi.

7. Kết luận

Availability Group lo phần server; người dùng chỉ không thấy lỗi khi ứng dụng trỏ vào listener và chạy lại cả giao dịch bằng execution strategy. Thử lại đổi lỗi thành thời gian chờ, nên trần thời gian chờ phải được chọn có chủ đích.

Trong dự án .NET của bạn:

  • Chuỗi kết nối trỏ vào listener, có MultiSubnetFailover=True; báo cáo dùng chuỗi riêng có ApplicationIntent=ReadOnly khi có AG Enterprise.
  • Bật EnableRetryOnFailure và bọc mọi BeginTransactionAsync trong CreateExecutionStrategy().ExecuteAsync, gọi ChangeTracker.Clear() ở đầu khối.
  • Mọi lệnh ghi tiền có khóa chống trùng, vì lần chạy lại có thể đến sau một commit đã thành công.
  • Diễn tập failover và restore STOPAT mỗi quý, đo thời gian ứng dụng báo lỗi; chạy DBCC CHECKDB trên replica chính, vì Basic AG không cho chạy nó trên replica phụ.

Những chỗ hay hiểu sai

  • "Có Availability Group thì không cần backup." Replica phụ redo cả lệnh DELETE nhầm; chỉ chuỗi log backup và STOPAT lấy lại được dữ liệu trước đó.
  • "MultiSubnetFailover làm failover nhanh hơn." Nó chỉ giúp client tìm đúng IP nhanh hơn; thời gian chuyển vai trò của server không đổi.
  • "Database đã lên lại thì kết nối mới thành công ngay." Pool của SqlClient còn ném lại lỗi cũ trong thời gian chặn, 5 giây trở lên.
  • "Replica phụ của Basic AG dùng cho báo cáo được." Basic AG không cho đọc, không cho backup trên replica phụ.

Đọc tiếp

Nguồn

Đọc tiếp

Bài tiếp theo trong series

Ghi nhật ký truy cập phải chờ log từng dòng: delayed durability

Worker ghi nhật ký truy cập mỗi dòng một giao dịch, nên 45% thời gian là chờ log xuống đĩa và file log bị ghi 20.000 lần cho 20.000 dòng. COMMIT WITH (DELAYED_DURABILITY = ON) đưa một luồng từ 1.229 lên 3.826 dòng/s và còn 356 lần ghi log, đổi lại mất trung vị 53 dòng đã báo thành công mỗi lần máy chủ bị giết.

10 phút đọc

Trong Azure SQL

Đưa BanHang lên Azure: chọn dịch vụ và sửa code .NET

Filegroup trên từng ổ, chuỗi backup với SQL Agent, và một API chưa từng bị ngắt kết nối đều đổi khi BanHang lên Azure. Chọn Azure SQL Database, đặt partition lên PRIMARY, khôi phục bằng point-in-time restore, và bọc giao dịch trong execution strategy: 200 lần POST gặp lỗi tạm thời giả lập, từ 145 lên 200 đơn ghi được.

12 phút đọc

Trong SQL Server

Báo cáo doanh thu quét 10 triệu đơn: columnstore và batch mode

Màn hình doanh thu theo tháng gom 10 triệu đơn bằng EF Core, đọc 46.012 page và tốn 8.844 ms CPU mỗi lần mở. Chỉ mục columnstore NCCI_DonHang cộng câu LINQ gom theo ngày đưa CPU xuống 2.360 ms và số page đọc xuống 15.522, trong khi tra một đơn vẫn 3 page.

11 phút đọc

Trong SQL Server

Dọn cả năm đơn cũ mà không khóa bảng: SWITCH partition

Job dọn đơn cũ xóa 3,0 triệu đơn trước 2025 bằng ExecuteDeleteAsync mất 40,7 giây và ghi 710 MB log; SWITCH thường thì chặn API 3.393 ms khi gặp một phiên báo cáo đang mở. SWITCH với WAIT_AT_LOW_PRIORITY dọn cùng số đơn trong 225 ms, 24 KB log, API không lệnh nào quá 500 ms.

14 phút đọc