1. 问题背景与现象描述
那天下午三点十七分,我正端着第三杯咖啡准备解决一个简单的用户积分更新需求。系统突然报警,生产环境出现大量"积分扣除成功但商品未发放"的异常订单。查看日志发现,积分服务的事务提交了,但商品服务却因网络抖动导致事务回滚。这个典型的分布式事务问题,让我不得不放下咖啡杯开始长达8小时的排查之旅。
在微服务架构下,这类跨服务的事务问题尤为常见。我们的系统采用Spring Cloud架构,积分服务和商品服务分别独立部署,通过RESTful API交互。当用户用积分兑换商品时,需要先扣减积分再发放商品,这两个操作必须保持原子性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 初步排查与问题定位
2.1 日志分析三板斧
首先通过ELK日志系统检索异常时间段的请求流水。关键日志显示:
code复制[积分服务] 11:23:45.123 INFO 扣减用户100积分成功,事务ID:tx-789012
[商品服务] 11:23:45.456 ERROR 商品库存不足,事务回滚 tx-789012
这明显出现了部分提交的情况。在分布式系统中,这种"半成功"状态是最危险的数据不一致场景。
2.2 事务传播机制验证
检查代码发现两个致命问题:
- 使用默认的
@Transactional(propagation=REQUIRED),在商品服务异常时只会回滚本地事务 - 没有实现任何补偿机制,积分扣除后无法自动恢复
java复制// 有问题的原始代码
@PostMapping("/exchange")
public Result exchange(@RequestBody OrderDTO dto) {
// 扣积分(服务A)
pointsService.decrease(dto.getUserId(), dto.getPoints());
// 发商品(服务B)
goodsService.deliver(dto.getUserId(), dto.getGoodsId());
return Result.success();
}
2.3 分布式事务边界确认
通过Arthas工具动态监控事务状态,确认了两个关键事实:
- 积分服务的MySQL事务确实已提交
- 商品服务的Hibernate会话在抛出异常后执行了rollback
这说明问题不是简单的本地事务失效,而是跨服务事务缺乏协调机制导致的。
3. 解决方案设计与验证
3.1 方案选型对比
考虑过三种主流分布式事务方案:
| 方案 | 一致性 | 性能影响 | 改造成本 | 适用场景 |
|---|---|---|---|---|
| 2PC/XA | 强一致 | 高 | 高 | 银行核心系统 |
| TCC模式 | 最终 | 中 | 中 | 电商交易 |
| 本地消息表+定时任务 | 最终 | 低 | 低 | 本次积分兑换场景 |
最终选择本地消息表方案,因为:
- 积分兑换对实时性要求不高
- 系统已有定时任务框架
- 改造只需新增三张表,不引入新中间件
3.2 具体实现细节
3.2.1 数据库表设计
sql复制CREATE TABLE transaction_log (
id BIGINT PRIMARY KEY,
biz_id VARCHAR(64) NOT NULL COMMENT '业务ID',
status TINYINT NOT NULL COMMENT '1处理中 2成功 3失败',
payload JSON NOT NULL COMMENT '业务数据',
retry_count INT DEFAULT 0,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME ON UPDATE CURRENT_TIMESTAMP,
KEY idx_biz_id (biz_id),
KEY idx_status_retry (status, retry_count)
);
CREATE TABLE transaction_confirm (
id BIGINT PRIMARY KEY,
log_id BIGINT NOT NULL,
confirm_status TINYINT NOT NULL,
error_msg VARCHAR(255),
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE transaction_compensate (
id BIGINT PRIMARY KEY,
log_id BIGINT NOT NULL,
compensate_type VARCHAR(32) NOT NULL,
compensate_status TINYINT NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
3.2.2 核心代码改造
java复制@Transactional
public void executeWithTransaction(OrderDTO dto) {
// 1. 记录事务日志
TransactionLog log = new TransactionLog();
log.setBizId(generateBizId());
log.setStatus(1);
log.setPayload(JSON.toJSONString(dto));
transactionLogMapper.insert(log);
try {
// 2. 执行业务操作
pointsService.decrease(dto.getUserId(), dto.getPoints());
// 3. 记录确认日志
TransactionConfirm confirm = new TransactionConfirm();
confirm.setLogId(log.getId());
confirm.setConfirmStatus(1);
transactionConfirmMapper.insert(confirm);
// 4. 发送商品(可能失败)
goodsService.deliver(dto.getUserId(), dto.getGoodsId());
// 5. 更新状态
log.setStatus(2);
transactionLogMapper.updateById(log);
} catch (Exception e) {
// 6. 失败时记录补偿任务
TransactionCompensate compensate = new TransactionCompensate();
compensate.setLogId(log.getId());
compensate.setCompensateType("POINTS_REFUND");
compensate.setCompensateStatus(0);
transactionCompensateMapper.insert(compensate);
log.setStatus(3);
transactionLogMapper.updateById(log);
throw e;
}
}
3.3 补偿任务实现
java复制@Scheduled(fixedDelay = 30000)
public void compensateTask() {
List<TransactionCompensate> tasks = transactionCompensateMapper
.selectList(new LambdaQueryWrapper<TransactionCompensate>()
.eq(TransactionCompensate::getCompensateStatus, 0)
.lt(TransactionCompensate::getCreateTime, LocalDateTime.now().minusMinutes(5))
.last("LIMIT 100"));
tasks.forEach(task -> {
TransactionLog log = transactionLogMapper.selectById(task.getLogId());
OrderDTO dto = JSON.parseObject(log.getPayload(), OrderDTO.class);
try {
if ("POINTS_REFUND".equals(task.getCompensateType())) {
pointsService.increase(dto.getUserId(), dto.getPoints());
}
task.setCompensateStatus(1);
transactionCompensateMapper.updateById(task);
} catch (Exception e) {
log.error("补偿任务执行失败", e);
task.setRetryCount(task.getRetryCount() + 1);
if (task.getRetryCount() > 5) {
task.setCompensateStatus(2); // 标记为失败
alertService.send("补偿任务多次失败:" + task.getId());
}
transactionCompensateMapper.updateById(task);
}
});
}
4. 上线验证与监控完善
4.1 验证方案设计
为确保方案可靠性,设计了三级验证:
- 单元测试:Mock商品服务异常,验证补偿逻辑
- 集成测试:在测试环境切断商品服务网络连接
- 混沌工程:在生产环境灰度发布时随机拒绝商品服务请求
4.2 监控指标建设
新增Prometheus监控指标:
code复制transaction_status{type="main"} // 主事务状态
transaction_compensate_total // 补偿任务总数
transaction_retry_count // 重试次数分布
transaction_latency_seconds // 事务完成延迟
配置Grafana看板监控:
- 事务成功率趋势图
- 补偿任务积压监控
- 平均补偿延迟监控
4.3 关键性能优化
发现的两个性能瓶颈及解决方案:
- 日志表索引问题:原设计缺少(status, create_time)联合索引,导致补偿任务查询慢
sql复制ALTER TABLE transaction_log ADD INDEX idx_status_time (status, create_time); - JSON序列化开销:高频操作中JSON解析消耗15%CPU
java复制// 改用Protobuf序列化后性能提升40% log.setPayload(OrderDTOProto.toByteArray(dto));
5. 经验总结与避坑指南
5.1 分布式事务设计原则
- 业务可补偿性:所有扣减类操作必须设计对应的逆向操作
- 操作幂等性:补偿操作可能重复执行,必须保证多次执行结果一致
- 日志可追溯:完整记录事务生命周期,方便问题排查
- 最终一致性:明确告知业务方系统是最终一致,避免业务误解
5.2 常见陷阱清单
-
Spring事务传播误区:
- 以为
@Transactional能跨服务生效 - 在private方法上使用注解(不生效)
- 捕获异常不抛出导致不回滚
- 以为
-
补偿任务设计坑:
- 忘记设置最大重试次数
- 补偿操作不是幂等的
- 没有补偿失败告警机制
-
性能优化雷区:
- 事务日志表缺少合适索引
- 序列化方案选择不当
- 补偿任务单次处理量过大
5.3 推荐工具链
-
问题诊断:
- Arthas:动态查看事务状态
- SkyWalking:分布式链路追踪
- Prometheus + Grafana:事务指标监控
-
方案验证:
- ChaosBlade:模拟网络分区
- JMeter:压力测试补偿任务
- Mockito:单元测试异常场景
-
生产保障:
- Sentinel:补偿任务限流
- Elastic-Job:分布式调度补偿
- Canal:基于binlog的最终一致性方案
这次事故给我的最大教训是:在分布式系统中,没有完美的全局事务方案,只有适合业务场景的取舍。选择方案时要综合考虑业务容忍度、团队技术栈和运维成本,同时必须建立完善的监控和应急机制。
