Event Sourcing (ES) combined with Command Query Responsibility Segregation (CQRS) has become the architectural backbone for many high-scalability systems. While the read-side benefits are well-documented, the write-side often presents a significant bottleneck: the sequential writing of events to the storage backend. When millions of commands arrive simultaneously, naive implementations can suffer from transaction contention, lock escalation, and I/O saturation. In this post, we explore advanced strategies to optimize these write paths, focusing on batching and append-only mechanics.
The Challenge of Write Contention
In a standard Event Sourcing implementation, every command results in one or more domain events that must be persisted. If each event triggers a separate database transaction, the overhead of network round-trips and transaction logging can cripple throughput. Furthermore, optimistic concurrency control (OCC) requires comparing versions during every write. If multiple commands target the same aggregate simultaneously, they may fail validation and need to be retried, leading to thundering herd problems.
To achieve high throughput, we must shift from row-by-row insertion to batched, append-only operations. This approach minimizes locking overhead and maximizes I/O efficiency.
High-Throughput Batching Strategies
Batching events reduces the number of round trips to the database. Instead of committing events individually, the application collects events in a local buffer and flushes them in bulk. However, naive batching can introduce latency. The key is balancing batch size against real-time requirements.
A common pattern involves a background worker that monitors an in-memory queue. When the queue reaches a certain size or time threshold, the worker commits the batch as a single atomic unit. This ensures that either all events in the batch are persisted or none are, maintaining data consistency without sacrificing performance.
// Pseudo-code for a high-throughput event bus
class EventBatcher:
def __init__(self, batch_size=100, flush_interval_ms=50):
self.buffer = []
self.batch_size = batch_size
self.flush_interval = flush_interval_ms
def append(self, event):
self.buffer.append(event)
if len(self.buffer) >= self.batch_size:
self.flush()
def flush(self):
if not self.buffer:
return
# Begin a single database transaction for the entire batch
try:
with db.transaction():
for event in self.buffer:
db.insert("events", event)
db.commit()
except IntegrityError:
# Handle concurrency conflicts here
retry_buffer(self.buffer)
finally:
self.buffer = []
This strategy significantly reduces the transaction log overhead because the database only needs to write one commit record for dozens or hundreds of events. However, developers must be cautious: batching increases the blast radius of a failure. If a batch fails, all events in that batch must be rolled back, potentially requiring complex retry logic.
Append-Only Storage Mechanics
Event Sourcing is inherently append-only. Unlike traditional relational databases that update existing rows (which can cause page splits and fragmentation), appending new records allows for sequential I/O, which is far faster than random writes on most storage media.
To leverage this, consider using append-only log structures or databases designed for sequential writes, such as Apache Kafka, Amazon Kinesis, or specialized event stores like EventStoreDB. These systems are optimized for high write throughput by separating the write path from the read path entirely.
When implementing append-only logs, ensure that your database engine supports efficient partitioning. Partitioning by aggregate ID or tenant ID ensures that writes to related events remain localized, further reducing lock contention. For example, all events for a single Order aggregate should be written to the same partition to guarantee sequential consistency.
Optimistic Concurrency with Batched Writes
Implementing OCC in a batched environment requires careful version management. Instead of checking the version before every single event, you can check the version at the start of the batch. If the version has changed since the command was received, the entire batch is invalidated and retried.
This approach assumes that within the time it takes to write the batch, the aggregate state is unlikely to change drastically. While this is a trade-off, it allows for much higher concurrency. For systems requiring strict linearizability, additional locking mechanisms or sequence numbers may be necessary, but for most business domains, the eventual consistency provided by batched OCC is acceptable.
Conclusion
Optimizing the write path in an Event Sourcing system is critical for achieving the scale that CQRS promises. By combining high-throughput batching with append-only storage strategies, developers can dramatically reduce latency and increase system throughput. The key lies in balancing batch sizes, managing concurrency conflicts gracefully, and choosing storage engines that excel at sequential writes. As your system grows, these optimizations will become the difference between a sluggish application and one that scales seamlessly under heavy load.