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

2021-04-26 15:29:23 浏览数 (1)

作者 | 张嘉伟(GitHub ID:l81893521)

就职于逸仙电商交易中心;Seata Committer,加入 Seata 社区已有一年半,见证了从 Fescar 到 Seata 的变更,GA 等。

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

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

1. 问题背景

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

2. 业务介绍

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

3. 原理分析

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

4. Demo演示

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

数据不一致的原因


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

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

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

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

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

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

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

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

接下来我会根据逸仙在生产上稳定运行将近一年总结的经验并且尽可能简单的去描述 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的处理过程,相信理解起来会简单很多。

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

2、在每一层的调用链路中,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:

代码语言:javascript复制
@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。下面贴出关键代码:

代码语言:javascript复制
@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 查询分支事务时,数据未同步到从库,导致遗漏了一部分分支事务数据。

源码部分

代码语言:javascript复制
@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();
    }
}

代码语言:javascript复制
@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/seata/pull/3086 有兴趣的朋友也可以尝试去 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 配置方式如下:

代码语言:javascript复制
代码语言:javascript复制
# 广州机房
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 = ""
  }
}
代码语言:javascript复制

Demo


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

https://start.aliyun.com

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

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

0 人点赞