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.
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.
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.
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.
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.
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.
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
}
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
}
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
}
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.
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)
}
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)
}
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.
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
}
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()
}
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.
ctx.Done() alongside blocking operations.Pool.Get() returns any. If the New function returns []byte but callers expect *[]byte, the type assertion panics under load (when the pool is empty and New is called). Be consistent: always pool the same type.group.Forget(key) before returning transient errors to allow the next caller to retry.go vet with the -copylocks flag.make(chan T, 1000000) to “avoid blocking” trades deadlocks for OOM kills. Size channel buffers based on measured throughput or use a bounded worker pool to apply backpressure.