分布式锁

分布式锁

Author:Exchanges

Version:9.0.0

一、引言

高并发的情况下还要保证数据的安全性问题:在互联网一些秒杀的环境下,例如:抢优惠券啊,秒杀商品等等,如果处理不当会产生超卖现象,因为是分布式环境,传统的一些技术会失败,比如传统的synchronized或者lock锁,以及数据库的事务,会无法保证ACID,我们需要想办法去解决,这里我们使用Redisson和Seata来解决分布式架构中锁和事务的问题

二、分布式锁


2.1 介绍

在微服务系统中,某些场景需要阻塞所有节点的所有线程,对共享资源的访问。比如并发时“超卖”和“余额减为负数”等情况,需要对同一资源进行加锁,由于传统的锁是基于Tomcat服务器内部的,搭建了集群之后,导致锁失效,使用分布式锁来处理。

分布式锁介绍

2.2 特性

当我们在设计分布式锁的时候,我们应该考虑分布式锁至少要满足的一些条件,同时考虑如何高效的设计分布式锁,有以下几点:

2.2.1 互斥

在分布式高并发的条件下,我们最需要保证,同一时刻只能有一个线程获得锁,这是最基本的一点。

2.2.2 防止死锁

在分布式高并发的条件下,比如有个线程获得锁的同时,还没有来得及去释放锁,就因为系统故障或者其它原因使它无法执行释放锁的命令,导致其它线程都无法获得锁,造成死锁。

所以分布式非常有必要设置锁的有效时间,确保系统出现故障后,在一定时间内能够主动去释放锁,避免造成死锁的情况。

2.2.3 性能

对于访问量大的共享资源,需要考虑减少锁等待的时间,避免导致大量线程阻塞。

所以在锁的设计时,需要考虑两点:

1.锁的颗粒度要尽量小:比如你要通过锁来减库存,那这个锁的名称你可以设置成是商品的ID,而不是任取名称,这样这个锁只对当前商品有效,锁的颗粒度小。

2.锁的范围尽量要小:比如只要锁2行代码就可以解决问题的,那就不要去锁10行代码了。

2.2.4 重入

我们知道ReentrantLock是可重入锁,那它的特点就是:同一个线程可以重复拿到同一个资源的锁。重入锁非常有利于资源的高效利用。

三、分布式锁解决方案


3.1 搭建环境

1.创建SpringBoot工程并导入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- 引入redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2.配置application.properties文件

#配置redis
spring.redis.host=127.0.0.1
spring.redis.database=0
spring.redis.port=6379

3.编写抢购业务的SecondKillController

package com.qf.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("secondKill")
public class SecondKillController {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    //模拟减库存的案例
    @RequestMapping("stock")
    public String stock(){
        //以商品的id作为key,手动去redis-cli中添加数量:set product001 200
        String stockKey = "product001";

        //获取库存数量
        String product001 = stringRedisTemplate.opsForValue().get(stockKey);
        //转为Integer类型
        Integer product001Stock = Integer.parseInt(product001);

        //判断
        if(product001Stock > 0){
            //在原来库存的基础上减 1 ,获取真实的库存
            int realProduct001Stock = product001Stock -1;
            //重新再设置一下库存
            stringRedisTemplate.opsForValue().set(stockKey,realProduct001Stock+"");
            //提示语句
            System.out.println("下单成功,库存量剩余:"+ realProduct001Stock);
        }else{
            //提示语句
            System.out.println("下单失败,当前商品卖完了");
        }

        return "success";
    }
}

4.启动工程,使用 Jmter 进行压力测试,会发现数据出现不一致现象(超卖现象)

5.我们可以使用synchronized关键字以及ReentrantLock在单机状态下加锁,解决线程并发问题,但在tomcat集群环境下依然会有此现象,如需测试,需要配置一台Nginx以及两台Tomcat(这里不做演示了)

3.2 Redis实现分布式锁原理

Redis实现分布式锁原理

3.4 Redis实现分布式锁 代码进阶

1.修改SecondKillController

package com.qf.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("secondKill")
public class SecondKillController {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    //模拟减库存的案例
    @RequestMapping("stock")
    public String stock(){
        //以商品的id作为key,手动去redis中添加数量
        String stockKey = "product001";

        //设置不同的值,用于不同线程进来,执行之后,通过每一个线程自己的value去删除自己key
        String productLockValue = "product_"+ UUID.randomUUID().toString();

        //如果在执行业务期间出现异常,我们需要对其进行处理
        try {
            //key相同,value不同,目的在于,自己删除自己的key
            Boolean lockKey = stringRedisTemplate.opsForValue()
                    .setIfAbsent("lockKey", productLockValue, 20, TimeUnit.SECONDS);

            //判断
            if (lockKey == false) {
                //表示有一个线程已经在访问当前方法了
                return "服务器繁忙,请稍后再试";
            }

            //获取库存数量
            String product001 = stringRedisTemplate.opsForValue().get(stockKey);
            //转为Integer类型
            Integer product001Stock = Integer.parseInt(product001);

            //判断
            if (product001Stock > 0) {
                //在原来库存的基础上减 1 ,获取真实的库存
                int realProduct001Stock = product001Stock - 1;
                //重新再设置一下库存
                stringRedisTemplate.opsForValue().set(stockKey, realProduct001Stock + "");
                //提示语句
                System.out.println("下单成功,库存量剩余:" + realProduct001Stock);
            } else {
                //提示语句
                System.out.println("下单失败,当前商品卖完了");
            }
        }catch (Exception e){
            //处理异常
        }finally {
            //程序结束之后,在redis中释放锁,判断之后删除
            if(productLockValue.equals(stringRedisTemplate.opsForValue().get("lockKey"))){
                stringRedisTemplate.delete("lockKey");
            }
        }

        return "success";
    }
}

2.重启工程,使用 Jmter 进行压力测试,并发数据量大的情况下依旧会有超卖问题

四、Redisson


4.1 介绍

Redisson是一个基于NIO的Netty框架的企业级的开源Redis Client,也提供了分布式锁的支持,Redisson 是架设在 Redis 基础上的一个 Java 驻内存数据网格框架, 充分利用 Redis 键值数据库提供的一系列优势, 基于 Java 实用工具包中常用接口, 为使用者提供了 一系列具有分布式特性的常用工具类.

4.2 Redis、Redis lua脚本和Redission加锁对比

方案 实现原理 优点 缺点
基于Redis命令 1. 加锁:执行setnx,若成功再执行expire添加过期时间2. 解锁:执行delete命令 实现简单,相比数据库和分布式系统的实现,该方案最轻,性能最好 1.setnx和expire分2步执行,非原子操作;若setnx执行成功,但expire执行失败,就可能出现死锁 2.delete命令存在误删除非当前线程持有的锁的可能 3.不支持阻塞等待、不可重入
基于Redis Lua脚本 1. 加锁:执行SET lock_name random_value EX seconds NX 命令2. 解锁:执行Lua脚本,释放锁时验证random_value -- ARGV[1]为random_value, KEYS[1]为lock_name if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end 实现逻辑上也更严谨,除了单点问题,生产环境采用用这种方案,问题也不大 不支持锁重入,不支持阻塞等待
基于Redission 结合redis和lua脚本实现 支持锁重入、支持阻塞等待、Lua脚本原子操作 Redisson 的宗旨是促进使用者对 Redis 的关注分离,从而让使用者能够将精力更集中地放在处理业务逻辑上。

4.3 Redisson的使用

1.添加依赖

<!-- redisson -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.16.7</version>
</dependency>

2.创建redisson配置类

package com.qf.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0);
        RedissonClient redissonClient = Redisson.create(config);

        return redissonClient;
    }
}

3.修改SecondKillController

package com.qf.controller;

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("secondKill")
public class SecondKillController {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private RedissonClient redissonClient;

    //模拟减库存的案例
    @RequestMapping("stock")
    public String stock(){
        //以商品的id作为key,手动去redis中添加数量
        String stockKey = "product001";

        //获取锁对象
        RLock rLock = redissonClient.getLock("lockKey");

        try {
            //加锁
            rLock.lock();
            //boolean tryLock(long waitTime, long leaseTime, TimeUnit unit)
            //lock(long leaseTime, TimeUnit unit)

            //获取库存数量
            String product001 = stringRedisTemplate.opsForValue().get(stockKey);
            //转为Integer类型
            Integer product001Stock = Integer.parseInt(product001);

            //判断
            if(product001Stock > 0){
                //在原来库存的基础上减 1 ,获取真实的库存
                int realProduct001Stock = product001Stock -1;
                //重新再设置一下库存
                stringRedisTemplate.opsForValue().set(stockKey,realProduct001Stock+"");
                //提示语句
                System.out.println("下单成功,库存量剩余:"+ realProduct001Stock);
            }else{
                //提示语句
                System.out.println("下单失败,当前商品卖完了");
            }

        }catch (Exception e){
            //处理异常
        }finally {
            //释放锁
            rLock.unlock();
        }


        return "success";
    }
}

3.重启工程,使用 Jmter 再次进行压力测试

4.3Redisson源码分析

redisson所有指令都通过lua脚本执行,redis支持lua脚本原子性执行

redisson设置一个key的默认过期时间为30s,如果某个客户端持有一个锁超过了30s怎么办?

redisson中有一个watchdog的概念,翻译过来就是看门狗,它会在你获取锁之后,每隔10秒帮你把key的超时时间设为30s,这样的话,就算一直持有锁也不会出现key过期了,其他线程获取到锁的问题了。

redisson的“看门狗”逻辑保证了没有死锁发生,如果机器宕机了,看门狗也就没了。此时就不会延长key的过期时间,到了30s之后就会自动过期了,其他线程可以获取到锁

源码分析图

核心源码,基于Lua脚本语言(具有原子性)

Redission加锁Lua脚本解读

参数 示例 含义
KEY个数 1 KEY个数
KEYS[1] lock_name 锁名
ARGV[1] 60000 持有锁的有效时间:毫秒
ARGV[2] 58c62432-bb74-4d14-8a00-9908cc8b828f:1 唯一标识:获取锁时set的唯一值,实现上为redisson客户端ID(UUID)+线程ID

Redission请求流程图

五、Redisson常用的锁 (了解)

5.1 可重入锁(Reentrant Lock)

Redisson的分布式可重入锁RLock Java对象实现了java.util.concurrent.locks.Lock接口,同时还支持自动过期解锁

public void testReentrantLock(RedissonClient redisson) {

    RLock lock = redisson.getLock("anyLock");
    try {
        // 1. 最常见的使用方法
        // lock.lock();
        // 2. 支持过期解锁功能,10秒钟以后自动解锁, 无需调用unlock方法手动解锁
        // lock.lock(10, TimeUnit.SECONDS);
        // 3. 尝试加锁,最多等待3秒,上锁以后10秒自动解锁
        boolean res = lock.tryLock(3, 10, TimeUnit.SECONDS);
        if (res) { // 成功
            // do your business
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        lock.unlock();
    }
}

5.2 异步执行(Async)

Redisson同时还为分布式锁提供了异步执行的相关方法

public void testAsyncReentrantLock(RedissonClient redisson) {

    RLock lock = redisson.getLock("anyLock");
    try {
        lock.lockAsync();
        lock.lockAsync(10, TimeUnit.SECONDS);
        Future<Boolean> res = lock.tryLockAsync(3, 10, TimeUnit.SECONDS);
        if (res.get()) {
            // do your business
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    } catch (ExecutionException e) {
        e.printStackTrace();
    } finally {
        lock.unlock();
    }
}

5.3 公平锁(Fair Lock)

Redisson分布式可重入公平锁也是实现了java.util.concurrent.locks.Lock接口的一种RLock对象。在提供了自动过期解锁功能的同时,保证了当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程。

public void testFairLock(RedissonClient redisson){

    RLock fairLock = redisson.getFairLock("anyLock");  
    try{  
        // 最常见的使用方法  
        fairLock.lock();  
        // 支持过期解锁功能, 10秒钟以后自动解锁,无需调用unlock方法手动解锁  
        fairLock.lock(10, TimeUnit.SECONDS);  
        // 尝试加锁,最多等待100秒,上锁以后10秒自动解锁  
        boolean res = fairLock.tryLock(100, 10, TimeUnit.SECONDS);  
        if (res) {
            // do your business
        }
    } catch (InterruptedException e) {  
        e.printStackTrace();  
    } finally {  
        fairLock.unlock();  
    }  
}

5.4 联锁(MultiLock)

Redisson的RedissonMultiLock对象可以将多个RLock对象关联为一个联锁,每个RLock对象实例可以来自于不同的Redisson实例

public void testMultiLock(RedissonClient redisson1,RedissonClient redisson2, RedissonClient redisson3){

    RLock lock1 = redisson1.getLock("lock1");  
    RLock lock2 = redisson2.getLock("lock2");  
    RLock lock3 = redisson3.getLock("lock3");  
    RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2, lock3);  
    try {  
        // 同时加锁:lock1 lock2 lock3, 所有的锁都上锁成功才算成功。  
        lock.lock();  
        // 尝试加锁,最多等待100秒,上锁以后10秒自动解锁  
        boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);  
        if (res) {
            // do your business
        }
    } catch (InterruptedException e) {  
        e.printStackTrace();  
    } finally {  
        lock.unlock();  
    }  
}

5.5 红锁(RedLock)

Redisson的RedissonRedLock对象实现了Redlock介绍的加锁算法。该对象也可以用来将多个RLock对象关联为一个红锁,每个RLock对象实例可以来自于不同的Redisson实例

public void testRedLock(RedissonClient redisson1,RedissonClient redisson2, RedissonClient redisson3){

    RLock lock1 = redisson1.getLock("lock1");  
    RLock lock2 = redisson2.getLock("lock2");  
    RLock lock3 = redisson3.getLock("lock3");  
    RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);  
    try {  
        // 同时加锁:lock1 lock2 lock3, 红锁在大部分节点上加锁成功就算成功。  
        lock.lock();  
        // 尝试加锁,最多等待100秒,上锁以后10秒自动解锁  
        boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);  
        if (res) {
            // do your business
        }
    } catch (InterruptedException e) {  
        e.printStackTrace();  
    } finally {  
        lock.unlock();  
    }  
}

六、 基于zookeeper实现分布式锁(了解)

常见的分布式锁实现方案里面,除了使用redis来实现之外,使用zookeeper也可以实现分布式锁。

6.1 介绍

Zookeeper是一种提供配置管理、分布式协同以及命名的中心化服务

6.2 模型

zookeeper的模型是这样的:zookeeper包含一系列的节点,叫做znode,就好像文件系统一样每个znode表示一个目录。

znode有一些特性:
有序节点:假如当前有一个父节点为/lock,我们可以在这个父节点下面创建子节点;
zookeeper提供了一个可选的有序特性,例如我们可以创建子节点“/lock/node-”并且指明有序,那么zookeeper在生成子节点时会根据当前的子节点数量自动添加整数序号
也就是说,如果是第一个创建的子节点,那么生成的子节点为/lock/node-0000000000,下一个节点则为/lock/node-0000000001,依次类推。

临时节点:客户端可以建立一个临时节点,在会话结束或者会话超时后,zookeeper会自动删除该节点。
事件监听:在读取数据时,我们可以同时对节点设置事件监听,当节点数据或结构变化时,zookeeper会通知客户端。

zookeeper有如下四种事件:
节点创建
节点删除
节点数据修改
子节点变更

6.3 实现分布式锁的落地方案

使用zk的临时节点和有序节点,每个线程获取锁就是在zk创建一个临时有序的节点,比如在/lock/目录下。
创建节点成功后,获取/lock目录下的所有临时节点,再判断当前线程创建的节点是否是所有的节点的序号最小的节点
如果当前线程创建的节点是所有节点序号最小的节点,则认为获取锁成功。
如果当前线程创建的节点不是所有节点序号最小的节点,则对节点序号的前一个节点添加一个事件监听。
比如当前线程获取到的节点序号为/lock/003,然后所有的节点列表为[/lock/001,/lock/002,/lock/003],则对/lock/002这个节点添加一个事件监听器。

如果锁释放了,会唤醒下一个序号的节点,然后重新执行第3步,判断是否自己的节点序号是最小。
比如/lock/001释放了,/lock/002监听到时间,此时节点集合为[/lock/002,/lock/003],则/lock/002为最小序号节点,获取到锁。

zookeeper流程图:

6.4 Curator

Curator是一个zookeeper的开源客户端,也提供了分布式锁的实现。

核心代码如下:

private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception
{
    boolean  haveTheLock = false;
    boolean  doDelete = false;
    try {
        if ( revocable.get() != null ) {
            client.getData().usingWatcher(revocableWatcher).forPath(ourPath);
        }

        while ( (client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock ) {
            // 获取当前所有节点排序后的集合
            List<String>        children = getSortedChildren();
            // 获取当前节点的名称
            String              sequenceNodeName = ourPath.substring(basePath.length() + 1); // +1 to include the slash
            // 判断当前节点是否是最小的节点
            PredicateResults    predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases);
            if ( predicateResults.getsTheLock() ) {
                // 获取到锁
                haveTheLock = true;
            } else {
                // 没获取到锁,对当前节点的上一个节点注册一个监听器
                String  previousSequencePath = basePath + "/" + predicateResults.getPathToWatch();
                synchronized(this){
                    Stat stat = client.checkExists().usingWatcher(watcher).forPath(previousSequencePath);
                    if ( stat != null ){
                        if ( millisToWait != null ){
                            millisToWait -= (System.currentTimeMillis() - startMillis);
                            startMillis = System.currentTimeMillis();
                            if ( millisToWait <= 0 ){
                                doDelete = true;    // timed out - delete our node
                                break;
                            }
                            wait(millisToWait);
                        }else{
                            wait();
                        }
                    }
                }
                // else it may have been deleted (i.e. lock released). Try to acquire again
            }
        }
    }
    catch ( Exception e ) {
        doDelete = true;
        throw e;
    } finally{
        if ( doDelete ){
            deleteOurPath(ourPath);
        }
    }
    return haveTheLock;
}

七、 面试题:Redis和Zookeeper实现分布式锁的区别?

7.1 CAP原则上分析

CAP原则又称CAP定理,指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。

一致性(C):在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)

可用性(A):保证每个请求不管成功或者失败都有响应。

分区容忍性(P):系统中任意信息的丢失或失败不会影响系统的继续运作。 [1]

CAP原则的精髓就是要么AP,要么CP,要么AC,但是不存在CAP。

Redis: AP(保证可用性和分区容错性),Master加锁成功后就给客户端返回成功标识了,而不是等到同步完Slave再发,如果Slave还没同步完成,此时Maste宕机了,就会有问题。

Zookeeper : CP (保证一致性和分区容错性),如果要保证完全一致性,可以使用zookeeper锁,CP,能够保证绝对一致, Zookeeper是Master节点加锁后,把状态也同步到了另外的节点成功后才给客户端返回成功标识但是性能没有redis高。

7.2 redis和zookeeper技术有何不同?

Redis 是nosql数据,主要特点缓存。
Zookeeper是分布式协调工具,主要用于分布式解决方案。

7.3 Redis实现分布式锁与Zookeeper实现分布式锁的思路分别是什么?

获取锁
Zookeeper:多个客户端(jvm),会在Zookeeper上创建同一个临时节点,因为Zookeeper节点命名路径保证唯一,不允许出现重复,只要谁能够先创建成功,谁能够获取到锁。

Redis:多个客户端(jvm),会在Redis使用setnx命令创建相同的一个key,因为Redis的key保证唯一,不允许出现重复,只要谁能够先创建成功,谁能够获取到锁。

释放锁
Zookeeper:使用直接关闭临时节点session会话连接,因为临时节点生命周期与session会话绑定在一块,如果session会话连接关闭的话,该临时节点也会被删除。
这时候客户端使用事件监听,如果该临时节点被删除的话,重新进入盗获取锁的步骤。

Redis:在释放锁的时候,为了确保是锁的一致性问题,在删除的redis 的key时候,需要判断同一个锁的id,才可以删除。

7.4 redis和zookeeper如何解决死锁问题?

Zookeeper使用会话有效期方式解决死锁现象。
Redis 是对key设置有效期解决死锁现象

7.5 分别从性能和可靠性两个角度谈谈redis和zookeeper实现分布式锁的优缺点

性能角度考虑:
因为Redis是NoSQL数据库,相对比来说Redis比Zookeeper性能要好。

可靠性:
从可靠性角度分析,Zookeeper可靠性比Redis更好,因为Redis有效期不是很好控制,可能会产生有效期延迟,Zookeeper就不一样,因为Zookeeper临时节点先天性可控的有效期,所以相对来说Zookeeper比Redis更好

7.6 总结两种方案的优缺点比较

对于redis的分布式锁而言,它有以下缺点:
它获取锁的方式简单粗暴,获取不到锁直接不断尝试获取锁,比较消耗性能。
另外来说的话,redis的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题。锁的模型不够健壮
即便使用redlock算法来实现,在某些复杂场景下,也无法保证其实现100%没有问题,关于redlock的讨论可以看How to do distributed locking
redis分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。

但是另一方面使用redis实现分布式锁在很多企业中非常常见,而且大部分情况下都不会遇到所谓的“极端复杂场景”
所以使用redis作为分布式锁也不失为一种好的方案,最重要的一点是redis的性能很高,可以支撑高并发的获取、释放锁操作。

对于zk分布式锁而言:
zookeeper天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁。
如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。

但是zk也有其缺点:如果有较多的客户端频繁的申请加锁、释放锁,对于zk集群的压力会比较大。

posted @ 2022-07-10 23:38  qtyanan  阅读(147)  评论(0编辑  收藏  举报