Implementing Stream Joins
1. Understanding Join Types
| Join Type | Description | Use Case | Example |
|---|---|---|---|
| Stream-Stream Join | Joins two Kafka streams based on a common key. | Real-time data aggregation from multiple sources. | |
| Stream-Table Join | Joins a stream with a Kafka table ( changelog ) based on key. | Enriching stream data with static or slowly changing data. | |
| Table-Table Join | Joins two Kafka tables (changelog) on a key. | Combining static data sources for analytics. | |
2. Using Stream-Stream Join
A stream-stream join combines two Kafka streams based on a common key and allows for processing of data from both streams in a single operation.
| Property | Description | Values |
|---|---|---|
| join.window.ms | Time window for joining streams | 30000 |
| left.stream | Left input stream name | user-events |
| right.stream | Right input stream name | user-profiles |
| join.key | Key used for joining streams | user_id |
Example: Stream-Stream Join Configuration
const kafka = new Kafka({ brokers: ['localhost:9092'] });
const consumer = kafka.consumer();
const producer = kafka.producer();
// Define the streams
const userEventsStream = consumer.subscribe({ topic: 'user-events' });
const userProfilesStream = consumer.subscribe({ topic: 'user-profiles' });
// Perform the join operation
const joinedStream = Streams.join(
userEventsStream,
userProfilesStream,
(event, profile) => {
return { ...event, ...profile };
},
{ window: 30000 }
);
// Process joined data
joinedStream.forEach((message) => {
console.log('Joined message:', message);
});
3. Using Stream-Table Join
A stream-table join combines a continuous stream of events with a changelog table (e.g., a configuration or lookup table) to produce a new stream of results. This is useful for enriching streaming data with static or slowly-changing reference data.
| Property | Description | Example |
|---|---|---|
| stream | The continuous stream to join with the table | ordersStream |
| table | The changelog table to join with the stream | customerTable |
| joinKey | The field used to match records between stream and table | customerId |
| outputStream | The resulting stream after the join operation | enrichedOrdersStream |
Example: Stream-Table Join in Kafka Streams
StreamsBuilder builder = new StreamsBuilder();
KStream<String, Order> ordersStream = builder.stream("orders");
KTable<String, Customer> customerTable = builder.table("customers");
KStream<String, EnrichedOrder> enrichedOrders = ordersStream.join(
customerTable,
(orderId, order) -> order.getCustomerId(),
(order, customer) -> new EnrichedOrder(order, customer)
);
enrichedOrders.to("enriched-orders");
joinKey must be present in both the stream and table records to perform a successful join.
4. Using Table-Table Join
A table-table join in Kafka Streams allows you to join two Kafka topics that represent tables (i.e., they contain key-value records where keys are unique). This type of join is useful for enriching data from one topic with data from another.
| Property | Description | Values |
|---|---|---|
| joinType | Specifies the type of join to perform | INNER, LEFT, RIGHT, FULL |
| table1 | Name of the first Kafka topic (table) | orders |
| table2 | Name of the second Kafka topic (table) | customers |
| keyField | Field used for joining records | customerId |
Example: Table-Table Join
KTable<String, Order> orders = builder.table("orders");
KTable<String, Customer> customers = builder.table("customers");
KTable<String, OrderWithCustomer> joined = orders.join(
customers,
(order, customer) -> new OrderWithCustomer(order, customer),
JoinWindows.of(Duration.ofHours(1)),
Joined.with(Serdes.String(), new OrderSerde(), new CustomerSerde())
);
Key Considerations
- Both tables must have a common key field for joining.
- Join windows should be set to accommodate data latency and out-of-order events.
- Use appropriate Serdes for serialization/deserialization of keys and values.
5. Implementing Inner Join
An inner join in Kafka Streams combines records from two streams based on a common key, emitting only those records that have matching keys in both streams.
| Property | Description | Example |
|---|---|---|
| join.window.ms | Time window in milliseconds for joining records. | 30000 |
| left.stream | Stream to join from the left side. | user-events |
| right.stream | Stream to join from the right side. | user-profiles |
Example: Inner Join Implementation
StreamsBuilder builder = new StreamsBuilder();
KStream<String, UserEvent> userEvents = builder.stream("user-events");
KStream<String, UserProfile> userProfiles = builder.stream("user-profiles");
KStream<String, JoinedUser> joinedStream = userEvents.join(
userProfiles,
(event, profile) -> new JoinedUser(event.getUserId(), event.getEvent(), profile.getName()),
JoinWindows.of(Duration.ofMinutes(5)),
StreamJoined.with(Serdes.String(), new UserEventSerde(), new UserProfileSerde())
);
joinedStream.to("joined-output");
6. Implementing Left Join
| Property | Description | Default Value | Notes |
|---|---|---|---|
| join.window.ms | Time window for joining records from left and right streams | 60000 |
Milliseconds |
| left.stream | Name of the left stream in join operation | null |
Required field |
| right.stream | Name of the right stream in join operation | null |
Required field |
Example: Left Join Configuration
const leftStream = streams.left;
const rightStream = streams.right;
const joinedStream = KStream.leftJoin(
leftStream,
rightStream,
(leftValue, rightValue) => {
return {
id: leftValue.id,
leftData: leftValue.data,
rightData: rightValue.data
};
},
JoinWindows.of(TimeWindows.of(60000))
);
7. Implementing Outer Join
An outer join in Kafka Streams combines records from two streams based on a common key, including all records from both streams even if there's no match in the other stream. This is useful for scenarios where you want to preserve all data, even if some records don't have corresponding entries in the other stream.
| Property | Description | Example |
|---|---|---|
| outerJoin | Method to perform an outer join between two KStream objects | kstream1.outerJoin(kstream2, ...) |
| joinWindow | Defines the time window for joining records | JoinWindows.of(TimeWindows.of(Duration.ofMinutes(5))) |
| leftJoin | Performs a left outer join, preserving all records from the left stream | kstream1.leftJoin(kstream2, ...) |
| rightJoin | Performs a right outer join, preserving all records from the right stream | kstream1.rightJoin(kstream2, ...) |
Example: Performing an Outer Join
KStream<String, String> leftStream = builder.stream("left-topic");
KStream<String, String> rightStream = builder.stream("right-topic");
KStream<String, String> joinedStream = leftStream.outerJoin(
rightStream,
(leftValue, rightValue) -> leftValue + "|" + rightValue,
JoinWindows.of(TimeWindows.of(Duration.ofMinutes(5))),
StreamJoined.with(Serdes.String(), Serdes.String(), Serdes.String())
);
8. Configuring Join Windows
| Property | Default Value | Description |
|---|---|---|
| join.window.ms | 60000 |
Join window duration in milliseconds. Controls how long to wait for matching records from the other stream. |
| join.window.start | 0 |
Start time of the join window in milliseconds. Used for windowed joins with fixed windows. |
| join.window.end | 0 |
End time of the join window in milliseconds. Used for windowed joins with fixed windows. |
Example: Setting Join Window Duration
StreamsBuilder builder = new StreamsBuilder();
KStream<String, String> stream1 = builder.stream("topic1");
KStream<String, String> stream2 = builder.stream("topic2");
KStream<String, String> joinedStream = stream1.join(stream2,
(value1, value2) -> value1 + " - " + value2,
JoinWindows.of(Duration.ofMinutes(5))
);
9. Setting Join Retention
| Property | Default Value | Description |
|---|---|---|
| retention.ms | 604800000 |
Retention period for join results in milliseconds (7 days) |
| retention.bytes | -1 |
Maximum size of join results in bytes (-1 = unlimited) |
10. Understanding Join Semantics
| Join Type | Description | Use Case | Notes |
|---|---|---|---|
| Stream-Stream Join | Joins two Kafka streams based on a common key. | Real-time event processing with correlated data sources. | Requires both streams to be keyed by the same field. |
| Stream-Table Join | Joins a stream with a changelog table (KTable) based on key. | Enriching stream events with static or slowly changing data. | Supports left joins and inner joins; updates in table propagate to joined stream. |
| Table-Table Join | Joins two changelog tables (KTables) on a shared key. | Combining static data sources for real-time analytics. | Supports inner joins and left joins; updates in either table propagate to result. |
Example: Stream-Stream Join
KStream<String, Order> orders = builder.stream("orders");
KStream<String, Customer> customers = builder.stream("customers");
KStream<String, OrderCustomer> joined = orders.join(customers,
(order, customer) -> new OrderCustomer(order, customer),
JoinWindows.of(Duration.ofHours(1)),
StreamJoined.with(Serdes.String(), orderSerde, customerSerde));
joined.to("order-customer-joins");
11. Optimizing Join Performance
| Configuration | Value | Description |
|---|---|---|
| join.window.ms | 60000 |
Window size for stream joins in milliseconds |
| cache.max.bytes.buffering | 1048576 |
Maximum number of bytes to buffer per partition |
| max.task.idle.ms | 0 |
Maximum idle time for a task before it's considered stale |
Example: Configuring Join Window Size
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "join-app");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(StreamsConfig.JOIN_WINDOW_MS_CONFIG, 30000L); // 30 seconds
join.window.ms too low can cause data loss due to late-arriving records.
join.window.ms values can increase memory usage and latency.