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ộ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.
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
Bảng số liệu
| Dữ liệu cột | Header dòng | Null bitmap | Đệm căn lề | Slot hoặc line pointer | Tổng | |
|---|---|---|---|---|---|---|
| SQL Server | 28 byte | 4 byte | 3 byte | 0 byte | 2 byte | 37 byte |
| PostgreSQL | 27 byte | 23 byte | 0 byte | 6 byte | 4 byte | 60 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 pageinspect
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 psql
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 sinh
#: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.12
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 đồ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)."numericdà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
Selectchỉ lấy cột cần; kiểm câu sinh ra bằngToQueryString(). - Trả
bytealớn bằngCommandBehavior.SequentialAccessvàGetStreamAsync, không đọc thànhbyte[]. - Cột đã nén sẵn như PDF, ảnh: thêm
ALTER TABLE ... SET STORAGE EXTERNALvào migration bằngmigrationBuilder.Sql. - Đo
pg_column_sizetrên vài dòng thật trước khi ước lượng dung lượng cả bảng.
Đọc tiếp
- Bài trước trong series: PostgreSQL — Cluster, tablespace và partition.
- Bài sau trong series: PostgreSQL — MVCC, HOT update và VACUUM:
xmin,xmaxtrong header được dùng thế nào khi đơn đổi trạng thái. - SQL Server — Page, dòng và extent: page 8 KB, dòng 37 byte và
LOB_DATAở phía SQL Server.