• Spring Cloud Seata 分布式事务学习总结


    分布式事务场景

    跨库事务

    跨库事务指的是,一个应用某个功能需要操作多个库,不同的库中存储不同的业务数据。

    分库分表

    通常一个库数据量比较大或者预期未来的数据量比较大,都会进行水平拆分,也就是分库分表。
    对于分库分表的情况,一般开发人员都会使用一些数据库中间件来降低sql操作的复杂性。如,对于sql:insert into user(id,name) values (1,“张三”),(2,“李四”)。这条sql是操作单库的语法,单库情况下,可以保证事务的一致性。
    但是由于现在进行了分库分表,开发人员希望将1号记录插入分库1,2号记录插入分库2。所以数据库中间件要将其改写为2条sql,分别插入两个不同的分库,此时要保证两个库要不都成功,要不都失败,因此基本上所有的数据库中间件都面临着分布式事务的问题。
    在这里插入图片描述

    服务化

    微服务架构是目前一个比较一个比较火的概念。例如上面笔者提到的一个案例,某个应用同时操作了9个库,这样的应用业务逻辑必然非常复杂,对于开发人员是极大的挑战,应该拆分成不同的独立服务,以简化业务逻辑。拆分后,独立服务之间通过RPC框架来进行远程调用,实现彼此的通信。
    Service A完成某个功能需要直接操作数据库,同时需要调用Service B和Service C,而Service B又同时操作了2个数据库,Service C也操作了一个库。需要保证这些跨服务的对多个数据库的操作要不都成功,要不都失败,实际上这可能是最典型的分布式事务场景。

    在这里插入图片描述

    X/Open DTP模型与XA规范

    DTP模型
    构成DTP模型的5个基本元素:

    应用程序(Application Program ,简称AP):用于定义事务边界,也就是通过应用程序来操作事物
    资源管理器(Resource Manager,简称RM):如数据库、文件系统等,用来存储数据。
    事务管理器(Transaction Manager ,简称TM):负责分配事务唯一标识,监控事务的执行进度,并负责事务的提交、回滚等。
    通信资源管理器(Communication Resource Manager,简称CRM):用来控制分布式应用之间的通信。
    通信协议(Communication Protocol,简称CP):提供 分布式应用节点之间的底层通信服务。

    XA规范

    在DTP模型本地实例中,只需要由AP、RMS和TM组成,不需要其他元素。AP、RM和TM之间,彼此都需要进行交互,如下图所示:
    (1)表示AP-RM的交互接口
    (2)表示AP-TM的交互接口
    (3)表示RM-TM的交互接口
    在这里插入图片描述
    XA规范的最主要的作用是,就是定义了RM-TM的交互接口,XA规范除了定义的RM-TM交互的接口之外,还对两阶段提交协议进行了优化。
    两阶段协议是在OSI TP标准中提出的;在DTP参考模型中,指定了全局事务的提交要使用two-phase commit协议;而XA规范只是定义了两阶段提交协议中需要使用到的接口,也就是上述提到的RM-TM交互的接口,因为两阶段提交过程中的参与方,只有TM和RMS。

    两阶段提交协议(2PC)

    两阶段提交协议(Two Phase Commit)从字面意思来理解,Two Phase Commit,就是将提交(commit)过程划分为2个阶段(Phase):
    在这里插入图片描述
    阶段1
    以mysql数据库为例,在第一阶段,事务管理器向所有涉及到的数据库服务器发出"准备提交"请求,数据库收到请求后执行数据修改和日志记录等处理,处理完成后只是把事务的状态改成"可以提交",然后把结果返回给事务管理器。
    阶段2
    如果第一阶段中所有相关事物的数据库都准备完毕,那么事务管理器向数据库服务器发出"确认提交"请求,数据库服务器把事务的"可以提交"状态改为"提交完成"状态,然后返回应答。如果在第一阶段内有任何一个数据库的操作发生了错误,或者事务管理器收不到某个数据库的回应,则认为事务失败,回撤所有数据库的事务。数据库服务器收不到第二阶段的确认提交请求,也会把"可以提交"的事务回撤。
    XA是资源层面的分布式事务,强一致性,在两阶段提交的整个过程中,一直会持有资源的锁。 TCC是业务层面的分布式事务,最终一致性,不会一直持有资源的锁。
    在这里插入图片描述

    两阶段提交协议存在的问题

    同步阻塞问题
    两阶段提交方案下全局事务的ACID特性,是依赖于参与者的。一个全局事务内部包含了多个独立的事务分支,这一组事务分支要不都成功,要不都失败。各个事务分支的ACID特性共同构成了全局事务的ACID特性。即使在本地事务中,如果对操作读很敏感,我们需要将事务隔离级别设置为SERIALIZABLE。
    对于分布式事务来说,可重复读隔离级别不足以保证分布式事务一致性。如果我们使用mysql来支持XA分布式事务的话,最好将事务隔离级别设置为SERIALIZABLE,然而SERIALIZABLE(串行化)是四个事务隔离级别中最高的一个级别,也是执行效率最低的一个级别。
    单点故障
    由于协调者是至关重要的,一旦协调者发生故障,参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)
    数据不一致
    在二阶段中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这会导致只有一部分参与者接受到了commit请求,而在这部分参与者接到commit请求之后就会执行commit操作,但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致性的现象。

    三阶段提交协议(Three-phase commit)

    由于二阶段提交存在着诸如同步阻塞、单点问题等缺陷,所以,研究者们在二阶段提交的基础上做了改进,提出了三阶段提交。与两阶段提交不同的是,三阶段提交有两个改动点:

    • 1、引入超时机制。同时在协调者和参与者中都引入超时机制。
    • 2、在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。
    CanCommit阶段

    3PC的CanCommit阶段其实和2PC的准备阶段很像。协调者向参与者发送commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。

    • 1.事务询问: 协调者向参与者发送CanCommit请求,询问是否可以执行事务提交操作,然后等待参与者的响应。
    • 2.响应反馈:参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则反馈Yes,并进入预备状态。否则反馈No
    PreCommit阶段

    协调者根据参与者的响应反馈来决定是否可以进行PreCommit操作。有以下两种可能:

    假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行链。

    • 1.发送预提交请求: 协调者向参与者发送PreCommit请求,并进入Prepared阶段。
    • 2.事务预提交: 参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。
    • 3.响应反馈: 如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。

    假如有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。

    • 1.发送中断请求:协调者向所有参与者发送abort请求。
    • 2.中断事务:参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。
    doCommit阶段

    该阶段进行真正的事务提交,也可以分为以下两种情况:
    执行提交

    • 1.发送提交请求: 协调者接收到参与者发送的ACK响应,那么他将从预提交状态进入到提交状态,并向所有参与者发送doCommit请求。
    • 2.事务提交: 参与者接收到doCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
    • 3.响应反馈: 事务提交完之后,向协调者发送Ack响应。
    • 4.完成事务: 协调者接收到所有参与者的Ack响应之后,完成事务。

    执行中断
    协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。

    • 1.发送中断请求: 协调者向所有参与者发送abort请求
    • 2.事务回滚: 参与者接收到abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
    • 3.反馈结果: 参与者完成事务回滚之后,向协调者发送ACK消息
    • 4.中断事务: 协调者接收到参与者反馈的ACK消息之后,执行事务的中断。

    Seata 介绍

    Seata 是一款开源的分布式事务解决方案,为用户提供了XA、AT、TCC、SAGA事务模式,AT模式是阿里首推的模式

    Seata的三大角色

    TC (Transaction Coordinator) - 事务协调者
    维护全局和分支事务的状态,驱动全局事务提交或回滚。
    TM (Transaction Manager) - 事务管理器
    定义全局事务的范围:开始全局事务、提交或回滚全局事务。
    RM (Resource Manager) - 资源管理器
    管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
    其中,TC 为单独部署的 Server 服务端,TM 和 RM 为嵌入到应用中的 Client 客户端。
    在这里插入图片描述
    1.TM 请求 TC 开启一个全局事务。TC 会生成一个 XID 作为该全局事务的编号。XID会在微服务的调用链路中传播,将多个微服务的子事务关联在一起。
    2.RM 请求 TC 将本地事务注册为全局事务的分支事务,通过全局事务的 XID 进行关联。
    3.TM 请求 TC 告诉 XID 对应的全局事务是进行提交还是回滚。
    4.TC 驱动 RM 们将 XID 对应的自己的本地事务进行提交还是回滚。

    Seata的四种模式:

    AT模式:

    T 模式分为两个阶段:

    • 一阶段:执行用户SQL

      • Seata 会拦截SQ,首先解析 SQL 语义,找到SQL要更新的数据,在数据被更新前,将其保存成前置快照,然后执行SQL更新数据,在数据更新之后,再将其保存成后置快照,最后生成行锁。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。
    • 二阶段:Seata框架自动生成

      • 提交:因为SQL在一阶段已经提交至数据库, 所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。

      • 回滚:需要回滚一阶段已经执行的SQL,使用前置快照恢复数据,但在还原前要首先要校验脏写,对比数据库当前数据和后置快照,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。

    AT 模式的一阶段、二阶段提交和回滚均由 Seata框架自动生成,用户只需编写SQL,便能轻松接入分布式事务,AT模式是一种对业务无任何侵入的分布式事务解决方案。

    TCC模式:

    TCC分为三个阶段:
    Cancel:

    • Try阶段:检查资源是否足够,足够则冻结资源,执行try方法
    • Confirm阶段:
      • 执行成功:执行Confirm方法删除冻结资源。
      • 执行失败:执行Canel逻辑,恢复冻结资源。
    • Cancel阶段:对业务处理进行取消,即回滚操作,该步骤回对 Try 冻结的资源进行释放。

    TCC 是一种侵入式的分布式事务解决方案,以上三个操作都需要业务系统自行实现,对业务系统有着非常大的入侵性,设计相对复杂,但优点是 TCC 完全不依赖数据库,能够实现跨数据库、跨应用资源管理,对这些不同数据访问通过侵入式的编码方式实现一个原子操作,更好地解决了在各种复杂业务场景下的分布式事务问题。

    XA模式:

    实现了对XA协议的支持,XA模式属于两阶段提交。

    • 第一阶段进行事务注册,将事务注册到TC中,执行SQL语句。
    • 第二阶段TC判断无事务出错,通知所有事务提交,否则回滚。

    在第一到第二阶段过程中,事务一直占有数据库锁,因此性能比较低,但是所有事务要么一起提交,要么一起回滚,所以能实现强一致性。

    Sage模式:

    Saga 模式是长事务解决方案,适用于业务流程长且需要保证事务最终一致性的业务系统,Saga 模式一阶段就会提交本地事务,无锁,长流程情况下可以保证性能,多用于渠道层、集成层业务系统。

    四种模式对比:

    请添加图片描述
    AT:默认,弱一致性,无代码侵入,一阶段事务直接提交,失败则根据undolog日志回滚,隔离性引入全局锁,但并发几率低,所以性能会比XA好。
    XA:强一致性,无代码侵入、但一阶段事务不提交、会锁住资源,导致性能低。需要依赖数据库的事务特性。
    TCC:无需依赖关系型数据库,基于资源预留隔离。try、confirm、canel需要人工手写,而且需要考虑空悬挂、空回滚、幂等性判断,较为复杂、性能最好,但成本太高。
    Seaga:适用于长事务类型,无太多应用场景。

    TCC和AT区别:

    AT 模式基于支持本地 ACID 事务 的 关系型数据库,TCC 模式不依赖于底层数据资源的事务支持。
    请添加图片描述

    Seata 使用

    官方教程

    Server端存储模式(store.mode)支持三种:
    • file:单机模式,全局事务会话信息内存中读写并持久化本地文件root.data,性能较高
    • db:高可用模式,全局事务会话信息通过db共享,相应性能差些
    • redis:Seata-Server 1.3及以上版本支持,性能较高,存在事务信息丢失风险,请提前配置适合当前场景的redis持久化配置

    Spring Cloud Alibaba整合Seata

    启动Seata Server并指定nacos作为配置中心和注册中心

    1、修改registry.conf文件:
    在这里插入图片描述
    在这里插入图片描述
    客户端配置registry.conf使用nacos时也要注意group要和seata server中的group一致,默认group是"DEFAULT_GROUP"

    2、同步seata server的配置到nacos:
    在这里插入图片描述
    配置事务分组, 要与客户端配置的事务分组一致:
    客户端properties配置:spring.cloud.alibaba.seata.tx‐service‐group=my_test_tx_group
    在这里插入图片描述
    配置参数同步到Nacos:
    shell:

    sh ${SEATAPATH}/script/config-center/nacos/nacos-config.sh -h localhost -p 8848 -g SEATA_GROUP -t 5a3c7d6c-f497-4d68-a71a-2e5e3340b3ca
    
    -h: host,默认值 localhost
    -p: port,默认值 8848
    -g: 配置分组,默认值为 'SEATA_GROUP'
    -t: 租户信息,对应 Nacos 的命名空间ID字段, 默认值为空 ''
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    3、启动Seata Server

    bin/seata-server.sh
    
    • 1

    启动成功,默认端口8091
    在这里插入图片描述
    在注册中心中可以查看到seata-server注册成功
    在这里插入图片描述

    整合Spring Cloud
    业务场景:

    用户下单,整个业务逻辑由三个微服务构成:
    仓储服务:对给定的商品扣除库存数量。
    订单服务:根据采购需求创建订单。
    帐户服务:从用户帐户中扣除余额。
    在这里插入图片描述
    在这里插入图片描述
    1、依赖包:

    <spring-cloud.version>Greenwich.SR3spring-cloud.version>
    <spring-cloud-alibaba.version>2.1.1.RELEASEspring-cloud-alibaba.version>
    
    • 1
    • 2

    注意版本选择问题:
    spring cloud alibaba 2.1.2 及其以上版本使用seata1.4.0会出现如下异常 (支持seata 1.3.0)
    在这里插入图片描述

    
    <dependency>
        <groupId>com.alibaba.cloudgroupId>
        <artifactId>spring-cloud-starter-alibaba-seataartifactId>
        <exclusions>
            <exclusion>
                <groupId>io.seatagroupId>
                <artifactId>seata-allartifactId>
            exclusion>
        exclusions>
    dependency>
    <dependency>
        <groupId>io.seatagroupId>
        <artifactId>seata-allartifactId>
        <version>1.4.0version>
    dependency>
    
    
    <dependency>
        <groupId>com.alibaba.cloudgroupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discoveryartifactId>
    dependency>
    
    <dependency>
        <groupId>org.springframework.cloudgroupId>
        <artifactId>spring-cloud-starter-openfeignartifactId>
    dependency>
    
    <dependency>
        <groupId>com.alibabagroupId>
        <artifactId>druid-spring-boot-starterartifactId>
        <version>1.1.21version>
    dependency>
    
    <dependency>
        <groupId>mysqlgroupId>
        <artifactId>mysql-connector-javaartifactId>
        <scope>runtimescope>
        <version>8.0.16version>
    dependency>
    
    <dependency>
        <groupId>org.mybatis.spring.bootgroupId>
        <artifactId>mybatis-spring-boot-starterartifactId>
        <version>2.1.1version>
    dependency>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46

    2、对应数据库中添加undo_log表:

    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,
      PRIMARY KEY (`id`),
      UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    3、使用seata DataSourceProxy代理自己的数据源:

    /**
     * 需要用到分布式事务的微服务都需要使用seata DataSourceProxy代理自己的数据源
     */
    @Configuration
    @MapperScan("com.tuling.datasource.mapper")
    public class MybatisConfig {
        
        /**
         * 从配置文件获取属性构造datasource,注意前缀,这里用的是druid,根据自己情况配置,
         * 原生datasource前缀取"spring.datasource"
         *
         * @return
         */
        @Bean
        @ConfigurationProperties(prefix = "spring.datasource")
        public DataSource druidDataSource() {
            DruidDataSource druidDataSource = new DruidDataSource();
            return druidDataSource;
        }
        
        /**
         * 构造datasource代理对象,替换原来的datasource
         * @param druidDataSource
         * @return
         */
        @Primary
        @Bean("dataSource")
        public DataSourceProxy dataSourceProxy(DataSource druidDataSource) {
            return new DataSourceProxy(druidDataSource);
        }
        
        
        @Bean(name = "sqlSessionFactory")
        public SqlSessionFactory sqlSessionFactoryBean(DataSourceProxy dataSourceProxy) throws Exception {
            SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
            //设置代理数据源
            factoryBean.setDataSource(dataSourceProxy);
            ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
            factoryBean.setMapperLocations(resolver.getResources("classpath*:mybatis/**/*-mapper.xml"));
            
            org.apache.ibatis.session.Configuration configuration=new org.apache.ibatis.session.Configuration();
            //使用jdbc的getGeneratedKeys获取数据库自增主键值
            configuration.setUseGeneratedKeys(true);
            //使用列别名替换列名
            configuration.setUseColumnLabel(true);
            //自动使用驼峰命名属性映射字段,如userId ---> user_id
            configuration.setMapUnderscoreToCamelCase(true);
            factoryBean.setConfiguration(configuration);
            
            return factoryBean.getObject();
        }
        
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53

    4、启动类排除DataSourceAutoConfiguration.class:

    @SpringBootApplication(scanBasePackages = "com.tuling",exclude = DataSourceAutoConfiguration.class)
    public class AccountServiceApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(AccountServiceApplication.class, args);
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    如不排除会出现循环依赖的问题:
    在这里插入图片描述
    5、添加seata的配置:
    spring cloud alibaba 2.1.4 之后支持yml中配置seata属性,可以用来替换registry.conf文件
    配置支持实现在seata-spring-boot-starter.jar中,也可以引入依赖

    <dependency>
        <groupId>io.seatagroupId>
        <artifactId>seata-spring-boot-starterartifactId>
        <version>1.4.0version>
    dependency>
    
    • 1
    • 2
    • 3
    • 4
    • 5

    在yml中配置:

    seata:
      # seata 服务分组,要与服务端nacos-config.txt中service.vgroup_mapping的后缀对应
      tx-service-group: my_test_tx_group
      registry:
        # 指定nacos作为注册中心
        type: nacos
        nacos:
          server-addr: 127.0.0.1:8848
          namespace: ""
          group: SEATA_GROUP  
        
      config:
        # 指定nacos作为配置中心
        type: nacos
        nacos:
          server-addr: 127.0.0.1:8848
          namespace: "54433b62-df64-40f1-9527-c907219fc17f"
          group: SEATA_GROUP
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    6、在事务发起者中添加@GlobalTransactional注解:

    @Override
    @GlobalTransactional(name="createOrder")
    public Order saveOrder(OrderVo orderVo){
        log.info("=============用户下单=================");
        log.info("当前 XID: {}", RootContext.getXID());
        
        // 保存订单
        Order order = new Order();
        order.setUserId(orderVo.getUserId());
        order.setCommodityCode(orderVo.getCommodityCode());
        order.setCount(orderVo.getCount());
        order.setMoney(orderVo.getMoney());
        order.setStatus(OrderStatus.INIT.getValue());
    
        Integer saveOrderRecord = orderMapper.insert(order);
        log.info("保存订单{}", saveOrderRecord > 0 ? "成功" : "失败");
        
        //扣减库存
        storageFeignService.deduct(orderVo.getCommodityCode(),orderVo.getCount());
        
        //扣减余额
        accountFeignService.debit(orderVo.getUserId(),orderVo.getMoney());
    
        //更新订单
        Integer updateOrderRecord = orderMapper.updateOrderStatus(order.getId(),OrderStatus.SUCCESS.getValue());
        log.info("更新订单id:{} {}", order.getId(), updateOrderRecord > 0 ? "成功" : "失败");
        
        return order;
        
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
  • 相关阅读:
    <网络> HTTP
    Spring boot项目配置
    第P6周—好莱坞明星识别(1)
    Node.js之http模块
    Python-Selenium
    HTTP安全头部对jsp页面不生效
    Kubernetes存储:Ceph架构,部署和使用
    Java 网络编程
    python代码学习——递归函数
    呼声与现实:WPS Office 64位版何时到来?
  • 原文地址:https://blog.csdn.net/weixin_45161367/article/details/126816235