性能优化那些事(珍藏贴)

性能优化是个恒久的话题,它伴随着业务的一次次迭代,产品的一步步演进,它陪伴企业一步步走向壮大再走向衰败,是我们面临的不可回避的问题。就如同宇宙的熵增定律,一切都走向混乱走向无序,性能的劣化边随着企业的发展壮大,业务的膨胀,人员的流动,复杂度的提升,一定也最终走向不可收拾的一步。

我们没法像消除吸血鬼一样,性能的优化没有银弹可用,但不代表性能优化没有共性可言,本文针对笔者做过的一些性能优化案例,尝试总结下解决性能问题的常用手段,以及如何持续性地避免过快的熵增。

首先我们把性能优化分为两种情况,第一种是在企业发展阶段的平稳期产生的性能瓶颈,第二种是企业发展的临界点产生的性能瓶颈,知道第二曲线原理的同学们可以尝试对应到第二曲线上去,一种是在曲线内的性能优化,一种是跨越曲线的性能优化。

image
理论源自查尔斯·汉迪《第二曲线:跨越“S型曲线”的二次增长》

比如著名的C10K问题,和10年淘宝架构的演进基本都属于第二种情况,这种情况很难通过业务代码的优化或者简单的架构调整就能解决性能问题,这种情况的性能优化一般在算法&理论的突破或者是架构哲学&语言的调整层面了。我们没法在一条曲线上完成性能的突破,可以看到曲线后期的收益越来越小,我们必须跳跃到一个新的曲线上去,这就是为什么很多大企业会注重架构的演进,第一曲线和第二曲线重合的部分就是企业高层进行重要决策的时机,我们再看淘宝的架构演进很明显是符合第二曲线原理的。

针对这种如同换血般的性能优化,评估的时候需要结合现有流量和指标的分析给出强有理的数学模型,来证实在当前架构模型上是否能承载未来一段时间的业务高速发展,这种预判需要有前瞻性,和对市场有准确的估计。一旦发现数学模型证实架构模型无法承载更多的业务增长,那就需要果断的迁移到第二曲线上去,公司的前瞻性和战略性在这个阶段表现无遗。

我们很多的性能优化接触更多的其实是第一种情况,我们需要在不打破现有架构的情况下,进行性能调优。我们继续在这个场景下进行总结:

环境优化

所谓环境优化就是代码执行环境的优化,就如同你的工作环境影响你工作效率一样,程序的运行环境对性能影响也很大。举个栗子,网卡中断与CPU亲和性,在Linux的网络调优方面,如果你发现网络流量上不去,那么有一个方面需要去查一下:网卡处理网络请求的中断是否被绑定到单个CPU(或者说跟处理其它中断的是同一个CPU)。

这就是个典型的运行环境对性能的影响,你的服务会应为网卡中断的原因导致性能下降的很厉害。当然环境的优化比较吃经验,如果没有经验会比较难定位问题,但一些基础的Linux优化常识还是得必备的,需要学会看各项指标,有足够的敏锐力发现异常的指标,有足够的经验识别异常指标的诱因是什么。

轮子的优化和选择

很多库提供了非常便利的功能,但有些情况下这些便利的功能对性能不是很友好。准确来说很多轮子对开发而言是个黑盒,即使有源码也鲜有人去一行行研究,往往很多性能问题就暴露在简单的一句调用中。先说说简单的,大家都知道的,HashTable和ConcurrentHashMap,都是并发安全的组件,但是性能上差别就大了,明显用ConcurrentHashMap性能就会比HashTable好。

一样的道理,ArrayBlockingQueue是JDK提供的同步堵塞队列,很多场景下会用到这个组件,但是追求极致性能的情况下Disruptor是个更好的选择。对于大量定时任务的调用,Netty的时间轮算法就是更为优秀的选择。你知道JDK8的ConcurrentHashMap用的不好有严重的性能BUG么?可以看看这篇文章的例子:https://zhuanlan.zhihu.com/p/364340936

对于轮子的性能选择可以遵循下面的原则:无锁设计普遍优于有锁的设计,细粒度锁优于粗粒度锁,环形队列的设计普遍优于无边界队列的设计。

万恶的循环
一般烂代码都出现在循环里,比如几百次的REST请求,几千次的SQL请求,手动找起来海底捞针,特别是很深的调用堆栈很难发现,这里需要利用工具,我们单独去说,无论是基于语法树还是字节码的静态检测还是持续性能集成都能一定程度的预防这种情况的发生,经验告诉我这里是优化成果的大头,越复杂的项目这种问题越严重,解决方案是做批处理和小心的使用缓存。这里性能可视化和调用链分析可以帮助你快速定位问题。


锁可以说性能优化的难点,一类锁会牵扯到业务,优化的重心是如何合理的使用锁,有没有行成锁的使用规范,锁的粒度足够细么?有可能的集中管理锁,限制开发人员直接使用锁。那另一类锁一般在中间件那块,属于通用组件,和业务关系不大,但如果瓶颈在中间件那就得着手去优化了,最好的是实现无锁模型。

缓存
缓存是能够解决一些性能问题的,在某些场合是杀手锏的存在,但缓存需要注意的是时效性和生效范围,控制好这2点一般缓存会带来很大的收益。

线程池
线程池过大对性能也是有一定影响的,毕竟JAVA的线程是1:1的内核线程,解决方法是设置合适的线程池大小不要过于庞大,线程上下文切换的开销可是不小的,或者干脆使用阿里的JDK开启全局虚拟线程模式(黑科技)。

同步
同步一般会堵塞线程导致需要大量线程池,异步太难写了,协程JAVA不支持,有条件的用阿里的JDK吧。

慎用Hibernate
为啥单独说Hibernate,可能笔者有条件反射了,一般使用Hibernate的项目多多少少都对其用法有误解,或者完全沉迷于它带来的便利性而忽略了这些便利性带来的性能问题。简单的N+1问题经常在项目上遇到,复杂的级联更新问题导致的性能问题也屡见不鲜,总之大家小心使用Hibernate。

GC
由于频繁FullGC导致的性能问题也是很常见的,这块有点大,可以说个几天几夜了,这里不细说。

还有些奇葩的优化点,比如缓存行失效,一般来说业务涉及不到,都是中间件基础组件才有可能碰到的优化策略。

业务优化,业务优化往往会取得很喜人的成绩,但这是一个取舍的问题,而且涉及到业务,小心谨慎,一般来说在性能优化的专项工作中尽量不去修改业务。

性能优化应该什么时候开始

有些性能问题是随着时间的积累慢慢产生的,比如系统一开始数据量很小的时候,没有什么问题,等到数据积累到一定程度,问题就暴露出来了;有些问题是由于访问量的过大造成的,比如系统平时没问题,一到搞活动时就挂;也有些问题是遗留系统经过太多人去维护修改,导致各种坏代码味道性能问题仿佛到处存在。性能问题就如同一颗定时炸弹,只要数据量访问量一上来,或者各个团队在开发迭代中没有注重性能的意识,早晚会炸。既然迟早会出问题,那我们应该什么时候开始进行性能优化呢?是等出了问题后在进行优化,还是在编码的过程中就尝试避免那些错误的代码模式呢,或者采用一些手段尽可能的避免踩坑呢?

有人会说项目压力大,如果开始过程中要考虑性能问题那么会影响进度。我觉得这是在给自己或者给后人挖坑,我们在一开始设计接口的时候,就应该考虑性能问题,不仅仅要考虑接口的合理性易用性,同时也要考虑接口是否有批量调用的情况。最简单的方法,就是在设计接口的时候就直接设计批量接口,这样这个接口又能支持批量又能支持单个,当然考虑到批量会有额外的工作要做,但总比出了问题到处去填坑强吧,这需要我们有能力识别未来业务上对批量的需求,并不是每个接口都需要支持批量操作。

我们还可以用很多方法来保证代码质量以提高系统性能的,比如:

  • 使用合理的数据结构和算法,比如,同样是列表,LinkedList 就比 ArrayList 的插入性能高很多
  • 多线程环境下合理选择锁的类型和使用场景
  • 编写高效 SQL、合理使用索引和事务来提升数据库性能,使用ORM工具时注意N+1问题,有些看起来很便捷的方法请理解其细节再去使用。
  • 多考虑接口的使用场景,是否有批量的可能,如果有提供批量接口
  • 如果对性能要求很高,是否考虑使用Netty等异步手段

你的脑袋里应该有一大堆这样的手段,在开发过程中,可以尽情发挥。但有一点需要着重强调:不要使用任何你不知道背后原理的优化技巧

这里有个有争议的优化手段:“不使用的对象应手动赋值为 NULL”有利于 GC 更早地回收内存,但在大多数场景下,不使用的局部变量是否设置为 NULL,对 GC 没有任何影响,毕竟方法执行完毕,栈帧就从操作数栈中弹出,方法中的局部变量就没了,是否设置为 NULL 也就没有任何影响。但是如果你是开发中间件的,或者某个复杂算法,那么手动设置为NULL确实在某些情况会有利于GC,比如临时变量占用了大量内存当遇到『安全点』时如果不主动设置为NULL在JDK运行在『解释』阶段时确实会导致GC回收的比较慢。你可以在J.U.C包中经常看到xxx=null,注释都是help gc,但是我们经常写业务代码的其实没必要这么做。

在系统开发完成以后,可以根据一些预期的指标 ( 比如,并发数 ) 和硬件资源来对系统进行测试,通过各种分析统计工具来判断各项指标是否在预期范围内。等到系统上线后,还要根据日志、监控系统来观测系统性能,一旦发现问题,就要及时分析并修复。这里可以使用的软件很多,比如Dynatrace等各类APM工具,但如果你的系统比较定制也比较奇特的话,那么恐怕很难找到现成的工具,我们可以自己开发一套监控系统,其实知道原理也很简单的。

不管是新系统还是老系统,也不管是上线前还是上线后,做性能优化都要遵循两原则三步骤:

  • 两原则:不去优化没有测试的软件(单元测试要有,不然优化出了bug都不知道)、不去优化你不了解的软件
  • 三步骤:测试、分析、调优

性能测试的主要指标

一般来说,衡量系统的性能,主要有以下几个指标:

响应时间
可以从端到端的响应时间细分下去:比如数据库的响应时间,IO的响应时间,HTTPClient的响应时间。当我们优化系统的时候,通过收集这些响应时间可以精确定位性能问题出现在哪。

并发数
并发数是指系统能够同时处理请求的数量,这个数字也反映了系统的负载承受能力。

吞吐量
吞吐量是指单位时间内系统处理的请求数量,体现的是系统的处理能力。在 Web 系统中,常常用 TPS ( 每秒事务处理量 ) 或者 QPS ( 每秒查询量 ) 来衡量系统的吞吐量。在不考虑网卡等网络设备限制的情况下,可以使用下面的公式来大致估算系统的吞吐量:
吞吐量 = (1000/响应时间 ms) x 并发数

如何严谨地做性能测试

那如何更严谨地做性能测试?分享一个做性能测试比较科学的方法(来源自COOLSHELL):

  1. 定义一个系统的响应时间latency,建议是 TP99,以及成功率。比如路透的定义:99.9%的响应时间必须在 1ms 之内,平均响应时间在 1ms 以内,100%的请求成功。当然一般的 Web 系统不用定义的这么苛刻,99.9%的响应时间在 100ms 内即可。
  2. 在这个响应时间的限制下,来测试系统的吞吐量。测试用的数据,需要有大中小各种尺寸的数据,并可以混合。最好使用生产线上的测试数据。
  3. 在这个吞吐量做浸泡测试,比如:使用第二步测试得到的吞吐量连续 7 天的不间断的压测系统。然后收集 CPU,内存,硬盘/网络 IO,等指标,查看系统是否稳定,比如,CPU 是平稳的,内存使用也是平稳的。那么,这个值就是系统的性能。
  4. 找到系统的极限值。比如:在成功率 100%的情况下 (不考虑响应时间的长短),系统能保持 10 分钟的吞吐量。
  5. 做 Burst Test。用第二步得到的吞吐量执行 5 分钟,然后在第四步得到的极限值执行 1 分钟,再回到第二步的吞吐量执行 5 分钟,再到第四步的权限值执行 1 分钟,如此往复个一段时间,比如 2 天。收集系统数据:CPU、内存、硬盘/网络 IO 等,观察他们的曲线,以及相应的响应时间,确保系统是稳定的。
  6. 低吞吐量和网络小包的测试。有时候,在低吞吐量的时候,可能会导致延迟上升,比如 TCP_NODELAY 的参数没有开启会导致延迟上升,而网络小包会导致带宽用不满也会导致性能上不去,所以,性能测试还需要根据实际情况有选择的测试一下这两个场景。

影响系统性能的主要因素
我们要先了解下一般情况哪些因素会影响到系统的性能,这样我们可以逐个排查。

硬件
一般硬件是我们首先考虑的因素,如果可以提升硬件那么一般可以解决一些性能问题。常见的影响因素有CPU、内存、磁盘 I/O 、网络等,如果内存不够或者CPU长期满负载那么就需要升级硬件了,如果业务中IO很重那么要考虑换个SSD硬盘,如果流量很大要考虑网络带宽够不够,网卡性能跟得上不。

系统
系统相关的点实在是太多了,这里简单介绍几种常见的情况:
1)Linux文件描述符限制,有时候默认的值比较低,影响并发。2)Linux中Swap强烈建议关闭,打开坏处多于好处,会有意想不到的问题。3)高流量的应用需要注意网卡中断问题,使用CPU亲和性绑定网卡。

软件
一般有几个因素需要重点关注1)数据库:数据库操作不仅涉及大量的内存以及 CPU 计算,还涉及到大量的磁盘读写。对数据库的性能优化是整个系统的核心,比如,我们常用的各种缓存都是为了减少对数据库的压力。开启慢SQL搜集,通过分析慢SQL来优化系统中效率低下的SQL语句。2)锁竞争:单机环境下,锁的使用可能会带来大量的线程资源浪费,从而给系统带来性能开销;而分布式环境下,使用分布式锁也可能造成大量的请求堆积,影响整个系统性能。优化重心在于锁粒度的控制,以及如何采用无锁模型去替代。3)线程池:线程池的不恰当申明和配置也会带来问题,请确保你的线程池都是有界的,确保你的线程池大小是合理的。4)异步系统与同步IO:确保你理解Netty相关知识,不要在Reactor线程中去使用同步IO。5)循环与外部请求:不要将外部请求放到循环中,而是应该尽可能通过批量方式一次请求。6)看似便利确暗藏杀机:很多库提供了看似便利的方法,其实暗藏杀机,不要使用你不了解原理的所谓高级用法。

兜底策略

性能优化做得再好,系统总会存在极限,因此,兜底的策略也是性能优化的一部分,常见的兜底策略有限流、降级和熔断。很多中间件都有这样的功能,我们应当合理使用。还有我们可以通过减少涌入服务器的流量来避免高流量对我们服务器的冲击,比如接入CDN,利用CDN的节点优化和缓存能力能很好的优化我们的性能,当然能使用更高级的边缘计算技术那么在某些场景下会有质的飞跃。

性能优化的套路

先说结论,性能优化或者说如何能够开发出效率更高的程序来(当然硬件资源一样且使用率一样的条件下),只有两条路:

  • 掌握局部性原理
  • 掌握基本的算法设计与分析

在现有计算机构架下(冯诺依曼构架)这是调优的充分必要条件,换句话说就是,如果你写了一个性能比别人好的程序,那么只有两种情况,要么是局部性原理用得更好,要么是就是采用了更好的算法;同时,如果你想要写出比别人好的程序,那么你也只有两条路可以走,运用更底层的局部性优化设计,或者设计更好的算法来解决这个问题。

局部性原理
如果你在软件工程领域“阅历”足够丰富,你就会有恍然大悟的感觉,因为这种例子实在太多:Linux中的硬盘缓冲、页缓存,Redis中元素较少的时候用ziplist代替hashmap可能还会带来性能的提升,内存的减少;Kafka依赖的磁盘缓存,分区存储机制,Mysql的B+树索引;更底层的CPU的Cacheline技术,分页技术等等其实都是用了局部性原理来作为理论基础的。那么什么是局部性原理呢?简单来说分为三个要点:

程序总是按照“批”来处理数据,这样效率最高;比如:CPU读取内存是按照批来处理如:一个cacheline=64/128byte,内存与硬盘按照4KB来读取。因为计算机结构的特点,各级设备(CPU到内存到硬盘与外设)之间的访问速度是有数量级的差别;所以就注定不能一个个字节来读取;这个现象决定计算机优化的方向,被称为冯诺依曼瓶颈;

最近访问过的资源(内存位置),可能近期内还会被访问,这个叫做时间局部性,对应的解决方案是缓存;

当前位置内存被访问过,那么大概率旁边的内存也会被后续访问,这个叫做空间局部性,对应的解决方案是预加载;

所以推导的顺序是:正是因为现代计算机有冯诺依曼瓶颈所以需要用缓存与预加载来提高程序的性能,否则会因为IO过重,资源得不到利用而效率偏低。

还是拿做饭这件事举例子。当我们去买菜的时候,会尽可能的减少去菜市场的时间,也肯定不可能发现什么菜没有了再临时去菜市场购买,这样“IO”就太重了,要使用局部性原理来解决。就炒菜的例子来说,我们要炒青椒炒肉这个菜;首先是买菜,我们发现在买青椒的时候可以把葱姜蒜也都一并买了,因为蔬菜区往往是相邻的;而买肉的时候,尽量也把酱油醋都一并买了,它们也是很可能也是挨着的;这种在空间上相邻的提前加载这就是“预加载”了;而回到家,我们开始炒菜的时候,厨房的布置往往是炒菜的锅子跟油盐酱醋是分开放置的,但是炒菜的时候要经常加盐、放酱油,所以为了减少来回取油盐的时间,会在炒菜的时候将配菜与油盐放置在炒锅的附近,加快炒菜的速度,等炒完后又重新放回去,这个就叫做“缓存”的思维——把经常使用的资源放在工作较近的地方,等使用完再释放。

在Linux内核中,我们可以看到很多的buffer与cache,这些数据结构都是局部性原理的具体使用实例。比如,我们知道内存跟磁盘之间IO速度相差5个数量级。那么往往会出现,一个进程刚写入磁盘的数据,又要被其他进程读取,那么一来一回CPU都在等待磁盘读取数据,十分浪费资源,那么如果将磁盘的数据放入磁盘写buffer,读的时候优先从buffer中读取,而buffer都是在内存中,这样就弥补了这个性能的gap,这就是“时间局部性”原理的使用,也就是我们常说的“缓存”,或者“空间换时间”;Mysql数据库有个特点就是数据是存在磁盘上,读取记录时需要将数据从磁盘加载到内存然后进入CPU计算得出结果,所以会横跨三个IO边界——磁盘、内存与CPU,它们之间都有好几个数量级的延迟,所以IO性能的平衡就十分的重要。其中比较典型的就是B+树这种针对外部存储型的数据结构的引入,它十分贴合磁盘IO的“口味”。磁盘IO有个特点,就是读取写入都很慢,但是可以一次读取大量的数据。根据这个特点,B+树采用多叉树的结构进行设计,这样做相比二叉树来说可以减少树的高度,这样带来的好处是每一层数据都可以是在物理空间相邻的,从而可以通过最少的IO次数加载更多的数据到内存;而这些数据往往是业务相关的,所以一次IO读取的数据可以供后续很多计算工作使用而不用重复IO。比如,一个表有20列,那么每一行数据就有20个字段,这些字段往往在磁盘中是连续存放的,当我们通过id查询其中某个字段a的值时,Mysql其实会将整个记录都取出来,加载到内存,而程序访问完a字段,再访问记录中其他字段时就不需要磁盘IO了,直接读取内存中记录的缓存就行,这就是“空间局部性”的使用实例,也就是我们常说的“预加载”,系统预热。

算法分析与设计
这是计算机科学的范畴,也是最引人注目的领域,就好比武侠小说里面的武功,华丽的招式比不过强大的内功,练招式容易练内功难,一旦内功深厚,招式什么的都是分分钟被秒杀,就好比《一拳超人》中的琦玉老师,专治各种花里胡哨。而我想说的是,算法设计技术就是软件工程领域的内功心法。当然我远远没有达到可以对这个领域品头论足的程度,就想抛砖引玉,介绍点皮毛,希望对有天赋的同学有所启发。

数据结构
数据结构之所以重要是因为不同的数据结构表现出来的数学性质是构成特定算法的基础。就好比各种化学元素可以搭建各种性质不同的化合物,而不同性质的化合物正式化学工程所依赖的基础。计算机科学的数据结构可以大致分为两种,一种是数组,另一种是链表。两种性质截然相反的“物质”。

数组——线性性能最好,支持随机访问,按照索引取数组中的元素时间复杂度是O(1),而插入与删除元素时间复杂度是O(n);

链表——扩展性能最好,支持动态的增减元素,插入、删除元素的时间复杂度是O(1),而检索元素的时间复杂度变成了O(n);

(注:O标记是数学语言,用于标记一个函数的增长快慢,是一个渐进界函数,具体细节可以参考屈婉玲教授的详细解释)这个世界有时候是惊人对称的,对称是美妙的。这样一对性质完全互斥的结构就是构成所有高等数据结构的基础。就像中国传统文化中的太极一样,你中有我我中有你,互相补充,世间万物的运行法则皆可解释。举几个例子,有些高等数据结构具有耀眼的数学特性,比如“堆”,或者叫做优先级队列、二叉堆,就是以数组作为基础的。它在插入、删除、查询元素时的性能可以稳定在O(logn)量级;在互联网行业中,只有logn级别的算法可以适用,因为当数据规模急剧增加的时候,对数函数能够很好的平稳压力,所以,"logn"的算法对互联网行业贡献巨大,具有整流器的作用。比如,在微服务流量控制,大数据流处理、topN、高性能定时器都有很多应用。而堆只有在数组实现的算法中才能保持这个特性,如果用链表就会退化为O(nlogn),失去魔力。另一个例子是二叉树,这就是链表的一个使用场景。二叉树是一种树状结构,其中平衡二叉树在插入与删除的过程中只要移动logn次就能找到自己的新位置,而且代码简单易于维护(不容易写错也是工程中一个重要的考虑点,如果写得代码过于复杂就要反思下是否使用错了数据结构)。比如:红黑树就是一个综合性能很好的平衡二叉查找树;它是一个动态的数据结构,可以在动态添加与查找过程中稳定在O(logn)量级;在Linux内核中大量使用;而且在Java中的ConcurrentHashMap在冲突大的情况下,冲突元素大于8也会升级成红黑树存储冲突元素,来平衡工程与算法效率之间的矛盾。

当然,数据结构是可以融合的,比如Java中的LinkedHashMap就是融合了数组、哈希表与链表的优秀实践。普通的哈希表因为通过哈希函数将元素链接到数组的索引号上面,实现高速的查找性能,但是丢失了元素的插入顺序,而有时候我们需要这个顺序性来实现特殊的需求,比如缓存淘汰策略;而如果使用链表,形成一个插入的队列,先插入的在队列头,后插入在队列尾部;但是这样虽然保存了插入的顺序但是丢失了查找性能;为了平衡,我们可以在哈希表的基础上,每个元素再增加一个指针用来连接前后插入的元素,形成队列,这样没插入一个元素不仅在哈希表上挂载新的索引点,还要将新元素挂接到队列的尾部,而每一步都是O(1)的开销,是可以接受的,这就是LinkedHashMap的实现原理。

算法设计技术
算法设计技术是使用数据结构解决实际问题的技术,就好比化合物特性研究与化学工程的关系,一个是偏重科学特性的研究,一个是解决实际问题。为了能够让科学能够指导生产,能够造福世间,必须将科学落地,那么这个过程中最重要的一步就是对实际问题的建模,建模是对问题的模拟,越准确越能够解决好问题。那么,在算法设计层面有哪些建模方式呢?大体可以分为四个:

  1. 蛮力算法
  2. 贪心算法
  3. 分治算法
  4. 动态规划

其中,蛮力算法是对问题建模的初期阶段,是对问题的程序再现,追求的是定性,性能不一定重要,但是问题描述没问题;分治与贪心是在蛮力基础上的一次降阶,往往可以将问题优化到O(nlogn)的规模以内;而动态规划可以进一步将问题的阶降到O(n)级别。降阶是设计算法的初衷,前提是问题本身计算的各个阶段是有冗余的,有重复计算的地方,而找到这个重复的点并不容易,就拿动态规划来说吧,虽然有极致的性能但是发现递推方程并不容易,当然这一切都要经过严格的数学证明才行,这就更加难能可贵。我们这里没有考虑空间的优化,往往降阶的过程中最好保证空间的复杂度不会激增,这样才会有效。这方面我们不展开了,有很多资料可以参考,其中我推荐屈婉玲教授的算法设计分析(https://www.bilibili.com/video/av83623454?p=1)。

posted @ 2025-08-02 14:46  愿鲁且愚6746  阅读(16)  评论(0)    收藏  举报