Build a feed stream system for tens of millions of users

Updated at:
Copy as MD

Build a feed stream system that supports tens of millions of TPS/QPS based on the Tablestore Timeline model. This system handles user following, message publishing, and feed stream reading.

Solution overview

Feed streams are common in social media and content recommendation. After a user publishes a message, the system distributes the message to all followers and displays it in their feed streams in chronological order. Key challenges include high-concurrency message writes and reads (read-to-write ratio typically exceeds 10:1), dynamic user relationships, and guaranteed message delivery.

The Tablestore Timeline model separates messages into a store (outbox) and a sync (inbox), and synchronizes them with a combined push-pull approach. When a message is published, the system pushes it to all followers' inboxes (push mode). When reading a feed stream, the system scans the inbox sequentially by auto-increment sequence ID (pull mode). With auto-increment columns and search indexes, this architecture supports tens of millions of TPS/QPS.

Solution design

This solution uses five data tables:

Table

Primary key

Description

feed_user_table

user_id (String)

User information table. Stores user attributes such as username, email, and gender.

feed_user_relation_table

main_user + sub_user (String)

User relationship table. Records follow relationships. main_user is the follower, and sub_user is the followed user.

feed_timeline_store_table

timeline_id + sequence_id (auto-increment)

Message store (outbox). Stores all messages published by users. Includes a search index that supports queries by sender, time, and content.

feed_timeline_sync_table

timeline_id + sequence_id (auto-increment)

Message sync (inbox). Stores message copies pushed to followers. Feed stream reads scan from this table.

feed_timeline_meta_table

timeline_id (String)

Timeline metadata table. Stores timeline type and associated user information. Includes a search index.

Quick start

Try the feed stream system with either of the following methods:

  • ROS one-click deployment: Open the one-click deployment template and follow the on-screen instructions to complete the deployment. After deployment, go to the Outputs tab of the stack to get the Swagger API documentation URL. From there, test user registration, following, feed publishing, and feed stream reading online.

  • Run the sample code locally: Download the sample code feed.demo.zip (a Spring Boot project). Configure the instance information in application.yml, and set access credentials in the OTS_AK_ENV and OTS_SK_ENV environment variables. Tables are created automatically on the first run.

The sample code provides the following REST APIs:

Method

Path

Description

POST

/users/register

Register a user.

POST

/users/follow

Follow a user.

POST

/users/unfollow

Unfollow a user.

POST

/feeds/post

Publish a message.

GET

/feeds/get

Read the subscribed feed stream.

GET

/feeds/getUserPosts

View messages published by a specific user.

Implementation

If you already have an instance, skip this step. Otherwise, activate Tablestore and create an instance before you begin.

Note

The following examples use the Timeline model library in Tablestore SDK for Java. Before you begin, install and configure Tablestore SDK for Java.

Step 1: Initialize the Timeline model

Use the Timeline SDK to initialize the store (outbox), sync (inbox), and meta store separately. Configure a search index for the store to support queries by sender and message content.

TimelineStoreFactory serviceFactory = new TimelineStoreFactoryImpl(syncClient);

// Initialize store (outbox): stores all published messages
TimelineIdentifierSchema idSchema = new TimelineIdentifierSchema.Builder()
        .addStringField("timeline_id").build();

IndexSchema timelineIndex = new IndexSchema();
timelineIndex.addFieldSchema(new FieldSchema("sender", FieldType.KEYWORD)
        .setIndex(true).setEnableSortAndAgg(true).setStore(true));
timelineIndex.addFieldSchema(new FieldSchema("send_time", FieldType.LONG)
        .setIndex(true).setEnableSortAndAgg(true).setStore(true));
timelineIndex.addFieldSchema(new FieldSchema("text", FieldType.TEXT)
        .setIndex(true).setStore(true).setAnalyzer(FieldSchema.Analyzer.MaxWord));

TimelineSchema storeSchema = new TimelineSchema("feed_timeline_store_table", idSchema)
        .autoGenerateSeqId()
        .setTimeToLive(-1)
        .withIndex("feed_timeline_store_index", timelineIndex);

TimelineStore store = serviceFactory.createTimelineStore(storeSchema);

// Initialize sync (inbox): receives pushed messages from followed users
TimelineSchema syncSchema = new TimelineSchema("feed_timeline_sync_table", idSchema)
        .autoGenerateSeqId()
        .setTimeToLive(-1);

TimelineStore sync = serviceFactory.createTimelineStore(syncSchema);

// Initialize meta: stores timeline metadata
IndexSchema metaIndex = new IndexSchema();
metaIndex.setFieldSchemas(Arrays.asList(
        new FieldSchema("type", FieldType.KEYWORD)
                .setIndex(true).setEnableSortAndAgg(true).setStore(true),
        new FieldSchema("user", FieldType.TEXT)
                .setIndex(true).setStore(true).setAnalyzer(FieldSchema.Analyzer.MaxWord)
));

TimelineMetaSchema metaSchema = new TimelineMetaSchema("feed_timeline_meta_table", idSchema)
        .withIndex("feed_timeline_meta_index", metaIndex);

TimelineMetaStore metaStore = serviceFactory.createMetaStore(metaSchema);

// Create all tables (call once during initialization)
store.prepareTables();
sync.prepareTables();
metaStore.prepareTables();

Step 2: Follow a user

Call the PutRow operation of the base SDK to write a record to the user relationship table and establish a follow relationship.

public void follow(String userA, String userB) {
    PrimaryKey primaryKey = PrimaryKeyBuilder.createPrimaryKeyBuilder()
            .addPrimaryKeyColumn("main_user", PrimaryKeyValue.fromString(userA))
            .addPrimaryKeyColumn("sub_user", PrimaryKeyValue.fromString(userB))
            .build();

    RowPutChange rowPutChange = new RowPutChange("feed_user_relation_table", primaryKey);
    rowPutChange.addColumn("timestamp", ColumnValue.fromLong(System.currentTimeMillis()));

    syncClient.putRow(new PutRowRequest(rowPutChange));
}

Step 3: Publish a message

To publish a message, write it to the sender's store (outbox) first, then push it to all followers' inboxes (sync). This is the push operation in the combined push-pull model.

public void sendMessage(String userId, Message message, List<String> followers) {
    // 1. Write to sender's store (outbox)
    TimelineIdentifier identifier = new TimelineIdentifier.Builder()
            .addField("timeline_id", message.getTimelineId())
            .build();
    store.createTimelineQueue(identifier).store(message.getTimelineMessage());

    // 2. Push to each follower's sync (inbox)
    for (String friend : followers) {
        TimelineIdentifier friendId = new TimelineIdentifier.Builder()
                .addField("timeline_id", friend)
                .build();
        sync.createTimelineQueue(friendId).store(message.getTimelineMessage());
    }
}

Step 4: Read the feed stream

To read the feed stream, scan messages from the sync library (inbox) sequentially by auto-increment sequence ID. This is the pull operation in the combined push-pull model. The client records the lastSequenceId from the previous read and resumes scanning from that position in the next request.

public List<Post> fetchFeed(String userId, long lastSequenceId) {
    TimelineIdentifier identifier = new TimelineIdentifier.Builder()
            .addField("timeline_id", userId)
            .build();

    ScanParameter parameter = new ScanParameter()
            .scanForward(lastSequenceId)
            .maxCount(30);

    Iterator<TimelineEntry> iterator = sync.createTimelineQueue(identifier).scan(parameter);

    List<Post> posts = new LinkedList<>();
    int counter = 0;
    while (iterator.hasNext() && counter++ <= 30) {
        TimelineEntry entry = iterator.next();
        TimelineMessage msg = entry.getMessage();

        Post post = new Post()
                .setName(msg.getString("sender"))
                .setTime(msg.getLong("send_time"))
                .setContent(msg.getString("text"))
                .setSequenceId(entry.getSequenceID());
        posts.add(post);
    }
    return posts;
}

To view all messages published by a specific user (user profile page), scan from the store instead of the sync library. The code structure is the same.

Resource cleanup

Note

Delete the following resources when you no longer need them to avoid unnecessary charges.

Clean up resources in the following order:

  1. Use the Timeline SDK to delete the store, sync, and meta store (including tables and indexes).

  2. Delete the user table and relationship table.

  3. (Optional) If you created a new instance for this solution, release the instance.

If you used the ROS one-click deployment, delete the stack in the ROS console to automatically clean up all resources.

// Clean up Timeline tables and indexes
store.dropAllTables();
sync.dropAllTables();
metaStore.dropAllTables();

// Clean up user and relation tables
syncClient.deleteTable(new DeleteTableRequest("feed_user_table"));
syncClient.deleteTable(new DeleteTableRequest("feed_user_relation_table"));