RocketMQ源码解析——存储部分(4)ConsumeQueue逻辑日志文件相关的`ConsumeQueue`类

ConsumeQueue文件讲解

概述

 RocketMQ是基于主题topic的订阅模式,消息消费是针对主题进行的。多个Topic文件是共用一个CommitLog文件的,如果要遍历commitlog文件中根据topic检索消息是非常低效的。ConsumeQueue文件的引入的目的主要是提高消息消费的性能。

文件结构

 消息消费者Consumer可根据ConsumeQueue来查找待消费的消息。其中,ConsumeQueue(逻辑消费队列)作为消费消息的索引,保存了指定Topic下的队列消息在CommitLog(物理消费队列)中的起始物理偏移量offset,消息大小size和消息Tag的HashCode值
 ConsumeQueue文件可以看成是基于topic的CommitLog索引文件,故ConsumeQueue文件夹的组织方式如下:topic/queue/file三层组织结构,具体存储路径为:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}。
 同样consumequeue文件采取定长设计,每一个条目共20个字节,分别为8字节的commitlog物理偏移量、4字节的消息长度、8字节tag hashcode,单个文件由30W个条目组成,可以像数组一样随机访问每一个条目,每个ConsumeQueue文件大小约5.72M。单条记录结构如下:

在这里插入图片描述

 消息的起始物理偏移量physical offset(long 8字节)+消息大小size(int 4字节)+tagsCode(long 8字节),每条数据的大小为20个字节(这个很重要,源码中有用到这个固定值),从而每个文件的默认大小为600万个字节。

ConsumeQueue类讲解

字段属性

    private final DefaultMessageStore defaultMessageStore;
    //映射文件队列
    private final MappedFileQueue mappedFileQueue;
    //消息的Topic
    private final String topic;
    //消息的queueId
    private final int queueId;
    //指定大小的缓冲,因为一个记录的大小是20byte的固定大小
    private final ByteBuffer byteBufferIndex;
    //保存的路径
    private final String storePath;
    //映射文件的大小
    private final int mappedFileSize;
    //最后一个消息对应的物理偏移量  也就是在CommitLog中的偏移量
    private long maxPhysicOffset = -1;
    //最小的逻辑偏移量 在ConsumeQueue中的最小偏移量
    private volatile long minLogicOffset = 0;
    //ConsumeQueue的扩展文件,保存一些不重要的信息,比如消息存储时间等
    private ConsumeQueueExt consumeQueueExt = null;

 这里比较重要的属性,topicqueueIdmaxPhysicOffsetminLogicOffset。这里对这几个属性进行说明一下

属性 说明
topic 文件所属的topic
queueId 文件所属的topic下的队列id
maxPhysicOffset 最大的物理偏移量,这里指的是CommitLog中的偏移量
minLogicOffset 最小的逻辑偏移量,这里指的是ConsumeQueue中的最小偏移量

 需要分清楚的是ConsumeQueue是消息的逻辑地址文件,CommitLog是消息的物理地址文件。

内部方法解析

构造方法

ConsumeQueue只有一个构造方法。

    public ConsumeQueue(
        final String topic,
        final int queueId,
        final String storePath,
        final int mappedFileSize,
        final DefaultMessageStore defaultMessageStore) {
        //指定文件的存储位置
        this.storePath = storePath;
        //指定文件大小
        this.mappedFileSize = mappedFileSize;
        //指定DefaultMessageStore对象
        this.defaultMessageStore = defaultMessageStore;
        //存储指定topic消息
        this.topic = topic;
        //指定指定queueId消息
        this.queueId = queueId;
        //设置对应的文件路径,$HOME/store/consumequeue/{topic}/{queueId}/{fileName}
        String queueDir = this.storePath
            + File.separator + topic
            + File.separator + queueId;
        //创建文件映射队列
        this.mappedFileQueue = new MappedFileQueue(queueDir, mappedFileSize, null);
        //创建20个字节大小的缓冲
        this.byteBufferIndex = ByteBuffer.allocate(CQ_STORE_UNIT_SIZE);
        //是否启用消息队列的扩展存储
        if (defaultMessageStore.getMessageStoreConfig().isEnableConsumeQueueExt()) {
            //创建一个扩展存储对象
            this.consumeQueueExt = new ConsumeQueueExt(
                topic,
                queueId,
                //consumeQueueExt的存储地址
                StorePathConfigHelper.getStorePathConsumeQueueExt(defaultMessageStore.getMessageStoreConfig().getStorePathRootDir()),
                //todo 设置消费队列文件扩展大小  默认48M
                defaultMessageStore.getMessageStoreConfig().getMappedFileSizeConsumeQueueExt(),
                //todo 位图过滤的位图长度
                defaultMessageStore.getMessageStoreConfig().getBitMapLengthConsumeQueueExt()
            );
        }
    }

 构造方法中没有除了设置字段值之外的额外的逻辑。都是比较简单的逻辑,不多进行分析。

文件加载load

load方法调用也是在RocketMQ的Broker启动的时候,会调用到,用来加载机器内存中的ConsumeQueue文件

    public boolean load() {
        //从映射文件队列加载
        boolean result = this.mappedFileQueue.load();
        log.info("load consume queue " + this.topic + "-" + this.queueId + " " + (result ? "OK" : "Failed"));
        //存在扩展存储则加载
        if (isExtReadEnable()) {
            //消息队列扩展加载=》
            result &= this.consumeQueueExt.load();
        }
        return result;
    }
服务重启时修复文件的recover

 RocketMQ在启动时候,会去尝试恢复服务器中的ConsumeQueue文件。文件恢复的逻辑就是通过检查每个消息记录单元中记录信息来判断这个记录是否完整,进而分析整个文件是不是完整,最后对文件中损坏的记录进行截断。整体的恢复逻辑有点长。这里对每个消息单元的分析是基于单个消息单元的长度是20个字节长度的原理来进行分析

    public void recover() {
        final List<MappedFile> mappedFiles = this.mappedFileQueue.getMappedFiles();
        if (!mappedFiles.isEmpty()) {
            //如果文件列表大于3就从倒数第3个开始,否则从第一个开始
            int index = mappedFiles.size() - 3;
            if (index < 0)
                index = 0;
            //获取consumeQueue单个文件的大小
            int mappedFileSizeLogics = this.mappedFileSize;
            //获取最后一个映射文件
            MappedFile mappedFile = mappedFiles.get(index);
            ByteBuffer byteBuffer = mappedFile.sliceByteBuffer();
            //映射文件处理的起始偏移量
            long processOffset = mappedFile.getFileFromOffset();
            long mappedFileOffset = 0;
            long maxExtAddr = 1;
            while (true) {
                //遍历文件列表
                for (int i = 0; i < mappedFileSizeLogics; i += CQ_STORE_UNIT_SIZE) {
                    long offset = byteBuffer.getLong();
                    int size = byteBuffer.getInt();
                    long tagsCode = byteBuffer.getLong();
                    //顺序解析,每个数据单元隔20个字节,如果offset跟size大于0则表示有效
                    if (offset >= 0 && size > 0) {
                        //正常数据的大小
                        mappedFileOffset = i + CQ_STORE_UNIT_SIZE;
                        //设置最大的物理偏移量
                        this.maxPhysicOffset = offset;
                        if (isExtAddr(tagsCode)) {
                            maxExtAddr = tagsCode;
                        }
                    } else {
                        log.info("recover current consume queue file over,  " + mappedFile.getFileName() + " "
                            + offset + " " + size + " " + tagsCode);
                        break;
                    }
                }
                //如果已经   加载正常数据的大小 = 队列文件的大小,则表示这个文件加载完毕
                if (mappedFileOffset == mappedFileSizeLogics) {
                    index++;
                    if (index >= mappedFiles.size()) {

                        log.info("recover last consume queue file over, last mapped file "
                            + mappedFile.getFileName());
                        break;
                    } else {
                        mappedFile = mappedFiles.get(index);
                        byteBuffer = mappedFile.sliceByteBuffer();
                        processOffset = mappedFile.getFileFromOffset();
                        mappedFileOffset = 0;
                        log.info("recover next consume queue file, " + mappedFile.getFileName());
                    }
                } else {
                    log.info("recover current consume queue queue over " + mappedFile.getFileName() + " "
                        + (processOffset + mappedFileOffset));
                    break;
                }
            }
            // 完整的偏移量 = 最后一个文件的起始偏移量(getFileFromOffset) +  正常数据的长度(mappedFileOffset)
            processOffset += mappedFileOffset;
            //设置刷新到的 offset位置
            this.mappedFileQueue.setFlushedWhere(processOffset);
            //设置提交到的 offset位置
            this.mappedFileQueue.setCommittedWhere(processOffset);
            //删除有效的 offset 之后的文件
            this.mappedFileQueue.truncateDirtyFiles(processOffset);
            //如果有扩展文件,则恢复扩展文件
            if (isExtReadEnable()) {
                this.consumeQueueExt.recover();
                log.info("Truncate consume queue extend file by max {}", maxExtAddr);
                //映射文件队列删除最大offset的脏数据文件=》
                this.consumeQueueExt.truncateByMaxAddress(maxExtAddr);
            }
        }
    }
根据时间获取消息在队列中的逻辑位置getOffsetInQueueByTime
    public long getOffsetInQueueByTime(final long timestamp) {
        //根据时间找到映射的文件,文件可以知道最后一次修改的时间
        MappedFile mappedFile = this.mappedFileQueue.getMappedFileByTime(timestamp);
        if (mappedFile != null) {
            long offset = 0;
            //如果文件的最小偏移量 大于 查找的时间戳所在的文件的起始偏移量 说明对应的消息在这个文件中。
            int low = minLogicOffset > mappedFile.getFileFromOffset() ? (int) (minLogicOffset - mappedFile.getFileFromOffset()) : 0;
            int high = 0;
            int midOffset = -1, targetOffset = -1, leftOffset = -1, rightOffset = -1;
            long leftIndexValue = -1L, rightIndexValue = -1L;
            //获取最小的物理偏移量  也就是CommitLog的最小偏移量
            long minPhysicOffset = this.defaultMessageStore.getMinPhyOffset();
            //获取文件的内容buffer
            SelectMappedBufferResult sbr = mappedFile.selectMappedBuffer(0);
            if (null != sbr) {
                ByteBuffer byteBuffer = sbr.getByteBuffer();
                //计算文件的最大的数据单元的偏移量
                high = byteBuffer.limit() - CQ_STORE_UNIT_SIZE;
                try {
                    //用二分法来获取更新的时间戳
                    while (high >= low) {
                        //获取中间单元
                        midOffset = (low + high) / (2 * CQ_STORE_UNIT_SIZE) * CQ_STORE_UNIT_SIZE;
                        byteBuffer.position(midOffset);
                        //获取消息的物理偏移量,也就是在commitLog上的偏移量
                        long phyOffset = byteBuffer.getLong();
                        int size = byteBuffer.getInt();
                        //如果小于最小的物理偏移量,则取下一条消息的位置
                        if (phyOffset < minPhysicOffset) {
                            low = midOffset + CQ_STORE_UNIT_SIZE;
                            leftOffset = midOffset;
                            continue;
                        }

                        //按物理offset从commitLog中获取存储时间=》
                        long storeTime =
                            this.defaultMessageStore.getCommitLog().pickupStoreTimestamp(phyOffset, size);
                        if (storeTime < 0) {
                            return 0;
                        } else if (storeTime == timestamp) {//如果存储时间相等就是要找的
                            targetOffset = midOffset;
                            break;
                        } else if (storeTime > timestamp) {//如果存储时间大于目标时间,则消息需要往前找
                            high = midOffset - CQ_STORE_UNIT_SIZE;
                            rightOffset = midOffset;
                            rightIndexValue = storeTime;
                        } else {//如果存储时间小于目标时间,则消息需要往后
                            low = midOffset + CQ_STORE_UNIT_SIZE;
                            leftOffset = midOffset;
                            leftIndexValue = storeTime;
                        }
                    }

                    //找到了符合条件的消息的逻辑地址
                    if (targetOffset != -1) {
                        offset = targetOffset;
                    } else {
                        if (leftIndexValue == -1) {

                            offset = rightOffset;
                        } else if (rightIndexValue == -1) {

                            offset = leftOffset;
                        } else {
                            offset =
                                Math.abs(timestamp - leftIndexValue) > Math.abs(timestamp
                                    - rightIndexValue) ? rightOffset : leftOffset;
                        }
                    }
                    //返回对应的消息
                    return (mappedFile.getFileFromOffset() + offset) / CQ_STORE_UNIT_SIZE;
                } finally {
                   // 映射文件释放
                    sbr.release();
                }
            }
        }
        return 0;
    }

 通过消息的存储时间,来获取对应的消息在队列中的逻辑偏移量,大概的步骤如下:

  1. 根据传入的timestamp获取对应的MappedFile文件,这个获取的文件,在前面的MappedFileQueue类分析讲到过
  2. 根据minLogicOffset最小逻辑偏移量和选择的MappedFile文件的起始偏移量来确定起始的消息单元
  3. 根据最小消息单元和最大消息单元(文件的最后一个消息单元)区间来进行二分查找对应的消息落盘时间和timestamp进行对比,找到合适的消息单元。并返回。
截断逻辑文件truncateDirtyLogicFiles
   public void truncateDirtyLogicFiles(long phyOffet) {

        int logicFileSize = this.mappedFileSize;

        this.maxPhysicOffset = phyOffet - 1;
        long maxExtAddr = 1;
        while (true) {
//            获取映射队列中最后的映射文件=》
            MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile();
            if (mappedFile != null) {
                ByteBuffer byteBuffer = mappedFile.sliceByteBuffer();

                mappedFile.setWrotePosition(0);
                mappedFile.setCommittedPosition(0);
                mappedFile.setFlushedPosition(0);
                //遍历所有的MappedFile,每次读取的间隔是20个字节(ConsumeQueue的单个数据单元大小为20字节)
                for (int i = 0; i < logicFileSize; i += CQ_STORE_UNIT_SIZE) {
                    long offset = byteBuffer.getLong();
                    int size = byteBuffer.getInt();
                    long tagsCode = byteBuffer.getLong();

                    if (0 == i) {
                        //如果第一个单元的物理偏移量CommitLog Offset大于phyOffet,则直接删除最后一个文件。因为phyOffet表示的是最后一个有效的commitLog文件的起始偏移量。
                        if (offset >= phyOffet) {
                            this.mappedFileQueue.deleteLastMappedFile();
                            break;
                        } else {
                            //设置wrotePostion和CommittedPosition两个变量为解析到的数据块位置
                            int pos = i + CQ_STORE_UNIT_SIZE;
                            mappedFile.setWrotePosition(pos);
                            mappedFile.setCommittedPosition(pos);
                            mappedFile.setFlushedPosition(pos);
                            this.maxPhysicOffset = offset;
                            // This maybe not take effect, when not every consume queue has extend file.
                            if (isExtAddr(tagsCode)) {
                                maxExtAddr = tagsCode;
                            }
                        }
                    } else {
                        //解析到数据块的大小为空或者物理偏移值大于了processOffset为止。
                        if (offset >= 0 && size > 0) {
                            if (offset >= phyOffet) {
                                return;
                            }

                            int pos = i + CQ_STORE_UNIT_SIZE;
                            mappedFile.setWrotePosition(pos);
                            mappedFile.setCommittedPosition(pos);
                            mappedFile.setFlushedPosition(pos);
                            this.maxPhysicOffset = offset;
                            if (isExtAddr(tagsCode)) {
                                maxExtAddr = tagsCode;
                            }

                            if (pos == logicFileSize) {
                                return;
                            }
                        } else {
                            return;
                        }
                    }
                }
            } else {
                break;
            }
        }

        if (isExtReadEnable()) {
//            删除最大位置的消息队列=》
            this.consumeQueueExt.truncateByMaxAddress(maxExtAddr);
        }
    }

 截断文件,一般在服务重启后会调用,用来删除损坏或者有问题的消息。主要的逻辑步骤如下:

  1. 遍历MappedFile,遍历文件中的消息单元,如果是第一个消息单元,则比较消息单元中记录的物理偏移量是不是大于传入的phyOffet,如果是的则删除当前文件。
  2. 依次比较每个单元记录的偏移量和phyOffet大小,直到大于phyOffet值,然后重置文件的提交,写入和刷新的文职
保存消息逻辑日志putMessagePositionInfoWrapper

 逻辑消息的保存逻辑比较长,主要的逻辑步骤如下:

  1. 检查对应的文件是不是可写的状态,以及写入的重试次数是否达到上限30次
  2. 如果消息扩展服务开启了,则保存对应的扩展信息到扩展文件队列中
  3. 组装消息进行写入
  4. 如果写入成功,则更新CheckPoint文件中的逻辑日志落盘时间

 其中组装消息写入被抽出到另外一个方法putMessagePositionInfo中。主要逻辑如下:

  1. 申请20个字节长度的buffer,然后依次拼接消息在CommitLog中的偏移量,消息长度和消息的tagCode
  2. 然后获取 MappedFile,并把消息保存进去,同时更新maxPhysicOffset字段。

 方法中很多用到MappedFileMappedFileQueue类中的方法,可以看看前面的文章

   public void putMessagePositionInfoWrapper(DispatchRequest request) {
        //最大的重试次数
        final int maxRetries = 30;
        //检查对应的ConsumeQueue文件是不是可写
        boolean canWrite = this.defaultMessageStore.getRunningFlags().isCQWriteable();
        //可写 并且 重试次数还没达到30次,则进行写入
        for (int i = 0; i < maxRetries && canWrite; i++) {
            //获取消息的  Tag
            long tagsCode = request.getTagsCode();
            //消息扩展服务是否开启
            if (isExtWriteEnable()) {
                ConsumeQueueExt.CqExtUnit cqExtUnit = new ConsumeQueueExt.CqExtUnit();
                cqExtUnit.setFilterBitMap(request.getBitMap());
                cqExtUnit.setMsgStoreTime(request.getStoreTimestamp());
                cqExtUnit.setTagsCode(request.getTagsCode());

                long extAddr = this.consumeQueueExt.put(cqExtUnit);
                if (isExtAddr(extAddr)) {
                    tagsCode = extAddr;
                } else {
                    log.warn("Save consume queue extend fail, So just save tagsCode! {}, topic:{}, queueId:{}, offset:{}", cqExtUnit,
                        topic, queueId, request.getCommitLogOffset());
                }
            }
            //组装消息存储位置信息=》CommitLog中的偏移量,消息的大小,和小的Tag的hash值
            boolean result = this.putMessagePositionInfo(request.getCommitLogOffset(),
                request.getMsgSize(), tagsCode, request.getConsumeQueueOffset());
            //设置CheckPoint文件中的逻辑日志落盘时间
            if (result) {
                this.defaultMessageStore.getStoreCheckpoint().setLogicsMsgTimestamp(request.getStoreTimestamp());
                return;
            } else {
                // XXX: warn and notify me
                log.warn("[BUG]put commit log position info to " + topic + ":" + queueId + " " + request.getCommitLogOffset()
                    + " failed, retry " + i + " times");

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    log.warn("", e);
                }
            }
        }

        // XXX: warn and notify me
        log.error("[BUG]consume queue can not write, {} {}", this.topic, this.queueId);
        this.defaultMessageStore.getRunningFlags().makeLogicsQueueError();
    }

    private boolean putMessagePositionInfo(final long offset,/*CommitLog文件的偏移量*/
                                           final int size, /*消息的大小*/
                                           final long tagsCode, /*消息的tag*/
                                           final long cqOffset/*ConsumeQueue的偏移,这个偏移在添加消息到CommitLog的时候确定了*/
        ) {
        //如果CommitLog 的偏移量比consumequeue的最大偏移量还小,说明已经追加过了
        if (offset <= this.maxPhysicOffset) {
            return true;
        }
        //把buffer重置
        this.byteBufferIndex.flip();
        //申请20个字节大小
        this.byteBufferIndex.limit(CQ_STORE_UNIT_SIZE);
        //设置在CommitLog中的偏移量
        this.byteBufferIndex.putLong(offset);
        //设置消息的大小
        this.byteBufferIndex.putInt(size);
        //设置消息的tag信息
        this.byteBufferIndex.putLong(tagsCode);
        //希望拼接到的偏移量=commitLog中的QUEUEOFFSET*20
        final long expectLogicOffset = cqOffset * CQ_STORE_UNIT_SIZE;

        //从映射文件队列中获取最后一个映射文件=》
        MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile(expectLogicOffset);
        if (mappedFile != null) {
            //映射文是第一个创建、consumerOffset不是0,映射文件写位置是0
            if (mappedFile.isFirstCreateInQueue() && cqOffset != 0 && mappedFile.getWrotePosition() == 0) {
                //设置最小的逻辑偏移量 为 对应消息的起始偏移量
                this.minLogicOffset = expectLogicOffset;
                this.mappedFileQueue.setFlushedWhere(expectLogicOffset);
                this.mappedFileQueue.setCommittedWhere(expectLogicOffset);
                //填充文件=》如果不是第一次拼接,则指定位置进行拼接
                this.fillPreBlank(mappedFile, expectLogicOffset);
                log.info("fill pre blank space " + mappedFile.getFileName() + " " + expectLogicOffset + " "
                    + mappedFile.getWrotePosition());
            }

            //consumerOffset不是0,则表示不是文件中的第一个记录
            if (cqOffset != 0) {
                //计算当前的文件提交到的位置
                long currentLogicOffset = mappedFile.getWrotePosition() + mappedFile.getFileFromOffset();
                //如果文件写入的偏移量 已经 大于这个消息写入的初始偏移量, 表示这个消息重复了
                if (expectLogicOffset < currentLogicOffset) {
                    log.warn("Build  consume queue repeatedly, expectLogicOffset: {} currentLogicOffset: {} Topic: {} QID: {} Diff: {}",
                        expectLogicOffset, currentLogicOffset, this.topic, this.queueId, expectLogicOffset - currentLogicOffset);
                    return true;
                }
                //如果两个值不相等, 说明队列的顺序可能有问题
                if (expectLogicOffset != currentLogicOffset) {
                    LOG_ERROR.warn(
                        "[BUG]logic queue order maybe wrong, expectLogicOffset: {} currentLogicOffset: {} Topic: {} QID: {} Diff: {}",
                        expectLogicOffset,
                        currentLogicOffset,
                        this.topic,
                        this.queueId,
                        expectLogicOffset - currentLogicOffset
                    );
                }
            }
            //这只最大的物理偏移量为CommitLog 的偏移量
            this.maxPhysicOffset = offset;
            //消息写入映射文件=》
            return mappedFile.appendMessage(this.byteBufferIndex.array());
        }
        return false;
    }
根据消息的index获取消息单元getIndexBuffer

 逻辑比较简单,就是依据单个消息单元的大小为20字节,来计算在文件中的位置,然后去出来

    public SelectMappedBufferResult getIndexBuffer(final long startIndex) {
        int mappedFileSize = this.mappedFileSize;
        //计算消息的在文件中的便宜offset
        long offset = startIndex * CQ_STORE_UNIT_SIZE;
        //偏移量小于最小的逻辑偏移量,则说明消息在文件中
        if (offset >= this.getMinLogicOffset()) {
            //根据offset查询映射文件 =》
            MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset);
            if (mappedFile != null) {
                SelectMappedBufferResult result = mappedFile.selectMappedBuffer((int) (offset % mappedFileSize));
                return result;
            }
        }
        return null;
    }
补充:ConsumeQueue何时填充消息单元

ConsumeQueue类中还包含一些别的方法,这里不一一进行讲解。这些方法主要是对文件的提交,刷盘和获取偏移量方面的方法。跟前面的CommitLog文件的步骤差不多,都是利用MappedFileMappedFileQueue类来完成的。这里主要说一下什么时候填充ConsumeQueue文件。
 在DefaultMessageStore中有个任务线程ReputMessageService。这个线程会不断检查CommitLog文件是否有新的消息填充,如果有会调用doDispatch方法进行消息分发,最后会调用ConsumeQueue类的putMessagePositionInfoWrapper。这里给一个简单的调用链,后续的文章会分析。

ReputMessageService#run
    ReputMessageService#doReput
        DefaultMessageStore#doDispatch
            CommitLogDispatcherBuildConsumeQueue#dispatch
                DefaultMessageStore#putMessagePositionInfo
                    ConsumeQueue#putMessagePositionInfoWrapper

下一篇存储部分(4)IndexFile消息索引日志文件相关的IndexService

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

推荐阅读更多精彩内容