Cơ sở dữ liệuPostgreSQL, phần 4/5
WAL, checkpoint và recovery
Commit trong PostgreSQL bền khi commit record của WAL đã xuống pg_wal; checkpoint ghi page sau; recovery chỉ có redo; .NET đổi độ bền lấy tốc độ cho nhật ký truy cập bằng synchronous_commit theo từng giao dịch.
Khi API BanHang nhận kết quả COMMIT cho đơn 10042, page chứa đơn đó có thể chưa hề được ghi xuống tệp dữ liệu. PostgreSQL vẫn không mất đơn nhờ WAL, và phục hồi sau mất điện khác SQL Server ở một điểm: không có pha undo. Đọc xong, bạn biết commit bền lúc nào, chỉnh nhịp checkpoint theo số đo, và chọn đúng chỗ để đổi độ bền lấy tốc độ ghi từ .NET.
Đọc nhanh
- Commit bền khi commit record của WAL đã flush xuống
pg_wal; tệp dữ liệu được ghi sau, bởi checkpointer hoặc background writer. - Recovery sau sự cố chỉ có redo: giao dịch không có commit record tự vô hình nhờ
pg_xact. - Lần sửa đầu của mỗi page sau checkpoint ghi cả ảnh page vào WAL, nên checkpoint dày làm WAL phình.
synchronous_commit = offchỉ có thể mất các giao dịch cuối, tối đa khoảng 600 ms, không làm hỏng database.
Bài dùng đơn 10042 và các ổ của db01 ở bài cluster, tablespace và partition, cùng chuỗi phiên bản dòng ở bài MVCC, mốc PostgreSQL 17.
1. Đường ghi dữ liệu
shared_buffers và page cache
Page được đọc vào shared_buffers, bộ đệm dùng chung của mọi tiến trình. Sửa dòng là sửa page trong bộ đệm, page thành dirty. Khác SQL Server, PostgreSQL đọc ghi tệp qua page cache của hệ điều hành, nên một page có thể nằm ở cả hai nơi.
shared_buffers mặc định thường là 128 MB. Tài liệu gợi ý điểm khởi đầu 25% RAM cho máy chỉ chạy database, và cho rằng trên 40% khó tốt hơn vì PostgreSQL vẫn dựa vào cache của hệ điều hành. Với 64 GB RAM, điểm khởi đầu là 16 GB. Phần còn lại hệ điều hành dùng làm cache tệp, khác với max server memory 56 GB của chương SQL Server.
WAL trước, page sau
Quy tắc giống SQL Server: thay đổi trên tệp dữ liệu chỉ được ghi sau khi bản ghi WAL mô tả nó đã nằm trên đĩa. Mỗi page mang pd_lsn. Trước khi ghi một dirty page, engine flush WAL tới ít nhất pd_lsn đó. Thứ tự của một UPDATE đã commit:
- Page nằm trong
shared_buffers, hoặc được đọc lên. - Tuple mới được ghi,
xmaxcủa tuple cũ được đặt, bản ghi WAL vào WAL buffers, page thành dirty và nhậnpd_lsnmới. - Lúc
COMMIT, commit record vào WAL, và WAL được flush xuốngpg_waltới hết commit record. pg_xactđánh dấu giao dịch là committed.- Client nhận kết quả thành công.
- Sau đó checkpointer, background writer hoặc chính backend mới ghi dirty page xuống tệp dữ liệu.
sequenceDiagram participant API as API .NET participant BE as Backend participant W as pg_wal participant CP as Checkpointer participant F as Tệp dữ liệu API->>BE: UPDATE đơn 10042 Note over BE: sửa page trong shared_buffers API->>BE: COMMIT BE->>W: flush tới hết commit record Note over BE: pg_xact ghi committed BE-->>API: thành công CP->>F: về sau mới ghi dirty page
Với synchronous_commit = on, mặc định, bước 3 chờ flush cục bộ, và chờ thêm standby đồng bộ nếu đã cấu hình.
Sửa đơn 10042, theo từng thời điểm
BEGIN;
SELECT pg_current_xact_id(); -- minh họa: 7341905
UPDATE don_hang
SET tong_tien = 1750000.00
WHERE don_hang_id = 10042
AND ngay_tao = '2026-10-02 11:58:00+07';
-- Chưa COMMIT. Page 26470 trong shared_buffers có hai tuple:
-- lp 12 (1.500.000, xmax = 7341905) và lp 48 (1.750.000, xmin = 7341905).
-- Bản ghi WAL đang ở WAL buffers. Tệp trên /srv/pg_ssd chưa có lp 48.
-- Phiên khác đọc thấy 1.500.000 vì 7341905 chưa commit.
COMMIT;
-- Commit record đã được flush xuống /srv/pg_wal. pg_xact ghi 7341905 là committed.
-- Phiên mới đọc thấy 1.750.000. Tệp dữ liệu có thể chưa có lp 48 cho tới checkpoint.
Ba cách mất điện ngay sau đó:
| Thời điểm mất điện | Trên tệp dữ liệu | Trong pg_wal |
Sau khi PostgreSQL mở lại |
|---|---|---|---|
Sau UPDATE, trước COMMIT, WAL chưa flush |
Chỉ có lp 12 | Không có bản ghi của lần sửa | 1.500.000. Giao dịch không để lại dấu vết |
Sau UPDATE, trước COMMIT, WAL đã flush vì commit khác hoặc WAL writer |
Có thể đã có cả lp 48 nếu page được ghi | Có bản ghi sửa, không có commit record | Redo dựng lại cả hai tuple. 7341905 không commit nên bị coi là aborted, lp 48 vô hình. Vẫn đọc 1.500.000. Không chạy undo |
Sau COMMIT, trước checkpoint |
Có thể chỉ có lp 12 | Có bản ghi sửa và commit record | Redo dựng lại lp 48 và trạng thái commit. Đọc 1.750.000 |
Hàng giữa là khác biệt chính với SQL Server, nơi recovery có pha undo đưa TongTien về 1.500.000. Ở đây không cần: tuple của giao dịch chưa commit vẫn nằm trên page nhưng không ai thấy, và VACUUM dọn sau như mọi dead tuple khác.
full_page_writes và WAL sau checkpoint
Đĩa ghi theo sector, thường 512 byte. Mất điện giữa lúc ghi một page 8 KB có thể để lại page nửa cũ nửa mới, và bản ghi WAL chỉ mô tả thay đổi thì không sửa được page rách. Vì vậy, với full_page_writes = on (mặc định), lần sửa đầu tiên của mỗi page sau một checkpoint ghi cả ảnh page vào WAL. Recovery dùng ảnh đó làm điểm xuất phát cho page.
Hệ quả: lượng WAL tăng vọt ngay sau mỗi checkpoint, rồi giảm khi các page nóng đã có ảnh trong chu kỳ. Ước lượng thô cho banhang, chỉ tính page heap:
- Đơn của khoảng 3 ngày gần nhất nhận phần lớn lần đổi trạng thái: 39.000 đơn / 136 ≈ 290 page nóng.
checkpoint_timeoutmặc định 5 phút: 288 checkpoint mỗi ngày × 290 ảnh page × 8 KB ≈ 652 MB WAL mỗi ngày chỉ cho ảnh page.checkpoint_timeout = 15min: 96 × 290 × 8 KB ≈ 218 MB.- Lệnh
DELETEnhầm lúc 12:07 chạm 47.059 page lần đầu trong chu kỳ: riêng ảnh page tới khoảng 47.059 × 8 KB ≈ 368 MB WAL, chưa kể bản ghi xóa cho từng dòng.
Checkpoint thưa hơn làm WAL nhỏ hơn, đổi lại recovery sau sự cố dài hơn vì phải redo nhiều WAL hơn. wal_compression nén ảnh page bằng pglz, hoặc lz4, zstd nếu bản build có hỗ trợ. Chỉ tắt full_page_writes khi hệ thống tệp bảo đảm không ghi rách page; tài liệu lấy ví dụ ZFS. Điểm khởi đầu cho banhang, không phải giá trị mặc định:
# /etc/postgresql/17/main/postgresql.conf
shared_buffers = 16GB # 25% RAM, cần restart
checkpoint_timeout = 15min # mặc định 5min
max_wal_size = 4GB # mặc định 1GB
Đo lượng ảnh page và loại checkpoint. View pg_stat_checkpointer có từ PostgreSQL 17. Trên PostgreSQL 16, xem checkpoints_timed và checkpoints_req trong pg_stat_bgwriter.
SELECT wal_records, wal_fpi, pg_size_pretty(wal_bytes) AS wal FROM pg_stat_wal;
SELECT num_timed, num_requested, buffers_written FROM pg_stat_checkpointer;
num_requested tăng nhanh hơn num_timed nghĩa là WAL chạm max_wal_size trước khi hết checkpoint_timeout. Nâng max_wal_size.
Ai đẩy dữ liệu xuống đĩa
Checkpoint chạy mỗi checkpoint_timeout, hoặc sớm hơn khi WAL sắp vượt max_wal_size.
| Tiến trình | Việc làm | Gần với SQL Server |
|---|---|---|
Backend lúc COMMIT |
Flush WAL tới hết commit record của chính nó | Log flush khi commit |
| WAL writer | Ghi và flush WAL buffers định kỳ, mặc định mỗi 200 ms | Log flush nền |
| Checkpointer | Ghi mọi dirty page, rải đều trên khoảng 90% chu kỳ theo checkpoint_completion_target 0,9, ghi bản ghi checkpoint, cập nhật pg_control |
Checkpoint |
| Background writer | Ghi trước một phần dirty page để backend tìm được buffer sạch | Lazy writer |
| Backend khi cần buffer | Tự ghi một dirty page nếu không còn buffer sạch | Không có tương ứng gần |
Recovery lúc khởi động: chỉ có redo
Sau sự cố, PostgreSQL đọc pg_control, lấy bản ghi checkpoint gần nhất, rồi redo từ vị trí redo ghi trong bản ghi đó tới hết WAL. Mọi thay đổi trước vị trí redo đã chắc chắn nằm trên tệp dữ liệu. Không có pha undo. Giao dịch không có commit record là aborted, tuple của nó vô hình. Thời gian recovery tỷ lệ với lượng WAL phải redo, nên checkpoint_timeout và max_wal_size là hai núm chỉnh RTO cho sự cố mất điện, giống TARGET_RECOVERY_TIME của SQL Server.
Commit không chờ WAL
synchronous_commit = off cho COMMIT trả về trước khi WAL được flush. Nó là bản tương ứng của delayed durability trong chương kỹ thuật SQL Server. Rủi ro là mất các giao dịch cuối, không phải hỏng dữ liệu: tài liệu ghi cửa sổ rủi ro tối đa bằng ba lần wal_writer_delay, tức 600 ms với mặc định. fsync = off thì khác, có thể làm hỏng database tùy ý sau sự cố.
Tham số đặt được cho từng giao dịch bằng SET LOCAL synchronous_commit = off ngay sau BEGIN, hợp với luồng ghi dày như nhật ký truy cập. Đơn đã thu tiền giữ on.
2. Mức phục hồi
Recovery lúc khởi động chỉ cứu được sự cố mất điện. Mất cả máy thì cần bản sao ở nơi khác. PostgreSQL không có recovery model theo database. Mức phục hồi do cấu hình cấp cluster quyết định: wal_level, archive_mode cùng archive_command hoặc archive_library, và lịch base backup.
| Cấu hình | Về được đâu khi mất cả máy | Gần với SQL Server |
|---|---|---|
Chỉ pg_dump hằng đêm |
Thời điểm bắt đầu bản dump gần nhất | SIMPLE với một full mỗi đêm |
wal_level = replica, base backup, không archive |
Thời điểm kết thúc base backup gần nhất | SIMPLE với full backup |
replica, archive_mode = on, base backup |
Bất kỳ thời điểm nào từ cuối base backup tới WAL cuối đã archive | FULL với chuỗi log backup |
Cùng sự cố lúc 14:00 ngày 2026-10-02, ba cách cấu hình cho ba kết cục:
Cách để banhang |
Còn lại sau sự cố | Phần đơn hàng mất |
|---|---|---|
Chỉ pg_dump lúc 22:00 hôm trước |
Dữ liệu lúc bắt đầu dump | Mọi đơn từ 22:00 đến 14:00 |
Base backup và WAL archive, archive_timeout = 5min, mất cả máy |
Tới segment WAL cuối đã archive | Tối đa khoảng 5 phút, cộng thời gian chạy lệnh archive |
Như trên, /srv/pg_wal còn đọc được |
Chép phần WAL chưa archive vào, redo tới cuối | Không mất giao dịch đã commit |
wal_level = minimal bỏ thông tin cần cho archive và replica. Nó không đi cùng archive_mode hay base backup trực tuyến, chỉ hợp với máy nạp dữ liệu làm lại được. Lệnh archive chỉ chạy trên segment đã đầy 16 MB. archive_timeout ép chuyển segment sau một khoảng thời gian, nên giới hạn tuổi của phần WAL chưa archive. Đó là RPO khi mất cả máy.
Tài liệu cảnh báo segment chuyển sớm vẫn dài 16 MB, nên archive_timeout quá ngắn làm phình kho archive, và coi mức khoảng một phút là hợp lý. banhang chọn 5 phút: tối đa 288 segment mỗi ngày, khoảng 4,5 GB nếu mọi segment đều bị ép chuyển. Cần RPO vài giây thì dùng pg_receivewal nhận WAL liên tục qua kết nối replication, hoặc một standby.
RTO là thời gian chép lại toàn bộ cluster từ bản backup, cộng thời gian redo WAL từ điểm bắt đầu backup tới mốc cần về. Base backup 22:00 cộng WAL tới 12:06 nghĩa là redo hơn 14 giờ WAL. Incremental lúc 12:00 rút phần đó còn vài phút, giống vai trò differential ở SQL Server. Cách lấy hai bản đó và khôi phục ở bài sao lưu và khôi phục.
3. Cách các tầng này ăn khớp
Lấy đúng lần COMMIT lúc 12:04 của đơn 10042, tong_tien từ 1.500.000 lên 1.750.000:
- Tuple 51 byte, chiếm 56 byte cộng 4 byte line pointer, nằm ở page 26470 của heap
don_hang_2026, trong tệp dưới/srv/pg_ssd/ts_data/PG_17_<catversion>/<OID banhang>/. UPDATEghi tuple mới ở lp 48 trên cùng page và đặtxmaxcho lp 12. Không cột nào có chỉ mục bị đổi và page còn chỗ, nên đây là HOT: hai chỉ mục không nhận entry mới.- Bản ghi WAL, kèm ảnh cả page nếu đây là lần sửa đầu của page sau checkpoint, được flush xuống
/srv/pg_waltrước khiCOMMITtrả về.pg_xactghi giao dịch là committed. Tệp trên/srv/pg_ssdcó thể chưa đổi. - Incremental chạy từ 12:00 đến 12:05 nhận WAL qua
-X streamtới lúc nó kết thúc, nên lần sửa nằm trong phần WAL đi kèm bản đó. Ghép bản 22:00 với bản 12:00 rồi redo tới điểm nhất quán là có 1.750.000. - Checkpoint sau đó ghi dirty page xuống tệp dữ liệu.
- Segment WAL chứa lần sửa được archive sang
/mnt/backup/walkhi đầy hoặc khiarchive_timeoutép chuyển. Sau checkpoint kế tiếp và sau khi đã archive, segment được tái sử dụng. - Base backup 22:00 ngày 2026-10-01 là mốc gốc. Không có nó thì bản incremental và toàn bộ WAL archive không dựng được cluster nào.
- Khi không snapshot nào còn cần lp 12, pruning hoặc VACUUM dọn dữ liệu của nó, lp 12 thành redirect sang lp 48.
Thiếu bước 6, pg_wal phình tới khi đầy và server dừng. Thiếu bước 8 trên diện rộng, heap phình và index-only scan quay về đọc heap.
4. Áp dụng trong .NET
Với đơn hàng và thanh toán, giữ mặc định synchronous_commit = on: CommitAsync của Npgsql trả về nghĩa là commit record đã nằm trên pg_wal, đơn sống qua mất điện. Nhật ký truy cập thì khác. API ghi một dòng cho mỗi request, khoảng 300 request mỗi giây lúc cao điểm, và mất vài trăm mili giây nhật ký cuối khi sập máy là chấp nhận được. Đó đúng là chỗ tài liệu gợi ý dùng commit không chờ WAL.
Middleware đẩy mỗi lượt truy cập vào một Channel<T> có giới hạn. Một BackgroundService gom tối đa 1.000 lượt, rồi ghi cả lô trong một giao dịch có SET LOCAL synchronous_commit = off, bằng COPY nhị phân:
await using var conn = await db.OpenConnectionAsync(ct);
await using var tx = await conn.BeginTransactionAsync(ct);
// Chỉ giao dịch này không chờ flush WAL. Đơn hàng, thanh toán vẫn dùng mặc định on.
await using (var set = new NpgsqlCommand("SET LOCAL synchronous_commit = off", conn, tx))
await set.ExecuteNonQueryAsync(ct);
await using (var writer = await conn.BeginBinaryImportAsync(
"COPY nhat_ky_truy_cap (thoi_diem, thoi_gian_ms, ma_trang_thai, duong_dan) FROM STDIN (FORMAT BINARY)", ct))
{
foreach (var luot in lo)
{
await writer.StartRowAsync(ct);
await writer.WriteAsync(luot.ThoiDiem, NpgsqlDbType.TimestampTz, ct);
await writer.WriteAsync(luot.ThoiGianMs, NpgsqlDbType.Integer, ct);
await writer.WriteAsync(luot.MaTrangThai, NpgsqlDbType.Smallint, ct);
await writer.WriteAsync(luot.DuongDan, NpgsqlDbType.Text, ct);
}
await writer.CompleteAsync(ct);
}
await tx.CommitAsync(ct); // trả về trước khi commit record xuống pg_wal
SET LOCAL chỉ có hiệu lực tới hết giao dịch, nên kết nối trả về pool không mang thiết lập này sang request khác. Cột của nhat_ky_truy_cap xếp 8, 4, 2 byte rồi text, theo quy tắc căn lề ở bài page và tuple. Channel đầy thì bỏ lượt mới (DropWrite), để nhật ký không bao giờ làm chậm request. Bản đầ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.
NhatKyTruyCap.cs: middleware, Channel và worker ghi nhật ký theo lô
#:sdk Microsoft.NET.Sdk.Web
#:package Npgsql@10.0.3
// Nhật ký truy cập của API: middleware đẩy vào Channel, worker ghi theo lô
// bằng COPY nhị phân trong giao dịch có SET LOCAL synchronous_commit = off.
// Biên dịch: dotnet build NhatKyTruyCap.cs. Chạy cần PostgreSQL 17 với bảng nhat_ky_truy_cap:
// CREATE TABLE nhat_ky_truy_cap (thoi_diem timestamptz NOT NULL, thoi_gian_ms int NOT NULL,
// ma_trang_thai smallint NOT NULL, duong_dan text NOT NULL);
using System.Diagnostics;
using System.Threading.Channels;
using Npgsql;
using NpgsqlTypes;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(_ => NpgsqlDataSource.Create(
builder.Configuration.GetConnectionString("BanHang") ?? "Host=db01;Database=banhang;Username=api"));
builder.Services.AddSingleton(Channel.CreateBounded<LuotTruyCap>(
new BoundedChannelOptions(50_000) { FullMode = BoundedChannelFullMode.DropWrite }));
builder.Services.AddHostedService<GhiNhatKy>();
var app = builder.Build();
app.Use(async (HttpContext ctx, RequestDelegate next) =>
{
var batDau = Stopwatch.GetTimestamp();
await next(ctx);
var kenh = ctx.RequestServices.GetRequiredService<Channel<LuotTruyCap>>();
kenh.Writer.TryWrite(new LuotTruyCap(DateTime.UtcNow,
(int)Stopwatch.GetElapsedTime(batDau).TotalMilliseconds,
(short)ctx.Response.StatusCode, ctx.Request.Path.Value ?? "/"));
});
app.MapGet("/", () => "BanHang API");
app.Run();
record LuotTruyCap(DateTime ThoiDiem, int ThoiGianMs, short MaTrangThai, string DuongDan);
sealed class GhiNhatKy(Channel<LuotTruyCap> kenh, NpgsqlDataSource db, ILogger<GhiNhatKy> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stop)
{
var lo = new List<LuotTruyCap>(1_000);
while (await kenh.Reader.WaitToReadAsync(stop))
{
while (lo.Count < 1_000 && kenh.Reader.TryRead(out var luot)) lo.Add(luot);
try { await GhiLoAsync(lo, stop); }
catch (NpgsqlException ex) { log.LogWarning(ex, "Bỏ {SoDong} dòng nhật ký", lo.Count); }
lo.Clear();
}
}
async Task GhiLoAsync(List<LuotTruyCap> lo, CancellationToken ct)
{
await using var conn = await db.OpenConnectionAsync(ct);
await using var tx = await conn.BeginTransactionAsync(ct);
// Chỉ giao dịch này không chờ flush WAL. Đơn hàng, thanh toán vẫn dùng mặc định on.
await using (var set = new NpgsqlCommand("SET LOCAL synchronous_commit = off", conn, tx))
await set.ExecuteNonQueryAsync(ct);
await using (var writer = await conn.BeginBinaryImportAsync(
"COPY nhat_ky_truy_cap (thoi_diem, thoi_gian_ms, ma_trang_thai, duong_dan) FROM STDIN (FORMAT BINARY)", ct))
{
foreach (var luot in lo)
{
await writer.StartRowAsync(ct);
await writer.WriteAsync(luot.ThoiDiem, NpgsqlDbType.TimestampTz, ct);
await writer.WriteAsync(luot.ThoiGianMs, NpgsqlDbType.Integer, ct);
await writer.WriteAsync(luot.MaTrangThai, NpgsqlDbType.Smallint, ct);
await writer.WriteAsync(luot.DuongDan, NpgsqlDbType.Text, ct);
}
await writer.CompleteAsync(ct);
}
// Trả về trước khi commit record xuống pg_wal: sập máy có thể mất lô cuối, không hỏng dữ liệu.
await tx.CommitAsync(ct);
}
}
Những chỗ hay hiểu sai
- "
COMMITtrả về nghĩa là page đã nằm trên tệp dữ liệu." Chỉ commit record của WAL đã nằm trênpg_wal. Page xuống tệp sau, lúc checkpoint hoặc khi cần buffer. - "Recovery hoàn tác giao dịch dở dang." Không có undo. Giao dịch không có commit record là aborted, tuple của nó vô hình.
- "
synchronous_commit = offcó thể làm hỏng database." Nó chỉ có thể mất các giao dịch cuối, tối đa khoảng ba lầnwal_writer_delay.fsync = offmới có thể làm hỏng database. - "Checkpoint càng dày càng tốt." Checkpoint dày rút ngắn recovery, nhưng làm WAL phình vì ảnh page sau mỗi checkpoint.
Kết luận
Ở PostgreSQL, commit bền khi commit record nằm trên pg_wal. Page xuống tệp, checkpoint và archive đều đến sau, và chỉ quyết định recovery dài bao lâu, về được tới đâu.
Trong dự án .NET của bạn:
- Coi
CommitAsynctrả về là mốc bền vững của đơn hàng và thanh toán; không đặtsynchronous_commit = offtrong chuỗi kết nối dùng chung. - Luồng ghi dày, mất được vài trăm mili giây cuối như nhật ký truy cập: gom lô qua
Channel<T>,SET LOCAL synchronous_commit = offtrong giao dịch, ghi bằngBeginBinaryImportAsync. - Đo
wal_fpi,wal_bytestrongpg_stat_walvànum_requestedso vớinum_timedtrongpg_stat_checkpointertrước khi đổicheckpoint_timeouthaymax_wal_size. - Không bao giờ đặt
fsync = off, kể cả trên môi trường thử tải dùng chung dữ liệu thật.
Đọc tiếp
- Bài trước trong series: PostgreSQL — MVCC, HOT update và VACUUM.
- Bài sau trong series: PostgreSQL — Sao lưu và khôi phục theo thời điểm: WAL archive, base backup và lần khôi phục về 12:06.
- SQL Server — Kỹ thuật thường dùng: an toàn và sẵn sàng: ADR và delayed durability, bản tương ứng gần với recovery chỉ redo và
synchronous_commit = off. - SQL Server — Write-ahead log, VLF và recovery model: log, checkpoint và recovery có undo phía SQL Server.