从 Redis 到 Dragonfly

把 Dragonfly 说成「多线程版 Redis」,就像把 PostgreSQL 说成「支持 SQL 的文件系统」:不能算完全错误,但几乎解释不了任何工程现象。

Redis 的核心优势并不只是快,而是把复杂的数据结构、事务语义和网络并发压进一条可预测的串行执行流。十几年里,大量客户端、运维工具、故障处理流程和业务假设都围绕这条执行流生长。Dragonfly 则从另一个前提出发:现代云服务器有几十个 CPU 核、数百 GB 内存和高速网卡,如果命令最终仍在一个主线程上排队,那么纵向扩容买来的大部分计算资源没有进入数据路径。

于是两者真正的分歧不是 C 与 C++,也不是 epoll 与 io_uring,而是:系统应该通过全局串行化换取简单性,还是通过分片所有权、协作式调度和显式事务协调换取多核并行? 前者把并发问题推给 Cluster,后者把分布式系统里常见的协调问题搬进单个进程。

先给出结论地图:

维度Redis / Valkey 的核心取舍Dragonfly 的核心取舍对生产的直接影响
命令执行单主线程串行执行,外围任务可多线程每核一个执行线程,键空间在线程间分片Dragonfly 能纵向扩展,跨 Shard 命令有协调成本
索引链式 dict,两张表渐进 Rehash分段开放寻址 DashtableDragonfly 减少指针追逐与元数据,但实现更复杂
一致性串行执行天然给出原子性VLL 风格意向锁、事务队列与多 Hop常见单 Key 快,跨 Shard 仍需排序和等待
快照fork() 后由子进程写 RDB,依赖 COWFiber 遍历与写前复制旧值,不 fork()Dragonfly 避免页级 COW 峰值,写路径多一条快照 Hook
持久化RDB、AOF、混合模式成熟支持快照,当前不支持 AOF需要低 RPO 时不能只看吞吐量
淘汰多种近似 LRU/LFU/TTL 策略cache_mode 下使用 2Q 思想的 LFRUDragonfly 更抗扫描污染,但策略可配置性较弱
横向集群16384 Slot、Gossip、故障转移与迁槽兼容数据面;开源版控制面需外置少节点很省事,多节点不一定比 Redis 更省事
生态客户端、Module、托管服务和故障经验深RESP 客户端兼容,部分 JSON/Search/Bloom 原生实现「能连接」不等于「命令、边界和运维完全等价」

一、线程模型与并发控制:串行化问题,还是协调问题

1.1 Redis 的 Reactor:一个线程为什么能服务许多连接

Redis 的网络核心建立在 aeEventLoop 上。ae.c 在编译时选择 epoll、kqueue、event ports 或 select,事件循环同时管理文件事件和时间事件。套接字被设为非阻塞,某个连接暂时读不到数据时,主线程不会睡在那里,而是回到多路复用器处理其他就绪连接。Redis 8.2 的 ae.c仍保留这套结构。

一次简化后的循环可以写成:

text
┌──────────────────────────── aeEventLoop ────────────────────────────┐
│ 1. 计算最近 timer 的超时时间                                        │
│ 2. epoll_wait / kevent 等待就绪 fd                                  │
│ 3. 读取请求 → RESP 解码 → 查找命令 → 执行命令 → 生成回复             │
│ 4. 将可写连接的回复刷入 socket                                      │
│ 5. 执行过期扫描、统计、持久化协作等时间事件                          │
└─────────────────────────────────────────────────────────────────────┘
                         ↑ 下一轮仍由同一主线程推进

「单线程」在这里容易产生两个误解。第一,Redis 进程从来不等于只有一个 OS 线程。后台释放对象、AOF fsync、异步关闭文件以及持久化子进程都可能在主执行流之外工作。第二,事件循环能并发管理连接,却不能让两条普通命令同时修改键空间。异步 I/O 解决的是等待问题,并行执行解决的是计算资源问题,两者不是一回事。

Redis 的聪明之处,是让命令执行天然形成一个全序。假设客户端 A 执行 INCR counter,客户端 B 同时执行 GET counter,主线程总能先完整执行其中一条,再执行另一条;字典、过期索引和对象编码不需要在每个访问点加互斥锁。MULTI/EXEC 和 Lua 脚本也可以在这条串行流上获得「执行期间不插入其他客户端命令」的原子性。Redis 事务文档明确说明,事务内命令按序执行,其他客户端不会插入其中。

它的代价同样直接:一条慢命令就是所有客户端共同的队头阻塞。 SORT 处理巨大集合、Lua 脚本循环几千万次、DEL 同步释放大对象,甚至一个需要扫描大量 Bucket 的操作,都会占住主线程。非阻塞套接字无法拯救被业务计算占住的事件循环。

1.2 Redis 6 的 I/O Threads 到底并行了什么

Redis 6 引入 Threaded I/O,主要目的不是改造数据结构并发,而是把网络收发的 CPU 开销分摊出去。启用 io-threads 后,主线程仍负责事件循环组织、客户端状态和核心命令执行;I/O 线程可并行写回复,打开 io-threads-do-reads yes 后也可参与读取和输入处理。RESP 解析在具体版本与路径上有所演进,不能把所有版本概括成「I/O 线程只做 read()/write(),绝不解析协议」;但最稳定的边界是:普通命令对键空间的语义执行仍由主线程串行完成。 Redis 官方的延迟诊断文档也仍把服务请求描述为 mostly single-threaded。

text
             I/O Thread 1 ─┐
socket read / reply write ──┼──> Main Thread:命令调度与键空间修改
             I/O Thread 2 ─┤          │
             I/O Thread 3 ─┘          ├── dict / expires / streams / ...
                                      └── 生成回复,再交给 I/O 阶段

因此 I/O Threads 最适合下列场景:请求或回复较大、连接很多、TLS/协议处理明显吃 CPU,而命令本身很轻。若 INFO commandstats、SLOWLOG 和 CPU Profile 已表明瓶颈在复杂命令、热点 Lua 或大集合遍历,增加 I/O 线程不会让核心执行并行起来,甚至可能抢占主线程所在 CPU 的资源。

这也解释了 Redis 的纵向扩展曲线:提升单核频率、缓存和内存带宽通常有效;增加核心数只会帮助网络、后台工作及在同机部署多个 Redis 实例。要让一个逻辑数据集吃满 64 核,常见方案是 Redis Cluster 把 Key 分散到多个进程或节点。

1.3 Redis Cluster:把一个进程内的并发问题变成分布式问题

Redis Cluster 将 Key 映射到 16384 个 Hash Slot:

text
slot = CRC16(key) mod 16384

Node A:     0 ───── 5460
Node B:  5461 ───── 10922
Node C: 10923 ───── 16383

Hash Tag 允许 {tenant:42}:profile 与 {tenant:42}:orders 落在同一个 Slot。跨 Slot 的多 Key 命令通常会返回 CROSSSLOT,客户端必须重新设计 Key、拆分操作,或者将协调提升到应用层。迁槽期间还要处理 MOVED 与 ASK 重定向;节点之间通过 Cluster Bus 和 Gossip 传播拓扑、故障与配置 Epoch。Redis Cluster 规范给出了 16384 Slot、重定向、故障转移与配置传播的完整边界。

Cluster 获得了横向吞吐和故障域隔离,却带来新的账单:

  • 客户端必须理解拓扑与重定向,连接池从一组连接变成每节点一组连接;
  • 容量规划要同时考虑主从、副本延迟、热点 Slot 和迁移余量;
  • 多 Key 原子性从「默认存在」变成「只有同 Slot 才容易保留」;
  • 扩缩容需要搬迁键,搬迁本身消耗网络、CPU 和内存;
  • 一个热点 Key 不会因为 Cluster 自动拆开,它仍然只由一个主节点执行。

所以「Redis 不支持多核」并不是故事的全部。更准确地说,Redis 选择以进程/节点为并行单元,把单实例的简单执行模型保留下来,再用 Cluster 承担扩展。

1.4 Dragonfly:每个 Shard 线程拥有自己的数据

Dragonfly 使用单进程、多线程的 Shared-Nothing 架构。键空间按 Hash 分到若干 Shard,每个 Shard 只由它的 Owner Thread 访问;线程之间不共享可任意读写的字典,而通过消息传递提交工作。连接建立后也会绑定到某个线程中的 Fiber。Dragonfly 的架构文档明确描述了「每个数据库 Shard 由单个线程拥有」以及连接 Fiber 作为协调者的模型。

text
Client connections
       │
       ├── Connection Fiber @ Thread 0 ─┬─ hash(k1) ─> Shard 1 @ Thread 1
       │                                ├─ hash(k2) ─> Shard 2 @ Thread 2
       └── Connection Fiber @ Thread 3 ─┴─ hash(k3) ─> Shard 3 @ Thread 3

每个 Owner Thread 内:
  Proactor / I/O poller + Fiber Scheduler + 本地 Dashtable + 本地事务队列

单 Key SET 的理想路径很短:连接 Fiber 解析出 Key,计算目标 Shard,把闭包投递给 Owner Thread;Owner Thread 在自己的字典上执行,不需要 pthread_mutex 保护这个 Key;结果再返回给连接 Fiber。若协调 Fiber 恰好位于目标 Shard 所在线程,满足不可挂起等条件时还可以 Inline Execution,省掉一次队列投递。

这里所谓「无锁」需要严格限定。Dragonfly 不是整个进程没有原子变量、队列或锁,而是 Shard 内的数据结构访问不依靠所有线程共同争抢一把全局互斥锁。当前事务模型仍使用全局原子序列、意向锁表、事务队列和 Barrier;全局命令还会取得 Shard 级锁。把它宣传成「Lock-free Database」很容易让读者误以为所有操作都满足计算机科学中严格的 Lock-Free Progress Guarantee,这并不准确。

1.5 Fiber 与 io_uring:为什么线程等待时仍能做别的事

Dragonfly 的 Helio 运行时把 Boost.Fibers 调度器与 I/O Poller 集成。在 Linux 新内核上可以使用 io_uring,并保留 epoll 等后端。Fiber 是用户态协作式调度单元:某个连接等待网络、快照 Fiber 等待写盘、跨 Shard 协调者等待 Barrier 时,可以让出执行权,同一个 OS 线程继续跑其他 Fiber。

text
Thread 7 时间线

Fiber A: parse → dispatch shard 3 ── wait ─────────────── resume → reply
Fiber B:                    snapshot serialize → await I/O
Fiber C:                              GET local shard → reply
OS Thread:  A A A B B B C C C scheduler/poll B ... A

Fiber 不是魔法。若代码调用阻塞式 write()、长时间不 Yield 的 CPU 循环或普通 pthread_mutex_lock,整个 Owner Thread 仍可能卡住;Dragonfly 的架构文档也特别强调,异步性要求从底层 I/O 到同步原语都支持 Fiber。io_uring 减少系统调用和完成通知开销,但高吞吐的根因是「每线程拥有数据 + 多线程真正执行命令 + 非阻塞调度」的组合,不能把所有功劳归给一个 Linux API。

1.6 VLL:跨 Shard 原子性不是免费午餐

MSET a 1 b 2 若 a 与 b 在不同 Shard 上,就不能让两个线程随意各写一次:其中一个写完而另一个尚未写时,第三个客户端可能看到中间态。Dragonfly 的事务框架参考 Very Lightweight Locking(VLL)论文,并针对 Shared-Nothing/Fiber 做了改造。当前事务设计文档给出的目标是多 Key、多命令操作的原子性与严格可串行化。

一笔事务分成调度与执行两阶段:

text
Coordinator                      Shard A                    Shard B
    │                               │                          │
    ├─ allocate TxId ───────────────┼──────────────────────────┤
    ├─ Schedule(keys A) ───────────>│ intent lock + tx-queue   │
    ├─ Schedule(keys B) ──────────────────────────────────────>│
    │<────────────── all scheduled / retry if order conflict ──┤
    ├─ Execute hop 1 ──────────────>│ mutate local keys        │
    ├─ Execute hop 1 ─────────────────────────────────────────>│
    │<──────────────────── run_barrier complete ───────────────┤
    └─ final hop: release intent locks and dequeue              │

每个 Shard 有按 TxId 排序的事务队列和一个从 64 位 Key Fingerprint 映射到 IntentLock 计数的表。读使用 Shared Intent,写使用 Exclusive Intent。Fingerprint 冲突不会破坏正确性,只会把无关 Key 当成有竞争,退回较慢的有序路径。多 Shard 调度消息可能乱序到达;若较小 TxId 晚到且与已排队事务冲突,协调者撤销本轮调度并用新 TxId 重试。进入执行阶段后,事务不会再回滚或重试,而是在相关 Shard 达到可执行位置时完成。

常见单 Shard、无竞争命令不会每次争全局序列。它会走 Optimistic Fast Path:在目标 Shard 获取并立即释放意向,Inline 执行,不进入事务队列,也不分配 TxId。这条优化决定了 Dragonfly 不会为了极少数跨 Shard 命令,让每个 GET 都支付分布式协调成本。

多 Key 原子性因此不是「没有锁也神奇成立」,而是以下不变量共同成立:

  1. 每个 Key 只有一个 Shard Owner;
  2. 涉及多个 Shard 的事务在各自队列中使用一致的全序;
  3. 冲突通过意向计数阻止不安全的乱序执行;
  4. Coordinator 只在所有参与 Shard 完成一个 Hop 后推进下一 Hop;
  5. 事务回调在关键路径不能任意挂起,否则同一 Shard 的其他 Fiber 可能穿插执行。

RENAME 是理解 Multi-Hop 的好例子。第一 Hop 从源和目标 Shard 读取状态,Coordinator 处理结果;第二 Hop 删除源并写入目标。锁在两次 Hop 之间保持,其他访问相同 Key 的事务不能越过。代价是跨线程消息、Barrier 和队列等待,所以 Hash Tag 仍然有价值:把经常一起操作的 Key 映射到同一个内部 Shard,可以减少协调,但不要依赖未公开的内部 Shard 数作为永久数据协议。

Lua 与 MULTI/EXEC 属于 Multi-Transaction。若事先知道全部 Key,Dragonfly 可 Lock Ahead,只锁声明过的 Key,不妨碍其他 Shard/Key 并行;脚本访问未声明 Key 时,默认会报错。打开 allow-undeclared-keys 后,为保证原子性需要进入 Global Mode,暂停其他操作。Dragonfly Lua 文档明确说明了这个兼容性成本。

第一章的工程结论:Redis 用「所有命令本来就排成一列」获得简单原子性;Dragonfly 用「数据所有权 + 消息传递 + VLL 排序」重建同样的外部语义。前者的瓶颈是单核与队头阻塞,后者的瓶颈会转移到热点 Shard、跨 Shard 协调、内存带宽和网络。核心数增加不会自动保证线性扩展,访问分布和命令形态决定曲线。

二、内存数据结构与存储引擎:CPU 真正在等待什么

2.1 Redis dict:渐进 Rehash 解决停顿,不解决指针追逐

Redis 8.2 的 dict 仍保留两张 Bucket Table:ht_table[0] 与 ht_table[1],并用 rehashidx 表示迁移位置。dict.h可以看到两张表、使用量和指数形式的表大小;dict.c则定义了经典 dictEntry:Key 指针、Value Union 和同 Bucket 的 next 指针。

c
struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
};

发生扩容时,Redis 不会在一次请求里搬完 N 个元素,而是分配目标 Bucket Array,将 rehashidx 指向旧表起点,后续字典操作顺手迁移若干 Bucket,定时任务也按时间预算推进:

text
rehash 开始

ht[0]: [A→B] [C] [ ] [D→E] ...     rehashidx = 0
          │
          └── 逐 Bucket 搬迁 ──> ht[1]: [ ] [B] [A→C] [ ] ...

查找:先查 ht[0],未找到且正在 rehash 再查 ht[1]
新增:通常进入 ht[1]
完成:释放旧 Bucket Array,ht[1] 变为新的 ht[0]

渐进式 Rehash 把 O(N) 停顿摊薄为许多小步骤,是 Redis 低延迟的重要设计。但它有两类成本。

第一类是空间。扩容过程中两张 Bucket Array 同时存在;负载因子与扩容倍数决定瞬时空槽数量。dictEntry 在 64 位平台的典型大小是 24 Byte,另有 Bucket 指针、Key 对象、Value 对象和 allocator 元数据。Redis 8 对无 Value 的集合做了 Entry 指针编码优化,不能再把「所有 Redis Entry 永远固定浪费 24 Byte」当作无版本条件的真理;普通 Keyspace Map 仍受链式结构与独立对象影响。

第二类是 CPU Cache。查一个 Key 可能先读 Bucket Array,再追到 dictEntry,再追到 Key 与 Value;链冲突还会继续追 next。这些分配未必相邻,一个 L1 Miss 可能升级为 L2、L3,最后等待 DRAM。现代 CPU 一条简单指令只需几个 Cycle,跨内存层级等待却可能是几十到数百 Cycle。Redis 单线程没有锁竞争,不代表没有 Memory Stall。

2.2 Dashtable:不是一张巨大连续数组,而是连续的小 Segment

Dragonfly 的 Dashtable 受到持久内存 Hash Table「Dash」的启发,实质上是 Extendible Hashing 与固定大小开放寻址 Segment 的组合。它不是把整个数据库放进一段永不移动的连续内存,而是用一个较小的 Directory 指向多个 Segment,每个 Segment 内部连续布局 Bucket 与 Slot。Dragonfly 的 Dashtable 设计文档给出了当前描述:一个 Segment 有 56 个 Regular Bucket、4 个 Stash Bucket,每个 Bucket 有 14 个 Slot,总容量 840 条记录。

下面这张 ASCII 图就是提纲所要求的连续内存布局。重点不是框画得多漂亮,而是 CPU 访问路径发生了什么变化。

text
Directory(很小,常可驻留较高层 Cache)
┌─────┬─────┬─────┬─────┬─────────┐
│ *S0 │ *S1 │ *S1 │ *S2 │   ...   │  多个目录项可指向同一 Segment
└──┬──┴──┬──┴─────┴──┬──┴─────────┘
   │     │            │
   ▼     ▼            ▼
┌──────────────── Segment S0:固定大小、连续布局 ────────────────┐
│ Regular Bucket 0                                               │
│ [meta/fp][slot0][slot1] ... [slot13]                           │
│ Regular Bucket 1                                               │
│ [meta/fp][slot0][slot1] ... [slot13]                           │
│ ...                                                            │
│ Regular Bucket 55                                              │
│ [meta/fp][slot0][slot1] ... [slot13]                           │
│ Stash Bucket 0 ... Stash Bucket 3                              │
└────────────────────────────────────────────────────────────────┘

hash(key)
   ├─ 高位选择 Directory / Segment
   ├─ 中间位选择 Home Bucket,并可检查相邻 Bucket
   └─ Fingerprint 先筛 Slot,命中后再比较完整 Key

插入时先定位 Segment,再尝试 Home Bucket、相邻 Bucket和 Stash Bucket。若候选区域没有空 Slot,就把当前 Segment 分裂成两个,重新分布其中一部分记录,并扩展或调整 Directory。它只触碰固定数量的 Segment,而不是先分配一张 2N Bucket Array 再慢慢搬完整张表。

这种布局有三个直接效果:

  • 开放寻址消除了每条记录用于链表 next 的指针,也减少每次插入单独分配 dictEntry;
  • Fingerprint 和 Slot Metadata 密集排列,探测通常集中在少数 Cache Line,硬件预取器更容易发挥作用;
  • Segment 独立分裂,使扩容内存与工作量更平滑,尾延迟不必承受巨大 Bucket Array 的一次分配。

官方设计文档估算 Dashtable 的目录开销约为 8N/840,平均每条记录的 Metadata「税」不足 20 Bit;在其模型中,实际表开销约为每条 6~16 Byte,而 Redis 字典约为 16~32 Byte。这个数字只能解释索引层,不能直接推导整个实例节省固定百分比。Value 很大时,索引占比很低;Key 很短、Value 很小时,Metadata 才可能成为主角。Dragonfly 早期对 2000 万个小对象的测试出现 1 GB 对 1.73 GB,但那是 2022 年、Redis 6、特定填充命令与特定对象尺寸的项目方实验,不应改写成「所有负载节省 42%」。

更严谨的容量模型是:

text
实例内存 ≈ Key/Value 有效字节
         + 顶层索引元数据
         + 复合结构节点与编码空洞
         + TTL / LFU / 事务 / 复制元数据
         + allocator size class 内部碎片
         + 未归还 OS 的空闲页
         + 快照或重写期间的临时增量

Dashtable 只显著改变其中几项。生产压测必须保持相同 Key 长度分布、Value 大小、TTL 比例、数据结构编码、装载因子和删除历史,再比较稳定态 RSS 与有效数据量。

2.3 SDS 与 Compact Object:短字符串为什么值得特殊照顾

Redis 的 SDS(Simple Dynamic String)在字符数组前保存长度、已分配容量和类型标识。它是二进制安全的,取长度 O(1),追加时可利用预留空间,结尾仍保留 \0 以兼容 C API。Redis 又会根据字符串是否能表示为整数、长度是否适合 Embstr 等条件选择对象编码,从而避免一部分额外分配。

概念布局如下:

text
Redis SDS
┌──────┬───────┬───────┬────────────────────┬────┐
│ len  │ alloc │ flags │ binary-safe payload│ \0 │
└──────┴───────┴───────┴────────────────────┴────┘
                           ↑ sds 指向 payload

Dragonfly 的值封装核心是 CompactObj 一类结构。它会区分整数、短字符串 Inline、外部分配字符串以及集合类型;短内容尽量塞进对象自身,避免额外 Heap Allocation 和指针跳转。较长字符串仍需要动态内存,启用 Tiered Storage 时部分大字符串还可能被换出到 SSD。这里的优势仍取决于分布:若缓存里大量是几十 Byte 的 Session Flag、计数器与短 JSON 片段,Inline 很值钱;若 Value 普遍是 100 KB 图片片段,顶层对象省几个 Byte 很难改变总账单。

Redis 与 Dragonfly 都不会对每种逻辑结构使用一个永远不变的物理表示。集合从小变大时,编码会升级;删除后也未必自动降级。比较内存时应看「这批真实数据触发了什么编码」,而不是只看 HSET、ZADD 的命令名称。

2.4 Listpack、Ziplist、Skiplist:协议兼容不等于内部同构

Redis 早期常用 Ziplist 紧凑保存小 Hash、List 与 Sorted Set。Ziplist 的每个 Entry 记录前一 Entry 长度,遇到编码长度变化可能产生 Cascade Update;后来 Listpack 改为在 Entry 尾部记录自身长度,降低这种连锁修改问题。当前 Redis 的小 Hash、Set、Sorted Set 等会根据元素数和大小阈值采用 Listpack 或其他紧凑编码;List 则以 Quicklist 组织多个 Listpack 节点,大 Sorted Set 通常使用 Dict + Skiplist 组合。

Dragonfly 为了命令兼容实现相同的逻辑类型,但内部会使用自己的紧凑容器、树或从 Redis/Valkey 移植并调整的数据结构。不能因为两边都响应 OBJECT ENCODING 或都接受 ZADD,就假定内存曲线和复杂度常数完全相同。特别是:

逻辑类型Redis 典型思路Dragonfly 关注点迁移时要测什么
StringSDS + Integer/Embstr/Raw 编码CompactObj + Inline/外置存储短值占比、更新是否频繁、Tiering 边界
Hash小对象 Listpack,大对象 Hashtable紧凑 Map / Dense Set 类结构Field 数量分布、Field TTL、随机访问
ListQuicklist + Listpack自有 List 实现及压缩优化两端操作、Blocking Pop、超长 List 复制
SetIntset/Listpack/Hashtable 等Dense Set / 紧凑编码整数比例、交并集大集合成本
ZSetListpack 或 Dict + Skiplist紧凑编码与有序树实现Rank/Range、更新 Score、内存放大
StreamRadix Tree/Listpack 等组合Redis API 对标实现Consumer Group、Pending Entry 与裁剪语义

对架构师而言,最重要的不是背下每个类名,而是建立一条因果链:连续元数据让一次查找触碰更少 Cache Line;更少独立分配让 allocator 元数据与碎片下降;分段扩容削弱大 Rehash 尖峰;但复杂容器、超大 Value、跨 Shard 事务和复制仍可能主导成本。

三、持久化与 Snapshot:COW 不是免费副本

3.1 Redis RDB:fork() 很快,复制页表和写时复制未必便宜

执行 BGSAVE 时,Redis 主进程调用 fork() 创建子进程。父子初始共享相同物理页,子进程遍历某个逻辑时间点的数据并写出 RDB;父进程继续服务请求。只要双方都不改某一页,就不需要复制这一页,这就是 Copy-On-Write(COW)。Redis 持久化文档将 RDB、AOF 及混合方式列为主要选项。

text
fork 前                      fork 后,尚未写入
Redis ──> Page A/B/C         Parent ─┐
                                    ├──> Page A/B/C(共享)
                            Child  ─┘

Parent 修改 Page B 后
Parent ──> Page A / Page B' / Page C
Child  ──> Page A / Page B  / Page C
                    ↑ COW 复制的是内存页,不是一个 Redis Key

常见的「快照期间内存可能翻倍」是最坏情况,不是必然行为。实际额外内存近似取决于快照持续时间内被父进程写脏的不同内存页数,而不是写命令条数。一个热 Key 反复覆盖且总落在少数页,COW 可能有限;大量随机小对象更新会把许多 allocator Page 写脏,即使每次只改几 Byte,也可能复制 4 KB Page。Transparent Huge Pages 若介入,还可能放大成本,因此 Redis 生产实践通常要求谨慎处理 THP。

fork() 自身也不是 O(1) 的零成本动作。内核要复制页表、建立子进程元数据;数据集越大,页表越大,主线程暂停越明显。之后父子进程并发遍历和写内存,会争用 Memory Bandwidth、Last-Level Cache 与磁盘 I/O。是否发生 OOM 取决于写入率、快照速度、内存余量、Overcommit、容器限制和碎片,不能把「RDB 一定让内存翻倍」写成定律。

Redis 为降低 COW 会在子进程存在时限制不必要的 Rehash,并提供 lazyfree-lazy-user-del、UNLINK 等手段把大对象释放移出主线程。但异步释放解决的是主线程停顿,不会消灭总体内存和后台 CPU 成本。

3.2 Redis AOF:RPO 更小,写放大与 Rewrite 必须计入

AOF 把会改变数据集的命令按 RESP 形式追加到日志。appendfsync always 每次提交同步刷盘,持久性更强但延迟高;everysec 常见地把最坏数据窗口控制在秒级;no 交由 OS 安排。随着 AOF 增长,需要 Rewrite 生成等价但更紧凑的状态。现代 Redis 的 Multi-Part AOF 将 Base 与 Incremental 文件组织在 Manifest 下,但本质取舍没有消失:前台持续追加,后台重写全量基线,完成时原子切换。

RDB 与 AOF 回答不同问题:RDB 是稠密的时间点状态,适合备份和快速传输;AOF 是更细的变更历史,能够缩小 RPO。两者都不是自动的异地灾备,也不等于同步复制。复制、持久化、备份和故障转移必须分别定义承诺。

3.3 Dragonfly 的 Forkless Snapshot:把 COW 从 OS 页下沉到 Entry

Dragonfly 不依赖 fork() 生成快照。每个 Shard 启动 Snapshot Fiber,记录自己的 Epoch Cut,遍历本地 Dashtable 并序列化属于该 Cut 的 Entry;多个 Shard 并行产出 Blob,经 Channel 汇总到单个 Redis-Compatible RDB Sink,或用于复制流。Dragonfly Snapshot 设计描述了 Thread-Local Snapshot、Bucket 粒度 Blob、Direct I/O 对齐以及 Epoch Cut。

难点是并发写。假设扫描器还没访问 user:42,前台就把旧值 A 改成新值 B。若快照起点语义要求保存 A,扫描器后来只看到 B 就破坏了一致性。Dragonfly 在写路径挂 On-Write Hook:若 Entry 仍属于尚未序列化的 Cut,先把旧值送入 Sink,再允许覆盖。

text
Snapshot 开始:cut = shard.epoch++

扫描 Fiber:
for entry in dashtable:
    if entry.version <= cut:
        entry.version = cut + 1       // 标记已处理
        serialize(entry)

并发写 Hook:
before_write(entry, new_value):
    if entry.version <= cut:          // 扫描器还没保存旧版本
        serialize(entry.old_value)    // Entry 级按需复制
    entry.value = new_value
    entry.version = shard.epoch++

这与 OS COW 的差别是复制粒度和控制位置:

机制Redis fork() COWDragonfly On-Demand Copying
一致性切点fork() 得到子进程地址空间视图每 Shard Epoch 与逻辑 Cut
复制粒度被写脏的 OS Page尚未扫描 Entry 的旧逻辑值
并发服务父进程继续执行Owner Thread 上其他 Fiber 继续执行
临时内存页副本、页表、子进程开销序列化 Buffer、写前旧值、Channel Backpressure
主要风险fork Pause、COW、内存带宽、OOMHook CPU、Sink 反压、超大值复制、磁盘吞吐

Dragonfly 的优势在随机写高峰下尤其明显:修改一个 40 Byte 对象不必因为 Page 粒度复制周围 4 KB,且不复制整个进程页表。不过「Forkless」不等于「Zero-Copy」或「快照不耗内存」。旧 Value 仍可能需要在覆盖前序列化,RDB Serializer 与对齐 Buffer 占空间,磁盘跟不上时 Channel 会产生反压;超大对象和全局命令仍可拉高延迟。

此外,Dragonfly 文档中的「Relaxed Point-in-Time」部分仍带 TBD 标识,文章不能把设计设想当作所有版本的已交付保证。对文件备份应以 Conservative Snapshot 行为和实际版本测试为准。

3.4 一个不能略过的差异:Dragonfly 当前没有 AOF

截至 2026 年 8 月,Dragonfly 官方AOF 文档仍明确写着「当前不支持 AOF」。这会直接改变选型,而不是一个功能表里的小叉号。

如果业务要求:单机进程或整机故障后最多丢 1 秒、必须依靠本地增量日志恢复,Redis 的 AOF everysec 有成熟路径;Dragonfly 需要通过复制、快照频率和外部架构共同定义 RPO。若只每 5 分钟做一次 RDB,主副本又在同一故障域,故障窗口就不是宣传页上的亚毫秒延迟,而是最多数分钟数据。

建议为两类用途分开决策:

  • 纯缓存:源数据可重建,关闭持久化或低频快照通常合理,Dragonfly Forkless Snapshot 是运维加分项;
  • 主数据或队列状态:必须先写 RPO/RTO、复制确认、快照成功率、对象存储上传与恢复演练,再讨论吞吐量。

不要把 WAIT、Replica ACK 或「有两个副本」自动理解为同步提交。Redis 官方复制文档也提醒,WAIT 会显著降低写丢失概率,但不会把部署变成强一致 CP 系统。Dragonfly 兼容命令时同样需要核验其版本语义与故障边界。

四、内存管理与驱逐:RSS、有效数据和命中率是三笔账

4.1 Redis:jemalloc、碎片与 Active Defrag

Redis 默认常与 jemalloc 构建,也可使用其他 allocator。jemalloc 用 Size Class、Arena、Extent 等机制降低通用分配成本,但当大量对象被删除或尺寸改变后,空闲块可能散落在不能立即归还 OS 的 Page 中。于是 used_memory、used_memory_rss 与容器看到的 RSS 会产生差距。

常用观测项包括:

text
allocator_frag_ratio  = allocator_active / allocator_allocated
allocator_rss_ratio   = allocator_resident / allocator_active
mem_fragmentation_ratio = used_memory_rss / used_memory

这些 Ratio 不能脱离绝对值看。实例只有 10 MB 数据时,固定开销就可能让比值很高;实例 200 GB 时,1.1 的碎片可能意味着数十 GB。activedefrag yes 会渐进扫描对象,把分散分配迁到更紧凑的位置,再释放旧块。它用 CPU 与内存复制换 RSS 回收,阈值过激会影响延迟,过保守则无法在扩容前及时释放内存。

Redis 的 Lazy Free 与 Active Defrag 解决的也不是同一件事:前者把昂贵析构移出主线程,后者重新布局仍存活对象以腾出 Page。一个降低请求 Pause,一个降低长期碎片。

4.2 Dragonfly:是 mimalloc + Engine 级策略,不是神秘自研分配器

Dragonfly 当前源码在主程序中接入 mimalloc,并通过 C++ PMR Resource 等路径使用它;dfly_main.cc可以看到 mimalloc-new-delete.h、mi_malloc_aligned 与 mi_free_size_aligned。因此把它描述为「完全自研页面分配器」不准确。

真正有区别的是 allocator 上方:每线程 Shard 减少跨核共享;Dashtable 省掉独立 dictEntry;Compact Object 内联短值;按 Shard 记账并设置内存预算;快照不依赖进程 COW。这些 Engine-Level Decision 与 mimalloc 的 Thread-Local Heap、Page 管理叠加,才形成整体内存曲线。

Shared-Nothing 也可能带来新问题。例如每个线程都有局部数据与 allocator 状态,若 Key 分布倾斜,某个 Shard 提前触及预算,其他 Shard 的空闲不一定能无成本搬过来;删除后 RSS 是否及时下降仍受 mimalloc Page 状态影响。2026 年仍有项目 Issue 在讨论 RSS 归还问题,这提醒我们:减少元数据不等于 RSS 永远等于 Live Bytes。

容量测试至少要记录四个阶段:冷启动、装载完成、随机更新 1 小时、大量删除后空闲 30 分钟。只在装载完成那一刻截图,很容易错过碎片与回收行为。

4.3 Redis 的近似 LRU/LFU:不是固定随机 5 个 Key

Redis 不维护一条精确全局 LRU 双向链表,因为每次读都移动节点会增加写流量、指针和 CPU 开销。它在对象的复用字段里记录近似时间或 LFU 信息,淘汰时从 Keyspace 随机取样,将较差候选放进 Eviction Pool,再选择受害者。

「随机抽 5 个 Key」来自默认 maxmemory-samples 5,但这是可配置的采样数,不是算法永远固定的常数。近似 LRU 比较空闲时间;LFU 使用衰减的 Logarithmic Counter,使一次突发热点不会永久霸占缓存。Redis 还提供 allkeys-*、volatile-*、volatile-ttl、noeviction 等策略,选择范围与排序指标可以分开控制。

text
候选采样:k17  k93  k4  k51  k8
             │    │    │    │    │
             └────┴─ 比较 idle / LFU / TTL
                         │
                    Eviction Pool
                         │
                      victim

采样越多,越接近全局最差 Key,但每次淘汰 CPU 越高。若请求写入速度超过释放速度,Redis 会在命令路径连续淘汰,P99 上升。评估策略时应看命中率与源站回源成本,而不是只看 Eviction QPS。

4.4 Dragonfly 的 2Q/LFRU:利用 Segment 排序抵抗扫描污染

Dragonfly 的 cache_mode=true 启用统一的 LFRU(Least Frequently Recently Used)策略。其设计来源是 2Q:新对象先进入 Probationary 区,只有再次访问才晋升到 Protected 区。一次性全表扫描会把大量对象带进 Probationary,但不容易把已经证明有复用价值的热点全部挤掉。Dragonfly Cache Design解释了 2Q 与 Dashtable Segment/Bucket Rank 的结合。

text
第一次进入 / 一次性 Scan
          │
          ▼
┌──────────────────────┐  再次命中  ┌──────────────────────┐
│ Probationary         │ ─────────> │ Protected            │
│ 新对象、低置信度对象 │            │ 已证明会复用的对象   │
└──────────────────────┘ <───────── └──────────────────────┘
          │                降级                 │
          └────────────── 优先淘汰 ─────────────┘

经典 2Q 需要 Queue 与链表元数据。Dragonfly 利用 Dashtable Slot 在 Bucket 内的 Rank 和 Segment 分裂/满载时机近似维护顺序,从满 Segment 中 O(1) 地选择候选,不为每个对象增加两根 LRU 指针。它同时考虑 Recency 与 Frequency,因此项目文章常称 LFRU。

这套设计对 Scan/Bulk Read 污染更有韧性,但不能写成「命中率必然高于 Redis LFU」。工作集均匀随机、对象 TTL 极短、写入后只读一次时,Protected 的价值有限;策略实现还受 cache_mode、内存阈值、Shard 局部候选和版本影响。Redis 有更丰富的 volatile-ttl 等策略,Dragonfly 的统一策略可操作性更简单,也意味着不能一比一映射所有 maxmemory-policy。

还有一个语义风险:作为缓存时,Eviction 是设计内行为;作为主数据存储时,Eviction 就是数据丢失。迁移脚本不能只照搬 maxmemory,必须确认 Dragonfly 是否打开 cache_mode,哪些 Key 可驱逐,以及业务是否使用了类似 volatile-* 的「只淘汰带 TTL Key」假设。

五、高可用、复制与集群:协议相似,控制面不同

5.1 Redis PSYNC:Replication ID + Offset 定义历史位置

Redis 主节点将修改数据集的命令编码成 Replication Stream。每个主节点维护 Replication ID 与单调增加的 Offset;副本断线重连时发送 PSYNC replid offset。若主节点的 Replication Backlog 仍保留所缺区间,就做 Partial Resynchronization;否则进行 Full Resynchronization。

text
稳定复制
Primary command stream ───────────────> Replica apply
       offset 10500                         ACK 10420

短暂断线后
Replica: PSYNC <replid> 10420
Primary backlog still has [10421, 10500] ?
       ├─ yes → CONTINUE + 增量字节
       └─ no  → FULLRESYNC + RDB baseline + 后续命令流

Full Sync 通常要生成或传送 RDB,同时把快照期间的新写入缓存起来;副本加载新数据集时也可能出现阻塞窗口。Backlog 太小、写入速度太高或网络中断太久,都会把本可增量修复的故障放大成全量同步。Redis 的复制文档详细说明了 PSYNC、Replication ID、Offset 和异步复制属性。

Redis 复制默认异步。Sentinel 负责 Standalone 主从的故障检测与提升,Redis Cluster 则把复制与 Slot Ownership、Quorum 和配置 Epoch 组合起来。无论哪种方式,故障切换期间仍可能丢失尚未到达副本的已确认写入。min-replicas-to-write、min-replicas-max-lag 与 WAIT 可以收窄风险,却不是通用共识协议。

5.2 Dragonfly 复制:API 兼容,内部按 Shard 并行

Dragonfly 对外保留 ROLE、REPLICAOF、INFO REPLICATION 等接口,内部复制不是 Redis 单流实现的简单拷贝。每个 Shard 有自己的变更 Journal 与复制进度,全量同步可以由多个 Snapshot Shard 并行序列化,稳定阶段继续传送增量记录。主节点报告的 dragonfly_connected_replica_lag_records 是各 Shard 未确认记录的最大值。Dragonfly 复制文档说明了 Native Replication、级联复制实验开关及 Lag 指标。

并行管道的好处是全量数据生成和增量应用能够利用多核,不会把复制吞吐压进单一命令执行线程;代价是接收端必须把多路 Shard Stream 重建成一致状态,还要处理 Snapshot Cut 与后续 Journal 的衔接。大 Collection 不能粗暴当成一条必须持锁发送的巨型消息,否则一个核心等待 Socket 就会拖住同 Shard 的其他复制任务。Dragonfly 1.40 又引入 Tagged Chunks 改进这条路径,这恰好说明复制实现仍在快速演进,不应该用 2023 年的一张架构图代替版本测试。

从 Redis 直接让 Dragonfly 做 Replica 是一种迁移能力,但边界很明确:当前文档称支持 Redis OSS 至 6.2 的数据结构与复制协议。若源端是 Redis 7/8,使用 Function、新 RDB Opcode、Module 自定义类型或新版命令,不能看到 REPLICAOF 成功就假定数据完整。迁移后必须做 Key Count、抽样 Value、TTL、类型与业务不变量校验。

5.3 Dragonfly Cluster 兼容了什么,又没有兼容什么

Dragonfly 有两种容易混淆的 Cluster Mode。

Emulated Mode 让单个 Dragonfly 节点对 Redis Cluster Client 返回可接受的拓扑,看起来拥有全部 Slot。它适合把原先使用 Cluster Client 的应用迁到一个大 Dragonfly 实例,无需立刻改成 Standalone Client。

Multi-Shard Cluster 则由多个 Dragonfly Server 共同承担 16384 Slot,客户端侧的 Hash Tag、拓扑查询与重定向行为尽量兼容 Redis Cluster。但开源 Dragonfly Server 主要提供 Data Plane:节点除了复制和迁槽外不会像 Redis Cluster 那样通过 Gossip 自行维护一致拓扑,也不内建完整 Control Plane。配置变更需向各节点下发 DFLYCLUSTER CONFIG;自动健康检查、Failover、Rebalance 属于外部系统或 Dragonfly Swarm 的职责。Dragonfly Cluster 文档明确写出这一区别。

能力Redis ClusterDragonfly EmulatedDragonfly Multi-Shard 开源数据面
16384 Slot 客户端语义原生单节点模拟全部 Slot支持兼容分片
节点间 Gossip有不需要无常规 Gossip 自管理
自动故障检测/提升Cluster 内建机制需外部 HA需外部控制面
Slot Migration原生工具与协议不涉及数据面支持,编排需外部负责
多数据库Cluster 只用 DB 0取决于模式当前仅 DB 0
运维复杂度节点多、机制成熟最低控制面由使用者承担

因此「单个 Dragonfly 可以替代许多 Redis Shard」是有条件的架构优势:只要数据和 QPS 能放进一台机器、允许该机器作为较大故障域、复制与恢复达到目标,就能减少节点、连接池和迁槽次数。一旦超过单机内存、网卡或可接受故障域,Dragonfly 也要横向分片;此时它并不会凭空消灭控制面复杂度,开源部署甚至需要你自己补上其中一部分。

5.4 少节点的真正收益与代价

少节点不是单纯节省三份 YAML。它减少了客户端连接、拓扑变化、Slot 倾斜、跨节点网络 Hop、每节点保留内存和监控告警数量。对 50 个 Redis Shard 缩到 4 个 Dragonfly 节点的场景,SRE 的认知负担可能比机器账单下降更重要。

但大节点也放大 Blast Radius:

  • 一次进程崩溃影响更大比例流量;
  • Full Sync 的数据量更大,恢复和扩容需要更高网络带宽;
  • 单机内存越大,故障后 Warm-Up 与页缓存恢复越慢;
  • 跨 NUMA Node 的内存访问可能破坏「加核近似线性」假设;
  • 云上超大规格实例的单位内存价格、可用区库存和预留折扣未必更优。

选型模型应是 TCO = 实例 + 副本 + 网络 + 控制面 + 人力 + 故障损失,不是 QPS / 单价 一项。

六、性能、吞吐与长尾:基准数字要带着实验条件读

6.1 「Redis 20 万、Dragonfly 400 万」从哪里来

Dragonfly 项目公开基准在 AWS c6gn.16xlarge(64 vCPU、128 GB RAM)上,用另一台同可用区机器运行 memtier_benchmark,256 Byte Value、调优过线程和连接数。其 README 报告 Dragonfly 单实例约 380 万 QPS;项目的对比页在 YCSB 条件下报告 GET 约 420 万、SET 约 310 万,而 Redis 单进程约 12.5~24 万。Dragonfly README 基准同时给出了命令、实例类型和 P99/P99.9。

这些数据说明架构可以利用多核,不是对你的生产环境的容量承诺。至少有六个变量会让数字失效:

  1. Value 大小决定内存带宽与网卡上限;
  2. Pipeline Depth 决定每次 RTT 能摊多少请求;
  3. Uniform/Zipfian 分布决定 Cache 与热点 Shard;
  4. GET/SET 比例决定分配、复制和快照压力;
  5. Client 是否成为瓶颈决定测到 Server 还是压测机;
  6. TLS、跨可用区、持久化和副本会加入真实成本。

项目 README 里 Redis 单节点约 15~22 万 QPS 与提纲中的「约 20 万」在同一量级,Dragonfly 约 380~420 万也接近「约 400 万」。文章可以引用这组数,但必须写作「厂商公开基准在特定 64 vCPU 环境的结果」,不能写成普适上限。

6.2 多核扩展曲线为什么先上升,后来变平

一个理想 Shared-Nothing 引擎在 CPU 是唯一瓶颈、Key 均匀分布、命令完全独立时,吞吐近似:

text
QPS(N) ≈ min(
    N × per_core_command_rate,
    NIC_bandwidth / bytes_per_op,
    memory_bandwidth / memory_bytes_touched,
    client_generation_limit,
    hottest_shard_limit
)

从 16 核到 32 核,若 CPU 饱和而网卡尚有余量,QPS 可能接近翻倍;从 32 核到 64 核,内存带宽、NUMA、网络 IRQ、协调消息或客户端开始主导,斜率会下降。若 40% 请求打到同一个 Key,其 Owner Shard 就是串行热点,其他 63 核再空闲也不能并发修改这个 Key。

text
QPS
 ^                       ─────── NIC / Memory BW 上限
 |                  ____/
 |             ____/
 |        ____/                Dragonfly:随核心增加后逐渐饱和
 |   ____/
 |-- Redis 单进程 ─────────    核心数增加不并行执行普通命令
 +--------------------------------------------------> cores
     1       8       16       32       64

这幅图表达趋势,不是实测数据。真正的曲线必须在目标机器上分别限制 proactor_threads 或 CPU Affinity,保持客户端余量和数据集不变,逐点测 1/2/4/8/16/32/64 核。

6.3 P99/P999:平均延迟最擅长藏故障

若 99.9% 请求为 0.5 ms,0.1% 请求为 500 ms,平均值仍可能看起来不错,但每秒 10 万请求意味着每秒约 100 个用户遭遇 500 ms。内存服务应至少记录 P50、P95、P99、P99.9 和 Max,并在目标吞吐下比较,而不是拿「Redis 在满载」对「Dragonfly 在半载」。

Redis 的尾延迟来源常见有:

  • 主线程执行 O(N) 或 O(N log N) 命令;
  • 大 Key 同步删除、过期或集合运算;
  • fork() Pause 与 COW Page Fault;
  • AOF fsync、Rewrite 与磁盘抖动;
  • Rehash、Active Defrag 和 allocator 竞争;
  • CPU Steal、NUMA 与网络队列。

Dragonfly 消除了「所有普通命令共用一个执行线程」和 fork() COW 两类来源,但新增或保留:

  • 热点 Key/Shard 的事务队列;
  • 跨 Shard Multi-Hop 与 Barrier;
  • Global Transaction,例如某些全局命令与未声明 Key 的 Lua;
  • Cooperative Fiber 中不 Yield 的长 CPU 工作;
  • Snapshot Sink、复制 Channel 与磁盘反压;
  • 大对象序列化、网络和 allocator Page 行为。

所以合理结论是「架构移除了若干系统性队头阻塞」,不是「Dragonfly 没有长尾」。

6.4 KEYS *、SCAN、大 Key 删除和 RDB 应如何测

KEYS * 在 Redis 上要遍历整个 Keyspace,并且返回结果本身会占用内存与网络,生产上通常禁用。Dragonfly 可以让不同 Shard 参与遍历,并通过 keys_output_limit 限制输出,但全局扫描仍消耗所有 Shard 的 CPU,限制结果还可能与依赖完整结果的旧应用不兼容。

SCAN 把工作分批,但「每次只返回少量」不代表总成本消失。Redis 的渐进 Rehash 和 Dragonfly 的 Segment Split 都要求 Cursor 遍历允许重复、不能承诺严格快照;客户端必须容忍重复 Key,并持续到 Cursor 回 0。

大 Key 删除应分别测试:

bash
# 业务峰值相同流量下,在另一个连接执行
redis-cli UNLINK huge:list
redis-cli DEL huge:list

# 观察
# - command latency histogram
# - event loop / shard utilization
# - allocator RSS 回落
# - replica lag
# - snapshot duration / COW or hook bytes

RDB 测试则需要在稳定写入下触发 BGSAVE,同时记录进程/容器总 RSS、Minor/Major Fault、磁盘吞吐、P99.9、复制 Lag 和失败率。只测「生成文件用了几秒」会漏掉 Redis COW 与 Dragonfly On-Write Hook 对前台的影响。

6.5 一份可复现的基准框架

下面不是一条万能命令,而是一组必须固定的实验变量:

yaml
server:
  cpu: 16 / 32 / 64 vCPU, record model and NUMA
  memory: enough for dataset + 40% safety margin
  kernel: record version, THP, io_uring availability
  persistence: off / snapshot-under-load / AOF-everysec-if-supported
  replication: none / one replica

dataset:
  keys: 10_000_000
  key_length: production histogram
  value_length: p50/p95/p99 histogram
  ttl_ratio: production value
  structures: string/hash/list/zset actual mix

traffic:
  distribution: uniform + Zipfian + explicit hot-key test
  read_write: 95/5, 80/20, 50/50
  pipeline: 1 and production value
  duration: warmup 10m + measure 30m
  target: open-loop stepped QPS, not only maximum blast

report:
  throughput, errors, P50/P99/P99.9/max
  CPU per core, RSS/live bytes, network, faults
  hit ratio, evictions, expirations, replication lag

最有意义的容量点不是「把 Server 打死前的最高 QPS」,而是满足 P99 < SLA、错误率为零且 CPU/RSS 留出 Failover 余量时的最高 Sustained QPS。副本提升后可能承受原主节点流量,日常把 CPU 跑到 95% 等于主动放弃故障余量。

七、协议兼容与高级生态:Wire Compatibility 只是第一层

7.1 RESP2/RESP3:客户端能握手,不代表应用完全无感

Redis Serialization Protocol(RESP)是二进制安全、长度前缀的客户端协议。RESP2 使用 Simple String、Error、Integer、Bulk String、Array 等基础类型;RESP3 增加 Map、Set、Double、Boolean、Push 等更明确的类型。Redis 6 引入 HELLO 协商与 RESP3,Redis 7 起核心命令可被 RESP2/3 Client 调用。RESP 规范给出了两版类型和握手方式。

Dragonfly 支持 RESP2/RESP3,因此 go-redis、ioredis、Jedis、redis-py 等通常可以直接连接,连接池、Pipeline 和大多数基础命令无需改代码。迁移真正要检查四层兼容:

层次问题典型验证
Wire能否握手、认证、编码/解码HELLO 3、TLS、ACL、Push Reply
Command命令与参数是否存在从真实流量提取 Command Inventory
Semantics返回类型、错误、TTL、阻塞是否一致Differential Test 同时打两套实例
Operations复制、持久化、指标、Failover 是否一致故障演练与恢复演练

「零修改直连」多半只描述前两层中的常用路径。框架可能依赖 CLIENT SETINFO、Keyspace Notification、Script Cache、Cluster Topology、特定错误字符串或阻塞取消语义,这些都应进入测试。

7.2 Lua:5.1 与 5.4、声明 Key 与全局暂停

Redis 使用嵌入式 Lua 5.1,脚本在主线程原子执行;脚本运行期间其他活动不能插入,所以长脚本会阻塞整个实例。Redis Lua 文档将这种 Blocking Semantics 作为原子性来源。Redis 7 还提供持久化、可复制的 Functions,不能简单等同于临时 EVAL。

Dragonfly 使用 Lua 5.4。它要求脚本通过 EVAL script numkeys key... arg... 声明 Key,才能在运行前确定相关 Shard 并 Lock Ahead。访问未声明 Key 默认失败;允许未声明 Key 后进入全局事务,停止其他操作。Lua 5.1 与 5.4 的语言和库差异也意味着复杂脚本不能只做 SHA 对比。

迁移脚本建议做静态与动态两轮审计:

text
静态:
  - 是否动态拼接 Key 名且未放进 KEYS[]
  - 是否依赖 Lua 5.1 特有行为或 Redis Function
  - 是否调用 Dragonfly 尚不允许在脚本内执行的命令
  - 最坏循环次数、返回体和内存

动态:
  - 相同初始数据,两边执行并比较返回值、最终数据与 TTL
  - 并发注入冲突命令,验证原子性
  - 记录脚本 P99、Global Transaction 次数和其他请求抖动

对于每天执行一次、会扫描全库的管理脚本,Dragonfly 多线程并不能自动缓解;若脚本需要 Global Mode,反而可能把所有 Shard 一起停住。把动态 Key 改成显式 KEYS,经常比换数据库更有效。

7.3 MULTI/EXEC、Pub/Sub 与 Blocking Command

Redis 的 MULTI 先把命令排队,EXEC 时在主线程连续执行;它没有关系数据库式 Rollback,运行时某条命令出错不会撤销前面已成功的命令。WATCH 提供乐观并发控制,Key 在 EXEC 前变化则事务失败。

Dragonfly 把事务命令序列映射为一个 Multi-Transaction。已知 Key 可预先加意向锁,连续命令按 Hop 执行;优化器还能把属于不同 Shard 的相邻单 Shard 命令 Squash 成一个并行 Hop,同时保留各 Shard 内顺序。这是 Dragonfly 能利用多核执行一组事务命令的机制,但跨 Shard 总协调仍存在。

BLPOP X Y 0 更麻烦。若 X、Y 分属不同 Shard,Coordinator 要原子检查两边;都为空时,向各 Shard 注册 Watch,保留相关意向但从普通事务队列移出,然后 Fiber 挂起。LPUSH Y v 到达后通知等待者,抢到 Baton 的通知唤醒 Coordinator;最终 Hop 从 Y 弹出并释放锁。Fiber 让「阻塞客户端」不等于「阻塞 OS 线程」,VLL 则防止检查为空和注册等待之间丢失唤醒。

Pub/Sub 的数据不属于普通 Keyspace,却同样面对连接归属与跨线程 Fan-Out:订阅者可能散布在多个线程,发布需要把消息投递到对应连接线程。高 Fan-Out 时瓶颈可能转向网络写与消息复制,而不是字典查询。迁移要测试 Pattern Subscription、RESP3 Push、断线重订阅与 Cluster Pub/Sub 语义。

7.4 Redis Modules 与 Dragonfly 原生功能不是 ABI 兼容

Redis Modules API 允许动态加载 C Module,扩展命令、数据类型、RDB/AOF 编解码、阻塞与事件 Hook。Redis Search、JSON、Bloom、TimeSeries 等已经深度进入 Redis 生态;当前 Redis 源码仓库也将若干 Module 作为固定源码依赖组织。Redis Modules 目录列出了 Search、JSON、Bloom 与 TimeSeries。

Dragonfly 不是 Redis Fork,不能加载一个面向 Redis Modules ABI 编译的 .so 并期待工作。它选择在自身代码库原生实现一部分兼容命令。到 Dragonfly 1.40,项目已包含 JSON、Search/Vector Search、Bloom 与 Cuckoo Filter 等能力;但命令覆盖、索引语法、持久化、脚本内调用和结果排序仍可能不同。比如公开 Issue 至今仍记录 FT.SEARCH 在 Lua 中受限的场景。

需求Redis 路线Dragonfly 路线风险
JSON PathRedisJSON / Redis Open Source 集成原生 JSON 命令子集Path 语法和边界命令需对照
全文/向量搜索Redis Search内建 Search(编译/版本相关)索引、聚合、Lua 内调用不完全等价
Bloom/CuckooRedisBloom原生概率结构RDB、错误语义、命令覆盖需测
自研 ModuleRedis Modules API通常需要重写或拆外部服务不是 Drop-In
TimeSeriesRedisTimeSeries无通用 ABI 替换数据模型和查询需重新选型

如果业务高度依赖自研 Module,协议兼容几乎没有帮助,因为数据类型的内存表示、持久化 Opcode、复制和命令执行上下文都属于 ABI/Engine 层。此时 Redis/Valkey 往往是更保守的选择。

7.5 许可证与生态也属于技术决策

Redis 与 Valkey 在治理和许可证上已走出不同路线;Dragonfly 使用 BSL 1.1,并设有未来转换条款。许可证会随版本与发行物变化,企业选型应由法务核验准备部署的确切版本,而不是凭几年前的印象。本文不把许可证优劣混进性能结论,但它会影响云服务、二次分发和长期维护路线。

生态成熟度最难量化,却经常在凌晨两点决定恢复速度。Redis/Valkey 有更多客户端边缘案例、托管产品、Exporter、故障文章和工程师经验;Dragonfly 的新架构减少了一些老问题,也意味着团队要学习新的 Shard、Fiber、Global Transaction、Snapshot Hook 与控制面故障。新设计不是没有复杂度,而是把复杂度换了位置。

八、生产选型与迁移:先定义不能丢什么,再比较能跑多快

8.1 决策矩阵

下面的评分不是产品排名,而是把约束翻译成选择。Redis 一列也包括保持高度兼容的 Valkey 路线,但具体版本、Modules 与托管能力仍需分别核验。

场景更倾向 Redis / Valkey更倾向 Dragonfly原因与代价
单实例低于 10 万 QPS、数据几十 GB✓✓两者都够,成熟度与团队经验更重要
单机数百万 QPS、Key 可均匀分片✓Dragonfly 能让多个核心执行命令
依赖 Redis Search/JSON/Bloom 常用子集✓条件性 ✓Dragonfly 有原生实现,但需命令级验证
依赖自研 Module 或 RedisTimeSeries✓Dragonfly 不兼容 Modules ABI
大量动态 Key Lua、无法声明 KEYS✓Dragonfly 可能进入 Global Mode 或不兼容
需要本地 AOF 将 RPO 压到秒级✓Dragonfly 当前无 AOF,需另建数据安全路径
缓存扫描污染严重、RAM 昂贵条件性 ✓✓比较 Redis LFU 与 Dragonfly LFRU 的真实命中率
想用一台大机替代多个 Redis Shard✓减少节点,但扩大故障域并要求足够恢复带宽
跨 Slot 多 Key 原子操作很多Standalone ✓✓Redis Cluster 受 Slot 限制;Dragonfly 单节点可跨内部 Shard 协调
开源多节点需内建自动 Failover/Rebalance✓Dragonfly 开源 Server 不含完整控制面
已有成熟 Redis Cluster SRE 体系✓条件性 ✓迁移收益必须覆盖工具和经验重建成本

我会用三个 Gate 阻止团队过早讨论基准:

  1. 数据安全 Gate:RPO/RTO、AOF、复制确认、备份恢复是否满足?
  2. 语义 Gate:命令、Lua、Module、TTL、Blocking、Cluster 是否兼容?
  3. 经济性 Gate:在满足 P99 与故障余量的容量点,三年 TCO 是否下降?

任意一关失败,400 万 QPS 都没有意义。

8.2 迁移前先做 Command Inventory

不要先导数据,先观察应用。至少收集一周真实流量中的命令频率、Key/Value 尺寸、TTL、Pipeline、Lua SHA、事务组合和错误。Redis 可从 INFO commandstats、SLOWLOG、Latency Monitor、ACL Log、客户端埋点与短时安全抓样中获得信息;注意不要在生产长期 MONITOR,它本身开销高且可能泄露敏感 Value。

清单可按风险分层:

text
Level 0:GET/SET/MGET/MSET/DEL/EXPIRE/INCR
Level 1:Hash/List/Set/ZSet 的普通操作
Level 2:MULTI/WATCH、Blocking、Pub/Sub、Streams Consumer Group
Level 3:Lua/Functions、Module、自定义错误依赖、Cluster 管理命令
Level 4:复制、AOF、备份、Failover、ACL/TLS、审计与运维自动化

Dragonfly 的 Command Reference 应与清单做机器可读 Diff,但「命令存在」之后还要做 Differential Test。将脱敏后的同一请求序列分别发给 Redis 与 Dragonfly,比对 Reply Type、错误、最终 Value、TTL 容差和内存副作用。写请求不能在未隔离的生产数据上双发。

8.3 三种数据迁移路线

路线 A:离线 RDB

适合允许维护窗口、数据量可控的系统:停止写入,执行并验证 RDB,将文件放到 Dragonfly 的加载目录,启动后核对数据,再切连接。优点是简单、可回滚;缺点是停机时间包含最终快照、传输和加载。

检查项包括 RDB Version、Module Type、源 Redis 版本、过期 Key、磁盘空间、Checksum 和加载日志。Dragonfly 能生成 Redis-Compatible RDB,不代表能读取所有新版 Redis 或 Module 扩展 Opcode。

路线 B:Redis → Dragonfly 复制直连

让 Dragonfly 临时作为 Redis Replica:

bash
# 在目标 Dragonfly 上,建议通过受控管理网络执行
redis-cli -h dragonfly-target -p 6379 \
  --user migration --pass "$MIGRATION_PASSWORD" \
  REPLICAOF redis-source 6379

# 观察目标
redis-cli -h dragonfly-target -p 6379 INFO replication
redis-cli -h dragonfly-target -p 6379 INFO keyspace

# 切换前冻结或双重约束源写入,确认追平后提升目标
redis-cli -h dragonfly-target -p 6379 \
  --user migration --pass "$MIGRATION_PASSWORD" \
  REPLICAOF NO ONE

不要把密码直接写入 Shell History;示例用环境变量仍要确认进程环境和 CI 日志不会泄漏。复制路线停机短,但当前官方边界是 Redis OSS 6.2 协议/数据结构。源端若更高版本,应先用相同数据做完整演练。

切换顺序应该显式定义:阻止源端新写、等待目标 Lag 为 0、记录 Source Offset/业务水位、提升目标、修改 Service Discovery、逐步放量。若允许双写,需要业务级 Idempotency 与冲突规则;不要临时发明一个没有一致性协议的双主系统。

路线 C:redis-cli --pipe 或应用层重放

--pipe 接受 RESP Command Stream,适合从导出工具、业务数据库或定制扫描程序高速导入:

bash
generate_resp_stream | redis-cli \
  -h dragonfly-target -p 6379 \
  --pipe --pipe-timeout 30

它不是 redis-cli --scan | redis-cli --pipe 直接拼起来就能搬完整数据。SCAN 只给 Key,仍需按 Type 导出 Value、TTL 和复合结构;迁移期间源端变化还要另做 CDC。DUMP/RESTORE 可以保留序列化对象与 TTL,但版本兼容、吞吐和错误重试需要验证。

应用层重放最灵活:从权威数据库重建缓存、消费 Kafka/Binlog 重放,或让新旧缓存并行 Warm-Up。对纯缓存来说,这通常比复制一堆即将过期的旧 Key 更干净。

8.4 校验不是比较 DBSIZE 就结束

DBSIZE 相等仍可能全部错。过期时机、迁移并发和 Lazy Expiration 会让 Key Count 自然漂移;反过来,相同 Count 也可能 Value 不同。建议分层校验:

  1. 每个 DB/Slot 的 Key Count 与类型分布;
  2. 按 Key Hash 稳定抽样,比较 TYPE、Value、集合成员与 Score;
  3. TTL 允许迁移耗时造成的合理误差,但永久 Key 不能意外获得 TTL;
  4. 订单计数、队列长度、幂等集合等业务不变量;
  5. Lua/事务在并发冲突下的最终状态;
  6. Full Restart、Replica Promotion、Snapshot Restore 后再次校验。

对巨大 Hash/ZSet,不要一次拉全量导致校验程序自己 OOM;使用 HSCAN/ZSCAN 分批计算可交换的 Digest,同时保存重复处理能力。若数据敏感,Digest 与抽样日志也要纳入访问控制。

8.5 灰度、回滚与连接池

最安全的切换不是 DNS 瞬间全量。可以按租户、业务或只读流量逐步导向 Dragonfly,观察错误与 P99;写流量迁移则需要一个明确的 Single Writer Boundary。回滚方案必须回答:切到 Dragonfly 后产生的新写怎样回 Redis?如果没有反向复制或业务变更日志,所谓「一键回滚」只会回到旧数据。

客户端配置也会影响结果。Redis Cluster 迁到 Dragonfly Emulated Mode 后,旧 Client 可能仍按 Slot 建许多连接;Dragonfly 单节点吞吐很高,但连接数、Pipeline 和 Backpressure 需要重新调优。不要沿用「每个 Redis Shard 100 个连接」并乘上旧节点数,否则新实例会收到大量没有必要的连接。

上线至少分四个阶段:

text
Shadow Read(不影响用户)
    → 1% 可回退流量
    → 10% / 30% / 50%,每档覆盖一个业务峰值
    → 100% 但保留源端只读与备份
    → 经过恢复演练和观察窗后退役旧集群

8.6 Prometheus 与 Grafana:名字能对上,含义未必对上

Dragonfly 默认可在主端口的 /metrics 暴露 Prometheus 格式指标,Kubernetes 场景也可通过 Admin Port 抓取;官方提供 Grafana Stack 示例。Dragonfly Monitoring 文档提醒,部分 Batch I/O Counter 只在 INFO stats,尚未全部暴露到 Prometheus。

不要直接把 Redis Exporter Dashboard 改个 Data Source 就宣布完成。至少建立以下映射:

SLIRedis 常见来源Dragonfly 常见来源告警含义
可用性up、连接/拒绝数/metrics、连接/拒绝数实例能否接受请求
饱和度CPU 主线程、命中率、Eviction每线程 CPU、Eviction、Shard 指标是否接近容量上限或热点 Shard
内存used_memory、RSS、FragmentationUsed Memory、RSS、预算Live Bytes 与 RSS 是否持续分叉
延迟Latency Histogram/客户端客户端 Histogram + Server 指标必须按命令与成功/失败分组
复制Offset、Lag、Link Statusdragonfly_connected_replica_lag_records单位是 Record,不要误当 Byte/秒
快照Last Save、Duration、COWSnapshot 状态、Duration、错误备份是否真的成功且可恢复

Prometheus 指标名和 Label 在快速迭代项目中可能变化,Dashboard 应锁定 Dragonfly Version,并为 Missing Series 写告警。/metrics 与管理端口不应裸露公网;监控系统只需读权限,TLS、ACL 与网络策略要一起配置。

推荐的告警不是「CPU > 80%」一个阈值,而是组合条件:P99 上升 + 单 Shard 饱和、Eviction 加速 + Hit Ratio 下跌、Replica Lag 持续增长 + Backlog 逼近窗口、Snapshot 连续失败 + 最近成功备份过旧。组合信号更接近用户真正感受到的风险。

8.7 上线前故障演练清单

性能压测通过后,还要主动制造麻烦:

  • Kill Primary,测故障检测、提升、客户端重连与已确认写丢失量;
  • 断开 Replica 网络超过 Backlog 窗口,观察 Full Sync 对前台的影响;
  • 在峰值写入时触发 Snapshot,记录 P99.9 与 RSS;
  • 注入磁盘满、对象存储超时和证书过期;
  • 写入一个接近上限的大 String/List/Hash,再删除、复制和备份;
  • 制造一个 40% 流量的 Hot Key,观察 Owner Shard 而不是平均 CPU;
  • 重启并从最近备份恢复,验证 RPO/RTO 和业务不变量;
  • 对多节点 Dragonfly,故意下发不一致 Cluster Config,验证控制面保护。

只有恢复演练成功,备份才是备份;只有故障切换时的写丢失量经过测量,高可用才是工程承诺。

结语:Dragonfly 改写了单机边界,没有取消分布式系统

Redis 的单线程执行并非落后时代的偶然,而是一种强有力的复杂度管理:用串行化消除大量锁,让命令原子性、数据结构与故障分析保持直观。它的天花板也来自同一个决定。I/O Threads 能减轻网络开销,却不能让普通命令同时修改键空间;Redis Cluster 能扩展吞吐,却把事务边界、重定向、迁槽和故障转移交给多节点系统。

Dragonfly 的价值在于,它没有只给 Redis 套一个线程池。它从数据所有权开始重建引擎:每线程 Shard、Fiber 调度、消息传递、VLL 事务、Segment 化 Dashtable 和 Entry 级 Forkless Snapshot 互相咬合。Dashtable 减少 Cache Miss 与索引税,Shared-Nothing 让不同 Key 在不同核心执行,Fiber 避免协调等待阻塞 OS 线程,VLL 又把多 Key 原子性补回来。正是这条机制链,而不是一句「C++ 更快」,解释了它为何能在 64 核机器上把单实例吞吐推到数百万 QPS。

但工程取舍从不凭空消失。Dragonfly 当前没有 AOF,Redis Module 不能直接加载,复杂 Lua 要处理 Key 声明与 Lua 版本,多节点开源部署需要外部控制面。少节点降低运维表面积,也扩大单节点故障域。厂商基准证明了潜力,却不能替代带持久化、复制、真实 Value 分布和目标 P99 的容量测试。

如果你的问题是「Redis Cluster 太多、单机 CPU 已顶住、RAM Metadata 很贵、负载主要是可重建缓存」,Dragonfly 值得认真试,甚至可能显著简化架构。如果你的问题是「大量自定义 Module 与动态 Lua、需要成熟 AOF 恢复链、团队已经有稳定 Redis/Valkey 平台」,继续使用 Redis/Valkey 很可能更可靠。

最终的选择不应该是「新一代」对「旧时代」,而是两种复杂度放在哪里:Redis 把多核扩展复杂度放到节点之间;Dragonfly 把它放进单进程的 Shard、Fiber 与事务协调器。先找出你现在真正付钱的复杂度,再决定是否值得交换。

作者Aoang
发布于2025-08-31
更新于2025-08-31
许可协议CC BY-NC-SA 4.0
转载或引用本文时请遵守许可协议,并注明出处。