Processing user behavior data is challenging due to its high volume and diverse formats. Traditional wide table models offer efficient query performance but at the cost of high data redundancy, increased storage overhead, and slow maintenance cycles. This tutorial shows how to use Realtime Compute for Apache Flink, ApsaraDB for MongoDB, and Hologres to build a real-time wide table pipeline that handles these trade-offs.
How it works
Realtime Compute for Apache Flink handles stream processing. ApsaraDB for MongoDB stores the fact and dimension tables as a document-oriented NoSQL database with flexible schema and strong read/write throughput. Hologres serves as the analytical data warehouse — data is queryable immediately after it is written.
The pipeline uses two Flink jobs connected by a Kafka topic:
-
Job 1 reads the change data capture (CDC) stream from MongoDB. When the fact table (
game_sales) changes, its primary keys (PKs) are streamed directly to Kafka. When a dimension table changes, a lookup join identifies the affected fact table records and sends their PKs to Kafka. -
Job 2 consumes the PKs from Kafka, performs lookup joins against the MongoDB fact and dimension tables to reconstruct the full wide-table record, and upserts the result into Hologres.
Benefits:
-
High write throughput: ApsaraDB for MongoDB handles high-concurrency reads and writes in sharded clusters, scaling performance and storage to accommodate large volumes of frequent updates.
-
Efficient change propagation: Only the PKs of affected records are forwarded to Kafka, not the full rows. This keeps reprocessing minimal regardless of total data volume.
-
Real-time query: Hologres supports low-latency upserts and makes data immediately queryable after each write.
Hands-on
By the end of this tutorial, you will have a live pipeline where MongoDB changes — to both the fact table and dimension tables — automatically propagate to a Hologres wide table and are immediately queryable.
The pipeline joins three MongoDB collections into one Hologres wide table:
game_sales
<table> <thead> <tr> <td><p>sale_id</p></td> <td><p>game_id</p></td> <td><p>platform_id</p></td> <td><p>sale_date</p></td> <td><p>units_sold</p></td> <td><p>sale_amt</p></td> <td><p>status</p></td> </tr> </thead> <colgroup></colgroup> <colgroup></colgroup> <colgroup></colgroup> <colgroup></colgroup> <colgroup></colgroup> <colgroup></colgroup> <colgroup></colgroup> <tbody></tbody> </table>
game_dimension
<table> <thead> <tr> <td><p>game_id</p></td> <td><p>game_name</p></td> <td><p>release_date</p></td> <td><p>developer</p></td> <td><p>publisher</p></td> </tr> </thead> <colgroup></colgroup> <colgroup></colgroup> <colgroup></colgroup> <colgroup></colgroup> <colgroup></colgroup> <tbody></tbody> </table>
platform_dimension
<table> <thead> <tr> <td><p>platform_id</p></td> <td><p>platform_name</p></td> <td><p>type</p></td> </tr> </thead> <colgroup></colgroup> <colgroup></colgroup> <colgroup></colgroup> <tbody></tbody> </table>
game_sales_details
<table> <thead> <tr> <td><p>sale_id</p></td> <td><p>game_id</p></td> <td><p>platform_id</p></td> <td><p>sale_date</p></td> <td><p>units_sold</p></td> <td><p>sale_amt</p></td> <td><p>status</p></td> </tr> </thead> <colgroup></colgroup> <colgroup></colgroup> <colgroup></colgroup> <colgroup></colgroup> <colgroup></colgroup> <colgroup></colgroup> <colgroup></colgroup> <tbody> <tr> <td><p>game_name</p></td> <td><p>release_date</p></td> <td><p>developer</p></td> <td><p>publisher</p></td> <td><p>platform_name</p></td> <td><p>type</p></td> <td><p></p></td> </tr> </tbody> </table>
Change propagation flow
Each change flows through four stages:
-
Capture: Detect real-time changes from MongoDB dimension tables.
-
Propagate: When a dimension table changes, Job 1 performs a lookup join (for example, on
game_id) to find affected rows in the fact table and extracts their PKs (for example,sale_id). -
Trigger: Send the PKs to Kafka to notify Job 2 of pending refreshes.
-
Upsert: Job 2 fetches the latest data, reconstructs the wide table row, and upserts it into Hologres.
Prerequisites
Before you begin, ensure that you have:
-
A Realtime Compute for Apache Flink workspace running VVR 8.0.5 or later. For more information, see Activate Realtime Compute for Apache Flink.
-
An ApsaraDB for MongoDB instance running version 4.0 or later. For more information, see Create a sharded cluster instance.
-
A Hologres exclusive instance running version 1.3 or later. For more information, see Purchase a Hologres instance.
-
An ApsaraMQ for Kafka instance. For more information, see Deploy an ApsaraMQ for Kafka instance.
-
All four instances in the same Virtual Private Cloud (VPC). If they are in different VPCs, establish cross-VPC connectivity or enable internet access for Realtime Compute for Apache Flink. For more information, see How does Realtime Compute for Apache Flink access a service across VPCs? and How does Realtime Compute for Apache Flink access the Internet?
-
RAM user or RAM role permissions for the relevant resources.
Step 1: Prepare the data
Create MongoDB collections
-
Add the CIDR block of your Flink workspace to the MongoDB whitelist. For details, see Configure a whitelist for an instance and How do I configure a whitelist?
-
In the SQL editor of the Data Management (DMS) console, create the
mongo_testdatabase:use mongo_test; -
Create the
game_sales,game_dimension, andplatform_dimensioncollections and insert sample data:// Game sales table (status: 1 = active, 0 = logically deleted) db.game_sales.insert( [ {sale_id:0,game_id:101,platform_id:1,"sale_date":"2024-01-01",units_sold:500,sale_amt:2500,status:1}, ] ); // Game dimension table db.game_dimension.insert( [ {game_id:101,"game_name":"SpaceInvaders","release_date":"2023-06-15","developer":"DevCorp","publisher":"PubInc"}, {game_id:102,"game_name":"PuzzleQuest","release_date":"2023-07-20","developer":"PuzzleDev","publisher":"QuestPub"}, {game_id:103,"game_name":"RacingFever","release_date":"2023-08-10","developer":"SpeedCo","publisher":"RaceLtd"}, {game_id:104,"game_name":"AdventureLand","release_date":"2023-09-05","developer":"Adventure","publisher":"LandCo"}, ] ); // Platform dimension table db.platform_dimension.insert( [ {platform_id:1,"platform_name":"PCGaming","type":"PC"}, {platform_id:2,"platform_name":"PlayStation","type":"Console"}, {platform_id:3,"platform_name":"Mobile","type":"Mobile"} ] ); -
Verify the inserts:
db.game_sales.find(); db.game_dimension.find(); db.platform_dimension.find();The
db.game_dimension.find()query returns 4 records:-
game_id: 101,game_name: SpaceInvaders,release_date: 2023-06-15 -
game_id: 102,game_name: PuzzleQuest,release_date: 2023-07-20 -
game_id: 103,game_name: RacingFever,release_date: 2023-08-10 -
game_id: 104,game_name: AdventureLand,release_date: 2023-09-05
-
Create the Hologres table
-
Log on to the Hologres console, click Instances in the left navigation pane, and then click your Hologres instance. In the upper-right corner, click Connect to Instance.
-
In the top navigation bar, click Metadata Management > Create Database. Enter
testin the Database Name field, set Policy to SPM, and click OK. For more information, see Create a database.In the dialog box, select the instance named User-behavior-test, set Log On Immediately to Yes, and click OK.
-
In the top navigation bar, click SQL Editor. Click the SQL icon to create a new SQL query, select the target instance and database, and run the following statement to create the
game_sales_detailswide table:CREATE TABLE game_sales_details( sale_id INT not null primary key, game_id INT, platform_id INT, sale_date VARCHAR(50), units_sold INT, sale_amt INT, status INT, game_name VARCHAR(50), release_date VARCHAR(50), developer VARCHAR(50), publisher VARCHAR(50), platform_name VARCHAR(50), type VARCHAR(50) );
Create the Kafka topic
-
Log on to the ApsaraMQ for Kafka console. Click Instances in the left navigation pane, and click your instance.
-
In the left navigation pane, click Whitelist Management and add the CIDR block of your Flink workspace.
-
In the left navigation pane, click Topics > Create Topic. In the right pane, enter
game_sales_factin the Name field, enter a description, and keep the default values for all other fields. Click OK.
Step 2: Create stream jobs
Job 1: Write primary keys to Kafka
Job 1 monitors all three MongoDB collections. When game_sales changes, sale_id is streamed directly to Kafka. When a dimension table changes, a lookup join against game_sales retrieves the affected sale_id values, which are then streamed to Kafka.
The MongoDB connector serves two roles in this pipeline. In Job 1, it acts as a CDC source, reading the MongoDB change stream. In Job 2, it acts as a lookup source, fetching the current state of each document by primary key. Both roles use the same connector configuration.
-
Log on to the Realtime Compute for Apache Flink console.
-
In the Actions column of your workspace, click Console.
-
In the left navigation menu, click Development > ETL.
-
Click New Blank Stream Draft.
-
In the New Draft dialog, enter
dwd_mongo_kafkain Name, select an engine version, and click Create. -
Copy the following SQL into the editor. Each of the three
INSERTstatements independently captures changes from one MongoDB collection and streams the affectedsale_idvalues to the Kafka sink. This ensures Hologres is updated accurately and in real time whenever any table changes. Store sensitive values such as connection strings and passwords as variables rather than hardcoding them. For more information, see Manage variables.Time game_dimensionstate forgame_id = 101T1 game_name = "SpaceInvaders"T2 Updated to game_name = "SpaceInvaders_v2"-- Source: game_sales (CDC stream) CREATE TEMPORARY TABLE game_sales ( `_id` STRING, -- MongoDB auto-generated ID sale_id INT, -- Sales ID PRIMARY KEY (_id) NOT ENFORCED ) WITH ( 'connector' = 'mongodb', 'uri' = '${secret_values.MongoDB-URI}', 'database' = 'mongo_test', 'collection' = 'game_sales' ); -- Source: game_dimension (CDC stream) CREATE TEMPORARY TABLE game_dimension ( `_id` STRING, game_id INT, PRIMARY KEY (_id) NOT ENFORCED ) WITH ( 'connector' = 'mongodb', 'uri' = '${secret_values.MongoDB-URI}', 'database' = 'mongo_test', 'collection' = 'game_dimension' ); -- Source: platform_dimension (CDC stream) CREATE TEMPORARY TABLE platform_dimension ( `_id` STRING, platform_id INT, PRIMARY KEY (_id) NOT ENFORCED ) WITH ( 'connector' = 'mongodb', 'uri' = '${secret_values.MongoDB-URI}', 'database' = 'mongo_test', 'collection' = 'platform_dimension' ); -- Lookup source: game_sales (used for dimension-change joins) CREATE TEMPORARY TABLE game_sales_dim ( `_id` STRING, sale_id INT, game_id INT, platform_id INT, PRIMARY KEY (_id) NOT ENFORCED ) WITH ( 'connector' = 'mongodb', 'uri' = '${secret_values.MongoDB-URI}', 'database' = 'mongo_test', 'collection' = 'game_sales' ); -- Sink: Kafka topic that stores affected PKs CREATE TEMPORARY TABLE game_sales_fact ( sale_id INT, PRIMARY KEY (sale_id) NOT ENFORCED ) WITH ( 'connector' = 'upsert-kafka', 'properties.bootstrap.servers' = '${secret_values.Kafka-hosts}', 'topic' = 'game_sales_fact', 'key.format' = 'json', 'value.format' = 'json', 'properties.enable.idempotence' = 'false' -- Required when writing to ApsaraMQ for Kafka ); BEGIN STATEMENT SET; -- Stream PKs from game_sales changes INSERT INTO game_sales_fact (sale_id) SELECT sale_id FROM game_sales; -- Stream PKs of game_sales rows affected by game_dimension changes INSERT INTO game_sales_fact (sale_id) SELECT gs.sale_id FROM game_dimension AS gd JOIN game_sales_dim FOR SYSTEM_TIME AS OF PROCTIME() AS gs ON gd.game_id = gs.game_id; -- Stream PKs of game_sales rows affected by platform_dimension changes INSERT INTO game_sales_fact (sale_id) SELECT gs.sale_id FROM platform_dimension AS pd JOIN game_sales_dim FOR SYSTEM_TIME AS OF PROCTIME() AS gs ON pd.platform_id = gs.platform_id; END;About lookup joins The
FOR SYSTEM_TIME AS OF PROCTIME()clause defines a lookup join (a type of temporal join). At the moment a source row is processed, the join fetches the matching dimension table row from MongoDB and freezes that snapshot for the join result. If the dimension table is updated later, already-processed rows are not affected. For example: Agame_salesrow processed at T1 joins with"SpaceInvaders". A row processed at T2 joins with"SpaceInvaders_v2". The join condition isgd.game_id = gs.game_idandpd.platform_id = gs.platform_id. For more information, see JOIN statements for dimension tables and Choose Kafka, Upsert Kafka, or Kafka JSON catalog. -
In the upper-right corner, click Deploy. In the dialog, click Confirm. For more information, see Deploy a job.
Job 2: Reconstruct the wide table and upsert to Hologres
Job 2 consumes sale_id values from the game_sales_fact Kafka topic, performs lookup joins against the MongoDB fact and dimension tables, and upserts the resulting wide-table rows into Hologres.
Follow the steps in Job 1 to create a new draft named dws_kafka_mongo_holo and deploy it with the following SQL:
-- Source: Kafka topic that provides affected PKs
CREATE TEMPORARY TABLE game_sales_fact
(
sale_id INT,
PRIMARY KEY (sale_id) NOT ENFORCED
)
WITH (
'connector' = 'upsert-kafka',
'properties.bootstrap.servers' = '${secret_values.Kafka-hosts}',
'topic' = 'game_sales_fact',
'key.format' = 'json',
'value.format' = 'json',
'properties.group.id' = 'game_sales_fact',
'properties.auto.offset.reset' = 'earliest'
);
-- Lookup source: game_sales fact table
CREATE TEMPORARY TABLE game_sales
(
`_id` STRING,
sale_id INT,
game_id INT,
platform_id INT,
sale_date STRING,
units_sold INT,
sale_amt INT,
status INT,
PRIMARY KEY (_id) NOT ENFORCED
)
WITH (
'connector' = 'mongodb',
'uri' = '${secret_values.MongoDB-URI}',
'database' = 'mongo_test',
'collection' = 'game_sales'
);
-- Lookup source: game_dimension
CREATE TEMPORARY TABLE game_dimension
(
`_id` STRING,
game_id INT,
game_name STRING,
release_date STRING,
developer STRING,
publisher STRING,
PRIMARY KEY (_id) NOT ENFORCED
)
WITH (
'connector' = 'mongodb',
'uri' = '${secret_values.MongoDB-URI}',
'database' = 'mongo_test',
'collection' = 'game_dimension'
);
-- Lookup source: platform_dimension
CREATE TEMPORARY TABLE platform_dimension
(
`_id` STRING,
platform_id INT,
platform_name STRING,
type STRING,
PRIMARY KEY (_id) NOT ENFORCED
)
WITH (
'connector' = 'mongodb',
'uri' = '${secret_values.MongoDB-URI}',
'database' = 'mongo_test',
'collection' = 'platform_dimension'
);
-- Sink: Hologres wide table
CREATE TEMPORARY TABLE IF NOT EXISTS game_sales_details
(
sale_id INT,
game_id INT,
platform_id INT,
sale_date STRING,
units_sold INT,
sale_amt INT,
status INT,
game_name STRING,
release_date STRING,
developer STRING,
publisher STRING,
platform_name STRING,
type STRING,
PRIMARY KEY (sale_id) NOT ENFORCED
)
WITH (
'connector' = 'hologres',
'dbname' = 'test',
'tablename' = 'public.game_sales_details',
'username' = '${secret_values.AccessKeyID}',
'password' = '${secret_values.AccessKeySecret}',
'endpoint' = '${secret_values.Hologres-endpoint}',
'sink.delete-strategy' = 'IGNORE_DELETE', -- Insert or update only; never delete rows
'sink.on-conflict-action' = 'INSERT_OR_UPDATE', -- Enable partial column updates
'sink.partial-insert.enabled' = 'true'
);
INSERT INTO game_sales_details (
sale_id, game_id, platform_id, sale_date, units_sold, sale_amt, status,
game_name, release_date, developer, publisher, platform_name, type
)
SELECT
gsf.sale_id,
gs.game_id,
gs.platform_id,
gs.sale_date,
gs.units_sold,
gs.sale_amt,
gs.status,
gd.game_name,
gd.release_date,
gd.developer,
gd.publisher,
pd.platform_name,
pd.type
FROM game_sales_fact AS gsf
JOIN game_sales FOR SYSTEM_TIME AS OF PROCTIME() AS gs
ON gsf.sale_id = gs.sale_id
JOIN game_dimension FOR SYSTEM_TIME AS OF PROCTIME() AS gd
ON gs.game_id = gd.game_id
JOIN platform_dimension FOR SYSTEM_TIME AS OF PROCTIME() AS pd
ON gs.platform_id = pd.platform_id;
Step 3: Start the jobs
-
In the Development Console, choose O&M > Deployments and start both job deployments.
-
After both jobs reach the Running state, go to HoloWeb and query the
game_sales_detailstable:SELECT * FROM game_sales_details;The initial row seeded in Step 1 appears in the result.
The query returns one record with the following field values:
-
sale_id: 0 -
game_id: 0 -
platform_id: 101 -
sale_date: 2024-01-01 -
units_sold: 500 -
sale_amt: 2500 -
status: 1 -
game_name: SpaceInvaders -
release_date: 2023-06-15
-
Step 4: Update and query data
Changes to game_sales and the dimension tables in MongoDB propagate to Hologres automatically. The following examples demonstrate each update type.
Fact table updates
-
Insert five more rows into
game_sales:db.game_sales.insert( [ {sale_id:1,game_id:101,platform_id:1,"sale_date":"2024-01-01",units_sold:500,sale_amt:2500,status:1}, {sale_id:2,game_id:102,platform_id:2,"sale_date":"2024-08-02",units_sold:400,sale_amt:2000,status:1}, {sale_id:3,game_id:103,platform_id:1,"sale_date":"2024-08-03",units_sold:300,sale_amt:1500,status:1}, {sale_id:4,game_id:101,platform_id:3,"sale_date":"2024-08-04",units_sold:200,sale_amt:1000,status:1}, {sale_id:5,game_id:104,platform_id:2,"sale_date":"2024-08-05",units_sold:100,sale_amt:3000,status:1} ] );Query
game_sales_detailsin Hologres. Five new rows appear.The query result table contains
game_name(such as SpaceInvaders, PuzzleQuest, RacingFever, and AdventureLand) andrelease_datecolumns in addition to the fields synchronized from MongoDB, showing the associated game details. -
Update
sale_datefrom2024-01-01to2024-08-01:db.game_sales.updateMany({"sale_date": "2024-01-01"}, {$set: {"sale_date": "2024-08-01"}});Query
game_sales_details. Thesale_datecolumn reflects the new value. -
Logically delete the row where
sale_id = 5by settingstatusto0:db.game_sales.updateMany({"sale_id": 5}, {$set: {"status": 0}});Query
game_sales_details. Thestatuscolumn forsale_id = 5changes to0.
Dimension table updates
-
Add new games and platforms to the dimension tables:
// New games db.game_dimension.insert( [ {game_id:105,"game_name":"HSHWK","release_date":"2024-08-20","developer":"GameSC","publisher":"GameSC"}, {game_id:106,"game_name":"HPBUBG","release_date":"2018-01-01","developer":"BLUE","publisher":"KK"} ] ); // New platforms db.platform_dimension.insert( [ {platform_id:4,"platform_name":"Steam","type":"PC"}, {platform_id:5,"platform_name":"Epic","type":"PC"} ] );Inserting into dimension tables alone does not trigger synchronization — the pipeline is driven by changes to
game_sales. Insert corresponding sales records to trigger the wide table update:db.game_sales.insert( [ {sale_id:6,game_id:105,platform_id:4,"sale_date":"2024-09-01",units_sold:400,sale_amt:2000,status:1}, {sale_id:7,game_id:106,platform_id:1,"sale_date":"2024-09-01",units_sold:300,sale_amt:1500,status:1} ] );Query
game_sales_details. Two new rows appear with the enriched dimension data. -
Update dimension data in MongoDB:
// Update release date db.game_dimension.updateMany({"release_date": "2018-01-01"}, {$set: {"release_date": "2024-01-01"}}); // Update platform type db.platform_dimension.updateMany({"type": "PC"}, {$set: {"type": "Swich"}});The updated fields propagate to the corresponding rows in Hologres.