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

12.3 — 2. SQL Server so với PostgreSQL

Tóm tắt

Bảng so sánh tính năng ít hữu ích, vì cả hai đều làm được gần như mọi thứ. Khác biệt thật sự ảnh hưởng tới code nằm ở ba chỗ. MVCC: PostgreSQL cho người đọc thấy ảnh chụp cũ nên đọc không bao giờ chặn ghi — đổi lại nó sinh dòng chết và cần VACUUM, thứ bị bỏ quên là bảng phình gấp nhiều lần. Collation: SQL Server mặc định không phân biệt hoa thường, PostgreSQL thì có — một truy vấn WHERE Email = 'A@B.com' chạy đúng ở SQL Server sẽ không tìm thấy gì ở PostgreSQL. Định danh: PostgreSQL tự chuyển tên không đặt trong nháy kép thành chữ thường, nên "CustomerId" và CustomerId là hai thứ khác nhau.

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

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

  • Giải thích MVCC và hệ quả vận hành của nó.
  • Tránh bẫy collation khi chuyển giữa hai hệ.
  • Chọn giữa NVARCHAR(MAX) + JSON và jsonb.
  • Liệt kê những gì phải sửa khi chuyển đổi.
  • Chọn hệ quản trị có lý do, không theo thói quen.

Nội dung bài học​

12.3.1 — MVCC: khác biệt lớn nhất​

PostgreSQL dùng MVCC làm cơ chế mặc định: mỗi lần UPDATE không sửa dòng cũ mà tạo một phiên bản mới, đánh dấu phiên bản cũ là chết.

UPDATE customers SET status = 'Churned' WHERE customer_id = 42;

Trước: [row v1: Active]
Sau: [row v1: Active — CHẾT] [row v2: Churned — sống]

Hệ quả tích cực: người đọc không bao giờ chặn người ghi, và ngược lại. Một SELECT dài 10 phút không cản trở UPDATE nào — nó chỉ đọc ảnh chụp tại thời điểm bắt đầu.

Hệ quả tiêu cực: dòng chết tích luỹ. Bảng cập nhật thường xuyên phình lên gấp nhiều lần dữ liệu thật nếu VACUUM không kịp chạy — gọi là table bloat. Autovacuum bật sẵn, nhưng với bảng ghi rất nhiều thì phải chỉnh:

ALTER TABLE leads SET (
autovacuum_vacuum_scale_factor = 0.05, -- mặc định 0.2
autovacuum_analyze_scale_factor = 0.02
);

Bỏ quên chuyện này là một trong những nguyên nhân phổ biến nhất khiến PostgreSQL "đột nhiên chậm" sau vài tháng chạy.

SQL Server mặc định dùng khoá: người đọc chặn người ghi và ngược lại ở mức READ COMMITTED. Nó có MVCC nhưng phải bật:

ALTER DATABASE CrmDb SET READ_COMMITTED_SNAPSHOT ON;

RCSI gần như luôn nên bật cho ứng dụng OLTP — nó xoá bỏ phần lớn tranh chấp đọc/ghi. Cái giá là tempdb phải chịu tải version store. Chi tiết ở bài 12.7 và trong bài SQL isolation level.

12.3.2 — Collation: bẫy khi chuyển đổi​

-- SQL Server, collation mặc định (ví dụ SQL_Latin1_General_CP1_CI_AS)
SELECT * FROM Users WHERE Email = 'A@B.COM'; -- TIM THAY 'a@b.com'

-- PostgreSQL
SELECT * FROM users WHERE email = 'A@B.COM'; -- KHÔNG tìm thấy 'a@b.com'

CI trong tên collation của SQL Server là case-insensitive. PostgreSQL thì luôn phân biệt hoa thường với text và varchar.

Đây là lỗi tốn nhiều thời gian nhất khi chuyển từ SQL Server sang PostgreSQL, vì code không đổi gì và không báo lỗi — chỉ là đăng nhập thất bại với người gõ email viết hoa.

Ba cách xử lý trong PostgreSQL:

-- 1. Chuẩn hoá khi ghi (tốt nhất)
email TEXT NOT NULL CHECK (email = lower(email))

-- 2. citext extension
CREATE EXTENSION citext;
ALTER TABLE users ALTER COLUMN email TYPE citext;

-- 3. Index trên biểu thức
CREATE UNIQUE INDEX uq_users_email_lower ON users (lower(email));
SELECT * FROM users WHERE lower(email) = lower($1);

Cách 1 tốt nhất: chuẩn hoá ở tầng ứng dụng, ràng buộc CHECK bảo vệ, và index thường hoạt động bình thường.

Ngược lại, chuyển từ PostgreSQL sang SQL Server thì bạn có thể vô tình gộp hai người dùng khác nhau — A@b.com và a@b.com từng là hai dòng, giờ vi phạm unique.

Còn một khác biệt về sắp xếp: ORDER BY với tiếng Việt cho kết quả khác nhau tuỳ collation. Với PostgreSQL, khai rõ:

CREATE DATABASE crm LC_COLLATE = 'vi_VN.UTF-8' LC_CTYPE = 'vi_VN.UTF-8';

12.3.3 — Định danh và kiểu dữ liệu​

-- PostgreSQL TỰ CHUYỂN về chữ thường nếu không có nháy kép
CREATE TABLE Customers (CustomerId INT); -- thanh customers(customerid)

SELECT CustomerId FROM Customers; -- OK
SELECT "CustomerId" FROM "Customers"; -- LỖI — không tồn tại

-- Dùng nháy kép khi tạo thì phải dùng mãi mãi
CREATE TABLE "Customers" ("CustomerId" INT);
SELECT customerid FROM customers; -- LOI

Quy ước PostgreSQL là snake_case không nháy kép. EF Core có UseSnakeCaseNamingConvention() để tự chuyển.

Đưa PascalCase có nháy kép từ SQL Server sang là nguồn phiền phức bất tận: mọi truy vấn viết tay phải có nháy kép, và quên một chỗ là lỗi.

SQL ServerPostgreSQLGhi chú
NVARCHAR(n)VARCHAR(n) / TEXTPostgreSQL luôn UTF-8; TEXT không chậm hơn VARCHAR
DATETIME2TIMESTAMP(3) / TIMESTAMPTZTIMESTAMPTZ gần như luôn đúng hơn
BITBOOLEANPostgreSQL có kiểu boolean thật
UNIQUEIDENTIFIERUUID
IDENTITY(1,1)GENERATED ALWAYS AS IDENTITYSERIAL là cách cũ
ROWVERSIONxmin (hệ thống)EF Core map được
NVARCHAR(MAX) + JSONJSONBKhác biệt lớn — mục sau
GETUTCDATE()NOW() AT TIME ZONE 'UTC'
TOP nLIMIT n
ISNULL(a, b)COALESCE(a, b)COALESCE là chuẩn SQL, chạy cả hai

TIMESTAMPTZ đáng nhấn mạnh: nó lưu thời điểm tuyệt đối và tự chuyển theo múi giờ phiên. TIMESTAMP không có múi giờ và gây nhầm lẫn khi hệ thống chạy ở nhiều vùng.

12.3.4 — JSON: khác biệt thực chất​

-- SQL SERVER: chỉ là chuỗi, không index được trực tiếp
ALTER TABLE Leads ADD Metadata NVARCHAR(MAX) NULL
CONSTRAINT CK_Leads_Metadata CHECK (ISJSON(Metadata) = 1);

SELECT LeadId, JSON_VALUE(Metadata, '$.utmSource') AS UtmSource
FROM Leads
WHERE JSON_VALUE(Metadata, '$.utmSource') = 'google'; -- QUET TOAN BANG

Để index được, phải tạo cột tính toán:

ALTER TABLE Leads ADD UtmSource AS JSON_VALUE(Metadata, '$.utmSource') PERSISTED;
CREATE INDEX IX_Leads_UtmSource ON Leads(UtmSource);

Nghĩa là bạn phải biết trước sẽ truy vấn theo trường nào — mất phần lớn tính linh hoạt.

-- POSTGRESQL: jsonb là kiểu dữ liệu thật, index được TOÀN BỘ
ALTER TABLE leads ADD COLUMN metadata JSONB;

CREATE INDEX idx_leads_metadata ON leads USING GIN (metadata);

-- Bất kỳ trường nào cũng dùng được index
SELECT lead_id, metadata->>'utmSource'
FROM leads
WHERE metadata @> '{"utmSource": "google"}';

GIN index phục vụ mọi khoá trong tài liệu, không cần khai trước. Đây là lợi thế rõ rệt nhất của PostgreSQL và là lý do chính chọn nó cho dữ liệu bán cấu trúc.

jsonb khác json: jsonb được phân tích thành dạng nhị phân (index được, chậm hơn khi ghi), còn json lưu nguyên văn. Gần như luôn dùng jsonb.

Đừng lạm dụng JSON

Cột JSON không có ràng buộc, không có kiểu, và không có khoá ngoại. Dữ liệu có cấu trúc rõ ràng thuộc về cột thật. JSON dành cho phần thực sự linh hoạt: cấu hình theo khách hàng, trường tuỳ biến, dữ liệu từ bên thứ ba.

12.3.5 — Chọn cái nào​

Chọn SQL Server khi:

  • Khách hàng đã có license — chi phí gần như bằng không.
  • Cần tích hợp sâu với Active Directory, SSRS, SSIS.
  • Team đã quen T-SQL, SSMS, Extended Events.
  • Cần temporal table hoặc Always On sẵn có.

Chọn PostgreSQL khi:

  • Không có ngân sách license — Express bị giới hạn 10GB mỗi database.
  • Cần jsonb cho dữ liệu bán cấu trúc.
  • Triển khai bằng container trên Linux.
  • Cần extension: PostGIS cho bản đồ, pgvector cho tìm kiếm ngữ nghĩa, TimescaleDB cho dữ liệu thời gian.
  • Muốn nhiều lựa chọn hosting giá rẻ.

Cái không nên là lý do chọn: hiệu năng. Với ứng dụng CRM, cả hai đều nhanh hơn nhiều so với chất lượng truy vấn và index của bạn. Một index thiếu làm chậm gấp 100 lần; khác biệt giữa hai hệ quản trị thường dưới 20%.

Và đừng cố viết code chạy được trên cả hai. Bạn mất mọi tính năng riêng của từng hệ để đổi lấy một khả năng mà 95% dự án không bao giờ dùng tới.

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

Danh sách rà soát khác biệt giữa hai hệ

  • •PostgreSQL: đã cấu hình autovacuum cho bảng ghi nhiều.
  • •PostgreSQL: có giám sát table bloat.
  • •SQL Server: đã bật READ_COMMITTED_SNAPSHOT cho ứng dụng OLTP.
  • •Email và định danh được chuẩn hoá chữ thường khi ghi.
  • •Biết rõ collation của database và hệ quả phân biệt hoa thường.
  • •PostgreSQL: dùng snake_case không nháy kép, không mang PascalCase sang.
  • •PostgreSQL: cột thời gian dùng TIMESTAMPTZ, không dùng TIMESTAMP.
  • •PostgreSQL: dùng jsonb, không dùng json.
  • •Dữ liệu có cấu trúc rõ ràng nằm ở cột thật, không nhét vào JSON.
  • •Không cố viết SQL chạy được trên cả hai hệ.

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

Bài 1 — Cùng một truy vấn, hai kết quả khác nhau​

Tạo cùng một bảng users trên SQL Server và PostgreSQL, chèn a@b.com, rồi truy vấn bằng A@B.COM. Ghi lại kết quả ở mỗi hệ.

Tiêu chí hoàn thành: bạn nêu được vì sao khác biệt này đặc biệt nguy hiểm khi chuyển hệ quản trị, chứ không phải khi chọn một hệ ngay từ đầu.

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

Gợi ý. Collation mặc định quyết định việc so sánh chuỗi có phân biệt hoa thường hay không — và hai hệ chọn mặc định ngược nhau.

Lời giải — SQL Server:

-- Collation mặc định (ví dụ SQL_Latin1_General_CP1_CI_AS) — CI = case insensitive
CREATE TABLE Users (Id INT IDENTITY PRIMARY KEY, Email NVARCHAR(256) NOT NULL);
INSERT INTO Users (Email) VALUES (N'a@b.com');

SELECT * FROM Users WHERE Email = N'A@B.COM';
Id  Email
1 a@b.com <- TÌM THẤY

PostgreSQL:

CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT NOT NULL);
INSERT INTO users (email) VALUES ('a@b.com');

SELECT * FROM users WHERE email = 'A@B.COM';
(0 rows)          <- KHÔNG tìm thấy

Cùng một câu lệnh, hai kết quả ngược nhau.

Vì sao nguy hiểm khi chuyển hệ. Nếu bạn chọn một hệ ngay từ đầu, hành vi của nó trở thành giả định ngầm mà cả ứng dụng được xây dựng lên trên, dù không ai viết nó ra:

// Trên SQL Server, đoạn này "đúng" — vì database tự lo phần hoa thường
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == input.Email, ct);
if (user is not null) return Conflict("Email đã tồn tại");

Chuyển sang PostgreSQL, đoạn này vẫn biên dịch sạch, vẫn chạy, vẫn không có cảnh báo nào — nhưng giờ An@company.com và an@company.com trở thành hai tài khoản khác nhau. Người dùng đăng ký lại được với email đã có, và sau đó không hiểu vì sao đăng nhập không được.

Đây đúng loại thay đổi mà không công cụ nào bắt: không phải lỗi cú pháp, không phải lỗi kiểu, không phải lỗi thời gian chạy. Chỉ là kết quả khác đi, một cách im lặng.

Ba cách xử lý, theo thứ tự nên chọn:

1. Chuẩn hoá khi ghi — tốt nhất.

public sealed record Email
{
public string Value { get; }
public Email(string value)
{
if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
throw new ArgumentException($"Email không hợp lệ: {value}");
Value = value.Trim().ToLowerInvariant(); // CHUẨN HOÁ tại đây
}
}

Dữ liệu trong database luôn ở dạng chữ thường, nên so sánh thế nào cũng cho cùng kết quả. Cách này không phụ thuộc hệ quản trị, và nó còn giải quyết luôn vấn đề khoảng trắng thừa.

2. Index trên biểu thức — khi không sửa được dữ liệu cũ.

-- PostgreSQL
CREATE UNIQUE INDEX ux_users_email_lower ON users (LOWER(email));
SELECT * FROM users WHERE LOWER(email) = LOWER('A@B.COM');

Nhớ rằng truy vấn phải dùng đúng biểu thức LOWER(email) thì index mới có tác dụng — viết WHERE email = ... là quay về quét bảng.

3. Đổi collation của cột.

-- PostgreSQL 12+ — collation không phân biệt hoa thường
CREATE COLLATION ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false);
ALTER TABLE users ALTER COLUMN email TYPE TEXT COLLATE ci;
-- SQL Server — đổi theo chiều ngược lại nếu muốn phân biệt
ALTER TABLE Users ALTER COLUMN Email NVARCHAR(256) COLLATE Latin1_General_CS_AS;

Cách này đổi hành vi ở tầng database nên ứng dụng không phải sửa gì — nhưng nó ảnh hưởng mọi truy vấn trên cột đó, kể cả những chỗ bạn muốn phân biệt.

Cái bẫy đi kèm: so sánh giữa hai cột khác collation sẽ ném lỗi.

Msg 468: Cannot resolve the collation conflict between
"Latin1_General_CS_AS" and "SQL_Latin1_General_CP1_CI_AS" in the equal to operation.

Nên nếu đổi collation, hãy đổi nhất quán cho mọi cột tham gia vào cùng một phép so sánh hay JOIN.

Và một lưu ý khi chuẩn hoá email: chỉ hạ chữ thường, đừng làm gì thêm. Có hệ thống bỏ dấu chấm trong phần trước @ vì Gmail xem chúng là tương đương — nhưng quy tắc đó chỉ đúng với Gmail, và áp cho mọi nhà cung cấp sẽ gộp nhầm hai người khác nhau thành một.


Bài 2 — Table bloat khi tắt autovacuum​

Trên PostgreSQL, tạo bảng 100.000 dòng, UPDATE toàn bộ 20 lần với autovacuum tắt, và so sánh pg_total_relation_size trước sau. Chạy VACUUM FULL và đo lại.

Tiêu chí hoàn thành: bạn nêu được vì sao UPDATE trong PostgreSQL làm bảng lớn lên, trong khi trực giác nói nó chỉ sửa tại chỗ.

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

Gợi ý. PostgreSQL dùng MVCC: người đọc không bao giờ chặn người ghi. Hãy nghĩ xem điều đó đòi hỏi gì khi một dòng đang được sửa mà có giao dịch khác đang đọc nó.

Lời giải — dựng bảng và tắt autovacuum:

CREATE TABLE leads (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL
) WITH (autovacuum_enabled = false);

INSERT INTO leads (name, status)
SELECT 'Khách hàng ' || g, 'New' FROM generate_series(1, 100000) g;

SELECT pg_size_pretty(pg_total_relation_size('leads')) AS truoc;
truoc
7168 kB
DO $$
BEGIN
FOR i IN 1..20 LOOP
UPDATE leads SET status = 'Contacted-' || i;
END LOOP;
END $$;

SELECT pg_size_pretty(pg_total_relation_size('leads')) AS sau;
sau
143 MB

Bảng phình lên khoảng 20 lần, dù số dòng không đổi.

Vì sao UPDATE làm bảng lớn lên. PostgreSQL không sửa tại chỗ. Mỗi UPDATE ghi một phiên bản mới của dòng và đánh dấu phiên bản cũ là đã chết:

UPDATE leads SET status = 'Contacted' WHERE id = 42;

Trước: [row v1: New]
Sau: [row v1: New — CHẾT] [row v2: Contacted — sống]

Đây là cái giá của MVCC, và nó đổi lại một điều rất đáng: người đọc không bao giờ chặn người ghi, và ngược lại. Một SELECT chạy 10 phút không cản UPDATE nào — nó chỉ đọc ảnh chụp tại thời điểm bắt đầu, và ảnh chụp đó chính là các phiên bản cũ còn nằm đó.

Dòng chết chỉ được thu hồi khi VACUUM chạy. Tắt autovacuum đi thì chúng tích luỹ mãi — 20 lần UPDATE toàn bảng nghĩa là 20 phiên bản chết cho mỗi dòng.

Thu hồi không gian:

VACUUM FULL leads;
SELECT pg_size_pretty(pg_total_relation_size('leads')) AS sau_vacuum;
sau_vacuum
7280 kB

Nhưng VACUUM FULL khoá bảng hoàn toàn trong suốt thời gian chạy — không đọc được, không ghi được. Với bảng lớn trên production, đó là một đợt ngừng dịch vụ.

Phân biệt hai loại VACUUM:

VACUUMVACUUM FULL
KhoáKhông chặn đọc/ghiKhoá độc quyền
Trả không gian cho hệ điều hànhKhôngCó
Tác dụngĐánh dấu không gian dùng lại đượcViết lại cả bảng
Dùng khiThường xuyên, tự độngHiếm, ngoài giờ

VACUUM thường đủ trong phần lớn trường hợp: nó không trả đĩa về hệ điều hành, nhưng nó làm cho không gian đó được tái sử dụng cho các dòng mới — nên bảng ngừng phình.

Với bảng ghi rất nhiều, chỉnh autovacuum cho riêng nó:

ALTER TABLE leads SET (
autovacuum_enabled = true,
autovacuum_vacuum_scale_factor = 0.05, -- mặc định 0.2
autovacuum_vacuum_cost_delay = 2
);

autovacuum_vacuum_scale_factor = 0.2 nghĩa là autovacuum chỉ chạy khi 20% số dòng đã chết. Với bảng một triệu dòng, đó là 200.000 dòng chết trước khi có gì xảy ra. Hạ xuống 0.05 khiến nó chạy thường xuyên hơn và giữ bảng gọn hơn.

Theo dõi bloat:

SELECT relname,
n_live_tup,
n_dead_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

dead_pct vượt 20% kéo dài là dấu hiệu autovacuum không theo kịp.

Thay thế VACUUM FULL khi không được phép khoá bảng: dùng pg_repack, nó viết lại bảng mà chỉ cần khoá rất ngắn ở cuối.

Và điều này không tồn tại trên SQL Server, vì nó dùng mô hình khác: UPDATE sửa tại chỗ, còn phiên bản cũ (khi bật RCSI) nằm ở tempdb. Đổi lại là tempdb có thể phình — vấn đề khác, ở chỗ khác.


Bài 3 — Index cho dữ liệu JSON​

Trên SQL Server, truy vấn theo một trường JSON không có cột tính toán và xem execution plan. Làm điều tương tự trên PostgreSQL với GIN index và so sánh.

Tiêu chí hoàn thành: bạn nêu được khác biệt cốt lõi về cách hai hệ lưu JSON, và điều đó quyết định gì.

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

Gợi ý. Một hệ coi JSON là một chuỗi văn bản; hệ kia coi nó là một kiểu dữ liệu có cấu trúc.

Lời giải — SQL Server:

CREATE TABLE Leads (
Id INT IDENTITY PRIMARY KEY,
Metadata NVARCHAR(MAX) NULL -- JSON chỉ là chuỗi
);

INSERT INTO Leads (Metadata)
SELECT CONCAT('{"source":"', CASE WHEN g % 3 = 0 THEN 'web' ELSE 'ads' END,
'","score":', g % 100, '}')
FROM generate_series(1, 200000) g; -- hoặc vòng lặp tương đương

SET SHOWPLAN_ALL ON;
SELECT * FROM Leads WHERE JSON_VALUE(Metadata, '$.source') = 'web';
|--Table Scan(OBJECT:([Leads]),
WHERE:(JSON_VALUE([Metadata],'$.source')='web'))
Estimated rows: 200000

Quét toàn bảng. JSON_VALUE là một hàm bọc quanh cột, nên nó không SARGable — đúng nguyên tắc ở bài 12.5. SQL Server phải phân tích JSON của từng dòng để biết giá trị.

Cách sửa trên SQL Server — cột tính toán rồi index lên nó:

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

CREATE INDEX IX_Leads_Source ON Leads(Source);

SELECT * FROM Leads WHERE Source = 'web';
|--Index Seek(OBJECT:([IX_Leads_Source]), SEEK:([Source]='web'))
Estimated rows: 66666

Nhưng chú ý điều này: bạn phải khai báo trước mỗi trường muốn truy vấn. Thêm một trường mới trong JSON là phải thêm một cột tính toán và một index nữa. JSON mất đi tính "linh hoạt" vốn là lý do người ta chọn nó.

PostgreSQL với jsonb:

CREATE TABLE leads (
id SERIAL PRIMARY KEY,
metadata JSONB NOT NULL -- KIỂU DỮ LIỆU thật, không phải chuỗi
);

INSERT INTO leads (metadata)
SELECT jsonb_build_object('source', CASE WHEN g % 3 = 0 THEN 'web' ELSE 'ads' END,
'score', g % 100)
FROM generate_series(1, 200000) g;

CREATE INDEX idx_leads_metadata ON leads USING GIN (metadata);

EXPLAIN ANALYZE
SELECT * FROM leads WHERE metadata @> '{"source":"web"}';
Bitmap Heap Scan on leads  (cost=... rows=66667)
Recheck Cond: (metadata @> '{"source": "web"}'::jsonb)
-> Bitmap Index Scan on idx_leads_metadata (cost=... rows=66667)
Index Cond: (metadata @> '{"source": "web"}'::jsonb)
Execution Time: 18.4 ms

Dùng được index, và quan trọng hơn: một index cho MỌI trường.

-- Bất kỳ trường nào cũng dùng được index
SELECT * FROM leads WHERE metadata @> '{"score": 42}';
SELECT * FROM leads WHERE metadata @> '{"source":"ads","score":7}';

Khác biệt cốt lõi — cách lưu:

SQL Server NVARCHAR(MAX)PostgreSQL JSONB
Lưu dưới dạngChuỗi văn bảnCây đã phân tích, dạng nhị phân
Phải phân tích khi đọcMỗi lần truy vấnKhông — đã phân tích sẵn
IndexMột cột tính toán cho mỗi trườngMột GIN index cho tất cả
Giữ thứ tự khoá và khoảng trắngCóKhông (chuẩn hoá)
Khoá trùngGiữ nguyênChỉ giữ cái cuối

Điều này quyết định gì. Với PostgreSQL, JSON là một lựa chọn thiết kế hợp lý cho dữ liệu thật sự có hình dạng thay đổi — thuộc tính tuỳ biến theo khách hàng, cấu hình, payload webhook. Bạn truy vấn được mà không phải biết trước cấu trúc.

Với SQL Server, JSON hợp lý cho dữ liệu bạn chỉ đọc nguyên khối, không lọc theo bên trong — ví dụ lưu lại bản gốc của một request để đối soát. Ngay khi cần lọc theo một trường, bạn phải khai báo trường đó ra, và lúc đó câu hỏi đúng là: sao không làm luôn một cột bình thường?

Và câu hỏi đó đáng hỏi ở cả hai hệ. JSON không phải cách tránh việc thiết kế lược đồ. Nếu một trường luôn tồn tại và luôn được truy vấn, nó là một cột — với kiểu dữ liệu rõ ràng, ràng buộc, và index thường. JSON dành cho phần thật sự không biết trước, xem bài 12.2.

SQL Server 2025 có kiểu json gốc gần với jsonb hơn, nhưng tại thời điểm viết bài thì phần lớn hệ thống production vẫn chạy trên bản cũ hơn — nên bảng so sánh trên vẫn là thứ bạn gặp trong thực tế.

Tự kiểm tra​

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

MVCC của PostgreSQL mang lại gì và có cái giá nào?

Mỗi UPDATE tạo phiên bản mới thay vì sửa dòng cũ, nên người đọc không bao giờ chặn người ghi và ngược lại. Cái giá là dòng chết tích luỹ, và bảng cập nhật thường xuyên sẽ phình lên gấp nhiều lần nếu VACUUM không kịp chạy. Đây là nguyên nhân phổ biến khiến PostgreSQL đột nhiên chậm sau vài tháng.

READ_COMMITTED_SNAPSHOT trên SQL Server để làm gì?

Nó bật cơ chế tương tự MVCC, xoá bỏ phần lớn tranh chấp giữa đọc và ghi ở mức READ COMMITTED. Gần như luôn nên bật cho ứng dụng OLTP; cái giá là tempdb phải chịu tải version store.

Bẫy collation khi chuyển từ SQL Server sang PostgreSQL là gì?

SQL Server mặc định không phân biệt hoa thường còn PostgreSQL thì có, nên truy vấn email viết hoa sẽ không tìm thấy dòng lưu chữ thường. Code không đổi gì và không báo lỗi, chỉ là đăng nhập thất bại; đây là lỗi tốn nhiều thời gian nhất khi chuyển đổi.

Nên xử lý phân biệt hoa thường thế nào?

Tốt nhất là chuẩn hoá về chữ thường khi ghi, với một CHECK constraint bảo vệ, khi đó index thường hoạt động bình thường. Hai cách khác là dùng extension citext hoặc tạo index trên biểu thức lower của cột.

JSON trên SQL Server khác jsonb trên PostgreSQL ra sao?

SQL Server lưu JSON như một chuỗi, nên muốn index phải tạo cột tính toán cho từng trường và bạn phải biết trước sẽ truy vấn theo trường nào. PostgreSQL có jsonb là kiểu dữ liệu thật với GIN index phục vụ mọi khoá trong tài liệu mà không cần khai trước.

Hiệu năng có nên là lý do chọn giữa hai hệ không?

Không. Với ứng dụng CRM, cả hai đều nhanh hơn nhiều so với chất lượng truy vấn và index của bạn: một index thiếu làm chậm gấp trăm lần trong khi khác biệt giữa hai hệ thường dưới hai mươi phần trăm. Chọn theo license, hệ sinh thái, extension cần dùng và kỹ năng của team.

Kết luận​

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

  1. MVCC của PostgreSQL đổi tranh chấp khoá lấy nhu cầu VACUUM. Bỏ quên nó là bảng phình.
  2. Collation khác nhau làm code chạy sai mà không báo lỗi. Chuẩn hoá chữ thường khi ghi.
  3. jsonb index được mọi khoá; JSON của SQL Server cần cột tính toán khai trước.

Tham khảo​

Điều hướng​

Bài liên quan​