CAP 与一致性

CAP 的取舍

分布式系统最多只能同时保证三者中的两个:

  • **一致性(C)**:所有节点在同一时刻看到相同数据
  • **可用性(A)**:系统对请求的响应不会失败
  • **分区容错性(P)**:节点间网络分区时系统继续运行

实际里 P 基本没法回避,所以讨论更多是在分区出现时,系统更偏向保一致还是保可用:

  • CP系统:优先数据一致,网络分区时拒绝服务(ZK、etcd)
  • AP系统:优先可用,允许临时不一致,最终一致(Dynamo、Cassandra)

大多数互联网业务不会把所有链路都做成强一致,常见做法是核心扣减、扣款类链路偏保守,外围状态同步和通知链路接受短暂不一致。

BASE 与最终一致性

BASE是对CAP的实用补充:

  • **基本可用(BA)**:服务基本可用,可能存在性能下降
  • **软状态(S)**:允许存在中间状态,不要求强一致
  • **最终一致性(E)**:经过一段时间后,所有副本达到一致状态

落到工程实现里,通常就是主流程先落库,后续再通过消息、补偿任务和对账任务把状态收平。


分布式锁

Redis 锁

常见写法是用 SET NX EX 把加锁动作收成一步:

1
2
3
4
5
6
7
// 加锁
SET key value NX EX 30

// 释放
if (get(key).equals(value)) {
del(key);
}

优点:接入简单,延迟低,大部分业务系统都已经有 Redis

问题

  • Redis宕机时完全丢失锁
  • 释放逻辑不原子(GET + DEL分成两步)

适用场景:更适合做业务侧互斥,尤其是活动、预占、短时挡重这类允许做兜底补偿的场景

Zookeeper 锁

ZK 方案的核心不是“锁”,而是它本身就提供了强一致的节点视图和临时节点机制:

1
2
3
/locks/order_123
- /lock_001 (client A, ephemeral)
- /lock_002 (client B, ephemeral)

创建顺序临时节点,最小号获得锁。释放时节点自动删除,其他客户端被通知。

优点:一致性更强,客户端断开后节点能自动清理

问题:性能低于Redis、需要额外ZK集群、网络分区时可能阻塞

适用场景:更偏任务调度、主节点选举、全局串行这类对一致性更敏感的场景

数据库锁

很多业务最后兜底还是会落回数据库唯一键或行锁:

1
SELECT * FROM lock_table WHERE resource_id = ? FOR UPDATE;

优点:和业务数据放在一起,事务边界清楚

问题:吞吐上不去,热点竞争时容易把数据库拖成瓶颈

适用场景:并发不高、需要强一致且可持久化的场景

怎么选

并发量 一致性要求 场景 推荐
支付、订单扣款 DB行锁
定时任务、库存扣减 ZK
活动、优惠券 Redis
核心扣减链路 先拆业务,再决定是否局部DB兜底

分布式事务基础

2PC

协调者(Coordinator)分两个阶段处理事务:

第一阶段(投票)

  • 协调者向所有参与者发送prepare请求
  • 参与者执行业务逻辑,锁定资源,返回OK或失败

第二阶段(提交)

  • 协调者收集投票结果
  • 全部OK则发commit,任何一个失败则发rollback
  • 参与者执行对应操作

问题

  • 同步阻塞,参与者长时间占用资源
  • 协调者宕机时,参与者资源无法释放
  • 网络分区时容易脑裂

严格意义上的 2PC 在业务系统里不常见,更多是理解它为什么重、为什么容易阻塞。

TCC

应用层实现的分布式事务:

  • Try:业务逻辑检查 + 资源预留(冻结金额、库存锁定)
  • Confirm:真正执行业务(扣款、减库存)
  • Cancel:业务失败或超时时释放预留资源
1
2
3
4
5
订单系统 --Try(冻结100)--> 账户系统
|
|--Try(预留商品)--> 库存系统
|
+--业务检查通过 -> Confirm | 业务检查失败 -> Cancel

优点:强一致性、资源及时释放

问题:业务代码侵入性强、需要为每个操作实现Try/Confirm/Cancel三个方法

适用:更适合金额、额度这类强一致要求高,且业务方愿意承担实现成本的场景

事件驱动最终一致

互联网系统里更常见的是把事务边界收在本地,然后靠消息和补偿把后续状态推平:

1
2
3
1. 订单确认 -> 写库 + 发消息
2. 消息消费端处理(减库存、扣款)
3. 如果处理失败 -> 消息重试 + 后台定时对账修复

优点:吞吐更高,链路更松耦合

问题:数据有短期不一致窗口、需要对账和补偿机制

适用:订单通知、库存同步、发券、积分到账这类允许短时间不一致的链路


幂等

幂等说白了就是:同一笔请求因为重试、重复投递、超时回放又来一次时,系统别把结果做重了。

入口幂等

在业务逻辑前面拦截重复请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 客户端生成唯一的requestId
String requestId = generateUUID();

// 服务端先查记录
PrevResult prev = cache.get(requestId);
if (prev != null) {
return prev; // 直接返回之前的结果
}

// 执行业务逻辑
Result result = doBusinessLogic();

// 保存结果(缓存或DB)
cache.set(requestId, result, 24h);
return result;

缓存适合挡住短时间重复请求,真正长期可靠的判断通常还是要落到数据库唯一键或状态表。

操作幂等

对于无法在入口拦截的异步操作(消息消费、定时任务),在操作执行前检查状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
@KafkaListener(topics = "order_events")
public void handleOrderEvent(OrderEvent event) {
// 先查这个事件是否已处理过
if (eventStore.isProcessed(event.getEventId())) {
return;
}

// 执行业务
processOrder(event);

// 标记已处理
eventStore.markProcessed(event.getEventId());
}

共识算法

Raft

Raft 这类共识算法,业务开发平时不一定直接实现,但在理解 etcd、Consul、配置中心和注册中心行为时很有用。

核心是日志复制 + 领导者选举

领导者选举

  • 集群中有一个Leader,其他是Follower
  • 如果Follower在一段时间内没收到Leader的心跳,发起选举
  • 获得多数票的服务器成为新Leader

日志复制

  • Leader接收客户端请求,写入日志
  • Leader将日志复制到所有Follower
  • 多数Follower确认后,Leader提交日志
  • 提交后的日志才真正应用到状态机

优点:相比 Paxos 更容易讲清楚,也更容易映射到工程实现

如果不是做中间件,通常不用死抠推导过程。先理解“多数派提交”“Leader 负责推进日志”这两个核心点,已经够支撑大部分工程判断。