Sổ tiền: sửa số dư tại chỗ hay chỉ ghi thêm bút toán?
Sổ giữ mười năm không sửa, ba tài khoản chung nhận mọi lượt thanh toán, số dư đọc dưới 50 ms. Chọn cách ghi sổ, đo cái giá trên Postgres.
TL;DR: Có ba cách ghi sổ tiền: cột số dư kèm bảng lịch sử, sổ cái bút toán kép chỉ ghi thêm rồi lấy số dư bằng tổng, và sổ cái kèm số dư dẫn xuất cập nhật trong cùng transaction. Đo trên Postgres 16, hai cách có cập nhật hàng số dư tài khoản chung chỉ đạt khoảng 1.000 lượt mỗi giây, dưới đỉnh 3.000. Chỉ ghi thêm đạt khoảng 27.000, nhưng tài khoản một triệu bút toán thì không đọc số dư kịp 50 ms. Lời giải dưới ràng buộc này là chọn theo từng tài khoản: tài khoản chung chỉ ghi thêm, người bán giữ số dư dẫn xuất.
Sáng hôm sau đợt sale, một người bán báo số dư thiếu 237.500 đ, đúng phần của một đơn. Cộng bảng lịch sử thì ra đủ, cột số dư thì thiếu. Bên nào đúng? Không ai trả lời được, vì cột số dư là bản gốc còn bảng lịch sử chỉ là nhật ký ghi kèm.
Bài này bắt bạn chọn một trong ba cách ghi sổ, rồi đo cả ba trên hai trục: tranh chấp ghi ở tài khoản chung và độ trễ đọc số dư. Dung lượng mười năm chỉ là con số kiểm, ba cách cùng số dòng.
1. Ba cách ghi sổ, cái giá nào ràng buộc này gánh được?
Ràng buộc lấy nguyên từ bài 01: ba tài khoản chung (phải thu nhà cung cấp, phí nhà cung cấp, doanh thu phí sàn) lượt nào cũng ghi, và mười năm bút toán là khoảng 5,84 tỉ dòng chưa kể đợt sale.
Ba thuật ngữ cần trước khi chọn. Bút toán kép (double-entry) là quy ước mọi nghiệp vụ ghi ít nhất hai dòng sao cho tổng Nợ bằng tổng Có. Sổ cái (ledger) là bảng chứa các dòng đó. Số dư dẫn xuất (derived balance) là con số tính ra được từ sổ cái, cất sẵn để đọc nhanh, không phải bản gốc.
Ràng buộc: 3.000 lượt/giây · 4 bút toán mỗi lượt, 3 tài khoản chung · đọc số dư người bán 500 lượt/giây, p99 dưới 50 ms · lưu 10 năm, không sửa, không xoá
Với ba tài khoản chung nhận mọi lượt và người bán cần đọc số dư dưới 50 ms, bạn chấp nhận cái giá nào?
Chọn trước khi đọc tiếp — phần sau phân tích từng cái giá.
Nền: hàng nóng
Chọn một phương án trước khi đọc tiếp.
Bài 01 đã đo: four.sql ghi đủ bốn tài khoản đạt 900 đến 1.000 TPS ở 50 kết nối, vì mọi transaction xếp hàng ngay ở hàng id 1.
2. Analogy: sổ quỹ tẩy xoá và sổ cái ghi mực
Thủ quỹ thứ nhất tẩy con số tồn quỹ viết bút chì rồi viết số mới, kèm một dòng nhật ký. Thủ quỹ thứ hai ghi mỗi nghiệp vụ hai vế Nợ và Có bằng mực, muốn biết tồn quỹ thì cộng sổ.
| Đời thường | Sổ tiền của case |
|---|---|
| Con số tồn quỹ viết bút chì | Cột balance bị UPDATE tại chỗ (A) |
| Sổ cái ghi mực, mỗi nghiệp vụ hai vế | Bảng entries chỉ INSERT (B) |
| Tờ ghi tồn quỹ cuối ngày, kiểm lại được bằng sổ | Số dư dẫn xuất cập nhật cùng transaction (C) |
| Ghi sai thì ghi thêm dòng đảo | Bút toán đảo thay cho UPDATE, DELETE |
Bài này để Nợ dương, Có âm, nên mỗi giao dịch cộng lại bằng 0.
Ở A, số dư là sự thật và lịch sử là lời kể. Ở B và C, sổ cái là sự thật và số dư chỉ là phép cộng làm sẵn.
3. Vì sao sổ tiền nên là sổ chỉ ghi thêm?
Ba phương án khác nhau ở chỗ cái gì là bản gốc. Ở A, số dư và lịch sử lệch nhau thì không quy tắc nào nói bên nào thắng, và lịch sử ghi đơn nên không có phép kiểm "Nợ bằng Có" để bắt một lượt thiếu dòng phí sàn.
Ở B và C, sổ cái là bản gốc và cho hai bất biến kiểm được bằng SQL:
-- every transaction balances: expect 0 rows
SELECT txn_id FROM entries
GROUP BY txn_id HAVING sum(amount) <> 0;
-- C only: stored balance equals the ledger: expect 0 rows
SELECT a.id FROM accounts a
JOIN (SELECT account_id, sum(amount) AS s
FROM entries GROUP BY 1) e ON e.account_id = a.id
WHERE a.balance <> e.s;
Sau 3.732 bút toán ghi bằng c.sql (mục 4), cả hai câu trả 0 dòng. "Không sửa" cũng nên do database giữ: cho app một role không phải owner của bảng, chỉ cấp SELECT, INSERT. Role đó thử UPDATE thì Postgres 16 trả ERROR: permission denied for table entries; owner thì luôn sửa được.
Event đã ghi không sửa, sai thì ghi bút toán đảo; trạng thái là phép cộng dồn log; log dài thì lưu snapshot rồi chỉ cộng phần sau snapshot. Sổ cái là đúng mô hình ấy, với event là bút toán và state là số dư.
Thử ngẫmngười bán bị ghi thừa một đơn. Ở A bạn sửa bằng câu gì, còn ở B bạn ghi thêm những dòng nào?
4. Tự đo ba cách trên máy bạn
Tạo database ledgerlab, 10.003 tài khoản, id 1 đến 3 là ba tài khoản chung:
CREATE TABLE accounts (
id int PRIMARY KEY, balance bigint NOT NULL DEFAULT 0);
INSERT INTO accounts SELECT g, 0 FROM generate_series(1, 10003) g;
CREATE TABLE history (account_id int, amount bigint); -- A only
CREATE SEQUENCE txn_seq;
CREATE TABLE entries ( -- B and C; debit > 0, credit < 0
id bigserial PRIMARY KEY,
txn_id bigint NOT NULL,
account_id int NOT NULL,
amount bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now());
CREATE INDEX entries_acc ON entries (account_id, id) INCLUDE (amount);
Index entries_acc chứa sẵn amount để phép cộng số dư đọc thẳng từ index. a.sql là four.sql của bài 01 cộng một câu ghi bốn dòng lịch sử trước COMMIT. b.sql chỉ ghi bút toán:
-- b.sql: append-only ledger, one payment of 250000
\set seller random(4, 10003)
BEGIN;
INSERT INTO entries (txn_id, account_id, amount)
SELECT t, a, m FROM (SELECT nextval('txn_seq') AS t) s,
(VALUES (1, 247250), (2, 2750), (3, -12500),
(:seller, -237500)) v(a, m);
COMMIT;
c.sql là b.sql cộng bốn câu UPDATE accounts của four.sql. Xoá dữ liệu giữa các lần chạy:
pgbench -n -f b.sql -c 50 -j 5 -T 10 ledgerlab # then a.sql, c.sql
psql ledgerlab -c "TRUNCATE entries, history; UPDATE accounts SET balance = 0"
B không chạm hàng số dư nào. So với A ở 50 kết nối, B nhanh gấp đôi, gấp năm, hay gấp hai mươi? Ghi ra rồi mới đọc mục 5.
5. Số đo: ghi vào tài khoản chung
Postgres 16.15 trong Docker, máy 10 nhân, mỗi script chạy hai lần:
| Phương án | TPS ở 50 kết nối | So với đỉnh 3.000 |
|---|---|---|
| A: cột số dư kèm lịch sử | 1.007 đến 1.009 | thiếu khoảng 3 lần |
| B: chỉ ghi thêm | 23.705 đến 30.085 | dư khoảng 8 đến 10 lần |
| C: sổ cái kèm số dư cả bốn tài khoản | 962 đến 1.035 | thiếu khoảng 3 lần |
Lời giải phép thử đoán: gấp khoảng 24 đến 30 lần. A ra 1.007 đến 1.009 trong khi four.sql ở bài 01 ra 900 đến 1.000: hai lần chạy khác nhau, cùng cỡ 1.000 TPS.

A và C ngang nhau vì cùng một nút cổ chai: UPDATE hàng id 1 giữ khoá tới COMMIT, 49 kết nối còn lại chờ đúng khoá đó. B thì mỗi lượt tạo bốn dòng mới, không ai phải chờ khoá của dòng người khác đang sửa. Hai lần chạy B cách nhau tới 6.000 TPS, nên tin tỉ lệ, đừng tin chữ số cuối.
6. Số đo: đọc số dư và dung lượng mười năm
Số dư của B là một phép cộng, tốn bao lâu tuỳ số bút toán. Người bán 274 đơn mỗi ngày (chưa tới 0,07% lượt) sau 10 năm có khoảng 1 triệu bút toán. Đo 500 lượt đọc mỗi giây trên tài khoản như thế:
psql ledgerlab -c "INSERT INTO entries (txn_id, account_id, amount)
SELECT g, 5000, -237500 FROM generate_series(1, 1000000) g;"
psql ledgerlab -c "VACUUM ANALYZE entries"
echo "SELECT sum(amount) FROM entries WHERE account_id = 5000;" > r.sql
pgbench -n -f r.sql -c 20 -j 4 -T 10 -R 500 -l ledgerlab
-R 500 ép nhịp; khi đó pgbench tính độ trễ từ lúc lượt lẽ ra bắt đầu, nên phần xếp hàng cũng nằm trong số đo. -l ghi độ trễ từng lượt (cột 3, micro giây) để bạn tự lấy p99.
| Bút toán của tài khoản | Lượt đọc đạt được | p50 | p99 |
|---|---|---|---|
| khoảng 10 nghìn | 508/giây | 2,8 ms | 10 ms |
| khoảng 100 nghìn | 497/giây | 5,1 ms | 12 ms |
| 1 triệu | 139 đến 148/giây | 3,5 s | 7,2 s |

Một lần SUM lẻ trên 1 triệu dòng đã mất 41 đến 74 ms theo EXPLAIN ANALYZE; ở 500 lượt mỗi giây máy chỉ cộng nổi khoảng 140, phần còn lại xếp hàng. Đó còn là số đẹp, đo sau VACUUM nên Heap Fetches: 0. Sổ đang ghi thì trang mới chưa được đánh dấu trong visibility map (bản đồ ghi trang nào mọi dòng đều đã thấy được), nên index-only scan, vốn đọc thẳng từ index, phải quay về bảng kiểm từng dòng.
Dung lượng thì cả ba cùng số dòng: bảng entries của B và C khoảng 157 byte mỗi dòng kể cả hai index, 5,84 tỉ dòng là cỡ 0,9 TB. Bảng history của A trong lab chỉ có hai cột và không có index nên nhỏ hơn, nhưng lịch sử thật cần thêm mã giao dịch và thời gian, nên về cùng bậc. Thứ phân biệt ba cách là B đọc chậm dần theo số dòng đó, còn A và C đọc một hàng dù sổ dày bao nhiêu.
7. Kết luận dưới ràng buộc này: chọn theo từng tài khoản
A và C hỏng ở ghi, vì ba tài khoản chung. B hỏng ở đọc, vì người bán lâu năm. Hai chỗ hỏng rơi vào hai nhóm tài khoản khác nhau, và đó là lối ra.

Người bán không nóng: 3.000 lượt mỗi giây rải ra hàng nghìn người bán. Vậy giữ số dư dẫn xuất chỉ cho người bán: d.sql là b.sql cộng đúng một câu UPDATE accounts ... WHERE id = :seller trước COMMIT. Tác giả đo d.sql ở 50 kết nối được 18.943 đến 22.072 TPS; trong lúc nó chạy, 500 lượt SELECT balance mỗi giây có p99 4 ms.
Số dư ba tài khoản chung chỉ kế toán và job đối soát đọc, vài lần mỗi ngày. Cách rẻ là snapshot của event sourcing: cuối ngày ghi "số dư tới hết ngày D", lần đọc sau chỉ cộng phần sau mốc. Ngày sale có cỡ 2,2 triệu dòng mỗi tài khoản chung, theo mục 6 là khoảng 100 đến 150 ms một lần cộng: chậm, nhưng không ai cần nhanh hơn.
Cái giá: code đọc số dư phải biết tài khoản thuộc nhóm nào, và người bán lớn tới mức thành hàng nóng thì phải chuyển nhóm.
Câu 4 và 5 ở phần tự kiểm tra là hai tình huống mới: một người bán nhận 40% lượt ở đỉnh (1.200 lượt mỗi giây) và bộ phận hoàn tiền cần số dư phải thu (50 lượt mỗi giây). Tự thiết kế trước khi mở đáp án mẫu.
Thử ngẫmngười bán tăng từ 274 lên 2.740 đơn mỗi ngày. Sau 10 năm số bút toán của họ và độ trễ đọc số dư bằng tổng thay đổi thế nào?
8. Hai cái bẫy khi làm sổ tiền
❌ Nhầm 1: sửa bút toán sai bằng UPDATE.
UPDATE entries SET amount = -225000 WHERE txn_id = 81234 AND account_id = 77;
✅ Câu này xoá dấu vết con số cũ và làm giao dịch 81234 hết cân (câu kiểm ở mục 3 bắt ngay). Ghi thêm một giao dịch đảo đủ hai vế, rồi ghi lại giao dịch đúng:
-- txn 81234 moved 237500 between account 1 and account 77
INSERT INTO entries (txn_id, account_id, amount) VALUES
(:rev, 1, -237500), (:rev, 77, 237500), -- reversal
(:fix, 1, 225000), (:fix, 77, -225000); -- corrected, two txns
❌ Nhầm 2: ghi bút toán ở transaction này, cập nhật số dư ở transaction sau.
BEGIN; INSERT INTO entries ...; COMMIT;
-- process dies here: the entry exists, the balance is short
BEGIN; UPDATE accounts SET balance = balance - 237500
WHERE id = :seller; COMMIT;
✅ Tiến trình chết giữa hai transaction (kiểu hỏng của bài 02) là số dư thiếu đúng một đơn, như người bán ở đầu bài. Gộp cả hai vào một transaction:
BEGIN;
INSERT INTO entries ...;
UPDATE accounts SET balance = balance - 237500 WHERE id = :seller;
COMMIT;
9. Đào sâu
Tài liệu chính thức của PostgreSQL 16:
- Index-Only Scans and Covering Indexes: visibility map, và vì sao index-only scan chỉ có lợi khi phần lớn trang đã được đánh dấu.
- Privileges: owner luôn sửa được bảng của mình;
TRUNCATElà quyền riêng, tách khỏiDELETE. - pgbench:
--ratetính độ trễ từ lịch bắt đầu (schedule lag), và định dạng file-l.
10. Liên hệ các bài khác
- Bài 01: Ràng buộc và ước lượng: nguồn của trần một hàng, 5,84 tỉ dòng và bút toán bốn dòng.
- Bài 05: Đối soát với nhà cung cấp: so sổ cái này với file của nhà cung cấp.
- Bài 06: Đối chiếu thực tế: hệ thanh toán thật xử lý tài khoản nóng thế nào, và vì sao khác lời giải ở đây.
11. Tóm tắt
- Tài khoản lượt nào cũng ghi vào: chỉ ghi thêm bút toán, đừng giữ hàng số dư.
- Cần đọc số dư dưới vài chục mili giây mà tài khoản không nóng: giữ số dư dẫn xuất, cập nhật cùng transaction với bút toán.
- Muốn chắc sổ không lệch: kiểm "mỗi giao dịch tổng bằng 0" bằng SQL, cho app role không có quyền
UPDATE.
12. Tự kiểm tra
- Q1A và C khác nhau về cái gì là bản gốc, vậy mà ở 50 kết nối cùng ra khoảng 1.000 TPS. Vì sao?
- Q2Đồng nghiệp đo B với một người bán 5.000 bút toán, đọc số dư chỉ 2 ms, và kết luận B đủ cho cả đọc lẫn ghi. Sai ở đâu?
- Q3Vì sao bút toán và cập nhật số dư người bán phải nằm cùng một transaction, và câu SQL nào cho biết chúng đã từng lệch?
- Q4Tình huống mới: một người bán độc quyền đợt sale, nhận 40% lượt ở đỉnh. Tài khoản của nó còn giữ số dư dẫn xuất được không? Nếu không, số dư trước khi chi trả lấy từ đâu?
- Q5Tình huống mới: bộ phận hoàn tiền muốn kiểm số dư phải thu nhà cung cấp trước mỗi lệnh hoàn, khoảng 50 lượt mỗi giây. Có nên giữ số dư dẫn xuất cho tài khoản đó không?
Bài tiếp theo: Đối soát với nhà cung cấp
Bài này có giúp bạn hiểu bản chất không?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Thứ tự bước: đặt lệnh trừ tiền không đảo được ở đâu?
Bài tiếpChốt và đối soát: không giao dịch nào mập mờ quá 24 giờ
Thảo luận & góp ý
Hỏi đáp về bài này