1
0
mirror of https://github.com/redis/go-redis.git synced 2026-10-22 19:49:00 +03:00
Files
go-redis/autopipeline_fd_shards.go
Nedyalko Dyakov 16a7904db0 feat(autopipeline): add FullDuplexPipes option (#4107)
NumShards meant two different things: the number of half-duplex queue
shards (raising it usually hurts and needs Unordered on the async face),
and, with FullDuplex, the number of held full-duplex connections
(raising it is the only way past one connection's ceiling).

FullDuplexPipes now names the full-duplex pipe count. NumShards > 1 is
still honored under FullDuplex when FullDuplexPipes is 0, so 9.23.0
configs keep working; Validate rejects the two set to different values.
Unlike NumShards, FullDuplexPipes has no effect when full duplex cannot
engage, so it never turns into unordered half-duplex shards. Cluster
full duplex keeps one pipe per master.
2026-10-16 10:25:55 +03:00

167 lines
7.4 KiB
Go

package redis
// Multi-connection full duplex: N engines behind ONE autopipeliner.
//
// A single full-duplex engine streams on one held connection, and one
// connection has a throughput ceiling — measured at roughly 920k ops/s on a
// 14-core client against a local server, for both this client and rueidis. Past
// that the engine does not saturate, it degrades, because callers queue behind
// a wire that is already busy. Raising FullDuplexPipes above 1 runs
// several engines on the same client, each holding its own connection, which is
// the only way past that ceiling.
//
// Everything stays on ONE client: one connection pool, one hook chain, one
// invalidation stream. Only the engines multiply.
//
// ROUTING IS BY KEY, NOT ROUND-ROBIN, and that choice is load-bearing. Commands
// for the same key always take the same engine, so per-key order holds and no
// Unordered opt-in is needed — the same exemption the half-duplex path already
// grants cluster slot sharding (see the contentSharded note in
// newAutoPipeliner). Round-robin would put `SET k` and `GET k`, issued
// back-to-back as futures on the deferred face, on two engines with no ordering
// between them, which is a silent behaviour change on a released face.
//
// ORDER IS PER FIRST KEY, NOT PER COMMAND STREAM. A single engine keeps the
// whole submit order; N engines keep only the order of commands that share a
// first key. A command that touches other keys after its first one (COPY,
// RENAME, SMOVE, LMOVE, MSET, DEL/UNLINK/EXISTS with several keys, the
// *STORE family, EVAL with several keys) is routed by its first key only. So
// `Copy(src, dst)` followed by `Get(dst)` can run in either order: they may be
// on different engines. Cluster sharding never meets this case because Redis
// Cluster rejects multi-key commands across slots (CROSSSLOT); a standalone
// server accepts them. Callers that chain such commands should await the
// first future before submitting the dependent one, or keep FullDuplexPipes at 1.
//
// Keyless commands are not ordered against anything either: they round-robin.
// That includes commands that change state for every key (FLUSHDB, FLUSHALL,
// SWAPDB, SCRIPT FLUSH, FUNCTION FLUSH/RESTORE), so `FlushDB()` followed by
// `Get(k)` can run the GET first. The same rule applies: await the first
// future, or keep FullDuplexPipes at 1.
//
// Keys are read with stringArg, the same conversion Ring and the cluster slot
// hash use, so all three route the same key the same way. It differs from the
// wire bytes only for pathological key arguments (a nil key is written as ""
// but read as "<nil>").
//
// For comparison, rueidis spreads keyed commands over its standalone wires at
// random (mux.go slotfn, 4 wires by default on 4+ cores) and orders nothing
// across them; its Do is synchronous, so there are no in-flight futures to
// reorder. Only a DoMulti batch shares one wire.
//
// This is an opt-in: before this change, NumShards>1 never ran more than one
// full-duplex engine, so every existing caller keeps the single ordered stream
// and only a caller that raises the pipe count (FullDuplexPipes, or NumShards as
// spelled in 9.23.0) on a full-duplex autopipeliner accepts per-first-key order.
//
// The cost of that choice is measurable and worth stating: pinning a caller to
// one engine means an unlucky key waits behind that engine's queue while
// another sits idle. At eight wires and 4096 callers this shows up as a wider
// tail than a balancing client achieves (p99.9 77.6 ms against 59.9 ms), and
// the p99.9/p50 ratio rises from 1.2x on a single engine to 3.2x on eight. A
// single FIFO cannot starve anyone; any split gives that up. Ordering was
// judged the more valuable property, but a selectable policy is the obvious
// follow-up for callers who would rather have the tail.
import (
"hash/fnv"
)
// fdPipeCount reports how many full-duplex engines (pipes) to run.
//
// FullDuplexPipes is the option; NumShards>1 is honored as its 9.23.0 spelling
// when FullDuplexPipes is 0 (Validate rejects the two disagreeing). 0 and 1 both
// mean a single engine, which is the default and leaves the single-engine path
// untouched. Sizing is deliberately NOT automatic yet: the
// optimum tracks caller count (roughly one wire per 256-512 callers), and a
// fixed count above 1 costs low-concurrency callers real throughput — eight
// engines measured ~30% behind one at 8 callers, because a handful of callers
// cannot fill eight queues and every batch fragments. Until the count can be
// derived from observed load, it stays the caller's explicit decision.
func fdPipeCount(cfg *AutoPipelineOptions) int {
switch {
case !cfg.FullDuplex:
return 1
case cfg.FullDuplexPipes > 0:
return cfg.FullDuplexPipes
case cfg.NumShards > 1:
return cfg.NumShards
}
return 1
}
// fdFor picks the engine that will carry one command.
//
// The single-engine case returns before doing any work, so the default
// configuration pays one length check per command and nothing else.
func (ap *AutoPipeliner) fdFor(cmd Cmder) *fdEngine {
if len(ap.fds) <= 1 {
return ap.fd
}
// Keyed commands hash to a stable engine so per-key order holds. Keyless
// ones have no order to preserve, so they round-robin and keep the engines
// evenly loaded. Modulo on uint32: converting to int first goes negative
// on 32-bit platforms and panics as a slice index.
if k, ok := cmdFirstKeyFor(cmd); ok {
return ap.fdForKey(k)
}
return ap.fds[(ap.fdRR.Add(1)-1)%uint32(len(ap.fds))]
}
// fdForKey is the engine a key hashes to. Multi-engine only.
func (ap *AutoPipeliner) fdForKey(k string) *fdEngine {
h := fnv.New32a()
_, _ = h.Write([]byte(k))
return ap.fds[h.Sum32()%uint32(len(ap.fds))]
}
// fdForBatch picks the one engine a whole FD pipeline batch can ride, or
// reports false when its keyed commands hash to different engines.
//
// A batch is contiguous on ONE wire, so it can only keep the per-first-key
// order if every keyed command in it belongs to that wire: an earlier,
// unawaited command for one of its keys went to that key's engine, and the
// batch must queue behind it. Keyless commands are not ordered against
// anything (see the header), so they ride along. A batch with no keyed
// command takes the round-robin pick.
func (ap *AutoPipeliner) fdForBatch(cmds []Cmder) (*fdEngine, bool) {
if len(ap.fds) <= 1 {
return ap.fd, true
}
var e *fdEngine
for _, cmd := range cmds {
k, ok := cmdFirstKeyFor(cmd)
if !ok {
continue
}
if pick := ap.fdForKey(k); e == nil {
e = pick
} else if pick != e {
return nil, false
}
}
if e == nil {
e = ap.fdFor(cmds[0])
}
return e, true
}
// cmdFirstKeyFor returns the command's first key and whether it has one.
//
// Uses the shared key-position logic rather than assuming args[1]: that handles
// keyless commands, the static command table, and eval/evalsha variants whose
// key position depends on the runtime numkeys argument. Passing a nil
// CommandInfo keeps this synchronous and network-free, the same way the
// client-side-cache path calls it.
//
// The key is read with stringArg, as Ring does, so a []byte, *string or
// numeric key hashes like the same key given as a string. The key position,
// not the key text, decides whether the command is keyed, so an empty-string
// key still routes by hash.
func cmdFirstKeyFor(cmd Cmder) (string, bool) {
pos := cmdFirstKeyPosWithInfo(cmd, nil)
if pos <= 0 || pos >= len(cmd.Args()) {
return "", false
}
return cmd.stringArg(pos), true
}