Skip to content

About

最快的golang redis client库(和D老师合作)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

quickredis

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):

  1. One Result[T] instead of twenty *Cmd types — the result type is a type parameter, selected and type-checked at compile time.
  2. Compile-time reply typing — Get returns *string, HGetAll returns map[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 idea in one line

The type parameter = the reply type. A single Exec[T] runs any command; helper methods fix T at 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]string

Universal interface

Client (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()
}

Universal constructor

NewUniversalClient takes one UniversalOptions struct (the union of Options, ClusterOptions, and FailoverOptions) and returns the right client as a UniversalClient.

Automatic mode inference

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 mode

Explicit mode

Set 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.

Quick start

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)
}

API surface

Typed helpers (Result[T])

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

Generic escape hatch

// 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)

Pipeline

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 (借用 API, zero-allocation read)

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")

Owned vs borrowed semantics

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 here

fn must not call blocking commands on the same client (the connection is checked out until fn returns, so a nested borrow would deadlock).

RESP3 push + client-side caching

// 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)
})

Command coverage (162 named helpers)

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, ...

Examples by data type

// 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()      // string

Everything 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.

Type → RESP3 wire-type mapping

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()

Production features

Beyond the typed helpers, the client now has the infrastructure that separates a demo from a dependable library:

Connection pool + reconnection

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.

Error model

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.

Typed data structures

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).

Connection pool tuning

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
})

Parser limits

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 by MaxAggregateLen; 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 RESP3 HELLO map. 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.

Transactions (MULTI/EXEC/WATCH)

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 index

Lua scripts (EVALSHA → EVAL fallback)

script := 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()     // any

RunScript/Run first try EVALSHA and transparently fall back to EVAL when the server answers NOSCRIPT (script not cached, e.g. after a restart).

Pub/Sub event loop

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.

Client-side caching (server-assisted, RESP2 + RESP3)

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.

Hooks + metrics

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())

ScanIterator (automatic iteration)

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).

Auto-pipelining

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 / Unix socket

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{...},
})

Redis Cluster

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.

Sentinel (high availability)

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.

Architecture

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

Design notes / limits

  • 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 would go generate the helper methods from COMMAND 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 — Limits bounds 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.

Run the tests

go test ./...

About

最快的golang redis client库(和D老师合作)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages