幂等性
幂等性:一次和多次请求某一个资源对于资源本身应该具有同样的结果
任意多次执行对资源本身所产生的影响均与一次执行的影响相同
幂等性定义的几个重点:
- 幂等不仅仅只是一次或者多次请求对资源没有副作用
比如,查询数据库操作,没有增删改,无论多少次操作对数据库都没有任何影响 - 幂等还包括第一次请求的时候对资源产生了副作用,但是以后的多次请求都不会再对资源产生副作用
- 幂等关注的是以后多次请求是否对资源产生副作用,并不关注结果
- 网络超时等问题,不是幂等的讨论范围
- 幂等性是系统服务对外一种承诺,而不是实现
承诺只要调用接口成功,外部多次调用对系统的影响是一致的
一、MQ 消息丢失、重复、积压问题,如何解决?
解决方案 :网络中的数据传输不可靠,想要解决如何不丢消息的问题,首先要知道
1、哪些环节可能丢消息,
2、我们如何知道消息是否丢失了,
3、解决方案
4、如何解决重复消费
5、消息积压
1.1、哪些环节可能丢消息
消息生产阶段: 从消息被生产出来,然后提交给 MQ 的过程中,只要能正常收到 MQ Broker 的 ack 确认响应,就表示发送成功,所以只要处理好返回值和异常,这个阶段是不会出现消息丢失的。
消息存储阶段: 这个阶段一般会直接交给 MQ 消息中间件来保证,但是你要了解它的原理,比如 Broker 会做副本,保证一条消息至少同步两个节点再返回 ack。
消息消费阶段: 消费端从 Broker 上拉取消息,只要消费端在收到消息后,不立即发送消费确认给 Broker,而是等到执行完业务逻辑后,再发送消费确认,也能保证消息的不丢失。
方案看似万无一失,每个阶段都能保证消息的不丢失,但在分布式系统中,故障不可避免,作为消息生产端
,你并不能保证 MQ 是不是弄丢了你的消息,消费者是否消费了你的消息,所以,本着 Design for Failure 的设计原则,你还是需要一种机制,来 Check 消息是否丢失了。
1.2、怎么进行消息检测?
Q1的方案看似万无一失,每个阶段都能保证消息的不丢失,但在分布式系统中,故障不可避免,作为消息生产端,你并不能保证 MQ 是不是弄丢了你的消息,消费者是否消费了你的消息,所以,本着 Design for Failure 的设计原则,你还是需要一种机制,来 Check 消息是否丢失了。
总体方案解决思路为:在消息生产端,给每个发出的消息都指定一个全局唯一 ID,或者附加一个连续递增的版本号,然后在消费端做对应的版本校验。
1.3、丢失处理?
具体怎么落地实现呢? 你可以利用附加一个连续递增的版本号。 在生产端发送消息之前,通过拦截器将消息版本号注入消息中(版本号可以采用连续递增的 ID 生成,也可以通过分布式全局唯一 ID生成)。然后在消费端收到消息后,再通过拦截器检测版本号的连续性或消费状态,这样实现的好处是消息检测的代码不会侵入到业务代码中,可以通过单独的任务来定位丢失的消息,做进一步的排查。
这里需要你注意:如果同时存在多个消息生产端和消息消费端,通过版本号递增的方式就很难实现了,因为不能保证版本号的唯一性,此时只能通过全局唯一 ID 的方案来进行消息检测,具体的实现原理和版本号递增的方式一致。
1.4、如何解决重复消费?
RabbitMQ、RocketMQ、Kafka,都有可能会出现消息重复消费的问题,正常。因为这问题通常不是 MQ 自己保证的,是由我们开发来保证的。
消费端处理消息的业务逻辑保持幂等性。幂等性:通俗点说,就一个数据,或者一个请求,给你重复来多次,你得确保对应的数据是不会改变的,不能出错。
- 就是在数据库中建一张消息日志表 , 这个表有两个字段:消息 ID和消息执行状态。
- 因为我们每次都会在插入之前检查是否消息已存在,所以就不会出现一条消息被执行多次的情况,这样就实现了一个幂等的操作。当然,基于这个思路,不仅可以使用关系型数据库,也可以通过 Redis 来代替数据库实现唯一约束的方案。反正每次都是 set,天然幂等性。
在这里我多说一句,想要解决“消息丢失”和“消息重复消费”的问题,有一个前提条件就是要实现一个全局唯一 ID 生成的技术方案。这也是面试官喜欢考察的问题,你也要掌握。
1.5、如何解决消息积压?
如果出现积压,那一定是性能问题,想要解决消息从生产到消费上的性能问题,就首先要知道哪些环节可能出现消息积压,然后在考虑如何解决。
因为消息发送之后才会出现积压的问题,所以和消息生产端没有关系,又因为绝大部分的消息队列单节点都能达到每秒钟几万的处理能力,相对于业务逻辑来说,性能不会出现在中间件的消息存储上面。毫无疑问,出问题的肯定是消息消费阶段,那么从消费端入手,如何回答呢?
如果是线上突发问题,要临时扩容,增加消费端的数量,与此同时,降级一些非核心的业务。通过扩容和降级承担流量,这是为了表明你对应急问题的处理能力。
其次,才是排查解决异常问题,如通过监控,日志等手段分析是否消费端的业务逻辑代码出现了问题,优化消费端的业务处理逻辑。
最后,如果是消费端的处理能力不足,可以通过水平扩容来提供消费端的并发处理能力,但这里有一个考点需要特别注意 , 那就是在扩容消费者的实例数的同时,必须同步扩容主题 Topic 的分区数量,确保消费者的实例数和分区数相等。如果消费者的实例数超过了分区数,由于分区是单线程消费,所以这样的扩容就没有效果。
比如在 Kafka 中,一个 Topic 可以配置多个 Partition(分区),数据会被写入到多个分区中,但在消费的时候,Kafka 约定一个分区只能被一个消费者消费,Topic 的分区数量决定了消费的能力,所以,可以通过增加分区来提高消费者的处理能力。
二、丢失数据场景
丢数据一般分为三种,一种是生产者发送消息失败,一种是mq把消息丢了,一种就是消费时将消息丢了。下面从rabbitmq和kafka分别说一下,丢失数据的场景:
A:生产者弄丢了数据
生产者将数据发送到rabbitmq的时候,可能在传输过程中因为网络问题
而将数据弄丢了。
B:rabbitmq自己丢了数据
如果未开启rabbitmq的持久化
,那么rabbitmq一旦重启,那么数据就丢了。所依必须开启持久化将消息持久化到磁盘,这样就算rabbitmq挂了,恢复之后会自动读取之前存储的数据,一般数据不会丢失。除非极其罕见的情况,rabbitmq还没来得及持久化自己就挂了,这样可能导致一部分数据丢失。
C:消费端弄丢了数据
如果一个消费者应用在消费的时候,刚消费到,还没处理,如进程挂了
,比如重启了,rabbitmq认为你都消费了,这数据就丢了。
三、 RabbitMQ
3.1 生产者弄丢了数据
生产者将数据发送到 RabbitMQ 的时候,可能数据就在半路给搞丢了,因为网络问题
啥的,都有可能。
这里有两种解决办法:1、开启事务(不推荐);2、开启confirm模式
3.1.1 开启事务
生产者发送数据之前开启 RabbitMQ 事务channel.txSelect
,然后发送消息,如果消息没有成功被 RabbitMQ 接收到,那么生产者会收到异常报错,此时就可以回滚事务channel.txRollback
,然后重试发送消息;如果收到了消息,那么可以提交事务channel.txCommit
。
// 开启事务
channel.txSelect
try {
// 这里发送消息
} catch (Exception e) {
channel.txRollback
// 这里再次重发这条消息
}
// 提交事务
channel.txCommit
缺点:rabbitmq事物已开启,就会变为同步阻塞操作,生产者会阻塞等待是否发送成功,太耗性能会造成吞吐量的下降。
3.1.2 开启confirm模式
在生产者设置开启了confirm模式之后,每次写的消息都会分配一个唯一的id,然后如何写入了rabbitmq之中,rabbitmq会给你回传一个ack消息,告诉你这个消息发送OK了;如果rabbitmq没能处理这个消息,会回调你一个nack接口,告诉你这个消息失败了,你可以进行重试。而且你可以结合这个机制知道自己在内存里维护每个消息的id,如果超过一定时间还没接收到这个消息的回调,那么你可以进行重发。
//开启confirm
channel.confirm();
//发送成功回调
public void ack(String messageId){
}
// 发送失败回调
public void nack(String messageId){
//重发该消息
}
消息中间件如何保证不丢失消息
3.1.3 事务 vs confirm
- 事务机制是同步的,你提交了一个事物之后会阻塞住,但是confirm机制是异步的,发送消息之后可以接着发送下一个消息,然后rabbitmq会回调告知成功与否。
- 一般在生产者这块避免丢失,都是用confirm机制。
3.2 rabbitmq自己弄丢了数据
设置消息持久化到磁盘。设置持久化有两个步骤:
- 队列持久化。这样就可以保证rabbitmq持久化queue的元数据,但是不会持久化queue里面的数据。
- 消息持久化。发送消息设置发送模式deliveryMode=2,代表持久化消息。这样消息就会被设为持久化方式,此时rabbitmq就会将消息持久化到磁盘上。
- 交换机持久化。 持久化标识durable设置为true
持久化可以跟生产的confirm机制配合起来,只有消息持久化到了磁盘之后,才会通知生产者ack,这样就算是在持久化之前rabbitmq挂了,数据丢了,生产者收不到ack回调也会进行消息重发。
3.2.1 消费者弄丢数据
手动ack机制
rabbitmq取消自动ack机制改为自动ack机制来解决消费者弄丢数据:
如果使用rabbitmq提供的ack机制,首先关闭rabbitmq的自动ack,使用手动ack,每次在确保处理完这个消息之后,在代码里手动调用ack。这样就可以避免消息还没有处理完就ack。
但是ack机制在异常情况下可能造成重复消费:当消费者异常断掉连接,但并未挂掉,broker 会得知, 此时broker 尚未获得 ack,那么消息会被重新放入其他队列,这样就导致数据被重复消费了。
3.2.2 消息消费者如何通知 Rabbit 消息消费成功?
- 消息通过 ACK 确认是否被正确接收,每个 Message 都要被确认(acknowledged),可以手动去 ACK 或自动 ACK
- 自动确认会在消息发送给消费者后立即确认,但存在丢失消息的可能,如果消费端消费逻辑抛出异常,也就是消费端没有处理成功这条消息,那么就相当于丢失了消息
- 如果消息已经被处理,但后续代码抛出异常,使用 Spring 进行管理的话消费端业务逻辑会进行回滚,这也同样造成了实际意义的消息丢失
- 如果手动确认则当消费者调用 ack、nack、reject 几种方法进行确认,手动确认可以在业务失败后进行一些操作,如果消息未被 ACK 则会发送到下一个消费者
- 如果某个服务忘记 ACK 了,则 RabbitMQ 不会再发送数据给它,因为 RabbitMQ 认为该服务的处理能力有限
- ACK 机制还可以起到限流作用,比如在接收到某条消息时休眠几秒钟
总结:AMQP 提供的是“至少一次交付”(at-least-once delivery),异常情况下,消息会被重复消费,此时业务要实现幂等性(重复消息处理)
四、Kafka
1、生产者
如何保证数据的不丢失:消息的确认机制,使用ack机制我们可以配置我们的消息不丢失机制为-1,保证我们的partition的leader与follower都保存好了数据
2、消费者
如何保证不重复消费数据:offset偏移量,记录了我们的消息消费的偏移量,新版本偏移量记录在了一个topic里面
3、broker
如何保证数据的不丢失:partition的副本机制
4.1 producer 生产端是如何保证数据不丢失的
方法:
- ack的配置策略
- retries的配置策略
1、ack的配置策略
①、acks = 0。生产者发送消息之后 不需要等待服务端的任何响应,它不管消息有没有发送成功
②、acks = 1(默认值)。生产者发送消息之后,只要分区的leader副本成功写入消息,那么它就会收到来自服务端的成功响应。
③、acks = all (或-1)。生产者在发送消息之后,需要等待ISR中所有的副本都成功写入消息之后才能够收到来自服务端的成功响应。
acks = 0
-----生产者发送消息之后 不需要等待服务端的任何响应,它不管消息有没有发送成功,
如果发送过程中遇到了异常,导致broker端没有收到消息,消息也就丢失了。
实际上它只是把消息发送到了socketBuffer(缓存)中,而socketBuffer什么时候被提交到broker端并不关心,
它不担保broker端是否收到了消息,但是这样的配置对retry是不起作用的,因为producer端都不知道是否发生了错误,而且对于offset的获取永远都是-1,因为broker端可能还没有开始写数据。这样不保险的操作为什么还有这样的配置?kafka对于收集海量数据,如果在收集某一项日志时是允许数据量有一定丢失的话,是可以用这种配置来收集日志。
acks = 1(默认值)
-----生产者发送消息之后,只要分区的leader副本成功写入消息,那么它就会收到来自服务端的成功响应。
其实就是消息只发给了leader。leader收到消息后会返回ack到producer端。如果消息无法写入leader时(选举、宕机等情况时),生产都会收到一个错误的响应,为了避免消息丢失,生产者可以选择重发消息,如果消息成功写入,在被其它副本同步数据时leader突然崩溃了,那么此条数据还是会丢失,因为新选举的leader是没有收到这条消息,ack设置为1是消息可靠性和吞吐量折中的方案。
acks = all (或-1)
-----生产者在发送消息之后,需要等待ISR中所有的副本都成功写入消息之后才能够收到来自服务端的成功响应
在配置环境相同的情况下此种配置可以达到最强的可靠性。
即:在发送消息时,需要leader 向fllow 同步完数据之后,也就是ISR队列中所有的broker全部保存完这条消息后,才会向ack发送消息,表示发送成功。
2、 retries的配置策略
在kafka中错误分为2种,一种是可恢复的,另一种是不可恢复的。
- 可恢复性的错误:
如遇到在leader的选举、网络的抖动等这些异常时,如果我们在这个时候配置的retries>0,就可以进行重试操作,那么等到leader选举完成后、网络稳定后,这些异常就会消息,错误也就可以恢复,数据再次重发时就会正常发送到broker端。需要注意retries(重试)之间的时间间隔,以确保在重试时可恢复性错误都已恢复。 - 不可恢复性的错误:
如:超过了发送消息的最大值(max.request.size)时,这种错误是不可恢复的,如果不做处理,那么数据就会丢失,因此我们需要注意在发生异常时把这些消息写入到DB、缓存本地文件中等等,把这些不成功的数据记录下来,等错误修复后,再把这些数据发送到broker端。
3、 如何选取
1.高可用型
配置:acks = all,retries > 0 retry.backoff.ms=100(毫秒) (并根据实际情况设置retry可能恢复的间隔时间)
优点:这样保证了producer端每发送一条消息都要成功,如果不成功并将消息缓存起来,等异常恢复后再次发送。
缺点:这样保证了高可用,但是这会导致集群的吞吐量不是很高,因为数据发送到broker之后,leader要将数据同步到fllower上,如果网络带宽、不稳定等情况时,ack响应时间会更长
2.折中型
配置:acks = 1 retries > 0 retries 时间间隔设置 (并根据实际情况设置retries可能恢复的间隔时间)
优点:保证了消息的可靠性和吞吐量,是个折中的方案
缺点:性能处于2者中间
3.高吞吐型
配置:acks = 0
优点:可以相对容忍一些数据的丢失,吞吐量大,可以接收大量请求
缺点:不知道发送的消息是 否成功
4.2 消费端弄丢了数据
唯一可能导致消费者弄丢数据的情况,就是说,你消费到了这个消息,然后消费者那边自动提交了 offset,让 Kafka 以为你已经消费好了这个消息,但其实你才刚准备处理这个消息,你还没处理,你自己就挂了,此时这条消息就丢咯。
这不是跟 RabbitMQ 差不多吗,大家都知道 Kafka 会自动提交 offset,那么只要关闭自动提交 offset,在处理完之后自己手动提交 offset,就可以保证数据不会丢。但是此时确实还是可能会有重复消费,比如你刚处理完,还没提交 offset,结果自己挂了,此时肯定会重复消费一次,自己保证幂等性就好了。
1、consumer端的配置项
group.id: consumer group 分组的一个id
消费者所在的消费组名称。在kafka中只允许消息只能被某个组里面的一个consumer端消费,如果为空,则会报异常。
对于一个新的consumer加入到消费时,肯定会隶属于哪个组,只有这样才能消费数据
auto.offset.reset = earliest(最早) /latest(最晚)
从何处开始进行消费!!!
当一个新加入的consumer要进行消费数据,如果这个consumer是做数据分析工作的,是需要以前的历史数据那就需要从最早的位置消费数据,如果仅仅是查看消费情况,那可以从最晚位置开始消费数据
enable.auto.commit = true/false(默认true)
是否开启自动提交消费位移的功能,默认开启.
当设置为true时,意味着由kafka的consumer端自己间隔一定的时间会自动提交offset,如果设置成了fasle,也就是由客户端(自己写代码)来提交,那就还得控制提交的时间间隔auto.commit.interval.ms
auto.commit.interval.ms
当enable.auto.commit设置为true时才生效,表示开启自动提交消费位移功能时自动提交消费位移的时间间隔
2、consumer端的配置策略
在consumer消费阶段,对offset的处理,关系到是否丢失数据,是否重复消费数据。是否重复消费数据,因此,我们把处理好offset就可以做到exactly-once && at-least-once(只消费一次)数据。
当enable.auto.commit=true时
表示由kafka的consumer端自动提交offset,当你在pull(拉取)30条数据,在处理到第20条时自动提交了offset,但是在处理21条的时候出现了异常,当你再次pull数据时,由于之前是自动提交的offset,所以是从30条之后开始拉取数据,这也就意味着21-30条的数据发生了丢失。
当enable.auto.commit=false时
由于上面的情况可知自动提交offset时,如果处理数据失败就会发生数据丢失的情况。那我们设置成手动提交。
当设置成false时,由于是手动提交的,可以处理一条提交一条,也可以处理一批,提交一批,由于consumer在消费数据时是按一个batch来的,当pull了30条数据时,如果我们处理一条,提交一个offset,这样会严重影响消费的能力,那就需要我们来按一批来处理,或者设置一个累加器,处理一条加1,如果在处理数据时发生了异常,那就把当前处理失败的offset进行提交(放在finally代码块中)注意一定要确保offset的正确性,当下次再次消费的时候就可以从提交的offset处进行再次消费。
3、comsumer 的应用场景
1.一直commit offset的处理
假如poll了100条数据,每处理1条,commit offset一次,这样会严重影响性能,在处理的时候设置1个计数器(或累加器),按一批来提交,但要确保提交offset的准确性
2.rebalance的影响(重平衡)
在处理数据时,有2种情况会发生,一种情况是处理了一半的时候,发生了rebalance,但是offset还没有来得及提交,另一种情况是rebalance发生后,重新分配了offset,在这种情况时会发生错误。
3.消息处理错误时的处理
假如consumer在处理数据的时候失败了,那么可以把这条数据给缓存起来,可以是redis、DB、file等,也可以把这条消息存入专门用于存储失败消息的topic中,让其它的consumer专门处理失败的消息。
4.处理消息的时间过长
假如poll一批100条消息的时间是1秒钟,但是在每处理1条需要花费1秒钟,这样来说极其影响消费能力,那我们可以把100条消息放到1个线程池中处理。这里特别特别注意,由于线程池的处理行为是并行的,所以要做对offset的判断。这里先说正常情况,如果消息都能被正常处理,那么会提交1个offset,并把这个offset存起来,假如此时又提交了1个offset,把2个offset相对比,哪个大把哪个存起来并做提交。如果消息处理发生了错误,我们在前面讲过,把这个错误消息发送到专门处理错误的topic中,让专门的consumer来处理。
4、consumer 保证确保消息只被处理一次处理,同时确保幂等性
exactly-once & at-least-once
如何保证消息只获取一次并且确定被处理呢?这就需要我们在 处理消息的时候要添加一个unique key
假如pull 一个batch 100条的消息,在处理到第80条的时候,由于网络延迟、或者crash的原因没有来得及提交offset,被处理的80条数据都添加了unique key, 可以存到到DB中或者redis中(推荐,因为这样更快),当consumer端会再次poll消费数据时,因为没有提交offset,所以会从0开始消费数据,如果对之前已经消息过的数据没有做unique key的处理,那么会造成重复消息之前的80条数据,但是如果把每条对应的消息都添加了unique key,那就只需要对被处理的消息进行判断,有没有unique key 就可以做到不重复消费数据的问题,这样也同时保证了幂等性。
4.3 Kafka (broker端)弄丢了数据
参考链接 https://www.cnblogs.com/MrRightZhao/p/11498952.html
这块比较常见的一个场景,就是 Kafka 某个 broker 宕机,然后重新选举 partition 的 leader。大家想想,要是此时其他的 follower 刚好还有些数据没有同步,结果此时 leader 挂了,然后选举某个 follower 成 leader 之后,不就少了一些数据?这就丢了一些数据啊。
1、 broker端的配置项
以下参数都是在创建topic时进行设置
1.replication-factor 3
在创建topic时会通过replication-factor来创建副本的个数,它提高了kafka的高可用性,同时,它允许n-1台broker挂掉,设置好合理的副本因子对kafka整体性能是非常有帮助的,通常是3个,极限是5个,如果多了也会影响开销。
2.min.insync.replicas = 2
分区ISR队列集合中最少有多少个副本,默认值是1
3.unclean.leander.election.enable = false
是否允许从ISR队列中选举leader副本,默认值是false,如果设置成true,则可能会造成数据丢失。
2、 leader选举造成的数据丢失
3个replica分别为0 1 2,0为leader,数据都能完全同步到100,在某一时刻,分别有2个fllow挂掉了,此时有producer往0 的replica上发送50条数据完后,此时的leader挂掉了,而此时刚好的1个fllow起来了,它没有向leader上feach数据,因为leader已经不存在了。
此时有2种处理方法:重新起来的fllow可以成为1个leader,需要通过 unclean.leader.election.enable=true,这样做保证了高可用,但是这样做的弊端是:新起来的fllow成为了leader,但是它会丢失部分数据,虽然这样保证了高可用。另一种情况是设置为false,不让fllow竞选leader,但是这样也会造成数据的丢失。假如在ISR的队列里面,只有0 1,但此时replica 1 没有来得及向leader feach数据leader挂掉了,这样也会造成数据的丢失。
3、broker端的配置策略
min.insync.replica
在一个topic中,1个分区 有3个副本,在创建时设置了min.insync.replica=2,假如此时在ISR中只有leader副本(1个)存在,在producer端生产数据时,此时的acks=all,这也就意味着在producer向broker端写数据时,必须保证ISR中指定数量的副本(包含leader、fllow副本)全部同步完成才算写成功,这个数量就是由min.insync.replica来控制的,这样producer端向broker端写数据是不成功,因为ISR中只有leader副本,min.insync.replica要求2个副本,此时的producer生产数据失败(异常),当然consumer端是可以消费数据的,只不过是没有新数据产生而已.这样保证了数据的一致性,但这样会导致高可用性降低了。一般的配置是按: n/2 +1 来配置min.insync.replicas 的数量的,同时也要将unclean.leader.election.enable=false
unclean.leader.election.enable
假如现在有leader 0 fllow 1 fllow 2 三个副本,存储的数据量分别是10 9 8,此时的broker的配置是:min.insync.replica=2 acks=all,leader的数据更新到了15,在没有同步到fllow 1 fllow 2时挂掉了,此时的ISR队列中是有fllow 1 和fllow 2的,如果unclean.leader.election.enable设置的是true,表示在ISR中的副本是可以竞选leader这样就会造成9-15或8-15之间的数据丢失,所以unclean.leader.election.enable必须设置成成false,这样整个kafka cluster都不读写了,这样就保证了数据的高度一致性
4.4 总结
综上,kafka一般是要求起码设置如下 4 个参数:
- 给 topic 设置 replication.factor 参数:这个值必须大于 1,要求每个 partition 必须有至少 2 个副本。
- 在 Kafka 服务端设置 min.insync.replicas 参数:这个值必须大于 1,这个是要求一个 leader 至少感知到有至少一个 follower 还跟自己保持联系,没掉队,这样才能确保 leader 挂了还有一个 follower 吧。
- 在 producer 端设置 acks=all:这个是要求每条数据,必须是写入所有 replica 之后,才能认为是写成功了。
- 在 producer 端设置 retries=MAX(很大很大很大的一个值,无限次重试的意思):这个是要求一旦写入失败,就无限重试,卡在这里了。
我们生产环境就是按照上述要求配置的,这样配置之后,至少在 Kafka broker 端就可以保证在 leader 所在 broker 发生故障,进行 leader 切换时,数据不会丢失。
参考链接
今天的文章消息中间件如何保证不丢失消息呢_前端和后端的区别「建议收藏」分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/84940.html