Cơ sở dữ liệuSQL Server, phần 3/6

Kiểu dữ liệu, collation và khóa chính

Chọn kiểu cho tiền, ngày giờ, chữ tiếng Việt và khóa chính của BanHang dựa trên số byte trên page, cách engine so sánh giá trị, và lỗi mà mỗi lựa chọn sai gây ra.

Mục lục
  1. 1. Kiểu dữ liệu là quyết định vật lý
  2. 2. Tiền: decimal, money, float
  3. 3. Ngày giờ: kích thước, độ chính xác và múi giờ
  4. 4. Chuỗi và chữ tiếng Việt
  5. 5. Collation: so sánh, sắp xếp và tìm không dấu
  6. 6. Chuyển kiểu ngầm và thứ tự ưu tiên kiểu
  7. 7. Khóa chính
  8. 8. NULL
  9. 9. Bảng tra cho BanHang
  10. Những chỗ hay hiểu sai
  11. Đọc tiếp
  12. Nguồn

Chương này chọn kiểu cho từng cột của BanHang: tiền, ngày giờ, chữ tiếng Việt, mã, và khóa chính của một bảng đã phân vùng. Mỗi lựa chọn được đo bằng ba thứ: số byte trên page, cách engine so sánh hai giá trị, và lỗi xảy ra khi chọn sai. Phép tính số dòng trên page dùng lại chương Kiến trúc lưu trữ. Mốc là SQL Server 2019, collation database Vietnamese_100_CI_AS. Kết quả in trong bài là kết quả chạy lại trên SQL Server 2019 với schema BanHang, trừ chỗ ghi là ước lượng.

Đọc nhanh

  • Mỗi byte thêm vào một dòng DonHang lặp lại 10 triệu lần và chép sang mọi chỉ mục không clustered. Bản dùng uniqueidentifier, datetime, int, money làm dòng từ 35 lên 47 byte, thêm khoảng 212 MiB ở tầng lá của hai chỉ mục chính.
  • Tiền VND dùng decimal(18, 2). money làm tròn kết quả chia về 4 chữ số lẻ. float cộng dồn sai số ngay khi số có phần lẻ.
  • Khoảng thời gian viết >= đầu ngày AND < đầu ngày sau. Mốc 23:59:59.997 chỉ hợp với datetime. Với datetime2 nó làm sót hoặc đếm trùng đơn.
  • Chữ tiếng Việt cần nvarchar và tiền tố N'...'. Thiếu N, 'Nguyễn Thị Ánh' thành Nguy?n Th? Ánh trước khi chạm tới bảng.
  • Vietnamese_100_CI_AI chỉ bỏ qua dấu thanh. ê, ơ, ư, đ vẫn là chữ riêng, nên nguyen không khớp Nguyễn. Tìm không dấu dùng Latin1_General_100_CI_AI trên một cột tính toán có chỉ mục.
  • DonHangId không tự duy nhất vì khóa chính là (NgayTao, DonHangId). Sinh số bằng SEQUENCE và giữ mọi chỉ mục aligned để SWITCH còn chạy.
  • NOT IN với danh sách có NULL trả 0 dòng. Dùng NOT EXISTS.

1. Kiểu dữ liệu là quyết định vật lý

Engine đọc và ghi theo page 8 KB. Cột rộng thêm 1 byte thì mọi dòng rộng thêm 1 byte, page chứa ít dòng hơn, và cùng 10 triệu đơn cần nhiều page hơn. Nhiều page hơn nghĩa là mỗi lần quét đọc nhiều hơn, buffer pool giữ nhiều hơn, backup lớn hơn, rebuild ghi nhiều log hơn.

Khóa clustered còn được chép vào từng dòng của mọi chỉ mục không clustered. Đó là con trỏ để từ chỉ mục quay về dòng gốc. IX_DonHang_KhachHang (KhachHangId, NgayTao) vì vậy mang thêm DonHangId, phần còn lại của khóa (NgayTao, DonHangId). Khóa clustered rộng thì mọi chỉ mục rộng theo.

Bản BanHang và bản tiện tay

Bản tiện tay là hình dạng hay gặp khi bảng được sinh từ ORM hoặc chép từ hệ cũ: khóa GUID, datetime, int cho mọi số, money cho tiền.

Cột BanHang Byte Bản tiện tay Byte
DonHangId bigint 8 uniqueidentifier 16
NgayTao datetime2(0) 6 datetime 8
KhachHangId int 4 int 4
TrangThai tinyint 1 int 4
TongTien decimal(18, 2) 9 money 8
Dữ liệu cố định 28 40

TongTien float cũng tốn 8 byte, nên phép tính kích thước dưới đây không đổi nếu thay money bằng float.

Công thức của chương lưu trữ: cỡ dòng bằng dữ liệu cố định cộng 3 byte null bitmap (năm cột) cộng 4 byte header. Mỗi dòng tốn thêm 2 byte slot. Phần dành cho dòng trên một page là 8.096 byte.

Clustered index BanHang Bản tiện tay
Cỡ dòng 28 + 3 + 4 = 35 byte 40 + 3 + 4 = 47 byte
Cộng slot 37 byte 49 byte
Dòng mỗi page 8.096 / 37 = 218 8.096 / 49 = 165,2 → 165
Page lá cho 10.000.000 đơn 10.000.000 / 218 = 45.871,6 → 45.872 10.000.000 / 165 = 60.606,1 → 60.607
Dung lượng 45.872 × 8 KB ≈ 358 MiB 60.607 × 8 KB ≈ 473 MiB

Chênh 14.735 page, khoảng 115 MiB, tức 32%, chỉ riêng clustered index.

IX_DonHang_KhachHang chịu nặng hơn về tỷ lệ, vì cả NgayTao lẫn DonHangId đều nằm trong nó. Dòng lá của chỉ mục không clustered gồm các cột khóa, phần khóa clustered chưa có mặt, 1 byte trạng thái và 3 byte null bitmap.

IX_DonHang_KhachHang BanHang Bản tiện tay
Dữ liệu 4 + 6 + 8 = 18 byte 4 + 8 + 16 = 28 byte
Cỡ dòng cộng slot 18 + 4 + 2 = 24 byte 28 + 4 + 2 = 34 byte
Dòng mỗi page 8.096 / 24 = 337,3 → 337 8.096 / 34 = 238,1 → 238
Page lá cho 10.000.000 đơn 29.674 42.017
Dung lượng ≈ 232 MiB ≈ 328 MiB

Chỉ mục lớn thêm 42%. Cộng hai cấu trúc, bản tiện tay cần 102.624 page lá thay vì 75.546: thêm 27.078 page, khoảng 212 MiB. Một lần quét partition năm 2026, 3,6 triệu đơn, đọc 3.600.000 / 165 → 21.819 page thay vì 3.600.000 / 218 → 16.514 page. Mỗi chỉ mục không clustered thêm sau này lại mang 8 byte GUID dư đó thêm một lần nữa.

Đo trên máy

Số trên là phép tính. Bản thử 218.000 dòng cho mỗi bảng, đo sau ALTER INDEX ... REBUILD:

Cấu trúc Cỡ dòng đo được Page lá Phép tính
PK_DonHang 35 1.000 218.000 / 218
IX_DonHang_KhachHang 22 647 218.000 / 337 = 646,9
Bản tiện tay, clustered 47 1.322 218.000 / 165 = 1.321,2
Bản tiện tay, chỉ mục theo khách 32 916 218.000 / 238 = 915,97

Câu đo trên BanHang. SAMPLED đọc một mẫu khi bảng lớn, đủ để lấy cỡ dòng trung bình mà không quét hết 10 triệu dòng:

SELECT
    i.name AS index_name,
    ps.partition_number,
    ps.page_count,
    ps.record_count,
    ps.avg_record_size_in_bytes
FROM sys.dm_db_index_physical_stats(
    DB_ID(), OBJECT_ID(N'dbo.DonHang'), NULL, NULL, 'SAMPLED') AS ps
JOIN sys.indexes AS i
    ON i.object_id = ps.object_id
   AND i.index_id = ps.index_id
WHERE ps.index_level = 0
  AND ps.page_count > 0
ORDER BY i.index_id, ps.partition_number;

Số byte khai báo của từng cột nằm ở sys.columns.max_length: 8, 6, 4, 1, 9 cho năm cột của DonHang, khớp bảng đầu mục.

Điều kiện: phép tính trên là cho bảng không nén. Nén ROW hoặc PAGE ở chương kỹ thuật ghi số theo độ dài thực của giá trị, nên khoảng cách hai bản hẹp lại. Với 10 triệu đơn, cả hai bản đều nằm gọn trong 56 GB buffer pool của máy minh họa. Chênh lệch lộ ra ở từng lần quét, ở dung lượng backup, ở thời gian rebuild, và nhân lên theo số chỉ mục.

2. Tiền: decimal, money, float

Kiểu Byte Bản chất
decimal(p, s) 5 (p 1–9), 9 (p 10–19), 13 (p 20–28), 17 (p 29–38) Số thập phân chính xác, p chữ số, s chữ số sau dấu phẩy
money 8 Chính xác tới 1/10.000 đơn vị, khoảng ±922.337.203.685.477
smallmoney 4 Như money, khoảng ±214.748
float 8 Số nhị phân dấu phẩy động, khoảng 15 chữ số có nghĩa
real 4 Như float, khoảng 7 chữ số có nghĩa

decimal(18, 2) của TongTien chứa tới 9.999.999.999.999.999,99, tức gần 10 triệu tỷ đồng, trong 9 byte.

float: sai số cộng dồn

float lưu số theo cơ số 2. Phần lẻ như 0,1 hay 0,37 không có dạng nhị phân hữu hạn, nên mỗi giá trị đã là một số gần đúng, và mỗi phép cộng làm tròn thêm một lần. CAST(0.1 AS float) + CAST(0.2 AS float) = CAST(0.3 AS float) là sai. Cùng phép đó trên decimal(18, 2) là đúng.

Với VND, float biểu diễn đúng mọi số nguyên tới 2^53 (khoảng 9 triệu tỷ), nên cộng tiền chẵn không lộ lỗi. Lỗi xuất hiện khi có phần lẻ: tách thuế, chiết khấu theo phần trăm, quy đổi ngoại tệ. Đơn 10042 trị giá 1.750.000 đồng, đã gồm thuế 8%. Giá trước thuế là 1.750.000 / 1,08 = 1.620.370,37 đồng. Cộng con số đó một triệu lần:

DECLARE @f float = 0, @d decimal(18, 2) = 0, @i int = 0;

WHILE @i < 1000000
BEGIN
    SET @f += 1620370.37;
    SET @d += 1620370.37;
    SET @i += 1;
END;

SELECT
    CAST(@f AS decimal(18, 2)) AS tong_float,
    @d AS tong_decimal,
    CAST(@f AS decimal(18, 2)) - @d AS chenh_lech;
tong_float tong_decimal chenh_lech
1620370370034.61 1620370370000.00 34.61

Vòng lặp chạy vài giây. Tổng đúng là 1.620.370.370.000,00. Bản float lệch 34,61 đồng. Ở mức 1,6 nghìn tỷ, hai giá trị float liền kề cách nhau 2^-12 ≈ 0,000244, nên mỗi lần cộng làm tròn tối đa khoảng 0,000122 đồng. Một triệu lần cộng gom lại thành vài chục đồng. SUM trên cột float cũng vậy, và thứ tự cộng đổi (ví dụ kế hoạch song song) thì các chữ số cuối có thể đổi theo giữa hai lần chạy.

money: kết quả trung gian chỉ có 4 chữ số lẻ

money lưu đúng tới 4 chữ số lẻ. Vấn đề nằm ở phép tính: money chia money cho ra money, nên tỷ lệ bị làm tròn về 4 chữ số lẻ trước khi dùng tiếp.

Phân bổ chiết khấu 70.000 đồng của đơn 10042 cho một dòng 500.000 đồng theo tỷ lệ giá trị. Kết quả đúng là 500.000 / 1.750.000 × 70.000 = 20.000.

DECLARE @DongTien money = 500000, @TongDon money = 1750000, @ChietKhau money = 70000;
SELECT @DongTien / @TongDon * @ChietKhau AS phan_bo_money;
GO

DECLARE @DongTien decimal(18, 2) = 500000, @TongDon decimal(18, 2) = 1750000,
        @ChietKhau decimal(18, 2) = 70000;
SELECT @DongTien / @TongDon * @ChietKhau AS phan_bo_decimal;
GO
phan_bo_money phan_bo_decimal
19999.0000 20000.000000

Tỷ lệ 0,285714… thành 0,2857 trong money, nhân 70.000 ra 19.999. Mỗi dòng mất 1 đồng, và tổng phân bổ không còn khớp số chiết khấu. Tài liệu money của Microsoft cũng khuyên dùng decimal khi giá trị tiền đi vào phép tính.

Bản decimal giữ 20 chữ số lẻ ở phép chia (decimal(38, 20)) rồi còn 6 ở phép nhân (decimal(38, 6)). Độ chính xác của kết quả tối đa 38. Ở phép nhân và chia, khi phần nguyên cần hơn 32 chữ số, scale bị đặt về 6: decimal(38, 20) nhân decimal(18, 2) cho độ chính xác 57, scale 22, phần nguyên 35 chữ số, nên ra decimal(38, 6). Với VND, 6 chữ số lẻ thừa đủ.

Với BanHang: tiền dùng decimal(18, 2) như TongTien và DonGia. decimal(18, 0) cũng 9 byte, nhưng giữ (18, 2) để mọi cột tiền cùng một kiểu và phần lẻ của thuế hoặc ngoại tệ có chỗ. Tỷ lệ như thuế suất dùng decimal(5, 4), ví dụ 0.0800. Làm tròn ở đúng bước nghiệp vụ quy định, bằng CAST(... AS decimal(18, 2)) hoặc ROUND, không để kiểu trung gian quyết định thay. float để cho số đo khoa học, tọa độ, điểm số mô hình.

3. Ngày giờ: kích thước, độ chính xác và múi giờ

Kiểu Byte Độ chính xác Khoảng ngày
date 3 1 ngày 0001-01-01 đến 9999-12-31
smalldatetime 4 1 phút 1900-01-01 đến 2079-06-06
datetime 8 Làm tròn về .000, .003, .007 giây 1753-01-01 đến 9999-12-31
datetime2(0) 6 1 giây 0001-01-01 đến 9999-12-31
datetime2(3) 7 1 mili giây như trên
datetime2(7) 8 100 nano giây như trên
datetimeoffset(0) 8 1 giây, kèm độ lệch múi giờ như trên
datetimeoffset(7) 10 100 nano giây, kèm độ lệch như trên

datetime2 dùng 6 byte với độ chính xác 0 đến 2, 7 byte với 3 đến 4, 8 byte với 5 đến 7. NgayTao datetime2(0) ghi tới giây, 6 byte, nhỏ hơn datetime 2 byte và đúng với cách đơn hàng được ghi.

datetime làm tròn

datetime không lưu từng mili giây. Phần lẻ giây được làm tròn về bội số gần nhất của 1/300 giây, hiện ra là .000, .003, .007. CAST('2026-10-01T23:59:59.998' AS datetime) ra 23:59:59.997. CAST('2026-10-01T23:59:59.999' AS datetime) ra 2026-10-02 00:00:00.000, tức ngày hôm sau. datetime2(0) làm tròn phần lẻ giây theo cách thông thường, nên CAST('2026-10-01T23:59:59.997' AS datetime2(0)) cũng ra 2026-10-02 00:00:00.

Khoảng thời gian: nửa mở thay cho BETWEEN

Mẫu BETWEEN '... 00:00:00' AND '... 23:59:59.997' sinh ra cho cột datetime, nơi .997 là giá trị cuối cùng trong ngày. Đổi kiểu cột hoặc kiểu tham số thì mốc đó sai. Ba đơn minh họa, một đơn tạo đúng nửa đêm:

CREATE TABLE #Don (
    DonHangId bigint NOT NULL,
    NgayTao datetime2(0) NOT NULL,
    TongTien decimal(18, 2) NOT NULL
);

INSERT #Don (DonHangId, NgayTao, TongTien)
VALUES
    (1, '2026-10-01T00:00:00', 100000),
    (2, '2026-10-01T23:59:59', 200000),
    (3, '2026-10-02T00:00:00', 300000);

DECLARE @Tu datetime = '2026-10-01', @Den datetime = '2026-10-01T23:59:59.999';

SELECT @Den AS den_thuc_te, COUNT(*) AS so_don, SUM(TongTien) AS doanh_thu
FROM #Don
WHERE NgayTao BETWEEN @Tu AND @Den;
den_thuc_te so_don doanh_thu
2026-10-02 00:00:00.000 3 600000.00

Báo cáo ngày 01/10 nhận cả đơn 3 tạo lúc 00:00:00 ngày 02/10. Báo cáo ngày 02/10 cũng nhận đơn đó. Doanh thu bị đếm hai lần. Kết quả giống hệt khi mốc trên đi qua biến hoặc tham số datetime2(0) với giá trị .997, vì datetime2(0) làm tròn nó lên 2026-10-02 00:00:00.

Chiều ngược lại: cột datetime2(3) có đơn lúc 23:59:59.998 và 23:59:59.999. BETWEEN ... '23:59:59.997' bỏ sót cả hai.

Một trường hợp tình cờ đúng: chuỗi ký tự so trực tiếp với cột datetime2(0), WHERE NgayTao BETWEEN '20261001' AND '2026-10-01 23:59:59.997'. Engine đổi chuỗi sang datetime2(7), giữ nguyên .997, nên không làm tròn. Đưa cùng câu đó vào thủ tục có tham số datetime2(0) thì sai.

Cách viết đúng với mọi kiểu và mọi độ chính xác:

DECLARE @Ngay date = '20261001';

SELECT COUNT(*) AS so_don, SUM(TongTien) AS doanh_thu
FROM #Don
WHERE NgayTao >= CAST(@Ngay AS datetime2(0))
  AND NgayTao < CAST(DATEADD(DAY, 1, @Ngay) AS datetime2(0));

DROP TABLE #Don;

Kết quả: 2 đơn, 300000.00. Trên dbo.DonHang, điều kiện này còn cho phép loại partition và seek trên PK_DonHang, như câu chỉ đọc partition 2026 ở chương lưu trữ.

Giờ Việt Nam hay UTC

BanHang lưu NgayTao theo giờ máy chủ, UTC+7: đơn 10042 tạo lúc 11:58 là 11:58 giờ Việt Nam. STOPAT ở chương lưu trữ cũng dùng giờ máy SQL Server. API restore của Azure nhận giờ UTC, nên 12:06 giờ Việt Nam phải viết thành 2026-10-02T05:06:00Z ở chương Azure SQL. Việt Nam không dùng giờ mùa hè, nên UTC+7 cố định và không có giờ lặp hay giờ mất trong năm.

Quy ước giờ địa phương hỏng ở hai chỗ:

  • Azure SQL Database luôn chạy theo UTC và không đổi múi giờ được. SYSDATETIME() ở đó trả giờ UTC. Một DEFAULT SYSDATETIME() trên NgayTao sẽ ghi giờ UTC lẫn với dữ liệu cũ giờ Việt Nam, và đơn ghi lúc 06:59 sáng 01/01/2027 giờ Việt Nam rơi vào partition năm 2026. Azure SQL Managed Instance chọn múi giờ lúc tạo instance và không đổi được sau đó.
  • Dữ liệu đến từ hệ khác (log Azure, API đối tác) thường là UTC. Trộn vào cùng cột thì không còn biết dòng nào theo giờ nào.

Đổi giữa hai hệ bằng AT TIME ZONE, có từ SQL Server 2016. Tên múi giờ lấy từ sys.time_zone_info.

SELECT
    CAST('2026-10-02T11:58:00' AS datetime2(0))
        AT TIME ZONE 'SE Asia Standard Time' AS gio_viet_nam,
    CAST('2026-10-02T11:58:00' AS datetime2(0))
        AT TIME ZONE 'SE Asia Standard Time'
        AT TIME ZONE 'UTC' AS gio_utc;
gio_viet_nam gio_utc
2026-10-02 11:58:00 +07:00 2026-10-02 04:58:00 +00:00

Lần AT TIME ZONE đầu gắn độ lệch +07:00 cho một giá trị chưa có múi giờ. Lần thứ hai đổi sang múi khác. Kết quả có kiểu datetimeoffset.

Cách làm cho BanHang:

  • Giữ quy ước hiện có, NgayTao là giờ Việt Nam, và ghi rõ quy ước trong mô tả cột. Thủ tục ghi đơn tính giờ từ UTC để không phụ thuộc múi giờ máy chủ, kể cả trên Azure SQL Database: CAST(SYSUTCDATETIME() AT TIME ZONE 'UTC' AT TIME ZONE 'SE Asia Standard Time' AS datetime2(0)).
  • Bảng mới nhận thời điểm từ nhiều múi giờ lưu UTC trong datetime2(0) với tên cột nói rõ (ThoiDiemUtc), hoặc datetimeoffset(0) nếu cần giữ độ lệch gốc, thêm 2 byte mỗi dòng. datetimeoffset chỉ lưu độ lệch tại thời điểm ghi, không lưu tên múi giờ.
  • Khi lọc, đổi múi giờ ở mốc so sánh, không bọc cột trong AT TIME ZONE. Bọc cột thì mỗi dòng phải tính lại và kế hoạch thành Index Scan.

4. Chuỗi và chữ tiếng Việt

Kiểu Lưu n đếm gì Chữ tiếng Việt
char(n) n byte cố định byte Theo code page của collation
varchar(n) số byte thực + 2 byte Theo code page, hoặc đủ nếu collation _UTF8
nchar(n) 2n byte cố định cặp byte UTF-16 Đủ
nvarchar(n) 2 × số ký tự + 2 cặp byte UTF-16 Đủ

n tối đa 8.000 với char/varchar và 4.000 với nchar/nvarchar. Kiểu (max) chứa tới 2 GB và đẩy giá trị lớn sang LOB_DATA như chương lưu trữ đã mô tả.

Vietnamese_100_CI_AS dùng code page 1258 cho char và varchar. Code page này có sẵn một số chữ như Á, Â, Ê, Đ, nhưng phần lớn chữ có dấu thanh như ễ, ị không có dạng một byte dựng sẵn. Vì vậy varchar không UTF-8 không chứa trọn tên tiếng Việt, và BanHang dùng nvarchar cho mọi cột chữ tiếng Việt.

Từ SQL Server 2019, collation có hậu tố _UTF8, như Vietnamese_100_CI_AS_SC_UTF8, biến char và varchar thành kiểu Unicode mã hóa UTF-8. Hậu tố này chỉ gắn được vào collation Windows hỗ trợ ký tự bổ sung, như các collation _SC.

Một tên, ba cách lưu

Nguyễn Thị Ánh có 14 ký tự ở dạng dựng sẵn: 11 ký tự ASCII (N g u y n, T h, n h và hai dấu cách) và 3 chữ có dấu. UTF-16 tốn 2 byte cho mọi ký tự ở đây: 14 × 2 = 28 byte. UTF-8 tốn 1 byte cho ký tự ASCII, 2 byte cho Á (U+00C1), 3 byte cho ễ (U+1EC5) và ị (U+1ECB): 11 + 2 + 3 + 3 = 19 byte.

CREATE TABLE #Ten (
    TenUtf8 varchar(60) COLLATE Vietnamese_100_CI_AS_SC_UTF8 NOT NULL,
    TenUtf16 nvarchar(60) NOT NULL
);

INSERT #Ten VALUES
    (N'Nguyễn Thị Ánh', N'Nguyễn Thị Ánh'),
    (N'Trương Đức Hiếu', N'Trương Đức Hiếu');

SELECT
    TenUtf16,
    LEN(TenUtf8) AS so_ky_tu,
    DATALENGTH(TenUtf8) AS byte_utf8,
    DATALENGTH(TenUtf16) AS byte_utf16
FROM #Ten;

DROP TABLE #Ten;
TenUtf16 so_ky_tu byte_utf8 byte_utf16
Nguyễn Thị Ánh 14 19 28
Trương Đức Hiếu 15 22 30

UTF-8 tiết kiệm nhờ phần ASCII. Phần lớn chữ mang dấu thanh nằm trong khối U+1EA0 đến U+1EF9, tốn 3 byte UTF-8 so với 2 byte UTF-16, nên chuỗi dày chữ có dấu có thể lớn hơn ở UTF-8. Ba hệ quả khi dùng UTF-8:

  • varchar(n) đếm byte. varchar(10) UTF-8 không chứa được Nguyễn Thị (10 ký tự, 14 byte). INSERT báo String or binary data would be truncated.
  • Biến varchar luôn mang collation mặc định của database. Trên BanHang, DECLARE @Ten varchar(60) vẫn là code page 1258 dù cột đích là UTF-8, nên chữ mất ngay ở biến.
  • Một database trộn cột UTF-8 và cột nvarchar phải đổi mã mỗi lần so sánh chéo. Chọn UTF-8 thì chọn ở mức database, từ đầu.

BanHang giữ nvarchar cho chữ tiếng Việt. Cột chỉ chứa ASCII như MaSanPham và SoDienThoai dùng varchar.

Tiền tố N

Chuỗi '...' không có N là varchar theo code page của database hiện tại. Engine đổi chuỗi sang code page đó ngay lúc phân tích câu lệnh, trước khi giá trị tới cột nvarchar.

SELECT
    'Nguyễn Thị Ánh' AS khong_N,
    N'Nguyễn Thị Ánh' AS co_N,
    CASE WHEN 'Nguyễn Thị Ánh' = N'Nguyễn Thị Ánh'
         THEN N'bằng' ELSE N'khác' END AS so_sanh;
khong_N co_N so_sanh
Nguy?n Th? Ánh Nguyễn Thị Ánh khác

Trên BanHang, ễ và ị thành ?, Á còn vì code page 1258 có nó. Database dùng code page khác cho kết quả khác từng ký tự, nên không dựa vào việc "chữ này vẫn còn". WHERE Ten = 'Nguyễn Thị Ánh' không tìm thấy ai. UPDATE ... SET Ten = '...' ghi dấu ? vĩnh viễn. Literal tiếng Việt luôn viết N'...'.

LEN và DATALENGTH

LEN đếm ký tự và bỏ dấu cách cuối. DATALENGTH đếm byte, kể cả dấu cách cuối. LEN('SP-00042 ') là 8, DATALENGTH là 10. Phép so sánh = cũng bỏ qua dấu cách cuối, nên 'SP-00042' = 'SP-00042 ' là đúng.

Cùng một chữ có thể đến ở dạng tổ hợp: ễ là e cộng hai dấu rời U+0302 và U+0303. LEN(N'Nguyễn') dạng tổ hợp ra 8 thay vì 6. Collation Vietnamese_100_CI_AS coi hai dạng là bằng nhau. Collation nhị phân (_BIN2) thì không. Giới hạn độ dài và chỉ mục tính trên số đơn vị lưu trữ, nên chuẩn hóa về dạng dựng sẵn ở tầng ứng dụng trước khi ghi.

5. Collation: so sánh, sắp xếp và tìm không dấu

Collation quyết định ba việc: ký tự nào bằng nhau, thứ tự sắp xếp, và code page cho char/varchar. Trong Vietnamese_100_CI_AS, Vietnamese là luật ngôn ngữ, 100 là phiên bản bảng sắp xếp có từ SQL Server 2008, CI là không phân biệt hoa thường, AS là phân biệt dấu. Các hậu tố khác: CS phân biệt hoa thường, AI không phân biệt dấu, KS và WS phân biệt kana và độ rộng ký tự, SC hiểu ký tự ngoài U+FFFF như một ký tự, UTF8 mã hóa char/varchar bằng UTF-8, BIN2 so theo mã điểm Unicode thay vì theo ngôn ngữ.

Collation Windows (như Vietnamese_100_CI_AS) so varchar và nvarchar bằng cùng một thuật toán. Collation SQL (tiền tố SQL_, như SQL_Latin1_General_CP1_CI_AS) dùng luật khác nhau cho dữ liệu Unicode và không Unicode. Điểm này quyết định mục 6.

Thứ tự chữ cái tiếng Việt

SELECT STRING_AGG(v, N', ') WITHIN GROUP (ORDER BY v COLLATE Vietnamese_100_CI_AS) AS thu_tu
FROM (VALUES (N'Ánh'), (N'An'), (N'Ăn'), (N'Ân'), (N'Bình'), (N'Dũng'), (N'Đức'),
             (N'Duy'), (N'Em'), (N'Ơn'), (N'Ông'), (N'Oanh'), (N'Út'), (N'Ưng'),
             (N'Uyên')) AS t (v);
Collation Thứ tự
Vietnamese_100_CI_AS An, Ánh, Ăn, Ân, Bình, Dũng, Duy, Đức, Em, Oanh, Ông, Ơn, Út, Uyên, Ưng
Latin1_General_100_CI_AS An, Ân, Ăn, Ánh, Bình, Đức, Dũng, Duy, Em, Oanh, Ơn, Ông, Ưng, Út, Uyên

Collation tiếng Việt xếp đúng bảng chữ cái: a, ă, â; d, đ; o, ô, ơ; u, ư. Dấu thanh là khác biệt nhỏ hơn khác biệt chữ cái, nên Ánh đứng cạnh An. Collation Latin coi Đ là D có dấu và xếp Đức trước Dũng. Danh sách khách cho người Việt đọc sắp theo Vietnamese_100.

AI trong collation tiếng Việt không bỏ hết dấu

Chữ tiếng Việt có hai loại dấu. Dấu thanh (sắc, huyền, hỏi, ngã, nặng) là dấu. Mũ, trăng, móc và gạch ngang (â ă ê ô ơ ư đ) tạo ra chữ cái riêng. Vietnamese_100_CI_AI tôn trọng bảng chữ cái đó: nó bỏ qua dấu thanh, không bỏ qua chữ cái.

SELECT
    CASE WHEN N'Nguyễn' = N'nguyen' COLLATE Vietnamese_100_CI_AI THEN N'bằng' ELSE N'khác' END AS vi_nguyen,
    CASE WHEN N'Nguyễn' = N'nguyên' COLLATE Vietnamese_100_CI_AI THEN N'bằng' ELSE N'khác' END AS vi_nguyen_mu,
    CASE WHEN N'Nguyễn' = N'nguyen' COLLATE Latin1_General_100_CI_AI THEN N'bằng' ELSE N'khác' END AS latin_nguyen,
    CASE WHEN N'Đức' = N'duc' COLLATE Vietnamese_100_CI_AI THEN N'bằng' ELSE N'khác' END AS vi_duc,
    CASE WHEN N'Đức' = N'duc' COLLATE Latin1_General_100_CI_AI THEN N'bằng' ELSE N'khác' END AS latin_duc;
vi_nguyen vi_nguyen_mu latin_nguyen vi_duc latin_duc
khác bằng bằng khác bằng

Kiểm trên đủ 67 chữ có dấu của tiếng Việt (66 nguyên âm có dấu và đ): Latin1_General_100_CI_AI coi cả 67 bằng chữ gốc không dấu, cả chữ thường lẫn chữ hoa. Vietnamese_100_CI_AI chỉ coi 30 chữ là bằng, đúng là các chữ a, e, i, o, u, y mang dấu thanh. Ô tìm kiếm cho người gõ không dấu cần Latin1_General_100_CI_AI.

Tìm "nguyen" ra "Nguyễn" mà vẫn seek

WHERE Ten COLLATE Latin1_General_100_CI_AI LIKE N'nguyen%' trả đúng dòng nhưng quét. Cây B-tree của một chỉ mục trên Ten được sắp theo Vietnamese_100_CI_AS. So sánh theo collation khác thì thứ tự đó vô dụng: kế hoạch là Clustered Index Scan, hoặc Index Scan nếu có chỉ mục trên Ten, với CONVERT(nvarchar(200), [Ten]) ở vị từ.

Cách sửa là cột tính toán mang sẵn collation tìm kiếm, rồi đặt chỉ mục lên cột đó. Biểu thức Ten COLLATE ... là xác định và chính xác (COLUMNPROPERTY trả IsDeterministic = 1, IsPrecise = 1), nên chỉ mục được mà không cần PERSISTED.

dbo.KhachHang có hai ràng buộc làm việc này dài hơn bảng thường. Bảng temporal không nhận cột tính toán khi SYSTEM_VERSIONING = ON (lỗi 13724). Security policy của chương kỹ thuật được tạo với schema binding, nên chặn cả lệnh tắt versioning (lỗi 3729), và STATE = OFF không đủ để gỡ chặn. Thứ tự đã chạy thử:

DROP SECURITY POLICY dbo.pol_KhachHang;
GO

ALTER TABLE dbo.KhachHang SET (SYSTEM_VERSIONING = OFF);
GO

ALTER TABLE dbo.KhachHang
    ADD TenKhongDau AS (Ten COLLATE Latin1_General_100_CI_AI);

ALTER TABLE dbo.KhachHang_LichSu
    ADD TenKhongDau nvarchar(200) COLLATE Latin1_General_100_CI_AI NULL;
GO

UPDATE dbo.KhachHang_LichSu SET TenKhongDau = Ten;

ALTER TABLE dbo.KhachHang_LichSu
    ALTER COLUMN TenKhongDau nvarchar(200) COLLATE Latin1_General_100_CI_AI NOT NULL;
GO

ALTER TABLE dbo.KhachHang SET (
    SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.KhachHang_LichSu)
);
GO

CREATE SECURITY POLICY dbo.pol_KhachHang
ADD FILTER PREDICATE dbo.fn_KhachHang_ChiNhanh (ChiNhanhId)
    ON dbo.KhachHang
WITH (STATE = ON);
GO

CREATE INDEX IX_KhachHang_TenKhongDau
    ON dbo.KhachHang (TenKhongDau);
GO

Bảng lịch sử nhận một cột thường, cùng kiểu, cùng collation và cùng tính NOT NULL với cột tính toán. Lệch collation báo lỗi 13526, lệch nullability báo lỗi 13531, và versioning không bật lại được. Sau lần sửa tên kế tiếp, dòng lịch sử mang giá trị TenKhongDau của phiên bản cũ.

Chạy trong cửa sổ bảo trì, ứng dụng đã dừng

Từ lúc DROP SECURITY POLICY đến lúc tạo lại, nhân viên mọi chi nhánh thấy khách của nhau. Từ lúc tắt đến lúc bật SYSTEM_VERSIONING, mọi lần sửa khách không được ghi lịch sử. Lệnh tạo lại policy phải giống hệt định nghĩa ở chương kỹ thuật.

Tìm theo tên không dấu:

EXEC sys.sp_set_session_context @key = N'ChiNhanhId', @value = 3;

SELECT KhachHangId, Ten
FROM dbo.KhachHang
WHERE TenKhongDau = N'nguyen thi anh';

Kết quả là các khách chi nhánh 3 tên Nguyễn Thị Ánh. Kế hoạch là Index Seek trên IX_KhachHang_TenKhongDau, rồi lookup về PK_KhachHang, nơi bộ lọc chi nhánh được áp. Khi chỉ mục đã có, câu viết dạng WHERE Ten COLLATE Latin1_General_100_CI_AI = N'nguyen thi anh' cũng được trình tối ưu khớp sang cột tính toán và seek.

LIKE N'nguyen thi anh%' seek được trong khoảng >= N'nguyen thi anh' khi tiền tố đủ hẹp. Mỗi dòng khớp tốn một lookup, và bộ lọc chi nhánh chỉ áp sau lookup, nên khách mọi chi nhánh có tên khớp đều bị đọc. Trên bản thử 200.000 khách, một tiền tố khớp khoảng 1,2% số khách đã đủ để trình tối ưu chọn quét PK_KhachHang. Tìm theo đuôi tên (LIKE N'%anh') luôn quét: tiền tố không cố định thì B-tree không có điểm bắt đầu. Ngưỡng giữa seek kèm lookup và quét ở Chỉ mục và thống kê.

SET options cho chỉ mục trên cột tính toán và chỉ mục lọc

Mọi phiên INSERT, UPDATE, DELETE trên bảng có loại chỉ mục này phải bật ANSI_NULLS, ANSI_PADDING, ANSI_WARNINGS, ARITHABORT, CONCAT_NULL_YIELDS_NULL, QUOTED_IDENTIFIER và tắt NUMERIC_ROUNDABORT. Sai một tùy chọn thì lệnh ghi lỗi 1934, còn SELECT bỏ qua chỉ mục. Kết nối SqlClient và ODBC mặc định đạt yêu cầu. sqlcmd bản ODBC mặc định QUOTED_IDENTIFIER OFF, cần thêm cờ -I.

Bảng tạm và lỗi xung đột collation

Bảng tạm #... nằm trong tempdb và lấy collation mặc định của tempdb, tức collation của instance. Instance cài với SQL_Latin1_General_CP1_CI_AS còn BanHang là Vietnamese_100_CI_AS thì phép nối giữa bảng tạm và bảng thật thất bại:

CREATE TABLE #MaCanTim (MaSanPham varchar(20) NOT NULL);
INSERT #MaCanTim VALUES ('SP-00042'), ('SP-00043');

SELECT s.SanPhamId, s.MaSanPham
FROM dbo.SanPham AS s
JOIN #MaCanTim AS t
    ON t.MaSanPham = s.MaSanPham;
Msg 468, Level 16, State 9
Cannot resolve the collation conflict between "Vietnamese_100_CI_AS" and
"SQL_Latin1_General_CP1_CI_AS" in the equal to operation.

Sửa bằng cách khai báo cột bảng tạm theo collation của database hiện tại: MaSanPham varchar(20) COLLATE DATABASE_DEFAULT NOT NULL. Xóa bảng tạm cũ trong một batch riêng trước khi tạo lại. Nếu cùng batch, câu SELECT được biên dịch theo bảng tạm cũ còn tồn tại và vẫn báo 468. Bảng tạm tạo bằng SELECT ... INTO #t FROM dbo.SanPham giữ collation của cột nguồn. Biến bảng DECLARE @t TABLE (...) lấy collation của database hiện tại. Cả hai không gặp lỗi này. Kiểm tra collation trước khi sửa script:

SELECT
    SERVERPROPERTY('Collation') AS instance_collation,
    DATABASEPROPERTYEX(N'tempdb', 'Collation') AS tempdb_collation,
    DATABASEPROPERTYEX(N'BanHang', 'Collation') AS banhang_collation;

6. Chuyển kiểu ngầm và thứ tự ưu tiên kiểu

Khi hai vế của phép so sánh khác kiểu, SQL Server đổi vế có kiểu ưu tiên thấp hơn sang kiểu cao hơn. nvarchar cao hơn varchar. Cột MaSanPham varchar(20) so với tham số nvarchar thì chính cột bị đổi: CONVERT_IMPLICIT(nvarchar(20), MaSanPham). Hàm bọc cột thường làm mất seek.

Tham số nvarchar là mặc định ở nhiều nơi: SqlCommand.Parameters.AddWithValue với chuỗi .NET, chuỗi trong Dapper, thuộc tính string trong EF Core khi chưa khai báo IsUnicode(false).

Hậu quả phụ thuộc collation của cột. Bản sao dbo.SanPham_Latin dưới đây chỉ khác bảng gốc ở collation SQL_Latin1_General_CP1_CI_AS của MaSanPham, dùng để so sánh rồi xóa:

SELECT SanPhamId, MaSanPham, Ten
INTO dbo.SanPham_Latin
FROM dbo.SanPham;

ALTER TABLE dbo.SanPham_Latin
    ALTER COLUMN MaSanPham varchar(20) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL;

ALTER TABLE dbo.SanPham_Latin
    ADD CONSTRAINT PK_SanPham_Latin PRIMARY KEY CLUSTERED (SanPhamId),
        CONSTRAINT UQ_SanPham_Latin_Ma UNIQUE (MaSanPham);
GO

SET STATISTICS IO ON;

EXEC sys.sp_executesql
    N'SELECT SanPhamId, Ten FROM dbo.SanPham WHERE MaSanPham = @Ma;',
    N'@Ma nvarchar(4000)', @Ma = N'SP-00042';

EXEC sys.sp_executesql
    N'SELECT SanPhamId, Ten FROM dbo.SanPham_Latin WHERE MaSanPham = @Ma;',
    N'@Ma nvarchar(4000)', @Ma = N'SP-00042';

EXEC sys.sp_executesql
    N'SELECT SanPhamId, Ten FROM dbo.SanPham_Latin WHERE MaSanPham = @Ma;',
    N'@Ma varchar(20)', @Ma = 'SP-00042';

SET STATISTICS IO OFF;
GO

DROP TABLE dbo.SanPham_Latin;
Cột MaSanPham Tham số Kế hoạch Logical reads, 5.000 sản phẩm
Vietnamese_100_CI_AS (collation Windows) nvarchar(4000) Index Seek qua GetRangeThroughConvert 4
SQL_Latin1_General_CP1_CI_AS (collation SQL) nvarchar(4000) Index Scan, cảnh báo PlanAffectingConvert 18
SQL_Latin1_General_CP1_CI_AS varchar(20) Index Seek 4

Với collation Windows, varchar và nvarchar so bằng cùng một luật. Trình tối ưu tính trước một khoảng giá trị varchar tương ứng với tham số, rồi seek trong khoảng đó. Kế hoạch có thêm Constant Scan, Compute Scalar và Nested Loops trước Index Seek:

Seek Predicates: GetRangeThroughConvert([@Ma],[@Ma],(62))
Predicate:       CONVERT_IMPLICIT(nvarchar(20),[dbo].[SanPham].[MaSanPham],0)=[@Ma]

Với collation SQL, luật so sánh varchar khác luật nvarchar, nên không có khoảng tương đương. Engine đọc hết chỉ mục, đổi từng giá trị, và kế hoạch mang cảnh báo PlanAffectingConvert với ConvertIssue="Seek Plan". 18 page thay vì 4 trên 5.000 sản phẩm. Cùng lỗi trên một bảng vài chục triệu dòng là một lần quét toàn bộ cho mỗi lần tra. BanHang dùng collation Windows nên tránh được phần nặng nhất. Seek qua khoảng vẫn tốn thêm phép tính, và CONVERT_IMPLICIT trên cột có thể làm ước lượng số dòng lệch ở câu phức tạp hơn. Khi đó kế hoạch mang cảnh báo PlanAffectingConvert loại ConvertIssue="Cardinality Estimate". Câu so bằng đơn giản ở trên không có cảnh báo này trong lần chạy thử. Cách đọc cảnh báo trong kế hoạch ở Đọc kế hoạch thực thi.

Chiều ngược lại an toàn: cột nvarchar so với tham số varchar thì tham số bị đổi, cột giữ nguyên, seek bình thường. Một trường hợp nặng khác: WHERE SoDienThoai = 912345678. int ưu tiên cao hơn varchar, nên cột bị đổi sang int: không seek được, và chỉ cần một số lưu dạng +84... là câu báo lỗi chuyển kiểu.

Sửa ở ứng dụng, khai báo đúng kiểu và độ dài của cột:

// AddWithValue suy ra NVarChar từ string
cmd.Parameters.AddWithValue("@Ma", ma);

// Đúng kiểu cột: varchar(20)
cmd.Parameters.Add(new SqlParameter("@Ma", SqlDbType.VarChar, 20) { Value = ma });

// Dapper
var sanPham = conn.QuerySingleOrDefault<SanPham>(
    "SELECT SanPhamId, MaSanPham, Ten, DonGia FROM dbo.SanPham WHERE MaSanPham = @Ma;",
    new { Ma = new DbString { Value = ma, IsAnsi = true, Length = 20 } });

Với EF Core, khai báo .IsUnicode(false).HasMaxLength(20) cho thuộc tính MaSanPham trong cấu hình model, để tham số sinh ra là varchar(20).

Tìm câu đang chịu chuyển kiểu trong plan cache. Câu này đọc XML của mọi kế hoạch đang lưu, nên chạy ngoài giờ cao điểm:

SELECT TOP (20)
    qs.execution_count,
    qs.total_logical_reads,
    SUBSTRING(st.text, 1, 200) AS cau_lenh
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) AS qp
WHERE CAST(qp.query_plan AS nvarchar(max)) LIKE N'%PlanAffectingConvert%'
ORDER BY qs.total_logical_reads DESC;

7. Khóa chính

Khóa thay thế và khóa tự nhiên

MaSanPham dạng SP-00042 là khóa tự nhiên: người dùng thấy, in trên nhãn, có thể đổi định dạng. SanPhamId int là khóa thay thế: chỉ hệ thống thấy, không bao giờ đổi. BanHang dùng khóa thay thế làm khóa chính và giữ khóa tự nhiên bằng UQ_SanPham_Ma.

Khóa chính được chép sang mọi bảng tham chiếu. ChiTietDonHang.SanPhamId int chiếm 4 byte. Nếu tham chiếu bằng MaSanPham varchar(20), giá trị SP-00042 chiếm 8 byte dữ liệu cộng khoảng 4 byte quản lý cột biến độ dài, thêm khoảng 8 byte mỗi dòng. 30 triệu dòng chi tiết thêm khoảng 30.000.000 × 8 = 240.000.000 byte, gần 229 MiB, ước lượng chưa tính chỉ mục. Đổi định dạng mã thì phải sửa 30 triệu dòng thay vì 5.000.

IDENTITY và SEQUENCE

IDENTITY SEQUENCE
Gắn với Một cột của một bảng Đối tượng riêng, nhiều bảng dùng chung được
Lấy số trước khi INSERT Không. Lấy sau bằng SCOPE_IDENTITY() hoặc OUTPUT Có, NEXT VALUE FOR
Thêm vào cột đã có Không. Phải tạo cột mới Có, qua DEFAULT
Cache Bật mặc định. Từ SQL Server 2017 tắt được cho cả database bằng IDENTITY_CACHE = OFF CACHE n hoặc NO CACHE cho từng sequence
Lỗ hổng Khi rollback, và khi máy dừng đột ngột lúc còn số trong cache Như vậy

Cả hai không bảo đảm số liền nhau. Số đã cấp cho giao dịch rollback thì mất. Sequence có cache mất phần số còn trong cache khi engine dừng đột ngột, nhưng tài liệu bảo đảm không cấp trùng một số, trừ khi sequence được khai báo CYCLE hoặc bị RESTART tay. ALTER DATABASE SCOPED CONFIGURATION SET IDENTITY_CACHE = OFF giảm lỗ hổng của IDENTITY sau khởi động lại hoặc failover, đổi lại INSERT chậm hơn. Giá trị hiện tại nằm ở sys.database_scoped_configurations. Số cần liên tục tuyệt đối không lấy từ hai cơ chế này. Khi đó dùng một bảng đếm cập nhật trong cùng giao dịch, đổi lại là mọi lần ghi phải xếp hàng qua dòng đếm đó.

Khóa GUID

uniqueidentifier 16 byte, gấp đôi bigint, và mọi chỉ mục không clustered mang 16 byte đó. NEWID() sinh giá trị ngẫu nhiên. Với khóa clustered chỉ gồm GUID, mỗi dòng mới rơi vào một page bất kỳ giữa cây. Page đó đầy thì tách đôi, như chương lưu trữ đã mô tả: ghi log nhiều hơn chèn vào mép phải và để lại hai page đầy khoảng một nửa. Trong bản tiện tay ở mục 1, NgayTao đứng đầu khóa nên chèn vẫn theo thời gian. Cái giá còn lại là kích thước.

NEWSEQUENTIALID() sinh GUID tăng dần, nhưng có điều kiện:

  • Chỉ dùng được trong DEFAULT của cột uniqueidentifier, không gọi được trong câu truy vấn.
  • Chỉ tăng trong một lần khởi động Windows trên một máy. Khởi động lại máy, failover sang máy khác, hoặc chuyển database thì có thể bắt đầu từ một khoảng thấp hơn.
  • Đoán được giá trị kế tiếp. Không dùng làm mã truy cập.

SQL Server so sánh uniqueidentifier từ nhóm byte cuối trước. ORDER BY trên ba giá trị 00000000-0000-0000-0000-000000000001, 00000001-0000-0000-0000-000000000000, 00000000-0000-0000-0001-000000000000 trả giá trị thứ hai đứng đầu và giá trị thứ nhất đứng cuối. UUID phiên bản 7 sinh ở ứng dụng đặt thời gian ở đầu chuỗi, nên tăng dần theo chuỗi ký tự nhưng không tăng theo thứ tự của SQL Server.

DonHangId trong một bảng phân vùng

Khóa chính (NgayTao, DonHangId) chỉ bảo đảm cặp hai cột là duy nhất. Hai đơn cùng DonHangId, khác NgayTao, đều được nhận. Trong khi đó hóa đơn in cho khách chỉ có số đơn, và tra cứu, trả hàng, đối soát đều đi bằng DonHangId.

Chỉ mục duy nhất trên riêng DonHangId, aligned với bảng, bị từ chối. Bỏ mệnh đề ON cũng vậy, vì chỉ mục trên bảng phân vùng mặc định dùng partition scheme của bảng:

CREATE UNIQUE INDEX UX_DonHang_DonHangId
    ON dbo.DonHang (DonHangId)
    ON ps_DonHang_Ngay (NgayTao);
Msg 1908, Level 16, State 1
Column 'NgayTao' is partitioning column of the index 'UX_DonHang_DonHangId'.
Partition columns for a unique index must be a subset of the index key.

Đặt chỉ mục đó lên một filegroup (ON FG_DATA) thì tạo được, vì nó không phân vùng. Lần SWITCH kế tiếp ở chương kỹ thuật thất bại với lỗi 7733: The table 'BanHang.dbo.DonHang' is partitioned while index 'UX_DonHang_DonHangId' is not partitioned.

Phương án DonHangId duy nhất SWITCH Giá phải trả
A. SEQUENCE sinh số, không chỉ mục duy nhất Do cách sinh số. Engine không kiểm Giữ Một câu kiểm tra trùng định kỳ. Dòng nhập tay với số tự chọn có thể trùng
B. Chỉ mục duy nhất không aligned trên FG_DATA Engine bảo đảm Mất. Muốn SWITCH phải xóa chỉ mục rồi tạo lại trên 10 triệu dòng Chỉ mục không chia partition, rebuild nguyên khối
C. Chỉ mục duy nhất aligned (DonHangId, NgayTao) Không thêm gì. Cùng tập cột với khóa chính Giữ Thêm một chỉ mục mà ràng buộc không mạnh hơn
D. Bảng sổ số đơn dbo.SoDonHang (DonHangId PRIMARY KEY) không phân vùng, ghi cùng giao dịch Engine bảo đảm Giữ Thêm một lần ghi mỗi đơn và một bảng phải giữ mãi

Phương án C hay bị chọn vì tạo được và trông như ràng buộc. Nó chặn đúng những gì PK_DonHang đã chặn.

BanHang chọn A. Mọi đơn được ghi qua thủ tục ghi đơn, cùng chỗ đang giữ toàn vẹn thay cho khóa ngoại. SWITCH là đường lưu trữ năm cũ của chương kỹ thuật, nên B trả giá quá đắt. Khi có nguồn ghi khác ngoài thủ tục (nhập hàng loạt, hệ đối tác), chuyển sang D.

CREATE SEQUENCE dbo.seq_DonHangId
    AS bigint
    START WITH 1
    INCREMENT BY 1
    CACHE 1000;
GO

DECLARE @TiepTheo bigint = (SELECT ISNULL(MAX(DonHangId), 0) + 1 FROM dbo.DonHang);
DECLARE @Sql nvarchar(200) =
    N'ALTER SEQUENCE dbo.seq_DonHangId RESTART WITH '
    + CAST(@TiepTheo AS nvarchar(20)) + N';';
EXEC sys.sp_executesql @Sql;
GO

ALTER TABLE dbo.DonHang
    ADD CONSTRAINT DF_DonHang_DonHangId
    DEFAULT (NEXT VALUE FOR dbo.seq_DonHangId) FOR DonHangId;
GO

MAX(DonHangId) quét chỉ mục hẹp nhất một lần lúc tạo. Phía gọi lấy số trước, bằng SELECT NEXT VALUE FOR dbo.seq_DonHangId, rồi truyền vào thủ tục ghi đơn để dùng cùng số đó cho DonHang và các dòng ChiTietDonHang. Lấy số ngoài thủ tục, trước lần thử đầu tiên, để lần thử lại sau deadlock dùng đúng số cũ thay vì sinh thêm một đơn. Thủ tục và vòng thử lại nằm ở Transaction, khóa và mức isolation. Ràng buộc DEFAULT không cần có trên bảng staging để SWITCH chạy.

Câu kiểm tra trùng, chạy mỗi đêm:

SELECT DonHangId, COUNT(*) AS so_dong
FROM dbo.DonHang
GROUP BY DonHangId
HAVING COUNT(*) > 1;

Tra theo số đơn không kèm ngày là việc thường xuyên thì thêm một chỉ mục thường, aligned:

CREATE INDEX IX_DonHang_DonHangId
    ON dbo.DonHang (DonHangId)
    ON ps_DonHang_Ngay (NgayTao);

Dòng lá gồm DonHangId 8 byte, NgayTao 6 byte và 4 byte quản lý, cộng 2 byte slot là 20 byte, 404 dòng mỗi page, khoảng 24.753 page, gần 193 MiB cho 10 triệu đơn (ước lượng). Câu WHERE DonHangId = 10042 không có NgayTao nên seek một lần trong mỗi partition, bốn lần với pf_DonHang_Ngay. Bảng staging dùng cho SWITCH phải có chỉ mục tương ứng.

8. NULL

SQL dùng logic ba giá trị: TRUE, FALSE, UNKNOWN. Mọi phép so sánh với NULL, kể cả NULL = NULL, ra UNKNOWN. WHERE chỉ giữ dòng có điều kiện TRUE. COUNT(*) đếm dòng, COUNT(cot) bỏ dòng có cot là NULL.

NOT IN với danh sách có NULL

Phiếu trả hàng ghi số đơn in trên hóa đơn. Khách trả tại quầy mà không mang hóa đơn thì DonHangId để trống:

CREATE TABLE dbo.PhieuTraHang (
    PhieuTraHangId int NOT NULL CONSTRAINT PK_PhieuTraHang PRIMARY KEY CLUSTERED,
    DonHangId bigint NULL,
    NgayTra datetime2(0) NOT NULL,
    LyDo nvarchar(200) NOT NULL
);

INSERT dbo.PhieuTraHang (PhieuTraHangId, DonHangId, NgayTra, LyDo)
VALUES
    (1, 10042, '2026-10-02T15:00:00', N'Lỗi sản phẩm'),
    (2, NULL, '2026-10-02T16:10:00', N'Trả tại quầy, không mang hóa đơn');

Đơn tháng 10/2026 chưa từng bị trả:

SELECT COUNT(*) AS so_don
FROM dbo.DonHang AS d
WHERE d.NgayTao >= CAST('20261001' AS datetime2(0))
  AND d.NgayTao < CAST('20261101' AS datetime2(0))
  AND d.DonHangId NOT IN (SELECT p.DonHangId FROM dbo.PhieuTraHang AS p);

Kết quả: 0. x NOT IN (10042, NULL) nghĩa là x <> 10042 AND x <> NULL. Vế sau luôn UNKNOWN, nên cả điều kiện không bao giờ TRUE. Một phiếu trả không có số đơn làm báo cáo trả 0 dòng mà không báo lỗi.

SELECT COUNT(*) AS so_don
FROM dbo.DonHang AS d
WHERE d.NgayTao >= CAST('20261001' AS datetime2(0))
  AND d.NgayTao < CAST('20261101' AS datetime2(0))
  AND NOT EXISTS (
      SELECT 1
      FROM dbo.PhieuTraHang AS p
      WHERE p.DonHangId = d.DonHangId
  );

NOT EXISTS trả mọi đơn trong tháng trừ đơn 10042. Phiếu có DonHangId NULL không khớp đơn nào nên không loại đơn nào. IN không mắc lỗi này: x IN (10042, NULL) vẫn TRUE khi x là 10042. Thêm WHERE p.DonHangId IS NOT NULL vào truy vấn con cũng sửa được NOT IN, nhưng thói quen an toàn là NOT EXISTS, vì cột đang NOT NULL hôm nay có thể thành nullable sau một lần đổi schema.

Duy nhất khi có giá trị

KhachHang.SoDienThoai cho phép NULL. Yêu cầu: hai khách không trùng số, nhưng nhiều khách được để trống. Ràng buộc UNIQUE coi các NULL là trùng nhau và chỉ cho một NULL:

ALTER TABLE dbo.KhachHang
    ADD CONSTRAINT UQ_KhachHang_SoDienThoai UNIQUE (SoDienThoai);

Lệnh thất bại với lỗi 1505 ngay khi bảng có hai khách không có số: The duplicate key value is (<NULL>).

Chỉ mục duy nhất có lọc chỉ chứa dòng có số, nên chỉ kiểm tra trùng giữa các số thật:

CREATE UNIQUE INDEX UX_KhachHang_SoDienThoai
    ON dbo.KhachHang (SoDienThoai)
    WHERE SoDienThoai IS NOT NULL;

Gán cho khách thứ hai một số đã có thì lỗi 2601 Cannot insert duplicate key row ... with unique index 'UX_KhachHang_SoDienThoai'. Gán NULL cho bao nhiêu khách cũng được. Chỉ mục lọc cần đúng các SET option như ghi chú ở mục 5.

Lệnh tạo chỉ mục đọc mọi dòng, không chịu row-level security. Câu SELECT tìm số trùng trước khi tạo thì chịu, và chỉ thấy khách của chi nhánh trong SESSION_CONTEXT. Cần nhìn cả bảng thì tạm tắt lọc trong cửa sổ bảo trì bằng ALTER SECURITY POLICY dbo.pol_KhachHang WITH (STATE = OFF), rồi bật lại.

9. Bảng tra cho BanHang

Mục đích cột Kiểu Lý do
Tiền VND (TongTien, DonGia) decimal(18, 2) Chính xác, 9 byte, không làm tròn trung gian như money, không cộng dồn sai số như float
Tỷ lệ (thuế suất, chiết khấu) decimal(5, 4) 0.0800, 5 byte
Số lượng (SoLuong) int, kèm CHECK (SoLuong > 0) 4 byte. smallint chỉ khi chắc dưới 32.767
Mã trạng thái (TrangThai) tinyint, kèm CHECK hoặc bảng tra 1 byte cho 5 trạng thái. int tốn thêm 3 byte trên mọi dòng
Thời điểm tạo (NgayTao) datetime2(0), giờ Việt Nam theo quy ước 6 byte, đủ tới giây. Cần mili giây thì datetime2(3), 7 byte
Ngày không giờ (ngày sinh, ngày hiệu lực) date 3 byte, không có phần giờ để so sai
Thời điểm từ nhiều múi giờ datetime2(0) UTC hoặc datetimeoffset(0) Tên cột nói rõ UTC. datetimeoffset giữ độ lệch, thêm 2 byte
Số điện thoại (SoDienThoai) varchar(20) Là chuỗi: giữ số 0 đầu và dấu +, không làm phép tính
Mã sản phẩm (MaSanPham) varchar(20), ràng buộc UNIQUE ASCII. Khóa tự nhiên, không làm khóa chính. Tham số khai báo varchar(20)
Tên tiếng Việt (Ten) nvarchar(200) UTF-16 đủ mọi dấu. Tìm không dấu qua cột tính toán Latin1_General_100_CI_AI
Ghi chú tự do nvarchar(1000), hoặc nvarchar(max) khi có thể vượt 4.000 ký tự Giá trị lớn của (max) nằm ở LOB_DATA, không làm phình dòng gốc
Tệp đính kèm (HopDong.BanScan) varbinary(max), không đưa vào câu liệt kê PDF 2 MB là khoảng 256 page LOB_DATA. Nhiều tệp lớn thì lưu ngoài database, giữ đường dẫn
Khóa đơn hàng bigint từ SEQUENCE 8 byte, tăng dần, chèn vào mép phải cây

Những chỗ hay hiểu sai

  • "Collation AI bỏ hết dấu." Vietnamese_100_CI_AI chỉ bỏ dấu thanh. ă â ê ô ơ ư đ là chữ cái riêng. Latin1_General_100_CI_AI mới coi Nguyễn bằng nguyen.
  • "money là kiểu dành cho tiền nên chính xác cho tiền." Giá trị lưu chính xác tới 4 chữ số lẻ, nhưng money chia money làm tròn kết quả trung gian về 4 chữ số lẻ.
  • "BETWEEN ... '23:59:59.997' lấy đủ một ngày." Chỉ với cột và tham số datetime. Với datetime2 mốc đó làm sót hoặc đếm trùng.
  • "nvarchar(200) chứa 200 ký tự, varchar(200) UTF-8 cũng vậy." n đếm cặp byte với nvarchar và byte với varchar. Tiếng Việt UTF-8 có chữ tốn 3 byte.
  • "CONVERT_IMPLICIT trên cột luôn gây quét." Với collation Windows, cột varchar so với tham số nvarchar vẫn seek qua GetRangeThroughConvert. Với collation SQL thì quét.
  • "Khóa chính (NgayTao, DonHangId) làm DonHangId duy nhất." Chỉ cặp hai cột là duy nhất. Thêm UNIQUE (DonHangId, NgayTao) cũng không chặn thêm gì.
  • "UNIQUE bỏ qua NULL." Ràng buộc UNIQUE cho đúng một NULL. Chỉ mục duy nhất có lọc WHERE ... IS NOT NULL mới bỏ qua NULL.
  • "NEWSEQUENTIALID() luôn tăng." Chỉ tăng trong một lần khởi động Windows trên một máy.
  • "IDENTITY_CACHE = OFF cho số liền nhau." Rollback vẫn để lại lỗ hổng.

Đọc tiếp

Nguồn

Đọc tiếp

Trong SQL Server

Kiến trúc lưu trữ

Từ tệp trên đĩa, filegroup, page và extent đến write-ahead log, recovery model, backup và restore.

45 phút đọc

Trong PostgreSQL

Kiến trúc lưu trữ

Cluster và tablespace, page 8 KB và tuple mang thông tin MVCC, TOAST, WAL và checkpoint, base backup, WAL archive và khôi phục theo thời điểm trên database banhang.

56 phút đọc