Chinaunix首页 | 论坛 | 博客
  • 博客访问: 3508342
  • 博文数量: 203
  • 博客积分: 0
  • 博客等级: 民兵
  • 技术积分: 7301
  • 用 户 组: 普通用户
  • 注册时间: 2013-01-23 18:56
个人简介

将晦涩难懂的技术讲的通俗易懂

文章分类

全部博文(203)

文章存档

2024年(6)

2023年(9)

2022年(4)

2021年(12)

2020年(8)

2019年(18)

2018年(19)

2017年(9)

2016年(26)

2015年(18)

2014年(54)

2013年(20)

分类: LINUX

2024-07-14 16:00:42

Google Falcon看拥塞控制

——lvyilong316

FalconGoogle自研的可靠传输协议,其采用分层设计,由底层数据包传输层,事务层和ULP层构成。可支持RDMANVMe等不同ULP协议。其拥塞控制主要采用GoogleSwiftPLB,两个算法都比较经典。本文就以Falcon拥塞控制为切入点,讲一下其拥塞控制。

流量控制和拥塞控制

首先,我发现现实中大家讨论流量反压/限速过程中经常搞混流量控制和拥塞控制。

流量控制是作用于接收者的,它是控制发送者的发送速度从而使接收者来得及接收,防止分组丢失的。所以流量控制的核心是防止接受端处理不过来

而拥塞控制是作用于网络的,它是防止过多的数据注入到网络中,避免出现网络负载过大的情况,拥塞控制就是防止这种现象的发生,或者缓解拥堵问题。所以拥塞控制的核心是防止中间链路处理不过来。如果仅有首发两端的11通信是不需要拥塞控制的。

TCP为例,其流量控制方法是“滑动窗口”,接收方在返回的数据中会包含自己的接收窗口的大小,以控制发送方的数据发送。

    而拥塞控制方法就比较多了,如常见的Reno算法(慢启动、拥塞避免算法

快速重快速恢),BBRCubic等。所以TCP的发送窗口是受到流量控制和拥塞控制共同影响的:

发送窗口的上限值 = Min [rwnd, cwnd],其中rwnd为接收端窗口,cwnd为拥塞窗口;

l  rwnd < cwnd 时,是接收端的接收能力限制发送窗口的{BANNED}最佳大值。

l  cwnd < rwnd 时,则是网络的拥塞限制发送窗口的{BANNED}最佳大值。

    不过拥塞控制其实也包含了流量控制的的部分作用,比如接受端处理不过来和中间交换机buffer溢出都会导致丢包或者rtt增加,这个在发送端是无法感知的,所以如果由于超过接受端处理能力,也会导致拥塞控制感知,{BANNED}最佳终降速。那是不是有了拥塞控制就不需要流量控制了呢?当然不是,俗话说专业的人干专业的事,因为流量控制专门针对接收端,可以通过滑动窗口或credit机制的过早的感知接收端的能力,不用等到真正接收端丢包才做出反应。

另一个方面,其实有些技术也不严格的区分流量控制还是拥塞控制,比如ECNPFC,如果中间链路的交换机buffer 超过阈值导致PFC,那是拥塞控制,如果因为目的端buffer超过阈值导致PFC,那就是流量控制了。

滑动窗口的局限性

    TCP滑动窗口的原理上我们可以看出其只考虑了接收端buffer的处理能力,因为早期通常情况下接收端的瓶颈只会出现在buffer不足。但是随着技术的发展,接收端也有其他瓶颈逐步出现,比如CPU的处理能力。而这个并没有在滑动窗口中体现。当前{BANNED}最佳终他会体现拥塞控制中的丢包或者rtt增加。

Falcon拥塞控制

Falcon拥塞控制(CC)是在每个连接的基础上实现的,并且是数据包传输子层的一部分。CC算法实现在一个速率更新引擎(RUE)中,RUE是一个逻辑模块,与数据包传输子层的主要数据路径分开。数据包传输子层向RUE提供多种拥塞信号和状态信息,包括延迟的精确测量、ECN标记、接收缓冲区占用情况、已确认(ACKed)和未确认(NACKed)的数据包数量等。数据包传输子层与RUE之间的接口在每个方向上使用生产者-消费者队列,并在后续部分进行描述。RUE逻辑模块可以完全在硬件中实现,也可以在NIC上的嵌入式CPU上运行的软件中实现。

    将拥塞控制功能在数据包传输子层与RUE之间的分离至少有两个优点:

无论RUE是基于软件还是硬件,对拥塞控制信号的测量、确认数据包的生成以及针对单个连接的拥塞窗口和速率的执行都在靠近数据传输线路的Falcon硬件中实现。这种分离使得CC能通过快速的信号测量和速率执行对网络拥塞做出更迅速的响应。

● RUEFalcon协议其他部分的分离允许对CC进行调整和迭代,而不影响数据包传输子层的主要数据路径。

接下来,我们将介绍Swift拥塞控制算法和保护性负载平衡(PLB)。Swift设计在以下论文中有详细描述——Swift: Delay is Simple and Effective for Congestion Control in the Datacenter》。PLB的设计则在另一篇论文中阐述——PLB: Congestion Signals are Simple and Effective for Network Load Balancing

Swift

Swift Google发表在2020sigcomm上的一篇论文中,用于datacenter的一种基于delay的拥塞控制算法。丢包即拥塞和基于delay判断网络拥塞一直是两条传统的研究路线,所以swift使用delay作为网络拥塞判断依据并不是一个新颖的做法,因为timely也是基于delayrtt)实现的,但是swift提出了一个很重要的观点:将网络拥塞拆分为endpoint congestion(主机侧拥塞控制)和fabric congestion(网络侧拥塞控制),分别计算 ecwndfcwnd来控制发送速率,传统的工作的研究点都集中在fabric congestion,而swift指出,endpoint congestion也是不可忽略的

   如下图所示,SwiftPony Express中实现,Pony Express是一个提供自定义可靠传输的网络堆栈,在Snap 中实例化。它使用NIC和软件时间戳进行准确的RTT测量。它使用Pony Express进行CPU高效运行和实现低延迟,并且适合于诸如分组调整等特性。下图显示了SwiftPony Express中的位置。Pony Express提供了命令和完成队列API:应用程序向Pony Express提交命令,也称为“Ops”,并接收完成。Ops映射到网络流,而Swift管理每个流的传输速率

下面分四个小节介绍,分别是rtt的组成,延迟的测量,fabric congestionendpoint congestion的意义,以及cwnd的调整。

rtt的组成

下图显示了一个rtt的组成,从本地发送一个数据包到收到对端响应的ack数据包:

l  local nic tx delay  NIC 准备好发送数据包时,主机将数据包交给 NIC,因此此延迟可以忽略不计。

l  forward fabric delay 数据包在源和目的地之间的交换机上的序列化、传播和排队延迟的总和。 它还包括 NIC 序列化延迟。

l  remote nic rx delay 数据包进入远程网NIC,到被远程协议栈处理的时延。

l  remote processing delay 远程协议栈处理时延,不包括在远程网卡收队列和发队列中的时延。

l  remote nic tx delay 响应的数据包被远程协议栈交给网卡的发送队列,到数据包从网卡的发送队列被发出去的时延。

l  reverse fabric delay ACK数据包在反向路径上花费的时间。 值得注意的是,正向和反向路径可能不是对称的。

l  local nic rx delay ACK包进入NIC后,在被网络协议栈处理之前所花费的时间。

延迟测量

因为swift使用延迟来判断网络是否拥塞,所以需要精确测量延迟。下图中的红色为软件时间戳,蓝色为硬件时间戳。

t1是数据包被发送的时间,由协议栈记录。

t2是数据包到达对端网卡的时间,由网卡记录。

t3是数据包被远程协议栈处理的时间,t3?t2就是数据包在远程网卡收包队列中的排队时延。

t4ACK数据包被协议栈发出去的时间,t4?t3远程协议栈处理数据包花费的时间。

t5是网卡收到响应ack数据包的时间。

t6是协议栈处理ACK数据包的时间,t6?t1就是rtt

注意:和timely一样,Swift没有考虑发送方向从协议栈到网卡的时延。

区分fabric congestionendpoint congestion,那么自然就会引出fabric delayendpoint delayfabric target delayendpoint target delay这两组概念。因为swift要根据fabric delayfabric target delay来判断是否发生了fabric congestion,以及根据endpoint delayendpoint tartet delay来判断是否发生了endpoint congestion

什么是fabric delay?这里先说endpoint delayendpoint delay等于local nic rx delay(t6-t5)remote queuing(t4-t2)两者的和,那就知道了fabric delay等于rtt-endpoint delay

由于t6是{BANNED}最佳终计算RTT时协议制可以随时获取的,所以如果实现一个协议中,报文需要能够存放4timestamp,即t1t2t4t5

拥塞窗口调整

Swift 的核心算法是一个简单的AIMD(加法增加,乘法减小)控制器,基于测量到的延迟是否超过目标延迟。我们发现简单性是一种优点,因为 TIMELY 演变成 Swift 并删除了一些复杂性,例如,使用 RTT 与目标延迟之间的差异而不是 RTT 梯度。

Swift根据收到ack时得到的delay,然后和target delay比较计算出合适的cwnd。如果delay小于target delay,则cwnd以数据包为单位)增加ai/cwndai=附加增量),使得在一个 RTT上的累积增加等于ai。 否则,cwnd乘法减小,减小的粒度取决于delaytarget delay的距离。乘法减少被限制为每个RTT一次,因此 Swift 在一个RTT内不会多次对同一个拥塞事件做出反应。我们通过检查{BANNED}最佳后一次cwnd减少的时间来做到这一点。cwnd的初始值在我们的设置中几乎没有影响,算法的细节见下图:

其中关键是7-11行的加法增加部分,为什么要区分cwnd大于1的情况,并且还要ai/cwnd。首先这几个的{BANNED}最佳终目的都是为了确保一个 RTT中,cwnd的累积增加等于ai。而一个RTT发送端是可以发送cwndpacket的,因此是需要收到cwnd个报文的ack的(包括聚合ack)。如果假设ai1,即控制每个RTTcwnd增加1,因此在cwnd大于1时需要ai/cwnd。而cwnd小于1Swift的一个创新,因为正常cwnd{BANNED}最佳小就是1Swift允许cwnd小于1主要是为了解决incast场景。

在部署过程中,我们遇到了依赖于极大规模incast的应用程序,成千上万的流量同时流向单个主机。在这种情况下,当流量数量超过路径BDP时,即使是单个数据包的拥塞窗口(cwnd=1)也过高,无法防止过载。为了处理这种情况,我们扩展了Swift,允许拥塞窗口下降到低于一个数据包,{BANNED}最佳低到0.001个数据包。这种情况需要特殊处理算法1中的增量更新(第7-11行)。为了实现分数拥塞窗口,我们将其转换为数据包之间的RTT/cwnd延迟,发送方使用该延迟将数据包步进式地发送到网络中。例如,cwnd0.5的结果是在2 × RTT的延迟后发送一个数据包。

12-14的乘法减,前者(delay-target_delay)/delay主要是希望延时比target_delay越大,则乘法的因子就越小,以便cwnd更快的减小;而又不希望cwnd减小的太小,所以有(1-max_mdf)约束。至于算法中的参数(aimax_mdf等)都是根据实际环境评估出的。其中can_decrease是用于判断是否处于一个RTT中。

当然Swift也考虑了重传超时和快恢复的处理,具体如下,这里不再展开。

target_delay(目标延时)怎么来?

到目前为止,我们已经描述了具有固定目标延迟的Swift。接下来,我们描述如何根据路径较长或负载较重的延迟来缩放目标基础设施延迟(target_delay)(以下简称为目标延迟)。目标延迟封装了基础设施延迟的固定和可变部分,如下图所示。目标延迟的基础部分包括对于小型数量流量的单跳网络所发生的延迟:传播延迟、NIC和交换机中的序列化延迟(这取决于链路速度)、少量流量的排队延迟、软件和硬件时间戳的测量误差,以及网络中的任何未计算延迟,例如由于QoS调度引起的延迟。在此基础之上,我们根据拓扑和负载对目标延迟进行缩放。

fabric congestion vs endpoint congestion

传统基于delay研究拥塞控制都没有区分fabric congestionendpoint congestionswift的作者认为区分这两者是有必要的。为什么很有必要呢?作者在paper没有详细讨论,只是在多处提及很重要,其中一处原文解释如下:

Many congestion control designs focus on the fabric as the network, and ignore host issues. We learned over time that host issues are important and need a different congestion response.

通过查阅sigcomm‘2015 timely,找到了一些解释。在广域网,rtt一般都是亚秒级别,主要延迟是在链路上的传播延迟,而host内的处理延迟可以相对忽略不计。但是在数据中心不一样,一般链路上的单向传播延迟可能也就十几个微秒,所以这时候host内的处理延迟相对而言就不小了,必须要重视。另外应用类型也会造成不同的拥塞,例如IOPS-intensive应用更容易造成endpoint congestionthroughput-intensive应用更容易造成fabric congestion

但我认为这里仅仅解释了为什么要考虑host延时,而没有解释为什么要单独考虑host延时。因为类似timely测量整体RTT中也包含了host的延时。为什么要将host延时和fabric延时分开考虑,使用两个cwnd,我认为是为了更加精准敏捷的控制。考虑一下如果host侧出现了拥塞,而fabric非常空闲,如果分开考虑此时就可以精准感知到hostcwnd减少而降速,而如果统一考虑整体cwnd,势必降低的会比较慢。

Swift使用了两个拥塞窗口,fcwndecwnd,分别对应fabric congestionendpoint congestionfcwndecwnd的计算都采用前面图中的算法,区别是:

There is a slight difference in that we use ExponentiallyWeighted Moving Average (EWMA) filtering for the endpoint delay,given that endpoint delays are more noisy in our experience.

paper原文只说了fabric target delay的值如何评估,endpoint target delay的值如何给成了悬念。{BANNED}最佳后实际用的拥塞窗口为min(fcwnd,ecwnd)

再看流量控制和拥塞控制

如果注意的话,可以看到Swift这里min(fcwnd,ecwnd)TCP的“发送窗口的上限值 = Min [rwnd, cwnd]”有点类似?那是不是可以理解为这里单独考虑的ecwnd就是流量控制中的rwnnd。我认为应该这么理解:正如前文所述,流量控制是端到端的控制,当目前滑动窗口仅仅考虑了接受端buffer的因素,而其他因素没有考虑,这里的ecwnd正是滑动窗口的一个补充。考虑一下接受端buffer充足,但是由于流量过大导致CPU资源紧张,处理的不够及时,这样就能通过ecwnd反馈在Swift的拥塞控制上。从另一方面,如果流量控制考虑了per connectioncpu credit,以及其他因素(如pcie带宽,内存带宽等),那么确实不需要这里Swiftecwnd控制了。但是实际情况我们很难向buffer一样,约束一个connection的各种资源,如CPU creditpcie带宽,内存带宽等。因此,这里的端侧拥塞控制可以理解为滑动窗口流量控制的补充。

基于ECN/Rate vs基于window

谷歌的timely(sigcomm15)rate-based mechanism,而Swift则变成window-basedwindow-based 还是rate-based的速率调控也是一直在讨论的问题。那两者有什么差异呢?

基于ECN/Rate的拥塞控制

ECNExplicit Congestion Notification)是一种网络拥塞通知机制,它允许网络设备在检测到即将发生拥塞时,通过标记网络包来通知发送端和接收端。在RDMA中,当ECN标记被检测到时,发送端会根据信号降低其发送速率,从而减轻网络拥塞。

速率控制(Rate Control):这种方式下,端点会根据网络的拥塞信号来动态调整发送速率。如果网络中出现拥塞迹象(例如,通过ECN标记),发送端会降低其发送速率以减轻拥塞;如果网络拥塞情况缓解,发送速率可以逐渐增加。

优点:可以更精细地控制发送速度,对网络变化的响应更为直接和快速。

缺点:要求网络设备和协议栈支持ECN标记和处理逻辑,可能需要更复杂的算法来确定适当的发送速率(当然timely这种基于RTT的不需要)。

基于窗口的拥塞控制

窗口基拥塞控制,如TCP中使用的TCP拥塞控制算法,依赖于未确认数据的数量(即拥塞窗口)来决定可以发送多少数据。发送端根据网络的传输确认来调整拥塞窗口的大小。

窗口调整(Window Adjustment):发送端根据确认回来的数据包来增加或减少拥塞窗口的大小。如果数据包成功传送并确认,窗口就增大;如果发生丢包,窗口则缩小。

优点:是已经广泛实施并经过测试的成熟技术,特别是在TCP协议中。

缺点:响应速度可能比基于ECN/Rate的方法慢,因为窗口调整依赖于数据包的往返时间(RTT)。

我个人觉得随着DCN链路速度的增加window-based的调节(或者rate-based + window-based)可能性能更好, 因为window-based调节能更好地控制inflight数据量,当拥塞发生时不会因端的拥塞控制还未来得及反应而造成拥塞加剧。HPCC SIGCOMM 2019)论文里包含了这个观点,它论文里给rate-based 的拥塞控制加了window限制后性能提升不少。

PLB

现代网络通过并行使用许多链路来扩展容量,通常采用Clos拓扑结构。这种设计导致从源点到每个目的节点都有许多条路径。在这种设置中,有效的机制来分散可用网络路径上的负载对应用程序性能和网络效率至关重要。当前广泛采用的ECMP不能产生平衡的负载(一方面有大象流,另一方面可能hash极化),并可能加剧拥塞热点问题。Falcon采用了PLB方案进行处理。PLB使用IPv6 Flow Label区分路径和连接,并且通过一定测量保证大流量和小流量都能够平滑切换。

PLB 是一种主机设计,它将负载均衡与 IPv6 流量的传输集成在一起。它与 ECMP/WCMP 路由互补,只不过流量哈希扩展到包括 Flow Label 以及通常的 4-元组。PLB 设计有两个部分:拥塞检测和重新路径

发送主机通过传输检测到连接正在经历拥塞。然后,它通过为后续传出的数据包分配一个新的、随机生成的 Flow Label 来重新路径连接。PLB 使用相应传输中的现有拥塞信号进行拥塞控制,不需要任何额外的拥塞工具。目前google已经开发了 PLB-TCP,并与 BBRv2 拥塞控制一起使用,整个 PLB-TCP 实现大约是 Linux 内核 TCP 堆栈中的 50 行代码。

检测拥塞

PLB-TCP 发送方使用一个简单的 DCTCP 类似启发式来检测连接是否拥塞。伪代码显示在算法 1 中,如下图所示:

当交换机队列超过某个阈值时,交换机在数据包上标记 CE。接收方将 CE 标记回传给发送方。这些都是 ECN 的现有 TCP/IP 行为。对于每个收到的 ACK,发送方调用 TCP_CongDetection()。发送方计算每个往返行程中带有 CE 标记的数据包的分数(fraction)。当这个次数大于一个常数 K时,我们称该轮次(一个RTT是一个轮次)为拥塞的。在经历了 M个连续拥塞的轮次之后,我们标记该流为拥塞。

重新路径(Repathing)

Repath会导致乱序,尽管当前linux TCPRACK技术已经{BANNED}最佳小化了由于重新排序引起的错误丢失恢复,但是在GRO场景仍然有问题。乱序会导致GRO过早聚合结束,起不到聚合64k的效果,进而导致CPU的开销过大。

为了避免乱序,PLB采用对一个拥塞(congested)flow进行延时repathing,即发现flow出现拥塞,不立即进行repathing,而是等到flow变的idle在切换。这里idle并不是flow结束,而是flow段时间空闲。因为大部分应用都是多个RPC调用复用一个TCP长连接,RPC调用之间就是flowidle状态。这样flow repathing时没有infly的报文,就不会导致乱序,这种策略有效是因为大多数RPC都是比较轻量,可以快速结束的。但面临一些大的RPC调用导致的heavy flow时,我们强制flowN个连续的congested rounds后进行repath。具体策略参考算法2,这样{BANNED}最佳坏情况也是每N RTT才会有一次切换导致乱序。

通过这两种策略结合,可以将轻负载的流(有idleRPC流)切换到不合重负载流冲突的路径,从而降低轻负载RPC的长尾延时。

链路故障

为了应对链路故障情况,PLB 还会在发生超时重传(RTO)时进行repathing,所以虽然 ECN 是检测拥塞的常见情况,但发现 RTO 超时也是repathing的关键部分。

Pony ExpressPLB拥塞检测

Google内部,使用名为Pony Express的传输协议以及TCPPony Express在用户空间运行,直接访问NIC以实现低延迟传输。PLB-Pony Express的实现大约也是50行代码,并与基于Swift的延迟拥塞控制一起使用。PLB-Pony ExpressPLB-TCP的唯一区别在于它如何检测拥塞,如算法1图。对于收到的每个ACK,发送方调用Swift_CongDetection()。与ECN标记不同,Pony Express中的Swift拥塞控制使用NIC时间戳来测量网络路径和排队延迟,并旨在保持RTT低于目标值(target delay)。对于每个往返行程(round-trip),PLB计算超过目标延迟????????????????????_????????????????????????_????????????????????RTT测量值的比例。目标延迟是基于预期路径传播延迟和排队目标的Swift配置参数。与TCP一样,当有M个连续往返行程中至少有K比例的高RTT时,我们就认为检测到拥塞。Pony Express不使用TSOGRO,因为它绕过了内核。然而,{BANNED}最佳小化重新排序仍然很重要,以避免误报丢失恢复和拥塞反应。为此,我们使用算法2中的相同重新路径逻辑。

算法动态

这部分描述了PLB如何与拥塞控制交互,以及它如何实现全网络范围内的负载均衡。

与拥塞控制的交互

路径负载均衡的一个核心问题是:当发现有路径出现了拥塞(如RTT增加),此时是该调用负载均衡算法切换路径呢,还是该调用拥塞控制进行降速?

针对此问题,PLB在时间维度上进行分离,让PLB和拥塞控制可以同时操作,而不会有不利的交互。当一个流经历拥塞时,PLB会等待几个往返行程(由算法2中的MN配置)才重新路径(repath)。这给了拥塞控制响应瞬态问题的时间。如果PLB立即repath,那么拥塞控制就会发现每个往返行程可能测量到不同的路径,进而干扰拥塞控制的算法执行。因此,PLB更倾向于在空闲后repath,此时拥塞状态可能已过时,而且需要探测以获取新状态。通过这种方式,PLB不需要修改底层拥塞控制模块。

另一方面,我们不希望长时间承受严重的拥塞(即算法1中的大NK)。这可能会降低性能,因为拥塞控制可能会减慢流速,而不是PLB寻找更多可用带宽。我们进行了参数测试,并经验性地发现???? = 0.5, ???? = 3 ???? = 12时在一系列工作负载中取得了良好的平衡。我们发现没有特别的敏感性,只是需要不同的MN来同时更快地重新路径,以加速小型RPCs,并且更慢地避免干扰大型RPCs。对于其他工作负载模式,可能需要不同的值。例如,如果交换机ECN标记阈值较高,则可能需要较低的K值。如果相应的拥塞控制需要更多的往返行程来响应和稳定拥塞,则需要更高的M

     在链路故障时,故障链路上的流将经历数个RTO后,PLBrepath它们,这是符合预期的。但是在repath后,如果当前的workload大于新路径的容量,很可能导致再次拥塞,而这种情况不应该再次触发repath,因为很可能重新repath到之前的故障链路上。为此,PLB通过在算法1中的PLBDetection()模块暂停PLB,在RTO超时后抑制repath。而抑制时间是根据预期的链路恢复时间设置的其值的一到两倍。

PLB 在生产中的部署

我们在所有Google数据中心服务器上为TCPPony Express部署了PLB。我们的生产网络由遍布全球的许多大型数据中心组成。它们支持从存储工作负载到像内存中键值存储这样的低延迟工作负载等多种应用。IPv6的使用非常普遍,所有交换机除了四元组外,还使用Flow Label进行ECMP/WCMP哈希。网络控制器或流量工程(例如ECMPWCMP权重、路由)没有其他更改。PLB涵盖了几乎所有内部应用程序在数据中心内部和跨B4骨干网的传输。TCP使用BBRv2拥塞控制,而Pony Express使用Swift拥塞控制。这两种协议共享相同的底层网络,因此它们的PLB实现会相互交互。PLB允许快速部署,因为它可以单方面在发送方逐流启用,甚至不需要重启流。因此,PLB可以快速且逐步部署,而不会干扰各个数据中心中的应用程序。它也可以有选择地禁用用于故障排除,尽管我们没有这个需求。 

总结

    以上就是Falcon的流量控制方法,其实总体可以分为三部分:流量控制,拥塞控制,负载均衡。其中流量控制是滑动窗口,拥塞控制通过Swift,负载均衡通过PLB。正常我们设计一个高性能传输协议通常也需要考虑这几方面,当然负载均衡是可选的,但在AI大带宽场景通常是比较重要的。

阅读(141) | 评论(0) | 转发(0) |
给主人留下些什么吧!~~