做数据查询和排查的时候,我几乎都会先打开HBase Shell把Filter过滤器语法试一遍,再往Java里迁移。别小看这个习惯,它帮我省掉了大量打包、上传、重启的时间。这篇内容是我对HBase Shell里各种Filter过滤器用法的完整梳理,覆盖了行键过滤、列过滤、值过滤、组合过滤,以及几个我实际踩过且很容易翻车的坑。无论你是刚接触HBase,还是已经写过不少Scan,这篇都值得收藏备用。
1. 为什么我建议所有Filter调试都从Shell开始
1.1 一次真实需求:条件复杂,Java调试慢
有一回我需要从一张用户行为表里筛出“状态正常、最近登录时间在某个区间、且行为类型包含支付”的数据。当时第一反应是写Java:创建Scan,setFilter,循环Result打印,然后编译、打包、提交到集群。结果发现条件写错了,又得重新来一轮。来回折腾差不多一个下午,才把过滤逻辑调对。
后来我换了个思路:直接在Shell里用scan '表名', {FILTER => "..."}试。同样一个逻辑,改一行字符串、回车执行,结果马上出来。把Shell里验证好的过滤器表达式往Java代码里一套,scan.setFilter(...)一行搞定。从那以后,凡是涉及Filter的查询,我都先在Shell里把表达式敲通,再谈代码实现。
1.2 Filter在服务端执行,Shell和Java底层是同一套东西
很多人会误以为HBase的Filter是把数据先拉到客户端再过滤,其实不是。Filter是随着Scan请求发到RegionServer,在服务端逐条判断单元格数据,只把过滤后剩下的结果返回给客户端。这一点决定了Shell里写Filter和Java里写Filter在服务端行为上没有任何区别,也意味着过滤能减少网络IO和客户端内存消耗。
当然,服务端执行不代表Filter一定“很快”。有些过滤器(比如ValueFilter)需要扫描大量单元格才能判断,扫描量并不会因为用了Filter而自动变小。所以我在后面第3章会特别强调STARTROW/ENDROW和Filter配合的重要性,这两件事缺一不可。
1.3 先建立Filter的分类框架
HBase Shell里的Filter很多,但归类后其实很清晰。我是按“过滤作用在哪个维度”来记的,这张表基本就是全文的目录:
| 维度 | 过滤器 | 典型用途 |
|---|---|---|
| 行键 | RowFilter、PrefixFilter | 按行键精确匹配、范围、前缀筛选 |
| 列 | FamilyFilter、QualifierFilter、ColumnPrefixFilter、MultipleColumnPrefixFilter、ColumnRangeFilter | 按列族、列名过滤 |
| 值 | ValueFilter、SingleColumnValueFilter、SingleColumnValueExcludeFilter | 按单元格的值过滤 |
| 其他 | KeyOnlyFilter、FirstKeyOnlyFilter、TimestampsFilter、PageFilter | 行键导出、时间戳、分页 |
| 组合/包装 | FilterList、SkipFilter、WhileMatchFilter | 多条件、跳过整行、提前结束扫描 |
这个框架记熟了,面对任何查询需求,第一反应就能判断该用哪个分类下的Filter。
2. Shell过滤器语法:表达式、比较器与解析规则
2.1 一行scan命令的完整写法
Shell里使用Filter的基本模板是:
scan '表名', { STARTROW => '起始行键', ENDROW => '结束行键', COLUMNS => ['列族:列名'], FILTER => "过滤器表达式" }其中FILTER是核心。它本身是一个字符串,Shell会把这个字符串解析成真正的Java Filter对象。字符串内部主要由三部分组成:
- 过滤器类名,比如
RowFilter、ValueFilter - 比较操作符,比如
=、!=、>、>=、<、<= - 比较器,比如
binary:xxx、substring:xxx、regexstring:xxx
看一个最简单的例子:
scan 'user_log', {FILTER => "RowFilter(=, 'binary:user_001')"}这句话的意思是:把user_log表里行键精确等于user_001的行扫描出来。
2.2 四类比较器的语义差别
比较器决定了“拿什么标准去比”,Shell里最常用的是这四种:
| 比较器写法 | 含义 | 适用场景 | 注意点 |
|---|---|---|---|
binary:值 | 字节数组完全相等 | 精确匹配字符串/数值 | 默认按UTF-8编码转成字节 |
binaryprefix:前缀 | 字节数组前缀匹配 | 行键或值以某前缀开头 | 等价于PrefixFilter的底层实现 |
substring:子串 | 值中包含指定子串即匹配 | 模糊匹配 | 只能配合=使用,是包含语义 |
regexstring:正则 | 正则表达式匹配 | 复杂模式匹配 | 只能配合=使用,反斜杠要转义 |
这里要特别提醒:substring和regexstring做的是“包含”匹配,所以它们只适合=操作符。如果你写ValueFilter(>=, 'substring:abc'),看起来不像会报错,但语义已经变得很奇怪,实际结果基本不可用。
2.3 引号、括号、布尔值的书写规则
写Shell过滤器表达式时,引号规则很容易乱,我总结了一个口诀:
- 外层整条表达式用双引号包裹
- 过滤器内字符串参数一律用单引号
- 布尔值
true/false不加引号 - 数字参数不加引号
举个例子:
scan 'order', {FILTER => "SingleColumnValueFilter('info', 'status', =, 'binary:PAID', true, true)"}这里'info'和'status'是列族和列名,要加单引号;true是布尔参数,不加引号。如果你把true也写成'true',Shell解析时就会认为你想传一个叫true的字符串,轻则匹配不到,重则直接报参数类型错误。
2.4 解析报错是最快的排错入口
我一直把Shell当作过滤器语法的“解释器”在用。如果表达式写错,Shell会在执行时抛异常,比如IllegalArgumentException或者Unknown filter ...。这个报错其实非常有用,它会直接告诉你哪个类名、哪个比较器解析不了,省去了在Java代码里反复编译排查的时间。
报错信息里经常能看到的几个关键词:
ParseException:过滤器语法整体有问题,多半是括号或引号没配对IllegalArgumentException:参数类型不对,比如把布尔值加了引号Unknown comparator:比较器名称写错了,比如把binary拼成了binar或者Binary
这些错误在Shell阶段就能暴露,而如果直接写到Java里,很可能要等到运行期才会发现。
3. 行键过滤:RowFilter和PrefixFilter的选择边界
3.1 精确匹配、前缀匹配、范围匹配怎么选
行键是HBase查询里最关键的维度。Shell里针对行键的过滤器主要是RowFilter和PrefixFilter,但两者解决的问题不同。
精确匹配,用RowFilter配合binary比较器:
scan 'user_log', {FILTER => "RowFilter(=, 'binary:user_001')"}前缀匹配,最简单的写法是用PrefixFilter:
scan 'user_log', {FILTER => "PrefixFilter('user_00')"}PrefixFilter只接收一个前缀参数,内部其实就是RowFilter(=, 'binaryprefix:前缀')。如果你在其他地方看到这种替代写法,效果完全一样:
scan 'user_log', {FILTER => "RowFilter(=, 'binaryprefix:user_00')"}范围匹配,则必须用RowFilter配合比较操作符。HBase没有BETWEEN操作符,要实现闭开区间,得同时写两个条件再用AND连接:
scan 'user_log', { FILTER => "RowFilter(>=, 'binary:user_001') AND RowFilter(<, 'binary:user_100')" }这段表达式的意思是:行键从user_001到user_099(不含user_100)。注意这里比较的是行键的字节序,不是数字大小。
3.2 startRow和endRow才是真正的性能利器
很多人以为用了RowFilter(>=, ...)就万事大吉,其实这是一个性能误区。RowFilter本身并不能帮助HBase快速定位到起始行,它只是对扫描到的每一行做判断。如果表有几千万行,RangeFilter从第一个Region的第一行开始逐行筛,慢是必然的。
正确做法是:能用STARTROW/ENDROW圈定范围,就一定用。Filter负责把圈定范围内的行再按条件细筛,两者是配合关系,不是替代关系。
scan 'user_log', { STARTROW => 'user_001', ENDROW => 'user_200', FILTER => "RowFilter(=, 'binaryprefix:user_1')" }在这个例子里,STARTROW帮HBase跳过了user_001之前的所有行,ENDROW保证扫到user_200就停,RowFilter再把不是user_1开头的行剔除。这样扫描范围被有效压缩,效率比单独写Filter好很多。
3.3 快速导出全表行键的一个组合技巧
有时候你只想看某张表有哪些行键,不想关心具体列值。可以用KeyOnlyFilter和FirstKeyOnlyFilter组合:
scan 'user_log', {FILTER => "KeyOnlyFilter() AND FirstKeyOnlyFilter()"}KeyOnlyFilter会把每个单元格的值全部置空,只留行键、列族、列名这些键信息;FirstKeyOnlyFilter则保证每一行只返回第一个单元格。两者一配合,扫描出来的结果就变成一页清爽的行键列表,对日常排查表数据分布非常有用。
4. 列维度过滤:从列族到限定符的控制粒度
4.1 列族过滤:用得少,但要知道
FamilyFilter是按列族名称过滤的过滤器,写法如下:
scan 'user_log', {FILTER => "FamilyFilter(=, 'binary:info')"}实际项目里这个过滤器用得很少,因为表设计时列族通常就那几个,直接在COLUMNS参数里指定'info'就已经限定了列族。但如果你的查询逻辑里需要“某个列族存在才返回该行”这种动态判断,FamilyFilter还是能用上的,至少它的名字足够直观。
4.2 QualifierFilter与ColumnPrefixFilter:列名过滤的两把刀
按列名过滤,最常用的是QualifierFilter,它针对的是“列限定符”,也就是我们常说的“列名”。比如要精确找到info:name这一列:
scan 'user_info', {COLUMN => 'info', FILTER => "QualifierFilter(=, 'binary:name')"}如果要找“以某个前缀开头的所有列”,则用ColumnPrefixFilter:
scan 'user_info', {COLUMN => 'info', FILTER => "ColumnPrefixFilter('addr')"}这条命令会返回info列族下所有以addr开头的列,比如addr_province、addr_city、addr_detail。
注意两者的一个区别:QualifierFilter里需要写比较操作符和比较器,而ColumnPrefixFilter直接接收列名前缀字符串,没有比较操作符。我第一次用的时候习惯性写成了ColumnPrefixFilter(=, 'addr'),结果直接报错。它是少数的“非比较型”过滤器,语法上要和RowFilter这类区分开。
4.3 多前缀与范围:MultipleColumnPrefixFilter和ColumnRangeFilter
当你要匹配多个列名前缀时,可以用MultipleColumnPrefixFilter:
scan 'user_info', { COLUMN => 'info', FILTER => "MultipleColumnPrefixFilter('addr', 'phone', 'email')" }它会返回addr、phone、email这三个前缀下的所有列。比写一串QualifierFilter再用OR连接要简洁得多,性能上也更直接。
如果要按列名的字典序范围来过滤,则用ColumnRangeFilter。它接收四个参数:最小列名、是否包含最小列名、最大列名、是否包含最大列名。
scan 'user_info', { COLUMN => 'info', FILTER => "ColumnRangeFilter('a', true, 'm', false)" }这句的意思是:返回info列族下列名从a到m(不含m)之间的列。实际使用时要注意,HBase内部排序是按列族+列名的字节序来的,如果你不指定COLUMN就全表扫描,不同列族里同名限定符的列会混在一起,ColumnRangeFilter的结果可能和你预想的不一致。最好始终配合COLUMN使用。
4.4 COLUMNS参数和列过滤器的分工
这是很多新手会绕晕的一件事:我明明已经写了COLUMN => 'info:name',为什么还要用QualifierFilter?
关键在于区分“投影”和“过滤”。COLUMNS参数指的是“我要返回哪些列”,它是结果集的投影;而QualifierFilter是“根据列名做判断,决定这一列要不要参与返回”。如果COLUMNS已经精确指定了列,通常不需要再用列过滤器;但如果一个列族下有很多列,你想“只要列名符合某种模式的列”,那COLUMNS => 'info'加QualifierFilter就是很自然的组合。
scan 'user_info', { COLUMN => 'info', FILTER => "QualifierFilter(=, 'regexstring:^(name|age)$')" }这句可以只返回info列族下的name和age两个列。COLUMN管住列族范围,QualifierFilter管住列名模式,分工明确。
5. 值过滤:ValueFilter与SingleColumnValueFilter的取舍
5.1 ValueFilter:按单元格值匹配,不是按行匹配
ValueFilter是所有过滤器里最直白的一个:它遍历所有单元格,只要值满足比较条件,就把这个单元格返回。
scan 'user_log', { COLUMN => 'info:event_type', FILTER => "ValueFilter(=, 'substring:pay')" }这句会把info:event_type列下所有值包含pay的单元格筛出来。
但这里有个重要认知:ValueFilter返回的是“匹配的单元格”,不是“匹配的行”。如果一行里有10个单元格,只有1个单元格的值命中,返回给你的也只有这1个单元格,而不是整行。这在第7章踩坑部分还会细说。
ValueFilter的另一个特点是它很难利用行键索引,扫描时需要逐单元格判断,所以它是性能天花板最低的过滤器。能用行键和列名先缩范围,就不要用ValueFilter从头扫到尾。
5.2 SingleColumnValueFilter:把某列变成整行的“门槛”
和ValueFilter按单元格过滤不同,SingleColumnValueFilter的语义是:拿指定列的值做判断,决定“这一整行”要不要返回。它适合的场景是“某列等于某个值才返回该行”。
scan 'user_log', { FILTER => "SingleColumnValueFilter('info', 'status', =, 'binary:active')" }这句返回的是:info:status列的值等于active的那些行。条件列本身不会被包含在结果里,返回的是这些行里的其他列数据。
实际使用时有一个很隐蔽的坑:SingleColumnValueFilter默认情况下,如果某一行根本没有info:status这一列,这行也会被返回。原因是默认filterIfMissing=false,HBase认为“无法判断该列值是否匹配,就不因为缺少该列而过滤掉整行”。这经常导致过滤结果出现“漏网之鱼”。
5.3 filterIfMissing和latestVersionOnly两个参数
如果希望“某列不存在就不返回这行”,需要显式开启filterIfMissing。Shell的字符串表达式里可以在四个基础参数后面追加两个布尔参数:
scan 'user_log', { FILTER => "SingleColumnValueFilter('info', 'status', =, 'binary:active', true, true)" }这里倒数第二个true表示:当行里没有info:status列时,直接过滤掉该行;最后一个true表示:只使用该列的最新版本做判断,不理会历史版本。这两个参数在数据质量不齐整的表上非常关键,建议先确认数据里是否存在“缺列”的情况,再决定要不要传true。
5.4 SingleColumnValueExcludeFilter:返回整行但排除条件列
SingleColumnValueExcludeFilter的语义和SingleColumnValueFilter几乎一样,区别在于它明确排除了条件列本身。你可能会问:SingleColumnValueFilter不是也不返回条件列吗?实际使用中,如果你只想拿“排除条件列之后的整行”,用SingleColumnValueExcludeFilter语义更清晰,不容易被版本行为差异误导。
scan 'user_log', { FILTER => "SingleColumnValueExcludeFilter('info', 'status', =, 'binary:active', true, true)" }比如你要统计“状态正常且不是测试账号”的设备,设备ID存放在info:device_id,状态在info:status。用SingleColumnValueFilter会把device_id一起返回,而用SingleColumnValueExcludeFilter能把info:status从结果里摘掉,让输出更干净。
6. 组合过滤器:AND/OR的求值逻辑与两个实用的包装器
6.1 AND/OR在Shell里就是FilterList
单个过滤器满足不了复杂查询,Shell里可以直接用AND和OR连接多个过滤器。AND等价于FilterList的MUST_PASS_ALL,也就是所有条件都满足才保留;OR等价于MUST_PASS_ONE,任一条件满足就保留。
scan 'order', { FILTER => "PrefixFilter('2024') AND ValueFilter(=, 'substring:PAID')" }这句同时满足“行键以2024开头”和“单元格值包含PAID”两个条件。括号也可以嵌套:
scan 'order', { FILTER => "PrefixFilter('2024') AND (QualifierFilter(=, 'binary:status') OR QualifierFilter(=, 'binary:remark'))" }嵌套时注意左右括号配对,Shell的解析器对括号匹配很敏感,多一个少一个都会报ParseException。
6.2 组合顺序会影响性能,条件前置很重要
AND连接的过滤器是“逐个判断”的,前面的过滤器先把大部分行淘汰掉,后面的过滤器处理的单元格数量就大大减少。所以写组合条件时,我会把“代价低、过滤能力强”的条件放前面。
具体优先级大概是:行键相关条件 > 列名条件 > 值条件。因为RowFilter每行只判断一次,而ValueFilter要对该行每个单元格都判断一次,昂贵得多。把PrefixFilter放在最前面,能最大化剪枝效果。
6.3 SkipFilter:让单元格过滤升级为整行过滤
前面提到ValueFilter返回的是匹配的单元格,而不是整行。如果想让“一行里只要有一个单元格匹配,就返回整行”,就需要用SkipFilter把它包起来:
scan 'user_log', { FILTER => "SkipFilter(ValueFilter(=, 'substring:PAID'))" }SkipFilter的行为逻辑是:当内部过滤器对某行的所有单元格都判断为“不保留”时,跳过整行;只要有一个单元格让内部过滤器放行,整行就都可以返回。这个组合我几乎每个星期都会用,非常实用。
6.4 WhileMatchFilter:扫描到指定边界立即停止
默认情况下,一次Scan会把范围内所有匹配数据全部扫完。如果你只想“扫到某一类行就停止”,可以用WhileMatchFilter包住内部过滤器:
scan 'user_log', { STARTROW => 'user_001', FILTER => "WhileMatchFilter(RowFilter(<, 'binary:user_001'))" }这句会在行键到达user_001之前一直扫描,一旦遇到行键等于或大于user_001的行,整个扫描立即终止。这对“按行键顺序做早期截止”非常实用,避免无谓的后续扫描。不过要注意WhileMatchFilter是全局性的,不能指望它只在某个Region内生效,因此使用前要让扫描顺序符合你的预期。
7. 五个最容易翻车的Filter调试坑与排查记录
7.1 坑一:binary和substring用错,导致结果永远为空
有一回我想匹配行键前缀,写成了:
scan 'user_log', {FILTER => "RowFilter(=, 'substring:user_00')"}结果扫描出来的结果为空。我当时很困惑,user_00明明存在于很多行键里。后来想明白了:substring比较器虽然也是包含匹配,但它和binary比较器不是一回事。RowFilter(=, 'substring:user_00')做的事是“拿值user_00去按子串方式比较”,按道理应该能匹配才对。但为什么为空?
问题出在RowFilter的行键比较机制上:行键过滤按整行键判断,而substring匹配的对象是“目标值”,这里目标值是user_00,但它只匹配“完整值中包含user_00的情况”。行键是user_001、user_002这种,确实应该包含user_00。所以更可能的问题是:substring比较器在RowFilter等“整行键”场景里的行为和你预期不同,最稳妥的做法是用binaryprefix:
scan 'user_log', {FILTER => "RowFilter(=, 'binaryprefix:user_00')"}从此我养成了一个习惯:涉及行键前缀,一律用PrefixFilter或binaryprefix;涉及值模糊匹配,才用substring。两者不要混用。
7.2 坑二:binary:后面多了一个空格
Shell里写比较器时,binary:和值之间不能随便加空格。我见过同事写:
scan 'user_log', {FILTER => "RowFilter(=, 'binary: user_001')"}看起来只是多个空格,但实际上比较的值变成了" user_001",也就是带前导空格的字符串。HBase的BinaryComparator会把binary:后面的所有字符都当成字节内容,空格也是内容的一部分。你明明看到数据里就是user_001,可过滤却一直为空,调试到头大。
排查思路很简单:先在Shell里scan一下看看实际行键的原始显示,再用hex方式确认字节内容。日常写的时候,养成“冒号后紧跟值”的习惯,不要为了排版加空格。
7.3 坑三:正则比较器只适合等值判断
regexstring看起来很强,但有一个限制:它本质上是做“包含式”正则匹配,因此只适合配合=操作符。
如果写:
scan 'user_log', {FILTER => "RowFilter(>=, 'regexstring:^user_1')"}这在语义上就很怪,实际运行结果也可能不符合预期。遇到这种需求,不要试图用正则加范围比较,老老实实拆成RowFilter(>=, 'binary:user_1') AND RowFilter(<, 'binary:user_2')这种字节范围条件。
另外,正则表达式里的反斜杠在Shell双引号字符串里要注意转义。比如\d要写成\\d,否则可能被Shell字符串处理吃掉。我第一次写regexstring:\\d{4}的时候,也在这个转义问题上卡了十几分钟。
7.4 坑四:字符串数字比较不是数值比较
HBase里存的值本质上是字节数组。如果你用字符串形式存数字,比如"100"、"200",用binary比较器去做>=操作,实际是按字节字典序比较,而不是按数值大小。
举个例子:
scan 'order', {FILTER => "ValueFilter(>=, 'binary:100')"}你以为会筛出数值大于等于100的记录,但字典序下"99"排在"100"后面,所以"99"也会被匹配出来。这绝对是排查半天都发现不了的暗坑。
解决思路有两个方向:一是存储时把数字转成定长字符串,比如固定6位补零"000100";二是如果必须按真实数值比较,就不要在Shell里用字符串比较器,改成在Java里用Long.toBytes(long)构造比较值。归根结底,要让“存储的编码格式”和“比较器的比较规则”保持一致。
7.5 坑五:ValueFilter筛出来的单元格拼不成完整记录
这个坑前面提过,但值得单独再写一次。很多人想“找出所有备注里包含某个关键词的用户”,于是写:
scan 'user_info', {FILTER => "ValueFilter(=, 'substring:VIP')"}结果返回的是零散的info:remark单元格,而不是一行一行的完整用户记录。你需要的是SkipFilter(ValueFilter(...)):
scan 'user_info', {FILTER => "SkipFilter(ValueFilter(=, 'substring:VIP'))"}用SkipFilter把ValueFilter包起来,才能做到“一行里只要某个单元格匹配,就返回整行”。这个区别,文字上很难一眼看出来,只有踩过坑、看到输出结果时才明白。
我在实际使用中还有一个小习惯:每次调试Filter,先用LIMIT小范围取样看结果,确认数据形态符合预期,再放宽条件跑大查询。毕竟Filter在服务端执行,写错了顶多结果不对,但写对了性能却很差,同样会拖垮线上业务。先想清楚过滤维度,再决定用哪个分类下的过滤器,最后用Shell验证一遍,这条路对我来说是最稳妥的。