Skip to content

RDB and AOF

Verified on Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. The # cli/... transcript is replayed by the course harness; the console blocks are real docker exec sessions against my own throwaway container with a volume on /data (they change files, so they are not replayed). Persistence reference: Redis persistence docs.

RDB writes a point-in-time snapshot of the whole dataset to one compact binary file (dump.rdb). On startup Redis loads it, which makes RDB the fastest way back up after a restart.

Manual: SAVE writes the file on the command thread (everyone waits); BGSAVE forks a child that writes it while the parent keeps serving. Automatic: save <seconds> <changes> rules; a snapshot is taken when at least <changes> keys changed within <seconds>. These are the defaults of the official image, straight from the server, together with the answers that decide the next sections:

cli/persistence-expiration-rdb-aof-1.redis
127.0.0.1:6379> CONFIG GET save
1) "save"
2) "3600 1 300 100 60 10000"
127.0.0.1:6379> CONFIG GET appendonly
1) "appendonly"
2) "no"
127.0.0.1:6379> CONFIG GET aof-use-rdb-preamble
1) "aof-use-rdb-preamble"
2) "yes"
127.0.0.1:6379> CONFIG GET dbfilename
1) "dbfilename"
2) "dump.rdb"
127.0.0.1:6379> SET greeting hello
OK
127.0.0.1:6379> BGSAVE
Background saving started
127.0.0.1:6379> LASTSAVE
(integer) 1790750454

So a fresh image snapshots on the 3600 1 300 100 60 10000 rules, has no AOF (appendonly no), and would use an RDB preamble if you turned AOF on. BGSAVE replies at once with Background saving started; the save finishes later, so check for completion rather than assuming it:

src/persistence-expiration-rdb-aof-bgsave.ts
// @run
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
// INFO returns "field:value" lines; turn a section into an object
async function info(section: string) {
const out: Record<string, string> = {};
for (const line of (await redis.info(section)).split('\r\n')) {
const i = line.indexOf(':');
if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
}
return out;
}
await redis.set('greeting', 'hello');
const before = Number((await info('persistence')).rdb_saves);
console.log(await redis.bgsave()); // Background saving started
// BGSAVE only STARTS the save: poll until it finished, then check the status
let p = await info('persistence');
while (p.rdb_bgsave_in_progress === '1' || Number(p.rdb_saves) === before) {
await new Promise((r) => setTimeout(r, 50));
p = await info('persistence');
}
console.log('rdb_last_bgsave_status:', p.rdb_last_bgsave_status); // ok
console.log('changes since last save:', p.rdb_changes_since_last_save); // 0
console.log('LASTSAVE matches INFO:', (await redis.lastsave()) === Number(p.rdb_last_save_time)); // true
await redis.quit();

Real output (this Mac):

Background saving started
rdb_last_bgsave_status: ok
changes since last save: 0
LASTSAVE matches INFO: true

LASTSAVE is a Unix timestamp in seconds, so two saves within the same second are indistinguishable; the loop above watches rdb_saves and rdb_bgsave_in_progress from INFO persistence instead.

config/persistence-expiration-rdb-aof-save.conf
save 3600 1
save 300 100
save 60 10000
dbfilename dump.rdb
dir /data

Those three save rules mean: hourly if at least 1 key changed, every 5 minutes if 100 changed, every minute if 10,000 changed. save "" disables automatic snapshots. The cost of RDB is the window: anything written after the last snapshot is lost on a crash.

I loaded 1,000,000 small keys, ran SAVE from one client and sent a PING from another 20 ms later:

src/persistence-expiration-rdb-aof-save-blocks.ts
// @run
import { Redis } from 'ioredis';
const a = new Redis(process.env.REDIS_URL!);
const b = new Redis(process.env.REDIS_URL!);
await a.flushall();
await a.eval("for i=1,1000000 do redis.call('SET','k:'..i,'value-'..i) end return 1", 0); // 1M small keys
await b.ping();
// SAVE runs on the command thread: a second client's PING waits for it
const t0 = performance.now();
const saving = a.save();
await new Promise((r) => setTimeout(r, 20));
const t1 = performance.now();
await b.ping();
const waited = performance.now() - t1;
await saving;
console.log(`SAVE took ${(performance.now() - t0).toFixed(0)} ms; a PING sent 20 ms later waited ${waited.toFixed(0)} ms`);
// BGSAVE forks: the reply is immediate and other clients keep being served
await new Promise((r) => setTimeout(r, 300));
const t2 = performance.now();
console.log(await a.bgsave(), `in ${(performance.now() - t2).toFixed(1)} ms`);
const t3 = performance.now();
await b.ping();
console.log(`PING right after BGSAVE: ${(performance.now() - t3).toFixed(1)} ms`);
await new Promise((r) => setTimeout(r, 500));
await a.flushall();
a.disconnect();
b.disconnect();

Real output (this Mac):

SAVE took 259 ms; a PING sent 20 ms later waited 236 ms
Background saving started in 7.3 ms
PING right after BGSAVE: 1.2 ms

While SAVE ran, the second client’s PING waited 236 ms for a server that was busy writing. BGSAVE returned in single-digit milliseconds and the next PING took 1.2 ms. The fork is not free (it copies page tables and can add latency spikes on very large datasets, see the latency docs), but the write itself stays off the command thread.

With appendonly yes Redis appends every write command, in the wire format, to a log and replays it on restart. appendfsync controls how often the OS is told to flush to disk: always (after every command; safest, slowest), everysec (once per second; you can lose about a second; the default) and no (the OS decides).

config/persistence-expiration-rdb-aof-aof.conf
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec
aof-use-rdb-preamble yes

Since Redis 7.0 the AOF is a directory of files (multi-part AOF), not one file. I enabled AOF on a running container, wrote a key and ran a rewrite:

Terminal window
$ docker exec redis-b2-persist ls -A /data
(nothing yet: no snapshot, no AOF)
$ docker exec redis-b2-persist redis-cli SET greeting hello
OK
$ docker exec redis-b2-persist redis-cli CONFIG SET appendonly yes
OK
$ docker exec redis-b2-persist ls -l /data/appendonlydir
-rw------- 110 appendonly.aof.1.base.rdb
-rw------- 0 appendonly.aof.1.incr.aof
-rw------- 102 appendonly.aof.manifest
$ docker exec redis-b2-persist cat /data/appendonlydir/appendonly.aof.manifest
file appendonly.aof.1.base.rdb seq 1 type b
file appendonly.aof.1.incr.aof seq 1 type i startoffset 0
$ docker exec redis-b2-persist head -c 9 /data/appendonlydir/appendonly.aof.1.base.rdb
REDIS0015
$ docker exec redis-b2-persist redis-cli SET more 1
OK
$ docker exec redis-b2-persist cat /data/appendonlydir/appendonly.aof.1.incr.aof
*2
$6
SELECT
$1
0
*3
$3
SET
$4
more
$1
1
$ docker exec redis-b2-persist redis-cli BGREWRITEAOF
Background append only file rewriting started
$ docker exec redis-b2-persist ls /data/appendonlydir
appendonly.aof.2.base.rdb
appendonly.aof.2.incr.aof
appendonly.aof.manifest
$ docker exec redis-b2-persist cat /data/appendonlydir/appendonly.aof.manifest
file appendonly.aof.2.base.rdb seq 2 type b
file appendonly.aof.2.incr.aof seq 2 type i startoffset 1

Read the listing like this:

  • appendonly.aof.1.base.rdb is the base: a snapshot of the dataset at the moment AOF was switched on. It starts with the bytes REDIS0015, so it is an RDB file.
  • appendonly.aof.1.incr.aof is the incremental log of commands since the base, in the plain wire format shown (*3 $3 SET $4 more $1 1 is SET more 1).
  • appendonly.aof.manifest lists the parts and their sequence numbers.
  • BGREWRITEAOF did not rewrite a giant file: it wrote a new base (2), started a fresh incr file (2) and updated the manifest.

redis-check-aof (in the image) validates the whole set, and redis-check-rdb does the same for a snapshot:

Terminal window
$ docker exec redis-b2-persist redis-check-aof /data/appendonlydir/appendonly.aof.manifest (trimmed)
Start checking Multi Part AOF
Start to check BASE AOF (RDB format).
[offset 0] Checking RDB file /data/appendonlydir/appendonly.aof.4.base.rdb
[offset 143] Checksum OK
RDB preamble is OK, proceeding with AOF tail...
BASE AOF appendonly.aof.4.base.rdb is valid
Start to check INCR files.
INCR AOF appendonly.aof.5.incr.aof is valid
All AOF files and manifest are valid

(File numbers are higher here because the container had run more rewrites and backups by then.)

Hybrid persistence is what you already get

Section titled “Hybrid persistence is what you already get”

“Hybrid” means the AOF base is written in RDB format (fast to load) and only the tail is a command log. The setting is aof-use-rdb-preamble, and it is yes by default since Redis 5.0: the shipped redis.conf says aof-use-rdb-preamble no in 4.0 (with the comment that it will “at some point be used as the default”) and yes in 5.0.0. On 8.10.2 CONFIG GET aof-use-rdb-preamble is yes, and the .base.rdb above is the result. It applies whenever AOF is on; you do not need RDB snapshots to be on as well. (The earlier text of this lesson said 7.0; 7.0 only changed the file layout to multi-part.)

Copying dump.rdb or the appendonlydir by hand was always fiddly (you must stop rewrites or copy at the right moment). Redis 8.10.0 added the BACKUP family (docs): BACKUP START takes a fresh base into backupdirname (default backupdir, under dir), BACKUP LIST shows the pinned files, BACKUP SEAL freezes the incremental part and writes a manifest, BACKUP CLEANUP releases the files, BACKUP STATUS / BACKUP ABORT inspect and cancel. It works while writes continue:

Terminal window
127.0.0.1:6379> BACKUP STATUS
1) "state"
2) "idle"
3) "error"
4) ""
5) "start_time"
6) (integer) 0
7) "end_time"
8) (integer) 0
127.0.0.1:6379> BACKUP START
OK
127.0.0.1:6379> BACKUP LIST
1) "/data/backupdir/appendonly.aof.4.base.rdb"
127.0.0.1:6379> SET written-during-backup 1
OK
127.0.0.1:6379> BACKUP SEAL
OK
127.0.0.1:6379> BACKUP LIST
1) "/data/backupdir/appendonly.aof.4.base.rdb"
2) "/data/backupdir/appendonly.aof.5.incr.aof"
3) "/data/backupdir/appendonly.aof.manifest"
127.0.0.1:6379> BACKUP CLEANUP
OK
127.0.0.1:6379> BACKUP STATUS
1) "state"
2) "idle"
3) "error"
4) ""
5) "start_time"
6) (integer) 0
7) "end_time"
8) (integer) 0

The SET written after START is inside the sealed backup (the incr file appears in the second LIST). After SEAL you copy the three listed files to your storage, then CLEANUP. To restore, point the startup-only preload-file aof:/path/to/appendonly.aof.manifest at the manifest. This needs 8.10 or newer; on older servers use BGSAVE plus copying the file, or the documented “disable rewrites, copy, re-enable” AOF procedure. I did not test preload-file.

An assistant adds an admin endpoint “to persist Redis now”:

🐛 Spot the bug

app.post('/admin/persist', async (_req, res) => {
await redis.save(); // write dump.rdb now
res.json({ ok: true });
});
Show the answer

SAVE runs on the command thread. With 1,000,000 keys it took 259 ms, during which every other client, including the site’s own requests, was stuck (a PING waited 236 ms). At production sizes that is seconds of outage per click. BGSAVE forks and replies in 7 ms. Then check the outcome instead of assuming it:

await redis.bgsave(); // "Background saving started"
// poll INFO persistence: rdb_bgsave_in_progress === '0' and rdb_last_bgsave_status === 'ok'

The complete poll loop is the runnable persistence-expiration-rdb-aof-bgsave.ts above.

Persistence is two different promises. RDB promises a consistent picture as of some past moment, cheap to store and fast to reload. AOF promises a running record of changes, and how much of the record survives a crash depends on appendfsync. Modern Redis stores the AOF as a picture plus a short record after it, which is why you get fast restarts and small loss windows together.

ChoiceBenefitCost
RDB onlySmall files, fastest restart, good for backupsLoses everything since the last snapshot; BGSAVE fork can spike latency on huge datasets
AOF (everysec)About one second of exposureBigger files, replay time, fsync work
AOF (always)Smallest loss windowSlowest writes
Hybrid base (default)RDB-speed loading with a command-log tailTwo file formats in one directory to understand
SAVESimple, synchronousBlocks all clients (259 ms at 1M keys)
BACKUP (8.10+)Consistent online backup with a manifestNeeds 8.10; you still copy the files off the host
  • “AOF is a single file.” Since 7.0 it is a directory: base + incr + manifest.
  • “Hybrid mode arrived in 7.0.” aof-use-rdb-preamble yes has been the default since 5.0.
  • “BGSAVE returning means the data is safe.” It means the save started; check rdb_last_bgsave_status.
  • “Hybrid needs both RDB and AOF enabled.” The preamble applies whenever appendonly yes.
  • “AOF with everysec never loses data.” It can lose about a second.
  • SAVE on a live server — use BGSAVE.
  • RDB only, expecting high durability — the window is the save interval.
  • Never testing a restore — run redis-check-aof/redis-check-rdb and actually start a server from the copy.
  • Copying files by hand while a rewrite runs — use the BACKUP family (8.10+) or the documented procedure.

Real-world example

Redis’s own documentation compares RDB and AOF, explains the multi-part AOF and the RDB preamble, and documents the BACKUP commands added in 8.10 (Redis persistence).

Which command triggers a background RDB snapshot without blocking the server?
What does setting appendonly yes enable?
What is the default filename Redis uses for RDB snapshots?
Which appendfsync value flushes the AOF buffer to disk once per second?
You turn AOF on with defaults on Redis 8.10.2. What does /data/appendonlydir contain?
Since which Redis version is aof-use-rdb-preamble yes the default?
What is the right order for an online backup with the 8.10 BACKUP family?