日志系统的背景

  • 日志是线上定位问题排障的重要工具之一,对于可观测领域而言是不可或缺的。在日志系统中,稳定性、成本、易用性以及可扩展性都极为重要。
  • 同时ELK体系是业界最常用的日志技术栈之一,它采用JSON格式作为传输方式,易于多种语言实现和解析,并支持动态结构化字段。ElasticSearch作为存储引擎支持全文检索,可以从大量的日志信息中快速搜索到关键字。Kibana则提供美观易用的数据展示。
  • 基于以上两点,使用 ELK 搭建日志系统是非常常规的技术选型。酷家乐自2015年搭建基于ES的ELK日志系统,已经使用了7年多,集群规模为几十台物理机(规格50核256GB),每天记录超过数百亿条日志。
  • 随着业务系统的高速发展,我们的日志系统规模也在快速扩展。然而,我们也遇到了一些问题。为了解决这些问题,我们不得不向下一代日志系统迈进。

image.png

2020解决ES写入不均等问题

遇到的痛点

  • 索引管理压力:早期,我们根据应用 cmdb 生成 索引名称,每个应用独占一个索引。大约 500 个索引对于ES master 节点有较大的管理压力,在机器关机故障或机器搬迁后数据恢复都极为漫长。
  • 数据写入不均衡:由于新建索引并不知每个索引最终会多大,因此无法确定shard 数量。索引 shard 压力分配不平衡,导致写入QPS 不能最大化,并且导致磁盘占用不均衡。

规模:监控日志ES集群采用k8s宿主机独占模式部署。规模在20台宿主机左右,每台宿主机共2个节点,1个hot、1个warm。其中 hot 节点承担数据写入和热数据查询,配备SSD磁盘。warm 节点存储读写不频繁的冷数据,配备高容量HDD磁盘。

解决方法

image.png
合并索引:ES 官方推荐,一个 shard 大小在 30GB 左右,根据ES 集群节点数,可以算出一个索引的最佳容量600GB。于是将多个应用日志写入一个索引,最终索引数量减少到约13个;

定时创建:每晚8点,获得前一天各个服务对应的 doc 数量,并计算出 doc 平均大小,从而推断出第二日需要的索引个数,并在 hot 节点上立即创建:

image.png

别名映射:采用背包算法,通过读/写别名的方式尽可能将服务按前一日大小均匀分配到新创建的索引上;

image.png
 

# 创建读写别名
def create_aliases(aliases, yesterday, tomorrow):
    for item in aliases.items():
        actions = []
        for alias in item[1]:
            actions.append({
                "add": {
                    "index": item[0],
                    "alias": alias.replace(yesterday, tomorrow),
                    "filter": {
                        "term": {
                            "aliasName": alias.replace(yesterday, tomorrow)
                        }
                    }
                }
            })
        payload = {"actions": actions}
        print(payload)
        r = requests.post('http://%s/_aliases' % ES_ADDR, json=payload)
        print('%d alias are created, return code %d' % (len(actions), r.status_code))

定时归档:每晚9点,将前一日在hot节点的索引迁移到warm节点,减小hot节点空间占用,并删除warm节点指定时间之前的数据,以清除不再读取的历史数据;

# 选择 warm 节点进行数据归档
"index.routing.allocation.require.temperature": "warm",
"index.routing.allocation.total_shards_per_node": 4

解决后效果

通过别名管理多个应用到一个索引的读写关系:

image.png

下午高峰期单节点基本稳定在 QPS=36K 的写入:  
image.png

随着业务发展遇到的问题

  • 写入延迟:日志的延迟会对排障产生极大负面影响。作为一种应用产生的实时数据,随着业务应用规模发展而紧跟着扩大,日志系统必须在具备高吞吐量的同时,也要具备较高的实时性要求。Elasticsearch由于分词等特性,写入受限时,会产生断路器 429 错误,经过多轮调优后,面对这样的异常,除了扩容没有有效手段能解决。
  • 存储成本:由于ES压缩率不高,长时间存储日志,也会导致磁盘压力。由于这些因素,我们不得不减少日志的存储时长,这些因素也限制了排障的场景。

基于以上问题,同时考虑到成本因素,观测团队着手调研新的存储方案。ClickHouse 从18年后在国内得到广泛应用,其强悍的写入性能得到一致好评,而日志的使用场景恰好是读多写少,因此 ClickHouse 是我们的重点调研对象。

clickhouse 

ClickHouse是一个用于OLAP的列式数据库管理系统,关键特征:

  • 大多数是读请求 、数据总是以相当大的批(> 1000 rows)进行写入 ;
  • 不修改已添加的数据 、每次查询都从数据库中读取大量的行,但是同时又仅需要少量的列 ;
  • 宽表,即每个表包含着大量的列 ;
  • 较少的查询(通常每台服务器每秒数百个查询或更少);
  • 对于简单查询,允许延迟大约50毫秒 、列中的数据相对较小: 数字和短字符串(例如,每个URL 60个字节) 、处理单个查询时需要高吞吐量(每个服务器每秒高达数十亿行) ;
  • 事务不是必须的 、对数据一致性要求低 、每一个查询除了一个大表外都很小、查询结果明显小于源数据(数据被过滤或聚合后能够被盛放在单台服务器的内存中 )。

ClickHouse优势

  • 适合在线查询:意味着在没有对数据做任何预处理的情况下以极低的延迟处理查询并将结果加载到用户的页面中。
  • 支持近似计算:ClickHouse提供各种各样在允许牺牲数据精度的情况下对查询进行加速的方法:

    1. 用于近似计算的各类聚合函数,如:distinct 、quantiles;
    2. 基于数据的部分样本进行近似查询时,仅会从磁盘检索少部分比例的数据;
    3. 不使用全部的聚合条件,通过随机选择有限个数据聚合条件进行聚合。这在数据聚合条件满足某些分布条件下,在提供相当准确的聚合结果的同时降低了计算资源的使用。
  • 支持数据复制和数据完整性:ClickHouse使用异步的多主复制技术。当数据被写入任何一个可用副本后,系统会在后台将数据分发给其他副本,以保证系统在不同副本上保持相同的数据。在大多数情况下ClickHouse能在故障后自动恢复,在一些少数的复杂情况下需要手动恢复。

ClickHouse缺点

  • 没有完整的事务支持。
  • 缺少高频率,低延迟的修改或删除已存在数据的能力,仅能用于批量删除或修改数据。
  • 基于前面两点,clickhouse 应用于业务后台存储是较为困难的。但在监控或大数据场景就非常合适。

Druid

Druid 具有完善的生态,提供了很多非常方便的数据摄入功能,管理UI也比较全,在监控系统中,我们维护了数千核规模的Druid集群;

  • 但它的组件构成非常复杂:这也是我们维护的痛点,节点类型有6种(Overload, Coordinator, Middle Manager, Indexer, Broker和Historical);
  • 除了自身的节点,Druid还依赖于MySQL存储元数据信息、Zookeeper 选举 Coordinator和Overlord、COS备份历史数据。 ClickHouse的架构采用了对等节点的设计,节点只有一种类型,没有主从节点。如果使用了副本功能,则依赖于Zookeeper保存数据段的同步进度;
  • 占用资源高:目前hunter数据存储50%采样,依旧占用了 4000多GB内存;
  • 因为是聚合存储,特别适合存储点击流的数据。

ElasticSearch

ElasticSearch 是一个实时的分布式搜索分析引擎,它的底层构建在 Lucene 之上的。简单来说是通过扩展 Lucene 的搜索能力,最大的优势在于全文索引。

  • 横向扩展性:只需要增加一台服务器,做一点配置,启动一下ES进程就可以并入集群;
  • 分片机制提供更好的分布性:同一个索引分成多个分片(sharding),分而治之的方式来提供处理效率;
  • 高可用:提供复制(replica),一个分片可以设置多个复制分片,使得某台服务器宕机的情况下,集群仍旧可以照常运行;
  • 速度快,负载能力强,在面对海量数据时候,搜索速度极快;
  • 各节点数据的一致性问题:其默认的机制是通过多播机制,同步元数据信息,但是在比较繁忙的集群中,可能会由于网络的阻塞,或者节点处理能力达到饱和,导致各数据节点数据不一致——也就是所谓的脑裂问题,这样会使得集群处于不一致状态。目前并没有一个彻底的方案来解决这个问题,但是可以通过参数配置和节点角色配置来缓解这种情况。没有细致的权限管理,也就是说,没有像mysql那样的分各种用户,每个用户又有不同的权限。所以在操作上的限制需要自己开发一个系统化来完成;
  • ES 对机器性能一致性要求很高,容易出现部分节点性能不佳导致的集群性能长尾问题。由于历史原因,监控组用的机器有很多是3年前的机器,CPU 内存 规格都不能保证完全一致,在组建 42(hot+warm)的集群时常常会因为个别节点性能稍低导致集群整体写入能力下降。特别是在流量高峰期时,当出现节点节点负载过高时即会被熔断器剔除集群,进一步导致写入压力增大。

Clickhouse 在监控系统中的日志存储方案

日志存储ClickHouse 的灰度架构

采用可灰度的方式,近半年内只对量极大的个别服务写 ClickHouse(写这篇文章时已全量写 ClickHouse)

image.png

  1. datahub 是一个可配置的元数据管理中心。提供一个接口,返回需要存 ClickHouse 的服务列表。该服务列表通过 toad 配置;
  2. flink流计算根据服务列表,判断日志写入 ClickHouse,还是 ES;
  3. adhoc-apm 提供日志查询,根据 datahub 提供的服务列表,判断日志在 ClickHouse 还是 ES,查询对应的存储并返回结果。

CK 集群部署

clickhouse 部署架构

  1. chproxy 是ck查询代理,只有被统一查询层访问;
  2. ck 是 clickhouse 实例节点,每个实例独享一台机器;
  3. ip尾号双数为主本,单数为副本 (实际没有主副区分),图中 ch01 和 ch02 互为副本;
  4. 一对主副尽量用相邻的两个ip;

image.png

左下角原图展示了 ch02 节点上的 三种表:

  1. 本地表下划线全小写;
  2. 合并表名称后缀为\_merge,用于代理一组 符合指定表名前缀的本地表,主要方便运维,在必须创建新表时能同时查询旧表和新表的数据;
  3. 在merge表上套一层分布式表,分布式表名称后缀为 _all ,查询请求会随机路由到ck任意节点,再由该节点发起到其它节点的请求;
  4. 分布式表和本地表的区别可以看 推荐:入门必读

所有实例基于k8s部署,划分独立的namespace,每个clickhouse实例独享一台宿主机。

  • 数据存储:使用宿主机磁盘,有更高效的读写性能;
  • 节点标识:因为数据存储在宿主机磁盘,因此 pod 使用 deployment 无状态模式部署。同时 macros.xml 需要在宿主机创建,实例启动时挂载到 pod 配置文件夹中以标识当前节点身份。

部分 configmap 

image.png

在 k8s 上运行的实例(局部)

image.png

通过 tabix 查看表

image.png

数据摄入

Flink 常规 ETL 处理,由于写分布式表或通过 chproxy 都会有巨大的网络资源消耗,因此直接写入 CK 本地表;

CK 官方推荐的 sink:https://github.com/ivi-ru/flink-clickhouse-sink

基于此Sink做了几点改进:

  1. 写ck节点时如果发生网络异常以及节点响应超时,则会将该ck节点标记为不可写,在一定时间内不再向该节点发送请求,超过设定时间后自动取消标记;
  2. 使用 apache httpClient 替代 netty Async httpClient,避免堆外内存堆积的问题;同时为了防止Sink线程崩溃后卡死;
  3. INSERT 数据时,从 VALUES 改为 FORMAT 模式,解决字符串转义问题和避免构造复杂的map字段(VALUE 模式: insert into table values (),(),()  ;  FORMAT 模式:支持使用压缩后的JSON批量提交数
  4. 再可容忍 cpu 开销的条件下,使用 gzip 压缩后传输,减少机器的网卡带宽占用;

建表语句

本地表:

-- show create table qunhe_log; 
CREATE TABLE monitor.qunhe_log
(
    `timestamp` DateTime64(3, 'Asia/Shanghai'),
    `hostGroup` String,
    `ip` String,
    `podname` String,
    `level` String,
    `cls` String,
    `behavior` String,
    `message` String,
    `id` String DEFAULT '',
    INDEX message_index message TYPE tokenbf_v1(30720, 3, 0) GRANULARITY 1
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/qunhelog', '{replica}')
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (level, hostGroup, podname, ip, timestamp)
SETTINGS storage_policy = 'hdd0_hdd1_only', index_granularity = 8192
  • 按天分区,开发独立定时任务删除过期分区,可以更明确的掌控数据从磁盘清除的时间;
  • 排序键主要按照基数排序,hostGroup 在查询时是一定会作为条件的。虽然 timestamp 也会作为条件,但是排在前面会明显影响数据筛选效率;
  • 分配HDD磁盘,SDD 磁盘留给指标类查询比较多的表;
  • 添加tokenbf_v1分词索引,根据测试在过滤明确关键字是,能提高数据筛选效率80%以上;

merge表:

CREATE TABLE monitor.qunhe_log_merge ON CLUSTER clicks_cluster AS monitor.qunhe_log ENGINE=Merge("monitor", '^qunhe_log($|(.*\d$))')
  • merge表用于代理本地一组表,特别适合创建新表,并对旧表重命名后,需要新旧表一起查询;
  • 这里 '^qunhe_log($|(.*\\d$))' 匹配的表名比如: qunhe_log、qunhe_log_20230101、qunhe_log_20230102;

分布式表:

CREATE TABLE IF NOT EXISTS monitor.qunhe_log_all
    ON CLUSTER clicks_cluster
AS monitor.qunhe_log_merge
    ENGINE = Distributed(clicks_cluster, monitor, qunhe_log_merge)
  • 分布式表主要用于查询数据。众所周知大批量写入时肯定不会选择写分布式表,而是直接写各个节点本地表。

日志查询

统一查询层对查询封装后,基于此可以方便地在其它产品也做二次开发,包括日志查询的产品页面:
产品页主要分为三个模块:

  • 查询区;
  • 原文展示区;
  • 统计区;

检索功能:

image.png

image.png

统计功能:

数量统计:
image.png

多类型分组统计:
image.png

性能和监控

日志查询相关监控看板

最近一周日志量统计以及最近1小时各个服务日志量TopN排名:
image.png

查询效率监控
image.png

ClickHouse 监控

基于 clickhous_exporter 和宿主机 node_exporter 对ck 的性能和资源监控

资源使用量

image.png

image.png

表动态监控

image.png

集群查询监控

image.png

日志存储改造完成后,日志存储的机器规模减少一半以上。在写入方面,未出现因为性能导致的写入延迟。在查询方面,99%的查询能在2s内返回,90% 的查询能在 1s 内返回,常规查询明显快于ES。当然了,某些服务日志量确实很大,比如网关这类日志,实在无法通过索引提前筛选掉大多数数据就只能硬查了(加载到内存耗时较大)。如果添加过滤条件,命中分词索引也有极大的提升;

总的来说,相比于 Elasticsearch 来说,ClickHouse 的运维更加简单和明确,虽然很多操作只能通过一步步命令操作完成,但是对于开发人员而言更易能掌控细节。主要有以下几个方面:

  1. 日志的接入和查询优化:接入很容易,适配表结构即可写入。配合 EXPLAIN 也很方便对查询优化;
  2. 日志生命周期管理:我们独立开发了 ClickLive 完成对 日志分区的删除。相比 TTL 删除,定时删除具有时间确定性。
  3. 接入监控体系:使用 clickhouse_exporter 、node_exporter 暴露指标,通过prometheus 接入 thanos 指标体系,完整实现了全访问指标监控以及警报配置;

常见问题

zk 元信息丢失,导致表进入ReadOnly模式


-- 查看表结构

SHOW CREATE TABLE monitor.qunhe_log


-- 重命名
RENAME TABLE monitor.qunhe_log TO monitor.qunhe_log_old ON CLUSTER clicks_cluster


-- 创建新表,换一个zk路径
CREATE TABLE monitor.qunhe_log ON CLUSTER clicks_cluster . . .


-- 此时数据正常写入,下面开始数据恢复


-- 导入旧表分区到新表

-- 查询旧表分区,获得分区名称
SELECT table, partition  FROM system.parts  WHERE database = 'monitor' and table='qunhe_log'  GROUP BY (table,partition) ORDER BY (table,partition) ASC

-- 导入分区数据,分区逐个导入。非组合分区格式: '20230112'
ALTER TABLE monitor.qunhe_log ON CLUSTER clicks_cluster ATTACH PARTITION (20230112,'2') FROM monitor.qunhe_log_old

-- 到此应该能解决问题

重启实例后,表进入 ReadOnly模式

如果只是修改配置导致实例进入ReadOnly模式,大概率不需要做任何处理,会自动恢复;

写入数据时报 Too many parts 异常

  1. 这个问题基本就是分区不合理导致的,一般检查下是不是使用了时间戳分区和是不是有用其它字段做分区。我们遇到的过将日志等级做分区,然而flink将日志等级解析异常,解析出了数千个值。
  2. 也有可能 写入批次中的数据条数太少,我们日志写入场景中设置的 50k条/批;

出现大量损坏数据,进入detach目录

我们最开始发现某一个节点出现磁盘使用率异常,才发现大量数据进入detach 目录。在双副本模式下,一般查询数据还是数据完整的。

有可能是磁盘有损坏,一般换完磁盘可以解决。可以用smartctl 工具检测一下:

  1. 磁盘短时间测试:sudo smartctl -t short  /dev/sdc1
  2. 查看测试进度:sudo smartctl -c /dev/sdc1
  3. 查看测试结果:sudo smartctl -l selftest /dev/sdc1

换新磁盘后,zk 会自动下发任务开始拉数据,此时一般带宽或磁盘io会跑慢,最好在业务流量低峰期做;


赵栩彬
358 声望600 粉丝

愿,你在遭受打击时,记起你的珍贵,抵抗恶意;