Redis Lab and StackExchange.Redis: Docker, redis-cli, Redis Insight, ConnectionMultiplexer and Key Design
In StackExchange.Redis there should be one ConnectionMultiplexer per application: each multiplexer opens two connections, so creating one per request turns 200 requests into 400 connections and replies ten times slower. With real output: a compose lab with Redis 8 and Valkey 9, redis-cli latency modes, an unread MONITOR eating 101 MB, Redis Insight, RESP3 and the separate pub/sub connection, a 300x gain from pipelining, a 110-second timeout from sync over async, and listing keys with SCAN.
Version note: The examples in this post were run with Redis 8.10.3 (redis:8), Valkey 9.1.3 (valkey/valkey:9), Redis Insight 3.8.0, .NET 10 and StackExchange.Redis 3.4.35.
Guess First
A book view counter is being added to the API of Bookstore, the online bookstore used as the shared example across this site's series. The first draft opens a new connection to Redis on every request and closes it when done; the reasoning was "we close the connection with using, so nothing leaks":
// Anti-pattern: a new multiplexer for every "request".
for (int i = 0; i < 200; i++)
{
ConnectionMultiplexer mux = await ConnectionMultiplexer.ConnectAsync(connectionString + ",name=per-request");
await mux.GetDatabase().StringIncrementAsync("bookstore:book:42:views");
if (dispose)
{
await mux.DisposeAsync();
}
else
{
leaked.Add(mux); // nobody disposes it: the connection stays open
}
}
How many new TCP connections does Redis receive for 200 requests: 200? What happens if someone forgets DisposeAsync? And how much does the time change when the same 200 requests share a single multiplexer?
dotnet run --project src/Bookstore.RedisLab -- mux-per-request
dotnet run --project src/Bookstore.RedisLab -- mux-per-request --leak
dotnet run --project src/Bookstore.RedisLab -- mux-singleton
200 requests: 462 ms, 2.31 ms/request, new connections: 400, connected_clients now: 2
200 requests: 400 ms, 2.00 ms/request, new connections: 400, connected_clients now: 402
200 requests: 44 ms, 0.22 ms/request, new connections: 2
Not 200 connections but 400. Every ConnectionMultiplexer opens two: an interactive connection for commands and a separate one for pub/sub. When dispose is forgotten, the connections stay after the request ends: connected_clients climbed to 402 (plus the two I opened to observe). With a single multiplexer the same work is ten times faster, because the TCP connection, the HELLO handshake and topology discovery are not repeated for every request. This post explains why and shows the right way; first we set up a permanent Redis lab for Bookstore.
The Lab: Redis 8 and Valkey 9 Side by Side
Instead of the one-off docker run from post 01 we now use a compose file. Redis and Valkey run at the same time; Redis Insight sits in an optional profile:
# Bookstore Redis lab: Redis 8 and Valkey 9 side by side, Redis Insight optional
name: bookstore-redis-lab
services:
redis:
image: redis:8
ports:
# only reachable from this machine
- "127.0.0.1:6379:6379"
volumes:
- redis-data:/data
valkey:
image: valkey/valkey:9
ports:
- "127.0.0.1:6380:6379"
volumes:
- valkey-data:/data
insight:
image: redis/redisinsight:3.8
profiles: ["tools"]
ports:
- "127.0.0.1:5540:5540"
volumes:
- insight-data:/data
volumes:
redis-data:
valkey-data:
insight-data:
# the temporary containers from post 01
docker rm -f bookstore-redis bookstore-valkey
docker compose -f lab/compose.yaml up -d
docker compose -f lab/compose.yaml exec redis redis-cli CONFIG GET protected-mode
1) "protected-mode"
2) "no"
The 127.0.0.1 in the port mapping is deliberate. In the official redis:8 and valkey/valkey:9 images protected-mode is off and the server listens on all interfaces (bind * -::*); otherwise it would not work on a container network. If you publish a Redis without a password with -p 6379:6379, anyone on your machine's network can send it commands. That is why the lab binds to loopback only; ACLs and TLS for production are the subject of post 14. The volumes are mounted on /data because the image writes dump.rdb there with the default RDB settings (save 3600 1 300 100 60 10000); the data survives the container being removed and recreated.
redis-cli, MONITOR and Redis Insight
You do not need to install redis-cli on Windows; it ships inside the image. docker compose -f lab/compose.yaml exec redis redis-cli opens an interactive session, and if you append a command it runs that one command and exits. For diagnosis the most useful modes are the latency measurements:
docker compose -f lab/compose.yaml exec redis redis-cli --latency
docker compose -f lab/compose.yaml exec redis redis-cli --latency-history -i 1
docker compose -f lab/compose.yaml exec redis redis-cli --latency --latency-percentiles 50,99,99.9
min: 0.025, max: 0.661, avg: 0.155 (429 samples)
min: 0.076, max: 0.526, avg: 0.184 (91 samples) -- 1.00 seconds range
min: 0.047, max: 0.425, avg: 0.127 (91 samples) -- 1.00 seconds range
min: 0.031, max: 0.887, avg: 0.142 (87 samples) -- 1.01 seconds range
min: 0.029, max: 2.038, avg: 0.172 (424 samples), p50: 0.160, p99: 0.469, p99.9: 2.038
The values are in milliseconds. --latency sends PINGs continuously and reports the minimum, maximum and average; --latency-history does the same but starts a new line at each interval, so you see how latency changes over time. --latency-percentiles, which prints percentiles, arrived with Redis 8.10. A caveat: when you run redis-cli inside the container you measure the server's own latency; what the application sees over the network comes on top. We measured that gap in post 01.
MONITOR streams every command the server receives, which makes it very tempting for debugging. The Redis documentation says that in its own measurements a single MONITOR client cut throughput by more than half. On this machine, with one MONITOR writing quickly to /dev/null, the SET result of redis-benchmark went from 292,825 to 275,103 requests per second; the real danger turned up elsewhere. I ran 200,000 SETs of 512 bytes with a client that had sent MONITOR and then stopped reading, standing in for a laptop on a slow network or a stuck terminal:
SET: 198412.69 requests per second, p50=0.215 msec
name=stalled-monitor omem=106292736 tot-mem=106298112 cmd=monitor
used_memory_human:162.35M
mem_clients_normal:106303488
The unread MONITOR output piled up in the server's memory: a single client's output buffer reached 101 MB and used_memory went from 61 MB to 162 MB. For normal clients client-output-buffer-limit defaults to normal 0 0 0, which means unlimited. Leaving MONITOR open in production for more than a few seconds means Redis can hit its memory limit because of a debugging session rather than your application's data. If you want to know which commands are slow, SLOWLOG is safer; if you want to know which are called often, use INFO commandstats.
If you prefer a graphical tool there is Redis Insight. Start it with the tools profile from the compose file and open http://localhost:5540 in the browser:
docker compose -f lab/compose.yaml --profile tools up -d insight
When adding a connection, do not use localhost as the host: Insight runs in its own container, so localhost is Insight itself. When I tried it, localhost:6379 returned a RedisConnectionFailedException with status 424; the service name on the compose network, redis (port 6379), connected immediately. For Valkey the host is valkey.
StackExchange.Redis 3.x and a Single ConnectionMultiplexer
On the .NET side the client is StackExchange.Redis; the current stable release at the time of writing is 3.4.35 (12 November 2026). Version 3.0 shipped in June 2026; according to its release notes it rewrote the network layer and the parser without intentionally changing the public API, and made RESP3, the protocol version introduced with Redis 6 that can carry pub/sub messages and richer types on the same connection, the default. The package documentation states the main rule plainly: ConnectionMultiplexer is thread-safe, designed to be shared, and should not be created per operation. In Bookstore.Api we open one per application and register it in the DI container as a singleton:
using Bookstore.Api;
using StackExchange.Redis;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// One multiplexer for the whole application, shared by every request
string redisConnection = builder.Configuration.GetConnectionString("Redis")
?? throw new InvalidOperationException("ConnectionStrings:Redis is missing.");
IConnectionMultiplexer redis = await ConnectionMultiplexer.ConnectAsync(redisConnection);
builder.Services.AddSingleton(redis);
WebApplication app = builder.Build();
app.MapBookViews();
app.Run();
{
"ConnectionStrings": {
"Redis": "localhost:6379,name=bookstore-api,abortConnect=false"
}
}
Two settings in the connection string are deliberate. name=bookstore-api shows who owns the connection in CLIENT LIST and SLOWLOG; that is how we found the offending command in post 01. Without abortConnect=false the application does not start at all while Redis is down; with it the multiplexer keeps trying to connect in the background and the application comes up. The endpoints take the multiplexer from DI and call GetDatabase() on every request. IDatabase is a cheap object; there is no need to keep it around:
app.MapPost("/books/{bookId:int}/views", async (int bookId, IConnectionMultiplexer redis) =>
{
long views = await redis.GetDatabase().StringIncrementAsync(RedisKeys.BookViews(bookId));
return Results.Ok(new { bookId, views });
});
dotnet run --project src/Bookstore.Api
curl -X POST http://localhost:5080/books/42/views
curl -X POST http://localhost:5080/books/42/views
curl http://localhost:5080/books/7/views
{"bookId":42,"views":1}
{"bookId":42,"views":2}
{"bookId":7,"views":0}
The answer to the "two connections per multiplexer" question from Guess First is in CLIENT LIST. Right after the multiplexer connects, and after it subscribes to a channel:
after connect:
id=830 name=bookstore-api resp=Resp3 flags=P sub=1 cmd=subscribe
id=831 name=bookstore-api resp=Resp3 flags=N sub=0 cmd=ping
after SubscribeAsync:
id=830 name=bookstore-api resp=Resp3 flags=P sub=2 cmd=subscribe
id=831 name=bookstore-api resp=Resp3 flags=N sub=0 cmd=ping
Both speak RESP3. flags=N is the normal command connection, flags=P the pub/sub connection. Before the application has subscribed to anything, the pub/sub connection already holds one subscription: according to the documentation the multiplexer subscribes to a configuration channel on every server to hear about primary/replica changes. RESP3 could in fact carry pub/sub messages on the command connection. Since 3.4.0 StackExchange.Redis still uses a separate connection by default, because Redis applies the much tighter output buffer limits of the pubsub class to any connection with a subscription, and a shared connection could be closed because of one large reply. You can switch to a single connection with sharedSubscriptionConnection=true; the documentation's condition is that you first check the server's pubsub limits are large enough for your biggest replies. Pub/sub itself is covered in post 09.
The same code connects to Valkey; only the port differs. Let's compare the HELLO reply with the version the client sees:
Unspecified/localhost:6379: server=redis, version=8.10.3, protocol=Resp3, ServerType=Standalone, Version=8.10.3
Unspecified/localhost:6380: server=valkey, version=9.1.3, protocol=Resp3, ServerType=Standalone, Version=7.2.4
Valkey reports its own name and 9.1.3 in HELLO, yet IServer.Version says 7.2.4. According to the 3.4.20 release notes, the client looks at the server field before treating version as the Redis API version; for Valkey it assumes Redis 7.2.4 compatibility. If your code enables features by checking server.Version, take this into account.
How Multiplexing and Pipelining Actually Work
Won't a single connection become a bottleneck? StackExchange.Redis answers with multiplexing: commands from different callers are written back to back onto the same socket, replies come back in the same order, and each one completes the right Task. The time one caller spends waiting for a reply is filled with other callers' commands. Let's measure the effect by incrementing a counter 10,000 times in four different ways:
// 1) await every command before sending the next one
for (int i = 0; i < count; i++)
{
await db.StringIncrementAsync("bookstore:demo:sequential");
}
// 2) start all commands, then await them together: the multiplexer pipelines them
Task<long>[] pending = new Task<long>[count];
for (int i = 0; i < count; i++)
{
pending[i] = db.StringIncrementAsync("bookstore:demo:concurrent");
}
await Task.WhenAll(pending);
// 3) IBatch: buffered on the client and written to the socket in one go on Execute()
IBatch batch = db.CreateBatch();
Task<long>[] batched = new Task<long>[count];
for (int i = 0; i < count; i++)
{
batched[i] = batch.StringIncrementAsync("bookstore:demo:batch");
}
batch.Execute();
await Task.WhenAll(batched);
// 4) fire-and-forget: no reply is awaited, the return value is always default
for (int i = 0; i < count; i++)
{
await db.StringIncrementAsync("bookstore:demo:faf", flags: CommandFlags.FireAndForget);
}
long faf = (long)await db.StringGetAsync("bookstore:demo:faf"); // a normal command; replies arrive in order
sequential await : 2,144 ms
Task.WhenAll : 7 ms
IBatch : 16 ms
FireAndForget : 4 ms (value after a normal GET: 10,000)
With sequential await every increment waits for a round trip: 10,000 × ~0.2 ms ≈ 2 seconds. When you start the commands first and await them together, the multiplexer writes them onto the socket one after another and lets Redis process many commands per round trip; that is pipelining. The difference is 300x. IBatch is not faster here; it is useful when you want a group of commands to go out contiguously without other callers' commands mixed in. It is not atomic; for that, see the transactions in post 08. FireAndForget does not wait for the reply and its return value is meaningless (always 0 instead of the counter). It suits writes where a loss is tolerable, such as a view counter, and never a command whose result you use. The final GET returning 10,000 is also a consequence of the model: commands on the same connection run in the order they were sent.
The same model rules something out: blocking commands such as BLPOP. If one caller held the shared connection for seconds, everyone would wait; that is why the package documentation says it does not, and will not, support blocking pops. We come back to this in post 05 when building a queue with lists.
The Async API and Sync over Async
Every StackExchange.Redis command has a sync and an async version. In ASP.NET Core the rule is simple: await the async one. Let's measure why it matters. Five hundred requests arrive at once, and each of them waits for its Redis call by blocking on .Result:
Task[] requests = [.. Enumerable.Range(0, 500).Select(_ => Task.Run(() =>
{
try
{
long views = db.StringIncrementAsync("bookstore:book:42:views").Result;
}
catch (AggregateException ex) when (ex.InnerException is RedisTimeoutException timeout)
{
Interlocked.Increment(ref failed);
firstError ??= timeout.Message;
}
}))];
await Task.WhenAll(requests);
At build time the package's own analyzer warns:
SyncOverAsyncDemo.cs(21,30): warning SER307: 'StringIncrementAsync' is being waited on synchronously, which ties up a thread until redis replies - and the reply needs a thread of its own to be processed; await it instead (https://seredis.dev/SyncOverAsync)
I ignored the warning and ran it (asyncTimeout=2000):
500 blocking requests: 109,739 ms, timeouts: 492
Timeout awaiting response (outbound=0KiB, inbound=0KiB, 109705ms elapsed, timeout is 2000ms), command=INCR, next: INCR bookstore:book:42:views, inst: 0, qu: 0, qs: 0, aw: False, bw: SpinningDown, rs: ReadAsync, ws: Idle, in: 6, in-pipe: 0, last-in: 361, cur-in: 361, sync-ops: 0, async-ops: 500, serverEndpoint: localhost:6379, conn-sec: 109.74, aoc: 1, mc: 1/1/0, clientName: bookstore-api, IOCP: (Busy=0,Free=1000,Min=1,Max=1000), WORKER: (Busy=501,Free=32266,Min=10,Max=32767), POOL: (Threads=501,QueuedItems=4,CompletedItems=24,Timers=1), v: 3.4.35.20751 (see https://seredis.dev/Timeouts for some common client-side issues that can cause timeouts)
500 awaited requests: 3 ms
Five hundred INCRs took 110 seconds and 492 of them timed out. The same 500 requests with await took 3 ms. The clue in the message is WORKER: (Busy=501, … Min=10): all 501 thread pool threads are waiting on .Result, and no thread is left to process the replies. Once the .NET thread pool is above its minimum, here the processor count of 10, it adds new threads slowly, and since every new thread arrives and blocks as well, things never recover. Redis itself sits idle the whole time. The package documentation stresses two points: switching to the sync API is not a fix, because it also holds the calling thread until the reply arrives, and raising MinThreads only moves the wall further away. The only real fix is to make the call chain async from top to bottom. Post 14 covers reading timeout messages in detail.
Key Design and SCAN Instead of KEYS
In Bookstore, keys follow one shape: bookstore:<entity>:<id>[:<detail>]. For example bookstore:book:42:views, bookstore:cart:7, bookstore:customer:7:recent. The colon is not special to Redis, it is only a convention; but Redis Insight displays keys as a tree based on it, and it makes ACL key patterns such as ~bookstore:* easy to write. Instead of scattering key names through the code by concatenating strings, we keep them in one place:
using StackExchange.Redis;
namespace Bookstore.Api;
// Bookstore key naming: bookstore:<entity>:<id>[:<detail>]
public static class RedisKeys
{
private const string Prefix = "bookstore";
public static RedisKey BookViews(int bookId) => $"{Prefix}:book:{bookId}:views";
}
Readable keys have a cost, but usually a smaller one than people expect. I wrote a million counters with two naming schemes and compared used_memory:
bookstore:book:{id}:views 1,000,000 keys: 59.2 MB, MEMORY USAGE of one key: 42 bytes
b:{id}:v 1,000,000 keys: 43.9 MB, MEMORY USAGE of one key: 25 bytes
That is 15 MB per million keys. Keeping values small and not creating unnecessary keys usually pays off far more than turning key names into a cipher. Versioned keys and the design of cache keys were covered in the post "IDistributedCache and Running Redis as a Cache" in the Caching series.
When you need to list keys, use SCAN, not KEYS. StackExchange.Redis 3.x already stops you: in post 01, when I tried to send KEYS through Execute, the client refused with This operation is not available unless admin mode is enabled: KEYS and asked for allowAdmin=true. The right way is IServer.KeysAsync, which calls SCAN page by page behind the scenes. I repeated the experiment from post 01 with it instead of KEYS:
// IServer.KeysAsync uses SCAN: many small calls instead of one long one
IServer server = instanceB.GetServers()[0];
long count = 0;
await foreach (RedisKey _ in server.KeysAsync(pattern: "bookstore:book:*", pageSize: 1000))
{
count++;
}
instance B: SCAN found 1,000,000 keys in 1,031 ms
instance A: 90 PINGs, median 0.36 ms, p99 6.40 ms, max 6.40 ms
cmdstat_scan:calls=2000,usec=616106,usec_per_call=308.05
The total time is longer than with KEYS (one second), but it is split into 1,000 separate calls; each SCAN runs for 0.3 ms on average on the server, and other clients' commands get in between. The longest PING from instance-a dropped from 102 ms to 6 ms. Still, SCAN walks the whole keyspace; it is not a query to run often in production. If a question like "how many books have a counter?" is asked frequently, keep the answer in a separate counter or set. SCAN also lacks one guarantee: keys added or removed during the scan may or may not show up in the result.
In Production
Keep one multiplexer per application and register it as a singleton; if the same application uses a library that opens its own connection, such as AddStackExchangeRedisCache, share the multiplexer with it as shown in the Caching series. Give every client a name=: four hundred unnamed connections in CLIENT LIST make diagnosis harder. Watch connected_clients and compare it with the number you expect; two connections per instance is normal, a number that grows with request count is a leak. Do not use MONITOR in production, and if you must, limit it to a few seconds. Expose the Redis port only to the network that needs it, even in containers, and remember that protected-mode is off in the official images. Being able to run the same code against Valkey does not guarantee the same behaviour; test the commands you use on both sides.
Common Pitfalls
A ConnectionMultiplexer per request: 400 TCP connections for 200 requests and replies ten times slower. If dispose is forgotten, connections pile up.
.Result, .Wait() or the sync API in async code: under load the thread pool runs dry, and the application produces timeouts while Redis sits idle. Instead of suppressing SER307, await the call.
KEYS * in production: one command makes every client wait. Use IServer.KeysAsync (SCAN), and use that sparingly too.
Using the return value of FireAndForget: it is always the default, never the server's reply.
Connecting to localhost from Insight or another container: inside a container, localhost is that container itself; use the service name on the compose network.
A Small Experiment
★☆☆ Set up the lab with compose, run Bookstore.Api and count the connections named bookstore-api with docker compose -f lab/compose.yaml exec redis redis-cli CLIENT LIST. Does the number change after you send 100 requests to the API?
★★☆ Run the pipeline experiment against Valkey with BOOKSTORE_REDIS=localhost:6380. Then raise the command count in the Task.WhenAll part to 100,000: does the time grow linearly?
★★★ Add sharedSubscriptionConnection=true to Bookstore.Api's connection string and look at CLIENT LIST again. How many connections are there, and what changed in the flags field? Read the pubsub limits with CONFIG GET client-output-buffer-limit and explain why this setting could be risky with a large HGETALL reply.
In the next post we go back to Redis's most basic type, the string: the options of SET, counters, the compare-and-set commands from Redis 8.4, and how expiration really works inside.