A from-scratch, generics-first Redis client for Go (RESP2 + RESP3), written to demonstrate how a new Go Redis library can use modern generics to fix the two biggest pain points of the existing ecosystem (go-redis / rueidis):
- One
Result[T]instead of twenty*Cmdtypes — the result type is a type parameter, selected and type-checked at compile time. - Compile-time reply typing —
Getreturns*string,HGetAllreturnsmap[string]string, and the decoder is picked by the type parameter.
The design trade-off: generic types make the result layer minimal and type
safe, but the command layer still needs named helpers (or code generation)
for full safety. Exec[T] and Do are the runtime escape hatches.
The type parameter = the reply type. A single
Exec[T]runs any command; helper methods fixTat the call site.
val, err := c.Get(ctx, "key").Val() // val is *string
n, err := c.Incr(ctx, "counter").Val() // n is int64
m, err := c.HGetAll(ctx, "h").Val() // m is map[string]stringClient (standalone), Cluster, and FailoverClient (Sentinel) all implement
the same UniversalClient interface. Write business code against the
interface and switch deployment modes without touching application logic:
func newRedis() quickredis.UniversalClient {
switch config.Mode {
case "standalone":
c, _ := quickredis.NewClient(ctx, quickredis.Options{Addr: ...})
return c
case "cluster":
c, _ := quickredis.NewCluster(ctx, quickredis.ClusterOptions{Addrs: ...})
return c
case "sentinel":
c, _ := quickredis.NewFailoverClient(ctx, quickredis.FailoverOptions{...})
return c
}
}
func getUserName(rdb quickredis.UniversalClient, id string) (string, error) {
return rdb.Get(ctx, "user:"+id+":name").Val()
}NewUniversalClient takes one UniversalOptions struct (the union of Options,
ClusterOptions, and FailoverOptions) and returns the right client as a
UniversalClient.
With Mode left empty (the zero value ModeAuto), the mode is inferred from
the fields you set:
| You set | Mode |
|---|---|
MasterName |
Sentinel — Addrs are the sentinel nodes |
len(Addrs) > 1 |
Cluster — Addrs are the seed nodes |
| otherwise | standalone — Addrs[0] (or the default address) |
rdb, err := quickredis.NewUniversalClient(ctx, quickredis.UniversalOptions{
MasterName: "mymaster",
Addrs: []string{"10.0.0.1:26379", "10.0.0.2:26379"},
Password: "secret",
PoolSize: 8,
ReadTimeout: 3 * time.Second,
})
if err != nil {
panic(err)
}
defer rdb.Close()
val, err := rdb.Get(ctx, "key").Val() // works regardless of the modeSet Mode to force a specific client type, overriding inference. This matters
when the heuristic guesses wrong — for example, a standalone deployment whose
single address happens to be a load balancer in front of one Redis, or several
Addrs that are multiple standalone hosts rather than a cluster.
// Standalone behind a single load-balancer VIP:
rdb, err := quickredis.NewUniversalClient(ctx, quickredis.UniversalOptions{
Mode: quickredis.ModeStandalone,
Addrs: []string{"redis.internal:6379"},
})
// Multiple standalone hosts (NOT a cluster) — force standalone:
rdb, err = quickredis.NewUniversalClient(ctx, quickredis.UniversalOptions{
Mode: quickredis.ModeStandalone,
Addrs: []string{"10.0.0.1:6379", "10.0.0.2:6379"},
})
// A cluster reachable through a single seed address:
rdb, err = quickredis.NewUniversalClient(ctx, quickredis.UniversalOptions{
Mode: quickredis.ModeCluster,
Addrs: []string{"cluster-proxy:6379"},
})
// Sentinel with an explicit mode (MasterName still required):
rdb, err = quickredis.NewUniversalClient(ctx, quickredis.UniversalOptions{
Mode: quickredis.ModeSentinel,
MasterName: "mymaster",
Addrs: []string{"10.0.0.1:26379", "10.0.0.2:26379"},
})Available modes: ModeAuto (infer, the default), ModeStandalone,
ModeCluster, ModeSentinel. An unknown mode string returns an error.
Fields that apply to only one mode (e.g. AutoPipeline is standalone-only,
ReadOnly is cluster-only) are ignored by the other modes.
package main
import (
"context"
"fmt"
"time"
"github.com/antlabs/quickredis"
)
func main() {
ctx := context.Background()
// RESP2 by default; set Protocol: 3 to enable RESP3 types (map, set,
// bool, double, push, client-side caching).
c, err := quickredis.NewClient(ctx, quickredis.Options{
Addr: "localhost:6379",
Protocol: 3,
})
if err != nil {
panic(err)
}
defer c.Close()
// Typed helpers — the type parameter is fixed at the call site.
if _, err := c.Set(ctx, "key", "hello", 10*time.Second).Val(); err != nil {
panic(err)
}
s, err := c.Get(ctx, "key").Val() // *string; Nil error if key missing
fmt.Printf("%v %v\n", s, err)
// Generic escape hatch for any command.
n, err := quickredis.Exec[int64](c, ctx, "INCRBY", "counter", "5").Val()
fmt.Println(n, err)
}Every helper fixes T at compile time:
| Helper | T |
Null semantics |
|---|---|---|
Get |
*string |
Nil error when missing |
HGet |
*string |
Nil error when missing |
Incr / IncrBy / Decr / Del / Exists |
int64 |
— |
HGetAll |
map[string]string |
empty map |
SMembers / LRange / ZRange / MGet |
[]string / []*string |
— |
SIsMember / Expire / SetNX |
bool |
— |
Ping / Type / Set / MSet |
string |
— |
Every helper returns a Result[T]; unwrap it in three ways:
s, err := c.Get(ctx, "key").Val() // (T, error) — idiomatic Go
if err := c.Set(ctx, "k", "v", 0).Err(); err != nil { ... }
s := c.Get(ctx, "key").Must() // panics on error// Exec[T] runs any command; T picks the decoder.
n, _ := quickredis.Exec[int64](c, ctx, "INCRBY", "counter", "5").Val()
xs, _ := quickredis.Exec[[]string](c, ctx, "ZRANGE", "z", "0", "-1").Val()
kv, _ := quickredis.Exec[map[string]string](c, ctx, "CONFIG", "GET", "maxmemory").Val()
u, _ := quickredis.Exec[User](c, ctx, "HGETALL", "user:1").Val() // struct (see below)
// Do returns the natural Go value (string, int64, float64, bool, []any, map[string]any, nil).
a, _ := c.Do(ctx, "XRANGE", "s", "-", "+").Val() // a is []any
// Raw returns the parsed Value; use its typed accessors or Decode[T].
v, err := c.Raw(ctx, "HGETALL", "user:1")
m, _ := v.AsMap() // map[string]string (RESP2 array or RESP3 map)vs, err := c.Pipeline(ctx,
[]string{"GET", "k"},
[]string{"INCR", "c"},
)
s, err := quickredis.Decode[string](vs[0]).Val()
n, err := quickredis.Decode[int64](vs[1]).Val()// The Builder assembles raw argument slices for Do / Raw / Pipeline:
vs, err := c.Pipeline(ctx,
c.B().Args("GET", "k"),
c.B().Args("INCR", "c"),
)Borrow runs a command and hands the reply to a callback as a borrowed
Value, whose string/byte fields alias the connection's reusable read buffer:
err := c.Borrow(ctx, func(v quickredis.Value) error {
// v.Bytes / v.Str are zero-copy views into the connection buffer.
// They are valid ONLY inside this callback.
fmt.Println(string(v.Bytes))
return nil
}, "GET", "key")quickredis exposes two reply models with different lifetime and allocation guarantees. Choosing between them is the main performance lever in the hot path:
| Owned API (typed helpers) | Borrowed API (Borrow) |
|
|---|---|---|
| Example | s, _ := c.Get(ctx, k).Val() |
c.Borrow(ctx, fn, "GET", k) |
| Returns | Result[T] (e.g. map[string]string, []*string) |
Value inside a callback |
| Lifetime | Valid indefinitely, safe to store / cross goroutines | Valid only inside fn; overwritten on next command |
| Allocation | Allocates the return value (map, slice, each pointer/string) | Zero-allocation read path (bytes alias the buffer) |
| Safety | No footguns | Must copy before fn returns (string(v.Bytes)) |
| Use when | You need to keep the result, or ergonomics matter more than allocation | Hot path that consumes the reply immediately (e.g. high-throughput counters, dashboards) |
The owned path is the idiomatic default; the borrowed path is an escape hatch for allocation-sensitive code. They share the same command encoding and read layer, so results are identical — only lifetime and allocation differ.
To retain borrowed data beyond the callback, copy it explicitly:
var keep string
c.Borrow(ctx, func(v quickredis.Value) error {
keep = string(v.Bytes) // copy escapes the borrow
return nil
}, "GET", "key")
// keep is safe to use herefn must not call blocking commands on the same client (the connection is
checked out until fn returns, so a nested borrow would deadlock).
// Raw push handler (RESP3 '>' frames of any type):
c.OnPush(quickredis.PushFunc(func(ctx context.Context, p quickredis.Push) {
fmt.Println("push:", p.Type, p.Values)
}))
// Convenience bindings:
c.OnInvalidate(func(ctx context.Context, keys []string) {
fmt.Println("invalidated:", keys)
})
c.OnPubSub(func(ctx context.Context, channel, message string) {
fmt.Printf("%s: %s\n", channel, message)
})Named helpers are organized by Redis command group, one file per data type:
| File | Commands |
|---|---|
commands.go |
server + pubsub + generic keys + strings (Ping, Del, Exists, Expire, Get, Set, Incr, MGet, MSet, ...) |
commands_hash.go |
HSet, HGet, HGetAll, HDel, HIncrBy, HKeys, HVals, HScan, ... |
commands_list.go |
LPush, RPush, LPop, RPop, LRange, LIndex, LInsert, LMove, BLPop, BRPop, ... |
commands_set.go |
SAdd, SRem, SMembers, SIsMember, SInter, SUnion, SDiff, SPop, ... |
commands_zset.go |
ZAdd, ZScore, ZRange, ZRank, ZIncrBy, ZCount, ZPopMin, ... |
commands_bitmap.go |
SetBit, GetBit, BitCount, BitPos, BitOp |
commands_hll.go |
PFAdd, PFCount, PFMerge |
commands_geo.go |
GeoAdd, GeoPos, GeoDist, GeoHash, GeoSearch |
commands_stream.go |
XAdd, XLen, XDel, XRange, XRead, XTrim |
commands_stream_extra.go |
XGroupCreate, XReadGroup, XAck, XPending, XClaim, XAutoClaim, XInfoStream, ... |
commands_misc.go |
BitField, GeoSearchStore, ClientList, ConfigGet, ObjectEncoding, ... |
types.go |
typed decoders for complex replies: ZRangeWithScores, GeoPosLocation, XRangeMessages, ... |
// Keys and TTL
n, _ := c.Exists(ctx, "key", "other").Val() // int64
ok, _ := c.Expire(ctx, "key", time.Minute).Val() // bool
ttl, _ := c.TTL(ctx, "key").Val() // int64 (seconds)
keys, _ := c.Keys(ctx, "user:*").Val() // []string
typ, _ := c.Type(ctx, "key").Val() // string
// Strings
c.Set(ctx, "key", "hello", 10*time.Second)
c.SetNX(ctx, "lock", "1", time.Minute) // Result[bool]
c.MSet(ctx, map[string]string{"a": "1", "b": "2"}) // Result[string]
s, _ := c.Get(ctx, "key").Val() // *string, Nil error if missing
n, _ = c.IncrBy(ctx, "counter", 5).Val() // int64
vals, _ := c.MGet(ctx, "a", "b").Val() // []*string
// Hashes
c.HSet(ctx, "user:1", "name", "ada") // int64
m, _ := c.HGetAll(ctx, "user:1").Val() // map[string]string
f, _ := c.HGet(ctx, "user:1", "name").Val() // *string
fs, _ := c.HMGet(ctx, "user:1", "name", "email").Val() // []*string
c.HIncrBy(ctx, "user:1", "visits", 1) // int64
// Lists
c.RPush(ctx, "queue", "job1", "job2") // int64
items, _ := c.LRange(ctx, "queue", 0, -1).Val() // []string
first, _ := c.LPop(ctx, "queue").Val() // *string
kv, _ := c.BLPop(ctx, 5, "queue").Val() // []string{key, value}; blocks up to 5s
// Sets
c.SAdd(ctx, "tags", "go", "redis") // int64
members, _ := c.SMembers(ctx, "tags").Val() // []string
ok, _ = c.SIsMember(ctx, "tags", "go").Val() // bool
inter, _ := c.SInter(ctx, "set1", "set2").Val() // []string
// Sorted sets
c.ZAdd(ctx, "scores", 95.5, "ada") // int64
rng, _ := c.ZRange(ctx, "scores", 0, -1).Val() // []string
score, _ := c.ZScore(ctx, "scores", "ada").Val() // *float64
zs, _ := c.ZRangeWithScores(ctx, "scores", 0, -1).Val() // []quickredis.Z
top, _ := c.ZPopMaxWithScore(ctx, "scores").Val() // quickredis.Z
// Bitmaps + bit fields
c.SetBit(ctx, "flags", 3, 1) // int64
bit, _ := c.GetBit(ctx, "flags", 3).Val() // int64
ones, _ := c.BitCount(ctx, "flags", -1, -1).Val() // int64 (whole string)
c.BitOp(ctx, "AND", "dest", "flags", "other") // int64
bf, _ := c.BitField(ctx, "flags", "GET", "u8", "0").Val() // []int64
// HyperLogLog
c.PFAdd(ctx, "visits", "u1", "u2", "u3") // int64
approx, _ := c.PFCount(ctx, "visits").Val() // int64
// Geo
c.GeoAdd(ctx, "cities", 13.361389, 38.115556, "Palermo")
c.GeoAdd(ctx, "cities", 15.087269, 37.502669, "Catania")
dist, _ := c.GeoDist(ctx, "cities", "Palermo", "Catania").Val() // *float64
locs, _ := c.GeoPosLocation(ctx, "cities", "Palermo").Val() // []*quickredis.GeoLocation
near, _ := c.GeoSearch(ctx, "cities",
"FROMLONLAT", "15", "37", "BYRADIUS", "200", "km").Val() // []string
// Streams
id, _ := c.XAdd(ctx, "events", "*", "type", "click").Val() // string
msgs, _ := c.XRangeMessages(ctx, "events", "-", "+").Val() // []quickredis.XMessage
c.XGroupCreateMkStream(ctx, "events", "workers", "0") // string
c.XReadGroup(ctx, "workers", "consumer1", 1, -1, "events", ">") // Result[any]; block<0 = no BLOCK
c.XAck(ctx, "events", "workers", id) // int64
// Admin
cfg, _ := c.ConfigGet(ctx, "maxmemory").Val() // map[string]string
clients, _ := c.ClientList(ctx).Val() // string
enc, _ := c.ObjectEncoding(ctx, "key").Val() // stringEverything else (ACL, module commands, and the long tail of niche commands)
goes through Exec[T] / Do — the runtime escape hatches. The JSON command
metadata in the Redis source tree carries no reply-type information, so the
result type of each helper is mapped by Redis semantics, not auto-generated.
The type parameter doubles as the protocol type checker:
Go type T |
RESP3 type | Prefix |
|---|---|---|
string / []byte |
bulk string | $ |
int64 (all ints) |
integer | : |
float64 (all floats) |
double | , |
bool |
boolean | # |
map[K]V |
map | % |
[]T |
array / set / push | * / ~ / > |
*T (pointer) |
null-able | _ |
any |
natural Go value | — |
Struct targets decode from a RESP3 map (HGETALL-style) using redis:"field"
tags, or from a bulk string via JSON (the default codec):
type User struct {
Name string `redis:"name"`
Email string `redis:"email"`
Age int64 `redis:"age"`
}
// From a hash: HGETALL's reply fills the struct via the redis tags.
u, _ := quickredis.Exec[User](c, ctx, "HGETALL", "user:1").Val()
// From a string: the bulk string is JSON-decoded by the default codec.
data, _ := json.Marshal(User{Name: "ada", Email: "ada@example.com", Age: 36})
c.Set(ctx, "user:1", string(data), 0)
u2, _ := quickredis.Exec[User](c, ctx, "GET", "user:1").Val()Beyond the typed helpers, the client now has the infrastructure that separates a demo from a dependable library:
c, _ := quickredis.NewClient(ctx, quickredis.Options{
Addr: "localhost:6379",
PoolSize: 8, // concurrent connections
MaxRetries: 3, // retries on broken connections
MinRetryBackoff: 8 * time.Millisecond,
MaxRetryBackoff: 512 * time.Millisecond,
})Connections are pooled and reused; broken connections are discarded and
retried with exponential backoff. NewClient fails fast with a PING.
if quickredis.IsNil(err) { /* missing key (GET on absent key) */ }
if quickredis.IsRedisError(err) { /* server error reply */ }
if quickredis.IsNetworkError(err) { /* connection failure */ }
if quickredis.IsTimeout(err) { /* network timeout */ }
if errors.Is(err, quickredis.ErrClosed) { /* client closed */ }
if errors.Is(err, quickredis.ErrTxAborted) { /* WATCH conflict */ }Null replies are errors. A command whose own reply is a RESP null
("key does not exist") returns quickredis.Nil as the error, matching
rueidis's nil-as-error model. Use quickredis.IsNil(err) to detect it.
There is one deliberate exception: null elements inside an array reply
stay as nil values rather than errors. For example MGET k1 k2 where k2 is
absent returns []*string{ptr, nil} with a nil error — one missing key does
not fail the whole command.
Server error replies carry the message; all client-side failures implement
error and can be wrapped with %w as usual.
Complex replies decode into dedicated types instead of []string / any:
zs, _ := c.ZRangeWithScores(ctx, "z", 0, -1).Val() // []quickredis.Z{Score, Member}
zs2, _ := c.ZRangeByScoreWithScores(ctx, "z", "-inf", "+inf").Val()
one, _ := c.ZPopMinWithScore(ctx, "z").Val() // quickredis.Z
msgs, _ := c.XRangeMessages(ctx, "s", "-", "+").Val() // []quickredis.XMessage{ID, Values map[string]string}
rev, _ := c.XRevRangeMessages(ctx, "s", "+", "-").Val()
locs, _ := c.GeoPosLocation(ctx, "g", "a", "b").Val() // []*quickredis.GeoLocation{Name, Longitude, Latitude}Stream consumer groups and admin commands are also covered: XGroupCreate,
XReadGroup, XAck, XPending, XClaim, XAutoClaim, XInfoStream,
BitField, ConfigGet, ClientSetName, ObjectEncoding, and more (162
command helpers total).
c, _ := quickredis.NewClient(ctx, quickredis.Options{
Addr: "localhost:6379",
IdleTimeout: 5 * time.Minute, // close idle conns after 5m
ConnMaxLifetime: 30 * time.Minute, // close conns after 30m
IdleCheckFrequency: time.Minute, // reap expired conns every 1m
})A RESP frame's length headers are peer-controlled, so the parser bounds what a single reply may make it allocate or recurse into. The defaults are:
| Field | Default | Caps |
|---|---|---|
MaxBulkLen |
512 MiB (512 << 20) |
a single bulk string, in bytes |
MaxAggregateLen |
1 << 20 (1,048,576) elements |
the element count of an array, set, push or map |
MaxReadDepth |
128 | RESP nesting |
MaxAggregateLen is a memory budget rather than a plain element count: for
arrays, sets, pushes and maps the parser sizes the allocation from the header
before any element arrives, and Value / KeyValue are 120 / 240 bytes on
64-bit platforms, so 1 << 20 bounds the worst case at 120 MiB / 240 MiB — the
same order of magnitude as the 512 MiB a single bulk string is allowed to
reach.
Without those caps a server could answer *1000000000\r\n and have the parser
allocate that outright, or nest *1\r\n forever and exhaust the goroutine
stack — and a Go stack overflow is a fatal error that recover cannot catch, so
it takes the whole process down. Exceeding a limit is instead an ordinary error:
the reply is treated as malformed, the connection is discarded, and the caller
gets a failure rather than a crash.
Options, ClusterOptions, FailoverOptions and UniversalOptions each carry
a Limits Limits field. The zero value means the defaults, and every zero
field falls back individually, so derive a modified copy from DefaultLimits()
with the With* methods:
rdb, err := quickredis.NewClient(ctx, quickredis.Options{
Addr: "localhost:6379",
// Take the defaults and adjust just the cap you care about.
Limits: quickredis.DefaultLimits().WithMaxBulkLen(64 << 20), // 512 MiB -> 64 MiB
})The same works in the other direction — WithMaxBulkLen(1 << 30) lifts the bulk
cap to 1 GiB for a client that genuinely needs replies that large.
Three caveats are worth knowing before you tune these:
- What a header can cost depends on the type. A bulk string (
$,=) is read in 64 KiB steps and its buffer grows only as bytes actually arrive, so a peer that declares a huge bulk and then sends nothing costs one chunk. Collections — arrays, sets, pushes, maps — are still sized from the header before any element arrives, bounded byMaxAggregateLen; that is the pre-allocation budget above. - A cap applies from the first byte, so it also governs the replies the
client itself reads while constructing:
CLUSTER SLOTS,SENTINEL get-master-addr-by-name, the RESP3HELLOmap. An over-tight cap fails the constructor, not the first command — loud and early, but surprising if you do not expect it. - There is deliberately no "unlimited" value. A zero field falls back to its
default, so omitting a field cannot silently disable that protection. You can
still lift a cap deliberately —
WithMaxBulkLen(math.MaxInt)is effectively off — but there is no way to switch one off by omission.
vals, err := c.Tx(ctx, func(tx *quickredis.Tx) error {
if err := tx.Watch(ctx, "counter"); err != nil {
return err
}
tx.Command("INCR", "counter")
tx.Command("SET", "last", "1")
return nil
})
if errors.Is(err, quickredis.ErrTxAborted) { /* retry */ }
n, _ := quickredis.Decode[int64](vals[0]).Val() // raw replies, decode per indexscript := quickredis.NewScript("return redis.call('INCR', KEYS[1])")
n, _ := quickredis.RunScript[int64](ctx, c, script, []string{"counter"}).Val()
// Natural-Go-value variant:
a, _ := script.Run(ctx, c, []string{"counter"}, "arg1").Val() // any
// Raw EVAL without SHA caching:
r, _ := c.Eval(ctx, "return ARGV[1]", nil, "hello").Val() // anyRunScript/Run first try EVALSHA and transparently fall back to EVAL when
the server answers NOSCRIPT (script not cached, e.g. after a restart).
sub, _ := c.Subscribe(ctx, "news", "weather")
for msg := range sub.Channel() {
fmt.Println(msg.Kind, msg.Channel, msg.Payload)
}
// Pattern subscription (PSUBSCRIBE):
psub, _ := c.PSubscribe(ctx, "news.*")Subscribe and PSubscribe use dedicated connections, so they never block
the command pool.
c, _ := quickredis.NewClient(ctx, quickredis.Options{Addr: "localhost:6379", Protocol: 3})
// EnableTracking opens a dedicated connection and turns on CLIENT TRACKING.
// The returned cache is automatically invalidated by server pushes.
cache, err := c.EnableTracking(ctx, quickredis.TrackingOptions{
Mode: "BCAST", // broadcast invalidations (no read tracking)
Prefix: []string{"user:"}, // only keys with this prefix
NoLoop: true, // ignore this client's own writes
})
v, _ := c.DoCache(ctx, cache, time.Minute, "GET", "user:1").Val() // tracked + invalidated
// With Mode: "OPTIN" (instead of BCAST above), opt individual reads in:
c.ClientCaching(ctx, true)
v2, _ := c.DoCache(ctx, cache, time.Minute, "GET", "user:2").Val()TrackingOptions supports OPTIN/OPTOUT/BCAST modes, Prefix filtering
for broadcast, and NoLoop. ClientTracking and ClientCaching are exposed
for manual control. Reads go through a dedicated tracking connection so
invalidation pushes arrive there; DoCache transparently routes to it when
the cache came from EnableTracking.
Both RESP2 and RESP3 are supported. In RESP3, invalidations arrive as push
frames on the same connection. In RESP2, EnableTracking opens a second
connection and uses CLIENT TRACKING ON REDIRECT <id> so invalidations arrive
on the redirect connection while commands run on the command connection.
c.AddHook(quickredis.HookFunc(func(ctx context.Context, cmd []string, next func(context.Context) error) error {
start := time.Now()
err := next(ctx)
log.Printf("%v took %v", cmd, time.Since(start))
return err
}))
s := c.Metrics()
fmt.Println(s.TotalCommands, s.FailedCommands, s.AvgLatency())for it := c.ScanIterator(ctx, "user:*", 100); it.Next(ctx); {
key := it.Val()
...
}
if err := it.Err(); err != nil { ... }
// HScanIterator yields flattened field/value pairs (even = field, odd = value):
it := c.HScanIterator(ctx, "user:1", "", 100)
for {
if !it.Next(ctx) { break }
field := it.Val()
if !it.Next(ctx) { break }
fmt.Println(field, "=", it.Val())
}
if err := it.Err(); err != nil { ... }ScanIterator, HScanIterator, SScanIterator, ZScanIterator follow the
cursor transparently — no manual cursor loop. Cursor() exposes the cursor
after the last fetched page (0 when complete).
c, _ := quickredis.NewClient(ctx, quickredis.Options{
Addr: "localhost:6379",
AutoPipeline: true,
AutoFlushInterval: time.Millisecond,
})Under concurrency, commands are coalesced into single round-trips on a dedicated connection — a large throughput win, at the cost of a small added latency. Blocking commands (BLPOP etc.) and transactions are never pipelined.
TLS is supported on all three client types. Unix sockets are supported on
the standalone client via Network: "unix".
// Standalone (TLS or unix socket)
c, _ := quickredis.NewClient(ctx, quickredis.Options{
Addr: "localhost:6379",
TLSConfig: &tls.Config{...}, // TLS, or Network: "unix" + a socket path
})
// Cluster over TLS
cluster, _ := quickredis.NewCluster(ctx, quickredis.ClusterOptions{
Addrs: []string{"127.0.0.1:7000"},
TLSConfig: &tls.Config{...},
})
// Sentinel over TLS
fc, _ := quickredis.NewFailoverClient(ctx, quickredis.FailoverOptions{
MasterName: "mymaster",
SentinelAddrs: []string{"10.0.0.1:26379"},
TLSConfig: &tls.Config{...},
})cluster, _ := quickredis.NewCluster(ctx, quickredis.ClusterOptions{
Addrs: []string{"127.0.0.1:7000", "127.0.0.1:7001", "127.0.0.1:7002"},
PoolSize: 8,
})
v, _ := cluster.Get(ctx, "key").Val() // *string
n, _ := quickredis.Exec[int64](cluster, ctx, "INCRBY", "counter", "5").Val()With ReadOnly: true, read commands are routed to replicas (read replicas):
cluster, _ := quickredis.NewCluster(ctx, quickredis.ClusterOptions{
Addrs: []string{"127.0.0.1:7000"},
ReadOnly: true, // route READONLY commands to replicas
})Cluster routes each command to the correct node via CRC16 slot hashing (with
hash-tag support {...}), caches the slot table from CLUSTER SLOTS, and
follows MOVED/ASK redirections transparently, refreshing the topology on
MOVED.
Key extraction is exact, not heuristic: keyspecs_gen.go is generated from
Redis's own commands/*.json metadata (go run ./cmd/genkeys /path/to/redis/src/commands), so irregular commands (EVAL, XGROUP CREATE,
XREAD ... STREAMS, BLPOP, MGET) route to the right node. Regenerate it
when you upgrade Redis versions.
fc, _ := quickredis.NewFailoverClient(ctx, quickredis.FailoverOptions{
MasterName: "mymaster",
SentinelAddrs: []string{"10.0.0.1:26379", "10.0.0.2:26379", "10.0.0.3:26379"},
})
v, _ := fc.Get(ctx, "key").Val() // *string
n, _ := quickredis.Exec[int64](fc, ctx, "INCRBY", "counter", "5").Val()The FailoverClient discovers the current master via SENTINEL get-master-addr-by-name, and on a failover (a READONLY/LOADING/MASTERDOWN
reply or a connection error) it re-resolves the master through the sentinels and
retries transparently.
resp.go — RESP wire parser/encoder (RESP2 + RESP3, all types)
value.go — Value: parsed reply + typed accessors
decode.go — decode[T]: reflection-driven Value → Go value
cmdable.go — cmdable: shared command surface (embedded by all clients)
universal.go — UniversalClient interface (one codebase, three modes)
client.go — Client, pool integration, retries, Exec[T]
pool.go — bounded connection pool with discard-on-error
errors.go — RedisError, Nil, error classification
tx.go — MULTI/EXEC/WATCH transactions
script.go — Lua Script, EVALSHA→EVAL fallback
pipeline.go — Pipeline + Decode[T]
pubsub.go — Subscribe/PSubscribe with message pump
cache.go — client-side caching + invalidation
hook.go — Hook interface (middleware)
metrics.go — per-client command metrics
commands*.go — named helpers returning Result[T]
types.go — typed decoders for complex replies (Z, XMessage, GeoLocation)
push.go — RESP3 push handling, invalidation binding
codec.go — Codec interface (JSON default)
scan.go — SCAN-family with typed ScanResult
iterator.go — ScanIterator (automatic cursor iteration)
autopipeline.go — auto-pipelining (batched round-trips)
tracking.go — server-assisted caching: CLIENT TRACKING + invalidation pump
builder.go — Builder helper for raw command argument slices
cluster.go — Redis Cluster: slot routing, MOVED/ASK, read replicas
sentinel.go — Sentinel failover client (master discovery + re-resolve)
keyspecs_gen.go — generated key-position table (from commands/*.json)
cmd/genkeys — generator for keyspecs_gen.go
- Cluster + Sentinel both supported — slot routing, hash tags, MOVED/ASK redirection, a generated key-position table, and Sentinel failover (master discovery + re-resolve) are all implemented.
- Named helpers are hand-written; the 200+ remaining Redis commands go
through
Exec[T]/Do. A production version wouldgo generatethe helper methods fromCOMMAND DOCS. Exec[T]cannot validate command→type correctness at compile time; it decodes at runtime and returns an error on mismatch.- Request format is always RESP2 (arrays of bulk strings), even in RESP3 mode — matching how Redis itself treats RESP3 as a response-only upgrade.
- Parser resource limits are configurable —
Limitsbounds how much a single reply can make the parser allocate (bulk length, aggregate elements) and how deeply it can recurse. The defaults are deliberately not switchable-off; see Parser limits above to tune them. - No low-level optimizations yet — no buffer reuse, no zero-copy reads. Correctness and API shape first; optimization later.
go test ./...