通俗易懂的spring事务

        最近一个月在看kafka那本书,之前看mysql和redis都会对学习过程中的知识做一些记录以及自己的看法,但前提是真的对某一个知识点有一定的了解;这个了解不是说一定要到很精通的程度,只要自己对知识点有一定的见解就够了,就像Innodb的缓存池,操作系统和kafka,如果你真的有深入了解过,那你便会豁然开朗,其当中的设计思想无非就是磁盘加缓存,预读加后写;当然其中还有一些很精妙的数据结构的设计结合,这需要感兴趣的人去深挖,在设想一下我们在高并发下的请求入队以及本地缓存和分布式缓存,何尝不是操作系统的扩大版和简化版呢!
        公司内部有技术分享,正好轮到我们组,所以就做了一份PPT用以借鉴,在这里记录下我整理的《spring中事务-原理介绍和应用》,之前有写过mysql中的事务,回头看了一下写的很简洁,所以在这里已编码的方式重新赘述一下。
从事务的隔离级别和spring事务的传播性两个方面入手:

事务的隔离级别

其实单纯的说起事务的隔离级别我们都已经耳熟能祥了,但是我敢说如果没有动手去体验过,相信你在不久的时间间隔内绝对会忘记,甚至有四种还是五种隔离级别你都会记混淆。
在说隔离级别之前,有必要了解下<b>脏写</b>是什么情况;
脏写:一个事务修改了另一个未提交事务修改过的数据。怎么理解呢?先假设user表中id为1的用户年龄为0;

SessionA SessionB
start transaction;
- start transaction;
- update user set age = 2 where id =1
update user set age = 1 where id =1
commit;
- rollback;

如上表展示,当SessionB回滚时将SessionA的修改也会滚了,这样看着A好像啥事也没干,这种情况就称之为脏写;
再说说隔离级别吧!SQL:1992标准中描述了四种事务隔离级别 READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, and SERIALIZABLE;Innodb均支持这四种隔离级别;
其中我们经常说的锁跟隔离级别是分不开的,在不同的隔离级别下innodb使用不同的锁定策略(这篇文章的重点不在锁,数据库中的锁后面会专门写一个章节),接下来我们一个一个看
下面每个点会通过具体的代码描述涉及到的表就一个user表

    create table user(
        id bigint auto_increment primary key ,
        name varchar(32) default null,
        age int default null
    );
    insert into user (name,age) values ('老王',1),('老张',2);

image

脏读

顾名思义,就是未提交读,首先简单的从字面意思来理解无非就是一个事务读了另一个事务未提交的记录;其实从数据库的角度看,从字面意思理解也八九不离十了

SessionA SessionB
start transaction;
- start transaction;
- update user set age = 2 where id =1
select age from user where id =1
commit;
- rollback;

如上所示,当SessionA读取到的age为2时就符合脏读的情况了,也就是在该隔离级别下会发生脏读(不可重复读,幻读)
通过代码看下脏读的情况

     //该方法属于UserService0_1
     public void getUserById(User user) throws InterruptedException {
            //使用默认的TransactionDefined传播性是REQUIRED,不符合条件,所以在这里重新定义了一个,REQURIED_NEW传播特性的事务
            TransactionDefinition transactionDefinition = new MyTranstractionDefined();
            TransactionStatus status = transactionManager.getTransaction(transactionDefinition);
            //在一个事务中读取用户信息并打印
            User id1 = userDao.getUserById(user.getId());
            log.error("---------------"+id1.getName()+"->"+id1.getAge()+"---------");
            transactionManager.commit(status);
     }

    //该方法属于userSerive02
    public void testReadUncommited(User user){
        TransactionDefinition transactionDefinition = new MyTranstractionDefined();
        TransactionStatus status = transactionManager.getTransaction(transactionDefinition);
        try {
            //在当前事务中更新用户
            userDao.updateUserAge(user);
            //开启一个事务读取被更新的数据,注意当前还没有提交
            userService01.getUserById(user);
            //在这里认为制造异常回滚
            int i = 1/0;
            transactionManager.commit(status);
        }catch (Exception e){
            transactionManager.rollback(status);
        }
    }
    
    //测试代码
    @Test
    public void testREAD_UNCOMMITED(){
        User user = new User();
        user.setId(1L);
        user.setAge(2);
        userService02.testReadUncommited(user);
    }

操作步骤

1.首先使用以下命令查看和修改数据库的隔离级别(在第一步先不执行第二条sql)

    #查看数据库隔离级别
    show variables like 'transaction_isolation';
    #设置数据库隔离级别(READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE)
    # global是全局的   session是当前会话
    set global transaction isolation level READ UNCOMMITTED 

建议先执行上述第一条命令查看当前隔离级别,如果之前没有设置过隔离级别,应该是如下图所示

image

2.在REPEATABLE_READ隔离界别下执行测试用例显示如下结果
image

可以看到老王的年龄没有被更新为2,而且读到了更新前的数据,我们先记下结果,执行第三步

3.执行第一步中的第二条sql语句,将数据库隔离级别设置为READ UNCOMMITTED(执行完后要查看事务隔离级别需要关闭当前客户端重新连接)
然后执行测试用例再看下输出结果
image

我们可以看到,读取到的数据跟数据库中的不一致,接下来总结一下

总结

在READ_UNCOMMITTED隔离级别下会发生脏读、不可重复读和幻读,(至于不可重复读和幻读我会在稍后的隔离级别下介绍);从MVCC层面来说就是读到版本链中最新的一条
至于什么是MVCC

不可重复读

定义:一个事务读取到了另一个已提交事务修改过的最新数据那么就发生了不可重复读;单从定义上理解有点扰人心魂,接下来从图表和代码两个方面演示

SessionA SessionB
start transaction;
select age from user where id =1(age=1) -
- update user set age = 2 where id =1
select age from user where id =1(age=2)
commit; -

如上表所示,在事务A中查询id = 1的记录时,读到的age为2,也就是sessionA中读到了SessionB更新后的结果;也就是不可重复读,接下来用代码演示一下


    //该方法属于UserService0_1
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void updateUser(User user){
        userDao.updateUserAge(user);
        log.error("更新成功");
    }
   
   //该方法属于userSerive02
   @Transactional(propagation = Propagation.REQUIRED)
   public void testReadCommitted(User user1) throws InterruptedException {
       User id = userDao.getUserById(user1.getId());
       log.error("first read:"+id.getAge());
       userService01.updateUser(user1);
       Thread.sleep(5000);
       User id1 = userDao.getUserById(user1.getId());
       log.error("second read:"+id1.getAge());
   }
   
   //测试代码
   @Test
   public void testREADCOMMITTED() throws InterruptedException {
       User user1 = new User();
       user1.setId(1L);
       user1.setAge(2);
       userService02.testReadCommitted(user1);

   }

操作步骤

1.将数据库隔离级别设置为 READ UNCOMMITTED|READ COMMITTED

2.执行测试代码得到结果,first read:1;second read:2

总结

在READ_COMMITTED隔离级别下会发生不可重复读和幻读;从MVCC讲就是:READ_COMMITTED隔离级别在每次查询开始时都会生成一个独立的ReadView,
然后creator_trx_id和min_trx_id与max_trx_id做比较再通过roll_pointer回滚;读取小于min_trx_id的一个版本

注意

1.如果在执行上述代码,first read 和second read的结果都一样,检查下mybatis是否禁用掉了一级缓存,如果没有那么请禁用一下

2.代码只演示了不可重复读的情况但是没有演示幻读,但是结论却说有幻读,是不是不太严谨?因为在接下来会演示幻读代码,到时候切换下隔离级别即可,防止啰嗦代码出现

幻读

定义:A事务进行范围查找,此时B事务新增了符合A查找范围的记录,然后被A读取到了这种情况就被称为幻读

SessionA SessionB
start transaction;
select * from user where age >1[{老张:2}] -
- insert into user (name,age) values("老李",3)
select * from user where age > 1[{老张:2},{老李:3}]
commit; -

如上表所示,在事务A中查询age > 1的记录时,读到的[{老张:2},{老李:3}],也就是sessionA中读到了SessionB新增后的结果;也就是幻读,接下来用代码演示一下

    //该方法属于UserService0_1
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void insertUser(User user){
        userDao.insertUser(user);
        log.error("插入成功");
    }
    
    //该方法属于userSerive02
    @Transactional(propagation = Propagation.REQUIRED)
    public void testREPEATABLEREAD(Integer age,User user) throws InterruptedException {
        List<User> users = userDao.listUserByParam(age);
        String first = users.stream().map(User::getName).collect(Collectors.joining(","));
        log.error("first read:"+first);

        userService01.insertUser(user);
        Thread.sleep(5000);

        List<User> users1 = userDao.listUserByParam(age);
        String second = users1.stream().map(User::getName).collect(Collectors.joining(","));
        log.error("second read:"+second);
    }
    //测试方法
    @Test
    public void testREPEATABLEREAD() throws InterruptedException {
        User user = new User();
        user.setAge(3);
        user.setName("老李");
        userService02.testREPEATABLEREAD(1,user);
    }

操作步骤

1.将数据库隔离级别设置为 REPEATABLE READ

2.执行测试代码得到结果,first read:{老张:2};second read:{老张:2}

总结

咦,为什么没有出现幻读的情况?

  1. 有个比较有争议的话题,我们测试REPEATABLE_READ隔离级别的时候看到,并没有发生幻读,有人却说mysql是通过mvcc解决幻读的,这是真的吗?(其实这里还要深究下去)
    怎么说呢? 对于SQL92标准来说REPEATABLE_READ是不能解决幻读的,但是mysql却可以,而且mysql又可以通过mvcc解决的,还可以通过next-key-lock解决。
  2. 针对幻读,还有人说我如果删除了符合条件的数据,结果读出来的数据少了,这个算不算幻读呢?

    答:不算,具体可以参考下SQL92标准的定义

    3.老是mvcc,到底有什么用?

    事务利用MVCC进行的读取操作称之为一致性读,或者一致性无锁读,有的地方也称之为快照读。所有普通的SELECT语句(plain SELECT)在READ COMMITTED、REPEATABLE READ隔离级别下都算是一致性读;
    一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动



  数据库的隔离级别就到这里先告一段落,接下来我们看看spring中事务的传播特性

Spring事务的传播特性

spring中事务相关核心API

如下图所示在spring中事务相关的api都是通过实现或者继承TransactionManager,我们重点的看下PlatformTransactionManager这个类


image

PlatformTransactionManager

image

在上图中我圈出了三个类分别为PlatformTransactionManager、TransactionStatus和TransactionDefinition

1.<b>TransactionStatus:<b/>直译过来是事务的状态,我们点进去看这个类继承了TransactionExecution、SavepointManager、Flushable三个类;分别定义了事务的一些行为比如回滚,是否是新事务,是否完成;事务的回滚点创建回滚等功能;

2.<b>TransactionDefinition:<b/>事务行为的定义,该类中定义了事务的隔离级别和传播特性,接下来详细解释下每个传播特性


    /**
     * 测试 transactionTemplate api
     */
    public void test1(final User user){
        TransactionCallback callback = new TransactionCallbackWithoutResult() {
            @Override
            protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {
                try {
                    //1.发生异常不抓捕时会自动回滚
                    userDao.updateUserAge(user);
                    int x = 4/0;
                }catch (Exception e){
                    //2.不手动指定回滚的话当前修改不会回滚
                    //3.不信的话把catch去掉试试,应该会回滚,
                    transactionStatus.setRollbackOnly();
                }
            }
        };
        transactionTemplate.execute(callback);
    }


    /**
     * 测试 transactionManager api
     */
    public void test2(User user){

        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            userDao.updateUserAge(user);
            //1.事务不手动提交的话是不会更新成功的,不信你试试,这里涉及到了刷脏
            transactionManager.commit(status);
            //提交之后的回滚会有用吗? 自己试试?刷脏完了然后回滚有用
            int x = 4/0;
        }catch (Exception e){
            //2.在这里不手动指定回滚,数据状态还是修改前的  所以这里的回滚有必要吗?
            transactionManager.rollback(status);
        }
    }
    //后续可以自己去调用一下

我们日常开发中是不需要手动去调用spring操作事务的api,最常用到的就是@Transaction;我们经常会看到,很多人在某个方法上加个@Transaction注解用以告慰我们的灵魂
(当然有人甚至注解都不加,不加注解什么后果有想过吗?)但是我们是否想过一个简单的注解真的能达到我们的要求吗?会不会有发生该回滚的没回滚不该回滚的回滚了;还有我们加注解的位置对不对(加private修饰方法上呢?)
?我们在未加注解的方法中调用加了注解的方法是什么情况?这一系列情况我就不通过代码展示了,在这里直接给出结论

image

1.如上图第153行代码所示,改注解只能作用在public修饰符修饰的方法上

2.可在上图所示的TransactionInterceptor类中查看第118行代码可知,我们事务是通过aop环绕增强的且事务失效类是cglib动态代理类,所以在Spring的事务传播策略在内部方法调用时将不起作用

事务的传播特性

事务的传播特性主要通过代码演示;我们将UserService2_1中的方法称为主调方法,UserService2_2中的方法称为被调方法,PropagationTest中的方法为测试方法

REQUIRED


    /**
    *主调方法
    */ 
    @Transactional(propagation = Propagation.REQUIRED,rollbackFor = Exception.class)
    public void testREQUIRED(User user1,User user2){

        try {
            userDao.updateUserAge(user1);
            userService22.testREQUIRED(user2);
        }catch (Exception e){

        }
    }
    
    /**
    *被调方法
    */ 
    @Transactional(propagation = Propagation.REQUIRED,isolation= Isolation.DEFAULT,rollbackFor = Exception.class)
    public void testREQUIRED(User user){
        userDao.updateUserAge(user);
        int i = 1/0;
    }
    
    /**
    *测试方法
    */ 
    @Test
    public void testREQUIRED(){
        User user1 = new User();
        user1.setId(1L);
        user1.setAge(2);
    
    
        User user2 = new User();
        user2.setId(2L);
        user2.setAge(2);
        userService21.testREQUIRED(user1,user2);
    }

上述代码我们分为三个步骤执行:

第一步
    操作:将被调方法中的事务注解去掉,主调方法不变
    结果:主调用方法不会回滚更新成功,被调方法也更新成功
    结论: 一条sql过去就是一个事务,这就是不加注解的后果
第二步
    操作:给被调方法加上注解,主调方法不变
    结果:都会回滚而且抛出UnexpectedRollbackException:Transaction rolled back because it has been marked as rollback-only
    疑问:明明在主调抓了异常为什么还会抛出这个异常呢?
    结论: 因为事务两个公用了一个事务,被调用方法已经标志当前事务应该回滚
第三步
    操作:被调方法的异常抓住,主调方法不变
    结果:都更新成功
    疑问:为啥?
    结论: 注解都声明了异常回滚异常回滚,你都不抛异常回滚个锤子
结论

被调方法和主调方法都用REQUIRED修饰 那么就是两个方法在一个事务中执行,有一个回滚则都回滚,回滚时要显视的看到异常抛出

REQUIRES_NEW


    /**
    *主调方法
    */ 
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void testREQUIRESNEW(User user1,User user2){
        try {
            userDao.updateUserAge(user1);
            userService22.testREQUIRES_NEW(user2);
        }catch (Exception e){

        }
    }
    
    /**
    *被调方法
    */ 
   @Transactional(propagation = Propagation.REQUIRES_NEW,isolation= Isolation.DEFAULT,rollbackFor = Exception.class)
   public void testREQUIRES_NEW(User user){
       userDao.updateUserAge(user);
       int i = 1/0;
   }
    
    /**
    *测试方法
    */ 
    @Test
    public void testREQUIRES_NEW(){
        User user1 = new User();
        user1.setId(1L);
        user1.setAge(2);

        User user2 = new User();
        user2.setId(2L);
        user2.setAge(2);
        userService21.testREQUIRESNEW(user1,user2);
    }

同样分为三个步骤执行:

第一步
    操作:将被调方法中的事务注解去掉,主调方法不变
    结果:主调用方法不会回滚更新成功,被调方法也更新成功
    结论: 一条sql过去就是一个事务,这就是不加注解的后果
第二步
    操作:给被调方法加上注解,主调方法不变
    结果:主调方法更新成功  被调更新失败
    疑问:为什么一个成功一个失败?
    结论: 因为主调方法和被调方法开启了两个事物,互不影响;被调更新失败是因为异常回滚了
第三步
    操作:被调方法的异常抓住,主调方法不变
    结果:都更新成功
    疑问:为啥?
    结论: 注解都声明了异常回滚异常回滚,你都不抛异常回滚个锤子
结论

创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。

NESTED


    /**
    *主调方法
    */ 
    @Transactional(propagation = Propagation.REQUIRED)
    public void  testNESTED(User user1,User user2){
        try {
            userDao.updateUserAge(user1);
            userService22.testNESTED(user2);
        }catch (Exception e){

        }

    }
    
    /**
    *被调方法
    */ 
    @Transactional(propagation = Propagation.NESTED,isolation= Isolation.DEFAULT,rollbackFor = Exception.class)
   public void testNESTED(User user){
       userDao.updateUserAge(user);
       int i = 1/0;
   }
    
    /**
    *测试方法
    */ 
    @Test
    public void testNESTED(){
        User user1 = new User();
        user1.setId(1L);
        user1.setAge(2);


        User user2 = new User();
        user2.setId(2L);
        user2.setAge(2);
        userService21.testNESTED(user1,user2);
    }

同样分为三个步骤执行:

第一步
    操作:将主调方法中的事务注解去掉,被调方法不变
    结果:主调方法不会滚,被调方法回滚
    结论: 如果当前没有事务,则该取值等价于TransactionDefinition.PROPAGATION_REQUIRED 且开启的事务相互独立,互不干扰(对于被动方法而言)
第二步
    操作:主调方法开启事务,被调也开启;让主调方法抛出异常,被调方法抓住异常
    结果:主调方法回滚,被调方法也回滚
    结论: 如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行,即作为当前事务的子事务,父事务回滚子事务也要回滚
第三步
    操作:主调方法开启事务,被调也开启;让主调方法抓住异常,被调方法抛出异常
    结果:主调方法不会滚,被调方法回滚
    结论: 如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行,即作为当前事务的子事务,子事务回滚父事务不用回滚
结论

如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;

如果当前没有事务,则该取值等价于TransactionDefinition.PROPAGATION_REQUIRED 且开启的事务相互独立,互不干扰

MANDATORY


    /**
    *主调方法
    */ 
    @Transactional(propagation = Propagation.REQUIRED)
    public void testMANDATORY(User user1,User user2){
        try {
            userDao.updateUserAge(user1);
            userService22.testMANDATORY(user2);
        }catch (Exception e){

        }
    }
    /**
    *被调方法
    */ 
    @Transactional(propagation = Propagation.MANDATORY,isolation= Isolation.DEFAULT,rollbackFor = Exception.class)
    public void testMANDATORY(User user){
        userDao.updateUserAge(user);
        int i = 1/0;
    }
    
    
   /**
    *测试方法
    */ 
    @Test
    public void testPROPAGATIONMANDATORY(){
        User user1 = new User();
        user1.setId(1L);
        user1.setAge(2);


        User user2 = new User();
        user2.setId(2L);
        user2.setAge(2);
        userService21.testMANDATORY(user1,user2);
    }

分两步骤执行:

第一步
    操作:去掉主调方法的事务声明,被调方法异常抓住
    结果:抛出异常 IllegalTransactionStateException:No existing transaction found for transaction marked with propagation 'mandatory'
    结论: 主调不存在事务抛出异常
第二步
    操作:给被调方法加上注解,被调方法异常抛出
    结果:抛出异常UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only<br/>
    疑问:为什么会抛出异常?
    结论: 加入主调事务,当前事务已经被标记异常,提交时检测到,所以会抛出异常
结论

如果主调存在事务则加入,如果不存在则抛出异常



上面通过代码叙述了几种最常用的隔离级别,具体代码示例请移步 https://github.com/dogYin/resource/tree/master/spring

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