分布式事务 - 钱不能算错,库存不能扣重复

5 阅读11分钟

一、先白话白话分布式事务多重要

零基础全栈开发Java微服务版本实战-后端-前端-运维-实战企业级三个实战项目

资源获取:关注公众号: 小坏说Java ,获取本文所有示例代码、配置模板及导出工具。

场景:用户下单买烩面

  1. 创建订单(order-service)
  2. 扣库存(product-service)
  3. 扣余额(account-service)

问题

  • 订单创建成功了,库存没扣 -> 超卖了
  • 库存扣了,余额不够 -> 库存白扣了
  • 余额扣了,订单没创建 -> 钱没了,订单没有

这就是分布式事务问题:多个服务操作数据库,要保证要么一起成功,要么一起失败

本地事务 vs 分布式事务

  • 本地事务:一个数据库里,用@Transactional搞定
  • 分布式事务:多个数据库/服务,@Transactional管不了跨服务

二、分布式事务的几种方案

方案1:2PC(两阶段提交) - 老村长模式

  • 阶段一:村长问大家“能结婚不?”(准备阶段)
  • 阶段二:大家都说“能”,村长说“结!”(提交阶段)
  • 缺点:同步阻塞,性能差,协调者单点故障

方案2:TCC(Try-Confirm-Cancel) - 预订模式

  • Try:预订(冻结资源)
  • Confirm:确认(使用资源)
  • Cancel:取消(释放资源)
  • 优点:性能好
  • 缺点:代码复杂,要写三个接口

方案3:本地消息表 - 写信模式

  • 本地事务+消息
  • 可靠消息最终一致性
  • 优点:实现简单
  • 缺点:消息可能重复消费

方案4:Seata的AT模式 - 自动模式(推荐)

  • 自动回滚
  • 不用改业务代码
  • Spring Cloud Alibaba集成好

三、Seata是啥?

零基础全栈开发Java微服务版本实战-后端-前端-运维-实战企业级三个实战项目

资源获取:关注公众号: 小坏说Java ,获取本文所有示例代码、配置模板及导出工具。

Seata(Simple Extensible Autonomous Transaction Architecture)是阿里巴巴开源的分布式事务解决方案。

三个角色:

  1. TC(Transaction Coordinator):事务协调者(村长)

    • 管理全局事务状态
    • 协调分支事务提交/回滚
  2. TM(Transaction Manager):事务管理器(婚礼司仪)

    • 开启全局事务
    • 提交/回滚全局事务
  3. RM(Resource Manager):资源管理器(各个家庭)

    • 管理分支事务
    • 向TC注册分支事务
    • 报告分支事务状态

工作流程(以结婚为例):

  1. TM说:“开始结婚事务!”(@GlobalTransactional)
  2. 新郎家RM:准备结婚(执行SQL,生成UNDO_LOG)
  3. 新娘家RM:准备结婚(执行SQL,生成UNDO_LOG)
  4. TC记录事务状态
  5. 都准备好了,TM说:“提交!”
  6. TC通知各家删除UNDO_LOG

如果出问题:

  • 任何一家说“不行”
  • TM说:“回滚!”
  • TC通知各家根据UNDO_LOG回滚

四、搭Seata环境

步骤1:下载Seata Server

  1. 官网:seata.io
  2. 下载1.5.0以上版本
  3. 解压到D:\seata

步骤2:改配置

1. 改存储模式(用file就行,生产用db)

seata\conf\file.conf

## transaction log store
store {
  ## store mode: file、db
  mode = "file"  # 先用file,简单
  
  ## file store property
  file {
    dir = "sessionStore"
  }
}

2. 改注册中心(用nacos)

seata\conf\registry.conf

registry {
  type = "nacos"  # 用nacos
  
  nacos {
    application = "seata-server"
    serverAddr = "127.0.0.1:8848"
    group = "SEATA_GROUP"
    namespace = ""
    cluster = "default"
    username = "nacos"
    password = "nacos"
  }
}

config {
  type = "nacos"
  
  nacos {
    serverAddr = "127.0.0.1:8848"
    namespace = ""
    group = "SEATA_GROUP"
    username = "nacos"
    password = "nacos"
  }
}

步骤3:启动Seata Server

cd D:\seata\bin
# Windows
seata-server.bat

# Linux/Mac
sh seata-server.sh

启动后,看到:

Server started at port: 8091

五、创建示例项目:分布式下单

零基础全栈开发Java微服务版本实战-后端-前端-运维-实战企业级三个实战项目

资源获取:关注公众号: 小坏说Java ,获取本文所有示例代码、配置模板及导出工具。

项目结构:

distributed-order-demo/
├── order-service/     # 订单服务
├── product-service/   # 商品服务(库存)
├── account-service/   # 账户服务(余额)
└── seata-server/      # Seata Server

步骤1:建数据库

-- 订单库
CREATE DATABASE `order_db`;
USE `order_db`;

CREATE TABLE `order` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` bigint NOT NULL,
  `product_id` bigint NOT NULL,
  `count` int NOT NULL COMMENT '购买数量',
  `money` decimal(10,2) NOT NULL COMMENT '金额',
  `status` int DEFAULT '0' COMMENT '状态:0-创建中,1-已完成',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 商品库
CREATE DATABASE `product_db`;
USE `product_db`;

CREATE TABLE `product` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `product_name` varchar(100) NOT NULL,
  `stock` int NOT NULL COMMENT '库存',
  `price` decimal(10,2) NOT NULL COMMENT '单价',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `product` VALUES (1, '河南烩面', 100, 15.00);

-- 账户库
CREATE DATABASE `account_db`;
USE `account_db`;

CREATE TABLE `account` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` bigint NOT NULL,
  `balance` decimal(10,2) NOT NULL COMMENT '余额',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `account` VALUES (1, 1, 1000.00);

-- UNDO_LOG表(每个库都要建)
CREATE TABLE `undo_log` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `branch_id` bigint NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int NOT NULL,
  `log_created` datetime NOT NULL,
  `log_modified` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

步骤2:创建订单服务

1. 加依赖

order-service/pom.xml

<!-- Seata -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>

<!-- MyBatis Plus -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>

<!-- MySQL驱动 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

2. 配置文件

bootstrap.yml

spring:
  application:
    name: order-service
  
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
    
    # Seata配置
    alibaba:
      seata:
        tx-service-group: my_tx_group  # 事务组,要跟Seata Server配置一致

# Seata配置
seata:
  enabled: true
  application-id: order-service
  tx-service-group: my_tx_group
  enable-auto-data-source-proxy: true  # 自动代理数据源
  
  config:
    type: nacos
    nacos:
      server-addr: localhost:8848
      group: SEATA_GROUP
      namespace: ""
  
  registry:
    type: nacos
    nacos:
      application: seata-server
      server-addr: localhost:8848
      namespace: ""
      group: SEATA_GROUP

application.yml

server:
  port: 8081

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/order_db?useSSL=false
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver

mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

3. 业务代码

Order实体:

@Data
@TableName("order")
public class Order {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long userId;
    private Long productId;
    private Integer count;
    private BigDecimal money;
    private Integer status;  // 0-创建中,1-已完成
}

OrderMapper

@Mapper
public interface OrderMapper extends BaseMapper<Order> {
}

OrderService

@Service
public class OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    @Autowired
    private RestTemplate restTemplate;
    
    // 创建订单(分布式事务入口)
    @GlobalTransactional  // 重点!开启全局事务
    public Order createOrder(Long userId, Long productId, Integer count) {
        // 1. 计算金额(调用商品服务获取价格)
        BigDecimal price = getProductPrice(productId);
        BigDecimal money = price.multiply(new BigDecimal(count));
        
        // 2. 创建订单(本地事务)
        Order order = new Order();
        order.setUserId(userId);
        order.setProductId(productId);
        order.setCount(count);
        order.setMoney(money);
        order.setStatus(0);  // 创建中
        orderMapper.insert(order);
        
        // 3. 扣库存(远程调用)
        boolean stockSuccess = decreaseStock(productId, count);
        if (!stockSuccess) {
            throw new RuntimeException("库存不足");
        }
        
        // 4. 扣余额(远程调用)
        boolean accountSuccess = decreaseAccount(userId, money);
        if (!accountSuccess) {
            throw new RuntimeException("余额不足");
        }
        
        // 5. 更新订单状态
        order.setStatus(1);  // 已完成
        orderMapper.updateById(order);
        
        return order;
    }
    
    private BigDecimal getProductPrice(Long productId) {
        // 调用商品服务获取价格
        String url = "http://product-service/product/price/" + productId;
        return restTemplate.getForObject(url, BigDecimal.class);
    }
    
    private boolean decreaseStock(Long productId, Integer count) {
        // 调用商品服务扣库存
        String url = "http://product-service/product/decreaseStock";
        Map<String, Object> params = new HashMap<>();
        params.put("productId", productId);
        params.put("count", count);
        
        Boolean result = restTemplate.postForObject(
            url, params, Boolean.class);
        return Boolean.TRUE.equals(result);
    }
    
    private boolean decreaseAccount(Long userId, BigDecimal money) {
        // 调用账户服务扣余额
        String url = "http://account-service/account/decrease";
        Map<String, Object> params = new HashMap<>();
        params.put("userId", userId);
        params.put("money", money);
        
        Boolean result = restTemplate.postForObject(
            url, params, Boolean.class);
        return Boolean.TRUE.equals(result);
    }
}

OrderController

@RestController
@RequestMapping("/order")
public class OrderController {
    
    @Autowired
    private OrderService orderService;
    
    @PostMapping("/create")
    public String createOrder(@RequestParam Long userId,
                              @RequestParam Long productId,
                              @RequestParam Integer count) {
        try {
            Order order = orderService.createOrder(userId, productId, count);
            return "订单创建成功,订单ID:" + order.getId();
        } catch (Exception e) {
            return "订单创建失败:" + e.getMessage();
        }
    }
}

步骤3:创建商品服务

ProductService

@Service
public class ProductService {
    
    @Autowired
    private ProductMapper productMapper;
    
    @Transactional  // 本地事务注解
    public boolean decreaseStock(Long productId, Integer count) {
        // 查询商品
        Product product = productMapper.selectById(productId);
        if (product == null) {
            throw new RuntimeException("商品不存在");
        }
        
        // 检查库存
        if (product.getStock() < count) {
            throw new RuntimeException("库存不足");
        }
        
        // 扣库存
        product.setStock(product.getStock() - count);
        productMapper.updateById(product);
        
        return true;
    }
    
    public BigDecimal getPrice(Long productId) {
        Product product = productMapper.selectById(productId);
        return product != null ? product.getPrice() : BigDecimal.ZERO;
    }
}

ProductController

@RestController
@RequestMapping("/product")
public class ProductController {
    
    @Autowired
    private ProductService productService;
    
    @PostMapping("/decreaseStock")
    public boolean decreaseStock(@RequestBody Map<String, Object> params) {
        Long productId = Long.valueOf(params.get("productId").toString());
        Integer count = Integer.valueOf(params.get("count").toString());
        return productService.decreaseStock(productId, count);
    }
    
    @GetMapping("/price/{productId}")
    public BigDecimal getPrice(@PathVariable Long productId) {
        return productService.getPrice(productId);
    }
}

步骤4:创建账户服务

AccountService

@Service
public class AccountService {
    
    @Autowired
    private AccountMapper accountMapper;
    
    @Transactional
    public boolean decrease(Long userId, BigDecimal money) {
        // 查询账户
        QueryWrapper<Account> wrapper = new QueryWrapper<>();
        wrapper.eq("user_id", userId);
        Account account = accountMapper.selectOne(wrapper);
        
        if (account == null) {
            throw new RuntimeException("账户不存在");
        }
        
        // 检查余额
        if (account.getBalance().compareTo(money) < 0) {
            throw new RuntimeException("余额不足");
        }
        
        // 扣余额
        account.setBalance(account.getBalance().subtract(money));
        accountMapper.updateById(account);
        
        return true;
    }
}

六、测试分布式事务

测试1:正常流程

# 1. 启动所有服务
# 2. 调用下单接口
POST http://localhost:8081/order/create?userId=1&productId=1&count=2

# 预期结果:
# 订单创建成功
# 库存减少2
# 余额减少30(15*2)

测试2:余额不足

// 在AccountService的decrease方法里模拟异常
@Transactional
public boolean decrease(Long userId, BigDecimal money) {
    // ... 正常逻辑
    
    // 模拟余额不足
    if (account.getBalance().compareTo(new BigDecimal("10000")) < 0) {
        throw new RuntimeException("模拟异常:余额不足");
    }
    
    // ...
}

调用下单接口:

  • 订单创建了(会回滚)
  • 库存扣了(会回滚)
  • 余额没扣(因为异常)
  • 最终:全部回滚,数据一致

测试3:查看Seata事务

  1. 访问Seata控制台:http://localhost:7091
  2. 用户名:seata
  3. 密码:seata
  4. 查看事务列表,能看到事务状态

七、Seata AT模式原理

第一阶段:提交

-- 业务SQL
UPDATE product SET stock = stock - 1 WHERE id = 1;

-- Seata自动做:
-- 1. 查询前镜像(before image)
SELECT * FROM product WHERE id = 1;

-- 2. 执行业务SQL
UPDATE product SET stock = stock - 1 WHERE id = 1;

-- 3. 查询后镜像(after image)
SELECT * FROM product WHERE id = 1;

-- 4. 生成UNDO_LOG
INSERT INTO undo_log (xid, branch_id, rollback_info, log_status)
VALUES ('全局事务ID', '分支事务ID', '{"beforeImage":..., "afterImage":...}', 1);

第二阶段:

  • 提交:删除UNDO_LOG
  • 回滚:用UNDO_LOG恢复数据

八、实际开发注意事项

1. 全局事务ID传递

// Feign拦截器传递XID
@Component
public class SeataFeignInterceptor implements RequestInterceptor {
    
    @Override
    public void apply(RequestTemplate template) {
        String xid = RootContext.getXID();
        if (StringUtils.isNotBlank(xid)) {
            template.header(RootContext.KEY_XID, xid);
        }
    }
}

2. 避免分布式事务大事务

// 不好的写法:事务太大
@GlobalTransactional
public void bigTransaction() {
    // 1. 复杂业务逻辑(30秒)
    // 2. RPC调用(5秒)
    // 3. 数据库操作(10秒)
    // 总时长45秒,容易超时
}

// 好的写法:拆分事务
public void business() {
    // 1. 本地准备
    prepare();
    
    // 2. 分布式事务只包含必要操作
    distributedTransaction();
    
    // 3. 后续处理
    postProcess();
}

@GlobalTransactional(timeoutMills = 10000)  // 设置超时时间
public void distributedTransaction() {
    // 只包含必须一起成功/失败的操作
    rpcCall1();
    rpcCall2();
}

3. 事务隔离级别问题

Seata AT模式默认是读未提交,需要业务上注意:

// 问题:脏读
// 事务A减库存,还没提交
// 事务B查库存,看到减少了,但其实可能回滚

// 解决方案1:使用SELECT FOR UPDATE(全局锁)
@GlobalTransactional
public void updateStock() {
    // 先加锁
    productMapper.selectForUpdate(productId);
    // 再操作
    // ...
}

// 解决方案2:使用SELECT ... FOR UPDATE
@GlobalLock  // Seata提供的注解
@GlobalTransactional
public void updateStock() {
    // ...
}

4. 幂等性设计

@Service
public class OrderService {
    
    @GlobalTransactional
    public Order createOrder(Long userId, Long productId, Integer count, String requestId) {
        // 1. 检查是否已处理(幂等)
        Order existOrder = orderMapper.selectByRequestId(requestId);
        if (existOrder != null) {
            return existOrder;  // 直接返回已创建的订单
        }
        
        // 2. 创建订单
        Order order = new Order();
        order.setRequestId(requestId);  // 唯一请求ID
        // ...
    }
}

九、Seata其他模式

TCC模式(适合复杂业务)

// Try接口
public interface AccountTccService {
    @TwoPhaseBusinessAction(name = "decrease", commitMethod = "confirm", rollbackMethod = "cancel")
    boolean prepareDecrease(@BusinessActionContextParameter(paramName = "userId") Long userId,
                           @BusinessActionContextParameter(paramName = "money") BigDecimal money);
    
    // Confirm接口
    boolean confirm(BusinessActionContext context);
    
    // Cancel接口
    boolean cancel(BusinessActionContext context);
}

// 实现:冻结金额,而不是直接扣
@Service
public class AccountTccServiceImpl implements AccountTccService {
    
    @Transactional
    public boolean prepareDecrease(Long userId, BigDecimal money) {
        // 冻结金额
        update account set balance = balance - #{money}, 
                         frozen = frozen + #{money} 
               where user_id = #{userId};
        return true;
    }
    
    @Transactional
    public boolean confirm(BusinessActionContext context) {
        // 确认:删除冻结金额
        update account set frozen = frozen - #{money} 
               where user_id = #{userId};
        return true;
    }
    
    @Transactional
    public boolean cancel(BusinessActionContext context) {
        // 取消:恢复金额
        update account set balance = balance + #{money}, 
                         frozen = frozen - #{money} 
               where user_id = #{userId};
        return true;
    }
}

Saga模式(长事务)

  • 每个步骤都有补偿操作
  • 适合业务流程长的场景
  • 如:订机票 -> 订酒店 -> 租车

十、常见问题

1. Seata连接失败

io.seata.common.exception.FrameworkException: can not connect to services-server.

解决

  1. 检查Seata Server启动了没
  2. 检查Nacos里有没有Seata Server
  3. 检查配置的tx-service-group对不对

2. 全局事务不生效

可能原因

  1. 没加@GlobalTransactional
  2. 异常被捕获了(要抛出RuntimeException)
  3. 嵌套事务问题
  4. 超时了

3. 数据源代理失败

No qualifying bean of type 'javax.sql.DataSource'

解决

seata:
  enable-auto-data-source-proxy: false  # 关闭自动代理

@Configuration
public class DataSourceConfig {
    
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource")
    public DataSource dataSource() {
        return new DruidDataSource();
    }
    
    @Primary
    @Bean("dataSource")
    public DataSourceProxy dataSourceProxy(DataSource dataSource) {
        return new DataSourceProxy(dataSource);  // 手动代理
    }
}

4. UNDO_LOG表没数据

可能原因

  1. 表结构不对
  2. 不是自动代理的数据源
  3. SQL不支持(如DDL语句)

十一、生产环境建议

1. Seata Server高可用

# 集群部署
seata:
  service:
    vgroup-mapping:
      my_tx_group: default  # 事务组映射到集群
  registry:
    type: nacos
    nacos:
      cluster: seata-cluster  # 集群名

2. 数据库模式存储

# file.conf
store {
  mode = "db"
  
  db {
    datasource = "druid"
    db-type = "mysql"
    driver-class-name = "com.mysql.cj.jdbc.Driver"
    url = "jdbc:mysql://127.0.0.1:3306/seata?useSSL=false"
    user = "root"
    password = "123456"
  }
}

3. 监控告警

// 监控事务成功率
@RestController
@RequestMapping("/seata")
public class SeataMonitorController {
    
    @Autowired
    private DefaultCoordinator coordinator;
    
    @GetMapping("/metrics")
    public Map<String, Object> getMetrics() {
        Map<String, Object> metrics = new HashMap<>();
        
        // 获取全局会话统计
        SessionManager sessionManager = coordinator.getSessionManager();
        metrics.put("activeSessions", sessionManager.getSessions().size());
        metrics.put("committed", coordinator.getMetrics().getCommited());
        metrics.put("rollbacked", coordinator.getMetrics().getRollbacked());
        
        return metrics;
    }
}

十二、今儿个总结

学会了啥?

  1. ✅ 分布式事务为啥重要
  2. ✅ Seata AT模式原理
  3. ✅ 搭建Seata环境
  4. ✅ @GlobalTransactional使用
  5. ✅ 异常回滚测试
  6. ✅ 生产环境注意事项

关键点

  1. @GlobalTransactional开全局事务
  2. UNDO_LOG表要创建
  3. 异常要抛出才能回滚
  4. 避免大事务,设置超时
  5. 幂等性设计防止重复

十三、明儿个学啥?

零基础全栈开发Java微服务版本实战-后端-前端-运维-实战企业级三个实战项目

资源获取:关注公众号: 小坏说Java ,获取本文所有示例代码、配置模板及导出工具。

明天咱学消息驱动

  • 服务之间除了HTTP调用,还能用消息
  • 削峰填谷:双11订单不直接写数据库,先放消息队列
  • 异步解耦:下单成功发短信,不用等短信发完
  • Spring Cloud Stream操作消息队列

明天咱让服务之间写信(发消息),不用打电话(HTTP调用)!📨