跳到主要内容
预计阅读 21 分钟

06 | 分布式基础 —— 一致性哈希、分布式 ID、分布式锁、分布式事务

分布式系统的每一个优雅方案,背后都藏着一个曾经让人彻夜难眠的生产事故。


开篇自测

  1. 当缓存集群从 3 台扩容到 4 台时,简单取模会导致多大比例的缓存失效?有没有更好的方案?
  2. 在微服务架构中,自增主键为什么不再适用?你知道哪些分布式 ID 方案?
  3. Redis 分布式锁用 SETNX 就够了吗?有哪些你可能没想到的陷阱?

一、一致性哈希——从一次扩容事故说起

1.1 问题现场

某内容平台用 3 台 Redis 缓存热门文章,路由规则 hash(articleId) % 3。加到 4 台后,数据库 QPS 从 800 飙升到 12000:

扩容前 (% 3):                    扩容后 (% 4):
hash("art-1001") % 3 = 2         hash("art-1001") % 4 = 1  ← 变了
hash("art-1002") % 3 = 0         hash("art-1002") % 4 = 2  ← 变了
hash("art-1003") % 3 = 1         hash("art-1003") % 4 = 3  ← 变了
hash("art-1004") % 3 = 0         hash("art-1004") % 4 = 0  ← 没变

约 75% (1 - 3/4) 的缓存失效 -> 缓存雪崩

1.2 一致性哈希原理

核心思想:将节点和数据都映射到同一个哈希环上,数据顺时针找最近的节点:

                    0
            Node C  *
                  / | \
                /   |   \
      270 ---       |       --- 90
                \   |   /
          Node A *  |  * Node B
                   180

hash("art-1001") 落在 A 和 B 之间 -> 归属 Node B
hash("art-1002") 落在 B 和 C 之间 -> 归属 Node C

扩容加 Node D(B 和 C 之间):
  只有 (B, D] 区间的数据从 C 迁移到 D,其他不变
  影响比例 ≈ 1/N,远小于取模的 (N-1)/N

1.3 虚拟节点解决数据倾斜

3 个物理节点可能分布不均 -> 某节点承担过多数据

解决:每个物理节点创建多个虚拟节点(建议 100~200 个)
  A-1, A-2, ..., A-150 -> Node A
  B-1, B-2, ..., B-150 -> Node B
  C-1, C-2, ..., C-150 -> Node C
  环上 450 个点,数据分布趋于均匀

1.4 代码实现(TypeScript)

import * as crypto from 'crypto';

class ConsistentHashRing<T> {
  private ring = new Map<number, T>();
  private positions: number[] = [];

  constructor(private replicas = 150) {}

  private hash(input: string): number {
    const d = crypto.createHash('md5').update(input).digest();
    return ((d[0] << 24) | (d[1] << 16) | (d[2] << 8) | d[3]) >>> 0;
  }

  addNode(node: T, id: string) {
    for (let i = 0; i < this.replicas; i++) {
      const pos = this.hash(`${id}#${i}`);
      this.ring.set(pos, node);
      this.positions.push(pos);
    }
    this.positions.sort((a, b) => a - b);
  }

  getNode(key: string): T | undefined {
    if (!this.ring.size) return undefined;
    const h = this.hash(key);
    // 二分查找第一个 >= h 的位置(顺时针最近节点)
    let lo = 0, hi = this.positions.length - 1;
    while (lo < hi) {
      const mid = (lo + hi) >>> 1;
      this.positions[mid] < h ? (lo = mid + 1) : (hi = mid);
    }
    const idx = this.positions[lo] >= h ? lo : 0;
    return this.ring.get(this.positions[idx]);
  }
}

// 注:教学示例中 addNode 每次重新排序 positions 数组(O(N log N)),
// 生产环境应使用跳表或平衡树等更高效的数据结构。
const ring = new ConsistentHashRing<string>();
ring.addNode('redis-A', 'A');
ring.addNode('redis-B', 'B');
ring.addNode('redis-C', 'C');
console.log(ring.getNode('art-1001')); // -> redis-B

二、分布式 ID——从主键冲突到全局唯一

2.1 问题现场

某社交应用分库分表后,不同库各自维护自增序列,ID 冲突:

db_0.dynamics: id=1, id=2, id=3 ...
db_1.dynamics: id=1, id=2, id=3 ...  ← 冲突!

2.2 方案对比

方案原理优点缺点
UUID128 位随机数本地生成无序(B+树分裂)、太长
数据库号段批量取 ID 缓存本地简单、趋势递增依赖 DB
Redis INCR原子自增简单高性能持久化丢失风险
Snowflake时间戳+机器ID+序列号本地生成、递增、高性能时钟回拨问题

2.3 Snowflake 算法

64 位 ID 结构:
+----+----------------------------+-------+-------+--------------+
| 1位 |       41 位时间戳          | 5位DC | 5位机器|  12位序列号   |
|符号 |     (毫秒级,可用69年)      |  ID   |  ID   | (每ms 4096)  |
+----+----------------------------+-------+-------+--------------+

时间戳在高位 -> ID 趋势递增 -> B+树顺序写入,避免页分裂
峰值 = 1000ms x 4096 = 409.6 万 ID/秒(单节点)
class SnowflakeGenerator {
  // 自定义纪元(2024-03-18),设置为系统上线日期附近,可延长 41 位时间戳的可用年限(约 69 年)
  private static readonly EPOCH = 1710720000000n;
  private seq = 0n;
  private lastTs = -1n;

  constructor(private dc: bigint, private worker: bigint) {}

  nextId(): bigint {
    let now = BigInt(Date.now());
    if (now < this.lastTs) {
      if (this.lastTs - now > 5n) throw new Error('时钟回拨超过5ms');
      while (now <= this.lastTs) now = BigInt(Date.now());
    }
    if (now === this.lastTs) {
      this.seq = (this.seq + 1n) & 4095n;
      if (this.seq === 0n) {
        while (BigInt(Date.now()) === this.lastTs) {}
        now = BigInt(Date.now());
      }
    } else {
      this.seq = 0n;
    }
    this.lastTs = now;
    return ((now - SnowflakeGenerator.EPOCH) << 22n)
      | (this.dc << 17n) | (this.worker << 12n) | this.seq;
  }
}

三、分布式锁——从超卖事故到正确加锁

3.1 问题现场

限量 100 件商品促销,卖出了 137 件。原因:两个 Pod 各自检查库存都看到 stock=1,都执行了扣减:

Pod 1: 读 stock=1 -> 扣减     Pod 2: 读 stock=1 -> 扣减
单机锁在分布式环境下无效,两个 Pod 互不感知

3.2 Redis 分布式锁的正确姿势

加锁(原子设置值 + 过期时间):
SET lock:sku-2048 "holder-uuid-abc" NX PX 30000

释放(Lua 脚本保证"检查+删除"原子性):
EVAL "
  if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
  else return 0 end
" 1 lock:sku-2048 holder-uuid-abc

为什么不能直接 DEL?锁过期后被别人持有,直接 DEL 会误删别人的锁!

3.3 三大陷阱

陷阱 1:锁过期但业务没执行完
  t=0s A获锁(TTL=30s) -> t=30s 锁过期 -> t=31s B获锁 -> t=32s A完成删锁(删了B的!)
  解法:看门狗(Watchdog)自动续期,Redisson 已内置

陷阱 2:Redis 主从切换丢锁
  A在Master加锁 -> Master宕机未同步 -> Slave升为Master -> B也加锁成功
  解法:RedLock(N个独立节点,半数以上成功才算获锁)代价:性能下降
  ⚠️ 注意:Martin Kleppmann 在 2016 年对 RedLock 的安全性提出了著名批评——
  认为 RedLock 缺少 fencing token 机制,且依赖时钟同步假设,在进程暂停
  或时钟偏移场景下仍可能失效。Redis 作者 Antirez 做了回应。
  如果业务对正确性要求极高(如资金场景),建议使用 ZooKeeper/etcd,
  或者在 Redis 锁之上引入 fencing token 作为补充。

陷阱 3:可重入问题
  同一进程递归调用需要多次获取同一把锁
  解法:Hash结构记录持有者+重入计数

3.4 方案对比

方案性能可靠性适用场景
Redis 单节点大多数业务
RedLock较高高正确性要求
ZooKeeper已有 ZK 集群
etcdK8s 生态
MySQL 行锁低频操作

四、分布式事务——从数据不一致到最终正确

4.1 问题现场

旅行平台预订”机票+酒店”套餐,机票扣座成功但酒店服务超时——用户付了钱却没完整套餐:

订单服务 --> 机票服务: 扣座位 ✓
订单服务 --> 酒店服务: 扣房间 ✗ 超时
结果:机票座位被占,酒店没扣到,数据不一致

4.2 两阶段提交(2PC)

          协调者 (TM)
         /         \
     参与者A       参与者B

阶段1 Prepare: TM问所有参与者"能做吗?" -> 参与者锁定资源回复Yes/No
阶段2 Commit:  全Yes -> 提交; 任何No -> 全部回滚

问题:同步阻塞(锁资源等待) | 协调者单点 | Commit阶段网络异常导致不一致

4.3 TCC 模式(Try-Confirm-Cancel)

Try:     机票冻结1座位 + 酒店冻结1房间(预留资源)
Confirm: 全部Try成功 -> 冻结转为"已售/已订"
Cancel:  任一Try失败 -> 冻结恢复为"可售/可订"

状态流转:可售 --Try--> 冻结 --Confirm--> 已售
                          +--Cancel--> 可售

优势:不依赖DB事务,Try只预留不长锁,各阶段可重试
代价:每个服务要实现三个接口,业务侵入性高

4.4 Saga 模式

正向:T1(创建订单) -> T2(扣座位) -> T3(扣房间) -> T4(扣款)
T3失败时反向补偿:C2(恢复座位) <- C1(取消订单)

两种执行方式:
  编排式:中央 Saga Manager 控制流程(清晰但有单点)
  协作式:各服务通过事件驱动协作(松耦合但难调试)

4.5 方案选型

方案一致性性能业务侵入适用场景
2PC强一致跨库事务
TCC最终一致较高资金、库存
Saga最终一致长流程事务
本地消息表最终一致跨服务数据同步

五、实战:四大件协同工作

回到旅行平台套餐预订,看四大组件如何串联:

1. 生成分布式 ID
   orderId = Snowflake.nextId()

2. 分布式锁防重复提交
   LOCK = "booking:{userId}:{flightId}:{hotelId}"
   if (!acquireLock(LOCK, 30s)) return "请勿重复提交"

3. Saga 事务编排
   T1: 创建订单(PENDING) -> T2: 预留机票座位
   -> T3: 预留酒店房间 -> T4: 扣款
   任一失败 -> 反向补偿

4. 一致性哈希路由缓存
   cacheNode = hashRing.getNode(flightId)
   cacheNode.set(flightId, info, TTL=5min)

5. 释放锁

六、生产环境避坑指南

问题 1: Snowflake 时钟回拨
  短回拨(<5ms)等待追上 | 长回拨报警+切备用workerId | 预防:NTP渐进式调整

问题 2: 一致性哈希热点
  本地缓存+短TTL | key加随机后缀分散 | 多级缓存

问题 3: 分布式锁忘释放
  必须设TTL兜底 | try/finally保证释放 | 监控持有时间

问题 4: Saga 补偿失败
  补偿必须幂等 | 指数退避重试 | 兜底:人工介入+对账
选型速查:
  全局唯一ID:趋势递增->Snowflake | 不要求递增->UUID | 连续递增->数据库号段
  分布式锁:已有Redis->SETNX+Lua | 高正确性->ZK/etcd | 低频->MySQL行锁
  分布式事务:涉及资金->TCC | 长流程->Saga | 数据同步->本地消息表+MQ

思考题

  1. 你的系统中有”跨服务修改数据”的场景吗?目前如何保证一致性?重新设计会选哪种方案?

  2. Redis 锁在主从切换时可能丢锁,但 RedLock 又过于复杂。你的业务中这个风险可接受吗?如何在”简单”和”正确”间取舍?


结尾自测

  1. 一致性哈希相比简单取模的优势?

    • :取模在节点变化时约 (N-1)/N 的数据需重映射,可能缓存雪崩。一致性哈希只迁移约 1/N 的数据,大幅降低扩缩容影响。
  2. Snowflake 为什么要把时间戳放在高位?

    • :保证 ID 趋势递增,对 B+ 树索引至关重要——顺序写入避免频繁页分裂。
  3. Redis 锁释放为什么必须用 Lua 脚本?

    • :直接 DEL 不检查持有者。锁过期后被别人获取时直接 DEL 会误删。Lua 将”检查持有者+删除”合为原子操作。
  4. TCC 和 Saga 各自适合什么场景?

    • :TCC 适合需要预留资源的短事务(资金冻结、库存预占)。Saga 适合长流程事务(旅行套餐预订),业务侵入性比 TCC 低。

下一章预告:数据是系统的血液,存储系统是心脏。关系型数据库、NoSQL、对象存储、时序数据库、搜索引擎——下一章我们将深入存储系统选型,帮你在不同场景中选出最合适的”容器”。

购买课程解锁全部内容

面试晋升必学:11 章掌握系统设计

¥29.90