编程 Valkey 9.0 深度拆解:原子槽迁移 ASM 如何终结 Redis Cluster 的扩缩容噩梦——从 -ASK 重定向到 AOF 流式交接

2026-08-11 02:48:51 +0800 CST views 7

Valkey 9.0 深度拆解:原子槽迁移 ASM 如何终结 Redis Cluster 的扩缩容噩梦——从 -ASK 重定向到 AOF 流式交接

分叉两年后,Valkey 交出了 9.0;几个月后,Redis 也把同一个机制合进了主干。这不是巧合,而是一个困扰 Redis Cluster 近十年的设计债,终于到了必须还的时候。本文从「传统槽迁移为什么是错的」讲起,拆到 ASM 的状态机、哈希字段过期的内存布局、集群编号数据库的命名空间语义,最后给出一套可落地的迁移与压测方案。


零、先说一个凌晨三点的故事

如果你运维过 Redis Cluster,下面这个场景应该不陌生。

大促前扩容,从 6 主 6 从扩到 12 主 12 从。你写好脚本,redis-cli --cluster reshard,一边喝咖啡一边看进度条。二十分钟后,业务群开始报警:

ERR Error in execution of command 'MGET': CROSSSLOT / TRYAGAIN
redis: ASK 5798 10.0.3.17:6379
context deadline exceeded

你查了半天,发现问题出在三个地方:

  1. 某个 Java 老服务用的 Jedis 版本对 -ASK 重定向的处理有 bug,重定向后没有先发 ASKING,请求被目标节点拒了;
  2. 一个批量接口用 MGET 一次拉 50 个 key,迁移期间这批 key 横跨新旧两个节点,服务端直接返回 TRYAGAIN,业务没做重试;
  3. 有个 12GB 的大 Hash 正好在迁移的槽里,MIGRATE 命令把源节点阻塞了 4 秒,那 4 秒里整个节点的 P99 飙到秒级。

你手动把 reshard 停了,结果更糟:集群卡在中间态,有的 key 在源节点,有的在目标节点,CLUSTER SETSLOT 的 importing/migrating 标记残留在几个节点上,副本节点对这一切一无所知。你只能一个节点一个节点地 CLUSTER SETSLOT <slot> NODE <target-id> 手工收尾。

这不是你的锅。这是 Redis Cluster 从 3.0 时代就带进来的设计债。

Valkey 9.0 的 Atomic Slot Migration(原子槽迁移,下称 ASM)就是来还这笔债的。而更有意思的是:几个月后 Redis 8.4 也合入了自己的 ASM 实现(PR #14414)。两个分叉出去的项目,在同一个问题上给出了同一个方向的答案——这件事本身,比任何一方的 release notes 都更能说明问题。


一、背景:一次许可证变更,两条技术路线,一个共同的痛点

1.1 时间线速览

  • 2024 年 3 月:Redis 宣布许可证从 BSD 改为 RSALv2 + SSPLv1 双许可。
  • 2024 年 4 月:Linux 基金会从 Redis 7.2.4 拉出分支,Valkey 诞生,继续走 BSD 3-Clause。AWS、Google Cloud、Oracle、Ericsson 等入局,国内腾讯云也是创始成员之一。
  • 2024 年下半年:Valkey 8.0 发布,主打异步 I/O 线程模型和 per-slot 字典重构,把「多核利用率」这个 Redis 单线程时代的老问题往前推了一大截。
  • 2025 年底:Valkey 9.0 发布。三大件:原子槽迁移哈希字段过期集群模式下的编号数据库。官方口径是可扩展到 2000 节点、集群总吞吐超过 10 亿 QPS。
  • 2026 年 5 月:Valkey 9.1 发布,修了三个 CVE,加了 cluster bus 流量指标,并用「渐进式页释放」解决 rehash 期间的延迟尖刺。有意思的是,这个版本里相当一批 backport 的 bug fix 是由 AI Agent 自动完成的。
  • 2026 年 8 月:腾讯云成为国内首家全面支持 Valkey 9.0 的云厂商,一次性从 8.0 跨到 9.0,吃下 8.1 和 9.0 两代能力。

1.2 为什么槽迁移是「所有人的痛点」

Redis Cluster 的分片模型极其简洁:所有 key 通过 CRC16(key) mod 16384 映射到 16384 个槽(slot),每个节点负责一段槽。这个设计的优点是无中心元数据服务——路由信息通过 gossip 协议(cluster bus)在节点间扩散,客户端本地缓存一份槽表即可。

但简洁是有代价的。当你要扩容,就必须把一部分槽从旧节点搬到新节点。而搬迁的过程,就是路由信息不一致的过程

这个「不一致窗口」到底有多难受?我们把它拆开看。


二、传统槽迁移:一份长达十年的技术债清单

2.1 老流程长什么样

标准的 resharding 流程是这样的(这也是 redis-cli --cluster reshard 底下真正在做的事):

# 1. 目标节点声明「我要接收这个槽」
redis-cli -h $TARGET -p 6379 CLUSTER SETSLOT 5798 IMPORTING $SOURCE_NODE_ID

# 2. 源节点声明「我要交出这个槽」
redis-cli -h $SOURCE -p 6379 CLUSTER SETSLOT 5798 MIGRATING $TARGET_NODE_ID

# 3. 循环:从源节点取出这个槽里的 key,一批一批搬
while :; do
  KEYS=$(redis-cli -h $SOURCE CLUSTER GETKEYSINSLOT 5798 100)
  [ -z "$KEYS" ] && break
  # MIGRATE 是同步阻塞的:DUMP -> RESTORE -> DEL
  redis-cli -h $SOURCE MIGRATE $TARGET_IP 6379 "" 0 5000 KEYS $KEYS
done

# 4. 通知集群里所有节点:这个槽归目标节点了
for node in "${ALL_NODES[@]}"; do
  redis-cli -h $node CLUSTER SETSLOT 5798 NODE $TARGET_NODE_ID
done

看起来还行?问题全在细节里。

2.2 七宗罪逐条拆解

罪一:客户端必须实现 -ASK 协议,而很多客户端实现得不对

在第 3 步的漫长过程中,槽 5798 处于「半迁移」状态。源节点的行为是:

  • key 还在我这儿 → 正常执行;
  • key 不在我这儿(可能已经搬走了,也可能压根不存在)→ 返回 -ASK <slot> <target_ip:port>

客户端收到 -ASK 后,必须先向目标节点发一个 ASKING 命令(这是一次性授权,告诉目标节点「下一条命令你别管槽归属,直接执行」),然后再发真正的命令。

用 Go 手写一遍你就知道有多啰嗦:

// 简化版的 ASK 重定向处理,真实客户端还要处理连接池、超时、槽表刷新
func (c *ClusterClient) doWithRedirect(ctx context.Context, cmd Cmder) error {
    node := c.slotNode(cmd.Slot())
    askingNext := false

    for attempt := 0; attempt < c.maxRedirects; attempt++ {
        conn, err := node.pool.Get(ctx)
        if err != nil {
            return err
        }

        // 关键:ASK 重定向必须先发 ASKING,且必须走同一条连接
        if askingNext {
            if err := conn.Send(ctx, NewCmd("ASKING")); err != nil {
                conn.Close()
                return err
            }
            askingNext = false
        }

        err = conn.Process(ctx, cmd)
        conn.Release()

        if err == nil {
            return nil
        }

        switch e := parseRedirect(err).(type) {
        case *MovedError:
            // MOVED 是永久性的:槽已经彻底归属新节点,刷新本地槽表
            c.reloadSlotsAsync()
            node = c.nodeByAddr(e.Addr)
        case *AskError:
            // ASK 是临时性的:不要刷新槽表!只对本次请求生效
            node = c.nodeByAddr(e.Addr)
            askingNext = true
        default:
            return err
        }
    }
    return ErrTooManyRedirects
}

注意两个极易写错的点:

  1. ASKING 和真实命令必须在同一条连接上。如果你的连接池在两次调用之间把连接还回去了,ASKING 的一次性授权就丢了,目标节点会返回 -MOVED 打回源节点,客户端在两个节点之间来回弹,直到耗尽 maxRedirects
  2. 收到 -ASK 绝对不能刷新槽表ASK 是临时重定向,槽的归属还没变。有些早期客户端把 ASK 当 MOVED 处理,结果本地槽表被污染,迁移结束后又要靠 MOVED 纠回来,中间产生大量无效跳转。

现实是:主流客户端(Lettuce、go-redis、redis-py、StackExchange.Redis)现在基本都对了,但你的技术栈里总有那么一两个用了五年没升级的老库,或者某个团队自己封装的「轻量客户端」。迁移期间暴露的,正是这些平时看不见的兼容性地雷。

罪二:多 key 操作在迁移期间会直接失败

MGET k1 k2 k3 要求所有 key 在同一个槽(否则 CROSSSLOT)。但即使在同一个槽,如果这个槽正在迁移,且这些 key 一部分已搬走、一部分没搬,源节点无法完成操作,返回:

TRYAGAIN Multiple keys request during rehashing of slot

同样的问题会出现在 DEL k1 k2SUNIONSTOREMSET,以及任何多 key 的 Lua 脚本上。

-- 这段库存扣减脚本,在槽迁移期间可能直接抛 TRYAGAIN
-- KEYS[1] = stock:{sku_1001}
-- KEYS[2] = reserved:{sku_1001}
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local need  = tonumber(ARGV[1])
if stock < need then
  return -1
end
redis.call('DECRBY', KEYS[1], need)
redis.call('INCRBY', KEYS[2], need)
return stock - need

哪怕你老老实实用了 hash tag {sku_1001} 保证同槽,迁移期间照样会 TRYAGAIN。业务侧唯一的解法是「捕获 TRYAGAIN 并退避重试」,但这意味着每一个用了多 key 操作的调用点都要加重试逻辑——在一个几百个微服务的体系里,这基本不可能全覆盖。

罪三:迁移是逐 key 的,中断即中间态

MIGRATE 每次搬一批 key。搬到一半源节点 OOM、目标节点崩溃、网络抖动、或者你 Ctrl+C 了脚本——集群就停在中间态:

  • 部分 key 在源,部分在目标;
  • 源节点的 MIGRATING 标记还在,目标节点的 IMPORTING 标记还在;
  • 集群拓扑视图里这个槽的归属没变,但实际数据已经分裂。

恢复只能靠人。而且如果此时目标节点内存不够,RESTORE 失败,你会看到 key 在源节点被 DEL 了但目标节点没写进去——数据真的会丢(取决于 MIGRATE 的实现细节和失败时机,这是最让人后背发凉的部分)。

罪四:副本节点完全不知情

MIGRATE 在源节点上表现为 DUMP + RESTORE + DEL,其中 DEL 会通过复制流传给副本。但「这个槽正在迁移」这个状态本身,副本是不知道的。

于是:迁移进行到一半,源节点主挂了,副本被提升为主。新主不知道有迁移在进行,MIGRATING 标记丢失,它认为整个槽都是自己的——但槽里已经有一半 key 被搬到目标节点去了。集群进入一个逻辑上自洽、事实上错误的状态:客户端访问已迁走的 key,新主返回 nil(因为本地真的没有),而不是 -ASK

这是静默数据丢失,比报错可怕得多。

罪五:大 Key 是延迟炸弹

MIGRATE 是同步阻塞的。搬一个 12GB 的 Hash 意味着:

  • 源节点:DUMP 序列化整个对象,主线程阻塞;
  • 网络:一次性传输 12GB;
  • 目标节点:RESTORE 反序列化整个对象,主线程阻塞。

这三段时间加起来可能是几秒到几十秒。在这期间,源节点和目标节点上所有其他 key 的请求全部排队。你的监控上会看到一个漂亮的延迟尖峰,而 CPU 使用率却不高——因为大家都在等一个 memcpy。

罪六:性能低下

逐 key 迁移,每个 key 一轮 DUMP/RESTORE/DEL,外加协议开销。对一个装了几百万小 key 的槽,整体迁移时间是以小时计的。而 Redis 8.4 的官方数据是:ASM 相比传统方式,槽迁移速度最高可提升约 30 倍。这个数量级的差距,来自「批量流式传输」对「逐条 RPC」的碾压。

罪七:没有可观测性

传统迁移没有统一的进度视图。你想知道「还剩多少」,只能自己去 CLUSTER COUNTKEYSINSLOT 轮询。想知道「迁移健不健康」,只能看日志。想中途取消,只能杀脚本然后手工收尾。


三、ASM 的核心机制:从「边迁边切」到「先复制、再交接」

3.1 一句话概括

传统方式:把 key 一个一个搬过去,搬的过程中槽归属就在变。
ASM:先把整个槽的数据完整复制到目标节点(期间源节点照常服务),等数据追平后,用一次原子操作完成归属权交接。

Valkey 开源负责人 Kyle Davis 的原话可以直接引用:

在 Valkey 中,所有键会被映射为 16,384 个槽位之一,每个节点负责一个或多个槽位。在 Valkey 9.0 中,迁移不再是按键迁移,而是一次迁移整个槽位,并通过 AOF 格式进行原子移动。

「通过 AOF 格式」是这句话里信息量最大的部分。

3.2 为什么是 AOF 格式?——复用复制协议的巧劲

这是整个设计里最聪明的一步。仔细想想:把一批数据从节点 A 完整地、增量地、保序地同步到节点 B,同时 A 还在持续接收写入——这个问题 Redis 已经解决了十年了,它叫主从复制

主从复制的机制是:

  1. 全量阶段:主节点 fork 出子进程生成 RDB 快照,发给从节点;
  2. 增量阶段:全量期间产生的新写入,以命令流(本质就是 AOF 格式的命令序列)的形式写入 replication buffer,快照传完后继续追发;
  3. 追平:从节点应用完所有积压命令,进入实时同步状态。

ASM 干的事情几乎一模一样,只是把「整库」换成了「槽的子集」:

  1. 快照阶段:源节点针对待迁移槽生成一份数据快照(因为 Valkey 8.0 引入了 per-slot 的字典结构,按槽切分数据的成本大大降低——这是 8.0 的架构改造在 9.0 收获的红利);
  2. 流式阶段:快照传输期间,落在该槽上的新写入被编码成 AOF 命令流,追加发送给目标节点;
  3. 追平与交接:当目标节点的 backlog 追到足够小时,源节点短暂暂停该槽的写入(毫秒级),把最后一点残留命令发完,然后原子地翻转槽归属,广播新拓扑,恢复服务。

用状态机描述大概是这样:

┌──────────┐   开始迁移    ┌──────────────┐
│   IDLE   │ ────────────> │  SNAPSHOTTING │  源节点生成槽快照并发送
└──────────┘               └──────┬───────┘
     ▲                            │ 快照发送完毕
     │                            ▼
     │                     ┌──────────────┐
     │                     │  STREAMING   │  增量 AOF 命令流持续追发
     │                     └──────┬───────┘  源节点照常读写,客户端零感知
     │                            │ backlog < 阈值
     │                            ▼
     │                     ┌──────────────┐
     │  失败/取消          │   PAUSING    │  暂停该槽写入(毫秒级)
     │  (数据丢弃,       └──────┬───────┘  发送残留命令
     │    源节点无损)            │ 完全追平
     │                            ▼
     │                     ┌──────────────┐
     └─────────────────────│  FINALIZING  │  原子翻转归属 + gossip 广播
                           └──────┬───────┘  源节点释放该槽数据
                                  ▼
                           ┌──────────────┐
                           │  COMPLETED   │
                           └──────────────┘

3.3 这个设计解决了什么

把七宗罪逐条对回来:

老问题ASM 如何解决
-ASK 重定向交接前槽 100% 属于源节点,交接后 100% 属于目标节点。中间态不对外暴露,客户端只会看到一次 MOVED(正常的拓扑变更),不需要 ASKING
多 key TRYAGAIN同上。槽内数据在任一时刻都完整地在同一个节点上,MGET/Lua 不会跨节点
中断留下中间态交接前失败 → 目标节点丢弃已接收数据,源节点毫发无损,天然幂等可重试
副本不知情迁移状态进入复制流与集群元数据,副本感知迁移进度;发生 failover 时新主能接管或安全回滚
大 Key 阻塞数据走流式复制通道而非同步 MIGRATE,大对象被拆成命令流分批传输,不再一次性阻塞主线程
速度慢批量流式 vs 逐条 RPC,Redis 侧给出的数据是最高约 30 倍提升
无可观测性提供迁移任务的状态查询与取消能力,运维从「杀脚本」升级为「发命令」

3.4 操作层面长什么样

⚠️ 下面的命令形态请以你实际使用的版本的官方文档为准。Valkey 与 Redis 两边的 ASM 在语义上高度一致,但命令名和参数并不完全相同,且在 9.x 小版本迭代中仍有调整。这里重点是理解语义。

概念上,ASM 把运维动作从「一堆命令的编排」收敛成了「提交一个迁移任务」:

# 概念示意:把一段槽区间迁移到目标节点,服务端自行完成全流程
valkey-cli -h $SOURCE CLUSTER MIGRATESLOTS \
    SLOTSRANGE 5000 5999 NODE $TARGET_NODE_ID

# 查看迁移进度(关注状态、已同步字节数、剩余 backlog)
valkey-cli -h $SOURCE CLUSTER SLOT-STATS  # 或对应的迁移状态查询命令

# 中途取消:安全,目标节点丢弃临时数据
valkey-cli -h $SOURCE CLUSTER CANCELSLOTMIGRATIONS

对比一下老流程那个几十行的 while 循环,差别不只是命令行数,而是责任边界的转移:以前迁移的正确性由运维脚本 + 客户端库共同保证,现在由服务端保证。

这是分布式系统演进里一个很典型的模式——把复杂度从边缘收回内核。Kafka 从 ZooKeeper 走向 KRaft 是这个模式,MySQL 从「应用层分库分表」走向原生分区也是这个模式。凡是「需要客户端配合才能保证正确性」的协议,最终都会被「服务端自洽」的方案取代。

3.5 一个必须泼的冷水:ASM 不是免费的

ASM 的代价是内存。在 STREAMING 阶段,这个槽的数据同时存在于源节点和目标节点上。如果你一次性迁移 4000 个槽(1/4 的数据),那么在迁移完成前,目标节点需要额外承载这 1/4 的数据,而源节点一点都没释放。

所以扩容规划要改:

传统方式的内存峰值 ≈ max(源节点内存, 目标节点内存)
ASM 的内存峰值    ≈ 源节点内存 + 迁移批次数据量(在目标节点上)

实践建议:分批迁移,每批控制在总数据量的 5%~10%。 别一把梭。

另外,STREAMING 阶段会占用额外的网络带宽和 CPU(序列化/反序列化)。在带宽是瓶颈的环境(比如跨可用区),要评估迁移速率对正常业务流量的挤占。


四、哈希字段过期:终结「为了 TTL 而拆 key」的时代

4.1 老问题:Hash 只能整体过期

Redis/Valkey 的 EXPIRE 作用在 key 上。一个 Hash 里有 1000 个 field,你要么让这 1000 个 field 一起过期,要么一个都不过期。

但业务需求经常是字段级的:

  • 购物车:每个 SKU 加入时间不同,超过 7 天的单品自动移除;
  • 会话属性session:uid 里,csrf_token 30 分钟过期,last_active 永不过期,temp_captcha 60 秒过期;
  • 风控滑窗risk:{uid} 里每个行为标记有各自的观察期;
  • 配置缓存:一个业务域的配置项批量存在一个 Hash 里,但不同配置项的刷新周期不同。

4.2 过去的三种解法,都很难受

解法 A:拆成独立 key

# 一个购物车 20 个 SKU,就是 20 个 key
SET cart:{uid_1001}:sku_2001 "{\"qty\":2,\"price\":399}" EX 604800
SET cart:{uid_1001}:sku_2002 "{\"qty\":1,\"price\":1299}" EX 604800

代价:

  • key 数量爆炸,每个 key 的 robj + dictEntry + expires 字典条目开销约 50~100 字节,20 个 SKU 就是 1~2KB 的纯管理开销;
  • 想拿整个购物车必须 SCAN 或者维护一个索引 Set,一次读变多次读;
  • 集群下必须用 hash tag 保证同槽,否则连 MGET 都用不了。

解法 B:ZSET 存过期时间 + 定时清理

-- 写入:数据进 Hash,过期时间进 ZSET
redis.call('HSET', 'cart:'..uid, sku, payload)
redis.call('ZADD', 'cart_exp:'..uid, expire_at_ms, sku)

-- 读取:先剔除过期字段
local now = tonumber(ARGV[1])
local expired = redis.call('ZRANGEBYSCORE', 'cart_exp:'..uid, '-inf', now)
if #expired > 0 then
  redis.call('HDEL', 'cart:'..uid, unpack(expired))
  redis.call('ZREMRANGEBYSCORE', 'cart_exp:'..uid, '-inf', now)
end
return redis.call('HGETALL', 'cart:'..uid)

代价:

  • 双写一致性问题(Lua 能解决原子性,但代码复杂度上去了);
  • 内存翻倍(ZSET 里存了一份 field 名);
  • 没人访问的 key 永远不会被清理,需要额外的后台扫描任务;
  • 每次读都带一次写(HDEL/ZREM),把读操作变成了写操作,在主从架构下这意味着从库不能承担这个读。

解法 C:应用层过滤

读出来在业务代码里判断时间戳。内存永远不释放,纯粹是把问题往后拖。

4.3 Valkey 9.0 的解法:原生字段级 TTL

命令族与 Redis 7.4 引入的那一套基本对齐:

# 给 field 设置 TTL(秒);返回值数组对应每个 field 的结果码
HEXPIRE cart:{uid_1001} 604800 FIELDS 2 sku_2001 sku_2002

# 毫秒级 / 绝对时间戳版本
HPEXPIRE   cart:{uid_1001} 60000        FIELDS 1 temp_lock
HEXPIREAT  cart:{uid_1001} 1786000000   FIELDS 1 sku_2003
HPEXPIREAT cart:{uid_1001} 1786000000000 FIELDS 1 sku_2004

# 查询剩余 TTL
HTTL  cart:{uid_1001} FIELDS 2 sku_2001 sku_2002
HPTTL cart:{uid_1001} FIELDS 1 sku_2001

# 移除某个 field 的 TTL,让它变成永久
HPERSIST cart:{uid_1001} FIELDS 1 sku_2001

# 读取并同时刷新 TTL(滑动过期)/ 读取并删除
HGETEX  session:{uid_1001} EX 1800 FIELDS 1 csrf_token
HGETDEL session:{uid_1001} FIELDS 1 one_time_code

返回值语义要记住(这是最容易踩的坑):

返回码含义
-2key 不存在,或 field 不存在
0因为 NX/XX/GT/LT 条件不满足,TTL 没有被设置
1TTL 设置成功
2TTL 是过去的时间,field 被立即删除

第 4 条特别阴险:HEXPIRE key 0 FIELDS 1 f 等价于 HDEL key f,不是「立即过期但还能读一次」。

4.4 用它重写购物车

package cart

import (
    "context"
    "encoding/json"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

const cartTTL = 7 * 24 * time.Hour

type Item struct {
    SKU   string `json:"sku"`
    Qty   int    `json:"qty"`
    Price int64  `json:"price"` // 分
}

type Store struct{ rdb redis.UniversalClient }

func key(uid int64) string {
    // hash tag 保证同一用户的所有数据落在同一个槽
    return fmt.Sprintf("cart:{u%d}", uid)
}

// Add 加入购物车:写入 + 设置字段级 TTL,一次 pipeline 搞定
func (s *Store) Add(ctx context.Context, uid int64, it Item) error {
    payload, err := json.Marshal(it)
    if err != nil {
        return err
    }
    k := key(uid)

    pipe := s.rdb.TxPipeline()
    pipe.HSet(ctx, k, it.SKU, payload)
    pipe.HExpire(ctx, k, cartTTL, it.SKU)
    // 兜底:整个 key 也给一个更长的 TTL,防止僵尸 key
    pipe.Expire(ctx, k, cartTTL+24*time.Hour)
    _, err = pipe.Exec(ctx)
    return err
}

// List 读取购物车:不需要任何过滤逻辑,过期字段服务端已经清掉了
func (s *Store) List(ctx context.Context, uid int64) ([]Item, error) {
    m, err := s.rdb.HGetAll(ctx, key(uid)).Result()
    if err != nil {
        return nil, err
    }
    items := make([]Item, 0, len(m))
    for _, v := range m {
        var it Item
        if err := json.Unmarshal([]byte(v), &it); err != nil {
            continue // 脏数据跳过,不要因为一条坏数据毁掉整个购物车
        }
        items = append(items, it)
    }
    return items, nil
}

// Touch 续期:用户又看了一眼这个商品,把 TTL 续上
func (s *Store) Touch(ctx context.Context, uid int64, skus ...string) error {
    return s.rdb.HExpire(ctx, key(uid), cartTTL, skus...).Err()
}

对比解法 B,代码量少了一半,读路径从「读+写」变回「纯读」,内存里少了一份 field 名的副本。

4.5 实现层面:主动过期是怎么做的

Valkey 采用的是惰性过期 + 主动过期的组合,和 key 级 TTL 的思路一致,但实现上有额外考量。

惰性过期:访问 field 时检查 TTL,过期则删除并当作不存在。这部分很直接。

主动过期:难点在于「怎么快速找到下一个要过期的 field」。如果每次后台扫描都要遍历所有 Hash 的所有 field,成本是不可接受的。

工程上的做法是维护一个全局的、按最近过期时间排序的索引——每个「含有带 TTL field 的 Hash」在这个索引里占一个条目,排序键是这个 Hash 内部最早的那个 field 过期时间。后台任务只需要看索引头部:

/* 概念性伪代码,真实实现请以 Valkey 源码为准 */
void activeExpireHashFields(server_t *server, mstime_t now, mstime_t budget_us) {
    mstime_t start = ustime();

    while (ustime() - start < budget_us) {
        /* 索引按「Hash 内最早过期时间」排序,只看头部 */
        hashExpireEntry *e = hashExpireIndexPeekMin(server->hash_expires);
        if (e == NULL || e->min_expire_at > now) {
            break;  /* 头部都没到期,后面的更不可能到期,直接退出 */
        }

        robj *o = lookupKeyReadNoTouch(e->db, e->key);
        if (o == NULL || o->type != OBJ_HASH) {
            hashExpireIndexRemove(server->hash_expires, e);
            continue;
        }

        /* 删除这个 Hash 里所有已过期的 field,
           并返回剩余 field 中最早的过期时间 */
        mstime_t next_min = hashDeleteExpiredFields(o, now);

        if (next_min == HASH_NO_EXPIRE) {
            /* 没有带 TTL 的 field 了,移出索引 */
            hashExpireIndexRemove(server->hash_expires, e);
            /* Hash 空了就把整个 key 删掉 */
            if (hashTypeLength(o) == 0) {
                dbDelete(e->db, e->key);
                notifyKeyspaceEvent(NOTIFY_GENERIC, "del", e->key, e->db->id);
            }
        } else {
            /* 更新索引位置 */
            hashExpireIndexUpdate(server->hash_expires, e, next_min);
        }
    }
}

关键设计点有三个:

  1. 索引粒度是 Hash 而不是 field。如果按 field 建索引,一个有百万 field 的 Hash 会往索引里塞百万个条目,内存和维护成本都爆炸。按 Hash 建索引,索引大小只和「含 TTL 的 Hash 数量」成正比。
  2. 有时间预算(budget)。主动过期任务在 serverCron 里跑,必须有硬性时间上限,否则会拖慢主线程。这和 key 级过期的 ACTIVE_EXPIRE_CYCLE 是一个思路。
  3. 共享的过期任务。AWS 工程师在实现说明里强调了这一点:字段级过期复用了同一套主动过期调度,而不是新起一个独立的扫描循环。这样在高写入压力下,内存回收仍然高效,且不会和 key 级过期抢 CPU 时间片。

官方给出的基准测试结论是:字段级过期的额外内存开销可控,指令吞吐未受影响。这个结论值得信任的原因在于——如果实现得不好,光是给每个 field 加一个 8 字节的时间戳,一个百万 field 的 Hash 就要多 8MB,再加上 listpack 编码被破坏导致降级为 hashtable,内存可能翻好几倍。

4.6 编码层面的坑:listpack 会不会退化

小 Hash 默认用 listpack(紧凑数组)编码,超过 hash-max-listpack-entries(默认 128)或 hash-max-listpack-value(默认 64 字节)才转成 hashtable。

引入 field TTL 后,listpack 需要额外存一个 TTL 字段——实现上通常是一个变体编码(可以理解为「带 TTL 的 listpack」)。这意味着:

# 检查你的 Hash 到底是什么编码
127.0.0.1:6379> OBJECT ENCODING cart:{u1001}
"listpackex"      # 带 TTL 的 listpack 变体

127.0.0.1:6379> HEXPIRE cart:{u1001} 3600 FIELDS 1 sku_2001
1) (integer) 1

127.0.0.1:6379> MEMORY USAGE cart:{u1001}
(integer) 312

实践建议:上线前,用你真实的数据分布跑一遍 MEMORY USAGE 对比。特别是那些 field 数量在 100~200 之间徘徊的 Hash,加了 TTL 之后可能刚好越过阈值触发编码升级,内存增长会比预期陡。


五、集群模式下的编号数据库:一个被低估的特性

5.1 背景

单机 Redis 默认有 16 个 database(SELECT 0 ~ SELECT 15),本质是一个命名空间机制。但集群模式下只能用 db 0,这是从 Redis Cluster 诞生起就有的限制。

原因不难理解:Cluster 的槽映射是 CRC16(key) mod 16384,没有 db 维度。如果允许多 db,那么 db 0 的 foo 和 db 1 的 foo 会落在同一个槽、同一个节点,但它们是两个不同的对象——槽迁移、复制、持久化都要处理这个维度,实现复杂度陡增。

Valkey 9.0 把这个限制去掉了。

5.2 它到底解决了什么

Kyle Davis 的说法是「把编号数据库视为一种命名空间机制」,最直接的场景是:

需要逻辑上隔离数据,同时能够接受资源共享带来的影响。例如,将不同客户的数据分隔开,或在资源不成问题的情况下整合多个应用到同一个集群中。

翻译成大白话,两个典型场景:

场景一:SaaS 多租户

以前你有三个选择:

  • 每个租户一个集群 → 成本爆炸,一百个小客户要一百套集群;
  • 所有租户混在一个集群,用 key 前缀隔离 → tenant_1001:user:2001,前缀开销 + 误操作风险(一个 KEYS tenant_* 就能扫穿);
  • 前缀 + Lua 强制校验 → 每次访问多一层逻辑。

现在可以按租户分 db。注意:这不是安全隔离,是逻辑隔离。真正的安全隔离要配合 ACL:

# 给租户创建独立账号,限制只能访问自己的 db
ACL SETUSER tenant_1001 on \
    >SomeStrongPassword \
    ~* \
    +@read +@write -@dangerous \
    %RW~*

场景二:小应用合并

一个中型公司常常有二三十个「只用了几百 MB Redis」的小应用,每个都单独开集群极不划算。合并到一个集群,用 db 编号分开,运维成本大幅下降。

5.3 三个必须知道的坑

坑一:FLUSHDB 的杀伤范围变了,但 FLUSHALL 没变

在集群里,FLUSHDB 清空当前节点当前 db 的数据。如果你在多租户场景下误在错误的连接上执行……后果自负。生产环境应该用 ACL 直接禁掉 FLUSHDB/FLUSHALL/KEYS

ACL SETUSER app_user on >pwd ~* +@all -flushdb -flushall -keys -shutdown -debug

坑二:连接池的 SELECT 语义

绝大多数客户端连接池的实现是「借出连接 → 用完还回」。如果你在借出的连接上 SELECT 3,用完还回池子,下一个借到这条连接的人默认就在 db 3 上操作——除非客户端在归还时重置。

主流客户端的做法不一:有的在配置里指定 db,池内所有连接建立时就 SELECT;有的支持运行时切换但会污染连接。不要在业务代码里手动 SELECT,一律用「一个 db 一个客户端实例/连接池」。

// 正确:每个 db 一个独立的客户端
var (
    tenantA = redis.NewClusterClient(&redis.ClusterOptions{
        Addrs: addrs,
        // 注意:go-redis 的 ClusterOptions 传统上不支持 DB 字段,
        // 使用集群多 db 前务必确认你的客户端版本已支持
    })
)

// 错误示范:在共享连接池上手动切库
// conn.Do(ctx, "SELECT", 3)  // ← 连接归还后污染后续使用者

坑三:跨 db 操作依然不可用

SWAPDBMOVE、跨 db 的事务和 Lua,在集群模式下要么不支持,要么行为受限。把 db 当成完全独立的隔离单元来用,不要试图跨 db 做任何事。


六、性能提升从哪来:把「2000 节点、10 亿 QPS」拆开看

6.1 数字先放这

  • 社区口径:Valkey 9.0 可扩展至 2000 节点,集群总吞吐 超过 10 亿 QPS
  • 腾讯云基于 8.0 → 9.0 的实测口径:批量请求场景吞吐 最高 +40%,大数据量访问场景 最高 +20%,用户画像/UV 去重这类计算密集场景 部分操作最高 +200%
  • Valkey 9.1:通过「渐进式页释放」降低 rehash 期间的延迟尖刺(PR #3481)。

6.2 10 亿 QPS 是怎么算出来的

先做个除法:10 亿 ÷ 2000 = 50 万 QPS/节点

对一个现代服务器(比如 32 核、100Gbps 网卡),单实例 50 万 QPS 在纯 GET、pipeline 打满的情况下是可以达到的。所以这个数字不是营销数字,但它的前提条件很苛刻:

  • 必须用 pipeline 或多路复用,否则被 RTT 卡死;
  • 必须是简单命令(GET/SET 小 value);
  • 必须打开多线程 I/O;
  • 必须没有大 key、没有慢查询、没有 KEYS

这个数字的真正意义不是「你能跑到 10 亿」,而是「集群规模扩到 2000 节点时,架构本身不再是瓶颈」。

6.3 2000 节点为什么难?cluster bus 的 O(N²) 问题

Redis Cluster 用 gossip 协议维护拓扑。每个节点每秒会向随机几个节点发 PING,携带自己已知的部分节点信息。节点数 N 增大时:

  • 每个节点要维护 N-1 个 cluster bus 连接 → 连接数 O(N²)
  • gossip 消息里携带的节点信息量随 N 增长 → 单条消息变大
  • 故障检测需要多数节点达成 PFAIL → FAIL 共识 → 收敛时间变长

社区历来的经验值是「Redis Cluster 建议不超过 1000 节点」,实践中很多团队 300 节点就开始遇到 cluster bus 流量过大、拓扑收敛慢的问题。

Valkey 9.x 在这块的改进方向包括压缩 gossip 消息、优化连接管理、以及——这点很实用——9.1 加入了 cluster bus 网络流量指标(PR #3396)

# 现在你可以直接看到 cluster bus 到底吃了多少带宽
valkey-cli INFO stats | grep -i cluster
# 关注 cluster bus 的发送/接收字节数增长率

这是典型的「可观测性先行」:在优化一个东西之前,先让它可测量。以前 cluster bus 流量只能靠抓包估算,现在有了原生指标,容量规划才有依据。

6.4 rehash 延迟尖刺与渐进式页释放

这是 9.1 里一个很「工程师」的改进,值得单独说。

Redis/Valkey 的字典(dict)在扩容时用渐进式 rehash:分配一个 2 倍大的新表,然后在后续的每次操作中搬一小部分数据过去,避免一次性搬迁造成长时间阻塞。

释放旧表这一步一直是一次性的。当 rehash 完成,zfree(ht[0].table) 一把释放一个可能有几百 MB 的连续内存块。对于 jemalloc 来说,释放大块内存会触发 madvise(MADV_DONTNEED) 把物理页还给操作系统,这个系统调用的耗时和页数成正比——几百 MB 意味着上万个页,内核要遍历页表、更新 VMA、刷 TLB。

结果就是:监控上看到一个孤零零的延迟尖刺,找不到对应的慢查询,CPU 也不高。这种「幽灵尖刺」是最难排查的一类问题。

9.1 的解法是把页释放也做成渐进式的:分批把旧表的内存还给系统,每批控制在一个可接受的时间预算内。

/* 概念示意 */
static void incrementalPageRelease(dict *d, size_t bytes_budget) {
    size_t released = 0;
    while (d->pending_free != NULL && released < bytes_budget) {
        size_t chunk = MIN(d->pending_free_size - d->pending_free_offset,
                           PAGE_RELEASE_CHUNK);
        /* 只把这一小段还给 OS,而不是整块 */
        madviseDontNeed((char *)d->pending_free + d->pending_free_offset, chunk);
        d->pending_free_offset += chunk;
        released += chunk;

        if (d->pending_free_offset >= d->pending_free_size) {
            zfree(d->pending_free);
            d->pending_free = NULL;
        }
    }
}

如果你线上有那种「毫无规律、找不到慢查询、P999 偶发飙升」的现象,且实例内存在 GB 级别、key 数量在持续增长——这很可能就是 rehash 导致的。 这一条足以成为升级理由。

6.5 per-slot 字典:8.0 的地基,9.0 的红利

Valkey 8.0 做了一个架构上的大改:把「一个 db 一个大字典」改成「每个槽一个字典」。

这个改动当时的主要动机是多线程——按槽分片后,不同线程可以并行操作不同槽的数据,锁粒度从「整个 db」降到「单个槽」。

但它在 9.0 收获了一个意外红利:ASM 需要「按槽取出所有数据」这个能力。在老架构下,这意味着遍历整个 db 的字典,对每个 key 算一次 CRC16 判断是否属于目标槽——O(全库 key 数)。在 per-slot 架构下,这就是一次 O(1) 的字典查找加一次遍历——O(槽内 key 数)。

这是架构演进里很典型的复利效应:一个为 A 目的做的改造,几个版本后让 B 功能变得可行。反过来说,如果 8.0 没做这个改造,9.0 的 ASM 要么做不出来,要么性能不可接受。


七、迁移实战:从 Redis 7.x 到 Valkey 9.x

7.1 兼容性盘点

Valkey 从 Redis 7.2.4 分叉,协议层(RESP2/RESP3)、命令集、RDB/AOF 格式高度兼容。绝大多数客户端不需要改代码

需要注意的差异:

项目说明
模块生态RedisJSON、RediSearch、RedisTimeSeries 等属于 Redis Stack,走的是 Redis 的许可证。Valkey 侧有对应的社区替代(如 valkey-json、valkey-search),但成熟度和 API 需要单独验证
RDB 版本Valkey 高版本的 RDB 不一定能被 Redis 读回去,降级路径要提前验证
INFO 字段部分字段名/新增字段有差异,监控采集脚本要回归
版本号INFO server 里会有 valkey_versionredis_version(后者用于兼容性伪装),监控告警规则里如果硬编码了版本判断要检查

7.2 三种迁移路径

路径 A:主从复制切换(推荐,停机时间最短)

# 1. 起一个 Valkey 实例,让它作为 Redis 主库的从库
valkey-cli -h $NEW -p 6379 REPLICAOF $OLD_HOST 6379

# 2. 等待全量同步完成
watch -n 2 'valkey-cli -h '$NEW' INFO replication | grep -E "master_link_status|master_sync_in_progress"'
# 直到 master_link_status:up 且 master_sync_in_progress:0

# 3. 确认数据量一致
redis-cli  -h $OLD DBSIZE
valkey-cli -h $NEW DBSIZE

# 4. 业务侧切流量(或改 DNS / 改配置中心)

# 5. 断开复制,Valkey 独立
valkey-cli -h $NEW REPLICAOF NO ONE

路径 B:RDB 冷导入(适合可停机场景)

redis-cli -h $OLD --rdb /tmp/dump.rdb
scp /tmp/dump.rdb $NEW_HOST:/var/lib/valkey/dump.rdb
ssh $NEW_HOST 'chown valkey:valkey /var/lib/valkey/dump.rdb && systemctl start valkey'

路径 C:双写灰度(数据不能丢的核心业务)

// 影子双写:以旧库为准,新库只写不读,用于验证一致性
type DualWriter struct {
    primary redis.UniversalClient // Redis
    shadow  redis.UniversalClient // Valkey
    metrics *Metrics
}

func (d *DualWriter) Set(ctx context.Context, k string, v any, ttl time.Duration) error {
    // 主库同步写,失败直接返回
    if err := d.primary.Set(ctx, k, v, ttl).Err(); err != nil {
        return err
    }

    // 影子库异步写,失败只打点不影响主流程
    go func() {
        sctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
        defer cancel()
        if err := d.shadow.Set(sctx, k, v, ttl).Err(); err != nil {
            d.metrics.ShadowWriteFail.Inc()
            log.Warnw("shadow write failed", "key", k, "err", err)
        }
    }()
    return nil
}

// 定时对账:抽样比对两边数据
func (d *DualWriter) Reconcile(ctx context.Context, sampleKeys []string) (drift int) {
    for _, k := range sampleKeys {
        p, errP := d.primary.Get(ctx, k).Result()
        s, errS := d.shadow.Get(ctx, k).Result()
        if errP != errS || p != s {
            drift++
            d.metrics.Drift.Inc()
            log.Warnw("data drift detected", "key", k, "primary", p, "shadow", s)
        }
    }
    return drift
}

跑一到两周,drift 稳定为 0,再切读流量。

7.3 用 ASM 做扩容的完整 SOP

#!/usr/bin/env bash
set -euo pipefail

# ============ 扩容前检查 ============
# 1. 确认版本
valkey-cli -h "$SOURCE" INFO server | grep valkey_version

# 2. 检查大 key —— 即使 ASM 对大 key 友好,也应该先治理
valkey-cli -h "$SOURCE" --bigkeys --memkeys -i 0.01

# 3. 检查目标节点内存余量(关键:ASM 期间数据双份存在)
valkey-cli -h "$TARGET" INFO memory | grep -E 'used_memory_human|maxmemory_human'

# 4. 记录基线延迟
valkey-cli -h "$SOURCE" --latency-history -i 5 &
BASELINE_PID=$!

# ============ 分批迁移 ============
# 每批 800 个槽(约 5% 数据),批间观察 5 分钟
BATCHES=(
  "0 799" "800 1599" "1600 2399" "2400 3199"
)

for range in "${BATCHES[@]}"; do
  read -r start end <<< "$range"
  echo "==> 迁移槽 $start-$end 到 $TARGET_NODE_ID"

  # 提交迁移任务(命令形态以实际版本文档为准)
  valkey-cli -h "$SOURCE" CLUSTER MIGRATESLOTS \
      SLOTSRANGE "$start" "$end" NODE "$TARGET_NODE_ID"

  # 轮询等待完成
  while true; do
    status=$(valkey-cli -h "$SOURCE" CLUSTER INFO | grep -c 'migrating' || true)
    [ "$status" -eq 0 ] && break
    sleep 5
  done

  # 关键:批间观察,确认 P99 没有恶化再继续
  echo "==> 批次完成,观察 300 秒"
  sleep 300

  p99=$(valkey-cli -h "$SOURCE" --latency -i 10 | awk '{print $4}')
  echo "当前延迟采样: $p99"
done

kill $BASELINE_PID

7.4 监控要盯什么

# Prometheus 告警规则示例
groups:
- name: valkey-migration
  rules:
  - alert: ValkeySlotMigrationStalled
    expr: increase(valkey_cluster_migration_bytes_total[5m]) == 0
      and valkey_cluster_migration_state != 0
    for: 10m
    annotations:
      summary: "槽迁移卡住超过 10 分钟,检查网络与目标节点内存"

  - alert: ValkeyTargetMemoryPressure
    expr: valkey_memory_used_bytes / valkey_memory_max_bytes > 0.75
    for: 2m
    annotations:
      summary: "迁移目标节点内存超过 75%,ASM 期间数据双份,立即暂停迁移"

  - alert: ValkeyClusterBusTrafficHigh
    # 9.1 新增指标
    expr: rate(valkey_cluster_bus_sent_bytes_total[1m]) > 50 * 1024 * 1024
    for: 5m
    annotations:
      summary: "cluster bus 流量超过 50MB/s,集群规模可能已达瓶颈"

  - alert: ValkeyP99Degradation
    expr: |
      histogram_quantile(0.99, rate(valkey_command_duration_seconds_bucket[1m]))
      > 2 * histogram_quantile(0.99, rate(valkey_command_duration_seconds_bucket[1h] offset 2h))
    for: 3m
    annotations:
      summary: "P99 延迟相比迁移前基线翻倍"

八、分叉两年后的观察:竞争让所有人受益

8.1 一个值得玩味的事实

Valkey 9.0 带来 ASM 是 2025 年底;Redis 8.4 预览版合入 ASM(PR #14414)在时间上紧随其后。两个项目在同一个问题上给出了机制层面高度一致的答案:先复制、再一次性交接

这说明什么?

说明这个问题的正确解法是唯一的,只是过去没人有动力去做。

在 Redis 一家独大的年代,槽迁移的痛苦被「大家都这样」消化掉了。运维写脚本,客户端库打补丁,业务加重试——整个生态在集体维护一个有缺陷的设计。没有外部压力,重构一个涉及集群协议、复制协议、持久化格式的核心机制,收益不明确、风险极大,很难被排上优先级。

分叉之后,情况变了。Valkey 需要证明「离开 Redis 我们能走得更好」,Redis 需要证明「我们仍然是那个更强的」。在这种压力下,那些「早该做但一直没做」的事情,突然全部有了优先级。

同样的模式在别处也发生过:

  • MySQL 与 MariaDB 分叉后,两边在 JSON 支持、窗口函数、并行复制上你追我赶;
  • OpenSearch 与 Elasticsearch 分叉后,向量检索能力的迭代速度明显加快;
  • OpenTofu 与 Terraform 分叉后,状态加密这类老需求几个月就落地了。

开源世界里,垄断带来的不是效率,而是惰性。

8.2 那到底怎么选?

抛开许可证立场,给一个务实的决策树:

你在用云托管服务吗?
├─ 是 → 看你的云厂商提供什么。
│        国内主流云已经在跟进 Valkey,选托管版本省心。
│        如果重度依赖 RedisJSON/RediSearch → 优先 Redis 系托管。
│
└─ 否(自建)
   ├─ 新项目,无历史包袱
   │   ├─ 在意许可证/合规(尤其是要做商业分发的)→ Valkey(BSD 3-Clause)
   │   └─ 重度依赖 Redis Stack 模块生态 → Redis
   │
   └─ 存量 Redis 集群
       ├─ 版本 ≤ 7.2 且没用 Stack 模块 → 迁 Valkey 成本极低,收益明显
       ├─ 用了 Stack 模块 → 先评估 valkey-json / valkey-search 的替代成本
       └─ 集群规模大(>200 节点)或扩缩容频繁 → 
           强烈建议升级到带 ASM 的版本,无论哪一边

核心判断:如果你的痛点是「扩缩容风险」和「多核利用率」,Valkey 9.x 现在的答卷更完整。如果你的痛点是「向量检索 + 全文检索 + JSON 一站式」,Redis Stack 的生态仍然更成熟。

8.3 一个额外的信号:AI Agent 进入了核心基础软件的维护流程

Valkey 9.1 里有一批 bug fix 的 backport 是由 AI Agent 自动完成的。

这件事的分量比它看起来重。backport 是开源项目里典型的「必要但枯燥」的工作:把主干上的修复挑到维护分支,处理冲突,跑回归,提 PR。它消耗维护者大量精力,却几乎不产生新价值。

如果这部分工作能被自动化,维护者的时间就能释放到设计和评审上。而 backport 恰好是 AI 最擅长的那类任务——有明确的正确性标准(测试套件)、有清晰的输入输出(一个 commit → 另一个分支)、有可验证的边界

我的判断是:2026 年之后,「AI 参与维护」会成为主流开源项目的标配,而衡量标准不是「AI 写了多少代码」,而是「AI 承担了多少维护者不想做的工作」。 Valkey 在这件事上走在了前面,这本身也是项目健康度的一个信号。


九、十二条踩坑清单

按重要性排序,都是真金白银换来的。

  1. ASM 期间内存是双份的。 扩容前算清楚:源节点内存 + 单批迁移量 ≤ 目标节点可用内存 × 0.7。别一次迁一半数据。

  2. 分批迁移,批间观察。 每批 5%~10% 数据,批间留至少 5 分钟观察 P99。发现异常立刻取消——ASM 的取消是安全的,这是它相比老方案最大的心理优势。

  3. HEXPIRE key 0 FIELDS 1 f 是删除,不是「立即过期」。 返回码 2 表示 field 已被删掉。别拿它当「标记过期」用。

  4. 字段级 TTL 会改变 Hash 编码。 上线前用真实数据跑 MEMORY USAGE 对比,特别是 field 数量在 listpack 阈值附近的场景。

  5. 给带字段 TTL 的 Hash 加一个兜底的 key 级 TTL。 如果所有 field 都过期了,Hash 会变成空对象被删除;但如果业务持续写入新 field,这个 key 可能永远不死。加个比字段 TTL 长一些的 key 级 TTL 兜底。

  6. 集群多 db 不是安全隔离。 它是命名空间,不是权限边界。真正的隔离要配 ACL,并且一定要禁掉 FLUSHDB/FLUSHALL/KEYS

  7. 不要在共享连接池上手动 SELECT 一个 db 一个客户端实例。连接归还后的库号污染是最难排查的 bug 之一,因为它的表现是「偶发读不到数据」。

  8. 升级前确认客户端库的 Valkey 兼容性。 大部分库开箱即用,但如果你用了「解析 INFO 输出做健康检查」这类逻辑,字段差异会让健康检查误判。

  9. RDB 降级路径要提前验证。 升到 Valkey 9.x 之后,RDB 未必能被老版本 Redis 读回去。回滚方案要在升级前就演练过,不要等出事了才发现回不去。

  10. 大 key 治理不能省。 ASM 让大 key 迁移不再阻塞主线程,但大 key 本身仍然是 HGETALL 慢查询、内存不均衡、单槽热点的根源。ASM 治标不治本。

  11. 升级到 9.1 修 CVE。 9.1 修了三个内存安全问题(unblock client 流程的 UAF、RESTORE 命令的非法内存访问、Lua/function 执行让出时发生 full sync 导致的 UAF)。都是可远程触发的类型,不要拖。

  12. 监控 cluster bus 流量。 9.1 新增的这个指标是集群规模的早期预警。如果 bus 流量随节点数增长得比业务流量还快,说明你该考虑「多个中等集群」而不是「一个超大集群」了。


十、总结:一次「把复杂度还给服务端」的胜利

回到开头那个凌晨三点的故事。

传统槽迁移之所以痛苦,根本原因是它把分布式一致性的责任推给了客户端。服务端说:「我在迁移,你自己处理 ASK 重定向,处理 TRYAGAIN,处理中间态。」于是每一个客户端库都要实现一遍这套逻辑,每一个业务调用点都要考虑重试,每一次迁移都是对整个生态兼容性的一次抽查。

ASM 做的事情,本质是把这份复杂度收回服务端:对外只暴露「迁移前」和「迁移后」两个状态,中间过程完全内化。客户端只需要处理一次正常的 MOVED——那本来就是它必须支持的。

这个设计哲学值得记住:

一个协议如果需要「客户端正确实现某个复杂行为」才能保证正确性,那么它在现实世界里就一定会出问题。因为客户端有无数个,而正确只有一个。

顺着这条线看,Valkey 9.0 的三个主要特性其实是同一件事的三个侧面:

  • ASM:把「迁移正确性」从客户端收回服务端;
  • 哈希字段过期:把「字段级 TTL 管理」从应用层收回服务端;
  • 集群编号数据库:把「命名空间隔离」从 key 前缀约定收回服务端。

三个都在做同一个动作:消除那些「靠约定和纪律维持的正确性」,换成「靠机制保证的正确性」。

至于分叉这件事本身——两年过去,结论已经比较清楚了:它对使用者是好事。两个项目互相追赶的这两年,内存数据库这个「大家以为已经没什么可做的」领域,反而出现了近十年最密集的架构改进。

如果你现在还在用 Redis 7.0 的集群,且扩缩容对你来说是个需要提前一周排期、写方案、拉三个人守夜的事情——那么无论你最终选 Valkey 还是 Redis,升到带 ASM 的版本,都是今年性价比最高的一次基础设施升级。

至少,你可以在凌晨三点睡个整觉了。


参考与延伸

  • Valkey 官方站点与 9.x release notes(valkey.io
  • InfoQ:《Valkey 9.0 引入多数据库集群、原子级槽位迁移,并带来大幅性能提升》
  • Redis PR #14414:Atomic Slot Migration 实现讨论
  • Valkey 9.1 release notes:CVE-2026-23479 / CVE-2026-25243 / CVE-2026-23631,PR #3396(cluster bus 流量指标)、PR #3481(渐进式页释放)
  • 腾讯云数据库:Valkey 9.0 全面支持公告与性能实测数据

文中涉及的具体命令语法(尤其是 ASM 相关命令)请以你实际部署版本的官方文档为准,不同小版本间存在差异。性能数据均来自公开的社区与厂商披露,实际收益取决于你的负载特征——永远用自己的数据压一遍。

推荐文章

使用 Git 制作升级包
2024-11-19 02:19:48 +0800 CST
Vue3中如何处理SEO优化?
2024-11-17 08:01:47 +0800 CST
SpaceX 600亿美元收购Cursor(中篇)
2026-06-22 03:30:23 +0800 CST
pip安装到指定目录上
2024-11-17 16:17:25 +0800 CST
Nginx 防止IP伪造,绕过IP限制
2025-01-15 09:44:42 +0800 CST
Plyr.js 播放器介绍
2024-11-18 12:39:35 +0800 CST
程序员茄子在线接单