(五)Mybatis-缓存解析

1、概述

Mybatis的缓存大体上分为一级缓存和二级缓存,我们先来说下一级缓存。

2、一级缓存

  当我们使用Mybatis对数据库进行一次查询操作的时候,会通过SqlSession来表示一次数据库会话。在每次会话中,可能会对数据库执行相同的SQL查询操作,而我们也知道,对数据库频繁操作是很耗费性能的,因为数据库中的数据是持久化再磁盘上的。Web工程最大的瓶颈就在于对磁盘文件的I/O操作,因为学过计算机的都了解,I/O操作比内存操作速度差了恐怕几个量级。

而为了避免相同sql的多次数据库查询操作,Mybatis提供了一个简单的缓存机制。将每次sql查询的结果缓存起来,下次相同sql执行的时候直接查询缓存。缓存中存在,从缓存中获取后直接返回,缓存中不存在,查询数据库将查询结果放入缓存并返回。我们把这种一次会话级别的缓存称为一级缓存。

2.1 实现

  一级缓存在Mybatis中是通过SqlSession中的Executor来维护的,上文我们已经了解过Executor了,这次不再详述了。在BaseExecutor中,维护了一个PerpetualCache的localCache,来实现一级缓存的功能。

2.1.1 首先,我们先来看下PerpetualCache的实现。

PerpetualCache的实现很简单,实现了Mybatis的Cache接口。Mybatis的Cache接口是用于缓存的接口,一般与缓存相关的类都应该实现这个接口。PerpetualCache内部维护了一个HashMap来实现缓存的功能:

public class PerpetualCache implements Cache {
  private String id;
  private Map<Object, Object> cache = new HashMap<Object, Object>();
}

id是一个名为LocalCache的字符串,而Map用来存储数据,key也就是接下来会说到的CacheKey,value则是查询到的数据。

2.1.2 然后我们来看下缓存的实现流程,我们从BaseExecutor的query方法看起。
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    BoundSql boundSql = ms.getBoundSql(parameter);
    // 获取缓存的key
    CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
    return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}

这里,比较重要的一点是缓存key的创建,如何确定相同sql的key值是相同的?这里会涉及到用于存储缓存key的CacheKey类。我们先来简单看下CacheKey,再来看下createCacheKey方法。

CacheKey

我们来看下CacheKey内部的实现:

public class CacheKey implements Cloneable, Serializable {
  ...
  public static final CacheKey NULL_CACHE_KEY = new NullCacheKey();

  private static final int DEFAULT_MULTIPLYER = 37;
  private static final int DEFAULT_HASHCODE = 17;

  // 这个是用于hashcode计算时的扩展因子,默认37
  private int multiplier;
  // 计算hashcode
  private int hashcode;
  // 生成key的各项参数的默认hashcode的总和
  private long checksum;
  // 计数
  private int count;
  // 生成key的各项参数
  private List<Object> updateList;
}

CacheKey内部有一些属性,用于生成cacheKey及获取时的校验。由于HashMap的get方法是先判断hashCode再equals进行判断,所以我们可以简单看下CacheKey中对hashcode的处理及equals方法。

private void doUpdate(Object object) {
    // 对象默认的hashcode
    int baseHashCode = object == null ? 1 : object.hashCode();
    // 计数
    count++;
    // 所有对象的hashcode相加
    checksum += baseHashCode;
    // 对象的hashcode扩大count倍
    baseHashCode *= count;
    // 根据扩展因子扩展,然后加上扩大后的对象的hashcode
    hashcode = multiplier * hashcode + baseHashCode;
    // 添加对象到list中
    updateList.add(object);
}

由于生成key的时候最终方法会调用到doUpdate,我们只需看下doUpdate方法,了解它的hashcode是如何生成的即可。我们再来看下equals方法:

public boolean equals(Object object) {
    if (this == object) {
      return true;
    }
    if (!(object instanceof CacheKey)) {
      return false;
    }

    final CacheKey cacheKey = (CacheKey) object;
    if (hashcode != cacheKey.hashcode) {
      return false;
    }
    if (checksum != cacheKey.checksum) {
      return false;
    }
    if (count != cacheKey.count) {
      return false;
    }

    for (int i = 0; i < updateList.size(); i++) {
      Object thisObject = updateList.get(i);
      Object thatObject = cacheKey.updateList.get(i);
      if (thisObject == null) {
        if (thatObject != null) {
          return false;
        }
      } else {
        if (!thisObject.equals(thatObject)) {
          return false;
        }
      }
    }
    return true;
}

equals方法会对CachKey的各个属性进行比较判断,并且会循环判断updateList中的每个元素,通过这种方式来保证key的唯一性。


createCacheKey相关

简单看了CacheKey后,我们再来看下createCacheKey方法,了解一下CacheKey的创建规则。

public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    CacheKey cacheKey = new CacheKey();
    // 1. MappedStatement的id
    cacheKey.update(ms.getId());
    // 2. 查询的分页参数 offset和limit
    cacheKey.update(Integer.valueOf(rowBounds.getOffset()));
    cacheKey.update(Integer.valueOf(rowBounds.getLimit()));
    // 3. sql语句
    cacheKey.update(boundSql.getSql());
    // 4. 传递给JDBC的参数
    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
    TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
    // mimic DefaultParameterHandler logic
    // 解析参数
    for (int i = 0; i < parameterMappings.size(); i++) {
      ParameterMapping parameterMapping = parameterMappings.get(i);
      if (parameterMapping.getMode() != ParameterMode.OUT) {
        Object value;
        String propertyName = parameterMapping.getProperty();
        if (boundSql.hasAdditionalParameter(propertyName)) {
          value = boundSql.getAdditionalParameter(propertyName);
        } else if (parameterObject == null) {
          value = null;
        } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
          value = parameterObject;
        } else {
          MetaObject metaObject = configuration.newMetaObject(parameterObject);
          value = metaObject.getValue(propertyName);
        }
        cacheKey.update(value);
      }
    }
    // 5. 如果mybatis-config配置的environment不为空,取environment的id
    if (configuration.getEnvironment() != null) {
      // issue #176
      cacheKey.update(configuration.getEnvironment().getId());
    }
    return cacheKey;
}

createCacheKey方法表明了缓存key的生成规则,拿我们来看一下生成cacheKey的一些条件。

  1. MappedStatement的id。所谓id,即是Mybatis的映射文件中,每个select节点的namespace及名称,有了它,我们才能确定执行的是哪一条sql,我们拿上文的实例来看一下id:id="com.mapper.IStudentMapper.getAll"
  2. offset及limit。这里就与Mybatis的分页有关系了,Mybatis的分页功能是通过RowBounds来实现的,而RowBounds则是通过offset和limit属性来实现分页,而这种分页功能是基于查询结果的再过滤,而不是进行数据库的物理分页;
  3. sql语句。Mybatis的sql语句是通过BoundSql来实现的,这个就比较好理解了,sql语句不一样,那key肯定不会相同;
  4. 参数。也就是说,调用JDBC的时候,sql语句要一样,传递的参数也要完全一样,这样才是相同的sql。
  5. environment的id。这里大致说一点:这里配置的id是每个环境的id,可能开发,测试环境等。

针对environment的id,我们看下官网的解释就明白了:

MyBatis 可以配置成适应多种环境,这种机制有助于将 SQL 映射应用于多种数据库之中, 现实情况下有多种理由需要这么做。例如,开发、测试和生产环境需要有不同的配置;或者共享相同 Schema 的多个生产数据库, 想使用相同的 SQL 映射。许多类似的用例。
节选自:配置环境(environments)


query和queryFromDatabase方法

获取到缓存的key之后,接下来的操作就比较简单了。我们接着来看query方法:

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    ...
    // 如果mapper节点中配置了flushCache=true,就清空缓存
    // queryStack 参数应该是用于延迟加载用的
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }
    List<E> list;
    try {
      queryStack++;
      // 从缓存localCache中获取
      list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
      // 如果获取到
      if (list != null) {
        // 针对存储过程的参数进行缓存
        handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
      } else {
        // 如果获取不到,查询数据库
        list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
      }
    } finally {
      queryStack--;
    }
    ...
    return list;
}

这里也说明了一点,如果我们不想从缓存里查询,只想查询数据库,那么只需要配置对应节点的flushCache=true即可了。

<select id="getAll" resultType="Student2" statementType="CALLABLE" flushCache="true">
    SELECT * FROM Student
</select>

我们接着来看下queryFromDatabase方法,这个方法就是缓存数据库查询的结果并返回:

private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    List<E> list;
    localCache.putObject(key, EXECUTION_PLACEHOLDER);
    try {
      list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
    } finally {
      localCache.removeObject(key);
    }
    localCache.putObject(key, list);
    // 如果statementType类型是callable,则缓存存储过程的参数
    if (ms.getStatementType() == StatementType.CALLABLE) {
      localOutputParameterCache.putObject(key, parameter);
    }
    return list;
}

这里有一个问题,我以前就遇到过:

就是说 我们查询完成之后,localCache直接缓存我们查询的结果,并没有拷贝或者怎么处理,然后这个结果又被直接返回了。但是由于引用的关系,这里就会出现一种情况,就是外部修改了这个结果,缓存中的值也会跟着发生变化。这样的话,可能会出现我们意想不到的结果,所以这里可以注意一下。

2.1.3 我们再简单看下 insert,update,delete方法

我们随便看下这几个方法的实现,可以看到它们底层都是通过调用update方法来实现的,我们来看下BaseExecutor中update方法的实现:

// update方法实现
public int update(MappedStatement ms, Object parameter) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    // 清空缓存
    clearLocalCache();
    return doUpdate(ms, parameter);
}

在update方法中我们可以看到,每次进行insert,update,delete之后,就会进行清空缓存操作。

2.2 如何清除一级缓存或者说不使用一级缓存

其实,我们从query方法的源码中就可以找到解决方式,我们再来看下query源码:

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    ...
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }
    ...
      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        // issue #482
        clearLocalCache();
      }
    }
    return list;
}

从这里,我们可以看到两种解决方式:

  1. 配置映射文件中节点的 flushCache属性,设置为true
  2. 配置mybatis-config.xml中的localCacheScopeSTATEMENT

针对第二种方式可以简单说下:
Mybatis一级缓存的范围有SESSION 和STATEMENT两种,默认是SESSION。我们配置为STATEMENT,这样每次执行完一个对应的Mapper方法后,就会将缓存清空:

<setting name="localCacheScope" value="STATEMENT"/>
2.3 适用场景
  1. 单从一级缓存来看,它只是对HashMap的操作,并且没有容量的大小限制,所以存在HashMap占用内存太大,导致内存溢出的可能;但一般情况下,每个SqlSession的生命周期很短,并且只要执行相应的update方法,缓存就会被清空,当然我们也可以手动清空缓存,所以正常情况下一般不会出现缓存过大,内存溢出的情况;
  1. 所以我们在使用一级缓存的时候还是要注意下:对于时效性很高的数据,我们要控制好SqlSession的生存时间,SqlSession的生存时间越长,它其中缓存的数据有可能就越旧,从而造成和真实数据库的误差。对于这种情况,我们可以适时的手动清空缓存;对于特定的查询,我们也可以配置flushCache属性,对该条SQL语句不适用一级缓存;
2.4 一级缓存总结

到这里,一级缓存的学习就差不多结束了,我们来总结下,然后开始学习二级缓存。

  1. Mybatis的一级缓存是SqlSession级别的,而缓存的维护则是通过Executor来实现的,当一次会话结束(比如调用了close方法)后,相应的一级缓存也会被清除;
  2. 对于一级缓存中的数据,由于引用的关系,如果外部修改了这个结果,那缓存中的值也会跟着发生变化,注意下这种情况;
  3. 如果不想使用一级缓存,可以配置映射文件中节点的flushCache属性为true或者配置全局文件的localCacheScopeSTATEMENT

3、二级缓存

我们现在来开始一下二级缓存。二级缓存是Application级别的缓存,默认是开启的,我们可以通过配置cacheEnabled参数来关闭二级缓存:

<setting name="cacheEnabled" value="false"/>

Mybatis中的二级缓存适用的是Executor接口的另一个实现类:CachingExecutor。前文已经学习过如何获取CachingExecutor,现在再来简单看一下:

public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
    executorType = executorType == null ? defaultExecutorType : executorType;
    executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
    Executor executor;
    if (ExecutorType.BATCH == executorType) {
      executor = new BatchExecutor(this, transaction);
    } else if (ExecutorType.REUSE == executorType) {
      executor = new ReuseExecutor(this, transaction);
    } else {
      executor = new SimpleExecutor(this, transaction);
    }
    // 配置cacheEnabled
    if (cacheEnabled) {
      executor = new CachingExecutor(executor);
    }
    executor = (Executor) interceptorChain.pluginAll(executor);
    return executor;
  }

在Configuration的newExecutor方法中,我们通过cacheEnabled参数来判断是否开启了二级缓存,如果开启的话,Mybatis就将通过CachingExecutor来完成操作,而CachingExecutor通过适用装饰者模式,在内部包装了一个Executor的实例来进行实际的操作:

// 包装的实际执行器
private Executor delegate;
// 事务缓存数据
private TransactionalCacheManager tcm = new TransactionalCacheManager();

而对于实际用于缓存数据的 TransactionalCacheManager 类,其实底层也是通过HashMap来实现的。其中map的key是每个节点的Cache对象,value是TransactionalCache对象,感兴趣的童鞋可以看下该类:

public class TransactionalCacheManager {
  private Map<Cache, TransactionalCache> transactionalCaches = new HashMap<Cache, TransactionalCache>();
}
public class TransactionalCache implements Cache {
  private static final Log log = LogFactory.getLog(TransactionalCache.class);
    
  // 包装的缓存对象
  private Cache delegate;
  private boolean clearOnCommit;
  private Map<Object, Object> entriesToAddOnCommit;
  private Set<Object> entriesMissedInCache;
}
3.1 二级缓存配置及条件

对整个Application而言,Mybatis二级缓存并不是只有一份,对每个Mapper文件都会有一个<cache>节点,只要配置了这个节点的话,那这个mapper文件就会对应一个Cache对象。

当然,我们也可以对多个Mapper文件公用一个Cache,需要配置一下<cache-ref>节点,指定它的namespace属性;

<cache></cache>
<cache-ref namespace="" ></cache-ref>

当然,如果我们同时配置了cache和cache-ref节点的话,那么Mybatis中cache节点的优先级是高于cache-ref的,所以Mybatis会选择cache节点。

  1. Mybatis的二级缓存的粒度很细,它可以指定某一条查询语句是否可以使用二级缓存。
  2. 虽然在Mapper中配置了<cache>,并且为此Mapper分配了Cache对象,这并不表示这个Mapper中的任一条sql语句查到的结果都会放置到Cache对象之中,只有指定了`useCache="true"的<select>节点才会走二级缓存。
<cache></cache>
<select id="getAll" resultType="Student2" useCache="true">
    SELECT * FROM Student
</select>

也就是说,如果要使某个<select>节点支持二级缓存,要满足以下三个条件:

  1. Mybatis开启了二级缓存:cacheEnabled=true
  2. 该select所在的mapper,配置了cache或cache-ref节点;
  3. 该select节点配置了useCache=true属性;
3.1.2 源码分析

接下来,我们来通过源码来查看一下二级缓存:

public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
      throws SQLException {
    // 获取该mapper对象的cache节点
    Cache cache = ms.getCache();
    if (cache != null) {
      // 清空二级缓存
      flushCacheIfRequired(ms);
      if (ms.isUseCache() && resultHandler == null) {
        ensureNoOutParams(ms, parameterObject, boundSql);
        @SuppressWarnings("unchecked")
        List<E> list = (List<E>) tcm.getObject(cache, key);
        if (list == null) {
          list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        return list;
      }
    }
    return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

我们来看一下二级缓存的流程:

  1. 首先,我们通过MappedStatement的getCache()方法获取mapper文件的cache节点;
  2. 如果该cache节点不存在,调用BaseExecutor的query方法执行一级缓存相关的操作;如果存在,先根据节点的flushCache属性来确定是否清除该节点的二级缓存;
private void flushCacheIfRequired(MappedStatement ms) {
    Cache cache = ms.getCache();
    if (cache != null && ms.isFlushCacheRequired()) {      
      tcm.clear(cache);
    }
}
  1. 通过ensureNoOutParams方法来确保二级缓存不会存储存储过程相关的查询结果;
  2. 从事务缓存对象tcm中获取数据,这里同样用到了装饰者模式,最终会从TranactionalCache中包装的缓存对象Cache的实例对象中获取数据。
  3. 如果二级缓存中没有查询到数据,调用BaseExecutor的query方法查询数据,查询到后向二级缓存中缓存一份;
3.2 cache相关
  1. 由于Mybatis二级缓存的生命周期也就是整个application的生命周期,所以application不结束,二级缓存就会一直在内存中。当然,这里也会出现一级缓存的内存溢出的可能,不过Mybatis在cache节点中增加了许多配置,比如readOnly(只读),eviction(缓存的回收算法),flushInterval(缓存的清理时间间隔),blocking(读取时是否阻塞)等,详细参数可以查看官网:
    Mybatis-XML映射文件
  2. 我们也可以通过实现Cache接口,然后配置cache节点的type属性为我们自定义的cache实现。当然,我们也可以使用第三方缓存来实现;
  1. 所以说,Mybatis的二级缓存有三个选择:
  • Mybatis默认的缓存实现;
  • 我们自定义的缓存实现;
  • 第三方缓存的结合,如Redis等。
3.3 二级缓存的清除

同样,二级缓存,在进行update,insert,delete的时候时可以自动清空的:

public int update(MappedStatement ms, Object parameterObject) throws SQLException {
    // 根据flushCache属性来判断是否清空
    flushCacheIfRequired(ms);
    return delegate.update(ms, parameterObject);
}

当然我们也可以指定flushCache=false,在更新的时候不清除二级缓存。

3.4 二级缓存的一个问题

  在关联查询的时候,Mybatis会有一个小问题。比如说关联查询两张表A与B,他们位于不同的mapper中,有不同的命名空间,我们先在A中进行一次关联查询,然后对B进行了一次update操作,然后再对原先的A的关联查询进行了一次查询,那查询还是原来的结果,这样就有可能导致数据的不同步。这种情况下,我们可以将A,B使用同一份cache来解决这个问题,但这并不是最好的解决方案。

而最理想的解决方案就是:

对于某些表执行了更新(update、delete、insert)操作后,如何去清空跟这些表有关联的查询语句所造成的缓存;这样,就是以很细的粒度管理MyBatis内部的缓存,使得缓存的使用率和准确率都能大大地提升。

所以,使用Mybatis的二级缓存时,最好保证所有的增删改查都在同一个命名空间下;

3.5 二级缓存总结

二级缓存的介绍大致完了,我们来简单总结下二级缓存。

  1. 二级缓存是Application级别的缓存,默认情况下每个mapper文件会对应一份缓存;
  2. 二级缓存的粒度很细,可以具体到某条select语句,只需要相应的配置即可;
  3. 我们可以使用默认的二级缓存,也可以自定义缓存,当然也可以使用第三方的缓存;
  4. 注意下关联查询的问题,最好在同一个命名空间下进行二级缓存的操作;
3.6 二级缓存和一级缓存的顺序

根据源代码我们也可以很清除的看到,如果同时配置了一级缓存和二级缓存,那Mybatis会先执行二级缓存,再执行一级缓存,最后查询数据库,顺序大致是:

二级缓存 -> 一级缓存 -> 数据库查询

4 总结

到这里,Mybatis的缓存基本上就学习完了。

  1. Mybatis缓存这里好多地方用到了装饰者模式,我们可以参考学习下;
  2. Mybatis的缓存还是很灵活的,大部分的配置都可以由我们来选择;

其实缓存这块还有许多东西我们没有分析,比如cache节点的解析,Cache的多个实例如BlockingCache,FifoCache,以及用于自定义及第三软件的LoggingCache等,还有自定义cache的实现,结合第三方软件的实现等。这些等以后有时间了再来学习吧。

本文参考自:
终结篇:MyBatis原理深入解析(三)
Mybatis介绍之缓存
Mybatis - XML映射配置文件
【MyBatis源码解析】MyBatis一二级缓存

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

推荐阅读更多精彩内容