预计阅读 21 分钟
06 | 分布式基础 —— 一致性哈希、分布式 ID、分布式锁、分布式事务
分布式系统的每一个优雅方案,背后都藏着一个曾经让人彻夜难眠的生产事故。
开篇自测
- 当缓存集群从 3 台扩容到 4 台时,简单取模会导致多大比例的缓存失效?有没有更好的方案?
- 在微服务架构中,自增主键为什么不再适用?你知道哪些分布式 ID 方案?
- 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 方案对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| UUID | 128 位随机数 | 本地生成 | 无序(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 集群 |
| etcd | 中 | 高 | K8s 生态 |
| 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
思考题
-
你的系统中有”跨服务修改数据”的场景吗?目前如何保证一致性?重新设计会选哪种方案?
-
Redis 锁在主从切换时可能丢锁,但 RedLock 又过于复杂。你的业务中这个风险可接受吗?如何在”简单”和”正确”间取舍?
结尾自测
-
一致性哈希相比简单取模的优势?
- 答:取模在节点变化时约 (N-1)/N 的数据需重映射,可能缓存雪崩。一致性哈希只迁移约 1/N 的数据,大幅降低扩缩容影响。
-
Snowflake 为什么要把时间戳放在高位?
- 答:保证 ID 趋势递增,对 B+ 树索引至关重要——顺序写入避免频繁页分裂。
-
Redis 锁释放为什么必须用 Lua 脚本?
- 答:直接 DEL 不检查持有者。锁过期后被别人获取时直接 DEL 会误删。Lua 将”检查持有者+删除”合为原子操作。
-
TCC 和 Saga 各自适合什么场景?
- 答:TCC 适合需要预留资源的短事务(资金冻结、库存预占)。Saga 适合长流程事务(旅行套餐预订),业务侵入性比 TCC 低。
下一章预告:数据是系统的血液,存储系统是心脏。关系型数据库、NoSQL、对象存储、时序数据库、搜索引擎——下一章我们将深入存储系统选型,帮你在不同场景中选出最合适的”容器”。
购买课程解锁全部内容
面试晋升必学:11 章掌握系统设计
¥29.90