亲宝软件园·资讯

展开

dubbo(三):负载均衡实现解析

等你归去来 人气:0

  dubbo作为分布式远程调用框架,要保证的点很多,比如:服务注册与发现、故障转移、高性能通信、负载均衡等等!

  负载均衡的目的是为了特定场景下,能够将请求合理地平分到各服务实例上,以便发挥所有机器的叠加作用。主要考虑的点如:不要分配请求到挂掉的机器,性能越好的机器可以分配更多的请求。。。

  一般负载均衡是借助外部工具,硬件负载均衡或软件负载均衡,如F5/nginx。当然了,在当前分布式环境遍地开花的情况下,客户端的负载均衡看起来就更轻量级,显得不可或缺。

  今天我们就来看看dubbo是如何进行负载均衡的吧!

1. dubbo负载均衡的作用?

  其出发点,自然也就是普通的负载均衡器的出发点了。将负载均衡功能实现在rpc客户端侧,以便能够随时适应外部的环境变化,更好地发挥硬件作用。而且客户端的负载均衡天然地就避免了单点问题。定制化的自有定制化的优势和劣势。

  它可以从配置文件中指定,也可以在管理后台进行配置修改。

  事实上,它支持 服务端服务/方法级别、客户端服务/方法级别 的负载均衡配置。

 

2. dubbo有哪些负载均衡方式?

  即dubbo提供了哪些负载均衡策略呢?

Dubbo内置了4种负载均衡策略:

  RandomLoadBalance:随机负载均衡。随机的选择一个。是Dubbo的默认负载均衡策略。

  RoundRobinLoadBalance:轮询负载均衡。轮询选择一个。

  LeastActiveLoadBalance:最少活跃调用数,相同活跃数的随机。活跃数指调用前后计数差。使慢的 Provider 收到更少请求,因为越慢的 Provider 的调用前后计数差会越大。

  ConsistentHashLoadBalance:一致性哈希负载均衡。相同参数的请求总是落在同一台机器上。

 

3. 如何配置dubbo负载均衡策略?

  其实在第一点时已经提过,有多种级别的配置:服务端服务/方法级别、客户端服务/方法级别; 具体配置如下:

    <!-- 服务端服务级别 -->
    <dubbo:service interface="..." loadbalance="roundrobin" />
    <!-- 客户端服务级别 -->
    <dubbo:reference interface="..." loadbalance="roundrobin" />
    <!-- 服务端方法级别 -->
    <dubbo:service interface="...">
        <dubbo:method name="hello" loadbalance="roundrobin"/>
    <https://img.qb5200.com/download-x/dubbo:service>
    <!-- 客户端方法级别 -->
    <dubbo:reference interface="...">
        <dubbo:method name="hello" loadbalance="roundrobin"/>
    <https://img.qb5200.com/download-x/dubbo:reference>

  多个配置是有覆盖关系的, 配置的优先级是:

    1. 客户端方法级别配置;(最优先)
    2. 客户端接口级别配置;
    3. 服务端方法级别配置;
    4. 服务端接口级别配置;(最后使用)

  注意: 虽说以上配置有全封闭服务端配置的,有针对客户端配置的,但是,真正使负载均衡起作用的是,客户端在发起调用的时候,使用相应负载均衡算法进行选择调用。(服务端不可能有这能力)

  负载均衡器的初始化过程如下:

    // 调用提供者服务入口
    // org.apache.dubbo.rpc.cluster.support.AbstractClusterInvoker#invoke
    @Override
    public Result invoke(final Invocation invocation) throws RpcException {
        checkWhetherDestroyed();

        // binding attachments into invocation.
        Map<String, Object> contextAttachments = RpcContext.getContext().getAttachments();
        if (contextAttachments != null && contextAttachments.size() != 0) {
            ((RpcInvocation) invocation).addAttachments(contextAttachments);
        }

        List<Invoker<T>> invokers = list(invocation);
        // 根据可用的提供者列表和要调用的方法,决定选取的负载均衡器
        LoadBalance loadbalance = initLoadBalance(invokers, invocation);
        RpcUtils.attachInvocationIdIfAsync(getUrl(), invocation);
        return doInvoke(invocation, invokers, loadbalance);
    }

    // 实例化一个负载均衡器,以备后续使用
    // org.apache.dubbo.rpc.cluster.support.AbstractClusterInvoker#initLoadBalance
    /**
     * Init LoadBalance.
     * <p>
     * if invokers is not empty, init from the first invoke's url and invocation
     * if invokes is empty, init a default LoadBalance(RandomLoadBalance)
     * </p>
     *
     * @param invokers   invokers
     * @param invocation invocation
     * @return LoadBalance instance. if not need init, return null.
     */
    protected LoadBalance initLoadBalance(List<Invoker<T>> invokers, Invocation invocation) {
        if (CollectionUtils.isNotEmpty(invokers)) {
            // 从provider 的 url 地址中取出 loadbalance=xxx 配置,如果没有仍使用 random 策略
            return ExtensionLoader.getExtensionLoader(LoadBalance.class).getExtension(invokers.get(0).getUrl()
                    .getMethodParameter(RpcUtils.getMethodName(invocation), LOADBALANCE_KEY, DEFAULT_LOADBALANCE));
        } else {
            // 默认是 random 策略
            return ExtensionLoader.getExtensionLoader(LoadBalance.class).getExtension(DEFAULT_LOADBALANCE);
        }
    }

  所以,事实上,到最终客户端决定使用哪个负载均衡策略时,只是从请求参数中取出 loadbalance=xxx 的参数,进而决定具体实例。前面所有的配置,也都是为决定这个参数做出的努力。

 

4. dubbo负载均衡的实现?

  前面我们看到,dubbo中提供了4种负载均衡策略,功能也是很明了。那么他们都是如何实现的呢?

  先来看下其继承图:

 

   很明显,多个负载均衡器都有一些共同点,所以统一使用 AbstractLoadBalance 进行抽象模板方法,差异点由各子算法决定即可。

  那么抽象类中,到底有多少公用功能被抽取出来了呢?到底什么是公用的呢?

    // org.apache.dubbo.rpc.cluster.loadbalance.AbstractLoadBalance#select
    @Override
    public <T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        // 没有可用的提供者,没得选
        if (CollectionUtils.isEmpty(invokers)) {
            return null;
        }
        // 只有一个提供者,没得均衡的,直接用
        if (invokers.size() == 1) {
            return invokers.get(0);
        }
        // 然后就是各自均衡算法的实现了
        return doSelect(invokers, url, invocation);
    }

  好吧,看起来是我想多了。抽象方法并没有太多的职责,仅做普通判空操作而已。不过它倒是提供几个公用方法被调用,如 getWeight();

  事实上,模板方法更多地存在于集群的抽象调用方法中。AbstractClusterInvoker 。

  整个负载均衡的功能,都被统一放在 cluster 模块下的 loadbalance 包下,一看即明了。

 

   还是来看具体的实现好玩些!

4.1. 随机负载均衡的实现

/**
 * This class select one provider from multiple providers randomly.
 * You can define weights for each provider:
 * If the weights are all the same then it will use random.nextInt(number of invokers).
 * If the weights are different then it will use random.nextInt(w1 + w2 + ... + wn)
 * Note that if the performance of the machine is better than others, you can set a larger weight.
 * If the performance is not so good, you can set a smaller weight.
 */
public class RandomLoadBalance extends AbstractLoadBalance {
    // 标识自身
    public static final String NAME = "random";

    /**
     * Select one invoker between a list using a random criteria
     * @param invokers List of possible invokers
     * @param url URL
     * @param invocation Invocation
     * @param <T>
     * @return The selected invoker
     */
    @Override
    protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        // Number of invokers
        int length = invokers.size();
        // Every invoker has the same weight?
        boolean sameWeight = true;
        // the weight of every invokers
        int[] weights = new int[length];
        // the first invoker's weight
        int firstWeight = getWeight(invokers.get(0), invocation);
        weights[0] = firstWeight;
        // The sum of weights
        int totalWeight = firstWeight;
        for (int i = 1; i < length; i++) {
            int weight = getWeight(invokers.get(i), invocation);
            // save for later use
            weights[i] = weight;
            // 计算出所有权重和,以便在进行随机时设定范围
            totalWeight += weight;
            if (sameWeight && weight != firstWeight) {
                sameWeight = false;
            }
        }
        // 针对各提供供者权重不一的情况,则找到第一个大于随机数的提供者即可
        if (totalWeight > 0 && !sameWeight) {
            // If (not every invoker has the same weight & at least one invoker's weight>0), select randomly based on totalWeight.
            int offset = ThreadLocalRandom.current().nextInt(totalWeight);
            // Return a invoker based on the random value.
            for (int i = 0; i < length; i++) {
                offset -= weights[i];
                if (offset < 0) {
                    return invokers.get(i);
                }
            }
        }
        // If all invokers have the same weight value or totalWeight=0, return evenly.
        // 如果大家权重都一样,则直接以个数进行随机即可得到提供者
        return invokers.get(ThreadLocalRandom.current().nextInt(length));
    }

}

  稍微有点小技巧的就是,针对不一样权重的随机实现,以相减的方式找到第一个为负的提供者即可。注意,此处计算各提供者权重的算法,倒成了难点了有木有。

 

4.2. 轮询负载均衡的实现

    
/**
 * Round robin load balance.
 */
public class RoundRobinLoadBalance extends AbstractLoadBalance {
    // 自身标识
    public static final String NAME = "roundrobin";
    
    private static final int RECYCLE_PERIOD = 60000;
    
    protected static class WeightedRoundRobin {
        private int weight;
        private AtomicLong current = new AtomicLong(0);
        private long lastUpdate;
        public int getWeight() {
            return weight;
        }
        public void setWeight(int weight) {
            this.weight = weight;
            current.set(0);
        }
        public long increaseCurrent() {
            return current.addAndGet(weight);
        }
        public void sel(int total) {
            current.addAndGet(-1 * total);
        }
        public long getLastUpdate() {
            return lastUpdate;
        }
        public void setLastUpdate(long lastUpdate) {
            this.lastUpdate = lastUpdate;
        }
    }

    private ConcurrentMap<String, ConcurrentMap<String, WeightedRoundRobin>> methodWeightMap = new ConcurrentHashMap<String, ConcurrentMap<String, WeightedRoundRobin>>();
    private AtomicBoolean updateLock = new AtomicBoolean();
    
    /**
     * get invoker addr list cached for specified invocation
     * <p>
     * <b>for unit test only</b>
     * 
     * @param invokers
     * @param invocation
     * @return
     */
    protected <T> Collection<String> getInvokerAddrList(List<Invoker<T>> invokers, Invocation invocation) {
        String key = invokers.get(0).getUrl().getServiceKey() + "." + invocation.getMethodName();
        Map<String, WeightedRoundRobin> map = methodWeightMap.get(key);
        if (map != null) {
            return map.keySet();
        }
        return null;
    }
    
    @Override
    protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        String key = invokers.get(0).getUrl().getServiceKey() + "." + invocation.getMethodName();
        ConcurrentMap<String, WeightedRoundRobin> map = methodWeightMap.get(key);
        if (map == null) {
            methodWeightMap.putIfAbsent(key, new ConcurrentHashMap<String, WeightedRoundRobin>());
            map = methodWeightMap.get(key);
        }
        int totalWeight = 0;
        long maxCurrent = Long.MIN_VALUE;
        long now = System.currentTimeMillis();
        Invoker<T> selectedInvoker = null;
        WeightedRoundRobin selectedWRR = null;
        for (Invoker<T> invoker : invokers) {
            String identifyString = invoker.getUrl().toIdentityString();
            WeightedRoundRobin weightedRoundRobin = map.get(identifyString);
            int weight = getWeight(invoker, invocation);

            if (weightedRoundRobin == null) {
                weightedRoundRobin = new WeightedRoundRobin();
                weightedRoundRobin.setWeight(weight);
                map.putIfAbsent(identifyString, weightedRoundRobin);
            }
            if (weight != weightedRoundRobin.getWeight()) {
                //weight changed
                weightedRoundRobin.setWeight(weight);
            }
            // 自增权重
            long cur = weightedRoundRobin.increaseCurrent();
            weightedRoundRobin.setLastUpdate(now);
            // 获取最大权重项,并以对应的 invoker 作为本次选择的实例
            if (cur > maxCurrent) {
                maxCurrent = cur;
                selectedInvoker = invoker;
                selectedWRR = weightedRoundRobin;
            }
            totalWeight += weight;
        }
        // 当invoker数量发生变化时,需要能感知到,以便清理 map, 避免内存泄露
        if (!updateLock.get() && invokers.size() != map.size()) {
            if (updateLock.compareAndSet(false, true)) {
                try {
                    // copy -> modify -> update reference
                    // 超出计数周期,则清空原来的 WeightedRoundRobin
                    ConcurrentMap<String, WeightedRoundRobin> newMap = new ConcurrentHashMap<>(map);
                    newMap.entrySet().removeIf(item -> now - item.getValue().getLastUpdate() > RECYCLE_PERIOD);
                    methodWeightMap.put(key, newMap);
                } finally {
                    updateLock.set(false);
                }
            }
        }
        if (selectedInvoker != null) {
            // 将本次选中的invoker, 权重置为最低, 以便下次不会再被选中
            selectedWRR.sel(totalWeight);
            return selectedInvoker;
        }
        // should not happen here
        return invokers.get(0);
    }

}

  依次从最大权重的invoker开始选择,然后将选中的项放到最后,轮流选中。使用一个 ConcurrentHashMap 来保存每个url的权重信息,且维护其活跃性。

 

4.3. 最少活跃调用数负载均衡的实现

/**
 * LeastActiveLoadBalance
 * <p>
 * Filter the number of invokers with the least number of active calls and count the weights and quantities of these invokers.
 * If there is only one invoker, use the invoker directly;
 * if there are multiple invokers and the weights are not the same, then random according to the total weight;
 * if there are multiple invokers and the same weight, then randomly called.
 */
public class LeastActiveLoadBalance extends AbstractLoadBalance {
    // 自身标识
    public static final String NAME = "leastactive";

    @Override
    protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        // Number of invokers
        int length = invokers.size();
        // The least active value of all invokers
        int leastActive = -1;
        // The number of invokers having the same least active value (leastActive)
        int leastCount = 0;
        // The index of invokers having the same least active value (leastActive)
        int[] leastIndexes = new int[length];
        // the weight of every invokers
        int[] weights = new int[length];
        // The sum of the warmup weights of all the least active invokers
        int totalWeight = 0;
        // The weight of the first least active invoker
        int firstWeight = 0;
        // Every least active invoker has the same weight value?
        boolean sameWeight = true;


        // Filter out all the least active invokers
        for (int i = 0; i < length; i++) {
            Invoker<T> invoker = invokers.get(i);
            // Get the active number of the invoker
            int active = RpcStatus.getStatus(invoker.getUrl(), invocation.getMethodName()).getActive();
            // Get the weight of the invoker's configuration. The default value is 100.
            int afterWarmup = getWeight(invoker, invocation);
            // save for later use
            weights[i] = afterWarmup;
            // If it is the first invoker or the active number of the invoker is less than the current least active number
            if (leastActive == -1 || active < leastActive) {
                // Reset the active number of the current invoker to the least active number
                leastActive = active;
                // Reset the number of least active invokers
                leastCount = 1;
                // Put the first least active invoker first in leastIndexes
                leastIndexes[0] = i;
                // Reset totalWeight
                totalWeight = afterWarmup;
                // Record the weight the first least active invoker
                firstWeight = afterWarmup;
                // Each invoke has the same weight (only one invoker here)
                sameWeight = true;
                // If current invoker's active value equals with leaseActive, then accumulating.
            } else if (active == leastActive) {
                // Record the index of the least active invoker in leastIndexes order
                leastIndexes[leastCount++] = i;
                // Accumulate the total weight of the least active invoker
                totalWeight += afterWarmup;
                // If every invoker has the same weight?
                if (sameWeight && i > 0
                        && afterWarmup != firstWeight) {
                    sameWeight = false;
                }
            }
        }
        // Choose an invoker from all the least active invokers
        if (leastCount == 1) {
            // 如果只有一个最小则直接返回
            // If we got exactly one invoker having the least active value, return this invoker directly.
            return invokers.get(leastIndexes[0]);
        }
        if (!sameWeight && totalWeight > 0) {
            // If (not every invoker has the same weight & at least one invoker's weight>0), select randomly based on 
            // totalWeight.
            // 如果权重不相同且权重大于0则按总权重数随机
            // 并确定随机值落在哪个片断上(第一个相减为负的值)
            int offsetWeight = ThreadLocalRandom.current().nextInt(totalWeight);
            // Return a invoker based on the random value.
            for (int i = 0; i < leastCount; i++) {
                int leastIndex = leastIndexes[i];
                offsetWeight -= weights[leastIndex];
                if (offsetWeight < 0) {
                    return invokers.get(leastIndex);
                }
            }
        }
        // If all invokers have the same weight value or totalWeight=0, return evenly.
        return invokers.get(leastIndexes[ThreadLocalRandom.current().nextInt(leastCount)]);
    }
}

  官方解释:最少活跃调用数,相同活跃数的随机,活跃数指调用前后计数差,使慢的机器收到更少。

  额,有点难以理解的样子。

 

4.4. 一致性哈希负载均衡的实现

/**
 * ConsistentHashLoadBalance
 */
public class ConsistentHashLoadBalance extends AbstractLoadBalance {
    public static final String NAME = "consistenthash";

    /**
     * Hash nodes name
     * 通过 指定 hash.nodes=0,1,2... 可以自定义参与一致性hash的参数列表
     */
    public static final String HASH_NODES = "hash.nodes";

    /**
     * Hash arguments name
     */
    public static final String HASH_ARGUMENTS = "hash.arguments";
    // 使用selector 保存某个固定状态时 invoker 的映射关系
    private final ConcurrentMap<String, ConsistentHashSelector<?>> selectors = new ConcurrentHashMap<String, ConsistentHashSelector<?>>();

    @SuppressWarnings("unchecked")
    @Override
    protected <T> Invoker<T> doSelect(List<Invoker<T>> invokers, URL url, Invocation invocation) {
        String methodName = RpcUtils.getMethodName(invocation);
        String key = invokers.get(0).getUrl().getServiceKey() + "." + methodName;
        int identityHashCode = System.identityHashCode(invokers);
        ConsistentHashSelector<T> selector = (ConsistentHashSelector<T>) selectors.get(key);
        // 第一次进入或者 identityHashCode 不相等时(invoker环境发生了变化)
        if (selector == null || selector.identityHashCode != identityHashCode) {
            selectors.put(key, new ConsistentHashSelector<T>(invokers, methodName, identityHashCode));
            selector = (ConsistentHashSelector<T>) selectors.get(key);
        }
        return selector.select(invocation);
    }

    private static final class ConsistentHashSelector<T> {

        private final TreeMap<Long, Invoker<T>> virtualInvokers;

        private final int replicaNumber;

        private final int identityHashCode;

        private final int[] argumentIndex;

        ConsistentHashSelector(List<Invoker<T>> invokers, String methodName, int identityHashCode) {
            // 存放虚拟节点
            this.virtualInvokers = new TreeMap<Long, Invoker<T>>();
            this.identityHashCode = identityHashCode;
            URL url = invokers.get(0).getUrl();
            // hash.nodes 默认是 160
            this.replicaNumber = url.getMethodParameter(methodName, HASH_NODES, 160);
            String[] index = COMMA_SPLIT_PATTERN.split(url.getMethodParameter(methodName, HASH_ARGUMENTS, "0"));
            argumentIndex = new int[index.length];
            for (int i = 0; i < index.length; i++) {
                argumentIndex[i] = Integer.parseInt(index[i]);
            }
            for (Invoker<T> invoker : invokers) {
                String address = invoker.getUrl().getAddress();
                for (int i = 0; i < replicaNumber / 4; i++) {
                    byte[] digest = md5(address + i);
                    for (int h = 0; h < 4; h++) {
                        long m = hash(digest, h);
                        virtualInvokers.put(m, invoker);
                    }
                }
            }
        }

        public Invoker<T> select(Invocation invocation) {
            // 取出参与一致性hash计算的参数信息
            String key = toKey(invocation.getArguments());
            byte[] digest = md5(key);
            // 根据hash值选取 invoker
            return selectForKey(hash(digest, 0));
        }

        private String toKey(Object[] args) {
            StringBuilder buf = new StringBuilder();
            // 按照指定的参与hash的参数,调用 toString() 方法,得到参数标识信息
            for (int i : argumentIndex) {
                if (i >= 0 && i < args.length) {
                    buf.append(args[i]);
                }
            }
            return buf.toString();
        }

        private Invoker<T> selectForKey(long hash) {
            // ceilingEntry
            Map.Entry<Long, Invoker<T>> entry = virtualInvokers.ceilingEntry(hash);
            // 如果没有找到,取第一个值
            if (entry == null) {
                entry = virtualInvokers.firstEntry();
            }
            return entry.getValue();
        }

        private long hash(byte[] digest, int number) {
            return (((long) (digest[3 + number * 4] & 0xFF) << 24)
                    | ((long) (digest[2 + number * 4] & 0xFF) << 16)
                    | ((long) (digest[1 + number * 4] & 0xFF) << 8)
                    | (digest[number * 4] & 0xFF))
                    & 0xFFFFFFFFL;
        }

        private byte[] md5(String value) {
            MessageDigest md5;
            try {
                md5 = MessageDigest.getInstance("MD5");
            } catch (NoSuchAlgorithmException e) {
                throw new IllegalStateException(e.getMessage(), e);
            }
            md5.reset();
            byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
            md5.update(bytes);
            return md5.digest();
        }

    }

}

  主要就是取第多少个参数,参与hashCode的计算,然后按照一致性hash算法,定位invoker. 它使用一个 TreeMap 来保存一致性哈希虚拟节点,hashCode->invoker形式存储,使用 ceilingEntry(hash) 的方式获取最近的虚拟节点(天然的一致性hash应用)。

  值得一提的是,一致性哈希负载均衡策略是唯一一个没有使用到权重项的负载均衡算法。而前面几种均衡算法,多少都与权重相关。该负载均衡的应用场景嘛,还得自己找了。

 

5. 更多的负载均衡策略?

  dubbo实现了4种负载均衡策略,是否就只能是这么多呢?一个好的设计是不会的,对扩展开放。基于dubbo的SPI机制,可以自行实现任意的负载均衡策略!

  1. 实现 LoadBalance 接口;
  2. 添加资源文件 添加文件:src/main/resource/META-INFhttps://img.qb5200.com/download-x/dubbo/com.alibaba.dubbo.rpc.cluster.LoadBalance;

  demo=my=com.demo.dubbo.DemoLoadBalance

  3. 设置负载均衡策略为自己实现的demo;

 

6. 有了负载均衡就可以保证高可用了吗?

  当然不能。负载均衡只是保证了在发生调用的时候,可以将流量按照既定规定均摊到各机器上。然而,均摊是不是最合理的方式却是不一定的。另外,如果发生异常,此次负载均衡就失败了,从而成功躲过了高可用。

  事实上,dubbo用三种方式协同保证了高可用:

    1. 负载均衡
    2. 集群容错
    3. 服务路由

  以下故事描述摘自官网:

这3个概念容易混淆。他们都描述了怎么从多个 Provider 中选择一个来进行调用。那他们到底有什么区别呢?下面我来举一个简单的例子,把这几个概念阐述清楚吧。
有一个Dubbo的用户服务,在北京部署了10个,在上海部署了20个。一个杭州的服务消费方发起了一次调用,然后发生了以下的事情:
根据配置的路由规则,如果杭州发起的调用,会路由到比较近的上海的20个 Provider。
根据配置的随机负载均衡策略,在20个 Provider 中随机选择了一个来调用,假设随机到了第7个 Provider。
结果调用第7个 Provider 失败了。
根据配置的Failover集群容错模式,重试其他服务器。
重试了第13个 Provider,调用成功。
上面的第1,2,4步骤就分别对应了路由,负载均衡和集群容错。 Dubbo中,先通过路由,从多个 Provider 中按照路由规则,选出一个子集。再根据负载均衡从子集中选出一个 Provider 进行本次调用。如果调用失败了,根据集群容错策略,进行重试或定时重发或快速失败等。 可以看到Dubbo中的路由,负载均衡和集群容错发生在一次RPC调用的不同阶段。最先是路由,然后是负载均衡,最后是集群容错。 

 

 

  

加载全部内容

相关教程
猜你喜欢
用户评论