核查日期: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 | * the number of buckets. */ |
为什么有问题:传入 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 | uint64_t h; |
源码(2018 快照,b65ddfb): dict.c:476–483 · 固定提交链接。
1 | dictEntry *dictFind(dict *d, const void *key) |
为什么有问题:这里重连的是同一批 dictEntry 节点的 next 指针,旧桶随后被清空,并未保留一份可供旧读者使用的副本。甚至 dictFind 这种查找操作也能推进搬迁。它是把迁移成本分摊到多次操作中的增量迁移,不是在写入时复制共享数据。Redis 为后台持久化 fork 后使用的操作系统 COW 是另一回事;两者会相互影响内存成本,但不是同一机制。
第 1、2 项的实际复现:使用独立验证程序,分别链接两个快照中原样的 dict.c、zmalloc.c 和 siphash.c,两次运行均得到:
1 | forced expansion: old buckets=4, old entries=24, new buckets=64 |
这项实验同时验证了扩容倍率、读操作推进迁移,以及节点本身没有被复制。编译及运行步骤。
【Response】
是的,这里主要还是强调是类似 COW 的方案。
3. 跳表原地更新分数需要同时满足前后边界
作者原文摘录(节选):
或者是最后一个节点
见原文小节。
作者论述(转述):作者将可原地改分数的情形列成两项:先判断前驱,再以或关系引出后继;不满足时才删除后重新插入。
源码(2020 快照,c01e94a): t_zset.c:285–300 · 固定提交链接。
1 | if ((x->backward == NULL || x->backward->score < newscore) && |
为什么有问题:源码用 && 连接两组条件:新分数既不能越过前驱,也不能越过后继。两组内部才各自有 ||,分别允许没有前驱或没有后继。该优化使用严格的 < 和 >;与邻居同分也会落入重新插入分支。例:分数 1, 2, 3 的中间节点改成 4,虽然满足前驱小于新分数,但必须移动到末尾,不能原地改。更准确的表述是:前后两侧的条件同时成立才原地更新,否则删除旧节点并重新插入。
4. 随机层高是截断的几何分布
作者原文摘录(节选):
powerlaw
见原文小节。
作者论述(转述):解释随机层高时,作者先说明节点拥有较高层的概率更小,再将这种概率分布归为幂律分布。
源码(2020 快照,c01e94a): t_zset.c:118–128 · 固定提交链接。
1 | /* Returns a random level for the new skiplist node we are going to create. |
为什么有问题:每成功通过一次相同概率的随机判断,层高增加 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 | hash >>= HLL_P; /* Remove bits used to address the register. */ |
为什么有问题:代码对单个元素的哈希就能计算出很大的 count;这不要求先插入相应数量的元素。第一次抽样便出现概率为 1/8 的事件完全可能,集合此时仍可只有 1 个元素。等待该事件出现的期望次数、由最大零串长度得到的统计估计,以及真实基数的确定下界,是三种不同概念。HLL 用许多寄存器和估计器降低误差,并不作这种确定性下界保证。
集合里只有一个元素 A,假设它的哈希结果恰好是:
1 | 集合:{A} |
此时:
- 真实元素数量: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 | /* Populate the sparse representation with as many XZERO opcodes as |
源码(2020 快照,c01e94a): hyperloglog.c:971–973 · 固定提交链接。
1 | double hllSigma(double x) { |
源码(2020 快照,c01e94a): hyperloglog.c:1039–1047 · 固定提交链接。
1 | double z = m * hllTau((m-reghisto[HLL_Q+1])/(double)m); |
为什么有问题:刚创建的 HLL 把全部寄存器初始化为 0,表示尚无元素映射到这些桶。全零寄存器直方图使 hllSigma(1) 返回无穷大,最终估计基数为 0。相反,如果某个元素去掉桶下标后的哈希位恰好全零,hllPatLen 会添加哨兵位并得到 HLL_Q + 1,即 51,而不是 0。这两种“零”的含义相反。作者后面正确说明了新对象用 XZERO 初始化,所以这里也构成文章内部的概念冲突。
7. 连续相同值超过四个桶,不会因此转换成 dense
作者原文摘录(节选):
超过4
见原文小节。
作者论述(转述):作者分别讨论寄存器的取值和重复数量,认为同值连续桶数超出单条 VAL 的容量也会触发 dense 转换。
源码(2020 快照,c01e94a): hyperloglog.c:660–662 · 固定提交链接。
1 | /* If the count is too big to be representable by the sparse representation |
源码(2020 快照,c01e94a): hyperloglog.c:831–832 · 固定提交链接。
1 | if (deltalen > 0 && |
源码(2020 快照,c01e94a): hyperloglog.c:854–874 · 固定提交链接。
1 | /* We need two adjacent VAL opcodes to try a merge, having |
为什么有问题: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 | } |
源码(2020 快照,c01e94a): hyperloglog.c:1284–1291 · 固定提交链接。
1 | int invalid = 0; |
为什么有问题:出错时设置的是 *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 么?是的
作者论述(转述):作者询问其他哨兵的下线意见是否会让本机也将主库判为 SDOWN,并肯定回答;随后又区分了本机与对端的判断标志。
对应源码,2020 快照:sentinel.c:3788,sentinelReceiveIsMasterDownReply():
1 | ri->last_master_down_reply_time = mstime(); |
这里的 ri 是回复消息的另一 Sentinel 的本地描述对象。统计客观下线时,sentinel.c:3739:
1 | if (master->flags & SRI_S_DOWN) { |
为何不对:接收回复只更新对端 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 | if (!sentinel.tilt && ri && (ri->flags & SRI_S_DOWN) && |
真正统计 Leader 选票时,sentinel.c:3943:
1 | /* Count other sentinels votes */ |
为何不对:下线意见和选举授权是两个独立结果。一个 Sentinel 可以回复 isdown=0,同时依据 epoch 规则授予 Leader 选票;接收端也会独立保存该选票。统计时检查的是 leader 和 leader_epoch,没有检查 SRI_MASTER_DOWN。把两种投票混在一起,会错误地认为所有参与 Leader 授权的 Sentinel 都必须先判定 Master 下线。
3. 连接共享复用的是同一个远端 Sentinel 的连接
作者原文摘录(节选):
复用这个实例
原文位置:sentinelTryConnectionSharing。
作者论述(转述):作者先称共享的是本机已有的主库连接,再称遍历找到 ri->master 就复用它;虽表示代码难懂,结论使用肯定语气。
对应源码,2020 快照:sentinel.c:1064:
1 | while((de = dictNext(di)) != NULL) { |
为何不对:条件恰好相反:遇到 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 | if (retval == C_OK) { |
下线检测使用它累计等待时间,sentinel.c:3665:
1 | if (ri->link->act_ping_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 | void publishCommand(client *c) { |
订阅模式下的服务端约束,server.c:3725:
1 | /* Only allow a subset of commands in the context of Pub/Sub if the |
为何不对:普通连接执行 PUBLISH 会收到订阅接收者数量的整数回复,不会因此进入订阅模式。原文示例实际发送的是 SUBSCRIBE。在这里使用的 RESP2 模式下,即便 Hiredis 函数返回了、或者异步调用成功入队,也不代表订阅连接能成功执行 SET;服务端仍会拒绝。因此分开 cc 和 pc 的关键理由包括订阅连接的协议限制及持续接收推送的需要,不能用客户端调用是否返回来判断是否可以复用。上述结论限定于 RESP2,源码已经明确 RESP3 没有同样的命令限制。
6. 当选要求达到 quorum,不是严格超过 quorum
作者原文摘录(节选):
必须大于master->quorum
作者论述(转述):作者列出当选者须获得绝对多数、严格超过配置 quorum;随后贴出实际只排除票数小于门槛的判断。
对应源码,2020 快照:sentinel.c:3983:
1 | voters_quorum = voters/2+1; |
为何不对:两个条件是票数 >= 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才刷盘
作者论述(转述):作者从增量同步的 32 MB 阈值推断宕机会破坏持久性;往下分析保存流程时,又说明写完须先强制同步,再改名替换文件。
对应源码(2020-08):src/rdb.c:1315、src/rdb.c:1331,函数 rdbSave:
1 | snprintf(tmpfile,256,"temp-%d.rdb", (int) getpid()); |
为什么欠妥: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成功之前不会被修改
作者论述(转述):作者先说明重写时需要另存新增命令,随后认为旧文件在重写完成前保持不变,并以此解释为何重写失败也不会丢数据。
对应源码(2020-08):src/aof.c:642,feedAppendOnlyFile:
1 | if (server.aof_state == AOF_ON) |
src/aof.c:394,正常 AOF 刷写:
1 | nwritten = aofWrite(server.aof_fd,server.aof_buf,sdslen(server.aof_buf)); |
src/aof.c:1784,重写成功、完成文件替换后才切换描述符:
1 | } else { |
为什么不对:开启 AOF 的正常运行期间,新增修改会同时进入普通 AOF 缓冲区与重写差异缓冲区。普通缓冲区继续写入旧的 server.aof_fd;旧文件因此持续增长,并非冻结不变。重写不成功时还能继续使用旧文件,恰恰依赖这条正常追加路径。
更准确的说法是:旧 AOF 在重写成功前不会被重写结果替换或截断,但仍接收正常追加。 重写失败本身不要求丢弃旧 AOF;是否可能因另一次故障损失最近写入,还取决于原有同步策略,不能把重写机制解释成无条件的数据零丢失保证。
3. everysec 的这段分支推迟的是 write,而且可能先回复客户端
作者原文摘录(节选):
延迟这次fsync
作者论述(转述):作者认为后台同步会阻塞写入,因而需要延后此次同步并由定时任务补做;但 force 为 1 时仍强制写入。他还将回复安排在写 AOF 之后。
对应源码(2020-08):src/aof.c:362:
1 | if (server.aof_fsync == AOF_FSYNC_EVERYSEC && !force) { |
真正的 aofWrite 在该提前返回分支之后,见 src/aof.c:394。而 src/server.c:2222 的 beforeSleep 是:
1 | /* Write the AOF buffer on disk */ |
为什么不对:此时后台的 fsync 已经在运行,代码没有把这个正在运行的调用推迟;它在新一轮 aofWrite 之前返回,把新数据暂留在 Redis 的 AOF 缓冲区。等待达到阈值后才继续尝试 write,即使前一次 fsync 还没有结束。
beforeSleep 会在 flushAppendOnlyFile 返回后继续处理客户端输出,所以这里连“每次回复前均已完成 AOF 的 write”都不是无条件保证,更不能把 write 等同于 fsync 已完成。更准确的说法是:everysec 用后台 fsync;后台同步繁忙时可推迟下一次 write,期间客户端仍可能收到成功回复。 force=1 跳过的是这段推迟 write 的逻辑。
4. always 并非逐条命令独立 fsync,no 也并非数据永远不落盘
作者原文摘录(节选):
每个命令刷盘一次
从不刷盘
作者论述(转述):作者在解释客户端回复之前的 AOF 刷写时,列出逐命令、按秒和不刷三种策略,并将第一种评价为安全性最高、速度最低。
对应源码(2020-08):普通命令先把序列化结果追加到同一个 server.aof_buf,见前文的 src/aof.c:642。随后 flushAppendOnlyFile 刷写整段缓冲区,并按策略同步,见 src/aof.c:394、src/aof.c:503:
1 | nwritten = aofWrite(server.aof_fd,server.aof_buf,sdslen(server.aof_buf)); |
配置文件 redis.conf:1108 的定义尤其明确:
1 | # Redis supports three different modes: |
为什么欠妥:若一次读入多个完整的 pipeline 命令,输入处理循环可以先执行它们,再到事件循环边界刷出累积的 AOF,因此多个命令可以共享一次 fsync;事务中的多条写命令也会累积到该缓冲区。相应循环见 src/networking.c:1843。always 的核心是每批 AOF 写出后的同步,而不是命令数量与 fsync 次数一一对应。
no 则表示常规追加路径不由 Redis 主动请求 fsync,仍会执行 write,由操作系统安排写回。重写、关闭等其他路径中的同步也不能由该选项概括为永远不发生。更准确的说法是:always 每次 AOF 刷写后同步;everysec 按秒调度后台同步;no 将常规写回时机交给操作系统。
5. AOF 格式的说明范围不完整:这两个快照都支持默认开启的 RDB 前缀
作者原文摘录(节选):
协议文本
作者论述(转述):作者开篇以二进制快照和命令文本对比 RDB、AOF;介绍重写时又用多次递增合并为一次赋值,说明如何减少保存当前数据所需的命令。
对应源码(2020-08):src/aof.c:1431,rewriteAppendOnlyFile:
1 | if (server.aof_use_rdb_preamble) { |
1 | # When rewriting the AOF file, Redis is able to use an RDB preamble in the |
该配置在 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 | addReplyArrayLen(c,c->mstate.count); |
为什么欠妥:这里需要区分两种含义。作者关于“执行时出错没有回滚”的判断是对的:某个命令出错不意味着此前的成功修改被撤销,也不必然停止后续命令。例如 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 描述为 MULTI 前对若干键建立监控;当这些键后来被修改,就把相应事务的结果称作回滚。
对应源码(2020-08):src/multi.c:164,冲突分支位于执行循环之前:
1 | if (c->flags & (CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC)) { |
1 | void discardTransaction(client *c) { |
为什么不对:发生 WATCH 冲突时,排队的事务命令还没有执行。这里清理的是队列、事务标志与监视关系,没有恢复旧值的操作。其他客户端对被监视键作出的修改也不会被撤销。
例如客户端 A WATCH k 后,客户端 B 把 k 改成 2;A 随后的事务因冲突未执行,k 仍然是 B 写入的 2。准确表述应为:WATCH 冲突使 EXEC 放弃尚未执行的事务,客户端可重新读取并重试。
3. WATCH 冲突返回 null,与成功执行空事务的空数组不同
作者原文摘录(节选):
对于CLIENT_DIRTY_CAS返回空数组
作者论述(转述):作者区分执行前的两类标志:监视键变化时给出特殊结果而非错误,入队阶段出错则返回 EXECABORT;前者被称为空数组。
对应源码(2020-08):src/multi.c:164 在 CLIENT_DIRTY_CAS 分支返回 shared.nullarray[c->resp]。这些对象在 src/server.c:2259、src/server.c:2305 中有明确区分:
1 | shared.emptyarray = createObject(OBJ_STRING,sdsnew("*0\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的数据一致性
作者论述(转述):作者以 SPOP 改写成 SREM 为例,说明执行会改变命令及参数,因此需要写回事务队列;他将此操作解释为保证副本与 AOF 一致。
对应源码(2020-08):src/multi.c:208:
1 | call(c,server.loading ? CMD_CALL_NONE : CMD_CALL_FULL); |
但 call 内部已经使用当前改写后的参数传播,见 src/server.c:3433:
1 | if (propagate_flags != PROPAGATE_NONE && !(c->cmd->flags & CMD_MODULE)) |
改写可以释放旧参数数组,见 src/networking.c:2636:
1 | for (j = 0; j < c->argc; j++) decrRefCount(c->argv[j]); |
事务结束后,discardTransaction 则会调用 src/multi.c:43 清理队列:
1 | for (j = 0; j < c->mstate.count; j++) { |
为什么欠妥:把 SPOP 等命令改写为确定性操作,确实与复制、AOF 重放的一致性有关;但文章把这个作用归给了之后更新 mstate 的动作,混淆了两个步骤。传播发生在 call 内,早于写回队列。队列必须跟上新 argv/argc,否则稍后的释放会访问已释放的旧数组,还可能遗漏新数组。
更准确的解释是:命令改写使传播具有确定性;把结果写回事务状态,使队列继续拥有正确的命令参数,并能在事务结束时正确释放。
Redis主从复制
原文:Redis主从复制。以下仍以 2020 年源码快照 c01e94a4319c416c4c231ffbea9e778d52424e30 为主。三个问题均另核对了本地 **2018 年快照 b65ddfb16a7060a543b523feadeca1234cffd323**,不存在把两个版本混用导致的误判。
1. PSYNC 请求的 offset 是下一待接收字节的位置
作者原文摘录(节选):
offset 表示 Slave 接受到最后命令的偏移量,以字节计算
作者论述(转述):作者把 PSYNC 的 offset 定义为从库最后收到命令的字节偏移;后文所贴发送代码却在缓存偏移上加一。
对应源码,2020 快照:replication.c:1969:
1 | if (server.cached_master) { |
真正发送请求,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
作者论述(转述):作者介绍安装 RDB 读取回调、进入传输状态后,说该字段设为正一;紧接着贴出的初始化代码实际是负一。
对应源码,2020 快照:replication.c:2384:
1 | server.repl_state = REPL_STATE_TRANSFER; |
接收回调据此判断是否读取协议头,replication.c:1493:
1 | /* If repl_transfer_size == -1 we still have to read the bulk length |
2018 快照初始化同样为 -1,见 replication.c:1841。
为何不对:这应归为正文笔误,与其紧随的源码自相矛盾。这里的负号有协议含义:-1 是尚未解析长度/EOF 传输头的哨兵值。首轮回调先读取 $<长度> 或 $EOF:<标记>,然后才接收 RDB 数据。若照正文把它理解成 1,就会把“长度尚未知”误读为“长度已确定为 1 字节”,漏掉正确的接收状态转换。
3. CAPA 发送的是协议能力,不是容量
作者原文摘录(节选):
用来发送Slave的容量
作者论述(转述):作者把 REPL_STATE_SEND_CAPA 解释为发送从库容量,并追问该容量的含义;随附注释说明的是 EOF、PSYNC2 协议能力。
对应源码,2020 快照:replication.c:2272:
1 | if (server.repl_state == REPL_STATE_SEND_CAPA) { |
主库解析为能力位,replication.c:893:
1 | } else if (!strcasecmp(c->argv[j]->ptr,"capa")) { |
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
作者论述(转述):作者为解释主从及 AOF 执行结果一致,将冻结时钟说成脚本内不再过期,并认为应先清理脚本涉及的键再执行。
对应源码(2020-08,c01e94a):src/db.c:1232,以下为同一个函数内的三个节选。
1 | int keyIsExpired(redisDb *db, robj *key) { |
1 | if (server.lua_caller) { |
1 | return now > when; |
主节点收到过期判断后,仍然执行删除:src/db.c:1288、src/db.c:1299。
1 | int expireIfNeeded(redisDb *db, robj *key) { |
1 | if (server.masterhost != NULL) return 1; |
为什么不准确:假设键的过期时间是 900 ms,脚本从 1000 ms 开始。脚本第一次访问该键时,比较结果仍然是 1000 > 900,主节点会在这次访问中删除它。被固定的是判断用的时钟,所以在脚本开始时尚未到期的键,不会仅仅因为脚本耗时而在后续访问中突然过期。
evalGenericCommand 设置 server.lua_time_start 后调用 lua_pcall,并没有在进入脚本之前遍历 KEYS 统一执行 expireIfNeeded;参见 src/scripting.c:1541。因此,更准确的表述是:在脚本内按脚本开始时刻进行惰性过期判断;首次访问仍可能触发过期删除。
2. FAST 过期循环不是根据键少而选用的模式
作者原文摘录(节选):
key比较少
作者论述(转述):作者将两种循环按清理强度区分:FAST 用于键少时节省 CPU,过期键数量降至阈值便退出;SLOW 更积极地回收内存。
对应源码(2020-08,c01e94a):src/expire.c:153。
1 | if (type == ACTIVE_EXPIRE_CYCLE_FAST) { |
调用地点分别是 databasesCron 和 beforeSleep:src/server.c:1706、src/server.c:2184。
1 | if (server.active_expire_enabled) { |
1 | if (server.active_expire_enabled && server.masterhost == NULL) |
为什么不准确:这不是按键数量二选一的策略。SLOW 是定时维护中的常规循环;FAST 是事件循环中的补充清理机会,其入口受前次是否超时、过期统计量及距上次 FAST 的间隔约束。低清理压力下 FAST 反而可能直接返回。两者共用同一套扫描主体,主要差别是调用时机和时间预算:默认 FAST 为约 1 ms,SLOW 为 25% / server.hz 秒,参见 src/expire.c:117、src/expire.c:182。这些是预算与定期检查机制,不能理解为每次调用绝不会超出的硬实时上限。
3. 把 SYNC / PSYNC 概括成全量同步,遗漏了 PSYNC 的核心分支
作者原文摘录(节选):
来个全量同步
作者论述(转述):作者把复制分为状态同步和指令传播:SYNC/PSYNC 开始时全量同步主节点状态,propagate 则把后续指令发送给从节点或 AOF。
对应源码(2020-08,c01e94a):src/replication.c:743。
1 | if (!strcasecmp(c->argv[0]->ptr,"psync")) { |
部分同步接受后会回复 CONTINUE 并发送 backlog:src/replication.c:585。
1 | if (c->slave_capa & SLAVE_CAPA_PSYNC2) { |
为什么欠妥:首次连接且没有可续传历史时,确实通常需要全量同步。但 PSYNC 还用于断线后的部分重同步:复制 ID 可接受,而且请求的 offset 仍在 backlog 覆盖范围内时,只补传缺失的复制流,不重新发送整个数据库。对应检查见 src/replication.c:534、src/replication.c:559。应分别说明 SYNC 的全量同步,以及 PSYNC 的部分同步/退回全量同步,否则读者容易误判复制重连的代价。
4. LFU 计数器与访问次数并非严格的对数关系
作者原文摘录(节选):
成对数关系
作者论述(转述):作者由计数越大、增长概率越低推论其与访问数呈对数关系,称调大因子能记录更高频次,并认为随机性使数学计算困难。
对应源码(2020-08,c01e94a):src/evict.c:315。
1 | uint8_t LFULogIncr(uint8_t counter) { |
为什么不准确:忽略衰减、饱和以及伪随机数离散化,令 f = lfu_log_factor,b = counter - LFU_INIT_VAL ≥ 0。在计数值固定时,每次命中使其增加的概率为 1 / (f*b + 1),所以等到下一次增加所需命中次数的期望是 f*b + 1。从初始值增加 m 次的总命中次数期望为:
1 | E[N_m] = Σ(b=0…m−1) (f*b + 1) |
当 f > 0 时,这是关于 m 的二次式,其反函数呈平方根量级;f = 0 时则近似每次命中都增加,直到饱和。因此,应说它是增长概率随当前值减小的压缩计数器,不能由函数名 LFULogIncr 推出严格的对数关系。源码注释自身也使用了 “Logarithmically” 这个词,但具体的数学关系仍应以函数内的概率公式为准。
5. DEL 不会无条件发布两条键通知
作者原文摘录(节选):
分发两条消息
作者论述(转述):作者以在零号数据库对 mykey 执行 DEL 为例,称两个频道会同时发送通知,分别告知该键发生的事件及执行删除命令的键。
对应源码(2020-08,c01e94a):src/notify.c:111。
1 | /* If notifications for this class of events are off, return ASAP. */ |
第二个频道有独立开关:src/notify.c:129。
1 | if (server.notify_keyspace_events & NOTIFY_KEYEVENT) { |
DEL 也仅在确实删除成功时触发 del 事件:src/db.c:562。
1 | for (j = 1; j < c->argc; j++) { |
为什么欠妥:默认 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禁用内存统计的功能
作者论述(转述):作者先说明 zmalloc 增加长度前缀、返回偏移后的指针,再认为定义 HAVE_MALLOC_SIZE 就能省去这部分记录并停用内存统计。
对应源码(2020-08,c01e94a):src/zmalloc.c:89。
1 | void *zmalloc(size_t size) { |
统计宏对两个分支相同:src/zmalloc.c:74。
1 |
为什么错误:两个分支都更新 used_memory。HAVE_MALLOC_SIZE 表示当前分配器可以查询已分配块的大小,因此 Redis 不必在返回给调用者的内存前额外存储长度。以 macOS 为例,src/zmalloc.h:58 把 zmalloc_size(p) 映射到系统的 malloc_size(p)。关闭的是 Redis 自己添加的长度前缀,内存统计仍然有效;有分配器大小接口时,统计依据还可以反映分配器实际提供的块大小。