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 → 执行命令 → 返回结果
2 ↓
3 全程内存操作,微秒级
内存访问延迟约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
2 │
3 ▼
4+------------------+ +------------------+
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。