首先给出一个核心结论:Golang与ClickHouse集成时遇到的坑,表面上看起来很多,但绝大部分都可以归结为以下几个典型问题。要么是协议端口不匹配,要么是批量插入的方式不正确,再就是Nullable字段与时区未显式处理——这些问题都属于“一旦知道就很简单,不知道就会卡很久”的类型。

接下来,我们逐一剖析最关键的几个要点。
ClickHouse 连接不上?八成是协议端口不匹配
你遇到的大多数连接失败问题,其实并非密码错误,而是驱动默认使用的协议与你服务端开放的端口不一致。这是最基础的入门级问题。
clickhouse-go v2 默认走的是 TCP 协议,默认端口是 9000。但你本地开发环境很可能只开了 HTTP 协议的 8123 端口;反过来,云服务又经常只开 TCP 端口,禁用了 HTTP。两边一错位,自然就连不上了。
解决方案也很明确——不要依赖自动协商,把协议配死:
- 如果服务端只监听
9000端口,DSN 这么写:tcp://127.0.0.1:9000?database=default&secure=false&compress=false - 如果只开放了
8123,DSN 就改成https://127.0.0.1:8123?database=default&compress=true。但注意,HTTP 模式下,URL 里user:pass格式的认证会被忽略,必须在连接时显式传入Auth: clickhouse.Auth{Username:"default", Password:"xxx"}
还有一个容易忽略的细节:别指望驱动自己去猜协议。你最保险的做法是显式设定 Protocol: clickhouse.HTTP 或 Protocol: clickhouse.Native。如果不设,默认就是 Native(TCP),它就会尝试去连 8123 端口,导致超时报错“dial timeout”。
百万行 INSERT 导致内存崩溃?不要拼接 SQL,用 PrepareBatch 控制批大小
新手常犯的错误是:获取数据后,逐条执行 INSERT 语句,或直接拼接一个超长的 SQL 字符串一次性提交。这样做的直接后果——GC 飙升、内存暴涨、甚至 OOM,最终还会被 ClickHouse 自身的 max_insert_block_size 限流给堵住。
问题的关键不在于“怎么插”,而在于“怎么分批”。
正确的做法是:
- 彻底放弃
db.Query("INSERT ...")和循环调stmt.Exec() - 改用
conn.PrepareBatch("INSERT INTO t (a,b) VALUES (?,?)"),每一批塞10000到100000行数据 - 在调
batch.Send()发送完毕后,用batch.Reset()复用实例,而不是反复 new 新的实例,避免不必要的内存分配 - 如果数据是从文件或管道读进来的,可以优先走
conn.Writer().Write()+bytes.Reader这条路线。底层缓冲是复用的,性能比 batch 模式还能快 3 到 5 倍,而且内存管理更稳
还有一个提醒:如果你不知道 ClickHouse 的 max_insert_block_size 默认是多少(通常是一百万行),强行用更大的批插入,服务端也会自动拒绝。控制好批大小,既是保护自己,也是尊重系统。
Scan() 发生 panic 或获取到零值?需要重视 Nullable 和时区
这两个问题是连在一起的,而且一旦出现,排查起来非常隐蔽。
先说 Nullable。ClickHouse 的 Nullable(String) 字段,你在 Go 里用 sql.NullString 去接收是必须的,不是“建议”,是“刚需”。如果你图省事直接用 *string 去 Scan,程序会直接 panic。类似的,Nullable(Int64) 对应 sql.NullInt64……这是一条铁律,没什么好商量的。
再说时区。很多同学遇到 time.Time 解析失败的情况,第一反应是代码写错了。其实更常见的原因是服务端和客户端的时区没对齐。要知道,ClickHouse 服务端默认是 UTC 时区,你如果在 Go 客户端用东八区去解析服务端返回的时间戳,结果极大概率是错的。
解决方法很简单:连接建立后,第一时间执行 conn.Exec("SET timezone = 'Asia/Shanghai'");或者在 DSN 里加上 &timezone=Asia%2FShanghai。这样服务端和客户端时区就统一了。
还有两个细节值得记住:
SELECT语句一定要显式列出列名,不要写SELECT *。因为你Scan(&a, &b, &c)的顺序必须和查询字段严格一致,顺序错了,结果就是错误的- 扫描循环结束后,务必检查
if err := rows.Err(); err != nil。很多你以为“空结果”的查询,其实是查询中途静默失败了,你根本没察觉
查询卡死或频繁超时?context 和 max_execution_time 双重保护才可靠
ClickHouse 的查询,不是越快越好,而是越可控越好。没有客户端超时,也没有服务端限流,一个慢查询就能让你整个 goroutine 一直卡住——这在线上是非常危险的。
每个查询都必须带上两层防护:
- Go 层:用
context.WithTimeout建立上下文,比如ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second),然后把这个ctx传给conn.Query(ctx, ...) - ClickHouse 层:在你的 SQL 语句末尾加上
SETTINGS max_execution_time = 20——这是服务端主动中止查询的防御,如果查询执行超过 20 秒,服务端会自己停了它
这两层超时不能相互替代。context 超时是客户端主动断开连接,但服务端还在跑;max_execution_time 是服务端主动中止,但客户端可能等不到自己设定超时就先收到错误。漏掉任一个,线上都可能出现连锁反应——一个慢查询拖慢所有查询,最终雪崩。
还有一个易错点:WHERE 条件里如果要跟 DateTime 字段比较,别直接用 time.Now() 丢进去。因为服务端和客户端的时区没对齐时,查出来的数据可能都是空的。稳妥的做法是,将 time.Now() 转成带时区的字符串,你再传入 SQL 中查询。
