Redis源码探究系列—Redis 为什么这么快?

欢迎各位同学关注我哦~
在这个 AI 喧嚣的时代
不忘初心,戒骄戒躁,认真沉淀
Deepincode

面试官问"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 喧嚣的时代
不忘初心,戒骄戒躁,认真沉淀
Deepincode
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值