Go Concurrency Patterns for Databases

Systems Programming intermediate Building with AI ~30 min read

Overview

Database engines are inherently concurrent systems: multiple clients issue reads and writes simultaneously, background compaction and replication run alongside foreground queries, and resource cleanup must happen reliably even when operations fail. Go’s goroutine-based concurrency model provides powerful primitives for building these systems, but using them correctly in a database context requires specific patterns that go beyond basic channel usage.

Production database code in Go relies on a toolkit of concurrency patterns: errgroup for supervised goroutine lifecycles, sync.Pool for amortizing allocation costs on hot paths, singleflight for deduplicating concurrent requests for the same key, concurrent maps (xsync.MapOf) for lock-free metadata caches, and bounded worker pools (such as the ants library) for controlling parallelism in compaction and indexing pipelines.

In production database systems, these patterns appear throughout the storage engine, query executor, and replication layers. Understanding when and why to use each pattern—and the failure modes that arise from misuse—is critical for building reliable, high-performance database systems in Go.

Core Concepts

Goroutine Lifecycle Management

The most dangerous pattern in Go concurrency is the “fire and forget” goroutine. In database systems, every goroutine must have a clear owner, a shutdown path, and error propagation. Leaked goroutines cause memory growth, hold locks indefinitely, and leave resources (file descriptors, network connections) unreleased. The golang.org/x/sync/errgroup package solves this by providing structured concurrency: a group of goroutines that share a context, cancel on first error, and wait for all members to finish.

Object Pooling with sync.Pool

Database hot paths—serializing keys, encoding WAL entries, building query results—allocate and discard temporary buffers at high rates. sync.Pool amortizes allocation costs by reusing objects across goroutines. The pool is goroutine-safe and is drained at each garbage collection cycle, so it adapts to load: under high concurrency the pool grows; under low load, objects are reclaimed.

Request Deduplication with singleflight

When multiple goroutines request the same cache key simultaneously (a “thundering herd”), only one should perform the expensive backend lookup while the others wait for its result. golang.org/x/sync/singleflight provides exactly this: concurrent callers with the same key share a single in-flight request. This is essential for database caches, metadata lookups, and schema resolution.

Key Concept: Structured Concurrency with errgroup

errgroup.Group binds goroutine lifecycles to a shared context.Context. When any goroutine returns an error, the context is canceled, signaling all other goroutines to stop. g.Wait() blocks until all goroutines finish and returns the first error. This prevents goroutine leaks and ensures clean shutdown.

Key Concept: Singleflight Deduplication

singleflight.Group.Do(key, fn) ensures that only one execution of fn is in flight for a given key at any time. Concurrent callers with the same key block and receive the same result. This eliminates redundant work in caches, connection pools, and metadata resolvers.

How It Works

errgroup for Parallel Compaction

Database compaction often processes multiple SSTable files in parallel. An errgroup coordinates the workers, limits concurrency, and cancels remaining work if any single compaction fails:

import (
    "context"
    "golang.org/x/sync/errgroup"
)

func (db *DB) compactLevel(ctx context.Context, tables []*SSTable) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(runtime.NumCPU()) // bound parallelism

    for _, t := range tables {
        t := t // capture loop variable
        g.Go(func() error {
            select {
            case <-ctx.Done():
                return ctx.Err()
            default:
                return db.compactTable(ctx, t)
            }
        })
    }

    return g.Wait() // blocks until all done, returns first error
}

sync.Pool for Buffer Reuse

WAL encoding allocates a byte buffer for every entry. Under write-heavy workloads, this creates GC pressure. A sync.Pool of pre-allocated buffers eliminates most allocations:

var bufPool = sync.Pool{
    New: func() any {
        buf := make([]byte, 0, 4096)
        return &buf
    },
}

func (w *WALWriter) Encode(entry *WALEntry) ([]byte, error) {
    bp := bufPool.Get().(*[]byte)
    buf := (*bp)[:0] // reset length, keep capacity
    defer func() {
        *bp = buf
        bufPool.Put(bp)
    }()

    // Encode header
    buf = binary.AppendUvarint(buf, entry.Sequence)
    buf = binary.AppendUvarint(buf, uint64(len(entry.Key)))
    buf = append(buf, entry.Key...)
    buf = append(buf, entry.Value...)

    // Return a copy — the pooled buffer will be reused
    result := make([]byte, len(buf))
    copy(result, buf)
    return result, nil
}

singleflight for Cache Loading

When the block cache misses, multiple concurrent readers of the same block should not all hit disk. Singleflight ensures only one reader performs the I/O:

import "golang.org/x/sync/singleflight"

type BlockCache struct {
    cache map[uint64][]byte
    mu    sync.RWMutex
    group singleflight.Group
    store *BlockStore
}

func (bc *BlockCache) GetBlock(blockID uint64) ([]byte, error) {
    // Fast path: check cache
    bc.mu.RLock()
    if data, ok := bc.cache[blockID]; ok {
        bc.mu.RUnlock()
        return data, nil
    }
    bc.mu.RUnlock()

    // Slow path: deduplicated disk read
    key := strconv.FormatUint(blockID, 10)
    val, err, _ := bc.group.Do(key, func() (any, error) {
        data, err := bc.store.ReadBlock(blockID)
        if err != nil {
            return nil, err
        }
        bc.mu.Lock()
        bc.cache[blockID] = data
        bc.mu.Unlock()
        return data, nil
    })
    if err != nil {
        return nil, err
    }
    return val.([]byte), nil
}
Key Concept: sync.Pool Semantics

Objects in a sync.Pool may be garbage-collected at any time (typically between GC cycles). Never store state in a pooled object that must persist across uses. Always reset pooled objects before returning them to the pool. Pool *[]byte (pointer to slice) rather than []byte to avoid interface allocation overhead.

Practical Applications

Bounded Worker Pools with ants

The ants library provides a goroutine pool with a fixed capacity, preventing unbounded goroutine creation under load. For example, a document indexing pipeline uses an ants pool to process incoming documents with controlled parallelism, avoiding OOM conditions when thousands of documents arrive simultaneously:

import "github.com/panjf2000/ants/v2"

pool, _ := ants.NewPoolWithFunc(256, func(i any) {
    doc := i.(*Document)
    if err := indexer.IndexDocument(doc); err != nil {
        log.Error("index failed", "doc", doc.ID, "err", err)
    }
})
defer pool.Release()

for doc := range incomingDocs {
    _ = pool.Invoke(doc)
}

Concurrent Maps for Metadata

Schema metadata, table descriptors, and connection state require concurrent read/write access. While sync.Map is part of the standard library, the xsync.MapOf from the puzpuzpuz/xsync package provides better performance for mixed read/write workloads through a sharded, lock-striped design:

import "github.com/puzpuzpuz/xsync/v3"

type SchemaCache struct {
    tables *xsync.MapOf[string, *TableDescriptor]
}

func NewSchemaCache() *SchemaCache {
    return &SchemaCache{
        tables: xsync.NewMapOf[string, *TableDescriptor](),
    }
}

func (sc *SchemaCache) GetTable(name string) (*TableDescriptor, bool) {
    return sc.tables.Load(name)
}

func (sc *SchemaCache) PutTable(name string, desc *TableDescriptor) {
    sc.tables.Store(name, desc)
}

Context-Based Cancellation Chains

Every database operation threads a context.Context through the call chain. Client disconnection cancels the context, which propagates through query execution, storage reads, and network calls. This ensures that abandoned queries release resources promptly rather than running to completion and discarding results.

Implementation Patterns

The Worker-Supervisor Pattern

Long-running background processes (compaction, replication, garbage collection) follow a worker-supervisor pattern: a supervisor goroutine manages worker lifecycles, restarts failed workers, and coordinates graceful shutdown:

type CompactionSupervisor struct {
    db     *DB
    cancel context.CancelFunc
    done   chan struct{}
}

func (cs *CompactionSupervisor) Start(ctx context.Context) {
    ctx, cs.cancel = context.WithCancel(ctx)
    cs.done = make(chan struct{})

    go func() {
        defer close(cs.done)
        ticker := time.NewTicker(30 * time.Second)
        defer ticker.Stop()

        for {
            select {
            case <-ctx.Done():
                return
            case <-ticker.C:
                if err := cs.db.maybeCompact(ctx); err != nil {
                    log.Warn("compaction failed, will retry",
                        "err", err)
                }
            }
        }
    }()
}

func (cs *CompactionSupervisor) Stop() {
    cs.cancel()
    <-cs.done // wait for clean shutdown
}

Fan-Out/Fan-In for Scatter-Gather Queries

Distributed queries scatter requests to multiple shards and gather results. The fan-out/fan-in pattern launches parallel requests with errgroup and collects results through a channel:

func (q *QueryEngine) ScatterGather(ctx context.Context,
    shards []*Shard, query *Query) ([]*Result, error) {

    g, ctx := errgroup.WithContext(ctx)
    results := make(chan *Result, len(shards))

    for _, s := range shards {
        s := s
        g.Go(func() error {
            r, err := s.Execute(ctx, query)
            if err != nil {
                return fmt.Errorf("shard %s: %w", s.ID, err)
            }
            results <- r
            return nil
        })
    }

    // Close results channel when all goroutines finish
    go func() {
        g.Wait()
        close(results)
    }()

    var collected []*Result
    for r := range results {
        collected = append(collected, r)
    }

    return collected, g.Wait()
}
Key Concept: Channel Ownership

Only the goroutine that creates a channel should close it. In fan-out/fan-in patterns, this means the coordinator (not the workers) closes the results channel after g.Wait() returns. Workers that close a shared channel cause panics when other workers try to send.

Common Pitfalls

Further Reading