共计 7293 个字符,预计需要花费 19 分钟才能阅读完成。
简介:本文将会以逸仙电商的业务作为背景,先介绍一下 seata 的原理,并给大家进行线上演示,由浅入深去介绍这款中间件,以便读者更加容易去了解 Seata 这个中间件。
作者 | 张嘉伟(GitHub ID:l81893521)
就任于逸仙电商交易中心;Seata Committer,退出 Seata 社区已有一年半,见证了从 Fescar 到 Seata 的变更,GA 等。
你可能没有据说过逸仙电商,然而你的女朋友不可能没有据说过它。逸仙电商旗下有完满日记、小奥汀、完子心选等品牌。完满日记作为国货美妆界的黑马用了不到三年工夫,达到了行业龙头企业通常须要十年以上能力达到的营收规模。2020 年正式登陆纽约证券交易所,成为第一家在美国上市的“国货美妆品牌”。在快速增长的业务下,零碎流量增长速度越来越快,服务数量一直增多,调用链路盘根错节,数据不统一的问题日渐浮现,为了升高人力老本和系统资源,咱们抉择了 Seata。
本文将会以逸仙电商的业务作为背景,先介绍一下 seata 的原理,并给大家进行线上演示,由浅入深去介绍这款中间件,以便读者更加容易去了解 Seata 这个中间件。
- 问题背景
在微服务的架构下,数据不统一的产生起因 - 业务介绍
筛选了逸仙电商一些比较简单易懂的业务作为发展背景 - 原理剖析
Seata 的实现原理和故障解决以及部署计划 - Demo 演示
如何在线体验这款中间件,无需整合和下载任何代码
数据不统一的起因
在微服务的环境下,因为调用链路逾越多个利用,甚至逾越多个数据源,数据的一致性在一般状况下难以保障,导致数据不统一的起因十分多,这里列举了三个最常见的起因
- 业务异样一个服务链路调用中,如果调用的过程呈现业务异样,产生异样的利用独立回滚,非异样的利用数据曾经长久化到数据库。
- 网络异样调用的过程中,因为网络不稳固,导致链路中断,局部利用业务执行实现,局部利用业务未被执行。
- 服务不可用若服务不可用,无奈被失常调用,也会导致问题的产生
这里筛选了逸仙电商业务体系外面一个十分艰深容易了解的调用形式,并且去掉了多余简单的链路,不便在浏览过程中更加关注重点。
在以往如果呈现数据不统一的问题,置信大多数的解决方案是这样的
• 人工弥补数据
• 定时工作检查和弥补数据
然而这两种形式的毛病也是显然意见的,一种是节约大量的人力老本和工夫,另外一种是节约大量的系统资源去检查数据是否统一和额定的人力老本。
接下来我会依据逸仙在生产上稳固运行将近一年总结的教训并且尽可能简略的去形容 Seata 是如何保证数据统一的。
原理
在接触一项新技术之前,咱们应该先从宏观的角度去了解它大略蕴含些什么。在 Seata 中,它大略分为以下三个角色。
• 黄色,Transaction Manager(TM),client 端
• 蓝色,Resource Manager(RM),client 端
• 绿色,Transaction Coordinator(TC),server 端
你能够依据色彩,名字,缩写甚至客户端 / 服务端去辨别这三者的关系,同时简略去了解它们每一个本身的职责大略是要干些什么事件,前面的解说我也会放弃一样的色彩和名字来辨别它们。
Seata 其中只一个外围是数据源代理,意味着在你执行一句 Sql 语句时,Seata 会帮你在执行之前和之后做一些额定的操作,从而保证数据的一致性,并且尽可能做到无感知,让你应用起来感觉十分不便和神奇。这里首先要去了解两个知识点。
• 前置镜像(Before Image):保留数据变更前的样子
• 后置镜像(After Image):保留数据变更后的样子
• Undo Log:保留镜像
有时候新我的项目接入的时候,有共事会问,为什么事务不失效,如果你也遇到过同样的问题,那首先要检查一下本人的数据源是否曾经代理胜利。
当执行一句 Sql 时,Seata 会尝试去获取这条 / 批数据变更前的内容,并保留到前置镜像中(Insert 语句没有前置镜像),而后执行业务 Sql,执行完后会尝试去获取这条 / 批数据变更后的内容,并保留到后置镜像中(Delete 语句没有后置镜像),之后会进行分支事务注册,TC 在收到分支事务注册申请时,会长久化这些分支事务信息和依据操作数据的主键为维度作为全局锁并长久化,可选长久化形式有
• file
• db
• redis
在收到 TC 返回的分支注册胜利响应后,会把镜像长久化到利用所在的数据源的 Undo Log 表中,最初提交本地事务。
以上所有操作都会保障在同一个本地事务中,保障业务操作和 Undo Log 操作的原子性
一阶段
了解了单个利用的解决流程,再从一个齐全的调用链路,去看 Seata 的处理过程,置信了解起来会简略很多,
- 首先一个应用了 @GlobalTransactional 的接口被调用,Seata 会对其进行拦挡,拦挡的角色咱们称之为 TM,这个时候会拜访 TC 开启一个新的全局事务,TC 收到申请后会生成 XID 和全局事务信息并长久化,而后返回 XID。
- 在每一层的调用链路中,XID 都必须往下传递,而后每一层都通过之前说过的解决逻辑,直到执行实现 / 异样抛出。
直到目前,一阶段曾经执行实现。
另外一个须要留神的问题是,如果发现事务不失效,须要查看 XID 是否胜利往下传递
二阶段提交
如果在整个调用链路的过程,没有产生任何异样,那么二阶段提交的过程是非常简单而且十分的高效,只有两步
• TC 清理全局事务对应的信息
• RM 清理对应 Undo Log 信息
二阶段回滚
若调用过程中出现异常,会主动触发反向回滚
反向回滚示意,如果调用链路程序为 A -> B -> C,那么回滚程序为 C -> B -> A。
例:A=Insert,B=Update,如果回滚时不依照反向的程序进行回滚,则有可能呈现回滚时先把 A 删除了,再更新 A,引发谬误
在回滚的过程中有可能会遇到一种十分极其的状况,回滚到对应的模块时,找不到对应的 Undo Log,这种状况次要产生在
• 分支事务注册胜利,然而因为网络起因收不到胜利的响应,Undo Log 未被长久化
• 同时全局事务超时(超时工夫可自在配置) 触发回滚
这时候 RM 会长久化一个非凡的 Undo Log,状态为 GlobalFinished。因为这个全局事务曾经回滚,须要防止网络复原时,未长久化 Undo Log 的利用收到了分支注册胜利的响应和长久化 Undo Log,并提交本地最终引发的数据不统一。
读已提交
因为在一阶段的时候,数据曾经保留到数据库并提交,所以 Seata 默认的隔离级别为读未提交,如果须要把隔离级别晋升至读已提交则须要应用 @GlobalLock 标签并且在查问语句上加上 for update
@GlobalLock
@Transactional
public PayMoneyDto detail(ProcessOnEventRequestDto processOnEventRequestDto) {
return baseMapper.detail(processOnEventRequestDto.getProcessInfoDto().getBusinessKey())
}
@Mapper
public interface PayMoneyMapper extends BaseMapper<PayMoney> {
@Select("select id, name, amount, account, has_repayment, pay_amount from pay_money m where m.business_key = #{businessKey} for update")
PayMoneyDto detail(@Param("businessKey") String businessKey);
}
这个时候 Seata 会对增加了 for update 的查问语句进行代理
如果一个全局事务 1 正在操作,并且未进行二阶段提交 / 回滚的时候,全局锁是被全局事务 1 锁持有的,同时另外一个全局事务 2 尝试去查问雷同的数据,因为查问语句被代理,seata 会尝试去获取这条数据的全局锁,直到获取胜利 / 失败 (重试次数达到配置值) 为止。
问题
在生产上运行靠近 1 年工夫,总体来说遇到的问题不算多,解决起来也比拟容易,比方以下这个问题
通过排查发现,因为 Seata 会应用 jdbc 标准接口尝试获取业务操作所对应的表构造,因为表构造改变频率较少,并且思考到表构造变更后利用会进行重启,所以会对表构造进行缓存,如果表构造改变后不对利用进行重启,有可能引发构建镜像时呈现 NullPointerException。上面贴出要害代码
@Override
public TableMeta getTableMeta(final Connection connection, final String tableName, String resourceId) {
if (StringUtils.isNullOrEmpty(tableName)) {throw new IllegalArgumentException("TableMeta cannot be fetched without tableName");
}
TableMeta tmeta;
final String key = getCacheKey(connection, tableName, resourceId);
// 谬误关键处,尝试从缓存获取表构造
tmeta = TABLE_META_CACHE.get(key, mappingFunction -> {
try {return fetchSchema(connection, tableName);
} catch (SQLException e) {LOGGER.error("get table meta of the table `{}` error: {}", tableName, e.getMessage(), e);
return null;
}
});
if (tmeta == null) {throw new ShouldNeverHappenException(String.format("[xid:%s]get table meta failed," +
"please check whether the table `%s` exists.", RootContext.getXID(), tableName));
}
return tmeta;
}
批改表构造,须要对利用进行重启,即可解决此问题,非常简单
第二个遇到的问题就是在生产运行一段时间后,发现 branch_table 和 lock_table 存在数据残留,并且依据 xid 查问 global_table 没有对应的数据,导致后续操作雷同的数据行会呈现获取全局锁失败,并且会每隔一段时间小量呈现。这个异样暗藏的比拟深,而且在开发环境和测试环境无奈复现,通过跟踪源码和总结起因发现,是因为开启了 Mysql 主从,导致提交 / 回滚时,Seata 通过 xid 查问分支事务时,数据未同步到从库,导致脱漏了一部分分支事务数据。
源码局部
@Override
public GlobalStatus commit(String xid) throws TransactionException {
// 依据 xid 查问信息,如果开启主从,会有可能导致查问信息不残缺
GlobalSession globalSession = SessionHolder.findGlobalSession(xid);
if (globalSession == null) {return GlobalStatus.Finished;}
globalSession.addSessionLifecycleListener(SessionHolder.getRootSessionManager());
// just lock changeStatus
boolean shouldCommit = SessionHolder.lockAndExecute(globalSession, () -> {
// Highlight: Firstly, close the session, then no more branch can be registered.
globalSession.closeAndClean();
if (globalSession.getStatus() == GlobalStatus.Begin) {if (globalSession.canBeCommittedAsync()) {globalSession.asyncCommit();
return false;
} else {globalSession.changeStatus(GlobalStatus.Committing);
return true;
}
}
return false;
});
if (shouldCommit) {boolean success = doGlobalCommit(globalSession, false);
//If successful and all remaining branches can be committed asynchronously, do async commit.
if (success && globalSession.hasBranch() && globalSession.canBeCommittedAsync()) {globalSession.asyncCommit();
return GlobalStatus.Committed;
} else {return globalSession.getStatus();
}
} else {return globalSession.getStatus() == GlobalStatus.AsyncCommitting ? GlobalStatus.Committed : globalSession.getStatus();}
}
@Override
public GlobalStatus rollback(String xid) throws TransactionException {
// 依据 xid 查问信息,如果开启主从,会有可能导致查问信息不残缺
GlobalSession globalSession = SessionHolder.findGlobalSession(xid);
if (globalSession == null) {return GlobalStatus.Finished;}
globalSession.addSessionLifecycleListener(SessionHolder.getRootSessionManager());
// just lock changeStatus
boolean shouldRollBack = SessionHolder.lockAndExecute(globalSession, () -> {globalSession.close(); // Highlight: Firstly, close the session, then no more branch can be registered.
if (globalSession.getStatus() == GlobalStatus.Begin) {globalSession.changeStatus(GlobalStatus.Rollbacking);
return true;
}
return false;
});
if (!shouldRollBack) {return globalSession.getStatus();
}
doGlobalRollback(globalSession, false);
return globalSession.getStatus();
}
置信此问题会在反对 Raft 之后失去完满的解决
pr: https://github.com/seata/seat…
有趣味的敌人也能够尝试去 review 一下代码
部署 - 高可用
Seata 和其余中间件的高可用部署形式差异不大,如图片所示,确保应用服务和 TC 拜访雷同的注册核心和配置核心,同时只须要启动多台 TC,并将 store.mode 改为 db 模式即可实现高可用部署,并抉择适合的注册核心和配置核心即可,目前反对的配置核心有
• nacos
• consul
• etcd3
• eureka
• redis
• sofa
• zookeeper
可选的配置核心有
• nacos
• etcd3
• consul
• apollo
• zk
部署 - 单节点多利用
当然也有更加灵便的部署形式,通过 vgoup-mapping(事务集群),能够做到单节点多利用的隔离,比方 A 利用和 B 利用拜访 A -Group 的两个 TC,C 利用和 D 利用拜访 B -Group 的两个 TC,E 利用和 F 利用拜访 C -Group 的两个 TC。
部署 - 异地容灾
通过 vgoup-mapping 也能够做到异地容灾,当原有集群呈现不可用时,能够通过变更配置立即转移到备用的集群上。此处以 Nacos 作为注册核心举例,TC 配置形式如下:
广州机房
registry {
# file、nacos、eureka、redis、zk、consul、etcd3、sofa
type = “nacos”
loadBalance = “RandomLoadBalance”
loadBalanceVirtualNodes = 10
nacos {
application = "seata-server"
serverAddr = "127.0.0.1:8848"
group = "SEATA_GROUP"
namespace = ""cluster ="Guangzhou"username =""
password = ""
}
}
上海机房
registry {
# file、nacos、eureka、redis、zk、consul、etcd3、sofa
type = “nacos”
loadBalance = “RandomLoadBalance”
loadBalanceVirtualNodes = 10
nacos {
application = "seata-server"
serverAddr = "127.0.0.1:8848"
group = "SEATA_GROUP"
namespace = ""cluster ="Shanghai"username =""
password = ""
}
}
原文链接
本文为阿里云原创内容,未经容许不得转载。