Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案
Seata 官网:https://seata.io/zh-cn/
Spring Cloud Alibaba 官网:https://sca.aliyun.com/zh-cn/
版本说明
SpringBoot 版本 2.6.5
SpringCloud 版本 2021.0.1
SpringCloudAlibaba 版本 2021.0.1.0
本文详细说明
数据库服务器版本 mysql 8.0.25
mybatis plus 版本 3.5.1
nacos 版本 1.4.2
seata 客户端版本 1.4.2
seata 服务端版本 1.7.1
本文讲解的是 seata 的 TCC 事物模型,在开始阅读下面内容之前,建议先阅读笔者的这篇文章《Spring Cloud Alibaba Seata 实现分布式事物》,这篇文章中实现的是 seata 的 AT 事物,且笔者的本篇文章《Spring Cloud Alibaba Seata 实现 TCC 事物》是在《Spring Cloud Alibaba Seata 实现分布式事物》基础上写的,很多内容需要先了解,涉及seata 和nacos的重复内容,笔者在本篇文章中不在赘述,因此建议读者先看《Spring Cloud Alibaba Seata 实现分布式事物》,之后再学习本篇文章。当然,如果你对 seata 的搭建已经非常熟悉,那么可以直接开始下面阅读
目录
1、创建项目
1.1、新建 maven 聚合项目 cloud-learn
1.2、创建 account 服务
1.3、创建 order 服务
2、添加配置
2.1、客户端配置
2.2、服务端配置
3、数据库建表
3.1、seata 服务端建表
3.2、seata 客户端建表
4、运行测试
5、项目代码
最外层父工程 cloud-learn 的 pom.xml
4.0.0
com.wsjzzcbq
cloud-learn
1.0-SNAPSHOT
gateway-learn
consumer-learn
sentinel-learn
seata-at-account-learn
seata-at-order-learn
seata-tcc-order-learn
seata-tcc-account-learn
pom
naxus-aliyun
naxus-aliyun
https://maven.aliyun.com/repository/public
true
false
org.springframework.boot
spring-boot-starter-parent
2.6.5
2021.0.1
2021.0.1.0
2021.1
2021.1
3.1.1
1.1.17
8.0.11
3.5.1
org.springframework.cloud
spring-cloud-dependencies
${spring-cloud.version}
pom
import
com.alibaba.cloud
spring-cloud-alibaba-dependencies
${spring-cloud-alibaba.version}
pom
import
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-discovery
${alibaba-nacos-discovery.veriosn}
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-config
${alibaba-nacos-config.version}
org.springframework.cloud
spring-cloud-starter-bootstrap
${spring-cloud-starter-bootstrap.version}
com.alibaba.fastjson2
fastjson2
2.0.40
org.projectlombok
lombok
下面会创建2个服务 account 和 order,模拟用户下订单后扣减账户金额,服务间使用 feign 调用,因为 account 和 order 服务使用不同的数据库,因此产生分布式事物,使用 seata 解决
创建子工程 seata-tcc-account-learn
seata-tcc-account-learn pom 文件
cloud-learn
com.wsjzzcbq
1.0-SNAPSHOT
4.0.0
seata-tcc-account-learn
org.springframework.boot
spring-boot-starter-web
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
com.alibaba
druid-spring-boot-starter
${druid.version}
mysql
mysql-connector-java
${mysql.version}
com.baomidou
mybatis-plus-boot-starter
${mybatis-plus.version}
org.springframework.boot
spring-boot-maven-plugin
启动类 SeataTCCAccountApplication
package com.wsjzzcbq;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* SeataTCCAccountApplication
*
* @author wsjz
* @date 2023/10/20
*/
@MapperScan(value = {"com.wsjzzcbq.mapper"})
@SpringBootApplication
public class SeataTCCAccountApplication {
public static void main(String[] args) {
SpringApplication.run(SeataTCCAccountApplication.class, args);
}
}
实体类 Account
package com.wsjzzcbq.bean;
import lombok.Data;
/**
* Account
*
* @author wsjz
* @date 2022/07/07
*/
@Data
public class Account {
private Integer id;
private String userId;
private Integer money;
}
AccountMapper
package com.wsjzzcbq.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wsjzzcbq.bean.Account;
/**
* AccountMapper
*
* @author wsjz
* @date 2023/10/13
*/
public interface AccountMapper extends BaseMapper {
}
AccountReduceAction TCC 方法声明
package com.wsjzzcbq.action;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
/**
* AccountReduceAction
*
* @author wsjz
* @date 2023/10/16
*/
@LocalTCC
public interface AccountReduceAction {
@TwoPhaseBusinessAction(name = "account-reduce", commitMethod = "commit", rollbackMethod = "cancel")
boolean reduce(@BusinessActionContextParameter(paramName = "userId") String userId, @BusinessActionContextParameter(paramName = "money") int money);
boolean commit(BusinessActionContext businessActionContext);
boolean cancel(BusinessActionContext businessActionContext);
}
这里是TCC事物,需要定义接口,加上@LocalTCC 注解标识,@TwoPhaseBusinessAction 注解声明commit 和 rollback 对应的方法,TCC 中 commit 和rollback 可阶段需要的参数可通过 @BusinessActionContextParameter 注解传递,接收时通过 BusinessActionContext 获取
AccountReduceActionImpl TCC的实际逻辑
package com.wsjzzcbq.action.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.wsjzzcbq.action.AccountReduceAction;
import com.wsjzzcbq.bean.Account;
import com.wsjzzcbq.service.AccountService;
import io.seata.rm.tcc.api.BusinessActionContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* AccountReduceActionImpl
*
* @author wsjz
* @date 2023/10/16
*/
@Slf4j
@Component
public class AccountReduceActionImpl implements AccountReduceAction {
@Autowired
private AccountService accountService;
@Override
public boolean reduce(String userId, int money) {
UpdateWrapper up = new UpdateWrapper<>();
String sql = "money = money - " + money;
up.setSql(sql);
up.eq("user_id", userId);
accountService.update(up);
return true;
}
@Override
public boolean commit(BusinessActionContext businessActionContext) {
String userId = String.valueOf(businessActionContext.getActionContext("userId"));
System.out.println(userId);
log.info("提交成功");
return true;
}
@Override
public boolean cancel(BusinessActionContext businessActionContext) {
String userId = String.valueOf(businessActionContext.getActionContext("userId"));
int money = (int) businessActionContext.getActionContext("money");
LambdaQueryWrapper lqw = new LambdaQueryWrapper<>();
lqw.eq(Account::getUserId, userId);
Account account = accountService.getOne(lqw);
UpdateWrapper uw = new UpdateWrapper<>();
uw.set("money", account.getMoney() + money);
uw.eq("user_id", userId);
accountService.update(uw);
log.info("回滚成功");
return true;
}
}
AccountService
package com.wsjzzcbq.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.wsjzzcbq.bean.Account;
/**
* AccountService
*
* @author wsjz
* @date 2023/10/13
*/
public interface AccountService extends IService {
String reduce(String userId, int money);
}
AccountServiceImpl
package com.wsjzzcbq.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.wsjzzcbq.action.AccountReduceAction;
import com.wsjzzcbq.bean.Account;
import com.wsjzzcbq.mapper.AccountMapper;
import com.wsjzzcbq.service.AccountService;
import io.seata.core.context.RootContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* AccountServiceImpl
*
* @author wsjz
* @date 2023/10/13
*/
@Service
public class AccountServiceImpl extends ServiceImpl implements AccountService {
@Autowired
private AccountReduceAction accountReduceAction;
@Override
public String reduce(String userId, int money) {
String xid = RootContext.getXID();
System.out.println(xid);
accountReduceAction.reduce(userId, money);
return "ok";
}
}
AccountController
package com.wsjzzcbq.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wsjzzcbq.bean.Account;
import com.wsjzzcbq.service.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* AccountController
*
* @author wsjz
* @date 2023/10/13
*/
@RequestMapping("/account")
@RestController
public class AccountController {
@Autowired
private AccountService accountService;
@GetMapping("/find")
public String find() throws JsonProcessingException {
Account account = accountService.list().get(0);
ObjectMapper objectMapper = new ObjectMapper();
String res = objectMapper.writeValueAsString(account);
System.out.println(res);
return res;
}
@RequestMapping("/reduce")
public String debit(String userId, int money) {
try {
accountService.reduce(userId, money);
return "扣款成功";
} catch (Exception e) {
return "扣款失败";
}
}
}
application.yml 文件
server:
port: 9001
spring:
main:
allow-circular-references: true
application:
name: seata-tcc-account-learn
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.3.232:3306/pmc-account?useUnicode=true&characterEncoding=utf8&autoReconnect=true&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
cloud:
nacos:
username: nacos
password: nacos
server-addr: 192.168.2.140
discovery:
namespace: public
# server-addr: 192.168.2.140
# config:
# server-addr:
seata:
config:
type: nacos
nacos:
server-addr: ${spring.cloud.nacos.server-addr}
username: ${spring.cloud.nacos.username}
password: ${spring.cloud.nacos.password}
group: SEATA_GROUP
data-id: seata-tcc.properties
registry:
type: nacos
nacos:
application: seata-server
cluster: default
server-addr: ${spring.cloud.nacos.server-addr}
username: ${spring.cloud.nacos.username}
password: ${spring.cloud.nacos.password}
group: SEATA_GROUP
# 事物分组,如果不配置默认是spring.application.name + '-seata-service-group'
# tx-service-group:
logging:
level:
com.wsjzzcbq.mapper: debug
配置参数说明可以看《Spring Cloud Alibaba Seata 实现分布式事物》,这里不再赘述
创建子工程 seata-tcc-order-learn 项目
seata-tcc-order-learn pom 文件
cloud-learn
com.wsjzzcbq
1.0-SNAPSHOT
4.0.0
seata-tcc-order-learn
org.springframework.boot
spring-boot-starter-web
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-discovery
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-config
org.springframework.cloud
spring-cloud-starter-openfeign
org.springframework.cloud
spring-cloud-starter-loadbalancer
com.alibaba.cloud
spring-cloud-starter-alibaba-seata
com.alibaba
druid-spring-boot-starter
${druid.version}
mysql
mysql-connector-java
${mysql.version}
com.baomidou
mybatis-plus-boot-starter
${mybatis-plus.version}
org.springframework.boot
spring-boot-maven-plugin
启动类 SeataTCCOrderApplication
package com.wsjzzcbq;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* SeataTCCOrderApplication
*
* @author wsjz
* @date 2023/10/20
*/
@MapperScan(value = {"com.wsjzzcbq.mapper"})
@EnableFeignClients
@SpringBootApplication
public class SeataTCCOrderApplication {
public static void main(String[] args) {
SpringApplication.run(SeataTCCOrderApplication.class, args);
}
}
订单实体类 Order
package com.wsjzzcbq.bean;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* Order
*
* @author wsjz
* @date 2022/07/07
*/
@TableName("order_tbl")
@Data
public class Order {
@TableId
private Integer id;
private String userId;
private String code;
private Integer count;
private Integer money;
}
OrderMapper
package com.wsjzzcbq.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wsjzzcbq.bean.Order;
/**
* OrderMapper
*
* @author wsjz
* @date 2022/07/07
*/
public interface OrderMapper extends BaseMapper {
}
AccountFeign
package com.wsjzzcbq.feign;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
/**
* AccountFeign
*
* @author wsjz
* @date 2023/10/13
*/
@FeignClient(value = "seata-tcc-account-learn")
public interface AccountFeign {
@RequestMapping("/account/reduce")
String debit(@RequestParam("userId") String userId, @RequestParam("money") int money);
}
OrderServiceCreateAction TCC 方法定义
package com.wsjzzcbq.action;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
/**
* OrderServiceCreateAction
*
* @author wsjz
* @date 2023/10/16
*/
@LocalTCC
public interface OrderServiceCreateAction {
@TwoPhaseBusinessAction(name = "order-create", commitMethod = "commit", rollbackMethod = "cancel")
void create(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money") int money,
@BusinessActionContextParameter(paramName = "orderCode") String orderCode,
@BusinessActionContextParameter(paramName = "rollback") boolean rollback);
boolean commit(BusinessActionContext businessActionContext);
boolean cancel(BusinessActionContext businessActionContext);
}
OrderServiceCreateActionImpl TCC实际方法
package com.wsjzzcbq.action.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wsjzzcbq.action.OrderServiceCreateAction;
import com.wsjzzcbq.bean.Order;
import com.wsjzzcbq.service.OrderService;
import io.seata.rm.tcc.api.BusinessActionContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* OrderServiceCreateActionImpl
*
* @author wsjz
* @date 2023/10/16
*/
@Slf4j
@Component
public class OrderServiceCreateActionImpl implements OrderServiceCreateAction {
@Autowired
private OrderService orderService;
@Override
public void create(String userId, int money, String orderCode, boolean rollback) {
Order order = new Order();
order.setCode(orderCode);
order.setCount(1);
order.setUserId(userId);
order.setMoney(money);
orderService.save(order);
}
@Override
public boolean commit(BusinessActionContext businessActionContext) {
System.out.println("money");
System.out.println(businessActionContext.getActionContext("money"));
System.out.println(businessActionContext.getActionContext("orderCode"));
log.info("提交成功");
return true;
}
@Override
public boolean cancel(BusinessActionContext businessActionContext) {
String orderCode = String.valueOf(businessActionContext.getActionContext("orderCode"));
QueryWrapper queryWrapper = new QueryWrapper<>();
queryWrapper.eq("code", orderCode);
orderService.remove(queryWrapper);
log.info("回滚成功");
return true;
}
}
OrderService
package com.wsjzzcbq.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.wsjzzcbq.bean.Order;
/**
* OrderService
*
* @author wsjz
* @date 2022/07/07
*/
public interface OrderService extends IService {
void create(String userId, int money, boolean rollback);
}
OrderServiceImpl
package com.wsjzzcbq.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.wsjzzcbq.action.OrderServiceCreateAction;
import com.wsjzzcbq.bean.Order;
import com.wsjzzcbq.feign.AccountFeign;
import com.wsjzzcbq.mapper.OrderMapper;
import com.wsjzzcbq.service.OrderService;
import io.seata.core.context.RootContext;
import io.seata.spring.annotation.GlobalTransactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.UUID;
/**
* OrderServiceImpl
*
* @author wsjz
* @date 2022/07/07
*/
@Service
public class OrderServiceImpl extends ServiceImpl implements OrderService {
@Autowired
private AccountFeign accountFeign;
@Autowired
private OrderServiceCreateAction orderServiceCreateAction;
@GlobalTransactional
@Override
public void create(String userId, int money, boolean rollback) {
String xid = RootContext.getXID();
System.out.println(xid);
String orderCode = UUID.randomUUID().toString();
orderServiceCreateAction.create(userId, money, orderCode, rollback);
accountFeign.debit(userId, money);
if (rollback) {
int a = 1/0;
}
}
}
OrderController
package com.wsjzzcbq.controller;
import com.wsjzzcbq.service.OrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* OrderController
*
* @author wsjz
* @date 2022/07/09
*/
@RequestMapping("/order")
@RestController
public class OrderController {
@Autowired
private OrderService orderService;
/**
* http://localhost:9002/order/create?userId=101&money=10&rollback=false
* @param userId
* @param money
* @param rollback
* @return
*/
@RequestMapping("/create")
public String create(String userId, int money, boolean rollback) {
try {
orderService.create(userId, money, rollback);
return "下单成功";
} catch (Exception e) {
e.printStackTrace();
return "下单失败";
}
}
}
application.yml 文件
server:
port: 9002
spring:
main:
allow-circular-references: true
application:
name: seata-tcc-order-learn
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.3.232:3306/pmc-order?useUnicode=true&characterEncoding=utf8&autoReconnect=true&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
cloud:
nacos:
username: nacos
password: nacos
server-addr: 192.168.2.140
discovery:
namespace: public
# server-addr: 192.168.2.140
# config:
# server-addr:
seata:
config:
type: nacos
nacos:
server-addr: ${spring.cloud.nacos.server-addr}
username: ${spring.cloud.nacos.username}
password: ${spring.cloud.nacos.password}
group: SEATA_GROUP
data-id: seata-tcc.properties
registry:
type: nacos
nacos:
application: seata-server
cluster: default
server-addr: ${spring.cloud.nacos.server-addr}
username: ${spring.cloud.nacos.username}
password: ${spring.cloud.nacos.password}
group: SEATA_GROUP
# 事物分组,如果不配置默认是spring.application.name + '-seata-service-group'
tx-service-group: seata-tcc-account-learn-seata-service-group
logging:
level:
com.wsjzzcbq.mapper: debug
mybatis-plus:
global-config:
db-config:
id-type: auto
在nacos上新建 group 是 SEATA_GROUP,data-id 是 seata-tcc.properties 的配置,内容如下
seata-tcc.properties
#For details about configuration items, see https://seata.io/zh-cn/docs/user/configurations.html
#Transport configuration, for client and server
transport.type=TCP
transport.server=NIO
transport.heartbeat=true
transport.enableTmClientBatchSendRequest=false
transport.enableRmClientBatchSendRequest=true
transport.enableTcServerBatchSendResponse=false
transport.rpcRmRequestTimeout=30000
transport.rpcTmRequestTimeout=30000
transport.rpcTcRequestTimeout=30000
transport.threadFactory.bossThreadPrefix=NettyBoss
transport.threadFactory.workerThreadPrefix=NettyServerNIOWorker
transport.threadFactory.serverExecutorThreadPrefix=NettyServerBizHandler
transport.threadFactory.shareBossWorker=false
transport.threadFactory.clientSelectorThreadPrefix=NettyClientSelector
transport.threadFactory.clientSelectorThreadSize=1
transport.threadFactory.clientWorkerThreadPrefix=NettyClientWorkerThread
transport.threadFactory.bossThreadSize=1
transport.threadFactory.workerThreadSize=default
transport.shutdown.wait=3
transport.serialization=seata
transport.compressor=none
#Transaction routing rules configuration, only for the client
service.vgroupMapping.seata-tcc-account-learn-seata-service-group=default
#If you use a registry, you can ignore it
service.default.grouplist=127.0.0.1:8091
service.enableDegrade=false
service.disableGlobalTransaction=false
#Transaction rule configuration, only for the client
client.rm.asyncCommitBufferLimit=10000
client.rm.lock.retryInterval=10
client.rm.lock.retryTimes=30
client.rm.lock.retryPolicyBranchRollbackOnConflict=true
client.rm.reportRetryCount=5
client.rm.tableMetaCheckEnable=true
client.rm.tableMetaCheckerInterval=60000
client.rm.sqlParserType=druid
client.rm.reportSuccessEnable=false
client.rm.sagaBranchRegisterEnable=false
client.rm.sagaJsonParser=fastjson
client.rm.tccActionInterceptorOrder=-2147482648
client.tm.commitRetryCount=5
client.tm.rollbackRetryCount=5
client.tm.defaultGlobalTransactionTimeout=60000
client.tm.degradeCheck=false
client.tm.degradeCheckAllowTimes=10
client.tm.degradeCheckPeriod=2000
client.tm.interceptorOrder=-2147482648
client.undo.dataValidation=true
client.undo.logSerialization=jackson
client.undo.onlyCareUpdateColumns=true
server.undo.logSaveDays=7
server.undo.logDeletePeriod=86400000
client.undo.logTable=undo_log
client.undo.compress.enable=true
client.undo.compress.type=zip
client.undo.compress.threshold=64k
#For TCC transaction mode
tcc.fence.logTableName=tcc_fence_log
tcc.fence.cleanPeriod=1h
# You can choose from the following options: fastjson, jackson, gson
tcc.contextJsonParserType=fastjson
#Log rule configuration, for client and server
log.exceptionRate=100
seata-tcc.properties 在《Spring Cloud Alibaba Seata 实现分布式事物》的 seata.properties 基础上修改事物分组即可
服务端配置和《Spring Cloud Alibaba Seata 实现分布式事物》保持一致,无需修改
看《Spring Cloud Alibaba Seata 实现分布式事物》seata 服务端建表,保持一致,无需修改
看《Spring Cloud Alibaba Seata 实现分布式事物》seata 客户端建表
undo_log 表不需要,保留 account 和 order_tbl 表即可
启动 seata-server-1.7.1
进入 bin 目录,双击 seata-server.bat
启动 account 和 order 服务
nacos 服务和配置
测试正常情况
浏览器请求:http://localhost:9002/order/create?userId=101&money=10&rollback=false
扣减账户 10 元,新增订单
测试回滚情况
码云地址:https://gitee.com/wsjzzcbq/csdn-blog/tree/master/cloud-learn
至此完