Back to blog

Giải Thích Source Code Redis: Khám Phá Repository GitHub

redisdatabasecachingbackendperformance
Giải Thích Source Code Redis: Khám Phá Repository GitHub

Giới Thiệu

Bạn đã dùng Redis. Bạn biết các lệnh — SET, GET, LPUSH, ZADD. Nhưng bạn đã bao giờ tự hỏi điều gì thực sự xảy ra khi Redis thực thi một trong những lệnh đó? Code C trông như thế nào?

Source code Redis nằm tại github.com/redis/redis và đây là một trong những codebase C dễ đọc, có comment tốt nhất trong open source. Đọc nó không chỉ dạy bạn về Redis — nó dạy bạn các pattern lập trình hệ thống được dùng trong database, game engine và hệ điều hành.

Bài viết này đi qua năm file source code quan trọng và rút ra những ý tưởng giải thích tại sao Redis lại nhanh đến vậy.

Bạn Sẽ Học Được:
✅ Cách Redis biểu diễn mọi giá trị bằng một struct chung (robj)
✅ Cách SET và GET thực sự hoạt động trong C
✅ Tại sao counter Redis dùng gần như không có bộ nhớ (integer encoding)
✅ Cách Redis list tự động chuyển đổi giữa hai cấu trúc dữ liệu
✅ Thủ thuật incremental rehashing giúp Redis có độ trễ ổn định
✅ Cách event loop đơn luồng xử lý 100.000+ kết nối

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

  • Kiến thức Redis cơ bản (khuyến nghị đọc Learning Redis Fundamentals)
  • Thoải mái khi đọc code ở bất kỳ ngôn ngữ nào (cú pháp C sẽ được giải thích)
  • Tò mò về cách database hoạt động nội tại

Cấu Trúc Repository

Clone repository và bạn sẽ thấy cấu trúc này:

git clone https://github.com/redis/redis.git
cd redis/src
ls *.c | head -20

Thư mục src/ chứa ~100 file C. Đây là các file chúng ta sẽ nghiên cứu:

FileCông Dụng
server.hĐịnh nghĩa các cấu trúc dữ liệu cốt lõi
t_string.cTất cả lệnh string (GET, SET, INCR…)
t_list.cTất cả lệnh list (LPUSH, RPOP, BLPOP…)
dict.cHash table dùng ở khắp nơi
ae.cEvent loop — engine của Redis

server.h — Bản Thiết Kế

Mọi file Redis đều include server.h. Đây là header trung tâm định nghĩa các struct mà mọi thứ khác phụ thuộc vào.

robj — Một Kiểu Để Cai Quản Tất Cả

Trong Redis, mọi giá trị bạn lưu trữ — dù là string, list, sorted set hay hash — đều được bọc trong một struct duy nhất gọi là robj (Redis Object):

// Đơn giản hóa từ server.h
typedef struct redisObject {
    unsigned type:4;       // OBJ_STRING, OBJ_LIST, OBJ_SET...
    unsigned encoding:4;   // Cách lưu trữ nội tại
    unsigned lru:LRU_BITS; // Thời gian truy cập cuối cho eviction
    int refcount;          // Đếm tham chiếu để quản lý bộ nhớ
    void *ptr;             // Con trỏ đến dữ liệu thực
} robj;

Trường type chứa một trong các hằng số này:

#define OBJ_STRING 0
#define OBJ_LIST   1
#define OBJ_SET    2
#define OBJ_ZSET   3  // Sorted Set
#define OBJ_HASH   4
#define OBJ_STREAM 6

Thiết kế này rất thanh lịch. Các lệnh như TYPE key chỉ cần đọc robj->type. EXPIRE hoạt động với mọi kiểu vì nó được đặt trên key, không phải trên giá trị. Hàm checkType() kiểm tra rằng bạn đang dùng đúng lệnh với đúng kiểu dữ liệu.

Struct Database

Redis có thể giữ tới 16 logical database. Mỗi cái là một redisDb:

typedef struct redisDb {
    kvstore *keys;           // Các cặp key-value thực tế
    kvstore *expires;        // Theo dõi TTL cho từng key
    dict *blocking_keys;     // Key mà client đang BLPOP
    int id;                  // Số database (SELECT 0, SELECT 1...)
} redisDb;

Khi bạn chạy EXPIRE user:1 3600, Redis thêm một entry vào expires ánh xạ user:1 → Unix timestamp. Một timer nền kiểm tra dict này và xóa key hết hạn một cách lazy (khi truy cập) hoặc chủ động (mỗi 100ms).

Struct Server

Tất cả trạng thái toàn cục nằm trong một struct redisServer. Một vài trường đáng chú ý:

struct redisServer {
    pid_t pid;                  // Process ID
    redisDb *db;                // Mảng 16 database
    dict *commands;             // Tên lệnh → hàm xử lý
    list *clients;              // Tất cả client đang kết nối
    aeEventLoop *el;            // Event loop
    size_t stat_numcommands;    // Tổng lệnh đã xử lý
};

Dict commands là cách Redis định tuyến "SET" đến hàm setCommand(). Redis phân tích tên lệnh từ input của client, tra cứu trong dict này và gọi handler.


t_string.c — Cách GET và SET Hoạt Động

File này cài đặt mọi lệnh string. Hãy đọc GET trước — nó đơn giản đến mức tuyệt vời.

Đọc Lệnh GET

int getGenericCommand(client *c) {
    kvobj *o;
 
    // 1. Tra cứu key (c->argv[1] là tên key)
    if ((o = lookupKeyReadOrReply(c, c->argv[1],
            shared.null[c->resp])) == NULL)
        return C_OK;  // Không tìm thấy key — đã trả lời null
 
    // 2. Đảm bảo đây là string, không phải list hay hash
    if (checkType(c, o, OBJ_STRING)) return C_ERR;
 
    // 3. Gửi giá trị về cho client
    addReplyBulk(c, o);
    return C_OK;
}
 
void getCommand(client *c) {
    getGenericCommand(c);
}

Ba bước: tra cứu, kiểm tra kiểu, trả lời. Hàm lookupKeyReadOrReply xử lý kiểm tra dict expires nội tại — nếu key đã hết hạn, nó xóa key ngay tại đây và trả về NULL.

Lệnh SET Phức Tạp Hơn

SET có nhiều tùy chọn: EX, PX, NX, XX, GET, KEEPTTL. Nhưng một chi tiết cài đặt đặc biệt thú vị: thời gian hết hạn tương đối được chuyển đổi thành timestamp tuyệt đối trước khi replication.

// Khi bạn chạy: SET key value EX 60
// Redis nội tại chuyển thành: SET key value PXAT <unix_ms + 60000>
// trước khi gửi đến replica

Tại sao? Vì các replica server có thể nhận lệnh muộn hơn một chút. Nếu Redis replication EX 60 theo nghĩa đen, thời gian hết hạn của replica sẽ lệch đúng bằng độ trễ replication. Chuyển sang timestamp tuyệt đối (EXAT/PXAT) đảm bảo tất cả replica hết hạn key vào đúng cùng một thời điểm.

Integer Encoding — Không Cần Cấp Phát Bộ Nhớ

Đây là một trong những micro-optimization thông minh nhất trong Redis. Khi bạn lưu một counter:

SET visits 0
INCR visits  # Bây giờ là 1
INCR visits  # Bây giờ là 2

Redis không lưu "1" hay "2" dưới dạng string trong bộ nhớ. Thay vào đó, nó đóng gói số nguyên trực tiếp vào trường con trỏ của robj:

// Thay vì cấp phát string "1001" trên heap...
// Redis lưu số trực tiếp trong slot con trỏ:
o->ptr = (void*)((long)value);
o->encoding = OBJ_ENCODING_INT;

Con trỏ C là 8 byte trên hệ thống 64-bit. Một long cũng là 8 byte. Vậy Redis tái sử dụng slot con trỏ để lưu chính số nguyên. Không có heap allocation. Không có string parsing. Chỉ là một bitcast.

Đây là lý do tại sao INCR trên counter nhanh hơn so với set key string — và tại sao Redis counter dùng một lượng bộ nhớ nhỏ và không đổi bất kể giá trị là bao nhiêu.

Giới hạn: nếu số nguyên không vừa trong C long (xấp xỉ ±9,2 × 10¹⁸), Redis dùng string encoding.


t_list.c — List Thông Minh Hơn Bạn Nghĩ

Cài đặt list tiết lộ một trong những triết lý thiết kế cốt lõi của Redis: tự động sử dụng cấu trúc tiết kiệm bộ nhớ nhất dựa trên kích thước dữ liệu thực tế.

Hai Cấu Trúc Nền

Redis list không phải lúc nào cũng được cài đặt như linked list. Nội tại, chúng chọn giữa hai biểu diễn:

Listpack là định dạng bộ nhớ liền kề, compact. Hãy tưởng tượng đóng gói các phần tử chặt chẽ trong một byte array với tiền tố độ dài — không có con trỏ, không có overhead, thân thiện với cache CPU.

Quicklist là doubly-linked list trong đó mỗi node chứa một listpack. Nó được dùng cho các list lớn hơn khi cần O(1) push/pop ở cả hai đầu.

Cài Đặt Push Cho Thấy Cả Hai

void listTypePush(robj *subject, robj *value, int where) {
    if (subject->encoding == OBJ_ENCODING_QUICKLIST) {
        int pos = (where == LIST_HEAD) ?
            QUICKLIST_HEAD : QUICKLIST_TAIL;
        quicklistPush(subject->ptr, value->ptr,
                      sdslen(value->ptr), pos);
    } else if (subject->encoding == OBJ_ENCODING_LISTPACK) {
        subject->ptr = (where == LIST_HEAD) ?
            lpPrepend(subject->ptr, ...) :
            lpAppend(subject->ptr, ...);
    }
}

Cùng một lệnh LPUSH dispatch khác nhau dựa trên encoding hiện tại. Caller không bao giờ thấy điều này.

Tự Động Thăng Cấp với Hysteresis

Khi list listpack phát triển vượt list-max-listpack-size (mặc định: 128 phần tử), Redis gọi listTypeConvert để thăng cấp lên quicklist. Điều này tự động và vô hình với người dùng.

Còn co lại thì sao? Redis không giảm cấp ngay ở 128 — nó dùng ngưỡng thấp hơn. Hysteresis này ngăn cấu trúc bị thrashing giữa các định dạng khi phần tử dao động gần ranh giới:

Thăng cấp lên quicklist: khi phần tử > 128
Giảm cấp về listpack: khi phần tử < 64  (xấp xỉ)

Pattern này — thăng cấp ở N, giảm cấp ở N/2 — xuất hiện trong nhiều hệ thống nhạy cảm với hiệu suất (CPU branch predictor, thuật toán thay đổi kích thước buffer) vì cùng lý do: tránh chi phí chuyển đổi liên tục ở ranh giới.


dict.c — Hash Table Cung Cấp Năng Lượng Cho Mọi Thứ

dict của Redis là cấu trúc dữ liệu quan trọng nhất trong codebase. Nó được tái sử dụng ở khắp nơi: lưu toàn bộ key của mỗi database (redisDb->keys), lưu command registry (ánh xạ tên lệnh → hàm xử lý), cài đặt kiểu dữ liệu Set, lưu trữ các field của Hash, và nhiều hơn nữa.

Cấu Trúc: Chained Hash Table

struct dictEntry {
    struct dictEntry *next;   // Entry tiếp theo trong collision chain
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;             // Không cần allocation cho số
    } v;
};

Collision được giải quyết bằng chaining — mỗi bucket là một linked list các entry. Union trong trường giá trị là cùng trick tiết kiệm không gian chúng ta đã thấy trong robj: nếu giá trị của bạn là số, không cần heap allocation.

Chỉ số bucket dùng bitwise AND thay vì modulo:

// Thay vì: index = hash % table_size  (phép chia chậm)
// Redis dùng: index = hash & (table_size - 1)  (AND nhanh)

Điều này chỉ hoạt động khi table_size là lũy thừa của hai — và Redis luôn cấp phát table theo lũy thừa của hai chính vì lý do này.

Incremental Rehashing — Viên Ngọc Quý

Khi hash table quá đầy (load factor > 1), nó cần resize. Cách tiếp cận ngây thơ: cấp phát bảng mới lớn hơn, sao chép tất cả entry, xóa bảng cũ. Với database có hàng triệu key, điều này sẽ gây ra spike độ trễ nhìn thấy được — có thể hàng trăm millisecond bị chặn.

Giải pháp của Redis là incremental rehashing: duy trì hai bảng đồng thời và di chuyển entry dần dần qua nhiều thao tác.

// dict giữ hai bảng trong khi rehashing
struct dict {
    dictEntry **ht_table[2];  // [0] = cũ, [1] = mới
    long ht_used[2];          // Số entry trong mỗi bảng
    long rehashidx;           // Tiến trình di chuyển hiện tại (chỉ số bucket)
};

Hàm di chuyển tiến hành vài bucket mỗi lần:

int dictRehash(dict *d, int n) {
    while (n-- && d->ht_used[0] != 0) {
        // Di chuyển entry từ bucket table[0] cũ sang table[1] mới
        rehashEntriesInBucketAtIndex(d, d->rehashidx);
        d->rehashidx++;  // Tiến cursor
    }
    return !dictCheckRehashingCompleted(d);
}

Trong khi rehashing:

  • Tra cứu kiểm tra table[0] trước, rồi table[1]
  • Ghi đi thẳng vào table[1]
  • Timer nền (serverCron, chạy mỗi 100ms) gọi dictRehash để tăng tốc di chuyển khi rảnh

Kết quả: không có thao tác đơn lẻ nào phải trả đủ chi phí di chuyển. Công việc được phân tán qua hàng nghìn request thông thường, mỗi cái chỉ trả một chi phí tăng thêm nhỏ.


ae.c — Event Loop Làm Cho Mọi Thứ Hoạt Động

Redis nổi tiếng là đơn luồng để thực thi lệnh. Một thread. Không có lock. 100.000+ kết nối. Làm thế nào?

Câu trả lời là ae.c — event loop 500 dòng cài đặt reactor pattern.

Hai Loại Event

typedef struct aeFileEvent {
    int mask;              // AE_READABLE | AE_WRITABLE
    aeFileProc *rfileProc; // Gọi khi socket có thể đọc
    aeFileProc *wfileProc; // Gọi khi socket có thể ghi
    void *clientData;
} aeFileEvent;
 
typedef struct aeTimeEvent {
    long long id;
    monotime when;         // Khi nào kích hoạt (microseconds)
    aeTimeProc *timeProc;  // Hàm callback
    struct aeTimeEvent *next;
} aeTimeEvent;

File event kích hoạt khi socket mạng có dữ liệu sẵn sàng để đọc (client gửi lệnh) hoặc sẵn sàng để ghi (buffer phản hồi có thể được flush). Time event kích hoạt vào thời điểm tương lai đã lên lịch — dùng cho serverCron, kiểm tra hết hạn và thu thập stats.

Vòng Lặp Chính

void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        aeProcessEvents(eventLoop,
            AE_ALL_EVENTS |
            AE_CALL_BEFORE_SLEEP |
            AE_CALL_AFTER_SLEEP);
    }
}

aeProcessEvents là nơi phép màu xảy ra:

  1. Tính toán thời gian còn lại đến time event tiếp theo kích hoạt
  2. Gọi OS multiplexer (epoll_wait trên Linux, kevent trên macOS) với timeout đó — chặn cho đến khi socket sẵn sàng HOẶC timeout hết hạn
  3. Với mỗi socket sẵn sàng, gọi handler của nó (đọc lệnh / ghi phản hồi)
  4. Kiểm tra time event và kích hoạt bất kỳ cái nào đã hết hạn

Trừu Tượng Hóa Nền Tảng

Redis hỗ trợ bốn backend multiplexing sau một interface chung:

// ae.c chọn backend tốt nhất có sẵn khi compile:
#ifdef HAVE_EVPORT
#include "ae_evport.c"   // Solaris
#elif defined(HAVE_EPOLL)
#include "ae_epoll.c"    // Linux (mặc định)
#elif defined(HAVE_KQUEUE)
#include "ae_kqueue.c"   // macOS/BSD
#else
#include "ae_select.c"   // Fallback (bất kỳ hệ thống POSIX)
#endif

Phần còn lại của ae.c không bao giờ gọi epoll trực tiếp — nó gọi aeApiPoll, mà mỗi backend cài đặt. Đây là strategy pattern trong C.

Tại Sao Đơn Luồng Hoạt Động Tốt

Mô hình event loop tránh mọi đau đầu của multithreading:

  • Không mutex — chỉ một thread truy cập cấu trúc dữ liệu
  • Không deadlock — không có lock để acquire
  • Không overhead context switch — OS không phải juggling các thread

Bottleneck cho hầu hết workload Redis không phải CPU — mà là network I/O. OS kernel xử lý việc theo dõi hàng nghìn file descriptor đồng thời. Redis chỉ phản ứng với những gì sẵn sàng. Một handler tại một thời điểm, không chặn, không locking.

Lưu ý: Redis 6.0+ đã thêm I/O thread để đọc/ghi network data song song, nhưng thực thi lệnh vẫn là đơn luồng. Điều này bảo tồn sự đơn giản trong khi cải thiện throughput thô trên workload băng thông rất cao.


Kết Hợp Tất Cả Lại

Hãy theo dõi điều gì xảy ra khi bạn chạy SET counter 0 rồi INCR counter:

Mọi layer đóng góp vào tốc độ:

  • ae.c thức dậy chỉ vì socket sẵn sàng (không polling)
  • dict.c tìm thấy key trong O(1) với bitwise AND
  • t_string.c tăng số mà không cấp phát bộ nhớ
  • Phản hồi là string nhỏ 4 byte

Bài Học Chính Từ Source Code

Đọc source code Redis tiết lộ một tập hợp nguyên tắc thiết kế nhất quán:

1. Lưu số là số, không phải string
Integer encoding đóng gói giá trị vào slot con trỏ, loại bỏ heap allocation cho use case counter phổ biến nhất.

2. Dùng cấu trúc phù hợp cho kích thước
Listpack cho list nhỏ, quicklist cho list lớn. Encoding bị ẩn với người dùng nhưng tiết kiệm đáng kể bộ nhớ cho workload điển hình mà hầu hết list nhỏ.

3. Phân tán công việc tốn kém theo thời gian
Incremental rehashing đảm bảo không có thao tác đơn lẻ nào phải trả đủ chi phí resize hash table với hàng triệu entry.

4. Một thread, OS-level multiplexing
Event loop scale đến hàng nghìn kết nối mà không có thread hay lock, vì việc chờ network I/O được xử lý bởi kernel, không phải Redis thread.

5. Mọi thứ đều là robj
Một wrapper đa hình duy nhất làm cho việc thêm kiểu dữ liệu mới, eviction policy và lệnh mới trở nên đơn giản mà không cần viết lại infrastructure.


Cách Khám Phá Code Tự Mình

# Clone repository
git clone https://github.com/redis/redis.git
cd redis
 
# Bắt đầu với entry point
vim src/server.c   # Main function, server startup, command table
 
# Tìm bất kỳ command handler nào
grep -n "void setCommand" src/t_string.c
grep -n "void lrangeCommand" src/t_list.c
grep -n "void zaddCommand" src/t_zset.c
 
# Tìm kiếm nơi một khái niệm được cài đặt
grep -rn "OBJ_ENCODING_INT" src/
grep -rn "dictRehash" src/

Thứ tự đọc gợi ý:

  1. server.h — Hiểu các kiểu cốt lõi (robj, client, redisServer)
  2. t_string.c — Cài đặt lệnh đơn giản nhất
  3. dict.c — Hash table dùng ở khắp nơi
  4. ae.c — Event loop
  5. t_list.c, t_zset.c — Kiểu dữ liệu phức tạp hơn

Codebase Redis dùng style nhất quán, tên hàm rõ ràng và comment xuất sắc. Đừng bị đe dọa bởi C — hầu hết logic thú vị đọc như pseudocode khi bạn qua được các khai báo kiểu.


Tổng Kết

Redis nhanh không phải bằng phép màu, mà bằng những tối ưu hóa có chủ đích, nhiều lớp ở mọi tầng:

✅ robj cung cấp wrapper thống nhất làm cho tất cả kiểu dữ liệu có thể kết hợp
✅ Integer encoding loại bỏ heap allocation cho giá trị counter
✅ List tự động chọn lưu trữ compact hay linked dựa trên kích thước
✅ Incremental rehashing phân tán chi phí resize qua hàng nghìn request
✅ Event loop xử lý concurrency lớn với không thread và không lock

Lần tới khi bạn chạy INCR counter hay LPUSH queue item, bạn sẽ biết chính xác điều gì đang xảy ra trong code C bên dưới.


Tài Nguyên Bổ Sung

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.