concurrent device registration without redis — seg6 ← back to index<br>subject concurrent device registration without redis<br>date jun 1, 2026<br>re race conditions, row-level locks, and why your isolation level matters.
on this page<br>the constraints<br>iteration 0: the naive handler<br>iteration 1: a global mutex<br>iteration 2: per-user mutexes<br>iteration 3: let the database lock for us<br>a table that exists only to be locked<br>the second wrinkle: isolation levels<br>repeatable read<br>serializable<br>read committed<br>the difference, side by side<br>testing it<br>wrapping up<br>A user installs the desktop app on a new machine, signs in, and the backend has to decide: do they have a free seat, or have they hit their device limit? Issue a key or send them packing.
The constraint sounds trivial when you say it out loud. For any user with a maximum device count L and an active count A, make sure A holds. That’s it. That’s the whole feature.
Then you ship it, two of the user’s machines hit “register” within the same millisecond, and your invariant goes out the window.
This is the story of how I got it back, without bringing in any new infrastructure to do it.
the constraints
Half the interesting decisions here come from things I couldn’t do.
The MySQL database is nearly as old as I am. Tables that were designed for one purpose a long time ago, picked up extra columns over the years, and are now load bearing for things they were never meant to do. The legacy backend that’s still serving production traffic reads them in ways that aren’t fully documented and aren’t well tested. The rewrite was rolling out incrementally, and the two backends would run side by side for months.
So:
no schema rewrites to existing tables. Auditing every legacy query wasn’t on the table.
no new infrastructure. No Redis, no ZooKeeper. One more service to operate and monitor, might not be worth it.
no broad locking. Whatever I introduced couldn’t stall unrelated queries. The DB has to keep doing its day job.
multiple backend instances behind a load balancer. Anything relying on shared in-process state is dead on arrival.
That last one is the one that kills most of the obvious approaches.
A quick map of the tables that come up:
users: the main user table. MyISAM , never migrated to InnoDB. Hold that thought, it matters.
features: per-user plan info, including devices (the seat limit L). No unique constraint on user_id. A user might have zero rows, exactly one, or, for some reason, multiple.
registrations: one row per registered device.
iteration 0: the naive handler
// POST /registration { username, password, device_name }<br>func createRegistration() {<br>// We actually have a middleware for this, do not worry.<br>user := GetUserByCredentials(username, password)<br>if user == nil {<br>return http.StatusUnauthorized
seatLimit := GetSeatLimit(user)<br>activeSeats := GetActiveSeatCount(user)
if activeSeats >= seatLimit {<br>return http.StatusConflict
return CreateSeatRegistration(user, deviceName)<br>Read, compare, insert. Looks fine.
It isn’t.
Time Request 1 Request 2 DB State<br>t0 START A=1, L=2<br>auth ok
t1 get_limit -> 2 START A=1, L=2<br>get_count -> 1 auth ok
t2 check (1 2 A=1, L=2<br>get_count -> 1
t3 check (1<br>create
t4 create A=2, L=2
t5 DONE A=3, L=2 *broken*<br>Two requests for the same user both read A=1. Both decide there’s room. Both insert. The user now has three devices registered against a two device limit.
Classic time-of-check to time-of-use. The check (“is there room?”) and the use (“create the registration”) aren’t atomic, so anything that changes between the two, like the other request inserting its row, invalidates the check. The fix is some flavour of synchronization. The interesting question is where it lives.
iteration 1: a global mutex
The dumbest possible synchronization primitive is a sync.Mutex at the top of the handler:
var registrationMu sync.Mutex
func createRegistration() {<br>registrationMu.Lock()<br>defer registrationMu.Unlock()<br>// ...<br>This works, in the technical sense. It also means that two completely unrelated users (different accounts, different plans, different continents) can’t register devices at the same time. One of them waits. For nothing.
We can’t ship that. Throughput collapses the moment traffic shows up. The lock proposed here is too coarse. We only need to serialize requests for the same user.
iteration 2: per-user mutexes
A sync.Map of mutexes, keyed by user ID. Each request grabs the mutex for its user.
var userLocks sync.Map
func lockFor(userID int) *sync.Mutex {<br>mu, _ := userLocks.LoadOrStore(userID, &sync.Mutex{})<br>return mu.(*sync.Mutex)<br>Different users register in parallel, same user requests serialize. The throughput is fine, correctness is fine, life is good. In a single process world, this could be the answer.
We do not live in a single-process world.
flowchart LR<br>lb[load balancer]<br>subgraph instances[backend instances]<br>i1[instance 1<br>userLocks map]<br>i2[instance 2<br>userLocks map]<br>i3[instance 3<br>userLocks...