春水煎茶
图片来自 unsplash

逻辑时钟 - 如何刻画分布式中的事件顺序

· · 源自博客「春水煎茶」

逻辑时钟是描述分布式系统中时序和因果关系的一种机制。 由于网络延迟、时钟漂移等现实问题,我们无法建立一个全局物理时钟来描述时序, 因此区别于物理时钟的「逻辑时钟」机制应运而生。 第一个逻辑时钟算法是由分布式领域的大神Lamport在1978年提出的lamport时钟算法。

本文将从一个基础问题的讨论切入,逐步介绍:

逻辑时钟的思想有趣而深刻,值得探究。

§ 如何确定分布式系统中事件的发生顺序?

首先,我们来观察一个「如何确定分布式系统中事件的发生顺序」的问题:

在这个分布式系统中,有三个独立的进程A、B、C:

  1. 首先,进程A中发生了一个事件$a$,并把这个事件消息同步给另外两个进程B和C。
  2. B收到消息后,发生了一个事件$b$,并把这个事件消息同步给进程C。
  3. 但是由于无法确定的网络延迟原因,导致进程A发出的消息到达C晚于进程B发出的消息到达C,这样 进程C的视角上,最终看到的事件顺序是 $b, a$,但是这与事实是相悖的。
图1 - 由于网络延迟, 进程$B$发出的消息晚于进程$A$发出的消息到达进程$C$

可以看到,在分布式通信中,由于网络延迟的不确定性, 仅仅以接收顺序作为整个分布式系统中事件的发生顺序是不可取的

下面我们再观察一个稍微现实点的例子。

  1. 假设朋友圈有三个数据中心,分别在北京、维也纳和纽约。
  2. 北京小明在朋友圈中发了一张风景图,并问到「猜猜这里是哪里?」,这条消息被扩散到其他数据中心。
  3. 维也纳的小红看到这个消息后,回复说她知道。这条回复也被扩散到其他数据中心。
  4. 但是由于无法确定的网络延迟的原因,导致纽约的数据中心先收到小红的回复,而后收到了原始的提问消息。 这样,导致最终小李看到的问答顺序是不符合问答的因果一致性的。
图2 - 由于网络延迟,小李看到的事件顺序并不符合问答的因果关系

以上的例子,是腾讯微信朋友圈真实碰到并解决的问题。 详细的介绍见 微信朋友圈技术之道

下面是微信朋友圈架构设计的两个关于因果一致性算法设计的PPT截图:

图3 - 《微信朋友圈技术之道》中分享的关于因果性的PPT
图4 - 《微信朋友圈技术之道》中分享的关于向量时钟的PPT

在腾讯的分享PPT中,提到了「向量时钟」, 不过我们稍后再揭开它的神秘面纱。

而这两个例子中,我们面临的要解决的问题, 其实是分布式系统中的因果一致性, 换句话说,就是如何准确刻画分布式中的事件顺序, 显然仅仅简单地依靠进程接收到的(看到的)事件发生顺序是不准确的

§ 全局物理时钟

对于上面提到的两个问题,我们试图解决的一个天然想法是, 记录每个分布式进程中发生事件的原始时间戳, 并把它连同事件本身扩散到其他节点,这样其他节点的视角上就可以观察到完整的因果顺序了?

“If you have one clock, you know what the time is. If you have two, you are not sure.” – Anonymous

大家都清楚的一点是,不同节点的物理时钟其实是不一致的,而且无法做到精确一致

其原因:

  1. 仍然是由于网络延迟的不确定,我们无法通过网络同步时间来获取一个全局一致的物理时钟。
  2. 现实中的多个时钟,即使时间已经调成一致,但是由于日积月累的计时速率的差异,会导致时钟漂移而显示不同的时间。

如此看来,寄希望于一个全局的时钟来对事件顺序做全局标定也是不现实的

参考链接:

§ 全序和偏序

在数学上,「顺序」是如何描述的?

我们看下序理论中的两种序关系:偏序(partial ordering) 和 全序 (total ordering).

偏序: 假设 $\le$ 是集合$S$上的一个二元关系,如果 $\le$ 满足:

  1. 自反性: 对$S$中任意的元素$a$,都有 $a\le a$.
  2. 反对称性: 如果对于$S$中的两个元素 $a$ 和 $b$, $a\le b$ 且 $b\le a$,那么 $a=b$
  3. 传递性: 如果对于$S$中的三个元素,有$a\le b$ 且 $b\le c$, 那么 $a \le c$

以上的数学内容其实不那么重要~ 关键理解: 偏序关系是一种序关系,但只是部分元素有序,并不是全部元素都可以比较。

全序则比偏序的要求更为严格一些, 在偏序的基础上,多了一个完全性的条件:

  1. 完全性: 对于$S$中的任意$a$ $b$元素,必然有 $a \le b$ 或 $b \le a$.

可以看出,实际上, 全序就是在偏序的基础上,要求全部元素都必须可以比较

总结来看,简短说: 偏序是部分可比较的序关系,全序是全部可比较的序关系

举例来看:

  1. 自然数集合中的比较大小的关系,就是一种全序关系。
  2. 集合之间的包含关系,则是一种偏序关系(集合之间可以有包含关系,也可以没有包含关系)。
  3. 复数之间的大小关系,是一种偏序关系,而复数的模的大小关系,则是一种全序关系。

下面用简单的有向图(哈斯图)来描述下全序和偏序的区别:

图5中$S_{1}$上的图示关系,描述的是整数之间的大小顺序,是一种全序关系,可以看到任意两个元素之间都可以比较顺序。

而$S_{2}$上的关系,描述的是集合之间的包含关系,是一种偏序关系,其中 $v_{2}$ 和 $v_{3}$ 是不可比较的。

图5 - $S1$描述了全序关系; $S2$描述了偏序关系

现在我们回头看,寻求全局时钟为分布式节点中的时间做顺序标定的方式, 其实是在寻求一种全序关系来描述分布式中的事件顺序, 而且是严格对齐真实的物理时钟的全序关系。

§ 事件先后的相对性

逻辑时钟的概念是由著名的分布式系统科学家 Leslie Lamport (2013年图灵奖得主) 提出的, 在他的那篇著名的论文「Time, Clocks and the Ordering of Events in a Distributed System」 的介绍上,lamport提到了著名的狭义相对论

Special relativity teaches us that there is no invariant total ordering of events in space-time; different observers can disagree about which of two events happened first. There is only a partial order in which an event e1 precedes an event e2 iff e1 can causally affect e2. – Leslie Lamport Time clocks and …

爱因斯坦的狭义相对论告诉我们,时空中不存在绝对的全序事件顺序, 不同的观察者可能对哪个事件是先发生的无法达成一致。 但是有偏序关系存在,当事件e2是由事件e1引起的时候,e1和e2之间才有先后关系

对于「不同的观察者可能对哪个事件是先发生的无法达成一致」这个说法, 我们从同时的相对性开始说起:

根据狭义相对论,发生在空间中不同位置的两个事件,它们的同时性并不具有绝对的意义, 我们没办法肯定地说它们是否为同时发生。若在某一参考系中此两事件是同时的,则在另一相对于原参考系等速运动的新参考系中, 此两事件将不再同时。 – 维基百科【同时的相对性】

因为狭义相对论最基本的假设,光速不变原理: 无论在何种惯性参考系下,光在真空中的传播速度相对观察者都是一个常数。所以「同时」这个概念也是相对的。

关于同时的相对性,有一个著名的火车思想实验:

有一个观察者$A$在移动的火车中间,有另一位观察者$B$在地面上的月台上, 当两个观察者相遇时,一道闪光从火车的中央发出。

对于火车上的观察者$A$而言,由于火车头和火车尾距离光源的距离是相同的, 因此观察到了光同时到达了车头和车尾。

但是对于月台的观察者$B$而言,火车的尾部会迎向光移动,而车头会远离光移动, 而且光速是有限的,且相对于两个观察者都是相同的常数,所以$B$认为光会先到达车尾,后到达车头。

图6 - 狭义相对论中著名的火车思想实验

这样,对于不同参照系的观察者而言,事件的顺序并没有一个一致性的结论。 之所以得出这样神奇的结论,仍然是因为关键的「光速不变原理」。 但是,这并不意味着发生了因果上的逻辑矛盾, 我们在这种情况下,只是无法在不同的参照系下观察到一致性的事件顺序。

我们经验上所说的「同时发生」,是因为光速太大,或者我们生活的尺度太小, 所以同时是一种近乎同时。

相对论告诉我们,光速是物质移动的最大速度,信息传播的速度不可能超过这个速度。 假如太阳消失了,地球上的我们也要在8分钟之后感知到太阳消失了。

也就是说,一个事件$A$发生后, 载有这个事件信息的光(引力、无论什么,快不过光速)到达观察者之前, 观察者是无法没有任何感知的, 这时我们就无法定义事件的顺序。 而当信息最终传播到达观察者时, 这个事件也就对观察者发生了影响, 造成了一个新的事件$B$,叫做「观察到了事件$A$的事件$B$」。 这时我们才有 $A$ happened before $B$ 的因果关系。

图7 - 物理时空中因果关系建立了事件顺序

可以看到,时空对于描述事件顺序的「happened before」同样是偏序关系。

因为事件$A$的发生, 造成了事件$B$的发生(包括$B$这种观察$A$而发生的事件),那么$A$和$B$就存在因果关系。

Lamport受相对论中事件顺序的相对性的启发,创造了Lamport Logical Clocks

我们的分布式系统,和相对论有很多相似之处:

总体来说, 逻辑时钟尝试用「通过进程间创造通信以添加因果关系」的方式来对分布式中的事件顺序做描述。

下面,我们来看下,「通过通信创造因果关系」 这个设计对于刻画分布式系统中的事件顺序有多么重要。

观察下面图8中的两个图:

我们试着从每个进程的视角,依次对图$S_{1}$和$S_{2}$进行推导一下,会发现, 其实两个图所描述的事件顺序,在进程的相对视角中,是一样的

图8 - 示例: $S_{1}$和$S_{2}$虽然在物理时序上不一样,但是在各个进程的视角上推导出来却是一样的

我们的逻辑时序应该越接近物理时序越好,然而两个图对时序的刻画, 出现了歧义(比如无法确定 $a_{3}$ 和 $b_{2}$ 的顺序)。 根本上是没有做充分的消息传递来添加因果关系

§ 逻辑时钟(Lamport’s timestamp)

现在,我们开始讨论Lamport的逻辑时钟算法。

首先我们需要明确一点: 逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序。

那么,「事件」是如何分类的:

  1. 进程内自发的事件(如下图中的红色标记的事件)。
  2. 发送一个消息,是一个事件(如下图中的蓝色标记的事件)。
  3. 接收一个消息,是一个事件 (如下图中的黑灰色标记的事件)。
图9 - 分布式中事件的分类

我们上面有提到「happened before」的关系,我们知道,因果关系是推导「happened before」关系的重要一环:

if $e_{i}$ causes $e_{j}$, then $e_{i}$ must happen before $e_{j}$ – Logical Time

道理是显然的:如果事件$e_{i}$导致了事件$e_{j}$,那么一定$e_{i}$发生在$e_{j}$之前

我们现在给「happened before」这个关系一个记号: $\rightarrow$, 事件$a$在事件$b$之前发生则表示为 $a \rightarrow b$, 那么我们有:

  1. 如果 $a$ 和 $b$ 是同一个进程内的事件,并且 $a$ 在 $b$ 之前发生,则 $a \rightarrow b$。
  2. 如果事件 $a$ 是「发送了一个消息」,而事件 $b$ 是接收了这个消息,则 $a \rightarrow b$。
  3. 如果 $a \rightarrow b$ 并且 $b \rightarrow c$ ,那么 $a \rightarrow c$ (即传递性)。

那么,是否存在两个事件并无顺序关系吗? 经过前面的讨论,答案当然是肯定的。

如果两个事件无法推导出顺序关系的话,我们称两个事件是并发的,记作 $a \parallel b$。

这样,我们可以这样描述上面的图9中存在的事件顺序:

可以看出, 「 happened before」$\rightarrow$ 是一个偏序关系。

Lamport的时钟算法

  1. 每个进程 $P_{i}$ 内维护本地一个计数器 $C_{i}$ ,初始为$0$.
  2. 每次执行一个事件,计数器 $C_{i}$ 自增 (假设自增量为$1$).
  3. 进程 $P_{i}$ 发消息给进程 $P_{j}$ 时,需要在消息上附带自己的计数器 $C_{i}$.
  4. 当进程 $P_{j}$ 接收到消息时,更新自己的计数器 $C_{j}=Max(C_{i}, C_{j})+1$

下面的图10是这个算法的示意图,可以推算最后的时钟计数器的值:$C_{i}=3$, $C_{j}=5$

图10 - Lamport时钟算法

下面,将证明:如果 $a \rightarrow b$,那么一定有 $C_{a} < C_{b}$。

  1. 假设 $a$ 和 $b$ 发生在同一个进程内,显然 $C_{a} < C_{b}$.
  2. 假设 $a$ 和 $b$ 分别处在不同的进程内,如 $P_{a}$ 和 $P_{b}$,

    根据事件先后的定义,必然存在一个不早于 $a$ 且 不晚于 $b$ 的由 $P_{a}$ 到 $P_{b}$ 的通信 (否则 $a \parallel b$ ,矛盾)。

    那么假设两个进程在 $a$ 和 $b$ 之间最近一次通信是由 $P_{a}$ 向 $P_{b}$ 发送了消息 $a \rightarrow b$: 易得 $a \rightarrow c \rightarrow d \rightarrow b$ (其中可能 $a=c$ 或者 $d=b$) 。根据算法定义,得:

    1. $C_{a} \le C_{c}$ (进程内计数器自增).
    2. $C_{d} \le C_{b}$ (进程内计数器自增).
    3. $C_{c} < C_{d}$ (进程间通信,观察者事件已经严格大于发生者事件的计数器)。

    那么,最终推导出 $C_{a} < C_{b}$(严格小于)。

    图11 - 事件 $a$ 和 $b$ 在不同进程的情况下,中间一定有消息传递,否则两个事件并发

以上,得证 $a \rightarrow b \Rightarrow C_{a} < C_{b}$。

但是,如果我们已知 $C_{a} < C_{b}$ 的话,是否可以推导出 $a \rightarrow b$ 呢?

悲哀的是,不能。 下面的图12是个反例:

图12 - lamport时钟无法反向推导事件顺序的反例: 红色 $a$ 和蓝色 $b$ 是并发的

这样,我们反证了 $C_{ a } < C_{ b } \nRightarrow a \rightarrow b$。

我们无法推导出 $C_{ a } < C_{ b } \Rightarrow a \rightarrow b$ 的原因,在于 $a$ 可能和 $b$ 并发。

但是, 如果 $C_{ a } < C_{ b }$,一定不会有 $b \rightarrow a$ 的关系存在。

Lamport的逻辑时钟算法构建了一个全序(total ordering)时钟来描述事件顺序, 但是我们知道「happened before」是一个偏序关系, 用全序关系的一维计数器来描述「happened before」的话, 就会导致无法等价化描述的结果, lamport时钟的缺陷在于:如果两个事件并不相关,那么这个时钟给出的大小关系则没有意义, 这个缺陷其实恰好就是全序和偏序的不同点而已

所以,要准确描述事件顺序,我们终究要寻求偏序方法。

于是,我们继续探讨向量时钟。

§ 向量时钟 (Vector Clocks)

向量时钟,其实是对Lamport的时钟的一个延伸思考,算法结构一致,只是多传了一部分信息

对每个进程,定义一个向量 $VC$ ,向量的长度是 $n$, $n$ 是进程数目。

  1. 初始化各个进程 $P_{i}$ 的向量, 全部抹零: ${VC}_{i} = [0,…,0]$。
  2. 进程 $P_{i}$ 每发生一个事件时, 其向量的第 $i$ 个元素自增: ${VC}_{i}[i] += 1$。
  3. 当进程 $P_{i}$ 发消息给进程 $P_{j}$ 时,需要在消息上附带自己的向量 ${VC}_{i}$。
  4. 当进程 $P_{j}$ 接收到消息时,对齐对方的时钟,并在自己的时钟上自增:

    对 $[0, n)$ 上的任意一个整数 $k$ 执行

    接着,对应第2点:

下面图13是一个向量时钟算法的示意图:

  1. 和lamport时钟算法示意图一样:(红色点、蓝色点、黑灰色点..)
  2. 图的右方部分,总结了这个算法对不同事件的操作:

    1. 对于红色和蓝色,也就是进程内自发性事件和发送消息的事件,向量内相应的计数器自增。
    2. 发送消息的时候,需要传播出去自己的整个向量(也就是广播自己对整个系统的视角)。
    3. 接收到消息的时候,也就是蓝色事件,需要对齐对方的向量,并应用第一条规则,即自己的向量内相应计数器自增。
图13 - 向量时钟示意图

可以发现,向量时钟和lamport的时钟算法结构一样, 不同的点在于: lamport时钟只是在对齐时钟的计数器, 而向量时钟是在对齐各自对整个系统的视角

我们可以推导出来关于向量时钟比较大小的几个性质:

  1. 向量的各维相等,则向量相等。 这个是显而易见的。

  2. 向量时钟是有序的 (充要),即:

    的各维上的值不大于 对应维上的值, 则认为 不大于

  3. 向量时钟有序性质的进一步细化,定义了严格小于

    如果 不大于 , 并且至少存在一个维,在这个维上 的值严格小于 在这个维上的值, 则认为 小于

  4. 如果两个向量不存在大小关系,则认为两个向量平行,记作 .

这几个性质看起来复杂,其实都是在定义向量时钟之间的大小关系。 我们可以得出一个结论:

向量时钟之间的大小关系是一种偏序关系

和lamport时钟一样,我们可以利用类似的推导方式,证明 $ a \rightarrow b \Rightarrow VC_a < VC_b$ 。 这里不再描述证明思路。

我们接下来要花一定篇幅论证下 $VC_a < VC_b \Rightarrow a \rightarrow b$ 。

  1. 如果 $a$ 和 $b$ 两个事件处在同一个进程中,显而易见 $a \rightarrow b$ 。
  2. 假设 $a$ 和 $b$ 分别处在不同的进程内,如 $P_a$ 和 $P_b$ ,

    ,

    因为 $VC_a < VC_b$ ,所以 $m \le s$, 所以必然在 不早于 $a$ 之前 和 不晚于 $b$ 之后的时间内, $P_a$ 向 $P_b$ 发送了消息 (否则 $P_b$ 对 $P_a$ 的计数器得不到及时刷新,$s$ 就不会不小于 $m$ )。

    图14 - 中间必然存在$P_a$向$P_b$发送了消息

    实际上,可以分为以下几种情况:

    图15 - 可能出现的4种情况
    1. 当 $a = c$ 且 $d = b$ , 易得 $a \rightarrow b$.
    2. 当 $a = c$ 且 $d \rightarrow b$ ,由传递性,得 $a \rightarrow b$ .
    3. 同样对于 $d = b$ 且 $a \rightarrow c$ 的情况.
    4. 当 $a \rightarrow c$ 且 $d \rightarrow b$ ,根据进程内的算法逻辑和传递性,也很容易得出结论。

综上: $VC_a < VC_b \Rightarrow a \rightarrow b$ 得证。

进一步的,我们可以得出这样的结论:

  1. 向量有序,则事件有序(充要):

    $VC_a < VC_b \Leftrightarrow a \rightarrow b$

  2. 向量平行,则事件并发(充要):

    $VC_a \parallel VC_b \Leftrightarrow a \parallel b$

是的,向量时钟可以准确刻画事件顺序

其本质在于将lamport时钟的全序计数器的方式改造成向量时钟的偏序大小关系。

§ 向量时钟看前面的问题

现在我们回到文中一开始提到的问题。

由于网络延迟的不确定性, 我们在文章开始提出了一个$A$, $B$, $C$三个进程中如何确定事件顺序的问题。

我们可以从下面的图16中看出, $VC_{a} < VC_{b} < VC_{c}$,那么可以确定 $a \rightarrow b \rightarrow c$。 也就是我们找到了一种方法来描述这种情况下的事件顺序。 进程$C$的视角下观察 $a$ 和 $b$ 的顺序的问题也有了明确的答案。

图16 - 用向量时钟的方法解答文章开始提出的问题

最后,我们回到朋友圈的例子。 下面图17中可以看出, $VC_{a} < VC_{b} < VC_{c} < VC_{d} < VC_{e}$, 显然可以确定 $a \rightarrow b \rightarrow c \rightarrow d \rightarrow e$ 。

在小李看到小明的朋友圈和小红的评论的时候,也收到了这两条数据的向量, 我们可以根据向量时钟来确定事件的先后关系,从而不会显示出因果矛盾。

图17 - 用向量时钟的方法看朋友圈问题

图17中还有一个事件 $f$, 它的发生可能是小红又发了一条评论。 我们可以看到 $VC_{f} \parallel VC_{e}$。 这时候,无法确定事件 $f$ 和 $e$ 的先后关系,也就是说 $f \parallel e$。

但是,这时候小李还没有看见这个事情。所谓「因果」:有因才有果。 看见也是一个事件的果。 这样说, $f$ 和 $e$ 没有因果关系, 因为小李还没有看见这个事件。 所以讨论也就没有意义。

§ 版本向量 (Version Vectors)

版本向量 Version Clocks 是一种工程上用来同步分布式数据的算法, 和向量时钟非常相似,以至于人们经常混淆这两种算法。 但是二者却有不同的地方 (Version Vectors are not Vector Clocks)。

我们注意到在向量时钟算法中, 消息传播后,发送方的向量一定会小于接收者的向量, 是因为接收者对齐了发送者的原因。

版本向量在此基础上,做了一点小的加强:

消息传播后,发送方也对齐接收者的向量, 也就是双向对齐,在版本向量中,叫做同步。

用数学语言描述的话,向量时钟算法的第三点,改成:

对 $[0, n)$ 上的任意一个整数 $k$ 执行

除了这个关键性区别外, 版本向量还和向量时钟算法有一点细微的区别:

发送消息和接收消息的时候不再自增向量中的自己的计数器,而是只做双方的向量对齐操作。 也就是,只有在更新数据的时候做向量自增

版本向量是针对数据的更新和同步的协调算法,那么我们把数据操作的事件分为以下两种:

  1. 更新数据的事件, 即图18中红色的点。
  2. 同步数据的事件, 发送数据 和 接收数据, 分别为蓝色和黑灰色的点。
图18 - 版本向量示意图

我们可以看到,在进行一次同步操作后,双方进程的版本向量会变成一样。

在我们熟知的网络通信方式上(比如TCP,UDP),同步操作可以做成原子的 , 比如接收进程对收到的消息进行消息反写。

§ 向量时钟的不足

我们再次回到向量时钟,可以看出它的两点不足:

  1. 只考虑了固定数量的节点,没有考虑节点的动态添加和销毁。

    如果我们考虑用类似Hashtable而不是Vector的描述方式的话, 我们还需要事先给每个节点定义一个全局唯一标识。

    现代解决方案: 区间树时钟 - Interval Tree Clocks。 这个论文我没有看明白 T_T。

    大概是每个时间戳设定为 $(Id, Event)$, 而 $Id$ 和 $Event$ 都用区间树的数据结构来做。 而区间树是可以在 $[0,1]$ 实数域上无限二分的。

    文中建立了一个通用的时钟模型叫做「Fork-Event-Join Model」,

    新加入一个节点的时候,我们找一个节点进行 $fork$ , $fork$ 则将当前节点的时间戳的 $Id$ 二分、 $Event$ 克隆,作为新节点的初始时间戳。

    当一个节点要移出的时候, 我们把它合并到一个其他节点, $join$ 类似 $fork$ 的一个 反向操作, 类似一个取最大者的方式进行合并。

    区间树时钟算法消去了向量时钟对全局唯一节点标识的依赖。

  2. 假设节点数量是 $N$ , 那么每个节点需要维护的空间复杂度是 $O(N)$。 通信的信息量的复杂度也是 $O(N)$ 。

    关于这个话题,有个文章可以看下: Causality Is Expensive. 文中提到:

    Causality can be characterized only by vector timestamps of size N.

    也就是这个 $O(N)$ 的复杂度,不能再小了。

    2019年新鲜出炉的一个寻求优化时钟空间的算法,布隆时钟 - Bloom Clocks

§ 总结与感想

此外,向量时钟给我一种感想,对每个分布式节点来说:

  1. 我把我的视角分享给其他节点。
  2. 我对齐我看到的其他节点的视角。

本质上,是在做 视角对齐

这自然地,让我想起了Gossip谣言传播算法。 Gossip算法如其名,每个分布式参与者都在散播自己视角的信息,以达到谣言扩散的效果。 同样是在做 视角对齐

对于分布式系统中的事件顺序的刻画,就讨论到这里。

– 毕 「逻辑时钟」

评论 · 打赏 · 分享
🦄 我的团队正在寻优秀的小伙伴加入~ !!
正在加载disqus...