业务系统存在有大约26亿条数据,前期采用了mysql数据库集群来存储.后续计划修改为opentsbd,结果流产了.
业务类型为采集系统,因此存在大量的写入,仅有少量页面访问.
数据模型及系统架构
数据模型如下
设备->指标->实际采集对象(网卡,路径等)
现网的设备五花八门,甚至有2-3000千张网卡的设备存在.
因此每增加一个采集设备,所采集到的数据量其实是不固定的增长,增长量和设备的特征数有关.
采用了分库分表+读写分离的mysql架构.
数据库中间件为mycat
分库使用指标id的固定hash分片
分表是数据汇总分表,即实时采集数据在一个表中,小时的数据在一个表中,天的数据在一个表中…以此类推.
使用mysql自身的主从同步,并且使用mycat
将读操作定向到从库,将写操作定向到主库.
数据存储结构
数据存储结构为
source|collect_time|write_time|kpi_id|kpi_name|kpi_type|kpi_value|tree_name
其中tree_name指的就是实际采集对象
第一次优化
数据汇总使用的是定时任务
加存储过程
,存储过程中使用游标(分批处理),进行数据汇总,然后删除过期数据.
删除方式为
delete xx where collect_time>xx;
一开始使用的没有什么问题,后面由于数据增长,几乎每两天就能发生一次表死锁或者数据库主从同步异常导致relay.log和bin.log日志大量占用磁盘,导致磁盘告警.
这样子的日志经历了一个多月.?
实际上这个阶段发生的各种问题在高性能mysql
中几乎都提及为don't do it
…
后面把高性能mysql
通读了一遍后,整理了一堆问题,然后逐个修改,这些简单问题没啥好说的,都在书里了.
数据滚动问题
最后遗留一个问题是书中没有提及的.即如何在大量的数据中在线删除部分数据
.
这个问题来自于这个需求
保留3天的实时数据
实际上在设计之初就可以处理掉这个问题,即表按天分区,删除时按天重建分区.
分表也可以,但是要处理跨表查询问题,出于奥康剃刀原则,直接采用了分区表的方式.然后修改了存储过程.
上线后感觉生活又有了颜色?
第二次优化
好日子没有过几天,随着业务的增加带来的数据量级的再一次增长,磁盘’报捷’,几乎每个星期都能被运维同学’开心’的通知
那个谁,xx磁盘叕满了?
于是又放下手中的活,开始上服务器.
然后,有了第二次的优化
设计存在的问题
其实存在两个问题
- 对于同一个采集对象的
kpi_id|kpi_name|kpi_type|source|tree_name
其实是基本不会变化的,因此这些数据的存储都是冗余的 - 按照指标的不同,实际上采集的对象的数量是不均衡的.
事实上这些问题也直接暴露在了现网.
- 磁盘使用总量大约2.5T
- 数据库服务器的磁盘空间使用量不均匀,有些服务器使用了80%,有些才使用了30%
进行的优化
针对磁盘使用量的问题,进行了如下优化
提取结构信息作为结构表,非结构信息作为值表.因此数据存储结构调整为如下两种
值表: struct_id|collect_time|write_time|kpi_value|tree_name
结构表: struct_id|kpi_id|kpi_name|kpi_type|source|tree_name
查询方式如下
- 查询结构表,获得结构id
- 按照结构id和collec_time查询值表
按照查询设计索引
大部分查询结构使用到的字段为source
,kpi_id
,tree_name
查询值表则需要struct_id
和collect_time
的时间范围.
因此需要对source
,kpi_id
,tree_name
添加索引,并且struct_id
为主键.
为了保证结构表信息的唯一性,在插入时使用ignore
语法,而非先查询再更新的方式.
按照业务方向进行优化
由于业务大部分时间都在插入数据,因此为了获取一个数据的struct_id
而进行一次查询是不可取的,而使用缓存,则由于结构数量的庞大,约200万,也是不可取的.(不过不可取不代表没有可取之处,后面还是有一个利用缓存的优化操作.)
查询了一些分布式id生成算法,但似乎没有符合业务需求的算法.大部分算法都是保证了id的唯一性,而在这里,需要的是对于某些相同来源的数据的唯一性.纠结了一个上午,也咨询了一些同事,并没有什么推荐,然而实际上一开始就找错了方向,要的不是生成一个唯一id,而是一种基于现有数据进行计算并且能够保证对于相同的数据能够生成相同的结果并且对于不同的数据生成不同的结果的算法.
首先想到的是md5,然而md5对于不同的数据生成不同的结果
存在一些缺陷,因此又查询了一些别的散列算法.最终选择了sha1
.
生成时使用source
,kpi_id
,tree_name
组装为一个字符串,然后进行sha1
.因此在插入时无需查询结构id,而只需要直接计算出id值.
分库方式
之前存在的问题是分片不均匀导致服务器负载不一致,因此新的分片方式采用字段struct_id
的一致性hash算法进行分片.因为sha1计算出的散列值可以认为是随机的,因此所有的查询对象可以认为是均匀的分布在服务器上.
并且这还有两个好处,
- 结构表和值表被分配在同一个数据库实例中,因此可以直接使用mysql的join函数,而无需使用mycat的join函数.也就是说表关联是在一个实例中进行的.
- 页面查询时的数据实际上可能是被分配在不同的实例上,因此查询实际可能是发生在两个实例上.
结果
上线了这几个优化后,服务器的磁盘压力基本不再存在了,计划优化存储50%,后面实际上也达到了,目前使用的存储空间为1.2T左右.
而线上服务器也基本不再报慢sql问题.
第三次优化
产品同学接入了7k设备的监控后,打开了性能图表点开了cpu使用率,然后等了20s?于是第三次优化来了.
由于之前我们主要关心的是设备的数据采集和告警,对于页面显示基本没有人关注,实际上在第二次优化前,点开图表大约需要1min.
按explain优化
找了图表查询的sql.随便找了一台服务器执行了一下explain.
select * from value where struct_id='9f3594a0bc82f7debf903dfe973296a87606aa3a' and collect_time>'2019-10-14 06' and collect_time<'2019-10-14 10';
发现了一个问题,即我查询的时间范围为10-14日,然而实际扫描的分区为所有的分区.emm
分区策略
下面是分区策略
-
list分区
-
分区函数为day(collect_time)
实际上是由于mysql分区不支持day函数导致的.也就是说优化器并不能从查询条件中直接获取到需要查询的分区.
解决方式有两种
- 在表中冗余分区字段,查询时携带这个参数,对于我们的表来说,也就是增加一个day字段,day来自于day函数.然后在查询时带上day参数
- 直接在sql中指定查询分区
由于方式一需要修改现有的表结构,而方式二只需要修改查询sql即可,因此选择了方式二.并且由于在第二次优化中使用服务化思想抽离了一个数据访问服务,并且最后收口只有两个,因此修改非常方便.
这里生成查询分区稍微需要写一点代码.比较简单.
public static List<LocalDate> getDatesBetween(LocalDate startDate, LocalDate endDate) {
long numOfDaysBetween = ChronoUnit.DAYS.between(startDate, endDate);
return IntStream.iterate(0, i -> i + 1)
.limit(numOfDaysBetween)
.mapToObj(startDate::plusDays)
.collect(Collectors.toList());
}
public static LocalDate dateToLocalDate(Date date) {
Instant instant = date.toInstant();
ZoneId zone = ZoneId.systemDefault();
LocalDateTime localDateTime = LocalDateTime.ofInstant(instant, zone);
return localDateTime.toLocalDate();
}
public static String buildPartition(Date start,Date end){
List<LocalDate> datesBetween = getDatesBetween(dateToLocalDate(start),dateToLocalDate(new Date(end.getTime()+24*60*60*1000)));
return "PARTITION("+datesBetween.stream().map(LocalDate::getDayOfMonth).map(d->"p"+d).collect(Collectors.joining(","))+")";
}
使用指定了查询分区的sql后,查询时间被优化到了5s.继续看explain好像没有什么可优化的地方了.
索引分析
之前没有提到建立的索引类型,实际上是b+tree索引,也就是innodb的默认索引类型.
从业务角度分析,查询实际上是分为两步
- 获取需要的结构id(实际上有时候可以直接计算得出)
- 必定是
where struct_id='xxx'
并且从struct_id的生成算法来看,这个id的区分度实际上是非常大的.实际上是可计算的
业务每分钟采集一次设备的特征信息,而一个分区存储一天的数据,也就是说同一个
struct_id
最多只有24*60=1440
条记录.
因此使用hash
索引可以极大的提高索引定位效率.
虽然实施起来稍微有点问题,索引只能重建.
分区优化
统计了一下分区的数据量,发现一个分区居然有1800w的数据.
因为虽然有31个分区,然而数据只会保存在3个分区中(按天).
众所周知,mysql是将一个表(分区表也算)的数据保存在一个文件中的,因此看了一下文件大小大约为6GB.
于是终极优化手段就产生了.
使用monthDay*100+hour作为分区函数.
由于一个区的数据被均分到了24个区中,因此单条访问效率最高可以提升24倍(瞎估的?).
插入优化
之前提到了使用ignore语法防止插入重复数据,然而对于固定的一个监控设备,实际上大部分后来的结构数据都是重复的.因此实际上存在了大量的数据库资源消耗.
然而又无法做到将已经有的struct_id记录记录到缓存中.
布隆过滤器了解一下?
简单来说,布隆过滤器是一个概率性数据结构,可以告诉我们某样东西一定不存在或可能存在
可以说布隆过滤器可以完美支持这里的需求:
判断一个key是否不存在
redis中有一个bitmap结构可以实现布隆过滤器.
具体的细节不再描述.说明一下整体的流程
- 若不存在这个key,则创建结构数据的插入任务,提交到线程池中
- 无论如何创建值数据的插入任务,提交到线程池中
结语
整个优化过程跨越了将近两年,期间遇到了各种其他的奇奇怪怪的问题,甚至春节还在处理一个mycat文件打开数的问题.还有一个一个数据库主从同步,从库磁盘同步率太低导致的同步异常,现在数据库集群也算稳定了下来.
纸上得来终觉浅,绝知此事要躬行.经历到现在,也终于从mysql新手升级到了mysql小白.?