Files
go-redis/csc_refresh_support.go
Nedyalko Dyakov 58cc03e024 perf(csc)!: mark reads with a second-chance bit (#4034)
* feat(csc)!: always refresh invalidated entries

ClientSideCacheRefreshRecencyWindow bounded refresh to entries read
within a window. It was the only consumer that needed a per-READ
recency timestamp on the cache entry, and that timestamp is the
dominant cost of a cache hit.

Removing the option lets the read path stop stamping entries, which the
following commit does. Refresh now covers every invalidated entry.

BREAKING CHANGE: Options.ClientSideCacheRefreshRecencyWindow is removed.
Refresh-on-invalidate always refreshes every invalidated entry.

* perf(csc): mark reads with a second-chance bit

A cache hit stamped entry.lastAccessNs. The read already touches the
entry's cache line, but a store dirties it, so the line must be held
exclusive and costs a writeback when evicted from the CPU cache. With
many concurrent readers over a working set larger than L2, that store
dominates the cost of a hit.

A read now sets a readSinceSweep bit instead. A load that finds it set
leaves the line SHARED, so every core can hold it at once and only the
first read after an eviction sweep writes. Eviction consumes the bit as
a second chance; ordering still uses lastAccessNs, written on insert.

Measured at 256 concurrent callers, get=90/set=10, 99.94% hit rate:
263,801 -> 328,842 reads/s (+24.7%), CPU 27.0 -> 18.9 us/op, hit rate
unchanged. Removing the recency update entirely measured +40.5%, so
this recovers about three fifths of the headroom while keeping
eviction honest.

* perf(csc): decode simple cached replies without a reader

Serving a hit allocated a proto.Reader (and its bufio buffer) per hit
and re-parsed the cached bytes. applyCachedFast decodes the simple
single-frame replies -- nil, status, bulk string -- straight into the
Cmder, and getShared serves the bytes without the defensive copy where
the caller provably cannot retain them.

* fix(csc): do not evict the entry just fulfilled

fulfill publishes an entry with its second-chance bit clear and then
evicts. In a warm memory-capped shard, where every resident entry has
been read, that entry was the only clear-bit candidate and was evicted
on the spot; and since a clear candidate always existed, the sweep that
resets the others never ran, so the shard admitted nothing new. The
entry just fulfilled is now skipped in its own eviction pass.

A refresh republish also restores the invalidated entry's read bit with
its access token, so a refreshed hot key is not the first victim.

* fix(csc): decline negative bulk lengths other than -1

The fast cached-reply decoder took any negative bulk length as a RESP2
null. The reader rejects lengths below -1, and that rejection is what
makes processCached drop a bad entry and refetch; the fast path turned
it into a false miss. It now handles only -1 and declines the rest.

* fix(csc): reject bare LF in cached status replies

The fast cached-reply decoder rejected a status frame with an interior
CR but took one with a bare LF, such as "+OK\nMORE\r\n", as a valid
string. The reader stops at the first LF and rejects the frame, and
that rejection is what makes processCached drop the entry and refetch.
The fast path now declines an interior CR or LF.

* fix(csc): keep refresh recency in the publish lock

A refresh republish published the entry with its second-chance bit
clear and restored the bit in a second lock. On a warm shard the
republished entry was then the only clear-bit victim, so an insert in
that gap evicted the key the refresh had just fetched. The refresher
now stages the kept recency on its reservation (stageRefreshAccess),
and fulfill applies it in the same lock as the publish.

* docs(csc)!: refresh-on-invalidate refreshes all

ClientSideCacheRefreshOnInvalidate still said it re-fetches recently
read keys. Since the recency window was removed, it re-fetches every
cached entry of an invalidated key, so on a write-heavy keyspace the
refetch traffic covers the whole resident cache.

BREAKING CHANGE: Options.ClientSideCacheRefreshRecencyWindow and
UniversalOptions.ClientSideCacheRefreshRecencyWindow are removed. They
shipped only in v9.23.0-beta.1; delete the field where it is set.

* docs(csc): note reads are not monotonic per caller

A read that loses the reservation twice reads the server directly.
Another caller's fetch, reserved earlier and still running, can store
an older value afterwards, and a later hit returns it until the key's
invalidation arrives. This is within the staleness a hit already has,
so document it rather than drop the older fetch, which would cost its
waiters a miss each under the churn that leads here.
2026-10-01 14:36:58 +03:00

113 lines
4.6 KiB
Go

package redis
import (
"sync"
)
// Support shims for refresh-on-invalidate and reader-miss coalescing, kept in
// one file so the feature is a clean addition over the CSC base.
// cscRevalidateHandle is the stop/join handle for a background CSC goroutine.
type cscRevalidateHandle struct {
stop chan struct{}
done chan struct{}
stopOnce sync.Once
}
// signalStop closes stop at most once (so stopCSCRefresher and the AddCleanup
// safety net cannot double-close) and does not join — a GC cleanup must not block.
func (h *cscRevalidateHandle) signalStop() {
h.stopOnce.Do(func() { close(h.stop) })
}
// InvalidationStats reports INCOMING invalidation pushes: the count of keys named
// in server invalidation messages, tallied at the handler before dedup/batching.
func (c *LocalCache) InvalidationStats() (invalidations uint64) {
return c.invalidations.Load()
}
// DeletionStats reports APPLIED invalidations. deletions counts keys the cache
// actually processed for removal (post-dedup, so <= InvalidationStats under
// duplicate pushes); noop counts those that removed no live entry — a key that
// matched nothing, or one whose only match was skipped by the fetch-order guard
// because it was refetched after the invalidation (see collectHotAndDelete).
// Both are the duplicate/stale-invalidation signature.
func (c *LocalCache) DeletionStats() (deletions, noop uint64) {
return c.deletions.Load(), c.deletionsNoop.Load()
}
// deleteByRedisKeyCollectingHot deletes every cache entry tracked under redisKey
// and returns those that were VALID and read since sinceToken, as refetch
// targets. Delete and collect happen under one shard lock: the entry's cache key
// and recency are known only there, and it is about to be removed. fetchSnap is
// the cscFetchSeq value observed when this invalidation arrived; a Valid entry
// whose fetch was ISSUED after that (entry.fetchSeq > fetchSnap) is kept — it was
// refetched after the write, so evicting it would be a spurious miss (see
// cacheEntry.fetchSeq).
func (c *LocalCache) deleteByRedisKeyCollectingHot(redisKey string, sinceToken int64, fetchSnap uint64, dst []cscRefreshTarget) []cscRefreshTarget {
removed := 0
for i := range c.shards {
var n int
dst, n = c.shards[i].collectHotAndDelete(redisKey, sinceToken, fetchSnap, dst)
removed += n
}
// Applied-delete accounting (refresh-on path). Twin of DeleteByRedisKey; the
// incoming push was already counted at the handler.
c.deletions.Add(1)
if removed == 0 {
c.deletionsNoop.Add(1)
}
return dst
}
func (s *cacheShard) collectHotAndDelete(redisKey string, sinceToken int64, fetchSnap uint64, dst []cscRefreshTarget) ([]cscRefreshTarget, int) {
s.mu.Lock()
defer s.mu.Unlock()
cacheKeys, ok := s.byRedisKey[redisKey]
if !ok {
return dst, 0
}
toRemove := make([]string, 0, len(cacheKeys))
for cacheKey := range cacheKeys {
toRemove = append(toRemove, cacheKey)
}
removed := 0
for _, cacheKey := range toRemove {
entry, exists := s.entries[cacheKey]
// Fetch-order guard: an entry whose fetch was ISSUED after this invalidation
// was observed (fetchSeq > fetchSnap) cannot hold the pre-write value, so keep
// it — whether already Valid or still in-progress. Evicting a Valid one would
// be a spurious miss (and a stale queued duplicate straddling a refetch must
// not undo the fresh value, #3965); canceling an in-progress one would fail its
// racing Fulfill, wake every waiter as a miss, and defeat miss coalescing. An
// entry reserved at or before the observe (fetchSeq <= fetchSnap) may predate
// the write — possibly fetched on a different stream (see deleteByRedisKey) —
// so it is removed, and any racing Fulfill for it correctly fails.
if exists && entry.fetchSeq > fetchSnap {
continue
}
if exists && entry.state == cacheEntryValid && entry.lastAccessNs.Load() > sinceToken {
keys := make([]string, len(entry.redisKeys))
copy(keys, entry.redisKeys)
dst = append(dst, cscRefreshTarget{
cacheKey: cacheKey,
redisKeys: keys,
// Preserve the reader-access token so the refresh republish can restore it
// and not renew demand (see cscRefreshTarget.accessNs / stageRefreshAccess).
// Re-load is safe: the shard lock is held, so no writer intervenes.
accessNs: entry.lastAccessNs.Load(),
read: entry.readSinceSweep.Load(),
// The dying entry's payload size approximates the refetch REPLY size —
// the refresher chunks round trips by expected reply bytes with it (see
// cscRefreshChunkEnd). Known for free here, under the same shard lock.
valBytes: len(entry.value),
})
}
if s.removeEntryLocked(cacheKey) {
removed++
}
}
return dst, removed
}