分布式事务的问题:
事务的ACID原则:
验证分布式事务的ACID特性:
订单服务:请求创建订单
controller:
@PostMapping public ResponseEntity<Long> createOrder(Order order){ Long orderId = orderService.create(order); return ResponseEntity.status(HttpStatus.CREATED).body(orderId); }
service:
@GlobalTransactional //全局事务 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(); }
这里使用feign的客户端调用实现用户余额和库存的扣减
@FeignClient("account-service") public interface AccountClient { @PutMapping("/account/{userId}/{money}") void deduct(@PathVariable("userId") String userId, @PathVariable("money") Integer money); }
@FeignClient("storage-service") public interface StorageClient { @PutMapping("/storage/{code}/{count}") void deduct(@PathVariable("code") String code, @PathVariable("count") Integer count); }
账户服务:account-service:
controller:
/** * 扣减账户余额 * @param userId * @param money * @return */
@PutMapping("/{userId}/{money}") public ResponseEntity<Void> deduct(@PathVariable("userId") String userId, @PathVariable("money") Integer money){ accountService.deduct(userId, money); return ResponseEntity.noContent().build(); }
库存服务:storage-service
controller:
/** * 扣减库存 * @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(); }
数据库数据:
account表(账户表)
order表
storage表:
向订单服务发起以下请求
查看数据库数据:
storage:
account:
order:
可以发现在库存不足的情况下扣减账户余额成功,新增订单成功,扣减库存失败,违反了事物的ACID特性。
分布式服务的事务问题:
对于单体服务只需要一个@Trancational就可以解决,但是对于分布式事务,跨服务跨数据库的业务无法解决。
解决分布式事物的方法:
理论基础:
CAP定理:
当网络出现故障时,一定会出现分区问题(P),当出现分区时,系统的一致性和可用性不能同时满足。CP和AP原则。
elasticsearch是CP原则,当es集群出现分区时,故障节点会贝剔除集群,数据分片会重新分配到其他结点,保证数据一致,因此是CP。
BASE理论:
初始seata:分布式事务解决方案
seata部署:
下载seata-server:
修改配置:
registry.conf:
registry {//配置注册中心
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "nacos"
nacos {
application = "seata-tc-server"
serverAddr = "localhost:8845"
group = "DEFAULT_GROUP"
namespace = ""
cluster = "SH"
username = "nacos"
password = "nacos"
}
}
config { //配置中心,将配置文件交给nacos管理
# file、nacos 、apollo、zk、consul、etcd3
type = "nacos"
nacos {
serverAddr = "localhost:8845"
namespace = ""
group = "SEATA_GROUP"
username = "nacos"
password = "nacos"
dataId = "seataServer.properties" //seata配置文件
}
}
seata.properties文件:
# 数据存储方式,db代表数据库
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://localhost:3306/seta?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
store.db.user=root
store.db.password=123456
store.db.minConn=5
store.db.maxConn=30
store.db.globalTable=global_table
store.db.branchTable=branch_table
store.db.queryLimit=100
store.db.lockTable=lock_table
store.db.maxWait=5000
# 事务、日志等配置
server.recovery.committingRetryPeriod=1000
server.recovery.asynCommittingRetryPeriod=1000
server.recovery.rollbackingRetryPeriod=1000
server.recovery.timeoutRetryPeriod=1000
server.maxCommitRetryTimeout=-1
server.maxRollbackRetryTimeout=-1
server.rollbackRetryTimeoutUnlockEnable=false
server.undo.logSaveDays=7
server.undo.logDeletePeriod=86400000
# 客户端与服务端传输方式
transport.serialization=seata
transport.compressor=none
# 关闭metrics功能,提高性能
metrics.enabled=false
metrics.registryType=compact
metrics.exporterList=prometheus
metrics.exporterPrometheusPort=9898
创建全局事务表和分支事务表:
tc服务在管理分布式事物的时候,需要记录事务相关数据到数据库中。
主要记录全局事务、分支事务、全局锁信息:
启动TC服务:
微服务集成seata:
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>
2.配置application.yml,让微服务通过注册中心找到seata-tc-server:
seata:
registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
# 参考tc服务自己的registry.conf中的配置
type: nacos
nacos: # tc
server-addr: 127.0.0.1:8845
namespace: ""
group: DEFAULT_GROUP
application: seata-tc-server # tc服务在nacos中的服务名称
cluster: SH
tx-service-group: seata-demo # 事务组,根据这个获取tc服务的cluster名称
service:
vgroup-mapping: # 事务组与TC服务cluster的映射关系
seata-demo: SH
seata的动手实践:
XA模式原理:
XA模式第一阶段如果全部成功,则第二阶段进行提交,如果有一个事务失败,则将成功的事务进行回滚。XA模式基于数据库本身的特性,是强一致的特性。
seata的XA实现:
优点:具备强一致的特性,满足ACID特性,常用的数据库都支持,实现简单,并且没有代码侵入
缺点:因为第一阶段需要锁定数据库资源,等待第二阶段结束才能释放,性能较差,依赖于关系型数据库实现事务。
实现:
1.修改application.yml文件,开启XA模式
data-source-proxy-mode: XA #全局事务一致性策略
2.给发起全局事物的入口方法上添加@GlobalTransactional注解,本例中是OrderServiceImpl中的Create方法(创建订单的方法)。
@GlobalTransactional //全局事务
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();
}
3.启动服务。
4.发起以下请求。
从上图可以看出事务回滚了,并且数据库的数据并没有发生变化,实现了一致性。
AT模式:
XA模式和AT模式的区别:
XA模式第一阶段不提交,锁定资源,AT模式第一阶段直接提交,不锁定资源。
XA模式依赖数据库机制实现回滚,AT模式利用数据快照实现数据的回滚。
XA模式是强一直,AT模式是最终一致。
AT模式实现:
1、创建lock_table(记录全局锁的信息),undo_log(记录快照信息)。气中lock_table放到TC服务关联的数据库中,undo_log放到微服务关联的数据库。
2.修改applicaion.yml文件,将事务模式改为AT模式
data-source-proxy-mode: AT #全局事务一致性策略
3.重启服务并测试
再次发起以下请求:
从上图可以看出账户回滚。
TCC模式:
TCC模式与AT模式相似,每阶段都是独立的事务,不同的是TCC模式通过人工编码实现数据的恢复。
Try:资源预留和检测
confirm:完成资源操作。要求try成功confirm一定成功。
cancel:预留资源释放,try的反向操作。
代码实现:
1.创建冻结金额表account_freeze_tabl
2.创建account_freeze实体类
@Data
@TableName("account_freeze_tbl")
public class AccountFreeze {
@TableId(type = IdType.INPUT)
private String xid;
private String userId;
private Integer freezeMoney;
private Integer state;
public static abstract class State {
public final static int TRY = 0;
public final static int CONFIRM = 1;
public final static int CANCEL = 2;
}
}
3.创建accountTccService
@LocalTCC
public interface AccountTccService {
/**
* try逻辑,@TwoPhaseBusinessAction中的name属性要与当前方法名一致,用于指定try逻辑对应的方法
* @param
* @BusinessActionContextParameter标记的属性都会放到上下文对象中confirm和cancel方法的context多可以获取到
*/
@TwoPhaseBusinessAction(name = "deduct",commitMethod = "confirm",rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId")String userId,@BusinessActionContextParameter(paramName = "money") int money);
/**
* 二阶段提交confirm确认方法,方法名与commitMethod的属性值一致。
* @param context
* @return
*/
boolean confirm(BusinessActionContext context);
/**
* 二阶段回滚方法,要保证与rollbackMethod的属性值一致
* @param context
* @return
*/
boolean cancel(BusinessActionContext context);
}
AccountServiceImpl:
@Service
@Slf4j
public class AccountTccServiceImpl implements AccountTccService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper accountFreezeMapper;
@Override
public void deduct(String userId,int money) {
//获取事务id
String xid = RootContext.getXID();
//判断freeze是否有冻结记录,如果有则一定是cancel执行过,拒绝业务
AccountFreeze accountFreeze1 = accountFreezeMapper.selectById(xid);
if (accountFreeze1!=null){
//已经执行过cancel操作了,避免业务悬挂
return;
}
//扣减可用余额
accountMapper.deduct(userId,money);
//记录冻结金额,事务状态
AccountFreeze accountFreeze=new AccountFreeze();
accountFreeze.setFreezeMoney(money);
accountFreeze.setXid(xid); //事务id
accountFreeze.setState(AccountFreeze.State.TRY);
accountFreeze.setUserId(userId);
//插入数据
accountFreezeMapper.insert(accountFreeze);
}
@Override
public boolean confirm(BusinessActionContext context) {
//获取事务id,根据id删除冻结事务记录
String xid = context.getXid();
int i = accountFreezeMapper.deleteById(xid);
return i==1;
}
@Override
public boolean cancel(BusinessActionContext context) {
//查询冻结记录
String xid = context.getXid();
String userId1 = context.getActionContext("userId").toString();
AccountFreeze accountFreeze = accountFreezeMapper.selectById(xid);
//空回滚判断,判断freeze是否为null
if (accountFreeze==null){
//空回滚,记录回滚记录
accountFreeze=new AccountFreeze();
accountFreeze.setState(AccountFreeze.State.CANCEL);
accountFreeze.setUserId(userId1);
accountFreeze.setXid(xid);
accountFreeze.setFreezeMoney(0);
accountFreezeMapper.insert(accountFreeze);
return true;
}
//判断幂等
if (accountFreeze.getState()==AccountFreeze.State.CANCEL){
//已经处理过一次了cancel,无需重复处理
return true;
}
//恢复可用余额,冻结金额的获取可以从context中和freeze中获取
String userId = accountFreeze.getUserId();
Integer money = accountFreeze.getFreezeMoney();
//恢复金额
accountMapper.refund(userId, money);
//将冻结金额清零,将状态改为cancel
accountFreeze.setFreezeMoney(0);
accountFreeze.setState(AccountFreeze.State.CANCEL);
int i = accountFreezeMapper.updateById(accountFreeze);
return i==1;
}
}
4.启动测试:发送请求购买11个商品。
account_freeze_tbl:冻结金额表插入了一条新的数据
Saga模式:
四种方式对比: