Seata介绍
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。
2019 年 1 月,阿里巴巴中间件团队发起了开源项目 Fescar(Fast & EaSy Commit And Rollback),和社区一起共建开源分布式事务解决方案。Fescar 的愿景是让分布式事务的使用像本地事务的使用一样,简单和高效,并逐步解决开发者们遇到的分布式事务方面的所有难题。
Fescar 开源后,蚂蚁金服加入 Fescar 社区参与共建,并在 Fescar 0.4.0 版本中贡献了 TCC 模式。
为了打造更中立、更开放、生态更加丰富的分布式事务开源社区,经过社区核心成员的投票,大家决定对 Fescar 进行品牌升级,并更名为 Seata,意为:Simple Extensible Autonomous Transaction Architecture,是一套一站式分布式事务解决方案。
Seata 融合了阿里巴巴和蚂蚁金服在分布式事务技术上的积累,并沉淀了新零售、云计算和新金融等场景下丰富的实践经验,但要实现适用于所有的分布式事务场景的愿景,仍有很长的路要走。
Seata AT事务方案
Seata 的 AT 模式(Automatic Transaction)是一种无侵入的分布式事务解决方案。下面结合具体业务场景来分析其执行的原理。
业务场景
订单系统
当用户下订单时,执行以下三步流程:
- 订单系统保存订单
- 订单系统调用库存服务,减少商品库存
- 订单系统调用账户服务,扣减用户金额
这三步要作为一个整体事务进行管理,要么整体成功,要么整体失败。
Seata AT基本原理
Seata AT 事务分两个阶段来管理全局事务:
第一阶段: 执行各分支事务
第二阶段: 控制全局事务最终提交或回滚
第一阶段:执行各分支事务
微服务系统中,各服务之间无法相互感知事务是否执行成功,这时就需要一个专门的服务,来协调各个服务的运行状态。这个服务称为 TC(Transaction Coordinator),事务协调器。
订单系统开始执行保存订单之前,首先启动 TM(Transaction Manager,事务管理器),由 TM 向 TC 申请开启一个全局事务:
这时TC会产生一个全局事务ID,称为 XID,并将 XID 传回 TM:
这样就开启了全局事务!
全局事务开启后,开始执行创建订单的业务。首先执行保存订单,这时会先启动一个 RM(Resource Manager,资源管理器),并将 XID 传递给 RM。
RM 负责对分支事务(即微服务的本地事务)进行管理,并与 TC 通信,上报分支事务的执行状态、接收全局事务的提交或回滚指令。
RM 首先会使用 XID 向 TC 注册分支事务,将分支事务纳入对应的全局事务管辖。
现在可以执行保存订单的分支事务了。一旦分支事务执行成功,RM 会上报事务状态:
到此,保存订单过程结束。下面是调用库存服务,减少商品库存,与订单的执行过程相同。
首先调用库存服务,启动 RM,并传递 XID:
库存服务的 RM 使用 XID 向 TC 进行注册,纳入全局事务管辖:
执行本地事务成功后上报状态,TC会将状态发送给TM:
相同的,完成账户分支事务:
第二阶段:控制全局事务最终提交
现在,TM(全局事务管理器)收集齐了全部分支事务的成功状态,它会进行决策,确定全局事务成功,向 TC 发送全局事务的提交请求:
然后,TC 会向所有 RM 发送提交操作指令,RM 会完成最终提交操作:
到此,全局事务全部提交完成!
第二阶段:控制全局事务最终回滚
上面是全局事务执行成功的情况,下面再来看看事务执行失败的情况。
假设订单业务执行过程中,扣减账户金额这一步分支事务执行失败,那么失败状态对TC上报,然后再发送给TM:
TM 会进行决策,确定全局事务失败,向 TC 发送全局事务的回滚请求:
然后,TC 会向所有 RM 发送回滚操作指令,RM 会完成最终回滚操作:
Seata AT具体工作机制
以上了解了 Seata AT 的基本原理、工作流程,那么 Seata 具体是如何实现全局事务的提交和回滚操作呢?下面来分析 Seata 的具体工作机制。
第一阶段:执行分支事务
以全面订单业务中的库存服务为例,库存表中存在一条商品的库存信息:
现在要执行业务操作减少库存,从50件减少到40件:
执行修改库存业务操作前, 会先取出旧的库存信息:
现在可以修改库存了:
接着,取出更新后的新数据:
接下来,会把旧数据和新数据合并起来,保存到一个事务回滚日志表:undo_log表:
至此,第一阶段,分支事务完成,将状态上报给TC:
第二阶段:控制全局事务最终回滚
假如全局事务失败,那么第一阶段已提交的分支事务要执行回滚操作。
首先会收到来自 TC 的全局事务回滚指令:
然后删除事务日志,最终完成第二阶段回滚操作:
第二阶段:控制全局事务最终提交
上面是全局事务回滚操作。如果全局事务成功,要完成最终提交,AT模式最终提交操作非常简单,只需要删除日志数据即可。
首先接收到 TC 的全局事务提交指令:
接着,直接删除事务日志,就完成了第二阶段提交操作:
导入订单项目,无事务版本
下载项目代码
- 访问 git 仓库 https://gitee.com/benwang6/seata-samples
- 访问项目标签
- 下载无事务版
订单案例涉及四个数据库:
使用db-init工具,来重置所有数据库表,可以方便地把数据重置到初始状态。
Seata Server - TC全局事务协调器
在前面介绍了 Seata AT 事务原理,介绍了 AT 事务的三个角色:TC(事务协调器)、TM(事务管理器)和RM(资源管理器),其中 TM 和 RM 是嵌入在业务应用中的,而 TC 则是一个独立服务。
Seata Server 就是 TC,直接从官方仓库下载启动即可,下载地址:https://github.com/seata/seata/releases
Seata Server 配置
Seata Server 的配置文件有两个:
seata/conf/registry.conf
seata/conf/file.conf
registry.conf
Seata Server 要向注册中心进行注册,这样,其他服务就可以通过注册中心去发现 Seata Server,与 Seata Server 进行通信。
Seata 支持多款注册中心服务:nacos 、eureka、redis、zk、consul、etcd3、sofa。
我们项目中要使用 eureka 注册中心,eureka服务的连接地址、注册的服务名,这需要在 registry.conf
文件中进行配置:
registry {
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
# 这里选择 eureka 注册配置
type = "eureka"
nacos {
......
}
# eureka的注册配置
eureka {
# 注册中心地址
serviceUrl = "http://localhost:8761/eureka"
# 注册的服务ID
application = "seata-server"
weight = "1"
}
redis {
......
}
......
file.conf
Seata 需要存储全局事务信息、分支事务信息、全局锁信息,这些数据存储到什么位置?
针对存储位置的配置,支持放在配置中心,或者也可以放在本地文件。Seata Server 支持的配置中心服务有:nacos 、apollo、zk、consul、etcd3。
这里我们选择最简单的,使用本地文件,这需要在 registry.conf
配置文件中来指定:
......
config {
# file、nacos 、apollo、zk、consul、etcd3
# 在这里选择使用本地文件来保存配置
type = "file"
......
etcd3 {
serverAddr = "http://localhost:2379"
}
file {
# 在这里设置配置文件的文件名
name = "file.conf"
}
}
file.conf
中对事务信息的存储位置进行配置,存储位置支持:file、db、redis。
这里我们选择数据库作为存储位置,这需要在 file.conf
中进行配置:
store {
## store mode: file、db、redis
# 这里选择数据库存储
mode = "db"
## file store property
file {
......
}
# 数据库存储
db {
## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp)/HikariDataSource(hikari) etc.
datasource = "druid"
## mysql/oracle/postgresql/h2/oceanbase etc.
dbType = "mysql"
driverClassName = "com.mysql.jdbc.Driver"
# 数据库连接配置
url = "jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2B8"
user = "root"
password = "root"
minConn = 5
maxConn = 30
# 事务日志表表名设置
globalTable = "global_table"
branchTable = "branch_table"
lockTable = "lock_table"
queryLimit = 100
maxWait = 5000
}
## redis store property
redis {
......
}
}
启动参数设置
启动文件:seata-server.bat
用文本编辑器打开文件,找到文件中这一行:
%JAVACMD% %JAVA_OPTS% -server -Xmx2048m -Xms2048m -Xmn1024m -Xss512k -XX:Sur......
看到 Seata Server 默认使用 2G 内存,测试环境我们可以把内存调低:
%JAVACMD% %JAVA_OPTS% -server -Xmx256m -Xms256m -Xmn128m -Xss512k -XX:Sur......
启动 Seata Server
双击 seata-server.bat
启动 Seata Server。
查看 Eureka 注册中心 Seata Server 的注册信息:
order订单服务添加 Seata AT 事务
订单调用库存和账户,我们先从前面的订单开始。
在订单项目中要启动全局事务,还要执行订单保存的分支事务。
order-parent 添加 seata 依赖
order-parent 的 pom.xml 文件中有一段注释掉的 seata 依赖,现在可以打开它了:
4.0.0
org.springframework.boot
spring-boot-starter-parent
2.3.2.RELEASE
cn.tedu
order-parent
1.0-SNAPSHOT
pom
order-parent
3.3.2
1.1.23
1.3.0
2.0.0.RELEASE
Hoxton.SR6
org.springframework.boot
spring-boot-starter
org.springframework.boot
spring-boot-starter-web
org.springframework.cloud
spring-cloud-starter-netflix-eureka-client
org.springframework.cloud
spring-cloud-starter-openfeign
com.baomidou
mybatis-plus-boot-starter
${mybatis-plus.version}
mysql
mysql-connector-java
com.alibaba
druid-spring-boot-starter
${druid-spring-boot-starter.version}
com.alibaba.cloud
spring-cloud-alibaba-seata
${spring-cloud-alibaba-seata.version}
seata-all
io.seata
io.seata
seata-all
${seata.version}
org.projectlombok
lombok
org.springframework.boot
spring-boot-starter-test
test
org.junit.vintage
junit-vintage-engine
org.springframework.cloud
spring-cloud-dependencies
${spring-cloud.version}
pom
import
org.springframework.boot
spring-boot-maven-plugin
配置
application.yml
TC 事务协调器通过“事务组”的方式将多个服务组织成一个全局事务。每个服务启动时要向TC注册,加入同一个事务组。
spring:
......
cloud:
alibaba:
seata:
tx-service-group: order_tx_group
......
registry.conf
需要从注册中心获得 TC 的地址,这里配置注册中心的地址。
TC 在注册中心注册的服务ID在下面 file.conf
中指定。
registry {
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "eureka"
nacos {
serverAddr = "localhost"
namespace = ""
cluster = "default"
}
eureka {
serviceUrl = "http://localhost:8761/eureka"
# application = "default"
# weight = "1"
}
redis {
serverAddr = "localhost:6379"
db = "0"
password = ""
cluster = "default"
timeout = "0"
}
zk {
cluster = "default"
serverAddr = "127.0.0.1:2181"
session.timeout = 6000
connect.timeout = 2000
username = ""
password = ""
}
consul {
cluster = "default"
serverAddr = "127.0.0.1:8500"
}
etcd3 {
cluster = "default"
serverAddr = "http://localhost:2379"
}
sofa {
serverAddr = "127.0.0.1:9603"
application = "default"
region = "DEFAULT_ZONE"
datacenter = "DefaultDataCenter"
cluster = "default"
group = "SEATA_GROUP"
addressWaitTime = "3000"
}
file {
name = "file.conf"
}
}
config {
# file、nacos 、apollo、zk、consul、etcd3、springCloudConfig
type = "file"
nacos {
serverAddr = "localhost"
namespace = ""
group = "SEATA_GROUP"
}
consul {
serverAddr = "127.0.0.1:8500"
}
apollo {
app.id = "seata-server"
apollo.meta = "http://192.168.1.204:8801"
namespace = "application"
}
zk {
serverAddr = "127.0.0.1:2181"
session.timeout = 6000
connect.timeout = 2000
username = ""
password = ""
}
etcd3 {
serverAddr = "http://localhost:2379"
}
file {
name = "file.conf"
}
}
file.conf
在这里我们指定 TC 的服务ID seata-server
:
vgroupMapping.order_tx_group = "seata-server"
order_tx_group
对应 application.yml
中注册的事务组名。
transport {
# tcp udt unix-domain-socket
type = "TCP"
#NIO NATIVE
server = "NIO"
#enable heartbeat
heartbeat = true
# the client batch send request enable
enableClientBatchSendRequest = true
#thread factory for netty
threadFactory {
bossThreadPrefix = "NettyBoss"
workerThreadPrefix = "NettyServerNIOWorker"
serverExecutorThread-prefix = "NettyServerBizHandler"
shareBossWorker = false
clientSelectorThreadPrefix = "NettyClientSelector"
clientSelectorThreadSize = 1
clientWorkerThreadPrefix = "NettyClientWorkerThread"
# netty boss thread size,will not be used for UDT
bossThreadSize = 1
#auto default pin or 8
workerThreadSize = "default"
}
shutdown {
# when destroy server, wait seconds
wait = 3
}
serialization = "seata"
compressor = "none"
}
service {
#transaction service group mapping
# order_tx_group 与 yml 中的 “tx-service-group: order_tx_group” 配置一致
# “seata-server” 与 TC 服务器的注册名一致
# 从eureka获取seata-server的地址,再向seata-server注册自己,设置group
vgroupMapping.order_tx_group = "seata-server"
#only support when registry.type=file, please don't set multiple addresses
order_tx_group.grouplist = "127.0.0.1:8091"
#degrade, current not support
enableDegrade = false
#disable seata
disableGlobalTransaction = false
}
client {
rm {
asyncCommitBufferLimit = 10000
lock {
retryInterval = 10
retryTimes = 30
retryPolicyBranchRollbackOnConflict = true
}
reportRetryCount = 5
tableMetaCheckEnable = false
reportSuccessEnable = false
}
tm {
commitRetryCount = 5
rollbackRetryCount = 5
}
undo {
dataValidation = true
logSerialization = "jackson"
logTable = "undo_log"
}
log {
exceptionRate = 100
}
}
创建 seata 数据源代理
Seata AT 事务对业务代码无侵入,全自动化处理全局事务,其功能是靠 Seata 的数据源代理工具实现的。
这里我们创建 Seata 的数据源代理,并排除 Spring 默认的数据源。
package cn.tedu.order;
import com.alibaba.druid.pool.DruidDataSource;
import io.seata.rm.datasource.DataSourceProxy;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import javax.sql.DataSource;
@Configuration
public class DatasourceConfiguration {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DataSource druidDataSource(){
DruidDataSource druidDataSource = new DruidDataSource();
return druidDataSource;
}
@Primary
@Bean("dataSource")
public DataSourceProxy dataSource(DataSource druidDataSource){
return new DataSourceProxy(druidDataSource);
}
}
主程序中排除Springboot 的默认数据源:
package cn.tedu.order;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.cloud.openfeign.EnableFeignClients;
@EnableFeignClients
@MapperScan("cn.tedu.order.mapper")
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)//不使用springboot自己的数据源自动配置
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
启动全局事务
Seata AT 对业务无侵入,所以启动全局事务非常简单,只需要添加一个 @GlobalTransactional
注解即可。
另外我们一步一步地添加全局事务并测试,这里先把 storage 和 account 调用注掉。
package cn.tedu.order.service;
import cn.tedu.order.entity.Order;
import cn.tedu.order.feign.AccountClient;
import cn.tedu.order.feign.EasyIdGeneratorClient;
import cn.tedu.order.feign.StorageClient;
import cn.tedu.order.mapper.OrderMapper;
import io.seata.spring.annotation.GlobalTransactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
EasyIdGeneratorClient easyIdGeneratorClient;
@Autowired
private AccountClient accountClient;
@Autowired
private StorageClient storageClient;
//由TM(Transaction Manager),由TM向协调器申请开启全局事务
@GlobalTransactional
@Override
public void create(Order order) {
// 从全局唯一id发号器获得id
Long orderId = Long.valueOf(easyIdClient.nextId("order_business"));
order.setId(orderId);
orderMapper.create(order);
// 修改库存
//storageClient.decrease(order.getProductId(), order.getCount());
// 修改账户余额
//accountClient.decrease(order.getUserId(), order.getMoney());
}
}
启动 order 项目进行测试
按顺序启动服务:
- Eureka
- Seata Server
- Easy Id Generator
- Order
调用保存订单,地址:
http://localhost:8083/create?userId=1&productId=1&count=10&money=100
观察控制台,看到全局事务和订单的分支事务已经启动,并可以看到全局事务ID(XID)和分支事务ID(Branch ID):
然后观察数据库中新添加的订单数据:
测试出现异常,回滚的情况
在业务代码中加一个模拟异常再试一下:
package cn.tedu.order.service;
import cn.tedu.order.entity.Order;
import cn.tedu.order.feign.AccountClient;
import cn.tedu.order.feign.EasyIdGeneratorClient;
import cn.tedu.order.feign.StorageClient;
import cn.tedu.order.mapper.OrderMapper;
import io.seata.spring.annotation.GlobalTransactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
EasyIdGeneratorClient easyIdGeneratorClient;
@Autowired
private AccountClient accountClient;
@Autowired
private StorageClient storageClient;
@GlobalTransactional
@Override
public void create(Order order) {
// 从全局唯一id发号器获得id
Long orderId = easyIdGeneratorClient.nextId("order_business");
order.setId(orderId);
orderMapper.create(order);
if (Math.random() < 0.5) {
throw new RuntimeException("模拟异常");
}
// 修改库存
//storageClient.decrease(order.getProductId(), order.getCount());
// 修改账户余额
//accountClient.decrease(order.getUserId(), order.getMoney());
}
}
重启 order 项目,并调用保存订单:
http://localhost:8083/create?userId=1&productId=1&count=10&money=100
可以看到全局事务回滚的日志:
订单启动全局事务部分完成,在继续之前,先把模拟异常注释掉:
......
//if (Math.random() < 0.5) {
// throw new RuntimeException("模拟异常");
//}
......
storage库存服务添加 Seata AT 事务
配置
与订单项目中添加的配置完全相同,请参考订单配置章节配置下面三个文件:
- application.yml
- registry.conf
- file.conf
创建 seata 数据源代理
与订单项目中数据源代理完全相同,请参考订单中数据源代理章节,在 cn.tedu.storage
包下创建数据源配置类 DatasourceConfiguration
。主程序注解排除 DataSourceAutoConfiguration
自动配置类。
启动分支事务
在业务方法上添加 @Transactional
注解启动本地事务:
package cn.tedu.storage.service;
import cn.tedu.storage.mapper.StorageMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class StorageServiceImpl implements StorageService {
@Autowired
private StorageMapper storageMapper;
@Transactional
@Override
public void decrease(Long productId, Integer count) throws Exception {
storageMapper.decrease(productId,count);
}
}
order 的业务类中调用减少商品库存
前面我们把调用商品库存注释掉了,现把注释打开:
// 修改库存
storageClient.decrease(order.getProductId(), order.getCount());
启动 storage 项目进行测试
按顺序启动项目:
- Eureka
- Seata Server
- Easy Id Generator
- Storage
- Order
调用保存订单,地址:
http://localhost:8083/create?userId=1&productId=1&count=10&money=100
订单会调用库存,这两个服务会分别启动一个分支事务,两个分支事务一起组成一个全局事务:
观察两个项目的控制台都有Seata AT事务的日志,Storage 项目控制台如下:
然后观察数据库中新添加的订单和减少的库存:
减少的库存:
测试出现异常,回滚的情况
在业务代码中加一个模拟异常再试一下:
package cn.tedu.storage.service;
import cn.tedu.storage.mapper.StorageMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class StorageServiceImpl implements StorageService {
@Autowired
private StorageMapper storageMapper;
@Transactional
@Override
public void decrease(Long productId, Integer count) throws Exception {
storageMapper.decrease(productId,count);
if (Math.random() < 0.5) {
throw new RuntimeException("模拟异常");
}
}
}
重启 storage 项目,并调用保存订单:
http://localhost:8083/create?userId=1&productId=1&count=10&money=100
查看数据库表 order 和 storage,如果执行成功会新增订单、减少库存,如果执行失败则数据没有变化,被回滚了。
storage 分支事务部分完成,在继续之前,先把模拟异常注释掉:
......
//if (Math.random() < 0.5) {
// throw new RuntimeException("模拟异常");
//}
......
account账户服务添加 Seata AT 事务
配置
与订单项目中添加的配置完全相同,请参考订单配置章节配置下面三个文件:
- application.yml
- registry.conf
- file.conf
创建 seata 数据源代理
与订单项目中数据源代理完全相同,请参考订单中数据源代理章节,在 cn.tedu.account
包下创建数据源配置类 DatasourceConfiguration
。主程序注解排除 DataSourceAutoConfiguration
自动配置类。
启动分支事务
在业务方法上添加 @Transactional
注解启动本地事务:
package cn.tedu.account.service;
import cn.tedu.account.mapper.AccountMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountMapper accountMapper;
@Transactional
@Override
public void decrease(Long userId, BigDecimal money) {
accountMapper.decrease(userId,money);
}
}
order 的业务类中调用扣减账户金额
前面我们把调用账户注释掉了,现把注释打开:
// 修改账户余额
accountClient.decrease(order.getUserId(), order.getMoney());
启动 account 项目进行测试
按顺序启动项目:
- Eureka
- Seata Server
- Easy Id Generator
- Storage
- Account
- Order
调用保存订单,地址:
http://localhost:8083/create?userId=1&productId=1&count=10&money=100
订单会调用库存和账户,这三个服务会分别启动一个分支事务,三个分支事务一起组成一个全局事务:
观察三个项目的控制台都有Seata AT事务的日志,account 项目控制台如下:
然后观察数据库中的订单表、库存表和账户表。
这是账户表,看到金额已经被扣减:
测试出现异常,回滚的情况
在业务代码中加一个模拟异常再试一下:
package cn.tedu.account.service;
import cn.tedu.account.mapper.AccountMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountMapper accountMapper;
@Transactional
@Override
public void decrease(Long userId, BigDecimal money) {
accountMapper.decrease(userId,money);
if (Math.random() < 0.5) {
throw new RuntimeException("模拟异常");
}
}
}
重启 account 项目,并调用保存订单:
http://localhost:8083/create?userId=1&productId=1&count=10&money=100
查看数据库表 order、storage 和 account,如果执行成功会新增订单、减少库存、扣减金额,如果执行失败则数据没有变化,被回滚了。
失败时,在 order 和 storage 控制台可以看到回滚日志。
这是 storage 的回滚日志:
account 分支事务部分完成,最后把模拟异常注释掉:
......
//if (Math.random() < 0.5) {
// throw new RuntimeException("模拟异常");
//}
......