AT模式的优势,代码无侵入。
示例包括三个服务,订单服务,商品服务,账户服务。
创建订单的同时,需要扣减商品库存,并扣减账户余额,三个操作要么同时成功,要么同时失败。
在idea创建maven项目,parent项目seata-server-demo,在项目中创建订单服务模块(seata-oder-server)、商品服务模块(seata-product-server)、账户服务模块(seata-account-server)。
8
8
2.2.5.RELEASE
2.3.11.RELEASE
Hoxton.SR8
org.springframework.boot
spring-boot-dependencies
${spring-boot-version}
pom
import
org.springframework.cloud
spring-cloud-dependencies
${spring-cloud-version}
pom
import
com.alibaba.cloud
spring-cloud-alibaba-dependencies
${spring.cloud.alibaba.version}
pom
import
org.springframework.boot
spring-boot-starter-web
mysql
mysql-connector-java
com.baomidou
mybatis-plus-boot-starter
3.4.2
com.alibaba
druid-spring-boot-starter
1.2.6
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-discovery
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-config
com.alibaba.cloud
spring-cloud-starter-alibaba-seata
# seata配置
seata:
enabled: true
# Seata 应用编号,默认为 ${spring.application.name}
application-id: account-seata
# Seata 事务组编号,用于 TC 集群名
tx-service-group: seataserver-group
# 关闭自动代理
enable-auto-data-source-proxy: false
enableAutoDataSourceProxy: true
# 服务配置项
service:
# 虚拟组和分组的映射
vgroup-mapping:
seataserver-group: default
# 分组和 Seata 服务的映射
grouplist:
default: 127.0.0.1:8091
config:
type: nacos
nacos:
serverAddr: 127.0.0.1:8848
group: SEATA_GROUP
namespace:
registry:
type: nacos
nacos:
application: seata-server
server-addr: 127.0.0.1:8848
namespace:
controller接口:
package com.platform.account.controller;
import com.platform.account.handler.AjaxResult;
import com.platform.account.service.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AccountController {
@Autowired
private AccountService accountService;
/**
* 扣减库存接口
* @param userId
* @param price
*/
@PostMapping("/reduceBalance")
AjaxResult reduceBalance(@RequestParam("userId") Long userId, @RequestParam("price")Double price)
{
int res = accountService.reduceBalance(userId, price);
return AjaxResult.success(res);
}
}
服务实现:
package com.platform.account.service.impl;
import com.platform.account.domain.Account;
import com.platform.account.mapper.AccountMapper;
import com.platform.account.service.AccountService;
import io.seata.core.context.RootContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
@Service
public class AccountServiceImpl implements AccountService
{
private static final Logger log = LoggerFactory.getLogger(AccountServiceImpl.class);
@Resource
private AccountMapper accountMapper;
/**
* 扣减账户余额
* 事务传播特性设置为 REQUIRES_NEW 开启新的事务 重要!!!!一定要使用REQUIRES_NEW
*/
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int reduceBalance(Long userId, Double price)
{
int result = 0;
log.info("=============ACCOUNT START=================");
log.info("当前 XID: {}", RootContext.getXID());
Account account = accountMapper.selectById(userId);
Double balance = account.getBalance();
log.info("下单用户{}余额为 {},商品总价为{}", userId, balance, price);
if (balance < price)
{
log.warn("用户 {} 余额不足,当前余额:{}", userId, balance);
throw new RuntimeException("余额不足");
}
log.info("开始扣减用户 {} 余额", userId);
double currentBalance = account.getBalance() - price;
account.setBalance(currentBalance);
result = accountMapper.updateById(account);
log.info("扣减用户 {} 余额成功,扣减后用户账户余额为{}", userId, currentBalance);
log.info("=============ACCOUNT END=================");
return result;
}
}
# seata配置
seata:
enabled: true
# Seata 应用编号,默认为 ${spring.application.name}
application-id: product-seata
# Seata 事务组编号,用于 TC 集群名
tx-service-group: seataserver-group
# 关闭自动代理
enable-auto-data-source-proxy: false
enableAutoDataSourceProxy: true
# 服务配置项
service:
# 虚拟组和分组的映射
vgroup-mapping:
seataserver-group: default
# 分组和 Seata 服务的映射
grouplist:
default: 127.0.0.1:8091
config:
type: nacos
nacos:
serverAddr: 127.0.0.1:8848
group: SEATA_GROUP
namespace:
registry:
type: nacos
nacos:
application: seata-server
server-addr: 127.0.0.1:8848
namespace:
package com.platform.product.controller;
import com.platform.product.handler.AjaxResult;
import com.platform.product.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProductController {
@Autowired
private ProductService productService;
@GetMapping("/reduceStock")
AjaxResult reduceStock(@RequestParam("productId") Long productId, @RequestParam("amount")Integer amount)
{
Double remain = productService.reduceStock(productId, amount);
return AjaxResult.success(remain);
}
}
package com.platform.product.service.impl;
import com.platform.product.domain.Product;
import com.platform.product.mapper.ProductMapper;
import com.platform.product.service.ProductService;
import io.seata.core.context.RootContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
@Service
public class ProductServiceImpl implements ProductService
{
private static final Logger log = LoggerFactory.getLogger(ProductServiceImpl.class);
@Resource
private ProductMapper productMapper;
/**
* 事务传播特性设置为 REQUIRES_NEW 开启新的事务 重要!!!!一定要使用REQUIRES_NEW
*/
// @DS("product")
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public Double reduceStock(Long productId, Integer amount)
{
log.info("=============PRODUCT START=================");
log.info("当前 XID: {}", RootContext.getXID());
// 检查库存
Product product = productMapper.selectById(productId);
Integer stock = product.getStock();
log.info("商品编号为 {} 的库存为{},订单商品数量为{}", productId, stock, amount);
if (stock < amount)
{
log.warn("商品编号为{} 库存不足,当前库存:{}", productId, stock);
throw new RuntimeException("库存不足");
}
log.info("开始扣减商品编号为 {} 库存,单价商品价格为{}", productId, product.getPrice());
// 扣减库存
int currentStock = stock - amount;
product.setStock(currentStock);
productMapper.updateById(product);
double totalPrice = product.getPrice() * amount;
log.info("扣减商品编号为 {} 库存成功,扣减后库存为{}, {} 件商品总价为 {} ", productId, currentStock, amount, totalPrice);
log.info("=============PRODUCT END=================");
return totalPrice;
}
}
org.springframework.cloud
spring-cloud-starter-openfeign
# seata配置
seata:
enabled: true
# Seata 应用编号,默认为 ${spring.application.name}
application-id: order-seata
# Seata 事务组编号,用于 TC 集群名
tx-service-group: seataserver-group
# 服务配置项
service:
# 虚拟组和分组的映射
vgroup-mapping:
seataserver-group: default
# 分组和 Seata 服务的映射
grouplist:
default: 127.0.0.1:8091
config:
type: nacos
nacos:
serverAddr: 127.0.0.1:8848
group: SEATA_GROUP
namespace:
registry:
type: nacos
nacos:
application: seata-server
server-addr: 127.0.0.1:8848
namespace:
controller代码:
package com.platform.order.controller;
import com.platform.order.dto.PlaceOrderRequest;
import com.platform.order.service.OrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/order")
public class OrderController
{
@Autowired
private OrderService orderService;
@PostMapping("/placeOrder")
public String placeOrder(@Validated @RequestBody PlaceOrderRequest request)
{
orderService.placeOrder(request);
return "下单成功";
}
@PostMapping("/test1")
// @ApiOperation("测试商品库存不足-异常回滚")
public String test1()
{
// 商品单价10元,库存20个,用户余额50元,模拟一次性购买22个。 期望异常回滚
orderService.placeOrder(new PlaceOrderRequest(1L, 1L, 22));
return "下单成功";
}
@PostMapping("/test2")
// @ApiOperation("测试用户账户余额不足-异常回滚")
public String test2()
{
// 商品单价10元,库存20个,用户余额50元,模拟一次性购买6个。 期望异常回滚
orderService.placeOrder(new PlaceOrderRequest(1L, 1L, 6));
return "下单成功";
}
}
service实现代码:
package com.platform.order.service.impl;
import com.platform.order.domain.Order;
import com.platform.order.dto.PlaceOrderRequest;
import com.platform.order.entity.AjaxResult;
import com.platform.order.feign.service.AccountFeignService;
import com.platform.order.feign.service.ProductFeignService;
import com.platform.order.mapper.OrderMapper;
import com.platform.order.service.OrderService;
import io.seata.core.context.RootContext;
import io.seata.spring.annotation.GlobalTransactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
@Service
public class OrderServiceImpl implements OrderService
{
private static final Logger log = LoggerFactory.getLogger(OrderServiceImpl.class);
@Resource
private OrderMapper orderMapper;
@Autowired
private AccountFeignService accountService;
@Autowired
private ProductFeignService productService;
// @DS("order") // 每一层都需要使用多数据源注解切换所选择的数据库
@Override
@GlobalTransactional // 重点 第一个开启事务的需要添加seata全局事务注解
public void placeOrder(PlaceOrderRequest request)
{
log.info("=============ORDER START=================");
Long userId = request.getUserId();
Long productId = request.getProductId();
Integer amount = request.getAmount();
// log.info("收到下单请求,用户:{}, 商品:{},数量:{}", userId, productId, amount);
//
// log.info("当前 XID: {}", RootContext.getXID());
//
Order order = new Order(userId, productId, 0, amount);
//
// orderMapper.insert(order);
// log.info("订单一阶段生成,等待扣库存付款中");
// 扣减库存并计算总价
commitOrder(request);
AjaxResult result = productService.reduceStock(productId, amount);
Double totalPrice = 0d;
if(0 == (Integer) result.get(AjaxResult.CODE_TAG))
{
totalPrice = (Double)result.get("data");
}
else
{
throw new RuntimeException("扣减库存Failed!");
}
// 扣减余额
result = accountService.reduceBalance(userId, totalPrice);
// success
if(0 == (Integer) result.get(AjaxResult.CODE_TAG))
{
order.setStatus(1);
order.setTotalPrice(totalPrice);
orderMapper.updateById(order);
log.info("订单已成功下单");
}
else {
throw new RuntimeException("扣减Account Failed!");
}
log.info("=============ORDER END=================");
}
@Transactional
public void commitOrder(PlaceOrderRequest request)
{
log.info("=============ORDER START=================");
Long userId = request.getUserId();
Long productId = request.getProductId();
Integer amount = request.getAmount();
log.info("收到下单请求,用户:{}, 商品:{},数量:{}", userId, productId, amount);
log.info("当前 XID: {}", RootContext.getXID());
Order order = new Order(userId, productId, 0, amount);
orderMapper.insert(order);
log.info("订单一阶段生成,等待扣库存付款中");
}
}
这里以单个数据库为例。如果多个数据库,则需要分表在每个库中创建undo_log表。
CREATE TABLE p_order
(
id INT(11) NOT NULL AUTO_INCREMENT,
user_id INT(11) DEFAULT NULL,
product_id INT(11) DEFAULT NULL,
amount INT(11) DEFAULT NULL,
total_price DOUBLE DEFAULT NULL,
status VARCHAR(100) DEFAULT NULL,
add_time DATETIME DEFAULT CURRENT_TIMESTAMP,
last_update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8;
CREATE TABLE product
(
id INT(11) NOT NULL AUTO_INCREMENT,
price DOUBLE DEFAULT NULL,
stock INT(11) DEFAULT NULL,
last_update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8;
INSERT INTO product (id, price, stock) VALUES (1, 10, 20);
CREATE TABLE account
(
id INT(11) NOT NULL AUTO_INCREMENT,
balance DOUBLE DEFAULT NULL,
last_update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8;
INSERT INTO account (id, balance) VALUES (1, 50);
CREATE TABLE undo_log
(
id BIGINT(20) NOT NULL AUTO_INCREMENT,
branch_id BIGINT(20) NOT NULL,
xid VARCHAR(100) NOT NULL,
context VARCHAR(128) NOT NULL,
rollback_info LONGBLOB NOT NULL,
log_status INT(11) 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
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8;
undo_log
表,是Seata AT
模式必须创建的表,主要用于分支事务的回滚。
测试发送数据的方式:
1. Success test.
Post: http://localhost:9202/order/placeOrder
Content-Type/application/json
{
"userId": 1,
"productId": 1,
"amount": 1
}
2. Fail Test库存不足
Post: http://localhost:9202/order/placeOrder
Content-Type/application/json
{
"userId": 1,
"productId": 1,
"amount": 22
}
3. Fail Test用户余额不足
Post: http://localhost:9202/order/placeOrder
Content-Type/application/json
{
"userId": 1,
"productId": 1,
"amount": 6
}
根据数据库表中的数据多少,决定测试的场景参数。
在执行回滚时,出现无法回滚的错误提示:
2022-03-24 14:35:29.998 INFO 172484 --- [h_RMROLE_1_9_16] i.seata.rm.datasource.DataSourceManager : branchRollback failed. branchType:[AT], xid:[192.168.17.85:8091:9205601971902693387], branchId:[9205601971902693392], resourceId:[jdbc:mysql://localhost:3306/ftcsp], applicationData:[null]. reason:[Branch session rollback failed and try again later xid = 192.168.17.85:8091:9205601971902693387 branchId = 9205601971902693392 com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `java.time.LocalDateTime` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (byte[])"{"@class":"io.seata.rm.datasource.undo.BranchUndoLog","xid":"192.168.17.85:8091:9205601971902693387","branchId":9205601971902693392,"sqlUndoLogs":["java.util.ArrayList",[{"@class":"io.seata.rm.datasource.undo.SQLUndoLog","sqlType":"UPDATE","tableName":"product","beforeImage":{"@class":"io.seata.rm.datasource.sql.struct.TableRecords","tableName":"product","rows":["java.util.ArrayList",[{"@class":"io.seata.rm.datasource.sql.struct.Row","fields":["java.util.ArrayList",[{"@class":"io.seata.rm.dataso"[truncated 1605 bytes]; line: 1, column: 940] (through reference chain: io.seata.rm.datasource.undo.BranchUndoLog["sqlUndoLogs"]->java.util.ArrayList[0]->io.seata.rm.datasource.undo.SQLUndoLog["beforeImage"]->io.seata.rm.datasource.sql.struct.TableRecords["rows"]->java.util.ArrayList[0]->io.seata.rm.datasource.sql.struct.Row["fields"]->java.util.ArrayList[3]->io.seata.rm.datasource.sql.struct.Field["value"])]
2022-03-24 14:35:29.999 INFO 172484 --- [h_RMROLE_1_9_16] io.seata.rm.AbstractRMHandler : Branch Rollbacked result: PhaseTwo_RollbackFailed_Retryable
2022-03-24 14:35:30.986 INFO 172484 --- [_RMROLE_1_10_16] i.s.c.r.p.c.RmBranchRollbackProcessor : rm handle branch rollback process:xid=192.168.17.85:8091:9205601971902693387,branchId=9205601971902693392,branchType=AT,resourceId=jdbc:mysql://localhost:3306/ftcsp,applicationData=null
2022-03-24 14:35:30.986 INFO 172484 --- [_RMROLE_1_10_16] io.seata.rm.AbstractRMHandler : Branch Rollbacking: 192.168.17.85:8091:9205601971902693387 9205601971902693392 jdbc:mysql://localhost:3306/ftcsp
2022-03-24 14:35:30.992 ERROR 172484 --- [_RMROLE_1_10_16] i.s.r.d.u.parser.JacksonUndoLogParser : json decode exception, Cannot construct instance of `java.time.LocalDateTime` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (byte[])"{"@class":"io.seata.rm.datasource.undo.BranchUndoLog","xid":"192.168.17.85:8091:9205601971902693387","branchId":9205601971902693392,"sqlUndoLogs":["java.util.ArrayList",[{"@class":"io.seata.rm.datasource.undo.SQLUndoLog","sqlType":"UPDATE","tableName":"product","beforeImage":{"@class":"io.seata.rm.datasource.sql.struct.TableRecords","tableName":"product","rows":["java.util.ArrayList",[{"@class":"io.seata.rm.datasource.sql.struct.Row","fields":["java.util.ArrayList",[{"@class":"io.seata.rm.dataso"[truncated 1605 bytes]; line: 1, column: 940] (through reference chain: io.seata.rm.datasource.undo.BranchUndoLog["sqlUndoLogs"]->java.util.ArrayList[0]->io.seata.rm.datasource.undo.SQLUndoLog["beforeImage"]->io.seata.rm.datasource.sql.struct.TableRecords["rows"]->java.util.ArrayList[0]->io.seata.rm.datasource.sql.struct.Row["fields"]->java.util.ArrayList[3]->io.seata.rm.datasource.sql.struct.Field["value"])
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `java.time.LocalDateTime` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (byte[])"{"@class":"io.seata.rm.datasource.undo.BranchUndoLog","xid":"192.168.17.85:8091:9205601971902693387","branchId":9205601971902693392,"sqlUndoLogs":["java.util.ArrayList",[{"@class":"io.seata.rm.datasource.undo.SQLUndoLog","sqlType":"UPDATE","tableName":"product","beforeImage":{"@class":"io.seata.rm.datasource.sql.struct.TableRecords","tableName":"product","rows":["java.util.ArrayList",[{"@class":"io.seata.rm.datasource.sql.struct.Row","fields":["java.util.ArrayList",[{"@class":"io.seata.rm.dataso"[truncated 1605 bytes]; line: 1, column: 940] (through reference chain: io.seata.rm.datasource.undo.BranchUndoLog["sqlUndoLogs"]->java.util.ArrayList[0]->io.seata.rm.datasource.undo.SQLUndoLog["beforeImage"]->io.seata.rm.datasource.sql.struct.TableRecords["rows"]->java.util.ArrayList[0]->io.seata.rm.datasource.sql.struct.Row["fields"]->java.util.ArrayList[3]->io.seata.rm.datasource.sql.struct.Field["value"])
at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:67) ~[jackson-databind-2.11.4.jar:2.11.4]
at com.fasterxml.jackson.databind.DeserializationContext.reportBadDefinition(DeserializationContext.java:1615) ~[jackson-databind-2.11.4.jar:2.11.4]
at com.fasterxml.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:400) ~[jackson-databind-2.11.4.jar:2.11.4]
回滚时,出现回滚日志无法解析到实体类,此时需要将mysql表中需要回滚的业务表的datetime数据类型,修改为timestamp类型。原因是seata 1.4.2 在MySQL8.0上对datetime类型的转换存在bug。
参考代码下载地址