如何扩展有状态服务:从方法论到 Redis、Memcached、MySQL 实战

Plan of the City of Rome, Antonio Tempesta, 1645
Antonio Tempesta,《罗马城市平面图》, 1645 — 一座庞大的城市被划分为若干区域(Regiones),每个区域有自己的边界和管辖范围。这和分布式系统的分片异曲同工:数据被划分到不同的 Slot,每个 Slot 有明确的归属节点。扩展一座城市和扩展一个有状态服务,面对的是同一个问题:如何在不停摆的前提下重新划分边界
这篇文章的价值:扩展无状态服务很简单——加机器、挂到负载均衡器后面就行。但有状态服务(数据库、缓存、消息队列)的扩展是分布式系统中最难的问题之一:数据在哪台机器上?怎么搬过去?搬的过程中请求怎么办?这篇文章先给出一个可复用的五步方法论,然后用 Redis Cluster、Memcached 和 MySQL 三个真实系统说明每一步是怎么落地的。

为什么有状态服务难扩展

无状态服务的扩展模型:

  请求 ──► Load Balancer ──┬──► Server A
                           ├──► Server B    任意一台都能处理
                           └──► Server C    加机器就能扩容

有状态服务的扩展模型:

  请求 (user_id=42)
    │
    │  这个请求只能去存着 user_id=42 数据的那台机器
    │  不能随便发
    ▼
  ┌──────────┐  ┌──────────┐  ┌──────────┐
  │ Node A   │  │ Node B   │  │ Node C   │
  │ id: 1-100│  │id:101-200│  │id:201-300│
  └──────────┘  └──────────┘  └──────────┘

  加一台 Node D:
  - 谁的数据要搬过去?
  - 搬的过程中读写怎么路由?
  - 搬到一半崩溃了怎么办?

本质问题是数据有归属。扩展有状态服务 = 在不停服的前提下,重新分配数据的归属权。

方法论:五步扩展模型

第一步:分片策略——Key 到 Node 的映射

最直觉的做法是 hash(key) % N 直接映射到 N 台机器。但加一台机器后 N 变成 N+1,几乎所有 key 的映射都会改变——这意味着全量数据迁移。

解法是加一层逻辑槽(Virtual Slot)

  坏的设计:Key → Node(直接映射)
  hash(key) % 3 = Node A
  hash(key) % 4 = Node B  ← 加一台机器,几乎所有 key 都要搬

  好的设计:Key → Slot → Node(两层映射)
  hash(key) % 16384 = Slot 5742    ← 这层永远不变
  Slot 5742 → Node A               ← 只需要移动 Slot 的归属

  ┌─────────────────────────────────────────────────────────┐
  │  Key Space                                               │
  │  ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐     │
  │  │Slot │Slot │Slot │Slot │Slot │Slot │Slot │ ... │     │
  │  │  0  │  1  │  2  │  3  │  4  │  5  │  6  │     │     │
  │  └──┬──┴──┬──┴──┬──┴──┬──┴──┬──┴──┬──┴──┬──┴─────┘     │
  │     │     │     │     │     │     │                      │
  │     ▼     ▼     ▼     ▼     ▼     ▼                      │
  │   Node A        Node B        Node C                     │
  │  Slot 0,1,2    Slot 3,4      Slot 5,6                   │
  └─────────────────────────────────────────────────────────┘

  扩容:把 Slot 2 从 Node A 搬到 Node D
  - Key 到 Slot 的映射不变
  - 只改 Slot 到 Node 的映射
  - 只搬 Slot 2 里的数据

Slot 数量的选择:通常是机器数的 100 倍以上。太少则 Slot 粒度太粗,无法均匀分配;太多则元数据开销增大。Redis Cluster 选了 16384,是一个经过实践验证的平衡点。

第二步:控制面——元数据存在哪

"Slot X 在 Node A 上"这张路由表需要存在某个地方,并且所有参与者都能看到一致的版本。

方案原理优势劣势典型系统
Gossip节点间互相传播,最终一致无单点、自愈收敛慢、脑裂风险、难以做复杂调度Redis Cluster
中心化存储etcd/ZooKeeper 存权威元数据强一致、易于编排多一个组件要维护TiDB PD、Vitess
配置服务Mcrouter 从配置文件读路由表极简、无运行时依赖变更需要推送、不适合动态扩缩容Memcached + Mcrouter

2026 年的趋势:中心化控制面 + K8s Operator 是主流。etcd 提供强一致性元数据存储,Operator 自动化执行扩缩容、故障转移、数据迁移等操作。Gossip 协议仍然用于故障检测(快速发现节点挂了),但不再承担路由决策。

第三步:路由层——请求怎么找到正确的节点

  方案 A: Smart Client                方案 B: Proxy

  ┌──────────┐                        ┌──────────┐
  │  Client   │                        │  Client   │
  │ 内置 Slot │                        │ 只知道    │
  │  Map 缓存 │                        │ Proxy 地址│
  └─────┬────┘                        └─────┬────┘
        │ 直连目标节点                        │
        │ 延迟: 1 hop                        ▼
        │                             ┌──────────┐
        │                             │  Proxy    │
        │                             │ (Mcrouter │
        │                             │  /Envoy)  │
        │                             └─────┬────┘
        │                                   │ 转发
        ▼                                   ▼
  ┌────────────────────────────────────────────┐
  │  Node A    Node B    Node C    Node D      │
  └────────────────────────────────────────────┘
Smart ClientProxy
延迟最低(直连)多一跳
客户端复杂度高(每种语言都要实现)低(只需知道 Proxy 地址)
升级路由逻辑需要升级所有客户端只升级 Proxy
附加能力限流、灰度、监控、连接池
适用场景语言统一、延迟敏感多语言、运维优先

大规模生产环境倾向于 Proxy 模式。当你有几十种编程语言的客户端时,不可能为每种语言维护一个复杂的智能客户端。Proxy 把路由逻辑集中管理,客户端只需要会说协议就行。

第四步:副本与可用性——数据不能只有一份

分片解决了容量问题,但每个分片仍然是单点。副本解决可用性:

  Slot 0-5461              Slot 5462-10922          Slot 10923-16383
  ┌──────────────┐         ┌──────────────┐         ┌──────────────┐
  │ Master A     │         │ Master B     │         │ Master C     │
  │ (读写)       │         │ (读写)       │         │ (读写)       │
  └──────┬───────┘         └──────┬───────┘         └──────┬───────┘
         │ 异步复制                │ 异步复制                │ 异步复制
         ▼                        ▼                        ▼
  ┌──────────────┐         ┌──────────────┐         ┌──────────────┐
  │ Replica A'   │         │ Replica B'   │         │ Replica C'   │
  │ (只读)       │         │ (只读)       │         │ (只读)       │
  └──────────────┘         └──────────────┘         └──────────────┘

故障转移(Failover)的标准流程:

  1. 检测到 Master A 挂了(心跳超时 / Gossip 投票 / 控制面探活)
  2. 从副本中选出新 Master(选复制进度最接近的那个)
  3. 关键步骤:更新控制面的元数据——Slot 0-5461 的 Owner 从 A 变成 A'
  4. 通知路由层(Proxy 更新路由表 / Client 刷新 Slot Map)
  5. 新 Master A' 开始接受读写

复制模式的选择:

模式一致性延迟可用性典型系统
异步复制可能丢数据最低最高Redis、Memcached
半同步复制至少一个副本确认中等MySQL Semi-sync
Raft/Paxos多数派确认,强一致较高中等TiKV、CockroachDB

第五步:数据迁移——在飞行中换引擎

这是最难的一步。新增一台机器后,要把部分 Slot 从旧节点搬到新节点,同时不中断线上服务

  迁移 Slot 3 从 Node A → Node D:

  阶段 1: 标记迁移状态
  ┌──────────┐                    ┌──────────┐
  │ Node A   │                    │ Node D   │
  │ Slot 3:  │                    │ Slot 3:  │
  │ MIGRATING│                    │ IMPORTING│
  └──────────┘                    └──────────┘

  阶段 2: 全量快照传输
  ┌──────────┐  ══ 快照数据 ══►   ┌──────────┐
  │ Node A   │                    │ Node D   │
  │ 继续服务  │                    │ 接收数据  │
  └──────────┘                    └──────────┘

  阶段 3: 增量追赶
  ┌──────────┐  ── 增量日志 ──►   ┌──────────┐
  │ Node A   │  (迁移期间的新写入) │ Node D   │
  │ 继续服务  │                    │ 追赶中   │
  └──────────┘                    └──────────┘

  阶段 4: 原子切换 (关键时刻)
  ┌──────────┐                    ┌──────────┐
  │ Node A   │                    │ Node D   │
  │ 暂停 Slot│  ← 短暂阻塞写入    │ 追平!    │
  │ 3 的写入  │  (通常几毫秒)      │          │
  └──────────┘                    └──────────┘
       │
       │  控制面更新: Slot 3 → Node D
       │  路由层刷新
       ▼
  ┌──────────┐                    ┌──────────┐
  │ Node A   │                    │ Node D   │
  │ 删除 Slot│                    │ Slot 3:  │
  │ 3 数据   │                    │ 正常服务  │
  └──────────┘                    └──────────┘

阶段 4 是整个流程中最危险的时刻。如果增量日志追不上(写入太快),阻塞时间会拉长,影响线上服务。两种应对策略:

实战:Redis Cluster 的扩展

步骤Redis Cluster 的实现
分片16384 个 Slot,CRC16(key) % 16384
控制面去中心化 Gossip。每个节点维护完整的 Slot Map,通过 Gossip 协议同步
路由Smart Client。客户端缓存 Slot Map,直连目标节点。如果发错了,节点返回 MOVED 重定向
副本每个 Master 配 1-N 个 Replica,异步复制。Failover 通过 Gossip 投票选举新 Master
迁移CLUSTER SETSLOT + MIGRATE 命令,逐 Key 迁移

Redis 迁移的细节

# 1. 标记迁移状态
CLUSTER SETSLOT 3 MIGRATING target-node-id   # 在源节点标记
CLUSTER SETSLOT 3 IMPORTING source-node-id   # 在目标节点标记

# 2. 逐 Key 迁移(Redis 没有快照迁移,逐个搬)
CLUSTER GETKEYSINSLOT 3 100                  # 获取 Slot 3 的 100 个 Key
MIGRATE target-host 6379 "" 0 5000 KEYS key1 key2 key3 ...

# 3. 完成切换
CLUSTER SETSLOT 3 NODE target-node-id        # 所有节点都执行

Redis 迁移的特点和局限:

Redis 扩容的实际操作

  扩容前: 3 Master, 每个 ~5461 Slots

  Master A: Slot 0-5460
  Master B: Slot 5461-10922
  Master C: Slot 10923-16383

  加入 Master D,需要从 A/B/C 各搬一部分 Slot:

  Master A: Slot 0-4095          (搬出 1366 个 Slot)
  Master B: Slot 5461-9556       (搬出 1366 个 Slot)
  Master C: Slot 10923-15017     (搬出 1366 个 Slot)
  Master D: Slot 4096-5460,      (接收 4098 个 Slot)
            9557-10922,
            15018-16383

  redis-cli --cluster reshard 自动完成上述过程

实战:Memcached 的扩展

步骤Memcached 的实现
分片客户端一致性哈希(Consistent Hashing)。没有 Slot 概念,哈希环上的虚拟节点
控制面无。Memcached 服务器之间完全不通信,互相不知道对方存在
路由纯客户端路由。Mcrouter(Proxy 模式)或 libmemcached(Smart Client 模式)
副本默认没有。Mcrouter 可以配置 shadow/replica pool 做读副本
迁移不迁移。缓存 miss 后回源重建

Memcached 的扩展哲学和 Redis 完全不同——它把自己定位为纯缓存,不是数据存储:

一致性哈希:加机器时只影响相邻节点

  哈希环 (0 ~ 2^32)

        Node A                    Node A
       ╱      ╲                  ╱      ╲
      ╱   key1  ╲               ╱   key1  ╲
  Node D ─── Node B    →    Node D ─── Node B
      ╲        ╱               ╲    ↑   ╱
       ╲      ╱                 ╲ Node E╱    ← 新加入
        Node C                   Node C

  加入 Node E:
  - 只有 Node B 和 Node E 之间的 key 需要重新映射
  - 其他 key 完全不受影响
  - 不需要数据迁移——缓存 miss 后自然会回源写入 Node E

Mcrouter:Memcached 的"大脑"

Memcached 本身无脑,所有智能都在 Mcrouter(Meta 开源的 Proxy)里:

// Mcrouter 路由配置示例
{
  "pools": {
    "A": { "servers": ["mc1:11211", "mc2:11211", "mc3:11211"] },
    "B": { "servers": ["mc4:11211", "mc5:11211", "mc6:11211"] }
  },
  "route": {
    "type": "WarmUpRoute",          // 冷启动预热
    "cold": "PoolRoute|B",          // 新集群
    "warm": "PoolRoute|A",          // 旧集群(miss 时回源)
    "exptime": 180
  }
}

Memcached 扩容的核心思路:不搬数据,靠缓存自愈。加机器 → 更新 Mcrouter 配置 → 新 key 自动路由到新节点 → 旧 key 访问 miss 后回源重建。代价是扩容后短期内缓存命中率下降(冷启动),但对于缓存场景这是可接受的。

实战:MySQL 的扩展

步骤MySQL 的实现
分片应用层分片(ShardingSphere)或中间件分片(Vitess)。按 user_id 或 order_id 取模
控制面Vitess: etcd 存拓扑。ShardingSphere: ZooKeeper/etcd
路由Vitess: VTGate (Proxy)。ShardingSphere: 可选 Proxy 或 JDBC Driver (Smart Client)
副本InnoDB 半同步复制 / Group Replication / Raft (TiDB)
迁移基于 Binlog 的增量复制 + 原子切换

MySQL 的扩展比 Redis 和 Memcached 复杂得多,因为它涉及事务、JOIN、外键等关系型语义。

Vitess:YouTube 验证过的 MySQL 扩展方案

  Application
      │
      ▼
  ┌──────────┐     ┌───────────────┐
  │ VTGate   │────►│ Topology      │  etcd 存储:
  │ (Proxy)  │     │ Server (etcd) │  - Keyspace → Shard 映射
  └────┬─────┘     └───────────────┘  - Shard → Tablet 映射
       │                               - Tablet 角色 (Master/Replica)
       │  SQL 路由
       │
  ┌────┼──────────────────────────────────────────┐
  │    │  Keyspace: user_db                        │
  │    │                                           │
  │    ├──► Shard -80     ──► vttablet ──► MySQL A │  user_id < 0x80
  │    │    (Master)                                │
  │    │    Shard -80     ──► vttablet ──► MySQL A' │  (Replica)
  │    │                                           │
  │    └──► Shard 80-     ──► vttablet ──► MySQL B │  user_id >= 0x80
  │         (Master)                                │
  │         Shard 80-     ──► vttablet ──► MySQL B' │  (Replica)
  └────────────────────────────────────────────────┘

MySQL 分片迁移:基于 Binlog

MySQL 的数据迁移本质上和 PG 的流复制是同一个模式——全量快照 + 增量日志追赶

  1. 全量快照:对源 Shard 做 mysqldump 或 Xtrabackup 物理快照,传输到新节点
  2. 增量追赶:从快照时的 Binlog 位点开始,持续消费 Binlog,应用到新节点。这一步和 PG 的 WAL replay 异曲同工
  3. 校验:对比源和目标的数据一致性(VDiff in Vitess)
  4. 原子切换:短暂禁写 → 等 Binlog 追平 → 更新拓扑 → 恢复写入

Vitess 的 Reshard 命令自动化这个流程。关键在于 VReplication 引擎——它不是简单地重放 Binlog,而是按分片规则过滤:只把属于目标 Shard 的行变更应用过去。

MySQL 扩展的特殊挑战

三个系统的对比

Redis ClusterMemcached + McrouterMySQL + Vitess
定位数据存储 + 缓存纯缓存持久化数据存储
分片方式16384 Slot一致性哈希Range 或 Hash 分片
控制面Gossip (去中心化)无 (配置驱动)etcd (中心化)
路由Smart Client + MOVEDMcrouter (Proxy)VTGate (Proxy)
迁移方式逐 Key 搬运不迁移,缓存自愈快照 + Binlog 增量
迁移对业务影响大 Key 阻塞短期 miss 率上升短暂禁写 (毫秒级)
扩容复杂度中等
数据丢失风险异步复制可能丢本来就是缓存半同步/Raft 保证

总结

扩展有状态服务的五步模型:

  1. 分片:Key → Slot → Node 两层映射,Slot 数量 = 机器数 × 100+
  2. 控制面:路由表存在哪、谁是权威来源。趋势是 etcd + Operator
  3. 路由:Smart Client(低延迟)vs Proxy(易运维)。多语言环境选 Proxy
  4. 副本:异步(快但可能丢)vs 半同步(平衡)vs Raft(强一致但慢)
  5. 迁移:全量快照 + 增量日志 + 原子切换。关键是切换那一刻的处理策略

三个不同层次的实践:

不管具体系统怎么变,五步模型是通用的。下次遇到一个新的有状态系统需要扩展,先问这五个问题:怎么分片、元数据存哪、请求怎么路由、副本怎么做、数据怎么搬。