HBase PageFilter踩坑之旅

做個好人君發表於2018-01-04

個人部落格

有這樣一個場景,在HBase中需要分頁查詢,同時根據某一列的值進行過濾。

不同於RDBMS天然支援分頁查詢,HBase要進行分頁必須由自己實現。據我瞭解的,目前有兩種方案, 一是《HBase權威指南》中提到的用PageFilter加迴圈動態設定startRow實現,詳細見這裡。但這種方法效率比較低,且有冗餘查詢。因此京東研發了一種用額外的一張表來儲存行序號的方案。 該種方案效率較高,但實現麻煩些,需要維護一張額外的表。

不管是方案也好,人也好,沒有最好的,只有最適合的。 在我司的使用場景中,對於效能的要求並不高,所以採取了第一種方案。本來使用的美滋滋,但有一天需要在分頁查詢的同時根據某一列的值進行過濾。根據列值過濾,自然是用SingleColumnValueFilter(下文簡稱SCVFilter)。程式碼大致如下,只列出了本文主題相關的邏輯,

Scan scan = initScan(xxx);
FilterList filterList=new FilterList();
scan.setFilter(filterList);
filterList.addFilter(new PageFilter(1));
filterList.addFilter(new SingleColumnValueFilter(FAMILY,ISDELETED, CompareFilter.CompareOp.EQUAL, Bytes.toBytes(false)));
複製程式碼

資料如下


 row1                 column=f:content, timestamp=1513953705613, value=content1
 row1                 column=f:isDel, timestamp=1513953705613, value=1
 row1                 column=f:name, timestamp=1513953725029, value=name1
 row2                 column=f:content, timestamp=1513953705613, value=content2
 row2                 column=f:isDel, timestamp=1513953744613, value=0
 row2                 column=f:name, timestamp=1513953730348, value=name2
 row3                 column=f:content, timestamp=1513953705613, value=content3
 row3                 column=f:isDel, timestamp=1513953751332, value=0
 row3                 column=f:name, timestamp=1513953734698, value=name3

複製程式碼

在上面的程式碼中。向scan新增了兩個filter:首先新增了PageFilter,限制這次查詢數量為1,然後新增了一個SCVFilter,限制了只返回isDeleted=false的行。

上面的程式碼,看上去無懈可擊,但在執行時卻沒有查詢到資料!

剛好最近在看HBase的程式碼,就在本地debug了下HBase服務端Filter相關的查詢流程。

Filter流程

首先看下HBase Filter的流程,見圖: [圖片上傳失敗...(image-a30991-1514982234552)]

然後再看PageFilter的實現邏輯。


public class PageFilter extends FilterBase {
  private long pageSize = Long.MAX_VALUE;
  private int rowsAccepted = 0;

  /**
   * Constructor that takes a maximum page size.
   *
   * @param pageSize Maximum result size.
   */
  public PageFilter(final long pageSize) {
    Preconditions.checkArgument(pageSize >= 0, "must be positive %s", pageSize);
    this.pageSize = pageSize;
  }

  public long getPageSize() {
    return pageSize;
  }

  @Override
  public ReturnCode filterKeyValue(Cell ignored) throws IOException {
    return ReturnCode.INCLUDE;
  }
 
  public boolean filterAllRemaining() {
    return this.rowsAccepted >= this.pageSize;
  }

  public boolean filterRow() {
    this.rowsAccepted++;
    return this.rowsAccepted > this.pageSize;
  }
  
}

複製程式碼

其實很簡單,內部有一個計數器,每次呼叫filterRow的時候,計數器都會+1,如果計數器值大於pageSize,filterrow就會返回true,那之後的行就會被過濾掉。

再看SCVFilter的實現邏輯。

public class SingleColumnValueFilter extends FilterBase {
  private static final Log LOG = LogFactory.getLog(SingleColumnValueFilter.class);

  protected byte [] columnFamily;
  protected byte [] columnQualifier;
  protected CompareOp compareOp;
  protected ByteArrayComparable comparator;
  protected boolean foundColumn = false;
  protected boolean matchedColumn = false;
  protected boolean filterIfMissing = false;
  protected boolean latestVersionOnly = true;

 

  /**
   * Constructor for binary compare of the value of a single column.  If the
   * column is found and the condition passes, all columns of the row will be
   * emitted.  If the condition fails, the row will not be emitted.
   * <p>
   * Use the filterIfColumnMissing flag to set whether the rest of the columns
   * in a row will be emitted if the specified column to check is not found in
   * the row.
   *
   * @param family name of column family
   * @param qualifier name of column qualifier
   * @param compareOp operator
   * @param comparator Comparator to use.
   */
  public SingleColumnValueFilter(final byte [] family, final byte [] qualifier,
      final CompareOp compareOp, final ByteArrayComparable comparator) {
    this.columnFamily = family;
    this.columnQualifier = qualifier;
    this.compareOp = compareOp;
    this.comparator = comparator;
  }

 
   
  @Override
  public ReturnCode filterKeyValue(Cell c) {
    if (this.matchedColumn) {
      // We already found and matched the single column, all keys now pass
      return ReturnCode.INCLUDE;
    } else if (this.latestVersionOnly && this.foundColumn) {
      // We found but did not match the single column, skip to next row
      return ReturnCode.NEXT_ROW;
    }
    if (!CellUtil.matchingColumn(c, this.columnFamily, this.columnQualifier)) {
      return ReturnCode.INCLUDE;
    }
    foundColumn = true;
    if (filterColumnValue(c.getValueArray(), c.getValueOffset(), c.getValueLength())) {
      return this.latestVersionOnly? ReturnCode.NEXT_ROW: ReturnCode.INCLUDE;
    }
    this.matchedColumn = true;
    return ReturnCode.INCLUDE;
  }

 
  
  private boolean filterColumnValue(final byte [] data, final int offset,
      final int length) {
    int compareResult = this.comparator.compareTo(data, offset, length);
    switch (this.compareOp) {
    case LESS:
      return compareResult <= 0;
    case LESS_OR_EQUAL:
      return compareResult < 0;
    case EQUAL:
      return compareResult != 0;
    case NOT_EQUAL:
      return compareResult == 0;
    case GREATER_OR_EQUAL:
      return compareResult > 0;
    case GREATER:
      return compareResult >= 0;
    default:
      throw new RuntimeException("Unknown Compare op " + compareOp.name());
    }
  }

  public boolean filterRow() {
    // If column was found, return false if it was matched, true if it was not
    // If column not found, return true if we filter if missing, false if not
    return this.foundColumn? !this.matchedColumn: this.filterIfMissing;
  }
   
 
}
複製程式碼

在HBase中,對於每一行的每一列都會呼叫到filterKeyValue,SCVFilter的該方法處理邏輯如下:

1. 如果已經匹配過對應的列並且對應列的值符合要求,則直接返回INCLUE,表示這一行的這一列要被加入到結果集
2. 否則如latestVersionOnly為true(latestVersionOnly代表是否只查詢最新的資料,一般為true),並且已經匹配過對應的列(但是對應的列的值不滿足要求),則返回EXCLUDE,代表丟棄該行
3. 如果當前列不是要匹配的列。則返回INCLUDE,否則將matchedColumn置為true,代表以及找到了目標列
4. 如果當前列的值不滿足要求,在latestVersionOnly為true時,返回NEXT_ROW,代表忽略當前行還剩下的列,直接跳到下一行
5. 如果當前列的值滿足要求,將matchedColumn置為true,代表已經找到了對應的列,並且對應的列值滿足要求。這樣,該行下一列再進入這個方法時,到第1步就會直接返回,提高匹配效率
複製程式碼

再看filterRow方法,該方法呼叫時機在filterKeyValue之後,對每一行只會呼叫一次。 SCVFilter中該方法邏輯很簡單:

1. 如果找到了對應的列,如其值滿足要求,則返回false,代表將該行加入到結果集,如其值不滿足要求,則返回true,代表過濾該行
2. 如果沒找到對應的列,返回filterIfMissing的值。
複製程式碼

猜想:

是不是因為將PageFilter新增到SCVFilter的前面,當判斷第一行的時候,呼叫PageFilter的filterRow,導致PageFilter的計數器+1,但是進行到SCVFilter的filterRow的時候,該行又被過濾掉了,在檢驗下一行時,因為PageFilter計數器已經達到了我們設定的pageSize,所以接下來的行都會被過濾掉,返回結果沒有資料。

驗證:

在FilterList中,先加入SCVFilter,再加入PageFilter

Scan scan = initScan(xxx);
FilterList filterList=new FilterList();
scan.setFilter(filterList);
filterList.addFilter(new SingleColumnValueFilter(FAMILY,ISDELETED, CompareFilter.CompareOp.EQUAL, 	Bytes.toBytes(false)));
filterList.addFilter(new PageFilter(1));
複製程式碼

結果是我們期望的第2行的值。

結論

當要將PageFilter和其他Filter使用時,最好將PageFilter加入到FilterList的末尾,否則可能會出現結果個數小於你期望的數量。 (其實正常情況PageFilter返回的結果數量可能大於設定的值,因為伺服器叢集的PageFilter是隔離的。)

彩蛋

其實,在排查問題的過程中,並沒有這樣順利,因為問題出線上上,所以我在本地查問題時自己造了一些測試資料,令人驚訝的是,就算我先加入SCVFilter,再加入PageFilter,返回的結果也是符合預期的。 測試資料如下:

 row1                 column=f:isDel, timestamp=1513953705613, value=1
 row1                 column=f:name, timestamp=1513953725029, value=name1
 row2                 column=f:isDel, timestamp=1513953744613, value=0
 row2                 column=f:name, timestamp=1513953730348, value=name2
 row3                 column=f:isDel, timestamp=1513953751332, value=0
 row3                 column=f:name, timestamp=1513953734698, value=name3

複製程式碼

當時在本地一直不能復現問題。很是苦惱,最後竟然發現使用SCVFilter查詢的結果還和資料的列的順序有關。

在服務端,HBase會對客戶端傳遞過來的filter封裝成FilterWrapper。

class RegionScannerImpl implements RegionScanner {

    RegionScannerImpl(Scan scan, List<KeyValueScanner> additionalScanners, HRegion region)
        throws IOException {
      this.region = region;
      this.maxResultSize = scan.getMaxResultSize();
      if (scan.hasFilter()) {
        this.filter = new FilterWrapper(scan.getFilter());
      } else {
        this.filter = null;
      }
    }
   ....
}
複製程式碼

在查詢資料時,在HRegion的nextInternal方法中,會呼叫FilterWrapper的filterRowCellsWithRet方法

FilterWrapper相關程式碼如下:

/**
 * This is a Filter wrapper class which is used in the server side. Some filter
 * related hooks can be defined in this wrapper. The only way to create a
 * FilterWrapper instance is passing a client side Filter instance through
 * {@link org.apache.hadoop.hbase.client.Scan#getFilter()}.
 * 
 */
 
final public class FilterWrapper extends Filter {
  Filter filter = null;

  public FilterWrapper( Filter filter ) {
    if (null == filter) {
      // ensure the filter instance is not null
      throw new NullPointerException("Cannot create FilterWrapper with null Filter");
    }
    this.filter = filter;
  }

 
  public enum FilterRowRetCode {
    NOT_CALLED,
    INCLUDE,     // corresponds to filter.filterRow() returning false
    EXCLUDE      // corresponds to filter.filterRow() returning true
  }
  
  public FilterRowRetCode filterRowCellsWithRet(List<Cell> kvs) throws IOException {
    this.filter.filterRowCells(kvs);
    if (!kvs.isEmpty()) {
      if (this.filter.filterRow()) {
        kvs.clear();
        return FilterRowRetCode.EXCLUDE;
      }
      return FilterRowRetCode.INCLUDE;
    }
    return FilterRowRetCode.NOT_CALLED;
  }

 
}

複製程式碼

這裡的kvs就是一行資料經過filterKeyValue後沒被過濾的列。

可以看到當kvs不為empty時,filterRowCellsWithRet方法中會呼叫指定filter的filterRow方法,上面已經說過了,PageFilter的計數器就是在其filterRow方法中增加的。

而當kvs為empty時,PageFilter的計數器就不會增加了。再看我們的測試資料,因為行的第一列就是SCVFilter的目標列isDeleted。回顧上面SCVFilter的講解我們知道,當一行的目標列的值不滿足要求時,該行剩下的列都會直接被過濾掉!

對於測試資料第一行,走到filterRowCellsWithRet時kvs是empty的。導致PageFilter的計數器沒有+1。還會繼續遍歷剩下的行。從而使得返回的結果看上去是正常的。

而出問題的資料,因為在列isDeleted之前還有列content,所以當一行的isDeleted不滿足要求時,kvs也不會為empty。因為列content的值已經加入到kvs中了(這些資料要呼叫到SCVFilter的filterrow的時間會被過濾掉)。

感想

從實現上來看HBase的Filter的實現還是比較粗糙的。效率也比較感人,不考慮網路傳輸和客戶端記憶體的消耗,基本上和你在客戶端過濾差不多。

相關文章