Skip to content
Go back

Redis 面试回答——数据结构、持久化、高可用与缓存设计

Redis 面试回答

① 核心数据结构(String / List / Hash / Set / ZSet)

一句话结论(30s)

Redis 的 5 种类型都不是「一种类型一种结构」,而是每种类型都有多种底层编码,根据数据量和元素大小在「省内存」和「快操作」之间动态切换——因为 Redis 是内存数据库、内存是最稀缺资源,所以小数据一律用连续内存的紧凑编码(int/embstr、listpack、intset),数据变大才切换到指针结构(raw、quicklist、dict、skiplist)。

核心原理(2min)

先带着一个问题读这五种类型:为什么同一种类型还要搞出多套底层编码?——答案始终是「内存在 Redis 里最贵」:数据量小时用紧凑的连续内存编码省空间,数据大了再切换到指针结构换更快的操作,边界条件就是「空间」和「速度」的权衡点。

底层深入(5-10min)

SDS 为什么替代 C 字符串:C 的 char* 有 O(n) 取长度、缓冲区溢出、遇 \0 截断(二进制不安全)三个问题。SDS 用 len 字段 O(1) 取长、alloc 跟踪容量自动扩容、len 指定长度实现二进制安全。Redis 3.2 起按长度拆成 sdshdr5/8/16/32/64 五种头(sdshdr5 已废弃,都从 sdshdr8 起步),并用 __attribute__((__packed__)) 禁用编译器对齐填充,短字符串的头只占 3 字节。

真实源码 sds.h 里的五种头结构——注意 __packed__buf[] 柔性数组:

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

五种头按 len/alloc 的位宽 8/16/32/64 分级,短串用 sdshdr8 头只占 3 字节;__packed__ 关掉编译器对齐填充,flags 低 3 位存类型(SDS_TYPE_*)、buf[] 是柔性数组紧跟头后,所以对外返回的 sds 指针其实指向 buf,真正的头藏在指针前面(sdslen 通过 s[-1] 读 flags 再定位头)。

按长度选头类型的 sdsReqTypesds.c):

char sdsReqType(size_t string_size) {
    if (string_size < 1 << 5) return SDS_TYPE_5;
    if (string_size <= (1 << 8) - sizeof(struct sdshdr8) - 1) return SDS_TYPE_8;
    if (string_size <= (1 << 16) - sizeof(struct sdshdr16) - 1) return SDS_TYPE_16;
#if (LONG_MAX == LLONG_MAX)
    if (string_size <= (1ll << 32) - sizeof(struct sdshdr32) - 1) return SDS_TYPE_32;
    return SDS_TYPE_64;
#else
    return SDS_TYPE_32;
#endif
}

阈值是「该类型能表示的最大长度减去头大小再减 1(\0)」倒推出来的,正好让每个长度段都用上不浪费空间的头;< 1<<5 本应选 SDS_TYPE_5,但 type 5 不存 alloc 无法预留空间,追加时每次都得 realloc,所以实际写入都从 sdshdr8 起步。

embstr 的 44 字节阈值是怎么来的:这是最漂亮的点,可以主动展开。redisObject 固定 16 字节(type/encoding/lru 位域共 4B + refcount 4B + ptr 8B),sdshdr8 头 3 字节,\0 1 字节。jemalloc 最接近的 size class 是 64 字节 bucket64 - 16 - 3 - 1 = 44,所以内容恰好 44 字节能一次 malloc 填满 64B bucket,零指针开销零碎片。超过 44 字节就升级 raw,要多一次 malloc。这个「44」不是拍脑袋,而是「一次 malloc 刚好填满一个 64B 内存块、不浪费任何字节」倒推出来的——想通这个推导过程,比背下 44 这个数值值钱得多。embstr 是只读的:因为它没有预留空间,一旦 APPEND 需要扩容,realloc 后无法保证 RedisObject 和 SDS 的连续性,所以直接升级为 raw。这里也值得问一句:为什么不留点余量给 embstr?——留了余量,连续分配就不一定正好塞进一个 bucket,反而破坏「零碎片」这个初衷。

/* Create a string object with EMBSTR encoding if it is smaller than
 * OBJ_ENCODING_EMBSTR_SIZE_LIMIT, otherwise the RAW encoding is
 * used.
 *
 * The current limit of 44 is chosen so that the biggest string object
 * we allocate as EMBSTR will still fit into the 64 byte arena of jemalloc. */
#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44
robj *createStringObject(const char *ptr, size_t len) {
    if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT)
        return createEmbeddedStringObject(ptr,len);
    else
        return createRawStringObject(ptr,len);
}

官方注释把「44」的来历直接写在源码里——the current limit of 44 is chosen so that ... will still fit into the 64 byte arena of jemalloc,这就是上面那段推导的「官方证明」。createStringObject 一条 if (len <= 44) 就完成了 embstr / raw 的编码选择,超过 44 字节走 createRawStringObject(两次分配)。

SDS 扩容策略:小于 1MB 时翻倍,大于 1MB 时每次 +1MB——比 Java ArrayList 的 1.5 倍更激进,因为字符串频繁增长时翻倍能显著减少 realloc 次数。

真实源码 _sdsMakeRoomForsds.c):

    len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    reqlen = newlen = (len+addlen);
    assert(newlen > len);   /* Catch size_t overflow */
    if (greedy == 1) {
        if (newlen < SDS_MAX_PREALLOC)
            newlen *= 2;
        else
            newlen += SDS_MAX_PREALLOC;
    }

SDS_MAX_PREALLOC 就是 1MB(#define SDS_MAX_PREALLOC (1024*1024)):新长度小于 1MB 就翻倍,否则固定 +1MB。这个「小于翻倍、大于线性加」的双段策略,在小串时摊薄 realloc 次数、大串时避免预留空间指数爆炸;greedy == 1 对应 sdsMakeRoomForgreedy == 0sdsMakeRoomForNonGreedy(只扩到刚好够,用于需要精确控制内存的场景)。

quicklist 与 listpack(连锁更新):quicklist 外层双向链表,每个节点内是一段 ziplist(7.0 起换成 listpack)。ziplist 的经典问题是连锁更新——每个 entry 用 prevlen 记录前一个 entry 长度,前一个 <254B 时 prevlen 占 1B、≥254B 时占 5B,一旦某个 entry 跨过 254 字节边界,后面所有 entry 的 prevlen 都要级联膨胀,O(N) 重分配。quicklist 把元素分散到多个独立节点,连锁更新被限制在单个节点内部(≤几百个 entry)。Redis 7.0 更进一步用 listpack 根除连锁更新:把「记录前驱长度」的 prevlen 换成「记录自身长度」的 backlen,自己的长度写入时就确定、不随前驱变化,级联的根源被消除。这两代修复揭示了一个通用思路:连锁问题的根源往往是「我的状态依赖别人」,quicklist 是「把范围切小让影响局部化」,listpack 是「把依赖方向倒过来让状态只由自己决定」——先限制、再根除。

dict 渐进式 rehash:dict 内部有 ht[0]ht[1] 两个哈希表。扩容时新建更大的 ht[1],但不一次性搬完,而是每次对该 dict 的增删改查操作顺带把 ht[0] 里的若干个桶迁移到 ht[1],直到搬完再交换。先想「一次性搬完会怎样」——Redis 是单线程,一个大字典一次性 rehash 会长时间阻塞其他命令,几十万 key 就能卡住几百毫秒。渐进式把「一次大阻塞」摊成「每次操作多付一点点」,这正是单线程系统里「削峰」的典型手法。

真实源码 dictRehashdict.c):

int dictRehash(dict *d, int n) {
    int empty_visits = n*10; /* Max number of empty buckets to visit. */
    unsigned long s0 = DICTHT_SIZE(d->ht_size_exp[0]);
    unsigned long s1 = DICTHT_SIZE(d->ht_size_exp[1]);
    dictResizeEnable can_resize;
    atomicGet(dict_can_resize, can_resize);
    if (can_resize == DICT_RESIZE_FORBID || !dictIsRehashing(d)) return 0;
    /* If dict_can_resize is DICT_RESIZE_AVOID, we want to avoid rehashing.
     * - If expanding, the threshold is dict_force_resize_ratio which is 4.
     * - If shrinking, the threshold is 1 / (HASHTABLE_MIN_FILL * dict_force_resize_ratio) which is 1/32. */
    if (can_resize == DICT_RESIZE_AVOID &&
        ((s1 > s0 && s1 < dict_force_resize_ratio * s0) ||
         (s1 < s0 && s0 < HASHTABLE_MIN_FILL * dict_force_resize_ratio * s1)))
    {
        return 0;
    }

    while(n-- && d->ht_used[0] != 0) {
        /* Note that rehashidx can't overflow as we are sure there are more
         * elements because ht[0].used != 0 */
        assert(DICTHT_SIZE(d->ht_size_exp[0]) > (unsigned long)d->rehashidx);
        while(d->ht_table[0][d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        /* Move all the keys in this bucket from the old to the new hash HT */
        rehashEntriesInBucketAtIndex(d, d->rehashidx);
        d->rehashidx++;
    }

    return !dictCheckRehashingCompleted(d);
}

n 是每次调用最多迁移的非空桶个数,empty_visits = n*10 限制「最多跳过 10n 个空桶」,防止稀疏表里空桶过多时单次调用耗时失控;rehashidx 记录已搬到的桶位置,搬完整个 ht[0] 后由 dictCheckRehashingCompleted 交换两张表并复位 rehashidx = -1

普通增删改查每次顺带只搬一个桶,入口就是 _dictRehashStep

static void _dictRehashStep(dict *d) {
    if (d->pauserehash == 0) dictRehash(d,1);
}

每次对 dict 的查/写都会调用它,把「一次搬完的大卡顿」摊成「每次操作只搬 1 桶」;pauserehash 在迭代器活跃期间暂停迁移,避免元素被漏掉或重复。

ZSet 的跳表:跳表是多层有序链表,底层(level 0)含全部节点,越往上节点越稀疏,每层随机以 1/4 概率(p=0.25)向上一层提升。查找从最高层开始:当前层下一个节点的 score 比目标大就降一层,否则前进,平均 O(log N)。找到起点后沿 level 0 顺序遍历即可做范围查询。可以先想「一个链表怎么就能做到 O(log N)」——单层链表只能顺序扫 O(N),跳表靠「多建几层稀疏的『高速路』,从高处大步跳、逼近了再降到细粒度」把查找压到对数级,本质是「用额外的索引层换查找速度」。为什么不用红黑树?跳表实现简单、天然支持范围遍历和 rank 排名、插入删除不用旋转(平衡树要旋转再平衡),在内存数据库里这种「简单 + 够快」最契合 antirez 的设计哲学。


② 持久化(RDB / AOF / 混合)

一句话结论(30s)

Redis 靠两种持久化互补——RDB 快照保「恢复快」、AOF 日志保「不丢数据」,生产环境用 4.0 的混合持久化两者兼得——因为 RDB 是二进制快照、恢复秒级但窗口期丢数据,AOF 逐条回放、丢得少但恢复慢文件大,混合文件「前段 RDB + 后段 AOF」各取所长。

核心原理(2min)

先问一句:RDB 和 AOF 为什么是「互补」而不是「二选一」?——因为一个要「恢复快」就必须牺牲「丢得少」,一个要「丢得少」就必然「恢复慢」,两者正好各占一头,谁也替不了谁,才有了后面的混合方案。

底层深入(5-10min)

RDB 的 fork + COW 真实入口bgsave 最终落到 rdbSaveBackgroundrdb.c),核心就是 redisFork 一个子进程后父子分头走:

int rdbSaveBackground(int req, char *filename, rdbSaveInfo *rsi, int rdbflags) {
    pid_t childpid;

    if (hasActiveChildProcess()) return C_ERR;
    server.stat_rdb_saves++;

    server.dirty_before_bgsave = server.dirty;
    server.lastbgsave_try = time(NULL);

    if ((childpid = redisFork(CHILD_TYPE_RDB)) == 0) {
        int retval;

        /* Child */
        redisSetProcTitle("redis-rdb-bgsave");
        redisSetCpuAffinity(server.bgsave_cpulist);
        retval = rdbSave(req, filename,rsi,rdbflags);
        if (retval == C_OK) {
            sendChildCowInfo(CHILD_INFO_TYPE_RDB_COW_SIZE, "RDB");
        }
        exitFromChild((retval == C_OK) ? 0 : 1, 0);
    } else {
        /* Parent */
        if (childpid == -1) {
            server.lastbgsave_status = C_ERR;
            serverLog(LL_WARNING,"Can't save in background: fork: %s",
                strerror(errno));
            return C_ERR;
        }
        serverLog(LL_NOTICE,"Background saving started by pid %ld",(long) childpid);
        server.rdb_save_time_start = time(NULL);
        server.rdb_child_type = RDB_CHILD_TYPE_DISK;
        return C_OK;
    }
    return C_OK; /* unreached */
}

redisFork() == 0 是 fork 的分岔点:返回 0 的是子进程(执行 rdbSave 把内存序列化写盘、写完 exitFromChild 退出),非 0 的是父进程(记录状态立即 return C_OK 继续服务请求)。hasActiveChildProcess() 保证同一时刻只有一个后台子进程,避免多个 RDB/AOF 重写同时 fork 带来的双重内存与写放大——这也是「fork 阻塞 + COW」发生的真正位置。

bgrewriteaof 重写期间不丢数据:AOF 越来越大(同一 key 反复写就多条命令),bgrewriteaof fork 子进程按当前内存生成最小化等效文件。先想一个矛盾:子进程照着 fork 时刻的快照生成新文件,可这期间父进程还在收新写入——这些新写入会不会被漏掉?这正是重写期间父进程的新写入怎么办这个问题的来源,Redis 用双缓冲区解决:父进程继续写旧 AOF 文件(保证宕机能从旧文件恢复),同时把新命令写入 aof_rewrite_buf;子进程按 fork 时快照生成新文件,完成后父进程把 aof_rewrite_buf 里的命令追加到新文件末尾,再 rename 原子替换。aof_buf(正常刷盘)和 aof_rewrite_buf(重写兜底)是两个独立缓冲区,所以即使宕机旧 AOF 始终完整。

AOF 的「先执行命令、再写缓冲区、最后刷盘」在 aof.c 里是两条链:feedAppendOnlyFile 负责把命令追加进 aof_buf

    /* Append to the AOF buffer. This will be flushed on disk just before
     * of re-entering the event loop, so before the client will get a
     * positive reply about the operation performed. */
    if (server.aof_state == AOF_ON ||
        (server.aof_state == AOF_WAIT_REWRITE && server.child_type == CHILD_TYPE_AOF))
    {
        server.aof_buf = sdscatlen(server.aof_buf, buf, sdslen(buf));
    }

flushAppendOnlyFile 再按 appendfsync 三档决定何时 fsync(always / everysec / no 对应的分支就在 server.aof_fsync 上):

            if (server.aof_fsync == AOF_FSYNC_EVERYSEC &&
                server.mstime - server.aof_last_fsync >= 1000 &&
                !(sync_in_progress = aofFsyncInProgress()))
                goto try_fsync;

            /* Check if we need to do fsync even the aof buffer is empty,
             * the reason is described in the previous AOF_FSYNC_EVERYSEC block,
             * and AOF_FSYNC_ALWAYS is also checked here to handle a case where
             * aof_fsync is changed from everysec to always. */
            if (server.aof_fsync == AOF_FSYNC_ALWAYS)
                goto try_fsync;

EVERYSEC 分支用 mstime - aof_last_fsync >= 1000 判断「距上次 fsync 已满 1 秒」才刷,且 fsync 进行中会先推迟写入、最多等 2 秒;ALWAYS 则每次都走到 try_fsync。注释里那句「buffer 为空也要 fsync」是处理 everysec 停写后数据还在 page cache 的边界情况——这就是「最多丢 1 秒」的落点。

AOF 重写触发:两个条件——auto-aof-rewrite-percentage(默认 100,当前 AOF 比上次重写后增长了一倍)和 auto-aof-rewrite-min-size(默认 64MB,文件至少这么大才触发),两个同时满足才触发。也可手动 BGREWRITEAOF

大实例 fork 阻塞 + THP:fork 要拷贝父进程的页表(不是数据)。先问为什么只拷页表不拷数据——因为 COW 承诺「数据等我真去改时才复制」,fork 只要把「地址映射关系」复制一份、让父子指向同一片物理内存即可;但映射关系(页表)本身是必须完整复制的。32GB 实例可能有 800 万+ 页表项,fork 拷贝页表阻塞 200-500ms。优化是关闭 THP(透明大页)——THP 把 4KB 页合并成 2MB 大页,COW 粒度也从 4KB 变 2MB,一次修改触发 512 倍副本复制,严重放大写放大。生产务必 echo never > /sys/kernel/mm/transparent_hugepage/enabled


③ IO 模型(单线程快 / 6.0 多线程)

一句话结论(30s)

Redis 单线程快不是因为线程少,而是因为瓶颈在网络 IO 不在 CPU——用 epoll 多路复用让一个线程同时处理成千上万个连接,纯内存操作又避开了锁竞争和上下文切换;6.0 的多线程只把最耗 CPU 的网络读写和协议解析拿出来并行,命令执行仍是单线程。

核心原理(2min)

回答「单线程为什么快」之前,先纠正问题本身:快不快,从来不是看「用了几个线程」,而是看「瓶颈卡在哪」。Redis 的瓶颈在网络 IO、不在 CPU,所以多线程的算力优势根本用不上,反而会带来锁竞争。

单线程快的四个原因:

  1. 纯内存操作,访问是纳秒级,CPU 不是瓶颈,网络 IO 才是。
  2. 单线程无锁,没有多线程锁竞争和上下文切换的开销。
  3. epoll 多路复用,一个线程用事件驱动 + 非阻塞 IO 同时监控多个 socket 的可读可写事件。
  4. 高效数据结构,跳表/dict/SDS 都是 O(1) 或 O(log N)。

6.0 多线程:默认关闭(io-threads 默认 1),开启后只在网络读写 + 协议解析阶段用多线程,命令的执行、数据结构的操作仍由主线程单线程完成。为什么命令不并行?因为数据结构操作非线程安全,加锁的代价可能超过并行收益,且命令执行本身极快,瓶颈本就不在这。

底层深入(5-10min)

epoll 为什么比 select/poll 强:select/poll 每次调用都要把整个 fd 集合从用户态拷到内核态、再 O(n) 遍历所有 fd 找出就绪的;epoll 用 epoll_ctl 注册一次、内核用红黑树维护 fd、就绪事件用就绪链表 epoll_wait 直接取,复杂度从 O(n) 降到 O(就绪数),fd 数量大时优势巨大——这正是 Redis 单线程扛上万连接的基础。

真实源码 ae_epoll.caeApiPoll——事件循环的核心就是这一次 epoll_wait

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 + 999)/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|AE_READABLE;
            if (e->events & EPOLLHUP) mask |= AE_WRITABLE|AE_READABLE;
            eventLoop->fired[j].fd = e->data.fd;
            eventLoop->fired[j].mask = mask;
        }
    } else if (retval == -1 && errno != EINTR) {
        panic("aeApiPoll: epoll_wait, %s", strerror(errno));
    }

    return numevents;
}

epoll_wait 一次只返回就绪的 fd(retval 就是就绪数),后面的 for 循环只遍历就绪事件、不是遍历全部 fd,复杂度 O(就绪数);而 epoll_ctlaeApiAddEvent 里)已把 fd 提前注册进内核红黑树,无需每次把整个 fd 集合拷进内核——这正是单线程扛住上万连接的基础。

单线程设计的本质权衡:Redis 选择单线程是因为「内存操作 + 网络多路复用」已经足够快,瓶颈在单机网络带宽和内存大小,而不在 CPU 核数。多线程带来的锁竞争、数据一致性复杂度,收益不成正比。值得想的是:既然当年结论是「单线程够快」,为什么 6.0 又加回了多线程?——因为瓶颈是会移动的。6.0 引入 IO 多线程是应对「单线程解析大量协议包占满 CPU」的新瓶颈:网络吞吐到 10 万 QPS 级别后,socket 的 read/write 和协议解析成了 CPU 热点,把这些纯计算、无共享状态的环节并行化,命令执行仍串行,既提速又不引入数据竞争。这个「移动」提醒我们:架构决策不是一劳永逸的,它只在某个量级区间内成立。


④ 高可用(哨兵 / Cluster / 脑裂)

一句话结论(30s)

哨兵解决「主挂了怎么自动切换」、Cluster 解决「数据多了怎么横向扩容」,脑裂靠 min-slaves-to-write 让被隔离的旧主拒写——因为网络分区后旧主收不到从库心跳,一旦它发现自己「从库数为 0」就拒绝写入,两个主同时写数据的窗口就被关闭。

核心原理(2min)

哨兵两件事:监控(持续 PING 主从判活)+ 故障转移(主挂选新主、通知从库和客户端)。主库下线分两步:单个哨兵 PING 超时是 SDOWN 主观下线,再向其他哨兵确认、超过 quorum 个同意才是 ODOWN 客观下线,才触发切换。切换前哨兵之间先用 Raft 风格多数派投票选出一个 Leader 执行,Leader 再按三轮打分选新主。

Cluster:把数据按 CRC16(key) % 16384 分到 16384 个槽,槽再分给各主节点。节点间用 Gossip 协议做去中心化的节点发现和故障检测,无中心协调者、无单点。

脑裂:网络分区后旧主被隔离仍自以为主继续收写,哨兵又选出了新主 → 两个主并存数据冲突。先想「网络一断,旧主凭什么还自以为是主」——因为「谁是主」这个身份不是全局实时协商出来的,而是写在本地状态里的;旧主收不到「你被降级」的通知,就只能按旧身份继续干活。防护是 min-slaves-to-write 1 + min-slaves-max-lag 10:旧主的从库都被切到新主那边去了,旧主从库数为 0 不满足条件 → 拒绝写入。这套防护的聪明之处在于:不试图去「通知旧主你下课了」(通知可能根本送不到),而是让旧主自己因为「看不到从库」而主动闭嘴。

底层深入(5-10min)

三轮打分选新主(哨兵文章的核心,可主动展开):选新主最难的不是「选不出」,而是「多个候选里必须选出唯一一个,还得尽量数据最全、最合配置」,三轮打分就是给「先比什么、再比什么」定优先级。

真实源码 compareSlavesForPromotionsentinel.c)——三轮打分的完整比较器:

int compareSlavesForPromotion(const void *a, const void *b) {
    sentinelRedisInstance **sa = (sentinelRedisInstance **)a,
                          **sb = (sentinelRedisInstance **)b;
    char *sa_runid, *sb_runid;

    if ((*sa)->slave_priority != (*sb)->slave_priority)
        return (*sa)->slave_priority - (*sb)->slave_priority;

    /* If priority is the same, select the slave with greater replication
     * offset (processed more data from the master). */
    if ((*sa)->slave_repl_offset > (*sb)->slave_repl_offset) {
        return -1; /* a < b */
    } else if ((*sa)->slave_repl_offset < (*sb)->slave_repl_offset) {
        return 1; /* a > b */
    }

    /* If the replication offset is the same select the slave with that has
     * the lexicographically smaller runid. Note that we try to handle runid
     * == NULL as there are old Redis versions that don't publish runid in
     * INFO. A NULL runid is considered bigger than any other runid. */
    sa_runid = (*sa)->runid;
    sb_runid = (*sb)->runid;
    if (sa_runid == NULL && sb_runid == NULL) return 0;
    else if (sa_runid == NULL) return 1;  /* a > b */
    else if (sb_runid == NULL) return -1; /* a < b */
    return strcasecmp(sa_runid, sb_runid);
}

这是一个 qsort 比较器,sentinelSelectSlave 对候选从库数组排序后取 instance[0] 即胜者。注意方向:slave_priority升序(小的排前),slave_repl_offset降序(offset 大返回 -1 排前);前两轮都平票才落到 runid 的字典序,纯确定性、绝不随机,保证多个哨兵最终选出同一个新主。

Gossip 协议(Cluster 文章):每个节点维护已知节点列表,每 100ms 随机选几个节点发 PING(带自己的节点视图 + 槽位分配),收到 PONG 后合并视图,O(log N) 轮内全集群收敛。故障检测两级:单节点 PING 超时标 PFAIL(主观),PFAIL 经 Gossip 传播、半数以上主节点确认后标 FAIL(客观) 触发转移。代价是最终一致——新节点要几秒到几十秒才被全集群知晓,符合 CAP 的 AP 选择。

16384 槽位为什么不是 65536:每个节点的心跳包要携带自己的槽位位图,16384 bit = 2KB,若用 65536 槽位图膨胀到 8KB,Gossip 带宽翻 4 倍。16384 是「心跳包保持在 ~2KB」和「槽位粒度」之间的精确平衡。


⑤ 高性能原理(过期删除 / 内存分配)

一句话结论(30s)

Redis 高性能的底层支撑是分配回收两件事都做了精细优化——分配用 jemalloc 把碎片率压到 1.0-1.2,回收用「惰性删除 + 定期删除」配淘汰策略,不让过期 key 白占内存,因为内存数据库的容量和延迟都直接取决于碎片率和过期 key 的清理效率。

核心原理(2min)

底层深入(5-10min)

jemalloc 四层架构(jemalloc 文章):L1 tcache 线程缓存无锁分配 <10ns;miss/overflow 时批量从 L2 arena fill/flush,锁竞争降 N 倍;L2 arena 内 bins(small)+ extents(红黑树管理,O(log n) 最适合匹配 + 合并相邻空闲块);L3 Huge 单独管理。延迟回收用 madvise(MADV_DONTNEED) 标记 OS 可回收物理页但保留虚拟地址,下次直接复用无需 mmap——这也是「used_memory 降了但 used_memory_rss 不降」的原因,是特性不是 bug。size class 每级增长约 12.5%,内碎片控制在 ≤12.5%。

近似 LRU 为什么不用真正的双向链表:先想标准 LRU 在 Redis 里为什么不划算——经典 LRU 每个 key 要维护 prev/next 两指针,内存开销大;且单线程每次访问移动节点本身是 CPU 开销。Redis 在每个 redisObject 里用一个 24 位 lru 字段记最后访问时间,淘汰时随机抽 maxmemory-samples(默认 5)个 key,挑 lru 最小的淘汰,不够再重复。antirez 的洞察:不需要找绝对最久未用的,N 个随机候选里找相对最久的已经足够好。这个洞察可以这样内化:很多「精确」是用昂贵代价换来的,而工程上「足够好」往往就够了——关键是想清楚「这个精确度到底值不值那个价」。Redis 7.0 加淘汰池(16 个候选的小顶堆跨多轮采样)进一步提升精度。

LFU 的 8 位对数计数器 + 时间衰减:LFU 复用原来的 24 位字段——高 16 位 ldt 上次衰减时间、低 8 位 logc 对数访问计数。为什么访问次数要用「对数」而不是「线性」计数?——线性的话,8 位最多记到 255 次就溢出了;而对数增长下「访问 10 次」和「访问百万次」能同时被区分开(约 3 vs 约 13),用一个字节就装下几千万倍的跨度。计数器按 概率 = 1/(counter*lfu_log_factor+1) 增长,所以 8 位就能区分「访问 10 次(约 3)」和「访问百万次(约 13)」。纯 LFU 会让过气热 key 永不淘汰,所以加时间衰减:每访问一次先按 lfu_decay_time(默认 1 分钟)衰减旧计数再累加,让计数器能「忘记」。三个关键参数:maxmemory-samples 5lfu_log_factor 10lfu_decay_time 1

真实源码 LFUDecrAndReturnevict.c)——时间衰减的实现:

unsigned long LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8;
    unsigned long counter = o->lru & 255;
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;
    return counter;
}

24 位 lru 字段被拆成「高 16 位 ldt 上次衰减时间 + 低 8 位 counter 对数计数」(o->lru >> 8o->lru & 255);num_periods 是距上次衰减经过了多少个 lfu_decay_time(默认 1 分钟),每过一个周期计数减 1、减到 0 为止——这就是计数器能「忘记」过气热 key 的来源。

定期删除的真实循环 activeExpireCycleexpire.c)——serverCron 周期调用的核心,每轮随机抽一批带过期时间的 key,先定出这一轮要扫多少个:

        do {
            unsigned long num;
            iteration++;

            /* If there is nothing to expire try next DB ASAP. */
            if ((num = kvstoreSize(db->expires)) == 0) {
                db->avg_ttl = 0;
                break;
            }
            data.now = mstime();

            /* The main collection cycle. Scan through keys among keys
             * with an expire set, checking for expired ones. */
            data.sampled = 0;
            data.expired = 0;

            if (num > config_keys_per_loop)
                num = config_keys_per_loop;

然后是真正的扫描循环,随机起一个游标 expires_cursor,逐个 key 回调 expireScanCallback 判断是否过期:

            while (data.sampled < num && checked_buckets < max_buckets) {
                db->expires_cursor = kvstoreScan(db->expires, db->expires_cursor, -1, expireScanCallback, expirySamplingShouldSkipDict, &data);
                if (db->expires_cursor == 0) {
                    db_done = 1;
                    break;
                }
                checked_buckets++;
            }
            total_expired += data.expired;
            total_sampled += data.sampled;

config_keys_per_loop 默认 20(随 active-expire-effort 上调),每轮最多随机扫 20 个带过期时间的 key;是否继续扫由 repeat 决定:

            repeat = db_done ? 0 : (data.sampled == 0 || (data.expired * 100 / data.sampled) > config_cycle_acceptable_stale);

过期比例 > config_cycle_acceptable_stale(默认 10%)就继续、直到降到 10% 以下,同时外层受 timelimit 时间上限(默认约 25ms)约束——这就是「定期删除」既清内存又不卡住主线程的削峰实现。


⑥ 缓存设计(穿透 / 击穿 / 雪崩 / 双写一致)

一句话结论(30s)

穿透、击穿、雪崩本质都是「大量请求同时绕过缓存打到数据库」——穿透是查不存在的 key 用布隆过滤器挡,击穿是热点 key 过期用互斥锁只放一个回源,雪崩是大量 key 同时过期或 Redis 挂了用 TTL 随机化 + 高可用 + 多级缓存扛;双写一致的核心是「先更新数据库再删缓存 + 过期时间兜底」,因为删缓存是最终一致方案里不一致概率最低的。

核心原理(2min)

问题根因主防线备用防线
穿透请求不存在的 key,每次 miss 全打 DB布隆过滤器拦截非法 key缓存空值(短 TTL 5 分钟)
击穿热点 key 过期,同瞬几千请求打 DB互斥锁(SET NX)只放一个回源永不过期 / 逻辑过期
雪崩大量 key 同时过期 或 Redis 宕机TTL 随机化 + 哨兵/集群多级缓存(Caffeine)+ 熔断降级

双写一致走 Cache Aside(旁路缓存):读——先查缓存,miss 查 DB 写缓存;写——先更新数据库、再删除缓存,靠 TTL 兜底最终一致。

底层深入(5-10min)

布隆过滤器(穿透终极方案):预加载所有合法 ID,mightContain(id) 判断——不存在的一定返回 false 直接拦掉,存在只是可能(有误判率)。为什么它敢说「不存在就一定不存在、存在只是可能」?——因为布隆的每个 bit 都是「多个哈希共同标记」出来的,一个元素只要进来过,它对应的 bit 就一定被置 1;反过来「所有 bit 都是 1」不代表「就是它」,可能是别的元素撞出来的。这种「宁可误报、绝不漏报」的不对称性,正是它适合当「第一道拦截网」的原因。Guava BloomFilter 单机、Redisson RBloomFilter 基于 Redis Bitmap 支持分布式。缓存空值是兜底:查 DB 为 null 时也写一个空值、短 TTL,防止换 ID 攻击撑爆内存。

击穿的互斥锁:热点 key miss 后用 SET lock:key 1 NX EX 5 抢锁,抢到的去查 DB 写缓存,没抢到的 sleep 50ms 重试读缓存。也可用「逻辑过期」——热点 key 不设 TTL,value 里带过期时间戳,读时判断已过期就异步重建,旧值继续返回,避免阻塞。

SET lock:key 1 NX EX 5 为什么能当锁」的真实依据在 t_string.csetGenericCommand——NX/EX 是同一条命令的条件与过期选项,执行时先查 key 再决定写不写:

    dictEntryLink link = NULL;
    found = (lookupKeyWriteWithLink(c->db,key,&link) != NULL);

    if ((flags & OBJ_SET_NX && found) ||
        (flags & (OBJ_SET_XX | OBJ_SET_IFEQ | OBJ_SET_IFDEQ) && !found))
    {
        if (!(flags & OBJ_SET_GET)) {
            addReply(c, abort_reply ? abort_reply : shared.null[c->resp]);
        }
        return;
    }

OBJ_SET_NX && found 意味着「要求 key 不存在但已存在」,直接回 nil 返回、什么都不写OBJ_SET_XX && !found 则相反。因为 Redis 单线程,这个「lookup 判断 + 条件写入」中间不会被别的命令插队,所以 SET NX 天然是原子的抢锁原语——比 SETNX + EXPIRE 两步(中间宕机会留下一个永不过期的锁)安全。

Cache Aside 双写顺序为什么选「先 DB 后删缓存」:进入细节前先解决一个更基础的问题:为什么写操作是「删缓存」而不是「更新缓存」?——因为更新缓存会产生「并发写互相覆盖」和「算出来的值未必有人读」两个麻烦,删缓存让下一次读自然回源重建,简单且最终一致。再回到顺序:先删缓存再更新 DB,窗口期里并发读会把旧值写回缓存,旧值一直驻留到过期;先更新 DB 再删缓存,并发读只是瞬间读到旧值、缓存删掉后下次就从 DB 取新值。理论上仍有极小概率不一致(读 miss 时恰好卡在「DB 已更新、缓存还没删」读到了新值、又把新值之前的旧值写入……)——用延迟双删(更新 DB → 删缓存 → sleep 几百 ms → 再删一次)兜底,把这种窗口的脏值擦掉。核心心法:缓存是数据库的快照,不是真相源,一致性冲突时用 TTL 兜底,时间过去一定一致。

三种更新模式选型:Cache Aside(应用管缓存,90% 场景首选)/ Read-Through、Write-Through(缓存层代理 DB,强一致但写延迟高,Redis 本身不支持需 Lua 模拟)/ Write Back(只写缓存异步批量刷 DB,写性能最好但宕机丢数据,用于计数器、埋点)。


⑦ 并发控制(分布式锁)

一句话结论(30s)

Redis 分布式锁手写 SET NX EX 有三个坑——锁超时误删、释放不原子、不可重入,Redisson 用一段 Lua 脚本把「加锁 / 校验 / 释放」做成原子操作、再用 Watch Dog 自动续期解决,因为 Redis 单线程执行 Lua 脚本天然原子,能把「先判断再操作」的多步操作压成一步。

核心原理(2min)

先想为什么「看似一行 SET NX EX 就能搞定」的锁,实际会冒出这么多坑——因为锁不是「加上了」就结束,还得回答三个问题:业务跑超时了锁过期了怎么办、释放时怎么确认删的是自己的锁、同一线程要重入怎么办。下面三个坑就是这三个问题没答好。

手写锁的坑:

  1. 锁超时误删:锁 30 秒过期但业务跑 35 秒,锁被抢走后前持有者删了别人的锁。
  2. 释放不原子get 校验 owner 和 del 之间有窗口,校验通过后锁过期被抢,del 删的是别人的锁。
  3. 不可重入:同一线程调用链二次加锁,SET NX 返回 false 死锁。

Redisson 用 Lua 脚本 + Hash 结构解决:加锁用 hincrby key owner 1(owner = UUID:threadId),重入就 +1;释放用 hincrby -1,计数归 0 才真正 del。默认 leaseTime = -1Watch Dog,每 leaseTime/3(10 秒)续期,JVM 挂了就停止续期、锁 30 秒自动释放不死锁。

底层深入(5-10min)

加锁 Lua 脚本(原子判断不存在/重入/失败):

if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1)  -- ARGV[2]=UUID:threadId
    redis.call('pexpire', KEYS[1], ARGV[1])      -- ARGV[1]=ttl
    return nil
end
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1)   -- 重入
    redis.call('pexpire', KEYS[1], ARGV[1])
    return nil
end
return redis.call('pttl', KEYS[1])               -- 失败返回剩余 TTL

为什么 SET NX EX 是一条原子命令、而不是先 SETNXEXPIRE 两步——看 t_string.cSET 选项的解析 parseExtendedStringArgumentsOrReplyNXEX 都是同一条命令的参数位:

    for (; j < c->argc; j++) {
        char *opt = c->argv[j]->ptr;
        robj *next = (j == c->argc-1) ? NULL : c->argv[j+1];

        if ((opt[0] == 'n' || opt[0] == 'N') &&
            (opt[1] == 'x' || opt[1] == 'X') && opt[2] == '\0' &&
            !(args->flags & (cond_mut_excl & ~OBJ_SET_NX)) && (command_type == COMMAND_SET || command_type == COMMAND_MSETEX))
        {
            args->flags |= OBJ_SET_NX;
        } else if ((opt[0] == 'x' || opt[0] == 'X') &&
                   (opt[1] == 'x' || opt[1] == 'X') && opt[2] == '\0' &&
                   !(args->flags & (cond_mut_excl & ~OBJ_SET_XX)) && (command_type == COMMAND_SET || command_type == COMMAND_MSETEX))
        {
            args->flags |= OBJ_SET_XX;
        }

NX/XX 被解析成互斥的标志位(cond_mut_excl 保证不能同时出现),随后 EX 被解析成 OBJ_EX + 过期值,全部在同一次命令执行里完成。这就是「手写分布式锁」的地基——SET NX EX 本身是原子的,但「加锁 → 执行业务 → 释放」的完整生命周期是多个命令,跨命令的窗口才需要 Redisson 的 Lua 脚本去补。

为什么用 Hash + UUID:threadId 而不是 String:同一 JVM 内不同线程各自用 field 记重入次数,解锁时 hincrby -1、计数归 0 才 del,天然支持重入。释放锁同样是 Lua 原子:先 hexists 校验 owner 不是自己就返回,是就 hincrby -1,归 0 再 delpublish 通知等锁方。

Watch Dog 自动续期:Redisson 默认 watch dog 模式,用 Netty HashedWheelTimer 每 leaseTime/3(默认 10 秒)触发,Lua 判断 key 存在且 owner 匹配就 EXPIRE key 30s。关键:JVM 崩溃 → 续期停止 → 锁最多 30 秒自动释放,不会死锁。加锁失败不是忙轮询,而是订阅 Pub/Sub 频道,持锁方解锁时 PUBLISH 通知等锁方重试,避免 1000 线程轮询 Redis。

Redlock:单实例锁有个问题——Redis 宕机锁就没了。先想为什么实例一挂锁就丢:因为锁的「持有状态」就存在那个 Redis 实例里,状态和实例是绑定的,实例没了状态也没了。Redlock 是官方提出的多实例算法:在 N 个独立 Redis 节点上分别加锁,超过半数(N/2+1)成功且总耗时小于锁有效期才算加锁成功。解决单点故障,但它依赖「各节点时钟可靠」的假设,Martin Kleppmann 质疑其在 GC 停顿 / 时钟跳变下仍不安全。工程上:一般业务用单实例 Redisson 足够,只有对可用性要求极高且能接受复杂度的场景才上 Redlock。


追问清单(题库 10 问 · 一句话结论 + 展开)

1. String 底层怎么实现?SDS 和 C 字符串区别? 结论:String 底层是 SDS,按长度用 int/embstr/raw 三种编码。因为 SDS 用 len 字段 O(1) 取长度、alloc 跟踪容量自动扩容、len 指定长度二进制安全,恰好补掉 C 字符串「O(n) 取长、缓冲区溢出、遇 \0 截断」三个短板。3.2 起按长度拆 sdshdr5/8/16/32/64 五种头,短串只 3 字节头;≤44 字节用 embstr(RedisObject + SDS 一次 malloc 填满 64B jemalloc bucket),>44 字节用 raw。

2. ZSet 底层是什么?跳表怎么实现有序和范围查询? 结论:ZSet 小数据用 listpack、大数据用 dict + skiplist 双结构。因为 dict 按 member 查 score 是 O(1),而跳表按 score 有序、支持范围查询和排名,两者互补。跳表是多层有序链表,底层含全部节点,每层以 1/4 概率向上提升,查找从高层「大了就降层、否则前进」平均 O(log N),找到起点沿 level 0 顺序遍历即范围查询;比红黑树好在实现简单、无需旋转、天然支持 rank。

3. RDB 和 AOF 区别?生产环境怎么选?为什么? 结论:RDB 是二进制快照「恢复快但窗口期丢数据」,AOF 是写命令日志「最多丢 1 秒但恢复慢文件大」。因为两者互补,生产环境推荐混合持久化 + everysec:混合文件前段 RDB 保证秒级恢复、后段 AOF 保证安全性,everysec 在「最多丢 1 秒」和性能间折中,只要业务能接受这 1 秒的数据丢失(有数据库和幂等兜底)就是最优解。

4. AOF 重写怎么触发?重写过程中新写命令怎么处理? 结论:重写靠「文件增长比例 + 最小体积」双条件触发,重写期间用双缓冲区保证新写不丢。因为 auto-aof-rewrite-percentage(默认 100%)和 auto-aof-rewrite-min-size(默认 64MB)同时满足才触发 bgrewriteaof。子进程按 fork 快照生成新文件,父进程同时写旧 AOF 文件(兜底恢复)和 aof_rewrite_buf(收集新命令),子进程完成后父进程把缓冲区命令追加到新文件末尾再 rename 原子替换——即使宕机旧文件始终完整。

5. 为什么单线程还快?6.0 多线程做了什么? 结论:单线程快是因为瓶颈在网络 IO 不在 CPU,而非线程少。因为纯内存操作是纳秒级、单线程无锁竞争和上下文切换、epoll 多路复用一个线程扛上万连接、数据结构高效。6.0 多线程只在网络读写 + 协议解析阶段并行(默认关闭,io-threads 开启),命令执行仍是单线程——因为数据结构操作非线程安全,加锁代价超并行收益。

6. 哨兵和 Cluster 区别?脑裂怎么处理? 结论:哨兵管「主从自动切换」、Cluster 管「数据横向扩容」,是解决不同问题。因为哨兵靠监控 + 故障转移保高可用但单机容量有限,Cluster 用 16384 槽 + Gossip 去中心化把数据分散到多主节点。脑裂靠 min-slaves-to-write 1 + min-slaves-max-lag 10:旧主被隔离后从库都被切到新主,旧主从库数为 0 不满足条件就拒绝写入,关闭两个主并存写数据的窗口。

7. 过期键删除策略?惰性删除 + 定期删除各怎么做? 结论:惰性删除 + 定期删除结合,配内存淘汰兜底。因为惰性删除(访问时才检查过期)省 CPU 但会内存泄漏,定期删除(每 100ms activeExpireCycle 随机抽 20 个带过期 key 删掉、过期比例超 25% 就继续但限 25ms)省内存但可能误删——两者互补才能平衡。内存到 maxmemory 时再按 LRU/LFU 淘汰策略兜底,形成「惰性 → 定期 → 淘汰」三层。

8. 穿透、击穿、雪崩怎么解决? 结论:三者都靠「挡在 DB 前面」的思路解决。穿透(查不存在的 key)用布隆过滤器 + 缓存空值;击穿(热点 key 过期)用互斥锁只放一个回源 + 逻辑过期;雪崩(大量同时过期/Redis 挂)用 TTL 随机化 + 哨兵集群 + 多级缓存熔断降级。典型组合是「布隆过滤器防穿透、互斥锁防击穿、TTL 随机化防雪崩」三种,视业务场景再加本地缓存兜底。

9. 怎么保证 DB 和缓存双写一致?用什么方案? 结论:Cache Aside「先更新 DB、再删缓存 + TTL 兜底」,必要时延迟双删。因为先删缓存再更新 DB,窗口期并发读会把旧值写回缓存且驻留到过期;先 DB 后删缓存只是瞬间不一致、删掉后下次读即新值,概率最低。极端窗口再用延迟双删(更新 DB → 删缓存 → sleep → 再删)擦脏值;记住缓存是 DB 快照不是真相源,冲突时靠 TTL 保证最终一致。

10. Redis 怎么实现分布式锁?setnx 有什么问题?Redlock 呢? 结论:手写 SET NX EX 有「超时误删、释放不原子、不可重入」三个坑,Redisson 用 Lua 原子脚本 + Hash 重入计数 + Watch Dog 续期解决。因为 Redis 单线程执行 Lua 天然原子,能把「判断 owner + 删除」压成一步;Hash 的 field 记 UUID:threadId 支持重入,Watch Dog 每 10 秒续期防业务超时误删。Redlock 是多实例算法——N 个独立节点半数以上加锁成功才算成功,解决单实例宕机锁失效,但它依赖时钟假设、被 Martin Kleppmann 质疑,一般业务用单实例 Redisson 足够。


Share this post on:

Previous Post
SQL行转列——CASE WHEN + MAX + GROUP BY的通用范式
Next Post
MySQL 面试回答——事务 MVCC、索引、锁、日志与主从