第12章 Dubbo 集群容错的设计与实现

image.png

本节介绍 Dubbo 十层架构中的 Cluster 层。首先来看 Cluster 层包含的组件及其调用关系:Cluster、ClusterInvoker、Directory、Router、LoadBalance。


image.png
  1. 首先使用 Cluster(eg. FailoverCluster) 创建一个具体的 ClusterInvoker(eg. FailoverClusterInvoker);(Cluster 是 ClusterInvoker 的抽象工厂
  2. 之后调用 ClusterInvoker#list 从 Directory(eg. RegistryDirectory)中根据方法名获取具体的 Invoker(eg. DubboInvoker(filtered))列表,然后 Directory 会调用其包含的 List<Router> 进行过滤(此时可实现读写分离等);
  3. 之后调用 ClusterInvoker#select:根据 SPI 机制获取 LoadBalance 实例,之后使用 LoadBalance 实例从 2 中过滤出的 List<Invoker> 中选出最终的一个 Invoker;
  4. 最后调用 ClusterInvoker#doInvoke:调用 3 中选出的 Invoker#invoke(...),发起远程调用。

一、Cluster 接口

image.png

Dubbo 提供了八种不同姿势的 Cluster(分别实现了不同的集群容错策略):

  • FailoverCluster(默认):当请求失败时(如果是抛出业务异常 bizException,则直接抛出异常 RpcException,不再重试),否则重试其他服务器。
  • 可以通过 retries = "2" 来设置重试次数,重试次数为 retries + 1
  • 使用场景:读操作或幂等的写操作。
  • FailfastCluster:当请求失败时,直接抛出 RpcException。
  • 使用场景:非幂等操作
  • FailbackCluster:当请求失败时,会记录在失败列表中,并由一个定时线程池进行定时重试(每5s执行一次重试),如果重试成功,则从失败列表中删除,否则,记录error日志,下一个5s继续重试(如果一直失败,会一直重试)。
  • 使用场景:异步或最终一致性,eg. 通知服务。
  • FailsafeCluster:当请求失败时,会忽略异常。即不关心响应结果的成功与否。
  • 使用场景:eg. 写日志。
  • AvailableCluster:遍历所有节点,找到第一个可用节点,直接请求并返回响应;如果没有可用节点,则抛出异常。(不会对请求做负载均衡,对于 DubboInvoker 来讲,可用 = Invoker 没有被 destroy && 至少有一个 NettyClient 处于已连接状态 && 不是只读)
  • ForkingCluster:同时并行调用多个相同的服务(使用异步线程池),此时主线程等待返回结果(默认等待1s,可通过 timeout 参数进行配置),只要其中一个返回,则立即返回结果;如果全失败了,则抛出异常;如果 timeout 内没返回响应结果,则抛出 timeout 异常
  • 通过配置 forks = "2"(默认为2) 来指定并行调用的服务数量
  • 通过配置 timeout="5000"(默认为1000,即1s)来指定主线程等待并行异步线程返回结果的时间
  • 使用场景:对实时性要求极高。
  • BroadcastCluster:广播调用所有可用的服务,任意一个节点报错则报错
  • MergeableCluster:自动把对多个节点请求的结果进行合并。

Dubbo 还提供了一种 ClusterWrapper(MockClusterWrapper),其包含一个具体的 Cluster(eg. FailoverCluster),通过 AOP 的方式实现了 mock 操作。

@SPI(FailoverCluster.NAME) // 默认 FailoverCluster
public interface Cluster {
    /**
     * 创建 ClusterInvoker,该 ClusterInvoker 持有 Directory 实例。
     * 实际上就是将 Directory 中的多个 Invoker 封装成了一个 Invoker(ClusterInvoker)。
     * 
     * Cluster 是 ClusterInvoker 的抽象工厂。
     */
    @Adaptive
    <T> Invoker<T> join(Directory<T> directory) throws RpcException;
}

Cluster 是 ClusterInvoker 的抽象工厂,用于创建 ClusterInvoker,该 ClusterInvoker 持有 Directory 实例,实际上就是将 Directory 中的多个 Invoker 封装成了一个 Invoker(ClusterInvoker)。

以 FailoverCluster 和 FailfastCluster 为例。

public class FailoverCluster implements Cluster {
    public final static String NAME = "failover";
    @Override
    public <T> Invoker<T> join(Directory<T> directory) throws RpcException {
        return new FailoverClusterInvoker<T>(directory);
    }
}

public class FailfastCluster implements Cluster {
    public final static String NAME = "failfast";
    @Override
    public <T> Invoker<T> join(Directory<T> directory) throws RpcException {
        return new FailfastClusterInvoker<T>(directory);
    }
}

二、ClusterInvoker

image.png

针对每一种 Cluster,Dubbo 都提供了对应的 ClusterInvoker(该 ClusterInvoker 由对应的 Cluster 来创建),其中 MockClusterInvoker 和 MergeableClusterInvoker 较为特殊,直接实现了 Invoker 接口;其他的 ClusterInvoker 都继承与模板基类 AbstractClusterInvoker,复写了其中的 doInvoker(...) 方法。

MockClusterInvoker 和 MergeableClusterInvoker 较为特殊,后续分析。

AbstractClusterInvoker 模板基类

public abstract class AbstractClusterInvoker<T> implements Invoker<T> {
    protected final Directory<T> directory;
    ...
    public AbstractClusterInvoker(Directory<T> directory) {
        this(directory, directory.getUrl());
    }
    ...
    @Override
    public Result invoke(Invocation invocation) throws RpcException {
        ...
        LoadBalance loadbalance = null;
        ...
        /**
         * 1. 从 Directory(eg. RegistryDirectory)中根据方法名获取具体的 Invoker(eg. DubboInvoker(filtered))列表
         *    然后 Directory 会调用其包含的 List<Router> 进行过滤
         */
        List<Invoker<T>> invokers = list(invocation);
        if (invokers != null && !invokers.isEmpty()) {
            /**
             * 2. 根据 SPI 机制获取 LoadBalance 实例
             */
            loadbalance = ExtensionLoader.getExtensionLoader(LoadBalance.class).getExtension(invokers.get(0).getUrl().getMethodParameter(RpcUtils.getMethodName(invocation), "loadbalance", "random"));
        }
        ...
        /**
         * 3. 调用子类实现负载均衡选择和rpc调用
         */
        return doInvoke(invocation, invokers, loadbalance);
    }

    /**
     * 从 Directory(eg. RegistryDirectory)中根据方法名获取具体的 Invoker(eg. DubboInvoker(filtered))列表,
     * 然后 Directory 会调用其包含的 List<Router> 进行过滤
     */
    protected List<Invoker<T>> list(Invocation invocation) throws RpcException {
        return directory.list(invocation);
    }

    /**
     * 调用子类实现负载均衡选择和rpc调用
     */
    protected abstract Result doInvoke(Invocation invocation, List<Invoker<T>> invokers,
                                       LoadBalance loadbalance) throws RpcException;

    /**
     * 选择一个 Invoker。
     * a) 首先, 使用 loadbalance 选择一个 Invoker。如果该 Invoker 之前已经被选择了(在 selected List 中)或者该 Invoker 不可用(eg. DubboInvoker,netty 没有处于连接状态),则进行重新选择操作(reselect);否则返回直接返回该 Invoker;
     * b) 重新选择操作(reselect):
     *     1. 使用 lb 从可用的且之前没有被选择的Invoker列表中选择一个,如果成功,直接返回,否则;
     *     2. 使用 lb 从可用的且之前被选择的Invoker列表中选择一个,如果成功,直接返回
     * c) 如果 reselect 还没有成功的话,则从 invokers(被 List<Router> 过滤过的)列表中选出 a) 中的选出的 Invoker 的下一个 Invoker
     */
    protected Invoker<T> select(LoadBalance loadbalance, Invocation invocation, List<Invoker<T>> invokers, List<Invoker<T>> selected) throws RpcException {
        // 1. 处理 sticky 逻辑
        ...
        // 2. 真正的进行选择
        Invoker<T> invoker = doSelect(loadbalance, invocation, invokers, selected);
        ...
        return invoker;
    }

    private Invoker<T> doSelect(LoadBalance loadbalance, Invocation invocation, List<Invoker<T>> invokers, List<Invoker<T>> selected) throws RpcException {
        ...
        // 如果只有一个 Invoker,直接返回,否则进行选择操作
        if (invokers.size() == 1) {
            return invokers.get(0);
        }
        if (loadbalance == null) {
            loadbalance = ExtensionLoader.getExtensionLoader(LoadBalance.class).getExtension("random");
        }
        // 1. 使用 loadbalance 进行选择
        Invoker<T> invoker = loadbalance.select(invokers, getUrl(), invocation);

        // 2. 如果该 Invoker 之前已经被选择了(在 selected List 中)或者该 Invoker 不可用(eg. DubboInvoker,netty 没有处于连接状态),则进行重新选择操作(reselect(...))
        if ((selected != null && selected.contains(invoker)) || (!invoker.isAvailable() && getUrl() != null && availablecheck)) {
            // 2.1. 进行重新选择,如果选择成功,则直接返回;否则获取当前选出的 Invoker 的下一个 Invoker
            Invoker<T> rinvoker = reselect(loadbalance, invocation, invokers, selected, availablecheck);
            if (rinvoker != null) {
                invoker = rinvoker;
            } else {
                int index = invokers.indexOf(invoker);
                invoker = index < invokers.size() - 1 ? invokers.get(index + 1) : invokers.get(0);
            }
        }
        return invoker;
    }

    /**
     * 重新选择操作(reselect):
     * 1. 使用 lb 从可用的且之前没有被选择的Invoker列表中选择一个,如果成功,直接返回,否则;
     * 2. 使用 lb 从可用的且之前被选择的Invoker列表中选择一个,如果成功,直接返回
     */
    private Invoker<T> reselect(LoadBalance loadbalance, Invocation invocation, List<Invoker<T>> invokers, List<Invoker<T>> selected, boolean availablecheck) throws RpcException {
        ...
    }
    ...
}

选择部分的流程相对复杂,流程图如下:

image.png

1、FailoverClusterInvoker

三种使用姿势
<!-- 1. 所有的消费者使用 FailoverCluster,且重试次数为6次(retries+1) -->
<dubbo:consumer cluster="failover" retries="5"/>
<!-- 2. 指定的消费者的所有方法使用 FailoverCluster,且重试次数为6次(retries+1) -->
<dubbo:reference ... cluster="failover" retries="5"/>
<!-- 3. 指定的消费者使用 FailoverCluster,且指定方法 sayHello 的重试次数为5次(retries+1),其余方法使用默认值 -->
<dubbo:reference ... cluster="failover">
    <dubbo:method name="sayHello" retries="5"/>
</dubbo:reference>
  • retries 默认为2,即重试3次
public class FailoverClusterInvoker<T> extends AbstractClusterInvoker<T> {
    ...
    RpcException le = null; // last exception.
    @Override
    public Result doInvoke(Invocation invocation, final List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
        List<Invoker<T>> copyinvokers = invokers;
        // 默认重试3次(retries+1)
        int len = getUrl().getMethodParameter(invocation.getMethodName(), "retries", 2) + 1;
        ...
        // 已选择列表
        List<Invoker<T>> invoked = new ArrayList<Invoker<T>>(copyinvokers.size());
        for (int i = 0; i < len; i++) {
            // 每一次重新选择都要重置 copyinvokers:重新从 Directory 根据方法名进行 Invoker(filtered) 的获取。
            // 因为有可能此时提供者列表已经发生了变化,提供者列表发生变化,Directory 也会更新其缓存的 Invoker(filtered) 列表。
            if (i > 0) {
                copyinvokers = list(invocation);
            }
            // 1. 调用父类 AbstractClusterInvoker 的选择机制进行选择
            Invoker<T> invoker = select(loadbalance, invocation, copyinvokers, invoked);
            // 2. 将选择出来的 Invoker 添加到已选择列表中,供下一次选择时使用
            invoked.add(invoker);
            RpcContext.getContext().setInvokers((List) invoked);
            try {
                // 3. 发起 rpc 调用,如果抛出异常(业务异常,直接抛出,不再重试),否则,catch 住,进行下一个 Invoker 的重试
                Result result = invoker.invoke(invocation);
                return result;
            } catch (RpcException e) {
                if (e.isBiz()) { // biz exception.
                    throw e;
                }
                le = e;
            } catch (Throwable e) {
                le = new RpcException(e.getMessage(), e);
            }
        }
        throw new RpcException(...le.getCause());
    }
}

2、FailbackClusterInvoker

public class FailbackClusterInvoker<T> extends AbstractClusterInvoker<T> {
    /**
     * 重试线程池
     */
    private final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(2, new NamedInternalThreadFactory("failback-cluster-timer", true));
    /**
     * 调用失败列表
     */
    private final ConcurrentMap<Invocation, AbstractClusterInvoker<?>> failed = new ConcurrentHashMap<Invocation, AbstractClusterInvoker<?>>();
    private volatile ScheduledFuture<?> retryFuture;
    ...
    private void addFailed(Invocation invocation, AbstractClusterInvoker<?> router) {
        // 1. 双重检测创建并启动定时重试任务
        if (retryFuture == null) {
            synchronized (this) {
                if (retryFuture == null) {
                    retryFuture = scheduledExecutorService.scheduleWithFixedDelay(new Runnable() {
                        @Override
                        public void run() {
                            try {
                                retryFailed();
                            } catch (Throwable t) { // Defensive fault tolerance
                                logger.error("Unexpected error occur at collect statistic", t);
                            }
                        }
                    }, 5 * 1000, 5 * 1000, TimeUnit.MILLISECONDS);
                }
            }
        }
        // 2. 将失败的调用加入到失败列表中
        failed.put(invocation, router);
    }

    void retryFailed() {
        if (failed.size() == 0) {
            return;
        }
        for (Map.Entry<Invocation, AbstractClusterInvoker<?>> entry : new HashMap<Invocation, AbstractClusterInvoker<?>>(failed).entrySet()) {
            Invocation invocation = entry.getKey();
            Invoker<?> invoker = entry.getValue();
            try {
                // 对于失败的 Invoker,进行重试:如果重试成功,则从失败列表中删除,否则,记录error日志,下一个5s继续重试(如果一直失败,会一直重试)
                invoker.invoke(invocation);
                failed.remove(invocation);
            } catch (Throwable e) {
                logger.error(...);
            }
        }
    }

    @Override
    protected Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
        try {
            checkInvokers(invokers, invocation);
            // 1. 调用父类 AbstractClusterInvoker 的选择机制进行选择
            Invoker<T> invoker = select(loadbalance, invocation, invokers, null);
            // 2. 发起 rpc 调用,如果失败,添加到失败列表,后续进行定时重试。返回空 RpcResult
            return invoker.invoke(invocation);
        } catch (Throwable e) {
            logger.error("Failback to invoke method " + invocation.getMethodName() + ", wait for retry in background. Ignored exception: "
                    + e.getMessage() + ", ", e);
            // 如果失败,添加到失败列表,后续进行定时重试
            addFailed(invocation, this);
            return new RpcResult(); // ignore
        }
    }
}

3、FailfastClusterInvoker

public class FailfastClusterInvoker<T> extends AbstractClusterInvoker<T> {
    ...
    @Override
    public Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
        // 1. 调用父类 AbstractClusterInvoker 的选择机制进行选择
        Invoker<T> invoker = select(loadbalance, invocation, invokers, null);
        try {
            // 2. 发起 rpc 调用,如果失败,直接抛出异常
            return invoker.invoke(invocation);
        } catch (Throwable e) {
            ...
            throw new RpcException(...);
        }
    }
}

4、FailsafeClusterInvoker

public class FailsafeClusterInvoker<T> extends AbstractClusterInvoker<T> {
    ...
    @Override
    public Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
        try {
            // 1. 调用父类 AbstractClusterInvoker 的选择机制进行选择
            Invoker<T> invoker = select(loadbalance, invocation, invokers, null);
            // 2. 发起 rpc 调用,如果失败,直接忽略,返回空的 RpcResult
            return invoker.invoke(invocation);
        } catch (Throwable e) {
            return new RpcResult(); // ignore
        }
    }
}

5、AvailableClusterInvoker

public class AvailableClusterInvoker<T> extends AbstractClusterInvoker<T> {
    ...
    @Override
    public Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
        // 找出第一个可用的 Invoker,直接发起 rpc 调用(没有负载均衡)
        for (Invoker<T> invoker : invokers) {
            if (invoker.isAvailable()) {
                return invoker.invoke(invocation);
            }
        }
        throw new RpcException("No provider available in " + invokers);
    }
}

对于 DubboInvoker,可用的逻辑如下:

    @Override
    public boolean isAvailable() {
        // AbstractInvoker:Invoker 没有被 destroy,则可用
        if (!super.isAvailable()) {
            return false;
        }
        // 只要有一个 NettyClient 处于已连接状态 && 不是只读,则可用
        for (ExchangeClient client : clients) {
            if (client.isConnected() && !client.hasAttribute(Constants.CHANNEL_ATTRIBUTE_READONLY_KEY)) {
                return true;
            }
        }
        return false;
    }

6、ForkingClusterInvoker

两种使用姿势
<!-- 1. 所有的消费者使用ForkingCluster,且同时并发调用3个Invoker(forks),并且等待结果返回时间为5s -->
<dubbo:consumer cluster="forking" forks="3" timeout="5000"/>
<!-- 2. 指定的消费者使用ForkingCluster,且同时并发调用3个Invoker(forks),并且等待结果返回时间为5s -->
<dubbo:reference ... cluster="forking" forks="3" timeout="5000"/>
public class ForkingClusterInvoker<T> extends AbstractClusterInvoker<T> {
    /** 异步并行调用线程池 */
    private final ExecutorService executor = Executors.newCachedThreadPool(new NamedInternalThreadFactory("forking-cluster-timer", true));
    ...
    @Override
    public Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
        List<Invoker<T>> selected; // 选择多少个Invoker进行调用
        int forks = getUrl().getParameter("forks", 2);
        int timeout = getUrl().getParameter("timeout", 1000);
        if (forks <= 0 || forks >= invokers.size()) {
            selected = invokers; // 选择全部Invoker进行调用
        } else {
            selected = new ArrayList<Invoker<T>>();
            // 1. 多次调用父类 AbstractClusterInvoker 的选择机制进行 Invoker 的选择,并添加到 List<Invoker<T>> selected 中
            for (int i = 0; i < forks; i++) {
                Invoker<T> invoker = select(loadbalance, invocation, invokers, selected);
                if (!selected.contains(invoker)) { // 去重操作
                    selected.add(invoker);
                }
            }
        }
        ...
        final AtomicInteger count = new AtomicInteger();
        final BlockingQueue<Object> ref = new LinkedBlockingQueue<Object>();
        for (final Invoker<T> invoker : selected) {
            executor.execute(new Runnable() {
                public void run() {
                    try {
                        // 2. 使用线程池并行发起多次调用,并将调用结果存储到ref阻塞队列中;如果全部调用都失败了,则将失败异常也存储到ref中
                        Result result = invoker.invoke(invocation);
                        ref.offer(result);
                    } catch (Throwable e) {
                        int value = count.incrementAndGet();
                        // 全部调用都失败了
                        if (value >= selected.size()) {
                            ref.offer(e);
                        }
                    }
                }
            });
        }
        // 3. 主线程从阻塞队列弹出第一个对象(阻塞等待1s,即1s内如果调用无法完成,则抛出 timeout 异常;如果1s内有任一个调用返回,则直接处理),如果得到的是异常,则直接抛出,否则,返回调用结果
        Object ret = ref.poll(timeout, TimeUnit.MILLISECONDS);
        if (ret instanceof Throwable) {
            throw new RpcException(...ret...);
        }
        return (Result) ret;
    }
}

7、BroadcastClusterInvoker

public class BroadcastClusterInvoker<T> extends AbstractClusterInvoker<T> {
    ...
    @Override
    public Result doInvoke(final Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException {
        RpcException exception = null;
        Result result = null;
        // 对每一个节点进行调用,只要有一个失败,直接抛出异常
        for (Invoker<T> invoker : invokers) {
            try {
                result = invoker.invoke(invocation);
            }  catch (Throwable e) {
                exception = new RpcException(e.getMessage(), e);
            }
        }
        if (exception != null) {
            throw exception;
        }
        return result;
    }
}

8、MergeableCluster

在合并器 merger 部分进行分析。

9、MockClusterInvoker

在服务降级 mock 部分进行分析。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,463评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,868评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,213评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,666评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,759评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,725评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,716评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,484评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,928评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,233评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,393评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,073评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,718评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,308评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,538评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,338评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,260评论 2 352

推荐阅读更多精彩内容