大家好,欢送来到小菜同学的集体 solo 学堂,常识收费,不吝排汇!关注收费,不吝入手!

本文次要介绍 分布式事务

如有须要,能够参考

如有帮忙,不忘 点赞

微信公众号已开启,小菜良记,没关注的同学们记得关注哦!

生存可能对你耍无赖,但科技不能

我去小卖部买货色,付完了钱,老板转身抽了口烟,却遗记了我付完钱?这种状况怎么办,产生在日常生活并不奇怪。然而你在网上下单,付完了钱,刚要查看订单,却提醒你待领取,心中几万只草泥马跑过也不得而知!所以避免这种状况的产生,分布式事务也变得尤为重要。

有人纳闷了,付不付钱跟分布式事务有什么关系,这不是程序耍无赖吗?然而耍无赖的背地却是因为分布式事务在作怪!如果还不明确,那可能你还没明确什么是事务,什么是分布式事务~

分布式事务

定义

事务提供一种机制将一个流动波及的所有操作都纳入到一个不可分割的执行单元,组成事务的祝所有操作只有在操作均失常执行的状况下能力提交,只有其中任一操作执行失败,都会导致整个事务的回滚。简略来说,要么做,要么不做

听起来有点 man,不要迷恋,先来理解一下事务的四大个性:ACID

  • A(Atomic):原子性,形成事物的所有操作,要么全副执行实现,要么全副不执行,不可能呈现局部胜利局部失败的状况。
  • C(Consistency):一致性,在事务执行前后,数据库的一致性束缚没有被毁坏。一旦所有事务动作实现,事务就被提交,数据和资源处于一种满足业务规定的一致性状态中。比方下面说的,我向商店老板付了钱,我这边扣除了100,而老板减少了100,这种就称为一致性。
  • I(Isolation):隔离性,数据库中的事务个别都是并发的,隔离性是指并发的两个事务互不烦扰,一个事务不能看到其余事务运行过程的中间状态,通过配置事务隔离级别能够防止在脏读、幻读、不可反复读等问题。
  • D(Durability):持久性,事务实现之后,该事务对数据的更改会长久化到数据库中,并且不会被回滚
单体事务

晚期咱们应用的还是单体架构,像是一个大家族,其乐融融的生存在一起日夜耕作。

工夫久了,各种各样的问题自然而然的也呈现了:复杂性高,部署频率低,可靠性差,扩大能力受限... 接受了太多不该接受的风言风语,而大家也逐步找寻新的前途, 那微服务架构也便受应呈现:易于开发、扩大、了解和保护,不会受限于任何技术栈,易于和第三方利用系统集成... 太多太多的长处,让单体零碎也逐步淡出人们的视角,如同如果当初不必微服务架构开发我的项目,就与社会脱节了~ 益处很多,然而问题也会变得更加简单。这节咱们不讲别的,就来看看分布式事务是咋回事。

事务无论在单体还是微服务中都必定是存在,然而在 单体 架构中,咱们通常是怎么解决事务的呢? @Transactional,单靠这个注解就能够开启事务来保障整个操作的 原子性

分布式事务

微服务架构,其实就是将传统的单体拆分成多个服务,而后多个服务之间相互配合,来实现业务需要。

分布式事务就是指事务的参与者,反对事务的服务器,资源服务器以及事务管理器别离位于不同的分布式系统的不同节点之上。

既然说到分布式事务了,咱们无妨一起理解一下微服务中的 CAP实践

  • C(Consistency):一致性。服务A、B、C三个节点都存储了用户数据,三个节点的数据都须要放弃同一时刻数据一致性
  • A(Availability):可用性。服务A、B、C三个节点,其中一个节点如果宕机了,不能影响整个集群对外提供服务。
  • P(Partition Tolerance):分区容错性就是容许零碎通过网络协同工作,分区容错性要解决因为网络分区导致数据的不残缺及无法访问等问题。

咱们都晓得鱼和熊掌不可兼得,三者不能兼备择两者是也!CAP 目前来说无奈都兼备,因而以后微服务策略中要么 CA,要么CP,不然就是AP。而这个时候又有一个实践呈现了,那就是 BASE实践 。它是用来对 CAP实践 进行一些补充,它值得是:

  • BA(Basically Available):根本可用
  • S(Soft State):软状态
  • E(Eventually Consistent):最终一致性

这个实践的核心思想便是:如果咱们如法做到强一致性,那么每个利用都应该依据本身的业务特点,采纳适当的形式来使零碎达到最终一致性。

呈现场景

让咱们回到分布式事务中来,什么时候会呈现分布式事务呢?

场景1: 尽管时单体的架构服务,但因为在分库的状况下,仍然会导致分布式事务的状况,因而单体服务不会呈现分布式事务的这种说法,~

场景2: 分布式架构下,两个服务之间互相调用,尽管应用的是同一个数据库,然而还是会呈现分布式事务。谁让你应用的是分布式架构呢~

场景3: 分布式架构下,两个服务之间互相调用,应用的是不必的数据库,这种状况下必定会呈现分布式事务的问题,想都不必想!

解决办法

有问题的中央便会有办法,当然也不肯定,然而在这里,分布式事务问题的确有解决的办法, 如果没有,小菜也不会写这篇文章来自讨苦吃了!

办法一:全局事务

不晓得这里该说 全局事务 会让你比拟相熟,还是 两阶段提交(2PC) 会让你比拟相熟,还是说都不相熟~,不相熟也没关系,小菜带你相熟相熟!

全局事务是基于DTP模型实现的,它规定了要实现分布式事务须要三种角色:

  • AP(Application):利用零碎(微服务)
  • TM(Transaction Manager):事务管理器(全局事务管理)
  • RM(Resource Manager):资源管理器(数据库)

除了 AP 这个角色,咱们多意识了其余两个同学别离是事务管理器资源管理器,那么他们起到什么作用呢,那咱们就得看,这个两阶段提交是哪两阶段了!

阶段1 :表决阶段

所有参与者都将本人的事务进行预提交,并将是否胜利的信息反馈给协调者

  1. 事务管理器发一个 prepare 指令给 A 和 B 两个服务器
  2. A 和 B 两个服务器收到音讯后,依据本身状况,判断本人是否能够提交事务
  3. 将处理结果记录到资源管理器中
  4. 将处理结果返回给事务管理器
阶段2 :执行阶段

协调者依据所有参与者的反馈,告诉所有参与者,各自为政地执行提交或者回滚

  1. 事务管理器向 A 和 B 两个服务器发送提交指令
  2. A 和 B 两个服务器收到指令后,将本人自身事务提交
  3. 将处理结果记录到资源管理器
  4. 将处理结果返回给事务管理器

这就是两阶段提交的大抵过程,它进步了数据一致性的概率,实现老本较低。然而这种实现形式带来的毛病也是很显著的!

  • 单点故障:如果事务管理器呈现了故障,整个零碎将不可用
  • 同步阻塞:提早了提交事件,加长了资源阻塞事件,不适宜高并发的场景
  • 数据不统一:如果执行到第二阶段,仍然存在commit后果未知的状况,只有局部参与者接管到 commit 音讯,局部没有收到,那也只有局部参与者提交了事务,仍然会导致数据不统一问题
办法二:三阶段提交

既然两阶段提交解决不了问题,那咱们就来三阶段提交。三阶段提交绝对于两阶段提交来说减少了 ConCommit 阶段和超时机制。在一段规定工夫内,如果服务器参与者没有承受到来自事务管理器的提交执行,那他们就会本人主动提交,这样子就能解决两阶段中单体故障问题。

咱们来看看三阶段提交是哪三阶段:

  • CanCommit:筹备阶段。这个阶段要做的事就和两阶段提交一样,先去询问参与者是否有条件接管这个事务,这样子不会太暴力,一开始就间接干活锁死资源。
  • PreCommit:这个阶段是事务管理器向各个参加者发送筹备提交申请,各个参与者接到申请或,将处理结果记录到本人的资源管理器中,如果筹备好了,就会想协调者反馈ACK示意我曾经筹备好提交了。
  • DoCommit:这个就断就是从 预提交状态 转为 提交状态。事务管理器向各个参与者发送 提交 申请,参与者接管到申请后,就会各自执行本人事务的提交操作。将处理结果记录到本人的资源管理器中,并向协调者反馈 ACK 示意本人曾经实现事务,如果有一个参与者未实现PreCommit的反馈或者反馈超时,那么协调者都会向所有的参与者节点发送abort申请,从而中断事务。

其实三阶段提交看起来就是把两阶段提交中的提交阶段变成了 预提交阶段提交阶段

那其实从下面能够看到,三阶段提交解决的只是两阶段提交中 单体故障 的问题,因为退出了超时机制,这里的超时的机制作用于 预提交阶段提交阶段。如果期待 预提交申请 超时,那参与者相当于说啥都没干,间接回到筹备阶段之前。如果等到提交申请超时,那参与者就会提交事务了。

所以能够看到其实 三阶段提交还是没基本解决问题,尽管比两阶段提交提高了一点点~

办法三:TCC

TCC(Try Confirm Cancel) ,它是属于弥补型分布式事务。它的核心思想是 针对每个操作,都要注册一个与其对应的确认和弥补(撤销)操作。TCC 实现分布式事务一共有三个步骤:

  • Try:尝试待执行的业务

这个过程并未执行业务,只是实现所有业务的一致性查看,并预留好执行所需的所有资源

  • Confirm:确认执行业务

确认执行业务的操作,不做任何业务查看,只应用Try阶段预留的业务资源。通常状况下,采纳TCC则会认为 Confirm 阶段是不会出错的。只有 Try 胜利,则 Confirm 肯定胜利。如果 Confirm 出错了,则须要引入重试机制或人工解决

  • Cancel:勾销待执行的业务

勾销 Try 阶段预留的业务资源。通常状况下,采纳 TCC 则认为 Cancel 阶段也是肯定能胜利的,若 Cancel 阶段真的出错了,也要引入重试机制或人工解决

TCC 是业务层面的分布式事务,最终一致性,不会始终持有资源的锁。它的优缺点如下:

长处: 吧数据库层的二阶段提交上提到了应用层来实现,躲避了数据库的 2PC 性能低下问题

毛病:TCC 的 Try、Confirm 和 Cancel 操作性能需业务提供,开发成本高。TCC 对业务的侵入较大和业务紧耦合,须要依据特定的场景和业务逻辑来设计相应的操作

办法五:可靠消息事务

音讯事务的原理是 将两个事务通过消息中间件来进行异步解耦。基于可靠消息服务的计划是通过消息中间件来保障上、上游利用数据操作的一致性。假如有 A、B两个服务,散布能够解决 A、B两个工作,此时须要存在一个业务流程,将工作 A和B 放到同一个事物中解决,这种形式就能够借助消息中间件来实现。

整体上能够分为两个大的步骤:A服务向消息中间件公布音讯音讯向B服务投递音讯

步骤一: A 服务向消息中间件公布音讯

  1. 在服务A解决工作A前,首先向消息中间件发送一条半信息
  2. 消息中间件收到后将该音讯长久化,但不进行投递。长久化胜利后,向A服务返回确认应答
  3. 服务A收到确认应答后,便能够开始解决工作A
  4. 工作A解决实现后,服务A便会向消息中间件发送Commit 或者 Rollback 申请,该申请发送实现后,服务A的工作工作就完结了,该事务的处理过程也就完结了
  5. 在消息中间件收到 Commit 后,便会向 B 服务投递音讯,如果收到 Rollback 便会间接抛弃音讯

如果消息中间件在最初的过程中,长时间没有收到服务A 发送的 CommitRollback 指令,这个时候就须要依附 超时询问机制

超时询问机制

服务A除了实现失常的业务流程之外,还是须要提供一个可供消息中间件事务询问的接口。在消息中间件第一次收到音讯后便会开始计时,如果超过规定的工夫没有收到后续的指令,就会被动调用服务A提供的事务询问接口,询问以后服务的状态,通常来说该接口会返回三种后果,中间件须要依据这三种不同的后果做出不同的解决:

  • 提交:间接将该音讯投递给服务B
  • 回滚:间接将该音讯抛弃
  • 解决中:持续期待,从新计时

步骤二: 消息中间件向B服务投递音讯

消息中间件收到A服务的提交 Commit指令后便会将该音讯投递给B服务,而后将本人的状态置为阻塞期待状态。B服务收到消息中间件发送的音讯后便开始解决工作B,解决实现后便会向消息中间件收回回应。然而在消息中间件阻塞期待的时候同样会呈现问题

  • 失常状况:消息中间件投递完音讯后,进入阻塞期待状态,在收到确认应答后便认为事务处理实现,该流程完结
  • 期待超时状况:在期待确认应答超时之后就会从新进行投递,直到B服务器返回生产胜利响应为止。而音讯重试的次数和工夫距离都能够设置,如果最终还是不能胜利进行投递,则须要人工干预。

可靠消息服务计划是实现了 最终一致性。比照本地音讯表实现计划,不须要再建设音讯表。不必依赖本地数据库事务,实用于高并发的场景。RocketMQ 就很好的反对了音讯事务。

办法四:最大致力告诉

最大致力告诉也成为定期校对,是对可靠消息服务的进一步优化。它引入了本地音讯表来记录谬误音讯,而后退出失败音讯的定期校对性能,来进一步保障音讯会被上游服务生产。

同样的这个跟音讯事务一样能够分为两步:

步骤一: 服务A向消息中间件发送音讯

  1. 在解决业务的同一个事务中,向本地音讯表写入一条记录
  2. 音讯发送者一直取出本地音讯表中的音讯发送到消息中间件,如果发送失败则进行重试

步骤二: 消息中间件向服务B投递音讯

  1. 消息中间件收到音讯后便会将音讯投递到上游服务B,服务B收到音讯后便会执行本人的业务
  2. 当服务B业务解决胜利后,便会向消息中间件返回反馈应答,消息中间件便可将该音讯删除,该流程完结
  3. 如果消息中间件向服务B投递音讯失败,便会尝试重试,如果重试失败,便会将该音讯接入失败音讯表中
  4. 消息中间件同样须要提供查问失败音讯的接口,服务B 定期查问失败信息,并进行生产

最大致力告诉的计划实现比较简单,实用于一些最终一致性要求比拟低的业务。

Seata

Seata概念

既然分布式事务处理起来这么麻烦,那能不能让分布式事务处理起来像本地事务那么简略。当然这是咱们的愿景。当然这个愿景是所有开发人员所心愿的。而阿里巴巴团队就为这个愿景做出了口头,发动了开源我的项目 Seata(Simple Extensible Autonomous Transaction Architecture) 。这是一套分布式事务解决方案,意在解决开发人员遇到的分布式事务各方面的难题。

Seata 的设计指标是对业务无侵入,因而它是从业务无侵入的两阶段提交(全局事务)着手,在传统的两阶段上进行改良,他把一个分布式事务了解成一个蕴含了若干分支事务的全局事务。而全局事务的职责是协调它治理的分支事务达成一致性,要么一起胜利提交,要么一起失败回滚。也就是一荣俱荣一损俱损~

Seata 组成

咱们看下 Seata 中存在几种重要角色:

  • TC(Transaction Coordinator):事务协调者。治理全局的分支事务的状态,用于全局性事务的提交和回滚。
  • TM(Transaction Manager):事务管理者。用于开启、提交或回滚事务。
  • RM(Resource Manager):资源管理器。用于分支事务上的资源管理,向 TC 注册分支事务,上报分支事务的状态,接管 TC 的命令来提交或者回滚分支事务。

这是一种很奇妙的设计,咱们来看图:

执行流程是这样的:

  1. 服务A中的 TMTC 申请开启一个全局事务,TC 就会创立一个全局事务并返回一个惟一的 XID
  2. 服务A中的 RMTC 注册分支事务,而后将这个分支事务纳入 XID 对应的全局事务管辖中
  3. 服务A开始执行分支事务
  4. 服务A开始近程调用B服务,此时 XID 会依据调用链流传
  5. 服务B中的 RM 也向 TC 注册分支事务,而后将这个分支事务纳入 XID 对应的全局事务管辖中
  6. 服务B开始执行分支事务
  7. 全局事务调用解决完结后,TM 会依据有误异常情况,向 TC 发动全局事务的提交或回滚
  8. TC 协调其管辖之下的所有分支事务,决定是提交还是回滚

Seata应用

咱们从下面理解到了 Seata 的组成和执行流程,咱们接下来就来理论的应用下 Seata

示例演示

咱们简略创立了一个微服务项目,其中有订单服务和库存服务。

咱们这里采纳了 nacos 作为注册核心,别离启动两个服务,咱们在nacos控制台能够看到两个曾经注册的服务:

号外:如果对nacos还不相熟的小伙伴能够跳转查看 nacos解说:微服务新秀之Nacos

咱们接着创立了一个数据库,其中有两张表:c_orderc_product,其中商品表中有一条数据,而订单表中还未有数据,接下来咱们将要对其进行操作!

咱们当初模仿一个下单的过程:

  1. 申请进来,通过商品 pid 往数据库中查商品的信息
  2. 创立一条该商品的订单
  3. 对应扣减该商品的库存量
  4. 流程完结

咱们接下来就进入代码演示一下:

留神:这里 ProductService 并非是库存服务外面的类,而是利用 Feign 近程调用库存服务的接口

代码三步走,失常请况下必定是没有问题的:

订单生成,库存也对应缩小,感觉本人代码能够上线进入正规的时候,咱们来模仿一下库存中的异样,库存商品数量归为 100,订单表清空:

咱们持续发送下单申请,能够看到库存服务曾经抛出了异样

失常来说这个时候,库存表数量不应该缩小,订单表不应该插入订单数据,然而事实真的是这样的吗?咱们看数据:

库存数量没减,然而订单却减少了。好了,到这里,你就曾经见识到了分布式事务的灾难性危害。接下来配角退场!

Seata 装置

咱们首先须要点击下载地址进行下载 Seata

因为咱们是应用 nacos 作为服务中心和配置核心,因而咱们下载解压后须要做一些批改操作

  • 进入 conf 目录编辑 registry.conffile.conf 两个文件,编辑后内容如下:

  • 因为新版 Seata 中没有 nacos-conf.shconfig.txt 两个文件,因而咱们须要独立下载:
nacos-config.sh 下载地址

config.txt 下载地址

咱们须要将 config.txt 文件放到 seata 目录下,而非 conf 目录下,并且须要批改 config.txt 内容

config.txt就是seata各种具体的配置,执行 nacos-config.sh 即可将这些配置导入到nacos,这样就不须要将file.confregistry.conf放到咱们的我的项目中了,须要什么配置就间接从nacos中读取。
  • 执行导入

conf 目录下关上 git bash 窗口,执行以下命令:

sh nacos-config.sh -h localhost -p 8848 -g SEATA_GROUP -t namespace-id(须要替换) -u nacos -w nacos

操作完结后,咱们便能够在 nacos 控制台中看到配置列表,日后配置有须要批改便能够间接从这边批改,而不必批改目录文件:

  • 数据库配置

1.4.1 最新版中仍然没有 sql 文件,所以咱们还是须要另外下载:sql 下载地址

seata 数据中执行这个文件,生成三张表:

在咱们的业务数据库中执行 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,    `ext` VARCHAR(100) DEFAULT NULL,    PRIMARY KEY (`id`),    UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)) ENGINE = INNODBAUTO_INCREMENT = 1DEFAULT CHARSET = utf8;
  • 增加 log 文件

如果咱们没有log输入文件,启动 seata 可能会报错,因而咱们须要在 seata 目录下创立 logs 文件夹,在 logs 文件下创立 seata_gc.log 文件

  • 启动

做好了以上筹备,咱们便能够启动 seata 了,间接在 bin 目录下 cmd 执行 bat 脚本即可,启动完结便可在 nacos 中看到 seata 服务:

Seata 集成

在 Seata 装置的步骤中咱们便实现了 Seata 服务端 的启动装置,接下来就是在我的项目中集成 Seata 客户端

  • 第一步:咱们须要在 pom.xml 文件中增加两个依赖:seata 依赖nacos 配置依赖
<!--nacos--><dependency>    <groupId>com.alibaba.cloud</groupId>    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency><!--seata--><dependency>            <groupId>com.alibaba.cloud</groupId>            <artifactId>spring-cloud-starter-alibaba-seata</artifactId>            <exclusions>                <!-- 排除依赖 指定版本和服务器端统一 -->                <exclusion>                    <groupId>io.seata</groupId>                    <artifactId>seata-all</artifactId>                </exclusion>                <exclusion>                    <groupId>io.seata</groupId>                    <artifactId>seata-spring-boot-starter</artifactId>                </exclusion>            </exclusions>        </dependency>        <dependency>            <groupId>io.seata</groupId>            <artifactId>seata-all</artifactId>            <version>1.4.1</version>        </dependency>        <dependency>            <groupId>io.seata</groupId>            <artifactId>seata-spring-boot-starter</artifactId>            <version>1.4.1</version>        </dependency>

留神: 这里须要排除 spring-cloud-starter-alibaba-seata 自带的 seata 依赖,而后引入咱们本人须要的 seata 版本,如果版本不统一启动时可能会造成 no available server to connect 谬误!

  • 第二步:咱们须要把 restry.conf 文件复制到我的项目的 resource 目录下

  • 第三步:须要本人配置seata代理数据源
@Configurationpublic class DataSourceProxyConfig {    @Bean    @ConfigurationProperties(prefix = "spring.datasource")    public DruidDataSource druidDataSource() {        return new DruidDataSource();    }    @Primary    @Bean    public DataSourceProxy dataSource(DruidDataSource druidDataSource) {        return new DataSourceProxy(druidDataSource);    }}

配置完数据源咱们得在启动类的 SpringBootApplication 上排除Druid数据源依赖,否则可能会呈现循环依赖的谬误:

@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
  • 第四步:在 nacos 的配置文件控制台中退出咱们服务的事务组项:
service.vgroupMapping + 服务名称 = defaultgroup为: SEATA_GROUP

  • 第五步:我的项目中配置批改

  • 第六步:开启全局事务

这步就是最终一步了,在咱们须要开启事务的办法上增加 @GlobalTransactional 注解,相似于咱们单体事务增加的@Transactional

Seata 测试

咱们当初回到我的项目中,在下面的示例演示中,咱们曾经晓得了如果库存服务产生异样,会呈现的状况是,库存没有缩小,而订单仍然会生成。那咱们如果减少了 Seata 来治理全局事务,状况是否会有所扭转?咱们测试如下:

库存服务曾经了异样:

看下数据库数据:

看样子咱们全局事务曾经失效了,事务也曾经完满的管制住了!

而咱们创立的 undo_log 这张表在治理事务中也启动了重要的作用:

看完了以上操作,咱们趁热打铁来梳理一下其中的执行流程,让你印象更加粗浅些~

置信看完这张图,你对 Seata 执行事务的流程也更加相熟了吧!

这还没完结,咱们接着来看看其中的一些要点:

  1. 每个 RM 都须要应用 DataSourceProxy 连贯数据库,这样是为了应用 ConnectionProxy,应用数据源和数据连贯代理的目标就是在第一阶段将 undo_log 和业务数据放在一个本地事务提交,这样就保留了只有有业务操作就肯定有 undo_log 产生!
  2. 在第一阶段的 undo_log 中寄存了数据批改前和批改后的值,为事务回滚做好筹备,所以第一阶段就曾经将分支事务提交,也就开释了锁资源!
  3. TM 开启全局事务后,便会将 XID 放入全局事务的上下文中,咱们通过 feign 调用也会将 XID 传入上游服务中,每个分支事务都会将本人的 Branch IDXID 相关联!
  4. 第二阶段如果全局事务是失常提交,那么TC 会告诉各分支参与者提交分支事务,各参与者只须要删除对应的 undo_log 即可,并且能够异步执行!
  5. 第二阶段如果全局事务须要回滚,那么 TC 会告诉各分支事务参与者回滚分支事务,通过 XIDBranch ID 找到相应的 undo_log 日志,通过回滚日志生成反向 SQL 并执行,实现事务提交之前的状态,如果回滚失败便会重试回滚操作!

END

到这里,一篇分布式事务就讲完了,咱们回顾下,从分布式事务的五种解决方案到引出 Seata 的应用,小菜同学真是用心良苦~ 言归正传,看完后排汇了多少,动动小手,写写代码,让常识与你更亲热~

明天的你多致力一点,今天的你就能少说一句求人的话!

我是小菜,一个和你一起学习的男人。 ????

微信公众号已开启,小菜良记,没关注的同学们记得关注哦!