clickhouse 在货拉拉的应用实践,千亿级别数据实现秒级查询

作者:扬大平仔

前携程、网易高级工程师,现为货拉拉高级工程师。热爱技术,敢于将新技术用于项目实践。


前言

        为了解决线上问题定位慢,相应不及时等问题。所以我们决定开发一套智能问题定位系统。对于我们的一些核心系统,每个订单会对应推送多个司机(线上最多达到上千个司机,平均也有几百个司机)。如果要每个司机都记录一条埋点信息那么数据量将会非常庞大,目前埋点数据日均60+亿,一个月接近2000亿数据。这种以司机维度存储数据会存在大量的数据冗余,耗费大量存储等机器资源。并且传统的关系型数据库在动则几十亿到上千亿级别的表上查询,几乎做不到秒级响应。前期我们调研的时候考虑过使用hbase 、druid、kudu等,但这些要么不太适合我们的业务要求,要么就是公司还不支持。后来调研了clickhouse 后,如沐春风。使用clickhouse 能够绝佳的满足我们的需求。首先,clickhouse 不但查询速度快,而且能够像mysql 那样支持多维度查询;其次,clickhouse 的嵌套类型 能够很好的满足我们订单和司机一对多的存储要求。调研后决定存储复合结构数据,即订单维度存储,一个订单对应多个司机。


一、什么是clickhouse?

        ClickHouse是一个用于联机分析(OLAP)的列式数据库管理系统(DBMS)。ClickHouse是Yandex(俄罗斯最大的搜索引擎)开源的一个用于实时数据分析的基于列存储的数据库,其处理数据的速度比传统方法快100-1000倍。ClickHouse的性能超过了目前市场上可比的面向列的DBMS,每秒钟每台服务器每秒处理数亿至十亿多行和数十千兆字节的数据。

详细可以移步官方文档:什么是ClickHouse? | ClickHouse文档


二、ClickHouse在具体业务的实践


1.系统基本设计

为了更好的收集机构化的数据,我们自己开发了一款埋点sdk用于收集各业务放的埋点日志,日志统一异步发送Kafka,然后我们在大数据平台使用flink消费Kafka数据,经过一定处理后写入click house。然后我们也开发了一个专门用于在线查询的系统对外提供查询服务。

2.具体实现

  •  flink 摄入

        因为写clickhouse底层都是使用httpclient的方式写入的,所以对于clickhouse来说单条频繁写入效率很低,适合批量写入。官网建议没批次写入100000+条(要视flink TM 内存大小调整,防止批量过大出息oom)。我们自定义了sink 用于摄入clickhouse,达到一定批次或者执行checkpoint时就写入一次。

  • clickhouse 部署

        为了实现高可用,在具体部署上我们采用的是多副本,写本地表查分布式表。ck的表分为两种:

分布式表

一个逻辑上的表, 可以理解为数据库中的视图, 一般查询都查询分布式表. 分布式表引擎会将我们的查询请求路由本地表进行查询, 然后进行汇总最终返回给用户。

本地表:

实际存储数据的表

1. 不写分布式表的原因

分布式表接收到数据后会将数据拆分成多个parts, 并转发数据到其它服务器, 会引起服务器间网络流量增加、服务器merge的工作量增加, 导致写入速度变慢, 并且增加了Too many parts的可能性.

数据的一致性问题, 先在分布式表所在的机器进行落盘, 然后异步的发送到本地表所在机器进行存储,中间没有一致性的校验, 而且在分布式表所在机器时如果机器出现down机, 会存在数据丢失风险.

数据写入默认是异步的,短时间内可能造成不一致.

2. Replication & Sharding

ClickHouse依靠ReplicatedMergeTree引擎族与ZooKeeper实现了复制表机制, 成为其高可用的基础。ClickHouse像ElasticSearch一样具有数据分片(shard)的概念, 这也是分布式存储的特点之一, 即通过并行读写提高效率. ClickHouse依靠Distributed引擎实现了分布式表机制, 在所有分片(本地表)上建立视图进行分布式查询。

三、线上问题以及解决方案

3.1 线上问题

问题1: 司机明细相关业务查询极其缓慢,一个查询往往要耗时15s, 订单明细相关查询却很快。

问题2: 汇总相关指标查询特别慢,一个查询接近20S

 3.2尝试解决方案     

         通过分析发现,查询缓慢的都是针对司机维度查询的。虽然我们针对司机ID 这个维度建了一个二级的索引 (INDEX driverId_idx drivers.driverId TYPE bloom_filter(0.01) GRANULARITY 5),也分析了查询sql 的执行计划,重执行日志上看是使用到了我们建的二级索引的。但查询还是十分的慢,这完全超过了我们的预期。

  • 尝试绕过嵌套类型作为查询条件

        一开始分析发现,查询慢的都是因为在查询条件中使用了嵌套类型的司机ID作为查询条件,这导致了查询十分缓慢。顾我们尝试使用一个额外的字段来绕过嵌套类型查询。新增一个driverIds 字段,里面包含所有司机ID(多个司机ID用,拼接),在这个字段上建索引。查询司机场景时通过 driverIds like %%查询;使用driverIds作为条件like查询,2600万数据查询耗时3+s左右,性能能提升一倍,但还是不够理想,随着数据量上升,耗时将越来越大。

Select appId,arrayElement(driversDriverId, index) as driversDriverId 
,pushId,orderId,uuid,orderPushType,orderVehicleId,eventTime 
,arrayElement(driversAppStatus, index)as driversAppStatus,eventLogEvent, arrayElement(isWinner, index) as isWinner 
from (Select arrayFirstIndex(id -> id = '9047485', drivers.driverId) as index,appId, 
drivers.driverId as driversDriverId,pushId,orderId,uuid,orderPushType,orderVehicleId,eventTime 
,drivers.appStatus as driversAppStatus,eventLogEvent,drivers.isWinner as isWinner 
from ai_XX_log_db.ai_XXXX_all prewhere driverIds like '%9047485%' 
AND toUnixTimestamp(eventTime) >= 1629907200 AND toUnixTimestamp(eventTime) <= 1629993600 
AND appId = 'XXX' AND scene = 'XXXX' ) 
ORDER BY eventTime DESC LIMIT 1,20;

  • 通过sq执行计划进行分析

        尝试绕过嵌套类型作为查询条件带来的性能提升并不够理想,于是我们决定深入了解clickhouse的一些底层机制。尝试通过SQL执行计划来确定一个sql 的查询瓶颈。目前查看sql 执行计划有两种方法,方法一(20.6之前版本)

clickhouse-client -u xxxx --password xxxxxx --send_logs_level=trace <<< 'your query sql' > /dev/null

 方法二(20.6与20.6之后版本)

explain SQL

方法一是指定clickhouse 执行日志级别为trace,这样可以打印出来sql 各个阶段执行的日志,通过日志型来分析SQL执行情况,能够详细的了解到SQL执行情况。方法二有点像mysql那样,但这个只能打印部分SQL执行情况,不够详细。所以我们最终使用了方法一。

部分问题SQL的执行计划日志:


测试环境2600W 查询耗时大概7.3S+, 生产环境2+亿数据查询耗时15+s。

从上面的执行计划看,driverId_idx 是使用到了的,但主键索引和分区所以都没有用到。没有用到主键索引是导致查询慢的主要原因。至于为什么,这个要从clickhouse的底层存储结构说了,这里不详细说明,想了解的可以去看看这两个帖子:

1、云数据库ClickHouse二级索引-最佳实践 

2、ClickHouse内核分析-MergeTree的存储结构和查询加速

  • 提出解决方案

        通过上面的一系列分析,我们知道导致click house查询慢的根本原因是没有使用到clickhouse主键索引(一级索引)。click house 的存储结构决定对于大数据量查询时,使用主键索引能够精确的找到所需的数据块,减少不必要的数据块扫描,这样更够极大的提高查询效率。顾为了使用司机ID查询的时候也能够用到主键索引,所以我们尝试自己建立一个索引映射机制,类似于hbase 做二级索引。首先建立一张司机和订单的映射表,将查询司机的场景也映射到通过订单查询的场景。如果存储司机维度的数据,那么每天需要存储60亿数据,一个月就是1800亿,这是相当庞大的数据量。为此我们考虑到了分表策略,取司机ID最后一位作为表后缀,即分十张表存储数据,将数据控制在百亿级别。在数据这块我们是使用flink摄入数据的,使用分表了策略后我们又自定义了分表摄入的策略。

1、建立司机和订单映射表:

其中一张表:

CREATE TABLE ai_XX_log_db.ai_XXX_log_driver_local_0 ON CLUSTER ck_cluster (
orderId String,
driverId String,
scene String,
eventTime DateTime 
) ENGINE ReplicatedMergeTree('/clickhouse_ai/tables/{shard}/ai_XXX_log_db/ai_XXX_log_driver_local_0', '{replica}')
PARTITION BY toYYYYMMDD(eventTime) TTL eventTime + INTERVAL 14 DAY
ORDER BY(driverId, eventTime, scene)
SETTINGS index_granularity = 8192;

在测试环境中单表数据在800+亿,通过司机ID、时间和scene 查询订单ID耗时在200ms 以内。然后通过订单ID到主表中查询埋点记录,耗时在300ms以内。

完整查询示例:

select orderId,orderTime,eventTime,drivers.driverId as driversDriverId , 
drivers.isWinner as isWinner,drivers.pkRule as pkRule, 
eventLogLogicType,eventLogRefProp, eventLogEvent 
from ai_XX_log_db.ai_XXXevent_log_all prewhere scene = 'pk_driver_result' 
AND toUnixTimestamp(eventTime) >=1630857600 AND toUnixTimestamp(eventTime) <= 1630944000 
AND appId = 'ai-pk-api' 
AND has(drivers.driverId, '1899302024') 
AND orderId IN ( 
select DISTINCT orderId from ai_order_log_db.ai_XXX_log_driver_all_4 
WHERE driverId = '1899302024' 
AND toUnixTimestamp(eventTime) >=1630857600 AND toUnixTimestamp(eventTime) <= 1630944000 
AND scene = 'pk_driver_result' 
) 
ORDER BY eventTime DESC

通过这样优化后我们绝大部分司机维度查询场景耗时在500ms 以内,有些查询耗时在200ms以内,性能较原来的最高提高了近百倍。

  • 2
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值