微服务调用链追踪原理及最佳实践

引言:调用链追踪是微服务架构需要解决的一个关键问题,本篇文章对调用链追踪的实现原理进行了说明,并对基于OpenTracing的最佳实现方式进行整体介绍,便于读者快速掌握实现方案

       微服务架构的本质,是将复杂庞大的大型项目按照业务功能领域进行拆分,形成多个小型可复用的独立服务单元(也可以叫服务组件,甚至可以视为一个独立的小系统),来完成原来项目所需实现的功能;同时因为各个服务单元的独立性,更有利于进行功能复用和横向扩展(例如增加服务),能有效减少重复建设的浪费,以及支撑高并发的性能需求。

图片

       不过采用微服务架构也会带来成新的问题,其中最显著的问题是接口服务管理的难度呈指数上升。原来一个系统里不同功能模块之间的组合使用,只需要调用同一个系统内部的模块函数,跨系统调用才会用到网络接口服务,因此接口数量少并且易于管理。而微服务架构的不同功能之间的调用都需要通过网络接口方式处理,接口数量大幅增加;并且由于其复用性,接口使用范围的边界趋于模糊,让接口管理的难度大大增加。微服务的服务管理问题有多种情况,不同情况有不同的解决方案,例如服务发现问题可通过注册中心机制解决、横向扩展问题可通过k8s这类容器编排方案解决,本篇文章将重点介绍服务调用追踪问题的解决方案。

图片

        基于微服务架构,由于服务拆分粒度比较细,并且服务复用范围增大,不太可能再通过人工登记的方式进行接口调用情况的管理,因此对于每个请求的调用情况追踪将成为不可忽视的问题。这里解释一下我们为什么要追踪请求的调用情况,主要有几个作用:

  • 当请求发生失败时,可以快速排查到在哪个微服务出现问题,并获取到失败的信息
  • 当请求响应时间超过预期时,可以分析调用链路上的每个服务处理时长,从而找到性能瓶颈并进行优化
  • 完整呈现一笔交易的处理过程,便于分析和优化;同时也有利于评估某个微服务升级时的影响范围

调用链追踪原理

       微服务的调用链追踪原理其实也满简单的,跟人工追踪的思考方向基本一样,就是通过服务所记录的日志信息进行匹配和分析,从而找到一个请求的所有相关调用记录而形成调用链条。不过为了能把不同的调用日志关联起来,需要有一些特殊的信息辅助分析:

  • 调用链的唯一标识:一个请求的所有调用日志信息都要记录一个相同的标识,这样才能把记录关联起来,这个唯一标识可以称为追踪标识(trace_id),由第一个发起的请求生成,并传递到后续的每次调用进行记录
  • 每次调用的唯一标识:服务端在接收到请求后,要生成一个唯一标识标记该次调用,一方面通过该标识可以在调用链中区分不同的调用(有可能同一个接口被调用多次的情况),另外一方面该标识也要传递给本次服务过程中需要执行的子调用(下一个服务),并作为子调用信息的父调用标识,从而形成直接相邻调用之间的上下级关联关系。我们可以把每次调用称为一个时间跨度(Span),调用的唯一标识称为时间跨度标识(span_id),由服务端生成,并传递到下一个调用使用
  • 时间信息:服务端必须在接收到请求时记录开始时间(start timestamp),并在结束调用时记录结束时间(finish timestamp),这样才能对不同的调用顺序进行排序,同时分析当次服务调用的耗时。

       有了上面的几个特殊的信息,我们就可以把一次请求的整个调用链条关联起来:通过trace_id找到所有调用日志,通过span_id和每次调用的父span_id形成调用关系,通过timestamp形成调用的先后顺序,从而形成一个有向无环图(DAG图),这就是我们所需要追踪的调用链。

       用图示的方式可以更清晰理解前面所需的流程:

图片

      需要注意的是,像trace_id,span_id这些信息需要从一个调用传递到下一个调用中,接口协议的设计应支持这些附加信息的传递,例如假设我们使用的通讯协议是http,则可以把这些信息放置在http协议头(Http Header)中送到请求服务中。

        基于以上几个特殊信息,就可以形成一个调用链的关系图并用于分析。而在正常应用的情况下,肯定还会在日志中记录一些辅助分析的信息,例如服务模块或组件名,业务标识等等,这些信息可以根据实际需要设计和记录。

       由于每个服务都记录了这些跟调用链相关的日志信息,我们就可以通过trace_id查找日志把所有调用日志集合起来分析,但这样处理起来还是比较麻烦,效率也不高。所以我们还可以更进一步,构建调用链管理系统,汇总所有的调用链日志信息,并基于日志自动形成一些便于分析问题的统计图表,比如查找耗时交易统计、链路各节点耗时图展示、服务间的调用关系分析图等。

图片

最佳实践-OpenTracing

       基于微服务架构,不同的微服务很有可能是通过不同的开发语言实现(例如java、C++、go、Python、NodeJS等等),可有可能存在多种通讯协议或接口协议共存的情况(RPC、Restful等),因此有必要对调用链追踪所需的日志记录数据结构和操作方式指定一套统一的标准,这样才能方便于数据的汇总、分析、处理。

       目前对于调用链追踪事实标准是OpenTracing,这套标准定义了追踪日志数据的标准数据模型,以及操作数据的一套API规范(称为OpenTracing API),同时提供了不同开发语言的OpenTracing API抽象类的实现。OpenTracing只是定义了规范,具体落地可以由各项目自行实现,现在已经有多个调用链管理开源项目的实现可供选择,例如uber的jaeger、twitter的zipkin、华为的skywalking等,我们只需要按照OpenTracing API的规范开发,就可以自由选择支持OpenTracing的调用链管理开源项目进行日志数据的存储和落地,无需从零开始构建。

Metrics、Tracing和Logging的定位

       看完上面的原理,大家知道链路追踪(Tracing)的原理是基于日志记录的,另外监控指标(Metrics)的部分实现也是通过监控系统日志来进行告警,此外应用系统自身也需要进行日志记录(Logging)来支持一些开发运维工作。Metrics、Tracing和Logging都和日志记录相关,那这三者的定位有什么不一样呢?明确了三者的定位,能对OpenTracing有更深入的理解。

图片

       Metrics 监控指标的主要特征是可聚合,在一段时间内的信息可以形成单个逻辑指标、计数器或直方图。例如:队列的当前深度的变化信息可以设计为一个监控模型;传入的http请求的数量可以建模为一个计数器,其更新聚合为简单的加法;观察到的请求持续时间可以建模为一种直方图

       Logging 日志的特征是离散事件信息的记录。例如:应用程序调试或错误消息的记录; 审计所需追踪事件的信息记录;或者从服务调用中提取特定于请求的数据信息的记录等

       Tracing 调用链追踪的特征是请求范围内的信息–任何可以绑定到系统中单个事务对象的生命周期的数据或元数据。例如:远程服务的出站rpc的持续时间;发送到数据库的实际sql查询的文本;或入站http请求的相关ID等。

       在这三个领域中的信息类型并不是严格区分的,会有交汇的情况存在,例如Tracing中的请求执行事件的信息,同样也需要作为日志事件记录下来。而从资源消耗的角度来看,Logging因为事件范围的广度原因,所需消耗的资源最多;Tracing关注的是请求范围,因此所需消耗的资源次之;而Metrics只关注跟状态正常或异常的相关指标数据,往往需要最少的资源来管理。

       这里划出一个重点:Tracing关注的是请求范围内的信息

OpenTracing数据模型

        从Metrics、Tracing和Logging的定位可以看出来,Tracing本身记录的也是日志,但重点是将属于请求范围的特定信息记录下来支持调用链追踪和分析。如果要自己实现调用链追踪的功能,直接根据前面的原理设计并实现即可,不过自行设计要考虑的问题还有不少,例如不同微服务所记录的信息要形成一套标准,否则难以汇总处理;不同微服务可能基于不同的开发语言开发,需要基于不同的开发语言实现相关功能。但更好的方式是遵循OpenTracing的标准规范进行实现,OpenTracing中对调用链数据制定了一套标准数据模型,遵循该数据模型则可直接将支持OpenTracing的调用链管理系统集成进来。

       OpenTracing数据模型中最核心的对象是Span(前面原理提到的时间跨度),每个Span就是一次服务调用信息,而一条Trace(调用链)可以被认为是一个由多个Span组成的有向无环图(DAG图)。

       在单个Trace中,Span间的关系如下图所示:

      [Span A] ←←←(the root span)
          |
    +------+------+
    |             |
[Span B]     [Span C] ←←←(Span C 是 Span A 的孩子节点, ChildOf)
    |             |
[Span D]     +---+-------+
              |           |
          [Span E]   [Span F] >>> [Span G] >>> [Span H]
                                      ↑
                                      ↑
                        (Span G 在 Span F 后被调用, FollowsFrom)

       如上图所示,一个Span可以看成某次接口调用在服务方的处理记录,服务端收到请求时可以通过 tracer.start_span 方法生成本次服务的Span(本质上是记录操作名称 – operation name、起始时间 – start timestamp,生成Span_id,获取或生成trace_id),并在服务结束时(返回请求)通过 span.finish 方法结束span并进行记录(记录结束时间 – finish timestamp)。

       目前OpenTracing支持的Span关系主要有两种(未来可能会继续扩展):ChildOf关系(父子关系)代表父Span发起子Span后,需要等待子Span完成以后父Span才能完成(同步接口调用);FollowsFrom关系(兄弟关系)代表上一个Span触发下一个Span执行后,无需等待下一个Span完成(异步接口调用)。

       下面是一个Span的数据结构示例:

Span {
operation name: db_query,
start timestamp: 2021-01-01 12:01:01.132,
finish timestamp: 2021-01-01 12:01:05.483,
Tags: {
  db.instance:"customers"
  db.statement:"SELECT * FROM mytable WHERE foo='bar'"
  peer.address:"mysql://127.0.0.1:3306/customers"
  ...
},
Logs: {
  timestamp: {
    message: "Can't connect to mysql server on '127.0.0.1'(10061)",
  },
  ...
},
SpanContext: {
  trace_id: "abc123",
  span_id:"xyz789",
  Baggage Items: {
    special_id:"vsid1738",
    ...
  }
}
}

   该数据模型的说明如下:

  • Span:是一次调用的处理记录,整个记录会提交到调用链管理平台存储下来
    • Span有3个固定信息:操作名-operation name、开始时间-start timestamp、结束时间-finish timestamp
    • Span中还可以携带Tags信息集、Logs信息集、SpanContext上下文对象
  • Tags:是一组key-value形式的键值对,用于应用自定义Span所要记录的附加信息,例如可以针对db_query的操作,定义span中需要记录的数据库实例、执行sql等
    • Tags完全由应用自定义添加,可以存在也可以不存在
    • Tags记录的信息可作用于 整个Span,也就是说,它会覆盖Span的整个事件周期,所以无需指定特别的时间戳
    • 官方对Tags提供了一套命名标准,具体见《附录1 – Span Tag命名标准》,应尽量遵循该标准使用
  • Logs: 是一组日志记录,每个记录都有一个特定的时间戳(这个时间戳必须在Span的开始时间和结束时间之间),并一个具有多个field的key-value形式的键值对;用于记录有助于分析问题的日志信息,类似于应用写日志信息;
    • Logs由应用按需要进行写入,可以存在也可以不存在;
    • 官方对Logs的field提供了一套标准,具体见《附录2 – Span log field命名标准》,应尽量遵循该标准使用
  • SpanContext: 是Span的上下文信息,包含需要传递到其他子孙Span的信息,SpanContext包含的固定信息包括:
    • trace_id:调用链的唯一标识,大部分实现是根Span(root_span)的span_id
    • span_id:  当前Span的唯一标识
    • Baggage Items: 一组key-value形式的键值对,是要在Span中向子孙span传递的信息,可以由应用自定义要传递的信息;注意这些信息会传递给所有子孙节点,会造成网络、内存的开销,需要谨慎使用

OpenTracing API

       OpenTracing除了定义了标准数据模型,还定义了一套指引具体功能实现的抽象接口规范,称为OpenTracing API。OpenTracing API针对不同开发语言(如Java、Go、Python、JavaScript、C++、C#等)分别提供了抽象接口类定义,具体的实现逻辑由实际的项目自行实现,这样我们在应用时只要使用OpenTracing API标准接口,就可以自行切换要使用的支持OpenTracing的调用链管理平台。

       这里我们不去说明每个API的定义(具体定义可自行参考官方文档),而是把一些关键的API和概念,以及主要流程进行介绍,让大家对OpenTracing API的使用方法有一个大体的了解。

Tracer对象

       Tracer是OpenTracing API的核心接口对象,提供了标准的Span对象创建,以及用于跨进程边界传递SpanContext上下文信息所需的Inject(注入)和Extract(提取)方法。OpenTracing API提供了一个默认的Tracer实现(空功能),以保证未集成真正实现时程序仍能正常运行;如果要集成某个具体的调用链管理平台,直接将Tracer替换为平台所提供的Tracer即可,例如直接使用jaeger提供的客户端Tracer。

  • StartSpan:Tracer提供的创建Span对象的方法,所实现的功能是创建一个状态为started的Span对象。该方法的主要入参包括:注:对于一个线程,有可能会创建多个Span(内部函数调用的情况,或需要追踪多个代码块的执行情况),但同一个时间只能有一个Span为active状态。
    • OperationName:Span对象的操作名称
    • ChildOf:可选,指定当前Span的父SpanContext,以此形成ChildOf的关系
    • references:可选,通过列表方式指定多个Span之间的关系(支持FollowForm关系),例如可以设置为 follows_from(SpanContext) 来指定Span之间关系为异步调用
  • Inject:Tracer提供的注入方法,所实现的功能是将SpanContext上下文信息序列化并放入接口协议中,从而可通过接口传输SpanContext数据。注入方法的入参包括:
    • SpanContext:需要通过接口传递的上下文对象
    • format:格式化描述,是一个字符串,以此通知Tracer应该用什么方式对SpanContext进行处理;例如设置为opentracing.Format.HTTP_HEADERS,说明按http协议头方式进行格式化
    • carrier:注入信息的载体对象,Tracer按format完成对象处理后,放入carrier所指定的载体对象中,供后续接口调用处理;例如设置为http请求对象的 request.headers
  • Extract:Tracer提供的提取方法,所实现的功能是从接口送入的carrier载体对象中,按照format的格式还原为SpanContext对象。提取方法的入参与注入方法相对应,同样包括format和carrier两个参数。注意:OpenTracing要求所有的Tracer实现都必须支持下面的format,大家可以放心使用:
    • Text Map: 基于字符串:字符串的map,对于key和value不约束字符集。
    • HTTP Headers: 适合作为HTTP头信息的,基于字符串:字符串的map。(RFC 7230.在工程实践中,如何处理HTTP头具有多样性,强烈建议tracer的使用者谨慎使用HTTP头的键值空间和转义符)
    • Binary: 一个简单的二进制大对象,记录SpanContext的信息。

Span对象

       Span是OpenTracing API的主要操作对象,除记录Span数据模型的相关信息以外,还提供了一系列接口对这些数据进行操作。

  • Finish:在调用结束时必须通过 Span.Finish 方法关闭Span对象(具体实现会将Span信息送到调用链管理平台存储)
  • SetTag:向Span对象添加Tags键值对信息,具体添加的信息由应用自行确定,例如 Span.SetTag(key, value)
  • Log:向Span对象添加Logs事件日志信息,对事件会自动添加当前时间作为时间戳,例如 Span.Log({‘event’: ‘string-format’, ‘value’: hello_str})
  • SetBaggageItem: 向SpanContext对象添加Baggage Items信息,具体添加的信息由应用自行确定

       OpenTracing Api还定义了一些其他对象,例如 Scope/ScopeManager 对象用于管理Span的作用范围,以及获取当前active状态的Span对象,不再具体说明。

标准使用流程

       我们用Python代码来简单说明OpenTracing API的标准使用流程:

1、第一个请求发起的服务端:start_trace_request调用remote_request函数发起远程访问

def start_trace_request():
  span = tracer.start_span('start-trace') # 启动Span
  ... # 具体的业务逻辑
  span.set_tag('test_tag', 'test_tag_value') # 设置tag
  span.log_kv({'event': 'string-format', 'value': 'span log event'}) # 记录event日志
  ...
  remote_request(root_span=span) # 调用远程请求处理函数
  ...
  span.finish() # 关闭Span

def remote_request(root_span):
  span = tracer.start_span('remote-request', child_of=root_span) # 指定当前Span与上一个Span为父子关系
  ... # 具体的业务逻辑代码
  # 注入SpanContext
  span.set_tag(tags.HTTP_METHOD, 'GET')
  span.set_tag(tags.HTTP_URL, url)
  span.set_tag(tags.SPAN_KIND, tags.SPAN_KIND_RPC_CLIENT)
  headers = {}
  tracer.inject(span, Format.HTTP_HEADERS, headers)
  # 执行调用
  r = requests.get(url, params={param: value}, headers=headers)
  ... # 具体业务逻辑代码
  span.finish() # 关闭当前Span

2、远程服务端处理

@app.route("/test_request")
def test_request():
  span_ctx = tracer.extract(Format.HTTP_HEADERS, request.headers) # 从协议中提取SpanContext
  span_tags = {tags.SPAN_KIND: tags.SPAN_KIND_RPC_SERVER}
  tracer.start_span('test_request', child_of=span_ctx, tags=span_tags)
  ... # 具体的业务逻辑
  span.finish() # 关闭当前Span
     

       从上面的流程可以看出,OpenTracing不只是用在接口调用,实际上也可以用于函数之间的调用处理,具体怎么使用可以根据应用的实际需求。另外利用开发语言的不同特性,可以提供更简便的非浸入性的代码解决方案,例如可以通过注解或修饰符(@)的方式,让使用函数只需要增加注解或修饰符,即可实现OpenTraicing的接入,例如:

@app.route("/test_request")
@trace()
def test_request():
  ... # 具体的业务逻辑

典型场景的Tag设置建议

RPCs(RPC调用)

为RPC调用场景使用下面tag:

  • span.kind: "client""server"在Span开始时,设置此tag是十分重要的,它可能影响内部ID的生成。
  • error: RPC调用是否发生错误
  • peer.address, peer.hostname, peer.ipv4, peer.ipv6, peer.port, peer.service: 可选tag。描述RPC的对端信息。(一般只有在无法获取到这些信息时,才不设置这些值)

Message Bus(消息服务总线)

消息服务是一个异步调用,所以消费端的Span和生产端的Span使用 Follows From 关系。

为消息服务使用下面tag:

  • message_bus.destination
  • span.kind: "producer""consumer". 建议 在span开始时 设置此tag,它可能影响内部ID的生成。
  • peer.address, peer.hostname, peer.ipv4, peer.ipv6, peer.port, peer.service: 可选tag,描述消息服务中broker的地址。(可能在内部无法获取)

Database (client) calls(数据库客户端调用)

为数据库客户端调用场景使用下面tag:

  • db.type, db.instance, db.user, 和 db.statement
  • peer.address, peer.hostname, peer.ipv4, peer.ipv6, peer.port, peer.service: 描述数据库信息的可选tag
  • span.kind: "client"

Captured errors(捕获错误)

OpenTracing中,根据语言的不同,错误可以通过不同的方式来进行描述,有一些field是专门针对错误输出的,其他则不是(例如:eventmessage

如果存在错误对象,它其中包含栈信息和错误信息,log时使用如下的field:

  • event="error"
  • error.object=<error object instance>

对于其他语言,或上述操作不可行时:

  • event="error"
  • message="..."
  • stack="..." (可选)
  • error.kind="..." (可选)

通过此方案,Tracer实现可以在需要时,获取所需的错误信息。

各大厂商Trace产品的对比

       为了便于大家选择Trace产品,下面贴出从网上找到的各家厂商开源项目的对比情况,可以根据自己的实际需要选择:

产品名称厂商开源OpenTracing标准侵入性应用策略时效性决策支持可视化低消耗延展性
jaegeruber开源完全支持部分侵入策略灵活时效性高, UDP协议传输数据(在Uber任意给定的一个Jaeger安装可以很容易地每天处理几十亿spans)决策支持较好,并且底层支持metrics指标报表不丰富,UI比较简单消耗低jaeger比较复杂,使用框架较多,比如:rpc框架采用thrift协议,不支持pb协议之类。后端存储比较复杂。但经过uber大规模使用,延展性好
zipkintwitter开源部分支持侵入性强策略灵活时效性好决策一般(功能单一,监控维度和监控信息不够丰富。没有告警功能)丰富的数据报表系统开销小延展性好
CAT大众点评 吴其敏开源侵入性强策略灵活时效性较好,rpc框架采用tcp传输数据决策好报表丰富,满足各种需求消耗较低 , 国内很多大厂都在使用
Appdashsourcegraph开源完全支持侵入性较弱采样率支持(粒度:不能根据流量采样,只能依赖于请求数量);没有trace开关时效性高决策支持低可视化太弱,无报表分析消耗方面。不支持大规模部署, 因为appdash主要依赖于memory,虽然可以持久化到磁盘,以及内存存储支持hash存储、带有效期的map存储、以及不加限制的内存存储,前者存储量过小、后者单机内存存储无法满足延展性差
skywalking华为 吴晟开源完全支持侵入性很低策略灵活时效性较好由于调用链路的更细化, 但是作者在性能和追踪细粒度之间保持了比较好的平衡。决策好丰富的数据报表消耗较低延展性非常好,水平理论上无限扩展

附录1 – Span Tag命名标准

Span tag 名称类型描述与实例
componentstring生成此Span所相关的软件包,框架,类库或模块。如 "grpc""django""JDBI".
db.instancestring数据库实例名称。以Java为例,如果 jdbc.url="jdbc:mysql://127.0.0.1:3306/customers",实例名为 "customers".
db.statementstring一个针对给定数据库类型的数据库访问语句。例如, 针对数据库类型 db.type="sql",语句可能是 "SELECT * FROM wuser_table"; 针对数据库类型为 db.type="redis",语句可能是 "SET mykey 'WuValue'".
db.typestring数据库类型。对于任何支持SQL的数据库,取值为 "sql". 否则,使用小写的数据类型名称,如 "cassandra""hbase", or "redis".
db.userstring访问数据库的用户名。如 "readonly_user" 或 "reporting_user"
errorbool设置为true,说明整个Span失败。译者注:Span内发生异常不等于error=true,这里由被监控的应用系统决定
http.methodstringSpan相关的HTTP请求方法。例如 "GET""POST"
http.status_codeintegerSpan相关的HTTP返回码。例如 200, 503, 404
http.urlstring被处理的trace片段锁对应的请求URL。例如 "https://domain.net/path/to?resource=here"
message_bus.destinationstring消息投递或交换的地址。例如,在Kafka中,在生产者或消费者两端,可以使用此tag来存储"topic name"
peer.addressstring远程地址。适合在网络调用的客户端使用。存储的内容可能是"ip:port""hostname",域名,甚至是一个JDBC的连接串,如 "mysql://prod-db:3306"
peer.hostnamestring远端主机名。例如 "opentracing.io""internal.dns.name"
peer.ipv4string远端 IPv4 地址,使用 . 分隔。例如 "127.0.0.1"
peer.ipv6string远程 IPv6 地址,使用冒号分隔的元组,每个元素为4位16进制数。例如 "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
peer.portinteger远程端口。如 80
peer.servicestring远程服务名(针对没有被标准化定义的"service")。例如 "elasticsearch""a_custom_microservice""memcache"
sampling.priorityinteger如果大于0,Tracer实现应该尽可能捕捉这个调用链。如果等于0,则表示不需要捕捉此调用链。如不存在,Tracer使用自己默认的采样机制。
span.kindstring基于RPC的调用角色,"client" 或 "server". 基于消息的调用角色,"producer" 或 "consumer"

附录2 – Span log field命名标准

Span log field 名称类型描述和实例
error.kindstring错误类型(仅在event="error"时使用)。如 "Exception""OSError"
error.objectobject如果当前语言支持异常对象(如 Java, Python),则为实际的Throwable/Exception/Error对象实例本身。例如 一个 java.lang.UnsupportedOperationException 实例, 一个python的 exceptions.NameError 实例
eventstringSpan生命周期中,特定时刻的标识。例如,一个互斥锁的获取与释放,或 在Performance.timing 规范中描述的,浏览器页面加载过程中的各个事件。还例如,Zipkin中 "cs""sr""ss", 或 "cr". 或者其他更抽象的 "initialized" 或 "timed out"。出现错误时,设置为 "error"
messagestring简洁的,具有高可读性的一行事件描述。如 "Could not connect to backend""Cache invalidation succeeded"
stackstring针对特定平台的栈信息描述,不强制要求与错误相关。如 "File "example.py", line 7, in <module>ncaller()nFile "example.py", line 5, in callerncallee()nFile "example.py", line 2, in calleenraise Exception("Yikes")n"

发表评论

登录后才能评论
网站客服
网站客服
申请收录 侵权处理
分享本页
返回顶部