既有 Redis 论文修订

核查日期:2026-09-22。使用 chatgpt 6 修订该站 Redis 标签下的六篇文章。

本地版本依据实际 Git HEAD 确认:

本文简称 本地源码 固定提交
2018 快照 redis-unstable-2018-07-23 b65ddfb16a7060a543b523feadeca1234cffd323
2020 快照 redis-c01e94a-2020-08 c01e94a4319c416c4c231ffbea9e778d52424e30

二者均为 unstable 开发快照,不直接等同于 Redis 5.0 / 6.0 正式版。对象篇的 dict 分析使用 2018 快照,其余主要使用作者明确链接的 2020 快照;涉及跨版本结论的地方另行标注。

下文保留 32 处值得纠正或补充限定的表述:对象 8 处、Sentinel 6 处、持久化 5 处、事务 4 处、复制 3 处、基础机制 6 处。机制错误、条件遗漏、术语问题与明显笔误分别说明,不把它们视为同等严重。

每项先以引用块展示关键原话,再以“作者论述(转述)”补足前提、推理与限定,并保留原文位置。引用块中的“……”表示省略;转述不作为作者逐字原话。受同篇逐字引用长度限制,未将所有相关原段落完整转载。源码来自本地对应快照,/* … */ 表示中间省略;链接指向可核对的文件和起始行。除对象篇第 1、2 项另做了编译复现,其余结论来自源码和调用路径核查。两个源码目录未作修改。

当前对象篇已修正缩容扫描可能重复、安全迭代器及 2020 年模块扫描修复背景等旧问题,故未将旧笔记中的相关批评照搬进来。


Redis底层对象实现原理分析

博文原址。dict 以 2018 快照核查,其余以 2020 快照核查。

1. 强制扩容不保证把桶数翻倍

作者原文摘录(节选):

按照2倍

见原文小节。

作者论述(转述):讨论禁止常规扩容后的例外时,作者说负载比例超过默认阈值 5 仍可强制扩容,并把这次容量增长也解释为翻倍。

源码(2018 快照,b65ddfb): dict.c:933–954 · 固定提交链接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
     * the number of buckets. */
if (d->ht[0].used >= d->ht[0].size &&
(dict_can_resize ||
d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
{
return dictExpand(d, d->ht[0].used*2);
}
return DICT_OK;
}

/* Our hash table capability is a power of two */
static unsigned long _dictNextPower(unsigned long size)
{
unsigned long i = DICT_HT_INITIAL_SIZE;

if (size >= LONG_MAX) return LONG_MAX + 1LU;
while(1) {
if (i >= size)
return i;
i *= 2;
}
}

为什么有问题:传入 dictExpand 的是 used * 2,不是 size * 2;dictExpand 又在第 155 行通过 _dictNextPower 向上取整到 2 的幂。比如旧表有 4 个桶、24 个元素,关闭常规扩容后,24 / 4 > 5 成立,申请容量为 48,最终得到 64 个桶,是原来的 16 倍。只有通常在 used == size 附近触发扩容时,才常见桶数翻倍。这里源码上方的英文注释也不够严谨,应以实际表达式为准。

2. 渐进式 rehash 不是写时复制

作者原文摘录(节选):

又是COW了

见原文小节。

作者论述(转述):作者先指出分配新表时尚未迁移旧元素,再追问是否属于写时复制;紧接着给出肯定回答,随后介绍实际迁移函数。

源码(2018 快照,b65ddfb): dict.c:205–219 · 固定提交链接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
        uint64_t h;

nextde = de->next;
/* Get the index in the new hash table */
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
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++;
}

源码(2018 快照,b65ddfb): dict.c:476–483 · 固定提交链接。

1
2
3
4
5
6
7
8
dictEntry *dictFind(dict *d, const void *key)
{
dictEntry *he;
uint64_t h, idx, table;

if (d->ht[0].used + d->ht[1].used == 0) return NULL; /* dict is empty */
if (dictIsRehashing(d)) _dictRehashStep(d);
h = dictHashKey(d, key);

为什么有问题:这里重连的是同一批 dictEntry 节点的 next 指针,旧桶随后被清空,并未保留一份可供旧读者使用的副本。甚至 dictFind 这种查找操作也能推进搬迁。它是把迁移成本分摊到多次操作中的增量迁移,不是在写入时复制共享数据。Redis 为后台持久化 fork 后使用的操作系统 COW 是另一回事;两者会相互影响内存成本,但不是同一机制。

第 1、2 项的实际复现:使用独立验证程序,分别链接两个快照中原样的 dict.c、zmalloc.c 和 siphash.c,两次运行均得到:

1
2
forced expansion: old buckets=4, old entries=24, new buckets=64
dictFind advances rehashidx: 0 -> 1; same dictEntry pointer=yes

这项实验同时验证了扩容倍率、读操作推进迁移,以及节点本身没有被复制。编译及运行步骤。

【Response】

是的,这里主要还是强调是类似 COW 的方案。

3. 跳表原地更新分数需要同时满足前后边界

作者原文摘录(节选):

或者是最后一个节点

见原文小节。

作者论述(转述):作者将可原地改分数的情形列成两项:先判断前驱,再以或关系引出后继;不满足时才删除后重新插入。

源码(2020 快照,c01e94a): t_zset.c:285–300 · 固定提交链接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if ((x->backward == NULL || x->backward->score < newscore) &&
(x->level[0].forward == NULL || x->level[0].forward->score > newscore))
{
x->score = newscore;
return x;
}

/* No way to reuse the old node: we need to remove and insert a new
* one at a different place. */
zslDeleteNode(zsl, x, update);
zskiplistNode *newnode = zslInsert(zsl,newscore,x->ele);
/* We reused the old node x->ele SDS string, free the node now
* since zslInsert created a new one. */
x->ele = NULL;
zslFreeNode(x);
return newnode;

为什么有问题:源码用 && 连接两组条件:新分数既不能越过前驱,也不能越过后继。两组内部才各自有 ||,分别允许没有前驱或没有后继。该优化使用严格的 < 和 >;与邻居同分也会落入重新插入分支。例:分数 1, 2, 3 的中间节点改成 4,虽然满足前驱小于新分数,但必须移动到末尾,不能原地改。更准确的表述是:前后两侧的条件同时成立才原地更新,否则删除旧节点并重新插入。

4. 随机层高是截断的几何分布

作者原文摘录(节选):

powerlaw

见原文小节。

作者论述(转述):解释随机层高时,作者先说明节点拥有较高层的概率更小,再将这种概率分布归为幂律分布。

源码(2020 快照,c01e94a): t_zset.c:118–128 · 固定提交链接。

1
2
3
4
5
6
7
8
9
10
/* Returns a random level for the new skiplist node we are going to create.
* The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL
* (both inclusive), with a powerlaw-alike distribution where higher
* levels are less likely to be returned. */
int zslRandomLevel(void) {
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

为什么有问题:每成功通过一次相同概率的随机判断,层高增加 1;失败则停止,最后截断到最大层高。若单次通过概率记为 p,则未到上限时 P(L = k) = (1-p) p^(k-1),尾概率为 P(L ≥ k) = p^(k-1),按层高呈指数衰减;这里 p 约为 1/4。它是截断几何分布,而不是按 k 的负幂衰减的幂律分布。源码注释也仅写 powerlaw-alike;把它当作严格的分布名称会误导复杂度分析。

5. 一次罕见哈希结果不能给出集合基数的下界

作者原文摘录(节选):

至少……8个元素

见原文小节。

作者论述(转述):作者先以抛掷次数解释前缀出现的概率,再由遇到 001 这一结果,推断当前集合的元素数不小于 8。

源码(2020 快照,c01e94a): hyperloglog.c:468–480 · 固定提交链接。

1
2
3
4
5
6
7
8
9
10
11
12
    hash >>= HLL_P; /* Remove bits used to address the register. */
hash |= ((uint64_t)1<<HLL_Q); /* Make sure the loop terminates
and count will be <= Q+1. */
bit = 1;
count = 1; /* Initialized to 1 since we count the "00000...1" pattern. */
while((hash & bit) == 0) {
count++;
bit <<= 1;
}
*regp = (int) index;
return count;
}

为什么有问题:代码对单个元素的哈希就能计算出很大的 count;这不要求先插入相应数量的元素。第一次抽样便出现概率为 1/8 的事件完全可能,集合此时仍可只有 1 个元素。等待该事件出现的期望次数、由最大零串长度得到的统计估计,以及真实基数的确定下界,是三种不同概念。HLL 用许多寄存器和估计器降低误差,并不作这种确定性下界保证。

集合里只有一个元素 A,假设它的哈希结果恰好是:

1
2
3
4
集合:{A}
哈希:00110101……
↑
第一个 1 出现在第 3 位

此时:

  • 真实元素数量:1。
  • 按文章的直觉,用 2³ = 8 来推测数量。
  • 如果进一步说“集合至少有 8 个元素”,就错了——集合明明只有 A。
    这个结果完全可能发生:均匀随机的哈希以 001 开头,概率就是 1/8,第一次就可能遇到。
    “平均要尝试 8 次才遇到”不意味着“必须尝试满 8 次才能遇到”。类似中奖概率是 1/8,有人买第一张就中奖了,不能据此断定他至少买过 8 张。
    实际 Redis HLL 会综合多个桶并进行校正;上面的 8 是文章简化模型的推测,不是说 Redis 对一个元素执行 PFCOUNT 就会返回 8。

6. 寄存器为零与哈希后缀全零不是一回事

作者原文摘录(节选):

远远大于

见原文小节。

作者论述(转述):作者猜测零值意味着第一个 1 尚在当前位宽之外,因而代表更大的基数;段末明确询问这一理解是否正确。

源码(2020 快照,c01e94a): hyperloglog.c:1124–1136 · 固定提交链接。

1
2
3
4
5
6
7
8
9
10
11
12
13
/* Populate the sparse representation with as many XZERO opcodes as
* needed to represent all the registers. */
aux = HLL_REGISTERS;
s = sdsnewlen(NULL,sparselen);
p = (uint8_t*)s + HLL_HDR_SIZE;
while(aux) {
int xzero = HLL_SPARSE_XZERO_MAX_LEN;
if (xzero > aux) xzero = aux;
HLL_SPARSE_XZERO_SET(p,xzero);
p += 2;
aux -= xzero;
}
serverAssert((p-(uint8_t*)s) == sparselen);

源码(2020 快照,c01e94a): hyperloglog.c:971–973 · 固定提交链接。

1
2
3
double hllSigma(double x) {
if (x == 1.) return INFINITY;
double zPrime;

源码(2020 快照,c01e94a): hyperloglog.c:1039–1047 · 固定提交链接。

1
2
3
4
5
6
7
8
9
double z = m * hllTau((m-reghisto[HLL_Q+1])/(double)m);
for (j = HLL_Q; j >= 1; --j) {
z += reghisto[j];
z *= 0.5;
}
z += m * hllSigma(reghisto[0]/(double)m);
E = llroundl(HLL_ALPHA_INF*m*m/z);

return (uint64_t) E;

为什么有问题:刚创建的 HLL 把全部寄存器初始化为 0,表示尚无元素映射到这些桶。全零寄存器直方图使 hllSigma(1) 返回无穷大,最终估计基数为 0。相反,如果某个元素去掉桶下标后的哈希位恰好全零,hllPatLen 会添加哨兵位并得到 HLL_Q + 1,即 51,而不是 0。这两种“零”的含义相反。作者后面正确说明了新对象用 XZERO 初始化,所以这里也构成文章内部的概念冲突。

7. 连续相同值超过四个桶,不会因此转换成 dense

作者原文摘录(节选):

超过4

见原文小节。

作者论述(转述):作者分别讨论寄存器的取值和重复数量,认为同值连续桶数超出单条 VAL 的容量也会触发 dense 转换。

源码(2020 快照,c01e94a): hyperloglog.c:660–662 · 固定提交链接。

1
2
3
/* If the count is too big to be representable by the sparse representation
* switch to dense representation. */
if (count > HLL_SPARSE_VAL_MAX_VALUE) goto promote;

源码(2020 快照,c01e94a): hyperloglog.c:831–832 · 固定提交链接。

1
2
if (deltalen > 0 &&
sdslen(o->ptr)+deltalen > server.hll_sparse_max_bytes) goto promote;

源码(2020 快照,c01e94a): hyperloglog.c:854–874 · 固定提交链接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    /* We need two adjacent VAL opcodes to try a merge, having
* the same value, and a len that fits the VAL opcode max len. */
if (p+1 < end && HLL_SPARSE_IS_VAL(p+1)) {
int v1 = HLL_SPARSE_VAL_VALUE(p);
int v2 = HLL_SPARSE_VAL_VALUE(p+1);
if (v1 == v2) {
int len = HLL_SPARSE_VAL_LEN(p)+HLL_SPARSE_VAL_LEN(p+1);
if (len <= HLL_SPARSE_VAL_MAX_LEN) {
HLL_SPARSE_VAL_SET(p+1,v1,len);
memmove(p,p+1,end-p);
sdsIncrLen(o->ptr,-1);
end--;
/* After a merge we reiterate without incrementing 'p'
* in order to try to merge the just merged value with
* a value on its right. */
continue;
}
}
}
p++;
}

为什么有问题:4 是单个 VAL opcode能表示的连续桶数上限,不是整个 sparse 表示的上限。连续 5 个相同非零寄存器可以用长度为 4 和 1 的两个 VAL 表示;不能合并时只需保留相邻 opcode。hllSparseSet 在更新寄存器时触发自动转换的条件是 count 大于可编码上限 32,或本次扩展后的编码字节数超过配置阈值。其他操作如 PFMERGE 还存在额外转换路径,这里只讨论该更新函数。文章后文也描述了转换条件,但前面的这一条仍会让人误解 sparse 的能力。

8. HLL 非法标记的赋值方向写反了

作者原文摘录(节选):

设置invalid为0

见原文小节。

作者论述(转述):介绍估算函数的错误参数时,作者把非法编码对应的标志写成 0,并表示合法时不改写这个参数。

源码(2020 快照,c01e94a): hyperloglog.c:932–935 · 固定提交链接。

1
2
3
4
        }
}
if (idx != HLL_REGISTERS && invalid) *invalid = 1;
}

源码(2020 快照,c01e94a): hyperloglog.c:1284–1291 · 固定提交链接。

1
2
3
4
5
6
7
8
int invalid = 0;
/* Recompute it and update the cached value. */
card = hllCount(hdr,&invalid);
if (invalid) {
addReplySds(c,sdsnew(invalid_hll_err));
return;
}
hdr->card[0] = card & 0xff;

为什么有问题:出错时设置的是 *invalid = 1;合法时不改写。调用者先把 invalid 初始化为 0,之后检查非零值决定是否向客户端返回错误。如果照文章描述实现调用逻辑,就会把损坏的 HLL 当成有效数据。这是明确的标志极性错误,很可能是文字笔误,但会影响对错误处理路径的理解。


Redis Sentinel实现原理分析

原文:Redis Sentinel实现原理分析。以下以本地 2020 年源码快照 c01e94a4319c416c4c231ffbea9e778d52424e30 为准;目录为 redis-c01e94a-2020-08。这是开发快照,src/version.h 为 999.999.999,不能称为某个正式 Redis 发行版。源码均为节选。

1. 其他 Sentinel 的下线判断不会直接使本机把 Master 标为 SDOWN

作者原文摘录(节选):

也设置为 SDOWN 么?是的

原文位置:sentinelRedisInstance。

作者论述(转述):作者询问其他哨兵的下线意见是否会让本机也将主库判为 SDOWN,并肯定回答;随后又区分了本机与对端的判断标志。

对应源码,2020 快照:sentinel.c:3788,sentinelReceiveIsMasterDownReply():

1
2
3
4
5
6
ri->last_master_down_reply_time = mstime();
if (r->element[0]->integer == 1) {
ri->flags |= SRI_MASTER_DOWN;
} else {
ri->flags &= ~SRI_MASTER_DOWN;
}

这里的 ri 是回复消息的另一 Sentinel 的本地描述对象。统计客观下线时,sentinel.c:3739:

1
2
3
4
5
6
7
8
9
10
11
12
13
if (master->flags & SRI_S_DOWN) {
/* Is down for enough sentinels? */
quorum = 1; /* the current sentinel. */
/* Count all the other sentinels. */
di = dictGetIterator(master->sentinels);
while((de = dictNext(di)) != NULL) {
sentinelRedisInstance *ri = dictGetVal(de);

if (ri->flags & SRI_MASTER_DOWN) quorum++;
}
dictReleaseIterator(di);
if (quorum >= master->quorum) odown = 1;
}

为何不对:接收回复只更新对端 Sentinel 对 Master 的判断 SRI_MASTER_DOWN,不会设置 master->flags 的 SRI_S_DOWN。本机必须先通过自己的超时/角色检查作出 SDOWN 判断,才会汇总其他 Sentinel 的意见判断 ODOWN。因此,即使其他 Sentinel 都报告下线,本机仍可能认为 Master 正常。原文随后区分两个 flag 的说明基本正确,所以这里应定性为前后矛盾的错误回答,而非作者全文都混淆两者。

2. Sentinel 的 Leader 选票不以其报告 Master 下线为生效条件

作者原文摘录(节选):

有SRI_MASTER_DOWN标记时有效力

原文位置:sentinelAskMasterStateToOtherSentinels。

作者论述(转述):作者解释清理过期选票时,断言只有带 SRI_MASTER_DOWN 标志,ri->leader 才生效;并提醒该字段不始终代表当前 Leader。

对应源码,2020 快照:sentinel.c:3135,处理 is-master-down-by-addr 请求:

1
2
3
4
5
6
7
8
9
10
11
if (!sentinel.tilt && ri && (ri->flags & SRI_S_DOWN) &&
(ri->flags & SRI_MASTER))
isdown = 1;

/* Vote for the master (or fetch the previous vote) if the request
* includes a runid, otherwise the sender is not seeking for a vote. */
if (ri && ri->flags & SRI_MASTER && strcasecmp(c->argv[5]->ptr,"*")) {
leader = sentinelVoteLeader(ri,(uint64_t)req_epoch,
c->argv[5]->ptr,
&leader_epoch);
}

真正统计 Leader 选票时,sentinel.c:3943:

1
2
3
4
5
6
7
/* Count other sentinels votes */
di = dictGetIterator(master->sentinels);
while((de = dictNext(di)) != NULL) {
sentinelRedisInstance *ri = dictGetVal(de);
if (ri->leader != NULL && ri->leader_epoch == sentinel.current_epoch)
sentinelLeaderIncr(counters,ri->leader);
}

为何不对:下线意见和选举授权是两个独立结果。一个 Sentinel 可以回复 isdown=0,同时依据 epoch 规则授予 Leader 选票;接收端也会独立保存该选票。统计时检查的是 leader 和 leader_epoch,没有检查 SRI_MASTER_DOWN。把两种投票混在一起,会错误地认为所有参与 Leader 授权的 Sentinel 都必须先判定 Master 下线。

3. 连接共享复用的是同一个远端 Sentinel 的连接

作者原文摘录(节选):

复用这个实例

原文位置:sentinelTryConnectionSharing。

作者论述(转述):作者先称共享的是本机已有的主库连接,再称遍历找到 ri->master 就复用它;虽表示代码难懂,结论使用肯定语气。

对应源码,2020 快照:sentinel.c:1064:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
while((de = dictNext(di)) != NULL) {
sentinelRedisInstance *master = dictGetVal(de), *match;
/* We want to share with the same physical Sentinel referenced
* in other masters, so skip our master. */
if (master == ri->master) continue;
match = getSentinelRedisInstanceByAddrAndRunID(master->sentinels,
NULL,0,ri->runid);
if (match == NULL) continue; /* No match. */
if (match == ri) continue; /* Should never happen but... safer. */

/* We identified a matching Sentinel, great! Let's free our link
* and use the one of the matching Sentinel. */
releaseInstanceLink(ri->link,NULL);
ri->link = match->link;
match->link->refcount++;

为何不对:条件恰好相反:遇到 ri->master 会跳过。它在其他 Master 的 sentinels 字典中,寻找 runid 相同的远端 Sentinel,然后共享 match->link。例如本机同时监控 M1、M2,而远端 S2 也监控两者,本机会有两个描述 S2 的对象,但这两个对象可以共用本机到 S2 的连接;它们不会借用本机到 M1/M2 的连接,也不是多个 Sentinel 进程共用一个 TCP 连接。

4. act_ping_time 不是最近一次发送 PING 的时间

作者原文摘录(节选):

最后一个PING发出的时间

原文位置:sentinelCheckSubjectivelyDown。

作者论述(转述):作者将 act_ping_time 解释成最近发送 PING 的时刻,认为零值表示已收 PONG、尚未再发;另称 last_ping_time 不随 PONG 清零。

对应源码,2020 快照:sentinel.c:2704:

1
2
3
4
5
6
7
8
if (retval == C_OK) {
ri->link->pending_commands++;
ri->link->last_ping_time = mstime();
/* We update the active ping time only if we received the pong for
* the previous ping, otherwise we are technically waiting since the
* first ping that did not receive a reply. */
if (ri->link->act_ping_time == 0)
ri->link->act_ping_time = ri->link->last_ping_time;

下线检测使用它累计等待时间,sentinel.c:3665:

1
2
3
4
if (ri->link->act_ping_time)
elapsed = mstime() - ri->link->act_ping_time;
else if (ri->link->disconnected)
elapsed = mstime() - ri->link->last_avail_time;

为何不对:每次发送成功都会更新 last_ping_time;只有 act_ping_time 已清零,才会将它更新。因此在连续未获得可接受回复时,act_ping_time 保留的是这段等待的起点,不是最近一个 PING。比如第 0、1、2 秒均发送 PING 而始终无有效回复,第 2 秒的等待时间仍从第 0 秒计算,否则持续发送心跳就可能不断推迟故障超时。新建 link 时也会预设这个起点,以检测尚未成功建立连接的超时。

5. PUBLISH、订阅模式和 Hiredis 是否阻塞是三个不同问题

作者原文摘录(节选):

PUBLISH

原文位置:连接。

作者论述(转述):作者借 redis-cli 执行 PUBLISH 后无法继续发命令,对比 Hiredis 不阻塞;前文仅把防止广播丢失列为双连接的一种解释。

对应源码,2020 快照:pubsub.c:400:

1
2
3
4
5
6
7
8
void publishCommand(client *c) {
int receivers = pubsubPublishMessage(c->argv[1],c->argv[2]);
if (server.cluster_enabled)
clusterPropagatePublish(c->argv[1],c->argv[2]);
else
forceCommandPropagation(c,PROPAGATE_REPL);
addReplyLongLong(c,receivers);
}

订阅模式下的服务端约束,server.c:3725:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* Only allow a subset of commands in the context of Pub/Sub if the
* connection is in RESP2 mode. With RESP3 there are no limits. */
if ((c->flags & CLIENT_PUBSUB && c->resp == 2) &&
c->cmd->proc != pingCommand &&
c->cmd->proc != subscribeCommand &&
c->cmd->proc != unsubscribeCommand &&
c->cmd->proc != psubscribeCommand &&
c->cmd->proc != punsubscribeCommand) {
rejectCommandFormat(c,
"Can't execute '%s': only (P)SUBSCRIBE / "
"(P)UNSUBSCRIBE / PING / QUIT are allowed in this context",
c->cmd->name);
return C_OK;
}

为何不对:普通连接执行 PUBLISH 会收到订阅接收者数量的整数回复,不会因此进入订阅模式。原文示例实际发送的是 SUBSCRIBE。在这里使用的 RESP2 模式下,即便 Hiredis 函数返回了、或者异步调用成功入队,也不代表订阅连接能成功执行 SET;服务端仍会拒绝。因此分开 cc 和 pc 的关键理由包括订阅连接的协议限制及持续接收推送的需要,不能用客户端调用是否返回来判断是否可以复用。上述结论限定于 RESP2,源码已经明确 RESP3 没有同样的命令限制。

6. 当选要求达到 quorum,不是严格超过 quorum

作者原文摘录(节选):

必须大于master->quorum

原文位置:sentinelGetLeader。

作者论述(转述):作者列出当选者须获得绝对多数、严格超过配置 quorum;随后贴出实际只排除票数小于门槛的判断。

对应源码,2020 快照:sentinel.c:3983:

1
2
3
voters_quorum = voters/2+1;
if (winner && (max_votes < voters_quorum || max_votes < master->quorum))
winner = NULL;

为何不对:两个条件是票数 >= floor(voters/2)+1,并且 >= master->quorum。例如已知 3 个 Sentinel、配置 quorum 为 2,获得 2 票即可;按原文的严格大于理解则错误地要求 3 票。这是影响故障转移可用性判断的边界错误,不能把对投票总人数的“超过半数”套到配置 quorum 上。


Redis持久化机制实现

原文:Redis持久化机制实现。以下主要核对 **2020-08 源码快照 c01e94a4319c416c4c231ffbea9e778d52424e30**(目录 redis-c01e94a-2020-08)。这是仓库提交标识,不把它误称为某个正式发布版;下列代码块中的 /* … */ 表示省略无关代码。

1. 32 MB 的增量 fsync 阈值,不是已成功保存的 RDB 的持久性边界

作者原文摘录(节选):

写32MB才刷盘

原文位置:rdbSave。

作者论述(转述):作者从增量同步的 32 MB 阈值推断宕机会破坏持久性;往下分析保存流程时,又说明写完须先强制同步,再改名替换文件。

对应源码(2020-08):src/rdb.c:1315、src/rdb.c:1331,函数 rdbSave:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
snprintf(tmpfile,256,"temp-%d.rdb", (int) getpid());
fp = fopen(tmpfile,"w");
/* … */
if (server.rdb_save_incremental_fsync)
rioSetAutoSync(&rdb,REDIS_AUTOSYNC_BYTES);

if (rdbSaveRio(&rdb,&error,RDBFLAGS_NONE,rsi) == C_ERR) {
errno = error;
goto werr;
}

/* Make sure data will not remain on the OS's output buffers */
if (fflush(fp) == EOF) goto werr;
if (fsync(fileno(fp)) == -1) goto werr;
if (fclose(fp) == EOF) goto werr;

/* Use RENAME to make sure the DB file is changed atomically only
* if the generate DB file is ok. */
if (rename(tmpfile,filename) == -1) {

为什么欠妥:32 MB 控制的是生成临时快照过程中的分段同步。即使最后一段不足 32 MB,甚至整个 RDB 都不到 32 MB,成功路径也必须执行末尾的 fflush 和 fsync,然后才替换目标文件。不能由这个阈值推出成功的 RDB 留有最后 32 MB 未同步。作者紧接着其实也解释了最终的 fsync;这里的问题是前面以 32 MB 阈值解释持久性风险的因果关系欠妥,并非文章遗漏了最终同步步骤。

生成新快照途中崩溃,新快照可能失败,但代码此时尚未用它替换旧快照。RDB 不能逐笔保障最近写入,主要是因为它保存离散时刻的快照;这一点与是否启用增量 fsync 应分开。更准确的说法是:增量 fsync 用来分摊快照写盘压力,最终保存仍执行完整同步;快照之间的更新则不由 RDB 实时保存。 配置文件也把该选项的目的写为减少延迟尖峰,见 redis.conf:1723。这里不额外推导操作系统、文件系统和硬件在一切断电情形下的保证。

2. BGREWRITEAOF 期间,旧 AOF 仍然追加新写入

作者原文摘录(节选):

旧的AOF在BGREWRITEAOF成功之前不会被修改

原文位置:feedAppendOnlyFile。

作者论述(转述):作者先说明重写时需要另存新增命令,随后认为旧文件在重写完成前保持不变,并以此解释为何重写失败也不会丢数据。

对应源码(2020-08):src/aof.c:642,feedAppendOnlyFile:

1
2
3
4
5
if (server.aof_state == AOF_ON)
server.aof_buf = sdscatlen(server.aof_buf,buf,sdslen(buf));
/* … */
if (server.aof_child_pid != -1)
aofRewriteBufferAppend((unsigned char*)buf,sdslen(buf));

src/aof.c:394,正常 AOF 刷写:

1
nwritten = aofWrite(server.aof_fd,server.aof_buf,sdslen(server.aof_buf));

src/aof.c:1784,重写成功、完成文件替换后才切换描述符:

1
2
3
4
} else {
/* AOF enabled, replace the old fd with the new one. */
oldfd = server.aof_fd;
server.aof_fd = newfd;

为什么不对:开启 AOF 的正常运行期间,新增修改会同时进入普通 AOF 缓冲区与重写差异缓冲区。普通缓冲区继续写入旧的 server.aof_fd;旧文件因此持续增长,并非冻结不变。重写不成功时还能继续使用旧文件,恰恰依赖这条正常追加路径。

更准确的说法是:旧 AOF 在重写成功前不会被重写结果替换或截断,但仍接收正常追加。 重写失败本身不要求丢弃旧 AOF;是否可能因另一次故障损失最近写入,还取决于原有同步策略,不能把重写机制解释成无条件的数据零丢失保证。

3. everysec 的这段分支推迟的是 write,而且可能先回复客户端

作者原文摘录(节选):

延迟这次fsync

原文位置:flushAppendOnlyFile。

作者论述(转述):作者认为后台同步会阻塞写入,因而需要延后此次同步并由定时任务补做;但 force 为 1 时仍强制写入。他还将回复安排在写 AOF 之后。

对应源码(2020-08):src/aof.c:362:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (server.aof_fsync == AOF_FSYNC_EVERYSEC && !force) {
/* With this append fsync policy we do background fsyncing.
* If the fsync is still in progress we can try to delay
* the write for a couple of seconds. */
if (sync_in_progress) {
if (server.aof_flush_postponed_start == 0) {
/* No previous write postponing, remember that we are
* postponing the flush and return. */
server.aof_flush_postponed_start = server.unixtime;
return;
} else if (server.unixtime - server.aof_flush_postponed_start < 2) {
/* We were already waiting for fsync to finish, but for less
* than two seconds this is still ok. Postpone again. */
return;
}

真正的 aofWrite 在该提前返回分支之后,见 src/aof.c:394。而 src/server.c:2222 的 beforeSleep 是:

1
2
3
4
5
/* Write the AOF buffer on disk */
flushAppendOnlyFile(0);

/* Handle writes with pending output buffers. */
handleClientsWithPendingWritesUsingThreads();

为什么不对:此时后台的 fsync 已经在运行,代码没有把这个正在运行的调用推迟;它在新一轮 aofWrite 之前返回,把新数据暂留在 Redis 的 AOF 缓冲区。等待达到阈值后才继续尝试 write,即使前一次 fsync 还没有结束。

beforeSleep 会在 flushAppendOnlyFile 返回后继续处理客户端输出,所以这里连“每次回复前均已完成 AOF 的 write”都不是无条件保证,更不能把 write 等同于 fsync 已完成。更准确的说法是:everysec 用后台 fsync;后台同步繁忙时可推迟下一次 write,期间客户端仍可能收到成功回复。 force=1 跳过的是这段推迟 write 的逻辑。

4. always 并非逐条命令独立 fsync,no 也并非数据永远不落盘

作者原文摘录(节选):

每个命令刷盘一次

从不刷盘

原文位置:flushAppendOnlyFile。

作者论述(转述):作者在解释客户端回复之前的 AOF 刷写时,列出逐命令、按秒和不刷三种策略,并将第一种评价为安全性最高、速度最低。

对应源码(2020-08):普通命令先把序列化结果追加到同一个 server.aof_buf,见前文的 src/aof.c:642。随后 flushAppendOnlyFile 刷写整段缓冲区,并按策略同步,见 src/aof.c:394、src/aof.c:503:

1
2
3
4
5
6
7
nwritten = aofWrite(server.aof_fd,server.aof_buf,sdslen(server.aof_buf));
/* … */
if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
/* redis_fsync is defined as fdatasync() for Linux in order to avoid
* flushing metadata. */
latencyStartMonitor(latency);
redis_fsync(server.aof_fd); /* Let's try to get this data on the disk */

配置文件 redis.conf:1108 的定义尤其明确:

1
2
3
4
5
# Redis supports three different modes:
#
# no: don't fsync, just let the OS flush the data when it wants. Faster.
# always: fsync after every write to the append only log. Slow, Safest.
# everysec: fsync only one time every second. Compromise.

为什么欠妥:若一次读入多个完整的 pipeline 命令,输入处理循环可以先执行它们,再到事件循环边界刷出累积的 AOF,因此多个命令可以共享一次 fsync;事务中的多条写命令也会累积到该缓冲区。相应循环见 src/networking.c:1843。always 的核心是每批 AOF 写出后的同步,而不是命令数量与 fsync 次数一一对应。

no 则表示常规追加路径不由 Redis 主动请求 fsync,仍会执行 write,由操作系统安排写回。重写、关闭等其他路径中的同步也不能由该选项概括为永远不发生。更准确的说法是:always 每次 AOF 刷写后同步;everysec 按秒调度后台同步;no 将常规写回时机交给操作系统。

5. AOF 格式的说明范围不完整:这两个快照都支持默认开启的 RDB 前缀

作者原文摘录(节选):

协议文本

原文位置:开头对 AOF 的定义。

作者论述(转述):作者开篇以二进制快照和命令文本对比 RDB、AOF;介绍重写时又用多次递增合并为一次赋值,说明如何减少保存当前数据所需的命令。

对应源码(2020-08):src/aof.c:1431,rewriteAppendOnlyFile:

1
2
3
4
5
6
7
8
9
if (server.aof_use_rdb_preamble) {
int error;
if (rdbSaveRio(&aof,&error,RDBFLAGS_AOF_PREAMBLE,NULL) == C_ERR) {
errno = error;
goto werr;
}
} else {
if (rewriteAppendOnlyFileRio(&aof) == C_ERR) goto werr;
}

redis.conf:1196:

1
2
3
4
5
# When rewriting the AOF file, Redis is able to use an RDB preamble in the
# AOF file for faster rewrites and recoveries. When this option is turned
# on the rewritten AOF file is composed of two different stanzas:
#
# [RDB file][AOF tail]

该配置在 redis.conf:1205 为 aof-use-rdb-preamble yes,程序默认值也为真,见 src/config.c:2255。2018-07-23 快照 b65ddfb16a7060a543b523feadeca1234cffd323 也已有相同分支及默认配置,见 src/aof.c:1323、redis.conf:781。

为什么欠妥:普通追加部分确实用命令协议表示,但启用该选项后,重写时的存量数据用二进制 RDB 写在 AOF 开头,后面才接命令增量。读取 AOF 也先识别并加载 RDB 前缀,再解析命令尾部,见 src/aof.c:755。因此应区分传统命令式 AOF 与混合格式;这并不是拿较新的 Redis 特性反驳旧版本。


Redis事务的实现

原文:Redis事务的实现。以下代码对应 **2020-08 源码快照 c01e94a4319c416c4c231ffbea9e778d52424e30**,与文章展示的 ACL 检查、RESP3 回复等代码一致。

1. 缺少失败回滚,不宜直接概括为 Redis 事务完全没有原子性

作者原文摘录(节选):

Redis并没有原子性

原文位置:简介。

作者论述(转述):作者先说明事务命令执行完才处理其他客户端,随后以不支持回滚为理由否定原子性,并强调 DISCARD 只用于命令排队阶段。

对应源码(2020-08):src/multi.c:176、src/multi.c:195:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
addReplyArrayLen(c,c->mstate.count);
for (j = 0; j < c->mstate.count; j++) {
c->argc = c->mstate.commands[j].argc;
c->argv = c->mstate.commands[j].argv;
c->cmd = c->mstate.commands[j].cmd;
/* … */
int acl_keypos;
int acl_retval = ACLCheckCommandPerm(c,&acl_keypos);
if (acl_retval != ACL_OK) {
/* … */
} else {
call(c,server.loading ? CMD_CALL_NONE : CMD_CALL_FULL);
}
/* … */
}

为什么欠妥:这里需要区分两种含义。作者关于“执行时出错没有回滚”的判断是对的:某个命令出错不意味着此前的成功修改被撤销,也不必然停止后续命令。例如 MULTI → SET k text → INCR k → SET done 1 → EXEC,INCR 失败,但两个 SET 的效果仍然保留;INCR 的解析失败会直接返回,见 src/t_string.c:345,外围事务循环继续运行。

但同一个执行循环也表明,正常命令执行期间,其他客户端的命令不能插入这个事务的命令序列。Redis 在这种“不可穿插执行”的意义上提供事务原子执行。更准确的结论是:Redis 事务可以作为不可穿插的命令序列执行,但不提供关系数据库式的执行失败后全部回滚语义。 如果特指 ACID 中的全有或全无,就应明确这个限定,而不是笼统否定所有原子性含义。

2. WATCH 冲突是在执行前放弃队列,不是撤销已执行操作

作者原文摘录(节选):

如果这些key被修改,则事务回滚

原文位置:watch。

作者论述(转述):作者将 WATCH 描述为 MULTI 前对若干键建立监控;当这些键后来被修改,就把相应事务的结果称作回滚。

对应源码(2020-08):src/multi.c:164,冲突分支位于执行循环之前:

1
2
3
4
5
6
7
8
9
if (c->flags & (CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC)) {
addReply(c, c->flags & CLIENT_DIRTY_EXEC ? shared.execaborterr :
shared.nullarray[c->resp]);
discardTransaction(c);
goto handle_monitor;
}

/* Exec all the queued commands */
unwatchAllKeys(c); /* Unwatch ASAP otherwise we'll waste CPU cycles */

src/multi.c:83:

1
2
3
4
5
6
void discardTransaction(client *c) {
freeClientMultiState(c);
initClientMultiState(c);
c->flags &= ~(CLIENT_MULTI|CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC);
unwatchAllKeys(c);
}

为什么不对:发生 WATCH 冲突时,排队的事务命令还没有执行。这里清理的是队列、事务标志与监视关系,没有恢复旧值的操作。其他客户端对被监视键作出的修改也不会被撤销。

例如客户端 A WATCH k 后,客户端 B 把 k 改成 2;A 随后的事务因冲突未执行,k 仍然是 B 写入的 2。准确表述应为:WATCH 冲突使 EXEC 放弃尚未执行的事务,客户端可重新读取并重试。

3. WATCH 冲突返回 null,与成功执行空事务的空数组不同

作者原文摘录(节选):

对于CLIENT_DIRTY_CAS返回空数组

原文位置:exec。

作者论述(转述):作者区分执行前的两类标志:监视键变化时给出特殊结果而非错误,入队阶段出错则返回 EXECABORT;前者被称为空数组。

对应源码(2020-08):src/multi.c:164 在 CLIENT_DIRTY_CAS 分支返回 shared.nullarray[c->resp]。这些对象在 src/server.c:2259、src/server.c:2305 中有明确区分:

1
2
3
4
5
6
shared.emptyarray = createObject(OBJ_STRING,sdsnew("*0\r\n"));
/* … */
shared.nullarray[0] = NULL;
shared.nullarray[1] = NULL;
shared.nullarray[2] = createObject(OBJ_STRING,sdsnew("*-1\r\n"));
shared.nullarray[3] = createObject(OBJ_STRING,sdsnew("_\r\n"));

而成功执行的事务使用 src/multi.c:176:

1
addReplyArrayLen(c,c->mstate.count);

为什么不对:RESP2 下 WATCH 冲突得到 null array(*-1\r\n),RESP3 下得到 null(_\r\n);真正的零元素数组是 *0\r\n。MULTI 后直接 EXEC 可成功返回空数组,与因冲突没有执行事务不是同一结果。这一区别会直接影响客户端判断是否需要重试,不能用同一个“空数组”概括。

2018-07-23 快照同样使用 shared.nullmultibulk 表示冲突,见 src/multi.c:133,不是 2020 年才出现的语义。

4. 执行后更新 mstate,主要是维护被改写参数的所有权与清理,不是此时才保障传播内容

作者原文摘录(节选):

确保Slave和AOF的数据一致性

原文位置:exec。

作者论述(转述):作者以 SPOP 改写成 SREM 为例,说明执行会改变命令及参数,因此需要写回事务队列;他将此操作解释为保证副本与 AOF 一致。

对应源码(2020-08):src/multi.c:208:

1
2
3
4
5
6
7
    call(c,server.loading ? CMD_CALL_NONE : CMD_CALL_FULL);
}

/* Commands may alter argc/argv, restore mstate. */
c->mstate.commands[j].argc = c->argc;
c->mstate.commands[j].argv = c->argv;
c->mstate.commands[j].cmd = c->cmd;

但 call 内部已经使用当前改写后的参数传播,见 src/server.c:3433:

1
2
if (propagate_flags != PROPAGATE_NONE && !(c->cmd->flags & CMD_MODULE))
propagate(c->cmd,c->db->id,c->argv,c->argc,propagate_flags);

改写可以释放旧参数数组,见 src/networking.c:2636:

1
2
3
4
5
for (j = 0; j < c->argc; j++) decrRefCount(c->argv[j]);
zfree(c->argv);
/* Replace argv and argc with our new versions. */
c->argv = argv;
c->argc = argc;

事务结束后,discardTransaction 则会调用 src/multi.c:43 清理队列:

1
2
3
4
5
6
7
8
9
for (j = 0; j < c->mstate.count; j++) {
int i;
multiCmd *mc = c->mstate.commands+j;

for (i = 0; i < mc->argc; i++)
decrRefCount(mc->argv[i]);
zfree(mc->argv);
}
zfree(c->mstate.commands);

为什么欠妥:把 SPOP 等命令改写为确定性操作,确实与复制、AOF 重放的一致性有关;但文章把这个作用归给了之后更新 mstate 的动作,混淆了两个步骤。传播发生在 call 内,早于写回队列。队列必须跟上新 argv/argc,否则稍后的释放会访问已释放的旧数组,还可能遗漏新数组。

更准确的解释是:命令改写使传播具有确定性;把结果写回事务状态,使队列继续拥有正确的命令参数,并能在事务结束时正确释放。


Redis主从复制

原文:Redis主从复制。以下仍以 2020 年源码快照 c01e94a4319c416c4c231ffbea9e778d52424e30 为主。三个问题均另核对了本地 **2018 年快照 b65ddfb16a7060a543b523feadeca1234cffd323**,不存在把两个版本混用导致的误判。

1. PSYNC 请求的 offset 是下一待接收字节的位置

作者原文摘录(节选):

offset 表示 Slave 接受到最后命令的偏移量,以字节计算

原文位置:PSYNC命令用法。

作者论述(转述):作者把 PSYNC 的 offset 定义为从库最后收到命令的字节偏移;后文所贴发送代码却在缓存偏移上加一。

对应源码,2020 快照:replication.c:1969:

1
2
3
if (server.cached_master) {
psync_replid = server.cached_master->replid;
snprintf(psync_offset,sizeof(psync_offset),"%lld", server.cached_master->reploff+1);

真正发送请求,replication.c:1980:

1
reply = sendSynchronousCommand(SYNC_CMD_WRITE,conn,"PSYNC",psync_replid,psync_offset,NULL);

2018 快照也有同样的 reploff+1,见 replication.c:1441。

为何不对:文章混淆了从库已经处理到的复制偏移量和 PSYNC 请求的起点。常规部分同步请求传的是 cached_master->reploff + 1:若已经处理到字节 N,就请求从 N+1 开始。主库按该起点截取 backlog,而不会自动替请求参数再加一。PSYNC ? -1 是另一个表示无法提供有效复制历史的特殊请求。原文后面虽然贴出了正确的 +1 源码,开头的参数定义没有把这一区别讲清楚。

2. repl_transfer_size 初始化为 -1,表示尚未读取传输头

作者原文摘录(节选):

repl_transfer_size为1

原文位置:syncWithMaster。

作者论述(转述):作者介绍安装 RDB 读取回调、进入传输状态后,说该字段设为正一;紧接着贴出的初始化代码实际是负一。

对应源码,2020 快照:replication.c:2384:

1
2
3
4
5
server.repl_state = REPL_STATE_TRANSFER;
server.repl_transfer_size = -1;
server.repl_transfer_read = 0;
server.repl_transfer_last_fsync_off = 0;
server.repl_transfer_lastio = server.unixtime;

接收回调据此判断是否读取协议头,replication.c:1493:

1
2
3
4
/* If repl_transfer_size == -1 we still have to read the bulk length
* from the master reply. */
if (server.repl_transfer_size == -1) {
if (connSyncReadLine(conn,buf,1024,server.repl_syncio_timeout*1000) == -1) {

2018 快照初始化同样为 -1,见 replication.c:1841。

为何不对:这应归为正文笔误,与其紧随的源码自相矛盾。这里的负号有协议含义:-1 是尚未解析长度/EOF 传输头的哨兵值。首轮回调先读取 $<长度> 或 $EOF:<标记>,然后才接收 RDB 数据。若照正文把它理解成 1,就会把“长度尚未知”误读为“长度已确定为 1 字节”,漏掉正确的接收状态转换。

3. CAPA 发送的是协议能力,不是容量

作者原文摘录(节选):

用来发送Slave的容量

原文位置:syncWithMaster。

作者论述(转述):作者把 REPL_STATE_SEND_CAPA 解释为发送从库容量,并追问该容量的含义;随附注释说明的是 EOF、PSYNC2 协议能力。

对应源码,2020 快照:replication.c:2272:

1
2
3
if (server.repl_state == REPL_STATE_SEND_CAPA) {
err = sendSynchronousCommand(SYNC_CMD_WRITE,conn,"REPLCONF",
"capa","eof","capa","psync2",NULL);

主库解析为能力位,replication.c:893:

1
2
3
4
5
6
} else if (!strcasecmp(c->argv[j]->ptr,"capa")) {
/* Ignore capabilities not understood by this master. */
if (!strcasecmp(c->argv[j+1]->ptr,"eof"))
c->slave_capa |= SLAVE_CAPA_EOF;
else if (!strcasecmp(c->argv[j+1]->ptr,"psync2"))
c->slave_capa |= SLAVE_CAPA_PSYNC2;

2018 快照发送相同的能力声明,见 replication.c:1739。

为何欠妥:这是将 capabilities 误译成 capacity。eof 表示支持用 EOF 标记界定 RDB 传输结尾,psync2 表示支持 PSYNC v2,包括识别 +CONTINUE 中的新复制 ID;该步骤不传输内存、磁盘空间或数据容量。它属于术语错误,但会影响读者对握手时在协商什么的理解。


Redis基础机制分析

原文:《Redis基础机制分析》。以下均核对本地 **Redis 2020-08 快照 c01e94a4319c416c4c231ffbea9e778d52424e30**,目录为 redis-c01e94a-2020-08。这里用提交号识别版本,不把开发快照当作某个正式发行版。原文摘录仅保留定位问题的短语,后面的“作者论述(转述)”不是逐字引文。

1. Lua 脚本固定过期判断时间,并没有全面禁止过期

作者原文摘录(节选):

要禁止expire

原文位置:keyIsExpired

作者论述(转述):作者为解释主从及 AOF 执行结果一致,将冻结时钟说成脚本内不再过期,并认为应先清理脚本涉及的键再执行。

对应源码(2020-08,c01e94a):src/db.c:1232,以下为同一个函数内的三个节选。

1
2
3
4
5
6
7
8
int keyIsExpired(redisDb *db, robj *key) {
mstime_t when = getExpire(db,key);
mstime_t now;

if (when < 0) return 0; /* No expire for this key */

/* Don't expire anything while loading. It will be done later. */
if (server.loading) return 0;
1
2
3
if (server.lua_caller) {
now = server.lua_time_start;
}
1
return now > when;

主节点收到过期判断后,仍然执行删除:src/db.c:1288、src/db.c:1299。

1
2
int expireIfNeeded(redisDb *db, robj *key) {
if (!keyIsExpired(db,key)) return 0;
1
2
3
4
5
6
7
8
9
if (server.masterhost != NULL) return 1;

/* Delete the key */
server.stat_expiredkeys++;
propagateExpire(db,key,server.lazyfree_lazy_expire);
notifyKeyspaceEvent(NOTIFY_EXPIRED,
"expired",key,db->id);
int retval = server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
dbSyncDelete(db,key);

为什么不准确:假设键的过期时间是 900 ms,脚本从 1000 ms 开始。脚本第一次访问该键时,比较结果仍然是 1000 > 900,主节点会在这次访问中删除它。被固定的是判断用的时钟,所以在脚本开始时尚未到期的键,不会仅仅因为脚本耗时而在后续访问中突然过期。

evalGenericCommand 设置 server.lua_time_start 后调用 lua_pcall,并没有在进入脚本之前遍历 KEYS 统一执行 expireIfNeeded;参见 src/scripting.c:1541。因此,更准确的表述是:在脚本内按脚本开始时刻进行惰性过期判断;首次访问仍可能触发过期删除。

2. FAST 过期循环不是根据键少而选用的模式

作者原文摘录(节选):

key比较少

原文位置:主动expire实现

作者论述(转述):作者将两种循环按清理强度区分:FAST 用于键少时节省 CPU,过期键数量降至阈值便退出;SLOW 更积极地回收内存。

对应源码(2020-08,c01e94a):src/expire.c:153。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
/* Don't start a fast cycle if the previous cycle did not exit
* for time limit, unless the percentage of estimated stale keys is
* too high. Also never repeat a fast cycle for the same period
* as the fast cycle total duration itself. */
if (!timelimit_exit &&
server.stat_expired_stale_perc < config_cycle_acceptable_stale)
return;

if (start < last_fast_cycle + (long long)config_cycle_fast_duration*2)
return;

last_fast_cycle = start;
}

调用地点分别是 databasesCron 和 beforeSleep:src/server.c:1706、src/server.c:2184。

1
2
3
4
5
6
7
if (server.active_expire_enabled) {
if (iAmMaster()) {
activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
} else {
expireSlaveKeys();
}
}
1
2
if (server.active_expire_enabled && server.masterhost == NULL)
activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST);

为什么不准确:这不是按键数量二选一的策略。SLOW 是定时维护中的常规循环;FAST 是事件循环中的补充清理机会,其入口受前次是否超时、过期统计量及距上次 FAST 的间隔约束。低清理压力下 FAST 反而可能直接返回。两者共用同一套扫描主体,主要差别是调用时机和时间预算:默认 FAST 为约 1 ms,SLOW 为 25% / server.hz 秒,参见 src/expire.c:117、src/expire.c:182。这些是预算与定期检查机制,不能理解为每次调用绝不会超出的硬实时上限。

3. 把 SYNC / PSYNC 概括成全量同步,遗漏了 PSYNC 的核心分支

作者原文摘录(节选):

来个全量同步

原文位置:propagate机制

作者论述(转述):作者把复制分为状态同步和指令传播:SYNC/PSYNC 开始时全量同步主节点状态,propagate 则把后续指令发送给从节点或 AOF。

对应源码(2020-08,c01e94a):src/replication.c:743。

1
2
3
4
if (!strcasecmp(c->argv[0]->ptr,"psync")) {
if (masterTryPartialResynchronization(c) == C_OK) {
server.stat_sync_partial_ok++;
return; /* No full resync needed, return. */

部分同步接受后会回复 CONTINUE 并发送 backlog:src/replication.c:585。

1
2
3
4
5
6
7
8
9
10
if (c->slave_capa & SLAVE_CAPA_PSYNC2) {
buflen = snprintf(buf,sizeof(buf),"+CONTINUE %s\r\n", server.replid);
} else {
buflen = snprintf(buf,sizeof(buf),"+CONTINUE\r\n");
}
if (connWrite(c->conn,buf,buflen) != buflen) {
freeClientAsync(c);
return C_OK;
}
psync_len = addReplyReplicationBacklog(c,psync_offset);

为什么欠妥:首次连接且没有可续传历史时,确实通常需要全量同步。但 PSYNC 还用于断线后的部分重同步:复制 ID 可接受,而且请求的 offset 仍在 backlog 覆盖范围内时,只补传缺失的复制流,不重新发送整个数据库。对应检查见 src/replication.c:534、src/replication.c:559。应分别说明 SYNC 的全量同步,以及 PSYNC 的部分同步/退回全量同步,否则读者容易误判复制重连的代价。

4. LFU 计数器与访问次数并非严格的对数关系

作者原文摘录(节选):

成对数关系

原文位置:增加counter

作者论述(转述):作者由计数越大、增长概率越低推论其与访问数呈对数关系,称调大因子能记录更高频次,并认为随机性使数学计算困难。

对应源码(2020-08,c01e94a):src/evict.c:315。

1
2
3
4
5
6
7
8
9
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;
double r = (double)rand()/RAND_MAX;
double baseval = counter - LFU_INIT_VAL;
if (baseval < 0) baseval = 0;
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++;
return counter;
}

为什么不准确:忽略衰减、饱和以及伪随机数离散化,令 f = lfu_log_factor,b = counter - LFU_INIT_VAL ≥ 0。在计数值固定时,每次命中使其增加的概率为 1 / (f*b + 1),所以等到下一次增加所需命中次数的期望是 f*b + 1。从初始值增加 m 次的总命中次数期望为:

1
2
E[N_m] = Σ(b=0…m−1) (f*b + 1)
= m + f*m*(m−1)/2

当 f > 0 时,这是关于 m 的二次式,其反函数呈平方根量级;f = 0 时则近似每次命中都增加,直到饱和。因此,应说它是增长概率随当前值减小的压缩计数器,不能由函数名 LFULogIncr 推出严格的对数关系。源码注释自身也使用了 “Logarithmically” 这个词,但具体的数学关系仍应以函数内的概率公式为准。

5. DEL 不会无条件发布两条键通知

作者原文摘录(节选):

分发两条消息

原文位置:notifyKeyspaceEvent

作者论述(转述):作者以在零号数据库对 mykey 执行 DEL 为例,称两个频道会同时发送通知,分别告知该键发生的事件及执行删除命令的键。

对应源码(2020-08,c01e94a):src/notify.c:111。

1
2
3
4
5
6
7
/* If notifications for this class of events are off, return ASAP. */
if (!(server.notify_keyspace_events & type)) return;

eventobj = createStringObject(event,strlen(event));

/* __keyspace@<db>__:<key> <event> notifications. */
if (server.notify_keyspace_events & NOTIFY_KEYSPACE) {

第二个频道有独立开关:src/notify.c:129。

1
if (server.notify_keyspace_events & NOTIFY_KEYEVENT) {

DEL 也仅在确实删除成功时触发 del 事件:src/db.c:562。

1
2
3
4
5
6
7
8
for (j = 1; j < c->argc; j++) {
expireIfNeeded(c->db,c->argv[j]);
int deleted = lazy ? dbAsyncDelete(c->db,c->argv[j]) :
dbSyncDelete(c->db,c->argv[j]);
if (deleted) {
signalModifiedKey(c,c->db,c->argv[j]);
notifyKeyspaceEvent(NOTIFY_GENERIC,
"del",c->argv[j],c->db->id);

为什么欠妥:默认 server.notify_keyspace_events = 0,见 src/server.c:2405,所以默认不会发布文中这两条 Pub/Sub 消息。只有同时开启通用事件类别 g、keyspace 频道 K、keyevent 频道 E,例如设置 notify-keyspace-events KEg,而且该键被真正删除,例子才成立。删除不存在的键不会产生 del 通知;过期键若先被 expireIfNeeded 清理,则走的是 expired 事件。模块通知另有通道,不受这一 Pub/Sub 配置判断控制。

6. HAVE_MALLOC_SIZE 不会关闭内存统计

作者原文摘录(节选):

通过HAVE_MALLOC_SIZE禁用内存统计的功能

原文位置:Redis内存管理zmalloc

作者论述(转述):作者先说明 zmalloc 增加长度前缀、返回偏移后的指针,再认为定义 HAVE_MALLOC_SIZE 就能省去这部分记录并停用内存统计。

对应源码(2020-08,c01e94a):src/zmalloc.c:89。

1
2
3
4
5
6
7
8
9
10
11
12
13
void *zmalloc(size_t size) {
void *ptr = malloc(size+PREFIX_SIZE);

if (!ptr) zmalloc_oom_handler(size);
#ifdef HAVE_MALLOC_SIZE
update_zmalloc_stat_alloc(zmalloc_size(ptr));
return ptr;
#else
*((size_t*)ptr) = size;
update_zmalloc_stat_alloc(size+PREFIX_SIZE);
return (char*)ptr+PREFIX_SIZE;
#endif
}

统计宏对两个分支相同:src/zmalloc.c:74。

1
2
#define update_zmalloc_stat_alloc(__n) atomicIncr(used_memory,(__n))
#define update_zmalloc_stat_free(__n) atomicDecr(used_memory,(__n))

为什么错误:两个分支都更新 used_memory。HAVE_MALLOC_SIZE 表示当前分配器可以查询已分配块的大小,因此 Redis 不必在返回给调用者的内存前额外存储长度。以 macOS 为例,src/zmalloc.h:58 把 zmalloc_size(p) 映射到系统的 malloc_size(p)。关闭的是 Redis 自己添加的长度前缀,内存统计仍然有效;有分配器大小接口时,统计依据还可以反映分配器实际提供的块大小。