← Back to DevBytes

Go Database Bottleneck Detection and Resolution

Understanding Database Bottlenecks in Go Applications

Database bottlenecks occur when the interaction between your Go application and the database becomes the limiting factor in overall system performance. These bottlenecks manifest as slow query execution, connection pool exhaustion, transaction contention, or excessive network latency between the application and database server. In Go's concurrent architecture, what might seem like a minor delay can cascade into goroutine pile-ups, increased memory pressure, and degraded throughput across the entire service.

A database bottleneck isn't always obvious. Your application may handle thousands of requests per second with ease, yet a single unoptimized query running under a lock can stall dozens of goroutines waiting for a connection from the pool. The database often becomes the silent constraint that limits horizontal scaling efforts—you can add more application instances, but if all of them contend for the same database resources, performance eventually plateaus or degrades.

Common Symptoms of Database Bottlenecks

Why Detecting and Resolving Database Bottlenecks Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Database bottlenecks directly impact user experience, infrastructure costs, and system reliability. An e-commerce checkout that takes three seconds instead of 200 milliseconds due to a contended inventory row translates directly to cart abandonment. A reporting endpoint that scans millions of rows without proper indexing can saturate database CPU, starving other queries and causing cascading failures across seemingly unrelated services.

From an operational standpoint, undetected database bottlenecks force premature scaling. Teams often add more database replicas or upgrade to larger instances when the real fix is a five-line query optimization or an additional index. This wastes cloud spend and increases architectural complexity. Furthermore, in Go's concurrency model—where each HTTP handler typically runs in its own goroutine—a blocked database call doesn't just affect that one request. It holds onto memory, prevents the goroutine from being garbage collected quickly, and contributes to the overall degradation curve when the system approaches saturation.

The Go-Specific Impact

Go's database/sql package provides connection pooling out of the box, but it's easy to misconfigure. The default settings include an unbounded SetMaxOpenConns in older versions, which can overwhelm the database. Even with proper configuration, the pool's FIFO behavior means that during a latency spike, all connections can become occupied by slow queries, starving fast queries that could complete in microseconds. Understanding Go's database interaction patterns is critical because the language's high concurrency capability amplifies both throughput potential and the damage caused by blocking operations.

Detection Strategies and Tools

1. Instrumenting Connection Pool Metrics

The most immediate signal of a database bottleneck is connection pool behavior. Go's database/sql.DB object exposes statistics via the Stats() method. You should expose these metrics in your monitoring system and alert on them aggressively.

package main

import (
    "context"
    "database/sql"
    "fmt"
    "net/http"
    "time"

    _ "github.com/lib/pq"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    maxOpenConnections = promauto.NewGauge(prometheus.GaugeOpts{
        Name: "db_max_open_connections",
        Help: "Maximum number of open connections to the database",
    })
    openConnections = promauto.NewGauge(prometheus.GaugeOpts{
        Name: "db_open_connections",
        Help: "Number of currently open connections",
    })
    inUseConnections = promauto.NewGauge(prometheus.GaugeOpts{
        Name: "db_in_use_connections",
        Help: "Number of connections currently in use",
    })
    idleConnections = promauto.NewGauge(prometheus.GaugeOpts{
        Name: "db_idle_connections",
        Help: "Number of idle connections",
    })
    waitCount = promauto.NewCounter(prometheus.CounterOpts{
        Name: "db_wait_count_total",
        Help: "Total number of connection wait events",
    })
    waitDuration = promauto.NewCounter(prometheus.CounterOpts{
        Name: "db_wait_duration_seconds_total",
        Help: "Total time spent waiting for connections",
    })
)

func collectDBStats(db *sql.DB) {
    stats := db.Stats()

    maxOpenConnections.Set(float64(stats.MaxOpenConnections))
    openConnections.Set(float64(stats.OpenConnections))
    inUseConnections.Set(float64(stats.InUse))
    idleConnections.Set(float64(stats.Idle))
    waitCount.Set(float64(stats.WaitCount))
    waitDuration.Set(stats.WaitDuration.Seconds())
}

func main() {
    db, err := sql.Open("postgres",
        "host=localhost port=5432 user=app dbname=production sslmode=disable")
    if err != nil {
        panic(err)
    }

    // Critical configuration
    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(10)
    db.SetConnMaxLifetime(5 * time.Minute)
    db.SetConnMaxIdleTime(2 * time.Minute)

    // Collect stats every 10 seconds
    go func() {
        ticker := time.NewTicker(10 * time.Second)
        defer ticker.Stop()
        for range ticker.C {
            collectDBStats(db)
        }
    }()

    // Expose metrics endpoint
    http.Handle("/metrics", promhttp.Handler())
    http.ListenAndServe(":2112", nil)
}

Watch the WaitCount and WaitDuration metrics closely. A non-zero wait count means goroutines are queuing for connections—this is your early warning that the connection pool is the bottleneck. If wait duration grows beyond a few milliseconds, you have a capacity problem.

2. Query-Level Observability with Tracing

Connection pool metrics tell you that a bottleneck exists; tracing tells you which queries cause it. OpenTelemetry integration with the database calls provides end-to-end visibility into query execution time, allowing you to pinpoint exactly which SQL statements consume the most time.

package main

import (
    "context"
    "database/sql"
    "fmt"
    "net/http"

    _ "github.com/lib/pq"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    "go.opentelemetry.io/otel/trace"
)

var tracer trace.Tracer

func initTracer() {
    exporter, _ := stdouttrace.New(stdouttrace.WithPrettyPrint())
    provider := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
    )
    otel.SetTracerProvider(provider)
    tracer = otel.Tracer("db-tracer")
}

// tracedQuery executes a query with OpenTelemetry instrumentation
func tracedQuery(ctx context.Context, db *sql.DB, query string, args ...interface{}) (*sql.Rows, error) {
    ctx, span := tracer.Start(ctx, "SQL Query",
        trace.WithAttributes(
            attribute.String("db.system", "postgresql"),
            attribute.String("db.statement", query),
        ),
    )
    defer span.End()

    rows, err := db.QueryContext(ctx, query, args...)
    if err != nil {
        span.SetStatus(codes.Error, err.Error())
        span.RecordError(err)
        return nil, err
    }

    span.SetAttributes(attribute.Int("db.rows_affected", 0))
    return rows, nil
}

// slowQueryDetector wraps a query and logs if it exceeds a threshold
func slowQueryDetector(ctx context.Context, db *sql.DB, threshold time.Duration,
    query string, args ...interface{}) (*sql.Rows, error) {

    start := time.Now()
    rows, err := db.QueryContext(ctx, query, args...)
    elapsed := time.Since(start)

    if elapsed > threshold {
        fmt.Printf("SLOW QUERY [%v]: %s | args: %v\n", elapsed, query, args)
    }

    return rows, err
}

func main() {
    initTracer()

    db, _ := sql.Open("postgres",
        "host=localhost port=5432 user=app dbname=production sslmode=disable")
    db.SetMaxOpenConns(25)

    ctx := context.Background()

    // Use traced query for observability
    rows, err := tracedQuery(ctx, db,
        "SELECT id, email, created_at FROM users WHERE status = $1", "active")
    if err != nil {
        fmt.Printf("Query failed: %v\n", err)
        return
    }
    defer rows.Close()

    for rows.Next() {
        var id int
        var email string
        var createdAt time.Time
        rows.Scan(&id, &email, &createdAt)
        fmt.Printf("User: %d, %s\n", id, email)
    }
}

3. Database-Side Profiling

While Go-side instrumentation is essential, database-side profiling reveals the queries that consume the most CPU, execute most frequently, or cause the most logical I/O. For PostgreSQL, use the pg_stat_statements extension; for MySQL, use the performance_schema.events_statements_summary_by_digest table. Here's a Go-based collector that periodically fetches top queries from PostgreSQL:

package main

import (
    "context"
    "database/sql"
    "fmt"
    "time"

    _ "github.com/lib/pq"
)

// TopQuery represents a high-impact database query
type TopQuery struct {
    QueryText       string
    Calls           int64
    TotalTimeMs     float64
    MeanTimeMs      float64
    RowsReturned    int64
    SharedBlocksHit  int64
    SharedBlocksRead int64
}

// collectTopQueries fetches the most expensive queries from pg_stat_statements
func collectTopQueries(ctx context.Context, db *sql.DB, limit int) ([]TopQuery, error) {
    query := `
        SELECT
            query,
            calls,
            total_exec_time::float8 AS total_time_ms,
            mean_exec_time::float8 AS mean_time_ms,
            rows,
            shared_blks_hit,
            shared_blks_read
        FROM pg_stat_statements
        WHERE query NOT LIKE '%pg_stat_statements%'
          AND query NOT LIKE '%pg_stat_%'
        ORDER BY total_exec_time DESC
        LIMIT $1`

    rows, err := db.QueryContext(ctx, query, limit)
    if err != nil {
        return nil, fmt.Errorf("failed to query pg_stat_statements: %w", err)
    }
    defer rows.Close()

    var results []TopQuery
    for rows.Next() {
        var tq TopQuery
        err := rows.Scan(
            &tq.QueryText,
            &tq.Calls,
            &tq.TotalTimeMs,
            &tq.MeanTimeMs,
            &tq.RowsReturned,
            &tq.SharedBlocksHit,
            &tq.SharedBlocksRead,
        )
        if err != nil {
            return nil, fmt.Errorf("scan error: %w", err)
        }
        results = append(results, tq)
    }

    return results, rows.Err()
}

func main() {
    db, err := sql.Open("postgres",
        "host=localhost port=5432 user=app dbname=production sslmode=disable")
    if err != nil {
        panic(err)
    }
    defer db.Close()

    ctx := context.Background()

    // Run every 30 seconds
    ticker := time.NewTicker(30 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ticker.C:
            topQueries, err := collectTopQueries(ctx, db, 10)
            if err != nil {
                fmt.Printf("Error collecting top queries: %v\n", err)
                continue
            }

            fmt.Println("\n=== Top 10 Most Expensive Queries ===")
            for i, tq := range topQueries {
                fmt.Printf("%d. Calls: %d | Total Time: %.2fms | Mean: %.2fms | Query: %.100s...\n",
                    i+1, tq.Calls, tq.TotalTimeMs, tq.MeanTimeMs, tq.QueryText)
            }
        }
    }
}

Pay attention to the ratio of shared_blks_read to shared_blks_hit. A high read ratio indicates queries that bypass the database buffer cache and go to disk—these are prime candidates for index optimization.

Resolution Techniques

1. Connection Pool Tuning

Misconfigured connection pools are the most common and easiest-to-fix bottleneck. The key parameters interact in subtle ways:

package main

import (
    "database/sql"
    "fmt"
    "math"
    "time"
)

// optimalPoolConfig calculates reasonable pool settings based on workload
func optimalPoolConfig(
    expectedConcurrentQueries int,
    avgQueryDuration time.Duration,
    databaseMaxConnections int,
) (maxOpen, maxIdle int, maxLifetime time.Duration) {

    // Little's Law: L = λ × W
    // L = connections needed, λ = query arrival rate, W = average query duration
    // For safety, add 30% buffer
    requiredConnections := int(math.Ceil(float64(expectedConcurrentQueries) * 1.3))

    // Never exceed 80% of database's max connections
    maxDatabaseConnections := int(float64(databaseMaxConnections) * 0.8)

    if requiredConnections < maxDatabaseConnections {
        maxOpen = requiredConnections
    } else {
        maxOpen = maxDatabaseConnections
        fmt.Printf("WARNING: Required connections (%d) exceeds 80%% of database limit (%d)\n",
            requiredConnections, maxDatabaseConnections)
    }

    // Idle connections should be ~40% of max open for burst absorption
    maxIdle = int(float64(maxOpen) * 0.4)

    // Recycle connections every 30 minutes to distribute across replicas
    maxLifetime = 30 * time.Minute

    return maxOpen, maxIdle, maxLifetime
}

func main() {
    // Example: 100 concurrent users, queries take 50ms avg, DB allows 200 connections
    maxOpen, maxIdle, maxLifetime := optimalPoolConfig(100, 50*time.Millisecond, 200)

    fmt.Printf("Recommended settings:\n")
    fmt.Printf("  MaxOpenConns: %d\n", maxOpen)
    fmt.Printf("  MaxIdleConns: %d\n", maxIdle)
    fmt.Printf("  ConnMaxLifetime: %v\n", maxLifetime)

    db, _ := sql.Open("postgres", "host=localhost...")
    db.SetMaxOpenConns(maxOpen)
    db.SetMaxIdleConns(maxIdle)
    db.SetConnMaxLifetime(maxLifetime)
    db.SetConnMaxIdleTime(5 * time.Minute) // Close idle connections after 5 min
}

A common mistake is setting SetMaxOpenConns too high relative to database capacity. Each connection consumes a database backend process and memory (typically 2-10 MB per connection on PostgreSQL). If your Go service has 50 instances each with 100 max open connections, that's 5,000 potential connections hitting a database that may only support 500. Always calculate the global connection ceiling across all instances.

2. Query Optimization with Prepared Statements

Prepared statements reduce parse time and protect against SQL injection. In Go, you can prepare once and reuse across many executions:

package main

import (
    "context"
    "database/sql"
    "fmt"
    "sync"
    "time"
)

// PreparedStatementCache holds pre-compiled statements
type PreparedStatementCache struct {
    mu          sync.RWMutex
    statements  map[string]*sql.Stmt
    db          *sql.DB
}

func NewPreparedStatementCache(db *sql.DB) *PreparedStatementCache {
    return &PreparedStatementCache{
        db:         db,
        statements: make(map[string]*sql.Stmt),
    }
}

// GetOrPrepare returns an existing prepared statement or creates one
func (c *PreparedStatementCache) GetOrPrepare(ctx context.Context, key, query string) (*sql.Stmt, error) {
    // Try read lock first for fast path
    c.mu.RLock()
    stmt, exists := c.statements[key]
    c.mu.RUnlock()

    if exists {
        return stmt, nil
    }

    // Slow path: prepare and store
    c.mu.Lock()
    defer c.mu.Unlock()

    // Double-check after acquiring write lock
    if stmt, exists := c.statements[key]; exists {
        return stmt, nil
    }

    stmt, err := c.db.PrepareContext(ctx, query)
    if err != nil {
        return nil, fmt.Errorf("failed to prepare %s: %w", key, err)
    }

    c.statements[key] = stmt
    return stmt, nil
}

// CloseAll cleans up all prepared statements
func (c *PreparedStatementCache) CloseAll() {
    c.mu.Lock()
    defer c.mu.Unlock()

    for key, stmt := range c.statements {
        stmt.Close()
        delete(c.statements, key)
    }
}

// Example usage: batch insert with prepared statement
func batchInsertUsers(ctx context.Context, cache *PreparedStatementCache, users []User) error {
    stmt, err := cache.GetOrPrepare(ctx, "insert_user",
        "INSERT INTO users (name, email, created_at) VALUES ($1, $2, $3)")
    if err != nil {
        return err
    }

    now := time.Now()
    for _, user := range users {
        _, err := stmt.ExecContext(ctx, user.Name, user.Email, now)
        if err != nil {
            return fmt.Errorf("insert failed for %s: %w", user.Email, err)
        }
    }

    return nil
}

type User struct {
    Name  string
    Email string
}

However, be cautious with prepared statements in environments with many distinct query patterns. Each prepared statement consumes database resources. If your application generates thousands of unique queries (common in ORM-heavy code), you may want to limit prepared statement usage to the most frequent queries only.

3. Implementing Read/Write Splitting

For read-heavy workloads, directing queries to read replicas relieves pressure on the primary database. Here's a pattern that routes queries based on context:

package main

import (
    "context"
    "database/sql"
    "fmt"
    "strings"
)

// DBCluster holds primary and replica connections
type DBCluster struct {
    Primary  *sql.DB
    Replicas []*sql.DB
    rrCounter uint64 // Round-robin counter for replica selection
}

// readContextKey is used to force read-only queries to replicas
type readOnlyContextKey struct{}

// WithReadOnly returns a context that routes queries to replicas
func WithReadOnly(ctx context.Context) context.Context {
    return context.WithValue(ctx, readOnlyContextKey{}, true)
}

// IsReadOnly checks if the context requests read-only routing
func IsReadOnly(ctx context.Context) bool {
    v, ok := ctx.Value(readOnlyContextKey{}).(bool)
    return ok && v
}

// SelectDB chooses the appropriate database based on context and query type
func (c *DBCluster) SelectDB(ctx context.Context, query string) *sql.DB {
    // If context explicitly requests read-only, use replica
    if IsReadOnly(ctx) {
        return c.selectReplica()
    }

    // Heuristic: SELECT queries without FOR UPDATE can go to replicas
    upperQuery := strings.ToUpper(strings.TrimSpace(query))
    if strings.HasPrefix(upperQuery, "SELECT") &&
        !strings.Contains(upperQuery, "FOR UPDATE") &&
        !strings.Contains(upperQuery, "FOR SHARE") {
        return c.selectReplica()
    }

    // Everything else goes to primary
    return c.Primary
}

func (c *DBCluster) selectReplica() *sql.DB {
    if len(c.Replicas) == 0 {
        return c.Primary // Fallback to primary if no replicas
    }

    // Simple round-robin; use atomic increment in production
    idx := int(c.rrCounter % uint64(len(c.Replicas)))
    c.rrCounter++
    return c.Replicas[idx]
}

// QueryContext routes the query to the appropriate database
func (c *DBCluster) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
    db := c.SelectDB(ctx, query)
    return db.QueryContext(ctx, query, args...)
}

// ExecContext always routes to primary for write operations
func (c *DBCluster) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
    return c.Primary.ExecContext(ctx, query, args...)
}

func main() {
    primary, _ := sql.Open("postgres", "host=primary.example.com...")
    replica1, _ := sql.Open("postgres", "host=replica1.example.com...")
    replica2, _ := sql.Open("postgres", "host=replica2.example.com...")

    cluster := &DBCluster{
        Primary:  primary,
        Replicas: []*sql.DB{replica1, replica2},
    }

    ctx := context.Background()

    // This SELECT will hit a replica
    rows, err := cluster.QueryContext(ctx,
        "SELECT id, name FROM products WHERE category = $1", "electronics")
    if err != nil {
        fmt.Printf("Query error: %v\n", err)
        return
    }
    defer rows.Close()

    // Force a specific query to use replica even if it looks like a write
    readCtx := WithReadOnly(ctx)
    rows2, err := cluster.QueryContext(readCtx,
        "SELECT balance FROM accounts WHERE id = $1 FOR UPDATE", 12345)
    if err != nil {
        // This will fail because FOR UPDATE requires primary
        fmt.Printf("Expected error on replica with FOR UPDATE: %v\n", err)
    }
    defer rows2.Close()
}

Be aware of replication lag when implementing read/write splitting. If your application writes data and immediately reads it back, that read must go to the primary. Use context markers or explicit API flags to indicate "read-your-own-writes" consistency requirements.

4. Strategic Indexing and Query Restructuring

Sometimes the bottleneck isn't the Go code at all—it's missing database indexes. Use the profiling data from pg_stat_statements to identify slow queries, then analyze their execution plans. Here's how to automate EXPLAIN collection for slow queries:

package main

import (
    "context"
    "database/sql"
    "fmt"
    "time"
)

// ExplainPlan captures the execution plan for a query
type ExplainPlan struct {
    NodeType     string
    RelationName string
    Cost         float64
    Rows         int64
    ActualTime   float64
}

// analyzeSlowQuery runs EXPLAIN ANALYZE on a query that exceeds the threshold
func analyzeSlowQuery(ctx context.Context, db *sql.DB, query string, args ...interface{}) ([]ExplainPlan, error) {
    // Wrap the query with EXPLAIN ANALYZE
    explainQuery := "EXPLAIN (ANALYZE, COSTS, BUFFERS, FORMAT JSON) " + query

    rows, err := db.QueryContext(ctx, explainQuery, args...)
    if err != nil {
        return nil, fmt.Errorf("EXPLAIN failed: %w", err)
    }
    defer rows.Close()

    var planJSON string
    for rows.Next() {
        rows.Scan(&planJSON)
    }

    // In production, parse the JSON properly
    // For brevity, we just log it
    fmt.Printf("Execution Plan:\n%s\n", planJSON)

    return nil, nil // Simplified; real implementation would parse JSON
}

// indexAdvisor suggests missing indexes based on slow query patterns
func indexAdvisor(query string, explainPlans []ExplainPlan) []string {
    suggestions := []string{}

    // Heuristic: look for sequential scans on large tables
    for _, node := range explainPlans {
        if node.NodeType == "Seq Scan" && node.Rows > 10000 {
            // Suggest an index (simplified—real implementation would parse WHERE clauses)
            suggestions = append(suggestions,
                fmt.Sprintf("Consider adding an index on %s for query: %.100s",
                    node.RelationName, query))
        }
    }

    return suggestions
}

func main() {
    db, _ := sql.Open("postgres", "host=localhost...")
    ctx := context.Background()

    // Identify slow queries first (using previous techniques)
    slowQuery := "SELECT * FROM orders WHERE status = $1 AND created_at > $2"
    threshold := 100 * time.Millisecond

    // Time the query
    start := time.Now()
    rows, err := db.QueryContext(ctx, slowQuery, "pending", time.Now().AddDate(0, -1, 0))
    elapsed := time.Since(start)
    rows.Close()

    if elapsed > threshold || err != nil {
        fmt.Printf("Query took %v - analyzing...\n", elapsed)
        analyzeSlowQuery(ctx, db, slowQuery, "pending", time.Now().AddDate(0, -1, 0))
    }
}

5. Implementing Caching Layers

For read-heavy workloads where data changes infrequently, a cache in front of the database dramatically reduces load. Here's a pattern using an in-memory cache with periodic refresh:

package main

import (
    "context"
    "database/sql"
    "fmt"
    "sync"
    "time"
)

// CacheEntry holds cached data with metadata
type CacheEntry struct {
    Data      interface{}
    ExpiresAt time.Time
    TTL       time.Duration
}

// QueryCache provides a thread-safe cache for database query results
type QueryCache struct {
    mu    sync.RWMutex
    store map[string]*CacheEntry
}

func NewQueryCache() *QueryCache {
    return &QueryCache{
        store: make(map[string]*CacheEntry),
    }
}

// Get retrieves cached data if still valid
func (c *QueryCache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    entry, exists := c.store[key]
    if !exists {
        return nil, false
    }

    if time.Now().After(entry.ExpiresAt) {
        return nil, false
    }

    return entry.Data, true
}

// Set stores data in the cache with a TTL
func (c *QueryCache) Set(key string, data interface{}, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()

    c.store[key] = &CacheEntry{
        Data:      data,
        ExpiresAt: time.Now().Add(ttl),
        TTL:       ttl,
    }
}

// Invalidate removes a specific cache entry
func (c *QueryCache) Invalidate(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    delete(c.store, key)
}

// PurgeExpired removes all expired entries
func (c *QueryCache) PurgeExpired() {
    c.mu.Lock()
    defer c.mu.Unlock()

    now := time.Now()
    for key, entry := range c.store {
        if now.After(entry.ExpiresAt) {
            delete(c.store, key)
        }
    }
}

// CachedQueryService combines database access with caching
type CachedQueryService struct {
    db    *sql.DB
    cache *QueryCache
}

// GetUserByID fetches a user, using cache when possible
func (s *CachedQueryService) GetUserByID(ctx context.Context, userID int) (*User, error) {
    cacheKey := fmt.Sprintf("user:%d", userID)

    // Try cache first
    if cached, ok := s.cache.Get(cacheKey); ok {
        return cached.(*User), nil
    }

    // Cache miss - query database
    var user User
    err := s.db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = $1", userID).
        Scan(&user.ID, &user.Name, &user.Email)
    if err != nil {
        return nil, err
    }

    // Store in cache with 5-minute TTL
    s.cache.Set(cacheKey, &user, 5*time.Minute)

    return &user, nil
}

// InvalidateUser removes a user from cache after an update
func (s *CachedQueryService) InvalidateUser(userID int) {
    cacheKey := fmt.Sprintf("user:%d", userID)
    s.cache.Invalidate(cacheKey)
}

type User struct {
    ID    int
    Name  string
    Email string
}

func main() {
    db, _ := sql.Open("postgres", "host=localhost...")
    cache := NewQueryCache()

    // Background purging of expired entries every minute
    go func() {
        ticker := time.NewTicker(1 * time.Minute)
        defer ticker.Stop()
        for range ticker.C {
            cache.PurgeExpired()
        }
    }()

    service := &CachedQueryService{db: db, cache: cache}

    ctx := context.Background()
    user, err := service.GetUserByID(ctx, 42)
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    fmt.Printf("User: %s <%s>\n", user.Name, user.Email)

    // After updating the user in the database
    service.InvalidateUser(42)
}

6. Transaction and Lock Contention Management

Transaction contention is a subtle but devastating bottleneck. When multiple goroutines compete for locks on the same rows, throughput collapses. The solution often involves keeping transactions short, using optimistic concurrency, or restructuring data access patterns.

package main

import (
    "context"
    "database/sql"
    "fmt"
    "time"
)

// OptimisticLockingUpdate performs an update with version checking
// This avoids holding database locks while the user thinks
func OptimisticLockingUpdate(ctx context.Context, db *sql.DB,
    productID int, expectedVersion int, newQuantity int) error {

    // Start transaction
    tx, err := db.BeginTx(ctx, &sql.TxOptions{
        Isolation: sql.LevelReadCommitted,
        ReadOnly:  false,
    })
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback() // Will be no-op after Commit

    // Attempt update only if version matches
    result, err := tx.ExecContext(ctx,
        `UPDATE inventory 
         SET quantity = $1, version = version + 1, updated_at = $2
         WHERE product_id = $3 AND version = $4`,
        newQuantity, time.Now(), productID, expectedVersion)
    if err != nil {
        return fmt.Errorf("update failed: %w", err)
    }

    rowsAffected, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("rows affected error: %w", err)
    }

    if rowsAffected == 0 {
        // Version mismatch - someone else updated the row
        return fmt.Errorf("concurrent modification detected for product %d (version %d)",
            productID, expectedVersion)
    }

    return tx.Commit()
}

// BulkTransfer performs multiple account transfers in a single transaction
// This reduces round trips but must be short to avoid lock contention
func BulkTransfer(ctx context.Context, db *sql.DB, transfers []Transfer) error {
    tx, err := db.BeginTx(ctx, &sql.TxOptions{
        Isolation: sql.LevelSerializable,
    })
    if err != nil {
        return err
    }
    defer tx.Rollback()

    // Prepare statements for reuse within transaction
    debitStmt, err := tx.PrepareContext(ctx,
        "UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1")
    if err != nil {
        return fmt.Errorf("prepare debit: %w", err)
    }
    defer debitStmt.Close()

    creditStmt, err := tx.PrepareContext(ctx,
        "UPDATE accounts SET balance = balance + $1 WHERE id = $2")
    if err != nil {
        return fmt.Errorf("prepare credit: %w", err)
    }
    defer creditStmt.Close()

    // Sort transfers by account ID to prevent deadlocks
    // (both transactions acquire locks in the same order)
    for _, t := range transfers {
        // Debit source account
        result, err := debitStmt.ExecContext(ctx, t.Amount, t.FromAccount)
        if err != nil {
            return fmt.Errorf("debit %d: %w", t.FromAccount, err)
        }
        affected, _ := result.RowsAffected()
        if affected == 0 {
            return fmt.Errorf("insufficient funds for account %d", t.FromAccount)
        }

        // Credit destination account
        _, err = creditStmt.ExecContext(ctx, t.Amount, t.ToAccount)
        if err != nil {
            return fmt.Errorf("credit %d: %w", t.ToAccount, err)
        }
    }

    return tx.Commit()
}

type Transfer struct {
    FromAccount int
    ToAccount   int
    Amount      float64
}

For hot-row contention (like an inventory counter that gets updated hundreds of times per second), consider moving to a queue-based approach where updates are batched, or use database-specific features like PostgreSQL advisory locks or queue tables to serialize contentious operations without blocking all queries.

Best Practices for Go Database Performance

1. Always Set Connection Pool Limits Explicitly

Never rely on defaults. Always call SetMaxOpenConns, SetMaxIdleConns, and SetConnMaxLifetime immediately after sql.Open. Document the rationale

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles