作者简介:袁鸣凯,家乐福技术总监, 高知特有限技术公司中国区架构师,HP上海研发技术专家,夸客金融首席架构师,现任家乐福中国区技术总监。多年互联网、企业级SOA、微服务、全渠道中台方面的架构设计实战经验,曾先后参与过Metlife、CIGNA(信诺保险)内部开发设计安全规范制定,以及参与过JAVA代码标准规范的编写。
出品 | CSDN(CSDNnews)
阿里把FESCAR开源了,开源后的名称叫SEATA,目前GIT上已经超13000+星。
可惜笔者遍历全网段,无一篇是生产实用级说明。同时,GIT官网上的相关文档缺失以及Sample都太HelloWorld了,无法应用在真正的生产环境上。
于是,笔者结合了在6、7年前那时在那个MQ年代来解决分布式事务的经验,结合这次的SEATA(最新一次COMMIT在2019年12月底)来讲一下最最新的也是目前最最潮的,如何解决分布式事务同时要考虑数据的最终一致性还要兼顾性能及高效、吐吞率时,作为阿里的这一套开源组合是怎么把它们做到极致的。
我们在全篇中会引用两个例子:
第一个例子会用商品与库存来模拟SEATA中的AT模式的分布式事务如何实现;
第二个例子会使用两个银行间的跨行转款来模拟SEATA中的TCC模式的分布式事务如何实现。
尤其,目前网上所有的对于SEATA的TCC讲解只有一篇阿里原本的SEATA-tcc,它原本自带的这个例子有如下几个缺点:
若干个provider混在一起
provider和consumer混在一个项目
不支持nacos连接
不支持注解
然后网上所有的博客全部是围绕着这篇helloworld级别的例子而讲,其实很多都是抄袭,没有一篇融入了自己的领会与思想,也没有去把原本的例子按照生产级去做分离,这显然会误导很多读者。
因此,我们这次就在原本阿里官方的例子上做生产级别的增强,使得它可以适应你正要准备做的生产环境全模拟。
笔者在这边并不想作太多长篇大论或者像网上所有的关于SEATA方面的文章那样直接COPY PASTER一堆的所谓源码来凑字数。笔者在这边先讲一下分布式事务的几个重要概念,然后上生产上的实战级代码、应用和剖析。
在大型网站、并发流量高的网站,其实也不用太高,高到什么样的一个层级需要考虑分布式事务呢。看一下下面这样的一个描述:当你的生产环境的DB中假设有100个表,每个表数据量超过1亿。这时已经不是集群、主备、读写分离这么简单的一件事就可以搞定了。
或者你可以通过集群、主备、读写分离来提高网站前端的响应速度、吞吐量,可是你知道吗?如果你真的是在这样的一个量级的环境下呆过,你一定会对有一种情况不陌生!那就是当每个或者说主要单表超1亿数据的就算你有再多的主或者从或者分离,只要你在“主数据库”上作一条修改(删除或者是更新),它就会有一个动作叫作“主从同步”。
然后你会发现,这个主从同步会造成你的生产数据库频繁的“主从延迟”,我这边指的是6主6从每个固态硬盘、64C CPU, 128GB这样的配置的mySQL哦,如果你说“你们家狂有钱,都是512GB,128C的小型机”我也说这种情况最多是晚几天会出现但迟早还会出现的。
当主从延迟出现后会发生什么呢?比如说你要删一条数据,主删了然后开始去试图同步从数据库时,它会锁库、锁表。假设你有1000个表(一个系统子模块就有1000个表,一个大型网站有个30-40个子模块对于大型网站来说很正常),每个表是上亿数据,任何一笔业务造成的JOIN操作时同时这个数据库还在同步主从库此时这个同步付出的代价是“6-8小时甚至更多”,同步失败,这个数据的一致性就会越来越糟糕,再说白了,你的经营数据就会受到严重影响。
严重到后来你数据库的磁盘快不够了、你要删一点历史数据,你都不敢去操作了,因为你一操作,从凌晨到早上8:00左右你的主从同步同步不完,库还锁着此时就会影响到了你的线上、线下的业务了。这都是因为数据库太大太重造成了连你要删点历史记录、日志都不敢动的情况。这时IT会相当的被动。
因此我们按照最优的设计会保证单表数据不要超过2000万条,此时我们就会去做表的业务垂直折分,于是就有了微服务,它就会折成这样的架构。我们可以看到每一个数据库与服务都会折开甚至同一个会员也会折成每1000万数据一个库对应着一个微服务实例。
折完了情况当然得到了很大程度的改善,性能、实时性、吞吐量都得到了提高。然后碰到了下面类似的场景了,此时分布式事务的问题就出现了:
场景一
从上例我们可以看到,当商品主数据与库存主数据被折开来后,就会发生一个数据一致性的问题。假设你在后台对商品主数据做了一个添加或者是更新的动作那么整个系统也要求相应的库存数据与主数据必须一致,而一旦你拆成了微服务后主数据与库存说白了其实已经变成了两个不同的系统,每个系统都有自己的独立DB。这时要解决的就是这2个系统间任何的一个更新操作失败后为了维护数据的一致性那么这两个相关的“服务”都需要回滚之前的操作。
场景二
银行跨行转款,假设帐户A是工行,它通过工行向B的招商银行帐户转帐过去。这个转帐可是一个分布式事务,要么成功要么失败,不可能会有“部分成功”,这也是要求数据最终一致性的一个分布式事务的场景。
无论是场景一还是场景二,它就讲究数据的最终一致性。对于这个问题的讨论20多年前就已经产生了,解决方案也早有了。
从最早的使用MQ的acknowledge模式在事务发起时先通知一下相关参与方,当所有相关参与方commit(成功)后主发起事务再显示成功,如果有一方失败,每一个参与方都会被通知到,此时再逐级回滚事务。到现代的分布式事务、跨表事务都是为了解决类似问题而诞生。
但是,传统的做法在面对大流量大并的场景下,如果是类似最早的MQ的这种逐级通知方式它就会严重影响系统的交易时的性能,它的吞吐量就会受到制约。
但是在使用分布式事务的场景中,我们要求的是数据的最终一致性,它势必会涉及到锁库、锁表、锁业务段,因此我们近20年来一直也都在数据的一致性和性能间试图达到一个平衡。
这于是就诞生了几大核心的解决方案,即:2PC(二阶段)提交、3PC(三阶段-在二阶段上加了一个准备阶段)与TCC(事务补偿)机制。
对于这几大核心解决方案的原理涉及到的CAP和PAXOS理论本文不做探讨,网上太多相关论文了,如果你要应付PPT架构师面试可以去死记硬背,如果你要上生产代码,那么我们接下去继续说。这边只做简单叙述2PC与TCC的核心机制。
1)第一阶段:准备阶段(prepare)
协调者通知参与者准备提交订单,参与者开始投票。
协调者完成准备工作向协调者回应Yes。
2)第二阶段:提交(commit)/回滚(rollback)阶段
协调者根据参与者的投票结果发起最终的提交指令。
如果有参与者没有准备好则发起回滚指令。
应用程序通过事务协调器向两个库发起prepare,两个数据库收到消息分别执行本地事务(记录日志),但不提交,如果执行成功则回复yes,否则回复no。
事务协调器收到回复,只要有一方回复no则分别向参与者发起回滚事务,参与者开始回滚事务。
事务协调器收到回复,全部回复yes,此时向参与者发起提交事务。如果参与者有一方提交事务失败则由事务协调器发起回滚事务。
TCC事务补偿是基于2PC实现的业务层事务控制方案,它是Try(准备)、Confirm(提交)和Cancel(回滚)三个单词的首字母,含义如下:
1) Try 检查及预留业务资源完成提交事务前的检查,并预留好资源。
2) Confirm 确定执行业务操作,对try阶段预留的资源正式执行。
3) Cancel 取消执行业务操作,对try阶段预留的资源释放。
1. Try
转帐时,from库和to库分别进行帐户号信息、余额信息、冻洁转帐款项的操作,并锁定资源。
2. Confirm
from帐户把转帐金额变成冻结金额,然后from帐户扣除转帐金额同时在操作时进行记录锁定。to帐户把转帐金额变成冻结金额,然后to帐户余额+转帐金额=剩余金额同时在操作时进行记录锁定。
3. Cancel阶段
如果在from和to的各自业务中有任何一步抛错或者说失败,那么from和to的所有操作都要取消各自的操作;
于是
from操作把余额+被冻结的金额=原有from余额;原有冻结金额归0;
to操作把冻结的金额-转帐金额=原冻结金额,to操作把余额-转帐=原有余额;
以上所有步骤必须实现“业务幂等”,什么叫“业务幂等”?
业务幂等
就是无论以上各自步骤如何操作,它们的业务关联性必须相等,比如说:
from帐户原有100,冻结字段为0元,欲转出10元;
to帐户原有100,冻结字段为0元,欲从from转入10元;
那么以上一圈步骤轮下来有一步操作了,必须回到这个起始原点的状态。这就需要我们的应用程序做中间状态的保留以及在程序代码里预埋“业务补偿”或者我们也把它称为“反交易”逻辑。
好了,以上就是核心逻辑,不再展开更深入的原理,再展开就涉及到算法和理论了,我们这边不是为了帮大家应对面试而是帮大家真正的走上“生产环境”。因此下面就要开始show me the code了,我上面画图时其实已经预留了整体的“架构设计”了,因此下面就围绕着以上的2个场景我们来使用springboot+dubbo+nacos+SEATA来实现它们。
这边需要介绍一下SEATA和nacos。
SEATA是什么?
SEATA的前身就是阿里蚂蚁金服的:FESCAR,它就是为了解决又要实现事务的最终一致性,又要保证整体系统的高性能、高吞吐还要解决为了实现以上的事务2PC或者是TCC时不对已经写好的业务代码进行太多的“侵入式破坏”来设计的。
写本文时它的最高版本为1.0.0GA版,最后一次GIT提交时间在2天前即1月19号还在不断有人提交Patch和fix bug。目前网上所有的sample要么跑不起来、要么不可用全部都是helloworld级别的东西,只可以单机玩无法跑生产,而且都不是结合nacos的应用的根本没法实用,文档又不全,因此我是把我实际生产中的经验直接分享给到各位的。
这是一个相当成熟了的服务注册发现+资源管理器,目前最新版为1.1.4。它是作为取代Zookeeper的地位的,而事实上它也正在取代Zookeeper,它相当的成熟,比SEATA成熟,必竟它比SEATA出现的早吗。
我们都知道SpringCloud和Dubbo都有自己的基于ZK的服务管理中心对吧?那玩意过时了,简陋、又不易操作、不易运维,dubbo从2.6版开始已经内含nacos-registry了,因此越来越多的远程服务注册、服务自发现开始使用nacos了。
我们注意到了我在画TCC事务时放置了一个“配置管理中心”,这是我故意放置的,此处使用的正是Nacos。
SEATA就是事务管理中心,Transaction Management,简称TM。各个Dubbo微服务会被当成一种Resource被注册进SEATA的“资源管理器”,我们简称RM。
那有Nacos什么事?
大家想一下我上面说过的话,要解决的是“又要实现事务的最终一致性,又要保证整体系统的高性能、高吞吐还要解决为了实现以上的事务2PC或者是TCC时不对已经写好的业务代码进行太多的侵入式破坏“。注意此处的“不对写好的业务代码进行太多的侵入式破坏”这几个字眼。
如何不侵入式破坏?类反射+回调->Spring+回调,Yeah!
同时,各个微服务就是一套独立的系统,怎么让远程的2个系统或者是多个系统互相类反射?
那么我们的做法起源于最最最最古老的J2EE v1.2规范中的EJB的Session Bean的设计理念。
它的原理其实就是各个EJB(微服务,那时叫SOA)把自己的接口名的全路径以JNDI的寻址方式注册进J2EE容器(要玩J2EE可不能用Tomcat哦,Tomcat永远只是一个webcontainer,要玩J2EE必须使用Websphere,Weblogic或者是开源的JBOSS,spring+mybatis=Thisis not a J2EE)。然后不同参于的“服务组件”都通过这个JNDI来进行寻址并互相通知(调用)。
Dubbo的作者(包括他的开发团队)曾提出过这么一个思想:我觉得事务的管理不应该属于Dubbo框架,Dubbo只需实现可被事务管理即可,像JDBC和JMS都是可被事务管理的分布式资源,Dubbo只要实现相同的可被事务管理的行为,比如可以回滚, 其它事务的调度,都应该由专门的事务管理器实现。FESCAR就是在这么一个前提下在被架构出来的。
SEATA, Nacos, Dubbo三者间就正是这么一种羁绊。
Dubbo是微服务核心服务提供者;
Dubbo与Dubbo间的通信用的是远程接口,它需要一个远程的自动服务发现、注册管理中心,于是就有了Nacos;
而SEATA是一种TM,它通过的是通过去注册管理中心里寻找相应的RM的注册地址,然后通过远程消息+异步回调机制来完成RM内的相关事务的统一协调与管理;
知道了这三者的关系后我们下面开始就用实例来实现这一次魔幻之旅吧。
所有工程源码位于我的git上,各位可以下载了后学习用:
https://github.com/mkyuangithub/mkyuangithub
首先我们使用SEATA-server v0.9.0版本,它是一个中间件,启动在那边就可以了。但是对于它的客户端我们必须要注意使用seata-all version 1.0.0。
没错,server端和client端版本不一致,你没看错。
Server版我们还不能完全使用SEATA-server 1.0GA,如前文所提,它还不是一个稳定版本,而且还在不断的修相应的bug,0.9.0是一个比较稳定的版本也支持HA机制,是可以用来上生产的。
Client版我们坚持使用v1.0.0,那是因为0.8.0~0.9.0间的client版有Bug,这个Bug如果你不是真正的生产级别应用是发现不了的,因为如果你直接跑它的官方Sample那是没有任何问题,如果你要把多个微服务分开来布署,并且每个微服务还要有自己的DB(这才是生产级应用),然后通过nacos靠SEATA来协调不同微服务间的事务,它会造成你一个微服务提交事务后整个后台事务“死锁”,这也在git上被SEATA相关贡献者给证明了,这个bug相关的0.8.1~0.9.0的client端还在待修复中,而v1.0.0目前没有这个问题。
目前全网所有的讲解SEATA的例子均出于两篇文章,这两篇一篇是阿里内部开发人员通过SEATA中相应的源码来说明这对搞原理的人来说可以做做相应的学术研究,另一篇是等于把git上的sample原份不动的抄了一下也没有太多实际的生产价值,并且会误导真正要去关注其技术的读者。
我们这边就拿SEATA-Server0.9.0+Nacos1.1.4来做我们实例的讲解。
两个者的部署运行包请去它们各自的官方git上下载,我这边也给出了这两者的官网下载地址:
https://github.com/seata/seata/releases/download/v0.9.0/seata-server-0.9.0.zip
https://github.com/alibaba/nacos/releases/download/1.1.4/nacos-server-1.1.4.zip
要下载tar包的读者们可直行去下面这两个网址下载:
https://github.com/seata/seata/releases
https://github.com/alibaba/nacos/releases
nacos怎么用我已经在上一篇阿里的nacos+springboot+dubbo2.7.3集成以及统一处理异常的两种方式看详细有讲述了。因此我们主要就来看SEATA的用法。我要强调的是本示例是推荐大家把nacos和SEATA全部部署在linux上进行使用的,为什么后面你就会知道了。
Seata-Server0.9.0下载后打开它是这么样的一个目录结构
你可以直接跑到bin目录下运行./seata-server.sh,但是我请你先不要这么干,我们进conf目录中进行一个简单的不超过5分钟的配置。如果你不看这章直接只管运行你会变成网上那些根本无法把它运用到实战的人群之一。
我们进入conf目录
SEATA有两种运行模式:
它可以依赖于nacos或者是zk、redis、springcloud的eureka的方式
它也可以不依赖于任何第三方,仅靠client端与SEATA间进行通讯,它使用的就是这个conf目录内的file.conf了
因此,如果你要使用nacos来做SEATA的“托载”,就请把这个file.conf删了,而网上所有的教程竟然都是保留着它。
剁了它吧!
我们用的是SEATA+nacos的这种模式来跑我们的实例的。
至于为什么使用file模式?它和其它模式有什么不一样的地方呢?
我们打开conf目录下的registry.conf目录来一窥究竟吧,下面是registry.conf的内容:
registry {
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "file"
nacos {
serverAddr = "localhost"
namespace = ""
cluster = "default"
}
eureka {
serviceUrl = "http://localhost:8761/eureka"
application = "default"
weight = "1"
}
redis {
serverAddr = "localhost:6379"
db = "0"
}
zk {
cluster = "default"
serverAddr = "127.0.0.1:2181"
session.timeout = 6000
connect.timeout = 2000
}
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
type = "file"
nacos {
serverAddr = "localhost"
namespace = ""
}
consul {
serverAddr = "127.0.0.1:8500"
}
apollo {
app.id = "seata-server"
apollo.meta = "http://192.168.1.204:8801"
}
zk {
serverAddr = "127.0.0.1:2181"
session.timeout = 6000
connect.timeout = 2000
}
etcd3 {
serverAddr = "http://localhost:2379"
}
file {
name = "file.conf"
}
Registry{}段与Config{}段。大家要注意的就是两个段:
这么一陀都要么?可能么?肯定我们只选用其中一种配置,这边我们用的就是nacos模式,于是我们把这个registry.conf设置成我下面这样即可,看到了吧,registry里的type设成nacos,config里也设成nacos,并指定nacos运行的地址,因此你的nacos一定先要运行起来。
这边再多插一个番外,nacos在windows下你直接使用startup.cmd启动是没问题的,它默认会在windows下使用standalone模式启动,而在linux下你不能直接startup.sh,因为在linux下它会默认使用cluster模式启动,因此在linux下你需要使用如下启动命令来启动你的nacos:
./startup.sh -m standalone
registry {
type = "nacos"
nacos {
serverAddr = "192.168.56.101"
namespace = "public"
cluster = "default"
}
}
config {
type = "nacos"
nacos {
serverAddr = "192.168.56.101"
namespace = "public"
cluster = "default"
}
}
因此一旦设置了type=nacos后就请把file.conf文件删除了即可,不要用file了。为什么这边的nacos的serverAddr不要写成:192.168.56.101:8848呢?因为SEATA目前写死掉如果是nacos那么代码取端口“写死8848”,相应后期1.0.0GA版本后一定会做成端口动态设置的,不过这不影响我们使用的。
因为SEATA如果通过的是file模式运行,客户端与SEATA server端不是通过注册中心去做服务连接和自动发现的,它是让拥有seata-client端的jar代码与seata server端直接通过tcp直连的模式的。
因为我们使用的是type=nacos,因此我们需要把SEATA的相关服务给注册到nacos中。
请使用纯文本编辑工具(一定是纯文本编辑工具哦)在seata/conf目录下建立一个叫“nacos.config.txt”的文件。对,你没看错文件名就是“nacos-config.txt”文件,而且一个字不能错。
内容如下,注意前方一陀参数预警,不过我会把重要的参数都给说一遍的。
transport.type=TCP
transport.server=NIO
transport.heartbeat=true
transport.thread-factory.boss-thread-prefix=NettyBoss
transport.thread-factory.worker-thread-prefix=NettyServerNIOWorker
transport.thread-factory.server-executor-thread-prefix=NettyServerBizHandler
transport.thread-factory.share-boss-worker=false
transport.thread-factory.client-selector-thread-prefix=NettyClientSelector
transport.thread-factory.client-selector-thread-size=1
transport.thread-factory.client-worker-thread-prefix=NettyClientWorkerThread
transport.thread-factory.boss-thread-size=1
transport.thread-factory.worker-thread-size=8
transport.shutdown.wait=3
service.vgroup_mapping.demo-tx-grp=default
service.default.grouplist=192.168.56.101:8091
service.enableDegrade=false
service.disable=false
service.max.commit.retry.timeout=-1
service.max.rollback.retry.timeout=-1
client.async.commit.buffer.limit=10000
client.lock.retry.internal=10
client.lock.retry.times=30
client.lock.retry.policy.branch-rollback-on-conflict=true
client.table.meta.check.enable=true
client.report.retry.count=5
client.tm.commit.retry.count=1
client.tm.rollback.retry.count=1
store.mode=db
store.file.dir=file_store/data
store.file.max-branch-session-size=16384
store.file.max-global-session-size=512
store.file.file-write-buffer-cache-size=16384
store.file.flush-disk-mode=async
store.file.session.reload.read_size=100
store.db.datasource=druid
store.db.db-type=mysql
store.db.driver-class-name=com.mysql.jdbc.Driver
store.db.url=jdbc:mysql://192.168.56.101:3306/seata?useUnicode=true
store.db.user=seata
store.db.password=111111
store.db.min-conn=1
store.db.max-conn=3
store.db.global.table=global_table
store.db.branch.table=branch_table
store.db.query-limit=100
store.db.lock-table=lock_table
recovery.committing-retry-period=1000
recovery.asyn-committing-retry-period=1000
recovery.rollbacking-retry-period=1000
recovery.timeout-retry-period=1000
transaction.undo.data.validation=true
transaction.undo.log.serialization=jackson
transaction.undo.log.save.days=7
transaction.undo.log.delete.period=86400000
transaction.undo.log.table=undo_log
transport.serialization=seata
transport.compressor=none
metrics.enabled=false
metrics.registry-type=compact
metrics.exporter-list=prometheus
metrics.exporter-prometheus-port=9898
support.spring.datasource.autoproxy=false
妈妈呀,这么长一陀,不难理解,它其实也分几个段来理解的。
SEATA连接DB
SEATA一旦配置了nacos后它会在nacos中生成一个配置管理的服务,这个服务是需要依托于DB来做持久的,这时你需要把它的store.mode改成db。
此处你看到了,对于db你可以直接使用druid来作你的datasource,它自带druid客户端了:
store.db.datasource=druid
store.db.db-type=mysql
store.db.driver-class-name=com.mysql.jdbc.Driver
store.db.url=jdbc:mysql://192.168.56.101:3306/seata?useUnicode=true
store.db.user=seata
store.db.password=111111
store.db.min-conn=1
store.db.max-conn=3
配完了db不要忘记在db中手动运行conf/目录下的一个叫db_store.sql的文件,它会生成SEATA所需运行的3个表。
-- the table to store GlobalSession data
drop table if exists `global_table`;
create table `global_table` (
`xid` varchar(128) not null,
`transaction_id` bigint,
`status` tinyint not null,
`application_id` varchar(32),
`transaction_service_group` varchar(32),
`transaction_name` varchar(128),
`timeout` int,
`begin_time` bigint,
`application_data` varchar(2000),
`gmt_create` datetime,
`gmt_modified` datetime,
primary key (`xid`),
key `idx_gmt_modified_status` (`gmt_modified`, `status`),
key `idx_transaction_id` (`transaction_id`)
);
-- the table to store BranchSession data
drop table if exists `branch_table`;
create table `branch_table` (
`branch_id` bigint not null,
`xid` varchar(128) not null,
`transaction_id` bigint ,
`resource_group_id` varchar(32),
`resource_id` varchar(256) ,
`lock_key` varchar(128) ,
`branch_type` varchar(8) ,
`status` tinyint,
`client_id` varchar(64),
`application_data` varchar(2000),
`gmt_create` datetime,
`gmt_modified` datetime,
primary key (`branch_id`),
key `idx_xid` (`xid`)
);
-- the table to store lock data
drop table if exists `lock_table`;
create table `lock_table` (
`row_key` varchar(128) not null,
`xid` varchar(96),
`transaction_id` long ,
`branch_id` long,
`resource_id` varchar(256) ,
`table_name` varchar(32) ,
`pk` varchar(36) ,
`gmt_create` datetime ,
`gmt_modified` datetime,
primary key(`row_key`)
);
这边的server.vgroup_mapping指的就是分布式事务生效的那个“范围”,看下图就懂了:
这边的事务作用group就是我们的service.vgroup_mappiing然后“小点点”后面的这个名字了,这个名字你必须和你在SEATA client端带的事务group名一致。
什么是SEATA客户端,喏,上图中的2个dubbo和那个消费者(consumer)就是SEATA的客户端,seata-server0.9.0就是server端。
service.default.grouplist=192.168.56.101:8091是什么意思?
很简单,就是为这个事务范围它会开启一个基于netty的远程服务,它是一个远程消息服务。当任何一段抛出Exception,这个消息服务就会触发它的onMessage方法中的相应的逻辑,它要做的事就是去nacos中寻找注册进去的provider端和consumer端,然后通过回调接口来通知各个事务参与者要么提交,要么回滚用的。
因此这边的ip或者是hostname就是指向SEATA server端自身的ip即可。
其实这么长一陀参数,我们只要讲这几个核心参数即可,其它的参数?你们自己去看看,那都是用来调解性能阀值的,你配过druid的相关阀值参数就会配那余下的几个参数,这个可以留给大家根据自身部署环境去做调整,没有一个绝对的答案。
全配置完后我们要开始启动SEATA server了。等等等等!还有一个步骤!
你配完这个nacos-config.txt后不代表SEATA启动时就会加载它,不会的哦,这个文件是要倒过来先“注入nacos”的,而且是在SEATA启动前先注入给到nacos的,因此你的nacos必须在第1步骤中就启动起来。
我们在SEATA的conf目录下看到有这么两个文件,一个叫nacos-config.sh一个叫nacos-config.py。它们的作用就是:
nacos-config.sh 192.168.56.101
此时SEATA会自动找到命令后的ip加上:8848端口号,然后找到conf目录下的nacos-config.txt文件,然后把nacos-config.txt文件里的那一陀内容全部注入到nacos中去的。
这边重提一下上面说的为什么我推荐大家要在linux下用nacos和SEATA,你们看到了,它没有nacos-config.bat哈,你要在windows下用要么你装个cygwin要么装一个python for windows环境。
此处千万记得,在nacos-config.txt文件最后不要留空行啊,因为nacos-config.sh也会把空行当成null值给set进nacos的。
或者你在linux下完成了我上述这些步骤后,再把个SEATA copy回到windows下,然后通过seata/bin下的seata-server.bat运行哈?这不折腾吗?
启动前最后要配一个东西,什么?logback.xml文件。
它里面指定了SEATA的log生成在哪儿,要不,它会生成在这些地方 :
windows下默认的seata log生成在c盘的user目录下;
linux就生成在/home/登录用户下的logs/seata了;
全设完了,我们就可以通过以下命令来启动seata了。
./seata-server.sh
所有代码我已经放置在了我的git上了,地址在这:
https://github.com/mkyuangithub/mkyuangithub
我们在这个例子中会设计2个dubbo service:
seata-product-service
seata-stock-service
设计一个controller,seata-demo-consumer,它会通过这两个dubbo service分别在product库和stock库同时插入一条记录,如:
{
"productname" : "tea",
"stock" : 10000
}
要么成功全部提交事务;这两个dubbo service可是分别连着自己的物理数据库,连链接都不同的数据库的,并且这两个dubboservice启动在两个不同的jvm实例中,以此来完成:
要么在操作过程中有任何Exception抛出那么两个Dubbo Service全部回滚各自的业务以保持分布式事务的最终数据一致性;
先不急着分析代码,们在做代码前笔者要把SEATA结合spring boot和nacos的坑好好的给各位快速撸一把。
我们做1个consumer来沟通这两个dubbo service的架构入手!
我们一共会分成三个工程:
seata-product-service,连接着productDB
seata-stock-service,连接着stockDB
seata-demo-consumer,它是一个springboot controller,在一个service方法内同时调用product与stock的dubbo service来完成t_product和t_stock表的插入动作
每一个工程记得要在它们的src/main/resources目录下放置一个registry.conf,它用来让每个工程内的seata-all的客户端可以访问到SEATA。三个工程都要放。
seata-product-service与seata-stock-service工程内的registry.conf会调用工程的maven中的:
io.seata
seata-all
registry.conf文件内容如下把自身注册进RM(Resource Manager)里。如果你的type选用的是file那么你还要在每个工程的src/main/resources内放置一个file.conf的文件。文件内容必须和seata-server端的conf/file.conf文件内容一致。目前网上的所谓例子都是基于file来做的,它不会使用到注册中心的概念只会在SEATA所属的客户端和seata-server端使用本地开启netty然后通过8091端口来互传,因此我才说这不是生产级的应用。
registry.conf文件内容如下:
registry {
type = "nacos"
nacos {
serverAddr = "192.168.56.101"
namespace = "public"
cluster = "default"
}
}
config {
type = "nacos"
nacos {
serverAddr = "192.168.56.101"
namespace = ""
}
}
而seata-demo-consumer里有一个普通的service方法,它是一个dubbo consumer,在它的方法内会有一个这样的标记。
这个@GlobalTransactional用来通过nacos寻址返向找到product和stock内的dubbo provider然后再回调相应的provider service中的涉及到db的transaction来执行回滚或提交。它只安置于调用若干个dubboservice的业务service这一层,各dubbo service内不需要加这个参数(除非你有事嵌套和传播的需求)。
为此,我们还需要多做一步,那就是把SEATA的transaction注入到有DB涉及到的dubbo provider工程内,见下文。
在dubbo工程内,有涉及到操作DB的,都需要在连接dataSource时这样进行配置,此处我们使用spring boot全注解方式来连接DB。
package org.sky.product.config;
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 org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import com.alibaba.druid.pool.DruidDataSource;
import io.seata.rm.datasource.DataSourceProxy;
import io.seata.spring.annotation.GlobalTransactionScanner;
@Configuration
public class DruidConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DruidDataSource druidDataSource() {
return new DruidDataSource();
}
/**
* init datasource proxy
*
* @Param: druidDataSource datasource bean instance
* @Return: DataSourceProxy datasource proxy
*/
@Bean
public DataSourceProxy dataSourceProxy(DruidDataSource druidDataSource) {
return new DataSourceProxy(druidDataSource);
}
@Bean
public DataSourceTransactionManager transactionManager(DataSourceProxy dataSourceProxy) {
return new DataSourceTransactionManager(dataSourceProxy);
}
/**
* init jdbc template by using the dataSourceProxy
*
* @Return: JdbcTemplate
*/
@Primary
@Bean
public JdbcTemplate dataSource(DataSourceProxy dataSourceProxy) {
return new JdbcTemplate(dataSourceProxy);
}
/**
* init global transaction scanner
*
* @Return: GlobalTransactionScanner
*/
@Bean
public GlobalTransactionScanner globalTransactionScanner() {
return new GlobalTransactionScanner("seata-product-service", "demo-tx-grp");
}
}
return newGlobalTransactionScanner("seata-product-service","demo-tx-grp");
此处的第一个参数是你的dubbo-application-name,它对应于你的application.properties文件内的如下这么一块内容。每个dubbo provider的name都不一样的。
dubbo.protocol.id=dubbo
dubbo.protocol.name=dubbo
dubbo.application.name=seata-product-service
dubbo.application.id=seata-product-service
此处的第二个参数就是“全局事务的范围”,它也对应着我们在SEATA的nacos-config.txt文件内配置的这一行,要是这个名字对不起来那么各SEATA client端无法通过seata-server:8091的netty端口连上SEATA的service group,客户端也会频繁抛 can not connect toserver的出错信息的:
每个SEATA客户端连接着的DB库内要有一个undo_log表,这个表是供SEATA客户端自己调用的,它的建表语句就位于seata/conf目录内的db_undo_log.sql。
有参于事务的客户端的业务DB库内就必须要有一个这样的表,SEATA在回调dubbo provider时在提交和回滚时是会自动用到这张表的。
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,
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
和之前的工程一样,它们共用一个parent即nacos-parent,这边给出seata-product-service工程的pom.xml文件。这边要记的就是SEATA的客户端一定要用1.0.0,这些版本号的工作都是在我的parent工程内制定的。
1.8
1.5.15.RELEASE
2.7.3
4.0.1
2.8.0
1.1.20
27.0.1-jre
1.2.59
2.7.3
1.1.4
5.1.46
3.4.2
1.8.13
1.8.14-RELEASE
0.0.1-SNAPSHOT
1.0.0
4.1.42.Final
0.1.4
1.16.22
3.1.0
3.4.5
1.3.1
${java.version}
${java.version}
3.8.1
3.2.3
3.1.2
UTF-8
UTF-8
下面是seata-product-service的pom.xml全内容
4.0.0
org.sky.demo
seata-product-service
0.0.1-SNAPSHOT
jar
seata-product-service
Demo project Dubbo+Nacos+Seata
org.sky.demo
nacos-parent
0.0.1-SNAPSHOT
UTF-8
org.springframework.boot
spring-boot-starter-jdbc
org.springframework.boot
spring-boot-starter-logging
org.apache.dubbo
dubbo
org.apache.curator
curator-framework
org.apache.curator
curator-recipes
mysql
mysql-connector-java
com.alibaba
druid
org.springframework.boot
spring-boot-starter-test
test
org.spockframework
spock-core
test
org.spockframework
spock-spring
org.springframework.boot
spring-boot-configuration-processor
true
org.springframework.boot
spring-boot-starter-log4j2
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-logging
org.springframework.boot
spring-boot-starter-tomcat
org.aspectj
aspectjweaver
com.lmax
disruptor
redis.clients
jedis
com.google.guava
guava
com.alibaba
fastjson
org.apache.dubbo
dubbo-registry-nacos
com.alibaba.nacos
nacos-client
org.sky.demo
skycommon
${skycommon.version}
io.seata
seata-all
com.alibaba.boot
nacos-config-spring-boot-starter
nacos-client
com.alibaba.nacos
io.netty
netty-all
org.projectlombok
lombok
${project.artifactId}
src/main/java
src/test/java
org.apache.maven.plugins
maven-compiler-plugin
org.springframework.boot
spring-boot-maven-plugin
-Dfile.encoding=UTF-8
repackage
org.apache.maven.plugins
maven-war-plugin
2.6
false
src/main/resources
application*.properties
src/main/webapp
META-INF/resources
**/**
src/main/resources
true
application.properties
相应的t_product建表语句如下:
CREATE TABLE `t_product` (
`product_id` int(16) NOT NULL AUTO_INCREMENT,
`product_name` varchar(45) COLLATE utf8_bin DEFAULT NULL,
PRIMARY KEY (`product_id`)
) ;
application.properties
项目使用springboot全注解,来看看项目的application.properties文件的内容吧。
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driverClassName=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://192.168.56.101:3306/product?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username=product
spring.datasource.password=111111
spring.datasource.initialize=false
spring.redis.database=0
spring.redis.host=192.168.56.101
spring.redis.port=6379
spring.redis.password=111111
spring.redis.pool.max-active=10
spring.redis.pool.max-wait=-1
spring.redis.pool.max-idle=10
spring.redis.pool.min-idle=0
spring.redis.timeout=1000
#tomcat settings
server.port=8080
server.tomcat.maxConnections=300
server.tomcat.maxThreads=300
server.tomcat.uriEncoding=UTF-8
server.tomcat.maxThreads=300
server.tomcat.minSpareThreads=150
server.connectionTimeout=20000
server.tomcat.maxHttpPostSize=0
server.tomcat.acceptCount=300
#Dubbo provider configuration
dubbo.protocol.id=dubbo
dubbo.protocol.name=dubbo
dubbo.application.name=seata-product-service
dubbo.application.id=seata-product-service
dubbo.registry.protocol=dubbo
dubbo.registry.address=nacos://192.168.56.101:8848
dubbo.protocol.name=dubbo
dubbo.protocol.port=20880
dubbo.protocol.threads=2000
dubbo.protocol.queues=1000
dubbo.protocol.threadpool=cached
dubbo.provider.retries=3
dubbo.provider.threadpool=cached
dubbo.provider.threads=2000
dubbo.provider.connections=2000
dubbo.provider.acceptes=0
dubbo.provider.executes=0
dubbo.consumer.actives=0
dubbo.scan.base-packages=org.sky.product.service.dubbo
logging.config=classpath:log4j2.xml
以下是项目的自动注入相关资源到pring用的DruidConfig.java文件
package org.sky.product.config;
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 org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import com.alibaba.druid.pool.DruidDataSource;
import io.seata.rm.datasource.DataSourceProxy;
import io.seata.spring.annotation.GlobalTransactionScanner;
@Configuration
public class DruidConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DruidDataSource druidDataSource() {
return new DruidDataSource();
}
/**
* init datasource proxy
*
* @Param: druidDataSource datasource bean instance
* @Return: DataSourceProxy datasource proxy
*/
@Bean
public DataSourceProxy dataSourceProxy(DruidDataSource druidDataSource) {
return new DataSourceProxy(druidDataSource);
}
@Bean
public DataSourceTransactionManager transactionManager(DataSourceProxy dataSourceProxy) {
return new DataSourceTransactionManager(dataSourceProxy);
}
/**
* init jdbc template by using the dataSourceProxy
*
* @Return: JdbcTemplate
*/
@Primary
@Bean
public JdbcTemplate dataSource(DataSourceProxy dataSourceProxy) {
return new JdbcTemplate(dataSourceProxy);
}
/**
* init global transaction scanner
*
* @Return: GlobalTransactionScanner
*/
@Bean
public GlobalTransactionScanner globalTransactionScanner() {
return new GlobalTransactionScanner("seata-product-service", "demo-tx-grp");
}
}
可以看到它开了一个dubbo providerservice,dubbo.application.name申明了该dubbo服务的名称。并且这个dubbo provider service会运行在20880端口。
此处我们使用jdbcTemplate来做全工程的SQL访问。大家可以看到它使用了SEATA带的DatasourceProxy代理了DruiDatasource,然后再用GlobalTransationScanner来代理Spring的TransactionManager。
ProductDAO.java
package org.sky.product.dao;
import org.sky.exception.DemoRpcRunTimeException;
public interface ProductDAO {
public long addNewProduct(String productName) throws DemoRpcRunTimeException;
}
ProductDAOImpl
package org.sky.product.dao;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import javax.annotation.Resource;
import org.sky.dao.BaseDAO;
import org.sky.exception.DemoRpcRunTimeException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Component;
@Component
public class ProductDAOImpl extends BaseDAO implements ProductDAO {
@Resource
private JdbcTemplate jdbcTemplate;
@Override
public long addNewProduct(String productName) throws DemoRpcRunTimeException {
String prodSql = "insert into t_product(product_name)values(?)";
long newProdId = 0;
try {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(new PreparedStatementCreator() {
@Override
public PreparedStatement createPreparedStatement(Connection connection) throws SQLException {
PreparedStatement ps = connection.prepareStatement(prodSql, new String[] { "id" });
ps.setString(1, productName);
return ps;
}
}, keyHolder);
newProdId = keyHolder.getKey().longValue();
} catch (Exception e) {
throw new DemoRpcRunTimeException(
"error occured on insert product with: product_id->\" + newProdId + \" and productname->\"\r\n"
+ " + productName" + e.getMessage(),
e);
}
return newProdId;
}
}
然后我们给出ProductBizService,这个BizService只是一个普通的@Service,它会被ProductDubboService调用,整个SEATA的事务会通过这样的事务传播路径Dubbo->Service->Dao一路传进来,因此对于参于全局SEATA事务的@Service类就不需要再加@Transactional这样的注解了。
package org.sky.product.service.biz;
import org.sky.exception.DemoRpcRunTimeException;
import org.sky.product.dao.ProductDAO;
import org.sky.product.service.biz.ProductBizService;
import org.sky.service.BaseService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class ProductBizServiceImpl extends BaseService implements ProductBizService {
@Autowired
ProductDAO productDAO;
@Override
public long addProduct(String productName) throws DemoRpcRunTimeException {
long newProdId = 0;
try {
newProdId = productDAO.addNewProduct(productName);
} catch (Exception e) {
logger.error("error occured on Biz Service Side: " + e.getMessage(), e);
throw new DemoRpcRunTimeException("error occured on Biz Service Side: " + e.getMessage(), e);
}
return newProdId;
}
}
ProductDubboServiceImpl.java
下面是product的微服务ProductDubboService的实现类,我们把ProductDubboService这种用于远程访问的Dubbo的Inerface(在EJB理念里我们管这种叫残根)都放置在了sky-common工程里了。下面给出ProductDubboService的实现类。
package org.sky.product.service.dubbo;
import org.apache.dubbo.config.annotation.Service;
import org.sky.exception.DemoRpcRunTimeException;
import org.sky.product.service.biz.ProductBizService;
import org.sky.product.service.dubbo.ProductDubboService;
import org.sky.service.BaseService;
import org.springframework.beans.factory.annotation.Autowired;
@Service(version = "1.0.0", interfaceClass = ProductDubboService.class, timeout = 30000, loadbalance = "roundrobin")
public class ProductDubboServiceImpl extends BaseService implements ProductDubboService {
@Autowired
ProductBizService productBizService;
@Override
public long addProduct(String productName) throws DemoRpcRunTimeException {
long result = 0;
try {
result = productBizService.addProduct(productName);
logger.info("======>insert into t_product with product_id->" + result + " and productname->" + productName
+ " successfully");
} catch (Exception e) {
logger.error("error occured on insert product with: product_id->\" + newProdId + \" and productname->\"\r\n"
+ " + productName" + e.getMessage(), e);
throw new DemoRpcRunTimeException(
"error occured on insert product with: product_id->\" + newProdId + \" and productname->\"\r\n"
+ " + productName" + e.getMessage(),
e);
}
return result;
}
}
最后,就是用于运行该工程用的springboot的Application.java文件了
package org.sky.product;
import org.apache.dubbo.config.spring.context.annotation.EnableDubbo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@EnableDubbo
@EnableAutoConfiguration
@ComponentScan(basePackages = { "org.sky.product" })
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
运行起来后,它会把这个dubbo providerservice自动注册进nacos去。
因此启动顺序一定记得是mysql->nacos->seata再启动各个dubbo provider以及consumer。
pom.xml
同seata-product-service项目。
4.0.0
org.sky.demo
seata-stock-service
0.0.1-SNAPSHOT
jar
seata-stock-service
Demo project Dubbo+Nacos+Seata
org.sky.demo
nacos-parent
0.0.1-SNAPSHOT
UTF-8
org.springframework.boot
spring-boot-starter-jdbc
org.springframework.boot
spring-boot-starter-logging
org.apache.dubbo
dubbo
org.apache.curator
curator-framework
org.apache.curator
curator-recipes
mysql
mysql-connector-java
com.alibaba
druid
org.springframework.boot
spring-boot-starter-test
test
org.spockframework
spock-core
test
org.spockframework
spock-spring
org.springframework.boot
spring-boot-configuration-processor
true
org.springframework.boot
spring-boot-starter-log4j2
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-logging
org.springframework.boot
spring-boot-starter-tomcat
org.aspectj
aspectjweaver
com.lmax
disruptor
redis.clients
jedis
com.google.guava
guava
com.alibaba
fastjson
org.apache.dubbo
dubbo-registry-nacos
com.alibaba.nacos
nacos-client
org.sky.demo
skycommon
${skycommon.version}
io.seata
seata-all
com.alibaba.boot
nacos-config-spring-boot-starter
nacos-client
com.alibaba.nacos
io.netty
netty-all
org.projectlombok
lombok
${project.artifactId}
src/main/java
src/test/java
org.apache.maven.plugins
maven-compiler-plugin
org.springframework.boot
spring-boot-maven-plugin
-Dfile.encoding=UTF-8
repackage
org.apache.maven.plugins
maven-war-plugin
2.6
false
src/main/resources
application*.properties
src/main/webapp
META-INF/resources
**/**
src/main/resources
true
application.properties
建表语句:
CREATE TABLE `t_stock` (
`stock_id` int(16) NOT NULL AUTO_INCREMENT,
`stock` int(6) DEFAULT NULL,
`product_id` int(16) NOT NULL,
PRIMARY KEY (`stock_id`)
) ;
application.properties
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driverClassName=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://192.168.56.101:3306/stock?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username=stock
spring.datasource.password=111111
spring.datasource.initialize=false
spring.redis.database=0
spring.redis.host=192.168.56.101
spring.redis.port=6379
spring.redis.password=111111
spring.redis.pool.max-active=10
spring.redis.pool.max-wait=-1
spring.redis.pool.max-idle=10
spring.redis.pool.min-idle=0
spring.redis.timeout=1000
#tomcat settings
server.port=8081
server.tomcat.maxConnections=300
server.tomcat.maxThreads=300
server.tomcat.uriEncoding=UTF-8
server.tomcat.maxThreads=300
server.tomcat.minSpareThreads=150
server.connectionTimeout=20000
server.tomcat.maxHttpPostSize=0
server.tomcat.acceptCount=300
#Dubbo provider configuration
dubbo.protocol.id=dubbo
dubbo.protocol.name=dubbo
dubbo.application.name=seata-stock-service
dubbo.application.id=seata-stock-service
dubbo.registry.protocol=dubbo
dubbo.registry.address=nacos://192.168.56.101:8848
dubbo.protocol.name=dubbo
dubbo.protocol.port=20981
dubbo.protocol.threads=2000
dubbo.protocol.queues=1000
dubbo.protocol.threadpool=cached
dubbo.provider.retries=3
dubbo.provider.threadpool=cached
dubbo.provider.threads=2000
dubbo.provider.connections=2000
dubbo.provider.acceptes=0
dubbo.provider.executes=0
dubbo.consumer.actives=0
dubbo.scan.base-packages=org.sky.stock.service.dubbo
logging.config=classpath:log4j2.xml
它的registry.conf和seata-product-service中的registry.conf内容完全一模一样,此处就不再重复了。可以看到它是另一个dubbo provider实例,它会运行在20981端口,同时请注意它的dubbo.application.name,和seata-product-service是不同的。
DruidConfig.java
package org.sky.stock.config;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import com.alibaba.druid.pool.DruidDataSource;
import com.alibaba.druid.support.http.StatViewServlet;
import com.alibaba.druid.support.http.WebStatFilter;
import io.seata.rm.datasource.DataSourceProxy;
import io.seata.spring.annotation.GlobalTransactionScanner;
@Configuration
public class DruidConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DruidDataSource druidDataSource() {
return new DruidDataSource();
}
/**
* init datasource proxy
*
* @Param: druidDataSource datasource bean instance
* @Return: DataSourceProxy datasource proxy
*/
@Bean
public DataSourceProxy dataSourceProxy(DruidDataSource druidDataSource) {
return new DataSourceProxy(druidDataSource);
}
@Bean
public DataSourceTransactionManager transactionManager(DataSourceProxy dataSourceProxy) {
return new DataSourceTransactionManager(dataSourceProxy);
}
/**
* init jdbc template by using the dataSourceProxy
*
* @Return: JdbcTemplate
*/
@Bean
public JdbcTemplate dataSource(DataSourceProxy dataSourceProxy) {
return new JdbcTemplate(dataSourceProxy);
}
/**
* init global transaction scanner
*
* @Return: GlobalTransactionScanner
*/
@Bean
public GlobalTransactionScanner globalTransactionScanner() {
return new GlobalTransactionScanner("seata-stock-service", "demo-tx-grp");
}
可以看到此处的GlobalTransactionScanner的第一个参数是贴合着dubbo.application.name的。
StockDAO.java
package org.sky.stock.dao;
import org.sky.exception.DemoRpcRunTimeException;
public interface StockDAO {
public void addNewStock(long productId, int stock) throws DemoRpcRunTimeException;
}
StockDAOImpl.java
package org.sky.stock.dao;
import javax.annotation.Resource;
import org.sky.dao.BaseDAO;
import org.sky.exception.DemoRpcRunTimeException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Component
public class StockDAOImpl extends BaseDAO implements StockDAO {
@Resource
private JdbcTemplate jdbcTemplate;
@Override
public void addNewStock(long productId, int stock) throws DemoRpcRunTimeException {
String prodSql = "insert into t_stock(product_id,stock)values(?,?)";
try {
jdbcTemplate.update(prodSql, productId, stock);
} catch (Exception e) {
throw new DemoRpcRunTimeException(e.getMessage(), e);
}
}
}
StockBizServiceImpl.java
package org.sky.stock.service.biz;
import org.sky.exception.DemoRpcRunTimeException;
import org.sky.service.BaseService;
import org.sky.stock.dao.StockDAO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class StockBizServiceImpl extends BaseService implements StockBizService {
@Autowired
private StockDAO stockDAO;
@Override
public void addStock(long productId, int stock) throws DemoRpcRunTimeException {
try {
stockDAO.addNewStock(productId, stock);
} catch (Exception e) {
throw new DemoRpcRunTimeException("error occured on Biz Service Side: " + e.getMessage(), e);
}
}
}
StockDubboServiceImpl.java
package org.sky.stock.service.dubbo;
import org.apache.dubbo.config.annotation.Service;
import org.sky.exception.DemoRpcRunTimeException;
import org.sky.service.BaseService;
import org.sky.stock.service.biz.StockBizService;
import org.springframework.beans.factory.annotation.Autowired;
@Service(version = "1.0.0", interfaceClass = StockDubboService.class, timeout = 30000,loadbalance = "roundrobin")
public class StockDubboServiceImpl extends BaseService implements StockDubboService {
@Autowired
private StockBizService stockBizService;
@Override
public void addStock(long productId, int stock) throws DemoRpcRunTimeException {
try {
stockBizService.addStock(productId, stock);
logger.info("======>insert into t_stock with successful\n data:\n productid: " + productId
+ "\n stock: " + stock);
} catch (Exception e) {
logger.error("error occured on insert stock with productid->: " + productId + " and stock->" + stock
+ e.getMessage(), e);
throw new DemoRpcRunTimeException("error occured on insert stock with productid->: " + productId
+ " and stock->" + stock + e.getMessage(), e);
}
}
}
启动用Application.java
package org.sky.stock;
import org.apache.dubbo.config.spring.context.annotation.EnableDubbo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@EnableDubbo
@EnableAutoConfiguration
@ComponentScan(basePackages = { "org.sky.stock" })
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
同样,我们把它启动起来。
seata-demo-consumer工程
这个就是一个纯 spring boot controller工程了,它给我们提供一个人机界面,我们可以通过它post一个json请求以达到后端两个个不同的dubbo provider service向两个不同的数据库插入数据,如果有任意错误也可以进行全部的回滚。
pom.xml
4.0.0
org.sky.demo
seata-demo-consumer
0.0.1-SNAPSHOT
org.sky.demo
nacos-parent
0.0.1-SNAPSHOT
war
Demo project Dubbo+Nacos+Seata
org.apache.dubbo
dubbo
org.apache.curator
curator-recipes
org.springframework.boot
spring-boot-starter-test
test
org.spockframework
spock-core
test
org.spockframework
spock-spring
org.springframework.boot
spring-boot-configuration-processor
true
org.springframework.boot
spring-boot-starter-log4j2
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-logging
org.springframework.boot
spring-boot-starter-tomcat
org.springframework.boot
spring-boot-starter-tomcat
compile
org.aspectj
aspectjweaver
com.lmax
disruptor
redis.clients
jedis
com.google.guava
guava
com.alibaba
fastjson
org.apache.dubbo
dubbo-registry-nacos
com.alibaba.nacos
nacos-client
org.sky.demo
skycommon
${skycommon.version}
io.seata
seata-all
com.alibaba.boot
nacos-config-spring-boot-starter
nacos-client
com.alibaba.nacos
org.apache.curator
curator-framework
org.apache.curator
curator-recipes
io.netty
netty-all
org.projectlombok
lombok
javax.servlet
javax.servlet-api
${javax.servlet.version}
provided
src/main/java
src/test/java
org.springframework.boot
spring-boot-maven-plugin
src/main/resources
src/main/webapp
META-INF/resources
**/**
src/main/resources
true
application.properties
application-${profileActive}.properties
application.properties
此处的application.properties相对provider工程来说会比较简单,它也不需要数据库连接信息。
server.port=8082
server.tomcat.maxConnections=300
server.tomcat.maxThreads=300
server.tomcat.uriEncoding=UTF-8
server.tomcat.maxThreads=300
server.tomcat.minSpareThreads=150
server.connectionTimeout=20000
server.tomcat.maxHttpPostSize=0
server.tomcat.acceptCount=300
#Dubbo provider configuration
dubbo.protocol.id=dubbo
dubbo.protocol.name=dubbo
dubbo.application.name=demo-seata-consumer
dubbo.application.id=demo-seata-consumer
dubbo.registry.protocol=dubbo
dubbo.registry.address=nacos://192.168.56.101:8848
#dubbo.consumer.time=120000
logging.config=classpath:log4j2.xml
但是一样,它也需要连接nacos来Reference dubbo provider的应用。我们接下来看它的基于于spring boot的自动装配,SeataAutoConfig.java,由于它根本不需要连接什么DB,因此它里面只有一个GlobalTransactionScanner。
SeataAutoConfig.java
package org.sky.seatademo.config;
import io.seata.spring.annotation.GlobalTransactionScanner;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SeataAutoConfig {
/**
* init global transaction scanner
*
* @Return: GlobalTransactionScanner
*/
@Bean
public GlobalTransactionScanner globalTransactionScanner() {
return new GlobalTransactionScanner("demo-seata-consumer", "demo-tx-grp");
}
}
注意:这边的GlobalTransactionScanner中的第一个参数,这个参数是seata-demo-consumer的dubbo.application.name,同时也是后面我们要生效的Global Transaction的Scope。
来看核心业务逻辑调用:
BusinessDubboServiceImpl.java
package org.sky.seatademo.service.biz;
import org.apache.dubbo.config.annotation.Reference;
import org.sky.exception.DemoRpcRunTimeException;
import org.sky.product.service.dubbo.ProductDubboService;
import org.sky.seatademo.vo.SeataProductVO;
import org.sky.service.BaseService;
import org.sky.stock.service.dubbo.StockDubboService;
import org.springframework.stereotype.Service;
import io.seata.core.context.RootContext;
import io.seata.spring.annotation.GlobalTransactional;
@Service
public class BusinessDubboServiceImpl extends BaseService implements BusinessDubboService {
@Reference(version = "1.0.0")
private ProductDubboService productDubboService;
@Reference(version = "1.0.0")
private StockDubboService stockDubboService;
@Override
@GlobalTransactional(timeoutMills = 300000, name = "seata-at-service")
public SeataProductVO addProductAndStock(SeataProductVO vo) throws DemoRpcRunTimeException {
SeataProductVO rtnProduct = new SeataProductVO();
try {
// logger.info("======>start global transaction: " + RootContext.getXID());
long prodId = productDubboService.addProduct(vo.getProductName());
stockDubboService.addStock(prodId, vo.getStock());
rtnProduct.setProductId(prodId);
rtnProduct.setProductName(vo.getProductName());
rtnProduct.setStock(vo.getStock());
if (vo.getProductName().equalsIgnoreCase("donny")) {
throw new Exception("Mk Thru The Exception To Force Rollback");
} else {
return rtnProduct;
}
} catch (
Exception e) {
logger.error("error occured on dubbo BusinessService side: " + e.getMessage(), e);
throw new DemoRpcRunTimeException("error occured on dubbo BusinessService side: " + e.getMessage(), e);
}
}
@Override
@GlobalTransactional(timeoutMills = 120000, name = "demo-seata-consumer")
public SeataProductVO addProductAndStockFailed(SeataProductVO vo) throws DemoRpcRunTimeException {
SeataProductVO rtnProduct = new SeataProductVO();
try {
logger.info("======>start global transaction: " + RootContext.getXID());
long prodId = productDubboService.addProduct(vo.getProductName());
stockDubboService.addStock(prodId, vo.getStock());
rtnProduct.setProductId(prodId);
rtnProduct.setProductName(vo.getProductName());
rtnProduct.setStock(vo.getStock());
throw new Exception("Mk throw the exception to enforce rollback all transaction");
} catch (Exception e) {
logger.error("error occured on dubbo BusinessService side: " + e.getMessage(), e);
throw new DemoRpcRunTimeException("error occured on dubbo BusinessService side: " + e.getMessage(), e);
}
}
}
看到没有,首先:
它会@Reference两个dubbo provider的引用,然后在一个business方法内,它开始“切面”事务了,这边事务的范围正是用的这个name="demo-seata-consumer”来标记的。
同时,我们在这个Service中有一段逻辑,即当productName为donny时,那么故意我们会抛出一个错误来以迫使全局事务回滚!
必竟donny是一个人,它不是一个“合格”的商品!
值的高度注意的一点 ,为什么我说截止目前为止网上的例子都是跑不通的呢?
是因为,SEATA使用到的mysql里的几个用于作持久化作用的表的transaction_name和group_name字段都是32位长度的字符,而那些例子里的application_name和group_name都大于了32位,SEATA运行时就已经报错了更不要说后面还能把事务给做对了呢。
对于人机交五用界面用的SeataDemoController.java
package org.sky.seatademo.controller;
import java.util.HashMap;
import java.util.Map;
import org.apache.dubbo.config.annotation.Reference;
import org.sky.controller.BaseController;
import org.sky.seatademo.service.biz.BusinessDubboService;
import org.sky.seatademo.vo.SeataProductVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
@RestController
@RequestMapping("seatademo")
public class SeataDemoController extends BaseController {
@Autowired
private BusinessDubboService businessDubboService;
@PostMapping(value = "/addSeataProduct", produces = "application/json")
public ResponseEntity addSeataProduct(@RequestBody String params) throws Exception {
ResponseEntity response = null;
String returnResultStr;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON_UTF8);
Map result = new HashMap<>();
try {
JSONObject requestJsonObj = JSON.parseObject(params);
SeataProductVO inputProductPara = getSeataProductFromJson(requestJsonObj);
SeataProductVO returnData = businessDubboService.addProductAndStock(inputProductPara);
result.put("code", HttpStatus.OK.value());
result.put("message", "add a new product successfully");
result.put("productid", returnData.getProductId());
result.put("productname", returnData.getProductName());
result.put("stock", returnData.getStock());
returnResultStr = JSON.toJSONString(result);
response = new ResponseEntity<>(returnResultStr, headers, HttpStatus.OK);
} catch (Exception e) {
logger.error("add a new product with error: " + e.getMessage(), e);
result.put("message", "add a new product with error: " + e.getMessage());
returnResultStr = JSON.toJSONString(result);
response = new ResponseEntity<>(returnResultStr, headers, HttpStatus.EXPECTATION_FAILED);
}
return response;
}
@PostMapping(value = "/addSeataProductFailed", produces = "application/json")
public ResponseEntity addSeataProductFailed(@RequestBody String params) throws Exception {
ResponseEntity response = null;
String returnResultStr;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON_UTF8);
Map result = new HashMap<>();
try {
JSONObject requestJsonObj = JSON.parseObject(params);
SeataProductVO inputProductPara = getSeataProductFromJson(requestJsonObj);
SeataProductVO returnData = businessDubboService.addProductAndStockFailed(inputProductPara);
result.put("code", HttpStatus.OK.value());
result.put("message", "add a new product successfully");
result.put("productid", returnData.getProductId());
result.put("productname", returnData.getProductName());
result.put("stock", returnData.getStock());
returnResultStr = JSON.toJSONString(result);
response = new ResponseEntity<>(returnResultStr, headers, HttpStatus.OK);
} catch (Exception e) {
logger.error("add a new product with error: " + e.getMessage(), e);
result.put("message", "add a new product with error: " + e.getMessage());
returnResultStr = JSON.toJSONString(result);
response = new ResponseEntity<>(returnResultStr, headers, HttpStatus.EXPECTATION_FAILED);
}
return response;
}
}
然后就是启动用Application.java
package org.sky.seatademo;
import org.apache.dubbo.config.spring.context.annotation.EnableDubbo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@EnableDubbo
@ServletComponentScan
@EnableAutoConfiguration
@ComponentScan(basePackages = { "org.sky.seatademo" })
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
这个工程我们把它做成了可以用于在tomcat里部署的war包的形式,因为有不少读者习惯于用tomcat来部署,此处用于演示如何把springboot的工程做成war。
我们把它也启动起来
这是seata server后端的日志显示
nacos中对于seatar的配置的显示
我们先post一段正常的商品信息
{
"productname" : "apple",
"stock" : 10000
}
看上面,我们可以看到TM在进行全局事务的commit。
我们再来提交一个“不正常”的请求
{
"productname" : "donny",
"stock" : 10000
}
我们可以看到它触发了逻辑抛错
分别观察两个provider的后端
看,TM协调下,RM里的相应的远程Service进行了rollback。
再来看数据库端
刚才的那条叫donny的“劣质商品”没有被插进去,它被SEATA给回滚了。
我们接下来要开始详细讲述基于springboot+dubbo+nacos+seata如何实现TCC事务的实现,我们会完成一个跨行转帐的实例。
前面我们讲述了SEATA的基于2PC的AT事务实战篇。在下篇中我们将会非常详细的描述一下如何利用SEATA来实现TCC事务补偿机制的原理。
还记得我们在前面曾经出现过这么一个例子用于详细描述TCC描述事务的原理吧?
现在我们就会围绕着这个例子来进一步用代码演示它。所有代码我已经上传到了我的GIT上了,地址在这:https://github.com/mkyuangithub/mkyuangithub
我们假设有这么一个业务场景:
你的公司是一家叫moneyking的第三方支付公司,连接着几个主要的银行支付渠道;
现在有一个帐户A要通过工行向另一个位于招商银行的B帐户转帐;
转帐要么成功要么失败。
于是我们结合着例子创建了3个项目:
tcc-bank-cmb
tcc-bank-icbc
tcc-money-king
tcc-bank-cmb和tcc-bank-icbc都是dubbo provider,它们分别连接着自己的数据库(不同的url)。
两个不同的schema,一个schema叫bank_icbc,一个schema叫bank_cmb,每个schema中的表结构相同。
我们下面给出相关的建表语句,每个业务表内的undo_log请各位看前面篇幅中所介绍的内容(内含有undo_log表建表语句)。
CREATE TABLE `bank_account` (
`account_id` varchar(32) COLLATE utf8_bin NOT NULL,
`amount` double(11,2) DEFAULT '0.00',
`freezed_amount` double(11,2) DEFAULT '0.00',
PRIMARY KEY (`account_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
工程详细讲解tcc-bank-icbc和tcc-bank-cmb分别连接着这2个schema。而tcc-money-king就是一个consumer,它来模拟你所在的那家第三方支付公司,所有的客户都是通过tcc-money-king来进行转帐的。
如前面内容一样,我们在讲述具体的代码前先要把tcc如何在SEATA中实现的一些个坑给“填了”。
和SEATA中的AT模式不同TCC的全局事务不需要你设置datasourceProxy代理,它只需要把事务范围和事务组申明好就可以了。
我们这边的事务组如下所示:
事务组:demo-tx-grp
事务边界:tcc-bank-sample,此处这个边界就是指的是tcc-money-king项目的dubbo.application.name。
在我们的tcc-money-king中有一个业务方法,在这个业务方法中只需要如此使用@GlobalTransaction申明即可启用SEATA的tcc机制。
没错!截止发稿稿为止seata-1.0GA的tcc不支持@Service, @Reference这样注解方式的dubbo发布,它虽然不会出错可是会使得整个tcc事务失效(AT模式中是完全可以使用注解模式的,TCC模式目前还不支持),只有那些使用普通的spring的.xml配置来申明的provider和reference才能享受tcc的“盛餐”。
那么这对于我们的spring boot工程来说岂不是很“恶心”的一件事?不要急,笔者已经探索出来了一条“熊掌与鱼兼得”法,即混用springboot和普通spring .xml文件配置。
即,只对dubbo bean进行.xml配置而对其它我们坚持可以使用spring boot的全注解方法来搭建整个项目的框架,见下例。
这边除了dubbo和一个比较特殊的transactionTemplate需要使用.xml,其它我们照样可以使用spring boot的全注解配置yyaa,只需要在我们的XxxConfig文件内写上这么一句即可:
@Configuration
@ImportResource(locations = { "spring/spring-bean.xml", "spring/dubbo-bean.xml" })
public class TccBankConfig {
然后在你使用到的地方比如说我们在tcc-money-king中使用了.xml文件配置一个dubbo的引用,那么此时你只需要在你要Reference的Service方法内@Autowired一下即可,如下例:
整个tcc它围绕着try, confirm, cancel这3个方法来运作的。这使得你需要使用tcc事务的话就必须对原有代码有侵入性。可是SEATA在这方面做的很好,它j通过远程调用、AOP来做的全局事务切入进而实现这一过程的。
所以在seata tcc编程中最最重要的有这么几个元素:
Global Transactional
Transaction Template
Transaction Manager
下面我们就以实例来感受seata tcc是如何做到尽量少侵入业务代码、又能做到性能最优、同时做到数据的最终一致性吧。
pom.xml
4.0.0
org.sky.demo
nacos-parent
0.0.1-SNAPSHOT
org.sky.demo
tcc-bank-icbc
0.0.1
tcc-bank-icbc
Demo project Dubbo+Nacos+SeataTCC
UTF-8
org.mybatis
mybatis
org.mybatis
mybatis-spring
org.springframework.boot
spring-boot-starter-jdbc
org.springframework.boot
spring-boot-starter-logging
org.apache.dubbo
dubbo
org.apache.curator
curator-framework
org.apache.curator
curator-recipes
mysql
mysql-connector-java
com.alibaba
druid
org.springframework.boot
spring-boot-starter-test
test
org.spockframework
spock-core
test
org.spockframework
spock-spring
org.springframework.boot
spring-boot-configuration-processor
true
org.springframework.boot
spring-boot-starter-log4j2
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-logging
org.springframework.boot
spring-boot-starter-tomcat
org.aspectj
aspectjweaver
com.lmax
disruptor
redis.clients
jedis
com.google.guava
guava
com.alibaba
fastjson
org.apache.dubbo
dubbo-registry-nacos
com.alibaba.nacos
nacos-client
org.sky.demo
skycommon
${skycommon.version}
io.seata
seata-all
com.alibaba.boot
nacos-config-spring-boot-starter
nacos-client
com.alibaba.nacos
io.netty
netty-all
org.projectlombok
lombok
${project.artifactId}
src/main/java
src/test/java
org.apache.maven.plugins
maven-compiler-plugin
org.springframework.boot
spring-boot-maven-plugin
-Dfile.encoding=UTF-8
repackage
org.apache.maven.plugins
maven-war-plugin
2.6
false
src/main/resources
application*.properties
src/main/webapp
META-INF/resources
**/**
src/main/resources
true
application.properties
application.properties
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driverClassName=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://192.168.56.101:3306/bank_icbc?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username=icbc
spring.datasource.password=111111
spring.datasource.initialize=false
spring.datasource.initialSize=5
spring.datasource.minIdle=5
spring.datasource.maxActive: 20
spring.datasource.maxWait: 30000
spring.datasource.validationQuery=SELECT 1 FROM DUAL
spring.datasource.testWhileIdle=true
spring.datasource.testOnBorrow=false
spring.datasource.testOnReturn=false
spring.datasource.poolPreparedStatements=true
spring.datasource.maxPoolPreparedStatementPerConnectionSize=128
logging.config=classpath:log4j2.xml
由于我们对于dubbo需要使用.xml文件的方式配置,因此我们的application.properties文件内容相对简单。
TccBankConfig.java
package org.sky.tcc.bank.icbc.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import com.alibaba.druid.pool.DruidDataSource;
import io.seata.spring.annotation.GlobalTransactionScanner;
@Configuration
@ImportResource(locations = { "spring/spring-bean.xml", "spring/dubbo-bean.xml" })
public class TccBankConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DruidDataSource druidDataSource() {
return new DruidDataSource();
}
@Bean
public DataSourceTransactionManager transactionManager(DruidDataSource druidDataSource) {
return new DataSourceTransactionManager(druidDataSource);
}
@Bean
public JdbcTemplate jdbcTemplate(DruidDataSource druidDataSource) {
return new JdbcTemplate(druidDataSource);
}
@Bean
public GlobalTransactionScanner globalTransactionScanner() {
return new GlobalTransactionScanner("tcc-bank-icbc", "demo-tx-grp");
}
}
这个就是我们的全局配置类,在这个配置类内对于datasource,transaction manager, global transactional我们使用的是全注解。
我们在spring/spring-bean.xml文件内申明了transactional template
spring/spring-bean.xml
PROPAGATION_REQUIRES_NEW
对于dubbo我们使用的是spring/dubbo-bean.xml来配置的
spring/dubbo-bean.xml
我们可以看到在这个dubbo-bean.xml文件中我们配置了一个核心的org.sky.tcc.bank.icbc.dubbo.MinusMoneyAction,我们先来看这个MinusMoneyAction。
因为我们是从:
工行划款;
招行打款;
因此我们相应的在tcc-bank-cmb中还有一个核心的dubbo叫PlusMoneyAction。
MinusMoneyAction的接口类,注意此接口类为一个“残根”即“被调用者”,因此我们把它放置于了skycommon工程内了。
MinusMoneyAction.java
package org.sky.tcc.bank.icbc.dubbo;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
public interface MinusMoneyAction {
public String sayHello() throws RuntimeException;
/**
* 一阶段从from帐户扣钱
*
* @param businessActionContext
* @param accountNo
* @param amount
*/
@TwoPhaseBusinessAction(name = "minusMoneyAction", commitMethod = "commit", rollbackMethod = "rollback")
public boolean prepareMinus(BusinessActionContext businessActionContext,
@BusinessActionContextParameter(paramName = "accountNo") String accountNo,
@BusinessActionContextParameter(paramName = "amount") double amount);
/**
* 二阶段提交
*
* @param businessActionContext
* @return
*/
public boolean commit(BusinessActionContext businessActionContext);
/**
* 二阶段回滚
*
* @param businessActionContext
* @return
*/
public boolean rollback(BusinessActionContext businessActionContext);
}
我们可以通过实现类看到它其实是事先了tcc的3个阶段:
commit方法对confirm
rollback方法对cancel
prepareMinus方法对try
这3个方法的实现就是让我们在尽量少破坏业务代码的方法下实现tcc补偿式事务的。这3个方法是相当特殊的,它们的调用为“被SEATA server端全自动异步回调”,即不需要你try if xxx catch exception rollback的,你要做的只是告诉业务方法在何种状态它应该要rollback;何种状态属于调用成功即自动commit。一切都是自动的。
而这边的commit也不是我们传统意义的数据库层面的commit。
让我们来一起看一下它的实现类
MinusMoneyActionImpl.java
package org.sky.tcc.bank.icbc.dubbo;
import org.sky.service.BaseService;
import org.sky.tcc.bank.icbc.dao.TransferMoneyDAO;
import org.sky.tcc.bean.AccountBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
public class MinusMoneyActionImpl extends BaseService implements MinusMoneyAction {
/**
* 扣钱账户 DAO
*/
@Autowired
private TransferMoneyDAO transferMoneyDAO;
/**
* 扣钱数据源事务模板
*/
@Autowired
private TransactionTemplate transactionTemplate;
@Override
public String sayHello() throws RuntimeException {
return "hi I am icbc-dubbo";
}
@Override
public boolean prepareMinus(BusinessActionContext businessActionContext, String accountNo, double amount) {
logger.info("==========into prepareMinus");
// 分布式事务ID
final String xid = RootContext.getXID();
return transactionTemplate.execute(new TransactionCallback() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
// 校验账户余额
AccountBean account = transferMoneyDAO.getAccountForUpdate(accountNo);
if (account == null) {
throw new RuntimeException("账户不存在");
}
if (account.getAmount() - amount < 0) {
throw new RuntimeException("余额不足");
}
// 冻结转账金额
double freezedAmount = account.getFreezedAmount() + amount;
account.setFreezedAmount(freezedAmount);
transferMoneyDAO.updateFreezedAmount(account);
logger.info(String.format("======>prepareMinus account[%s] amount[%f], dtx transaction id: %s.",
accountNo, amount, xid));
return true;
} catch (Throwable t) {
logger.error("======>error occured in MinusMoneyActionImpl.prepareMinus: " + t.getMessage(),
t.getCause());
status.setRollbackOnly();
return false;
}
}
});
}
@Override
public boolean commit(BusinessActionContext businessActionContext) {
logger.info("======>into MinusMoneyActionImpl.commit() method");
// 分布式事务ID
final String xid = RootContext.getXID();
// 账户ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
// 转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return transactionTemplate.execute(new TransactionCallback() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
AccountBean account = transferMoneyDAO.getAccountForUpdate(accountNo);
// 扣除账户余额
double newAmount = account.getAmount() - amount;
if (newAmount < 0) {
throw new RuntimeException("余额不足");
}
account.setAmount(newAmount);
// 释放账户 冻结金额
account.setFreezedAmount(account.getFreezedAmount() - amount);
transferMoneyDAO.updateAmount(account);
logger.info(String.format("======>minus account[%s] amount[%f], dtx transaction id: %s.", accountNo,
amount, xid));
return true;
} catch (Throwable t) {
logger.error("======>error occured in MinusMoneyActionImpl.commit: " + t.getMessage(),
t.getCause());
status.setRollbackOnly();
return false;
}
}
});
}
@Override
public boolean rollback(BusinessActionContext businessActionContext) {
logger.info("======>into MinusMoneyActionImpl.rollback() method");
// 分布式事务ID
final String xid = RootContext.getXID();
// 账户ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
// 转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return transactionTemplate.execute(new TransactionCallback() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
AccountBean account = transferMoneyDAO.getAccountForUpdate(accountNo);
if (account == null) {
// 账户不存在,回滚什么都不做
return true;
}
// 释放冻结金额
if (account.getFreezedAmount() >= amount) {
account.setFreezedAmount(account.getFreezedAmount() - amount);
transferMoneyDAO.updateFreezedAmount(account);
}
logger.info(
String.format("======>Undo prepareMinus account[%s] amount[%f], dtx transaction id: %s.",
accountNo, amount, xid));
return true;
} catch (Throwable t) {
logger.error("======>error occured in MinusMoneyActionImpl.rollback: " + t.getMessage(),
t.getCause());
status.setRollbackOnly();
return false;
}
}
});
}
}
从以上代码我们可以看到它是一个“工行划款”的全过程。
一开始它会从prepareMinus方法走起,你在consumer端只需要调用这个prepareMinus然后后面的commit与rollback是SEATA根据业务方法执行的状态自动回调并决定后一步调用到底是调用commit还是调用rollback的,即在consumer端的业务方法内是不含有commit和rollback的。
此处的PrepareMinus要做的事就是:
先检查帐户是否存,如果不存在直接抛出一个RuntimeException迫使全局事务走后面的rollback分支而不继续走数据库的commit也不走该MinusMoneyAction中的commit(第二步);
如果要转帐的数额大于余额,那肯定也不行的,会抛错;
检查好了,它这边开始做业务幂等了,即为了后面的业务rollback做准备,它会先把一个“冻结余额”+“转帐额”。
这就是prepare阶段,prepare阶段如果成功SEATA会自动走下一步commit,如果遇到有问题就可以运行rollback方法。
那么我们来看业务commit(即confirm)方法吧:
经过了上述的prepare过程,一切无误,那么我们就要开始扣款了。为了做到业务幂等,在此要再做一次余额校验,因为spring中的bean都是“非线程安全”的,此时可能由于并发操作的原因,在过了commit方法后实际数据库内的余额因为其它生产上的一些业务方法导致了这个余额已经低于转帐额了,因此在这里要再做一次校验,如果校验不通过那么抛出RuntimeException。
把要转帐帐户from_account扣去转帐额然后把中间状态 freezed_amount-转帐额以还原到原有状态,整个“工行阶段的业务 ”结束。
很多人在此处要问,为什么需要增加一个freezed_amount,直接扣不就完了。
是!你可以直接扣,可是我们前面说过幂等了,那么请问你在commit或者是在rollback时你会怎么回滚这个数据?
我们人操作的话就是原来转出10元,失败了,把10元退给原帐户!
因此我们这边拿了这个freezed_amount就是来做计算机可以认得的这个“中间暂存”变量。还记得我们在上篇中提到的业务幂等吗?我们需要保存一切中间状态以便于“业务回退/反交易”。
那么我们下面来看看这个“业务回退”是怎么样的,即rollback方法:
整个划款过程分为2个步骤,6个小步骤。它们是:minusMoneyAction, plusMoneyAction,每个步骤都有prepare,commit, rollback。
此处的rollback为业务rollback,即在6个分解的小步骤中有任何一步抛RuntimeException那么SEATA会自动触发两个大步骤中的rollback。
rollback要做的,拿icbc是扣款来说就是一个“业务回退”,它先查询该帐户是否存在,如果不存在那也不要做了,帐户不存在不存在任何抛错只要return true就可以了什么都不用做。这边的return true是什么意思尼?这叫空回滚。
所谓空回滚就是事务协调器在调用TCC服务的一阶段Try操作时,可能会出现因为丢包而导致的网络超时,此时事务协调器会触发二阶段回滚,调用TCC服务的Cancel操作;TCC服务在未收到Try请求的情况下收到Cancel请求,这种场景被称为空回滚;TCC服务在实现时应当允许空回滚的执行;如果你觉得前面这段话有点拗口,那么我们再说了白一点,看下图就能理解了
从上图看到,这个rollback以返回true来判定回滚成功,此时你要不给它true给它false或者是Exception的话它就会不断的尝试回滚,于是你在后台会看到一堆的try rollback but failed tryagain...要try多少次呢?它是依赖于SEATA server端的conf/nacos-config.txt中的这么几个参数来设定的。
client.tm.commit.retry.count=1
client.tm.rollback.retry.count=1
你现在理解为什么在rollback调用时如果检查到了帐户已经不存在,直接返回true而不需要再thru什么Exception或者是return false了吧?再加上你如果前面这2个retry.count参数没有设好,到时你就会限入“无限回滚”(因为默认这两个值是-1,代表无限尝试)的状态,最后把jvm给搞爆掉。
rollback中对于帐户检查完后如果没有问题那么接下来要做的就是把freezed_amount-要转帐额还原到原来的freezed_amount,并把余额还原回操作前的值即可
下面给出DAO的详细代码,DAO代码很简单,没什么需要多说的。
TransferMoneyDAO.java
package org.sky.tcc.bank.icbc.dao;
import org.sky.tcc.bean.AccountBean;
public interface TransferMoneyDAO {
public void addAccount(AccountBean account) throws Exception;
public int updateAmount(AccountBean account) throws Exception;
public AccountBean getAccount(String accountNo) throws Exception;
public AccountBean getAccountForUpdate(String accountNo) throws Exception;
public int updateFreezedAmount(AccountBean account) throws Exception;
}
TransferMoneyDAOImpl.java
package org.sky.tcc.bank.icbc.dao;
import java.sql.ResultSet;
import java.sql.SQLException;
import org.sky.tcc.bean.AccountBean;
import org.sky.tcc.dao.BaseDAO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
@Component
public class TransferMoneyDAOImpl extends BaseDAO implements TransferMoneyDAO {
@Autowired
private JdbcTemplate fromJdbcTemplate;
@Override
public void addAccount(AccountBean account) throws Exception {
String sql = "insert into bank_account(account_id,amount,freezed_amount) values(?,?,?)";
fromJdbcTemplate.update(sql, account.getAccountId(), account.getAmount(), account.getFreezedAmount());
}
@Override
public int updateAmount(AccountBean account) throws Exception {
String sql = "update bank_account set amount=?, freezed_amount=? where account_id=?";
int result = 0;
result = fromJdbcTemplate.update(sql, account.getAmount(), account.getFreezedAmount(), account.getAccountId());
return result;
}
@Override
public AccountBean getAccount(String accountNo) throws Exception {
String sql = "select account_id,amount,freezed_amount from bank_account where account_id=?";
AccountBean account = null;
// Object[] params = new Object[] { accountNo };
try {
account = fromJdbcTemplate.queryForObject(sql, new RowMapper() {
@Override
public AccountBean mapRow(ResultSet rs, int rowNum) throws SQLException {
AccountBean account = new AccountBean();
account.setAccountId(rs.getString("account_id"));
account.setAmount(rs.getDouble("amount"));
account.setFreezedAmount(rs.getDouble("freezed_amount"));
return account;
}
}, accountNo);
} catch (Exception e) {
logger.error("getAccount error: " + e.getMessage(), e);
account = null;
}
return account;
}
@Override
public AccountBean getAccountForUpdate(String accountNo) throws Exception {
String sql = "select account_id,amount,freezed_amount from bank_account where account_id=? for update";
AccountBean account = null;
// Object[] params = new Object[] { accountNo };
try {
account = fromJdbcTemplate.queryForObject(sql, new RowMapper() {
@Override
public AccountBean mapRow(ResultSet rs, int rowNum) throws SQLException {
AccountBean account = new AccountBean();
account.setAccountId(rs.getString("account_id"));
account.setAmount(rs.getDouble("amount"));
account.setFreezedAmount(rs.getDouble("freezed_amount"));
return account;
}
}, accountNo);
} catch (Exception e) {
logger.error("getAccount error: " + e.getMessage(), e);
return null;
}
return account;
}
@Override
public int updateFreezedAmount(AccountBean account) throws Exception {
String sql = "update bank_account set freezed_amount=? where account_id=?";
int result = 0;
result = fromJdbcTemplate.update(sql, account.getFreezedAmount(), account.getAccountId());
return result;
}
}
用于启用动的ICBCApplication。
此处因为我们用了.xml模式配置dubbo,因此可就不能使用@EnableDubbo了啊!
这是一个“招行打款”的dubbo provider,它和前面的工行扣款类似,也是实现了TCC的提交方式,只不过它要做的是“增加余额操作”。
其它逻辑和tcc-bank-icbc一样,我们在此看一下它的三个TCC吧。
PlusMoneyActionImpl.java
package org.sky.tcc.bank.cmb.dubbo;
import org.sky.service.BaseService;
import org.sky.tcc.bank.cmb.dao.TransferMoneyDAO;
import org.sky.tcc.bean.AccountBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
public class PlusMoneyActionImpl extends BaseService implements PlusMoneyAction {
@Autowired
private TransactionTemplate transactionTemplate;
@Autowired
private TransferMoneyDAO transferMoneyDAO;
@Override
public String sayHello() throws RuntimeException {
return "hi I am cmb-dubbo";
}
@Override
public boolean prepareAdd(BusinessActionContext businessActionContext, String accountNo, double amount) {
logger.info("======>inti prepare add");
final String xid = RootContext.getXID();
return transactionTemplate.execute(new TransactionCallback() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
// 校验账户
AccountBean account = transferMoneyDAO.getAccountForUpdate(accountNo);
if (account == null) {
logger.info(
"======>prepareAdd: 账户[" + accountNo + "]不存在, txId:" + businessActionContext.getXid());
return false;
}
// 待转入资金作为 不可用金额
double freezedAmount = account.getFreezedAmount() + amount;
account.setFreezedAmount(freezedAmount);
transferMoneyDAO.updateFreezedAmount(account);
logger.info(String.format(
"PlusMoneyActionImpl.prepareAdd account[%s] amount[%f], dtx transaction id: %s.", accountNo,
amount, xid));
return true;
} catch (Throwable t) {
logger.error("======>error occured in PlusMoneyActionImpl.prepareAdd: " + t.getMessage(),
t.getCause());
status.setRollbackOnly();
return false;
}
}
});
}
@Override
public boolean commit(BusinessActionContext businessActionContext) {
logger.info("======>into PlusMoneyActionImpl.commit() method");
// 分布式事务ID
final String xid = RootContext.getXID();
// 账户ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
// 转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return transactionTemplate.execute(new TransactionCallback() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
AccountBean account = transferMoneyDAO.getAccountForUpdate(accountNo);
// 加钱
double newAmount = account.getAmount() + amount;
account.setAmount(newAmount);
// 冻结金额 清除
account.setFreezedAmount(account.getFreezedAmount() - amount);
transferMoneyDAO.updateAmount(account);
logger.info(String.format("======>add account[%s] amount[%f], dtx transaction id: %s.", accountNo,
amount, xid));
return true;
} catch (Throwable t) {
logger.error("======>error occured in PlusMoneyActionImpl.commit: " + t.getMessage(), t.getCause());
status.setRollbackOnly();
return false;
}
}
});
}
@Override
public boolean rollback(BusinessActionContext businessActionContext) {
logger.info("======>into PlusMoneyActionImpl.rollback() method");
// 分布式事务ID
final String xid = RootContext.getXID();
// 账户ID
final String accountNo = String.valueOf(businessActionContext.getActionContext("accountNo"));
// 转出金额
final double amount = Double.valueOf(String.valueOf(businessActionContext.getActionContext("amount")));
return transactionTemplate.execute(new TransactionCallback() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
AccountBean account = transferMoneyDAO.getAccountForUpdate(accountNo);
if (account == null) {
// 账户不存在, 无需回滚动作
return true;
}
// 冻结金额 清除
if (account.getFreezedAmount() >= amount) {
account.setFreezedAmount(account.getFreezedAmount() - amount);
transferMoneyDAO.updateFreezedAmount(account);
}
logger.info(String.format("======>Undo account[%s] amount[%f], dtx transaction id: %s.", accountNo,
amount, xid));
return true;
} catch (Throwable t) {
logger.error("======>error occured in PlusMoneyActionImpl.rollback: " + t.getMessage(),
t.getCause());
status.setRollbackOnly();
return false;
}
}
});
}
}
prepareAdd阶段阶段,检查帐户如果有异常抛出RuntimeEcxeption让SEATA触发回滚(业务回滚+事务回滚)。如果无误那么把中间状态 freezed_amount+转入额。
commit阶段,帐户余额+转入金融,接着把freezed_amount-转入额度还原到原来的值。
rollback阶段,和minusMoneyAction逻辑一样,把冻结款-转帐额再把余额回退到操作前的值。
pom.xml
4.0.0
org.sky.demo
nacos-parent
0.0.1-SNAPSHOT
org.sky.demo
tcc-bank-cmb
0.0.1
tcc-bank-cmb
Demo project Dubbo+Nacos+SeataTCC
UTF-8
org.mybatis
mybatis
org.mybatis
mybatis-spring
org.springframework.boot
spring-boot-starter-jdbc
org.springframework.boot
spring-boot-starter-logging
org.apache.dubbo
dubbo
org.apache.curator
curator-framework
org.apache.curator
curator-recipes
mysql
mysql-connector-java
com.alibaba
druid
org.springframework.boot
spring-boot-starter-test
test
org.spockframework
spock-core
test
org.spockframework
spock-spring
org.springframework.boot
spring-boot-configuration-processor
true
org.springframework.boot
spring-boot-starter-log4j2
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-logging
org.springframework.boot
spring-boot-starter-tomcat
org.aspectj
aspectjweaver
com.lmax
disruptor
redis.clients
jedis
com.google.guava
guava
com.alibaba
fastjson
org.apache.dubbo
dubbo-registry-nacos
com.alibaba.nacos
nacos-client
org.sky.demo
skycommon
${skycommon.version}
io.seata
seata-all
com.alibaba.boot
nacos-config-spring-boot-starter
nacos-client
com.alibaba.nacos
io.netty
netty-all
org.projectlombok
lombok
${project.artifactId}
src/main/java
src/test/java
org.apache.maven.plugins
maven-compiler-plugin
org.springframework.boot
spring-boot-maven-plugin
-Dfile.encoding=UTF-8
repackage
org.apache.maven.plugins
maven-war-plugin
2.6
false
src/main/resources
application*.properties
src/main/webapp
META-INF/resources
**/**
src/main/resources
true
application.properties
application.properties
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driverClassName=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://192.168.56.101:3306/bank_cmb?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username=cmb
spring.datasource.password=111111
spring.datasource.initialize=false
spring.datasource.initialSize=5
spring.datasource.minIdle=5
spring.datasource.maxActive: 20
spring.datasource.maxWait: 30000
spring.datasource.validationQuery=SELECT 1 FROM DUAL
spring.datasource.testWhileIdle=true
spring.datasource.testOnBorrow=false
spring.datasource.testOnReturn=false
spring.datasource.poolPreparedStatements=true
spring.datasource.maxPoolPreparedStatementPerConnectionSize=128
logging.config=classpath:log4j2.xml
spring/dubbo-bean.xml
spring/spirng-bean.xml
PROPAGATION_REQUIRES_NEW
自动装配用TccBankConfig.java,这边要注意的是此处的GlobalTransaction里的名字可必须是tcc-bank-cmb啦,不要复制粘贴后忘改了。
TccBankConfig.java
package org.sky.tcc.bank.cmb.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import com.alibaba.druid.pool.DruidDataSource;
import io.seata.spring.annotation.GlobalTransactionScanner;
@Configuration
@ImportResource(locations = { "spring/spring-bean.xml", "spring/dubbo-bean.xml" })
public class TccBankConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DruidDataSource druidDataSource() {
return new DruidDataSource();
}
@Bean
public DataSourceTransactionManager transactionManager(DruidDataSource druidDataSource) {
return new DataSourceTransactionManager(druidDataSource);
}
@Bean
public JdbcTemplate jdbcTemplate(DruidDataSource druidDataSource) {
return new JdbcTemplate(druidDataSource);
}
@Bean
public GlobalTransactionScanner globalTransactionScanner() {
return new GlobalTransactionScanner("tcc-bank-cmb", "demo-tx-grp");
}
}
TransferMoneyDAO.java
package org.sky.tcc.bank.cmb.dao;
import org.sky.tcc.bean.AccountBean;
public interface TransferMoneyDAO {
public void addAccount(AccountBean account) throws Exception;
public int updateAmount(AccountBean account) throws Exception;
public AccountBean getAccount(String accountNo) throws Exception;
public AccountBean getAccountForUpdate(String accountNo) throws Exception;
public int updateFreezedAmount(AccountBean account) throws Exception;
}
TransferMoneyDAOImpl.java
package org.sky.tcc.bank.cmb.dao;
import java.sql.ResultSet;
import java.sql.SQLException;
import org.sky.tcc.bean.AccountBean;
import org.sky.tcc.dao.BaseDAO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
@Component
public class TransferMoneyDAOImpl extends BaseDAO implements TransferMoneyDAO {
@Autowired
private JdbcTemplate toJdbcTemplate;
@Override
public void addAccount(AccountBean account) throws Exception {
String sql = "insert into bank_account(account_id,amount,freezed_amount) values(?,?,?)";
toJdbcTemplate.update(sql, account.getAccountId(), account.getAmount(), account.getFreezedAmount());
}
@Override
public int updateAmount(AccountBean account) throws Exception {
String sql = "update bank_account set amount=?, freezed_amount=? where account_id=?";
int result = 0;
result = toJdbcTemplate.update(sql, account.getAmount(), account.getFreezedAmount(), account.getAccountId());
return result;
}
@Override
public AccountBean getAccount(String accountNo) throws Exception {
String sql = "select account_id,amount,freezed_amount from bank_account where account_id=?";
AccountBean account = null;
// Object[] params = new Object[] { accountNo };
try {
account = toJdbcTemplate.queryForObject(sql, new RowMapper() {
@Override
public AccountBean mapRow(ResultSet rs, int rowNum) throws SQLException {
AccountBean account = new AccountBean();
account.setAccountId(rs.getString("account_id"));
account.setAmount(rs.getDouble("amount"));
account.setFreezedAmount(rs.getDouble("freezed_amount"));
return account;
}
}, accountNo);
} catch (Exception e) {
logger.error("getAccount error: " + e.getMessage(), e);
account = null;
}
return account;
}
@Override
public AccountBean getAccountForUpdate(String accountNo) throws Exception {
String sql = "select account_id,amount,freezed_amount from bank_account where account_id=? for update";
AccountBean account = null;
// Object[] params = new Object[] { accountNo };
try {
account = toJdbcTemplate.queryForObject(sql, new RowMapper() {
@Override
public AccountBean mapRow(ResultSet rs, int rowNum) throws SQLException {
AccountBean account = new AccountBean();
account.setAccountId(rs.getString("account_id"));
account.setAmount(rs.getDouble("amount"));
account.setFreezedAmount(rs.getDouble("freezed_amount"));
return account;
}
}, accountNo);
} catch (Exception e) {
logger.error("getAccount error: " + e.getMessage(), e);
account = null;
}
return account;
}
@Override
public int updateFreezedAmount(AccountBean account) throws Exception {
String sql = "update bank_account set freezed_amount=? where account_id=?";
int result = 0;
result = toJdbcTemplate.update(sql, account.getFreezedAmount(), account.getAccountId());
return result;
}
}
用于启用动的CMBApplication,此处因为我们用了.xml模式配置dubbo,因此可就不能使用@EnableDubbo了哦再次提醒一次。
CMBApplication.java
package org.sky.tcc.bank.cmb;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
@EnableAutoConfiguration
@ComponentScan(basePackages = { "org.sky.tcc.bank" })
public class CMBApplication {
public static void main(String[] args) {
SpringApplication.run(CMBApplication.class, args);
}
}
到此为止两个dubbo provider制作完成,我们把它们分别运行起来。
启动之前我放出此次在生产环境调整过的nacos-config.txt文件,你只要在nacos服务启动的情况下重新在seata/conf下就可以了。
./nacos-config.sh localhost
seata/conf/nacos-config.txt
transport.type=TCP
transport.server=NIO
transport.heartbeat=true
transport.thread-factory.boss-thread-prefix=NettyBoss
transport.thread-factory.worker-thread-prefix=NettyServerNIOWorker
transport.thread-factory.server-executor-thread-prefix=NettyServerBizHandler
transport.thread-factory.share-boss-worker=false
transport.thread-factory.client-selector-thread-prefix=NettyClientSelector
transport.thread-factory.client-selector-thread-size=1
transport.thread-factory.client-worker-thread-prefix=NettyClientWorkerThread
transport.thread-factory.boss-thread-size=1
transport.thread-factory.worker-thread-size=8
transport.shutdown.wait=3
service.vgroup_mapping.demo-tx-grp=default
service.default.grouplist=192.168.56.101:8091
service.enableDegrade=false
service.disable=false
service.max.commit.retry.timeout=10000
service.max.rollback.retry.timeout=3
client.async.commit.buffer.limit=10000
client.lock.retry.internal=3
client.lock.retry.times=3
client.lock.retry.policy.branch-rollback-on-conflict=true
client.table.meta.check.enable=true
client.report.retry.count=1
client.tm.commit.retry.count=1
client.tm.rollback.retry.count=1
store.mode=db
store.file.dir=file_store/data
store.file.max-branch-session-size=16384
store.file.max-global-session-size=512
store.file.file-write-buffer-cache-size=16384
store.file.flush-disk-mode=async
store.file.session.reload.read_size=100
store.db.datasource=druid
store.db.db-type=mysql
store.db.driver-class-name=com.mysql.jdbc.Driver
store.db.url=jdbc:mysql://192.168.56.101:3306/seata?useUnicode=true
store.db.user=seata
store.db.password=111111
store.db.min-conn=1
store.db.max-conn=3
store.db.global.table=global_table
store.db.branch.table=branch_table
store.db.query-limit=100
store.db.lock-table=lock_table
recovery.committing-retry-period=1000
recovery.asyn-committing-retry-period=1000
recovery.rollbacking-retry-period=1000
recovery.timeout-retry-period=1000
transaction.undo.data.validation=true
transaction.undo.log.serialization=jackson
transaction.undo.log.save.days=1
transaction.undo.log.delete.period=86400000
transaction.undo.log.table=undo_log
transport.serialization=seata
transport.compressor=none
metrics.enabled=false
metrics.registry-type=compact
metrics.exporter-list=prometheus
metrics.exporter-prometheus-port=9898
support.spring.datasource.autoproxy=false
tcc-bank-icbc运行在28880端口;
tcc-bank-cmb运行在29990端口;
tcc-money-king工程
为了全真模拟生产,我们制作了一个spring boot的consumer,在这个工程里我们依然使用springboot+xml配置混合的方式,关键在该工程的业务方法内,我们看下去。
pom.xml
4.0.0
org.sky.demo
nacos-parent
0.0.1-SNAPSHOT
org.sky.demo
tcc-money-king
0.0.1
war
Demo project Dubbo+Nacos+SeataTCC Consumer
org.apache.dubbo
dubbo
org.apache.curator
curator-recipes
org.springframework.boot
spring-boot-starter-test
test
org.spockframework
spock-core
test
org.spockframework
spock-spring
org.springframework.boot
spring-boot-configuration-processor
true
org.springframework.boot
spring-boot-starter-log4j2
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-logging
org.springframework.boot
spring-boot-starter-tomcat
org.springframework.boot
spring-boot-starter-tomcat
compile
org.aspectj
aspectjweaver
com.lmax
disruptor
redis.clients
jedis
com.google.guava
guava
com.alibaba
fastjson
org.apache.dubbo
dubbo-registry-nacos
com.alibaba.nacos
nacos-client
org.sky.demo
skycommon
${skycommon.version}
io.seata
seata-all
com.alibaba.boot
nacos-config-spring-boot-starter
nacos-client
com.alibaba.nacos
org.apache.curator
curator-framework
org.apache.curator
curator-recipes
io.netty
netty-all
org.projectlombok
lombok
javax.servlet
javax.servlet-api
${javax.servlet.version}
provided
src/main/java
src/test/java
org.springframework.boot
spring-boot-maven-plugin
src/main/resources
src/main/webapp
META-INF/resources
**/**
src/main/resources
true
application.properties
application-${profileActive}.properties
application.properties
server.port=8082
server.tomcat.maxConnections=300
server.tomcat.maxThreads=300
server.tomcat.uriEncoding=UTF-8
server.tomcat.maxThreads=300
server.tomcat.minSpareThreads=150
server.connectionTimeout=20000
server.tomcat.maxHttpPostSize=0
server.tomcat.acceptCount=300
logging.config=classpath:log4j2.xml
spring/dubbo-reference.xml
spring boot自动注解用SeataAutoConfig.java
package org.sky.tcc.moneyking.config;
import io.seata.spring.annotation.GlobalTransactionScanner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
@Configuration
@ImportResource(locations = { "spring/dubbo-reference.xml" })
public class SeataAutoConfig {
@Bean
public GlobalTransactionScanner globalTransactionScanner() {
return new GlobalTransactionScanner("tcc-bank-sample", "demo-tx-grp");
}
}
这边的GlobalTRansactionScanner里的第一个参数可就是事务边界了啊,注意这边的事务group必须和SEATA端的nacos-config.txt内配置的完全一致。
TccMoneyKingBizService.java
package org.sky.tcc.moneyking.service.biz;
import org.sky.exception.DemoRpcRunTimeException;
public interface TccMoneyKingBizService {
public boolean transfer(String from, String to, double amount) throws DemoRpcRunTimeException;
}
核心业务方法TccMoneyKingBizServiceImpl.java
package org.sky.tcc.moneyking.service.biz;
import org.sky.exception.DemoRpcRunTimeException;
import org.sky.service.BaseService;
import org.sky.tcc.bank.cmb.dubbo.PlusMoneyAction;
import org.sky.tcc.bank.icbc.dubbo.MinusMoneyAction;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import io.seata.spring.annotation.GlobalTransactional;
@Service
public class TccMoneyKingBizServiceImpl extends BaseService implements TccMoneyKingBizService {
@Autowired
private MinusMoneyAction minusMoneyAction;
@Autowired
private PlusMoneyAction plusMoneyAction;
@Override
@GlobalTransactional(timeoutMills = 300000, name = "tcc-bank-sample")
public boolean transfer(String from, String to, double amount) throws DemoRpcRunTimeException {
boolean answer = minusMoneyAction.prepareMinus(null, from, amount);
if (!answer) {
// 扣钱参与者,一阶段失败; 回滚本地事务和分布式事务
throw new DemoRpcRunTimeException("账号:[" + from + "] 预扣款失败");
}
// 加钱参与者,一阶段执行
answer = plusMoneyAction.prepareAdd(null, to, amount);
if (!answer) {
throw new DemoRpcRunTimeException("账号:[" + to + "] 预收款失败");
}
return true;
}
}
这边可以看到是如何调用icbc的扣款和cmb的打款动作的,这边根本不需要你再去写什么commit和rollback,只要这两个dubbo provider中的prepare方法执行正常,SEATA就会自动回调icbc和cmb中的commit方法;只要icbc或者是cmb中有任何一步抛错,就会触发这两个provider中的业务回滚rollback方法。
MonekyKingController.java
package org.sky.tcc.moneyking.controller;
import java.util.HashMap;
import java.util.Map;
import org.sky.controller.BaseController;
import org.sky.tcc.bean.AccountBean;
import org.sky.tcc.moneyking.service.biz.TccMoneyKingBizService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
@RestController
@RequestMapping("moneyking")
public class MonekyKingController extends BaseController {
@Autowired
private TccMoneyKingBizService tccMoneyKingBizService;
@PostMapping(value = "/transfermoney", produces = "application/json")
public ResponseEntity transferMoney(@RequestBody String params) throws Exception {
ResponseEntity response = null;
String returnResultStr;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON_UTF8);
Map result = new HashMap<>();
try {
logger.info("input params=====" + params);
JSONObject requestJsonObj = JSON.parseObject(params);
Map acctMap = getAccountFromJson(requestJsonObj);
AccountBean acctFrom = acctMap.get("account_from");
AccountBean acctTo = acctMap.get("account_to");
boolean answer = tccMoneyKingBizService.transfer(acctFrom.getAccountId(), acctTo.getAccountId(),
acctFrom.getAmount());
// tccMoneyKingBizService.icbcHello();
// tccMoneyKingBizService.cmbHello();
result.put("account_from", acctFrom.getAccountId());
result.put("account_to", acctTo.getAccountId());
result.put("transfer_money", acctFrom.getAmount());
result.put("message", "transferred successfully");
returnResultStr = JSON.toJSONString(result);
logger.info("transfer money successfully======>\n" + returnResultStr);
response = new ResponseEntity<>(returnResultStr, headers, HttpStatus.OK);
} catch (Exception e) {
logger.error("transfer money with error: " + e.getMessage(), e);
result.put("message", "transfer money with error[ " + e.getMessage() + "]");
returnResultStr = JSON.toJSONString(result);
response = new ResponseEntity<>(returnResultStr, headers, HttpStatus.EXPECTATION_FAILED);
}
return response;
}
}
用于启动的MoneyKingApplication.java
package org.sky.tcc.moneyking;
import org.apache.dubbo.config.spring.context.annotation.EnableDubbo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@ServletComponentScan
@EnableAutoConfiguration
@ComponentScan(basePackages = { "org.sky" })
public class MoneyKingApplication {
public static void main(String[] args) {
SpringApplication.run(MoneyKingApplication.class, args);
}
}
把MoneyKingApplication启动起来。
看,两个dubbo provider已经被SEATA纳入托管。
我们初始化两个帐户,一个叫a一个叫b。然后通过a给b每次打100块钱。
use bank_icbc;
delete from bank_account;
insert into bank_account
(account_id,amount,freezed_amount)values('a',50000,0);
use bank_cmb;
delete from bank_account;
insert into bank_account
(account_id,amount,freezed_amount)values('b',100,0);
再看数据库端
转帐额大于余额
{
"account_from" : "a",
"account_to" : "b",
"transfer_money" : 1000000000
}
来看icbc和cmb端的回滚
看到没,rollback被自动触发。数据库端当然也没被插进数据(被回滚掉了)。
帐户不存在
{
"account_from" : "a",
"account_to" : "c",
"transfer_money" : 100
}
我们可以通过上述的例子看到,SEATA把分布式事务的锁可以定义为最最小业务原子操作,这使得本来冗长的事务锁的开销可以尽量的小,尽快的释放原子操作从而加速了分布式事务处理的效率。
SEATA通过数据一致性、尽可能少破坏业务代码、高性能这三者关系中进行了一个取舍,它付的代价就是使用netty通讯实现了异步消息回调+spring aop,这个对服务器的硬件要求很高。当服务器的硬件如果跟不上的话,你会发现部署一个SEATA简直是要了你的老命了,很多网上的网友也都说过,我部署了一个SEATA比原来竟然慢了8倍。这倒不是说这个框架不好,只是它的开销会比较大。当然,在现今硬件越来越廉价的情况下,要保证数据的最终一致完整性,总要有适当的付出的。