一、数据不一致问题到底是怎么来的

HBase作为分布式数据库,设计上遵循的是最终一致性模型,意味着在正常运行下,所有副本最终会达成一致。但生产环境中总有一些意外让“最终”变得遥遥无期。我遇到过最典型的一种情况:某个RegionServer因为FullGC卡了三十秒,导致ZooKeeper认为它挂了,触发Master重新分配Region。但GC结束后的老旧RegionServer其实还活着,它继续接收客户端请求,同时新分配出来的Region也开始工作。两份数据同时写入不同的Region,冲突就产生了。另一个常见原因是HDFS的副本损坏:HBase的底层数据存在HDFS上,假如某个StoreFile的三个副本中有两个数据不一致,读请求可能拿到错误结果。还有客户端层面的Scanner缓存也会导致数据不一致——代码里设置了一个大的缓存行数,同一个Scanner可能会读到过期版本。

这些问题的本质是写流程读流程出现了时序错乱。正常HBase写入会先写WAL(预写日志),再写MemStore。WAL是顺序写入,保证宕机后能回放。但是当Region移动或分裂时,WAL的归属关系会变复杂。读到不一致的数据,通常不是系统崩了,而是元数据实际数据之间的映射乱了。

二、从一次真实的“读不到新数据”故障说起

想象你在做一个用户行为实时统计系统,数据写入HBase后,下游程序每隔几十秒读一次最新结果。某天你发现,有些用户的信息始终是旧版本,就算刷新页面也不更新。第一反应是写入失败了?检查写入日志,没问题,每次都返回了成功状态码。读出时却得不到最新数据。

用HBase Shell跑一个简单的get命令看看差异:

# 在HBase Shell中进行一次简单的读取测试
get 'behavior', 'user_1001', {COLUMN => 'cf:score'}
# 返回value=100

# 然后手动scan一下整个表
scan 'behavior', {STARTROW => 'user_1001', ENDROW => 'user_1001', VERSIONS => 3}
# 结果中看到version1:100, version2:200, version3:180
# 最新版本是180,但get只返回了100

这就奇怪了:明明有三个版本,最新的数据是180,为什么get只拿到100?仔细一看,原来get默认只返回一个版本且是最近的一个,但这里的100是最新的吗?不,时间戳上180比100更大。那么问题出在Region已经分裂了——旧的Region里保留着老版本数据,新数据写到了新Region,而读请求依然路由到了旧Region。这是因为客户端对Region分裂的感知有延迟,或者Meta表没有及时更新。

三、排查步骤,像侦探一样找出真相

3.1 用代码确认数据分布

写一个Java小程序,遍历表的每个Region,打印每个Region的数据行数和最新版本。这样可以快速定位哪个Region落下了。

// 技术栈:Java (HBase 2.x 客户端API)
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.*;
import org.apache.hadoop.hbase.client.*;
import org.apache.hadoop.hbase.util.Bytes;
import java.io.IOException;

public class RegionDataCheck {
    public static void main(String[] args) throws IOException {
        Configuration conf = HBaseConfiguration.create();
        // 假设客户端配置已经读取了hbase-site.xml
        Connection conn = ConnectionFactory.createConnection(conf);
        TableName tableName = TableName.valueOf("behavior");
        Admin admin = conn.getAdmin();

        // 获取表的所有Region位置信息
        RegionLocator locator = conn.getRegionLocator(tableName);
        for (HRegionLocation location : locator.getAllRegionLocations()) {
            byte[] startKey = location.getRegionInfo().getStartKey();
            byte[] endKey = location.getRegionInfo().getEndKey();
            String host = location.getHostname();

            // 获取该Region对应的HBase Region对象(通过构造扫描)
            Scan scan = new Scan();
            scan.setStartRow(startKey);
            if (endKey.length > 0) {
                scan.setStopRow(endKey);
            }
            // 限制只读一行,快速拿到数据
            scan.setLimit(1);
            scan.setMaxVersions(3); // 想看到多个版本

            Table table = conn.getTable(tableName);
            ResultScanner scanner = table.getScanner(scan);
            int rowCount = 0;
            long latestTs = 0;
            for (Result result : scanner) {
                rowCount++;
                for (Cell cell : result.rawCells()) {
                    long ts = cell.getTimestamp();
                    if (ts > latestTs) {
                        latestTs = ts;
                    }
                }
                // 只取第一行就够了,不浪费
                break;
            }
            scanner.close();
            table.close();
            System.out.println("Region on " + host + " startKey=" + Bytes.toString(startKey)
                + " endKey=" + Bytes.toString(endKey) + " 行数(估计)≈" + rowCount + " 最新时间戳=" + latestTs);
        }
        admin.close();
        conn.close();
    }
}

运行这段代码,你就会发现某个Region的最新时间戳远远小于其他Region,说明它没有接收到新写入的数据。那很可能就是这个Region的元数据没有被正确指向。

3.2 检查RegionServer是否存有僵尸体

用hbase hbck工具可以找出“孤儿Region”或“重复分配”的Region。

# 在HBase集群任意节点执行
hbase hbck -details 2>&1 | grep -E "inconsistencies|orphan|MULTI_REGION"

如果输出类似“MULTI_REGION assignment”或者“ORPHAN”,就说明有Region同时被两个RegionServer管理。这时候就需要强制修复了。

3.3 查看WAL的完整性

如果怀疑写入过程中WAL没有完整回放,可以用WAL工具查看特定时间的日志。

# 查看指定WAL文件的内容
hbase org.apache.hadoop.hbase.wal.WALPrettyPrinter /hbase/WALs/RegionServer-1/xxxxxx.wal

重点看是否有“FLUSH”或“REGION_CLOSE”事件。如果WAL里记录的数据比Region实际存储的多,说明Region的MemStore没来得及刷盘就挂了,导致部分WAL事件被回放到错误位置。

3.4 检验客户端缓存

许多客户端框架(比如Spring Data HBase)默认启用了Scan的缓存。如果代码里设置了scan.setCaching(1000),并且同一个ResultScanner没有被及时关闭,就可能读到过期数据。排查方法是:在数据写入后立即重新打开一个连接去读,而非复用同一个Scanner。

四、动手修复,回到一致状态

修复方式取决于具体错误类型。

4.1 Region分配混乱 — 重新分配

使用hbase hbck的修复命令:

# 先不加-fix预览问题
hbase hbck -details
# 确认后执行修复
hbase hbck -fixAssignments -fixMeta

如果问题严重,可以重启Master并让HBase自动重建元数据。

4.2 老Region没有关闭导致两写 — 手动下线僵尸RegionServer

首先找到僵尸RegionServer的进程ID,杀掉它:

# 找到占用Region端口的进程(通常端口是16020或16030)
netstat -tlnp | grep 16020
# 强制终止(注意先尝试优雅停止)
kill -9 <PID>

然后通过HBase Master Web UI确认该RegionServer不再显示。最后再次运行hbase hbck -fix把所有Region重新分配。

4.3 WAL回放后数据仍缺失 — 重建数据

如果WAL的某些事件丢失(比如HDFS副本损坏导致文件不可读),只能从业务上游重新推送数据。这是一个防御性方案:在写入HBase的同时把数据写入一个消息队列(比如Kafka)作为备份,出问题时回放。

也可以用Java写一个补数据作业:

// 技术栈:Java (HBase客户端 + Kafka消费)
// 假设我们已经从Kafka拿到备份数据
import org.apache.hadoop.hbase.client.*;
import org.apache.hadoop.hbase.util.Bytes;

public class DataRepair {
    public static void repair(Connection conn) throws IOException {
        Table table = conn.getTable(TableName.valueOf("behavior"));
        // 拿到Kafka中的一条原始消息
        // 假设消息格式: user_1001,cf:score,180
        String rowKey = "user_1001";
        String family = "cf";
        String qualifier = "score";
        String value = "180";

        Put put = new Put(Bytes.toBytes(rowKey));
        put.addColumn(Bytes.toBytes(family), Bytes.toBytes(qualifier), Bytes.toBytes(value));
        // 设置一个非常老的版本,避免覆盖新数据?
        // 实际上需要手动选择版本,这里就忽略版本
        table.put(put);
        table.close();
        System.out.println("已修复 rowKey=" + rowKey);
    }
}

注意:补数据时最好用put.addColumn(..., timestamp)指定一个确定的时间戳,避免与现有数据冲突。

五、真实场景里哪种情况下最容易出现不一致

  • 高并发写入 + 频繁Region Split:当写入压力大时,Region自动分裂,但旧Region的元数据更新有延迟,新Region还没注册到Meta,导致写入一直往旧Region跑。
  • RegionServer频繁重启:比如因OOM重启,重启后WAL回放不完整,或者回放时遇到手动脱敏操作导致部分事件跳过。
  • HDFS磁盘故障:特别是使用普通磁盘的企业级集群,坏道导致数据块损坏,读请求可能拿到Checksum错误,返回空或旧副本的数据。
  • 客户端使用长连接且设置了大的Scanner缓存:一个ResultScanner没关闭,后面再执行扫描可能获取的是缓存中的旧快照。

六、技术优缺点和必须注意的坑

优点:HBase天然支持横向扩展,写入性能高(百万行/秒级),对于超大规模数据集(PB级)仍然能保持较低延迟。

缺点:运维复杂度高,一致性保障靠运维经验,而不是系统设计。一旦出现不一致,排查链路长,要从ZooKeeper、HDFS、RegionServer、客户端四方同时查。修复过程可能导致服务中断。

注意事项

  • 不要盲目依赖hbase hbck -fix,它会根据当前状态乱修,可能把正确的也搞乱。生产环境使用前务必加-details评估。
  • 设置合理的hbase.client.scanner.caching值,一般500以下,避免长时间占用服务端资源。
  • 监控WAL日志大小和RegionServer的GC时间,如果GC超过30秒,考虑增大RegionServer堆内存或缩短刷盘间隔。
  • 对重要表开启hbase.regionserver.storefile.refresh.period为0(关闭),避免缓存导致读旧数据。
  • 升级HBase版本时,一定要读变更日志,因为很多不一致bug在后续版本修复了。

七、总结:不要等到出问题才想起来

数据的最终一致性是HBase的设计代价,不是Bug。但生产环境下的不一致大多数是可以预防的:定期运行hbase hbck做检查,监控RegionServer的WAL延迟和HDFS健康度,合理设计RowKey避免热点,以及准备好从外部数据源恢复的兜底方案。当不一致真的发生时,按照“确认现象→定位所属Region→检查RegionServer和WAL→修复元数据→补充数据”的顺序走,不要慌,每一步都有对应工具帮忙。最后,我自己的经验是:每周在测试环境模拟一次RegionServer宕机,验证修复流程,真到线上出事就能从容应对。