ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ClickHouse SAMPLE采样聚合实战:让BI查询从12秒优化到300毫秒

ClickHouse SAMPLE采样聚合实战:让BI查询从12秒优化到300毫秒 上个月帮朋友优化一个内部BI系统数据量不过几亿行但每次点开报表都要等十几秒。用户的需求其实很简单就想快速看一眼今天大盘怎么样趋势对不对再决定要不要深挖。等十几秒在技术上不算离谱问题是这个“快速预览”的场景根本不该承受全量聚合的成本。我当时的第一反应是加缓存后来发现治标不治本——数据一直在变缓存命中率不高。真正该做的是让查询本身变轻。正好ClickHouse从引擎层面提供了数据采样SAMPLE能力于是我把整个预览链路从全量扫描改成了采样聚合效果立竿见影同样的查询从12秒压到了300毫秒以内。这篇文章就把我在这套方案里的完整思考写出来包括SAMPLE子句的用法、SAMPLE BY采样键怎么选、误差怎么控制以及几个我实际踩过的坑。适合正在用ClickHouse做日志分析、监控大盘、报表预览的同行参考。如果你只是听说过“采样”但还没上手看完应该也能直接在自己的表上跑起来。1. 全量查询的代价与采样预览的适用边界1.1 一次全量聚合到底有多贵接着说开头提到的场景。那张表大概有5000万行单行包含URL、UA等文本字段原始大小约15GBClickHouse列存加压缩之后在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,334420mscount() SAMPLE 0.01501,89350mscount() SAMPLE 0.15,011,455110mscount() SAMPLE 0.525,048,220230ms大概能看出来采样比例和数据量基本是线性的但耗时下降并不是严格的线性——因为即使只读一小部分查询优化、网络传输、聚合的固定开销还在。这张表里0.1的采样跑了110ms对“快速预览”来说已经是完全无感的水准。要提醒的是SAMPLE 0.1并不保证结果恰好是总行数的10%。ClickHouse的目标是“不少于指定比例”所以你会看到结果可能是5,011,455行也可能偏差百分之几。这不影响趋势分析但如果你的下游逻辑对行数敏感比如按固定1000行做分页那就应该用定行数的写法或者接受误差。2.3 采样在查询计划中的位置很多人会把SAMPLE理解成“先全量查再随机丢一部分”这其实是误解。在ClickHouse的查询执行中采样发生在数据读取阶段分区裁剪和PREWHERE过滤先执行然后抽样引擎决定读取哪些granule最后才对读出来的数据做聚合、排序。这意味着两件事。第一WHERE条件里的过滤是优先于采样的。过滤后数据越少采样越不稳定。比如一张表本身只有2个granuleSAMPLE 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)说起来抽象我列个直观的数字。p0.5最坏情况时样本量 n标准差95%置信区间宽度1,0001.58%约±3.1%10,0000.50%约±1.0%100,0000.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是不是就能解决。至少在我这几年的实践里它帮我挡掉了很多不必要的计算资源开销。
返回列表