This is an automated email from the ASF dual-hosted git repository.
wombatu-kun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new bc8434219a50 perf(flink): reduce RocksDB lookup cache overhead (#19980)
bc8434219a50 is described below
commit bc8434219a5064d56d36b2ff43daab839163a9ce
Author: Danny Chan <[email protected]>
AuthorDate: Fri Sep 18 10:50:27 2026 +0800
perf(flink): reduce RocksDB lookup cache overhead (#19980)
---
.../hudi/common/util/collection/RocksDBDAO.java | 52 +++++++++++----
.../common/util/collection/TestRocksDBDAO.java | 52 +++++++++++++++
.../hudi/table/lookup/HoodieLookupFunction.java | 1 +
.../org/apache/hudi/table/lookup/LookupCache.java | 7 ++
.../hudi/table/lookup/RocksDBLookupCache.java | 74 ++++++++++++++--------
.../table/lookup/TestHoodieLookupFunction.java | 9 ++-
.../hudi/table/lookup/TestRocksDBLookupCache.java | 54 ++++++++++++++++
7 files changed, 209 insertions(+), 40 deletions(-)
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/util/collection/RocksDBDAO.java
b/hudi-common/src/main/java/org/apache/hudi/common/util/collection/RocksDBDAO.java
index 904f71abb64a..2ec55280b514 100644
---
a/hudi-common/src/main/java/org/apache/hudi/common/util/collection/RocksDBDAO.java
+++
b/hudi-common/src/main/java/org/apache/hudi/common/util/collection/RocksDBDAO.java
@@ -52,7 +52,6 @@ import java.io.Serializable;
import java.net.URI;
import java.util.ArrayList;
import java.util.Iterator;
-import java.util.LinkedList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@@ -380,24 +379,44 @@ public class RocksDBDAO {
* @param <T> Type of value stored
*/
public <T extends Serializable> Stream<Pair<String, T>> prefixSearch(String
columnFamilyName, String prefix) {
+ List<Pair<String, T>> results = new ArrayList<>();
+ this.<T, RuntimeException>prefixSearch(columnFamilyName, prefix, (key,
value) -> results.add(Pair.of(key, value)));
+ return results.stream();
+ }
+
+ /**
+ * Visits matching entries synchronously without collecting them in memory.
The iterator is
+ * closed before returning, including when the handler throws an exception.
+ *
+ * @param columnFamilyName Column family name
+ * @param prefix Prefix key
+ * @param handler Handler invoked once per matching entry, in key order
+ * @param <T> Type of value stored
+ * @param <E> Type of exception thrown by the handler
+ */
+ public <T extends Serializable, E extends Exception> void prefixSearch(
+ String columnFamilyName, String prefix, PrefixSearchHandler<T, E>
handler) throws E {
ValidationUtils.checkArgument(!closed);
- final HoodieTimer timer = HoodieTimer.start();
- long timeTakenMicro = 0;
- List<Pair<String, T>> results = new LinkedList<>();
+ final boolean debug = log.isDebugEnabled();
+ final HoodieTimer timer = debug ? HoodieTimer.start() : null;
+ long count = 0;
try (final RocksIterator it =
getRocksDB().newIterator(managedHandlesMap.get(columnFamilyName))) {
it.seek(getUTF8Bytes(prefix));
- while (it.isValid() && fromUTF8Bytes(it.key()).startsWith(prefix)) {
- long beginTs = System.nanoTime();
- T val = deserializePayload(columnFamilyName, it.value());
- timeTakenMicro += ((System.nanoTime() - beginTs) / 1000);
- results.add(Pair.of(fromUTF8Bytes(it.key()), val));
+ while (it.isValid()) {
+ String key = fromUTF8Bytes(it.key());
+ if (!key.startsWith(prefix)) {
+ break;
+ }
+ handler.accept(key, deserializePayload(columnFamilyName, it.value()));
+ count++;
it.next();
}
}
- log.info("Prefix Search for (query={}) on {}. Total Time Taken (msec)={}.
Serialization Time taken(micro)={}, num entries={}",
- prefix, columnFamilyName, timer.endTimer(), timeTakenMicro,
results.size());
- return results.stream();
+ if (debug) {
+ log.debug("Prefix Search for (query={}) on {}. Total Time Taken
(msec)={}, num entries={}",
+ prefix, columnFamilyName, timer.endTimer(), count);
+ }
}
/**
@@ -614,4 +633,13 @@ public class RocksDBDAO {
void apply(WriteBatch batch);
}
+
+ /**
+ * Handler for a prefix search that may propagate a checked exception to the
caller.
+ */
+ @FunctionalInterface
+ public interface PrefixSearchHandler<T, E extends Exception> {
+
+ void accept(String key, T value) throws E;
+ }
}
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/util/collection/TestRocksDBDAO.java
b/hudi-common/src/test/java/org/apache/hudi/common/util/collection/TestRocksDBDAO.java
index 6c4a3f7cf56c..28a1a442aadb 100644
---
a/hudi-common/src/test/java/org/apache/hudi/common/util/collection/TestRocksDBDAO.java
+++
b/hudi-common/src/test/java/org/apache/hudi/common/util/collection/TestRocksDBDAO.java
@@ -18,6 +18,7 @@
package org.apache.hudi.common.util.collection;
+import org.apache.hudi.common.serialization.CustomSerializer;
import org.apache.hudi.common.table.view.FileSystemViewStorageConfig;
import lombok.Value;
@@ -28,6 +29,7 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.io.File;
+import java.io.IOException;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
@@ -42,6 +44,7 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -49,6 +52,8 @@ import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -164,6 +169,53 @@ public class TestRocksDBDAO {
assertFalse(new File(rocksDBBasePath).exists());
}
+ @Test
+ public void testPrefixSearchHandler() throws IOException {
+ String family = "prefix_handler";
+ AtomicInteger deserialized = new AtomicInteger();
+ ConcurrentHashMap<String, CustomSerializer<?>> serializers = new
ConcurrentHashMap<>();
+ serializers.put(family, new CustomSerializer<byte[]>() {
+ @Override
+ public byte[] serialize(byte[] value) {
+ return value;
+ }
+
+ @Override
+ public byte[] deserialize(byte[] bytes) {
+ deserialized.incrementAndGet();
+ return bytes;
+ }
+ });
+ RocksDBDAO dao = new RocksDBDAO("/prefix-handler",
dbManager.getRocksDBBasePath(), serializers);
+ try {
+ dao.addColumnFamily(family);
+ dao.put(family, "key_1", new byte[] {1});
+ dao.put(family, "key_2", new byte[] {2});
+ dao.put(family, "key_other", new byte[] {3});
+ dao.put(family, "other", new byte[] {4});
+ List<String> keys = new ArrayList<>();
+ dao.<byte[], IOException>prefixSearch(family, "key_", (key, value) -> {
+ keys.add(key);
+ assertEquals(keys.size(), deserialized.get(), "Values must be consumed
during the scan");
+ assertEquals(keys.size(), value[0]);
+ });
+ assertEquals(Arrays.asList("key_1", "key_2", "key_other"), keys);
+ assertEquals(keys, dao.prefixSearch(family,
"key_").map(Pair::getKey).collect(Collectors.toList()));
+ dao.prefixSearch(family, "missing", (key, value) -> {
+ throw new AssertionError("No entries should match");
+ });
+ IOException failure = new IOException("handler failed");
+ deserialized.set(0);
+ assertSame(failure, assertThrows(IOException.class, () ->
dao.prefixSearch(family, "key_", (key, value) -> {
+ throw failure;
+ })));
+ assertEquals(1, deserialized.get(), "A failed handler must stop the scan
immediately");
+ assertEquals(4, dao.prefixSearch(family, "").count());
+ } finally {
+ dao.close();
+ }
+ }
+
@Test
public void testWithSerializableKey() {
String prefix1 = "prefix1_";
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/HoodieLookupFunction.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/HoodieLookupFunction.java
index 7b02f9da826b..605cbd354937 100644
---
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/HoodieLookupFunction.java
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/HoodieLookupFunction.java
@@ -165,6 +165,7 @@ public class HoodieLookupFunction extends LookupFunction
implements Serializable
cache.addRow(key, rowData);
}
}
+ cache.flush();
currentCommit = latestCommitInstant.get();
scheduleNextLoad();
log.info("Loaded {} row(s) into lookup join cache", count);
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/LookupCache.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/LookupCache.java
index 93e99c913e7c..6a5542f49d8e 100644
---
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/LookupCache.java
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/LookupCache.java
@@ -44,6 +44,13 @@ public interface LookupCache extends Closeable {
*/
void addRow(RowData key, RowData row) throws IOException;
+ /**
+ * Flushes buffered writes after loading the cache. Implementations must
also make buffered
+ * rows visible to {@link #getRows(RowData)} before an explicit flush.
+ */
+ default void flush() throws IOException {
+ }
+
/**
* Returns all rows matching the given lookup key, or {@code null} / empty
list if none exist.
*
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/RocksDBLookupCache.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/RocksDBLookupCache.java
index 4bb41dfcc9a6..cbe8940ce4e6 100644
---
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/RocksDBLookupCache.java
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/lookup/RocksDBLookupCache.java
@@ -19,6 +19,8 @@
package org.apache.hudi.table.lookup;
import org.apache.hudi.common.serialization.CustomSerializer;
+import org.apache.hudi.common.util.StringUtils;
+import org.apache.hudi.common.util.collection.Pair;
import org.apache.hudi.common.util.collection.RocksDBDAO;
import lombok.extern.slf4j.Slf4j;
@@ -33,7 +35,6 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
-import java.util.stream.Collectors;
/**
* Off-heap {@link LookupCache} backed by RocksDB.
@@ -68,6 +69,7 @@ public class RocksDBLookupCache implements LookupCache {
private RocksDBDAO rocksDBDAO;
private long rowCounter;
+ private final WriteBuffer writeBuffer = new WriteBuffer();
public RocksDBLookupCache(
TypeSerializer<RowData> keySerializer,
@@ -77,39 +79,33 @@ public class RocksDBLookupCache implements LookupCache {
this.rowSerializer = rowSerializer;
this.rocksDbBasePath = rocksDbBasePath;
this.rocksDBDAO = createDAO();
- this.rowCounter = 0L;
}
@Override
public void addRow(RowData key, RowData row) throws IOException {
- String keyHex = serializeKeyToHex(key);
- String compoundKey = keyHex + KEY_SEPARATOR + rowCounter++;
- byte[] valueBytes = serializeRow(row);
- rocksDBDAO.put(COLUMN_FAMILY, compoundKey, valueBytes);
+ String compoundKey = serializeKeyToHex(key) + KEY_SEPARATOR + rowCounter++;
+ writeBuffer.add(rocksDBDAO, compoundKey, serializeRow(row));
+ }
+
+ @Override
+ public void flush() {
+ writeBuffer.flush(rocksDBDAO);
}
@Override
@Nullable
public List<RowData> getRows(RowData key) throws IOException {
+ flush();
String prefix = serializeKeyToHex(key) + KEY_SEPARATOR;
- List<byte[]> rawValues = rocksDBDAO.<byte[]>prefixSearch(COLUMN_FAMILY,
prefix)
- .map(pair -> pair.getValue())
- .collect(Collectors.toList());
- if (rawValues.isEmpty()) {
- return null;
- }
- List<RowData> result = new ArrayList<>(rawValues.size());
- for (byte[] bytes : rawValues) {
- result.add(deserializeRow(bytes));
- }
- return result;
+ List<RowData> result = new ArrayList<>();
+ rocksDBDAO.<byte[], IOException>prefixSearch(COLUMN_FAMILY, prefix,
+ (storedKey, bytes) -> result.add(deserializeRow(bytes)));
+ return result.isEmpty() ? null : result;
}
@Override
public void clear() {
- if (rocksDBDAO != null) {
- rocksDBDAO.close();
- }
+ close();
rocksDBDAO = createDAO();
rowCounter = 0L;
log.debug("RocksDB lookup cache cleared and reinitialized at {}",
rocksDbBasePath);
@@ -117,6 +113,7 @@ public class RocksDBLookupCache implements LookupCache {
@Override
public void close() {
+ writeBuffer.clear();
if (rocksDBDAO != null) {
rocksDBDAO.close();
rocksDBDAO = null;
@@ -138,7 +135,7 @@ public class RocksDBLookupCache implements LookupCache {
private String serializeKeyToHex(RowData key) throws IOException {
keyOutputBuffer.clear();
keySerializer.serialize(key, keyOutputBuffer);
- return bytesToHex(keyOutputBuffer.getCopyOfBuffer());
+ return StringUtils.toHexString(keyOutputBuffer.getCopyOfBuffer());
}
private byte[] serializeRow(RowData row) throws IOException {
@@ -152,12 +149,37 @@ public class RocksDBLookupCache implements LookupCache {
return rowSerializer.deserialize(rowInputBuffer);
}
- private static String bytesToHex(byte[] bytes) {
- StringBuilder sb = new StringBuilder(bytes.length * 2);
- for (byte b : bytes) {
- sb.append(String.format("%02x", b));
+ /**
+ * Pending serialized writes and their byte count, reset together after a
successful flush
+ * or when the cache is discarded.
+ */
+ private static class WriteBuffer {
+ private static final int MAX_ROWS = 1024;
+ private static final int MAX_BYTES = 1024 * 1024;
+
+ private final List<Pair<String, byte[]>> entries = new ArrayList<>();
+ private long sizeInBytes;
+
+ private void add(RocksDBDAO dao, String key, byte[] value) {
+ entries.add(Pair.of(key, value));
+ sizeInBytes += key.length() + (long) value.length;
+ if (entries.size() >= MAX_ROWS || sizeInBytes >= MAX_BYTES) {
+ flush(dao);
+ }
+ }
+
+ private void flush(RocksDBDAO dao) {
+ if (!entries.isEmpty()) {
+ dao.writeBatch(batch -> entries.forEach(
+ entry -> dao.putInBatch(batch, COLUMN_FAMILY, entry.getKey(),
entry.getValue())));
+ clear();
+ }
+ }
+
+ private void clear() {
+ entries.clear();
+ sizeInBytes = 0;
}
- return sb.toString();
}
/**
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/lookup/TestHoodieLookupFunction.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/lookup/TestHoodieLookupFunction.java
index e2af300219cd..08097d3015ea 100644
---
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/lookup/TestHoodieLookupFunction.java
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/lookup/TestHoodieLookupFunction.java
@@ -29,6 +29,8 @@ import org.apache.flink.table.data.RowData;
import org.apache.flink.table.data.StringData;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import java.io.File;
import java.io.IOException;
@@ -77,9 +79,12 @@ class TestHoodieLookupFunction {
}
}
- @Test
- void testLookupCacheDoesNotReloadWhenCompletedCommitHasNotChanged() throws
Exception {
+ @ParameterizedTest
+ @ValueSource(strings = {"heap", "rocksdb"})
+ void testLookupCacheDoesNotReloadWhenCompletedCommitHasNotChanged(String
cacheType) throws Exception {
Configuration conf = getConf();
+ conf.set(FlinkOptions.LOOKUP_JOIN_CACHE_TYPE, cacheType);
+ conf.set(FlinkOptions.LOOKUP_JOIN_ROCKSDB_PATH, new File(tempFile,
"rocksdb").getAbsolutePath());
TestData.writeData(TestData.DATA_SET_SINGLE_INSERT, conf);
CountingLookupTableReader reader = new
CountingLookupTableReader(TestData.DATA_SET_SINGLE_INSERT, conf);
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/lookup/TestRocksDBLookupCache.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/lookup/TestRocksDBLookupCache.java
index 95b805f0809d..8d8a505912db 100644
---
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/lookup/TestRocksDBLookupCache.java
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/lookup/TestRocksDBLookupCache.java
@@ -28,9 +28,14 @@ import org.apache.flink.table.types.logical.RowType;
import org.apache.flink.table.types.logical.VarCharType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import java.io.File;
+import java.util.Collections;
import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -76,6 +81,55 @@ class TestRocksDBLookupCache {
cache.close();
}
+ @ParameterizedTest
+ @ValueSource(ints = {1, 1024, 2051})
+ void testBatchedWrites(int rowCount) throws Exception {
+ RowType keyType = RowType.of(new VarCharType(VarCharType.MAX_LENGTH));
+ RowType rowType = RowType.of(new VarCharType(VarCharType.MAX_LENGTH), new
IntType());
+ try (RocksDBLookupCache cache = new RocksDBLookupCache(
+ InternalSerializers.create(keyType),
InternalSerializers.create(rowType), tempFile.getAbsolutePath())) {
+ RowData lookupKey = key("维度");
+ GenericRowData reuse = GenericRowData.of(StringData.fromString("维度"), 0);
+ for (int i = 0; i < rowCount; i++) {
+ reuse.setField(1, i);
+ cache.addRow(lookupKey, reuse);
+ }
+ reuse.setField(1, -1);
+ List<RowData> rows = cache.getRows(lookupKey);
+ assertEquals(IntStream.range(0,
rowCount).boxed().collect(Collectors.toList()),
+ rows.stream().map(row ->
row.getInt(1)).sorted().collect(Collectors.toList()));
+
+ cache.addRow(lookupKey, row("维度", rowCount));
+ cache.flush();
+ cache.flush();
+ assertEquals(rowCount + 1, cache.getRows(lookupKey).size());
+
+ // Clear must discard both persisted rows and the unflushed tail of a
load.
+ cache.addRow(key("pending"), row("pending", 1));
+ cache.clear();
+ assertNull(cache.getRows(lookupKey));
+ assertNull(cache.getRows(key("pending")));
+ cache.addRow(lookupKey, row("维度", -1));
+ assertEquals(-1, cache.getRows(lookupKey).get(0).getInt(1));
+ }
+ }
+
+ @Test
+ void testLargeRows() throws Exception {
+ RowType keyType = RowType.of(new VarCharType(VarCharType.MAX_LENGTH));
+ RowType rowType = RowType.of(new VarCharType(VarCharType.MAX_LENGTH), new
IntType());
+ String value = String.join("", Collections.nCopies(1024 * 1024, "x"));
+ try (RocksDBLookupCache cache = new RocksDBLookupCache(
+ InternalSerializers.create(keyType),
InternalSerializers.create(rowType), tempFile.getAbsolutePath())) {
+ cache.addRow(key("large"), row(value, 1));
+ cache.addRow(key("large"), row("tail", 2));
+ List<RowData> rows = cache.getRows(key("large"));
+ assertEquals(2, rows.size());
+ assertEquals(value, rows.get(0).getString(0).toString());
+ assertEquals("tail", rows.get(1).getString(0).toString());
+ }
+ }
+
private static RowData key(String key) {
return GenericRowData.of(StringData.fromString(key));
}