我最早认真考虑 ClickHouse,不是在看性能测评的时候,而是被大数据体育分析的业务数据逼到墙角之后。2021年初,我接手一个足球赛事数据平台的重构,摄像机追踪系统每秒吐过来上千条坐标,比赛事件流在MySQL里堆到两千多万行,几个常用聚合SQL跑一次要三四十秒。比赛直播期间,转播方和教练组等着看实时控球率,那边主播都开嗓了,这边数据还没算出来,那个场面真的很抓狂。
后面我做了个决定:线上业务继续留在MySQL,分析负载全部切到ClickHouse,再用Flink把MySQL数据实时同步进ClickHouse。调整完之后效果很直接——原来三四十秒的聚合查询压到几十毫秒,直播中按分钟刷新的全场统计也稳定扛住。这篇文章就是这套方案从0到1的完整记录,包括数据管道设计、Flink同步细节、ClickHouse表建模、Linux部署集群的实操,以及最后跟Doris做选型对比的思考。如果你也在做体育类数据分析,或者手里压着千万级事件流不知道往哪放,这篇应该能帮你少走不少弯路。
1. 体育数据平台的瓶颈:为什么一场比赛能把MySQL拖到崩溃
1.1 先算清一笔账:单场比赛与整个赛季的数据量
写代码的人经常低估体育数据量级。我拿足球赛举例,让大家有个体感。场上22名球员加1颗球,定位追踪系统按25Hz采样,每秒产生575条坐标记录;一场比赛按110分钟净时间算,定位数据就有接近380万行。事件类数据虽然没这么夸张,但射门、传球、盘带、抢断每条都带起止坐标、球员ID、对手ID和速度方向,场均也在几十万条。如果把摄像机捕捉的战术标签、裁判判罚事件也加进去,一场主流赛事大约能产出400万到500万行明细数据。
赛季维度更夸张。五大联赛一个赛季每家俱乐部打38轮,你分析范围如果是几个联赛加杯赛,再配合多年历史数据,表轻松过亿。我这边平台当时持有8个赛季的历史数据,事件明细超过5亿行,聚合查询在MySQL上基本做不了。
| 数据源 | 单场数据量 | 备注 |
|---|---|---|
| 定位坐标 | ~380万行 | 22人+球,25Hz,90分钟+补时 |
| 事件流 | 30-50万行 | 传球/射门/抢断/犯规等 |
| 传感器体能 | 10-20万行 | 心率、跑动加速度 |
| 周边数据 | 数十万行 | 票务、转播观看、社交互动 |
所以这个场景的本质是:高吞吐写入、大跨度历史、秒级聚合、多维度切片。传统的行存数据库在这里是很吃亏的。
1.2 传统架构的三个痛点
第一,聚合慢。MySQL这类行存引擎要按行读取整条记录,统计控球率时要扫全表,IO开销大。数据量到千万行,带group by和多个countIf的查询,执行计划再优化也很难进秒级。
第二,热点与锁竞争。比赛进行中,事件流持续写入同一场比赛的分区数据,MySQL的锁机制和主从同步在写入高峰容易顶不住,加上分析查询占IO,经常把上游业务库拖慢。
第三,扩展困难。为了支撑分析需求,MySQL方案最后总会演进成"一堆冗余统计表加定时任务"。每加一个指标就要写一段汇总逻辑,凌晨批量算好,白天拉报表。指标一多,表数量爆炸,口径还经常对不上——技术债全部变成运营和数据的吵架现场。
我并不是说MySQL烂。业务系统、订单、用户中心放在MySQL上用得好好的,但当分析负载压过来,行存引擎真心不合适。这也是我后来坚持"业务归业务、分析归分析",把两个体系彻底分开的原因。
1.3 为什么是ClickHouse:列存、向量化和MergeTree
ClickHouse能解决上面问题,靠的是三个核心机制,这也是后面所有表设计和SQL优化的底层依据。
列式存储:同一列的数据连续存放在一起,做聚合时只需要把涉及的列读进内存,跟行存"整行读入再裁剪"完全是两个能耗水平。体育事件表动辄60多个字段,但统计控球率只需要team_id、event_type、time几个字段,列存天然占优。
向量化执行:聚合、过滤、函数计算一次处理一批数据,充分发挥CPU的SIMD能力。实际测试里,5亿行事件明细做一次countIf聚合,ClickHouse在普通服务器上跑约1到2秒,MySQL在同机直接洗洗睡。
MergeTree家族:MergeTree是ClickHouse的存储引擎基座,支持分区、排序、稀疏索引、后台合并,还能通过副本复制。ReplacingMergeTree做幂等去重,SummingMergeTree做预聚合——这两个引擎在我们体育场景里几乎是日常主力。
外加ClickHouse支持大宽表(几百列都没有问题),可以把常用维度全部冗余在明细表里,从根上躲开Join。这一点在第六节讲Doris对比时还要重点说。
2. 整体链路设计:Flink实时同步MySQL到ClickHouse的落地方案
2.1 四层大数据架构里的分工
整个数据链路我按标准四层切:采集层、存储层、计算层、应用层。说得直白点就是:
- 数据源:体育赛事供应商的接口、赛道追踪系统、MySQL业务库(球队、球员、用户、票务)
- 采集与同步:Flink CDC负责MySQL binlog到ClickHouse的实时同步;接口数据通过定时任务落Kafka再进ClickHouse
- 存储与计算:ClickHouse既是存储层也是计算引擎,承担所有分析查询和预聚合
- 应用层:BI报表、实时比赛大屏、教练组移动端、对外数据API
这套设计解决了两个关键问题:MySQL继续扮演OLTP角色,承受高频读写;ClickHouse专职OLAP,承受高吞吐分析和秒级响应。两边井水不犯河水,出问题也不会互相拖垮。
2.2 Flink CDC同步的完整配置
同步工具我选Flink CDC,原因很现实:Flink CDC直接订阅MySQL binlog,不需要额外部署中间件,增量捕获延迟低,且Flink SQL写同步任务无需写大量Java代码,运维同学也能上手。下面这套配置就是我们生产环境在用的骨架。
Flink SQL里先定义MySQL源表:
CREATE TABLE mysql_match_events ( event_id BIGINT, match_id BIGINT, team_id INT, player_id BIGINT, event_type STRING, event_time TIMESTAMP(3), x_coord DOUBLE, y_coord DOUBLE, is_goal INT, updated_at TIMESTAMP(3), PRIMARY KEY (event_id) NOT ENFORCED ) WITH ( 'connector' = 'mysql-cdc', 'hostname' = '192.168.10.21', 'port' = '3306', 'username' = 'cdc_user', 'password' = '********', 'database-name' = 'sports_db', 'table-name' = 'match_events', 'scan.startup.mode' = 'initial', 'server-time-zone' = 'Asia/Shanghai' );再定义ClickHouse目标表:
CREATE TABLE clickhouse_match_events ( event_id BIGINT, match_id BIGINT, team_id INT, player_id BIGINT, event_type STRING, event_time TIMESTAMP(3), x_coord DOUBLE, y_coord DOUBLE, is_goal INT ) WITH ( 'connector' = 'clickhouse', 'url' = 'clickhouse://node1:8123,node2:8123', 'sink.batch-size' = '1000', 'sink.flush-interval'= '1000', 'sink.max-retries' = '3', 'format' = 'json' );最后一条INSERT INTO完成同步:
INSERT INTO clickhouse_match_events SELECT event_id, match_id, team_id, player_id, event_type, event_time, x_coord, y_coord, is_goal FROM mysql_match_events;scan.startup.mode='initial'的意思是任务启动时先做一次全量快照,再无缝切换成binlog增量,历史数据和新数据一次性搞定。生产上注意把binlog格式设为ROW,且给cdc_user授REPLICATION SLAVE、REPLICATION CLIENT相关权限,否则任务起不来。
2.3 同步链路里常见的三个坑
第一个坑是时区。MySQL的DATETIME不带时区,Flink CDC在server-time-zone配置不对时会跟ClickHouse的DateTime字段产生8小时偏差。我们统一约定:MySQL连接串和Flink都指定Asia/Shanghai,ClickHouse表字段带明确时区语义,报表层再转UTC输出。这个坑造成的脏数据排查了我整整一天,说出来都是泪。
第二个坑是ClickHouse的并发写入合并。ClickHouse官方不推荐每批次一条地写,Flink的ClickHouse connector默认按batch-size和flush-interval攒批,我生产配置1000条攒一个批次写入,写入吞吐稳定在每秒几万行。如果业务要求秒级可见,可以把flush-interval调成500毫秒,代价是ClickHouse的part数量增多,合并压力变大。这个需要根据线上写入量来动态调,没有绝对最优值。
第三个坑是更新与删除。MySQL业务库经常会有撤销数据、人工纠错等操作,binlog里对应是UPDATE和DELETE事件。ClickHouse不是为单行更新设计的,我们把目标表设计成ReplacingMergeTree,并在同步SQL里带上update事件(重新INSERT全字段),靠updated_at版本字段做去重。DELETE事件则需要单独写删除标记列,或者定期用轻量删除清理。没有这一步,你会看到同一个event_id在表里出现多行,聚合口径直接错乱。
提示:改动MySQL表结构加列时,Flink CDC同步任务通常需要重启才能拿到新的schema映射。体育建模里这个很常见——业务库今天加一个VAR审核状态,明天加一个门线技术标记,我们专门排了一个同步任务重启窗口。
还有一个小提示:ClickHouse连接串里多写几个节点,Flink写入任务偶发断连时会自动切换,我经历过一次node1重启,靠这个配置稳住了整整一个赛季的数据同步没有断流。
3. ClickHouse表模型设计:让体育指标查询跑进毫秒级
3.1 分区键、排序键和主键怎么定
到了ClickHouse里,建表不是一个SQL的事儿,是先搭骨架再填肉的过程。分区键、排序键和主键三者职责完全不一样,我见过太多人把三者混为一谈,结果查询越跑越慢。
分区键(PARTITION BY):控制数据在物理上按什么粒度切分,主要用于数据生命周期管理。体育数据我按月份分区(toYYYYMM(event_time)),这样删三个月前的明细直接DROP PARTITION,一秒钟的事,不用DELETE扫全表。有人喜欢按天分区,但按天会产生大量小part,合并压力大,查询反而退化。按月是体感和运维的平衡点。
排序键(ORDER BY):这是ClickHouse索引的核心,决定了稀疏索引的排布方式,直接影响查询裁剪能力。我们的事件表明细排序键是(match_id, event_id)。为什么match_id放最前?因为几乎所有分析查询都带比赛维度——"某场比赛""某队本赛季所有比赛"——把match_id放第一位,ClickHouse能利用索引直接跳过大量无关行。
那为什么排序键里没有event_time?这里有个容易被忽略的点:ReplacingMergeTree的去重逻辑是以排序键为唯一标识的。同步链路上UPDATE事件会把整行重新插入一次,想让它替换旧行,排序键里必须有不会变化的主键标识(event_id)。match_id加event_time的组合可能因为人工修正而改变,一旦event_time被更新,新旧两行排序键不一致,去重就失效了。所以我把event_time踢出排序键,靠月份分区来兜住时间范围过滤,这个设计在后续查询中验证是够用的。
主键(PRIMARY KEY):注意,ClickHouse的主键只是索引项,不是唯一约束,可以跟排序键重叠,或者作为排序键的前缀。建表时如果只写ORDER BY不写PRIMARY KEY,主键默认等于排序键。主键不要设置太长,因为每个数据块的主键都要放内存,太长了内存吃紧。我们主键就直接用match_id。
举例我们的核心事件表明细(生产脱敏):
CREATE TABLE match_events ( event_id UInt64, match_id UInt64, team_id UInt16, player_id UInt64, event_type LowCardinality(String), event_time DateTime, x_coord Float32, y_coord Float32, is_goal UInt8, is_success UInt8, updated_at DateTime ) ENGINE = ReplacingMergeTree(updated_at) PARTITION BY toYYYYMM(event_time) ORDER BY (match_id, event_id) SETTINGS index_granularity = 8192;event_type用LowCardinality做编码,因为足球事件类型撑死就二三十种,低基数字段开启字典编码后,存储和CPU都有收益。
3.2 宽表化与字典:告别Join地狱
ClickHouse的Join能力是相对弱项,特别是大表跟大表做关联。体育分析恰恰需要把"球员基础信息""球队信息"这些维度拼到明细上,如果每次查询都去JOIN,查询性能和代码复杂度都很糟。
我的做法是"宽表化+字典"双管齐下。
宽表化:在同步阶段直接把队伍名、联赛、赛季、主客场、球员姓名、位置等维度冗余进事件明细表。反正列存不心疼列数,这张事件表从最初的20列慢慢扩到70多列,单查询完全不需要Join。坏处是同步链路复杂一点,但换来的是查询的简单和快——这笔账非常划算。
字典:低维度查找(比如"按球队ID拿球队名")用ClickHouse自带的dict词典,加载到内存后SQL里直接dictGet,没有Join成本。
SELECT team_id, countIf(event_type = 'pass') AS total_pass, countIf(event_type = 'pass' AND is_success = 1) AS success_pass, round(success_pass / total_pass, 4) AS pass_rate FROM match_events WHERE match_id = 2024091501 GROUP BY team_id;3.3 物化视图:把比赛指标预先算好
有了明细表还不够。比赛进行中,转播方和教练组高频刷新"当前控球率""双方射门比""跑动距离",每次都去扫几百万行明细,即使ClickHouse能扛住,也别这么糟蹋算力。物化视图是这里的正解。
MATERIALIZED VIEW在数据写入时触发增量聚合,结果落到物化表里,查询时几乎零延迟。我们给分钟级比赛指标建了一张物化视图:
CREATE MATERIALIZED VIEW mv_minute_stats ENGINE = SummingMergeTree() PARTITION BY toYYYYMM(event_time) ORDER BY (match_id, team_id, minute_bucket) AS SELECT match_id, team_id, toStartOfMinute(event_time) AS minute_bucket, count() AS event_count, sum(is_goal) AS goals, sum(is_success) AS success_events FROM match_events GROUP BY match_id, team_id, minute_bucket;查询实时比赛大屏时,直接按match_id和minute_bucket查这张物化表,毫秒级返回。另一个思路是定期把"半场统计""全场统计"任务落成AggregatingMergeTree,比如控球率、xG这样的复杂指标,可以用中途聚合结果进一步压榨延迟。
物化视图的代价是占存储、占写入CPU,所以设计时要挑选真正高频的指标,而不是把每个group by都建成物化视图。我是按"被转播大屏或教练组高频拉取的指标优先物化"这个原则来收敛视图数量,目前线上11张物化视图,明细表5亿行,写入瓶颈远没到。
4. Linux部署ClickHouse 21.8.15.7:单机验证到集群分片
4.1 安装方式与初始配置
我们生产环境用的是Linux(CentOS系)的rpm安装方式,版本固定21.8.15.7。为什么固定版本?大数据项目最怕版本漂移,21.8这个分支稳定、新特性够用,而且社区和运维文档齐全。升级的事后面再说,先把业务跑稳。
# 安装服务端和客户端,注意版本号一致 sudo yum install -y clickhouse-server-21.8.15.7.noarch.rpm \ clickhouse-client-21.8.15.7.x86_64.rpm # 启动并设置开机自启 sudo systemctl enable clickhouse-server sudo systemctl start clickhouse-server # 用客户端验证 clickhouse-client --query "SELECT version()"如果公司内网严格,没有外网yum源,就用tar.gz离线包解压到指定目录,写一个systemd脚本托管,效果完全一样。需要提醒的是:先把/etc/clickhouse-server/users.xml里default用户的密码改掉,并把远程访问的listen_host配成0.0.0.0或内网IP,否则服务开在localhost上等于白装,或者裸奔在公网等于给别人送肉鸡。
提示:离线包安装时,需要手工创建clickhouse用户和目录(/var/lib/clickhouse、/var/log/clickhouse-server),chown好权限再启动。rpm包会自动处理,但tar.gz离线包不会,漏掉目录权限问题启动直接报错。
ClickHouse默认就监听两个端口:8123是HTTP接口,用来给BI和Flink连接;9000是原生TCP接口,给clickhouse-client和集群内部通信。两个端口都要在防火墙白名单里按需放开。
4.2 分片副本集群配置与ZooKeeper
单机验证完性能后,我们上了4节点集群:2个分片、每个分片2副本。先说基础组件:ReplicatedMergeTree引擎的副本同步依赖ZooKeeper,所以集群搭建第一步是把ZK集群起来(3节点即可),然后在config.xml里配置。
config.xml的<remote_servers>节点定义了集群拓扑:
<remote_servers> <sports_cluster> <shard> <replica> <host>ck-node1</host> <port>9000</port> </replica> <replica> <host>ck-node2</host> <port>9000</port> </replica> </shard> <shard> <replica> <host>ck-node3</host> <port>9000</port> </replica> <replica> <host>ck-node4</host> <port>9000</port> </replica> </shard> </sports_cluster> </remote_servers>然后配置ZooKeeper节点,和remote_servers在config.xml里都是顶层节点:
<zookeeper> <node> <host>zk-node1</host> <port>2181</port> </node> <node> <host>zk-node2</host> <port>2181</port> </node> <node> <host>zk-node3</host> <port>2181</port> </node> </zookeeper>四台节点的config.xml全部同步这份配置,然后分别重启服务。建表时用ON CLUSTER让集群所有节点同步执行,副本路径里用{shard}和{replica}宏替换:
CREATE TABLE match_details ON CLUSTER sports_cluster ( match_id UInt64, team_id UInt16, event_type LowCardinality(String), event_time DateTime, is_goal UInt8 ) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/match_details', '{replica}') PARTITION BY toYYYYMM(event_time) ORDER BY (match_id, event_time);接着再用Distributed引擎建一张逻辑表,把两个分片的数据合并视图提供给上层查询和Flink写入端:
CREATE TABLE match_details_dist ON CLUSTER sports_cluster AS match_details ENGINE = Distributed(sports_cluster, default, match_details, rand());分布式表是查询的入口,写入端写它,查询端查它。数据按rand()轮询落到分片上,对于"按比赛整体分析"的体育场景是够用的。如果后续某个分析场景有明确的shard key需求——比如想保证同一场比赛的数据落在同一个分片——可以把rand()替换成match_id,这样还有机会做分片裁剪。
4.3 权限与参数调优
权限方面,ClickHouse的grant体系支持列级和行级。我们给不同角色开了差异化权限:分析师只能查select,运营可以查明细但看不到球员薪资和商业敏感字段,同步账号只能写不能查。列级授权按列名控制,行级授权靠行策略(row policy)过滤,这套东西在体育数据对外商业化时特别有用——至少不会因为权限问题把客户数据和内部数据搞混。
参数调优我提三个最见效的:max_memory_usage(单查询内存上限,默认10G,我们按节点内存的60%调)、max_threads(默认CPU核数即可,但并发大的时候要限制单查询线程数,防止一个查询打满全集群)、background_pool_size(后台合并线程,写入量大时调高,避免part积压)。另外如果大量使用低基数枚举字段,建议把allow_suspicious_low_cardinality_types打开,避免建表时报错。
调优一定以监控为准。我用Grafana加clickhouse-exporter盯着系统表和进程指标,看parts数量、merge队列、查询耗时分位数,再做调节,而不是凭感觉乱改配置。
5. 实战场:三类典型分析查询的性能表现
5.1 比赛进行中的实时统计
先看直播场景。转播大屏唤醒时,需要实时拉取双方球队的进球、射门、射正、控球率、传球成功率,还要每60秒刷新。生产上我们直接查mv_minute_stats物化视图:
SELECT team_id, sum(goals) AS goals, count() AS active_minutes FROM mv_minute_stats WHERE match_id = 2024091501 GROUP BY team_id;这个查询在物化视图上毫秒级返回。如果没有物化视图,直接扫match_events明细按team_id累加,在千万行级别的单场数据上也只要一两百毫秒,ClickHouse扛得住。所以物化视图解决的不是"能不能查",而是"高并发刷新时不给集群制造压力"。
5.2 球员与球队多维对比
赛后分析场景经常是"本轮所有比赛各队传球成功率""某球员最近5场的跑动距离、冲刺次数""对比两位中后卫的防守动作分布"。这类查询的特征是:过滤条件多变、聚合维度不同。明细表配排序键基本都能应付。
跑动距离这类指标,在追踪坐标表上做,用neighbor函数取相邻采样点求欧氏距离:
SELECT player_id, sum(distance_m) AS distance_m FROM ( SELECT player_id, if(player_id = neighbor(player_id, 1), sqrt(pow(x2 - x1, 2) + pow(y2 - y1, 2)), 0) AS distance_m FROM ( SELECT player_id, x_coord AS x1, y_coord AS y1, neighbor(x_coord, 1) AS x2, neighbor(y_coord, 1) AS y2 FROM player_tracking WHERE match_id = 2024091501 ORDER BY player_id, sample_time ) ) GROUP BY player_id ORDER BY distance_m DESC;注意这里的关键是ORDER BY player_id, sample_time,它是窗口逻辑的顺序基础,ClickHouse的neighbor函数依赖这个顺序才能正确计算相邻点。if(player_id = neighbor(player_id, 1), ...)是防止跨球员边界把两个不同球员的距离累加起来。我见过有人漏了这行,结果跑动距离算出来的值全在瞎编——不用惊讶,坐标追踪数据不按时间排序,距离计算就是纯随机数。
5.3 赛季大跨度趋势分析
最后是运营和教练组最常用的"趋势类"查询:按轮次看球队的状态曲线、球员赛季热力、射门转化率月度变化。这类查询的特点是时间跨度大,如果按事件明细直接聚合,需要扫很长的时间分区;可以先按轮次聚合出中间结果,再在外面套窗口函数算移动平均。一点说明:ClickHouse的窗口函数在21.x上可以用,但需要先开开关(老版本执行SET allow_experimental_window_functions = 1),我们实际是把窗口函数跑在子查询聚合后的数据上,把扫描量控制在最小范围。
-- 老版本需要先 SET allow_experimental_window_functions = 1; SELECT match_round, team_id, goals, avg(goals) OVER (PARTITION BY team_id ORDER BY match_round ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) AS rolling_avg_goals FROM ( SELECT match_round, team_id, sum(is_goal) AS goals FROM match_events WHERE season = '2024-2025' GROUP BY match_round, team_id ) ORDER BY team_id, match_round;这种"子查询先聚合缩小数据量,外层再上窗口函数"的写法,是把窗口分析成本控制在最小范围的通用套路。直接对5亿行明细上窗口函数不是不行,但数据扫描量会成倍增加,运维账单也会成倍增加。
最后给大家一个性能数据参考。线上配置是4节点(每节点32核128G),明细表5亿行,单场比赛事件扫描加聚合在80到300毫秒之间波动;赛季级查询(按轮次聚合)在1秒内返回;物化视图查询普遍小于50毫秒。这套性能在同配置下MySQL做不到,2020年我用MySQL的同样5亿行做季度统计,跑了快20分钟。技术选型正确,省掉的都是真金白银。
6. ClickHouse与Doris的选型:我在体育分析场景下的最终判断
6.1 两类引擎的定位差异
做技术选型时,团队里也讨论过用Apache Doris。这里不谈营销话术,只说我实际感受。
ClickHouse是"为单一大宽表聚合分析而生"的偏科选手,列存加向量化加稀疏索引,聚合性能极其强悍,但对分布式Join、高并发点查、高频单行更新这些场景,设计上就是短板。
Doris是MPP架构,更均衡:兼容MySQL协议、自带完整SQL优化器、支持分布式Join、Unique模型可以高效做upsert,适合"需要频繁更新、多表关联、高并发服务化查询"的BI场景。
体育分析平台如果用Doris,球队、球员、赛事这些维度表是要频繁修改的——比如转会、换教练、改球员号码——Unique模型更新起来确实爽。但我们把维度字段全部冗余进事件宽表之后,"更新维度"变成了"重刷宽表",频率大大降低。
6.2 关键维度对比
| 对比维度 | ClickHouse | Doris |
|---|---|---|
| 大表聚合 | 极强,向量化列存专长 | 强,MPP并行 |
| 点查询并发 | 弱,适合低频精确查找 | 强,适合高并发报表服务 |
| 主键更新 | 需ReplacingMergeTree合并,非实时 | Unique模型实时upsert |
| 多表Join | 弱,建议宽表/字典 | 原生分布式Join |
| 运维复杂度 | 单机即用;集群依赖ZK | FE/BE组件多,部署门槛高 |
| MySQL协议 | 有限 | 兼容性好 |
6.3 什么情况下我会换成Doris
这个表不意味着Doris全面优于ClickHouse。纯粹比"亿级事件明细做聚合",Doris对比ClickHouse没有明显优势,运维成本还更高。但如果你的体育分析平台要对外面向大量用户服务——比如球迷App里的实时积分榜、球员详情接口,每个请求都是几十毫秒的点查或小范围查询,并发几千——那Doris的MPP架构和对MySQL协议的兼容性会让它从容很多。ClickHouse硬顶这种高并发小查询会吃力,需要在前面加一层缓存或查询网关,架构变复杂。
一句话总结我的选型逻辑:分析为主、内部使用、数据量大、查询模式以聚合为主,选ClickHouse;服务化查询、高并发点查、频繁更新维度,选Doris。我们业务后来多了一个球迷端实时排行榜需求,我确实为那一小撮查询单独起了个Doris实例放着,两边互不干扰。没有架构洁癖的人不会把鸡蛋放一个篮子。
最后聊点跟技术无关但跟工作有关的体会。体育数据分析这个赛道,表面是在处理一行行SQL,本质是跟教练、转播方、运营的人性和需求打交道。教练要看"这个球员为什么状态下滑",转播要"下一分钟给镜头哪边",运营要"本赛季哪个环节可以出内容"。工具选对了只是第一步,把技术指标翻译成业务语言才是长期被认可的关键。ClickHouse帮我省出了大量本应该做重复报表的时间,让我有余力去思考这些问题。
再分享一个很便宜但很值钱的习惯:定期翻ClickHouse的system.query_log,里面记录了每一条SQL的耗时和扫描行数,每个月按耗时倒序看Top查询。一半的"慢查询"其实不是引擎慢,而是查询写得太贪婪——比如明明只要team_id维度,却把60多列的明细全select出来。每次从query_log里抓出这种查询,优化一下,一个月下来集群查询水位能肉眼可见地降一截。这个习惯比任何参数调优都省钱,也最容易被忽略。