|
欢迎各位同学关注我哦~
在这个 AI 喧嚣的时代 不忘初心,戒骄戒躁,认真沉淀 | |
面试官问"Redis 为什么快",很多人脱口而出"单线程没锁"。然而这只是表象。真正的原因是:精心设计的数据结构、极致的内存优化、非阻塞I/O、渐进式算法……这篇文章从源码层面逐一拆解,带你彻底理解 Redis 为什么快的底层逻辑。
一、先看数据:Redis 真的快吗?
基准测试(redis-benchmark):
$ redis-benchmark -t set,get -n 100000 -q
SET: 110231.24 requests per second
GET: 118121.12 requests per second
单实例10万QPS是常态,好的机器能到15万。这个数字对单线程程序来说相当惊人。
二、单线程:是优势,也是选择
2.1 为什么选单线程?
Redis作者antirez解释过:
Redis是内存数据库,瓶颈在内存带宽和网络I/O,不在CPU。多线程的锁竞争、上下文切换开销,可能比收益还大。
核心逻辑:
客户端请求 → 解析命令 → 查找 key → 执行命令 → 返回结果
↓
全程内存操作,微秒级
内存访问延迟约100ns,网络往返约100μs——差三个数量级。多线程加速CPU部分意义不大,反而引入锁开销。
2.2 单线程快在哪?
无锁:
// db.c:98 - 执行GET命令,直接读,不加锁
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
robj *val;
if (expireIfNeeded(db,key) == 1) {
// key 过期了
if (server.masterhost == NULL) return NULL;
// 从节点处理逻辑...
}
val = lookupKey(db, key, flags);
return val;
}
// db.c:53 - 底层查找函数
robj *lookupKey(redisDb *db, robj *key, int flags) {
dictEntry *de = dictFind(db->dict,key->ptr);
if (de) {
robj *val = dictGetVal(de);
// 更新LRU/LFU...
return val;
} else {
return NULL;
}
}
多线程的话,这里要加读锁。写操作加写锁时,读操作全阻塞。
无上下文切换:线程切换要保存寄存器、刷新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 事件循环模型全景解析)
// ae.c
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
if (eventLoop->beforesleep)
eventLoop->beforesleep(eventLoop);
aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
}
}
核心是aeApiPoll,底层调用epoll_wait(Linux):
// ae_epoll.c:108
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
aeApiState *state = eventLoop->apidata;
int retval, numevents = 0;
// 超时时间由最近的定时器决定
retval = epoll_wait(state->epfd, state->events, eventLoop->setsize,
tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1);
if (retval > 0) {
int j;
numevents = retval;
for (j = 0; j < numevents; j++) {
int mask = 0;
struct epoll_event *e = state->events + j;
if (e->events & EPOLLIN) mask |= AE_READABLE;
if (e->events & EPOLLOUT) mask |= AE_WRITABLE;
if (e->events & EPOLLERR) mask |= AE_WRITABLE;
if (e->events & EPOLLHUP) mask |= AE_WRITABLE;
eventLoop->fired[j].fd = e->data.fd;
eventLoop->fired[j].mask = mask;
}
}
return numevents;
}
关键点:只有活跃的连接才会返回。1万个连接里可能只有100个在发命令,epoll只返回这100个,其他9900个零开销。
3.2 文件事件和时间事件
int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
// 1. 计算超时时间(由最近时间事件决定)
shortest = aeSearchNearestTimer(eventLoop);
// 2. 等待I/O事件
numevents = aeApiPoll(eventLoop, tvp);
// 3. 处理文件事件
for (j = 0; j < numevents; j++) {
if (mask & AE_READABLE) fe->rfileProc(...);
if (mask & AE_WRITABLE) fe->wfileProc(...);
}
// 4. 处理时间事件(serverCron)
processTimeEvents(eventLoop);
}
时间事件主要是serverCron,每秒跑hz次(默认10次),做:
- 清理过期key
- 更新统计信息
- 触发AOF重写/RDB保存
- 集群心跳
四、数据结构:为性能而设计
Redis的数据结构不是学术上的标准实现,而是针对实际场景优化过的版本。
4.1 SDS:字符串的正确打开方式
C字符串的问题:
char *s = "hello";
strlen(s); // O(N),要遍历到'\0'
strcat(s, " world"); // 可能越界
Redis的SDS:
// sds.h
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // 1字节:已用长度
uint8_t alloc; // 1字节:分配长度
unsigned char flags; // 1字节:类型
char buf[]; // 柔性数组
};
len记录长度,strlen变O(1)。
空间预分配:
// sds.c:204
sds sdsMakeRoomFor(sds s, size_t addlen) {
void *sh, *newsh;
size_t avail = sdsavail(s);
size_t len, newlen;
char type, oldtype = s[-1] & SDS_TYPE_MASK;
int hdrlen;
if (avail >= addlen) return s; // 空间足够,直接返回
len = sdslen(s);
sh = (char*)s - sdsHdrSize(oldtype);
newlen = (len + addlen);
if (newlen < SDS_MAX_PREALLOC) // 小于1MB:翻倍
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC; // 大于1MB:加1MB
type = sdsReqType(newlen);
if (type == SDS_TYPE_5) type = SDS_TYPE_8; // 不用type 5,无法记录空闲空间
hdrlen = sdsHdrSize(type);
if (oldtype == type) {
newsh = s_realloc(sh, hdrlen + newlen + 1); // 头部大小不变,直接realloc
} else {
newsh = s_malloc(hdrlen + newlen + 1); // 头部大小变化,重新malloc
memcpy((char*)newsh + hdrlen, s, len + 1);
s_free(sh);
}
s = (char*)newsh + hdrlen;
s[-1] = type;
sdssetlen(s, len);
sdssetalloc(s, newlen);
return s;
}
追加N次,最多log(N)次内存分配。
二进制安全:buf里可以有\0,len记录真实长度。
4.2 ziplist:极致紧凑
小数据量时,Redis 用ziplist而不是链表或哈希表:
+--------+--------+--------+--------+--------+--------+
|zlbytes |zltail |zllen | entry | entry |zlend |
| 4B | 4B | 2B | ... | ... | 1B(0xFF)|
+--------+--------+--------+--------+--------+--------+
每个entry:
+-------------------+----------+---------+
| prevlen | encoding | data |
| 1B or 5B | 1-5B | 变长 |
+-------------------+----------+---------+
prevlen记录前一个entry的长度,支持从后往前遍历。
优势:
- 连续内存,CPU缓存友好
- 无指针开销(链表每个节点两个指针,64位系统就是16字节)
代价:插入/删除要移动内存,O(N)。所以只在元素少时用。
4.3 intset:整数集合的优雅实现
全是整数的Set,用intset:
// intset.h
typedef struct intset {
uint32_t encoding; // INTSET_ENC_INT16/INT32/INT64
uint32_t length;
int8_t contents[]; // 有序数组
} intset;
特点:
- 有序数组,二分查找O(log N)
- 自动升级编码:插入int64时整个数组升级,不降级
- 内存连续,无指针
4.4 dict:渐进式rehash
哈希表扩容是老大难问题。一次性迁移的话,百万元素可能卡顿几秒。
Redis的解法:渐进式rehash。
// dict.h
typedef struct dict {
dictht ht[2]; // 两个哈希表
long rehashidx; // -1表示未rehash,否则是当前迁移到哪个桶
} dict;
每次增删改查,顺便迁移一个桶:
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d, 1);
}
int dictRehash(dict *d, int n) {
int empty_visits = n * 10; // 最多访问10n个空桶
while (n-- && d->ht[0].used != 0) {
// 跳过空桶
while (d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
// 迁移这个桶的链表
dictEntry *de = d->ht[0].table[d->rehashidx];
while (de) {
uint64_t h = dictHashKey(d, de->key) & d->ht[1].sizemask;
dictEntry *nextde = de->next;
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
d->ht[0].used--;
d->ht[1].used++;
de = nextde;
}
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}
// 全部迁移完
if (d->ht[0].used == 0) {
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
_dictReset(&d->ht[1]);
d->rehashidx = -1;
return 0;
}
return 1;
}
大迁移拆成无数小步骤,对客户端完全透明。
4.5 quicklist:鱼和熊掌兼得
Redis 3.2后List的默认实现:
// quicklist.h
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; // 元素总数
unsigned long len; // 节点数
int fill : 16; // 每个ziplist的大小限制
unsigned int compress : 16; // 中间节点压缩
} quicklist;
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl; // ziplist
unsigned int sz;
unsigned int count : 16;
unsigned int encoding : 2; // RAW or LZF
} quicklistNode;
结构:
quicklist
│
▼
+------------------+ +------------------+
| Node: ziplist |────▶| Node: ziplist |────▶ ...
| [a,b,c,d,e,f] |◀────│ [g,h,i,j,k,l] |
+------------------+ +------------------+
中间节点可以用LZF压缩,省内存:
// 配置:list-compress-depth 1
// 意思:首尾各1个节点不压缩,中间的压缩
4.6 编码自动选择
Redis会根据数据特征自动切换编码:
// object.c:433
robj *tryObjectEncoding(robj *o) {
long value;
sds s = o->ptr;
size_t len;
// 只对 RAW 或 EMBSTR 编码的对象进行编码
if (!sdsEncodedObject(o)) return o;
// 不对共享对象进行编码
if (o->refcount > 1) return o;
len = sdslen(s);
// 尝试将字符串表示为长整型
if (len <= 20 && string2l(s, len, &value)) {
// 如果可以表示为长整型,并且不超过最大共享整数限制,尝试使用共享整数对象
if ((server.maxmemory == 0 ||
!(server.maxmemory_policy & MAXMEMORY_FLAG_NO_SHARED_INTEGERS)) &&
value >= 0 &&
value < OBJ_SHARED_INTEGERS)
{
decrRefCount(o);
incrRefCount(shared.integers[value]);
return shared.integers[value];
} else {
// 否则,将对象编码方式设置为整数类型
if (o->encoding == OBJ_ENCODING_RAW) sdsfree(o->ptr);
o->encoding = OBJ_ENCODING_INT;
o->ptr = (void*) value;
return o;
}
}
// 如果字符串较小且仍然是 RAW 编码的,尝试使用 EMBSTR 编码
if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT) {
robj *emb;
if (o->encoding == OBJ_ENCODING_EMBSTR) return o;
emb = createEmbeddedStringObject(s, sdslen(s));
decrRefCount(o);
return emb;
}
return o;
}
各类型的编码选择:
| 类型 | 条件 | 编码 |
|---|---|---|
| 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 |
*注:共享整数0-9999有前提条件:未设置maxmemory,或maxmemory_policy不包含MAXMEMORY_FLAG_NO_SHARED_INTEGERS标志(如LRU/LFU淘汰策略会禁用共享整数)
五、内存优化:省钱就是赚性能
5.1 共享对象
启动时创建0-9999的共享整数:
// server.c
for (int j = 0; j < OBJ_SHARED_INTEGERS; j++) {
shared.integers[j] = makeObjectShared(createObject(OBJ_STRING, (void*)(long)j));
shared.integers[j]->encoding = OBJ_ENCODING_INT;
}
还有常用字符串:
shared.ok = createObject(OBJ_STRING, sdsnew("+OK\r\n"));
shared.err = createObject(OBJ_STRING, sdsnew("-ERR\r\n"));
shared.nullbulk = createObject(OBJ_STRING, sdsnew("$-1\r\n"));
高频对象不用重复创建。
5.2 jemalloc
Redis默认用jemalloc:
// zmalloc.c
#ifdef USE_JEMALLOC
#define malloc(size) je_malloc(size)
#define free(ptr) je_free(ptr)
#endif
jemalloc比glibc malloc:
- 减少内存碎片
- 多线程性能更好
- 更好的内存统计
5.3 内存淘汰
内存满时,Redis可以淘汰数据:
// evict.c
int freeMemoryIfNeeded(void) {
int keys_freed = 0;
while (mem_freed < mem_tofree) {
if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_LRU ||
server.maxmemory_policy == MAXMEMORY_VOLATILE_LRU) {
// 淘汰LRU key
evictionPoolPopulate();
} else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM) {
// 随机淘汰
}
// ...
}
}
淘汰策略可配置:LRU、LFU、TTL、随机等。
六、异步操作:不让慢操作拖后腿
6.1 bio后台线程
// bio.c
#define BIO_CLOSE_FILE 0
#define BIO_AOF_FSYNC 1
#define BIO_LAZY_FREE 2
异步删除:
// lazyfree.c
void lazyfreeFreeObjectFromBioThread(robj *o) {
decrRefCount(o);
}
// 客户端执行UNLINK
void unlinkCommand(client *c) {
if (lazyfreeGetFreeEffort(val) > LAZYFREE_THRESHOLD) {
bioCreateBackgroundJob(BIO_LAZY_FREE, val, NULL, NULL);
}
}
大对象删除不阻塞主线程。
6.2 fork子进程持久化
RDB保存:
int rdbSaveBackground(char *filename) {
pid_t childpid;
if ((childpid = fork()) == 0) {
// 子进程写RDB
rdbSave(filename);
exit(0);
}
// 父进程继续服务
server.rdb_child_pid = childpid;
return C_OK;
}
fork后子进程继承父进程内存(copy-on-write),父进程继续服务。只有写操作时才复制内存页。
七、网络优化
7.1 多路复用连接处理
前面说过,epoll只返回活跃连接。
7.2 批量读写
// networking.c
int writeToClient(int fd, client *c) {
ssize_t nwritten;
int totwritten = 0;
while(1) {
if (c->bufpos > 0) {
nwritten = write(fd, c->buf + c->sentlen, c->bufpos - c->sentlen);
if (nwritten <= 0) break;
c->sentlen += nwritten;
totwritten += nwritten;
}
// 还有reply链表里的数据...
}
return totwritten;
}
一次write尽量多写。
7.3 管道支持
客户端可以一次发多条命令:
SET a 1
SET b 2
SET c 3
Redis一次性处理,一次性返回,减少网络往返。
八、总结
| 优化维度 | 具体措施 | 收益 |
|---|---|---|
| 线程模型 | 单线程+I/O多路复用 | 无锁、无切换、高并发 |
| 数据结构 | SDS、ziplist、intset、quicklist | 内存紧凑、缓存友好 |
| 哈希表 | 渐进式rehash | 扩容不阻塞 |
| 内存管理 | 共享对象、jemalloc | 减少分配、减少碎片 |
| 慢操作 | bio后台线程、fork子进程 | 主线程不被阻塞 |
| 网络 | epoll、批量读写、管道 | 减少syscall、减少往返 |
Redis 为什么快?答案已经清晰:单线程无锁、I/O多路复用、紧凑数据结构、渐进式rehash、共享对象、后台异步处理……
|
欢迎各位同学关注我哦~
在这个 AI 喧嚣的时代 不忘初心,戒骄戒躁,认真沉淀 | |

1671

被折叠的 条评论
为什么被折叠?



