【Redis】 May

quicklist

Redis 的 quicklist 是自 3.2 版本起,用来作为列表(List)类型底层实现的核心数据结构。它取代了早期的 linkedlist(双向链表)和 ziplist(压缩列表)的混合逻辑,将两者的优势结合到了一起。


一、设计动机

在 quicklist 出现前,Redis 根据元素数量和大小在两种结构间切换:

  • ziplist:一整块连续内存,存储紧凑,内存利用率极高。但插入、删除时需要频繁重新分配内存和移动数据,对长列表性能很差。
  • linkedlist:标准双向链表,在头部、尾部操作都是 O(1),但每个元素都有 prevnext 两个指针,内存开销大(尤其在存小元素时),且容易产生内存碎片。

quicklist 的设计思路
将多个 ziplist 用双向链表串起来。每个链表节点存放一个 ziplist,这样就同时具备了:

  • 链表快速的在两端增删能力(O(1));
  • ziplist 优秀的内存紧凑性(减少指针开销);
  • 通过控制每个节点内 ziplist 的大小,来限制插入/删除时的内存复制成本。

二、核心数据结构

quicklist 源码中相关的定义如下(简化并添加注释):

// 快速链表整体结构
typedef struct quicklist {
    quicklistNode *head;        // 头节点
    quicklistNode *tail;        // 尾节点
    unsigned long count;        // 所有 ziplist 中的元素总数
    unsigned long len;          // quicklistNode 的数量
    int fill : 16;              // 填充因子(控制每个节点 ziplist 的大小)
    unsigned int compress : 16; // 压缩深度(不压缩的首尾节点数)
    // ... 其他记录信息
} quicklist;

// 链表节点
typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;          // 指向真正的 ziplist 或 LZF 压缩后的数据
    unsigned int sz;             // ziplist 的字节大小
    unsigned int count : 16;    // 该节点内的元素数量
    unsigned int encoding : 2;  // 编码:RAW(1,未压缩)或 LZF(2,已压缩)
    unsigned int container : 2; // 容器类型:NONE(1) 或 ZIPLIST(2)
    unsigned int recompress : 1;// 节点被临时解压后,是否需要重新压缩
    unsigned int attempted_compress : 1; // 测试用
    unsigned int extra : 10;    // 预留位
} quicklistNode;

结构示意:

quicklist
  head                                tail
   |                                   |
   v                                   v
[node1] <-> [node2] <-> ... <-> [nodeN]
   |             |
   v             v
[ziplist]    [ziplist]    ...    [ziplist]
 (entry)      (entry)

每个 node 内的 ziplist 才是真正存放元素的地方,元素连续排列、无指针开销


三、关键配置与参数

quicklist 的行为主要由 Redis 配置中的两个参数控制:

1. list-max-ziplist-size(对应 quicklist 的 fill 属性)

决定每个链表节点内 ziplist 的最大容量。其值可以取:

  • 正数:表示每个 ziplist 最多包含的 元素个数
    例如 5 表示一个节点最多存 5 个元素。
  • 负数-1 ~ -5):表示每个 ziplist 的 最大内存占用(单位字节)。Redis 提供 5 档:
    • -1:每个 ziplist 不超过 4 KB
    • -2:不超过 8 KB(默认值
    • -3:不超过 16 KB
    • -4:不超过 32 KB
    • -5:不超过 64 KB

设计思想:限制单个 ziplist 的大小,可以确保在插入/删除时即使发生连锁更新或内存重分配,代价也是可控的(最多影响一个 ziplist)。

2. list-compress-depth(对应 compress 属性)

决定 quicklist 两端 不压缩的节点个数
除了两端指定的节点外,其它中间节点内的 ziplist 会被 LZF 算法压缩,进一步节省内存。

  • 0:不压缩(默认);
  • 1:首尾各 1 个节点不压缩,中间节点压缩;
  • 2:首尾各 2 个节点不压缩,中间压缩;
  • 以此类推……

适用场景:很多列表使用场景(如消息队列)只会频繁访问两端数据,中间数据很少被读取。压缩中间节点可以大幅减少内存占用,而两端不压缩则保证了头尾操作的性能。


四、关键操作的实现原理

1. 头尾插入/删除(LPUSH / RPUSH / LPOP / RPOP)

以头部插入为例(quicklistPushHead):

  1. 找到头节点 head
  2. 尝试将新元素加入该节点的 ziplist(调用 ziplistPush)。
  3. 如果加入后 ziplist 的大小超过了 fill 限制,则该 ziplist 会分裂或新元素被放入新建节点,并链接到原头部之前。
  4. 若配置了压缩且头部节点恰巧在“被压缩节点”中,会先解压再操作,操作完成后根据 compress 决定是否重新压缩。

时间复杂度:分两种情况:

  • 如果头节点有空余空间,只需要在 ziplist 内做一次插入,复杂度 O(1)(相对数据量,ziplist 固定大小)。
  • 如果需要分裂或新建节点,仍是 O(1),因为只需操作链表头。

同理,尾部的操作也完全对称。

2. 随机读写(LINDEX / LSET / LRANGE)

quicklist 需要支持按照索引访问:

  1. 根据 count 和各个节点内的 count,计算出目标索引在哪个节点。
  2. 遍历链表定位到该节点。
  3. 在该节点的 ziplist 内以偏移量定位到具体条目。

如果目标节点在中间且被压缩(encoding == LZF),会先解压到临时缓冲区,访问完后再根据 recompress 标记决定是否重新压缩。

复杂度:O(N) 即链表节点数,但比起纯 linkedlist 遍历元素,这里跳跃是以节点为单位,因此实际遍历的步数更少;不过仍需逐个节点扫描。

3. 插入 / 删除中间元素(LINSERT / LREM)

操作步骤:

  • 先定位到包含目标值的节点以及 ziplist 内的位置。
  • 在该 ziplist 上进行插入或删除。
  • 操作后可能触发:
    • ziplist 大小超过 fill → 将超出的元素移到相邻节点,或拆分节点。
    • 节点过小(例如删除后)→ 可能会与相邻节点合并,以维持内存效率。

Redis 的 quicklist 合并逻辑是保守的:只有当两个相邻节点合并后的大小不超过 fill 一半时才合并,以避免无意义的合并又拆分。


五、性能与内存特性

  • 内存效率
    相比纯 linkedlist,quicklist 每个元素节省了约两个指针的空间(16 字节在 64 位系统上),并且可以利用 LZF 压缩中间节点,内存优势明显。

  • CPU 开销
    由于节点内是 ziplist,每次头尾操作可能涉及一次小范围的内存复制,但通过 fill 限制单节点大小,复制量是可控的。
    对于大量随机中间访问的场景,quicklist 不如纯 linkedlist 友好(因为可能需要解压和 ziplist 内遍历),但 Redis 列表的主要用例是队列和栈,这点影响不大。

  • 缓存友好
    ziplist 连续内存布局使 CPU 缓存命中率更高,遍历一个节点内的多个元素时效率很高。


六、与旧实现的对比

特性linkedlist + ziplist(3.2 前)quicklist(3.2 后)
存储结构要么全 ziplist,要么全 linkedlist永远是一个链表,每个节点是 ziplist
内存效率ziplist 阶段极好;转链表后极差一直很优秀,且支持中间压缩
头尾操作ziplist 在元素多时变慢;链表保持 O(1)稳定 O(1),性能波动小
配置复杂度多种条件切换(list-max-ziplist-*只需 fillcompress 两个参数
大元素适应性ziplist 无法存储超长元素时会转链表同样会限制元素大小,但由 ziplist 能力决定

七、总结与应用建议

  • quicklist 是 Redis 列表的唯一内部编码,从 3.2 开始所有列表操作都建立在该结构之上。
  • 它适合队列(LPUSH/RPOP)栈(LPUSH/LPOP)消息列表等频繁头尾操作、偶尔随机访问的场景。
  • 通过调整 list-max-ziplist-size 可以在内存与 CPU 间取舍:
    • 调大 → 更少节点,更省内存(减少节点指针开销),但插入时 ziplist 复制代价变大;
    • 调小 → 更多节点,操作碎片化,但单次插入更轻量。
  • 启用 list-compress-depth 可以在只访问两头的场景下大幅压缩内存(压缩中间长期不访问的数据),非常适合日志队列、延时队列等。

quicklist 的设计是工程上的优秀折中,用一个巧妙的组合结构,让 Redis 列表在大部分实际负载下都保持了“内存高效 + 操作极快”的特点。

ziplist & listpack

在 Redis 中,ziplist(压缩列表)和 listpack(紧凑列表)都是小数据量下的连续内存编码结构,用于替代传统的链表或哈希表,以追求极致的内存效率。listpack 是专门为解决 ziplist 固有缺陷而设计的新一代方案,目前正逐步取代 ziplist。下面从结构、连锁更新、编码、性能和使用演变等方面详细对比。


一、ziplist 的结构与痛点

1. 内存布局

<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
  • zlbytes(4 字节):整个 ziplist 占用字节数。
  • zltail(4 字节):最后一个 entry 的偏移量,便于尾部操作。
  • zllen(2 字节):entry 数量,超过 65535 时需遍历得到真实值。
  • zlend(1 字节):结束标记 0xFF

2. 单个 entry 结构

<prevlen> <encoding> <entry-data>
  • prevlen:记录前一个 entry 的字节长度,用于向前遍历。
    • 若前一个 entry 长度 < 254 字节,prevlen 用 1 字节存储。
    • 若 >= 254 字节,prevlen 用 5 字节:首字节 0xFE,后 4 字节为真实长度。
  • encoding:表明数据类型及长度,可能直接包含小整数。
  • entry-data:实际数据。

3. 连锁更新(Cascade Update)

假设有一串连续的长度在 250~253 字节的 entry,它们后面的 prevlen 都是 1 字节。如果突然将某个 entry 修改为 254 字节以上,会导致:

  1. 本 entry 长度变长。
  2. 下一个 entry 的 prevlen 需要从 1 字节膨胀为 5 字节(因为前一个长度 ≥ 254)。
  3. 那个 entry 的整体长度也相应增加了 4 字节,可能再次突破 254 阈值,导致再下一个 entry 的 prevlen 也要膨胀,引发连锁反应。
  4. 连锁更新可能在插入、删除时触发,导致单次操作引发大量内存重分配和移动,造成性能尖刺。

这就是 ziplist 最大的设计缺陷。


二、listpack 的结构与创新

1. 内存布局

<total_bytes> <num_elements> <entry> <entry> ... <entry> <end>
  • total_bytes(4 字节):整个 listpack 的总字节数。
  • num_elements(2 字节):元素个数,超出 65535 时需遍历计数(与 ziplist 类似)。
  • end(1 字节):结束标记 0xFF

没有 zltail 字段,尾部定位通过从末尾倒序遍历实现。

2. 单个 entry 结构

<encoding> <data> <backlen>
  • encoding:变长编码,描述 data 的类型和长度。小整数直接嵌在 encoding 中,不需要额外 data 部分。
  • data:实际数据(若 encoding 内已包含则无此段)。
  • backlen:记录当前 entry 的总长度(从 encoding 开始到 data 结束,不含 backlen 自身)。用于向前遍历

backlen 的编码:从 entry 的最后一个字节开始,每个字节的最高位为延续标志

  • 1:表示该字节是 backlen 的一部分,继续向前读。
  • 0:表示这是 backlen 的最后一个字节。 剩余 7 位拼接起来构成长度值。这种设计类似 UTF-8,使得 backlen 可以用 15 字节表示 02^32-1 的长度。

3. 为什么 listpack 没有连锁更新?

listpack 的 entry 不再依赖“前一个 entry 的长度”。每个 entry 记录的是自己的长度(backlen),且 backlen 放在 entry 末尾。当修改一个 entry 时,影响的只是其自身的 backlen(可能变长或缩短),但相邻的下一个 entry 不需要修改任何元数据,因此不会引发级联效应。插入或删除操作只需调整内存并更新前一个 entry 的 backlen(但前一个 entry 的 backlen 改变只会影响自身,不会波及再前一个)。从根本上消除了连锁更新问题。


三、详细特性对比

维度ziplistlistpack
连锁更新存在,插入/删除可能引发大量连续更新不存在,任何修改只影响当前 entry
前向遍历(向表头)通过当前 entry 的 prevlen 直接跳转到前一个 entry,O(1)通过读取当前 entry 的末尾 backlen,计算出本 entry 起始,再继续向前读前一个 entry 的 backlen;也是 O(1)
后向遍历(向表尾)需要知道当前 entry 长度,沿指针往后计算同样需要计算本 entry 总长后跳过,与 ziplist 类似
内存开销每个 entry 有 1 或 5 字节 prevlen每个 entry 有 1~5 字节 backlen,平均开销相当
整数编码支持多种小整数编码(如 0~12 直接存于 encoding)也支持丰富的整数编码,且分类更细致(7 位、13 位、16 位、32 位、64 位等)
元素数量上限zllen 字段 2 字节,超 65534 需遍历num_elements 也是 2 字节,超 65535 需遍历
最大长度理论 2^32-1,受连续内存限制同 ziplist
内存布局整块连续内存整块连续内存
操作稳定性可能会有性能尖刺(连锁更新)性能平稳,无级联效应
适用场景曾广泛用于 hash、zset、list、stream现已成为 Redis 7.0+ 中 hash、zset、stream 的默认编码,quicklist 内部可选 listpack

四、在 Redis 中的使用演进

  • 早期:ziplist 几乎是所有“小数据量”场景的唯一选择(hash、zset、list 内部元素少时)。
  • Redis 5.0:引入 listpack,最初只在 Stream 类型内部的消息存储中使用(Radix Tree 的节点),作为解决 ziplist 连锁更新的试验田。
  • Redis 7.0:配置大更新,hash、zset 的默认紧凑编码从 ziplist 替换为 listpack
    旧参数 hash-max-ziplist-*hash-max-listpack-* 替代,但配置项向下兼容。
  • 列表(List):仍由 quicklist 承载,quicklist 的每个节点内可以使用 ziplist 或 listpack。通过 list-max-listpack-size 控制,且通常建议配合 list-compress-depth 使用。在 Redis 7.2 中,listpack 已成为 quicklist 节点的默认容器。
  • 未来趋势:ziplist 将在内部被完全移除,只保留 listpack 作为唯二的紧凑数据结构。

五、总结

  • ziplist:经典紧凑结构,内存效率高,但存在连锁更新这一致命缺陷,可能导致操作延迟不可预测。
  • listpack:吸取 ziplist 教训,通过“记录自身长度”+“backlen 逆序编码”消除了连锁更新,保持了同等的内存紧凑度,并提供更丰富的整数编码,操作性能更平稳。
  • 选型:如果使用 Redis 7.0 及以上版本,你已几乎不需要关心 ziplist,它会自动被 listpack 取代。若需对老版本做调优,应注意 list-max-ziplist-* 的连锁更新隐患,避免在写入频繁且元素大小接近临界值时使用过大的 ziplist。

listpack 是 ziplist 的完美进化,在内存和 CPU 稳定性上取得了更好的平衡,是 Redis 内部数据结构的又一次巧妙优化。

rehash

Redis 的 rehash 指的是其内部哈希表(dict)在容量不足或空间浪费时,动态调整大小的过程。为了避免像传统哈希表那样一次性扩容导致服务阻塞,Redis 实现了一种 渐进式 rehash 机制,这也是其高性能的关键设计之一。

下面围绕 dict 的 rehash 展开详细介绍,主要涵盖结构、触发条件、渐进过程以及对操作的影响。


1. 为什么需要 rehash?

Redis 的 dict 用链地址法解决哈希冲突。当元素越来越多、负载因子升高时,哈希冲突加剧,链表变长,查询复杂度从 O(1) 退化到 O(n)。
反之,删除大量元素后,很多桶空置,造成内存浪费。

rehash 就是重新调整哈希表的大小(扩容或缩容),将所有元素重新映射到新大小的表中,以平衡时间与空间。


2. 字典结构预备知识

每个 dict 内部维护了两个哈希表

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];      // 两个哈希表
    long rehashidx;    // rehash 进度标志,-1 表示没有在 rehash
    // ...
} dict;

typedef struct dictht {
    dictEntry **table;      // 桶数组
    unsigned long size;     // 桶个数(总是 2^n)
    unsigned long sizemask; // size - 1,用于取模
    unsigned long used;     // 当前元素数量
} dictht;
  • ht[0]:正常提供服务的表。
  • ht[1]:只有在 rehash 期间才使用,是一个新大小的空表。
  • rehashidx:当 rehashidx == -1 时,表示没在做 rehash;否则其值表示 ht[0] 中下一个将要迁移的桶索引

3. 触发 rehash 的条件

扩容

在每次添加新元素前,会检查是否需要进行扩容(_dictExpandIfNeeded):

  1. 若 ht[0].size 为 0,直接初始化为 4。
  2. 当以下任一条件满足时扩容(新大小为第一个大于等于 ht[0].used * 2 的 2^n):
    • 负载因子 ≥ 1,且 没有执行 BGSAVE 或 AOF 重写
    • 负载因子 ≥ 5,即使有后台子进程也强行扩容(避免严重冲突)。

负载因子 = ht[0].used / ht[0].size
在有子进程时提高阈值是为了减少写时复制(Copy-on-Write)带来的内存页复制开销。

缩容

serverCron 定时任务中,当字典可用空间过大时会触发缩容:

  • 条件:负载因子 < 0.1(即使用率低于 10%)。
  • 新大小:第一个大于等于 ht[0].used 的 2^n(不能小于初始大小 4)。

4. 渐进式 rehash 的详细流程

这是 Redis rehash 的精髓——分多次、分小步将 ht[0] 中的元素迁移到 ht[1]

4.1 初始化

当触发扩容/缩容时:

  1. ht[1] 分配空间:设置 size、sizemask,used=0。
  2. rehashidx 置为 0,表示即将从 ht[0] 的第 0 号桶开始迁移。

4.2 逐步迁移

迁移通过 dictRehash(dict, n) 函数进行,n 表示此次要处理的桶数量(注意:迁移时以桶为单位进行,即一次完整迁移一个桶上的整条链表)。

每次 dictRehash 的执行逻辑:

  • rehashidx 开始扫描 ht[0] 的桶数组,跳过空桶
  • 找到一个非空桶后,遍历该桶链表上的每一个 dictEntry
    • 根据 key 重新计算它在 ht[1] 中的哈希值和索引(hash & ht[1].sizemask)。
    • 使用头插法将该 entry 迁移到 ht[1] 对应桶中。
    • ht[1].used++,ht[0].used–。
  • 处理完一个桶后,该桶在 ht[0] 中置为 NULL。
  • rehashidx++,指向下一个桶。
  • 若已处理完 n 个桶,或 ht[0] 中已无元素,则停止。

4.3 迁移完成的收尾

ht[0].used == 0 时,表示所有元素都已迁移到 ht[1]:

  1. 释放 ht[0] 的桶数组(zfree)。
  2. 将 ht[1] 设置为 ht[0](指针赋值)。
  3. 重置 ht[1] 为空表。
  4. rehashidx 置为 -1,rehash 结束。

5. rehash 进行期间的操作保证

为了在 rehash 过程中对外仍表现一致,所有增删改查操作都需要兼容两个表。

操作类型具体行为
查找先在 ht[0] 中查找(索引由 hash & ht[0].sizemask 决定),如果 rehashidx != -1 且没找到,则继续在 ht[1] 中查找。
插入新键值对一律插入到 ht[1] 中,确保 ht[0] 不会再增长,rehash 能最终完成。
删除在两个表中都查找并执行删除。
更新查找后原地更新,会优先在 ht[0] 找到并修改,若该 key 已迁移则会在 ht[1] 中更新。

这些规则保证了 rehash 期间的数据一致性,并使 rehash 随着日常访问被逐渐“捎带”完成。


6. 后台辅助 rehash

如果某个字典在 rehash 期间一直未被访问(例如被冷落的大型 hash 对象),单靠请求触发迁移可能很久都无法完成。因此 Redis 还有一个定时辅助机制

  • serverCron 中调用 databasesCronincrementallyRehash
  • 它每次运行时给每个在 rehash 的字典分配 1 毫秒 的时间,调用 dictRehash 尽可能多地迁移桶。
  • 可通过配置项 activerehashing 控制开关:
    • yes(默认):启用辅助 rehash,CPU 略有开销但能及时完成 rehash。
    • no:关闭,完全依赖客户端操作触发,适合对延迟极度敏感且能容忍长 rehash 的场景。

7. 对迭代器的影响(SCAN 等)

渐进式 rehash 会给全量遍历带来挑战:如果扫描过程中发生了 rehash,如何保证不遗漏、不重复?

Redis 的 SCAN 命令(以及 HSCAN 等)通过基于高位掩码的游标来应对:

  • 游标值会随着 rehash 同时体现在 ht[0] 和 ht[1] 的对应索引位上。
  • 当 rehash 尚未结束时,迭代器会先扫描 ht[0] 的小表,再将游标扩展到 ht[1] 的大表,保证覆盖所有元素。
  • 这确保了在 rehash 期间遍历 dict 也能得到完整的快照式数据。

8. 流程示意图

初始状态: ht[0] size=4, ht[1]=empty, rehashidx=-1
   |
   v
触发扩容 → 分配 ht[1] size=8, rehashidx=0
   |
   v
每次操作/定时任务调用 dictRehash(n) 迁移若干桶
   |
   v
ht[0].used 变为 0 → 释放 ht[0] 表
   |                ht[1] 变为新 ht[0]
   v
rehashidx = -1,完成

9. 总结

  • 渐进式:将一次性大规模内存复制分摊到毫秒级的小步骤,避免 Redis 短暂夯住。
  • 双表结构:ht[0]/ht[1] 配合 rehashidx 实现平滑切换。
  • 无感操作:查找、插入、删除自动适配双表,业务无感知。
  • 定时辅助activerehashing 保证冷数据字典也能完成 rehash。
  • 内存与性能平衡:高负载自动扩容保证查询效率,低负载缩容回收多余内存。

正是这套精巧的渐进式 rehash 设计,让 Redis 在极端读写压力下依然能保持稳定的低延迟响应。

redis 事务

Redis 事务通过 MULTIEXECWATCH 等命令,能保证一组命令顺序执行且不被其他客户端请求打断。但它没有回滚——某条命令失败,其他命令仍会执行。这决定了它的适用场景。

下面是几个典型使用场景,附具体例子。


1. 需要原子性批量写入的“无关联”操作

当一组写操作必须连续完成,但彼此没有逻辑依赖(无需前一步结果),只求中间不被其他命令插入时适用。

例:社交平台发表文章 用户发帖时,需要同时完成:文章内容存 Hash、文章 ID 加入用户列表、文章 ID 加入时间线列表。

MULTI
HMSET article:1001 title "Hello" content "..." author "user:55"
LPUSH user:55:articles 1001
LPUSH timeline:global 1001
EXEC

这三个命令在事务中顺序执行,不会出现“文章存了但没加到列表”的中间状态。


2. 简单资源扣减与乐观锁控制

结合 WATCH 可实现乐观锁:操作前监视 key,如果执行前被改过,事务自动取消,客户端可重试。适合并发不高、冲突代价小的场景。

例:库存扣减(秒杀或兑换)

WATCH stock:sku123
stock = GET stock:sku123
if stock > 0:
    MULTI
    DECR stock:sku123
    # 记录扣减日志
    LPUSH log:sku123 "user:123 扣减成功"
    EXEC

EXEC 返回 nil(表明 stock:sku123 在 WATCH 后被他人修改),客户端重试即可。这避免了超卖。

例:非金融级转账(如虚拟金币) A 给 B 转 100 金币,无需强一致性回滚,可重试:

WATCH user:A:coins user:B:coins
a_coins = GET user:A:coins
if a_coins >= 100:
    MULTI
    DECRBY user:A:coins 100
    INCRBY user:B:coins 100
    EXEC

失败重试即可。


3. 获取多个 key 的瞬时一致快照

多个 GET 放在事务里,能在不中断其他写操作的情况下,读到同一时间点的值(隔离性)。

例:读取用户积分与等级

MULTI
GET user:55:points
GET user:55:level
EXEC

这两个值在读取期间,不会被其他 SET 插在中间修改积分而没改等级,保证逻辑上的一致性。


4. 简单状态流转

适用于状态机转换,且转换过程无需根据中间结果做复杂判断。

例:订单状态从“待支付”→“已支付”

WATCH order:1000
status = HGET order:1000 status
if status == "待支付":
    MULTI
    HSET order:1000 status "已支付"
    HSET order:1000 pay_time "2026-05-19 10:00:00"
    EXEC

WATCH 保证状态只从“待支付”变为“已支付”,防止并发重复支付。


5. 批量命令替代 Pipeline(需原子性时)

Pipeline 用于减少 RTT,不保证原子性;事务在打包命令的同时提供原子性。当批量操作必须连续无穿插时,就用事务代替管道。


6. 什么时候不建议用事务?

  • 需要回滚的场景(如银行账户转账,涉及严格资金一致性)。
  • 逻辑复杂、分支多,建议使用 Lua 脚本(可包含判断、循环,且原生原子性,比事务更灵活)。
  • 需要跨多个数据库节点(集群环境),事务难以保证。

小结:Redis 事务适用于简单原子批处理、带乐观锁的资源竞争、瞬时快照读取等场景。随着 Lua 脚本的普及,很多复杂原子操作已迁移到脚本,但事务在轻量级、无复杂逻辑的场合下依旧简洁有效。

RedisObject

RedisObject 是 Redis 中所有键和值的统一封装结构。它像是一个“通用容器”,使得 Redis 可以用同一套接口处理字符串、列表、集合、有序集合、哈希、流等完全不同的数据类型,并能在运行时动态切换底层编码以平衡内存与性能。

下面从结构定义、字段详解、类型与编码、内存管理及对象共享等方面详细介绍。


一、为什么需要 RedisObject?

Redis 是一个键值数据库,键总是字符串,但值可以是多种数据类型。如果每种类型都独立实现,命令处理、内存回收、淘汰策略等逻辑会高度耦合且重复。RedisObject 的设计目的:

  1. 多态:通过 typeencoding 字段,为不同数据类型和底层实现提供统一的操作接口。
  2. 内存管理:通过 refcount 实现引用计数,自动回收内存,并支持对象共享。
  3. 淘汰支持:通过 lru 字段记录对象的访问信息,实现 LRU / LFU 缓存淘汰。
  4. 灵活性:同一个类型可以根据数据大小、元素数量自动切换底层编码(如 ziplist → listpack → hashtable),对用户完全透明。

二、redisObject 结构定义

在 Redis 源码 server.h 中定义如下(简化并注释):

typedef struct redisObject {
    unsigned type:4;       // 数据类型(4 位)
    unsigned encoding:4;   // 底层编码(4 位)
    unsigned lru:LRU_BITS; // 淘汰信息(24 位,LRU 时钟或 LFU 计数)
    int refcount;          // 引用计数(32 位)
    void *ptr;             // 指向实际数据结构的指针
} robj;

总大小为 16 字节(64 位系统下,含 4 字节对齐填充)。结构非常紧凑,目的是减少每个键值对的内存开销。


三、字段详解

1. type(数据类型,4 位)

标明该对象所代表的值类型。取值对应常量:

常量含义
OBJ_STRING字符串
OBJ_LIST列表
OBJ_SET集合
OBJ_ZSET有序集合
OBJ_HASH哈希
OBJ_STREAM
OBJ_MODULE模块类型

键始终是字符串对象(OBJ_STRING),值的类型由这 4 位决定。
客户端执行命令时,Redis 会根据 key 对应 value 的 type 决定是否允许操作(例如 LPUSH 只能用于 OBJ_LIST)。


2. encoding(底层编码,4 位)

指示 ptr 指针指向的具体数据结构的实现方式。同一个 type 可能对应多种 encoding,Redis 会根据数据特征在运行时自动转换。

常见的编码常量(部分):

encoding 常量底层实现适用 type
OBJ_ENCODING_RAW简单动态字符串(SDS)STRING
OBJ_ENCODING_INT整数值直接存于 ptr(伪造指针)STRING
OBJ_ENCODING_EMBSTR内嵌的 SDS(≤44 字节)STRING
OBJ_ENCODING_QUICKLISTquicklist(3.2+ 列表)LIST
OBJ_ENCODING_LISTPACKlistpackLIST / HASH / ZSET
OBJ_ENCODING_HT字典(dict)SET / HASH / ZSET
OBJ_ENCODING_INTSET整数集合SET
OBJ_ENCODING_SKIPLIST跳跃表 + 字典ZSET
OBJ_ENCODING_STREAMRadix Tree + listpackSTREAM

编码转换示例

  • 一个集合:元素全是整数且数量少 → INTSET;插入非整数或数量超标 → 自动转为 HT(字典)。
  • 一个哈希:字段少、值短 → LISTPACK;超过阈值 → HT

这种“一种类型,多种实现”是 Redis 内存高效的关键。


3. refcount(引用计数,32 位)

用于内存共享和垃圾回收。原理:

  • 创建对象时 refcount = 1
  • 当对象被其他位置引用时(如作为键、被共享),refcount 增加。
  • 不再使用时 refcount 减少,当降为 0 时自动释放内存。
  • 对象共享主要应用在小整数(0~9999),这些整数对象在 Redis 启动时预分配并共享,减少内存分配和重复对象创建。
    也用于部分场景下字符串对象的临时共享(但 Redis 对字符串共享比较谨慎,以节省 CPU 比较成本)。

引用计数使得 Redis 无需垃圾回收器(GC),内存释放实时、可预测。


4. lru(24 位)

该字段在两种淘汰策略下含义不同,由 maxmemory-policy 配置决定:

  • LRU(最近最少使用)模式
    存储一个 24 位的时钟值server.lruclock 的分辨率近似秒级),记录对象上次被访问的时间戳。淘汰时选择时钟值最旧的对象。

  • LFU(最不经常使用)模式
    这 24 位被拆分为:

    • 高 16 位:最后一次衰减时间(分钟级),用于衰减频率。
    • 低 8 位:访问频率计数器(0~255),每次访问时根据概率递增。 淘汰时选择频率计数最低的对象。

该字段的存在让每个键值对都可以参与淘汰,而无需额外维护链表或堆。


5. ptr(指向实际数据的指针)

指向实际的底层实现结构,例如:

  • 指向 SDS 字符串(OBJ_ENCODING_RAW)。
  • 指向 quicklist(OBJ_ENCODING_QUICKLIST)。
  • 指向 dict(OBJ_ENCODING_HT)。

特殊用法:当 encodingOBJ_ENCODING_INT 时,整数值本身直接存储在 ptr 字段中(将 void* 强制转换为 long),不额外分配内存。Redis 能直接通过类型转换获取整数值,非常高效。


四、对象创建与共享

Redis 内部通过专门的函数创建各种类型对象,例如:

  • createStringObject():创建字符串对象(根据长度选择 EMBSTRRAW)。
  • createIntsetObject():创建整数集合对象。
  • createQuicklistObject():创建列表对象。

小整数共享:Redis 启动时会创建 0 到 9999 共 10000 个字符串表示的整数对象,放在 shared.integers 数组中。当需要用到这些整数(如 SET counter 0)时,直接将其引用计数 +1 并返回,避免重复分配内存。不过由于浮点数、带前缀的字符串等并不共享,且共享对象在多线程环境下会引入复杂性,Redis 7.0 以前在 IO 线程化后就限制了一些共享行为。


五、RedisObject 在命令处理中的角色

SET key value 为例:

  1. keyvalue 分别封装为 redisObject(字符串类型)。
  2. 将键值对存入全局 dict,键是 SDS 字符串,值是 redisObject
  3. 当执行 GET key 时,根据 key 找到 redisObject,检查 type == OBJ_STRING,然后将 ptr 指向的内容返回。

对于容器类型如 HSET myhash field1 hello,值对象是一个 OBJ_HASH 类型的 redisObject,其 ptr 指向一个 dictlistpack,内部再存储 field-value 对。


六、内存布局示意图

键值对存储结构
  dictEntry {
      key -> SDS "username"
      value -> redisObject {
          type    = OBJ_STRING
          encoding= OBJ_ENCODING_EMBSTR
          lru     = 123456
          refcount= 1
          ptr     -> embstr SDS "Alice"
      }
  }

对于列表:

value -> redisObject {
    type    = OBJ_LIST
    encoding= OBJ_ENCODING_QUICKLIST
    lru     = ...
    refcount= 1
    ptr     -> quicklist {
        head -> node { zl/listpack } <-> node <-> ...
        tail
    }
}

七、总结

  • RedisObject 是 Redis 的数据基石,用极小的 16 字节实现了多态、内存管理、淘汰支持三大核心功能。
  • 通过 typeencoding 解耦了接口与实现,使 Redis 能够灵活地在多种底层数据结构间切换。
  • 引用计数实现了简单高效的内存回收,整数共享进一步节约了内存。
  • lru 字段在紧凑的 24 位中兼容了 LRU 和 LFU 淘汰算法,支撑了 Redis 作为缓存的核心能力。

没有 RedisObject,Redis 的命令分发、内存优化和类型透明化将变得异常复杂。正是这个统一的“信封”设计,让 Redis 在性能、内存效率和代码可维护性之间取得了绝佳的平衡。

AOF

AOF(Append Only File)日志放在主线程执行,主要是遵循 Redis “先执行命令,后记录日志”的核心设计。这绝非权宜之计,而是在数据安全性性能开销架构简洁性之间精心权衡后的最优解。

📝 定海神针:为什么必须是“先执行,后记录”?

这种“写后日志”的方式是 AOF 在主线程执行的基石,它带来了两个关键好处:

  • 保证日志中都是“正确”的指令:Redis只在命令成功执行后,才将其写入AOF缓冲区。这确保了AOF文件中的每一条命令都是语法正确、操作合法的。如果是先写日志,一旦命令执行失败(如类型错误),日志中就会记录下无效命令,在系统恢复重放时就会报错,导致数据恢复失败。

  • 避免对当前命令的阻塞:Redis记录日志的行为发生在命令执行之后,这意味着写入AOF这个潜在耗时的I/O操作,不会延迟对当前客户端请求的响应。如果反过来,每次请求都需等待日志写完才能返回,性能将不堪设想。

🤔 平衡之道:主线程执行的代价与应对

在主线程执行AOF确实存在风险:如果日志写入磁盘的速度很慢,后续的命令请求就会因为主线程被阻塞而无法处理。为此,Redis提供了优雅的解决方案来规避风险。

1. 写回策略:灵活控制“落盘”时机

Redis通过 appendfsync 策略,让你可以自由选择在主线程执行AOF时,何时真正将数据写入磁盘:

  • always (同步写回):每个写命令都立即同步到磁盘。
    • 优点:数据安全性最高,几乎不丢数据。
    • 缺点:对主线程和磁盘I/O性能影响最大。
  • everysec (每秒写回,默认配置):命令先写入缓冲区,再由后台线程每秒同步到磁盘。
    • 优点:性能和数据安全性的最佳平衡,性能影响很小,最多丢失1秒数据。
  • no (操作系统控制):命令只写入缓冲区,由操作系统决定何时同步。
    • 优点:对主线程性能影响最小。
    • 缺点:数据安全性最不可控,宕机时可能丢失较多数据。

2. 后台线程与子进程:为“重活”另辟蹊径

Redis巧妙地利用后台线程(BIO)和子进程,将繁重的AOF相关I/O操作移出主线程,进一步提升性能:

  • 利用后台线程异步刷盘:在appendfsync everysec模式下,主线程只需快速地将命令写入AOF缓冲区,真正耗时的fsync操作,实际上是由专门的后台线程(BIO)异步完成的。这相当于主线程只处理了核心的记录工作,将“体力活”交给了辅助线程。

  • 利用子进程进行AOF重写:随着命令增多,AOF文件会变大。为了“瘦身”,Redis引入了AOF重写。这是一个高成本操作,Redis会fork一个子进程来执行。重写过程中,主线程会维护两份AOF缓冲区:一份用于保障原AOF文件,另一份通过管道pipe发送给子进程,确保新旧文件数据一致。

唯一的开销是,在主线程创建子进程(fork操作)的瞬间会产生短暂阻塞,但阻塞时间通常极短。

💎 总结:高效架构的经典设计

AOF机制充分体现了Redis简单、高效、可靠的设计哲学:

  • 放在主线程的“轻量”部分:是将命令记录到内存缓冲区,这个操作极快,且能保证日志的准确性和执行顺序。
  • 移出主线程的“重量”部分:是将数据真正写入磁盘、对文件进行压缩重写等繁重I/O操作,交给后台线程和子进程处理。

这套设计让Redis在保证数据可靠性的同时,最大限度地降低了持久化对核心服务性能的影响。

AOF COW

AOF 重写是 Redis 持久化机制中的重要一环。它通过创建一个子进程来生成一份新的、紧凑的 AOF 文件,从而压缩历史命令。而支撑这一过程的核心技术,正是操作系统的 写时复制(Copy-on-Write, COW)。它让 Redis 可以在不阻塞主线程的情况下,获得一份可用于重放的“数据快照”。

下面从原理、过程、内存影响及一致性保证等角度详细展开。


一、AOF 重写为什么需要“快照”?

重写的目的不是回放旧的 AOF 日志,而是直接读取当前内存中的全量数据,生成等价的写入命令。例如,对于某个 Key 被反复修改的历史,重写后的新 AOF 只保留最终状态的那条 SET 命令。

要安全地读取全量数据,最理想的方式是拥有一个某个时间点的、不可变的静态快照。如果由主线程直接遍历数据库,不仅会长时间阻塞用户请求,而且这期间发生的新写入也会让重写结果不一致。

于是,Redis 利用 fork 系统调用创建子进程,并借助 写时复制 获得这个“瞬间快照”。


二、fork 与写时复制的基本原理

fork() 创建一个与父进程完全相同的子进程,包括代码、数据、堆栈和内存映射。但在现代操作系统中,它不会立即复制所有物理内存,而是使用写时复制:

  1. 共享只读页
    内核将父进程的所有物理内存页标记为写保护(只读)。父进程和子进程的页表都指向这些相同的物理页框。此时没有任何真正的内存复制发生,fork 执行极快。

  2. 写入触发复制
    当父进程(或子进程)试图修改某个只读页时,CPU 会触发缺页中断(Page Fault)。内核捕获到该中断后:

    • 分配一个新的物理页。
    • 原页的数据完整拷贝到新页。
    • 将触发写入的进程的页表项更新为指向新页,并恢复该页的写权限。
    • 另一个进程的页表项仍指向旧页(依然只读),完全不受影响。

这样,谁修改谁复制,没修改的继续共享,实现了内存的高效利用。子进程“看到”的,正是 fork 那一刻父进程内存的“冻结”快照。


三、AOF 重写过程中的写时复制

AOF 重写的完整过程可分为三个阶段,COW 贯穿其中:

1. fork 创建子进程

当 Redis 触发 AOF 重写时,主线程调用 fork()。由于写时复制的存在,这个操作通常非常快(毫秒级),即使 Redis 占用了数 GB 内存。唯一的阻塞就是 fork 调用本身(内核复制页表),内存越大,页表越大,该操作耗时越长(通常要求不超过 1ms/G,否则会阻塞客户端)。之后,父子进程并行运行。

2. 子进程生成新 AOF,父进程继续服务

  • 子进程:拥有 fork 那一刻的完整内存快照。它遍历所有数据库,将每个 key-value 对象序列化成对应的写入命令,写入新的 AOF 文件。该过程完全只读,不会触发 COW。
  • 父进程(主线程):继续处理客户端请求,执行写入命令。当父进程修改某个数据对象时(如改变一个 Key 的值或调整内部数据结构),就会触发 COW:
    • 该对象所在的内存页被复制一份。
    • 父进程在新页上进行修改。
    • 子进程仍然持有旧页数据,因此它看到的始终是 fork 瞬间的状态,保证了快照的一致性

3. 重写缓冲区补全差异

子进程基于的是旧快照,它生成的新 AOF 文件会缺失 fork 之后父进程积累的新写入。为了解决这个问题,Redis 主线程在 fork 后开启一个 AOF 重写缓冲区,将所有新写入的命令同时追加到:

  • 现有的旧 AOF 缓冲区(保证正常持久化)。
  • AOF 重写缓冲区(用于追平差异)。

子进程完成重写后,父进程会将 AOF 重写缓冲区的内容追加到新 AOF 文件末尾,然后原子地用新文件替换旧文件。至此,重写完成,新 AOF 文件既包含了重写时刻的快照数据,也包含了 fork 至今的所有变更,数据完整且最新。


四、写时复制的内存影响与风险

COW 极大节约了内存,但仍潜藏着内存膨胀的风险:

  • 最坏情况:如果在子进程重写期间,父进程的写入操作覆盖了大量不同的内存页,那么这些页都会逐一被复制。极端时,父进程占用的内存可能接近重写前的 2 倍(原共享页 + 复制后的新页)。如果系统物理内存不足,可能触发 OOM Killer 或导致 Redis 被杀死。
  • 影响因素
    • 写入量:写入的 Key 越多,写入越分散,触发的 COW 页就越多。
    • 数据结构和编码:小对象的修改(如 ziplistlistpack)可能导致整个连续内存页都被复制;指针结构(如 skiplist)的修改可能只影响几个页。
    • 内存页大小:通常为 4KB,一个页可能包含多个 Key,修改其中一个 Key 可能导致整个页被复制,放大内存开销。

应对措施

  • Redis 建议为系统预留 最大内存的 1.5 ~ 2 倍 物理内存,以吸收 COW 带来的额外内存消耗。
  • 配置 vm.overcommit_memory = 1,允许内核更激进地分配内存,避免 fork 时因 overcommit 策略导致失败。但仍需保证物理内存足够。
  • 控制 AOF 重写的触发时机,避免在写入高峰期频繁重写(通过 auto-aof-rewrite-percentageauto-aof-rewrite-min-size 调优)。

五、与 RDB 的写时复制对比

RDB 持久化也使用 fork + COW 来生成快照,原理几乎相同。主要区别在于:

  • 输出:RDB 生成二进制快照文件,AOF 重写生成的是 AOF 命令。
  • 差异补齐:RDB 只需要 fork 时的快照,完全不需要关心 fork 后的新写入(除非用户混合持久化,会搭配 AOF)。而 AOF 重写必须通过重写缓冲区补齐后续命令。

因此,COW 在两者中的角色完全一致:都是为子进程提供一份无锁、一致的内存快照,同时让父进程可以无阻塞地继续修改数据


六、总结

AOF 重写的写时复制机制,是 Redis 能够在不停止服务、不阻塞写入的前提下,安全地重新生成一份紧凑日志的基石。它用极小的 fork 延迟换取了子进程拥有一份静态数据快照,并通过重写缓冲区保证了最终的数据一致性。

理解 COW 的关键在于:

  • 共享内存,按需复制,让 fork 既快又省内存。
  • 子进程读旧页,父进程写新页,自然形成快照隔离。
  • 内存开销可控但需预留,避免写入风暴导致内存耗尽。

这也是为什么 Redis 的主线程模型可以如此简洁高效——它将耗时的持久化 I/O 和计算交给了子进程,而自己只需承担因写入触发的少量内存复制开销,堪称操作系统写时复制机制的经典应用。

数据一致性

保证缓存(如 Redis)和数据库(如 MySQL)的一致性,是后端开发中的经典难题。由于这两个系统独立运行,任何跨系统的操作都无法天然实现 ACID 事务,所以追求强一致性往往代价高昂,通常我们会根据业务场景选择合适的一致性策略,在性能和数据准确性之间做权衡

下面详细梳理不同策略及其优劣。


一、核心指导思想:更新数据库还是删除缓存?

当数据发生变更时,有两种基本选择:

策略操作优点缺点
更新缓存直接更新缓存中的值缓存命中率高,避免了一次 Cache Miss1. 写多读少场景下大量无效更新,浪费资源。
2. 并发更新时,两个写操作的顺序难以控制,容易造成缓存和数据库值永久不一致。
删除缓存删除缓存条目,等待下次读取时再从数据库加载1. 操作简单,避免了并发写引起的顺序问题。
2. 懒加载思想,只在实际被读时才填充缓存,节省空间和写入成本。
首次读会 Miss,有轻微延迟,但通常可忽略。

业界共识:绝大多数场景下,删除缓存是更简单、更安全的选择。 后续的讨论都基于“失效缓存”的思路。


二、Cache-Aside 模式(旁路缓存)——最经典的方案

应用直接管理缓存和数据库,逻辑如下:

  • 读请求:先查缓存,命中则直接返回;未命中则查数据库,将结果写入缓存,再返回。
  • 写请求先更新数据库,再删除缓存

为什么是“先更新数据库,后删除缓存”?

看反例(先删缓存,后更新数据库):

  1. 线程 A 删除缓存。
  2. 线程 B 读请求 Cache Miss,去读数据库,得到旧值
  3. 线程 B 将旧值写入缓存。
  4. 线程 A 更新数据库为新值。 结果:缓存中永远存着旧值,直到下一次失效(可能是很久以后),出现数据不一致。

先更新数据库,后删除缓存的异常窗口极小:

  1. 缓存刚好过期或不存在。
  2. 线程 A 读请求查数据库,得到旧值(此时数据库还未更新)。
  3. 线程 B 更新数据库为新值,并删除缓存。
  4. 线程 A 将旧值写入缓存。 结果:缓存再次被旧值污染。但该场景需要缓存刚好失效 + 读写严格串行,在实际高并发下发生概率极低。

结论:Cache-Aside 以“先更新 DB,后删缓存”为主流。

如何进一步降低 Cache-Aside 的不一致风险?

  • 延时双删:写操作时,先删除缓存,再更新数据库,然后休眠几百毫秒,再删除一次缓存
    目的:试图覆盖“先删缓后,其他线程可能又将旧值写回”的脏窗口。但休眠会降低写请求吞吐量,只能作为补充手段。

  • 设置合理的过期时间这是最简单、最有效的兜底策略。无论前期出现何种不一致,最终都会在 TTL 到期后自动修复。


三、Read/Write Through(读写穿透)——缓存服务接管

由缓存代理层(如 Redis 客户端封装,或一些中间件)接管数据库逻辑:

  • Read Through:缓存层未命中时,由缓存层去数据库加载,应用只与缓存交互。
  • Write Through:写入时直接写缓存层,缓存层同步更新数据库。

优点:应用逻辑简单,能保证较强的同步性。
缺点:实现复杂,且 Write Through 需要保证缓存和数据库写入的事务性,对大多数场景过重。在 Redis 生态中,原生的 Read/Write Through 需要借助自定义插件或如 Apache Ignite 等数据网格产品。


四、Write Behind(写回)——异步写库,追求极致性能

写入时,只更新缓存,然后以异步的方式(例如每隔一段时间或定量)批量写回数据库。

  • 优点:写性能极高,极其适合高并发写场景。
  • 缺点:数据强一致性无法保证,缓存宕机可能丢失大量尚未回写的更新。

这类方案适用于可容忍少量数据丢失的计数、统计类场景(如视频播放量、点赞数),通常配合 Redis 的 AOF/RDB 持久化做兜底。


五、基于 binlog 的最终一致性(异步订阅)

这是大型互联网公司广泛使用的终极方案,能最大程度降低耦合,并保证最终一致性。

架构原理

  1. 应用只负责直接操作数据库(如 MySQL)。
  2. 通过监听数据库 binlog(如使用 Canal、Debezium 等中间件),抽取所有数据变更事件。
  3. 将这些事件经过清洗、转换后,推送至消息队列(如 Kafka)。
  4. 专门的消费服务订阅消息,异步更新或删除 Redis 缓存

优势

  • 业务代码零侵入:应用层完全不需要关心缓存逻辑。
  • 有序可靠:binlog 天然保证了变更的顺序性。
  • 最终一致:虽然有一定延迟(毫秒至秒级),但能保证缓存最终与数据库同步。
  • 健壮:消息队列可重试,即使缓存更新失败也能不断补偿。

挑战:架构复杂度显著上升,需额外维护 Canal、MQ 等中间件,适用于微服务、数据一致性要求高且读写 QPS 极大的核心链路。


六、策略对比与选择建议

策略一致性程度实现复杂度性能适用场景
Cache-Aside + TTL最终一致(极低概率短暂不一致)绝大多数业务场景,通用首选
延时双删同上,进一步降低脏写概率对一致性容忍度更低,但可接受轻微写延迟
Write Through较强同步中高需要严格一致性的非高并发场景
Write Behind弱一致性,可能丢数据极高统计、计数等非关键数据
Binlog 订阅最终一致,稳定可靠核心链路、微服务架构、对一致性要求高且并发量极大

七、通用最佳实践

对于 90% 的业务,遵循以下黄金法则即可:

  1. :Cache-Aside 模式,先读缓存,未命中则读库并回填。
  2. 先更新数据库,成功后立即删除缓存(无需更新缓存内容)。
  3. 兜底所有缓存必须设置过期时间,这是最后的保障。
  4. 重试:若删除缓存失败,必须引入重试机制(例如发送 MQ 消息异步重试删除),否则缓存将永远留存脏数据。
  5. 降级:在极端压力下,可接受短暂不一致,通过 TTL 自然恢复,避免重试风暴。

总结:没有一劳永逸的强一致性银弹。设计缓存一致性方案的本质是——承认分布式的不完美,通过冗余(TTL、重试)和架构优化(binlog 监听),在业务可接受的窗口内完成数据同步。

Licensed under CC BY-NC-SA 4.0
Last updated on Jun 09, 2026 11:16 CST
comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy