Redis Persistence — The AOF Story

Let’s talk about Redis persistence. For traditional relational databases like PostgreSQL, we are very familiar with their persistence mechanisms. If Redis is used purely as a cache, persistence is somewhat optional—after all, a cache is all about speed, and enabling persistence means talking to disk, which certainly affects performance. But from another angle, when Redis shuts down abnormally without persistence enabled, all cached data is lost, and rebuilding the cache takes time. With persistence, that rebuild is much faster. So whether to use persistence really depends on the business scenario. Redis leaves the choice of whether to enable persistence and which mechanism to use entirely up to the user.

Persistence mechanisms

Redis supports two persistence approaches: RDB and AOF. The former stores the in-memory data on disk according to configured rules (it can be triggered on a schedule, when the process exits normally, or via the SAVE | BGSAVE commands). The latter records the commands themselves after each execution. AOF is the latter.

How AOF works

The downside of RDB is that if Redis shuts down abnormally, upon restart it loses everything written after the last RDB snapshot was taken. How to solve this? The database needs to record every write operation and its effect on the data. That way, even if the database shuts down abnormally, as long as we replay the recorded writes on restart, the data can be recovered. In PostgreSQL this mechanism is WAL; in Redis it is AOF. When an executed command (a write command) modifies the database or changes its state, Redis writes that command into a file—the AOF file. Unlike PostgreSQL, Redis records the commands.

Why does Redis record commands, while PostgreSQL records page-level modifications? The key insight is that both are redo mechanisms whose purpose is to recover database state by replaying the log. In Redis, the full dataset lives in memory, and its key data structure is the dict. Replaying the commands restores the database to its previous state. PostgreSQL cannot do this—its data lives on disk, organized and stored through data pages.

This also raises another question: Redis master-replica synchronization. When a Redis node crashes, restarting it goes through a data-recovery phase. If the dataset is large, this phase takes a while. How to reduce its impact on the business? You can set up one master with one replica: when the master fails, the replica is promoted to master, reducing the business impact. How do the master and replica stay in sync then? Via Redis’s AOF and RDB.

How to enable AOF?

You enable AOF persistence with the appendonly yes config option. Once enabled, every write command Redis executes is written to the AOF file on disk. We can run a few write commands and inspect the AOF file:

postgres@slpc:~/redis/data$ tail -f appendonly.aof 
*2
$6
SELECT   the reason there is a "select 0" here is that Redis must record which database the command was executed in
$1
0
*3
$3
set
$7
beijing
$8
kingbase

You can see the AOF file records Redis’s write commands as plain text—its content is exactly the raw communication protocol the Redis client sends to the server.

When Redis shuts down abnormally and restarts, on startup it executes the commands in the AOF file one by one to load the on-disk data back into memory and recover it. Loading is somewhat slower than RDB. Here is Redis’s startup log:

18403:C 22 May 2025 16:33:25.588 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
18403:C 22 May 2025 16:33:25.588 # Redis version=6.2.18, bits=64, commit=ee4d13ab, modified=0, pid=18403, just started
18403:C 22 May 2025 16:33:25.588 # Configuration loaded
18403:M 22 May 2025 16:33:25.590 * monotonic clock: POSIX clock_gettime
18403:M 22 May 2025 16:33:25.591 * Running mode=standalone, port=6379.
18403:M 22 May 2025 16:33:25.591 # Server initialized
18403:M 22 May 2025 16:33:25.592 * DB loaded from append only file: 0.000 seconds  data loaded from the AOF file
18403:M 22 May 2025 16:33:25.592 * Ready to accept connections  server is ready and can accept connections

When the database shuts down, Redis flushes the AOF file and saves an RDB file. Here is Redis’s shutdown log:

16224:signal-handler (1747901694) Received SIGINT scheduling shutdown...   received SIGINT, exiting the process
16224:M 22 May 2025 16:14:54.922 # User requested shutdown...
16224:M 22 May 2025 16:14:54.922 * Calling fsync() on the AOF file.   flush the AOF file, force it to disk
16224:M 22 May 2025 16:14:54.922 * Saving the final RDB snapshot before exiting.   save the RDB file
16224:M 22 May 2025 16:14:54.923 * DB saved on disk
16224:M 22 May 2025 16:14:54.923 * Removing the pid file.    
16224:M 22 May 2025 16:14:54.923 # Redis is now ready to exit, bye bye...

AOF file rewriting

The AOF file keeps recording write commands and grows over time. How to solve or mitigate this? Rewrite the AOF file. To replay commands and recover the final database state, what matters is the final value of every key—the intermediate states are unnecessary, so we can record only the final state of each key, not the intermediate ones. We can rewrite the AOF file: for example, set mykey 1, set mykey 2—only the last set mykey 2 needs to be kept. This greatly compresses the AOF file size.

Source code analysis

Here we analyze AOF from two angles: writing and replaying the AOF file. These are the two most core AOF processes.

Writing the AOF file

Let’s look at the source implementation related to AOF. First, how does Redis record the AOF file? After the server receives a client command, it executes the command, ensures it succeeds, writes it to the AOF file, and finally returns success to the client. The AOF file must contain only write commands that executed successfully.

This requires determining whether a command is a write command and whether it executed successfully. We analyzed a command’s execution flow earlier; let’s review it:

readQueryFromClient(connection *conn)
--> connRead(c->conn, c->querybuf+qblen, readlen)  // read data from the socket into the buffer
    --> conn->type->read(conn, buf, buf_len);
--> processInputBuffer(c);    // parse the Redis protocol, store the command in the client's argv array
    --> processInlineBuffer(c)   // handle inline commands, create argument objects
        --> sdsfreesplitres(argv,argc);
    --> processMultibulkBuffer(c) // convert the protocol content in c->querybuf into argument objects in c->argv
    --> processCommandAndResetClient(c)        // execute the command and return the result
        --> processCommand(c)
            --> call(c,CMD_CALL_FULL);
                --> c->cmd->proc(c);  // execute the concrete command
                --> determine whether it is a write command and whether it should be written to the AOF file
                --> propagate(c->cmd,c->db->id,c->argv,c->argc,propagate_flags); // write the command into the AOF file
                    --> feedAppendOnlyFile(cmd,dbid,argv,argc);
                        --> catAppendOnlyGenericCommand(buf,argc,argv); // convert the command into a RESP protocol string
                        --> server.aof_buf = sdscatlen(server.aof_buf,buf,sdslen(buf)); // append the command string to the AOF buffer

Taking the set command as an example, after the write to the database completes, server.dirty++ is called. Other data types and commands are similar—any change to the database state calls server.dirty++. When judging whether a command should be recorded in the AOF file, we can rely on this.

setCommand(client *c)
--> setGenericCommand(c,flags,c->argv[1],c->argv[2],expire,unit,NULL,NULL)
    --> genericSetKey(c,c->db,key, val,flags & OBJ_KEEPTTL,1);
        --> dbAdd(db,key,val);
            --> dictAdd(db->dict, copy, val); // add an element to the target hash table
                --> dictAddRaw(d,key,NULL);
                --> dictSetVal(d, entry, val);
    --> server.dirty++;     // increment the database's dirty counter    

Once we determine it is a write command, we can run the write flow:

+-------------------+      +-------------------+      +-------------------+
|   Client Command  | ---> |   Execute Command | ---> |   Append to AOF   |
|   (e.g., SET key) |      |   (Modify Data)   |      |   Buffer (Memory)  |
+-------------------+      +-------------------+      +-------------------+
                                                           |
                                                           v
+-------------------+      +-------------------+      +-------------------+
|  appendfsync      | <--> |  Flush to Disk    | <--> |   AOF File        |
|  (always/everysec/no) |  |  (fsync)         |      |   (on Disk)       |
+-------------------+      +-------------------+      +-------------------+
                                                           |
                                                           v
                                                     (AOF Rewrite)
                                                           |
                                                           v
                                                   +-------------------+
                                                   |   New AOF File    |
                                                   |   (Compressed)    |
                                                   +-------------------+

Let’s look further at the implementation of the call function:

void call(client *c, int flags) 
{
    long long dirty = server.dirty;  // save the number of DB modifications since the last RDB, used to compute modifications after this command
    
    c->cmd->proc(c);  // execute the concrete command

    dirty = server.dirty-dirty;  // compute the number of DB modifications after this command
    if (dirty < 0) dirty = 0;    // the modification count cannot be negative

    /* Propagate the command into the AOF and replication link */
    if (flags & CMD_CALL_PROPAGATE &&
        (c->flags & CLIENT_PREVENT_PROP) != CLIENT_PREVENT_PROP)
    {
        int propagate_flags = PROPAGATE_NONE;

        /* Check if the command operated changes in the data set. If so
         * set for replication / AOF propagation. */
        if (dirty) propagate_flags |= (PROPAGATE_AOF|PROPAGATE_REPL); // if the command modified the dataset, set PROPAGATE_AOF and PROPAGATE_REPL, meaning the command should be written to the AOF file and synced to replicas

        /* If the client forced AOF / replication of the command, set
         * the flags regardless of the command effects on the data set. */
        if (c->flags & CLIENT_FORCE_REPL) propagate_flags |= PROPAGATE_REPL;
        if (c->flags & CLIENT_FORCE_AOF) propagate_flags |= PROPAGATE_AOF;

        /* Call propagate() only if at least one of AOF / replication
         * propagation is needed. Note that modules commands handle replication
         * in an explicit way, so we never replicate them automatically. */
        if (propagate_flags != PROPAGATE_NONE && !(c->cmd->flags & CMD_MODULE))
            // note the parameters: cmd is the currently executed command, dbid is the current DB id, argv are the command arguments, argc is the argument count, propagate_flags is the propagation flag
            propagate(c->cmd,c->db->id,c->argv,c->argc,propagate_flags); // write the command to the AOF file and sync it to replicas
    }
}

The propagate function calls feedAppendOnlyFile, which converts the command into a RESP protocol string and writes it into the server.aof_buf buffer. Because the command ultimately goes to a file, this step is essentially a serialization/encoding operation.

At this point, writing the command into the AOF file is just one step away from flushing to disk: writing the contents of the server.aof_buf buffer into the actual disk file. This means dealing with I/O. To minimize the performance impact of writing the AOF file, Redis does not write directly to disk; instead it leaves the choice of when to flush to disk up to the user. Three modes are supported:

  • AOF_FSYNC_NO: the OS decides when to flush
  • AOF_FSYNC_EVERYSEC: flush once per second
  • AOF_FSYNC_ALWAYS: flush on every write Pick according to your business scenario.

Let’s look at the concrete write process:

main(int argc, char **argv)
--> initServer();       // server initialization, create databases, etc.
--> aeCreateEventLoop(server.maxclients+CONFIG_FDSET_INCR);  // event loop initialization
--> aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL)  // create the timed task that handles many background jobs, e.g. expiring keys, writing the AOF log, etc.
--> aeMain(server.el);
    while (!eventLoop->stop)
        aeProcessEvents(eventLoop, AE_ALL_EVENTS | AE_CALL_BEFORE_SLEEP | AE_CALL_AFTER_SLEEP);
        --> eventLoop->beforesleep(eventLoop);  // called before entering the event loop
        --> aeApiPoll(eventLoop, tvp)
        --> eventLoop->aftersleep(eventLoop);  // called after entering the event loop

Before each entry into the event loop, the function beforeSleep is called, and inside it flushAppendOnlyFile is called to write the contents of server.aof_buf to the actual disk file.

void beforeSleep(struct aeEventLoop *eventLoop)
{
    // ... 
    /* Write the AOF buffer on disk */
    if (server.aof_state == AOF_ON)
        flushAppendOnlyFile(0);
    // ...
}

Meanwhile, in Redis’s background timed task, flushAppendOnlyFile is also called to write the contents of server.aof_buf to the actual disk file.

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData)
{
    if (server.aof_state == AOF_ON && server.aof_flush_postponed_start)
        flushAppendOnlyFile(0);     // write the AOF buffer contents to the actual disk file
    // ...
}

So what does the flushAppendOnlyFile function do?

  1. It calls aofWrite(server.aof_fd,server.aof_buf,sdslen(server.aof_buf));, which calls write to write the file—but this does not immediately hit disk; it goes into the kernel buffer.
  2. Depending on the user-set policy, it decides when to fsync, writing the kernel buffer’s data to the disk file.
/* Write the append only file buffer on disk.
 *
 * Since we are required to write the AOF before replying to the client,
 * and the only way the client socket can get a write is entering when the
 * event loop, we accumulate all the AOF writes in a memory
 * buffer and write it on disk using this function just before entering
 * the event loop again.
 */
void flushAppendOnlyFile(int force)
{
    ssize_t nwritten = aofWrite(server.aof_fd,server.aof_buf,sdslen(server.aof_buf));

    /* Perform the fsync if needed. */
    if (server.aof_fsync == AOF_FSYNC_ALWAYS)   // if always, fsync immediately
        redis_fsync(server.aof_fd)
    else if ((server.aof_fsync == AOF_FSYNC_EVERYSEC && server.unixtime > server.aof_last_fsync)) // if everysec, fsync once per second
        aof_background_fsync(server.aof_fd); // Starts a background task that performs fsync() 
    // if no, leave the fsync to the operating system
}

This concludes the AOF log write process.

Replaying the AOF log

On startup, Redis first checks whether the AOF file exists; if so, it loads the AOF file and recovers the data.

main(int argc, char **argv)
--> initServer();           // initialize the server, create databases, etc.
--> loadDataFromDisk();     // load (RDB, AOF file) data
    {
        if (server.aof_state == AOF_ON)
            loadAppendOnlyFile(server.aof_filename);  // replay the AOF log file, recover data
        else
            rdbLoad(server.rdb_filename,&rsi,RDBFLAGS_NONE);
    }
--> aeMain(server.el);      // event loop, process client requests

How do we actually replay the commands in the AOF log file?

  1. Create a fake client without a network connection: Redis commands must execute in the context of a client, so we need a fake client. Why “fake”? Because it is not a client established from a real client connection.
  2. Read the AOF file and reconstruct the command and its arguments and count: the file stores RESP protocol strings, so we must parse the RESP protocol to reconstruct the command, its arguments, and the count.
  3. Execute the command using the fake client.
  4. Keep reading the AOF file until all its commands have been executed.

Let’s look at the implementation of loadAppendOnlyFile:

int loadAppendOnlyFile(char *filename) {
    struct client *fakeClient;
    FILE *fp = fopen(filename,"r");  // open the AOF log file
    // in Redis, commands must execute in a client context, so create a fake client without a network connection to simulate executing the commands in the AOF
    fakeClient = createAOFClient();
    startLoadingFile(fp, filename, RDBFLAGS_AOF_PREAMBLE);

    // ...

    /* Read the actual AOF file, in REPL format, command by command. */
    while(1) {
        int argc, j;
        unsigned long len;
        robj **argv;
        char buf[128];
        sds argsds;
        struct redisCommand *cmd;

        // ...
        if (fgets(buf,sizeof(buf),fp) == NULL) { // read one line of command; fgets reads up to '\n'
            if (feof(fp))
                break;
            else
                goto readerr;
        }
        // the command read should normally start with '*', e.g. the RESP string for "set shanghai aaa" is "*3\r\n$3\r\nset\r\n$8\r\nshanghai\r\n$3\r\naaa\r\n"
        if (buf[0] != '*') goto fmterr;  
        if (buf[1] == '\0') goto readerr;
        argc = atoi(buf+1);  // read the number of command strings
        if (argc < 1) goto fmterr;

        /* Load the next command in the AOF as our fake client
         * argv. */
        argv = zmalloc(sizeof(robj*)*argc);
        fakeClient->argc = argc;  // load the next command in the AOF into the fake client's argv
        fakeClient->argv = argv;

        for (j = 0; j < argc; j++) {  // read the command and its arguments
            /* Parse the argument len. */
            char *readres = fgets(buf,sizeof(buf),fp);  // parse the argument length, which should start with '$' followed by an integer
            if (readres == NULL || buf[0] != '$') {
                fakeClient->argc = j; /* Free up to j-1. */
                freeFakeClientArgv(fakeClient);
                if (readres == NULL)
                    goto readerr;
                else
                    goto fmterr;
            }
            len = strtol(buf+1,NULL,10);   // convert the string to an integer in decimal to get the command length

            /* Read it into a string object. */
            argsds = sdsnewlen(SDS_NOINIT,len);
            if (len && fread(argsds,len,1,fp) == 0) {  // read the command-length string
                sdsfree(argsds);
                fakeClient->argc = j; /* Free up to j-1. */
                freeFakeClientArgv(fakeClient);
                goto readerr;
            }
            argv[j] = createObject(OBJ_STRING,argsds);  // create the command object

            /* Discard CRLF. */
            if (fread(buf,2,1,fp) == 0) {  // read the command's CRLF
                fakeClient->argc = j+1; /* Free up to j. */
                freeFakeClientArgv(fakeClient);
                goto readerr;
            }
        }

        /* Command lookup */
        cmd = lookupCommand(argv[0]->ptr);   // check whether the command exists; argv[0] is the command, argv[1] is arg 1, argv[2] is arg 2, and so on
        if (!cmd) {
            serverLog(LL_WARNING,
                "Unknown command '%s' reading the append only file",
                (char*)argv[0]->ptr);
            exit(1);
        }

        if (cmd == server.multiCommand) valid_before_multi = valid_up_to;

        /* Run the command in the context of a fake client */
        fakeClient->cmd = fakeClient->lastcmd = cmd;
        if (fakeClient->flags & CLIENT_MULTI &&
            fakeClient->cmd->proc != execCommand) // if currently in a transaction, queue the command
        {
            queueMultiCommand(fakeClient);
        } else {  // if not in a transaction, execute the command
            cmd->proc(fakeClient); // execute the command
        }

        // ...
    }

loaded_ok: /* DB loaded, cleanup and return C_OK to the caller. */
    fclose(fp);
    freeFakeClient(fakeClient);

    return C_OK;
}

At this point, the AOF file is fully loaded and the Redis server begins accepting and processing client requests.

Summary

Redis’s persistence mechanism differs from PostgreSQL’s; this is determined by the different positioning of the two databases. When thinking about AOF in Redis’s persistence, we need to understand what Redis needs from persistence—or rather, what the user needs from persistence in their business scenario—and how to balance the performance cost that persistence introduces.