Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

Multi-tenant Database Routing and Connection Pooling in Go microservices

The simplest multi-tenant design in Go is also the most dangerous: one *sql.DB per tenant, looked up from a map at request time. It works beautifully for ten tenants and kills you at ten thousand. The first symptom is subtle — connections stop being reused because every tenant’s pool holds a few idle connections, and before long too many open files shows up in a random request. We learned this the hard way: a pool-per-tenant service with ~1,200 tenants, default settings everywhere, OOM’d a 1 GB container because every *sql.DB carries its own idle connection budget and sql.Open happily creates pools that never close. The fix isn’t “fewer tenants” — it’s engineering the pool lifecycle.

Why per-tenant pools blow up

sql.DB is not a connection; it’s a pool. With defaults, it opens connections lazily and keeps them alive. A tenant pool holding even two idle connections means 1,200 tenants × 2 = 2,400 live Postgres backends, each consuming several MB on the server and one file descriptor per socket on your side — before anyone makes a request. The kernel and the database both have limits, and you’ll hit both long before the code looks busy. So a correct pool-per-tenant manager needs three things: explicit small idle limits, a lifetime cap so sockets rotate, and a reaper that closes pools for tenants that stopped showing up.

A manager that doesn’t leak

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
package tenantdb

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

	_ "github.com/jackc/pgx/v5/stdlib"
	"golang.org/x/sync/singleflight"
)

type Manager struct {
	sf      singleflight.Group
	mu      sync.Mutex
	dbs     map[string]*sql.DB
	touched map[string]time.Time
	dsnFor  func(ctx context.Context, tenantID string) (string, error)
}

func New(dsnFor func(ctx context.Context, tenantID string) (string, error)) *Manager {
	m := &Manager{
		dbs:     make(map[string]*sql.DB),
		touched: make(map[string]time.Time),
		dsnFor:  dsnFor,
	}
	go m.reap(30 * time.Minute)
	return m
}

func (m *Manager) DB(ctx context.Context, tenantID string) (*sql.DB, error) {
	m.mu.Lock()
	if db, ok := m.dbs[tenantID]; ok {
		m.touched[tenantID] = time.Now()
		m.mu.Unlock()
		return db, nil
	}
	m.mu.Unlock()

	v, err, _ := m.sf.Do(tenantID, func() (any, error) {
		// double-check inside the singleflight after the concurrent openers collapse
		m.mu.Lock()
		if db, ok := m.dbs[tenantID]; ok {
			m.touched[tenantID] = time.Now()
			m.mu.Unlock()
			return db, nil
		}
		m.mu.Unlock()

		dsn, err := m.dsnFor(ctx, tenantID)
		if err != nil {
			return nil, err
		}
		db, err := sql.Open("pgx", dsn)
		if err != nil {
			return nil, err
		}
		db.SetMaxOpenConns(20)
		db.SetMaxIdleConns(2)
		db.SetConnMaxLifetime(30 * time.Minute)
		db.SetConnMaxIdleTime(5 * time.Minute)
		if err := db.PingContext(ctx); err != nil {
			db.Close()
			return nil, err
		}
		m.mu.Lock()
		m.dbs[tenantID] = db
		m.touched[tenantID] = time.Now()
		m.mu.Unlock()
		return db, nil
	})
	if err != nil {
		return nil, err
	}
	return v.(*sql.DB), nil
}

func (m *Manager) reap(idleFor time.Duration) {
	for range time.Tick(time.Minute) {
		cutoff := time.Now().Add(-idleFor)
		m.mu.Lock()
		for id, db := range m.dbs {
			if m.touched[id].Before(cutoff) {
				db.Close()
				delete(m.dbs, id)
				delete(m.touched, id)
			}
		}
		m.mu.Unlock()
	}
}

Two details here are the entire difference between working and broken. singleflight.Group collapses N concurrent first-requests for the same tenant into one sql.Open — without it, a traffic spike to a cold tenant opens N duplicate pools and the race-avoidance logic turns into a stampede. And PingContext is mandatory: sql.Open doesn’t dial the database, it only validates the DSN. A bad tenant credential that skips the ping gives you a pool that fails on the first real query — late, in the middle of a request, with no context. The ping moves that failure to a place you can handle.

Sizing the whole system

Pool-per-tenant only stays viable if the aggregate is bounded, not just each pool. With MaxIdleConns: 2 and a 30-minute reaper, a 10,000-tenant shard holds at most ~20,000 idle backends only if every tenant is hot; in practice the 80/20 rule means a few hundred pools are live at once, which Postgres tolerates. That’s the constraint to design for: Postgres max_connections and your ulimit, not the pool config of any single tenant. If your hot set exceeds a few hundred, pool-per-tenant stops being the right shape and you’re really building a router.

The alternatives that scale further

Once per-tenant pools become too heavy, the options, in escalating order:

  • Pool-per-shard. Tenants share a database (schema per tenant, or a tenant_id column) and you keep one pool per shard. Routing picks a pool, not a connection. This is what most of us actually want — pooling by where data lives, not by who owns it.
  • Single pool plus Postgres Row Level Security. One database, one pool, and SET app.tenant_id = ? per request with RLS policies enforcing isolation. No per-tenant pools at all; the database does the firewalling. This is the right answer for thousands of tenants on a few servers.
  • pgbouncer in transaction mode. One Go pool to PgBouncer, which multiplexes many transactions over few real Postgres connections. Useful when tenant count explodes faster than you can re-architect.

Pool-per-tenant is a legitimate stage, not a destination. Our rule of thumb: under a few hundred active tenants it’s simple and fine; above that, shard-pool or RLS and keep the connection math boring.

Monitoring that catches the leak early

db.Stats() is the leak detector and it’s free — OpenConnections, Idle, WaitCount, MaxIdleClosed, MaxLifetimeClosed. We export it per tenant on an interval and alert on WaitCount growth (queued for a connection that never comes) and on the number of live pools. The two alerts that would have caught our original outage: pool count growing past the daily-active-tenant baseline, and file descriptor count tracking 1:1 with pool count. Expose both before you need them — every connection leak we’ve had was invisible until it was an incident.

comments powered by Disqus