版权声明
一、示例
1、导入示例
详细看最后一节【示例代码】
2、数据表
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for account_tbl
-- ----------------------------
DROP TABLE IF EXISTS `account_tbl`;
CREATE TABLE `account_tbl` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`money` int(11) UNSIGNED NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Records of account_tbl
-- ----------------------------
INSERT INTO `account_tbl` VALUES (1, 'user202003032042012', 1000);
-- ----------------------------
-- Table structure for order_tbl
-- ----------------------------
DROP TABLE IF EXISTS `order_tbl`;
CREATE TABLE `order_tbl` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`commodity_code` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`count` int(11) NULL DEFAULT 0,
`money` int(11) NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Table structure for storage_tbl
-- ----------------------------
DROP TABLE IF EXISTS `storage_tbl`;
CREATE TABLE `storage_tbl` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`commodity_code` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`count` int(11) UNSIGNED NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `commodity_code`(`commodity_code`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Records of storage_tbl
-- ----------------------------
INSERT INTO `storage_tbl` VALUES (1, '100202003032041', 10);
-- ----------------------------
-- Table structure for undo_log
-- ----------------------------
DROP TABLE IF EXISTS `undo_log`;
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`context` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime(0) NOT NULL,
`log_modified` datetime(0) NOT NULL,
`ext` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `ux_undo_log`(`xid`, `branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
SET FOREIGN_KEY_CHECKS = 1;
账户表account_tbl
记录用户的账户余额
库存表storage_tbl
记录商品编号对应的库存
count
int(11) UNSIGNED NULL DEFAULT 0
注意UNSIGNED表示字段不能为0
MySQL处理两个整数(INT)相减的时候,如果其中有一个是UNSIGNED INT类型的,那么结果就被当做是UNSIGNED的。
如果相减的结果是负数:
在MySQL 5.5.5之前,结果变成了最大的整数(18446744073709551615)
从MySQL 5.5.5开始,这种情况会返回一个错误:BIGINT UNSIGNED value is out of range…
3、postman测试
二、代码解读
1、order-service
@RestController
@RequestMapping("order")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<Long> createOrder(Order order){
Long orderId = orderService.create(order);
return ResponseEntity.status(HttpStatus.CREATED).body(orderId);
}
}
创建订单之后,先扣减账户余额,接着扣减库存
Slf4j
@Service
public class OrderServiceImpl implements OrderService {
private final AccountClient accountClient;
private final StorageClient storageClient;
private final OrderMapper orderMapper;
public OrderServiceImpl(AccountClient accountClient, StorageClient storageClient, OrderMapper orderMapper) {
this.accountClient = accountClient;
this.storageClient = storageClient;
this.orderMapper = orderMapper;
}
@Override
@Transactional
public Long create(Order order) {
// 创建订单
orderMapper.insert(order);
try {
// 扣用户余额
accountClient.deduct(order.getUserId(), order.getMoney());
// 扣库存
storageClient.deduct(order.getCommodityCode(), order.getCount());
} catch (FeignException e) {
log.error("下单失败,原因:{}", e.contentUTF8(), e);
throw new RuntimeException(e.contentUTF8(), e);
}
return order.getId();
}
}
2、AccountClient
@RestController
@RequestMapping("account")
public class AccountController {
@Autowired
private AccountService accountService;
@PutMapping("/{userId}/{money}")
public ResponseEntity<Void> deduct(@PathVariable("userId") String userId, @PathVariable("money") Integer money){
accountService.deduct(userId, money);
return ResponseEntity.noContent().build();
}
}
@FeignClient("account-service")
public interface AccountClient {
@PutMapping("/account/{userId}/{money}")
void deduct(@PathVariable("userId") String userId, @PathVariable("money") Integer money);
}
3、account-service
@Slf4j
@Service
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountMapper accountMapper;
@Override
@Transactional
public void deduct(String userId, int money) {
log.info("开始扣款");
try {
accountMapper.deduct(userId, money);
} catch (Exception e) {
throw new RuntimeException("扣款失败,可能是余额不足!", e);
}
log.info("扣款成功");
}
}
4、storage-service
@RestController
@RequestMapping("storage")
public class StorageController {
private final StorageService storageService;
public StorageController(StorageService storageService) {
this.storageService = storageService;
}
/**
* 扣减库存
* @param code 商品编号
* @param count 要扣减的数量
* @return 无
*/
@PutMapping("/{code}/{count}")
public ResponseEntity<Void> deduct(@PathVariable("code") String code,@PathVariable("count") Integer count){
storageService.deduct(code, count);
return ResponseEntity.noContent().build();
}
}
@Slf4j
@Service
public class StorageServiceImpl implements StorageService {
@Autowired
private StorageMapper storageMapper;
@Transactional
@Override
public void deduct(String commodityCode, int count) {
log.info("开始扣减库存");
try {
storageMapper.deduct(commodityCode, count);
} catch (Exception e) {
throw new RuntimeException("扣减库存失败,可能是库存不足!", e);
}
log.info("扣减库存成功");
}
}
三、集成seata
注意mysql的版本不要太高,最好8.0.11否则报错java.lang.NoSuchMethodException: com.mysql.cj.conf.PropertySet.getBooleanReadableProperty(java.lang.String)
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.11</version>
</dependency>
1、引入依赖
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<exclusions>
<!--版本较低,1.3.0,因此排除-->
<exclusion>
<artifactId>seata-spring-boot-starter</artifactId>
<groupId>io.seata</groupId>
</exclusion>
</exclusions>
</dependency>
<!--seata starter 采用1.4.2版本-->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>${seata.version}</version>
</dependency>
注意:如果cloud和阿里巴巴的版本是<spring-cloud.version>2021.0.1</spring-cloud.version>
<spring-cloud-alibaba.version>2021.0.1.0</spring-cloud-alibaba.version>
则只需要引入
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
2、修改配置文件
需要修改application.yml文件,添加一些配置:
GZ:表示广州
seata:
registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
# 参考tc服务自己的registry.conf中的配置
type: nacos
nacos: # tc
server-addr: 127.0.0.1:8848
namespace: ""
group: DEFAULT_GROUP
application: seata-server # tc服务在nacos中的服务名称
cluster: GZ
tx-service-group: seata-demo # 事务组,根据这个获取tc服务的cluster名称
service:
vgroup-mapping: # 事务组与TC服务cluster的映射关系
seata-demo: GZ
由于阿里巴巴seata存在循环依赖,在application.yml 中使用以下配置,好像是SpringBoot2.6.0之后默认禁止的。
spring:
main:
allow-circular-references: true #允许循环引用
3、启动
在服务控制台看到
05-17 17:25:13:346 INFO 16489 --- [eoutChecker_1_1] i.s.c.rpc.netty.TmNettyRemotingClient : register TM success. client version:1.4.2, server version:1.4.2,channel:[id: 0xf95abb53, L:/192.168.0.55:63134 - R:/192.168.0.55:8091]
之后在seata控制台看到如下说明成功
四、实现XA模式
Seata的starter已经完成了XA模式的自动装配,实现非常简单,步骤如下:
1、修改application.yml文件(每个参与事务的微服务),开启XA模式:
seata:
data-source-proxy-mode: XA # 开启数据源代理的XA模式
2、给发起全局事务的入口方法添加@GlobalTransactional注解,
本例中是OrderServiceImpl中的create方法:
@Override@GlobalTransactional
public Long create(Order order) {
// 创建订单
orderMapper.insert(order);
// 扣余额 ...略
// 扣减库存 ...略
return order.getId();
}
3、重启服务并测试
如果扣减库存超过10则回顾
五、实现AT模式
AT模式是seata的默认模式。
AT模式的快照生成,回滚等动作都是有框架自动完成,没有任何代码侵入,因此实现非常简单。
1、导入数据库表
lock_table导入到TC服务关联的数据库:seata
create table lock_table
(
row_key varchar(128) not null
primary key,
xid varchar(96) null,
transaction_id bigint null,
branch_id bigint not null,
resource_id varchar(256) null,
table_name varchar(32) null,
pk varchar(36) null,
gmt_create datetime null,
gmt_modified datetime null
)charset=utf8;
create index idx_branch_id on lock_table (branch_id);
undo_log导入到微服务关联的业务数据库:seata_demo,在前面已经导入。
2、修改application.yml文件(每个参与事务的微服务),开启AT模式:
seata:
data-source-proxy-mode: AT # 开启数据源代理的AT模式
3、重启服务测试
account-service日志如下:
如果断点的话,可以看到undo_log数据,执行完之后会被清空
六、实现TCC模式
不需要生成快照,不需要全局锁,不依赖数据库事务,而是通过Try、Confirm和Cancel接口实现冻结
示例需求如下:
- 修改account-service,编写try、confirm、cancel逻辑
- try业务:添加冻结金额,扣减可用金额
- confirm业务:三处冻结金额
- cancel业务:删除冻结金额,回复可用金额
- 保证confirm、cancel接口的幂等性
- 允许空回滚
- 拒绝业务悬挂
1、流程
2、新增数据表
DROP TABLE IF EXISTS `account_freeze_tbl`;
CREATE TABLE `account_freeze_tbl` (
`xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '事务id',
`user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '用户id',
`freeze_money` int(11) UNSIGNED NULL DEFAULT 0 COMMENT '冻结金额',
`state` int(1) NULL DEFAULT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
PRIMARY KEY (`xid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 ROW_FORMAT = COMPACT;
3、声明 TCC接口
4、申明AccountTCCService接口
@LocalTCC
public interface AccountTCCService {
/**
* deduct try的接口
*
* @param userId the account
* @param money the money
* @return the boolean
*/
@TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money") int money);
/**
* confirm boolean. 提交事务的接口
*
* @param actionContext the action context
* @return the boolean
*/
boolean confirm(BusinessActionContext actionContext);
/**
* cancel boolean. 回滚接口
*
* @param actionContext the action context
* @return the boolean
*/
boolean cancel(BusinessActionContext actionContext);
}
5、实现接口
@Service
@Slf4j
public class AccountTCCServiceImpl implements AccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper freezeMapper;
@Override
@Transactional
public void deduct(String userId, int money) {
// 0.获取事务id
String xid = RootContext.getXID();
//1.判断freeze中是否有冻结数据,有回滚记录则,会造成业务悬挂
AccountFreeze oldFreeze = freezeMapper.selectById(xid);
if (oldFreeze!=null){
return;
}
// 1.扣减可用余额
accountMapper.deduct(userId, money);
// 2.记录冻结金额,事务状态
AccountFreeze freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setFreezeMoney(money);
freeze.setState(AccountFreeze.State.TRY);
freeze.setXid(xid);
freezeMapper.insert(freeze);
}
@Override
public boolean confirm(BusinessActionContext ctx) {
// 1.获取事务id
String xid = ctx.getXid();
// 2.根据id删除冻结记录,删除是天然幂等性
int count = freezeMapper.deleteById(xid);
return count == 1;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
// 0.查询冻结记录c
String xid = ctx.getXid();
AccountFreeze freeze = freezeMapper.selectById(xid);
//1.判断空回滚,根据freeze是否为null
String userId = ctx.getActionContext("userId").toString();
if (freeze==null){
//try没执行,需要回滚,并记录回滚
freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
freeze.setXid(xid);
freezeMapper.insert(freeze);
return true;
}
//2.幂等性判断,防止重复操作
if (freeze.getState()==AccountFreeze.State.CANCEL){
//说明已经回滚过了
return true;
}
// 1.恢复可用余额
accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
// 2.将冻结金额清零,状态改为CANCEL
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
int count = freezeMapper.updateById(freeze);
return count == 1;
}
}
6、效果
当库存申请10时,扣减失败,account-service日志如下
七、示例代码
1、代码仓库
- base分支:未集成Seata
- XA分支:实现XA模式
- AT分支:实现AT模式
- TCC分支:实现TCC模式