上个月帮朋友优化一个内部BI系统,数据量不过几亿行,但每次点开报表都要等十几秒。用户的需求其实很简单:就想快速看一眼今天大盘怎么样,趋势对不对,再决定要不要深挖。等十几秒在技术上不算离谱,问题是这个“快速预览”的场景根本不该承受全量聚合的成本。我当时的第一反应是加缓存,后来发现治标不治本——数据一直在变,缓存命中率不高。真正该做的,是让查询本身变轻。正好ClickHouse从引擎层面提供了数据采样(SAMPLE)能力,于是我把整个预览链路从全量扫描改成了采样聚合,效果立竿见影:同样的查询,从12秒压到了300毫秒以内。
这篇文章就把我在这套方案里的完整思考写出来,包括SAMPLE子句的用法、SAMPLE BY采样键怎么选、误差怎么控制,以及几个我实际踩过的坑。适合正在用ClickHouse做日志分析、监控大盘、报表预览的同行参考。如果你只是听说过“采样”但还没上手,看完应该也能直接在自己的表上跑起来。
1. 全量查询的代价与采样预览的适用边界
1.1 一次全量聚合到底有多贵
接着说开头提到的场景。那张表大概有5000万行,单行包含URL、UA等文本字段,原始大小约15GB,ClickHouse列存加压缩之后在5GB左右。这种体量对ClickHouse来说其实不算大,跑一个SELECT count()大概一两百毫秒。但问题在于,BI报表从来不是只查count,它要做GROUP BY、字符串排序、多条件过滤,这些操作一叠加,查询时间直接就奔着秒级去了。
真正让全量聚合“贵”的,不是磁盘扫描,而是两个东西:一是中间结果集,GROUP BY产生的哈希表可能占用大量内存;二是CPU开销,字符串比较、排序、聚合函数都要吃CPU。数据量翻10倍,查询时间通常不是线性涨,而是更陡。
我举个例子。一个查询做GROUP BY url ORDER BY count() DESC LIMIT 20,全量跑大概4.5秒;同样的逻辑,我用SAMPLE 0.1只扫了十分之一的数据,耗时140毫秒。4.5秒和140毫秒,差了30多倍。而且采样得到的Top URL列表和全量结果重合度在90%以上——对快速预览来说,这个精度完全够用。
1.2 采样能回答哪类问题,不能回答哪类
这里需要划一条清晰的线。采样适合回答的是“趋势、分布、占比、排序、异常”这类统计型问题:
- 今天请求量比昨天涨了还是跌了
- 哪个接口的P95延迟最高
- 流量来源的占比大概什么样
- 哪几个URL占了大头
- 某个时间段是否出现突刺
不适合的是需要精确结果的场景:
- 财务金额汇总、对账、审计
- SQL里涉及所有明细行的导出任务
count(DISTINCT user_id)这类去重计数——后面我会专门讲为什么它不能简单放大
把这两类问题分清,采样才不会“翻车”。我见过有同事对订单金额表做采样统计,得出一个“大约”的数字直接发给了财务,这就属于用错了地方。
1.3 为什么是ClickHouse来做这件事
ClickHouse把采样做成了引擎级别的能力,而不是应用层的玩具。SAMPLE BY在MergeTree建表时就固定了采样键,查询时用SAMPLE子句即可,完全不需要在业务代码里写随机逻辑。
关键是它的采样粒度。ClickHouse不会逐行随机抽,而是按数据块(part/granule)的维度决定哪些数据参与查询。这样做的好处是:读取路径依然是顺序扫描,压缩块的解压效率不会被破坏,I/O友好。代价是采样误差比理论上“逐行随机”大一些。这是一个典型的用少量精度换取数量级性能提升的工程取舍。
2. SAMPLE子句的本质:不是LIMIT,是块级别的随机抽取
2.1 三种采样写法与语义
SAMPLE基本语法:
SELECT ... FROM table SAMPLE k; SELECT ... FROM table SAMPLE k OFFSET m;k有两种常见写法,含义完全不同,这点很容易搞混:
- 小数比例:
SAMPLE 0.1,表示大约取10%的数据。k是0到1之间的浮点数时,按比例采样。 - 整数行数:
SAMPLE 100000,表示大约采样100000行。k是整数时,按行数采样。
注意,SAMPLE 100是取约100行,而SAMPLE 0.1是取10%,一个是绝对数,一个是比例,写错了结果会完全对不上。
OFFSET用于分段采样。SAMPLE 0.1 OFFSET 0.5,表示跳过前50%,从50%到60%这一段里采样10%。这在做交叉验证时很有用:你可以用OFFSET 0、OFFSET 0.5取两段互不重叠的样本,对比两次查询的结果是否稳定。
2.2 直接跑一次:采样系数与结果估算
我用本地一张5000万行的访问日志表试了一下,结果如下:
| 查询 | 数据量 | 耗时 |
|---|---|---|
| count() | 50,102,334 | 420ms |
| count() SAMPLE 0.01 | 501,893 | 50ms |
| count() SAMPLE 0.1 | 5,011,455 | 110ms |
| count() SAMPLE 0.5 | 25,048,220 | 230ms |
大概能看出来,采样比例和数据量基本是线性的,但耗时下降并不是严格的线性——因为即使只读一小部分,查询优化、网络传输、聚合的固定开销还在。这张表里0.1的采样跑了110ms,对“快速预览”来说已经是完全无感的水准。
要提醒的是:SAMPLE 0.1并不保证结果恰好是总行数的10%。ClickHouse的目标是“不少于指定比例”,所以你会看到结果可能是5,011,455行,也可能偏差百分之几。这不影响趋势分析,但如果你的下游逻辑对行数敏感,比如按固定1000行做分页,那就应该用定行数的写法,或者接受误差。
2.3 采样在查询计划中的位置
很多人会把SAMPLE理解成“先全量查,再随机丢一部分”,这其实是误解。在ClickHouse的查询执行中,采样发生在数据读取阶段:分区裁剪和PREWHERE过滤先执行,然后抽样引擎决定读取哪些granule,最后才对读出来的数据做聚合、排序。
这意味着两件事。
第一,WHERE条件里的过滤是优先于采样的。过滤后数据越少,采样越不稳定。比如一张表本身只有2个granule,SAMPLE 0.1可能一个granule都抽不到,返回0行,这是正常的。
第二,你无法通过SAMPLE来减少WHERE的扫描成本——如果过滤条件本身要扫全表,采样帮不了太多。正确姿势是让采样和过滤一起工作:先通过分区、索引把范围缩到最小,再看采样能否进一步降低数据量。
3. SAMPLE BY采样键:建表时就要想清楚的三个问题
3.1 为什么采样键必须出现在主键里
SAMPLE BY是建表时声明的,而且有一个硬性要求:采样表达式必须包含在ORDER BY主键中。比如:
CREATE TABLE access_log ( event_time DateTime, user_id UInt64, url String, status UInt16, response_ms UInt32 ) ENGINE = MergeTree PARTITION BY toYYYYMM(event_time) ORDER BY (event_time, user_id) SAMPLE BY user_id;如果你写了SAMPLE BY url,但ORDER BY里没有url,建表直接报错:
Sampling expression must be present in the primary key
这个设计很好理解:采样要利用主键索引做稀疏读取,如果采样键不在主键里,就无法高效定位哪些块需要读。
3.2 选业务实体的键还是随机键
我见过很多人的第一反应是:用rand()当采样键,这样最随机。理论上确实更接近“均匀随机”,但实际上有一个严重的坑:rand()没有业务含义,同一个用户的数据会被拆得七零八落。
举个具体例子。你想分析用户留存:某用户在一天内产生了20条事件。如果采样键是user_id,用SAMPLE 0.1时,这20条要么全部进入样本,要么全部不进入——用户的完整行为链被保留了。如果采样键是rand(),这20条事件会被随机打散,其中可能只抽到2条,你算出来的留存率完全失真。
所以采样键的第一原则是:选你分析时的“分析实体”。分析用户,用user_id;分析设备,用device_id;分析店铺,用shop_id。这样采样后,实体层面的统计口径不会被破坏。
3.3 倾斜数据下采样键的最优解
另一个需要考虑的是数据倾斜。用user_id做采样键,如果头部用户的数据量十倍于普通用户,那么当头部用户在采样区间内被抽中时,整体结果会被严重放大。
我处理过一张埋点表,前1%的用户贡献了60%的事件量。直接用user_id采样,两次SAMPLE 0.1的结果相差超过30%。解决办法有几种:
- 把超高活用户单独分区/分表,主表只留普通用户,再采样;
- 采样键改为
cityHash64(user_id) % 100这种分桶表达式,让采样单位从用户变成用户桶,降低单用户权重; - 如果只是想看整体趋势,不考虑用户维度,也可以直接用rand()做采样键,但要注意上述留存类分析做不了。
这三种方案没有绝对最优,取决于你的分析场景。关键在于:不要在业务跑起来之后才发现采样结果不稳,建表前把倾斜程度摸清楚。我一般会先跑一个SELECT count(), uniqExact(user_id) FROM table GROUP BY user_id ORDER BY count() DESC LIMIT 10,看看头部用户的占比。
4. 误差从哪来,又怎么控:采样结果的可信度工程化
4.1 误差的三个来源
采样结果的偏差主要来自三处。
第一是抽样误差,这是所有采样方法都逃不掉的。样本越多,误差越小,关系大概是1/sqrt(样本量)的级别。
第二是块级采样的结构性偏差。前面说了,ClickHouse不是逐行随机抽,而是按采样键/数据块选取。如果数据在块内分布不均匀,比如同一个事件在某个时间段集中写入,就可能出现系统性偏差。
第三是数据倾斜。个别key占的比重过高,抽中与没抽中结果差别巨大。这种误差不是随机误差,是结构性问题,需要靠前文说的分桶、拆分等方法解决。
4.2 一个可复现的误差估算方法
在ClickHouse里验证误差其实很容易,不需要数学推导。同一张表,用OFFSET取两段独立样本,分别算同一个指标,看差距:
SELECT count() FROM access_log SAMPLE 0.1 OFFSET 0; SELECT count() FROM access_log SAMPLE 0.1 OFFSET 0.2; SELECT count() FROM access_log SAMPLE 0.1 OFFSET 0.4;正常来说,如果采样键选得好,这几段样本算出的占比、均值、分位数应该比较接近;如果差距大到不可接受,说明采样方案有问题,得回3.3节找原因。
如果还想更精确,可以用一个经典公式估算比例指标的标准误。假设样本量为n,某个比例的真实值为p,那么样本比例的近似标准差是:
sqrt(p(1-p) / n)
说起来抽象,我列个直观的数字。p=0.5(最坏情况)时:
| 样本量 n | 标准差 | 95%置信区间宽度 |
|---|---|---|
| 1,000 | 1.58% | 约±3.1% |
| 10,000 | 0.50% | 约±1.0% |
| 100,000 | 0.16% | 约±0.31% |
所以,一个指标如果本身是“5%左右”的占比,你用SAMPLE 0.01从5000万行表里抽5万行,估算出5.2%和5.8%,误差可能在零点几个百分点级别,对绝大多数业务预览够了。但你要是抽完只有几百行,那结果就别当真了。
4.3 工程上的三个控误差手段
第一个手段是多次采样取稳定值。对同一时间段反复跑几次SAMPLE,看指标是否跳。如果跳得厉害,把多次结果做平均,或者降低采样比例到更稳定的量级。
第二个手段是分而治之。把关键指标拆成“大维度分组统计”。比如按天、按小时分别采样聚合,不要让一天的流量波动影响整周的趋势判断。
第三个手段是关键指标不走采样链路。总量、金额、唯一用户数这些和钱、和KPI直接挂钩的指标,用物化视图或者单独的精确聚合表来算;采样只用来做探索性的快速预览。这在系统设计上不复杂,但能避免很多业务上的麻烦。
5. 落地实战:访问日志快速预览与聚合链路改造
5.1 表结构与采样键设计
接回最开始的场景。我改造的是一张访问日志表,目标是让业务方在BI里能秒开“今日大盘”。
表结构:
CREATE TABLE access_log ( event_time DateTime, user_id UInt64, url String, status UInt16, response_ms UInt32, country LowCardinality(String) ) ENGINE = MergeTree PARTITION BY toYYYYMM(event_time) ORDER BY (event_time, user_id) SAMPLE BY user_id;ORDER BY选择了(event_time, user_id)。这样按时间范围过滤时能走主键索引,同时user_id作为采样键也保留在主键内。
5.2 趋势、TopN、分位数三组经典查询
改造后的预览查询大概长这样。
总量趋势:采样后乘系数放大,用来估算绝对值。
SELECT toStartOfHour(event_time) AS hour, count() * 10 AS est_requests, count() AS sampled_requests FROM access_log SAMPLE 0.1 WHERE event_time >= today() GROUP BY hour ORDER BY hour;Top URL:排序逻辑在采样样本上做,然后取Top 20。这个场景下排序的重合度很高,不用乘系数,直接用样本排名即可。
SELECT url, count() AS c FROM access_log SAMPLE 0.1 WHERE event_time >= today() GROUP BY url ORDER BY c DESC LIMIT 20;P95延迟:分位数是位置统计量,均匀采样下偏差相对可控,同样可以直接算。
SELECT quantile(0.95)(response_ms) AS p95, quantile(0.99)(response_ms) AS p99 FROM access_log SAMPLE 0.1 WHERE event_time >= today() - INTERVAL 1 DAY;这三条查询在改造后,耗时基本都压到了200毫秒以内。没改造前,第一条全量跑可能要5秒。
5.3 从预览切换到精确:缓存与物化视图兜底
采样查询再快,也顶不住频繁反复点。我当时的做法是两层。
第一层,BI前端缓存采样结果,5分钟过期。大盘上看到的是“近5分钟的采样快照”,叠加一个“采样”标记,业务方知道这不是精确数字。
第二层,针对真正要精确的指标——比如总订单数、总销售额——单独建物化视图,按分钟预聚合。这些指标不会用采样,而是走精确聚合。
这样整个链路就有层次:精确指标走物化视图兜底,探索性指标走采样快速预览。这也是我认为采样在工程里正确的定位——它不是替代精确计算,而是把“快”和“准”分层交给不同的机制去完成。
6. 采样路上的坑:我踩过的和帮你避开的
6.1 坑一:用低基数字段做采样键,结果要么全有要么全无
第一次做采样表时,我贪图方便,用status字段当采样键。status只有200、404、500等几个值。结果SAMPLE 0.1时,经常抽出来全是200,少数情况混进几条500。原因很简单:低基数字段的粒度太粗,数据块天然按这些值聚集,采样等于在“选块”而非“选样本”。
排查方式也简单:跑两次SAMPLE 0.1 OFFSET 0和SAMPLE 0.1 OFFSET 0.5,对比status的分布。如果两次结果天差地别,采样键基本可以确认有问题。
6.2 坑二:count(DISTINCT)直接按系数放大,偏差不可控
这是采样里最容易翻车的一类操作。假设全表有100万独立用户,你SAMPLE 0.1后发现样本里只有8万独立用户,顺手乘10变成80万。问题是,用户出现的频率各不相同,样本里的独立用户数绝不等于总体独立用户数的10%。
这件事情没有简单的放大系数。我踩过之后总结的经验是:唯一值类指标要么走精确预聚合,要么用HyperLogLog之类的基数估计。ClickHouse的uniq()、uniqHLL12()在底层也是概率结构,不是靠采样行数算出来的,这才是去重统计的正确打开方式。
6.3 坑三:分布式表上的SAMPLE比例失真
集群环境里,Distributed表作为查询入口,SAMPLE子句下发到每个分片时,是各自独立执行的。如果各分片数据量不均衡,全局采样比例就会失真。比如两个分片一个2亿行、一个2000万行,SAMPLE 0.1在两个分片上各取10%,但合起来的样本里,大分片的数据占了绝对主导,整体偏差被放大。
解决思路有两个:一是尽量保证分片数据均衡;二是遇到这种场景,把采样下推到本地表去验证,必要时用加权系数修正。最稳妥的是在有Distributed表的情况下,先确认每个分片的采样结果是否符合预期,再上线到BI。
6.4 最后一个心得:采样不是偷懒,是分层查询策略的一部分
写到这里,想分享一个比较个人的观点。很多人听到采样,第一反应是“结果不精确,能用吗”。但实际做数据平台的人都知道,业务方需要的往往是“先看个大概,再决定要不要深挖”。一个5秒才能出来的精确结果,体验上不如一个200毫秒的近似结果——用户会反复刷新、筛选、探索,最终找到自己想要的方向,然后才需要精确数字。
所以我更愿意把采样当成查询策略的一个层级。它和物化视图、精确查询、缓存组合在一起,构成一套完整的分层体系。预览快,探索爽,精确有兜底,这才是大数据分析正确的打开方式。
以后你遇到“数据太大,查询太慢,但业务只要看个大概”的需求,别急着上昂贵的计算引擎,先想想ClickHouse的SAMPLE是不是就能解决。至少在我这几年的实践里,它帮我挡掉了很多不必要的计算资源开销。