ClickHouse初探

简介

首先介绍下ClickHouse的特点以及适用场景,引用官方的介绍ClickHouse是一个用于联机分析(OLAP)的列式数据库管理系统(DBMS)。与mysql相比ClickHouse不支持事务,mysql是行式存储,ClickHouse是列式存储。
在传统的行式数据库系统中,数据按如下顺序存储:

学号 姓名 性别 班级 分数
1 张三 1 100
2 李四 1 80
3 王五 2 75
N ... ... ... ...

在列式数据库系统中,数据按如下的顺序存储:

学号 1 2 3 N
姓名 张三 李四 王五 ...
性别 ...
班级 1 1 2 ...
分数 100 80 75 ...

可以看出行存储处于同一行中的数据总是被物理的存储在一起。列存储是来自不同列的值被单独存储,来自同一列的数据被存储在一起


行存储的写入是一次完成的,如果这种写入建立在操作系统的文件系统上,可以保证写入过程的成功或者失败,可以保证数据的完整性。列式存储需要把一行记录拆分成单列保存,写入次数明显比行存储多,这会增加写入出错的概率;数据读取时,行存储通常将一行数据完全读出,如果只需要其中几列数据的情况,就会存在冗余列,出于缩短处理时间的考量,消除冗余列的过程通常是在内存中进行的。列存储每次读取的数据是集合的一段或者全部,不存在冗余性问题。另外列式存储中的每一列数据类型是相同的,不存在二义性问题,这种情况使数据解析变得十分容易,其次对数据压缩更加友好,这一点对于数据量极大的业务场景可以有效降低存储成本。
综上所述,行式存储在数据写入和修改上具有很大优势,列式存储在数据读取和解析数据做数据分析上更具优势。因为使用列式存储,是ClickHouse适用于数据完整性要求不高的大数据处理领域的一个很重要的原因。

如何安装

这里介绍下使用docker安装单节点的方法,分布式的安装方法后面在介绍,首先拉取最新版本的docker镜像,然后启动clickhouse-server

docker run -d --ulimit nofile=262144:262144 -p 8123:8123 -p 9000:9000 -p 9009:9009 clickhouse/clickhouse-server:22.3.3.44

使用docker ps查看容器ID



执行docker exec -it 容器ID /bin/bash进入容器终端,然后通过容器内部自带的客户端和clickhouse-server进行交互



创建一张学生表做个测试,使用TinyLog引擎
create table student(id UInt8,name String,age UInt8) engine=TinyLog;

向表中插入数据

insert into student values(1,'张三',18),(2,'李四',19),(3,'王五',20);

表引擎

ClickHouse提供了很多种表引擎,一共分为四个系列,分别是Log系列、MergeTree系列、Integration系列、Special系列,各有各的用途,不同的存储引擎提供不同的存储机制、索引方式等功能,比如有Log系列用来做小表数据分析,MergeTree系列用来做大数据量分析,而Integration系列则多用于外表数据集成。Special系列中的表引擎Replicated、Distributed,可以根据场景和其他的表引擎组合使用。

MergeTree系列表引擎

Clickhouse 中最强大的表引擎当属 MergeTree (合并树)引擎及该系列(*MergeTree)中的其他引擎,这个也是官方主推的存储引擎,拥有最为强大的性能和最广泛的使用场合,有主键索引、数据分区、数据副本、数据采样、删除和修改等功能,支持几乎所有ClickHouse核心功能。MergeTree系列表引擎包含:MergeTree、ReplacingMergeTree、SummingMergeTree(汇总求和功能)、AggregatingMergeTree(聚合功能)、CollapsingMergeTree(折叠删除功能)、VersionedCollapsingMergeTree(版本折叠功能)引擎,在这些的基础上还可以叠加Replicated和Distributed。其中MergeTree是家族系列最基础的表引擎,家族中其它引擎都是建立在它之上的,下面内容主要讲MergeTree,以及叠加ReplicatedDistributed的场景,其它的引擎只做一个简单介绍,具体内容参考官方文档

  • ReplacingMergeTree

适用于在后台清除重复的数据以节省空间,但是它不保证没有重复的数据出现,按照ORDER BY去重,在数据合并的时候, 从所有具有相同排序键(ORDER BY)的行中选择一行留下

  • SummingMergeTree

按照相同排序键去重,并把指定的字段做汇总

  • AggregatingMergeTree

按照相同排序键去重,并把指定的字段按照自定义的规则做汇总

  • CollapsingMergeTree

通过以增代删的思路,支持行级数据删除,分区合并时,同一数据分区内,sign标记为1和-1的一组数据会被抵消删除

  • VersionedCollapsingMergeTree

在CollapsingMergeTree的基础上增加版本维度

  • GraphiteMergeTree

用来对 Graphite数据进行瘦身及汇总

MergeTree

MergeTree系列的引擎被设计用于插入极大量的数据到一张表当中,数据可以以数据片段的形式写入磁盘,为了避免片段过多,ClickHouse会通过后台线程,定期合并这些数据片段,属于相同分区的数据片段会被合成一个新的片段,相比在插入时不断修改(重写)已存储的数据,这种策略会高效很多。这种数据片段合并的特点,也正是合并树名称的由来。分区是MergeTree引擎的核心特性,在大部分的业务场景中,常用时间字段作为分区字段,可以按照月、天、小时对数据进行分区,查询时数据时使用分区字段作为where条件,可以有效的过滤掉大量非结果集数据。

  • 建表语句

CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
(
    name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1] [TTL expr1],
    name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2] [TTL expr2],
    ...
    INDEX index_name1 expr1 TYPE type1(...) GRANULARITY value1,
    INDEX index_name2 expr2 TYPE type2(...) GRANULARITY value2
) ENGINE = MergeTree()
ORDER BY expr
[PARTITION BY expr]
[PRIMARY KEY expr]
[SAMPLE BY expr]
[TTL expr [DELETE|TO DISK 'xxx'|TO VOLUME 'xxx'], ...]
[SETTINGS name=value, ...]

关于以上建表语句的解释如下:

  • ENGINE:ENGINE = MergeTree(),MergeTree引擎没有参数。

  • ORDER BY:排序字段。比如ORDER BY (Col1, Col2),需要注意如果没有使用 PRIMARY KEY 显式的指定主键ORDER BY排序字段自动作为主键。如果不需要排序,则可以使用 ORDER BY tuple() 语法,这样的话,创建的表也就不包含主键。这种情况下,ClickHouse会按照插入的顺序存储数据。必选项

  • PRIMARY KEY:指定主键,如果排序字段与主键不一致,可以单独指定主键字段。否则默认主键是排序字段。大部分情况下不需要再专门指定一个 PRIMARY KEY子句。可选
    注意在MergeTree中主键并不用于去重,而是会创建稀疏索引加快数据查询速度。

  • SAMPLE BY:采样字段,如果指定了该字段,那么主键中也必须包含该字段。比如 SAMPLE BY intHash32(UserID) ORDER BY (CounterID, EventDate, intHash32(UserID))。可选

  • TTL:数据的存活时间。在MergeTree中,可以为某个列字段或整张表设置TTL。当时间到达时,如果是列字段级别的TTL,则会删除这一列的数据;如果是表级别的TTL,则会删除整张表的数据。可选。

  • SETTINGS:额外的参数配置。可选

  • 示例

在名字为db_merge_tree的数据库中,创建student表

#创建数据库
create database db_merge_tree;
#切换数据库
use db_merge_tree;
create table db_merge_tree.student(
        id UInt8,
        name String,
        age UInt8,
        birthday Date
) engine = MergeTree()
order by id
primary key id
partition by toYYYYMM(birthday);

创建表时可以不指定数据库名字create table student(),默认在名字为default的数据库下

向表中插入一批数据

insert into student values(1,'张三',18,'2022-04-11'),
(2,'李四',19,'2022-01-08'),
(3,'王五',20,'2022-04-11'),
(1,'大聪明',20,'2022-05-12'),
(5,'小美',20,'2022-01-09');

查询表中的数据

select * from student;

从上图中可以看到,有两条ID值都为1的记录,印证了前面说的MergeTree中主键并不用于去重

目录解析

ClickHouse数据存放的目录规则是/var/lib/clickhouse/data/{库名}/{表名}
数据插入完成后,我们在容器内部,进入目录/var/lib/clickhouse/data/db_merge_tree/student,可以看到有几个以时间年月开头的目录,这个就是对应的分区目录,命令规则是分区最小的块编号最大块编号_经历了多少次合并


进入分区目录202201_2_2_0中,可以看到如下内容

上面这些就是保存数据的各个文件,以下是相关文件的介绍,有个了解就行了

  • checksums.txt
    目录中其它文件的大小以及大小的哈希值,用于快速校验文件的完整性和正确性
  • columns.txt
    存储当前分区的所有列信息


  • count.txt

记录当前数据分区目录下数据的总行数

[图片上传失败...(image-e63ef6-1651637128046)]

  • data.bin

存储压缩后的数据,默认为LZ4压缩格式

  • data.mrk3

列字段标记文件,使用二进制格式存储。标记文件中保存了data.bin文件中数据的偏移量信息

  • default_compression_codec.txt

存储data.bin文件的数据压缩格式,如果是LZ4文件内容为CODEC(LZ4)

  • partition.dat 与minmax_[列名].idx

指定了分区键时才会生成这两个文件,前者用于保存当前分区下分区表达式最终生成的值 ,后者保存原始数据的最大和最小值

  • primary.idx

一级索引文件,使用二进制格式存储。用于存放稀疏索引,一张 MergeTree 表只能声明一次一级索引,即通过 ORDER BY 或者 PRIMARY KEY 指定字段。借助稀疏索引,在数据查询时可以有效减少数据扫描范围,加速查询速度

索引

MergeTree会根据创建表时的参数index_granularity为间隔(默认 8192 行),为数据表生成一级索引并保存至 primary.idx 文件内,索引数据按照主键进行排序,没有显式的指定主键时会以ORDER BY 对应的列作为主键。

由于ClickHouse设计之初就是为了存储海量的数据,考虑到降低索引存储的空间,采用了稀疏索引的技术,它的一个前提就是索引字段需要是排序的,数据结构类似于下面这样,每个索引点存储开始、结束位置以及数据节点的指针,查询时通过与start、end做对比进行二分查找,命中区间后再次进行查找

[
    {
        start: "a0000001",
        end: "a0008192",
        point: xxx,
    },
    .....
    {
        start: "a1000000",
        end: "a1008192",
        point: xxx,
    },
    ....
    {
        start: "a2000000",
        end: "a2008192",
        point: xxx,
    },
]

分布式

ClickHouse之所以能支持海量的数据储存,低延迟的查询,归功于它的分布式架构设计,学习这块之前首先要理解一下这几个概念。

  • 副本
    顾名思义同样的一份数据,在不同的节点上各存一份,这样做的目的是增加数据的冗余来防止数据的丢失,提高系统的可用性。单节点使用时副本的内容是一张表的全量数据,分布式的状态下副本的内容可以是一个分片下对应的数据
  • 分片
    一张表横水平切分为多份,每份中的数据不相同且存储在不同的节点上,这样查询数据时可以借助多台机器
  • 分区
    一张表根据PARTITION BY分出的多个目录

集群安装

使用docker-compose安装一个集群便于后续的学习,这个集群包含1个zookeeper容器(用于节点之间的同步以及元数据的存储)、6个ClickHouse容器,所需资源放在github上了点我进入
启动容器所需资源内容如下

docker-compose.yml配置了集群所需容器并组网,为了方便在宿主机器上查看ClickHouse节点的数据,把容器内部数据存储目录/var/lib/clickhouse挂载到宿主./volume/data/{容器名}
我们来看下其中一个ClickHouse节点的配置文件volume/config/node1/metrika.xml,内容如下

<yandex>
   <remote_servers>
       <mycluster>
          <shard>
             <internal_replication>true</internal_replication>
             <replica>
                 <host>ch01</host>
                 <port>9000</port>
             </replica>
            <replica>
                 <host>ch02</host>
                 <port>9000</port>
             </replica>
          </shard>
          <shard>
             <internal_replication>true</internal_replication>
             <replica>
                 <host>ch03</host>
                 <port>9000</port>
             </replica>
            <replica>
                 <host>ch04</host>
                 <port>9000</port>
             </replica>
          </shard>
          <shard>
             <internal_replication>true</internal_replication>
             <replica>
                 <host>ch05</host>
                 <port>9000</port>
             </replica>
            <replica>
                 <host>ch06</host>
                 <port>9000</port>
             </replica>
          </shard>
       </mycluster>
   </remote_servers>
   <zookeeper>
       <node index="1">
          <host>zk01</host>
          <port>2181</port>
       </node>
   </zookeeper>
   <macros>
       <shard>01</shard>
       <replica>ch01</replica>
   </macros>
   <networks>
       <ip>::/0</ip>
   </networks>
   <ClickHouse_compression>
       <case>
          <min_part_size>10000000000</min_part_size>
          <min_part_size_ratio>0.01</min_part_size_ratio>
          <method>lz4</method>
       </case>
   </ClickHouse_compression>
</yandex>

在<remote_servers>节点下配置了一个名字为mycluster的集群,<shard>节点代表一个分片,<replica>节点代表副本,mycluster集群下面包含三个分片,每个分片有两个副本(在每个分片中至少配置一个副本

执行docker-compose up -d启动集群所需要的容器,看到如下的信息表示启动成功


ClickHouse目前只有 MergeTree 系列表引擎才支持副本 ,数据表如果想要有副本,创建表时需要在对应的表引擎前面加上Replicated前缀

副本示例

下面我们以ReplicatedMergeTree引擎为例,来看下ClickHouse中的数据副本。
分别进入node1、node2容器,通过clickhouse-client进入交互shell

docker exec -it $(docker ps | grep node1 | awk '{print $1}') /bin/bash 
docker exec -it $(docker ps | grep node2 | awk '{print $1}') /bin/bash

创建student表

create table student(
        id UInt8,
        name String,
        age UInt8,
        birthday Date
) engine = ReplicatedMergeTree('/ClickHouse/tables/{shard}/student','{replica}')
order by id
primary key id
partition by toYYYYMM(birthday);

在node1节点上执行插入如下数据

insert into student values
(1,'张三',18,'2022-01-01'),
(2,'李四',19,'2022-01-02'),
(3,'王五',20,'2022-01-03');

在node1、node2上可以查询到相同的数据



分片示例

还是用上面的student表做演示,进入node3、node4、node5、node6的交互shell,创建student表

docker exec -it $(docker ps | grep node3 | awk '{print $1}') /bin/bash 
docker exec -it $(docker ps | grep node5 | awk '{print $1}') /bin/bash

node3添加一条数据

insert into student values(1,'分片2',18,'2022-01-11');

接着在node5也添加一条数据

insert into student values(1,'分片3',18,'2022-01-22');

由于node1、node2容器属于分片1的副本,node3、node4容器属于分片2的副本,node5、node6容器属于分片1的副本,因此经过以上操作完成后6个容器的数据如下

  • node1、node2


  • node3、node4


  • node5、node6


可以看到三个分片拥有不同的数据,在单个节点上查询时只能查询到当前分片的数据,看到这里可能会想在实际的业务场景中如果想查询集群中student表中所有的数据时,难道需要在后台服务中从每个分片中取出数据在做组合?ClickHouse官方考虑到了这一点,另外提供了一个分布式引擎来解决这个问题,分布式引擎+MergeTree系列引擎组合是使用最广泛的场景

分布式引擎

分布式引擎本身只是一个逻辑层,用来屏蔽分片的复杂性,可以把分布式引擎看做成一个网关,执行写操作时把数据按照一定规则拆分后分流到不同分片上,执行读操作时并发的从各个分散的分片上取数据然后组合返回给客户端

创建数据表,可以采用以下语句:

CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
(
    name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1],
    name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2],
    ...
) ENGINE = Distributed(cluster, database, table[, sharding_key[, policy_name]])
[SETTINGS name=value, ...]

分布式引擎参数

  • cluster - 服务为配置中的集群名

  • database - 远程数据库名

  • table - 远程数据表名

  • sharding_key - (可选) 分片key

  • policy_name - (可选) 规则名,它会被用作存储临时文件以便异步发送数据

让我们来实际操作一把,以student表作为实际的数据表,创建一个名字为student_d的分布式表,在node1容器执行创建语句

CREATE TABLE student_d ON CLUSTER mycluster(
        id UInt8,
        name String,
        age UInt8,
        birthday Date
)
ENGINE = Distributed(mycluster, default, student2, id);

在node5上student2表中插入一条数据

insert into student2 values(0, '00', 0, '2022-01-01');

在任意节点对student_d表发起查询,可以看到这条数据

[图片上传失败...(image-31784c-1651639816331)]

我们再来尝试下对分布式表进行批量插入操作

在node1容器插入上面这1000条数据,然后再来观察下分片的数据分布情况. 点我获取sql

  • node1(分片1)
  • node3(分片2)
  • node5(分片3)

JAVA连接ClickHouse

官方提供了JDBC驱动来连接ClickHouse,原生的 JDBC写起来比较繁琐,用common-dbutils来做交互

dependencies {
    implementation 'ru.yandex.clickhouse:clickhouse-jdbc:0.2.6'
    implementation 'commons-dbutils:commons-dbutils:1.7'
}

fun main() {
    val ds = getDataSource()
    insert(ds);
    update(ds);
    query(ds);
    delete(ds);
}

fun getDataSource(): DataSource {
    DbUtils.loadDriver("ru.yandex.clickhouse.ClickHouseDriver")
    val props = ClickHouseProperties()
    props.user = "root"
    props.password = "root"
    return BalancedClickhouseDataSource("jdbc:clickhouse://127.0.0.1:8123/default", props)
}

fun insert(ds: DataSource) {
    val qr = QueryRunner(ds)
    val df = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
    val num = qr.update("insert into student(id, name, age, birthday) values(?, ?, ?, ?)", 255, "255", 255, df.format(Date()))
    println("insert success: ${num == 1}")
}

fun update(ds: DataSource) {
    val qr = QueryRunner(ds)
    val num = qr.update("alter table student update name = ? where id = ?", "name255", 255)
    println("update success: ${num >= 1}")
}

fun delete(ds: DataSource) {
    val qr = QueryRunner(ds)
    val df = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
    qr.update("insert into student(id, name, age, birthday) values(?, ?, ?, ?)", 200, "200", 200, df.format(Date()))
    val num = qr.update("alter table student delete where id = ?", 200)
    println("delete success: ${num >= 1}")
}

fun query(ds: DataSource) {
    val qr = QueryRunner(ds)
    val version = qr.query("select version();", ScalarHandler<String>())
    println("clickhouse version: $version")

    val students = qr.query("select id, name, age, birthday from student;", BeanListHandler(Student::class.java))
    println("students: $students")

    val student = qr.query("select id, name, age, birthday from student where id = ?;", BeanHandler(Student::class.java), 1)
    println("student: $student")

    val count = qr.query("select count(id) from student where id = 100", ScalarHandler<BigInteger>()).toLong()
    println(count)
}

data class Student(
    var id: Long? = 0,
    var name: String? = "",
    var age: Int? = 0,
    var birthday: Date? = null
)

demo地址

参考资料

https://github.com/typ0520/clickhouse-learning

https://www.runoob.com/docker/docker-tutorial.html

https://www.runoob.com/docker/docker-compose.html

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

https://github.com/ClickHouse/ClickHouse

https://hub.docker.com/r/clickhouse/clickhouse-server

https://github.com/Al-assad/clickhouse-cluster-docker

https://developer.aliyun.com/article/377022

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

推荐阅读更多精彩内容

  • 5、数据库引擎 ClickHouse 中支持在创建数据库时指定引擎,目前比较常用的两种引擎为默认引擎和 MySQL...
    ArthurHC阅读 415评论 0 1
  • 一、简介 ClickHouse最初是为 YandexMetrica[https://metrica.yandex....
    想成为大师的学徒小纪阅读 1,268评论 1 6
  • 1 概述 什么是 ClickHouse? ClickHouse 是俄罗斯的Yandex于2016年开源的列式存储数...
    djm猿阅读 438评论 0 0
  • 设计理念 Everything is table(万物皆表),数据表就是ClickHouse和外部交互的接口。在数...
    愚公300代阅读 1,228评论 0 0
  • 简介 ClickHouse是一个用于联机分析(OLAP)的列式数据库管理系统(DBMS)。列式数据库总是将同一列的...
    盗梦者_56f2阅读 1,769评论 0 1