Distributed Matching Engine Architecture with Redis and Valkey

A distributed datastore does not make one order book safely multi-writer — it distributes the race. What scales is the matching cell: one ordered writer, one atomic state boundary, one outbox per partition, each replicated and operated on its own.

By Oleksii Vasylenko, Matching Engine Architect & Hands-on Technical Lead · · 15 min read

Where this comes from. Re-engineering Bitsten around deterministic market partitions, same-slot atomic state and outbox commits, permanent order identities, replicated AOF durability, and failure tests for broker redelivery and conductor takeover. The design is accepted for prototype; production rollout is gated on soak and failure testing.

Redis and Valkey give you fast data structures, atomic transactions, replication, persistence, Sentinel discovery, and cluster partitioning. Not one of those features decides which order trades first. If two conductors read and mutate the same book concurrently, moving the hashes into a cluster distributes the race rather than resolving it.

The scaling mistake is predictable: add a datastore cluster, run more engine replicas, expect throughput. The replicas then need a distributed lock or optimistic retry around every command, and failover can briefly produce two owners for one pair. You have bought infrastructure and sold correctness.

A matching cell is a simpler unit. A stable set of markets maps to one command queue, one active conductor, one primary/replica storage group, and one outbox. Add cells for independent traffic. The datastore is that cell’s atomic state and event boundary — never a substitute for execution semantics.

Producers compute the partition from the pair and publish to that partition’s durable queue. The active conductor consumes in order, reads candidate makers, stages book changes and events, commits them atomically, waits for the configured durability acknowledgement, and only then acknowledges the command to the broker. A cell-local outbox publishes committed events to settlement and market-data consumers under stable event identities.

pairId -> partition -> RabbitMQ quorum queue (single active consumer)
                              |
                          conductor            (one writer, in order)
                              |
             atomic same-hash-tag MULTI/EXEC
             state + book + dedup + outbox     (all or nothing)
                              |
                Valkey primary --AOF--> disk
                     | WAITAOF 1 1             (then, and only then, ACK)
                     v
                Valkey replica --AOF--> disk
                              |
                   partition outbox
                              |
             bounded publisher confirms
                              |
          at-least-once event + consumer inbox
The full path for one command, with the ordering guarantee at each hop.
The full command path through one matching cellPartition routing, a single-active-consumer quorum queue, one conductor, an atomic same-hash-tag transaction covering state and outbox, replicated AOF acknowledgement before the broker ack, then bounded publisher confirms.WAITAOF 1 1pairId → partitionRabbitMQ quorum queuesingle active consumerconductor — one writer, in orderatomic same-hash-tagMULTI/EXECstate + book + dedup + outboxValkey primary — AOF → diskValkey replica — AOF → diskonly now: ACK the commandpartition outboxbounded publisher confirmsat-least-once event+ consumer inbox
One command, end to end. The two highlighted boxes are the contract: everything commits together, and the broker is not acknowledged until durability is proven.

Partition count is topology metadata, not an ordinary environment variable. If producers use 16 while consumers use 32, the same pair reaches two owners. Choose enough logical partitions for future placement and keep the mapping stable. Several logical partitions can share a physical machine, and a hot one can later get a host of its own. Relocation is a migration: stop routing, drain the queue, snapshot or replay state, verify checksums, change ownership, resume, and watch sequence continuity.

  • One deterministic command route per pair.
  • One active writer per logical partition.
  • One same-slot atomic transaction for state, identity, and outbox.
  • One explicit durability policy, applied before the broker acknowledgement.
  • One relocation procedure that cannot produce overlapping ownership.

Valkey Cluster divides keys across 16,384 hash slots, and multi-key operations only work when every participating key lands in the same slot. Hash tags force that: only the substring inside braces is hashed. Putting the partition tag in every key of a partition keeps one command transaction local to one primary.

matching:{matching-p3}:order:<orderId>              # HASH  order record
matching:{matching-p3}:book:<pairId>:ask:rates     # ZSET  price index
matching:{matching-p3}:book:<pairId>:bid:rates     # ZSET  price index
matching:{matching-p3}:processed-command:<cmdId>   # STRING command dedup
matching:{matching-p3}:accepted-order:<orderId>    # STRING permanent marker
matching:{matching-p3}:events                      # STREAM outbox
matching:{matching-p3}:events:dead-letter          # STREAM quarantine
apps/conductor/src/orders.storage.ts — every key in one command carries the same {matching-pN} tag.

The transaction must include every fact whose separation would leave an impossible state. Closing an order removes it from both the order hash and the price index in the same commit. A partial fill updates the remaining quantity and appends its event together. The command dedup record and the accepted-order marker go in too — otherwise a crash between “I matched” and “I remembered that I matched” lets the same order match again.

The dual-write gap and the outbox that closes itPublishing after committing lets a crash lose the event entirely. Appending the event inside the state transaction makes delivery at-least-once instead, which a consumer inbox deduplicates.Append event inside the commitstate + event in ONE transactioncrashoutbox replays from the streamevent delivered, possibly twiceconsumer inbox dedupes byeventIdPublish after commit — the gapcommit state to Rediscrashevent never publishedtrade in the book,settlement never hears
You cannot get exactly-once here, so choose which failure you want. Losing the event is unrecoverable; delivering it twice is a problem a consumer inbox already solves.

Cross-partition transactions should be structurally impossible, not merely discouraged. Settlement across accounts is downstream work driven by the committed trade event; it is never a reason to join two books into one storage transaction.

Broker redelivery and client retries look similar and need different defences. A processed-command record handles the same command ID arriving twice after a crash or a lost acknowledgement: the conductor returns the prior result without opening a second state transition.

That does not cover the client that retries the same logical order under a freshly generated command ID. If the first attempt already filled and its live record was deleted, command idempotency has nothing left to recognise. So there is a second marker, keyed by order ID, checked before matching and committed with the first transition — and deliberately never deleted when the order closes.

const acceptedByCommand =
  await this.ordersStorage.getAcceptedOrderCommand(input.id, partition);

if (acceptedByCommand) {
  this.duplicateOrders += 1;            // surfaced as a metric, not a log line
  this.logger.warn('Skipping duplicate matching order identity', {
    orderId: input.id, pairId: input.pairId, commandId, acceptedByCommand,
  });
  return;
}
this.ordersStorage.markOrderAccepted(input.id, commandId, partition, transaction);
apps/conductor/src/conductor.service.ts — the check that makes a re-placed order a counted no-op instead of a second fill.
Two different duplicate problems needing two different defencesA repeated command identity is handled by a processed-command record. A client retry under a fresh command identity needs a permanent accepted-order marker that outlives the closed order.yesnoyesa repeated request arrivessame commandId?broker redeliveryor lost responseprocessed-command recordreturns the prior resultclient retried under aNEW command identityoriginal alreadyfilled and deleted?command dedup sees nothing→ would match twicepermanent accepted-ordermarkerkeyed by orderId, never removed
Command dedup and order-identity dedup look like the same feature and defend against different failures. The right branch is the one that only shows up after an order has already closed.

Two operational caveats come with this. Enabling it on an existing dataset requires backfilling a marker for every historical and live order — a partial backfill leaves old closed orders replayable. And the marker is deliberately partition-local, so the authoritative orders database still has to enforce globally unique order IDs; making the matching layer reject reuse across partitions would mean adding exactly the global coordination bottleneck the partitioning exists to avoid.

Outbox delivery remains at-least-once regardless. Every financial consumer needs a transactional inbox keyed by the stable event ID, committed in the same database transaction as the projection it drives.

Asynchronous replication can lose an acknowledged write if the primary dies before the replica receives it. The Valkey Cluster specification says this plainly: write safety is best-effort, and acknowledged writes can be lost in some partitions. A financial state service therefore has to define what “success” means rather than inheriting a default.

async ensureDurability(): Promise<void> {
  const { localAofFsyncs, replicaAofFsyncs, timeoutMs } = this.durability;
  if (localAofFsyncs === 0 && replicaAofFsyncs === 0) return;

  const result = await this.redis.call(
    'WAITAOF', localAofFsyncs, replicaAofFsyncs, timeoutMs,
  );

  if (!Array.isArray(result) ||
      Number(result[0]) < localAofFsyncs ||
      Number(result[1]) < replicaAofFsyncs) {
    throw new Error(
      `Matching durability acknowledgement failed: ${JSON.stringify(result)}`,
    );
  }
}
apps/conductor/src/orders.storage.ts — success means primary and replica AOF fsync, or it means failure.

This adds latency by design — the numbers below quantify how much. When the datastore cannot satisfy the contract, the service fails closed rather than quietly degrading to memory semantics. Durability timeouts, replica health, fsync latency, and broker acknowledgement age all become first-class service indicators, because they are now the difference between an acknowledged trade and a lost one.

Memory mode remains available and is a legitimate choice — but only as an explicit declaration that recovery comes from a separate durable log. The startup capability check enforces the distinction: a datastore without WAITAOF support cannot boot in replicated-AOF mode. A persistence checkbox with no tested recovery point objective behind it is not a durability design.

Sentinel and Cluster solve different problems. Sentinel discovers and promotes a primary inside one replicated group. Cluster shards a keyspace across many primaries and redirects clients by hash slot. Because the application already partitions markets, matching cells can start with Sentinel per partition — failure domains and multi-key transactions stay obvious. A larger deployment can place several partition tags in a real cluster, provided the client is genuinely cluster-aware and all transaction keys share a slot.

“Genuinely” is doing work in that sentence. We had a configuration that listed cluster nodes for a client library that constructs a standalone connection. The list was silently ignored. Everything worked in development, and the topology existed only in the config file. That now fails closed:

export function matchingRedisOptions(config: RedisConfig): RedisOptions {
  if (config.REDIS_CLUSTER_NODES) {
    throw new Error(
      'REDIS_CLUSTER_NODES requires a real ioredis Cluster client; ' +
      'use partitioned matching cells or wire ClusterModule explicitly',
    );
  }
  // ... Sentinel discovery when REDIS_SENTINEL_HOSTS and _NAME are both set
}
libs/shared-lib/src/matching/config.ts — refusing to boot beats pretending to be clustered.

Capability checks should prove the instantiated client mode, the Sentinel master name, persistence command support, and the assigned partition — at boot, in the process, against the real connection. A conductor also rejects any command whose declared partition is not its own. Failover testing then means stopping a primary, watching promotion, verifying the durability boundary held, and comparing book and event state before letting traffic back in.

Publishing to RabbitMQ after committing to Redis creates a dual-write gap: the engine can crash after the state changed but before the event was sent, and the trade exists in the book but never reaches settlement. Appending the event to a Redis stream inside the state transaction closes that gap. A separate worker reads the stream in order and publishes with confirms.

The remaining gap is smaller and honest: a crash after a broker confirm but before acknowledging the stream entry republishes the event. Stable event IDs make that detectable, which is why every consumer needs an inbox. We do not claim exactly-once delivery across Redis and RabbitMQ, because it is not achievable and claiming it just means the duplicate handling lives somewhere nobody has looked.

Sequential publish-and-confirm preserves order but caps a worker at a few hundred events per second — which is precisely what our measurements showed. A bounded confirm window fixes the throughput without losing the ordering:

// Publish without awaiting each result: a bounded window of confirms
// stays open, and RabbitMQ receives the calls on one channel in stream order.
const confirmations = deliveries.map(({ entry, event }) =>
  publishEvent(rabbit, entry, event),
);
const confirmed = await Promise.allSettled(confirmations);

if (confirmed.some((r) => r.status === 'rejected' || !r.value)) {
  throw new Error('RabbitMQ did not confirm publication batch');
}

// Only now advance the stream, as one transaction.
const transaction = dataClient.multi();
transaction.xack(streamKey, consumerGroup, ...streamIds);
transaction.xdel(streamKey, ...streamIds);
transaction.hdel(attemptsKey, ...eventIds);
apps/conductor-outbox/src/outbox-delivery.ts — one channel, stream order, all-or-nothing batch acknowledgement.

Note the allSettled rather than all: if any confirm fails you still wait for every outstanding promise to settle before retrying or closing the channel, or you leave callbacks firing against a channel you already tore down. And watch queue age and the oldest unacknowledged stream entry rather than raw backlog count — the count tells you how much is waiting, the age tells you how long your financial projections have been wrong.

The synthetic workload writes a hash, a sorted-set member, and a stream event in one transaction — the conductor’s access pattern in miniature. We ran it against one cell and two cells to test the horizontal scaling claim.

TopologyDurabilityTransactions/svs one cell
Dragonfly, 1 cellmemory + replica25,621
Dragonfly, 2 cellsmemory + replicas22,986−10%
Valkey, 1 cellAOF always + WAITAOF 1 13,254
Valkey, 2 cellsAOF always + WAITAOF 1 12,956−9%

Both cells shared one Docker Desktop CPU and disk budget. Adding a second cell added processes, not capacity.

This disconfirms the naive version of our own architecture claim, which is exactly why it is worth publishing. Cells scale when traffic is independent and resources are isolated. A second cell on the same constrained host is two processes fighting over one CPU scheduler, one memory bus, and one disk.

It also puts the durability cost in perspective: replicated AOF acknowledgement is roughly an 8x throughput reduction against memory mode on the same hardware. That is the price of the recovery point objective, stated honestly rather than buried.

The next meaningful test places cells on separate hosts and requires at least 1.5x aggregate throughput going from one cell to two, with zero invariant violations under sustained load and during failover. Until that passes, the multi-cell design is a hypothesis with a plausible mechanism, not a demonstrated result.

Observe every partition independently, and keep the partition dimension on every metric. A healthy aggregate hides one stalled market perfectly. Track command age, processing percentiles, wrong-partition rejects, duplicate commands, duplicate order identities, event creation rate, datastore latency, AOF acknowledgement latency, replication offset, outbox age, confirm failures, and consumer inbox duplicates.

The engine also exposes an on-demand consistency check, because the invariant “every indexed order exists and matches its index” is cheap to verify and expensive to violate silently:

{
  "pairId": 12, "partition": 3,
  "indexedOrders": 8412, "scannedOrders": 8412,
  "truncated": false, "healthy": false,
  "issues": [
    "ask:7f3a…:missing-order",   // in the index, no order record
    "bid:91c2…:index-mismatch"   // record disagrees with its index
  ]
}
GET /reconcile?pairId=<id> — scans both book indexes against the order records. A truncated result is not a healthy result.

Capacity planning follows the busiest symbol, not total traffic divided by worker count. Reserve headroom for bursts, snapshot work, failover replay, and a temporarily absent replica. Put a hot partition on dedicated resources long before you consider changing its execution model — and if one market genuinely reaches its single-writer ceiling after data-layout and round-trip work, that is a market-policy or hardware decision to make explicitly, not a threading change to sneak in.

Distributed Redis is most valuable when it keeps independent state independent. It is least useful when it is used to disguise shared ownership.

TopologyStrengthRiskUse when
In-memory + journalLowest hot-path latencyCustom replay and snapshot operationsThe engine log is authoritative
Valkey + Sentinel cellSimple atomic partition and failoverOne primary’s capacity per cellThe application owns market partitioning
Valkey ClusterMany primaries, client-side routingHash-slot and resharding complexityLarge fixed partition sets share infrastructure
Dragonfly memory cellHigh Redis-compatible throughputNo WAITAOF; not the replicated-AOF contractState is rebuildable from another durable log
Database-centricFamiliar, strong durabilityLocks and tail latency under churnModerate volume, auditability dominates

Choose authoritative storage only after you have defined when a command may be acknowledged and how the book is reconstructed after failure. Those two answers eliminate most of the options.

The pillar guide explains the matching policy, order lifecycle, technology choices, double-match prevention, recovery, and production evidence behind these distributed cells.

Read the complete matching engine architecture guide

Planning a distributed matching-engine rollout?

I can turn the current order path into explicit ownership, durability, idempotency, outbox, and recovery contracts, then work hands-on with the team through the implementation. Market count, traffic distribution, and required recovery point are enough to start with the real constraints.

Discuss a matching-engine rollout