mailwal

Building a Redis Clone in Zig—Part 6

Charles Fonseca' Substack

Building a Redis Clone in Zig—Part 6

Zedis 0.2.x, the best release yet. TCP server is finally sorted out, fixed server to not crash under memory pressure

Oct 23

In this release, I rolled a new hash map made for Zedis. I tried a multithreaded approach, a single global lock, batched locks; no matter what, locks are bad for a system like an in-memory KV store. I even tried shared-nothing using a sharded hash map like DragonflyDB does. It’s the most elegant and fastest approach by far, but I still don’t understand it 100%, so I decided to leverage single-threaded async I/O using io_uring/kqueue to the max. Perhaps in the future, when I’ve studied the papers more, I can implement shared-nothing.

Rafael Batiati from TigerBeetle, a friend of mine and one of the best developers I’ve ever met, once said: “make sure to take the most out of your thread before you use another one.” It took me a while to understand this.

Thanks for reading! Subscribe for free to receive new posts and support my work.

What changed

The TCP server is far more resilient and elegant. It now keeps serving even when the store’s memory budget is exhausted and under extremely high request rates. I also decided not to support some features Redis does. I think logical databases were a terrible mistake, and Redis having no memory limit by default is a big design flaw.

Custom ‘hash map’: Zedis uses a custom open-addressing hash map. It's not general-purpose tough, it was made strictly for Zedis’ architecure. The previous implementation grew the entry array linearly, which meant every resize was an O(n) copy and insert throughput fell off a cliff past 1M keys. The new table grows geometrically, capacity doubles on each resize.

Zero-copy command parsing: Commands that arrive fully buffered are now parsed in a single pass without allocating. Arg slices point directly into the socket read buffer. The previous 64 KB inline-value limit is gone; bulk strings up to 512 MB are accepted.

AOF writer thread with group commit: It runs on a background thread, so it doesn't block the commands. Under appendfsync always, concurrent writes are batched into a single fsync.

Correct AOF replay for expiring keys: EXPIRE, SETEX, and PSETEX are rewritten to PEXPIREAT with an absolute timestamp before being logged. Expiry times now survive restart correctly.

MISCONF on AOF failure: If the AOF writer encounters a write or fsync error, subsequent write commands are rejected with -MISCONF, matching Redis behaviour.


Execution model

This is the single most important thing on a server. It decides whether you use a spinlock, hand work off to another thread, let it hop between cores, and how many cache lines you burn.

The previous model was a pool of executors and one global store lock. Every command acquired that lock, and the coroutines bounced between cores on every command. I tried several lock-free implementations, but they’re pretty hard to do.

Using .exact(1) hit ~2.66M rps on ~0.75 cores, .auto hit ~1.2M rps on ~4.5 cores, and one executor was ~35% faster even without pipelining. The command thread never blocks; AOF and clock maintenance run off the command path on background threads.

Zero-copy command parsing

A SET foo bar looks like this on the network:

*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n

The old parser read this byte by byte from the socket, allocating a copy of each argument (SET, foo, bar) into an arena allocator. On every single command. In a pipelined batch of 64 commands that’s 192 allocations before any actual work happens.

The new parser checks first: is the entire command already sitting in the 16 KB read stack buffer? If so, which is almost always the case for pipelined traffic, it parses in one linear scan and returns slices that point directly into that buffer:

read buffer: *3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n...next cmd...
                         ^^^          ^^^          ^^^
                        arg[0]       arg[1]        arg[2]
                        (no copy)    (no copy)     (no copy)

The buffer stays pinned until the response is written, then the pointers are simply abandoned. For a pipelined batch of 64 commands, zero allocations happen until the batch is done.

The only time it falls back to allocating is when a command spans a buffer refill, for example, a value larger than 16 KB arriving in multiple TCP segments. In that case the old path runs as before.

This also removed the previous 64 KB argument size limit, which was an artifact of a fixed stack buffer. The new limit is 512 MB, matching Redis.

What happens when there's no memory anymore

Redis has no memory limit by default. I think that’s a mistake, so Zedis always has a budget, by default it's either 80% of the available memory if running on a container minus the fixed pool, with a 64 MB floor. However I strongly believe that you should set a memory limit for your workload.

The allocator checked the budget on every allocation, and when it ran out it evicted a key immediately. But the allocation often came from a command that was halfway through writing to the hash map, sometimes targeting the very key being evicted. With allkeys-lru and a single key, that’s a use-after-free. Multikey commands are also tricky. In the previous implementation, if an allocation failed during LPUSH, it would lead to undefined behaviour, it could have failed on the second element of the list, or the first, you could never know.

Persistence: AOF and RDB

Zedis supports two persistence mechanisms, same as Redis, but I'd like to go over AOF (Append-Only File). It is nothing more than a command log. Every write, SET, LPUSH, EXPIRE, is appended to a file before the client gets a reply. On restart, Zedis replays the log from top to bottom to reconstruct the store. You lose nothing, but the file grows over time and replay takes longer as it does.

The appendfsync setting controls the durability guarantee:

  • no: let the OS flush whenever it wants. Fastest, but up to a few seconds of data loss on a hard crash.

  • everysec: fsync once per second. At most one second of data loss. Redis’s default.

  • always : fsync after every write. Zero data loss, but the client waits for the disk.

always was unusable before this release. Every write blocked the event loop on a disk fsync, one command at a time, waiting for the disk to spin. Throughput was ~450 rps. Now nothing blocks the TCP serving loop. Every client waiting on that batch gets their acknowledgement at once. This is group commit, the same technique databases like PostgreSQL use for WAL writes.

client A ──[queue SET]──────────────────────[wait]──[ack]──▶
client B ──────────[queue LPUSH]────────────[wait]──[ack]──▶
client C ──────────────────[queue SET]──────[wait]──[ack]──▶
                                               ↓
writer thread ────────────────────────[write A+B+C][fsync][notify all]

One fsync covers all three clients. Throughput under apendfsync always: 450 → 190k rps (420×). This is not exactly a performance gain, I just didn't know better.

This release also fixed a correctness bug: EXPIRE and SETEX were being logged with relative TTLs, what makes no sense. If you set a key to expire in 60 seconds and replayed the AOF 30 seconds later, the key would get 60 more seconds instead of 30.

Numbers

Benchmark command: redis-benchmark -n 100000 -c 100 -P 32

Linux

Ubuntu 26.04.1 LTS, Linux 7.0.0-34-generic, i7-11800H, 32GB RAM.

|                          | Redis 8.8.1 | Zedis 0.2.x |
| ------------------------ | ----------- | ----------- |
| SET (100c -P32)          | 990k rps    | 1,389k rps  |
| GET (100c -P32)          | 1,064k rps  | 1,613k rps  |
| SET p50                  | 2.9ms       | 2.2ms       |
| GET p50                  | 2.3ms       | 2.0ms       |
| GET max latency          | 6.3ms       | 2.9ms       |
| AOF `appendfsync always` | ~200k rps   | ~190k rps   |

macOS

MacBook M4 Pro

|                 | Redis 8.8.1 | Zedis 0.2.x |
| --------------- | ----------- | ----------- |
| SET (100c -P32) | 1,000k rps  | 2,000k rps  |
| GET (100c -P32) | 1,667k rps  | 2,222k rps  |
| SET p50         | 2.9ms       | 1.1ms       |
| GET p50         | 1.8ms       | 1.2ms       |
| SET p99         | 3.9ms       | 1.5ms       |
| GET p99         | 2.9ms       | 1.6ms       |
| SET max         | 4.1ms       | 1.5ms       |
| GET max         | 3.0ms       | 1.6ms       |

Devil’s advocate

Redis has many more features than Zedis: cluster mode, ACL, TLS, replication, modules, AOF rewrite, sets, hashes and sorted sets all cost something. It also has a decade of production hardening that Zedis doesn’t.

Thanks for reading! Subscribe for free to receive new posts and support my work.

© 2026 Charles Fonseca
548 Market Street PMB 72296, San Francisco, CA 94104