Springcloud基础知识(18)- Spring Cloud Alibaba Seata (四) | Nacos+Seata+Openfeign 分布式事务实例(库存服务)
我们以电商系统为例,来演示下业务系统如何整合 Seata。
在电商系统中,用户下单购买一件商品,简化为 3 个服务提供支持:
Order(订单服务):创建和修改订单。
Storage(库存服务):对指定的商品扣除仓库库存。
Account(账户服务) :从用户帐户中扣除商品金额。
当用户从这个电商网站购买了一件商品后,其服务调用步骤如下:
(1) 调用 Order 服务,创建一条订单数据,订单状态为 “未完成”;
(2) 调用 Storage 服务,扣减商品库存;
(3) 调用 Account 服务,从用户账户中扣除商品金额;
(4) 调用 Order 服务,将订单状态修改为 “已完成”。
本文使用 “Springcloud基础知识(11)- Spring Cloud Alibaba Nacos (一) | Nacos 简介、服务注册中心” 里的 Nacos 2.1.0 作为 Seata 的注册和配置中心,设置 Nacos 运行在 8848 端口上。
使用 “Springcloud基础知识(15)- Spring Cloud Alibaba Seata (一) | Seata 简介、事务模式、Seata Server” 里的 Seata Server 1.4.2,设置 Seata Server 运行在 8092 端口上。
在 “Springcloud基础知识(17)- Spring Cloud Alibaba Seata (三) | 配置 db 存储模式、整合 Nacos” 里 SpringcloudDemo05 项目基础上,创建 SeataStorage 子模块。
1. 创建数据库
在 MariaDB (MySQL) 中,创建一个名为 seata_storage 的数据库实例,并在该数据库内执行以下 SQL。
1 DROP TABLE IF EXISTS `tbl_storages`; 2 CREATE TABLE `tbl_storages` ( 3 `id` bigint NOT NULL AUTO_INCREMENT, 4 `product_id` bigint DEFAULT NULL COMMENT 'product id', 5 `total` int DEFAULT NULL COMMENT 'total inventory', 6 `used` int DEFAULT NULL COMMENT 'used inventory', 7 `residue` int DEFAULT NULL COMMENT 'remaining inventory', 8 PRIMARY KEY (`id`) 9 ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8; 10 11 INSERT INTO `tbl_storages` VALUES ('1', '1', '100', '0', '100'); 12 13 DROP TABLE IF EXISTS `undo_log`; 14 CREATE TABLE `undo_log` ( 15 `branch_id` bigint NOT NULL COMMENT 'branch transaction id', 16 `xid` varchar(128) NOT NULL COMMENT 'global transaction id', 17 `context` varchar(128) NOT NULL COMMENT 'undo_log context,such as serialization', 18 `rollback_info` longblob NOT NULL COMMENT 'rollback info', 19 `log_status` int NOT NULL COMMENT '0:normal status,1:defense status', 20 `log_created` datetime(6) NOT NULL COMMENT 'create datetime', 21 `log_modified` datetime(6) NOT NULL COMMENT 'modify datetime', 22 UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`) 23 ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='AT transaction mode undo table';
执行 SQL 后,数据库 `tbl_storages` 表显示如下:
id | product_id | total | used | residue |
1 | 1 | 100 | 0 | 100 |
2. 创建 Maven 模块
选择左上的项目列表中的 SpringcloudDemo05,点击鼠标右键,选择 New -> Module 进入 New Module 页面:
Maven -> Project SDK: 1.8 -> Check "Create from archtype" -> select "org.apache.maven.archtypes:maven-archtype-quickstart" -> Next
Name: SeataStorage
GroupId: com.example
ArtifactId: SeataStorage
-> Finish
3. 修改 pom.xml,内容如下
1 <?xml version="1.0" encoding="UTF-8"?> 2 <project xmlns="http://maven.apache.org/POM/4.0.0" 3 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 4 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 5 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 6 <parent> 7 <artifactId>SpringcloudDemo05</artifactId> 8 <groupId>com.example</groupId> 9 <version>1.0-SNAPSHOT</version> 10 </parent> 11 <modelVersion>4.0.0</modelVersion> 12 13 <artifactId>SeataStorage</artifactId> 14 15 <name>SeataStorage</name> 16 <!-- FIXME change it to the project's website --> 17 <url>http://www.example.com</url> 18 19 <properties> 20 <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> 21 <maven.compiler.source>1.8</maven.compiler.source> 22 <maven.compiler.target>1.8</maven.compiler.target> 23 <maven.install.skip>true</maven.install.skip> 24 </properties> 25 26 <dependencies> 27 <dependency> 28 <groupId>junit</groupId> 29 <artifactId>junit</artifactId> 30 <scope>test</scope> 31 </dependency> 32 33 <dependency> 34 <groupId>org.springframework.boot</groupId> 35 <artifactId>spring-boot-starter-web</artifactId> 36 </dependency> 37 <dependency> 38 <groupId>org.springframework.boot</groupId> 39 <artifactId>spring-boot-starter-test</artifactId> 40 <scope>test</scope> 41 </dependency> 42 43 <!-- JDBC --> 44 <dependency> 45 <groupId>org.springframework.boot</groupId> 46 <artifactId>spring-boot-starter-data-jdbc</artifactId> 47 </dependency> 48 <!-- Druid --> 49 <dependency> 50 <groupId>com.alibaba</groupId> 51 <artifactId>druid</artifactId> 52 <version>1.2.8</version> 53 </dependency> 54 55 <!-- MariaDB --> 56 <dependency> 57 <groupId>org.mariadb.jdbc</groupId> 58 <artifactId>mariadb-java-client</artifactId> 59 <version>${mariadb.version}</version> 60 </dependency> 61 <!-- MyBatis --> 62 <dependency> 63 <groupId>org.mybatis.spring.boot</groupId> 64 <artifactId>mybatis-spring-boot-starter</artifactId> 65 <version>${mybatis.version}</version> 66 </dependency> 67 <dependency> 68 <groupId>org.projectlombok</groupId> 69 <artifactId>lombok</artifactId> 70 <version>${lombok.version}</version> 71 </dependency> 72 73 <!-- nacos --> 74 <dependency> 75 <groupId>com.alibaba.cloud</groupId> 76 <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> 77 </dependency> 78 <dependency> 79 <groupId>com.alibaba.cloud</groupId> 80 <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> 81 </dependency> 82 83 <!-- seata --> 84 <dependency> 85 <groupId>com.alibaba.cloud</groupId> 86 <artifactId>spring-cloud-starter-alibaba-seata</artifactId> 87 <!-- Spring cloud 2021.1 自动导入的 seata 版本是 1.3.0 --> 88 <exclusions> 89 <exclusion> 90 <groupId>io.seata</groupId> 91 <artifactId>seata-spring-boot-starter</artifactId> 92 </exclusion> 93 </exclusions> 94 </dependency> 95 <dependency> 96 <groupId>io.seata</groupId> 97 <artifactId>seata-spring-boot-starter</artifactId> 98 <version>1.4.2</version> 99 </dependency> 100 101 <!-- OpenFeign --> 102 <!-- 103 <dependency> 104 <groupId>org.springframework.cloud</groupId> 105 <artifactId>spring-cloud-starter-openfeign</artifactId> 106 </dependency> 107 <dependency> 108 <groupId>org.springframework.cloud</groupId> 109 <artifactId>spring-cloud-loadbalancer</artifactId> 110 </dependency> --> 111 112 </dependencies> 113 114 </project>
注:这里我们用 seata 1.4.2 版本替换自动导入的 seata 1.3.0 版本,是因为下文需要用到 seata 1.4.2 的导入单个 dataId 配置的功能。
4. 配置文件
1) 访问 Nacos 页面修改 seataClient.properties
浏览器访问 http://localhost:8848/nacos/, 输入登录名和密码(默认 nacos/nacos),点击提交按钮,跳转到 Nacos Server 控制台页面。
在 Nacos Server 控制台的 “配置管理” 下的 “配置列表” 中,创建或修改如下配置。
1 Data ID: seataClient.properties 2 Group: SEATA_GROUP 3 配置格式: Properties 4 配置内容: 5 6 service.vgroupMapping.default_tx_group=default 7 service.vgroupMapping.service-storage-group=default 8 service.default.grouplist=127.0.0.1:8092
注:可以把这两条内容直接加入到 seataServer.properties,无需新创建 seataClient.properties。这里分开放置 server 和 client 的配置,可以避免混淆两者的配置。
2) 创建 src/main/resources/application.yml 文件
1 server: 2 port: 5001 # 端口号 3 4 spring: 5 application: 6 name: seata-storage-5001 # 服务名 7 datasource: # 数据源配置 8 driver-class-name: org.mariadb.jdbc.Driver 9 name: seata_storage 10 url: jdbc:mysql://127.0.0.1:3306/seata_storage?rewriteBatchedStatements=true 11 username: nacos 12 password: nacos 13 cloud: 14 nacos: 15 discovery: 16 server-addr: 127.0.0.1:8848 17 namespace: # 留空表示使用 public 18 group: SEATA_GROUP 19 username: nacos 20 password: nacos 21 config: 22 server-addr: ${spring.cloud.nacos.discovery.server-addr} 23 context-path: /nacos 24 namespace: # 留空表示使用 public 25 username: ${spring.cloud.nacos.discovery.username} 26 password: ${spring.cloud.nacos.discovery.password} 27 28 mybatis: 29 mapper-locations: classpath:mapper/*.xml 30 31 seata: 32 #enabled: true 33 application-id: ${spring.application.name} 34 tx-service-group: service-storage-group 35 registry: 36 type: nacos 37 nacos: 38 server-addr: ${spring.cloud.nacos.discovery.server-addr} 39 application: seata-server 40 group: ${spring.cloud.nacos.discovery.group} 41 namespace: ${spring.cloud.nacos.discovery.namespace} 42 username: ${spring.cloud.nacos.discovery.username} 43 password: ${spring.cloud.nacos.discovery.password} 44 config: 45 type: nacos 46 nacos: 47 server-addr: ${spring.cloud.nacos.discovery.server-addr} 48 group: ${spring.cloud.nacos.discovery.group} 49 namespace: ${spring.cloud.nacos.discovery.namespace} 50 username: ${spring.cloud.nacos.discovery.username} 51 password: ${spring.cloud.nacos.discovery.password} 52 dataId: seataClient.properties
5. 数据库配置
1) 配置 Druid
创建 src/main/java/com/example/config/DruidDataSourceConfig.java 文件
1 package com.example.config; 2 3 import javax.sql.DataSource; 4 import java.sql.SQLException; 5 6 import com.alibaba.druid.pool.DruidDataSource; 7 import org.springframework.boot.context.properties.ConfigurationProperties; 8 import org.springframework.context.annotation.Bean; 9 import org.springframework.context.annotation.Configuration; 10 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 11 12 @Configuration 13 public class DruidDataSourceConfig implements WebMvcConfigurer { 14 15 @ConfigurationProperties("spring.datasource") 16 @Bean 17 public DataSource dataSource() throws SQLException { 18 DruidDataSource druidDataSource = new DruidDataSource(); 19 return druidDataSource; 20 } 21 }
2) 实体类
创建 src/main/java/com/example/entity/Storage.java 文件
1 package com.example.entity; 2 3 import lombok.Data; 4 import lombok.NoArgsConstructor; 5 import lombok.experimental.Accessors; 6 import java.io.Serializable; 7 8 @NoArgsConstructor // 无参构造函数 9 @Data // 提供类的 get、set、equals、hashCode、canEqual、toString 方法 10 @Accessors(chain = true) 11 public class Storage implements Serializable { 12 private Long id; 13 private Long productId; 14 private Integer total; 15 private Integer used; 16 private Integer residue; 17 }
3) Mybatis Mapper
(1) 创建 src/main/java/com/example/mapper/StorageMapper.java 文件
1 package com.example.mapper; 2 3 import com.example.entity.Storage; 4 import org.apache.ibatis.annotations.Mapper; 5 6 @Mapper 7 public interface StorageMapper { 8 9 Storage selectByProductId(Long productId); 10 11 int decrease(Storage storage); 12 }
(2) 创建 src/main/resources/mapper/StorageMapper.xml 文件
1 <?xml version="1.0" encoding="UTF-8"?> 2 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 3 "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> 4 <mapper namespace="com.example.mapper.StorageMapper"> 5 <resultMap id="BaseResultMap" type="com.example.entity.Storage"> 6 <id column="id" jdbcType="BIGINT" property="id"/> 7 <result column="product_id" jdbcType="BIGINT" property="productId"/> 8 <result column="total" jdbcType="INTEGER" property="total"/> 9 <result column="used" jdbcType="INTEGER" property="used"/> 10 <result column="residue" jdbcType="INTEGER" property="residue"/> 11 </resultMap> 12 <sql id="Base_Column_List"> 13 id, product_id, total, used, residue 14 </sql> 15 <update id="decrease" parameterType="com.example.entity.Storage"> 16 UPDATE tbl_storages 17 <set> 18 <if test="total != null"> 19 total = #{total,jdbcType=INTEGER}, 20 </if> 21 <if test="used != null"> 22 used = #{used,jdbcType=INTEGER}, 23 </if> 24 <if test="residue != null"> 25 residue = #{residue,jdbcType=INTEGER}, 26 </if> 27 </set> 28 WHERE product_id = #{productId,jdbcType=BIGINT} 29 </update> 30 <select id="selectByProductId" parameterType="java.lang.Long" resultMap="BaseResultMap"> 31 SELECT 32 <include refid="Base_Column_List"/> 33 FROM tbl_storages 34 WHERE product_id = #{productId,jdbcType=BIGINT} 35 </select> 36 </mapper>
6. 业务操作
1) 创建 src/main/java/com/example/service/StorageService.java 文件
1 package com.example.service; 2 3 public interface StorageService { 4 5 int decrease(Long productId, Integer count); 6 7 }
2) 创建 src/main/java/com/example/service/StorageServiceImpl.java 文件
1 package com.example.service; 2 3 import org.springframework.beans.factory.annotation.Autowired; 4 5 import com.example.entity.Storage; 6 import com.example.mapper.StorageMapper; 7 import org.springframework.stereotype.Service; 8 9 @Service 10 public class StorageServiceImpl implements StorageService { 11 @Autowired 12 private StorageMapper storageMapper; 13 14 @Override 15 public int decrease(Long productId, Integer count) { 16 17 Storage storage = storageMapper.selectByProductId(productId); 18 System.out.println("StorageServiceImpl -> decrease(): storage = " + storage); 19 20 if (storage != null && storage.getResidue().intValue() >= count.intValue()) { 21 22 Storage storage2 = new Storage(); 23 storage2.setProductId(productId); 24 storage.setUsed(storage.getUsed() + count); 25 storage.setResidue(storage.getTotal().intValue() - storage.getUsed()); 26 int ret = storageMapper.decrease(storage); 27 System.out.println("StorageServiceImpl -> decrease(): ret = " + ret); 28 return ret; 29 } else { 30 31 System.out.println("StorageServiceImpl -> decrease(): Insufficient Balance"); 32 throw new RuntimeException("StorageServiceImpl - Insufficient Balance"); 33 } 34 } 35 }
3) 创建 src/main/java/com/example/controller/StorageController.java 文件
1 package com.example.controller; 2 3 import org.springframework.beans.factory.annotation.Autowired; 4 import org.springframework.beans.factory.annotation.Value; 5 import org.springframework.web.bind.annotation.PostMapping; 6 import org.springframework.web.bind.annotation.RequestParam; 7 import org.springframework.web.bind.annotation.RestController; 8 import com.example.service.StorageService; 9 10 @RestController 11 public class StorageController { 12 @Autowired 13 private StorageService storageService; 14 15 @Value("${server.port}") 16 private String serverPort; 17 18 @PostMapping(value = "/storage/decrease") 19 public int decrease(@RequestParam("productId") Long productId, 20 @RequestParam("count") Integer count) { 21 22 return storageService.decrease(productId, count); 23 24 } 25 }
4) 修改 src/main/java/com/example/App.java 文件
1 package com.example; 2 3 import org.springframework.boot.SpringApplication; 4 import org.springframework.boot.autoconfigure.SpringBootApplication; 5 import org.springframework.cloud.client.discovery.EnableDiscoveryClient; 6 import org.springframework.cloud.openfeign.EnableFeignClients; 7 8 @EnableDiscoveryClient 9 @EnableFeignClients 10 @SpringBootApplication(scanBasePackages = "com.example") 11 public class App { 12 public static void main(String[] args) { 13 SpringApplication.run(App.class, args); 14 } 15 }
7. 打包运行
菜单 Run -> Edit Configurations (或工具条上选择) —> 进入 Run/Debug Configurations 页面 -> Click "+" add new configuration -> Select "Maven":
Working directory: SeataStorage 所在路径
Command line: clean package
-> Apply / OK
Click Run "SeataStorage [clean, package]" ,jar 包生成在目录 target/ 里
SeataStorage-1.0-SNAPSHOT.jar
SeataStorage-1.0-SNAPSHOT.jar.original
打开 cmd 命令行窗口,进入 SeataStorage 模块目录,运行如下命令:
...\SpringcloudDemo05\SeataStorage>java -jar target\SeataStorage-1.0-SNAPSHOT.jar
显示如下:
1 ... 2 3 INFO 29796 --- [ main] com.example.App : Started App in 3.527 seconds (JVM running for 3.906) 4 INFO 29796 --- [eoutChecker_2_1] i.s.c.r.netty.NettyClientChannelManager : will connect to 192.168.0.2:8092 5 INFO 29796 --- [eoutChecker_1_1] i.s.c.r.netty.NettyClientChannelManager : will connect to 192.168.0.2:8092 6 INFO 29796 --- [eoutChecker_2_1] i.s.core.rpc.netty.NettyPoolableFactory : NettyPool create channel to transactionRole:RMROLE,address:192.168.0.2:8092,msg:< RegisterRMRequest{resourceIds='null', applicationId='seata-storage-5001', transactionServiceGroup='service-storage-group'} > 7 INFO 29796 --- [eoutChecker_1_1] i.s.core.rpc.netty.NettyPoolableFactory : NettyPool create channel to transactionRole:TMROLE,address:192.168.0.2:8092,msg:< RegisterTMRequest{applicationId='seata-storage-5001', transactionServiceGroup='service-storage-group'} > 8 INFO 29796 --- [eoutChecker_2_1] i.s.c.rpc.netty.RmNettyRemotingClient : register RM success. client version:1.4.2, server version:1.4.2,channel:[id: 0x71db9d47, L:/192.168.0.2:49789 - R:/192.168.0.2:8092] 9 INFO 29796 --- [eoutChecker_1_1] i.s.c.rpc.netty.TmNettyRemotingClient : register TM success. client version:1.4.2, server version:1.4.2,channel:[id: 0xe1c5c21b, L:/192.168.0.2:49788 - R:/192.168.0.2:8092] 10 INFO 29796 --- [eoutChecker_2_1] i.s.core.rpc.netty.NettyPoolableFactory : register success, cost 130 ms, version:1.4.2,role:RMROLE,channel:[id: 0x71db9d47, L:/192.168.0.2:49789 - R:/192.168.0.2:8092] 11 INFO 29796 --- [eoutChecker_1_1] i.s.core.rpc.netty.NettyPoolableFactory : register success, cost 130 ms, version:1.4.2,role:TMROLE,channel:[id: 0xe1c5c21b, L:/192.168.0.2:49788 - R:/192.168.0.2:8092]
注:使用 spring-cloud-starter-alibaba-seata 或 seata-spring-boot-starter 的 seata 客户端默认是开启状态 (可以设置 seata.enabled=false 来关闭)。
seata 客户端里包含了一个全局事务扫描器 (GlobalTransactionScanner),seata 客户端运行后(30 秒左右)GlobalTransactionScanner 会调用初始化功能,使用 netty 连接 Seata 服务端。
从 log 可以看出 SeataStorage 成功连接到了 Seata Server (192.168.0.2:8092),192.168.0.2 是本地主机的内网地址。