mirror of
https://github.com/redis/go-redis.git
synced 2026-10-28 16:35:06 +08:00
* feat(command): add zero-copy GetToBuffer and SetFromBuffer
Adds two new methods on the StringCmdable interface that let callers read
and write Redis string values directly into/from pre-allocated byte
buffers, eliminating the per-call payload allocation that the regular
Get/Set methods incur.
Public API:
GetToBuffer(ctx, key, buf) *ZeroCopyStringCmd
SetFromBuffer(ctx, key, buf) *StatusCmd
ZeroCopyStringCmd { Val() int; Bytes() []byte; Result() (int, error) }
Available on *Client, *ClusterClient, *Ring, *Conn and Pipeliner via
* fix(proto): avoid stale line[0] after bufio refill in ReadStringInto
ReadLine() returns a slice into bufio.Reader's internal buffer (the
ReadSlice fast path). The follow-up io.ReadFull / Discard calls on
r.rd can trigger bufio.fill(), which copies new source bytes over the
slot line[0] used to point at. The post-read
if line[0] == RespVerbatim { ... }
check on line 520 of reader.go could therefore observe a refilled byte
rather than the original response type. When that refilled byte
happened to be '=' (0x3D, RespVerbatim) — for example the head of a
bulk-string payload whose tail forced the refill — a regular `$<n>...`
Fix: capture line[0] into a local `isVerbatim` boolean before any
further read on r.rd, and use that local for both the pre-read length
check and the post-read prefix-stripping branch.
Regression test (internal/proto/reader_test.go): a payload sized to
exactly DefaultBufferSize, all '=' bytes. After ReadLine consumes the
header, bufio holds (bufsize - headerLen) payload bytes; the final
headerLen bytes io.ReadFull asks for force bufio.fill(), which places
payload-tail + trailing \r\n at position 0 of bufio.buf — i.e.
'=' at the slot line[0] points at. Against the buggy code the test
fails with n=32764 wanted 32768; with the fix all 10 ReadStringInto
tests pass.
* chore(ci): bump bench test timeout to 11m
* chore(bench): cap CI bench wrappers at 64 KiB; cover 10 MiB via test
* fix(bench): keep proto Reader benches O(1) in b.N via loopFrameReader
* fix(proto): drain payload on buffer-too-small; drop verbatim handling
Three related cleanups to ReadStringInto and the cmd it backs:
1. Buffer-too-small now drains the unread payload + trailing CRLF before
returning the error, so the underlying connection stays aligned for
the next reply. Without this drain, a too-small caller buffer left
the bulk payload sitting in the bufio stream — the connection would
then be returned to the pool in a corrupted state, and the next user
of that connection would parse the leftover bytes as a malformed
response. The existing test only checked the first call's error and
missed this; it now feeds two consecutive bulk-string replies and
asserts the second parses cleanly. Verified failure-mode by reverting
just the drain: the follow-up read fails with
`can't parse reply="hello" reading string into buffer`.
2. Drop RespVerbatim handling from ReadStringInto. The only public
caller is GetToBuffer, which issues GET, and GET never returns a
verbatim string — RESP3 verbatim replies come from server-info
commands like CLIENT INFO / DEBUG OBJECT / LOLWUT. Keeping the
branch re-introduces a hazard class where the response-type byte
read from a stale line[0] after a bufio refill can be misinterpreted
as the verbatim format tag (the bug fixed in
"fix(proto): avoid stale line[0] after bufio refill in ReadStringInto").
Removing the branch eliminates the class structurally and drops an
O(n) memmove on the verbatim path (`copy(buf, buf[4:n])`).
The unit test that previously asserted verbatim parsing is reshaped
to assert verbatim now surfaces as an error, and the
BulkStringStartingWithEqRefill regression test is kept (and its
comment updated) as a guard against re-introducing verbatim support
without re-introducing the bug.
3. Add a godoc paragraph to ZeroCopyStringCmd.Clone explaining the
deep-copy semantics (clone gets a freshly allocated buffer, not a
slice of the caller's memory) and noting that Clone is unreachable
through normal client flows: it is only called from cluster fan-out
routing in osscluster_router.go for multi-shard commands like
DBSIZE / KEYS / FLUSHDB, and ZeroCopyStringCmd is only produced by
GetToBuffer which issues GET — a single-key command never fanned
out. Combined with NoRetry()=true blocking the retry path, Clone
exists only to satisfy the Cmder interface and to keep the cmd usable
if a future caller does fan out.
* fix(zerocopy): clone fails explicitly; reset cmd.n; update doc
Three small follow-ups to ZeroCopyStringCmd identified during a full
review of the zero-copy buffer code path:
1. Clone() now returns a marked clone whose readReply drains the
network reply and surfaces an explicit error rather than producing
silently-wrong results. Cloning a ZeroCopyStringCmd has no
well-defined semantics: the cmd writes into caller-owned memory
(the buf passed to GetToBuffer), and a clone can neither share that
buf (sibling clones would race, last-writer wins) nor allocate its
own buf (the result would be invisible to whoever asked for the
original cmd's reply). The previous implementation chose the second
path and lost the result; this commit chooses to fail loudly.
The Cmder interface requires Clone, so we still return a clone.
The clone is flagged via a new private field, and readReply checks
that flag, calls rd.DiscardNext() to keep the connection aligned,
and returns a clear error ("redis: ZeroCopyStringCmd cannot be
cloned (cmd writes into caller-owned memory)").
Pre-setting cmd.err in Clone would NOT work because pipelineReadCmds
overwrites it with whatever readReply returns — the drain-and-error
path is what actually survives the framework.
In practice this path is unreachable through normal client flows:
Clone is only called from cluster fan-out routing in
osscluster_router.go for multi-shard commands (DBSIZE / KEYS /
FLUSHDB), and ZeroCopyStringCmd is only produced by GetToBuffer
which issues GET — a single-key command routed to one shard, never
fanned out. Combined with NoRetry()=true blocking the retry path,
the clone path is exercised only by explicit caller code.
A new Ginkgo spec asserts both that processing a clone returns the
expected error and that a follow-up GetToBuffer on the same client
succeeds (the drain keeps the connection usable).
2. readReply now resets cmd.n = 0 on entry so that an error after a
previous successful run can't leak the old data through Bytes().
Cheap defensive belt-and-braces — the caller is supposed to check
Err() first, but this removes a stale-state failure mode entirely.
3. The ZeroCopyStringCmd type godoc previously claimed "no data is
consumed beyond the header line" on the buffer-too-small path.
That stopped being true after the previous commit added the drain
to ReadStringInto. Updated to match reality.
Verified end-to-end against Redis 8.8 in Docker:
orig GetToBuffer: OK
cloned cmd processing: errored as expected
follow-up GetToBuffer: OK — connection stays aligned
* docs(string): clarify that SetFromBuffer is API symmetry, not a perf path
SetFromBuffer and Set(ctx, key, buf, 0) dispatch to the same `case []byte`
branch in proto.Writer and produce byte-identical output on the wire. The
"zero-copy on send" property comes from bufio.Writer.Write bypassing its
internal buffer for payloads larger than that buffer — a property of
bufio, not of SetFromBuffer. Set([]byte) gets the same behaviour for
free.
Without this clarification, callers reading the godoc could reasonably
infer that SetFromBuffer is a performance optimisation over Set, choose
it for that reason, and be disappointed when the bench numbers come back
indistinguishable from Set([]byte). The added paragraph spells out the
actual rationale (API symmetry with GetToBuffer for code that uses the
buffer-based pattern on both sides) and tells callers who don't care
about that symmetry that Set(ctx, key, buf, 0) is equally efficient.
No behaviour change.
111 lines
3.4 KiB
Go
111 lines
3.4 KiB
Go
// This example demonstrates the zero-copy buffer operations GetToBuffer and
|
|
// SetFromBuffer. These methods allow reading and writing Redis values directly
|
|
// from/to user-provided byte buffers, avoiding intermediate allocations.
|
|
//
|
|
// It also shows how these zero-copy commands work with pipelining for
|
|
// batching multiple operations in a single round-trip, including reading
|
|
// multiple keys into a single shared buffer.
|
|
//
|
|
// This is especially useful when working with large values (MB+) where
|
|
// reducing memory allocations and copies is important.
|
|
//
|
|
// Prerequisites:
|
|
// - A running Redis server on localhost:6379
|
|
//
|
|
// Run:
|
|
//
|
|
// go run .
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"log"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
func main() {
|
|
ctx := context.Background()
|
|
|
|
rdb := redis.NewClient(&redis.Options{
|
|
Addr: "localhost:6379",
|
|
})
|
|
defer rdb.Close()
|
|
|
|
// Clean up example keys on exit.
|
|
defer rdb.Del(ctx, "zerocopy:greeting", "zerocopy:largeblob")
|
|
|
|
// --- Example 1: Basic SetFromBuffer / GetToBuffer round-trip ---
|
|
fmt.Println("=== Example 1: Basic round-trip ===")
|
|
|
|
// Write data from a buffer directly to Redis.
|
|
writeData := []byte("Hello, zero-copy world!")
|
|
if err := rdb.SetFromBuffer(ctx, "zerocopy:greeting", writeData).Err(); err != nil {
|
|
log.Fatalf("SetFromBuffer failed: %v", err)
|
|
}
|
|
fmt.Printf("SET zerocopy:greeting (%d bytes)\n", len(writeData))
|
|
|
|
// Read data from Redis directly into a pre-allocated buffer.
|
|
readBuf := make([]byte, 100)
|
|
cmd := rdb.GetToBuffer(ctx, "zerocopy:greeting", readBuf)
|
|
if err := cmd.Err(); err != nil {
|
|
log.Fatalf("GetToBuffer failed: %v", err)
|
|
}
|
|
|
|
n := cmd.Val() // number of bytes read
|
|
fmt.Printf("GET zerocopy:greeting -> %d bytes: %q\n", n, string(cmd.Bytes()))
|
|
|
|
// --- Example 2: Large binary data (1 MB) ---
|
|
fmt.Println("\n=== Example 2: Large binary data (1 MB) ===")
|
|
|
|
const blobSize = 1 * 1024 * 1024 // 1 MB
|
|
blob := make([]byte, blobSize)
|
|
if _, err := rand.Read(blob); err != nil {
|
|
log.Fatalf("failed to generate random data: %v", err)
|
|
}
|
|
|
|
// Write 1 MB directly from the buffer — no intermediate string conversion.
|
|
if err := rdb.SetFromBuffer(ctx, "zerocopy:largeblob", blob).Err(); err != nil {
|
|
log.Fatalf("SetFromBuffer (1MB) failed: %v", err)
|
|
}
|
|
fmt.Printf("SET zerocopy:largeblob (%d bytes)\n", blobSize)
|
|
|
|
// Read 1 MB directly into a pre-allocated buffer — no extra allocations.
|
|
largeBuf := make([]byte, blobSize+64) // slightly larger than needed
|
|
cmd = rdb.GetToBuffer(ctx, "zerocopy:largeblob", largeBuf)
|
|
if err := cmd.Err(); err != nil {
|
|
log.Fatalf("GetToBuffer (1MB) failed: %v", err)
|
|
}
|
|
fmt.Printf("GET zerocopy:largeblob -> %d bytes\n", cmd.Val())
|
|
|
|
// Verify data integrity.
|
|
retrieved := cmd.Bytes()
|
|
if len(retrieved) != blobSize {
|
|
log.Fatalf("size mismatch: expected %d, got %d", blobSize, len(retrieved))
|
|
}
|
|
for i := range blob {
|
|
if blob[i] != retrieved[i] {
|
|
log.Fatalf("data mismatch at byte %d", i)
|
|
}
|
|
}
|
|
fmt.Println("Data integrity verified ✓")
|
|
|
|
// --- Example 3: Handling non-existent keys ---
|
|
fmt.Println("\n=== Example 3: Handling non-existent keys ===")
|
|
|
|
buf := make([]byte, 64)
|
|
cmd = rdb.GetToBuffer(ctx, "zerocopy:nonexistent", buf)
|
|
if cmd.Err() == redis.Nil {
|
|
fmt.Println("Key does not exist (redis.Nil) — as expected")
|
|
} else if cmd.Err() != nil {
|
|
log.Fatalf("unexpected error: %v", cmd.Err())
|
|
}
|
|
|
|
runPackingExamples(ctx, rdb)
|
|
runPipelineExamples(ctx, rdb)
|
|
|
|
fmt.Println("\nDone!")
|
|
}
|