跳至主要內容

Clickhouse 初体验

Mayee...大约 11 分钟

前言

公司中有些业务数据是要求永久存储的,随着运营时间累计越来越久,存储的数据量也越来越多,数据存储的成本也越来越高,单是使用的阿里云 mongodb 数据库,每个月需要花费将近 10W RMB。 虽然后续对 mongodb 做了冷热库拆分,云上只存储近半年的数据,因为近半年的数据查询频繁,半年之前的数据都放在自建机房。这极大降低了运营成本,但还是有计划转换到 clickhouse 存储,因为 clickhouse 存储数据压缩比例非常高,查询速度极快,因此计划将系统中存储在 mongodb 和 elasticsearch 中的数据换到 clickhouse 中。

1. 数据库对比

mongodb:非关系型数据库。也就是说它没有表结构的约束,每行记录可以存储任意结构的数据,并且支持 Object 和 Array 类型。数据吞吐量大,集群部署时易于水平扩展。 elasticsearch:分布式搜索引擎。一般来说,elasticsearch 的使用场景是在搜索方面,强大的分词查询功能,通常也用带来做日志存储和搜索,因此它也可以用来存储海量数据。 clickhouse:列式数据库。clickhouse 与传统数据库相比,其中最大的一点区别是,它是列式存储。而且它的压缩比例非常高。

2. ORM 框架选型

GO 的 ORM 框架也不少,但能支持 clickhouse 的并不多,最终将目光锁定在了:clickhouse-goopen in new windowgormopen in new windowclickhouse-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-clickhouseopen in new window。 这里创建的 clickhouse 版本是 22.12.2.25 。 注意:这里我们是以快速为主,使用了 docker 创建了一个单机版,并且没有将存储目录挂载出来。如果是作为个人长期使用的数据库,为避免数据丢失,应当挂载出存储目录。如果是企业环境,则应该使用二进制集群部署,做好主备容灾。

3.2 连接 clickhouse

查看 clickhouse 官方文档open in new window,选择使用 DBeaveropen in new window。 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 支持的数据类型非常多,具体可查看文档open in new window。 需要注意的是,JSON类型是 22 版本才支持,如果创建的表中含有JSON类型的字段,需要设置SET allow_experimental_object_type = 1;,该语句需要和建表语句一起执行,才能成功创建表。

3.5 gorm 操作 clickhouse

查看 gorm 的官方文档open in new window示例仓库open in new window,连接到 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 文档open in new window,创建模型如下:

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仓库open in new windowclickhouse-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时,我们在模型中定义的对应字段数据类型必须为:StructMapString。实际使用时发现,自定义的结构体类型,写入数据时会报错。其他 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 字符串插入的。 由于不能使用自定义类型open in new window,因此没法去实现ScannerValuer接口来做序列化和反序列的动作。因为只要使用自定义数据类型,就无法工程写入。 官方的写法如下:

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方法查询就无法映射了,会报错。 我又想过用字段权限控制open in new window,只给模型中 string 类型字段的写入权限,给 map 类型只读权限。但依然如上,查询时无法将数据库中的JSON映射为string

因此,建议在面对复杂数据类型,如 JSON、Array 时,模型中用 string。写入和查询使用原生 sql 方式,即不使用 gorm 的API。

还有很重要的一点是,JSON类型存储的值,结构必须一模一样,否则写入会报错。

目前使用 gorm 操作 clickhouse 发现如下几点问题:

  • 无法使用自定义类型,因此无法去实现ScannerValuer接口;
  • 无法做软删除,因为软删除open in new window需要使用 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:本文完整示例代码已上传至 Giteeopen in new window