支付系统的分布式事务灾难:两阶段提交从理论到实践的血泪史
我们的支付系统差点酿成大祸。一笔订单在扣款成功后,商户却没收到钱,用户的订单状态还显示"待支付"。更恐怖的是,这种情况在压测中出现了上百次。距离双十一只有两周,老板直接发话:“解决不了就别上线!”
这个问题的根源,就是分布式事务处理不当。我们花了整整10天,从两阶段提交(2PC)的基础实现,到各种优化方案,再到最终的混合方案,总算是惊险过关。今天就来复盘这次"生死时速",聊聊分布式事务的那些坑。
问题的根源:一笔支付涉及多少个系统?
先看看我们的支付流程涉及多少服务:
用户支付 → 支付网关 → 账务系统 → 银行接口 → 商户结算 → 订单系统 → 库存系统
每个环节都可能失败,最要命的是它们部署在不同的服务器上,有各自的数据库:
| 服务名称 | 数据库 | 关键操作 | 失败概率 | 失败后果 |
|---|---|---|---|---|
| 支付网关 | MySQL | 创建支付单 | 0.01% | 支付失败 |
| 账务系统 | PostgreSQL | 用户扣款 | 0.05% | 资金异常 |
| 银行接口 | - | 实际扣款 | 0.1% | 扣款失败 |
| 商户结算 | MySQL | 商户加款 | 0.02% | 商户损失 |
| 订单系统 | MongoDB | 更新订单状态 | 0.03% | 状态不一致 |
| 库存系统 | Redis | 扣减库存 | 0.01% | 超卖/少卖 |
任何一个环节出错,都可能导致数据不一致。本地事务?不存在的。
两阶段提交:教科书方案的实践
2PC的基本实现
既然是分布式事务,第一个想到的就是两阶段提交。原理很简单:
第一阶段(准备阶段):
- 协调者问所有参与者:“你们都准备好了吗?”
- 参与者执行事务但不提交,回答"Yes"或"No"
第二阶段(提交阶段):
- 如果都是"Yes",协调者说:“提交!”
- 如果有"No",协调者说:“回滚!”
我们的初版实现:
public class PaymentCoordinator {
private List<TransactionParticipant> participants;
public boolean executeDistributedTransaction(PaymentOrder order) {
// 第一阶段:准备
Map<String, Boolean> prepareResults = new HashMap<>();
for (TransactionParticipant participant : participants) {
try {
boolean prepared = participant.prepare(order);
prepareResults.put(participant.getName(), prepared);
if (!prepared) {
// 有人说No,直接进入回滚
rollbackAll(prepareResults.keySet());
return false;
}
} catch (Exception e) {
// 准备阶段就失败了
rollbackAll(prepareResults.keySet());
return false;
}
}
// 第二阶段:提交
try {
commitAll();
return true;
} catch (Exception e) {
// 提交阶段失败,这是最糟糕的情况
handleCommitFailure();
return false;
}
}
}
第一次压测:惨不忍睹
信心满满地开始压测,结果:
| 并发数 | 成功率 | 平均耗时 | 超时率 | 数据不一致 |
|---|---|---|---|---|
| 10 | 99.5% | 230ms | 0.1% | 0 |
| 100 | 95.2% | 580ms | 2.3% | 0.1% |
| 500 | 78.3% | 2.3s | 15.6% | 0.8% |
| 1000 | 45.6% | 8.5s | 48.2% | 3.2% |
数据不一致率达到3.2%!这意味着1000笔交易中有32笔会出现资金问题,这在金融系统中是绝对不能接受的。
2PC的问题分析
深入分析后,发现2PC的问题比想象的多:
| 问题类型 | 具体表现 | 发生场景 | 影响程度 | 我们遇到的频率 |
|---|---|---|---|---|
| 同步阻塞 | 所有参与者都要等最慢的 | 银行接口慢 | 严重 | 每小时50次 |
| 协调者单点故障 | 协调者挂了全完蛋 | 服务器宕机 | 灾难 | 每天1-2次 |
| 数据不一致 | 部分提交部分失败 | 网络分区 | 灾难 | 每天5-10次 |
| 资源锁定时间长 | 数据库行锁占用 | 高并发时 | 严重 | 持续存在 |
最可怕的是"提交阶段失败"的情况。比如5个参与者,前3个提交成功,第4个网络超时,第5个还在等待。这时候数据已经不一致了,而且很难恢复。
两阶段提交的各种优化尝试
优化1:超时机制
第一个想法是加入超时机制:
// 给每个阶段设置超时时间
private static final long PREPARE_TIMEOUT = 3000; // 3秒
private static final long COMMIT_TIMEOUT = 2000; // 2秒
public boolean prepareWithTimeout(PaymentOrder order) {
Future<Boolean> future = executorService.submit(() -> {
return participant.prepare(order);
});
try {
return future.get(PREPARE_TIMEOUT, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
// 超时当作失败处理
future.cancel(true);
return false;
}
}
效果:
| 指标 | 优化前 | 优化后 | 改善 |
|---|---|---|---|
| 平均响应时间 | 8.5s | 3.2s | 62% |
| 超时率 | 48.2% | 12.3% | 74% |
| 数据不一致率 | 3.2% | 2.8% | 12.5% |
响应时间改善了,但数据一致性问题依然存在。
优化2:事务日志
为了解决协调者单点故障,我们引入了事务日志:
public class TransactionLog {
// 事务状态
enum Status {
INIT, // 初始化
PREPARING, // 准备中
PREPARED, // 已准备
COMMITTING, // 提交中
COMMITTED, // 已提交
ABORTING, // 回滚中
ABORTED // 已回滚
}
// 记录每个参与者的状态
private Map<String, ParticipantStatus> participantStates;
// 持久化到数据库
public void persist() {
// 写入MySQL,保证ACID
}
}
事务恢复流程:
| 崩溃时机 | 恢复策略 | 数据一致性 | 恢复时间 |
|---|---|---|---|
| 准备阶段崩溃 | 全部回滚 | 保证 | <1秒 |
| 全部准备完崩溃 | 重新提交 | 保证 | <2秒 |
| 部分提交后崩溃 | 继续提交 | 风险 | <5秒 |
| 提交完成后崩溃 | 无需恢复 | 保证 | 0 |
优化3:参与者优化
发现有些参与者天生就慢,需要区别对待:
| 参与者类型 | 平均耗时 | 失败率 | 优化策略 | 效果 |
|---|---|---|---|---|
| 内部数据库 | 20ms | 0.01% | 连接池优化 | 降到15ms |
| 缓存服务 | 5ms | 0.001% | 批量操作 | 降到2ms |
| 银行接口 | 800ms | 0.1% | 异步+重试 | 稳定性提升 |
| 消息队列 | 50ms | 0.05% | 本地缓存 | 降到30ms |
现实的妥协:2PC的变种方案
补偿事务(Saga模式)
纯粹的2PC太重了,我们尝试了Saga模式:
public class PaymentSaga {
// 定义每个步骤及其补偿操作
private List<SagaStep> steps = Arrays.asList(
new SagaStep("扣款", this::debitAccount, this::creditAccount),
new SagaStep("创建订单", this::createOrder, this::cancelOrder),
new SagaStep("扣库存", this::decreaseStock, this::increaseStock),
new SagaStep("通知商户", this::notifyMerchant, this::cancelNotify)
);
public boolean execute(PaymentContext context) {
int executedSteps = 0;
try {
for (SagaStep step : steps) {
step.execute(context);
executedSteps++;
}
return true;
} catch (Exception e) {
// 反向补偿
compensate(context, executedSteps);
return false;
}
}
}
Saga vs 2PC对比:
| 特性 | 2PC | Saga | 适用场景 |
|---|---|---|---|
| 一致性 | 强一致 | 最终一致 | Saga适合长事务 |
| 性能 | 差 | 好 | Saga高并发友好 |
| 复杂度 | 中 | 高 | 2PC相对简单 |
| 隔离性 | 好 | 差 | 2PC适合金融场景 |
TCC模式(Try-Confirm-Cancel)
对于资金相关的核心操作,我们采用TCC:
public interface TccService {
// Try:预留资源
String tryPay(BigDecimal amount, String accountId);
// Confirm:确认执行
boolean confirmPay(String reservationId);
// Cancel:取消预留
boolean cancelPay(String reservationId);
}
实际效果对比:
| 方案 | 实现复杂度 | 性能 | 一致性保证 | 资源占用 | 我们的选择 |
|---|---|---|---|---|---|
| 纯2PC | ★★★☆☆ | ★☆☆☆☆ | ★★★★★ | ★★★★★ | 核心资金流 |
| Saga | ★★★★☆ | ★★★★☆ | ★★★☆☆ | ★★☆☆☆ | 订单流程 |
| TCC | ★★★★★ | ★★★☆☆ | ★★★★☆ | ★★★☆☆ | 库存预扣 |
| 本地消息表 | ★★☆☆☆ | ★★★★★ | ★★☆☆☆ | ★☆☆☆☆ | 通知类 |
最终方案:混合架构
经过反复权衡,我们最终采用了混合方案:
核心支付流程(强一致性要求)
使用优化后的2PC,但只包含最核心的操作:
| 参与者 | 操作 | 超时时间 | 回滚策略 |
|---|---|---|---|
| 账务系统 | 冻结资金 | 1秒 | 解冻资金 |
| 银行网关 | 发起扣款 | 3秒 | 退款接口 |
| 商户账户 | 预增金额 | 1秒 | 撤销预增 |
订单状态更新(最终一致性)
使用消息队列+本地事务表:
-- 本地消息表
CREATE TABLE payment_event (
id BIGINT PRIMARY KEY,
order_id VARCHAR(64),
event_type VARCHAR(32),
payload TEXT,
status TINYINT, -- 0:待发送 1:已发送 2:已确认
retry_count INT DEFAULT 0,
create_time TIMESTAMP,
update_time TIMESTAMP,
INDEX idx_status_create(status, create_time)
);
消息可靠性保证:
| 保证机制 | 实现方式 | 效果 | 成本 |
|---|---|---|---|
| 事务写入 | 本地事务 | 100%不丢 | 低 |
| 定时扫描 | 每30秒扫描未发送 | 防止遗漏 | 低 |
| 消费确认 | ACK机制 | 至少一次 | 中 |
| 幂等处理 | 唯一ID去重 | 防重复 | 低 |
监控和补偿机制
建立了完善的监控体系:
| 监控指标 | 告警阈值 | 检查频率 | 自动处理 |
|---|---|---|---|
| 事务成功率 | <99.9% | 实时 | 限流降级 |
| 长事务数 | >100 | 1分钟 | 强制超时 |
| 数据一致性 | 任何不一致 | 5分钟 | 人工介入 |
| 补偿失败 | >10次/小时 | 实时 | 告警升级 |
双十一实战数据
最终方案在双十一的表现:
| 时间段 | TPS | 成功率 | 平均耗时 | 不一致事务 | 资损 |
|---|---|---|---|---|---|
| 00:00-08:00 | 5000 | 99.98% | 125ms | 12笔 | 0 |
| 08:00-12:00 | 15000 | 99.95% | 156ms | 95笔 | 0 |
| 12:00-14:00 | 8000 | 99.97% | 143ms | 28笔 | 0 |
| 14:00-22:00 | 20000 | 99.94% | 189ms | 156笔 | 0 |
| 22:00-24:00 | 35000 | 99.91% | 235ms | 342笔 | 0 |
| 全天汇总 | - | 99.94% | 178ms | 633笔 | 0 |
633笔不一致事务全部通过补偿机制自动修复,无一例资损!
踩坑总结
1. 2PC不是万能的
- 适合场景:参与者少、网络稳定、强一致性要求
- 不适合:长事务、跨机房、高并发
2. 设计要务实
不要追求完美的ACID,要根据业务特点选择:
- 资金:强一致性,可以牺牲性能
- 订单:最终一致性,追求高性能
- 通知:尽力而为,可以有损
3. 监控比方案更重要
再完美的方案也会出问题,关键是:
- 快速发现问题
- 快速定位原因
- 快速修复数据
4. 简单可靠 > 复杂完美
我们曾经设计过一个"完美"的分布式事务框架,支持各种模式,结果:
- 代码复杂,bug多
- 运维困难,不敢改
- 性能差,扛不住压力
最后还是回归简单方案,反而稳定可靠。
写在最后
分布式事务是分布式系统永恒的难题。这次支付系统的改造让我深刻体会到:没有银弹,只有权衡。
两阶段提交在理论上很美好,但实践中问题重重。最终我们通过分层设计、混合方案、完善监控,才勉强达到生产要求。这个过程充满了妥协和取舍。
如果你也在做分布式事务,我的建议是:
- 先想清楚一致性要求,不要过度设计
- 从简单方案开始,逐步优化
- 监控和补偿比预防更重要
- 保持敬畏之心,分布式系统总会出问题的
记住:分布式事务的本质是在CAP中找平衡,完美是不存在的,适合才是最好的。
最后,如果你有更好的分布式事务实践经验,欢迎交流!在这个领域,我们都是学生。
- 点赞
- 收藏
- 关注作者
评论(0)