1
0
mirror of https://github.com/redis/go-redis.git synced 2026-10-24 07:59:20 +03:00
Nedyalko Dyakov 349bcf47a9 feat(autopipeline): run Pipeline on the full-duplex connection (#4021)
* feat(autopipeline): add fdQueue.pushBatch for FD batches

FDPipelined submits a pipeline as one contiguous run; pushBatch enqueues
it under a single lock, all or nothing.

* feat(autopipeline): run Pipeline on the full-duplex connection

ap.Pipeline() delegated to the client's pipeliner, so a pipeline built
from a full-duplex autopipeliner quietly left the held connection, took a
pooled one per Exec, and lost the cross-caller coalescing the engine
exists for. It now submits the batch to the engine as one contiguous run.

FDPipelined does the submission: the batch is admitted all-or-nothing, so
its commands occupy consecutive in-flight slots and their replies come
back in submit order, which keeps a pipeline's internal ordering exactly
as a dedicated connection would. Batches that cannot ride the wire -- a
blocking command, a per-command read timeout, anything the divert
predicate claims, or a batch larger than the queue -- fall back to the
ordinary pipeline path.

Measured on a 14-core client against a local server, 10 commands per
Exec, 3 passes:

  callers   pooled       on the engine   active sockets
      256   586,557        871,469       149 -> 1
     1024   516,042        785,191       150 -> 1

So +48.6% and +52.2%, on one socket instead of 149.

Above ~2048 callers a single engine saturates its submit queue and
admission serializes; spreading pipelines across several auto-pipeliners
avoids that (1.6M ops/s across eight), which is an argument for a
supported multi-connection full-duplex mode rather than for this commit.

TxPipeline is deliberately untouched: MULTI/EXEC needs connection
affinity for the whole transaction, which the engine does not offer.

* fix(autopipeline): keep FD pipeline Exec semantics

Pipeline() on a full-duplex autopipeliner runs the batch on the held
connection. Three things differed from an ordinary pipeline:

- A retryable reply (LOADING and the like) was retried per command off
  the reader, so later commands of the pipeline could run before it.
  Pipelined commands now settle retryable replies inline, and a
  retryable reply on the first command re-runs the whole batch the
  ordinary way, as processPipeline does.
- With ContextTimeoutEnabled the caller's deadline did not bound the
  wait. Such clients keep the pooled pipeline path.
- Exec dropped redis.Nil. It now returns the first error, Nil included.

FDPipelined is documented as standalone only.

* fix(autopipeline): close FD pipeline Exec gaps

Pipeline() on a full-duplex autopipeliner still differed from an
ordinary pipeline:

- Hooks, a Limiter or an OTel recorder: an ordinary pipeline runs the
  ProcessPipelineHook chain, takes one Limiter decision per batch and
  records one pipeline duration. The FD path did none of that, so such
  clients now keep the pooled pipeline. Running the FD path under these
  is a follow-up.
- The whole-batch retry on a retryable first reply re-ran pipelines
  holding a NoRetry command. It now stops on cmdsContainNoRetry, as
  generalProcessPipeline does.
- That retry also restarted the full MaxRetries budget, so MaxRetries:1
  allowed three executions. The FD attempt now counts as the first: the
  re-run gets MaxRetries-1 retries, after one backoff.
- A canceled ctx was only checked while waiting for queue room, so a
  pre-canceled Exec still ran. submitBatch now refuses a done ctx before
  admission.

* fix(autopipeline): count FD replays in the retry budget

The whole-batch retry assumed the FD engine issued the pipeline once.
After a connection-error replay it had issued it twice, so a lost first
write, LOADING on the replay and the pooled re-run made three executions
under MaxRetries:1, where an ordinary pipeline stops at two.

The reader now stamps each pipelined command's attempt count on its
batch before completing it, fdPipelined reports the highest, and the
re-run gets MaxRetries minus that, or none when it is spent.

* fix(autopipeline): keep FD pipelines off cluster children

A cluster node's FD child is the node client's cached async
autopipeliner, so ForEachMaster hands it to users, and its engine is
redirect-aware: it follows MOVED/ASK by re-running one command off the
pipe. A Pipeline() on it would let the rest of the batch run ahead of a
redirected command. FDPipelined now refuses redirect-aware engines, so
such a pipeline keeps the node client's ordinary pipeline, as before.

* fix(autopipeline): keep NoRetry batches off the FD pipe

An ordinary pipeline holding a NoRetry command retries nothing after a
connection error. The FD tail recovery replays per command, up to the
first sent NoRetry one, so a pipeline like SET k; RawWriteTo(GET x)
that lost its connection ran the SET twice. FDPipelined now refuses a
batch with a NoRetry command, and Pipeline() runs it the ordinary way.

The pipeline retry budget read fdAttempts, which only the reader
stamped. A command that was replayed and then failed carried no count,
so a pooled re-run could exceed MaxRetries. The batch is stamped at
submit and on every replay.

* fix(autopipeline): run pipeline hooks and metrics on FD

Pipeline() and FDPipelined kept away from the full-duplex path whenever
hooks, a Limiter or an OTel recorder were set, or bypassed them. They
now run on it:

- The batch runs inside the client's ProcessPipelineHook chain, with
  fdPipelineExec as its terminal where processPipeline sits in an
  ordinary pipeline. Fallback and the whole-batch retry happen inside
  the chain, so hooks run once. Batched commands get no per-command
  ProcessHook, as in an ordinary pipeline.
- An FD batch records one pipeline duration and, on failure, one
  pipeline-level error; the reader skips its per-command metrics for
  batched commands.
- A Limiter stays asked per write chunk, which can partly admit a batch
  longer than MaxBatchSize. Documented on FDPipelined.

Only ContextTimeoutEnabled still keeps the pooled pipeline.

* fix(autopipeline): no FD pipeline re-run after a replay

The whole-batch retry re-ran a pipeline whose first command got a
retryable reply even when the engine had already replayed its unread
tail after a connection error, so a command in that tail could run
three times where an ordinary pipeline runs it twice. It now re-runs
only a batch the engine issued once. The Close-time flush stamps the
batch as issued again, so a flushed batch is not re-run either.

The re-run continues the FD attempt's measurement (start and attempts)
through processPipelineRetriesAfter, so one pipeline metric covers the
whole operation, and a ctx that ends in the backoff still records it.

* fix(autopipeline): keep flushed FD pipelines whole

When Close wins against an admitted FD pipeline, shutdownFlush sends
its commands through flushReqs. That split them at MaxBatchSize and at
attempt boundaries, so a retryable first reply retried only the first
chunk. Every batch of one FD pipeline now points at its first batch
(fdGroup), and the flush keeps that run in one chunk, so the pooled
retry re-runs the pipeline as a whole.

The pooled flush already records the pipeline metric. fdPipelined now
reports that the flush ran the batch, and FDPipelined/fdPipelineExec
skip their own metric in that case. failQueue skips the per-command
error for pipelined commands, as failReqs does, since fdPipelineExec
reports the batch once.

* fix(autopipeline): FD batch errors and queue wait

An FD pipeline now returns a transport or protocol failure before any
Redis error, as processPipeline does, so a caller that checks only
Exec's error sees that later commands have no known result.

The whole-batch re-run for a retryable first reply is also ruled out
when any command in the batch failed that way. The engine does not
replay such a command, since it may already have run, so the re-run
could execute it twice; an ordinary pipeline does not retry there.

A batch waiting for more room than one writer take freed put the room
signal back, took it straight back, failed to fit, and spun holding
submitMu.RLock. It now passes a wake it cannot use on once per take,
which keeps the wake chain for smaller waiters without spinning.

* fix(autopipeline): FD pipeline metrics and backoff

The pooled re-run after an FD attempt restarted its retry backoff at the
first step. generalProcessPipelineFrom now continues it from the prior
attempts, so a pipeline sleeps the same steps an ordinary one does.

A pipeline refused before admission (ctx done, or closed) recorded no
pipeline metric; an ordinary pipeline records one when withPipelineConn
fails. It now records one with a single attempt.

The Close flush marked every batch flushed before running any, so a
batch it never ran (a dead endpoint fails the rest) recorded nothing.
It now marks a batch where its chunk runs. And the FD metric is skipped
only when the flush ran the whole pipeline: when it ran only an unread
tail, the FD metric still covers the whole operation.

* fix(autopipeline): skip FD duration with no start

The start time is read only when a pipeline duration callback exists.
A recorder installed while an FD pipeline ran found a zero start, and
fdPipelineMetrics reported time.Since(time.Time{}), a duration of
centuries. It now records no duration for that operation, as an
ordinary pipeline does, which reads the callback once at the start.

Also pin that CSC state commands (SELECT, AUTH, HELLO with a protocol,
RESET, CLIENT TRACKING) never ride the FD pipe: they leave it as
runsOutsidePipeline commands, so Pipeline() falls back to the ordinary
pipeline and its CSC guard, and FDPipelined refuses the batch.

* fix(autopipeline): Close flush budget and retry timing

An FD pipeline flushed on Close can carry commands with different
attempt counts, and runs as one pipeline. It took the retry budget of
its first command, so a later command with more attempts could run past
its MaxRetries+1 executions. It now gets the smallest budget of its
commands, and fails with errFDRetryBudgetExhausted when any has none
left. A command with more budget may get fewer retries than it alone
would, as in an ordinary pipeline, whose budget covers the whole batch.

The pooled re-run of an FD operation that began with no duration
callback (zero start) started its clock at the re-run when a recorder
was installed in between, so the duration covered only the tail while
the count included the FD attempt. generalProcessPipelineFrom now
records no duration for a continued operation with no start.

* fix(autopipeline): keep flush budget groups separate

flushCarryBudgeted grew a group while the next command had the same
attempt count or belonged to the same FD pipeline, so a command in front
of a pipeline joined that pipeline's budget group. The group's smallest
budget then applied to it, and a pipeline command with no budget left
failed the unrelated command too. A group is now one FD pipeline, or a
run of other commands with the same attempt count, never both.

* fix(autopipeline): stamp flush issues where they run

shutdownFlush stamped every queued pipelined batch with one more issue
before either flush ran. When the carry flush hit a dead endpoint the
fresh queue was only failed, never issued, yet its callers saw an issue
count of 2 and reported a retry that never happened. flushReqs now
stamps the issue, with fdFlushed, where it runs a chunk. A batch that
never runs keeps its count; the transport error it is failed with
already rules out a re-run in fdPipelineExec.

* fix(autopipeline): flush a split FD pipeline whole

When the writer took only a prefix of an FD pipeline before the session
failed, Close found the prefix in the carry and the tail still queued,
and flushed them as two pooled pipelines. A retryable first reply then
retried only the prefix, and the tail ran under a full budget of its
own. shutdownFlush now moves the queued tail into the carry, so the
pipeline runs as one at the smallest remaining budget of its commands.

* docs(autopipeline): note lease retries in FD metrics

A full-duplex pipeline's attempts count the times the batch was issued.
Connection-lease retries belong to the engine: a batch queued during
them waits and is issued once, so they are not counted, where an
ordinary pipeline counts each failed connection acquisition. Say so on
fdPipelineMetrics.

* fix(autopipeline): reserve queue room for FD batches (#4057)

* fix(autopipeline): reserve queue room for FD batches

* fix(autopipeline): FD batch head-of-line starvation

* fix(autopipeline): remove unused function

* fix(autopipeline): fail a split FD pipeline whole

Live connection-error recovery partitions the unacked tail per command
(budget spent, after a sent NoRetry, not replayable) and fails part of
it. When the writer had taken only a prefix of an FD pipeline, the rest
of that pipeline, kept for replay or still queued, ran on the next
connection, and Exec reported a transport error for a pipeline whose
later commands executed. A pipeline on a dedicated connection sends
nothing more once it has failed. failPipelines now fails the whole
pipeline: its members kept for replay and its tail at the queue head.

---------

Co-authored-by: Vladislav Kotsev <vladygk8@gmail.com>
2026-10-02 16:15:05 +03:00
2026-04-28 14:16:39 +03:00
2026-07-22 17:02:29 +03:00
2014-10-07 09:27:55 +03:00
2023-01-23 08:48:54 +02:00
2023-01-27 15:01:50 +00:00
2026-04-28 14:16:39 +03:00
2024-01-01 22:19:22 +02:00

Redis client for Go

build workflow PkgGoDev Documentation Go Report Card codecov

Discord Twitch YouTube Twitter Stack Exchange questions

go-redis is the official Redis client library for the Go programming language. It offers a straightforward interface for interacting with Redis servers.

Supported versions

In go-redis we are aiming to support the last three releases of Redis. Currently, this means we do support:

Although the go.mod states it requires at minimum go 1.26, our CI is configured to run the tests against all supported versions of Redis and multiple versions of Go (1.26, oldstable, and stable). We observe that some modules related test may not pass with Redis Stack 7.2 and some commands are changed with Redis CE 8.0. Although it is not officially supported, go-redis/v9 should be able to work with any Redis 7.0+. Please do refer to the documentation and the tests if you experience any issues.

Array data type (Redis 8.8+)

Starting with Redis 8.8, go-redis exposes the new array data type via the AR* command family (ARSET, ARGET, ARGETRANGE, ARMSET, ARMGET, ARINSERT, ARDEL, ARDELRANGE, ARLEN, ARCOUNT, ARNEXT, ARSEEK, ARSCAN, ARGREP, ARRING, ARLASTITEMS, ARINFO/ARINFOFULL, and the AROP* reducers). See array_commands.go for the full surface. The API is experimental and may change in a future release.

How do I Redis?

Learn for free at Redis University

Build faster with the Redis Launchpad

Try the Redis Cloud

Dive in developer tutorials

Join the Redis community

Work at Redis

Resources

old documentation

Ecosystem

Features

Installation

go-redis supports 2 last Go versions and requires a Go version with modules support. So make sure to initialize a Go module:

go mod init github.com/my/repo

Then install go-redis/v9:

go get github.com/redis/go-redis/v9

Quickstart

import (
    "context"
    "fmt"

    "github.com/redis/go-redis/v9"
)

var ctx = context.Background()

func ExampleClient() {
    rdb := redis.NewClient(&redis.Options{
        Addr:     "localhost:6379",
        Password: "", // no password set
        DB:       0,  // use default DB
    })
    defer rdb.Close()

    err := rdb.Set(ctx, "key", "value", 0).Err()
    if err != nil {
        panic(err)
    }

    val, err := rdb.Get(ctx, "key").Result()
    if err != nil {
        panic(err)
    }
    fmt.Println("key", val)

    val2, err := rdb.Get(ctx, "key2").Result()
    if err == redis.Nil {
        fmt.Println("key2 does not exist")
    } else if err != nil {
        panic(err)
    } else {
        fmt.Println("key2", val2)
    }
    // Output: key value
    // key2 does not exist
}

Dial retries and backoff

Connection establishment can be retried by the connection pool when dialing fails.

  • DialerRetries: maximum number of dial attempts (default: 5).
  • DialerRetryTimeout: default delay between attempts when no custom backoff is provided (default: 100ms).
  • DialerRetryBackoff: optional function hook to control the delay between attempts.

Example:

rdb := redis.NewClient(&redis.Options{
	Addr: "localhost:6379",

	DialerRetries:      5,
	DialerRetryTimeout: 100 * time.Millisecond, // used when DialerRetryBackoff is nil

	// Optional: exponential backoff with jitter and a cap.
	DialerRetryBackoff: redis.DialRetryBackoffExponential(100*time.Millisecond, 2*time.Second),
})
defer rdb.Close()

Authentication

The Redis client supports multiple ways to provide authentication credentials, with a clear priority order. Here are the available options:

1. Streaming Credentials Provider (Highest Priority)

The streaming credentials provider allows for dynamic credential updates during the connection lifetime. This is particularly useful for managed identity services and token-based authentication.

type StreamingCredentialsProvider interface {
    Subscribe(listener CredentialsListener) (Credentials, UnsubscribeFunc, error)
}

type CredentialsListener interface {
    OnNext(credentials Credentials)  // Called when credentials are updated
    OnError(err error)              // Called when an error occurs
}

type Credentials interface {
    BasicAuth() (username string, password string)
    RawCredentials() string
}

Example usage:

rdb := redis.NewClient(&redis.Options{
    Addr: "localhost:6379",
    StreamingCredentialsProvider: &MyCredentialsProvider{},
})

Note: The streaming credentials provider can be used with go-redis-entraid to enable Entra ID (formerly Azure AD) authentication. This allows for seamless integration with Azure's managed identity services and token-based authentication.

Example with Entra ID:

import (
    "github.com/redis/go-redis/v9"
    "github.com/redis/go-redis-entraid"
)

// Create an Entra ID credentials provider
provider := entraid.NewDefaultAzureIdentityProvider()

// Configure Redis client with Entra ID authentication
rdb := redis.NewClient(&redis.Options{
    Addr: "your-redis-server.redis.cache.windows.net:6380",
    StreamingCredentialsProvider: provider,
    TLSConfig: &tls.Config{
        MinVersion: tls.VersionTLS12,
    },
})

2. Context-based Credentials Provider

The context-based provider allows credentials to be determined at the time of each operation, using the context.

rdb := redis.NewClient(&redis.Options{
    Addr: "localhost:6379",
    CredentialsProviderContext: func(ctx context.Context) (string, string, error) {
        // Return username, password, and any error
        return "user", "pass", nil
    },
})

3. Regular Credentials Provider

A simple function-based provider that returns static credentials.

rdb := redis.NewClient(&redis.Options{
    Addr: "localhost:6379",
    CredentialsProvider: func() (string, string) {
        // Return username and password
        return "user", "pass"
    },
})

4. Username/Password Fields (Lowest Priority)

The most basic way to provide credentials is through the Username and Password fields in the options.

rdb := redis.NewClient(&redis.Options{
    Addr:     "localhost:6379",
    Username: "user",
    Password: "pass",
})

Priority Order

The client will use credentials in the following priority order:

  1. Streaming Credentials Provider (if set)
  2. Context-based Credentials Provider (if set)
  3. Regular Credentials Provider (if set)
  4. Username/Password fields (if set)

If none of these are set, the client will attempt to connect without authentication.

Protocol Version

The client supports both RESP2 and RESP3 protocols. You can specify the protocol version in the options:

rdb := redis.NewClient(&redis.Options{
    Addr:     "localhost:6379",
    Password: "", // no password set
    DB:       0,  // use default DB
    Protocol: 3,  // specify 2 for RESP 2 or 3 for RESP 3
})

Client-side caching

go-redis supports server-assisted client-side caching for standalone clients. Eligible read replies are stored in the application's memory, so repeated reads can avoid a Redis round trip. Redis tracks which keys each connection has read and sends RESP3 invalidation notifications when those keys change. go-redis uses those notifications to evict affected entries automatically.

Experimental: The client-side caching API may change in a minor release.

Enable the built-in bounded cache with ClientSideCacheConfig:

rdb := redis.NewClient(&redis.Options{
    Addr:     "localhost:6379",
    Protocol: 3,
    DB:       0,
    ClientSideCacheConfig: &redis.ClientSideCacheConfig{
        MaxEntries: 10_000,
    },
})
defer rdb.Close()

Client-side caching currently requires RESP3, a standalone client, and database 0. Fixed Username and Password values are supported. It is disabled when a dynamic credential provider is configured, because cached data must never be reused after the client's ACL identity changes. Only deterministic read commands supported by the cache are stored; writes and streaming responses bypass it.

While client-side caching is enabled, go-redis rejects SELECT, AUTH, HELLO with arguments, RESET, CLIENT TRACKING, and raw SUBSCRIBE, PSUBSCRIBE, or SSUBSCRIBE commands because they would change connection state that the cache relies on. A guarded command also fails its whole pipeline. The typed Subscribe, PSubscribe, and SSubscribe APIs remain supported because they use dedicated connections.

Invalidations are processed asynchronously. DrainInterval controls how often idle connections are checked for them, while MaxStaleness can provide an optional upper bound on an entry's lifetime.

Until a key's invalidation arrives, a cache hit can return a value older than the one on the server. Reads are also not monotonic for one caller. A read that waits twice for another caller's fetch of the same key reads the server directly instead. The other fetch ran on the server earlier, can store its older value afterwards, and a later hit then returns that older value until the invalidation arrives. See the client-side caching example for a working demonstration.

Connecting via a redis url

go-redis also supports connecting via the redis uri specification. The example below demonstrates how the connection can easily be configured using a string, adhering to this specification.

import (
    "github.com/redis/go-redis/v9"
)

func ExampleClient() *redis.Client {
    url := "redis://user:password@localhost:6379/0?protocol=3"
    opts, err := redis.ParseURL(url)
    if err != nil {
        panic(err)
    }

    return redis.NewClient(opts)
}

Instrument with OpenTelemetry

import (
    "github.com/redis/go-redis/v9"
    "github.com/redis/go-redis/extra/redisotel/v9"
    "errors"
)

func main() {
    ...
    rdb := redis.NewClient(&redis.Options{...})

    if err := errors.Join(redisotel.InstrumentTracing(rdb), redisotel.InstrumentMetrics(rdb)); err != nil {
        log.Fatal(err)
    }

Buffer Size Configuration

go-redis uses 32KiB read and write buffers by default for optimal performance. For high-throughput applications or large pipelines, you can customize buffer sizes:

rdb := redis.NewClient(&redis.Options{
    Addr:            "localhost:6379",
    ReadBufferSize:  1024 * 1024, // 1MiB read buffer
    WriteBufferSize: 1024 * 1024, // 1MiB write buffer
})

Automatic pipelining

Experimental — the API may still change. Reach for autopipelining in high-throughput / high-load / scale scenarios; at low concurrency a plain client is simpler and just as fast. A runnable usage tour and throughput comparison live in example/autopipeline.

EXPERIMENTAL: the autopipelining API is subject to change in a future release as we gather feedback — pin your go-redis version if you adopt it.

When many goroutines issue commands concurrently, autopipelining batches them into Redis pipelines automatically — without you writing any pipeline code. It comes in two faces:

  • AutoPipeline() — blocking, drop-in. Each command call blocks until it executes and returns its own value/error, exactly like a normal client, so existing code keeps working unchanged. Under concurrency the engine coalesces commands from all goroutines into deep, back-to-back pipelines (a single ordered batch stream by default), reaching several times a plain client's executed commands per second in the same environment — roughly an order of magnitude with a parallel-batch config (MaxConcurrentBatches > 1 with Unordered). Per-goroutine ordering is preserved.
  • AsyncAutoPipeline() — deferred, highest throughput. Command calls return immediately; you submit a window of commands and read their results afterward, which keeps each pipeline deep — tens of times a plain client's throughput. Ordered by default. Absolute numbers depend heavily on the machine, network path and server; see autopipeline_bench_README.md for the benchmark methodology and multipliers.
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
defer rdb.Close()
ctx := context.Background()

// Blocking face: drop-in for a normal client, batched under the hood.
ap, err := rdb.AutoPipeline()
if err != nil { // invalid AutoPipelineOptions, or the client is closed
    log.Fatal(err)
}
defer ap.Close()

var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func(i int) {
        defer wg.Done()
        key := fmt.Sprintf("key:%d", i)
        if err := ap.Set(ctx, key, i, 0).Err(); err != nil { // blocks until executed
            log.Printf("set %s: %v", key, err)
        }
    }(i)
}
wg.Wait()

For maximum throughput, submit a window on the async face and read later:

ctx := context.Background()
ap, err := rdb.AsyncAutoPipeline() // ordered by default
if err != nil {
    log.Fatal(err)
}
defer ap.Close()

cmds := make([]*redis.StatusCmd, 0, 200)
for i := 0; i < 200; i++ {
    cmds = append(cmds, ap.Set(ctx, fmt.Sprintf("key:%d", i), i, 0)) // returns immediately
}
for _, cmd := range cmds {
    if err := cmd.Err(); err != nil { // blocks until executed
        log.Printf("set: %v", err)
    }
}

Each face has a no-argument form that uses Options.AutoPipelineOptions (or the built-in default) and a WithOptions form that takes an explicit *AutoPipelineOptions; both return (*AutoPipeliner, error) — the error is non-nil for an invalid config or a closed client (e.g. ap, err := rdb.AsyncAutoPipelineWithOptions(&redis.AutoPipelineOptions{MaxConcurrentBatches: 8, Unordered: true})); a handful of parallel batches saturates the link — more permits only add overlapping batches without deepening them. They work on ClusterClient too: commands are routed to the correct shard per key, so a single batch may span many slots; ordering across nodes is per key (same-key commands stay in order, different nodes' sub-pipelines run concurrently). Because batches share a few pipeline connections, autopipelining also needs far fewer connections than a plain client at the same concurrency (see PipelinePoolSize). Autopipelining is only a win under concurrency (or windowed submission) — a single goroutine issuing one blocking command at a time sees little benefit, and a hand-written Pipeline() is still fastest when you can batch by hand.

Caveats: a command's context is not honored once it is queued (batches execute on the autopipeliner's own context) — use a plain client for per-command deadlines. Blocking commands (BLPOP, WAIT, ...) are never batched and run directly on your context — as are SHUTDOWN and MONITOR, which would poison a shared pipeline connection — and Do also bypasses batching with plain Client.Do semantics — prefer the typed methods (ap.Set, ap.Get, ...). On a dropped connection a batch is retried whole (up to MaxRetries), so non-idempotent commands may execute twice. Both faces return a cached, client-shared instance: the first call's config wins and Close stops it for all callers. Hooks may read command results (the engine hands a hook running on the dispatch goroutine the same view a plain pipeline hook gets), but a hook must never issue a command on the same autopipeliner and wait for it — the nested command needs the very dispatch slot the hook is holding, and the engine only recovers by failing that flush after its 30s permit backstops. Options.Limiter is consulted once per batch dispatch (as with a manual pipeline), not once per command. An autopipeliner created on a WithTimeout/WithReadTimeout clone is not stopped by the parent's Close — close it explicitly.

Advanced Configuration

go-redis supports extending the client identification phase to allow projects to send their own custom client identification.

Default Client Identification

By default, go-redis automatically sends the client library name and version during the connection process. This feature is available in redis-server as of version 7.2. As a result, the command is "fire and forget", meaning it should fail silently, in the case that the redis server does not support this feature.

Disabling Client Identification

The DisableIdentity option disables sending the client library name and version with CLIENT SETINFO during connection initialization. It does not disable authentication or TLS certificate verification. Initially there was a typo and the option was named DisableIndentity instead of DisableIdentity. The misspelled option is marked as Deprecated and will be removed in V10 of this library. Although both options will work at the moment, the correct option is DisableIdentity. The deprecated option will be removed in V10 of this library, so please use the correct option name to avoid any issues.

To disable client identification, set the DisableIdentity option to true in the Redis client options:

rdb := redis.NewClient(&redis.Options{
    Addr:            "localhost:6379",
    Password:        "",
    DB:              0,
    DisableIdentity: true, // Disable set-info on connect
})

RESP3 for RediSearch Commands (UnstableResp3 is deprecated)

As of v9.20, FT.SEARCH, FT.AGGREGATE, FT.INFO, FT.SPELLCHECK, and FT.SYNDUMP parse RESP3 (map) responses into the same typed result objects as RESP2. No flag is required — Val() / Result() work uniformly on both protocols.

The legacy UnstableResp3 option is now a no-op and is retained on every options struct only for backwards compatibility. It will be removed in a future release; new code should not set it.

RawResult() / RawVal() continue to work for callers that prefer the raw RESP payload directly:

res1, err := client.FTSearchWithArgs(ctx, "txt", "foo bar", &redis.FTSearchOptions{}).RawResult()
val1 := client.FTSearchWithArgs(ctx, "txt", "foo bar", &redis.FTSearchOptions{}).RawVal()

Redis-Search Default Dialect

In the Redis-Search module, the default dialect is 2. If needed, you can explicitly specify a different dialect using the appropriate configuration in your queries.

Important: Be aware that the query dialect may impact the results returned. If needed, you can revert to a different dialect version by passing the desired dialect in the arguments of the command you want to execute. For example:

	res2, err := rdb.FTSearchWithArgs(ctx,
		"idx:bicycle",
		"@pickup_zone:[CONTAINS $bike]",
		&redis.FTSearchOptions{
			Params: map[string]interface{}{
				"bike": "POINT(-0.1278 51.5074)",
			},
			DialectVersion: 3,
		},
	).Result()

You can find further details in the query dialect documentation.

Custom buffer sizes

Prior to v9.12, the buffer size was the default go value of 4096 bytes. Starting from v9.12, go-redis uses 32KiB read and write buffers by default for optimal performance. For high-throughput applications or large pipelines, you can customize buffer sizes:

rdb := redis.NewClient(&redis.Options{
    Addr:            "localhost:6379",
    ReadBufferSize:  1024 * 1024, // 1MiB read buffer
    WriteBufferSize: 1024 * 1024, // 1MiB write buffer
})

Important: If you experience any issues with the default buffer sizes, please try setting them to the go default of 4096 bytes.

Contributing

We welcome contributions to the go-redis library! If you have a bug fix, feature request, or improvement, please open an issue or pull request on GitHub. We appreciate your help in making go-redis better for everyone. If you are interested in contributing to the go-redis library, please check out our contributing guidelines for more information on how to get started.

Look and feel

Some corner cases:

// SET key value EX 10 NX
set, err := rdb.SetNX(ctx, "key", "value", 10*time.Second).Result()

// SET key value keepttl NX
set, err := rdb.SetNX(ctx, "key", "value", redis.KeepTTL).Result()

// SORT list LIMIT 0 2 ASC
vals, err := rdb.Sort(ctx, "list", &redis.Sort{Offset: 0, Count: 2, Order: "ASC"}).Result()

// ZRANGEBYSCORE zset -inf +inf WITHSCORES LIMIT 0 2
vals, err := rdb.ZRangeByScoreWithScores(ctx, "zset", &redis.ZRangeBy{
    Min: "-inf",
    Max: "+inf",
    Offset: 0,
    Count: 2,
}).Result()

// ZINTERSTORE out 2 zset1 zset2 WEIGHTS 2 3 AGGREGATE SUM
vals, err := rdb.ZInterStore(ctx, "out", &redis.ZStore{
    Keys: []string{"zset1", "zset2"},
    Weights: []int64{2, 3}
}).Result()

// EVAL "return {KEYS[1],ARGV[1]}" 1 "key" "hello"
vals, err := rdb.Eval(ctx, "return {KEYS[1],ARGV[1]}", []string{"key"}, "hello").Result()

// custom command
res, err := rdb.Do(ctx, "set", "key", "value").Result()

Raw commands and connection state

Do sends the command verbatim on whichever pooled connection happens to be free. For keyspace commands that is all you need. It is the wrong tool for any command that alters connection session state — SELECT, CLIENT SETNAME, CLIENT TRACKING, RESET, HIMPORT PREPARE/DISCARD, and similar: the state lands on (or is wiped from) a single arbitrary connection, later commands are served by other connections that don't share it, and the affected connection eventually returns to the pool and serves unrelated callers. The result is nondeterministic behavior that typed APIs manage for you — for example, the typed HImport* methods keep a client-side registry and replay fieldsets onto every connection that needs them, while a raw Do(ctx, "himport", "prepare", ...) bypasses that entirely, with no replay, recovery, or discard propagation.

For session-scoped work without a typed API, hold a dedicated connection (client.Conn()) for its whole lifetime and close it afterwards.

Typed Errors

go-redis provides typed error checking functions for common Redis errors:

// Cluster and replication errors
redis.IsLoadingError(err)        // Redis is loading the dataset
redis.IsReadOnlyError(err)       // Write to read-only replica
redis.IsClusterDownError(err)    // Cluster is down
redis.IsTryAgainError(err)       // Command should be retried
redis.IsMasterDownError(err)     // Master is down
redis.IsMovedError(err)          // Returns (address, true) if key moved
redis.IsAskError(err)            // Returns (address, true) if key being migrated

// Connection and resource errors
redis.IsMaxClientsError(err)     // Maximum clients reached
redis.IsAuthError(err)           // Authentication failed (NOAUTH, WRONGPASS, unauthenticated)
redis.IsPermissionError(err)     // Permission denied (NOPERM)
redis.IsOOMError(err)            // Out of memory (OOM)

// Transaction errors
redis.IsExecAbortError(err)      // Transaction aborted (EXECABORT)

Error Wrapping in Hooks

When wrapping errors in hooks, use custom error types with Unwrap() method (preferred) or fmt.Errorf with %w. Always call cmd.SetErr() to preserve error type information:

// Custom error type (preferred)
type AppError struct {
    Code      string
    RequestID string
    Err       error
}

func (e *AppError) Error() string {
    return fmt.Sprintf("[%s] request_id=%s: %v", e.Code, e.RequestID, e.Err)
}

func (e *AppError) Unwrap() error {
    return e.Err
}

// Hook implementation
func (h MyHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
    return func(ctx context.Context, cmd redis.Cmder) error {
        err := next(ctx, cmd)
        if err != nil {
            // Wrap with custom error type
            wrappedErr := &AppError{
                Code:      "REDIS_ERROR",
                RequestID: getRequestID(ctx),
                Err:       err,
            }
            cmd.SetErr(wrappedErr)
            return wrappedErr  // Return wrapped error to preserve it
        }
        return nil
    }
}

// Typed error detection works through wrappers
if redis.IsLoadingError(err) {
    // Retry logic
}

// Extract custom error if needed
var appErr *AppError
if errors.As(err, &appErr) {
    log.Printf("Request: %s", appErr.RequestID)
}

Alternatively, use fmt.Errorf with %w:

wrappedErr := fmt.Errorf("context: %w", err)
cmd.SetErr(wrappedErr)

Pipeline Hook Example

For pipeline operations, use ProcessPipelineHook:

type PipelineLoggingHook struct{}

func (h PipelineLoggingHook) DialHook(next redis.DialHook) redis.DialHook {
    return next
}

func (h PipelineLoggingHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
    return next
}

func (h PipelineLoggingHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
    return func(ctx context.Context, cmds []redis.Cmder) error {
        start := time.Now()

        // Execute the pipeline
        err := next(ctx, cmds)

        duration := time.Since(start)
        log.Printf("Pipeline executed %d commands in %v", len(cmds), duration)

        // Process individual command errors
        // Note: Individual command errors are already set on each cmd by the pipeline execution
        for _, cmd := range cmds {
            if cmdErr := cmd.Err(); cmdErr != nil {
                // Check for specific error types using typed error functions
                if redis.IsAuthError(cmdErr) {
                    log.Printf("Auth error in pipeline command %s: %v", cmd.Name(), cmdErr)
                } else if redis.IsPermissionError(cmdErr) {
                    log.Printf("Permission error in pipeline command %s: %v", cmd.Name(), cmdErr)
                }

                // Optionally wrap individual command errors to add context
                // The wrapped error preserves type information through errors.As()
                wrappedErr := fmt.Errorf("pipeline cmd %s failed: %w", cmd.Name(), cmdErr)
                cmd.SetErr(wrappedErr)
            }
        }

        // Return the pipeline-level error (connection errors, etc.)
        // You can wrap it if needed, or return it as-is
        return err
    }
}

// Register the hook
rdb.AddHook(PipelineLoggingHook{})

// Use pipeline - errors are still properly typed
pipe := rdb.Pipeline()
pipe.Set(ctx, "key1", "value1", 0)
pipe.Get(ctx, "key2")
_, err := pipe.Exec(ctx)

Run the test

Recommended to use Docker, just need to run:

make test

See also

Contributors

The go-redis project was originally initiated by ⭐ uptrace/uptrace. Uptrace is an open-source APM tool that supports distributed tracing, metrics, and logs. You can use it to monitor applications and set up automatic alerts to receive notifications via email, Slack, Telegram, and others.

See OpenTelemetry example which demonstrates how you can use Uptrace to monitor go-redis.

Thanks to all the people who already contributed!

Description
Клиент Redis Go
Readme BSD-2-Clause 96 MiB
Languages
Go 99.7%
Shell 0.2%