初链主网上线技术解读之-PBFT委员会的选举

从2017年11月启动至今,经过历时近一年的研究、开发与测试,初链主网Beta版于新加坡时间2018年09月28日08:00正式上线,在此之前,07:56分PBFT委员会第一次共识出块和TrueChain fPOW创世区块被挖出。在阅读了一些大神对初链技术的解读之后有一定的了解。本文将对之前白皮书解读黄皮书解读中的一些疑问进行代码的学习。这里主要记录PBFT选举。

白皮书和黄皮书中选举的相关内容及问题

初链白皮书中对PBFT的选举描述如下:

以 PoW 为基础,选举产生 PBFT 节点的混合共识机制设计可以保证 PBFT 节点出现问题时及时进行重新选举,并对PBFT节点进行实时监督。

因此,PBFT节点是从慢链的挖矿节点中选举的。一定程度上讲,PBFT选举也算是双链交互的一个方面。

初链黄皮书中对PBFT的换届描述如下:

PoW协议选择BFT委员会成员的依据是csize(挖出的区块数量)和节点权益的结合。这就提供了一种必要的准入系统,以处理动态的成员以及在许可的环境下切换委员会。

[问题] 这里的csize与节点权益有什么区别?
[解答] 从代码中看到委员会的选举对节点设置了水果数量的门槛,依靠难度值来评估节点选中的概率。这里设置水果的门槛是滤除那些挖出水果少的、性能较差的节点。

另一方面,我们结合了来自Thunderella 的经过认证的投诉的观点,其中慢链可以作为BFT委员会成员不当行为的证据。也就是说,每当委员会的不当行为从慢链中被发现时,第二天的起始点(不一定是第k天)就会触发委员会强制换届。

因此,换届有两种方式:一种固定时间的强制换届(即这里的选举),一种由不当行为触发的特定的换届。
[问题] 不当行为触发的换届是否有在代码中实现?如何进行识别行为不当?
[解答] 在委员会的选举过程的代码中没有体现,可能在其他位置进行设定,还需要学习和追踪。

代码解读

这里在代码中加入一些注释,包含变量的作用、自己的思考与问题,以便更好的理解函数。注释中包含一些关于GO语言语法的学习。

查看了相关代码,对如下一些函数进行细致分析:

getCommittee:获取委员会成员
electCommittee:选举委员会成员
getCandinates:获取候选人
elect:对候选人进行选举

先从选举委员会成员开始。

electCommittee:选举委员会成员

    // electCommittee elect committee members from snail block.
    // go语法:实现接口的方法;(e *Election)为结构体指针;函数作用:选举委员会成员
    func (e *Election) electCommittee(snailBeginNumber *big.Int, snailEndNumber *big.Int) []*types.CommitteeMember {
        // 打印LOG:选举范围:慢链开始的区块数、慢链结束的区块数、参与选举慢链节点的水果数量门槛、最大的委员会委员数量
        log.Info("elect new committee..", "begin", snailBeginNumber, "end", snailEndNumber, "threshold", params.ElectionFruitsThreshold, "max", params.MaximumCommitteeNumber)

        // go语法:变量名为committee的CommitteeMember类型的指针切片
        var committee []*types.CommitteeMember
        // singleNode为布尔值,表示是否为单节点;
        if e.singleNode {
            // 如果只有一个节点,则直接把创世委员会节点的第一个放入到committee中并返回committee。
            // [问题] 通过genesisCommittee是一个数组(或切片)的形式推断创世委员会成员也不止一个,那么singleNode会在什么时候使用?
            committee = append(committee, e.genesisCommittee[0])
            return committee
        }
        // 将默认的members放到committee里面
        for _, member := range e.defaultMembers {
            committee = append(committee, member)
        }
        // 调用getCandinates获取候选人和随机数种子
        seed, candidates := e.getCandinates(snailBeginNumber, snailEndNumber)
        if candidates == nil { // 如果没有候选人,则仍然使用目前的委员会成员
            log.Info("can't get new committee, retain current committee")
        } else {
            members := e.elect(candidates, seed) // 通过seed来选举候选人

            for _, member := range members {
                committee = append(committee, member) // 将选举之后的members放入到committee中。
            }
        }

        return committee // 返回committee:选举完成,返回新的委员会成员(可能没有候选人,仍然是之前的委员会成员)
    }

该函数使用了如下两个函数:获取候选人和对候选人进行选举。这中间通过随机数种子seed进行随机地选举委员会成员。这里随机数种子seed是与选举中选用的慢链区块区间中的区块相关,如果挖矿节点的难度区间越大,其获得选举的可能性越高。且看后面的elect的分析。

getCandinates:获取候选人

    // getCandinates get candinate miners and seed from given snail blocks
    // go语法:实现接口的方法;函数作用:获取候选矿工以及从慢链区块中获取种子。
    func (e *Election) getCandinates(snailBeginNumber *big.Int, snailEndNumber *big.Int) (common.Hash, []*candidateMember) {
        // fruitsCount作用:标记每个地址挖出了多少个水果
        var fruitsCount map[common.Address]uint64 = make(map[common.Address]uint64)
        // 候选人的指针切片
        var members []*candidateMember
        var seed []byte
        // get all fruits want to be elected and their pubic key is valid
        // 获取公钥有效且水果表示愿意被选举的节点
        for blockNumber := snailBeginNumber; blockNumber.Cmp(snailEndNumber) <= 0; {
            // 通过区块number来获取区块
            block := e.snailchain.GetBlockByNumber(blockNumber.Uint64())
            if block == nil {
                return common.Hash{}, nil
            }
            // 把区块的哈希按照字节放入到seed切片中
            seed = append(seed, block.Hash().Bytes()...)
            // 获取慢链的水果
            fruits := block.Fruits()
            for _, f := range fruits {
                if f.ToElect() { // 如果这个水果表示是愿意被选举的
                    // 获取public Key
                    pubkey, err := f.GetPubKey()
                    if err != nil { // 异常处理
                        continue
                    }
                    // 通过Public Key 转换成Address
                    addr := crypto.PubkeyToAddress(*pubkey)
                    // 获取难度值
                    act, diff := e.engine.GetDifficulty(f.Header())
                    // 定义并赋值member,填充coinbase、public key、address、difficulty
                    // coinbase应该为矿工地址。public key和address是挖出该水果的节点的公钥和地址
                    // 问题:既然public key可以获取address,那么为什么还要保存这两个参数?是因为防止篡改吗?
                    member := &candidateMember{
                        coinbase:   f.Coinbase(),
                        publickey:  pubkey,
                        address:    addr,
                        difficulty: new(big.Int).Sub(act, diff),
                    }
                    // 将member放入members切片中;这里放入了每一个挖出水果的愿意被选举的节点
                    members = append(members, member)
                    // 如果fruitsCount[addr]有值(表示之前有记录该地址)则加一,否则,赋值为1表示第一次遇到该地址产生的水果。
                    if _, ok := fruitsCount[addr]; ok {
                        fruitsCount[addr] += 1
                    } else {
                        fruitsCount[addr] = 1
                    }
                }
            }
            blockNumber = new(big.Int).Add(blockNumber, big.NewInt(1)) // blockNumber加一,这里较复杂是因为其类型为big.Int的指针
        }
        // log记录水果中标识愿意被选举的水果,以及对应的节点个数(由于members中包含重复的节点)
        log.Debug("get committee candidate", "fruit", len(members), "members", len(fruitsCount))

        var candidates []*candidateMember
        // 所有members总的难度值
        td := big.NewInt(0)
        for _, member := range members {
            if cnt, ok := fruitsCount[member.address]; ok {
                // log记录获取了委员会委员候选人:其地址、挖出的水果数量、对应的难度目标
                log.Trace("get committee candidate", "keyAddr", member.address, "count", cnt, "diff", member.difficulty)
                // 如果挖出的水果数量大于设定的阈值,则进行记录
                // ElectionFruitsThreshold在protocal_params.go文件中设置为100(该值可能随着初链的发展,不断的增加)
                if cnt >= params.ElectionFruitsThreshold {
                    td.Add(td, member.difficulty)           // 累加所有节点的难度值
                    candidates = append(candidates, member) // 把member添加到candidates切片中
                }
            }
        }
        // log记录最终的候选人名录:候选人的个数,候选人的总难度。
        log.Debug("get final candidate", "count", len(candidates), "td", td)
        if len(candidates) == 0 { // 如果没有候选人的错误警告
            log.Warn("getCandinates not get candidates")
            return common.Hash{}, nil
        }

        // 计算各候选人的挖矿难度在256位数值里的区间分布
        // dd的含义:累加难度的中间值
        dd := big.NewInt(0)
        // maxUint256 = 2 ^ 256 - 1
        rate := new(big.Int).Div(maxUint256, td)
        for i, member := range candidates {
            member.lower = new(big.Int).Mul(rate, dd)    // 难度的下限
            dd = new(big.Int).Add(dd, member.difficulty) // dd进行对每个节点的难度累加
            if i == len(candidates)-1 {
                member.upper = new(big.Int).Set(maxUint256) // 最后一个节点的难度上限设置为maxUint256
            } else {
                member.upper = new(big.Int).Mul(rate, dd) // 一般节点(不是最后一个)的难度上限是dd*rate
            }
            // 因为dd进行累加,所以每个member的难度的区间是连续的子片段。第一个member的下限是0,最后一个难度的上限是maxUint256
            // log记录每个member的地址以及难度的下限以及难度的上限。
            log.Trace("get power", "member", member.address, "lower", member.lower, "upper", member.upper)
        }
        // 返回seed的哈希,以及所有的候选人
        // 这里的seed汇集了所有这个区块区间的哈希值:block.Hash(),并对它转换成256的哈希值。因此,这个随机数种子与区块相关,有一定的随机性。
        return crypto.Keccak256Hash(seed), candidates
    }

【GO语法】这段程序中有一段GO语言的语法的知识点:这里的...的含义?

            // 把区块的哈希按照字节放入到seed切片中
            seed = append(seed, block.Hash().Bytes()...)

【解答】append主要用于给某个切片(slice)追加元素
第一个参数为切片,后面是该切片存储元素类型的可变参数
第二个参数也可以直接写另一个切片,将它里面所有元素拷贝追加到第一个切片后面。要注意的是,这种用法函数的参数只能接收两个slice,并且末尾要加三个点。
因此,这里是将block.Hash().Bytes()这个切片中的所有元素拷贝到seed这个切片中。

通过慢链的区块来查找这段区块区间[snailBeginNumber, snailEndNumber]的慢链挖矿节点,如果该节点愿意参选,则加入到候选人的列表中。
这里有加入候选人的水果的门槛:ElectionFruitsThreshold在protocal_params.go文件中设置为100(该值随着初链的发展,可能不断的增加)。
但是,其增大选中几率的因素是其难度值。因此,挖出的水果数目是加入候选人的门槛,其难度值更大,被选中的几率才会更高。
接下来看一下选举的函数:

elect:选举过程

    // elect is a lottery function that select committee members from candidates miners
    // 选举是一个从候选矿工中选举委员会成员的随机函数(lottery:彩票)
    func (e *Election) elect(candidates []*candidateMember, seed common.Hash) []*types.CommitteeMember {
        // addrs含义:标记该地址是否被选中,来避免同一地址被重复选举出来
        var addrs map[common.Address]uint = make(map[common.Address]uint)
        var members []*types.CommitteeMember
        // log记录传入的参数:候选人的数量以及seed
        log.Debug("elect committee members ..", "count", len(candidates), "seed", seed)
        // big.go文件中:Big1 = big.NewInt(1)
        round := new(big.Int).Set(common.Big1)
        for {
            seedNumber := new(big.Int).Add(seed.Big(), round) // seed + 1
            hash := crypto.Keccak256Hash(seedNumber.Bytes())  // 取seedNumber的哈希值
            //prop := new(big.Int).Div(maxUint256, hash.Big())
            prop := hash.Big() // 可以理解为获得的随机值,通过该值来取候选人,从而得到选举后的委员会成员

            for _, cm := range candidates {
                if prop.Cmp(cm.lower) < 0 { // prop小于该候选人的最低难度值,则不选该候选人
                    continue
                }
                if prop.Cmp(cm.upper) >= 0 { // prop大于该候选人的最高难度值,则不选该候选人
                    continue
                }
                // log记录seed的哈希值,以及选中的命中的候选人的地址,以及选中时的随机值:prop
                log.Trace("get member", "seed", hash, "member", cm.address, "prop", prop)
                if _, ok := addrs[cm.address]; ok { // 不能重复选同一个地址
                    break
                }
                addrs[cm.address] = 1             // 表示该地址已经选中
                member := &types.CommitteeMember{ // 对member赋值为命中节点的矿工地址和publicKey
                    Coinbase:  cm.coinbase,
                    Publickey: cm.publickey,
                }
                members = append(members, member) // 将该member放入到members切片中

                break
            }

            round = new(big.Int).Add(round, common.Big1) // 继续加一
            // protocol_params.go文件中:MaximumCommitteeNumber = big.NewInt(40)
            if round.Cmp(params.MaximumCommitteeNumber) > 0 { // 退出条件:选中的委员会成员数量大约最大的委员会成员数量
                break
            }
        }
        // log记录debug信息:members的数量
        log.Debug("get new committee members", "count", len(members))

        return members // 返回members即选中的委员会成员数量
    }

本质上就是使用这个随机数种子来查看其落入哪个候选人的难度区间中。因此,候选人的难度区间越大,其获选的可能性越高。
委员会节点多久进行选举一次?这里的慢链区块区间如何决定的?需要看electCommittee的上一级函数。这里选electCommittee进行解读。

getCommittee:获取委员会成员

    // getCommittee returns the committee members who propose this fast block
    // 获取委员会成员:返回那些提名快链委员会的成员
    func (e *Election) getCommittee(fastNumber *big.Int, snailNumber *big.Int) *committee {
        // log记录传入的参数:快链区块的数量和慢链区块的数量
        log.Info("get committee ..", "fastnumber", fastNumber, "snailnumber", snailNumber)
        // propotocol_params.go: ElectionPeriodNumber = big.NewInt(144) // snail block period number
        // committeeNumber:选举的次数。初链每ElectionPeriodNumber进行选举一次。
        committeeNumber := new(big.Int).Div(snailNumber, params.ElectionPeriodNumber)
        // lastSnailNumber:上次慢链选举时的慢链的数量
        lastSnailNumber := new(big.Int).Mul(committeeNumber, params.ElectionPeriodNumber)
        // propotocol_params.go: SnailConfirmInterval = big.NewInt(12)
        // 提前SnailConfirmInterval个区块进行切换委员会成员。
        switchCheckNumber := new(big.Int).Sub(lastSnailNumber, params.SnailConfirmInterval)

        log.Debug("get pre committee ", "committee", committeeNumber, "last", lastSnailNumber, "switchcheck", switchCheckNumber)

        if committeeNumber.Cmp(common.Big0) == 0 { // 如果当前没有委员会成员,则从创世委员会中产生委员会成员
            // genesis committee
            log.Debug("get genesis committee")
            return &committee{
                id:                  new(big.Int).Set(common.Big0),
                beginFastNumber:     new(big.Int).Set(common.Big1),
                endFastNumber:       new(big.Int).Set(common.Big0),
                firstElectionNumber: new(big.Int).Set(common.Big0),
                lastElectionNumber:  new(big.Int).Set(common.Big0),
                switchCheckNumber:   params.ElectionPeriodNumber,
                members:             e.genesisCommittee,
            }
        }

        // find the last committee end fastblock number
        // 找到上次委员会成员的快链区块的区块数
        // 获取switchCheckNumber对应的慢链的区块
        switchCheckBlock := e.snailchain.GetBlockByNumber(switchCheckNumber.Uint64())
        if switchCheckBlock == nil {
            return nil
        }
        // 委员会成员切换时慢链上的水果,这里的返回类型为[]*SnailBlock,即慢链区块的切片
        fruits := switchCheckBlock.Fruits()
        // propotocol_params.go: ElectionSwitchoverNumber = big.NewInt(300)
        // 一个区块中包含多个水果,这里取该区块的最后一个水果。从中取出快链的区块数。
        // 因此,我们推断:一个水果中可能包含一个或多个快链的区块。因此,这里要取慢链区块对应的最后一个水果。
        // 这里是使用慢链区块来计算快链要进行选举时的区块数
        // [问题] 这里的ElectionSwitchoverNumber是如何来的,有特定的计算公式如快链和慢链区块的对应关系,还是仅是经验值?
        lastFastNumber := new(big.Int).Add(fruits[len(fruits)-1].Number(), params.ElectionSwitchoverNumber)
        // log记录debug信息:选举次数、上次切换委员会成员时快链的区块数,当前的快链的区块数
        log.Debug("check last fast block", "committee", committeeNumber, "last fast", lastFastNumber, "current", fastNumber)
        if lastFastNumber.Cmp(fastNumber) >= 0 { // 如果要进行委员会成员切换的快链的区块数大于当前的区块数,则只是进行选举委员会成员,不进行切换
            if committeeNumber.Cmp(common.Big1) == 0 { // 如果仅进行了一次委员会选举,这里的endSnailNumber还是负值,所以还是要使用创世委员会
                // still at genesis committee
                log.Debug("get genesis committee")
                return &committee{
                    id:                  new(big.Int).Set(common.Big0),
                    beginFastNumber:     new(big.Int).Set(common.Big1),
                    endFastNumber:       lastFastNumber,
                    firstElectionNumber: new(big.Int).Set(common.Big0),
                    lastElectionNumber:  new(big.Int).Set(common.Big0),
                    switchCheckNumber:   params.ElectionPeriodNumber,
                    members:             e.genesisCommittee,
                }
            }
            // get pre snail block to elect current committee
            // 获取上次慢链的区块来选举当前的委员会
            // 慢链的结束区块数:switchCheckNumber(上次切换委员会的区块高度) - ElectionPeriodNumber(144)
            endSnailNumber := new(big.Int).Sub(switchCheckNumber, params.ElectionPeriodNumber)
            // 慢链的开始区块:endSnailNumber - ElectionPeriodNumber(144) + 1
            beginSnailNumber := new(big.Int).Add(new(big.Int).Sub(endSnailNumber, params.ElectionPeriodNumber), common.Big1)
            if beginSnailNumber.Cmp(common.Big0) <= 0 { // beginSnailNumber < 0, 则设置成1
                beginSnailNumber = new(big.Int).Set(common.Big1)
            }
            // 获取结束的慢链的区块
            endSnailBlock := e.snailchain.GetBlockByNumber(endSnailNumber.Uint64())
            // 获取该区块的水果
            fruits = endSnailBlock.Fruits()
            // 上次选举委员会时快链的区块数:该水果前一个水果对应的快链区块高度+ ElectionSwitchoverNumber(300)
            preEndFast := new(big.Int).Add(fruits[len(fruits)-1].FastNumber(), params.ElectionSwitchoverNumber)

            // 从慢链开始的区块高度到结束的区块高度这个区间内进行选举委员会成员
            members := e.electCommittee(beginSnailNumber, endSnailNumber)
            return &committee{ // 除了选举委员会成员获取的member值之外,还要记录如下信息:
                id:                  new(big.Int).Sub(committeeNumber, common.Big1), // id是committeeNumber+1
                beginFastNumber:     new(big.Int).Add(preEndFast, common.Big1),      // 开始选举的快链区块高度
                endFastNumber:       lastFastNumber,                                 // 结束选举的快链区块高度
                firstElectionNumber: beginSnailNumber,                               // 开始选举的慢链区块高度
                lastElectionNumber:  endSnailNumber,                                 // 结束选举的慢链区块高度
                switchCheckNumber:   lastSnailNumber,                                // 切换时额慢链区块高度
                members:             members,
            }
        }

        // 如果要进行委员会成员切换的快链的区块数小于当前的区块数,则进行替换委员会
        // 这里的慢链结束区块数和前面的endSnailNumber不一样。这里直接是switchCheckNumber,不再进行 - ElectionPeriodNumber(144)
        endSnailNumber := new(big.Int).Set(switchCheckNumber)
        // 慢链开始区块数:endSnailNumber - ElectionPeriodNumber(144) + 1
        beginSnailNumber := new(big.Int).Add(new(big.Int).Sub(endSnailNumber, params.ElectionPeriodNumber), common.Big1)

        log.Debug("get committee", "electFirst", beginSnailNumber, "electLast", endSnailNumber, "lastFast", lastFastNumber)

        members := e.electCommittee(beginSnailNumber, endSnailNumber)
        return &committee{
            id:                  committeeNumber,                               // committeeNumber
            beginFastNumber:     new(big.Int).Add(lastFastNumber, common.Big1), // lastFastNumber + 1
            endFastNumber:       new(big.Int).Set(common.Big0),                 // 由于新的委员会选举出来,还没有切换,这里设置为0
            firstElectionNumber: beginSnailNumber,
            lastElectionNumber:  endSnailNumber,
            switchCheckNumber:   new(big.Int).Add(lastSnailNumber, params.ElectionPeriodNumber), // lastSnailNumber + ElectionPeriodNumber(144)
            members:             members,
        }
    }

慢链和快链的区块高度的联系需要通过水果的number值来来计算。如下面的例子:

    lastFastNumber := new(big.Int).Add(fruits[len(fruits)-1].Number(), params.ElectionSwitchoverNumber)

选举是每隔ElectionPeriodNumber(144)个慢链的时间进行的。慢链区块区间也是根据ElectionPeriodNumber(144)来进行划分的。
该函数由于包含了快链和慢链、委员会选举和委员会切换等概念。看完该函数后,还是有些迷惑。这里用表格举一些参数数据的例子进行说明:

参数 case1 case2 case3 case4
snailNumber 100 200 300 300
fastNumber x(无所谓) 120 250 300
committeeNumber 0 1 2 2
lastSnailNumber 0 144 288 288
switchCheckNumber -12 132 276 276
genesis committee
lastFastNumber 132区块中最后一个水果中记录的快链区块数 276区块中最后一个水果中记录的快链区块数 276区块中最后一个水果中记录的快链区块数
still at genesis committee
endSnailNumber 132 276
beginSnailNumber 0 133
id 3 2
preEndFast 132区块中最后一个水果中记录的快链区块数
beginFastNumber 132区块中最后一个水果中记录的快链区块数+1 276区块中最后一个水果中记录的快链区块数+1
endFastNumber lastFastNumber 0
firstElectionNumber 0 133
lastElectionNumber 132 276
switchCheckNumber 288 432

在case1中,由于committeeNumber是0,所以,选择了创世委员会;
在case2中,由于committeeNumber是1,由于endSnailNumber=switchCheckNumber-144会变成负数,所以这里仍然无法进行选举,仍然使用创世委员会;
在case3中,进行委员会选举。这里会等待SnailConfirmInterval(12)个慢链区块的时间来提供委员会来切换,也可能是因为选举结果的确认时间:最新的慢链的区块还没有得到足够的确认(慢链区块的确认时间也是12个慢链的区块时间)。
在case4中,进行委员会切换。可见,其firstElectionNumber和lastElectionNumber能够连续起来。且通过id来表示其切换的是第几次的选举结果。

总结

委员会的成员是从慢链的水果中的矿工中选举出来。选举的过程是对难度值得区间进行随机的选举,而不是仅仅靠水果的数量等单一依靠算力的因素。委员会的选举与委员会成员的切换有一定的时间间隔,函数中两次调用选举过程,committee中记录对应的快链和慢链的起始区块高度,对于获取候选人和选举均是依据慢链的区块,快链的区块只是通过慢链的水果记录的快链的值获取进行记录,在选举过程中没有使用。

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

推荐阅读更多精彩内容