前言 公司中有些业务数据是要求永久存储的,随着运营时间累计越来越久,存储的数据量也越来越多,数据存储的成本也越来越高,单是使用的阿里云 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 创建数据库 使用如下语句创建一个数据库:
1 CREATE DATABASE IF NOT EXISTS cloud ENGINE = Atomic ;
3.4 创建表 使用如下语句创建一个表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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 的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 package dataimport ( "fmt" click "github.com/ClickHouse/clickhouse-go/v2" glick "gorm.io/driver/clickhouse" "gorm.io/gorm" "time" ) type ClickDB = gorm.DBvar DB *ClickDBfunc 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, DisableDatetimePrecision: true , DontSupportRenameColumn: true , SkipInitializeWithVersion: false , DefaultGranularity: 3 , DefaultCompression: "LZ4" , DefaultIndexType: "minmax" , DefaultTableEngineOpts: "ENGINE=MergeTree() ORDER BY tuple()" , }), &gorm.Config{ NowFunc: func () time.Time { 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 文档 ,创建模型如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 package modelimport ( "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"` 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
的文件定义了如下接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 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
类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 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 了,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 package usersimport ( "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 ), "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 ), "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, } return data.DB.Debug().Updates(&us).Error } func Delete () error { 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
接口来做序列化和反序列的动作。因为只要使用自定义数据类型,就无法工程写入。 官方的写法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 type JSON json.RawMessagefunc (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 } 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