写点什么

逸仙电商 Seata 企业级落地实践

2021 年 4 月 25 日

逸仙电商Seata企业级落地实践

你可能没有听说过逸仙电商,但是你的女朋友不可能没有听说过它。逸仙电商旗下有完美日记、小奥汀、完子心选等品牌。完美日记作为国货美妆界的黑马用了不到三年时间,达到了行业龙头企业通常需要十年以上才能达到的营收规模。2020 年正式登陆纽约证券交易所,成为第一家在美国上市的“国货美妆品牌”。在快速增长的业务下,系统流量增长速度越来越快,服务数量不断增多,调用链路错综复杂,数据不一致的问题日渐显现,为了降低人力成本和系统资源,我们选择了 Seata。


本文将会以逸仙电商的业务作为背景, 先介绍一下 Seata 的原理, 并给大家进行线上演示, 由浅入深去介绍这款中间件, 以便读者更加容易去理解 Seata 这个中间件。

1. 问题背景

在微服务的架构下,数据不一致的产生原因。

2. 业务介绍

挑选了逸仙电商一些比较简单易懂的业务作为开展背景。

3. 原理分析

Seata 的实现原理和故障解决以及部署方案。

4. Demo 演示

如何在线体验这款中间件,无需整合和下载任何代码。

数据不一致的原因


image

在微服务的环境下,由于调用链路跨越多个应用,甚至跨越多个数据源,数据的一致性在普通情况下难以保证,导致数据不一致的原因非常多,这里列举了三个最常见的原因:


1、业务异常一个服务链路调用中,如果调用的过程出现业务异常,产生异常的应用独立回滚,非异常的应用数据已经持久化到数据库。

2、网络异常调用的过程中,由于网络不稳定,导致链路中断,部分应用业务执行完成,部分应用业务未被执行。

3、服务不可用若服务不可用,无法被正常调用,也会导致问题的产生。


image

这里挑选了逸仙电商业务体系里面一个非常通俗容易理解的调用方式,并且去掉了多余复杂的链路,方便在阅读过程中更加关注重点。


在以往如果出现数据不一致的问题,相信大多数的解决方案是这样的:

  • 人工补偿数据

  • 定时任务检查和补偿数据


但是这两种方式的缺点也是显然意见的,一种是浪费大量的人力成本和时间,另外一种是浪费大量的系统资源去检查数据是否一致和额外的人力成本。


接下来我会根据逸仙在生产上稳定运行将近一年总结的经验并且尽可能简单的去描述 Seata 是如何保证数据一致的。

原理


image

在接触一项新技术之前,我们应该先从宏观的角度去理解它大概包含些什么。在 Seata 中,它大概分为以下三个角色:

  • 黄色,Transaction Manager(TM),client 端

  • 蓝色,Resource Manager(RM),client 端

  • 绿色,Transaction Coordinator(TC),server 端


你可以根据颜色,名字,缩写甚至客户端/服务端去区分这三者的关系,同时简单去理解它们每一个自身的职责大概是要干些什么事情,后面的讲解我也会保持一样的颜色和名字来区分它们。

image

Seata 其中只一个核心是数据源代理,意味着在你执行一句 Sql 语句时,Seata 会帮你在执行之前和之后做一些额外的操作,从而保证数据的一致性,并且尽可能做到无感知,让你使用起来感觉非常方便和神奇。这里首先要去理解两个知识点。


  • 前置镜像(Before Image):保存数据变更前的样子

  • 后置镜像(After Image):保存数据变更后的样子

  • Undo Log:保存镜像


有时候新项目接入的时候,有同事会问,为什么事务不生效,如果你也遇到过同样的问题,那首先要检查一下自己的数据源是否已经代理成功。


当执行一句 Sql 时,Seata 会尝试去获取这条/批数据变更前的内容,并保存到前置镜像中(Insert 语句没有前置镜像),然后执行业务 Sql,执行完后会尝试去获取这条/批数据变更后的内容,并保存到后置镜像中(Delete 语句没有后置镜像),之后会进行分支事务注册,TC 在收到分支事务注册请求时,会持久化这些分支事务信息和根据操作数据的主键为维度作为全局锁并持久化,可选持久化方式有:


  • file

  • db

  • redis


在收到 TC 返回的分支注册成功响应后,会把镜像持久化到应用所在的数据源的 Undo Log 表中,最后提交本地事务。


以上所有操作都会保证在同一个本地事务中,保证业务操作和 Undo Log 操作的原子性。

一阶段


image

理解了单个应用的处理流程,再从一个完全的调用链路,去看 Seata 的处理过程,相信理解起来会简单很多。


1、首先一个使用了 @GlobalTransactional 的接口被调用,Seata 会对其进行拦截,拦截的角色我们称之为 TM,这个时候会访问 TC 开启一个新的全局事务,TC 收到请求后会生成 XID 和全局事务信息并持久化,然后返回 XID。

2、在每一层的调用链路中,XID 都必须往下传递,然后每一层都经过之前说过的处理逻辑,直到执行完成/异常抛出。


直到目前,一阶段已经执行完成。


另外一个需要注意的问题是,如果发现事务不生效,需要检查 XID 是否成功往下传递。

二阶段提交

image

如果在整个调用链路的过程,没有发生任何异常,那么二阶段提交的过程是非常简单而且非常的高效,只有两步:


  • TC 清理全局事务对应的信息

  • RM 清理对应 Undo Log 信息

二阶段回滚


image

若调用过程中出现异常,会自动触发反向回滚:


反向回滚表示,如果调用链路顺序为 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@Transactionalpublic PayMoneyDto detail(ProcessOnEventRequestDto processOnEventRequestDto) { return baseMapper.detail(processOnEventRequestDto.getProcessInfoDto().getBusinessKey())}@Mapperpublic 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 的查询语句进行代理:


image

如果一个全局事务 1 正在操作,并且未进行二阶段提交/回滚的时候,全局锁是被全局事务 1 锁持有的,同时另外一个全局事务 2 尝试去查询相同的数据,由于查询语句被代理,Seata 会尝试去获取这条数据的全局锁,直到获取成功/失败(重试次数达到配置值)为止。

问题

在生产上运行接近 1 年时间,总体来说遇到的问题不算多,解决起来也比较容易,比如以下这个问题:



经过排查发现,由于 Seata 会使用 jdbc 标准接口尝试获取业务操作所对应的表结构,由于表结构改动频率较少,并且考虑到表结构变更后应用会进行重启,所以会对表结构进行缓存,如果表结构改动后不对应用进行重启,有可能引发构建镜像时出现 NullPointerException。下面贴出关键代码:



@Overridepublic 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 查询分支事务时,数据未同步到从库,导致遗漏了一部分分支事务数据。


源码部分


@Overridepublic 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(); }}
复制代码



@Overridepublic 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/seata/pull/3086

有兴趣的朋友也可以尝试去 review 一下代码。

部署-高可用


image

Seata 和其他中间件的高可用部署方式差别不大,如图片所示,确保应用服务和 TC 访问相同的注册中心和配置中心,同时只需要启动多台 TC,并将 store.mode 改为 db 模式即可完成高可用部署,并选择合适的注册中心和配置中心即可,目前支持的配置中心有:

  • nacos

  • consul

  • etcd3

  • eureka

  • redis

  • sofa

  • zookeeper


可选的配置中心有:

  • nacos

  • etcd3

  • consul

  • apollo

  • zk

部署-单节点多应用

image

当然也有更加灵活的部署方式,通过 vgoup-mapping(事务集群),可以做到单节点多应用的隔离,比如 A 应用和 B 应用访问 A-Group 的两个 TC,C 应用和 D 应用访问 B-Group 的两个 TC,E 应用和 F 应用访问 C-Group 的两个 TC。

部署-异地容灾


image


image

通过 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 = "" }}
复制代码

Demo

最后通过访问阿里云知行动手首页,即可在线快速体验各种各样的中间件:


https://start.aliyun.com


Seata 直达传送门,无需下载代码,在线编译和部署:


https://start.aliyun.com/handson/isnEO76f/distributedtransaction


作者介绍:

作者 | 张嘉伟(GitHub ID:l81893521),就职于逸仙电商交易中心;Seata Committer,加入 Seata 社区已有一年半,见证了从 Fescar 到 Seata 的变更,GA 等。


本文转自:阿里巴巴中间件(ID:Aliware_2018)

原文链接:逸仙电商Seata企业级落地实践

2021 年 4 月 25 日 13:001145

评论

发布
暂无评论
发现更多内容

5. Bean Validation声明式验证四大级别:字段、属性、容器元素、类

YourBatman

Hibernate-Validator Bean Validation 数据校验

非阻塞的无界线程安全队列 —— ConcurrentLinkedQueue

程序员小航

Java 源码 并发 源码阅读 JUC

Docker内部组件

混沌畅想

Docker 容器 运维

JMM 应用实例:单例模式

朱华

单例模式

算法图解:如何用两个栈实现一个队列?

王磊

Java 数据结构 算法和数据结构

【架构师训练营 1 期】第五周作业

诺乐

架构师训练营 - 第 5 周课后作业(1 期)

Pudding

一文快速入门分库分表中间件 Sharding-JDBC (必修课)

程序员内点事

Java 分库分表

独家揭秘 | 京东物流Elasticsearch大规模“迁移上云”实践

京东科技开发者

云计算

mongodb源码实现系列-网络传输层模块实现二

杨亚洲(专注mongodb及高性能中间件)

MySQL 数据库 mongodb 高性能 分布式数据库mongodb

万字长文深入理解java中的集合-附PDF下载

程序那些事

java编程 JAVA集合 java集合总结 java集合使用 java秘籍

架构师训练第五周 -编程语言实现一致性 hash 算法

郎哲158

JVM系列笔记 - 虚拟机栈

朱华

JVM

1024丨奈学教育致敬程序员:‘3+2’战略发布会圆满落幕

奈学教育

奈学教育 程序员节

使用Hugo和GitHub搭建博客

Félix

GitHub GitHub Pages Blog Hugo

这个应用魔方厉害了,让软件开发者效率提升10倍

华为云开发者社区

软件开发 代码

架构训练营 - 第5周课后作业 - 学习总结

Pudding

Go发起HTTP2.0请求流程分析(后篇)——标头压缩

Gopher指北

golang 后端开发 HTTP2.0

环信入选2020在线教育视频云创新排行TOP10

DT极客

区块链数字货币交易所开发,交易系统搭建方案

WX13823153201

iOS touch事件点的获取

teoking

ios

前端科普系列(5):ESLint - 守住优雅的护城河

vivo互联网技术

Java 前端 代码仓库

Consistent Hashing算法实现 - JavaScript

【架构师训练营 1 期】第五周学习总结

诺乐

超越视觉支持语音新版OpenVINO发布,为更多智能边缘开发者赋能

intel001

LAXCUS 大数据集群操作系统:一个分布式分时共享 E 级系统软件(五)

陈泽云

人工智能 数据库 大数据 操作系统

1024丨奈学教育致敬程序员:‘3+2’战略发布会圆满落幕

古月木易

奈学教育

阿里云盘线下交流会

兔2🐰🍃

阿里云网盘 Teambition 线下体验

10月24日,“网安小酒馆”线上活动开启,有红包,更有名酒相送

Cloudaemon

低代码开发平台,真的是为了“干掉“程序员嘛?

力软.net/java开发平台

软件开发 低代码

配置企业应用业务流程别头大,有工作流引擎就不怕

Marilyn

敏捷开发

2021年全国大学生计算机系统能力大赛操作系统设计赛 技术报告会

2021年全国大学生计算机系统能力大赛操作系统设计赛 技术报告会

逸仙电商Seata企业级落地实践-InfoQ