Dashboard, aggregate, tối ưu query, materialized viewDashboards, aggregates, query tuning, materialized views
Checkout ghi đơn vào PostgreSQL primary. Sự kiện order.paid qua outbox lên Kafka; Rollup Worker đánh dấu ngày bị ảnh hưởng và tính lại vào bảng revenue_daily, ClickHouse nhận cùng luồng sự kiện cho phân tích sâu. Reporting API đọc rollup, có cache Redis; báo cáo ad-hoc chạy trên read replica. Chọn “Sự kiện tới trễ” hoặc “Báo cáo chậm” để xem hai tình huống khó.Checkout writes orders to the PostgreSQL primary. order.paid events go through an outbox to Kafka; the Rollup Worker marks the affected day dirty and recomputes it into revenue_daily, while ClickHouse takes the same event stream for deep analysis. The Reporting API reads rollups behind a Redis cache; ad-hoc reports run on a read replica. Pick “Late events” or “Slow report” to see the two hard cases.
1Quản lý tenant t-17 mở biểu đồ doanh thu 16 ngày đầu tháng 10.A manager of tenant t-17 opens the revenue chart for the first 16 days of October.
GROUP BY trên hàng chục triệu dòng chiếm CPU, I/O và buffer cache của checkout. Tách đường đọc: dashboard đọc bảng rollup, báo cáo ad-hoc chạy trên replica với pool và statement_timeout riêng.A GROUP BY over tens of millions of rows takes CPU, I/O and buffer cache from checkout. Split the read path: dashboards read rollup tables, ad-hoc reports run on a replica with their own pool and statement_timeout.
REFRESH MATERIALIZED VIEW lấy ACCESS EXCLUSIVE lock. Dùng REFRESH … CONCURRENTLY (cần unique index trên view), nhưng nó vẫn tính lại toàn bộ; khi dữ liệu lớn, chuyển sang bảng rollup cập nhật từng ngày bằng upsert.REFRESH MATERIALIZED VIEW takes an ACCESS EXCLUSIVE lock. Use REFRESH … CONCURRENTLY (it needs a unique index on the view), but it still recomputes everything; at scale switch to a rollup table updated per day with upserts.
Cộng dồn gross += total sẽ cộng đôi khi Kafka giao lại. Sự kiện chỉ đánh dấu ngày bẩn; job tính lại cả ngày từ bảng gốc và ghi đè. Ngày cũ hơn 72 giờ đóng sổ, chỉ tính lại khi có lệnh thủ công.A running gross += total double-counts when Kafka redelivers. Events only mark the day dirty; a job recomputes the whole day from the source and overwrites it. Days older than 72 hours are closed and only recomputed on a manual command.
Lưu timestamptz (UTC), gom nhóm theo múi giờ tenant: (paid_at AT TIME ZONE 'Asia/Ho_Chi_Minh')::date. Rollup giờ theo UTC không gộp đúng được cho múi lệch nửa giờ như Asia/Kolkata, nên rollup ngày lưu theo ngày địa phương của từng tenant.Store timestamptz (UTC) and group in the tenant's zone: (paid_at AT TIME ZONE 'Asia/Ho_Chi_Minh')::date. UTC hourly rollups cannot be re-bucketed correctly for half-hour zones such as Asia/Kolkata, so daily rollups are stored per tenant-local day.
Định nghĩa báo cáo là một cây metric, dimension, filter. Visitor duyệt cây và sinh SQL cho PostgreSQL hoặc ClickHouse; Adapter che khác biệt hàm thời gian (date_trunc ⇄ toStartOfDay) giữa hai engine.A report definition is a tree of metrics, dimensions and filters. A Visitor walks it and emits SQL for PostgreSQL or ClickHouse; an Adapter hides the time-function differences (date_trunc ⇄ toStartOfDay) between engines.
Đầu giờ sáng hàng trăm người mở cùng dashboard. Cache Redis TTL 60 s, khoá có version của tenant để vô hiệu hoá khi rollup đổi, single-flight để mỗi khoá chỉ một truy vấn đang chạy.At the start of the day hundreds open the same dashboard. Cache in Redis for 60 s, include the tenant's rollup version in the key to invalidate on change, and use single-flight so each key has one query in flight.
Replay WAL xung đột với truy vấn đang đọc gây lỗi “canceling statement due to conflict with recovery”. Tăng max_standby_streaming_delay trên replica dành cho báo cáo, hoặc bật hot_standby_feedback và chấp nhận bloat trên primary; theo dõi replay_lag.WAL replay conflicting with a running read raises “canceling statement due to conflict with recovery”. Raise max_standby_streaming_delay on a reporting-only replica, or enable hot_standby_feedback and accept some bloat on the primary; watch replay_lag.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
orders | id, tenant_id, status, total, refunded, currency, paid_at timestamptz, created_at | Nguồn sự thật trên primary; index (tenant_id, paid_at) cho việc tính lại một ngàySource of truth on the primary; index (tenant_id, paid_at) serves single-day recomputes |
revenue_daily | tenant_id, day date, orders, gross, refunds, computed_at | PK (tenant_id, day); day là ngày địa phương của tenant; ghi bằng upsertPK (tenant_id, day); day is the tenant-local date; written by upsert |
revenue_hourly | tenant_id, hour_utc timestamptz, orders, gross, refunds | Cho biểu đồ trong ngày; gộp lên ngày chỉ đúng với múi giờ lệch tròn giờFor intraday charts; rolling up to days is only correct for whole-hour offsets |
rollup_dirty | tenant_id, day, marked_at | PK (tenant_id, day); job lấy bằng FOR UPDATE SKIP LOCKED rồi xoá sau khi tính lạiPK (tenant_id, day); the job takes rows with FOR UPDATE SKIP LOCKED and deletes them after recomputing |
order_events (ClickHouse) | order_id, tenant_id, event_time DateTime64, category, province, amount, version | ReplacingMergeTree(version), PARTITION BY toYYYYMM(event_time), ORDER BY (tenant_id, event_time, order_id)ReplacingMergeTree(version), PARTITION BY toYYYYMM(event_time), ORDER BY (tenant_id, event_time, order_id) |
Java: worker đánh dấu ngày bẩn từ Kafka và job tính lại từng ngày theo múi giờ tenant bằng upsert. Go: Reporting API đọc rollup trên replica, cache có version và single-flight.Java: a worker marks dirty days from Kafka and a job recomputes each day in the tenant's time zone with an upsert. Go: the Reporting API reads rollups on the replica with a versioned cache and single-flight.
@Service
@RequiredArgsConstructor
public class RevenueRollup {
private final JdbcTemplate jdbc;
private final DirtyDayRepository dirty;
private final ApplicationEventPublisher events;
private static final String RECOMPUTE_DAY = """
INSERT INTO revenue_daily (tenant_id, day, orders, gross, refunds, computed_at)
SELECT o.tenant_id, ?::date, count(*), sum(o.total), sum(o.refunded), now()
FROM orders o JOIN tenant t ON t.id = o.tenant_id
WHERE o.tenant_id = ? AND o.status IN ('PAID', 'REFUNDED')
AND o.paid_at >= (?::date)::timestamp AT TIME ZONE t.timezone
AND o.paid_at < (?::date + 1)::timestamp AT TIME ZONE t.timezone
GROUP BY o.tenant_id
ON CONFLICT (tenant_id, day) DO UPDATE
SET orders = EXCLUDED.orders, gross = EXCLUDED.gross,
refunds = EXCLUDED.refunds, computed_at = EXCLUDED.computed_at
""";
@KafkaListener(topics = {"order.paid", "order.refunded"}, groupId = "revenue-rollup")
public void onOrderEvent(OrderEvent e) {
LocalDate day = e.paidAt().atZone(e.tenantZone()).toLocalDate();
dirty.mark(e.tenantId(), day); // INSERT … ON CONFLICT DO NOTHING
}
@Scheduled(fixedDelay = 30_000)
@Transactional
public void recomputeDirtyDays() {
for (DirtyDay d : dirty.lockBatch(50)) { // FOR UPDATE SKIP LOCKED
jdbc.update(RECOMPUTE_DAY, d.day(), d.tenantId(), d.day(), d.day());
dirty.delete(d);
events.publishEvent(new RollupChanged(d.tenantId(), d.day()));
}
}
}type RevenueReport struct {
replica *pgxpool.Pool // reporting pool on the read replica
cache *redis.Client
group singleflight.Group
}
func (r *RevenueReport) Daily(ctx context.Context, q RevenueQuery) ([]DayRow, error) {
ver, _ := r.cache.Get(ctx, "report:ver:"+q.TenantID).Result()
key := fmt.Sprintf("report:rev:%s:%s:%s:v%s", q.TenantID, q.From, q.To, ver)
if b, err := r.cache.Get(ctx, key).Bytes(); err == nil {
var rows []DayRow
return rows, json.Unmarshal(b, &rows)
}
v, err, _ := r.group.Do(key, func() (any, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
rows, err := r.load(ctx, q)
if err != nil {
return nil, err
}
if b, err := json.Marshal(rows); err == nil {
r.cache.Set(ctx, key, b, 60*time.Second)
}
return rows, nil
})
if err != nil {
return nil, err
}
return v.([]DayRow), nil
}
func (r *RevenueReport) load(ctx context.Context, q RevenueQuery) ([]DayRow, error) {
rows, err := r.replica.Query(ctx, `
SELECT day, orders, gross, refunds
FROM revenue_daily
WHERE tenant_id = $1 AND day BETWEEN $2 AND $3
ORDER BY day`, q.TenantID, q.From, q.To)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, pgx.RowToStructByName[DayRow])
}| Rollup PostgreSQL + replicaPostgreSQL rollups + replica | ClickHouse nạp từ sự kiệnClickHouse fed by events | |
|---|---|---|
| Hợp vớiGood for | Bộ chỉ số cố định, vài dashboard, đội nhỏA fixed set of metrics, a few dashboards, a small team | Phân tích ad-hoc, nhiều chiều, hàng tỷ dòngAd-hoc analysis, many dimensions, billions of rows |
| Tốc độ aggregateAggregate speed | Nhanh khi đọc rollup, chậm khi quét bảng gốcFast on rollups, slow on raw scans | Lưu theo cột, nén tốt, quét trăm triệu dòng trong vài giâyColumnar and compressed, scans hundreds of millions of rows in seconds |
| Sửa và xoáUpdates and deletes | Upsert và transaction bình thườngOrdinary upserts and transactions | Gần như chỉ thêm; sửa qua ReplacingMergeTree, kết quả đúng sau merge hoặc FINALMostly append-only; updates via ReplacingMergeTree, exact after merges or with FINAL |
| Chọn khiPick it when | Muốn ít hệ thống, số khớp tuyệt đối với DBYou want few systems and figures that match the DB exactly | Báo cáo đa chiều thay đổi thường xuyên, dữ liệu vượt sức replicaMulti-dimensional reports change often and data outgrows a replica |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Ngay lập tức: chuyển truy vấn sang replica với statement_timeout. Sau đó: EXPLAIN (ANALYZE, BUFFERS) để thêm index phù hợp. Lâu dài: bảng rollup theo ngày cập nhật tăng dần, cache trước API; nếu báo cáo đa chiều nhiều lên thì đưa sự kiện sang ClickHouse.Right away: move the query to a replica with a statement_timeout. Next: EXPLAIN (ANALYZE, BUFFERS) and add the right index. Longer term: an incrementally updated daily rollup with a cache in front; if multi-dimensional reports grow, stream events into ClickHouse.
Kafka giao ít nhất một lần nên sẽ cộng đôi; hoàn tiền, huỷ đơn cũng phải trừ đúng. Đánh dấu ngày bẩn rồi tính lại cả ngày từ bảng gốc là idempotent và tự sửa được mọi sai lệch trước đó.Kafka delivers at least once, so you will double-count, and refunds and cancellations must subtract correctly. Marking the day dirty and recomputing it from the source is idempotent and repairs any earlier drift.
Mỗi tenant lấy khoảng [00:00, 24:00) theo múi giờ của mình đổi sang UTC: Việt Nam là 14/10 17:00Z tới 15/10 17:00Z, Ấn Độ là 14/10 18:30Z tới 15/10 18:30Z. Vì vậy rollup ngày lưu theo ngày địa phương, không gộp từ rollup giờ UTC.Each tenant takes its local [00:00, 24:00) and converts it to UTC: Vietnam is 14 Oct 17:00Z to 15 Oct 17:00Z, India is 14 Oct 18:30Z to 15 Oct 18:30Z. That is why daily rollups are stored per local day and not summed from UTC hourly rollups.
Materialized view hợp khi dữ liệu vừa và refresh định kỳ là đủ; dùng REFRESH CONCURRENTLY với unique index để không chặn người đọc. Khi dữ liệu lớn hoặc cần tính lại từng phần (một ngày, một tenant), bảng rollup với upsert rẻ hơn nhiều vì không tính lại toàn bộ.A materialized view suits moderate data where a periodic refresh is enough; use REFRESH CONCURRENTLY with a unique index so readers are not blocked. With large data or partial recomputes (one day, one tenant), a rollup table with upserts is far cheaper because nothing is recomputed in full.
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.