较ES降本30%!滴滴ClickHouse日志存储建设实践
较ES降本30%!滴滴ClickHouse日志存储建设实践
随着数据量的不断增长,滴滴在日志存储系统中遇到了ES性能瓶颈问题。为了解决这一问题,滴滴选择将日志存储从ES迁移到ClickHouse(CK),并经过一系列的技术探索和优化,成功实现了系统的升级。本文详细介绍了这一技术实践过程,包括架构设计、存储优化、稳定性保障等方面的实践经验。
一、背景
此前,滴滴日志主要存储于ES中。然而,ES的分词、倒排和正排等功能导致其写入吞吐量存在明显瓶颈。此外,ES需要存储原始文本、倒排索引和正排索引,这增加了存储成本,并对内存有较高要求。随着滴滴数据量的不断增长,ES的性能已无法满足当前需求。
在追求降低成本和提高效率的背景下,我们开始寻求新的存储解决方案。经过研究,我们决定采用CK作为滴滴内部日志的存储支持。据了解,京东、携程、B站等多家公司在业界的实践中也在尝试用CK构建日志存储系统。
二、挑战
面临的挑战主要来自下面三个方面:
- 数据量大:每天会产生PB级别的日志数据,存储系统需要稳定地支撑PB级数据的实时写入和存储。
- 查询场景多:在一个时间段内的等值查询、模糊查询及排序场景等,查询需要扫描的数据量较大且查询都需要在秒级返回。
- QPS高:在PB级的数据量下,对Trace查询同时要满足高QPS的要求。
三、为什么选Clickhouse
- 大数据量:CK的分布式架构支持动态扩缩容,可支撑海量数据存储。
- 写入性能:CK的MergeTree表的写入速度在200MB/s,具有很高吞吐,写入基本没有瓶颈。
- 查询性能:CK支持分区索引和排序索引,具有很高的检索效率,单机每秒可扫描数百万行的数据。
- 存储成本:CK基于列式存储,数据压缩比很高,同时基于HDFS做冷热分离,能够进一步地降低存储成本。
四、架构升级
旧的存储架构下需要将日志数据双写到ES和HDFS两个存储上,由ES提供实时的查询,Spark来分析HDFS上的数据。这种设计要求用户维护两条独立的写入链路,导致资源消耗翻倍,且操作复杂性增加。
在新升级的存储架构中,CK取代了ES的角色,分别设有Log集群和Trace集群。Log集群专门存储明细日志数据,而Trace集群则专注于存储trace数据。这两个集群在物理上相互隔离,有效避免了log的高消耗查询(如like查询)对trace的高QPS查询产生干扰。此外,独立的Trace集群有助于防止trace数据过度分散。
日志数据通过Flink直接写入Log集群,并通过Trace物化视图从log中提取trace数据,然后利用分布式表的异步写入功能同步至Trace集群。这一过程不仅实现了log与trace数据的分离,还允许Log集群的后台线程定期将冷数据同步到HDFS中。
新架构仅涉及单一写入链路,所有关于log数据冷存储至HDFS以及log与trace分离的处理均由CK完成,从而为用户屏蔽了底层细节,简化了操作流程。
考虑到成本和日志数据特点,Log集群和Trace集群均采用单副本部署模式。其中,最大的Log集群有300多个节点,Trace集群有40多个节点。
五、存储设计
存储设计是提升性能最关键的部分,只有经过优化的存储设计才能充分发挥CK强大的检索性能。借鉴时序数据库的理念,我们将logTime调整为以小时为单位进行取整,并在存储过程中按照小时顺序排列数据。这样,在进行其他排序键查询时,可以快速定位到所需的数据块。例如,查询一个小时内数据时,最多只需读取两个索引块,这对于处理海量日志检索至关重要。
以下是我们根据日志查询特性和CK执行逻辑制定的存储设计方案,包括Log表、Trace表和Trace索引表:
1.Log表
Log表旨在为明细日志提供存储和查询服务,它位于Log集群中,并由Flink直接从Pulsar消费数据后写入。每个日志服务都对应一张Log表,因此整个Log集群可能包含数千张Log表。其中,最大的表每天可能会生成PB级别的数据。鉴于Log集群面临表数量众多、单表数据量大以及需要进行冷热数据分离等挑战,以下是针对Log表的设计思路:
CREATE TABLE ck_bamai_stream.cn_bmauto_local
(
`logTime` Int64 DEFAULT 0, -- log打印的时间
`logTimeHour` DateTime MATERIALIZED toStartOfHour(toDateTime(logTime / 1000)), -- 将logTime向小时取整
`odinLeaf` String DEFAULT '',
`uri` LowCardinality(String) DEFAULT '',
`traceid` String DEFAULT '',
`cspanid` String DEFAULT '',
`dltag` String DEFAULT '',
`spanid` String DEFAULT '',
`message` String DEFAULT '',
`otherColumn` Map<String,String>,
`_sys_insert_time` DateTime MATERIALIZED now()
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(logTimeHour)
ORDER BY (logTimeHour,odinLeaf,uri,traceid)
TTL _sys_insert_time + toIntervalDay(7),_sys_insert_time + toIntervalDay(3) TO VOLUME 'hdfs'
SETTINGS index_granularity = 8192,min_bytes_for_wide_part=31457280
- 分区键:根据查询特点,几乎所有的sql都只会查1小时的数据,但这里只能按天分区,小时分区导致Part过多及HDFS小文件过多的问题。
- 排序键:为了快速定位到某一个小时的数据,基于logTime向小时取整物化了一个新的字段logTimeHour,将logTimeHour作为第一排序键,这样就能将数据范围锁定在小时级别,由于大部分查询都会指定上odinLeaf、uri、traceid,依据基数从小到大分别将其作为第二、三、四排序键,这样查询某个traceid的数据只需要读取少量的索引块,经过上述的设计所有的等值查询都能达到毫秒级。
- Map列:引入了Map类型,实现动态的Scheme,将不需要用来过滤的列统统放入Map中,这样能有效减少Part的文件数,避免HDFS上出现大量小文件。
2.Trace表
Trace表是用来提供trace相关的查询,这类查询对QPS要求很高,创建在Trace集群。数据来源于从Log表中提取的trace记录。Trace表只会有一张,所有的Log表都会将trace记录提取到这张Trace表,实现的方式是Log表通过物化视图触发器跨集群将数据写到Trace表中。
Trace表的难点在于查询速度快且QPS高,以下是Trace表的设计思路:
CREATE TABLE ck_bamai_stream.trace_view
(
`traceid` String,
`spanid` String,
`clientHost` String,
`logTimeHour` DateTime,
`cspanid` AggregateFunction(groupUniqArray, String),
`appName` SimpleAggregateFunction(any, String),
`logTimeMin` SimpleAggregateFunction(min, Int64),
`logTimeMax` SimpleAggregateFunction(max, Int64),
`dltag` AggregateFunction(groupUniqArray, String),
`uri` AggregateFunction(groupUniqArray, String),
`errno` AggregateFunction(groupUniqArray, String),
`odinLeaf` SimpleAggregateFunction(any, String),
`extractLevel` SimpleAggregateFunction(any, String)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMMDD(logTimeHour)
ORDER BY (logTimeHour, traceid, spanid, clientHost)
TTL logTimeHour + toIntervalDay(7)
SETTINGS index_granularity = 1024
- AggregatingMergeTree:Trace表采用了聚合表引擎,会按traceid进行聚合,能很大程度的聚合trace数据,压缩比在5:1,能极大地提升Trace表的检索速度。
- 分区键和排序键:与Log的设计类似。
- index_granularity:这个参数是用来控制稀疏索引的粒度,默认是8192,减小这个参数是为了减少数据块中无效的数据扫描,加快traceid的检索速度。
3.Trace索引表
Trace索引表的主要作用是加快order_id、driver_id、driver_phone等字段查询traceid的速度。为此,我们给需要加速的字段创建了一个聚合物化视图,以提高查询速度。数据则是通过为Log表创建相应的物化视图触发器,将数据提取到Trace索引表中。
以下是建立Trace索引表的语句:
CREATE TABLE orderid_traceid_index_view
(
`order_id` String,
`traceid` String,
`logTimeHour` DateTime
)
ENGINE = AggregatingMergeTree
PARTITION BY logTimeHour
ORDER BY (order_id, traceid)
TTL logTimeHour + toIntervalDay(7)
SETTINGS index_granularity = 1024
存储设计的核心目标是提升查询性能。接下来,我将介绍从ES迁移至CK过程中,在这一架构下所面临的稳定性问题及其解决方法。
六、稳定性之路
支撑日志场景对CK来说是非常大的挑战,面临庞大的写入流量及超大集群规模,经过一年的建设,我们能够稳定的支撑重点节假日的流量高峰,下面的篇幅主要是介绍了在支撑日志场景过程中,遇到的一些问题。
1.大集群小表数据碎片化问题
在Log集群中,90%的Log表流量低于10MB/s。若将所有表的数据都写入数百个节点,会导致大量小表数据碎片化问题。这不仅影响查询性能,还会对整个集群性能产生负面影响,并为冷数据存储到HDFS带来大量小文件问题。
为解决大规模集群带来的问题,我们根据表的流量大小动态分配写入节点。每个表分配的写入节点数量介于2到集群最大节点数之间,均匀分布在整个集群中。Flink通过接口获取表的写入节点列表,并将数据写入相应的CK节点,有效解决了大规模集群数据分散的问题。
2.写入限流及写入性能提升
在滴滴日志场景中,晚高峰和节假日的流量往往会大幅增加。为避免高峰期流量过大导致集群崩溃,我们在Flink上实现了写入限流功能。该功能可动态调整每张表写入集群的流量大小。当流量超过集群上限时,我们可以迅速降低非关键表的写入流量,减轻集群压力,确保重保表的写入和查询不受影响。
同时为了提升把脉的写入性能,我们基于CK原生TCP协议开发了Native-connector。相比于HTTP协议,Native-connector的网络开销更小。此外,我们还自定义了数据类型的序列化机制,使其比之前的Parquet类型更高效。启用Native-connector后,写入延迟率从之前的20%降至5%,整体写入性能提升了1.2倍。
七、HDFS冷热分离的性能问题
用HDFS来存储冷数据,在使用的过程中出现以下问题:
- 服务重启变得特别慢且Sys cpu被打满,原因是在服务重启的过程中需要并发的加载HDFS上Part的元数据,而libhdfs3库并发读HDFS的性能非常差,每当读到文件末尾都会抛出异常打印堆栈,产生了大量的系统调用。
- 当写入历史分区的数据时,数据不会落盘,而是直接往HDFS上写,写入性能很差,并且直接写到HDFS的数据还需要拉回本地merge,进一步降低了merge的性能。
- 本地的Part路径和HDFS的路径是通过uuid来映射的,所有表的数据都是存储在HDFS的同一路径下,导致达到了HDFS目录文件数100w的限制。
- HDFS上的Part文件路径映射关系是存储在本地的,如果出现节点故障,那么文件路径映射关系将会丢失,HDFS上的数据丢失且无法被删除。
为此我们对HDFS冷热分离功能进行了比较大的改造来解决上面的问题,解决libhdfs3库并发读HDFS的问题并在本地缓存HDFS的Part元数据文件,服务的启动速度由原来的1小时到1分钟。
同时禁止历史数据直接写HDFS,必须先写本地,merge之后再上传到HDFS,最后对HDFS的存储路径进行改造。由原来数据只存储在一个目录下改为按cluster/shard/database/table/进行划分,并按表级别备份本地的路径映射关系到HDFS。这样一来,当节点故障时,可以通过该备份恢复HDFS的数据。
八、收益
在日志场景中,我们已经成功完成了从ES到CK的迁移。目前,CK的日志集群规模已超过400个物理节点,写入峰值流量达到40+GB/s,每日查询量约为1500万次,支持的QPS峰值约为200。相较于ES,CK的机器成本降低了30%。
查询速度相比ES提高了约4倍。下图展示了bamailog集群和bamaitrace集群的P99查询耗时情况,基本都在1秒以内。
九、总结
将日志从ES迁移到CK不仅可以显著降低存储成本,还能提供更快的查询体验。经过一年多的建设和优化,系统的稳定性和性能都有了显著提升。然而,在处理模糊查询时,集群的资源消耗仍然较大。未来,我们将继续探索二级索引、zstd压缩以及存算分离等技术手段,以进一步提升日志检索性能。