Appearance
[BUG-PERF] Trang Voucher Campaign Detail load quá chậm
Severity: High — ảnh hưởng UX trực tiếp khi mở chi tiết chiến dịch voucher Route:
cms/voucher-management/voucher-campaign/:idComponent:VoucherCampaignDetail.tsxNgày phân tích: 2026-03-18
1. Mô tả vấn đề
Khi mở trang chi tiết một chiến dịch voucher (campaign group), trang load rất chậm. Nguyên nhân gốc nằm ở SQL function get_voucher_analytics_summary kết hợp với cách Hasura xử lý query.
2. Root Cause (đã xác minh)
2.1 Tổng quan data flow
VoucherCampaignDetail.tsx
→ GraphQL: GetVoucherCampaignAnalyticsSummary
→ Hasura resolve 2 fields:
├── get_voucher_analytics_summary(args, where, offset, limit) ← CALL 1
└── get_voucher_analytics_summary_aggregate(args) ← CALL 2 (full data)
→ Mỗi CALL = chạy toàn bộ SQL function 1 lần2.2 Bảng bottleneck chi tiết
| # | Bottleneck | Severity | File & Line |
|---|---|---|---|
| B1 | Hasura KHÔNG push-down limit/offset vào function — function luôn tính TẤT CẢ rows rồi Hasura mới cắt | Critical | voucher_analytics_summary_result là phantom table, Hasura apply limit sau |
| B2 | Function chạy 2 lần — get_voucher_analytics_summary + _aggregate trong cùng 1 GraphQL request | Critical | voucher.graphql:525-576 |
| B3 | voucher_logs bị JOIN 4 lần riêng biệt trong function (latest, usage, financial, restored) | High | Migration 1768377844676: dòng 246-301 |
| B4 | COALESCE(issued_at, activated_at) không match index nào — seq scan trên user_vouchers | High | filtered_vouchers CTE, dòng 217 |
| B5 | Mỗi đổi trang = re-fetch aggregate (count + sum không đổi khi chỉ đổi page) | Medium | VoucherCampaignDetail.tsx:46-58 |
2.3 Giải thích B1 — Tại sao limit: 20 không giúp gì
voucher_analytics_summary_result là bảng PostgreSQL thật nhưng không chứa data — chỉ dùng làm return type cho function. Khi Hasura nhận limit: 20, flow là:
1. Hasura gọi: SELECT * FROM get_voucher_analytics_summary(args...)
2. Function tính TOÀN BỘ campaigns → trả N rows
3. Hasura nhận N rows → apply WHERE, OFFSET, LIMIT → trả 20 rows cho clientNghĩa là dù client request 20 rows, function vẫn phải tính toàn bộ.
2.4 Giải thích B3 — 4 lần JOIN voucher_logs
Trong function hiện tại (version cuối — migration 1768377844676):
sql
-- JOIN lần 1: Lấy log mới nhất per voucher (DISTINCT ON + ORDER BY)
latest_voucher_logs AS (
SELECT DISTINCT ON (vl.voucher_id) ...
FROM voucher_logs vl
JOIN filtered_vouchers fv ON vl.voucher_id = fv.id
WHERE vl.action IN ('voucher_redeemed', 'voucher_restored')
ORDER BY vl.voucher_id, vl.created_at DESC
),
-- JOIN lần 2: Đếm usage gross
usage_stats AS (
SELECT COUNT(DISTINCT vl.voucher_id) as gross
FROM voucher_logs vl
JOIN filtered_vouchers fv ON vl.voucher_id = fv.id
WHERE vl.action = 'voucher_redeemed'
),
-- JOIN lần 3: Tính doanh thu
financial_logs AS (
SELECT vl.voucher_id, vl.before_voucher_values, vl.after_voucher_values
FROM voucher_logs vl
JOIN filtered_vouchers fv ON vl.voucher_id = fv.id
WHERE vl.action = 'voucher_redeemed'
),
-- JOIN lần 4: Tìm voucher bị restore
restored_logs AS (
SELECT vl.voucher_id
FROM voucher_logs vl
JOIN filtered_vouchers fv ON vl.voucher_id = fv.id
WHERE vl.action = 'voucher_restored'
)Mỗi CTE scan voucher_logs × filtered_vouchers độc lập → 4 lần I/O cho cùng tập dữ liệu.
3. Kế hoạch Fix (3 tasks, thứ tự deploy)
Task 1: Thêm expression index — Risk thấp, deploy trước
File: Tạo migration mới
sql
-- Migration: add_expression_index_user_vouchers_coalesce
CREATE INDEX CONCURRENTLY idx_user_vouchers_campaign_coalesce_date
ON user_vouchers (campaign_id, COALESCE(issued_at, activated_at) DESC);Giải quyết: B4 Risk: Thấp — CONCURRENTLY không lock table, additive change Test: So sánh EXPLAIN ANALYZE trước/sau trên query filtered_vouchers CTE
Task 2: Gộp 4 CTE voucher_logs thành 1 — High impact
File: Tạo migration mới, CREATE OR REPLACE FUNCTION get_voucher_analytics_summary
Thay thế:
latest_voucher_logs(DISTINCT ON)usage_stats(COUNT DISTINCT)financial_logs(SUM)restored_logs(EXISTS check)
Bằng 1 CTE duy nhất + derive:
sql
-- 1 lần JOIN duy nhất
voucher_log_summary AS (
SELECT
fv.voucher_campaign_id,
vl.voucher_id,
vl.action,
vl.before_voucher_values,
vl.after_voucher_values,
ROW_NUMBER() OVER (
PARTITION BY vl.voucher_id
ORDER BY vl.created_at DESC
) as rn
FROM voucher_logs vl
JOIN filtered_vouchers fv ON vl.voucher_id = fv.id
WHERE vl.action IN ('voucher_redeemed', 'voucher_restored')
AND (filter_branch_ids IS NULL
OR CARDINALITY(filter_branch_ids) = 0
OR vl.branch_id = ANY(filter_branch_ids))
),
-- Derive usage gross: đếm distinct voucher_id có action = redeemed
usage_stats AS (
SELECT
voucher_campaign_id,
COUNT(DISTINCT voucher_id) FILTER (
WHERE action = 'voucher_redeemed'
) as gross
FROM voucher_log_summary
GROUP BY voucher_campaign_id
),
-- Derive usage net: chỉ tính log mới nhất (rn=1) mà action = redeemed
usage_net_stats AS (
SELECT
voucher_campaign_id,
COUNT(*) FILTER (WHERE action = 'voucher_redeemed') as net
FROM voucher_log_summary
WHERE rn = 1
GROUP BY voucher_campaign_id
),
-- Derive gross revenue + cost: từ tất cả redeemed logs
financial_stats AS (
SELECT
voucher_campaign_id,
COALESCE(SUM(before_voucher_values), 0) as gross_revenue,
COALESCE(SUM(before_voucher_values - after_voucher_values), 0) as total_cost
FROM voucher_log_summary
WHERE action = 'voucher_redeemed'
GROUP BY voucher_campaign_id
),
-- Derive net revenue: chỉ voucher KHÔNG bị restore (rn=1 AND action=redeemed)
net_financial_stats AS (
SELECT
voucher_campaign_id,
COALESCE(
SUM(before_voucher_values) - SUM(before_voucher_values - after_voucher_values),
0
) as net_revenue
FROM voucher_log_summary
WHERE rn = 1 AND action = 'voucher_redeemed'
GROUP BY voucher_campaign_id
)Giải quyết: B3 (giảm ~75% I/O trên voucher_logs) Risk: Medium — logic net_revenue và restored phức tạp Precedent: Migration 1769585514000_optimize_user_vouchers_with_usage đã dùng chính xác pattern ROW_NUMBER() thay DISTINCT ON + NOT EXISTS
Test bắt buộc:
- [ ] Tạo dataset có voucher bị restore (redeemed → restored) — verify net revenue loại trừ đúng
- [ ] Tạo dataset có voucher redeemed nhiều lần (edge case) — verify gross vs net
- [ ] So sánh output function cũ vs mới trên cùng dataset — phải identical
- [ ] EXPLAIN ANALYZE so sánh execution time
Task 3: Tách aggregate ra khỏi pagination query — Frontend
Files cần sửa:
3a. src/modules/cms/graphql/voucher.graphql — Tách thành 2 queries
graphql
# Query 1: Chỉ data phân trang (re-fetch khi đổi page)
query GetVoucherCampaignAnalyticsList(
$args: get_voucher_analytics_summary_args!
$where: voucher_analytics_summary_result_bool_exp!
$offset: Int
$limit: Int
) {
get_voucher_analytics_summary(
args: $args
where: $where
offset: $offset
limit: $limit
) {
voucher_campaign {
id
code
name
}
vouchers_released_total
vouchers_released_online
vouchers_released_print
vouchers_released_offline
vouchers_claimed_total
vouchers_claimed_online
vouchers_claimed_print
vouchers_claimed_offline
usage_gross
usage_net
gross_revenue
net_revenue
voucher_cost
}
}
# Query 2: Aggregate only (fetch 1 lần, cache)
query GetVoucherCampaignAnalyticsAggregate(
$args: get_voucher_analytics_summary_args!
) {
get_voucher_analytics_summary_aggregate(args: $args) {
aggregate {
count
sum {
vouchers_released_total
vouchers_released_online
vouchers_released_print
vouchers_released_offline
vouchers_claimed_total
vouchers_claimed_online
vouchers_claimed_print
vouchers_claimed_offline
usage_gross
usage_net
gross_revenue
net_revenue
voucher_cost
}
}
}
}3b. VoucherCampaignDetail.tsx — Dùng 2 hooks riêng
typescript
// Query 1: Paginated data — re-fetch khi đổi page
const { data: analyticsData, fetching: loading } =
useGetVoucherCampaignAnalyticsListQuery({
variables: computed(() => ({
args: { filter_campaign_group_id: campaignId.value },
where: {},
offset: (pager.pagination.value.page - 1) * pager.pagination.value.rowsPerPage,
limit: pager.pagination.value.rowsPerPage,
})),
requestPolicy: "network-only",
});
// Query 2: Aggregate — fetch 1 lần, dùng cache
const { data: aggregateData } =
useGetVoucherCampaignAnalyticsAggregateQuery({
variables: computed(() => ({
args: { filter_campaign_group_id: campaignId.value },
})),
requestPolicy: "cache-and-network",
});Update các computed sử dụng aggregate data:
reportDataTop,reportDataBottom→ đọc từaggregateDatathay vìanalyticsDatavouchersList→ vẫn đọc từanalyticsData- Watch
rowsNumber→ đọc từaggregateData
Giải quyết: B2 + B5 Risk: Thấp — chỉ thay đổi FE query, cần chạy codegen Sau khi sửa: Đổi page chỉ trigger 1 lần function (thay vì 2)
4. Ước tính cải thiện
| Scenario | Trước fix | Sau fix (cả 3 tasks) |
|---|---|---|
| Load trang lần đầu | 2× full function execution (N campaigns × 4 voucher_logs JOINs) | 2× optimized function (N campaigns × 1 voucher_logs JOIN) + index hit |
| Đổi trang | 2× full function execution | 1× optimized function (chỉ paginated list) |
| Campaign group có 50 campaigns, mỗi campaign 5000 vouchers | ~250K rows scan × 4 JOINs × 2 calls = ~2M row operations | ~250K rows scan × 1 JOIN × 1 call = ~250K row operations |
Estimated improvement: 6-8x faster cho trường hợp trung bình.
5. Files liên quan
Frontend
| File | Vai trò |
|---|---|
src/modules/cms/components/voucher/voucher-campaign/VoucherCampaignDetail.tsx | Component chính — nơi gọi 2 queries |
src/modules/cms/graphql/voucher.graphql (dòng 525-576) | GraphQL query GetVoucherCampaignAnalyticsSummary |
src/api/graphql/generated/controller-types.ts | Generated types — cần chạy lại codegen sau khi tách query |
Backend
| File | Vai trò |
|---|---|
migrations/ecommerce/1768377844676_exclude_greeting_from_summary/up.sql (dòng 182-353) | Version hiện tại của get_voucher_analytics_summary |
migrations/ecommerce/1769585514000_optimize_user_vouchers_with_usage/up.sql | Reference — precedent cho ROW_NUMBER() optimization |
metadata/databases/ecommerce/functions/public_get_voucher_analytics_summary.yaml | Hasura function tracking |
metadata/databases/ecommerce/tables/public_voucher_analytics_summary_result.yaml | Hasura result type + relationship |
Indexes hiện có (reference)
| Index | Columns |
|---|---|
idx_user_vouchers_campaign_source_status | (campaign_id, channel_source, status) |
idx_user_vouchers_campaign_activated_at | (campaign_id, activated_at DESC) |
idx_voucher_logs_voucher_action_created | (voucher_id, action, created_at DESC) |
idx_voucher_logs_action_redeemed | (action, voucher_id) WHERE action = 'voucher_redeemed' |
6. Test checklist
- [ ] Task 1: EXPLAIN ANALYZE trên
SELECT * FROM user_vouchers WHERE campaign_id = X AND COALESCE(issued_at, activated_at) >= Y— phải dùng index mới - [ ] Task 2: Output function mới = output function cũ trên cùng dataset (tạo script so sánh)
- [ ] Task 2: Test edge case: voucher redeemed → restored → verify net revenue loại trừ đúng
- [ ] Task 2: Test edge case: campaign group không có voucher nào → verify trả về 0 cho tất cả fields
- [ ] Task 2: EXPLAIN ANALYZE so sánh execution time trước/sau
- [ ] Task 3: Đổi page → verify chỉ 1 network request (không gọi aggregate)
- [ ] Task 3: Load trang lần đầu → verify aggregate hiển thị đúng (report cards)
- [ ] Task 3: Verify codegen chạy thành công, không break type