# HBase应用开发规则
#### Configuration实例的创建
该类应该通过调用HBaseConfiguration的create()方法来实例化。否则，将无法正确加载HBase中的相关配置项。
**正确示例：**
```
//该部分，应该是在类成员变量的声明区域声明
private Configuration hbaseConfig = null;
//建议在类的构造函数中，或者初始化方法中实例化该类
hbaseConfig = HBaseConfiguration.create();
```
**错误示例：**
```
hbaseConfig = new Configuration();
```
#### 共享Configuration实例
HBase客户端代码通过创建一个与ZooKeeper之间的HConnection，来获取与一个HBase集群进行交互的权限。一个ZooKeeper的HConnection连接，对应着一个Configuration实例，已经创建的HConnection实例，会被缓存起来。也就是说，如果客户端需要与HBase集群进行交互的时候，会传递一个Configuration实例到缓存中去，HBase Client部分通过已缓存的HConnection实例，来判断属于这个Configuration实例的HConnection实例是否存在，如果不存在，会创建一个新的HConnection，如果存在，则会直接返回相应的实例。
因此，如果频繁地创建Configuration实例，会导致创建很多不必要的HConnection实例，很容易达到ZooKeeper的连接数上限。
建议在整个客户端代码范围内，都共用同一个Configuration对象实例。
#### 创建Table实例
```
public abstract class TableOperationImpl {
  private static Configuration conf = null;
  private static Connection connection = null;
  private static Table table = null;
  private static TableName tableName = TableName.valueOf("sample_table");
  public TableOperationImpl() {
    init();
  }
  public void init() {
    conf = ConfigurationSample.getConfiguration();
    try {
      connection = ConnectionFactory.createConnection(conf);
      table = conn.getTable(tableName);
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
  public void close() {
    if (table != null) {
      try {
        table.close();
      } catch (IOException e) {
        System.out.println("Can not close table.");
      } finally {
        table = null;
      }
    }
    if (connection != null) {
      try {
        connection.close();
      } catch (IOException e) {
        System.out.println("Can not close connection.");
      } finally {
        connection = null;
      }
    }
  }
  public void operate() {
    init();
    process();
    close();
  }
}
```
#### 不允许多个线程在同一时间共用同一个Table实例
Table是一个非线程安全类，因此，同一个Table实例，不应该被多个线程同时使用，否则可能会出现并发问题。
#### Table实例缓存
如果一个Table实例可能长时间会被同一个线程固定且频繁地用到，例如，通过一个线程不断地往一个表内写入数据，那么这个Table在实例化后，就需要缓存下来，而不是每一次插入操作，都要实例化一个Table对象（尽管提倡实例缓存，但也不是在一个线程中一直沿用一个实例，个别场景下依然需要重构，可参见下一条规则）。
**正确示例：**
**注意该实例中提供的以Map形式缓存Table实例的方法，未必通用。这与多线程多Table实例的设计方案有关。如果确定一个Table实例仅仅可能会被用于一个线程，而且该线程也仅有一个Table实例的话，就无须使用Map。这里提供的思路仅供参考。**
```
//该Map中以TableName为Key值，缓存所有已经实例化的Table
private Map<String, Table> demoTables = new HashMap<String, Table>();
//所有的Table实例，都将共享这个Configuration实例
private Configuration demoConf = null;
/**
* <初始化一个HTable类>
* <功能详细描述>
* @param tableName
* @return
* @throws IOException
* @see [类、类#方法、类#成员]
*/
private Table initNewTable(String tableName) throws IOException
{
try (Connection conn = ConnectionFactory.createConnection(demoConf)){
      return conn.getTable(tableName);
    }
}
/**
* <获取Table实例>
* <功能详细描述>
* @see [类、类#方法、类#成员]
*/
private Table getTable(String tableName)
{
if (demoTables.containsKey(tableName))
{
return demoTables.get(tableName);
} else {
Table table = null;
try
{
table = initNewTable(tableName);
demoTables.put(tableName, table);
}
catch (IOException e)
{
// TODO Auto-generated catch block
e.printStackTrace();
}
return table;
}
}
/**
* <写数据>
* <这里未涉及到多线程多Table实例在设计模式上的优化.这里采用同步方法，
* 主要是是考虑到同一个Table是非线程安全的.通常，建议一个Table实例，在同一
*  时间只能被用在一个写数据的线程中>
* @param dataList
* @param tableName
* @see [类、类#方法、类#成员]
*/
public void putData(List<Put> dataList, String tableName)
{Table table = getTable(tableName);
//关于这里的同步：如果在采用的设计方案中，不存在多线程共用同一个Table实例
//的可能的话，就无须同步了。这里需要注意Table实例是非线程安全的
synchronized (table)
{
try
{
table.put(dataList);
table.notifyAll();
}
catch (IOException e)
{
                // 在捕获到IOE时，需要将缓存的实例重构。
try {
     // 关闭之前的Connection.
       table.close();
                  // 重新创建这个实例.
                  table = initNewTable(tableName);
} catch (IOException e1) {
// TODO
}
}
}
}
```
**错误示例：**
```
public void putDataIncorrect(List<Put> dataList, String tableName)
{Table table = null;
try
{
//每次写数据，都创建一个HTable实例
table = initNewTable(tableName);
table.put(dataList);
}
catch (IOException e1)
{
// TODO Auto-generated catch block
e1.printStackTrace();
}
finally
{
table.close();
}
}
```
#### Table实例写数据的异常处理
尽管在前一条规则中提到了提倡Table实例的重构，但是，并非提倡一个线程自始至终要沿用同一个Table实例，当捕获到IOException时，依然需要重构Table实例。示例代码可参考上一个规则的示例。
另外，请谨慎调用如下两个方法：
- **Configuration#clear：**
  该方法会清理所有已加载的属性，对于已经在使用这个Configuration的类或线程而言，可能会带来潜在的问题（例如，假如Table还在使用这个Configuration，那么，调用这个方法后，Table中的这个Configuration的所有的参数，都被清理掉了），也就是说：只要还有对象或者线程在使用这个Configuration，就不应该调用这个clear方法，除非所有的类或线程，都已经确定不用这个Configuration了。
  因此，该方法应该要放在进程退出时执行，而不是每一次Table要重构的时候执行。
  
- **HConnectionManager#deleteAllConnections：**
  该方法可能会导致现有的正在使用的连接被从**连接集合**中清理掉，同时，因为在HTable中保存了原有连接的引用，可能会导致这个连接无法关闭，进而可能会造成泄漏。因此，该方法不建议使用。
  
#### 写入失败的数据要做相应的处理
在写数据的过程中，如果进程异常或一些其它的短暂的异常，可能会导致一些写入操作失败。因此，对于操作的数据，需要将其记录下来。在集群恢复正常后，重新将其写入到HBase数据表中。
另外，有一点需要注意：HBase Client返回写入失败的数据，是不会自动重试的，仅仅会告诉接口调用者哪些数据写入失败了。对于写入失败的数据，一定要做一些安全的处理，例如可以考虑将这些失败的数据，暂时写在文件中，或者，直接缓存在内存中。
**正确示例：**
```
private List<Row> errorList = new ArrayList<Row>();
/**
* <采用PutList的模式插入数据>
* <如果不是多线程调用该方法，可不采用同步>
* @param put 一条数据记录
* @throws IOException
* @see [类、类#方法、类#成员]
*/
public synchronized void putData(Put put)
{
// 暂时将数据缓存在该List中
dataList.add(put);
// 当dataList的大小达到PUT_LIST_SIZE之后，就执行一次Put操作
if (dataList.size() >= PUT_LIST_SIZE)
{
try
{
demoTable.put(dataList);
}
catch (IOException e)
{
// 如果是RetriesExhaustedWithDetailsException类型的异常，
// 说明这些数据中有部分是写入失败的这通常都是因为
// HBase集群的进程异常引起，有时也会因为有大量
// 的Region正在被转移，导致尝试一定的次数后失败
if (e instanceof RetriesExhaustedWithDetailsException)
{
RetriesExhaustedWithDetailsException ree = 
  (RetriesExhaustedWithDetailsException)e;
int failures = ree.getNumExceptions();
for (int i = 0; i < failures; i++)
{
errorList.add(ree.getRow(i));
}
}
}
dataList.clear();
}
}
```
#### 资源释放
关于ResultScanner和Table实例，在用完之后，需要调用它们的close方法，将资源释放掉。Close方法要放在finally块中，来确保一定会被调用到。
**正确示例：**
```
ResultScanner scanner = null;
try
{
scanner = demoTable.getScanner(s);
//Do Something here.
}
finally
{
scanner.close();
}
```
**错误示例：**
1. 在代码中未调用scanner.close()方法释放相关资源。
2. scanner.close()方法未放置在finally块中。
   ```
   ResultScanner scanner = null;
   scanner = demoTable.getScanner(s);
   //Do Something here.
   scanner.close();
   ```
   
 
#### Scan时的容错处理
Scan时不排除会遇到异常，例如，租约过期。在遇到异常时，建议Scan应该有重试的操作。
事实上，重试在各类异常的容错处理中，都是一种优秀的实践，这一点，可以应用在各类与HBase操作相关的接口方法的容错处理过程中。
#### 不用Admin时，要及时关闭，Admin实例不应常驻内存
Admin的实例应尽量遵循 "用时创建，用完关闭"的原则。不应该长时间缓存同一个Admin实例。
#### RowKey设计规范
HBase作为分布式列式存储引擎，其读写性能和扩展性高度依赖于RowKey的设计质量与表的预分区策略。不合理的RowKey设计会导致数据写入热点，使个别RegionServer过载而其余节点空闲；未预分区的表在初始阶段只有一个Region，所有写入请求集中打到单节点，引发性能瓶颈和Region Split风暴。
**RowKey设计原则**
- 长度原则：RowKey长度建议10\~100字节，最大不超过200字节。 过长的RowKey会导致MemStore和HFile索引膨胀，影响内存利用率和查询性能。HBase底层将RowKey存储在HFile的索引块中，每行数据的RowKey都会被完整记录，RowKey越长，索引占用的内存和磁盘空间越大。
  
- 散列原则：实际数据映射到RowKey时，应均匀分布到所有Region。 避免读写集中到个别RegionServer，形成热点瓶颈。均匀分布是水平扩展的前提。
  
- 唯一原则：RowKey必须全局唯一。 RowKey重复会导致数据覆盖，查询结果不符合预期。
  
**RowKey设计如何规避热点**
- **加盐（Salting）**
  在RowKey前缀添加随机数或模数，打散数据分布。
  **设计方式**：
  原始：
  ```
  RowKey:  user123_event20260711
  ```
  加盐：
  ```
  RowKey:  0_user123_event20260711   (prefix = hash(key) % regionNum)
           1_user123_event20260711
           ...
           N_user123_event20260711
  ```
  **适用场景**：
  - 写入密集型场景，需要将写入压力均匀分散到所有Region。
  
  - 不需要按原始RowKey前缀进行范围扫描的场景。
  
  
  **优缺点**：
  - 优点：写入均匀分布，消除写热点。
  
  - 缺点：扫描时需遍历所有前缀，无法直接范围查询；读取单条数据时需知道加盐前缀值。
  
  
  **加盐前缀数量选择**：
  - 前缀数量应与预分区数量一致或为其整数倍。
  
  - 前缀计算公式：prefix = hash(业务键) % N，其中N为前缀数量。
  
  - 前缀建议使用零填充的定长数字，如00、01、...、15，保证排序正确性。
   
- **哈希（Hashing）**
  对关键字段做MD5/SHA1，并取前几位作为RowKey的前缀。
  **设计方式**：
  原始：
  ```
  RowKey:  13800138000
  ```
  哈希：
  ```
  RowKey:  a1b2c_13800138000   (MD5("13800138000")[0:5])
  ```
  **适用场景**：
  - 需要按原始键精确查询，且同一原始值的哈希结果一致。
  
  - 不需要按原始键排序进行范围扫描。
  
  
  **优缺点**：
  - 优点：同一原始值哈希结果一致，可精确查询；散列分布均匀。
  
  - 缺点：丧失原始排序，范围扫描需另外设计。
  
  
  **哈希前缀位数选择**：
  表1**哈希前缀位数** 
  | 前缀位数   | 取值空间          | 适合Region数量      |
  |:---|:---|:---|
  | 2位hex   | 256          | ≤ 64             |
  | 4位hex  | 65,536       | 64 \~ 1,000       |
  | 6位hex | 16,777,216   | 1,000 \~ 10,000 |
  | 8位hex  | 4,294,967,296 | \> 10,000       |
     
  前缀位数不宜过多，通常4\~6位即可满足绝大多数场景。前缀位数越多，散列粒度越细，但RowKey长度会增加，可能会导致MemStore和HFile索引膨胀，影响内存利用率和查询性能。
  
- **反转（Reversing）**
  将RowKey反转存储，把变化部分移到前面。
  **设计方式**：
  原始：
  ```
  RowKey:  20260711150000_event001   (时间前缀，写热点)
  ```
  反转：
  ```
  RowKey:  100_tneve_00005111071602
  ```
  **适用场景**：
  - RowKey前缀为时间戳或自增序列，尾部的业务标识变化较大。
  
  - 主要按单个键精确查询，范围扫描需求较少。
  
  
  **优缺点**：
  - 优点：有效打散时间、序列前缀的热点。实现简单，无需额外计算。
  
  - 缺点：丧失时间有序性，范围扫描困难。反转后可读性差。
  
  - 缓解：仅反转固定长度前缀（如手机号反转），保留后缀排序能力。
   
- **桶排序** **（Bucket Sorting）**
  在散列粒度与排序粒度之间寻找平衡------以"桶"为粒度做散列，桶内保持业务键排序，范围扫描（Scan）在桶内高效执行，跨桶通过并行扫描合并结果。
  **设计方式**：
  ```
  RowKey = <桶编号> + <SEP> + <范围扫描键> + <SEP> + <业务键>
  桶编号 = hash(分桶维度) % 桶数量
  范围扫描键 = 需要范围扫描的字段（如时间戳），保持原始排序
  ```
  示例：按用户查询时间范围内的行为日志
  ```
  RowKey = <userId桶编号> + <SEP> + <timestamp> + <SEP> + <事件ID>
  示例:  03_1720695600000_event001
        └┘└─────┘ └──┘
         桶号  时间戳正序  事件标识
  桶编号 = MD5(userId) 取前2位hex → % 桶数量(如16)
  ```
  **设计说明**：
  - 桶编号：对范围扫描的"固定维度"（如用户ID）做散列，而非对范围扫描键（如时间戳）做散列。
  
  - 范围扫描键保持原序：同一桶内，数据按时间戳有序排列，支持高效范围扫描。
  
  - 桶数量选择：桶数量 = Region数量或其整数倍，确保写入均匀分布。
  
  
  **查询方式**：
  表2分桶排序的查询方式 
  | 查询需求           | Scan方式                                                       | 扫描Region数 |
  |:---|:---|:---|
  | 查询某用户某时间段行为    | scan startRow=\<桶号\>_\<startTs\>, stopRow=\<桶号\>_\<endTs\> | 1个        |
  | 查询某时间段所有用户行为 | 对所有桶并行Scan，每个桶设startRow/stopRow为时间范围                      | 桶数量个（可并行） |
  | 查询某条具体事件     | get \<桶号\>_\<ts\>_\<事件ID\>                                 | 1个        |
     
  **优缺点**：
  - 优点：桶内范围扫描高效，只需扫描1个Region。精确查询可计算桶号定位。
  
  - 缺点：跨桶范围扫描需并行扫描多个Region后合并。桶数量过多时跨桶扫描开销大。
  
  - 适用：范围扫描维度有固定的"分组维度"（如用户ID、设备ID、租户ID）。
  
  
  1. **业务键分桶预分区设计**
     **适用场景**：范围扫描键本身就是业务主键（如订单号前缀、设备类型编码），无需额外散列。
     **设计方式**：
     ```
     RowKey = <业务键前缀> + <SEP> + <业务键> + <SEP> + <时间戳>
     业务键前缀 = 业务键的前N位（如订单号前4位、设备类型编码）
     ```
     示例：按订单号前缀分桶
     ```
     RowKey = <订单号前4位> + <SEP> + <订单号> + <SEP> + <timestamp>
     示例:  ORD2_ORD202607110001_1720695600000
            └┘ └──────┘└─────┘
            前缀桶     订单号       时间戳
     预分区按订单号前缀范围划分：
       SPLITS => ['ORD1', 'ORD2', 'ORD3', 'ORD4', 'ORD5']
     ```
     **设计说明**：
     - 业务键前缀天然分桶：前缀相同的订单自然落入同一Region，支持按前缀范围扫描。
     
     - 预分区按业务键前缀划分：分区边界与业务键前缀对齐，避免数据倾斜。
     
     - 适用条件：业务键前缀的取值分布较均匀，且前缀具有业务含义（可按前缀范围查询）。
     
     
     **注意事项**：
     - 业务键前缀分布不均匀时（如某些前缀的订单量远超其他），需在前缀基础上叠加散列。
     
     - 可使用hash(前缀) % N将不均匀的前缀重新分配到均匀的桶中。
      
  
  2. **时间序列范围扫描设计**
     **适用场景**：以时间范围为主要查询模式，需要按时间段扫描数据。
     **核心挑战**：时间前缀天然有序但会产生写热点（当前时间数据集中写入最后一个Region）。
     **方案一：时间分桶 + 桶内时间有序（推荐）**
     ```
     RowKey = <时间桶> + <SEP> + <精确时间戳> + <SEP> + <业务ID>
     时间桶 = 时间戳按小时/天取整
     示例（按天分桶）:  20260711_1720695600000_device001
                        └────┘└───┘ └───┘
                        日期桶      精确时间戳    设备ID
     ```
     - 同一天的数据落入同一桶（Region），天内按时间有序，支持高效时间范围扫描。
     
     - 写入时当前天只有一个Region接收写入，但天级粒度的热点可接受（写入压力分散到天的维度）。
     
     - 适合场景：写入QPS \< 5万/天，或按天归档查询的场景。
     
     
     **方案二：时间前缀 + 散列子桶**
     时间前缀 + 散列子桶
     ```
     RowKey = <时间桶> + <SEP> + <散列子桶> + <SEP> + <精确时间戳> + <SEP> + <业务ID>
     示例:  20260711_a3_1720695600000_device001
            └──┘└┘└─────┘└───┘
            日期桶   子桶    精确时间戳  设备ID
     散列子桶 = hash(业务ID) % 子桶数量(如8)
     ```
     - 将同一天的数据按散列子桶进一步打散到多个Region。
     
     - 时间范围扫描时，对同一天的所有子桶并行Scan。
     
     - 适合场景：写入QPS \> 5万/天，单天写入量大的时序场景。
      
   
- **RowKey方案选择** **操作步骤**
  1. 确认业务是否涉及顺序范围扫描（Scan）。
     - 是，执行[3]。
     
     - 否，执行[2]。
      
  
  2. 检查写入频率是否较高（大于10万QPS）。
     - 是，参考[加盐]（前缀数 = Region数）。
     
     - 否，参考[哈希]前缀（精确查询为主）。
      
  
  3. 业务数据Scan扫描的维度。
     - 时间范围，参考[4]。
     
     - 业务键范围，参考[5]。
     
     - 固定维度+范围维度，参考[分桶排序]。
      
  
  4. 写入速率：
     - 写入QPS \< 5万/天，时间分桶 + 桶内时间有序。
     
     - 写入QPS ≥ 5万/天，时间前缀 + 散列子桶。
      
  
  5. 业务键前缀分布：
     - 业务键前缀分布均匀，业务键分桶预分区。
     
     - 业务键前缀分布不均，hash(前缀)分桶 + 桶内排序。
      
   
**RowKey设计红线**
[表3]内的设计为禁止项，违反这些规定将导致严重的性能或正确性问题。
 表3**RowKey设计红线** 
| **RowKey设计**红线     | 违反后果                                     | 正确做法                                  |
|:---|:---|:---|
| 禁止纯时间戳或自增ID作为RowKey | 所有写入打到最后一个Region，形成写热点。                   | 时间戳前加散列前缀或反转。                         |
| 禁止手机号或用户ID直接作为前缀    | 相同号段数据聚集在同一Region。                     | 反转手机号或取hash前缀。                        |
| RowKey长度超过200字节    | MemStore和HFile索引膨胀，GC压力增大。              | 精简字段，使用编码压缩。                        |
| 多字段拼接时缺少分隔符        | 字段边界模糊，解析出错。Scan的startRow/stopRow边界难以确定。 | 使用"_"、"\|"等非业务字符分隔。                  |
| 分隔符与字段值字符集重叠        | 字段值包含分隔符导致解析错误。                           | 选择字段值中不会出现的字符作为分隔符或对字段值做Base32/Hex编码。 |
   
