LabHub

Blog

HBase Data Modeling Playbook: Hotspot Avoidance and Performance Stabilization

한국어English日本語

HBase Data Modeling Playbook: Hotspot Avoidance and Performance Stabilization

1. Introduction

HBase is a distributed Column-Family NoSQL database designed based on the Google Bigtable paper. Running on top of HDFS, it can handle billions of rows and millions of columns with millisecond-level latency. However, this performance is only achievable when data modeling is done correctly. A single poorly designed RowKey can funnel all traffic to one RegionServer out of dozens, creating a hotspot that effectively reduces the entire cluster's throughput to that of a single server.

This article is not a general HBase architecture overview but a hands-on playbook focused on data modeling and hotspot avoidance. It systematically covers RowKey design patterns, hotspot detection methods, Region distribution strategies, schema design patterns, and operational techniques for performance stabilization.

2. HBase Data Model Fundamentals

Core Components

HBase's data model is fundamentally different from RDBMS. The following five elements constitute a single Cell.

ComponentDescriptionExample
Row (RowKey)A byte array that uniquely identifies a row. Lexicographically sorteduser001_20260308
Column FamilyPhysical grouping of columns. Defined at table creationcf, info, stats
Column QualifierIndividual column name within a Column Family. Dynamically addablename, email, count
TimestampVersion of a cell in milliseconds1709856000000
Cell (Value)The actual data value identified by the four coordinates above"John Doe"

Physical Storage Structure

HBase data appears as tables logically but is physically stored separately per Column Family. This is the most important constraint in schema design.

Logical View:
┌──────────────────────────────────────────────────────────────┐
RowKeyCF:info            │ CF:metrics             │
│                │ name    │ email    │ cpu_avg  │ mem_used    │
├──────────────────────────────────────────────────────────────┤
│ server_web01   │ Web-01...72.58192│ server_db01    │ DB-01...45.216384└──────────────────────────────────────────────────────────────┘

Physical Storage (separate HFiles per Column Family):
[HFile: info]
(server_db01, info:email, t1)"admin@example.com"
(server_db01, info:name, t1)"DB-01"
(server_web01, info:email, t1)"web@example.com"
(server_web01, info:name, t1)"Web-01"

[HFile: metrics]
(server_db01, metrics:cpu_avg, t2)45.2
(server_db01, metrics:mem_used, t2)16384
(server_web01, metrics:cpu_avg, t2)72.5
(server_web01, metrics:mem_used, t2)8192

RowKey Sorting and Region Distribution

An HBase table is sorted by RowKey in lexicographic order, and contiguous RowKey ranges form a Region. Regions are assigned to RegionServers that handle actual reads and writes.

Entire RowKey space:
[aaa...] ─────────── [mmm...] ─────────── [zzz...]

Region partitioning:
Region 1: [aaa ~ fff]RegionServer A
Region 2: [fff ~ mmm]RegionServer B
Region 3: [mmm ~ sss]RegionServer C
Region 4: [sss ~ zzz]RegionServer D

The critical insight here is that RowKey distribution directly determines load distribution. If writes are concentrated in a specific RowKey range, the single RegionServer hosting that Region becomes overloaded.

Column Family Design Principles

Column Families must be defined at table creation time and serve as the unit for physical storage and configuration (compression, TTL, version count, etc.). Follow these principles:

# Column Family design example
create 'metrics', \
  {NAME => 'd', VERSIONS => 1, COMPRESSION => 'SNAPPY', BLOOMFILTER => 'ROW', TTL => 7776000}, \
  {NAME => 'm', VERSIONS => 1, COMPRESSION => 'SNAPPY', BLOOMFILTER => 'ROW', TTL => 31536000}

# d: raw data (90-day TTL)
# m: aggregated metadata (1-year TTL)

3. RowKey Design Patterns

RowKey design determines 80% of HBase performance. You must consider read/write patterns, data distribution, and scan ranges.

3.1 Salting (Prefix Distribution)

Salting prepends a hash-based prefix (salt) to the RowKey to distribute data evenly across multiple Regions.

public class SaltedRowKeyGenerator {

    private static final int NUM_BUCKETS = 16;  // Aligned with Region count

    /**
     * Adds a salt prefix to the original RowKey for distribution.
     * Example: "20260308_sensor001" → "0a_20260308_sensor001"
     */
    public static byte[] generateSaltedKey(String originalKey) {
        int bucket = Math.abs(originalKey.hashCode() % NUM_BUCKETS);
        String saltPrefix = String.format("%02x", bucket);
        String saltedKey = saltPrefix + "_" + originalKey;
        return Bytes.toBytes(saltedKey);
    }

    /**
     * Reverse-computes the salt for a specific original key (for Get requests).
     */
    public static byte[] getSaltedKey(String originalKey) {
        return generateSaltedKey(originalKey);  // Same hash result
    }

    /**
     * Full range Scan: must execute parallel scans across all salt buckets.
     */
    public static List<Scan> createParallelScans(String startKey, String endKey) {
        List<Scan> scans = new ArrayList<>();
        for (int i = 0; i < NUM_BUCKETS; i++) {
            String prefix = String.format("%02x", i);
            Scan scan = new Scan();
            scan.withStartRow(Bytes.toBytes(prefix + "_" + startKey));
            scan.withStopRow(Bytes.toBytes(prefix + "_" + endKey));
            scans.add(scan);
        }
        return scans;
    }
}

Best for: Maximizing write throughput with infrequent range scans (log ingestion, event collection).

Trade-off: Range scans require parallel scans across all salt buckets, increasing scan cost.

3.2 Hashing (Hash Prefix)

Transforms the RowKey entirely or partially through a hash function for even distribution. Similar to salting but uses the hash result itself as a prefix for a wider distribution range.

import org.apache.commons.codec.digest.DigestUtils;

public class HashedRowKeyGenerator {

    /**
     * Uses the first 4 characters of an MD5 hash as prefix.
     * Provides 65,536 distribution buckets.
     */
    public static byte[] createHashedKey(String userId, long timestamp) {
        String baseKey = userId + "_" + timestamp;
        String hashPrefix = DigestUtils.md5Hex(baseKey).substring(0, 4);
        String rowKey = hashPrefix + "_" + userId + "_" + timestamp;
        return Bytes.toBytes(rowKey);
    }

    /**
     * Querying a specific user's data also requires the same hash computation.
     */
    public static byte[] getHashedKey(String userId, long timestamp) {
        return createHashedKey(userId, timestamp);
    }
}

// Usage example
// Original: "user001_1709856000000"
// Hashed: "a3f2_user001_1709856000000"

Best for: Point Get-oriented access patterns where the specific RowKey is known.

Warning: Hashing destroys original key ordering, making range scans effectively impossible.

3.3 Key Reversing

Reverses keys like domain names or timestamps where the prefix is similar but the suffix varies, achieving distribution.

public class ReversedKeyExamples {

    /**
     * Domain reversal: distributes domains under the same TLD.
     * "www.google.com" → "moc.elgoog.www"
     */
    public static String reverseDomain(String domain) {
        return new StringBuilder(domain).reverse().toString();
    }

    /**
     * Reverse Timestamp: pattern for scanning most recent data first.
     * Since HBase sorts RowKeys in ascending order, subtracting from
     * Long.MAX_VALUE places the latest timestamp at the top (smallest value).
     */
    public static long reverseTimestamp(long timestamp) {
        return Long.MAX_VALUE - timestamp;
    }

    /**
     * RowKey design for fast lookup of a user's most recent activity.
     */
    public static byte[] createUserActivityKey(String userId, long timestamp) {
        long reversedTs = Long.MAX_VALUE - timestamp;
        String rowKey = userId + "_" + String.format("%019d", reversedTs);
        return Bytes.toBytes(rowKey);
        // Result: "user001_9223370449055775807"
        // Scan(startRow=user001_, stopRow=user002) → returns in newest-first order
    }
}

Best for: Patterns like "most recent N entries for a specific user" where newest data within a prefix should be retrieved first.

3.4 Composite Key

Combines multiple dimensions of data into a single RowKey. Uses _ or null byte (\x00) as delimiters.

public class CompositeKeyDesign {

    /**
     * Composite key design for IoT sensor data.
     * Structure: {region_code}_{device_id}_{reverse_timestamp}
     *
     * - region_code: geographic distribution (2 bytes)
     * - device_id: device identification (variable)
     * - reverse_timestamp: newest-first ordering (8 bytes)
     */
    public static byte[] createIoTRowKey(String regionCode, String deviceId, long timestamp) {
        long reversedTs = Long.MAX_VALUE - timestamp;
        String rowKey = String.format("%s_%s_%019d", regionCode, deviceId, reversedTs);
        return Bytes.toBytes(rowKey);
    }

    /**
     * Composite key for messaging systems.
     * Structure: {chat_room_id}_{reverse_timestamp}_{message_id}
     * → Efficiently scan latest messages in a specific chat room
     */
    public static byte[] createMessageRowKey(String roomId, long timestamp, String msgId) {
        long reversedTs = Long.MAX_VALUE - timestamp;
        return Bytes.toBytes(roomId + "_" + String.format("%019d", reversedTs) + "_" + msgId);
    }
}

3.5 RowKey Design for Time-Series Data

Time-series data is the most common HBase workload and the type most prone to hotspots. Using sequential timestamps directly as RowKeys always concentrates writes on the last Region.

[Bad Design] Timestamp at the beginning of RowKey
RowKey: 20260308120000_sensor001
RowKey: 20260308120001_sensor001
RowKey: 20260308120002_sensor001
All writes concentrated on the last Region (hotspot!)

[Good Design] salt + deviceID + reverse timestamp
RowKey: 0a_sensor001_9223370449055775807
RowKey: 03_sensor002_9223370449055775807
RowKey: 0f_sensor003_9223370449055775807
Evenly distributed across 16 Regions

Recommended RowKey pattern for time-series data:

/**
 * RowKey generator for time-series metric collection.
 *
 * Pattern: {salt}_{metric_name}_{device_id}_{reverse_timestamp}
 *
 * Advantages:
 * 1. Salt for Region distribution
 * 2. metric_name + device_id to narrow Scan range
 * 3. reverse_timestamp for newest-first queries
 */
public class TimeSeriesRowKeyGenerator {

    private static final int SALT_BUCKETS = 32;

    public static byte[] generate(String metricName, String deviceId, long timestamp) {
        String baseKey = metricName + "_" + deviceId;
        int salt = Math.abs(baseKey.hashCode() % SALT_BUCKETS);
        long reversedTs = Long.MAX_VALUE - timestamp;

        String rowKey = String.format("%02x_%s_%s_%019d",
            salt, metricName, deviceId, reversedTs);
        return Bytes.toBytes(rowKey);
    }

    /**
     * Time-range Scan for a specific device's specific metric.
     * Since the salt is known, efficient single-Region scan is possible.
     */
    public static Scan createRangeScan(
            String metricName, String deviceId,
            long startTime, long endTime) {

        String baseKey = metricName + "_" + deviceId;
        int salt = Math.abs(baseKey.hashCode() % SALT_BUCKETS);
        String prefix = String.format("%02x", salt);

        // Since using reverse timestamp, start/end are inverted
        long reversedEnd = Long.MAX_VALUE - startTime;
        long reversedStart = Long.MAX_VALUE - endTime;

        Scan scan = new Scan();
        scan.withStartRow(Bytes.toBytes(
            prefix + "_" + metricName + "_" + deviceId + "_"
            + String.format("%019d", reversedStart)));
        scan.withStopRow(Bytes.toBytes(
            prefix + "_" + metricName + "_" + deviceId + "_"
            + String.format("%019d", reversedEnd)));
        scan.setCaching(500);
        return scan;
    }
}

RowKey Design Decision Guide

Access PatternRecommended RowKey StrategyRationale
Random Point GetHashingEven distribution, order not needed
Latest N entries for an entityentity_id + reverse timestampPrefix Scan for newest-first queries
High-volume sequential writes (logs)Salting + composite keyWrite distribution + minimal Scan support
Time-series range queriessalt + metric + device + reverse tsSupports both distribution and range queries
Full-text search alternativereverse domain + pathSubdomain grouping

4. Hotspot Causes and Detection

What Is a Hotspot

A hotspot occurs when read or write requests are abnormally concentrated on a specific Region. While HBase is designed for horizontal scaling, a hotspot turns a single RegionServer's processing limit into the bottleneck for the entire cluster.

Normal distribution:
RS-1: ████████  (25%)
RS-2: ████████  (25%)
RS-3: ████████  (25%)
RS-4: ████████  (25%)

Hotspot:
RS-1: ██  (5%)
RS-2:    (2%)
RS-3:    (3%)
RS-4: ████████████████████████████████████████  (90%)Overloaded!

Primary Causes of Hotspots

1. Sequential RowKey

Using monotonically increasing values like timestamps or auto-increment IDs as RowKeys causes all new data to be written to the end of the key space (the last Region).

# Bad example: Timestamp-based RowKey
2026030812000001Region [2026030811~ 2026030812]All writes here
2026030812000002Region [2026030811~ 2026030812]
2026030812000003Region [2026030811~ 2026030812]

2. Skewed Key Distribution

When data with a specific prefix vastly outnumbers other prefixes. For example, if 90% of data starts with user_ and only 10% uses other prefixes, Regions covering the user_ range become overloaded.

3. Popular Key (Hot Key)

When reads/writes concentrate on a few specific RowKeys. Celebrity profiles, popular product pages, etc.

Hotspot Detection Methods

Checking per-Region request counts with HBase Shell:

# Check Region distribution for a table
hbase shell <<'EOF'
status 'detailed'
EOF

# Check per-Region request counts for a specific table
hbase shell <<'EOF'
list_regions 'metrics_table'
EOF

# Example output:
# REGION                  | START_KEY  | END_KEY    | SIZE   | REQ    | LOCALITY
# metrics_table,,1709...  | (empty)    | 08_       | 2.1GB  | 1204   | 0.95
# metrics_table,08_,17... | 08_        | 10_       | 2.3GB  | 982341 | 0.92  ← Hotspot!
# metrics_table,10_,17... | 10_        | 18_       | 1.8GB  | 1102   | 0.97

Checking per-RegionServer load via JMX metrics:

# Compare request counts across RegionServers
curl -s "http://regionserver1:16030/jmx?qry=Hadoop:service=HBase,name=RegionServer,sub=Server" \
  | python3 -c "
import json, sys
data = json.load(sys.stdin)
for bean in data['beans']:
    print(f\"ReadRequests: {bean.get('readRequestCount', 'N/A')}\")
    print(f\"WriteRequests: {bean.get('writeRequestCount', 'N/A')}\")
    print(f\"TotalRequests: {bean.get('totalRequestCount', 'N/A')}\")
"

# If a specific server's requests exceed 3x the average,
# suspect a hotspot

Automated hotspot detection script using HBase metrics:

#!/bin/bash
# hotspot_detector.sh - Detect per-Region request deviation

TABLE="metrics_table"
THRESHOLD=3.0  # Alert if 3x above average

echo "=== Hotspot Detection for $TABLE ==="

# Extract per-Region request counts
REGIONS=$(echo "list_regions '$TABLE'" | hbase shell 2>/dev/null | grep -E "^\s+\{" | \
  awk -F',' '{for(i=1;i<=NF;i++) if($i ~ /REQ/) print $i}' | \
  grep -oP '\d+')

if [ -z "$REGIONS" ]; then
    echo "No region data found."
    exit 1
fi

# Calculate average
TOTAL=0
COUNT=0
for req in $REGIONS; do
    TOTAL=$((TOTAL + req))
    COUNT=$((COUNT + 1))
done
AVG=$((TOTAL / COUNT))

echo "Average requests per region: $AVG"
echo "Threshold (${THRESHOLD}x average): $(echo "$AVG * $THRESHOLD" | bc | cut -d. -f1)"
echo ""

# Identify hotspot Regions
IDX=0
for req in $REGIONS; do
    RATIO=$(echo "scale=2; $req / $AVG" | bc)
    if (( $(echo "$RATIO > $THRESHOLD" | bc -l) )); then
        echo "[HOTSPOT] Region $IDX: $req requests (${RATIO}x average)"
    fi
    IDX=$((IDX + 1))
done

5. Hotspot Avoidance Strategies

5.1 Pre-splitting

Pre-splitting Regions at table creation time prevents load concentration on a single Region during initial data loading.

# Method 1: Uniform split (hex-based)
# Suitable when RowKeys start with hash prefixes
create 'events', 'data', SPLITS => [
  '10', '20', '30', '40', '50', '60', '70', '80', '90',
  'a0', 'b0', 'c0', 'd0', 'e0', 'f0'
]

# Method 2: HexStringSplit utility
# Evenly splits the hex key space into the specified number of Regions
create 'events', 'data', {NUMREGIONS => 16, SPLITALGO => 'HexStringSplit'}

# Method 3: UniformSplit (binary key uniform split)
create 'events', 'data', {NUMREGIONS => 32, SPLITALGO => 'UniformSplit'}

# Method 4: Custom split points file
# List split points in a file, one per line
create 'events', 'data', SPLITS_FILE => '/path/to/splits.txt'

Determining the initial Region count:

Recommended formula:
Initial Region count = Number of RegionServers × Target Regions per server (10-30)

Example:
- 10 RegionServer cluster
- Target 20 Regions per server
- Initial Region count = 10 × 20 = 200

Verify that the RowKey distribution actually results in even partitioning.

5.2 Key Distribution (Bucketing)

Prepends a fixed number of bucket numbers as prefixes to RowKeys, distributing writes across multiple Regions.

/**
 * Bucket-based RowKey distributor.
 * Distributes writes across N buckets while allowing reads
 * to directly access the correct Region by computing the bucket number.
 */
public class BucketedKeyStrategy {

    private final int numBuckets;

    public BucketedKeyStrategy(int numBuckets) {
        this.numBuckets = numBuckets;
    }

    /**
     * Bucket number is determined by the RowKey's hash,
     * so the same original key always maps to the same bucket.
     */
    public byte[] createKey(String entityId, long timestamp) {
        int bucket = Math.abs(entityId.hashCode() % numBuckets);
        String key = String.format("%04d_%s_%d", bucket, entityId, timestamp);
        return Bytes.toBytes(key);
    }

    /**
     * To query all data for a specific entity:
     * compute the bucket number for an efficient single Scan.
     */
    public Scan createEntityScan(String entityId) {
        int bucket = Math.abs(entityId.hashCode() % numBuckets);
        String prefix = String.format("%04d_%s_", bucket, entityId);

        Scan scan = new Scan();
        scan.withStartRow(Bytes.toBytes(prefix));
        scan.withStopRow(Bytes.toBytes(prefix + "~"));  // ~ is high in ASCII
        return scan;
    }

    /**
     * For scanning all data:
     * execute parallel Scans across all buckets.
     */
    public List<Scan> createFullScans() {
        List<Scan> scans = new ArrayList<>();
        for (int i = 0; i < numBuckets; i++) {
            String startPrefix = String.format("%04d_", i);
            String endPrefix = String.format("%04d_~", i);
            Scan scan = new Scan();
            scan.withStartRow(Bytes.toBytes(startPrefix));
            scan.withStopRow(Bytes.toBytes(endPrefix));
            scans.add(scan);
        }
        return scans;
    }
}

5.3 TTL-Based Data Partitioning

Automatically expiring old time-series data maintains consistent Region sizes and reduces Compaction load.

# Set TTL on Column Family (in seconds)
# Auto-delete after 90 days
alter 'sensor_data', {NAME => 'raw', TTL => 7776000}

# Keep aggregated data for 1 year
alter 'sensor_data', {NAME => 'agg', TTL => 31536000}
<!-- hbase-site.xml: Configuration linked with table-level TTL policies -->
<configuration>
    <!-- Clean up expired data during Major Compaction after MemStore flush -->
    <property>
        <name>hbase.hstore.compaction.min</name>
        <value>3</value>
    </property>

    <!-- Shorten Major Compaction interval for TTL-enabled tables -->
    <!-- to clean up expired data quickly (default 7 days → 1 day) -->
    <property>
        <name>hbase.hregion.majorcompaction</name>
        <value>86400000</value>
    </property>
</configuration>

5.4 Time-Based Table Partitioning

Storing data in separate tables per time period allows dropping entire old tables for fast cleanup without Compaction.

/**
 * Daily/monthly table partitioning strategy.
 * Includes dates in table names for lifecycle management.
 */
public class TimePartitionedTableStrategy {

    private final Connection connection;
    private final Admin admin;

    public TimePartitionedTableStrategy(Connection connection) throws IOException {
        this.connection = connection;
        this.admin = connection.getAdmin();
    }

    /**
     * Auto-create monthly tables.
     * Example: logs_202603, logs_202604
     */
    public Table getOrCreateMonthlyTable(String baseName, LocalDate date) throws IOException {
        String tableName = baseName + "_" + date.format(DateTimeFormatter.ofPattern("yyyyMM"));
        TableName tn = TableName.valueOf(tableName);

        if (!admin.tableExists(tn)) {
            TableDescriptorBuilder builder = TableDescriptorBuilder.newBuilder(tn);
            ColumnFamilyDescriptor cf = ColumnFamilyDescriptorBuilder
                .newBuilder(Bytes.toBytes("d"))
                .setCompressingAlgo(Algorithm.SNAPPY)
                .setBloomFilterType(BloomType.ROW)
                .setMaxVersions(1)
                .build();
            builder.setColumnFamily(cf);

            // Pre-split into 16 Regions
            byte[][] splits = RegionSplitter.HexStringSplit
                .split(16);
            admin.createTable(builder.build(), splits);
        }

        return connection.getTable(tn);
    }

    /**
     * Delete tables older than 90 days.
     * DROP TABLE completes instantly without Compaction, more efficient than TTL.
     */
    public void purgeOldTables(String baseName, int retentionDays) throws IOException {
        LocalDate cutoff = LocalDate.now().minusDays(retentionDays);
        String cutoffStr = cutoff.format(DateTimeFormatter.ofPattern("yyyyMM"));

        for (TableDescriptor td : admin.listTableDescriptors()) {
            String name = td.getTableName().getNameAsString();
            if (name.startsWith(baseName + "_")) {
                String datePart = name.substring(baseName.length() + 1);
                if (datePart.compareTo(cutoffStr) < 0) {
                    admin.disableTable(td.getTableName());
                    admin.deleteTable(td.getTableName());
                    System.out.println("Purged table: " + name);
                }
            }
        }
    }
}

6. Schema Design Patterns in Practice

6.1 Tall-Narrow vs Flat-Wide

This is the most important structural choice in HBase table design.

Tall-Narrow

Many rows with few columns each. Each event or measurement is stored as a separate row.

RowKey                              | d:value | d:type
────────────────────────────────────┼─────────┼────────
sensor001_9223370449055775807       | 72.5    | cpu
sensor001_9223370449055775808       | 71.3    | cpu
sensor001_9223370449055775809       | 73.1    | cpu

Flat-Wide

Few rows with a very large number of columns. All time-series data for one entity is encoded in Column Qualifiers.

RowKey       | d:20260308120000 | d:20260308120100 | d:20260308120200 | ...
─────────────┼──────────────────┼──────────────────┼──────────────────┤
sensor001    | 72.5             | 71.3             | 73.1             | (thousands of columns)

Comparison and selection criteria:

CriterionTall-NarrowFlat-Wide
Atomic row operationsAtomic per row onlyEntire time series is atomic
Scan efficiencyEasy time-range ScanFull retrieval with a single Get
Row size limitsNo limitLarge column counts cause Region Split issues
Delete efficiencyRow-level deletionColumn-level deletion required
Recommended useMost time-seriesSmall time-series, single-Get patterns

Tall-Narrow is generally recommended. HBase Scan performance scales with data size rather than row count, and Flat-Wide makes Region Split point determination difficult when individual rows grow too large.

6.2 Reverse Index (Secondary Index) Pattern

HBase only provides a built-in index on the RowKey. To search by other columns, you must maintain separate index tables.

/**
 * Multi-dimensional search support using reverse index tables.
 *
 * Main table: users (RowKey = user_id)
 * Index table: users_by_email (RowKey = email, value = user_id)
 * Index table: users_by_region (RowKey = region_user_id, value = "")
 */
public class SecondaryIndexManager {

    private final Table mainTable;
    private final Table emailIndex;
    private final Table regionIndex;

    /**
     * Update both main and index tables on data insertion.
     */
    public void putWithIndex(String userId, String email, String region,
                             Map<String, String> attributes) throws IOException {
        // 1. Insert data into main table
        Put mainPut = new Put(Bytes.toBytes(userId));
        mainPut.addColumn(Bytes.toBytes("info"), Bytes.toBytes("email"),
                          Bytes.toBytes(email));
        mainPut.addColumn(Bytes.toBytes("info"), Bytes.toBytes("region"),
                          Bytes.toBytes(region));
        for (Map.Entry<String, String> attr : attributes.entrySet()) {
            mainPut.addColumn(Bytes.toBytes("info"),
                              Bytes.toBytes(attr.getKey()),
                              Bytes.toBytes(attr.getValue()));
        }
        mainTable.put(mainPut);

        // 2. Update email index
        Put emailPut = new Put(Bytes.toBytes(email));
        emailPut.addColumn(Bytes.toBytes("idx"), Bytes.toBytes("uid"),
                           Bytes.toBytes(userId));
        emailIndex.put(emailPut);

        // 3. Update region index (composite key: region_userId)
        Put regionPut = new Put(Bytes.toBytes(region + "_" + userId));
        regionPut.addColumn(Bytes.toBytes("idx"), Bytes.toBytes(""),
                            Bytes.toBytes(""));
        regionIndex.put(regionPut);
    }

    /**
     * Lookup user by email: 2-hop lookup via index → main table.
     */
    public Result getUserByEmail(String email) throws IOException {
        Get indexGet = new Get(Bytes.toBytes(email));
        Result indexResult = emailIndex.get(indexGet);
        byte[] userId = indexResult.getValue(Bytes.toBytes("idx"),
                                             Bytes.toBytes("uid"));
        if (userId == null) return null;

        Get mainGet = new Get(userId);
        return mainTable.get(mainGet);
    }

    /**
     * Query all users in a specific region: Prefix Scan on index table.
     */
    public List<String> getUsersByRegion(String region) throws IOException {
        Scan scan = new Scan();
        scan.withStartRow(Bytes.toBytes(region + "_"));
        scan.withStopRow(Bytes.toBytes(region + "_~"));

        List<String> userIds = new ArrayList<>();
        try (ResultScanner scanner = regionIndex.getScanner(scan)) {
            for (Result r : scanner) {
                String rowKey = Bytes.toString(r.getRow());
                String userId = rowKey.substring(region.length() + 1);
                userIds.add(userId);
            }
        }
        return userIds;
    }
}

Operational considerations for reverse indexes:

6.3 Secondary Index with Phoenix

Apache Phoenix provides a SQL layer on top of HBase and automatically manages Secondary Indexes.

-- Table creation and index usage with Phoenix

-- Create table
CREATE TABLE IF NOT EXISTS users (
    user_id VARCHAR NOT NULL PRIMARY KEY,
    email VARCHAR,
    region VARCHAR,
    created_at TIMESTAMP,
    login_count BIGINT
) SALT_BUCKETS=16, COMPRESSION='SNAPPY';

-- Covered Index: includes additional columns to avoid main table lookups
CREATE INDEX idx_users_email ON users (email)
    INCLUDE (region, login_count);

-- Index is automatically used when querying by email
SELECT user_id, email, region, login_count
FROM users
WHERE email = 'user@example.com';

-- Query users by region
CREATE INDEX idx_users_region ON users (region, created_at DESC)
    INCLUDE (email);

SELECT user_id, email, created_at
FROM users
WHERE region = 'ap-northeast-2'
ORDER BY created_at DESC
LIMIT 100;

7. Performance Stabilization Operations

7.1 Compaction Strategy

Compaction is the background operation with the greatest impact on HBase performance. Poor management leads to write stalls, read performance degradation, and I/O storms.

Minor Compaction: Merges small HFiles. Retains delete markers (tombstones). Runs automatically and frequently.

Major Compaction: Merges all HFiles into one. Removes delete markers and expired data. Generates heavy I/O and must be managed carefully.

<!-- hbase-site.xml: Production Compaction settings -->
<configuration>
    <!-- Disable automatic Major Compaction -->
    <property>
        <name>hbase.hregion.majorcompaction</name>
        <value>0</value>
        <description>Set to 0 to disable automatic Major Compaction.
        Run manually via cron during off-peak hours.</description>
    </property>

    <!-- Minor Compaction trigger: runs when 3+ HFiles exist -->
    <property>
        <name>hbase.hstore.compactionThreshold</name>
        <value>3</value>
    </property>

    <!-- Maximum HFiles included in Minor Compaction -->
    <property>
        <name>hbase.hstore.compaction.max</name>
        <value>10</value>
    </property>

    <!-- Minimum HFile size for Compaction -->
    <property>
        <name>hbase.hstore.compaction.min.size</name>
        <value>134217728</value> <!-- 128MB -->
    </property>

    <!-- Compaction throttle: limit I/O to minimize service impact -->
    <property>
        <name>hbase.regionserver.throughput.controller</name>
        <value>org.apache.hadoop.hbase.regionserver.compactions.PressureAwareCompactionThroughputController</value>
    </property>
    <property>
        <name>hbase.hstore.compaction.throughput.lower.bound</name>
        <value>52428800</value> <!-- 50MB/s lower bound -->
    </property>
    <property>
        <name>hbase.hstore.compaction.throughput.higher.bound</name>
        <value>104857600</value> <!-- 100MB/s upper bound -->
    </property>
</configuration>

Running Major Compaction during off-peak hours (cron):

#!/bin/bash
# major_compaction_scheduler.sh
# crontab: 0 3 * * 0 /opt/hbase/scripts/major_compaction_scheduler.sh

TABLES=("metrics_table" "events_table" "logs_table")
LOG_FILE="/var/log/hbase/major_compaction_$(date +%Y%m%d).log"

echo "=== Major Compaction Start: $(date) ===" >> "$LOG_FILE"

for table in "${TABLES[@]}"; do
    echo "Compacting $table..." >> "$LOG_FILE"
    echo "major_compact '$table'" | hbase shell >> "$LOG_FILE" 2>&1

    # 1-hour interval between tables to distribute I/O load
    sleep 3600
done

echo "=== Major Compaction End: $(date) ===" >> "$LOG_FILE"

7.2 Region Split and Merge Management

Automatic Split policy tuning:

<configuration>
    <!-- Maximum Region size: auto-Split when this size is reached -->
    <property>
        <name>hbase.hregion.max.filesize</name>
        <value>10737418240</value> <!-- 10GB -->
    </property>

    <!-- Split policy selection -->
    <property>
        <name>hbase.regionserver.region.split.policy</name>
        <value>org.apache.hadoop.hbase.regionserver.SteppingSplitPolicy</value>
        <description>
        SteppingSplitPolicy: splits quickly when Region count is low,
        waits until max.filesize when Region count is sufficient.
        More stable than IncreasingToUpperBoundRegionSplitPolicy.
        </description>
    </property>
</configuration>

Manual Split and Merge:

# Manually split a hotspot Region
# First check the hotspot Region's encoded name and appropriate split key
hbase shell <<'EOF'
list_regions 'metrics_table'
EOF

# Split Region at a specific key
hbase shell <<'EOF'
split 'metrics_table', '08_sensor500'
EOF

# Region Merge: merge over-split small Regions
# In HBase 2.x, use the merge_region command in hbase shell
hbase shell <<'EOF'
merge_region 'ENCODED_REGION_NAME_1', 'ENCODED_REGION_NAME_2', true
EOF

7.3 BlockCache and BucketCache Configuration

Cache hit ratio is the key to read performance. BlockCache caches HFile data blocks in memory.

<configuration>
    <!-- On-heap BlockCache ratio (40% of heap) -->
    <property>
        <name>hfile.block.cache.size</name>
        <value>0.4</value>
    </property>

    <!-- Enable BucketCache: extend cache with off-heap memory -->
    <property>
        <name>hbase.bucketcache.ioengine</name>
        <value>offheap</value>
        <description>Choose from offheap, file:/path/to/cache, mmap:/path/to/cache</description>
    </property>

    <!-- BucketCache size (MB) -->
    <property>
        <name>hbase.bucketcache.size</name>
        <value>8192</value> <!-- 8GB Off-heap -->
    </property>

    <!-- CombinedBlockCache: On-heap(index/meta) + Off-heap(data) -->
    <property>
        <name>hbase.bucketcache.combinedcache.enabled</name>
        <value>true</value>
    </property>
</configuration>
# RegionServer JVM settings (hbase-env.sh)
# On-heap 32GB + Off-heap(BucketCache) 8GB
export HBASE_REGIONSERVER_OPTS="
  -Xmx32g -Xms32g
  -XX:MaxDirectMemorySize=10g
  -XX:+UseG1GC
  -XX:MaxGCPauseMillis=100
  -XX:G1HeapRegionSize=16m
  -XX:InitiatingHeapOccupancyPercent=65
"

Monitoring cache hit ratio:

# Check BlockCache hit ratio (JMX)
curl -s "http://regionserver:16030/jmx?qry=Hadoop:service=HBase,name=RegionServer,sub=Server" \
  | python3 -c "
import json, sys
data = json.load(sys.stdin)
for bean in data['beans']:
    hit = bean.get('blockCacheHitCount', 0)
    miss = bean.get('blockCacheMissCount', 0)
    total = hit + miss
    ratio = (hit / total * 100) if total > 0 else 0
    print(f'BlockCache Hit Ratio: {ratio:.1f}%')
    print(f'  Hits: {hit:,}')
    print(f'  Misses: {miss:,}')
    print(f'  Evictions: {bean.get(\"blockCacheEvictionCount\", 0):,}')
"
# Target: maintain 95%+ hit ratio

7.4 MemStore Tuning

MemStore is a write buffer that flushes to HFile when full. MemStore size and flush frequency directly affect write performance.

<configuration>
    <!-- Individual MemStore flush size -->
    <property>
        <name>hbase.hregion.memstore.flush.size</name>
        <value>134217728</value> <!-- 128MB -->
    </property>

    <!-- RegionServer total MemStore upper limit (40% of heap) -->
    <property>
        <name>hbase.regionserver.global.memstore.size</name>
        <value>0.4</value>
    </property>

    <!-- Force flush starts when total MemStore exceeds this ratio -->
    <property>
        <name>hbase.regionserver.global.memstore.size.lower.limit</name>
        <value>0.95</value>
    </property>

    <!-- Ensure MemStore + BlockCache does not exceed 80% of heap -->
    <!-- global.memstore.size(0.4) + block.cache.size(0.4) = 0.8 -->
</configuration>

8. Practical Checklist and Anti-Patterns

Design Phase Checklist

Operations Phase Checklist

Anti-Pattern Collection

Anti-Pattern 1: Timestamp at the beginning of RowKey

# Bad example
RowKey: 20260308120000_event_click
# → All latest writes concentrate on the last Region

# Correct alternative
RowKey: 0a_click_20260308120000  (salt + event_type + timestamp)

Anti-Pattern 2: Excessive Column Family count

# Bad example: 10 CFs
create 'user_profile', 'basic', 'contact', 'preference', 'history',
       'security', 'billing', 'social', 'activity', 'settings', 'cache'
# → Cascading flushes across CFs cause I/O storms, MemStore memory waste

# Correct alternative: consolidate to 2-3 CFs
create 'user_profile', \
  {NAME => 'i', VERSIONS => 1},   \  # info: basic + contact + settings
  {NAME => 'a', VERSIONS => 1, TTL => 7776000}  # activity: activity logs (90 days)

Anti-Pattern 3: Variable-length RowKey sorting issues

# Bad example: storing numbers as strings (beware lexicographic sorting)
"1", "10", "100", "2", "20", "3"Lexicographic order: 1 < 10 < 100 < 2

# Correct alternative: fixed-length padding
"001", "002", "003", "010", "020", "100"Correct order

Anti-Pattern 4: Sensitive information in RowKey

# Bad example: using email directly as RowKey
RowKey: user@example.com_20260308
# → RowKey is exposed in plaintext in WAL, HFile, and meta table

# Correct alternative: hash it
RowKey: sha256(user@example.com)_20260308

Anti-Pattern 5: Single Row too large

# Bad example: tens of thousands of columns in one Row (extreme Flat-Wide)
# → Cannot split the Region at this Row during Region Split
# → Single Row may exceed MemStore flush size

# Correct alternative: switch to Tall-Narrow or split Row by time
RowKey: entity001_20260308_00  (split Row by time units)
RowKey: entity001_20260308_01

Anti-Pattern 6: Scan without range specification

// Bad example: full table Scan
Scan scan = new Scan();
// → Traverses billions of rows, overloading RegionServer

// Correct alternative: explicitly restrict the range
Scan scan = new Scan();
scan.withStartRow(Bytes.toBytes("sensor001_"));
scan.withStopRow(Bytes.toBytes("sensor001_~"));
scan.setCaching(500);      // Rows returned per RPC
scan.setMaxResultSize(5 * 1024 * 1024);  // 5MB limit
scan.addColumn(Bytes.toBytes("d"), Bytes.toBytes("value"));  // Only needed columns

9. Conclusion

HBase data modeling is, without exaggeration, all about RowKey design. Here is a summary of the key takeaways:

  1. RowKey determines load distribution: Always avoid Sequential Keys; use Salting/Hashing/Bucketing for even distribution.
  2. Read patterns determine RowKey: Design RowKeys so the most frequent queries can be efficiently handled via Prefix Scan or Point Get.
  3. Default to Tall-Narrow: More stable than Flat-Wide for most workloads.
  4. Prevention is the best hotspot strategy: Block hotspots before they occur with Pre-split, Key distribution, and monitoring.
  5. Control Compaction: Disable automatic Major Compaction, run manually during off-peak hours, and apply I/O throttling.
  6. Defend cache hit ratio: Properly configure BlockCache + BucketCache and target 95%+ hit ratio.
  7. Fewer Column Families, shorter RowKeys: Maintain brevity for both storage efficiency and performance.

Proper data modeling delivers performance improvements greater than doubling your cluster size. Invest sufficient time in designing a single RowKey.

Quiz

Q1: What is the main topic covered in "HBase Data Modeling Playbook: Hotspot Avoidance and Performance Stabilization"?

A practical playbook for HBase RowKey design patterns, hotspot detection and avoidance, Region distribution optimization, and performance stabilization in large-scale deployments.

Q2: What is HBase Data Model Fundamentals? Core Components HBase's data model is fundamentally different from RDBMS. The following five elements constitute a single Cell. Physical Storage Structure HBase data appears as tables logically but is physically stored separately per Column Family.

Q3: Describe the RowKey Design Patterns. RowKey design determines 80% of HBase performance. You must consider read/write patterns, data distribution, and scan ranges. 3.1 Salting (Prefix Distribution) Salting prepends a hash-based prefix (salt) to the RowKey to distribute data evenly across multiple Regions.

Q4: What are the key aspects of Hotspot Causes and Detection? What Is a Hotspot A hotspot occurs when read or write requests are abnormally concentrated on a specific Region. While HBase is designed for horizontal scaling, a hotspot turns a single RegionServer's processing limit into the bottleneck for the entire cluster.

Q5: How does Hotspot Avoidance Strategies work? 5.1 Pre-splitting Pre-splitting Regions at table creation time prevents load concentration on a single Region during initial data loading.

Comments

No comments yet.

Sign in to leave a comment