性能文章>眼见不一定为实:调用链HBase倾斜修复>

眼见不一定为实:调用链HBase倾斜修复原创

178903

hello,大家好,我是小楼。

今天给大家分享一个关于HBase数据倾斜的排查案例,不懂调用链?不懂HBase?没关系,看完包懂~

背景

最近HBase负责人反馈HBase存储的调用链数据偶尔出现极其严重的倾斜情况,并且日常的倾斜情况也比较大,讲的通俗点就是出现了热点机器。

举个例子,有三台HBase机器存储调用链数据,其中大部分数据读写都在一台机器上,导致机器负载特别大,经常告警,这就是HBase倾斜,也叫热点现象。本文主要讲述了治理倾斜情况的过程,以及踩的几个坑。

知识铺垫

为什么会出现HBase倾斜的情况呢?既然是调用链数据HBase倾斜,那么首先简单介绍下几个调用链和HBase的背景知识。

全链路追踪

全链路追踪可能是一个比较统一的叫法,平常最多的叫法叫调用链,也可能有其他的叫法,不过说的都是同一个东西,本文全都用调用链来指代。

调用链是分布式服务化场景下,跨应用问题排查性能分析的工具。

说的直白点,就是可以让你看到你的代码逻辑在哪个地方调用了什么东西,比如在serviceA的methodA的逻辑里,依次调用了redis、mysql、serviceB等,可以看到每个调用的耗时、报错、出入参、ip地址等信息,这就是调用链。

目前调用链有一个统一的标准,以前叫OpenTracing,现在与其他的一些标准整合进了OpenTelemetry,不过调用链的标准基本没变。

调用链标准的最核心的概念如下,只列出了一些最核心的元素,不代表全部:

  • Span:调用链最基本的元素就是Span,一次 Dubbo Server 请求处理,一次 HTTP 客户端请求,乃至一次线程池异步调用都可以作为一个 Span。
    • SpanID:一个Span的唯一标识,需要保证全局唯一
    • TraceID:一条调用链的唯一标识,会在整个调用链路中传递
    • ParentID:父 Span 的 SpanID。当存在 A -> B 这样的调用关系时,B Span 的 ParentID 是 A Span 的 SpanID。ParentID 用来构造整个调用链路的树形结构。每次发起新的请求时,都要把当前的 SpanID 作为 ParentID 传递给下一个 Span。
  • Segment:Segment是特殊的Span,一般表示这是一个应用的边界 Span。如作为 Dubbo Server 的一次请求处理;作为 HTTP Server 的一次请求处理;作为 NSQ Consumer 的一次消息处理等。
  • Trace:一条调用链就是一条Trace,Trace是一堆Span的集合,每一个Trace理论上来说是一颗树

下面用一张图来演示一次简单的三个服务间的Dubbo调用来展示调用链的数据是如何、何时产生的,以及各Span之间是通过什么关联起来的,用于深入理解上面的核心概念。

文字描述:外部请求调用了ServiceA.MethodA, SA.MA依次调用了SB.MB、Redis、MySQL, SB.MB调用了SC.MC, SC.MC内部只有计算逻辑。

注意:

  • 图里Span内容只包含了一部分,不代表全部内容。
  • 可能不同的调用链系统上报存储的方式不一样,有的是每个Segment上报一次,有的是每个Span上报一次,图中表示的是每个Span上报一次

HBase

网上关于HBase介绍的文章很多,这里不做详细的介绍,只是列出来一些基本的概念用于理解。

HBase是一个可以存储海量数据的数据库,既然是数据库,那么最基本的操作就是添加和查询

  • RowKey

HBase基本的数据操作都是通过RowKey这个东西,RowKey是HBase的一个核心概念,如何设计Rowkey是使用HBase最关键的部分。

RowKey在HBase里的作用是什么?一个是数据的操作要通过rowkey,可以把rowkey理解为mysql的主键,有索引的作用,另一个是用来做负载均衡。Rowkey的数据格式是字节流,也就是byte数组,这个概念很重要。

什么是byte?就是一个8位字符,值在-128到127之间,所以即使你的rowkey不是那128个ascii码,也是可以存的,例如你的rowkey有三个字节,十进制表示分别是-56、-110、-27,发送到HBase也是可以存储的,不过你要展示出来给人看,可能就不太好展示这个RowKey了。

  • Region

Region是HBase数据分片的基本单位,可以把Region理解为HBase的数据分片。

HBase是按什么来做分片的?如果你有搭建过HBase的话,并且看过HBase的web界面,可以看到Region部分有两个属性,Start Key和End Key。

这两个属性代表什么意思?举个例子,现在有两个Region,RegionA的StartKey和EndKey是00和01,RegionB的StartKey和EndKey是01和02,你要存两条数据,RowKey分别是0000ABC和0100DEF,第一条数据就会落到RegionA里,第二条数据就会落到RegionB里,简单来讲就是根据RowKey的前缀来决定这条RowKey落到哪个Region里,如果Rowkey匹配不到任何一个Region,那么会新建一个Region存储数据。

当Region的数据量到达某个阈值后,Region会自动**为两个Region,避免性能降低,HBase还有一个功能是预分区,比如在新建Table后,可以在Table里预先指定256个分区,StartKey和EndKey依次是00-01、01-02一直到FE-FF(前提是你的所有的RowKey的前缀都在00-FF区间内),预分区的好处是避免HBase最开始过多的自动**,因为**时数据是不可用的,过多的**会导致性能降低。

问题分析

介绍完了调用链和HBase的基本概念,这里介绍下我们调用链系统的存储架构,以及为什么会产生倾斜问题。

首先是调用链TraceID的设计,格式是 service_name-xx-yy-zz,也就是应用名+时间戳+IP+随机数。

调用链数据存储有两部分,一部分在ES,一部分在HBase,为什么不直接把原始数据存到ES里?因为ES机器比较贵,用的固态盘,为了节省成本。

ES里存储的是索引数据,也就是一些筛选条件,例如根据appName、startTime、耗时、是否有报错这些属性筛选调用链,这些可以用来筛选调用链的属性是存储在ES里的,并且为了节省空间,除了TraceID和SpanID这两个属性,其他属性的doc_value是关掉的,也就是只存了索引,没有存数据,因为要筛选出来TraceID和SpanID,然后根据这两个ID去HBase里取原始数据。

HBase里存储的是HBase的原始数据,除了TraceID和SpanID,因为这两个属性的数据在ES里已经有了。HBase里的每条数据是一个Span,每条数据的RowKey是xx-TraceID-SpanID,最开始的两个字符是TraceID做hash取前两位,为什么要做个hash?因为我们TraceID的开头是应用名,如果不加前面两位hash值的话,根据HBase存储数据的策略,前缀一样的会存储到一起,也就是同一个应用的Trace会存储到一起,那么流量大的应用Trace会很多,这样就会导致倾斜问题,加两位hash值可以让数据分散开,并且同一个TraceID的数据会存储到一起,可以一次性Scan出来。

既然RowKey的设计已经考虑到了倾斜问题,已经做了hash分散数据,那为什么日常会存在倾斜问题?而且偶尔会出现很严重的倾斜问题?原因是每个Trace的Span数量是不一样的,有的Trace可能就几个Span,有的Trace有几万个Span,还会出现一种极端情况,一个MQ消费者消费消息后又向好几个Topic里发送了消息,后续的消费者重复这样的操作,导致一条消息最终放大了几万甚至几十万倍,导致一个Trace里有几十万甚至几千万个Span,这只是其中一种场景,也可能业务开发做了什么骚操作,也会导致一个Trace包含的Span数量非常多,那么根据现在的存储架构,同一个Trace的数据会存储到一起,这就导致了倾斜问题。

方案设计

在定位到问题后,最直接的想法就是彻底打散RowKey,也就是把SpanID的MD5当作RowKey,因为SpanID是全局唯一的,所以MD5必然是彻底打散的,不过这样做有一个坏处,就是数据彻底打散后,要查出一整个Trace的话,就得一个Span一个Span去查,不像之前的RowKey设计可以一次性Scan出来。

为了知道这样查询性能有多慢,特意做了一次性能测试,结果如下:

span数量(个) scan(ms) search_es(ms) gets(ms) gets_parallel_batch100(ms) gets_parallel_batch200(ms) gets_parallel_batch300(ms) gets_parallel_batch500(ms)
100 5 12 12+10        
265 10 20 20+25 20+10 20+15    
336 10 20 20+28 20+10 20+15    
562 10 25 25+45 25+15 25+15 25+23  
1759 30 57 57+130 57+38 57+40 57+45 57+45
2812 70 85 85+210 85+70 85+70 85+70 85+70
8000 170 210 210+700 210+180 210+180 210+180 210+200

之前的设计查询一整个Trace的步骤就是直接用TraceID去HBase里scan,不用查询ES,也就是第二列的耗时。

如果改成一个Span一个Span去查的话,查询步骤变成了两步,第一步先用TraceID从ES里查询出这个Trace所有的SpanID,然后再根据SpanID去HBase里批量gets,表格里的后5列就是两步查询的耗时,加号前面是查询ES的耗时,加号后面是HBase批量gets的耗时。第四列表示串行gets,后四列表示并行gets,并对不同batch的大小做了测试。

根据测试结果,串行gets的性能要比并行gets的性能低3-4倍,所以不考虑串行gets。并行batch的大小对性能影响不大,并且最终耗时相比只scan的耗时也就增大一倍,例如查询8000个Span,前后方案查询耗时对比为170ms:390ms,实际上用户感知不到,所以方案就定为用MD5彻底打散数据。

踩的坑

在开发完成后,在测试环境测试无误后就直接发了线上,由于最开始不太了解HBase的Region相关的概念,所以误以为RowKey改成MD5后倾斜情况会直接消失,就直接发布了HBase数据写入的服务,发布后HBase那边立刻出现了非常严重的倾斜情况,导致HBase写入超时,kafka堆积,赶紧回滚了,HBase负责人查看监控发现大部分数据写入到了一台机器上。

为什么会出现这种情况?测试环境为什么没有出现这个问题?

根据上面介绍的HBase的Region相关的概念,出现这种情况的原因可能是RowKey没有匹配到任何一个Region,所以数据写入到了新建的Region上,也就是一台机器上。

但是代码里写的明明就是MD5,并且在测试环境测试无误,之前的RowKey方案的前两位hash在00-FF之间,MD5的前缀肯定也在00-FF之间啊,按理说肯定可以匹配到一个Region的,为什么还会写到新的Region里?直接上代码

import org.apache.commons.codec.digest.DigestUtils;

// 用spanId的MD5值当作RowKey,写入到HBase里
public static byte[] rowKeyMD5(String spanId) {
    // DigestUtils只是JDK加密包的封装,底层还是调用JDK本身的MD5加密
    return DigestUtils.md5(spanId);
}

DigestUtils是org.apache.commons.codec.digest.DigestUtils包里带的,实际还是调用的JDK自带的MD5库,等同于如下的写法

import java.security.MessageDigest;
// MessageDigest是JDK自带的加密包,里面有MD5加密算法
MessageDigest.getInstance("md5").digest(spanId.getBytes(StandardCharsets.UTF_8));

调试一波,发现了问题,这里用一个简单的demo演示下,逻辑就是用md5加密"abc"这个字符串

一般我们看到的加密后的MD5是16个或者32个0-F之间的字符,0-F的ASCII码是48-57和97-102,但是加密后的byte数组有的byte是负的,那加密出来的这16个byte是什么玩意?虽然继续看了MD5加密的源码,但是水平不足,看不懂加密原理。。。

看到加密后的byte数组应该就可以知道了为什么一发布就严重热点了,因为byte数组里面的东西根本不是正常的0-F之间的字符,虽然hbase的rowkey是只要是byte(-127~128)就行,但是现在MD5加密出的byte数组匹配不到原有的Region的StartKey和EndKey,全都写到新建的Region里了,那么我只需要把RowKey搞成MD5的16进制字符不就可以匹配到原有的Region了么?

那么Java怎么MD5加密出一般我们看到的那种16进制字符的呢?比较方便的写法是

import org.apache.commons.codec.binary.Hex;

Hex.encodeHex(DigestUtils.md5(str));

那么看下encodeHex里是怎么把md5byte数组转成十六进制字符串的

每个byte是8位,但是每个16进制字符,也就是0-F只需要四位bit就可以表示,所以一个byte可以表示两个16进制字符,也就是我们日常写的0xFF表示一个byte,上面的逻辑就是把一个byte的前四位和后四位分开,分别表示一个16进制字符,那么16个byte就可以拆成32个16进制字符,这就对上了,接下来看下encodeHex的输出

abc经过MD5加密后的16进制字符串是900150983cd24fb0d6963f7d28e17f72,我们按照encodeHex的逻辑来手动拆下byte看看对不对的上

首先看bs[0],也就是-112,用二进制表示就是10010000,注意,这是个补码,简单解释下原码和补码,计算机中的数值都是用二进制补码来存储的,正数的补码是它本身,也就是它的原码,负数的补码是它的原码除了符号位取反加1,详细的可以去看看计算机基础的书籍。

那么-112的原码就是11110000,补码就是10010000,拆成两部分也就是1001和0000,也就是9和0,跟16进制字符串的前两位,也就是90,对上了。

再拆下bs[1],也就是1,用二进制表示就是00000001,拆成两部分也就是0000和0001,也就是0和1,跟16进制字符串的三四位,也就是01,对上了

再拆下bs[2],也就是80,用二进制表示就是01010000,拆成两部分也就是0101和0000,也就是5和0,跟16进制字符串的五六位,也就是50,对上了

后面的同理,就不写了,看到这里我们就知道了那个16长度的byte数组到底是什么玩意,就是把每两个16进制字符合并成了一个byte

所以,我们经常以为或经常看到Java中的MD5每一位都是0-F的字符串是经过了encodeHex处理,但RowKey实际上用的是处理之前的byte[],它并不在0-F这个范围

改进

知道原因后,把RowKey的MD5改成十六进制字符,重新发布,果然没有出现严重热点问题,监控曲线跟之前一样,说明复用了已有的Region,日常倾斜情况需要跑一段时间才可以解决。

总结

  1. HBase的RowKey设计是使用HBase最最重要的地方
  2. 注意Java的MD5加密出来的东西不一定是你想要的
  3. 其实直接使用那个16长度的byte数组当作RowKey也可以,虽然基本不会复用已有的Region,不过要一点一点的灰度发布才可以

  • 搜索关注微信公众号"捉虫大师",后端技术分享,架构设计、性能优化、源码阅读、问题排查、踩坑实践。
  • 进技术交流群欢迎加我微信 MrRoshi,众多大佬带你飞。
分类:标签:
请先登录,感受更多精彩内容
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

从一起GC血案谈到反射原理
前言 首先回答一下提问者的问题。这主要是由于存在大量反射而产生的临时类加载器和 ASM 临时生成的类,这些类会被保留在 Metaspace,一旦 Metaspace 即将满的时候,就会触发 Fu
类初始化导致死锁
一张图简单描述死锁 如上图,Thread1 拿到了 object1,Thread2 拿到了 object2,但是现在 Thread1 需要拿到 object2 的锁才能继续往下,Thread2 又要拿到 object1 才能继续往下
在调试器里看LINUX内核态栈溢出
图灵最先发明了栈,但没有给它取名字。德国人鲍尔也“发明”了栈,取名叫酒窖。澳大利亚人汉布林也“发明”了栈,取名叫弹夹。1959年,戴克斯特拉在度假时想到了Stack这个名字,后来被广泛使用。
不起眼,但是足以让你收获的JVM内存案例
今天的这个案例我觉得应该会让你涨姿势吧,不管你对JVM有多熟悉,看到这篇文章,应该还是会有点小惊讶的,不过我觉得这个案例我分享出来,是想表达不管多么奇怪的现象请一定要追究下去,会让你慢慢变得强大起来,
如何通过反射获得方法的真实参数名(以及扩展研究)
前段时间,在做一个小的工程时,遇到了需要通过反射获得方法真实参数名的场景,在这里我遇到了一些小小的问题,后来在部门老大的指导下,我解决了这个问题。通过解决这个问题,附带着我了解到了很多新的知识,我觉得
谨防JDK8重复类定义造成的内存泄漏
概述 如今JDK8成了主流,大家都紧锣密鼓地进行着升级,享受着JDK8带来的各种便利,然而有时候升级并没有那么顺利?比如说今天要说的这个问题。我们都知道JDK8在内存模型上最大的改变是,放弃了Perm
JDK13 GA发布:5大特性解读
JDK13 GA版本 5大新特性如下: 350: Dynamic CDS Archives 351: ZGC: Uncommit Unused Memory 353: Reimplement the
眼见不一定为实:调用链HBase倾斜修复
hello,大家好,我是小楼。今天给大家分享一个关于HBase数据倾斜的排查案例,不懂调用链?不懂HBase?没关系,看完包懂~背景 最近HBase负责人反馈HBase存储的调用链数据偶尔出现极其严重的倾斜情况,并且日常的倾斜情况也比较大,讲的通俗点就是出现了热点机器。举个例子,有三台H