skip to content

How would you split one sync.Mutex-guarded session map into hash-sharded mutexes in Go?

level: middleimportance: should knowfreq 48%

answer

  1. one lock becomes many little stores
  2. the key decides which one
  3. hash must be stable per key
  4. never copy a struct holding a Mutex
  5. cross-shard totals stop being atomic

basics

~20 s

Replace the single map and mutex with a fixed array of shards, each holding its own mutex and map. Pick the shard from a hash of the session id, and always work through a pointer so the mutex is never copied.

solid answer

~50 s

I keep a fixed-size array of shard structs, each with its own `sync.Mutex` and its own `map[string]Session`, and route every operation through `&shards[hash(id)%N]`. The hash has to be over the key, so the same id always lands on the same shard. Two Go details matter. First, the helper must return `*shard`, not `shard`: copying a struct that contains a `sync.Mutex` copies the lock, guards nothing, and `go vet`'s `copylocks` check reports it — same reason you never range over the shard array by value. Second, anything that spans shards stops being atomic: a total count, a range over everything, or a move between two ids now needs either several locks in a fixed order or an accepted approximation. Choose `N` in the low tens to low hundreds; more shards mean more idle memory and no extra parallelism beyond the number of goroutines actually running.

code

go · 22 lines
go
type shard struct {
	mu      sync.Mutex
	entries map[string]Session
}

type Store struct {
	seed   maphash.Seed
	shards [64]shard
}

// &s.shards[i], never s.shards[i]: copying a shard copies its sync.Mutex.
func (s *Store) shardFor(id string) *shard {
	return &s.shards[maphash.String(s.seed, id)%64]
}

func (s *Store) Get(id string) (Session, bool) {
	sh := s.shardFor(id)
	sh.mu.Lock()
	defer sh.mu.Unlock()
	sess, ok := sh.entries[id]
	return sess, ok
}

go deeper

for a junior

Know the idea: instead of one lock over one map, keep several small maps each with its own lock, and use a hash of the key to decide which one an operation touches.

for a middle

Be ready to write the struct and the routing helper, and to explain why it returns a pointer to the array element rather than a copy of it. Name the vet check that catches the copy.

for a senior

An interviewer expects you to lead with what sharding costs: no atomic cross-shard count, no multi-key atomicity without ordered lock acquisition, and no help at all when traffic concentrates on one key.

for a principal

Own the decision of where the boundary sits. Pick a fixed shard count nobody tunes, and be able to say why this data was worth partitioning at all rather than restructuring the access pattern.

## The shape Start from the single-lock version: one `sync.Mutex` and one `map[string]Session`, every request path taking the same lock. Sharding replaces it with an array of independent little stores: ```go type shard struct { mu sync.Mutex entries map[string]Session } type Store struct { seed maphash.Seed shards [64]shard } ``` A fixed-size **array**, not a slice, and not a map of shards: the array is allocated once with the `Store`, the count never changes, and there is no second lock protecting the container itself. Each shard's map must be made once at construction (`entries: make(map[string]Session)`), because a nil map panics on write. ## Routing a key to a shard The routing function must be deterministic per key and cheap: ```go func (s *Store) shardFor(id string) *shard { return &s.shards[maphash.String(s.seed, id)%64] } ``` Three properties matter. - **Deterministic.** The same id must always route to the same shard, or two goroutines will operate on the same logical entry under two different locks — a data race that the race detector will find only if both paths actually run concurrently during the test. - **Well spread.** If ids share a prefix, a naive `id[0]` bucket concentrates traffic on a few shards, and you have paid for sharding without buying parallelism. A real hash over the whole key spreads it. - **Cheap.** The hash runs on every operation, inside the request path but outside the lock. Anything that allocates is the wrong choice here. With a power-of-two count you can write `& (N-1)` instead of `% N`; the compiler already turns a modulo by a constant power of two into a mask, so prefer whichever reads better. ## The Go-specific traps **Never copy a shard.** `sync.Mutex` is a value type with no copy semantics. `sh := s.shards[i]` copies the mutex and the map header, so locking `sh.mu` locks a private copy that nothing else can see, while the map inside stays shared — a race with no lock at all. The same trap hides in a loop: `for _, sh := range s.shards { ... }` copies each shard per iteration. Write `for i := range s.shards { sh := &s.shards[i]; ... }`. `go vet` ships a `copylocks` analyzer that reports "assignment copies lock value" for these, and `go test` runs vet by default, so the mistake is usually caught — but only if you do not silence it. **Return a pointer.** The routing helper's return type is `*shard`. That is why `&s.shards[...]` is written with the address-of: array elements are addressable, so this is safe and points into the `Store` itself. **Do not embed the mutex by value in something you pass around.** A `Store` that contains mutexes must itself be passed as `*Store` everywhere, for the same reason. ## What you give up Sharding buys parallelism for **per-key** operations and takes it away from **cross-key** ones. - **A total count** is no longer a single read. You either lock every shard in turn and sum — a snapshot that was never true at any instant, because early shards can change while you read late ones — or keep an `atomic.Int64` alongside, updated inside each shard's critical section, which is exact but adds a shared counter that every writer touches. - **Iterating everything** (an expiry sweep over the session store, say) visits shards one at a time. That is usually fine and even desirable: the sweep holds one shard's lock at a time instead of freezing the whole store. - **Multi-key atomicity** is gone. Moving a value from id A to id B, when they hash to different shards, needs both locks. Take them in a fixed global order — for instance ascending shard index — or you have a lock-ordering cycle waiting to happen. If two shards ever need to be held at once regularly, that is a hint the sharding boundary is in the wrong place. - **Memory** grows a little: N maps each with their own bucket arrays, plus N mutexes. With small N this is noise. ## Choosing N N bounds how many operations can proceed at once, but the real ceiling is how many goroutines are running Go code simultaneously, which `GOMAXPROCS` bounds. There is no parallelism to win beyond that, so a count in the low tens to low hundreds covers realistic machines with headroom for uneven hashing. Very large N mostly buys memory and cache misses. Fix N as a constant; making it configurable invites someone to tune a number nobody can measure. A refinement for very hot shard arrays is to pad each shard so that two shards do not share a CPU cache line, since two cores repeatedly locking neighbouring mutexes will bounce that line between them. Measure before adding padding — it doubles or triples the struct size and, below real contention, buys nothing. ## When not to shard If the critical section is wide because it does I/O, shrink it first: that is a one-line change and often removes the contention entirely. Shard when the section is already tight, the traffic is spread across many keys, and the operations really are per-key. If every request touches the *same* key, no number of shards helps — they all route to one.

  • What goes wrong if the routing helper returns `shard` instead of `*shard`?
    It returns a copy: the `sync.Mutex` is copied, so locking it locks a private lock nobody else sees, while the map header inside still points at the shared map. Every caller then mutates the same map with no effective lock. `go vet`'s `copylocks` analyzer reports the copy, and `go test` runs vet by default, so this normally fails the build.
  • How do you report the total number of entries once the store is sharded?
    Either lock each shard in turn and sum the lengths, accepting that the total is a smear rather than an instant snapshot, or keep a separate `atomic.Int64` incremented and decremented inside each shard's critical section for an exact count. The counter is exact but reintroduces one shared cache line that every writer touches.
  • An operation must move a session from one id to another. What is the rule?
    If the two ids route to different shards you need both locks, and they must always be acquired in the same global order — ascending shard index is the usual choice — otherwise two concurrent moves in opposite directions can each hold one lock and wait for the other. If the two ids land on the same shard, take that one lock once, not twice: `sync.Mutex` is not reentrant.
  • Why does bucketing by the first character of the id often fail to help?
    Because real key spaces are not uniform. If ids share a prefix, or one tenant's keys dominate, most traffic lands on a handful of shards and the busiest lock is as contended as the original single one. A hash over the whole key spreads traffic regardless of the key's structure.

saying these in an interview costs you the question

  • Copying a shard struct, and with it the mutex, into a local variable
  • Ranging over the shard array by value in a sweep
  • Bucketing on a key prefix that is not evenly distributed
  • Assuming a length or total is still atomic after sharding
  • Adding hundreds of shards to a machine with a handful of cores
  • Taking two shard locks in whatever order the ids arrive in