同一条 SQL 每次查出来不一样:先看它是怎么变的
2850 字 · 约 7 分钟
做 ClickHouse 这些年,陆陆续续有很多人来问我:为什么我的数据一会儿有一会儿没有?为什么同一条 SQL 多次查询,结果不一样?
来问的人里,有刚接触 ClickHouse 不久的,也有在数据领域深耕多年的老兵。从排查的结果看,造成这个现象的原因也不尽相同。查着查着,我觉得有必要写点什么了。
碰到这类问题,我一般会先问对方两个问题:这种现象是偶发还是必现?结果是在几个值之间反复横跳,还是毫无规律?
问第二个问题,是因为结果怎么变,基本就决定了往哪个方向查。在几个固定值之间来回跳,过多久都不收敛,多半是副本之间的数据根本不一致;也来回跳,但过一阵自己收敛到一个值,是副本同步延迟;不跳,只是一次比一次少,最后稳定下来,是去重引擎还没合并完,查询又没加 FINAL;先查得到,后来查不到,再也不回来,是 TTL 把数据删了。
表引擎用错了
这是四种里最常见的,新手尤其容易犯。所谓带副本的集群,就是表用 ReplicatedMergeTree 引擎,数据通过 Keeper 在同一个分片的多个副本之间自动同步。不管查哪个分片的哪个副本,数据都一样,用分布式表查整个集群的时候,协调节点不管从哪个副本取数,结果都一样。
如果建表的时候用的是 MergeTree 呢?假设集群是 2 分片、每分片 2 副本,副本之间没有同步,某张表的数据分布是这样的:
用分布式表查询时,协调节点会在每个分片上挑一个副本,查出来的结果就有四种可能:
| 选中的副本 | 结果 |
|---|---|
| 分片 1 副本 1 + 分片 2 副本 1 | 1, 3, 6 |
| 分片 1 副本 1 + 分片 2 副本 2 | 1, 3, 4, 5 |
| 分片 1 副本 2 + 分片 2 副本 1 | 2, 6 |
| 分片 1 副本 2 + 分片 2 副本 2 | 2, 4, 5 |
这可不就是每次查询的数据都不一样嘛。而且怎么等都不会收敛,因为根本没有东西在同步它们。
很多人会把这个问题和集群配置里的 internal_replication 搅在一起。这个参数管的其实是另一件事:通过分布式表写入的时候,数据是写到分片里的一个副本,还是所有副本都写一份。它为 true,分布式表只写一个副本,剩下的靠 ReplicatedMergeTree 自己复制过去,这是正确的搭配。生产环境绝大多数人都会把它设成 true,ckman 默认也是这么配的。这时候建表要是用了 MergeTree,数据就只进了一个副本,没有任何机制去同步,也就是上面的现象。
反过来设成 false 也不省心。配 MergeTree 的话,分布式表自己往每个副本各写一份,某个副本写失败了,副本之间就悄悄不一致了;配 ReplicatedMergeTree 的话,分布式表写了所有副本,引擎又复制一遍,可能重复写入。
确认的时候不要通过分布式表查,直接看每个副本各有多少行:
SELECT hostName() AS host, count()
FROM clusterAllReplicas('<cluster>', <db>.<local_table>)
GROUP BY host ORDER BY host;同一个分片内的副本行数不一样,而且长时间不一样,再看一眼建表语句的引擎,基本就坐实了。
那么出现这种情况,应该怎么解决呢?
如果数据不重要,或者数据量不大,最安全也最快的办法就是删表,用 ReplicatedMergeTree 重建,把数据重新导一遍。
数据比较多,或者数据源已经没了,没法重导呢?也有办法。可以建一张 ReplicatedMergeTree 的临时表,用 INSERT ... SELECT 把旧表的数据搬过去,然后删掉旧表,把临时表 rename 成旧表的名字。ClickHouse 的 INSERT ... SELECT 性能很高,单节点基本能做到每秒百万行。
ClickHouse 本来还有一种更高效的搬数据方式,MOVE PARTITION TO TABLE。可官方文档写明了源表和目标表必须是同一个引擎族(都是 replicated,或者都不是),所以这条路走不通。
还有一种更简单粗暴的,用 ATTACH TABLE ... AS REPLICATED 把一张 detach 了的 MergeTree 原地转成 ReplicatedMergeTree。较新的版本还支持在数据目录里放一个 convert_to_replicated 标志文件,重启时自动转换。
不过原地转换有个前提,官方文档里写了,但很容易被跳过:如果不同副本上的数据不同,要先同步,或者除了一个副本以外,把其他副本上的数据都删掉。而你会碰到这个问题,恰恰说明各副本的数据已经不一样了。所以原地转换之前,得先决定以哪个副本为准,或者先把各副本的数据合到一处。在每个副本上直接原地转换,各副本会带着各自的数据进入复制,结果不会是你想要的。
副本同步延迟
第二种情况,集群配置和表引擎都是对的,查出来的结果还是每次不一样。区别在于,过一阵它会自己收敛。
还是 2 分片 2 副本,数据都往副本 1 上写,副本 2 负责同步,两个分片的副本 2 都落后了:
这时候四种组合查出来分别是 1~6、1,2,3,4、1,2,4,5,6、1,2,4,同样每次都不一样。等副本 2 追上来,四种组合就收敛成同一个结果了。
怎么知道副本同步延迟了呢?查 system.replicas:
SELECT database, table, replica_name, absolute_delay, queue_size, inserts_in_queue
FROM system.replicas
WHERE absolute_delay > 0 OR queue_size > 0;absolute_delay 一直很大,queue_size 也不往下降,就是副本追不上了,再去 system.replication_queue 里看卡住的条目和 last_exception。用 ckman 的话,复制表状态页面上同步进度长期在 80% 以下的副本,八成是出问题了。
副本同步不及时,主要和两方面有关:一个是 Keeper 的健康状态,另一个是拉取线程的个数(background_fetches_pool_size)。
还有一点要知道,分布式查询自己有一道保护:副本延迟超过 max_replica_delay_for_distributed_queries(默认 300 秒)时,会尽量避开这个副本。所以延迟很小的时候,你可能根本看不到结果在跳;延迟很大的时候,又可能被这道保护盖住。要确认,还是直接查 system.replicas。
去重引擎查询没加 FINAL
还有一种情况,集群配置正确,表引擎正确,副本同步也没问题,可查询结果每次还是不一样。
比如用了 ReplacingMergeTree,它按 ORDER BY 的字段去重。去重发生在后台 merge 的时候,相同的数据合并成一条,只留最新的。可 merge 的时机是不确定的,每次查到的数据,有可能还没来得及 merge,也有可能已经 merge 完了。更常见的是一边写一边查,merge 持续在发生,每次查到的都是一个不同的中间状态。
假设查的是 A 字段,数据却是按 B 字段去重的,那同一个 A 值可能一会儿出现好几次,一会儿只出现一次。count() 也一样,会随着 merge 推进一点点变少。
所以去重引擎的表,要查最终状态,就得在表后面加上 FINAL,让数据在查询时就地去重一次(不会把合并结果写回磁盘)。代价是查询会变重,那是另一个话题了。
另外有两个边界很容易漏掉。merge 不跨分区,去重键相同、但落在不同分区的两行,后台 merge 永远不会把它们合掉。FINAL 也不跨分片,分布式表上的 FINAL 是各个分片自己做自己的,去重键相同的两行要是落在了不同分片,FINAL 也去不掉。所以分片键得包含去重键,或者由去重键决定,不然这个问题没法解。
TTL 之谜
除了上面三种,还有一种更难排查的:数据被 TTL 清理掉了。它难查,是因为它看起来像丢数据。
ClickHouse 可以给表设 TTL,超过一定时间的数据自动删掉,做生命周期管理。如果 TTL 设得比较短,插进来的数据又不规范,比如 TTL 是 1 个月,写进来的却混着大量存量数据,一年前甚至几年前的都有,那这些数据可能没过多久就被清掉了。
数据还没来得及删的时候,查询是没问题的,等删除生效,之前的数据就查不到了。要是这种存量数据一直在持续写入,看起来自然就是一会儿有一会儿没有。
那 TTL 一般什么时候生效呢?它不是写入时立即生效的,而是在后台 merge 的时候执行。带 TTL 的 merge 还有频率限制,merge_with_ttl_timeout(默认 4 小时)规定了同一分区两次 TTL merge 之间的最小间隔。所以过期数据写进来以后,可能被看到几分钟,也可能被看到几个小时,取决于它落进的那个分区上一次 TTL merge 是什么时候。
确认的办法是直接看数据本身:
-- 最近写进来的数据里,有多少已经超出了 TTL 窗口
SELECT count(), min(<ttl_column>), max(<ttl_column>)
FROM <table>
WHERE <ttl_column> < now() - INTERVAL 1 MONTH;这个数不是 0,过一阵又变成 0,就是 TTL 在删。修起来不在 ClickHouse 这边,在写入端:把超出保留期的数据过滤掉,或者想清楚这批存量数据本来该不该进这张表。
顺带说一句,给存量数据补算 TTL 用的 MATERIALIZE TTL 是作为 mutation 在后台池里跑的,和 merge 共用一个池。池里空闲额度低于 number_of_free_entries_in_pool_to_execute_mutation(默认 20)时,新的 mutation 不会启动。ckman 的线程池监控页面上能看到 MergesAndMutations 这个池的使用情况。补算的 TTL 迟迟不生效,不一定是没生效,可能只是还在排队。
实际会导致查询结果不一致的原因,可能远不止这几种。但上面这四种,都是我在工作中实实在在碰到过的。下次再碰到结果对不上,先别急着挨个怀疑,连查几次,看看它是怎么变的。
相关:一次讲透 · Too many parts:merge 追不上写入的时候(同一个后台池)