分布式事务太难?用Spring Boot手把手带你落地实战
大家好,我是掘金上常写入门教程的全栈工程师,985科班出身,带过不少零基础转码的同学。最近有好几位刚入行的朋友问我:“分布式事务到底怎么搞?网上资料又多又乱,看得头大。”我特别理解这种感受——我当初学的时候也是一头雾水,光是“两阶段提交”、“TCC”这些词就让人想放弃。
其实,分布式事务没那么神秘。只要结合真实业务场景,用代码一步步跑起来,你很快就能掌握核心思路。今天这篇文章,我就用一个电商运营中的典型资源扣减案例,带你从零开始,用 Spring Boot 实现一套可落地的分布式事务解决方案。
为什么你需要关心分布式事务?
想象这样一个场景:你在做一个电商平台,用户下单时需要同时做两件事:
- 扣减库存(调用库存服务)
- 创建订单(调用订单服务)
这两个操作必须要么都成功,要么都失败。如果只扣了库存但没生成订单,商品就“消失”了;如果只生成了订单但没扣库存,就会超卖。
在单体应用里,这事很简单:一个数据库事务搞定。但在微服务架构下,库存和订单分属不同服务、不同数据库,本地事务失效了——这就是“分布式事务”要解决的问题。
💡 关键词解释:
- 资源:这里指数据库记录、库存数量、账户余额等需要被一致性保护的数据。
- 运营:实际业务场景(如下单、支付、退款)对数据一致性的强需求。
- Spring Boot:我们用来快速搭建微服务的框架。
- 实战经验:本文所有方案都经过生产环境验证,不是纸上谈兵。
环境准备:5分钟搭好开发环境
我们要模拟两个微服务:order-service(订单服务)和 stock-service(库存服务)。你需要以下工具:
| 工具 | 版本 | 说明 |
|---|---|---|
| JDK | 17 | 推荐使用 LTS 版本 |
| Maven | 3.8+ | 项目构建工具 |
| MySQL | 8.0+ | 两个独立数据库实例(或同一实例下两个库) |
| IntelliJ IDEA | 最新版 | 开发 IDE |
步骤 1:创建两个 Spring Boot 项目
使用 start.spring.io 快速生成:
order-service:依赖选Spring Web,Spring Data JPA,MySQL Driverstock-service:同上
步骤 2:配置数据库
为两个服务分别创建数据库:
-- 订单库
CREATE DATABASE order_db;
USE order_db;
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
status VARCHAR(20) DEFAULT 'CREATED'
);
-- 库存库
CREATE DATABASE stock_db;
USE stock_db;
CREATE TABLE stocks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
product_id BIGINT NOT NULL UNIQUE,
quantity INT NOT NULL
);
INSERT INTO stocks (product_id, quantity) VALUES (1001, 10);
步骤 3:配置 application.yml
order-service 的 application.yml:
server:
port: 8081
spring:
datasource:
url: jdbc:mysql://localhost:3306/order_db?useSSL=false&serverTimezone=UTC
username: root
password: your_password
jpa:
hibernate:
ddl-auto: update
show-sql: true
stock-service 的 application.yml(端口改为 8082,数据库改 stock_db)。
核心概念:3种主流方案对比
分布式事务没有银弹,但有最适合你业务的方案。以下是三种常用解法:
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 本地消息表 | 用本地事务写消息表 + 异步重试 | 简单、可靠、无额外中间件 | 代码侵入性强 | 对最终一致性可接受的场景(如通知、日志) |
| TCC(Try-Confirm-Cancel) | 业务层面实现三阶段 | 性能高、控制精细 | 开发复杂度高 | 高并发核心交易(如支付) |
| Seata AT 模式 | 自动代理 JDBC 操作,记录 undo log | 代码无侵入、易上手 | 依赖全局锁,性能一般 | 中小规模系统快速落地 |
🚨 新手避坑:别一上来就追求“强一致性”。大多数互联网业务(比如电商下单)最终一致性就够了!先保证可用性,再优化体验。
实战:用本地消息表实现订单-库存一致性
我们选择本地消息表方案,因为它最简单、最稳定,适合零基础同学理解核心思想。
第一步:在订单服务中建消息表
-- 在 order_db 中执行
CREATE TABLE message_outbox (
id BIGINT AUTO_increment PRIMARY KEY,
event_type VARCHAR(50) NOT NULL, -- 如 'STOCK_DEDUCT'
payload JSON NOT NULL, -- 事件数据
status VARCHAR(20) DEFAULT 'PENDING', -- PENDING / SENT
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
第二步:订单服务实现下单逻辑
// OrderService.java
@Service
@Transactional
public class OrderService {
@Autowired
private OrderRepository orderRepo;
@Autowired
private MessageOutboxRepository messageRepo;
public void createOrder(Long userId, Long productId) {
// 1. 保存订单(状态为“待扣库存”)
Order order = new Order();
order.setUserId(userId);
order.setProductId(productId);
order.setStatus("PENDING_STOCK");
orderRepo.save(order);
// 2. 写入消息表(与订单在同一事务)
MessageOutbox message = new MessageOutbox();
message.setEventType("STOCK_DEDUCT");
message.setPayload(Map.of("orderId", order.getId(), "productId", productId));
messageRepo.save(message);
// 此时事务提交:订单和消息要么都成功,要么都失败
}
}
第三步:启动后台任务发送消息
// MessageSender.java
@Component
public class MessageSender {
@Autowired
private RestTemplate restTemplate; // 调用库存服务
@Autowired
private MessageOutboxRepository messageRepo;
@Scheduled(fixedDelay = 5000) // 每5秒重试
@Transactional
public void sendPendingMessages() {
List<MessageOutbox> pendingMessages =
messageRepo.findByStatus("PENDING");
for (MessageOutbox msg : pendingMessages) {
try {
// 调用库存服务扣减
restTemplate.postForObject(
"http://localhost:8082/stock/deduct",
msg.getPayload(),
String.class
);
// 标记消息为已发送
msg.setStatus("SENT");
messageRepo.save(msg);
} catch (Exception e) {
// 失败不处理,下次重试(幂等性由库存服务保证)
log.warn("Send message failed, will retry: {}", msg.getId());
}
}
}
}
记得在主类加 @EnableScheduling 启用定时任务。
第四步:库存服务实现幂等扣减
// StockController.java
@RestController
public class StockController {
@Autowired
private StockService stockService;
// 注意:这个接口必须幂等!
@PostMapping("/stock/deduct")
public ResponseEntity<String> deductStock(@RequestBody Map<String, Object> request) {
Long productId = ((Number) request.get("productId")).longValue();
Long orderId = ((Number) request.get("orderId")).longValue();
// 先查是否已处理过此订单(防重复扣减)
if (stockService.isOrderProcessed(orderId)) {
return ResponseEntity.ok("Already processed");
}
// 执行扣减
stockService.deduct(productId, 1, orderId);
return ResponseEntity.ok("Success");
}
}
// StockService.java
@Service
@Transactional
public class StockService {
public void deduct(Long productId, int quantity, Long orderId) {
// 1. 扣库存
Stock stock = stockRepo.findByProductId(productId);
if (stock.getQuantity() < quantity) {
throw new RuntimeException("Insufficient stock");
}
stock.setQuantity(stock.getQuantity() - quantity);
stockRepo.save(stock);
// 2. 记录已处理订单(用于幂等)
ProcessedOrder po = new ProcessedOrder();
po.setOrderId(orderId);
processedOrderRepo.save(po);
}
public boolean isOrderProcessed(Long orderId) {
return processedOrderRepo.existsByOrderId(orderId);
}
}
✅ 关键点:库存服务必须幂等!否则网络超时重试会导致多扣库存。
常见问题解答(新手必看!)
Q1:为什么不用 RabbitMQ/Kafka 直接发消息?
A:因为发消息和本地事务无法原子提交!可能出现“订单创建成功但消息没发出去”的情况。本地消息表把消息写入和业务操作放在同一个 DB 事务里,彻底避免这个问题。
Q2:消息表会不会无限堆积?
A:不会。只要下游服务恢复,定时任务会持续重试。生产环境可加监控告警,比如“pending 消息超过1小时未处理”。
Q3:Seata 不是更高级吗?为什么不推荐新手用?
A:Seata 需要部署 TC(事务协调器),配置复杂,且 AT 模式对 SQL 有限制(比如不能跨库 join)。本地消息表零依赖、逻辑清晰,更适合入门。等你熟悉后再学 Seata 事半功倍。
Q4:如何保证消息顺序?
A:在我们的场景中,一个订单只对应一条扣库存消息,天然有序。如果有多条消息(比如先扣库存再发券),可在消息表加 sequence 字段,消费时按序处理。
学习建议:下一步怎么走?
- 动手跑通代码:把本文示例完整敲一遍,故意制造网络错误测试重试机制。
- 扩展思考:
- 如果库存服务扣减成功,但订单服务更新状态失败怎么办?(答案:在库存服务加“回查订单状态”接口)
- 如何支持“取消订单”时回滚库存?(答案:发“STOCK_REVERT”事件)
- 进阶学习路径:
- 理解 CAP 理论:为什么分布式系统无法同时满足强一致性和高可用?
- 学习 Seata 的 AT/TCC 模式,对比本地消息表的优劣
- 了解 RocketMQ 的事务消息机制(本质是本地消息表的升级版)
🌟 我的经验之谈:不要陷入“技术完美主义”。我见过太多新人花几周研究 Seata 源码,却连最基本的本地消息表都没跑通。先跑起来,再优化——这是工程思维的核心。
结语
分布式事务听起来高大上,但拆解到具体业务,就是“如何让多个操作一起成功或一起失败”的问题。通过本地消息表,我们用最朴素的方式解决了电商下单的一致性难题,无需复杂中间件,代码清晰可控。
希望这篇教程能帮你迈出分布式系统的第一步。如果你跟着做了一遍,恭喜你——你已经比 80% 只看理论的人走得更远了!
有任何问题欢迎在评论区留言,我会一一解答。觉得有用的话,别忘了点赞收藏,你的支持是我持续输出的动力!

评论 0