Valkey 9.2 Forkless Snapshots: BGSAVE Without fork()

Valkey 9.2 Forkless Snapshots: BGSAVE Without fork()

The page comes at 3 a.m. Memory on the cache node went from 60% to 97% in under a minute, the OOM killer picked the biggest process, and the biggest process was your cache. Nothing changed in your application. What changed is that BGSAVE fired, and BGSAVE calls fork().

Valkey 9.2, currently at release candidate 1, ships an opt-in way to take an RDB snapshot without forking at all. It is the most interesting change in the release and the easiest to misunderstand, so here is what fork() was costing you, what the forkless path does instead, and where the trade-offs moved.

What fork() was costing you

The classic trick is elegant. The main process calls fork(), the child gets a copy-on-write view of memory, walks the keyspace and writes the RDB file while the parent keeps serving traffic. Nothing is copied until somebody writes.

The problem is the word "somebody". Copy-on-write works on pages, not keys. A 4 KB page holds hundreds of small values, and the first write to any of them copies the whole page. On a busy write workload the child is effectively holding a full second copy of every hot region, which is how a 20 GB instance ends up needing 35 GB during a save. Transparent huge pages make it worse: one write copies 2 MB.

There is a second cost people forget. fork() duplicates the page tables, roughly 2 MB per gigabyte of resident memory. On a 50 GB instance that is a 100 MB memcpy on the main thread with the event loop stopped. INFO shows it as latest_fork_usec, and on large instances it is routinely tens of milliseconds, sometimes hundreds. Every client sees that pause.

What Valkey 9.2 does instead

The forkless path replaces the child process with a background thread and an iterator. The thread walks the main dictionary and serializes keys into the RDB file while the main thread keeps running. There is no second address space, so there is nothing to copy-on-write.

The obvious question is how you get a consistent point-in-time snapshot when the main thread is still mutating the data the thread has not reached yet. The answer is that Valkey lets writers jump the queue.

Every key carries 4 bytes of iteration metadata when the infrastructure is enabled. When a write command arrives, the main thread checks the keys it touches against the iterator:

  • The key was already serialized: the write goes through, nothing to do.
  • The key is brand new: the write goes through, it does not belong in this snapshot.
  • The key has not been reached yet: the client is parked in a BLOCKED_INUSE state, the key is expedited to the front of the background thread's work list, and once it has been written to the file the client is unblocked and the command re-executes.

The snapshot is therefore a true point-in-time image of the keyspace as it existed when BGSAVE started. The price for that guarantee is paid per key, by the client that wanted to write it, and only if the iterator had not got there first.

A few edge cases are handled explicitly. DEL and UNLINK are treated as writes and expedited. MOVE and COPY get special handling because they touch two databases. FLUSHALL and FLUSHDB terminate a consistent save rather than trying to reconcile it. Lua scripts that do not declare their keys cannot be checked in advance, so they block the main thread synchronously and log a warning.

Fork vs forkless, side by side

Concernfork()Forkless
Peak memory during saveUp to 2x on write-heavy dataBaseline plus 4 bytes per key
Main-thread pause at startPage table copy, ms to hundreds of msNone
Write latency during savePage faults on first write per pageOccasional block until the key is serialized
Snapshot consistencyPoint in timePoint in time
Replica full syncYesNot in 9.2 RC1, still forks
Module data typesTransparentModule must opt in or save falls back to fork

That replica row matters. The iteration infrastructure has an "eventually consistent" mode designed for streaming a full sync to a replica, but 9.2 RC1 only wires forkless into BGSAVE and periodic saves. If your memory spikes come from replicas resyncing rather than from scheduled saves, this release does not fix that yet.

Turning it on

Two settings, and the order matters. The first is immutable and must be in valkey.conf before the server starts, because it changes the per-key allocation:

forkless-infrastructure-enabled yes
bgsave-default-method fork

Start the server, confirm it came up, then switch the method at runtime:

valkey-cli CONFIG SET bgsave-default-method forkless
valkey-cli BGSAVE
valkey-cli INFO persistence | grep -E "rdb_current_bgsave_type|rdb_last_bgsave_type|forkless_estimated_seconds_remaining|current_fork_perc"

Setting bgsave-default-method to forkless on a server that was started without the infrastructure flag is rejected outright, and CONFIG SET rolls back the whole command. While a save runs, rdb_current_bgsave_type reports forkless and forkless_estimated_seconds_remaining gives you an ETA; when idle it reads -1. rdb_bgsave_in_progress is 1 for either method now, so existing monitoring keeps working.

Watch INFO memory before and after enabling the infrastructure. Four bytes per key is small until you have 500 million keys, at which point it is 2 GB.

Where I would not run it yet

This is RC1, and a maintainer's audit pass on the release has already logged two rough edges in the forkless path. If the save cannot create its temp file, the failure is not recorded, so rdb_last_bgsave_status stays ok and stop-writes-on-bgsave-error never engages. And a shutdown during a save leaks the partial temp-forkless RDB file, one per restart. Neither is data loss, but the first silently removes a safety net.

My recommendation: enable it on a replica first, run your real write load against the primary, and compare used_memory_peak and p99 write latency across a few saves. Leave the primary on fork until 9.2 is GA and your replica has been boring for a couple of weeks.

Troubleshooting

CONFIG SET bgsave-default-method forkless returns an error. The server was started without forkless-infrastructure-enabled yes. It is immutable; add it to the config file and restart.

Some saves still report rdb_last_bgsave_type:fork. A loaded module registers a data type without the VALKEYMODULE_OPTIONS_HANDLE_FORKLESS_SAVE option, so Valkey falls back. Check MODULE LIST and ask the module author.

Clients show up blocked in CLIENT LIST during a save. That is the expedite mechanism doing its job on keys the iterator had not reached. If the same clients block on every save, they are writing a small hot set, and the wait should be milliseconds. Sustained blocking points at slow disk under the background thread.

Module commands fail with "-INUSE key is being processed". A module write hit a key mid-serialization and did not pass the flag that allows blocking. That is a module bug, not a Valkey one.

Save aborted right after FLUSHALL or FLUSHDB. Expected. A consistent snapshot cannot survive a flush under it; the next scheduled save will run normally.

Running Valkey on Elestio

If you would rather not babysit an RC, Valkey on Elestio starts from $11/month with automated backups, updates and monitoring handled for you, and you can pin the version until 9.2 is GA. When it lands, the two config lines above are all the migration there is.

Thanks for reading ❤️