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. Kiểu dữ liệu là quyết định vật lý
- 2. Tiền: decimal, money, float
- 3. Ngày giờ: kích thước, độ chính xác và múi giờ
- 4. Chuỗi và chữ tiếng Việt
- 5. Collation: so sánh, sắp xếp và tìm không dấu
- 6. Chuyển kiểu ngầm và thứ tự ưu tiên kiểu
- 7. Khóa chính
- 8. NULL
- 9. Bảng tra cho BanHang
- Những chỗ hay hiểu sai
- Đọc tiếp
- 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
DonHanglặp lại 10 triệu lần và chép sang mọi chỉ mục không clustered. Bản dùnguniqueidentifier,datetime,int,moneylà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).moneylàm tròn kết quả chia về 4 chữ số lẻ.floatcộ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ốc23:59:59.997chỉ hợp vớidatetime. Vớidatetime2nó làm sót hoặc đếm trùng đơn. - Chữ tiếng Việt cần
nvarcharvà tiền tốN'...'. ThiếuN,'Nguyễn Thị Ánh'thànhNguy?n Th? Ánhtrước khi chạm tới bảng. Vietnamese_100_CI_AIchỉ bỏ qua dấu thanh.ê,ơ,ư,đvẫn là chữ riêng, nênnguyenkhông khớpNguyễn. Tìm không dấu dùngLatin1_General_100_CI_AItrên một cột tính toán có chỉ mục.DonHangIdkhông tự duy nhất vì khóa chính là(NgayTao, DonHangId). Sinh số bằngSEQUENCEvà giữ mọi chỉ mục aligned đểSWITCHcòn chạy.NOT INvới danh sách có NULL trả 0 dòng. DùngNOT 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ộtDEFAULT SYSDATETIME()trênNgayTaosẽ 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ó,
NgayTaolà 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ặcdatetimeoffset(0)nếu cần giữ độ lệch gốc, thêm 2 byte mỗi dòng.datetimeoffsetchỉ 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ànhIndex 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 đượcNguyễn Thị(10 ký tự, 14 byte).INSERTbáoString or binary data would be truncated.- Biến
varcharluôn mang collation mặc định của database. TrênBanHang,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
nvarcharphả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
DEFAULTcủa cộtuniqueidentifier, 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
AIbỏ hết dấu."Vietnamese_100_CI_AIchỉ bỏ dấu thanh.ă â ê ô ơ ư đlà chữ cái riêng.Latin1_General_100_CI_AImới coiNguyễnbằngnguyen. - "
moneylà 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ưngmoneychiamoneylà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ớidatetime2mố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ớinvarcharvà byte vớivarchar. Tiếng Việt UTF-8 có chữ tốn 3 byte. - "
CONVERT_IMPLICITtrên cột luôn gây quét." Với collation Windows, cộtvarcharso với tham sốnvarcharvẫn seek quaGetRangeThroughConvert. Với collation SQL thì quét. - "Khóa chính
(NgayTao, DonHangId)làmDonHangIdduy nhất." Chỉ cặp hai cột là duy nhất. ThêmUNIQUE (DonHangId, NgayTao)cũng không chặn thêm gì. - "
UNIQUEbỏ qua NULL." Ràng buộcUNIQUEcho đúng một NULL. Chỉ mục duy nhất có lọcWHERE ... IS NOT NULLmớ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 = OFFcho số liền nhau." Rollback vẫn để lại lỗ hổng.
Đọc tiếp
- Seek, scan, lookup, và cách trình tối ưu chọn giữa chúng cho các chỉ mục trong bài này: Chỉ mục, thống kê và cách dữ liệu được tìm.
- Thủ tục ghi đơn chạy trong một transaction. Khóa và mức isolation của nó: Transaction, khóa và mức isolation.
- Cảnh báo
CONVERT_IMPLICITvàPlanAffectingConverthiện ra thế nào trong kế hoạch: Đọc kế hoạch thực thi. - Một truy vấn chậm được điều tra từ đầu đến cuối: Điều tra một truy vấn chậm.
- Cùng câu hỏi về byte trên page ở engine khác: PostgreSQL — Kiến trúc lưu trữ.