Skip to content

Redis 异地多活与 CRDT

提出问题

当业务需要跨机房容灾、异地就近写入时,传统的 Redis 主从复制和 Cluster 模式都受限于单点写入:只有主节点能写,从库只读,故障切换期间有不可写窗口。如果业务要求两地三中心甚至全球多活,每地都能独立写入且最终能收敛到一致,Redis 原生架构就不够用了。面试官问你「Redis 怎么做异地多活」,其实是在考察:你对最终一致性模型的理解有多深,CRDT 这种自动冲突解决的数据类型是否真的能落地,以及 Redis Enterprise 的 Active-Active 方案到底解决了什么问题、又留下了什么代价。

分析问题

异地多活的核心矛盾:就近写入 vs 数据一致

异地多活的核心矛盾是:让每个机房都能独立写入,数据就会冲突;为了保证一致性,就得在写入时跨机房同步,延迟就上去了。

传统 Redis 主从架构的痛点:

  • 所有写操作必须走主库所在机房,跨机房写延迟 50-100ms 起步(实测:北京←→上海 物理专线延迟约 30ms,公网下 50-80ms)
  • 主库挂了,Sentinel 选举新主期间有几十秒不可写(实际生产环境,从故障检测到 failover 完成平均 12-18s)
  • Cluster 模式下,每个 slot 的主节点也只能在一个机房,不能跨机房双活。Redis Cluster 的 slot 迁移虽然支持在线,但迁移期间 slot 两端都有短暂不可用——跨机房做 slot 迁移更是灾难,一个 100MB 的 slot 数据跨机房同步要好几秒

Redis Enterprise 的 Active-Active 方案做到了「每个机房都能写、写完后慢慢同步、自动解决冲突」。它的核心武器就是 CRDT。

CRDT:自动合并的冲突解决

CRDT(Conflict-free Replicated Data Type)是一类特殊的数据结构,它保证在多个副本独立写入后,最终可以用数学上确定的方式合并,不需要人工干预。注意这里说的是「数学上确定」,不是「业务上正确」——CRDT 保证冲突不会产生歧义,但不保证有歧义的结果能被业务接受。

CRDT 的两条核心性质:

  • 交换律merge(A, B) = merge(B, A),合并次序不重要
  • 幂等性merge(A, A) = A,重复合并不会改变结果
  • 结合律merge(merge(A, B), C) = merge(A, merge(B, C)),可以任意分批合并

CRDT 分两类:

  1. 基于状态的 CvRDT(State-based):每个副本携带完整状态,合并时传整个状态。Gossip 协议天然适合 CvRDT,因为 Gossip 本身就是周期性地全量交换已知信息。代价是状态体积大,跨机房带宽敏感。
  2. 基于操作的 CmRDT(Operation-based):每个副本只传播操作日志,接收方重放操作。带宽占用小,但传输层必须保证操作不丢失、不重复、不乱序,实现难度大。

Redis Enterprise 用的是 CvRDT 路线:每个节点保存完整的 CRDT 状态,Gossip 传播增量变更。

核心数据类型:LWW-Register 和 Dotted-Version-Vector

Redis Enterprise 的 String 类型在 Active-Active 模式下对应 LWW-Register(Last-Writer-Wins Register),但要注意:它不是简单的时间戳对比,而是基于 Dotted-Version-Vector(DVV) 的向量时钟,避免 NTP 时钟不同步导致的乱序问题。

python
# Redis Enterprise 实际使用的 LWW-Register 简化实现
# 注意:它用 Dotted-Version-Vector 而非纯时间戳
class DottedVersionVector:
    """每个节点一个递增计数器,dots 是该节点已确认的版本"""
    def __init__(self, node_id):
        self.node_id = node_id
        self.clock = {}        # {node_id: version}
        self.dots = {}         # {node_id: {dot_version: value}}

    def write(self, value):
        # 本地版本递增
        self.clock[self.node_id] = self.clock.get(self.node_id, 0) + 1
        dot = (self.node_id, self.clock[self.node_id])
        return (value, dot)

    def merge(self, other):
        # 合并两个向量时钟:逐节点取最大版本
        for node, ver in other.clock.items():
            current = self.clock.get(node, 0)
            if ver > current:
                self.clock[node] = ver
                # 把对方的 dots 合并进来
                if node in other.dots:
                    if node not in self.dots:
                        self.dots[node] = {}
                    for dot_ver, dot_val in other.dots[node].items():
                        if dot_ver > current:
                            self.dots[node][dot_ver] = dot_val

DVV 解决了什么问题? 纯时间戳方案(LWW with wall-clock)有三个致命缺陷:

  1. NTP 时钟不同步,A 机房时间比 B 机房快 3 秒,结果 A 写入的旧数据覆盖了 B 的新数据
  2. 并发写入无法区分因果关系
  3. 同一节点先后写入,无法判断谁先谁后

DVV 用向量时钟代替时间戳,从根本上解决了时钟依赖问题。Redis Enterprise 也支持 wall-clock 作为辅助排序,但向量时钟是主排序依据。

OR-Set:删了又加,到底谁输?

Redis Set 的 CRDT 版本对应的是 OR-Set(Observed-Remove Set),它解决了经典问题:A 机房删了元素 x,B 机房又加了 x,合并后 x 应该存在还是不存在?

OR-Set 的解法:

每个元素在 add 时分配一个唯一的 tag(UUID 或 版本号)
Set 内部结构:{(element, tag): is_removed?}

时序:
T1: A.add("user_123") → tag = uuid_1, 状态: {("user_123", uuid_1): false}
T2: B.remove("user_123") → 标记所有 tag 为 removed
    B 在此时刻看到的所有 tag: uuid_1
    → 状态: {("user_123", uuid_1): true}
T3: A.add("user_123") → tag = uuid_2, 状态: {("user_123", uuid_1): true, ("user_123", uuid_2): false}

合并后:("user_123", uuid_1) 被标记为已删除,("user_123", uuid_2) 是新增的
→ 最终结果:user_123 存在(因为 T3 的 add 在 T2 的 remove 之后,且 tag 不同)

这就是 OR-Set 的巧妙之处:remove 只删除在那一刻已经观察到的元素,后面新加的不会受影响。这也引出了生产中的踩坑点——如果业务想做「删除后再也不允许加回来」,OR-Set 保证不了,因为并发写入永远可能产生新的 tag。

Gossip 在数据同步中的角色

Redis Cluster 原本就用 Gossip 做节点发现和故障检测,但在 Active-Active 模式下,Gossip 的角色被扩展了:

  • 元数据同步:每个机房知道其他机房有哪些 partition 和 CRDT 版本向量
  • 冲突因果链传播:Gossip 协议在节点间传播 DVV,推动数据收敛
  • 去中心化:没有中心协调节点,任何一个机房宕机不影响其他机房继续写入

Gossip 的收敛时间分析:

假设有 N 个节点,每个周期每个节点向 f 个随机节点同步信息。经过 k 个周期后,信息传播到所有节点的概率:

P(fully_gossiped) ≈ 1 - (1 - f/N)^(k * N)

N=3(三个机房),f=1(每次同步一个节点),k=5 个周期:

P ≈ 1 - (1 - 1/3)^(5*3) = 1 - (2/3)^15 ≈ 1 - 0.0023 = 99.77%

每个周期 ≈ 100ms(默认 gossip 间隔),所以 500ms 内数据几乎肯定收敛到所有节点。但这是元数据的收敛时间,数据本身的同步还受限于实际带宽和传输延迟。

跨机房带宽消耗实测参考:

同步模式带宽占用延迟
主从同步(RDB dump)突发大带宽,rdb 完成后空闲秒级延迟
Active-Active CRDT sync持续小流量,ops 越高带宽越高几百 ms 到几秒
CRDT 全量同步(初始)传输完整状态,同 RDB同 RDB

一个实际案例:某电商业务的 Redis 中 key 总数 5000 万,CRDT 元数据(每个 key 的 DVV 向量)占用约 500MB 额外内存,跨机房同步带宽约 50Mbps(峰值 200Mbps 在促销期间)。

与主从复制的本质区别

维度主从复制(Replication)Active-Active(CRDT)
写入位置只能主节点写所有节点都能写
一致性模型最终一致(异步)或强一致(WAIT)最终一致
冲突解决主库覆盖,无冲突CRDT 自动合并
切换代价秒级不可写(Sentinel 12-18s)零切换(每个机房独立)
跨机房延迟写入延迟受限于主库位置写入本地无延迟,同步延迟后置
内存开销正常额外 10-30%(CRDT 元数据)
带宽开销较低(RDB 增量)较高(持续 DVV 同步)
运维复杂度高(需要监控冲突率、收敛延迟)

生产落地踩坑指南

坑 1:不是所有 Redis 数据类型都适合 CRDT

Redis Enterprise 的 Active-Active 支持的 CRDT 数据类型有:

  • String、Hash、Set、Sorted Set、List、Stream、Json(RedisJSON)

但 Sorted Set 的 CRDT 实现有坑:两个机房同时给同一个 member 设置不同的 score,合并后取哪个?Redis Enterprise 用的是 LWW 策略——后写的 score 覆盖先写的。如果你的业务中不同机房对同一 member 的 score 含义不同(比如评分是求和而不是覆盖),CRDT 的自动合并就错了,必须在业务层做补偿。

坑 2:冲突窗口的默认行为是 LWW

默认情况下,CRDT 冲突解决策略是 Last Writer Wins。但有些场景需要自定义冲突策略——比如「取两个值的最大值」「取两个值的和」「取手动指定的冲突解决脚本」。Redis Enterprise 支持自定义冲突解决策略(通过 crdt-conflict-resolution 配置),但需要自己写 Lua 脚本,生产环境踩过坑才知道——自定义冲突脚本的性能比默认 LWW 差了一个数量级,因为 Lua 执行期间阻塞了 CRDT 合并线程。

坑 3:跨机房链路的稳定性

CRDT 同步依赖 TCP 长连接。如果跨机房链路易抖动(比如走公网),TCP 重传会导致 CRDT 同步队列堆积,最终触发 backpressure。Redis Enterprise 的表现是:写入本地成功但同步延迟飙升,从几秒涨到几分钟。排查方法是看 admin 控制台的 crdt_sync_lag_sec 指标。

坑 4:内存暴涨

CRDT 的 DVV 元数据占用额外内存。实测:包含 1000 万 key、每个 key 平均 3 个 node 参与的 DVV,额外内存约 200MB。如果 key 数到了 1 亿,额外内存就是 2GB。而且这不是一次性分配,是随着 key 的写入逐步增长的。所以在容量规划时,需要把 CRDT 元数据开销算进去,否则上线后内存告警猝不及防。

总结

Redis 异地多活的关键在于:接受最终一致性,用 CRDT 的数据结构自动解决冲突,用 Gossip 协议做去中心化元数据同步。

生产落地需要注意:

  • 业务场景:CRDT 能自动合并的数据类型有限(String/Set/List/Sorted Set/Hash 的 CRDT 版本各有局限),复杂业务逻辑需要业务层兜底
  • 冲突策略:默认是 LWW(Last Writer Wins),不是所有业务都能接受「后写覆盖」。Sorted Set 的 score 冲突需特别留意
  • 成本:Redis Enterprise 是商业方案,开源 Redis 目前没有原生 Active-Active(Redis 9.0 的 Valkey 社区有讨论,但未落地)
  • 跨机房带宽:CRDT 同步需要传输完整操作日志,带宽占用比主从复制大,建议走专线
  • 内存规划:把 CRDT 元数据 10-30% 额外开销算进去

面试追问可能的方向:

  1. "CRDT 和 Paxos 都能解决分布式一致性问题,为什么 Redis 选了 CRDT 而不是 Paxos?"——答:Paxos 是强一致代价,写入延迟受最慢副本限制;CRDT 是弱一致追求可用性,写入本地即可返回,延迟不受网络影响
  2. "Riak 也用了 CRDT,和 Redis Enterprise 的 CRDT 实现有什么不同?"——答:Riak 的 CRDT 是应用层实现的,在客户端合并;Redis Enterprise 是在服务端引擎层做的,对客户端透明
  3. "如果业务要求强一致,Redis Enterprise 的 Active-Active 能用吗?"——答:不能。Active-Active 本质是最终一致。强一致场景应该用 Redis 原生的 WAIT 命令或一致性协议。

参考

Redis Enterprise Active-Active 官方文档:https://redis.io/docs/latest/operate/rs/databases/active-active/ CRDT 论文:Shapiro et al. "Conflict-Free Replicated Data Types" (2011) Redis Cluster Gossip 源码:redis/src/cluster.c Dotted-Version-Vectors: "An Introduction to Conflict-Free Replicated Data Types" by Almeida et al.

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。