做游戏数据分析的人,应该都有过这种体验:DAU 才几百万,Hive 跑每日留存要十分钟,MySQL 里查七日活跃直接超时,老板还要你在看板上实时看付费转化率。去年我们团队被各种慢查询逼到墙角,最后被同事拉去研究 ClickHouse,前后折腾了两个月,算是把一套完整的游戏分析链路跑通了。这篇东西就把我的实践过程、踩过的坑、和最终沉淀下来的方案完整写出来,给准备引入 ClickHouse 做大数据分析的团队一个真实参考。如果你也正要处理这种“量大、维度多、要秒级响应”的游戏日志数据,这篇内容应该能帮上忙。
1. 为什么要给游戏分析引入 ClickHouse
1.1 游戏日志数据到底有多“任性”
游戏数据分析的场景相比普通互联网业务,有几个特别麻烦的地方:第一,事件量特别大,用户的每次点击、每局对战、每个道具掉落、每次付费都会产生一条日志,光是行为埋点一天就能积累几十亿条;第二,维度特别多,同一份分析里面可能要同时看渠道、版本、地图、角色、设备、网络环境、深浅链路等各种维度;第三,查询模式高度聚合,大部分分析都是先按天、按小时分组,然后做 COUNT、SUM、AVG,计算 DAU、留存、付费 ARPU,这种查询如果走传统行存储或者 Hadoop 全套链路,往往要等很久。
我们团队早期用的是 Hive + MySQL 的组合。晚上跑离线宽表任务,早上的日报才能出来;白天临时想看一个线上活动效果,要写 Hive SQL 然后排队跑十分钟。后来数据量继续涨,MySQL 里业务表越来越大,单表查询经常九十秒起步,这就到了非换不可的地步。
这种背景下,我们需要的是一个能直接应对“高频写入 + 高维度聚合 + 秒级响应”的 OLAP 引擎,而不是再堆一层批处理。ClickHouse 在列式存储和向量化执行这两件事上做得足够极致,才让我们在选型时下定决心。
1.2 ClickHouse 与 Doris、Hive 的选型对比
当时我们花了两周做调研,其实圈内可选的 OLAP 引擎不少,主要对比了 ClickHouse、Doris、以及原有 Hive 体系升级方案。
ClickHouse:俄罗斯 Yandex 公司开源,列式存储、向量化执行,单表聚合性能极强,这一点在游戏事件分析里太重要了,因为我们的查询几乎都是“按维度聚合计数”。短板是多表 JOIN 不是强项,复杂 JOIN 容易爆内存,好在游戏分析通过宽表建模可以绕开。
Doris:百度开源、Apache 孵化,MPP 架构,MySQL 协议兼容性好,多表 JOIN 支持更好,在明细加聚合混合场景、实时更新场景有优势。不过我们团队对 SQL 方言和数据导入的多样性不如 Kafka 体系熟悉,而且针对“高频事件写入 + 大宽表分析”的场景,ClickHouse 的 MergeTree 引擎体系更成熟一些。如果你们的主要诉求是“以用户为主的报表系统,有大量 JOIN”,Doris 也是一个不错的选项;但我们这类以事件流为主的游戏日志,用 ClickHouse 更对味。
Hive:用来做离线全量计算没问题,但交互式查询和实时看板基本没戏。Spark SQL 也一样,调度、资源池、metric 耗时摆在那里,本质上是批处理思维,不适合做在线分析。
我们最终选了 ClickHouse,还要加一句:如果你们已有 Doris 团队且精力充足,走 Doris 也完全可以,选型这件事没有绝对对错,只有场景匹配度。
1.3 整体架构与数据链路设计
整个架构分成四个模块。埋点采集:客户端打点统一走日志服务,过滤脏数据后写入 Kafka。实时处理:Flink 消费 Kafka 数据,做格式清洗、字段补全、维表关联,同时把需要同步的 MySQL 业务表通过 Flink CDC 拉进 ClickHouse。存储与计算:ClickHouse 作为 OLAP 引擎,承载事件明细表、聚合结果表、宽表,以及给报表和多维分析提供 SQL 接口。查询服务:数据平台后端用 API 封装 ClickHouse 查询,支撑自助分析、监控大屏和运营后台。
这套链路里我最满意的是把明细和聚合分开了。明细数据进 MergeTree,聚合结果通过物化视图和定时任务生成,查询全部走聚合表,没有 Hive 时代那么复杂的调度,半夜很少因为某个依赖任务失败导致第二天报表出不来。后面我们逐步把原本跑在 Spark 上的十几个粉丝报表迁移到了这套链路,运维压力小了很多。
2. ClickHouse 部署与核心配置:文档里不会明说的细节
2.1 ClickHouse 21.8 在 Linux 上的安装记录
部署版本我们选了 21.8.15.7,这个版本生命周期比较健康、社区反馈稳定,问问题也好查。服务器用的是 16 核 64GB 的云主机,磁盘走了 SSD。安装时用的是官方 RPM 仓库,也可以下载 tgz 包离线部署。tgz 方式:从 packages 页面下载 clickhouse-server、clickhouse-common-static、clickhouse-client 三个包,解压到 /opt/clickhouse,然后创建独立系统用户,修改 config.xml 里的<listen_host>、<path>、<tmp_path>等关键路径,再启动服务。
我建议至少给 ClickHouse 配一块独立的 SSD 数据盘,别和系统盘混在一起,因为 ClickHouse 的数据写入是批量追加的,默认的 merge 和分区重排会吃不少磁盘 IO。生产环境最好用 NVMe SSD,如果预算有限,冷数据可以放到普通机械盘,配合后面会讲的 TTL 冷热分离策略,成本能压下来。
安装后第一件事是压测。我当时直接灌了一个 200GB 的样本数据,测了 group by 和 filter 查询,结果相当震撼:以前 Hive 跑三分钟的任务,在 ClickHouse 里几十毫秒到几百毫秒就能返回。当然这是理想情况,但确实坚定了我们换平台的信心。注意:安装前检查 CPU 是否支持 SSE 4.2 指令集,老机器上很容易踩到这个坑,启动直接报 Illegal instruction。
2.2 表引擎选型与事件表建模
ClickHouse 的 MergeTree 家族是我认为最值得花时间研究的部分,没有之一。基础引擎常用四种:MergeTree、ReplacingMergeTree、SummingMergeTree、AggregatingMergeTree。
游戏事件明细表用的 MergeTree。分区用toYYYYMMDD(event_time)按天分区,排序键ORDER BY (event_date, event_type, game_id),因为绝大多数查询都是先按天、按事件类型、按游戏维度过滤。这里有个特别重要的认知:主键和排序键在 MergeTree 里的逻辑不是一回事,索引是稀疏索引,数据按排序键物理有序,所以排序键的选择直接决定查询能不能高效剪枝。我们最开始把 timestamp 放第一位,查询老是慢,后来意识到事件类型本来一屏就几个,应该放前面,这一改查询性能直接翻倍。
维度关联表则用 ReplacingMergeTree,配合写入时按唯一键去重,适合 MySQL 业务表的同步场景。如果你们用 Flink CDC 同步 MySQL,重启任务后会有 binlog 重复消费,ReplacingMergeTree 能用版本列自动在后台合并时去重,这是兜底保障。
指标汇总表用 SummingMergeTree,当我们需要对某些数值字段做累加而不用明细时,这个引擎可以在合并阶段把同键的数据预聚合,查询直接读部分行,性能收益非常大。比如日活分时曲线,用 SummingMergeTree 预聚合小时维度的活跃次数,接口响应就一直在 100ms 上下。
建表参考:
CREATE TABLE game.event_detail ( event_date Date, event_time DateTime, game_id UInt32, channel_id String, user_id UInt64, event_type LowCardinality(String), map_id UInt32, level_id UInt32, duration_sec UInt32, pay_amount Decimal(18,2), ... ) ENGINE = MergeTree() PARTITION BY toYYYYMMDD(event_date) ORDER BY (event_date, event_type, game_id) TTL event_date + INTERVAL 180 DAY SETTINGS index_granularity = 8192;其中event_type用了LowCardinality,这是一个非常实用的优化。游戏里事件类型就几十个,字符串转换成类似字典 ID 的表示,存储空间和查询速度都能大幅提升,实测单个字段压缩比能达到几十倍。注意 LowCardinality 不太适合高基数数据,比如用户 ID,如果强行用,反而会拖慢查询。
2.3 行级/列级权限设计:让数据安全落地
数据平台的权限问题一直很头疼,游戏行业尤其如此——不同运营、不同数值策划只能看自己负责的游戏和渠道,不能看到全量数据。ClickHouse 从 21.x 开始对 RBAC 支持得很好,我们做了行列权限设计。
权限设计的核心思路有三层。第一层是列级权限,直接通过GRANT SELECT(event_date, game_id, event_type, user_id) ON game.event_detail TO role_operator;让普通运营只能看到指定列。第二层是行级权限,ClickHouse 原生没有行级权限关键词,但可以通过创建带过滤条件的 VIEW 来模拟,比如CREATE VIEW game.operator_data AS SELECT ... WHERE game_id IN (1,2,3) AND channel_id='official';然后把这个 VIEW 的查询权限授给对应角色。第三层是角色管理,用CREATE ROLE role_analyst;定义权限模板,再挂载到用户上,避免一个一个 grant。
这里有个我踩过的坑:创建只读用户时把<readonly>1</readonly>和<allow_ddl>0</allow_ddl>写在 users.xml,却发现 SQL 层还能 grant,权限行为很迷惑。后来才意识到,ClickHouse 的权限模型更新后,之前通过表级别配置做的设置在某些版本有优先级冲突。我的建议是统一用 SQL 层管理权限,不要一半用 users.xml 一半用 SQL,不然会有一堆“看到了但操作不了”的莫名其妙的问题。
另外,如果用分布式表,行级过滤 VIEW 最好建在分布式表外层,统一入口过滤,权限更集中。否则每个 shard 都要执行一遍视图过滤,管理起来容易乱。
3. Flink + Kafka 同步 MySQL 入 ClickHouse:数据管道实战
3.1 同步方案选型:为什么用 Flink CDC
游戏分析里有几类数据必须实时或准实时地从 MySQL 同步到 ClickHouse:玩家基础信息、游戏配置表(道具、地图、任务)、支付订单、客服工单。MySQL 里这些表每天有大量增删改,如果直接定时全量拉,会对 MySQL 造成压力,而且延迟不可控。
我们最终选择 Flink CDC + Kafka 的方式。Flink CDC 底层通过 Debezium 解析 binlog,对 MySQL 只读,不需要改业务表结构,支持增量与全量自动切换。具体链路是:Flink CDC 读 binlog 后把变更数据转成统一的 JSON 消息发到 Kafka,然后由另一个 Flink 任务消费 Kafka,写入 ClickHouse。当然如果只是单表同步、要求不高,Flink 直接接 ClickHouse 连接器写入也可以,但我们中间加 Kafka 一是解耦,二是多个下游(比如实时数仓的 DWD 层)可以复用同一个数据流,这个架构在团队扩展业务时非常舒服。
实测下来,每秒万级的 binlog 事件,Flink 侧延迟在秒级,对游戏实时分析足够了。如果数据量再大,可以调 Flink 并行度,或者把一个大表按 ID 范围拆成多个同步任务。
选型时还有一个可以讨论的方案:如果你们有现成的 Canal 组件,可以直接 Canal 到 Kafka、再用 Flink 或 Spark 或官方 clickhouse-kafka-connect 写 CK。这个无所谓对错,只要延迟和吞吐满足需求即可。如果嫌 binlog 太重,只是同步配置表,每小时全量 load 一次可能都够用。关键是想清楚数据是给谁用的、延迟要求多高。
3.2 数据同步管道搭建细节
这部分我用 Flink SQL 来演示典型链路。
第一步,在 Kafka 建 Topic,原表 binlog 以 JSON 格式写入。
第二步,创建 Kafka Source 表:
CREATE TABLE kafka_source_orders ( op_type STRING, database_name STRING, table_name STRING, id BIGINT, user_id BIGINT, order_amount DECIMAL(18,2), created_at TIMESTAMP(3), PRIMARY KEY (id) NOT ENFORCED ) WITH ( 'connector' = 'kafka', 'topic' = 'game_orders_binlog', 'properties.bootstrap.servers' = 'kafka01:9092', 'properties.group.id' = 'flink-ck-sync', 'scan.startup.mode' = 'earliest-offset', 'format' = 'json' );第三步,创建 ClickHouse Sink 表:
CREATE TABLE ck_sink_orders ( id BIGINT, user_id BIGINT, order_amount DECIMAL(18,2), created_at TIMESTAMP(3), PRIMARY KEY (id) NOT ENFORCED ) WITH ( 'connector' = 'clickhouse', 'url' = 'clickhouse://ck01:8123', 'table-name' = 'game.orders', 'sink.batch-size' = 1000, 'sink.flush-interval' = '1s' );第四步,执行同步写入语句:
INSERT INTO ck_sink_orders SELECT id, user_id, order_amount, created_at FROM kafka_source_orders WHERE op_type = 'INSERT';同步时注意字段类型映射:MySQL 的 datetime 到 CK 通常是 DateTime 或 DateTime64,注意精度;MySQL 的 decimal 到 CK 用 Decimal(18,2),别用 Float,否则金额精度会丢;字符串字段要处理 Nullable 和空字符串默认值,否则写入报错。
我踩过的一个坑是 binlog 里日期字段是字符串,直接写入 DateTime 列时 Flink 默认格式不匹配,导致大量脏数据进入 error topic。解决方法是给时间字段在 SELECT 时用CAST或DATE_FORMAT函数标准化。另外,写入 CK 的 Sink 并行度不建议开太高,ClickHouse 是批量写入友好型数据库,单批次大点写比高并发小批次更优。我们最终配置是 4 并行、1 秒 flush 或者 1000 条批量写,效果最稳。
3.3 查询优化三板斧:排序键、物化视图与预聚合
ClickHouse 查询性能好,但前提是你会建表、会设计,否则照样能跑到十秒。我的优化三板斧是:
第一,排序键。所有 MergeTree 表的 ORDER BY 必须围绕主流查询模式设计。越靠左的字段会被优先用于索引剪枝和排序。比如我们有按渠道分析的需求,渠道基数不大但查询频率很高,可以考虑把 channel_id 放进排序键。如果索引设计不理想,可以用ALTER TABLE ... MODIFY ORDER BY修改,但这会触发后台 merge,数据量大时会有一段时间的 IO 压力,最好在低峰期做。
第二,物化视图。ClickHouse 的物化视图本质是插入触发器,写数据时会同步写一份预聚合结果表。我们给常用指标建了小时级聚合视图,比如每分钟的用户活跃数、付费金额、关卡通过率。查询时直接从视图读,避免扫描全表明细。注意:视图数据是实时写的,但如果主表有历史数据补录,视图不会自动回填历史,需要自己写回填任务。
第三,预聚合与应用层缓存。对留存率这类复杂指标,直接用 Flink 或定时任务算好结果写进 SummingMergeTree 表,分析接口直接查聚合表,不再通过明细表计算。这套模式的查询响应基本在 100ms 左右。
给出一个物化视图片段:
CREATE MATERIALIZED VIEW game.mv_user_dau_hourly ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(event_date) ORDER BY (event_date, event_type, hour) AS SELECT toDate(event_time) AS event_date, toHour(event_time) AS hour, event_type, uniqExact(user_id) AS uv, count() AS pv FROM game.event_detail GROUP BY event_date, hour, event_type;这里有个非常容易踩的细节:物化视图里的uniqExact写的是状态,查询时仍需调用uniqExact(user_id)或uniqCombined(user_id)读取,不能直接 SUM 那个 uv 字段,否则大量内存会花在状态合并上,很容易 OOM。
4. 日常运维中的问题排查实录
4.1 查询从 60ms 变成 6s:一次典型的性能调查
上线一个月后,监控群里有人反馈运营后台一个“渠道留存漏斗”报表变慢了,从之前的 60ms 变成 6 秒。排查过程还挺典型。
第一步,看 query log。ClickHouse 自带system.query_log表,能查每次执行耗时、内存、读写字节。我们发现这个查询扫描了事件明细表几乎全量分区,partition 数量达到 200 多个,说明 WHERE 里时间条件没有生效。深入看 SQL,发现报表工具把时间条件写成了字符串传参,参数类型是 String,而表里是 Date,ClickHouse 做了隐式转换,整个查询走了全表过滤。
解决方法是把时间参数在客户端转成 Date 类型再拼进 SQL。建议在应用层统一用toDate()函数包裹时间字段,或者在 SQL 里写WHERE event_date >= toDate(?) AND event_date <= toDate(?)。
第二步,看 parts 是否健康。某个分区可能有几万个 parts,说明合并线程跟不上写入,查询要读更多片段。用SELECT table, count() FROM system.parts WHERE active GROUP BY table检查,必要时通过OPTIMIZE TABLE ... FINAL手动触发合并,注意选低峰期。
第三步,用EXPLAIN indexes = 1 SELECT ...看查询计划,确认是否走索引、读取了多少 granule。如果 granularity 很高但 reads 很多,那排序键大概率设计不合理。
4.2 数据重复?ReplacingMergeTree 的最终一致性
用 Flink CDC 同步 MySQL 到 CK 后,运营发现订单金额偶发翻倍。排查后发现:Flink 任务重启时,binlog 重复消费,而 CK 的表如果用 MergeTree,写入的重复行就是真实重复数据,用 SUM 计算总额时自然翻倍。
解决办法一是用 ReplacingMergeTree,按业务主键设置 ORDER BY 和 version 列,例如ORDER BY (order_id) SETTINGS version = updated_at。注意 ReplacingMergeTree 的去重发生在后台 merge 时,不是写入时立即去重,所以查询时如果用FINAL或配合GROUP BY加argMax才能拿到准确结果。如果对一致性要求更高,可以在 Flink 端做 keyBy 去重,用 state 记录最新记录,但这样会增加 State 开销。
最终我们生产环境采取的策略是:CK 层用 ReplacingMergeTree 做兜底,Flink 端再按主键做去重,双保险。查询层在实时大屏用SELECT SUM(order_amount) FROM ... GROUP BY date时会出现偶发偏差,但心理预期是最终一致,通常在几分钟内后台 merge 完成后自动恢复正常。
4.3 磁盘告警与 TTL 数据生命周期管理
游戏日志明细数据特别大,一直不清理会撑爆磁盘。我们的策略很简单:业务要求明细数据保留 180 天,超过部分直接物理删除。这个需求用 ClickHouse 的 TTL 非常方便,建表时加TTL event_date + INTERVAL 180 DAY,再配合TTL event_date + INTERVAL 90 DAY TO DISK 'cold',可以把 90 天前的数据移动到冷盘,180 天前删除。
但 TTL 也不是完全没有坑。某次我们的 TTL 不生效,原因是一台机器的merge_with_ttl_timeout被改大了,且后台 merge 压力大,TTL 合并迟迟不执行。建议定期用SYSTEM STOP/START MERGES控制合并窗口,低峰期强制触发合并,同时监控system.mutations队列长度。另一个坑是分区目录包含 TTL 偏移,刚设置 TTL 的旧分区可能不立刻重写,只有新写入的数据才会正确带上 TTL 路径。所以我们在迁移大表时先手动把历史分区卸载到冷盘或直接 truncate,让规则表从头跑。
另外,不要把过期数据都丢进一个冷盘分区,ClickHouse 支持 Disk 分卷,可以把冷热分离做得更细。如果数据量没那么大,TTL to DISK 冷盘就够用了,再省一点可以直接 drop partition。
4.4 性能调优清单
最后汇总一份我实际调优时反复对照的清单,按优先级排列:
| 检查项 | 默认值 | 建议值或策略 |
|---|---|---|
| max_threads | 0(自适应) | 大查询可设为 CPU 核数 |
| max_memory_usage | 默认大 | 单次查询内存限制,防 OOM |
| max_bytes_before_external_group_by | 0(无限制) | 超限时用磁盘外部聚合,防止 GROUP BY 内存爆 |
| background_pool_size | 默认 16 | 磁盘合并线程,可按 CPU 调整 |
| max_concurrent_queries | 默认 0 | 限制并发数,避免高峰期拖垮集群 |
| index_granularity | 8192 | 数据量不大可保持,量大可适当调大 |
| flatten_nested | 1 | Nested 结构选择,需要时了解嵌套效果 |
这些参数改完后通常要重启集群才能生效,修改前记得备份 config.xml 和 users.xml。还要提一句:ClickHouse 单机性能虽然强,但到了更大规模还是要做集群分片。我们目前单机 16 核 64GB 能扛住几十亿条明细,但随着游戏数和埋点事件变多,后面还是要上多副本加分片集群。扩容时横向加节点、配置 Distributed 表,又是一个大课题。
最后分享一点我的个人体会:引入 ClickHouse 之前,我一直以为数据分析的瓶颈在计算引擎,后来折腾完整个链路才发现,真正的工程难度在数据建模和管道稳定性。CK 把查询从分钟级拉到秒级很容易,但如果排序键、物化视图、权限设计没做好,后面每个新需求都会让你想骂人。如果你的团队也在调研 OLAP 引擎,建议先在真实业务数据上做一版完整的小链路验证,别只看官方 benchmark。毕竟引擎好用是一回事,能不能在你家复杂的埋点、审批和变更流程里扎稳根,那才是另一回事。