接手一套跑了三年的集群第一件事往往不是看读写延迟曲线而是有人跑来问这张表能不能删了重来——问这话的人通常已经准备好执行drop了而我很清楚那张表底下挂着两个下游的离线任务和一张快照策略。HBase 的表操作看起来是所有数据库里最朴素的一类建、删、改、停用、清空命令就那么几条help一敲全出来。但真正在生产里出事的从来不是记不住语法而是不知道每条命令背后改了什么元数据、触发了哪些 region 状态迁移、以及在哪个时间窗口内集群是不可用的。这篇内容面向的是已经在用 HBase、但表操作还停留在照着文档敲命令阶段的朋友也适合刚接手集群、需要快速建立一套安全操作习惯的运维和开发。我会把建表参数怎么定、alter 到底动了什么、disable 和 truncate 的真实代价、以及表操作出问题后怎么顺着链路查下去按我自己的实操顺序讲一遍。所有涉及的参数和命令以 HBase 2.x 为准1.x 的差异我会在关键处点出来因为现在还有不少存量集群没升上去。1. 从一次建表失败说起表操作的边界与工具选择我印象最深的一次是帮一个模拟项目做数据接入对方给了个 HBase 表结构清单列族写着五六个每个都配了不同的 TTL。负责建表的同学在 shell 里一行行敲敲到第四个列族的时候报了个错他没细看直接重来一遍结果表已经建出来了后面所有的create都在报TableExistsException。折腾了四十分钟才发现是第一次已经成功了一半——HBase 的建表不是事务性的多列族建表过程中失败可能留下一个结构不完整的表。这件事让我后来养成一个习惯表操作要么全在 shell 里一次写完要么全走程序绝不在中间状态上反复试探。1.1 shell、Admin API 和客户端封装各自管什么HBase 提供了三层操作入口很多人混着用出了问题说不清是哪一层的问题。第一层是hbase shell底层是 JRuby 封装的 Admin API。它的优点是所见即所得适合排查和一次性操作缺点是没有幂等保护也没有事务脚本里循环执行容易出岔子。而且 shell 里执行长耗时的 alter一旦终端断开命令还在跑但你看不到结果很容易误判。第二层是 Java 的Admin接口这是所有表操作的真正入口。Connection.getAdmin()拿到实例后建表走createTable改表走modifyTable删表走deleteTable。它的关键特性是大部分方法是异步的——modifyTable返回成功只代表 master 接受了这个请求不代表所有 region 都已经应用了新结构。第三层是各种封装比如某些数据同步工具、ORM 层、内部平台。这些封装通常会帮你做重试和状态轮询但也可能把异常吞掉。我遇到过某个封装在createTable失败后自动重试结果因为表其实已经建好了重试全部失败最后抛出的是一个和真实原因完全无关的异常。判断该用哪一层很简单一次性人工操作走 shell需要进 CI/CD 或定时任务的走 Admin API任何涉及失败后要能重跑的场景都必须自己写幂等判断不要指望封装。Admin里其实提供了批量版本createTables、disableTables、deleteTables接受列表参数比循环单条调用效率高得多因为它能减少 RPC 往返。批量删除表这个能力在清理测试环境时特别好用一次请求把几十张表全处理掉比写 for 循环快一个数量级。1.2 表操作真正会踩坑的三类场景我把这些年遇到的表操作问题归了归类基本逃不出三种。第一种是状态机理解错。HBase 的表有明确的状态ENABLED、DISABLED、ENABLING、DISABLING还有 region 级别的OPEN、CLOSING、CLOSED、SPLITTING、MERGING。drop要求表处于DISABLEDtruncate内部会先 disable 再重建alter在某些情况下会让表短暂进入过渡态。不清楚这一点的人会反复执行命令越执行越乱。第二种是元数据与物理数据不同步。最典型的是修改列族的压缩算法——alter成功了describe也显示新算法但历史 HFile 还是用旧算法压的。读的时候没问题因为 HBase 能识别文件头但你去看 HDFS 上的存储占用会发现一点没降。真正生效要等一次 major compaction 把这些文件重写。第三种是多租户场景下的误伤。集群是共享的你 disable 一张表看起来只影响自己但如果这张表和别人的表在同一个 region server 上disable 过程中的 region 迁移会带来短时的负载抖动。alter 一个几千 region 的大表触发的 RPC 和 meta 更新量足以让整个集群的请求延迟出现毛刺。2. create 语句背后的每一个参数都在决定未来的读写性能建表是唯一一个错了还能改但代价很大的操作。列族数量、预分区、压缩算法这三样建表时定错了后面想改基本都是重建表加导数据。所以我把建表单独拿出来讲透。2.1 列族是唯一必须显式声明的物理单元HBase 里列族不是逻辑概念它是物理存储单元——每个列族对应一组独立的 HFile 和独立的 MemStore。这意味着两件事列族越多MemStore 占用越大flush 越频繁列族越多一次 Get 要打开的文件句柄越多。我在一个模拟项目里见过有人按业务模块建了 8 个列族理由是方便管理。结果这个表写入压力一上来MemStore 被 8 个列族分摊每个列族的数据量都不够触发高效 flushRegionServer 的堆内存里全是碎片化的小 MemStoreGC 压力陡增。后来重建成 2 个列族写入吞吐直接翻了差不多一倍。经验值列族数量控制在 1 到 3 个超过 3 个就要问自己这些数据是不是真的需要放在同一张表里。判断标准是访问模式——经常一起读的数据放一个列族从不一起读的数据分开放。如果两组数据完全不相关那它们应该是两张表。建表的最小语句长这样create order_detail, {NAME info, VERSIONS 3}, {NAME ext, VERSIONS 1, TTL 7776000}注意NAME在 HBase 2.x 里必须大写1.x 时代name小写也能跑但 2.x 严格了。这个细节让不少从旧版本迁过来的脚本直接挂掉。2.2 预分区建表时不做后面补的代价不预分区的表初始只有 1 个 region所有写入全压在一个 RegionServer 上。等 region 大到触发 split默认hbase.hregion.max.filesize是 10GB才会分裂成两个。这个过程中写入热点问题会持续很久而如果你的 rowkey 是单调递增的比如时间戳前缀split 之后热点还是只在最后一个 region 上永远分不开。预分区的数量怎么定我给一个实际算过的思路。假设业务每天写入 5000 万条平均每条 200 字节一天约 10GB数据保留 90 天总容量约 900GB。单 region 大小按 10GB 到 20GB 规划比较舒服那么需要 45 到 90 个 region。取整到便于 hash 分布的数量比如 64 或者 128。具体到命令上有两种写法。一种是直接给分割点create order_detail, {NAME info}, SPLITS [1,2,3,4,5,6,7,8,9]另一种是给一个分割点文件适合分割点很多的情况create order_detail, {NAME info}, SPLITS_FILE /tmp/splits.txt如果 rowkey 是十六进制散列很常见的做法比如 MD5 前几位用RegionSplitter工具更省事hbase org.apache.hadoop.hbase.util.RegionSplitter order_detail HexStringSplit -c 64 -f infoHexStringSplit会在00000000到ffffffff之间均匀切 64 份配合对 rowkey 做 hash写入能均匀落到所有 region 上。另一个可选算法是UniformSplit它按字节均匀切分适合 rowkey 是随机字节序列的场景。预分区最怕的是分了很多区但 rowkey 分布不均。我见过一张表预分了 100 个 region结果 rowkey 是自增 ID 直接转字符串所有数据还是往第一个 region 挤剩下 99 个空着。分区解决的是容量切分解决热点还得靠 rowkey 设计这两件事不能互相替代。预分区还有个隐藏问题分多了也不好。每个 region 都有独立的 MemStore 和打开的文件句柄region 数量上千之后RegionServer 的内存开销和 master 的 assignment 时间都会明显上升。经验上单台 RegionServer 承载 100 到 200 个 region 比较健康超过 500 个就要考虑拆集群了。2.3 压缩、TTL、版本数这些建表时顺手填的字段建表时不填这些参数HBase 会给默认值而默认值往往不是你想要的。下面这张表是我自己整理的高频列族属性对照。属性默认值建议值说明VERSIONS11 到 3只保留最近版本超出的在 compaction 时清理TTLFOREVER按业务定单位秒过期数据读不到但物理删除要等 compactionCOMPRESSIONNONESNAPPY 或 ZSTD省磁盘也省 IOCPU 开销可接受BLOCKCACHEtrue读多写少保持 true关闭后读性能下降明显BLOCKSIZE65536大扫描调大点查保持默认影响单次读取的数据量BLOOMFILTERROWROW 或 ROWCOL显著减少无效 HFile 扫描DATA_BLOCK_ENCODINGNONEPREFIX 或 FAST_DIFF对前缀相似的 rowkey 压缩效果好IN_MEMORYfalse小维表可设 true优先驻留内存慎用KEEP_DELETED_CELLSfalse一般保持 false设 true 会让删除的数据长期占用空间关于压缩算法SNAPPY是通用选择压缩比大约 2 到 3 倍CPU 开销低ZSTD压缩比更高能到 3 到 5 倍但需要 Hadoop 原生库支持而且 CPU 开销略高。写多读少的场景我倾向 SNAPPY归档类冷数据用 ZSTD 更划算。TTL 有一个特别容易误解的地方必须说清楚TTL 过期后数据在逻辑上不可见但 HFile 里的字节还在占用 HDFS 空间。真正释放空间要等 major compaction。所以如果你把 TTL 从 90 天改成 7 天别指望第二天磁盘占用就降下来得主动触发一次大合并major_compact order_detail但这个命令在大表上很重会集中消耗大量 IO 和网络带宽。我的做法是避开业务高峰或者在低峰期分批按 region 触发而不是整表一把梭。3. alter 不是免费的表结构变更的在线风险与安全做法HBase 2.x 相比 1.x 最大的体验改善之一是大部分 alter 操作不需要先 disable 表了。1.x 时代改个列族属性得先停写2.x 引入了在线修改能力modifyTable会走一遍 region 的重新 open但表整体保持可用。听着很美好实际用起来还是有一堆细节。3.1 alter 改了哪些东西会触发 region 重建不是所有 alter 都一样重。我把常见变更按代价从低到高排了一下。改 TTL、改 VERSIONS、改 BLOOMFILTER 这类纯元数据属性master 更新表描述符后通知各个 RegionServer 刷新缓存几乎瞬间完成对读写没有可感知影响。但注意这些改动对已经写入的 HFile 不生效要等 compaction 重写后才真正体现。改 COMPRESSION 属于中等代价。元数据立刻变新写入的 HFile 用新算法老文件保持原样。表会变成混合压缩状态直到下一次 major compaction。加列族代价偏高。新增列族需要在每个 region 上创建对应的 store 目录和 MemStore如果表有几百个 region这个过程会持续几分钟期间 master 的 assignment 队列会有明显积压。改列族名代价最高而且HBase 根本不支持直接改列族名。alter里写NAME newName会被当成新增一个列族处理旧列族还在。正确做法是新建列族、把数据迁过去、再删旧列族或者直接用快照克隆一张新表。# 错误理解以为这是改名实际是新增列族 alter order_detail, {NAME info_new, VERSIONS 3}表级属性里还有几个值得单独提的。MAX_FILESIZE控制单 region 上限改小会让 region 更快触发 splitREADONLY设为 true 后表变成只读适合在迁移前冻结写入MEMSTORE_FLUSHSIZE影响 flush 频率调大能减少 flush 次数但增加恢复时间。3.2 加列族与删列族的时序问题加列族相对安全但有个细节新列族的属性是独立的一份不会继承原列族的配置。很多人加完列族发现 TTL 是默认的 FOREVER压缩是 NONE因为它压根没配。所以加列族时属性一定要写全。alter order_detail, {NAME ext2, VERSIONS 1, TTL 2592000, COMPRESSION SNAPPY, BLOOMFILTER ROW}删列族就麻烦多了。命令本身简单alter order_detail, delete ext2执行后元数据里这个列族立刻消失客户端再访问这个列族会报错但 HDFS 上的数据目录不会马上删。master 会启动一个清理任务把对应的 store 文件标记为 archiveHDFS 的回收站策略决定了它们多久之后真正消失。如果你的 HDFS 回收站间隔设的是 7 天那这 7 天里磁盘占用是不降的。还有一条硬性规则一张表至少要保留一个列族。删最后一个列族会抛InvalidFamilyOperationException。这个错误信息写得比较直白但很多人不看以为是权限问题。删列族之前一定要确认两件事客户端代码里没有残留的读写这个列族的逻辑下游如果有依赖这个列族的导出任务得先停掉。我吃过一次亏删了一个看起来没人用的列族结果一个跑了半年的定时任务第二天凌晨挂了报错信息在一堆堆栈里排查了半天才定位到。3.3 变更前的检查清单与回退手段每次要做结构变更我现在都会过一遍这个清单。先确认表的状态和规模exists order_detail is_enabled order_detail describe order_detail list_regions order_detaillist_regions的输出能直接告诉你 region 数量和分布几百个 region 的表和几十个 region 的表alter 的耗时完全不是一个量级。然后打快照。这是我个人认为最值钱的一个习惯快照是元数据级别的操作秒级完成几乎不占额外空间它只记录 HFile 的引用但出问题的时候能救命。snapshot order_detail, order_detail_before_alter_20240115快照恢复需要先 disable 表命令是restore_snapshot。要注意恢复是覆盖式的表在快照之后新写入的数据会丢。所以快照只能作为结构变更的回退手段不能替代备份。变更执行后别急着走观察一段时间。重点看三件事describe确认元数据符合预期业务侧的读写监控有没有异常毛刺master 日志里有没有 region 的OPEN失败重试。如果表规模大可以过几分钟再看一眼list_regions确认所有 region 都是 OPEN 状态。4. disable、truncate、drop表生命周期里最容易被误用的三个命令这三个命令是表操作的破坏性动作区误用的后果比 alter 严重得多因为 alter 错了还能改回来这几个错了数据就没了。4.1 为什么 drop 之前必须先 disable直接drop order_detail会抛TableNotDisabledException。这个设计不是为了烦人而是有实际意义的drop 操作要删除元数据和所有 region 的 HDFS 目录如果表还在对外服务正在进行的读写会全部失败而且可能留下半删除的状态。disable order_detail is_disabled order_detail drop order_detaildisable本身也不是瞬时的。它会把表的所有 region 从 OPEN 状态迁移到 CLOSED这个过程需要等所有 RegionServer 确认。对于几百个 region 的大表可能要几十秒甚至几分钟。如果你在disable返回后立刻drop通常没问题因为 shell 会阻塞等待但如果你在脚本里用异步 Admin API就得自己轮询状态直到DISABLED。Admin admin connection.getAdmin(); TableName tn TableName.valueOf(order_detail); admin.disableTable(tn); // 必须轮询到 DISABLED 才能删 while (!admin.isTableDisabled(tn)) { Thread.sleep(500); } admin.deleteTable(tn);另一件要提醒的事disable期间客户端对这个表的所有请求都会失败抛的是TableNotEnabledException或者NotServingRegionException取决于请求打到哪个环节。所以生产环境做这个操作要么提前通知下游要么选在业务低峰。4.2 truncate 的数据保留边界与快照兜底truncate是清空数据但保留表结构它的实现是 disable 表、删除所有 region、再按原表描述符重建。这里有个极其重要的参数差异很多人不知道truncate order_detail # 默认不保留预分区重建后可能只有一个 region truncate order_detail, true # 保留原分区边界不带第二个参数时重建出来的表会按默认的 split policy 生成 region对于原来是 128 个预分区的表这等于把辛苦设计的分布全废了重建后写入立刻回到单 region 热点。我第一次踩这个坑的时候truncate 完发现写入延迟涨了十倍查了半天才想起来是这个参数。所以我的规则是只要这张表建表时做过预分区truncate 一律带 true。truncate之前要不要打快照取决于你能不能承受数据丢失。如果是测试环境随便清如果是有真实数据的环境我建议先打一个尤其是清空后要重新灌数的场景万一灌数脚本有问题还能回到清空前。snapshot order_detail, order_detail_before_truncate truncate order_detail, true # 如果出问题 disable order_detail restore_snapshot order_detail_before_truncate快照的管理也要有规矩。list_snapshots能看到所有快照delete_snapshot删掉不要的。我见过一个集群上累计了两百多个快照没人清理虽然不是大问题但 master 启动时要加载这些元数据启动时间明显被拖长了。4.3 批量清理表的脚本化做法清理测试环境时经常要按前缀删一批表。shell 里手动敲不现实我一般这样处理。先用list过滤出目标表确认无误再动手。这一步绝不能省我见过有人用错了正则把生产前缀的表列进去了。hbase shell EOF list test_.* EOF确认之后再执行删除。shell 脚本里可以这样批量处理for t in $(echo list test_.* | hbase shell 2/dev/null | grep -oP ^test_\S); do echo drop $t done /tmp/to_drop.txt把待删列表落成文件人工再看一遍然后执行cat /tmp/to_drop.txt | hbase shell也可以用 Admin API 的批量删除一次 RPC 处理完效率更高。但要记住批量操作里如果有一张表 disable 失败整个批次的行为取决于具体版本实现保守做法还是先逐个 disable 再批量 delete。删除表这件事上我唯一的建议是脚本里永远保留一个--dry-run开关先打印要删什么人工确认后再真删。省下的几十秒抵不上误删一次的成本。5. 表操作出问题时的排查链路命令敲下去报错是好事最怕的是命令返回成功但状态不对。这一节我按自己的排查顺序写从最表层往里走。5.1 从 master 日志和 shell 报错定位第一步永远是读报错。HBase 的异常信息其实写得挺直白关键是别被堆栈淹没。我把高频的几个列出来。异常触发场景处理方向TableExistsException建表时表已存在先 exists 确认再决定复用还是换名TableNotFoundException表名拼错或已被删检查 namespace 前缀有没有漏TableNotEnabledException表处于 disabled 状态is_enabled 确认需要时 enableTableNotDisabledExceptiondrop 前没 disable先 disable 再 dropInvalidFamilyOperationException删最后一个列族或列族名不合法检查列族列表和命名DoNotRetryIOException通常是元数据不一致或 master 未就绪看 master 日志必要时跑 hbck如果 shell 层面看不出所以然就去翻 master 日志。master 的日志里会记录每次createTable、modifyTable、deleteTable的完整过程包括 procedure 的 ID 和执行阶段。HBase 2.x 引入了 Procedure 框架所有表操作都是一个 procedure日志里能看到它走到哪一步失败。grep -n procedure /var/log/hbase/hbase-master-*.log | grep -i order_detail5.2 region 状态异常与表不可用的区分有时候表is_enabled返回 true但客户端就是读不到数据报NotServingRegionException。这说明表级状态正常但 region 级别的 assignment 出了问题。先看 region 分布list_regions order_detail如果某个 region 显示OFFLINE或者没有 location说明它没有被分配到任何 RegionServer。原因可能是 RegionServer 挂过、网络分区过、或者上一次 split 没完成。这种情况下可以手动 assignassign a1b2c3d4e5f6... # 参数是 encoded region name如果表整体状态乱得看不下去用hbase hbck做一次体检。HBase 2.x 推荐用 hbck2hbase hbck -j hbase-hbck2.jar details order_detail它会列出不一致的地方比如 region 在 meta 里存在但 HDFS 目录缺失或者反过来的孤儿 region。修不修、怎么修要看具体输出有些能自动修有些得人工判断。还有一种情况容易被误判成表操作问题客户端拿到的连接缓存了旧的 region 位置。表做过 split、merge 或者 region 迁移之后客户端如果没刷新缓存会往旧位置发请求。表现是间歇性失败但服务端一切正常。排查方法是看客户端日志里失败的 region 名和服务端list_regions的结果对比如果对不上就是缓存问题。Admin提供了clearRegionCache之类的接口具体方法名各版本略有差异思路是主动清掉客户端缓存。5.3 常用检查命令速查表我在运维手册里固化了一张表表操作前后各看一遍能挡掉大部分问题。目的命令确认表是否存在exists t1查看表的启用状态is_enabled t1/is_disabled t1查看表结构describe t1查看 region 分布list_regions t1列出所有表list/list prefix.*查看集群状态status detailed查看快照list_snapshots t1.*手动合并 regionmerge_region encoded1,encoded2手动切分 regionsplit encodedRegionName, splitKey触发大合并major_compact t1查看表的写入缓冲flush t1这张表里status detailed是我用得最频繁的它能看到 RegionServer 数量、region 总数、平均负载表操作之前扫一眼能判断当前集群是不是在健康状态下避免在本来就抖的时候再加一把火。管理 namespace 也是表操作的一部分脚本里如果涉及多命名空间建表时表名要带前缀create_namespace biz_order create biz_order:order_detail, {NAME info}不带 namespace 的表会落在default下。从 1.x 迁到 2.x 的集群里经常能看到混用的情况有些表带前缀有些不带list的时候容易看漏。我一般会在脚本里显式写全名宁可啰嗦一点。另外提一句hbase:meta这张系统表它是所有表操作的地基任何情况下都不要用alter或disable去碰它。我见过有人为了清理一下去扫 meta 表结果直接把集群搞挂恢复过程极其麻烦。系统表只能通过 hbck2 这类工具在明确知道后果的前提下操作。最后分享一个我自己的习惯。每次要对生产表做结构性操作之前我会先在测试集群上完整走一遍包括建表、alter、disable、enable 全流程记录每一步的耗时和输出。这个动作看起来麻烦但它能暴露出来很多文档里不会写的问题比如某张表因为有大量 region 导致 disable 耗时超过预期比如某个 alter 在特定版本上会触发额外的 compaction。真正到生产执行的时候你心里是有数的而不是靠祈祷。比这更重要的是心态上的转变——把表操作当成一次变更管理而不是一次命令执行。命令只有一行变更涉及的是元数据、region 状态、客户端缓存、下游依赖这一整条链路。想清楚这条链路上每一环会发生什么比记住语法有用得多。