The BoostServerTech Chat project stores every message in Redis, an in-memory data store that Rubén Pérez (@anarthal) knows will eventually need to be replaced for older messages. Here’s why and what the code looks like.
Chat messages have a specific access pattern: append-only, read backward (newest first), scoped to a room. Redis streams match this almost exactly. Each room (chat group) is a stream. Writing a message uses XADD, reading history employs XREVRANGE. Redis assigns each entry a unique, time-ordered ID, providing message ordering and cursor-based pagination for free, without schema migrations, indexing decisions, or ORM. While a SQL table could achieve this, the rapid message generation would overwhelm most SQL databases, necessitating serious performance tuning that Redis handles natively.
Storing a Message: When a user sends a message, the server appends it to the room's Redis stream. The * tells Redis to auto-assign a stream ID:
// Compose the request.
redis::request req;
for (const auto& msg : messages)
req.push("XADD", room_id, "*", "payload", serialize_redis_message(msg));
// Execute it.
redis::generic_response res;
error_code ec;
co_await conn_.async_exec(req, res, asio::redirect_error(ec));
Three noteworthy points:
- Multiple
XADDcommands get pushed into a singleredis::request. Boost.Redis pipelines them over one connection, so even if a client sends several messages at once, it’s one round trip. - This is a C++20 coroutine. The
co_awaitsuspends until Redis responds, but the thread is free to handle other work while waiting. XADDaccepts an arbitrary list of (key, value) string pairs. We are using a single key named “payload” that contains the message serialized as JSON, allowing arbitrary nesting.
Serialization without Boilerplate: Each message is stored as a JSON payload inside the stream entry. The wire format is a simple struct:
struct redis_wire_message {
std::string_view content;
std::int64_t timestamp;
std::int64_t user_id;
};
BOOST_DESCRIBE_STRUCT(redis_wire_message, (), (content, timestamp, user_id))
This BOOST_DESCRIBE_STRUCT macro registers the struct's members for compile-time reflection. Boost.JSON automatically picks it up: boost::json::value_from(msg) serializes it, boost::json::try_value_to<redis_wire_message>(jv) deserializes it, eliminating the need for hand-written to_json/from_json functions.
The Tradeoff: Redis keeps everything in memory, which makes it fast but poses an obvious problem. Currently, the server runs with Redis persistence enabled, so data survives restarts. However, as message volume grows, keeping the full history in RAM becomes impractical. The plan is to eventually offload old messages to MySQL for archival. The message layer is already isolated behind its own service interface, so swapping in a tiered storage strategy (recent messages from Redis, older ones from MySQL) touches just one component, leaving others unaffected.
But "eventually" involves many questions. The migration boundary is filled with considerations: Do you move messages after a time window? After a count threshold? Do you do it inline during reads, or as a background job? What happens to cursor-based pagination when the data lives in two places? If you’ve built a system that migrated data from a fast ephemeral store to a slower durable one, what triggered the migration and what surprised you about it? Rubén is keen to hear what actually worked.
Blogger's Review: This article provides a deep insight into the advantages of using Redis streams for chat message storage and the future migration strategy, showcasing how to leverage Boost libraries to simplify serialization while tackling challenges of high-concurrency writes. The author clearly identifies the limitations of in-memory storage and the necessity for future scalability, making it a valuable read for developers.