Ai sửa gì, trước/sauWho changed what, before/after
Service ghi thay đổi nghiệp vụ và một dòng audit_outbox trong cùng transaction. Relay (hoặc Debezium) đẩy sự kiện lên Kafka, Audit Writer chèn vào bảng audit_log chỉ cho INSERT, chia partition theo tháng. Bật “Xem lịch sử đơn hàng” để xem truy vấn; bật “Thử sửa dòng audit” để xem cơ sở dữ liệu từ chối.The service writes the business change and an audit_outbox row in one transaction. A relay (or Debezium) publishes the event to Kafka, and the Audit Writer inserts it into an INSERT-only audit_log table partitioned by month. Turn on “Order history” to see the query; turn on “Tamper attempt” to see the database refuse.
1Admin [email protected] đổi giá sản phẩm 42 từ 1.190.000 ₫ sang 1.290.000 ₫.Admin [email protected] changes product 42 from 1,190,000 ₫ to 1,290,000 ₫.
Tầng service biết actor, lý do và tên hành động (PRICE_CHANGED) nên audit có ý nghĩa nghiệp vụ. JPA entity listener (@PostUpdate, Hibernate Envers) tự động nhưng chỉ thấy cột, không thấy ý định. Trigger DB bắt cả lệnh SQL tay nhưng không biết user ứng dụng. CDC (Debezium) đọc WAL, không đụng code, cũng thiếu actor nếu không có cột updated_by.The service layer knows the actor, the reason and the action name (PRICE_CHANGED), so entries carry business meaning. JPA entity listeners (@PostUpdate, Hibernate Envers) are automatic but see columns, not intent. DB triggers catch manual SQL too but do not know the application user. CDC (Debezium) reads the WAL without touching code, and also lacks the actor unless there is an updated_by column.
Gửi Kafka sau commit thì service có thể chết giữa chừng; ghi audit bằng @Async thì mất khi process dừng. Ghi audit_outbox trong cùng transaction, relay đẩy sau, Audit Writer idempotent theo event_id. Hệ thống nhỏ có thể INSERT thẳng audit_log trong transaction.Publishing to Kafka after commit can die halfway; an @Async audit write is lost when the process stops. Write audit_outbox in the same transaction, let a relay publish it, keep the Audit Writer idempotent on event_id. A small system can simply INSERT into audit_log inside the transaction.
Audit chụp nguyên đối tượng sẽ chép cả mật khẩu băm, số thẻ, token. Dùng danh sách trường cần che (password, card_number, national_id, token) áp lên cả before và after; số thẻ chỉ giữ 4 số cuối. Audit được giữ 7 năm nên rò rỉ ở đây rất lâu dài.Snapshotting the whole object copies password hashes, card numbers and tokens. Apply a mask list (password, card_number, national_id, token) to both before and after; keep only the last 4 digits of a card. Audit data lives for 7 years, so a leak here lasts a long time.
Role của ứng dụng chỉ có INSERT và SELECT; trigger BEFORE UPDATE OR DELETE chặn cả chủ bảng. Mỗi dòng lưu hash = sha256(prev_hash ‖ nội dung) nên sửa một dòng làm lệch cả chuỗi phía sau. Chuỗi hash cần một writer cho mỗi chuỗi (ví dụ một chuỗi trên mỗi partition Kafka).The application role has only INSERT and SELECT; a BEFORE UPDATE OR DELETE trigger stops even the owner. Each row stores hash = sha256(prev_hash ‖ content), so editing one row breaks the rest of the chain. A hash chain needs one writer per chain (for example one chain per Kafka partition).
3 triệu dòng mỗi ngày là khoảng 1 tỉ dòng mỗi năm. Chia partition theo tháng (pg_partman tạo trước), truy vấn luôn có khoảng thời gian để PostgreSQL bỏ qua partition không liên quan. Hết hạn lưu thì DETACH và DROP cả partition, hoặc xuất sang S3 Glacier trước khi xoá.3 million rows a day is about a billion rows a year. Partition by month (pg_partman creates them ahead), and always query with a time range so PostgreSQL prunes the other partitions. When retention ends, DETACH and DROP the whole partition, or export it to S3 Glacier first.
Lưu cả đối tượng dễ đọc nhưng tốn chỗ và dễ dính trường nhạy cảm. Lưu phần khác biệt (các trường đã đổi) gọn hơn, đủ để trả lời “ai đổi gì”. Cột JSONB cho phép thêm trường mới mà không đổi schema.Storing whole objects is easy to read but costs space and drags in sensitive fields. Storing the diff (changed fields only) is compact and enough to answer “who changed what”. A JSONB column lets new fields appear without schema changes.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
audit_log | id, event_id, actor, action, entity_type, entity_id, before jsonb, after jsonb, ip, at, prev_hash, hash | PARTITION BY RANGE (at) theo tháng; unique(event_id, at); index (entity_type, entity_id, at DESC) và (actor, at DESC)PARTITION BY RANGE (at), monthly; unique(event_id, at); indexes (entity_type, entity_id, at DESC) and (actor, at DESC) |
audit_outbox | event_id, payload jsonb, created_at, published_at | Ghi cùng transaction với thay đổi nghiệp vụ; relay đánh dấu published_atWritten in the same transaction as the business change; the relay sets published_at |
product | id, name, price, updated_by, updated_at, version | Bảng nghiệp vụ; updated_by giúp CDC biết actor nếu dùng DebeziumThe business table; updated_by gives CDC an actor if Debezium is used |
db roles | audit_writer: INSERT, SELECT · audit_reader: SELECT · app: không có quyền trên audit_log | Không role nào của ứng dụng có UPDATE hay DELETENo application role has UPDATE or DELETE |
Service chụp giá trị trước, áp thay đổi, che trường nhạy cảm và ghi sự kiện audit vào outbox trong cùng transaction. File SQL tạo bảng chia partition, chỉ cấp INSERT/SELECT và chặn UPDATE/DELETE bằng trigger.The service snapshots the old values, applies the change, masks sensitive fields and writes the audit event to the outbox in the same transaction. The SQL file creates the partitioned table, grants only INSERT/SELECT and blocks UPDATE/DELETE with a trigger.
@Service
@RequiredArgsConstructor
public class ProductAdminService {
private final ProductRepository products;
private final AuditOutboxRepository outbox;
private final CurrentRequest request; // actor + ip from the security context
@Transactional
public Product changePrice(long id, BigDecimal newPrice) {
Product p = products.findByIdForUpdate(id).orElseThrow();
Map<String, Object> before = Map.of("price", p.getPrice());
p.setPrice(newPrice);
Map<String, Object> after = Map.of("price", newPrice);
outbox.save(AuditEvent.of(request.actor(), "PRICE_CHANGED",
"product", String.valueOf(id), before, after, request.ip()));
return p; // UPDATE + outbox INSERT, one commit
}
}
record AuditEvent(UUID eventId, String actor, String action, String entity, String entityId,
Map<String, Object> before, Map<String, Object> after, String ip, Instant at) {
private static final Set<String> SECRET = Set.of("password", "cardNumber", "nationalId", "token");
static AuditEvent of(String actor, String action, String entity, String id,
Map<String, Object> before, Map<String, Object> after, String ip) {
return new AuditEvent(UUID.randomUUID(), actor, action, entity, id,
mask(before), mask(after), ip, Instant.now());
}
static Map<String, Object> mask(Map<String, Object> m) {
Map<String, Object> out = new LinkedHashMap<>(m);
out.replaceAll((k, v) -> SECRET.contains(k) ? "***" : v);
return out;
}
}var secret = map[string]bool{"password": true, "card_number": true, "national_id": true, "token": true}
func mask(m map[string]any) map[string]any {
out := make(map[string]any, len(m))
for k, v := range m {
if secret[k] {
v = "***"
}
out[k] = v
}
return out
}
type ProductAdmin struct{ tx TxRunner }
func (s *ProductAdmin) ChangePrice(ctx context.Context, id int64, price decimal.Decimal) error {
actor, ip := auth.ActorFrom(ctx), auth.IPFrom(ctx)
return s.tx.Do(ctx, func(tx Tx) error {
p, err := tx.LockProduct(ctx, id) // SELECT … FOR UPDATE
if err != nil {
return err
}
if err := tx.UpdatePrice(ctx, id, price); err != nil {
return err
}
ev := AuditEvent{
EventID: uuid.New(),
Actor: actor,
Action: "PRICE_CHANGED",
Entity: "product",
EntityID: strconv.FormatInt(id, 10),
Before: mask(map[string]any{"price": p.Price}),
After: mask(map[string]any{"price": price}),
IP: ip,
At: time.Now().UTC(),
}
return tx.InsertAuditOutbox(ctx, ev) // same commit as the UPDATE
})
}
// Audit Writer: Kafka may redeliver, the unique key makes it harmless.
func (w *AuditWriter) Handle(ctx context.Context, ev AuditEvent) error {
prev, err := w.store.LastHash(ctx, ev.Chain())
if err != nil {
return err
}
h := sha256.Sum256(append(prev, ev.Canonical()...))
return w.store.Insert(ctx, ev, prev, h[:]) // ON CONFLICT (event_id, at) DO NOTHING
}| Trong service (ghi tường minh)In the service (explicit) | CDC / trigger DBCDC / DB trigger | |
|---|---|---|
| Thông tin có đượcWhat it captures | Actor, hành động nghiệp vụ, lý do, IPActor, business action, reason, IP | Mọi thay đổi cột, kể cả SQL chạy tay; thiếu actor nếu không có updated_byEvery column change, manual SQL included; no actor unless there is updated_by |
| Khả năng sótChance of gaps | Sót nếu một đường ghi quên gọi auditGaps when a write path forgets to call the audit | Không sót thay đổi nào đã vào DBNo committed change is missed |
| Chi phí vận hànhOperational cost | Chỉ code và một bảngJust code and a table | Debezium, Kafka Connect, replication slot phải theo dõiDebezium, Kafka Connect and a replication slot to monitor |
| Chọn khiPick it when | Cần audit có ý nghĩa nghiệp vụ cho người đọcYou need business-level audit people can read | Cần bằng chứng đầy đủ cho mọi thay đổi dữ liệu, kể cả ngoài ứng dụngYou need complete evidence of every data change, even outside the application |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
@Async chạy sau và ngoài transaction: process chết hoặc thread pool đầy thì dòng audit mất trong khi thay đổi đã commit. Ghi vào outbox trong cùng transaction rồi đẩy bất đồng bộ thì vừa nhanh vừa không mất.@Async runs later and outside the transaction: if the process dies or the pool is full, the audit row is lost while the change is committed. Writing to an outbox in the same transaction and delivering asynchronously is both fast and lossless.
Ba lớp: role ứng dụng không có UPDATE/DELETE, trigger chặn cả chủ bảng, và chuỗi hash cho phép phát hiện nếu superuser sửa. Có thể ký hash cuối ngày hoặc ghi ra kho WORM như S3 Object Lock.Three layers: the application roles have no UPDATE/DELETE, a trigger blocks even the owner, and the hash chain detects edits made by a superuser. You can also sign the daily head hash or write it to WORM storage such as S3 Object Lock.
Partition theo tháng cộng index (entity_type, entity_id, at DESC). Truy vấn có khoảng thời gian nên chỉ chạm vài partition; trong mỗi partition index tìm thẳng tới vài dòng.Monthly partitions plus an (entity_type, entity_id, at DESC) index. The query carries a time range so it touches only a few partitions, and in each one the index goes straight to a handful of rows.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.