前置问题与边界

HBase 的一致性边界从 row key 开始。PutDeleteIncrementAppendCheckAndMutate 都围绕单行建立语义;同一行内的多个 column family、qualifier 和版本可以在一次 mutation 中共同提交,但这个边界不会扩展成跨行事务、跨表事务或关系数据库里的多语句事务。

本篇只回答一个核心问题:这些单行操作能保证什么,跨行场景缺少什么。WAL、MemStore 和 MVCC 的写入细节在第 04 篇已经铺底;RegionServer 崩溃后的 WAL 恢复放到第 11 篇;异步复制的一致性边界放到第 12 篇。

HBase 是宽列模型,不是关系数据库。行级原子性不等于外键约束、唯一索引、二级索引事务,也不等于任意 SQL 事务。它的强项是把同一 row key 下的 mutation 收束到一个 RegionServer 的单行执行边界内。

操作路径图

flowchart TD
    C[Client] --> RS[RegionServer]
    RS --> R[Region by row key]
    R --> L[Row mutation boundary]
    L --> W[WAL append if durability requires it]
    L --> M[MemStore update]
    M --> V[MVCC read point]
    V --> G[Get or Scan sees committed cells]
    L --> X[No cross-row transaction]

这张图的重点是边界。HBase 可以把同一 row key 的一组 Cell 作为一个原子 mutation 处理,但两个 row key 即使相邻,也可能分属不同 Region;即使当前在同一个 Region,也没有公共事务协议把它们包成一笔提交。

关键对象和状态

对象 作用 一致性含义
RowKey 单行边界 同一 row key 内的 mutation 可作为原子单元
Region row-key range 的服务单元 单行操作路由到负责该 row key 的 RegionServer
WAL 按 durability 记录 edit 支撑崩溃恢复,不提供跨行事务
MemStore 保存可读的最新状态 已提交 mutation 通过 MVCC read point 暴露给读路径
MVCC 管理写入序列和读点 读请求看到某个一致的提交点
CheckAndMutate 条件更新 API 条件检查和 mutation 在同一行内原子执行

Durability 会影响 mutation 是否要求 WAL 同步。SKIP_WAL 可以降低日志开销,但崩溃恢复边界随之变弱;它不能改变单行原子性的范围,也不能让跨行操作变成事务。

Put 与 Delete 的单行原子性

一次 Put 可以包含同一 row key 下多个列族和多个 qualifier。HBase 在该行的 mutation 边界内处理这些 Cell,读请求不会看到同一次 mutation 的半截结果。Delete 也是 mutation,它通常写入 tombstone;旧版本是否立刻消失取决于版本策略、TTL、扫描可见性和 compaction 条件。

同一行里,原子性覆盖的是 mutation 的提交边界,不是所有历史版本的立即物理清理。Delete 返回成功以后,读语义会根据 tombstone 过滤旧 Cell,但 HFile 上的旧数据可能要等后续 compaction 才有机会被清掉。

模式提炼:把事务边界压到单个分片键。公式可以写成 atomic({partition_key}, {mutation_set})。同一思想也出现在 Redis 单 key 操作、分片数据库按 shard key 更新、Kafka 单分区顺序处理里。边界越小,提交协议越简单;代价是跨边界约束要交给应用层或外部系统。

Increment 与 Append 的语义

Increment 针对同一行的数值列做原子加法,Append 针对同一行的字节值追加内容。它们适合计数、游标和同一实体内的轻量状态更新,但不适合表达“更新 A 行后必须更新 B 行”的约束。

IncrementAppend 的返回值代表服务端执行后的结果。这个结果仍受单行边界约束。跨行计数器、跨用户余额、全局唯一二级索引,都不能靠多个 Increment 拼出 ACID 事务。

CheckAndMutate 的条件提交

CheckAndMutate 把条件检查和 mutation 绑定在同一行内执行。典型用途是“如果某一列仍为期望值,则写入新值”。它可以实现单行 CAS(Compare-And-Set)式保护,常用于状态机流转、幂等占位和同一实体的乐观更新。

条件和 mutation 都必须围绕同一个 row key 设计。HBase 2.6 的公共 API 支持通过 CheckAndMutate builder 构造条件更新;RowMutations 也只能把同一 row key 下的 PutDelete 组合起来。把不同 row key 塞进同一个 RowMutations 不属于合法建模。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.HBaseConfiguration;
import org.apache.hadoop.hbase.TableName;
import org.apache.hadoop.hbase.client.CheckAndMutate;
import org.apache.hadoop.hbase.client.CheckAndMutateResult;
import org.apache.hadoop.hbase.client.Connection;
import org.apache.hadoop.hbase.client.ConnectionFactory;
import org.apache.hadoop.hbase.client.Put;
import org.apache.hadoop.hbase.client.Table;
import org.apache.hadoop.hbase.util.Bytes;

public final class SingleRowCasExample {
public static void main(String[] args) throws Exception {
Configuration conf = HBaseConfiguration.create();
try (Connection connection = ConnectionFactory.createConnection(conf);
Table table = connection.getTable(TableName.valueOf("lab:t10_atomic"))) {
byte[] row = Bytes.toBytes("order#1001");
byte[] cf = Bytes.toBytes("cf");
byte[] state = Bytes.toBytes("state");

table.put(new Put(row).addColumn(cf, state, Bytes.toBytes("NEW")));

Put paid = new Put(row).addColumn(cf, state, Bytes.toBytes("PAID"));
CheckAndMutate change = CheckAndMutate.newBuilder(row)
.ifEquals(cf, state, Bytes.toBytes("NEW"))
.build(paid);
CheckAndMutateResult result = table.checkAndMutate(change);
boolean changed = result.isSuccess();
System.out.println(changed);
}
}
}

UNVERIFIED_RUNTIME:上面的 Java 示例只使用 HBase 2.x public API 形态,当前环境未安装 HBase 2.6.6、Hadoop 3.4.3 和 JDK 17 组合,未在本机编译运行。正式核验应在目标版本的 Maven 工程中完成。

时间范围和读点

HBase 的版本读取还受 timestamp 和 time range 影响。公共 API 的 setTimeRange(minStamp, maxStamp) 使用左闭右开区间,即包含 minStamp,不包含 maxStamp。这条规则影响多版本读取和条件判断的测试设计。

MVCC 读点让一次读请求看到一个提交边界上的 Cell 集合。它不是关系数据库隔离级别的完整替代品,也不是跨行快照事务。Scan 的一致视图、客户端重试和 Region 移动仍需要按 HBase 的访问路径理解。

最小实验

UNVERIFIED_RUNTIME:以下命令用于 HBase 2.6.6、Hadoop 3.4.3、JDK 17 环境复现,当前任务未采集真实输出。

1
hbase shell
1
create_namespace 'lab'
1
create 'lab:t10_atomic', 'cf'
1
put 'lab:t10_atomic', 'order#1001', 'cf:state', 'NEW'
1
put 'lab:t10_atomic', 'order#1001', 'cf:amount', '10'
1
get 'lab:t10_atomic', 'order#1001'
1
incr 'lab:t10_atomic', 'order#1001', 'cf:version', 1
1
append 'lab:t10_atomic', 'order#1001', 'cf:trace', 'created'

实验只观察同一 row key 下状态变化是否作为一个实体处理,不记录性能数字。CheckAndMutate 建议用上面的 Java public API 验证,因为 Shell 语法随版本和命令封装有差异。

失败恢复

单行 mutation 返回成功后的恢复能力取决于 durability。默认路径会借助 WAL 支撑 RegionServer 崩溃后的 replay;如果 mutation 使用 SKIP_WAL,未 flush 数据在进程或节点失败时可能丢失。WAL 支撑的是 edit 重放,不是跨行事务补偿。

客户端重试也要配合幂等设计。Put 写同一个 Cell 通常容易设计成幂等,IncrementAppend 在客户端超时后更容易出现“不知道服务端是否已经执行”的灰区。需要精确一次语义时,单行 CAS、幂等请求号和业务状态机应一起设计。

工程迁移

需求 HBase 建模 风险边界
单订单状态流转 row key 为订单号,CheckAndMutate 更新状态 只保护该订单行
用户计数器 row key 为用户号,Increment 更新计数 跨用户汇总需要异步计算
幂等写入 同一行保存 request id 和业务状态 请求号必须位于同一 row key
二级索引 主表和索引表分两行或两表写 HBase 不自动提供跨表事务

模式速查:听到“同一实体内状态变化”,优先考虑单行原子;听到“多个实体必须一起成功”,HBase 原生行级语义已经不够,需要事务层、补偿流程或换用具备相应事务边界的数据库。

常见误解

  • “HBase 保证强一致”过于宽泛;更准确的说法是单行操作具备明确原子边界,复制、跨行和客户端重试另有边界。
  • “RowMutations 是小事务”容易误导;它只组合同一 row key 下的 PutDelete
  • “WAL 开启就等于事务日志”不准确;WAL 支撑恢复,不提供关系数据库事务管理器。
  • “CheckAndMutate 能做分布式锁”需要限定到同一行状态;锁持有者、过期和失败恢复仍由应用设计。

练习

  • 把一个订单状态机设计成单行 CAS,列出每个状态允许的前置状态。
  • 设计一个跨两行转账流程,标出 HBase 原生语义覆盖不到的位置。
  • 比较 PutIncrementAppend 在客户端超时后的幂等风险。

系列导航

参考资料