分布式系统一致性问题实战——CAP、BASE、最终一致性的工程落地
分布式系统一致性问题实战——CAP、BASE、最终一致性的工程落地
适读人群:有分布式系统实战经验、想深入理解一致性理论并落地的 Java 后端开发者 | 阅读时长:约18分钟 | 核心价值:把 CAP/BASE 理论从概念落到工程实践,建立一致性问题的系统化处理思路
"理论我都背过,但还是踩了坑"
前段时间有个读者来找我,说他面试回答 CAP 理论没问题,但实际工作中还是出了数据一致性的问题,被老板追责。
他描述了问题:他们做了一个账户余额服务,用 Redis 做缓存,MySQL 做持久化。写操作先写 MySQL,再删 Redis(经典的 Cache-Aside 模式)。结果某天发现 Redis 里有用户的旧余额数据,持续存在了好几天,导致用户一直看到错误的余额。
我问他:"你们写 MySQL 和删 Redis 是原子的吗?"
他愣了一下:"不是原子的,中间可能有任何故障……"
问题就在这里。MySQL 写成功,然后进程崩溃了,Redis 没删,缓存就永久是旧数据了(因为 TTL 没设好,或者设了很长的 TTL)。
这是一个非常典型的写后缓存失效问题,根源是没有理解"最终一致性"的工程要求。CAP 理论告诉你无法同时保证三者,但不告诉你怎么在工程上尽量逼近一致性。
先搞清楚 CAP 到底在说什么
CAP 理论说:在分布式系统中,不能同时保证以下三者:
- C(Consistency,一致性):所有节点在同一时间看到相同的数据
- A(Availability,可用性):每个请求都能得到非错误响应(不一定是最新数据)
- P(Partition Tolerance,分区容错):即使网络分区(节点间通信中断),系统仍然运行
很多人背 CAP 但有一个误解:以为是"三选二"。
实际上,P 是分布式系统的基本假设,必须接受。网络是不可靠的,分区随时可能发生。所以真正的选择是:当分区发生时,你优先保 C 还是保 A?
- CP 系统:分区时拒绝请求,保证返回的数据一定是一致的(ZooKeeper、HBase)
- AP 系统:分区时继续服务,但可能返回过期数据(Cassandra、Eureka)
BASE 理论:工程上的一致性妥协
BASE 是 CAP 中 AP 方向的工程实践指导:
- Basically Available(基本可用):出现故障时,允许损失部分可用性(降级、限流),而不是完全不可用
- Soft State(软状态):允许系统存在中间状态,这些中间状态不影响系统整体可用性
- Eventually Consistent(最终一致性):所有数据副本在经过一段时间后,最终达到一致状态
最终一致性的工程实现模式
模式一:发件箱模式(Transactional Outbox)
最可靠的最终一致性实现方式:
@Service
@Transactional
public class BalanceService {
@Autowired
private BalanceMapper balanceMapper;
@Autowired
private OutboxMapper outboxMapper;
/**
* 扣减余额:在同一个 DB 事务中写 balance 表和 outbox 表
* outbox 表由 Relay 进程异步处理(更新缓存、发 MQ 消息等)
*/
public void deductBalance(String userId, BigDecimal amount) {
// 1. 写 balance 表
balanceMapper.deductBalance(userId, amount);
// 2. 写 outbox 表(同一事务,原子性)
OutboxEvent event = new OutboxEvent();
event.setId(UUID.randomUUID().toString());
event.setEventType("BalanceUpdated");
event.setAggregateId(userId);
event.setPayload(JSON.toJSONString(Map.of(
"userId", userId,
"amount", amount,
"timestamp", System.currentTimeMillis()
)));
event.setStatus(OutboxStatus.PENDING);
outboxMapper.insert(event);
// DB 事务提交:balance 和 outbox 要么一起成功,要么一起失败
}
}
/**
* Relay:处理 outbox 事件,更新 Redis 缓存
*/
@Component
public class BalanceOutboxRelay {
@Autowired
private OutboxMapper outboxMapper;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Scheduled(fixedDelay = 500) // 每 500ms 处理一次
public void processOutboxEvents() {
List<OutboxEvent> events = outboxMapper.findPendingEvents(50);
for (OutboxEvent event : events) {
try {
if ("BalanceUpdated".equals(event.getEventType())) {
// 删除 Redis 缓存(让下次查询从 DB 读取最新数据)
String cacheKey = "balance:" + event.getAggregateId();
redisTemplate.delete(cacheKey);
// 也可以主动更新缓存
// Balance latest = balanceMapper.selectByUserId(event.getAggregateId());
// redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(latest), Duration.ofMinutes(30));
}
outboxMapper.markProcessed(event.getId());
} catch (Exception e) {
log.error("处理 outbox 事件失败", e);
outboxMapper.markFailed(event.getId(), e.getMessage());
}
}
}
}模式二:基于版本号的最终一致性
为每条数据增加版本号,冲突时以最新版本为准:
@Entity
@Table(name = "user_profile")
public class UserProfile {
@Id
private String userId;
private String name;
private String email;
@Version // JPA 乐观锁版本号
private long version;
private LocalDateTime updatedAt;
}@Service
public class UserProfileService {
@Autowired
private UserProfileRepository repository;
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 更新用户信息,使用版本号保证缓存一致性
*/
public void updateProfile(UpdateProfileRequest request) {
// 1. 更新 DB(乐观锁,版本号自动+1)
UserProfile profile = repository.findById(request.getUserId())
.orElseThrow(() -> new NotFoundException("用户不存在"));
profile.setName(request.getName());
profile.setEmail(request.getEmail());
profile.setUpdatedAt(LocalDateTime.now());
repository.save(profile); // 版本号自动递增
// 2. 更新缓存时带上版本号
String cacheKey = "user:profile:" + request.getUserId();
String cacheValue = JSON.toJSONString(profile);
// 使用 Lua 脚本:只在缓存版本 <= DB 版本时才更新
String luaScript =
"local current = redis.call('hget', KEYS[1], 'version') " +
"if current == false or tonumber(current) < tonumber(ARGV[2]) then " +
" redis.call('hset', KEYS[1], 'data', ARGV[1]) " +
" redis.call('hset', KEYS[1], 'version', ARGV[2]) " +
" redis.call('expire', KEYS[1], 1800) " +
" return 1 " +
"else " +
" return 0 " +
"end";
redisTemplate.execute(
RedisScript.of(luaScript, Long.class),
Collections.singletonList(cacheKey),
cacheValue,
String.valueOf(profile.getVersion())
);
}
}模式三:补偿事务(Saga 中的最终一致性)
当分布式操作(如跨服务转账)发生部分失败时,通过补偿恢复一致性:
@Service
public class TransferService {
/**
* 账户转账:A 扣款 → B 收款
* 任何一步失败,执行逆向补偿
*/
public void transfer(String fromUserId, String toUserId, BigDecimal amount) {
String sagaId = UUID.randomUUID().toString();
try {
// Step 1:A 账户扣款
accountService.deductBalance(fromUserId, amount, sagaId);
// Step 2:B 账户收款
try {
accountService.addBalance(toUserId, amount, sagaId);
} catch (Exception e) {
// Step 2 失败:补偿 Step 1(把钱退回给 A)
log.error("B 账户收款失败,触发补偿", e);
accountService.compensateDeduct(fromUserId, amount, sagaId);
throw new TransferFailedException("转账失败,资金已退回", e);
}
// 两步都成功,记录转账记录
transferRecordService.saveRecord(fromUserId, toUserId, amount, sagaId, "SUCCESS");
} catch (TransferFailedException e) {
transferRecordService.saveRecord(fromUserId, toUserId, amount, sagaId, "FAILED");
throw e;
}
}
}三大踩坑实录
坑一:Cache-Aside 模式的并发写问题
现象: 如文章开头所述,写 DB 后删 Redis,但偶发旧缓存:进程崩溃时 Redis 没删成功,或者因为分布式锁缺失,两个并发写操作顺序颠倒(晚写的 DB 数据被早到的缓存删除所"覆盖")。
原因: Write-Back 操作(写 DB + 删 Cache)不是原子的。
解法:
- 写操作都走发件箱模式(DB 事务保证原子性)
- 缓存删除失败时,把删除任务写入 Redis List,定期重试
- 为缓存设置合理的 TTL(兜底过期),避免永久脏缓存
坑二:补偿操作幂等性缺失
现象: Saga 补偿操作偶发重复执行,导致 A 账户的钱被退了两次,出现数据异常。
原因: 补偿操作因为网络问题被重试,但没有做幂等判断。
解法: 补偿操作也需要幂等:用 sagaId + stepId 作为幂等键,同一个 Saga 的同一步补偿只执行一次。
public void compensateDeduct(String userId, BigDecimal amount, String sagaId) {
// 幂等检查:这次补偿是否已经执行过
String idempotencyKey = "saga:compensate:" + sagaId + ":deduct:" + userId;
Boolean isNew = redisTemplate.opsForValue()
.setIfAbsent(idempotencyKey, "1", Duration.ofDays(1));
if (Boolean.FALSE.equals(isNew)) {
log.warn("补偿已执行,跳过重复补偿。sagaId={}, userId={}", sagaId, userId);
return;
}
// 执行补偿
balanceMapper.addBalance(userId, amount);
}坑三:最终一致性的"最终"时间太长
现象: 用户反馈:"我刚充值了,但显示的余额还是之前的数值,刷新了好几次都不对。"排查后发现 outbox relay 处理延迟了 30 秒。
原因: outbox relay 是每秒扫描一次,加上数据库查询延迟和 Redis 写入时间,最差情况下有 2-3 秒延迟。但某次数据库慢查询,relay 跑了 30 秒,积压了几百条事件。
解法:
- 缩短 relay 扫描间隔(200ms)
- 换用 CDC(Debezium)监听 DB binlog,实时触发缓存更新,延迟降到 50ms 以内
- 充值成功后给前端返回的响应里,直接带上最新余额,不依赖缓存
一致性级别选型指南
| 业务场景 | 推荐一致性级别 | 方案 |
|---|---|---|
| 账户余额、支付 | 强一致性 | 数据库 ACID 事务 |
| 库存扣减 | 强一致性(或最终) | 分布式锁 + 乐观锁 |
| 订单状态 | 最终一致性 | Saga + 发件箱 |
| 用户资料更新 | 最终一致性(延迟 < 1s) | Cache-Aside + 版本号 |
| 商品浏览历史 | 弱一致性(可丢失) | 异步写入 |
核心原则:一致性越强,性能损耗越大,实现越复杂。在满足业务要求的前提下,选最弱的一致性保证。
