Cơ sở dữ liệuPostgreSQL, phần 2/5

Page, tuple và TOAST

Một dòng đơn hàng chiếm 60 byte trên page PostgreSQL vì header MVCC 23 byte; giá trị dài được nén hoặc cắt sang bảng TOAST; code .NET đặt cột đúng thứ tự và chỉ đọc cột cần.

Mục lục
  1. 1. Page và tuple
  2. 2. Dữ liệu lớn: TOAST
  3. 3. Áp dụng trong .NET
  4. Những chỗ hay hiểu sai
  5. Kết luận
  6. Đọc tiếp
  7. Nguồn

Một dòng don_hang chiếm 60 byte trên page PostgreSQL, so với 37 byte ở SQL Server, dù dữ liệu cột gần bằng nhau. Không hiểu chỗ chênh đó thì ước lượng dung lượng sai, đặt cột sai thứ tự, và mỗi lần mở trang hợp đồng lại kéo theo cả bản scan PDF. Đọc xong, bạn tự tính được byte của một dòng, đếm được dòng TOAST, và viết truy vấn .NET chỉ đọc cột cần.

Đọc nhanh

  • Mỗi tuple mang header riêng cho MVCC, nên một page chỉ chứa 136 dòng đơn hàng.
  • Thứ tự cột quyết định số byte đệm căn lề: cột rộng cố định đặt trước, cột độ dài thay đổi đặt sau.
  • Giá trị làm tuple vượt khoảng 2 KB được nén, hoặc cắt thành chunk trong bảng TOAST riêng của bảng chính.
  • Giá trị TOAST chỉ được đọc khi truy vấn cần cột đó, nên lấy cả entity EF Core đắt hơn một phép chiếu.

Bài dùng bảng don_hang và tablespace ts_data dựng ở bài cluster, tablespace và partition, mốc PostgreSQL 17 trên máy 64 bit.

1. Page và tuple

Cấu trúc một page

Vùng Kích thước Nội dung
Page header 24 byte pd_lsn (LSN của bản ghi WAL sửa page gần nhất), checksum, cờ, pd_lower, pd_upper, pd_special, phiên bản, pd_prune_xid
Line pointer 4 byte mỗi cái Độ lệch, độ dài và trạng thái của một tuple. Mọc từ sau header đi xuống
Tuple Thay đổi Ghi từ cuối vùng trống đi ngược lên
Special space 0 với bảng B-tree dùng vùng này để giữ liên kết sang page anh em

Hướng mọc ngược với SQL Server, nơi dòng mọc từ trên xuống và slot array mọc từ cuối page lên. Chỗ trống của page là khoảng giữa pd_lower (cuối dãy line pointer) và pd_upper (đầu vùng tuple). Địa chỉ của một tuple là ctid = (số page, số line pointer). Chỉ mục lưu ctid. Engine dời được tuple trong page khi dồn chỗ trống mà không làm hỏng chỉ mục, vì line pointer giữ nguyên số thứ tự.

Hình dưới là page 0 của don_hang_truoc_2025 khi đầy. Các con số được tính ở hai phần ngay sau.

0 24 pd_lower = 568 576 Header 24 B lp 1 lp 2 … mọc sang phải → lp 136 trống 8 B tuple 136 ← mọc sang trái … tuple 2 tuple 1 576 = pd_upper 8.080 8.136 8.192
Page đầy của don_hang: 24 + 136 × 4 + 8 + 136 × 56 = 8.192 byte.

Header của tuple

Mỗi tuple của bảng mở đầu bằng header cố định 23 byte trên phần lớn máy:

Trường Byte Giữ gì
t_xmin 4 Giao dịch đã insert phiên bản này
t_xmax 4 Giao dịch đã xóa hoặc thay nó, 0 nếu chưa có
t_cid 4 Số thứ tự lệnh trong giao dịch
t_ctid 6 ctid của chính nó hoặc của phiên bản mới hơn
t_infomask2, t_infomask 2 + 2 Số cột, cờ có NULL, hint bit commit, cờ HOT
t_hoff 1 Độ lệch tới phần dữ liệu

Sau header là null bitmap, chỉ có khi dòng có ít nhất một NULL, một bit cho mỗi cột. Dữ liệu bắt đầu ở t_hoff, luôn là bội số của MAXALIGN. Trên máy 64 bit, MAXALIGN là 8, nên header 23 byte thực chất chiếm 24 byte. Bảng đến 8 cột có null bitmap 1 byte vừa lấp byte thứ 24.

Mỗi cột còn được căn theo kiểu của nó: bigint và timestamptz căn 8, int căn 4, smallint căn 2. Theo tài liệu TOAST, giá trị độ dài thay đổi ngắn dùng header varlena 1 byte và không căn theo ranh giới nào.

Một dòng đơn hàng chiếm bao nhiêu byte

tong_tien là numeric, độ dài thay đổi. Tài liệu mô tả: giá trị lưu không kèm số 0 thừa ở đầu và cuối; mỗi nhóm 4 chữ số thập phân tốn 2 byte, cộng 3 đến 8 byte overhead. Nhóm tính từ dấu thập phân, và các nhóm 0 ở cuối bị bỏ:

Giá trị Nhóm 4 chữ số Nhóm còn lưu Byte
1500000.00 150, 0000 trước dấu phẩy; 00 sau dấu phẩy 1 2 + 3 = 5
1750000.00 Như trên 1 5
12345678.90 Ba nhóm khác 0 3 6 + 3 = 9

Dòng don_hang_id = 10042, không cột nào NULL:

Phần Kiểu Căn lề Vị trí trong tuple Byte
Header 0–22 23
Đệm tới t_hoff 23 1
don_hang_id bigint 8 24–31 8
ngay_tao timestamptz(0) 8 32–39 8
khach_hang_id int 4 40–43 4
trang_thai smallint 2 44–45 2
tong_tien numeric, header 1 byte không căn 46–50 5
Độ dài tuple 51
Làm tròn MAXALIGN khi đặt lên page 56
Line pointer 4
Tổng mỗi dòng trên page 60

Đơn dưới 100 triệu đồng cần tối đa ba nhóm chữ số, nên tong_tien từ 5 đến 9 byte và tuple từ 51 đến 55 byte. Làm tròn lên bội số 8, mọi tuple như vậy đều chiếm 56 byte. Con số 60 byte mỗi dòng đứng vững cho gần như mọi đơn của banhang.

Thứ tự cột ảnh hưởng tới đệm. Nếu khach_hang_id int đứng trước ngay_tao, engine chèn 4 byte đệm để ngay_tao căn 8. Bảng mới nên đặt cột 8 byte trước, rồi 4 byte, 2 byte, cuối cùng là cột độ dài thay đổi.

Bao nhiêu dòng một page

Bảng mặc định fillfactor 100, tức insert lấp đầy page. Trừ header page, chia cho 60 byte mỗi dòng, page chứa tối đa 136 dòng:

Phần dùng được = 8.192 − 24        = 8.168 byte
Số dòng tối đa = 8.168 / 60        = 136, dư 8.168 − 136 × 60 = 8 byte
pd_lower       = 24 + 136 × 4      = 568
pd_upper       = 8.192 − 136 × 56  = 576

Bảng dưới là ước lượng theo công thức, cho bảng vừa nạp, chưa có UPDATE hay DELETE, chưa tính chỉ mục. Số page là số dòng chia 136, làm tròn lên. Kích thước là số page × 8 KB.

Partition Dòng Page Kích thước
don_hang_truoc_2025 3.000.000 22.059 khoảng 172 MB
don_hang_2025 3.400.000 25.000 khoảng 195 MB
don_hang_2026 3.600.000 26.471 khoảng 207 MB
Tổng 10.000.000 73.530 khoảng 574 MB

Vì sao lớn hơn 37 byte của SQL Server

Phần SQL Server PostgreSQL
Dữ liệu cột 28 27
Header dòng 4 23
Null bitmap 3 0
Đệm căn lề 0 6 (1 trước dữ liệu, 5 cuối tuple)
Slot hoặc line pointer 2 4
Tổng mỗi dòng 37 60
Dòng mỗi page 218 136
Page cho 1 triệu dòng 4.588 7.353

Phần dữ liệu gần như bằng nhau. timestamptz lớn hơn datetime2(0) 2 byte, smallint lớn hơn tinyint 1 byte, nhưng numeric 5 byte nhỏ hơn decimal(18, 2) cố định 9 byte.

Chênh lệch nằm ở overhead: 33 byte mỗi dòng so với 9. 1 triệu đơn chiếm khoảng 57 MB heap so với khoảng 36 MB clustered index, gấp khoảng 1,6 lần. Bảng có dòng rộng thì overhead cố định chiếm tỷ lệ nhỏ hơn.

Dữ liệu cột gần bằng nhau, phần chênh nằm ở header, đệm và line pointer

Dữ liệu cộtHeader dòngNull bitmapĐệm căn lềSlot hoặc line pointer
SQL Server37 bytePostgreSQL60 byte
Một dòng đơn hàng, không cột nào NULL, theo bảng trên. Tính từ cấu trúc lưu trữ, không phải số đo.
Bảng số liệu
Dữ liệu cộtHeader dòngNull bitmapĐệm căn lềSlot hoặc line pointerTổng
SQL Server28 byte4 byte3 byte0 byte2 byte37 byte
PostgreSQL27 byte23 byte0 byte6 byte4 byte60 byte

Header 23 byte mang xmin, xmax và ctid. Nhờ đó engine quyết định phiên bản nào thấy được ngay trên page, không cần kho phiên bản riêng; cơ chế này là chủ đề của bài MVCC, HOT update và VACUUM. SQL Server giữ phiên bản cũ trong version store của tempdb, hoặc persisted version store khi bật ADR, và chỉ thêm 14 byte thông tin phiên bản vào dòng khi database bật snapshot isolation hoặc READ_COMMITTED_SNAPSHOT. Hai cách được so sánh trong bài mức isolation và SNAPSHOT của SQL Server.

Kiểm tra trên máy thật

SELECT pg_column_size(d.*)         AS byte_tuple,
       pg_column_size(d.tong_tien) AS byte_tong_tien
FROM don_hang AS d
WHERE d.don_hang_id = 10042
  AND d.ngay_tao = '2026-10-02 11:58:00+07';

Mong đợi byte_tuple 51 và byte_tong_tien 5. pg_column_size(d.*) tính cả header nhưng không tính phần làm tròn và line pointer. Số page thực tế của từng partition có trong pg_class.relpages, số ước lượng do VACUUM và ANALYZE cập nhật.

Nhìn thẳng vào page bằng extension pageinspect, chỉ superuser gọi được. get_raw_page cần tên partition, vì bảng cha không có page. Khối dưới đọc header và ba tuple đầu của page 0 thuộc partition lịch sử, đã đầy và chưa từng bị sửa.

Đọc page 0 của don_hang_truoc_2025 bằng pageinspectSQL · 9 dòng
CREATE EXTENSION IF NOT EXISTS pageinspect;

SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('don_hang_truoc_2025', 0));

SELECT lp, lp_off, lp_len, t_xmin, t_xmax, t_ctid, t_hoff
FROM heap_page_items(get_raw_page('don_hang_truoc_2025', 0))
ORDER BY lp
LIMIT 3;

Mong đợi lower 568, upper 576, special 8.192. Ba tuple đầu có lp_off 8.136, 8.080, 8.024, cách nhau đúng 56 byte. lp_len từ 51 đến 55 tùy tong_tien, t_hoff 24.

2. Dữ liệu lớn: TOAST

Một tuple phải nằm trong một page: tối đa 8.160 byte với page 8 KB, tức 8.192 trừ header page và một line pointer đã căn lề. Cột độ dài cố định không được đẩy ra ngoài, nên tổng của chúng phải nằm trong giới hạn này. Cột độ dài thay đổi (text, varchar, bytea, numeric, jsonb, …) đi qua TOAST.

TOAST bắt đầu khi tuple sắp ghi rộng hơn TOAST_TUPLE_THRESHOLD, thường khoảng 2 KB. Engine nén và/hoặc chuyển giá trị ra ngoài cho tới khi tuple ngắn hơn TOAST_TUPLE_TARGET, cũng khoảng 2 KB, hoặc không còn gì để làm. Giá trị chuyển ra ngoài được cắt thành chunk và ghi vào bảng TOAST riêng của bảng chính, với ba cột chunk_id, chunk_seq, chunk_data và chỉ mục duy nhất trên (chunk_id, chunk_seq). Tuple chính giữ một con trỏ 18 byte. Một giá trị tối đa 1 GB.

Chiến lược Nén Ra ngoài Dùng cho
PLAIN Không Không Kiểu không TOAST được, như int
EXTENDED Có Có Mặc định của phần lớn kiểu TOAST được, như text, bytea
EXTERNAL Không Có Dữ liệu đã nén sẵn, hoặc cần cắt chuỗi con nhanh
MAIN Có Chỉ khi hết cách Ưu tiên giữ trong dòng

Nén mặc định là pglz. Từ PostgreSQL 14 có thêm lz4 nếu bản build có hỗ trợ. default_toast_compression chọn thuật toán cho giá trị mới, từng cột đổi bằng ALTER TABLE ... ALTER COLUMN ... SET COMPRESSION.

Hợp đồng dài và file PDF

CREATE TABLE hop_dong (
    hop_dong_id int           NOT NULL,
    tom_tat     varchar(5000) NOT NULL,
    dieu_khoan  varchar(5000) NOT NULL,
    ban_scan    bytea,
    CONSTRAINT pk_hop_dong PRIMARY KEY (hop_dong_id) USING INDEX TABLESPACE ts_data
) TABLESPACE ts_data;

ALTER TABLE hop_dong ALTER COLUMN ban_scan SET STORAGE EXTERNAL;

varchar(5000) trong PostgreSQL giới hạn 5.000 ký tự, không phải 5.000 byte. Chữ tiếng Việt có dấu trong UTF-8 tốn 2 đến 3 byte mỗi ký tự. Ví dụ dưới giả định mỗi cột chữ khoảng 5.000 byte, giống chương SQL Server. ban_scan là PDF, vốn đã nén. EXTERNAL bỏ bước nén thử, tiết kiệm CPU mà không mất gì về dung lượng. SET STORAGE chỉ áp cho giá trị ghi sau đó.

Hợp đồng 15 có tom_tat và dieu_khoan khoảng 5.000 byte mỗi cột, ban_scan 2 MB. Lúc INSERT, engine xử lý cột lớn nhất trước. ban_scan ra bảng TOAST nguyên dạng, tuple chính giữ con trỏ 18 byte. Hai cột chữ được nén. Văn bản thường nén tốt, nên mỗi cột có thể còn nằm trong dòng ở dạng nén, hoặc cũng ra ngoài nếu tuple vẫn trên 2 KB. Kết quả tùy nội dung, xem bằng pg_column_size. PostgreSQL không có ngưỡng 8.060 byte với row-overflow và LOB riêng như SQL Server: một cơ chế cho mọi giá trị độ dài thay đổi, kèm nén.

Chunk dài tối đa khoảng 2.000 byte, chọn để bốn dòng chunk vừa một page. Trên bản build 64 bit thông thường, chunk đầy là 1.996 byte. Bảng TOAST vì thế có 1.051 dòng cho bản scan:

PDF 2 MB         = 2.097.152 byte
2.097.152 / 1.996 = 1.050,7  → 1.050 chunk đầy + 1 chunk cuối 1.352 byte = 1.051 dòng
1.051 / 4        → 263 page, khoảng 2,05 MB, cộng vài page chỉ mục của bảng TOAST
SQL Server, cùng tệp: khoảng 256 page LOB_DATA

SELECT hop_dong_id, tom_tat FROM hop_dong WHERE hop_dong_id = 15 không đọc 1.051 dòng đó, vì giá trị TOAST chỉ được lấy khi cột thực sự cần. SELECT * thì có. Tài liệu còn ghi: UPDATE không đổi giá trị đã ở ngoài thì giữ nguyên con trỏ, nên sửa tom_tat không chép lại 2 MB của ban_scan dù phiên bản dòng mới được ghi.

Kiểm tra TOAST

SELECT octet_length(tom_tat)  AS tom_tat_goc,  pg_column_size(tom_tat)  AS tom_tat_luu,
       octet_length(ban_scan) AS ban_scan_goc, pg_column_size(ban_scan) AS ban_scan_luu
FROM hop_dong
WHERE hop_dong_id = 15;

pg_column_size trả số byte đang lưu, sau nén. ban_scan_goc và ban_scan_luu cùng 2.097.152 vì cột không nén. tom_tat_luu nhỏ hơn tom_tat_goc khi văn bản nén được. Đếm chunk của đúng bản scan bằng pg_column_toast_chunk_id, có từ PostgreSQL 17. Đọc bảng TOAST cần quyền chủ bảng hoặc superuser.

Đếm chunk của bản scan trong bảng TOAST, chạy trong psqlSQL · 8 dòng
SELECT reltoastrelid::regclass AS bang_toast
FROM pg_class
WHERE oid = 'hop_dong'::regclass \gset

SELECT count(*) AS so_chunk, max(length(chunk_data)) AS chunk_lon_nhat
FROM :bang_toast
WHERE chunk_id = (SELECT pg_column_toast_chunk_id(ban_scan)
                  FROM hop_dong WHERE hop_dong_id = 15);

Mong đợi so_chunk 1.051 và chunk_lon_nhat 1.996 trên bản build thông thường.

3. Áp dụng trong .NET

Ba chỗ trong code .NET của BanHang chạm thẳng vào cơ chế trên.

Thứ tự cột. Theo tài liệu EF Core, Migrations đặt cột khóa chính trước, rồi tới property theo thứ tự khai báo; [Column(Order = n)] đổi được thứ tự, nhưng chỉ lúc tạo bảng. Khai báo entity theo 8 byte, 4 byte, 2 byte, rồi cột độ dài thay đổi là đủ: GenerateCreateScript() của Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 sinh don_hang đúng thứ tự và kiểu của bài trước, trừ PARTITION BY.

Chỉ đọc cột cần. Lấy cả entity HopDong sinh câu có ban_scan, nên mỗi lần mở trang hợp đồng engine đọc 1.051 dòng TOAST và gửi 2 MB qua mạng. Phép chiếu thì không:

var hopDong = await db.HopDong
    .Where(h => h.HopDongId == 15)
    .Select(h => new { h.HopDongId, h.TomTat })
    .SingleAsync();

ToQueryString() không mở kết nối, nên chạy được không cần server. Trên .NET 10.0.12 nó in:

-- Lấy cả entity:
SELECT h.hop_dong_id, h.ban_scan, h.dieu_khoan, h.tom_tat
FROM hop_dong AS h
WHERE h.hop_dong_id = 15

-- Chỉ lấy cột cần:
SELECT h.hop_dong_id AS "HopDongId", h.tom_tat AS "TomTat"
FROM hop_dong AS h
WHERE h.hop_dong_id = 15

Khối dưới là toàn bộ chương trình in hai câu trên và DDL của hai entity, chạy bằng dotnet run HopDongSql.cs. Nhánh --doc chạy phép chiếu thật thì cần server, nên mới chỉ được biên dịch. File-based app bật PublishAot mặc định, và khi đó EF Core báo "Model building is not supported when publishing with NativeAOT", nên file có #:property PublishAot=false.

HopDongSql.cs: entity don_hang, hop_dong và lệnh in SQL mà EF Core sinhC# · 55 dòng
#:package Npgsql.EntityFrameworkCore.PostgreSQL@10.0.3
#:property PublishAot=false
// In DDL và câu SELECT mà EF Core sinh cho don_hang và hop_dong. Không cần server:
// GenerateCreateScript và ToQueryString không mở kết nối.
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

await using var db = new BanHangDb();
Console.WriteLine(db.Database.GenerateCreateScript());

Console.WriteLine("-- Lấy cả entity:");
Console.WriteLine(db.HopDong.Where(h => h.HopDongId == 15).ToQueryString());
Console.WriteLine();
Console.WriteLine("-- Chỉ lấy cột cần:");
Console.WriteLine(db.HopDong.Where(h => h.HopDongId == 15)
    .Select(h => new { h.HopDongId, h.TomTat }).ToQueryString());

if (args is ["--doc"]) // cần PostgreSQL với database banhang
{
    var hopDong = await db.HopDong
        .Where(h => h.HopDongId == 15)
        .Select(h => new { h.HopDongId, h.TomTat })
        .SingleAsync();
    Console.WriteLine($"Hợp đồng {hopDong.HopDongId}: {hopDong.TomTat.Length} ký tự tóm tắt");
}

class BanHangDb : DbContext
{
    public DbSet<DonHang> DonHang => Set<DonHang>();
    public DbSet<HopDong> HopDong => Set<HopDong>();

    protected override void OnConfiguring(DbContextOptionsBuilder options) =>
        options.UseNpgsql("Host=db01;Database=banhang");
}

// Thứ tự khai báo: 8 byte, 4 byte, 2 byte, rồi cột độ dài thay đổi.
[Table("don_hang"), PrimaryKey(nameof(DonHangId), nameof(NgayTao))]
class DonHang
{
    [Column("don_hang_id")] public long DonHangId { get; set; }
    [Column("ngay_tao"), Precision(0)] public DateTime NgayTao { get; set; }
    [Column("khach_hang_id")] public int KhachHangId { get; set; }
    [Column("trang_thai")] public short TrangThai { get; set; }
    [Column("tong_tien"), Precision(18, 2)] public decimal TongTien { get; set; }
}

[Table("hop_dong")]
class HopDong
{
    [Key, Column("hop_dong_id"), DatabaseGenerated(DatabaseGeneratedOption.None)] public int HopDongId { get; set; }
    [Column("tom_tat"), MaxLength(5000)] public string TomTat { get; set; } = "";
    [Column("dieu_khoan"), MaxLength(5000)] public string DieuKhoan { get; set; } = "";
    [Column("ban_scan")] public byte[]? BanScan { get; set; }
}
Phần DDL trong output của HopDongSql.cs, .NET 10.0.1217 dòng
CREATE TABLE don_hang (
    don_hang_id bigint NOT NULL,
    ngay_tao timestamp(0) with time zone NOT NULL,
    khach_hang_id integer NOT NULL,
    trang_thai smallint NOT NULL,
    tong_tien numeric(18,2) NOT NULL,
    CONSTRAINT "PK_don_hang" PRIMARY KEY (don_hang_id, ngay_tao)
);


CREATE TABLE hop_dong (
    hop_dong_id integer NOT NULL,
    tom_tat character varying(5000) NOT NULL,
    dieu_khoan character varying(5000) NOT NULL,
    ban_scan bytea,
    CONSTRAINT "PK_hop_dong" PRIMARY KEY (hop_dong_id)
);

Trả bản scan theo luồng. Mặc định Npgsql đọc trọn một dòng vào bộ đệm. Với CommandBehavior.SequentialAccess, tài liệu Npgsql ghi nó chỉ đọc thêm khi bộ đệm cạn, đổi lại phải đọc cột theo đúng thứ tự và mỗi cột một lần. Server vẫn đọc đủ các chunk; phần được lợi là API không giữ cả 2 MB trong bộ nhớ cho mỗi request:

app.MapGet("/hop-dong/{id:int}/ban-scan", async (int id, NpgsqlDataSource db, HttpResponse response) =>
{
    await using var cmd = db.CreateCommand(
        "SELECT ban_scan FROM hop_dong WHERE hop_dong_id = $1 AND ban_scan IS NOT NULL");
    cmd.Parameters.Add(new NpgsqlParameter<int> { TypedValue = id });
    await using var reader = await cmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess);
    if (!await reader.ReadAsync()) { response.StatusCode = StatusCodes.Status404NotFound; return; }
    response.ContentType = "application/pdf";
    await using var pdf = await reader.GetStreamAsync(0);
    await pdf.CopyToAsync(response.Body);
});

Chiều ghi, Npgsql từ 7.0 nhận Stream làm giá trị cho tham số bytea. Lệnh SET STORAGE EXTERNAL không có trong mô hình EF Core, nên đặt nó trong migration bằng migrationBuilder.Sql(...). Bản đầy đủ của API dưới đây đã biên dịch bằng dotnet build (.NET SDK 10.0.401, Npgsql 10.0.3) nhưng chưa chạy với PostgreSQL server.

BanScanApi.cs: minimal API trả tóm tắt và bản scan của hợp đồngC# · 41 dòng
#:sdk Microsoft.NET.Sdk.Web
#:package Npgsql@10.0.3
// Trả bản scan PDF của hợp đồng mà không giữ cả 2 MB trong bộ nhớ.
// Biên dịch: dotnet build BanScanApi.cs. Chạy cần PostgreSQL 17 với database banhang.
using System.Data;
using Npgsql;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(_ => NpgsqlDataSource.Create(
    builder.Configuration.GetConnectionString("BanHang") ?? "Host=db01;Database=banhang;Username=api"));
var app = builder.Build();

// Danh sách hợp đồng: chỉ chọn cột cần, không chạm bảng TOAST của ban_scan.
app.MapGet("/hop-dong/{id:int}", async (int id, NpgsqlDataSource db) =>
{
    await using var cmd = db.CreateCommand("SELECT hop_dong_id, tom_tat FROM hop_dong WHERE hop_dong_id = $1");
    cmd.Parameters.Add(new NpgsqlParameter<int> { TypedValue = id });
    await using var reader = await cmd.ExecuteReaderAsync();
    return await reader.ReadAsync()
        ? Results.Ok(new { HopDongId = reader.GetInt32(0), TomTat = reader.GetString(1) })
        : Results.NotFound();
});

// Bản scan: đọc tuần tự, chép từng phần ra response.
app.MapGet("/hop-dong/{id:int}/ban-scan", async (int id, NpgsqlDataSource db, HttpResponse response) =>
{
    await using var cmd = db.CreateCommand(
        "SELECT ban_scan FROM hop_dong WHERE hop_dong_id = $1 AND ban_scan IS NOT NULL");
    cmd.Parameters.Add(new NpgsqlParameter<int> { TypedValue = id });
    await using var reader = await cmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess);
    if (!await reader.ReadAsync())
    {
        response.StatusCode = StatusCodes.Status404NotFound;
        return;
    }
    response.ContentType = "application/pdf";
    await using var pdf = await reader.GetStreamAsync(0);
    await pdf.CopyToAsync(response.Body);
});

app.Run();

Những chỗ hay hiểu sai

  • "numeric(18, 2) luôn chiếm 9 byte như decimal(18, 2)." numeric dài theo giá trị: 1.500.000,00 chiếm 5 byte trên đĩa.
  • "Thứ tự cột không ảnh hưởng dung lượng." Cột int đứng trước cột 8 byte làm engine chèn 4 byte đệm cho mỗi dòng.
  • "varchar(5000) là tối đa 5.000 byte." Giới hạn tính bằng ký tự; chữ tiếng Việt có dấu tốn 2 đến 3 byte mỗi ký tự trong UTF-8.
  • "Sửa một cột nhỏ của dòng có bản scan sẽ chép lại cả bản scan." Giá trị TOAST không đổi được giữ nguyên con trỏ.

Kết luận

Dung lượng của một bảng PostgreSQL là số dòng nhân 60 byte chứ không phải 27 byte dữ liệu: header MVCC, đệm căn lề và line pointer chiếm hơn nửa. Giá trị dài không làm dòng phình, nhưng làm truy vấn đắt khi nó chọn cột không cần.

Trong dự án .NET của bạn:

  • Khai báo property của entity theo thứ tự 8, 4, 2 byte rồi chuỗi và mảng byte, hoặc đặt [Column(Order = n)], trước khi migration đầu tiên tạo bảng.
  • Màn hình danh sách và API tra cứu dùng Select chỉ lấy cột cần; kiểm câu sinh ra bằng ToQueryString().
  • Trả bytea lớn bằng CommandBehavior.SequentialAccess và GetStreamAsync, không đọc thành byte[].
  • Cột đã nén sẵn như PDF, ảnh: thêm ALTER TABLE ... SET STORAGE EXTERNAL vào migration bằng migrationBuilder.Sql.
  • Đo pg_column_size trên vài dòng thật trước khi ước lượng dung lượng cả bảng.

Đọc tiếp

Nguồn

Đọc tiếp

Bài tiếp theo trong series

MVCC, HOT update và VACUUM

UPDATE trong PostgreSQL ghi phiên bản dòng mới ngay trong heap; HOT giữ chỉ mục khỏi bị chạm, VACUUM dọn phiên bản cũ; code .NET dùng xmin làm concurrency token và không để giao dịch mở lâu.

14 phút đọc

Trong PostgreSQL

Cluster, tablespace và partition

Một instance PostgreSQL là một cluster dùng chung WAL và sao lưu; tablespace là một thư mục của cả cluster; partition đặt đơn mới lên SSD, đơn cũ lên HDD; .NET nạp đơn vào đúng partition bằng COPY nhị phân.

14 phút đọc

Trong SQL Server

Page, dòng và extent

Bên trong một tệp dữ liệu: page 8 KB, dòng DonHang 35 byte và 218 dòng mỗi page, row-overflow và LOB, extent và allocation unit, kèm cách trả file PDF lớn từ .NET mà không kéo cả dòng.

13 phút đọc