[HBase] - Phoenix Scan 冷启动慢的问题

1.问题与现象

业务反馈 Phoniex 最近总是有 10 多秒的超时。我们测试的时候发现 Phoenix Client( JDBC ) 第一次查询(基于 rowkey scan)的时候特别慢,即时是 expalin 这条 sql 也很慢,耗时达到 10s+,再查就很快,毫秒级。简称冷启动查询很慢。

问题必现,操作步骤如下:

  1. 进入 phoenix-client/bin/sqlline.py
  2. 执行 sql :select "record" from "secondline"."use_log_info" where "ROW" between '0155822583120211001000000000000' and '0155822583120211020zzzzzzzzzzzz' limit 2;
  3. CTRL + C ,退出 shell

2. 结论

原因是 Phoenix 对于表 scan 类型的查询,第一次会查询表的 stats 信息(SYSTEM.STATS)并缓存到客户端(这个信息用来对 scan 执行计划进行优化),由于业务的表巨大,stats 信息特别多,导致这个操作耗时很久,引起冷启动查询慢的问题;

3. 问题定位过程

查看这张表 "secondline"."use_log_info" 有 2500+ region ,行数应该在百亿级别。最开始猜测:由于表太大,scan 查询本身是查询是很慢,后续再查走的缓存,所以很快,是正常的。

3.1 到底是谁的问题?HBase vs Phoenix

不确定是 hbase 本身的问题还是 phoenix 的问题,所以用排除法验证。我把这条 phoenix sql 用 Java HBase Client API 重写了,线上执行耗时 130 ms,所以基本可以排除 HBase 本身的性能问题,那问题就应该出在 Phoenix。其实也可以通过经验分析,业务的 rowkey 是基于:手机号倒序+时间+hash 拼接的。业务 scan 也基本都是针对单个手机号,没有负责的 filter,rowkey 是字典排序,前缀相同,scan 的数据范围应该不会跨多个 region,所以性能应该尚可。

3.2 Phoenix 表大小的问题

为了验证是不是表规模的问题,我用上述同样的步骤查询一张百万级的小表(2 个 region),确实没有出现冷启动问题,第一次查询很快。初步可以断定是表太大了会出现此类问题。

3.3 通过 Phoneix 源码分析问题

实在没辙,写了个 Java phoenix jdbc demo 程序放线上跑起来,日志级别调成 debug 。通过执行日志,可以清晰的看到第一次查询在 execute.ScanPlan 和 Iterator ready 之间卡了 10s 之久。


image.png

通过源码跟踪,卡住的这块代码是没有提交任务的,主要是在生成执行计划,怀疑是这一步里面有大量的循环计算或者是第一次请求元数据引起的耗时。

// 就卡在这一步,是在生成执行计划
ResultIterator iterator = newIterator(scanGrouper);

一路跟代码,大部分都是在初始化执行计划,设置优化参数等;跟到这里有两个地方在获取数据

public BaseResultIterators(QueryPlan plan, Integer perScanLimit, ParallelScanGrouper scanGrouper) throws SQLException {
    super(plan.getContext(), plan.getTableRef(), plan.getGroupBy(), plan.getOrderBy(), plan.getStatement().getHint(), plan.getLimit());
    this.plan = plan;
    this.scanGrouper = scanGrouper;
    StatementContext context = plan.getContext();
    // Clone MutationState as the one on the connection will change if auto commit is on
    // yet we need the original one with the original transaction from TableResultIterator.
    this.mutationState = new MutationState(context.getConnection().getMutationState());
    TableRef tableRef = plan.getTableRef();
    PTable table = tableRef.getTable();
    physicalTableName = table.getPhysicalName().getBytes();
    // 1.如果使用 stats,则获取表的 stats 信息
    tableStats = useStats() ? new MetaDataClient(context.getConnection()).getTableStats(table) : PTableStats.EMPTY_STATS;
    // Used to tie all the scans together during logging
    scanId = UUID.randomUUID().toString();
    
    initializeScan(plan, perScanLimit);
    
    // 2.这里会获取表的所有 region location 信息
    this.scans = getParallelScans();
    List<KeyRange> splitRanges = Lists.newArrayListWithExpectedSize(scans.size() * ESTIMATED_GUIDEPOSTS_PER_REGION);
    for (List<Scan> scanList : scans) {
        for (Scan aScan : scanList) {
            splitRanges.add(KeyRange.getKeyRange(aScan.getStartRow(), aScan.getStopRow()));
        }
    }
    this.splits = ImmutableList.copyOf(splitRanges);
    // If split detected, this will be more than one, but that's unlikely
    this.allFutures = Lists.newArrayListWithExpectedSize(1);
}

3.3.1 猜测卡在获取表 region location 这里

其中 this.scans = getParallelScans(); 里面会遍历 meta 表获取 region 元数据信息(表所有的 region location 以及对应的 start key 和 end key)。 具体的 API phoneix 用到了 HConnection.getRegionLocation,其实是一个过时的 API 了,代码也解释了用它是因为它有缓存,所以下次一次读就可以直接走缓存。代码如下:

@Override
public List<HRegionLocation> getAllTableRegions(byte[] tableName) throws SQLException {
    /*
     * Use HConnection.getRegionLocation as it uses the cache in HConnection, while getting
     * all region locations from the HTable doesn't.
     */
    int retryCount = 0, maxRetryCount = 1;
    boolean reload =false;
    while (true) {
        try {
            // We could surface the package projected HConnectionImplementation.getNumberOfCachedRegionLocations
            // to get the sizing info we need, but this would require a new class in the same package and a cast
            // to this implementation class, so it's probably not worth it.
            List<HRegionLocation> locations = Lists.newArrayList();
            byte[] currentKey = HConstants.EMPTY_START_ROW;
            do {
              HRegionLocation regionLocation = connection.getRegionLocation(
                  TableName.valueOf(tableName), currentKey, reload);
              locations.add(regionLocation);
              currentKey = regionLocation.getRegionInfo().getEndKey();
            } while (!Bytes.equals(currentKey, HConstants.EMPTY_END_ROW));
            return locations;
        } catch (org.apache.hadoop.hbase.TableNotFoundException e) {
            String fullName = Bytes.toString(tableName);
            throw new TableNotFoundException(SchemaUtil.getSchemaNameFromFullName(fullName), SchemaUtil.getTableNameFromFullName(fullName));
        } catch (IOException e) {
            if (retryCount++ < maxRetryCount) { // One retry, in case split occurs while navigating
                reload = true;
                continue;
            }
            throw new SQLExceptionInfo.Builder(SQLExceptionCode.GET_TABLE_REGIONS_FAIL)
                .setRootCause(e).build().buildException();
        }
    }
}

最开始怀疑问题是这里导致的。第一次需要获取 region 元数据信息,大表的 region 确实很多,达到 2000 +。第一次耗时久,后续可以直接走缓存就很快。。而小表元数据很少,不存在这个问题。跟目前的现象是一致的。

但是经验又告诉我好像不太对,hbase client 基本都会获取一次表 region 信息,不至于要 10 几秒,为了验证,我把这段代码直接拿线上跑了一次,耗时 1300ms 左右,也就是这张大表耗时 1.3s 拿到所有的 region 信息。所以这个猜测 pass。

3.3.2 猜测卡在获取 table stats

继续看代码,有一段是获取 table stats 信息:tableStats = useStats() ? new MetaDataClient(context.getConnection()).getTableStats(table) : PTableStats.EMPTY_STATS; 继续怀疑是它。

这里了解下 table stats 是什么? 通过 Phoenix 来扫描最大的问题就是数据倾斜导致不方便并行化,因此首先要解决的问题是找到一种能均匀分片的方式。Phoenix 的 Statistics Collection 文档 提到了 Phoenix 内部通过收集数据的统计信息(table stats)来实现扫描并行化:

The UPDATE STATISTICS command updates the statistics collected on a table. This command collects a set of keys per region per column family that are equal byte distanced from each other. These collected keys are called guideposts and they act as hints/guides to improve the parallelization of queries on a given target region.

这些统计信息保存在 SYSTEM.STATS 表当中,可以通过表名查到对应的信息:

SELECT COLUMN_FAMILY, GUIDE_POST_KEY, GUIDE_POSTS_WIDTH, GUIDE_POSTS_ROW_COUNT
FROM SYSTEM.STATS
WHERE PHYSICAL_NAME = 'secondline:use_log_info''
LIMIT 5;
COLUMN_FAMILY GUIDE_POST_KEY GUIDE_POSTS_WIDTH GUIDE_POSTS_ROW_COUNT
info rowkey bytes 314572865 1063441
info rowkey bytes 314573187 1071522
info rowkey bytes 314573504 1051022
info rowkey bytes 314572810 1036136

这个结果显示 Phoenix 保存了很多个 row key 的值,这些 key 之间大约间隔 300MB,100 万行,分布非常均匀。

看下什么情况需要获取 stats,以下两种情况,不需要获取 guide :

  1. point lookup ,因为 HBase 本身就很擅长这种查询,所以不需要做额外的并行化处理。这里我理解 point lookup 就是 rowkey get 查询,因为 HBase 本身就是 key value 数据库,直接查就好了,不需要做优化。
  2. 正在采集 stats 数据的时候,有一种场景,我们需要扫描整个 region 信息,这时候不需要。
private boolean useStats() {
    Scan scan = context.getScan();
    boolean isPointLookup = context.getScanRanges().isPointLookup();
    /*
     *  Don't use guide posts if:
     *  1) We're doing a point lookup, as HBase is fast enough at those
     *     to not need them to be further parallelized. TODO: perf test to verify
     *  2) We're collecting stats, as in this case we need to scan entire
     *     regions worth of data to track where to put the guide posts.
     */
    if (isPointLookup || ScanUtil.isAnalyzeTable(scan)) { 
        return false;
    }
    return true;
}

为了验证是不是这里慢了,基于以上分析,我将上述步骤中的 scan sql 换成 get(point lookup),这样可以跳过获取 stats 这一步。还是查询那张大表,执行 sql:select * from "secondline"."use_log_info" where "ROW" = ‘xxx’; 终于看到曙光,果然没有冷启动问题,第一次耗时 1s 多 (主要在获取 region 元数据),第二次毫秒级,那基本可以定位出就是慢在获取 stats 这一步了。



分析下为什么这一步很慢。查看具体的实现,在获取此表在 SYSTEM.STATS 的信息,并逐行分析写入缓存。。通过 sql 查看有 64430 行数据 (SELECT count(1) FROM SYSTEM.STATS WHERE PHYSICAL_NAME = 'secondline:use_log_info';)里面涉及到大量的复杂逻辑,留给以后再深入。(#todo)

public PTableStats getTableStats(PTable table) throws SQLException {
    /*
     *  The shared view index case is tricky, because we don't have
     *  table meta data for it, only an HBase table. We do have stats,
     *  though, so we'll query them directly here and cache them so
     *  we don't keep querying for them.
     */
    boolean isSharedIndex = table.getViewIndexId() != null;
    if (isSharedIndex) {
        // we are assuming the stats table is not transactional
        return connection.getQueryServices().getTableStats(table.getPhysicalName().getBytes(), getCurrentScn());
    }
    boolean isView = table.getType() == PTableType.VIEW;
    String physicalName = table.getPhysicalName().getString();
    if (isView && table.getViewType() != ViewType.MAPPED) {
        try {
            return connection.getTable(new PTableKey(null, physicalName)).getTableStats();
        } catch (TableNotFoundException e) {
            // Possible when the table timestamp == current timestamp - 1.
            // This would be most likely during the initial index build of a view index
            // where we're doing an upsert select from the tenant specific table.
            // TODO: would we want to always load the physical table in updateCache in
            // this case too, as we might not update the view with all of it's indexes?
            String physicalSchemaName = SchemaUtil.getSchemaNameFromFullName(physicalName);
            String physicalTableName = SchemaUtil.getTableNameFromFullName(physicalName);
            MetaDataMutationResult result = updateCache(null, physicalSchemaName, physicalTableName, false);
            if (result.getTable() == null) {
                throw new TableNotFoundException(physicalSchemaName, physicalTableName);
            }
            return result.getTable().getTableStats();
        }
    }
    return table.getTableStats();
}

4. 解决办法

  1. 减小表的数据规模
  2. 改用 phoenix queryserver,只要 queryserver 不重启缓存不会失效。

5. 总结

定位性能问题需要完善的日志与监控(metrics),不然每次遇到问题只能摸着石头过河了,想当然。

遇到问题尽量基于日志,基于源码分析。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容