分布式事务详解

数据库事务简介

所有的数据访问技术都离不开事务处理,否则会造成数据不一致,在目前企业级应用开发中,事务管理是必不可少的。

数据库事务是指作为单个逻辑工作单元执行的一系列操作,要么完全地执行,要么完全地不执行。事务处理可以确保除非事务性单元内的所有操作都成功完成,否则不会永久更新面向数据的资源。通过将一组相关操作组合为一个要么全部成功要么全部失败的单元,可以简化错误恢复并使应用程序更加可靠。一个逻辑工作单元要成为事务,必须满足所谓的 ACID(原子性、一致性、隔离性和持久性)属性,事务是数据库运行中的逻辑工作单位,由数据库中的事务管理子系统负责事务的处理。

对于实战项目新蜂商城中的订单生成逻辑来说,其流程图如图 10-1 所示。

image 2025 04 16 19 43 46 512
Figure 1. 图10-1 订单生成流程图

具体的实现代码如下:

@Transactional //开启事务
public String saveOrder(MallUser loginMallUser, MallUserAddress address,
                        List<NewBeeMallShoppingCartItemVO> myShoppingCartItems) {
    List<Long> itemIdList = myShoppingCartItems.stream().map(NewBeeMallShoppingCartItemVO::getCartItemId).collect(Collectors.toList());
    List<Long> goodsIds = Map<Long, NewBeeMallGoods> goodsMap = newBeeMallGoods.stream().collect(Collectors.toMap(NewBeeMallGoods::getGoodsId, Function.identity(), (entity1, entity2) -> entity1));
    //判断商品库存

    for (NewBeeMallShoppingCartItemVO shoppingCartItemVO : myShoppingCartItems) {
        //查出的商品中不存在购物车的这条关联商品数据,直接返回错误提示
        if (!goodsMap.containsKey(shoppingCartItemVO.getGoodsId())) {
            NewBeeMallException.fail(ServiceResultEnum.SHOPPING_ITEM_ERROR.getResult());
        }
        //存在数量大于库存数量的情况,直接返回错误提示
        if (shoppingCartItemVO.getGoodsCount() > newBeeMallGoodsMap.get(shoppingCartItemVO.getGoodsId()).getStockNum()) {
            NewBeeMallException.fail(ServiceResultEnum.SHOPPING_ITEM_COUNT_ERROR.getResult());
        }
    }

    //删除购物项
    if (!CollectionUtils.isEmpty(itemIdList)
            && !CollectionUtils.isEmpty(goodsIds) && !CollectionUtils.isEmpty(newBeeMallGoods)) {
        if (newBeeMallShoppingCartItemMapper.deleteBatch(itemIdList) > 0) {
            List<StockNumDTO> stockNumDTOS =
                    BeanUtil.copyList(myShoppingCartItems, StockNumDTO.class);
            int updateStockNumResult = newBeeMallGoodsMapper.updateStockNum(stockNumDTOS);
            if (updateStockNumResult < 1) {
                NewBeeMallException.fail(ServiceResultEnum.SHOPPING_ITEM_COUNT_ERROR.getResult());
            }
            //生成订单号
            String orderNo = NumberUtil.genOrderNo();
            //保存订单
            NewBeeMallOrder newBeeMallOrder = new NewBeeMallOrder();
            newBeeMallOrder.setOrderNo(orderNo);
            newBeeMallOrder.setUserId(loginMallUser.getUserId());
            //总价
            for (NewBeeMallShoppingCartItemVO newBeeMallShoppingCartItemVO : myShoppingCartItems) {
                priceTotal += newBeeMallShoppingCartItemVO.getGoodsCount() * newBeeMallShoppingCartItemVO.getSellingPrice();
            }
            if (priceTotal < 1) {
                NewBeeMallException.fail(ServiceResultEnum.ORDER_PRICE_ERROR.getResult());
            }
            newBeeMallOrder.setTotalPrice(priceTotal);
            String extraInfo = "";
            newBeeMallOrder.setExtraInfo(extraInfo);
            //生成订单项并保存订单项记录
            if (newBeeMallOrderMapper.insertSelective(newBeeMallOrder) > 0) {
                //生成订单收货地址快照,并保存至数据库
                NewBeeMallOrderAddress newBeeMallOrderAddress = new NewBeeMallOrderAddress();
                BeanUtil.copyProperties(address, newBeeMallOrderAddress);
                newBeeMallOrderAddress.setOrderId(newBeeMallOrder.getOrderId());
                //生成所有的订单项快照,并保存至数据库
                List<NewBeeMallOrderItem> newBeeMallOrderItems = new ArrayList<>();
                for (NewBeeMallShoppingCartItemVO newBeeMallShoppingCartItemVO : myShoppingCartItems) {
                    NewBeeMallOrderItem newBeeMallOrderItem = new NewBeeMallOrderItem();
                    //使用BeanUtil工具类将newBeeMallShoppingCartItemVO中的属性复制到newBeeMallOrderItem对象中
                    BeanUtil.copyProperties(newBeeMallShoppingCartItemVO, newBeeMallOrderItem);
                    //因为newBeeMallOrderItem文件中的insert()方法中使用了useGeneratedKeys,因此orderId可以获取到
                    newBeeMallOrderItem.setOrderId(newBeeMallOrder.getOrderId());
                    newBeeMallOrderItems.add(newBeeMallOrderItem);
                }
                //保存至数据库
                if (newBeeMallOrderItemMapper.insertBatch(newBeeMallOrderItems) > 0 && newBeeMallOrderAddressMapper.insertSelective(newBeeMallOrderAddress) > 0) {

                    //所有操作成功后,将订单号返回,以供Controller方法跳转到订单详情
                    return orderNo;
                }
                NewBeeMallException.fail(ServiceResultEnum.ORDER_PRICE_ERROR.getResult());
            }
            NewBeeMallException.fail(ServiceResultEnum.DB_ERROR.getResult());
        }
        NewBeeMallException.fail(ServiceResultEnum.DB_ERROR.getResult());
    }
    NewBeeMallException.fail(ServiceResultEnum.SHOPPING_ITEM_ERROR.getResult());
    return ServiceResultEnum.SHOPPING_ITEM_ERROR.getResult();
}

订单生成的方法总结一下就是先验证,然后进行订单数据封装,最后将订单数据和订单项数据保存到数据库。

结合订单生成流程图来理解,订单生成的详细过程如下。

  1. 检查是否包含已下架商品,如果有,则抛出异常;如果无,则继续后续流程。

  2. 判断商品数据和商品库存,如果商品数据有误或商品库存不足,则抛出异常;如果一切正常,则继续后续流程。

  3. 对象的非空判断。

  4. 生成订单后,需要删除购物项数据,这里调用 NewBeeMallShoppingCartItemMapper.deleteBatch() 方法将这些数据批量删除。

  5. 更新商品库存记录。

  6. 判断订单价格,如果所有购物项加起来的数据为 0 或小于 0,则不继续生成订单。

  7. 生成订单号并封装 NewBeeMallOrder 对象,保存订单记录到数据库。

  8. 封装订单项数据并保存订单项数据到数据库。

  9. 生成订单收货地址快照,并保存至数据库。

  10. 返回订单号。

该方法涉及的表有订单表、订单项表、购物项表、商品表、用户信息表、用户收货地址表。当然,该方法也使用了 @Transactional 注解来开启事务,只要其中任何一个步骤没有通过验证或在任意一行代码中抛出异常,那么涉及这些表的操作都会回滚。这就是数据库事务的特点,要么完全地执行,要么完全地不执行。此时讲解的业务和代码在同一份工程代码中,其中所操作的表也在同一个数据库中,能够很简单地实现数据库事务的控制,如图 10-2 所示。

image 2025 04 16 19 51 04 009
Figure 2. 图10-2 单库中对数据库事务的控制

如果这些表不在同一个数据库中,没有统一的事务管理器,会发生什么呢?比如,在微服务架构中,原来的单体应用会被拆分出多个微服务,所有的表也被分割到多个数据库实例中,如图 10-3 所示。

image 2025 04 16 19 51 32 656
Figure 3. 图10-3 多服务实例和多数据库下的事务控制

此时,生成订单的这个操作就会涉及多个服务和多个数据库实例,本地事务就无法保证数据一致性了。接下来笔者将通过实际的编码来演示在微服务架构的项目中会出现的分布式事务问题,并分析该问题产生的原因,让读者更好地理解这个问题。

分布式事务的问题演示编码

本节演示编码是在 spring-cloud-alibaba-openfeign-demo 模板项目的基础上修改的,主要包括三个服务:商品服务、购物车服务和订单服务。示例中的逻辑很简单,下单后生成订单记录,删除购物车记录并修改商品中的库存。服务之间的通信组件使用 OpenFeign,对数据库的相关操作使用 Spring Boot 自动配置的 JdbcTemplate 工具。

在实际编码前,先修改项目名称为 spring-cloud-alibaba-distribution-demo,再把各个模块中 pom.xml 文件的 artifactId 修改为 spring-cloud-alibaba-distribution-demo,然后依次修改三个服务代码。具体操作步骤如下。

编写商品服务代码

  1. 创建商品服务所需的数据库和表。

    将商品服务的数据库命名为 test_distribution_goods_db,将商品表命名为 tb_goodsSQL 语句如下:

    # 创建商品服务所需的数据
    CREATE DATABASE /*!32312 IF NOT EXISTS*/`test_distribution_goods_db` /*!40100 DEFAULT CHARACTER SET utf8 */;
    
    USE `test_distribution_goods_db`;
    
    # 表结构
    DROP TABLE IF EXISTS `tb_goods`;
    CREATE TABLE `tb_goods` (
      `goods_id` int(11) NOT NULL AUTO_INCREMENT COMMENT '测试商品id',
      `goods_name` varchar(100) NOT NULL DEFAULT '' COMMENT '测试商品名称',
      `goods_stock` int(11) NOT NULL DEFAULT 0 COMMENT '测试商品库存',
      PRIMARY KEY (`goods_id`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=1 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
    
    # 新增两条测试数据
    INSERT INTO `tb_goods` VALUES (2022, 'Spring Cloud Alibaba 大型微服务架构实战(上册)', 999);
    INSERT INTO `tb_goods` VALUES (2025, 'Spring Cloud Alibaba 大型微服务架构实战(下册)', 1000);

    上述 SQL 语句主要是新建 test_distribution_goods_db 数据库并在该数据库中新增一张 tb_goods 表,以及新增两条商品表的测试数据。读者可以直接将 SQL 语句导入 MySQL 数据库,这样商品服务中数据库的准备工作就完成了。

  2. 增加数据库操作的相关依赖。

    开始实际的编码操作,打开 goods-service-demo 项目。

    因为有数据库操作,所以要添加 JDBC 连接依赖、MySQL 数据库连接驱动,在 pom.xml 文件中增加如下依赖:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
  3. 修改 application.properties 文件。

    增加数据库连接的配置,配置项如下:

    # datasource config
    spring.datasource.url=jdbc:mysql://localhost:3306/test_distribution_goods_
    db?useUnicode=true&characterEncoding=utf8&autoReconnect=true&useSSL=false
    spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
    spring.datasource.username=root
    spring.datasource.password=123456

    在这里配置好数据库的地址、用户名和密码,才能在商品服务中连接到 MySQL 数据库。

    为了给章节间的代码做区分,将端口号修改为 8151,配置项如下:

    server.port=8151
  4. 增加修改库存的接口。

    打开 NewBeeCloudGoodsAPI.java 文件,在 Controller 类中增加修改库存的接口,代码如下:

    package ltd.goods.cloud.newbee.controller;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.jdbc.core.JdbcTemplate;
    import org.springframework.web.bind.annotation.PathVariable;
    import org.springframework.web.bind.annotation.PutMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    public class NewBeeCloudGoodsAPI {
    
        @Autowired
        private JdbcTemplate jdbcTemplate;
    
        @PutMapping("/goods/{goodsId}")
        public Boolean deStock(@PathVariable("goodsId") int goodsId) {
            // 减库存操作
            int result = jdbcTemplate.update("update tb_goods set goods_stock=goods_stock-1 where goods_id=?", goodsId);
            if (result > 0) {
                return true;
            }
            return false;
        }
    }

    修改商品库存接口的逻辑并不复杂,请求方式为 PUT,接口地址为 /goods/{goodsId}。先接收一个路径参数 goodsId,然后直接执行一条 update 语句,将库存字段 goods_stock 减 1,最后根据执行结果返回成功或失败。

  5. 功能测试。

    启动 goods-service-demo 项目并测试该接口是否正常,测试 URL 如下:

    PUT http://localhost:8151/goods/2022

    由于接口的请求方式为 PUT,因此笔者用 PostMan 工具来测试这个接口。当然,也可以使用命令行工具或其他测试工具。

    测试过程如图 10-4 所示。接口请求成功。

    当然,还需要去数据库中查看 id2022 的测试数据是否被正确地修改。此时的数据库结果如图 10-5 所示。

    数据与预期结果一致,编码完成。

    如果有报错,则需要检查数据库连接是否写对,以及数据库是否被正常创建。

    image 2025 04 16 20 04 29 463
    Figure 4. 图10-4 库存修改接口的测试过程
    image 2025 04 16 20 05 21 157
    Figure 5. 图10-5 库存修改后的数据库结果

编写购物车服务代码

  1. 创建购物车服务所需的数据库和表。

    将购物车服务的数据库命名为 test_distribution_cart_db,将购物项的表命名为 tb_cart_itemSQL 语句如下:

    # 创建购物车服务所需的数据
    CREATE DATABASE /*!32312 IF NOT EXISTS*/`test_distribution_cart_db` /*!40100 DEFAULT CHARACTER SET utf8 */;
    
    USE `test_distribution_cart_db`;
    
    # 表结构
    DROP TABLE IF EXISTS `tb_cart_item`;
    CREATE TABLE `tb_cart_item` (
      `cart_id` int(11) NOT NULL AUTO_INCREMENT COMMENT '测试购物项id',
      `goods_id` int(11) NOT NULL DEFAULT 0 COMMENT '测试商品id',
      `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
      PRIMARY KEY (`cart_id`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=1 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
    
    # 新增 10 条测试数据
    INSERT INTO `tb_cart_item` (`cart_id`, `goods_id`) VALUES (1, 2022);
    INSERT INTO `tb_cart_item` (`cart_id`, `goods_id`) VALUES (2, 2022);
    INSERT INTO `tb_cart_item` (`cart_id`, `goods_id`) VALUES (3, 2022);
    INSERT INTO `tb_cart_item` (`cart_id`, `goods_id`) VALUES (4, 2022);
    INSERT INTO `tb_cart_item` (`cart_id`, `goods_id`) VALUES (5, 2022);
    INSERT INTO `tb_cart_item` (`cart_id`, `goods_id`) VALUES (6, 2025);
    INSERT INTO `tb_cart_item` (`cart_id`, `goods_id`) VALUES (7, 2025);
    INSERT INTO `tb_cart_item` (`cart_id`, `goods_id`) VALUES (8, 2025);
    INSERT INTO `tb_cart_item` (`cart_id`, `goods_id`) VALUES (9, 2025);
    INSERT INTO `tb_cart_item` (`cart_id`, `goods_id`) VALUES (10, 2025);

    上述 SQL 语句主要是新建数据库和表,并且新增了 10 条购物项表的测试数据。读者可以直接将 SQL 语句导入 MySQL 数据库,这样购物车服务中数据库的准备工作就完成了。

  2. 增加数据库操作的相关依赖。

    开始实际编码操作,打开 shopcart-service-demo 项目。

    因为有数据库操作,所以要添加 JDBC 连接依赖、MySQL 数据库连接驱动,在 pom.xml 文件中增加如下依赖:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
  3. 修改 application.properties 文件。

    增加数据库连接的配置,配置项如下:

    # datasource config
    spring.datasource.url=jdbc:mysql://localhost:3306/test_distribution_cart_db?
    useUnicode=true&characterEncoding=utf8&autoReconnect=true&useSSL=false
    spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
    spring.datasource.username=root
    spring.datasource.password=123456

    在这里配置好数据库的地址、用户名和密码,才能在商品服务中连接到 MySQL 数据库。

    为了给章节间的代码做区分,将端口号修改为 8154,配置项如下:

    server.port=8154
  4. 增加测试接口

    打开 NewBeeCloudShopCartAPI 文件,在 Controller 类中增加两个接口,代码如下:

    package ltd.shopcart.cloud.newbee.controller;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.jdbc.core.JdbcTemplate;
    import org.springframework.web.bind.annotation.*;
    
    import java.util.Map;
    
    @RestController
    public class NewBeeCloudShopCartAPI {
    
        @Autowired
        private JdbcTemplate jdbcTemplate;
    
        @GetMapping("/shop-cart/getGoodsId")
        public int getGoodsId(@RequestParam("cartId") int cartId) {
            // 根据主键id查询购物车表
            Map<String, Object> cartItemObject = jdbcTemplate.queryForMap(
                    "select * from tb_cart_item where cart_id=" + cartId + " limit 1");
            if (cartItemObject.containsKey("goods_id")) {
                // 返回商品id
                return (int) cartItemObject.get("goods_id");
            }
            return 0;
        }
    
        @DeleteMapping("/shop-cart/{cartId}")
        public Boolean deleteItem(@PathVariable("cartId") int cartId) {
            // 删除购物车数据
            int result = jdbcTemplate.update("delete from tb_cart_item where cart_id=" + cartId);
            if (result > 0) {
                return true;
            }
            return false;
        }
    }

    代码中增加了两个接口,分别是根据购物项 cartId 获取对应的 goodsId 接口和根据购物项 cartId 删除记录的接口。

    查询商品 id 接口的请求方式为 GET,接口地址为 /shop-cart/getGoodsId。先接收一个请求参数 cartId,然后直接执行一条查询语句,将记录中的 goods_id 返回给调用端。

    删除购物车数据接口的请求方式为 DELETE,接口地址为 /shop-cart/{cartId}。先接收一个路径参数 cartId,然后直接执行一条删除语句,将表中 idcartId 的记录删除。

  5. 功能测试。

启动 shopcart-service-demo 项目并测试该接口是否正常,测试 URL 如下:

GET http://localhost:8154/shop-cart/getGoodsId?cartId=1
DELETE http://localhost:8154/shop-cart/1

查询商品 id 接口的测试过程如图 10-6 所示。

image 2025 04 16 20 19 12 617
Figure 6. 图10-6 查询商品 id 接口的测试过程

接口请求成功。

删除购物车数据接口的测试过程如图 10-7 所示。

image 2025 04 16 20 19 37 078
Figure 7. 图10-7 删除购物车数据接口的测试过程

接口请求成功。

当然,还需要去数据库中查看购物项表中 id 为 1 的测试数据是否已被删除。数据与预期结果一致,编码完成。

编写订单服务代码

  1. 创建订单服务所需的数据库和表。

    将订单服务的数据库命名为 test_distribution_order_db,将订单表命名为 tb_orderSQL 语句如下:

    # 创建订单服务所需的数据
    CREATE DATABASE /*!32312 IF NOT EXISTS*/`test_distribution_order_db` /*!40100 DEFAULT CHARACTER SET utf8 */;
    
    USE `test_distribution_order_db`;
    
    DROP TABLE IF EXISTS `tb_order`;
    CREATE TABLE `tb_order` (
      `order_id` int(11) NOT NULL AUTO_INCREMENT COMMENT '测试订单id',
      `cart_id` int(11) NOT NULL COMMENT '测试购物车id',
      `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
      PRIMARY KEY (`order_id`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=1 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

    上述 SQL 语句主要是新建数据库和表,读者可以直接将 SQL 语句导入 MySQL 数据库,这样订单服务中数据库的准备工作就完成了。

  2. 增加数据库操作的相关依赖。

    开始实际编码操作,打开 order-service-demo 项目。

    因为有数据库操作,所以要添加 JDBC 连接依赖、MySQL 数据库连接驱动,在 pom.xml 文件中增加如下依赖:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
  3. 修改 application.properties 文件。

    主要是增加数据库配置和修改端口号,最终配置项如下:

    server.port=8157
    # 应用名称
    spring.application.name=newbee-cloud-order-service
    # 注册中心 Nacos 的访问地址
    spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
    # 登录名(默认为 nacos, 可自行修改)
    spring.cloud.nacos.username=nacos
    # 密码(默认为 nacos, 可自行修改)
    spring.cloud.nacos.password=nacos
    
    # OpenFeign 超时时间
    feign.client.config.default.connectTimeout=2000
    feign.client.config.default.readTimeout=5000
    
    # datasource config
    spring.datasource.url=jdbc:mysql://localhost:3306/test_distribution_orde
    r_db?useUnicode=true&characterEncoding=utf8&autoReconnect=true&useSSL=fa
    lse
    spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
    spring.datasource.username=root
    spring.datasource.password=123456
  4. 修改 FeignClient 代码。

    因为订单服务中会远程调用商品服务和购物车服务,所以这里需要声明远程调用商品服务和购物车服务中新增的接口。修改 openfeign 中的 FeignClient 代码,声明调用商品服务和购物车服务中新增的接口代码。

    NewBeeGoodsDemoService.java 文件的代码修改如下:

    package ltd.order.cloud.newbee.openfeign;
    
    import org.springframework.cloud.openfeign.FeignClient;
    import org.springframework.web.bind.annotation.PathVariable;
    import org.springframework.web.bind.annotation.PutMapping;
    
    @FeignClient(value = "newbee-cloud-goods-service", path = "/goods")
    public interface NewBeeGoodsDemoService {
    
        @PutMapping(value = "/{goodsId}")
        Boolean deStock(@PathVariable(value = "goodsId") int goodsId);
    }

    NewBeeShopCartDemoService.java 文件的代码修改如下:

    package ltd.order.cloud.newbee.openfeign;
    
    import org.springframework.cloud.openfeign.FeignClient;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PathVariable;
    import org.springframework.web.bind.annotation.RequestParam;
    
    @FeignClient(value = "newbee-cloud-shopcart-service", path = "/shop-cart")
    public interface NewBeeShopCartDemoService {
    
        @GetMapping(value = "/getGoodsId")
        int getGoodsId(@RequestParam(value = "cartId") int cartId);
    
        @DeleteMapping(value = "/{cartId}")
        Boolean deleteItem(@PathVariable(value = "cartId") int cartId);
    }
  5. 编写订单服务中的 service 层代码和接口代码。

    新建 ltd.order.cloud.newbee.service 包,并新增 OrderService.java 文件,代码及注释如下:

    package ltd.order.cloud.newbee.service;
    
    import ltd.order.cloud.newbee.openfeign.NewBeeGoodsDemoService;
    import ltd.order.cloud.newbee.openfeign.NewBeeShoppingCartDemoService;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.jdbc.core.JdbcTemplate;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    
    import javax.annotation.Resource;
    
    @Service
    public class OrderService {
        @Autowired
        private JdbcTemplate jdbcTemplate;
    
        @Resource
        private NewBeeGoodsDemoService newBeeGoodsDemoService;
        @Resource
        private NewBeeShopCartDemoService newBeeShopCartDemoService;
    
        @Transactional
        public Boolean saveOrder(int cartId) {
            // 简单模拟下单流程,包括服务间的调用流程
    
            // 调用购物车服务-获取即将操作的 goods_id
            int goodsId = newBeeShopCartDemoService.getGoodsId(cartId);
    
            // 调用商品服务-减库存
            Boolean goodsResult = newBeeGoodsDemoService.deStock(goodsId);
    
            // 调用购物车服务-删除当前购物车数据
            Boolean cartResult = newBeeShopCartDemoService.deleteItem(cartId);
    
            // 执行下单逻辑
            if (goodsResult && cartResult) {
                // 向订单表中新增一条记录
                int orderResult = jdbcTemplate.update("insert into tb_order ('cart_id') value ('" + cartId + "')");
                if (orderResult > 0) {
                    return true;
                }
            }
            return false;
        }
    }

    这个方法主要是模拟订单生成的过程,接收参数为购物项 id,方法上方也加了 @Transactional 事务注解。执行逻辑如下:

    • ①调用购物车服务获取将要减库存的商品 id

    • ②调用商品服务进行减库存的操作。

    • ③调用购物车服务删除当前的购物车数据。

    • ④如果两个服务都调用成功,则生成订单数据。

    • ⑤向订单表中新增一条记录,根据订单操作的 SQL 语句返回内容,返回成功或失败。

    修改 NewBeeCloudOrderAPIsaveOrder() 方法,代码如下:

    package ltd.order.cloud.newbee.controller;
    
    import ltd.order.cloud.newbee.service.OrderService;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    import org.springframework.web.bind.annotation.RestController;
    
    import javax.annotation.Resource;
    
    @RestController
    public class NewBeeCloudOrderAPI {
    
        @Resource
        private OrderService orderService;
    
        @GetMapping("/order/saveOrder")
        public Boolean saveOrder(@RequestParam("cartId") int cartId) {
            return orderService.saveOrder(cartId);
        }
    }

    接收 cartId 参数,之后调用 OrderService 业务类的 saveOrder() 方法。

  6. 功能测试。

    启动 Nacos Server,之后依次启动这三个项目。如果未能成功启动,则开发人员需要查看控制台中的日志是否报错,并及时确认问题和修复。启动成功后进入 Nacos 控制台,单击 “服务管理” 中的服务列表,可以看到列表中已经存在这三个服务的服务信息,如图 10-8 所示。

    image 2025 04 16 20 30 31 113
    Figure 8. 图10-8 Nacos 控制台中的服务列表

测试生成订单的接口是否正常,测试 URL 如下:

GET http://localhost:8157/order/saveOrder?cartId=3

生成订单接口的测试过程如图 10-9 所示。

image 2025 04 16 20 31 36 747
Figure 9. 图10-9 生成订单接口的测试过程

接口请求成功。

当然,还需要去数据库中查看,此时数据库结果如图 10-10 所示。

image 2025 04 16 20 32 04 088
Figure 10. 图10-10 请求生成订单接口后的数据库结果

生成了一条订单数据,查看商品表和购物车表的记录,对应的商品库存字段的数值已经被扣减,对应的购物项也被删除。数据与预期结果一致,编码完成。

分布式事务问题演示

订单服务编码完成后演示的是一切都正常的情况:订单记录生成并存储至数据库,并且商品库存扣减成功,购物车数据也成功删除。这是最终期待的结果。如果商品没有扣减成功,那么购物车中的数据不应该被删除,订单记录也不应该落库成功。如果订单记录没有成功生成,则商品不应该扣减库存,购物车中的数据也不应该被删除。

如果在 saveOrder() 方法执行期间,某些环节出现问题,如网络波动或代码中出现异常,就会出现数据不一致的情况。接下来,笔者就以网络波动和代码异常这两种情况来模拟分布式项目中出现的数据不一致的问题。

先模拟网络波动的情况。比如,在减库存的接口代码中故意加上一行休眠 10 秒的代码:

public Boolean deStock(@PathVariable("goodsId") int goodsId) {
    // 减库存操作
    int result = jdbcTemplate.update("update tb_goods set goods_stock=goods_stock-1 where goods_id=" + goodsId);
    // 模拟网络波动问题
    try {
        Thread.sleep(10 * 1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    if (result > 0) {
        return true;
    }
    return false;
}

然后请求测试地址:

http://localhost:8157/order/saveOrder?cartId=4

结果出现了异常信息,生成订单的接口没有返回成功的响应。控制台报出的异常信息如下:

java.net.SocketTimeoutException: Read timed out
    at java.net.SocketInputStream.socketRead0(Native Method) ~[na:1.8.0_361]

由于 feign.client.config.default.readTimeout=5000 配置了 OpenFeign 的超时时间为 5 秒,因此 saveOrder() 方法执行到减库存这里就会出现超时的问题,不会继续进行下去了。但是查看三个数据库中的数据,可以发现一个大问题:订单没有新增、购物车数据没有删除,商品库存却扣减成功了!这就出现了数据不一致的问题。当然,读者如果在购物车服务中模拟网络超时的问题,同样会出现数据不一致的问题。

接下来屏蔽网络波动,模拟 OrderService#saveOrder() 方法出现异常时会出现的问题。把模拟超时的休眠代码注释掉,然后修改 OrderService#saveOrder() 方法,添加一行执行会出现异常的代码:

@Transactional
public Boolean saveOrder(int cartId) {
    // 调用购物车服务-获取即将操作的 goods_id
    int goodsId = newBeeShoppingCartDemoService.getGoodsId(cartId);
    // 调用商品服务-减库存
    Boolean goodsResult = newBeeGoodsDemoService.deStock(goodsId);
    // 调用购物车服务-删除当前购物车数据
    Boolean cartResult = newBeeShoppingCartDemoService.deleteItem(cartId);
    // 执行下单逻辑
    if (goodsResult && cartResult) {
        // 向订单表中新增一条记录
        int orderResult = jdbcTemplate.update("insert into tb_order('cart_id') value ('" + cartId + "')");
        // 此处出现了异常
        int i=1/0;
        if (orderResult > 0) {
            return true;
        }
        return false;
    }
    return false;
}

重启三个项目,之后请求测试地址:

http://localhost:8157/order/saveOrder?cartId=5

结果同样出现了异常信息,生成订单的接口没有返回成功的响应。此时,控制台报出的异常信息如下:

java.lang.ArithmeticException: / by zero
    at ltd.order.cloud.newbee.service.OrderService.saveOrder
    (OrderService.java:49) ~[classes/:na]

查看三个数据库中的数据,情况如下:订单数据没有新增,购物车数据被删除了,商品库存也扣减成功了!

分析一下原因。在执行到 “int i=1/0;” 这一行代码前执行了向订单表中新增一条记录的 SQL 请求,然而由于业务层方法中出现了异常及该方法上标注了 @Transactional 注解,因此捕捉到异常后数据回滚了。所以,订单表中是没有新增数据的。另外两个服务的代码中并没有发生异常,数据正常落库。

于是数据不一致的问题就出现了。不只是微服务架构,在常见的分布式架构中,由于数据库的分割、项目代码的分割,导致事务并不在同一个事务管理器中,分布式事务的问题就出现了。分布式事务的定义如下:

分布式事务是指事务的参与者、支持事务的服务器、资源服务器及事务管理器分别位于不同的分布式系统的不同节点上。

本节的主要内容是由一个具体的生成订单逻辑开始的,讲解了在单体应用中的事务处理,进而引出同样一个业务逻辑在微服务架构中会出现的问题,并通过实际的编码模拟分布式事务的问题,代码比较简单,读者可以根据文中给出的思路自行测试。出现了一个问题,复现了一个问题,接下来就要把这个问题给解决掉。