写点什么

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

作者:捉虫大师
  • 2022 年 4 月 26 日
  • 本文字数:5175 字

    阅读完需:约 17 分钟

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 出来。


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



之前的设计查询一整个 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,不过要一点一点的灰度发布才可以




搜索关注微信公众号"捉虫大师",后端技术分享,架构设计、性能优化、源码阅读、问题排查、踩坑实践。

发布于: 刚刚阅读数: 2
用户头像

捉虫大师

关注

还未添加个人签名 2018.09.19 加入

欢迎关注我的公众号“捉虫大师”

评论

发布
暂无评论
眼见不一定为实:调用链HBase倾斜修复_HBase_捉虫大师_InfoQ写作社区