HBase里最让人头疼的,不是数据不够大,而是数据一多,某些机器累成狗,某些机器闲得长草。这就是大家常说的热点问题。很多人知道要改行键,但一上手就懵:到底怎么改?改了之后还能不能按原来自增Id查?今天咱们就用大白话把散列和盐值这两个招数掰开揉碎了讲清楚,再给你一套能直接抄作业的代码。

一、热点是怎么来的

咱们先回到HBase最底层的机制。HBase把一张表按照行键的字典序切成很多个Region,每个Region负责一段连续的行键区间。比如行键“a”到“m”在Region1,“n”到“z”在Region2。当你往表里写新数据时,RegionServer会根据行键把数据放到对应的Region里。

现在问题来了:如果你的行键是自增ID,比如订单号从1涨到10000,那么这些键正好落在最后那个Region的区间里。你每秒写几百条,每一条都涌向同一个RegionServer,CPU、内存、磁盘I/O瞬间飙高,而其他RegionServer却在旁边喝咖啡。这就是最典型的顺序写热点。读也一样,如果你要查最新订单,同样会扎堆去那个Region。

有人问:HBase不是会自动分裂吗?对,Region可以分裂,但分裂也需要时间。而且分裂之后,新数据还是往最后一个Region写,因为新键一直变大。所以自动分裂治标不治本。

二、散列和盐值到底是个啥

别被这两个词吓到,把它们想象成厨房里的处理手法。

“盐值”就是你炒菜的时候撒的那把盐。原始行键比如用户ID是12345,你随手在这个密钥前面加上一个两位数的盐,变成“07-12345”。同样一个用户ID,你每次撒的盐不同,它在表里就能分散到不同Region。简单说,就是让原本扎堆的键各回各家,别挤在一起。

“散列”更像是绞肉机。你把原始键丢进去,出来一堆看起来毫无规律的碎肉。比如对“12345”做一个哈希运算,得到“9f2c”,然后把这个哈希结果拼到原始键前面,变成“9f2c-12345”。这样相邻的原始键(12345和12346)得到的哈希前缀可能完全不同,自然就散开了。

不过,盐值和散列都不是免费的。它们最大的代价是:原来你可以在一个Region里痛快地扫一段数据,比如查某段时间的所有订单,现在数据被打散了,你必须好几台机器挨个找,或者把加盐/散列结果存下来才能定位。所以具体怎么用,得看你的业务。

三、实战:给订单表设计一个扛得住的键

这一节咱们用Java写一个完整的行键生成器。技术栈:Java 8 + HBase 2.x客户端。

3.1 简单加盐:用取模给原始键编号

首先演示最朴素的加盐方式。假设原始键是自增的订单号 orderId,我们取一个盐值桶数量 SALT_BUCKETS,通过 orderId % SALT_BUCKETS 得到商,然后把这个商作为前缀拼上去。注意,为了让前缀定长,要用String.format补零。

import java.text.DecimalFormat;

/**
 * 简单加盐工具,用取模方式打散自增订单号
 */
public class SaltUtil {

    // 盐桶数量,一般取RegionServer数量的2~3倍
    private static final int SALT_BUCKETS = 10;

    // 定长格式化,保证字典序里前缀排序一致
    private static final DecimalFormat BUCKET_FMT = new DecimalFormat("00");

    /**
     * 生成加盐后的行键
     * @param orderId 原始自增订单号,例如 900001
     * @return 形如 "03-900001" 的键
     */
    public static String makeSaltKey(long orderId) {
        // 计算桶编号:0 ~ 9
        int bucket = (int) (orderId % SALT_BUCKETS);
        // 拼接前缀和原始键,中间用横线分隔方便阅读
        return BUCKET_FMT.format(bucket) + "-" + orderId;
    }

    public static void main(String[] args) {
        long[] ids = {900001L, 900002L, 900003L, 9000011L};
        for (long id : ids) {
            System.out.println(makeSaltKey(id));
        }
    }
}

输出长这样:

01-900001
02-900002
03-900003
01-9000011

你看,900001和9000011同余,都落在“01”,但900002和900003已经分散开了。这个方法的优点是简单,而且你能通过原始键快速算出来它在哪个桶。缺点是需要提前知道自增ID的规律,如果订单号不是均匀自增,比如中间有跳跃,那取模结果可能倾斜。

3.2 真正散列:用哈希把原始键抹匀

要想让任何原始键都均匀散开,可以用哈希。这里用MurmurHash,比MD5更快,而且均匀性好。为了演示方便,我们用JDK自带的CRC32。

import java.nio.charset.StandardCharsets;
import java.util.zip.CRC32;

/**
 * 散列前缀工具:对原始键做哈希,取前几个字符作为前缀
 */
public class HashUtil {

    // 想要的前缀长度,每增加一个字符,可能的分区数量乘以16
    private static final int PREFIX_LEN = 2;

    /**
     * 使用CRC32得到8位十六进制,截取前2位
     * @param rawKey 原始行键,比如"12345_20250101"
     * @return 形如"a3-12345_20250101"
     */
    public static String makeHashKey(String rawKey) {
        CRC32 crc32 = new CRC32();
        crc32.update(rawKey.getBytes(StandardCharsets.UTF_8));
        // 转成十六进制字符串,比如 "a3b2c1d0"
        String hex = Long.toHexString(crc32.getValue());
        // 注意:如果不足8位,前面补0,保证长度稳定
        while (hex.length() < 8) {
            hex = "0" + hex;
        }
        // 取前两位作为前缀,拼上原始键
        return hex.substring(0, PREFIX_LEN) + "-" + rawKey;
    }

    public static void main(String[] args) {
        String[] keys = {"12345_20250101", "12346_20250101", "12345_20250102"};
        for (String key : keys) {
            System.out.println(makeHashKey(key));
        }
    }
}

运行结果可能类似:

b1-12345_20250101
7e-12346_20250101
f3-12345_20250102

你会发现,不但不同用户的键散开了,连同一个用户在不同日期的键也散开了。这带来一个副作用:你想查“用户12345的全部订单”,原来是一个连续区间,现在被拆到很多Region里,查询时得用过滤器扫描,或者额外维护一个“用户ID -> 所有散列前缀”的索引。

3.3 结合业务:订单表完整设计示例

下面结合一个真实的订单表,设计一个同时支持“按订单号查询”和“按用户查询”的场景。技术栈依然是Java。

假设每条订单有订单号(自增)、用户ID、创建时间。我们希望写入时均匀,读取时要能快速按订单号精确查、按用户ID+时间范围查。

思路是这样:将用户ID的后几位作为盐前缀,拼上创建时间反转,再拼原始订单号。这样同一个用户的数据会落到同一个桶里(因为盐前缀固定),而不同用户会散开。时间反转是为了让同一用户的新订单排在旧订单前面,方便顺序读。

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * 订单行键生成器 - 结合盐值、时间反转、原始键
 */
public class OrderKeyGenerator {

    // 时间格式:精确到秒
    private static final DateTimeFormatter TIME_FMT =
            DateTimeFormatter.ofPattern("yyyyMMddHHmmss");

    // 盐前缀长度:这里用用户ID的后4位,范围0~9999
    private static final int SALT_LEN = 4;

    /**
     * 生成写入HBase用的行键
     *
     * @param orderId  订单号,例如 100023
     * @param userId   用户ID,例如 20001
     * @param createTime 创建时间
     * @return 例如 "0001-20250102093000-100023"
     */
    public static String buildKey(long orderId, long userId,
                                  LocalDateTime createTime) {
        // 1. 提取用户ID后4位作为盐前缀,不足4位左侧补0
        String salt = String.format("%04d", userId % 10000);

        // 2. 把时间格式化成字符串,反转后用于倒序排列
        String rawTime = createTime.format(TIME_FMT);
        String reversedTime = new StringBuilder(rawTime).reverse().toString();

        // 3. 拼接盐前缀 + 反转时间 + 订单号
        return salt + "-" + reversedTime + "-" + orderId;
    }

    public static void main(String[] args) {
        LocalDateTime now = LocalDateTime.of(2025, 1, 2, 9, 30, 0);
        System.out.println(buildKey(100023L, 20001L, now));
        System.out.println(buildKey(100024L, 20002L, now));
        System.out.println(buildKey(100025L, 20003L, now));
    }
}

输出:

0001-0000030905202102-100023
0002-0000030905202102-100024
0003-0000030905202102-100025

这里有个关键点:盐前缀用了“userId % 10000”,尺寸固定,region可以预分区成10000个?不现实。实际生产中,你只需要预分区几十个或几百个,然后让盐前缀再散列一下到这些分区里。你可以用哈希或者取模,这里不展开,后面注意事项会讲。

如果你要按订单号精确查,不知道userId,就会比较麻烦。你可以再建一张映射表,或者把订单号哈希作为另一个行键写入同一张表。这就引申出“全局二级索引”的问题,HBase本身没有,得配合Phoenix或者自己维护。

3.4 关联技术:预分区

散列和盐值设计必须搭配预分区才能发挥效果。如果表只有一个Region,你撒再多的盐也白搭。预分区就是用代码提前创建多个Region,让散列后的数据能均匀落进去。

// 这里给出伪代码级别的示例,技术栈:Java + HBaseAdmin
import org.apache.hadoop.hbase.HBaseConfiguration;
import org.apache.hadoop.hbase.HColumnDescriptor;
import org.apache.hadoop.hbase.HTableDescriptor;
import org.apache.hadoop.hbase.TableName;
import org.apache.hadoop.hbase.client.Admin;
import org.apache.hadoop.hbase.client.Connection;
import org.apache.hadoop.hbase.client.ConnectionFactory;
import org.apache.hadoop.hbase.util.Bytes;

public class CreateTableWithPreSplit {
    public static void main(String[] args) throws Exception {
        Connection conn = ConnectionFactory.createConnection(HBaseConfiguration.create());
        Admin admin = conn.getAdmin();

        TableName tableName = TableName.valueOf("order_table");
        HTableDescriptor desc = new HTableDescriptor(tableName);
        desc.addFamily(new HColumnDescriptor("cf"));

        // 预分区键:注意要与行键前缀的字典序对应
        byte[][] splitKeys = new byte[][] {
                Bytes.toBytes("0000-"),
                Bytes.toBytes("0100-"),
                Bytes.toBytes("0200-"),
                Bytes.toBytes("0300-"),
                Bytes.toBytes("0400-")
        };
        // 如果表存在,先删除(演示用,生产不要这样)
        if (admin.tableExists(tableName)) {
            admin.disableTable(tableName);
            admin.deleteTable(tableName);
        }
        admin.createTable(desc, splitKeys);
        admin.close();
        conn.close();
    }
}

这个例子创建了5个Region,分别以“0000-”、“0100-”等为起点。如果你的盐前缀范围是0000~9999,那么你可以把整个范围均匀切分成100份,这里为了简单只切了5个。注意实际生产建议有两倍的RegionServer数量。

四、应用场景和优缺点

4.1 适合的场景

加了盐或散列之后,最好的朋友是“高并发写入”。比如日志系统、监控数据、订单流水,这些都是写多读少,而且不依赖范围扫描。

还有“随机读取”非常快:你知道原始键,能很快算出盐前缀或者散列前缀,然后精确get。这样只打一个Region,性能很高。

另外,对时间做反转,非常适合“读取最近N条数据”的业务。比如一个用户最近订单,你通过反转时间,让新数据排在最前面,加盐后每个用户的数据依然连续,但不同用户之间散开。

4.2 不适合的场景

最典型的就是“全表范围扫描”。比如你要统计所有用户最近一周的订单,此时因为键被打散,你几乎要遍历所有Region,而且还需要把盐前缀去掉再排序,成本极高。如果业务必须做这种报表,建议还是用列存储的引擎或者把数据同步到分析平台。

还有“需要按多个条件任意组合查询”。散列只能针对一个主键维度打散,你照顾了用户维度的散落,却牺牲了订单维度的顺序。多个维度都需要高效访问,往往会做成多张表,或者用索引表。

4.3 优缺点对比

简而言之:

  • 优点:消除热点,提高集群吞吐;数据分布均匀,磁盘利用率高;配合预分区能极大减少分区分裂次数。
  • 缺点:牺牲范围扫描能力;需要额外处理键的转换;代码复杂度上升;如果盐值范围设计不当,可能让某一个用户自己形成热点。

这里要特别强调“同一个用户形成热点”。比如你的盐前缀用“userId % 128”,某个大V用户有海量订单,他一个人的写入就占到所有分区的1/128,虽然不会像自增键那样全部砸到一个Region,但那个Region依然可能承受不了。这种情况需要进一步把用户的维度拆细,比如加上日期段散列。

五、工程落地注意事项

5.1 盐值范围要和预分区保持匹配

很多人只设计了盐值,忘了预分区,结果表只有一个Region,白干。正确做法是:先想好盐值范围是什么,然后根据这个范围在创建表时把Region切好。比如你的盐前缀是两位十六进制,范围00~ff共256个,你就可以切256个分区,或者把256个分区合并成64个(每4个前缀共用一个Region),关键是前缀和Region范围的映射要对得上。

5.2 客户端要能解析出原始键

加盐后,你的查询条件不能再直接塞原始键。最稳妥的方式是在客户端写一个和写入时一样的算法,查询时用同样的算法算出实际行键。比如上面订单表的例子,如果你知道userId和时间,就能算出反转时间和盐前缀,然后构造出查询的起始键和结束键,做扫描。这里要小心时间反转后,起止键的范围也得跟着反转。举个例子,你想要查“2025-01-01 00:00:00”到“2025-01-02 00:00:00”,原始时间字符串从“20250101000000”到“20250102000000”,反转后就变成“00000000105202”到“0000020105202”,注意字典序完全反过来了。所以扫描时要设反,或者干脆用过滤器。

5.3 布隆过滤器可以帮你过滤无效行

有时你散列后还要扫很多前缀,每个前缀下可能没有你要的数据。HBase的布隆过滤器(BloomFilter)能快速判断一个RowKey是否在一个StoreFile里存在,从而减少无谓的磁盘IO。在创建表时,可以对列族设置BloomFilter类型:

// 技术栈:Java
HColumnDescriptor family = new HColumnDescriptor("cf");
family.setBloomFilterType(org.apache.hadoop.hbase.regionserver.BloomType.ROW);

对于散列键来说,布隆过滤器非常有用,因为你会发起多个前缀的查询,它能把那些不存在的Region挡在门外,减少查询延迟。

5.4 监控热点,别等事故发生

即使你做好了盐值设计,也要持续监控。重点看HBase的RegionServer指标:请求量方差、磁盘吞吐、CPU使用率。如果发现某个RegionServer明显高于其他节点,第一时间检查是不是盐值分布不均匀,或者出现单用户热点。很多公司喜欢用“grafana + prometheus”监控,HBase官方也提供了这些指标。你还可以定期扫描表,统计每个Region的Key分布,用脚本画出来看看是否有长尾。

5.5 新旧切换要有过渡期

一旦你的系统早期没有加盐,线上已经积累了数据,你不能直接改行键。常见的做法是写一个双写程序:新数据按新键写入,老数据仍然按旧键读取,同时用MapReduce任务扫描老表,把老数据转换成新键写入新表。这个过程中,查询要同时查两张表再合并。等数据全部迁移完成后,再切开关。这个过程很繁琐,所以最好一开始就设计好。

5.6 测试环境要模拟真实压力

最后也是最重要的,你在本地用几万条数据测不出热点。至少要在测试环境起十几个RegionServer,用和线上一样的Key分布压测,观察Region请求热力图。很多问题都是在数据量达到100亿、读写并发达到10万QPS时才暴露出来。别拍脑袋,用数据说话。

六、全文总结

咱们从头顺一下:热点源于顺序键把数据集中到少数Region。解决思路是给行键“去顺序化”,常用的两把斧头是盐值和散列。盐值简单可控,适合需要保留部分顺序的场景;散列均匀度高,适合随机读写。但两者都牺牲了范围查询,还会让代码变得复杂。

实战中,你需要结合自己的业务特点,确定盐值的范围和计算方式,再配合预分区,让数据真正均匀地摊到多个RegionServer上。同时要监控,要平滑迁移,要允许读写放大。

记住一句话:没有万能的RowKey设计,只有最匹配你访问模式的方案。写之前先列出所有查询语句,照着查询去设计散列规则,而不是反过来。

最后,如果你还在纠结要不要加盐,我的建议是:如果你的写并发超过千QPS,或者一个RegionServer的CPU长期超过70%,赶紧动手改。等线上出了热点故障再改,代价只会更大。