Skip to content

Build1 publisher3 min readPublished

Splitting pacecache into 64 segments cuts each one's capacity to 1,562 entries

pacecache divides its entry budget across storage segments. Each segment gets its own lock, LRU list and local capacity, so raising the segment count for concurrency lowers how many entries any one hot key range can hold.

The Engineer · Build desk

Illustration accompanying Splitting pacecache into 64 segments cuts each one's capacity to 1,562 entries

What happened

  • pacecache bounds itself with an entry budget instead of a byte-size memory limit, defaulting to 10,000 entries, one storage segment and no time-based expiration.
  • An entry past its deadline is treated as a miss and removed when a lookup finds it, so TTL enforcement does not wait on the optional background cleanup goroutine.
  • GetOrLoadFunc takes a per-call loader and coalesces concurrent misses for the same key into a single shared load.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Once the segment count goes up, the configured maximum stops being available to any single key range: a skewed workload starts evicting at a fraction of the number you set while most of the budget sits idle.
  • decision Segment count turns into a measurement task before deployment, and picking it badly shows up as eviction pressure on hot keys rather than as an error anyone will see in logs.
  • capability An operator can leave background cleanup switched off to avoid the periodic wakeups and still never be served a logically dead entry.
  • cost Memory planning stays with the caller, because the ceiling counts entries: the resident size of a full cache depends on how big the stored values are.

Call `GetOrLoadFunc` with a key that is not in the cache, from twenty goroutines at once, and one loader runs [22]. The other nineteen wait on that result. The loader contract is `func(ctx context.Context, key K) (value V, found bool, err error)` [23], and the separate `found` return lets a loader report a definite absence without inventing an error to carry it.

`WithMaxEntries(100_000)` together with `WithSegmentCount(64)` does not give you one budget of 100,000 guarded by 64 locks. The total entry budget is divided across the segments, and their capacities add up to the configured maximum [7]. That is 1,562.5 entries per segment [25]. Each segment owns its own storage, LRU list, expiration index and lock [8] and enforces its own local capacity, so an uneven key distribution can have one segment evicting while another still has unused space [10]. If the hot key range hashes into a single segment, its working ceiling is one sixty-fourth of the configured maximum, about 1.6 percent [26].

"That makes segmentation a trade-off rather than a free performance switch," pacecache's author wrote [12]. The library does not pick a large number automatically; it defaults to one segment [11], and with one segment the whole cache shares one LRU and one capacity budget [5]. It ships a 64-segment option and defaults to 1 because it does not know your key distribution. The author says the right segment count "depends on the workload" and is "worth measuring rather than guessing" [13].

The unit of the budget is entries, not bytes [2]. The author described cache design as "a collection of trade-offs: lock contention, eviction quality, capacity utilization, expiration, memory overhead, and implementation complexity all pull in different directions" [14]. Counting entries makes the segment division computable before you deploy, and it bounds count only: footprint is entries times average value size, so the ceiling on a 10,000-entry default cache [3] moves with the size of the struct you store [27]. The post publishes no benchmark figures and no comparison against a byte-limited library [28]. Segmentation divides whatever unit you pick.

"An entry being expired is not the same thing as that entry already being physically removed from storage," the author wrote [15]. An expired entry met during a lookup is treated as a miss and removed on that path [16], so TTL enforcement does not depend on a cleanup goroutine [18]. Physical reclamation happens explicitly or through optional background cleanup [17]. An entry that expires and is never read again holds its slot until one of those runs. "I prefer that separation because scheduling cleanup and enforcing expiration are two different concerns," the author wrote [24].

`WithJitter(30*time.Second)` adds a random duration below the configured limit when an expiring entry is stored [19]. That spreads deadlines and keeps a large group of entries from expiring at exactly the same time [19]. Sliding expiration reuses the effective TTL selected when the entry was stored, including any jitter already applied; it does not randomize again on every read [21]. Re-rolling jitter per read would let a hot key's deadline drift on every hit. Individual writes can also pick their own policy, `DefaultExpiration`, `NoExpiration`, or an explicit `30*time.Second` [20].

What to watch

  • Published throughput numbers for 1, 8 and 64 segments on a measured, skewed key distribution.
  • Any change that lets an idle segment lend capacity to a hot one, which would remove the per-segment eviction cliff.
  • Documentation on whether an expired entry still occupies its segment's capacity until cleanup reclaims it.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories