如何扩展有状态服务:从方法论到 Redis、Memcached、MySQL 实战
这篇文章的价值:扩展无状态服务很简单——加机器、挂到负载均衡器后面就行。但有状态服务(数据库、缓存、消息队列)的扩展是分布式系统中最难的问题之一:数据在哪台机器上?怎么搬过去?搬的过程中请求怎么办?这篇文章先给出一个可复用的五步方法论,然后用 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 Client | Proxy | |
|---|---|---|
| 延迟 | 最低(直连) | 多一跳 |
| 客户端复杂度 | 高(每种语言都要实现) | 低(只需知道 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)的标准流程:
- 检测到 Master A 挂了(心跳超时 / Gossip 投票 / 控制面探活)
- 从副本中选出新 Master(选复制进度最接近的那个)
- 关键步骤:更新控制面的元数据——Slot 0-5461 的 Owner 从 A 变成 A'
- 通知路由层(Proxy 更新路由表 / Client 刷新 Slot Map)
- 新 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 是整个流程中最危险的时刻。如果增量日志追不上(写入太快),阻塞时间会拉长,影响线上服务。两种应对策略:
- 限流写入:在追赶阶段对该 Slot 限流,降低写入速度让增量追赶能追上
- 双写模式:切换期间同时写两个节点,不阻塞,但逻辑更复杂,需要处理冲突
实战: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 迁移的特点和局限:
- 逐 Key 迁移,不是快照。大 Key(如百万元素的 Hash)迁移时会阻塞源节点
- 迁移期间,源节点对 MIGRATING Slot 的处理:Key 还在本地 → 正常处理;Key 已经搬走 → 返回
ASK重定向到目标节点 - Gossip 收敛慢——大集群(200+ 节点)中,一次 Slot 变更可能需要几秒才能传播到所有节点
- 没有增量日志:迁移 Key 是原子的(MIGRATE 是 DEL + RESTORE),不存在"迁移过程中 Key 被修改"的问题,但代价是大 Key 迁移会阻塞
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)里:
- 一致性哈希路由:计算 key 应该去哪台 Memcached
- 故障处理:节点挂了自动从环上摘除,key 自动重映射到下一个节点
- 灰度切换:配置文件更新路由规则,可以逐步把流量切到新集群
- 连接池:前端几千个客户端连接,复用为到 Memcached 的少量长连接
- 冷启动预热:新集群上线时,读 miss 自动回源到旧集群读取,同时写入新集群
// 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 的流复制是同一个模式——全量快照 + 增量日志追赶:
- 全量快照:对源 Shard 做
mysqldump或 Xtrabackup 物理快照,传输到新节点 - 增量追赶:从快照时的 Binlog 位点开始,持续消费 Binlog,应用到新节点。这一步和 PG 的 WAL replay 异曲同工
- 校验:对比源和目标的数据一致性(VDiff in Vitess)
- 原子切换:短暂禁写 → 等 Binlog 追平 → 更新拓扑 → 恢复写入
Vitess 的 Reshard 命令自动化这个流程。关键在于 VReplication 引擎——它不是简单地重放 Binlog,而是按分片规则过滤:只把属于目标 Shard 的行变更应用过去。
MySQL 扩展的特殊挑战
- 跨 Shard JOIN:
SELECT * FROM orders JOIN users ON orders.user_id = users.id,如果 orders 和 users 在不同 Shard 上,Proxy 需要分别查询再合并。性能急剧下降 - 跨 Shard 事务:需要两阶段提交(2PC),Vitess 通过 VTGate 协调,但性能和可靠性都不如单 Shard 事务
- 分片键的选择:一旦选定(如 user_id),几乎不可能更改。选错了(如按 order_id 分片但经常按 user_id 查询)会导致大量跨 Shard 查询
- 热点问题:如果一个大客户(user_id=1)的数据量远超其他用户,它所在的 Shard 会成为热点。解法:进一步分裂该 Shard,或者做应用层的特殊处理
三个系统的对比
| Redis Cluster | Memcached + Mcrouter | MySQL + Vitess | |
|---|---|---|---|
| 定位 | 数据存储 + 缓存 | 纯缓存 | 持久化数据存储 |
| 分片方式 | 16384 Slot | 一致性哈希 | Range 或 Hash 分片 |
| 控制面 | Gossip (去中心化) | 无 (配置驱动) | etcd (中心化) |
| 路由 | Smart Client + MOVED | Mcrouter (Proxy) | VTGate (Proxy) |
| 迁移方式 | 逐 Key 搬运 | 不迁移,缓存自愈 | 快照 + Binlog 增量 |
| 迁移对业务影响 | 大 Key 阻塞 | 短期 miss 率上升 | 短暂禁写 (毫秒级) |
| 扩容复杂度 | 中等 | 低 | 高 |
| 数据丢失风险 | 异步复制可能丢 | 本来就是缓存 | 半同步/Raft 保证 |
总结
扩展有状态服务的五步模型:
- 分片:Key → Slot → Node 两层映射,Slot 数量 = 机器数 × 100+
- 控制面:路由表存在哪、谁是权威来源。趋势是 etcd + Operator
- 路由:Smart Client(低延迟)vs Proxy(易运维)。多语言环境选 Proxy
- 副本:异步(快但可能丢)vs 半同步(平衡)vs Raft(强一致但慢)
- 迁移:全量快照 + 增量日志 + 原子切换。关键是切换那一刻的处理策略
三个不同层次的实践:
- Memcached:最简单——不搬数据,缓存 miss 自愈。适合纯缓存场景
- Redis:中等复杂——逐 Key 迁移,Gossip 协调。适合既做缓存又做轻量存储
- MySQL:最复杂——快照 + Binlog 增量,还要处理跨 Shard JOIN 和事务。适合持久化关系型数据
不管具体系统怎么变,五步模型是通用的。下次遇到一个新的有状态系统需要扩展,先问这五个问题:怎么分片、元数据存哪、请求怎么路由、副本怎么做、数据怎么搬。