一款OLAP数据库ClickHouse

本篇主题:基于3W1H原则,讲解ClickHouse,以便于后续开发实践。

中文文档:https://clickhouse.com/docs/zh/operations/performance-test/

1 clickhouse是什么?

ClickHouse 是俄罗斯的Yandex于2016年开源的列式存储数据库(DBMS),使用C++语言编写,主要用于在线分析处理查询(OLAP),能够使用SQL查询实时生成分析数据报告。

tips:

OLTP(on-line transaction processing)翻译为联机事务处理, OLAP(On-Line Analytical Processing)翻译为联机分析处理,从字面上来看OLTP是做事务处理,OLAP是做分析处理。从对数据库操作来看,OLTP主要是对数据的增删改,OLAP是对数据的查询。

1.1 Why we choose it

1.1.1 经典架构及分析

架构分析

数据的实效性,中间过程经过Kafka、ETL、调度处理,报表的实效性不理想

即席分析性能,Hive存储是hdfs文件系统,查询效率不高,不适合即席查询

涉及Hadoop组件多

数据链路长,数据链路处理流程长,繁琐容错也不好

1.1.2 为啥选择ClickHouse

ClickHouse独立于hadoop生态之外,开源的OLAP数据库

支持海量数据,实时导入,适合在线查询,多维 聚合分析

真正的面向列的DBMS,SQL语法支持

简单、开箱即用

主主架构,支持线性扩展

其他原因,请看下方特性

1.2 clickhouse特点

1、完备的DBMS功能

ClickHouse拥有完备的管理功能,所以它称得上是一个DBMS(Database Management System,数据库管理系统),而不仅是一个数据库。作为一个DBMS,它具备了一些基本功能,如下所示。

DDL(数据定义语言):可以动态地创建、修改或删除数据库、表和视图,而无须重启服务。

DML(数据操作语言):可以动态查询、插入、修改或删除数据。

权限控制:可以按照用户粒度设置数据库或者表的操作权限,保障数据的安全性。

数据备份与恢复:提供了数据备份导出与导入恢复机制,满足生产环境的要求。

分布式管理:提供集群模式,能够自动管理多个数据库节点。

这里只列举了一些最具代表性的功能,但已然足以表明为什么Click House称得上是DBMS了。

2、列式存储与数据压缩

列式存储和数据压缩,对于一款高性能数据库来说是必不可少的特性。一个非常流行的观点认为,如果你想让查询变得更快,最简单且有效的方法是减少数据扫描范围和数据传输时的大小,而列式存储和数据压缩就可以帮助我们实现上述两点。列式存储和数据压缩通常是伴生的,因为一般来说列式存储是数据压缩的前提。

列式存储相比于行式存储,列式存储在分析场景下有着许多优良的特性。

分析场景中往往需要读大量行但是少数几个列。 

在行存模式下,数据按行连续存储,所有列的数据都存储在一个block中,不参与计算的列在IO时也要全部读出,读取操作被严重放大。

列存模式下,只需要读取参与计算的列即可,极大的减低了IO cost,加速了查询。

同一列中的数据属于同一类型,压缩效果显著,压缩比高。 

列存往往有着高达十倍甚至更高的压缩比,节省了大量的存储空间,降低了存储成本。

更高的压缩比意味着更小的data size,从磁盘中读取相应数据耗时更短。

自由的压缩算法选择。clickhouse默认使用LZ4算法压缩,数据总体的压缩比可以达到8:1。

不同列的数据具有不同的数据类型,适用的压缩算法也就不尽相同。可以针对不同列类型,选择最合适的压缩算法。

高压缩比,意味着同等大小的内存能够存放更多数据,系统cache效果更好。

官方显示,通过列存储,在某些分析场景下,能获得100倍甚至更高的加速效果。


3、向量化执行引擎

clickhouse不仅将数据按列存储,而且按列进行计算。传统的OLTP数据库通常采用按行计算,原因是事务处理中以点查为主,SQL计算量小,实现这些技术的收益不够明显,单个SQL所涉及计算量可能极大,将每行作为一个基本单元进行处理会带来严重的性能损耗:

1)对每一行数据都要调用相应的函数,函数调用开销占比高;

2)存储层按列存储数据,在内存中按列组织,但是计算层按行处理,无法充分利用CPU Cache的预读能力,造成CPU Cache miss严重;

3)按行处理,无法利用高效的SIMD指令

ClickHouse实现了向量化执行引擎(Vectorized execution engine),对内存中的列式数据,一个batch调用一次SIMD指令(而非每一行调用一次),不仅减少函数调用次数,降低Cache miss,而且可以充分发挥SIMD指令的并行能力,大幅度缩短了计算耗时。向量执行引擎,通常能够带来数倍的性能提升。

SIMD全称Sigle Instuction Multiple Data,单指令多数据流,能够赋值多个操作数,并把它们打包在大型寄存器的一组指令集。以同步方式,在同一时刻执行同一条指令。

4、关系模型与SQL查询

相比HBase和Redis这类NoSQL数据库,ClickHouse使用关系模型描述数据并提供了传统数据库的概念(数据库、表、视图和函数等)。

与此同时,ClickHouse完全使用SQL作为查询语言(支持GROUP BY、ORDER BY、JOIN、IN等大部分标准SQL)。

5、多样化的表引擎

ClickHouse和MySQL类似,把表级的存储引擎插件化,根据表的不同需求可以设定不同的存储引擎。目前包括合并树、日志、接口和其他四大类20多种引擎。

不同的引擎决定了表数据的存储特点和表数据的操作行为:

1)决定表存储在哪里以及以何种方式存储

2)支持哪些查询以及如何支持

3)并发数据访问

4)索引的使用

5)是否可以执行多线程请求

6)数据复制参数

表引擎决定了数据在文件系统的存储方式,常用的也是官方推荐的MergeTree系列,如果需要数据副本的话可以使用ReplicateMergeTree系列,相当于MergeTree的副本版本。读取集群数据需要使用分布式表引擎Distribute。

6、多线程与分布式

如果说向量化执行是通过数据级并行的方式提升了性能,那么多线程处理就是通过线程级并行的方式实现了性能的提升。相比基于底层硬件实现的向量化执行SIMD,线程级并行通常由更高层次的软件层面控制。由于SIMD不适合用于带有较多分支判断的场景,ClickHouse也大量使用了多线程技术以实现提速,以此和向量化执行形成互补。

ClickHouse使用分布式分而治之的思想,利用多台服务的资源协同处理。在数据层面预先将数据分布到各太服务器,在计算层面,将数据的计算查询直接下推到数据所在服务器。在数据存取方面,既支持分区(纵向扩展,利用多线程原理),也支持分片(横向扩展,利用分布式原理),可以说是将多线程和分布式的技术应用到了极致。

7、多主架构

HDFS、Spark、HBase和Elasticsearch这类分布式系统,都采用了Master-Slave主从架构,由一个管控节点作为Leader统筹全局。而ClickHouse则采用Multi-Master多主架构,集群中的每个节点角色对等,客户端访问任意一个节点都能得到相同的效果。这种多主的架构有许多优势,例如对等的角色使系统架构变得更加简单,不用再区分主控节点、数据节点和计算节点,集群中的所有节点功能相同。所以它天然规避了单点故障的问题,非常适合用于多数据中心、异地多活的场景。

8、数据分片与分布式查询

数据分片是将数据进行横向切分,这是一种在面对海量数据的场景下,解决存储和查询瓶颈的有效手段,是一种分治思想的体现。

ClickHouse支持分片,而分片则依赖集群。每个集群由1到多个分片组成,而每个分片则对应了ClickHouse的1个服务节点。分片的数量上限取决于节点数量(1个分片只能对应1个服务节点)。

ClickHouse并不像其他分布式系统那样,拥有高度自动化的分片功能。ClickHouse提供了本地表(Local Table)与分布式表(Distributed Table)的概念。一张本地表等同于一份数据的分片。而分布式表本身不存储任何数据,它是本地表的访问代理,其作用类似分库中间件。借助分布式表,能够代理访问多个数据分片,从而实现分布式查询。

这种设计类似数据库的分库和分表,十分灵活。例如在业务系统上线的初期,数据体量并不高,此时数据表并不需要多个分片。所以使用单个节点的本地表(单个数据分片)即可满足业务需求,待到业务增长、数据量增大的时候,再通过新增数据分片的方式分流数据,并通过分布式表实现分布式查询。

1.3 clickhouse性能测试

官网提供的测试:https://clickhouse.com/benchmark/dbms/

专家测试:http://www.clickhouse.com.cn/topic/5c453371389ad55f127768ea

1.4 clickhouse适用和不适用场景

官网文档:OLAP使用场景

总结:

数据只是添加到数据库,没有必要修改

读取数据时,会从数据库中提取出大量的行,但只用到一小部分列

大宽表,读大量行但是少量列,结果集较小,列的值是比较小的数值和短字符串

数据需要以大批次(大于1000行)进行更新,而不是单行更新;或者根本没有更新操作

无需事务,数据一致性要求低

每次查询中只会查询一个大表。除了一个大表,其余都是小表

查询结果显著小于数据源。即数据有过滤或聚合。返回结果不超过单个服务器内存大小

2 clickhouse的使用

官网在线测试链接:https://play.clickhouse.tech/?file=welcome

2.1 数据类型

基础类型只有数值、字符串和时间三种类型,没有 Boolean 类型,但可以使用整型的 0 或 1 替代。

ClickHouse 的数据类型和常见的其他存储系统的数据类型对比:

看官网:https://clickhouse.tech/docs/zh/sql-reference/data-types/

说明:Clickhouse的字段默认是不允许为NULL的,如果数据有可能为NULL,需要将字段定义为类似Nullable(Int64)的类型。

2.2 存储引擎

ClickHouse 提供了大约 28 种表引擎,各有各的用途,比如有 Log 系列用来做小表数据分析,MergeTree 系列用来做大数据量分析,而 Integration 系列则多用于外表数据集成。再考虑复制表Replicated 系列,分布式表 Distributed 等,纷繁复杂,新用户上手选择时常常感到迷惑。

ClickHouse表引擎一共分为四个系列,分别是Log、MergeTree、Integration、Special。其中包含了两种特殊的表引擎Replicated、Distributed,功能上与其他表引擎正交,根据场景组合使用。

最强大的表引擎当属 MergeTree (合并树)引擎及该系列(*MergeTree)中的其他引擎。对于大多数正式的任务,推荐使用MergeTree 族中的引擎。因为只有合并树系列的表引擎才支持主键索引、数据分区、数据副本和数据采样这些特性,同时也只有此系列的表引擎支持ALTER相关操作。

Log、Special、Integration 主要用于特殊用途,场景相对有限。MergeTree 系列才是官方主推的存储引擎,支持几乎所有 ClickHouse 核心功能。

存储引擎,去查阅官方文档

2.2.1 表引擎概览

一共分为四个系列,分别是Log、MergeTree、Integration、Special。其中包含了两种特殊的表引擎Replicated、Distributed,功能上与其他表引擎正交。表引擎(即表的类型)决定了:

(1)数据的存储方式和位置,写到哪里以及从哪里读取数据

(2)支持哪些查询以及如何支持。

(3)并发数据访问。

(4)索引的使用(如果存在)。

(5)是否可以执行多线程请求。

(6)数据复制参数。

2.2.2 翻牌子存储引擎如何选

1、Log系列

Log系列表引擎功能相对简单,主要用于快速写入小表(1百万行左右的表),然后全部读出的场景。

几种Log表引擎的共性是:

数据被顺序append写到磁盘上;

不支持delete、update;

不支持index;

不支持原子性写;

insert会阻塞select操作。

它们彼此之间的区别是:

TinyLog:不支持并发读取数据文件,查询性能较差;格式简单,适合用来暂存中间数据;

StripLog:支持并发读取数据文件,查询性能比TinyLog好;将所有列存储在同一个大文件中,减少了文件个数;

Log:支持并发读取数据文件,查询性能比TinyLog好;每个列会单独存储在一个独立文件中。

2、Integration系列

该系统表引擎主要用于将外部数据导入到ClickHouse中,或者在ClickHouse中直接操作外部数据源。

Kafka:将Kafka Topic中的数据直接导入到ClickHouse;

MySQL:将Mysql作为存储引擎,直接在ClickHouse中对MySQL表进行select等操作;

JDBC/ODBC:通过指定jdbc、odbc连接串读取数据源;

HDFS:直接读取HDFS上的特定格式的数据文件;

3、Special系列

Special系列的表引擎,大多是为了特定场景而定制的。这里也挑选几个简单介绍,不做详述。

Memory:将数据存储在内存中,重启后会导致数据丢失。查询性能极好,适合于对于数据持久性没有要求的1亿一下的小表。在ClickHouse中,通常用来做临时表。

Buffer:为目标表设置一个内存buffer,当buffer达到了一定条件之后会flush到磁盘。

File:直接将本地文件作为数据存储;

Null:写入数据被丢弃、读取数据为空;

4、MergeTree系列

Log、Special、Integration主要用于特殊用途,场景相对有限。MergeTree系列才是官方主推的存储引擎,支持几乎所有ClickHouse核心功能。

1、一般情况下使用MergeTree引擎即可。

2、子类引擎使用

特殊功能:发生在分区合并时

ReplacingMergeTree:解决数据去重问题,有相同主键无法去重的问题,主要基于order by后的字段进行去重。

CollapsingMergeTree:数据删除问题,以增代删。缺陷:多线程情况下,乱序写入,无法删除。

VersionedCollapsingMergeTree:数据删除问题,以增代删。解决CollapsingMergeTree缺陷

SummingMergeTree:特定数据合并问题,主要基于order by后的字段进行sum聚合。

AggregatingMergeTree:特定数据合并问题,主要基于order by后的字段进行AggregateFunction 类型的聚合

5、Distributed 分布式引擎

Distributed 分布式引擎本身不存储数据, 但可以在多个服务器上进行分布式查询。

2.3 clickhouse的SQL特性

参见知识库其他文档:clickhouse的语法特性 vs maxcomputer

2.4 ClickHouse的函数

ClickHouse中至少存在两种类型的函数

常规函数:每一行执行一次函数计算一样(对于每一行,函数的结果不依赖于其他行)

聚合函数。 合函数则从各行累积一组值

arrayJoin函数 与 表函数 均属于第三种类型的函数

官网资料:https://clickhouse.com/docs/en/sql-reference/functions/

自定义函数实现,知识库《ClickHouse源码:函数&自定义函数》

3 实践与应用

3.1 基于ClickHouse数仓分层

ck适合的数据模型

ck数仓数据模型采用星型模型搭建,星型架构是一种非正规化的结构,多维数据集的每一个维度都直接与事实表相连,不存在渐变维度,所以数据有一定冗余。因为有冗余,所以很多统计不需要做外部的关联查询,因此一般情况下效率比雪花模型高。

说明:使用星型模型搭建即最大限度的允许数据冗余,减少必要的外表连接,提高sql效率

3.2 电信用户画像案例分析

基于clickHouse数据架构

1、数据流程处理

2、任务调度

3、数据导出实现

定义引擎类别MySQL,直接进行操作。

clear_dx_user_group_001 数据源是mysql;export_dx_user_group_001数据源是clickhouse。

4、bitmap函数在用户画像中实现

核心表:

CREATE TABLE IF NOT EXISTS ctuserbasecoredb.app_dx_portrait_tag_umap_1d

(

    dw_gmt_create DateTime COMMENT '日期',

    ds FixedString(8) COMMENT '数据分区 yyyymmdd',

    tag_id        String  COMMENT '标签id',

    tag_value    String  COMMENT '标签值',

    us AggregateFunction(groupBitmap,UInt64) COMMENT '用户uid集合形成bitmapping'

)engine=AggregatingMergeTree()

partition by  ds

order by (tag_id,tag_value)

-- 指定数据生命周期为7天,数据备份,到指定卷或者磁盘

TTL dw_gmt_create  + INTERVAL 7 DAY

-- 索引粒度,默认8192

SETTINGS index_granularity = 8192

COMMENT '标签-标签聚合用户表-全量表(tag->uidbitmap)';

聚集:

INSERT INTO TABLE ctuserbasecoredb.app_dx_portrait_tag_umap_1d

select

    now() AS dw_gmt_create

    ,'${before_date_str}' AS ds

    ,t.tag_id

    ,t.tag_value

    ,groupBitmapState(t.bit_map_id) AS us

from (

    select

        s1.tag_id

          ,s1.out_user_id

          ,s1.tag_value

          ,s2.bit_map_id

    from(

        SELECT

            tag_id

              ,out_user_id

              ,tag_value

        FROM ctuserbasecoredb.dwb_dx_portrait_user_lni_act_1d

        where ds = '${before_date_str}'

        UNION ALL

        select

            tag_id

              ,out_user_id

              ,tag_value

        FROM ctuserbasecoredb.dwb_dx_portrait_user_attr_1d

        where ds = '${before_date_str}'

    )s1

    join (

    select out_user_id,bit_map_id

    from ctuserbasecoredb.dim_dx_user_bitmap_id_df

    where ds = '${before_date_str}'

    )s2

    on s1.out_user_id = s2.out_user_id

)t

group by t.tag_id,t.tag_value;

关键SQL:

  insert into app_dx_portrait_group_tag_aggregation_1d

  select now() as dw_gmt_create

  ,'{self.ds_partition}' as ds

  ,'{group_id}' as group_id

  ,s2.out_user_id as out_user_id

  ,s2.tag_detail

  ,now() as base_time

  from ( select arrayJoin(bitmapToArray(t.us)) as id from ({group_tag_conditions_sql})t) s1

  join (

    select out_user_id,bit_map_id,tag_detail

    from dim_dx_user_bitmap_id_df

    where ds = '20211013'

  )s2

  on s1.id = s2.bit_map_id

group_tag_conditions_sql:

select arrayJoin(bitmapToArray(t.us)) as id

from (

  select groupBitmapOrState(us) as us from (

    select groupBitmapAndState(us) as us from (

      select us from app_dx_portrait_tag_umap_1d where ds='20211013' and tag_id='DX_01_AB_0001' and tag_value = 'true'

      UNION ALL 

      select us from app_dx_portrait_tag_umap_1d where ds='20211013' and tag_id='DX_01_AA_0001' and tag_value = 'true'

      UNION ALL 

      select us from app_dx_portrait_tag_umap_1d where ds='20211013' and tag_id='DX_01_AA_0002' and tag_value = '电信'

      UNION ALL 

      select groupBitmapOrState(us) as us from app_dx_portrait_tag_umap_1d where ds='20211013' and tag_id='DX_01_AA_0003' and tag_value in ('未知', '浙江', '上海', '北京', '湖北')

    )

)

)

3.3 ClickHouse监控

知识库文档:ClickHouse 监控平台

https://clickhouse.com/docs/zh/operations/monitoring/

这是官网给出的方案:基于prometheus + grafana生态。

测试环境:http://172.31.28.31:3000/d/10Bw94W7z/clickhousece-shi-huan-jing?orgId=1

4 clickhouse优化

4.1 Clickhouse SQL常规优化

单表查询:

Prewhere替代where

列裁剪与分区裁剪,代替使用select *

orderby 结合where、limit

避免构建虚拟列(as xx),不要在结果集上构建虚拟列,虚拟列非常消耗资源浪费性能,可以考虑在前

uniqCombined替代distinct ,uniqCombined底层采用类似HyperLogLog算法实现,能接收2%左右的数据误差,可直接使用这种去重方式提升查询性能。

多表查询:

用IN 代替JOIN 

insert into hits_v2

select a.* from hits_v1 a where a. CounterID in (select CounterID from

visits _v1

大小表JOIN ,小表在右 

insert into table hits_v2

select a.* from hits_v1 a left join visits_v 2 b on a. CounterID=b.

CounterID;

注意谓词下推(版本差异),每个子查询提前完成过滤操作

分布式表使用GLOBAL

使用字典表,字典表会常驻内存,在需要进行关联分析的业务中使用

4.2 clickhouse explain

基本语法:

EXPLAIN [AST | SYNTAX | PLAN | PIPELINE] [setting = value, ...]

SELECT ... [FORMAT ...]

➢ PLAN:用于查看执行计划,默认值。

header 打印计划中各个步骤的head 说明,默认关闭,默认值0;

description 打印计划中各个步骤的描述,默认开启,默认值1;

actions 打印计划中各个步骤的详细信息,默认关闭,默认值0。

➢ AST :用于查看语法树;

➢ SYNTAX:用于优化语法;

➢ PIPELINE:用于查看PIPELINE 计划。

header 打印计划中各个步骤的head 说明,默认关闭;

graph 用DOT图形语言描述管道图,默认关闭,需要查看相关的图形需要配合graphviz查看;

actions 如果开启了graph,紧凑打印打,默认开启。

注:PLAN 和PIPELINE 还可以进行额外的显示设置,如上参数所示。

-- 先做一次查询

SELECT number = 1 ? 'hello' : (number = 2 ? 'world' : 'hello') FROM numbers(10);

-- 查看语法优化

EXPLAIN SYNTAX SELECT number = 1 ? 'hello' : (number = 2 ? 'world' : 'hello') FROM numbers(10);

-- 开启三元运算符优化

SET optimize_if_chain_to_multiif = 1;

-- 再次查看语法优化

EXPLAIN SYNTAX SELECT number = 1 ? 'hello' : (number = 2 ? 'world' : 'hello') FROM numbers(10);

-- 返回优化后的语句

SELECT if(number = 1, 'hello', if(number = 2, 'world', 'hello')) FROM numbers(10);

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

推荐阅读更多精彩内容