Skip to content
Huseyin Babal
Go back

Redis 8.10 vs Valkey 9.1 under load: the same Go app, the same 4-core machine

Valkey started in March 2024 as a Linux Foundation fork of Redis 7.2.4, after Redis moved to source-available licenses. Two and a half years later both projects ship new major versions and still speak the same protocol, so any Redis client works against either. I wanted to know how far apart they are on one machine with the settings most people never touch.

Short version: on a 4 dedicated vCPU machine, Redis 8.10.3 reached 8–9% more requests per second than Valkey 9.1.3, with default settings and with io-threads 3. Setting io-threads 3 raised both by about 12%, more than the gap between them. With three I/O threads, Valkey’s first I/O thread was the bottleneck; Redis still had headroom on every thread.

Results

The number is the highest request rate the server reached while k6 kept raising it (it asked for up to 45,000 req/s). Each round ran three times per server, in the order Redis, Valkey, Valkey, Redis, Redis, Valkey. The table shows the median run. None of the 82.7 million requests across the 12 runs failed.

RoundRedis 8.10.3Valkey 9.1.3Gap
Default settings (io-threads 1)34,863 req/s32,227 req/sRedis +8.2%
io-threads 339,306 req/s36,079 req/sRedis +8.9%
Gain from io-threads 3+12.7%+12.0%

The three runs behind each median:

RoundRedisValkey
io-threads 131,916 / 36,399 / 34,86333,186 / 32,227 / 30,333
io-threads 339,713 / 38,988 / 39,30636,614 / 32,692 / 36,079

With default settings the runs overlap: Valkey’s best run (33,186) beat Redis’s worst (31,916), and Redis came out ahead in two of the three pairs. With io-threads 3 every Redis run was above every Valkey run.

Memory after loading the same million products, from INFO memory:

RedisValkey
used_memory236.0–236.1 MiB238.7–239.3 MiB

These numbers hold for this workload on one 4 vCPU machine, with no cluster, no replicas and default persistence (RDB snapshots, no AOF). They say nothing about cluster mode, replication, heavy write persistence or bigger machines.

The setup

Four DigitalOcean droplets in lon1 on one private network:

MachineSizeRuns
kv-dbc-4: 4 dedicated vCPU, 8 GBRedis or Valkey in Docker, one at a time, plus redis_exporter
kv-appc-16the Go app
kv-loadc-16k6
kv-mons-2vcpu-4gbPrometheus and Grafana

The app and load machines are oversized on purpose, so the only thing that runs out is the server. The server runs with --network host and only three flags: the private address, the port and --io-threads.

docker run -d --name kv --network host redis:8.10.3-trixie \
  redis-server --bind $KV_DB_IP --port 6379 --protected-mode no --io-threads 1

docker run -d --name kv --network host valkey/valkey:9.1.3-trixie \
  valkey-server --bind $KV_DB_IP --port 6379 --protected-mode no --io-threads 1

Valkey answers INFO server with redis_version:7.2.4 next to valkey_version:9.1.3; the second field is its real version.

The Redis 8 image loads its default modules (Search, JSON, Bloom, TimeSeries and vector sets). Nothing in the test calls them.

The app and the traffic

A small Go service with go-redis v9.23.0 and a pool of 200 connections. One k6 iteration is one user request, and the app turns it into two or three round trips to the server:

ShareHTTP requestCommands
60%GET /products/{id}HGETALL product:{id}, INCR views:{id}, ZINCRBY trending 1 {id}
20%GET /trendingZREVRANGE trending 0 9 WITHSCORES, then one pipeline of 10 HMGET
20%POST /cart/{user}HINCRBY cart:{user} {product} 1, EXPIRE cart:{user} 1800

Before each run the app loads 1,000,000 product hashes with 8 fields each, 1,000 per pipeline:

pipe.HSet(ctx, fmt.Sprintf("product:%d", j),
	"name", fmt.Sprintf("Product %d", j),
	"price", fmt.Sprintf("%d.%02d", 5+j%495, j%100),
	"category", cats[j%len(cats)],
	"stock", strconv.Itoa(j%250),
	"rating", fmt.Sprintf("%d.%d", 1+j%5, j%10),
	"brand", fmt.Sprintf("Brand %d", j%300),
	"sku", fmt.Sprintf("SKU-%08d", j),
	"description", "A product used in the Redis vs Valkey load test.")

k6 uses a ramping-arrival-rate executor, so it sends a fixed number of requests per second whatever the latency is. It starts at 10,000 req/s and adds 5,000 every 35 seconds (a 5 s ramp, then a 30 s hold) up to 45,000:

const stages = [];
for (let i = 0; i < STEPS; i++) {
  stages.push({ target: START + i * STEP, duration: '5s' });
  stages.push({ target: START + i * STEP, duration: HOLD });
}

export const options = {
  scenarios: { shop: { executor: 'ramping-arrival-rate', startRate: START, timeUnit: '1s',
                       preAllocatedVUs: 3000, maxVUs: 10000, stages } },
  discardResponseBodies: true,
  // no `url` tag: every /products/{id} would become its own Prometheus series
  systemTags: ['status', 'method', 'name', 'scenario', 'expected_response', 'check'],
};

Each step is measured over its last 25 seconds, from k6’s metrics in Prometheus.

Where latency broke

Peak throughput hides when each server stopped being fast. Here is p95 latency per step in the median runs:

k6 asked forRedis, io-threads 1Valkey, io-threads 1Redis, io-threads 3Valkey, io-threads 3
10,0000.7 ms0.7 ms0.8 ms0.7 ms
15,0000.8 ms0.8 ms0.9 ms0.7 ms
20,0001.7 ms117.2 ms1.3 ms1.0 ms
25,00014.1 ms67.1 ms2.2 ms1.3 ms
30,000119.4 ms304.6 ms8.1 ms137.9 ms
35,000274.0 ms283.1 ms152.2 ms239.6 ms

Below 20,000 req/s the two are the same within a tenth of a millisecond. The difference that matters for most deployments is how much traffic each one takes before p95 jumps from about a millisecond to over a hundred. With io-threads 3, Redis stayed under 10 ms up to 30,000 req/s in this run and Valkey up to 25,000. These are single runs, so read the exact step where each one broke as approximate. results/summary.json in the repository has every step of all 12 runs.

Threads: who ran out of what

pidstat -t on the server during each run shows CPU per thread. At the peak step:

RoundRedisValkey
io-threads 1main thread 93–96%main thread 97–98%
io-threads 3main 67–77%, I/O threads 69–80%main 91–96%, first I/O thread 99.6–99.9%
pidstat -t -u -h -p $(pgrep -x redis-server | head -1) 1

With one thread, both servers execute every command and do all the socket reads and writes on the main thread. One core sat near 100% and the other three were mostly idle.

io-threads moves reading requests and writing replies to extra threads; commands still run on the main thread. --io-threads 3 means the main thread plus two I/O threads. For a four-core machine, Redis’s sample config suggests 3 and Valkey’s suggests 2 or 3. With three, Valkey’s first I/O thread hit its ceiling, so Valkey had used up what this setting could give it. Redis did not max out any thread, so something outside Redis stopped it near 39,000 req/s. A Redis rerun with the app’s pool raised from 200 to 600 connections peaked at about the same rate. Its real limit on this machine is higher than the table shows.

The license

The bigger difference between them is legal, not speed:

Teams whose legal policy rules out AGPL and source-available code are left with Valkey. Where both are allowed, this test puts the trade at about 8–9% more throughput for Redis, with memory within about 1% of each other.

Run it yourself

Everything is in huseyinbabal/db-wars, redis-vs-valkey/: the app, the k6 script, the droplet setup scripts, the Grafana dashboard and the per-step results.

cd redis-vs-valkey/setup
./up.sh && ./provision.sh      # 4 droplets, tag dbwars5
cd ..
./batch.sh                     # 2 rounds x 3 runs per server, alternating order
python3 analyze.py             # results/summary.json
./setup/down.sh                # delete the droplets

A pull request that changes the app or ci/compose.yml (for example --io-threads 3) gets a short version of the same test on a GitHub runner, with the numbers in a PR comment. All comparisons in the series are on db-wars.huseyinbabal.dev.

Sources


Share this post:

Previous Post
One Express app on Node 26, Bun 1.4 and Deno 2.9: a load test with the same node_modules