分布式事务太难?用Spring Boot手把手带你落地实战

半个架构师
2026-01-06 03:21
阅读 444

大家好,我是掘金上常写入门教程的全栈工程师,985科班出身,带过不少零基础转码的同学。最近有好几位刚入行的朋友问我:“分布式事务到底怎么搞?网上资料又多又乱,看得头大。”我特别理解这种感受——我当初学的时候也是一头雾水,光是“两阶段提交”、“TCC”这些词就让人想放弃。

其实,分布式事务没那么神秘。只要结合真实业务场景,用代码一步步跑起来,你很快就能掌握核心思路。今天这篇文章,我就用一个电商运营中的典型资源扣减案例,带你从零开始,用 Spring Boot 实现一套可落地的分布式事务解决方案。


为什么你需要关心分布式事务?

想象这样一个场景:你在做一个电商平台,用户下单时需要同时做两件事:

  1. 扣减库存(调用库存服务)
  2. 创建订单(调用订单服务)

这两个操作必须要么都成功,要么都失败。如果只扣了库存但没生成订单,商品就“消失”了;如果只生成了订单但没扣库存,就会超卖。

在单体应用里,这事很简单:一个数据库事务搞定。但在微服务架构下,库存和订单分属不同服务、不同数据库,本地事务失效了——这就是“分布式事务”要解决的问题。

💡 关键词解释

  • 资源:这里指数据库记录、库存数量、账户余额等需要被一致性保护的数据。
  • 运营:实际业务场景(如下单、支付、退款)对数据一致性的强需求。
  • 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 Driver
  • stock-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-serviceapplication.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-serviceapplication.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 字段,消费时按序处理。


学习建议:下一步怎么走?

  1. 动手跑通代码:把本文示例完整敲一遍,故意制造网络错误测试重试机制。
  2. 扩展思考
    • 如果库存服务扣减成功,但订单服务更新状态失败怎么办?(答案:在库存服务加“回查订单状态”接口)
    • 如何支持“取消订单”时回滚库存?(答案:发“STOCK_REVERT”事件)
  3. 进阶学习路径
    • 理解 CAP 理论:为什么分布式系统无法同时满足强一致性和高可用?
    • 学习 Seata 的 AT/TCC 模式,对比本地消息表的优劣
    • 了解 RocketMQ 的事务消息机制(本质是本地消息表的升级版)

🌟 我的经验之谈不要陷入“技术完美主义”。我见过太多新人花几周研究 Seata 源码,却连最基本的本地消息表都没跑通。先跑起来,再优化——这是工程思维的核心。


结语

分布式事务听起来高大上,但拆解到具体业务,就是“如何让多个操作一起成功或一起失败”的问题。通过本地消息表,我们用最朴素的方式解决了电商下单的一致性难题,无需复杂中间件,代码清晰可控

希望这篇教程能帮你迈出分布式系统的第一步。如果你跟着做了一遍,恭喜你——你已经比 80% 只看理论的人走得更远了!

有任何问题欢迎在评论区留言,我会一一解答。觉得有用的话,别忘了点赞收藏,你的支持是我持续输出的动力!

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝