Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 13 / 18 40 MIN

Data and persistence

database/sql and Connection Pools

sql.DB is a pool, not a connection. Set three settings that decide what happens under load, read db.Stats while it is busy, and stop leaking rows.

A service runs clean for three months and then one Tuesday the p99 goes to eleven seconds while the database CPU sits at four percent. Nothing was deployed. What changed is concurrency: a marketing push doubled the number of simultaneous requests, and the pool settings nobody wrote down finally mattered. The connections were there. The pool had a hundred of them open, most of them idle, and the requests were not short of capacity. They were short of configuration.

sql.DB is the thing everyone misreads on first contact. The name says database, the type is a struct you can pass around, and the first handler that uses it looks like it holds one connection to MySQL. It holds none until it needs one, and then it holds as many as it decides you need, up to a limit you never set. Everything in this lesson comes from taking that sentence seriously.

sql.Open returns a pool that has not dialled anything

The function that catches people is sql.Open, because its error return looks like a connection failure and is almost never one. It validates the driver name and parses the DSN; that is the whole job. No socket is opened, no handshake happens, and the error is nil even when MySQL is down. A program that opens the database, logs "connected", and then serves traffic will discover the outage on the first query instead of at startup.

Ping and PingContext are what turn that into a real check. They ask the pool for a connection, which dials one if there is none, and send a packet the server has to answer. Watch the counters either side of the call:

db, err := sql.Open("mysql", dsn)
if err != nil {
	log.Fatal(err) // wrong driver name or malformed DSN, not an outage
}
fmt.Println("after Open: ", db.Stats())

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
start := time.Now()
if err := db.PingContext(ctx); err != nil {
	log.Fatal(err)
}
fmt.Printf("PingContext took %v\n", time.Since(start).Round(time.Microsecond))
fmt.Println("after Ping: ", db.Stats())
after Open:  {0 0 0 0 0 0s 0 0 0}
PingContext took 1.592ms
after Ping:  {0 1 0 1 0 0s 0 0 0}
server version: 9.6.0

Nine zeroes before the ping and a one in the second position after it. That second number is OpenConnections, and the ping is the reason it exists. MySQL 9.6.0 answered in 1.6 milliseconds, so the pool paid one TCP connect and one authentication and now has a warm connection to reuse. A PingContext with a three-second deadline is a sensible startup check: fail fast and loudly at boot rather than on the first customer request.

Three settings decide what the pool does under load

Left alone, the pool has no upper bound, keeps two idle connections, and never retires one. That is a considered default for a script and a poor one for a service. Three calls change it, and each one exists because of a specific way production breaks:

db.SetMaxOpenConns(20)                  // cap on connections in use at once
db.SetMaxIdleConns(20)                  // cap on connections kept after use
db.SetConnMaxLifetime(5 * time.Minute)  // retire a connection after this long
db.SetConnMaxIdleTime(2 * time.Minute)  // retire one that has sat unused
SettingDefaultFailure it prevents
SetMaxOpenConnsunlimitedA traffic spike opens a connection per request until the server refuses the next one with too many connections, which your code sees as a failed query.
SetMaxIdleConns2Every request after a quiet spell pays a TCP handshake and an authentication round trip because the pool threw the connections away.
SetConnMaxLifetimeneverThe server, a proxy or a load balancer closes a connection that has been open for hours; the pool does not know, and hands the corpse to the next request.

The two most common mistakes are opposites. Setting MaxIdleConns below MaxOpenConns means the pool closes connections it just paid to open: in the run below, a burst of eight queries against an idle limit of one leaves MaxIdleClosed=7 after the first burst and 14 after the second. The pool never grows past one connection, and every burst pays the handshake again. Setting MaxOpenConns very high is the other mistake, and it is invisible until a bad day, because the database is the thing that runs out, not your process.

A cap is a queue, and the queue is visible in db.Stats

Sixteen concurrent queries that each take 200 milliseconds, once with the pool capped at four connections and once with Go's default of unlimited:

capped pool: SetMaxOpenConns(4), 16 concurrent 200ms queries
  wall clock            1.108s
  fastest / median / slowest query   288ms / 780ms / 1.108s
  peak open / peak in use            4 / 4
  wait count / wait duration         12 / 6.649s

unlimited pool: SetMaxOpenConns(0), 16 concurrent 200ms queries
  wall clock            281ms
  fastest / median / slowest query   247ms / 280ms / 281ms
  peak open / peak in use            16 / 16
  wait count / wait duration         0 / 0s

The capped run is slower and that is the point. Total wall clock is 1.1 seconds because four connections at 200 milliseconds each can only drain sixteen queries in four waves. The slowest request waited 1.087 seconds, and WaitDuration of 6.649 seconds is that waiting summed across all twelve requests that had to queue. The unlimited run finishes in 281 milliseconds with sixteen sockets open on the server at once. Multiply that by every instance behind your load balancer before deciding the cap is unnecessary.

Read those counters in a running service and the diagnosis is immediate. WaitCount rising while InUse equals MaxOpenConnections means requests are queueing for the database, which could be a slow query holding a connection, a cap set too low for the traffic, or one handler that forgot to return a connection. InUse pinned at the cap with WaitDuration climbing faster than the traffic is the signature of a leak, and the leak is the next section.

Query needs a Close and QueryRow does not

Query returns a *sql.Rows that holds a connection until you close it or read to the end of the result set. QueryRow returns a *sql.Row with no close method, because it closes the underlying rows as soon as Scan returns. That asymmetry is safe to ignore right up until it stops being safe, and then it takes the pool down silently.

// The bug, in full. Four concurrent requests against a pool capped at two.
func handlerQuery(db *sql.DB) (int, error) {
	rows, err := db.Query("SELECT id FROM accounts")
	if err != nil {
		return 0, err
	}
	// defer rows.Close() is missing here
	if rows.Next() {
		var got int
		if err := rows.Scan(&got); err != nil {
			return 0, err
		}
		return got, nil
	}
	return 0, rows.Err()
}
request 0: id=1
request 2: id=1
3s in: open=2 inUse=2 idle=0 waitCount=2 - nothing can progress
a new request gave up after 501ms with err=context deadline exceeded

Two requests leaked both connections in the pool, and the other two waited forever. The new request that arrived with a 500 millisecond deadline got context deadline exceeded, an error that reads like a slow database and is nothing of the sort. Worse, the process was not stuck: the garbage collector eventually finalises an unreachable *sql.Rows and returns the connection, so the symptom comes and goes with allocation timing. Delete one comment and the same four requests finish immediately:

all four requests finished
request 0: id=1
request 1: id=1
request 2: id=1
request 3: id=1

The habit that prevents all of it: if the method name is Query, QueryContext or Prepare, the next line closes what it returned. In a loop, do not defer the close inside the loop body, because the connections accumulate until the function returns. Close each one at the end of its iteration, or wrap the body in a function of its own.

A Ping, a ConnMaxLifetime and a closed Rows all have tests, but the one that catches everything is db.Stats() printed periodically from a background goroutine. It costs nothing and it is the only view you get into a component that otherwise fails by going quiet.

The pool is the shareable thing; a transaction is not

The documentation is explicit in one direction: sql.Open returns a *sql.DB that "is safe for concurrent use by multiple goroutines", and a *sql.Stmt carries the same guarantee. Everything a handler needs to share, it can share. Go's own documentation makes no such promise for *sql.Tx or *sql.Rows, and the reason is in the structure: both are bound to one connection, and one connection runs one statement at a time. Passing a transaction into a second goroutine while the first keeps using it interleaves two statements on one wire, and what the driver makes of that is not something you want to find out during an incident.

The rule to carry into the next two lessons is short. Share the *sql.DB as far as you like, and keep the *sql.Tx inside the function that began it, since it is a handle on one connection rather than on the pool.

Log the pool, or find out about it from a graph six hours late

Nothing in the previous section reports itself. A pool that has run out of usable connections is quiet by construction: no error is returned, no goroutine panics, and the database reports a normal number of queries because the queries stopped arriving. The only place the situation is visible is db.Stats(), and the only way it is visible at the right moment is if something prints it on a timer.

func logPool(ctx context.Context, db *sql.DB, every time.Duration) {
	t := time.NewTicker(every)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			s := db.Stats()
			log.Printf("db: open=%d inUse=%d idle=%d maxOpen=%d wait=%d waited=%v lifetimeClosed=%d",
				s.OpenConnections, s.InUse, s.Idle, s.MaxOpenConnections,
				s.WaitCount, s.WaitDuration.Round(time.Millisecond), s.MaxLifetimeClosed)
		}
	}
}

A snapshot once every thirty seconds costs four microseconds and answers the three questions you will actually have. Running that logger against four sequential 150 millisecond queries prints a line like this for every interval, and the shape is what matters rather than the numbers:

db: open=1 inUse=1 idle=0 maxOpen=4 wait=0 waited=0s lifetimeClosed=0
db: open=1 inUse=1 idle=0 maxOpen=4 wait=0 waited=0s lifetimeClosed=0
db: open=1 inUse=0 idle=1 maxOpen=4 wait=0 waited=0s lifetimeClosed=0

open - (idle + inUse) is the count of connections in transit, so a persistent gap there means connections are being opened and discarded rather than retained. inUse pinned at maxOpen with wait climbing means the cap is the bottleneck rather than the database, and lifetimeClosed advancing every interval means connections are being replaced faster than they are being used, which points at a lifetime that is too short for the workload.

The counters are also the cheapest way to prove a fix. A leak shows as inUse climbing by one per failed request and never coming back down; after the Close is added, the same load leaves inUse oscillating around the concurrency of the workload and WaitCount static. That difference, in one log line per interval, is worth more than any amount of reasoning about what the code should be doing.

Lab 40 minutes

Point a Go program at your own database. Configure the pool with SetMaxOpenConns(4), SetMaxIdleConns(4) and a SetConnMaxLifetime of one minute, then ping it and print db.Stats() before and after.

Write a query function that runs SELECT SLEEP(0.2) and fire sixteen concurrent calls at it from a sync.WaitGroup. Record the wall clock, the slowest call and db.Stats() when they are all done. Then change SetMaxOpenConns to zero and run it again. Explain both results in terms of connections rather than in terms of speed.

Now introduce the bug: write a second function that calls db.Query without closing the rows, and call it four times concurrently against a pool capped at two. Record what db.Stats() reports while the processes are stuck, and what the fifth caller sees when it runs with a 500 millisecond deadline.

Show the solution and the reasoning

The three settings belong together, and the ordering of the ping and the configuration is a decision rather than a style choice: configure first, ping second, because the ping may open the first connection and you want it opened under the rules you just set.

db, err := sql.Open("mysql", dsn)
if err != nil {
	return err
}
db.SetMaxOpenConns(4)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(time.Minute)

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
	return err
}

With the cap at four, sixteen 200 millisecond queries need four waves, so the wall clock lands near 800 milliseconds and the slowest caller waits for three waves. The numbers from the run in this lesson were 1.108 seconds and 6.649 seconds of total waiting. With the cap at zero, all sixteen connect at once, finish in roughly 280 milliseconds, and open sixteen sockets on the server. The slower configuration is the correct one for a service, because the fast configuration pushes the limit to the database, where it applies to every instance at once and produces connection errors instead of queueing. Pick the cap from what the server can hold divided by the number of instances you run, and leave headroom for migrations and an administrator with a shell.

For the leak, db.Stats() shows open=2 inUse=2 idle=0 with waitCount=2 and then stops changing. Nothing is wrong with the two connections; they are healthy and idle from the server's point of view and merely never returned to the pool. The fifth caller does not fail, it waits, and a request context with a deadline converts that wait into context deadline exceeded, which is how a leak gets misdiagnosed as a slow query for months. If you are debugging this in a real service, the tell is that WaitCount keeps rising while the database's own query counters stay flat.

One detail worth keeping: rows.Close() returns an error and it is safe to ignore in the common case, because reading a result set to completion already reports errors through rows.Err(). Close unconditionally, and check rows.Err() after the loop if the query matters; those two are not the same check and neither one replaces the other.

Official reference

The Go standard library documentation on pkg.go.dev for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.