Optimizing Search Performance with CQRS: Relational Writes and NoSQL Reads

Most developers start with a single relational database. It works well until your search requirements grow beyond simple primary key lookups. Once you introduce complex filtering, full-text search, and high-concurrency read patterns, that ACID-compliant relational engine starts to crawl. The solution isn’t a bigger server; it’s a different architecture.

I’ve tried many alternatives: spending time optimizing the database index, trying to optimize the controllers in the backend, trying to add some cache in the server (good luck managing the invalidations). But nothing really worked.

I have found that Command Query Responsibility Segregation (CQRS) is the most effective way to handle this. By splitting the “write” path from the “read” path, you stop forcing a single tool to be good at two diametrically opposed tasks.

Scaling Search Systems with CQRS Architecture

CQRS separates the data model for updates (Commands) from the data model for lookups (Queries). In a search-heavy system, this usually means using a Relational Database (RDBMS) like PostgreSQL for writes and a NoSQL store or Search Engine (like Elasticsearch or MongoDB) for reads.

The write side focuses on data integrity and ACID compliance. You want to ensure that when a user updates their profile or creates an order, the transaction is atomic and consistent. Normalization is your friend here because it prevents data anomalies.

The read side focuses on retrieval speed. Here, you should ignore normalization entirely. Data duplication is a strategic choice, not a mistake. You are pre-calculating the views your users need so the database doesn’t have to do expensive joins at runtime.

The Command Side: Prioritizing ACID and Data Integrity

Your relational database remains the single source of truth. When an action occurs, the system validates the business logic and persists the change.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductService {
private final ProductRepository productRepository;
private final EventBus eventBus;
public ProductService(ProductRepository productRepository, EventBus eventBus) {
this.productRepository = productRepository;
this.eventBus = eventBus;
}
@Transactional
public void updateProductStock(Long productId, int quantity) {
Product product = productRepository.findByIdForUpdate(productId)
.orElseThrow(() -> new IllegalArgumentException("Product not found"));
if (product.getStock() < quantity) {
throw new IllegalStateException("Insufficient stock");
}
product.setStock(product.getStock() - quantity);
productRepository.save(product);
eventBus.publish("product_updated", new ProductUpdatedEvent(productId, product.getStock()));
}
}

The primary goal of the write side is to protect the state of your application. If the search index lags by 200ms, the world keeps turning. If your inventory count is wrong because of a race condition, you have a business crisis.

The Query Side: High-Performance NoSQL Search

On the read side, you want a flat structure. If a user searches for a “Red Nike Shoe,” the query should hit a single document that already contains the brand name, the color, and the current price—no joins required.

NoSQL stores are designed for horizontal scaling and rapid document retrieval. By denormalizing your relational data into a document format, you move the computational cost from the “read” phase to the “write” phase.

# Example: Indexing a denormalized product document in a search engine
curl -X POST "http://search-engine:9200/products/_doc/101" -H 'Content-Type: application/json' -d'
{
"id": "101",
"name": "Air Max",
"brand": "Nike",
"color": "Red",
"categories": ["Footwear", "Running"],
"price": 120.00,
"in_stock": true
}'

Managing Eventual Consistency and Data Synchronization

The biggest hurdle with CQRS is keeping the two stores in sync. This introduces eventual consistency. In my experience, developers worry about eventual consistency far more than users actually notice it.

You typically synchronize using one of two methods:

  • Application-level updates: Your service writes to the RDBMS and then immediately sends an update to the NoSQL store.
  • Change Data Capture (CDC): A tool like Debezium monitors your database logs and automatically streams changes to your read store via a message broker.

Use a message queue (RabbitMQ/Kafka) to decouple these systems. This ensures that if your search engine is briefly offline, the update isn’t lost—it’s simply retried.

Actionable Takeaways

  1. Don’t over-normalize your search index. If a search result needs the author’s name, store the author’s name in the document. Do not look it up at query time.
  2. Monitor your lag. Track the time difference between an RDBMS commit and the NoSQL update. If it stays under 500ms, it is usually invisible to the user.
  3. Implement a “read-your-own-writes” strategy. If a user updates a record, consider updating the local UI state immediately while the backend synchronization finishes in the background.
  4. Accept the storage cost. Storage is cheap; CPU cycles and user patience are expensive. Duplicating data to gain performance is a standard architectural trade-off at scale.

Discover more from The Dev World – Sergio Lema

Subscribe to get the latest posts sent to your email.


Comments

Leave a comment