一篇文章带你读懂MySQL的事务机制原理

一、事务

1.1 多事务并发执行访问MySQL

  通常而言,我们都是在业务系统里开启事务来执行增删改操作,我随便给大家举个例子,下面的代码大家看看。

@Transactional
public void doService() {
	// 增加一条数据
	addUser();
	// 更新一条数据
	updateUser();
	// 删除一条数据
	deleteUser();
}

  所以一般来说,业务系统是执行一个一个的事务,每个事务里可能是一个或者多个增删改查的SQL语句。

  这个事务的概念想必不用我多说了,其实就是一个事务里的SQL要不然一起成功就提交了,要不然有一个SQL失败,那么事务就回滚了,所有SQL做的修改都撤销了!

  接着问题来了,这个业务系统它可不是一个单线程系统啊!它是有多线程的!这个业务系统很可能是基于多线程并发的对MySQL数据库去执行多个事务的!

所以这里就有很多问题了:

  • 多个事务并发执行的时候,可能会同时对缓存页里的一行数据进行更新,这个冲突怎么处理?是否要加锁?
  • 可能有的事务在对一行数据做更新,有的事务在查询这行数据,这里的冲突怎么处理?

  所以接下来,我们要给大家讲解的,就是解决多个事务并发运行的时候,同时写和同时读写的一些并发冲突的处理机制,包括了MySQL事务的隔离级别MVCC多版本并发控制锁机制等等。

1.2 多事务并发执行的脏写、脏读、不可重复读、幻读问题

(1)脏写

  这个脏写的话,它的意思就是说有两个事务,事务A和事务B同时在更新一条数据,事务A先把它更新为A值,事务B紧接着就把它更新为B值。

  此时事务A更新之后会记录一条undo log日志,事务A是先更新的,它更新前,这行数据的值是NULL。所以此时事务A的undo log日志大概就是:更新之前这行数据的值为NULL,主键为XX。

  那么此时事务B更新完了数据的值为B,结果此时事务A突然回滚了,那么就会用它的undo log日志去回滚。此时事务A一回滚,直接就会把那行数据的值更新回之前的NULL值!所以此时事务A回滚了,可能看起来这行数据的值就是NULL了。

  然后就尴尬了,事务B一看,我的妈呀,为什么我更新的B值没了?就因为你事务A反悔了就把数据值回滚成NULL了,搞得我更新的B值也没了,这也太坑爹了吧!

  所以对于事务B看到的场景,就是自己明明更新了,结果值却没了,这就是脏写!

(2)脏读

  假设事务A更新了一行数据的值为A值,此时事务B去查询了一下这行数据的值,看到的值是A。现在事务B拿着查询到的A值做各种业务处理,但是接着坑爹的事情发生了,事务A突然回滚了事务,导致它刚才更新的A值没了,此时那行数据的值回滚为NULL值。

  然后事务B紧接着此时再次查询那行数据的值,看到的居然此时是NULL值,事务B此时简直欲哭无泪。

  其实一句话总结:无论是脏写还是脏读,都是因为一个事务去更新或者查询了另外一个还没提交的事务更新过的数据。因为另外一个事务还没提交,所以它随时可能会反悔会回滚,那么必然导致你更新活着查询的数据就没了,这就是脏写和脏读的两种坑爹场景。

(3)不可重复读

  假设我们有一个事务A开启了,在这个事务A里会多次对一条数据进行查询。然后呢,另外有两个事务,一个是事务B,一个是事务C,它们俩都是对这条数据进行更新的。

  再假设一个前提,就是比如说事务B更新数据之后,如果还没提交,那么事务A是读不到的,必须要事务B提交之后,它修改的值才能被事务A给读取到,其实这种情况下,就是我们首先避免了脏读的发生。但你你以为没有脏读就万事大吉了吗?绝对不是的,此时会有另外一个问题,叫做不可重复读

  假设缓存页里一条数据原来的值是A值,此时事务A开启之后,第一次查询这条数据,读取到的就是A值。

  接着事务B更新了那行数据的值为B值,同时事务B立马提交了,然后事务A此时可是还没提交!它在事务执行期间第二次查询数据,此时查询到的是事务B修改过的B值,因为事务B已经提交了,所以事务A可以读到该值。

  接着事务C再次更新数据为C值,并且提交事务了,此时事务A在没提交事务的情况下,第三次查询数据,查到的值为C值。

  好了,那么上面的场景有什么问题呢?其实要说没问题也可以是没问题,毕竟事务B和事务C都提交之后,事务A多次查询查到他们修改的值,是OK的。

  但是你要说有问题,也可以是有问题的,就是事务A可能第一次查询到的是A值,那么它可能希望的是在事务执行期间,如果多次查询数据,都是同样的一个A值,它希望这个A值是它重复读取的时候一直可以读到的,他希望这行数据的值是可重复读的!

  上面描述的,其实就是不可重复的问题,其实这个问题你说是问题也不一定就是什么大问题,但是说他有问题,确实是有问题的。因为这取决于你自己想要数据库时什么样子的。

(4)幻读

  简单来说,你一个事务A,先发送一条SQL语句,里面有一个条件,要查询一批数据出来,比如 “select * from user where id > 10”,类似这种SQL。

  然后呢,它一开始查询出来了10条数据。接着这个时候,别的事务B往表里插入了几条数据,而且事务B还提交了,此时表里就多了几条数据出来。接着事务A此时再按照之前一模一样的条件执行 “select * from user where id > 10” 这条SQL语句,由于其他事务插入了几条数据,导致这次他查询出来了13条数据。

  于是此时事务A开始怀疑自己的双眼了,为什么一模一样的SQL语句,第一次查询是10条数据,第二次查询是13条数据?难道刚才出现了幻觉?导致我刚才幻读了?这就是幻读这个名词的由来。

  其实脏写脏读不可重复读幻读这些问题的本质,都是数据库的多事务并发问题,那么为了解决多事务并发问题,数据库才设计了事务隔离级别MVCC多版本隔离机制锁机制,用一整套机制来解决多事务并发问题。

1.3 事务隔离级别

1.3.1 SQL标准中事务的4个隔离级别

  注意一下,现在这个说的是SQL标准的事务隔离级别,并不是MySQL的事务隔离级别,MySQL在具体实现事务隔离级别的时候会有点差别。

  • read uncommitted(读未提交)
  • read committed(读已提交)
  • repeatable read(可重复读)
  • serializable(串行化)

(1)read uncommitted(读未提交)

  不允许发生脏写,也就是说,不可能两个事务在没提交的情况下去更新同一行数据的值,但是在这种隔离级别下,可能发生脏读、不可重复读、幻读。

(2)read committed(读已提交)

  不会发生脏写和脏读。也就是说,人家事务没提交的情况下修改的值,你是绝对读不到!但是呢,可能会发生不可重复读和幻读问题,因为一旦人家事务修改了值然后提交了,你事务时会读到的,所以可能你多次读到的值是不同的!

(3)repeatable read(可重复读)

  这个级别下,不会发生脏写、脏读和不可重复读的问题,因为你一个事务多次查询一个数据的值,哪怕别的事务修改了这个值还提交了,没用,你不会读到人家提交事务修改过的值,你事务一旦开始,多次查询一个值,会一直读到同一个值。

(4)serializable(串行化)

  这种级别,根本就不允许你多个事务并发执行,只能串行起来执行,先执行事务A提交,然后执行事务B提交,接着执行事务C提交,所以此时你根本不可能有幻读的问题,因为事务压根儿都不并发执行!

  但是这种级别一般除非脑子坏了,否则更不可能设置了,因为多个事务串行,那数据库很可能一秒并发就只有几十了,性能会极差的。

1.3.2 MySQL中事务的4个隔离级别

  上面我们讲完了SQL标准下的4种事务隔离级别,平时比较多用的就是RC和RR隔离两种级别,那么在MySQL中也是支持那4种隔离级别的,基本的语义都是差不多的。

  但是要注意的一点是,MySQL默认设置的事务隔离级别,都是RR级别的,而且MySQL的RR级别是可以避免幻读发生的。这点是MySQL的RR级别的语义跟SQL标准的RR级别不同的,毕竟SQL标准里规定RR级别是可以发生幻读的,但是MySQL的RR级别避免了!

  然后给大家说一下,假设你要修改MySQL的默认事务隔离级别,是下面的命令,可以设置级别为不同的level,level的值可以是READ UNCOMMITTEDREAD COMMITTEDREPEATABLE READSERIALIZABLE几种级别。

SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level;

  但是一般来说,真的其实不用修改这个级别,就用默认的RR其实就特别好,保证你每个事务跑的时候都没人干扰,何乐而不为呢?

1.3.3 MySQL中MVCC多版本隔离机制

  MySQL中多个事务并发执行时的隔离到底是怎么做的,因为我们知道默认是骚气的RR隔离级别,也就是说脏写、脏读、不可重复读、幻读都不会发生,每个事务执行的时候,跟别的事务压根儿就没关系,不管你别的事务怎么更新和插入,我查到的值都是不变的,是一致的!

  但是这到底是怎么做到的呢?这就是由经典的MVCC多版本并发控制机制做到的,但是讲解这个MVCCMVCC机制之前,我们还得先讲讲undo log版本链的故事,这是一个前奏,了解了这个机制,大家才能更好的理解MVCC机制。

1.3.3.1 undo log 版本链

  简单来说呢,我们每条数据其实都有两个隐藏字段,一个是trx_id,一个是roll_pointer,这个trx_id就是最近一次更新这条数据的事务id,roll_pointer就是指向你这个事务更新数据之前生成的undo log。

  举个例子,现在假设有一个事务A(id=50),插入了一条数据,那么此时这条数据的隐藏字段以及指向的undo log如下图所示,插入的这条数据的值是值A,因为事务A的id是50,所以这条数据的trx_id 就是50,roll_pointer 指向一个空的undo log,因为之前这条数据是没有的。
在这里插入图片描述
  接着假设有一个事务B跑来修改了一下这条数据,把值改成了B值,事务B的id是58,那么此时更新之前会生成一个undo log记录之前的值,然后会让roll_pointer指向这个实际的undo log回滚日志,如下图所示:
在这里插入图片描述
  大家看上图是不是觉得很有意思?事务B修改了值为值B,此时表里的那行数据的值就是值B了,那行数据的trx_id就是事务B的id,也就是58,roll_pointer指向了undo log,这个undo log就记录你更新之前的那条数据的值,在这里就记录了值A。

  接着假设事务C又来修改了一下这个值为值C,它的事务id是69,此时会把数据行里的trx_id改成69,然后生成一条undo log,记录之前事务B修改的那个值,此时如下图所示:
在这里插入图片描述
  我们在上图可以清晰看到,数据行里的值变成了值C,trx_id是事务C的id,也就是69,然后roll_pointer 指向了本次修改之前生成的undo log,也就是记录了事务B修改的那个值,包括事务B的id,同时事务B修改的那个undo log还串联了最早事务A插入的那个undo log。

  所以这就是现在给大家讲的一点,大家先不管多个事务并发执行时如何执行的,起码先搞清楚一点,就是多个事务串行执行的时候,每个人修改了一行数据都会更新隐藏字段trx_id和roll_pointer,同时之前多个数据快照对应的undo log,会通过roll_pointer指针串联起来,形成一个重要的版本链!

1.3.3.2 ReadView机制

  现在我们来讲讲这个 基于undo log多版本链条 实现的 ReadView机制,这个ReadView呢,简单来说,就是你执行一个事务的时候,就会给你生成一个ReadView,里面比较关键的东西有4个

  • m_ids:这个就是说此时有哪些事务在MySQL里执行还没提交的;
  • min_trx_id:就是m_ids里最小的值;
  • max_trx_id:就是说MySQL下一个要生成的事务id,就是最大事务id;
  • creator_trx_id:就是你这个事务的id;

那么现在我们来举个例子,让大家通过例子来理解这个ReadView是怎么用的。

  假设原来数据库里就有一行数据,很早以前就有事务插入过了,事务id是32,它的值就是初始值,如下图所示:
在这里插入图片描述
  接着呢,此时两个事务并发过来执行了,一个是事务A(id=45),一个是事务B(id=59),事务B是要去更新这行数据的,事务A是要去读取这行数据的值的,此时两个事务如下图所示:
在这里插入图片描述
  现在事务A直接开启一个ReadView,这个ReadView里的m_ids就包含了事务A和事务B的两个id,45和59,然后min_trx_id就是45,max_trx_id就是60,creator_trx_id就是45,是事务A自己。

  这个时候事务A第一次查询这行数据,会走一个判断,就是判断一下当前这行数据的trx_id是否小于ReadView中的min_trx_id,此时发现trx_id=32,是小于ReadView里的min_trx_id的45的,说明你事务开启之前,修改这行数据的事务早就提交了,所以此时可以查到这行数据,如下图所示:
在这里插入图片描述
  接着事务B开始动手了,它把这行数据的值修改为了值B,然后这行数据的trx_id设置为自己的id,也就是59,同时roll_pointer指向了修改之前生成的undo log,接着这个事务B就提交了,如下图所示:
在这里插入图片描述
  这个时候事务A再次查询,此时查询的时候,会发现一个问题,那就是此时数据行里的trx_id=59,那么这个trx_id是大于ReadView里的min_trx_id(45),同时小于ReadView里的max_trx_id(60)的,说明更新这条数据的事务,很可能就跟自己差不多同时开启,于是就会看一下这个trx_id=59,是否在m_ids列表里?

  果然,在ReadView的m_ids列表里,有45和59两个事务id,直接证实了,这个修改数据的事务是跟自己同一时段并发执行然后提交的,所以对这行数据是不能查询的!

那么既然这行数据不能查询,那查什么呢?

  简单,顺着这条数据的roll_pointer顺着undo log日志链条往下找,就会找到最近的一条undo log,trx_id是32,此时发现trx_id=32是小于ReadView里的min_trx_id(45)的,说明这个undo log版本必然是在事务A开启之前就执行且提交的。

  好了,那么就查询最近的那个undo log里的值好了,这就是undo log多版本链条的作用,它可以保存一个快照链条,让你可以读到之前的快照值,如下图:
在这里插入图片描述
  看到这里,大家有没有觉得很奇妙?多个事务并发执行的时候,事务B更新的值,通过这套ReadView + undo log版本链的机制,就可以保证事务A不会读到并发执行的事务B更新的值,只会读到之前更早的值。

  接着假设事务A自己更新了这行数据的值,改成值A,trx_id修改为45,同时保证之前事务B修改的值的快照,如下图所示:
在这里插入图片描述
  此时事务A来查询这条数据的值,会发现这个trx_id=45,居然跟自己的ReadView里的creator_trx_id(45)是一样的,说明这行数据就是自己修改的,自己修改的值当然是可以看到的了!

  接着在事务A执行的过程中,突然开启了一个事务C,这个事务的id是78,然后他更新了那行数据的值为值C,还提交了,如下图所示:
在这里插入图片描述
  这个时候事务A再去查询,会发现当前数据的trx_id=78,大于了自己的ReadView中max_trx_id(60),此时说明什么?

  说明是这个事务A开启之后,然后有一个事务更新了数据,自己当然是不能看到的了!

  此时就会顺着undo log多版本链条往下找,自然先找到值A自己之前修改过的那个版本,因为那个trx_id=45跟自己的ReadView里的creator_trx_id是一样的,所以此时直接读取自己之前修改的那个版本,如下图:
在这里插入图片描述
  现在我们知道,通过undo log多版本链条,加上你开启事务的时候生成的ReadView,然后再有一个查询的时候,根据ReadView进行判断的机制,你就知道你应该查询读取哪个版本的数据了。而且他可以保证你只能读到你事务开启之前,别的事务提交更新的值,还有就是你自己事务更新的值。假如说是你事务开启之前,就有别的事务正在运行,然后你事务开启之后,别的事务更新了值,你是绝对读不到的!或者是你事务开启之后,比你晚开启的事务更新了值,你也是读不到的!

通过这套机制就可以实现多个事务并发执行时候的数据隔离。

1.3.3.3 Read Committed隔离级别是如何基于ReadView机制 + undo log版本链实现的?

  这个RC隔离级别,实际上意思就是说你事务运行期间,只要别的事务修改数据还提交了,你就是可以读到人家修改的数据,这种隔离级别下可能会发生不可重复读、幻读问题的。

那么MySQL是如何基于ReadView机制来实现RC隔离级别的呢?

  其实这里的一个非常核心的要点在于,当你一个事务设置它处于RC隔离级别的时候,它是每次发起查询,都重新生成一个ReadView! 大家注意,这点非常重要的,接着我们通过画图一步一步演示这个RC隔离级别是怎么做到的。

  首先假设我们的数据库里有一行数据,事务id=50的一个事务之前就插入进去的,然后现在呢,或者着两个事务,一个是事务A(id=60),一个是事务B(id=70),此时如下图所示:
在这里插入图片描述
  现在的情况就是,事务B发起了一次update操作,更新了这条数据,把这条数据的值更新为值B,所以此时数据的trx_id会变为事务B的id=70,同时会生成一条undo log,由roll_pointer来指向,如下图:
在这里插入图片描述
  这个时候,事务A要发起一次查询操作,此时它一发起查询操作,就会生成一个ReadView,此时ReadView里的min_trx_id=60,max_trx_id=71,creator_trx_id=60,此时如下图所示:
在这里插入图片描述
  这个时候事务A发起查询,发现当前这条数据的trx_id是70。也就是说,属于ReadView的事务id范围之内,说明是它生成ReadView之前就有这个活跃的事务,是这个事务修改了这条数据的值,但是此时这个事务B还没有提交,所以ReadView的m_ids活跃事务列表里,是有[60,70]两个id的,所以此时根据ReadView的机制,此时事务A是无法查到事务B修改的值B的。

  接着就顺着undo log版本链条往下查找,就会找到一个原始值,发现发的trx_id是50,小于当前ReadView里的min_trx_id,说明是它生成ReadView之前,就有一个事务插入了这个值并且早就提交了,因此可以查到这个原始值。如下图:
在这里插入图片描述

  接着,咱们假设事务B此时就提交了,好了,那么提交了就说明事务B不会活跃与数据库里了,是不是?可以的,大家一定记住,事务B现在提交了。那么按照RC隔离级别的定义,事务B此时一但提交了,说明事务A下次再查询,就可以读到事务B修改过的值了,因为事务B提交了。

那么到底怎么让事务A能够读到提交的事务B修改过的值呢?

  很简单,就是事务A下次发起查询,再次生成一个ReadView。此时再次生成ReadView,数据库内活跃的事务只有事务A了,因此min_trx_id是60,max_trx_id是71,但是m_ids这个活跃事务列表里,只会有一个60了,事务B的id=70不会出现在m_ids活跃事务列表里了。如下图:
在这里插入图片描述
  此时事务A再次基于这个ReadView去查询,会发现这条数据的trx_id=70,虽然在ReadView的min_trx_id和max_trx_id范围之间,但是此时并不在m_ids列表内,说明事务B在生成本次ReadView之前就已经提交了。

  那么既然在生成本次ReadView之前,事务B就已经提交了,就说明这次你查询就可以查到事务B修改过的这个值了,此时事务A就会查到值B,如下图所示:
在这里插入图片描述
  到目前为止,RC隔离级别如何实现的,大家应该就理解了,它的关键点在于每次查询都生成新的ReadView,那么如果在你这次查询之前,有事务修改了数据还提交了,你这次查询生成的ReadView里,那个m_ids列表当然不包含这个已经提交的事务了,既然不包含已经提交的事务了,那么当然可以读到人家修改过的值了。

1.3.3.4 Repeatable Read 隔离级别是如何基于ReadView机制 + undo log版本链实现的?

  在MySQL的RR隔离级别下,你这个事务读一条数据,无论读多少次,都是同一个值,别的事务修改数据之后哪怕提交了,你也是看不到人家修改的值,这就避免了不可重复读的问题。同时如果别的事务插入了一些新的数据,你也是读不到的,这样你就可以避免幻读的问题。

那么到底是如何实现的呢?

  首先我们还是假设有一条数据是事务id=50的一个事务插入的,同时此时有事务A和事务B同时在执行,事务A的id是60,事务B的id是70,如下图所示:
在这里插入图片描述
  这个时候,事务A发起了一个查询,他就是第一次查询就会生成一个ReadView,此时ReadView里的creator_trx_id是60,min_trx_id是60,max_trx_id是71,m_ids是 [60,70],此时ReadView如下图所示:
在这里插入图片描述
  这个时候事务A基于这个ReadView去查这条数据,会发现这条数据的trx_id为50,是小于ReadView里的min_trx_id的,说明他发起查询之前,早就有事务插入这条数据并且提交了,所以此时可以查到这条原始值的,如下图:
在这里插入图片描述
  接着就是事务B此时更新了这条数据的值为值B,此时会修改trx_id为70,同时生成一个undo log,而且关键时事务B此时他还提交了,也就是说此时事务B已经结束了,如下图所示:
在这里插入图片描述
这个时候大家思考一个问题,ReadView中的m_ids此时还会是60和70?

  那必然是的,因为ReadView一旦生成了就不会改变了,这个时候虽然事务B已经结束了,但是事务A的ReadView里,还是会有60和70两个事务id。

  那么好,接着此时事务A去查询这条数据的值,他会惊讶的发现此时数据的trx_id是70了,70一方面是在ReadView的min_trx_id和max_trx_id的范围之内,同时还在m_ids列表里,所以此时事务A是不能查询到事务B更新的这个值,因此这个时候继续顺着指针往历史版本链条上去找,如下图:
在这里插入图片描述
  接着事务A顺着指针找到下面一条数据,trx_id为50,是小于ReadView的min_trx_id的,说明在他开启查询之前,就已经提交了这个事务了,所以事务A是可以查询到这个值的,此时事务A查到的是原始值,如下图:
在这里插入图片描述
  看到这里,是不是感觉到这一下子就避免了不可重复读的问题?你事务A多次读同一个数据,每次读到的都是一样的值,除非是它自己修改了值,否则读到的一直会是一样的值。

  接着,我们来看看幻读的问题它是如何解决的。假设现在事务A先用select * from user where id > 10来查询,此时可能查询到的就是一条数据,而且读到的是这条数据的原始值的那个版本,如下图:
在这里插入图片描述
现在有一个事务C插入了一条数据,然后提交了,此时如下图所示:
在这里插入图片描述
  接着,此时事务A再次查询,此时会发现符合条件的有2条数据,一条是原始值那个数据,一条是事务C插入的那条数据,但是事务C插入的那条数据的trx_id是80,这个80是大于自己的ReadView的max_trx_id的,说明是自己发起查询之后,这个事务才启动的,所以此时这条数据是不能查询的。

因此事务A本次查询,还是只能查到原始值一条数据,如下图:
在这里插入图片描述
  所以大家可以看到,在这里,事务A根本不会发生幻读,它根据条件范围查询的时候,每次读到的数据都是一样的,不会读到人家插入进去的数据,这都是依托ReadView机制实现的。

1.3.3.5 梳理一下数据库的多事务并发运行的隔离机制

  现在我们简单梳理一下MySQL中的多事务并发运行的隔离原理,其实这套隔离原理,说白了就是MVCC机制,也就是multi-version concurrent control,就是多版本并发控制机制,专门控制多个事务并发运行的时候,互相之间会如何影响。

  首先我们要先明白,多个事务并发运行的时候,同时读写一个数据,可能会出现脏写脏读不可重复读幻读几个问题。

  • 所谓的脏写,就是两个事务都更新一个数据,结果有一个人回滚了把另外一个人更新的数据也回滚没了。
  • 脏读,就是一个事务读到了另外一个事务没提交的时候修改的数据,结果另外一个事务回滚了,下次读就读不到了。
  • 不可重复读,就是多次读一条数据,别的事务老是修改数据值还提交了,多次读到的值不同。
  • 幻读,就是范围查询,每次查到的数据不同,有时候别的事务插入了新的值,就会读到更多的数据。

针对这些问题,所以才有RURCRR串行化4个隔离级别。

  • RU隔离级别,就是可以读到人家没提交的事务修改的数据,只能避免脏写问题;
  • RC隔离级别,可以读到人家提交的事务修改过的数据,可以避免脏写和脏读问题。
  • RR隔离级别,是不会读到别的已经提交事务修改的数据,可以避免脏写、脏读、不可重复读问题;
  • 串行是让事务都串行执行,可以避免所有问题;

  然后MySQL实现MVCC机制的时候,是基于undo log多版本链条 + ReadView机制来做的,默认的RR隔离级别,就是基于这套机制来实现的,依托这套机制实现了RR级别,除了避免脏写、脏读、不可重复读,还能避免幻读问题。因此一般来说我们都用默认的RR隔离级别就好了。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值