Clickhouse 初体验
前言
公司中有些业务数据是要求永久存储的,随着运营时间累计越来越久,存储的数据量也越来越多,数据存储的成本也越来越高,单是使用的阿里云 mongodb 数据库,每个月需要花费将近 10W RMB。 虽然后续对 mongodb 做了冷热库拆分,云上只存储近半年的数据,因为近半年的数据查询频繁,半年之前的数据都放在自建机房。这极大降低了运营成本,但还是有计划转换到 clickhouse 存储,因为 clickhouse 存储数据压缩比例非常高,查询速度极快,因此计划将系统中存储在 mongodb 和 elasticsearch 中的数据换到 clickhouse 中。
1. 数据库对比
mongodb:非关系型数据库。也就是说它没有表结构的约束,每行记录可以存储任意结构的数据,并且支持 Object 和 Array 类型。数据吞吐量大,集群部署时易于水平扩展。 elasticsearch:分布式搜索引擎。一般来说,elasticsearch 的使用场景是在搜索方面,强大的分词查询功能,通常也用带来做日志存储和搜索,因此它也可以用来存储海量数据。 clickhouse:列式数据库。clickhouse 与传统数据库相比,其中最大的一点区别是,它是列式存储。而且它的压缩比例非常高。
2. ORM 框架选型
GO 的 ORM 框架也不少,但能支持 clickhouse 的并不多,最终将目光锁定在了:clickhouse-go 和 gorm。 clickhouse-go
是 clickhouse 官方的驱动,使用起来遵循 JDBC 的方式:加载驱动、会话连接、准备陈述对象、执行SQL、获取结果集、映射结果集到对象、关闭结果集、关闭陈述对象、关闭会话连接。可以看出,使用起来还是比较繁琐的,属于最原始的方式。 gorm
是对clickhouse-go
做了一层封装,使用gorm
的 API 进行操作,实际底层调用的是clickhouse-go
来进行 CRUD 操作,只是简化了使用方式,并附加了一些额外的开发中会用到的功能。此外,gorm
也支持如:mysql、postgresql、sqlite、sqlserver。
因此,决定使用gorm
作为 ORM 框架。
3. 使用 clickhouse
clickhouse 的特点:查询和新增数据快,修改、删除数据是重型操作。因此不建议将需要频繁变动的数据放进 clickhouse。clickhouse 会在后台异步合并数据,因此会比较消耗 CPU。
3.1 部署 clickhouse
使用 docker 的方式快速创建一个 clickhouse 实例,可参照docker-clickhouse。 这里创建的 clickhouse 版本是 22.12.2.25 。 注意:这里我们是以快速为主,使用了 docker 创建了一个单机版,并且没有将存储目录挂载出来。如果是作为个人长期使用的数据库,为避免数据丢失,应当挂载出存储目录。如果是企业环境,则应该使用二进制集群部署,做好主备容灾。
3.2 连接 clickhouse
查看 clickhouse 官方文档,选择使用 DBeaver。 DBeaver 连接使用的端口默认是 8123,而程序中连接使用的端口默认是 9000。默认数据库名为 default,输入账号/密码:root / 123456 连接成功。
3.3 创建数据库
使用如下语句创建一个数据库:
CREATE DATABASE IF NOT EXISTS cloud ENGINE = Atomic;
3.4 创建表
使用如下语句创建一个表:
-- 支持 JSON 类型。注意要和建表语句一起执行
SET allow_experimental_object_type = 1;
CREATE TABLE IF NOT EXISTS cloud.users
(
serial String COMMENT '编号',
name String COMMENT '姓名',
age UInt8 COMMENT '年龄',
birthday Date COMMENT '生日',
gender Enum('ladyBody'=2,'male'=1, 'female'=0) COMMENT '性别',
mobile String COMMENT '手机号',
residence String COMMENT '现居地',
receives Array(Map(String,String)) COMMENT '收货信息',
remark JSON COMMENT '备注',
deleted Bool COMMENT '删除标记',
created_at Datetime COMMENT '创建时间',
created_by String COMMENT '创建人编号',
updated_at Datetime COMMENT '更新时间',
updated_by String COMMENT '更新人编号'
) ENGINE = MergeTree()
ORDER BY serial
PARTITION BY toYYYYMM(created_at)
PRIMARY KEY serial
COMMENT '用户信息表';
clickhouse 支持的数据类型非常多,具体可查看文档。 需要注意的是,JSON
类型是 22 版本才支持,如果创建的表中含有JSON
类型的字段,需要设置SET allow_experimental_object_type = 1;
,该语句需要和建表语句一起执行,才能成功创建表。
3.5 gorm 操作 clickhouse
查看 gorm 的官方文档及示例仓库,连接到 clickhouse 的代码如下:
package data
import (
"fmt"
click "github.com/ClickHouse/clickhouse-go/v2"
glick "gorm.io/driver/clickhouse"
"gorm.io/gorm"
"time"
)
// ClickDB 类型别名是 GO 1.19 新增的
type ClickDB = gorm.DB
var DB *ClickDB
func init() {
DB = NewClickhouse()
}
func NewClickhouse() *ClickDB {
conn := click.OpenDB(&click.Options{
Addr: []string{"127.0.0.1:9000"},
Auth: click.Auth{
Username: "root",
Password: "123456",
Database: "cloud",
},
DialTimeout: time.Second * 5,
Settings: click.Settings{
"max_execution_time": 60,
},
})
db, err := gorm.Open(glick.New(glick.Config{
Conn: conn, // initialize with existing database conn
DisableDatetimePrecision: true, // disable datetime64 precision, not supported before clickhouse 20.4
DontSupportRenameColumn: true, // rename column not supported before clickhouse 20.4
SkipInitializeWithVersion: false, // smart configure based on used version
DefaultGranularity: 3, // 1 granule = 8192 rows
DefaultCompression: "LZ4", // default compression algorithm. LZ4 is lossless
DefaultIndexType: "minmax", // index stores extremes of the expression
DefaultTableEngineOpts: "ENGINE=MergeTree() ORDER BY tuple()",
}), &gorm.Config{
NowFunc: func() time.Time {
// 指定 gorm 生成当前时间的实现
return time.Now().UTC()
},
})
if err != nil {
panic(fmt.Sprintf("NewClickhouse error: %s", err.Error()))
}
return db
}
在这里我为gorm.DB
指定类型别名,使用type ClickDB = gorm.DB
,注意它和type ClickDB gorm.DB
是有区别的,使用前者 DB 类型仍是 gorm.DB,使用后者 DB 的类型是 ClickDB。 即,前者只是为 gorm.DB 起了一个小名,后者是定义了一个新的类型 ClickDB。类型别名至少需要 Go 1.19 才支持。
3.5 定义数据模型
参考 gorm 文档,创建模型如下:
package model
import (
"20230107/internal/enum"
"gorm.io/gorm"
"time"
)
type Base struct {
Deleted bool `gorm:"column:deleted;type:Bool"`
CreatedAt time.Time `gorm:"column:created_at;type:DateTime;autoCreateTime"`
CreatedBy string `gorm:"column:created_by;type:String"`
UpdatedAt time.Time `gorm:"column:updated_at;type:DateTime;autoUpdateTime"`
UpdatedBy string `gorm:"column:updated_by;type:String"`
}
type Users struct {
Serial string `gorm:"column:serial;type:String;primaryKey"`
Name string `gorm:"column:name;type:String"`
Age uint8 `gorm:"column:age;type:Uint8"`
Birthday string `gorm:"column:birthday;type:Date"`
Gender enum.Gender `gorm:"column:gender;type:Enum"`
Mobile string `gorm:"column:mobile;type:String"`
Residence string `gorm:"column:residence;type:String"`
Receives []map[string]string `gorm:"column:receives;type:Array(Map(String,String))"`
Remark map[string]any `gorm:"column:remark;type:JSON"` // value 的值为 int 时会报错, int -> int64 失败
Base `gorm:"embedded"`
}
func (*Users) TableName() string {
return "users"
}
func (u *Users) BeforeCreate(*gorm.DB) error {
u.CreatedBy = "Mayee"
return nil
}
func (u *Users) BeforeUpdate(tx *gorm.DB) error {
u.UpdatedBy = "Bobby"
return nil
}
func (u *Users) BeforeDelete(*gorm.DB) error {
// 硬删除了
return nil
}
这里需要重点注意,由于gorm
底层使用的是clickhouse-go
,而在clickhouse-go
的仓库中clickhouse-go/lib/column/column.go
的文件定义了如下接口:
type Interface interface {
Name() string
Type() Type
Rows() int
Row(i int, ptr bool) interface{}
ScanRow(dest interface{}, row int) error
Append(v interface{}) (nulls []uint8, err error)
AppendRow(v interface{}) error
Decode(reader *proto.Reader, rows int) error
Encode(buffer *proto.Buffer)
ScanType() reflect.Type
Reset()
}
当我们做写入(新增\更新)操作,调用的是AppendRow
方法,实现此接口的类型非常多,都是映射到 clickhouse 中的数据类型。 例如,其中一个JSONObject
类型,它对应的是 clickhouse 中的JSON
类型:
func (jCol *JSONObject) AppendRow(v interface{}) error {
if reflect.ValueOf(v).Kind() == reflect.Struct || reflect.ValueOf(v).Kind() == reflect.Map {
if jCol.columns != nil && jCol.encoding == 1 {
return &Error{
ColumnType: fmt.Sprint(jCol.Type()),
Err: fmt.Errorf("encoding of JSON columns cannot be mixed in a batch - %s cannot be added as previously String", reflect.ValueOf(v).Kind()),
}
}
err := appendStructOrMap(jCol, v)
return err
}
switch v := v.(type) {
case string:
if jCol.columns != nil && jCol.encoding == 0 {
return &Error{
ColumnType: fmt.Sprint(jCol.Type()),
Err: fmt.Errorf("encoding of JSON columns cannot be mixed in a batch - %s cannot be added as previously Struct/Map", reflect.ValueOf(v).Kind()),
}
}
jCol.encoding = 1
if jCol.columns == nil {
jCol.columns = append(jCol.columns, &JSONValue{Interface: &String{}})
}
jCol.columns[0].AppendRow(v)
default:
return &ColumnConverterError{
Op: "AppendRow",
To: "String",
From: fmt.Sprintf("json row must be struct, map or string - received %T", v),
}
}
return nil
}
可以看到,如果数据库中的字段类型为JSON
时,我们在模型中定义的对应字段数据类型必须为:Struct
、Map
或String
。实际使用时发现,自定义的结构体类型,写入数据时会报错。其他 clickhouse 类型也类似,基本上只能用 Go 原生的数据类型。
3.6 CRUD 操作
这里基本就是使用 gorm 的 API 了,代码如下:
package users
import (
"20230107/internal/data"
"20230107/internal/enum"
"20230107/internal/model"
"time"
)
func Create() error {
u1 := model.Users{
Serial: "NO12580",
Name: "Bobby",
Age: 18,
Birthday: time.Now().Format("2006-01-02"),
Gender: enum.LadyBody,
Mobile: "",
Residence: "",
Receives: []map[string]string{
{
"mobile": "10001",
"address": "佛山",
},
{
"mobile": "12345",
"address": "东莞",
},
},
Remark: map[string]any{
"a": int64(1), // 如果是 int,必须用 int64,否则报错
"b": map[string]any{
"c": 3.14,
},
},
}
u2 := model.Users{
Serial: "NO12581",
Name: "Regan",
Age: 20,
Birthday: time.Now().Format("2006-01-02"),
Gender: enum.LadyBody,
Mobile: "",
Residence: "",
Receives: []map[string]string{
{
"mobile": "10086",
"address": "深圳",
},
{
"mobile": "10010",
"address": "广州",
},
},
Remark: map[string]any{
"a": int64(0), // 如果是 int,必须用 int64,否则报错
"b": map[string]any{
"c": 5.68,
},
},
}
us := []model.Users{u1, u2}
return data.DB.Debug().Create(&us).Error
}
func Update() error {
us := model.Users{
Serial: "NO12580",
Name: "Mayee",
Age: 20,
Birthday: time.Now().Format("2006-01-02"),
Gender: enum.Male,
// 如果更新 json 或 数组 等复杂字段会报转换错误。
//Receives: []map[string]string{
// {
// "mobile": "12315",
// "address": "北京",
// },
// {
// "mobile": "12306",
// "address": "上海",
// },
//},
//Remark: map[string]any{
// "a": "baby",
// "b": map[string]any{
// "c": "999",
// },
//},
}
return data.DB.Debug().Updates(&us).Error
}
func Delete() error {
// 软删除依赖自定义的数据类型,clickhouse 当前不支持
return data.DB.Debug().Where("serial = 'NO12581'").Delete(&model.Users{}).Error
}
func Find() []model.Users {
var us []model.Users
if err := data.DB.Debug().Where("remark.b.c = 3.14").Find(&us).Error; err != nil {
panic(err)
}
return us
}
这里我使用map
来映射 clickhouse 中的JSON
类型,使用数组对应 clickhouse 中的Array
类型。但实际使用时发现,新增是正常的,更新就会报错,写入时也不是以 json 字符串插入的。 由于不能使用自定义类型,因此没法去实现Scanner
和Valuer
接口来做序列化和反序列的动作。因为只要使用自定义数据类型,就无法工程写入。 官方的写法如下:
type JSON json.RawMessage
// 实现 sql.Scanner 接口,Scan 将 value 扫描至 Jsonb
func (j *JSON) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value))
}
result := json.RawMessage{}
err := json.Unmarshal(bytes, &result)
*j = JSON(result)
return err
}
// 实现 driver.Valuer 接口,Value 返回 json value
func (j JSON) Value() (driver.Value, error) {
if len(j) == 0 {
return nil, nil
}
return json.RawMessage(j).MarshalJSON()
}
这里有一个细节需要注意,Scan
方法的接收者是指针,Value
方法的接收者是结构体。 Goland 会提示警告,要你把Value
方法的接收者写成指针。但一旦这么做了,Value
方法就不会生效了。
总结
目前使用 gorm 来操作 clickhouse 还是有不少问题在里面,特别是对于 json 格式的数据写入和查询。 我想过在模型中定义两个字段,一个是 string 类型,用于写入数据,一个是 map 类型,用于将前者的值类型转换到此字段,但查询时使用 gorm 的Find
方法查询就无法映射了,会报错。 我又想过用字段权限控制,只给模型中 string 类型字段的写入权限,给 map 类型只读权限。但依然如上,查询时无法将数据库中的JSON
映射为string
。
因此,建议在面对复杂数据类型,如 JSON、Array 时,模型中用 string。写入和查询使用原生 sql 方式,即不使用 gorm 的API。
还有很重要的一点是,JSON
类型存储的值,结构必须一模一样,否则写入会报错。
目前使用 gorm 操作 clickhouse 发现如下几点问题:
- 无法使用自定义类型,因此无法去实现
Scanner
和Valuer
接口; - 无法做软删除,因为软删除需要使用 gorm 的
soft_delete.DeletedAt
类型; - 使用
map
映射数据的JSON
类型时,如果map
中的值有整型,go 默认会当成int
类型,但写入时就会报错,提示int
无法转为int64
类型。这一点可以在clickhouse-go
库的lib/column/json.go
文件中查看kindMappings
变量知道。因此,如果想正常插入,必须将整型数据强转为int64
类型。若 json 数据结果非常复杂时,这将会是灾难,因此建议在模型中定义为 string 类型; - JSON 类型为实验功能,必须要 clickhouse 至少为 22 版本,且建表时设置
SET allow_experimental_object_type = 1;
; - gorm 的
DryRun
功能不生效; - 批量写入时,debug 看到 gorm 打印的 sql 不完整,只能看到第一个数据值,但实际写入数据库是完整的;
- 写入时 gorm 无法获知影响行数,影响的 rows 始终为 0,实际上数据已经写入数据库了,但查询的时候看到 rows 是正确的;
- JSON 类型的字段,不能为 Nullable。若某一行数据存在其他行不存在的 key 时,则所有行会补充这个字段,并赋予默认值;
- 使用 dbeaver 查看 JSON 类型的字段的值时,无法看到 key,只能看到 value,查看数据不方便;
Tip:本文完整示例代码已上传至 Gitee