分布式事务解决方案汇总:2PC、消息中间件、TCC、状态机+重试+幂等(转)

  数据一致性问题很是多样,下面举一些常见例子。好比在更新数据的时候,先更新了数据库,后更新了缓存,一旦缓存更新失败,此时数据库和缓存数据会不一致。反过来,若是先更新缓存,再更新数据库,一旦缓存更新成功,数据库更新失败,数据仍是不一致;java

  好比数据库中的参照完整性,从表引用了主表的主键,对从表来讲,也就是外键。当主表的记录删除后,从表是字段置空,仍是级联删除。一样,当要建立从表记录时,主表记录是否要先建立,仍是能够直接建立从表的记录;算法

   好比数据库中的原子性:同时修改两条记录,一条记录修改为功了,一条记录没有修改为功,数据就会不一致,此时必须回滚,不然会出现脏数据。 数据库

  好比数据库的Master-Slave异步复制,Master宕机切换到Slave,致使部分数据丢失,数据会不一致。缓存

   发送方发送了消息一、二、三、四、5,由于消息中间件的不稳定,致使丢了消息4,接收方只收到了消息一、二、三、5,发送方和接收方数据会不一致。网络

   从以上案例能够看出,数据一致性问题几乎无处不在。本书把一致性问题分为了两大类:事务一致性和多副本一致性。这两类一致性问题基本涵盖了实践中所遇到的绝大部分场景,本章和下一章将分别针对这两类一致性问题进行详细探讨。架构

 

随处可见的分布式事务问题并发

   在“集中式”的架构中,不少系统用的是Oracle这种大型数据库,把整个业务数据放在这样一个强大的数据库里面,利用数据库的参照完整性机制、事务机制,避免出现数据一致性问题。这正是数据库之因此叫“数据库”而不是“存储”的一个重要缘由,就是数据库强大的数据一致性保证。框架

  但到了分布式时代,人们对数据库进行了分库分表,同时在上面架起一个个的服务。到了微服务时代,服务的粒度拆得更细,致使一个没法避免的问题:数据库的事务机制无论用了,由于数据库自己只能保证单机事务,对于分布式事务,只能靠业务系统解决。 异步

  例如作一个服务,最初底下只有一个数据库,用数据库自己的事务来保证数据一致性。随着数据量增加到必定规模,进行了分库,这时数据库的事务就无论用了,如何保证多个库之间的数据一致性呢?分布式

   再以电商系统为例,好比有两个服务,一个是订单服务,背后是订单数据库;一个是库存服务,背后是库存数据库,下订单的时候须要扣库存。不管先建立订单,后扣库存,仍是先扣库存,后建立订单,都没法保证两个服务必定会调用成功,如何保证两个服务之间的数据一致性呢? 

  这样的案例在微服务架构中随处可见:凡是一个业务操做,须要调用多个服务,而且都是写操做的时候,就可能会出现有的服务调用成功,有的服务调用失败,致使只部分数据写入成功,也就出现了服务之间的数据不一致性。 

分布式事务解决方案汇总

   接下来,以一个典型的分布式事务问题——“转帐”为例,详细探讨分布式事务的各类解决方案。

  以支付宝为例,要把一笔钱从支付宝的余额转帐到余额宝,支付宝的余额在系统A,背后有对应的DB1;余额宝在系统B,背后有对应的DB2;蚂蚁借呗在系统C,背后有对应的DB3,这些系统之间都要支持相关转帐。所谓“转帐”,就是转出方的系统里面帐号要扣钱,转入方的系统里面帐号要加钱,如何保证两个操做在两个系统中同时成功呢? 

1. 2PC

 (1)2PC理论。在讲MySQL Binlog和Redo Log的一致性问题时,已经用到了2PC。固然,那个场景只是内部的分布式事务问题,只涉及单机的两个日志文件之间的数据一致性;2PC是应用在两个数据库或两个系统之间。 

  2PC有两个角色:事务协调者和事务参与者。具体到数据库的实现来讲,每个数据库就是一个参与者,调用方也就是协调者。2PC是指事务的提交分为两个阶段,如图10-1所示。

    阶段1:准备阶段。协调者向各个参与者发起询问,说要执行一个事务,各参与者可能回复YES、NO或超时。

    阶段2:提交阶段。若是全部参与者都回复的是YES,则事务协调者向全部参与者发起事务提交操做,即Commit操做,全部参与者各自执行事务,而后发送ACK。

 

  若是有一个参与者回复的是NO,或者超时了,则事务协调者向全部参与者发起事务回滚操做,全部参与者各自回滚事务,而后发送ACK,如图10-2所示。

  

  因此,不管事务提交,仍是事务回滚,都是两个阶段。

 

 (2)2PC的实现。经过分析能够发现,要实现2PC,全部参与者都要实现三个接口:Prepare、Commit、Rollback,这也就是XA协议,在Java中对应的接口是javax.transaction.xa.XAResource,一般的数据库也都实现了这个协议。开源的Atomikos也基于该协议提供了2PC的解决方案,有兴趣的读者能够进一步研究。

 

 (3)2PC的问题。2PC在数据库领域很是常见,但它存在几个问题:

  问题1:性能问题。在阶段1,锁定资源以后,要等全部节点返回,而后才能一块儿进入阶段2,不能很好地应对高并发场景。

  问题2:阶段1完成以后,若是在阶段2事务协调者宕机,则全部的参与者接收不到Commit或Rollback指令,将处于“悬而不决”状态。

  问题3:阶段1完成以后,在阶段2,事务协调者向全部的参与者发送了Commit指令,但其中一个参与者超时或出错了(没有正确返回ACK),则其余参与者提交仍是回滚呢? 也不能肯定。 

 为了解决2PC的问题,又引入了3PC。3PC存在相似宕机如何解决的问题,所以仍是没能完全解决问题,此处再也不详述。

  2PC除自己的算法局限外,还有一个使用上的限制,就是它主要用在两个数据库之间(数据库实现了XA协议)。但以支付宝的转帐为例,是两个系统之间的转帐,而不是底层两个数据库之间直接交互,因此没有办法使用2PC。

  不只支付宝,其余业务场景基本都采用了微服务架构,不会直接在底层的两个业务数据库之间作一致性,而是在两个服务上面实现一致性。 

  正由于2PC有诸多问题和不便,在实践中通常不多使用,而是采用下面要讲的各类方案。

 

2. 最终一致性(消息中间件) 

  通常的思路是经过消息中间件来实现“最终一致性”,如图10-3所示。

  系统A收到用户的转帐请求,系统A先本身扣钱,也就是更新DB1;而后经过消息中间件给系统B发送一条加钱的消息,系统B收到此消息,对本身的帐号进行加钱,也就是更新DB2。

 这里面有一个关键的技术问题:

  系统A给消息中间件发消息,是一次网络交互;更新DB1,也是一次网络交互。系统A是先更新DB1,后发送消息,仍是先发送消息,后更新DB1?

  假设先更新DB1成功,发送消息网络失败,重发又失败,怎么办?又假设先发送消息成功,更新DB1失败。消息已经发出去了,又不能撤回,怎么办?或者消息中间件提供了消息撤回的接口,可是又调用失败怎么办?

  由于这是两次网络调用,两个操做不是原子的,不管谁先谁后,都是有问题的。

 

下面来看最终一致性的几种具体实现思路:

  a.最终一致性:错误的方案0

  有人可能会想,能够把“发送加钱消息”这个网络调用和更新DB1放在同一个事务里面,若是发送消息失败,更新DB自动回滚。这样不就能够保证两个操做的原子性了吗? 

  这个方案看似正确,实际上是错误的,缘由有两点: 

(1)网络的2将军问题:发送消息失败,发送方并不知道是消息中间件没有收到消息,仍是消息已经收到了,只是返回response的时候失败了?

  若是已经收到消息了,而发送端认为没有收到,执行update DB的回滚操做,则会致使帐户A的钱没有扣,帐户B的钱却被加了。

(2)把网络调用放在数据库事务里面,可能会由于网络的延时致使数据库长事务。严重的会阻塞整个数据库,风险很大。 

 

 b.最终一致性:第1种实现方式(业务方本身实现)

  假设消息中间件没有提供“事务消息”功能,好比用的是Kafka。该如何解决这个问题呢?

  消息中间件实现最终一致性示意图如图10-4所示。

  (1)系统A增长一张消息表,系统A再也不直接给消息中间件发送消息,而是把消息写入到这张消息表中。把DB1的扣钱操做(表1)和写入消息表(表2)这两个操做放在一个数据库事务里,保证二者的原子性。 

  (2)系统A准备一个后台程序,源源不断地把消息表中的消息传送给消息中间件。若是失败了,也不断尝试重传。由于网络的2将军问题,系统A发送给消息中间件的消息网络超时了,消息中间件可能已经收到了消息,也可能没有收到。系统A会再次发送该消息,直到消息中间件返回成功。因此,系统A容许消息重复,但消息不会丢失,顺序也不会打乱。

  (3)经过上面的两个步骤,系统A保证了消息不丢失,但消息可能重复。系统B对消息的消费要解决下面两个问题:

 问题1:丢失消费。系统B从消息中间件取出消息(此时还在内存里面),若是处理了一半,系统B宕机并再次重启,此时这条消息未处理成功,怎么办? 

  答案是经过消息中间件的ACK机制,凡是发送ACK的消息,系统B重启以后消息中间件不会再次推送;凡是没有发送ACK的消息,系统B重启以后消息中间件会再次推送。

  但这又会引起一个新问题,就是下面问题2的重复消费:即便系统B把消息处理成功了,可是正要发送ACK的时候宕机了,消息中间件觉得这条消息没有处理成功,系统B再次重启的时候又会收到这条消息,系统B就会重复消费这条消息(对应加钱类的场景,帐号里面的钱就会加两次)

 问题2:重复消费。除了ACK机制,可能会引发重复消费;系统A的后台任务也可能给消息中间件重复发送消息。

 

  为了解决重复消息的问题,系统B增长一个判重表。判重表记录了处理成功的消息ID和消息中间件对应的offset(以Kafka为例),系统B宕机重启,能够定位到offset位置,从这以后开始继续消费。 

  每次接收到新消息,先经过判重表进行判重,实现业务的幂等。一样,对DB2的加钱操做和消息写入判重表两个操做,要在一个DB的事务里面完成。 

  这里要补充的是,消息的判重不止判重表一种方法。若是业务自己就有业务数据,能够判断出消息是否重复了,就不须要判重表了。

  经过上面三步,实现了消息在发送方的不丢失、在接收方的不重复,联合起来就是消息的不漏不重,严格实现了系统A和系统B的最终一致性。

 

 但这种方案有一个缺点:系统A须要增长消息表,同时还须要一个后台任务,不断扫描此消息表,会致使消息的处理和业务逻辑耦合,额外增长业务方的开发负担。

 

 c.最终一致性:第二种实现方式(基于RocketMQ事务消息)

  为了能经过消息中间件解决该问题,同时又不和业务耦合,RocketMQ提出了“事务消息”的概念,如图10-5所示。

 

 RocketMQ不是提供一个单一的“发送”接口,而是把消息的发送拆成了两个阶段,Prepare阶段(消息预发送)和Confirm阶段(确认发送)。具体使用方法以下: 

  步骤1:系统A调用Prepare接口,预发送消息。此时消息保存在消息中间件里,但消息中间件不会把消息给消费方消费,消息只是暂存在那。

  步骤2:系统A更新数据库,进行扣钱操做。

  步骤3:系统A调用Comfirm接口,确认发送消息。此时消息中间件才会把消息给消费方进行消费。

 

 显然,这里有两种异常场景: 

  场景1:步骤1成功,步骤2成功,步骤3失败或超时,怎么处理?

  场景2:步骤1成功,步骤2失败或超时,步骤3不会执行。怎么处理?

  这就涉及RocketMQ的关键点:RocketMQ会按期(默认是1min)扫描全部的预发送但尚未确认的消息,回调给发送方,询问这条消息是要发出去,仍是取消。发送方根据本身的业务数据,知道这条消息是应该发出去(DB更新成功了),仍是应该取消(DB更新失败)。

  对比最终一致性的两种实现方案会发现,RocketMQ最大的改变实际上是把“扫描消息表”这件事不让业务方作,而是让消息中间件完成。 

  至于消息表,其实仍是没有省掉。由于消息中间件要询问发送方事物是否执行成功,还须要一个“变相的本地消息表”,记录事务执行状态和消息发送状态。

  同时对于消费方,仍是没有解决系统重启可能致使的重复消费问题,这只能由消费方解决。须要设计判重机制,实现消息消费的幂等。

 

 d.人工介入

  不管方案1,仍是方案2,发送端把消息成功放入了队列中,但若是消费端消费失败怎么办?

  若是消费失败了,则能够重试,但还一直失败怎么办?是否要自动回滚整个流程? 

  答案是人工介入。从工程实践角度来说,这种整个流程自动回滚的代价是很是巨大的,不但实现起来很复杂,还会引入新的问题。好比自动回滚失败,又如何处理? 

  对应这种发生几率极低的事件,采起人工处理会比实现一个高复杂的自动化回滚系统更加可靠,也更加简单。 

3. TCC

  2PC一般用来解决两个数据库之间的分布式事务问题,比较局限。如今企业采用的是各式各样的SOA服务,更须要解决两个服务之间的分布式事务问题。

  为了解决SOA系统中的分布式事务问题,支付宝提出了TCC。TCC是Try、Confirm、Cancel三个单词的缩写,实际上是一个应用层面的2PC协议,Confirm对应2PC中的事务提交操做,Cancel对应2PC中的事务回滚操做,如图10-6所示。

 

 (1)准备阶段:调用方调用全部服务方提供的Try接口,该阶段各调用方作资源检查和资源锁定,为接下来的阶段2作准备。

 (2)提交阶段:若是全部服务方都返回YES,则进入提交阶段,调用方调用各服务方的Confirm接口,各服务方进行事务提交。若是有一个服务方在阶段1返回NO或者超时了,则调用方调用各服务方的Cancel接口,如图10-7所示。

 

  这里有一个关键问题:TCC既然也借鉴2PC的思路,那么它是如何解决2PC的问题的呢?也就是说,在阶段2,调用方发生宕机,或者某个服务超时了,如何处理呢? 

  答案是:不断重试!无论是Confirm失败了,仍是Cancel失败了,都不断重试。这就要求Confirm和Cancel都必须是幂等操做。注意,这里的重试是由TCC的框架来执行的,而不是让业务方本身去作。

  下面以一个转帐的事件为例,来讲明TCC的过程。假设有三个帐号A、B、C,经过SOA提供的转帐服务操做。A、B同时分别要向C转30元、50元,最后C的帐号+80元,A、B各减30元、50元。

  阶段1:分别对帐号A、B、C执行Try操做,A、B、C三个帐号在三个不一样的SOA服务里面,也就是分别调用三个服务的Try接口。具体来讲,就是帐号A锁定30元,帐号B锁定50元,检查帐号C的合法性,好比帐号C是否违法被冻结,帐号C是否已注销。

  因此,在这个场景里面,对应的“扣钱”的Try操做就是“锁定”,对应的“加钱”的Try操做就是检查帐号合法性,为的是保证接下来的阶段2扣钱可扣、加钱可加! 

  阶段2:A、B、C的Try操做都成功,执行Confirm操做,即分别调用三个SOA服务的Confirm接口。A、B扣钱,C加钱。若是任意一个失败,则不断重试,直到成功为止。

  从案例能够看出,Try操做主要是为了“保证业务操做的前置条件都获得知足”,而后在Confirm阶段,由于前置条件都知足了,因此能够不断重试保证成功。

 

4. 事务状态表+调用方重试+接收方幂等

  一样以转帐为例,介绍一种相似于TCC的方法。TCC的方法经过TCC框架内部来作,下面介绍的方法是业务方本身实现的。

  调用方维护一张事务状态表(或者说事务日志、日志流水),在每次调用以前,落盘一条事务流水,生成一个全局的事务ID。事务状态表的表结构如表1所示。 

  

  初始是状态1,每调用成功1个服务则更新1次状态,最后全部系统调用成功,状态更新到状态4,状态二、3是中间状态。固然,也能够不保存中间状态,只设置两个状态:Begin和End。事务开始以前的状态是Begin,所有结束以后的状态是End。若是某个事务一直停留在Begin状态,则说明该事务没有执行完毕。

  而后有一个后台任务,扫描状态表,在过了某段时间后(假设1次事务执行成功一般最多花费30s),状态没有变为最终的状态4,说明这条事务没有执行成功。因而从新调用系统A、B、C。保证这条流水的最终状态是状态4(或End状态)。固然,系统A、B、C根据全局的事务ID作幂等操做,因此即便重复调用也没有关系。

补充说明:

 (1)若是后台任务重试屡次仍然不能成功,要为状态表加一个Error状态,经过人工介入干预。

 (2)对于调用方的同步调用,若是部分红功,此时给客户端返回什么呢?

  答案是不肯定,或者说暂时未知。只能告诉用户该笔钱转帐超时,请稍后再来确认。

 (3)对于同步调用,调用方调用A或B失败的时候,能够重试三次。若是重试三次还不成功,则放弃操做,再交由后台任务后续处理。

 

5 对帐 

  把上一节的方案扩展一下,岂止事务有状态,系统中的各类数据对象都有状态,或者说都有各自完整的生命周期,同时数据与数据之间存在着关联关系。咱们能够很好地利用这种完整的生命周期和数据之间的关联关系,来实现系统的一致性,这就是“对帐”。

  在前面,咱们把注意力都放在了“过程”中,而在“对帐”的思路中,将把注意力转移到“结果”中。什么意思呢?

  在前面的方案中,不管最终一致性,仍是TCC、事务状态表,都是为了保证“过程的原子性”,也就是多个系统操做(或系统调用),要么所有成功,要么所有失败。

  但全部的“过程”都必然产生“结果”,过程是咱们所说的“事务”,结果就是业务数据。一个过程若是部分执行成功、部分执行失败,则意味着结果是不完整的。从结果也能够反推出过程出了问题,从而对数据进行修补,这就是“对帐”的思路!

下面举几个对帐的例子。

  案例1:电商网站的订单履约系统。一张订单从“已支付”,到“下发给仓库”,到“出仓完成”。假定从“已支付”到“下发给仓库”最多用1个小时;从“下发给仓库”到“出仓完成”最多用8个小时。意味着只要发现1个订单的状态过了1个小时以后还处于“已支付”状态,就认为订单下发没有成功,须要从新下发,也就是“重试”。一样,只要发现订单过了8个小时还未出仓,这时可能会发出报警,仓库的做业系统是否出了问题……诸如此类。

  这个案例跟事务的状态很相似:一旦发现系统中的某个数据对象过了一个限定时间生命周期仍然没有走完,仍然处在某个中间状态,就说明系统不一致了,要进行某种补偿操做(好比重试或报警)。

  更复杂一点:订单有状态,库存系统的库存也有状态,优惠系统的优惠券也有状态,根据业务规则,这些状态之间进行比对,就能发现系统某个地方不一致,作相应的补偿。

  案例2:微博的关注关系。须要存两张表,一张是关注表,一张是粉丝表,这两张表各自都是分库分表的。假设A关注了B,须要先以A为主键进行分库,存入关注表;再以B为主键进行分库,存入粉丝表。也就是说,一次业务操做,要向两个数据库中写入两条数据,如何保证原子性?

  案例3:电商的订单系统也是分库分表的。订单一般有两个经常使用的查询维度,一个是买家,一个是卖家。若是按买家分库,按卖家查询就很差作;若是按卖家分库,按买家查询就很差作。这种一般会把订单数据冗余一份,按买家进行分库分表存一份,按卖家再分库分表存一份。和案例2存在一样的问题:一个订单要向两个数据库中写入两条数据,如何保证原子性?

  若是把案例二、案例3的问题看做为一个分布式事务的话,能够用最终一致性、TCC、事务状态表去实现,但这些方法都过重,一个简单的方法是“对帐”。

  由于两个库的数据是冗余的,能够先保证一个库的数据是准确的,以该库为基准校对另一个库。

对帐又分为全量对帐和增量对帐:

 (1)全量对帐。好比天天晚上运做一个定时任务,比对两个数据库。

 (2)增量对帐。能够是一个定时任务,基于数据库的更新时间;也能够基于消息中间件,每一次业务操做都抛出一个消息到消息中间件,而后由一个消费者消费这条消息,对两个数据库中的数据进行比对(固然,消息可能丢失,没法百分之百地保证,仍是须要全量对帐来兜底)。

  总之,对帐的关键是要找出“数据背后的数学规律”。有些规律比较直接,谁都能看出来,好比案例二、案例3的冗余数据库;有些规律隐含一些,好比案例1的订单履约的状态。找到了规律就能够基于规律进行数据的比对,发现问题,而后补偿。

 

6. 妥协方案:弱一致性+基于状态的补偿

 能够发现:

  “最终一致性”是一种异步的方法,数据有必定延迟;

  TCC是一种同步方法,但TCC须要两个阶段,性能损耗较大;

  事务状态表也是一种同步方法,但每次要记事务流水,要更新事务状态,很烦琐,性能也有损耗;

  “对帐”也是一个过后过程。

 若是须要一个同步的方案,既要让系统之间保持一致性,又要有很高的性能,支持高并发,应该怎么处理呢?

  如图10-8所示,电商网站的下单至少须要两个操做:建立订单和扣库存。订单系统有订单的数据库和服务,库存系统有库存的数据库和服务。先建立订单,后扣库存,可能会建立订单成功,扣库存失败;反过来,先扣库存,后建立订单,可能会扣库存成功,建立订单失败。如何保证建立订单 + 扣库存两个操做的原子性,同时还要能抵抗线上的高并发流量?

 

  若是用最终一致性方案,由于是异步操做,若是库存扣减不及时会致使超卖,所以最终一致性的方案不可行;若是用TCC方案,则意味着一个用户请求要调用两次(Try和Confirm)订单服务、两次(Try和Confirm)库存服务,性能又达不到要求。若是用事务状态表,要写事务状态,也存在性能问题。 

  既要知足高并发,又要达到一致性,鱼和熊掌不能兼得。能够利用业务的特性,采用一种弱一致的方案。

  对于该需求,有一个关键特性:对于电商的购物来说,容许少卖,但不能超卖。好比有100件东西,卖给99我的,有1件没有卖出去,这是能够接受的;但若是卖给了101我的,其中1我的拿不到货,平台违约,这就不能接受。而该处就利用了这个特性,具体作法以下。

 

 方案1:先扣库存,后建立订单。

  如表2所示,有三种状况:

  (1)扣库存成功,提交订单成功,返回成功。

  (2)扣库存成功,提交订单失败,返回失败,调用方重试(此处可能会多扣库存)。

  (3)扣库存失败,再也不提交订单,返回失败,调用方重试(此处可能会多扣库存)。 

 

 方案2:先建立订单,后扣库存。

  如表3所示,也有三种状况:

  (1)提交订单成功,扣库存成功,返回成功。

  (2)提交订单成功,扣库存失败,返回失败,调用方重试(此处可能会多扣库存)。

  (3)提交订单失败,再也不扣库存,调用方重试。

 

  不管方案1,仍是方案2,只要最终保证库存能够多扣,不能少扣便可。 

  可是,库存多扣了,数据不一致,怎么补偿呢?

  库存每扣一次,都会生成一条流水记录。这条记录的初始状态是“占用”,等订单支付成功后,会把状态改为“释放”。

  对于那些过了很长时间一直是占用,而不释放的库存,要么是由于前面多扣形成的,要么是由于用户下了单但没有支付。 

  经过比对,获得库存系统的“占用又没有释放的库存流水”与订单系统的未支付的订单,就能够回收这些库存,同时把对应的订单取消。相似12306网站,过必定时间不支付,订单会取消,将库存释放。

 

7. 妥协方案:重试+回滚+报警+人工修复 

  上文介绍了基于订单的状态 +库存流水的状态作补偿(或者说叫对帐)。若是业务很复杂,状态的维护也很复杂,就能够采用下面这种更加妥协而简单的方法。

  按方案1,先扣库存,后建立订单。不作状态补偿,为库存系统提供一个回滚接口。建立订单若是失败了,先重试。若是重试还不成功,则回滚库存的扣减。如回滚也失败,则发报警,进行人工干预修复。

  总之,根据业务逻辑,经过三次重试或回滚的方法,最大限度地保证一致。实在不一致,就发报警,让人工干预。只要日志流水记录得完整,人工确定能够修复!一般只要业务逻辑自己没问题,重试、回滚以后还失败的几率会比较低,因此这种办法虽然丑陋,但很实用。

 

8. 总结

   本章总结了实践中比较可靠的七种方法:两种最终一致性的方案,两种妥协办法,两种基于状态 + 重试 + 幂等的方法(TCC,状态机+重试+幂等),还有一种对帐方法。

  在实现层面,妥协和对帐的办法最容易,最终一致性次之,TCC最复杂。

原文:https://blog.csdn.net/uxiAD7442KMy1X86DtM3/article/details/88968532

相关文章
相关标签/搜索