Skip to content

[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ần

2.2 Bảng bottleneck chi tiết

#BottleneckSeverityFile & Line
B1Hasura KHÔNG push-down limit/offset vào function — function luôn tính TẤT CẢ rows rồi Hasura mới cắtCriticalvoucher_analytics_summary_result là phantom table, Hasura apply limit sau
B2Function chạy 2 lần — get_voucher_analytics_summary + _aggregate trong cùng 1 GraphQL requestCriticalvoucher.graphql:525-576
B3voucher_logs bị JOIN 4 lần riêng biệt trong function (latest, usage, financial, restored)HighMigration 1768377844676: dòng 246-301
B4COALESCE(issued_at, activated_at) không match index nào — seq scan trên user_vouchersHighfiltered_vouchers CTE, dòng 217
B5Mỗi đổi trang = re-fetch aggregate (count + sum không đổi khi chỉ đổi page)MediumVoucherCampaignDetail.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 client

Nghĩ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_revenuerestored 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ừ aggregateData thay vì analyticsData
  • vouchersList → 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

ScenarioTrước fixSau fix (cả 3 tasks)
Load trang lần đầu2× full function execution (N campaigns × 4 voucher_logs JOINs)2× optimized function (N campaigns × 1 voucher_logs JOIN) + index hit
Đổi trang2× full function execution1× 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

FileVai trò
src/modules/cms/components/voucher/voucher-campaign/VoucherCampaignDetail.tsxComponent 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.tsGenerated types — cần chạy lại codegen sau khi tách query

Backend

FileVai 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.sqlReference — precedent cho ROW_NUMBER() optimization
metadata/databases/ecommerce/functions/public_get_voucher_analytics_summary.yamlHasura function tracking
metadata/databases/ecommerce/tables/public_voucher_analytics_summary_result.yamlHasura result type + relationship

Indexes hiện có (reference)

IndexColumns
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