Redis 为什么这么快?从源码角度拆解性能根因

面试官问"Redis 为什么快",很多人脱口而出"单线程没锁"。但这只是表象。真正的原因是:精心设计的数据结构、极致的内存优化、非阻塞I/O、渐进式算法……这篇文章从源码层面逐一拆解,带你彻底理解 Redis 为什么快的底层逻辑。

一、先看数据:Redis 真的快吗?

基准测试(redis-benchmark):

1$ redis-benchmark -t set,get -n 100000 -q
2SET: 110231.24 requests per second
3GET: 118121.12 requests per second

单实例10万QPS是常态,好的机器能到15万。这个数字对单线程程序来说相当惊人。

二、单线程:是优势,也是选择

2.1 为什么选单线程?

Redis作者antirez解释过:

Redis是内存数据库,瓶颈在内存带宽和网络I/O,不在CPU。多线程的锁竞争、上下文切换开销,可能比收益还大。

核心逻辑:

1客户端请求 → 解析命令 → 查找 key → 执行命令 → 返回结果
23   全程内存操作,微秒级

内存访问延迟约100ns,网络往返约100μs——差三个数量级。多线程加速CPU部分意义不大,反而引入锁开销。

2.2 单线程快在哪?

无锁

 1// db.c - 执行GET命令,直接读,不加锁
 2robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
 3    dictEntry *de;
 4    if (expireIfNeeded(db, key) == 1) {
 5        // key 过期了
 6    }
 7    de = dictFind(db->dict, key->ptr);
 8    if (de) {
 9        robj *val = dictGetVal(de);
10        return val;
11    }
12    return NULL;
13}

多线程的话,这里要加读锁。写操作加写锁时,读操作全阻塞。

无上下文切换:线程切换要保存寄存器、刷新TLB、切换栈,开销约1-2μs。Redis单线程绑在一个核上,一直跑。

缓存友好:单线程顺序访问,CPU的L1/L2缓存命中率高。

2.3 单线程的边界

单线程不是万能的。耗时操作会阻塞:

  • KEYS * 遍历所有key
  • 大Hash的 HGETALL
  • 大集合的SUNION

所以Redis设计了:

  • SCAN替代KEYS
  • 后台线程处理AOF fsync、异步删除
  • fork子进程做RDB/AOF重写

三、I/O多路复用:一个线程管万级连接

单线程怎么处理大量连接?靠I/O多路复用。

3.1 ae 事件循环

感兴趣的可以参考一下之前介绍的一篇文章(Redis 事件循环模型全景解析

1// ae.c
2void aeMain(aeEventLoop *eventLoop) {
3    eventLoop->stop = 0;
4    while (!eventLoop->stop) {
5        if (eventLoop->beforesleep)
6            eventLoop->beforesleep(eventLoop);
7        aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
8    }
9}

核心是aeApiPoll,底层调用epoll_wait(Linux):

 1// ae_epoll.c
 2static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
 3    aeApiState *state = eventLoop->apidata;
 4    int retval, numevents = 0;
 5
 6    // 超时时间由最近的定时器决定
 7    retval = epoll_wait(state->epfd, state->events, eventLoop->setsize,
 8                        tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1);
 9    if (retval > 0) {
10        numevents = retval;
11        for (j = 0; j < numevents; j++) {
12            int mask = 0;
13            struct epoll_event *e = state->events + j;
14
15            if (e->events & EPOLLIN) mask |= AE_READABLE;
16            if (e->events & EPOLLOUT) mask |= AE_WRITABLE;
17            if (e->events & EPOLLERR) mask |= AE_WRITABLE;
18            if (e->events & EPOLLHUP) mask |= AE_WRITABLE;
19
20            eventLoop->fired[j].fd = e->data.fd;
21            eventLoop->fired[j].mask = mask;
22        }
23    }
24    return numevents;
25}

关键点:只有活跃的连接才会返回。1万个连接里可能只有100个在发命令,epoll只返回这100个,其他9900个零开销。

3.2 文件事件和时间事件

 1int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
 2    // 1. 计算超时时间(由最近时间事件决定)
 3    shortest = aeSearchNearestTimer(eventLoop);
 4
 5    // 2. 等待I/O事件
 6    numevents = aeApiPoll(eventLoop, tvp);
 7
 8    // 3. 处理文件事件
 9    for (j = 0; j < numevents; j++) {
10        if (mask & AE_READABLE) fe->rfileProc(...);
11        if (mask & AE_WRITABLE) fe->wfileProc(...);
12    }
13
14    // 4. 处理时间事件(serverCron)
15    processTimeEvents(eventLoop);
16}

时间事件主要是serverCron,每秒跑hz次(默认10次),做:

  • 清理过期key
  • 更新统计信息
  • 触发AOF重写/RDB保存
  • 集群心跳

四、数据结构:为性能而设计

Redis的数据结构不是学术上的标准实现,而是针对实际场景优化过的版本。

4.1 SDS:字符串的正确打开方式

C字符串的问题:

1char *s = "hello";
2strlen(s);  // O(N),要遍历到'\0'
3strcat(s, " world");  // 可能越界

Redis的SDS:

1// sds.h
2struct __attribute__ ((__packed__)) sdshdr8 {
3    uint8_t len;          // 1字节:已用长度
4    uint8_t alloc;        // 1字节:分配长度
5    unsigned char flags;  // 1字节:类型
6    char buf[];           // 柔性数组
7};

len记录长度,strlen变O(1)。

空间预分配

 1// sds.c
 2sds sdsMakeRoomFor(sds s, size_t addlen) {
 3    size_t avail = sdsavail(s);
 4    if (avail >= addlen) return s;
 5
 6    size_t newlen = sdslen(s) + addlen;
 7    if (newlen < SDS_MAX_PREALLOC)  // 1MB
 8        newlen *= 2;                // 翻倍
 9    else
10        newlen += SDS_MAX_PREALLOC; // 加1MB
11    return sdsResize(s, newlen);
12}

追加N次,最多log(N)次内存分配。

二进制安全:buf里可以有\0,len记录真实长度。

4.2 ziplist:极致紧凑

小数据量时,Redis 用ziplist而不是链表或哈希表:

1+--------+--------+--------+--------+--------+--------+
2|zlbytes |zltail  |zllen   | entry  | entry  |zlend   |
3| 4B     | 4B     | 2B     | ...    | ...    | 1B(0xFF)|
4+--------+--------+--------+--------+--------+--------+

每个entry:

1+-------------------+----------+---------+
2| prevlen           | encoding | data    |
3| 1B or 5B          | 1-5B     | 变长     |
4+-------------------+----------+---------+

prevlen记录前一个entry的长度,支持从后往前遍历。

优势

  • 连续内存,CPU缓存友好
  • 无指针开销(链表每个节点两个指针,64位系统就是16字节)

代价:插入/删除要移动内存,O(N)。所以只在元素少时用。

4.3 intset:整数集合的优雅实现

全是整数的Set,用intset:

1// intset.h
2typedef struct intset {
3    uint32_t encoding;  // INTSET_ENC_INT16/INT32/INT64
4    uint32_t length;
5    int8_t contents[];  // 有序数组
6} intset;

特点:

  • 有序数组,二分查找O(log N)
  • 自动升级编码:插入int64时整个数组升级,不降级
  • 内存连续,无指针

4.4 dict:渐进式rehash

哈希表扩容是老大难问题。一次性迁移的话,百万元素可能卡顿几秒。

Redis的解法:渐进式rehash

1// dict.h
2typedef struct dict {
3    dictht ht[2];      // 两个哈希表
4    long rehashidx;    // -1表示未rehash,否则是当前迁移到哪个桶
5} dict;

每次增删改查,顺便迁移一个桶:

 1static void _dictRehashStep(dict *d) {
 2    if (d->iterators == 0) dictRehash(d, 1);
 3}
 4
 5int dictRehash(dict *d, int n) {
 6    int empty_visits = n * 10;  // 最多访问10n个空桶
 7    while (n-- && d->ht[0].used != 0) {
 8        // 跳过空桶
 9        while (d->ht[0].table[d->rehashidx] == NULL) {
10            d->rehashidx++;
11            if (--empty_visits == 0) return 1;
12        }
13        // 迁移这个桶的链表
14        dictEntry *de = d->ht[0].table[d->rehashidx];
15        while (de) {
16            uint64_t h = dictHashKey(d, de->key) & d->ht[1].sizemask;
17            dictEntry *nextde = de->next;
18            de->next = d->ht[1].table[h];
19            d->ht[1].table[h] = de;
20            d->ht[0].used--;
21            d->ht[1].used++;
22            de = nextde;
23        }
24        d->ht[0].table[d->rehashidx] = NULL;
25        d->rehashidx++;
26    }
27    // 全部迁移完
28    if (d->ht[0].used == 0) {
29        zfree(d->ht[0].table);
30        d->ht[0] = d->ht[1];
31        _dictReset(&d->ht[1]);
32        d->rehashidx = -1;
33        return 0;
34    }
35    return 1;
36}

大迁移拆成无数小步骤,对客户端完全透明。

4.5 quicklist:鱼和熊掌兼得

Redis 3.2后List的默认实现:

 1// quicklist.h
 2typedef struct quicklist {
 3    quicklistNode *head;
 4    quicklistNode *tail;
 5    unsigned long count;     // 元素总数
 6    unsigned long len;       // 节点数
 7    int fill : 16;           // 每个ziplist的大小限制
 8    unsigned int compress : 16;  // 中间节点压缩
 9} quicklist;
10
11typedef struct quicklistNode {
12    struct quicklistNode *prev;
13    struct quicklistNode *next;
14    unsigned char *zl;       // ziplist
15    unsigned int sz;
16    unsigned int count : 16;
17    unsigned int encoding : 2;  // RAW or LZF
18} quicklistNode;

结构:

1quicklist
234+------------------+     +------------------+
5| Node: ziplist    |────▶| Node: ziplist    |────▶ ...
6| [a,b,c,d,e,f]    |◀────│ [g,h,i,j,k,l]    |
7+------------------+     +------------------+

中间节点可以用LZF压缩,省内存:

1// 配置:list-compress-depth 1
2// 意思:首尾各1个节点不压缩,中间的压缩

4.6 编码自动选择

Redis会根据数据特征自动切换编码:

 1// object.c
 2robj *tryObjectEncoding(robj *o) {
 3    long value;
 4    sds s = o->ptr;
 5    if (o->encoding == OBJ_ENCODING_RAW) {
 6        // 尝试转成整数
 7        if (string2l(s, sdslen(s), &value)) {
 8            if (value >= 0 && value < OBJ_SHARED_INTEGERS) {
 9                // 用共享整数对象
10                decrRefCount(o);
11                incrRefCount(shared.integers[value]);
12                return shared.integers[value];
13            } else {
14                // 用embstr或raw
15                if (value >= LONG_MIN && value <= LONG_MAX) {
16                    o->encoding = OBJ_ENCODING_INT;
17                    o->ptr = (void*) value;
18                    sdsfree(s);
19                }
20            }
21        }
22    }
23    return o;
24}

各类型的编码选择:

类型 条件 编码
String 整数,范围0-9999 共享对象
String 整数,范围外 INT
String 长度≤44字节 EMBSTR
String 长度>44字节 RAW
Hash 元素少,值小 ziplist
Hash 否则 dict
List 始终 quicklist
Set 全整数,数量少 intset
Set 否则 dict
ZSet 元素少,值小 ziplist
ZSet 否则 skiplist+dict

五、内存优化:省钱就是赚性能

5.1 共享对象

启动时创建0-9999的共享整数:

1// server.c
2for (int j = 0; j < OBJ_SHARED_INTEGERS; j++) {
3    shared.integers[j] = makeObjectShared(createObject(OBJ_STRING, (void*)(long)j));
4    shared.integers[j]->encoding = OBJ_ENCODING_INT;
5}

还有常用字符串:

1shared.ok = createObject(OBJ_STRING, sdsnew("+OK\r\n"));
2shared.err = createObject(OBJ_STRING, sdsnew("-ERR\r\n"));
3shared.nullbulk = createObject(OBJ_STRING, sdsnew("$-1\r\n"));

高频对象不用重复创建。

5.2 jemalloc

Redis默认用jemalloc:

1// zmalloc.c
2#ifdef USE_JEMALLOC
3#define malloc(size) je_malloc(size)
4#define free(ptr) je_free(ptr)
5#endif

jemalloc比glibc malloc:

  • 减少内存碎片
  • 多线程性能更好
  • 更好的内存统计

5.3 内存淘汰

内存满时,Redis可以淘汰数据:

 1// evict.c
 2int freeMemoryIfNeeded(void) {
 3    int keys_freed = 0;
 4    while (mem_freed < mem_tofree) {
 5        if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_LRU ||
 6            server.maxmemory_policy == MAXMEMORY_VOLATILE_LRU) {
 7            // 淘汰LRU key
 8            evictionPoolPopulate();
 9        } else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM) {
10            // 随机淘汰
11        }
12        // ...
13    }
14}

淘汰策略可配置:LRU、LFU、TTL、随机等。

六、异步操作:不让慢操作拖后腿

6.1 bio后台线程

1// bio.c
2#define BIO_CLOSE_FILE    0
3#define BIO_AOF_FSYNC     1
4#define BIO_LAZY_FREE     2

异步删除

 1// lazyfree.c
 2void lazyfreeFreeObjectFromBioThread(robj *o) {
 3    decrRefCount(o);
 4}
 5
 6// 客户端执行UNLINK
 7void unlinkCommand(client *c) {
 8    if (lazyfreeGetFreeEffort(val) > LAZYFREE_THRESHOLD) {
 9        bioCreateBackgroundJob(BIO_LAZY_FREE, val, NULL, NULL);
10    }
11}

大对象删除不阻塞主线程。

6.2 fork子进程持久化

RDB保存:

 1int rdbSaveBackground(char *filename) {
 2    pid_t childpid;
 3    if ((childpid = fork()) == 0) {
 4        // 子进程写RDB
 5        rdbSave(filename);
 6        exit(0);
 7    }
 8    // 父进程继续服务
 9    server.rdb_child_pid = childpid;
10    return C_OK;
11}

fork后子进程继承父进程内存(copy-on-write),父进程继续服务。只有写操作时才复制内存页。

七、网络优化

7.1 多路复用连接处理

前面说过,epoll只返回活跃连接。

7.2 批量读写

 1// networking.c
 2int writeToClient(int fd, client *c) {
 3    ssize_t nwritten;
 4    int totwritten = 0;
 5
 6    while(1) {
 7        if (c->bufpos > 0) {
 8            nwritten = write(fd, c->buf + c->sentlen, c->bufpos - c->sentlen);
 9            if (nwritten <= 0) break;
10            c->sentlen += nwritten;
11            totwritten += nwritten;
12        }
13        // 还有reply链表里的数据...
14    }
15    return totwritten;
16}

一次write尽量多写。

7.3 管道支持

客户端可以一次发多条命令:

1SET a 1
2SET b 2
3SET c 3

Redis一次性处理,一次性返回,减少网络往返。

八、总结

优化维度 具体措施 收益
线程模型 单线程+I/O多路复用 无锁、无切换、高并发
数据结构 SDS、ziplist、intset、quicklist 内存紧凑、缓存友好
哈希表 渐进式rehash 扩容不阻塞
内存管理 共享对象、jemalloc 减少分配、减少碎片
慢操作 bio后台线程、fork子进程 主线程不被阻塞
网络 epoll、批量读写、管道 减少syscall、减少往返

Redis 为什么快?答案已经清晰:单线程无锁、I/O多路复用、紧凑数据结构、渐进式rehash、共享对象、后台异步处理……每一项优化单独看都不复杂,但组合在一起,就是10万QPS的基石。理解这些原理,才能真正用好 Redis。

— END —