Powrót do strony głównej

Flink real-time JOIN: tabele 4+2 TB

Artykuł opisuje implementację real-time INNER JOIN między tabelami terabajtowymi users i domains na Apache Flink. Używany jest Table API do odczytu CDC z Kafka i KeyedCoProcessFunction z MapState dla relacji one-to-many. Zapewniona jest aktualizacja wyniku O(1).

Real-time JOIN 6 TB tabel na Flink: pełna implementacja
Advertisement 728x90

# Implementacja real-time JOIN dla dużych tabel w Apache Flink: podstawowe podejście

Należy zapewnić aktualizację wyniku INNER JOIN między tabelą users (4 TB) i domains (2 TB) w czasie rzeczywistym. Zapytanie zwraca user_id, firstname, lastname i domain_name. Tradycyjne widoki obciążają źródło danych (SI), a prosta materializacja przez CDC w DWH nie zapewnia wymaganej prędkości.

Przykład danych:

users:

Google AdInline article slot

| id | firstname | lastname |

|----|-----------|----------|

| 1 | Egor | Myasnik |

Google AdInline article slot

| 2 | Pavel | Hvastun |

| 3 | Mitya | Volk |

domains:

Google AdInline article slot

| id | user_id | domain_name |

|----|---------|-------------|

| 1 | 1 | Approval |

| 2 | 1 | Rejection |

| 3 | 1 | Stoppage |

| 4 | 3 | Cancellation|

Analiza alternatyw

  • Bezpośredni widok na SI — niedopuszczalne ze względu na obciążenie systemu OLTP.
  • CDC → Kafka → DWH — odciąża SI, ale prędkość odczytu z DWH jest podobna do wyjściowej. Dodaje dwie warstwy: Kafka i widok.

Oba podejścia nie zapewniają aktualizacji wyniku JOIN w czasie rzeczywistym.

Architektura w Flink

Rozwiązanie: strumienie CDC do Kafka → Flink Table API → stanowy JOIN → sink (konsola dla MVP).

Zależności Maven

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-connector-kafka</artifactId>
    <version>3.2.0-1.19</version>
</dependency>
<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-sql-client</artifactId>
    <version>1.19</version>
</dependency>
<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-java</artifactId>
    <version>${flink.version}</version>
</dependency>
<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-table-planner-loader</artifactId>
    <version>${flink.version}</version>
</dependency>

Modele danych

public class User implements Serializable {
    public Integer id;
    public String firstname;
    public String lastname;
    // getters/setters
    public static User fromRow(Row row) { /* mapper */ }
}

public class Domain implements Serializable {
    public Integer id;
    public Integer user_id;
    public String domain_name;
    // getters/setters
    public static Domain fromRow(Row row) { /* mapper */ }
}

Tworzenie tabel Kafka

tableEnv.executeSql("CREATE TABLE users (" +
    "`before` ROW<id: INT, firstname: STRING, lastname: STRING>," +
    "`op` STRING," +
    "`after` ROW<id: INT, firstname: STRING, lastname: STRING>," +
    ") WITH (" +
    "'connector' = 'kafka'," +
    "'topic' = 'users_topic'," +
    "'properties.bootstrap.servers' = 'kafka-brokers'," +
    "'properties.group.id' = 'users_consumer_group'," +
    "'scan.startup.mode' = 'earliest'");

DataStream<User> users = tableEnv.toDataStream(tableEnv.from("users")).map(User::fromRow);

// Similarly for domains
tableEnv.executeSql("CREATE TABLE domains (...)");
DataStream<Domain> domains = tableEnv.toDataStream(tableEnv.from("domains")).map(Domain::fromRow);

Implementacja stanowego JOIN

Połączenie strumieni po kluczu user_id == domain.user_id z użyciem KeyedCoProcessFunction:

users
    .connect(domains)
    .keyBy(
        user -> user.id,
        domain -> domain.user_id
    )
    .process(new Join1())
    .print();

KeyedCoProcessFunction

public class Join1 extends KeyedCoProcessFunction<Integer, User, Domain, Output> {
    private MapState<Integer, Domain> domainsState;
    private ValueState<User> usersState;

    @Override
    public void processElement1(User user, Context ctx, Collector<Output> out) throws Exception {
        usersState.update(user);
        for (Domain domain : domainsState.values()) {
            out.collect(new Output(user.id, user.firstname, user.lastname, domain.domain_name));
        }
    }

    @Override
    public void processElement2(Domain domain, Context ctx, Collector<Output> out) throws Exception {
        domainsState.put(domain.id, domain);
        User user = usersState.value();
        if (user != null) {
            out.collect(new Output(user.id, user.firstname, user.lastname, domain.domain_name));
        }
    }

    @Override
    public void open(OpenContext openContext) throws Exception {
        usersState = getRuntimeContext().getState(new ValueStateDescriptor<"users", User.class>);
        domainsState = getRuntimeContext().getMapState(
            new MapStateDescriptor<"domains", Integer.class, Domain.class>);
    }

    public static class Output implements Serializable {
        public Integer user_id;
        public String firstname, lastname, domain_name;
        // constructors/getters/setters
    }
}

MapState wybrano dla domains ze względu na relację one-to-many. Operacje na stanie — O(1).

Co ważne

  • Aktualizacje w czasie rzeczywistym: wynik JOIN jest aktualny po nadejściu zdarzeń CDC.
  • Przetwarzanie stanowe: ValueState dla users (1:1), MapState dla domains (1:N).
  • Skalowalność: przetestowane na terabajtowych wolumenach z sinkiem OLAP.
  • Wydajność: O(1) dostęp do stanu, niska latencja.
  • Następne kroki: obsługa DELETE/UPDATE, RocksDB, balansowanie skew.

Zalecenia dla produkcji

  • Używaj sinka OLAP (ClickHouse, Pinot) zamiast konsoli.
  • Obsługuj flagi op z CDC dla poprawnych UPDATE/DELETE.
  • Wdroż RocksDB jako backend stanu przy dużych wolumenach.
  • Monitoruj skew kluczy i rozdziel partycje.
  • Testuj skalowanie na klastrze.

— Editorial Team

Advertisement 728x90

Czytaj dalej