Back to ER Diagram
Database Performance

Database Performance at Scale

Strategies for table partitioning, index optimization, read replicas, connection pooling, materialized views, and data archival to handle 10M+ records efficiently.

PostgreSQL 18
PgBouncer
PITR
254 Tables

Overview

With 254 PostgreSQL tables and projected growth to 10M+ records in high-volume tables (audit_logs, po_items, grn_items), database performance optimization is critical for maintaining sub-500ms API response times.

254
PostgreSQL Tables
10M+
Target Records
<500ms
Query Target
100-500
Connection Pool

Table Partitioning

Partition large tables by date to improve query performance and enable efficient data archival.

TablePartition KeyStrategyRetention
audit_logscreated_atMonthly partitions2 years active, then archive
po_itemscreated_atQuarterly partitions5 years
grn_itemsgrn_dateQuarterly partitions5 years
invoice_itemsinvoice_dateQuarterly partitions7 years (statutory)
auction_bidsbid_timeMonthly partitions1 year active
notification_logssent_atMonthly partitions6 months
-- Create partitioned audit_logs table
CREATE TABLE audit_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    entity_type VARCHAR(100) NOT NULL,
    entity_id UUID NOT NULL,
    action VARCHAR(50) NOT NULL,
    old_values JSONB,
    new_values JSONB,
    user_id UUID NOT NULL,
    tenant_id UUID NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);

-- Create monthly partitions
CREATE TABLE audit_logs_2026_01 PARTITION OF audit_logs
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE audit_logs_2026_02 PARTITION OF audit_logs
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

-- Auto-create partitions (pg_partman extension)
SELECT partman.create_parent(
    p_parent_table := 'public.audit_logs',
    p_control := 'created_at',
    p_type := 'native',
    p_interval := '1 month',
    p_premake := 3
);

Index Optimization

Strategic indexing for frequently queried columns while avoiding over-indexing that slows writes.

TableIndexTypePurpose
purchase_ordersidx_po_tenant_statusB-tree (composite)List POs by tenant + status
purchase_ordersidx_po_vendorB-treeVendor-wise PO lookup
vendorsidx_vendor_searchGIN (trgm)Full-text search on name
productsidx_product_categoryB-treeCategory filtering
audit_logsidx_audit_entityB-tree (composite)Entity-wise audit trail
rfq_responsesidx_rfq_vendor_priceB-treePrice comparison queries
-- Composite index for tenant + status queries (most common)
CREATE INDEX idx_po_tenant_status
    ON purchase_orders (tenant_id, status)
    WHERE is_deleted = FALSE;

-- Partial index for active vendors only
CREATE INDEX idx_vendor_active
    ON vendors (tenant_id, vendor_code)
    WHERE status = 'ACTIVE';

-- GIN index for full-text search
CREATE INDEX idx_vendor_search
    ON vendors USING GIN (
        to_tsvector('english', name || ' ' || COALESCE(gstin, ''))
    );

-- BRIN index for time-series data (audit logs)
CREATE INDEX idx_audit_created_brin
    ON audit_logs USING BRIN (created_at);

-- Identify unused indexes (run weekly)
SELECT
    schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND indexrelname NOT LIKE '%_pkey'
ORDER BY pg_relation_size(indexrelid) DESC;

Business Rule: BR-DB-001

Run ANALYZE after bulk imports (> 1000 rows) to update statistics for query planner.

Connection Pooling (PgBouncer)

Manage database connections efficiently to prevent exhaustion under high load.

SettingDevelopmentStagingProduction
Max Connections50100500
Default Pool Size1025100
Min Pool Size51025
Pool ModeSessionTransactionTransaction
Connection Timeout30s30s15s
Idle Timeout300s120s60s
# pgbouncer.ini (Production)
[databases]
reqvise = host=127.0.0.1 port=5432 dbname=reqvise

[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

# Pool settings
pool_mode = transaction
max_client_conn = 500
default_pool_size = 100
min_pool_size = 25
reserve_pool_size = 10
reserve_pool_timeout = 5

# Timeouts
server_connect_timeout = 15
server_idle_timeout = 60
client_idle_timeout = 300
query_timeout = 30

Read Replicas

Route heavy read operations to replicas to reduce load on the primary database.

Query TypeTargetAcceptable Lag
Dashboard KPIsRead Replica5 minutes
Analytics ReportsRead Replica15 minutes
Audit Log QueriesRead Replica1 minute
Vendor Rating CalcRead Replica5 minutes
CRUD OperationsPrimaryN/A
Approval WorkflowsPrimaryN/A
// EF Core Read Replica Configuration
public class ReqviseDbContext : DbContext
{
    private readonly bool _useReadReplica;

    public ReqviseDbContext(DbContextOptions options, bool useReadReplica = false)
        : base(options)
    {
        _useReadReplica = useReadReplica;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        var connectionString = _useReadReplica
            ? Configuration["ConnectionStrings:ReadReplica"]
            : Configuration["ConnectionStrings:Primary"];

        optionsBuilder.UseNpgsql(connectionString);
    }
}

// Service using read replica for reports
public class AnalyticsService
{
    private readonly ReqviseDbContext _readContext;

    public AnalyticsService(IDbContextFactory<ReqviseDbContext> factory)
    {
        _readContext = factory.CreateDbContext(useReadReplica: true);
    }

    public async Task<DashboardKpis> GetKpisAsync()
    {
        return await _readContext.PurchaseOrders
            .AsNoTracking()
            .GroupBy(p => p.Status)
            .Select(g => new { Status = g.Key, Count = g.Count() })
            .ToListAsync();
    }
}

Materialized Views

Pre-compute expensive aggregations for dashboard queries.

Materialized ViewSource TablesRefresh IntervalPurpose
mv_spend_by_categorypo_items, categories15 minutesSpend analytics
mv_vendor_performancevendors, grns, ratings1 hourVendor ratings
mv_procurement_kpisprs, rfqs, pos15 minutesDashboard KPIs
mv_msme_compliancevendors, paymentsDailyMSME reports
-- Spend by Category materialized view
CREATE MATERIALIZED VIEW mv_spend_by_category AS
SELECT
    c.id AS category_id,
    c.name AS category_name,
    po.tenant_id,
    DATE_TRUNC('month', po.po_date) AS month,
    SUM(poi.total_amount) AS total_spend,
    COUNT(DISTINCT po.id) AS po_count
FROM po_items poi
JOIN purchase_orders po ON poi.po_id = po.id
JOIN products p ON poi.product_id = p.id
JOIN categories c ON p.category_id = c.id
WHERE po.status IN ('APPROVED', 'COMPLETED')
GROUP BY c.id, c.name, po.tenant_id, DATE_TRUNC('month', po.po_date);

-- Create unique index for concurrent refresh
CREATE UNIQUE INDEX idx_mv_spend_unique
    ON mv_spend_by_category (category_id, tenant_id, month);

-- Refresh concurrently (no lock)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_spend_by_category;

Data Archival Strategy

Move old data to archive tables to keep active tables lean and fast.

TableActive PeriodArchive TriggerArchive Retention
audit_logs2 yearsQuarterly job7 years (compliance)
notification_logs6 monthsMonthly job2 years
session_logs90 daysMonthly job1 year
auction_bids1 yearQuarterly job5 years
-- Archive audit logs older than 2 years
INSERT INTO audit_logs_archive
SELECT * FROM audit_logs
WHERE created_at < NOW() - INTERVAL '2 years';

DELETE FROM audit_logs
WHERE created_at < NOW() - INTERVAL '2 years';

-- Run as Hangfire scheduled job
[RecurringJob("archive-audit-logs", "0 2 1 */3 *")] // Quarterly at 2 AM
public async Task ArchiveAuditLogsAsync()
{
    await _dbContext.Database.ExecuteSqlRawAsync(archiveSql);
    _logger.Information("Archived audit logs older than 2 years");
}

Business Rules Summary

Rule IDCategoryDescription
BR-DB-001StatisticsRun ANALYZE after bulk imports > 1000 rows
BR-DB-002PartitioningTables > 10M rows MUST be partitioned
BR-DB-003QueriesAll queries MUST have execution time < 30 seconds
BR-DB-004IndexesUnused indexes MUST be reviewed and dropped quarterly
BR-DB-005Read ReplicaAnalytics queries MUST use read replica
BR-DB-006ArchivalAudit logs MUST be retained for 7 years (compliance)