Chuyển tới nội dung chính

12.10 — Mở rộng và đào sâu

Tóm tắt

Những chủ đề SQL nối tiếp Module 12, kèm điều kiện cụ thể. Thứ đáng học ngay và có tỷ lệ lợi ích cao nhất là window function: nó thay được cả một nhóm truy vấn lồng nhau phức tạp bằng một câu đọc được, và bạn sẽ dùng nó liên tục cho báo cáo, xếp hạng và tính luỹ kế. Thứ bị đánh giá thấp nhất là temporal table — nó cho lịch sử thay đổi đầy đủ mà không phải viết một dòng code nào, giải quyết đúng nhu cầu kiểm toán mà nhiều đội tính tới event sourcing để đáp ứng. Và một cảnh báo về partition: nó thường được kỳ vọng sai — nó giúp quản lý dữ liệu lớn, chứ hiếm khi tự làm truy vấn nhanh hơn.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Dùng window function cho xếp hạng và luỹ kế.
  • Bật temporal table cho lịch sử thay đổi.
  • Biết partition giúp gì và không giúp gì.
  • Chọn giữa cột JSON và bảng quan hệ.

Nội dung bài học​

12.10.1 — Window function: học ngay​

Điều kiện kích hoạt: ngay khi viết báo cáo hoặc xếp hạng.

-- Xếp hạng doanh số trong TỪNG khu vực
SELECT
NhanVienId,
KhuVuc,
DoanhSo,
ROW_NUMBER() OVER (PARTITION BY KhuVuc ORDER BY DoanhSo DESC) AS ThuHang,
SUM(DoanhSo) OVER (PARTITION BY KhuVuc) AS TongKhuVuc,
DoanhSo * 100.0 / SUM(DoanhSo) OVER (PARTITION BY KhuVuc) AS PhanTram
FROM DoanhSoThang
WHERE Thang = '2026-09';

Không có window function, mỗi cột trên cần một subquery riêng — truy vấn dài gấp ba và chậm hơn nhiều vì bảng bị quét nhiều lần.

Bốn hàm dùng nhiều nhất:

HàmViệc làm
ROW_NUMBER()Đánh số thứ tự, không trùng
RANK() / DENSE_RANK()Xếp hạng, đồng hạng thì cùng số
LAG() / LEAD()Lấy giá trị hàng trước/sau
SUM() OVER (ORDER BY ...)Tính luỹ kế
-- So sánh với tháng trước — LAG thay cho self-join
SELECT
Thang,
DoanhThu,
LAG(DoanhThu) OVER (ORDER BY Thang) AS ThangTruoc,
DoanhThu - LAG(DoanhThu) OVER (ORDER BY Thang) AS ChenhLech
FROM DoanhThuThang;

-- Luy ke tu dau nam
SELECT
Thang,
DoanhThu,
SUM(DoanhThu) OVER (ORDER BY Thang ROWS UNBOUNDED PRECEDING) AS LuyKe
FROM DoanhThuThang;

ROWS UNBOUNDED PRECEDING chỉ định khung cửa sổ là "từ hàng đầu tiên tới hàng hiện tại". Không có nó, mặc định là RANGE, cho kết quả khác khi có giá trị trùng trong cột sắp xếp — một khác biệt tinh vi và dễ gây sai.

RANK() và DENSE_RANK() khác nhau khi có đồng hạng: RANK cho 1, 2, 2, 4; DENSE_RANK cho 1, 2, 2, 3 (bài 12.4).

12.10.2 — Temporal table​

Điều kiện kích hoạt: nghiệp vụ cần lịch sử thay đổi để kiểm toán.

CREATE TABLE Leads (
Id UNIQUEIDENTIFIER PRIMARY KEY,
Name NVARCHAR(200),
Status NVARCHAR(50),
Value DECIMAL(18,2),
ValidFrom DATETIME2 GENERATED ALWAYS AS ROW START,
ValidTo DATETIME2 GENERATED ALWAYS AS ROW END,
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)
)
WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.LeadsHistory));

SQL Server tự ghi mọi phiên bản của mỗi hàng vào bảng lịch sử. Ứng dụng không cần biết gì.

-- Dữ liệu tại MỘT thời điểm trong quá khứ
SELECT * FROM Leads
FOR SYSTEM_TIME AS OF '2026-09-01 10:00:00'
WHERE Id = '8f3a...';

-- Toàn bộ lịch sử thay đổi của một hàng
SELECT * FROM Leads
FOR SYSTEM_TIME ALL
WHERE Id = '8f3a...'
ORDER BY ValidFrom;

Nó giải quyết đúng nhu cầu mà nhiều đội tính tới event sourcing để đáp ứng, nhưng không đổi mô hình lập trình: code vẫn UPDATE bình thường (bài 17.12).

Hai điều cần biết:

  • Bảng lịch sử tăng nhanh. Cần chính sách lưu trữ; SQL Server hỗ trợ tự dọn theo tuổi.
  • Nó ghi "cái gì đã đổi", không ghi "ai đổi và vì sao". Cần cột ModifiedBy và ModifiedReason trong bảng chính nếu kiểm toán yêu cầu điều đó.

EF Core hỗ trợ temporal table từ bản 6:

b.ToTable("Leads", t => t.IsTemporal());

// Truy vấn lịch sử
var luc = await _db.Leads.TemporalAsOf(thoiDiem).ToListAsync(ct);

12.10.3 — Columnstore index​

Điều kiện kích hoạt: bảng hàng chục triệu dòng và truy vấn chủ yếu là tổng hợp.

CREATE CLUSTERED COLUMNSTORE INDEX CCI_DoanhSoLichSu ON DoanhSoLichSu;

Khác biệt cốt lõi so với index thường:

Rowstore (index thường): lưu theo HÀNG
[Id=1, Ten=A, DoanhSo=100] [Id=2, Ten=B, DoanhSo=200] ...
-> Tot cho "lay ban ghi Id=1"

Columnstore: lưu theo CỘT
Id: [1, 2, 3, ...]
Ten: [A, B, C, ...]
DoanhSo: [100, 200, 300, ...]
-> Tốt cho "tổng DoanhSo" — chỉ đọc MỘT cột, và nén rất tốt

Kết quả điển hình: truy vấn tổng hợp trên bảng lớn nhanh hàng chục lần, và dung lượng lưu trữ giảm 5–10 lần nhờ nén.

Không dùng cho: bảng nghiệp vụ có nhiều UPDATE và truy vấn theo từng bản ghi. Columnstore tối ưu cho đọc theo cột và ghi theo lô, không tối ưu cho sửa từng hàng.

Mô hình thực dụng: rowstore cho bảng giao dịch, columnstore cho bảng lịch sử và báo cáo.

12.10.4 — Partition: kỳ vọng đúng​

Điều kiện kích hoạt: bảng rất lớn cần xoá hoặc lưu trữ dữ liệu cũ theo lô.

CREATE PARTITION FUNCTION pfTheoThang (DATETIME2)
AS RANGE RIGHT FOR VALUES ('2026-01-01', '2026-02-01', '2026-03-01');

CREATE PARTITION SCHEME psTheoThang
AS PARTITION pfTheoThang ALL TO ([PRIMARY]);

CREATE TABLE NhatKy (...) ON psTheoThang(NgayTao);

Lợi ích thật:

-- Xoá dữ liệu cũ: tức thì, không ghi log từng hàng
ALTER TABLE NhatKy SWITCH PARTITION 1 TO NhatKyLuuTru PARTITION 1;

SWITCH PARTITION chỉ đổi metadata — nó tức thì bất kể partition chứa bao nhiêu hàng. So với DELETE hàng triệu dòng (ghi log từng hàng, khoá bảng, có thể mất hàng giờ), đây là khác biệt rất lớn.

Kỳ vọng sai thường gặp: "partition làm truy vấn nhanh hơn". Nó chỉ nhanh hơn khi truy vấn lọc theo đúng cột partition và database loại bỏ được các partition không liên quan. Với truy vấn lọc theo cột khác, partition không giúp gì — thậm chí có thể chậm hơn vì phải xử lý nhiều partition.

Kết luận: partition là công cụ quản lý dữ liệu, không phải công cụ tăng tốc truy vấn. Muốn truy vấn nhanh thì thêm index.

12.10.5 — JSON trong SQL​

Điều kiện kích hoạt: dữ liệu có cấu trúc thay đổi theo từng bản ghi.

CREATE TABLE Leads (
Id UNIQUEIDENTIFIER PRIMARY KEY,
Name NVARCHAR(200),
CustomFields NVARCHAR(MAX)
CHECK (ISJSON(CustomFields) = 1) -- BAT BUOC: chan JSON hong
);

-- Truy van
SELECT Id, Name, JSON_VALUE(CustomFields, '$.industry') AS NganhNghe
FROM Leads
WHERE JSON_VALUE(CustomFields, '$.employeeCount') > 100;

Truy vấn trên quét toàn bảng vì JSON_VALUE là hàm áp lên cột (bài 12.12). Sửa bằng computed column có index:

ALTER TABLE Leads ADD NganhNghe AS JSON_VALUE(CustomFields, '$.industry') PERSISTED;
CREATE INDEX IX_Leads_NganhNghe ON Leads (NganhNghe);

PERSISTED nghĩa là giá trị được tính và lưu thật, nên index được. Không có nó, computed column tính lại mỗi lần và không index được.

Khi nào dùng JSON, khi nào dùng bảng:

Tình huốngChọn
Trường tuỳ chỉnh do khách hàng tự định nghĩaJSON
Dữ liệu ít truy vấn, chủ yếu đọc cả khốiJSON
Trường có trong mọi bản ghiCột thật
Cần lọc, sắp xếp, hoặc join thường xuyênCột thật
Cần ràng buộc toàn vẹnCột thật

Ranh giới thực dụng: nếu bạn thấy mình tạo computed column có index cho nhiều trường trong JSON, những trường đó nên là cột thật.

12.10.6 — Thứ tự nên học​

Ưu tiênChủ đềVì sao
CaoWindow functionDùng liên tục cho báo cáo, thay truy vấn lồng
CaoĐọc execution planNền tảng của mọi tối ưu
Trung bìnhTemporal tableLịch sử đầy đủ không cần viết code
Trung bìnhJSON cho trường tuỳ chỉnhKhi cấu trúc thay đổi theo bản ghi
ThấpColumnstoreChỉ với bảng hàng chục triệu dòng
ThấpPartitionCông cụ quản lý, không phải tăng tốc

Sau Module 12, bước tiếp theo là Module 13 — Entity Framework Core.

12.10.7 — Rà lại code của bạn​

Danh sách rà soát SQL nâng cao

  • •Báo cáo dùng window function thay vì nhiều subquery.
  • •Dùng ROWS thay vì để mặc định RANGE khi tính luỹ kế.
  • •Nếu cần lịch sử kiểm toán, đã cân nhắc temporal table.
  • •Bảng lịch sử có chính sách lưu trữ.
  • •Cột JSON có ràng buộc ISJSON.
  • •Trường JSON cần lọc có computed column PERSISTED và index.
  • •Trường có trong mọi bản ghi được lưu thành cột thật.
  • •Không kỳ vọng partition làm truy vấn nhanh hơn.

Bài tập áp dụng​

Bài 1 — Viết lại subquery tương quan bằng window function​

Lấy một truy vấn báo cáo có subquery và viết lại bằng OVER. So sánh thời gian.

Tiêu chí hoàn thành: bạn nêu được vì sao subquery tương quan chậm theo số dòng, còn window function thì không.

Gợi ý và lời giải — Bài 1

Gợi ý. Đếm xem bảng được đọc bao nhiêu lần trong mỗi cách viết.

Lời giải — bản subquery tương quan:

-- Mỗi dòng kèm tổng doanh số của khu vực chứa nó
SELECT l.Id, l.Name, l.Value, l.Region,
(SELECT SUM(Value) FROM Leads WHERE Region = l.Region) AS RegionTotal,
l.Value * 100.0 / (SELECT SUM(Value) FROM Leads WHERE Region = l.Region) AS Pct
FROM Leads l;
CPU time = 28.4 s,  elapsed time = 31.2 s
Table 'Leads'. logical reads 1,842,000

Bản window function:

SELECT Id, Name, Value, Region,
SUM(Value) OVER (PARTITION BY Region) AS RegionTotal,
Value * 100.0 / SUM(Value) OVER (PARTITION BY Region) AS Pct
FROM Leads;
CPU time = 1.9 s,  elapsed time = 2.3 s
Table 'Leads'. logical reads 8,900

Nhanh hơn khoảng 13 lần, đọc ít hơn 200 lần.

Vì sao subquery tương quan chậm theo số dòng. Chữ "tương quan" nghĩa là subquery tham chiếu tới dòng bên ngoài (l.Region). Vì giá trị đó đổi theo từng dòng, subquery phải được đánh giá lại cho mỗi dòng:

Dòng 1 (Region = 'HCM')  -> quét Leads, tính SUM cho HCM
Dòng 2 (Region = 'HN') -> quét Leads, tính SUM cho HN
Dòng 3 (Region = 'HCM') -> quét Leads LẠI, tính SUM cho HCM LẠI
...

Với một triệu dòng, đó là một triệu lần đánh giá — và ở đây còn nhân đôi vì subquery xuất hiện hai lần trong SELECT. Optimizer đôi khi rút gọn được bằng cách vật chất hoá kết quả trung gian, nhưng không phải lúc nào cũng làm được.

Vì sao window function không bị vậy. Nó được tính trong một lượt đi qua dữ liệu: SQL Server sắp xếp hoặc băm theo PARTITION BY, rồi vừa đi vừa tích luỹ tổng cho từng nhóm. Mỗi dòng được đọc đúng một lần, bất kể có bao nhiêu hàm cửa sổ trong câu lệnh.

Subquery tương quan:  O(số dòng × chi phí một lần quét)
Window function: O(số dòng), cộng chi phí sắp xếp nếu cần

Ba dạng viết lại thường gặp:

-- 1. Tổng theo nhóm, giữ nguyên từng dòng
SUM(Value) OVER (PARTITION BY Region)

-- 2. So sánh với dòng trước/sau — LAG thay cho self-join
LAG(Value) OVER (PARTITION BY CustomerId ORDER BY CreatedAt) AS PrevValue
LEAD(Value) OVER (PARTITION BY CustomerId ORDER BY CreatedAt) AS NextValue

-- 3. Xếp hạng trong TỪNG khu vực
ROW_NUMBER() OVER (PARTITION BY Region ORDER BY Value DESC) AS RankInRegion

Khác biệt then chốt so với GROUP BY:

-- GROUP BY: 6 dòng (một dòng mỗi trạng thái)
SELECT Status, COUNT(*) FROM Leads GROUP BY Status;

-- Window: MỌI dòng, kèm tổng số của trạng thái đó
SELECT Id, Name, Status, COUNT(*) OVER (PARTITION BY Status) FROM Leads;

GROUP BY gộp dòng lại; window function giữ nguyên từng dòng và thêm một cột tính toán. Đó là lý do bạn không thay thế cái này bằng cái kia — chúng trả lời hai câu hỏi khác nhau.

Một chi tiết về hiệu năng của window function: nếu có ORDER BY trong OVER, SQL Server cần dữ liệu được sắp xếp. Index (PARTITION BY cols, ORDER BY cols) giúp bỏ hẳn bước sắp xếp:

CREATE INDEX IX_Leads_Region_Value ON Leads(Region, Value DESC);

Không có index đó, bước Sort có thể chiếm phần lớn thời gian — và với tập dữ liệu vượt bộ nhớ, nó tràn ra tempdb.


Bài 2 — Temporal table cho một bảng đang chạy​

Bật temporal table cho một bảng, cập nhật vài lần, rồi truy vấn dữ liệu tại một thời điểm quá khứ.

Tiêu chí hoàn thành: bạn nêu được điều cần chuẩn bị trước khi bật trên một bảng đã có dữ liệu production.

Gợi ý và lời giải — Bài 2

Gợi ý. Bật versioning thêm hai cột vào bảng và tạo một bảng lịch sử. Hãy nghĩ xem điều đó ảnh hưởng gì tới một bảng đang có traffic.

Lời giải — bật cho bảng đã có dữ liệu:

-- Bước 1: thêm hai cột thời gian, cho phép NULL trước
ALTER TABLE Leads ADD
ValidFrom DATETIME2 GENERATED ALWAYS AS ROW START HIDDEN
CONSTRAINT DF_Leads_ValidFrom DEFAULT SYSUTCDATETIME(),
ValidTo DATETIME2 GENERATED ALWAYS AS ROW END HIDDEN
CONSTRAINT DF_Leads_ValidTo DEFAULT CONVERT(DATETIME2, '9999-12-31 23:59:59.9999999'),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);

-- Bước 2: bật versioning
ALTER TABLE Leads
SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.LeadsHistory));
UPDATE Leads SET Value = 8000000 WHERE Id = 1;
WAITFOR DELAY '00:00:02';
UPDATE Leads SET Status = N'Won' WHERE Id = 1;
-- Dữ liệu tại MỘT thời điểm trong quá khứ
SELECT * FROM Leads FOR SYSTEM_TIME AS OF '2026-09-25 09:14:20' WHERE Id = 1;

-- Toàn bộ lịch sử thay đổi của một hàng
SELECT Value, Status, ValidFrom, ValidTo
FROM Leads FOR SYSTEM_TIME ALL WHERE Id = 1 ORDER BY ValidFrom;
Value     Status  ValidFrom                ValidTo
5000000 New 2026-09-25 09:14:18.12 2026-09-25 09:14:20.34
8000000 New 2026-09-25 09:14:20.34 2026-09-25 09:14:22.56
8000000 Won 2026-09-25 09:14:22.56 9999-12-31 23:59:59.99

Bốn thứ phải chuẩn bị trước khi bật trên production:

1. Thêm cột là thao tác thay đổi schema — phải chọn thời điểm. Trên SQL Server bản mới, thêm cột có DEFAULT là thao tác metadata nên nhanh; nhưng hãy kiểm chứng trên staging với đúng lượng dữ liệu thật trước.

2. Ước lượng dung lượng bảng lịch sử. Mỗi UPDATE sinh một dòng lịch sử. Bảng cập nhật 10.000 lần một ngày sẽ sinh 3,6 triệu dòng lịch sử mỗi năm:

SELECT COUNT(*) AS updates_per_day FROM ...;     -- ước lượng từ log hoặc audit hiện có

3. Đặt chính sách giữ lịch sử ngay từ đầu, đừng để sau:

ALTER TABLE Leads SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.LeadsHistory,
HISTORY_RETENTION_PERIOD = 2 YEARS
));
ALTER DATABASE Crm SET TEMPORAL_HISTORY_RETENTION ON;

4. Biết trước quy trình đổi schema sau này. Khi cần thêm hay sửa cột, bạn phải tắt versioning, sửa cả hai bảng, rồi bật lại:

ALTER TABLE Leads SET (SYSTEM_VERSIONING = OFF);
ALTER TABLE Leads ADD Source NVARCHAR(50) NULL;
ALTER TABLE LeadsHistory ADD Source NVARCHAR(50) NULL;
ALTER TABLE Leads SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.LeadsHistory));

Trong khoảng tắt versioning, thay đổi không được ghi lịch sử. Với migration tự động qua EF Core, điều này phải được đưa vào script migration một cách tường minh — nếu không, dotnet ef database update sẽ thất bại.

Truy vấn từ EF Core:

var snapshot = await _db.Leads
.TemporalAsOf(new DateTime(2026, 9, 25, 9, 14, 20, DateTimeKind.Utc))
.Where(l => l.Id == 1)
.ToListAsync(ct);

Và nhớ rằng AS OF nhận thời gian UTC. Truyền giờ địa phương vào là nhận về trạng thái của bảy tiếng trước hoặc sau — một lỗi rất dễ mắc và rất khó nhận ra, vì kết quả vẫn "hợp lý".

Khi nào không dùng temporal table: bảng ghi rất nhiều mà giá trị lịch sử thấp (log, event, session), và bảng mà bạn chỉ cần biết ai đã đổi chứ không cần biết giá trị cũ là gì — trường hợp đó một bảng audit đơn giản với UserId và Action phù hợp hơn, vì temporal table không ghi lại ai thực hiện thay đổi.


Bài 3 — Index cho truy vấn theo trường JSON​

Truy vấn theo JSON_VALUE không index, rồi thêm computed column PERSISTED có index và đo lại logical reads.

Tiêu chí hoàn thành: bạn nêu được vì sao phải có PERSISTED, và giới hạn của cách này so với jsonb của PostgreSQL.

Gợi ý và lời giải — Bài 3

Gợi ý. Index lưu giá trị đã tính sẵn. Hãy nghĩ xem SQL Server cần gì để chắc rằng giá trị đó không đổi.

Lời giải — không index:

SET STATISTICS IO ON;

SELECT Id, Name FROM Leads
WHERE JSON_VALUE(Metadata, '$.source') = N'web';
|--Clustered Index Scan(OBJECT:([PK_Leads]),
WHERE:(JSON_VALUE([Metadata],'$.source')=N'web'))

Table 'Leads'. logical reads 18,420

Thêm computed column và index:

ALTER TABLE Leads
ADD Source AS JSON_VALUE(Metadata, '$.source') PERSISTED;

CREATE INDEX IX_Leads_Source ON Leads(Source);

SELECT Id, Name FROM Leads WHERE Source = N'web';
|--Index Seek(OBJECT:([IX_Leads_Source]), SEEK:([Source]=N'web'))

Table 'Leads'. logical reads 94

Từ 18.420 xuống 94 — gần 200 lần.

Vì sao phải có PERSISTED. Không có nó, cột tính toán chỉ là một biểu thức được đánh giá mỗi lần đọc — giá trị không được lưu ở đâu cả.

Để tạo index trên một cột, SQL Server cần giá trị đó ổn định và lưu được. Và nó chỉ chấp nhận điều đó khi biểu thức là deterministic (cùng đầu vào luôn cho cùng đầu ra) và precise. JSON_VALUE thoả điều kiện, nhưng SQL Server vẫn yêu cầu PERSISTED cho các biểu thức trên kiểu NVARCHAR(MAX) để đảm bảo giá trị được vật chất hoá cùng dòng.

Thiếu PERSISTED:

Msg 2739: The text, ntext, and image data types are invalid for local variables.
-- hoặc
Msg 1904: ... column is not deterministic or not precise.

Cái giá của PERSISTED: giá trị được lưu thật trong mỗi dòng, nên bảng phình thêm, và mỗi lần Metadata đổi thì cột tính toán phải được tính lại và ghi lại.

Giới hạn so với jsonb của PostgreSQL — và đây là điểm chính:

-- PostgreSQL: MỘT index cho MỌI trường
CREATE INDEX idx_leads_metadata ON leads USING GIN (metadata);

SELECT * FROM leads WHERE metadata @> '{"source":"web"}';
SELECT * FROM leads WHERE metadata @> '{"score": 42}';
SELECT * FROM leads WHERE metadata @> '{"campaign":"q4"}';
-- cả ba đều dùng được index đó
-- SQL Server: MỘT cột tính toán + MỘT index cho MỖI trường
ALTER TABLE Leads ADD Source AS JSON_VALUE(Metadata, '$.source') PERSISTED;
ALTER TABLE Leads ADD Score AS JSON_VALUE(Metadata, '$.score') PERSISTED;
ALTER TABLE Leads ADD Campaign AS JSON_VALUE(Metadata, '$.campaign') PERSISTED;
CREATE INDEX IX_Leads_Source ON Leads(Source);
CREATE INDEX IX_Leads_Score ON Leads(Score);
CREATE INDEX IX_Leads_Campaign ON Leads(Campaign);

Bạn phải biết trước mọi trường sẽ truy vấn — và điều đó mâu thuẫn với lý do người ta chọn JSON ngay từ đầu. Thêm một trường mới là thêm một cột, một index, và một migration.

Ba index đó còn kéo theo chi phí ghi đo được ở bài 12.4.

Kết luận thực dụng:

Tình huốngLựa chọn
PostgreSQL, dữ liệu thật sự linh hoạtjsonb + GIN index
SQL Server, biết trước vài trường cần lọcComputed column PERSISTED + index
SQL Server, trường luôn tồn tại và luôn lọcLàm hẳn một cột bình thường
Chỉ đọc nguyên khối, không lọc bên trongNVARCHAR(MAX), không cần index gì

Dòng thứ ba đáng dừng lại: nếu bạn đang tạo computed column cho một trường luôn có mặt trong mọi bản ghi, thì trường đó không thuộc về JSON — nó là một cột, với kiểu dữ liệu rõ ràng, ràng buộc NOT NULL và index thường.

Tự kiểm tra​

Câu hỏi thường gặp

Window function thay thế được gì?

Nó thay cả một nhóm subquery lồng nhau bằng một câu đọc được. Xếp hạng trong nhóm, so sánh với hàng trước, tính luỹ kế — mỗi thứ trước đây cần một subquery riêng và bảng bị quét nhiều lần.

Vì sao cần ROWS UNBOUNDED PRECEDING khi tính luỹ kế?

Vì mặc định khung cửa sổ là RANGE, cho kết quả khác khi có giá trị trùng trong cột sắp xếp. Đây là khác biệt tinh vi và dễ gây sai số liệu mà không ai phát hiện.

Temporal table giải quyết vấn đề gì?

Nó cho lịch sử thay đổi đầy đủ mà không phải viết dòng code nào, và không đổi mô hình lập trình vì code vẫn UPDATE bình thường. Đó là nhu cầu mà nhiều đội tính tới event sourcing để đáp ứng.

Temporal table không ghi lại điều gì?

Nó ghi cái gì đã đổi nhưng không ghi ai đổi và vì sao. Nếu kiểm toán yêu cầu những thông tin đó thì cần thêm cột ModifiedBy và ModifiedReason trong bảng chính.

Kỳ vọng sai thường gặp về partition là gì?

Rằng nó làm truy vấn nhanh hơn. Thực tế nó chỉ nhanh hơn khi truy vấn lọc đúng theo cột partition. Lợi ích thật của nó là SWITCH PARTITION cho phép xoá hoặc lưu trữ dữ liệu cũ tức thì thay vì DELETE hàng triệu dòng.

Khi nào dữ liệu nên là cột thật thay vì JSON?

Khi trường có trong mọi bản ghi, khi cần lọc sắp xếp hay join thường xuyên, hoặc khi cần ràng buộc toàn vẹn. Nếu bạn phải tạo computed column có index cho nhiều trường trong JSON, những trường đó nên là cột thật.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Window function là thứ đáng học ngay — nó thay cả nhóm truy vấn lồng bằng một câu.
  2. Temporal table cho lịch sử đầy đủ không cần viết code — cân nhắc trước khi nghĩ tới event sourcing.
  3. Partition là công cụ quản lý dữ liệu, không phải công cụ tăng tốc truy vấn.

Tham khảo​

Điều hướng​