Redis persistence: RDB, AOF và khi nào mất dữ liệu

1. Tổng quan

Redis giữ toàn bộ dữ liệu trong RAM. Nếu process chết, dữ liệu chỉ còn lại nếu trước đó Redis đã ghi xuống đĩa. Bài này giải thích hai cơ chế persistence là RDB (snapshot) và AOF (append-only file), cách chọn và cấu hình chúng. Bài cũng liệt kê những tình huống bạn vẫn mất dữ liệu dù đã bật persistence. Cuối bài có code Spring Boot để kiểm tra trạng thái persistence và một test chạy với Testcontainers.

2. RDB: chụp snapshot định kỳ

RDB ghi toàn bộ dataset ra một file nhị phân, mặc định là dump.rdb, tại một thời điểm. Cấu hình trong redis.conf:

# Snapshot nếu sau 3600s có ít nhất 1 thay đổi,
# sau 300s có ít nhất 100 thay đổi, hoặc sau 60s có ít nhất 10000 thay đổi
save 3600 1 300 100 60 10000

dbfilename dump.rdb
dir /data

# Từ chối lệnh ghi nếu lần BGSAVE gần nhất thất bại
stop-writes-on-bgsave-error yes
rdbcompression yes

Khi một điều kiện save thỏa mãn, Redis gọi fork(). Process con ghi snapshot ra file tạm, sau đó đổi tên thành dump.rdb. Process cha vẫn phục vụ client như bình thường. Nhờ cơ chế copy-on-write của hệ điều hành, hai process dùng chung trang bộ nhớ. Một trang chỉ bị sao chép khi process cha sửa nó.

Bạn cũng có thể chụp snapshot thủ công:

redis-cli BGSAVE
redis-cli LASTSAVE
Background saving started
(integer) 1760169600

BGSAVE chạy nền. LASTSAVE trả về Unix timestamp của lần snapshot thành công gần nhất. Tránh dùng SAVE trên production: lệnh này chặn toàn bộ server cho tới khi ghi xong.

Ưu điểm của RDB:

  • File gọn, rất hợp để backup hoặc copy sang môi trường khác.
  • Khởi động lại nhanh vì Redis chỉ cần nạp một file nhị phân.
  • Gần như không ảnh hưởng tới throughput giữa các lần snapshot.

Nhược điểm: mọi thay đổi sau snapshot cuối cùng sẽ mất nếu Redis crash. Với cấu hình trên, trong trường hợp xấu nhất bạn mất tới vài phút dữ liệu.

Lưu ý: fork() trên dataset lớn (hàng chục GB) có thể làm Redis đứng hình vài trăm mili giây. Nếu ghi nhiều trong lúc snapshot, copy-on-write có thể làm RAM tăng gần gấp đôi. Hãy để dư bộ nhớ và bật vm.overcommit_memory = 1 trên Linux.

3. AOF: ghi lại từng lệnh ghi

AOF ghi mọi lệnh làm thay đổi dữ liệu (SET, INCR, HSET…) vào cuối file log. Khi khởi động, Redis chạy lại log này để dựng lại dataset.

appendonly yes
appenddirname "appendonlydir"
appendfilename "appendonly.aof"

# always | everysec | no
appendfsync everysec

auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
aof-use-rdb-preamble yes

3.1. Chính sách appendfsync

Ghi vào file chưa có nghĩa là dữ liệu đã nằm trên đĩa. Dữ liệu có thể còn trong page cache của kernel cho tới khi có lệnh fsync(). Đây là chỗ quyết định bạn mất bao nhiêu dữ liệu:

Giá trị Khi nào fsync Mất tối đa khi crash Hiệu năng
always Sau mỗi lệnh ghi Gần như không mất Chậm nhất
everysec Mỗi giây, trên background thread Khoảng 1 giây (có thể tới 2 giây) Tốt, là mặc định
no Do OS quyết định, thường khoảng 30 giây Vài chục giây Nhanh nhất

Với everysec, nếu lần fsync trước chưa xong sau 2 giây vì đĩa chậm, Redis sẽ chặn lệnh ghi để không trễ thêm. Vì vậy cửa sổ mất dữ liệu thực tế có thể là 2 giây, không phải 1.

Cũng cần phân biệt hai loại sự cố. Nếu chỉ process Redis crash, dữ liệu đã write() vẫn nằm trong page cache và kernel sẽ ghi nó ra đĩa sau. Nếu cả máy mất điện hoặc kernel panic, phần chưa fsync sẽ mất.

3.2. AOF rewrite và multi-part AOF

AOF lớn dần theo thời gian. Ví dụ, 1 triệu lệnh INCR counter chỉ cần thay bằng một lệnh SET counter 1000000. Redis tự rewrite AOF khi file lớn gấp đôi lần rewrite trước (auto-aof-rewrite-percentage 100) và đạt tối thiểu 64 MB.

Từ Redis 7, AOF nằm trong thư mục appendonlydir và gồm nhiều file:

appendonlydir/
├── appendonly.aof.1.base.rdb    # base, định dạng RDB nhờ aof-use-rdb-preamble
├── appendonly.aof.1.incr.aof    # các lệnh ghi phát sinh sau base
└── appendonly.aof.manifest      # mô tả file nào thuộc AOF hiện tại

Khi bật aof-use-rdb-preamble yes, file base được ghi theo định dạng RDB nên nạp nhanh. Phần incr vẫn là log lệnh nên mất ít dữ liệu. Đây là chế độ “hybrid” và là mặc định từ Redis 4.0 trở đi.

4. Nên chọn chế độ nào?

Nhu cầu Cấu hình gợi ý
Cache thuần, dữ liệu dựng lại được từ DB Tắt hết: save "", appendonly no
Chấp nhận mất vài phút, cần backup gọn Chỉ RDB
Session, giỏ hàng, rate limit cần bền AOF everysec + RDB để backup
Không được mất lệnh nào (hiếm) AOF always, nhưng nên cân nhắc dùng DB khác

Lựa chọn hợp lý cho phần lớn ứng dụng là bật cả hai: AOF everysec để giảm mất mát, RDB để có file backup định kỳ. Khi cả hai cùng bật, Redis ưu tiên nạp AOF lúc khởi động vì AOF đầy đủ hơn.

Lưu ý: nếu bạn chỉ dùng Redis làm cache, hãy tắt persistence. Bật RDB cho cache chỉ tốn thêm fork(), I/O đĩa và RAM mà không mang lại lợi ích gì.

5. Kiểm tra persistence từ ứng dụng Spring Boot

Thêm dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Lớp sau đọc trạng thái persistence, yêu cầu snapshot và chờ replica xác nhận:

@Component
public class PersistenceInspector {

    private final StringRedisTemplate template;

    public PersistenceInspector(StringRedisTemplate template) {
        this.template = template;
    }

    public Properties persistenceInfo() {
        return template.execute((RedisCallback<Properties>) conn ->
                conn.serverCommands().info("persistence"));
    }

    public void triggerSnapshot() {
        template.execute((RedisCallback<Object>) conn ->
                conn.execute("BGSAVE", "SCHEDULE".getBytes()));
    }

    public long waitForReplicas(int replicas, long timeoutMs) {
        Long acked = template.execute((RedisCallback<Long>) conn ->
                (Long) conn.execute("WAIT",
                        String.valueOf(replicas).getBytes(),
                        String.valueOf(timeoutMs).getBytes()));
        return acked == null ? 0 : acked;
    }
}

Giải thích từng phương thức:

  • persistenceInfo() gọi INFO persistence và trả về một Properties chứa các trường như aof_enabled, rdb_last_bgsave_status.
  • triggerSnapshot() dùng BGSAVE SCHEDULE. Nếu một AOF rewrite đang chạy, Redis sẽ xếp lịch snapshot thay vì trả lỗi.
  • waitForReplicas() gọi WAIT và trả về số replica đã nhận các lệnh ghi trước đó. Mục 6 sẽ nói vì sao bạn cần lệnh này.

Một phần output của INFO persistence khi bật AOF:

loading:0
rdb_changes_since_last_save:42
rdb_bgsave_in_progress:0
rdb_last_save_time:1760169600
rdb_last_bgsave_status:ok
aof_enabled:1
aof_rewrite_in_progress:0
aof_last_bgrewrite_status:ok
aof_last_write_status:ok

Ba trường bạn nên đưa vào monitoring và đặt cảnh báo khi giá trị khác ok: rdb_last_bgsave_status, aof_last_write_status và aof_last_bgrewrite_status. Trường rdb_changes_since_last_save cho biết bạn sẽ mất bao nhiêu thay đổi nếu chỉ dựa vào RDB.

5.1. Test với Testcontainers

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class RedisPersistenceTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4"))
            .withExposedPorts(6379)
            .withCommand("redis-server", "--appendonly", "yes", "--appendfsync", "everysec");

    @Autowired
    StringRedisTemplate template;

    @Autowired
    PersistenceInspector inspector;

    @Test
    void aofShouldBeEnabledWithEverySecPolicy() {
        template.opsForValue().set("order:1001", "PAID");

        Properties info = inspector.persistenceInfo();
        List<String> fsync = template.execute((RedisCallback<List<String>>) conn ->
                conn.serverCommands().getConfig("appendfsync")
                        .values().stream().map(Object::toString).toList());

        assertThat(info.getProperty("aof_enabled")).isEqualTo("1");
        assertThat(info.getProperty("aof_last_write_status")).isEqualTo("ok");
        assertThat(fsync).containsExactly("everysec");
    }

    @Test
    void backgroundSnapshotShouldSucceed() throws InterruptedException {
        template.opsForValue().set("customer:42", "Lan");
        long before = Long.parseLong(inspector.persistenceInfo().getProperty("rdb_last_save_time"));

        Thread.sleep(1100); // đảm bảo timestamp mới khác timestamp cũ
        inspector.triggerSnapshot();

        Properties info = inspector.persistenceInfo();
        for (int i = 0; i < 50 && Long.parseLong(info.getProperty("rdb_last_save_time")) <= before; i++) {
            Thread.sleep(100);
            info = inspector.persistenceInfo();
        }

        assertThat(info.getProperty("rdb_last_bgsave_status")).isEqualTo("ok");
        assertThat(Long.parseLong(info.getProperty("rdb_last_save_time"))).isGreaterThan(before);
    }
}

Test thứ nhất khởi động Redis 7.4 với AOF everysec và xác nhận cấu hình đã có hiệu lực. @ServiceConnection tự trỏ spring.data.redis.host/port tới container, nên bạn không cần khai báo thêm property nào. Test thứ hai chờ tối đa 5 giây để rdb_last_save_time tăng lên, rồi kiểm tra snapshot đã thành công.

6. Khi nào vẫn mất dữ liệu?

Bật persistence chưa đủ. Dưới đây là các tình huống thường gặp nhất trên production.

1. Crash giữa hai lần snapshot. Nếu chỉ dùng RDB, bạn mất mọi thứ ghi sau rdb_last_save_time.

2. Cửa sổ fsync. Với AOF everysec, bạn mất khoảng 1–2 giây khi máy sập. Với no, con số này có thể lên tới vài chục giây.

3. Failover với replication bất đồng bộ. Master trả OK cho client trước khi replica nhận được lệnh. Nếu master chết và Sentinel hoặc Cluster đưa replica lên làm master, những lệnh chưa kịp sao chép sẽ biến mất, kể cả khi master có AOF always. WAIT giúp giảm rủi ro này:

template.opsForValue().set("payment:7788", "CAPTURED");
long acked = inspector.waitForReplicas(1, 100);
if (acked < 1) {
    log.warn("payment:7788 chưa được replica xác nhận");
}

WAIT 1 100 chặn tối đa 100 ms cho tới khi ít nhất 1 replica xác nhận. WAIT vẫn không biến Redis thành hệ thống strongly consistent: replica có thể nhận lệnh nhưng chưa fsync. Hãy coi đây là cách giảm xác suất mất dữ liệu, không phải lời đảm bảo.

4. Master khởi động lại với dataset rỗng. Giả sử master tắt persistence, bị restart tự động và lên lại với dữ liệu rỗng. Replica sẽ đồng bộ theo master và xóa sạch dữ liệu của chính nó. Nếu master không bật persistence, đừng để nó tự restart.

5. Container không mount volume. Redis vẫn ghi dump.rdb và AOF, nhưng vào filesystem của container. Khi container bị xóa và tạo lại, các file đó mất theo.

services:
  redis:
    image: redis:7.4
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redis-data:/data
volumes:
  redis-data:

Trên Kubernetes, tình huống tương đương là dùng emptyDir thay vì PersistentVolumeClaim.

6. Đĩa đầy hoặc fork() thất bại. Khi BGSAVE lỗi và stop-writes-on-bgsave-error yes đang bật, Redis từ chối mọi lệnh ghi với lỗi MISCONF. Hành vi này bảo vệ dữ liệu nhưng làm ứng dụng của bạn lỗi. Nhiều người phản ứng bằng cách tắt tùy chọn này, và từ đó Redis chạy tiếp mà không còn persistence.

7. Lỡ chạy FLUSHALL. Với AOF, lệnh FLUSHALL chỉ là một dòng ở cuối file incr. Nếu AOF chưa bị rewrite, bạn có thể dừng Redis, xóa dòng đó và khởi động lại. Nếu rewrite đã chạy thì không còn cách khôi phục. Vì vậy, hãy đổi tên hoặc chặn lệnh này bằng ACL trên production.

8. Eviction. Đây không phải lỗi persistence, nhưng kết quả giống nhau. Với maxmemory-policy allkeys-lru, Redis sẽ xóa key khi đầy RAM. Dữ liệu không thể mất được cần noeviction.

Lưu ý: đừng dùng Redis làm nơi lưu trữ duy nhất cho dữ liệu tiền bạc hoặc đơn hàng. Nếu bạn cần đảm bảo “đã trả OK là không mất”, hãy dùng database quan hệ hoặc hệ thống có consensus. Redis nên đóng vai cache hoặc lớp tăng tốc.

7. Kết luận

RDB cho file backup gọn và khởi động nhanh, nhưng có thể mất vài phút dữ liệu. AOF với everysec giảm mức mất xuống khoảng 1–2 giây, đổi lại tốn thêm I/O. Chế độ hybrid (aof-use-rdb-preamble) kết hợp ưu điểm của cả hai. Phần lớn rủi ro thực tế lại nằm ngoài hai cơ chế này: replication bất đồng bộ, volume không được mount, đĩa đầy, FLUSHALL và eviction. Hãy giám sát các trường trong INFO persistence, dùng WAIT cho những lệnh ghi quan trọng, và test cấu hình bằng Testcontainers như ở mục 5.

redispersistencerdbaofspring-boot