Redis 面试回答
① 核心数据结构(String / List / Hash / Set / ZSet)
一句话结论(30s)
Redis 的 5 种类型都不是「一种类型一种结构」,而是每种类型都有多种底层编码,根据数据量和元素大小在「省内存」和「快操作」之间动态切换——因为 Redis 是内存数据库、内存是最稀缺资源,所以小数据一律用连续内存的紧凑编码(int/embstr、listpack、intset),数据变大才切换到指针结构(raw、quicklist、dict、skiplist)。
核心原理(2min)
先带着一个问题读这五种类型:为什么同一种类型还要搞出多套底层编码?——答案始终是「内存在 Redis 里最贵」:数据量小时用紧凑的连续内存编码省空间,数据大了再切换到指针结构换更快的操作,边界条件就是「空间」和「速度」的权衡点。
- String:底层是 SDS(Simple Dynamic String),有三种编码——
int(能转 long 的整数,指针直接存值)、embstr(≤44 字节短串,一次分配连续内存)、raw(>44 字节长串,两次分配)。 - List:Redis 3.2 起统一用
quicklist,是「双向链表 + listpack」的混合结构,外层链表负责快速头尾操作,内层 listpack 省指针开销。 - Hash:小数据用
listpack(连续紧凑),大数据用hashtable(dict),dict 扩容走渐进式 rehash。 - Set:元素全为整数且数量少时用
intset,否则用hashtable(value 置 null)。 - ZSet:小数据用
listpack,大数据用 dict + skiplist 双结构——dict 按 member 查 score 是 O(1),skiplist 按 score 排序支持范围和排名查询。
底层深入(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 再定位头)。
按长度选头类型的 sdsReqType(sds.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 字节 bucket:64 - 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 次数。
真实源码 _sdsMakeRoomFor(sds.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 对应 sdsMakeRoomFor,greedy == 0 是 sdsMakeRoomForNonGreedy(只扩到刚好够,用于需要精确控制内存的场景)。
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 就能卡住几百毫秒。渐进式把「一次大阻塞」摊成「每次操作多付一点点」,这正是单线程系统里「削峰」的典型手法。
真实源码 dictRehash(dict.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 为什么是「互补」而不是「二选一」?——因为一个要「恢复快」就必须牺牲「丢得少」,一个要「丢得少」就必然「恢复慢」,两者正好各占一头,谁也替不了谁,才有了后面的混合方案。
- RDB:
bgsavefork 一个子进程,利用 COW(写时复制) 把内存快照序列化成二进制文件,最后rename原子替换。优点是紧凑、恢复快,适合冷备和主从全量同步;缺点是两次快照之间的写入宕机全丢。 - AOF:记录每条写命令,先执行命令、写 AOF 缓冲区,再按
appendfsync刷盘——always(每条 fsync,最安全最慢)、everysec(每秒 fsync,最多丢 1 秒,折中)、no(交给 OS,最快)。优点是安全(everysec 最多丢 1 秒)、文件可读;缺点是恢复慢、文件膨胀。 - 混合持久化:RDB 格式的全量快照 + AOF 格式的增量命令,加载时先解析 RDB(快)再回放少量增量(少),恢复从 AOF 的分钟级降到秒级,安全性等同 AOF。混合方案能成立的逻辑很简单:把「恢复」拆成「大头用快照秒恢复 + 尾巴用日志少量回放」,两头的好处都拿到。
底层深入(5-10min)
RDB 的 fork + COW 真实入口:bgsave 最终落到 rdbSaveBackground(rdb.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,所以多线程的算力优势根本用不上,反而会带来锁竞争。
单线程快的四个原因:
- 纯内存操作,访问是纳秒级,CPU 不是瓶颈,网络 IO 才是。
- 单线程无锁,没有多线程锁竞争和上下文切换的开销。
- epoll 多路复用,一个线程用事件驱动 + 非阻塞 IO 同时监控多个 socket 的可读可写事件。
- 高效数据结构,跳表/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.c 的 aeApiPoll——事件循环的核心就是这一次 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_ctl(aeApiAddEvent 里)已把 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)
三轮打分选新主(哨兵文章的核心,可主动展开):选新主最难的不是「选不出」,而是「多个候选里必须选出唯一一个,还得尽量数据最全、最合配置」,三轮打分就是给「先比什么、再比什么」定优先级。
- 第一轮
replica-priority:配置的优先级,越小越优先,0 = 永不选为主。 - 第二轮
slave_repl_offset:复制偏移量,越大说明与原主同步越完整、丢数据最少。 - 第三轮
runID:前两轮平票时用 runID 字典序兜底,纯确定性决策避免平票。 任一轮决出唯一胜者即停止。本质是尽量选出配置最合适、数据最完整的新主。
真实源码 compareSlavesForPromotion(sentinel.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)
- 内存分配 jemalloc:Redis 不用系统 malloc,因为它的模式是「大量小对象高频分配释放 + 碎片敏感」。jemalloc 用线程缓存(tcache 无锁)+ 细粒度 size class(200+ 个 bucket)+ 红黑树管理 extent + 延迟回收,碎片率远低于 ptmalloc 的 1.5-2.0。
- 过期删除(惰性 + 定期):惰性删除——访问 key 时才检查是否过期,对 CPU 友好但会内存泄漏;定期删除——
serverCron每 100ms 随机抽一批带过期时间的 key 删掉过期的,对内存友好但可能误删。两者结合,再配内存淘汰策略兜底。 - 淘汰策略:内存到
maxmemory时按策略淘汰。生产常用allkeys-lru(近似 LRU 随机采样)或allkeys-lfu(对数计数器 + 时间衰减)。
底层深入(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 5、lfu_log_factor 10、lfu_decay_time 1。
真实源码 LFUDecrAndReturn(evict.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 >> 8 与 o->lru & 255);num_periods 是距上次衰减经过了多少个 lfu_decay_time(默认 1 分钟),每过一个周期计数减 1、减到 0 为止——这就是计数器能「忘记」过气热 key 的来源。
定期删除的真实循环 activeExpireCycle(expire.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.c 的 setGenericCommand——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 就能搞定」的锁,实际会冒出这么多坑——因为锁不是「加上了」就结束,还得回答三个问题:业务跑超时了锁过期了怎么办、释放时怎么确认删的是自己的锁、同一线程要重入怎么办。下面三个坑就是这三个问题没答好。
手写锁的坑:
- 锁超时误删:锁 30 秒过期但业务跑 35 秒,锁被抢走后前持有者删了别人的锁。
- 释放不原子:
get校验 owner 和del之间有窗口,校验通过后锁过期被抢,del 删的是别人的锁。 - 不可重入:同一线程调用链二次加锁,
SET NX返回 false 死锁。
Redisson 用 Lua 脚本 + Hash 结构解决:加锁用 hincrby key owner 1(owner = UUID:threadId),重入就 +1;释放用 hincrby -1,计数归 0 才真正 del。默认 leaseTime = -1 走 Watch 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 是一条原子命令、而不是先 SETNX 再 EXPIRE 两步——看 t_string.c 里 SET 选项的解析 parseExtendedStringArgumentsOrReply,NX 和 EX 都是同一条命令的参数位:
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 再 del 并 publish 通知等锁方。
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 足够。