Search

PostgreSQL JSON: Hướng Dẫn Đầy Đủ Với Ví Dụ Thực Tế

PostgreSQL JSON: Hướng Dẫn Đầy Đủ Với Ví Dụ Thực Tế

PostgreSQL hỗ trợ JSON từ version 9.2, nhưng phải đến khi có jsonb ở version 9.4, JSON trong PostgreSQL mới thật sự mạnh. Mạnh đến mức nhiều trường hợp bạn không cần MongoDB hay document database riêng — PostgreSQL vừa là relational database vừa xử lý JSON cực tốt.

Mình dùng JSON trong PostgreSQL hàng ngày với các dự án .NET + EF Core — từ lưu cấu hình linh hoạt cho từng tenant, đến lưu metadata sản phẩm mà schema không cố định, đến audit log ghi lại toàn bộ thay đổi dưới dạng JSON diff. Bài viết này tổng hợp những gì mình thấy thực sự hữu ích, kèm ví dụ code từ cả phía SQL lẫn C#/EF Core.


JSON vs JSONB — Chọn cái nào?

PostgreSQL có hai kiểu dữ liệu JSON: jsonjsonb. Câu trả lời ngắn: gần như luôn dùng jsonb.

json lưu text nguyên gốc — mỗi lần query phải parse lại từ đầu. jsonb lưu dạng binary đã parse sẵn — query nhanh hơn nhiều lần và hỗ trợ index.

Sự khác biệt cụ thể: json giữ nguyên format (khoảng trắng, thứ tự key, duplicate key), insert nhanh hơn một chút vì không cần parse. jsonb bỏ khoảng trắng thừa, sắp xếp lại key, loại bỏ duplicate key, insert chậm hơn chút nhưng mọi thao tác đọc và query đều nhanh hơn đáng kể.

Trường hợp duy nhất nên dùng json: khi bạn cần giữ nguyên format gốc (ví dụ lưu raw API response để debug) và không bao giờ query bên trong nội dung JSON đó. Ngoài ra, dùng jsonb.

-- Tạo bảng với cột jsonb
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    attributes JSONB DEFAULT '{}'::jsonb,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

Thao tác cơ bản — Insert, Select, Update

Insert dữ liệu JSON

INSERT INTO products (name, attributes) VALUES
('Lốp Michelin Primacy 4', '{
    "brand": "Michelin",
    "size": "205/55R16",
    "season": "summer",
    "load_index": 91,
    "speed_rating": "V",
    "prices": {"retail": 2500000, "wholesale": 2100000},
    "tags": ["premium", "fuel-efficient", "quiet"]
}'),
('Lốp Bridgestone Turanza', '{
    "brand": "Bridgestone",
    "size": "195/65R15",
    "season": "all-season",
    "load_index": 88,
    "speed_rating": "H",
    "prices": {"retail": 1800000, "wholesale": 1500000},
    "tags": ["comfortable", "durable"]
}');

Một lỗi mình từng mắc khi mới dùng: truyền JSON từ C# vào PostgreSQL mà quên cast sang jsonb. EF Core xử lý tốt việc này, nhưng nếu bạn viết raw SQL thì cần chú ý:

-- SAI — PostgreSQL không tự cast text sang jsonb
INSERT INTO products (name, attributes) VALUES ('Test', '{"key": "value"}');
-- Câu trên CÓ THỂ hoạt động tùy context, nhưng để an toàn:

-- ĐÚNG — cast rõ ràng
INSERT INTO products (name, attributes) VALUES ('Test', '{"key": "value"}'::jsonb);

Truy vấn dữ liệu JSON

PostgreSQL cung cấp hai operator chính để truy cập JSON:

-> trả về JSON element (vẫn là kiểu JSON). ->> trả về text (đã convert sang text).

-- Lấy brand dưới dạng text
SELECT name, attributes->>'brand' AS brand
FROM products;

-- Lấy giá bán lẻ (truy cập nested)
SELECT name, attributes->'prices'->>'retail' AS retail_price
FROM products;

-- Lấy tag đầu tiên trong mảng
SELECT name, attributes->'tags'->>0 AS first_tag
FROM products;

Điểm hay mà mình mất một lúc mới nhận ra: kết quả của -> là JSON nên bạn có thể chain tiếp, còn ->> trả về text nên nó phải là operation cuối cùng trong chuỗi. Nếu bạn viết attributes->>'prices'->'retail' sẽ bị lỗi vì ->> đã trả về text, không thể dùng -> trên text.

Lọc dữ liệu theo nội dung JSON

-- Tìm sản phẩm theo brand
SELECT * FROM products
WHERE attributes->>'brand' = 'Michelin';

-- Tìm sản phẩm có giá bán lẻ dưới 2 triệu
-- Lưu ý: ->> trả về text, cần cast sang numeric để so sánh
SELECT * FROM products
WHERE (attributes->'prices'->>'retail')::numeric < 2000000;

-- Tìm sản phẩm có tag "premium"
SELECT * FROM products
WHERE attributes->'tags' ? 'premium';

-- Tìm sản phẩm mùa hè hoặc all-season
SELECT * FROM products
WHERE attributes->>'season' IN ('summer', 'all-season');

Cái cast ::numeric ở trên là điều mà ai mới dùng JSON trong PostgreSQL cũng sẽ vấp. Vì ->> luôn trả về text, nên khi so sánh số hoặc sắp xếp, bạn phải cast sang kiểu dữ liệu phù hợp. Quên cast thì so sánh theo alphabetical — "9" sẽ lớn hơn "10""9" > "1" theo thứ tự chữ cái.


Operator @> — Truy vấn mạnh nhất của JSONB

Operator @> kiểm tra "JSON bên trái có chứa JSON bên phải không" (containment). Đây là operator mình dùng nhiều nhất vì nó vừa dễ đọc, vừa tận dụng được GIN index.

-- Tìm sản phẩm brand Michelin
SELECT * FROM products
WHERE attributes @> '{"brand": "Michelin"}';

-- Tìm sản phẩm mùa hè, size 205/55R16
SELECT * FROM products
WHERE attributes @> '{"season": "summer", "size": "205/55R16"}';

-- Tìm sản phẩm có tag "premium" trong mảng
SELECT * FROM products
WHERE attributes @> '{"tags": ["premium"]}';

-- Tìm sản phẩm có nested value cụ thể
SELECT * FROM products
WHERE attributes @> '{"prices": {"retail": 2500000}}';

So sánh với cách dùng ->>:

-- Cách 1: dùng ->> (không dùng được GIN index trên attributes)
WHERE attributes->>'brand' = 'Michelin' AND attributes->>'season' = 'summer';

-- Cách 2: dùng @> (dùng được GIN index, một điều kiện duy nhất)
WHERE attributes @> '{"brand": "Michelin", "season": "summer"}';

Cách 2 gọn hơn và quan trọng hơn — nếu bạn có GIN index trên cột attributes, cách 2 sẽ nhanh hơn nhiều vì PostgreSQL dùng được index. Mình sẽ nói về index ở phần sau.


Cập nhật JSON — Không phải thay cả cột

Đây là thứ nhiều người không biết: bạn có thể update một phần JSON mà không cần đọc cả object ra, sửa trong application, rồi ghi lại toàn bộ.

jsonb_set — Update một key cụ thể

-- Cập nhật giá bán lẻ
UPDATE products
SET attributes = jsonb_set(attributes, '{prices,retail}', '2800000')
WHERE id = 1;

-- Thêm key mới
UPDATE products
SET attributes = jsonb_set(attributes, '{warranty_months}', '24')
WHERE id = 1;

-- Cập nhật nested value
UPDATE products
SET attributes = jsonb_set(attributes, '{prices,promotional}', '2200000', true)
WHERE id = 1;
-- Parameter thứ 4 = true: tạo key mới nếu chưa tồn tại

Tham số thứ hai của jsonb_set là path dạng array — '{prices,retail}' nghĩa là vào key prices rồi vào key retail. Đây là cú pháp dễ nhầm — nó dùng dấu phẩy chứ không phải dấu chấm hay mũi tên.

Operator || — Merge JSON

-- Thêm nhiều key cùng lúc
UPDATE products
SET attributes = attributes || '{"origin": "Japan", "year": 2024}'::jsonb
WHERE id = 1;

-- Cập nhật nested object (merge vào prices)
UPDATE products
SET attributes = jsonb_set(
    attributes,
    '{prices}',
    attributes->'prices' || '{"flash_sale": 1900000}'::jsonb
)
WHERE id = 1;

Operator || merge hai JSON object — key trùng sẽ bị ghi đè bởi giá trị bên phải. Nhưng lưu ý: || chỉ merge ở level đầu tiên, không deep merge. Nếu bạn || hai object mà cả hai đều có key prices, toàn bộ object prices bên trái bị thay bằng bên phải — không phải merge từng key bên trong.

Xóa key

-- Xóa một key
UPDATE products
SET attributes = attributes - 'warranty_months'
WHERE id = 1;

-- Xóa nested key
UPDATE products
SET attributes = attributes #- '{prices,flash_sale}'
WHERE id = 1;

-- Xóa phần tử khỏi mảng theo giá trị
UPDATE products
SET attributes = jsonb_set(
    attributes,
    '{tags}',
    (attributes->'tags') - 'quiet'
)
WHERE id = 1;

Index cho JSONB — Đừng bỏ qua

JSONB mà không có index giống như bảng relational mà không có index — chạy được nhưng chậm khi data lớn.

GIN Index — Index tổng quát

-- Index toàn bộ cột jsonb
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);

GIN index hỗ trợ các operator: @>, ?, ?|, ?&. Đây là lý do @> nên được ưu tiên dùng thay vì ->> khi filter — vì GIN index chỉ hoạt động với @>, không hoạt động với ->>.

-- Query này DÙNG ĐƯỢC GIN index
SELECT * FROM products WHERE attributes @> '{"brand": "Michelin"}';

-- Query này KHÔNG DÙNG ĐƯỢC GIN index trên attributes
SELECT * FROM products WHERE attributes->>'brand' = 'Michelin';

B-tree Index trên expression — Cho trường hợp cụ thể

Nếu bạn thường xuyên query theo một key cụ thể bằng ->>, tạo expression index:

-- Index cho query theo brand
CREATE INDEX idx_products_brand ON products ((attributes->>'brand'));

-- Index cho query theo giá (cast sang numeric)
CREATE INDEX idx_products_retail_price ON products
    (((attributes->'prices'->>'retail')::numeric));

Giờ query WHERE attributes->>'brand' = 'Michelin' sẽ dùng được index.

Khi nào dùng GIN, khi nào dùng B-tree expression?

GIN index phù hợp khi bạn query theo nhiều key khác nhau, không biết trước key nào sẽ được query. Nó giống "index mọi thứ" — linh hoạt nhưng tốn disk space và insert chậm hơn.

B-tree expression index phù hợp khi bạn biết rõ key nào hay được query — ví dụ luôn filter theo brandseason. Nó nhỏ hơn, nhanh hơn GIN cho trường hợp cụ thể, nhưng chỉ hỗ trợ key bạn đã index.

Trong thực tế, mình thường dùng cả hai: GIN index cho cột chính (linh hoạt cho ad-hoc query), cộng thêm B-tree expression index cho những key query nhiều nhất (tối ưu performance cho hot path).


Aggregate và phân tích dữ liệu JSON

jsonb_each — Tách JSON thành rows

-- Liệt kê tất cả key-value trong attributes
SELECT p.name, kv.key, kv.value
FROM products p,
     jsonb_each(p.attributes) AS kv
WHERE p.id = 1;

jsonb_array_elements — Tách mảng thành rows

-- Liệt kê từng tag riêng biệt
SELECT p.name, tag.value AS tag
FROM products p,
     jsonb_array_elements_text(p.attributes->'tags') AS tag;

-- Đếm số lượng sản phẩm theo tag
SELECT tag.value AS tag, COUNT(*) AS product_count
FROM products p,
     jsonb_array_elements_text(p.attributes->'tags') AS tag
GROUP BY tag.value
ORDER BY product_count DESC;

jsonb_agg — Gom dữ liệu thành JSON

-- Gom tất cả sản phẩm theo brand thành JSON array
SELECT
    attributes->>'brand' AS brand,
    jsonb_agg(jsonb_build_object(
        'name', name,
        'price', attributes->'prices'->'retail'
    )) AS products
FROM products
GROUP BY attributes->>'brand';

Function jsonb_build_object rất hữu ích khi bạn cần tạo JSON response trực tiếp từ SQL — giảm việc mapping trong application layer.


Sử dụng với EF Core và C#

Đây là phần thực tế nhất với developer .NET.

Cấu hình Entity với cột JSONB

Từ EF Core 7+, PostgreSQL provider (Npgsql) hỗ trợ mapping JSON column rất tốt:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public ProductAttributes Attributes { get; set; } = new();
    public DateTime CreatedAt { get; set; }
}

public class ProductAttributes
{
    public string Brand { get; set; } = string.Empty;
    public string Size { get; set; } = string.Empty;
    public string Season { get; set; } = string.Empty;
    public int LoadIndex { get; set; }
    public string SpeedRating { get; set; } = string.Empty;
    public PriceInfo Prices { get; set; } = new();
    public List<string> Tags { get; set; } = new();
}

public class PriceInfo
{
    public decimal Retail { get; set; }
    public decimal Wholesale { get; set; }
}

Cấu hình trong DbContext:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>(entity =>
    {
        entity.Property(e => e.Attributes)
              .HasColumnType("jsonb");
    });
}

EF Core + Npgsql tự động serialize/deserialize C# object sang JSONB. Bạn làm việc với C# object bình thường, EF Core lo phần JSON.

Query JSONB qua LINQ

// Filter theo property trong JSON
var michelinProducts = await _context.Products
    .Where(p => p.Attributes.Brand == "Michelin")
    .ToListAsync();

// Filter theo nested property
var affordableProducts = await _context.Products
    .Where(p => p.Attributes.Prices.Retail < 2000000)
    .ToListAsync();

// Filter theo element trong array
var premiumProducts = await _context.Products
    .Where(p => p.Attributes.Tags.Contains("premium"))
    .ToListAsync();

// Combine nhiều điều kiện
var results = await _context.Products
    .Where(p => p.Attributes.Brand == "Michelin"
             && p.Attributes.Season == "summer"
             && p.Attributes.Prices.Retail < 3000000)
    .OrderBy(p => p.Attributes.Prices.Retail)
    .ToListAsync();

Điều tuyệt vời: EF Core translate các LINQ expression này thành SQL query đúng cú pháp JSONB. Bạn viết C# thuần, nhưng bên dưới là query dùng ->, ->> của PostgreSQL. Kiểm tra SQL log để verify — đây là thói quen tốt mình đã nhấn mạnh ở bài LINQ Performance.

Update một phần JSON trong EF Core

Đây là chỗ cần cẩn thận. Mặc định, EF Core update toàn bộ cột JSON khi bạn thay đổi bất kỳ property nào:

var product = await _context.Products.FindAsync(1);
product.Attributes.Prices.Retail = 2800000; // Chỉ sửa 1 field
await _context.SaveChangesAsync();
// EF Core UPDATE toàn bộ cột attributes, không chỉ prices.retail

Với object JSON nhỏ, điều này không thành vấn đề. Nhưng nếu cột JSON rất lớn (ví dụ lưu cả lịch sử thay đổi), bạn nên dùng raw SQL với jsonb_set để update chính xác field cần thiết:

await _context.Database.ExecuteSqlInterpolatedAsync($"""
    UPDATE "Products"
    SET "Attributes" = jsonb_set("Attributes", '{{prices,retail}}', {2800000}::text::jsonb)
    WHERE "Id" = {productId}
""");

Lưu ý cú pháp {{prices,retail}} — dấu {{}} là escape cho string interpolation trong C#, PostgreSQL nhận được {prices,retail}.


Tình huống thực tế: Khi nào dùng JSON, khi nào dùng cột riêng?

Đây là câu hỏi quan trọng nhất mà nhiều bài viết bỏ qua. JSON trong PostgreSQL mạnh, nhưng không phải lúc nào cũng nên dùng.

Nên dùng JSON khi:

Schema không cố định. Sản phẩm lốp xe có attributes khác sản phẩm mâm xe. Nếu tạo cột riêng cho mỗi attribute, bảng sẽ có hàng chục cột NULL. JSON cho phép mỗi product có attributes riêng mà không ảnh hưởng schema.

Lưu cấu hình linh hoạt. Trong hệ thống multi-tenant, mỗi tenant có cấu hình khác nhau. Thay vì tạo bảng configuration với hàng trăm cột, một cột settings kiểu JSONB linh hoạt hơn nhiều:

public class TenantSettings
{
    public int TenantId { get; set; }
    public JsonDocument Settings { get; set; } // Hoặc dùng typed class
}

Audit log và event data. Ghi lại "trước" và "sau" khi data thay đổi — JSON là format tự nhiên nhất cho việc này vì schema thay đổi theo thời gian.

Dữ liệu từ external API. Lưu response từ API bên ngoài mà schema có thể thay đổi bất kỳ lúc nào — JSON cho phép lưu nguyên mà không cần update database schema.

Không nên dùng JSON khi:

Dữ liệu có schema cố định và rõ ràng. Tên khách hàng, email, số điện thoại — những thứ mọi record đều có và schema không thay đổi. Dùng cột riêng để có type safety, constraint (NOT NULL, UNIQUE), và foreign key.

Cần JOIN hoặc foreign key. JSON không hỗ trợ foreign key constraint. Nếu brand cần reference đến bảng brands, nó phải là cột riêng với FK, không phải một key trong JSON.

Query phức tạp trên JSON là bottleneck. Dù có index, query trên JSONB vẫn chậm hơn query trên cột native. Nếu một field trong JSON được query rất thường xuyên với yêu cầu performance cao, cân nhắc "promote" nó thành cột riêng.

Nguyên tắc chung mình dùng: dữ liệu "cứng" (luôn có, hay query, cần constraint) → cột riêng. Dữ liệu "mềm" (optional, schema linh hoạt, ít query trực tiếp) → JSON.


Một số pattern hữu ích

Pattern 1: Hybrid approach — cột riêng + JSON

CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,        -- Query thường xuyên → cột riêng
    brand VARCHAR(100) NOT NULL,       -- FK candidate → cột riêng
    price NUMERIC(12,2) NOT NULL,      -- Sort/filter nhiều → cột riêng
    attributes JSONB DEFAULT '{}'::jsonb,  -- Phần linh hoạt → JSON
    created_at TIMESTAMPTZ DEFAULT NOW()
);

Cách này lấy ưu điểm của cả hai: performance và constraint cho dữ liệu quan trọng, flexibility cho phần còn lại.

Pattern 2: Lưu lịch sử thay đổi

CREATE TABLE audit_logs (
    id BIGSERIAL PRIMARY KEY,
    table_name VARCHAR(100) NOT NULL,
    record_id INTEGER NOT NULL,
    action VARCHAR(10) NOT NULL, -- INSERT, UPDATE, DELETE
    old_data JSONB,
    new_data JSONB,
    changed_by VARCHAR(100),
    changed_at TIMESTAMPTZ DEFAULT NOW()
);

-- Query: xem ai đã thay đổi giá sản phẩm
SELECT changed_by, changed_at,
       old_data->'prices'->>'retail' AS old_price,
       new_data->'prices'->>'retail' AS new_price
FROM audit_logs
WHERE table_name = 'products'
  AND record_id = 1
  AND new_data->'prices'->>'retail' IS DISTINCT FROM old_data->'prices'->>'retail'
ORDER BY changed_at DESC;

Pattern 3: Feature flags theo tenant

public class TenantConfig
{
    public int TenantId { get; set; }
    public TenantFeatures Features { get; set; } = new();
}

public class TenantFeatures
{
    public bool EnableVippsPayment { get; set; }
    public bool EnableBulkInvoice { get; set; }
    public int MaxUsersAllowed { get; set; } = 10;
    public List<string> EnabledModules { get; set; } = new();
    public Dictionary<string, string> CustomLabels { get; set; } = new();
}
// Query: lấy tất cả tenant có bật Vipps payment
var vippsTenants = await _context.TenantConfigs
    .Where(t => t.Features.EnableVippsPayment)
    .ToListAsync();

JSON cho phép thêm feature flag mới mà không cần migration — chỉ thêm property vào class C#, set default value, deploy. Các tenant cũ có JSON chưa có key mới sẽ nhận default value từ C# class.


Những lỗi phổ biến cần tránh

Lỗi 1: So sánh text với số. attributes->>'price' trả về text "2500000". Nếu so sánh > '200000' bạn đang so sánh string, không phải số. Luôn cast: (attributes->>'price')::numeric > 200000.

Lỗi 2: Quên handle NULL. Nếu key không tồn tại, -> trả về NULL, không phải lỗi. Nhưng chain tiếp trên NULL sẽ vẫn trả về NULL mà không báo lỗi — kết quả query có thể thiếu record mà bạn không nhận ra.

-- Nếu product không có key "warranty", query này trả về NULL, không lỗi
SELECT attributes->'warranty'->>'months' FROM products;

-- Handle NULL rõ ràng
SELECT COALESCE(attributes->'warranty'->>'months', '0') AS warranty_months
FROM products;

Lỗi 3: Lưu mọi thứ vào JSON vì "linh hoạt". JSON không thay thế database design. Nếu bạn có một bảng chỉ gồm iddata JSONB thì bạn đang dùng PostgreSQL như document database — lúc đó dùng MongoDB có khi còn hợp lý hơn. JSON nên bổ sung cho relational schema, không phải thay thế.

Lỗi 4: Không đặt index cho JSONB. Bảng nhỏ thì không sao, nhưng khi data lên hàng trăm ngàn records, query trên JSONB không có index sẽ full table scan. Ít nhất nên có GIN index cho cột JSONB chính.


Kết luận

PostgreSQL JSON mạnh nhất khi nó bổ sung cho relational model, không phải thay thế. Dùng cột riêng cho dữ liệu cố định, dùng JSON cho phần linh hoạt — cách tiếp cận hybrid này cho bạn cả type safety lẫn flexibility.

Ba điều cần nhớ khi làm việc với JSONB: một là dùng jsonb thay vì json trừ khi có lý do cụ thể. Hai là dùng @> operator kết hợp GIN index cho query hiệu quả. Ba là luôn cast kiểu dữ liệu khi so sánh giá trị lấy từ ->> vì nó luôn trả về text.

Với EF Core và Npgsql, bạn có thể làm việc với JSONB gần như hoàn toàn qua C# object — không cần viết raw SQL trừ khi cần partial update trên JSON lớn. Đó là sự kết hợp tốt nhất giữa developer experience và database performance.

Culi Dev

Culi Dev

Enjoy coding, enjoy life!

Leave a comment

Your email address will not be published. Required fields are marked *

Your experience on this site will be improved by allowing cookies Cookie Policy