Back to blog

Cơ Chế Persistence Của Redis: RDB và AOF Hoạt Động Như Thế Nào

redisdatabasebackendperformance
Cơ Chế Persistence Của Redis: RDB và AOF Hoạt Động Như Thế Nào

Giới Thiệu

Mỗi lần bạn khởi động lại Redis server, bạn kỳ vọng dữ liệu vẫn còn đó. Nhưng Redis là một cơ sở dữ liệu in-memory — khi process thoát, RAM bị xóa sạch. Vậy Redis tồn tại qua lần khởi động lại bằng cách nào?

Câu trả lời là persistence: Redis ghi dữ liệu ra đĩa ở nền, để có thể tải lại khi khởi động. Redis cung cấp hai cơ chế bổ trợ cho nhau:

  • RDB (Redis Database) — snapshot định kỳ của toàn bộ dataset
  • AOF (Append-Only File) — write-ahead log ghi lại mọi lệnh thay đổi dữ liệu

Cả hai đều được implement trong source code Redis với kỹ thuật cẩn thận để tránh block event loop hoặc gây ra độ trễ đột biến. Bài viết này đọc qua rdb.c, aof.c và các file liên quan để hiểu chính xác cơ chế hoạt động.

Bạn Sẽ Học Được:
✅ Cách Redis fork process để tạo snapshot mà không cần tạm dừng
✅ Copy-on-write nghĩa là gì và tại sao nó làm cho BGSAVE an toàn
✅ Cách AOF ghi lại lệnh và ba chiến lược fsync
✅ Thủ thuật dual-buffer ngăn AOF rewrite mất dữ liệu
✅ Cách Redis kết hợp RDB và AOF để có ưu điểm của cả hai

Điều Kiện Tiên Quyết:


Hai File Persistence

git clone https://github.com/redis/redis.git
cd redis/src
FileChức Năng
rdb.cTạo, encode và load RDB snapshot
rdb.hHằng số định dạng RDB và khai báo hàm
aof.cGhi AOF, fsync và background rewrite
server.cserverCron — timer kích hoạt cả hai
server.hCác trường redisServer cho trạng thái persistence

Trạng thái persistence trong redisServer (từ server.h) đáng xem trước:

struct redisServer {
    // Trạng thái RDB
    pid_t rdb_child_pid;          // PID của process save nền (-1 nếu không có)
    int rdb_bgsave_scheduled;     // BGSAVE được yêu cầu nhưng bị chặn bởi AOF rewrite
    time_t rdb_last_save;         // Unix timestamp của lần save thành công gần nhất
    int rdb_last_bgsave_status;   // C_OK hoặc C_ERR
    long long dirty;              // Số thay đổi kể từ lần RDB save gần nhất
    long long dirty_before_bgsave;// Số dirty khi BGSAVE bắt đầu
 
    // Trạng thái AOF
    int aof_state;                // AOF_OFF, AOF_ON hoặc AOF_WAIT_REWRITE
    int aof_fd;                   // File descriptor của AOF file
    pid_t aof_child_pid;          // PID của process background rewrite
    sds aof_buf;                  // Buffer in-memory: lệnh chờ flush
    sds aof_rewrite_buf_blocks;   // Buffer phụ: lệnh trong khi rewrite
    off_t aof_current_size;       // Kích thước AOF file hiện tại tính bằng byte
};

Hai child PID, hai buffer, một dirty counter — bạn đã thấy hình dạng của cách hệ thống hoạt động. Hãy khám phá từng phần.


Phần 1: RDB — Snapshot với fork()

RDB File Là Gì

Một RDB file là một binary snapshot nhỏ gọn của toàn bộ Redis dataset tại một thời điểm. Nó chứa mọi key, mọi value, mọi TTL, được encode theo định dạng binary của Redis. Khi khởi động, Redis load file này và tái tạo lại trạng thái in-memory.

Bạn có thể kích hoạt save thủ công:

SAVE      # Đồng bộ — block Redis cho đến khi xong (tránh dùng ở production)
BGSAVE    # Không đồng bộ — fork child process, trả về ngay lập tức

Bạn cũng có thể cấu hình automatic save trong redis.conf:

# Save nếu ít nhất 1 key thay đổi trong 900 giây qua
save 900 1
# Save nếu ít nhất 10 key thay đổi trong 300 giây qua
save 300 10
# Save nếu ít nhất 10000 key thay đổi trong 60 giây qua
save 60 10000

Các ngưỡng này được kiểm tra bởi serverCron, chạy mỗi 100ms.

Implement BGSAVE

Cốt lõi của việc tạo snapshot không blocking là rdbSaveBackground() trong rdb.c:

int rdbSaveBackground(int req, char *filename,
                      rdbSaveInfo *rsi, int rdbflags) {
    pid_t childpid;
 
    // Không thể chạy hai background save đồng thời
    if (hasActiveChildProcess()) return C_ERR;
 
    server.dirty_before_bgsave = server.dirty;
    server.lastbgsave_try = time(NULL);
 
    // *** THAO TÁC CHÍNH ***
    if ((childpid = redisFork(CHILD_TYPE_RDB)) == 0) {
        // === Child process ===
        int retval;
        redisSetProcTitle("redis-rdb-bgsave");
        redisSetCpuAffinity(server.bgsave_cpulist);
        retval = rdbSave(req, filename, rsi, rdbflags);
        if (retval == C_OK) {
            sendChildCowInfo(CHILD_INFO_TYPE_RDB_COW_SIZE,
                             "RDB");
        }
        exitFromChild((retval == C_OK) ? 0 : 1);
    } else {
        // === Parent process (Redis server chính) ===
        if (childpid == -1) {
            // fork() thất bại
            server.lastbgsave_status = C_ERR;
            return C_ERR;
        }
        serverLog(LL_NOTICE,
            "Background saving started by pid %ld",
            (long) childpid);
        server.rdb_save_time_start = time(NULL);
        server.rdb_child_pid = childpid;
        return C_OK;
    }
    return C_OK; // không đến được ở parent
}

Điểm mấu chốt là lệnh gọi fork(). fork() tạo một bản sao chính xác của Redis process. Child ghi snapshot; parent tiếp tục phục vụ lệnh. Chúng chia sẻ cùng các memory page — ban đầu không tốn chi phí copy nào.

Copy-on-Write: Tại Sao Fork (Gần Như) Miễn Phí

Sau fork(), cả parent và child process đều trỏ đến cùng các physical memory page. OS đánh dấu tất cả các page này là copy-on-write (COW):

Khi parent thay đổi một key (vì client vẫn đang ghi), OS trong suốt nhân đôi chỉ page đó. Child vẫn thấy phiên bản cũ, chưa thay đổi. Điều này có nghĩa là:

  • Child có một cái nhìn nhất quán tại một thời điểm về dữ liệu
  • Parent không bao giờ bị block — nó tiếp tục phục vụ request bình thường
  • Overhead bộ nhớ tỷ lệ với lượng dữ liệu thay đổi trong quá trình save, không phải tổng kích thước dataset

Đây là lý do tại sao Redis log RDB: X MB of memory used by copy-on-write sau khi save — nó cho bạn biết bao nhiêu page đã được nhân đôi.

Child: Serialize Dataset

Bên trong child process, rdbSave() lặp qua tất cả database và serialize mọi cặp key-value:

int rdbSave(int req, char *filename,
            rdbSaveInfo *rsi, int rdbflags) {
    // Ghi vào temp file trước, sau đó rename atomic
    snprintf(tmpfile, 256, "temp-%d.rdb", (int) getpid());
    fp = fopen(tmpfile, "w");
    rioInitWithFile(&rdb, fp);
 
    if (rdbSaveRio(req, &rdb, &error, rdbflags, rsi) == C_ERR) {
        // Xử lý lỗi...
    }
 
    // Flush OS buffer ra đĩa
    if (fflush(fp) == EOF) goto werr;
    if (fsync(fileno(fp)) == -1) goto werr;
    if (fclose(fp) == EOF) goto werr;
 
    // Rename atomic: dump.rdb cũ được thay thế bằng một syscall
    if (rename(tmpfile, filename) == -1) goto werr;
 
    return C_OK;
}

Hai chi tiết đảm bảo độ tin cậy nổi bật:

  1. Ghi vào temp file trước. Nếu process crash giữa chừng, dump.rdb hiện tại không bị ảnh hưởng. File chưa hoàn chỉnh không bao giờ trở thành snapshot đang hoạt động.
  2. rename() là atomic. Trên hệ thống POSIX, đổi tên file là một syscall duy nhất. Không có khoảng thời gian nào để reader thấy file viết dở.

Định Dạng Binary RDB

Hàm rdbSaveRio() ghi dữ liệu theo định dạng binary tùy chỉnh. Cái nhìn tổng quan đơn giản hóa:

[REDIS][version][aux fields]
  Với mỗi database:
    [SELECTDB][db number]
    [RESIZE_DB][key count][expire count]
    Với mỗi key:
      [tùy chọn: EXPIRETIME ms][unix timestamp]
      [type byte]
      [encoded key]
      [encoded value]
[EOF]
[8-byte CRC64 checksum]

Encoding là theo từng loại và nhỏ gọn. Integer được lưu dưới dạng variable-length integer (không phải ASCII), string dùng length-prefixed byte, và các loại phức tạp như sorted set có biểu diễn binary riêng. CRC64 cuối cùng phát hiện hỏng dữ liệu.

Khi Child Hoàn Thành

Parent phát hiện child hoàn thành trong serverCron qua wait3() (kiểm tra trạng thái child không blocking). Nếu child thoát với status 0 (thành công):

// Trong server.c, serverCron():
if (pid == server.rdb_child_pid) {
    backgroundSaveDoneHandler(exitcode, bysignal);
}

backgroundSaveDoneHandler cập nhật server.rdb_last_save, xóa server.dirty (bộ đếm thay đổi), và log thời gian save. Nếu thất bại, Redis log lỗi và lên lịch thử lại.


Phần 2: AOF — Write-Ahead Logging

Snapshot RDB hiệu quả nhưng có khoảng trống: nếu Redis crash 50 giây sau khi snapshot, bạn mất 50 giây ghi. AOF đóng khoảng trống này bằng cách ghi lại mọi lệnh thay đổi vào log file.

Cách AOF Hoạt Động Tại Điểm Gọi

Mọi lệnh thay đổi dữ liệu gọi propagate() sau khi thực thi. Bên trong propagate(), feedAppendOnlyFile() được gọi:

// Đơn giản hóa từ aof.c
void feedAppendOnlyFile(int dictid, robj **argv, int argc) {
    sds buf = sdsempty();
 
    // Nếu client đang ở database khác, emit SELECT trước
    if (server.aof_selected_db != dictid) {
        char seldb[64];
        snprintf(seldb, sizeof(seldb), "%d", dictid);
        buf = sdscatprintf(buf, "*2\r\n$6\r\nSELECT\r\n$%lu\r\n%s\r\n",
                           (unsigned long)strlen(seldb), seldb);
        server.aof_selected_db = dictid;
    }
 
    // Chuyển đổi expiry tương đối thành timestamp tuyệt đối
    // (cùng lý do như trong replication: tránh drift)
    if (argc == 3 && !strcasecmp(argv[0]->ptr, "set")) {
        // Viết lại SET key value EX 60
        // thành:   SET key value PXAT <absolute_ms>
    }
 
    // Encode lệnh theo định dạng RESP và append vào buffer
    buf = catAppendOnlyGenericCommand(buf, argc, argv);
    server.aof_buf = sdscatlen(server.aof_buf, buf, sdslen(buf));
    sdsfree(buf);
}

Lệnh được serialize theo định dạng RESP (cùng protocol Redis dùng cho giao tiếp client) và append vào server.aof_buf — một in-memory buffer. Nó KHÔNG ghi ngay ra đĩa.

Ba Chiến Lược fsync

Ghi vào aof_buf nhanh, nhưng durability đòi hỏi dữ liệu phải đến được đĩa. Điều này xảy ra trong flushAppendOnlyFile(), được gọi từ hook "before sleep" của event loop:

void flushAppendOnlyFile(int force) {
    ssize_t nwritten;
 
    if (sdslen(server.aof_buf) == 0) {
        // Không có gì để flush, nhưng có thể vẫn fsync theo timer
        if (server.aof_fsync == AOF_FSYNC_EVERYSEC && ...) {
            goto try_fsync;
        }
        return;
    }
 
    // Ghi buffer vào file descriptor
    nwritten = aofWrite(server.aof_fd,
                        server.aof_buf,
                        sdslen(server.aof_buf));
 
    // Xóa buffer (dữ liệu bây giờ trong OS page cache)
    sdsclear(server.aof_buf);
 
try_fsync:
    if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
        // Flush OS page cache ra đĩa vật lý NGAY BÂY
        // Đảm bảo durability: tối đa 1 lệnh mất khi crash
        // Chi phí: một fsync mỗi lệnh (rất chậm trên ổ cứng quay)
        redis_fsync(server.aof_fd);
 
    } else if (server.aof_fsync == AOF_FSYNC_EVERYSEC) {
        // Ủy quyền fsync cho background thread, mỗi giây một lần
        // Tối đa 1 giây dữ liệu mất khi crash
        // Chi phí: ~1 fsync mỗi giây (mặc định thực tế)
        if (server.aof_last_fsync < now) {
            aof_background_fsync(server.aof_fd);
        }
 
    } else {
        // AOF_FSYNC_NO: không bao giờ gọi fsync tường minh
        // OS quyết định khi nào flush (thường mỗi 30 giây)
        // Tối đa ~30 giây dữ liệu mất khi crash
        // Chi phí: nhanh nhất, nhưng đảm bảo durability thấp nhất
    }
}

Ba chiến lược đại diện cho sự đánh đổi giữa durability và performance:

Chiến LượcMất dữ liệu tối đaHiệu năngUse case
always0 lệnhChậm (fsync mỗi lần ghi)Tài chính, dữ liệu quan trọng
everysec~1 giâyNhanh (mặc định)Hầu hết ứng dụng
noPhụ thuộc OSNhanh nhấtCache-only, dữ liệu có thể tái tạo

Syscall write() chuyển dữ liệu từ aof_buf vào page cache của kernel OS — điều này nhanh nhưng không durable. fsync() buộc OS flush page cache ra đĩa vật lý. Khoảng cách giữa hai lệnh gọi này là cửa sổ mà crash có thể mất dữ liệu.

AOF Rewrite: Nén Log

AOF file tăng trưởng mãi mãi — mỗi INCR thêm một dòng, dù key được increment một triệu lần. Một AOF cho counter từ 0 đến 1,000,000 sẽ có một triệu dòng, mặc dù trạng thái hiện tại chỉ là SET counter 1000000.

Giải pháp là BGREWRITEAOF, tạo ra một AOF tối giản mới biểu diễn trạng thái hiện tại:

BGREWRITEAOF   # Kích hoạt thủ công
# Redis cũng làm điều này tự động dựa trên giá trị config
# aof-rewrite-min-size và aof-rewrite-percentage

Implement trong aofRewriteBackground() cũng dùng fork():

int aofRewriteBackground(void) {
    pid_t childpid;
 
    if (hasActiveChildProcess()) return C_ERR;
 
    if ((childpid = redisFork(CHILD_TYPE_AOF)) == 0) {
        // === Child process ===
        char tmpfile[256];
        snprintf(tmpfile, 256, "temp-rewriteaof-bg-%d.aof",
                 (int) getpid());
        if (rewriteAppendOnlyFile(tmpfile) == C_OK) {
            sendChildCowInfo(CHILD_INFO_TYPE_AOF_COW_SIZE, "AOF rewrite");
            exitFromChild(0);
        } else {
            exitFromChild(1);
        }
    } else {
        // === Parent process ===
        server.aof_child_pid = childpid;
        // Bắt đầu tích lũy lệnh vào buffer phụ
        aofRewriteBufferReset();
        server.aof_rewrite_buf_blocks = listCreate();
        return C_OK;
    }
}

Vấn Đề Dual-Buffer

Đây là thách thức kỹ thuật tinh tế nhất trong AOF: điều gì xảy ra với các lệnh ghi đến trong khi rewrite đang chạy?

Child có snapshot tại một thời điểm (qua COW), nhưng parent tiếp tục phục vụ client ghi. Nếu các lệnh ghi mới chỉ vào aof_buf (AOF file chính), AOF nhỏ gọn mới sẽ không bao gồm chúng. Khi rewrite hoàn thành và chúng ta chuyển sang file mới, các lệnh đó sẽ bị mất.

Redis giải quyết vấn đề này với một buffer phụ: aof_rewrite_buf_blocks. Trong khi rewrite đang chạy, mọi lệnh thay đổi mới được ghi vào cả hai: aof_buf (AOF file hiện tại) VÀ aof_rewrite_buf_blocks (buffer tích lũy):

void feedAppendOnlyFile(int dictid, robj **argv, int argc) {
    // ... encode lệnh thành buf ...
 
    // Luôn append vào main AOF buffer
    server.aof_buf = sdscatlen(server.aof_buf, buf, sdslen(buf));
 
    // CŨNG append vào rewrite buffer nếu rewrite đang chạy
    if (server.aof_child_pid != -1)
        aofRewriteBufferAppend((unsigned char*)buf, sdslen(buf));
}

Khi child hoàn thành và báo hiệu cho parent:

void backgroundRewriteDoneHandler(int exitcode, int bysignal) {
    if (!bysignal && exitcode == 0) {
        // 1. Mở AOF nhỏ gọn mới
        newfd = open(tmpfile, O_WRONLY|O_APPEND);
 
        // 2. Append toàn bộ buffer phụ tích lũy vào đó
        //    (tất cả lệnh ghi đến trong quá trình rewrite)
        if (aofRewriteBufferWrite(newfd) == -1) { ... }
 
        // 3. Rename atomic: file mới thay thế file cũ
        rename(tmpfile, server.aof_filename);
 
        // 4. Chuyển file descriptor sang file mới
        oldfd = server.aof_fd;
        server.aof_fd = newfd;
        close(oldfd);  // Có thể chậm, nhưng xảy ra sau rename
    }
}

Điều này đảm bảo không có lệnh ghi nào bị mất. Buffer phụ là cầu nối giữa snapshot đóng băng của child và luồng ghi trực tiếp của parent.


Phần 3: Load Khi Khởi Động

Khi Redis khởi động, loadDataFromDisk() trong server.c quyết định phải load gì:

void loadDataFromDisk(void) {
    long long start = ustime();
 
    if (server.aof_state == AOF_ON) {
        // AOF được ưu tiên — nó đầy đủ hơn
        if (loadAppendOnlyFiles(server.aof_manifest) == AOF_FAILED) {
            exit(1);
        }
    } else {
        // Không có AOF, thử RDB
        rdbSaveInfo rsi = RDB_SAVE_INFO_INIT;
        errno = 0;
        int rdb_flags = RDBFLAGS_MAIN_FILE;
        if (rdbLoad(server.rdb_filename, &rsi, rdb_flags) == C_OK) {
            serverLog(LL_NOTICE, "DB loaded from disk: %.3f seconds",
                      (float)(ustime()-start)/1000000);
        } else if (errno != ENOENT) {
            serverLog(LL_WARNING, "Fatal error loading RDB file: %s. Exiting.",
                      server.rdb_filename);
            exit(1);
        }
    }
}

AOF được ưu tiên vì nó cập nhật hơn. RDB chỉ được load khi AOF bị tắt hoặc không có.

AOF loader (loadSingleAppendOnlyFile()) replay lệnh bằng cách tạo một fake client và thực thi từng lệnh như thể nó đến từ network — sử dụng đúng cùng đường dẫn dispatch lệnh như hoạt động bình thường. Sự tái sử dụng command table này có nghĩa là AOF loading tự động hưởng lợi từ bất kỳ tối ưu lệnh tương lai nào.


Phần 4: Kết Hợp RDB + AOF (Hybrid Persistence)

Redis 4.0 giới thiệu chế độ hybrid kết hợp cả hai:

# redis.conf
aof-use-rdb-preamble yes

Khi BGREWRITEAOF chạy ở chế độ hybrid, child viết một RDB-format preamble (encoding binary nhanh) theo sau bởi AOF-format commands cho các lệnh ghi xảy ra sau snapshot:

[Dữ liệu binary RDB — nhỏ gọn, load nhanh]
[Lệnh AOF — chỉ những gì thay đổi kể từ RDB preamble]

Điều này có nghĩa là:

  • Thời gian load nhanh như RDB (giải mã binary hàng loạt, không phải replay lệnh)
  • Mất dữ liệu nhỏ như AOF (tối đa 1 giây với everysec)
  • Kích thước file nhỏ hơn plain AOF sau nhiều lệnh ghi nhỏ

Loader phát hiện preamble bằng cách kiểm tra xem file có bắt đầu bằng magic bytes RDB (REDIS) không. Nếu có, nó load phần RDB, rồi chuyển sang AOF replay cho phần đuôi.


Tổng Hợp Lại

Đây là bức tranh đầy đủ về những gì xảy ra trong vòng đời của Redis:


Nguyên Tắc Thiết Kế Chính

1. Không bao giờ block event loop vì disk I/O
Cả BGSAVE và BGREWRITEAOF đều dùng fork() để chuyển toàn bộ công việc đĩa sang child process. Parent trả về ngay lập tức và tiếp tục phục vụ request.

2. fork() + copy-on-write làm cho snapshot rẻ
OS chia sẻ memory page giữa parent và child cho đến khi một trong hai ghi. Snapshot của dataset 4 GB mất vài microsecond để khởi tạo — không phải gigabyte copy.

3. Ghi vào temp file, rename atomic
Cả RDB và AOF rewrite đều dùng pattern temp-file-then-rename. Crash giữa chừng không bao giờ làm hỏng file hiện tại.

4. Buffer phụ kết nối hai timeline
Trong AOF rewrite, lệnh ghi mới vào cả AOF trực tiếp và buffer phụ. Khi rewrite hoàn thành, buffer được append vào file mới, đảm bảo không có lệnh nào rơi vào khoảng trống.

5. AOF replay lệnh qua đường dẫn dispatch bình thường
Load khi khởi động tạo fake client và gọi cùng setCommand, incrCommand, v.v. mà traffic production dùng. Không cần logic loader đặc biệt.


Cách Tự Khám Phá Source Code

# Các hàm chính cần đọc theo thứ tự:
grep -n "rdbSaveBackground" src/rdb.c     # Entry point BGSAVE
grep -n "rdbSave\b" src/rdb.c             # Công việc của child
grep -n "feedAppendOnlyFile" src/aof.c    # Nơi lệnh được log
grep -n "flushAppendOnlyFile" src/aof.c   # Quyết định fsync
grep -n "aofRewriteBackground" src/aof.c  # Entry point BGREWRITEAOF
grep -n "backgroundRewriteDoneHandler" src/aof.c  # Ghép buffer phụ
grep -n "loadDataFromDisk" src/server.c   # Logic load khi khởi động

Để xem nội dung RDB file:

# Redis có sẵn công cụ này
src/redis-check-rdb dump.rdb
 
# Hoặc dùng redis-cli
redis-cli --rdb /path/to/dump.rdb

Để xem nội dung AOF file — nó là RESP dạng human-readable:

cat appendonly.aof | head -50
# Bạn sẽ thấy:
# *3
# $3
# SET
# $6
# mykey
# $5
# hello

Tóm Tắt

Redis persistence là một nghiên cứu về việc tránh các giải pháp hiển nhiên:

✅ Thay vì tạm dừng để snapshot, Redis fork() và dùng copy-on-write
✅ Thay vì rủi ro file bị hỏng, Redis ghi vào temp file rồi rename atomic
✅ Thay vì mất lệnh ghi trong AOF rewrite, Redis tích lũy chúng trong buffer phụ
✅ Thay vì code load đặc biệt, AOF replay tái sử dụng đường dẫn dispatch lệnh bình thường
✅ Thay vì chọn giữa RDB và AOF, chế độ hybrid kết hợp cả hai

Lần tới khi bạn cấu hình save 300 10 hoặc appendfsync everysec, bạn sẽ biết chính xác code nào chạy, tại sao các giá trị mặc định đó tồn tại, và bạn đang thực hiện đánh đổi gì.


Tài Nguyên Thêm

Redis Source Code:

Bài Viết Liên Quan:

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.