DevOps

Migration không dừng hệ thống: thêm cột KenhBan khi API vẫn nhận đơn

Một lệnh ALTER TABLE làm API BanHang đứng vì khóa Sch-M. Đo trên 10 triệu đơn: cột NOT NULL ghi lại mọi dòng, hàng đợi khóa, và expand–contract.

Mục lục
  1. 1. Một lệnh, hai edition: sửa metadata hay ghi lại 10 triệu dòng
  2. 2. Hàng đợi khóa: lệnh 28 ms làm API đứng 18 giây
  3. 3. Expand–contract: sáu bước, ba phiên bản API
  4. 4. Backfill và siết ràng buộc trên 10 triệu dòng
  5. 5. Đưa vào pipeline
  6. Những chỗ hay hiểu sai
  7. Đọc tiếp
  8. Nguồn

10:40 sáng thứ Sáu 2026-10-02, API của BanHang đang nhận khoảng 300 request mỗi giây. Nhóm POS cần mỗi đơn ghi kênh bán: 1 là online, 2 là POS tại cửa hàng, 0 là "chưa ghi nhận" cho 10 triệu đơn cũ. Migration chỉ một dòng: ALTER TABLE dbo.DonHang ADD KenhBan tinyint NOT NULL CONSTRAINT DF_DonHang_KenhBan DEFAULT (0);. Trên staging, SQL Server 2019 Developer với đủ 10 triệu đơn, lệnh xong ngay. Production chạy bản Standard. Thu ngân chờ 15 giây để lưu đơn 20000123 trị giá 1.250.000 đ rồi nhận lỗi, và API đứng gần một phút. Diễn biến là minh họa; con số lấy từ bản thử ở mục 1. Bài này tách hai nguyên nhân, đo từng cách tránh, rồi đổi schema theo từng bước trong lúc API vẫn nhận đơn.

Đọc nhanh

  • Thêm cột NOT NULL có default chỉ sửa metadata trên Enterprise và Developer. Standard và Express sửa từng dòng dưới khóa Sch-M: trên 10 triệu đơn ở LocalDB mất khoảng 54 giây, 1.657 MiB log, số page gấp đôi, 16.904 trên 19.870 request API chậm từ 1 giây.
  • Lệnh chỉ sửa metadata vẫn phải xin Sch-M. Chờ sau một báo cáo 20 giây, nó chặn mọi request đến sau: 5.366 request chậm, 811 lỗi hết pool. Thêm SET LOCK_TIMEOUT 500 và vòng thử lại: 654, 662 và 0 request chậm ở ba vòng, không lỗi nào.
  • Expand–contract: thêm cột NULL có DEFAULT, deploy bản API chịu được NULL, backfill theo lô, rồi mới siết ràng buộc và bỏ phần chỉ để bản cũ chạy. Backfill lô 4.000 dòng: 566 request chậm, so với 14.491 của một lệnh UPDATE.
  • Trong pipeline, migration là một job riêng chạy trước deploy, chạy lại được, có LOCK_TIMEOUT, và được diễn tập trên bản restore của production cùng edition.

1. Một lệnh, hai edition: sửa metadata hay ghi lại 10 triệu dòng

Mọi ALTER TABLE lấy khóa Sch-M trên bảng. Sch-M xung đột với mọi khóa khác, kể cả Sch-S mà câu nào cũng giữ khi biên dịch và chạy, theo bảng tương thích ở bài transaction. Trong lúc lệnh giữ Sch-M, không request nào đọc hay ghi được dbo.DonHang. Câu hỏi là giữ bao lâu.

Với cột NOT NULL có default là hằng số lúc chạy (runtime constant, như 0), tài liệu ALTER TABLE ghi: từ SQL Server 2012, bản Enterprise chỉ lưu default vào metadata, dòng cũ không bị sửa, lệnh xong gần như tức thì. Bảng tính năng SQL Server 2019 xếp "Online schema change" vào Enterprise, không có ở Standard, Web, Express. Developer có đủ tính năng Enterprise, nên staging Developer không thấy điều production làm. Default như NEWID(), cột varchar(max) hay xml vẫn chạy offline ngay trên Enterprise. Cột cho phép NULL, không kèm WITH VALUES, thì edition nào cũng chỉ sửa metadata: dòng cũ đọc ra NULL, dòng mới không ghi cột này thì lấy default.

Bản thử chạy trên LocalDB, tức SQL Server 2019 Express 15.0.4382 (CU27 GDR), cùng phía với Standard. dbo.DonHang theo đúng định nghĩa ở chương lưu trữ, 10 triệu đơn chia theo năm như số liệu chung, mọi partition trên PRIMARY cho gọn:

CREATE DATABASE Kumeo_migration COLLATE Vietnamese_100_CI_AS;
GO
ALTER DATABASE Kumeo_migration SET RECOVERY SIMPLE;
ALTER DATABASE Kumeo_migration SET AUTO_CLOSE OFF;   -- database tạo trên Express bật sẵn AUTO_CLOSE
ALTER DATABASE Kumeo_migration MODIFY FILE (NAME = Kumeo_migration, SIZE = 2048MB, FILEGROWTH = 512MB);
ALTER DATABASE Kumeo_migration MODIFY FILE (NAME = Kumeo_migration_log, SIZE = 256MB, FILEGROWTH = 256MB);
GO
USE Kumeo_migration;
CREATE PARTITION FUNCTION pf_DonHang_Ngay (datetime2(0)) AS RANGE RIGHT FOR VALUES ('20250101', '20260101', '20270101');
CREATE PARTITION SCHEME ps_DonHang_Ngay AS PARTITION pf_DonHang_Ngay ALL TO ([PRIMARY]);
CREATE TABLE dbo.DonHang (
    DonHangId bigint NOT NULL,
    NgayTao datetime2(0) NOT NULL,
    KhachHangId int NOT NULL,
    TrangThai tinyint NOT NULL,
    TongTien decimal(18, 2) NOT NULL,
    CONSTRAINT PK_DonHang PRIMARY KEY CLUSTERED (NgayTao, DonHangId)
) ON ps_DonHang_Ngay (NgayTao);
-- 10 triệu đơn: 3,0 triệu năm 2024, 3,4 triệu năm 2025, 3,6 triệu năm 2026 tới 10:00 ngày 2026-10-02
WITH so AS (SELECT TOP (10000000) CAST(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS bigint) AS n
            FROM sys.all_columns AS a CROSS JOIN sys.all_columns AS b)
INSERT INTO dbo.DonHang WITH (TABLOCK) (DonHangId, NgayTao, KhachHangId, TrangThai, TongTien)
SELECT n,
    CASE WHEN n <= 3000000 THEN DATEADD(SECOND, CAST((n - 1) * 31622400.0 / 3000000 AS int), CAST('20240101' AS datetime2(0)))
         WHEN n <= 6400000 THEN DATEADD(SECOND, CAST((n - 3000001) * 31536000.0 / 3400000 AS int), CAST('20250101' AS datetime2(0)))
         ELSE DATEADD(SECOND, CAST((n - 6400001) * 23709600.0 / 3600000 AS int), CAST('20260101' AS datetime2(0))) END,
    CASE WHEN n % 100 < 3 THEN 42 ELSE CAST(n * 2654435761 % 200000 + 1 AS int) END,     -- khách 42: 3% số đơn
    CASE WHEN n * 7919 % 100 < 1 THEN 1 WHEN n * 7919 % 100 < 3 THEN 2
         WHEN n * 7919 % 100 < 5 THEN 3 WHEN n * 7919 % 100 < 95 THEN 4 ELSE 5 END,
    100000 + n * 104729 % 300 * 50000
FROM so;
CREATE INDEX IX_DonHang_KhachHang ON dbo.DonHang (KhachHangId, NgayTao) ON ps_DonHang_Ngay (NgayTao);
BACKUP DATABASE Kumeo_migration TO DISK = N'Kumeo_migration_goc.bak' WITH INIT;   -- mọi lần đo restore từ bản này

Mỗi lần đo bắt đầu bằng RESTORE DATABASE ... WITH REPLACE từ bản backup đó, rồi COUNT_BIG(*) trên từng chỉ mục để cache nóng. Harness giả lập API bản v1, chưa biết KenhBan, ở tải mở: request thứ i đi lúc i/300 giây dù các request trước đã xong hay chưa. 70% tra 20 đơn gần nhất của một khách, 20% ghi đơn mới, 10% đổi trạng thái đơn vừa ghi. Pool để mặc định: tối đa 100 kết nối, chờ quá 15 giây thì lỗi. Đến giây thứ 5, harness chạy file DDL trên kết nối riêng, đo thời gian và log ghi ra. Tham số cuối bật báo cáo ở mục 2. Cần .NET 10, gói Microsoft.Data.SqlClient 7.1.1:

#:package Microsoft.Data.SqlClient@7.1.1
// dotnet run tai.cs -- <tên> <request/giây> <giây tải tối thiểu> <file DDL> <giây chạy DDL> [giây báo cáo]
using System.Collections.Concurrent;
using System.Diagnostics;
using Microsoft.Data.SqlClient;

const string Db = @"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_migration;Integrated Security=true;Encrypt=false;Application Name=";
string ten = args[0], ddl = File.ReadAllText(args[3]), ddlKq = "";
int rps = int.Parse(args[1]), giay = int.Parse(args[2]), baoCao = args.Length > 5 ? int.Parse(args[5]) : 0;
double ddlLuc = double.Parse(args[4]), ketThuc = double.MaxValue, ddlMs, logMiB;
var kq = new ConcurrentBag<(double ms, string loi)>();
var viec = new ConcurrentBag<Task>();
var dongHo = new Stopwatch();
long donMoi = 0;
ThreadPool.SetMinThreads(400, 400);

async Task Api(int loai, long so, int khach, double luc)   // một request của API bản v1, pool mặc định 100 kết nối
{
    string loi = "";
    try
    {
        await using var cn = new SqlConnection(Db + "API");
        await cn.OpenAsync();
        using var cmd = new SqlCommand(loai switch
        {
            0 => "SELECT TOP (20) DonHangId, NgayTao, TrangThai, TongTien FROM dbo.DonHang WHERE KhachHangId = @k ORDER BY NgayTao DESC;",
            1 => "INSERT INTO dbo.DonHang (DonHangId, NgayTao, KhachHangId, TrangThai, TongTien) VALUES (@id, @ngay, @k, 1, 1250000);",
            _ => "UPDATE dbo.DonHang SET TrangThai = 2 WHERE NgayTao = @ngay AND DonHangId = @id;"
        }, cn);
        cmd.Parameters.AddWithValue("@k", khach);
        cmd.Parameters.AddWithValue("@id", 20_000_000 + so);
        cmd.Parameters.AddWithValue("@ngay", new DateTime(2026, 10, 2, 10, 40, 0).AddSeconds(so / 50));
        await using var dr = await cmd.ExecuteReaderAsync();
        while (await dr.ReadAsync()) { }
    }
    catch (SqlException e) { loi = "sql" + e.Number; }
    catch (InvalidOperationException) { loi = "het_pool"; }   // chờ kết nối trong pool quá 15 s
    kq.Add(((dongHo.Elapsed.TotalSeconds - luc) * 1000, loi));
}

var tai = new Thread(() =>   // tải mở: request thứ i đi lúc i / rps giây, không chờ các request trước
{
    var rnd = new Random(42);
    for (long i = 0; dongHo.Elapsed.TotalSeconds < Volatile.Read(ref ketThuc); Thread.Sleep(1))
        for (; i < dongHo.Elapsed.TotalSeconds * rps; i++)
        {
            int r = rnd.Next(100), khach = rnd.Next(1, 200_001);
            int loai = r is >= 70 and < 90 ? 1 : r >= 90 && donMoi > 1 ? 2 : 0;   // 70% đọc, 20% ghi đơn, 10% sửa
            long so = loai == 1 ? ++donMoi : loai == 2 ? rnd.NextInt64(1, donMoi) : 0;
            viec.Add(Api(loai, so, khach, (double)i / rps));
        }
});
if (rps > 0)
{
    await Task.WhenAll(Enumerable.Range(1, 400).Select(k => Api(0, 0, k, 0)));   // làm nóng pool
    kq.Clear();
    tai.Start();
}
dongHo.Start();

var chayBaoCao = Task.Run(async () =>   // báo cáo xuất cả bảng từ giây thứ 3, client đọc chậm trong baoCao giây
{
    if (baoCao == 0) return;
    await Task.Delay(3000);
    await using var cn = new SqlConnection(Db + "BaoCao");
    await cn.OpenAsync();
    using var cmd = new SqlCommand("SELECT DonHangId, NgayTao, KhachHangId, TrangThai, TongTien FROM dbo.DonHang;", cn);
    using var dr = await cmd.ExecuteReaderAsync();
    for (var t = Stopwatch.StartNew(); t.Elapsed.TotalSeconds < baoCao && dr.Read(); )
        if (dr.GetInt64(0) % 1000 == 0) await Task.Delay(5);
    cmd.Cancel();
});

await Task.Delay(TimeSpan.FromSeconds(ddlLuc));
await using (var cn = new SqlConnection(Db + "Migration"))
{
    await cn.OpenAsync();
    var log = new SqlCommand("SELECT num_of_bytes_written FROM sys.dm_io_virtual_file_stats(DB_ID(), 2);", cn);
    long log0 = (long)log.ExecuteScalar()!;
    var t = Stopwatch.StartNew();
    ddlKq = (await new SqlCommand(ddl, cn) { CommandTimeout = 0 }.ExecuteScalarAsync())?.ToString() ?? "";
    ddlMs = t.Elapsed.TotalMilliseconds;
    logMiB = ((long)log.ExecuteScalar()! - log0) / 1048576.0;
}
Volatile.Write(ref ketThuc, Math.Max(giay, dongHo.Elapsed.TotalSeconds + 5));   // tải chạy thêm 5 s sau DDL
await chayBaoCao;
if (rps > 0) tai.Join();
await Task.WhenAll(viec);

var ok = kq.Where(k => k.loi == "").Select(k => k.ms).Order().ToArray();
double P(double p) => ok.Length == 0 ? 0 : ok[(int)Math.Ceiling(p * ok.Length) - 1];
string loi = string.Join(", ", kq.Where(k => k.loi != "").GroupBy(k => k.loi).Select(g => $"{g.Key} {g.Count()}"));
Console.WriteLine($"{ten}: {kq.Count} request, {kq.Count(k => k.ms >= 1000)} chậm >= 1 s, lỗi [{loi}], p95 {P(0.95):F0} p99 {P(0.99):F0} "
    + $"max {kq.Select(k => k.ms).DefaultIfEmpty().Max():F0} ms | DDL {ddlMs:F0} ms, kết quả '{ddlKq}', log {logMiB:F1} MiB");

File s-notnull.sql chứa đúng lệnh ở đầu bài; s-null.sql chỉ đổi NOT NULL thành NULL. dotnet run tai.cs -- notnull 300 15 s-notnull.sql 5 đo dưới tải; dotnet run tai.cs -- notnull 0 0 s-notnull.sql 0 đo riêng lệnh. Mỗi cách chạy 3 lần không tải và 3 lần dưới tải trên laptop Intel Core Ultra 5 125U, Windows 11, máy đang chạy việc khác. Thời gian dao động mạnh, chỉ dùng để thấy bậc độ lớn. Request "chậm" là request mất từ 1 giây, kể cả request lỗi; số dưới tải lấy vòng trung vị:

Trên 10 triệu đơn NOT NULL DEFAULT (0) NULL DEFAULT (0)
Thời gian lệnh, trung vị 6 lần 54 s (40 đến 139 s) 28 ms (13 đến 74 ms)
Log ghi ra 1.657 MiB dưới 0,1 MiB
File log 256 thành 3.328 MiB 256 MiB
Page dùng của PK_DonHang 46.012 thành 92.142 46.012
Dưới tải: request chậm 16.904 trên 19.870 0 trên 4.498
Dưới tải: request lỗi 12.259 0

Trong 12.259 lỗi, 12.159 là hết 15 giây chờ pool, 100 là hết 30 giây chờ lệnh, ở cả ba vòng đều đúng 100: cả pool đã vào SQL Server rồi đứng sau Sch-M. sys.dm_tran_locks lúc lệnh chạy cho thấy phiên migration giữ Sch-M ở trạng thái GRANT.

Số page gấp đôi có lý do cụ thể. Chương lưu trữ tính một dòng DonHang 35 byte cộng 2 byte slot, 218 dòng lấp đầy 8.096 byte của page. Thêm cột tinyint thành 38 byte, và 218 × 38 = 8.284 byte không còn vừa. Mọi page lá đầy phải tách đôi, mỗi nửa đầy khoảng 50%.

2. Hàng đợi khóa: lệnh 28 ms làm API đứng 18 giây

Cột NULL chỉ sửa metadata, nhưng lệnh vẫn phải có Sch-M trước, và Sch-M chỉ được cấp khi không phiên nào còn giữ khóa trên bảng. Trong lúc chờ, nó đứng đầu hàng đợi khóa của dbo.DonHang. Request đến sau xin IS để đọc hay IX để ghi, dù hợp với mọi khóa đang được giữ, vẫn xếp sau Sch-M đang chờ. Tài liệu Microsoft tả đúng điều này ở khóa Sch-M cuối một lần dựng chỉ mục online.

Kịch bản: 10:40, kế toán xuất danh sách đơn ra Excel. Câu SELECT đọc cả bảng, client đọc chậm, nên câu chạy 20 giây và giữ Sch-S suốt thời gian đó. Harness với tham số cuối 20 chạy báo cáo từ giây thứ 3, ALTER ở giây thứ 5.

sequenceDiagram
  participant B as Báo cáo xuất Excel
  participant T as dbo.DonHang
  participant M as Migration
  participant A as Request API
  B->>T: SELECT cả bảng, giữ Sch-S 20 s
  M->>T: ALTER TABLE ADD KenhBan, xin Sch-M
  Note over M,T: Sch-M không hợp với Sch-S: chờ
  A->>T: Đọc xin IS, ghi xin IX
  Note over A,T: Xếp sau Sch-M đang chờ
  B-->>T: Hết 20 s, nhả Sch-S
  T-->>M: Cấp Sch-M, thêm cột trong vài ms
  T-->>A: Hàng đợi chạy tiếp

Lệnh ALTER chờ 18,0 giây ở cả ba vòng. Trên khoảng 10.500 request, ba vòng có 5.341, 5.366 và 5.391 request chậm, 802 đến 816 lỗi hết pool. Một lần lấy mẫu sys.dm_exec_requests cho thấy đúng chuỗi trong sơ đồ: request API chờ LCK_M_IS hoặc LCK_M_IX sau phiên migration, phiên migration chờ LCK_M_SCH_M sau phiên báo cáo, phiên báo cáo ở ASYNC_NETWORK_IO. Truy vấn tìm phiên đầu chuỗi ở mục 9 bài transaction vì vậy trỏ vào báo cáo, dù lệnh ALTER mới là thứ chặn API.

Bật READ_COMMITTED_SNAPSHOT hay gắn NOLOCK cho báo cáo không giúp gì: câu nào cũng giữ Sch-S khi biên dịch và chạy. Cách giảm là không để DDL đứng lâu trong hàng. SET LOCK_TIMEOUT giới hạn thời gian một lệnh chờ khóa; hết giờ, lệnh nhận lỗi 1222 và rời hàng. Script dưới là bước expand đầy đủ của mục 3: thêm cột rồi thêm ràng buộc, mỗi lần chờ tối đa 0,5 giây, hỏng thì nghỉ 3 giây. Nó kiểm COL_LENGTH và OBJECT_ID trước mỗi lệnh, nên đứt giữa chừng thì chạy lại được:

SET NOCOUNT ON;
SET LOCK_TIMEOUT 500;                    -- chờ khóa tối đa 0,5 s mỗi lần thử
DECLARE @lan int = 0;
WHILE COL_LENGTH(N'dbo.DonHang', N'KenhBan') IS NULL
   OR OBJECT_ID(N'dbo.CK_DonHang_KenhBan', N'C') IS NULL
BEGIN
    SET @lan += 1;
    BEGIN TRY
        IF COL_LENGTH(N'dbo.DonHang', N'KenhBan') IS NULL
            ALTER TABLE dbo.DonHang ADD KenhBan tinyint NULL CONSTRAINT DF_DonHang_KenhBan DEFAULT (0);
        IF OBJECT_ID(N'dbo.CK_DonHang_KenhBan', N'C') IS NULL   -- EXEC: cột chưa có lúc biên dịch batch
            EXEC (N'ALTER TABLE dbo.DonHang WITH NOCHECK
                  ADD CONSTRAINT CK_DonHang_KenhBan CHECK (KenhBan IS NOT NULL AND KenhBan IN (0, 1, 2));');
    END TRY
    BEGIN CATCH
        IF ERROR_NUMBER() <> 1222 OR @lan >= 60 THROW;   -- 1222: hết LOCK_TIMEOUT
        WAITFOR DELAY '00:00:03';
    END CATCH;
END;
SELECT @lan AS so_lan_thu;

Cùng báo cáo 20 giây, script thử 6 hoặc 7 lần, xong sau 18 đến 21 giây. Ba vòng cho 654, 662 và 0 request chậm, không request nào lỗi; chậm nhất 4,0 giây ở hai vòng đầu và 0,55 giây ở vòng ba. Mỗi lần thử vẫn chặn hàng tới 0,5 giây, nên LOCK_TIMEOUT phải nhỏ hơn mức trễ API chịu được. Cái được chắc chắn là đuôi: không request nào chờ 15 giây rồi lỗi.

DDL giữ hoặc chờ Sch-M làm hàng nghìn request chậm; chờ có giới hạn còn vài trăm trở xuống

ADD NOT NULL DEFAULT (0)16.904 requestADD NULL DEFAULT (0)0 requestADD NULL, sau báo cáo 20 s5.366 requestLOCK_TIMEOUT 500 ms, sau báo cáo654 requestSWITCH, sau báo cáo 20 s5.698 requestSWITCH low priority, sau báo cáo7 request
Request mất từ 1 giây, kể cả lỗi, ở 300 request mỗi giây; trung vị 3 vòng. LocalDB 15.0.4382, 10 triệu đơn, laptop Intel Core Ultra 5 125U.
Bảng số liệu
Giá trị
ADD NOT NULL DEFAULT (0)16.904 request
ADD NULL DEFAULT (0)0 request
ADD NULL, sau báo cáo 20 s5.366 request
LOCK_TIMEOUT 500 ms, sau báo cáo654 request
SWITCH, sau báo cáo 20 s5.698 request
SWITCH low priority, sau báo cáo7 request

SQL Server có sẵn cách chờ không chặn hàng: WAIT_AT_LOW_PRIORITY (MAX_DURATION = n MINUTES, ABORT_AFTER_WAIT = NONE | SELF | BLOCKERS). Lệnh chờ ở mức ưu tiên thấp, request khác đi trước; hết MAX_DURATION thì chờ tiếp như thường, tự hủy, hoặc kill phiên đang chặn (cần quyền ALTER ANY CONNECTION). Từ SQL Server 2014, tùy chọn này chỉ có ở ALTER INDEX ... REBUILD online và ALTER TABLE ... SWITCH; SQL Server 2022 thêm CREATE INDEX online, DBCC SHRINKFILE và DBCC SHRINKDATABASE. ALTER TABLE ... ADD không có. Dựng chỉ mục online chỉ có trên Enterprise, nên trên Standard còn SWITCH, lệnh chương kỹ thuật dùng để đẩy năm cũ ra. Trên LocalDB, cùng báo cáo 20 giây, SWITCH PARTITION 1 sang bảng staging làm 5.381, 5.698 và 5.949 request chậm. Thêm WITH (WAIT_AT_LOW_PRIORITY (MAX_DURATION = 1 MINUTES, ABORT_AFTER_WAIT = SELF)), lệnh vẫn chờ 18 giây rồi chuyển 3 triệu đơn năm 2024, còn request chậm là 7, 1 và 596.

3. Expand–contract: sáu bước, ba phiên bản API

Parallel change, còn gọi là expand–contract (Danilo Sato, 2014), tách một thay đổi không tương thích thành ba pha: mở rộng để cái cũ và cái mới cùng chạy, chuyển dần từng bên dùng, rồi thu lại; theo bài đó, phần lớn refactoring database đi theo mẫu này. Rolling deploy thay từng instance API một, như ở bài triển khai không gián đoạn, nên có lúc v1 và v2 cùng nhận request. Mọi trạng thái schema trên đường đi phải chạy được với mọi bản API đang chạy lúc đó.

Expand Migrate Contract Schema 1. Thêm cột NULL, DEFAULT 0 3. Backfill lô 4.000 dòng 4. Kiểm lại WITH CHECK 6. Bỏ DEFAULT hết đường về v1 API v1 chưa biết KenhBan API v2 ghi 1 hoặc 2, đọc NULL là 0 API v3 đọc thẳng KenhBan 2. v1 và v2 cùng chạy 5. v2 và v3 cùng chạy thời gian: vài giờ đến vài ngày
Schema luôn đi trước bản API cần nó, và chỉ thu lại khi không còn bản nào dựa vào phần cũ.
Bước Việc Bản API đang chạy Vì sao an toàn
1 Script ở mục 2: cột KenhBan NULL có DEFAULT (0), ràng buộc CK_DonHang_KenhBan thêm bằng WITH NOCHECK v1 Chỉ sửa metadata. v1 không ghi cột nên nhận 0
2 Rolling deploy v2: ghi 1 hoặc 2, đọc ISNULL(KenhBan, 0) v1, v2 Cột có trước khi bản nào ghi vào nó
3 Backfill theo lô: NULL thành 0 v2 Mỗi lô chỉ khóa 4.000 dòng (mục 4)
4 WITH CHECK CHECK CONSTRAINT v2 Không còn NULL. Quét bảng dưới Sch-M
5 Deploy v3: đọc thẳng KenhBan v2, v3 Mọi dòng có giá trị, ràng buộc được tin
6 Bỏ DF_DonHang_KenhBan v2, v3 Bản nào còn chạy cũng ghi cột. Từ đây v1 ghi đơn sẽ lỗi 547

WITH NOCHECK ở bước 1 làm ràng buộc áp cho mọi lần ghi từ đó mà không kiểm 10 triệu dòng cũ. Ràng buộc mang is_not_trusted = 1 trong sys.check_constraints, và trình tối ưu bỏ qua nó cho đến khi được kiểm lại bằng WITH CHECK. Thử trên LocalDB: đổi TrangThai của một đơn cũ còn KenhBan NULL vẫn chạy; ghi NULL vào KenhBan thì lỗi 547. v1 sửa đơn cũ không vướng gì, còn bản v2 quên ghi kênh bị chặn từ request đầu.

Ba chỗ làm bước 1 hỏng v1. INSERT INTO dbo.DonHang VALUES (...) không liệt kê cột lỗi 213 ngay khi bảng có thêm cột, code đọc cột theo vị trí cũng vậy. Bảng staging cho SWITCH phải có KenhBan cùng kiểu, cùng NULL, theo điều kiện "cùng cột, cùng thứ tự" ở chương kỹ thuật. Nếu dbo.DonHang bật CDC, capture instance cũ không chép cột mới, phải tạo instance thứ hai như bài CDC mô tả. Quay lại trước bước 6 chỉ là deploy lại binary v1, schema để nguyên.

4. Backfill và siết ràng buộc trên 10 triệu dòng

Backfill đi theo khóa clustered, mỗi lô 4.000 dòng, như cách chia lô ở mục leo thang khóa của bài transaction: không quét lại phần đã làm, và dưới ngưỡng 5.000 khóa của leo thang khóa. Mỗi lô là một giao dịch autocommit, sau mỗi lô nghỉ 50 ms. Script bắt đầu từ NgayTao nhỏ nhất còn NULL, nên dừng giữa chừng rồi chạy lại vẫn đi tiếp đúng chỗ:

SET NOCOUNT ON;
DECLARE @tu datetime2(0), @n int = 1, @lo int = 0;
DECLARE @xong TABLE (NgayTao datetime2(0) NOT NULL);
SELECT @tu = MIN(NgayTao) FROM dbo.DonHang WHERE KenhBan IS NULL;   -- chạy lại thì đi tiếp từ chỗ còn NULL
WHILE @n > 0
BEGIN
    DELETE FROM @xong;
    WITH lo AS (
        SELECT TOP (4000) NgayTao, KenhBan
        FROM dbo.DonHang
        WHERE NgayTao >= @tu AND KenhBan IS NULL
        ORDER BY NgayTao, DonHangId
    )
    UPDATE lo SET KenhBan = 0
    OUTPUT inserted.NgayTao INTO @xong;
    SET @n = @@ROWCOUNT;
    SELECT @tu = ISNULL(MAX(NgayTao), @tu), @lo += SIGN(@n) FROM @xong;
    WAITFOR DELAY '00:00:00.050';                                    -- nhường chỗ cho API
END;
SELECT @lo AS so_lo;

Các lệnh ở bảng dưới chạy sau bước 1, cùng harness, 3 lần không tải và 3 lần dưới tải; bước 4 và REORGANIZE chạy sau backfill:

Trên 10 triệu đơn Thời gian, trung vị 6 lần Log ghi ra Dưới tải: chậm Lỗi
UPDATE dbo.DonHang SET KenhBan = 0 WHERE KenhBan IS NULL 61 s (48 đến 218 s) 1.657 MiB 14.491 9.905
Backfill 2.500 lô, script trên 318 s (266 đến 539 s) 1.810 MiB 566 0
WITH CHECK CHECK CONSTRAINT CK_DonHang_KenhBan 2,4 s (1,9 đến 7,9 s) dưới 1 MiB 352 0
ALTER COLUMN KenhBan tinyint NOT NULL 8,1 s (6,1 đến 21,0 s) dưới 1 MiB 2.281 0
ALTER INDEX PK_DonHang ON dbo.DonHang REORGANIZE 34 s (30 đến 336 s) 1.894 MiB 0 0

Một lệnh UPDATE giống lệnh NOT NULL DEFAULT ở mục 1: cùng 1.657 MiB log, cùng 92.142 page, và phần lớn request đứng đến khi lệnh commit, vì khóa X trên dòng đã sửa giữ đến commit và câu sửa hàng triệu dòng leo thang lên khóa bảng như bài transaction mô tả. File log phình lên 3.328 MiB, vì log của giao dịch chưa commit không cắt được; khi ADR tắt, log backup cũng không cắt được phần đó.

Theo lô, tổng thời gian dài hơn khoảng 5 lần, log tổng còn nhiều hơn một chút vì mỗi dòng vẫn được ghi lại và page vẫn tách. File log chỉ lên 512 đến 768 MiB, vì checkpoint cắt log giữa các lô (database thử ở SIMPLE; ở FULL là việc của log backup 15 phút). Request chậm ở ba vòng là 566, 738 và 4. Default trace ghi các lần file log tự tăng 256 MB trong lúc đó: dài nhất 1.067 ms ở vòng 1, 4.067 ms ở vòng 2, 214 ms ở vòng 3; request chậm nhất của vòng 2 là 4,1 giây. Theo tài liệu SQL Server 2022, file log nói chung không dùng được instant file initialization, nên phần log mới phải được ghi số 0 trước khi dùng; từ bản 2022, lần tăng đến 64 MB mới được miễn. Cho file log đủ lớn trước khi backfill.

Backfill để lại 92.142 page đầy khoảng một nửa. REORGANIZE luôn chạy online, kể cả trên Express, và dừng giữa chừng thì phần đã làm vẫn giữ. Nó đưa PK_DonHang về 47.578 page với 0, 5 và 0 request chậm, đổi lại 1.894 MiB log.

Hai lệnh siết ràng buộc quét bảng trong lúc giữ Sch-M, thấy được trong sys.dm_tran_locks. ALTER COLUMN ... NOT NULL không ghi lại dòng nào nhưng quét lâu hơn: 2.281 request chậm, so với 352 của WITH CHECK. CK_DonHang_KenhBan đã gồm KenhBan IS NOT NULL, nên trên Standard một lần WITH CHECK là đủ; NOT NULL trong metadata chỉ thêm thông tin cho ORM và người đọc schema. Trên Enterprise, ALTER COLUMN chạy được với WITH (ONLINE = ON) từ SQL Server 2016. Cả hai nên chạy lúc ít đơn, với LOCK_TIMEOUT như bước 1.

5. Đưa vào pipeline

Sự cố lúc 10:40 có ba lỗi quy trình: migration chạy cùng deploy, chạy giờ cao điểm, và được thử trên một edition khác production. Pipeline của BanHang sau sự cố:

  1. Migration là một job riêng, chạy một lần trước rolling deploy, bằng tài khoản có quyền đổi schema; API dùng tài khoản chỉ đọc ghi dữ liệu. Tài liệu EF Core khuyên đúng như vậy, và khuyên không để mọi replica tự chạy migration khi khởi động. Từ EF Core 9, Migrate() và migration bundle lấy một khóa toàn database trước khi chạy; script SQL chạy bằng công cụ khác thì không có khóa đó.
  2. Script nào cũng chạy lại được: kiểm trạng thái trước mỗi lệnh như ở mục 2, hoặc sinh bằng dotnet ef migrations script --idempotent, script tự đối chiếu bảng lịch sử migration và chỉ chạy phần còn thiếu.
  3. Lệnh nào xin Sch-M cũng có LOCK_TIMEOUT và giới hạn số lần thử. Hết lượt thì job thất bại, deploy dừng. Một deploy dừng rẻ hơn 18 giây API đứng.
  4. Diễn tập trên bản restore gần nhất của production, cùng edition. Job diễn tập ghi thời gian, log và số page của từng bước. Bước nào giữ Sch-M quá vài trăm mili giây thì không chạy giờ cao điểm: tách theo mục 3, hoặc đưa vào cửa sổ bảo trì.
  5. Thứ tự release: expand, deploy v2, backfill (job riêng, theo dõi bằng số dòng còn NULL), siết ràng buộc, deploy v3. Contract thuộc một release sau, khi chắc không còn instance v1 và không cần quay về v1.

Những chỗ hay hiểu sai

  • "Từ SQL Server 2012, thêm cột NOT NULL có default là tức thì." Chỉ trên Enterprise và Developer. Standard và Express ghi lại từng dòng: khoảng 54 giây và 1.657 MiB log trên 10 triệu đơn.
  • "Lệnh chỉ sửa metadata thì không chặn ai." Nó vẫn xin Sch-M và chờ mọi khóa đang giữ; trong lúc chờ, request mới xếp sau nó.
  • "Bật RCSI hoặc dùng NOLOCK thì báo cáo không chặn DDL." Câu nào cũng giữ Sch-S, và Sch-M không hợp với Sch-S.
  • "Ràng buộc WITH NOCHECK là đủ." Dòng cũ chưa được kiểm, is_not_trusted = 1, trình tối ưu bỏ qua nó. Phải chạy WITH CHECK CHECK CONSTRAINT sau backfill.

Đọc tiếp

Nguồn

Đọc tiếp