Table of contents
Part of nurago, a collection of independent Go packages for backend services.
import "github.com/tecnickcom/nurago/pkg/mysqllock"
A named lock shared across processes, built on MySQL’s GET_LOCK and RELEASE_LOCK.
locker := mysqllock.New(db)
release, err := locker.Acquire(ctx, "daily-reconciliation", 10*time.Second)
if err != nil {
if errors.Is(err, mysqllock.ErrTimeout) {
return // another instance holds it
}
return err
}
defer release()
When a database you already have is the right lock
Running three replicas means a scheduled job runs three times. A cron-style task, a reconciliation, a report, a cleanup sweep: each needs exactly one instance to do the work.
The usual answers are a dedicated coordination service such as etcd or ZooKeeper, or a Redis lock. Both are correct, and both add an operational dependency. A service already talking to MySQL can get mutual exclusion from the database it already has, with no new component to run, monitor or fail.
ErrTimeout distinguishes “someone else holds it” from a genuine error, so the common case is a clean return rather than an error path.
The connection is pinned, and why that matters
GET_LOCK is scoped to a MySQL session. The lock belongs to the connection that took it, and releasing it requires the same connection.
A sql.DB is a pool, and consecutive calls land on arbitrary connections. Acquiring a lock through the pool and releasing it through the pool releases from whichever connection happened to serve the release, which is usually not the holder.
So Acquire pins a dedicated *sql.Conn for the lock’s lifetime and the returned ReleaseFunc uses it. That also means each held lock consumes a connection from the pool for as long as it is held, which is worth accounting for against MaxOpenConns in sqlconn if many locks are held at once.
Keep-alive, and the lost lock
An idle connection can be closed underneath you: MySQL’s wait_timeout, a proxy, a load balancer, a network partition. When the session ends, MySQL releases the lock, and another instance can take it while your code still believes it holds it.
Two goroutines’ worth of machinery exist for that:
WithKeepAliveInterval sets how often a keep-alive query runs on the pinned connection, holding the session open.
WithKeepAlivePingTimeout bounds a single keep-alive query. Without it a hung connection stalls the keep-alive goroutine indefinitely, and the lock is silently lost while the keep-alive appears to be running. The timeout must be smaller than the interval.
WithLostLockHandler, per acquisition, is called when the keep-alive fails while the lock is held. This is the important one. It is the only signal that your exclusive section is no longer exclusive. Work that must not run concurrently should check it and abort, rather than continuing on the assumption that holding the lock earlier means holding it now.
WithKeepAliveErrorHandler reports keep-alive failures for logging. It is called from the keep-alive goroutine, so it must be safe for concurrent use.
What the lock does not promise
It is not a fencing token. There is no monotonically increasing number to hand to a downstream system, so a delayed writer from a previous lock holder cannot be rejected on arrival. Martin Kleppmann’s How to do distributed locking covers why that matters for anything writing to external storage.
The lock is also only as available as the MySQL instance. A replica is a different server with different locks, so every participant must take the lock on the same instance.
WithReleaseTimeout bounds the release itself, so shutdown is not held open by an unresponsive database.
Dependencies
Reaches a MySQL driver at runtime. The package itself uses database/sql.
Links
- Reference documentation
- Source code
- In depth: A Distributed Lock in Go and MySQL, and Every Way the Session Can Betray You
- Connection pooling: sqlconn
- Scheduling the work: periodic
- All nurago packages