---
url: /article/3zg8flyd/index.md
---
## 前言

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

## 1. 数据库对比

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

## 2. ORM 框架选型

GO 的 ORM 框架也不少，但能支持 clickhouse 的并不多，最终将目光锁定在了：[clickhouse-go](https://github.com/ClickHouse/clickhouse-go) 和 [gorm](https://github.com/go-gorm/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](/article/qr0e6712/#clickhouse)。
这里创建的 clickhouse 版本是 22.12.2.25 。
注意：这里我们是以快速为主，使用了 docker 创建了一个单机版，并且没有将存储目录挂载出来。如果是作为个人长期使用的数据库，为避免数据丢失，应当挂载出存储目录。如果是企业环境，则应该使用二进制集群部署，做好主备容灾。

## 3.2 连接 clickhouse

查看 clickhouse [官方文档](https://clickhouse.com/docs/en/home)，选择使用 [DBeaver](https://dbeaver.io/download/)。
DBeaver 连接使用的端口默认是 8123，而程序中连接使用的端口默认是 9000。默认数据库名为 default，输入账号/密码：root / 123456 连接成功。

## 3.3 创建数据库

使用如下语句创建一个数据库：

```sql
CREATE DATABASE IF NOT EXISTS cloud ENGINE = Atomic;
```

## 3.4 创建表

使用如下语句创建一个表：

```sql
-- 支持 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 支持的数据类型非常多，具体可查看[文档](https://clickhouse.com/docs/en/sql-reference/data-types/)。
需要注意的是，`JSON`类型是 22 版本才支持，如果创建的表中含有`JSON`类型的字段，需要设置`SET allow_experimental_object_type = 1;`，该语句需要和建表语句一起执行，才能成功创建表。

## 3.5 gorm 操作 clickhouse

查看 gorm 的[官方文档](https://gorm.io/zh_CN/docs/connecting_to_the_database.html#Clickhouse)及[示例仓库](https://github.com/go-gorm/clickhouse)，连接到 clickhouse 的代码如下：

```go
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.6 定义数据模型

参考 gorm [文档](https://gorm.io/zh_CN/docs/models.html)，创建模型如下：

```go
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`的[仓库](https://github.com/ClickHouse/clickhouse-go/tree/v2.4.3)中`clickhouse-go/lib/column/column.go`的文件定义了如下接口：

```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`类型：

```go
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.7 CRUD 操作

这里基本就是使用 gorm 的 API 了，代码如下：

```go
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 字符串插入的。
由于不能使用[自定义类型](https://gorm.io/zh_CN/docs/data_types.html)，因此没法去实现`Scanner`和`Valuer`接口来做序列化和反序列的动作。因为只要使用自定义数据类型，就无法工程写入。
官方的写法如下：

```go
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`方法查询就无法映射了，会报错。
我又想过用[字段权限控制](https://gorm.io/zh_CN/docs/models.html#%E5%AD%97%E6%AE%B5%E7%BA%A7%E6%9D%83%E9%99%90%E6%8E%A7%E5%88%B6)，只给模型中 string 类型字段的写入权限，给 map 类型只读权限。但依然如上，查询时无法将数据库中的`JSON`映射为`string`。

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

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

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

* 无法使用自定义类型，因此无法去实现`Scanner`和`Valuer`接口；
* 无法做软删除，因为[软删除](https://gorm.io/zh_CN/docs/delete.html#%E8%BD%AF%E5%88%A0%E9%99%A4)需要使用 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，查看数据不方便；
