Redis 数据结构、执行与原子边界
源码固定为 Redis 7.2.5,不是最新版本推荐。2026-10-23 的本机实验实际为 8.6.4、standalone、无副本、AOF 关闭;两者证据分开。先读结构和调用链,再运行完整实验。跨数据库缓存协议的唯一讲解已归缓存与数据库一致性,本篇解释它依赖的 Redis 原子域、过期与失效条件。
从命令语义选择类型与键边界
建模先列出必须在一次命令中完成的操作。String 适合整值读取、整数增量和带条件 SET;Hash 支持字段级读取/修改;List 提供有序的两端操作;Set 用于去重成员与集合运算;ZSet 同时按 member 查分数、按 score 排序;Stream 以 ID 追加条目并管理消费组和待确认状态。Stream 有 PEL 并不自动使外部付款恰好执行一次,确认消息与业务效果仍需业务协议。
假设一个用户有十个资料字段,Hash 能减少独立 key、字典和过期元数据,却把该用户所有字段放在一个热点/删除/迁移单元;若把百万用户放进一个 Hash,单键的串行执行、重建和搬迁会变成大故障域。拆成百万键增加元数据和网络往返,但能分别过期和分布。选“一个对象一个 Hash”仍要限制字段总数、最大值和返回量,不能以少 key 等同便宜。
逻辑类型由 TYPE 告诉你,物理编码用 OBJECT ENCODING 才能观察。String 的 int 将合适整数编码到对象中,embstr 合并分配短字符串对象与内容,raw 使用独立 SDS。前导零、超出整数范围或 APPEND 改变可变性时,不能期待继续保持 int/embstr。SDS 记录长度、支持二进制,不靠零结尾判断内容长度;大 GET 的 CPU、拷贝、协议和网络成本仍按数据量增长。object.c 的 tryObjectEncoding 是此处入口。
List 在 7.2.5 使用 quicklist 组织多个紧凑节点,不能讲成每个元素一个链表节点;节点大小是在局部性、移动和压缩代价间取舍。ZSet 大对象用字典查 member→score,用 skiplist 排序和范围定位,span 支持 rank;同 score 时还按 member 比较,分页游标只保留 score 会漏掉并列成员。对应入口为 t_list.c 与 t_zset.c 的 zslInsert/zsetAdd。
Hash:参数预检也能触发转换
7.2.5 中 hsetCommand → hashTypeTryConversion → hashTypeSet。小 Hash 的 listpack 把字段和值紧凑排列,节省指针和分配,但查找、更新可能扫描和移动。默认 hash-max-listpack-entries=512、hash-max-listpack-value=64;越过字段数、字段/值字节长度或安全扩展限制会转 hashtable。
原码连续摘录,截取局部、未改写:redis/redis · 7.2.5 · src/t_hash.c · L40–L67。
void hashTypeTryConversion(robj *o, robj **argv, int start, int end) {
int i;
size_t sum = 0;
if (o->encoding != OBJ_ENCODING_LISTPACK) return;
/* We guess that most of the values in the input are unique, so
* if there are enough arguments we create a pre-sized hash, which
* might over allocate memory if there are duplicates. */
size_t new_fields = (end - start + 1) / 2;
if (new_fields > server.hash_max_listpack_entries) {
hashTypeConvert(o, OBJ_ENCODING_HT);
dictExpand(o->ptr, new_fields);
return;
}
for (i = start; i <= end; i++) {
if (!sdsEncodedObject(argv[i]))
continue;
size_t len = sdslen(argv[i]->ptr);
if (len > server.hash_max_listpack_value) {
hashTypeConvert(o, OBJ_ENCODING_HT);
return;
}
sum += len;
}
if (!lpSafeToAdd(o->ptr, sum))
hashTypeConvert(o, OBJ_ENCODING_HT);第一道判断拒绝处理非 listpack。随后 new_fields 依据本次参数对数估算,不是精确新增字段数:一次 HSET 传 513 对重复的 f x,最终 HLEN 可以只有 1,却已经预转换并按 513 预扩容。再往下逐个检查字段和值长度,最后检查 lpSafeToAdd。真正 hashTypeSet 追加后还会检查总字段数,因此“一次传 513 对”与“已有 512 再添一对”可经不同位置转换。
用三组反例理解成本:512 个短字段→加第 513 个;一个字段的值由 64→65 字节;513 对重复字段→最终只有 1 个字段。三者都可能出现 hashtable,不能仅看最终 HLEN 预测编码。删除到阈值以下不保证自动转回。字典渐进 rehash 把搬迁分摊到多次操作,却不提供严格单次耗时上界,分配、长桶或一条大命令仍会抖动;源码入口为 dict.c 的 dictRehash。
Set:非整数不一定直接进入 hashtable
7.2.5 的 Set 具有 intset、listpack、hashtable 三条路径。小整数集合可用 intset,整数宽度升级可能重分配;小的非整数成员可用 listpack。默认 intset 最多 512 个成员,Set listpack 最多 128 个成员、元素最多 64 字节。不能把较早版本“Set 只有 intset/hashtable”的答案沿用到本标签。
原码连续摘录,截取局部、未改写:redis/redis · 7.2.5 · src/t_set.c · L181–L224。
} else if (set->encoding == OBJ_ENCODING_INTSET) {
long long value;
if (string2ll(str, len, &value)) {
uint8_t success = 0;
set->ptr = intsetAdd(set->ptr,value,&success);
if (success) {
maybeConvertIntset(set);
return 1;
}
} else {
/* Check if listpack encoding is safe not to cross any threshold. */
size_t maxelelen = 0, totsize = 0;
unsigned long n = intsetLen(set->ptr);
if (n != 0) {
size_t elelen1 = sdigits10(intsetMax(set->ptr));
size_t elelen2 = sdigits10(intsetMin(set->ptr));
maxelelen = max(elelen1, elelen2);
size_t s1 = lpEstimateBytesRepeatedInteger(intsetMax(set->ptr), n);
size_t s2 = lpEstimateBytesRepeatedInteger(intsetMin(set->ptr), n);
totsize = max(s1, s2);
}
if (intsetLen((const intset*)set->ptr) < server.set_max_listpack_entries &&
len <= server.set_max_listpack_value &&
maxelelen <= server.set_max_listpack_value &&
lpSafeToAdd(NULL, totsize + len))
{
/* In the "safe to add" check above we assumed all elements in
* the intset are of size maxelelen. This is an upper bound. */
setTypeConvertAndExpand(set, OBJ_ENCODING_LISTPACK,
intsetLen(set->ptr) + 1, 1);
unsigned char *lp = set->ptr;
lp = lpAppend(lp, (unsigned char *)str, len);
lp = lpShrinkToFit(lp);
set->ptr = lp;
return 1;
} else {
setTypeConvertAndExpand(set, OBJ_ENCODING_HT,
intsetLen(set->ptr) + 1, 1);
/* The set *was* an intset and this value is not integer
* encodable, so dictAdd should always work. */
serverAssert(dictAdd(set->ptr,sdsnewlen(str,len),NULL) == DICT_OK);
return 1;
}
}这里处理已有 intset 中加入非整数的分支。先估算原有整数转换为文本后的最长长度和总大小,再同时检查数量、元素长度和 listpack 安全容量。都满足才转 listpack 并追加,否则转 hashtable。已有很多整数、一个 65 字节成员、或安全容量不满足,都可能直接走 HT;“加入字母必转 HT”“所有小 Set 都是 listpack”都不成立。
例如 {1,2} 加入 a 可变为 listpack,再加入 65 字节字符串变为 hashtable;513 个整数越过 intset 阈值且超过 listpack 数量预算,通常进入 hashtable。ZSet 的默认 listpack 阈值为 128 个成员/64 字节,越界转 skiplist+dict;本机实验对照 128→129。只把 OBJECT ENCODING 当编码证据,MEMORY USAGE 的具体字节受分配器、版本、数据形状影响,不能据一次样本给生产精确容量承诺。
普通命令串行,I/O 线程处理什么
7.2.5 调用链是 aeMain → aeProcessEvents,socket 读取进入 readQueryFromClient → processInputBuffer → processCommandAndResetClient,再由 processCommand → call → 命令 proc 执行;事件循环还穿插定时事件和 beforeSleep 的传播、持久化、写出。入口见 ae.c、networking.c、server.c。
I/O 多线程能分担 socket 读写和协议处理的一部分工作,但不是让普通 GET/HSET 同时修改键空间。本标签默认 io-threads-do-reads no,开启读线程后主线程也要等它们完成,再继续处理待执行命令:
原码连续摘录,截取局部、未改写:redis/redis · 7.2.5 · src/networking.c · L4476–L4509。
/* Wait for all the other threads to end their work. */
while(1) {
unsigned long pending = 0;
for (int j = 1; j < server.io_threads_num; j++)
pending += getIOPendingCount(j);
if (pending == 0) break;
}
io_threads_op = IO_THREADS_OP_IDLE;
/* Run the list of clients again to process the new buffers. */
while(listLength(server.clients_pending_read)) {
ln = listFirst(server.clients_pending_read);
client *c = listNodeValue(ln);
listDelNode(server.clients_pending_read,ln);
c->pending_read_list_node = NULL;
serverAssert(!(c->flags & CLIENT_BLOCKED));
if (beforeNextClient(c) == C_ERR) {
/* If the client is no longer valid, we avoid
* processing the client later. So we just go
* to the next. */
continue;
}
/* Once io-threads are idle we can update the client in the mem usage */
updateClientMemUsageAndBucket(c);
if (processPendingCommandAndInputBuffer(c) == C_ERR) {
/* If the client is no longer valid, we avoid
* processing the client later. So we just go
* to the next. */
continue;pending==0 才越过等待,io_threads_op 变 IDLE 后逐个客户端调用 processPendingCommandAndInputBuffer。因此一个长 Lua、大范围集合操作或巨大返回值仍会推迟别的连接。后台释放、fork 子进程和 I/O 线程存在,所以“整个进程只有一个线程”也不对。本机 8.6.4 的 CONFIG GET 未返回 io-threads-do-reads 这项,这里只用 7.2.5 原码解释该版本,不能据旧配置推断新版本所有 I/O 细节。
SCAN 是增量遍历而非一致性快照,COUNT 是提示,可能返回重复或空批;调用者要去重并定义并发变化的处理。HGETALL/SMEMBERS 返回多少就付多少处理与传输成本,ZRANGE 返回 m 项也有 m 的成本。UNLINK 可把部分释放放后台,不消除对象尚占内存或回收 CPU。
原子执行、事务错误与未知结果
processCommand 在调用 proc 前还检查合法性、ACL、内存、可写性和集群路由;拒绝执行与执行到一半出错不同。Lua/Functions 在一次服务端执行中不被其他普通命令穿插,MULTI/EXEC 的队列也按该执行边界处理,但都不把 MySQL/HTTP 纳入一个提交。
事务有三个常被混淆的分支:排队阶段发现错误可使 EXECABORT,WATCH 条件失效可使 EXEC 返回空结果;EXEC 已开始后的 WRONGTYPE 等运行期错误不会撤销之前写入,也不会自然取消后续命令。execCommand 逐项设置 argv/cmd、检查 ACL 后调用命令;下面是循环尾部,不是带 undo 的数据库事务。
原码连续摘录,截取局部、未改写:redis/redis · 7.2.5 · src/multi.c · L229–L243。
} else {
if (c->id == CLIENT_ID_AOF)
call(c,CMD_CALL_NONE);
else
call(c,CMD_CALL_FULL);
serverAssert((c->flags & CLIENT_BLOCKED) == 0);
}
/* 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].argv_len = c->argv_len;
c->mstate.commands[j].cmd = c->cmd;
}循环会在一次命令返回后恢复 mstate 并继续。公开实验在同一 EXEC 排入 SET k before、LPUSH k item、SET other after:第二条因 k 是 String 失败,第一、三条仍存在。脚本 SET k before; INCR k 因非整数失败,先前 SET 也保留。减少半成品的方法是先验证所有参数/类型与状态,再修改,并让错误分支尽量发生在写之前;这仍不是任意运行期失败可回滚的保证。事务官方说明是滚动文档,源码与实测版本仍分别标注。
原子性也不是幂等。INCR 执行后响应丢失,重试可能再加一次;请求 ID 与结果若一起存 Redis,去重记录还可能过期、被淘汰或切换丢失。重试需稳定业务身份、结果查询和明确保留窗口,外部效果回查业务幂等。大脚本要限制循环、键数、参数和返回体,原子不代表不阻塞。
TTL、缓存压力与跨资源边界
TTL 是 Redis 键的逻辑有效期,不是数据库提交水位。PTTL=-1 表示键存在但没有到期时间,-2 表示不存在;普通 SET 覆盖值默认清除旧 TTL,需显式 EX/PX 或 KEEPTTL 才保留意图。键到期后读检查与后台采样清理属于引擎机制,详细见恢复单元的过期与淘汰。
写库删缓存成功仍可能旧回填,延迟的读请求可在数据库提交很久之后才开启它自己的缓存 TTL。水位 F 与值 C 的原子比较、读取检查、F 的寿命及切换丢失都属于跨资源协议,完整时序和反例只维护在缓存与数据库一致性。Redis 提供同实例/同槽的原子操作,无法使尚未到达的数据库事件凭空可见,也不保证 F 永不丢失。
选防护前先区分压力来源:不存在键反复回源是穿透,可参数校验、短 TTL 空值、布隆过滤;布隆有误判,新增数据需要同步路径,不能凭旧过滤器拒绝真实新对象。热点键到期时大批请求回源是击穿,可单请求合并、受限互斥重建或逻辑过期刷新,但要规定失败和最大旧读时长。大量键同时过期、集群故障或批量淘汰造成雪崩,需要 TTL 抖动、分批预热、受限回源和降级。
10 万读/秒、命中率 99% 时数据库约承担 1000 读/秒;缓存全失效后全部回源为 10 万,放大 100 倍。加大连接池不能把数据库变快,反而可能增大排队。请求合并也需最大等待人数、总时限与租户/权限键,不能跨用户错误共享结果。重建互斥若使用 Redis 租约,其暂停与资源端 fencing 边界回查租约权威讲解,不在这里再维护另一套通用锁协议。
可运行实验与排障证据
完整入口:run_local.py,只用 Python 标准库;实验说明给出各 mode、输入、预期与完整下载文件。
python3 labs/data-senior/redis/run_local.py --mode inspect
python3 labs/data-senior/redis/run_local.py --mode all
python3 labs/data-senior/models.py --json cache真实服务程序先只读版本、配置和内存;有上限时余量不足 16 MiB 拒绝所有写,阈值过大则跳过编码负载。每次 UUID 前缀、NX 创建预留、记录本次创建列表、finally 只删该列表,不扫描、不 FLUSH、不调配置、不重启。保持少量有界数据,仍应选择自己获授权的实验服务。
2026-10-23 实际 8.6.4:Hash 512→513 为 listpack→hashtable,单 65B 值为 hashtable,513 对重复参数得到 HLEN=1/hashtable;Set 为 intset→listpack→hashtable,513 整数为 hashtable;ZSet 128→129 为 listpack→skiplist。512 字段样本 MEMORY USAGE=4585B 仅是本次观察。Lua 和 MULTI 结果如上;PX200 的键最终不可读/PTTL=-2,KEEPTTL 保留约 60 秒,普通 SET 后 PTTL=-1。12 个专用键最终剩余 0;未跑压力基准,不能从这些结果产生 P99。
排障先把客户端总延迟拆为池等待、网络、服务端排队/执行与响应传输。SLOWLOG 看服务端慢执行,不含完整网络往返;没有慢日志也可能是大响应、连接池、CPU 饥饿、缺页/swap、fork 或持久化。对照命令参数规模、OBJECT ENCODING/MEMORY USAGE、主机与 Redis 指标,避免只调一个阈值。
高级问答与架构条件变化
主问题“Redis 为什么快,为什么会慢”:紧凑表示降低常规分配/访存,内存访问与串行普通命令降低常规调度成本,I/O 复用管理连接;大命令、转换、长脚本、持久化和系统资源仍可主导尾延迟。主答 4 分:结构、执行模型、非恒定成本、可检查证据各 1 分。
第一轮:HSET 的 HLEN 只有 1 为什么也变 HT?参数对数预估触发预转换,513 对同字段不能被最后计数掩盖。2 分:说预检得 1,给重复参数反例得 1。第二轮:脚本失败是否可以安全重试?可能先写后错;再加响应丢失则还存在未知结果,需业务身份与结果查证。2 分:无回滚、未知各 1。第三轮:加水位是不是强一致?库提交到事件到达有窗口,F 丢失和读未检查也破坏条件,回查跨资源协议。2 分:传播窗口、寿命/读条件各 1。合计 10 分,至少 8 分且不把原子执行等同回滚才算完成本轮表达。
条件一,从允许展示旧 5 秒变为“付款后读己写”,读携带事实源最小版本,缓存不够则查事实源;若要求所有人在提交后立即最新,异步失效需要重新设计一致性与可用性。条件二,单商品占一半流量,Cluster 不会把同一个键的执行切成多份,需业务拆分、聚合、缓存或降级并重述陈旧性。条件三,从商品展示改为权限撤销,不能直接复用宽松 TTL,授权要按可靠裁决路径和请求身份验证。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。