Kafka和Flink的Exactly Once分析

Kafka的Exactly Once

聊到Kafka的Exactly Once,首先就必须对Kafka的Producer和Consumer的工作模式有个基础了解,我们先来看看Kafka Producer的工作模式:

1.Kafka Producer

Kafka Producer的数据可靠性机制规定如下:
为保证 producer 发送的数据,能可靠的发送到指定的 topic, topic 的每个 partition 收到producer 发送的数据后, 都需要向 producer 发送ack(acknowledgement 确认收到) ,如果producer 收到 ack, 就会继续进行下一轮的发送,否则重新发送数据;(注意:producer走的实际是异步发送的方法,数据可靠性保证指的是如果prodecer没有收到ack,会不断重发,但并不影响其自身异步发送的方式)

1) 副本数据同步策略:
  • 半数以上完成同步, 就发送 ack

优点:延迟低

缺点:选举新的 leader 时, 容忍 n 台 节点的故障,需要 2n+1 个副本(因为必须有一半以上节点确定是有数据的,挂掉了n台,这n台的数据就肯定是没有的,但是又要求一半以上有数据,所以必须要有2n+1台)

  • 全部完成同步,才发送 ack

优点:选举新的 leader 时,容忍 n 台节点的故障,需要 n+1 个副本(全部同步完成,且只要有一台活着传送数据即可,所以需要n+1个)

缺点:延迟高

Kafka 选择了第二种方案,原因如下:

1.同样为了容忍 n 台节点的故障,第一种方案需要 2n+1 个副本,而第二种方案只需要 n+1个副本,而 Kafka 的每个分区都有大量的数据, 第一种方案会造成大量数据的冗余。
2.虽然第二种方案的网络延迟会比较高,但网络延迟对 Kafka 的影响较小

采用第二种方案之后,设想以下情景: leader 收到数据,所有 follower 都开始同步数据,但有一个 follower,因为某种故障,迟迟不能与 leader 进行同步,那 leader 就要一直等下去,直到它完成同步,才能发送 ack。这个问题怎么解决呢?

针对第二种方案,kafka做了优化,具体如下:

2) ISR

​ Leader 维护了一个动态的 in-sync replica set (ISR)(同步副本),意为和 leader 保持同步的 follower 集合,即每个partition动态维护一个replication集合。当 ISR 中的 follower 完成数据的同步之后, leader 就会给 follower 发送 ack。如果 follower长 时 间 未 向 leader 同 步 数 据 , 则 该 follower 将 被 踢 出 ISR , 该 时 间 阈 值 由 replica.lag.time.max.ms 参数设定。最终目的为在 Leader 发生故障之后,就会从 ISR 中选举新的 leader,不影响使用。

注意:

  • 对于一个partition,集合中每个replication都同步完后,kafka才会将该消息标记为“已提交”状态,认为该条消息发送成功

  • 只要这个集合中至少存在一个replication或者,已提交的信息就不会丢失

  • 当一小部分replication开始落后于leader replication的速度时,就踢出ISR

  • 被踢出去的replication还在同步,只是不算在ISR里。被踢出去的同步追上leader后,又重新计入ISR;

bin/kafka-topics.sh --describe --topic first --zookeeper hadoop102:2181
# 输出
Topic:first     PartitionCount:1        ReplicationFactor:3     Configs:
        Topic: first    Partition: 0    Leader: 3       Replicas: 3,4,2 Isr: 3
# 看最后的ISR
老版本中两个条件: leader与follower消息差距条数、距离上次同步的时间

任意一个维度超过阈值都会把 Follower 剔除出 ISR,存入 OSR(Outof-Sync Replicas)列表,新加入的 Follower也会先存放在 OSR 中。    
    
leader和follower发消息差距大于10条就踢出ISR,如果小于10条再加进来。为什么踢出ISR还会又加进来呢?因为ISR只是决定了什么时候返回ACK,而无论在不在ISR里,都仍在继续同步数据。我们不能因为他慢了点就直接不用他备份。
    
生产者以batch(批量发送)发送数据,比如这个batch12条,如果batch大于设定的10条阻塞限制,那么所有的follower都被踢出ISR。频繁发送batch,就频繁加入ISR,踢出ISR,频繁操作ZK
所以新版本中删除了一个条件: leader与follower消息差距条数;
3) ack 应答机制
  • acks=0: producer 不等待 broker 的 ack;这一操作提供了一个最低的延迟, broker一接收到还没有写入磁盘就已经返回,当 broker 故障时有可能丢失数据;
  • acks=1: producer等待broker的ack;partition的leader 落盘(写入磁盘)成功后返回ack(只等待leader写完就发回ack),如果在 follower同步成功之前leader故障,那么将会丢失数据;
  • acks=-1(all):producer 等待 broker 的 ack;partition的leader和follower(指的是ISR里的follower) 全部落盘成功后才返回ack。但是如果在follower同步完成后, broker发送ack之前,leader发生故障,那么会造成数据重复。特殊情况下也可能丢失数据:比如ISR中只有一个leader(follower太慢都被踢出去了),leader写完了就发送ACK,但是还没同步就挂掉了,此时也会丢失数据。(生产者以为成功了,不会再发送了)
  • 4) 故障处理细节

​ leader故障后,follower一个同步到8条数据,一个同步到9条数据,结果选了8条数据的foller为新leader:

此时会造成问题:9条数据的follower数据与新leader不一致;

或者:follower一个同步到8条数据,一个同步到9条数据,结果选了9条数据的foller为新leader:

结果原来的10条数据的leader又重启了,还是会产生数据不一致的问题;
在这里插入图片描述

LEO:每个副本最大的offset;

HW:消费者能见到的最大的offset,ISR队列中最小的LEO;

在上述例子中,LE0=10,HW=8;HW之前的数据才对Consumer可见;

​ 但是若新leader选出来之后就直接写数据,会造成数据在follower和leader之间位置不一致的问题,所以又有了以下解决方法:

(以下方法只能保证数据一致性问题,不能保证数据不丢失或是不重复);

(1) follower 故障

follower 发生故障后会被临时踢出 ISR,待该 follower 恢复后, follower 会读取本地磁盘记录的上次的 HW,并将 log 文件高于 HW 的部分截取掉,从 HW 开始向 leader 进行同步。

等该 follower 的 LEO 大于等于该 Partition 的 HW(现在的),即 follower 追上 leader 之后,就可以重新加入 ISR 了。

(2) leader 故障

leader 发生故障之后,会从 ISR 中选出一个新的 leader,之后,为保证多个副本之间的数据一致性,(ISR中的) 其余的 follower 会先将各自的 log 文件高于 HW 的部分截掉,然后从新的 leader同步数据。

Exactly Once 语义(精准一次性)

​ 将服务器的 ACK 级别设置为-1,可以保证 Producer 到 Server 之间不会丢失数据,即 At Least Once 语义。相对的,将服务器 ACK 级别设置为 0,可以保证生产者每条消息只会被发送一次,即 At Most Once 语义。

​ At Least Once 可以保证数据不丢失,但是不能保证数据不重复;相对的, At Most Once可以保证数据不重复,但是不能保证数据不丢失。

​ 但是,对于一些非常重要的信息,比如说交易数据,下游数据消费者要求数据既不重复也不丢失,即 Exactly Once 语义。在 0.11 版本以前的 Kafka,对此是无能为力的,只能保证数据不丢失,再在下游消费者对数据做全局去重。对于多个下游应用的情况,每个都需要单独做全局去重,这就对性能造成了很大影响。

​ 后续版本引入一个新概念:幂等性:

At Least Once + 幂等性 = Exactly Once

特点:要启用幂等性,只需要将 Producer 的参数中 enable.idompotence 设置为 true 即可。 Kafka的幂等性实现其实就是将原来下游需要做的去重放在了数据上游。开启幂等性的 Producer 在初始化的时候会被分配一个 PID,发往同一 Partition 的消息会附带 Sequence Number。而Broker 端会对<PID, Partition, SeqNumber>做缓存,当具有相同主键的消息提交时,Broker 只会持久化一条。

注意:这里的pid和zookeeper里的那个pid不是一样的,这里的pid指的是producer的id,每一次producer挂掉重启后,会获得一个新的pid,而不同的partition的主键也有所不同,所以幂等性无法保证跨分区跨会话(不同次会话)的Exactly Once;

后来的kafka版本引入了事务,保证了跨分区跨会话的Exactly Once的性质。(见后续事务讲解部分)

Producer 事务

Exactly Once + producer事务 = 精准一致写到kafka集群;

​ 为了实现跨分区跨会话的事务,**需要引入一个全局唯一的 Transaction ID,并将 Producer获得的PID 和Transaction ID 绑定。**这样当Producer 重启后就可以通过正在进行的 Transaction ID 获得原来的 PID。

​ 为了管理 Transaction, Kafka 引入了一个新的组件 Transaction Coordinator。 Producer 就是通过和 Transaction Coordinator 交互获得 Transaction ID 对应的任务状态。 Transaction Coordinator 还负责将事务所有写入 Kafka 的一个内部 Topic,这样即使整个服务重启,由于事务状态得到保存,进行中的事务状态可以得到恢复,从而继续进行。
所以这才是真正实现了Kafka Producer的Exactly Once。

2.Kafka Consumer分析

2.1消费方式:

consumer 采用 pull(拉) 模式从 broker 中读取数据。

​ push(推)模式很难适应消费速率不同的消费者,因为消息发送速率是由 broker 决定的。它的目标是尽可能以最快速度传递消息,但是这样很容易造成 consumer 来不及处理消息, 典型的表现就是拒绝服务以及网络拥塞。而 pull 模式则可以根据 consumer 的消费能力以适当的速率消费消息。

​ pull 模式不足之处是,如果 kafka 没有数据,消费者可能会陷入循环中, 一直返回空数据。 针对这一点, Kafka 的消费者在消费数据时会传入一个时长参数 timeout,如果当前没有数据可供消费, consumer 会等待一段时间之后再返回,这段时长即为 timeout

2.2、分区分配策略

​ 一个 consumer group 中有多个 consumer,一个 topic 有多个 partition,所以必然会涉及到 partition 的分配问题,即确定那个 partition 由哪个 consumer 来消费。

​ Kafka 有两种分配策略,一是 RoundRobin,一是 Range 。

触发时机:消费者组里个数发生变化时。(包括消费者启动时和发生改变时)
1) RoundRobin

​ 把所有的 partition 和所有的 consumer 都列出来,然后按照 hashcode 进行排序,最后通过轮询算法来分配 partition 给到各个消费者。

具体案例:

假如有3个Topic :T0(三个分区P0-0,P0-1,P0-2),T1(两个分区P1-0,P1-1),T2(四个分区P2-0,P2-1,P2-2,P2-3)

有三个消费者:C0(订阅了T0,T1),C1(订阅了T1,T2),C2(订阅了T0,T2)

那么分区过程如下所示:

轮询关注的是组

分区将会按照一定的顺序(hashcode排序)排列起来,消费者将会组成一个环状的结构,然后开始轮询。

结果可能是这样的:

C0: P0-0,P0-2,P1-1
C1:P1-0,P2-0,P2-2
C2:P0-1,P2-1,P2-3

优点:

多个消费者之间消息条数差距在1以内;

缺点:

轮询的时候,可能消费者会拉取到不是自己分区的内容;

场景:所以应该在当前消费者组订阅的topic相同的情况下时使用;

2)Range(默认策略)

范围分区策略是对每个 topic 而言的,只关注单个的消费者

首先对同一个 topic 里面的分区按照序号进行排序,并对消费者(不是消费者组)按照字母顺序进行排序。通过 partitions数/consumer数 来决定每个消费者应该消费几个分区。如果除不尽,那么前面几个消费者将会多消费 1 个分区。

range跟组没什么关系,只给订阅了的消费者发,而不是给订阅了的消费者组发
缺点:随着主题数的增多,不同消费者之间消息的数量差距可能会越来越大;

场景:不同消费者订阅的topic不同;

注意:在这种分区策略下:同一消费者组中消费者个数是可以大于分区数的,但是这样会产生闲置的consumer;

2.3、offset

两种offset
Offset从语义上来看拥有两种:Current Offset和Committed Offset。

Current Offset

只要进程不挂,consumer访问的offset会是Current Offset;

​Current Offset保存在Consumer客户端中,它表示Consumer希望收到的下一条消息的序号。它仅仅在pull()方法中使用。例如,Consumer第一次调用pull()方法后收到了20条消息,那么Current Offset就被设置为20。这样Consumer下一次调用pull()方法时,Kafka就知道应该从序号为21的消息开始读取。这样就能够保证每次Consumer poll消息时,都能够收到不重复的消息。

Committed Offset

进程只在开启的时候访问一次Committed Offset;

​ Committed Offset保存在Broker上,它表示Consumer已经确认消费过的消息的序号。主要通过commitSync(同步提交)和commitAsync(异步提交)API来操作。举个例子,Consumer通过poll() 方法收到20条消息后,此时Current Offset就是20,经过一系列的逻辑处理后,并没有调用consumer.commitAsync()或consumer.commitSync()来提交Committed Offset,那么此时Committed Offset依旧是0。

​ Committed Offset主要用于Consumer Rebalance。在Consumer Rebalance的过程中,一个partition被分配给了一个Consumer,那么这个Consumer该从什么位置开始消费消息呢?答案就是Committed Offset。另外,如果一个Consumer消费了5条消息(pull并且成功commitSync)之后宕机了,重新启动之后它仍然能够从第6条消息开始消费,因为Committed Offset已经被Kafka记录为5。

总结一下,Current Offset是针对Consumer的poll过程的,它可以保证每次pull都返回不重复的消息;而Committed Offset是用于Consumer Rebalance过程的,它能够保证新的Consumer能够从正确的位置开始消费一个partition,从而避免重复消费。

​ 由于 consumer 在消费过程中可能会出现断电宕机等故障, consumer 恢复后,需要从故障前的位置继续消费,所以 consumer 需要实时记录自己消费到了哪个 offset,以便故障恢复后继续消费。

Consumer 事务

​ 对于 Consumer 而言,事务的保证就会相对较弱,尤其时无法保证 Commit 的信息被精确消费。这是由于 Consumer 可以通过 offset 访问任意信息,而且不同的 Segment File 生命周期不同,同一事务的消息可能会出现重启后被删除的情况;(比如说消费者挂掉之后,刚好消息保留时间超过了kafka设定的时间,从而被删除掉了,再次开启后找不到对应的数据了)
总体而言,Consumer的Exactly Once的保证主要来自于offset机制。

Flink的Exactly Once

同样,我们需要先了解两个Flink中特别重要的概念:barrier和checkpoint;

Checkpoint机制

Flink 故障恢复机制的核心,就是应用状态的一致性检查点

​ 有状态流应用的一致检查点,其实就是所有任务的状态,在某个时间点的一份拷贝(一份快照);这个时间点,应该是所有任务都恰好处理完一个相同的输入数据的时候;保存的状态其实是各个任务都处理完某个数据之后的一个状态;source的状态也需要保留,用来恢复source中的数据偏移量,防止丢失数据;

checkpoint算法实现–barrier机制

简单想法

​ 暂停应用,保存状态到检查点,再重新恢复应用;

Flink改进

​ 基于 Chandy-Lamport 算法的分布式快照;(分别处理,最后进行拼接)

​ 将检查点的保存和数据处理分离开不暂停整个应用

➢ 检查点分界线(Checkpoint Barrier)

​ Flink 的检查点算法用到了一种称为分界线(barrier)的特殊数据形式,用来把一条流上数据按照不同的检查点分开

​ 分界线之前到来的数据导致的状态更改,都会被包含在当前分界线所属的检查点中;而基于分界线之后的数据导致的所有更改,就会被包含在之后的检查点中

​ 举例说明:类似于 bbbbb|ooo|aaa ; o、a、b表示数据,|表示分界线barrier; 假设a先被处理,当a被处理完,触发到第一个barrier时,此时状态会被保存到状态后端中,当o被处理完时,触发到第二个barrier,此时状态也会被保存到状态后端中,哪一段地方出了问题,可以直接通过barrier对应的checkpoint中获取状态,然后读取状态;

图解说明:

在这里插入图片描述

​ 现在是一个有两个输入流(都是1、2、3、4、5、6的数据流行)(奇数求和和偶数求和)的应用程序,用并行的两个 Sourc e 任务来读取;不同颜色表示不同的流和流数据;

第一个蓝2:蓝流Source的1和Odd numbers的1相加得到;

第一个橙2:橙流Source的2和Even numbers的0相加得到;

第一个橙5:之前的2+Oddnumbers的3得到,此时蓝3还没有到;

Barrier如何产生的?

在这里插入图片描述

上方三角形蓝色的2就是检查点的ID(不是数据),而且这个checkpoint会发给所有并行Source;(JobManager主导的自动存盘过程)

此时蓝2和橙2已经从Sink处输出;此时Source中的偏移量分别为3和4;

检查点的保存
在这里插入图片描述

数据源将它们的状态写入检查点,并发出一个检查点 barrier;

​ 状态后端在状态存入检查点之后,会返回通知给 source 任务,source 任务就会 向 JobManager 确认检查点完成

​ 把之前记录到的蓝流3偏移量和橙流4偏移量进行保存,存储到状态后端对应的存储空间中;同时,把2号检查点的快照信息反馈给JobManagerJobManager记录对应checkpoint和对应的存储地址即可;此时不影响TaskManager的工作;同时,Source也应该给下游所有的任务进行广播发送checkpoint信息(这样每个下游任务都知道任务进行到何处了);

​ 同时,这一步蓝2和蓝3也进行了更新;

那么每一个任务是如何保存Barrier的呢?

注意到上图中此时蓝2和橙2checkpoint都会发送到每一个下游任务中,那么下游任务的快照到底是什么时候开始的?
在这里插入图片描述

注意上方蓝2checkpoint是在蓝4之前的,如果发生上图这种情况,如果蓝4在橙2进入Sum之前现行进入Sum了,那么根据一致性检查点的定义,这里就会出现问题;(这里的情况是很有可能发生的,比如一个Slot的并行度情况,一定会发生蓝2或者橙2现行进入Sum的情况,所以需要解决这个问题)

​ 这里就需要引入一个概念

  • 分界线对齐:

  • barrier 向下游传递,sum 任务会等待所有输入分区的 barrier到达

  • 对于barrier已经到达的分区,继续到达的数据会被缓存

  • 而barrier尚未到达的分区数据会被正常处理

    这么做的目的是为了让barrier到达时,确保其之前的数据已经全部运算完了;

    注意:如果没有barrier对齐,那么最后的状态一致性分类就是At-least-once;

当收到所有输入分区的 barrier 时,任务就将其状态保存到状态后端的检查点中,然后将 barrier 继续向下游转发;

也即这里的两个Sum任务会等待所有的橙2和蓝2checkpoint到达后才会进行下一步,此时的Sum保存的状态都是8,这也会保存到检查点中;
在这里插入图片描述

向下游转发检查点 barrier后,任务继续正常的数据处理;
在这里插入图片描述
注意:Sink在收到checkpoint的信息后,也会向JobManager返回确认状态;
在这里插入图片描述
当所有任务都确认已成功将状态保存到检查点时,检查点就真正完成了。

Flink端到端状态一致性

​ 由于输出的文件很难在文件系统中做到全盘重新扫描和回滚操作,所以需要在Sink端输出结果时,注意输出方式;

幂等写入(Idempotent)(了解)

过程并不是指写入一次,但结果满足EXACTLY-ONCE要达到的结果;

过程思考:类似对e*x求导结果还是e*x;(结果不影响)

也类似HashMap的写入,value与key对应,无论写入多少次,结果不变;

所以使用幂等写入会造成:可能会出现重复的一个输出过程,但最终的计算结果是一样的;

事务写入(Transactional Writes)

• 实现思想:构建的事务对应着 checkpoint,等到 checkpoint 真正完成的时候,才把所有对应的结果写入 sink 系统中

• 实现方式

➢ 预写日志

预写日志:(Write-Ahead-Log,WAL)

​ 把结果数据先当成状态保存,然后在收到 checkpoint 完成的通知时,一次性写入 sink 系统;

​ 简单易于实现,由于数据提前在状态后端中做了缓存,所以无论什么sink 系统,都能用这种方式一批搞定

​ DataStream API 提供了一个模板类:GenericWriteAheadSink,来实现这种事务性 sink;

缺点:类似批处理,影响了效率;

➢ 两阶段提交

两阶段提交:(Two-Phase-Commit,2PC)

​ 对于每个 checkpoint,sink 任务会启动一个事务,并将接下来所有接收的数据添加到事务里

​ 然后将这些数据写入外部 sink 系统,但不提交它们 —— 这时只是“预提交”;

​ 当它收到 checkpoint 完成的通知时,它才正式提交事务,实现结果的真正写入;(这里的等到checkpoint完成通知是指要等JobManager返回完成的通知信息,而不是任务做完就结束了)

​ 这种方式真正实现了 exactly-once,它需要一个提供事务支持的外部sink 系统。Flink 提供了 TwoPhaseCommitSinkFunction 接口。

Flink+Kafka 端到端状态一致性的保证

内部 —— 利用 checkpoint 机制,把状态存盘,发生故障的时候可以恢复,保证内部的状态一致性;

• source —— kafka consumer 作为 source,可以将偏移量保存下来,如果后续任务出现了故障,恢复的时候可以由连接器重置偏移量,重新消费数据,保证一致性;

• sink —— kafka producer 作为sink,采用两阶段提交 sink,需要实现一个 TwoPhaseCommitSinkFunction。

对于FlinkKafka消费者,可以使用以下代码实现exactly-once语义: 1. 在Flink环境中创建Kafka消费者时,需要使用 KafkaFlinkKryoSerializer 序列化器。 2. 设置ProducerRecordSemantic.EXACTLY_ONCE语义,这样Flink就会使用Kafka事务来确保数据仅被处理一次。 下面是示例代码: ``` import org.apache.flink.api.common.serialization.SimpleStringSchema; import org.apache.flink.streaming.api.CheckpointingMode; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; import org.apache.flink.streaming.connectors.kafka.FlinkKafkaConsumer; import org.apache.flink.streaming.connectors.kafka.FlinkKafkaProducer; import org.apache.flink.streaming.util.serialization.KafkaTuple2KryoSerializer; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.common.serialization.StringSerializer; import java.util.Properties; public class FlinkKafkaExactlyOnce { public static void main(String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.enableCheckpointing(1000, CheckpointingMode.EXACTLY_ONCE); Properties kafkaProps = new Properties(); kafkaProps.setProperty("bootstrap.servers", "localhost:9092"); kafkaProps.setProperty("group.id", "flink-group"); kafkaProps.setProperty("enable.auto.commit", "false"); kafkaProps.setProperty("auto.offset.reset", "earliest"); kafkaProps.setProperty(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer"); kafkaProps.setProperty(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer"); kafkaProps.setProperty(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed"); Properties producerProps = new Properties(); producerProps.setProperty("bootstrap.servers", "localhost:9092"); producerProps.setProperty(ProducerConfig.RETRIES_CONFIG, "3"); producerProps.setProperty(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); producerProps.setProperty(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); producerProps.setProperty(FlinkKafkaProducer.TRANSACTION_TIMEOUT_TIMER_INTERVAL_MS, "600000"); FlinkKafkaConsumer<String> consumer = new FlinkKafkaConsumer<>("input_topic", new SimpleStringSchema(), kafkaProps); consumer.setStartFromEarliest(); consumer.setCommitOffsetsOnCheckpoints(true); FlinkKafkaProducer<String> producer = new FlinkKafkaProducer<>("output_topic", new SimpleStringSchema(), producerProps, FlinkKafkaProducer.Semantic.EXACTLY_ONCE); env .addSource(consumer) .map(String::toLowerCase) .addSink(producer); env.execute(); } } ``` 在此代码中,我们使用 KafkaTuple2KryoSerializer 序列化器 ProducerRecordSemantic.EXACTLY_ONCE语义来确保消费数据生产数据仅处理一次。同时,我们还使用 FlinkKafkaProducer.TRANSACTION_TIMEOUT_TIMER_INTERVAL_MS 属性来延长事务生命周期,以便可以增加提交事务的成功率。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值